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 }