Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 4 additions & 6 deletions .github/workflows/update-demos.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
17 changes: 17 additions & 0 deletions docs/draft/tutorials/manage-clusterobjectdeployments.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
142 changes: 142 additions & 0 deletions test/e2e/platform/features/cod_demos.feature
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions test/e2e/platform/suite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions test/internal/e2e/platform/steps/demo_steps.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
23 changes: 23 additions & 0 deletions test/internal/e2e/platform/steps/resource_display.go
Original file line number Diff line number Diff line change
@@ -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
}
78 changes: 78 additions & 0 deletions test/internal/e2e/platform/steps/resource_display_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
40 changes: 40 additions & 0 deletions test/internal/e2e/platform/steps/resource_owner.go
Original file line number Diff line number Diff line change
@@ -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 <kind>/<name>", resource)
}
obj, err := shared.GetResource(kind, name, sc.Namespace)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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
}
Loading
Loading