diff --git a/internal/operator-controller/applier/boxcutter.go b/internal/operator-controller/applier/boxcutter.go index cb4da7e53..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" @@ -40,6 +41,9 @@ import ( const ( ClusterExtensionRevisionRetentionLimit = 5 + + maxBundleConfigAnnotationBytes = 50 * 1024 // 50KB + bundleConfigTruncatedSuffix = "" ) type ClusterExtensionRevisionGenerator interface { @@ -206,6 +210,17 @@ 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 { + value := string(ext.Spec.Config.Inline.Raw) + if len(value) > maxBundleConfigAnnotationBytes { + maxContent := maxBundleConfigAnnotationBytes - len(bundleConfigTruncatedSuffix) + for maxContent > 0 && !utf8.RuneStart(value[maxContent]) { + maxContent-- + } + value = value[:maxContent] + 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 4f8461250..261bea6b5 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,76 @@ 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, + }, + "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-len("")] + "", + }, + } { + 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) + 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") + } + }) + } +} + 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..0d8d4dc43 100644 --- a/internal/operator-controller/labels/labels.go +++ b/internal/operator-controller/labels/labels.go @@ -40,6 +40,13 @@ 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. + // 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 // 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..4f2378339 100644 --- a/test/e2e/features/install.feature +++ b/test/e2e/features/install.feature @@ -501,6 +501,36 @@ 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: + watchNamespace: ${TEST_NAMESPACE} + source: + sourceType: Catalog + catalog: + packageName: own-namespace-operator + 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 + """ + {"watchNamespace":"${TEST_NAMESPACE}"} + """ + @DeploymentConfig Scenario: deploymentConfig nodeSelector is applied to the operator deployment When ClusterExtension is applied 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, "")