🤖 fix: honor aggregated API delete preconditions - #110
Conversation
…letes Both custom DELETE handlers discarded metav1.DeleteOptions, so a delete carrying a wrong preconditions.uid or a stale preconditions.resourceVersion still deleted the template or requested the workspace delete build. Compare the supplied UID/resourceVersion with the object fetched for the request, after the existing namespace, canonical-name and cross-organization checks and before admission and the backend mutation, using the vendored storage.Preconditions check and InterpretDeleteError so a mismatch is the usual 409 Conflict. Nil options or preconditions keep today's behaviour; an explicitly supplied empty value is compared like any other value. The mutation targets the same fetched backend ID; the comparison is a snapshot check without compare-and-swap, which the how-to now states together with the asynchronous workspace deletion. Tests drive both storage entrypoints through the full matrix (nil/empty preconditions, UID/RV match and mismatch combinations, explicit empty values, missing objects, alias and cross-organization requests, admission rejection) and assert zero backend mutations on every rejection. Fixes #108 Signed-off-by: Thomas Kosiewski <tk@coder.com> --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `coder:anthropic/claude-fable-5-1` • Thinking: `xhigh`_ Change-Id: I8c2209be08e1d9d1681903cd0cb8d88cd070ab92
The how-to claimed `kubectl delete` of a manifest carrying `metadata.uid` sends a UID precondition; the owned Kind run showed `kubectl delete -f` with a wrong `metadata.uid` still deletes (kubectl sends no preconditions). It also implied a workspace `resourceVersion` becomes stale after backend changes, but Coder v2.37.2 leaves `workspaces.updated_at` untouched by builds, TTL, autostart and rename changes, so the derived `resourceVersion` cannot detect them. State both facts and point workspace users at `uid`. Part of #108 (docs only; no code change). _Generated with [`xum`](https://github.com/coder/xum) • Model: `coder:anthropic/claude-fable-5-1` • Thinking: `xhigh`_ Change-Id: Ib80a8d4f68aeebe471402b40283e79283d145322 Signed-off-by: Thomas Kosiewski <tk@coder.com>
…ordering Describe the DELETE precondition check as a snapshot equality on the exposed values: a mismatch is a Conflict only when the exposed uid or resourceVersion differs from the supplied one, the check is not a compare-and-swap, uid guards against a different object under the same name rather than same-object drift, and on Coder 2.37.2 builds, TTL, autostart and rename changes did not change the exposed workspace resourceVersion (tracked in issue #109). Drop the earlier "sets only on creation" wording and the blanket claim about other DeleteOptions fields; keep that kubectl delete -f sends no preconditions. Extend TestDeletePreconditionsRunBeforeAdmissionAndMutation so, for both fixtures, a wrong uid, a stale resourceVersion and both together return Conflict with zero admission calls and zero backend mutations, and a matching pair runs admission exactly once. Part of #108 (docs and test only; no production change). _Generated with [`xum`](https://github.com/coder/xum) • Model: `coder:anthropic/claude-fable-5-1` • Thinking: `xhigh`_ Change-Id: I55ac5ec8a1d0ee15680761acbed9498fc1aedf09 Signed-off-by: Thomas Kosiewski <tk@coder.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ 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. |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ 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. |
|
@codex security review |
🛡️ 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. |
|
@codex review |
|
Codex Review: Didn't find any major issues. Can't wait for the next one! 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". |
🤖 Delivery verifiedLanded through the required squash merge queue as
Issue #108 is closed. This implements fetched-snapshot UID/resourceVersion equality, not atomic deletion or general workspace change detection. Issue #109 remains open for workspace version representation; #107 remains open for canonical leaf names; #105 remains open for import readiness. The maintainer desk owns those follow-ups, with #107 next. No quickstart-support claim is added. Generated with |
Summary
Honor
DeleteOptions.Preconditionsfor aggregatedCoderTemplateandCoderWorkspacedeletes. A supplied UID or resourceVersion that differs from the fetched object now returns409 Conflictbefore admission or backend mutation. Deletes without preconditions, or with matching values, keep their existing behavior.Fixes #108. Workspace version derivation remains unfixed in #109. This does not implement #107's leaf-name checks or #105's import-readiness work, and does not restore quickstart support.
Implementation and review focus
Validation and limits
Candidate:
202821b7f9dd804157c2c182163d40c104b30667.DeleteOptionsbehavior was not exercised.Recorded evidence
Final screenshot:
e2e-accelerated-timing.webm
The video is a post-hoc terminal render with idle gaps trimmed: 27.48 seconds from a 380-second capture. It is not real-time playback. The raw cast is retained. The recorded-timing export failed; its failure receipt is retained. Both evidence packets passed hash verification: 438 initial and 274 final artifacts.
📋 Implementation Plan
Enforce DELETE preconditions on aggregated objects (#108)
Goal and boundary
Honor
metav1.DeleteOptions.Preconditions(UID and resourceVersion) onCoderTemplateandCoderWorkspacedeletes so a delete carrying a UID or resourceVersion that differs from the fetched object is rejected with a Kubernetes-compatible error before any Coder mutation. Deletes without preconditions, and deletes whose preconditions match the fetched object, behave as today.Out of scope: leaf-name case handling (#107), template-import readiness (#105), quickstart/installer, any new version scheme, compare-and-swap subsystem, persistence of Kubernetes metadata, adjacent cleanup.
Baseline behaviour (main
3f65df0c, tree6c49783c)internal/aggregated/storage/template.go:755andinternal/aggregated/storage/workspace.go:653take_ *metav1.DeleteOptions. The apiserver handler passes the decoded options through unchanged (vendor/k8s.io/apiserver/pkg/endpoints/handlers/delete.go:169); only the generic registryStoreenforces preconditions itself (vendor/k8s.io/apiserver/pkg/registry/generic/registry/store.go:629-641), which these custom storages do not use.UID = <backend ID>andresourceVersion = strconv.FormatInt(UpdatedAt.UnixNano(), 10)(internal/aggregated/convert/template.go:32-33,internal/aggregated/convert/workspace.go:36-37). The same converters build the object handed to admissiondeleteValidation(template.go:799,workspace.go:692), so the fetched object already carries the UID/RV that a precondition must be compared against.sdk.DeleteTemplate(ctx, template.ID)(template.go:804) andsdk.CreateWorkspaceBuild(ctx, workspace.ID, {Transition: delete})(workspace.go:698). Neither Coder API accepts a version/CAS argument.ef29d3a5): a template delete with a mismatched UID precondition deleted the canonical template (err=nil, deleted=true); the workspace path has the same shape and needs its own regression.Precondition policy (smallest supported)
preconditions.UIDwith the fetched object's UID (types.UID(<backend ID>)). Mismatch →409 Conflictwith the standard messagePrecondition failed: UID in precondition: <x>, UID in object meta: <y>. The subsequent mutation targets that same fetched backend ID rather than resolving the request name again.preconditions.ResourceVersionwith the fetched object's converted RV (UpdatedAt.UnixNano()string). Mismatch →409 Conflict(Precondition failed: ResourceVersion in precondition: …). This compares the supplied value with the fetched snapshot, not an atomic Kubernetes storage transaction. A mismatch detects a different exposed value, not every backend change. Template metadata updates changed that value in the live test. On Coder 2.37.2, tested workspace builds, rename, TTL and autostart changes did not advance it; matching workspace RVs therefore do not protect against those same-object changes (tracked in 🤖 fix: make workspace resource versions reflect backend changes #109). Check and delete are also not atomic: there is no CAS on the Coder delete APIs, so a change between the fetch and the backend call is not detected. UID protects identity, not edits to the same object.storage.Preconditions{UID, ResourceVersion}.Check(key, obj)(vendor/k8s.io/apiserver/pkg/storage/interfaces.go:138-165) on the converted fetched object and map itsInvalidObjErrorthroughstorageerrors.InterpretDeleteError(err, aggregationv1alpha1.Resource(<resource>), name)(vendor/k8s.io/apiserver/pkg/storage/errors/storage.go:97-108), which yields the sameapierrors.NewConflictthe generic registry produces.niloptions ornilpreconditions → no check (Checkreturns nil for a nil receiver).deleteValidationon the same converted object → backend delete by the fetched ID. Precondition failures therefore never reach upload/build/delete calls and cannot disclose anything about objects that the identity checks already made opaque (NotFoundstill wins for cross-organization requests because it is returned before the precondition check).metav1.Statussuccess; workspace delete keepsdeleted=falsebecause deletion is asynchronous).Changes (one bounded PR)
internal/aggregated/storage/template.goTemplateByName,obj := convert.TemplateToK8s(...);if err := checkDeletePreconditions(options, obj, resource, name); reuseobjfor admission and the watch eventinternal/aggregated/storage/workspace.gorequireFetchedWorkspaceIdentity; the converted pre-delete object is used for the check and admission; the post-build snapshot logic staysinternal/aggregated/storage/errors.go(existing file)checkDeletePreconditions(options *metav1.DeleteOptions, obj runtime.Object, resource schema.GroupResource, name string) errorwrappingstorage.Preconditions.Check+InterpretDeleteError; nil-safe; assertion on nil objinternal/aggregated/storage/storage_test.go/ newdelete_preconditions_test.goDELETE /api/v2/templates/*andPOST /api/v2/workspaces/*/builds(the existingmutations()recorder already captures every non-GET request); test matrix belowdocs/how-to/deploy-aggregated-apiserver.mdupdated_at(refetch on Conflict), non-atomic windowNo API type, codegen, manifest, dependency or RBAC change. Estimated production delta ≈ 40 lines; risk: low (additive checks before existing mutations; ordinary deletes unchanged). Clients holding an RV from before a backend
updated_atchange receive Conflict and must refetch. This is an intended stale-snapshot rejection, not an atomicity guarantee. Only UID/resourceVersion preconditions are in scope; this change does not claim support for everyDeleteOptionsfeature.Test matrix (red → green, real
rest.Storageentrypoints, both resources)options == nildeleted=true; workspace delete build,deleted=false)options.Preconditions == nil/ emptyPreconditions{}apierrors.IsConflict, message names both UIDs; object still present (templatehasTemplatetrue; workspacebuildTransitionsempty)UpdatedAt.UnixNano()NotFound/BadRequestunchanged, precondition never evaluated, no disclosure of the real UIDNotFound(fetch fails first)deleteValidationrejection with matching preconditionsGate: retain native failing-test receipts on unchanged main for the two RED cases per resource, then green for the whole matrix plus the existing storage/watch suites.
Phases and quality gates
3f65df0c; receipt of native failures.errors.gohelper, two call sites, doc subsection); gate: focused +./internal/aggregated/...green,make lint.make verify-vendor,make build,make test,make lint,make docs-check, govulncheck, Trivy fs; attributed local commits and an exact candidate patch series; any later source or documentation commit requires final-head checks again.vcs.revisionand running-pod imageID): bootstrap CNPG/control plane, canonical template + workspace as today, then:kubectl proxy+curl -X DELETE -H "Content-Type: application/json" -d '{"preconditions":{"uid":"<x>"}}'against/apis/aggregation.coder.com/v1alpha1/namespaces/coder/codertemplates/<canonical>): wrong UID → HTTP 409 with the original backend object intact; matching UID, matching RV and both matching → 200 on separate disposable fixtures. Template success means absent from Coder; workspace success means a delete build with transitiondelete. Exercise mismatch combinations. Preconditions must be in the explicit request body; the testedkubectl delete -fpath did not send them even withmetadata.uid;Evidence: terminal transcript, screenshots and video of the actual CLI (playback labelled), native exits, discovery, backend counts, cleanup receipts (own cluster/tag/kubeconfig/session only).
Race and limits statement (for docs and PR)
Precondition checks run against the object fetched in the same request, and the mutation targets that same backend ID. This avoids a second name lookup between the comparison and mutation. A resourceVersion precondition detects a mismatch against the fetched exposed value. It does not detect changes that leave that value unchanged, or changes between the fetch and backend call (no CAS in the Coder delete APIs). Deletion of workspaces remains asynchronous.
Generated with
xum• Model:coder:openai/gpt-6-astra• Thinking:xhigh