diff --git a/spring-cloud-dataflow-configuration-metadata/src/main/java/org/springframework/cloud/dataflow/configuration/metadata/container/DefaultContainerImageMetadataResolver.java b/spring-cloud-dataflow-configuration-metadata/src/main/java/org/springframework/cloud/dataflow/configuration/metadata/container/DefaultContainerImageMetadataResolver.java index 0af21b76cb..0ce5522293 100644 --- a/spring-cloud-dataflow-configuration-metadata/src/main/java/org/springframework/cloud/dataflow/configuration/metadata/container/DefaultContainerImageMetadataResolver.java +++ b/spring-cloud-dataflow-configuration-metadata/src/main/java/org/springframework/cloud/dataflow/configuration/metadata/container/DefaultContainerImageMetadataResolver.java @@ -20,6 +20,7 @@ import java.util.Map; import org.springframework.cloud.dataflow.container.registry.ContainerRegistryException; +import org.springframework.cloud.dataflow.container.registry.ContainerRegistryProperties; import org.springframework.cloud.dataflow.container.registry.ContainerRegistryRequest; import org.springframework.cloud.dataflow.container.registry.ContainerRegistryService; import org.springframework.util.StringUtils; @@ -30,6 +31,7 @@ * * @author Christian Tzolov * @author Ilayaperumal Gopinathan + * @author Corneil du Plessis */ public class DefaultContainerImageMetadataResolver implements ContainerImageMetadataResolver { @@ -39,6 +41,7 @@ public DefaultContainerImageMetadataResolver(ContainerRegistryService containerR this.containerRegistryService = containerRegistryService; } + @SuppressWarnings("unchecked") @Override public Map getImageLabels(String imageName) { @@ -48,12 +51,23 @@ public Map getImageLabels(String imageName) { ContainerRegistryRequest registryRequest = this.containerRegistryService.getRegistryRequest(imageName); - Map manifest = this.containerRegistryService.getImageManifest(registryRequest, Map.class); - - if (manifest != null && !isNotNullMap(manifest.get("config"))) { - throw new ContainerRegistryException( - String.format("Image [%s] has incorrect or missing manifest config element: %s", - imageName, manifest.toString())); + Map manifest = this.containerRegistryService.getImageManifest(registryRequest, Map.class); + + if (manifest != null && manifest.get("config") == null) { + // when both Docker and OCI images are stored in repository the response for OCI image when using Docker manifest type will not contain config. + // In the case where we don't receive a config and schemaVersion is less than 2 we try OCI manifest type. + String manifestMediaType = registryRequest.getRegistryConf().getManifestMediaType(); + if (asInt(manifest.get("schemaVersion")) < 2 + && !manifestMediaType.equals(ContainerRegistryProperties.OCI_IMAGE_MANIFEST_MEDIA_TYPE)) { + registryRequest.getRegistryConf() + .setManifestMediaType(ContainerRegistryProperties.OCI_IMAGE_MANIFEST_MEDIA_TYPE); + manifest = this.containerRegistryService.getImageManifest(registryRequest, Map.class); + } + if (manifest.get("config") == null) { + String message = String.format("Image [%s] has incorrect or missing manifest config element: %s", + imageName, manifest); + throw new ContainerRegistryException(message); + } } if (manifest != null) { String configDigest = ((Map) manifest.get("config")).get("digest"); @@ -85,12 +99,24 @@ public Map getImageLabels(String imageName) { (Map) configElement.get("Labels") : Collections.emptyMap(); } else { - throw new ContainerRegistryException( - String.format("Image [%s] is missing manifest", imageName)); + throw new ContainerRegistryException(String.format("Image [%s] is missing manifest", imageName)); + } + } + + private static int asInt(Object value) { + if (value instanceof Number) { + return ((Number) value).intValue(); + } + else if (value instanceof String) { + return Integer.parseInt((String) value); + } + else if (value != null) { + return Integer.parseInt(value.toString()); } + return 0; } - private boolean isNotNullMap(Object object) { - return object != null && (object instanceof Map); + private static boolean isNotNullMap(Object object) { + return object instanceof Map; } } diff --git a/spring-cloud-dataflow-configuration-metadata/src/test/java/org/springframework/cloud/dataflow/container/registry/DefaultContainerImageMetadataResolverTest.java b/spring-cloud-dataflow-configuration-metadata/src/test/java/org/springframework/cloud/dataflow/container/registry/DefaultContainerImageMetadataResolverTest.java index 9803c791a0..2bdfa8bee8 100644 --- a/spring-cloud-dataflow-configuration-metadata/src/test/java/org/springframework/cloud/dataflow/container/registry/DefaultContainerImageMetadataResolverTest.java +++ b/spring-cloud-dataflow-configuration-metadata/src/test/java/org/springframework/cloud/dataflow/container/registry/DefaultContainerImageMetadataResolverTest.java @@ -16,16 +16,15 @@ package org.springframework.cloud.dataflow.container.registry; -import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.Map; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatcher; import org.mockito.Mock; import org.mockito.MockitoAnnotations; @@ -35,6 +34,7 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.util.StringUtils; import org.springframework.web.client.RestTemplate; @@ -46,6 +46,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -210,6 +211,25 @@ public void getImageLabelsWithInvalidLabels() throws JsonProcessingException { assertThat(labels).isEmpty(); } + @Test + public void getImageLabelsWithMixedOCIResponses() throws JsonProcessingException { + DefaultContainerImageMetadataResolver resolver = new MockedDefaultContainerImageMetadataResolver( + this.containerRegistryService); + String ociInCompatible = "{\"schemaVersion\": 1,\"name\": \"test/image\"}"; + String ociCompatible = "{\"schemaVersion\": 2,\"mediaType\": \"application/vnd.oci.image.manifest.v1+json\",\"config\":{\"mediaType\": \"application/vnd.oci.image.config.v1+json\",\"digest\": \"sha256:efc06d6096cc88697e477abb0b3479557e1bec688c36813383f1a8581f87d9f8\",\"size\": 34268}}"; + mockManifestRestTemplateCallAccepts(ociInCompatible, "my-private-repository.com", "5000", "test/image", + "latest", ContainerRegistryProperties.DOCKER_IMAGE_MANIFEST_MEDIA_TYPE); + mockManifestRestTemplateCallAccepts(ociCompatible, "my-private-repository.com", "5000", "test/image", "latest", + ContainerRegistryProperties.OCI_IMAGE_MANIFEST_MEDIA_TYPE); + String blobResponse = "{\"config\": {\"Labels\": {\"boza\": \"koza\"}}}"; + mockBlogRestTemplateCall(blobResponse, "my-private-repository.com", "5000", "test/image", + "sha256:efc06d6096cc88697e477abb0b3479557e1bec688c36813383f1a8581f87d9f8"); + + Map labels = resolver.getImageLabels("my-private-repository.com:5000/test/image:latest"); + assertThat(labels).isNotEmpty(); + assertThat(labels).containsEntry("boza", "koza"); + } + private void mockManifestRestTemplateCall(Map mapToReturn, String registryHost, String registryPort, String repository, String tagOrDigest) { @@ -246,6 +266,39 @@ private void mockBlogRestTemplateCall(String jsonResponse, String registryHost, .thenReturn(new ResponseEntity<>(new ObjectMapper().readValue(jsonResponse, Map.class), HttpStatus.OK)); } + private void mockManifestRestTemplateCallAccepts(String jsonResponse, String registryHost, String registryPort, + String repository, String tagOrDigest, String accepts) throws JsonProcessingException { + + UriComponents blobUriComponents = UriComponentsBuilder.newInstance() + .scheme("https") + .host(registryHost) + .port(StringUtils.hasText(registryPort) ? registryPort : null) + .path("v2/{repository}/manifests/{reference}") + .build() + .expand(repository, tagOrDigest); + + MediaType mediaType = new MediaType(org.apache.commons.lang3.StringUtils.substringBefore(accepts, "/"), + org.apache.commons.lang3.StringUtils.substringAfter(accepts, "/")); + when(mockRestTemplate.exchange(eq(blobUriComponents.toUri()), eq(HttpMethod.GET), + argThat(new HeaderAccepts(mediaType)), eq(Map.class))) + .thenReturn(new ResponseEntity<>(new ObjectMapper().readValue(jsonResponse, Map.class), HttpStatus.OK)); + } + + static class HeaderAccepts implements ArgumentMatcher> { + + private final MediaType accepts; + + public HeaderAccepts(MediaType accepts) { + this.accepts = accepts; + } + + @Override + public boolean matches(HttpEntity argument) { + return argument.getHeaders().getAccept().contains(accepts); + } + + } + private class MockedDefaultContainerImageMetadataResolver extends DefaultContainerImageMetadataResolver { public MockedDefaultContainerImageMetadataResolver(ContainerRegistryService containerRegistryService) { super(containerRegistryService);