Skip to content

Commit e867e09

Browse files
authored
🤖 fix: hold a deleted CoderControlPlane while CoderMCPServer objects still use it (#384)
## Summary A deleted `CoderControlPlane` now waits for the `CoderMCPServer` objects that use it, as #362 made it wait for `CoderAIGatewayKey` and `CoderAIProvider` objects. Coder keeps running while it waits, so each MCP server delete reaches Coder and retries on failure. Before this PR the control plane went at once. The MCP server then made one 10-second delete attempt or released with `ControlPlaneGone`, and its config could stay in Coder, enabled and with its stored credential. ## What changes 1. `controlPlaneDependents` (`internal/controller/controlplane_hold.go`) also lists `CoderMCPServer` objects that reference the control plane and are being deleted with their cleanup finalizer, plus live ones for the first 30 seconds, the same rule as the other two kinds. The per-kind count for the grace period covers the new kind, and the grace period uses the largest bounded delete timeout of the three kinds (all are 10 s). 2. `requestForDependentControlPlane` maps a `CoderMCPServer` to its control plane, and the control plane controller watches `CoderMCPServer` removals (`dependentRemoved`), so a waiting control plane goes at once. 3. The `CoderMCPServer` delete path checks `controlPlaneHolds`, as the `CoderAIProvider` delete path does. While the control plane holds for it, it takes the normal path (retry every 60 s, capped at the hold end) instead of one bounded attempt and a release. Without this step, the hold would keep Coder running but a failed attempt would still release at once. Under the hold, the pass runs with a context deadline at the hold end, so a slow Coder cannot stretch it past the hold. The bounded pass after the hold then fits in the grace period. The retry counts from the end of the pass, so it lands right after the hold end (review rounds 2 and 3). 4. The how-to "Delete a server" section describes the hold and when the control plane does not wait (namespace deletion, foreground deletion, after 10 minutes). 5. How-to fix from the #381 assessment: a name in `access.groups` that is not a valid Coder group name stalls with `GroupNotFound` and no requeue (`codermcpserver_acl.go:147`). The doc said that it checks again every 10 minutes. It now says that the object is not checked again until the spec changes. The API server refuses such names, so this applies only when admission was bypassed. ## Validation - `TestCoderMCPServerDeleteUnderControlPlaneHold` (envtest): with a control plane that is being deleted and an applied `CoderMCPServer` that is being deleted, the control plane keeps its finalizer. A failed DELETE (502) keeps the MCP finalizer with `CoderUnavailable` and a 60 s requeue, and the control plane still waits. The retry deletes the config, the MCP object goes, and then the control plane goes. Red before the fix: "the control plane waits for the MCP server" failed. - `TestMCPServerDeleteUnderHoldEndsAtHoldEnd` (unit, fake client): with a hold that ends in 5 s and a Coder that hangs, the pass ends near the hold end with `CoderUnavailable`, keeps the finalizer, and requeues right after the hold end. Without the deadline it waits for the 30 s request timeout (mutation checked). - `TestRequestForDependentControlPlane` (unit): each of the three dependent kinds maps to its control plane, and another kind maps to nothing. - Mutation checks: removing the `CoderMCPServer` case of the mapper fails the unit test, forcing the bounded path in the MCP delete fails the envtest test, and dropping the hold-end deadline fails the unit test above. - The existing `TestCoderMCPServerDeleteWhileControlPlaneDeletes`, `TestControlPlane*`, `TestCoderAIProviderDelete*` and `TestCoderAIGatewayKeyDelete*` tests pass unchanged. - Local gates on the final head (`859d6bc7`): `make verify-vendor`, `make build`, `make lint`, `GOFLAGS=-mod=vendor go tool govulncheck ./...`, `make test`, `make test-integration`, `go test -race ./internal/controller/`, and `make docs-check` pass. `make codegen`, `make manifests`, `make build-installer` and `make docs-reference` leave no diff. RBAC does not change: the manager role already grants `get`, `list` and `watch` on `codermcpservers`. - The full Kind E2E runs in CI on this head (the path filter matches `internal/controller/codermcpserver*` and `controlplane_hold.go`). ## Not in this PR - `docs/how-to/manage-ai-providers.md` names only `CoderAIProvider` and `CoderAIGatewayKey` in its hold sentence. That page describes providers, and #374 edits it, so I left it. - MCP server releases without a delete do not count in `coder_k8s_cleanup_released_without_delete_total`, and #353 part 2 (cleanup records and the sweep, #374 and #375) covers only `CoderAIProvider`. Both are follow-ups for #353. ## Size 199 changed lines: 65 in the controllers, 129 in tests and 5 in the how-to. Fixes #382 Refs #353 Refs #155 --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `high` • Cost: `$224.47`_ <!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=high costs=224.47 --> --------- Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent 3f1bee7 commit e867e09

7 files changed

Lines changed: 185 additions & 14 deletions

‎docs/how-to/manage-mcp-servers.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ When a group in `access.groups` does not exist, the sync fails closed:
137137
1. It removes every entry that `access.groups` no longer names, also user entries.
138138
2. It adds nothing. Access never widens while a group is missing.
139139
3. If the same spec generation disables the server (`enabled: false`), it sends one update that holds only `enabled: false`.
140-
4. It leaves the rest of the generation unapplied. The object stalls with `GroupNotFound` and checks again every 10 minutes.
140+
4. It leaves the rest of the generation unapplied. The object stalls with `GroupNotFound`. When the name is valid but no such group exists yet, the operator checks again every 10 minutes. The API server refuses a name that is not a valid Coder group name. If such a name reaches the operator anyway, the operator does not check again until the spec changes: fix the name.
141141
5. Once the missing groups exist, the sync adds them and applies the rest of the generation.
142142

143143
A new server stays disabled in Coder while one of its groups is missing.
@@ -207,7 +207,8 @@ Deleting the object deletes the config in Coder. Coder removes the server from e
207207
- The operator deletes only a config that this object created. With only an unconfirmed create, it reads Coder first. It deletes a config that carries this object's temporary display name, and it never touches another config with the slug. A create can still commit for a short time, so within 2 minutes of the create request the operator reads again before it trusts that the config is absent.
208208
- When Coder is unreachable, or the control plane has no operator access, the finalizer stays and the operator retries every 60 seconds. The operator trusts only Coder's own 404 as proof that the config is gone. A 404 from a proxy keeps the finalizer.
209209
- When the control plane is gone, nothing can delete the config. The finalizer goes at once. The operator logs the slug and the config ID with the reason `ControlPlaneGone`. Delete the config in the Coder dashboard.
210-
- While the control plane is being deleted, the operator sends one delete with a 10-second limit, then removes the finalizer, also when that delete fails. So a namespace deletion finishes, but it can leave the config in Coder.
210+
- When you delete the control plane while `CoderMCPServer` objects that use it are being deleted, the control plane waits for them for up to 10 minutes, plus a grace period for a last bounded attempt. It keeps its finalizer, so Coder keeps running, and the delete above runs with its retries. An object that you do not delete holds the control plane for only 30 seconds, so delete the objects together with the control plane. A foreground deletion (`kubectl delete --cascade=foreground`) does not wait.
211+
- When the control plane does not wait (its namespace is being deleted, it is deleted in the foreground, or the 10 minutes have passed), the operator sends one delete with a 10-second limit, then removes the finalizer, also when that delete fails. So a namespace deletion finishes, but it can leave the config in Coder.
211212
- A withdrawn opt-in never blocks a delete.
212213

213214
Delete `CoderMCPServer` objects before their control plane and before their namespace.

‎internal/controller/codercontrolplane_controller.go‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -623,8 +623,8 @@ func (r *CoderControlPlaneReconciler) finalizeWorkspaceRBAC(
623623
return ctrl.Result{}, err
624624
}
625625
// The finalizer keeps the Coder Deployment (owned by this object)
626-
// running while AI Gateway keys and AI providers that use it still
627-
// delete their resources in Coder (#353). The grace period after the hold
626+
// running while AI Gateway keys, AI providers and MCP servers that use
627+
// it still delete their resources in Coder (#353, #382). The grace period after the hold
628628
// lets each of them run its bounded pass before Coder goes.
629629
now := r.now()
630630
dependents, perKind, err := controlPlaneDependents(ctx, r.Client, coderControlPlane, now)
@@ -3309,6 +3309,8 @@ func (r *CoderControlPlaneReconciler) SetupWithManager(mgr ctrl.Manager) error {
33093309
Watches(&coderv1alpha1.CoderAIGatewayKey{}, handler.EnqueueRequestsFromMapFunc(requestForDependentControlPlane),
33103310
ctrlbuilder.WithPredicates(dependentRemoved)).
33113311
Watches(&coderv1alpha1.CoderAIProvider{}, handler.EnqueueRequestsFromMapFunc(requestForDependentControlPlane),
3312+
ctrlbuilder.WithPredicates(dependentRemoved)).
3313+
Watches(&coderv1alpha1.CoderMCPServer{}, handler.EnqueueRequestsFromMapFunc(requestForDependentControlPlane),
33123314
ctrlbuilder.WithPredicates(dependentRemoved))
33133315

33143316
// Gateway API is optional; only watch HTTPRoutes when the CRD is installed.

‎internal/controller/codermcpserver_delete.go‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,33 @@ func (r *CoderMCPServerReconciler) deleteStep(ctx context.Context, server *coder
4949
return false, nil, err
5050
}
5151
deleting := !controlPlane.DeletionTimestamp.IsZero()
52-
sdk, _, wait, err := controlPlaneCoderClientFor(ctx, r.Client, ref.Namespace, ref.Name, deleting)
52+
if deleting {
53+
// While the control plane holds for this object, Coder keeps running:
54+
// use the normal path, which retries, instead of the bounded pass
55+
// (#382, #353).
56+
now := time.Now()
57+
holds, err := controlPlaneHolds(ctx, r.Client, controlPlane, now)
58+
if err != nil {
59+
return false, nil, err
60+
}
61+
deleting = !holds
62+
if holds {
63+
// A pass under the hold ends at the hold end, however slowly
64+
// Coder answers. The bounded pass after it then still fits in
65+
// the control plane's grace period.
66+
var cancel context.CancelFunc
67+
ctx, cancel = context.WithDeadline(ctx, controlPlaneHoldEnd(controlPlane))
68+
defer cancel()
69+
defer func() {
70+
if step != nil {
71+
// From the time the pass ended: a pass that ran until the
72+
// deadline retries right after the hold end.
73+
step.requeue = controlPlaneHoldRequeue(controlPlane, time.Now(), step.requeue)
74+
}
75+
}()
76+
}
77+
}
78+
sdk, _, wait, err := controlPlaneCoderClientFor(ctx, r.Client, ref.Namespace, ref.Name, !controlPlane.DeletionTimestamp.IsZero())
5379
switch {
5480
case err != nil && deleting:
5581
// One attempt only, also when the access setup fails.
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package controller
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"net/http/httptest"
7+
"testing"
8+
"time"
9+
10+
"github.com/google/uuid"
11+
"github.com/stretchr/testify/require"
12+
corev1 "k8s.io/api/core/v1"
13+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
14+
"k8s.io/apimachinery/pkg/runtime"
15+
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
16+
"sigs.k8s.io/controller-runtime/pkg/client/fake"
17+
18+
coderv1alpha1 "github.com/coder/coder-k8s/api/v1alpha1"
19+
)
20+
21+
// #384 review: a delete pass under the control plane hold ends at the hold
22+
// end, also when Coder does not answer, so the bounded pass after it fits in
23+
// the grace period. Here the hold ends in 5 s and Coder hangs on the DELETE,
24+
// whose own request timeout is 30 s.
25+
func TestMCPServerDeleteUnderHoldEndsAtHoldEnd(t *testing.T) {
26+
t.Parallel()
27+
coder := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
28+
<-r.Context().Done()
29+
}))
30+
t.Cleanup(coder.Close)
31+
32+
scheme := runtime.NewScheme()
33+
require.NoError(t, clientgoscheme.AddToScheme(scheme))
34+
require.NoError(t, coderv1alpha1.AddToScheme(scheme))
35+
deleted := metav1.NewTime(time.Now().Add(5*time.Second - controlPlaneDependentHold))
36+
controlPlane := &coderv1alpha1.CoderControlPlane{ObjectMeta: metav1.ObjectMeta{
37+
Name: "coder", Namespace: "ns", DeletionTimestamp: &deleted, Finalizers: []string{workspaceRBACFinalizer},
38+
}}
39+
controlPlane.Status.URL = coder.URL
40+
controlPlane.Status.OperatorAccessReady = true
41+
controlPlane.Status.OperatorTokenSecretRef = &coderv1alpha1.SecretKeySelector{Name: "coder-operator-token"}
42+
token := &corev1.Secret{
43+
ObjectMeta: metav1.ObjectMeta{Name: "coder-operator-token", Namespace: "ns"},
44+
Data: map[string][]byte{coderv1alpha1.DefaultTokenSecretKey: []byte("token")},
45+
}
46+
namespace := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "ns"}}
47+
r := &CoderMCPServerReconciler{Client: fake.NewClientBuilder().WithScheme(scheme).WithObjects(controlPlane, token, namespace).Build(), Scheme: scheme}
48+
held, err := controlPlaneHolds(context.Background(), r.Client, controlPlane, time.Now())
49+
require.NoError(t, err)
50+
require.True(t, held, "assertion failed: the test needs a control plane that holds")
51+
52+
server := &coderv1alpha1.CoderMCPServer{ObjectMeta: metav1.ObjectMeta{Name: "github", Namespace: "ns"}}
53+
server.Spec.ControlPlaneRef.Name = "coder"
54+
server.Spec.Slug = "github"
55+
server.Status.OrganizationID = uuid.NewString()
56+
server.Status.ConfigID = uuid.NewString()
57+
start := time.Now()
58+
release, step, err := r.deleteStep(context.Background(), server)
59+
require.NoError(t, err)
60+
require.Less(t, time.Since(start), 15*time.Second, "the pass ends at the hold end, not at the 30 s request timeout")
61+
require.False(t, release, "a pass under the hold never releases without a delete")
62+
require.NotNil(t, step)
63+
require.Equal(t, "CoderUnavailable", step.reason)
64+
// The requeue counts from the end of the pass: the time left to the hold
65+
// end, which is about 0 here, plus 1 s (controlPlaneHoldRequeue).
66+
require.LessOrEqual(t, step.requeue, 2*time.Second, "the next pass runs right after the hold end")
67+
}

‎internal/controller/codermcpserver_delete_test.go‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,44 @@ func TestCoderMCPServerDeleteWhileControlPlaneDeletes(t *testing.T) {
218218
}
219219
}
220220

221+
// #382 (#353): a control plane that is being deleted waits for a
222+
// CoderMCPServer that is being deleted, so Coder keeps running. Under that
223+
// hold the object takes the normal delete path: a failed DELETE keeps the
224+
// finalizer and retries, instead of one bounded attempt and a release. Then
225+
// the control plane goes.
226+
func TestCoderMCPServerDeleteUnderControlPlaneHold(t *testing.T) {
227+
t.Parallel()
228+
ctx := context.Background()
229+
env := newMCPServerEnv(ctx, t, true)
230+
env.keySecret(ctx, t, testMCPKey)
231+
got, _ := env.applied(ctx, t, nil)
232+
deleteFinalizedControlPlane(ctx, t, env.namespace, "coder")
233+
require.NoError(t, k8sClient.Delete(ctx, got))
234+
235+
controlPlaneGone := func() bool {
236+
t.Helper()
237+
r := &controller.CoderControlPlaneReconciler{Client: k8sClient, Scheme: scheme}
238+
_, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKey{Namespace: env.namespace, Name: "coder"}})
239+
require.NoError(t, err)
240+
err = k8sClient.Get(ctx, client.ObjectKey{Namespace: env.namespace, Name: "coder"}, &coderv1alpha1.CoderControlPlane{})
241+
return apierrors.IsNotFound(err)
242+
}
243+
require.False(t, controlPlaneGone(), "the control plane waits for the MCP server")
244+
245+
env.fake.failNext(routeDeleteMCPServer, fakeFault{Status: http.StatusBadGateway})
246+
kept, result := env.reconcileDeleting(ctx, t, got)
247+
require.NotNil(t, kept, "a failed delete keeps the finalizer while the control plane waits")
248+
requireMCPServerStep(t, kept, false, "CoderUnavailable")
249+
require.Equal(t, 60*time.Second, result.RequeueAfter)
250+
require.False(t, controlPlaneGone(), "the control plane still waits")
251+
252+
gone, _ := env.reconcileDeleting(ctx, t, kept)
253+
require.Nil(t, gone)
254+
require.Empty(t, env.fake.mcpServers(), "the retry deleted the config")
255+
require.Equal(t, 2, env.fake.requestCount(routeDeleteMCPServer))
256+
require.True(t, controlPlaneGone(), "the control plane goes after the MCP server")
257+
}
258+
221259
// From the #329 final assessment: a released object's entry in the
222260
// reconciler's credential hash map goes, so the map does not grow without
223261
// bound.

‎internal/controller/controlplane_hold.go‎

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,10 @@ import (
2222
)
2323

2424
// controlPlaneDependentHold is how long a CoderControlPlane that is being
25-
// deleted waits for the CoderAIGatewayKey and CoderAIProvider objects that
26-
// use it (#353). Its Coder Deployment keeps running while it waits, so they
27-
// can delete their keys and providers in Coder. It covers the 5-minute
25+
// deleted waits for the CoderAIGatewayKey, CoderAIProvider and CoderMCPServer
26+
// objects that use it (#353, #382). Its Coder Deployment keeps running while
27+
// it waits, so they can delete their keys, providers and MCP server configs
28+
// in Coder. It covers the 5-minute
2829
// settle window of a key deletion plus retries. After it, the control plane
2930
// goes, and they release with ControlPlaneGone.
3031
const controlPlaneDependentHold = 10 * time.Minute
@@ -93,7 +94,7 @@ func controlPlaneHoldEnd(controlPlane *coderv1alpha1.CoderControlPlane) time.Tim
9394
// controlPlaneDependentGraceFor is the grace period after the hold when
9495
// perKind dependents of one kind remain (controlPlaneDependentGrace).
9596
func controlPlaneDependentGraceFor(perKind int) time.Duration {
96-
return min(controlPlaneDependentGrace+time.Duration(perKind)*max(aiGatewayKeyBoundedDeleteTimeout, aiProviderBoundedDeleteTimeout), controlPlaneDependentGraceMax)
97+
return min(controlPlaneDependentGrace+time.Duration(perKind)*max(aiGatewayKeyBoundedDeleteTimeout, aiProviderBoundedDeleteTimeout, mcpServerBoundedDeleteTimeout), controlPlaneDependentGraceMax)
9798
}
9899

99100
// controlPlaneHoldRequeue caps a dependent's requeue under the hold, so it
@@ -126,8 +127,8 @@ func controlPlaneHolds(ctx context.Context, reader client.Reader, controlPlane *
126127
return namespace.DeletionTimestamp.IsZero() && namespace.Status.Phase != corev1.NamespaceTerminating, nil
127128
}
128129

129-
// controlPlaneDependents returns the CoderAIGatewayKey and CoderAIProvider
130-
// objects in controlPlane's namespace that reference it and hold it at now,
130+
// controlPlaneDependents returns the CoderAIGatewayKey, CoderAIProvider and
131+
// CoderMCPServer objects in controlPlane's namespace that reference it and hold it at now,
131132
// as kind/name, and the larger count of one kind. A dependent holds it while
132133
// it is being deleted and still has its cleanup finalizer (another finalizer
133134
// does not hold it), and a live one only for controlPlaneLiveDependentWait
@@ -163,7 +164,17 @@ func controlPlaneDependents(ctx context.Context, reader client.Reader, controlPl
163164
dependents = append(dependents, "CoderAIProvider/"+p.Name)
164165
}
165166
}
166-
return dependents, max(keyCount, len(dependents)-keyCount), nil
167+
providerCount := len(dependents) - keyCount
168+
servers := &coderv1alpha1.CoderMCPServerList{}
169+
if err := reader.List(ctx, servers, client.InNamespace(controlPlane.Namespace)); err != nil {
170+
return nil, 0, fmt.Errorf("list MCP servers of namespace %s: %w", controlPlane.Namespace, err)
171+
}
172+
for i := range servers.Items {
173+
if m := &servers.Items[i]; m.Spec.ControlPlaneRef.Name == controlPlane.Name && holds(m, coderv1alpha1.CoderMCPServerCleanupFinalizer) {
174+
dependents = append(dependents, "CoderMCPServer/"+m.Name)
175+
}
176+
}
177+
return dependents, max(keyCount, providerCount, len(dependents)-keyCount-providerCount), nil
167178
}
168179

169180
// dependentRemoved passes only the removal of a dependent object, the event
@@ -175,15 +186,17 @@ var dependentRemoved = predicate.Funcs{
175186
GenericFunc: func(event.GenericEvent) bool { return false },
176187
}
177188

178-
// requestForDependentControlPlane maps a CoderAIGatewayKey or CoderAIProvider
179-
// to the control plane that it references.
189+
// requestForDependentControlPlane maps a CoderAIGatewayKey, CoderAIProvider or
190+
// CoderMCPServer to the control plane that it references.
180191
func requestForDependentControlPlane(_ context.Context, obj client.Object) []reconcile.Request {
181192
var name string
182193
switch o := obj.(type) {
183194
case *coderv1alpha1.CoderAIGatewayKey:
184195
name = o.Spec.ControlPlaneRef.Name
185196
case *coderv1alpha1.CoderAIProvider:
186197
name = o.Spec.ControlPlaneRef.Name
198+
case *coderv1alpha1.CoderMCPServer:
199+
name = o.Spec.ControlPlaneRef.Name
187200
}
188201
if name == "" {
189202
return nil

‎internal/controller/controlplane_hold_internal_test.go‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
11
package controller
22

33
import (
4+
"context"
45
"testing"
56
"time"
67

78
"github.com/stretchr/testify/require"
9+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
10+
"k8s.io/apimachinery/pkg/types"
11+
"sigs.k8s.io/controller-runtime/pkg/client"
12+
"sigs.k8s.io/controller-runtime/pkg/reconcile"
13+
14+
coderv1alpha1 "github.com/coder/coder-k8s/api/v1alpha1"
815
)
916

1017
// #353: the grace period after the hold grows by one bounded pass per
@@ -16,3 +23,20 @@ func TestControlPlaneDependentGraceFor(t *testing.T) {
1623
require.Equal(t, time.Minute, controlPlaneDependentGraceFor(3))
1724
require.Equal(t, 5*time.Minute, controlPlaneDependentGraceFor(1000), "the grace period has a cap")
1825
}
26+
27+
// #353, #382: the removal of each dependent kind requeues the control plane
28+
// that it references, so a waiting control plane goes at once.
29+
func TestRequestForDependentControlPlane(t *testing.T) {
30+
t.Parallel()
31+
meta := metav1.ObjectMeta{Name: "dependent", Namespace: "coder"}
32+
ref := coderv1alpha1.CoderControlPlaneReference{Name: "coder"}
33+
want := []reconcile.Request{{NamespacedName: types.NamespacedName{Namespace: "coder", Name: "coder"}}}
34+
for _, obj := range []client.Object{
35+
&coderv1alpha1.CoderAIGatewayKey{ObjectMeta: meta, Spec: coderv1alpha1.CoderAIGatewayKeySpec{ControlPlaneRef: ref}},
36+
&coderv1alpha1.CoderAIProvider{ObjectMeta: meta, Spec: coderv1alpha1.CoderAIProviderSpec{ControlPlaneRef: ref}},
37+
&coderv1alpha1.CoderMCPServer{ObjectMeta: meta, Spec: coderv1alpha1.CoderMCPServerSpec{ControlPlaneRef: ref}},
38+
} {
39+
require.Equal(t, want, requestForDependentControlPlane(context.Background(), obj), "%T", obj)
40+
}
41+
require.Nil(t, requestForDependentControlPlane(context.Background(), &coderv1alpha1.CoderTemplateTest{ObjectMeta: meta}), "not a dependent kind")
42+
}

0 commit comments

Comments
 (0)