Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
2807342
feat: write a cleanup record before each CoderAIProvider create (#353)
ThomasK33 Oct 14, 2026
76d30ae
feat: sweep the cleanup records of CoderAIProvider objects that went …
ThomasK33 Oct 14, 2026
553cad7
feat: write a cleanup record before each CoderAIProvider create (#353)
ThomasK33 Oct 14, 2026
0c7697d
feat: sweep the cleanup records of CoderAIProvider objects that went …
ThomasK33 Oct 14, 2026
07a0126
fix: read cleanup records live, backfill them on the disable path, de…
ThomasK33 Oct 14, 2026
2ceb51f
Merge commit '07a012629f70d651414b9210a9b63b3fd238397b' into HEAD
ThomasK33 Oct 14, 2026
7337267
fix: pass the live reader to the record delete of the sweep
ThomasK33 Oct 14, 2026
d80f29e
Merge the first pushed head of this branch (tree unchanged)
ThomasK33 Oct 14, 2026
6e1d1bf
fix: backfill records before every check, keep the record when the co…
ThomasK33 Oct 14, 2026
4e52d75
Merge branch 'feat/ai-provider-cleanup-records' into feat/ai-provider…
ThomasK33 Oct 14, 2026
acf8385
fix: read the operator access live in the sweep, and treat a ready co…
ThomasK33 Oct 14, 2026
0884db3
fix: sweep a record only through a control plane in the object's name…
ThomasK33 Oct 14, 2026
887b0f1
fix: keep the record when the deployment changes under a live object,…
ThomasK33 Oct 14, 2026
9366d36
Merge branch 'feat/ai-provider-cleanup-records' into feat/ai-provider…
ThomasK33 Oct 14, 2026
5e9e849
fix: read the swept record live, try every matching control plane, an…
ThomasK33 Oct 14, 2026
a7cd76a
fix: keep a record with a provider ID when the object is deleted with…
ThomasK33 Oct 14, 2026
c4281d5
Merge branch 'feat/ai-provider-cleanup-records' into feat/ai-provider…
ThomasK33 Oct 14, 2026
76eb17f
Merge remote-tracking branch 'origin/main' into feat/ai-provider-clea…
ThomasK33 Oct 14, 2026
283f46a
Merge branch 'feat/ai-provider-cleanup-records' into feat/ai-provider…
ThomasK33 Oct 14, 2026
0ba5d46
Merge remote-tracking branch 'origin/main' into feat/ai-provider-clea…
ThomasK33 Oct 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions config/rbac/role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,13 @@ rules:
- patch
- update
- watch
- apiGroups:
- events.k8s.io
resources:
- events
verbs:
- create
- patch
- apiGroups:
- gateway.networking.k8s.io
resources:
Expand Down
7 changes: 7 additions & 0 deletions dist/install.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7139,6 +7139,13 @@ rules:
- patch
- update
- watch
- apiGroups:
- events.k8s.io
resources:
- events
verbs:
- create
- patch
- apiGroups:
- gateway.networking.k8s.io
resources:
Expand Down
15 changes: 12 additions & 3 deletions docs/how-to/manage-ai-providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,19 +121,28 @@ These limits remain:
Deleting the object deletes the provider in Coder. With only an unconfirmed create, the operator reads Coder first and deletes only a provider that this object created. A create request can still commit for a short time, so within 2 minutes of that request the operator reads again before it trusts that the provider is absent.

- When Coder is unreachable, or the control plane has no operator access, the finalizer `coder.com/ai-provider-cleanup` stays and the operator retries every 60 seconds. See [Stuck finalizer](#stuck-finalizer).
- When the control plane is gone, nothing can delete the provider. The finalizer goes at once, and the operator logs the provider ID. Delete the provider in the Coder dashboard.
- When the control plane is gone, the operator cannot delete the provider now. The finalizer goes at once, and the operator logs the provider ID. The [cleanup record](#cleanup-records) stays, so a later sweep can delete the provider.
- When you delete the control plane while `CoderAIProvider` or `CoderAIGatewayKey` 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, then for at most 5 more minutes (30 seconds plus 10 seconds per object of one kind) for a last bounded attempt. It keeps its finalizer, so Coder keeps running, and the delete above runs with its retries. 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. A foreground deletion (`kubectl delete --cascade=foreground`) does not wait.
- 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 sends one delete with a 10-second limit, then removes the finalizer. Without working access, the finalizer goes without a delete. Either way a namespace deletion finishes. When the delete did not succeed, the operator logs the provider ID: delete the provider in the Coder dashboard. The metric `coder_k8s_cleanup_released_without_delete_total` (label `kind`) counts these releases.

Delete `CoderAIProvider` objects before their namespace: a namespace deletion removes Coder's pods at once, so the control plane cannot wait for them. Coder deletes a provider even while chat models use it. Those models stop working.
Delete `CoderAIProvider` objects before their namespace: a namespace deletion removes Coder's pods at once, so the control plane cannot wait for them. A sweep deletes the leftover provider only when the namespace comes back with a control plane of the same Coder deployment. Coder deletes a provider even while chat models use it. Those models stop working.

### Cleanup records

Before the operator creates a provider in Coder, it writes a cleanup record: a ConfigMap named `ck8s-cleanup-<object UID>` with the label `coder.com/cleanup-record=CoderAIProvider` in the operator's namespace (`coder-system`). If it cannot write the record, it does not create the provider. The record holds no key: it holds the object's namespace, name and UID, Coder's deployment ID from `/api/v2/buildinfo`, the provider's display-name nonce, and the provider ID once the operator knows it. The deployment ID lives in the Coder database, so it names the database that holds the provider.

When the control plane later reaches another Coder deployment (for example after its database Secret changed), the record still locates the provider in the old one. The operator then stalls the object with `CoderDeploymentChanged`, keeps the record and creates nothing in the new deployment. Delete the provider in the old deployment, then delete the ConfigMap.

The operator removes the record when Coder confirms that the provider is deleted. When the finalizer goes without a delete, for example in a namespace deletion, the record stays. Find these records with `kubectl -n coder-system get configmaps -l coder.com/cleanup-record`. The operator gets write access to ConfigMaps only in that namespace (the Role `manager-role` and the RoleBinding `coder-k8s-cleanup-records`). Do not give other users write access to ConfigMaps there: a record names a Coder provider ID that a cleanup deletes. If you install the operator in another namespace, change the namespace of both.
The operator removes the record when Coder confirms that the provider is deleted. When the finalizer goes without a delete, for example in a namespace deletion, the record stays, and the operator sweeps it:

1. It waits until the object with that UID is gone and 5 minutes have passed since the last create, so that a create still in flight commits first.
2. It looks for a ready `CoderControlPlane` in the object's namespace whose Coder answers the record's deployment ID now. It never matches by URL or name: a new Coder database behind the same URL has another deployment ID and is never swept. A control plane in another namespace never sweeps the record, because Coder reports its deployment ID itself and a control plane that someone else runs could claim any ID.
3. Through that control plane it deletes the recorded provider ID. Without a recorded ID, it deletes only the provider of that name whose display name is still the record's nonce.
4. It removes the record when Coder answers the delete with success or with its own 404. Any other answer keeps the record, and the operator retries every minute.

So after a namespace deletion with an external database, the leftover provider goes once you create the namespace again with a control plane that uses the same database. The metric `coder_k8s_cleanup_records_swept_total` (label `kind`) counts the swept records.

When no ready control plane reaches the record's deployment, the record stays. The gauge `coder_k8s_cleanup_records_unmatched` counts these records, and the operator writes a `NoMatchingDeployment` Warning Event on the ConfigMap every 10 minutes. When that Coder deployment never comes back, delete the provider in the Coder dashboard (the Event names the object, and the record holds the provider ID), then delete the ConfigMap. Find the records with `kubectl -n coder-system get configmaps -l coder.com/cleanup-record`. The operator gets write access to ConfigMaps only in that namespace (the Role `manager-role` and the RoleBinding `coder-k8s-cleanup-records`). Do not give other users write access to ConfigMaps there: a record names a Coder provider ID that a cleanup deletes. If you install the operator in another namespace, change the namespace of both.

### Stuck finalizer

Expand Down
11 changes: 11 additions & 0 deletions internal/app/controllerapp/controllerapp.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,17 @@ func SetupControllers(mgr manager.Manager) error {
return fmt.Errorf("unable to create AI provider controller: %w", err)
}

cleanupRecordReconciler := &controller.CleanupRecordReconciler{
Client: client,
APIReader: mgr.GetAPIReader(),
OperatorNamespace: aiProviderReconciler.OperatorNamespace,
Clock: clock.RealClock{},
Recorder: mgr.GetEventRecorder("coder-k8s-cleanup"),
}
if err := cleanupRecordReconciler.SetupWithManager(mgr); err != nil {
return fmt.Errorf("unable to create cleanup record controller: %w", err)
}

aiGatewayKeyReconciler := &controller.CoderAIGatewayKeyReconciler{
Client: client,
APIReader: mgr.GetAPIReader(),
Expand Down
Loading
Loading