-
Notifications
You must be signed in to change notification settings - Fork 211
Expand file tree
/
Copy pathmcpexternalauthconfig_controller.go
More file actions
379 lines (329 loc) · 15.2 KB
/
mcpexternalauthconfig_controller.go
File metadata and controls
379 lines (329 loc) · 15.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0
package controllers
import (
"context"
"fmt"
"time"
"k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/handler"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1"
ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil"
)
const (
// ExternalAuthConfigFinalizerName is the name of the finalizer for MCPExternalAuthConfig
ExternalAuthConfigFinalizerName = "mcpexternalauthconfig.toolhive.stacklok.dev/finalizer"
// externalAuthConfigRequeueDelay is the delay before requeuing after adding a finalizer
externalAuthConfigRequeueDelay = 500 * time.Millisecond
)
// MCPExternalAuthConfigReconciler reconciles a MCPExternalAuthConfig object
type MCPExternalAuthConfigReconciler struct {
client.Client
Scheme *runtime.Scheme
}
// +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcpexternalauthconfigs,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcpexternalauthconfigs/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcpexternalauthconfigs/finalizers,verbs=update
// +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcpservers,verbs=get;list;watch;update;patch
// Reconcile is part of the main kubernetes reconciliation loop which aims to
// move the current state of the cluster closer to the desired state.
func (r *MCPExternalAuthConfigReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
// Fetch the MCPExternalAuthConfig instance
externalAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{}
err := r.Get(ctx, req.NamespacedName, externalAuthConfig)
if err != nil {
if errors.IsNotFound(err) {
// Object not found, could have been deleted after reconcile request.
// Return and don't requeue
logger.Info("MCPExternalAuthConfig resource not found. Ignoring since object must be deleted")
return ctrl.Result{}, nil
}
// Error reading the object - requeue the request.
logger.Error(err, "Failed to get MCPExternalAuthConfig")
return ctrl.Result{}, err
}
// Check if the MCPExternalAuthConfig is being deleted
if !externalAuthConfig.DeletionTimestamp.IsZero() {
return r.handleDeletion(ctx, externalAuthConfig)
}
// Add finalizer if it doesn't exist
if !controllerutil.ContainsFinalizer(externalAuthConfig, ExternalAuthConfigFinalizerName) {
controllerutil.AddFinalizer(externalAuthConfig, ExternalAuthConfigFinalizerName)
if err := r.Update(ctx, externalAuthConfig); err != nil {
logger.Error(err, "Failed to add finalizer")
return ctrl.Result{}, err
}
// Requeue to continue processing after finalizer is added
return ctrl.Result{RequeueAfter: externalAuthConfigRequeueDelay}, nil
}
// Validate spec configuration early
if err := externalAuthConfig.Validate(); err != nil {
logger.Error(err, "MCPExternalAuthConfig spec validation failed")
// Update status with validation error
meta.SetStatusCondition(&externalAuthConfig.Status.Conditions, metav1.Condition{
Type: "Valid",
Status: metav1.ConditionFalse,
Reason: "ValidationFailed",
Message: err.Error(),
ObservedGeneration: externalAuthConfig.Generation,
})
if updateErr := r.Status().Update(ctx, externalAuthConfig); updateErr != nil {
logger.Error(updateErr, "Failed to update status after validation error")
}
return ctrl.Result{}, nil // Don't requeue on validation errors - user must fix spec
}
// Validation succeeded - set Valid=True condition
conditionChanged := meta.SetStatusCondition(&externalAuthConfig.Status.Conditions, metav1.Condition{
Type: "Valid",
Status: metav1.ConditionTrue,
Reason: "ValidationSucceeded",
Message: "Spec validation passed",
ObservedGeneration: externalAuthConfig.Generation,
})
// Calculate the hash of the current configuration
configHash := r.calculateConfigHash(externalAuthConfig.Spec)
// Check if the hash has changed
hashChanged := externalAuthConfig.Status.ConfigHash != configHash
if hashChanged {
return r.handleConfigHashChange(ctx, externalAuthConfig, configHash)
}
// Update condition if it changed (even without hash change)
if conditionChanged {
if err := r.Status().Update(ctx, externalAuthConfig); err != nil {
logger.Error(err, "Failed to update MCPExternalAuthConfig status after condition change")
return ctrl.Result{}, err
}
}
// Even when hash hasn't changed, update referencing workloads list.
// This ensures ReferencingWorkloads is updated when MCPServers are created or deleted.
return r.updateReferencingWorkloads(ctx, externalAuthConfig)
}
// calculateConfigHash calculates a hash of the MCPExternalAuthConfig spec using Kubernetes utilities
func (*MCPExternalAuthConfigReconciler) calculateConfigHash(spec mcpv1alpha1.MCPExternalAuthConfigSpec) string {
return ctrlutil.CalculateConfigHash(spec)
}
// handleConfigHashChange handles the logic when the config hash changes
func (r *MCPExternalAuthConfigReconciler) handleConfigHashChange(
ctx context.Context,
externalAuthConfig *mcpv1alpha1.MCPExternalAuthConfig,
configHash string,
) (ctrl.Result, error) {
logger := log.FromContext(ctx)
logger.Info("MCPExternalAuthConfig configuration changed",
"oldHash", externalAuthConfig.Status.ConfigHash,
"newHash", configHash)
// Update the status with the new hash
externalAuthConfig.Status.ConfigHash = configHash
externalAuthConfig.Status.ObservedGeneration = externalAuthConfig.Generation
// Find all MCPServers that reference this MCPExternalAuthConfig
referencingServers, err := r.findReferencingMCPServers(ctx, externalAuthConfig)
if err != nil {
logger.Error(err, "Failed to find referencing MCPServers")
return ctrl.Result{}, fmt.Errorf("failed to find referencing MCPServers: %w", err)
}
// Update the status with the list of referencing workloads
refs := make([]mcpv1alpha1.WorkloadReference, 0, len(referencingServers))
for _, server := range referencingServers {
refs = append(refs, mcpv1alpha1.WorkloadReference{Kind: mcpv1alpha1.WorkloadKindMCPServer, Name: server.Name})
}
ctrlutil.SortWorkloadRefs(refs)
externalAuthConfig.Status.ReferencingWorkloads = refs
// Update the MCPExternalAuthConfig status
if err := r.Status().Update(ctx, externalAuthConfig); err != nil {
logger.Error(err, "Failed to update MCPExternalAuthConfig status")
return ctrl.Result{}, err
}
// Trigger reconciliation of all referencing MCPServers
for _, server := range referencingServers {
logger.Info("Triggering reconciliation of MCPServer due to MCPExternalAuthConfig change",
"mcpserver", server.Name, "externalAuthConfig", externalAuthConfig.Name)
// Add an annotation to the MCPServer to trigger reconciliation
if server.Annotations == nil {
server.Annotations = make(map[string]string)
}
server.Annotations["toolhive.stacklok.dev/externalauthconfig-hash"] = configHash
if err := r.Update(ctx, &server); err != nil {
logger.Error(err, "Failed to update MCPServer annotation", "mcpserver", server.Name)
// Continue with other servers even if one fails
}
}
return ctrl.Result{}, nil
}
// handleDeletion handles the deletion of a MCPExternalAuthConfig
func (r *MCPExternalAuthConfigReconciler) handleDeletion(
ctx context.Context,
externalAuthConfig *mcpv1alpha1.MCPExternalAuthConfig,
) (ctrl.Result, error) {
logger := log.FromContext(ctx)
if controllerutil.ContainsFinalizer(externalAuthConfig, ExternalAuthConfigFinalizerName) {
// Check if any workloads still reference this MCPExternalAuthConfig
referencingWorkloads, err := r.findReferencingWorkloads(ctx, externalAuthConfig)
if err != nil {
logger.Error(err, "Failed to check referencing workloads during deletion")
return ctrl.Result{}, err
}
if len(referencingWorkloads) > 0 {
logger.Info("MCPExternalAuthConfig is still referenced by workloads, blocking deletion",
"externalAuthConfig", externalAuthConfig.Name,
"referencingWorkloads", referencingWorkloads)
meta.SetStatusCondition(&externalAuthConfig.Status.Conditions, metav1.Condition{
Type: "DeletionBlocked",
Status: metav1.ConditionTrue,
Reason: "ReferencedByWorkloads",
Message: fmt.Sprintf("Cannot delete: referenced by workloads: %v", referencingWorkloads),
ObservedGeneration: externalAuthConfig.Generation,
})
externalAuthConfig.Status.ReferencingWorkloads = referencingWorkloads
if updateErr := r.Status().Update(ctx, externalAuthConfig); updateErr != nil {
logger.Error(updateErr, "Failed to update status during deletion block")
}
// Requeue to check again later
return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
}
// No references, safe to remove finalizer and allow deletion
controllerutil.RemoveFinalizer(externalAuthConfig, ExternalAuthConfigFinalizerName)
if err := r.Update(ctx, externalAuthConfig); err != nil {
logger.Error(err, "Failed to remove finalizer")
return ctrl.Result{}, err
}
logger.Info("Removed finalizer from MCPExternalAuthConfig", "externalAuthConfig", externalAuthConfig.Name)
}
return ctrl.Result{}, nil
}
// findReferencingMCPServers finds all MCPServers that reference the given MCPExternalAuthConfig
func (r *MCPExternalAuthConfigReconciler) findReferencingMCPServers(
ctx context.Context,
externalAuthConfig *mcpv1alpha1.MCPExternalAuthConfig,
) ([]mcpv1alpha1.MCPServer, error) {
return ctrlutil.FindReferencingMCPServers(ctx, r.Client, externalAuthConfig.Namespace, externalAuthConfig.Name,
func(server *mcpv1alpha1.MCPServer) *string {
if server.Spec.ExternalAuthConfigRef != nil {
return &server.Spec.ExternalAuthConfigRef.Name
}
return nil
})
}
// findReferencingWorkloads returns the workload resources (MCPServer)
// that reference this MCPExternalAuthConfig via their ExternalAuthConfigRef field.
func (r *MCPExternalAuthConfigReconciler) findReferencingWorkloads(
ctx context.Context,
externalAuthConfig *mcpv1alpha1.MCPExternalAuthConfig,
) ([]mcpv1alpha1.WorkloadReference, error) {
return ctrlutil.FindWorkloadRefsFromMCPServers(ctx, r.Client, externalAuthConfig.Namespace, externalAuthConfig.Name,
func(server *mcpv1alpha1.MCPServer) *string {
if server.Spec.ExternalAuthConfigRef != nil {
return &server.Spec.ExternalAuthConfigRef.Name
}
return nil
})
}
// SetupWithManager sets up the controller with the Manager.
// Watches MCPServer changes to maintain accurate ReferencingWorkloads status.
func (r *MCPExternalAuthConfigReconciler) SetupWithManager(mgr ctrl.Manager) error {
// Watch MCPServer changes to update ReferencingWorkloads on referenced MCPExternalAuthConfigs.
// This handler enqueues both the currently-referenced MCPExternalAuthConfig AND any
// MCPExternalAuthConfig that still lists this server in ReferencingWorkloads (covers the
// case where a server removes its externalAuthConfigRef — the previously-referenced
// config needs to reconcile and clean up the stale entry).
mcpServerHandler := handler.EnqueueRequestsFromMapFunc(
func(ctx context.Context, obj client.Object) []reconcile.Request {
server, ok := obj.(*mcpv1alpha1.MCPServer)
if !ok {
return nil
}
seen := make(map[types.NamespacedName]struct{})
var requests []reconcile.Request
// Enqueue the currently-referenced MCPExternalAuthConfig (if any)
if server.Spec.ExternalAuthConfigRef != nil {
nn := types.NamespacedName{
Name: server.Spec.ExternalAuthConfigRef.Name,
Namespace: server.Namespace,
}
seen[nn] = struct{}{}
requests = append(requests, reconcile.Request{NamespacedName: nn})
}
// Also enqueue any MCPExternalAuthConfig that still lists this server in
// ReferencingWorkloads — handles ref-removal and server-deletion cases.
extAuthConfigList := &mcpv1alpha1.MCPExternalAuthConfigList{}
if err := r.List(ctx, extAuthConfigList, client.InNamespace(server.Namespace)); err != nil {
log.FromContext(ctx).Error(err, "Failed to list MCPExternalAuthConfigs for MCPServer watch")
return requests
}
for _, cfg := range extAuthConfigList.Items {
nn := types.NamespacedName{Name: cfg.Name, Namespace: cfg.Namespace}
if _, already := seen[nn]; already {
continue
}
for _, ref := range cfg.Status.ReferencingWorkloads {
if ref.Kind == mcpv1alpha1.WorkloadKindMCPServer && ref.Name == server.Name {
requests = append(requests, reconcile.Request{NamespacedName: nn})
break
}
}
}
return requests
},
)
return ctrl.NewControllerManagedBy(mgr).
For(&mcpv1alpha1.MCPExternalAuthConfig{}).
// Watch for MCPServers and reconcile the referenced MCPExternalAuthConfig when they change
Watches(&mcpv1alpha1.MCPServer{}, mcpServerHandler).
Complete(r)
}
// updateReferencingWorkloads finds referencing workloads and updates the status if the list changed
func (r *MCPExternalAuthConfigReconciler) updateReferencingWorkloads(
ctx context.Context,
externalAuthConfig *mcpv1alpha1.MCPExternalAuthConfig,
) (ctrl.Result, error) {
refs, err := r.findReferencingWorkloads(ctx, externalAuthConfig)
if err != nil {
logger := log.FromContext(ctx)
logger.Error(err, "Failed to find referencing workloads")
return ctrl.Result{}, fmt.Errorf("failed to find referencing workloads: %w", err)
}
if !ctrlutil.WorkloadRefsEqual(externalAuthConfig.Status.ReferencingWorkloads, refs) {
externalAuthConfig.Status.ReferencingWorkloads = refs
if err := r.Status().Update(ctx, externalAuthConfig); err != nil {
logger := log.FromContext(ctx)
logger.Error(err, "Failed to update MCPExternalAuthConfig status")
return ctrl.Result{}, err
}
}
return ctrl.Result{}, nil
}
// GetExternalAuthConfigForMCPServer retrieves the MCPExternalAuthConfig referenced by an MCPServer.
// This function is exported for use by the MCPServer controller (Phase 5 integration).
func GetExternalAuthConfigForMCPServer(
ctx context.Context,
c client.Client,
mcpServer *mcpv1alpha1.MCPServer,
) (*mcpv1alpha1.MCPExternalAuthConfig, error) {
if mcpServer.Spec.ExternalAuthConfigRef == nil {
// We throw an error because in this case you assume there is a ExternalAuthConfig
// but there isn't one referenced.
return nil, fmt.Errorf("MCPServer %s does not reference a MCPExternalAuthConfig", mcpServer.Name)
}
externalAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{}
err := c.Get(ctx, types.NamespacedName{
Name: mcpServer.Spec.ExternalAuthConfigRef.Name,
Namespace: mcpServer.Namespace, // Same namespace as MCPServer
}, externalAuthConfig)
if err != nil {
if errors.IsNotFound(err) {
return nil, fmt.Errorf("MCPExternalAuthConfig %s not found in namespace %s",
mcpServer.Spec.ExternalAuthConfigRef.Name, mcpServer.Namespace)
}
return nil, fmt.Errorf("failed to get MCPExternalAuthConfig: %w", err)
}
return externalAuthConfig, nil
}