From 9f56eb5619fc9ab7b87a2363129e9b6b30fea3a1 Mon Sep 17 00:00:00 2001 From: Chaitanya Maili Date: Tue, 12 May 2026 10:22:22 +0530 Subject: [PATCH 1/4] feat(xpkg): add parseAnnotations helper for OCI manifest annotations Signed-off-by: Chaitanya Maili --- cmd/crossplane/xpkg/annotations.go | 37 +++++++++++ cmd/crossplane/xpkg/annotations_test.go | 86 +++++++++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 cmd/crossplane/xpkg/annotations.go create mode 100644 cmd/crossplane/xpkg/annotations_test.go diff --git a/cmd/crossplane/xpkg/annotations.go b/cmd/crossplane/xpkg/annotations.go new file mode 100644 index 0000000..4f45073 --- /dev/null +++ b/cmd/crossplane/xpkg/annotations.go @@ -0,0 +1,37 @@ +/* +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" + + "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 +} 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) + } + }) + } +} From 3c1ee22b2d034dc695d7d90cdedf4adac55d1ff7 Mon Sep 17 00:00:00 2001 From: Chaitanya Maili Date: Tue, 12 May 2026 10:24:15 +0530 Subject: [PATCH 2/4] feat(xpkg): add --annotation flag to xpkg build Signed-off-by: Chaitanya Maili --- cmd/crossplane/xpkg/build.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/cmd/crossplane/xpkg/build.go b/cmd/crossplane/xpkg/build.go index dfdd9af..c58d2b7 100644 --- a/cmd/crossplane/xpkg/build.go +++ b/cmd/crossplane/xpkg/build.go @@ -23,6 +23,7 @@ import ( "github.com/google/go-containerregistry/pkg/name" v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/daemon" + "github.com/google/go-containerregistry/pkg/v1/mutate" "github.com/google/go-containerregistry/pkg/v1/tarball" "github.com/spf13/afero" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -45,6 +46,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 +96,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 +185,14 @@ 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) + } + if len(anns) > 0 { + img = mutate.Annotations(img, anns).(v1.Image) + } + hash, err := img.Digest() if err != nil { return errors.Wrap(err, errImageDigest) From d29df87ae38eb1e72eee23560cbe7b04300f5d90 Mon Sep 17 00:00:00 2001 From: Chaitanya Maili Date: Tue, 12 May 2026 10:27:18 +0530 Subject: [PATCH 3/4] feat(xpkg): add --annotation flag to xpkg push Signed-off-by: Chaitanya Maili --- cmd/crossplane/xpkg/batch.go | 2 +- cmd/crossplane/xpkg/push.go | 18 ++++++++++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) 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/push.go b/cmd/crossplane/xpkg/push.go index 48eeffd..6ef5ccb 100644 --- a/cmd/crossplane/xpkg/push.go +++ b/cmd/crossplane/xpkg/push.go @@ -60,6 +60,7 @@ 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"` @@ -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,10 @@ func pushImages(logger logging.Logger, images []packageImage, url string, option return errors.Wrapf(err, errAnnotateLayers) } + if len(annotations) > 0 { + img = mutate.Annotations(img, annotations).(v1.Image) + } + if err := remote.Write(tag, img, options...); err != nil { return errors.Wrapf(err, errFmtPushPackage, pi.Path) } @@ -192,6 +202,10 @@ func pushImages(logger logging.Logger, images []packageImage, url string, option return errors.Wrapf(err, errAnnotateLayers) } + if len(annotations) > 0 { + img = mutate.Annotations(img, annotations).(v1.Image) + } + d, err := img.Digest() if err != nil { return errors.Wrapf(err, errFmtGetDigest, pi.Path) From a8a515be945e279117bbe14b4b34ff44ba2dfad6 Mon Sep 17 00:00:00 2001 From: Chaitanya Maili Date: Tue, 12 May 2026 10:55:55 +0530 Subject: [PATCH 4/4] fix(xpkg): address golangci-lint issues in annotation support Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Chaitanya Maili --- cmd/crossplane/xpkg/annotations.go | 12 ++++++++++++ cmd/crossplane/xpkg/build.go | 7 ++----- cmd/crossplane/xpkg/push.go | 10 +++------- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/cmd/crossplane/xpkg/annotations.go b/cmd/crossplane/xpkg/annotations.go index 4f45073..992a3a0 100644 --- a/cmd/crossplane/xpkg/annotations.go +++ b/cmd/crossplane/xpkg/annotations.go @@ -19,6 +19,9 @@ 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" ) @@ -35,3 +38,12 @@ func parseAnnotations(kvs []string) (map[string]string, error) { } 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/build.go b/cmd/crossplane/xpkg/build.go index c58d2b7..014419b 100644 --- a/cmd/crossplane/xpkg/build.go +++ b/cmd/crossplane/xpkg/build.go @@ -23,7 +23,6 @@ import ( "github.com/google/go-containerregistry/pkg/name" v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/daemon" - "github.com/google/go-containerregistry/pkg/v1/mutate" "github.com/google/go-containerregistry/pkg/v1/tarball" "github.com/spf13/afero" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -96,7 +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"` + 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"` @@ -189,9 +188,7 @@ func (c *buildCmd) Run(logger logging.Logger) error { if err != nil { return errors.Wrap(err, errParseAnnotations) } - if len(anns) > 0 { - img = mutate.Annotations(img, anns).(v1.Image) - } + img = annotateImage(img, anns) hash, err := img.Digest() if err != nil { diff --git a/cmd/crossplane/xpkg/push.go b/cmd/crossplane/xpkg/push.go index 6ef5ccb..76d9394 100644 --- a/cmd/crossplane/xpkg/push.go +++ b/cmd/crossplane/xpkg/push.go @@ -62,7 +62,7 @@ type pushCmd 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"` 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 @@ -176,9 +176,7 @@ func pushImages(logger logging.Logger, images []packageImage, url string, annota return errors.Wrapf(err, errAnnotateLayers) } - if len(annotations) > 0 { - img = mutate.Annotations(img, annotations).(v1.Image) - } + img = annotateImage(img, annotations) if err := remote.Write(tag, img, options...); err != nil { return errors.Wrapf(err, errFmtPushPackage, pi.Path) @@ -202,9 +200,7 @@ func pushImages(logger logging.Logger, images []packageImage, url string, annota return errors.Wrapf(err, errAnnotateLayers) } - if len(annotations) > 0 { - img = mutate.Annotations(img, annotations).(v1.Image) - } + img = annotateImage(img, annotations) d, err := img.Digest() if err != nil {