Skip to content

Commit cf4cdb1

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 to catch configuration errors before expensive operations begin. The first validator checks whether the specified ServiceAccount exists, providing clear feedback when it's missing. The validation step: - Executes all validators and collects all errors (no fail-fast) - Reuses the same TokenGetter cache for both validation and bundle operations - Sets appropriate status conditions (Installed=Unknown, Progressing=Retrying) - Provides user-friendly error messages This architectural improvement separates validation concerns from revision state retrieval, making it easier to add new validators in the future. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> Signed-off-by: Per Goncalves da Silva <pegoncal@redhat.com>
1 parent 910fa59 commit cf4cdb1

File tree

5 files changed

+291
-23
lines changed

5 files changed

+291
-23
lines changed

cmd/operator-controller/main.go

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -625,8 +625,12 @@ func (c *boxcutterReconcilerConfigurator) Configure(ceReconciler *controllers.Cl
625625
ActionClientGetter: acg,
626626
RevisionGenerator: rg,
627627
}
628+
tokenGetter := authentication.NewTokenGetter(coreClient, authentication.WithExpirationDuration(1*time.Hour))
628629
ceReconciler.ReconcileSteps = []controllers.ReconcileStepFunc{
629630
controllers.HandleFinalizers(c.finalizers),
631+
controllers.ValidateClusterExtension(
632+
controllers.ServiceAccountValidator(tokenGetter),
633+
),
630634
controllers.MigrateStorage(storageMigrator),
631635
controllers.RetrieveRevisionStates(revisionStatesGetter),
632636
controllers.ResolveBundle(c.resolver, c.mgr.GetClient()),
@@ -656,20 +660,14 @@ func (c *boxcutterReconcilerConfigurator) Configure(ceReconciler *controllers.Cl
656660
return fmt.Errorf("unable to add tracking cache to manager: %v", err)
657661
}
658662

659-
cerCoreClient, err := corev1client.NewForConfig(c.mgr.GetConfig())
660-
if err != nil {
661-
return fmt.Errorf("unable to create client for ClusterExtensionRevision controller: %w", err)
662-
}
663-
cerTokenGetter := authentication.NewTokenGetter(cerCoreClient, authentication.WithExpirationDuration(1*time.Hour))
664-
665663
revisionEngineFactory, err := controllers.NewDefaultRevisionEngineFactory(
666664
c.mgr.GetScheme(),
667665
trackingCache,
668666
discoveryClient,
669667
c.mgr.GetRESTMapper(),
670668
fieldOwnerPrefix,
671669
c.mgr.GetConfig(),
672-
cerTokenGetter,
670+
tokenGetter,
673671
)
674672
if err != nil {
675673
return fmt.Errorf("unable to create revision engine factory: %w", err)
@@ -747,6 +745,9 @@ func (c *helmReconcilerConfigurator) Configure(ceReconciler *controllers.Cluster
747745
revisionStatesGetter := &controllers.HelmRevisionStatesGetter{ActionClientGetter: acg}
748746
ceReconciler.ReconcileSteps = []controllers.ReconcileStepFunc{
749747
controllers.HandleFinalizers(c.finalizers),
748+
controllers.ValidateClusterExtension(
749+
controllers.ServiceAccountValidator(tokenGetter),
750+
),
750751
controllers.RetrieveRevisionStates(revisionStatesGetter),
751752
controllers.ResolveBundle(c.resolver, c.mgr.GetClient()),
752753
controllers.UnpackBundle(c.imagePuller, c.imageCache),

internal/operator-controller/controllers/clusterextension_controller_test.go

Lines changed: 195 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,14 @@ 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"
22+
"k8s.io/apimachinery/pkg/runtime"
2123
"k8s.io/apimachinery/pkg/types"
2224
"k8s.io/apimachinery/pkg/util/rand"
25+
"k8s.io/client-go/kubernetes/fake"
2326
ctrl "sigs.k8s.io/controller-runtime"
2427
"sigs.k8s.io/controller-runtime/pkg/client"
2528
crfinalizer "sigs.k8s.io/controller-runtime/pkg/finalizer"
@@ -768,14 +771,26 @@ func TestClusterExtensionBoxcutterApplierFailsDoesNotLeakDeprecationErrors(t *te
768771
}
769772

770773
func TestClusterExtensionServiceAccountNotFound(t *testing.T) {
774+
// Create fake Kubernetes clientset (without creating the service account)
775+
fakeClientset := fake.NewClientset()
776+
777+
// Create concrete TokenGetter with the fake client
778+
tokenGetter := authentication.NewTokenGetter(fakeClientset.CoreV1())
779+
771780
cl, reconciler := newClientAndReconciler(t, func(d *deps) {
772781
d.RevisionStatesGetter = &MockRevisionStatesGetter{
773-
Err: &authentication.ServiceAccountNotFoundError{
774-
ServiceAccountName: "missing-sa",
775-
ServiceAccountNamespace: "default",
776-
}}
782+
RevisionStates: &controllers.RevisionStates{},
783+
}
777784
})
778785

786+
// Add validation step to the beginning of the reconcile steps
787+
reconciler.ReconcileSteps = append(
788+
[]controllers.ReconcileStepFunc{controllers.ValidateClusterExtension(
789+
controllers.ServiceAccountValidator(tokenGetter),
790+
)},
791+
reconciler.ReconcileSteps...,
792+
)
793+
779794
ctx := context.Background()
780795
extKey := types.NamespacedName{Name: fmt.Sprintf("cluster-extension-test-%s", rand.String(8))}
781796

@@ -803,26 +818,197 @@ func TestClusterExtensionServiceAccountNotFound(t *testing.T) {
803818

804819
require.Equal(t, ctrl.Result{}, res)
805820
require.Error(t, err)
806-
var saErr *authentication.ServiceAccountNotFoundError
807-
require.ErrorAs(t, err, &saErr)
808821
t.Log("By fetching updated cluster extension after reconcile")
809822
require.NoError(t, cl.Get(ctx, extKey, clusterExtension))
810823

811824
t.Log("By checking the status conditions")
812825
installedCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypeInstalled)
813826
require.NotNil(t, installedCond)
814827
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"))
828+
require.Contains(t, installedCond.Message, "installation cannot proceed due to the following validation error(s)")
829+
require.Contains(t, installedCond.Message, "service account \"missing-sa\" not found")
817830

818831
progressingCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypeProgressing)
819832
require.NotNil(t, progressingCond)
820833
require.Equal(t, metav1.ConditionTrue, progressingCond.Status)
821834
require.Equal(t, ocv1.ReasonRetrying, progressingCond.Reason)
822-
require.Contains(t, progressingCond.Message, "installation cannot proceed due to missing ServiceAccount")
835+
require.Contains(t, progressingCond.Message, "installation cannot proceed due to the following validation error(s)")
836+
require.Contains(t, progressingCond.Message, "service account \"missing-sa\" not found")
823837
require.NoError(t, cl.DeleteAllOf(ctx, &ocv1.ClusterExtension{}))
824838
}
825839

840+
func TestValidateClusterExtension(t *testing.T) {
841+
tests := []struct {
842+
name string
843+
validators []controllers.ClusterExtensionValidator
844+
expectError bool
845+
errorMessageIncludes string
846+
}{
847+
{
848+
name: "all validators pass",
849+
validators: []controllers.ClusterExtensionValidator{
850+
// Validator that always passes
851+
func(_ context.Context, _ *ocv1.ClusterExtension) error {
852+
return nil
853+
},
854+
},
855+
expectError: false,
856+
},
857+
{
858+
name: "validator fails - sets Progressing condition",
859+
validators: []controllers.ClusterExtensionValidator{
860+
func(_ context.Context, _ *ocv1.ClusterExtension) error {
861+
return errors.New("generic validation error")
862+
},
863+
},
864+
expectError: true,
865+
errorMessageIncludes: "generic validation error",
866+
},
867+
{
868+
name: "multiple validators - collects all failures",
869+
validators: []controllers.ClusterExtensionValidator{
870+
// First validator fails
871+
func(_ context.Context, _ *ocv1.ClusterExtension) error {
872+
return errors.New("first validator failed")
873+
},
874+
// Second validator also fails
875+
func(_ context.Context, _ *ocv1.ClusterExtension) error {
876+
return errors.New("second validator failed")
877+
},
878+
// Third validator fails too
879+
func(_ context.Context, _ *ocv1.ClusterExtension) error {
880+
return errors.New("third validator failed")
881+
},
882+
},
883+
expectError: true,
884+
errorMessageIncludes: "first validator failed\nsecond validator failed\nthird validator failed",
885+
},
886+
{
887+
name: "multiple validators - all pass",
888+
validators: []controllers.ClusterExtensionValidator{
889+
func(_ context.Context, _ *ocv1.ClusterExtension) error {
890+
return nil
891+
},
892+
func(_ context.Context, _ *ocv1.ClusterExtension) error {
893+
return nil
894+
},
895+
func(_ context.Context, _ *ocv1.ClusterExtension) error {
896+
return nil
897+
},
898+
},
899+
expectError: false,
900+
},
901+
{
902+
name: "multiple validators - some pass, some fail",
903+
validators: []controllers.ClusterExtensionValidator{
904+
// First validator passes
905+
func(_ context.Context, _ *ocv1.ClusterExtension) error {
906+
return nil
907+
},
908+
// Second validator fails
909+
func(_ context.Context, _ *ocv1.ClusterExtension) error {
910+
return errors.New("validation error 1")
911+
},
912+
// Third validator passes
913+
func(_ context.Context, _ *ocv1.ClusterExtension) error {
914+
return nil
915+
},
916+
// Fourth validator fails
917+
func(_ context.Context, _ *ocv1.ClusterExtension) error {
918+
return errors.New("validation error 2")
919+
},
920+
},
921+
expectError: true,
922+
errorMessageIncludes: "validation error 1\nvalidation error 2",
923+
},
924+
{
925+
name: "service account not found",
926+
validators: []controllers.ClusterExtensionValidator{
927+
newServiceAccountValidator(),
928+
},
929+
expectError: true,
930+
errorMessageIncludes: "service account \"missing-sa\" not found",
931+
},
932+
{
933+
name: "service account found",
934+
validators: []controllers.ClusterExtensionValidator{
935+
newServiceAccountValidator(&corev1.ServiceAccount{
936+
ObjectMeta: metav1.ObjectMeta{
937+
Name: "test-sa",
938+
Namespace: "test-namespace",
939+
},
940+
}),
941+
},
942+
expectError: false,
943+
},
944+
}
945+
946+
for _, tt := range tests {
947+
t.Run(tt.name, func(t *testing.T) {
948+
ctx := context.Background()
949+
950+
cl, reconciler := newClientAndReconciler(t, func(d *deps) {
951+
d.RevisionStatesGetter = &MockRevisionStatesGetter{
952+
RevisionStates: &controllers.RevisionStates{},
953+
}
954+
d.Validators = tt.validators
955+
})
956+
957+
extKey := types.NamespacedName{Name: fmt.Sprintf("cluster-extension-test-%s", rand.String(8))}
958+
959+
t.Log("Given a cluster extension with a missing service account")
960+
clusterExtension := &ocv1.ClusterExtension{
961+
ObjectMeta: metav1.ObjectMeta{Name: extKey.Name},
962+
Spec: ocv1.ClusterExtensionSpec{
963+
Source: ocv1.SourceConfig{
964+
SourceType: "Catalog",
965+
Catalog: &ocv1.CatalogFilter{
966+
PackageName: "test-package",
967+
},
968+
},
969+
Namespace: "default",
970+
ServiceAccount: ocv1.ServiceAccountReference{
971+
Name: "missing-sa",
972+
},
973+
},
974+
}
975+
976+
require.NoError(t, cl.Create(ctx, clusterExtension))
977+
978+
t.Log("When reconciling the cluster extension")
979+
res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey})
980+
require.Equal(t, ctrl.Result{}, res)
981+
if tt.expectError {
982+
require.Error(t, err)
983+
t.Log("By fetching updated cluster extension after reconcile")
984+
require.NoError(t, cl.Get(ctx, extKey, clusterExtension))
985+
986+
t.Log("By checking the status conditions")
987+
installedCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypeInstalled)
988+
require.NotNil(t, installedCond)
989+
require.Equal(t, metav1.ConditionUnknown, installedCond.Status)
990+
require.Contains(t, installedCond.Message, "installation cannot proceed due to the following validation error(s)")
991+
require.Contains(t, installedCond.Message, tt.errorMessageIncludes)
992+
993+
progressingCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypeProgressing)
994+
require.NotNil(t, progressingCond)
995+
require.Equal(t, metav1.ConditionTrue, progressingCond.Status)
996+
require.Equal(t, ocv1.ReasonRetrying, progressingCond.Reason)
997+
require.Contains(t, progressingCond.Message, "installation cannot proceed due to the following validation error(s)")
998+
require.Contains(t, progressingCond.Message, tt.errorMessageIncludes)
999+
require.NoError(t, cl.DeleteAllOf(ctx, &ocv1.ClusterExtension{}))
1000+
}
1001+
})
1002+
}
1003+
}
1004+
1005+
func newServiceAccountValidator(objs ...runtime.Object) controllers.ClusterExtensionValidator {
1006+
fakeClientset := fake.NewClientset(objs...)
1007+
tokenGetter := authentication.NewTokenGetter(fakeClientset.CoreV1())
1008+
1009+
return controllers.ServiceAccountValidator(tokenGetter)
1010+
}
1011+
8261012
func TestClusterExtensionApplierFailsWithBundleInstalled(t *testing.T) {
8271013
mockApplier := &MockApplier{
8281014
installCompleted: true,

internal/operator-controller/controllers/clusterextension_reconcile_steps.go

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323

2424
apimeta "k8s.io/apimachinery/pkg/api/meta"
2525
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
26+
"k8s.io/apimachinery/pkg/types"
2627
ctrl "sigs.k8s.io/controller-runtime"
2728
"sigs.k8s.io/controller-runtime/pkg/client"
2829
"sigs.k8s.io/controller-runtime/pkg/finalizer"
@@ -63,19 +64,70 @@ 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 using the TokenGetter to fetch a token.
103+
// The TokenGetter maintains an internal cache, so this validation reuses tokens
104+
// instead of creating new ones on every validation attempt. The same token will
105+
// be reused for actual bundle operations.
106+
func ServiceAccountValidator(tokenGetter *authentication.TokenGetter) ClusterExtensionValidator {
107+
return func(ctx context.Context, ext *ocv1.ClusterExtension) error {
108+
saKey := types.NamespacedName{
109+
Name: ext.Spec.ServiceAccount.Name,
110+
Namespace: ext.Spec.Namespace,
111+
}
112+
_, err := tokenGetter.Get(ctx, saKey)
113+
if err != nil {
114+
var saErr *authentication.ServiceAccountNotFoundError
115+
if errors.As(err, &saErr) {
116+
return fmt.Errorf("service account %q not found in namespace %q", saKey.Name, saKey.Namespace)
117+
}
118+
return fmt.Errorf("error validating service account: %w", err)
119+
}
120+
return nil
121+
}
122+
}
123+
66124
func RetrieveRevisionStates(r RevisionStatesGetter) ReconcileStepFunc {
67125
return func(ctx context.Context, state *reconcileState, ext *ocv1.ClusterExtension) (*ctrl.Result, error) {
68126
l := log.FromContext(ctx)
69127
l.Info("getting installed bundle")
70128
revisionStates, err := r.GetRevisionStates(ctx, ext)
71129
if err != nil {
72130
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-
}
79131
setInstalledStatusConditionUnknown(ext, err.Error())
80132
setStatusProgressing(ext, errors.New("retrying to get installed bundle"))
81133
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)