Repository navigation
🤖 fix: a CoderProvisioner replaces only keys it recorded and binds key records to the control plane UID - #420
Conversation
…the control plane UID Add envtests for #414 and #417. They fail on main: - a provisioner P deletes and replaces the same-named key of provisioner Q at one endpoint and organization (first key, key name change, status without key records); - after a control plane is deleted and created again under the same name with another URL, the operator sends the new control plane's token to the old URL (rotation and deletion, with and without a recorded UID). Change-Id: Ic970e4e8143dff85ce799f4776c4dd8c3a1f21dc Signed-off-by: Thomas Kosiewski <tk@coder.com>
…y records to the control plane UID operator looks up the target key name in Coder. A key with that name whose ID status does not record (status.provisionerKeyID or a status.pendingKeys entry) can belong to another provisioner or a person: the operator neither deletes nor replaces it, changes nothing else, sets ProvisionerKeyReady=False with reason KeyNameInUse, and checks again every 2 minutes. A recorded key is deleted by its ID, then created again. The by-name delete (coderbootstrap DeleteProvisionerKey) is removed. (status.controlPlaneUID) record the UID of the control plane. The operator sends a control plane's operator token to its current Coder API URL, and to another recorded URL only while the control plane has the recorded UID. Otherwise the entry stays and the condition ControlPlaneChangedKeyKept names it. A record without a UID counts as bound only at the current URL of its control plane. Change-Id: I34b7edd839aaa8d2c1e3733983a806694add93d6 Signed-off-by: Thomas Kosiewski <tk@coder.com>
A revert to the applied key name needs no rotation, so the reconcile never cleared KeyNameInUse. Set ProvisionerKeyReady=True whenever the reconcile passes the key step. Align the controlPlaneUID field docs with the rule in tokenBoundTo: a control plane's current Coder API URL always gets its token, another recorded URL only while the recorded UID matches. Change-Id: I6e8eb9d01717d6feca66368d812f40f7d1028abd Signed-off-by: Thomas Kosiewski <tk@coder.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
Security findingsAdvisory findings (1)ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5fcce53357
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Codex review of #420: - A control plane created again under the same name keeps its in-cluster Coder API URL with TLS, and can serve another Coder backend there. A record with a UID now gets its control plane's token only while the UID matches, also at the current URL: an answer from another backend does not show that the old key is gone. Records without a UID keep the current-URL rule. - A changed control plane UID is now drift, so the provisioner creates a new key instead of keeping one that the new backend can reject. - Records with different control plane UIDs name different key slots, so the replaced key of the old control plane stays recorded and reported. A record of the key that the target replacement deletes is dropped with the target records. Change-Id: I87c3908df4a3f1e32896367da5018d32c413fc5f Signed-off-by: Thomas Kosiewski <tk@coder.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cd33036074
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Codex review of #420: after an upgrade, status.controlPlaneUID is empty, so a later recreate of the control plane under the same name and URL went undetected. A reconcile of such status now looks the applied key up at the current control plane. When Coder holds it there, the operator records that control plane's UID, so a later recreate is drift. When it does not, the provisioner creates a new key. Change-Id: Iaa1eb2ee78c71263b6775524e3bbef63dbff9af9 Signed-off-by: Thomas Kosiewski <tk@coder.com>
…lane UID Change-Id: I9f73b49b2eec2f1cf18b29857c9f2c3a9cd63e9b Signed-off-by: Thomas Kosiewski <tk@coder.com>
|
Codex Review: Didn't find any major issues. Already looking forward to the next diff. Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
|
@codex security review |
There was a problem hiding this comment.
🛡️ Codex Security Review
Here are some automated security review suggestions for this pull request.
Reviewed commit: 9b59a91791
ℹ️ About Codex security reviews in GitHub
This is an experimental Codex feature. Security reviews are triggered when:
- You comment "@codex security review"
- A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review
Once complete, Codex will leave suggestions, or a comment if no findings are found.
… use Codex security review of #420: the KeyNameInUse refusal returned before the pending cleanup, so whoever holds the target name kept every key recorded at an earlier target valid. The refusal now runs the same cleanup for the published records outside the target slot, each with the token of its own control plane. Unpublished records stay, because the Secret can hold their key, and the target is still not changed. Change-Id: I1d99dff9a50609f222012531ca33ae7d18e6f6ca Signed-off-by: Thomas Kosiewski <tk@coder.com>
|
@codex security review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0149387c47
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
🛡️ Codex Security ReviewSecurity review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
Change-Id: If41eca0e064a3642c84063f6ba99efdf0686156a Signed-off-by: Thomas Kosiewski <tk@coder.com>
…rename of status without a control plane UID Change-Id: I58615bff7826db6ca2c6b122bb1b4d3e2c7d8052 Signed-off-by: Thomas Kosiewski <tk@coder.com>
…by its recorded IDs An organization can be renamed and its name reused. The lookup by organization name then missed the applied key, the provisioner replaced it without a record, and deletion left the key valid in Coder. The lookup now lists the recorded organization ID and matches the recorded key ID. The key is replaced only when Coder answers that it has no such key there. Change-Id: I05a8ac8173d4d36c159aac4aef9bd99f4b3c499b Signed-off-by: Thomas Kosiewski <tk@coder.com>
…token of a recreated control plane Change-Id: I18cf5c99489f5e11e7fba9683f38176bfacf93e8 Signed-off-by: Thomas Kosiewski <tk@coder.com>
… UID to its control plane When a spec or control plane change replaces the key of status without a control plane UID, the entry of the replaced key now records the UID of the control plane that it names, while that control plane has the recorded URL. A control plane created again under that name before Coder confirms the delete then gets no operator token for the entry, which stays and is reported with ControlPlaneChangedKeyKept. Change-Id: Icd5366d5e86857b5e559fa539a33170ecfce3fdb Signed-off-by: Thomas Kosiewski <tk@coder.com>
…key by its recorded IDs Change-Id: I7b7e9c64b40486603df107bf9d905790eaab2371 Signed-off-by: Thomas Kosiewski <tk@coder.com>
…et by their recorded IDs An organization can be renamed and its name reused, so the lookup of the target key name can miss a key that status records at the target. A Secret loss, a metadata backfill or a tags change then created a key in the other organization and dropped the record of the old key, which stayed valid in Coder. The replacement now also deletes the applied key and the target records with key IDs by those IDs before the create. Change-Id: I45de872317bee52bd2f0f8fbf2ea6c54ea13f1e2 Signed-off-by: Thomas Kosiewski <tk@coder.com>
|
@codex review Head 90bb3a2. New since 0149387: the applied key check of status without a control plane UID looks the key up by its recorded organization and key IDs; a replacement at the target deletes the recorded target keys by ID before its create; a replaced UID-less entry records the UID of its control plane. |
|
@codex security review |
|
Codex Review: Didn't find any major issues. Bravo. Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
🛡️ Codex Security ReviewSecurity review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
Summary
Goal: every open coder/coder-k8s issue is fixed and lands on
main. This PR fixes two CoderProvisioner key-ownership issues.CoderControlPlaneUID (🤖 CoderProvisioner: bind each key record's operator token to the control plane UID #417). After a control plane is deleted and created again under the same name, the operator no longer sends the new control plane's token for a key of the old one, and creates a new key for the provisioner.Fixes #414
Fixes #417
Refs #416 (not changed here, see the decision below)
Background
status.pendingKeys(record before create) and made the cleanup of a record without key IDs delete nothing. The rotation paths at the target (drift, Secret loss or unpublished record, metadata backfill) still deleted a same-named key by name when Coder returned it without plaintext. That is 🤖 CoderProvisioner: a rotation at the target deletes another provisioner's same-named key #414.revokeKeyread the control plane by name and sent its token to the record's URL. That is 🤖 CoderProvisioner: bind each key record's operator token to the control plane UID #417.Implementation
ProvisionerKeyByName).status.provisionerKeyIDnor thekeyIDof astatus.pendingKeysentry is not deleted or replaced. The operator keeps the applied key, the Secret and the Deployment, and writes no target record. It setsProvisionerKeyReady=False, reasonKeyNameInUse, with a message that names the URL, organization, key name and key ID. It requeues after 2 minutes, because nothing signals when that key goes.status.pendingKeysrecords outside the target slot, each with the token of its own control plane (revokePendingKeys, the cleanup loop of 🤖 fix: revoke a CoderProvisioner key created at an intermediate control plane endpoint #413 moved into a function). Unpublished entries stay, because the Secret can hold their key. The Codex security review asked for this.For()has no predicate, so a write per reconcile would loop. The envtest checks that the resourceVersion stays the same.DeleteProvisionerKeyByID) after the record-before-create write, and then one create runs. A create answer without plaintext means another key took the name after the lookup. The operator then also refuses withKeyNameInUse.coderbootstrap.Client.DeleteProvisionerKey(delete by name) is removed with its SDK implementation and tests. No operator path can delete a key by name now.ProvisionerKeyReady=Trueis now set on every reconcile that passes the key step, so a revert to the applied key name after a conflict clearsKeyNameInUse.status.controlPlaneUIDandstatus.pendingKeys[].controlPlaneUID. A new target record and a new applied key record the UID of the control plane.revokeKey(pending cleanup and deletion) checkstokenBoundTobefore it reads the token. A record with a UID gets its control plane's token only while the control plane has that UID, also at the same URL. With TLS, a control plane created again under the same name keeps its in-cluster Coder API URL and can serve another Coder backend there. That backend's "no such key" answer does not show that the old key is gone.keyControlPlaneChanged: nothing is sent, the record stays, and the new conditionControlPlaneChangedKeyKept=Truenames each such entry. Each reconcile rebuilds it, likeUnverifiedKeyKept. Deletion logs such a record and removes the finalizer.status.controlPlaneUIDset and different) is drift. The provisioner creates a new key at the new control plane, instead of keeping a key that a new backend can reject.sameKeySlot), so the replaced key of the old control plane stays recorded and reported, also at the same URL. A record without a UID matches any UID.:mainbuild) is bound only at the current Coder API URL of its control plane. This is the safe side. An old URL never gets the token of a control plane that the operator cannot match. The cost: after an upgrade, a URL change of the same control plane before the next reconcile leaves the replaced key in Coder, andControlPlaneChangedKeyKeptnames it.status.controlPlaneUID, the next reconcile looks the applied key up at the current control plane by its recorded IDs (see item 3). When Coder lists that key ID there, the operator records the control plane's UID, so a later recreate is drift. When Coder answers that the key is gone there, it creates a new key. This needs one lookup per upgraded provisioner.status.organizationIDand matchesstatus.provisionerKeyID(ProvisionerKeyByID, new incoderbootstrap, which shares its list-and-match code withDeleteProvisionerKeyByID). Coder's own 404 for that organization, or a list without that key ID, means the key is gone at this control plane: the operator creates a new key and drops the old record. Any other error fails the reconcile and changes nothing.status.pendingKeysthat have key IDs, beside the key that the name lookup found. A failed delete fails the reconcile before the create.mainhas the same loss on these paths.status.pendingKeysnow records in that entry the UID of the control plane it names, while that control plane has the recorded Coder API URL (bindToControlPlane). A control plane created again under that name before Coder confirms the delete then gets no token for the entry, andControlPlaneChangedKeyKeptnames it.docs/tutorials/deploy-coderprovisioner.mddescribes the key name rule,KeyNameInUsewith its recovery, the UID binding,ControlPlaneChangedKeyKept, and the migration rule. CRD,dist/install.yamland the API reference are regenerated.Decision: #416 stays open (Refs)
#416 asks to delete off-target recorded keys while the current target fails its token or entitlement checks. It does not conflict with the fail-static credential checks. I did not fold it in for these reasons:
KeyNameInUsegate added here already runs the cleanup for published entries outside the target slot. The token and entitlement gates do not. There the cleanup would retry dead endpoints every 15 seconds (the operator-access retry interval) instead of every 2 minutes, and the target slot would have to be computed before the token check.revokePendingKeysis the function a 🤖 CoderProvisioner: keys recorded at an older endpoint are not deleted while the current target fails its checks #416 fix can call there.Validation
TestCoderProvisionerKey_OtherProvisionersKeyKept(envtest, 5 cases, stateful fake Coder with one key per name). Provisioner Q holds keyprovisioner. Provisioner P reaches that key name in three ways: its first key, a key name change, and status without key records. P must keep Q's key, keep its own key, Secret and Deployment, reportKeyNameInUsewith Q's key ID, write no status on a repeated conflict, and record no create. P recovers when Q's key goes, after a rename to a distinct name, or after a revert to its applied name (Q's key and P's old key ID stay). In one case P also has a replaced key recorded at another key name; the conflict reconcile deletes it and drops its record (this case fails on 9b59a91).TestCoderProvisionerKey_ControlPlaneRecreated(envtest, 10 cases). Control planecoderathttp://coder-old.example.comis deleted and created again under the same name with another token, athttp://coder-new.example.comor at the same URL. Then P rotates or is deleted, with and without a recorded UID. No request may carry the new token to the old URL, and with a recorded UID the new token is never used for the old key. On rotation, P creates a new key, the record of the old key stays, andControlPlaneChangedKeyKeptnames it. When the recreated control plane serves the old backend, the rotation deletes the old key there and keeps no record of it. For status without a UID: a recreate at the same URL against a fresh backend creates a new key, and a reconcile before such a recreate binds the key that the backend holds, so the recreate is then drift. One case keeps the control plane, moves P to another one with status without a UID, and expects the old key deleted at its URL with its own token.main, where they fail. The legacy "control plane kept" case passes onmaintoo, as a migration guard. The same-URL cases came with the fixes for the Codex review (cd33036, 94bdf89):On main (4094802)
TestCoderProvisionerKey_OrganizationRenamed(envtest, 4 cases). It runs the real SDK client against an HTTP Coder fixture. Organization A, named team, holds the applied key. A is renamed and organization B takes the name team. For status without a UID, the key stays applied, and deleting P deletes it in A by its IDs. This is the verifier's regression test. For a Secret loss (with and without a UID) and a tags change, the rotation deletes the old key in A by its IDs and creates the new key in B. In every case, deleting P leaves no key in Coder.TestCoderProvisionerKey_StatusWithoutUIDReplacedKeyBound(envtest). Status without a UID, P moves to another control plane, and Coder fails the delete of the replaced key. The old control plane is then created again at its URL with another token. The new token must not be used for the old key, its entry stays, andControlPlaneChangedKeyKeptnames it.TestProvisionerKeyByID(SDK): a listed key, another ID, Coder's own 404 (nil), and a 404 not from Coder (error).Failing before each fix
tokenBoundToreturning true fails the four different-URL recreate cases.TestCoderProvisionerKey_RevertAfterFailedRotationandTestCoderProvisionerDeletion_TriesCurrentControlPlaneURL.sameKeySlotwithout the UID fails the same-URL rotation case ("the record of the old key must stay").KeyNameInUsebefore the cleanup fails the replaced-key case ("P must record no create").ProvisionerKeyReady=Trueonly after a replacement fails the revert case.StatusWithoutUIDReplacedKeyBound.status without control plane UID, recreate at the same URL(no delete of the old key expected).RotationOnSecretLossnow loses the Secret of a recorded key.TagsDriftand theKeySecret_OwnedIsReusedtest use keys with IDs.UnpublishedKeyIsRotated: after a lost create answer, the operator now reportsKeyNameInUseuntil the key is deleted by hand, and then rotates.IDLessRecordKeepsOtherProvisionersKey: Q now creates its key at B after P's failed create at B. Before, P would now refuse B at once.make verify-vendor,make build,make lint,make test,make test-integration,make codegen manifests docs-reference build-installer(no further diff),nix develop -c make docs-check, andcspellandmarkdownlint-cli2on the changed docs. All pass.KeyNameInUseafter a revert (fixed in 5fcce53). It also flagged the current-URL allowance that the first version had, which Codex then raised too (fixed in cd33036), and confirmed the residual race listed under Risks.@codex reviewon cd33036 (1 finding, fixed in 94bdf89),@codex reviewon 9b59a91 (no major issues),@codex security reviewon 9b59a91 (1 finding, fixed in 0149387),@codex reviewon 0149387 (1 finding, first declined, then fixed in 1126867 and 90bb3a2 after the verifier showed a lost cleanup),@codex security reviewon 0149387 (no security issues).Risks
KeyNameInUseuntil someone deletes that key in Coder. Before, the operator deleted the key by name. That was the takeover of 🤖 CoderProvisioner: a rotation at the target deletes another provisioner's same-named key #414. The docs describe the manual delete.KeyNameInUsefor a key that may be its own. The provisioner keeps running with its Secret. Deleting the key in Coder lets the operator create a new one.DeleteProvisionerKeyByIDlists the keys, finds the recorded ID, then deletes by name, because Coder v2.37.3 deletes provisioner keys only by name (httpmw.ExtractProvisionerKeyParam). If someone deletes P's key and creates another key with the same name between those two requests, the delete hits the new key. This race predates this PR and is documented on the function. The operator cannot close it without a Coder API that deletes by ID.KeyNameInUseretry costs one lookup every 2 minutes.ControlPlaneChangedKeyKeptuntil someone deletes its key by hand and removes the entry. This is the migration cost above.ControlPlaneChangedKeyKeptuntil someone deletes it by hand and removes the entry. Deletion of the provisioner before that rotation sends no token for its key and leaves the key in Coder.Generated with
xum• Model:anthropic:claude-opus-5-5• Thinking:xhigh