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
4 changes: 3 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -243,9 +243,10 @@ verify-crd-compatibility: $(CRD_DIFF) manifests
.PHONY: test
test: manifests generate fmt lint test-unit test-e2e test-regression #HELP Run all tests.

E2E_TIMEOUT ?= 10m
.PHONY: e2e
e2e: #EXHELP Run the e2e tests.
go test -count=1 -v ./test/e2e/features_test.go
go test -count=1 -v ./test/e2e/features_test.go -timeout=$(E2E_TIMEOUT)

E2E_REGISTRY_NAME := docker-registry
E2E_REGISTRY_NAMESPACE := operator-controller-e2e
Expand Down Expand Up @@ -317,6 +318,7 @@ test-experimental-e2e: GO_BUILD_EXTRA_FLAGS := -cover
test-experimental-e2e: COVERAGE_NAME := experimental-e2e
test-experimental-e2e: export MANIFEST := $(EXPERIMENTAL_RELEASE_MANIFEST)
test-experimental-e2e: PROMETHEUS_VALUES := helm/prom_experimental.yaml
test-experimental-e2e: E2E_TIMEOUT := 15m
test-experimental-e2e: run-internal image-registry prometheus e2e e2e-coverage kind-clean #HELP Run experimental e2e test suite on local kind cluster

.PHONY: prometheus
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"strings"
"sync"
"time"

appsv1 "k8s.io/api/apps/v1"
Expand Down Expand Up @@ -462,10 +463,13 @@ func (c *ClusterExtensionRevisionReconciler) toBoxcutterRevision(ctx context.Con
previousObjs[i] = rev
}

if err = initializeProbes(); err != nil {
return nil, nil, err
}
opts := []boxcutter.RevisionReconcileOption{
boxcutter.WithPreviousOwners(previousObjs),
boxcutter.WithProbe(boxcutter.ProgressProbeType, probing.And{
deploymentProbe, statefulSetProbe, crdProbe, issuerProbe, certProbe,
&namespaceActiveProbe, deploymentProbe, statefulSetProbe, crdProbe, issuerProbe, certProbe, &pvcBoundProbe,
}),
}

Expand Down Expand Up @@ -511,6 +515,28 @@ func EffectiveCollisionProtection(cp ...ocv1.CollisionProtection) ocv1.Collision
return ecp
}

// initializeProbes is used to initialize CEL probes once, so we don't recreate them on every reconcile
var initializeProbes = sync.OnceValue(func() error {
nsCEL, err := probing.NewCELProbe(namespaceActiveCEL, `namespace phase must be "Active"`)
if err != nil {
return fmt.Errorf("initializing namespace CEL probe: %w", err)
}
pvcCEL, err := probing.NewCELProbe(pvcBoundCEL, `persistentvolumeclaim phase must be "Bound"`)
if err != nil {
return fmt.Errorf("initializing PVC CEL probe: %w", err)
}
namespaceActiveProbe = probing.GroupKindSelector{
GroupKind: schema.GroupKind{Group: corev1.GroupName, Kind: "Namespace"},
Prober: nsCEL,
}
pvcBoundProbe = probing.GroupKindSelector{
GroupKind: schema.GroupKind{Group: corev1.GroupName, Kind: "PersistentVolumeClaim"},
Prober: pvcCEL,
}

return nil
})

var (
deploymentProbe = &probing.GroupKindSelector{
GroupKind: schema.GroupKind{Group: appsv1.GroupName, Kind: "Deployment"},
Expand Down Expand Up @@ -542,6 +568,14 @@ var (
},
}

// namespaceActiveCEL is a CEL rule which asserts that the namespace is in "Active" phase
namespaceActiveCEL = `self.status.phase == "Active"`
namespaceActiveProbe probing.GroupKindSelector

// pvcBoundCEL is a CEL rule which asserts that the PVC is in "Bound" phase
pvcBoundCEL = `self.status.phase == "Bound"`
pvcBoundProbe probing.GroupKindSelector

// deplStaefulSetProbe probes Deployment, StatefulSet objects.
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment for deplStatefulSetProbe is misspelled/inaccurate (deplStaefulSetProbe). This makes the identifier hard to search for and is inconsistent with the actual variable name. Update the comment to match deplStatefulSetProbe (and correct “Stateful”).

Suggested change
// deplStaefulSetProbe probes Deployment, StatefulSet objects.
// deplStatefulSetProbe probes Deployment, StatefulSet objects.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't change this line, I can keep it in mind for a future PR though

deplStatefulSetProbe = &probing.ObservedGenerationProbe{
Prober: probing.And{
Expand Down
1 change: 1 addition & 0 deletions test/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ Leverage existing steps for common operations:
Use these variables in YAML templates:

- `${NAME}`: Scenario-specific ClusterExtension name (e.g., `ce-123`)
- `${CER_NAME}`: Scenario-specific ClusterExtensionRevision name (e.g., `cer-123`; for applying CERs directly)
- `${TEST_NAMESPACE}`: Scenario-specific namespace (e.g., `ns-123`)
- `${CATALOG_IMG}`: Catalog image reference (defaults to in-cluster registry, overridable via `CATALOG_IMG` env var)

Expand Down
137 changes: 137 additions & 0 deletions test/e2e/features/revision.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
@BoxcutterRuntime
Feature: Install ClusterExtensionRevision

As an OLM user I would like to install a cluster extension revision directly, without using the cluster extension API.

Background:
Given OLM is available
And ServiceAccount "olm-sa" with needed permissions is available in ${TEST_NAMESPACE}

Scenario: Probe failure for PersistentVolumeClaim halts phase progression
When ClusterExtensionRevision is applied
"""
apiVersion: olm.operatorframework.io/v1
kind: ClusterExtensionRevision
metadata:
annotations:
olm.operatorframework.io/service-account-name: olm-sa
olm.operatorframework.io/service-account-namespace: ${TEST_NAMESPACE}
Comment on lines +16 to +18
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are these annotations required for reconciliation to work?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah =/ this is a bit of a rough edge since we don't expose the service account configuration in the .spec. I think it's ok to leave it like this for now since we'll need to act on it once we have a decision on the OLM MVP and whether we want to keep the service account or not.

name: ${CER_NAME}
spec:
lifecycleState: Active
collisionProtection: Prevent
phases:
- name: pvc
objects:
- object:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: test-pvc
namespace: ${TEST_NAMESPACE}
spec:
accessModes:
- ReadWriteOnce
storageClassName: ""
volumeName: test-pv
resources:
requests:
storage: 1Mi
- name: configmap
objects:
- object:
apiVersion: v1
kind: ConfigMap
metadata:
name: test-configmap
namespace: ${TEST_NAMESPACE}
data:
name: test-configmap
version: v1.2.0
revision: 1
"""

Then resource "persistentvolumeclaim/test-pvc" is installed
And ClusterExtensionRevision "${CER_NAME}" reports Available as False with Reason ProbeFailure and Message:
"""
Object PersistentVolumeClaim.v1 ${TEST_NAMESPACE}/test-pvc: persistentvolumeclaim phase must be "Bound"
"""
And resource "configmap/test-configmap" is not installed

Scenario: Phases progress when PersistentVolumeClaim becomes "Bound"
When ClusterExtensionRevision is applied
"""
apiVersion: olm.operatorframework.io/v1
kind: ClusterExtensionRevision
metadata:
annotations:
olm.operatorframework.io/service-account-name: olm-sa
olm.operatorframework.io/service-account-namespace: ${TEST_NAMESPACE}
name: ${CER_NAME}
spec:
lifecycleState: Active
collisionProtection: Prevent
phases:
- name: pvc
objects:
- object:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: test-pvc
namespace: ${TEST_NAMESPACE}
spec:
accessModes:
- ReadWriteOnce
storageClassName: ""
volumeName: test-pv
resources:
requests:
storage: 1Mi
- object:
apiVersion: v1
kind: PersistentVolume
metadata:
name: test-pv
spec:
accessModes:
- ReadWriteOnce
capacity:
storage: 1Mi
claimRef:
apiVersion: v1
kind: PersistentVolumeClaim
name: test-pvc
namespace: ${TEST_NAMESPACE}
persistentVolumeReclaimPolicy: Delete
storageClassName: ""
volumeMode: Filesystem
local:
path: /tmp/persistent-volume
Comment on lines +108 to +110
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this works under kind, does it work under openshift?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does, I just tested it (aws). I can try a few other cluster types too just in case.

nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: NotIn
values:
- a-node-name-that-should-not-exist
- name: configmap
objects:
- object:
apiVersion: v1
kind: ConfigMap
metadata:
name: test-configmap
namespace: ${TEST_NAMESPACE}
data:
name: test-configmap
version: v1.2.0
revision: 1
"""

Then ClusterExtensionRevision "${CER_NAME}" reports Progressing as True with Reason Succeeded
And ClusterExtensionRevision "${CER_NAME}" reports Available as True with Reason ProbesSucceeded
And resource "persistentvolume/test-pv" is installed
And resource "persistentvolumeclaim/test-pvc" is installed
And resource "configmap/test-configmap" is installed
35 changes: 20 additions & 15 deletions test/e2e/steps/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,13 @@ type resource struct {
}

type scenarioContext struct {
id string
namespace string
clusterExtensionName string
removedResources []unstructured.Unstructured
backGroundCmds []*exec.Cmd
metricsResponse map[string]string
id string
namespace string
clusterExtensionName string
clusterExtensionRevisionName string
removedResources []unstructured.Unstructured
backGroundCmds []*exec.Cmd
metricsResponse map[string]string

extensionObjects []client.Object
}
Expand Down Expand Up @@ -142,9 +143,10 @@ func CheckFeatureTags(ctx context.Context, sc *godog.Scenario) (context.Context,

func CreateScenarioContext(ctx context.Context, sc *godog.Scenario) (context.Context, error) {
scCtx := &scenarioContext{
id: sc.Id,
namespace: fmt.Sprintf("ns-%s", sc.Id),
clusterExtensionName: fmt.Sprintf("ce-%s", sc.Id),
id: sc.Id,
namespace: fmt.Sprintf("ns-%s", sc.Id),
clusterExtensionName: fmt.Sprintf("ce-%s", sc.Id),
clusterExtensionRevisionName: fmt.Sprintf("cer-%s", sc.Id),
}
return context.WithValue(ctx, scenarioContextKey, scCtx), nil
}
Expand Down Expand Up @@ -176,13 +178,16 @@ func ScenarioCleanup(ctx context.Context, _ *godog.Scenario, err error) (context
if sc.clusterExtensionName != "" {
forDeletion = append(forDeletion, resource{name: sc.clusterExtensionName, kind: "clusterextension"})
}
if sc.clusterExtensionRevisionName != "" {
forDeletion = append(forDeletion, resource{name: sc.clusterExtensionRevisionName, kind: "clusterextensionrevision"})
}
forDeletion = append(forDeletion, resource{name: sc.namespace, kind: "namespace"})
go func() {
for _, r := range forDeletion {
if _, err := k8sClient("delete", r.kind, r.name, "--ignore-not-found=true"); err != nil {
logger.Info("Error deleting resource", "name", r.name, "namespace", sc.namespace, "stderr", stderrOutput(err))
for _, r := range forDeletion {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how is this change related to the PR?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before the change, in my test runs, the after hook would only delete a single item in forDeletion (the clusterextension) and would leave remaining resources (namespaces and owner-less clusterextensionrevisions) on the cluster. This fixed that and also made the deletion run more quickly by executing them asynchronously.

go func(res resource) {
if _, err := k8sClient("delete", res.kind, res.name, "--ignore-not-found=true"); err != nil {
logger.Info("Error deleting resource", "name", res.name, "namespace", sc.namespace, "stderr", stderrOutput(err))
}
}
}()
}(r)
}
return ctx, nil
}
39 changes: 27 additions & 12 deletions test/e2e/steps/steps.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@ func RegisterSteps(sc *godog.ScenarioContext) {
sc.Step(`^(?i)ClusterExtension reports ([[:alnum:]]+) as ([[:alnum:]]+) with Reason ([[:alnum:]]+)$`, ClusterExtensionReportsConditionWithoutMsg)
sc.Step(`^(?i)ClusterExtension reports ([[:alnum:]]+) as ([[:alnum:]]+)$`, ClusterExtensionReportsConditionWithoutReason)
sc.Step(`^(?i)ClusterExtensionRevision "([^"]+)" reports ([[:alnum:]]+) as ([[:alnum:]]+) with Reason ([[:alnum:]]+)$`, ClusterExtensionRevisionReportsConditionWithoutMsg)
sc.Step(`^(?i)ClusterExtensionRevision "([^"]+)" reports ([[:alnum:]]+) as ([[:alnum:]]+) with Reason ([[:alnum:]]+) and Message:$`, ClusterExtensionRevisionReportsConditionWithMsg)
sc.Step(`^(?i)ClusterExtension reports ([[:alnum:]]+) transition between (\d+) and (\d+) minutes since its creation$`, ClusterExtensionReportsConditionTransitionTime)
sc.Step(`^(?i)ClusterExtensionRevision is applied(?:\s+.*)?$`, ResourceIsApplied)
sc.Step(`^(?i)ClusterExtensionRevision "([^"]+)" is archived$`, ClusterExtensionRevisionIsArchived)
sc.Step(`^(?i)ClusterExtensionRevision "([^"]+)" contains annotation "([^"]+)" with value$`, ClusterExtensionRevisionHasAnnotationWithValue)
sc.Step(`^(?i)ClusterExtensionRevision "([^"]+)" has label "([^"]+)" with value "([^"]+)"$`, ClusterExtensionRevisionHasLabelWithValue)
Expand All @@ -80,7 +82,7 @@ func RegisterSteps(sc *godog.ScenarioContext) {
sc.Step(`^(?i)resource "([^"]+)" is installed$`, ResourceAvailable)
sc.Step(`^(?i)resource "([^"]+)" is available$`, ResourceAvailable)
sc.Step(`^(?i)resource "([^"]+)" is removed$`, ResourceRemoved)
sc.Step(`^(?i)resource "([^"]+)" is eventually not found$`, ResourceEventuallyNotFound)
sc.Step(`^(?i)resource "([^"]+)" is (?:eventually not found|not installed)$`, ResourceEventuallyNotFound)
sc.Step(`^(?i)resource "([^"]+)" exists$`, ResourceAvailable)
sc.Step(`^(?i)resource is applied$`, ResourceIsApplied)
sc.Step(`^(?i)resource "deployment/test-operator" reports as (not ready|ready)$`, MarkTestOperatorNotReady)
Expand Down Expand Up @@ -186,6 +188,7 @@ func substituteScenarioVars(content string, sc *scenarioContext) string {
vars := map[string]string{
"TEST_NAMESPACE": sc.namespace,
"NAME": sc.clusterExtensionName,
"CER_NAME": sc.clusterExtensionRevisionName,
"CATALOG_IMG": "docker-registry.operator-controller-e2e.svc.cluster.local:5000/e2e/test-catalog:v1",
}
if v, found := os.LookupEnv("CATALOG_IMG"); found {
Expand Down Expand Up @@ -235,8 +238,8 @@ func ClusterExtensionVersionUpdate(ctx context.Context, version string) error {
return err
}

// ResourceIsApplied applies the provided YAML resource to the cluster and in case of ClusterExtension it captures its name in the test context
// so that it can be referred to in later steps with ${NAME}
// ResourceIsApplied applies the provided YAML resource to the cluster and in case of ClusterExtension or ClusterExtensionRevision it captures
// its name in the test context so that it can be referred to in later steps with ${NAME} or ${CER_NAME}, respectively
func ResourceIsApplied(ctx context.Context, yamlTemplate *godog.DocString) error {
sc := scenarioCtx(ctx)
yamlContent := substituteScenarioVars(yamlTemplate.Content, sc)
Expand All @@ -246,10 +249,12 @@ func ResourceIsApplied(ctx context.Context, yamlTemplate *godog.DocString) error
}
out, err := k8scliWithInput(yamlContent, "apply", "-f", "-")
if err != nil {
return fmt.Errorf("failed to apply resource %v %w", out, err)
return fmt.Errorf("failed to apply resource %v; err: %w; stderr: %s", out, err, stderrOutput(err))
}
if res.GetKind() == "ClusterExtension" {
sc.clusterExtensionName = res.GetName()
} else if res.GetKind() == "ClusterExtensionRevision" {
sc.clusterExtensionRevisionName = res.GetName()
}
return nil
}
Expand Down Expand Up @@ -378,6 +383,17 @@ type msgMatchFn func(string) bool

func alwaysMatch(_ string) bool { return true }

func messageComparison(ctx context.Context, msg *godog.DocString) msgMatchFn {
msgCmp := alwaysMatch
if msg != nil {
expectedMsg := substituteScenarioVars(strings.Join(strings.Fields(msg.Content), " "), scenarioCtx(ctx))
msgCmp = func(actual string) bool {
return actual == expectedMsg
}
}
return msgCmp
}

func waitForCondition(ctx context.Context, resourceType, resourceName, conditionType, conditionStatus string, conditionReason *string, msgCmp msgMatchFn) error {
require.Eventually(godog.T(ctx), func() bool {
v, err := k8sClient("get", resourceType, resourceName, "-o", fmt.Sprintf("jsonpath={.status.conditions[?(@.type==\"%s\")]}", conditionType))
Expand Down Expand Up @@ -412,14 +428,7 @@ func waitForExtensionCondition(ctx context.Context, conditionType, conditionStat
// ClusterExtensionReportsCondition waits for the ClusterExtension to have a condition matching the specified type,
// status, reason, and exact message. Polls with timeout.
func ClusterExtensionReportsCondition(ctx context.Context, conditionType, conditionStatus, conditionReason string, msg *godog.DocString) error {
msgCmp := alwaysMatch
if msg != nil {
expectedMsg := substituteScenarioVars(strings.Join(strings.Fields(msg.Content), " "), scenarioCtx(ctx))
msgCmp = func(actual string) bool {
return actual == expectedMsg
}
}
return waitForExtensionCondition(ctx, conditionType, conditionStatus, &conditionReason, msgCmp)
return waitForExtensionCondition(ctx, conditionType, conditionStatus, &conditionReason, messageComparison(ctx, msg))
}

// ClusterExtensionReportsConditionWithMessageFragment waits for the ClusterExtension to have a condition matching
Expand Down Expand Up @@ -512,6 +521,12 @@ func ClusterExtensionRevisionReportsConditionWithoutMsg(ctx context.Context, rev
return waitForCondition(ctx, "clusterextensionrevision", substituteScenarioVars(revisionName, scenarioCtx(ctx)), conditionType, conditionStatus, &conditionReason, nil)
}

// ClusterExtensionRevisionReportsConditionWithMsg waits for the named ClusterExtensionRevision to have a condition
// matching type, status, reason, and message. Polls with timeout.
func ClusterExtensionRevisionReportsConditionWithMsg(ctx context.Context, revisionName, conditionType, conditionStatus, conditionReason string, msg *godog.DocString) error {
return waitForCondition(ctx, "clusterextensionrevision", substituteScenarioVars(revisionName, scenarioCtx(ctx)), conditionType, conditionStatus, &conditionReason, messageComparison(ctx, msg))
}

// ClusterExtensionRevisionIsArchived waits for the named ClusterExtensionRevision to have Progressing=False
// with reason Archived. Polls with timeout.
func ClusterExtensionRevisionIsArchived(ctx context.Context, revisionName string) error {
Expand Down
2 changes: 2 additions & 0 deletions test/e2e/steps/testdata/rbac-template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ rules:
- serviceaccounts
- events
- namespaces
- persistentvolumes
- persistentvolumeclaims
verbs: [update, create, list, watch, get, delete, patch]
- apiGroups: ["apps"]
resources:
Expand Down
Loading