From 6b2d4930e6f8a85f4dffd51aa8b7eb521026cff8 Mon Sep 17 00:00:00 2001 From: "Per G. da Silva" Date: Thu, 12 Nov 2026 10:32:47 +0100 Subject: [PATCH] test: add ClusterObjectDeployment revisions and ownership demo Demonstrate A to B to A template changes through three forward COS revisions, controller ownership handoff, ConfigMap UID preservation, archived history, and cascading cleanup. Reuse platform recording hooks and object-controller assertions, add tested resource inspection and ownership helpers, expand recording workflow path filters, and embed the demo in the standalone COD tutorial. Signed-off-by: Per G. da Silva --- .github/workflows/update-demos.yaml | 10 +- .../manage-clusterobjectdeployments.md | 17 +++ test/e2e/platform/features/cod_demos.feature | 142 ++++++++++++++++++ test/e2e/platform/suite_test.go | 2 + .../internal/e2e/platform/steps/demo_steps.go | 2 + .../e2e/platform/steps/resource_display.go | 23 +++ .../platform/steps/resource_display_test.go | 78 ++++++++++ .../e2e/platform/steps/resource_owner.go | 40 +++++ .../e2e/platform/steps/resource_owner_test.go | 74 +++++++++ 9 files changed, 382 insertions(+), 6 deletions(-) create mode 100644 test/e2e/platform/features/cod_demos.feature create mode 100644 test/internal/e2e/platform/steps/resource_display.go create mode 100644 test/internal/e2e/platform/steps/resource_display_test.go create mode 100644 test/internal/e2e/platform/steps/resource_owner.go create mode 100644 test/internal/e2e/platform/steps/resource_owner_test.go diff --git a/.github/workflows/update-demos.yaml b/.github/workflows/update-demos.yaml index 6327361756..8188337414 100644 --- a/.github/workflows/update-demos.yaml +++ b/.github/workflows/update-demos.yaml @@ -7,17 +7,15 @@ on: paths: - 'api/*' - 'config/*' - - 'test/e2e/platform/features/demos.feature' - - 'test/internal/e2e/platform/steps/demo_steps.go' - - 'test/internal/e2e/platform/steps/asciicast_*.go' + - 'test/e2e/platform/features/*demos.feature' + - 'test/internal/e2e/platform/steps/*.go' - '.github/workflows/update-demos.yaml' pull_request: paths: - 'api/*' - 'config/*' - - 'test/e2e/platform/features/demos.feature' - - 'test/internal/e2e/platform/steps/demo_steps.go' - - 'test/internal/e2e/platform/steps/asciicast_*.go' + - 'test/e2e/platform/features/*demos.feature' + - 'test/internal/e2e/platform/steps/*.go' - '.github/workflows/update-demos.yaml' jobs: diff --git a/docs/draft/tutorials/manage-clusterobjectdeployments.md b/docs/draft/tutorials/manage-clusterobjectdeployments.md index 764a0789fc..8a770381d8 100644 --- a/docs/draft/tutorials/manage-clusterobjectdeployments.md +++ b/docs/draft/tutorials/manage-clusterobjectdeployments.md @@ -14,6 +14,23 @@ flowchart LR COS --> CM[ConfigMap] ``` +## Demo: revisions and ownership + +This recording follows A → B → A, showing the new COS revision and ConfigMap +owner at each update, the unchanged ConfigMap UID, archived history, and cascading +cleanup when the COD is deleted. + +```asciinema-player +{ + "file": "../../../demos/cod-revisions-and-ownership.cast" +} +``` + +The demo uses direct COD resources in the experimental E2E environment, which +also runs operator-controller and catalogd. It does not create a ClusterExtension +or use a catalog. The installation instructions below show how to run only +object-controller. + ## Prerequisites - A Kubernetes test cluster where you can install CRDs and cluster-scoped RBAC. diff --git a/test/e2e/platform/features/cod_demos.feature b/test/e2e/platform/features/cod_demos.feature new file mode 100644 index 0000000000..c602b8ed7d --- /dev/null +++ b/test/e2e/platform/features/cod_demos.feature @@ -0,0 +1,142 @@ +@demo @BoxcutterRuntime +Feature: ClusterObjectDeployment demos + + # Direct COD use in the experimental E2E environment. No ClusterExtension or + # catalog is needed by this scenario; this does not test an isolated install. + Scenario: COD Revisions and Ownership + Given resource "customresourcedefinition/clusterobjectdeployments.olm.operatorframework.io" exists + And namespace "${TEST_NAMESPACE}" is available + + # A: the deployment creates and owns its first immutable revision. + When resource is applied + """ + apiVersion: olm.operatorframework.io/v1 + kind: ClusterObjectDeployment + metadata: + name: cod-${SCENARIO_ID} + spec: + revisionHistoryLimit: 2 + template: + spec: + collisionProtection: Prevent + phases: + - name: resources + objects: + - type: InlineManifest + inlineManifest: + apiVersion: v1 + kind: ConfigMap + metadata: + name: cod-demo-settings + namespace: ${TEST_NAMESPACE} + data: + message: Hello from revision A + """ + Then ClusterObjectSet "cod-${SCENARIO_ID}-1" reports Ready as True with Reason AllObjectsReady + And ClusterObjectDeployment "cod-${SCENARIO_ID}" reports Progressing as False with Reason NewClusterObjectSetProgressed + And ClusterObjectDeployment "cod-${SCENARIO_ID}" reports Available as True with Reason Available + And ClusterObjectDeployment "cod-${SCENARIO_ID}" owns 1 ClusterObjectSet + And resource "configmap/cod-demo-settings" matches + """ + data: + message: Hello from revision A + """ + And resource "configmap/cod-demo-settings" is controlled by ClusterObjectSet "cod-${SCENARIO_ID}-1" + And resource "configmap/cod-demo-settings" UID is remembered + And resource "clusterobjectdeployment/cod-${SCENARIO_ID}" is displayed with columns "NAME:.metadata.name,CONDITION:.status.conditions[*].type,STATUS:.status.conditions[*].status,REASON:.status.conditions[*].reason" + And resource "clusterobjectsets" matching field selector "spec.group=cod-${SCENARIO_ID}" is displayed with columns "NAME:.metadata.name,REVISION:.spec.revision,STATE:.spec.lifecycleState,OWNER:.metadata.ownerReferences[0].name" + And resource "configmap/cod-demo-settings" is displayed with columns "MESSAGE:.data.message,OWNER:.metadata.ownerReferences[0].name,UID:.metadata.uid" + + # B: wait for the new COS, not Available=True from the old revision. + When resource is applied + """ + apiVersion: olm.operatorframework.io/v1 + kind: ClusterObjectDeployment + metadata: + name: cod-${SCENARIO_ID} + spec: + revisionHistoryLimit: 2 + template: + spec: + collisionProtection: Prevent + phases: + - name: resources + objects: + - type: InlineManifest + inlineManifest: + apiVersion: v1 + kind: ConfigMap + metadata: + name: cod-demo-settings + namespace: ${TEST_NAMESPACE} + data: + message: Hello from revision B + """ + Then ClusterObjectSet "cod-${SCENARIO_ID}-2" reports Ready as True with Reason AllObjectsReady + And ClusterObjectDeployment "cod-${SCENARIO_ID}" reports Progressing as False with Reason NewClusterObjectSetProgressed + And ClusterObjectDeployment "cod-${SCENARIO_ID}" reports Available as True with Reason Available + And ClusterObjectDeployment "cod-${SCENARIO_ID}" owns 2 ClusterObjectSets + And resource "configmap/cod-demo-settings" matches + """ + data: + message: Hello from revision B + """ + And resource "configmap/cod-demo-settings" is controlled by ClusterObjectSet "cod-${SCENARIO_ID}-2" + And resource "configmap/cod-demo-settings" keeps its UID + And resource "configmap/cod-demo-settings" is displayed with columns "MESSAGE:.data.message,OWNER:.metadata.ownerReferences[0].name,UID:.metadata.uid" + + # A again: restoring a template creates revision 3, not a reactivated revision 1. + When resource is applied + """ + apiVersion: olm.operatorframework.io/v1 + kind: ClusterObjectDeployment + metadata: + name: cod-${SCENARIO_ID} + spec: + revisionHistoryLimit: 2 + template: + spec: + collisionProtection: Prevent + phases: + - name: resources + objects: + - type: InlineManifest + inlineManifest: + apiVersion: v1 + kind: ConfigMap + metadata: + name: cod-demo-settings + namespace: ${TEST_NAMESPACE} + data: + message: Hello from revision A + """ + Then ClusterObjectSet "cod-${SCENARIO_ID}-3" reports Ready as True with Reason AllObjectsReady + And ClusterObjectDeployment "cod-${SCENARIO_ID}" reports Progressing as False with Reason NewClusterObjectSetProgressed + And ClusterObjectDeployment "cod-${SCENARIO_ID}" reports Available as True with Reason Available + And ClusterObjectDeployment "cod-${SCENARIO_ID}" owns 3 ClusterObjectSets + And ClusterObjectSet "cod-${SCENARIO_ID}-1" is archived + And ClusterObjectSet "cod-${SCENARIO_ID}-2" is archived + And resource "clusterobjectset/cod-${SCENARIO_ID}-3" matches + """ + spec: + group: cod-${SCENARIO_ID} + revision: 3 + lifecycleState: Active + """ + And resource "configmap/cod-demo-settings" matches + """ + data: + message: Hello from revision A + """ + And resource "configmap/cod-demo-settings" is controlled by ClusterObjectSet "cod-${SCENARIO_ID}-3" + And resource "configmap/cod-demo-settings" keeps its UID + And resource "clusterobjectsets" matching field selector "spec.group=cod-${SCENARIO_ID}" is displayed with columns "NAME:.metadata.name,REVISION:.spec.revision,STATE:.spec.lifecycleState,OWNER:.metadata.ownerReferences[0].name" + And resource "configmap/cod-demo-settings" is displayed with columns "MESSAGE:.data.message,OWNER:.metadata.ownerReferences[0].name,UID:.metadata.uid" + + # Remove the parent and verify cascading cleanup before deleting the namespace. + When resource "clusterobjectdeployment/cod-${SCENARIO_ID}" is removed + Then resource "clusterobjectdeployment/cod-${SCENARIO_ID}" is not found + And resource "clusterobjectset/cod-${SCENARIO_ID}-1" is not found + And resource "clusterobjectset/cod-${SCENARIO_ID}-2" is not found + And resource "clusterobjectset/cod-${SCENARIO_ID}-3" is not found + And resource "configmap/cod-demo-settings" is not found diff --git a/test/e2e/platform/suite_test.go b/test/e2e/platform/suite_test.go index 5aa3f7f4ab..678cefc029 100644 --- a/test/e2e/platform/suite_test.go +++ b/test/e2e/platform/suite_test.go @@ -7,6 +7,7 @@ import ( "github.com/cucumber/godog" "github.com/operator-framework/operator-controller/test/internal/e2e" + objectsteps "github.com/operator-framework/operator-controller/test/internal/e2e/object-controller/steps" operatorsteps "github.com/operator-framework/operator-controller/test/internal/e2e/operator-controller/steps" platformsteps "github.com/operator-framework/operator-controller/test/internal/e2e/platform/steps" "github.com/operator-framework/operator-controller/test/internal/e2e/steps" @@ -17,6 +18,7 @@ func TestMain(m *testing.M) { tc.BeforeSuite(steps.BeforeSuite) }, func(sc *godog.ScenarioContext) { steps.RegisterSteps(sc) + objectsteps.RegisterSteps(sc) operatorsteps.RegisterSteps(sc) platformsteps.RegisterDemoSteps(sc) steps.RegisterHooks(sc, platformsteps.RegisterAsciiCastHooks) diff --git a/test/internal/e2e/platform/steps/demo_steps.go b/test/internal/e2e/platform/steps/demo_steps.go index feb4a07792..931e1453b0 100644 --- a/test/internal/e2e/platform/steps/demo_steps.go +++ b/test/internal/e2e/platform/steps/demo_steps.go @@ -20,6 +20,8 @@ import ( ) func RegisterDemoSteps(sc *godog.ScenarioContext) { + sc.Step(`^(?i)resource "([^"]+)"(?: matching field selector "([^"]+)")? is displayed with columns "([^"]+)"$`, ResourceDisplayedWithColumns) + sc.Step(`^(?i)resource "([^"]+)" is controlled by ClusterObjectSet "([^"]+)"$`, ResourceControlledByClusterObjectSet) sc.Step(`^(?i)catalog "([^"]+)" reports ([[:alnum:]]+) as ([[:alnum:]]+)$`, CatalogReportsConditionWithoutReason) sc.Step(`^(?i)catalog "([^"]+)" contains some packages$`, CatalogContainsSomePackages) sc.Step(`^(?i)package "([^"]+)" in catalog "([^"]+)" has some channels defined$`, PackageHasSomeChannels) diff --git a/test/internal/e2e/platform/steps/resource_display.go b/test/internal/e2e/platform/steps/resource_display.go new file mode 100644 index 0000000000..ab6a7c8232 --- /dev/null +++ b/test/internal/e2e/platform/steps/resource_display.go @@ -0,0 +1,23 @@ +package steps + +import ( + "context" + + shared "github.com/operator-framework/operator-controller/test/internal/e2e/steps" +) + +// ResourceDisplayedWithColumns records a concise resource view after the scenario's +// assertions have established readiness. An optional field selector scopes lists. +func ResourceDisplayedWithColumns(ctx context.Context, resource, fieldSelector, columns string) error { + sc := shared.Scenario(ctx) + args := []string{ + "get", shared.SubstituteScenarioVars(resource, sc), + "-n", sc.Namespace, + "-o", "custom-columns=" + shared.SubstituteScenarioVars(columns, sc), + } + if fieldSelector != "" { + args = append(args, "--field-selector", shared.SubstituteScenarioVars(fieldSelector, sc)) + } + _, err := shared.K8sClient(ctx, args...) + return err +} diff --git a/test/internal/e2e/platform/steps/resource_display_test.go b/test/internal/e2e/platform/steps/resource_display_test.go new file mode 100644 index 0000000000..b6861bb9b8 --- /dev/null +++ b/test/internal/e2e/platform/steps/resource_display_test.go @@ -0,0 +1,78 @@ +package steps_test + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/cucumber/godog" + "github.com/spf13/pflag" + "github.com/stretchr/testify/require" + + platformsteps "github.com/operator-framework/operator-controller/test/internal/e2e/platform/steps" + shared "github.com/operator-framework/operator-controller/test/internal/e2e/steps" +) + +func TestResourceDisplayedWithColumns(t *testing.T) { + for _, tt := range []struct { + name string + resource string + selector string + want string + }{ + { + name: "named resource", + resource: "configmap/settings-${SCENARIO_ID}", + want: "get configmap/settings-demo -n demo-namespace -o custom-columns=NAME:.metadata.name,MESSAGE:.data.message", + }, + { + name: "revision history selected by group", + resource: "clusterobjectsets", + selector: "spec.group=cod-${SCENARIO_ID}", + want: "get clusterobjectsets -n demo-namespace -o custom-columns=NAME:.metadata.name,MESSAGE:.data.message --field-selector spec.group=cod-demo", + }, + } { + t.Run(tt.name, func(t *testing.T) { + cli, err := exec.LookPath("echo") + require.NoError(t, err) + original := pflag.Lookup("k8s.cli").Value.String() + require.NoError(t, pflag.Set("k8s.cli", cli)) + t.Cleanup(func() { require.NoError(t, pflag.Set("k8s.cli", original)) }) + + ctx, err := shared.CreateScenarioContext(context.Background(), &godog.Scenario{Name: "Display resources"}) + require.NoError(t, err) + shared.Scenario(ctx).ID = "demo" + shared.Scenario(ctx).Namespace = "demo-namespace" + dir := t.TempDir() + recorder := platformsteps.NewAsciiCastRecorder("Display resources", dir) + ctx = platformsteps.WithRecorder(ctx, recorder) + recorder.BeginStep("Inspect resource ownership") + + err = platformsteps.ResourceDisplayedWithColumns(ctx, tt.resource, tt.selector, "NAME:.metadata.name,MESSAGE:.data.message") + require.NoError(t, err) + recorder.CommitStep() + require.NoError(t, recorder.Stop()) + content, err := os.ReadFile(filepath.Join(dir, "display-resources.cast")) + require.NoError(t, err) + require.Contains(t, string(content), tt.want) + require.Contains(t, string(content), "Inspect resource ownership") + require.NotContains(t, string(content), "${") + }) + } +} + +func TestResourceDisplayedWithColumnsPropagatesCLIError(t *testing.T) { + cli, err := exec.LookPath("false") + require.NoError(t, err) + original := pflag.Lookup("k8s.cli").Value.String() + require.NoError(t, pflag.Set("k8s.cli", cli)) + t.Cleanup(func() { require.NoError(t, pflag.Set("k8s.cli", original)) }) + ctx, err := shared.CreateScenarioContext(context.Background(), &godog.Scenario{}) + require.NoError(t, err) + + err = platformsteps.ResourceDisplayedWithColumns(ctx, "configmap/missing", "", "NAME:.metadata.name") + var exitErr *exec.ExitError + require.ErrorAs(t, err, &exitErr) +} diff --git a/test/internal/e2e/platform/steps/resource_owner.go b/test/internal/e2e/platform/steps/resource_owner.go new file mode 100644 index 0000000000..feed254f09 --- /dev/null +++ b/test/internal/e2e/platform/steps/resource_owner.go @@ -0,0 +1,40 @@ +package steps + +import ( + "context" + "fmt" + "strings" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + shared "github.com/operator-framework/operator-controller/test/internal/e2e/steps" +) + +// ResourceControlledByClusterObjectSet verifies the controller owner by UID after +// the named revision is Ready, so merely retaining an old owner name cannot pass. +func ResourceControlledByClusterObjectSet(ctx context.Context, resource, revision string) error { + sc := shared.Scenario(ctx) + resource = shared.SubstituteScenarioVars(resource, sc) + revision = shared.SubstituteScenarioVars(revision, sc) + kind, name, found := strings.Cut(resource, "/") + if !found { + return fmt.Errorf("resource %s is not in the format /", resource) + } + obj, err := shared.GetResource(kind, name, sc.Namespace) + if err != nil { + return err + } + cos, err := shared.GetResource("clusterobjectset", revision, "") + if err != nil { + return err + } + owner := metav1.GetControllerOf(obj) + if owner == nil || owner.APIVersion != cos.GetAPIVersion() || owner.Kind != cos.GetKind() || + owner.Name != cos.GetName() || owner.UID != cos.GetUID() { + return fmt.Errorf("resource %s is not controlled by ClusterObjectSet %s (uid %s)", resource, revision, cos.GetUID()) + } + if rec := shared.CommandRecorderFromContext(ctx); rec != nil { + rec.RecordCustom("", fmt.Sprintf("Controller owner UID: %s\nClusterObjectSet UID: %s\n", owner.UID, cos.GetUID()), "") + } + return nil +} diff --git a/test/internal/e2e/platform/steps/resource_owner_test.go b/test/internal/e2e/platform/steps/resource_owner_test.go new file mode 100644 index 0000000000..196f3d7b8d --- /dev/null +++ b/test/internal/e2e/platform/steps/resource_owner_test.go @@ -0,0 +1,74 @@ +package steps_test + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/cucumber/godog" + "github.com/spf13/pflag" + "github.com/stretchr/testify/require" + + platformsteps "github.com/operator-framework/operator-controller/test/internal/e2e/platform/steps" + shared "github.com/operator-framework/operator-controller/test/internal/e2e/steps" +) + +func TestResourceControlledByClusterObjectSet(t *testing.T) { + for _, tt := range []struct { + name string + ownerUID string + controller bool + wantError bool + }{ + {name: "current revision controls resource", ownerUID: "revision-uid", controller: true}, + {name: "same name with stale UID", ownerUID: "stale-uid", controller: true, wantError: true}, + {name: "non-controller owner", ownerUID: "revision-uid", wantError: true}, + } { + t.Run(tt.name, func(t *testing.T) { + cli := filepath.Join(t.TempDir(), "kubectl") + script := fmt.Sprintf(`#!/bin/sh +case "$2/$3" in + configmap/settings-demo) + echo '{"apiVersion":"v1","kind":"ConfigMap","metadata":{"ownerReferences":[{"apiVersion":"olm.operatorframework.io/v1","kind":"ClusterObjectSet","name":"cod-demo-1","uid":"%s","controller":%t}]}}' + ;; + clusterobjectset/cod-demo-1) + echo '{"apiVersion":"olm.operatorframework.io/v1","kind":"ClusterObjectSet","metadata":{"name":"cod-demo-1","uid":"revision-uid"}}' + ;; + *) exit 1 ;; +esac +`, tt.ownerUID, tt.controller) + require.NoError(t, os.WriteFile(cli, []byte(script), 0o700)) //nolint:gosec // The test CLI must be executable. + original := pflag.Lookup("k8s.cli").Value.String() + require.NoError(t, pflag.Set("k8s.cli", cli)) + t.Cleanup(func() { require.NoError(t, pflag.Set("k8s.cli", original)) }) + ctx, err := shared.CreateScenarioContext(context.Background(), &godog.Scenario{}) + require.NoError(t, err) + shared.Scenario(ctx).ID = "demo" + dir := t.TempDir() + recorder := platformsteps.NewAsciiCastRecorder("Verify ownership", dir) + ctx = platformsteps.WithRecorder(ctx, recorder) + recorder.BeginStep("Verify controller owner UID") + + err = platformsteps.ResourceControlledByClusterObjectSet(ctx, "configmap/settings-${SCENARIO_ID}", "cod-${SCENARIO_ID}-1") + if tt.wantError { + require.ErrorContains(t, err, "not controlled by ClusterObjectSet") + } else { + require.NoError(t, err) + } + recorder.CommitStep() + require.NoError(t, recorder.Stop()) + content, err := os.ReadFile(filepath.Join(dir, "verify-ownership.cast")) + require.NoError(t, err) + if tt.wantError { + require.NotContains(t, string(content), "Controller owner UID:") + require.NotContains(t, string(content), "ClusterObjectSet UID:") + } else { + require.Contains(t, string(content), "Verify controller owner UID") + require.Contains(t, string(content), "Controller owner UID: revision-uid") + require.Contains(t, string(content), "ClusterObjectSet UID: revision-uid") + } + }) + } +}