Skip to content

Commit 81c68c3

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 81c68c3

5 files changed

Lines changed: 290 additions & 58 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: 194 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,16 @@ import (
1515
"helm.sh/helm/v3/pkg/chart"
1616
"helm.sh/helm/v3/pkg/release"
1717
"helm.sh/helm/v3/pkg/storage/driver"
18+
authenticationv1 "k8s.io/api/authentication/v1"
19+
corev1 "k8s.io/api/core/v1"
1820
"k8s.io/apimachinery/pkg/api/equality"
1921
apimeta "k8s.io/apimachinery/pkg/api/meta"
2022
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
23+
"k8s.io/apimachinery/pkg/runtime"
2124
"k8s.io/apimachinery/pkg/types"
2225
"k8s.io/apimachinery/pkg/util/rand"
26+
"k8s.io/client-go/kubernetes/fake"
27+
clientgotesting "k8s.io/client-go/testing"
2328
ctrl "sigs.k8s.io/controller-runtime"
2429
"sigs.k8s.io/controller-runtime/pkg/client"
2530
crfinalizer "sigs.k8s.io/controller-runtime/pkg/finalizer"
@@ -767,60 +772,205 @@ func TestClusterExtensionBoxcutterApplierFailsDoesNotLeakDeprecationErrors(t *te
767772
require.NoError(t, cl.DeleteAllOf(ctx, &ocv1.ClusterExtension{}))
768773
}
769774

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",
775+
func TestValidateClusterExtension(t *testing.T) {
776+
tests := []struct {
777+
name string
778+
validators []controllers.ClusterExtensionValidator
779+
expectError bool
780+
errorMessageIncludes string
781+
}{
782+
{
783+
name: "all validators pass",
784+
validators: []controllers.ClusterExtensionValidator{
785+
// Validator that always passes
786+
func(_ context.Context, _ *ocv1.ClusterExtension) error {
787+
return nil
790788
},
791789
},
792-
Namespace: "default",
793-
ServiceAccount: ocv1.ServiceAccountReference{
794-
Name: "missing-sa",
790+
expectError: false,
791+
},
792+
{
793+
name: "validator fails - sets Progressing condition",
794+
validators: []controllers.ClusterExtensionValidator{
795+
func(_ context.Context, _ *ocv1.ClusterExtension) error {
796+
return errors.New("generic validation error")
797+
},
798+
},
799+
expectError: true,
800+
errorMessageIncludes: "generic validation error",
801+
},
802+
{
803+
name: "multiple validators - collects all failures",
804+
validators: []controllers.ClusterExtensionValidator{
805+
// First validator fails
806+
func(_ context.Context, _ *ocv1.ClusterExtension) error {
807+
return errors.New("first validator failed")
808+
},
809+
// Second validator also fails
810+
func(_ context.Context, _ *ocv1.ClusterExtension) error {
811+
return errors.New("second validator failed")
812+
},
813+
// Third validator fails too
814+
func(_ context.Context, _ *ocv1.ClusterExtension) error {
815+
return errors.New("third validator failed")
816+
},
817+
},
818+
expectError: true,
819+
errorMessageIncludes: "first validator failed\nsecond validator failed\nthird validator failed",
820+
},
821+
{
822+
name: "multiple validators - all pass",
823+
validators: []controllers.ClusterExtensionValidator{
824+
func(_ context.Context, _ *ocv1.ClusterExtension) error {
825+
return nil
826+
},
827+
func(_ context.Context, _ *ocv1.ClusterExtension) error {
828+
return nil
829+
},
830+
func(_ context.Context, _ *ocv1.ClusterExtension) error {
831+
return nil
832+
},
833+
},
834+
expectError: false,
835+
},
836+
{
837+
name: "multiple validators - some pass, some fail",
838+
validators: []controllers.ClusterExtensionValidator{
839+
// First validator passes
840+
func(_ context.Context, _ *ocv1.ClusterExtension) error {
841+
return nil
842+
},
843+
// Second validator fails
844+
func(_ context.Context, _ *ocv1.ClusterExtension) error {
845+
return errors.New("validation error 1")
846+
},
847+
// Third validator passes
848+
func(_ context.Context, _ *ocv1.ClusterExtension) error {
849+
return nil
850+
},
851+
// Fourth validator fails
852+
func(_ context.Context, _ *ocv1.ClusterExtension) error {
853+
return errors.New("validation error 2")
854+
},
855+
},
856+
expectError: true,
857+
errorMessageIncludes: "validation error 1\nvalidation error 2",
858+
},
859+
{
860+
name: "service account not found",
861+
validators: []controllers.ClusterExtensionValidator{
862+
serviceAccountValidatorWithFakeClient(&corev1.ServiceAccount{
863+
ObjectMeta: metav1.ObjectMeta{
864+
Name: "missing-sa",
865+
Namespace: "test-ns",
866+
},
867+
}),
868+
},
869+
expectError: true,
870+
errorMessageIncludes: `service account "test-sa" not found in namespace "test-ns"`,
871+
},
872+
{
873+
name: "service account found",
874+
validators: []controllers.ClusterExtensionValidator{
875+
serviceAccountValidatorWithFakeClient(&corev1.ServiceAccount{
876+
ObjectMeta: metav1.ObjectMeta{
877+
Name: "test-sa",
878+
Namespace: "test-ns",
879+
},
880+
}),
795881
},
882+
expectError: false,
796883
},
797884
}
798885

799-
require.NoError(t, cl.Create(ctx, clusterExtension))
886+
for _, tt := range tests {
887+
t.Run(tt.name, func(t *testing.T) {
888+
ctx := context.Background()
800889

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

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))
897+
extKey := types.NamespacedName{Name: fmt.Sprintf("cluster-extension-test-%s", rand.String(8))}
810898

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"))
899+
clusterExtension := &ocv1.ClusterExtension{
900+
ObjectMeta: metav1.ObjectMeta{Name: extKey.Name},
901+
Spec: ocv1.ClusterExtensionSpec{
902+
Source: ocv1.SourceConfig{
903+
SourceType: "Catalog",
904+
Catalog: &ocv1.CatalogFilter{
905+
PackageName: "test-package",
906+
},
907+
},
908+
Namespace: "test-ns",
909+
ServiceAccount: ocv1.ServiceAccountReference{
910+
Name: "test-sa",
911+
},
912+
},
913+
}
817914

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{}))
915+
require.NoError(t, cl.Create(ctx, clusterExtension))
916+
917+
t.Log("When reconciling the cluster extension")
918+
res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey})
919+
require.Equal(t, ctrl.Result{}, res)
920+
if tt.expectError {
921+
require.Error(t, err)
922+
t.Log("By fetching updated cluster extension after reconcile")
923+
require.NoError(t, cl.Get(ctx, extKey, clusterExtension))
924+
925+
t.Log("By checking the status conditions")
926+
installedCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypeInstalled)
927+
require.NotNil(t, installedCond)
928+
require.Equal(t, metav1.ConditionUnknown, installedCond.Status)
929+
require.Contains(t, installedCond.Message, "installation cannot proceed due to the following validation error(s)")
930+
require.Contains(t, installedCond.Message, tt.errorMessageIncludes)
931+
932+
progressingCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypeProgressing)
933+
require.NotNil(t, progressingCond)
934+
require.Equal(t, metav1.ConditionTrue, progressingCond.Status)
935+
require.Equal(t, ocv1.ReasonRetrying, progressingCond.Reason)
936+
require.Contains(t, progressingCond.Message, "installation cannot proceed due to the following validation error(s)")
937+
require.Contains(t, progressingCond.Message, tt.errorMessageIncludes)
938+
require.NoError(t, cl.DeleteAllOf(ctx, &ocv1.ClusterExtension{}))
939+
} else {
940+
require.NoError(t, err)
941+
require.NoError(t, cl.Get(ctx, extKey, clusterExtension))
942+
require.Empty(t, clusterExtension.Status.Conditions)
943+
}
944+
})
945+
}
946+
}
947+
948+
func serviceAccountValidatorWithFakeClient(serviceAccount *corev1.ServiceAccount) controllers.ClusterExtensionValidator {
949+
if serviceAccount == nil {
950+
return controllers.ServiceAccountValidator(authentication.NewTokenGetter(fake.NewClientset().CoreV1()))
951+
}
952+
fakeClientset := fake.NewClientset(serviceAccount)
953+
fakeClientset.PrependReactor("create", "serviceaccounts", func(action clientgotesting.Action) (bool, runtime.Object, error) {
954+
if action.GetSubresource() != "token" || action.GetNamespace() != serviceAccount.Namespace {
955+
// Let other reactors handle standard SA creates
956+
return false, nil, nil
957+
}
958+
createAction, ok := action.(clientgotesting.CreateActionImpl)
959+
if !ok {
960+
return false, nil, fmt.Errorf("unexpected action: expected CreateActionImpl but got %#v", action)
961+
}
962+
if createAction.GetNamespace() != serviceAccount.Namespace || createAction.Name != serviceAccount.Name {
963+
return false, nil, nil
964+
}
965+
tr := &authenticationv1.TokenRequest{
966+
Status: authenticationv1.TokenRequestStatus{
967+
Token: "fake-jwt-token-string",
968+
},
969+
}
970+
return true, tr, nil
971+
})
972+
tokenGetter := authentication.NewTokenGetter(fakeClientset.CoreV1())
973+
return controllers.ServiceAccountValidator(tokenGetter)
824974
}
825975

826976
func TestClusterExtensionApplierFailsWithBundleInstalled(t *testing.T) {

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)