From eb7969079704b7b055ead08c13cfb61e918e4e38 Mon Sep 17 00:00:00 2001 From: Fabricio Aguiar Date: Mon, 12 Oct 2026 15:55:50 +0100 Subject: [PATCH] test(object-controller): isolate direct ClusterObjectSet E2E scenarios Tag direct ClusterObjectSet scenarios with ObjectController and discover its Deployment independently of operator-controller. Provide a standalone mode that requires ClusterExtension and ClusterCatalog APIs to be absent. Keep ClusterObjectSet operations, assertions, and their registrations in the dedicated object-controller step file. Select service-account RBAC from scenario tags so renaming a feature file preserves its permissions. Resolve referenced Secrets by namespace and name, independently of the controllers' namespaces, defaulting omitted ref namespaces to the OLM system namespace in both lookup paths. Report missing object-controller Deployments explicitly and clear stale namespace entries during discovery. Cover tag selection, controller discovery, standalone readiness, and Secret assertions with omitted and explicit namespace references. Signed-off-by: Fabricio Aguiar rh-pre-commit.version: 2.3.2 rh-pre-commit.check-secrets: ENABLED --- test/e2e/README.md | 30 + test/e2e/features/revision.feature | 4 +- test/e2e/features_test.go | 1 + test/e2e/steps/demo_steps.go | 2 +- test/e2e/steps/hooks.go | 65 +- test/e2e/steps/object_controller_steps.go | 570 ++++++++++++++++++ .../e2e/steps/object_controller_steps_test.go | 291 +++++++++ test/e2e/steps/proxy_steps.go | 6 +- test/e2e/steps/steps.go | 499 +-------------- test/e2e/steps/upgrade_steps.go | 14 +- 10 files changed, 963 insertions(+), 519 deletions(-) create mode 100644 test/e2e/steps/object_controller_steps.go create mode 100644 test/e2e/steps/object_controller_steps_test.go diff --git a/test/e2e/README.md b/test/e2e/README.md index 60a7b79d71..03977fdf1c 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -2,6 +2,36 @@ This directory contains end-to-end (e2e) tests, written using the [Godog](https://github.com/cucumber/godog) framework. +### Independent object-controller suite + +`features/revision.feature` is the direct ClusterObjectSet suite, selected by +`@ObjectController`. Its API steps live in `steps/object_controller_steps.go` and +use shared resource helpers. ClusterExtension integration scenarios remain in +the other feature files. The existing experimental test run includes both groups; +`make e2e/revision` still selects the direct ClusterObjectSet scenarios. + +On a disposable cluster with only object-controller and the ClusterObjectSet CRD +installed, run the existing ten scenarios independently: + +```sh +cd test/e2e +KUBECONFIG=/path/to/standalone.kubeconfig \ + go test -tags containers_image_openpgp -count=1 -v . -timeout=20m \ + -args --e2e.object-controller-only --godog.tags=@ObjectController \ + --godog.concurrency=1 features/revision.feature +``` + +The standalone flag requires ClusterExtension and ClusterCatalog APIs to be +absent. Missing or unavailable object-controller fails readiness rather than +skipping the explicitly requested suite. A successful run executes all ten +scenarios with zero skipped or undefined scenarios. Run serially because some +scenarios change the progress-deadline CRD validation. + +In the general suite, standard installations skip this group. Experimental +Boxcutter installations run it, and fail if their required object-controller is +missing. Discovery uses the object-controller Deployment and its namespace; +standalone execution does not depend on an operator-controller feature gate. + ## Overview ### What is Godog/BDD/Cucumber? diff --git a/test/e2e/features/revision.feature b/test/e2e/features/revision.feature index 78cd131fb7..1e4f4269c6 100644 --- a/test/e2e/features/revision.feature +++ b/test/e2e/features/revision.feature @@ -1,10 +1,10 @@ -@BoxcutterRuntime +@ObjectController Feature: Install ClusterObjectSet As an OLM user I would like to install a ClusterObjectSet directly, without using the cluster extension API. Background: - Given OLM is available + Given object-controller is available Scenario: Probe failure for PersistentVolumeClaim halts phase progression Given ServiceAccount "pvc-probe-sa" with needed permissions is available in test namespace diff --git a/test/e2e/features_test.go b/test/e2e/features_test.go index 65eaebe255..1712d0bba0 100644 --- a/test/e2e/features_test.go +++ b/test/e2e/features_test.go @@ -139,6 +139,7 @@ func InitializeSuite(tc *godog.TestSuiteContext) { func InitializeScenario(sc *godog.ScenarioContext) { steps.RegisterSteps(sc) + steps.RegisterObjectControllerSteps(sc) steps.RegisterDemoSteps(sc) steps.RegisterHooks(sc) } diff --git a/test/e2e/steps/demo_steps.go b/test/e2e/steps/demo_steps.go index 80346bff88..44a0596369 100644 --- a/test/e2e/steps/demo_steps.go +++ b/test/e2e/steps/demo_steps.go @@ -93,7 +93,7 @@ func ensureCatalogPortForward(ctx context.Context) (string, error) { resetCatalogPortForward(ctx) } - ns := componentNamespaces["catalogd"] + ns := componentNamespaces[catalogdComponent] target, err := catalogdLeaderPod(ctx, ns) port := int32(443) if err != nil { diff --git a/test/e2e/steps/hooks.go b/test/e2e/steps/hooks.go index ee93303408..1f53e696fc 100644 --- a/test/e2e/steps/hooks.go +++ b/test/e2e/steps/hooks.go @@ -17,6 +17,7 @@ import ( "golang.org/x/sync/errgroup" appsv1 "k8s.io/api/apps/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/util/sets" "k8s.io/component-base/featuregate" "k8s.io/klog/v2/textlogger" "sigs.k8s.io/controller-runtime/pkg/client" @@ -44,6 +45,7 @@ type deploymentRestore struct { type scenarioContext struct { id string featureName string + tags sets.Set[string] scenarioName string namespace string clusterExtensionName string @@ -87,14 +89,16 @@ const ( ) var ( - devMode = false - featureGates = map[featuregate.Feature]bool{ + devMode = false + objectControllerOnly bool + featureGates = map[featuregate.Feature]bool{ features.WebhookProviderCertManager: true, features.SingleOwnNamespaceInstallSupport: false, features.WebhookProviderOpenshiftServiceCA: false, features.BoxcutterRuntime: false, features.DeploymentConfig: false, catalogdHAFeature: false, + objectControllerFeature: false, } logger logr.Logger ) @@ -102,6 +106,7 @@ 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 with no ClusterExtension or ClusterCatalog APIs for direct ClusterObjectSet scenarios") } func RegisterHooks(sc *godog.ScenarioContext) { @@ -112,33 +117,30 @@ func RegisterHooks(sc *godog.ScenarioContext) { 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 finds installed components 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] + deployments[operatorControllerComponent] = &dl[i] case catalogdDeploymentName: - catalogd = &dl[i] + 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() { @@ -156,20 +158,36 @@ func BeforeSuite() { featureGates[catalogdHAFeature] = 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 } + configureControllerFeatures(deployments) +} + +func configureControllerFeatures(deployments map[string]*appsv1.Deployment) { + objectController := deployments[objectControllerComponent] + featureGates[objectControllerFeature] = objectController != nil + if objectController != nil { + componentNamespaces[objectControllerComponent] = objectController.Namespace + } else { + delete(componentNamespaces, objectControllerComponent) + } + olm, catalogdDep := deployments[operatorControllerComponent], deployments[catalogdComponent] + if olm == nil { + logger.Info("operator-controller is absent; using independently detected components") + return + } olmNamespace = olm.Namespace - componentNamespaces["operator-controller"] = olmNamespace + componentNamespaces[operatorControllerComponent] = olmNamespace // Catalogd may be in a different namespace than operator-controller. catalogdNS := olmNamespace if catalogdDep != nil { catalogdNS = catalogdDep.Namespace } - componentNamespaces["catalogd"] = catalogdNS + componentNamespaces[catalogdComponent] = 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 @@ -203,6 +221,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 { + // An explicitly requested standalone suite, or an integrated Boxcutter + // installation, must fail readiness if its controller is missing. + 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 } @@ -214,6 +237,7 @@ func CreateScenarioContext(ctx context.Context, sc *godog.Scenario) (context.Con scCtx := &scenarioContext{ id: sc.Id, featureName: strings.TrimSuffix(filepath.Base(sc.Uri), filepath.Ext(sc.Uri)), + tags: sets.New[string](), scenarioName: sc.Name, namespace: fmt.Sprintf("ns-%s", sc.Id), clusterExtensionName: fmt.Sprintf("ce-%s", sc.Id), @@ -223,6 +247,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/e2e/steps/object_controller_steps.go b/test/e2e/steps/object_controller_steps.go new file mode 100644 index 0000000000..75ebffaabf --- /dev/null +++ b/test/e2e/steps/object_controller_steps.go @@ -0,0 +1,570 @@ +package steps + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/cucumber/godog" + corev1 "k8s.io/api/core/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/utils/ptr" + + ocv1 "github.com/operator-framework/operator-controller/api/v1" +) + +const ( + objectControllerFeature featuregate.Feature = "ObjectController" + objectControllerDeploymentName string = "object-controller-controller-manager" +) + +// ObjectControllerIsAvailable verifies the independently deployed controller. +// Explicit standalone runs also reject ClusterExtension and ClusterCatalog APIs. +func ObjectControllerIsAvailable(ctx context.Context) error { + namespace, found := componentNamespaces[objectControllerComponent] + if !found || namespace == "" { + return fmt.Errorf("object-controller is not installed: Deployment %q was not discovered", objectControllerDeploymentName) + } + if objectControllerOnly { + out, err := 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)) + } + } + if _, err := k8sClient(ctx, "get", "crd", "clusterobjectsets.olm.operatorframework.io"); err != nil { + return fmt.Errorf("ClusterObjectSet API is unavailable: %w", err) + } + _, err := k8sClient(ctx, "rollout", "status", "deployment/"+objectControllerDeploymentName, + "-n", namespace, "--timeout="+timeout.String()) + if err != nil { + return fmt.Errorf("object-controller is unavailable: %w", err) + } + return nil +} + +// RegisterObjectControllerSteps registers ClusterObjectSet API operations and assertions. +// ClusterExtension scenarios can reuse these assertions without defining the +// standalone suite's availability or feature-gate requirements. +func RegisterObjectControllerSteps(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) + sc.Step(`^(?i)ClusterObjectSet "([^"]+)" reports ([[:alnum:]]+) as ([[:alnum:]]+) with Reason ([[:alnum:]]+) and Message includes:$`, ClusterObjectSetReportsConditionWithMessageFragment) + sc.Step(`^(?i)ClusterObjectSet is applied(?:\s+.*)?$`, ResourceIsApplied) + sc.Step(`^(?i)ClusterObjectSet "([^"]+)" reconciliation is triggered$`, TriggerClusterObjectSetReconciliation) + sc.Step(`^(?i)ClusterObjectSet "([^"]+)" has observed phase "([^"]+)" with a non-empty digest$`, ClusterObjectSetHasObservedPhase) + sc.Step(`^(?i)ClusterObjectSet "([^"]+)" is archived$`, ClusterObjectSetIsArchived) + sc.Step(`^(?i)ClusterObjectSet "([^"]+)" reports phase "([^"]+)" object counts: total=(\d+), present=(\d+), synced=(\d+), ready=(\d+)$`, ClusterObjectSetPhaseHasObjectCounts) + sc.Step(`^(?i)ClusterObjectSet "([^"]+)" reports aggregate object counts: total=(\d+), present=(\d+), synced=(\d+), ready=(\d+)$`, ClusterObjectSetHasAggregateObjectCounts) + sc.Step(`^(?i)ClusterObjectSet "([^"]+)" contains annotation "([^"]+)" with value$`, ClusterObjectSetHasAnnotationWithValue) + sc.Step(`^(?i)ClusterObjectSet "([^"]+)" has label "([^"]+)" with value "([^"]+)"$`, ClusterObjectSetHasLabelWithValue) + sc.Step(`^(?i)ClusterObjectSet "([^"]+)" phase objects are not found or not owned by the revision$`, ClusterObjectSetObjectsNotFoundOrNotOwned) + sc.Step(`^(?i)ClusterObjectSet "([^"]+)" phase objects are managed in Kubernetes secrets$`, ClusterObjectSetPhaseObjectsManagedInSecrets) + sc.Step(`^(?i)ClusterObjectSet "([^"]+)" referred secrets exist in "([^"]+)" namespace$`, ClusterObjectSetReferredSecretsExist) + sc.Step(`^(?i)ClusterObjectSet "([^"]+)" referred secrets are immutable$`, ClusterObjectSetReferredSecretsAreImmutable) + sc.Step(`^(?i)ClusterObjectSet "([^"]+)" referred secrets contain labels$`, ClusterObjectSetReferredSecretsContainLabels) + sc.Step(`^(?i)ClusterObjectSet "([^"]+)" referred secrets are owned by the object set$`, ClusterObjectSetReferredSecretsOwnedByObjectSet) + sc.Step(`^(?i)ClusterObjectSet "([^"]+)" referred secrets have type "([^"]+)"$`, ClusterObjectSetReferredSecretsHaveType) +} + +// ClusterObjectSetLifecycleUpdate patches the ClusterObjectSet's lifecycleState to the specified value. +func ClusterObjectSetLifecycleUpdate(ctx context.Context, cosName, lifecycle string) error { + sc := scenarioCtx(ctx) + cosName = substituteScenarioVars(cosName, sc) + patch := map[string]any{ + "spec": map[string]any{ + "lifecycleState": lifecycle, + }, + } + pb, err := json.Marshal(patch) + if err != nil { + return err + } + _, err = k8sClient(ctx, "patch", "clusterobjectset", cosName, "--type", "merge", "-p", string(pb)) + return err +} + +// ClusterObjectSetReportsConditionWithoutMsg waits for the named ClusterObjectSet to have a condition +// matching type, status, and reason. Polls with timeout. +func ClusterObjectSetReportsConditionWithoutMsg(ctx context.Context, revisionName, conditionType, conditionStatus, conditionReason string) error { + return waitForCondition(ctx, "clusterobjectset", substituteScenarioVars(revisionName, scenarioCtx(ctx)), conditionType, conditionStatus, &conditionReason, nil) +} + +// ClusterObjectSetReportsConditionWithMsg waits for the named ClusterObjectSet to have a condition +// matching type, status, reason, and message. Polls with timeout. +func ClusterObjectSetReportsConditionWithMsg(ctx context.Context, revisionName, conditionType, conditionStatus, conditionReason string, msg *godog.DocString) error { + return waitForCondition(ctx, "clusterobjectset", substituteScenarioVars(revisionName, scenarioCtx(ctx)), conditionType, conditionStatus, &conditionReason, messageComparison(ctx, msg)) +} + +// ClusterObjectSetReportsConditionWithMessageFragment waits for the named ClusterObjectSet to have a condition +// matching type, status, reason, with a message containing the specified fragment. Polls with timeout. +func ClusterObjectSetReportsConditionWithMessageFragment(ctx context.Context, revisionName, conditionType, conditionStatus, conditionReason string, msgFragment *godog.DocString) error { + return waitForCondition(ctx, "clusterobjectset", substituteScenarioVars(revisionName, scenarioCtx(ctx)), conditionType, conditionStatus, &conditionReason, messageFragmentComparison(ctx, msgFragment)) +} + +// TriggerClusterObjectSetReconciliation annotates the named ClusterObjectSet +// to trigger a new reconciliation cycle. +func TriggerClusterObjectSetReconciliation(ctx context.Context, cosName string) error { + sc := scenarioCtx(ctx) + cosName = substituteScenarioVars(cosName, sc) + _, err := k8sClient(ctx, "annotate", "clusterobjectset", cosName, "--overwrite", + fmt.Sprintf("e2e-trigger=%d", time.Now().UnixNano())) + return err +} + +// ClusterObjectSetHasObservedPhase waits for the named ClusterObjectSet to have +// an observedPhases entry matching the given phase name with a non-empty digest. Polls with timeout. +func ClusterObjectSetHasObservedPhase(ctx context.Context, cosName, phaseName string) error { + sc := scenarioCtx(ctx) + cosName = substituteScenarioVars(cosName, sc) + phaseName = substituteScenarioVars(phaseName, sc) + + waitFor(ctx, func() bool { + out, err := k8sClient(ctx, "get", "clusterobjectset", cosName, "-o", + fmt.Sprintf(`jsonpath={.status.observedPhases[?(@.name=="%s")].digest}`, phaseName)) + if err != nil { + return false + } + return strings.TrimSpace(out) != "" + }) + return nil +} + +// ClusterObjectSetIsArchived waits for the named ClusterObjectSet to have Ready=False +// with reason Archived. Polls with timeout. +func ClusterObjectSetIsArchived(ctx context.Context, revisionName string) error { + return waitForCondition(ctx, "clusterobjectset", substituteScenarioVars(revisionName, scenarioCtx(ctx)), "Ready", "False", ptr.To("Archived"), nil) +} + +// ClusterObjectSetPhaseHasObjectCounts waits for a specific phase of the named ClusterObjectSet to +// report exact object counts for total, present, synced, and available. Polls with timeout. +func ClusterObjectSetPhaseHasObjectCounts(ctx context.Context, cosName, phaseName string, total, present, synced, ready int64) error { + sc := scenarioCtx(ctx) + cosName = substituteScenarioVars(cosName, sc) + phaseName = substituteScenarioVars(phaseName, sc) + waitFor(ctx, func() bool { + out, err := k8sClient(ctx, "get", "clusterobjectset", cosName, "-o", "json") + if err != nil { + return false + } + var cos ocv1.ClusterObjectSet + if err := json.Unmarshal([]byte(out), &cos); err != nil { + return false + } + for _, phase := range cos.Status.ObservedPhases { + if phase.Name == phaseName { + return phase.ObjectCounts.Total == total && + phase.ObjectCounts.Present == present && + phase.ObjectCounts.Synced == synced && + phase.ObjectCounts.Ready == ready + } + } + return false + }) + return nil +} + +// ClusterObjectSetHasAggregateObjectCounts waits for the named ClusterObjectSet to report exact +// aggregate object counts (summed across all phases). Polls with timeout. +func ClusterObjectSetHasAggregateObjectCounts(ctx context.Context, cosName string, total, present, synced, ready int64) error { + sc := scenarioCtx(ctx) + cosName = substituteScenarioVars(cosName, sc) + waitFor(ctx, func() bool { + out, err := k8sClient(ctx, "get", "clusterobjectset", cosName, "-o", "json") + if err != nil { + return false + } + var cos ocv1.ClusterObjectSet + if err := json.Unmarshal([]byte(out), &cos); err != nil { + return false + } + return cos.Status.ObjectCounts.Total == total && + cos.Status.ObjectCounts.Present == present && + cos.Status.ObjectCounts.Synced == synced && + cos.Status.ObjectCounts.Ready == ready + }) + return nil +} + +// ClusterObjectSetHasAnnotationWithValue waits for the named ClusterObjectSet to have the specified +// annotation with the expected value. Polls with timeout. +func ClusterObjectSetHasAnnotationWithValue(ctx context.Context, revisionName, annotationKey string, annotationValue *godog.DocString) error { + sc := scenarioCtx(ctx) + revisionName = substituteScenarioVars(strings.TrimSpace(revisionName), sc) + expectedValue := "" + if annotationValue != nil { + expectedValue = annotationValue.Content + } + waitFor(ctx, func() bool { + obj, err := getResource("clusterobjectset", revisionName, "") + if err != nil { + logger.V(1).Error(err, "failed to get clusterobjectset", "name", revisionName) + return false + } + if obj.GetAnnotations() == nil { + return false + } + return obj.GetAnnotations()[annotationKey] == expectedValue + }) + return nil +} + +// ClusterObjectSetHasLabelWithValue waits for the named ClusterObjectSet to have the specified label +// with the expected value. Polls with timeout. +func ClusterObjectSetHasLabelWithValue(ctx context.Context, revisionName, labelKey, labelValue string) error { + sc := scenarioCtx(ctx) + revisionName = substituteScenarioVars(strings.TrimSpace(revisionName), sc) + labelValue = substituteScenarioVars(labelValue, sc) + waitFor(ctx, func() bool { + obj, err := getResource("clusterobjectset", revisionName, "") + if err != nil { + logger.V(1).Error(err, "failed to get clusterobjectset", "name", revisionName) + return false + } + if obj.GetLabels() == nil { + return false + } + return obj.GetLabels()[labelKey] == labelValue + }) + return nil +} + +// ClusterObjectSetObjectsNotFoundOrNotOwned waits for all objects described in the named +// ClusterObjectSet's phases to either not exist on the cluster or not contain the revision +// in their ownerReferences. Polls with timeout. +func ClusterObjectSetObjectsNotFoundOrNotOwned(ctx context.Context, revisionName string) error { + sc := scenarioCtx(ctx) + revisionName = substituteScenarioVars(strings.TrimSpace(revisionName), sc) + + // Get the ClusterObjectSet to extract its phase objects + var rev ocv1.ClusterObjectSet + waitFor(ctx, func() bool { + out, err := k8sClient(ctx, "get", "clusterobjectset", revisionName, "-o", "json") + if err != nil { + return false + } + return json.Unmarshal([]byte(out), &rev) == nil + }) + + // For each object in each phase, verify it either doesn't exist or + // doesn't have the ClusterObjectSet in its ownerReferences + for i, phase := range rev.Spec.Phases { + for j, phaseObj := range phase.Objects { + var obj *unstructured.Unstructured + switch { + case phaseObj.Ref.Name != "": + resolved, err := resolveObjectRef(phaseObj.Ref) + if err != nil { + return fmt.Errorf("resolving ref in phase %q object %d: %w", phase.Name, j, err) + } + obj = resolved + case len(phaseObj.Object.Object) > 0: + obj = &phaseObj.Object + default: + return fmt.Errorf("clusterobjectset %q phase %d object %d has neither ref nor inline object", revisionName, i, j) + } + kind := obj.GetKind() + name := obj.GetName() + namespace := obj.GetNamespace() + + if kind == "" { + return fmt.Errorf("clusterobjectset %q has a phase object with empty kind", revisionName) + } + if name == "" { + return fmt.Errorf("clusterobjectset %q has a phase object with empty name (kind %q, namespace %q)", revisionName, kind, namespace) + } + + waitFor(ctx, func() bool { + args := []string{"get", kind, name, "--ignore-not-found", "-o", "json"} + if namespace != "" { + args = append(args, "-n", namespace) + } + out, err := k8sClient(ctx, args...) + if err != nil { + return false + } + // If output is empty, the resource does not exist — condition satisfied + if strings.TrimSpace(out) == "" { + return true + } + clusterObj, err := toUnstructured(out) + if err != nil { + return false + } + + // Check that no ownerReference points to this ClusterObjectSet + for _, ref := range clusterObj.GetOwnerReferences() { + if ref.Kind == ocv1.ClusterObjectSetKind && ref.Name == revisionName && ref.UID == rev.UID { + logger.V(1).Info("object still owned by revision", + "kind", kind, "name", name, "namespace", namespace, + "revision", revisionName) + return false + } + } + return true + }) + } + } + return nil +} + +// ClusterObjectSetPhaseObjectsManagedInSecrets verifies that every object in every phase of the named +// ClusterObjectSet uses a ref (not an inline object). Polls with timeout. +func ClusterObjectSetPhaseObjectsManagedInSecrets(ctx context.Context, revisionName string) error { + sc := scenarioCtx(ctx) + revisionName = substituteScenarioVars(strings.TrimSpace(revisionName), sc) + + waitFor(ctx, func() bool { + obj, err := getResource("clusterobjectset", revisionName, "") + if err != nil { + return false + } + phases, ok, _ := unstructured.NestedSlice(obj.Object, "spec", "phases") + if !ok || len(phases) == 0 { + return false + } + for _, p := range phases { + phase, ok := p.(map[string]interface{}) + if !ok { + return false + } + objects, ok, _ := unstructured.NestedSlice(phase, "objects") + if !ok || len(objects) == 0 { + return false + } + for _, o := range objects { + obj, ok := o.(map[string]interface{}) + if !ok { + return false + } + ref, refOK, _ := unstructured.NestedMap(obj, "ref") + if !refOK || len(ref) == 0 { + logger.V(1).Info("object does not use ref", "revision", revisionName) + return false + } + name, _, _ := unstructured.NestedString(ref, "name") + if name == "" { + logger.V(1).Info("ref has empty name", "revision", revisionName) + return false + } + } + } + return true + }) + return nil +} + +// ClusterObjectSetReferredSecretsExist verifies that all Secrets referenced by the named +// ClusterObjectSet's phase objects exist in the given namespace. Polls with timeout. +func ClusterObjectSetReferredSecretsExist(ctx context.Context, revisionName, namespace string) error { + sc := scenarioCtx(ctx) + revisionName = substituteScenarioVars(strings.TrimSpace(revisionName), sc) + namespace = substituteScenarioVars(strings.TrimSpace(namespace), sc) + + secretNames, err := collectReferredSecretNames(ctx, revisionName) + if err != nil { + return err + } + + for _, name := range secretNames { + waitFor(ctx, func() bool { + _, err := getResource("secret", name, namespace) + return err == nil + }) + } + return nil +} + +// ClusterObjectSetReferredSecretsAreImmutable verifies that all referred Secrets for the named +// ClusterObjectSet are immutable. Polls with timeout. +func ClusterObjectSetReferredSecretsAreImmutable(ctx context.Context, revisionName string) error { + sc := scenarioCtx(ctx) + revisionName = substituteScenarioVars(strings.TrimSpace(revisionName), sc) + + secrets, err := listReferredSecrets(ctx, revisionName) + if err != nil { + return err + } + if len(secrets) == 0 { + return fmt.Errorf("no referred secrets found for revision %q", revisionName) + } + for _, s := range secrets { + if s.Immutable == nil || !*s.Immutable { + return fmt.Errorf("referred secret %s/%s is not immutable", s.Namespace, s.Name) + } + } + return nil +} + +// ClusterObjectSetReferredSecretsContainLabels verifies that all referred Secrets for the named +// ClusterObjectSet have the expected labels specified in the data table. Polls with timeout. +func ClusterObjectSetReferredSecretsContainLabels(ctx context.Context, revisionName string, table *godog.Table) error { + sc := scenarioCtx(ctx) + revisionName = substituteScenarioVars(strings.TrimSpace(revisionName), sc) + + expected, err := parseKeyValueTable(table, sc) + if err != nil { + return fmt.Errorf("invalid labels table: %w", err) + } + + waitFor(ctx, func() bool { + secrets, err := listReferredSecrets(ctx, revisionName) + if err != nil || len(secrets) == 0 { + return false + } + for _, s := range secrets { + if _, _, ok := matchLabels(s.Labels, expected); !ok { + return false + } + } + return true + }) + return nil +} + +// ClusterObjectSetReferredSecretsOwnedByObjectSet verifies that all referred Secrets for the named +// ClusterObjectSet have an ownerReference pointing to the ClusterObjectSet with controller=true. +func ClusterObjectSetReferredSecretsOwnedByObjectSet(ctx context.Context, revisionName string) error { + sc := scenarioCtx(ctx) + revisionName = substituteScenarioVars(strings.TrimSpace(revisionName), sc) + + cosObj, err := getResource("clusterobjectset", revisionName, "") + if err != nil { + return fmt.Errorf("getting ClusterObjectSet %q: %w", revisionName, err) + } + cosUID := cosObj.GetUID() + + secrets, err := listReferredSecrets(ctx, revisionName) + if err != nil { + return err + } + if len(secrets) == 0 { + return fmt.Errorf("no referred secrets found for revision %q", revisionName) + } + + for _, s := range secrets { + found := false + for _, ref := range s.OwnerReferences { + if ref.Kind == ocv1.ClusterObjectSetKind && ref.Name == revisionName && ref.UID == cosUID { + if ref.Controller == nil || !*ref.Controller { + return fmt.Errorf("secret %s/%s has ownerReference to ClusterObjectSet but controller is not true", s.Namespace, s.Name) + } + found = true + break + } + } + if !found { + return fmt.Errorf("secret %s/%s does not have ownerReference to ClusterObjectSet %q (uid %s)", s.Namespace, s.Name, revisionName, cosUID) + } + } + return nil +} + +// ClusterObjectSetReferredSecretsHaveType verifies that all referred Secrets for the named +// ClusterObjectSet have the specified Secret type. +func ClusterObjectSetReferredSecretsHaveType(ctx context.Context, revisionName, expectedType string) error { + sc := scenarioCtx(ctx) + revisionName = substituteScenarioVars(strings.TrimSpace(revisionName), sc) + + secrets, err := listReferredSecrets(ctx, revisionName) + if err != nil { + return err + } + if len(secrets) == 0 { + return fmt.Errorf("no referred secrets found for revision %q", revisionName) + } + + for _, s := range secrets { + if string(s.Type) != expectedType { + return fmt.Errorf("secret %s/%s has type %q, expected %q", s.Namespace, s.Name, s.Type, expectedType) + } + } + return nil +} + +// collectReferredSecretNames returns the unique set of Secret names referenced by the ClusterObjectSet's phase objects. +func collectReferredSecretNames(ctx context.Context, revisionName string) ([]string, error) { + var names []string + seen := sets.New[string]() + + var obj *unstructured.Unstructured + waitFor(ctx, func() bool { + var err error + obj, err = getResource("clusterobjectset", revisionName, "") + return err == nil + }) + + phases, _, _ := unstructured.NestedSlice(obj.Object, "spec", "phases") + for _, p := range phases { + phase, ok := p.(map[string]interface{}) + if !ok { + continue + } + objects, _, _ := unstructured.NestedSlice(phase, "objects") + for _, o := range objects { + phaseObj, ok := o.(map[string]interface{}) + if !ok { + continue + } + name, _, _ := unstructured.NestedString(phaseObj, "ref", "name") + if name != "" && !seen.Has(name) { + seen.Insert(name) + names = append(names, name) + } + } + } + if len(names) == 0 { + return nil, fmt.Errorf("no referred secret names found in ClusterObjectSet %q", revisionName) + } + return names, nil +} + +// listReferredSecrets fetches each unique Secret referenced by the named +// ClusterObjectSet's phase objects, defaulting an omitted ref namespace to the OLM namespace. +func listReferredSecrets(ctx context.Context, revisionName string) ([]corev1.Secret, error) { + out, err := k8sClient(ctx, "get", "clusterobjectset", revisionName, "-o", "json") + if err != nil { + return nil, fmt.Errorf("getting ClusterObjectSet %q: %w", revisionName, err) + } + var revision ocv1.ClusterObjectSet + if err := json.Unmarshal([]byte(out), &revision); err != nil { + return nil, fmt.Errorf("unmarshalling ClusterObjectSet %q: %w", revisionName, err) + } + var secrets []corev1.Secret + seen := sets.New[types.NamespacedName]() + for _, phase := range revision.Spec.Phases { + for _, object := range phase.Objects { + ref := object.Ref + if ref.Name == "" { + continue + } + if ref.Namespace == "" { + ref.Namespace = olmNamespace + } + key := types.NamespacedName{Name: ref.Name, Namespace: ref.Namespace} + if seen.Has(key) { + continue + } + seen.Insert(key) + out, err := k8sClient(ctx, "get", "secret", ref.Name, "-n", ref.Namespace, "-o", "json") + if err != nil { + return nil, fmt.Errorf("getting referred Secret %s: %w", key, err) + } + var secret corev1.Secret + if err := json.Unmarshal([]byte(out), &secret); err != nil { + return nil, fmt.Errorf("unmarshalling referred Secret %s: %w", key, err) + } + secrets = append(secrets, secret) + } + } + return secrets, nil +} diff --git a/test/e2e/steps/object_controller_steps_test.go b/test/e2e/steps/object_controller_steps_test.go new file mode 100644 index 0000000000..85164754f5 --- /dev/null +++ b/test/e2e/steps/object_controller_steps_test.go @@ -0,0 +1,291 @@ +package steps + +import ( + "context" + "encoding/json" + "io" + "maps" + "os" + "path/filepath" + "strings" + "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/utils/ptr" + + ocv1 "github.com/operator-framework/operator-controller/api/v1" + "github.com/operator-framework/operator-controller/internal/operator-controller/features" +) + +func TestClusterObjectSetSecretAssertionsUseReferences(t *testing.T) { + originalCLI, originalNamespace := k8sCli, olmNamespace + originalNamespaces := componentNamespaces + t.Cleanup(func() { + k8sCli, olmNamespace = originalCLI, originalNamespace + componentNamespaces = originalNamespaces + }) + olmNamespace = "extensions" + componentNamespaces = map[string]string{objectControllerComponent: "objects"} + fixtureDir := t.TempDir() + t.Setenv("E2E_SECRET_FIXTURES", fixtureDir) + k8sCli = filepath.Join(fixtureDir, "read-fixture") + 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-$3.json" ;; + *) printf 'unexpected command: %s\n' "$*" >&2; exit 1 ;; +esac +` + //nolint:gosec // The private test CLI fixture must be executable. + require.NoError(t, os.WriteFile(k8sCli, []byte(script), 0o700)) + namespaces := []string{"objects", "extensions", "referenced-content"} + revision := ocv1.ClusterObjectSet{ObjectMeta: metav1.ObjectMeta{Name: "revision", UID: "revision-uid"}} + for _, namespace := range namespaces { + revision.Spec.Phases = append(revision.Spec.Phases, ocv1.ClusterObjectSetPhase{ + Name: namespace, + Objects: []ocv1.ClusterObjectSetObject{ + {Ref: ocv1.ObjectSourceRef{Name: "manifest", Namespace: namespace, Key: "first"}}, + {Ref: ocv1.ObjectSourceRef{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(fixtureDir, namespace+"-manifest.json"), data, 0o600)) + } + revision.Spec.Phases = append(revision.Spec.Phases, ocv1.ClusterObjectSetPhase{ + Name: "implicit-namespace", + Objects: []ocv1.ClusterObjectSetObject{ + {Ref: ocv1.ObjectSourceRef{Name: "manifest", Key: "implicit"}}, + }, + }) + data, err := json.Marshal(revision) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(fixtureDir, "revision.json"), data, 0o600)) + suite := godog.TestSuite{ + ScenarioInitializer: func(sc *godog.ScenarioContext) { + sc.Before(CreateScenarioContext) + RegisterObjectControllerSteps(sc) + }, + Options: &godog.Options{ + Format: "progress", Output: io.Discard, TestingT: t, Strict: true, + FeatureContents: []godog.Feature{{Name: "secret_refs.feature", Contents: []byte(`Feature: Referenced Secrets + Scenario: Secrets can be stored independently of controllers + 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(fixtureDir, "commands")) + require.NoError(t, err) + for _, namespace := range namespaces { + // Each assertion must fetch a Secret once, even when two objects reference its data. + require.Equal(t, 4, strings.Count(string(commands), "get secret manifest -n "+namespace+" -o json\n")) + } +} + +func TestResolveObjectRefNamespaceDefault(t *testing.T) { + originalCLI, originalNamespace := k8sCli, olmNamespace + t.Cleanup(func() { + k8sCli, olmNamespace = originalCLI, originalNamespace + }) + olmNamespace = "extensions" + fixtureDir := t.TempDir() + t.Setenv("E2E_REF_FIXTURES", fixtureDir) + k8sCli = filepath.Join(fixtureDir, "read-ref") + script := "#!/bin/sh\nnamespace=${5:-kubeconfig-default}\ncat \"$E2E_REF_FIXTURES/$namespace.json\"\n" + //nolint:gosec // The private test CLI fixture must be executable. + require.NoError(t, os.WriteFile(k8sCli, []byte(script), 0o700)) + for _, namespace := range []string{"extensions", "objects", "kubeconfig-default"} { + manifest := []byte(`{"apiVersion":"v1","kind":"ConfigMap","metadata":{"name":"from-` + namespace + `"}}`) + secret := corev1.Secret{Data: map[string][]byte{"object": manifest}} + data, err := json.Marshal(secret) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(fixtureDir, 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) { + object, err := resolveObjectRef(ocv1.ObjectSourceRef{Name: "manifest", Namespace: tc.namespace, Key: "object"}) + require.NoError(t, err) + require.Equal(t, "from-"+tc.want, object.GetName()) + }) + } +} + +func TestObjectControllerReadinessUsesDiscoveredNamespace(t *testing.T) { + originalGates, originalNamespaces := featureGates, componentNamespaces + originalNamespace, originalOnly, originalCLI := olmNamespace, objectControllerOnly, k8sCli + t.Cleanup(func() { + featureGates, componentNamespaces = originalGates, originalNamespaces + olmNamespace, objectControllerOnly, k8sCli = originalNamespace, originalOnly, originalCLI + }) + k8sCli = filepath.Join(t.TempDir(), "capture-command") + //nolint:gosec // The private test CLI fixture must be executable. + require.NoError(t, os.WriteFile(k8sCli, []byte("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$E2E_READINESS_COMMANDS\"\n"), 0o700)) + for _, tc := range []struct { + name string + present bool + standalone bool + }{ + {name: "missing standalone deployment", standalone: true}, + {name: "missing integrated deployment"}, + {name: "standalone namespace", present: true, standalone: true}, + {name: "integrated namespace", present: true}, + } { + t.Run(tc.name, func(t *testing.T) { + featureGates = maps.Clone(originalGates) + // A namespace from an earlier discovery must be replaced or cleared. + componentNamespaces = map[string]string{objectControllerComponent: "previous-installation"} + olmNamespace, objectControllerOnly = "extensions", tc.standalone + deployments := map[string]*appsv1.Deployment{} + if tc.present { + deployments[objectControllerComponent] = &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Namespace: "objects"}} + } + configureControllerFeatures(deployments) + commandsPath := filepath.Join(t.TempDir(), "commands") + t.Setenv("E2E_READINESS_COMMANDS", commandsPath) + err := ObjectControllerIsAvailable(t.Context()) + if !tc.present { + require.ErrorContains(t, err, "object-controller is not installed") + require.ErrorContains(t, err, objectControllerDeploymentName) + require.NoFileExists(t, commandsPath) + return + } + require.NoError(t, err) + commands, err := os.ReadFile(commandsPath) + require.NoError(t, err) + require.Contains(t, string(commands), "rollout status deployment/"+objectControllerDeploymentName+" -n objects --timeout=") + }) + } +} + +func TestServiceAccountPermissionsUseScenarioTags(t *testing.T) { + originalGates, originalCLI := featureGates, k8sCli + t.Cleanup(func() { + featureGates, k8sCli = originalGates, originalCLI + }) + k8sCli = filepath.Join(t.TempDir(), "capture-manifest") + //nolint:gosec // The private test CLI fixture must be executable. + require.NoError(t, os.WriteFile(k8sCli, []byte("#!/bin/sh\ncat > \"$E2E_RBAC_MANIFEST\"\n"), 0o700)) + + for _, tc := range []struct { + name string + featureFile string + tag string + boxcutter bool + wantHelm bool + }{ + {name: "renamed direct feature", featureFile: "renamed.feature", tag: "@ObjectController"}, + {name: "feature name alone does not select boxcutter", featureFile: "revision.feature", wantHelm: true}, + {name: "unrelated tag does not select boxcutter", featureFile: "renamed.feature", tag: "@ObjectControllerMetrics", wantHelm: true}, + {name: "integrated boxcutter runtime", featureFile: "install.feature", boxcutter: true}, + } { + t.Run(tc.name, func(t *testing.T) { + featureGates = maps.Clone(originalGates) + featureGates[features.BoxcutterRuntime] = tc.boxcutter + manifestPath := filepath.Join(t.TempDir(), "manifest.yaml") + t.Setenv("E2E_RBAC_MANIFEST", manifestPath) + scenario := &godog.Scenario{Id: "permissions", Uri: tc.featureFile} + if tc.tag != "" { + require.NoError(t, json.Unmarshal([]byte(`{"tags":[{"name":"`+tc.tag+`"}]}`), scenario)) + } + ctx, err := CreateScenarioContext(t.Context(), scenario) + require.NoError(t, err) + err = ServiceAccountWithNeededPermissionsIsAvailableInTestNamespace(ctx, "pvc-probe-sa") + if tc.wantHelm { + // This account only has a Boxcutter template, so selecting Helm must fail. + require.ErrorContains(t, err, "pvc-probe-sa-helm-rbac-template.yaml") + return + } + require.NoError(t, err) + manifest, err := os.ReadFile(manifestPath) + require.NoError(t, err) + require.Contains(t, string(manifest), "resources: [clusterobjectsets/finalizers]") + require.Contains(t, string(manifest), "name: ns-permissions-pvc-probe-sa-clusterrole") + }) + } +} + +func TestObjectControllerScenarioSelection(t *testing.T) { + originalGates, originalNamespaces := featureGates, componentNamespaces + originalNamespace, originalOnly := olmNamespace, objectControllerOnly + t.Cleanup(func() { + featureGates, componentNamespaces = originalGates, originalNamespaces + olmNamespace, objectControllerOnly = originalNamespace, originalOnly + }) + for _, tc := range []struct { + name string + objectPresent bool + operator bool + boxcutter bool + standalone bool + wantSkip bool + }{ + {name: "standalone", objectPresent: true, standalone: true}, + {name: "standard", operator: true, wantSkip: true}, + {name: "integrated", objectPresent: true, operator: true, boxcutter: true}, + {name: "unready deployment must run readiness check", objectPresent: true}, + {name: "missing integrated controller must fail readiness", operator: true, boxcutter: true}, + {name: "missing standalone controller must fail readiness", standalone: true}, + } { + t.Run(tc.name, func(t *testing.T) { + featureGates = maps.Clone(originalGates) + componentNamespaces = map[string]string{} + objectControllerOnly = tc.standalone + deployments := map[string]*appsv1.Deployment{} + if tc.objectPresent { + // Discovery must enable scenarios even before the Deployment is ready. + deployments[objectControllerComponent] = &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Namespace: "objects"}} + } + if tc.operator { + deployment := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Namespace: "extensions"}} + deployment.Spec.Template.Spec.Containers = []corev1.Container{{Name: "manager", Args: []string{"--feature-gates=BoxcutterRuntime=false"}}} + if tc.boxcutter { + deployment.Spec.Template.Spec.Containers[0].Args = []string{"--feature-gates=BoxcutterRuntime=true"} + } + deployments[operatorControllerComponent] = deployment + } + configureControllerFeatures(deployments) + require.Equal(t, tc.objectPresent, featureGates[objectControllerFeature]) + require.Equal(t, tc.boxcutter, featureGates[features.BoxcutterRuntime]) + if tc.objectPresent { + require.Equal(t, "objects", namespaceForComponent(objectControllerComponent)) + } + scenario := &godog.Scenario{} + require.NoError(t, json.Unmarshal([]byte(`{"tags":[{"name":"@ObjectController"}]}`), scenario)) + _, err := CheckFeatureTags(context.Background(), scenario) + if tc.wantSkip { + require.ErrorIs(t, err, godog.ErrSkip) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/test/e2e/steps/proxy_steps.go b/test/e2e/steps/proxy_steps.go index 02ac969114..adb5c4cb81 100644 --- a/test/e2e/steps/proxy_steps.go +++ b/test/e2e/steps/proxy_steps.go @@ -288,10 +288,10 @@ func configureDeploymentProxy(ctx context.Context, component, proxyURL string) e var deployName string switch component { - case "operator-controller": + case operatorControllerComponent: deployName = olmDeploymentName - case "catalogd": - deployName = "catalogd-controller-manager" + case catalogdComponent: + deployName = catalogdDeploymentName default: return fmt.Errorf("unknown component %q", component) } diff --git a/test/e2e/steps/steps.go b/test/e2e/steps/steps.go index 9ecc41fc8e..614b948089 100644 --- a/test/e2e/steps/steps.go +++ b/test/e2e/steps/steps.go @@ -35,7 +35,6 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/util/sets" k8sresource "k8s.io/cli-runtime/pkg/resource" - "k8s.io/utils/ptr" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/yaml" @@ -49,6 +48,10 @@ import ( ) const ( + operatorControllerComponent = "operator-controller" + catalogdComponent = "catalogd" + objectControllerComponent = "object-controller" + olmDeploymentName = "operator-controller-controller-manager" catalogdDeploymentName = "catalogd-controller-manager" defaultTimeout = 5 * time.Minute @@ -112,7 +115,6 @@ func RegisterSteps(sc *godog.ScenarioContext) { sc.Step(`^(?i)ClusterExtension is applied(?:\s+.*)?$`, ResourceIsApplied) sc.Step(`^(?i)ClusterExtension version is updated to "([^"]+)"$`, ClusterExtensionVersionUpdate) sc.Step(`^(?i)ClusterExtension is updated(?:\s+.*)?$`, ResourceIsApplied) - sc.Step(`^(?i)ClusterObjectSet "([^"]+)" lifecycle is set to "([^"]+)"$`, ClusterObjectSetLifecycleUpdate) sc.Step(`^(?i)ClusterExtension is available$`, ClusterExtensionIsAvailable) sc.Step(`^(?i)ClusterExtension is rolled out$`, ClusterExtensionIsRolledOut) sc.Step(`^(?i)ClusterExtension resources are created and labeled$`, ClusterExtensionResourcesCreatedAndAreLabeled) @@ -124,25 +126,7 @@ func RegisterSteps(sc *godog.ScenarioContext) { sc.Step(`^(?i)ClusterExtension reports ([[:alnum:]]+) as ([[:alnum:]]+) with Reason ([[:alnum:]]+) and Message includes:$`, ClusterExtensionReportsConditionWithMessageFragment) sc.Step(`^(?i)ClusterExtension reports ([[:alnum:]]+) as ([[:alnum:]]+) with Reason ([[:alnum:]]+)$`, ClusterExtensionReportsConditionWithoutMsg) sc.Step(`^(?i)ClusterExtension reports ([[:alnum:]]+) as ([[:alnum:]]+)$`, ClusterExtensionReportsConditionWithoutReason) - 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) - sc.Step(`^(?i)ClusterObjectSet "([^"]+)" reports ([[:alnum:]]+) as ([[:alnum:]]+) with Reason ([[:alnum:]]+) and Message includes:$`, ClusterObjectSetReportsConditionWithMessageFragment) sc.Step(`^(?i)ClusterExtension reports ([[:alnum:]]+) transition between (\d+) and (\d+) minutes since its creation$`, ClusterExtensionReportsConditionTransitionTime) - sc.Step(`^(?i)ClusterObjectSet is applied(?:\s+.*)?$`, ResourceIsApplied) - sc.Step(`^(?i)ClusterObjectSet "([^"]+)" reconciliation is triggered$`, TriggerClusterObjectSetReconciliation) - sc.Step(`^(?i)ClusterObjectSet "([^"]+)" has observed phase "([^"]+)" with a non-empty digest$`, ClusterObjectSetHasObservedPhase) - sc.Step(`^(?i)ClusterObjectSet "([^"]+)" is archived$`, ClusterObjectSetIsArchived) - sc.Step(`^(?i)ClusterObjectSet "([^"]+)" reports phase "([^"]+)" object counts: total=(\d+), present=(\d+), synced=(\d+), ready=(\d+)$`, ClusterObjectSetPhaseHasObjectCounts) - sc.Step(`^(?i)ClusterObjectSet "([^"]+)" reports aggregate object counts: total=(\d+), present=(\d+), synced=(\d+), ready=(\d+)$`, ClusterObjectSetHasAggregateObjectCounts) - sc.Step(`^(?i)ClusterObjectSet "([^"]+)" contains annotation "([^"]+)" with value$`, ClusterObjectSetHasAnnotationWithValue) - sc.Step(`^(?i)ClusterObjectSet "([^"]+)" has label "([^"]+)" with value "([^"]+)"$`, ClusterObjectSetHasLabelWithValue) - sc.Step(`^(?i)ClusterObjectSet "([^"]+)" phase objects are not found or not owned by the revision$`, ClusterObjectSetObjectsNotFoundOrNotOwned) - sc.Step(`^(?i)ClusterObjectSet "([^"]+)" phase objects are managed in Kubernetes secrets$`, ClusterObjectSetPhaseObjectsManagedInSecrets) - sc.Step(`^(?i)ClusterObjectSet "([^"]+)" referred secrets exist in "([^"]+)" namespace$`, ClusterObjectSetReferredSecretsExist) - sc.Step(`^(?i)ClusterObjectSet "([^"]+)" referred secrets are immutable$`, ClusterObjectSetReferredSecretsAreImmutable) - sc.Step(`^(?i)ClusterObjectSet "([^"]+)" referred secrets contain labels$`, ClusterObjectSetReferredSecretsContainLabels) - sc.Step(`^(?i)ClusterObjectSet "([^"]+)" referred secrets are owned by the object set$`, ClusterObjectSetReferredSecretsOwnedByObjectSet) - sc.Step(`^(?i)ClusterObjectSet "([^"]+)" referred secrets have type "([^"]+)"$`, ClusterObjectSetReferredSecretsHaveType) sc.Step(`^(?i)resource "([^"]+)" is installed$`, ResourceAvailable) sc.Step(`^(?i)resource "([^"]+)" is available$`, ResourceAvailable) @@ -643,23 +627,6 @@ func ClusterExtensionVersionUpdate(ctx context.Context, version string) error { return err } -// ClusterObjectSetLifecycleUpdate patches the ClusterObjectSet's lifecycleState to the specified value. -func ClusterObjectSetLifecycleUpdate(ctx context.Context, cosName, lifecycle string) error { - sc := scenarioCtx(ctx) - cosName = substituteScenarioVars(cosName, sc) - patch := map[string]any{ - "spec": map[string]any{ - "lifecycleState": lifecycle, - }, - } - pb, err := json.Marshal(patch) - if err != nil { - return err - } - _, err = k8sClient(ctx, "patch", "clusterobjectset", cosName, "--type", "merge", "-p", string(pb)) - return err -} - // ResourceIsApplied applies the provided YAML resource to the cluster and in case of ClusterExtension or ClusterObjectSet it captures // its name in the test context so that it can be referred to in later steps with ${NAME} or ${COS_NAME}, respectively func ResourceIsApplied(ctx context.Context, yamlTemplate *godog.DocString) error { @@ -974,456 +941,6 @@ func ClusterExtensionReportsActiveRevisions(ctx context.Context, rawRevisionName return nil } -// ClusterObjectSetReportsConditionWithoutMsg waits for the named ClusterObjectSet to have a condition -// matching type, status, and reason. Polls with timeout. -func ClusterObjectSetReportsConditionWithoutMsg(ctx context.Context, revisionName, conditionType, conditionStatus, conditionReason string) error { - return waitForCondition(ctx, "clusterobjectset", substituteScenarioVars(revisionName, scenarioCtx(ctx)), conditionType, conditionStatus, &conditionReason, nil) -} - -// ClusterObjectSetReportsConditionWithMsg waits for the named ClusterObjectSet to have a condition -// matching type, status, reason, and message. Polls with timeout. -func ClusterObjectSetReportsConditionWithMsg(ctx context.Context, revisionName, conditionType, conditionStatus, conditionReason string, msg *godog.DocString) error { - return waitForCondition(ctx, "clusterobjectset", substituteScenarioVars(revisionName, scenarioCtx(ctx)), conditionType, conditionStatus, &conditionReason, messageComparison(ctx, msg)) -} - -// ClusterObjectSetReportsConditionWithMessageFragment waits for the named ClusterObjectSet to have a condition -// matching type, status, reason, with a message containing the specified fragment. Polls with timeout. -func ClusterObjectSetReportsConditionWithMessageFragment(ctx context.Context, revisionName, conditionType, conditionStatus, conditionReason string, msgFragment *godog.DocString) error { - return waitForCondition(ctx, "clusterobjectset", substituteScenarioVars(revisionName, scenarioCtx(ctx)), conditionType, conditionStatus, &conditionReason, messageFragmentComparison(ctx, msgFragment)) -} - -// TriggerClusterObjectSetReconciliation annotates the named ClusterObjectSet -// to trigger a new reconciliation cycle. -func TriggerClusterObjectSetReconciliation(ctx context.Context, cosName string) error { - sc := scenarioCtx(ctx) - cosName = substituteScenarioVars(cosName, sc) - _, err := k8sClient(ctx, "annotate", "clusterobjectset", cosName, "--overwrite", - fmt.Sprintf("e2e-trigger=%d", time.Now().UnixNano())) - return err -} - -// ClusterObjectSetHasObservedPhase waits for the named ClusterObjectSet to have -// an observedPhases entry matching the given phase name with a non-empty digest. Polls with timeout. -func ClusterObjectSetHasObservedPhase(ctx context.Context, cosName, phaseName string) error { - sc := scenarioCtx(ctx) - cosName = substituteScenarioVars(cosName, sc) - phaseName = substituteScenarioVars(phaseName, sc) - - waitFor(ctx, func() bool { - out, err := k8sClient(ctx, "get", "clusterobjectset", cosName, "-o", - fmt.Sprintf(`jsonpath={.status.observedPhases[?(@.name=="%s")].digest}`, phaseName)) - if err != nil { - return false - } - return strings.TrimSpace(out) != "" - }) - return nil -} - -// ClusterObjectSetIsArchived waits for the named ClusterObjectSet to have Ready=False -// with reason Archived. Polls with timeout. -func ClusterObjectSetIsArchived(ctx context.Context, revisionName string) error { - return waitForCondition(ctx, "clusterobjectset", substituteScenarioVars(revisionName, scenarioCtx(ctx)), "Ready", "False", ptr.To("Archived"), nil) -} - -// ClusterObjectSetPhaseHasObjectCounts waits for a specific phase of the named ClusterObjectSet to -// report exact object counts for total, present, synced, and available. Polls with timeout. -func ClusterObjectSetPhaseHasObjectCounts(ctx context.Context, cosName, phaseName string, total, present, synced, ready int64) error { - sc := scenarioCtx(ctx) - cosName = substituteScenarioVars(cosName, sc) - phaseName = substituteScenarioVars(phaseName, sc) - waitFor(ctx, func() bool { - out, err := k8sClient(ctx, "get", "clusterobjectset", cosName, "-o", "json") - if err != nil { - return false - } - var cos ocv1.ClusterObjectSet - if err := json.Unmarshal([]byte(out), &cos); err != nil { - return false - } - for _, phase := range cos.Status.ObservedPhases { - if phase.Name == phaseName { - return phase.ObjectCounts.Total == total && - phase.ObjectCounts.Present == present && - phase.ObjectCounts.Synced == synced && - phase.ObjectCounts.Ready == ready - } - } - return false - }) - return nil -} - -// ClusterObjectSetHasAggregateObjectCounts waits for the named ClusterObjectSet to report exact -// aggregate object counts (summed across all phases). Polls with timeout. -func ClusterObjectSetHasAggregateObjectCounts(ctx context.Context, cosName string, total, present, synced, ready int64) error { - sc := scenarioCtx(ctx) - cosName = substituteScenarioVars(cosName, sc) - waitFor(ctx, func() bool { - out, err := k8sClient(ctx, "get", "clusterobjectset", cosName, "-o", "json") - if err != nil { - return false - } - var cos ocv1.ClusterObjectSet - if err := json.Unmarshal([]byte(out), &cos); err != nil { - return false - } - return cos.Status.ObjectCounts.Total == total && - cos.Status.ObjectCounts.Present == present && - cos.Status.ObjectCounts.Synced == synced && - cos.Status.ObjectCounts.Ready == ready - }) - return nil -} - -// ClusterObjectSetHasAnnotationWithValue waits for the named ClusterObjectSet to have the specified -// annotation with the expected value. Polls with timeout. -func ClusterObjectSetHasAnnotationWithValue(ctx context.Context, revisionName, annotationKey string, annotationValue *godog.DocString) error { - sc := scenarioCtx(ctx) - revisionName = substituteScenarioVars(strings.TrimSpace(revisionName), sc) - expectedValue := "" - if annotationValue != nil { - expectedValue = annotationValue.Content - } - waitFor(ctx, func() bool { - obj, err := getResource("clusterobjectset", revisionName, "") - if err != nil { - logger.V(1).Error(err, "failed to get clusterobjectset", "name", revisionName) - return false - } - if obj.GetAnnotations() == nil { - return false - } - return obj.GetAnnotations()[annotationKey] == expectedValue - }) - return nil -} - -// ClusterObjectSetHasLabelWithValue waits for the named ClusterObjectSet to have the specified label -// with the expected value. Polls with timeout. -func ClusterObjectSetHasLabelWithValue(ctx context.Context, revisionName, labelKey, labelValue string) error { - sc := scenarioCtx(ctx) - revisionName = substituteScenarioVars(strings.TrimSpace(revisionName), sc) - labelValue = substituteScenarioVars(labelValue, sc) - waitFor(ctx, func() bool { - obj, err := getResource("clusterobjectset", revisionName, "") - if err != nil { - logger.V(1).Error(err, "failed to get clusterobjectset", "name", revisionName) - return false - } - if obj.GetLabels() == nil { - return false - } - return obj.GetLabels()[labelKey] == labelValue - }) - return nil -} - -// ClusterObjectSetObjectsNotFoundOrNotOwned waits for all objects described in the named -// ClusterObjectSet's phases to either not exist on the cluster or not contain the revision -// in their ownerReferences. Polls with timeout. -func ClusterObjectSetObjectsNotFoundOrNotOwned(ctx context.Context, revisionName string) error { - sc := scenarioCtx(ctx) - revisionName = substituteScenarioVars(strings.TrimSpace(revisionName), sc) - - // Get the ClusterObjectSet to extract its phase objects - var rev ocv1.ClusterObjectSet - waitFor(ctx, func() bool { - out, err := k8sClient(ctx, "get", "clusterobjectset", revisionName, "-o", "json") - if err != nil { - return false - } - return json.Unmarshal([]byte(out), &rev) == nil - }) - - // For each object in each phase, verify it either doesn't exist or - // doesn't have the ClusterObjectSet in its ownerReferences - for i, phase := range rev.Spec.Phases { - for j, phaseObj := range phase.Objects { - var obj *unstructured.Unstructured - switch { - case phaseObj.Ref.Name != "": - resolved, err := resolveObjectRef(phaseObj.Ref) - if err != nil { - return fmt.Errorf("resolving ref in phase %q object %d: %w", phase.Name, j, err) - } - obj = resolved - case len(phaseObj.Object.Object) > 0: - obj = &phaseObj.Object - default: - return fmt.Errorf("clusterobjectset %q phase %d object %d has neither ref nor inline object", revisionName, i, j) - } - kind := obj.GetKind() - name := obj.GetName() - namespace := obj.GetNamespace() - - if kind == "" { - return fmt.Errorf("clusterobjectset %q has a phase object with empty kind", revisionName) - } - if name == "" { - return fmt.Errorf("clusterobjectset %q has a phase object with empty name (kind %q, namespace %q)", revisionName, kind, namespace) - } - - waitFor(ctx, func() bool { - args := []string{"get", kind, name, "--ignore-not-found", "-o", "json"} - if namespace != "" { - args = append(args, "-n", namespace) - } - out, err := k8sClient(ctx, args...) - if err != nil { - return false - } - // If output is empty, the resource does not exist — condition satisfied - if strings.TrimSpace(out) == "" { - return true - } - clusterObj, err := toUnstructured(out) - if err != nil { - return false - } - - // Check that no ownerReference points to this ClusterObjectSet - for _, ref := range clusterObj.GetOwnerReferences() { - if ref.Kind == ocv1.ClusterObjectSetKind && ref.Name == revisionName && ref.UID == rev.UID { - logger.V(1).Info("object still owned by revision", - "kind", kind, "name", name, "namespace", namespace, - "revision", revisionName) - return false - } - } - return true - }) - } - } - return nil -} - -// ClusterObjectSetPhaseObjectsManagedInSecrets verifies that every object in every phase of the named -// ClusterObjectSet uses a ref (not an inline object). Polls with timeout. -func ClusterObjectSetPhaseObjectsManagedInSecrets(ctx context.Context, revisionName string) error { - sc := scenarioCtx(ctx) - revisionName = substituteScenarioVars(strings.TrimSpace(revisionName), sc) - - waitFor(ctx, func() bool { - obj, err := getResource("clusterobjectset", revisionName, "") - if err != nil { - return false - } - phases, ok, _ := unstructured.NestedSlice(obj.Object, "spec", "phases") - if !ok || len(phases) == 0 { - return false - } - for _, p := range phases { - phase, ok := p.(map[string]interface{}) - if !ok { - return false - } - objects, ok, _ := unstructured.NestedSlice(phase, "objects") - if !ok || len(objects) == 0 { - return false - } - for _, o := range objects { - obj, ok := o.(map[string]interface{}) - if !ok { - return false - } - ref, refOK, _ := unstructured.NestedMap(obj, "ref") - if !refOK || len(ref) == 0 { - logger.V(1).Info("object does not use ref", "revision", revisionName) - return false - } - name, _, _ := unstructured.NestedString(ref, "name") - if name == "" { - logger.V(1).Info("ref has empty name", "revision", revisionName) - return false - } - } - } - return true - }) - return nil -} - -// ClusterObjectSetReferredSecretsExist verifies that all Secrets referenced by the named -// ClusterObjectSet's phase objects exist in the given namespace. Polls with timeout. -func ClusterObjectSetReferredSecretsExist(ctx context.Context, revisionName, namespace string) error { - sc := scenarioCtx(ctx) - revisionName = substituteScenarioVars(strings.TrimSpace(revisionName), sc) - namespace = substituteScenarioVars(strings.TrimSpace(namespace), sc) - - secretNames, err := collectReferredSecretNames(ctx, revisionName) - if err != nil { - return err - } - - for _, name := range secretNames { - waitFor(ctx, func() bool { - _, err := getResource("secret", name, namespace) - return err == nil - }) - } - return nil -} - -// ClusterObjectSetReferredSecretsAreImmutable verifies that all referred Secrets for the named -// ClusterObjectSet are immutable. Polls with timeout. -func ClusterObjectSetReferredSecretsAreImmutable(ctx context.Context, revisionName string) error { - sc := scenarioCtx(ctx) - revisionName = substituteScenarioVars(strings.TrimSpace(revisionName), sc) - - secrets, err := listReferredSecrets(ctx, revisionName) - if err != nil { - return err - } - if len(secrets) == 0 { - return fmt.Errorf("no referred secrets found for revision %q", revisionName) - } - for _, s := range secrets { - if s.Immutable == nil || !*s.Immutable { - return fmt.Errorf("referred secret %s/%s is not immutable", s.Namespace, s.Name) - } - } - return nil -} - -// ClusterObjectSetReferredSecretsContainLabels verifies that all referred Secrets for the named -// ClusterObjectSet have the expected labels specified in the data table. Polls with timeout. -func ClusterObjectSetReferredSecretsContainLabels(ctx context.Context, revisionName string, table *godog.Table) error { - sc := scenarioCtx(ctx) - revisionName = substituteScenarioVars(strings.TrimSpace(revisionName), sc) - - expected, err := parseKeyValueTable(table, sc) - if err != nil { - return fmt.Errorf("invalid labels table: %w", err) - } - - waitFor(ctx, func() bool { - secrets, err := listReferredSecrets(ctx, revisionName) - if err != nil || len(secrets) == 0 { - return false - } - for _, s := range secrets { - if _, _, ok := matchLabels(s.Labels, expected); !ok { - return false - } - } - return true - }) - return nil -} - -// ClusterObjectSetReferredSecretsOwnedByObjectSet verifies that all referred Secrets for the named -// ClusterObjectSet have an ownerReference pointing to the ClusterObjectSet with controller=true. -func ClusterObjectSetReferredSecretsOwnedByObjectSet(ctx context.Context, revisionName string) error { - sc := scenarioCtx(ctx) - revisionName = substituteScenarioVars(strings.TrimSpace(revisionName), sc) - - cosObj, err := getResource("clusterobjectset", revisionName, "") - if err != nil { - return fmt.Errorf("getting ClusterObjectSet %q: %w", revisionName, err) - } - cosUID := cosObj.GetUID() - - secrets, err := listReferredSecrets(ctx, revisionName) - if err != nil { - return err - } - if len(secrets) == 0 { - return fmt.Errorf("no referred secrets found for revision %q", revisionName) - } - - for _, s := range secrets { - found := false - for _, ref := range s.OwnerReferences { - if ref.Kind == ocv1.ClusterObjectSetKind && ref.Name == revisionName && ref.UID == cosUID { - if ref.Controller == nil || !*ref.Controller { - return fmt.Errorf("secret %s/%s has ownerReference to ClusterObjectSet but controller is not true", s.Namespace, s.Name) - } - found = true - break - } - } - if !found { - return fmt.Errorf("secret %s/%s does not have ownerReference to ClusterObjectSet %q (uid %s)", s.Namespace, s.Name, revisionName, cosUID) - } - } - return nil -} - -// ClusterObjectSetReferredSecretsHaveType verifies that all referred Secrets for the named -// ClusterObjectSet have the specified Secret type. -func ClusterObjectSetReferredSecretsHaveType(ctx context.Context, revisionName, expectedType string) error { - sc := scenarioCtx(ctx) - revisionName = substituteScenarioVars(strings.TrimSpace(revisionName), sc) - - secrets, err := listReferredSecrets(ctx, revisionName) - if err != nil { - return err - } - if len(secrets) == 0 { - return fmt.Errorf("no referred secrets found for revision %q", revisionName) - } - - for _, s := range secrets { - if string(s.Type) != expectedType { - return fmt.Errorf("secret %s/%s has type %q, expected %q", s.Namespace, s.Name, s.Type, expectedType) - } - } - return nil -} - -// collectReferredSecretNames returns the unique set of Secret names referenced by the ClusterObjectSet's phase objects. -func collectReferredSecretNames(ctx context.Context, revisionName string) ([]string, error) { - var names []string - seen := sets.New[string]() - - var obj *unstructured.Unstructured - waitFor(ctx, func() bool { - var err error - obj, err = getResource("clusterobjectset", revisionName, "") - return err == nil - }) - - phases, _, _ := unstructured.NestedSlice(obj.Object, "spec", "phases") - for _, p := range phases { - phase, ok := p.(map[string]interface{}) - if !ok { - continue - } - objects, _, _ := unstructured.NestedSlice(phase, "objects") - for _, o := range objects { - phaseObj, ok := o.(map[string]interface{}) - if !ok { - continue - } - name, _, _ := unstructured.NestedString(phaseObj, "ref", "name") - if name != "" && !seen.Has(name) { - seen.Insert(name) - names = append(names, name) - } - } - } - if len(names) == 0 { - return nil, fmt.Errorf("no referred secret names found in ClusterObjectSet %q", revisionName) - } - return names, nil -} - -// listReferredSecrets lists all Secrets in the OLM namespace that have the revision-name label -// matching the given revision name. -func listReferredSecrets(ctx context.Context, revisionName string) ([]corev1.Secret, error) { - out, err := k8sClient(ctx, "get", "secrets", "-n", olmNamespace, - "-l", "olm.operatorframework.io/revision-name="+revisionName, "-o", "json") - if err != nil { - return nil, fmt.Errorf("listing referred secrets for revision %q: %w", revisionName, err) - } - var secretList corev1.SecretList - if err := json.Unmarshal([]byte(out), &secretList); err != nil { - return nil, fmt.Errorf("unmarshalling Secret list: %w", err) - } - return secretList.Items, nil -} - // ResourceAvailable waits for the specified resource (kind/name format) to exist in the test namespace. Polls with timeout. func ResourceAvailable(ctx context.Context, resource string) error { sc := scenarioCtx(ctx) @@ -1646,10 +1163,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 ClusterObjectSet scenarios use boxcutter RBAC independently of the +// operator-controller feature gate. Extension scenarios select their enabled runtime. func ServiceAccountWithNeededPermissionsIsAvailableInTestNamespace(ctx context.Context, serviceAccount string) error { kernel := "helm" - if enabled, found := featureGates[features.BoxcutterRuntime]; found && enabled { + if featureGates[features.BoxcutterRuntime] || scenarioCtx(ctx).tags.Has("@"+string(objectControllerFeature)) { kernel = "boxcutter" } rbacTemplate := fmt.Sprintf("%s-%s-rbac-template.yaml", serviceAccount, kernel) @@ -2355,6 +1873,9 @@ func listExtensionRevisionResources(extName string) ([]client.Object, error) { // resolveObjectRef fetches an object from a Secret ref using kubectl. func resolveObjectRef(ref ocv1.ObjectSourceRef) (*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/e2e/steps/upgrade_steps.go b/test/e2e/steps/upgrade_steps.go index 4267dccd69..0e02fbcc8c 100644 --- a/test/e2e/steps/upgrade_steps.go +++ b/test/e2e/steps/upgrade_steps.go @@ -29,10 +29,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 not found after installation") + } olmNamespace = olm.Namespace return nil }) @@ -124,8 +128,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 @@ -150,7 +154,7 @@ func reconcileEndingCheck(ctx context.Context, namespace, leaderPod, resourceNam func ClusterExtensionIsReconciled(ctx context.Context) error { sc := scenarioCtx(ctx) - component := "operator-controller" + component := operatorControllerComponent resourceName := sc.clusterExtensionName if resourceName == "" { return fmt.Errorf("no ClusterExtension found in scenario context") @@ -246,7 +250,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 }