Repository navigation
🌱 OPRUN-4849: Prepare standalone object-controller E2E readiness #3158
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
openshift-merge-bot
merged 1 commit into
operator-framework:main
from
fao89:OPRUN-4849-runtime
Nov 12, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
219 changes: 219 additions & 0 deletions
219
test/internal/e2e/object-controller/steps/readiness_test.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,219 @@ | ||
| package steps | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "io" | ||
| "os" | ||
| "path/filepath" | ||
| "strings" | ||
| "testing" | ||
|
|
||
| "github.com/cucumber/godog" | ||
| "github.com/spf13/pflag" | ||
| "github.com/stretchr/testify/require" | ||
| corev1 "k8s.io/api/core/v1" | ||
| metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" | ||
| "k8s.io/utils/ptr" | ||
|
|
||
| ocv1 "github.com/operator-framework/operator-controller/api/v1" | ||
| shared "github.com/operator-framework/operator-controller/test/internal/e2e/steps" | ||
| ) | ||
|
|
||
| // TestObjectControllerReadiness checks API creation, establishment, controller | ||
| // availability, and isolation for standalone and integrated scenarios. | ||
| func TestObjectControllerReadiness(t *testing.T) { | ||
| for _, tc := range []struct { | ||
| name string | ||
| standalone bool | ||
| missing bool | ||
| integrated bool | ||
| cod bool | ||
| delayedCRDs bool | ||
| forbiddenAPI string | ||
| failCommand string | ||
| wantError string | ||
| }{ | ||
| {name: "standalone COS", standalone: true}, | ||
| {name: "standalone COD", standalone: true, cod: true}, | ||
| {name: "integrated namespaces", integrated: true}, | ||
| {name: "COS API created after discovery", delayedCRDs: true}, | ||
| {name: "COS and COD APIs created after discovery", cod: true, delayedCRDs: true}, | ||
| {name: "missing standalone deployment", standalone: true, missing: true, wantError: "object-controller is not installed"}, | ||
| {name: "missing integrated deployment", integrated: true, missing: true, wantError: "object-controller is not installed"}, | ||
| {name: "operator deployment violates isolation", standalone: true, integrated: true, wantError: "require operator-controller and catalogd to be absent"}, | ||
| {name: "ClusterExtension violates isolation", standalone: true, forbiddenAPI: "clusterextensions", wantError: "APIs to be absent"}, | ||
| {name: "ClusterCatalog violates isolation", standalone: true, forbiddenAPI: "clustercatalogs", wantError: "APIs to be absent"}, | ||
| {name: "isolation check errors fail closed", standalone: true, failCommand: "get crd", wantError: "checking standalone API isolation"}, | ||
| {name: "COS API creation is required", failCommand: "wait --for=create crd/clusterobjectsets.", wantError: "clusterobjectsets.olm.operatorframework.io was not created"}, | ||
| {name: "COD API creation is required for COD", cod: true, failCommand: "wait --for=create crd/clusterobjectdeployments.", wantError: "clusterobjectdeployments.olm.operatorframework.io was not created"}, | ||
| {name: "COS API is required", failCommand: "wait --for=condition=Established crd/clusterobjectsets.", wantError: "clusterobjectsets.olm.operatorframework.io is unavailable"}, | ||
| {name: "COD API is required for COD", cod: true, failCommand: "wait --for=condition=Established crd/clusterobjectdeployments.", wantError: "clusterobjectdeployments.olm.operatorframework.io is unavailable"}, | ||
| {name: "COS does not require COD creation", failCommand: "wait --for=create crd/clusterobjectdeployments."}, | ||
| {name: "COS does not require COD", failCommand: "wait --for=condition=Established crd/clusterobjectdeployments."}, | ||
| {name: "unavailable controller fails", failCommand: "rollout status", wantError: "object-controller is unavailable"}, | ||
| } { | ||
| t.Run(tc.name, func(t *testing.T) { | ||
| oldNamespaces := shared.ComponentNamespaces | ||
| oldCLI := pflag.Lookup("k8s.cli").Value.String() | ||
| oldOnly := pflag.Lookup("e2e.object-controller-only").Value.String() | ||
| t.Cleanup(func() { | ||
| shared.ComponentNamespaces = oldNamespaces | ||
| require.NoError(t, pflag.Set("k8s.cli", oldCLI)) | ||
| require.NoError(t, pflag.Set("e2e.object-controller-only", oldOnly)) | ||
| }) | ||
| shared.ComponentNamespaces = map[string]string{} | ||
| if !tc.missing { | ||
| shared.ComponentNamespaces[shared.ObjectControllerComponent] = "objects" | ||
| } | ||
| if tc.integrated { | ||
| shared.ComponentNamespaces[shared.OperatorControllerComponent] = "extensions" | ||
| shared.ComponentNamespaces[shared.CatalogdComponent] = "catalogs" | ||
| } | ||
| require.NoError(t, pflag.Set("e2e.object-controller-only", map[bool]string{true: "true", false: "false"}[tc.standalone])) | ||
| dir := t.TempDir() | ||
| cli, commands := filepath.Join(dir, "kubectl"), filepath.Join(dir, "commands") | ||
| t.Setenv("E2E_READINESS_COMMANDS", commands) | ||
| t.Setenv("E2E_FORBIDDEN_API", tc.forbiddenAPI) | ||
| t.Setenv("E2E_FAIL_COMMAND", tc.failCommand) | ||
| t.Setenv("E2E_DELAYED_CRDS", "") | ||
| if tc.delayedCRDs { | ||
| t.Setenv("E2E_DELAYED_CRDS", dir) | ||
| } | ||
| script := `#!/bin/sh | ||
| printf '%s\n' "$*" >> "$E2E_READINESS_COMMANDS" | ||
| if [ -n "$E2E_FAIL_COMMAND" ]; then | ||
| case "$*" in "$E2E_FAIL_COMMAND"*) echo "not available" >&2; exit 1;; esac | ||
| fi | ||
| if [ -n "$E2E_DELAYED_CRDS" ]; then | ||
| case "$1 $2" in | ||
| "wait --for=create") | ||
| # Simulate a CRD appearing during the creation wait. | ||
| touch "$E2E_DELAYED_CRDS/${3#crd/}";; | ||
| "wait --for=condition=Established") | ||
| if [ ! -f "$E2E_DELAYED_CRDS/${3#crd/}" ]; then | ||
| echo "CRD not found" >&2 | ||
| exit 1 | ||
| fi;; | ||
| esac | ||
| fi | ||
| case "$1 $2" in "get crd") printf '%s' "$E2E_FORBIDDEN_API";; esac | ||
| ` | ||
| //nolint:gosec // The private test CLI must be executable. | ||
| require.NoError(t, os.WriteFile(cli, []byte(script), 0o700)) | ||
| require.NoError(t, pflag.Set("k8s.cli", cli)) | ||
| scenario := &godog.Scenario{Id: "1", Uri: "renamed.feature"} | ||
| if tc.cod { | ||
| require.NoError(t, json.Unmarshal([]byte(`{"tags":[{"name":"@ClusterObjectDeployment"}]}`), scenario)) | ||
| } | ||
| ctx, err := shared.CreateScenarioContext(t.Context(), scenario) | ||
| require.NoError(t, err) | ||
| err = ObjectControllerIsAvailable(ctx) | ||
| if tc.wantError != "" { | ||
| require.ErrorContains(t, err, tc.wantError) | ||
| } else { | ||
| require.NoError(t, err) | ||
| } | ||
| if tc.missing || tc.standalone && tc.integrated { | ||
| require.NoFileExists(t, commands, "discovery errors should fail immediately") | ||
| return | ||
| } | ||
| data, err := os.ReadFile(commands) | ||
| require.NoError(t, err) | ||
| if strings.HasPrefix(tc.failCommand, "wait --for=create ") && tc.wantError != "" { | ||
| api := strings.TrimPrefix(tc.failCommand, "wait --for=create ") | ||
| require.NotContains(t, string(data), "wait --for=condition=Established "+api) | ||
| require.NotContains(t, string(data), "rollout status") | ||
| } | ||
| if tc.wantError == "" { | ||
| require.Contains(t, string(data), "rollout status deployment/"+shared.ObjectControllerDeploymentName+" -n objects --timeout=") | ||
| require.Equal(t, tc.cod, strings.Contains(string(data), "crd/clusterobjectdeployments.")) | ||
| require.Equal(t, tc.standalone, strings.Contains(string(data), "get crd clusterextensions.")) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| // TestReferredSecretAssertionsUseReferenceNamespaces checks that Secret assertions | ||
| // use declared namespaces and deduplicate implicit system-namespace references. | ||
| func TestReferredSecretAssertionsUseReferenceNamespaces(t *testing.T) { | ||
| oldNamespaces, oldNamespace, oldCLI := shared.ComponentNamespaces, shared.OLMNamespace, pflag.Lookup("k8s.cli").Value.String() | ||
| t.Cleanup(func() { | ||
| shared.ComponentNamespaces, shared.OLMNamespace = oldNamespaces, oldNamespace | ||
| require.NoError(t, pflag.Set("k8s.cli", oldCLI)) | ||
| }) | ||
| shared.OLMNamespace = "extensions" | ||
| shared.ComponentNamespaces = map[string]string{shared.ObjectControllerComponent: "objects"} | ||
| dir := t.TempDir() | ||
| t.Setenv("E2E_SECRET_FIXTURES", dir) | ||
| cli := filepath.Join(dir, "kubectl") | ||
| script := `#!/bin/sh | ||
| printf '%s\n' "$*" >> "$E2E_SECRET_FIXTURES/commands" | ||
| case "$1 $2" in | ||
| "get clusterobjectset") cat "$E2E_SECRET_FIXTURES/revision.json";; | ||
| "get secret") cat "$E2E_SECRET_FIXTURES/${5:-kubeconfig-default}-$3.json";; | ||
| *) echo "unexpected command: $*" >&2; exit 1;; | ||
| esac | ||
| ` | ||
| //nolint:gosec // The private test CLI must be executable. | ||
| require.NoError(t, os.WriteFile(cli, []byte(script), 0o700)) | ||
| require.NoError(t, pflag.Set("k8s.cli", cli)) | ||
| revision := ocv1.ClusterObjectSet{ObjectMeta: metav1.ObjectMeta{Name: "revision", UID: "revision-uid"}} | ||
| namespaces := []string{"objects", "extensions", "referenced-content"} | ||
| for _, namespace := range namespaces { | ||
| revision.Spec.Phases = append(revision.Spec.Phases, ocv1.ClusterObjectSetPhase{ | ||
| Name: namespace, | ||
| Objects: []ocv1.ClusterObjectSetObject{ | ||
| {Type: ocv1.ObjectTypeSecretReference, SecretRef: ocv1.SecretSourceRef{Name: "manifest", Namespace: namespace, Key: "first"}}, | ||
| {Type: ocv1.ObjectTypeSecretReference, SecretRef: ocv1.SecretSourceRef{Name: "manifest", Namespace: namespace, Key: "second"}}, | ||
| }, | ||
| }) | ||
| secret := corev1.Secret{ | ||
| ObjectMeta: metav1.ObjectMeta{ | ||
| Name: "manifest", Namespace: namespace, | ||
| Labels: map[string]string{"olm.operatorframework.io/revision-name": revision.Name}, | ||
| OwnerReferences: []metav1.OwnerReference{{ | ||
| Kind: ocv1.ClusterObjectSetKind, Name: revision.Name, UID: revision.UID, Controller: ptr.To(true), | ||
| }}, | ||
| }, | ||
| Immutable: ptr.To(true), | ||
| Type: "olm.operatorframework.io/object-data", | ||
| } | ||
| data, err := json.Marshal(secret) | ||
| require.NoError(t, err) | ||
| require.NoError(t, os.WriteFile(filepath.Join(dir, namespace+"-manifest.json"), data, 0o600)) | ||
| } | ||
| // The omitted namespace must deduplicate with the explicit system namespace. | ||
| revision.Spec.Phases = append(revision.Spec.Phases, ocv1.ClusterObjectSetPhase{ | ||
| Name: "implicit", | ||
| Objects: []ocv1.ClusterObjectSetObject{{ | ||
| Type: ocv1.ObjectTypeSecretReference, SecretRef: ocv1.SecretSourceRef{Name: "manifest", Key: "implicit"}, | ||
| }}, | ||
| }) | ||
| data, err := json.Marshal(revision) | ||
| require.NoError(t, err) | ||
| require.NoError(t, os.WriteFile(filepath.Join(dir, "revision.json"), data, 0o600)) | ||
| suite := godog.TestSuite{ | ||
| ScenarioInitializer: func(sc *godog.ScenarioContext) { | ||
| sc.Before(shared.CreateScenarioContext) | ||
| RegisterSteps(sc) | ||
| }, | ||
| Options: &godog.Options{ | ||
| Format: "progress", Output: io.Discard, TestingT: t, Strict: true, | ||
| FeatureContents: []godog.Feature{{Name: "renamed.feature", Contents: []byte(`Feature: Secret reference namespaces | ||
| Scenario: Assertions use each reference's namespace | ||
| Given ClusterObjectSet "revision" referred secrets are immutable | ||
| And ClusterObjectSet "revision" referred secrets contain labels | ||
| | key | value | | ||
| | olm.operatorframework.io/revision-name | revision | | ||
| And ClusterObjectSet "revision" referred secrets are owned by the object set | ||
| And ClusterObjectSet "revision" referred secrets have type "olm.operatorframework.io/object-data" | ||
| `)}}, | ||
| }, | ||
| } | ||
| require.Zero(t, suite.Run()) | ||
| commands, err := os.ReadFile(filepath.Join(dir, "commands")) | ||
| require.NoError(t, err) | ||
| for _, namespace := range namespaces { | ||
| require.Equal(t, 4, strings.Count(string(commands), "get secret manifest -n "+namespace+" -o json\n")) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wait for a required CRD to exist before waiting for its condition.
If discovery finds the Deployment before a required CRD is created,
kubectl wait --for=condition=Establishedfails at resource lookup. Its timeout does not wait for creation. The step then reports an unavailable API even if installation creates the CRD moments later. Wait for creation first, then wait forEstablished. The kubectl v0.37.2 implementation handles creation waits separately from condition waits. (github.com)There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
might be worth leveraging this, no?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Analysis chain
Script executed:
Length of output: 9326
web_search executed:
In which kubectl release waskubectl wait --for=createintroduced, and does it wait for a named resource that does not yet exist? Check official Kubernetes or kubectl release notes and source. Is it available in kubectl 1.32 and 1.33?Length of output: 19108
@perdasilvaIf you meankubectl wait --for=create, yes. It can wait for the CRD before the existing condition check:Check that the configured
k8s.clisupports--for=create. The kubectl documentation describes this option. Each command gets its own timeout, so the total wait can be longer. A regression test should cover a CRD created after the first check.The current code still waits only for
Established, so I’m not marking the finding as addressed.You are interacting with an AI system.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done. Added
wait --for=createbefore each required CRD'sEstablishedcheck, preserving the configured timeout for both waits. Regression coverage now includes CRDs appearing during the creation wait, creation failures stopping readiness, and COS scenarios remaining independent of the COD API.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.