Skip to content

Commit 6bad46a

Browse files
Per Goncalves da Silvaclaude
andcommitted
feat: Add validation framework with ServiceAccount validator to ClusterExtension
Introduces an extensible validation framework that runs early in the ClusterExtension reconciliation pipeline (after finalizer handling, before revision state retrieval) to catch configuration errors before expensive operations begin. Key changes: - New ClusterExtensionValidator type and ValidateClusterExtension reconcile step that executes all validators and collects all errors (no fail-fast behavior) - ServiceAccountValidator checks SA existence via direct CoreV1 API Get call, providing clear "not found" feedback - Sets Installed=Unknown and Progressing=Retrying conditions on validation failure - Removed SA-specific error handling from RetrieveRevisionStates, since validation now catches these errors earlier - Consolidated duplicate CoreV1 client creation in boxcutter configurator, sharing a single TokenGetter instance with the revision engine factory Testing: - Table-driven unit tests covering 7 scenarios (all pass, single fail, multiple fail, mixed, SA not found, SA found) - E2E scenario for missing ServiceAccount validation error Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> Signed-off-by: Per Goncalves da Silva <pegoncal@redhat.com> Signed-off-by: Per G. da Silva <pegoncal@redhat.com>
1 parent 910fa59 commit 6bad46a

File tree

5 files changed

+257
-53
lines changed

5 files changed

+257
-53
lines changed

cmd/operator-controller/main.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -627,6 +627,9 @@ func (c *boxcutterReconcilerConfigurator) Configure(ceReconciler *controllers.Cl
627627
}
628628
ceReconciler.ReconcileSteps = []controllers.ReconcileStepFunc{
629629
controllers.HandleFinalizers(c.finalizers),
630+
controllers.ValidateClusterExtension(
631+
controllers.ServiceAccountValidator(coreClient),
632+
),
630633
controllers.MigrateStorage(storageMigrator),
631634
controllers.RetrieveRevisionStates(revisionStatesGetter),
632635
controllers.ResolveBundle(c.resolver, c.mgr.GetClient()),
@@ -747,6 +750,9 @@ func (c *helmReconcilerConfigurator) Configure(ceReconciler *controllers.Cluster
747750
revisionStatesGetter := &controllers.HelmRevisionStatesGetter{ActionClientGetter: acg}
748751
ceReconciler.ReconcileSteps = []controllers.ReconcileStepFunc{
749752
controllers.HandleFinalizers(c.finalizers),
753+
controllers.ValidateClusterExtension(
754+
controllers.ServiceAccountValidator(coreClient),
755+
),
750756
controllers.RetrieveRevisionStates(revisionStatesGetter),
751757
controllers.ResolveBundle(c.resolver, c.mgr.GetClient()),
752758
controllers.UnpackBundle(c.imagePuller, c.imageCache),

internal/operator-controller/controllers/clusterextension_controller_test.go

Lines changed: 170 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@ import (
1515
"helm.sh/helm/v3/pkg/chart"
1616
"helm.sh/helm/v3/pkg/release"
1717
"helm.sh/helm/v3/pkg/storage/driver"
18+
corev1 "k8s.io/api/core/v1"
1819
"k8s.io/apimachinery/pkg/api/equality"
1920
apimeta "k8s.io/apimachinery/pkg/api/meta"
2021
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2122
"k8s.io/apimachinery/pkg/types"
2223
"k8s.io/apimachinery/pkg/util/rand"
24+
"k8s.io/client-go/kubernetes/fake"
2325
ctrl "sigs.k8s.io/controller-runtime"
2426
"sigs.k8s.io/controller-runtime/pkg/client"
2527
crfinalizer "sigs.k8s.io/controller-runtime/pkg/finalizer"
@@ -29,7 +31,6 @@ import (
2931
"github.com/operator-framework/operator-registry/alpha/declcfg"
3032

3133
ocv1 "github.com/operator-framework/operator-controller/api/v1"
32-
"github.com/operator-framework/operator-controller/internal/operator-controller/authentication"
3334
"github.com/operator-framework/operator-controller/internal/operator-controller/bundle"
3435
"github.com/operator-framework/operator-controller/internal/operator-controller/conditionsets"
3536
"github.com/operator-framework/operator-controller/internal/operator-controller/controllers"
@@ -767,60 +768,177 @@ func TestClusterExtensionBoxcutterApplierFailsDoesNotLeakDeprecationErrors(t *te
767768
require.NoError(t, cl.DeleteAllOf(ctx, &ocv1.ClusterExtension{}))
768769
}
769770

770-
func TestClusterExtensionServiceAccountNotFound(t *testing.T) {
771-
cl, reconciler := newClientAndReconciler(t, func(d *deps) {
772-
d.RevisionStatesGetter = &MockRevisionStatesGetter{
773-
Err: &authentication.ServiceAccountNotFoundError{
774-
ServiceAccountName: "missing-sa",
775-
ServiceAccountNamespace: "default",
776-
}}
777-
})
778-
779-
ctx := context.Background()
780-
extKey := types.NamespacedName{Name: fmt.Sprintf("cluster-extension-test-%s", rand.String(8))}
781-
782-
t.Log("Given a cluster extension with a missing service account")
783-
clusterExtension := &ocv1.ClusterExtension{
784-
ObjectMeta: metav1.ObjectMeta{Name: extKey.Name},
785-
Spec: ocv1.ClusterExtensionSpec{
786-
Source: ocv1.SourceConfig{
787-
SourceType: "Catalog",
788-
Catalog: &ocv1.CatalogFilter{
789-
PackageName: "test-package",
771+
func TestValidateClusterExtension(t *testing.T) {
772+
tests := []struct {
773+
name string
774+
validators []controllers.ClusterExtensionValidator
775+
expectError bool
776+
errorMessageIncludes string
777+
}{
778+
{
779+
name: "all validators pass",
780+
validators: []controllers.ClusterExtensionValidator{
781+
// Validator that always passes
782+
func(_ context.Context, _ *ocv1.ClusterExtension) error {
783+
return nil
790784
},
791785
},
792-
Namespace: "default",
793-
ServiceAccount: ocv1.ServiceAccountReference{
794-
Name: "missing-sa",
786+
expectError: false,
787+
},
788+
{
789+
name: "validator fails - sets Progressing condition",
790+
validators: []controllers.ClusterExtensionValidator{
791+
func(_ context.Context, _ *ocv1.ClusterExtension) error {
792+
return errors.New("generic validation error")
793+
},
795794
},
795+
expectError: true,
796+
errorMessageIncludes: "generic validation error",
797+
},
798+
{
799+
name: "multiple validators - collects all failures",
800+
validators: []controllers.ClusterExtensionValidator{
801+
// First validator fails
802+
func(_ context.Context, _ *ocv1.ClusterExtension) error {
803+
return errors.New("first validator failed")
804+
},
805+
// Second validator also fails
806+
func(_ context.Context, _ *ocv1.ClusterExtension) error {
807+
return errors.New("second validator failed")
808+
},
809+
// Third validator fails too
810+
func(_ context.Context, _ *ocv1.ClusterExtension) error {
811+
return errors.New("third validator failed")
812+
},
813+
},
814+
expectError: true,
815+
errorMessageIncludes: "first validator failed\nsecond validator failed\nthird validator failed",
816+
},
817+
{
818+
name: "multiple validators - all pass",
819+
validators: []controllers.ClusterExtensionValidator{
820+
func(_ context.Context, _ *ocv1.ClusterExtension) error {
821+
return nil
822+
},
823+
func(_ context.Context, _ *ocv1.ClusterExtension) error {
824+
return nil
825+
},
826+
func(_ context.Context, _ *ocv1.ClusterExtension) error {
827+
return nil
828+
},
829+
},
830+
expectError: false,
831+
},
832+
{
833+
name: "multiple validators - some pass, some fail",
834+
validators: []controllers.ClusterExtensionValidator{
835+
// First validator passes
836+
func(_ context.Context, _ *ocv1.ClusterExtension) error {
837+
return nil
838+
},
839+
// Second validator fails
840+
func(_ context.Context, _ *ocv1.ClusterExtension) error {
841+
return errors.New("validation error 1")
842+
},
843+
// Third validator passes
844+
func(_ context.Context, _ *ocv1.ClusterExtension) error {
845+
return nil
846+
},
847+
// Fourth validator fails
848+
func(_ context.Context, _ *ocv1.ClusterExtension) error {
849+
return errors.New("validation error 2")
850+
},
851+
},
852+
expectError: true,
853+
errorMessageIncludes: "validation error 1\nvalidation error 2",
854+
},
855+
{
856+
name: "service account not found",
857+
validators: []controllers.ClusterExtensionValidator{
858+
serviceAccountValidatorWithFakeClient(&corev1.ServiceAccount{
859+
ObjectMeta: metav1.ObjectMeta{
860+
Name: "missing-sa",
861+
Namespace: "test-ns",
862+
},
863+
}),
864+
},
865+
expectError: true,
866+
errorMessageIncludes: `service account "test-sa" not found in namespace "test-ns"`,
867+
},
868+
{
869+
name: "service account found",
870+
validators: []controllers.ClusterExtensionValidator{
871+
serviceAccountValidatorWithFakeClient(&corev1.ServiceAccount{
872+
ObjectMeta: metav1.ObjectMeta{
873+
Name: "test-sa",
874+
Namespace: "test-ns",
875+
},
876+
}),
877+
},
878+
expectError: false,
796879
},
797880
}
798881

799-
require.NoError(t, cl.Create(ctx, clusterExtension))
882+
for _, tt := range tests {
883+
t.Run(tt.name, func(t *testing.T) {
884+
ctx := context.Background()
800885

801-
t.Log("When reconciling the cluster extension")
802-
res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey})
886+
cl, reconciler := newClientAndReconciler(t, func(d *deps) {
887+
d.RevisionStatesGetter = &MockRevisionStatesGetter{
888+
RevisionStates: &controllers.RevisionStates{},
889+
}
890+
d.Validators = tt.validators
891+
})
803892

804-
require.Equal(t, ctrl.Result{}, res)
805-
require.Error(t, err)
806-
var saErr *authentication.ServiceAccountNotFoundError
807-
require.ErrorAs(t, err, &saErr)
808-
t.Log("By fetching updated cluster extension after reconcile")
809-
require.NoError(t, cl.Get(ctx, extKey, clusterExtension))
893+
extKey := types.NamespacedName{Name: fmt.Sprintf("cluster-extension-test-%s", rand.String(8))}
810894

811-
t.Log("By checking the status conditions")
812-
installedCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypeInstalled)
813-
require.NotNil(t, installedCond)
814-
require.Equal(t, metav1.ConditionUnknown, installedCond.Status)
815-
require.Contains(t, installedCond.Message, fmt.Sprintf("service account %q not found in namespace %q: unable to authenticate with the Kubernetes cluster.",
816-
"missing-sa", "default"))
895+
clusterExtension := &ocv1.ClusterExtension{
896+
ObjectMeta: metav1.ObjectMeta{Name: extKey.Name},
897+
Spec: ocv1.ClusterExtensionSpec{
898+
Source: ocv1.SourceConfig{
899+
SourceType: "Catalog",
900+
Catalog: &ocv1.CatalogFilter{
901+
PackageName: "test-package",
902+
},
903+
},
904+
Namespace: "test-ns",
905+
ServiceAccount: ocv1.ServiceAccountReference{
906+
Name: "test-sa",
907+
},
908+
},
909+
}
817910

818-
progressingCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypeProgressing)
819-
require.NotNil(t, progressingCond)
820-
require.Equal(t, metav1.ConditionTrue, progressingCond.Status)
821-
require.Equal(t, ocv1.ReasonRetrying, progressingCond.Reason)
822-
require.Contains(t, progressingCond.Message, "installation cannot proceed due to missing ServiceAccount")
823-
require.NoError(t, cl.DeleteAllOf(ctx, &ocv1.ClusterExtension{}))
911+
require.NoError(t, cl.Create(ctx, clusterExtension))
912+
913+
t.Log("When reconciling the cluster extension")
914+
res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey})
915+
require.Equal(t, ctrl.Result{}, res)
916+
if tt.expectError {
917+
require.Error(t, err)
918+
t.Log("By fetching updated cluster extension after reconcile")
919+
require.NoError(t, cl.Get(ctx, extKey, clusterExtension))
920+
921+
t.Log("By checking the status conditions")
922+
installedCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypeInstalled)
923+
require.NotNil(t, installedCond)
924+
require.Equal(t, metav1.ConditionUnknown, installedCond.Status)
925+
require.Contains(t, installedCond.Message, "installation cannot proceed due to the following validation error(s)")
926+
require.Contains(t, installedCond.Message, tt.errorMessageIncludes)
927+
928+
progressingCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypeProgressing)
929+
require.NotNil(t, progressingCond)
930+
require.Equal(t, metav1.ConditionTrue, progressingCond.Status)
931+
require.Equal(t, ocv1.ReasonRetrying, progressingCond.Reason)
932+
require.Contains(t, progressingCond.Message, "installation cannot proceed due to the following validation error(s)")
933+
require.Contains(t, progressingCond.Message, tt.errorMessageIncludes)
934+
require.NoError(t, cl.DeleteAllOf(ctx, &ocv1.ClusterExtension{}))
935+
} else {
936+
require.NoError(t, err)
937+
require.NoError(t, cl.Get(ctx, extKey, clusterExtension))
938+
require.Empty(t, clusterExtension.Status.Conditions)
939+
}
940+
})
941+
}
824942
}
825943

826944
func TestClusterExtensionApplierFailsWithBundleInstalled(t *testing.T) {
@@ -2878,3 +2996,10 @@ func TestCheckCatalogsExist(t *testing.T) {
28782996
require.False(t, exists)
28792997
})
28802998
}
2999+
3000+
func serviceAccountValidatorWithFakeClient(serviceAccount *corev1.ServiceAccount) controllers.ClusterExtensionValidator {
3001+
if serviceAccount == nil {
3002+
return controllers.ServiceAccountValidator(fake.NewClientset().CoreV1())
3003+
}
3004+
return controllers.ServiceAccountValidator(fake.NewClientset(serviceAccount).CoreV1())
3005+
}

internal/operator-controller/controllers/clusterextension_reconcile_steps.go

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,16 @@ import (
2121
"errors"
2222
"fmt"
2323

24+
apierrors "k8s.io/apimachinery/pkg/api/errors"
2425
apimeta "k8s.io/apimachinery/pkg/api/meta"
2526
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
27+
corev1client "k8s.io/client-go/kubernetes/typed/core/v1"
2628
ctrl "sigs.k8s.io/controller-runtime"
2729
"sigs.k8s.io/controller-runtime/pkg/client"
2830
"sigs.k8s.io/controller-runtime/pkg/finalizer"
2931
"sigs.k8s.io/controller-runtime/pkg/log"
3032

3133
ocv1 "github.com/operator-framework/operator-controller/api/v1"
32-
"github.com/operator-framework/operator-controller/internal/operator-controller/authentication"
3334
"github.com/operator-framework/operator-controller/internal/operator-controller/bundleutil"
3435
"github.com/operator-framework/operator-controller/internal/operator-controller/labels"
3536
"github.com/operator-framework/operator-controller/internal/operator-controller/resolve"
@@ -63,19 +64,62 @@ func HandleFinalizers(f finalizer.Finalizer) ReconcileStepFunc {
6364
}
6465
}
6566

67+
// ClusterExtensionValidator is a function that validates a ClusterExtension.
68+
// It returns an error if validation fails. Validators are executed sequentially
69+
// in the order they are registered.
70+
type ClusterExtensionValidator func(context.Context, *ocv1.ClusterExtension) error
71+
72+
// ValidateClusterExtension returns a ReconcileStepFunc that executes all
73+
// validators sequentially. All validators are executed even if some fail,
74+
// and all errors are collected and returned as a joined error.
75+
// This provides complete validation feedback in a single reconciliation cycle.
76+
func ValidateClusterExtension(validators ...ClusterExtensionValidator) ReconcileStepFunc {
77+
return func(ctx context.Context, state *reconcileState, ext *ocv1.ClusterExtension) (*ctrl.Result, error) {
78+
l := log.FromContext(ctx)
79+
80+
l.Info("validating cluster extension")
81+
var validationErrors []error
82+
for _, validator := range validators {
83+
if err := validator(ctx, ext); err != nil {
84+
validationErrors = append(validationErrors, err)
85+
}
86+
}
87+
88+
// If there are no validation errors, continue reconciliation
89+
if len(validationErrors) == 0 {
90+
return nil, nil
91+
}
92+
93+
// Set status condition with the validation error for other validation failures
94+
err := fmt.Errorf("installation cannot proceed due to the following validation error(s): %w", errors.Join(validationErrors...))
95+
setInstalledStatusConditionUnknown(ext, err.Error())
96+
setStatusProgressing(ext, err)
97+
return nil, err
98+
}
99+
}
100+
101+
// ServiceAccountValidator returns a validator that checks if the specified
102+
// ServiceAccount exists in the cluster by performing a direct Get call.
103+
func ServiceAccountValidator(saClient corev1client.ServiceAccountsGetter) ClusterExtensionValidator {
104+
return func(ctx context.Context, ext *ocv1.ClusterExtension) error {
105+
_, err := saClient.ServiceAccounts(ext.Spec.Namespace).Get(ctx, ext.Spec.ServiceAccount.Name, metav1.GetOptions{})
106+
if err != nil {
107+
if apierrors.IsNotFound(err) {
108+
return fmt.Errorf("service account %q not found in namespace %q", ext.Spec.ServiceAccount.Name, ext.Spec.Namespace)
109+
}
110+
return fmt.Errorf("error validating service account: %w", err)
111+
}
112+
return nil
113+
}
114+
}
115+
66116
func RetrieveRevisionStates(r RevisionStatesGetter) ReconcileStepFunc {
67117
return func(ctx context.Context, state *reconcileState, ext *ocv1.ClusterExtension) (*ctrl.Result, error) {
68118
l := log.FromContext(ctx)
69119
l.Info("getting installed bundle")
70120
revisionStates, err := r.GetRevisionStates(ctx, ext)
71121
if err != nil {
72122
setInstallStatus(ext, nil)
73-
var saerr *authentication.ServiceAccountNotFoundError
74-
if errors.As(err, &saerr) {
75-
setInstalledStatusConditionUnknown(ext, saerr.Error())
76-
setStatusProgressing(ext, errors.New("installation cannot proceed due to missing ServiceAccount"))
77-
return nil, err
78-
}
79123
setInstalledStatusConditionUnknown(ext, err.Error())
80124
setStatusProgressing(ext, errors.New("retrying to get installed bundle"))
81125
return nil, err

internal/operator-controller/controllers/suite_test.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ type deps struct {
8787
ImagePuller image.Puller
8888
ImageCache image.Cache
8989
Applier controllers.Applier
90+
Validators []controllers.ClusterExtensionValidator
9091
}
9192

9293
func newClientAndReconciler(t *testing.T, opts ...reconcilerOption) (client.Client, *controllers.ClusterExtensionReconciler) {
@@ -104,7 +105,11 @@ func newClientAndReconciler(t *testing.T, opts ...reconcilerOption) (client.Clie
104105
for _, opt := range opts {
105106
opt(d)
106107
}
107-
reconciler.ReconcileSteps = []controllers.ReconcileStepFunc{controllers.HandleFinalizers(d.Finalizers), controllers.RetrieveRevisionStates(d.RevisionStatesGetter)}
108+
reconciler.ReconcileSteps = []controllers.ReconcileStepFunc{
109+
controllers.HandleFinalizers(d.Finalizers),
110+
controllers.ValidateClusterExtension(d.Validators...),
111+
controllers.RetrieveRevisionStates(d.RevisionStatesGetter),
112+
}
108113
if r := d.Resolver; r != nil {
109114
reconciler.ReconcileSteps = append(reconciler.ReconcileSteps, controllers.ResolveBundle(r, cl))
110115
}

0 commit comments

Comments
 (0)