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
164 changes: 164 additions & 0 deletions internal/apistatus/apistatus.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors
// SPDX-License-Identifier: Apache-2.0

// Package apistatus provides a typed structured error for provider implementations.
package apistatus

import (
"errors"
"fmt"
"strings"

"sigs.k8s.io/controller-runtime/pkg/reconcile"
)

// Code identifies the category of a [StatusError].
// The zero value is not a valid code.
type Code uint8

const (
// CodeInvalidArgument signals that one or more spec field values have an
// incorrect format or structure. The field is supported, but the value
// does not meet the provider's requirements. The resource cannot be
// realized until the spec is corrected.
CodeInvalidArgument Code = iota + 1

// CodeUnsupportedField signals that one or more spec fields are not
// supported by the provider. The resource cannot be realized until
// the spec is changed.
CodeUnsupportedField

// CodeFailedPrecondition signals that a precondition on the device or
// environment is not met. Unlike other codes, this is retryable — the
// precondition may become true on a future attempt (e.g. a BGP process
// configured out-of-band). [WrapTerminalError] does not promote these
// errors to terminal.
CodeFailedPrecondition
)

// Valid reports whether c is a known, non-zero Code.
func (c Code) Valid() bool {
switch c {
case CodeInvalidArgument, CodeUnsupportedField, CodeFailedPrecondition:
return true
default:
return false
}
}

// String returns the string representation of c.
func (c Code) String() string {
switch c {
case CodeInvalidArgument:
return "InvalidArgument"
case CodeUnsupportedField:
return "UnsupportedField"
case CodeFailedPrecondition:
return "FailedPrecondition"
default:
return fmt.Sprintf("Code(%d)", c)
}
}

// FieldViolation describes a problem with a specific spec field.
type FieldViolation struct {
// Field is the dot-separated path to the field, e.g. "spec.mtu".
Field string
// Description explains why the field is invalid, e.g. "not supported on this platform".
Description string
}

// StatusError is a structured error returned by provider implementations.
// It carries a Code, an optional top-level Message, and an optional list of
// FieldViolations. Whether the error is terminal depends on the Code — see
// [WrapTerminalError].
type StatusError struct {
Code Code
Message string
FieldViolations []FieldViolation
}

// Is implements the errors.Is interface, reporting whether target is a [*StatusError].
// This allows [errors.Is] to be used for type detection in error chains.
func (e *StatusError) Is(target error) bool {
_, ok := FromError(target) //nolint:errcheck
return ok
}

// Error implements the error interface.
func (e *StatusError) Error() string {
parts := make([]string, 0, 1+len(e.FieldViolations))
if e.Message != "" {
parts = append(parts, e.Message)
}
for _, v := range e.FieldViolations {
parts = append(parts, fmt.Sprintf("field %s: %s", v.Field, v.Description))
}
if len(parts) == 0 {
return e.Code.String()
}
return e.Code.String() + ": " + strings.Join(parts, "; ")
}

// InvalidArgument returns a [StatusError] with [CodeInvalidArgument] for
// one or more spec field values that have an incorrect format or structure.
func InvalidArgument(violations ...FieldViolation) *StatusError {
return &StatusError{
Code: CodeInvalidArgument,
FieldViolations: violations,
}
}

// UnsupportedField returns a [StatusError] with [CodeUnsupportedField] for
// one or more spec fields that are not supported by the provider.
func UnsupportedField(violations ...FieldViolation) *StatusError {
return &StatusError{
Code: CodeUnsupportedField,
FieldViolations: violations,
}
}

// FailedPrecondition returns a [StatusError] with [CodeFailedPrecondition]
// for a precondition that is not yet met. These errors are retryable —
// controller-runtime exponential backoff will requeue the request so the
// precondition can be rechecked on a future attempt.
func FailedPrecondition(message string) *StatusError {
return &StatusError{
Code: CodeFailedPrecondition,
Message: message,
}
}

// FromError extracts a [*StatusError] from err.
// The boolean reports whether the extraction succeeded.
func FromError(err error) (*StatusError, bool) {
se, ok := errors.AsType[*StatusError](err)
return se, ok
}

// WrapTerminalError wraps err as a [reconcile.TerminalError] if it contains a
// [*StatusError] with a non-retryable code ([CodeInvalidArgument] or
// [CodeUnsupportedField]). [CodeFailedPrecondition] errors are returned
// unchanged so the controller-runtime exponential backoff can requeue them —
// the precondition may become true on a future attempt.
// All other errors are also returned unchanged.
//
// Example usage in a controller:
//
// err := s.Provider.EnsureBGPPeer(ctx, &provider.BGPPeerRequest{...})
// cond := conditions.FromError(err)
// conditions.Set(s.BGPPeer, cond)
// if err != nil {
// return apistatus.WrapTerminalError(err)
// }
func WrapTerminalError(err error) error {
if se, ok := FromError(err); ok {
switch se.Code {
case CodeInvalidArgument, CodeUnsupportedField:
return reconcile.TerminalError(err)
default:
// Other codes are not considered terminal — return the original error
}
}
return err
}
124 changes: 124 additions & 0 deletions internal/apistatus/apistatus_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors
// SPDX-License-Identifier: Apache-2.0

package apistatus_test

import (
"errors"
"fmt"
"testing"

"sigs.k8s.io/controller-runtime/pkg/reconcile"

"github.com/ironcore-dev/network-operator/internal/apistatus"
)

func TestCode(t *testing.T) {
tests := []struct {
code apistatus.Code
wantStr string
wantValid bool
}{
{apistatus.Code(0), "Code(0)", false},
{apistatus.CodeInvalidArgument, "InvalidArgument", true},
{apistatus.CodeUnsupportedField, "UnsupportedField", true},
{apistatus.CodeFailedPrecondition, "FailedPrecondition", true},
{apistatus.Code(99), "Code(99)", false},
}
for _, test := range tests {
if got := test.code.String(); got != test.wantStr {
t.Errorf("Code(%d).String() = %q, want %q", test.code, got, test.wantStr)
}
if got := test.code.Valid(); got != test.wantValid {
t.Errorf("Code(%d).Valid() = %v, want %v", test.code, got, test.wantValid)
}
}
}

func TestStatusError(t *testing.T) {
tests := []struct {
name string
err *apistatus.StatusError
wantCode apistatus.Code
wantMessage string
wantViolations []apistatus.FieldViolation
}{
{
name: "InvalidArgument",
err: apistatus.InvalidArgument(apistatus.FieldViolation{Field: "spec.name", Description: "invalid interface format, expected e.g. eth1/1"}),
wantCode: apistatus.CodeInvalidArgument,
wantViolations: []apistatus.FieldViolation{{Field: "spec.name", Description: "invalid interface format, expected e.g. eth1/1"}},
},
{
name: "UnsupportedField",
err: apistatus.UnsupportedField(apistatus.FieldViolation{Field: "spec.mtu", Description: "MTU configuration is not supported on this platform"}),
wantCode: apistatus.CodeUnsupportedField,
wantViolations: []apistatus.FieldViolation{{Field: "spec.mtu", Description: "MTU configuration is not supported on this platform"}},
},
{
name: "FailedPrecondition",
err: apistatus.FailedPrecondition("BGP instance must be configured before BGP peers can be realized"),
wantCode: apistatus.CodeFailedPrecondition,
wantMessage: "BGP instance must be configured before BGP peers can be realized",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if test.err.Code != test.wantCode {
t.Errorf("Code = %v, want %v", test.err.Code, test.wantCode)
}
if test.wantMessage != "" && test.err.Message != test.wantMessage {
t.Errorf("Message = %q, want %q", test.err.Message, test.wantMessage)
}
if len(test.wantViolations) > 0 {
if len(test.err.FieldViolations) != len(test.wantViolations) || test.err.FieldViolations[0] != test.wantViolations[0] {
t.Errorf("FieldViolations = %+v, want %+v", test.err.FieldViolations, test.wantViolations)
}
}
})
}
}

func TestFromError(t *testing.T) {
tests := []struct {
name string
err error
ok bool
}{
{"StatusError", apistatus.UnsupportedField(apistatus.FieldViolation{Field: "spec.mtu", Description: "x"}), true},
{"Wrapped", fmt.Errorf("outer: %w", apistatus.UnsupportedField(apistatus.FieldViolation{Field: "spec.mtu", Description: "x"})), true},
{"Plain", errors.New("plain error"), false},
{"Nil", nil, false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if _, ok := apistatus.FromError(test.err); ok != test.ok { //nolint:errcheck
t.Errorf("FromError ok = %v, want %v", ok, test.ok)
}
})
}
}

func TestWrapTerminalError(t *testing.T) {
tests := []struct {
name string
err error
wantTerminal bool
}{
{"InvalidArgument", apistatus.InvalidArgument(apistatus.FieldViolation{Field: "spec.mtu", Description: "x"}), true},
{"UnsupportedField", apistatus.UnsupportedField(apistatus.FieldViolation{Field: "spec.type", Description: "x"}), true},
{"WrappedUnsupportedField", fmt.Errorf("outer: %w", apistatus.UnsupportedField(apistatus.FieldViolation{Field: "spec.type", Description: "x"})), true},
{"FailedPrecondition", apistatus.FailedPrecondition("BGP instance must be configured before BGP peers can be realized"), false},
{"WrappedFailedPrecondition", fmt.Errorf("outer: %w", apistatus.FailedPrecondition("BGP instance must be configured")), false},
{"Plain", errors.New("transient"), false},
{"Nil", nil, false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := apistatus.WrapTerminalError(test.err)
if errors.Is(got, reconcile.TerminalError(nil)) != test.wantTerminal {
t.Errorf("IsTerminalError = %v, want %v", !test.wantTerminal, test.wantTerminal)
}
})
}
}
13 changes: 11 additions & 2 deletions internal/conditions/conditions.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

"github.com/ironcore-dev/network-operator/api/core/v1alpha1"
"github.com/ironcore-dev/network-operator/internal/apistatus"
)

// Getter defines methods that an API object should implement in order to
Expand Down Expand Up @@ -158,8 +159,9 @@ func Sort(conditions []metav1.Condition) {

// FromError creates a [v1alpha1.ConfiguredCondition] from the given error.
// If the error is nil, it returns a condition indicating success.
// If the error is a gRPC status error, it extracts the code and message
// to populate the condition's Reason and Message fields.
// If the error is an [apistatus.StatusError], its Code and formatted message
// are used to populate the condition's Reason and Message fields.
// If the error is a gRPC status error, its code and message are used instead.
func FromError(err error) metav1.Condition {
cond := metav1.Condition{
Type: v1alpha1.ConfiguredCondition,
Expand All @@ -172,6 +174,13 @@ func FromError(err error) metav1.Condition {
cond.Reason = v1alpha1.ErrorReason
cond.Message = err.Error()

// [apistatus.StatusError] takes precedence — checked before gRPC status.
if statusErr, ok := apistatus.FromError(err); ok {
cond.Reason = statusErr.Code.String()
cond.Message = statusErr.Error()
return cond
}

// If the error is a gRPC status error, extract the code and message
if statusErr, ok := grpcstatus.FromError(err); ok {
cond.Reason = statusErr.Code().String()
Expand Down
3 changes: 2 additions & 1 deletion internal/controller/cisco/nx/bordergateway_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (

nxv1alpha1 "github.com/ironcore-dev/network-operator/api/cisco/nx/v1alpha1"
"github.com/ironcore-dev/network-operator/api/core/v1alpha1"
"github.com/ironcore-dev/network-operator/internal/apistatus"
"github.com/ironcore-dev/network-operator/internal/conditions"
corecontroller "github.com/ironcore-dev/network-operator/internal/controller/core"
"github.com/ironcore-dev/network-operator/internal/deviceutil"
Expand Down Expand Up @@ -188,7 +189,7 @@ func (r *BorderGatewayReconciler) Reconcile(ctx context.Context, req ctrl.Reques

if err := r.reconcile(ctx, s); err != nil {
log.Error(err, "Failed to reconcile resource")
return ctrl.Result{}, err
return ctrl.Result{}, apistatus.WrapTerminalError(err)
}

return ctrl.Result{}, nil
Expand Down
3 changes: 2 additions & 1 deletion internal/controller/cisco/nx/system_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (

nxv1alpha1 "github.com/ironcore-dev/network-operator/api/cisco/nx/v1alpha1"
"github.com/ironcore-dev/network-operator/api/core/v1alpha1"
"github.com/ironcore-dev/network-operator/internal/apistatus"
"github.com/ironcore-dev/network-operator/internal/conditions"
corecontroller "github.com/ironcore-dev/network-operator/internal/controller/core"
"github.com/ironcore-dev/network-operator/internal/deviceutil"
Expand Down Expand Up @@ -185,7 +186,7 @@ func (r *SystemReconciler) Reconcile(ctx context.Context, req ctrl.Request) (_ c

if err := r.reconcile(ctx, s); err != nil {
log.Error(err, "Failed to reconcile resource")
return ctrl.Result{}, err
return ctrl.Result{}, apistatus.WrapTerminalError(err)
}

return ctrl.Result{}, nil
Expand Down
3 changes: 2 additions & 1 deletion internal/controller/cisco/nx/vpcdomain_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"sigs.k8s.io/controller-runtime/pkg/predicate"
"sigs.k8s.io/controller-runtime/pkg/reconcile"

"github.com/ironcore-dev/network-operator/internal/apistatus"
"github.com/ironcore-dev/network-operator/internal/conditions"
"github.com/ironcore-dev/network-operator/internal/paused"
"github.com/ironcore-dev/network-operator/internal/provider"
Expand Down Expand Up @@ -186,7 +187,7 @@ func (r *VPCDomainReconciler) Reconcile(ctx context.Context, req ctrl.Request) (

if err = r.reconcile(ctx, s); err != nil {
log.Error(err, "Failed to reconcile resource")
return ctrl.Result{}, err
return ctrl.Result{}, apistatus.WrapTerminalError(err)
}

return ctrl.Result{RequeueAfter: corecontroller.Jitter(r.RequeueInterval)}, nil
Expand Down
3 changes: 2 additions & 1 deletion internal/controller/core/acl_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (
"sigs.k8s.io/controller-runtime/pkg/reconcile"

"github.com/ironcore-dev/network-operator/api/core/v1alpha1"
"github.com/ironcore-dev/network-operator/internal/apistatus"
"github.com/ironcore-dev/network-operator/internal/conditions"
"github.com/ironcore-dev/network-operator/internal/deviceutil"
"github.com/ironcore-dev/network-operator/internal/paused"
Expand Down Expand Up @@ -195,7 +196,7 @@ func (r *AccessControlListReconciler) Reconcile(ctx context.Context, req ctrl.Re

if err := r.reconcile(ctx, s); err != nil {
log.Error(err, "Failed to reconcile resource")
return ctrl.Result{}, err
return ctrl.Result{}, apistatus.WrapTerminalError(err)
}

return ctrl.Result{}, nil
Expand Down
Loading
Loading