Skip to content

Commit f7ebc0c

Browse files
(feat): [Boxcutter] Use serviceAccount for ClusterExtensionRevision operations
ClusterExtensionRevision controller now uses the serviceAccount from ClusterExtension spec instead of admin client. This applies objects with proper RBAC permissions via a factory pattern that creates scoped clients per revision. Assisted-by: CLAUDE
1 parent dba48b9 commit f7ebc0c

6 files changed

Lines changed: 231 additions & 54 deletions

File tree

cmd/operator-controller/main.go

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,7 @@ import (
4242
_ "k8s.io/client-go/plugin/pkg/client/auth"
4343
"k8s.io/klog/v2"
4444
"k8s.io/utils/ptr"
45-
"pkg.package-operator.run/boxcutter/machinery"
4645
"pkg.package-operator.run/boxcutter/managedcache"
47-
"pkg.package-operator.run/boxcutter/ownerhandling"
48-
"pkg.package-operator.run/boxcutter/validation"
4946
ctrl "sigs.k8s.io/controller-runtime"
5047
crcache "sigs.k8s.io/controller-runtime/pkg/cache"
5148
"sigs.k8s.io/controller-runtime/pkg/certwatcher"
@@ -653,21 +650,31 @@ func (c *boxcutterReconcilerConfigurator) Configure(ceReconciler *controllers.Cl
653650
return fmt.Errorf("unable to add tracking cache to manager: %v", err)
654651
}
655652

653+
cerCoreClient, err := corev1client.NewForConfig(c.mgr.GetConfig())
654+
if err != nil {
655+
return fmt.Errorf("unable to create client for ClusterExtensionRevision controller: %w", err)
656+
}
657+
cerTokenGetter := authentication.NewTokenGetter(cerCoreClient, authentication.WithExpirationDuration(1*time.Hour))
658+
659+
revisionEngineFactory := &controllers.DefaultRevisionEngineFactory{
660+
Scheme: c.mgr.GetScheme(),
661+
TrackingCache: trackingCache,
662+
DiscoveryClient: discoveryClient,
663+
RESTMapper: c.mgr.GetRESTMapper(),
664+
FieldOwnerPrefix: fieldOwnerPrefix,
665+
}
666+
667+
scopedClientFactory := &controllers.DefaultScopedClientFactory{
668+
BaseConfig: c.mgr.GetConfig(),
669+
Scheme: c.mgr.GetScheme(),
670+
TokenGetter: cerTokenGetter,
671+
}
672+
656673
if err = (&controllers.ClusterExtensionRevisionReconciler{
657-
Client: c.mgr.GetClient(),
658-
RevisionEngine: machinery.NewRevisionEngine(
659-
machinery.NewPhaseEngine(
660-
machinery.NewObjectEngine(
661-
c.mgr.GetScheme(), trackingCache, c.mgr.GetClient(),
662-
ownerhandling.NewNative(c.mgr.GetScheme()),
663-
machinery.NewComparator(ownerhandling.NewNative(c.mgr.GetScheme()), discoveryClient, c.mgr.GetScheme(), fieldOwnerPrefix),
664-
fieldOwnerPrefix, fieldOwnerPrefix,
665-
),
666-
validation.NewClusterPhaseValidator(c.mgr.GetRESTMapper(), c.mgr.GetClient()),
667-
),
668-
validation.NewRevisionValidator(), c.mgr.GetClient(),
669-
),
670-
TrackingCache: trackingCache,
674+
Client: c.mgr.GetClient(),
675+
RevisionEngineFactory: revisionEngineFactory,
676+
TrackingCache: trackingCache,
677+
ScopedClientFactory: scopedClientFactory,
671678
}).SetupWithManager(c.mgr); err != nil {
672679
return fmt.Errorf("unable to setup ClusterExtensionRevision controller: %w", err)
673680
}

internal/operator-controller/applier/boxcutter.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,11 @@ func (r *SimpleRevisionGenerator) buildClusterExtensionRevision(
186186
ext *ocv1.ClusterExtension,
187187
annotations map[string]string,
188188
) *ocv1.ClusterExtensionRevision {
189+
if annotations == nil {
190+
annotations = make(map[string]string)
191+
}
192+
annotations[labels.ServiceAccountNameKey] = ext.Spec.ServiceAccount.Name
193+
189194
return &ocv1.ClusterExtensionRevision{
190195
ObjectMeta: metav1.ObjectMeta{
191196
Annotations: annotations,

internal/operator-controller/controllers/clusterextensionrevision_controller.go

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,15 @@ const (
4343
// ClusterExtensionRevisionReconciler actions individual snapshots of ClusterExtensions,
4444
// as part of the boxcutter integration.
4545
type ClusterExtensionRevisionReconciler struct {
46-
Client client.Client
47-
RevisionEngine RevisionEngine
48-
TrackingCache trackingCache
46+
Client client.Client
47+
RevisionEngineFactory RevisionEngineFactory
48+
TrackingCache trackingCache
49+
ScopedClientFactory ScopedClientFactory
50+
}
51+
52+
// ScopedClientFactory creates a client scoped to a specific serviceAccount
53+
type ScopedClientFactory interface {
54+
CreateScopedClient(ctx context.Context, namespace, serviceAccountName string) (client.Client, error)
4955
}
5056

5157
type trackingCache interface {
@@ -60,6 +66,12 @@ type RevisionEngine interface {
6066
Reconcile(ctx context.Context, rev machinerytypes.Revision, opts ...machinerytypes.RevisionReconcileOption) (machinery.RevisionResult, error)
6167
}
6268

69+
// RevisionEngineFactory creates a RevisionEngine with a given kube client.
70+
// This allows each ClusterExtensionRevision to use a client scoped to its serviceAccount.
71+
type RevisionEngineFactory interface {
72+
CreateRevisionEngine(client client.Client) RevisionEngine
73+
}
74+
6375
//+kubebuilder:rbac:groups=olm.operatorframework.io,resources=clusterextensionrevisions,verbs=get;list;watch;update;patch;create;delete
6476
//+kubebuilder:rbac:groups=olm.operatorframework.io,resources=clusterextensionrevisions/status,verbs=update;patch
6577
//+kubebuilder:rbac:groups=olm.operatorframework.io,resources=clusterextensionrevisions/finalizers,verbs=update
@@ -139,7 +151,15 @@ func (c *ClusterExtensionRevisionReconciler) reconcile(ctx context.Context, rev
139151
return ctrl.Result{}, werr
140152
}
141153

142-
rres, err := c.RevisionEngine.Reconcile(ctx, *revision, opts...)
154+
scopedClient, err := c.getScopedClient(ctx, rev)
155+
if err != nil {
156+
setRetryingConditions(rev, err.Error())
157+
return ctrl.Result{}, fmt.Errorf("failed to create client with serviceAccount permissions: %v", err)
158+
}
159+
160+
revisionEngine := c.RevisionEngineFactory.CreateRevisionEngine(scopedClient)
161+
162+
rres, err := revisionEngine.Reconcile(ctx, *revision, opts...)
143163
if err != nil {
144164
if rres != nil {
145165
// Log detailed reconcile reports only in debug mode (V(1)) to reduce verbosity.
@@ -253,7 +273,15 @@ func (c *ClusterExtensionRevisionReconciler) reconcile(ctx context.Context, rev
253273
func (c *ClusterExtensionRevisionReconciler) teardown(ctx context.Context, rev *ocv1.ClusterExtensionRevision, revision *boxcutter.Revision) (ctrl.Result, error) {
254274
l := log.FromContext(ctx)
255275

256-
tres, err := c.RevisionEngine.Teardown(ctx, *revision)
276+
scopedClient, err := c.getScopedClient(ctx, rev)
277+
if err != nil {
278+
markAsAvailableUnknown(rev, ocv1.ClusterExtensionRevisionReasonReconciling, err.Error())
279+
return ctrl.Result{}, fmt.Errorf("failed to create client for teardown: %v", err)
280+
}
281+
282+
revisionEngine := c.RevisionEngineFactory.CreateRevisionEngine(scopedClient)
283+
284+
tres, err := revisionEngine.Teardown(ctx, *revision)
257285
if err != nil {
258286
if tres != nil {
259287
l.V(1).Info("teardown failure report", "report", tres.String())
@@ -453,6 +481,36 @@ func (c *ClusterExtensionRevisionReconciler) toBoxcutterRevision(ctx context.Con
453481
return r, opts, nil
454482
}
455483

484+
// getScopedClient extracts serviceAccount information from the ClusterExtensionRevision
485+
// and creates a client scoped to that serviceAccount. The namespace is derived from the
486+
// owner ClusterExtension, following the project convention that ServiceAccounts must exist
487+
// in the ClusterExtension's namespace.
488+
func (c *ClusterExtensionRevisionReconciler) getScopedClient(ctx context.Context, rev *ocv1.ClusterExtensionRevision) (client.Client, error) {
489+
annotations := rev.GetAnnotations()
490+
if annotations == nil {
491+
return nil, fmt.Errorf("revision %q is missing required annotations", rev.Name)
492+
}
493+
494+
saName := annotations[labels.ServiceAccountNameKey]
495+
if saName == "" {
496+
return nil, fmt.Errorf("revision %q is missing serviceAccount name configuration", rev.Name)
497+
}
498+
499+
// Get namespace from owner ClusterExtension via label
500+
ownerName := rev.Labels[labels.OwnerNameKey]
501+
if ownerName == "" {
502+
return nil, fmt.Errorf("revision %q is missing owner label", rev.Name)
503+
}
504+
505+
// Fetch the owner ClusterExtension to get its namespace
506+
ext := &ocv1.ClusterExtension{}
507+
if err := c.Client.Get(ctx, types.NamespacedName{Name: ownerName}, ext); err != nil {
508+
return nil, fmt.Errorf("failed to get owner ClusterExtension %q: %w", ownerName, err)
509+
}
510+
511+
return c.ScopedClientFactory.CreateScopedClient(ctx, ext.Spec.Namespace, saName)
512+
}
513+
456514
var (
457515
deploymentProbe = &probing.GroupKindSelector{
458516
GroupKind: schema.GroupKind{Group: appsv1.GroupName, Kind: "Deployment"},

internal/operator-controller/controllers/clusterextensionrevision_controller_test.go

Lines changed: 51 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -484,14 +484,16 @@ func Test_ClusterExtensionRevisionReconciler_Reconcile_RevisionReconciliation(t
484484
Build()
485485

486486
// reconcile cluster extension revision
487-
result, err := (&controllers.ClusterExtensionRevisionReconciler{
488-
Client: testClient,
489-
RevisionEngine: &mockRevisionEngine{
490-
reconcile: func(ctx context.Context, rev machinerytypes.Revision, opts ...machinerytypes.RevisionReconcileOption) (machinery.RevisionResult, error) {
491-
return tc.revisionResult, tc.revisionReconcileErr
492-
},
487+
mockEngine := &mockRevisionEngine{
488+
reconcile: func(ctx context.Context, rev machinerytypes.Revision, opts ...machinerytypes.RevisionReconcileOption) (machinery.RevisionResult, error) {
489+
return tc.revisionResult, tc.revisionReconcileErr
493490
},
494-
TrackingCache: &mockTrackingCache{client: testClient},
491+
}
492+
result, err := (&controllers.ClusterExtensionRevisionReconciler{
493+
Client: testClient,
494+
RevisionEngineFactory: &mockRevisionEngineFactory{engine: mockEngine},
495+
TrackingCache: &mockTrackingCache{client: testClient},
496+
ScopedClientFactory: &mockScopedClientFactory{client: testClient},
495497
}).Reconcile(t.Context(), ctrl.Request{
496498
NamespacedName: types.NamespacedName{
497499
Name: tc.reconcilingRevisionName,
@@ -603,14 +605,16 @@ func Test_ClusterExtensionRevisionReconciler_Reconcile_ValidationError_Retries(t
603605
Build()
604606

605607
// reconcile cluster extension revision
606-
result, err := (&controllers.ClusterExtensionRevisionReconciler{
607-
Client: testClient,
608-
RevisionEngine: &mockRevisionEngine{
609-
reconcile: func(ctx context.Context, rev machinerytypes.Revision, opts ...machinerytypes.RevisionReconcileOption) (machinery.RevisionResult, error) {
610-
return tc.revisionResult, nil
611-
},
608+
mockEngine := &mockRevisionEngine{
609+
reconcile: func(ctx context.Context, rev machinerytypes.Revision, opts ...machinerytypes.RevisionReconcileOption) (machinery.RevisionResult, error) {
610+
return tc.revisionResult, nil
612611
},
613-
TrackingCache: &mockTrackingCache{client: testClient},
612+
}
613+
result, err := (&controllers.ClusterExtensionRevisionReconciler{
614+
Client: testClient,
615+
RevisionEngineFactory: &mockRevisionEngineFactory{engine: mockEngine},
616+
TrackingCache: &mockTrackingCache{client: testClient},
617+
ScopedClientFactory: &mockScopedClientFactory{client: testClient},
614618
}).Reconcile(t.Context(), ctrl.Request{
615619
NamespacedName: types.NamespacedName{
616620
Name: clusterExtensionRevisionName,
@@ -652,7 +656,7 @@ func Test_ClusterExtensionRevisionReconciler_Reconcile_Deletion(t *testing.T) {
652656
rev1.Finalizers = []string{
653657
"olm.operatorframework.io/teardown",
654658
}
655-
return []client.Object{rev1}
659+
return []client.Object{ext, rev1}
656660
},
657661
validate: func(t *testing.T, c client.Client) {
658662
rev := &ocv1.ClusterExtensionRevision{}
@@ -887,18 +891,20 @@ func Test_ClusterExtensionRevisionReconciler_Reconcile_Deletion(t *testing.T) {
887891
Build()
888892

889893
// reconcile cluster extension revision
890-
result, err := (&controllers.ClusterExtensionRevisionReconciler{
891-
Client: testClient,
892-
RevisionEngine: &mockRevisionEngine{
893-
reconcile: func(ctx context.Context, rev machinerytypes.Revision, opts ...machinerytypes.RevisionReconcileOption) (machinery.RevisionResult, error) {
894-
return tc.revisionResult, nil
895-
},
896-
teardown: tc.revisionEngineTeardownFn(t),
894+
mockEngine := &mockRevisionEngine{
895+
reconcile: func(ctx context.Context, rev machinerytypes.Revision, opts ...machinerytypes.RevisionReconcileOption) (machinery.RevisionResult, error) {
896+
return tc.revisionResult, nil
897897
},
898+
teardown: tc.revisionEngineTeardownFn(t),
899+
}
900+
result, err := (&controllers.ClusterExtensionRevisionReconciler{
901+
Client: testClient,
902+
RevisionEngineFactory: &mockRevisionEngineFactory{engine: mockEngine},
898903
TrackingCache: &mockTrackingCache{
899904
client: testClient,
900905
freeFn: tc.trackingCacheFreeFn,
901906
},
907+
ScopedClientFactory: &mockScopedClientFactory{client: testClient},
902908
}).Reconcile(t.Context(), ctrl.Request{
903909
NamespacedName: types.NamespacedName{
904910
Name: clusterExtensionRevisionName,
@@ -952,10 +958,11 @@ func newTestClusterExtensionRevision(t *testing.T, revisionName string, ext *ocv
952958
UID: types.UID(revisionName),
953959
Generation: int64(1),
954960
Annotations: map[string]string{
955-
labels.PackageNameKey: "some-package",
956-
labels.BundleNameKey: "some-package.v1.0.0",
957-
labels.BundleReferenceKey: "registry.io/some-repo/some-package:v1.0.0",
958-
labels.BundleVersionKey: "1.0.0",
961+
labels.PackageNameKey: "some-package",
962+
labels.BundleNameKey: "some-package.v1.0.0",
963+
labels.BundleReferenceKey: "registry.io/some-repo/some-package:v1.0.0",
964+
labels.BundleVersionKey: "1.0.0",
965+
labels.ServiceAccountNameKey: ext.Spec.ServiceAccount.Name,
959966
},
960967
Labels: map[string]string{
961968
labels.OwnerNameKey: "test-ext",
@@ -1001,6 +1008,24 @@ func (m mockRevisionEngine) Reconcile(ctx context.Context, rev machinerytypes.Re
10011008
return m.reconcile(ctx, rev)
10021009
}
10031010

1011+
// mockRevisionEngineFactory creates mock RevisionEngines for testing
1012+
type mockRevisionEngineFactory struct {
1013+
engine controllers.RevisionEngine
1014+
}
1015+
1016+
func (f *mockRevisionEngineFactory) CreateRevisionEngine(c client.Client) controllers.RevisionEngine {
1017+
return f.engine
1018+
}
1019+
1020+
// mockScopedClientFactory returns the test client for testing
1021+
type mockScopedClientFactory struct {
1022+
client client.Client
1023+
}
1024+
1025+
func (f *mockScopedClientFactory) CreateScopedClient(ctx context.Context, namespace, serviceAccountName string) (client.Client, error) {
1026+
return f.client, nil
1027+
}
1028+
10041029
type mockRevisionResult struct {
10051030
validationError *validation.RevisionValidationError
10061031
phases []machinery.PhaseResult
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
//go:build !standard
2+
3+
package controllers
4+
5+
import (
6+
"context"
7+
"fmt"
8+
"net/http"
9+
10+
"k8s.io/apimachinery/pkg/api/meta"
11+
"k8s.io/apimachinery/pkg/runtime"
12+
"k8s.io/apimachinery/pkg/types"
13+
"k8s.io/client-go/discovery"
14+
"k8s.io/client-go/rest"
15+
"pkg.package-operator.run/boxcutter/machinery"
16+
"pkg.package-operator.run/boxcutter/managedcache"
17+
"pkg.package-operator.run/boxcutter/ownerhandling"
18+
"pkg.package-operator.run/boxcutter/validation"
19+
"sigs.k8s.io/controller-runtime/pkg/client"
20+
21+
"github.com/operator-framework/operator-controller/internal/operator-controller/authentication"
22+
)
23+
24+
// DefaultRevisionEngineFactory creates boxcutter RevisionEngines with serviceAccount-scoped clients.
25+
type DefaultRevisionEngineFactory struct {
26+
Scheme *runtime.Scheme
27+
TrackingCache managedcache.TrackingCache
28+
DiscoveryClient discovery.CachedDiscoveryInterface
29+
RESTMapper meta.RESTMapper
30+
FieldOwnerPrefix string
31+
}
32+
33+
// CreateRevisionEngine constructs a boxcutter RevisionEngine using the provided client.
34+
// The client should be scoped to the appropriate serviceAccount for permission isolation.
35+
func (f *DefaultRevisionEngineFactory) CreateRevisionEngine(c client.Client) RevisionEngine {
36+
return machinery.NewRevisionEngine(
37+
machinery.NewPhaseEngine(
38+
machinery.NewObjectEngine(
39+
f.Scheme, f.TrackingCache, c,
40+
ownerhandling.NewNative(f.Scheme),
41+
machinery.NewComparator(ownerhandling.NewNative(f.Scheme), f.DiscoveryClient, f.Scheme, f.FieldOwnerPrefix),
42+
f.FieldOwnerPrefix, f.FieldOwnerPrefix,
43+
),
44+
validation.NewClusterPhaseValidator(f.RESTMapper, c),
45+
),
46+
validation.NewRevisionValidator(), c,
47+
)
48+
}
49+
50+
// DefaultScopedClientFactory creates Kubernetes clients scoped to specific ServiceAccounts.
51+
// This ensures that operations are performed with the appropriate RBAC permissions.
52+
type DefaultScopedClientFactory struct {
53+
BaseConfig *rest.Config
54+
Scheme *runtime.Scheme
55+
TokenGetter *authentication.TokenGetter
56+
}
57+
58+
// CreateScopedClient creates a client authenticated as the specified ServiceAccount.
59+
// The client uses token authentication via the TokenInjectingRoundTripper.
60+
func (f *DefaultScopedClientFactory) CreateScopedClient(ctx context.Context, namespace, serviceAccountName string) (client.Client, error) {
61+
saConfig := rest.AnonymousClientConfig(f.BaseConfig)
62+
saConfig.Wrap(func(rt http.RoundTripper) http.RoundTripper {
63+
return &authentication.TokenInjectingRoundTripper{
64+
Tripper: rt,
65+
TokenGetter: f.TokenGetter,
66+
Key: types.NamespacedName{
67+
Name: serviceAccountName,
68+
Namespace: namespace,
69+
},
70+
}
71+
})
72+
73+
scopedClient, err := client.New(saConfig, client.Options{
74+
Scheme: f.Scheme,
75+
})
76+
if err != nil {
77+
return nil, fmt.Errorf("failed to create client for ServiceAccount %s/%s: %w", namespace, serviceAccountName, err)
78+
}
79+
80+
return scopedClient, nil
81+
}
Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
package labels
22

33
const (
4-
OwnerKindKey = "olm.operatorframework.io/owner-kind"
5-
OwnerNameKey = "olm.operatorframework.io/owner-name"
6-
PackageNameKey = "olm.operatorframework.io/package-name"
7-
BundleNameKey = "olm.operatorframework.io/bundle-name"
8-
BundleVersionKey = "olm.operatorframework.io/bundle-version"
9-
BundleReferenceKey = "olm.operatorframework.io/bundle-reference"
4+
OwnerKindKey = "olm.operatorframework.io/owner-kind"
5+
OwnerNameKey = "olm.operatorframework.io/owner-name"
6+
PackageNameKey = "olm.operatorframework.io/package-name"
7+
BundleNameKey = "olm.operatorframework.io/bundle-name"
8+
BundleVersionKey = "olm.operatorframework.io/bundle-version"
9+
BundleReferenceKey = "olm.operatorframework.io/bundle-reference"
10+
ServiceAccountNameKey = "olm.operatorframework.io/service-account-name"
1011
)

0 commit comments

Comments
 (0)