Files
buzz/crates/buzz-backend-kubernetes/src/cluster.rs
Tyler 6530b58a61 feat(k8s): Kubernetes backend plugin + desktop deploy path (#4289)
# Kubernetes backend plugin (crates/buzz-backend-kubernetes) + desktop
deploy path

Implements docs/remote-agents.md (merged @ 28ae6cd21) as ONE PR: the
provider
binary, the desktop changes that make it work, the harness inactivity
reaper,
the Sprig image, and the conformance/live-test suites.

Channel: buzz-remote-agents (29414326-dba7-402d-b384-b1b34d63a2e6),
thread c42b70ef.

## What's here (by lane)
- **crates/buzz-backend-kubernetes** (Dawn): stdin/stdout JSON provider,
info +
  deploy; pure classify.rs (one match arm per spec state-machine row);
reconcile/GC with ownership-marker gate + same-clock orphan check;
per-attempt
immutable Secrets; three-tier env with clear-then-write authoritative
tier.
- **Desktop** (Mari): KD3 launch block from resolved descriptor, KD5
pre-secret
negotiation gate (resolve-once → stage-and-digest → info → protocol gate
→
deploy), KD1 Windows extension strip, bundling (externalBin + Justfile +
release/canary workflows + stub loops), tauri.windows.conf.json platform
  override (Decision B: no Windows artifact).
- **buzz-acp** (Max): KD4 BUZZ_ACP_EXIT_AFTER_INACTIVITY reaper
(pool-independent;
reset only at accepted dispatch; in-flight turn/heartbeat defers, never
resets);
BUZZ_ACP_EXIT_AFTER_INACTIVITY + BUZZ_ACP_NO_PRESENCE reserved. KD8 fix.
- **Image + tests** (Perci): Dockerfile.sprig (digest-pinned bases, exec
buzz-acp
PID 1, relay-scoped credential config), image contract script, provider
conformance suites (golden wire fixtures shared with desktop tests),
live-local
  runbook (namespace-scoped, shared-cluster safe).
- **Docs** (Sami, first commit): citation re-pin c1bca1b56 → 28ae6cd21
(44/49
were already byte-exact; 3 offsets fixed) + I3 presence-bound correction
(below).

## Named spec deviations (deliberate, each with rationale)
1. **No baked default image yet.** ghcr.io/block/buzz-sprig is
unpublished
(verified: anonymous pull 403 vs control 200). Omitted `image` returns
an
   in-band field-required error instead of a default.
2. **Image override STRICTER than spec §Image:** digest-only
(`name@sha256:<64hex>`); ALL tags rejected; `name:tag@digest`
normalized.
With no baked default the override is the only path, so tag-acceptance
would
make mutability the v1 norm. Strictness is reversible; a moved tag under
an
nsec is not. Baked digest default + tag re-acceptance = follow-up with
image
   publish.
3. **imagePullSecrets not in schema (v1).** Explicit user images may
rely on
namespace-preprovisioned pull credentials — the substrate boundary.
Field
added only if the publish decision proves it necessary. 9-field budget
intact.
4. **Decision A closed: writable empty workspace.** Nest projection =
named
   follow-up; no image-side scaffolding.
5. **Decision D overridden by Tyler (event b55398d8):** provider ships
bundled
with the desktop like buzz-acp/buzz-agent; spec §Distribution's separate
   release workflow deleted for v1.
6. **I3/vision presence bound corrected 90s → 180s.** PRESENCE_TTL_SECS
moved in
#3783 during this spec's base→merge window; the number was inherited,
not
chosen. Spec :206/:216/:928 + inline quote + VISION_REMOTE_AGENTS.md:59
corrected. ← Tyler: the vision is your document; this edit is flagged
for
   your explicit eyes.
7. **Spec citations are pinned to 28ae6cd21** (main at spec merge) and
resolve
   there, not at this PR's head — this PR's own lanes move
crates/buzz-acp/src/lib.rs by ~100 lines (19 citations across
KD4/KD6/KD7/
§Stop/§Launch data). Known Defects rows fixed BY this PR retire on
merge;
   the section documents main as of the pin.
8. **KD7 grace tension declared:** pod terminationGracePeriodSeconds=60
vs
KD7's measured ~87s shutdown tail at parallelism 10 (~197s at cap 32).
   KD7 is ruled out of scope, so L1-3's "enough grace for full graceful
shutdown" is NOT met at default config — deliberate, resolved by the KD7
   follow-up, not silently.

## Question for Tyler
Will ghcr.io/block/buzz-sprig publish PUBLIC? If private-by-policy,
§Image needs
an imagePullSecrets story before the baked-default follow-up can land.

## Out of scope (named follow-ups)
KD6 exit-code contract + KD7 shutdown budget (gate OnFailure), OnFailure
restart
policy, Windows provider binary, PVCs/nest projection, mesh
deployability,
sprig image publish workflow + baked multi-arch digest default.

## Reproduce locally (four traps that cost us real time)

**1. Git hooks inherit the invoking shell's PATH — pin the shell, not
just your
verification commands.** `rust-toolchain.toml` pins `1.95.0`, but the
rustup shim
that honors that pin lives in `~/.cargo/bin`. If Homebrew's cargo is
earlier on
PATH, `cargo` in this repo is 1.89.0, which cannot build the workspace
at all:

```
$ /opt/homebrew/bin/cargo check -p buzz-db
error: rustc 1.89.0 is not supported by the following packages:
  sqlx@0.9.0 requires rustc 1.94.0
  ...                                                    # exit 101
```

Verifying with `PATH="$HOME/.cargo/bin:$PATH" cargo test` does *not*
protect the
push: lefthook's `pre-push` → `just test-unit` re-resolves `cargo` from
the
shell's own PATH, so a green local run is followed by a hook failure on
a crate
you never touched. Export the PATH for the whole shell, not per-command.
This
bit twice.

**2. Line-scope your mutations, or the mutation edits its own
detector.** When
mutation-testing the respond-to guard, a whole-file `sed` on the mode
literal
touches 5 sites — the guard *and* the fixtures/assertions that test it.
The
mutation and its detector move together and the suite stays green, which
reads
as "this code is dead" when it actually means "you deleted the
experiment":

```
# WRONG — 5 sites, guard and tests mutate together
$ sed -i '' 's/"allowlist"/"allowlist-DISABLED"/g' src/env.rs
test result: ok. 145 passed; 0 failed          # false survivor

# RIGHT — 1 site, anchored to the guard's own definition line
$ sed -i '' '/^const RESPOND_TO_ALLOWLIST/s/"allowlist"/"allowlist-DISABLED"/' src/env.rs
failures:
    env::tests::allowlist_mode_with_an_empty_list_is_refused
    env::tests::an_allowlist_entry_that_is_not_64_hex_is_refused
test result: FAILED. 143 passed; 2 failed      # real kill
```

Restore by copying a pristine file back and confirming `git diff --stat`
is
empty, not by re-running an inverse `sed`.

**3. A completeness guard is not a correctness guard.** The shared wire
fixture
`tests/fixtures/provider-wire/deploy-full-launch.request.json` passed
every test
we had while containing four classes of invented data (wrong
`respond_to`
encoding, an env key no emitter writes, allowlist entries that fail the
harness's own 64-hex rule, a `launch.env` key from no descriptor layer).
The
provider's tests could not have caught this: its types are deliberately
indifferent to these values (`Option<String>`, `Vec<String>`, arbitrary
map), so
"the provider parses it" was never evidence that the desktop emits it.
The fix
was not a stronger provider assertion but a rule about provenance —
"recorded"
means executed-and-transcribed, and the desktop's whole-object equality
test is
the only enforcement that can exist. See the fixture README.

**4. Every drift this arc was a value that agreed with itself.** Five
invented
values were found, and not one was caught by an assertion failing — each
was
caught by someone asking where a value came from. A named constant
referenced
symbolically on both the fixture and assertion side. A `sed` that
mutated its
own detector. Six probe rows that all died at the same unrelated error.
A
descriptor struct literal compared against a fixture built from that
literal
(`launch.args: ["run","--session"]`, which the resolver actually returns
as
`["acp"]`). The general defense is not more assertions but provenance: a
stub is
a control that varies nothing, and the more faithful it looks the better
it
hides. Ask what executed, not what passed.

*Fixture-test determinism caveat (post-verification, Quinn + Dawn).* The
desktop's whole-object fixture test calls the real resolver, which
consults a
process-global harness registry whose own docs require
`registry_test_lock`
for any test touching it. The fixture test holds no lock and is
nonetheless
deterministic — but by containment, not by ordering. Measured, not
derived:
planting a definition with `id: "goose"` directly into the registry
(bypassing
the loader) changes the resolved descriptor from `args: ["acp"]` to
`args: ["--poisoned"]`, so `resolve_effective_harness_descriptor`
**does**
reach the registry for this id — it does not short-circuit on the
builtin
table first. Two controls discriminate: an empty registry and a registry
poisoned under a *different* id both return `["acp"]`. What actually
protects
the test is that the registry has exactly one writer
(`update_loaded_harness_registry`, reached only via
`warm_harness_registry_from_dir`) — but that writer concatenates **two**
sources of unequal strength (`custom_harnesses.rs:319-326`). Custom
files
pass through `load_custom_harnesses`, whose `check_id_collision` rejects
the
reserved builtin id `goose` case-insensitively at the loader — and that
leg
is tested (`load_applies_id_collision_check` writes a real `goose.json`
and
asserts the loader drops it). Preset definitions
(`preset_harness_definitions`, `presets.rs:177-193`) are a bare `.map`
over
`PRESET_HARNESSES` with **no collision check** — exhaustive call-site
enumeration at `60007fda4` finds four production `check_id_collision`
sites,
none on the preset path. That leg holds only because `goose` is not in
the
preset table today (intersection of TIER1 and preset ids is empty) —
executed, not just read: adding a preset with `id: "goose"`,
`args: ["--poisoned"]` and warming via the normal preset-only path
(`warm_harness_registry_from_dir(None)`, no custom dir, no direct
writer)
flips the fixture's emitted `launch.args` from `["acp"]` to
`["--poisoned"]`
at `60007fda4`, command/env/policy_env unchanged. So: no test in the
suite
can put a `goose` entry in the registry
via the custom path, and no preset currently carries one, so no
interleaving
can perturb this fixture — containment with one checked leg and one
coincidental one. A future fixture built on a **non-builtin** runtime id
has
no containment at all — it would be order-dependent against whatever
registry-writing test ran last and must take the lock.

*Late instance, found while reviewing the mode guard.* The guard
exact-matches
`respond_to` untrimmed and case-sensitively, which is only correct if
clap's
`ValueEnum` derive is case-sensitive. `config.rs` gives two answers: the
derive
at `:448-453` carries no `ignore_case`, while the crate's own tests call
`RespondTo::from_str(s, true)` — `ignore_case = true`. Reading the
source
supports either. Measured on the built binary instead: `owner-only`
starts,
`OWNER-ONLY` / `Owner-Only` / `ALLOWLIST` / `NOBODY` all exit rc=2
`invalid
value`. Case-sensitive at the CLI, so the guard is right — and right for
a
reason the source does not state. The `from_str(_, true)` tests exercise
a
different surface and are not evidence about the CLI.

*Corollary, and the sharper half.* When a test helper **reimplements**
production instead of calling it, the helper is a fork — and a fork can
be
right while production is wrong, or wrong in the same way, and the suite
reports green either way. Both `BUZZ_ACP_ALLOWED_*` gates are forked
like this:
production compares **strings** while the helpers compare **post-parse
enums**
(`config.rs:2623`) or re-derive the split
(`buzz-cli/.../channels.rs:1296`).
Production and the helper each carry their *own* copy of the empty-entry
filter
(`:1025` and `:1300`), so fixing one says nothing about the other.
Measured on
`buzz-cli`, restoring byte-exact between runs:

| tree | result |
|---|---|
| baseline | 274 passed |
| drop the empty-filter in **production** only (the real fix) | **274
passed** — no signal |
| drop it in the **test helper** only | **273 passed, 1 failed**
(`channels.rs:1338`) |

Two independent defects, stacked, and worse together than either alone:
production can be fixed with no test ever noticing, *and* the helper
cannot be
corrected without a false alarm demanding the bug back. The root cause
is one
bit of type information — `check_allowed_channel_add_policy(allowed_raw:
&str,
..)` cannot represent "unset", while production reads `env::var(..) ->
Result`,
where unset and `""` are different states. A helper whose parameter type
can't
represent all of production's input states isn't testing production's
states —
it's testing a subset it silently chose. Same family as the
struct-literal
descriptor and the fixture drift: the test and the thing it tests
agreeing with
each other, rather than the test measuring the thing. Neither defect is
in this
PR's diff (`git diff --name-only 28ae6cd21 <head> -- crates/buzz-cli` is
empty); both are now filed as NIP-34 issues on this repo: the fail-open
+
fork-helper defect at issue event `0524a4113f2d97fd…` and the respond-to
self-lock at `e32837498969b5e7…` (filed 2026-08-02 after Quinn measured
that
no prior filing existed — zero hits on GitHub `block/buzz` open *or*
closed
and zero on the relay's kind:1621 issues, against working positive
controls). The prescription was itself
mutation-tested before being written down: repairing the fork's
signature
(`Option<&str>` + assertion → `None`) still let the reintroduced
production
bug ship 274-green — an expressive fork is still a fork; it never
executes
production. So the `buzz-cli` fix has **three parts and one explicit
keep**:
drop the production filter; **delete** the helper and point its tests at
the
real `cmd_set_add_policy` (which self-discriminates by error variant —
`Usage` = refused, `Network(BadScheme)` = passed the gate — no relay
needed);
serialize the env-var tests behind one
**`tokio::sync::Mutex::const_new`**
lock taken with `.lock().await`, including the pre-existing `:1362`
integration test (the fork was silently buying test isolation — without
the
lock, parallel runs flake nondeterministically; a `std::sync::Mutex`
held
across `.await` trips `clippy::await_holding_lock` under `-D warnings`);
and
**keep** the then-dead `!allowed.is_empty()` clause with a comment
saying
why. It is unreachable-false (`split(',')` never yields an empty vec),
but it
is the only thing that keeps the reintroduced production bug detectable
—
mutation-tested: on a tree that deletes the clause, reintroducing the
empty-filter bug survives 275/0, because `""`/`","`/`" "` refuse either
way
and the filter goes semantically inert. Dead code can be load-bearing
for
tests: "provably unreachable" is an argument about behavior, never about
coverage. When a helper forks production, the fix has to delete the
fork:
any change that leaves two implementations standing can only ever be
verified against the one the tests call. *Final shape:* the keep and the
broad lock are both artifacts of the fork surviving in some form. The
extraction variant (Dawn, mutation-tested at `60007fda4`) removes the
tension: extract one `check_channel_add_policy_allowed(Option<&str>,
&str)`
that **production calls**, with the `Option` placed at the env boundary
where the `Result<String, VarError>` bit actually lives. 5/6 mutants
killed; the empty-filter survivor is proven **equivalent** (exhaustive
6174-pair check, 0 divergences, with a diverging negative control;
independently re-derived by a second generator — different tokens and
shape — 0 divergences on admitted policies, 500 on a non-admitted
control),
not a coverage hole — on a one-implementation tree there is no fork left
to
witness, so no dead clause needs keeping. One scope line on that
equivalence: it is **caller-conditional**, a property of the only
current
caller, not of the gate function — `cmd_set_add_policy`'s own match at
`:1027-1034` admits only three policies before the gate runs; a second
caller reaching the gate with arbitrary strings resurrects m1 as a real
hole. The lock does not disappear, it
narrows (Dawn's own correction, caught by Mari): lock exactly the tests
that mutate the process env — three-plus-one on a fork tree, two on the
extraction tree — behind one `tokio::sync::Mutex`, and the lock is part
of
the assertion, not hygiene: with it deleted, the gate test fails 8/8
runs
deterministically by receiving `Network(BadScheme)` where it expects
`Usage` — the unset test's `remove_var` clobbers the other's `set_var`,
and
**the gate test passes straight through the gate**, a false negative on
the
exact authz assertion the test exists to make. State it as an outcome:
these two tests must not observe each other's env writes. 276/0 stable
across 5 parallel runs, clippy `-D warnings` clean; independently
verified
(patch applied to a second worktree: result blob `d67e584be` matches the
patch index, full mutant matrix reproduces row for row). One new row no
earlier
prescription covered: collapsing unset into `Some("")` fails **closed**
—
an unconfigured deployment refuses every policy — killed by the unset
test.
Patch: `OUTBOX/BUZZ_CLI_ADD_POLICY_GATE_EXTRACT_FIX.patch`. The filed
issue
(`0524a411…`) carries the fork-shape prescription; whoever picks it up
should prefer the extraction shape, drop the dead-clause keep with it,
and
keep part 3 outcome-shaped: serialize whichever tests mutate the env.

## Verification (final HEAD `60007fda4`)
- Full touched-package suites at each integration merge (log in plan
file).
  At candidate parent `00e5b5fe9`: buzz-backend-kubernetes 154,
buzz-acp 673, desktop tauri 2100+3, pnpm 3908, workspace clippy/fmt/tsc
  all clean. The only delta to `60007fda4` is one character in
  `scripts/test-k8s-sprig-image-live.sh` (heredoc escape so the readlink
probe evaluates pod-side, not host-side at render); `crates/` tree hash
  is byte-identical at both SHAs, so the Rust receipts attach by tree
  identity. buzz-backend-kubernetes suite re-run in-shell at
  `HEAD == 60007fda4`: 154 passed.
- Adversarial one-HEAD gate (Sami): guard matrix 12/12, predicate
mutants
  7/7, doomed-invocation finding closed end-to-end; tree-hash carry to
  `60007fda4` confirmed (crates/buzz-backend-kubernetes blob unchanged).
- Live-local pass per TESTING.md + skill-buzz-testing (Perci, at
`60007fda4`): explicit `docker-desktop` context, digest-qualified image
  imported into node containerd `k8s.io` namespace, pull policy `Never`;
  pod printed `DIGEST_ABI_OK`, `resolved_spec` and `image_id` both the
  exact requested digest, script exit 0. Dedicated per-run namespace,
  ownership labels on every object, scoped cleanup verified empty after.
- Implementation review (Wren) at `60007fda4`: 9.6 minimalness /
  9.4 elegance / 9.3 correctness, no blocker.
- `origin/eva/k8s-backend` == `60007fda4` (ls-remote verified; SHA
  identity is byte identity).

---------

Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Signed-off-by: Tyler <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Co-authored-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@buzz.block.builderlab.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
2026-08-02 14:39:27 -04:00

428 lines
18 KiB
Rust

//! The real [`Substrate`]: kube-rs against a live apiserver.
//!
//! Everything that *decides* lives in `classify`/`gc`; this module only
//! performs I/O and maps apiserver responses onto the trait's vocabulary.
//! Three mappings here are normative rather than incidental:
//!
//! * **409 is discriminated on `Status.reason`, never on the code.** A create
//! 409 is `AlreadyExists`; a delete 409 from a failed precondition is
//! `Conflict`. Branching on `code == 409` conflates a lost create race with
//! a stale fence and is the trap the spec names (`:780-794`).
//! * **Reads leave `resourceVersion` unset**, which is the quorum read. `"0"`
//! is the cache read, and a confirmed absence from a cache is proof of
//! nothing (`:761-769`).
//! * **Deletes never set `grace_period_seconds`**, so the object's own 60s
//! budget applies. Passing `0` is a force-kill that discards the shutdown
//! window the pod declares (`:1185-1189`).
use crate::classify::Fence;
use crate::reconcile::{CreateOutcome, DeleteOutcome, Substrate};
use chrono::{DateTime, Utc};
use k8s_openapi::api::core::v1::{Namespace, Pod, Secret};
use kube::api::{Api, DeleteParams, GetParams, ListParams, PostParams, Preconditions};
use kube::core::ErrorResponse;
use kube::{Client, Resource};
use std::time::{Duration, Instant};
/// `Status.reason` values we branch on. Spelled once so the two 409 arms are
/// visibly the same discriminator read two ways.
///
/// These are wire strings `apimachinery` chooses, not names this crate picks:
/// `StatusReasonAlreadyExists`, `StatusReasonConflict`, `StatusReasonNotFound`,
/// and `StatusReasonForbidden` in `k8s.io/apimachinery/pkg/apis/meta/v1/types.go`.
/// kube-core types `ErrorResponse::reason` as a bare `String`, so there is no
/// upstream constant to bind to and the spelling is pinned by test instead.
const REASON_ALREADY_EXISTS: &str = "AlreadyExists";
const REASON_CONFLICT: &str = "Conflict";
const REASON_NOT_FOUND: &str = "NotFound";
const REASON_FORBIDDEN: &str = "Forbidden";
/// The apiserver-backed substrate for one deploy operation.
pub struct Cluster {
client: Client,
namespace: String,
/// Start of *this operation*, for the deadline. Monotonic: the 600s budget
/// must not move when the wall clock does.
started: Instant,
}
/// The typed API error underneath a `kube::Error`, if it is one.
fn api_error(error: &kube::Error) -> Option<&ErrorResponse> {
match error {
kube::Error::Api(response) => Some(response),
_ => None,
}
}
/// Does this error carry the given `Status.reason`?
fn reason_is(error: &kube::Error, reason: &str) -> bool {
api_error(error).is_some_and(|e| e.reason == reason)
}
impl Cluster {
pub fn new(client: Client, namespace: &str) -> Self {
Self {
client,
namespace: namespace.to_string(),
started: Instant::now(),
}
}
fn pods(&self) -> Api<Pod> {
Api::namespaced(self.client.clone(), &self.namespace)
}
fn secrets(&self) -> Api<Secret> {
Api::namespaced(self.client.clone(), &self.namespace)
}
/// List an object kind through the raw client so the response's HTTP
/// `Date` header is reachable.
///
/// `Api::list` returns only the decoded body, and the apiserver's clock is
/// the *only* clock the orphan-Secret age gate may use — a desktop's local
/// clock running fast computes every in-flight Secret as expired
/// (`:1321-1335`). So the list goes through `Client::send`, which hands
/// back the whole `http::Response`.
async fn list_with_date<K>(
&self,
selector: &str,
) -> Result<(Vec<K>, Option<DateTime<Utc>>), String>
where
K: Resource<Scope = k8s_openapi::NamespaceResourceScope>
+ Clone
+ serde::de::DeserializeOwned
+ std::fmt::Debug,
K::DynamicType: Default,
{
let dt = K::DynamicType::default();
let url = K::url_path(&dt, Some(&self.namespace));
// resourceVersion deliberately unset: quorum read.
let params = ListParams {
label_selector: Some(selector.to_string()),
..Default::default()
};
let request = kube::core::Request::new(url)
.list(&params)
.map_err(|e| format!("could not build a list request: {e}"))?;
let (parts, body) = request.into_parts();
let response = self
.client
.send(http::Request::from_parts(parts, body.into()))
.await
.map_err(|e| format!("could not list {}: {e}", K::plural(&dt)))?;
// Parsed before the body is consumed, and independently of it: a
// missing or malformed header is not a list failure, it just means the
// orphan sweep has no clock and skips.
let server_now = response
.headers()
.get(http::header::DATE)
.and_then(|v| v.to_str().ok())
.and_then(|v| DateTime::parse_from_rfc2822(v).ok())
.map(|v| v.with_timezone(&Utc));
let bytes = http_body_util::BodyExt::collect(response.into_body())
.await
.map_err(|e| format!("could not read the {} list body: {e}", K::plural(&dt)))?
.to_bytes();
let list: kube::core::ObjectList<K> = serde_json::from_slice(&bytes)
.map_err(|e| format!("could not decode the {} list: {e}", K::plural(&dt)))?;
Ok((list.items, server_now))
}
}
impl Substrate for Cluster {
async fn ensure_namespace(&self, namespace: &str) -> Result<(), String> {
let api: Api<Namespace> = Api::all(self.client.clone());
if api
.get_opt(namespace)
.await
.map_err(|e| format!("could not check whether namespace {namespace} exists: {e}"))?
.is_some()
{
return Ok(());
}
let spec = Namespace {
metadata: kube::core::ObjectMeta {
name: Some(namespace.to_string()),
..Default::default()
},
..Default::default()
};
match api.create(&PostParams::default(), &spec).await {
Ok(_) => Ok(()),
// Someone else created it between our check and our create. That
// is the desired end state, not a failure.
Err(e) if reason_is(&e, REASON_ALREADY_EXISTS) => Ok(()),
// Namespace-create is frequently denied on shared clusters. Name
// the exact command an operator runs, and never silently fall back
// to `default` — deploying an agent into someone else's namespace
// is worse than refusing (`:1002-1005`).
Err(e) if reason_is(&e, REASON_FORBIDDEN) => Err(format!(
"not authorized to create namespace {namespace}. Ask a cluster \
administrator to run `kubectl create namespace {namespace}`, \
then try again."
)),
Err(e) => Err(format!("could not create namespace {namespace}: {e}")),
}
}
async fn list_pods(&self, selector: &str) -> Result<(Vec<Pod>, Option<DateTime<Utc>>), String> {
self.list_with_date::<Pod>(selector).await
}
async fn list_secrets(&self, selector: &str) -> Result<Vec<Secret>, String> {
Ok(self.list_with_date::<Secret>(selector).await?.0)
}
async fn secret_exists(&self, name: &str) -> Result<bool, String> {
// `GetParams::default()` leaves resourceVersion unset — the quorum
// read this check requires to be proof of anything.
match self.secrets().get_with(name, &GetParams::default()).await {
Ok(_) => Ok(true),
Err(e) if reason_is(&e, REASON_NOT_FOUND) => Ok(false),
Err(e) => Err(format!("could not check whether secret {name} exists: {e}")),
}
}
async fn create_secret(&self, secret: &Secret) -> Result<(), String> {
let name = secret.metadata.name.clone().unwrap_or_default();
self.secrets()
.create(&PostParams::default(), secret)
.await
.map(|_| ())
.map_err(|e| format!("could not create secret {name}: {e}"))
}
async fn create_pod(&self, pod: &Pod) -> Result<CreateOutcome, String> {
let name = pod.metadata.name.clone().unwrap_or_default();
match self.pods().create(&PostParams::default(), pod).await {
Ok(_) => Ok(CreateOutcome::Created),
// The deterministic name is taken: a concurrent attempt won the
// election. Discriminated on the reason — a 409 whose reason is
// `Conflict` is a different condition and must not be read as a
// lost race.
Err(e) if reason_is(&e, REASON_ALREADY_EXISTS) => Ok(CreateOutcome::AlreadyExists),
Err(e) => Err(format!("could not create pod {name}: {e}")),
}
}
async fn delete_pod(&self, name: &str, fence: &Fence) -> Result<DeleteOutcome, String> {
let params = DeleteParams {
preconditions: Some(Preconditions {
uid: Some(fence.uid.clone()),
resource_version: Some(fence.resource_version.clone()),
}),
// grace_period_seconds deliberately unset: the pod's own 60s
// budget applies.
..Default::default()
};
match self.pods().delete(name, &params).await {
Ok(_) => Ok(DeleteOutcome::Accepted),
Err(e) if reason_is(&e, REASON_NOT_FOUND) => Ok(DeleteOutcome::NotFound),
// The object changed since the observation that authorized this
// delete. Same HTTP code as the create race above, different
// reason, different meaning.
Err(e) if reason_is(&e, REASON_CONFLICT) => Ok(DeleteOutcome::PreconditionFailed),
Err(e) => Err(format!("could not delete pod {name}: {e}")),
}
}
async fn delete_secret(&self, name: &str) -> Result<(), String> {
match self.secrets().delete(name, &DeleteParams::default()).await {
Ok(_) => Ok(()),
// Already gone is the desired end state.
Err(e) if reason_is(&e, REASON_NOT_FOUND) => Ok(()),
Err(e) => Err(format!("could not delete secret {name}: {e}")),
}
}
async fn get_pod(&self, name: &str) -> Result<Option<Pod>, String> {
match self.pods().get_with(name, &GetParams::default()).await {
Ok(pod) => Ok(Some(pod)),
Err(e) if reason_is(&e, REASON_NOT_FOUND) => Ok(None),
Err(e) => Err(format!("could not read pod {name}: {e}")),
}
}
async fn sleep(&self, duration: Duration) {
tokio::time::sleep(duration).await;
}
fn elapsed(&self) -> Duration {
self.started.elapsed()
}
}
#[cfg(test)]
mod tests {
use super::*;
use http::Response;
use kube::client::Body;
use std::sync::{Arc, Mutex};
use tower::service_fn;
fn list_response(date: Option<&str>) -> Response<Body> {
let mut response = Response::builder().status(200);
if let Some(date) = date {
response = response.header(http::header::DATE, date);
}
response
.body(Body::from(
serde_json::to_vec(&serde_json::json!({
"apiVersion": "v1",
"kind": "PodList",
"metadata": {"resourceVersion": "17"},
"items": [{
"apiVersion": "v1",
"kind": "Pod",
"metadata": {"name": "sprig"},
"spec": {"containers": [{"name": "agent", "image": "example.invalid/sprig@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}]}
}]
}))
.unwrap(),
))
.unwrap()
}
async fn list_through_real_request_path(
date: Option<&'static str>,
) -> (Vec<Pod>, Option<DateTime<Utc>>, String) {
let observed_uri = Arc::new(Mutex::new(None));
let service_uri = Arc::clone(&observed_uri);
let service = service_fn(move |request: http::Request<Body>| {
let service_uri = Arc::clone(&service_uri);
async move {
*service_uri.lock().unwrap() = Some(request.uri().to_string());
Ok::<_, std::convert::Infallible>(list_response(date))
}
});
let cluster = Cluster::new(Client::new(service, "ignored"), "owned-ns");
let result = cluster
.list_with_date::<Pod>("app.kubernetes.io/managed-by=buzz-backend-kubernetes")
.await
.unwrap();
let uri = observed_uri.lock().unwrap().take().unwrap();
(result.0, result.1, uri)
}
/// Exercise the shipped `Request` + `Client::send` seam. A fake
/// reconciler would not prove that kube-rs emits a quorum list request or
/// that the apiserver's clock survives body decoding.
#[tokio::test]
async fn list_with_date_uses_a_quorum_request_and_returns_the_server_clock() {
let (pods, server_now, uri) =
list_through_real_request_path(Some("Sun, 02 Aug 2026 04:00:00 GMT")).await;
assert_eq!(pods.len(), 1, "fixture must contain one decoded pod");
assert_eq!(pods[0].metadata.name.as_deref(), Some("sprig"));
assert!(uri.starts_with("/api/v1/namespaces/owned-ns/pods?"));
assert!(
uri.contains("labelSelector=app.kubernetes.io%2Fmanaged-by%3Dbuzz-backend-kubernetes")
);
assert!(
!uri.contains("resourceVersion"),
"cache read leaked into {uri}"
);
assert_eq!(
server_now.unwrap().to_rfc3339(),
"2026-08-02T04:00:00+00:00"
);
}
/// Header failure is deliberately not list failure: without a trustworthy
/// apiserver clock the orphan sweep skips, but normal reconciliation still
/// receives the decoded objects.
#[tokio::test]
async fn list_with_date_keeps_items_when_the_server_clock_is_unusable() {
for date in [Some("not a date"), None] {
let (pods, server_now, _) = list_through_real_request_path(date).await;
assert_eq!(pods.len(), 1, "fixture must contain one decoded pod");
assert!(server_now.is_none(), "unexpected clock for {date:?}");
}
}
/// A typed apiserver error, as kube-rs surfaces it.
fn api(reason: &str, code: u16) -> kube::Error {
kube::Error::Api(ErrorResponse {
status: "Failure".into(),
message: String::new(),
reason: reason.into(),
code,
})
}
/// The one discriminator the whole file rests on, and the trap the spec
/// predicts: "an implementation that branches on the code alone will
/// eventually take the adoption path on a failed delete or vice versa"
/// (`:788-790`).
///
/// Both of these are 409. Reading the *code* makes them identical; reading
/// `Status.reason` keeps a lost create race and a stale fence apart. The
/// mutation that must fail this test is `e.reason == …` → `e.code == 409`,
/// which no other test in the crate would catch — the fakes never produce
/// a real `kube::Error`.
#[test]
fn the_two_409s_are_never_conflated() {
let already_exists = api(REASON_ALREADY_EXISTS, 409);
let conflict = api(REASON_CONFLICT, 409);
assert!(reason_is(&already_exists, REASON_ALREADY_EXISTS));
assert!(reason_is(&conflict, REASON_CONFLICT));
// The cross terms are the whole point.
assert!(!reason_is(&already_exists, REASON_CONFLICT));
assert!(!reason_is(&conflict, REASON_ALREADY_EXISTS));
}
/// A transport-level failure is not an apiserver verdict. It must fall
/// through to the error arm rather than being read as any reason — a
/// connection reset silently classified as `NotFound` would report a pod
/// as confirmed-absent, which the classifier treats as proof.
#[test]
fn a_non_api_error_carries_no_reason() {
let transport = kube::Error::LinesCodecMaxLineLengthExceeded;
assert!(api_error(&transport).is_none());
for reason in [
REASON_ALREADY_EXISTS,
REASON_CONFLICT,
REASON_NOT_FOUND,
REASON_FORBIDDEN,
] {
assert!(!reason_is(&transport, reason), "matched {reason}");
}
}
/// `reason` is `#[serde(default)]` in kube-core, so an apiserver that
/// omits it yields an empty string. That must match nothing rather than
/// matching an empty pattern by accident.
#[test]
fn an_absent_reason_matches_nothing() {
let bare = api("", 409);
assert!(!reason_is(&bare, REASON_ALREADY_EXISTS));
assert!(!reason_is(&bare, REASON_CONFLICT));
}
/// The consts are the apiserver's spelling, asserted against literals
/// rather than against themselves.
///
/// Every other test here references the consts symbolically on both sides
/// — fixture *and* assertion — which is true for any pair of distinct
/// values. That tests the discriminator is self-consistent, not that it is
/// correct: swapping the two 409 values inverts `AlreadyExists` and
/// `Conflict` at a real apiserver (`:788-790`'s failure, reached by
/// editing a string instead of a branch) with every other test still
/// green. These are `apimachinery`'s wire strings and kube-core exposes no
/// constant for them, so a literal is the only external anchor available.
/// Found by Quinn's mutation matrix; M2/M3/M4 survived without it.
#[test]
fn the_reason_consts_are_the_apiservers_spelling() {
assert_eq!(REASON_ALREADY_EXISTS, "AlreadyExists");
assert_eq!(REASON_CONFLICT, "Conflict");
assert_eq!(REASON_NOT_FOUND, "NotFound");
assert_eq!(REASON_FORBIDDEN, "Forbidden");
}
}