diff --git a/cmd/crossplane/xpkg/annotations.go b/cmd/crossplane/xpkg/annotations.go new file mode 100644 index 0000000..992a3a0 --- /dev/null +++ b/cmd/crossplane/xpkg/annotations.go @@ -0,0 +1,49 @@ +/* +Copyright 2025 The Crossplane Authors. + +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 xpkg + +import ( + "strings" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/mutate" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" +) + +// parseAnnotations parses a slice of "key=value" strings into a map. Returns +// an error if any entry is not in key=value format. +func parseAnnotations(kvs []string) (map[string]string, error) { + anns := make(map[string]string, len(kvs)) + for _, kv := range kvs { + k, v, ok := strings.Cut(kv, "=") + if !ok { + return nil, errors.Errorf("invalid annotation %q: must be in key=value format", kv) + } + anns[k] = v + } + return anns, nil +} + +// annotateImage applies annotations to an OCI image manifest. It is a no-op +// when annotations is empty or nil. +func annotateImage(img v1.Image, annotations map[string]string) v1.Image { + if len(annotations) == 0 { + return img + } + return mutate.Annotations(img, annotations).(v1.Image) //nolint:forcetypeassert // mutate.Annotations always returns v1.Image when given v1.Image input +} diff --git a/cmd/crossplane/xpkg/annotations_test.go b/cmd/crossplane/xpkg/annotations_test.go new file mode 100644 index 0000000..0114ae3 --- /dev/null +++ b/cmd/crossplane/xpkg/annotations_test.go @@ -0,0 +1,86 @@ +/* +Copyright 2025 The Crossplane Authors. + +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 xpkg + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" +) + +func TestParseAnnotations(t *testing.T) { + type args struct { + kvs []string + } + type want struct { + anns map[string]string + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "EmptySlice": { + reason: "Empty input should return an empty map with no error.", + args: args{kvs: []string{}}, + want: want{anns: map[string]string{}}, + }, + "SingleEntry": { + reason: "A single valid key=value entry should be parsed correctly.", + args: args{kvs: []string{"org.example/key=value"}}, + want: want{anns: map[string]string{"org.example/key": "value"}}, + }, + "MultipleEntries": { + reason: "Multiple valid key=value entries should all be parsed.", + args: args{kvs: []string{ + "org.opencontainers.image.source=https://github.com/example/pkg", + "org.opencontainers.image.version=v1.0.0", + }}, + want: want{anns: map[string]string{ + "org.opencontainers.image.source": "https://github.com/example/pkg", + "org.opencontainers.image.version": "v1.0.0", + }}, + }, + "ValueContainsEquals": { + reason: "Values that contain '=' characters should be preserved intact.", + args: args{kvs: []string{"key=val=ue"}}, + want: want{anns: map[string]string{"key": "val=ue"}}, + }, + "MissingEquals": { + reason: "An entry without '=' should return an error.", + args: args{kvs: []string{"invalid-no-equals"}}, + want: want{err: cmpopts.AnyError}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got, err := parseAnnotations(tc.args.kvs) + + if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("\n%s\nparseAnnotations(...): -want error, +got error:\n%s", tc.reason, diff) + } + + if diff := cmp.Diff(tc.want.anns, got); diff != "" { + t.Errorf("\n%s\nparseAnnotations(...): -want, +got:\n%s", tc.reason, diff) + } + }) + } +} diff --git a/cmd/crossplane/xpkg/batch.go b/cmd/crossplane/xpkg/batch.go index bfad3a6..553aa18 100644 --- a/cmd/crossplane/xpkg/batch.go +++ b/cmd/crossplane/xpkg/batch.go @@ -281,7 +281,7 @@ func (c *batchCmd) pushWithRetry(logger logging.Logger, imgs []packageImage, s s retryMsg := "" for i := range tries { logger.Info(fmt.Sprintf("Pushing xpkg to %s.%s", t, retryMsg)) - err := pushImages(logger, imgs, t) + err := pushImages(logger, imgs, t, nil) if err == nil { break } diff --git a/cmd/crossplane/xpkg/build.go b/cmd/crossplane/xpkg/build.go index dfdd9af..014419b 100644 --- a/cmd/crossplane/xpkg/build.go +++ b/cmd/crossplane/xpkg/build.go @@ -45,6 +45,7 @@ const ( errPullRuntimeImage = "failed to pull runtime image" errLoadRuntimeTarball = "failed to load runtime tarball" errGetRuntimeBaseImageOpts = "failed to get runtime base image options" + errParseAnnotations = "failed to parse annotations" ) // AfterApply constructs and binds context to any subcommands @@ -94,6 +95,7 @@ func (c *buildCmd) AfterApply() error { // buildCmd builds a crossplane package. type buildCmd struct { // Flags. Keep sorted alphabetically. + Annotation []string `help:"An OCI manifest annotation to add to the package in key=value format. Repeatable." placeholder:"KEY=VALUE" short:"a"` EmbedRuntimeImage string `help:"An OCI image to embed in the package as its runtime." placeholder:"NAME" xor:"runtime-image"` EmbedRuntimeImageTarball string `help:"An OCI image tarball to embed in the package as its runtime." placeholder:"PATH" predictor:"file" type:"existingfile" xor:"runtime-image"` ExamplesRoot string `default:"./examples" help:"A directory of example YAML files to include in the package." predictor:"directory" short:"e" type:"path"` @@ -182,6 +184,12 @@ func (c *buildCmd) Run(logger logging.Logger) error { return errors.Wrap(err, errBuildPackage) } + anns, err := parseAnnotations(c.Annotation) + if err != nil { + return errors.Wrap(err, errParseAnnotations) + } + img = annotateImage(img, anns) + hash, err := img.Digest() if err != nil { return errors.Wrap(err, errImageDigest) diff --git a/cmd/crossplane/xpkg/push.go b/cmd/crossplane/xpkg/push.go index 48eeffd..76d9394 100644 --- a/cmd/crossplane/xpkg/push.go +++ b/cmd/crossplane/xpkg/push.go @@ -60,8 +60,9 @@ type pushCmd struct { Package string `arg:"" help:"Where to push the package. Must be a fully qualified OCI tag, including the registry, repository, and tag." placeholder:"REGISTRY/REPOSITORY:TAG"` // Flags. Keep sorted alphabetically. + Annotation []string `help:"An OCI manifest annotation to add to the package in key=value format. Repeatable." placeholder:"KEY=VALUE" short:"a"` InsecureSkipTLSVerify bool `help:"[INSECURE] Skip verifying TLS certificates."` - PackageFiles []string `help:"A comma-separated list of xpkg files to push." placeholder:"PATH" predictor:"xpkg_file" short:"f" type:"existingfile"` + PackageFiles []string `help:"A comma-separated list of xpkg files to push." placeholder:"PATH" predictor:"xpkg_file" short:"f" type:"existingfile"` // Internal state. These aren't part of the user-exposed CLI structure. fs afero.Fs @@ -135,7 +136,12 @@ func (c *pushCmd) Run(logger logging.Logger) error { remote.WithTransport(t), } - return pushImages(logger, images, c.Package, options...) + anns, err := parseAnnotations(c.Annotation) + if err != nil { + return errors.Wrap(err, errParseAnnotations) + } + + return pushImages(logger, images, c.Package, anns, options...) } // packageImage describes a package image that will be pushed. @@ -149,7 +155,7 @@ type packageImage struct { } // pushImages pushes package images to the given URL using the provided options. -func pushImages(logger logging.Logger, images []packageImage, url string, options ...remote.Option) error { +func pushImages(logger logging.Logger, images []packageImage, url string, annotations map[string]string, options ...remote.Option) error { if len(options) == 0 { options = []remote.Option{ remote.WithAuthFromKeychain(authn.DefaultKeychain), @@ -170,6 +176,8 @@ func pushImages(logger logging.Logger, images []packageImage, url string, option return errors.Wrapf(err, errAnnotateLayers) } + img = annotateImage(img, annotations) + if err := remote.Write(tag, img, options...); err != nil { return errors.Wrapf(err, errFmtPushPackage, pi.Path) } @@ -192,6 +200,8 @@ func pushImages(logger logging.Logger, images []packageImage, url string, option return errors.Wrapf(err, errAnnotateLayers) } + img = annotateImage(img, annotations) + d, err := img.Digest() if err != nil { return errors.Wrapf(err, errFmtGetDigest, pi.Path)