Files
buzz/docs/operator-community-deletion.md
Luke Tornquist 53a12100b3 fix(deletion): require sole owner at admission (#7966)
Follow-up to #7830: reject owner-origin deletion admission for a legacy
co-owned community before creating a request. Under the existing
community-row lock, admission locks all current owner memberships in
pubkey order and requires exactly one matching asserted owner.
Existing-request idempotent replay remains unchanged; completion retains
its independent post-lock revalidation.

**New optional operator field: `community_id`** (`ab0cd713`,
`766eb07f`). Owner-origin `POST /operator/communities/delete` now
accepts an optional `community_id` that binds the request to the host's
community:
- **Fresh submission:** checked after sole-owner authority is proven, so
a non-owner still gets `404 community_not_found`. A mismatch returns
`409 community_id_mismatch` and writes no request row.
- **Replay:** checked against the stored request, and only when the
stored request is for the same host. A mismatch returns `409
community_id_mismatch`, and the stored request is unchanged. A known
UUID sent with a different host goes through the existing convergence
check and returns `409 deletion_request_conflict`.
- **Omitted:** behaves as before. `admit_owner_request` takes
`expected_community_id: Option<Uuid>` rather than a second public entry
point, and existing callers pass `None`.
- `docs/operator-community-deletion.md` documents the ordering.

Also folds in the non-blocking review nits from #7969 (wpfleger96, at
`e21151f4`):
- **`limit_reached` has a stable `code`.** Create
(`provision_community`) and transfer-in now return `409` with `code:
"limit_reached"`, matching the other coded lifecycle errors. The `error`
message keeps its `limit_reached:` prefix, so clients that match on the
message still work. The provision limit test now asserts the code, and a
new PostgreSQL test covers a transfer to an owner at the limit (coded
409, no membership change).
- **Docs: an ack-version mismatch is a 400, not a 409.**
`docs/operator-community-deletion.md` and the `delete_community` handler
doc now say a changed host or owner under a known UUID is `409
deletion_request_conflict`, and an unsupported acknowledgement version
is rejected first with `400 unsupported_acknowledgement_version`.
- **Docs: active-cap overrides above the lifetime cap can't be
reached.** The `max_communities_per_owner` doc and the quota section now
say `MAX_LIFETIME_COMMUNITIES_PER_OWNER` (20) counts live ownership, so
`BUZZ_MAX_COMMUNITIES_PER_OWNER` above 20 can't be reached.
- **Legolas P2s:**
- The UUID-conflict docs now list every 409 case, including a stored ack
version that doesn't match and a UUID held by an operator-origin
request.
- The runbook now says a legacy co-owned community is rejected as `404
community_not_found` and should be converged with a transfer first.
- The preparation drift test now pins `DeletionSafety` plus "sole-owner
authority drifted".
- **Guard wording:** the `delete_community` handler doc now says "sole
current owner", matching the admission check.

What got simpler: admission and automatic preparation now enforce the
same sole-owner authority, so no accepted-but-unexecutable co-owned
request needs a new recovery path. `deletion_api_error` is renamed to
`coded_api_error`, since it now carries non-deletion codes too. There's
one helper for coded operator errors instead of a second one.

Rebased onto main after #7969 merged (`d7a35afa`). The sole-owner commit
applied cleanly.

Earlier focused Blox verification (at the pre-rebase head `161e9857`):
the new admission regression was red before the change and green after
it. Five temporary, restored mutants each failed its matching
production-seam regression. At the current head `766eb07f`, the relay
`api::operator::` suite passes 23/0 on Blox. Each new `community_id`
guard, removed one at a time, fails its own regression test. CI owns the
full suites.

Remaining debt: the separate P2-A purge/completion membership/community
lock-order cycle is unchanged.

Generated with Codex

---------

Signed-off-by: Codex <noreply@openai.com>
Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
2026-09-30 15:04:23 -04:00

219 lines
12 KiB
Markdown

# Community Deletion Operator Job
Buzz executes whole-community deletion through the typed, one-shot
`/usr/local/bin/buzz-admin deletions drain` command. The Helm chart can schedule
that command as a Kubernetes CronJob; it does not call relay HTTP and it does
not add another queue or retry service.
Postgres remains the handoff and source of truth. A run gives already-approved
work priority. When none is ready, it may claim an operator-attested
owner-origin request at `submitted`, build the existing bounded inventory, and
atomically freeze that inventory with a digest-bound `owner_automatic`
approval. The same lease then enters the unchanged executor and resumes from
durable checkpoints.
Operator-origin requests never auto-progress. `concurrencyPolicy: Forbid` prevents scheduled
pod overlap, `backoffLimit: 0` prevents Kubernetes Job retries, and the deletion
store remains authoritative when a pod exits, reaches its deadline, or is
replaced.
## Enablement
The CronJob is disabled by default. Production deployments should use an
existing Secret and a dedicated, pre-created service account:
```yaml
secrets:
existingSecret: buzz-operator-secrets
operatorJobs:
deletionDrain:
enabled: true
schedule: "*/5 * * * *"
activeDeadlineSeconds: 3600
terminationGracePeriodSeconds: 30
serviceAccountName: buzz-deletion-drain
podLabels:
tags.datadoghq.com/service: buzz-deletion-drain
podAnnotations:
sidecar.istio.io/inject: "false"
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: "1"
memory: 1Gi
```
Set `s3.endpoint`, `s3.bucket`, `s3.region`, and `s3.addressingStyle` in chart
values. The selected Secret must contain `DATABASE_URL` and `REDIS_URL`; it may
contain `BUZZ_S3_ACCESS_KEY` and `BUZZ_S3_SECRET_KEY` when the object store uses
static credentials. The pod receives only those connection values and the four
non-secret S3 settings. It does not receive `BUZZ_RELAY_PRIVATE_KEY`,
`BUZZ_GIT_HOOK_HMAC_SECRET`, `RELAY_URL`, or the full Secret through `envFrom`.
The pod also disables service-account token automounting and Kubernetes service
link environment injection because the executor does not call the Kubernetes
API or discover cluster Services. This disables the ordinary Kubernetes API
token mount, not credentials injected by a platform workload-identity
mechanism.
Leaving `operatorJobs.deletionDrain.serviceAccountName` empty falls back to the
relay's own service account, including cloud IAM attached through the platform's
workload-identity mechanism. For example, the EKS IRSA admission webhook can
inject its projected web-identity token and AWS environment variables despite
`automountServiceAccountToken: false`; other platforms provide their own
identity mechanism. The drain pod therefore inherits the relay's cloud role by
default. Create a dedicated service account with only the object-store
permissions listed below and name it explicitly if you want the executor's IAM
blast radius to be smaller than the relay's.
The S3 principal needs the relay's normal object permissions plus bucket-level
`s3:ListBucketVersions` and object-level `s3:DeleteObjectVersion` for every
tenant-owned prefix. This also applies to never-versioned buckets because S3
reports their objects with the `null` version id.
## Runbook
1. Confirm database migrations are current. For operator-origin requests,
confirm explicit inventory and operator approval with
`buzz-admin deletions inspect <request-id>`. For owner-origin requests,
expect the drain to record `approval_origin: owner_automatic`; `approved_by`
is the immutable mediating operator, not the owner.
2. Confirm the selected Secret contains the required keys and the S3 principal
has version-list and exact-version delete permissions.
3. Enable the CronJob and inspect its rendered command and environment before
rollout.
4. Locate the rendered CronJob by label rather than by guessing its name:
```sh
kubectl get cronjob -n <namespace> \
-l app.kubernetes.io/component=deletion-drain,app.kubernetes.io/instance=<release>
```
The name is `<bounded-fullname>-deletion-drain`. The chart truncates only the
fullname portion when needed so the suffix remains stable within Kubernetes'
52-character CronJob name limit. `buzz.fullname` collapses to the release
name when the release name already contains the chart name, so
`helm install buzz ...` renders `buzz-deletion-drain`, not
`buzz-buzz-deletion-drain`.
5. Start one staffed manual run with
`kubectl create job --from=cronjob/<cronjob-name> <job-name>`.
6. Follow pod logs and re-run `buzz-admin deletions inspect <request-id>` to
verify lease, checkpoint, retry, blocked, and terminal state.
7. If a run fails or times out, fix the recorded dependency or permission
failure. Do not add Kubernetes retries: the next scheduled drain consults the
durable retry/checkpoint state and resumes only when the store allows it.
7. Use `buzz-admin deletions abort` as privileged recovery while a request is
still at `submitted` or `inventoried` when safe preparation cannot continue.
Abort preserves the archived community and immutable request evidence. An
operator may also `unblock` a remediated preparation failure.
## Deadlines, termination, and the retry budget
`activeDeadlineSeconds` is a Kubernetes-side limit, and the deletion store does
not learn why a pod went away. Two consequences matter when reading state:
- A `DeadlineExceeded` Job is not recorded as a deletion retry. Shutdown
releases the claim without recording one, so `retry_count` and `blocked_at`
do not advance. Only the object-store drain checkpoints per manifest chunk
and resumes mid-stage; every other stage restarts from its beginning on the
next run. A deadline that keeps landing inside one of those non-resumable
stages therefore repeats indefinitely: each run increments `attempts` and
burns the window again while the retry budget never moves and the request is
never blocked.
- Diagnose this from both sides. `buzz-admin deletions inspect <request-id>`
shows a rising `attempts` with a flat `retry_count` and no `last_error`;
Kubernetes holds the reason. The chart labels the CronJob and the drain pods,
but not the generated Jobs, so find the attempts with
`kubectl get pods -n <namespace> -l app.kubernetes.io/component=deletion-drain`
and read the condition with `kubectl describe job <job-name>`.
Size `activeDeadlineSeconds` for the longest single stage this community will
run, not for the average run. To recover, either raise the deadline and let the
schedule pick the request back up, or take one staffed run with
`buzz-admin deletions run <request-id>` outside the CronJob's deadline.
`terminationGracePeriodSeconds` is a best-effort window, not a guarantee. The
drain command handles `SIGTERM` and releases its lease cleanly when it wins the
race, but a pod that is still working when the grace period expires is
`SIGKILL`ed with the lease still held. Nothing is lost: the durable lease simply
expires (60s by default, heartbeated every 10s) and the next run reclaims the
request with a fresh lease generation, which fences any straggler write from the
killed process. Expect up to roughly a lease duration of delay before the
request is runnable again; do not raise the grace period expecting a clean
handoff.
Owner self-serve relay admission still records only a `submitted` row and does
no inventory, approval, S3 work, or execution synchronously. A successful drain
has no human approval step or cooling-off period: operator-attested owner intent
is prepared automatically under privileged policy and becomes immediately
eligible for execution. Transient preparation failures use the existing retry
schedule; permanent or exhausted failures block durably. Owner-facing
admission has no cancellation endpoint. Admission requires the asserted owner
to be the community's sole current owner: a legacy community with more than
one owner row is rejected as `404 community_not_found`, indistinguishable from
a missing host. Converge ownership first: transfer rejects archived communities,
so unarchive, transfer to the intended owner (a self-transfer demotes the other
owner rows; the transferee's quota still applies), re-archive, then resubmit.
Admission is idempotent on the request UUID. Resending the same UUID with the
same host, owner, and acknowledgement version returns `202` with that request's
current `status` at any stage, including after membership purge, and admits no
new work. Clients recover an ambiguous submission by resending it. The same
UUID used for any other request returns `409 deletion_request_conflict`: a
different host, owner, or stored acknowledgement version, or a UUID held by an
operator-origin request.
The optional `community_id` UUID binds the request to the community resolved
from `host` without changing the host-derived authority. A different UUID
returns `409 community_id_mismatch` with no mutation: on a fresh submission
only after sole-owner authority is proven (a non-owner still gets `404`), and
on replay only for a stored request with the same host (a different host is
the `409 deletion_request_conflict` above). A malformed UUID returns
`400 invalid_request`; omitting the field preserves existing clients.
An unsupported acknowledgement version is the exception: it is rejected before
the UUID lookup with `400 unsupported_acknowledgement_version`, even for a
known UUID.
The acknowledgement version is a compile-time constant
(`OWNER_DELETION_ACKNOWLEDGEMENT_VERSION`), not operator configuration.
Admission validates it before the UUID lookup, and the executor claims and
leases only requests carrying the current version. Raising it therefore makes
resends of pending requests made under the old version fail with
`400 unsupported_acknowledgement_version`, and leaves already-admitted
old-version requests unclaimed in `submitted`. A version bump must ship code
that keeps admitting replays of, and executing, requests at the prior version
until none remain in a non-terminal stage.
## Owner quota
The relay enforces two per-owner caps on create and on transfer-in. Either
rejects with `409` and `code: "limit_reached"` (the `error` message keeps its
`limit_reached:` prefix for older clients):
- **Active:** live ownership plus incomplete owner deletions
(`BUZZ_MAX_COMMUNITIES_PER_OWNER`, default 5). A deletion keeps its slot
until logical completion records `completed_at`.
- **Lifetime:** live ownership plus every non-aborted owner deletion, including
completed ones, capped at an absolute 20 regardless of the active limit.
Lifetime usage includes live ownership, so no owner can hold more than 20
live communities; a `BUZZ_MAX_COMMUNITIES_PER_OWNER` above 20 is
unreachable, and owner lists would report a `quota_limit` the owner can
never reach.
Deleted communities keep their hosts as permanent tombstones, so this bounds
create-then-delete host squatting. Aborted deletions restore the community
and count only through its live membership.
Owner-list responses carry `quota_used` (active), `quota_limit` (active), and
`can_create` (both caps). `can_create: false` is the only signal a client needs:
show a generic community-limit message and do not derive a reason from the
counts, because an owner at the lifetime cap can have `quota_used` below
`quota_limit`. The projection is advisory: clients may use it for
UX, but the relay's `limit_reached` is authoritative, and quota changes have no
deployment order. Keep owner deletion off until this relay and the drain
executor are live; on rollback, turn deletion off before rolling back the relay.
The chart has no existing PrometheusRule or provider-neutral CronJob alert
integration. Operators must alert on failed/missed Jobs and long-running active
Jobs in their deployment platform. Adding a chart-native alert abstraction is
debt, not part of this job contract.