Skip to content

Commit 7046457

Browse files
authored
feat(notes): add multi-cluster support for notes webhook subject lookup (#510)
## Summary Notes can now be attached to resources that live in project control planes. Previously, notes could only reference resources in the main cluster, which caused errors when trying to attach notes to project-specific resources like Domains. ## What changed The notes webhook now searches across all connected clusters when looking up the resource a note references. It checks the main cluster first, then looks in any active project control planes. ## Test plan - [x] Unit tests pass - [ ] End-to-end tests validate notes work with project control plane resources
2 parents 3c4e4ed + f54cee1 commit 7046457

24 files changed

Lines changed: 561 additions & 58 deletions

cmd/milo/controller-manager/controllermanager.go

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ import (
9898
quotav1alpha1 "go.miloapis.com/milo/pkg/apis/quota/v1alpha1"
9999
resourcemanagerv1alpha1 "go.miloapis.com/milo/pkg/apis/resourcemanager/v1alpha1"
100100
miloprovider "go.miloapis.com/milo/pkg/multicluster-runtime/milo"
101+
milowebhook "go.miloapis.com/milo/pkg/webhook"
101102
apiregistrationv1 "k8s.io/kube-aggregator/pkg/apis/apiregistration/v1"
102103
)
103104

@@ -471,7 +472,11 @@ func Run(ctx context.Context, c *config.CompletedConfig, opts *Options) error {
471472
MapperProvider: func(c *restclient.Config, httpClient *http.Client) (meta.RESTMapper, error) {
472473
return controllerContext.RESTMapper, nil
473474
},
474-
WebhookServer: webhook.NewServer(webhook.Options{
475+
// Wrap the webhook server with ClusterAwareServer to automatically inject
476+
// project control plane context from UserInfo.Extra into the request context.
477+
// This allows webhook handlers to use mccontext.ClusterFrom(ctx) to determine
478+
// which project control plane the request is targeting.
479+
WebhookServer: milowebhook.NewClusterAwareServer(webhook.NewServer(webhook.Options{
475480
Port: opts.ControllerRuntimeWebhookPort,
476481
CertDir: opts.SecureServing.ServerCert.CertDirectory,
477482
// The webhook server expects the key and cert files to be in the
@@ -481,7 +486,7 @@ func Run(ctx context.Context, c *config.CompletedConfig, opts *Options) error {
481486
// key and cert file names.
482487
KeyName: strings.TrimPrefix(opts.SecureServing.ServerCert.CertKey.KeyFile, opts.SecureServing.ServerCert.CertDirectory+"/"),
483488
CertName: strings.TrimPrefix(opts.SecureServing.ServerCert.CertKey.CertFile, opts.SecureServing.ServerCert.CertDirectory+"/"),
484-
}),
489+
})),
485490
},
486491
)
487492
if err != nil {
@@ -553,14 +558,8 @@ func Run(ctx context.Context, c *config.CompletedConfig, opts *Options) error {
553558
logger.Error(err, "Error setting up platform access rejection webhook")
554559
klog.FlushAndExit(klog.ExitFlushTimeout, 1)
555560
}
556-
if err := notesv1alpha1webhook.SetupNoteWebhooksWithManager(ctrl); err != nil {
557-
logger.Error(err, "Error setting up note webhook")
558-
klog.FlushAndExit(klog.ExitFlushTimeout, 1)
559-
}
560-
if err := notesv1alpha1webhook.SetupClusterNoteWebhooksWithManager(ctrl); err != nil {
561-
logger.Error(err, "Error setting up clusternote webhook")
562-
klog.FlushAndExit(klog.ExitFlushTimeout, 1)
563-
}
561+
// Note webhooks are set up later after the multicluster manager is created,
562+
// so they can use it for project control plane lookups.
564563

565564
projectCtrl := resourcemanagercontroller.ProjectController{
566565
ControlPlaneClient: ctrl.GetClient(),
@@ -666,6 +665,16 @@ func Run(ctx context.Context, c *config.CompletedConfig, opts *Options) error {
666665
}
667666
logger.Info("Local cluster engaged successfully")
668667

668+
// Set up note webhooks with multicluster manager for project control plane lookups
669+
if err := notesv1alpha1webhook.SetupNoteWebhooksWithManager(ctrl, mcMgr); err != nil {
670+
logger.Error(err, "Error setting up note webhook")
671+
klog.FlushAndExit(klog.ExitFlushTimeout, 1)
672+
}
673+
if err := notesv1alpha1webhook.SetupClusterNoteWebhooksWithManager(ctrl, mcMgr); err != nil {
674+
logger.Error(err, "Error setting up clusternote webhook")
675+
klog.FlushAndExit(klog.ExitFlushTimeout, 1)
676+
}
677+
669678
// Start concurrently to resolve circular dependency between provider and manager
670679
go func() {
671680
logger.Info("Starting Datum cluster provider")

internal/webhooks/notes/v1alpha1/clusternote_webhook.go

Lines changed: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,21 @@ import (
1919
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
2020
logf "sigs.k8s.io/controller-runtime/pkg/log"
2121
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
22+
mccontext "sigs.k8s.io/multicluster-runtime/pkg/context"
23+
mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager"
2224
)
2325

2426
var clusterNoteLog = logf.Log.WithName("clusternote-resource")
2527

26-
func SetupClusterNoteWebhooksWithManager(mgr ctrl.Manager) error {
28+
func SetupClusterNoteWebhooksWithManager(mgr ctrl.Manager, mcMgr mcmanager.Manager) error {
2729
clusterNoteLog.Info("Setting up notes.miloapis.com clusternote webhooks")
2830
return ctrl.NewWebhookManagedBy(mgr).
2931
For(&notesv1alpha1.ClusterNote{}).
3032
WithDefaulter(&ClusterNoteMutator{
31-
Client: mgr.GetClient(),
32-
Scheme: mgr.GetScheme(),
33-
RESTMapper: mgr.GetRESTMapper(),
33+
Client: mgr.GetClient(),
34+
Scheme: mgr.GetScheme(),
35+
RESTMapper: mgr.GetRESTMapper(),
36+
ClusterManager: mcMgr,
3437
}).
3538
WithValidator(&ClusterNoteValidator{
3639
Client: mgr.GetClient(),
@@ -41,9 +44,10 @@ func SetupClusterNoteWebhooksWithManager(mgr ctrl.Manager) error {
4144
// +kubebuilder:webhook:path=/mutate-notes-miloapis-com-v1alpha1-clusternote,mutating=true,failurePolicy=fail,sideEffects=None,groups=notes.miloapis.com,resources=clusternotes,verbs=create,versions=v1alpha1,name=mclusternote.notes.miloapis.com,admissionReviewVersions={v1,v1beta1},serviceName=milo-controller-manager,servicePort=9443,serviceNamespace=milo-system
4245

4346
type ClusterNoteMutator struct {
44-
Client client.Client
45-
Scheme *runtime.Scheme
46-
RESTMapper meta.RESTMapper
47+
Client client.Client
48+
Scheme *runtime.Scheme
49+
RESTMapper meta.RESTMapper
50+
ClusterManager mcmanager.Manager
4751
}
4852

4953
var _ admission.CustomDefaulter = &ClusterNoteMutator{}
@@ -70,7 +74,6 @@ func (m *ClusterNoteMutator) Default(ctx context.Context, obj runtime.Object) er
7074
}
7175

7276
// Set owner reference to the subject resource for automatic garbage collection
73-
// This is critical for the ClusterNote to be garbage collected when the subject is deleted
7477
if err := m.setSubjectOwnerReference(ctx, clusterNote); err != nil {
7578
clusterNoteLog.Error(err, "Failed to set owner reference to subject", "clusternote", clusterNote.Name)
7679
return errors.NewInternalError(fmt.Errorf("failed to set owner reference to subject: %w", err))
@@ -79,7 +82,8 @@ func (m *ClusterNoteMutator) Default(ctx context.Context, obj runtime.Object) er
7982
return nil
8083
}
8184

82-
// setSubjectOwnerReference sets the owner reference to the subject resource if it's cluster-scoped
85+
// setSubjectOwnerReference sets the owner reference to the subject resource if it's cluster-scoped.
86+
// The cluster context is expected to be injected by the ClusterAwareServer wrapper.
8387
func (m *ClusterNoteMutator) setSubjectOwnerReference(ctx context.Context, clusterNote *notesv1alpha1.ClusterNote) error {
8488
// ClusterNote can only have owner references to other cluster-scoped resources
8589
if clusterNote.Spec.SubjectRef.Namespace != "" {
@@ -97,24 +101,34 @@ func (m *ClusterNoteMutator) setSubjectOwnerReference(ctx context.Context, clust
97101
return fmt.Errorf("failed to get REST mapping for %s: %w", groupKind, err)
98102
}
99103

104+
key := types.NamespacedName{
105+
Name: clusterNote.Spec.SubjectRef.Name,
106+
}
107+
108+
// Determine which client to use based on cluster context (injected by ClusterAwareServer)
109+
subjectClient := m.Client
110+
111+
if m.ClusterManager != nil {
112+
if clusterName, ok := mccontext.ClusterFrom(ctx); ok && clusterName != "" {
113+
cluster, err := m.ClusterManager.GetCluster(ctx, clusterName)
114+
if err != nil {
115+
return fmt.Errorf("failed to get project control plane %s: %w", clusterName, err)
116+
}
117+
subjectClient = cluster.GetClient()
118+
clusterNoteLog.V(1).Info("Using project control plane client", "cluster", clusterName)
119+
}
120+
}
121+
100122
subject := &unstructured.Unstructured{}
101123
subject.SetGroupVersionKind(mapping.GroupVersionKind)
102-
103-
if err := m.Client.Get(ctx, types.NamespacedName{
104-
Name: clusterNote.Spec.SubjectRef.Name,
105-
}, subject); err != nil {
124+
if err := subjectClient.Get(ctx, key, subject); err != nil {
106125
if errors.IsNotFound(err) {
107126
return fmt.Errorf("subject resource not found: %w", err)
108127
}
109128
return fmt.Errorf("failed to get subject resource: %w", err)
110129
}
111130

112-
// Set owner reference
113-
if err := controllerutil.SetOwnerReference(subject, clusterNote, m.Scheme); err != nil {
114-
return fmt.Errorf("failed to set owner reference: %w", err)
115-
}
116-
117-
return nil
131+
return controllerutil.SetOwnerReference(subject, clusterNote, m.Scheme)
118132
}
119133

120134
// +kubebuilder:webhook:path=/validate-notes-miloapis-com-v1alpha1-clusternote,mutating=false,failurePolicy=fail,sideEffects=None,groups=notes.miloapis.com,resources=clusternotes,verbs=create;update,versions=v1alpha1,name=vclusternote.notes.miloapis.com,admissionReviewVersions={v1,v1beta1},serviceName=milo-controller-manager,servicePort=9443,serviceNamespace=milo-system

internal/webhooks/notes/v1alpha1/clusternote_webhook_test.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,9 +126,10 @@ func TestClusterNoteMutator_Default(t *testing.T) {
126126

127127
fakeClient := fake.NewClientBuilder().WithScheme(testScheme).WithObjects(objects...).Build()
128128
mutator := &ClusterNoteMutator{
129-
Client: fakeClient,
130-
Scheme: testScheme,
131-
RESTMapper: newTestRESTMapper(),
129+
Client: fakeClient,
130+
Scheme: testScheme,
131+
RESTMapper: newTestRESTMapper(),
132+
ClusterManager: nil, // nil means local-only search (backwards compatible)
132133
}
133134

134135
// Create admission request context

internal/webhooks/notes/v1alpha1/note_webhook.go

Lines changed: 35 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,21 @@ import (
1919
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
2020
logf "sigs.k8s.io/controller-runtime/pkg/log"
2121
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
22+
mccontext "sigs.k8s.io/multicluster-runtime/pkg/context"
23+
mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager"
2224
)
2325

2426
var noteLog = logf.Log.WithName("note-resource")
2527

26-
func SetupNoteWebhooksWithManager(mgr ctrl.Manager) error {
28+
func SetupNoteWebhooksWithManager(mgr ctrl.Manager, mcMgr mcmanager.Manager) error {
2729
noteLog.Info("Setting up notes.miloapis.com note webhooks")
2830
return ctrl.NewWebhookManagedBy(mgr).
2931
For(&notesv1alpha1.Note{}).
3032
WithDefaulter(&NoteMutator{
31-
Client: mgr.GetClient(),
32-
Scheme: mgr.GetScheme(),
33-
RESTMapper: mgr.GetRESTMapper(),
33+
Client: mgr.GetClient(),
34+
Scheme: mgr.GetScheme(),
35+
RESTMapper: mgr.GetRESTMapper(),
36+
ClusterManager: mcMgr,
3437
}).
3538
WithValidator(&NoteValidator{
3639
Client: mgr.GetClient(),
@@ -41,9 +44,10 @@ func SetupNoteWebhooksWithManager(mgr ctrl.Manager) error {
4144
// +kubebuilder:webhook:path=/mutate-notes-miloapis-com-v1alpha1-note,mutating=true,failurePolicy=fail,sideEffects=None,groups=notes.miloapis.com,resources=notes,verbs=create,versions=v1alpha1,name=mnote.notes.miloapis.com,admissionReviewVersions={v1,v1beta1},serviceName=milo-controller-manager,servicePort=9443,serviceNamespace=milo-system
4245

4346
type NoteMutator struct {
44-
Client client.Client
45-
Scheme *runtime.Scheme
46-
RESTMapper meta.RESTMapper
47+
Client client.Client
48+
Scheme *runtime.Scheme
49+
RESTMapper meta.RESTMapper
50+
ClusterManager mcmanager.Manager
4751
}
4852

4953
var _ admission.CustomDefaulter = &NoteMutator{}
@@ -70,7 +74,6 @@ func (m *NoteMutator) Default(ctx context.Context, obj runtime.Object) error {
7074
}
7175

7276
// Set owner reference to the subject resource for automatic garbage collection
73-
// This is critical for the Note to be garbage collected when the subject is deleted
7477
if err := m.setSubjectOwnerReference(ctx, note); err != nil {
7578
noteLog.Error(err, "Failed to set owner reference to subject", "note", note.Name)
7679
return errors.NewInternalError(fmt.Errorf("failed to set owner reference to subject: %w", err))
@@ -79,14 +82,15 @@ func (m *NoteMutator) Default(ctx context.Context, obj runtime.Object) error {
7982
return nil
8083
}
8184

82-
// setSubjectOwnerReference sets the owner reference to the subject resource if it's in the same namespace
85+
// setSubjectOwnerReference sets the owner reference to the subject resource if it's in the same namespace.
86+
// The cluster context is expected to be injected by the ClusterAwareServer wrapper.
8387
func (m *NoteMutator) setSubjectOwnerReference(ctx context.Context, note *notesv1alpha1.Note) error {
8488
// Only set owner reference if the subject is in the same namespace
8589
if note.Spec.SubjectRef.Namespace == "" || note.Spec.SubjectRef.Namespace != note.Namespace {
8690
return nil
8791
}
8892

89-
// Resolve the GVK using REST mapper to discover the correct API version
93+
// Resolve the GVK using REST mapper
9094
groupKind := schema.GroupKind{
9195
Group: note.Spec.SubjectRef.APIGroup,
9296
Kind: note.Spec.SubjectRef.Kind,
@@ -97,25 +101,35 @@ func (m *NoteMutator) setSubjectOwnerReference(ctx context.Context, note *notesv
97101
return fmt.Errorf("failed to get REST mapping for %s: %w", groupKind, err)
98102
}
99103

100-
subject := &unstructured.Unstructured{}
101-
subject.SetGroupVersionKind(mapping.GroupVersionKind)
102-
103-
if err := m.Client.Get(ctx, types.NamespacedName{
104+
key := types.NamespacedName{
104105
Name: note.Spec.SubjectRef.Name,
105106
Namespace: note.Spec.SubjectRef.Namespace,
106-
}, subject); err != nil {
107+
}
108+
109+
// Determine which client to use based on cluster context (injected by ClusterAwareServer)
110+
subjectClient := m.Client
111+
112+
if m.ClusterManager != nil {
113+
if clusterName, ok := mccontext.ClusterFrom(ctx); ok && clusterName != "" {
114+
cluster, err := m.ClusterManager.GetCluster(ctx, clusterName)
115+
if err != nil {
116+
return fmt.Errorf("failed to get project control plane %s: %w", clusterName, err)
117+
}
118+
subjectClient = cluster.GetClient()
119+
noteLog.V(1).Info("Using project control plane client", "cluster", clusterName)
120+
}
121+
}
122+
123+
subject := &unstructured.Unstructured{}
124+
subject.SetGroupVersionKind(mapping.GroupVersionKind)
125+
if err := subjectClient.Get(ctx, key, subject); err != nil {
107126
if errors.IsNotFound(err) {
108127
return fmt.Errorf("subject resource not found: %w", err)
109128
}
110129
return fmt.Errorf("failed to get subject resource: %w", err)
111130
}
112131

113-
// Set owner reference
114-
if err := controllerutil.SetOwnerReference(subject, note, m.Scheme); err != nil {
115-
return fmt.Errorf("failed to set owner reference: %w", err)
116-
}
117-
118-
return nil
132+
return controllerutil.SetOwnerReference(subject, note, m.Scheme)
119133
}
120134

121135
// +kubebuilder:webhook:path=/validate-notes-miloapis-com-v1alpha1-note,mutating=false,failurePolicy=fail,sideEffects=None,groups=notes.miloapis.com,resources=notes,verbs=create;update,versions=v1alpha1,name=vnote.notes.miloapis.com,admissionReviewVersions={v1,v1beta1},serviceName=milo-controller-manager,servicePort=9443,serviceNamespace=milo-system

internal/webhooks/notes/v1alpha1/note_webhook_test.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -160,9 +160,10 @@ func TestNoteMutator_Default(t *testing.T) {
160160

161161
fakeClient := fake.NewClientBuilder().WithScheme(testScheme).WithObjects(objects...).Build()
162162
mutator := &NoteMutator{
163-
Client: fakeClient,
164-
Scheme: testScheme,
165-
RESTMapper: newTestRESTMapper(),
163+
Client: fakeClient,
164+
Scheme: testScheme,
165+
RESTMapper: newTestRESTMapper(),
166+
ClusterManager: nil, // nil means local-only search (backwards compatible)
166167
}
167168

168169
// Create admission request context

pkg/multicluster-runtime/milo/provider.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,9 @@ func (p *Provider) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result
156156
log := p.log.WithValues("project", req.Name)
157157
log.Info("Reconciling Project")
158158

159-
key := req.String()
159+
// Use just the project name as the key for cluster lookup.
160+
// This matches the project name used in URL paths and ParentNameExtraKey.
161+
key := req.Name
160162
var project unstructured.Unstructured
161163

162164
if p.opts.InternalServiceDiscovery {

pkg/multicluster-runtime/milo/provider_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ func TestReadyProject(t *testing.T) {
6161
assert.Zero(t, result.RequeueAfter)
6262
assert.Len(t, provider.projects, 1)
6363

64-
cl, err := provider.Get(context.Background(), "/test-project")
64+
cl, err := provider.Get(context.Background(), "test-project")
6565
assert.NoError(t, err)
6666
apiHost, err := url.Parse(cl.GetConfig().Host)
6767
assert.NoError(t, err)

0 commit comments

Comments
 (0)