diff --git a/cmd/image-processing/main.go b/cmd/image-processing/main.go index 59bee6cbbf..d7f4b1b841 100644 --- a/cmd/image-processing/main.go +++ b/cmd/image-processing/main.go @@ -14,6 +14,7 @@ import ( "os" "strconv" "strings" + "time" "github.com/google/go-containerregistry/pkg/name" containerreg "github.com/google/go-containerregistry/pkg/v1" @@ -40,6 +41,8 @@ type settings struct { label []string insecure bool image, + imageTimestamp, + imageTimestampFile, resultFileImageDigest, resultFileImageSize, secretPath string @@ -61,6 +64,10 @@ func initializeFlag() { pflag.StringArrayVar(&flagValues.annotation, "annotation", nil, "New annotations to add") pflag.StringArrayVar(&flagValues.label, "label", nil, "New labels to add") + + pflag.StringVar(&flagValues.imageTimestamp, "image-timestamp", "", "number to use as Unix timestamp to set image creation timestamp") + pflag.StringVar(&flagValues.imageTimestampFile, "image-timestamp-file", "", "path to a file containing a unix timestamp to set as the image timestamp") + pflag.StringVar(&flagValues.resultFileImageDigest, "result-file-image-digest", "", "A file to write the image digest to") pflag.StringVar(&flagValues.resultFileImageSize, "result-file-image-size", "", "A file to write the image size to") } @@ -89,6 +96,27 @@ func Execute(ctx context.Context) error { return nil } + // validate that only one of the image timestamp flags are used + if flagValues.imageTimestamp != "" && flagValues.imageTimestampFile != "" { + pflag.Usage() + return fmt.Errorf("image timestamp and image timestamp file flag is used, they are mutually exclusive, only use one") + } + + // validate that image timestamp file exists (if set), and translate it into the imageTimestamp field + if flagValues.imageTimestampFile != "" { + _, err := os.Stat(flagValues.imageTimestampFile) + if err != nil { + return fmt.Errorf("image timestamp file flag references a non-existing file: %w", err) + } + + data, err := os.ReadFile(flagValues.imageTimestampFile) + if err != nil { + return fmt.Errorf("failed to read image timestamp from %s: %w", flagValues.imageTimestampFile, err) + } + + flagValues.imageTimestamp = string(data) + } + return runImageProcessing(ctx) } @@ -151,6 +179,20 @@ func runImageProcessing(ctx context.Context) error { } } + // mutate the image timestamp + if flagValues.imageTimestamp != "" { + sec, err := strconv.ParseInt(flagValues.imageTimestamp, 10, 32) + if err != nil { + return fmt.Errorf("failed to parse image timestamp value %q as a number: %w", flagValues.imageTimestamp, err) + } + + log.Println("Mutating the image timestamp") + img, imageIndex, err = image.MutateImageOrImageIndexTimestamp(img, imageIndex, time.Unix(sec, 0)) + if err != nil { + return fmt.Errorf("failed to mutate the timestamp: %w", err) + } + } + // push the image and determine the digest and size log.Printf("Pushing the image to registry %q\n", imageName.String()) digest, size, err := image.PushImageOrImageIndex(imageName, img, imageIndex, options) @@ -159,6 +201,8 @@ func runImageProcessing(ctx context.Context) error { return err } + log.Printf("Image %s@%s pushed\n", imageName.String(), digest) + // Writing image digest to file if digest != "" && flagValues.resultFileImageDigest != "" { if err := os.WriteFile(flagValues.resultFileImageDigest, []byte(digest), 0400); err != nil { diff --git a/cmd/image-processing/main_suite_test.go b/cmd/image-processing/main_suite_test.go index 34a97bb535..8f66ab97ba 100644 --- a/cmd/image-processing/main_suite_test.go +++ b/cmd/image-processing/main_suite_test.go @@ -9,9 +9,14 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/onsi/gomega/types" ) func TestImageProcessingCmd(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "Image Processing Command Suite") } + +func FailWith(substr string) types.GomegaMatcher { + return MatchError(ContainSubstring(substr)) +} diff --git a/cmd/image-processing/main_test.go b/cmd/image-processing/main_test.go index 30d7a38b16..ba3de1a056 100644 --- a/cmd/image-processing/main_test.go +++ b/cmd/image-processing/main_test.go @@ -12,11 +12,13 @@ import ( "net/url" "os" "strconv" + "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" . "github.com/shipwright-io/build/cmd/image-processing" + "github.com/google/go-containerregistry/pkg/crane" "github.com/google/go-containerregistry/pkg/name" "github.com/google/go-containerregistry/pkg/registry" containerreg "github.com/google/go-containerregistry/pkg/v1" @@ -67,6 +69,14 @@ var _ = Describe("Image Processing Resource", func() { f(u.Host) } + withTempDir := func(f func(target string)) { + path, err := os.MkdirTemp(os.TempDir(), "temp-dir") + Expect(err).ToNot(HaveOccurred()) + defer os.RemoveAll(path) + + f(path) + } + withTestImage := func(f func(tag name.Tag)) { withTempRegistry(func(endpoint string) { tag, err := name.NewTag(fmt.Sprintf("%s/%s:%s", endpoint, "temp-image", rand.String(5))) @@ -77,6 +87,19 @@ var _ = Describe("Image Processing Resource", func() { }) } + withTestImageAsDirectory := func(f func(path string, tag name.Tag)) { + withTempRegistry(func(endpoint string) { + withTempDir(func(dir string) { + tag, err := name.NewTag(fmt.Sprintf("%s/%s:%s", endpoint, "temp-image", rand.String(5))) + Expect(err).ToNot(HaveOccurred()) + + Expect(crane.SaveOCI(empty.Image, dir)).To(Succeed()) + + f(dir, tag) + }) + }) + } + getCompressedImageSize := func(img containerreg.Image) int64 { manifest, err := img.Manifest() Expect(err).ToNot(HaveOccurred()) @@ -157,34 +180,70 @@ var _ = Describe("Image Processing Resource", func() { }) It("should fail in case mandatory arguments are missing", func() { - Expect(run()).To(HaveOccurred()) + Expect(run()).ToNot(Succeed()) }) It("should fail in case --image is empty", func() { - Expect(run("--image", "")).To(HaveOccurred()) + Expect(run( + "--image", "", + )).To(FailWith("argument must not be empty")) }) It("should fail in case --image does not exist", func() { Expect(run( - "--image", "docker.io/feqlQoDIHc/bcfHFHHXYF", - )).To(HaveOccurred()) + "--image", "docker.io/library/feqlqodihc:bcfhfhhxyf", + )).To(FailWith("unexpected status code 401")) }) It("should fail in case annotation is invalid", func() { withTestImage(func(tag name.Tag) { Expect(run( + "--insecure", "--image", tag.String(), "--annotation", "org.opencontainers.image.url*https://my-company.com/images", - )).To(HaveOccurred()) + )).To(FailWith("not enough parts")) }) }) It("should fail in case label is invalid", func() { withTestImage(func(tag name.Tag) { Expect(run( + "--insecure", "--image", tag.String(), "--label", " description*image description", - )).To(HaveOccurred()) + )).To(FailWith("not enough parts")) + }) + }) + + It("should fail if both --image-timestamp and --image-timestamp-file are used", func() { + Expect(run( + "--image-timestamp", "1234567890", + "--image-timestamp-file", "/tmp/foobar", + )).To(FailWith("image timestamp and image timestamp file flag is used")) + }) + + It("should fail if --image-timestamp-file is used with a non-existing file", func() { + Expect("/tmp/does-not-exist").ToNot(BeAnExistingFile()) + Expect(run( + "--image-timestamp-file", "/tmp/does-not-exist", + )).To(FailWith("image timestamp file flag references a non-existing file")) + }) + + It("should fail if --image-timestamp-file referenced file cannot be used", func() { + withTempDir(func(wrong string) { + Expect(run( + "--image-timestamp-file", wrong, + )).To(FailWith("failed to read image timestamp from")) + }) + }) + + It("should fail in case timestamp is invalid", func() { + withTestImage(func(tag name.Tag) { + Expect(run( + "--insecure", + "--image", tag.String(), + "--image-timestamp", "foobar", + )).To(FailWith("failed to parse image timestamp")) }) }) }) @@ -266,6 +325,46 @@ var _ = Describe("Image Processing Resource", func() { To(Equal("https://my-company.com/images")) }) }) + + It("should mutate the image timestamp using a provided timestamp", func() { + withTestImageAsDirectory(func(path string, tag name.Tag) { + Expect(run( + "--insecure", + "--push", path, + "--image", tag.String(), + "--image-timestamp", "1234567890", + )).ToNot(HaveOccurred()) + + image := getImage(tag) + + cfgFile, err := image.ConfigFile() + Expect(err).ToNot(HaveOccurred()) + + Expect(cfgFile.Created.Time).To(BeTemporally("==", time.Unix(1234567890, 0))) + }) + }) + + It("should mutate the image timestamp using a provided timestamp in a file", func() { + withTestImageAsDirectory(func(path string, tag name.Tag) { + withTempFile("timestamp", func(filename string) { + Expect(os.WriteFile(filename, []byte("1234567890"), os.FileMode(0644))) + + Expect(run( + "--insecure", + "--push", path, + "--image", tag.String(), + "--image-timestamp-file", filename, + )).ToNot(HaveOccurred()) + + image := getImage(tag) + + cfgFile, err := image.ConfigFile() + Expect(err).ToNot(HaveOccurred()) + + Expect(cfgFile.Created.Time).To(BeTemporally("==", time.Unix(1234567890, 0))) + }) + }) + }) }) Context("store result after image mutation", func() {