Namespace deletion leaves AI Gateway keys and AI providers in Coder #353
Description
Problem
When a namespace is deleted, Kubernetes deletes the CoderControlPlane and its CoderAIGatewayKey objects at the same time. The operator then cannot reach Coder: the control plane is gone, or its Coder pod is already down when the bounded pass runs. So it releases the finalizers without deleting the keys, and full-trust gateway keys stay valid in Coder. CoderAIProvider has the same gap for providers.
Seen in the dogfood run of #346 (see the G3 evidence comment, step 13):
- The namespace was gone after 11 s.
- The key row stayed in Coder's
ai_gateway_keystable. - The operator logged
Releasing the finalizer without deleting the keys in Coderwith reasonControlPlaneGoneand the key ID.
Today the how-to (docs/how-to/manage-ai-gateway-keys.md) warns about this and gives the cleanup: delete the key objects before the namespace, or delete the logged keys with coder ai-gateway keys delete.
Options
- A cluster-wide sweeper (plan risk RK7) that deletes
ck8s-<uid>-keys whose UID noCoderAIGatewayKeyhas, after the settle window. The same idea works for providers. - Hold the control plane's deletion (a finalizer or a deletion check) while dependent
CoderAIGatewayKeyandCoderAIProviderobjects exist, so they are cleaned up first.
Related items to decide together
- While the control plane is being deleted, a failed operator-access read releases at once without a retry (CoderAIGatewayKey CRD with automatic rotation #156 plan, K9 notes item q). This is a second path that leaves keys.
- The
CoderAnswerMismatchgate is lost when the status write right after the mismatch fails (K9 notes item u). The settle window bounds this to one extra create, whose key the sweep deletes. - A metric or a Kubernetes Event for
ControlPlaneGonereleases. Today the log line is the only signal.
Refs #156
Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: high
Activity
ThomasK33 commentedon Oct 14, 2026
Maintainer decision: this must land before the next release tag (v0.1.5). It does not block merging the
CoderAIGatewayKeyactivation (#346) tomain, because no released version runs that controller yet, and the how-to documents the cleanup.CoderAIProvideralready shipped in v0.1.4 with the same gap. The v0.1.4 notes say to deleteCoderAIProviderobjects before their control plane, and that a provider deleted after its control plane stays in Coder. So the provider half is a fix for released behavior, and should go first if the work is split.Prefer option 2 (hold the control plane's deletion while dependent objects exist) or a combination, and include items q and u and the metric or Event in the decision.
Generated with
xum• Model:anthropic:claude-opus-5-5• Thinking:mediumThomasK33 commentedon Oct 14, 2026
Follow-ups from the Codex review of #362 (part 1, the control-plane hold). I deferred both at first: each one only delays a control-plane deletion by at most the 10-minute hold, and neither leaks more than the code before #362 did. Owner: me, in the next #353 part.
OperatorAccessReadyis false at deletion time, the deletion path never runsreconcileOperatorAccessagain. Dependents wait withOperatorAccessNotReadyfor 10 minutes and then release without a delete. Before 🤖 fix: hold a deleted CoderControlPlane while AI Gateway keys and providers still use it #362 they released at once with the same leak. Fix: skip the hold when operator access is not ready, or keep repairing it during the hold.A terminating dependent with another finalizer keeps the hold(🤖 fix: hold a deleted CoderControlPlane while AI Gateway keys and providers still use it #362 (comment)). After its cleanup finalizer goes, an object that has a second finalizer still counts as a dependent until the 10 minutes pass. Fix: a terminating dependent holds only while it still has its AI cleanup finalizer.foregroundDeletionfinalizer can be gone before the first reconcile, so the hold starts although Coder is gone. Dependents then retry for up to the hold plus the grace. Fix: also check that the owned Deployment exists before holding.coderaiprovider_delete.gousestime.Now()for the hold check, while the key path usesr.Clock.Now(). Fix: use the reconciler clock, so tests can drive the provider hold end./buildinfocheck and the DELETE can reach pods on different databases, so a 404 from the new one can remove the record while the provider stays in the old one. Fix: recreate the Deployment on a database change, or check the deployment again after the delete. The same window applies to the sweep in 🤖 feat: sweep the cleanup records of CoderAIProvider objects that went without a delete (#353 part 2b) #375 (cleanup_sweep.go): its buildinfo match and its DELETE are also two requests.ensureAIProviderRecordskips the deployment check because both IDs are empty,recordAIProviderclears the marker (NameConflict), and a later delete removes the record. The provider in A can leak. Fix: whenCreateAttemptTimeis set, check the record's deployment ID against buildinfo before the ID shortcut, and test this transition.status.lastCreateAttemptTimeby hand, the observe step stamps it with the current time (fail closed), butrecordAIGatewayKeyIDrewrites the record only when the key ID changed. So the sweep can count its settle window from the older time. Needs a manual status edit. Fix: also rewrite the record when its create time is older thanlastCreateAttemptTime.Generated with
xum• Model:anthropic:claude-opus-5-5• Thinking:highThomasK33 commentedon Oct 14, 2026
Maintainer decision for part 2 (cleanup records): option B′, from an independent design review.
One ConfigMap per record, named
ck8s-cleanup-<object UID>, in the operator's own namespace (POD_NAMESPACE), matched by Coder's deployment ID (/api/v2/buildinfodeployment_id, which lives in the Coder database), never by URL or by user-chosen name.Why not the alternatives: a finalizer or hold cannot keep coderd running during a namespace delete; key expiry does not exist in Coder v2.37.3; a sweeper without records would guess from absence and could delete another cluster's keys; a cluster-scoped CRD (option A) gives little extra safety for a new public API type.
Invariants the implementation must enforce:
ck8s-<uid>-prefix, or a provider whose display name is the record's nonce. Never by user-chosen name alone.Keep the docs warning: it is the only fix when no control plane ever reconnects to that database.
Generated with
xum• Model:anthropic:claude-opus-5-5• Thinking:mediumfeat: sweep the cleanup records of CoderAIProvider objects that went …
ThomasK33 commentedon Oct 14, 2026
Follow-ups from #384 (control plane hold now covers
CoderMCPServer, fixes #382). None of them blocks v0.1.5.docs/how-to/manage-ai-providers.mdnames only providers and keys in its hold sentence. Add MCP servers.coder_k8s_cleanup_released_without_delete_totaldoes not count aCoderMCPServerthat releases its finalizer without a delete in Coder.CoderAIProvider.coderaiprovider_delete.gonear lines 84-88,coderaigatewaykey_delete.gonear lines 56-60) still count the retry from the start of the pass and have no hold-end deadline. A slow pass there can run into the grace period.controlPlaneDependentshas no unit test for the per-kind count whenCoderMCPServeris the largest kind.Generated with
xum• Model:anthropic:claude-opus-5-5• Thinking:mediumtest: a control plane holds without its Coder Deployment, also when i…
fix: keep the control plane hold when its Coder Deployment is missing (…
test: a control plane that lost operator access before its deletion d…
fix: keep the control plane hold without operator access (#353)
docs: drop the no-operator-access hold claim from the keys how-to (#353)
fix: report CoderDeploymentChanged when an MCP config looks deleted a…
test: an MCP record without a config ID deletes nothing and stays whi…
fix: an MCP record without a config ID deletes nothing and stays for …
test: cover MCP records on delete after a slug conflict, for legacy c…
fix: keep MCP records after a slug conflict, backfill them on delete,…