Skip to content

Commit aab11e7

Browse files
authored
🤖 feat: write a cleanup record before each CoderAIGatewayKey create (#353 part 2c) (#385)
Refs #353 (part 2c: the `CoderAIGatewayKey` half of option B′, records). Refs #156. ## Summary Before each AI Gateway key create, the operator now writes a cleanup record for the `CoderAIGatewayKey` object: a ConfigMap `ck8s-cleanup-<object UID>` in the operator namespace, the same kind of record that #374 added for `CoderAIProvider`. If the record cannot be written, no key is created. The delete path removes the record only after a LIST in the record's Coder deployment shows no key of the object. In every other release without a delete (for example a namespace deletion), the record stays. The sweep that uses it follows in the stacked PR (part 2d). ## Design note (option B′, maintainer decision #353 (comment)) How each invariant maps to keys: 1. **Record before create.** `issueKey` writes the record after the Secret claim and dry run, and before the create marker (`pendingKey`, `nextSerial`). A failed deployment ID read is a `CoderUnavailable` wait. A failed write is an error. Either way no POST is sent and no marker is written. 2. **No secrets.** Kind, UID, namespace, name, deployment ID, the key name prefix `ck8s-<UID without dashes>-` (in the `nonce` field), the time of the last create, and the current key ID. The test helper `reconcileErr` now also checks the record for every key plaintext, in every key test. 3. **Deployment ID from Coder** (`/api/v2/buildinfo`), read in the reconcile that sends the create. When a record already names another deployment, the create step stalls with `CoderDeploymentChanged` and creates nothing in the new deployment (same rule as providers). 4.–6. Sweep rules: next PR. 7. **The record goes only after Coder confirms.** It goes at the end of a normal delete (a LIST shows no key of the object). It also goes after the one bounded pass, but only when the last create was more than 5 minutes (the settle window) before it, so no create can still commit after that LIST. Before any delete pass, the operator checks that the control plane still reaches the recorded deployment. If it does not, the finalizer goes without a delete and the record stays. An object with `nextSerial == 0` sent no create, so its record (left by a failed marker write) goes with no Coder call. 8.–9. Same Role and namespace as #374. No RBAC change. Key-specific choices: - **One record per object, many keys.** A key object creates a new key at every rotation and drift re-issue. The prefix finds all of them, so the record stores only the current key ID, for admins. The ID is written right after a successful issue, with no extra Coder call. If that write fails, the next reconcile that sees the key listed writes it (`recordAIGatewayKeyID`). The same path also gives a record to an object from before records existed. - **The record's create time is the marker's instant.** One `attemptStart` stamps the record, `lastCreateAttemptTime` and the Secret write-age limit, so the sweep's settle window covers every create. Also in this PR: **#384 follow-up 4 for keys** (#353 (comment), item 4). A delete pass under the control plane hold now ends at the hold end (`context.WithDeadline`), and its retry counts from the end of the pass, as #384 did for `CoderMCPServer`. ## Validation - New tests: `TestCoderAIGatewayKeyCleanupRecord`, `TestCoderAIGatewayKeyNoCreateWithoutRecord`, `TestCoderAIGatewayKeyDeploymentChanged`, `TestCoderAIGatewayKeyRecordAfterBoundedPass`, `TestCoderAIGatewayKeyRecordWithoutCreate`, `TestCoderAIGatewayKeyRecordBackfill`, and `TestAIGatewayKeyDeleteUnderHoldEndsAtHoldEnd`. - Red then green: 11 mutations, each caught by a test: no record before the create, no deployment-change check, no deployment check on delete, the bounded pass always or never removing the record, a serial-0 delete keeping the record, a release keeping the record, no issued ID, no backfill, no hold deadline, and the requeue counted from the start of the pass. - Local gates: build-installer, codegen, docs-reference, drift, verify-vendor, build, lint, govulncheck, test, test-integration, race (`./internal/controller/...`), docs-check, actionlint. All passed. - Kind dogfood with a licensed Coder runs on the stacked PR (part 2d), because the sweep is needed for the namespace deletion scenario. ## Risks - The key create path sends one more Coder request (`GET /api/v2/buildinfo`) per create. A failure of that read blocks the create with a 60 s retry, as for providers. - Each reconcile with a listed current key does one live ConfigMap GET in the operator namespace. Writes happen only when the ID changed. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_ Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent b56243c commit aab11e7

10 files changed

Lines changed: 499 additions & 20 deletions

‎docs/how-to/manage-ai-gateway-keys.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,14 +137,22 @@ The finalizer stays while Coder cannot confirm the deletion:
137137
| `ControlPlaneNotReady`, `OperatorAccessNotReady` | Every 60 seconds |
138138
| `LicenseRequired`, `CoderRejected`, `NotSupported` (`Stalled=True`) | Every 10 minutes |
139139

140-
When the control plane is gone, nothing can delete the keys. The finalizer goes at once. The operator logs `Releasing the finalizer without deleting the keys in Coder` with reason `ControlPlaneGone` and the key IDs: delete those keys with `coder ai-gateway keys delete`.
140+
When the control plane is gone, nothing can delete the keys. The finalizer goes at once, and the [cleanup record](#cleanup-records) stays. The operator logs `Releasing the finalizer without deleting the keys in Coder` with reason `ControlPlaneGone` and the key IDs: delete those keys with `coder ai-gateway keys delete`.
141141

142142
When you delete the control plane while `CoderAIGatewayKey` or `CoderAIProvider` objects that use it are being deleted, the control plane delete can take up to about 15 minutes. The control plane waits for the objects for up to 10 minutes: it keeps its finalizer, so Coder keeps running, and the objects delete their keys and providers as usual, with retries. The operator log shows `Waiting for the objects that use this control plane` with their names. The operator removes the workspace RBAC of the control plane before it waits, so Coder loses its Kubernetes access at once. An object that you do not delete holds the control plane for only 30 seconds, so delete the objects together with the control plane (or within 30 seconds). After 10 minutes, the objects that remain get at most 5 more minutes (30 seconds plus 10 seconds per object of one kind) for their bounded pass below, and then the control plane goes anyway. A foreground deletion (`kubectl delete --cascade=foreground`) does not wait, because Kubernetes then deletes the Coder Deployment first.
143143

144144
When the control plane does not wait (its namespace is being deleted, it is deleted in the foreground, or the 10 minutes have passed) and its operator access still works, the operator runs one pass of at most 10 seconds without the settle window, then removes the finalizer whatever the answer. A key create that was still in flight at that moment can commit later, and its key stays in Coder. When the pass fails, the log lists the IDs that it did not delete.
145145

146146
Each release without a delete counts in the controller metric `coder_k8s_cleanup_released_without_delete_total` (label `kind`), next to the log line.
147147

148+
### Cleanup records
149+
150+
Before each key create, the operator writes a cleanup record: a ConfigMap named `ck8s-cleanup-<object UID>` with the label `coder.com/cleanup-record=CoderAIGatewayKey` in the operator's namespace (`coder-system`). If it cannot write the record, it does not create the key. The record holds no key: it holds the object's namespace, name and UID, Coder's deployment ID from `/api/v2/buildinfo`, the key name prefix `ck8s-<UID without dashes>-` (the `nonce` field), the time of the last create, and the current key ID once Coder answered it. The deployment ID lives in the Coder database, so it names the database that holds the keys.
151+
152+
When the control plane later reaches another Coder deployment (for example after its database Secret changed), the record still locates the keys in the old one. The operator then stalls the object with `CoderDeploymentChanged` at its next key create, keeps the record and creates no key in the new deployment. A delete of the object then removes the finalizer without a delete in Coder. Delete the keys in the old deployment, then delete the ConfigMap.
153+
154+
The operator removes the record only after a list in the record's deployment shows no key of the object: at the end of a normal delete, or after the one bounded pass when the last create was more than 5 minutes before it. In every other release without a delete, the record stays and names where the keys are. Find the records with `kubectl -n coder-system get configmaps -l coder.com/cleanup-record=CoderAIGatewayKey`. The operator gets write access to ConfigMaps only in that namespace. Do not give other users write access to ConfigMaps there.
155+
148156
!!! warning "Delete the keys before the namespace"
149157
When you delete the namespace, Kubernetes deletes the control plane and the `CoderAIGatewayKey` objects at the same time. The control plane is usually gone before the operator handles the objects, so the finalizers go without a delete, and the keys stay valid in Coder. The namespace deletion finishes, and the operator logs the key IDs with reason `ControlPlaneGone`.
150158

‎internal/app/controllerapp/controllerapp.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,8 @@ func SetupControllers(mgr manager.Manager) error {
153153
APIReader: mgr.GetAPIReader(),
154154
Scheme: managerScheme,
155155
Clock: clock.RealClock{},
156+
// The same namespace as the provider records, so one sweep finds both.
157+
OperatorNamespace: aiProviderReconciler.OperatorNamespace,
156158
}
157159
if err := aiGatewayKeyReconciler.SetupWithManager(mgr); err != nil {
158160
return fmt.Errorf("unable to create AI Gateway key controller: %w", err)

‎internal/controller/coderaigatewaykey_controller.go‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ type CoderAIGatewayKeyReconciler struct {
8383
APIReader client.Reader
8484
Scheme *runtime.Scheme
8585
Clock clock.PassiveClock
86+
// OperatorNamespace holds the cleanup records (#353).
87+
OperatorNamespace string
8688

8789
rateLimited coderBackoff // Keyed by types.NamespacedName.
8890
}
@@ -112,8 +114,8 @@ func aiGatewayKeyStalled(reason, format string, args ...any) *aiGatewayKeyStep {
112114
// the output Secret, or of any control plane in the namespace (any of them
113115
// can reserve the Secret name), requeues the object at once.
114116
func (r *CoderAIGatewayKeyReconciler) SetupWithManager(mgr ctrl.Manager) error {
115-
if mgr == nil || r.Client == nil || r.APIReader == nil || r.Scheme == nil || r.Clock == nil {
116-
return errors.New("assertion failed: AI Gateway key reconciler needs a manager, a client, an API reader, a scheme, and a clock")
117+
if mgr == nil || r.Client == nil || r.APIReader == nil || r.Scheme == nil || r.Clock == nil || r.OperatorNamespace == "" {
118+
return errors.New("assertion failed: AI Gateway key reconciler needs a manager, a client, an API reader, a scheme, a clock, and the operator namespace")
117119
}
118120
return ctrl.NewControllerManagedBy(mgr).
119121
For(&coderv1alpha1.CoderAIGatewayKey{}).
@@ -139,8 +141,8 @@ func (r *CoderAIGatewayKeyReconciler) keysInNamespace(ctx context.Context, obj c
139141
// Reconcile handles deletion first, stores the finalizer before any Coder
140142
// call, then runs the observe step and at most one action.
141143
func (r *CoderAIGatewayKeyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
142-
if r.Client == nil || r.APIReader == nil || r.Scheme == nil || r.Clock == nil {
143-
return ctrl.Result{}, errors.New("assertion failed: AI Gateway key reconciler needs a client, an API reader, a scheme, and a clock")
144+
if r.Client == nil || r.APIReader == nil || r.Scheme == nil || r.Clock == nil || r.OperatorNamespace == "" {
145+
return ctrl.Result{}, errors.New("assertion failed: AI Gateway key reconciler needs a client, an API reader, a scheme, a clock, and the operator namespace")
144146
}
145147
// Only a wait after HTTP 429 keeps the backoff count.
146148
keepBackoff := false
@@ -273,6 +275,7 @@ func (r *CoderAIGatewayKeyReconciler) observe(
273275
// Coder lists the current key, so no kill switch holds. A stale
274276
// generation would re-arm a later kill switch at once.
275277
key.Status.KillSwitchGeneration = 0
278+
r.recordAIGatewayKeyID(ctx, sdk, key, prefix)
276279
}
277280
if stall != nil {
278281
// The Secret is not this object's, but its live value still decides

‎internal/controller/coderaigatewaykey_controller_test.go‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const testAIGatewaySecret = "coder-ai-gateway-key"
3535
// plane named coder that points at it.
3636
type aiGatewayKeyEnv struct {
3737
namespace string
38+
records string // The operator namespace, which holds the cleanup records.
3839
fake *fakeCoder
3940
client client.Client // The reconciler's client. Tests can wrap it.
4041
clock *clocktesting.FakeClock
@@ -44,7 +45,7 @@ func newAIGatewayKeyEnv(ctx context.Context, t *testing.T, allowKeys *bool) aiGa
4445
t.Helper()
4546
// Status times have second precision, so the fake clock starts on a second.
4647
env := aiGatewayKeyEnv{
47-
namespace: createTestNamespace(ctx, t, "aigk"), fake: newFakeCoder(t), client: k8sClient,
48+
namespace: createTestNamespace(ctx, t, "aigk"), records: createTestNamespace(ctx, t, "aigk-ops"), fake: newFakeCoder(t), client: k8sClient,
4849
clock: clocktesting.NewFakeClock(time.Now().Truncate(time.Second)),
4950
}
5051
cp := createTestControlPlane(ctx, t, env.namespace, "coder", env.fake.server.URL)
@@ -93,7 +94,7 @@ func (env aiGatewayKeyEnv) reconcile(ctx context.Context, t *testing.T, key *cod
9394
// metadata, the logs or the error (plan S3).
9495
func (env aiGatewayKeyEnv) reconcileErr(ctx context.Context, t *testing.T, key *coderv1alpha1.CoderAIGatewayKey) (*coderv1alpha1.CoderAIGatewayKey, ctrl.Result, error) {
9596
t.Helper()
96-
r := &controller.CoderAIGatewayKeyReconciler{Client: env.client, APIReader: env.client, Scheme: scheme, Clock: env.clock}
97+
r := &controller.CoderAIGatewayKeyReconciler{Client: env.client, APIReader: env.client, Scheme: scheme, Clock: env.clock, OperatorNamespace: env.records}
9798
logCtx, logs := capturingContext(ctx)
9899
result, reconcileErr := r.Reconcile(logCtx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(key)})
99100
got := &coderv1alpha1.CoderAIGatewayKey{}
@@ -104,6 +105,10 @@ func (env aiGatewayKeyEnv) reconcileErr(ctx context.Context, t *testing.T, key *
104105
require.NoError(t, err)
105106
}
106107
haystacks := map[string]any{"logs": logs.String(), "error": fmt.Sprint(reconcileErr)}
108+
record := &corev1.ConfigMap{}
109+
if err := k8sClient.Get(ctx, client.ObjectKey{Namespace: env.records, Name: "ck8s-cleanup-" + string(key.UID)}, record); err == nil {
110+
haystacks["cleanup record"] = record.Data // #353 invariant 2.
111+
}
107112
if got != nil {
108113
haystacks["status"] = got.Status
109114
secret := &corev1.Secret{}
@@ -361,7 +366,9 @@ func TestCoderAIGatewayKeyIssuesFirstKey(t *testing.T) {
361366
require.Zero(t, writes)
362367
require.Equal(t, got.ResourceVersion, again.ResourceVersion)
363368
require.Equal(t, 2, env.fake.requestCount(routeAIGatewayKeys))
364-
require.Equal(t, 3, env.fake.totalRequests())
369+
// The deployment ID read before the create is for the cleanup record (#353).
370+
require.Equal(t, 1, env.fake.requestCount(routeBuildInfo))
371+
require.Equal(t, 4, env.fake.totalRequests())
365372
}
366373

367374
func requireAIGatewayKeyReady(t *testing.T, key *coderv1alpha1.CoderAIGatewayKey) {
@@ -552,7 +559,7 @@ func TestCoderAIGatewayKeyWatches(t *testing.T) {
552559
})
553560
require.NoError(t, err)
554561
require.NoError(t, (&controller.CoderAIGatewayKeyReconciler{
555-
Client: mgr.GetClient(), APIReader: mgr.GetAPIReader(), Scheme: mgr.GetScheme(), Clock: env.clock,
562+
Client: mgr.GetClient(), APIReader: mgr.GetAPIReader(), Scheme: mgr.GetScheme(), Clock: env.clock, OperatorNamespace: env.records,
556563
}).SetupWithManager(mgr))
557564
mgrCtx, cancel := context.WithCancel(ctx)
558565
mgrDone := make(chan error, 1)

‎internal/controller/coderaigatewaykey_delete.go‎

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@ const (
3232
// release when the finalizer can go. Otherwise the step says why it stays.
3333
func (r *CoderAIGatewayKeyReconciler) deleteStep(ctx context.Context, key *coderv1alpha1.CoderAIGatewayKey) (release bool, step *aiGatewayKeyStep, err error) {
3434
if key.Status.NextSerial == 0 {
35-
return true, nil, nil // No create was ever sent, so no key of this object can exist.
35+
// No create was ever sent, so no key of this object can exist. A
36+
// record can, when the marker write after it failed.
37+
return true, nil, deleteCleanupRecord(ctx, r.Client, r.APIReader, r.OperatorNamespace, key.UID)
3638
}
3739
ref := types.NamespacedName{Namespace: key.Namespace, Name: key.Spec.ControlPlaneRef.Name}
3840
controlPlane := &coderv1alpha1.CoderControlPlane{}
@@ -53,9 +55,17 @@ func (r *CoderAIGatewayKeyReconciler) deleteStep(ctx context.Context, key *coder
5355
}
5456
deleting = !holds
5557
if holds {
58+
// A pass under the hold ends at the hold end, however slowly
59+
// Coder answers, so the bounded pass after it still fits in the
60+
// control plane's grace period (#384 follow-up 4).
61+
var cancel context.CancelFunc
62+
ctx, cancel = context.WithDeadline(ctx, controlPlaneHoldEnd(controlPlane))
63+
defer cancel()
5664
defer func() {
5765
if step != nil {
58-
step.requeue = controlPlaneHoldRequeue(controlPlane, now, step.requeue)
66+
// From the end of the pass: a pass that ran until the
67+
// deadline retries right after the hold end.
68+
step.requeue = controlPlaneHoldRequeue(controlPlane, r.Clock.Now(), step.requeue)
5969
}
6070
}()
6171
}
@@ -76,12 +86,25 @@ func (r *CoderAIGatewayKeyReconciler) deleteStep(ctx context.Context, key *coder
7686
var cancel context.CancelFunc
7787
ctx, cancel = context.WithTimeout(ctx, aiGatewayKeyBoundedDeleteTimeout)
7888
defer cancel()
89+
if other, wait, err := r.aiGatewayKeyRecordDeployment(ctx, sdk, key); err != nil {
90+
return false, nil, err
91+
} else if wait != nil || other != "" {
92+
return r.releaseWithoutDelete(ctx, key, "the deployment check while CoderControlPlane is being deleted failed: "+other+aiGatewayKeyStepMessage(wait)), nil, nil
93+
}
7994
if _, left, failure, err := r.deleteKeys(ctx, sdk, key); err != nil {
8095
return false, nil, err
8196
} else if failure != nil {
8297
return r.releaseWithoutDelete(ctx, key, "the one pass while CoderControlPlane is being deleted failed: "+failure.message, left...), nil, nil
8398
}
84-
return true, nil, nil
99+
if last := key.Status.LastCreateAttemptTime; last == nil || r.Clock.Now().Before(last.Add(aiGatewayKeySettleWindow)) {
100+
// A create of the last attempt can still commit after this LIST,
101+
// so only a sweep after the settle window can prove that no key
102+
// is left. The record stays for it.
103+
log.FromContext(ctx).Info("Releasing the finalizer after one pass inside the settle window: the cleanup record stays for a sweep",
104+
"record", r.OperatorNamespace+"/"+cleanupRecordObjectName(key.UID))
105+
return true, nil, nil
106+
}
107+
return true, nil, deleteCleanupRecord(ctx, r.Client, r.APIReader, r.OperatorNamespace, key.UID)
85108
}
86109

87110
now := r.Clock.Now()
@@ -96,14 +119,22 @@ func (r *CoderAIGatewayKeyReconciler) deleteStep(ctx context.Context, key *coder
96119
"The object is being deleted. The operator deletes its keys in Coder at %s, when no create of the last attempt can still commit.",
97120
settleAt.UTC().Format(time.RFC3339)), nil
98121
}
122+
// The record names the deployment that holds the keys. When the control
123+
// plane now reaches another deployment, an empty LIST there proves
124+
// nothing, so the record stays for a sweep (#353 invariant 7).
125+
if other, wait, err := r.aiGatewayKeyRecordDeployment(ctx, sdk, key); err != nil || wait != nil {
126+
return false, wait, err
127+
} else if other != "" {
128+
return r.releaseWithoutDelete(ctx, key, other), nil, nil
129+
}
99130
// The pass repeats until a LIST shows no key of this object, so the
100-
// release follows a LIST that proves it.
131+
// release follows a LIST that proves it. Only then does the record go.
101132
for range 2 {
102133
deleted, _, failure, err := r.deleteKeys(ctx, sdk, key)
103134
if err != nil || failure != nil {
104135
return false, failure, err
105136
} else if deleted == 0 {
106-
return true, nil, nil
137+
return true, nil, deleteCleanupRecord(ctx, r.Client, r.APIReader, r.OperatorNamespace, key.UID)
107138
}
108139
}
109140
return false, aiGatewayKeyWait(aiGatewayKeyDeleteRetry, reasonDeletingKeys,
@@ -153,6 +184,14 @@ func aiGatewayKeyDeleteFailure(action string, err error) *aiGatewayKeyStep {
153184
return step
154185
}
155186

187+
// aiGatewayKeyStepMessage returns the message of step, or "".
188+
func aiGatewayKeyStepMessage(step *aiGatewayKeyStep) string {
189+
if step == nil {
190+
return ""
191+
}
192+
return step.message
193+
}
194+
156195
// releaseWithoutDelete logs the key IDs that can stay in Coder, because
157196
// nothing can delete them any more, and returns true (ControlPlaneGone). left
158197
// holds the IDs that a failed pass listed but did not delete.
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package controller
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"net/http/httptest"
7+
"testing"
8+
"time"
9+
10+
"github.com/stretchr/testify/require"
11+
corev1 "k8s.io/api/core/v1"
12+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
13+
"k8s.io/apimachinery/pkg/runtime"
14+
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
15+
"k8s.io/utils/clock"
16+
"sigs.k8s.io/controller-runtime/pkg/client/fake"
17+
18+
coderv1alpha1 "github.com/coder/coder-k8s/api/v1alpha1"
19+
)
20+
21+
// #384 follow-up 4 for keys: a delete pass under the control plane hold ends
22+
// at the hold end, also when Coder does not answer, and its retry counts from
23+
// the end of the pass. Here the hold ends in 5 s and Coder hangs on the LIST,
24+
// whose own request timeout is 30 s.
25+
func TestAIGatewayKeyDeleteUnderHoldEndsAtHoldEnd(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+
c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(controlPlane, token, namespace).Build()
48+
r := &CoderAIGatewayKeyReconciler{Client: c, APIReader: c, Scheme: scheme, Clock: clock.RealClock{}, OperatorNamespace: "coder-system"}
49+
held, err := controlPlaneHolds(context.Background(), r.Client, controlPlane, time.Now())
50+
require.NoError(t, err)
51+
require.True(t, held, "assertion failed: the test needs a control plane that holds")
52+
53+
key := &coderv1alpha1.CoderAIGatewayKey{ObjectMeta: metav1.ObjectMeta{Name: "gateway", Namespace: "ns", UID: testAIGatewayUID}}
54+
key.Spec.ControlPlaneRef.Name = "coder"
55+
key.Status.NextSerial = 1
56+
key.Status.LastCreateAttemptTime = &metav1.Time{Time: time.Now().Add(-time.Hour)}
57+
start := time.Now()
58+
release, step, err := r.deleteStep(context.Background(), key)
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+
// The requeue counts from the end of the pass: the time left to the hold
64+
// end, which is about 0 here, plus 1 s (controlPlaneHoldRequeue).
65+
require.LessOrEqual(t, step.requeue, 2*time.Second, "the next pass runs right after the hold end")
66+
}

‎internal/controller/coderaigatewaykey_delete_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ func TestCoderAIGatewayKeyDeleteWithControlPlaneGoing(t *testing.T) {
178178
require.NoError(t, err)
179179
env.fake.failNext(routeDeleteAIGatewayKey, fakeFault{Status: http.StatusBadGateway})
180180
}
181-
r := &controller.CoderAIGatewayKeyReconciler{Client: env.client, APIReader: env.client, Scheme: scheme, Clock: env.clock}
181+
r := &controller.CoderAIGatewayKeyReconciler{Client: env.client, APIReader: env.client, Scheme: scheme, Clock: env.clock, OperatorNamespace: env.records}
182182
logCtx, logs := capturingContext(ctx)
183183
_, err := r.Reconcile(logCtx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(got)})
184184
require.NoError(t, err)

0 commit comments

Comments
 (0)