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
6 changes: 6 additions & 0 deletions cmd/operator-controller/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,9 @@ func (c *boxcutterReconcilerConfigurator) Configure(ceReconciler *controllers.Cl
}
ceReconciler.ReconcileSteps = []controllers.ReconcileStepFunc{
controllers.HandleFinalizers(c.finalizers),
controllers.ValidateClusterExtension(
controllers.ServiceAccountValidator(coreClient),
),
controllers.MigrateStorage(storageMigrator),
controllers.RetrieveRevisionStates(revisionStatesGetter),
controllers.ResolveBundle(c.resolver, c.mgr.GetClient()),
Expand Down Expand Up @@ -747,6 +750,9 @@ func (c *helmReconcilerConfigurator) Configure(ceReconciler *controllers.Cluster
revisionStatesGetter := &controllers.HelmRevisionStatesGetter{ActionClientGetter: acg}
ceReconciler.ReconcileSteps = []controllers.ReconcileStepFunc{
controllers.HandleFinalizers(c.finalizers),
controllers.ValidateClusterExtension(
controllers.ServiceAccountValidator(coreClient),
),
controllers.RetrieveRevisionStates(revisionStatesGetter),
controllers.ResolveBundle(c.resolver, c.mgr.GetClient()),
controllers.UnpackBundle(c.imagePuller, c.imageCache),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ rules:
- serviceaccounts/token
verbs:
- create
- apiGroups:
- ""
resources:
- serviceaccounts
verbs:
- get
- apiGroups:
- apiextensions.k8s.io
resources:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ import (
"helm.sh/helm/v3/pkg/chart"
"helm.sh/helm/v3/pkg/release"
"helm.sh/helm/v3/pkg/storage/driver"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/equality"
apimeta "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/rand"
"k8s.io/client-go/kubernetes/fake"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
crfinalizer "sigs.k8s.io/controller-runtime/pkg/finalizer"
Expand All @@ -29,7 +31,6 @@ import (
"github.com/operator-framework/operator-registry/alpha/declcfg"

ocv1 "github.com/operator-framework/operator-controller/api/v1"
"github.com/operator-framework/operator-controller/internal/operator-controller/authentication"
"github.com/operator-framework/operator-controller/internal/operator-controller/bundle"
"github.com/operator-framework/operator-controller/internal/operator-controller/conditionsets"
"github.com/operator-framework/operator-controller/internal/operator-controller/controllers"
Expand Down Expand Up @@ -767,60 +768,177 @@ func TestClusterExtensionBoxcutterApplierFailsDoesNotLeakDeprecationErrors(t *te
require.NoError(t, cl.DeleteAllOf(ctx, &ocv1.ClusterExtension{}))
}

func TestClusterExtensionServiceAccountNotFound(t *testing.T) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I moved this use-case into a more general test against validation

cl, reconciler := newClientAndReconciler(t, func(d *deps) {
d.RevisionStatesGetter = &MockRevisionStatesGetter{
Err: &authentication.ServiceAccountNotFoundError{
ServiceAccountName: "missing-sa",
ServiceAccountNamespace: "default",
}}
})

ctx := context.Background()
extKey := types.NamespacedName{Name: fmt.Sprintf("cluster-extension-test-%s", rand.String(8))}

t.Log("Given a cluster extension with a missing service account")
clusterExtension := &ocv1.ClusterExtension{
ObjectMeta: metav1.ObjectMeta{Name: extKey.Name},
Spec: ocv1.ClusterExtensionSpec{
Source: ocv1.SourceConfig{
SourceType: "Catalog",
Catalog: &ocv1.CatalogFilter{
PackageName: "test-package",
func TestValidateClusterExtension(t *testing.T) {
tests := []struct {
name string
validators []controllers.ClusterExtensionValidator
expectError bool
errorMessageIncludes string
}{
{
name: "all validators pass",
validators: []controllers.ClusterExtensionValidator{
// Validator that always passes
func(_ context.Context, _ *ocv1.ClusterExtension) error {
return nil
},
},
Namespace: "default",
ServiceAccount: ocv1.ServiceAccountReference{
Name: "missing-sa",
expectError: false,
},
{
name: "validator fails - sets Progressing condition",
validators: []controllers.ClusterExtensionValidator{
func(_ context.Context, _ *ocv1.ClusterExtension) error {
return errors.New("generic validation error")
},
},
expectError: true,
errorMessageIncludes: "generic validation error",
},
{
name: "multiple validators - collects all failures",
validators: []controllers.ClusterExtensionValidator{
// First validator fails
func(_ context.Context, _ *ocv1.ClusterExtension) error {
return errors.New("first validator failed")
},
// Second validator also fails
func(_ context.Context, _ *ocv1.ClusterExtension) error {
return errors.New("second validator failed")
},
// Third validator fails too
func(_ context.Context, _ *ocv1.ClusterExtension) error {
return errors.New("third validator failed")
},
},
expectError: true,
errorMessageIncludes: "first validator failed\nsecond validator failed\nthird validator failed",
},
{
name: "multiple validators - all pass",
validators: []controllers.ClusterExtensionValidator{
func(_ context.Context, _ *ocv1.ClusterExtension) error {
return nil
},
func(_ context.Context, _ *ocv1.ClusterExtension) error {
return nil
},
func(_ context.Context, _ *ocv1.ClusterExtension) error {
return nil
},
},
expectError: false,
},
{
name: "multiple validators - some pass, some fail",
validators: []controllers.ClusterExtensionValidator{
// First validator passes
func(_ context.Context, _ *ocv1.ClusterExtension) error {
return nil
},
// Second validator fails
func(_ context.Context, _ *ocv1.ClusterExtension) error {
return errors.New("validation error 1")
},
// Third validator passes
func(_ context.Context, _ *ocv1.ClusterExtension) error {
return nil
},
// Fourth validator fails
func(_ context.Context, _ *ocv1.ClusterExtension) error {
return errors.New("validation error 2")
},
},
expectError: true,
errorMessageIncludes: "validation error 1\nvalidation error 2",
},
{
name: "service account not found",
validators: []controllers.ClusterExtensionValidator{
serviceAccountValidatorWithFakeClient(&corev1.ServiceAccount{
ObjectMeta: metav1.ObjectMeta{
Name: "missing-sa",
Comment on lines +858 to +860
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test setup is confusing because it creates a ServiceAccount named "missing-sa" in the fake client, but the test name "service account not found" and the expected error message suggest we're testing for "test-sa" not being found. While the test logic is correct (it verifies that when "test-sa" doesn't exist, we get the expected error), the setup could be clearer. Consider either: (1) Renaming the ServiceAccount to clarify it's a decoy (e.g., "other-sa"), (2) Adding a comment explaining that this creates a different SA to ensure "test-sa" is not found, or (3) Passing nil to serviceAccountValidatorWithFakeClient to make it explicit that no SA exists.

Suggested change
serviceAccountValidatorWithFakeClient(&corev1.ServiceAccount{
ObjectMeta: metav1.ObjectMeta{
Name: "missing-sa",
// Create a different ServiceAccount to ensure "test-sa" is not found.
serviceAccountValidatorWithFakeClient(&corev1.ServiceAccount{
ObjectMeta: metav1.ObjectMeta{
Name: "other-sa",

Copilot uses AI. Check for mistakes.
Namespace: "test-ns",
},
}),
},
expectError: true,
errorMessageIncludes: `service account "test-sa" not found in namespace "test-ns"`,
},
{
name: "service account found",
validators: []controllers.ClusterExtensionValidator{
serviceAccountValidatorWithFakeClient(&corev1.ServiceAccount{
ObjectMeta: metav1.ObjectMeta{
Name: "test-sa",
Namespace: "test-ns",
},
}),
},
expectError: false,
},
}

require.NoError(t, cl.Create(ctx, clusterExtension))
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()

t.Log("When reconciling the cluster extension")
res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey})
cl, reconciler := newClientAndReconciler(t, func(d *deps) {
d.RevisionStatesGetter = &MockRevisionStatesGetter{
RevisionStates: &controllers.RevisionStates{},
}
d.Validators = tt.validators
})

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

t.Log("By checking the status conditions")
installedCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypeInstalled)
require.NotNil(t, installedCond)
require.Equal(t, metav1.ConditionUnknown, installedCond.Status)
require.Contains(t, installedCond.Message, fmt.Sprintf("service account %q not found in namespace %q: unable to authenticate with the Kubernetes cluster.",
"missing-sa", "default"))
clusterExtension := &ocv1.ClusterExtension{
ObjectMeta: metav1.ObjectMeta{Name: extKey.Name},
Spec: ocv1.ClusterExtensionSpec{
Source: ocv1.SourceConfig{
SourceType: "Catalog",
Catalog: &ocv1.CatalogFilter{
PackageName: "test-package",
},
},
Namespace: "test-ns",
ServiceAccount: ocv1.ServiceAccountReference{
Name: "test-sa",
},
},
}

progressingCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypeProgressing)
require.NotNil(t, progressingCond)
require.Equal(t, metav1.ConditionTrue, progressingCond.Status)
require.Equal(t, ocv1.ReasonRetrying, progressingCond.Reason)
require.Contains(t, progressingCond.Message, "installation cannot proceed due to missing ServiceAccount")
require.NoError(t, cl.DeleteAllOf(ctx, &ocv1.ClusterExtension{}))
require.NoError(t, cl.Create(ctx, clusterExtension))

t.Log("When reconciling the cluster extension")
res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey})
require.Equal(t, ctrl.Result{}, res)
if tt.expectError {
require.Error(t, err)
t.Log("By fetching updated cluster extension after reconcile")
require.NoError(t, cl.Get(ctx, extKey, clusterExtension))

t.Log("By checking the status conditions")
installedCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypeInstalled)
require.NotNil(t, installedCond)
require.Equal(t, metav1.ConditionUnknown, installedCond.Status)
require.Contains(t, installedCond.Message, "installation cannot proceed due to the following validation error(s)")
require.Contains(t, installedCond.Message, tt.errorMessageIncludes)

progressingCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypeProgressing)
require.NotNil(t, progressingCond)
require.Equal(t, metav1.ConditionTrue, progressingCond.Status)
require.Equal(t, ocv1.ReasonRetrying, progressingCond.Reason)
require.Contains(t, progressingCond.Message, "installation cannot proceed due to the following validation error(s)")
require.Contains(t, progressingCond.Message, tt.errorMessageIncludes)
} else {
require.NoError(t, err)
require.NoError(t, cl.Get(ctx, extKey, clusterExtension))
require.Empty(t, clusterExtension.Status.Conditions)
}
require.NoError(t, cl.DeleteAllOf(ctx, &ocv1.ClusterExtension{}))
})
}
}

func TestClusterExtensionApplierFailsWithBundleInstalled(t *testing.T) {
Expand Down Expand Up @@ -2878,3 +2996,10 @@ func TestCheckCatalogsExist(t *testing.T) {
require.False(t, exists)
})
}

func serviceAccountValidatorWithFakeClient(serviceAccount *corev1.ServiceAccount) controllers.ClusterExtensionValidator {
if serviceAccount == nil {
return controllers.ServiceAccountValidator(fake.NewClientset().CoreV1())
}
return controllers.ServiceAccountValidator(fake.NewClientset(serviceAccount).CoreV1())
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,16 @@ import (
"errors"
"fmt"

apierrors "k8s.io/apimachinery/pkg/api/errors"
apimeta "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
corev1client "k8s.io/client-go/kubernetes/typed/core/v1"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/finalizer"
"sigs.k8s.io/controller-runtime/pkg/log"

ocv1 "github.com/operator-framework/operator-controller/api/v1"
"github.com/operator-framework/operator-controller/internal/operator-controller/authentication"
"github.com/operator-framework/operator-controller/internal/operator-controller/bundleutil"
"github.com/operator-framework/operator-controller/internal/operator-controller/labels"
"github.com/operator-framework/operator-controller/internal/operator-controller/resolve"
Expand Down Expand Up @@ -63,19 +64,62 @@ func HandleFinalizers(f finalizer.Finalizer) ReconcileStepFunc {
}
}

// ClusterExtensionValidator is a function that validates a ClusterExtension.
// It returns an error if validation fails. Validators are executed sequentially
// in the order they are registered.
type ClusterExtensionValidator func(context.Context, *ocv1.ClusterExtension) error

// ValidateClusterExtension returns a ReconcileStepFunc that executes all
// validators sequentially. All validators are executed even if some fail,
// and all errors are collected and returned as a joined error.
// This provides complete validation feedback in a single reconciliation cycle.
func ValidateClusterExtension(validators ...ClusterExtensionValidator) ReconcileStepFunc {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it would be nice to add some unit tests for the new reconcile step.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I've covered those here, no?

return func(ctx context.Context, state *reconcileState, ext *ocv1.ClusterExtension) (*ctrl.Result, error) {
l := log.FromContext(ctx)

l.Info("validating cluster extension")
var validationErrors []error
for _, validator := range validators {
if err := validator(ctx, ext); err != nil {
validationErrors = append(validationErrors, err)
}
}

// If there are no validation errors, continue reconciliation
if len(validationErrors) == 0 {
return nil, nil
}

// Set status condition with the validation error for other validation failures
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment "Set status condition with the validation error for other validation failures" is misleading. The word "other" suggests there are different types of validation failures handled differently, but this code path handles all validation failures uniformly. Consider changing to "Set status conditions with the validation errors" for clarity.

Suggested change
// Set status condition with the validation error for other validation failures
// Set status conditions with the validation errors

Copilot uses AI. Check for mistakes.
err := fmt.Errorf("installation cannot proceed due to the following validation error(s): %w", errors.Join(validationErrors...))
setInstalledStatusConditionUnknown(ext, err.Error())
setStatusProgressing(ext, err)
return nil, err
}
}

// ServiceAccountValidator returns a validator that checks if the specified
// ServiceAccount exists in the cluster by performing a direct Get call.
func ServiceAccountValidator(saClient corev1client.ServiceAccountsGetter) ClusterExtensionValidator {
return func(ctx context.Context, ext *ocv1.ClusterExtension) error {
_, err := saClient.ServiceAccounts(ext.Spec.Namespace).Get(ctx, ext.Spec.ServiceAccount.Name, metav1.GetOptions{})
if err != nil {
if apierrors.IsNotFound(err) {
return fmt.Errorf("service account %q not found in namespace %q", ext.Spec.ServiceAccount.Name, ext.Spec.Namespace)
}
return fmt.Errorf("error validating service account: %w", err)
}
return nil
}
}

func RetrieveRevisionStates(r RevisionStatesGetter) ReconcileStepFunc {
return func(ctx context.Context, state *reconcileState, ext *ocv1.ClusterExtension) (*ctrl.Result, error) {
l := log.FromContext(ctx)
l.Info("getting installed bundle")
revisionStates, err := r.GetRevisionStates(ctx, ext)
if err != nil {
setInstallStatus(ext, nil)
var saerr *authentication.ServiceAccountNotFoundError
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this got split between the service account validator and the cluster extension validator

if errors.As(err, &saerr) {
setInstalledStatusConditionUnknown(ext, saerr.Error())
setStatusProgressing(ext, errors.New("installation cannot proceed due to missing ServiceAccount"))
return nil, err
}
setInstalledStatusConditionUnknown(ext, err.Error())
setStatusProgressing(ext, errors.New("retrying to get installed bundle"))
return nil, err
Expand Down
7 changes: 6 additions & 1 deletion internal/operator-controller/controllers/suite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ type deps struct {
ImagePuller image.Puller
ImageCache image.Cache
Applier controllers.Applier
Validators []controllers.ClusterExtensionValidator
}

func newClientAndReconciler(t *testing.T, opts ...reconcilerOption) (client.Client, *controllers.ClusterExtensionReconciler) {
Expand All @@ -105,7 +106,11 @@ func newClientAndReconciler(t *testing.T, opts ...reconcilerOption) (client.Clie
for _, opt := range opts {
opt(d)
}
reconciler.ReconcileSteps = []controllers.ReconcileStepFunc{controllers.HandleFinalizers(d.Finalizers), controllers.RetrieveRevisionStates(d.RevisionStatesGetter)}
reconciler.ReconcileSteps = []controllers.ReconcileStepFunc{
controllers.HandleFinalizers(d.Finalizers),
controllers.ValidateClusterExtension(d.Validators...),
controllers.RetrieveRevisionStates(d.RevisionStatesGetter),
}
if r := d.Resolver; r != nil {
reconciler.ReconcileSteps = append(reconciler.ReconcileSteps, controllers.ResolveBundle(r, cl))
}
Expand Down
Loading
Loading