Skip to content
Open
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
49 changes: 48 additions & 1 deletion test/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ Suites register additional component steps when existing features contain integr
assertions: operator-controller uses object assertions, object-controller retains
ClusterExtension integration coverage, and catalogd proxy tests use ClusterExtension
steps. Platform registers ClusterExtension steps for demos. Feature contents,
scenario names, tags, and assertions are unchanged.
scenario names and assertions are preserved. Direct object-controller scenarios
use `@ObjectController` with `@ClusterObjectSet` or `@ClusterObjectDeployment`;
ClusterExtension integration scenarios retain `@BoxcutterRuntime`.

`test/internal/e2e/steps` owns `World` and the `Scenario(ctx)` accessor, generic resource and
condition helpers, catalog/registry prerequisites, RBAC templates, and cleanup.
Expand Down Expand Up @@ -318,6 +320,51 @@ Select `./test/e2e/<component>/...` with the same flags to run just one suite:
go test -count=1 -v ./test/e2e/object-controller/... -args --godog.tags="~@Serial && ~@demo"
```

### Run Standalone Object-Controller Scenarios

Direct scenarios use `Given object-controller is available`. Discovery finds
object-controller in its own namespace; readiness requires its Deployment and
the ClusterObjectSet CRD. Scenarios tagged `@ClusterObjectDeployment` also
require the ClusterObjectDeployment CRD. Permissions come from scenario tags,
independently of feature filenames.

Install only object-controller on a disposable cluster using the existing chart:

```bash
helm template olmv1 ./helm/olmv1 \
--set options.featureSet=experimental \
--set options.objectController.enabled=true \
--set options.operatorController.enabled=false \
--set options.catalogd.enabled=false | kubectl apply -f -
```

Run direct COS and COD scenarios separately, or select both with
`@ObjectController` (equivalently `@ClusterObjectSet,@ClusterObjectDeployment`;
Godog uses a comma for OR):

```bash
go test -count=1 -v ./test/e2e/object-controller/... -args \
--e2e.object-controller-only \
--godog.tags="@ClusterObjectSet && ~@Serial && ~@demo"
go test -count=1 -v ./test/e2e/object-controller/... -args \
--e2e.object-controller-only \
--godog.tags="@ClusterObjectDeployment && ~@Serial && ~@demo"
```

Repeat each selector with `@Serial` and `--godog.concurrency=1` for any serial
scenarios. The standalone flag rejects discovered operator-controller/catalogd
Deployments and any installed ClusterExtension/ClusterCatalog CRDs. Missing
object-controller or a required API fails readiness instead of skipping.

The two ClusterExtension integration features and the revision-pruning scenario
in `group.feature` stay in the object-controller suite, following the ownership
design. They retain `@BoxcutterRuntime`, require the integrated installation, and
are excluded by the direct COS/COD selectors. Aggregate standard runs skip direct
object scenarios when object-controller is absent; experimental Boxcutter runs
require it. Existing `@ProgressDeadline`, `@Serial`, and `@demo` filters still apply.
The legacy `go test ./test/e2e/features_test.go` entry point accepts these same
flags and feature paths for downstream Godog jobs.

### Run Standard and Experimental in Parallel

Both suites can run simultaneously on separate KIND clusters (see the Makefile
Expand Down
174 changes: 174 additions & 0 deletions test/e2e/object-controller/features/cos_object_lifecycle.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
@ObjectController @ClusterObjectSet @COSObjectLifecycle
Feature: Standalone ClusterObjectSet object lifecycle

Background:
Given object-controller is available
And namespace "${TEST_NAMESPACE}" is available

Scenario: Objects omitted from a successor are collected while retained objects are preserved
# COS phases are immutable. Apply a new COS revision directly, without a COD.
When ClusterObjectSet is applied
"""
apiVersion: olm.operatorframework.io/v1
kind: ClusterObjectSet
metadata:
name: ${NAME}-1
spec:
group: ${NAME}
revision: 1
lifecycleState: Active
collisionProtection: Prevent
phases:
- name: config
objects:
- type: InlineManifest
inlineManifest:
apiVersion: v1
kind: ConfigMap
metadata:
name: retained
namespace: ${TEST_NAMESPACE}
data:
value: unchanged
- type: InlineManifest
inlineManifest:
apiVersion: v1
kind: ConfigMap
metadata:
name: removed
namespace: ${TEST_NAMESPACE}
data:
value: obsolete
"""
Then ClusterObjectSet "${NAME}-1" reports Ready as True with Reason AllObjectsReady
And resource "configmap/removed" is installed
And resource "configmap/retained" UID is remembered
When ClusterObjectSet is applied
"""
apiVersion: olm.operatorframework.io/v1
kind: ClusterObjectSet
metadata:
name: ${NAME}-2
spec:
group: ${NAME}
revision: 2
lifecycleState: Active
collisionProtection: Prevent
phases:
- name: config
objects:
- type: InlineManifest
inlineManifest:
apiVersion: v1
kind: ConfigMap
metadata:
name: retained
namespace: ${TEST_NAMESPACE}
data:
value: unchanged
"""
Then ClusterObjectSet "${NAME}-2" reports Ready as True with Reason AllObjectsReady
And ClusterObjectSet "${NAME}-1" is archived
And resource "configmap/removed" is eventually not found
And resource "configmap/retained" keeps its UID
And resource "configmap/retained" matches
"""
data:
value: unchanged
"""
And ClusterObjectSet "${NAME}-2" reports aggregate object counts: total=1, present=1, synced=1, ready=1

Scenario: Later phases stay blocked across reconciles until each earlier probe passes
When ClusterObjectSet is applied
"""
apiVersion: olm.operatorframework.io/v1
kind: ClusterObjectSet
metadata:
name: ${COS_NAME}
spec:
group: ${NAME}
revision: 1
lifecycleState: Active
collisionProtection: Prevent
progressionProbes:
- selector:
type: Label
label:
matchLabels:
probe: gated
assertions:
- type: FieldValue
fieldValue:
fieldPath: metadata.annotations.ready
value: "true"
phases:
- name: first
objects:
- type: InlineManifest
inlineManifest:
apiVersion: v1
kind: ConfigMap
metadata:
name: first
namespace: ${TEST_NAMESPACE}
labels:
probe: gated
data:
phase: first
- name: second
objects:
- type: InlineManifest
inlineManifest:
apiVersion: v1
kind: ConfigMap
metadata:
name: second
namespace: ${TEST_NAMESPACE}
labels:
probe: gated
data:
phase: second
- name: third
objects:
- type: InlineManifest
inlineManifest:
apiVersion: v1
kind: ConfigMap
metadata:
name: third
namespace: ${TEST_NAMESPACE}
data:
phase: third
"""
Then resource "configmap/first" is installed
And ClusterObjectSet "${COS_NAME}" reports Ready as False with Reason ObjectsNotReady
And ClusterObjectSet "${COS_NAME}" keeps later phase resources absent for "5s":
| resource |
| configmap/second |
| configmap/third |
# Restoration proves the controller reconciled again with the probe blocked.
When resource "configmap/first" is removed
Then resource "configmap/first" is eventually restored
And ClusterObjectSet "${COS_NAME}" keeps later phase resources absent for "5s":
| resource |
| configmap/second |
| configmap/third |
# The readiness annotation is external to the immutable COS manifest.
When annotations are added to "configmap/first"
| key | value |
| ready | true |
Then resource "configmap/second" is installed
And ClusterObjectSet "${COS_NAME}" keeps later phase resources absent for "5s":
| resource |
| configmap/third |
When resource "configmap/second" is removed
Then resource "configmap/second" is eventually restored
And ClusterObjectSet "${COS_NAME}" keeps later phase resources absent for "5s":
| resource |
| configmap/third |
When annotations are added to "configmap/second"
| key | value |
| ready | true |
Then ClusterObjectSet "${COS_NAME}" reports Ready as True with Reason AllObjectsReady
And resource "configmap/third" is installed
And ClusterObjectSet "${COS_NAME}" reports aggregate object counts: total=3, present=3, synced=3, ready=3
1 change: 1 addition & 0 deletions test/e2e/object-controller/features/revision.feature
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,7 @@ Feature: Install ClusterObjectSet
And resource "configmap/test-configmap-3" is installed
And ClusterObjectSet "${COS_NAME}" reports Ready as True with Reason AllObjectsReady

@COSObjectLifecycle
Scenario: User can install a ClusterObjectSet with objects stored in Secrets
Given namespace "${TEST_NAMESPACE}" is available
When resource is applied
Expand Down
69 changes: 69 additions & 0 deletions test/internal/e2e/object-controller/steps/lifecycle_steps.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package steps

import (
"context"
"fmt"
"strings"
"time"

"github.com/cucumber/godog"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/wait"
"k8s.io/utils/ptr"

ocv1 "github.com/operator-framework/operator-controller/api/v1"
shared "github.com/operator-framework/operator-controller/test/internal/e2e/steps"
)

// ClusterObjectSetKeepsLaterPhaseResourcesAbsent observes a blocked COS and all
// listed resources throughout a window. An API error cannot establish absence.
func ClusterObjectSetKeepsLaterPhaseResourcesAbsent(ctx context.Context, name, durationStr string, table *godog.Table) error {
duration, err := time.ParseDuration(durationStr)
if err != nil || duration <= 0 {
return fmt.Errorf("expected a positive observation duration, got %q", durationStr)
}
if table == nil || len(table.Rows) < 2 || len(table.Rows[0].Cells) != 1 || table.Rows[0].Cells[0].Value != "resource" {
return fmt.Errorf("expected a resource table with a 'resource' header and at least one row")
}

sc := shared.Scenario(ctx)
name = shared.SubstituteScenarioVars(name, sc)
type resourceRef struct{ kind, name string }
resources := make([]resourceRef, 0, len(table.Rows)-1)
for _, row := range table.Rows[1:] {
if len(row.Cells) != 1 {
return fmt.Errorf("expected one resource per row")
}
resource := shared.SubstituteScenarioVars(row.Cells[0].Value, sc)
kind, resourceName, found := strings.Cut(resource, "/")
if !found || kind == "" || resourceName == "" || strings.Contains(resourceName, "/") {
return fmt.Errorf("resource %q is not in the format <kind>/<name>", resource)
}
resources = append(resources, resourceRef{kind, resourceName})
}

var start time.Time
return wait.PollUntilContextTimeout(ctx, shared.Tick, duration+shared.Timeout, true, func(ctx context.Context) (bool, error) {
resource, err := shared.ReadResourceConditions(ctx, "clusterobjectset", name)
if err != nil {
return false, err
}
ready := shared.CurrentCondition(resource.Metadata.Generation, resource.Status.Conditions, ocv1.ClusterObjectSetTypeReady)
if !shared.ConditionMatches(ready, metav1.ConditionFalse, ptr.To(ocv1.ClusterObjectSetReasonObjectsNotReady), nil) {
return false, fmt.Errorf("ClusterObjectSet %q no longer reports current-generation Ready=False/ObjectsNotReady: %v", name, ready)
}
for _, ref := range resources {
out, err := shared.K8sClient(ctx, "get", ref.kind, ref.name, "-n", sc.Namespace, "--ignore-not-found", "-o", "name")
if err != nil {
return false, fmt.Errorf("checking absence of %s/%s: %w", ref.kind, ref.name, err)
}
if strings.TrimSpace(out) != "" {
return false, fmt.Errorf("later phase resource %s/%s appeared while ClusterObjectSet %q was blocked", ref.kind, ref.name, name)
}
}
if start.IsZero() {
start = time.Now()
}
return time.Since(start) >= duration, nil
})
}
Loading
Loading