diff --git a/cmd/image-processing/main.go b/cmd/image-processing/main.go index 2f93db31bb..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" @@ -37,34 +38,16 @@ type settings struct { help bool push string annotation, - label *[]string + label []string insecure bool image, + imageTimestamp, + imageTimestampFile, resultFileImageDigest, resultFileImageSize, secretPath string } -func getAnnotation() []string { - var annotation []string - - if flagValues.annotation != nil { - return append(annotation, *flagValues.annotation...) - } - - return annotation -} - -func getLabel() []string { - var label []string - - if flagValues.label != nil { - return append(label, *flagValues.label...) - } - - return label -} - var flagValues settings func initializeFlag() { @@ -79,8 +62,12 @@ func initializeFlag() { pflag.StringVar(&flagValues.push, "push", "", "Push the image contained in this directory") - flagValues.annotation = pflag.StringArray("annotation", nil, "New annotations to add") - flagValues.label = pflag.StringArray("label", nil, "New labels to add") + 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") } @@ -109,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) } @@ -123,13 +131,13 @@ func runImageProcessing(ctx context.Context) error { } // parse annotations - annotations, err := splitKeyVals(getAnnotation()) + annotations, err := splitKeyVals(flagValues.annotation) if err != nil { return err } // parse labels - labels, err := splitKeyVals(getLabel()) + labels, err := splitKeyVals(flagValues.label) if err != nil { return err } @@ -171,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) @@ -179,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() { diff --git a/deploy/crds/shipwright.io_buildruns.yaml b/deploy/crds/shipwright.io_buildruns.yaml index 1725b332e8..7306e6b203 100644 --- a/deploy/crds/shipwright.io_buildruns.yaml +++ b/deploy/crds/shipwright.io_buildruns.yaml @@ -114,6 +114,15 @@ spec: description: Labels references the additional labels to be applied on the image type: object + timestamp: + description: 'Timestamp references the optional image timestamp + to be set, valid values are: - "Zero", to set 00:00:00 UTC + on 1 January 1970 - "SourceTimestamp", to set the source + timestamp dereived from the input source - "BuildTimestamp", + to set the timestamp of the current build itself - Parsable + integer number defined as the epoch seconds - or nil/empty + to not set any specific timestamp' + type: string required: - image type: object @@ -272,6 +281,15 @@ spec: description: Labels references the additional labels to be applied on the image type: object + timestamp: + description: 'Timestamp references the optional image timestamp + to be set, valid values are: - "Zero", to set 00:00:00 UTC + on 1 January 1970 - "SourceTimestamp", to set the source + timestamp dereived from the input source - "BuildTimestamp", + to set the timestamp of the current build itself - Parsable + integer number defined as the epoch seconds - or nil/empty + to not set any specific timestamp' + type: string required: - image type: object @@ -2352,6 +2370,15 @@ spec: description: Labels references the additional labels to be applied on the image type: object + timestamp: + description: 'Timestamp references the optional image timestamp + to be set, valid values are: - "Zero", to set 00:00:00 UTC on + 1 January 1970 - "SourceTimestamp", to set the source timestamp + dereived from the input source - "BuildTimestamp", to set the + timestamp of the current build itself - Parsable integer number + defined as the epoch seconds - or nil/empty to not set any specific + timestamp' + type: string required: - image type: object @@ -4125,6 +4152,15 @@ spec: description: Labels references the additional labels to be applied on the image type: object + timestamp: + description: 'Timestamp references the optional image timestamp + to be set, valid values are: - "Zero", to set 00:00:00 UTC + on 1 January 1970 - "SourceTimestamp", to set the source + timestamp dereived from the input source - "BuildTimestamp", + to set the timestamp of the current build itself - Parsable + integer number defined as the epoch seconds - or nil/empty + to not set any specific timestamp' + type: string required: - image type: object @@ -4283,6 +4319,15 @@ spec: description: Labels references the additional labels to be applied on the image type: object + timestamp: + description: 'Timestamp references the optional image timestamp + to be set, valid values are: - "Zero", to set 00:00:00 UTC + on 1 January 1970 - "SourceTimestamp", to set the source + timestamp dereived from the input source - "BuildTimestamp", + to set the timestamp of the current build itself - Parsable + integer number defined as the epoch seconds - or nil/empty + to not set any specific timestamp' + type: string required: - image type: object @@ -6552,6 +6597,16 @@ spec: description: Describes the secret name for pushing a container image. type: string + timestamp: + description: 'Timestamp references the optional image + timestamp to be set, valid values are: - "Zero", to + set 00:00:00 UTC on 1 January 1970 - "SourceTimestamp", + to set the source timestamp dereived from the input + source - "BuildTimestamp", to set the timestamp of the + current build itself - Parsable integer number defined + as the epoch seconds - or nil/empty to not set any specific + timestamp' + type: string required: - image type: object @@ -8691,6 +8746,15 @@ spec: description: Describes the secret name for pushing a container image. type: string + timestamp: + description: 'Timestamp references the optional image timestamp + to be set, valid values are: - "Zero", to set 00:00:00 UTC on + 1 January 1970 - "SourceTimestamp", to set the source timestamp + dereived from the input source - "BuildTimestamp", to set the + timestamp of the current build itself - Parsable integer number + defined as the epoch seconds - or nil/empty to not set any specific + timestamp' + type: string required: - image type: object @@ -10556,6 +10620,15 @@ spec: description: Describes the secret name for pushing a container image. type: string + timestamp: + description: 'Timestamp references the optional image timestamp + to be set, valid values are: - "Zero", to set 00:00:00 UTC + on 1 January 1970 - "SourceTimestamp", to set the source + timestamp dereived from the input source - "BuildTimestamp", + to set the timestamp of the current build itself - Parsable + integer number defined as the epoch seconds - or nil/empty + to not set any specific timestamp' + type: string required: - image type: object diff --git a/deploy/crds/shipwright.io_builds.yaml b/deploy/crds/shipwright.io_builds.yaml index c3ca0df34c..9f3dc63e82 100644 --- a/deploy/crds/shipwright.io_builds.yaml +++ b/deploy/crds/shipwright.io_builds.yaml @@ -100,6 +100,15 @@ spec: description: Labels references the additional labels to be applied on the image type: object + timestamp: + description: 'Timestamp references the optional image timestamp + to be set, valid values are: - "Zero", to set 00:00:00 UTC on + 1 January 1970 - "SourceTimestamp", to set the source timestamp + dereived from the input source - "BuildTimestamp", to set the + timestamp of the current build itself - Parsable integer number + defined as the epoch seconds - or nil/empty to not set any specific + timestamp' + type: string required: - image type: object @@ -252,6 +261,15 @@ spec: description: Labels references the additional labels to be applied on the image type: object + timestamp: + description: 'Timestamp references the optional image timestamp + to be set, valid values are: - "Zero", to set 00:00:00 UTC on + 1 January 1970 - "SourceTimestamp", to set the source timestamp + dereived from the input source - "BuildTimestamp", to set the + timestamp of the current build itself - Parsable integer number + defined as the epoch seconds - or nil/empty to not set any specific + timestamp' + type: string required: - image type: object @@ -2324,6 +2342,15 @@ spec: description: Describes the secret name for pushing a container image. type: string + timestamp: + description: 'Timestamp references the optional image timestamp + to be set, valid values are: - "Zero", to set 00:00:00 UTC on + 1 January 1970 - "SourceTimestamp", to set the source timestamp + dereived from the input source - "BuildTimestamp", to set the + timestamp of the current build itself - Parsable integer number + defined as the epoch seconds - or nil/empty to not set any specific + timestamp' + type: string required: - image type: object diff --git a/docs/build.md b/docs/build.md index f2b87c1a5c..9abd31bb43 100644 --- a/docs/build.md +++ b/docs/build.md @@ -51,25 +51,42 @@ When the controller reconciles it: ## Build Validations -**Note: reported validations in build status are deprecated, and will be removed in a future release.** - -To prevent users from triggering `BuildRuns` (_execution of a Build_) that will eventually fail because of wrong or missing dependencies or configuration settings, the Build controller will validate them in advance. If all validations are successful, users can expect a `Succeeded` `status.reason`. However, if any validations fail, users can rely on the `status.reason` and `status.message` fields to understand the root cause. - -| Status.Reason | Description | -| --- | --- | -| BuildStrategyNotFound | The referenced namespace-scope strategy doesn't exist. | -| ClusterBuildStrategyNotFound | The referenced cluster-scope strategy doesn't exist. | -| SetOwnerReferenceFailed | Setting ownerreferences between a Build and a BuildRun failed. This status is triggered when you set the `spec.retention.atBuildDeletion` to true in a Build. | -| SpecSourceSecretRefNotFound | The secret used to authenticate to git doesn't exist. | -| SpecOutputSecretRefNotFound | The secret used to authenticate to the container registry doesn't exist. | -| SpecBuilderSecretRefNotFound | The secret used to authenticate the container registry doesn't exist.| -| MultipleSecretRefNotFound | More than one secret is missing. At the moment, only three paths on a Build can specify a secret. | -| RestrictedParametersInUse | One or many defined `paramValues` are colliding with Shipwright reserved parameters. See [Defining Params](#defining-paramvalues) for more information. | -| UndefinedParameter | One or many defined `paramValues` are not defined in the referenced strategy. Please ensure that the strategy defines them under its `spec.parameters` list. | -| RemoteRepositoryUnreachable | The defined `spec.source.git.url` was not found. This validation only takes place for HTTP/HTTPS protocols. | -| BuildNameInvalid | The defined `Build` name (`metadata.name`) is invalid. The `Build` name should be a [valid label value](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set). | -| SpecEnvNameCanNotBeBlank | Indicates that the name for a user-provided environment variable is blank. | -| SpecEnvValueCanNotBeBlank | Indicates that the value for a user-provided environment variable is blank. | +**Note**: reported validations in build status are deprecated, and will be removed in a future release. + +To prevent users from triggering `BuildRun`s (_execution of a Build_) that will eventually fail because of wrong or missing dependencies or configuration settings, the Build controller will validate them in advance. If all validations are successful, users can expect a `Succeeded` `status.reason`. However, if any validations fail, users can rely on the `status.reason` and `status.message` fields to understand the root cause. + +| Status.Reason | Description | +|-------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| BuildStrategyNotFound | The referenced namespace-scope strategy doesn't exist. | +| ClusterBuildStrategyNotFound | The referenced cluster-scope strategy doesn't exist. | +| SetOwnerReferenceFailed | Setting ownerreferences between a Build and a BuildRun failed. This status is triggered when you set the `spec.retention.atBuildDeletion` to true in a Build. | +| SpecSourceSecretRefNotFound | The secret used to authenticate to git doesn't exist. | +| SpecOutputSecretRefNotFound | The secret used to authenticate to the container registry doesn't exist. | +| SpecBuilderSecretRefNotFound | The secret used to authenticate the container registry doesn't exist. | +| MultipleSecretRefNotFound | More than one secret is missing. At the moment, only three paths on a Build can specify a secret. | +| RestrictedParametersInUse | One or many defined `paramValues` are colliding with Shipwright reserved parameters. See [Defining Params](#defining-paramvalues) for more information. | +| UndefinedParameter | One or many defined `paramValues` are not defined in the referenced strategy. Please ensure that the strategy defines them under its `spec.parameters` list. | +| RemoteRepositoryUnreachable | The defined `spec.source.git.url` was not found. This validation only takes place for HTTP/HTTPS protocols. | +| BuildNameInvalid | The defined `Build` name (`metadata.name`) is invalid. The `Build` name should be a [valid label value](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set). | +| SpecEnvNameCanNotBeBlank | The name for a user-provided environment variable is blank. | +| SpecEnvValueCanNotBeBlank | The value for a user-provided environment variable is blank. | +| SpecEnvOnlyOneOfValueOrValueFromMustBeSpecified | Both value and valueFrom were specified, which are mutually exclusive. | +| RuntimePathsCanNotBeEmpty | The `spec.runtime` feature is used but the paths were not specified. | +| WrongParameterValueType | A single value was provided for an array parameter, or vice-versa. | +| InconsistentParameterValues | Parameter values have more than one of _configMapValue_, _secretValue_, or _value_ set. | +| EmptyArrayItemParameterValues | Array parameters contain an item where none of _configMapValue_, _secretValue_, or _value_ is set. | +| IncompleteConfigMapValueParameterValues | A _configMapValue_ is specified where the name or the key is empty. | +| IncompleteSecretValueParameterValues | A _secretValue_ is specified where the name or the key is empty. | +| VolumeDoesNotExist | Volume referenced by the Build does not exist, therefore Build cannot be run. | +| VolumeNotOverridable | Volume defined by build is not set as overridable in the strategy. | +| UndefinedVolume | Volume defined by build is not found in the strategy. | +| TriggerNameCanNotBeBlank | Trigger condition does not have a name. | +| TriggerInvalidType | Trigger type is invalid. | +| TriggerInvalidGitHubWebHook | Trigger type GitHub is invalid. | +| TriggerInvalidImage | Trigger type Image is invalid. | +| TriggerInvalidPipeline | Trigger type Pipeline is invalid. | +| OutputTimestampNotSupported | An unsupported output timestamp setting was used. | +| OutputTimestampNotValid | The output timestamp value is not valid. | ## Configuring a Build @@ -89,6 +106,11 @@ The `Build` definition supports the following fields: - `spec.timeout` - Defines a custom timeout. The value needs to be parsable by [ParseDuration](https://golang.org/pkg/time/#ParseDuration), for example, `5m`. The default is ten minutes. You can overwrite the value in the `BuildRun`. - `spec.output.annotations` - Refers to a list of `key/value` that could be used to [annotate](https://github.com/opencontainers/image-spec/blob/main/annotations.md) the output image. - `spec.output.labels` - Refers to a list of `key/value` that could be used to label the output image. + - `spec.output.timestamp` - Instruct the build to change the output image creation timestamp to the specified value. When omitted, the respective build strategy tool defines the output image timestamp. + - Use string `Zero` to set the image timestamp to UNIX epoch timestamp zero. + - Use string `SourceTimestamp` to set the image timestamp to the source timestamp, i.e. the timestamp of the Git commit that was used. + - Use string `BuildTimestamp` to set the image timestamp to the timestamp of the build run. + - Use any valid UNIX epoch seconds number as a string to set this as the image timestamp. - `spec.env` - Specifies additional environment variables that should be passed to the build container. The available variables depend on the tool that is being used by the chosen build strategy. - `spec.retention.atBuildDeletion` - Defines if all related BuildRuns needs to be deleted when deleting the Build. The default is false. - `spec.retention.ttlAfterFailed` - Specifies the duration for which a failed buildrun can exist. @@ -125,7 +147,7 @@ spec: contextDir: docker-build ``` -_Note_: The Build controller only validates two scenarios. The first one is when the endpoint uses an `http/https` protocol. The second one is when an `ssh` protocol such as `git@` has been defined but a referenced secret, such as `source.git.cloneSecret`, has not been provided. +**Note**: The Build controller only validates two scenarios. The first one is when the endpoint uses an `http/https` protocol. The second one is when an `ssh` protocol such as `git@` has been defined but a referenced secret, such as `source.git.cloneSecret`, has not been provided. Example of a `Build` with a source with **credentials** defined by the user. @@ -193,8 +215,7 @@ spec: value: "example-value-2" ``` -Example of a `Build` that uses the Kubernetes Downward API to -expose a `Pod` field as an environment variable: +Example of a `Build` that uses the Kubernetes Downward API to expose a `Pod` field as an environment variable: ```yaml apiVersion: shipwright.io/v1beta1 @@ -214,8 +235,7 @@ spec: fieldPath: metadata.name ``` -Example of a `Build` that uses the Kubernetes Downward API to -expose a `Container` field as an environment variable: +Example of a `Build` that uses the Kubernetes Downward API to expose a `Container` field as an environment variable: ```yaml apiVersion: shipwright.io/v1beta1 @@ -422,7 +442,7 @@ Here, we pass three items in the `build-args` array: 2. The second item is just a hard-coded value. 3. The third item references a Secret, the same as with ConfigMaps. -**NOTE**: The logging output of BuildKit contains expanded `ARG`s in `RUN` commands. Also, such information ends up in the final container image if you use such args in the [final stage of your Dockerfile](https://docs.docker.com/develop/develop-images/multistage-build/). An alternative approach to pass secrets is using [secret mounts](https://docs.docker.com/develop/develop-images/build_enhancements/#new-docker-build-secret-information). The BuildKit sample strategy supports them using the `secrets` parameter. +**Note**: The logging output of BuildKit contains expanded `ARG`s in `RUN` commands. Also, such information ends up in the final container image if you use such args in the [final stage of your Dockerfile](https://docs.docker.com/develop/develop-images/multistage-build/). An alternative approach to pass secrets is using [secret mounts](https://docs.docker.com/develop/develop-images/build_enhancements/#new-docker-build-secret-information). The BuildKit sample strategy supports them using the `secrets` parameter. ### Defining the Builder or Dockerfile @@ -470,9 +490,9 @@ spec: ### Defining the Output -A `Build` resource can specify the output where it should push the image. For external private registries, it is recommended to specify a secret with the related data to access it. An option is available to specify the annotation and labels for the output image. The annotations and labels mentioned here are specific to the container image and do not relate to the `Build` annotations. +A `Build` resource can specify the output where it should push the image. For external private registries, it is recommended to specify a secret with the related data to access it. An option is available to specify the annotation and labels for the output image. The annotations and labels mentioned here are specific to the container image and do not relate to the `Build` annotations. Analogous, the timestamp refers to the timestamp of the output image. -**NOTE**: When you specify annotations or labels, the output image will get pushed twice. The first push comes from the build strategy. Then, a follow-on update changes the image configuration to add the annotations and labels. If you have automation based on push events in your container registry, be aware of this behavior. +**Note**: When you specify annotations, labels, or timestamp, the output image **may** get pushed twice, depending on the respective strategy. For example, strategies that push the image to the registry as part of their build step will lead to an additional push of the image in case image processing like labels is configured. If you have automation based on push events in your container registry, be aware of this behavior. For example, the user specifies a public registry: @@ -550,16 +570,38 @@ spec: "description": "This is my cool image" ``` +Example of user specified image timestamp set to `SourceTimestamp` to set the output timestamp to match the timestamp of the Git commit used for the build: + +```yaml +apiVersion: shipwright.io/v1beta1 +kind: Build +metadata: + name: sample-go-build +spec: + source: + type: Git + git: + url: https://github.com/shipwright-io/sample-go + contextDir: source-build + strategy: + name: buildkit + kind: ClusterBuildStrategy + output: + image: some.registry.com/namespace/image:tag + pushSecret: credentials + timestamp: SourceTimestamp +``` + Annotations added to the output image can be verified by running the command: ```sh - docker manifest inspect us.icr.io/source-to-image-build/nodejs-ex | jq ".annotations" +docker manifest inspect us.icr.io/source-to-image-build/nodejs-ex | jq ".annotations" ``` You can verify which labels were added to the output image that is available on the host machine by running the command: ```sh - docker inspect us.icr.io/source-to-image-build/nodejs-ex | jq ".[].Config.Labels" +docker inspect us.icr.io/source-to-image-build/nodejs-ex | jq ".[].Config.Labels" ``` ### Defining Retention Parameters @@ -598,14 +640,14 @@ An example of a user using both TTL and Limit retention fields. In case of such succeededLimit: 20 ``` -**NOTE**: When changes are made to `retention.failedLimit` and `retention.succeededLimit` values, they come into effect as soon as the build is applied, thereby enforcing the new limits. On the other hand, changing the `retention.ttlAfterFailed` and `retention.ttlAfterSucceeded` values will only affect new buildruns. Old buildruns will adhere to the old TTL retention values. In case TTL values are defined in buildrun specifications as well as build specifications, priority will be given to the values defined in the buildrun specifications. +**Note**: When changes are made to `retention.failedLimit` and `retention.succeededLimit` values, they come into effect as soon as the build is applied, thereby enforcing the new limits. On the other hand, changing the `retention.ttlAfterFailed` and `retention.ttlAfterSucceeded` values will only affect new buildruns. Old buildruns will adhere to the old TTL retention values. In case TTL values are defined in buildrun specifications as well as build specifications, priority will be given to the values defined in the buildrun specifications. ### Defining Volumes `Builds` can declare `volumes`. They must override `volumes` defined by the according `BuildStrategy`. If a `volume` is not `overridable` then the `BuildRun` will eventually fail. -`Volumes` follow the declaration of [Pod Volumes](https://kubernetes.io/docs/concepts/storage/volumes/), so +`Volumes` follow the declaration of [Pod Volumes](https://kubernetes.io/docs/concepts/storage/volumes/), so all the usual `volumeSource` types are supported. Here is an example of `Build` object that overrides `volumes`: @@ -752,4 +794,4 @@ spec: retention: atBuildDeletion: true # [...] -``` \ No newline at end of file +``` diff --git a/docs/buildrun.md b/docs/buildrun.md index 8d66c0dfd7..96f4ece9a6 100644 --- a/docs/buildrun.md +++ b/docs/buildrun.md @@ -9,8 +9,8 @@ SPDX-License-Identifier: Apache-2.0 - [Overview](#overview) - [BuildRun Controller](#buildrun-controller) - [Configuring a BuildRun](#configuring-a-buildrun) - - [Defining the Build Reference](#defining-the-buildref) - - [Defining the Build Specification](#defining-the-buildspec) + - [Defining the Build Reference](#defining-the-build-reference) + - [Defining the Build Specification](#defining-the-build-specification) - [Defining ParamValues](#defining-paramvalues) - [Defining the ServiceAccount](#defining-the-serviceaccount) - [Defining Retention Parameters](#defining-retention-parameters) @@ -67,11 +67,12 @@ The `BuildRun` definition supports the following fields: - `spec.serviceAccount` - Refers to the SA to use when building the image. (_defaults to the `default` SA_) - `spec.timeout` - Defines a custom timeout. The value needs to be parsable by [ParseDuration](https://golang.org/pkg/time/#ParseDuration), for example, `5m`. The value overwrites the value that is defined in the `Build`. - `spec.paramValues` - Refers to a name-value(s) list to specify values for `parameters` defined in the `BuildStrategy`. This value overwrites values defined with the same name in the Build. - - `spec.output.image` - Refers to a custom location where the generated image would be pushed. The value will overwrite the `output.image` value defined in `Build`. ( Note: other properties of the output, for example, the credentials, cannot be specified in the buildRun spec. ) + - `spec.output.image` - Refers to a custom location where the generated image would be pushed. The value will overwrite the `output.image` value defined in `Build`. (**Note**: other properties of the output, for example, the credentials, cannot be specified in the buildRun spec. ) - `spec.output.pushSecret` - Reference an existing secret to get access to the container registry. This secret will be added to the service account along with the ones requested by the `Build`. + - `spec.output.timestamp` - Overrides the output timestamp configuration of the referenced build to instruct the build to change the output image creation timestamp to the specified value. When omitted, the respective build strategy tool defines the output image timestamp. - `spec.env` - Specifies additional environment variables that should be passed to the build container. Overrides any environment variables that are specified in the `Build` resource. The available variables depend on the tool used by the chosen build strategy. -_Note:_ The `spec.build.name` and `spec.build.spec` are mutually exclusive. Furthermore, the overrides for `timeout`, `paramValues`, `output`, and `env` can only be combined with `spec.build.name`, but **not** with `spec.build.spec`. +**Note**: The `spec.build.name` and `spec.build.spec` are mutually exclusive. Furthermore, the overrides for `timeout`, `paramValues`, `output`, and `env` can only be combined with `spec.build.name`, but **not** with `spec.build.spec`. ### Defining the Build Reference @@ -113,9 +114,7 @@ spec: ### Defining the Build Source -BuildRun's support the specification of a Local type source. This is useful for working on development -mode, without forcing a user to commit/push changes to their related version control system. For more information please -refer to [SHIP 0016 - enabling local source code](https://github.com/shipwright-io/community/blob/main/ships/0016-enable-local-source-code-support.md). +BuildRun's support the specification of a Local type source. This is useful for working on development mode, without forcing a user to commit/push changes to their related version control system. For more information please refer to [SHIP 0016 - enabling local source code](https://github.com/shipwright-io/community/blob/main/ships/0016-enable-local-source-code-support.md). ```yaml apiVersion: shipwright.io/v1beta1 @@ -190,7 +189,7 @@ spec: You can also set the value of `spec.serviceAccount` to `".generate"`. This will generate the service account during runtime for you. The name of the generated service account is the same as that of the BuildRun. -_**Note**_: When the service account is not defined, the `BuildRun` uses the `pipeline` service account if it exists in the namespace, and falls back to the `default` service account. +**Note**: When the service account is not defined, the `BuildRun` uses the `pipeline` service account if it exists in the namespace, and falls back to the `default` service account. ### Defining Retention Parameters @@ -216,18 +215,15 @@ spec: ttlAfterSucceeded: 10m ``` -**NOTE** In case TTL values are defined in buildrun specifications as well as build specifications, priority will be given to the values defined in the buildrun specifications. +**Note**: In case TTL values are defined in buildrun specifications as well as build specifications, priority will be given to the values defined in the buildrun specifications. ### Defining Volumes -`BuildRuns` can declare `volumes`. They must override `volumes` defined by the according `BuildStrategy`. If a `volume` -is not `overridable` then the `BuildRun` will eventually fail. +`BuildRuns` can declare `volumes`. They must override `volumes` defined by the according `BuildStrategy`. If a `volume` is not `overridable` then the `BuildRun` will eventually fail. -In case `Build` and `BuildRun` that refers to this `Build` override the same `volume`, one that is defined in the `BuildRun` -is the one used eventually. +In case `Build` and `BuildRun` that refers to this `Build` override the same `volume`, one that is defined in the `BuildRun` is the one used eventually. -`Volumes` follow the declaration of [Pod Volumes](https://kubernetes.io/docs/concepts/storage/volumes/), so -all the usual `volumeSource` types are supported. +`Volumes` follow the declaration of [Pod Volumes](https://kubernetes.io/docs/concepts/storage/volumes/), so all the usual `volumeSource` types are supported. Here is an example of `BuildRun` object that overrides `volumes`: @@ -296,8 +292,7 @@ spec: value: "example-value-2" ``` -Example of a `BuildRun` that uses the Kubernetes Downward API to -expose a `Pod` field as an environment variable: +Example of a `BuildRun` that uses the Kubernetes Downward API to expose a `Pod` field as an environment variable: ```yaml apiVersion: shipwright.io/v1beta1 @@ -314,8 +309,7 @@ spec: fieldPath: metadata.name ``` -Example of a `BuildRun` that uses the Kubernetes Downward API to -expose a `Container` field as an environment variable: +Example of a `BuildRun` that uses the Kubernetes Downward API to expose a `Container` field as an environment variable: ```yaml apiVersion: shipwright.io/v1beta1 @@ -365,40 +359,40 @@ The `status.conditions` hosts different fields, like `status`, `reason` and `mes The following table illustrates the different states a BuildRun can have under its `status.conditions`: -| Status | Reason | CompletionTime is set | Description | -| --- | --- | --- | --- | -| Unknown | Pending | No | The BuildRun is waiting on a Pod in status Pending. | -| Unknown | Running | No | The BuildRun has been validated and started to perform its work. | -| Unknown | Running | No | The BuildRun has been validated and started to perform its work. | -| Unknown | BuildRunCanceled | No | The user requested the BuildRun to be canceled. This results in the BuildRun controller requesting the TaskRun be canceled. Cancellation has not been done yet. | -| True | Succeeded | Yes | The BuildRun Pod is done. | -| False | Failed | Yes | The BuildRun failed in one of the steps. | -| False | BuildRunTimeout | Yes | The BuildRun timed out. | -| False | UnknownStrategyKind | Yes | The Build specified strategy Kind is unknown. (_options: ClusterBuildStrategy or BuildStrategy_) | -| False | ClusterBuildStrategyNotFound | Yes | The referenced cluster strategy was not found in the cluster. | -| False | BuildStrategyNotFound | Yes | The referenced namespaced strategy was not found in the cluster. | -| False | SetOwnerReferenceFailed | Yes | Setting ownerreferences from the BuildRun to the related TaskRun failed. | -| False | TaskRunIsMissing | Yes | The BuildRun related TaskRun was not found. | -| False | TaskRunGenerationFailed | Yes | The generation of a TaskRun spec failed. | -| False | MissingParameterValues | Yes | No value has been provided for some parameters that are defined in the build strategy without any default. Values for those parameters must be provided through the Build or the BuildRun. | -| False | RestrictedParametersInUse | Yes | A value for a system parameter was provided. This is not allowed. | -| False | UndefinedParameter | Yes | A value for a parameter was provided that is not defined in the build strategy. | -| False | WrongParameterValueType | Yes | A value was provided for a build strategy parameter using the wrong type. The parameter is defined as `array` or `string` in the build strategy. Depending on that, you must provide `values` or a direct value. | -| False | InconsistentParameterValues | Yes | A value for a parameter contained more than one of `value`, `configMapValue`, and `secretValue`. Any values including array items must only provide one of them. | -| False | EmptyArrayItemParameterValues | Yes | An item inside the `values` of an array parameter contained none of `value`, `configMapValue`, and `secretValue`. Exactly one of them must be provided. Null array items are not allowed. | -| False | IncompleteConfigMapValueParameterValues | Yes | A value for a parameter contained a `configMapValue` where the `name` or the `value` were empty. You must specify them to point to an existing ConfigMap key in your namespace. | -| False | IncompleteSecretValueParameterValues | Yes | A value for a parameter contained a `secretValue` where the `name` or the `value` were empty. You must specify them to point to an existing Secret key in your namespace. | -| False | ServiceAccountNotFound | Yes | The referenced service account was not found in the cluster. | -| False | BuildRegistrationFailed | Yes | The related Build in the BuildRun is in a Failed state. | -| False | BuildNotFound | Yes | The related Build in the BuildRun was not found. | -| False | BuildRunCanceled | Yes | The BuildRun and underlying TaskRun were canceled successfully. | -| False | BuildRunNameInvalid | Yes | The defined `BuildRun` name (`metadata.name`) is invalid. The `BuildRun` name should be a [valid label value](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set). | -| False | BuildRunNoRefOrSpec | Yes | BuildRun does not have either `spec.build.name` or `spec.build.spec` defined. There is no connection to a Build specification. | -| False | BuildRunAmbiguousBuild | Yes | The defined `BuildRun` uses both `spec.build.name` and `spec.build.spec`. Only one of them is allowed at the same time.| -| False | BuildRunBuildFieldOverrideForbidden | Yes | The defined `BuildRun` uses an override (e.g. `timeout`, `paramValues`, `output`, or `env`) in combination with `spec.build.spec`, which is not allowed. Use the `spec.build.spec` to directly specify the respective value. | -| False | PodEvicted | Yes | The BuildRun Pod was evicted from the node it was running on. See [API-initiated Eviction](https://kubernetes.io/docs/concepts/scheduling-eviction/api-eviction/) and [Node-pressure Eviction](https://kubernetes.io/docs/concepts/scheduling-eviction/node-pressure-eviction/) for more information. | - -_Note_: We heavily rely on the Tekton TaskRun [Conditions](https://github.com/tektoncd/pipeline/blob/main/docs/taskruns.md#monitoring-execution-status) for populating the BuildRun ones, with some exceptions. +| Status | Reason | CompletionTime is set | Description | +|---------|-----------------------------------------|-----------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Unknown | Pending | No | The BuildRun is waiting on a Pod in status Pending. | +| Unknown | Running | No | The BuildRun has been validated and started to perform its work. | +| Unknown | Running | No | The BuildRun has been validated and started to perform its work. | +| Unknown | BuildRunCanceled | No | The user requested the BuildRun to be canceled. This results in the BuildRun controller requesting the TaskRun be canceled. Cancellation has not been done yet. | +| True | Succeeded | Yes | The BuildRun Pod is done. | +| False | Failed | Yes | The BuildRun failed in one of the steps. | +| False | BuildRunTimeout | Yes | The BuildRun timed out. | +| False | UnknownStrategyKind | Yes | The Build specified strategy Kind is unknown. (_options: ClusterBuildStrategy or BuildStrategy_) | +| False | ClusterBuildStrategyNotFound | Yes | The referenced cluster strategy was not found in the cluster. | +| False | BuildStrategyNotFound | Yes | The referenced namespaced strategy was not found in the cluster. | +| False | SetOwnerReferenceFailed | Yes | Setting ownerreferences from the BuildRun to the related TaskRun failed. | +| False | TaskRunIsMissing | Yes | The BuildRun related TaskRun was not found. | +| False | TaskRunGenerationFailed | Yes | The generation of a TaskRun spec failed. | +| False | MissingParameterValues | Yes | No value has been provided for some parameters that are defined in the build strategy without any default. Values for those parameters must be provided through the Build or the BuildRun. | +| False | RestrictedParametersInUse | Yes | A value for a system parameter was provided. This is not allowed. | +| False | UndefinedParameter | Yes | A value for a parameter was provided that is not defined in the build strategy. | +| False | WrongParameterValueType | Yes | A value was provided for a build strategy parameter using the wrong type. The parameter is defined as `array` or `string` in the build strategy. Depending on that, you must provide `values` or a direct value. | +| False | InconsistentParameterValues | Yes | A value for a parameter contained more than one of `value`, `configMapValue`, and `secretValue`. Any values including array items must only provide one of them. | +| False | EmptyArrayItemParameterValues | Yes | An item inside the `values` of an array parameter contained none of `value`, `configMapValue`, and `secretValue`. Exactly one of them must be provided. Null array items are not allowed. | +| False | IncompleteConfigMapValueParameterValues | Yes | A value for a parameter contained a `configMapValue` where the `name` or the `value` were empty. You must specify them to point to an existing ConfigMap key in your namespace. | +| False | IncompleteSecretValueParameterValues | Yes | A value for a parameter contained a `secretValue` where the `name` or the `value` were empty. You must specify them to point to an existing Secret key in your namespace. | +| False | ServiceAccountNotFound | Yes | The referenced service account was not found in the cluster. | +| False | BuildRegistrationFailed | Yes | The related Build in the BuildRun is in a Failed state. | +| False | BuildNotFound | Yes | The related Build in the BuildRun was not found. | +| False | BuildRunCanceled | Yes | The BuildRun and underlying TaskRun were canceled successfully. | +| False | BuildRunNameInvalid | Yes | The defined `BuildRun` name (`metadata.name`) is invalid. The `BuildRun` name should be a [valid label value](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set). | +| False | BuildRunNoRefOrSpec | Yes | BuildRun does not have either `spec.build.name` or `spec.build.spec` defined. There is no connection to a Build specification. | +| False | BuildRunAmbiguousBuild | Yes | The defined `BuildRun` uses both `spec.build.name` and `spec.build.spec`. Only one of them is allowed at the same time. | +| False | BuildRunBuildFieldOverrideForbidden | Yes | The defined `BuildRun` uses an override (e.g. `timeout`, `paramValues`, `output`, or `env`) in combination with `spec.build.spec`, which is not allowed. Use the `spec.build.spec` to directly specify the respective value. | +| False | PodEvicted | Yes | The BuildRun Pod was evicted from the node it was running on. See [API-initiated Eviction](https://kubernetes.io/docs/concepts/scheduling-eviction/api-eviction/) and [Node-pressure Eviction](https://kubernetes.io/docs/concepts/scheduling-eviction/node-pressure-eviction/) for more information. | + +**Note**: We heavily rely on the Tekton TaskRun [Conditions](https://github.com/tektoncd/pipeline/blob/main/docs/taskruns.md#monitoring-execution-status) for populating the BuildRun ones, with some exceptions. ### Understanding failed BuildRuns @@ -428,17 +422,17 @@ status: All git-related operations support error reporting via `status.failureDetails`. The following table explains the possible error reasons: -| Reason | Description | -| --- | --- | -| `GitAuthInvalidUserOrPass` | Basic authentication has failed. Check your username or password. Note: GitHub requires a personal access token instead of your regular password. | -| `GitAuthInvalidKey` | The key is invalid for the specified target. Please make sure that the Git repository exists, you have sufficient permissions, and the key is in the right format. | -| `GitRevisionNotFound` | The remote revision does not exist. Check the revision specified in your Build. | -| `GitRemoteRepositoryNotFound`| The source repository does not exist, or you have insufficient permissions to access it. | -| `GitRemoteRepositoryPrivate` | You are trying to access a non-existing or private repository without having sufficient permissions to access it via HTTPS. | -| `GitBasicAuthIncomplete`| Basic Auth incomplete: Both username and password must be configured. | -| `GitSSHAuthUnexpected`| Credential/URL inconsistency: SSH credentials were provided, but the URL is not an SSH Git URL. | -| `GitSSHAuthExpected`| Credential/URL inconsistency: No SSH credentials provided, but the URL is an SSH Git URL. | -| `GitError` | The specific error reason is unknown. Check the error message for more information. | +| Reason | Description | +|-------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `GitAuthInvalidUserOrPass` | Basic authentication has failed. Check your username or password. **Note**: GitHub requires a personal access token instead of your regular password. | +| `GitAuthInvalidKey` | The key is invalid for the specified target. Please make sure that the Git repository exists, you have sufficient permissions, and the key is in the right format. | +| `GitRevisionNotFound` | The remote revision does not exist. Check the revision specified in your Build. | +| `GitRemoteRepositoryNotFound` | The source repository does not exist, or you have insufficient permissions to access it. | +| `GitRemoteRepositoryPrivate` | You are trying to access a non-existing or private repository without having sufficient permissions to access it via HTTPS. | +| `GitBasicAuthIncomplete` | Basic Auth incomplete: Both username and password must be configured. | +| `GitSSHAuthUnexpected` | Credential/URL inconsistency: SSH credentials were provided, but the URL is not an SSH Git URL. | +| `GitSSHAuthExpected` | Credential/URL inconsistency: No SSH credentials provided, but the URL is an SSH Git URL. | +| `GitError` | The specific error reason is unknown. Check the error message for more information. | ### Step Results in BuildRun Status diff --git a/pkg/apis/build/v1alpha1/build_types.go b/pkg/apis/build/v1alpha1/build_types.go index 8023c71f32..36da3dc68d 100644 --- a/pkg/apis/build/v1alpha1/build_types.go +++ b/pkg/apis/build/v1alpha1/build_types.go @@ -108,6 +108,17 @@ const ( AnnotationBuildVerifyRepository = BuildDomain + "/verify.repository" ) +const ( + // OutputImageZeroTimestamp indicates that the UNIX timestamp 0 is to be used + OutputImageZeroTimestamp = "Zero" + + // OutputImageSourceTimestamp indicates that the timestamp of the respective source it to be used + OutputImageSourceTimestamp = "SourceTimestamp" + + // OutputImageBuildTimestamp indicates that the current timestamp of the build run itself is to be used + OutputImageBuildTimestamp = "BuildTimestamp" +) + // BuildSpec defines the desired state of Build type BuildSpec struct { // Source refers to the Git repository containing the @@ -233,6 +244,16 @@ type Image struct { // // +optional Labels map[string]string `json:"labels,omitempty"` + + // Timestamp references the optional image timestamp to be set, valid values are: + // - "Zero", to set 00:00:00 UTC on 1 January 1970 + // - "SourceTimestamp", to set the source timestamp dereived from the input source + // - "BuildTimestamp", to set the timestamp of the current build itself + // - Parsable integer number defined as the epoch seconds + // - or nil/empty to not set any specific timestamp + // + // +optional + Timestamp *string `json:"timestamp,omitempty"` } // BuildStatus defines the observed state of Build diff --git a/pkg/apis/build/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/build/v1alpha1/zz_generated.deepcopy.go index c36175df27..58ce75daff 100644 --- a/pkg/apis/build/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/build/v1alpha1/zz_generated.deepcopy.go @@ -904,6 +904,11 @@ func (in *Image) DeepCopyInto(out *Image) { (*out)[key] = val } } + if in.Timestamp != nil { + in, out := &in.Timestamp, &out.Timestamp + *out = new(string) + **out = **in + } return } diff --git a/pkg/apis/build/v1beta1/build_conversion.go b/pkg/apis/build/v1beta1/build_conversion.go index be06a55a06..55a0b98fb2 100644 --- a/pkg/apis/build/v1beta1/build_conversion.go +++ b/pkg/apis/build/v1beta1/build_conversion.go @@ -196,6 +196,7 @@ func (dest *BuildSpec) ConvertFrom(orig *v1alpha1.BuildSpec) error { dest.Output.Annotations = orig.Output.Annotations dest.Output.Labels = orig.Output.Labels + dest.Output.Timestamp = orig.Output.Timestamp // Handle BuildSpec Timeout dest.Timeout = orig.Timeout @@ -288,6 +289,7 @@ func (dest *BuildSpec) ConvertTo(bs *v1alpha1.BuildSpec) error { } bs.Output.Annotations = dest.Output.Annotations bs.Output.Labels = dest.Output.Labels + bs.Output.Timestamp = dest.Output.Timestamp // Handle BuildSpec Timeout bs.Timeout = dest.Timeout diff --git a/pkg/apis/build/v1beta1/build_types.go b/pkg/apis/build/v1beta1/build_types.go index 0d233f0c50..805adbb36b 100644 --- a/pkg/apis/build/v1beta1/build_types.go +++ b/pkg/apis/build/v1beta1/build_types.go @@ -70,6 +70,10 @@ const ( TriggerInvalidImage BuildReason = "TriggerInvalidImage" // TriggerInvalidPipeline indicates the trigger type Pipeline is invalid TriggerInvalidPipeline BuildReason = "TriggerInvalidPipeline" + // OutputTimestampNotSupported indicates that an unsupported output timestamp setting was used + OutputTimestampNotSupported BuildReason = "OutputTimestampNotSupported" + // OutputTimestampNotValid indicates that the output timestamp value is not valid + OutputTimestampNotValid BuildReason = "OutputTimestampNotValid" // AllValidationsSucceeded indicates a Build was successfully validated AllValidationsSucceeded = "all validations succeeded" @@ -105,6 +109,17 @@ const ( AnnotationBuildVerifyRepository = BuildDomain + "/verify.repository" ) +const ( + // OutputImageZeroTimestamp indicates that the UNIX timestamp 0 is to be used + OutputImageZeroTimestamp = "Zero" + + // OutputImageSourceTimestamp indicates that the timestamp of the respective source it to be used + OutputImageSourceTimestamp = "SourceTimestamp" + + // OutputImageBuildTimestamp indicates that the current timestamp of the build run itself is to be used + OutputImageBuildTimestamp = "BuildTimestamp" +) + // BuildSpec defines the desired state of Build type BuildSpec struct { // Source refers to the location where the source code is, @@ -200,6 +215,16 @@ type Image struct { // // +optional Labels map[string]string `json:"labels,omitempty"` + + // Timestamp references the optional image timestamp to be set, valid values are: + // - "Zero", to set 00:00:00 UTC on 1 January 1970 + // - "SourceTimestamp", to set the source timestamp dereived from the input source + // - "BuildTimestamp", to set the timestamp of the current build itself + // - Parsable integer number defined as the epoch seconds + // - or nil/empty to not set any specific timestamp + // + // +optional + Timestamp *string `json:"timestamp,omitempty"` } // BuildStatus defines the observed state of Build diff --git a/pkg/apis/build/v1beta1/zz_generated.deepcopy.go b/pkg/apis/build/v1beta1/zz_generated.deepcopy.go index 689cbcfe67..9c1ce78f6b 100644 --- a/pkg/apis/build/v1beta1/zz_generated.deepcopy.go +++ b/pkg/apis/build/v1beta1/zz_generated.deepcopy.go @@ -808,6 +808,11 @@ func (in *Image) DeepCopyInto(out *Image) { (*out)[key] = val } } + if in.Timestamp != nil { + in, out := &in.Timestamp, &out.Timestamp + *out = new(string) + **out = **in + } return } diff --git a/pkg/image/mutate.go b/pkg/image/mutate.go index 2f87ac30ef..10de77aa59 100644 --- a/pkg/image/mutate.go +++ b/pkg/image/mutate.go @@ -6,12 +6,32 @@ package image import ( "errors" + "time" containerreg "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/mutate" "github.com/google/go-containerregistry/pkg/v1/types" ) +// replaceManifestIn replaces a manifest in an index, because there is no +// mutate.ReplaceManifests, so therefore, it removes the old one first, +// and then add the new one +func replaceManifestIn(imageIndex containerreg.ImageIndex, descriptor containerreg.Descriptor, replacement mutate.Appendable) containerreg.ImageIndex { + imageIndex = mutate.RemoveManifests(imageIndex, func(ref containerreg.Descriptor) bool { + return ref.Digest.String() == descriptor.Digest.String() + }) + + return mutate.AppendManifests(imageIndex, mutate.IndexAddendum{ + Add: replacement, + Descriptor: containerreg.Descriptor{ + Annotations: descriptor.Annotations, + MediaType: descriptor.MediaType, + Platform: descriptor.Platform, + URLs: descriptor.URLs, + }, + }) +} + // MutateImageOrImageIndex mutates an image or image index with additional annotations and labels func MutateImageOrImageIndex(image containerreg.Image, imageIndex containerreg.ImageIndex, annotations map[string]string, labels map[string]string) (containerreg.Image, containerreg.ImageIndex, error) { if imageIndex != nil { @@ -22,12 +42,9 @@ func MutateImageOrImageIndex(image containerreg.Image, imageIndex containerreg.I if len(labels) > 0 || len(annotations) > 0 { for _, descriptor := range indexManifest.Manifests { - digest := descriptor.Digest - var appendable mutate.Appendable - switch descriptor.MediaType { case types.OCIImageIndex, types.DockerManifestList: - childImageIndex, err := imageIndex.ImageIndex(digest) + childImageIndex, err := imageIndex.ImageIndex(descriptor.Digest) if err != nil { return nil, nil, err } @@ -36,9 +53,10 @@ func MutateImageOrImageIndex(image containerreg.Image, imageIndex containerreg.I return nil, nil, err } - appendable = childImageIndex + imageIndex = replaceManifestIn(imageIndex, descriptor, childImageIndex) + case types.OCIManifestSchema1, types.DockerManifestSchema2: - image, err := imageIndex.Image(digest) + image, err := imageIndex.Image(descriptor.Digest) if err != nil { return nil, nil, err } @@ -48,25 +66,8 @@ func MutateImageOrImageIndex(image containerreg.Image, imageIndex containerreg.I return nil, nil, err } - appendable = image - default: - continue + imageIndex = replaceManifestIn(imageIndex, descriptor, image) } - - // there is no mutate.ReplaceManifests, therefore, remove the old one first, and then add the new one - imageIndex = mutate.RemoveManifests(imageIndex, func(desc containerreg.Descriptor) bool { - return desc.Digest.String() == digest.String() - }) - - imageIndex = mutate.AppendManifests(imageIndex, mutate.IndexAddendum{ - Add: appendable, - Descriptor: containerreg.Descriptor{ - Annotations: descriptor.Annotations, - MediaType: descriptor.MediaType, - Platform: descriptor.Platform, - URLs: descriptor.URLs, - }, - }) } } @@ -120,3 +121,61 @@ func mutateImage(image containerreg.Image, annotations map[string]string, labels return image, nil } + +func MutateImageOrImageIndexTimestamp(image containerreg.Image, imageIndex containerreg.ImageIndex, timestamp time.Time) (containerreg.Image, containerreg.ImageIndex, error) { + if image != nil { + image, err := mutateImageTimestamp(image, timestamp) + return image, nil, err + } + + imageIndex, err := mutateImageIndexTimestamp(imageIndex, timestamp) + return nil, imageIndex, err +} + +func mutateImageTimestamp(image containerreg.Image, timestamp time.Time) (containerreg.Image, error) { + image, err := mutate.Time(image, timestamp) + if err != nil { + return nil, err + } + + return image, nil +} + +func mutateImageIndexTimestamp(imageIndex containerreg.ImageIndex, timestamp time.Time) (containerreg.ImageIndex, error) { + indexManifest, err := imageIndex.IndexManifest() + if err != nil { + return nil, err + } + + for _, desc := range indexManifest.Manifests { + switch desc.MediaType { + case types.OCIImageIndex, types.DockerManifestList: + childImageIndex, err := imageIndex.ImageIndex(desc.Digest) + if err != nil { + return nil, err + } + + childImageIndex, err = mutateImageIndexTimestamp(childImageIndex, timestamp) + if err != nil { + return nil, err + } + + imageIndex = replaceManifestIn(imageIndex, desc, childImageIndex) + + case types.OCIManifestSchema1, types.DockerManifestSchema2: + image, err := imageIndex.Image(desc.Digest) + if err != nil { + return nil, err + } + + image, err = mutateImageTimestamp(image, timestamp) + if err != nil { + return nil, err + } + + imageIndex = replaceManifestIn(imageIndex, desc, image) + } + } + + return imageIndex, nil +} diff --git a/pkg/image/mutate_test.go b/pkg/image/mutate_test.go index 444f39b504..3c56da3f2a 100644 --- a/pkg/image/mutate_test.go +++ b/pkg/image/mutate_test.go @@ -5,6 +5,9 @@ package image_test import ( + "fmt" + "time" + containerreg "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/mutate" "github.com/google/go-containerregistry/pkg/v1/random" @@ -157,4 +160,76 @@ var _ = Describe("MutateImageOrImageIndex", func() { } }) }) + + Context("mutate creation timestamp", func() { + referenceTime := time.Unix(1700000000, 0) + + creationTimeOf := func(img containerreg.Image) time.Time { + GinkgoHelper() + cfg, err := img.ConfigFile() + Expect(err).ToNot(HaveOccurred()) + return cfg.Created.Time + } + + imagesOf := func(index containerreg.ImageIndex) (result []containerreg.Image) { + indexManifest, err := index.IndexManifest() + Expect(err).ToNot(HaveOccurred()) + + for _, desc := range indexManifest.Manifests { + img, err := index.Image(desc.Digest) + Expect(err).ToNot(HaveOccurred()) + result = append(result, img) + } + + return result + } + + Context("mutating timestamp of an image", func() { + var img containerreg.Image + + BeforeEach(func() { + var err error + img, err = random.Image(1024, 1) + Expect(err).ToNot(HaveOccurred()) + Expect(creationTimeOf(img)).ToNot(BeTemporally("==", referenceTime)) + }) + + It("should mutate an image by setting a creation timestamp", func() { + img, _, err := image.MutateImageOrImageIndexTimestamp(img, nil, referenceTime) + Expect(err).ToNot(HaveOccurred()) + Expect(creationTimeOf(img)).To(BeTemporally("==", referenceTime)) + }) + }) + + Context("mutating timestamp of an image index", func() { + var index containerreg.ImageIndex + + BeforeEach(func() { + var err error + index, err = random.Index(1024, 2, 2) + Expect(err).ToNot(HaveOccurred()) + + for _, img := range imagesOf(index) { + fmt.Fprintf(GinkgoWriter, "%v=%v\n", + func() string { + digest, err := img.Digest() + Expect(err).ToNot(HaveOccurred()) + return digest.String() + }(), + creationTimeOf(img), + ) + Expect(creationTimeOf(img)).ToNot(BeTemporally("==", referenceTime)) + } + }) + + It("should mutate an image by setting a creation timestamp", func() { + _, index, err := image.MutateImageOrImageIndexTimestamp(nil, index, referenceTime) + Expect(err).ToNot(HaveOccurred()) + + for _, img := range imagesOf(index) { + Expect(creationTimeOf(img)).To(BeTemporally("==", referenceTime)) + } + }) + }) + }) }) diff --git a/pkg/reconciler/build/build.go b/pkg/reconciler/build/build.go index ea4d246ea9..b45c376b7d 100644 --- a/pkg/reconciler/build/build.go +++ b/pkg/reconciler/build/build.go @@ -29,6 +29,7 @@ var validationTypes = [...]string{ validate.Secrets, validate.Strategies, validate.Source, + validate.Output, validate.BuildName, validate.Envs, validate.Triggers, diff --git a/pkg/reconciler/build/build_test.go b/pkg/reconciler/build/build_test.go index bd4a14ac15..3e49b426bb 100644 --- a/pkg/reconciler/build/build_test.go +++ b/pkg/reconciler/build/build_test.go @@ -552,5 +552,42 @@ var _ = Describe("Reconcile Build", func() { Expect(err).ToNot(BeNil()) }) }) + + Context("when build object has output timestamp defined", func() { + It("should fail build validation due to unsupported combination of empty source with output image timestamp set to be the source timestamp", func() { + buildSample.Spec.Output.Timestamp = pointer.String(build.OutputImageSourceTimestamp) + buildSample.Spec.Output.PushSecret = nil + buildSample.Spec.Source = &build.Source{} + + statusWriter.UpdateCalls(func(ctx context.Context, o crc.Object, sruo ...crc.SubResourceUpdateOption) error { + Expect(o).To(BeAssignableToTypeOf(&build.Build{})) + b := o.(*build.Build) + Expect(*b.Status.Reason).To(BeEquivalentTo(build.OutputTimestampNotSupported)) + Expect(*b.Status.Message).To(BeEquivalentTo("cannot use SourceTimestamp output image setting with an empty build source")) + + return nil + }) + + _, err := reconciler.Reconcile(context.TODO(), request) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should fail when the output timestamp is not a parsable number", func() { + buildSample.Spec.Output.Timestamp = pointer.String("forty-two") + buildSample.Spec.Output.PushSecret = nil + + statusWriter.UpdateCalls(func(ctx context.Context, o crc.Object, sruo ...crc.SubResourceUpdateOption) error { + Expect(o).To(BeAssignableToTypeOf(&build.Build{})) + b := o.(*build.Build) + Expect(*b.Status.Reason).To(BeEquivalentTo(build.OutputTimestampNotValid)) + Expect(*b.Status.Message).To(BeEquivalentTo("output timestamp value is invalid, must be Zero, SourceTimestamp, BuildTimestamp, or number")) + + return nil + }) + + _, err := reconciler.Reconcile(context.TODO(), request) + Expect(err).ToNot(HaveOccurred()) + }) + }) }) }) diff --git a/pkg/reconciler/buildrun/resources/image_processing.go b/pkg/reconciler/buildrun/resources/image_processing.go index 6ad80852e2..28058e58d2 100644 --- a/pkg/reconciler/buildrun/resources/image_processing.go +++ b/pkg/reconciler/buildrun/resources/image_processing.go @@ -6,12 +6,15 @@ package resources import ( "fmt" + "strconv" + "time" + + core "k8s.io/api/core/v1" build "github.com/shipwright-io/build/pkg/apis/build/v1beta1" "github.com/shipwright-io/build/pkg/config" "github.com/shipwright-io/build/pkg/reconciler/buildrun/resources/sources" pipelineapi "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" - core "k8s.io/api/core/v1" ) const ( @@ -21,7 +24,7 @@ const ( ) // SetupImageProcessing appends the image-processing step to a TaskRun if desired -func SetupImageProcessing(taskRun *pipelineapi.TaskRun, cfg *config.Config, buildOutput, buildRunOutput build.Image) { +func SetupImageProcessing(taskRun *pipelineapi.TaskRun, cfg *config.Config, creationTimestamp time.Time, buildOutput, buildRunOutput build.Image) error { stepArgs := []string{} // Check if any build step references the output-directory system parameter. If that is the case, @@ -82,6 +85,32 @@ func SetupImageProcessing(taskRun *pipelineapi.TaskRun, cfg *config.Config, buil stepArgs = append(stepArgs, convertMutateArgs("--label", labels)...) } + // check if we need to set image timestamp + if imageTimestamp := getImageTimestamp(buildOutput, buildRunOutput); imageTimestamp != nil { + switch *imageTimestamp { + case build.OutputImageZeroTimestamp: + stepArgs = append(stepArgs, "--image-timestamp", "0") + + case build.OutputImageSourceTimestamp: + if !hasTaskSpecResult(taskRun, "shp-source-default-source-timestamp") { + return fmt.Errorf("cannot use SourceTimestamp setting, because there is no source timestamp available for this source") + } + + stepArgs = append(stepArgs, "--image-timestamp-file", "$(results.shp-source-default-source-timestamp.path)") + + case build.OutputImageBuildTimestamp: + stepArgs = append(stepArgs, "--image-timestamp", strconv.FormatInt(creationTimestamp.Unix(), 10)) + + default: + if _, err := strconv.ParseInt(*imageTimestamp, 10, 64); err != nil { + return fmt.Errorf("cannot parse output timestamp %s as a number, must be %s, %s, %s, or a an integer", + *imageTimestamp, build.OutputImageZeroTimestamp, build.OutputImageSourceTimestamp, build.OutputImageBuildTimestamp) + } + + stepArgs = append(stepArgs, "--image-timestamp", *imageTimestamp) + } + } + // check if there is anything to do if len(stepArgs) > 0 { // add the image argument @@ -138,6 +167,8 @@ func SetupImageProcessing(taskRun *pipelineapi.TaskRun, cfg *config.Config, buil // append the mutate step taskRun.Spec.TaskSpec.Steps = append(taskRun.Spec.TaskSpec.Steps, imageProcessingStep) } + + return nil } // convertMutateArgs to convert the argument map to comma separated values @@ -162,3 +193,26 @@ func mergeMaps(first map[string]string, second map[string]string) map[string]str } return first } + +func getImageTimestamp(buildOutput, buildRunOutput build.Image) *string { + switch { + case buildRunOutput.Timestamp != nil: + return buildRunOutput.Timestamp + + case buildOutput.Timestamp != nil: + return buildOutput.Timestamp + + default: + return nil + } +} + +func hasTaskSpecResult(taskRun *pipelineapi.TaskRun, name string) bool { + for _, result := range taskRun.Spec.TaskSpec.Results { + if result.Name == name { + return true + } + } + + return false +} diff --git a/pkg/reconciler/buildrun/resources/image_processing_test.go b/pkg/reconciler/buildrun/resources/image_processing_test.go index a96d9ac208..fb9a6bc9f5 100644 --- a/pkg/reconciler/buildrun/resources/image_processing_test.go +++ b/pkg/reconciler/buildrun/resources/image_processing_test.go @@ -5,6 +5,8 @@ package resources_test import ( + "time" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -12,6 +14,7 @@ import ( "github.com/shipwright-io/build/pkg/config" "github.com/shipwright-io/build/pkg/reconciler/buildrun/resources" utils "github.com/shipwright-io/build/test/utils/v1beta1" + pipelineapi "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" ) @@ -20,6 +23,9 @@ var _ = Describe("Image Processing overrides", func() { config := config.NewDefaultConfig() var processedTaskRun *pipelineapi.TaskRun + // just a fixed reference timestamp for the setup function + refTimestamp := time.Unix(1234567890, 0) + Context("for a TaskRun that does not reference the output directory", func() { taskRun := &pipelineapi.TaskRun{ Spec: pipelineapi.TaskRunSpec{ @@ -36,9 +42,13 @@ var _ = Describe("Image Processing overrides", func() { Context("for a build without labels and annotation in the output", func() { BeforeEach(func() { processedTaskRun = taskRun.DeepCopy() - resources.SetupImageProcessing(processedTaskRun, config, buildv1beta1.Image{ - Image: "some-registry/some-namespace/some-image", - }, buildv1beta1.Image{}) + Expect(resources.SetupImageProcessing( + processedTaskRun, + config, + refTimestamp, + buildv1beta1.Image{Image: "some-registry/some-namespace/some-image"}, + buildv1beta1.Image{}, + )).To(Succeed()) }) It("does not add the image-processing step", func() { @@ -50,12 +60,18 @@ var _ = Describe("Image Processing overrides", func() { Context("for a build with a label in the output", func() { BeforeEach(func() { processedTaskRun = taskRun.DeepCopy() - resources.SetupImageProcessing(processedTaskRun, config, buildv1beta1.Image{ - Image: "some-registry/some-namespace/some-image", - Labels: map[string]string{ - "aKey": "aLabel", + Expect(resources.SetupImageProcessing( + processedTaskRun, + config, + refTimestamp, + buildv1beta1.Image{ + Image: "some-registry/some-namespace/some-image", + Labels: map[string]string{ + "aKey": "aLabel", + }, }, - }, buildv1beta1.Image{}) + buildv1beta1.Image{}, + )).To(Succeed()) }) It("adds the image-processing step", func() { @@ -101,16 +117,22 @@ var _ = Describe("Image Processing overrides", func() { Context("for a build with label and annotation in the output", func() { BeforeEach(func() { processedTaskRun = taskRun.DeepCopy() - resources.SetupImageProcessing(processedTaskRun, config, buildv1beta1.Image{ - Image: "some-registry/some-namespace/some-image", - Labels: map[string]string{ - "a-label": "a-value", + Expect(resources.SetupImageProcessing( + processedTaskRun, + config, + refTimestamp, + buildv1beta1.Image{ + Image: "some-registry/some-namespace/some-image", + Labels: map[string]string{ + "a-label": "a-value", + }, }, - }, buildv1beta1.Image{ - Annotations: map[string]string{ - "an-annotation": "some-value", + buildv1beta1.Image{ + Annotations: map[string]string{ + "an-annotation": "some-value", + }, }, - }) + )).To(Succeed()) }) It("adds the output-directory parameter", func() { @@ -149,9 +171,13 @@ var _ = Describe("Image Processing overrides", func() { Context("for a build without labels and annotation in the output", func() { BeforeEach(func() { processedTaskRun = taskRun.DeepCopy() - resources.SetupImageProcessing(processedTaskRun, config, buildv1beta1.Image{ - Image: "some-registry/some-namespace/some-image", - }, buildv1beta1.Image{}) + Expect(resources.SetupImageProcessing( + processedTaskRun, + config, + refTimestamp, + buildv1beta1.Image{Image: "some-registry/some-namespace/some-image"}, + buildv1beta1.Image{}, + )).To(Succeed()) }) It("adds the output-directory parameter", func() { @@ -188,10 +214,16 @@ var _ = Describe("Image Processing overrides", func() { BeforeEach(func() { processedTaskRun = taskRun.DeepCopy() someSecret := "some-secret" - resources.SetupImageProcessing(processedTaskRun, config, buildv1beta1.Image{ - Image: "some-registry/some-namespace/some-image", - PushSecret: &someSecret, - }, buildv1beta1.Image{}) + Expect(resources.SetupImageProcessing( + processedTaskRun, + config, + refTimestamp, + buildv1beta1.Image{ + Image: "some-registry/some-namespace/some-image", + PushSecret: &someSecret, + }, + buildv1beta1.Image{}, + )).To(Succeed()) }) It("adds the output-directory parameter", func() { diff --git a/pkg/reconciler/buildrun/resources/taskrun.go b/pkg/reconciler/buildrun/resources/taskrun.go index 5a4991c9a3..10ee59d719 100644 --- a/pkg/reconciler/buildrun/resources/taskrun.go +++ b/pkg/reconciler/buildrun/resources/taskrun.go @@ -324,7 +324,12 @@ func GenerateTaskRun( if buildRunOutput == nil { buildRunOutput = &buildv1beta1.Image{} } - SetupImageProcessing(expectedTaskRun, cfg, build.Spec.Output, *buildRunOutput) + + // Make sure that image-processing is setup in case it is needed, which can fail with an error + // in case some assumptions cannot be met, i.e. illegal combination of fields + if err := SetupImageProcessing(expectedTaskRun, cfg, buildRun.CreationTimestamp.Time, build.Spec.Output, *buildRunOutput); err != nil { + return nil, err + } return expectedTaskRun, nil } diff --git a/pkg/validate/output.go b/pkg/validate/output.go new file mode 100644 index 0000000000..fd5ea0eb58 --- /dev/null +++ b/pkg/validate/output.go @@ -0,0 +1,56 @@ +// Copyright The Shipwright Contributors +// +// SPDX-License-Identifier: Apache-2.0 + +package validate + +import ( + "context" + "strconv" + + build "github.com/shipwright-io/build/pkg/apis/build/v1beta1" + "k8s.io/utils/pointer" +) + +// BuildSpecOutputValidator implements validation interface to add validations for `build.spec.output`. +type BuildSpecOutputValidator struct { + Build *build.Build // build instance for analysis +} + +var _ BuildPath = &BuildSpecOutputValidator{} + +func (b *BuildSpecOutputValidator) ValidatePath(_ context.Context) error { + if b.Build.Spec.Output.Timestamp != nil { + switch *b.Build.Spec.Output.Timestamp { + case "": + // no validation required + + case build.OutputImageZeroTimestamp: + // no validation required + + case build.OutputImageSourceTimestamp: + // check that there is a source defined that can be used in combination with source timestamp + if b.isEmptySource() { + b.Build.Status.Reason = build.BuildReasonPtr(build.OutputTimestampNotSupported) + b.Build.Status.Message = pointer.String("cannot use SourceTimestamp output image setting with an empty build source") + } + + case build.OutputImageBuildTimestamp: + // no validation required + + default: + // check that value is parsable integer + if _, err := strconv.ParseInt(*b.Build.Spec.Output.Timestamp, 10, 64); err != nil { + b.Build.Status.Reason = build.BuildReasonPtr(build.OutputTimestampNotValid) + b.Build.Status.Message = pointer.String("output timestamp value is invalid, must be Zero, SourceTimestamp, BuildTimestamp, or number") + } + } + } + + return nil +} + +func (b *BuildSpecOutputValidator) isEmptySource() bool { + return b.Build.Spec.Source == nil || + b.Build.Spec.Source.Git == nil && b.Build.Spec.Source.OCIArtifact == nil && b.Build.Spec.Source.Local == nil +} diff --git a/pkg/validate/output_test.go b/pkg/validate/output_test.go new file mode 100644 index 0000000000..2cc8d27ab8 --- /dev/null +++ b/pkg/validate/output_test.go @@ -0,0 +1,102 @@ +// Copyright The Shipwright Contributors +// +// SPDX-License-Identifier: Apache-2.0 + +package validate_test + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + corev1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + . "github.com/shipwright-io/build/pkg/apis/build/v1beta1" + "github.com/shipwright-io/build/pkg/validate" +) + +var _ = Describe("BuildSpecOutputValidator", func() { + var ctx context.Context + + BeforeEach(func() { + ctx = context.TODO() + }) + + var validate = func(build *Build) { + GinkgoHelper() + + var validator = &validate.BuildSpecOutputValidator{Build: build} + Expect(validator.ValidatePath(ctx)).To(Succeed()) + } + + Context("output timestamp is specified", func() { + var sampleBuild = func(timestamp string) *Build { + return &Build{ + ObjectMeta: corev1.ObjectMeta{ + Namespace: "foo", + Name: "bar", + }, + Spec: BuildSpec{ + Source: &Source{ + Type: GitType, + Git: &Git{ + URL: "https://github.com/shipwright-io/sample-go", + }, + }, + Strategy: Strategy{ + Name: "magic", + }, + Output: Image{ + Timestamp: ×tamp, + }, + }, + } + } + + It("should pass an empty string", func() { + build := sampleBuild("") + validate(build) + Expect(build.Status.Reason).To(BeNil()) + Expect(build.Status.Message).To(BeNil()) + }) + + It("should pass with string Zero", func() { + build := sampleBuild(OutputImageZeroTimestamp) + validate(build) + Expect(build.Status.Reason).To(BeNil()) + Expect(build.Status.Message).To(BeNil()) + }) + + It("should pass with string SourceTimestamp", func() { + build := sampleBuild(OutputImageSourceTimestamp) + validate(build) + Expect(build.Status.Reason).To(BeNil()) + Expect(build.Status.Message).To(BeNil()) + }) + + It("should pass with string BuildTimestamp", func() { + build := sampleBuild(OutputImageBuildTimestamp) + validate(build) + Expect(build.Status.Reason).To(BeNil()) + Expect(build.Status.Message).To(BeNil()) + }) + + It("should fail with string SourceTimestamp in case there are no sources", func() { + build := sampleBuild(OutputImageSourceTimestamp) + build.Spec.Source = nil + + validate(build) + Expect(*build.Status.Reason).To(Equal(OutputTimestampNotSupported)) + Expect(*build.Status.Message).To(ContainSubstring("cannot use SourceTimestamp")) + }) + + It("should fail when invalid timestamp is used", func() { + build := sampleBuild("WrongValue") + + validate(build) + Expect(*build.Status.Reason).To(Equal(OutputTimestampNotValid)) + Expect(*build.Status.Message).To(ContainSubstring("output timestamp value is invalid")) + }) + }) +}) diff --git a/pkg/validate/validate.go b/pkg/validate/validate.go index 7e308152e0..91d15db46b 100644 --- a/pkg/validate/validate.go +++ b/pkg/validate/validate.go @@ -24,6 +24,8 @@ const ( SourceURL = "sourceurl" // Sources for validating `spec.sources` entries Source = "source" + // Output for validating `spec.output` entry + Output = "output" // BuildName for validating `metadata.name` entry BuildName = "buildname" // Envs for validating `spec.env` entries @@ -65,6 +67,8 @@ func NewValidation( return &OwnerRef{Build: build, Client: client, Scheme: scheme}, nil case Source: return &SourceRef{Build: build}, nil + case Output: + return &BuildSpecOutputValidator{Build: build}, nil case BuildName: return &BuildNameRef{Build: build}, nil case Envs: diff --git a/test/e2e/v1alpha1/common_suite_test.go b/test/e2e/v1alpha1/common_suite_test.go index 2c7475342d..d7c1a18f7f 100644 --- a/test/e2e/v1alpha1/common_suite_test.go +++ b/test/e2e/v1alpha1/common_suite_test.go @@ -13,6 +13,7 @@ import ( "time" . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" core "k8s.io/api/core/v1" meta "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -78,6 +79,11 @@ func (b *buildPrototype) SourceGit(repository string) *buildPrototype { return b } +func (b *buildPrototype) SourceGitRevision(revision string) *buildPrototype { + b.build.Spec.Source.Revision = pointer.String(revision) + return b +} + func (b *buildPrototype) SourceBundle(image string) *buildPrototype { if b.build.Spec.Source.BundleContainer == nil { b.build.Spec.Source.BundleContainer = &buildv1alpha1.BundleContainer{} @@ -104,11 +110,6 @@ func (b *buildPrototype) Dockerfile(dockerfile string) *buildPrototype { return b } -func (b *buildPrototype) OutputImage(image string) *buildPrototype { - b.build.Spec.Output.Image = image - return b -} - func (b *buildPrototype) determineParameterIndex(name string) int { index := -1 for i, paramValue := range b.build.Spec.ParamValues { @@ -207,6 +208,11 @@ func (b *buildPrototype) StringParamValueFromSecret(name string, secretName stri return b } +func (b *buildPrototype) OutputImage(image string) *buildPrototype { + b.build.Spec.Output.Image = image + return b +} + func (b *buildPrototype) OutputImageCredentials(name string) *buildPrototype { if name != "" { b.build.Spec.Output.Credentials = &core.LocalObjectReference{Name: name} @@ -221,6 +227,11 @@ func (b *buildPrototype) OutputImageInsecure(insecure bool) *buildPrototype { return b } +func (b *buildPrototype) OutputTimestamp(timestampString string) *buildPrototype { + b.build.Spec.Output.Timestamp = ×tampString + return b +} + func (b buildPrototype) Create() (build *buildv1alpha1.Build, err error) { ctx := context.Background() @@ -390,6 +401,16 @@ func (b *buildRunPrototype) Create() (*buildv1alpha1.BuildRun, error) { Create(context.Background(), &b.buildRun, meta.CreateOptions{}) } +func (b *buildRunPrototype) MustCreate() *buildv1alpha1.BuildRun { + GinkgoHelper() + + buildrun, err := b.Create() + Expect(err).ToNot(HaveOccurred()) + Expect(buildrun).ToNot(BeNil()) + + return buildrun +} + // Logf logs data func Logf(format string, args ...interface{}) { currentTime := time.Now().UTC().Format(time.RFC3339) diff --git a/test/e2e/v1alpha1/e2e_image_mutate_test.go b/test/e2e/v1alpha1/e2e_image_mutate_test.go index d2fdacb5f0..024edf608f 100644 --- a/test/e2e/v1alpha1/e2e_image_mutate_test.go +++ b/test/e2e/v1alpha1/e2e_image_mutate_test.go @@ -5,10 +5,17 @@ package e2e_test import ( - containerreg "github.com/google/go-containerregistry/pkg/v1" + "fmt" + "os" + "strconv" + "time" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/google/go-containerregistry/pkg/name" + containerreg "github.com/google/go-containerregistry/pkg/v1" + buildv1alpha1 "github.com/shipwright-io/build/pkg/apis/build/v1alpha1" ) @@ -20,6 +27,27 @@ var _ = Describe("For a Kubernetes cluster with Tekton and build installed", fun buildRun *buildv1alpha1.BuildRun ) + annotationsOf := func(img containerreg.Image) map[string]string { + GinkgoHelper() + manifest, err := img.Manifest() + Expect(err).To(BeNil()) + return manifest.Annotations + } + + labelsOf := func(img containerreg.Image) map[string]string { + GinkgoHelper() + config, err := img.ConfigFile() + Expect(err).To(BeNil()) + return config.Config.Labels + } + + creationTimeOf := func(img containerreg.Image) time.Time { + GinkgoHelper() + cfg, err := img.ConfigFile() + Expect(err).ToNot(HaveOccurred()) + return cfg.Created.Time + } + AfterEach(func() { if CurrentSpecReport().Failed() { printTestFailureDebugInfo(testBuild, testBuild.Namespace, testID) @@ -62,28 +90,182 @@ var _ = Describe("For a Kubernetes cluster with Tekton and build installed", fun testBuild.ValidateImageDigest(buildRun) image := testBuild.GetImage(buildRun) + Expect(annotationsOf(image)).To(HaveKeyWithValue("org.opencontainers.image.url", "https://my-company.com/images")) + Expect(labelsOf(image)).To(HaveKeyWithValue("maintainer", "team@my-company.com")) + }) + }) - Expect( - getImageAnnotation(image, "org.opencontainers.image.url"), - ).To(Equal("https://my-company.com/images")) + Context("mutate image timestamp", func() { + var outputImage name.Reference - Expect( - getImageLabel(image, "maintainer"), - ).To(Equal("team@my-company.com")) + var insecure = func() bool { + if val, ok := os.LookupEnv(EnvVarImageRepoInsecure); ok { + if result, err := strconv.ParseBool(val); err == nil { + return result + } + } + + return false + }() + + BeforeEach(func() { + testID = generateTestID("timestamp") + + outputImage, err = name.ParseReference(fmt.Sprintf("%s/%s:%s", + os.Getenv(EnvVarImageRepo), + testID, + "latest", + )) + Expect(err).ToNot(HaveOccurred()) }) - }) -}) -func getImageAnnotation(img containerreg.Image, annotation string) string { - manifest, err := img.Manifest() - Expect(err).To(BeNil()) + Context("when using BuildKit based Dockerfile build", func() { + var sampleBuildRun = func(outputTimestamp string) *buildv1alpha1.BuildRun { + return NewBuildRunPrototype(). + Namespace(testBuild.Namespace). + Name(testID). + WithBuildSpec(NewBuildPrototype(). + ClusterBuildStrategy("buildkit"). + Namespace(testBuild.Namespace). + Name(testID). + SourceGit("https://github.com/shipwright-io/sample-nodejs"). + SourceGitRevision("0be20591d7096bef165949c22f6059f5d8eb6a85"). + SourceContextDir("docker-build"). + Dockerfile("Dockerfile"). + OutputImage(outputImage.String()). + OutputImageCredentials(os.Getenv(EnvVarImageRepoSecret)). + OutputImageInsecure(insecure). + OutputTimestamp(outputTimestamp). + BuildSpec()). + MustCreate() + } - return manifest.Annotations[annotation] -} + It("should create an image with creation timestamp set to unix epoch timestamp zero", func() { + buildRun := validateBuildRunToSucceed(testBuild, sampleBuildRun(buildv1alpha1.OutputImageZeroTimestamp)) + image := testBuild.GetImage(buildRun) + Expect(creationTimeOf(image)).To(BeTemporally("==", time.Unix(0, 0))) + }) -func getImageLabel(img containerreg.Image, label string) string { - config, err := img.ConfigFile() - Expect(err).To(BeNil()) + It("should create an image with creation timestamp set to the source timestamp", func() { + buildRun := validateBuildRunToSucceed(testBuild, sampleBuildRun(buildv1alpha1.OutputImageSourceTimestamp)) + image := testBuild.GetImage(buildRun) + Expect(creationTimeOf(image)).To(BeTemporally("==", time.Unix(1699261787, 0))) + }) - return config.Config.Labels[label] -} + It("should create an image with creation timestamp set to the build timestamp", func() { + buildRun := validateBuildRunToSucceed(testBuild, sampleBuildRun(buildv1alpha1.OutputImageBuildTimestamp)) + image := testBuild.GetImage(buildRun) + Expect(creationTimeOf(image)).To(BeTemporally("==", buildRun.CreationTimestamp.Time)) + }) + + It("should create an image with creation timestamp set to a custom timestamp", func() { + buildRun := validateBuildRunToSucceed(testBuild, sampleBuildRun("1691691691")) + image := testBuild.GetImage(buildRun) + Expect(creationTimeOf(image)).To(BeTemporally("==", time.Unix(1691691691, 0))) + }) + }) + + Context("when using Buildpacks build", func() { + var sampleBuildRun = func(outputTimestamp string) *buildv1alpha1.BuildRun { + return NewBuildRunPrototype(). + Namespace(testBuild.Namespace). + Name(testID). + WithBuildSpec(NewBuildPrototype(). + ClusterBuildStrategy("buildpacks-v3"). + Namespace(testBuild.Namespace). + Name(testID). + SourceGit("https://github.com/shipwright-io/sample-nodejs"). + SourceGitRevision("0be20591d7096bef165949c22f6059f5d8eb6a85"). + SourceContextDir("source-build"). + OutputImage(outputImage.String()). + OutputImageCredentials(os.Getenv(EnvVarImageRepoSecret)). + OutputImageInsecure(insecure). + OutputTimestamp(outputTimestamp). + BuildSpec()). + MustCreate() + } + + It("should create an image with creation timestamp set to unix epoch timestamp zero", func() { + buildRun := validateBuildRunToSucceed(testBuild, sampleBuildRun(buildv1alpha1.OutputImageZeroTimestamp)) + image := testBuild.GetImage(buildRun) + Expect(creationTimeOf(image)).To(BeTemporally("==", time.Unix(0, 0))) + }) + + It("should create an image with creation timestamp set to the source timestamp", func() { + buildRun := validateBuildRunToSucceed(testBuild, sampleBuildRun(buildv1alpha1.OutputImageSourceTimestamp)) + image := testBuild.GetImage(buildRun) + Expect(creationTimeOf(image)).To(BeTemporally("==", time.Unix(1699261787, 0))) + }) + + It("should create an image with creation timestamp set to the build timestamp", func() { + buildRun := validateBuildRunToSucceed(testBuild, sampleBuildRun(buildv1alpha1.OutputImageBuildTimestamp)) + image := testBuild.GetImage(buildRun) + Expect(creationTimeOf(image)).To(BeTemporally("==", buildRun.CreationTimestamp.Time)) + }) + + It("should create an image with creation timestamp set to a custom timestamp", func() { + buildRun := validateBuildRunToSucceed(testBuild, sampleBuildRun("1691691691")) + image := testBuild.GetImage(buildRun) + Expect(creationTimeOf(image)).To(BeTemporally("==", time.Unix(1691691691, 0))) + }) + }) + + Context("edge cases", func() { + It("should fail run a build run when source timestamp is used with an empty source", func() { + buildRun = NewBuildRunPrototype(). + Namespace(testBuild.Namespace). + Name(testID). + WithBuildSpec(NewBuildPrototype(). + ClusterBuildStrategy("buildkit"). + Namespace(testBuild.Namespace). + Name(testID). + OutputImage(outputImage.String()). + OutputImageCredentials(os.Getenv(EnvVarImageRepoSecret)). + OutputImageInsecure(insecure). + OutputTimestamp(buildv1alpha1.OutputImageSourceTimestamp). + BuildSpec()). + MustCreate() + + Expect(testBuild.CreateBR(buildRun)).ToNot(Succeed()) + + buildRun, err = testBuild.GetBRTillCompletion(buildRun.Name) + Expect(err).ToNot(HaveOccurred()) + + condition := buildRun.Status.GetCondition(buildv1alpha1.Succeeded) + Expect(condition).ToNot(BeNil()) + Expect(condition.Reason).To(ContainSubstring("TaskRunGenerationFailed")) + Expect(condition.Message).To(ContainSubstring("cannot use SourceTimestamp setting")) + }) + + It("should fail fail when output timestamp value is not valid", func() { + buildRun = NewBuildRunPrototype(). + Namespace(testBuild.Namespace). + Name(testID). + WithBuildSpec(NewBuildPrototype(). + ClusterBuildStrategy("buildkit"). + Namespace(testBuild.Namespace). + Name(testID). + SourceGit("https://github.com/shipwright-io/sample-nodejs"). + SourceGitRevision("0be20591d7096bef165949c22f6059f5d8eb6a85"). + SourceContextDir("docker-build"). + Dockerfile("Dockerfile"). + OutputImage(outputImage.String()). + OutputImageCredentials(os.Getenv(EnvVarImageRepoSecret)). + OutputImageInsecure(insecure). + OutputTimestamp("WrongValue"). + BuildSpec()). + MustCreate() + + Expect(testBuild.CreateBR(buildRun)).ToNot(Succeed()) + + buildRun, err = testBuild.GetBRTillCompletion(buildRun.Name) + Expect(err).ToNot(HaveOccurred()) + + condition := buildRun.Status.GetCondition(buildv1alpha1.Succeeded) + Expect(condition).ToNot(BeNil()) + Expect(condition.Reason).To(ContainSubstring("Failed")) + Expect(condition.Message).To(ContainSubstring("cannot parse output timestamp")) + }) + }) + }) +}) diff --git a/test/e2e/v1beta1/common_suite_test.go b/test/e2e/v1beta1/common_suite_test.go index a5dc8f8d25..699e0c9a4d 100644 --- a/test/e2e/v1beta1/common_suite_test.go +++ b/test/e2e/v1beta1/common_suite_test.go @@ -13,6 +13,7 @@ import ( "time" . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" core "k8s.io/api/core/v1" meta "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -101,6 +102,14 @@ func (b *buildPrototype) SourceGit(repository string) *buildPrototype { return b } +func (b *buildPrototype) SourceGitRevision(revision string) *buildPrototype { + if b.build.Spec.Source.Git == nil { + b.build.Spec.Source.Git = &buildv1beta1.Git{} + } + b.build.Spec.Source.Git.Revision = &revision + return b +} + func (b *buildPrototype) SourceBundle(image string) *buildPrototype { if b.build.Spec.Source == nil { b.build.Spec.Source = &buildv1beta1.Source{} @@ -256,6 +265,11 @@ func (b *buildPrototype) OutputImageInsecure(insecure bool) *buildPrototype { return b } +func (b *buildPrototype) OutputTimestamp(timestampString string) *buildPrototype { + b.build.Spec.Output.Timestamp = ×tampString + return b +} + func (b buildPrototype) Create() (build *buildv1beta1.Build, err error) { ctx := context.Background() @@ -425,6 +439,16 @@ func (b *buildRunPrototype) Create() (*buildv1beta1.BuildRun, error) { Create(context.Background(), &b.buildRun, meta.CreateOptions{}) } +func (b *buildRunPrototype) MustCreate() *buildv1beta1.BuildRun { + GinkgoHelper() + + buildrun, err := b.Create() + Expect(err).ToNot(HaveOccurred()) + Expect(buildrun).ToNot(BeNil()) + + return buildrun +} + // Logf logs data func Logf(format string, args ...interface{}) { currentTime := time.Now().UTC().Format(time.RFC3339) diff --git a/test/e2e/v1beta1/e2e_image_mutate_test.go b/test/e2e/v1beta1/e2e_image_mutate_test.go index 965a58f1ac..8a36253c94 100644 --- a/test/e2e/v1beta1/e2e_image_mutate_test.go +++ b/test/e2e/v1beta1/e2e_image_mutate_test.go @@ -5,10 +5,17 @@ package e2e_test import ( - containerreg "github.com/google/go-containerregistry/pkg/v1" + "fmt" + "os" + "strconv" + "time" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/google/go-containerregistry/pkg/name" + containerreg "github.com/google/go-containerregistry/pkg/v1" + buildv1beta1 "github.com/shipwright-io/build/pkg/apis/build/v1beta1" ) @@ -20,6 +27,27 @@ var _ = Describe("For a Kubernetes cluster with Tekton and build installed", fun buildRun *buildv1beta1.BuildRun ) + annotationsOf := func(img containerreg.Image) map[string]string { + GinkgoHelper() + manifest, err := img.Manifest() + Expect(err).To(BeNil()) + return manifest.Annotations + } + + labelsOf := func(img containerreg.Image) map[string]string { + GinkgoHelper() + config, err := img.ConfigFile() + Expect(err).To(BeNil()) + return config.Config.Labels + } + + creationTimeOf := func(img containerreg.Image) time.Time { + GinkgoHelper() + cfg, err := img.ConfigFile() + Expect(err).ToNot(HaveOccurred()) + return cfg.Created.Time + } + AfterEach(func() { if CurrentSpecReport().Failed() { printTestFailureDebugInfo(testBuild, testBuild.Namespace, testID) @@ -62,28 +90,182 @@ var _ = Describe("For a Kubernetes cluster with Tekton and build installed", fun testBuild.ValidateImageDigest(buildRun) image := testBuild.GetImage(buildRun) + Expect(annotationsOf(image)).To(HaveKeyWithValue("org.opencontainers.image.url", "https://my-company.com/images")) + Expect(labelsOf(image)).To(HaveKeyWithValue("maintainer", "team@my-company.com")) + }) + }) - Expect( - getImageAnnotation(image, "org.opencontainers.image.url"), - ).To(Equal("https://my-company.com/images")) + Context("mutate image timestamp", func() { + var outputImage name.Reference - Expect( - getImageLabel(image, "maintainer"), - ).To(Equal("team@my-company.com")) + var insecure = func() bool { + if val, ok := os.LookupEnv(EnvVarImageRepoInsecure); ok { + if result, err := strconv.ParseBool(val); err == nil { + return result + } + } + + return false + }() + + BeforeEach(func() { + testID = generateTestID("timestamp") + + outputImage, err = name.ParseReference(fmt.Sprintf("%s/%s:%s", + os.Getenv(EnvVarImageRepo), + testID, + "latest", + )) + Expect(err).ToNot(HaveOccurred()) }) - }) -}) -func getImageAnnotation(img containerreg.Image, annotation string) string { - manifest, err := img.Manifest() - Expect(err).To(BeNil()) + Context("when using BuildKit based Dockerfile build", func() { + var sampleBuildRun = func(outputTimestamp string) *buildv1beta1.BuildRun { + return NewBuildRunPrototype(). + Namespace(testBuild.Namespace). + Name(testID). + WithBuildSpec(NewBuildPrototype(). + ClusterBuildStrategy("buildkit"). + Namespace(testBuild.Namespace). + Name(testID). + SourceGit("https://github.com/shipwright-io/sample-nodejs"). + SourceGitRevision("0be20591d7096bef165949c22f6059f5d8eb6a85"). + SourceContextDir("docker-build"). + Dockerfile("Dockerfile"). + OutputImage(outputImage.String()). + OutputImageCredentials(os.Getenv(EnvVarImageRepoSecret)). + OutputImageInsecure(insecure). + OutputTimestamp(outputTimestamp). + BuildSpec()). + MustCreate() + } - return manifest.Annotations[annotation] -} + It("should create an image with creation timestamp set to unix epoch timestamp zero", func() { + buildRun := validateBuildRunToSucceed(testBuild, sampleBuildRun(buildv1beta1.OutputImageZeroTimestamp)) + image := testBuild.GetImage(buildRun) + Expect(creationTimeOf(image)).To(BeTemporally("==", time.Unix(0, 0))) + }) -func getImageLabel(img containerreg.Image, label string) string { - config, err := img.ConfigFile() - Expect(err).To(BeNil()) + It("should create an image with creation timestamp set to the source timestamp", func() { + buildRun := validateBuildRunToSucceed(testBuild, sampleBuildRun(buildv1beta1.OutputImageSourceTimestamp)) + image := testBuild.GetImage(buildRun) + Expect(creationTimeOf(image)).To(BeTemporally("==", time.Unix(1699261787, 0))) + }) - return config.Config.Labels[label] -} + It("should create an image with creation timestamp set to the build timestamp", func() { + buildRun := validateBuildRunToSucceed(testBuild, sampleBuildRun(buildv1beta1.OutputImageBuildTimestamp)) + image := testBuild.GetImage(buildRun) + Expect(creationTimeOf(image)).To(BeTemporally("==", buildRun.CreationTimestamp.Time)) + }) + + It("should create an image with creation timestamp set to a custom timestamp", func() { + buildRun := validateBuildRunToSucceed(testBuild, sampleBuildRun("1691691691")) + image := testBuild.GetImage(buildRun) + Expect(creationTimeOf(image)).To(BeTemporally("==", time.Unix(1691691691, 0))) + }) + }) + + Context("when using Buildpacks build", func() { + var sampleBuildRun = func(outputTimestamp string) *buildv1beta1.BuildRun { + return NewBuildRunPrototype(). + Namespace(testBuild.Namespace). + Name(testID). + WithBuildSpec(NewBuildPrototype(). + ClusterBuildStrategy("buildpacks-v3"). + Namespace(testBuild.Namespace). + Name(testID). + SourceGit("https://github.com/shipwright-io/sample-nodejs"). + SourceGitRevision("0be20591d7096bef165949c22f6059f5d8eb6a85"). + SourceContextDir("source-build"). + OutputImage(outputImage.String()). + OutputImageCredentials(os.Getenv(EnvVarImageRepoSecret)). + OutputImageInsecure(insecure). + OutputTimestamp(outputTimestamp). + BuildSpec()). + MustCreate() + } + + It("should create an image with creation timestamp set to unix epoch timestamp zero", func() { + buildRun := validateBuildRunToSucceed(testBuild, sampleBuildRun(buildv1beta1.OutputImageZeroTimestamp)) + image := testBuild.GetImage(buildRun) + Expect(creationTimeOf(image)).To(BeTemporally("==", time.Unix(0, 0))) + }) + + It("should create an image with creation timestamp set to the source timestamp", func() { + buildRun := validateBuildRunToSucceed(testBuild, sampleBuildRun(buildv1beta1.OutputImageSourceTimestamp)) + image := testBuild.GetImage(buildRun) + Expect(creationTimeOf(image)).To(BeTemporally("==", time.Unix(1699261787, 0))) + }) + + It("should create an image with creation timestamp set to the build timestamp", func() { + buildRun := validateBuildRunToSucceed(testBuild, sampleBuildRun(buildv1beta1.OutputImageBuildTimestamp)) + image := testBuild.GetImage(buildRun) + Expect(creationTimeOf(image)).To(BeTemporally("==", buildRun.CreationTimestamp.Time)) + }) + + It("should create an image with creation timestamp set to a custom timestamp", func() { + buildRun := validateBuildRunToSucceed(testBuild, sampleBuildRun("1691691691")) + image := testBuild.GetImage(buildRun) + Expect(creationTimeOf(image)).To(BeTemporally("==", time.Unix(1691691691, 0))) + }) + }) + + Context("edge cases", func() { + It("should fail run a build run when source timestamp is used with an empty source", func() { + buildRun = NewBuildRunPrototype(). + Namespace(testBuild.Namespace). + Name(testID). + WithBuildSpec(NewBuildPrototype(). + ClusterBuildStrategy("buildkit"). + Namespace(testBuild.Namespace). + Name(testID). + OutputImage(outputImage.String()). + OutputImageCredentials(os.Getenv(EnvVarImageRepoSecret)). + OutputImageInsecure(insecure). + OutputTimestamp(buildv1beta1.OutputImageSourceTimestamp). + BuildSpec()). + MustCreate() + + Expect(testBuild.CreateBR(buildRun)).ToNot(Succeed()) + + buildRun, err = testBuild.GetBRTillCompletion(buildRun.Name) + Expect(err).ToNot(HaveOccurred()) + + condition := buildRun.Status.GetCondition(buildv1beta1.Succeeded) + Expect(condition).ToNot(BeNil()) + Expect(condition.Reason).To(ContainSubstring("TaskRunGenerationFailed")) + Expect(condition.Message).To(ContainSubstring("cannot use SourceTimestamp setting")) + }) + + It("should fail fail when output timestamp value is not valid", func() { + buildRun = NewBuildRunPrototype(). + Namespace(testBuild.Namespace). + Name(testID). + WithBuildSpec(NewBuildPrototype(). + ClusterBuildStrategy("buildkit"). + Namespace(testBuild.Namespace). + Name(testID). + SourceGit("https://github.com/shipwright-io/sample-nodejs"). + SourceGitRevision("0be20591d7096bef165949c22f6059f5d8eb6a85"). + SourceContextDir("docker-build"). + Dockerfile("Dockerfile"). + OutputImage(outputImage.String()). + OutputImageCredentials(os.Getenv(EnvVarImageRepoSecret)). + OutputImageInsecure(insecure). + OutputTimestamp("WrongValue"). + BuildSpec()). + MustCreate() + + Expect(testBuild.CreateBR(buildRun)).ToNot(Succeed()) + + buildRun, err = testBuild.GetBRTillCompletion(buildRun.Name) + Expect(err).ToNot(HaveOccurred()) + + condition := buildRun.Status.GetCondition(buildv1beta1.Succeeded) + Expect(condition).ToNot(BeNil()) + Expect(condition.Reason).To(ContainSubstring("Failed")) + Expect(condition.Message).To(ContainSubstring("cannot parse output timestamp")) + }) + }) + }) +}) diff --git a/vendor/github.com/google/go-containerregistry/internal/windows/windows.go b/vendor/github.com/google/go-containerregistry/internal/windows/windows.go new file mode 100644 index 0000000000..62d04cfb30 --- /dev/null +++ b/vendor/github.com/google/go-containerregistry/internal/windows/windows.go @@ -0,0 +1,114 @@ +// Copyright 2021 Google LLC All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package windows + +import ( + "archive/tar" + "bytes" + "errors" + "fmt" + "io" + "path" + "strings" + + "github.com/google/go-containerregistry/internal/gzip" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/tarball" +) + +// userOwnerAndGroupSID is a magic value needed to make the binary executable +// in a Windows container. +// +// owner: BUILTIN/Users group: BUILTIN/Users ($sddlValue="O:BUG:BU") +const userOwnerAndGroupSID = "AQAAgBQAAAAkAAAAAAAAAAAAAAABAgAAAAAABSAAAAAhAgAAAQIAAAAAAAUgAAAAIQIAAA==" + +// Windows returns a Layer that is converted to be pullable on Windows. +func Windows(layer v1.Layer) (v1.Layer, error) { + // TODO: do this lazily. + + layerReader, err := layer.Uncompressed() + if err != nil { + return nil, fmt.Errorf("getting layer: %w", err) + } + defer layerReader.Close() + tarReader := tar.NewReader(layerReader) + w := new(bytes.Buffer) + tarWriter := tar.NewWriter(w) + defer tarWriter.Close() + + for _, dir := range []string{"Files", "Hives"} { + if err := tarWriter.WriteHeader(&tar.Header{ + Name: dir, + Typeflag: tar.TypeDir, + // Use a fixed Mode, so that this isn't sensitive to the directory and umask + // under which it was created. Additionally, windows can only set 0222, + // 0444, or 0666, none of which are executable. + Mode: 0555, + Format: tar.FormatPAX, + }); err != nil { + return nil, fmt.Errorf("writing %s directory: %w", dir, err) + } + } + + for { + header, err := tarReader.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, fmt.Errorf("reading layer: %w", err) + } + + if strings.HasPrefix(header.Name, "Files/") { + return nil, fmt.Errorf("file path %q already suitable for Windows", header.Name) + } + + header.Name = path.Join("Files", header.Name) + header.Format = tar.FormatPAX + + // TODO: this seems to make the file executable on Windows; + // only do this if the file should be executable. + if header.PAXRecords == nil { + header.PAXRecords = map[string]string{} + } + header.PAXRecords["MSWINDOWS.rawsd"] = userOwnerAndGroupSID + + if err := tarWriter.WriteHeader(header); err != nil { + return nil, fmt.Errorf("writing tar header: %w", err) + } + + if header.Typeflag == tar.TypeReg { + if _, err = io.Copy(tarWriter, tarReader); err != nil { + return nil, fmt.Errorf("writing layer file: %w", err) + } + } + } + + if err := tarWriter.Close(); err != nil { + return nil, err + } + + b := w.Bytes() + // gzip the contents, then create the layer + opener := func() (io.ReadCloser, error) { + return gzip.ReadCloser(io.NopCloser(bytes.NewReader(b))), nil + } + layer, err = tarball.LayerFromOpener(opener) + if err != nil { + return nil, fmt.Errorf("creating layer: %w", err) + } + + return layer, nil +} diff --git a/vendor/github.com/google/go-containerregistry/pkg/crane/append.go b/vendor/github.com/google/go-containerregistry/pkg/crane/append.go new file mode 100644 index 0000000000..f1c2ef69ae --- /dev/null +++ b/vendor/github.com/google/go-containerregistry/pkg/crane/append.go @@ -0,0 +1,114 @@ +// Copyright 2018 Google LLC All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package crane + +import ( + "fmt" + "os" + + "github.com/google/go-containerregistry/internal/windows" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/stream" + "github.com/google/go-containerregistry/pkg/v1/tarball" + "github.com/google/go-containerregistry/pkg/v1/types" +) + +func isWindows(img v1.Image) (bool, error) { + cfg, err := img.ConfigFile() + if err != nil { + return false, err + } + return cfg != nil && cfg.OS == "windows", nil +} + +// Append reads a layer from path and appends it the the v1.Image base. +// +// If the base image is a Windows base image (i.e., its config.OS is +// "windows"), the contents of the tarballs will be modified to be suitable for +// a Windows container image.`, +func Append(base v1.Image, paths ...string) (v1.Image, error) { + if base == nil { + return nil, fmt.Errorf("invalid argument: base") + } + + win, err := isWindows(base) + if err != nil { + return nil, fmt.Errorf("getting base image: %w", err) + } + + baseMediaType, err := base.MediaType() + + if err != nil { + return nil, fmt.Errorf("getting base image media type: %w", err) + } + + layerType := types.DockerLayer + + if baseMediaType == types.OCIManifestSchema1 { + layerType = types.OCILayer + } + + layers := make([]v1.Layer, 0, len(paths)) + for _, path := range paths { + layer, err := getLayer(path, layerType) + if err != nil { + return nil, fmt.Errorf("reading layer %q: %w", path, err) + } + + if win { + layer, err = windows.Windows(layer) + if err != nil { + return nil, fmt.Errorf("converting %q for Windows: %w", path, err) + } + } + + layers = append(layers, layer) + } + + return mutate.AppendLayers(base, layers...) +} + +func getLayer(path string, layerType types.MediaType) (v1.Layer, error) { + f, err := streamFile(path) + if err != nil { + return nil, err + } + if f != nil { + return stream.NewLayer(f, stream.WithMediaType(layerType)), nil + } + + return tarball.LayerFromFile(path, tarball.WithMediaType(layerType)) +} + +// If we're dealing with a named pipe, trying to open it multiple times will +// fail, so we need to do a streaming upload. +// +// returns nil, nil for non-streaming files +func streamFile(path string) (*os.File, error) { + if path == "-" { + return os.Stdin, nil + } + fi, err := os.Stat(path) + if err != nil { + return nil, err + } + + if !fi.Mode().IsRegular() { + return os.Open(path) + } + + return nil, nil +} diff --git a/vendor/github.com/google/go-containerregistry/pkg/crane/catalog.go b/vendor/github.com/google/go-containerregistry/pkg/crane/catalog.go new file mode 100644 index 0000000000..f30800cca3 --- /dev/null +++ b/vendor/github.com/google/go-containerregistry/pkg/crane/catalog.go @@ -0,0 +1,35 @@ +// Copyright 2019 Google LLC All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package crane + +import ( + "context" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/remote" +) + +// Catalog returns the repositories in a registry's catalog. +func Catalog(src string, opt ...Option) (res []string, err error) { + o := makeOptions(opt...) + reg, err := name.NewRegistry(src, o.Name...) + if err != nil { + return nil, err + } + + // This context gets overridden by remote.WithContext, which is set by + // crane.WithContext. + return remote.Catalog(context.Background(), reg, o.Remote...) +} diff --git a/vendor/github.com/google/go-containerregistry/pkg/crane/config.go b/vendor/github.com/google/go-containerregistry/pkg/crane/config.go new file mode 100644 index 0000000000..3e55cc93a7 --- /dev/null +++ b/vendor/github.com/google/go-containerregistry/pkg/crane/config.go @@ -0,0 +1,24 @@ +// Copyright 2018 Google LLC All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package crane + +// Config returns the config file for the remote image ref. +func Config(ref string, opt ...Option) ([]byte, error) { + i, _, err := getImage(ref, opt...) + if err != nil { + return nil, err + } + return i.RawConfigFile() +} diff --git a/vendor/github.com/google/go-containerregistry/pkg/crane/copy.go b/vendor/github.com/google/go-containerregistry/pkg/crane/copy.go new file mode 100644 index 0000000000..bbdf5481fd --- /dev/null +++ b/vendor/github.com/google/go-containerregistry/pkg/crane/copy.go @@ -0,0 +1,181 @@ +// Copyright 2018 Google LLC All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package crane + +import ( + "errors" + "fmt" + "net/http" + + "github.com/google/go-containerregistry/pkg/logs" + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/go-containerregistry/pkg/v1/remote/transport" + "golang.org/x/sync/errgroup" +) + +// Copy copies a remote image or index from src to dst. +func Copy(src, dst string, opt ...Option) error { + o := makeOptions(opt...) + srcRef, err := name.ParseReference(src, o.Name...) + if err != nil { + return fmt.Errorf("parsing reference %q: %w", src, err) + } + + dstRef, err := name.ParseReference(dst, o.Name...) + if err != nil { + return fmt.Errorf("parsing reference for %q: %w", dst, err) + } + + puller, err := remote.NewPuller(o.Remote...) + if err != nil { + return err + } + + if tag, ok := dstRef.(name.Tag); ok { + if o.noclobber { + logs.Progress.Printf("Checking existing tag %v", tag) + head, err := puller.Head(o.ctx, tag) + var terr *transport.Error + if errors.As(err, &terr) { + if terr.StatusCode != http.StatusNotFound && terr.StatusCode != http.StatusForbidden { + return err + } + } else if err != nil { + return err + } + + if head != nil { + return fmt.Errorf("refusing to clobber existing tag %s@%s", tag, head.Digest) + } + } + } + + pusher, err := remote.NewPusher(o.Remote...) + if err != nil { + return err + } + + logs.Progress.Printf("Copying from %v to %v", srcRef, dstRef) + desc, err := puller.Get(o.ctx, srcRef) + if err != nil { + return fmt.Errorf("fetching %q: %w", src, err) + } + + if o.Platform == nil { + return pusher.Push(o.ctx, dstRef, desc) + } + + // If platform is explicitly set, don't copy the whole index, just the appropriate image. + img, err := desc.Image() + if err != nil { + return err + } + return pusher.Push(o.ctx, dstRef, img) +} + +// CopyRepository copies every tag from src to dst. +func CopyRepository(src, dst string, opt ...Option) error { + o := makeOptions(opt...) + + srcRepo, err := name.NewRepository(src, o.Name...) + if err != nil { + return err + } + + dstRepo, err := name.NewRepository(dst, o.Name...) + if err != nil { + return fmt.Errorf("parsing reference for %q: %w", dst, err) + } + + puller, err := remote.NewPuller(o.Remote...) + if err != nil { + return err + } + + ignoredTags := map[string]struct{}{} + if o.noclobber { + // TODO: It would be good to propagate noclobber down into remote so we can use Etags. + have, err := puller.List(o.ctx, dstRepo) + if err != nil { + var terr *transport.Error + if errors.As(err, &terr) { + // Some registries create repository on first push, so listing tags will fail. + // If we see 404 or 403, assume we failed because the repository hasn't been created yet. + if !(terr.StatusCode == http.StatusNotFound || terr.StatusCode == http.StatusForbidden) { + return err + } + } else { + return err + } + } + for _, tag := range have { + ignoredTags[tag] = struct{}{} + } + } + + pusher, err := remote.NewPusher(o.Remote...) + if err != nil { + return err + } + + lister, err := puller.Lister(o.ctx, srcRepo) + if err != nil { + return err + } + + g, ctx := errgroup.WithContext(o.ctx) + g.SetLimit(o.jobs) + + for lister.HasNext() { + tags, err := lister.Next(ctx) + if err != nil { + return err + } + + for _, tag := range tags.Tags { + tag := tag + + if o.noclobber { + if _, ok := ignoredTags[tag]; ok { + logs.Progress.Printf("Skipping %s due to no-clobber", tag) + continue + } + } + + g.Go(func() error { + srcTag, err := name.ParseReference(src+":"+tag, o.Name...) + if err != nil { + return fmt.Errorf("failed to parse tag: %w", err) + } + dstTag, err := name.ParseReference(dst+":"+tag, o.Name...) + if err != nil { + return fmt.Errorf("failed to parse tag: %w", err) + } + + logs.Progress.Printf("Fetching %s", srcTag) + desc, err := puller.Get(ctx, srcTag) + if err != nil { + return err + } + + logs.Progress.Printf("Pushing %s", dstTag) + return pusher.Push(ctx, dstTag, desc) + }) + } + } + + return g.Wait() +} diff --git a/vendor/github.com/google/go-containerregistry/pkg/crane/delete.go b/vendor/github.com/google/go-containerregistry/pkg/crane/delete.go new file mode 100644 index 0000000000..58a8be1f0b --- /dev/null +++ b/vendor/github.com/google/go-containerregistry/pkg/crane/delete.go @@ -0,0 +1,33 @@ +// Copyright 2018 Google LLC All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package crane + +import ( + "fmt" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/remote" +) + +// Delete deletes the remote reference at src. +func Delete(src string, opt ...Option) error { + o := makeOptions(opt...) + ref, err := name.ParseReference(src, o.Name...) + if err != nil { + return fmt.Errorf("parsing reference %q: %w", src, err) + } + + return remote.Delete(ref, o.Remote...) +} diff --git a/vendor/github.com/google/go-containerregistry/pkg/crane/digest.go b/vendor/github.com/google/go-containerregistry/pkg/crane/digest.go new file mode 100644 index 0000000000..868a57010d --- /dev/null +++ b/vendor/github.com/google/go-containerregistry/pkg/crane/digest.go @@ -0,0 +1,52 @@ +// Copyright 2018 Google LLC All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package crane + +import "github.com/google/go-containerregistry/pkg/logs" + +// Digest returns the sha256 hash of the remote image at ref. +func Digest(ref string, opt ...Option) (string, error) { + o := makeOptions(opt...) + if o.Platform != nil { + desc, err := getManifest(ref, opt...) + if err != nil { + return "", err + } + if !desc.MediaType.IsIndex() { + return desc.Digest.String(), nil + } + + // TODO: does not work for indexes which contain schema v1 manifests + img, err := desc.Image() + if err != nil { + return "", err + } + digest, err := img.Digest() + if err != nil { + return "", err + } + return digest.String(), nil + } + desc, err := Head(ref, opt...) + if err != nil { + logs.Warn.Printf("HEAD request failed, falling back on GET: %v", err) + rdesc, err := getManifest(ref, opt...) + if err != nil { + return "", err + } + return rdesc.Digest.String(), nil + } + return desc.Digest.String(), nil +} diff --git a/vendor/github.com/google/go-containerregistry/pkg/crane/doc.go b/vendor/github.com/google/go-containerregistry/pkg/crane/doc.go new file mode 100644 index 0000000000..7602d7953f --- /dev/null +++ b/vendor/github.com/google/go-containerregistry/pkg/crane/doc.go @@ -0,0 +1,16 @@ +// Copyright 2019 Google LLC All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package crane holds libraries used to implement the crane CLI. +package crane diff --git a/vendor/github.com/google/go-containerregistry/pkg/crane/export.go b/vendor/github.com/google/go-containerregistry/pkg/crane/export.go new file mode 100644 index 0000000000..b5e1296d21 --- /dev/null +++ b/vendor/github.com/google/go-containerregistry/pkg/crane/export.go @@ -0,0 +1,54 @@ +// Copyright 2018 Google LLC All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package crane + +import ( + "io" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/mutate" +) + +// Export writes the filesystem contents (as a tarball) of img to w. +// If img has a single layer, just write the (uncompressed) contents to w so +// that this "just works" for images that just wrap a single blob. +func Export(img v1.Image, w io.Writer) error { + layers, err := img.Layers() + if err != nil { + return err + } + if len(layers) == 1 { + // If it's a single layer... + l := layers[0] + mt, err := l.MediaType() + if err != nil { + return err + } + + if !mt.IsLayer() { + // ...and isn't an OCI mediaType, we don't have to flatten it. + // This lets export work for single layer, non-tarball images. + rc, err := l.Uncompressed() + if err != nil { + return err + } + _, err = io.Copy(w, rc) + return err + } + } + fs := mutate.Extract(img) + _, err = io.Copy(w, fs) + return err +} diff --git a/vendor/github.com/google/go-containerregistry/pkg/crane/filemap.go b/vendor/github.com/google/go-containerregistry/pkg/crane/filemap.go new file mode 100644 index 0000000000..36dfc2a644 --- /dev/null +++ b/vendor/github.com/google/go-containerregistry/pkg/crane/filemap.go @@ -0,0 +1,72 @@ +// Copyright 2018 Google LLC All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package crane + +import ( + "archive/tar" + "bytes" + "io" + "sort" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/tarball" +) + +// Layer creates a layer from a single file map. These layers are reproducible and consistent. +// A filemap is a path -> file content map representing a file system. +func Layer(filemap map[string][]byte) (v1.Layer, error) { + b := &bytes.Buffer{} + w := tar.NewWriter(b) + + fn := []string{} + for f := range filemap { + fn = append(fn, f) + } + sort.Strings(fn) + + for _, f := range fn { + c := filemap[f] + if err := w.WriteHeader(&tar.Header{ + Name: f, + Size: int64(len(c)), + }); err != nil { + return nil, err + } + if _, err := w.Write(c); err != nil { + return nil, err + } + } + if err := w.Close(); err != nil { + return nil, err + } + + // Return a new copy of the buffer each time it's opened. + return tarball.LayerFromOpener(func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewBuffer(b.Bytes())), nil + }) +} + +// Image creates a image with the given filemaps as its contents. These images are reproducible and consistent. +// A filemap is a path -> file content map representing a file system. +func Image(filemap map[string][]byte) (v1.Image, error) { + y, err := Layer(filemap) + if err != nil { + return nil, err + } + + return mutate.AppendLayers(empty.Image, y) +} diff --git a/vendor/github.com/google/go-containerregistry/pkg/crane/get.go b/vendor/github.com/google/go-containerregistry/pkg/crane/get.go new file mode 100644 index 0000000000..98a2e8933e --- /dev/null +++ b/vendor/github.com/google/go-containerregistry/pkg/crane/get.go @@ -0,0 +1,61 @@ +// Copyright 2018 Google LLC All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package crane + +import ( + "fmt" + + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/remote" +) + +func getImage(r string, opt ...Option) (v1.Image, name.Reference, error) { + o := makeOptions(opt...) + ref, err := name.ParseReference(r, o.Name...) + if err != nil { + return nil, nil, fmt.Errorf("parsing reference %q: %w", r, err) + } + img, err := remote.Image(ref, o.Remote...) + if err != nil { + return nil, nil, fmt.Errorf("reading image %q: %w", ref, err) + } + return img, ref, nil +} + +func getManifest(r string, opt ...Option) (*remote.Descriptor, error) { + o := makeOptions(opt...) + ref, err := name.ParseReference(r, o.Name...) + if err != nil { + return nil, fmt.Errorf("parsing reference %q: %w", r, err) + } + return remote.Get(ref, o.Remote...) +} + +// Get calls remote.Get and returns an uninterpreted response. +func Get(r string, opt ...Option) (*remote.Descriptor, error) { + return getManifest(r, opt...) +} + +// Head performs a HEAD request for a manifest and returns a content descriptor +// based on the registry's response. +func Head(r string, opt ...Option) (*v1.Descriptor, error) { + o := makeOptions(opt...) + ref, err := name.ParseReference(r, o.Name...) + if err != nil { + return nil, err + } + return remote.Head(ref, o.Remote...) +} diff --git a/vendor/github.com/google/go-containerregistry/pkg/crane/list.go b/vendor/github.com/google/go-containerregistry/pkg/crane/list.go new file mode 100644 index 0000000000..38352153bb --- /dev/null +++ b/vendor/github.com/google/go-containerregistry/pkg/crane/list.go @@ -0,0 +1,33 @@ +// Copyright 2018 Google LLC All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package crane + +import ( + "fmt" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/remote" +) + +// ListTags returns the tags in repository src. +func ListTags(src string, opt ...Option) ([]string, error) { + o := makeOptions(opt...) + repo, err := name.NewRepository(src, o.Name...) + if err != nil { + return nil, fmt.Errorf("parsing repo %q: %w", src, err) + } + + return remote.List(repo, o.Remote...) +} diff --git a/vendor/github.com/google/go-containerregistry/pkg/crane/manifest.go b/vendor/github.com/google/go-containerregistry/pkg/crane/manifest.go new file mode 100644 index 0000000000..a54926aef3 --- /dev/null +++ b/vendor/github.com/google/go-containerregistry/pkg/crane/manifest.go @@ -0,0 +1,32 @@ +// Copyright 2018 Google LLC All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package crane + +// Manifest returns the manifest for the remote image or index ref. +func Manifest(ref string, opt ...Option) ([]byte, error) { + desc, err := getManifest(ref, opt...) + if err != nil { + return nil, err + } + o := makeOptions(opt...) + if o.Platform != nil { + img, err := desc.Image() + if err != nil { + return nil, err + } + return img.RawManifest() + } + return desc.Manifest, nil +} diff --git a/vendor/github.com/google/go-containerregistry/pkg/crane/options.go b/vendor/github.com/google/go-containerregistry/pkg/crane/options.go new file mode 100644 index 0000000000..d9d4417619 --- /dev/null +++ b/vendor/github.com/google/go-containerregistry/pkg/crane/options.go @@ -0,0 +1,178 @@ +// Copyright 2019 Google LLC All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package crane + +import ( + "context" + "crypto/tls" + "net/http" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/remote" +) + +// Options hold the options that crane uses when calling other packages. +type Options struct { + Name []name.Option + Remote []remote.Option + Platform *v1.Platform + Keychain authn.Keychain + Transport http.RoundTripper + + auth authn.Authenticator + insecure bool + jobs int + noclobber bool + ctx context.Context +} + +// GetOptions exposes the underlying []remote.Option, []name.Option, and +// platform, based on the passed Option. Generally, you shouldn't need to use +// this unless you've painted yourself into a dependency corner as we have +// with the crane and gcrane cli packages. +func GetOptions(opts ...Option) Options { + return makeOptions(opts...) +} + +func makeOptions(opts ...Option) Options { + opt := Options{ + Remote: []remote.Option{ + remote.WithAuthFromKeychain(authn.DefaultKeychain), + }, + Keychain: authn.DefaultKeychain, + jobs: 4, + ctx: context.Background(), + } + + for _, o := range opts { + o(&opt) + } + + // Allow for untrusted certificates if the user + // passed Insecure but no custom transport. + if opt.insecure && opt.Transport == nil { + transport := remote.DefaultTransport.(*http.Transport).Clone() + transport.TLSClientConfig = &tls.Config{ + InsecureSkipVerify: true, //nolint: gosec + } + + WithTransport(transport)(&opt) + } else if opt.Transport == nil { + opt.Transport = remote.DefaultTransport + } + + return opt +} + +// Option is a functional option for crane. +type Option func(*Options) + +// WithTransport is a functional option for overriding the default transport +// for remote operations. Setting a transport will override the Insecure option's +// configuration allowing for image registries to use untrusted certificates. +func WithTransport(t http.RoundTripper) Option { + return func(o *Options) { + o.Remote = append(o.Remote, remote.WithTransport(t)) + o.Transport = t + } +} + +// Insecure is an Option that allows image references to be fetched without TLS. +// This will also allow for untrusted (e.g. self-signed) certificates in cases where +// the default transport is used (i.e. when WithTransport is not used). +func Insecure(o *Options) { + o.Name = append(o.Name, name.Insecure) + o.insecure = true +} + +// WithPlatform is an Option to specify the platform. +func WithPlatform(platform *v1.Platform) Option { + return func(o *Options) { + if platform != nil { + o.Remote = append(o.Remote, remote.WithPlatform(*platform)) + } + o.Platform = platform + } +} + +// WithAuthFromKeychain is a functional option for overriding the default +// authenticator for remote operations, using an authn.Keychain to find +// credentials. +// +// By default, crane will use authn.DefaultKeychain. +func WithAuthFromKeychain(keys authn.Keychain) Option { + return func(o *Options) { + // Replace the default keychain at position 0. + o.Remote[0] = remote.WithAuthFromKeychain(keys) + o.Keychain = keys + } +} + +// WithAuth is a functional option for overriding the default authenticator +// for remote operations. +// +// By default, crane will use authn.DefaultKeychain. +func WithAuth(auth authn.Authenticator) Option { + return func(o *Options) { + // Replace the default keychain at position 0. + o.Remote[0] = remote.WithAuth(auth) + o.auth = auth + } +} + +// WithUserAgent adds the given string to the User-Agent header for any HTTP +// requests. +func WithUserAgent(ua string) Option { + return func(o *Options) { + o.Remote = append(o.Remote, remote.WithUserAgent(ua)) + } +} + +// WithNondistributable is an option that allows pushing non-distributable +// layers. +func WithNondistributable() Option { + return func(o *Options) { + o.Remote = append(o.Remote, remote.WithNondistributable) + } +} + +// WithContext is a functional option for setting the context. +func WithContext(ctx context.Context) Option { + return func(o *Options) { + o.ctx = ctx + o.Remote = append(o.Remote, remote.WithContext(ctx)) + } +} + +// WithJobs sets the number of concurrent jobs to run. +// +// The default number of jobs is GOMAXPROCS. +func WithJobs(jobs int) Option { + return func(o *Options) { + if jobs > 0 { + o.jobs = jobs + } + o.Remote = append(o.Remote, remote.WithJobs(o.jobs)) + } +} + +// WithNoClobber modifies behavior to avoid overwriting existing tags, if possible. +func WithNoClobber(noclobber bool) Option { + return func(o *Options) { + o.noclobber = noclobber + } +} diff --git a/vendor/github.com/google/go-containerregistry/pkg/crane/pull.go b/vendor/github.com/google/go-containerregistry/pkg/crane/pull.go new file mode 100644 index 0000000000..7e6e5b7b6e --- /dev/null +++ b/vendor/github.com/google/go-containerregistry/pkg/crane/pull.go @@ -0,0 +1,142 @@ +// Copyright 2018 Google LLC All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package crane + +import ( + "fmt" + "os" + + legacy "github.com/google/go-containerregistry/pkg/legacy/tarball" + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/layout" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/go-containerregistry/pkg/v1/tarball" +) + +// Tag applied to images that were pulled by digest. This denotes that the +// image was (probably) never tagged with this, but lets us avoid applying the +// ":latest" tag which might be misleading. +const iWasADigestTag = "i-was-a-digest" + +// Pull returns a v1.Image of the remote image src. +func Pull(src string, opt ...Option) (v1.Image, error) { + o := makeOptions(opt...) + ref, err := name.ParseReference(src, o.Name...) + if err != nil { + return nil, fmt.Errorf("parsing reference %q: %w", src, err) + } + + return remote.Image(ref, o.Remote...) +} + +// Save writes the v1.Image img as a tarball at path with tag src. +func Save(img v1.Image, src, path string) error { + imgMap := map[string]v1.Image{src: img} + return MultiSave(imgMap, path) +} + +// MultiSave writes collection of v1.Image img with tag as a tarball. +func MultiSave(imgMap map[string]v1.Image, path string, opt ...Option) error { + o := makeOptions(opt...) + tagToImage := map[name.Tag]v1.Image{} + + for src, img := range imgMap { + ref, err := name.ParseReference(src, o.Name...) + if err != nil { + return fmt.Errorf("parsing ref %q: %w", src, err) + } + + // WriteToFile wants a tag to write to the tarball, but we might have + // been given a digest. + // If the original ref was a tag, use that. Otherwise, if it was a + // digest, tag the image with :i-was-a-digest instead. + tag, ok := ref.(name.Tag) + if !ok { + d, ok := ref.(name.Digest) + if !ok { + return fmt.Errorf("ref wasn't a tag or digest") + } + tag = d.Repository.Tag(iWasADigestTag) + } + tagToImage[tag] = img + } + // no progress channel (for now) + return tarball.MultiWriteToFile(path, tagToImage) +} + +// PullLayer returns the given layer from a registry. +func PullLayer(ref string, opt ...Option) (v1.Layer, error) { + o := makeOptions(opt...) + digest, err := name.NewDigest(ref, o.Name...) + if err != nil { + return nil, err + } + + return remote.Layer(digest, o.Remote...) +} + +// SaveLegacy writes the v1.Image img as a legacy tarball at path with tag src. +func SaveLegacy(img v1.Image, src, path string) error { + imgMap := map[string]v1.Image{src: img} + return MultiSave(imgMap, path) +} + +// MultiSaveLegacy writes collection of v1.Image img with tag as a legacy tarball. +func MultiSaveLegacy(imgMap map[string]v1.Image, path string) error { + refToImage := map[name.Reference]v1.Image{} + + for src, img := range imgMap { + ref, err := name.ParseReference(src) + if err != nil { + return fmt.Errorf("parsing ref %q: %w", src, err) + } + refToImage[ref] = img + } + + w, err := os.Create(path) + if err != nil { + return err + } + defer w.Close() + + return legacy.MultiWrite(refToImage, w) +} + +// SaveOCI writes the v1.Image img as an OCI Image Layout at path. If a layout +// already exists at that path, it will add the image to the index. +func SaveOCI(img v1.Image, path string) error { + imgMap := map[string]v1.Image{"": img} + return MultiSaveOCI(imgMap, path) +} + +// MultiSaveOCI writes collection of v1.Image img as an OCI Image Layout at path. If a layout +// already exists at that path, it will add the image to the index. +func MultiSaveOCI(imgMap map[string]v1.Image, path string) error { + p, err := layout.FromPath(path) + if err != nil { + p, err = layout.Write(path, empty.Index) + if err != nil { + return err + } + } + for _, img := range imgMap { + if err = p.AppendImage(img); err != nil { + return err + } + } + return nil +} diff --git a/vendor/github.com/google/go-containerregistry/pkg/crane/push.go b/vendor/github.com/google/go-containerregistry/pkg/crane/push.go new file mode 100644 index 0000000000..90a0585021 --- /dev/null +++ b/vendor/github.com/google/go-containerregistry/pkg/crane/push.go @@ -0,0 +1,65 @@ +// Copyright 2018 Google LLC All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package crane + +import ( + "fmt" + + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/go-containerregistry/pkg/v1/tarball" +) + +// Load reads the tarball at path as a v1.Image. +func Load(path string, opt ...Option) (v1.Image, error) { + return LoadTag(path, "", opt...) +} + +// LoadTag reads a tag from the tarball at path as a v1.Image. +// If tag is "", will attempt to read the tarball as a single image. +func LoadTag(path, tag string, opt ...Option) (v1.Image, error) { + if tag == "" { + return tarball.ImageFromPath(path, nil) + } + + o := makeOptions(opt...) + t, err := name.NewTag(tag, o.Name...) + if err != nil { + return nil, fmt.Errorf("parsing tag %q: %w", tag, err) + } + return tarball.ImageFromPath(path, &t) +} + +// Push pushes the v1.Image img to a registry as dst. +func Push(img v1.Image, dst string, opt ...Option) error { + o := makeOptions(opt...) + tag, err := name.ParseReference(dst, o.Name...) + if err != nil { + return fmt.Errorf("parsing reference %q: %w", dst, err) + } + return remote.Write(tag, img, o.Remote...) +} + +// Upload pushes the v1.Layer to a given repo. +func Upload(layer v1.Layer, repo string, opt ...Option) error { + o := makeOptions(opt...) + ref, err := name.NewRepository(repo, o.Name...) + if err != nil { + return fmt.Errorf("parsing repo %q: %w", repo, err) + } + + return remote.WriteLayer(ref, layer, o.Remote...) +} diff --git a/vendor/github.com/google/go-containerregistry/pkg/crane/tag.go b/vendor/github.com/google/go-containerregistry/pkg/crane/tag.go new file mode 100644 index 0000000000..13bc395872 --- /dev/null +++ b/vendor/github.com/google/go-containerregistry/pkg/crane/tag.go @@ -0,0 +1,39 @@ +// Copyright 2019 Google LLC All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package crane + +import ( + "fmt" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/remote" +) + +// Tag adds tag to the remote img. +func Tag(img, tag string, opt ...Option) error { + o := makeOptions(opt...) + ref, err := name.ParseReference(img, o.Name...) + if err != nil { + return fmt.Errorf("parsing reference %q: %w", img, err) + } + desc, err := remote.Get(ref, o.Remote...) + if err != nil { + return fmt.Errorf("fetching %q: %w", img, err) + } + + dst := ref.Context().Tag(tag) + + return remote.Tag(dst, desc, o.Remote...) +} diff --git a/vendor/github.com/google/go-containerregistry/pkg/legacy/config.go b/vendor/github.com/google/go-containerregistry/pkg/legacy/config.go new file mode 100644 index 0000000000..3364bec61c --- /dev/null +++ b/vendor/github.com/google/go-containerregistry/pkg/legacy/config.go @@ -0,0 +1,33 @@ +// Copyright 2019 Google LLC All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package legacy + +import ( + v1 "github.com/google/go-containerregistry/pkg/v1" +) + +// LayerConfigFile is the configuration file that holds the metadata describing +// a v1 layer. See: +// https://github.com/moby/moby/blob/master/image/spec/v1.md +type LayerConfigFile struct { + v1.ConfigFile + + ContainerConfig v1.Config `json:"container_config,omitempty"` + + ID string `json:"id,omitempty"` + Parent string `json:"parent,omitempty"` + Throwaway bool `json:"throwaway,omitempty"` + Comment string `json:"comment,omitempty"` +} diff --git a/vendor/github.com/google/go-containerregistry/pkg/legacy/doc.go b/vendor/github.com/google/go-containerregistry/pkg/legacy/doc.go new file mode 100644 index 0000000000..1d1668887a --- /dev/null +++ b/vendor/github.com/google/go-containerregistry/pkg/legacy/doc.go @@ -0,0 +1,18 @@ +// Copyright 2019 Google LLC All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package legacy provides functionality to work with docker images in the v1 +// format. +// See: https://github.com/moby/moby/blob/master/image/spec/v1.md +package legacy diff --git a/vendor/github.com/google/go-containerregistry/pkg/legacy/tarball/README.md b/vendor/github.com/google/go-containerregistry/pkg/legacy/tarball/README.md new file mode 100644 index 0000000000..90b88c7578 --- /dev/null +++ b/vendor/github.com/google/go-containerregistry/pkg/legacy/tarball/README.md @@ -0,0 +1,6 @@ +# `legacy/tarball` + +[![GoDoc](https://godoc.org/github.com/google/go-containerregistry/pkg/legacy/tarball?status.svg)](https://godoc.org/github.com/google/go-containerregistry/pkg/legacy/tarball) + +This package implements support for writing legacy tarballs, as described +[here](https://github.com/moby/moby/blob/749d90e10f989802638ae542daf54257f3bf71f2/image/spec/v1.2.md#combined-image-json--filesystem-changeset-format). diff --git a/vendor/github.com/google/go-containerregistry/pkg/legacy/tarball/doc.go b/vendor/github.com/google/go-containerregistry/pkg/legacy/tarball/doc.go new file mode 100644 index 0000000000..62684d6e72 --- /dev/null +++ b/vendor/github.com/google/go-containerregistry/pkg/legacy/tarball/doc.go @@ -0,0 +1,18 @@ +// Copyright 2019 Google LLC All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package tarball provides facilities for writing v1 docker images +// (https://github.com/moby/moby/blob/master/image/spec/v1.md) from/to a tarball +// on-disk. +package tarball diff --git a/vendor/github.com/google/go-containerregistry/pkg/legacy/tarball/write.go b/vendor/github.com/google/go-containerregistry/pkg/legacy/tarball/write.go new file mode 100644 index 0000000000..627bfbfdb1 --- /dev/null +++ b/vendor/github.com/google/go-containerregistry/pkg/legacy/tarball/write.go @@ -0,0 +1,371 @@ +// Copyright 2019 Google LLC All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tarball + +import ( + "archive/tar" + "bytes" + "encoding/json" + "fmt" + "io" + "sort" + "strings" + + "github.com/google/go-containerregistry/pkg/legacy" + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/partial" + "github.com/google/go-containerregistry/pkg/v1/tarball" +) + +// repositoriesTarDescriptor represents the repositories file inside a `docker save` tarball. +type repositoriesTarDescriptor map[string]map[string]string + +// v1Layer represents a layer with metadata needed by the v1 image spec https://github.com/moby/moby/blob/master/image/spec/v1.md. +type v1Layer struct { + // config is the layer metadata. + config *legacy.LayerConfigFile + // layer is the v1.Layer object this v1Layer represents. + layer v1.Layer +} + +// json returns the raw bytes of the json metadata of the given v1Layer. +func (l *v1Layer) json() ([]byte, error) { + return json.Marshal(l.config) +} + +// version returns the raw bytes of the "VERSION" file of the given v1Layer. +func (l *v1Layer) version() []byte { + return []byte("1.0") +} + +// v1LayerID computes the v1 image format layer id for the given v1.Layer with the given v1 parent ID and raw image config. +func v1LayerID(layer v1.Layer, parentID string, rawConfig []byte) (string, error) { + d, err := layer.Digest() + if err != nil { + return "", fmt.Errorf("unable to get layer digest to generate v1 layer ID: %w", err) + } + s := fmt.Sprintf("%s %s", d.Hex, parentID) + if len(rawConfig) != 0 { + s = fmt.Sprintf("%s %s", s, string(rawConfig)) + } + + h, _, _ := v1.SHA256(strings.NewReader(s)) + return h.Hex, nil +} + +// newTopV1Layer creates a new v1Layer for a layer other than the top layer in a v1 image tarball. +func newV1Layer(layer v1.Layer, parent *v1Layer, history v1.History) (*v1Layer, error) { + parentID := "" + if parent != nil { + parentID = parent.config.ID + } + id, err := v1LayerID(layer, parentID, nil) + if err != nil { + return nil, fmt.Errorf("unable to generate v1 layer ID: %w", err) + } + result := &v1Layer{ + layer: layer, + config: &legacy.LayerConfigFile{ + ConfigFile: v1.ConfigFile{ + Created: history.Created, + Author: history.Author, + }, + ContainerConfig: v1.Config{ + Cmd: []string{history.CreatedBy}, + }, + ID: id, + Parent: parentID, + Throwaway: history.EmptyLayer, + Comment: history.Comment, + }, + } + return result, nil +} + +// newTopV1Layer creates a new v1Layer for the top layer in a v1 image tarball. +func newTopV1Layer(layer v1.Layer, parent *v1Layer, history v1.History, imgConfig *v1.ConfigFile, rawConfig []byte) (*v1Layer, error) { + result, err := newV1Layer(layer, parent, history) + if err != nil { + return nil, err + } + id, err := v1LayerID(layer, result.config.Parent, rawConfig) + if err != nil { + return nil, fmt.Errorf("unable to generate v1 layer ID for top layer: %w", err) + } + result.config.ID = id + result.config.Architecture = imgConfig.Architecture + result.config.Container = imgConfig.Container + result.config.DockerVersion = imgConfig.DockerVersion + result.config.OS = imgConfig.OS + result.config.Config = imgConfig.Config + result.config.Created = imgConfig.Created + return result, nil +} + +// splitTag splits the given tagged image name /: +// into / and . +func splitTag(name string) (string, string) { + // Split on ":" + parts := strings.Split(name, ":") + // Verify that we aren't confusing a tag for a hostname w/ port for the purposes of weak validation. + if len(parts) > 1 && !strings.Contains(parts[len(parts)-1], "/") { + base := strings.Join(parts[:len(parts)-1], ":") + tag := parts[len(parts)-1] + return base, tag + } + return name, "" +} + +// addTags adds the given image tags to the given "repositories" file descriptor in a v1 image tarball. +func addTags(repos repositoriesTarDescriptor, tags []string, topLayerID string) { + for _, t := range tags { + base, tag := splitTag(t) + tagToID, ok := repos[base] + if !ok { + tagToID = make(map[string]string) + repos[base] = tagToID + } + tagToID[tag] = topLayerID + } +} + +// updateLayerSources updates the given layer digest to descriptor map with the descriptor of the given layer in the given image if it's an undistributable layer. +func updateLayerSources(layerSources map[v1.Hash]v1.Descriptor, layer v1.Layer, img v1.Image) error { + d, err := layer.Digest() + if err != nil { + return err + } + // Add to LayerSources if it's a foreign layer. + desc, err := partial.BlobDescriptor(img, d) + if err != nil { + return err + } + if !desc.MediaType.IsDistributable() { + diffid, err := partial.BlobToDiffID(img, d) + if err != nil { + return err + } + layerSources[diffid] = *desc + } + return nil +} + +// Write is a wrapper to write a single image in V1 format and tag to a tarball. +func Write(ref name.Reference, img v1.Image, w io.Writer) error { + return MultiWrite(map[name.Reference]v1.Image{ref: img}, w) +} + +// filterEmpty filters out the history corresponding to empty layers from the +// given history. +func filterEmpty(h []v1.History) []v1.History { + result := []v1.History{} + for _, i := range h { + if i.EmptyLayer { + continue + } + result = append(result, i) + } + return result +} + +// MultiWrite writes the contents of each image to the provided reader, in the V1 image tarball format. +// The contents are written in the following format: +// One manifest.json file at the top level containing information about several images. +// One repositories file mapping from the image / to to the id of the top most layer. +// For every layer, a directory named with the layer ID is created with the following contents: +// +// layer.tar - The uncompressed layer tarball. +// .json- Layer metadata json. +// VERSION- Schema version string. Always set to "1.0". +// +// One file for the config blob, named after its SHA. +func MultiWrite(refToImage map[name.Reference]v1.Image, w io.Writer) error { + tf := tar.NewWriter(w) + defer tf.Close() + + sortedImages, imageToTags := dedupRefToImage(refToImage) + var m tarball.Manifest + repos := make(repositoriesTarDescriptor) + + seenLayerIDs := make(map[string]struct{}) + for _, img := range sortedImages { + tags := imageToTags[img] + + // Write the config. + cfgName, err := img.ConfigName() + if err != nil { + return err + } + cfgFileName := fmt.Sprintf("%s.json", cfgName.Hex) + cfgBlob, err := img.RawConfigFile() + if err != nil { + return err + } + if err := writeTarEntry(tf, cfgFileName, bytes.NewReader(cfgBlob), int64(len(cfgBlob))); err != nil { + return err + } + cfg, err := img.ConfigFile() + if err != nil { + return err + } + + // Store foreign layer info. + layerSources := make(map[v1.Hash]v1.Descriptor) + + // Write the layers. + layers, err := img.Layers() + if err != nil { + return err + } + history := filterEmpty(cfg.History) + // Create a blank config history if the config didn't have a history. + if len(history) == 0 && len(layers) != 0 { + history = make([]v1.History, len(layers)) + } else if len(layers) != len(history) { + return fmt.Errorf("image config had layer history which did not match the number of layers, got len(history)=%d, len(layers)=%d, want len(history)=len(layers)", len(history), len(layers)) + } + layerFiles := make([]string, len(layers)) + var prev *v1Layer + for i, l := range layers { + if err := updateLayerSources(layerSources, l, img); err != nil { + return fmt.Errorf("unable to update image metadata to include undistributable layer source information: %w", err) + } + var cur *v1Layer + if i < (len(layers) - 1) { + cur, err = newV1Layer(l, prev, history[i]) + } else { + cur, err = newTopV1Layer(l, prev, history[i], cfg, cfgBlob) + } + if err != nil { + return err + } + layerFiles[i] = fmt.Sprintf("%s/layer.tar", cur.config.ID) + if _, ok := seenLayerIDs[cur.config.ID]; ok { + prev = cur + continue + } + seenLayerIDs[cur.config.ID] = struct{}{} + + // If the v1.Layer implements UncompressedSize efficiently, use that + // for the tar header. Otherwise, this iterates over Uncompressed(). + // NOTE: If using a streaming layer, this may consume the layer. + size, err := partial.UncompressedSize(l) + if err != nil { + return err + } + u, err := l.Uncompressed() + if err != nil { + return err + } + defer u.Close() + if err := writeTarEntry(tf, layerFiles[i], u, size); err != nil { + return err + } + + j, err := cur.json() + if err != nil { + return err + } + if err := writeTarEntry(tf, fmt.Sprintf("%s/json", cur.config.ID), bytes.NewReader(j), int64(len(j))); err != nil { + return err + } + v := cur.version() + if err := writeTarEntry(tf, fmt.Sprintf("%s/VERSION", cur.config.ID), bytes.NewReader(v), int64(len(v))); err != nil { + return err + } + prev = cur + } + + // Generate the tar descriptor and write it. + m = append(m, tarball.Descriptor{ + Config: cfgFileName, + RepoTags: tags, + Layers: layerFiles, + LayerSources: layerSources, + }) + // prev should be the top layer here. Use it to add the image tags + // to the tarball repositories file. + addTags(repos, tags, prev.config.ID) + } + + mBytes, err := json.Marshal(m) + if err != nil { + return err + } + + if err := writeTarEntry(tf, "manifest.json", bytes.NewReader(mBytes), int64(len(mBytes))); err != nil { + return err + } + reposBytes, err := json.Marshal(&repos) + if err != nil { + return err + } + return writeTarEntry(tf, "repositories", bytes.NewReader(reposBytes), int64(len(reposBytes))) +} + +func dedupRefToImage(refToImage map[name.Reference]v1.Image) ([]v1.Image, map[v1.Image][]string) { + imageToTags := make(map[v1.Image][]string) + + for ref, img := range refToImage { + if tag, ok := ref.(name.Tag); ok { + if tags, ok := imageToTags[img]; ok && tags != nil { + imageToTags[img] = append(tags, tag.String()) + } else { + imageToTags[img] = []string{tag.String()} + } + } else { + if _, ok := imageToTags[img]; !ok { + imageToTags[img] = nil + } + } + } + + // Force specific order on tags + imgs := []v1.Image{} + for img, tags := range imageToTags { + sort.Strings(tags) + imgs = append(imgs, img) + } + + sort.Slice(imgs, func(i, j int) bool { + cfI, err := imgs[i].ConfigName() + if err != nil { + return false + } + cfJ, err := imgs[j].ConfigName() + if err != nil { + return false + } + return cfI.Hex < cfJ.Hex + }) + + return imgs, imageToTags +} + +// Writes a file to the provided writer with a corresponding tar header +func writeTarEntry(tf *tar.Writer, path string, r io.Reader, size int64) error { + hdr := &tar.Header{ + Mode: 0644, + Typeflag: tar.TypeReg, + Size: size, + Name: path, + } + if err := tf.WriteHeader(hdr); err != nil { + return err + } + _, err := io.Copy(tf, r) + return err +} diff --git a/vendor/modules.txt b/vendor/modules.txt index c81f78f325..93056d7bb9 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -244,9 +244,13 @@ github.com/google/go-containerregistry/internal/redact github.com/google/go-containerregistry/internal/retry github.com/google/go-containerregistry/internal/retry/wait github.com/google/go-containerregistry/internal/verify +github.com/google/go-containerregistry/internal/windows github.com/google/go-containerregistry/internal/zstd github.com/google/go-containerregistry/pkg/authn github.com/google/go-containerregistry/pkg/compression +github.com/google/go-containerregistry/pkg/crane +github.com/google/go-containerregistry/pkg/legacy +github.com/google/go-containerregistry/pkg/legacy/tarball github.com/google/go-containerregistry/pkg/logs github.com/google/go-containerregistry/pkg/name github.com/google/go-containerregistry/pkg/registry