Skip to content

Commit 3bfc704

Browse files
committed
Fix and test registry image digest fetching
Authored-by: Leonhardt Koepsell <[email protected]>
1 parent f6dac7a commit 3bfc704

File tree

11 files changed

+350
-127
lines changed

11 files changed

+350
-127
lines changed

CHANGELOG.md

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

1212
## [Unreleased]
1313

14-
### Changed
14+
### Chore
15+
16+
- Rename ContainerRunTask to ContainerTask.
17+
18+
### Fixed
1519

16-
- Rename ContainerRunTask to ContainerTask
20+
- Registry image references now correctly fetch their digest from the remote registry.
1721

1822
## [0.2.0] - 2025-04-22
1923

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
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+
8+
internal object Docker {
9+
internal fun createClient(dockerHost: String? = null): DockerClient {
10+
val config = DefaultDockerClientConfig.createDefaultConfigBuilder()
11+
.let { it -> if (dockerHost == null) it else it.withDockerHost(dockerHost) }
12+
.build()
13+
val httpClient = ApacheDockerHttpClient.Builder().dockerHost(config.dockerHost).build()
14+
return DockerClientImpl.getInstance(config, httpClient)
15+
}
16+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package dev.codebandits.container.gradle.docker
2+
3+
import dev.codebandits.container.gradle.tasks.ImageReferenceParts
4+
import dev.codebandits.container.gradle.tasks.toImageReferenceParts
5+
import org.gradle.api.GradleException
6+
import java.net.URI
7+
import java.net.http.HttpClient
8+
import java.net.http.HttpRequest
9+
import java.net.http.HttpResponse
10+
11+
internal object Registry {
12+
private val httpClient = HttpClient.newBuilder().build()
13+
14+
internal fun getDigest(imageReference: String): String {
15+
val imageReferenceParts = imageReference.toImageReferenceParts()
16+
val httpResponse = run {
17+
val httpRequest = buildRegistryManifestRequest(imageReferenceParts)
18+
httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString())
19+
}
20+
21+
return when (httpResponse.statusCode()) {
22+
200 -> httpResponse.headers().firstValue("Docker-Content-Digest")
23+
.orElseThrow { GradleException("Expected Docker-Content-Digest header from registry: ${httpResponse.headers()}") }
24+
25+
else -> throw GradleException("Failed to fetch manifest for $imageReference: HTTP ${httpResponse.statusCode()}")
26+
}
27+
}
28+
29+
private fun getPullToken(registry: String, repository: String): String {
30+
return fetchToken(
31+
registry = registry,
32+
scope = "repository:${repository}:pull"
33+
)
34+
}
35+
36+
private fun getHost(registry: String): String = when (registry) {
37+
"docker.io" -> "registry-1.docker.io"
38+
else -> registry
39+
}
40+
41+
private fun buildRegistryManifestRequest(parts: ImageReferenceParts): HttpRequest {
42+
val registryHost = getHost(parts.registry)
43+
val registryToken = getPullToken(parts.registry, parts.repository)
44+
val path = "/v2/${parts.namespace}/${parts.image}/manifests/${parts.tag}"
45+
val manifestUri = URI.create("https://$registryHost$path")
46+
47+
return HttpRequest.newBuilder()
48+
.uri(manifestUri)
49+
.header("Accept", listOf(
50+
"application/vnd.oci.image.index.v1+json",
51+
"application/vnd.docker.distribution.manifest.v2+json",
52+
"application/vnd.docker.distribution.manifest.list.v2+json",
53+
).joinToString(", "))
54+
.header("Authorization", "Bearer $registryToken")
55+
.GET()
56+
.build()
57+
}
58+
59+
private fun fetchToken(registry: String, scope: String): String {
60+
val initialRequest = HttpRequest.newBuilder()
61+
.uri(URI.create("https://${getHost(registry)}/v2/"))
62+
.GET()
63+
.build()
64+
65+
val httpResponse = httpClient.send(initialRequest, HttpResponse.BodyHandlers.discarding())
66+
67+
if (httpResponse.statusCode() != 401) {
68+
throw GradleException("Expected 401 response from registry: HTTP ${httpResponse.statusCode()}.")
69+
}
70+
71+
val authHeader = httpResponse.headers().firstValue("www-authenticate")
72+
.orElseThrow { GradleException("Expected www-authenticate header from registry: ${httpResponse.headers()}") }
73+
74+
val tokenRealm = Regex("""realm="([^"]+)"""").find(authHeader)?.groupValues?.get(1)
75+
?: throw GradleException("Missing realm in www-authenticate header")
76+
val tokenService = Regex("""service="([^"]+)"""").find(authHeader)?.groupValues?.get(1)
77+
?: throw GradleException("Missing service in www-authenticate header")
78+
val tokenUri = URI.create("$tokenRealm?service=$tokenService&scope=$scope")
79+
80+
val tokenRequest = HttpRequest.newBuilder()
81+
.uri(tokenUri)
82+
.GET()
83+
.build()
84+
85+
val tokenResponse = httpClient.send(tokenRequest, HttpResponse.BodyHandlers.ofString())
86+
87+
if (tokenResponse.statusCode() != 200) {
88+
throw GradleException("Failed to fetch token from $tokenUri: HTTP ${tokenResponse.statusCode()}")
89+
}
90+
91+
return Regex("\"token\":\\s*\"([^\"]+)\"")
92+
.find(tokenResponse.body())
93+
?.groupValues?.get(1)
94+
?: throw RuntimeException("Could not parse token from token response")
95+
}
96+
}

src/main/kotlin/dev/codebandits/container/gradle/docker/client.kt

Lines changed: 0 additions & 25 deletions
This file was deleted.

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import com.github.dockerjava.api.model.Bind
77
import com.github.dockerjava.api.model.Frame
88
import com.github.dockerjava.api.model.HostConfig
99
import com.github.dockerjava.api.model.StreamType
10-
import dev.codebandits.container.gradle.docker.createDockerClient
10+
import dev.codebandits.container.gradle.docker.Docker
1111
import org.gradle.api.DefaultTask
1212
import org.gradle.api.GradleException
1313
import org.gradle.api.model.ObjectFactory
@@ -57,7 +57,7 @@ public abstract class ContainerTask : DefaultTask() {
5757
action = {
5858
val spec = DockerPullSpec(project.objects).apply(configure)
5959
val dockerHost = spec.dockerHost.orNull
60-
val dockerClient = createDockerClient(dockerHost)
60+
val dockerClient = Docker.createClient(dockerHost)
6161

6262
dockerClient
6363
.pullImageCmd(spec.image.get())
@@ -74,7 +74,7 @@ public abstract class ContainerTask : DefaultTask() {
7474
action = {
7575
val spec = DockerRemoveSpec(project.objects).apply(configure)
7676
val dockerHost = spec.dockerHost.orNull
77-
val dockerClient = createDockerClient(dockerHost)
77+
val dockerClient = Docker.createClient(dockerHost)
7878

7979
dockerClient
8080
.removeImageCmd(spec.image.get())
@@ -90,7 +90,7 @@ public abstract class ContainerTask : DefaultTask() {
9090
action = {
9191
val spec = DockerRunSpec(project.objects).apply(configure)
9292
val dockerHost = spec.dockerHost.orNull
93-
val dockerClient = createDockerClient(dockerHost)
93+
val dockerClient = Docker.createClient(dockerHost)
9494

9595
val hostConfig = HostConfig.newHostConfig()
9696
.withAutoRemove(spec.autoRemove.get())
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package dev.codebandits.container.gradle.tasks
2+
3+
internal data class ImageReferenceParts(
4+
val registry: String,
5+
val namespace: String,
6+
val image: String,
7+
val tag: String
8+
) {
9+
val repository: String = "$namespace/$image"
10+
val normalized: String = "$registry/$namespace/$image:$tag"
11+
}
12+
13+
internal fun String.toImageReferenceParts(): ImageReferenceParts {
14+
val stringValue = this
15+
val defaultRegistry = "docker.io"
16+
val defaultNamespace = "library"
17+
val defaultTag = "latest"
18+
val registry: String
19+
val namespace: String
20+
val imageAndTag: String
21+
22+
if (stringValue.contains("/")) {
23+
val parts = stringValue.split("/", limit = 2)
24+
if (parts[0].contains(".")) {
25+
registry = parts[0]
26+
val namespaceAndImage = parts[1].split("/", limit = 2)
27+
if (namespaceAndImage.size == 2) {
28+
namespace = namespaceAndImage[0]
29+
imageAndTag = namespaceAndImage[1]
30+
} else {
31+
namespace = defaultNamespace
32+
imageAndTag = namespaceAndImage[0]
33+
}
34+
} else {
35+
registry = defaultRegistry
36+
namespace = parts[0]
37+
imageAndTag = parts[1]
38+
}
39+
} else {
40+
registry = defaultRegistry
41+
namespace = defaultNamespace
42+
imageAndTag = stringValue
43+
}
44+
45+
val imageParts = imageAndTag.split(":")
46+
val repository = imageParts[0]
47+
val tag = if (imageParts.size > 1) imageParts[1] else defaultTag
48+
49+
return ImageReferenceParts(
50+
registry = registry,
51+
namespace = namespace,
52+
image = repository,
53+
tag = tag
54+
)
55+
}

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

Lines changed: 8 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,22 @@
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
3+
import dev.codebandits.container.gradle.docker.Docker
4+
import dev.codebandits.container.gradle.docker.Registry
75
import org.gradle.api.Task
86
import org.gradle.api.file.RegularFile
97
import org.gradle.api.provider.Provider
108
import java.net.URLEncoder
119
import java.nio.charset.StandardCharsets
1210

11+
1312
public abstract class TaskImages(private val task: Task) {
1413
protected class ImageIdentifierFileConfig(
1514
public val fileProvider: Provider<RegularFile?>,
1615
public val updateStep: ExecutionStep,
1716
)
1817

1918
private fun getImageReferenceFileName(imageReference: String): String {
20-
return URLEncoder.encode(normalizeDockerImageReference(imageReference), StandardCharsets.UTF_8)
19+
return URLEncoder.encode(imageReference.toImageReferenceParts().normalized, StandardCharsets.UTF_8)
2120
}
2221

2322
protected fun getDockerLocalImageIdentifierFileConfig(imageReference: String): ImageIdentifierFileConfig {
@@ -26,7 +25,7 @@ public abstract class TaskImages(private val task: Task) {
2625
val updateStep = ExecutionStep(
2726
action = {
2827
val file = imageIdentifierFileProvider.get().asFile
29-
val dockerClient = createDockerClient()
28+
val dockerClient = Docker.createClient()
3029
try {
3130
dockerClient.pingCmd().exec()
3231
val inspectImageResponse = dockerClient.inspectImageCmd(imageReference).exec()
@@ -71,40 +70,9 @@ public abstract class TaskImages(private val task: Task) {
7170
val updateStep = ExecutionStep(
7271
action = {
7372
val file = imageIdentifierFileProvider.get().asFile
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-
}
73+
val digest = Registry.getDigest(imageReference)
74+
file.parentFile.mkdirs()
75+
file.writeText(digest)
10876
},
10977
resultHandler = { result ->
11078
if (result.exitValue != 0) {

src/main/kotlin/dev/codebandits/container/gradle/tasks/image-reference.kt

Lines changed: 0 additions & 28 deletions
This file was deleted.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package dev.codebandits.container.gradle.tasks
2+
3+
import org.junit.jupiter.api.Test
4+
import strikt.api.expectThat
5+
import strikt.assertions.isEqualTo
6+
7+
class ImageReferencePartsTest {
8+
9+
@Test
10+
fun `normalize short image name`() {
11+
expectThat("alpine".toImageReferenceParts()).and {
12+
get { normalized }.isEqualTo("docker.io/library/alpine:latest")
13+
}
14+
}
15+
16+
@Test
17+
fun `normalize image with explicit tag`() {
18+
expectThat("alpine:latest".toImageReferenceParts()).and {
19+
get { normalized }.isEqualTo("docker.io/library/alpine:latest")
20+
}
21+
}
22+
23+
@Test
24+
fun `normalize fully qualified image reference`() {
25+
expectThat("docker.io/library/alpine:latest".toImageReferenceParts()).and {
26+
get { normalized }.isEqualTo("docker.io/library/alpine:latest")
27+
}
28+
}
29+
}

0 commit comments

Comments
 (0)