Skip to content

Commit 511c8c1

Browse files
Add custom TerminalError with granular Reason support
Previously, all terminal errors used the generic "Blocked" reason in the Progressing condition. This wasn't semantically correct - "Blocked" was intended for external blockers like resource collisions, not for user configuration errors. This commit introduces a custom TerminalError type that can carry a specific Reason field, allowing more precise categorization of terminal errors in status conditions. Changes: - Add ReasonInvalidConfiguration constant for configuration errors - Create NewTerminalError(reason, err) to wrap errors with a reason - Add ExtractTerminalReason(err) to retrieve the reason from errors - Update setStatusProgressing to use extracted reason when available - Fall back to ReasonBlocked for backward compatibility with plain reconcile.TerminalError() usage - Update config validation to use ReasonInvalidConfiguration - Update tests and e2e expectations accordingly Result: - Config errors now show: Progressing: False, Reason: InvalidConfiguration - Generic terminal errors show: Progressing: False, Reason: Blocked - More helpful and semantically correct status reporting Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent f805c05 commit 511c8c1

8 files changed

Lines changed: 94 additions & 16 deletions

File tree

api/v1/common_types.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,10 @@ const (
2424
ReasonAbsent = "Absent"
2525

2626
// Progressing reasons
27-
ReasonRollingOut = "RollingOut"
28-
ReasonRetrying = "Retrying"
29-
ReasonBlocked = "Blocked"
27+
ReasonRollingOut = "RollingOut"
28+
ReasonRetrying = "Retrying"
29+
ReasonBlocked = "Blocked"
30+
ReasonInvalidConfiguration = "InvalidConfiguration"
3031

3132
// Deprecation reasons
3233
ReasonDeprecated = "Deprecated"

internal/operator-controller/applier/provider.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,14 @@ import (
99
"helm.sh/helm/v3/pkg/chart"
1010
"k8s.io/apimachinery/pkg/util/sets"
1111
"sigs.k8s.io/controller-runtime/pkg/client"
12-
"sigs.k8s.io/controller-runtime/pkg/reconcile"
1312

1413
"github.com/operator-framework/api/pkg/operators/v1alpha1"
1514

1615
ocv1 "github.com/operator-framework/operator-controller/api/v1"
1716
"github.com/operator-framework/operator-controller/internal/operator-controller/config"
1817
"github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/bundle/source"
1918
"github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/render"
19+
errorutil "github.com/operator-framework/operator-controller/internal/shared/util/error"
2020
)
2121

2222
// ManifestProvider returns the manifests that should be applied by OLM given a bundle and its associated ClusterExtension
@@ -78,7 +78,7 @@ func (r *RegistryV1ManifestProvider) Get(bundleFS fs.FS, ext *ocv1.ClusterExtens
7878
bundleConfigBytes := extensionConfigBytes(ext)
7979
bundleConfig, err := config.UnmarshalConfig(bundleConfigBytes, schema, ext.Spec.Namespace)
8080
if err != nil {
81-
return nil, reconcile.TerminalError(fmt.Errorf("invalid ClusterExtension configuration: %w", err))
81+
return nil, errorutil.NewTerminalError(ocv1.ReasonInvalidConfiguration, fmt.Errorf("invalid ClusterExtension configuration: %w", err))
8282
}
8383

8484
if watchNS := bundleConfig.GetWatchNamespace(); watchNS != nil {

internal/operator-controller/conditionsets/conditionsets.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ var ConditionReasons = []string{
4040
ocv1.ReasonDeprecationStatusUnknown,
4141
ocv1.ReasonFailed,
4242
ocv1.ReasonBlocked,
43+
ocv1.ReasonInvalidConfiguration,
4344
ocv1.ReasonRetrying,
4445
ocv1.ReasonAbsent,
4546
ocv1.ReasonRollingOut,

internal/operator-controller/controllers/clusterextension_controller.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ import (
5050
ocv1 "github.com/operator-framework/operator-controller/api/v1"
5151
"github.com/operator-framework/operator-controller/internal/operator-controller/conditionsets"
5252
"github.com/operator-framework/operator-controller/internal/operator-controller/labels"
53-
ocerror "github.com/operator-framework/operator-controller/internal/shared/util/error"
53+
errorutil "github.com/operator-framework/operator-controller/internal/shared/util/error"
5454
k8sutil "github.com/operator-framework/operator-controller/internal/shared/util/k8s"
5555
)
5656

@@ -456,11 +456,17 @@ func (r *ClusterExtensionReconciler) SetupWithManager(mgr ctrl.Manager, opts ...
456456
}
457457

458458
func wrapErrorWithResolutionInfo(resolved ocv1.BundleMetadata, err error) error {
459-
// Preserve TerminalError type through wrapping by re-wrapping after adding context
459+
// Preserve TerminalError type and reason through wrapping
460460
if errors.Is(err, reconcile.TerminalError(nil)) {
461-
// Unwrap to get the inner error, add context, then re-wrap as TerminalError
462-
innerErr := ocerror.UnwrapTerminal(err)
461+
// Extract the reason if one was provided
462+
reason, hasReason := errorutil.ExtractTerminalReason(err)
463+
// Unwrap to get the inner error, add context
464+
innerErr := errorutil.UnwrapTerminal(err)
463465
wrappedErr := fmt.Errorf("error for resolved bundle %q with version %q: %w", resolved.Name, resolved.Version, innerErr)
466+
// Re-wrap preserving the reason if it existed
467+
if hasReason {
468+
return errorutil.NewTerminalError(reason, wrappedErr)
469+
}
464470
return reconcile.TerminalError(wrappedErr)
465471
}
466472
return fmt.Errorf("error for resolved bundle %q with version %q: %w", resolved.Name, resolved.Version, err)

internal/operator-controller/controllers/common_controller.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import (
2525
"sigs.k8s.io/controller-runtime/pkg/reconcile"
2626

2727
ocv1 "github.com/operator-framework/operator-controller/api/v1"
28-
ocerror "github.com/operator-framework/operator-controller/internal/shared/util/error"
28+
errorutil "github.com/operator-framework/operator-controller/internal/shared/util/error"
2929
)
3030

3131
const (
@@ -157,12 +157,19 @@ func setStatusProgressing(ext *ocv1.ClusterExtension, err error) {
157157
if err != nil {
158158
progressingCond.Reason = ocv1.ReasonRetrying
159159
// Unwrap TerminalError to avoid "terminal error:" prefix in message
160-
progressingCond.Message = ocerror.UnwrapTerminal(err).Error()
160+
progressingCond.Message = errorutil.UnwrapTerminal(err).Error()
161161
}
162162

163163
if errors.Is(err, reconcile.TerminalError(nil)) {
164164
progressingCond.Status = metav1.ConditionFalse
165-
progressingCond.Reason = ocv1.ReasonBlocked
165+
// Try to extract a specific reason from the terminal error.
166+
// If the error was created with NewTerminalError(reason, err), use that reason.
167+
// Otherwise, fall back to the generic "Blocked" reason.
168+
if reason, ok := errorutil.ExtractTerminalReason(err); ok {
169+
progressingCond.Reason = reason
170+
} else {
171+
progressingCond.Reason = ocv1.ReasonBlocked
172+
}
166173
}
167174

168175
SetStatusCondition(&ext.Status.Conditions, progressingCond)

internal/operator-controller/controllers/common_controller_test.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"sigs.k8s.io/controller-runtime/pkg/reconcile"
1515

1616
ocv1 "github.com/operator-framework/operator-controller/api/v1"
17+
errorutil "github.com/operator-framework/operator-controller/internal/shared/util/error"
1718
)
1819

1920
func TestSetStatusProgressing(t *testing.T) {
@@ -46,7 +47,7 @@ func TestSetStatusProgressing(t *testing.T) {
4647
},
4748
},
4849
{
49-
name: "non-nil ClusterExtension, terminal error, Progressing condition has status False with reason Blocked",
50+
name: "non-nil ClusterExtension, terminal error without reason, Progressing condition has status False with reason Blocked",
5051
err: reconcile.TerminalError(errors.New("boom")),
5152
clusterExtension: &ocv1.ClusterExtension{},
5253
expected: metav1.Condition{
@@ -56,6 +57,17 @@ func TestSetStatusProgressing(t *testing.T) {
5657
Message: "boom",
5758
},
5859
},
60+
{
61+
name: "non-nil ClusterExtension, terminal error with InvalidConfiguration reason, Progressing condition has status False with that reason",
62+
err: errorutil.NewTerminalError(ocv1.ReasonInvalidConfiguration, errors.New("missing required field")),
63+
clusterExtension: &ocv1.ClusterExtension{},
64+
expected: metav1.Condition{
65+
Type: ocv1.TypeProgressing,
66+
Status: metav1.ConditionFalse,
67+
Reason: ocv1.ReasonInvalidConfiguration,
68+
Message: "missing required field",
69+
},
70+
},
5971
} {
6072
t.Run(tc.name, func(t *testing.T) {
6173
setStatusProgressing(tc.clusterExtension, tc.err)

internal/shared/util/error/terminal.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,57 @@ import (
66
"sigs.k8s.io/controller-runtime/pkg/reconcile"
77
)
88

9+
// terminalErrorWithReason is an internal error type that carries a Reason field
10+
// to provide more granular categorization of terminal errors for status conditions.
11+
type terminalErrorWithReason struct {
12+
reason string
13+
err error
14+
}
15+
16+
func (e *terminalErrorWithReason) Error() string {
17+
return e.err.Error()
18+
}
19+
20+
func (e *terminalErrorWithReason) Unwrap() error {
21+
return e.err
22+
}
23+
24+
// NewTerminalError creates a terminal error with a specific reason.
25+
// The error is wrapped with reconcile.TerminalError so controller-runtime
26+
// recognizes it as terminal, while preserving the reason for status reporting.
27+
//
28+
// Example usage:
29+
//
30+
// return error.NewTerminalError(ocv1.ReasonInvalidConfiguration, fmt.Errorf("missing required field"))
31+
//
32+
// The reason can be extracted later using ExtractTerminalReason() when setting
33+
// status conditions to provide more specific feedback than just "Blocked".
34+
func NewTerminalError(reason string, err error) error {
35+
return reconcile.TerminalError(&terminalErrorWithReason{
36+
reason: reason,
37+
err: err,
38+
})
39+
}
40+
41+
// ExtractTerminalReason extracts the reason from a terminal error created with
42+
// NewTerminalError. Returns the reason and true if found, or empty string and
43+
// false if the error was not created with NewTerminalError.
44+
//
45+
// This allows setStatusProgressing to use specific reasons like "InvalidConfiguration"
46+
// instead of the generic "Blocked" for all terminal errors.
47+
func ExtractTerminalReason(err error) (string, bool) {
48+
if err == nil {
49+
return "", false
50+
}
51+
// Unwrap the reconcile.TerminalError wrapper first
52+
unwrapped := errors.Unwrap(err)
53+
var terr *terminalErrorWithReason
54+
if errors.As(unwrapped, &terr) {
55+
return terr.reason, true
56+
}
57+
return "", false
58+
}
59+
960
func WrapTerminal(err error, isTerminal bool) error {
1061
if !isTerminal || err == nil {
1162
return err

test/e2e/features/install.feature

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ Feature: Install ClusterExtension
117117
matchLabels:
118118
"olm.operatorframework.io/metadata.name": test-catalog
119119
"""
120-
And ClusterExtension reports Progressing as False with Reason Blocked and Message:
120+
And ClusterExtension reports Progressing as False with Reason InvalidConfiguration and Message:
121121
"""
122122
error for resolved bundle "single-namespace-operator.1.0.0" with version "1.0.0":
123123
invalid ClusterExtension configuration: invalid configuration: required field "watchNamespace" is missing
@@ -169,7 +169,7 @@ Feature: Install ClusterExtension
169169
matchLabels:
170170
"olm.operatorframework.io/metadata.name": test-catalog
171171
"""
172-
And ClusterExtension reports Progressing as False with Reason Blocked and Message:
172+
And ClusterExtension reports Progressing as False with Reason InvalidConfiguration and Message:
173173
"""
174174
error for resolved bundle "own-namespace-operator.1.0.0" with version
175175
"1.0.0": invalid ClusterExtension configuration: invalid configuration: required
@@ -197,7 +197,7 @@ Feature: Install ClusterExtension
197197
matchLabels:
198198
"olm.operatorframework.io/metadata.name": test-catalog
199199
"""
200-
And ClusterExtension reports Progressing as False with Reason Blocked and Message:
200+
And ClusterExtension reports Progressing as False with Reason InvalidConfiguration and Message:
201201
"""
202202
error for resolved bundle "own-namespace-operator.1.0.0" with version
203203
"1.0.0": invalid ClusterExtension configuration: invalid configuration: 'some-ns'

0 commit comments

Comments
 (0)