From 16c91b1dc72b49bc5dcaa6c91289976593e36067 Mon Sep 17 00:00:00 2001 From: Fabricio Aguiar Date: Wed, 11 Nov 2026 18:53:49 +0000 Subject: [PATCH] test(e2e): prepare independent object-controller readiness Discover controllers independently and check object-controller readiness without requiring ClusterExtension or ClusterCatalog. Select permissions from scenario tags and use shared component deployment constants. Cover discovery, scenario selection, readiness failures, and referenced Secret namespaces before enabling standalone COS and COD feature tags. Refs: OPRUN-4849 Signed-off-by: Fabricio Aguiar rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- test/internal/e2e/catalogd/steps/ha_steps.go | 14 +- .../e2e/catalogd/steps/proxy_steps.go | 8 +- test/internal/e2e/catalogd/steps/tls_steps.go | 8 +- .../object-controller/steps/readiness_test.go | 219 ++++++++++++++++++ .../e2e/object-controller/steps/steps.go | 39 ++++ .../internal/e2e/platform/steps/demo_steps.go | 2 +- test/internal/e2e/steps/hooks.go | 82 +++++-- test/internal/e2e/steps/hooks_test.go | 204 ++++++++++++++++ test/internal/e2e/steps/steps.go | 29 ++- test/internal/e2e/steps/upgrade_steps.go | 14 +- 10 files changed, 568 insertions(+), 51 deletions(-) create mode 100644 test/internal/e2e/object-controller/steps/readiness_test.go create mode 100644 test/internal/e2e/steps/hooks_test.go diff --git a/test/internal/e2e/catalogd/steps/ha_steps.go b/test/internal/e2e/catalogd/steps/ha_steps.go index 9f82828137..b3a862d1c8 100644 --- a/test/internal/e2e/catalogd/steps/ha_steps.go +++ b/test/internal/e2e/catalogd/steps/ha_steps.go @@ -9,7 +9,7 @@ import ( ) // CatalogdLeaderPodIsForceDeleted force-deletes the catalogd leader pod to simulate leader loss. -// The pod is identified from sc.LeaderPods["catalogd"] (populated by a prior +// The pod is identified from sc.LeaderPods[shared.CatalogdComponent] (populated by a prior // "catalogd is ready to reconcile resources" step). Force-deletion is equivalent to // an abrupt process crash: the lease is no longer renewed and the surviving pod // acquires leadership after the lease expires. @@ -19,13 +19,13 @@ import ( // would break etcd quorum and make the API server unreachable for the rest of the test. func CatalogdLeaderPodIsForceDeleted(ctx context.Context) error { sc := shared.Scenario(ctx) - leaderPod := sc.LeaderPods["catalogd"] + leaderPod := sc.LeaderPods[shared.CatalogdComponent] if leaderPod == "" { return fmt.Errorf("catalogd leader pod not found in scenario context; run 'catalogd is ready to reconcile resources' first") } shared.Log().Info("Force-deleting catalogd leader pod", "pod", leaderPod) - if _, err := shared.K8sClient(ctx, "delete", "pod", leaderPod, "-n", shared.NamespaceForComponent("catalogd"), + if _, err := shared.K8sClient(ctx, "delete", "pod", leaderPod, "-n", shared.NamespaceForComponent(shared.CatalogdComponent), "--force", "--grace-period=0"); err != nil { return fmt.Errorf("failed to force-delete catalogd leader pod %q: %w", leaderPod, err) } @@ -34,13 +34,13 @@ func CatalogdLeaderPodIsForceDeleted(ctx context.Context) error { // NewCatalogdLeaderIsElected polls the catalogd leader election lease until the holder // identity changes to a pod other than the deleted leader. It updates -// sc.LeaderPods["catalogd"] with the new leader pod name. +// sc.LeaderPods[shared.CatalogdComponent] with the new leader pod name. func NewCatalogdLeaderIsElected(ctx context.Context) error { sc := shared.Scenario(ctx) - oldLeader := sc.LeaderPods["catalogd"] + oldLeader := sc.LeaderPods[shared.CatalogdComponent] shared.WaitFor(ctx, func() bool { - holder, err := shared.K8sClient(ctx, "get", "lease", "catalogd-operator-lock", "-n", shared.NamespaceForComponent("catalogd"), + holder, err := shared.K8sClient(ctx, "get", "lease", "catalogd-operator-lock", "-n", shared.NamespaceForComponent(shared.CatalogdComponent), "-o", "jsonpath={.spec.holderIdentity}") if err != nil || holder == "" { return false @@ -49,7 +49,7 @@ func NewCatalogdLeaderIsElected(ctx context.Context) error { if newPod == oldLeader { return false } - sc.LeaderPods["catalogd"] = newPod + sc.LeaderPods[shared.CatalogdComponent] = newPod shared.Log().Info("New catalogd leader elected", "pod", newPod) return true }) diff --git a/test/internal/e2e/catalogd/steps/proxy_steps.go b/test/internal/e2e/catalogd/steps/proxy_steps.go index 7035797a07..a3a1c6ac25 100644 --- a/test/internal/e2e/catalogd/steps/proxy_steps.go +++ b/test/internal/e2e/catalogd/steps/proxy_steps.go @@ -290,10 +290,10 @@ func configureDeploymentProxy(ctx context.Context, component, proxyURL string) e var deployName string switch component { - case "operator-controller": - deployName = "operator-controller-controller-manager" - case "catalogd": - deployName = "catalogd-controller-manager" + case shared.OperatorControllerComponent: + deployName = shared.OperatorControllerDeploymentName + case shared.CatalogdComponent: + deployName = shared.CatalogdDeploymentName default: return fmt.Errorf("unknown component %q", component) } diff --git a/test/internal/e2e/catalogd/steps/tls_steps.go b/test/internal/e2e/catalogd/steps/tls_steps.go index ebf7b0c1f6..c1fe32f851 100644 --- a/test/internal/e2e/catalogd/steps/tls_steps.go +++ b/test/internal/e2e/catalogd/steps/tls_steps.go @@ -147,10 +147,10 @@ func MetricsEndpointAcceptsConnectionUsingCurve(ctx context.Context, component, // the actual Kubernetes Deployment name. func componentDeploymentName(component string) (string, error) { switch component { - case "operator-controller": - return "operator-controller-controller-manager", nil - case "catalogd": - return "catalogd-controller-manager", nil + case shared.OperatorControllerComponent: + return shared.OperatorControllerDeploymentName, nil + case shared.CatalogdComponent: + return shared.CatalogdDeploymentName, nil default: return "", fmt.Errorf("unknown component %q: expected operator-controller or catalogd", component) } diff --git a/test/internal/e2e/object-controller/steps/readiness_test.go b/test/internal/e2e/object-controller/steps/readiness_test.go new file mode 100644 index 0000000000..d2402bef78 --- /dev/null +++ b/test/internal/e2e/object-controller/steps/readiness_test.go @@ -0,0 +1,219 @@ +package steps + +import ( + "encoding/json" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/cucumber/godog" + "github.com/spf13/pflag" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + + ocv1 "github.com/operator-framework/operator-controller/api/v1" + shared "github.com/operator-framework/operator-controller/test/internal/e2e/steps" +) + +// TestObjectControllerReadiness checks API creation, establishment, controller +// availability, and isolation for standalone and integrated scenarios. +func TestObjectControllerReadiness(t *testing.T) { + for _, tc := range []struct { + name string + standalone bool + missing bool + integrated bool + cod bool + delayedCRDs bool + forbiddenAPI string + failCommand string + wantError string + }{ + {name: "standalone COS", standalone: true}, + {name: "standalone COD", standalone: true, cod: true}, + {name: "integrated namespaces", integrated: true}, + {name: "COS API created after discovery", delayedCRDs: true}, + {name: "COS and COD APIs created after discovery", cod: true, delayedCRDs: true}, + {name: "missing standalone deployment", standalone: true, missing: true, wantError: "object-controller is not installed"}, + {name: "missing integrated deployment", integrated: true, missing: true, wantError: "object-controller is not installed"}, + {name: "operator deployment violates isolation", standalone: true, integrated: true, wantError: "require operator-controller and catalogd to be absent"}, + {name: "ClusterExtension violates isolation", standalone: true, forbiddenAPI: "clusterextensions", wantError: "APIs to be absent"}, + {name: "ClusterCatalog violates isolation", standalone: true, forbiddenAPI: "clustercatalogs", wantError: "APIs to be absent"}, + {name: "isolation check errors fail closed", standalone: true, failCommand: "get crd", wantError: "checking standalone API isolation"}, + {name: "COS API creation is required", failCommand: "wait --for=create crd/clusterobjectsets.", wantError: "clusterobjectsets.olm.operatorframework.io was not created"}, + {name: "COD API creation is required for COD", cod: true, failCommand: "wait --for=create crd/clusterobjectdeployments.", wantError: "clusterobjectdeployments.olm.operatorframework.io was not created"}, + {name: "COS API is required", failCommand: "wait --for=condition=Established crd/clusterobjectsets.", wantError: "clusterobjectsets.olm.operatorframework.io is unavailable"}, + {name: "COD API is required for COD", cod: true, failCommand: "wait --for=condition=Established crd/clusterobjectdeployments.", wantError: "clusterobjectdeployments.olm.operatorframework.io is unavailable"}, + {name: "COS does not require COD creation", failCommand: "wait --for=create crd/clusterobjectdeployments."}, + {name: "COS does not require COD", failCommand: "wait --for=condition=Established crd/clusterobjectdeployments."}, + {name: "unavailable controller fails", failCommand: "rollout status", wantError: "object-controller is unavailable"}, + } { + t.Run(tc.name, func(t *testing.T) { + oldNamespaces := shared.ComponentNamespaces + oldCLI := pflag.Lookup("k8s.cli").Value.String() + oldOnly := pflag.Lookup("e2e.object-controller-only").Value.String() + t.Cleanup(func() { + shared.ComponentNamespaces = oldNamespaces + require.NoError(t, pflag.Set("k8s.cli", oldCLI)) + require.NoError(t, pflag.Set("e2e.object-controller-only", oldOnly)) + }) + shared.ComponentNamespaces = map[string]string{} + if !tc.missing { + shared.ComponentNamespaces[shared.ObjectControllerComponent] = "objects" + } + if tc.integrated { + shared.ComponentNamespaces[shared.OperatorControllerComponent] = "extensions" + shared.ComponentNamespaces[shared.CatalogdComponent] = "catalogs" + } + require.NoError(t, pflag.Set("e2e.object-controller-only", map[bool]string{true: "true", false: "false"}[tc.standalone])) + dir := t.TempDir() + cli, commands := filepath.Join(dir, "kubectl"), filepath.Join(dir, "commands") + t.Setenv("E2E_READINESS_COMMANDS", commands) + t.Setenv("E2E_FORBIDDEN_API", tc.forbiddenAPI) + t.Setenv("E2E_FAIL_COMMAND", tc.failCommand) + t.Setenv("E2E_DELAYED_CRDS", "") + if tc.delayedCRDs { + t.Setenv("E2E_DELAYED_CRDS", dir) + } + script := `#!/bin/sh +printf '%s\n' "$*" >> "$E2E_READINESS_COMMANDS" +if [ -n "$E2E_FAIL_COMMAND" ]; then + case "$*" in "$E2E_FAIL_COMMAND"*) echo "not available" >&2; exit 1;; esac +fi +if [ -n "$E2E_DELAYED_CRDS" ]; then + case "$1 $2" in + "wait --for=create") + # Simulate a CRD appearing during the creation wait. + touch "$E2E_DELAYED_CRDS/${3#crd/}";; + "wait --for=condition=Established") + if [ ! -f "$E2E_DELAYED_CRDS/${3#crd/}" ]; then + echo "CRD not found" >&2 + exit 1 + fi;; + esac +fi +case "$1 $2" in "get crd") printf '%s' "$E2E_FORBIDDEN_API";; esac +` + //nolint:gosec // The private test CLI must be executable. + require.NoError(t, os.WriteFile(cli, []byte(script), 0o700)) + require.NoError(t, pflag.Set("k8s.cli", cli)) + scenario := &godog.Scenario{Id: "1", Uri: "renamed.feature"} + if tc.cod { + require.NoError(t, json.Unmarshal([]byte(`{"tags":[{"name":"@ClusterObjectDeployment"}]}`), scenario)) + } + ctx, err := shared.CreateScenarioContext(t.Context(), scenario) + require.NoError(t, err) + err = ObjectControllerIsAvailable(ctx) + if tc.wantError != "" { + require.ErrorContains(t, err, tc.wantError) + } else { + require.NoError(t, err) + } + if tc.missing || tc.standalone && tc.integrated { + require.NoFileExists(t, commands, "discovery errors should fail immediately") + return + } + data, err := os.ReadFile(commands) + require.NoError(t, err) + if strings.HasPrefix(tc.failCommand, "wait --for=create ") && tc.wantError != "" { + api := strings.TrimPrefix(tc.failCommand, "wait --for=create ") + require.NotContains(t, string(data), "wait --for=condition=Established "+api) + require.NotContains(t, string(data), "rollout status") + } + if tc.wantError == "" { + require.Contains(t, string(data), "rollout status deployment/"+shared.ObjectControllerDeploymentName+" -n objects --timeout=") + require.Equal(t, tc.cod, strings.Contains(string(data), "crd/clusterobjectdeployments.")) + require.Equal(t, tc.standalone, strings.Contains(string(data), "get crd clusterextensions.")) + } + }) + } +} + +// TestReferredSecretAssertionsUseReferenceNamespaces checks that Secret assertions +// use declared namespaces and deduplicate implicit system-namespace references. +func TestReferredSecretAssertionsUseReferenceNamespaces(t *testing.T) { + oldNamespaces, oldNamespace, oldCLI := shared.ComponentNamespaces, shared.OLMNamespace, pflag.Lookup("k8s.cli").Value.String() + t.Cleanup(func() { + shared.ComponentNamespaces, shared.OLMNamespace = oldNamespaces, oldNamespace + require.NoError(t, pflag.Set("k8s.cli", oldCLI)) + }) + shared.OLMNamespace = "extensions" + shared.ComponentNamespaces = map[string]string{shared.ObjectControllerComponent: "objects"} + dir := t.TempDir() + t.Setenv("E2E_SECRET_FIXTURES", dir) + cli := filepath.Join(dir, "kubectl") + script := `#!/bin/sh +printf '%s\n' "$*" >> "$E2E_SECRET_FIXTURES/commands" +case "$1 $2" in + "get clusterobjectset") cat "$E2E_SECRET_FIXTURES/revision.json";; + "get secret") cat "$E2E_SECRET_FIXTURES/${5:-kubeconfig-default}-$3.json";; + *) echo "unexpected command: $*" >&2; exit 1;; +esac +` + //nolint:gosec // The private test CLI must be executable. + require.NoError(t, os.WriteFile(cli, []byte(script), 0o700)) + require.NoError(t, pflag.Set("k8s.cli", cli)) + revision := ocv1.ClusterObjectSet{ObjectMeta: metav1.ObjectMeta{Name: "revision", UID: "revision-uid"}} + namespaces := []string{"objects", "extensions", "referenced-content"} + for _, namespace := range namespaces { + revision.Spec.Phases = append(revision.Spec.Phases, ocv1.ClusterObjectSetPhase{ + Name: namespace, + Objects: []ocv1.ClusterObjectSetObject{ + {Type: ocv1.ObjectTypeSecretReference, SecretRef: ocv1.SecretSourceRef{Name: "manifest", Namespace: namespace, Key: "first"}}, + {Type: ocv1.ObjectTypeSecretReference, SecretRef: ocv1.SecretSourceRef{Name: "manifest", Namespace: namespace, Key: "second"}}, + }, + }) + secret := corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "manifest", Namespace: namespace, + Labels: map[string]string{"olm.operatorframework.io/revision-name": revision.Name}, + OwnerReferences: []metav1.OwnerReference{{ + Kind: ocv1.ClusterObjectSetKind, Name: revision.Name, UID: revision.UID, Controller: ptr.To(true), + }}, + }, + Immutable: ptr.To(true), + Type: "olm.operatorframework.io/object-data", + } + data, err := json.Marshal(secret) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, namespace+"-manifest.json"), data, 0o600)) + } + // The omitted namespace must deduplicate with the explicit system namespace. + revision.Spec.Phases = append(revision.Spec.Phases, ocv1.ClusterObjectSetPhase{ + Name: "implicit", + Objects: []ocv1.ClusterObjectSetObject{{ + Type: ocv1.ObjectTypeSecretReference, SecretRef: ocv1.SecretSourceRef{Name: "manifest", Key: "implicit"}, + }}, + }) + data, err := json.Marshal(revision) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, "revision.json"), data, 0o600)) + suite := godog.TestSuite{ + ScenarioInitializer: func(sc *godog.ScenarioContext) { + sc.Before(shared.CreateScenarioContext) + RegisterSteps(sc) + }, + Options: &godog.Options{ + Format: "progress", Output: io.Discard, TestingT: t, Strict: true, + FeatureContents: []godog.Feature{{Name: "renamed.feature", Contents: []byte(`Feature: Secret reference namespaces + Scenario: Assertions use each reference's namespace + Given ClusterObjectSet "revision" referred secrets are immutable + And ClusterObjectSet "revision" referred secrets contain labels + | key | value | + | olm.operatorframework.io/revision-name | revision | + And ClusterObjectSet "revision" referred secrets are owned by the object set + And ClusterObjectSet "revision" referred secrets have type "olm.operatorframework.io/object-data" +`)}}, + }, + } + require.Zero(t, suite.Run()) + commands, err := os.ReadFile(filepath.Join(dir, "commands")) + require.NoError(t, err) + for _, namespace := range namespaces { + require.Equal(t, 4, strings.Count(string(commands), "get secret manifest -n "+namespace+" -o json\n")) + } +} diff --git a/test/internal/e2e/object-controller/steps/steps.go b/test/internal/e2e/object-controller/steps/steps.go index 0075168467..00adfbe81c 100644 --- a/test/internal/e2e/object-controller/steps/steps.go +++ b/test/internal/e2e/object-controller/steps/steps.go @@ -21,6 +21,7 @@ import ( // RegisterSteps registers ClusterObjectSet and ClusterObjectDeployment assertions. func RegisterSteps(sc *godog.ScenarioContext) { + sc.Step(`^object-controller is available$`, ObjectControllerIsAvailable) sc.Step(`^(?i)ClusterObjectSet "([^"]+)" lifecycle is set to "([^"]+)"$`, ClusterObjectSetLifecycleUpdate) sc.Step(`^(?i)ClusterObjectSet "([^"]+)" reports ([[:alnum:]]+) as ([[:alnum:]]+) with Reason ([[:alnum:]]+)$`, ClusterObjectSetReportsConditionWithoutMsg) sc.Step(`^(?i)ClusterObjectSet "([^"]+)" reports ([[:alnum:]]+) as ([[:alnum:]]+) with Reason ([[:alnum:]]+) and Message:$`, ClusterObjectSetReportsConditionWithMsg) @@ -54,6 +55,44 @@ func RegisterSteps(sc *godog.ScenarioContext) { sc.Step(`^(?i)ClusterObjectDeployment "([^"]+)" is owned by ClusterExtension "([^"]+)"$`, ClusterObjectDeploymentIsOwnedByClusterExtension) } +// ObjectControllerIsAvailable checks the controller and the APIs selected by the +// scenario. COS scenarios do not require COD, ClusterExtension, or ClusterCatalog. +func ObjectControllerIsAvailable(ctx context.Context) error { + namespace := shared.ComponentNamespaces[shared.ObjectControllerComponent] + if namespace == "" { + return fmt.Errorf("object-controller is not installed: Deployment %q was not discovered", shared.ObjectControllerDeploymentName) + } + if shared.ObjectControllerOnly() { + if shared.ComponentNamespaces[shared.OperatorControllerComponent] != "" || shared.ComponentNamespaces[shared.CatalogdComponent] != "" { + return fmt.Errorf("standalone object-controller tests require operator-controller and catalogd to be absent") + } + out, err := shared.K8sClient(ctx, "get", "crd", "clusterextensions.olm.operatorframework.io", "clustercatalogs.olm.operatorframework.io", "--ignore-not-found", "-o", "name") + if err != nil { + return fmt.Errorf("checking standalone API isolation: %w", err) + } + if strings.TrimSpace(out) != "" { + return fmt.Errorf("standalone object-controller tests require ClusterExtension and ClusterCatalog APIs to be absent; found: %s", strings.TrimSpace(out)) + } + } + apis := []string{"clusterobjectsets.olm.operatorframework.io"} + if shared.Scenario(ctx).Tags.Has("@ClusterObjectDeployment") { + apis = append(apis, "clusterobjectdeployments.olm.operatorframework.io") + } + for _, api := range apis { + if _, err := shared.K8sClient(ctx, "wait", "--for=create", "crd/"+api, "--timeout="+shared.Timeout.String()); err != nil { + return fmt.Errorf("required object-controller API %s was not created: %w", api, err) + } + if _, err := shared.K8sClient(ctx, "wait", "--for=condition=Established", "crd/"+api, "--timeout="+shared.Timeout.String()); err != nil { + return fmt.Errorf("required object-controller API %s is unavailable: %w", api, err) + } + } + if _, err := shared.K8sClient(ctx, "rollout", "status", "deployment/"+shared.ObjectControllerDeploymentName, + "-n", namespace, "--timeout="+shared.Timeout.String()); err != nil { + return fmt.Errorf("object-controller is unavailable: %w", err) + } + return nil +} + type referredSecretRef struct { Name string Namespace string diff --git a/test/internal/e2e/platform/steps/demo_steps.go b/test/internal/e2e/platform/steps/demo_steps.go index feb4a07792..a1c8fba29d 100644 --- a/test/internal/e2e/platform/steps/demo_steps.go +++ b/test/internal/e2e/platform/steps/demo_steps.go @@ -96,7 +96,7 @@ func ensureCatalogPortForward(ctx context.Context) (string, error) { resetCatalogPortForward(ctx) } - ns := shared.ComponentNamespaces["catalogd"] + ns := shared.ComponentNamespaces[shared.CatalogdComponent] target, err := catalogdLeaderPod(ctx, ns) port := int32(443) if err != nil { diff --git a/test/internal/e2e/steps/hooks.go b/test/internal/e2e/steps/hooks.go index 6a2ec13d7e..3ca69b1964 100644 --- a/test/internal/e2e/steps/hooks.go +++ b/test/internal/e2e/steps/hooks.go @@ -20,6 +20,7 @@ import ( appsv1 "k8s.io/api/apps/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/sets" "k8s.io/component-base/featuregate" "k8s.io/klog/v2/textlogger" "sigs.k8s.io/controller-runtime/pkg/client" @@ -37,6 +38,7 @@ type Resource struct { type World struct { ID string FeatureName string + Tags sets.Set[string] ScenarioName string Namespace string ClusterExtensionName string @@ -62,7 +64,8 @@ const ( ) var ( - devMode = false + devMode = false + objectControllerOnly bool // FeatureGates holds defaults refined by BeforeSuite from the cluster configuration. // Consumers must not mutate this map. FeatureGates = map[featuregate.Feature]bool{ @@ -72,6 +75,7 @@ var ( features.BoxcutterRuntime: false, features.DeploymentConfig: false, featuregate.Feature("CatalogdHA"): false, + ObjectControllerFeature: false, } logger logr.Logger ) @@ -79,8 +83,12 @@ var ( func init() { flagSet := pflag.CommandLine flagSet.BoolVar(&devMode, "log.debug", false, "print debug log level") + flagSet.BoolVar(&objectControllerOnly, "e2e.object-controller-only", false, "require object-controller without ClusterExtension or ClusterCatalog APIs/controllers") } +// ObjectControllerOnly reports whether the run must validate standalone isolation. +func ObjectControllerOnly() bool { return objectControllerOnly } + // RegisterHooks installs shared lifecycle hooks, with optional suite hooks between setup and cleanup. func RegisterHooks(sc *godog.ScenarioContext, hooks ...func(*godog.ScenarioContext)) { sc.Before(CheckFeatureTags) @@ -92,33 +100,30 @@ func RegisterHooks(sc *godog.ScenarioContext, hooks ...func(*godog.ScenarioConte sc.After(ScenarioCleanup) } -// detectOLMDeployments returns the operator-controller deployment (first) and the catalogd -// deployment (second) found via the app.kubernetes.io/part-of=olm label across all namespaces. -// The catalogd return value may be nil when OLM is not yet installed (upgrade scenarios -// install it in a Background step). -func detectOLMDeployments() (*appsv1.Deployment, *appsv1.Deployment, error) { +// detectOLMDeployments discovers components independently across namespaces. +// Missing components are valid for standalone installations and upgrade scenarios. +func detectOLMDeployments() (map[string]*appsv1.Deployment, error) { raw, err := K8sClient(context.Background(), "get", "deployments", "-A", "-l", "app.kubernetes.io/part-of=olm", "-o", "jsonpath={.items}") if err != nil { - return nil, nil, err + return nil, err } dl := []appsv1.Deployment{} if err := json.Unmarshal([]byte(raw), &dl); err != nil { - return nil, nil, fmt.Errorf("failed to unmarshal OLM deployments: %v", err) + return nil, fmt.Errorf("failed to unmarshal OLM deployments: %v", err) } - var operatorController, catalogd *appsv1.Deployment + deployments := map[string]*appsv1.Deployment{} for i := range dl { switch dl[i].Name { - case olmDeploymentName: - operatorController = &dl[i] - case catalogdDeploymentName: - catalogd = &dl[i] + case OperatorControllerDeploymentName: + deployments[OperatorControllerComponent] = &dl[i] + case CatalogdDeploymentName: + deployments[CatalogdComponent] = &dl[i] + case ObjectControllerDeploymentName: + deployments[ObjectControllerComponent] = &dl[i] } } - if operatorController == nil { - return nil, nil, fmt.Errorf("failed to detect OLM Deployment") - } - return operatorController, catalogd, nil + return deployments, nil } func BeforeSuite() { @@ -136,20 +141,36 @@ func BeforeSuite() { FeatureGates[featuregate.Feature("CatalogdHA")] = true } - olm, catalogdDep, err := detectOLMDeployments() + deployments, err := detectOLMDeployments() if err != nil { logger.Info("OLM deployments not found; skipping feature gate detection (upgrade scenarios will install OLM in Background)") return } - OLMNamespace = olm.Namespace - ComponentNamespaces["operator-controller"] = OLMNamespace + configureControllerFeatures(deployments) +} + +// configureControllerFeatures refreshes namespaces and feature gates from the +// independently discovered controller deployments. +func configureControllerFeatures(deployments map[string]*appsv1.Deployment) { + // Clear namespaces from any previous discovery, including removed components. + clear(ComponentNamespaces) + for component, deployment := range deployments { + ComponentNamespaces[component] = deployment.Namespace + } + object := deployments[ObjectControllerComponent] + FeatureGates[ObjectControllerFeature] = object != nil + olm, catalogdDep := deployments[OperatorControllerComponent], deployments[CatalogdComponent] + if olm != nil { + OLMNamespace = olm.Namespace + } else if object != nil { + // Shared Secret helpers default omitted namespaces to the system namespace. + OLMNamespace = object.Namespace + } // Catalogd may be in a different namespace than operator-controller. - catalogdNS := OLMNamespace - if catalogdDep != nil { - catalogdNS = catalogdDep.Namespace + if catalogdDep == nil && olm != nil { + ComponentNamespaces[CatalogdComponent] = OLMNamespace } - ComponentNamespaces["catalogd"] = catalogdNS // Refine CatalogdHA based on actual catalogd replica count now that catalogdNS is // known. The node-count check above can fire on any multi-node cluster even when @@ -162,6 +183,10 @@ func BeforeSuite() { } } + if olm == nil { + logger.Info("operator-controller is absent; using independently detected components") + return + } featureGatePattern := regexp.MustCompile(`--feature-gates=([[:alnum:]]+)=(true|false)`) for _, c := range olm.Spec.Template.Spec.Containers { if c.Name == "manager" { @@ -183,6 +208,11 @@ func BeforeSuite() { func CheckFeatureTags(ctx context.Context, sc *godog.Scenario) (context.Context, error) { for _, tag := range sc.Tags { if enabled, found := FeatureGates[featuregate.Feature(tag.Name[1:])]; found && !enabled { + // Explicit standalone and integrated Boxcutter runs must reach + // readiness so a missing required controller fails instead of skipping. + if tag.Name == "@"+string(ObjectControllerFeature) && (objectControllerOnly || FeatureGates[features.BoxcutterRuntime]) { + continue + } logger.Info(fmt.Sprintf("Skipping scenario %q because feature gate %q is disabled", sc.Name, tag.Name[1:])) return ctx, godog.ErrSkip } @@ -199,6 +229,7 @@ func CreateScenarioContext(ctx context.Context, sc *godog.Scenario) (context.Con scCtx := &World{ ID: id, FeatureName: featureName, + Tags: sets.New[string](), ScenarioName: sc.Name, Namespace: fmt.Sprintf("ns-%s", id), ClusterExtensionName: fmt.Sprintf("ce-%s", id), @@ -208,6 +239,9 @@ func CreateScenarioContext(ctx context.Context, sc *godog.Scenario) (context.Con MetricsResponse: make(map[string]string), LeaderPods: make(map[string]string), } + for _, tag := range sc.Tags { + scCtx.Tags.Insert(tag.Name) + } return context.WithValue(ctx, scenarioContextKey, scCtx), nil } diff --git a/test/internal/e2e/steps/hooks_test.go b/test/internal/e2e/steps/hooks_test.go new file mode 100644 index 0000000000..37c271a11a --- /dev/null +++ b/test/internal/e2e/steps/hooks_test.go @@ -0,0 +1,204 @@ +package steps + +import ( + "encoding/json" + "maps" + "os" + "path/filepath" + "testing" + + "github.com/cucumber/godog" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/component-base/featuregate" + "k8s.io/utils/ptr" + + ocv1 "github.com/operator-framework/operator-controller/api/v1" + "github.com/operator-framework/operator-controller/internal/operator-controller/features" +) + +// preserveDiscoveryState isolates discovery globals and restores them after the test. +func preserveDiscoveryState(t *testing.T) { + t.Helper() + gates, namespaces := FeatureGates, ComponentNamespaces + namespace, only, cli := OLMNamespace, objectControllerOnly, K8sCLI + FeatureGates, ComponentNamespaces = maps.Clone(gates), maps.Clone(namespaces) + t.Cleanup(func() { + FeatureGates, ComponentNamespaces = gates, namespaces + OLMNamespace, objectControllerOnly, K8sCLI = namespace, only, cli + }) +} + +// fakeCLI replaces the Kubernetes CLI with a temporary executable for the test. +func fakeCLI(t *testing.T, script string) { + t.Helper() + K8sCLI = filepath.Join(t.TempDir(), "kubectl") + //nolint:gosec // The private test CLI must be executable. + require.NoError(t, os.WriteFile(K8sCLI, []byte("#!/bin/sh\n"+script), 0o700)) +} + +// taggedScenario constructs a scenario carrying the requested feature tag. +func taggedScenario(t *testing.T, tag string) *godog.Scenario { + t.Helper() + scenario := &godog.Scenario{} + require.NoError(t, json.Unmarshal([]byte(`{"tags":[{"name":"`+tag+`"}]}`), scenario)) + return scenario +} + +// TestIndependentControllerDiscovery checks component detection and stale namespace +// cleanup when operator-controller is absent. +func TestIndependentControllerDiscovery(t *testing.T) { + preserveDiscoveryState(t) + deployments := []appsv1.Deployment{ + {ObjectMeta: metav1.ObjectMeta{Name: ObjectControllerDeploymentName, Namespace: "objects"}}, + {ObjectMeta: metav1.ObjectMeta{Name: CatalogdDeploymentName, Namespace: "catalogs"}, Spec: appsv1.DeploymentSpec{Replicas: ptr.To(int32(2))}}, + {ObjectMeta: metav1.ObjectMeta{Name: "unrelated", Namespace: "unrelated"}}, + } + data, err := json.Marshal(deployments) + require.NoError(t, err) + t.Setenv("E2E_DEPLOYMENTS", string(data)) + fakeCLI(t, `printf '%s' "$E2E_DEPLOYMENTS"`+"\n") + detected, err := detectOLMDeployments() + require.NoError(t, err) + require.Len(t, detected, 2) + ComponentNamespaces[OperatorControllerComponent] = "stale" + configureControllerFeatures(detected) + require.Equal(t, map[string]string{ObjectControllerComponent: "objects", CatalogdComponent: "catalogs"}, ComponentNamespaces) + require.Equal(t, "objects", OLMNamespace) + require.True(t, FeatureGates[ObjectControllerFeature]) + require.True(t, FeatureGates[featuregate.Feature("CatalogdHA")]) + + configureControllerFeatures(map[string]*appsv1.Deployment{}) + require.Empty(t, ComponentNamespaces, "removed components must not retain their namespaces") + require.False(t, FeatureGates[ObjectControllerFeature]) +} + +// TestObjectControllerScenarioSelection checks that required scenarios reach +// readiness while unrelated scenarios still honor their feature gates. +func TestObjectControllerScenarioSelection(t *testing.T) { + for _, tc := range []struct { + name string + object bool + operator bool + boxcutter bool + standalone bool + skip bool + }{ + {name: "standalone", object: true, standalone: true}, + {name: "standard skips object scenarios", operator: true, skip: true}, + {name: "integrated", object: true, operator: true, boxcutter: true}, + {name: "unready object deployment still reaches readiness", object: true}, + {name: "missing integrated controller reaches readiness", operator: true, boxcutter: true}, + {name: "missing standalone controller reaches readiness", standalone: true}, + } { + t.Run(tc.name, func(t *testing.T) { + preserveDiscoveryState(t) + FeatureGates[features.BoxcutterRuntime] = false + objectControllerOnly = tc.standalone + deployments := map[string]*appsv1.Deployment{} + if tc.object { + deployments[ObjectControllerComponent] = &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Namespace: "objects"}} + } + if tc.operator { + deployments[OperatorControllerComponent] = &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Namespace: "extensions"}, + Spec: appsv1.DeploymentSpec{Template: corev1.PodTemplateSpec{Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "manager", Args: []string{ + "--feature-gates=BoxcutterRuntime=" + map[bool]string{false: "false", true: "true"}[tc.boxcutter], + }}}, + }}}, + } + } + configureControllerFeatures(deployments) + _, err := CheckFeatureTags(t.Context(), taggedScenario(t, "@ObjectController")) + if tc.skip { + require.ErrorIs(t, err, godog.ErrSkip) + } else { + require.NoError(t, err) + } + // Existing integration features still honor the operator's runtime gate. + _, err = CheckFeatureTags(t.Context(), taggedScenario(t, "@BoxcutterRuntime")) + if tc.boxcutter { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, godog.ErrSkip) + } + }) + } +} + +// TestServiceAccountPermissionsUseScenarioTags checks RBAC selection from scenario +// tags and runtime gates regardless of feature filenames. +func TestServiceAccountPermissionsUseScenarioTags(t *testing.T) { + preserveDiscoveryState(t) + fakeCLI(t, "cat > \"$E2E_RBAC_MANIFEST\"\n") + for _, tc := range []struct { + name string + uri string + tag string + boxcutter bool + wantHelm bool + }{ + {name: "renamed direct feature", uri: "renamed.feature", tag: "@ObjectController"}, + {name: "filename does not select permissions", uri: "revision.feature", wantHelm: true}, + {name: "unrelated tag", uri: "renamed.feature", tag: "@ObjectControllerMetrics", wantHelm: true}, + {name: "integrated boxcutter runtime", uri: "install.feature", boxcutter: true}, + } { + t.Run(tc.name, func(t *testing.T) { + FeatureGates[features.BoxcutterRuntime] = tc.boxcutter + output := filepath.Join(t.TempDir(), "rbac.yaml") + t.Setenv("E2E_RBAC_MANIFEST", output) + scenario := &godog.Scenario{Id: "permissions", Uri: tc.uri} + if tc.tag != "" { + scenario.Tags = taggedScenario(t, tc.tag).Tags + } + ctx, err := CreateScenarioContext(t.Context(), scenario) + require.NoError(t, err) + err = ServiceAccountWithNeededPermissionsIsAvailableInTestNamespace(ctx, "pvc-probe-sa") + if tc.wantHelm { + // This account deliberately has only a Boxcutter template. + require.ErrorContains(t, err, "pvc-probe-sa-helm-rbac-template.yaml") + return + } + require.NoError(t, err) + manifest, err := os.ReadFile(output) + require.NoError(t, err) + require.Contains(t, string(manifest), "resources: [clusterobjectsets/finalizers]") + require.Contains(t, string(manifest), "name: "+Scenario(ctx).Namespace+"-pvc-probe-sa-clusterrole") + }) + } +} + +// TestResolveSecretRefNamespaceDefault checks explicit namespaces and the default +// system namespace when resolving Secret references. +func TestResolveSecretRefNamespaceDefault(t *testing.T) { + preserveDiscoveryState(t) + OLMNamespace = "extensions" + dir := t.TempDir() + t.Setenv("E2E_SECRET_FIXTURES", dir) + fakeCLI(t, "namespace=${5:-kubeconfig-default}\ncat \"$E2E_SECRET_FIXTURES/$namespace.json\"\n") + for _, namespace := range []string{"extensions", "objects", "kubeconfig-default"} { + secret := corev1.Secret{Data: map[string][]byte{ + "object": []byte(`{"apiVersion":"v1","kind":"ConfigMap","metadata":{"name":"from-` + namespace + `"}}`), + }} + data, err := json.Marshal(secret) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, namespace+".json"), data, 0o600)) + } + for _, tc := range []struct { + name string + namespace string + want string + }{ + {name: "omitted namespace", want: "extensions"}, + {name: "explicit namespace", namespace: "objects", want: "objects"}, + } { + t.Run(tc.name, func(t *testing.T) { + obj, err := ResolveSecretRef(ocv1.SecretSourceRef{Name: "manifest", Namespace: tc.namespace, Key: "object"}) + require.NoError(t, err) + require.Equal(t, "from-"+tc.want, obj.GetName()) + }) + } +} diff --git a/test/internal/e2e/steps/steps.go b/test/internal/e2e/steps/steps.go index 533c3a41a3..6653269de3 100644 --- a/test/internal/e2e/steps/steps.go +++ b/test/internal/e2e/steps/steps.go @@ -33,6 +33,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/component-base/featuregate" deploymentutil "k8s.io/kubectl/pkg/util/deployment" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/yaml" @@ -46,9 +47,21 @@ import ( ) const ( - olmDeploymentName = "operator-controller-controller-manager" - catalogdDeploymentName = "catalogd-controller-manager" - defaultTimeout = 5 * time.Minute + // OperatorControllerComponent identifies the ClusterExtension controller. + OperatorControllerComponent = "operator-controller" + // CatalogdComponent identifies the catalog controller. + CatalogdComponent = "catalogd" + // ObjectControllerComponent identifies the COS/COD controller. + ObjectControllerComponent = "object-controller" + // ObjectControllerFeature selects scenarios requiring object-controller. + ObjectControllerFeature featuregate.Feature = "ObjectController" + // OperatorControllerDeploymentName is the ClusterExtension controller Deployment. + OperatorControllerDeploymentName = "operator-controller-controller-manager" + // CatalogdDeploymentName is the catalog controller Deployment. + CatalogdDeploymentName = "catalogd-controller-manager" + // ObjectControllerDeploymentName is the independently deployable controller. + ObjectControllerDeploymentName = "object-controller-controller-manager" + defaultTimeout = 5 * time.Minute // Tick is the polling interval used by wait helpers. Tick = 1 * time.Second ) @@ -308,7 +321,7 @@ func ImageRegistryIsAvailable() error { // OLMisAvailable waits for the OLM operator-controller deployment to become available. Polls with timeout. func OLMisAvailable(ctx context.Context) error { require.Eventually(godog.T(ctx), func() bool { - v, err := K8sClient(ctx, "get", "deployment", "-n", OLMNamespace, olmDeploymentName, "-o", "json") + v, err := K8sClient(ctx, "get", "deployment", "-n", OLMNamespace, OperatorControllerDeploymentName, "-o", "json") if err != nil { return false } @@ -915,10 +928,11 @@ func ServiceAccountIsAvailableInNamespace(ctx context.Context, serviceAccount st } // ServiceAccountWithNeededPermissionsIsAvailableInTestNamespace creates a ServiceAccount and applies standard RBAC permissions. -// The RBAC template is selected based on the service account and BoxcutterRuntime feature gate: --rbac-template.yaml +// Direct object-controller scenarios select Boxcutter RBAC by tag; integrated +// scenarios use the BoxcutterRuntime gate, independently of feature filenames. func ServiceAccountWithNeededPermissionsIsAvailableInTestNamespace(ctx context.Context, serviceAccount string) error { kernel := "helm" - if enabled, found := FeatureGates[features.BoxcutterRuntime]; found && enabled { + if FeatureGates[features.BoxcutterRuntime] || Scenario(ctx).Tags.Has("@"+string(ObjectControllerFeature)) { kernel = "boxcutter" } rbacTemplate := fmt.Sprintf("%s-%s-rbac-template.yaml", serviceAccount, kernel) @@ -1474,6 +1488,9 @@ func GetResource(kind string, name string, namespace string) (*unstructured.Unst // ResolveSecretRef fetches an object from a Secret ref using kubectl. func ResolveSecretRef(ref ocv1.SecretSourceRef) (*unstructured.Unstructured, error) { + if ref.Namespace == "" { + ref.Namespace = OLMNamespace + } out, err := K8sClient(context.Background(), "get", "secret", ref.Name, "-n", ref.Namespace, "-o", "json") if err != nil { return nil, fmt.Errorf("getting Secret %s/%s: %w", ref.Namespace, ref.Name, err) diff --git a/test/internal/e2e/steps/upgrade_steps.go b/test/internal/e2e/steps/upgrade_steps.go index 0a6e4f08a0..7df5416382 100644 --- a/test/internal/e2e/steps/upgrade_steps.go +++ b/test/internal/e2e/steps/upgrade_steps.go @@ -28,10 +28,14 @@ var ( if err != nil { return err } - olm, _, err := detectOLMDeployments() + deployments, err := detectOLMDeployments() if err != nil { return err } + olm := deployments[OperatorControllerComponent] + if olm == nil { + return fmt.Errorf("operator-controller Deployment was not discovered after installation") + } OLMNamespace = olm.Namespace return nil }) @@ -123,8 +127,8 @@ func ComponentIsReadyToReconcile(ctx context.Context, component string) error { // resourceTypeToComponent maps resource type names to their controller component labels. var resourceTypeToComponent = map[string]string{ - "ClusterCatalog": "catalogd", - "ClusterExtension": "operator-controller", + "ClusterCatalog": CatalogdComponent, + "ClusterExtension": OperatorControllerComponent, } // reconcileEndingCheck returns a function that checks whether the leader pod's logs @@ -149,7 +153,7 @@ func reconcileEndingCheck(ctx context.Context, namespace, leaderPod, resourceNam func ClusterExtensionIsReconciled(ctx context.Context) error { sc := Scenario(ctx) - component := "operator-controller" + component := OperatorControllerComponent resourceName := sc.ClusterExtensionName if resourceName == "" { return fmt.Errorf("no ClusterExtension found in scenario context") @@ -245,7 +249,7 @@ func ScenarioCatalogIsReconciled(ctx context.Context, catalogUserName string) er return fmt.Errorf("no catalog %q has been created for this scenario", catalogUserName) } - component := "catalogd" + component := CatalogdComponent if err := ComponentIsReadyToReconcile(ctx, component); err != nil { return err }