diff --git a/jib-core/src/main/java/com/google/cloud/tools/jib/builder/steps/BuildImageStep.java b/jib-core/src/main/java/com/google/cloud/tools/jib/builder/steps/BuildImageStep.java index 00f21c5a8d..be2033ceb5 100644 --- a/jib-core/src/main/java/com/google/cloud/tools/jib/builder/steps/BuildImageStep.java +++ b/jib-core/src/main/java/com/google/cloud/tools/jib/builder/steps/BuildImageStep.java @@ -147,6 +147,13 @@ public Image call() throws LayerPropertyNotFoundException { imageBuilder.setWorkingDirectory(containerConfiguration.getWorkingDirectory().toString()); } + // Set base image reference and digest for OCI annotations. + if (!buildContext.getBaseImageConfiguration().getImage().isScratch()) { + imageBuilder.setBaseImageName( + buildContext.getBaseImageConfiguration().getImage().toString()); + imageBuilder.setBaseImageDigest(baseImage.getBaseImageDigest()); + } + // Gets the container configuration content descriptor. return imageBuilder.build(); } diff --git a/jib-core/src/main/java/com/google/cloud/tools/jib/builder/steps/PullBaseImageStep.java b/jib-core/src/main/java/com/google/cloud/tools/jib/builder/steps/PullBaseImageStep.java index 6ce49ba41a..da56aac2ed 100644 --- a/jib-core/src/main/java/com/google/cloud/tools/jib/builder/steps/PullBaseImageStep.java +++ b/jib-core/src/main/java/com/google/cloud/tools/jib/builder/steps/PullBaseImageStep.java @@ -297,11 +297,16 @@ private List pullBaseImages( ProgressEventDispatcher.Factory childProgressDispatcherFactory = progressDispatcher1.newChildProducer(); + String baseImageDigest = + manifestAndDigest.getDigest() != null ? manifestAndDigest.getDigest().toString() : null; + ManifestTemplate manifestTemplate = manifestAndDigest.getManifest(); if (manifestTemplate instanceof V21ManifestTemplate) { V21ManifestTemplate v21Manifest = (V21ManifestTemplate) manifestTemplate; cache.writeMetadata(baseImageConfig.getImage(), v21Manifest); - return Collections.singletonList(JsonToImageTranslator.toImage(v21Manifest)); + Image image = JsonToImageTranslator.toImage(v21Manifest); + image.setBaseImageDigest(baseImageDigest); + return Collections.singletonList(image); } else if (manifestTemplate instanceof BuildableManifestTemplate) { // V22ManifestTemplate or OciManifestTemplate @@ -311,8 +316,9 @@ private List pullBaseImages( manifestAndDigest, registryClient, childProgressDispatcherFactory); PlatformChecker.checkManifestPlatform(buildContext, containerConfig); cache.writeMetadata(baseImageConfig.getImage(), imageManifest, containerConfig); - return Collections.singletonList( - JsonToImageTranslator.toImage(imageManifest, containerConfig)); + Image image = JsonToImageTranslator.toImage(imageManifest, containerConfig); + image.setBaseImageDigest(baseImageDigest); + return Collections.singletonList(image); } Verify.verify(manifestTemplate instanceof ManifestListTemplate); @@ -344,7 +350,9 @@ private List pullBaseImages( manifestsAndConfigs.add( new ManifestAndConfigTemplate(imageManifest, containerConfig, manifestDigest)); - images.add(JsonToImageTranslator.toImage(imageManifest, containerConfig)); + Image image = JsonToImageTranslator.toImage(imageManifest, containerConfig); + image.setBaseImageDigest(manifestDigest); + images.add(image); } } diff --git a/jib-core/src/main/java/com/google/cloud/tools/jib/image/Image.java b/jib-core/src/main/java/com/google/cloud/tools/jib/image/Image.java index 687c9a1289..d596638d8d 100644 --- a/jib-core/src/main/java/com/google/cloud/tools/jib/image/Image.java +++ b/jib-core/src/main/java/com/google/cloud/tools/jib/image/Image.java @@ -57,6 +57,8 @@ public static class Builder { @Nullable private DockerHealthCheck healthCheck; @Nullable private String workingDirectory; @Nullable private String user; + @Nullable private String baseImageName; + @Nullable private String baseImageDigest; private Builder(Class imageFormat) { this.imageFormat = imageFormat; @@ -250,6 +252,28 @@ public Builder addHistory(HistoryEntry history) { return this; } + /** + * Sets the base image name (reference) used to build this image. + * + * @param baseImageName the base image reference string + * @return this + */ + public Builder setBaseImageName(@Nullable String baseImageName) { + this.baseImageName = baseImageName; + return this; + } + + /** + * Sets the base image digest. + * + * @param baseImageDigest the base image digest string + * @return this + */ + public Builder setBaseImageDigest(@Nullable String baseImageDigest) { + this.baseImageDigest = baseImageDigest; + return this; + } + /** * Create an {@link Image} instance. * @@ -271,7 +295,9 @@ public Image build() { ImmutableSet.copyOf(volumesBuilder), ImmutableMap.copyOf(labelsBuilder), workingDirectory, - user); + user, + baseImageName, + baseImageDigest); } } @@ -324,6 +350,12 @@ public static Builder builder(Class imageFormat) { /** User on the container configuration. */ @Nullable private final String user; + /** The base image name (reference) used to build this image. */ + @Nullable private final String baseImageName; + + /** The base image digest. */ + @Nullable private String baseImageDigest; + private Image( Class imageFormat, @Nullable Instant created, @@ -339,7 +371,9 @@ private Image( @Nullable ImmutableSet volumes, @Nullable ImmutableMap labels, @Nullable String workingDirectory, - @Nullable String user) { + @Nullable String user, + @Nullable String baseImageName, + @Nullable String baseImageDigest) { this.imageFormat = imageFormat; this.created = created; this.architecture = architecture; @@ -355,6 +389,8 @@ private Image( this.labels = labels; this.workingDirectory = workingDirectory; this.user = user; + this.baseImageName = baseImageName; + this.baseImageDigest = baseImageDigest; } public Class getImageFormat() { @@ -426,4 +462,34 @@ public ImmutableList getLayers() { public ImmutableList getHistory() { return history; } + + /** + * Returns the base image name (reference) used to build this image. + * + * @return the base image name, or {@code null} if not set + */ + @Nullable + public String getBaseImageName() { + return baseImageName; + } + + /** + * Returns the base image digest. + * + * @return the base image digest, or {@code null} if not set + */ + @Nullable + public String getBaseImageDigest() { + return baseImageDigest; + } + + /** + * Sets the base image digest. This is set after image construction when the digest becomes + * available from the registry pull. + * + * @param baseImageDigest the base image digest string + */ + public void setBaseImageDigest(@Nullable String baseImageDigest) { + this.baseImageDigest = baseImageDigest; + } } diff --git a/jib-core/src/main/java/com/google/cloud/tools/jib/image/json/ImageToJsonTranslator.java b/jib-core/src/main/java/com/google/cloud/tools/jib/image/json/ImageToJsonTranslator.java index 581cb17031..39fb309100 100644 --- a/jib-core/src/main/java/com/google/cloud/tools/jib/image/json/ImageToJsonTranslator.java +++ b/jib-core/src/main/java/com/google/cloud/tools/jib/image/json/ImageToJsonTranslator.java @@ -205,6 +205,18 @@ public T getManifestTemplate( layer.getBlobDescriptor().getSize(), layer.getBlobDescriptor().getDigest()); } + // Adds OCI base image annotations if the target format is OCI. + if (template instanceof OciManifestTemplate) { + OciManifestTemplate ociTemplate = (OciManifestTemplate) template; + if (image.getBaseImageName() != null) { + ociTemplate.addAnnotation(OCI_BASE_IMAGE_NAME_ANNOTATION, image.getBaseImageName()); + } + if (image.getBaseImageDigest() != null) { + ociTemplate.addAnnotation( + OCI_BASE_IMAGE_DIGEST_ANNOTATION, image.getBaseImageDigest()); + } + } + return template; } catch (InstantiationException diff --git a/jib-core/src/main/java/com/google/cloud/tools/jib/image/json/OciManifestTemplate.java b/jib-core/src/main/java/com/google/cloud/tools/jib/image/json/OciManifestTemplate.java index 3c2b457852..d4627b932f 100644 --- a/jib-core/src/main/java/com/google/cloud/tools/jib/image/json/OciManifestTemplate.java +++ b/jib-core/src/main/java/com/google/cloud/tools/jib/image/json/OciManifestTemplate.java @@ -17,9 +17,12 @@ package com.google.cloud.tools.jib.image.json; import com.google.cloud.tools.jib.api.DescriptorDigest; +import com.google.common.collect.ImmutableMap; import java.util.ArrayList; import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import javax.annotation.Nullable; /** @@ -77,6 +80,9 @@ public class OciManifestTemplate implements BuildableManifestTemplate { /** The list of layer references. */ private final List layers = new ArrayList<>(); + /** The manifest-level annotations. */ + @Nullable private Map annotations; + @Override public int getSchemaVersion() { return schemaVersion; @@ -107,4 +113,27 @@ public void setContainerConfiguration(long size, DescriptorDigest digest) { public void addLayer(long size, DescriptorDigest digest) { layers.add(new ContentDescriptorTemplate(LAYER_MEDIA_TYPE, size, digest)); } + + /** + * Returns the manifest-level annotations. + * + * @return the annotations map, or {@code null} if not set + */ + @Nullable + public Map getAnnotations() { + return annotations == null ? null : ImmutableMap.copyOf(annotations); + } + + /** + * Adds a manifest-level annotation. + * + * @param key the annotation key + * @param value the annotation value + */ + public void addAnnotation(String key, String value) { + if (annotations == null) { + annotations = new LinkedHashMap<>(); + } + annotations.put(key, value); + } } diff --git a/jib-core/src/test/java/com/google/cloud/tools/jib/image/ImageTest.java b/jib-core/src/test/java/com/google/cloud/tools/jib/image/ImageTest.java index b2c8e96728..dfe644b2be 100644 --- a/jib-core/src/test/java/com/google/cloud/tools/jib/image/ImageTest.java +++ b/jib-core/src/test/java/com/google/cloud/tools/jib/image/ImageTest.java @@ -95,4 +95,31 @@ public void testOsArch() { Assert.assertEquals("wasm", image.getArchitecture()); Assert.assertEquals("js", image.getOs()); } + + @Test + public void testBaseImageInfo() { + Image image = + Image.builder(V22ManifestTemplate.class) + .setBaseImageName("alpine:3.18") + .setBaseImageDigest("sha256:abc123") + .build(); + Assert.assertEquals("alpine:3.18", image.getBaseImageName()); + Assert.assertEquals("sha256:abc123", image.getBaseImageDigest()); + } + + @Test + public void testBaseImageInfo_defaults() { + Image image = Image.builder(V22ManifestTemplate.class).build(); + Assert.assertNull(image.getBaseImageName()); + Assert.assertNull(image.getBaseImageDigest()); + } + + @Test + public void testBaseImageDigest_mutableSetter() { + Image image = Image.builder(V22ManifestTemplate.class).build(); + Assert.assertNull(image.getBaseImageDigest()); + + image.setBaseImageDigest("sha256:def456"); + Assert.assertEquals("sha256:def456", image.getBaseImageDigest()); + } } diff --git a/jib-core/src/test/java/com/google/cloud/tools/jib/image/json/ImageToJsonTranslatorTest.java b/jib-core/src/test/java/com/google/cloud/tools/jib/image/json/ImageToJsonTranslatorTest.java index 7b5e5fb426..63a18e82e9 100644 --- a/jib-core/src/test/java/com/google/cloud/tools/jib/image/json/ImageToJsonTranslatorTest.java +++ b/jib-core/src/test/java/com/google/cloud/tools/jib/image/json/ImageToJsonTranslatorTest.java @@ -144,6 +144,139 @@ public void testGetManifest_oci() throws URISyntaxException, IOException, Digest testGetManifest(OciManifestTemplate.class, "core/json/translated_ocimanifest.json"); } + @Test + public void testGetManifest_ociWithBaseImageAnnotations() + throws IOException, DigestException, LayerPropertyNotFoundException { + Image.Builder testImageBuilder = + Image.builder(OciManifestTemplate.class) + .setCreated(Instant.ofEpochSecond(20)) + .setArchitecture("amd64") + .setOs("linux") + .setBaseImageName("alpine:3.18") + .setBaseImageDigest( + "sha256:8c662931926fa990b41da3c9f42663a537ccd498130030f9149173a0493832ad"); + + DescriptorDigest fakeDigest = + DescriptorDigest.fromDigest( + "sha256:8c662931926fa990b41da3c9f42663a537ccd498130030f9149173a0493832ad"); + testImageBuilder.addLayer( + new Layer() { + @Override + public Blob getBlob() throws LayerPropertyNotFoundException { + return Blobs.from("ignored"); + } + + @Override + public BlobDescriptor getBlobDescriptor() throws LayerPropertyNotFoundException { + return new BlobDescriptor(1000, fakeDigest); + } + + @Override + public DescriptorDigest getDiffId() throws LayerPropertyNotFoundException { + return fakeDigest; + } + }); + + ImageToJsonTranslator translator = new ImageToJsonTranslator(testImageBuilder.build()); + JsonTemplate containerConfiguration = translator.getContainerConfiguration(); + BlobDescriptor blobDescriptor = Digests.computeDigest(containerConfiguration); + OciManifestTemplate manifestTemplate = + translator.getManifestTemplate(OciManifestTemplate.class, blobDescriptor); + + Assert.assertNotNull(manifestTemplate.getAnnotations()); + Assert.assertEquals( + "alpine:3.18", manifestTemplate.getAnnotations().get("org.opencontainers.image.base.name")); + Assert.assertEquals( + "sha256:8c662931926fa990b41da3c9f42663a537ccd498130030f9149173a0493832ad", + manifestTemplate.getAnnotations().get("org.opencontainers.image.base.digest")); + } + + @Test + public void testGetManifest_v22NoAnnotations() + throws IOException, DigestException, LayerPropertyNotFoundException { + Image.Builder testImageBuilder = + Image.builder(V22ManifestTemplate.class) + .setCreated(Instant.ofEpochSecond(20)) + .setArchitecture("amd64") + .setOs("linux") + .setBaseImageName("alpine:3.18") + .setBaseImageDigest( + "sha256:8c662931926fa990b41da3c9f42663a537ccd498130030f9149173a0493832ad"); + + DescriptorDigest fakeDigest = + DescriptorDigest.fromDigest( + "sha256:8c662931926fa990b41da3c9f42663a537ccd498130030f9149173a0493832ad"); + testImageBuilder.addLayer( + new Layer() { + @Override + public Blob getBlob() throws LayerPropertyNotFoundException { + return Blobs.from("ignored"); + } + + @Override + public BlobDescriptor getBlobDescriptor() throws LayerPropertyNotFoundException { + return new BlobDescriptor(1000, fakeDigest); + } + + @Override + public DescriptorDigest getDiffId() throws LayerPropertyNotFoundException { + return fakeDigest; + } + }); + + ImageToJsonTranslator translator = new ImageToJsonTranslator(testImageBuilder.build()); + JsonTemplate containerConfiguration = translator.getContainerConfiguration(); + BlobDescriptor blobDescriptor = Digests.computeDigest(containerConfiguration); + V22ManifestTemplate manifestTemplate = + translator.getManifestTemplate(V22ManifestTemplate.class, blobDescriptor); + + // V22 (Docker) manifests should not have OCI annotations + String json = JsonTemplateMapper.toUtf8String(manifestTemplate); + Assert.assertFalse(json.contains("org.opencontainers.image.base.name")); + Assert.assertFalse(json.contains("org.opencontainers.image.base.digest")); + } + + @Test + public void testGetManifest_ociNoBaseImage() + throws IOException, DigestException, LayerPropertyNotFoundException { + // Image without base image info (e.g., scratch) + Image.Builder testImageBuilder = + Image.builder(OciManifestTemplate.class) + .setCreated(Instant.ofEpochSecond(20)) + .setArchitecture("amd64") + .setOs("linux"); + + DescriptorDigest fakeDigest = + DescriptorDigest.fromDigest( + "sha256:8c662931926fa990b41da3c9f42663a537ccd498130030f9149173a0493832ad"); + testImageBuilder.addLayer( + new Layer() { + @Override + public Blob getBlob() throws LayerPropertyNotFoundException { + return Blobs.from("ignored"); + } + + @Override + public BlobDescriptor getBlobDescriptor() throws LayerPropertyNotFoundException { + return new BlobDescriptor(1000, fakeDigest); + } + + @Override + public DescriptorDigest getDiffId() throws LayerPropertyNotFoundException { + return fakeDigest; + } + }); + + ImageToJsonTranslator translator = new ImageToJsonTranslator(testImageBuilder.build()); + JsonTemplate containerConfiguration = translator.getContainerConfiguration(); + BlobDescriptor blobDescriptor = Digests.computeDigest(containerConfiguration); + OciManifestTemplate manifestTemplate = + translator.getManifestTemplate(OciManifestTemplate.class, blobDescriptor); + + // No base image info means no annotations + Assert.assertNull(manifestTemplate.getAnnotations()); + } + @Test public void testPortListToMap() { ImmutableSet input = ImmutableSet.of(Port.tcp(1000), Port.udp(2000)); diff --git a/jib-core/src/test/java/com/google/cloud/tools/jib/image/json/OciManifestTemplateTest.java b/jib-core/src/test/java/com/google/cloud/tools/jib/image/json/OciManifestTemplateTest.java index 6eaac9c4bf..37581905f5 100644 --- a/jib-core/src/test/java/com/google/cloud/tools/jib/image/json/OciManifestTemplateTest.java +++ b/jib-core/src/test/java/com/google/cloud/tools/jib/image/json/OciManifestTemplateTest.java @@ -26,6 +26,7 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.security.DigestException; +import java.util.Map; import org.junit.Assert; import org.junit.Test; @@ -78,4 +79,85 @@ public void testFromJson() throws IOException, URISyntaxException, DigestExcepti Assert.assertEquals(1000_000, manifestJson.getLayers().get(0).getSize()); } + + @Test + public void testAnnotations_addAndGet() { + OciManifestTemplate manifest = new OciManifestTemplate(); + Assert.assertNull(manifest.getAnnotations()); + + manifest.addAnnotation("org.opencontainers.image.base.name", "alpine:3.18"); + manifest.addAnnotation( + "org.opencontainers.image.base.digest", + "sha256:8c662931926fa990b41da3c9f42663a537ccd498130030f9149173a0493832ad"); + + Map annotations = manifest.getAnnotations(); + Assert.assertNotNull(annotations); + Assert.assertEquals(2, annotations.size()); + Assert.assertEquals("alpine:3.18", annotations.get("org.opencontainers.image.base.name")); + Assert.assertEquals( + "sha256:8c662931926fa990b41da3c9f42663a537ccd498130030f9149173a0493832ad", + annotations.get("org.opencontainers.image.base.digest")); + } + + @Test + public void testAnnotations_serialization() throws DigestException, IOException { + OciManifestTemplate manifest = new OciManifestTemplate(); + manifest.setContainerConfiguration( + 1000, + DescriptorDigest.fromDigest( + "sha256:8c662931926fa990b41da3c9f42663a537ccd498130030f9149173a0493832ad")); + manifest.addLayer( + 1000_000, + DescriptorDigest.fromHash( + "4945ba5011739b0b98c4a41afe224e417f47c7c99b2ce76830999c9a0861b236")); + manifest.addAnnotation("org.opencontainers.image.base.name", "alpine:3.18"); + manifest.addAnnotation( + "org.opencontainers.image.base.digest", + "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"); + + String json = JsonTemplateMapper.toUtf8String(manifest); + Assert.assertTrue(json.contains("\"annotations\"")); + Assert.assertTrue(json.contains("\"org.opencontainers.image.base.name\":\"alpine:3.18\"")); + Assert.assertTrue( + json.contains( + "\"org.opencontainers.image.base.digest\":" + + "\"sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789\"")); + } + + @Test + public void testAnnotations_deserialization() throws IOException { + String json = + "{\"schemaVersion\":2," + + "\"mediaType\":\"application/vnd.oci.image.manifest.v1+json\"," + + "\"config\":{\"mediaType\":\"application/vnd.oci.image.config.v1+json\"," + + "\"digest\":\"sha256:8c662931926fa990b41da3c9f42663a537ccd498130030f9149173a0493832ad\"," + + "\"size\":1000}," + + "\"layers\":[]," + + "\"annotations\":{" + + "\"org.opencontainers.image.base.name\":\"alpine:3.18\"," + + "\"org.opencontainers.image.base.digest\":\"sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789\"}}"; + + OciManifestTemplate manifest = JsonTemplateMapper.readJson(json, OciManifestTemplate.class); + + Map annotations = manifest.getAnnotations(); + Assert.assertNotNull(annotations); + Assert.assertEquals("alpine:3.18", annotations.get("org.opencontainers.image.base.name")); + Assert.assertEquals( + "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + annotations.get("org.opencontainers.image.base.digest")); + } + + @Test + public void testAnnotations_getReturnsImmutableCopy() { + OciManifestTemplate manifest = new OciManifestTemplate(); + manifest.addAnnotation("key", "value"); + + Map annotations = manifest.getAnnotations(); + try { + annotations.put("another", "value"); + Assert.fail("Expected UnsupportedOperationException"); + } catch (UnsupportedOperationException expected) { + // expected + } + } }