Skip to content

Commit e18ab88

Browse files
pedjakclaude
andcommitted
Add bundle-release annotation to CER and phase objects
Plumb release as an explicit annotation (BundleReleaseKey) so the release portion of the bundle version is directly inspectable without parsing build metadata. The annotation is only set when a release value is present (additive, non-breaking). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b18a28b commit e18ab88

11 files changed

Lines changed: 217 additions & 2 deletions

File tree

internal/operator-controller/applier/boxcutter.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ func (r *SimpleRevisionGenerator) GenerateRevisionFromHelmRelease(
8282
if v := helmRelease.Labels[labels.PackageNameKey]; v != "" {
8383
annotationUpdates[labels.PackageNameKey] = v
8484
}
85+
if v := helmRelease.Labels[labels.BundleReleaseKey]; v != "" {
86+
annotationUpdates[labels.BundleReleaseKey] = v
87+
}
8588
if len(annotationUpdates) > 0 {
8689
obj.SetAnnotations(mergeStringMaps(obj.GetAnnotations(), annotationUpdates))
8790
}
@@ -90,12 +93,16 @@ func (r *SimpleRevisionGenerator) GenerateRevisionFromHelmRelease(
9093
WithObject(obj))
9194
}
9295

93-
rev := r.buildClusterExtensionRevision(objs, ext, map[string]string{
96+
cerAnnotations := map[string]string{
9497
labels.BundleNameKey: helmRelease.Labels[labels.BundleNameKey],
9598
labels.PackageNameKey: helmRelease.Labels[labels.PackageNameKey],
9699
labels.BundleVersionKey: helmRelease.Labels[labels.BundleVersionKey],
97100
labels.BundleReferenceKey: helmRelease.Labels[labels.BundleReferenceKey],
98-
})
101+
}
102+
if v := helmRelease.Labels[labels.BundleReleaseKey]; v != "" {
103+
cerAnnotations[labels.BundleReleaseKey] = v
104+
}
105+
rev := r.buildClusterExtensionRevision(objs, ext, cerAnnotations)
99106
rev.WithName(fmt.Sprintf("%s-1", ext.Name))
100107
rev.Spec.WithRevision(1)
101108
rev.Spec.WithCollisionProtection(ocv1.CollisionProtectionNone) // allow to adopt objects from previous release
@@ -164,6 +171,9 @@ func (r *SimpleRevisionGenerator) GenerateRevision(
164171
if v := revisionAnnotations[labels.PackageNameKey]; v != "" {
165172
annotationUpdates[labels.PackageNameKey] = v
166173
}
174+
if v := revisionAnnotations[labels.BundleReleaseKey]; v != "" {
175+
annotationUpdates[labels.BundleReleaseKey] = v
176+
}
167177
if len(annotationUpdates) > 0 {
168178
unstr.SetAnnotations(mergeStringMaps(unstr.GetAnnotations(), annotationUpdates))
169179
}

internal/operator-controller/applier/boxcutter_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ func Test_SimpleRevisionGenerator_GenerateRevisionFromHelmRelease(t *testing.T)
7575
labels.BundleNameKey: "my-bundle",
7676
labels.PackageNameKey: "my-package",
7777
labels.BundleVersionKey: "1.2.0",
78+
labels.BundleReleaseKey: "3",
7879
labels.BundleReferenceKey: "bundle-ref",
7980
},
8081
}
@@ -102,6 +103,7 @@ func Test_SimpleRevisionGenerator_GenerateRevisionFromHelmRelease(t *testing.T)
102103
WithAnnotations(map[string]string{
103104
"olm.operatorframework.io/bundle-name": "my-bundle",
104105
"olm.operatorframework.io/bundle-reference": "bundle-ref",
106+
"olm.operatorframework.io/bundle-release": "3",
105107
"olm.operatorframework.io/bundle-version": "1.2.0",
106108
"olm.operatorframework.io/package-name": "my-package",
107109
"olm.operatorframework.io/service-account-name": "test-sa",
@@ -129,6 +131,7 @@ func Test_SimpleRevisionGenerator_GenerateRevisionFromHelmRelease(t *testing.T)
129131
"my-label": "my-value",
130132
},
131133
"annotations": map[string]interface{}{
134+
"olm.operatorframework.io/bundle-release": "3",
132135
"olm.operatorframework.io/bundle-version": "1.2.0",
133136
"olm.operatorframework.io/package-name": "my-package",
134137
},
@@ -145,6 +148,7 @@ func Test_SimpleRevisionGenerator_GenerateRevisionFromHelmRelease(t *testing.T)
145148
"my-label": "my-value",
146149
},
147150
"annotations": map[string]interface{}{
151+
"olm.operatorframework.io/bundle-release": "3",
148152
"olm.operatorframework.io/bundle-version": "1.2.0",
149153
"olm.operatorframework.io/package-name": "my-package",
150154
},
@@ -210,6 +214,7 @@ func Test_SimpleRevisionGenerator_GenerateRevision(t *testing.T) {
210214
rev, err := b.GenerateRevision(t.Context(), dummyBundle, ext, map[string]string{}, map[string]string{
211215
labels.BundleVersionKey: "1.0.0",
212216
labels.PackageNameKey: "test-package",
217+
labels.BundleReleaseKey: "5",
213218
})
214219
require.NoError(t, err)
215220

@@ -235,6 +240,7 @@ func Test_SimpleRevisionGenerator_GenerateRevision(t *testing.T) {
235240
"metadata": map[string]interface{}{
236241
"name": "test-service",
237242
"annotations": map[string]interface{}{
243+
"olm.operatorframework.io/bundle-release": "5",
238244
"olm.operatorframework.io/bundle-version": "1.0.0",
239245
"olm.operatorframework.io/package-name": "test-package",
240246
},
@@ -259,6 +265,7 @@ func Test_SimpleRevisionGenerator_GenerateRevision(t *testing.T) {
259265
},
260266
"annotations": map[string]interface{}{
261267
"my-annotation": "my-annotation-value",
268+
"olm.operatorframework.io/bundle-release": "5",
262269
"olm.operatorframework.io/bundle-version": "1.0.0",
263270
"olm.operatorframework.io/package-name": "test-package",
264271
},

internal/operator-controller/bundle/versionrelease.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,19 @@ func (vr *VersionRelease) AsLegacyRegistryV1Version() bsemver.Version {
8585

8686
type Release []bsemver.PRVersion
8787

88+
// String returns the dot-separated string representation of the release segments.
89+
// Returns an empty string for an empty release.
90+
func (r Release) String() string {
91+
if len(r) == 0 {
92+
return ""
93+
}
94+
segments := make([]string, len(r))
95+
for i, seg := range r {
96+
segments[i] = seg.String()
97+
}
98+
return strings.Join(segments, ".")
99+
}
100+
88101
// Compare compares two Release values. It returns:
89102
//
90103
// -1 if r < other

internal/operator-controller/bundle/versionrelease_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,47 @@ func TestNewRelease(t *testing.T) {
346346
}
347347
}
348348

349+
func TestRelease_String(t *testing.T) {
350+
type testCase struct {
351+
name string
352+
input string
353+
expect string
354+
}
355+
for _, tc := range []testCase{
356+
{
357+
name: "empty release",
358+
input: "",
359+
expect: "",
360+
},
361+
{
362+
name: "single numeric segment",
363+
input: "1",
364+
expect: "1",
365+
},
366+
{
367+
name: "single alphanumeric segment",
368+
input: "alpha",
369+
expect: "alpha",
370+
},
371+
{
372+
name: "multiple numeric segments",
373+
input: "4.5",
374+
expect: "4.5",
375+
},
376+
{
377+
name: "mixed segments",
378+
input: "9.alpha.10",
379+
expect: "9.alpha.10",
380+
},
381+
} {
382+
t.Run(tc.name, func(t *testing.T) {
383+
rel, err := bundle.NewRelease(tc.input)
384+
require.NoError(t, err)
385+
assert.Equal(t, tc.expect, rel.String())
386+
})
387+
}
388+
}
389+
349390
func TestRelease_Compare(t *testing.T) {
350391
type testCase struct {
351392
name string

internal/operator-controller/controllers/boxcutter_reconcile_steps.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ func (d *BoxcutterRevisionStatesGetter) GetRevisionStates(ctx context.Context, e
6464
rm := &RevisionMetadata{
6565
RevisionName: rev.Name,
6666
Package: rev.Annotations[labels.PackageNameKey],
67+
Release: rev.Annotations[labels.BundleReleaseKey],
6768
Image: rev.Annotations[labels.BundleReferenceKey],
6869
Conditions: rev.Status.Conditions,
6970
BundleMetadata: ocv1.BundleMetadata{
@@ -105,6 +106,9 @@ func ApplyBundleWithBoxcutter(apply func(ctx context.Context, contentFS fs.FS, e
105106
labels.BundleVersionKey: state.resolvedRevisionMetadata.Version,
106107
labels.BundleReferenceKey: state.resolvedRevisionMetadata.Image,
107108
}
109+
if state.resolvedRevisionMetadata.Release != "" {
110+
revisionAnnotations[labels.BundleReleaseKey] = state.resolvedRevisionMetadata.Release
111+
}
108112
objLbls := map[string]string{
109113
labels.OwnerKindKey: ocv1.ClusterExtensionKind,
110114
labels.OwnerNameKey: ext.GetName(),

internal/operator-controller/controllers/clusterextension_controller.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,7 @@ func clusterExtensionRequestsForCatalog(c client.Reader, logger logr.Logger) crh
499499
type RevisionMetadata struct {
500500
RevisionName string
501501
Package string
502+
Release string
502503
Image string
503504
ocv1.BundleMetadata
504505
Conditions []metav1.Condition

internal/operator-controller/controllers/clusterextension_reconcile_steps.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ func ResolveBundle(r resolve.Resolver, c client.Client) ReconcileStepFunc {
188188

189189
state.resolvedRevisionMetadata = &RevisionMetadata{
190190
Package: resolvedBundle.Package,
191+
Release: resolvedBundleVersion.Release.String(),
191192
Image: resolvedBundle.Image,
192193
// TODO: Right now, operator-controller only supports registry+v1 bundles and has no concept
193194
// of a "release" field. If/when we add a release field concept or a new bundle format

internal/operator-controller/labels/labels.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ const (
2121
// associated with a ClusterExtensionRevision.
2222
BundleVersionKey = "olm.operatorframework.io/bundle-version"
2323

24+
// BundleReleaseKey is the annotation key used to record the release
25+
// portion of the bundle version, when present. This is the release
26+
// metadata extracted from the version's build metadata field.
27+
BundleReleaseKey = "olm.operatorframework.io/bundle-release"
28+
2429
// BundleReferenceKey is the label key used to record an external reference
2530
// (such as an image or catalog reference) to the bundle for a
2631
// ClusterExtensionRevision.

test/e2e/features/install.feature

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,81 @@ Feature: Install ClusterExtension
448448
"""
449449
And ClusterExtension reports Progressing transition between 1 and 2 minutes since its creation
450450

451+
@BoxcutterRuntime
452+
Scenario: Install bundle without release metadata does not include bundle-release annotation
453+
When ClusterExtension is applied
454+
"""
455+
apiVersion: olm.operatorframework.io/v1
456+
kind: ClusterExtension
457+
metadata:
458+
name: ${NAME}
459+
spec:
460+
namespace: ${TEST_NAMESPACE}
461+
serviceAccount:
462+
name: olm-sa
463+
source:
464+
sourceType: Catalog
465+
catalog:
466+
packageName: test
467+
version: 1.0.0
468+
selector:
469+
matchLabels:
470+
"olm.operatorframework.io/metadata.name": test-catalog
471+
"""
472+
Then ClusterExtension is rolled out
473+
And ClusterExtension is available
474+
And ClusterExtensionRevision "${NAME}-1" contains annotation "olm.operatorframework.io/bundle-version" with value
475+
"""
476+
1.0.0
477+
"""
478+
And ClusterExtensionRevision "${NAME}-1" contains annotation "olm.operatorframework.io/package-name" with value
479+
"""
480+
test
481+
"""
482+
And ClusterExtensionRevision "${NAME}-1" does not contain annotation "olm.operatorframework.io/bundle-release"
483+
And resource "configmap/test-configmap" has annotations
484+
| key | value |
485+
| olm.operatorframework.io/bundle-version | 1.0.0 |
486+
| olm.operatorframework.io/package-name | test |
487+
And resource "configmap/test-configmap" does not have annotation "olm.operatorframework.io/bundle-release"
488+
489+
@BoxcutterRuntime
490+
Scenario: Install bundle with release metadata includes bundle-release annotation
491+
When ClusterExtension is applied
492+
"""
493+
apiVersion: olm.operatorframework.io/v1
494+
kind: ClusterExtension
495+
metadata:
496+
name: ${NAME}
497+
spec:
498+
namespace: ${TEST_NAMESPACE}
499+
serviceAccount:
500+
name: olm-sa
501+
source:
502+
sourceType: Catalog
503+
catalog:
504+
packageName: test
505+
version: 1.0.5
506+
selector:
507+
matchLabels:
508+
"olm.operatorframework.io/metadata.name": test-catalog
509+
"""
510+
Then ClusterExtension is rolled out
511+
And ClusterExtension is available
512+
And ClusterExtensionRevision "${NAME}-1" contains annotation "olm.operatorframework.io/bundle-version" with value
513+
"""
514+
1.0.5+1
515+
"""
516+
And ClusterExtensionRevision "${NAME}-1" contains annotation "olm.operatorframework.io/bundle-release" with value
517+
"""
518+
1
519+
"""
520+
And resource "configmap/test-configmap" has annotations
521+
| key | value |
522+
| olm.operatorframework.io/bundle-version | 1.0.5+1 |
523+
| olm.operatorframework.io/bundle-release | 1 |
524+
| olm.operatorframework.io/package-name | test |
525+
451526
@BoxcutterRuntime
452527
Scenario: ClusterExtensionRevision is annotated with bundle properties
453528
When ClusterExtension is applied

test/e2e/steps/steps.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ func RegisterSteps(sc *godog.ScenarioContext) {
9494
sc.Step(`^(?i)ClusterExtensionRevision "([^"]+)" is archived$`, ClusterExtensionRevisionIsArchived)
9595
sc.Step(`^(?i)ClusterExtensionRevision "([^"]+)" contains annotation "([^"]+)" with value$`, ClusterExtensionRevisionHasAnnotationWithValue)
9696
sc.Step(`^(?i)ClusterExtensionRevision "([^"]+)" has label "([^"]+)" with value "([^"]+)"$`, ClusterExtensionRevisionHasLabelWithValue)
97+
sc.Step(`^(?i)ClusterExtensionRevision "([^"]+)" does not contain annotation "([^"]+)"$`, ClusterExtensionRevisionDoesNotHaveAnnotation)
98+
sc.Step(`^(?i)resource "([^"]+)" does not have annotation "([^"]+)"$`, ResourceDoesNotHaveAnnotation)
9799
sc.Step(`^(?i)ClusterExtensionRevision "([^"]+)" phase objects are not found or not owned by the revision$`, ClusterExtensionRevisionObjectsNotFoundOrNotOwned)
98100

99101
sc.Step(`^(?i)resource "([^"]+)" is installed$`, ResourceAvailable)
@@ -611,6 +613,49 @@ func ClusterExtensionRevisionHasLabelWithValue(ctx context.Context, revisionName
611613
return nil
612614
}
613615

616+
// ClusterExtensionRevisionDoesNotHaveAnnotation waits for the named ClusterExtensionRevision to exist
617+
// and then asserts that it does NOT have the specified annotation key. Polls with timeout.
618+
func ClusterExtensionRevisionDoesNotHaveAnnotation(ctx context.Context, revisionName, annotationKey string) error {
619+
sc := scenarioCtx(ctx)
620+
revisionName = substituteScenarioVars(strings.TrimSpace(revisionName), sc)
621+
waitFor(ctx, func() bool {
622+
obj, err := getResource("clusterextensionrevision", revisionName, "")
623+
if err != nil {
624+
logger.V(1).Error(err, "failed to get clusterextensionrevision", "name", revisionName)
625+
return false
626+
}
627+
_, found := obj.GetAnnotations()[annotationKey]
628+
return !found
629+
})
630+
return nil
631+
}
632+
633+
// ResourceDoesNotHaveAnnotation waits for a namespaced resource to exist and then asserts
634+
// that it does NOT have the specified annotation key. Polls with timeout.
635+
func ResourceDoesNotHaveAnnotation(ctx context.Context, resourceName, annotationKey string) error {
636+
sc := scenarioCtx(ctx)
637+
resourceName = substituteScenarioVars(resourceName, sc)
638+
639+
kind, name, ok := strings.Cut(resourceName, "/")
640+
if !ok {
641+
return fmt.Errorf("invalid resource name format: %q (expected kind/name)", resourceName)
642+
}
643+
644+
waitFor(ctx, func() bool {
645+
out, err := k8sClient("get", kind, name, "-n", sc.namespace, "-o", "json")
646+
if err != nil {
647+
return false
648+
}
649+
var obj unstructured.Unstructured
650+
if err := json.Unmarshal([]byte(out), &obj); err != nil {
651+
return false
652+
}
653+
_, found := obj.GetAnnotations()[annotationKey]
654+
return !found
655+
})
656+
return nil
657+
}
658+
614659
// ClusterExtensionRevisionObjectsNotFoundOrNotOwned waits for all objects described in the named
615660
// ClusterExtensionRevision's phases to either not exist on the cluster or not contain the revision
616661
// in their ownerReferences. Polls with timeout.

0 commit comments

Comments
 (0)