Skip to content

feat: invalidate provisioner daemon sessions on key deletion - #26532

Merged
jscottmiller merged 2 commits into
mainfrom
plat-305-invalidate-provisioner-sessions
Aug 11, 2026
Merged

feat: invalidate provisioner daemon sessions on key deletion#26532
jscottmiller merged 2 commits into
mainfrom
plat-305-invalidate-provisioner-sessions

Conversation

@jscottmiller

@jscottmiller jscottmiller commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes PLAT-305.

When a provisioner key is deleted, the associated daemon kept operating on its existing WebSocket connection, because authentication was only checked at connection establishment and deletion was a bare DELETE with no session invalidation.

This adds four layers of defense so a deleted key promptly stops doing work:

  1. Publish on delete. deleteProvisionerKey publishes to a new per-key pubsub channel (coderd/pubsub.ProvisionerKeyDeletedChannel) after a successful delete. Publish errors are logged but still return 204, since layer 3 is the durable backstop.
  2. Subscribe and tear down. The daemon serve handler subscribes to its key's channel and terminates the DRPC session on a deletion event. Termination is deferred while a job claimed by the session is active: the daemon may finish and report the in-flight job (UpdateJob/CompleteJob have no key check), and the last active job's completion performs the cancellation. Because Postgres LISTEN/NOTIFY does not buffer for non-listeners, the handler also performs a synchronous key-existence re-check immediately after subscribing to close the race between auth and subscription. The subscription uses SubscribeWithErr so that an ErrDroppedMessages signal (emitted when the pubsub listener reconnects) triggers the same key re-check, closing the listener-outage window in which a deletion notification could be missed.
  3. Backstop on acquire. AcquireJob and AcquireJobWithCancel verify the key still exists before waiting for a job, and the Acquirer claims jobs in a transaction that first locks the worker's deletable key (LockProvisionerKeyByIDForShare, a FOR KEY SHARE row lock held until commit) before running the AcquireProvisionerJob claim, so a claim cannot commit after the key's deletion. This guards against a missed pubsub message. A missing key row surfaces as its own result rather than overloading the claim query's no-rows response: the acquire terminates with ErrProvisionerKeyDeleted (terminating the session, with the same active-job deferral) and hands the consumed wakeup to another waiting daemon in the same domain, rather than silently re-parking and starving peers of job postings.
  4. Heartbeat watchdog. The per-session heartbeat loop (1m interval) also re-checks the key, so even a session whose deletion notification was silently lost terminates within one heartbeat interval instead of living until the connection breaks (same active-job deferral as layer 2). Reserved keys skip the check.

A job that is claimed but never delivered (the session or connection dies between the database claim and the stream send) is marked failed immediately on a fresh context, instead of staying assigned to the worker until the job reaper.

Reserved keys (built-in, user-auth, PSK) are exempt throughout, since they are not deletable rows. The acquire-time lookup runs as dbauthz.AsSystemReadProvisionerDaemons, because the provisionerd role cannot read provisioner keys and a provisioner key's RBAC object is a provisioner daemon.

A single key can back many daemons (and span HA replicas), so the per-key channel fans out to invalidate all of them at once. Per-key channels keep the LISTEN count proportional to distinct keys rather than waking every daemon on unrelated deletions.

Known limitations

  • UpdateJob/CompleteJob intentionally have no key check. By the time those RPCs arrive the work has already run; rejecting completion would strand a build in "running" (until the job reaper fails it) with real infrastructure left unreconciled. Session termination is deferred while a job is active so the completion can be reported; the daemon may not receive the final RPC response when the deferred termination fires, but the job's outcome is already persisted.
  • After termination, the daemon process redials and receives 401s until restarted. The dial-time exit logic only triggers on 403, and the auth middleware returns 401 for an invalid key; this dial behavior predates this PR and is tracked as a follow-up in PLAT-452 (return 403 for invalid provisioner keys).

Tests

  • coderd/provisionerdserver: TestAcquireJob_ProvisionerKeyDeleted (both RPC variants), TestAcquireJob_ReservedProvisionerKey, TestHeartbeat_ProvisionerKeyDeleted (heartbeat watchdog cancels the session after key deletion), TestAcquirer_ProvisionerKeyDeleted (a dead-key acquiree exits terminally and its clearance is promoted to a peer in the same domain), and TestTerminateSession_Deferral (termination is immediate when idle and deferred until the last active job finishes).
  • coderd/database: TestAcquireProvisionerJob/ProvisionerKeyLock covers the lock query against real Postgres: it returns the key ID while the row exists and no rows once it is deleted. The lock-then-claim composition is pinned by TestAcquirer_ProvisionerKeyDeleted.
  • enterprise/coderd: TestProvisionerDaemonServe/KeyDeletionClosesSession asserts an active session closes after its key is deleted. KeyDeletedDuringSetupClosesSession covers the post-subscribe re-check when a key is deleted between auth and subscription, and DroppedMessageClosesSession covers the ErrDroppedMessages re-check when a deletion is missed during a listener outage.

Validation

  • make pre-commit (gen/fmt/lint/build) passed via git hooks.
  • Targeted tests pass; existing acquire tests pass with no regression.
  • Manual: brought up a dev deployment (coder-in-coder) with a Premium license, created a deletable provisioner key, and started an external daemon with coder provisionerd start. Confirmed it authenticated via the key and connected, appearing as idle in both coder provisioner list (with the key name) and the organization Provisioners UI.
  • Manual, idle teardown: deleted the key while the daemon was idle. The server logged provisioner key deleted, terminating session, the daemon's session closed immediately, and it dropped from coder provisioner list (then entered the known 401 redial loop, PLAT-452).
  • Manual, deferred termination: ran a workspace build (tagged template, sleep 45 in local-exec) pinned to the external daemon and deleted the key mid-build. The server logged deferring session cancellation until active jobs finish; the heartbeat watchdog re-checked mid-build and re-deferred rather than force-killing. The build ran to completion (Apply complete, workspace Started) and only then did canceling session after job completion fire. The documented caveat reproduced: the daemon lost the final CompleteJob ack, and the build outcome was still persisted correctly.
Implementation plan and design decisions

Design

  • Per-key vs global channel: chose per-key (provisioner_key_deleted:<keyID>) so daemons do not wake on unrelated deletions. The cost is one LISTEN per distinct key per replica on the shared listener connection, which is negligible against Coder's existing channels.
  • Missing-key behavior on acquire: returns an error that tears down the acquire rather than silently returning an empty job.
  • Subscribe-startup race: ordering is authorize -> UpsertProvisionerDaemon -> Subscribe -> GetProvisionerKeyByID. The post-subscribe re-check handles a deletion that committed before the LISTEN registered (Postgres does not buffer notifications for non-listeners; the in-process buffer only smooths bursts and drops on overflow).
  • NewServer change: KeyID was added to provisionerdserver.Options to avoid a positional signature change across call sites. The in-memory (built-in) daemon leaves it unset and is therefore exempt.

Files

  • coderd/pubsub/provisionerkeydeleted.go (new) — channel helper.
  • enterprise/coderd/provisionerkeys.go — publish on delete.
  • enterprise/coderd/provisionerdaemons.go — subscribe, re-check, cancel session; pass KeyID.
  • coderd/provisionerdserver/provisionerdserver.goKeyID option and acquire-time existence check.

This pull request was created by Coder Agents on behalf of @jscottmiller.

@linear-code

linear-code Bot commented Jun 18, 2026

Copy link
Copy Markdown

PLAT-305

@jscottmiller

Copy link
Copy Markdown
Contributor Author

/coder-agents-review

@coder-agents-review

coder-agents-review Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Chat: Review posted | View chat
Requested: 2026-06-18 22:27 UTC by @jscottmiller
Spend: $40.36 / $100.00

Review history
  • R1 (2026-06-18): 17 reviewers, 6 Nit, 1 Note, 2 P3, COMMENT. Review

deep-review v0.8.0 | Round 1 | 61fa2ab..7e68348

Last posted: Round 1, 9 findings (2 P3, 6 Nit, 1 Note), COMMENT. Review

Finding inventory

Findings

# Sev Status Location Summary Round Reviewer Posted
CRF-1 Nit Open enterprise/coderd/provisionerkeys.go:201 Existing reserved-key guard duplicates new IsReservedProvisionerKey utility R1 Netero Yes
CRF-2 P3 Open coderd/provisionerdserver/provisionerdserver.go:382 keyDeleted error lacks operation context wrapping R1 Leorio Yes
CRF-3 P3 Open enterprise/coderd/provisionerdaemons.go:409 Session teardown log lines omit provisioner key ID R1 Chopper Yes
CRF-4 Nit Open coderd/provisionerdserver/provisionerdserver.go:385 Error message says "key" twice (wrapping + sentinel) R1 Leorio Yes
CRF-5 Nit Open coderd/provisionerdserver/provisionerdserver_test.go:320 sync.OnceFunc replaces sync.Once + Do pattern R1 Ging-Go Yes
CRF-6 Nit Open enterprise/coderd/provisionerdaemons.go:152 isDeletableProvisionerKey is De Morgan inverse of keyDeleted guard R1 Robin, Netero Yes
CRF-7 Note Open enterprise/coderd/provisionerkeys.go:218 Non-atomic delete+publish; pubsub failure window acknowledged by design R1 Knov Yes
CRF-8 Nit Open enterprise/coderd/provisionerdaemons.go:149 Doc comment on one-line predicate restates the function body R1 Gon Yes
CRF-9 Nit Open enterprise/coderd/provisionerdaemons.go:419 Re-check comment pads valuable Postgres observation with restatement R1 Gon Yes
CRF-10 Dropped by orchestrator (DRPC returns nil on ctx cancel; close code is StatusGoingAway) enterprise/coderd/provisionerdaemons.go:435 Pubsub teardown sends misleading close code R1 Hisoka No
CRF-11 Dropped by orchestrator (below threshold; Chopper acknowledged primary assertion catches) enterprise/coderd/provisionerdaemons_test.go:1108 deleted flag set unconditionally in test helper R1 Chopper No
CRF-12 Dropped by orchestrator (subsumed by CRF-8 pattern; individual trims are noise at 6 locations) multiple Additional comment verbosity instances across PR R1 Gon No
CRF-13 Dropped by orchestrator (subsumed by Gon CRF-9 at same location) coderd/provisionerdserver/provisionerdserver.go:367 Misleading comma in terminateOnDeletedKey doc R1 Leorio No

Round log

Round 1

Panel. 0 P0-P1, 2 P3, 4 Nit, 1 Note, 1 CRF-1 from Netero (not posted). 4 dropped. Reviewed against 61fa2ab..7e68348.

17 reviewers: Bisky, Hisoka, Mafu-san, Mafuuu, Pariston, Ging-Go, Gon, Leorio, Chopper, Takumi, Meruem, Kurapika, Komugi, Knov, Robin, Kite, Razor.

Hisoka P3 dropped: claimed pubsub-driven cancellation produces StatusInternalError. DRPC Server.Serve returns nil when ctx is canceled (drpcserver/server.go:132: if ctx.Err() != nil { return nil }), so the nil-error path at provisionerdaemons.go:442 correctly closes with StatusGoingAway. Knov independently verified this. Keep argument: "If DRPC changes behavior, the close code would be wrong." Counter: DRPC source is clear; this is documented behavior, not an edge case.

Gon raised 6 P2s for comment verbosity. Downgraded to Nit (style, not correctness) and consolidated to 2 representative examples (CRF-8, CRF-9). The remaining 4 trim suggestions (ProvisionerKeyDeletedChannel, IsReservedProvisionerKey, terminateOnDeletedKey, publish comment) are valid but posting 6 nits for comment trimming is noise. Pattern noted in review body.

About deep-review

CRF = Coder Review Finding (P0-P4, Nit, Note)

Reviewer Focus
Bisky tests
Chopper ops/errors
Churn-guard change verification
Ging language modernization
Gon naming
Hisoka edge cases
Killua perf
Kite change integrity
Knov contracts
Knuckle SQL
Komugi flake/determinism
Kurapika security
Law decomposition
Leorio docs
Luffy product
Mafu-san process
Mafuuu contracts
Melody dispatch/pairing
Meruem structural
Nami frontend
Netero mechanical checks
Pariston premise testing
Pen-botter product gaps
Razor verification
Robin duplication
Ryosuke Go arch
Takumi concurrency
Zoro shape

🤖 Managed by Coder Agents.

@coder-agents-review coder-agents-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well-constructed three-layer defense for provisioner key session invalidation. The subscribe-then-recheck ordering is textbook for closing the Postgres LISTEN/NOTIFY race, the per-key channel design keeps LISTEN count proportional, and the ctx-to-srvCtx fix is necessary and safe. Test coverage is strong: 219 test LOC covering both RPC variants, reserved-key exemption, end-to-end pubsub teardown, and the subscribe-startup race via a custom store interceptor. The deleteKeyOnReadStore wrapper is particularly well-done.

17 reviewers, 2 P3, 4 Nit, 1 Note. No correctness issues found. Takumi, Kurapika, Komugi, and Razor independently verified the concurrency, auth, and determinism properties. Pariston validated the problem framing and confirmed the three layers cover distinct failure modes without redundancy.

Gon identified a comment verbosity pattern across the PR (6 doc comments that partially restate their function bodies). Two representative examples are filed inline; the remaining four (ProvisionerKeyDeletedChannel, IsReservedProvisionerKey, terminateOnDeletedKey, and the publish comment in provisionerkeys.go) would also benefit from trimming to their non-obvious content.

"Subscribe first, then read. A deletion that commits between auth and subscribe is caught by the re-check. A deletion after the re-check is caught by the subscription. A deletion between subscribe and re-check is caught by both. Three layers, no gap. Pleased to find no seam." (Hisoka)


enterprise/coderd/provisionerkeys.go:201

Nit [CRF-1] Lines 201-203 check reserved keys by string comparison (provisionerKey.ID.String() == codersdk.ProvisionerKeyIDBuiltIn || ...). This PR adds codersdk.IsReservedProvisionerKey(uuid.UUID) which does the same check via UUID comparison. The guard here should use codersdk.IsReservedProvisionerKey(provisionerKey.ID) to avoid duplication. (Netero)

🤖

🤖 This review was automatically generated with Coder Agents.

Comment thread coderd/provisionerdserver/provisionerdserver.go Outdated
Comment thread enterprise/coderd/provisionerdaemons.go Outdated
Comment thread coderd/provisionerdserver/provisionerdserver.go Outdated
Comment thread coderd/provisionerdserver/provisionerdserver_test.go Outdated
Comment thread enterprise/coderd/provisionerdaemons.go Outdated
Comment thread enterprise/coderd/provisionerkeys.go
Comment thread enterprise/coderd/provisionerdaemons.go Outdated
Comment thread enterprise/coderd/provisionerdaemons.go Outdated
@datadog-coder

This comment has been minimized.

@jscottmiller
jscottmiller marked this pull request as ready for review June 22, 2026 18:44
@jscottmiller
jscottmiller requested a review from zedkipp June 30, 2026 18:16
@github-actions github-actions Bot added the stale This issue is like stale bread. label Jul 8, 2026
@github-actions github-actions Bot closed this Jul 12, 2026
@jscottmiller jscottmiller reopened this Jul 14, 2026
@github-actions github-actions Bot removed the stale This issue is like stale bread. label Jul 15, 2026
@github-actions github-actions Bot added the stale This issue is like stale bread. label Jul 22, 2026
@github-actions github-actions Bot closed this Jul 25, 2026
@jscottmiller jscottmiller reopened this Jul 28, 2026
@jscottmiller jscottmiller removed the stale This issue is like stale bread. label Jul 28, 2026

@johnstcn johnstcn left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think a better approach would be to modify the AcquireJob query to never acquire a job when the associated key is deleted. A provisioner daemon's tags come directly from the key (except in case of PSK, which is deprecated AFAIR).

Comment thread coderd/pubsub/provisionerkeydeleted.go Outdated
Comment on lines +5 to +7
// ProvisionerKeyDeletedChannel returns the pubsub channel that carries a
// notification when the provisioner key with the given ID is deleted. The
// payload is empty; the channel name encodes the key ID.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Too far away from implementation detail to make this kind of statement.

Suggested change
// ProvisionerKeyDeletedChannel returns the pubsub channel that carries a
// notification when the provisioner key with the given ID is deleted. The
// payload is empty; the channel name encodes the key ID.
// ProvisionerKeyDeletedChannel returns the pubsub channel that carries a
// notification when the provisioner key with the given ID is deleted. The
// channel name encodes the key ID.

Comment on lines +363 to +369
// terminateOnDeletedKey cancels the session, when a cancel is configured, so
// the daemon stops after its key is deleted.
func (s *server) terminateOnDeletedKey() {
if s.sessionCancel != nil {
s.sessionCancel()
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if someone forgets to pass in sessionCancel?

@jscottmiller
jscottmiller marked this pull request as draft August 3, 2026 20:01
@jscottmiller
jscottmiller removed the request for review from zedkipp August 5, 2026 18:02
ProvisionerTags: dbTags,
})
var job database.ProvisionerJob
err := a.store.InTx(func(tx database.Store) error {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is executed in a transaction to ensure that a job is claimed iff the provisioner's associated key is still live. The transaction adds an 4 extra round trips to the database, holding the key lock for 3 of these. imo this is worthwhile - the backup acquisition polling is every 30s per domain, much larger than the milliseconds added by the chained queries. If this is a concern, an earlier version of this code did the check within AcquireProvisionerJob, but overloading that query to handle key deletion sacrificed a good bit of clarity.

@jscottmiller

Copy link
Copy Markdown
Contributor Author

I think a better approach would be to modify the AcquireJob query to never acquire a job when the associated key is deleted. A provisioner daemon's tags come directly from the key (except in case of PSK, which is deprecated AFAIR).

That makes sense, and I have implemented a version of it. Do you think the pubsub fast path is still worthwhile? Terminating the process quickly is nice, but isn't essential as either acquisition or a new key-deleted heartbeat check (added in this PR) would catch it. My feeling is remove it. I may also remove that new heartbeat check - because it can fire during a running job, some extra machinery is needed to put the provisioner server into a terminating state in order to allow the current job to finish before shutting down. If we are OK only relying on the acquisition signal alone, I'll rip that out. The only downside of the acquisition signal is that it's delay is technically unbounded if you have >1 provisioner in a domain, but in practice that shouldn't be an issue.

Anyway, I'm spending too much time on this (though it's been a good intro to the provisioner). Let me know if you're cool with the acquirer-only path.

Comment on lines +414 to +424
// IsReservedProvisionerKey reports whether the given ID is one of the reserved
// provisioner keys (built-in, user-auth, PSK). Reserved keys are created by the
// system and cannot be deleted.
func IsReservedProvisionerKey(id uuid.UUID) bool {
switch id {
case ProvisionerKeyUUIDBuiltIn, ProvisionerKeyUUIDUserAuth, ProvisionerKeyUUIDPSK:
return true
default:
return false
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is only exported for use in tests. Could this be inlined into IsDeletableProvisionerKey?

@jscottmiller
jscottmiller marked this pull request as ready for review August 6, 2026 20:10
@jscottmiller
jscottmiller force-pushed the plat-305-invalidate-provisioner-sessions branch from 2467209 to 03987ab Compare August 6, 2026 20:45
@jscottmiller jscottmiller reopened this Aug 10, 2026
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 10, 2026
Closes PLAT-305. When a deletable provisioner key is deleted, tear down the
external provisioner daemon sessions authenticated with it via four layers:
publish-on-delete + pubsub subscribe/teardown (with post-subscribe and
dropped-message re-checks), an acquire-time key lock in the claim
transaction, and a heartbeat watchdog. Session termination is deferred while
a claimed job is active so the daemon can report its result.
TestAcquirer_ProvisionerKeyDeleted counted AcquireProvisionerJob calls
across every acquiree, so the unkeyed acquiree's call could land before
the assertion once the keyed acquiree handed over its clearance. Count
calls per worker instead.

TestProvisionerDaemonServe/DroppedMessageKeyCheckErrorKeepsSession
mutated the provisioner key while the handler's post-subscribe re-check
could still be pending or in flight, and that re-check closes the
connection on both the deleted and the error branch. Wait for a served
RPC before touching the key, and serialize key reads against the
mutation with a gate in the fake store.

Also restores gci import grouping in the files the branch reformatted.
@jscottmiller
jscottmiller force-pushed the plat-305-invalidate-provisioner-sessions branch from 2f39ab4 to db9eb68 Compare August 11, 2026 15:28
@jscottmiller
jscottmiller merged commit 866e676 into main Aug 11, 2026
28 checks passed
@jscottmiller
jscottmiller deleted the plat-305-invalidate-provisioner-sessions branch August 11, 2026 16:04
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants