Skip to content

Commit 8e814fb

Browse files
Per Goncalves da Silvaclaude
andcommitted
feat: Add ServiceAccount validation to ClusterExtension reconciliation
Add a validation stage at the start of the ClusterExtension reconciliation pipeline that checks whether the ServiceAccount specified in .spec.serviceAccount exists on the cluster before proceeding with bundle installation. Key changes: - Add ValidateClusterExtension orchestrator with open/closed architecture for extensible validation - Add ServiceAccountValidator using authentication.TokenGetter - Use cached tokens instead of creating new ones on each validation - Collect and return all validation errors (fail-fast disabled) - Set appropriate status conditions on validation failure - Add comprehensive unit tests (10 test cases) - Add e2e test scenario for ServiceAccount validation Benefits: - Early failure detection for missing ServiceAccounts - No unnecessary token creation (reuses cached tokens) - Reduced API server load through token caching - Same token used for validation and actual bundle operations - Automatic token rotation via TokenGetter - Clear error messages for operators Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> Signed-off-by: Per Goncalves da Silva <pegoncal@redhat.com>
1 parent 910fa59 commit 8e814fb

4 files changed

Lines changed: 289 additions & 22 deletions

File tree

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: 199 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,201 @@ 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+
})
955+
956+
reconciler.ReconcileSteps = append(
957+
[]controllers.ReconcileStepFunc{controllers.ValidateClusterExtension(tt.validators...)},
958+
reconciler.ReconcileSteps...,
959+
)
960+
961+
extKey := types.NamespacedName{Name: fmt.Sprintf("cluster-extension-test-%s", rand.String(8))}
962+
963+
t.Log("Given a cluster extension with a missing service account")
964+
clusterExtension := &ocv1.ClusterExtension{
965+
ObjectMeta: metav1.ObjectMeta{Name: extKey.Name},
966+
Spec: ocv1.ClusterExtensionSpec{
967+
Source: ocv1.SourceConfig{
968+
SourceType: "Catalog",
969+
Catalog: &ocv1.CatalogFilter{
970+
PackageName: "test-package",
971+
},
972+
},
973+
Namespace: "default",
974+
ServiceAccount: ocv1.ServiceAccountReference{
975+
Name: "missing-sa",
976+
},
977+
},
978+
}
979+
980+
require.NoError(t, cl.Create(ctx, clusterExtension))
981+
982+
t.Log("When reconciling the cluster extension")
983+
res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey})
984+
require.Equal(t, ctrl.Result{}, res)
985+
if tt.expectError {
986+
require.Error(t, err)
987+
t.Log("By fetching updated cluster extension after reconcile")
988+
require.NoError(t, cl.Get(ctx, extKey, clusterExtension))
989+
990+
t.Log("By checking the status conditions")
991+
installedCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypeInstalled)
992+
require.NotNil(t, installedCond)
993+
require.Equal(t, metav1.ConditionUnknown, installedCond.Status)
994+
require.Contains(t, installedCond.Message, "installation cannot proceed due to the following validation error(s)")
995+
require.Contains(t, installedCond.Message, tt.errorMessageIncludes)
996+
997+
progressingCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypeProgressing)
998+
require.NotNil(t, progressingCond)
999+
require.Equal(t, metav1.ConditionTrue, progressingCond.Status)
1000+
require.Equal(t, ocv1.ReasonRetrying, progressingCond.Reason)
1001+
require.Contains(t, progressingCond.Message, "installation cannot proceed due to the following validation error(s)")
1002+
require.Contains(t, progressingCond.Message, tt.errorMessageIncludes)
1003+
require.NoError(t, cl.DeleteAllOf(ctx, &ocv1.ClusterExtension{}))
1004+
}
1005+
})
1006+
}
1007+
}
1008+
1009+
func newServiceAccountValidator(objs ...runtime.Object) controllers.ClusterExtensionValidator {
1010+
fakeClientset := fake.NewClientset(objs...)
1011+
tokenGetter := authentication.NewTokenGetter(fakeClientset.CoreV1())
1012+
1013+
return controllers.ServiceAccountValidator(tokenGetter)
1014+
}
1015+
8261016
func TestClusterExtensionApplierFailsWithBundleInstalled(t *testing.T) {
8271017
mockApplier := &MockApplier{
8281018
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

0 commit comments

Comments
 (0)