Skip to content

Commit bcd714e

Browse files
committed
Distinguish transient from terminal condition failures
Reconcile previously set every handler error to reason=Failed, even when the underlying cause was transient (e.g. cluster operators still stabilizing or an upgrade in progress) and the operator was about to retry and succeed on its own. Consumers watching for a terminal Failed reason had no way to tell "needs intervention" apart from "will resolve itself." Add a transientError wrapper and a Retrying reason so preflight can mark known-transient conditions and have Reconcile record the right reason automatically.
1 parent 40a592f commit bcd714e

6 files changed

Lines changed: 144 additions & 3 deletions

File tree

api/v1alpha1/vmwarecloudfoundationmigration_types.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,11 @@ const (
129129
ReasonPaused = "Paused"
130130
ReasonPending = "Pending"
131131

132+
// ReasonRetrying indicates the operator hit a transient condition (e.g. an
133+
// external resource is temporarily unhealthy) and will automatically retry.
134+
// Unlike ReasonFailed, this is not a terminal state.
135+
ReasonRetrying = "Retrying"
136+
132137
// ReasonUnsupportedName indicates the object's name is not SingletonName,
133138
// so the operator is ignoring it.
134139
ReasonUnsupportedName = "UnsupportedName"

internal/controller/errors.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package controller
2+
3+
import (
4+
"errors"
5+
6+
migrationv1alpha1 "github.com/openshift/vcf-migration-operator/api/v1alpha1"
7+
)
8+
9+
// transientError wraps an error that represents a temporary condition (e.g. an
10+
// external resource is still stabilizing) rather than a permanent failure
11+
// requiring user intervention. The reconciler uses this distinction to choose
12+
// between ReasonFailed and ReasonRetrying when recording condition status.
13+
type transientError struct {
14+
err error
15+
}
16+
17+
// newTransientError marks err as transient.
18+
func newTransientError(err error) error {
19+
return &transientError{err: err}
20+
}
21+
22+
func (e *transientError) Error() string {
23+
return e.err.Error()
24+
}
25+
26+
func (e *transientError) Unwrap() error {
27+
return e.err
28+
}
29+
30+
// isTransientError reports whether err (or any error it wraps) was marked
31+
// transient via newTransientError.
32+
func isTransientError(err error) bool {
33+
var te *transientError
34+
return errors.As(err, &te)
35+
}
36+
37+
// reasonForError returns the condition Reason to record for a handler error:
38+
// ReasonRetrying for transient errors (the operator will automatically retry
39+
// on the next reconcile), ReasonFailed otherwise.
40+
func reasonForError(err error) string {
41+
if isTransientError(err) {
42+
return migrationv1alpha1.ReasonRetrying
43+
}
44+
return migrationv1alpha1.ReasonFailed
45+
}

internal/controller/errors_test.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package controller
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"testing"
7+
8+
migrationv1alpha1 "github.com/openshift/vcf-migration-operator/api/v1alpha1"
9+
)
10+
11+
func TestIsTransientError(t *testing.T) {
12+
tests := []struct {
13+
name string
14+
err error
15+
want bool
16+
}{
17+
{
18+
name: "nil error is not transient",
19+
err: nil,
20+
want: false,
21+
},
22+
{
23+
name: "plain error is not transient",
24+
err: fmt.Errorf("boom"),
25+
want: false,
26+
},
27+
{
28+
name: "transient error is transient",
29+
err: newTransientError(fmt.Errorf("cluster operators are not healthy")),
30+
want: true,
31+
},
32+
{
33+
name: "transient error wrapped by additional context is still transient",
34+
err: fmt.Errorf("running preflight checks: %w", newTransientError(fmt.Errorf("cluster upgrade is in progress"))),
35+
want: true,
36+
},
37+
}
38+
39+
for _, tt := range tests {
40+
t.Run(tt.name, func(t *testing.T) {
41+
if got := isTransientError(tt.err); got != tt.want {
42+
t.Fatalf("isTransientError(%v) = %v, want %v", tt.err, got, tt.want)
43+
}
44+
})
45+
}
46+
}
47+
48+
func TestTransientErrorUnwrap(t *testing.T) {
49+
inner := fmt.Errorf("cluster operators are not healthy")
50+
transient := newTransientError(inner)
51+
52+
if transient.Error() != inner.Error() {
53+
t.Fatalf("transient.Error() = %q, want %q", transient.Error(), inner.Error())
54+
}
55+
if !errors.Is(transient, inner) {
56+
t.Fatalf("errors.Is(transient, inner) = false, want true")
57+
}
58+
}
59+
60+
func TestReasonForError(t *testing.T) {
61+
tests := []struct {
62+
name string
63+
err error
64+
want string
65+
}{
66+
{
67+
name: "transient error retries",
68+
err: newTransientError(fmt.Errorf("cluster operators are not healthy")),
69+
want: migrationv1alpha1.ReasonRetrying,
70+
},
71+
{
72+
name: "plain error fails",
73+
err: fmt.Errorf("spec.failureDomains must not be empty"),
74+
want: migrationv1alpha1.ReasonFailed,
75+
},
76+
}
77+
78+
for _, tt := range tests {
79+
t.Run(tt.name, func(t *testing.T) {
80+
if got := reasonForError(tt.err); got != tt.want {
81+
t.Fatalf("reasonForError(%v) = %q, want %q", tt.err, got, tt.want)
82+
}
83+
})
84+
}
85+
}

internal/controller/preflight.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ func (r *VmwareCloudFoundationMigrationReconciler) runPreflightChecks(ctx contex
9393
}
9494
r.setCondition(migration, condType, metav1.ConditionFalse, migrationv1alpha1.ReasonProgressing, "Validating cluster readiness")
9595
if support.UpgradeInProgress {
96-
return "", fmt.Errorf("cluster upgrade is in progress; wait for ClusterVersion/version Progressing=False before starting migration")
96+
return "", newTransientError(fmt.Errorf("cluster upgrade is in progress; wait for ClusterVersion/version Progressing=False before starting migration"))
9797
}
9898

9999
opMgr := openshift.NewOperatorManager(r.ConfigClient)
@@ -102,7 +102,7 @@ func (r *VmwareCloudFoundationMigrationReconciler) runPreflightChecks(ctx contex
102102
return "", fmt.Errorf("checking cluster operator health: %w", err)
103103
}
104104
if !healthy {
105-
return "", fmt.Errorf("cluster operators are not healthy; wait for operators to recover before starting migration: %s", strings.Join(unhealthyOperators, ", "))
105+
return "", newTransientError(fmt.Errorf("cluster operators are not healthy; wait for operators to recover before starting migration: %s", strings.Join(unhealthyOperators, ", ")))
106106
}
107107

108108
if err := checkNoVSphereCSIPersistentVolumes(ctx, r.KubeClient); err != nil {

internal/controller/preflight_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,7 @@ func TestRunPreflightChecks(t *testing.T) {
481481
mutateMigration func(*migrationv1alpha1.VmwareCloudFoundationMigration)
482482
wantMessageContains string
483483
wantErrContains string
484+
wantTransient bool
484485
wantTargetSecretReadCount int
485486
}{
486487
{
@@ -551,6 +552,7 @@ func TestRunPreflightChecks(t *testing.T) {
551552
gateEnabled: true,
552553
progressing: true,
553554
wantErrContains: "cluster upgrade is in progress",
555+
wantTransient: true,
554556
wantTargetSecretReadCount: 1,
555557
},
556558
{
@@ -569,6 +571,7 @@ func TestRunPreflightChecks(t *testing.T) {
569571
},
570572
},
571573
wantErrContains: "cluster operators are not healthy",
574+
wantTransient: true,
572575
wantTargetSecretReadCount: 1,
573576
},
574577
{
@@ -668,6 +671,9 @@ func TestRunPreflightChecks(t *testing.T) {
668671
if !strings.Contains(err.Error(), tt.wantErrContains) {
669672
t.Fatalf("runPreflightChecks error = %q, want substring %q", err.Error(), tt.wantErrContains)
670673
}
674+
if got := isTransientError(err); got != tt.wantTransient {
675+
t.Fatalf("isTransientError(err) = %v, want %v", got, tt.wantTransient)
676+
}
671677
} else {
672678
if err != nil {
673679
t.Fatalf("runPreflightChecks: %v", err)

internal/controller/vmwarecloudfoundationmigration_controller.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ func (r *VmwareCloudFoundationMigrationReconciler) Reconcile(ctx context.Context
241241
log.V(1).Info("processing condition", "condition", condType)
242242
result, err := handler(ctx, migration)
243243
if err != nil {
244-
r.setCondition(migration, condType, metav1.ConditionFalse, migrationv1alpha1.ReasonFailed, err.Error())
244+
r.setCondition(migration, condType, metav1.ConditionFalse, reasonForError(err), err.Error())
245245
r.Recorder.Eventf(migration, "Warning", "ConditionFailed", "Condition %s failed: %v", condType, err)
246246
}
247247

0 commit comments

Comments
 (0)