Skip to content

Commit 89e12d1

Browse files
committed
Replace Docker CLI with Java Docker API Client
Authored-by: Leonhardt Koepsell <[email protected]>
1 parent e5b786a commit 89e12d1

File tree

13 files changed

+194
-68
lines changed

13 files changed

+194
-68
lines changed

.idea/codeStyles/Project.xml

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.idea/codeStyles/codeStyleConfig.xml

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
## [Unreleased]
1313

14+
### Fixed
15+
16+
- Remove the unreliable dependency on Docker CLI
17+
1418
### Chore
1519

1620
- Upgrade project dependencies

build.gradle.kts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,12 @@ import com.vanniktech.maven.publish.SonatypeHost
22
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
33
import org.gradle.api.tasks.testing.logging.TestLogEvent
44
import org.gradle.kotlin.dsl.support.serviceOf
5-
import org.gradle.kotlin.dsl.kotlin
6-
import org.gradle.kotlin.dsl.withType
75
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
86
import org.jetbrains.kotlin.gradle.plugin.extraProperties
97
import java.io.ByteArrayOutputStream
108
import java.io.StringReader
119
import java.nio.charset.StandardCharsets
12-
import java.util.Properties
10+
import java.util.*
1311

1412
group = "dev.codebandits"
1513

@@ -121,6 +119,9 @@ sourceSets {
121119
}
122120

123121
dependencies {
122+
implementation(libs.docker.java.core)
123+
implementation(libs.docker.java.transport.httpclient5)
124+
124125
add(sourceSets["testShared"].apiConfigurationName, libs.junit.jupiter.api)
125126
testImplementation(sourceSets["testShared"].output)
126127
}

gradle/libs.versions.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,16 @@ logback = "1.5.18" # https://github.com/qos-ch/logback/tags
77
slf4j = "2.0.17" # https://github.com/qos-ch/slf4j/tags
88
gradleMavenPublish = "0.31.0" # https://github.com/vanniktech/gradle-maven-publish-plugin/releases
99
testRetryGradlePlugin = "1.6.2" # https://github.com/gradle/test-retry-gradle-plugin/releases
10+
dockerJava = "3.5.0" # https://github.com/docker-java/docker-java/releases
1011

1112
[libraries]
1213
strikt-core = { module = "io.strikt:strikt-core", version.ref = "strikt" }
1314
testcontainers-testcontainers = { module = "org.testcontainers:testcontainers", version.ref = "testcontainers" }
1415
logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
1516
slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
1617
junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit-jupiter" }
18+
docker-java-core = { module = "com.github.docker-java:docker-java-core", version.ref = "dockerJava" }
19+
docker-java-transport-httpclient5 = { module = "com.github.docker-java:docker-java-transport-httpclient5", version.ref = "dockerJava" }
1720

1821
[bundles]
1922
logging-implementation = ["slf4j-api"]
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package dev.codebandits.container.gradle.docker
2+
3+
import com.github.dockerjava.api.DockerClient
4+
import com.github.dockerjava.core.DefaultDockerClientConfig
5+
import com.github.dockerjava.core.DockerClientImpl
6+
import com.github.dockerjava.httpclient5.ApacheDockerHttpClient
7+
import com.github.dockerjava.transport.DockerHttpClient
8+
9+
private fun createDockerClientConfig(dockerHost: String? = null): DefaultDockerClientConfig {
10+
var builder = DefaultDockerClientConfig.createDefaultConfigBuilder()
11+
if (dockerHost != null) {
12+
builder = builder.withDockerHost(dockerHost)
13+
}
14+
return builder.build()
15+
}
16+
17+
internal fun createDockerHttpClient(config: DefaultDockerClientConfig = createDockerClientConfig()): DockerHttpClient {
18+
return ApacheDockerHttpClient.Builder().dockerHost(config.dockerHost).build()
19+
}
20+
21+
internal fun createDockerClient(dockerHost: String? = null): DockerClient {
22+
val config = createDockerClientConfig(dockerHost)
23+
val httpClient = createDockerHttpClient(config)
24+
return DockerClientImpl.getInstance(config, httpClient)
25+
}

src/main/kotlin/dev/codebandits/container/gradle/tasks/ContainerExecTask.kt

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,10 @@ import org.gradle.api.tasks.TaskAction
66

77
public abstract class ContainerExecTask : DefaultTask() {
88
@Internal
9-
protected val actionSteps: MutableList<ExecutionStep> = mutableListOf<ExecutionStep>()
9+
protected val steps: MutableList<ExecutionStep> = mutableListOf()
1010

1111
@TaskAction
1212
public fun run() {
13-
actionSteps.forEach { action ->
14-
project.runExecutionStep(action)
15-
}
13+
steps.forEach(::run)
1614
}
1715
}

src/main/kotlin/dev/codebandits/container/gradle/tasks/ContainerRunTask.kt

Lines changed: 54 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
package dev.codebandits.container.gradle.tasks
22

3+
import com.github.dockerjava.api.async.ResultCallback
4+
import com.github.dockerjava.api.command.WaitContainerResultCallback
5+
import com.github.dockerjava.api.model.Bind
6+
import com.github.dockerjava.api.model.Frame
7+
import com.github.dockerjava.api.model.HostConfig
8+
import com.github.dockerjava.api.model.StreamType
9+
import dev.codebandits.container.gradle.docker.createDockerClient
10+
import org.gradle.api.GradleException
311
import org.gradle.api.model.ObjectFactory
412
import org.gradle.api.provider.Property
513

@@ -18,39 +26,55 @@ public abstract class ContainerRunTask : ContainerExecTask() {
1826
}
1927

2028
public fun dockerRun(configure: DockerRunSpec.() -> Unit) {
21-
actionSteps.add(
29+
steps.add(
2230
ExecutionStep(
23-
execAction = {
31+
action = {
2432
val spec = DockerRunSpec(project.objects).apply(configure)
25-
val image = spec.image.get()
26-
val options = mutableListOf<String>()
27-
val user = spec.user.orNull
28-
if (user != null) {
29-
options.addAll(listOf("--user", user))
30-
}
31-
if (spec.privileged.get()) {
32-
options.add("--privileged")
33-
}
34-
if (spec.autoRemove.get()) {
35-
options.add("--rm")
36-
}
37-
spec.volumes.get().forEach { volume ->
38-
options.addAll(listOf("--volume", volume))
39-
}
40-
val entrypoint = spec.entrypoint.orNull
41-
if (entrypoint != null) {
42-
options.addAll(listOf("--entrypoint", entrypoint))
43-
}
44-
val workdir = spec.workdir.orNull
45-
if (workdir != null) {
46-
options.addAll(listOf("--workdir", workdir))
47-
}
48-
val dockerArgs = arrayOf("run", *options.toTypedArray(), image, *spec.args.get())
49-
executable = "docker"
50-
args(*dockerArgs)
5133
val dockerHost = spec.dockerHost.orNull
52-
if (dockerHost != null) {
53-
environment("DOCKER_HOST", dockerHost)
34+
val dockerClient = createDockerClient(dockerHost)
35+
36+
val hostConfig = HostConfig.newHostConfig()
37+
.withAutoRemove(spec.autoRemove.get())
38+
.withPrivileged(spec.privileged.get())
39+
.withBinds(spec.volumes.get().map(Bind::parse))
40+
41+
val createContainerCmd = dockerClient.createContainerCmd(spec.image.get())
42+
.withHostConfig(hostConfig)
43+
.withCmd(*spec.args.get())
44+
.let { cmd -> spec.user.orNull?.let { user -> cmd.withUser(user) } ?: cmd }
45+
.let { cmd -> spec.entrypoint.orNull?.let { user -> cmd.withEntrypoint(user) } ?: cmd }
46+
.let { cmd -> spec.workdir.orNull?.let { user -> cmd.withWorkingDir(user) } ?: cmd }
47+
48+
val container = createContainerCmd.exec()
49+
50+
dockerClient.startContainerCmd(container.id).exec()
51+
52+
val containerLogCallback = dockerClient
53+
.logContainerCmd(container.id)
54+
.withStdOut(true)
55+
.withStdErr(true)
56+
.withSince(0)
57+
.withFollowStream(true)
58+
.exec(object : ResultCallback.Adapter<Frame>() {
59+
override fun onNext(frame: Frame) {
60+
when (frame.streamType) {
61+
StreamType.STDOUT -> System.out.write(frame.payload)
62+
StreamType.STDERR -> System.err.write(frame.payload)
63+
else -> {}
64+
}
65+
}
66+
})
67+
68+
val containerStatusCodeCallback = dockerClient
69+
.waitContainerCmd(container.id)
70+
.exec(WaitContainerResultCallback())
71+
72+
containerLogCallback.awaitCompletion()
73+
74+
val statusCode = containerStatusCodeCallback.awaitStatusCode()
75+
76+
if (statusCode != 0) {
77+
throw GradleException("Container exited with status code $statusCode")
5478
}
5579
},
5680
)

src/main/kotlin/dev/codebandits/container/gradle/tasks/TaskImages.kt

Lines changed: 62 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
package dev.codebandits.container.gradle.tasks
22

3+
import com.github.dockerjava.transport.DockerHttpClient
4+
import dev.codebandits.container.gradle.docker.createDockerClient
5+
import dev.codebandits.container.gradle.docker.createDockerHttpClient
6+
import org.gradle.api.GradleException
37
import org.gradle.api.Task
48
import org.gradle.api.file.RegularFile
59
import org.gradle.api.provider.Provider
6-
import java.io.OutputStream
710
import java.net.URLEncoder
811
import java.nio.charset.StandardCharsets
912

@@ -21,14 +24,26 @@ public abstract class TaskImages(private val task: Task) {
2124
val fileName = getImageReferenceFileName(imageReference)
2225
val imageIdentifierFileProvider = task.project.layout.buildDirectory.file("images/docker/local/$fileName")
2326
val updateStep = ExecutionStep(
24-
execAction = {
27+
action = {
2528
val file = imageIdentifierFileProvider.get().asFile
26-
file.parentFile.mkdirs()
27-
executable = "docker"
28-
args("inspect", "--format", "{{.Id}}", imageReference)
29-
standardOutput = imageIdentifierFileProvider.get().asFile.outputStream()
30-
errorOutput = OutputStream.nullOutputStream()
31-
isIgnoreExitValue = true
29+
val dockerClient = createDockerClient()
30+
try {
31+
dockerClient.pingCmd().exec()
32+
val inspectImageResponse = dockerClient.inspectImageCmd(imageReference).exec()
33+
val imageId = inspectImageResponse.id
34+
file.parentFile.mkdirs()
35+
if (imageId != null) {
36+
file.writeText(imageId)
37+
} else {
38+
file.delete()
39+
}
40+
} catch (exception: Exception) {
41+
if (exception.javaClass.name == "com.github.dockerjava.api.exception.NotFoundException") {
42+
file.delete()
43+
} else {
44+
throw exception
45+
}
46+
}
3247
},
3348
resultHandler = { result ->
3449
if (result.exitValue != 0) {
@@ -40,7 +55,7 @@ public abstract class TaskImages(private val task: Task) {
4055

4156
return ImageIdentifierFileConfig(
4257
fileProvider = imageIdentifierFileProvider.map { regularFile ->
43-
task.project.runExecutionStep(step = updateStep)
58+
task.run(updateStep)
4459
regularFile
4560
},
4661
updateStep = updateStep,
@@ -54,13 +69,42 @@ public abstract class TaskImages(private val task: Task) {
5469
val fileName = getImageReferenceFileName(imageReference)
5570
val imageIdentifierFileProvider = task.project.layout.buildDirectory.file("images/docker/registry/$fileName")
5671
val updateStep = ExecutionStep(
57-
execAction = {
72+
action = {
5873
val file = imageIdentifierFileProvider.get().asFile
59-
file.parentFile.mkdirs()
60-
executable = "docker"
61-
args("manifest", "inspect", imageReference)
62-
standardOutput = file.outputStream()
63-
isIgnoreExitValue = true
74+
val dockerHttpClient = createDockerHttpClient()
75+
76+
val (repo, tag) = imageReference
77+
.split(":", limit = 2)
78+
.let { it[0] to it.getOrElse(1) { "latest" } }
79+
val path = "/v2/$repo/manifests/$tag"
80+
81+
val request = DockerHttpClient.Request.builder()
82+
.method(DockerHttpClient.Request.Method.GET)
83+
.path(path)
84+
.putHeader("Accept", "application/vnd.docker.distribution.manifest.v2+json")
85+
.build()
86+
87+
dockerHttpClient.execute(request).use { response ->
88+
when (response.statusCode) {
89+
200 -> {
90+
val digest = response.headers["Docker-Content-Digest"]?.firstOrNull()
91+
if (digest != null) {
92+
file.parentFile.mkdirs()
93+
file.writeText(digest)
94+
} else {
95+
file.delete()
96+
}
97+
}
98+
99+
404 -> {
100+
file.delete()
101+
}
102+
103+
else -> throw GradleException(
104+
"Failed to fetch manifest for $imageReference: HTTP ${response.statusCode}"
105+
)
106+
}
107+
}
64108
},
65109
resultHandler = { result ->
66110
if (result.exitValue != 0) {
@@ -73,7 +117,7 @@ public abstract class TaskImages(private val task: Task) {
73117

74118
return ImageIdentifierFileConfig(
75119
fileProvider = imageIdentifierFileProvider.map { regularFile ->
76-
task.project.runExecutionStep(step = updateStep)
120+
task.run(updateStep)
77121
regularFile
78122
},
79123
updateStep = updateStep,
@@ -103,7 +147,7 @@ public abstract class TaskImages(private val task: Task) {
103147
imageReference = imageReference,
104148
)
105149
task.outputs.file(config.fileProvider)
106-
task.doLast { task -> task.project.runExecutionStep(config.updateStep) }
150+
task.doLast { task -> task.run(config.updateStep) }
107151
}
108152

109153
public fun dockerRegistry(imageReference: String, autoRefresh: Boolean = false) {
@@ -112,7 +156,7 @@ public abstract class TaskImages(private val task: Task) {
112156
autoRefresh = autoRefresh,
113157
)
114158
task.outputs.file(config.fileProvider)
115-
task.doLast { task -> task.project.runExecutionStep(config.updateStep) }
159+
task.doLast { task -> task.run(config.updateStep) }
116160
}
117161
}
118162
}
Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,17 @@
11
package dev.codebandits.container.gradle.tasks
22

3-
import org.gradle.api.Project
4-
import org.gradle.internal.extensions.core.serviceOf
5-
import org.gradle.process.ExecOperations
3+
import org.gradle.api.Action
4+
import org.gradle.api.Task
65
import org.gradle.process.ExecResult
7-
import org.gradle.process.ExecSpec
86

97
public class ExecutionStep(
10-
public val execAction: ExecSpec.() -> Unit,
8+
public val action: Action<Task>,
119
public val resultHandler: ((ExecResult) -> Unit)? = null,
1210
public val shouldRun: () -> Boolean = { true },
1311
)
1412

15-
internal fun Project.runExecutionStep(step: ExecutionStep) {
13+
internal fun Task.run(step: ExecutionStep) {
1614
if (step.shouldRun()) {
17-
val result = serviceOf<ExecOperations>().exec(step.execAction)
18-
step.resultHandler?.invoke(result)
15+
apply(step.action::execute)
1916
}
2017
}

0 commit comments

Comments
 (0)