From 249ea0699a1db9d546a6b8c174b1edb79915849a Mon Sep 17 00:00:00 2001 From: "Per G. da Silva" Date: Tue, 10 Mar 2026 12:48:43 +0100 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8=20Add=20bundle-config=20annotatio?= =?UTF-8?q?n=20to=20ClusterExtensionRevision?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a ClusterExtension has .spec.config.inline set, propagate the inline configuration as a JSON annotation on the ClusterExtensionRevision using the key olm.operatorframework.io/bundle-config. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../operator-controller/applier/boxcutter.go | 3 + .../applier/boxcutter_test.go | 62 +++++++++++++++++++ internal/operator-controller/labels/labels.go | 6 ++ test/e2e/features/install.feature | 32 ++++++++++ 4 files changed, 103 insertions(+) diff --git a/internal/operator-controller/applier/boxcutter.go b/internal/operator-controller/applier/boxcutter.go index cb4da7e53..0dad44b15 100644 --- a/internal/operator-controller/applier/boxcutter.go +++ b/internal/operator-controller/applier/boxcutter.go @@ -206,6 +206,9 @@ func (r *SimpleRevisionGenerator) buildClusterExtensionRevision( } annotations[labels.ServiceAccountNameKey] = ext.Spec.ServiceAccount.Name annotations[labels.ServiceAccountNamespaceKey] = ext.Spec.Namespace + if ext.Spec.Config != nil && ext.Spec.Config.Inline != nil { + annotations[labels.BundleConfigKey] = string(ext.Spec.Config.Inline.Raw) + } phases := PhaseSort(objects) diff --git a/internal/operator-controller/applier/boxcutter_test.go b/internal/operator-controller/applier/boxcutter_test.go index 4f8461250..47a846c9c 100644 --- a/internal/operator-controller/applier/boxcutter_test.go +++ b/internal/operator-controller/applier/boxcutter_test.go @@ -18,6 +18,7 @@ import ( appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -481,6 +482,67 @@ func Test_SimpleRevisionGenerator_PropagatesProgressDeadlineMinutes(t *testing.T } } +func Test_SimpleRevisionGenerator_BundleConfigAnnotation(t *testing.T) { + r := &FakeManifestProvider{ + GetFn: func(b fs.FS, e *ocv1.ClusterExtension) ([]client.Object, error) { + return []client.Object{}, nil + }, + } + + b := applier.SimpleRevisionGenerator{ + Scheme: k8scheme.Scheme, + ManifestProvider: r, + } + + for name, tc := range map[string]struct { + config *ocv1.ClusterExtensionConfig + wantAnnot bool + wantValue string + }{ + "annotation set when inline config is present": { + config: &ocv1.ClusterExtensionConfig{ + ConfigType: ocv1.ClusterExtensionConfigTypeInline, + Inline: &apiextensionsv1.JSON{Raw: []byte(`{"watchNamespace":"ns1"}`)}, + }, + wantAnnot: true, + wantValue: `{"watchNamespace":"ns1"}`, + }, + "annotation not set when config is nil": { + config: nil, + wantAnnot: false, + }, + "annotation not set when inline is nil": { + config: &ocv1.ClusterExtensionConfig{ + ConfigType: ocv1.ClusterExtensionConfigTypeInline, + }, + wantAnnot: false, + }, + } { + t.Run(name, func(t *testing.T) { + ext := &ocv1.ClusterExtension{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-extension", + }, + Spec: ocv1.ClusterExtensionSpec{ + Namespace: "test-namespace", + ServiceAccount: ocv1.ServiceAccountReference{Name: "test-sa"}, + Config: tc.config, + }, + } + rev, err := b.GenerateRevision(t.Context(), dummyBundle, ext, map[string]string{}, map[string]string{}) + require.NoError(t, err) + + val, ok := rev.Annotations[labels.BundleConfigKey] + if tc.wantAnnot { + require.True(t, ok, "expected bundle-config annotation to be present") + require.Equal(t, tc.wantValue, val) + } else { + require.False(t, ok, "expected bundle-config annotation to not be present") + } + }) + } +} + func Test_SimpleRevisionGenerator_Failure(t *testing.T) { r := &FakeManifestProvider{ GetFn: func(b fs.FS, e *ocv1.ClusterExtension) ([]client.Object, error) { diff --git a/internal/operator-controller/labels/labels.go b/internal/operator-controller/labels/labels.go index 16f45ecbb..0ee467027 100644 --- a/internal/operator-controller/labels/labels.go +++ b/internal/operator-controller/labels/labels.go @@ -40,6 +40,12 @@ const ( // ClusterExtensionRevision operations is preserved. ServiceAccountNamespaceKey = "olm.operatorframework.io/service-account-namespace" + // BundleConfigKey is the annotation key used to record the inline bundle + // configuration from the owning ClusterExtension. It is applied as an + // annotation on ClusterExtensionRevision resources when the ClusterExtension + // has .spec.config.inline set. The value is a JSON string of the configuration. + BundleConfigKey = "olm.operatorframework.io/bundle-config" + // MigratedFromHelmKey is the label key used to mark ClusterExtensionRevisions // that were created during migration from Helm releases. This label is used // to distinguish migrated revisions from those created by normal Boxcutter operation. diff --git a/test/e2e/features/install.feature b/test/e2e/features/install.feature index ce3fb3430..f7aaf4d1d 100644 --- a/test/e2e/features/install.feature +++ b/test/e2e/features/install.feature @@ -501,6 +501,38 @@ Feature: Install ClusterExtension And ClusterExtensionRevision "${NAME}-1" has label "olm.operatorframework.io/owner-kind" with value "ClusterExtension" And ClusterExtensionRevision "${NAME}-1" has label "olm.operatorframework.io/owner-name" with value "${NAME}" + @BoxcutterRuntime + Scenario: ClusterExtensionRevision is annotated with bundle config when inline config is set + When ClusterExtension is applied + """ + apiVersion: olm.operatorframework.io/v1 + kind: ClusterExtension + metadata: + name: ${NAME} + spec: + namespace: ${TEST_NAMESPACE} + serviceAccount: + name: olm-sa + config: + configType: Inline + inline: + deploymentConfig: + nodeSelector: + kubernetes.io/os: linux + source: + sourceType: Catalog + catalog: + packageName: test + selector: + matchLabels: + "olm.operatorframework.io/metadata.name": test-catalog + """ + Then ClusterExtension is rolled out + And ClusterExtensionRevision "${NAME}-1" contains annotation "olm.operatorframework.io/bundle-config" with value + """ + {"deploymentConfig":{"nodeSelector":{"kubernetes.io/os":"linux"}}} + """ + @DeploymentConfig Scenario: deploymentConfig nodeSelector is applied to the operator deployment When ClusterExtension is applied From 550f451d73c92f8130f8f56d3bdbf8b137882ec7 Mon Sep 17 00:00:00 2001 From: "Per G. da Silva" Date: Tue, 10 Mar 2026 14:03:31 +0100 Subject: [PATCH 2/3] Truncate bundle-config annotation value at 50KB Append "" suffix when the inline configuration JSON exceeds 50KB to stay within annotation size limits. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/operator-controller/applier/boxcutter.go | 9 ++++++++- internal/operator-controller/applier/boxcutter_test.go | 8 ++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/internal/operator-controller/applier/boxcutter.go b/internal/operator-controller/applier/boxcutter.go index 0dad44b15..d431b392f 100644 --- a/internal/operator-controller/applier/boxcutter.go +++ b/internal/operator-controller/applier/boxcutter.go @@ -40,6 +40,9 @@ import ( const ( ClusterExtensionRevisionRetentionLimit = 5 + + maxBundleConfigAnnotationBytes = 50 * 1024 // 50KB + bundleConfigTruncatedSuffix = "" ) type ClusterExtensionRevisionGenerator interface { @@ -207,7 +210,11 @@ func (r *SimpleRevisionGenerator) buildClusterExtensionRevision( annotations[labels.ServiceAccountNameKey] = ext.Spec.ServiceAccount.Name annotations[labels.ServiceAccountNamespaceKey] = ext.Spec.Namespace if ext.Spec.Config != nil && ext.Spec.Config.Inline != nil { - annotations[labels.BundleConfigKey] = string(ext.Spec.Config.Inline.Raw) + value := string(ext.Spec.Config.Inline.Raw) + if len(value) > maxBundleConfigAnnotationBytes { + value = value[:maxBundleConfigAnnotationBytes] + bundleConfigTruncatedSuffix + } + annotations[labels.BundleConfigKey] = value } phases := PhaseSort(objects) diff --git a/internal/operator-controller/applier/boxcutter_test.go b/internal/operator-controller/applier/boxcutter_test.go index 47a846c9c..4d8b645d4 100644 --- a/internal/operator-controller/applier/boxcutter_test.go +++ b/internal/operator-controller/applier/boxcutter_test.go @@ -517,6 +517,14 @@ func Test_SimpleRevisionGenerator_BundleConfigAnnotation(t *testing.T) { }, wantAnnot: false, }, + "annotation is truncated when inline config exceeds 50KB": { + config: &ocv1.ClusterExtensionConfig{ + ConfigType: ocv1.ClusterExtensionConfigTypeInline, + Inline: &apiextensionsv1.JSON{Raw: []byte(`{"key":"` + strings.Repeat("x", 50*1024) + `"}`)}, + }, + wantAnnot: true, + wantValue: (`{"key":"` + strings.Repeat("x", 50*1024))[:50*1024] + "", + }, } { t.Run(name, func(t *testing.T) { ext := &ocv1.ClusterExtension{ From ad2ae29a381c616b44ab467a33716efc9ce9571c Mon Sep 17 00:00:00 2001 From: "Per G. da Silva" Date: Tue, 10 Mar 2026 14:05:36 +0100 Subject: [PATCH 3/3] Fix truncation to keep total value within 50KB Account for the suffix length when truncating so the final annotation value (content + suffix) does not exceed the 50KB limit. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Per G. da Silva --- internal/operator-controller/applier/boxcutter.go | 7 ++++++- internal/operator-controller/applier/boxcutter_test.go | 3 ++- internal/operator-controller/labels/labels.go | 1 + test/e2e/features/install.feature | 8 +++----- test/e2e/steps/steps.go | 2 +- 5 files changed, 13 insertions(+), 8 deletions(-) diff --git a/internal/operator-controller/applier/boxcutter.go b/internal/operator-controller/applier/boxcutter.go index d431b392f..823fe5634 100644 --- a/internal/operator-controller/applier/boxcutter.go +++ b/internal/operator-controller/applier/boxcutter.go @@ -11,6 +11,7 @@ import ( "maps" "slices" "strings" + "unicode/utf8" "helm.sh/helm/v3/pkg/release" "helm.sh/helm/v3/pkg/storage/driver" @@ -212,7 +213,11 @@ func (r *SimpleRevisionGenerator) buildClusterExtensionRevision( if ext.Spec.Config != nil && ext.Spec.Config.Inline != nil { value := string(ext.Spec.Config.Inline.Raw) if len(value) > maxBundleConfigAnnotationBytes { - value = value[:maxBundleConfigAnnotationBytes] + bundleConfigTruncatedSuffix + maxContent := maxBundleConfigAnnotationBytes - len(bundleConfigTruncatedSuffix) + for maxContent > 0 && !utf8.RuneStart(value[maxContent]) { + maxContent-- + } + value = value[:maxContent] + bundleConfigTruncatedSuffix } annotations[labels.BundleConfigKey] = value } diff --git a/internal/operator-controller/applier/boxcutter_test.go b/internal/operator-controller/applier/boxcutter_test.go index 4d8b645d4..261bea6b5 100644 --- a/internal/operator-controller/applier/boxcutter_test.go +++ b/internal/operator-controller/applier/boxcutter_test.go @@ -523,7 +523,7 @@ func Test_SimpleRevisionGenerator_BundleConfigAnnotation(t *testing.T) { Inline: &apiextensionsv1.JSON{Raw: []byte(`{"key":"` + strings.Repeat("x", 50*1024) + `"}`)}, }, wantAnnot: true, - wantValue: (`{"key":"` + strings.Repeat("x", 50*1024))[:50*1024] + "", + wantValue: (`{"key":"` + strings.Repeat("x", 50*1024))[:50*1024-len("")] + "", }, } { t.Run(name, func(t *testing.T) { @@ -544,6 +544,7 @@ func Test_SimpleRevisionGenerator_BundleConfigAnnotation(t *testing.T) { if tc.wantAnnot { require.True(t, ok, "expected bundle-config annotation to be present") require.Equal(t, tc.wantValue, val) + require.LessOrEqual(t, len(val), 50*1024, "annotation value must not exceed 50KB") } else { require.False(t, ok, "expected bundle-config annotation to not be present") } diff --git a/internal/operator-controller/labels/labels.go b/internal/operator-controller/labels/labels.go index 0ee467027..0d8d4dc43 100644 --- a/internal/operator-controller/labels/labels.go +++ b/internal/operator-controller/labels/labels.go @@ -44,6 +44,7 @@ const ( // configuration from the owning ClusterExtension. It is applied as an // annotation on ClusterExtensionRevision resources when the ClusterExtension // has .spec.config.inline set. The value is a JSON string of the configuration. + // Values over 50KB are truncated to avoid overrunning metadata size. BundleConfigKey = "olm.operatorframework.io/bundle-config" // MigratedFromHelmKey is the label key used to mark ClusterExtensionRevisions diff --git a/test/e2e/features/install.feature b/test/e2e/features/install.feature index f7aaf4d1d..4f2378339 100644 --- a/test/e2e/features/install.feature +++ b/test/e2e/features/install.feature @@ -516,13 +516,11 @@ Feature: Install ClusterExtension config: configType: Inline inline: - deploymentConfig: - nodeSelector: - kubernetes.io/os: linux + watchNamespace: ${TEST_NAMESPACE} source: sourceType: Catalog catalog: - packageName: test + packageName: own-namespace-operator selector: matchLabels: "olm.operatorframework.io/metadata.name": test-catalog @@ -530,7 +528,7 @@ Feature: Install ClusterExtension Then ClusterExtension is rolled out And ClusterExtensionRevision "${NAME}-1" contains annotation "olm.operatorframework.io/bundle-config" with value """ - {"deploymentConfig":{"nodeSelector":{"kubernetes.io/os":"linux"}}} + {"watchNamespace":"${TEST_NAMESPACE}"} """ @DeploymentConfig diff --git a/test/e2e/steps/steps.go b/test/e2e/steps/steps.go index fd5ac1211..3d1bd1863 100644 --- a/test/e2e/steps/steps.go +++ b/test/e2e/steps/steps.go @@ -540,7 +540,7 @@ func ClusterExtensionRevisionHasAnnotationWithValue(ctx context.Context, revisio revisionName = substituteScenarioVars(strings.TrimSpace(revisionName), sc) expectedValue := "" if annotationValue != nil { - expectedValue = annotationValue.Content + expectedValue = substituteScenarioVars(strings.TrimSpace(annotationValue.Content), sc) } waitFor(ctx, func() bool { obj, err := getResource("clusterextensionrevision", revisionName, "")