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 }