Skip to content

Commit 3bcf014

Browse files
committed
[PROD-13446] Add a way to get a docker image label from a registry
1 parent 3280d77 commit 3bcf014

File tree

3 files changed

+170
-3
lines changed

3 files changed

+170
-3
lines changed

build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,7 @@ dependencies {
220220
implementation("org.springframework.boot:spring-boot-starter-actuator")
221221
implementation("io.micrometer:micrometer-registry-prometheus")
222222
implementation("org.springframework.boot:spring-boot-starter-aop")
223+
implementation("org.apache.httpcomponents.client5:httpclient5")
223224

224225
implementation("org.apache.tika:tika-core:${tikaVersion}")
225226
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinCoroutinesCoreVersion")

src/main/kotlin/com/cosmotech/api/containerregistry/ContainerRegistryService.kt

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,43 @@ package com.cosmotech.api.containerregistry
55
import com.cosmotech.api.config.CsmPlatformProperties
66
import com.cosmotech.api.exceptions.CsmClientException
77
import com.cosmotech.api.exceptions.CsmResourceNotFoundException
8+
import java.net.URI
89
import java.util.Base64
10+
import org.apache.hc.client5.http.impl.classic.HttpClientBuilder
911
import org.json.JSONArray
1012
import org.json.JSONObject
13+
import org.slf4j.LoggerFactory
1114
import org.springframework.http.HttpHeaders
15+
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory
1216
import org.springframework.stereotype.Service
1317
import org.springframework.web.client.RestClient
1418
import org.springframework.web.client.RestClientException
1519

1620
@Service("csmContainerRegistry")
1721
class ContainerRegistryService(private val csmPlatformProperties: CsmPlatformProperties) {
1822

19-
private val restClient =
23+
private val logger = LoggerFactory.getLogger(this::class.java)
24+
25+
private val baseUrl =
26+
"${csmPlatformProperties.containerRegistry.scheme}://${csmPlatformProperties.containerRegistry.host}"
27+
28+
private val restClient = RestClient.builder().baseUrl(baseUrl).build()
29+
30+
// Getting a blob from ACR using the default http client does not work because the client forwards
31+
// the Authorization header to the redirect query:
32+
// - the /v2/{repo}/blobs/{digest} endpoint is implemented by ACR with a 307 Temporary Redirect to
33+
// a blob storage
34+
// - the 'Location' header in the respons contains the necessary authentication string
35+
// - BUT the http client, by default, also forwards the initial ACR 'Authorization' header, which
36+
// the Azure Blob Storage rejects (rightfully) because this is an ACR auth
37+
// So for this specific query we use client with redirection disabled and make the follow up query
38+
// manually without the 'Authorization' header if we need to.
39+
private val noRedirectClient =
2040
RestClient.builder()
21-
.baseUrl(
22-
"${csmPlatformProperties.containerRegistry.scheme}://${csmPlatformProperties.containerRegistry.host}")
41+
.baseUrl(baseUrl)
42+
.requestFactory(
43+
HttpComponentsClientHttpRequestFactory(
44+
HttpClientBuilder.create().disableRedirectHandling().build()))
2345
.build()
2446

2547
private fun getHeaderAuthorization(): String {
@@ -48,4 +70,46 @@ class ContainerRegistryService(private val csmPlatformProperties: CsmPlatformPro
4870
"The repository $repository:$tag check has thrown error : " + e.message, e)
4971
}
5072
}
73+
74+
fun getImageLabel(repository: String, tag: String, label: String): String? {
75+
try {
76+
val manifest =
77+
restClient
78+
.get()
79+
.uri("/v2/$repository/manifests/$tag")
80+
.header(HttpHeaders.AUTHORIZATION, getHeaderAuthorization())
81+
.header(HttpHeaders.ACCEPT, "application/vnd.docker.distribution.manifest.v2+json")
82+
.retrieve()
83+
.body(String::class.java)!!
84+
85+
val digest = JSONObject(manifest).getJSONObject("config").getString("digest")
86+
87+
val blobResponse =
88+
noRedirectClient
89+
.get()
90+
.uri("/v2/$repository/blobs/$digest")
91+
.header(HttpHeaders.AUTHORIZATION, getHeaderAuthorization())
92+
.retrieve()
93+
.toEntity(String::class.java)
94+
95+
// If we need to follow a redirect, do it without the initial 'Authorization' header or Azure
96+
// Blob Storage will complain
97+
var blob =
98+
if (blobResponse.statusCode.is3xxRedirection())
99+
restClient
100+
.get()
101+
.uri(URI(blobResponse.headers.getFirst(HttpHeaders.LOCATION)))
102+
.retrieve()
103+
.body(String::class.java)!!
104+
else blobResponse.body!!
105+
106+
return JSONObject(blob)
107+
.getJSONObject("config")
108+
.optJSONObject("Labels", JSONObject())
109+
.optString(label, null)
110+
} catch (e: RestClientException) {
111+
logger.error("Failed to get label '$label' on docker image '$repository:$tag'", e)
112+
return null
113+
}
114+
}
51115
}

src/test/kotlin/com/cosmotech/api/containerregistry/ContainerRegistryServiceTest.kt

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,30 +7,38 @@ import com.cosmotech.api.exceptions.CsmClientException
77
import io.mockk.every
88
import io.mockk.impl.annotations.SpyK
99
import io.mockk.mockk
10+
import java.net.URI
1011
import kotlin.test.BeforeTest
1112
import kotlin.test.Test
13+
import kotlin.test.assertEquals
14+
import kotlin.test.assertNull
1215
import org.json.JSONArray
1316
import org.json.JSONObject
1417
import org.junit.jupiter.api.assertThrows
1518
import org.springframework.http.HttpHeaders
1619
import org.springframework.http.HttpStatus
20+
import org.springframework.http.ResponseEntity
1721
import org.springframework.test.util.ReflectionTestUtils
22+
import org.springframework.util.MultiValueMap
1823
import org.springframework.web.client.HttpClientErrorException
1924
import org.springframework.web.client.HttpServerErrorException
2025
import org.springframework.web.client.RestClient
2126
import org.springframework.web.client.RestClient.ResponseSpec
27+
import org.springframework.web.client.RestClientException
2228

2329
class ContainerRegistryServiceTest {
2430

2531
private var csmPlatformProperties: CsmPlatformProperties = mockk(relaxed = true)
2632
private var restClient: RestClient = mockk(relaxed = true)
33+
private var noRedirectClient: RestClient = mockk(relaxed = true)
2734

2835
@SpyK lateinit var containerRegistryService: ContainerRegistryService
2936

3037
@BeforeTest
3138
fun beforeTest() {
3239
containerRegistryService = ContainerRegistryService(csmPlatformProperties)
3340
ReflectionTestUtils.setField(containerRegistryService, "restClient", restClient)
41+
ReflectionTestUtils.setField(containerRegistryService, "noRedirectClient", noRedirectClient)
3442
every { csmPlatformProperties.containerRegistry.scheme } answers { "http" }
3543
every { csmPlatformProperties.containerRegistry.host } answers { "localhost:5000" }
3644
}
@@ -113,4 +121,98 @@ class ContainerRegistryServiceTest {
113121

114122
containerRegistryService.checkSolutionImage("my-repository", "latest")
115123
}
124+
125+
@Test
126+
fun `getImageLabel, existing label`() {
127+
every {
128+
restClient
129+
.get()
130+
.uri("/v2/myimage/manifests/mytag")
131+
.header(HttpHeaders.AUTHORIZATION, any())
132+
.header(HttpHeaders.ACCEPT, "application/vnd.docker.distribution.manifest.v2+json")
133+
.retrieve()
134+
.body(String::class.java)
135+
} returns """{"config":{"digest":"mydigest"}}"""
136+
137+
every {
138+
noRedirectClient
139+
.get()
140+
.uri("/v2/myimage/blobs/mydigest")
141+
.header(HttpHeaders.AUTHORIZATION, any())
142+
.retrieve()
143+
.toEntity(String::class.java)
144+
} returns ResponseEntity("""{"config":{"Labels":{"mylabel":"myvalue"}}}""", HttpStatus.OK)
145+
146+
assertEquals("myvalue", containerRegistryService.getImageLabel("myimage", "mytag", "mylabel"))
147+
}
148+
149+
@Test
150+
fun `getImageLabel, with redirect`() {
151+
every {
152+
restClient
153+
.get()
154+
.uri("/v2/myimage/manifests/mytag")
155+
.header(HttpHeaders.AUTHORIZATION, any())
156+
.header(HttpHeaders.ACCEPT, "application/vnd.docker.distribution.manifest.v2+json")
157+
.retrieve()
158+
.body(String::class.java)
159+
} returns """{"config":{"digest":"mydigest"}}"""
160+
161+
val response =
162+
ResponseEntity<String>(
163+
MultiValueMap.fromSingleValue(mapOf(HttpHeaders.LOCATION to String())),
164+
HttpStatus.TEMPORARY_REDIRECT)
165+
every {
166+
noRedirectClient
167+
.get()
168+
.uri("/v2/myimage/blobs/mydigest")
169+
.header(HttpHeaders.AUTHORIZATION, any())
170+
.retrieve()
171+
.toEntity(String::class.java)
172+
} returns response
173+
174+
every { restClient.get().uri(any<URI>()).retrieve().body(String::class.java) } returns
175+
"""{"config":{"Labels":{"mylabel":"myvalue"}}}"""
176+
177+
assertEquals("myvalue", containerRegistryService.getImageLabel("myimage", "mytag", "mylabel"))
178+
}
179+
180+
@Test
181+
fun `getImageLabel, empty labels`() {
182+
every {
183+
restClient
184+
.get()
185+
.uri("/v2/myimage/manifests/mytag")
186+
.header(HttpHeaders.AUTHORIZATION, any())
187+
.header(HttpHeaders.ACCEPT, "application/vnd.docker.distribution.manifest.v2+json")
188+
.retrieve()
189+
.body(String::class.java)
190+
} returns """{"config":{"digest":"mydigest"}}"""
191+
192+
every {
193+
noRedirectClient
194+
.get()
195+
.uri("/v2/myimage/blobs/mydigest")
196+
.header(HttpHeaders.AUTHORIZATION, any())
197+
.retrieve()
198+
.toEntity(String::class.java)
199+
} returns ResponseEntity("""{"config":{"Labels":null}}""", HttpStatus.OK)
200+
201+
assertNull(containerRegistryService.getImageLabel("myimage", "mytag", "mylabel"))
202+
}
203+
204+
@Test
205+
fun `getImageLabel, invalid image`() {
206+
every {
207+
restClient
208+
.get()
209+
.uri("/v2/wrong/manifests/wrong")
210+
.header(HttpHeaders.AUTHORIZATION, any())
211+
.header(HttpHeaders.ACCEPT, "application/vnd.docker.distribution.manifest.v2+json")
212+
.retrieve()
213+
.body(String::class.java)
214+
} throws RestClientException("")
215+
216+
assertNull(containerRegistryService.getImageLabel("wrong", "wrong", "mylabel"))
217+
}
116218
}

0 commit comments

Comments
 (0)