diff --git a/cmd/image.go b/cmd/image.go index c04c8cd2..3adf8681 100644 --- a/cmd/image.go +++ b/cmd/image.go @@ -14,5 +14,6 @@ var imageCmd = &cobra.Command{ func init() { imageCmd.AddCommand(image.ApplyTagsCmd) imageCmd.AddCommand(image.BuildCmd) + imageCmd.AddCommand(image.BuildImageIndexCmd) imageCmd.AddCommand(image.PushContainerfileCmd) } diff --git a/cmd/image/build_image_index.go b/cmd/image/build_image_index.go new file mode 100644 index 00000000..103fe5c5 --- /dev/null +++ b/cmd/image/build_image_index.go @@ -0,0 +1,55 @@ +package image + +import ( + "github.com/spf13/cobra" + + "github.com/konflux-ci/konflux-build-cli/pkg/commands" + "github.com/konflux-ci/konflux-build-cli/pkg/common" + l "github.com/konflux-ci/konflux-build-cli/pkg/logger" +) + +var BuildImageIndexCmd = &cobra.Command{ + Use: "build-image-index", + Short: "Build a multi-architecture image index", + Long: `Build a multi-architecture image index (manifest list) from multiple platform-specific images. + +This command combines multiple container images into a single image index, enabling +multi-platform container image support. + +Examples: + # Build an image index from multiple platform images + konflux-build-cli image build-image-index \ + --image quay.io/myorg/myapp:latest \ + --images quay.io/myorg/myapp@sha256:amd64digest... quay.io/myorg/myapp@sha256:arm64digest... + + # Build and push to additional tags (e.g., TaskRun name, commit SHA) + konflux-build-cli image build-image-index \ + --image quay.io/myorg/myapp:latest \ + --images quay.io/myorg/myapp@sha256:amd64digest... quay.io/myorg/myapp@sha256:arm64digest... \ + --additional-tags taskrun-xyz-12345 commit-abc123 + + # Write results to files (useful for Tekton tasks) + konflux-build-cli image build-image-index \ + --image quay.io/myorg/myapp:latest \ + --images quay.io/myorg/myapp@sha256:amd64digest... quay.io/myorg/myapp@sha256:arm64digest... \ + --result-path-image-digest /tekton/results/IMAGE_DIGEST \ + --result-path-image-url /tekton/results/IMAGE_URL \ + --result-path-image-ref /tekton/results/IMAGE_REF \ + --result-path-images /tekton/results/IMAGES +`, + Run: func(cmd *cobra.Command, args []string) { + l.Logger.Debug("Starting build-image-index") + buildImageIndex, err := commands.NewBuildImageIndex(cmd) + if err != nil { + l.Logger.Fatal(err) + } + if err := buildImageIndex.Run(); err != nil { + l.Logger.Fatal(err) + } + l.Logger.Debug("Finished build-image-index") + }, +} + +func init() { + common.RegisterParameters(BuildImageIndexCmd, commands.BuildImageIndexParamsConfig) +} diff --git a/integration_tests/build_image_index_test.go b/integration_tests/build_image_index_test.go new file mode 100644 index 00000000..f0a619fa --- /dev/null +++ b/integration_tests/build_image_index_test.go @@ -0,0 +1,583 @@ +package integration_tests + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + "testing" + + . "github.com/onsi/gomega" + + . "github.com/konflux-ci/konflux-build-cli/integration_tests/framework" + "github.com/konflux-ci/konflux-build-cli/pkg/common" +) + +const BuildImageIndexImage = "quay.io/konflux-ci/task-runner:1.4.1" + +type BuildImageIndexParams struct { + Image string + Images []string + BuildahFormat string + // Use bool pointers to allow tests to omit these flags and rely on CLI defaults (true), instead of being set + // to false (bool default) + TLSVerify *bool + AlwaysBuildIndex *bool + AdditionalTags []string + ResultPathImageDigest string + ResultPathImageURL string + ResultPathImageRef string + ResultPathImages string +} + +type BuildImageIndexResults struct { + ImageDigest string `json:"image_digest"` + ImageURL string `json:"image_url"` + ImageRef string `json:"image_ref"` + Images string `json:"images"` +} + +func RunBuildImageIndex(params BuildImageIndexParams, imageRegistry ImageRegistry, cleanupContainer bool) (*BuildImageIndexResults, *TestRunnerContainer, error) { + var err error + + container := NewBuildCliRunnerContainer("build-image-index", BuildImageIndexImage) + if cleanupContainer { + defer container.DeleteIfExists() + } + + err = container.StartWithRegistryIntegration(imageRegistry) + if err != nil { + return nil, nil, err + } + + // Construct the build-image-index arguments + args := []string{"image", "build-image-index"} + args = append(args, "--image", params.Image) + if params.TLSVerify != nil { + args = append(args, fmt.Sprintf("--tls-verify=%t", *params.TLSVerify)) + } + args = append(args, "--buildah-format", params.BuildahFormat) + if params.AlwaysBuildIndex != nil { + args = append(args, fmt.Sprintf("--always-build-index=%t", *params.AlwaysBuildIndex)) + } + + if len(params.Images) > 0 { + args = append(args, "--images") + args = append(args, params.Images...) + } + + if len(params.AdditionalTags) > 0 { + args = append(args, "--additional-tags") + args = append(args, params.AdditionalTags...) + } + + if params.ResultPathImageDigest != "" { + args = append(args, "--result-path-image-digest", params.ResultPathImageDigest) + } + if params.ResultPathImageURL != "" { + args = append(args, "--result-path-image-url", params.ResultPathImageURL) + } + if params.ResultPathImageRef != "" { + args = append(args, "--result-path-image-ref", params.ResultPathImageRef) + } + if params.ResultPathImages != "" { + args = append(args, "--result-path-images", params.ResultPathImages) + } + + stdout, stderr, err := container.ExecuteCommandWithOutput(KonfluxBuildCli, args...) + if err != nil { + return nil, container, fmt.Errorf("%w (stderr: %s)", err, stderr) + } + + // Parse the JSON output from stdout + var results BuildImageIndexResults + if err := json.Unmarshal([]byte(stdout), &results); err != nil { + return nil, container, fmt.Errorf("failed to parse results JSON (stderr: %s): %w", stderr, err) + } + + return &results, container, nil +} + +func TestBuildImageIndex_MultipleImages(t *testing.T) { + SetupGomega(t) + var err error + + // Setup registry + imageRegistry := NewImageRegistry() + err = imageRegistry.Prepare() + Expect(err).ToNot(HaveOccurred()) + err = imageRegistry.Start() + Expect(err).ToNot(HaveOccurred()) + defer imageRegistry.Stop() + + // Create input data + baseImageRepo := imageRegistry.GetTestNamespace() + "test-image-index" + tag := GenerateUniqueTag(t) + indexImage := baseImageRepo + ":" + tag + + // Create and push two platform images (simulating amd64 and arm64) + image1Ref := baseImageRepo + "-platform1:" + tag + image2Ref := baseImageRepo + "-platform2:" + tag + + err = CreateTestImage(TestImageConfig{ + ImageRef: image1Ref, + RandomDataSize: 1024, + Labels: map[string]string{ + "platform": "amd64", + }, + }) + Expect(err).ToNot(HaveOccurred()) + defer DeleteLocalImage(image1Ref) + + err = CreateTestImage(TestImageConfig{ + ImageRef: image2Ref, + RandomDataSize: 2048, + Labels: map[string]string{ + "platform": "arm64", + }, + }) + Expect(err).ToNot(HaveOccurred()) + defer DeleteLocalImage(image2Ref) + + digest1, err := PushImage(image1Ref) + Expect(err).ToNot(HaveOccurred()) + + digest2, err := PushImage(image2Ref) + Expect(err).ToNot(HaveOccurred()) + + // Build the image references with digests + imageRepo1 := common.GetImageName(image1Ref) + imageRepo2 := common.GetImageName(image2Ref) + image1WithDigest := imageRepo1 + "@" + digest1 + image2WithDigest := imageRepo2 + "@" + digest2 + + // Run the command + params := BuildImageIndexParams{ + Image: indexImage, + Images: []string{image1WithDigest, image2WithDigest}, + TLSVerify: boolptr(true), + BuildahFormat: "oci", + AlwaysBuildIndex: boolptr(true), + AdditionalTags: []string{"test-tag-1"}, + } + + results, _, err := RunBuildImageIndex(params, imageRegistry, true) + Expect(err).ToNot(HaveOccurred()) + + // Verify results + Expect(results.ImageURL).To(Equal(indexImage)) + Expect(results.ImageDigest).ToNot(BeEmpty()) + Expect(results.ImageDigest).To(HavePrefix("sha256:")) + Expect(results.ImageRef).To(Equal(baseImageRepo + "@" + results.ImageDigest)) + + // Images should contain both platform image digests (order may vary) + Expect(results.Images).To(Or( + Equal(baseImageRepo+"@"+digest1+","+baseImageRepo+"@"+digest2), + Equal(baseImageRepo+"@"+digest2+","+baseImageRepo+"@"+digest1), + )) + + // Verify the index was pushed to registry + tagExists, err := imageRegistry.CheckTagExistance(baseImageRepo, tag) + Expect(err).ToNot(HaveOccurred()) + Expect(tagExists).To(BeTrue(), fmt.Sprintf("Expected %s to exist", indexImage)) + + // Verify additional tag was created + tagExists, err = imageRegistry.CheckTagExistance(baseImageRepo, "test-tag-1") + Expect(err).ToNot(HaveOccurred()) + Expect(tagExists).To(BeTrue(), fmt.Sprintf("Expected %s:test-tag-1 to exist", baseImageRepo)) + + // Verify the manifest is actually an index (multi-arch) + imageIndexInfo, err := imageRegistry.GetImageIndexInfo(baseImageRepo, tag) + Expect(err).ToNot(HaveOccurred(), fmt.Sprintf("failed to get image index %s:%s", baseImageRepo, tag)) + Expect(imageIndexInfo.MediaType).To(Equal("application/vnd.oci.image.index.v1+json"), + "Created reference is not an OCI image index") + Expect(imageIndexInfo.Manifests).To(HaveLen(2)) + + // Verify platform manifests are OCI format and extract digests + obtainedDigests := make([]string, 0, 2) + for _, manifestInfo := range imageIndexInfo.Manifests { + Expect(manifestInfo.MediaType).To(Equal("application/vnd.oci.image.manifest.v1+json")) + obtainedDigests = append(obtainedDigests, manifestInfo.Digest) + } + + // Check that platform image digests are included in the index + Expect(obtainedDigests).To(ConsistOf(digest1, digest2)) + + // Verify the digest matches the actual manifest digest + actualDigest := fmt.Sprintf("sha256:%x", sha256.Sum256(imageIndexInfo.RawManifest)) + Expect(results.ImageDigest).To(Equal(actualDigest)) +} + +func TestBuildImageIndex_DockerFormat(t *testing.T) { + SetupGomega(t) + var err error + + // Setup registry + imageRegistry := NewImageRegistry() + err = imageRegistry.Prepare() + Expect(err).ToNot(HaveOccurred()) + err = imageRegistry.Start() + Expect(err).ToNot(HaveOccurred()) + defer imageRegistry.Stop() + + // Create input data + baseImageRepo := imageRegistry.GetTestNamespace() + "test-docker-format" + tag := GenerateUniqueTag(t) + indexImage := baseImageRepo + ":" + tag + + // Create and push two platform images + image1Ref := baseImageRepo + "-platform1:" + tag + image2Ref := baseImageRepo + "-platform2:" + tag + + err = CreateTestImage(TestImageConfig{ + ImageRef: image1Ref, + RandomDataSize: 1024, + Labels: map[string]string{ + "platform": "amd64", + }, + BuildahFormat: "docker", + }) + Expect(err).ToNot(HaveOccurred()) + defer DeleteLocalImage(image1Ref) + + err = CreateTestImage(TestImageConfig{ + ImageRef: image2Ref, + RandomDataSize: 2048, + Labels: map[string]string{ + "platform": "arm64", + }, + BuildahFormat: "docker", + }) + Expect(err).ToNot(HaveOccurred()) + defer DeleteLocalImage(image2Ref) + + digest1, err := PushImage(image1Ref) + Expect(err).ToNot(HaveOccurred()) + + digest2, err := PushImage(image2Ref) + Expect(err).ToNot(HaveOccurred()) + + // Build the image references with digests + imageRepo1 := common.GetImageName(image1Ref) + imageRepo2 := common.GetImageName(image2Ref) + image1WithDigest := imageRepo1 + "@" + digest1 + image2WithDigest := imageRepo2 + "@" + digest2 + + // Run the command with docker format + params := BuildImageIndexParams{ + Image: indexImage, + Images: []string{image1WithDigest, image2WithDigest}, + BuildahFormat: "docker", + } + + results, _, err := RunBuildImageIndex(params, imageRegistry, true) + Expect(err).ToNot(HaveOccurred()) + + // Verify results + Expect(results.ImageURL).To(Equal(indexImage)) + Expect(results.ImageDigest).ToNot(BeEmpty()) + Expect(results.ImageDigest).To(HavePrefix("sha256:")) + Expect(results.ImageRef).To(Equal(baseImageRepo + "@" + results.ImageDigest)) + + // Images should contain both platform image digests (order may vary) + Expect(results.Images).To(Or( + Equal(baseImageRepo+"@"+digest1+","+baseImageRepo+"@"+digest2), + Equal(baseImageRepo+"@"+digest2+","+baseImageRepo+"@"+digest1), + )) + + // Verify the index was pushed to registry + tagExists, err := imageRegistry.CheckTagExistance(baseImageRepo, tag) + Expect(err).ToNot(HaveOccurred()) + Expect(tagExists).To(BeTrue(), fmt.Sprintf("Expected %s to exist", indexImage)) + + // Verify the manifest is actually a docker manifest list (not OCI) + imageIndexInfo, err := imageRegistry.GetImageIndexInfo(baseImageRepo, tag) + Expect(err).ToNot(HaveOccurred(), fmt.Sprintf("failed to get image index %s:%s", baseImageRepo, tag)) + Expect(imageIndexInfo.MediaType).To(Equal("application/vnd.docker.distribution.manifest.list.v2+json"), + "Created reference is not a docker manifest list") + Expect(imageIndexInfo.Manifests).To(HaveLen(2)) + + // Verify platform manifests are also docker format + obtainedDigests := make([]string, 0, 2) + for _, manifestInfo := range imageIndexInfo.Manifests { + Expect(manifestInfo.MediaType).To(Equal("application/vnd.docker.distribution.manifest.v2+json")) + obtainedDigests = append(obtainedDigests, manifestInfo.Digest) + } + + // Check that platform image digests are included in the index + Expect(obtainedDigests).To(ConsistOf(digest1, digest2)) + + // Verify the digest matches the actual manifest digest + actualDigest := fmt.Sprintf("sha256:%x", sha256.Sum256(imageIndexInfo.RawManifest)) + Expect(results.ImageDigest).To(Equal(actualDigest)) +} + +func TestBuildImageIndex_SingleImageSkipIndex(t *testing.T) { + SetupGomega(t) + + // No registry needed - we're skipping index build, just returning input image info + targetImage := "quay.io/test/myapp:latest" + digest := "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + imageWithDigest := "quay.io/test/myapp@" + digest + + container := NewBuildCliRunnerContainer("build-image-index", BuildImageIndexImage) + defer container.DeleteIfExists() + + err := container.Start() + Expect(err).ToNot(HaveOccurred()) + + // Run the command with always-build-index=false + args := []string{"image", "build-image-index"} + args = append(args, "--image", targetImage) + args = append(args, "--images", imageWithDigest) + args = append(args, "--buildah-format", "oci") + args = append(args, "--always-build-index=false") + + stdout, stderr, err := container.ExecuteCommandWithOutput(KonfluxBuildCli, args...) + Expect(err).ToNot(HaveOccurred()) + + // Parse the JSON output + var results BuildImageIndexResults + err = json.Unmarshal([]byte(stdout), &results) + Expect(err).ToNot(HaveOccurred(), fmt.Sprintf("failed to parse results JSON (stderr: %s)", stderr)) + + // Verify results - should just return info about the single image + Expect(results.ImageURL).To(Equal(targetImage)) + Expect(results.ImageDigest).To(Equal(digest)) + Expect(results.ImageRef).To(Equal("quay.io/test/myapp@" + digest)) + Expect(results.Images).To(Equal(imageWithDigest)) +} + +func TestBuildImageIndex_SingleImageAlwaysBuildIndex(t *testing.T) { + SetupGomega(t) + var err error + + // Setup registry + imageRegistry := NewImageRegistry() + err = imageRegistry.Prepare() + Expect(err).ToNot(HaveOccurred()) + err = imageRegistry.Start() + Expect(err).ToNot(HaveOccurred()) + defer imageRegistry.Stop() + + // Create input data + baseImageRepo := imageRegistry.GetTestNamespace() + "test-single-always" + tag := GenerateUniqueTag(t) + indexImage := baseImageRepo + ":" + tag + + // Create and push a single image + sourceImageRef := baseImageRepo + "-source:" + tag + err = CreateTestImage(TestImageConfig{ + ImageRef: sourceImageRef, + RandomDataSize: 1024, + Labels: map[string]string{ + "platform": "amd64", + }, + }) + Expect(err).ToNot(HaveOccurred()) + defer DeleteLocalImage(sourceImageRef) + + digest, err := PushImage(sourceImageRef) + Expect(err).ToNot(HaveOccurred()) + + // Build the image reference with digest + imageRepo := common.GetImageName(sourceImageRef) + imageWithDigest := imageRepo + "@" + digest + + // Run the command with always-build-index=true + params := BuildImageIndexParams{ + Image: indexImage, + Images: []string{imageWithDigest}, + BuildahFormat: "oci", + AlwaysBuildIndex: boolptr(true), + } + + results, _, err := RunBuildImageIndex(params, imageRegistry, true) + Expect(err).ToNot(HaveOccurred()) + + // Verify results + Expect(results.ImageURL).To(Equal(indexImage)) + Expect(results.ImageDigest).ToNot(BeEmpty()) + Expect(results.ImageDigest).To(HavePrefix("sha256:")) + Expect(results.ImageRef).To(Equal(baseImageRepo + "@" + results.ImageDigest)) + Expect(results.Images).To(Equal(baseImageRepo + "@" + digest)) + + // Verify the index was pushed to registry + tagExists, err := imageRegistry.CheckTagExistance(baseImageRepo, tag) + Expect(err).ToNot(HaveOccurred()) + Expect(tagExists).To(BeTrue(), fmt.Sprintf("Expected %s to exist", indexImage)) + + // Verify the manifest is actually an index (even with single image) + imageIndexInfo, err := imageRegistry.GetImageIndexInfo(baseImageRepo, tag) + Expect(err).ToNot(HaveOccurred(), fmt.Sprintf("failed to get image index %s:%s", baseImageRepo, tag)) + Expect(imageIndexInfo.MediaType).To(Equal("application/vnd.oci.image.index.v1+json"), + "Created reference is not an OCI image index") + Expect(imageIndexInfo.Manifests).To(HaveLen(1)) + + // Verify platform manifest is OCI format + Expect(imageIndexInfo.Manifests[0].MediaType).To(Equal("application/vnd.oci.image.manifest.v1+json")) + Expect(imageIndexInfo.Manifests[0].Digest).To(Equal(digest)) + + // Verify the digest matches the actual manifest digest + actualDigest := fmt.Sprintf("sha256:%x", sha256.Sum256(imageIndexInfo.RawManifest)) + Expect(results.ImageDigest).To(Equal(actualDigest)) +} + +func TestBuildImageIndex_ResultPaths(t *testing.T) { + SetupGomega(t) + var err error + + // Setup registry + imageRegistry := NewImageRegistry() + err = imageRegistry.Prepare() + Expect(err).ToNot(HaveOccurred()) + err = imageRegistry.Start() + Expect(err).ToNot(HaveOccurred()) + defer imageRegistry.Stop() + + // Create input data + baseImageRepo := imageRegistry.GetTestNamespace() + "test-result-paths" + tag := GenerateUniqueTag(t) + indexImage := baseImageRepo + ":" + tag + + // Create and push two platform images + image1Ref := baseImageRepo + "-platform1:" + tag + image2Ref := baseImageRepo + "-platform2:" + tag + + err = CreateTestImage(TestImageConfig{ + ImageRef: image1Ref, + RandomDataSize: 1024, + Labels: map[string]string{ + "platform": "amd64", + }, + }) + Expect(err).ToNot(HaveOccurred()) + defer DeleteLocalImage(image1Ref) + + err = CreateTestImage(TestImageConfig{ + ImageRef: image2Ref, + RandomDataSize: 2048, + Labels: map[string]string{ + "platform": "arm64", + }, + }) + Expect(err).ToNot(HaveOccurred()) + defer DeleteLocalImage(image2Ref) + + digest1, err := PushImage(image1Ref) + Expect(err).ToNot(HaveOccurred()) + + digest2, err := PushImage(image2Ref) + Expect(err).ToNot(HaveOccurred()) + + // Build the image references with digests + imageRepo1 := common.GetImageName(image1Ref) + imageRepo2 := common.GetImageName(image2Ref) + image1WithDigest := imageRepo1 + "@" + digest1 + image2WithDigest := imageRepo2 + "@" + digest2 + + // Use paths in container's /tmp directory + resultPathDigest := "/tmp/test-result-digest" + resultPathURL := "/tmp/test-result-url" + resultPathRef := "/tmp/test-result-ref" + resultPathImages := "/tmp/test-result-images" + + // Run the command with result-path parameters + params := BuildImageIndexParams{ + Image: indexImage, + Images: []string{image1WithDigest, image2WithDigest}, + BuildahFormat: "oci", + ResultPathImageDigest: resultPathDigest, + ResultPathImageURL: resultPathURL, + ResultPathImageRef: resultPathRef, + ResultPathImages: resultPathImages, + } + + // Don't cleanup container automatically - we need to read result files first + results, container, err := RunBuildImageIndex(params, imageRegistry, false) + Expect(err).ToNot(HaveOccurred()) + defer container.DeleteIfExists() + + // Verify result files were created and contain correct content + digestContent, err := container.GetFileContent(resultPathDigest) + Expect(err).ToNot(HaveOccurred()) + Expect(digestContent).To(Equal(results.ImageDigest)) + + urlContent, err := container.GetFileContent(resultPathURL) + Expect(err).ToNot(HaveOccurred()) + Expect(urlContent).To(Equal(results.ImageURL)) + + refContent, err := container.GetFileContent(resultPathRef) + Expect(err).ToNot(HaveOccurred()) + Expect(refContent).To(Equal(results.ImageRef)) + + imagesContent, err := container.GetFileContent(resultPathImages) + Expect(err).ToNot(HaveOccurred()) + Expect(imagesContent).To(Equal(results.Images)) +} + +func TestBuildImageIndex_FormatMismatch(t *testing.T) { + SetupGomega(t) + var err error + + // Setup registry + imageRegistry := NewImageRegistry() + err = imageRegistry.Prepare() + Expect(err).ToNot(HaveOccurred()) + err = imageRegistry.Start() + Expect(err).ToNot(HaveOccurred()) + defer imageRegistry.Stop() + + // Create input data + baseImageRepo := imageRegistry.GetTestNamespace() + "test-format-mismatch" + tag := GenerateUniqueTag(t) + indexImage := baseImageRepo + ":" + tag + + // Create and push platform images with docker format + image1Ref := baseImageRepo + "-platform1:" + tag + image2Ref := baseImageRepo + "-platform2:" + tag + + err = CreateTestImage(TestImageConfig{ + ImageRef: image1Ref, + RandomDataSize: 1024, + Labels: map[string]string{ + "platform": "amd64", + }, + BuildahFormat: "docker", + }) + Expect(err).ToNot(HaveOccurred()) + defer DeleteLocalImage(image1Ref) + + err = CreateTestImage(TestImageConfig{ + ImageRef: image2Ref, + RandomDataSize: 2048, + Labels: map[string]string{ + "platform": "arm64", + }, + BuildahFormat: "docker", + }) + Expect(err).ToNot(HaveOccurred()) + defer DeleteLocalImage(image2Ref) + + digest1, err := PushImage(image1Ref) + Expect(err).ToNot(HaveOccurred()) + + digest2, err := PushImage(image2Ref) + Expect(err).ToNot(HaveOccurred()) + + // Build the image references with digests + imageRepo1 := common.GetImageName(image1Ref) + imageRepo2 := common.GetImageName(image2Ref) + image1WithDigest := imageRepo1 + "@" + digest1 + image2WithDigest := imageRepo2 + "@" + digest2 + + // Try to build an OCI index from docker format images (should fail) + params := BuildImageIndexParams{ + Image: indexImage, + Images: []string{image1WithDigest, image2WithDigest}, + BuildahFormat: "oci", + } + + _, _, err = RunBuildImageIndex(params, imageRegistry, true) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("platform image contains docker format, but index will be oci")) +} diff --git a/integration_tests/build_test.go b/integration_tests/build_test.go index 9e65c086..69f2461e 100644 --- a/integration_tests/build_test.go +++ b/integration_tests/build_test.go @@ -727,13 +727,13 @@ LABEL test.label="secret-dirs-test" t, buildParams, nil, WithVolumeWithOptions(secretsBaseDir, "/secrets", "z"), ) - stdout, _, err := runBuildWithOutput(container, buildParams) + _, stderr, err := runBuildWithOutput(container, buildParams) Expect(err).ToNot(HaveOccurred()) - // Verify that the secret values appear in the build output - Expect(stdout).To(ContainSubstring("token=secret-token-value")) - Expect(stdout).To(ContainSubstring("api-key=secret-api-key-value")) - Expect(stdout).To(ContainSubstring("password=secret-password-value")) + // Verify that the secret values appear in the build output (stderr contains build logs) + Expect(stderr).To(ContainSubstring("token=secret-token-value")) + Expect(stderr).To(ContainSubstring("api-key=secret-api-key-value")) + Expect(stderr).To(ContainSubstring("password=secret-password-value")) // Verify the image exists in buildah's local storage err = container.ExecuteCommand("buildah", "images", outputRef) @@ -2129,11 +2129,11 @@ FROM image.does.not/exist:1 AS stage-after-target container := setupBuildContainerWithCleanup(t, buildParams, nil) - stdout, _, err := runBuildWithOutput(container, buildParams) + _, stderr, err := runBuildWithOutput(container, buildParams) Expect(err).ToNot(HaveOccurred()) // Stage 0 should be built despite not being needed - Expect(stdout).To(ContainSubstring("stage 0 was built")) + Expect(stderr).To(ContainSubstring("stage 0 was built")) // But the target stage should still be "target" imageMeta := getImageMeta(container, outputRef) @@ -2176,12 +2176,11 @@ ADD https://1.1.1.1 /cloudflare-1111.html container := setupBuildContainerWithCleanup(t, buildParams, nil, opts...) - stdout, _, err := runBuildWithOutput(container, buildParams) + _, stderr, err := runBuildWithOutput(container, buildParams) Expect(err).To(HaveOccurred()) - // kbc prints the error to stderr, but we run it via 'podman exec -t', - // which prints everything to stdout - Expect(stdout).To(ContainSubstring("dial tcp 1.1.1.1:443: connect: network is unreachable")) + // kbc prints the error to stderr + Expect(stderr).To(ContainSubstring("dial tcp 1.1.1.1:443: connect: network is unreachable")) } t.Run("AsNonRoot", func(t *testing.T) { @@ -2225,12 +2224,11 @@ RUN if echo > /dev/tcp/8.8.8.8/53; then echo "Has network access!"; exit 1; fi container := setupBuildContainerWithCleanup(t, buildParams, nil, opts...) - stdout, _, err := runBuildWithOutput(container, buildParams) + _, stderr, err := runBuildWithOutput(container, buildParams) Expect(err).ToNot(HaveOccurred()) - // kbc prints the build logs to stderr, but we run it via 'podman exec -t', - // which prints everything to stdout - Expect(stdout).To(ContainSubstring("/dev/tcp/8.8.8.8/53: Network is unreachable")) + // kbc prints the build logs to stderr + Expect(stderr).To(ContainSubstring("/dev/tcp/8.8.8.8/53: Network is unreachable")) } t.Run("AsNonRoot", func(t *testing.T) { @@ -2368,12 +2366,12 @@ RUN cp /random-data.bin /data/realBaseImage.bin container := setupBuildContainerWithCleanup(t, buildParams, imageRegistry) - stdout, _, err := runBuildWithOutput(container, buildParams) + _, stderr, err := runBuildWithOutput(container, buildParams) // Main check: no error (would fail without pre-pulling) Expect(err).ToNot(HaveOccurred()) // Verify that buildah really did build the unused stage (otherwise we wasted a pull) - Expect(stdout).To(ContainSubstring("the unused stage WAS built")) + Expect(stderr).To(ContainSubstring("the unused stage WAS built")) // Verify that the correct base was pulled for each FROM/from // by checking the sizes of the random-data files diff --git a/integration_tests/framework/common.go b/integration_tests/framework/common.go index e430c764..6ba8ca91 100644 --- a/integration_tests/framework/common.go +++ b/integration_tests/framework/common.go @@ -181,6 +181,9 @@ type TestImageConfig struct { // Add a ramdom data file of given size. // Skip generation if the value is not positive. RandomDataSize int64 + // Format of the built image's manifest and metadata (oci or docker) + // If empty, defaults to oci + BuildahFormat string } func CreateTestImage(config TestImageConfig) error { @@ -231,6 +234,13 @@ func CreateTestImage(config TestImageConfig) error { if config.Platform != "" { buildArgs = append(buildArgs, "--platform", config.Platform) } + // --format is only supported by buildah/podman, not docker + if config.BuildahFormat != "" { + if containerTool == "docker" { + return fmt.Errorf("BuildahFormat is not supported with docker, only buildah/podman support --format flag") + } + buildArgs = append(buildArgs, "--format", config.BuildahFormat) + } buildArgs = append(buildArgs, ".") stdout, stderr, _, err := executor.Execute(cliWrappers.Cmd{Name: containerTool, Args: buildArgs, Dir: testImageDir}) if err != nil { diff --git a/integration_tests/framework/registry.go b/integration_tests/framework/registry.go index bee063d6..973774ba 100644 --- a/integration_tests/framework/registry.go +++ b/integration_tests/framework/registry.go @@ -33,8 +33,9 @@ type ImageRegistry interface { } type ImageIndexManifest struct { - MediaType string `json:"mediaType,omitempty"` - Manifests []ImageManifest `json:"manifests,omitempty"` + MediaType string `json:"mediaType,omitempty"` + Manifests []ImageManifest `json:"manifests,omitempty"` + RawManifest []byte `json:"-"` } type ImageManifest struct { MediaType string `json:"mediaType,omitempty"` diff --git a/integration_tests/framework/registry_quay.go b/integration_tests/framework/registry_quay.go index ef54a769..fee444ac 100644 --- a/integration_tests/framework/registry_quay.go +++ b/integration_tests/framework/registry_quay.go @@ -229,6 +229,7 @@ func (z *QuayRegistry) GetImageIndexInfo(repo, tag string) (*ImageIndexManifest, if err := json.Unmarshal(body, imageIndexInfo); err != nil { return nil, fmt.Errorf("error unmarshaling response JSON: %v", err) } + imageIndexInfo.RawManifest = body return imageIndexInfo, nil } diff --git a/integration_tests/framework/registry_zot_local.go b/integration_tests/framework/registry_zot_local.go index 0e679d26..e610f5b3 100644 --- a/integration_tests/framework/registry_zot_local.go +++ b/integration_tests/framework/registry_zot_local.go @@ -295,6 +295,7 @@ func (z *ZotRegistry) GetImageIndexInfo(imageName, tag string) (*ImageIndexManif if err := json.Unmarshal(body, imageIndexInfo); err != nil { return nil, fmt.Errorf("error unmarshaling response JSON: %v", err) } + imageIndexInfo.RawManifest = body return imageIndexInfo, nil } diff --git a/integration_tests/framework/runner_container.go b/integration_tests/framework/runner_container.go index 7f7a9a87..df97e9f9 100644 --- a/integration_tests/framework/runner_container.go +++ b/integration_tests/framework/runner_container.go @@ -333,7 +333,7 @@ func (c *TestRunnerContainer) ExecuteBuildCli(args ...string) error { // stdout, stderr, and error. func (c *TestRunnerContainer) ExecuteCommandWithOutput(command string, args ...string) (string, string, error) { c.ensureContainerRunning() - execArgs := []string{"exec", "-t", c.name} + execArgs := []string{"exec", c.name} execArgs = append(execArgs, command) execArgs = append(execArgs, args...) @@ -362,7 +362,7 @@ func (c *TestRunnerContainer) debugBuildCli(cliArgs ...string) error { return err } - execArgs := []string{"exec", "-t", c.name} + execArgs := []string{"exec", c.name} execArgs = append(execArgs, "dlv", "--listen=0.0.0.0:2345", "--headless=true", "--log=true", "--api-version=2", "exec", "/usr/bin/"+KonfluxBuildCli) if len(cliArgs) > 0 { execArgs = append(execArgs, "--") @@ -406,7 +406,7 @@ func (c *TestRunnerContainer) GetTaskResultValue(resultFilePath string) (string, } func (c *TestRunnerContainer) GetHomeDir() (string, error) { - execCmd := []string{"exec", "-t", c.name, "bash", "-c", "echo -n $HOME"} + execCmd := []string{"exec", c.name, "bash", "-c", "echo -n $HOME"} homeDir, _, _, err := c.executor.Execute(cliWrappers.Command(containerTool, execCmd...)) if err != nil { return "", err diff --git a/pkg/cliwrappers/buildah.go b/pkg/cliwrappers/buildah.go index 77fa4129..ebf7fd5f 100644 --- a/pkg/cliwrappers/buildah.go +++ b/pkg/cliwrappers/buildah.go @@ -21,6 +21,10 @@ type BuildahCliInterface interface { Inspect(args *BuildahInspectArgs) (string, error) InspectImage(name string) (BuildahImageInfo, error) Version() (BuildahVersionInfo, error) + ManifestCreate(args *BuildahManifestCreateArgs) error + ManifestAdd(args *BuildahManifestAddArgs) error + ManifestInspect(args *BuildahManifestInspectArgs) (string, error) + ManifestPush(args *BuildahManifestPushArgs) (string, error) } var _ BuildahCliInterface = &BuildahCli{} @@ -418,3 +422,150 @@ func (b *BuildahCli) Version() (BuildahVersionInfo, error) { return versionInfo, nil } + +type BuildahManifestCreateArgs struct { + ManifestName string +} + +// ManifestCreate creates a new manifest list +func (b *BuildahCli) ManifestCreate(args *BuildahManifestCreateArgs) error { + if args.ManifestName == "" { + return errors.New("manifest name is empty") + } + + buildahArgs := []string{"manifest", "create", args.ManifestName} + + buildahLog.Debugf("Running command:\nbuildah %s", strings.Join(buildahArgs, " ")) + + _, _, _, err := b.Executor.Execute(Cmd{Name: "buildah", Args: buildahArgs, LogOutput: true}) + if err != nil { + buildahLog.Errorf("buildah manifest create failed: %s", err.Error()) + return err + } + + buildahLog.Debug("Manifest create completed successfully") + + return nil +} + +type BuildahManifestAddArgs struct { + ManifestName string + ImageRef string + All bool +} + +// ManifestAdd adds an image to a manifest list +func (b *BuildahCli) ManifestAdd(args *BuildahManifestAddArgs) error { + if args.ManifestName == "" { + return errors.New("manifest name is empty") + } + if args.ImageRef == "" { + return errors.New("image reference is empty") + } + + buildahArgs := []string{"manifest", "add", args.ManifestName, args.ImageRef} + + if args.All { + buildahArgs = append(buildahArgs, "--all") + } + + buildahLog.Debugf("Running command:\nbuildah %s", strings.Join(buildahArgs, " ")) + + _, _, _, err := b.Executor.Execute(Cmd{Name: "buildah", Args: buildahArgs, LogOutput: true}) + if err != nil { + buildahLog.Errorf("buildah manifest add failed: %s", err.Error()) + return err + } + + buildahLog.Debug("Manifest add completed successfully") + + return nil +} + +type BuildahManifestInspectArgs struct { + ManifestName string +} + +// ManifestInspect inspects a manifest list and returns the JSON output +func (b *BuildahCli) ManifestInspect(args *BuildahManifestInspectArgs) (string, error) { + if args.ManifestName == "" { + return "", errors.New("manifest name is empty") + } + + buildahArgs := []string{"manifest", "inspect", args.ManifestName} + + buildahLog.Debugf("Running command:\nbuildah %s", strings.Join(buildahArgs, " ")) + + stdout, _, _, err := b.Executor.Execute(Command("buildah", buildahArgs...)) + if err != nil { + buildahLog.Errorf("buildah manifest inspect failed: %s", err.Error()) + return "", err + } + + buildahLog.Debug("Manifest inspect completed successfully") + + return stdout, nil +} + +type BuildahManifestPushArgs struct { + ManifestName string + Destination string + Format string + TLSVerify bool +} + +// ManifestPush pushes a manifest list to a registry and returns the digest +func (b *BuildahCli) ManifestPush(args *BuildahManifestPushArgs) (string, error) { + if args.ManifestName == "" { + return "", errors.New("manifest name is empty") + } + if args.Destination == "" { + return "", errors.New("destination is empty") + } + + tmpFile, err := os.CreateTemp("", "buildah-manifest-digest-") + if err != nil { + return "", err + } + digestFile := tmpFile.Name() + tmpFile.Close() + defer os.Remove(digestFile) + + buildahArgs := []string{"manifest", "push", "--digestfile", digestFile} + + if args.Format != "" { + buildahArgs = append(buildahArgs, "--format", args.Format) + } + + if args.TLSVerify { + buildahArgs = append(buildahArgs, "--tls-verify=true") + } else { + buildahArgs = append(buildahArgs, "--tls-verify=false") + } + + buildahArgs = append(buildahArgs, args.ManifestName, args.Destination) + + buildahLog.Debugf("Running command:\nbuildah %s", strings.Join(buildahArgs, " ")) + + retryer := NewRetryer(func() (string, string, int, error) { + return b.Executor.Execute(Cmd{Name: "buildah", Args: buildahArgs, LogOutput: true}) + }).WithImageRegistryPreset(). + StopIfOutputContains("unauthorized"). + StopIfOutputContains("authentication required") + + _, _, _, err = retryer.Run() + if err != nil { + buildahLog.Errorf("buildah manifest push failed: %s", err.Error()) + return "", err + } + + buildahLog.Debug("Manifest push completed successfully") + + content, err := os.ReadFile(digestFile) + if err != nil { + return "", err + } + + digest := strings.TrimSpace(string(content)) + return digest, nil +} diff --git a/pkg/cliwrappers/buildah_test.go b/pkg/cliwrappers/buildah_test.go index 073c93ea..2739e49d 100644 --- a/pkg/cliwrappers/buildah_test.go +++ b/pkg/cliwrappers/buildah_test.go @@ -806,3 +806,338 @@ func TestBuildahBuildArgs_Validate(t *testing.T) { g.Expect(err.Error()).To(Equal("':' in volume mount target path: other:dir")) }) } + +func TestBuildahCli_ManifestCreate(t *testing.T) { + g := NewWithT(t) + + const manifestName = "quay.io/org/myapp:latest" + + t.Run("should create manifest", func(t *testing.T) { + buildahCli, executor := setupBuildahCli() + var capturedArgs []string + executor.executeFunc = func(cmd cliwrappers.Cmd) (string, string, int, error) { + g.Expect(cmd.Name).To(Equal("buildah")) + g.Expect(cmd.LogOutput).To(BeTrue()) + capturedArgs = cmd.Args + return "", "", 0, nil + } + + args := &cliwrappers.BuildahManifestCreateArgs{ + ManifestName: manifestName, + } + + err := buildahCli.ManifestCreate(args) + + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(capturedArgs).To(Equal([]string{"manifest", "create", manifestName})) + }) + + t.Run("should error if manifest name is empty", func(t *testing.T) { + buildahCli, _ := setupBuildahCli() + args := &cliwrappers.BuildahManifestCreateArgs{ + ManifestName: "", + } + + err := buildahCli.ManifestCreate(args) + + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("manifest name is empty")) + }) + + t.Run("should error if buildah execution fails", func(t *testing.T) { + buildahCli, executor := setupBuildahCli() + executor.executeFunc = func(cmd cliwrappers.Cmd) (string, string, int, error) { + return "", "", 1, errors.New("failed to create manifest") + } + + args := &cliwrappers.BuildahManifestCreateArgs{ + ManifestName: manifestName, + } + + err := buildahCli.ManifestCreate(args) + + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(Equal("failed to create manifest")) + }) +} + +func TestBuildahCli_ManifestAdd(t *testing.T) { + g := NewWithT(t) + + const manifestName = "quay.io/org/myapp:latest" + const imageRef = "docker://quay.io/org/myapp@sha256:abc123" + + t.Run("should add image to manifest", func(t *testing.T) { + buildahCli, executor := setupBuildahCli() + var capturedArgs []string + executor.executeFunc = func(cmd cliwrappers.Cmd) (string, string, int, error) { + g.Expect(cmd.Name).To(Equal("buildah")) + g.Expect(cmd.LogOutput).To(BeTrue()) + capturedArgs = cmd.Args + return "", "", 0, nil + } + + args := &cliwrappers.BuildahManifestAddArgs{ + ManifestName: manifestName, + ImageRef: imageRef, + All: true, + } + + err := buildahCli.ManifestAdd(args) + + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(capturedArgs).To(Equal([]string{"manifest", "add", manifestName, imageRef, "--all"})) + }) + + t.Run("should add image without --all flag", func(t *testing.T) { + buildahCli, executor := setupBuildahCli() + var capturedArgs []string + executor.executeFunc = func(cmd cliwrappers.Cmd) (string, string, int, error) { + g.Expect(cmd.Name).To(Equal("buildah")) + g.Expect(cmd.LogOutput).To(BeTrue()) + capturedArgs = cmd.Args + return "", "", 0, nil + } + + args := &cliwrappers.BuildahManifestAddArgs{ + ManifestName: manifestName, + ImageRef: imageRef, + All: false, + } + + err := buildahCli.ManifestAdd(args) + + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(capturedArgs).To(Equal([]string{"manifest", "add", manifestName, imageRef})) + }) + + t.Run("should error if manifest name is empty", func(t *testing.T) { + buildahCli, _ := setupBuildahCli() + args := &cliwrappers.BuildahManifestAddArgs{ + ManifestName: "", + ImageRef: imageRef, + } + + err := buildahCli.ManifestAdd(args) + + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("manifest name is empty")) + }) + + t.Run("should error if image reference is empty", func(t *testing.T) { + buildahCli, _ := setupBuildahCli() + args := &cliwrappers.BuildahManifestAddArgs{ + ManifestName: manifestName, + ImageRef: "", + } + + err := buildahCli.ManifestAdd(args) + + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("image reference is empty")) + }) + + t.Run("should error if buildah execution fails", func(t *testing.T) { + buildahCli, executor := setupBuildahCli() + executor.executeFunc = func(cmd cliwrappers.Cmd) (string, string, int, error) { + return "", "", 1, errors.New("failed to add image") + } + + args := &cliwrappers.BuildahManifestAddArgs{ + ManifestName: manifestName, + ImageRef: imageRef, + All: true, + } + + err := buildahCli.ManifestAdd(args) + + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(Equal("failed to add image")) + }) +} + +func TestBuildahCli_ManifestInspect(t *testing.T) { + g := NewWithT(t) + + const manifestName = "quay.io/org/myapp:latest" + const manifestJSON = `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json"}` + + t.Run("should inspect manifest and return JSON", func(t *testing.T) { + buildahCli, executor := setupBuildahCli() + var capturedArgs []string + executor.executeFunc = func(cmd cliwrappers.Cmd) (string, string, int, error) { + g.Expect(cmd.Name).To(Equal("buildah")) + capturedArgs = cmd.Args + return manifestJSON, "", 0, nil + } + + args := &cliwrappers.BuildahManifestInspectArgs{ + ManifestName: manifestName, + } + + result, err := buildahCli.ManifestInspect(args) + + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(capturedArgs).To(Equal([]string{"manifest", "inspect", manifestName})) + g.Expect(result).To(Equal(manifestJSON)) + }) + + t.Run("should error if manifest name is empty", func(t *testing.T) { + buildahCli, _ := setupBuildahCli() + args := &cliwrappers.BuildahManifestInspectArgs{ + ManifestName: "", + } + + _, err := buildahCli.ManifestInspect(args) + + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("manifest name is empty")) + }) + + t.Run("should error if buildah execution fails", func(t *testing.T) { + buildahCli, executor := setupBuildahCli() + executor.executeFunc = func(cmd cliwrappers.Cmd) (string, string, int, error) { + return "", "", 1, errors.New("failed to inspect manifest") + } + + args := &cliwrappers.BuildahManifestInspectArgs{ + ManifestName: manifestName, + } + + _, err := buildahCli.ManifestInspect(args) + + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(Equal("failed to inspect manifest")) + }) +} + +func TestBuildahCli_ManifestPush(t *testing.T) { + g := NewWithT(t) + + const manifestName = "quay.io/org/myapp:latest" + const destination = "docker://quay.io/org/myapp:latest" + const digest = "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + + ensureRetryerDisabled(t) + + mockSuccessfulManifestPush := func(captureArgs *[]string) func(cmd cliwrappers.Cmd) (string, string, int, error) { + return func(cmd cliwrappers.Cmd) (string, string, int, error) { + g.Expect(cmd.Name).To(Equal("buildah")) + g.Expect(cmd.LogOutput).To(BeTrue()) + *captureArgs = cmd.Args + + digestFile := findDigestFile(cmd.Args) + g.Expect(digestFile).ToNot(BeEmpty()) + + os.WriteFile(digestFile, []byte(digest), 0644) + + return "", "", 0, nil + } + } + + t.Run("should push manifest with default options", func(t *testing.T) { + buildahCli, executor := setupBuildahCli() + var capturedArgs []string + executor.executeFunc = mockSuccessfulManifestPush(&capturedArgs) + + args := &cliwrappers.BuildahManifestPushArgs{ + ManifestName: manifestName, + Destination: destination, + TLSVerify: true, + } + + returnedDigest, err := buildahCli.ManifestPush(args) + + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(capturedArgs[0]).To(Equal("manifest")) + g.Expect(capturedArgs[1]).To(Equal("push")) + expectArgAndValue(g, capturedArgs, "--digestfile", findDigestFile(capturedArgs)) + g.Expect(capturedArgs).To(ContainElement("--tls-verify=true")) + g.Expect(capturedArgs[len(capturedArgs)-2]).To(Equal(manifestName)) + g.Expect(capturedArgs[len(capturedArgs)-1]).To(Equal(destination)) + g.Expect(returnedDigest).To(Equal(digest)) + }) + + t.Run("should push manifest with format and retry", func(t *testing.T) { + buildahCli, executor := setupBuildahCli() + var capturedArgs []string + executor.executeFunc = mockSuccessfulManifestPush(&capturedArgs) + + args := &cliwrappers.BuildahManifestPushArgs{ + ManifestName: manifestName, + Destination: destination, + Format: "oci", + TLSVerify: false, + } + + _, err := buildahCli.ManifestPush(args) + + g.Expect(err).ToNot(HaveOccurred()) + expectArgAndValue(g, capturedArgs, "--format", "oci") + g.Expect(capturedArgs).To(ContainElement("--tls-verify=false")) + }) + + t.Run("should error if manifest name is empty", func(t *testing.T) { + buildahCli, _ := setupBuildahCli() + args := &cliwrappers.BuildahManifestPushArgs{ + ManifestName: "", + Destination: destination, + } + + _, err := buildahCli.ManifestPush(args) + + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("manifest name is empty")) + }) + + t.Run("should error if destination is empty", func(t *testing.T) { + buildahCli, _ := setupBuildahCli() + args := &cliwrappers.BuildahManifestPushArgs{ + ManifestName: manifestName, + Destination: "", + } + + _, err := buildahCli.ManifestPush(args) + + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("destination is empty")) + }) + + t.Run("should error if buildah execution fails", func(t *testing.T) { + buildahCli, executor := setupBuildahCli() + executor.executeFunc = func(cmd cliwrappers.Cmd) (string, string, int, error) { + return "", "", 1, errors.New("failed to push manifest") + } + + args := &cliwrappers.BuildahManifestPushArgs{ + ManifestName: manifestName, + Destination: destination, + } + + _, err := buildahCli.ManifestPush(args) + + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(Equal("failed to push manifest")) + }) + + t.Run("should clean up digest file after push", func(t *testing.T) { + buildahCli, executor := setupBuildahCli() + var capturedArgs []string + executor.executeFunc = mockSuccessfulManifestPush(&capturedArgs) + + args := &cliwrappers.BuildahManifestPushArgs{ + ManifestName: manifestName, + Destination: destination, + } + + _, err := buildahCli.ManifestPush(args) + + g.Expect(err).ToNot(HaveOccurred()) + + digestFile := findDigestFile(capturedArgs) + g.Expect(digestFile).ToNot(BeEmpty()) + + _, statErr := os.Stat(digestFile) + g.Expect(os.IsNotExist(statErr)).To(BeTrue(), "digest file should be cleaned up") + }) +} diff --git a/pkg/commands/build_image_index.go b/pkg/commands/build_image_index.go new file mode 100644 index 00000000..7ddf00c5 --- /dev/null +++ b/pkg/commands/build_image_index.go @@ -0,0 +1,426 @@ +package commands + +import ( + "encoding/json" + "fmt" + "os" + "reflect" + "strings" + + "github.com/spf13/cobra" + + "github.com/konflux-ci/konflux-build-cli/pkg/cliwrappers" + "github.com/konflux-ci/konflux-build-cli/pkg/common" + l "github.com/konflux-ci/konflux-build-cli/pkg/logger" +) + +var BuildImageIndexParamsConfig = map[string]common.Parameter{ + "image": { + Name: "image", + ShortName: "i", + EnvVarName: "KBC_BUILD_IMAGE_INDEX_IMAGE", + TypeKind: reflect.String, + Usage: "The target image and tag where the image will be pushed to.", + Required: true, + }, + "images": { + Name: "images", + ShortName: "", + EnvVarName: "KBC_BUILD_IMAGE_INDEX_IMAGES", + TypeKind: reflect.Slice, + Usage: "List of Image Manifests to be referenced by the Image Index.", + Required: true, + }, + "tls-verify": { + Name: "tls-verify", + ShortName: "", + EnvVarName: "KBC_BUILD_IMAGE_INDEX_TLS_VERIFY", + TypeKind: reflect.Bool, + DefaultValue: "true", + Usage: "Verify the TLS on the registry endpoint (for push/pull to a non-TLS registry).", + }, + "buildah-format": { + Name: "buildah-format", + ShortName: "", + EnvVarName: "KBC_BUILD_IMAGE_INDEX_BUILDAH_FORMAT", + TypeKind: reflect.String, + DefaultValue: "oci", + Usage: "The format for the resulting image's mediaType. Valid values are oci (default) or docker.", + }, + "always-build-index": { + Name: "always-build-index", + ShortName: "", + EnvVarName: "KBC_BUILD_IMAGE_INDEX_ALWAYS_BUILD_INDEX", + TypeKind: reflect.Bool, + DefaultValue: "true", + Usage: "Force creation of image index even with a single image.", + }, + "additional-tags": { + Name: "additional-tags", + ShortName: "", + EnvVarName: "KBC_BUILD_IMAGE_INDEX_ADDITIONAL_TAGS", + TypeKind: reflect.Slice, + Usage: "Additional tags to push the image index to (e.g., taskrun name, commit sha).", + }, + "output-manifest-path": { + Name: "output-manifest-path", + ShortName: "", + EnvVarName: "KBC_BUILD_IMAGE_INDEX_OUTPUT_MANIFEST_PATH", + TypeKind: reflect.String, + Usage: "Path where the manifest JSON will be written for SBOM generation.", + }, + "result-path-image-digest": { + Name: "result-path-image-digest", + ShortName: "", + EnvVarName: "KBC_BUILD_IMAGE_INDEX_RESULT_PATH_IMAGE_DIGEST", + TypeKind: reflect.String, + Usage: "Write the image digest into this file.", + }, + "result-path-image-url": { + Name: "result-path-image-url", + ShortName: "", + EnvVarName: "KBC_BUILD_IMAGE_INDEX_RESULT_PATH_IMAGE_URL", + TypeKind: reflect.String, + Usage: "Write the image URL into this file.", + }, + "result-path-image-ref": { + Name: "result-path-image-ref", + ShortName: "", + EnvVarName: "KBC_BUILD_IMAGE_INDEX_RESULT_PATH_IMAGE_REF", + TypeKind: reflect.String, + Usage: "Write the image reference (with digest) into this file.", + }, + "result-path-images": { + Name: "result-path-images", + ShortName: "", + EnvVarName: "KBC_BUILD_IMAGE_INDEX_RESULT_PATH_IMAGES", + TypeKind: reflect.String, + Usage: "Write the comma-separated list of platform images into this file.", + }, +} + +type BuildImageIndexParams struct { + Image string `paramName:"image"` + Images []string `paramName:"images"` + TLSVerify bool `paramName:"tls-verify"` + BuildahFormat string `paramName:"buildah-format"` + AlwaysBuildIndex bool `paramName:"always-build-index"` + AdditionalTags []string `paramName:"additional-tags"` + OutputManifestPath string `paramName:"output-manifest-path"` + ResultPathImageDigest string `paramName:"result-path-image-digest"` + ResultPathImageURL string `paramName:"result-path-image-url"` + ResultPathImageRef string `paramName:"result-path-image-ref"` + ResultPathImages string `paramName:"result-path-images"` +} + +type BuildImageIndexResults struct { + // Digest of the image just built (e.g., "sha256:abc123...") + ImageDigest string `json:"image_digest"` + // Image repository and tag where the built image was pushed (e.g., "quay.io/org/repo:tag") + ImageURL string `json:"image_url"` + // Image reference of the built image containing both the repository and the digest (e.g., "quay.io/org/repo@sha256:abc123...") + ImageRef string `json:"image_ref"` + // Comma-separated list of all referenced image manifests with digests (e.g., "repo@sha256:aaa,repo@sha256:bbb") + Images string `json:"images"` +} + +type BuildImageIndexCliWrappers struct { + BuildahCli cliwrappers.BuildahCliInterface +} + +type BuildImageIndex struct { + Params *BuildImageIndexParams + CliWrappers BuildImageIndexCliWrappers + Results BuildImageIndexResults + ResultsWriter common.ResultsWriterInterface + + imageName string + imageDigest string + images []string +} + +func NewBuildImageIndex(cmd *cobra.Command) (*BuildImageIndex, error) { + params := &BuildImageIndexParams{} + if err := common.ParseParameters(cmd, BuildImageIndexParamsConfig, params); err != nil { + return nil, err + } + + buildImageIndex := &BuildImageIndex{ + Params: params, + ResultsWriter: common.NewResultsWriter(), + } + + if err := buildImageIndex.initCliWrappers(); err != nil { + return nil, err + } + + return buildImageIndex, nil +} + +func (c *BuildImageIndex) initCliWrappers() error { + executor := cliwrappers.NewCliExecutor() + + buildahCli, err := cliwrappers.NewBuildahCli(executor) + if err != nil { + return err + } + c.CliWrappers.BuildahCli = buildahCli + + return nil +} + +func (c *BuildImageIndex) Run() error { + common.LogParameters(BuildImageIndexParamsConfig, c.Params) + + if err := c.validateParams(); err != nil { + return err + } + + c.imageName = common.GetImageName(c.Params.Image) + + if err := c.buildManifestIndex(); err != nil { + return fmt.Errorf("failed to build image index: %w", err) + } + + c.Results.ImageDigest = c.imageDigest + c.Results.ImageURL = c.Params.Image + c.Results.ImageRef = c.imageName + "@" + c.imageDigest + c.Results.Images = strings.Join(c.images, ",") + + if resultsJson, err := c.ResultsWriter.CreateResultJson(c.Results); err == nil { + fmt.Print(resultsJson) + } else { + return fmt.Errorf("failed to create results JSON: %w", err) + } + + // Write individual results to files if paths are provided + if c.Params.ResultPathImageDigest != "" { + if err := c.ResultsWriter.WriteResultString(c.Results.ImageDigest, c.Params.ResultPathImageDigest); err != nil { + return fmt.Errorf("failed to write image digest result: %w", err) + } + } + + if c.Params.ResultPathImageURL != "" { + if err := c.ResultsWriter.WriteResultString(c.Results.ImageURL, c.Params.ResultPathImageURL); err != nil { + return fmt.Errorf("failed to write image URL result: %w", err) + } + } + + if c.Params.ResultPathImageRef != "" { + if err := c.ResultsWriter.WriteResultString(c.Results.ImageRef, c.Params.ResultPathImageRef); err != nil { + return fmt.Errorf("failed to write image ref result: %w", err) + } + } + + if c.Params.ResultPathImages != "" { + if err := c.ResultsWriter.WriteResultString(c.Results.Images, c.Params.ResultPathImages); err != nil { + return fmt.Errorf("failed to write images result: %w", err) + } + } + + return nil +} + +func (c *BuildImageIndex) buildManifestIndex() error { + l.Logger.Infof("Creating manifest list: %s", c.Params.Image) + err := c.CliWrappers.BuildahCli.ManifestCreate(&cliwrappers.BuildahManifestCreateArgs{ + ManifestName: c.Params.Image, + }) + if err != nil { + return err + } + + for _, imageRef := range c.Params.Images { + // Special case: single image with always-build-index=false + if !c.Params.AlwaysBuildIndex && len(c.Params.Images) == 1 { + l.Logger.Info("Skipping image index generation. Returning results for single image.") + c.images = []string{imageRef} + c.imageDigest = common.GetImageDigest(imageRef) + return nil + } + + l.Logger.Infof("Adding image to manifest: %s", imageRef) + err = c.CliWrappers.BuildahCli.ManifestAdd(&cliwrappers.BuildahManifestAddArgs{ + ManifestName: c.Params.Image, + ImageRef: imageRef, + All: true, + }) + if err != nil { + return fmt.Errorf("failed to add image %s: %w", imageRef, err) + } + } + + manifestJson, err := c.CliWrappers.BuildahCli.ManifestInspect(&cliwrappers.BuildahManifestInspectArgs{ + ManifestName: c.Params.Image, + }) + if err != nil { + return err + } + + l.Logger.Info("Validating format consistency") + if err := c.validateFormatConsistency(manifestJson); err != nil { + return err + } + + l.Logger.Infof("Pushing image index to registry: %s", c.Params.Image) + + digest, err := c.CliWrappers.BuildahCli.ManifestPush(&cliwrappers.BuildahManifestPushArgs{ + ManifestName: c.Params.Image, + Destination: "docker://" + c.Params.Image, + Format: c.Params.BuildahFormat, + TLSVerify: c.Params.TLSVerify, + }) + if err != nil { + return fmt.Errorf("failed to push manifest: %w", err) + } + + c.imageDigest = digest + l.Logger.Infof("Manifest pushed successfully with digest: %s", digest) + + if len(c.Params.AdditionalTags) > 0 { + for _, tag := range c.Params.AdditionalTags { + additionalImage := c.imageName + ":" + tag + l.Logger.Infof("Pushing manifest to additional tag: %s", additionalImage) + + _, err := c.CliWrappers.BuildahCli.ManifestPush(&cliwrappers.BuildahManifestPushArgs{ + ManifestName: c.Params.Image, + Destination: "docker://" + additionalImage, + Format: c.Params.BuildahFormat, + TLSVerify: c.Params.TLSVerify, + }) + if err != nil { + return fmt.Errorf("failed to push manifest to additional tag %s: %w", additionalImage, err) + } + l.Logger.Infof("Manifest pushed successfully to %s", additionalImage) + } + } + + platformImages, err := c.extractPlatformImages(manifestJson) + if err != nil { + return fmt.Errorf("failed to extract platform images: %w", err) + } + c.images = platformImages + + if c.Params.OutputManifestPath != "" { + if err := os.WriteFile(c.Params.OutputManifestPath, []byte(manifestJson), 0644); err != nil { + return fmt.Errorf("failed to write manifest file: %w", err) + } + l.Logger.Infof("Manifest data saved to %s", c.Params.OutputManifestPath) + } + + return nil +} + +func (c *BuildImageIndex) validateParams() error { + imageName := common.GetImageName(c.Params.Image) + if !common.IsImageNameValid(imageName) { + return fmt.Errorf("image name '%s' is invalid", c.Params.Image) + } + + if err := common.ValidateImageHasTagOrDigest(c.Params.Image); err != nil { + return fmt.Errorf("invalid image parameter: %w", err) + } + + if len(c.Params.Images) == 0 { + return fmt.Errorf("at least one image must be provided via --images") + } + + // Validate each image reference and check for duplicates + seenImages := make(map[string]bool) + for _, img := range c.Params.Images { + imgName := common.GetImageName(img) + if !common.IsImageNameValid(imgName) { + return fmt.Errorf("invalid image reference: %s", img) + } + + if err := common.ValidateImageHasTagOrDigest(img); err != nil { + return fmt.Errorf("invalid image parameter: %w", err) + } + + // Check for duplicates + if seenImages[img] { + return fmt.Errorf("duplicate image reference: %s", img) + } + seenImages[img] = true + } + + for _, tag := range c.Params.AdditionalTags { + if !common.IsImageTagValid(tag) { + return fmt.Errorf("invalid additional tag: %s", tag) + } + } + + validFormats := map[string]bool{"oci": true, "docker": true} + if !validFormats[c.Params.BuildahFormat] { + return fmt.Errorf("format must be 'oci' or 'docker', got '%s'", c.Params.BuildahFormat) + } + + return nil +} + +func (c *BuildImageIndex) validateFormatConsistency(manifestJson string) error { + var manifest struct { + Manifests []struct { + MediaType string `json:"mediaType"` + } `json:"manifests"` + } + + if err := json.Unmarshal([]byte(manifestJson), &manifest); err != nil { + return fmt.Errorf("failed to parse manifest JSON: %w", err) + } + + // Determine incompatible format string based on target format + incompatibleString := "vnd.oci.image.manifest" + incompatibleName := "oci" + if c.Params.BuildahFormat == "oci" { + incompatibleString = "vnd.docker.distribution.manifest" + incompatibleName = "docker" + } + + // Check if any manifest has incompatible format + for _, m := range manifest.Manifests { + if strings.Contains(m.MediaType, incompatibleString) { + return fmt.Errorf( + "platform image contains %s format, but index will be %s. "+ + "This will cause digest changes and break SBOM accessibility. "+ + "Ensure all platform images are built with buildah format: %s", + incompatibleName, c.Params.BuildahFormat, c.Params.BuildahFormat) + } + } + + return nil +} + +// extractPlatformImages extracts platform image references from the manifest list JSON. +// Returns a list of image references in the format: @ +// +// Note: The OCI/Docker manifest list spec does not preserve the original repository names +// of the platform images that were added to the index. Therefore, all returned image references +// use the index repository name (c.imageName), not the original platform image repository names. +// +// For example, if platform images were pushed as: +// - quay.io/myapp-platform1@sha256:aaa... +// - quay.io/myapp-platform2@sha256:bbb... +// +// The returned references will be: +// - quay.io/myapp@sha256:aaa... +// - quay.io/myapp@sha256:bbb... +func (c *BuildImageIndex) extractPlatformImages(manifestJson string) ([]string, error) { + l.Logger.Infof("DEBUG: Full manifest JSON:\n%s", manifestJson) + var manifest struct { + Manifests []struct { + Digest string `json:"digest"` + } `json:"manifests"` + } + + if err := json.Unmarshal([]byte(manifestJson), &manifest); err != nil { + return nil, fmt.Errorf("failed to parse manifest JSON: %w", err) + } + + var platformImages []string + for _, m := range manifest.Manifests { + platformImages = append(platformImages, c.imageName+"@"+m.Digest) + } + + return platformImages, nil +} diff --git a/pkg/commands/build_image_index_test.go b/pkg/commands/build_image_index_test.go new file mode 100644 index 00000000..58b3d60d --- /dev/null +++ b/pkg/commands/build_image_index_test.go @@ -0,0 +1,343 @@ +package commands + +import ( + "testing" + + . "github.com/onsi/gomega" +) + +func Test_BuildImageIndex_validateParams(t *testing.T) { + g := NewWithT(t) + + const validDigest1 = "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + const validDigest2 = "sha256:fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321" + + tests := []struct { + name string + params BuildImageIndexParams + errExpected bool + errSubstring string + }{ + { + name: "should allow valid parameters", + params: BuildImageIndexParams{ + Image: "quay.io/org/myapp:latest", + Images: []string{"quay.io/org/myapp@" + validDigest1}, + BuildahFormat: "oci", + AlwaysBuildIndex: true, + }, + errExpected: false, + }, + { + name: "should allow multiple images", + params: BuildImageIndexParams{ + Image: "quay.io/org/myapp:latest", + Images: []string{ + "quay.io/org/myapp@" + validDigest1, + "quay.io/org/myapp@" + validDigest2, + }, + BuildahFormat: "oci", + AlwaysBuildIndex: true, + }, + errExpected: false, + }, + { + name: "should allow docker format", + params: BuildImageIndexParams{ + Image: "quay.io/org/myapp:latest", + Images: []string{"quay.io/org/myapp@" + validDigest1}, + BuildahFormat: "docker", + AlwaysBuildIndex: true, + }, + errExpected: false, + }, + { + name: "should fail on invalid image name", + params: BuildImageIndexParams{ + Image: "Invalid Image Name", + Images: []string{"quay.io/org/myapp@sha256:abc123"}, + BuildahFormat: "oci", + }, + errExpected: true, + errSubstring: "image name.*is invalid", + }, + { + name: "should fail on empty images list", + params: BuildImageIndexParams{ + Image: "quay.io/org/myapp:latest", + Images: []string{}, + BuildahFormat: "oci", + }, + errExpected: true, + errSubstring: "at least one image must be provided", + }, + { + name: "should fail on invalid format", + params: BuildImageIndexParams{ + Image: "quay.io/org/myapp:latest", + Images: []string{"quay.io/org/myapp@" + validDigest1}, + BuildahFormat: "invalid", + }, + errExpected: true, + errSubstring: "format must be 'oci' or 'docker'", + }, + { + name: "should fail on invalid image reference in images list", + params: BuildImageIndexParams{ + Image: "quay.io/org/myapp:latest", + Images: []string{"Invalid Image Ref"}, + BuildahFormat: "oci", + }, + errExpected: true, + errSubstring: "invalid image reference", + }, + { + name: "should allow single image with always-build-index false", + params: BuildImageIndexParams{ + Image: "quay.io/org/myapp:latest", + Images: []string{"quay.io/org/myapp@" + validDigest1}, + BuildahFormat: "oci", + AlwaysBuildIndex: false, + }, + errExpected: false, + }, + { + name: "should succeed with single image and always-build-index true", + params: BuildImageIndexParams{ + Image: "quay.io/org/myapp:latest", + Images: []string{"quay.io/org/myapp@" + validDigest1}, + BuildahFormat: "oci", + AlwaysBuildIndex: true, + }, + errExpected: false, + }, + { + name: "should fail when image has no tag or digest", + params: BuildImageIndexParams{ + Image: "quay.io/org/myapp", + Images: []string{"quay.io/org/myapp@" + validDigest1}, + BuildahFormat: "oci", + }, + errExpected: true, + errSubstring: "must have a tag or digest", + }, + { + name: "should succeed when image has digest", + params: BuildImageIndexParams{ + Image: "quay.io/org/myapp@" + validDigest1, + Images: []string{"quay.io/org/myapp@" + validDigest1}, + BuildahFormat: "oci", + }, + errExpected: false, + }, + { + name: "should succeed when image has both tag and digest", + params: BuildImageIndexParams{ + Image: "quay.io/org/myapp:latest@" + validDigest1, + Images: []string{"quay.io/org/myapp@" + validDigest1}, + BuildahFormat: "oci", + }, + errExpected: false, + }, + { + name: "should fail on duplicate images", + params: BuildImageIndexParams{ + Image: "quay.io/org/myapp:latest", + Images: []string{ + "quay.io/org/myapp@" + validDigest1, + "quay.io/org/myapp@" + validDigest2, + "quay.io/org/myapp@" + validDigest1, + }, + BuildahFormat: "oci", + }, + errExpected: true, + errSubstring: "duplicate image reference", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + c := &BuildImageIndex{Params: &tc.params} + + err := c.validateParams() + + if tc.errExpected { + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(MatchRegexp(tc.errSubstring)) + } else { + g.Expect(err).ToNot(HaveOccurred()) + } + }) + } +} + +func Test_BuildImageIndex_validateFormatConsistency(t *testing.T) { + g := NewWithT(t) + + tests := []struct { + name string + format string + manifestJson string + errExpected bool + errSubstring string + }{ + { + name: "should allow OCI format with OCI manifests", + format: "oci", + manifestJson: `{ + "manifests": [ + {"mediaType": "application/vnd.oci.image.manifest.v1+json"}, + {"mediaType": "application/vnd.oci.image.manifest.v1+json"} + ] + }`, + errExpected: false, + }, + { + name: "should allow docker format with docker manifests", + format: "docker", + manifestJson: `{ + "manifests": [ + {"mediaType": "application/vnd.docker.distribution.manifest.v2+json"}, + {"mediaType": "application/vnd.docker.distribution.manifest.v2+json"} + ] + }`, + errExpected: false, + }, + { + name: "should fail on docker manifests when format is oci", + format: "oci", + manifestJson: `{ + "manifests": [ + {"mediaType": "application/vnd.docker.distribution.manifest.v2+json"} + ] + }`, + errExpected: true, + errSubstring: "platform image contains docker format, but index will be oci", + }, + { + name: "should fail on oci manifests when format is docker", + format: "docker", + manifestJson: `{ + "manifests": [ + {"mediaType": "application/vnd.oci.image.manifest.v1+json"} + ] + }`, + errExpected: true, + errSubstring: "platform image contains oci format, but index will be docker", + }, + { + name: "should fail on mixed formats with oci target", + format: "oci", + manifestJson: `{ + "manifests": [ + {"mediaType": "application/vnd.oci.image.manifest.v1+json"}, + {"mediaType": "application/vnd.docker.distribution.manifest.v2+json"} + ] + }`, + errExpected: true, + errSubstring: "platform image contains docker format", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + c := &BuildImageIndex{ + Params: &BuildImageIndexParams{ + BuildahFormat: tc.format, + }, + } + + err := c.validateFormatConsistency(tc.manifestJson) + + if tc.errExpected { + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring(tc.errSubstring)) + } else { + g.Expect(err).ToNot(HaveOccurred()) + } + }) + } +} + +func Test_BuildImageIndex_extractPlatformImages(t *testing.T) { + g := NewWithT(t) + + const digest1 = "sha256:aaa111aaa111aaa111aaa111aaa111aaa111aaa111aaa111aaa111aaa111aaa1" + const digest2 = "sha256:bbb222bbb222bbb222bbb222bbb222bbb222bbb222bbb222bbb222bbb222bbb2" + + tests := []struct { + name string + imageName string + manifestJson string + expected []string + errExpected bool + errSubstring string + }{ + { + name: "should extract multiple platform images", + imageName: "quay.io/org/myapp", + manifestJson: `{ + "manifests": [ + {"digest": "` + digest1 + `"}, + {"digest": "` + digest2 + `"} + ] + }`, + expected: []string{ + "quay.io/org/myapp@" + digest1, + "quay.io/org/myapp@" + digest2, + }, + errExpected: false, + }, + { + name: "should extract single platform image", + imageName: "quay.io/org/myapp", + manifestJson: `{ + "manifests": [ + {"digest": "` + digest1 + `"} + ] + }`, + expected: []string{ + "quay.io/org/myapp@" + digest1, + }, + errExpected: false, + }, + { + name: "should handle different repository", + imageName: "docker.io/library/nginx", + manifestJson: `{ + "manifests": [ + {"digest": "` + digest1 + `"} + ] + }`, + expected: []string{ + "docker.io/library/nginx@" + digest1, + }, + errExpected: false, + }, + { + name: "should error on invalid JSON", + imageName: "quay.io/org/myapp", + manifestJson: `{invalid json`, + errExpected: true, + errSubstring: "failed to parse manifest JSON", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + c := &BuildImageIndex{ + imageName: tc.imageName, + } + + platformImages, err := c.extractPlatformImages(tc.manifestJson) + + if tc.errExpected { + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring(tc.errSubstring)) + } else { + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(platformImages).To(Equal(tc.expected)) + } + }) + } +} diff --git a/pkg/commands/cli_mocks_test.go b/pkg/commands/cli_mocks_test.go index d7e83939..5ed0317f 100644 --- a/pkg/commands/cli_mocks_test.go +++ b/pkg/commands/cli_mocks_test.go @@ -28,12 +28,16 @@ func (m *mockSkopeoCli) Inspect(args *cliwrappers.SkopeoInspectArgs) (string, er var _ cliwrappers.BuildahCliInterface = &mockBuildahCli{} type mockBuildahCli struct { - BuildFunc func(args *cliwrappers.BuildahBuildArgs) error - PushFunc func(args *cliwrappers.BuildahPushArgs) (string, error) - PullFunc func(args *cliwrappers.BuildahPullArgs) error - InspectFunc func(args *cliwrappers.BuildahInspectArgs) (string, error) - InspectImageFunc func(name string) (cliwrappers.BuildahImageInfo, error) - VersionFunc func() (cliwrappers.BuildahVersionInfo, error) + BuildFunc func(args *cliwrappers.BuildahBuildArgs) error + PushFunc func(args *cliwrappers.BuildahPushArgs) (string, error) + PullFunc func(args *cliwrappers.BuildahPullArgs) error + InspectFunc func(args *cliwrappers.BuildahInspectArgs) (string, error) + InspectImageFunc func(name string) (cliwrappers.BuildahImageInfo, error) + VersionFunc func() (cliwrappers.BuildahVersionInfo, error) + ManifestCreateFunc func(args *cliwrappers.BuildahManifestCreateArgs) error + ManifestAddFunc func(args *cliwrappers.BuildahManifestAddArgs) error + ManifestInspectFunc func(args *cliwrappers.BuildahManifestInspectArgs) (string, error) + ManifestPushFunc func(args *cliwrappers.BuildahManifestPushArgs) (string, error) } func (m *mockBuildahCli) Build(args *cliwrappers.BuildahBuildArgs) error { @@ -78,6 +82,34 @@ func (m *mockBuildahCli) Version() (cliwrappers.BuildahVersionInfo, error) { return cliwrappers.BuildahVersionInfo{}, nil } +func (m *mockBuildahCli) ManifestCreate(args *cliwrappers.BuildahManifestCreateArgs) error { + if m.ManifestCreateFunc != nil { + return m.ManifestCreateFunc(args) + } + return nil +} + +func (m *mockBuildahCli) ManifestAdd(args *cliwrappers.BuildahManifestAddArgs) error { + if m.ManifestAddFunc != nil { + return m.ManifestAddFunc(args) + } + return nil +} + +func (m *mockBuildahCli) ManifestInspect(args *cliwrappers.BuildahManifestInspectArgs) (string, error) { + if m.ManifestInspectFunc != nil { + return m.ManifestInspectFunc(args) + } + return "", nil +} + +func (m *mockBuildahCli) ManifestPush(args *cliwrappers.BuildahManifestPushArgs) (string, error) { + if m.ManifestPushFunc != nil { + return m.ManifestPushFunc(args) + } + return "", nil +} + var _ cliwrappers.OrasCliInterface = &mockOrasCli{} type mockOrasCli struct { diff --git a/pkg/common/image_ref.go b/pkg/common/image_ref.go index 0611f8c0..9b719527 100644 --- a/pkg/common/image_ref.go +++ b/pkg/common/image_ref.go @@ -2,6 +2,7 @@ package common import ( _ "crypto/sha256" + "fmt" "github.com/containers/image/v5/docker/reference" go_digest "github.com/opencontainers/go-digest" @@ -37,3 +38,40 @@ func IsImageDigestValid(digest string) bool { _, err := go_digest.Parse(digest) return err == nil } + +// GetImageDigest extracts the digest from an image reference. +// Returns the digest string (e.g., "sha256:abc123...") or empty string if no digest. +func GetImageDigest(imageRef string) string { + ref, err := reference.Parse(imageRef) + if err != nil { + return "" + } + + canonical, ok := ref.(reference.Canonical) + if !ok { + return "" + } + + return canonical.Digest().String() +} + +// ValidateImageHasTagOrDigest checks that an image reference has at least a tag or digest. +// Returns an error if the image has neither a tag nor a digest. +func ValidateImageHasTagOrDigest(imageRef string) error { + ref, err := reference.Parse(imageRef) + if err != nil { + return err + } + + // Check if the reference has a digest + if _, ok := ref.(reference.Canonical); ok { + return nil + } + + // Check if the reference has a tag + if _, ok := ref.(reference.Tagged); ok { + return nil + } + + return fmt.Errorf("image '%s' must have a tag or digest", imageRef) +} diff --git a/pkg/common/image_ref_test.go b/pkg/common/image_ref_test.go index 60f328a2..3561760d 100644 --- a/pkg/common/image_ref_test.go +++ b/pkg/common/image_ref_test.go @@ -316,3 +316,97 @@ func Test_ImageRefUntils_IsImageTagValid(t *testing.T) { }) } } + +func Test_ImageRefUntils_GetImageDigest(t *testing.T) { + tests := []struct { + name string + image string + want string + }{ + { + name: "image with digest should return digest", + image: "registry.io/namespace/image@sha256:586ab46b9d6d906b2df3dad12751e807bd0f0632d5a2ab3991bdac78bdccd59a", + want: "sha256:586ab46b9d6d906b2df3dad12751e807bd0f0632d5a2ab3991bdac78bdccd59a", + }, + { + name: "image with tag and digest should return digest", + image: "registry.io/namespace/image:tag@sha256:586ab46b9d6d906b2df3dad12751e807bd0f0632d5a2ab3991bdac78bdccd59a", + want: "sha256:586ab46b9d6d906b2df3dad12751e807bd0f0632d5a2ab3991bdac78bdccd59a", + }, + { + name: "image with only tag should return empty string", + image: "registry.io/namespace/image:tag", + want: "", + }, + { + name: "image without tag or digest should return empty string", + image: "registry.io/namespace/image", + want: "", + }, + { + name: "invalid image should return empty string", + image: "not a valid reference", + want: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := common.GetImageDigest(tc.image) + if got != tc.want { + t.Errorf("For %s expected %s, but got: %s", tc.image, tc.want, got) + } + }) + } +} + +func Test_ValidateImageHasTagOrDigest(t *testing.T) { + tests := []struct { + name string + imageRef string + wantError bool + }{ + { + name: "image with tag should pass", + imageRef: "registry.io/repo:tag", + wantError: false, + }, + { + name: "image with digest should pass", + imageRef: "registry.io/repo@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + wantError: false, + }, + { + name: "image with both tag and digest should pass", + imageRef: "registry.io/repo:tag@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + wantError: false, + }, + { + name: "image without tag or digest should fail", + imageRef: "registry.io/repo", + wantError: true, + }, + { + name: "simple image without tag or digest should fail", + imageRef: "myimage", + wantError: true, + }, + { + name: "namespaced image without tag or digest should fail", + imageRef: "namespace/myimage", + wantError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := common.ValidateImageHasTagOrDigest(tc.imageRef) + if tc.wantError && err == nil { + t.Errorf("Expected error for %s, but got nil", tc.imageRef) + } + if !tc.wantError && err != nil { + t.Errorf("Expected no error for %s, but got: %v", tc.imageRef, err) + } + }) + } +}