From 54413a9edd24fac6fd122e7f0f977ed3ad1e230a Mon Sep 17 00:00:00 2001 From: Camila Macedo <7708031+camilamacedo86@users.noreply.github.com> Date: Mon, 2 Feb 2026 10:50:07 +0000 Subject: [PATCH] Use Installing/Upgrading reasons for active operations Show Installing when first install is rolling out and Upgrading when an existing bundle is being updated. This gives users clearer status about what's happening instead of the vague Absent reason. Assisted-by: Cursors/Claude --- api/v1/common_types.go | 4 +- .../conditionsets/conditionsets.go | 2 + .../controllers/common_controller.go | 64 +++- .../controllers/common_controller_test.go | 314 +++++++++++++++++- test/e2e/features/install.feature | 24 ++ test/e2e/features/update.feature | 3 +- test/e2e/steps/steps.go | 88 +++++ 7 files changed, 480 insertions(+), 19 deletions(-) diff --git a/api/v1/common_types.go b/api/v1/common_types.go index 53215dbdee..bd29b271fc 100644 --- a/api/v1/common_types.go +++ b/api/v1/common_types.go @@ -21,7 +21,9 @@ const ( TypeProgressing = "Progressing" // Installed reasons - ReasonAbsent = "Absent" + ReasonAbsent = "Absent" + ReasonInstalling = "Installing" + ReasonUpgrading = "Upgrading" // Progressing reasons ReasonRollingOut = "RollingOut" diff --git a/internal/operator-controller/conditionsets/conditionsets.go b/internal/operator-controller/conditionsets/conditionsets.go index 97073a02d8..812de85e53 100644 --- a/internal/operator-controller/conditionsets/conditionsets.go +++ b/internal/operator-controller/conditionsets/conditionsets.go @@ -43,6 +43,8 @@ var ConditionReasons = []string{ ocv1.ReasonInvalidConfiguration, ocv1.ReasonRetrying, ocv1.ReasonAbsent, + ocv1.ReasonInstalling, + ocv1.ReasonUpgrading, ocv1.ReasonRollingOut, ocv1.ReasonProgressDeadlineExceeded, } diff --git a/internal/operator-controller/controllers/common_controller.go b/internal/operator-controller/controllers/common_controller.go index cb6834c6b6..1067a77282 100644 --- a/internal/operator-controller/controllers/common_controller.go +++ b/internal/operator-controller/controllers/common_controller.go @@ -57,38 +57,56 @@ func setInstalledStatusFromRevisionStates(ext *ocv1.ClusterExtension, revisionSt // Nothing is installed if revisionStates.Installed == nil { setInstallStatus(ext, nil) - reason := determineFailureReason(revisionStates.RollingOut) + reason := determineInstalledReason(revisionStates.RollingOut) setInstalledStatusConditionFalse(ext, reason, "No bundle installed") return } - // Something is installed + + // Something is installed - check if upgrade is in progress installStatus := &ocv1.ClusterExtensionInstallStatus{ Bundle: revisionStates.Installed.BundleMetadata, } setInstallStatus(ext, installStatus) + + if len(revisionStates.RollingOut) > 0 { + latestRevision := revisionStates.RollingOut[len(revisionStates.RollingOut)-1] + progressingCond := apimeta.FindStatusCondition(latestRevision.Conditions, ocv1.ClusterExtensionRevisionTypeProgressing) + + if progressingCond != nil && progressingCond.Reason == string(ocv1.ReasonRollingOut) { + setInstalledStatusConditionUpgrading(ext, fmt.Sprintf("Upgrading from %s", revisionStates.Installed.Image)) + return + } + } + setInstalledStatusConditionSuccess(ext, fmt.Sprintf("Installed bundle %s successfully", revisionStates.Installed.Image)) } -// determineFailureReason determines the appropriate reason for the Installed condition +// determineInstalledReason determines the appropriate reason for the Installed condition // when no bundle is installed (Installed: False). // // Returns Failed when: // - No rolling revisions exist (nothing to install) // - The latest rolling revision has Reason: Retrying (indicates an error occurred) // +// Returns Installing when: +// - The latest rolling revision explicitly has Reason: RollingOut (healthy installation in progress) +// // Returns Absent when: -// - Rolling revisions exist with the latest having Reason: RollingOut (healthy phased rollout in progress) +// - Rolling revisions exist but have no conditions set (rollout just started) // // Rationale: // - Failed: Semantically indicates an error prevented installation -// - Absent: Semantically indicates "not there yet" (neutral state, e.g., during healthy rollout) +// - Installing: Semantically indicates a first-time installation is actively in progress +// - Absent: Neutral state when rollout exists but hasn't progressed enough to determine health // - Retrying reason indicates an error (config validation, apply failure, etc.) -// - RollingOut reason indicates healthy progress (not an error) +// - RollingOut reason indicates confirmed healthy progress // - Only the LATEST revision matters - old errors superseded by newer healthy revisions should not cause Failed // +// Note: This function is only called when Installed == nil (first-time installation scenario). // Note: rollingRevisions are sorted in ascending order by Spec.Revision (oldest to newest), -// so the latest revision is the LAST element in the array. -func determineFailureReason(rollingRevisions []*RevisionMetadata) string { +// +// so the latest revision is the LAST element in the array. +func determineInstalledReason(rollingRevisions []*RevisionMetadata) string { if len(rollingRevisions) == 0 { return ocv1.ReasonFailed } @@ -97,14 +115,21 @@ func determineFailureReason(rollingRevisions []*RevisionMetadata) string { // Latest revision is the last element in the array (sorted ascending by Spec.Revision) latestRevision := rollingRevisions[len(rollingRevisions)-1] progressingCond := apimeta.FindStatusCondition(latestRevision.Conditions, ocv1.ClusterExtensionRevisionTypeProgressing) - if progressingCond != nil && progressingCond.Reason == string(ocv1.ClusterExtensionRevisionReasonRetrying) { - // Retrying indicates an error occurred (config, apply, validation, etc.) - // Use Failed for semantic correctness: installation failed due to error - return ocv1.ReasonFailed + if progressingCond != nil { + if progressingCond.Reason == string(ocv1.ClusterExtensionRevisionReasonRetrying) { + // Retrying indicates an error occurred (config, apply, validation, etc.) + // Use Failed for semantic correctness: installation failed due to error + return ocv1.ReasonFailed + } + if progressingCond.Reason == string(ocv1.ReasonRollingOut) { + // RollingOut indicates healthy progress is confirmed + // Use Installing to communicate that a first-time installation is actively in progress + return ocv1.ReasonInstalling + } } - // No error detected in latest revision - it's progressing healthily (RollingOut) or no conditions set - // Use Absent for neutral "not installed yet" state + // No progressing condition or unknown reason - rollout just started or hasn't progressed + // Use Absent as neutral state return ocv1.ReasonAbsent } @@ -119,6 +144,17 @@ func setInstalledStatusConditionSuccess(ext *ocv1.ClusterExtension, message stri }) } +// setInstalledStatusConditionUpgrading sets the installed status condition to upgrading. +func setInstalledStatusConditionUpgrading(ext *ocv1.ClusterExtension, message string) { + SetStatusCondition(&ext.Status.Conditions, metav1.Condition{ + Type: ocv1.TypeInstalled, + Status: metav1.ConditionTrue, + Reason: ocv1.ReasonUpgrading, + Message: message, + ObservedGeneration: ext.GetGeneration(), + }) +} + // setInstalledStatusConditionFailed sets the installed status condition to failed. func setInstalledStatusConditionFalse(ext *ocv1.ClusterExtension, reason string, message string) { SetStatusCondition(&ext.Status.Conditions, metav1.Condition{ diff --git a/internal/operator-controller/controllers/common_controller_test.go b/internal/operator-controller/controllers/common_controller_test.go index 3cc7575430..d3925dc06d 100644 --- a/internal/operator-controller/controllers/common_controller_test.go +++ b/internal/operator-controller/controllers/common_controller_test.go @@ -253,6 +253,259 @@ func TestSetStatusConditionWrapper(t *testing.T) { } } +func TestSetInstalledStatusFromRevisionStates_Upgrade(t *testing.T) { + tests := []struct { + name string + revisionStates *RevisionStates + expectedInstalledCond metav1.Condition + setupProgressingCond *metav1.Condition + expectedProgressingCond *metav1.Condition + }{ + { + // When operator v1 is running and v2 upgrade starts rolling out + // Then status shows Upgrading to inform users the new version is being deployed + name: "installed bundle with healthy upgrade in progress - uses Upgrading", + revisionStates: &RevisionStates{ + Installed: &RevisionMetadata{ + RevisionName: "rev-1", + Image: "quay.io/example/bundle:v1.0.0", + BundleMetadata: ocv1.BundleMetadata{ + Name: "example.v1.0.0", + Version: "1.0.0", + }, + }, + RollingOut: []*RevisionMetadata{ + { + RevisionName: "rev-2", + Conditions: []metav1.Condition{ + { + Type: ocv1.ClusterExtensionRevisionTypeProgressing, + Status: metav1.ConditionTrue, + Reason: ocv1.ReasonRollingOut, + Message: "Upgrading to v2.0.0", + }, + }, + }, + }, + }, + expectedInstalledCond: metav1.Condition{ + Type: ocv1.TypeInstalled, + Status: metav1.ConditionTrue, + Reason: ocv1.ReasonUpgrading, + Message: "Upgrading from quay.io/example/bundle:v1.0.0", + }, + }, + { + // When operator is installed and no upgrades are happening + // Then status shows Succeeded because everything is stable + name: "installed bundle with no rolling revisions - uses Succeeded", + revisionStates: &RevisionStates{ + Installed: &RevisionMetadata{ + RevisionName: "rev-1", + Image: "quay.io/example/bundle:v1.0.0", + BundleMetadata: ocv1.BundleMetadata{ + Name: "example.v1.0.0", + Version: "1.0.0", + }, + }, + RollingOut: nil, + }, + expectedInstalledCond: metav1.Condition{ + Type: ocv1.TypeInstalled, + Status: metav1.ConditionTrue, + Reason: ocv1.ReasonSucceeded, + }, + }, + { + // When operator v1 is running and v2 upgrade just started but health not confirmed yet + // Then status stays Succeeded because current version still works fine + name: "installed bundle with rolling revision not yet healthy - uses Succeeded", + revisionStates: &RevisionStates{ + Installed: &RevisionMetadata{ + RevisionName: "rev-1", + Image: "quay.io/example/bundle:v1.0.0", + BundleMetadata: ocv1.BundleMetadata{ + Name: "example.v1.0.0", + Version: "1.0.0", + }, + }, + RollingOut: []*RevisionMetadata{ + { + RevisionName: "rev-2", + Conditions: []metav1.Condition{}, + }, + }, + }, + expectedInstalledCond: metav1.Condition{ + Type: ocv1.TypeInstalled, + Status: metav1.ConditionTrue, + Reason: ocv1.ReasonSucceeded, + }, + }, + { + // When operator v1 is running but v2 upgrade fails with config errors + // Then status stays Succeeded because v1 keeps working even though v2 upgrade failed + name: "installed bundle with upgrade error (Retrying) - uses Succeeded", + revisionStates: &RevisionStates{ + Installed: &RevisionMetadata{ + RevisionName: "rev-1", + Image: "quay.io/example/bundle:v1.0.0", + BundleMetadata: ocv1.BundleMetadata{ + Name: "example.v1.0.0", + Version: "1.0.0", + }, + }, + RollingOut: []*RevisionMetadata{ + { + RevisionName: "rev-2", + Conditions: []metav1.Condition{ + { + Type: ocv1.ClusterExtensionRevisionTypeProgressing, + Status: metav1.ConditionTrue, + Reason: ocv1.ClusterExtensionRevisionReasonRetrying, + Message: "Upgrade failed: invalid configuration", + }, + }, + }, + }, + }, + expectedInstalledCond: metav1.Condition{ + Type: ocv1.TypeInstalled, + Status: metav1.ConditionTrue, + Reason: ocv1.ReasonSucceeded, + }, + }, + { + // When operator v1 is running but v2 upgrade has unknown state + // Then status stays Succeeded because v1 is still healthy regardless + name: "installed bundle with upgrade having unknown reason - uses Succeeded", + revisionStates: &RevisionStates{ + Installed: &RevisionMetadata{ + RevisionName: "rev-1", + Image: "quay.io/example/bundle:v1.0.0", + BundleMetadata: ocv1.BundleMetadata{ + Name: "example.v1.0.0", + Version: "1.0.0", + }, + }, + RollingOut: []*RevisionMetadata{ + { + RevisionName: "rev-2", + Conditions: []metav1.Condition{ + { + Type: ocv1.ClusterExtensionRevisionTypeProgressing, + Status: metav1.ConditionTrue, + Reason: "SomeUnknownReason", + Message: "Unknown state", + }, + }, + }, + }, + }, + expectedInstalledCond: metav1.Condition{ + Type: ocv1.TypeInstalled, + Status: metav1.ConditionTrue, + Reason: ocv1.ReasonSucceeded, + }, + }, + { + // When operator v1 is running but multiple upgrade attempts keep failing + // Then users see both conditions showing the complete picture: + // Installed: True, Reason: Succeeded - current v1 is working fine + // Progressing: True, Reason: Retrying - v2 upgrade is failing + name: "installed bundle with multiple rolling revisions including errors - uses Succeeded", + revisionStates: &RevisionStates{ + Installed: &RevisionMetadata{ + RevisionName: "rev-1", + Image: "quay.io/example/bundle:v1.0.0", + BundleMetadata: ocv1.BundleMetadata{ + Name: "example.v1.0.0", + Version: "1.0.0", + }, + }, + RollingOut: []*RevisionMetadata{ + { + RevisionName: "rev-2", + Conditions: []metav1.Condition{ + { + Type: ocv1.ClusterExtensionRevisionTypeProgressing, + Status: metav1.ConditionTrue, + Reason: ocv1.ClusterExtensionRevisionReasonRetrying, + Message: "First upgrade attempt failed", + }, + }, + }, + { + RevisionName: "rev-3", + Conditions: []metav1.Condition{ + { + Type: ocv1.ClusterExtensionRevisionTypeProgressing, + Status: metav1.ConditionTrue, + Reason: ocv1.ClusterExtensionRevisionReasonRetrying, + Message: "Second upgrade attempt also failed", + }, + }, + }, + }, + }, + expectedInstalledCond: metav1.Condition{ + Type: ocv1.TypeInstalled, + Status: metav1.ConditionTrue, + Reason: ocv1.ReasonSucceeded, + }, + setupProgressingCond: &metav1.Condition{ + Type: ocv1.TypeProgressing, + Status: metav1.ConditionTrue, + Reason: ocv1.ReasonRetrying, + Message: "Second upgrade attempt also failed", + }, + expectedProgressingCond: &metav1.Condition{ + Type: ocv1.TypeProgressing, + Status: metav1.ConditionTrue, + Reason: ocv1.ReasonRetrying, + Message: "Second upgrade attempt also failed", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ext := &ocv1.ClusterExtension{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ext", + Generation: 1, + }, + } + + // Setup Progressing condition if specified to simulate controller behavior + if tt.setupProgressingCond != nil { + SetStatusCondition(&ext.Status.Conditions, *tt.setupProgressingCond) + } + + setInstalledStatusFromRevisionStates(ext, tt.revisionStates) + + // Verify Installed condition + installedCond := meta.FindStatusCondition(ext.Status.Conditions, ocv1.TypeInstalled) + require.NotNil(t, installedCond) + require.Equal(t, tt.expectedInstalledCond.Status, installedCond.Status) + require.Equal(t, tt.expectedInstalledCond.Reason, installedCond.Reason) + if tt.expectedInstalledCond.Message != "" { + require.Equal(t, tt.expectedInstalledCond.Message, installedCond.Message) + } + + // Verify Progressing condition if expected + // This verifies that setInstalledStatusFromRevisionStates does not modify Progressing + if tt.expectedProgressingCond != nil { + progressingCond := meta.FindStatusCondition(ext.Status.Conditions, ocv1.TypeProgressing) + require.NotNil(t, progressingCond) + require.Equal(t, tt.expectedProgressingCond.Status, progressingCond.Status) + require.Equal(t, tt.expectedProgressingCond.Reason, progressingCond.Reason) + require.Equal(t, tt.expectedProgressingCond.Message, progressingCond.Message) + } + }) + } +} + func TestSetInstalledStatusFromRevisionStates_ConfigValidationError(t *testing.T) { tests := []struct { name string @@ -260,6 +513,8 @@ func TestSetInstalledStatusFromRevisionStates_ConfigValidationError(t *testing.T expectedInstalledCond metav1.Condition }{ { + // When no operator is installed and no installation attempts exist + // Then status shows Failed because there is nothing to install name: "no revisions at all - uses Failed", revisionStates: &RevisionStates{ Installed: nil, @@ -272,6 +527,8 @@ func TestSetInstalledStatusFromRevisionStates_ConfigValidationError(t *testing.T }, }, { + // When installing operator for first time but it fails with validation errors + // Then status shows Failed to indicate the installation did not succeed name: "rolling revision with error (Retrying) - uses Failed", revisionStates: &RevisionStates{ Installed: nil, @@ -296,6 +553,8 @@ func TestSetInstalledStatusFromRevisionStates_ConfigValidationError(t *testing.T }, }, { + // When installing operator for first time with multiple attempts and latest one fails + // Then status shows Failed because the most recent attempt has errors name: "multiple rolling revisions with one Retrying - uses Failed", revisionStates: &RevisionStates{ Installed: nil, @@ -331,7 +590,9 @@ func TestSetInstalledStatusFromRevisionStates_ConfigValidationError(t *testing.T }, }, { - name: "rolling revision with RollingOut reason - uses Absent", + // When installing operator for first time and rollout is actively progressing + // Then status shows Installing to inform users installation is in progress + name: "rolling revision with RollingOut reason - uses Installing", revisionStates: &RevisionStates{ Installed: nil, RollingOut: []*RevisionMetadata{ @@ -351,11 +612,13 @@ func TestSetInstalledStatusFromRevisionStates_ConfigValidationError(t *testing.T expectedInstalledCond: metav1.Condition{ Type: ocv1.TypeInstalled, Status: metav1.ConditionFalse, - Reason: ocv1.ReasonAbsent, + Reason: ocv1.ReasonInstalling, }, }, { - name: "old revision with Retrying superseded by latest healthy - uses Absent", + // When first install attempt failed but user fixed config and second attempt is progressing + // Then status shows Installing because latest attempt is healthy + name: "old revision with Retrying superseded by latest healthy - uses Installing", revisionStates: &RevisionStates{ Installed: nil, RollingOut: []*RevisionMetadata{ @@ -383,6 +646,51 @@ func TestSetInstalledStatusFromRevisionStates_ConfigValidationError(t *testing.T }, }, }, + expectedInstalledCond: metav1.Condition{ + Type: ocv1.TypeInstalled, + Status: metav1.ConditionFalse, + Reason: ocv1.ReasonInstalling, + }, + }, + { + // When installing operator but rollout just started with no status updates yet + // Then status shows Absent because we cannot determine health yet + name: "rolling revision with no conditions set - uses Absent", + revisionStates: &RevisionStates{ + Installed: nil, + RollingOut: []*RevisionMetadata{ + { + RevisionName: "rev-1", + Conditions: []metav1.Condition{}, + }, + }, + }, + expectedInstalledCond: metav1.Condition{ + Type: ocv1.TypeInstalled, + Status: metav1.ConditionFalse, + Reason: ocv1.ReasonAbsent, + }, + }, + { + // When installing operator but status has unexpected reason we do not recognize + // Then status shows Absent as neutral state for unknown conditions + name: "rolling revision with unknown reason - uses Absent", + revisionStates: &RevisionStates{ + Installed: nil, + RollingOut: []*RevisionMetadata{ + { + RevisionName: "rev-1", + Conditions: []metav1.Condition{ + { + Type: ocv1.ClusterExtensionRevisionTypeProgressing, + Status: metav1.ConditionTrue, + Reason: "SomeOtherReason", + Message: "Revision is in an unknown state", + }, + }, + }, + }, + }, expectedInstalledCond: metav1.Condition{ Type: ocv1.TypeInstalled, Status: metav1.ConditionFalse, diff --git a/test/e2e/features/install.feature b/test/e2e/features/install.feature index c16af88ef7..a1103b6221 100644 --- a/test/e2e/features/install.feature +++ b/test/e2e/features/install.feature @@ -359,6 +359,30 @@ Feature: Install ClusterExtension invalid ClusterExtension configuration: invalid configuration: unknown field "watchNamespace" """ + Scenario: Report Installing status during initial installation + When ClusterExtension is applied + """ + apiVersion: olm.operatorframework.io/v1 + kind: ClusterExtension + metadata: + name: ${NAME} + spec: + namespace: ${TEST_NAMESPACE} + serviceAccount: + name: olm-sa + source: + sourceType: Catalog + catalog: + packageName: test + selector: + matchLabels: + "olm.operatorframework.io/metadata.name": test-catalog + """ + Then ClusterExtension eventually reports Installed as False with Reason Installing or has progressed + And ClusterExtension is rolled out + And ClusterExtension is available + And bundle "test-operator.1.2.0" is installed in version "1.2.0" + @BoxcutterRuntime @ProgressDeadline Scenario: Report ClusterExtension as not progressing if the rollout does not complete within given timeout diff --git a/test/e2e/features/update.feature b/test/e2e/features/update.feature index dee45e32a9..5fa8dc941d 100644 --- a/test/e2e/features/update.feature +++ b/test/e2e/features/update.feature @@ -31,7 +31,8 @@ Feature: Update ClusterExtension And ClusterExtension is rolled out And ClusterExtension is available When ClusterExtension is updated to version "1.0.1" - Then ClusterExtension is rolled out + Then ClusterExtension eventually reports Installed as True with Reason Upgrading or has progressed + And ClusterExtension is rolled out And ClusterExtension is available And bundle "test-operator.1.0.1" is installed in version "1.0.1" diff --git a/test/e2e/steps/steps.go b/test/e2e/steps/steps.go index d6ba67ccf1..024fd05bc0 100644 --- a/test/e2e/steps/steps.go +++ b/test/e2e/steps/steps.go @@ -69,6 +69,7 @@ func RegisterSteps(sc *godog.ScenarioContext) { sc.Step(`^(?i)ClusterExtension reports ([[:alnum:]]+) as ([[:alnum:]]+) with Reason ([[:alnum:]]+) and Message:$`, ClusterExtensionReportsCondition) sc.Step(`^(?i)ClusterExtension reports ([[:alnum:]]+) as ([[:alnum:]]+) with Reason ([[:alnum:]]+) and Message includes:$`, ClusterExtensionReportsConditionWithMessageFragment) sc.Step(`^(?i)ClusterExtension reports ([[:alnum:]]+) as ([[:alnum:]]+) with Reason ([[:alnum:]]+)$`, ClusterExtensionReportsConditionWithoutMsg) + sc.Step(`^(?i)ClusterExtension eventually reports ([[:alnum:]]+) as ([[:alnum:]]+) with Reason ([[:alnum:]]+) or has progressed$`, ClusterExtensionEventuallyReportsConditionOrProgressed) sc.Step(`^(?i)ClusterExtension reports ([[:alnum:]]+) as ([[:alnum:]]+)$`, ClusterExtensionReportsConditionWithoutReason) sc.Step(`^(?i)ClusterExtensionRevision "([^"]+)" reports ([[:alnum:]]+) as ([[:alnum:]]+) with Reason ([[:alnum:]]+)$`, ClusterExtensionRevisionReportsConditionWithoutMsg) sc.Step(`^(?i)ClusterExtension reports ([[:alnum:]]+) transition between (\d+) and (\d+) minutes since its creation$`, ClusterExtensionReportsConditionTransitionTime) @@ -423,6 +424,93 @@ func ClusterExtensionReportsConditionWithoutReason(ctx context.Context, conditio return waitForExtensionCondition(ctx, conditionType, conditionStatus, nil, nil) } +// ClusterExtensionEventuallyReportsConditionOrProgressed checks if a ClusterExtension ever reports +// a specific condition with a specific reason, OR has already progressed past that transient state. +// This is useful for testing transient states like "Installing" or "Upgrading" that may complete +// very quickly before the test can observe them. +// +// For Installing (Installed=False, Reason=Installing): accepts Installed=False with Reason=Installing OR Installed=True with Reason=Succeeded. +// For Upgrading (Installed=True, Reason=Upgrading): accepts Installed=True with Reason=Upgrading OR Installed=True with Reason=Succeeded. +// For any other condition reason (or when the condition does not match one of the transient patterns +// above), this helper behaves like ClusterExtensionReportsConditionWithoutMsg and requires an exact match on +// type, status, and reason via waitForExtensionCondition. +func ClusterExtensionEventuallyReportsConditionOrProgressed(ctx context.Context, conditionType, conditionStatus, conditionReason string) error { + sc := scenarioCtx(ctx) + + // Map of transient reasons to their progressed states with expected status + type progressedState struct { + reason string + status string + } + + progressedStates := map[string][]progressedState{ + "Installing": {{reason: "Succeeded", status: "True"}}, // Installing (False) -> Succeeded (True) + "Upgrading": {{reason: "Succeeded", status: "True"}}, // Upgrading (True) -> Succeeded (True) + } + + acceptedProgressed, isTransient := progressedStates[conditionReason] + if !isTransient { + // Not a transient state, use normal exact match + return waitForExtensionCondition(ctx, conditionType, conditionStatus, &conditionReason, nil) + } + + // For transient states, we accept either the target reason OR any progressed reason + require.Eventually(godog.T(ctx), func() bool { + v, err := k8sClient("get", "clusterextension", sc.clusterExtensionName, "-o", fmt.Sprintf("jsonpath={.status.conditions[?(@.type==\"%s\")]}", conditionType)) + if err != nil { + logger.V(1).Info("Failed to get clusterextension", "name", sc.clusterExtensionName, "error", err) + return false + } + + if v == "" { + logger.V(1).Info("No condition found yet", "conditionType", conditionType) + return false + } + + var condition metav1.Condition + if err := json.Unmarshal([]byte(v), &condition); err != nil { + logger.V(1).Info("Failed to unmarshal condition", "error", err, "value", v) + return false + } + + logger.V(1).Info("Checking condition", + "conditionType", conditionType, + "status", condition.Status, + "reason", condition.Reason, + "expectedStatus", conditionStatus, + "expectedReason", conditionReason) + + // Check if we have the target reason with expected status (transient state) + if condition.Status == metav1.ConditionStatus(conditionStatus) && condition.Reason == conditionReason { + logger.V(1).Info("Found transient state", + "conditionType", conditionType, + "status", condition.Status, + "reason", condition.Reason) + return true + } + + // Check if we've progressed past the transient state + for _, progressed := range acceptedProgressed { + if condition.Status == metav1.ConditionStatus(progressed.status) && condition.Reason == progressed.reason { + logger.V(1).Info("State has progressed past transient state", + "conditionType", conditionType, + "expectedTransientStatus", conditionStatus, + "expectedTransientReason", conditionReason, + "actualStatus", condition.Status, + "actualReason", condition.Reason) + return true + } + } + + logger.V(1).Info("Condition doesn't match expected or progressed states", + "actualStatus", condition.Status, + "actualReason", condition.Reason) + return false + }, timeout, tick) + + return nil +} + func ClusterExtensionReportsConditionTransitionTime(ctx context.Context, conditionType string, minMinutes, maxMinutes int) error { sc := scenarioCtx(ctx) t := godog.T(ctx)