feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
[package]
|
||||
name = "buzz-backend-kubernetes"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
description = "Kubernetes backend provider for Buzz remote agents (docs/remote-agents.md)"
|
||||
|
||||
[[bin]]
|
||||
name = "buzz-backend-kubernetes"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
kube = { workspace = true }
|
||||
k8s-openapi = { workspace = true }
|
||||
nostr = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
http = "1"
|
||||
http-body-util = "0.1"
|
||||
|
||||
# Explicit rustls dep with the ring provider — required to install the
|
||||
# process-level CryptoProvider at startup. Without it this binary panics on its
|
||||
# first TLS connection to the apiserver: the release build compiles every
|
||||
# sidecar in one cargo invocation (.github/workflows/release.yml), which unifies
|
||||
# both ring and aws-lc-rs features and leaves rustls unable to auto-select a
|
||||
# provider. Same dependency and reason as crates/buzz-cli/Cargo.toml.
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tower = { workspace = true }
|
||||
@@ -0,0 +1,377 @@
|
||||
//! The deploy state machine (spec §Deploy State Machine), as a pure function.
|
||||
//!
|
||||
//! `classify` maps a verified observation plus the desired create intent to
|
||||
//! one [`Action`]. It performs no I/O, so every row of the spec's table is a
|
||||
//! unit test with no cluster. `reconcile` executes actions and re-enters.
|
||||
//!
|
||||
//! Two invariants are structural rather than remembered:
|
||||
//!
|
||||
//! * [`Action::Delete`] carries the [`Fence`] from the exact observation that
|
||||
//! authorized it. There is no way to build a delete without one, so a later
|
||||
//! helper cannot re-read and silently substitute a fresher fence.
|
||||
//! * The pull-failure classifier ([`PullFailure`]) reaches only
|
||||
//! [`Action::Report`] and [`Action::Observe`]. It is absent from
|
||||
//! `Action::Delete`'s type, so "reason strings are never deletion
|
||||
//! authority" is enforced by the compiler.
|
||||
|
||||
use crate::intent::Fingerprint;
|
||||
|
||||
/// The compare-and-delete fence: UID + resourceVersion from the observation
|
||||
/// that authorized the deletion. A failed precondition means the object
|
||||
/// changed since the read — re-enter, never retry the delete.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Fence {
|
||||
pub uid: String,
|
||||
pub resource_version: String,
|
||||
}
|
||||
|
||||
/// Why a pod that never started looks permanently broken. Reporting only.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PullFailure {
|
||||
/// Registry auth: a 403/401 `ImagePullBackOff` retries forever without
|
||||
/// ever succeeding, so "the pull retries" is false for this case.
|
||||
Unauthorized,
|
||||
/// The digest or repository does not exist at that registry.
|
||||
ManifestUnknown,
|
||||
/// The image has no variant for the node's architecture.
|
||||
ArchMismatch,
|
||||
}
|
||||
|
||||
/// The container's startup state, already decoded from pod status. Decoding
|
||||
/// happens at the edge so this module stays free of API types.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Startup {
|
||||
/// `state.running` — the harness process is up. This, not pod phase, is
|
||||
/// what "live" means.
|
||||
Started,
|
||||
/// Started once and reached a terminal phase (Succeeded/Failed).
|
||||
Terminated,
|
||||
/// Never started, and self-healing is plausible: unschedulable during
|
||||
/// scale-from-zero, an image pull in progress, a transient
|
||||
/// `CreateContainerConfigError` whose Secret exists.
|
||||
NeverStartedRecoverable,
|
||||
/// Never started, and the provider *verified* the cause — not a reason
|
||||
/// string. Either the referenced Secret is confirmed absent by a
|
||||
/// most-recent read, or the image reference is structurally invalid.
|
||||
NeverStartedProvablyBroken,
|
||||
/// Never started; the pull is failing in a way that will not self-heal.
|
||||
/// Still recoverable in the *never delete* sense — this only changes what
|
||||
/// we report and how long we wait.
|
||||
NeverStartedPullFailing(PullFailure),
|
||||
}
|
||||
|
||||
/// A pod that passed identity and ownership verification: label-selected,
|
||||
/// full-pubkey annotation equal to the derived pubkey, management marker
|
||||
/// present. Constructing this type is the verification step's output, so an
|
||||
/// unverified object cannot reach `classify` at all.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct VerifiedPod {
|
||||
pub name: String,
|
||||
pub fence: Fence,
|
||||
/// Set once the apiserver accepts a delete. In Kubernetes there is no
|
||||
/// `Terminating` phase — a pod being gracefully deleted stays in phase
|
||||
/// `Running` for its whole grace period — so this must be checked
|
||||
/// *before* startup state or the dying pod reads as the no-op row.
|
||||
pub deletion_marked: bool,
|
||||
pub startup: Startup,
|
||||
/// The `buzz.block.xyz/create-intent` annotation as recorded at create.
|
||||
/// `None` for a pod written before the annotation existed, which counts
|
||||
/// as divergence.
|
||||
pub recorded_intent: Option<Fingerprint>,
|
||||
}
|
||||
|
||||
/// What the reconciler should do next.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
/// Create the pod, then wait for the harness container to start.
|
||||
Create,
|
||||
/// Compare-and-delete, poll for actual disappearance, then re-enter.
|
||||
Delete { name: String, fence: Fence },
|
||||
/// Wait for a deletion already in flight, then re-enter.
|
||||
AwaitDisappearance { name: String },
|
||||
/// Strict no-op: return this `agent_id`, mutate nothing.
|
||||
NoOp { agent_id: String },
|
||||
/// Keep observing until started or the operation deadline expires; on
|
||||
/// expiry report the latest condition. Never deletes, on this call or any
|
||||
/// later one.
|
||||
Observe { name: String },
|
||||
/// Surface an actionable condition immediately rather than burning the
|
||||
/// deadline on a failure that will not self-heal.
|
||||
Report { name: String, failure: PullFailure },
|
||||
}
|
||||
|
||||
/// Apply the spec's ordered rules to one verified observation.
|
||||
///
|
||||
/// `desired` is the freshly computed create intent; comparison is always
|
||||
/// recorded-annotation vs freshly-computed, never a diff against the live pod
|
||||
/// (admission defaulting would make every pod look divergent).
|
||||
pub fn classify(observed: Option<&VerifiedPod>, desired: &Fingerprint) -> Action {
|
||||
let Some(pod) = observed else {
|
||||
// Row: no instance → create. First deploy, or after GC.
|
||||
return Action::Create;
|
||||
};
|
||||
|
||||
// Row: deletion-marked, ANY phase. Checked before startup state because
|
||||
// there is no `Terminating` phase to match on — a gracefully deleting pod
|
||||
// reports phase `Running` throughout its grace period, so testing startup
|
||||
// first would mistake it for the live no-op row and return an id that
|
||||
// evaporates.
|
||||
if pod.deletion_marked {
|
||||
return Action::AwaitDisappearance {
|
||||
name: pod.name.clone(),
|
||||
};
|
||||
}
|
||||
|
||||
match &pod.startup {
|
||||
// Row: live and started → strict no-op. Start must never kill a live
|
||||
// agent mid-turn, whatever the fingerprint says.
|
||||
Startup::Started => Action::NoOp {
|
||||
agent_id: pod.name.clone(),
|
||||
},
|
||||
|
||||
// Row: terminated → delete residue, then re-enter to create. This is
|
||||
// the normal restart path — how a user revives a reaped agent.
|
||||
Startup::Terminated => Action::Delete {
|
||||
name: pod.name.clone(),
|
||||
fence: pod.fence.clone(),
|
||||
},
|
||||
|
||||
// Row: never started, provably non-recoverable → fenced replace.
|
||||
// "Provably" means a verified absence or a structural defect, never a
|
||||
// reason string.
|
||||
Startup::NeverStartedProvablyBroken => Action::Delete {
|
||||
name: pod.name.clone(),
|
||||
fence: pod.fence.clone(),
|
||||
},
|
||||
|
||||
// Inside the recoverable row: a pull that will not self-heal is
|
||||
// reported immediately instead of consuming the 600s deadline. This
|
||||
// changes reporting and wait behavior only — no delete authority.
|
||||
Startup::NeverStartedPullFailing(failure) => Action::Report {
|
||||
name: pod.name.clone(),
|
||||
failure: *failure,
|
||||
},
|
||||
|
||||
// Row: never started, recoverable — split on create-intent
|
||||
// divergence. Divergence is evidence of a config change the user is
|
||||
// waiting on, and it is the *only* thing that replaces a
|
||||
// never-started pod. Pod age triggers nothing: any finite age
|
||||
// threshold collides with Cluster Autoscaler's own pod-age delays,
|
||||
// and delete-recreate resets exactly the age it keys on.
|
||||
Startup::NeverStartedRecoverable => {
|
||||
if pod.recorded_intent.as_ref() == Some(desired) {
|
||||
Action::Observe {
|
||||
name: pod.name.clone(),
|
||||
}
|
||||
} else {
|
||||
Action::Delete {
|
||||
name: pod.name.clone(),
|
||||
fence: pod.fence.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fp(seed: &str) -> Fingerprint {
|
||||
Fingerprint::for_test(seed)
|
||||
}
|
||||
|
||||
fn pod(startup: Startup, intent: Option<Fingerprint>) -> VerifiedPod {
|
||||
VerifiedPod {
|
||||
name: "buzz-agent-abc123def456".into(),
|
||||
fence: Fence {
|
||||
uid: "uid-1".into(),
|
||||
resource_version: "rv-1".into(),
|
||||
},
|
||||
deletion_marked: false,
|
||||
startup,
|
||||
recorded_intent: intent,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_instance_creates() {
|
||||
assert_eq!(classify(None, &fp("a")), Action::Create);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn started_pod_is_strict_no_op() {
|
||||
let p = pod(Startup::Started, Some(fp("a")));
|
||||
assert_eq!(
|
||||
classify(Some(&p), &fp("a")),
|
||||
Action::NoOp {
|
||||
agent_id: p.name.clone()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// The asymmetry the spec states plainly: an edit cannot reach a started
|
||||
/// pod until it exits, but it *can* reach a never-started one — the
|
||||
/// never-started pod is the one the user is editing because it did not
|
||||
/// start.
|
||||
#[test]
|
||||
fn started_pod_no_ops_even_when_intent_diverges() {
|
||||
let p = pod(Startup::Started, Some(fp("old")));
|
||||
assert_eq!(
|
||||
classify(Some(&p), &fp("new")),
|
||||
Action::NoOp {
|
||||
agent_id: p.name.clone()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// In Kubernetes a gracefully deleting pod stays in phase `Running`. If
|
||||
/// the deletion mark were checked after startup state, this pod would
|
||||
/// take the no-op row and `deploy` would return an id that evaporates.
|
||||
#[test]
|
||||
fn deletion_mark_beats_every_startup_state() {
|
||||
for startup in [
|
||||
Startup::Started,
|
||||
Startup::Terminated,
|
||||
Startup::NeverStartedRecoverable,
|
||||
Startup::NeverStartedProvablyBroken,
|
||||
Startup::NeverStartedPullFailing(PullFailure::Unauthorized),
|
||||
] {
|
||||
let mut p = pod(startup.clone(), Some(fp("a")));
|
||||
p.deletion_marked = true;
|
||||
assert_eq!(
|
||||
classify(Some(&p), &fp("a")),
|
||||
Action::AwaitDisappearance {
|
||||
name: p.name.clone()
|
||||
},
|
||||
"deletion mark ignored for {startup:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminated_pod_is_replaced() {
|
||||
let p = pod(Startup::Terminated, Some(fp("a")));
|
||||
assert_eq!(
|
||||
classify(Some(&p), &fp("a")),
|
||||
Action::Delete {
|
||||
name: p.name.clone(),
|
||||
fence: p.fence.clone()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// A never-started winner is repairable: pod exists, Secret confirmed
|
||||
/// absent, container never started. A later deploy must delete-recreate
|
||||
/// rather than no-op — the test that pins started-not-phase as the no-op
|
||||
/// criterion.
|
||||
#[test]
|
||||
fn provably_broken_never_started_pod_is_replaced() {
|
||||
let p = pod(Startup::NeverStartedProvablyBroken, Some(fp("a")));
|
||||
assert_eq!(
|
||||
classify(Some(&p), &fp("a")),
|
||||
Action::Delete {
|
||||
name: p.name.clone(),
|
||||
fence: p.fence.clone()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// The anti-livelock rule: identical desired intent means *never* delete,
|
||||
/// however long the pod has been pending. Age is not an input to this
|
||||
/// function at all, which is the strongest way to say so.
|
||||
#[test]
|
||||
fn recoverable_with_matching_intent_only_observes() {
|
||||
let p = pod(Startup::NeverStartedRecoverable, Some(fp("same")));
|
||||
assert_eq!(
|
||||
classify(Some(&p), &fp("same")),
|
||||
Action::Observe {
|
||||
name: p.name.clone()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// Repeated identical Starts can never delete anything — the same
|
||||
/// classification, arbitrarily many times.
|
||||
#[test]
|
||||
fn repeated_identical_starts_never_delete() {
|
||||
let p = pod(Startup::NeverStartedRecoverable, Some(fp("same")));
|
||||
for _ in 0..100 {
|
||||
assert!(!matches!(
|
||||
classify(Some(&p), &fp("same")),
|
||||
Action::Delete { .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// The wedge escape: the user corrected a resource request or image, so
|
||||
/// the never-started pod is built from configuration they have since
|
||||
/// changed. Without this row the edit could never materialize.
|
||||
#[test]
|
||||
fn recoverable_with_divergent_intent_is_replaced() {
|
||||
let p = pod(Startup::NeverStartedRecoverable, Some(fp("old")));
|
||||
assert_eq!(
|
||||
classify(Some(&p), &fp("new")),
|
||||
Action::Delete {
|
||||
name: p.name.clone(),
|
||||
fence: p.fence.clone()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// A pod predating the annotation has no recorded intent — that is
|
||||
/// absence, which the spec groups with divergence.
|
||||
#[test]
|
||||
fn missing_recorded_intent_counts_as_divergence() {
|
||||
let p = pod(Startup::NeverStartedRecoverable, None);
|
||||
assert_eq!(
|
||||
classify(Some(&p), &fp("any")),
|
||||
Action::Delete {
|
||||
name: p.name.clone(),
|
||||
fence: p.fence.clone()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// Permanent-looking pull failures report immediately instead of burning
|
||||
/// 600s — and, critically, never delete.
|
||||
#[test]
|
||||
fn pull_failures_report_and_never_delete() {
|
||||
for failure in [
|
||||
PullFailure::Unauthorized,
|
||||
PullFailure::ManifestUnknown,
|
||||
PullFailure::ArchMismatch,
|
||||
] {
|
||||
let p = pod(Startup::NeverStartedPullFailing(failure), Some(fp("a")));
|
||||
// Divergent intent too — still no delete from this arm.
|
||||
for desired in [fp("a"), fp("different")] {
|
||||
assert_eq!(
|
||||
classify(Some(&p), &desired),
|
||||
Action::Report {
|
||||
name: p.name.clone(),
|
||||
failure
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every delete carries the fence from the observation that authorized
|
||||
/// it. Exhaustive over the delete-producing states, so a future arm that
|
||||
/// forgets is caught here rather than in a cluster.
|
||||
#[test]
|
||||
fn every_delete_carries_the_authorizing_fence() {
|
||||
let states = [
|
||||
(Startup::Terminated, fp("a")),
|
||||
(Startup::NeverStartedProvablyBroken, fp("a")),
|
||||
(Startup::NeverStartedRecoverable, fp("divergent")),
|
||||
];
|
||||
for (startup, desired) in states {
|
||||
let p = pod(startup, Some(fp("a")));
|
||||
match classify(Some(&p), &desired) {
|
||||
Action::Delete { fence, .. } => assert_eq!(fence, p.fence),
|
||||
other => panic!("expected Delete, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
//! Cluster auth and client construction (spec §Cluster auth,
|
||||
//! `docs/remote-agents.md:985-995`).
|
||||
//!
|
||||
//! Standard kubeconfig resolution (`$KUBECONFIG` → `~/.kube/config`).
|
||||
//! `provider_config` carries `context` and `namespace` only — credentials
|
||||
//! never transit config (I2, `:196-198`).
|
||||
|
||||
use kube::config::{ExecConfig, KubeConfigOptions, Kubeconfig};
|
||||
use kube::{Client, Config};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Directories prepended to `PATH` before the client is built.
|
||||
///
|
||||
/// Kubeconfigs at Block near-universally authenticate through `exec`
|
||||
/// credential plugins (`aws eks get-token`, `gke-gcloud-auth-plugin`) that
|
||||
/// resolve via `PATH` — and this provider inherits a Finder-launched
|
||||
/// desktop's minimal `PATH`, which contains none of the places those plugins
|
||||
/// install to (`:989-994`).
|
||||
const PATH_PREPEND: [&str; 2] = ["/opt/homebrew/bin", "/usr/local/bin"];
|
||||
|
||||
/// Compute the new `PATH` value: plugin directories first, inherited entries
|
||||
/// after, in order. Pure so the ordering can be tested without mutating the
|
||||
/// process's environment.
|
||||
fn prepended_path(home: Option<&Path>, existing: &std::ffi::OsStr) -> Option<std::ffi::OsString> {
|
||||
let mut dirs: Vec<PathBuf> = PATH_PREPEND.iter().map(PathBuf::from).collect();
|
||||
if let Some(home) = home {
|
||||
dirs.push(home.join(".local/bin"));
|
||||
}
|
||||
// An empty inherited PATH splits into one empty entry, which POSIX
|
||||
// resolves as the current directory — a place a credential plugin should
|
||||
// never be looked up. Drop empties rather than propagate them.
|
||||
dirs.extend(std::env::split_paths(existing).filter(|p| !p.as_os_str().is_empty()));
|
||||
std::env::join_paths(dirs).ok()
|
||||
}
|
||||
|
||||
/// Prepend the plugin directories to this process's `PATH`.
|
||||
///
|
||||
/// Modifies the provider's own environment, which is sound here: one process
|
||||
/// per operation, called before any client or task exists, and the child
|
||||
/// processes that read it are exactly the credential plugins this exists for.
|
||||
fn prepend_plugin_path() {
|
||||
let home = std::env::var_os("HOME");
|
||||
let existing = std::env::var_os("PATH").unwrap_or_default();
|
||||
if let Some(joined) = prepended_path(home.as_ref().map(Path::new), &existing) {
|
||||
std::env::set_var("PATH", joined);
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `command` runnable — an executable on `PATH`, or an existing path?
|
||||
fn resolves_on_path(command: &str) -> bool {
|
||||
if command.contains(std::path::MAIN_SEPARATOR) {
|
||||
return Path::new(command).is_file();
|
||||
}
|
||||
std::env::var_os("PATH")
|
||||
.map(|path| std::env::split_paths(&path).any(|dir| dir.join(command).is_file()))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The exec plugin the selected context authenticates with, if any.
|
||||
///
|
||||
/// Read from the kubeconfig directly rather than from `Config`, which does not
|
||||
/// expose it. A read failure yields `None`: this lookup exists only to improve
|
||||
/// an error message, and must never be the thing that fails a deploy.
|
||||
fn exec_plugin_for(context: Option<&str>) -> Option<ExecConfig> {
|
||||
let kubeconfig = Kubeconfig::read().ok()?;
|
||||
let context_name = context
|
||||
.map(str::to_string)
|
||||
.or_else(|| kubeconfig.current_context.clone())?;
|
||||
let user_name = kubeconfig
|
||||
.contexts
|
||||
.iter()
|
||||
.find(|c| c.name == context_name)
|
||||
.and_then(|c| c.context.as_ref())
|
||||
.and_then(|c| c.user.clone())?;
|
||||
kubeconfig
|
||||
.auth_infos
|
||||
.iter()
|
||||
.find(|a| a.name == user_name)
|
||||
.and_then(|a| a.auth_info.as_ref())
|
||||
.and_then(|a| a.exec.clone())
|
||||
}
|
||||
|
||||
/// Turn a client-construction failure into an error a user can act on.
|
||||
///
|
||||
/// When the context authenticates through an exec plugin that is not on
|
||||
/// `PATH`, that is almost always the cause, and the actionable fact is the
|
||||
/// plugin's name — not a kube-rs error chain (`:994-995`).
|
||||
fn explain(context: Option<&str>, error: &kube::Error) -> String {
|
||||
if let Some(command) = exec_plugin_for(context).and_then(|e| e.command) {
|
||||
if !resolves_on_path(&command) {
|
||||
return format!(
|
||||
"kubeconfig context {} authenticates with the credential plugin \
|
||||
{command:?}, which is not on PATH. Install it or add its \
|
||||
directory to PATH, then try again.",
|
||||
context.unwrap_or("(current)")
|
||||
);
|
||||
}
|
||||
}
|
||||
format!(
|
||||
"could not connect to the cluster using kubeconfig context {}: {error}",
|
||||
context.unwrap_or("(current)")
|
||||
)
|
||||
}
|
||||
|
||||
/// Build a client for the selected context.
|
||||
pub async fn connect(context: Option<&str>) -> Result<Client, String> {
|
||||
prepend_plugin_path();
|
||||
|
||||
let options = KubeConfigOptions {
|
||||
context: context.map(str::to_string),
|
||||
..Default::default()
|
||||
};
|
||||
let config = Config::from_kubeconfig(&options).await.map_err(|e| {
|
||||
// A named context that does not exist is a user typo, and the
|
||||
// kube-rs message for it is already specific.
|
||||
format!(
|
||||
"could not load kubeconfig for context {}: {e}",
|
||||
context.unwrap_or("(current)")
|
||||
)
|
||||
})?;
|
||||
|
||||
Client::try_from(config).map_err(|e| explain(context, &e))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The three plugin directories must end up ahead of the inherited PATH,
|
||||
/// or a Finder-launched desktop never finds `aws`/`gke-gcloud-auth-plugin`.
|
||||
/// Tested on the pure computation: mutating the process PATH here would
|
||||
/// race every other test in the binary.
|
||||
#[test]
|
||||
fn plugin_directories_are_prepended_in_order() {
|
||||
let joined = prepended_path(
|
||||
Some(Path::new("/tmp/fake-home")),
|
||||
std::ffi::OsStr::new("/inherited/bin:/usr/bin"),
|
||||
)
|
||||
.unwrap();
|
||||
let dirs: Vec<PathBuf> = std::env::split_paths(&joined).collect();
|
||||
assert_eq!(
|
||||
dirs,
|
||||
[
|
||||
"/opt/homebrew/bin",
|
||||
"/usr/local/bin",
|
||||
"/tmp/fake-home/.local/bin",
|
||||
"/inherited/bin",
|
||||
"/usr/bin",
|
||||
]
|
||||
.map(PathBuf::from)
|
||||
);
|
||||
}
|
||||
|
||||
/// No `HOME` is not a failure — the two absolute directories still apply.
|
||||
#[test]
|
||||
fn missing_home_still_prepends_the_absolute_directories() {
|
||||
let joined = prepended_path(None, std::ffi::OsStr::new("/inherited/bin")).unwrap();
|
||||
let dirs: Vec<PathBuf> = std::env::split_paths(&joined).collect();
|
||||
assert_eq!(
|
||||
dirs,
|
||||
["/opt/homebrew/bin", "/usr/local/bin", "/inherited/bin"].map(PathBuf::from)
|
||||
);
|
||||
}
|
||||
|
||||
/// An empty inherited PATH must not produce an empty entry, which the
|
||||
/// shell and `resolves_on_path` would both read as the cwd.
|
||||
#[test]
|
||||
fn empty_inherited_path_yields_no_empty_entry() {
|
||||
let joined = prepended_path(None, std::ffi::OsStr::new("")).unwrap();
|
||||
let dirs: Vec<PathBuf> = std::env::split_paths(&joined).collect();
|
||||
assert_eq!(
|
||||
dirs,
|
||||
["/opt/homebrew/bin", "/usr/local/bin"].map(PathBuf::from)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_absolute_paths_directly() {
|
||||
assert!(resolves_on_path("/bin/sh"));
|
||||
assert!(!resolves_on_path("/nonexistent/plugin-binary"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
//! 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(¶ms)
|
||||
.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, ¶ms).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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
//! `provider_config` parsing and the `info` config schema
|
||||
//! (spec §`provider_config` v1 fields, `docs/remote-agents.md:1384-1389`).
|
||||
//!
|
||||
//! Nine fields, all optional except `image` (required at parse time; the
|
||||
//! schema offers the published sprig image as a prefill default — §Image).
|
||||
//! No credential field exists, by I2: cluster auth comes from ambient
|
||||
//! kubeconfig resolution and nothing else (`:196-198`).
|
||||
|
||||
use crate::image::{self, ImageRef};
|
||||
|
||||
/// Resource requests and limits (§Pod shape: 1cpu/2Gi → 2cpu/4Gi, all four
|
||||
/// configurable — `cargo build` in an agent workspace makes 500m/1Gi
|
||||
/// unrealistic).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Resources {
|
||||
pub cpu_request: String,
|
||||
pub memory_request: String,
|
||||
pub cpu_limit: String,
|
||||
pub memory_limit: String,
|
||||
}
|
||||
|
||||
impl Default for Resources {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
cpu_request: "1".into(),
|
||||
memory_request: "2Gi".into(),
|
||||
cpu_limit: "2".into(),
|
||||
memory_limit: "4Gi".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Default inactivity budget: the I5 opt-in (§Auto-Stop). The config field and
|
||||
/// `BUZZ_ACP_EXIT_AFTER_INACTIVITY` are one knob, not two.
|
||||
pub const DEFAULT_INACTIVITY_SECONDS: u64 = 7200;
|
||||
|
||||
/// Default `image` schema prefill: the published sprig image, in tag+digest
|
||||
/// form so the tag stays human-traceable to its git SHA while the digest does
|
||||
/// the pinning (§Image — tag-only refs are rejected; `image::parse` drops the
|
||||
/// tag on normalization). This is a UI prefill, not a baked fallback: `image`
|
||||
/// stays required, an empty value still fails closed, and the value always
|
||||
/// arrives explicitly in `provider_config`, so create-intent fingerprints are
|
||||
/// unaffected by provider upgrades.
|
||||
pub const DEFAULT_IMAGE: &str = "ghcr.io/block/buzz-sprig:sha-6530b58@sha256:17facfc7608d8ddb33bc056c9aaba1098f4ef6abe5655702fbfd7584d1f74d76";
|
||||
|
||||
/// Fixed nonzero UID/GID for the agent container (§Pod shape hardening).
|
||||
pub const RUN_AS_UID: i64 = 10001;
|
||||
pub const RUN_AS_GID: i64 = 10001;
|
||||
|
||||
/// Writable workspace root; also `HOME` and the harness's cwd
|
||||
/// (§Working directory).
|
||||
pub const WORKSPACE_PATH: &str = "/home/agent";
|
||||
|
||||
/// `terminationGracePeriodSeconds` — a declared budget, not a derived sum
|
||||
/// (§Pod shape). Kubernetes' default 30s would SIGKILL the harness mid-drain.
|
||||
pub const TERMINATION_GRACE_SECONDS: i64 = 60;
|
||||
|
||||
/// The only restart policy v1 ships. `OnFailure` is double-gated on the
|
||||
/// harness exit-code contract *and* a crash-loop classification row the state
|
||||
/// machine does not have (`:1121-1139`); until both land the provider refuses
|
||||
/// the combination rather than shipping against an undefended convention.
|
||||
pub const RESTART_POLICY: &str = "Never";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderConfig {
|
||||
/// kubeconfig context; `None` uses the current context.
|
||||
pub context: Option<String>,
|
||||
pub namespace: String,
|
||||
pub image: ImageRef,
|
||||
pub resources: Resources,
|
||||
/// `None` when `inactivity_seconds` was 0 — refused in v1, see [`parse`].
|
||||
pub inactivity_seconds: Option<u64>,
|
||||
pub service_account: Option<String>,
|
||||
}
|
||||
|
||||
/// Read an optional non-empty string field. Rejects non-string scalars rather
|
||||
/// than stringifying them, so a mistyped field is named at the boundary.
|
||||
fn optional_string(cfg: &serde_json::Value, field: &str) -> Result<Option<String>, String> {
|
||||
match cfg.get(field) {
|
||||
None | Some(serde_json::Value::Null) => Ok(None),
|
||||
Some(serde_json::Value::String(s)) if s.trim().is_empty() => Ok(None),
|
||||
Some(serde_json::Value::String(s)) => Ok(Some(s.trim().to_string())),
|
||||
Some(other) => Err(format!(
|
||||
"provider_config.{field} must be a string, got {other}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read an optional unsigned integer. The desktop's form omits blank numeric
|
||||
/// fields rather than sending `""`, but a hand-crafted payload may send a
|
||||
/// numeric string — accept both, refuse anything else.
|
||||
fn optional_u64(cfg: &serde_json::Value, field: &str) -> Result<Option<u64>, String> {
|
||||
match cfg.get(field) {
|
||||
None | Some(serde_json::Value::Null) => Ok(None),
|
||||
Some(serde_json::Value::Number(n)) => n.as_u64().map(Some).ok_or_else(|| {
|
||||
format!("provider_config.{field} must be a non-negative integer, got {n}")
|
||||
}),
|
||||
Some(serde_json::Value::String(s)) if s.trim().is_empty() => Ok(None),
|
||||
Some(serde_json::Value::String(s)) => s.trim().parse::<u64>().map(Some).map_err(|_| {
|
||||
format!("provider_config.{field} must be a non-negative integer, got {s:?}")
|
||||
}),
|
||||
Some(other) => Err(format!(
|
||||
"provider_config.{field} must be a non-negative integer, got {other}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A Kubernetes namespace name: RFC 1123 label, ≤63 chars. Validated here so a
|
||||
/// typo fails with a named field instead of an apiserver rejection partway
|
||||
/// through a deploy.
|
||||
fn valid_namespace(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name.len() <= 63
|
||||
&& name.starts_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit())
|
||||
&& name.ends_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit())
|
||||
&& name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
|
||||
}
|
||||
|
||||
pub fn parse(cfg: &serde_json::Value) -> Result<ProviderConfig, String> {
|
||||
if !cfg.is_object() && !cfg.is_null() {
|
||||
return Err("provider_config must be a JSON object".to_string());
|
||||
}
|
||||
|
||||
let namespace = optional_string(cfg, "namespace")?.ok_or_else(|| {
|
||||
"provider_config.namespace is required: the info schema supplies a \
|
||||
generated default, so an empty value means the form was cleared"
|
||||
.to_string()
|
||||
})?;
|
||||
if !valid_namespace(&namespace) {
|
||||
return Err(format!(
|
||||
"provider_config.namespace {namespace:?} is not a valid Kubernetes \
|
||||
namespace (lowercase alphanumerics and '-', ≤63 characters)"
|
||||
));
|
||||
}
|
||||
|
||||
let image = image::parse(optional_string(cfg, "image")?.unwrap_or_default().as_str())?;
|
||||
|
||||
let defaults = Resources::default();
|
||||
let resources = Resources {
|
||||
cpu_request: optional_string(cfg, "cpu_request")?.unwrap_or(defaults.cpu_request),
|
||||
memory_request: optional_string(cfg, "memory_request")?.unwrap_or(defaults.memory_request),
|
||||
cpu_limit: optional_string(cfg, "cpu_limit")?.unwrap_or(defaults.cpu_limit),
|
||||
memory_limit: optional_string(cfg, "memory_limit")?.unwrap_or(defaults.memory_limit),
|
||||
};
|
||||
|
||||
// `inactivity_seconds: 0` is a legal, blessed value in the spec (§Auto-Stop)
|
||||
// meaning "no auto-stop" — but it selects `restartPolicy: OnFailure`, which
|
||||
// §Pod shape forbids until the harness exit-code contract is pinned AND the
|
||||
// state machine gains a crash-loop row. Refusing the *combination* is what
|
||||
// the spec asks for; silently downgrading to `Never` would ship an
|
||||
// indefinite agent that dies on its first crash.
|
||||
let inactivity_seconds = match optional_u64(cfg, "inactivity_seconds")? {
|
||||
None => Some(DEFAULT_INACTIVITY_SECONDS),
|
||||
Some(0) => {
|
||||
return Err(
|
||||
"provider_config.inactivity_seconds: 0 (indefinite lifetime) is not \
|
||||
supported in this version: it requires restartPolicy OnFailure, \
|
||||
which is gated on the harness exit-code contract. Set a positive \
|
||||
number of seconds."
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
Some(n) => Some(n),
|
||||
};
|
||||
|
||||
Ok(ProviderConfig {
|
||||
context: optional_string(cfg, "context")?,
|
||||
namespace,
|
||||
image,
|
||||
resources,
|
||||
inactivity_seconds,
|
||||
service_account: optional_string(cfg, "service_account")?,
|
||||
})
|
||||
}
|
||||
|
||||
/// A fresh `buzz-agents-<rand6>` namespace default.
|
||||
///
|
||||
/// Computed per `info` call, which is how "random default" is satisfied with
|
||||
/// zero UI changes: the schema's `default` prefills the form (§K8s Namespace).
|
||||
pub fn generated_namespace() -> String {
|
||||
use rand::RngExt;
|
||||
const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let mut rng = rand::rng();
|
||||
let suffix: String = (0..6)
|
||||
.map(|_| ALPHABET[rng.random_range(0..ALPHABET.len())] as char)
|
||||
.collect();
|
||||
format!("buzz-agents-{suffix}")
|
||||
}
|
||||
|
||||
/// The `config_schema` returned by `info`. Drives the UI form:
|
||||
/// `properties[*].default` prefill, scalar coercion, `required` gating
|
||||
/// (`:407-411`).
|
||||
pub fn config_schema() -> serde_json::Value {
|
||||
let defaults = Resources::default();
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"context": {
|
||||
"type": "string",
|
||||
"title": "Kubeconfig context",
|
||||
"description": "Context from your kubeconfig. Leave empty to use the current context."
|
||||
},
|
||||
"namespace": {
|
||||
"type": "string",
|
||||
"title": "Namespace",
|
||||
"description": "Created if it does not exist.",
|
||||
"default": generated_namespace()
|
||||
},
|
||||
"image": {
|
||||
"type": "string",
|
||||
"title": "Agent image",
|
||||
"description": "Digest-pinned image containing the buzz-acp runtime ABI, e.g. ghcr.io/block/buzz-sprig@sha256:<digest>. Tags alone are not accepted: this pod holds the agent's private key.",
|
||||
"default": DEFAULT_IMAGE
|
||||
},
|
||||
"cpu_request": {
|
||||
"type": "string", "title": "CPU request", "default": defaults.cpu_request
|
||||
},
|
||||
"memory_request": {
|
||||
"type": "string", "title": "Memory request", "default": defaults.memory_request
|
||||
},
|
||||
"cpu_limit": {
|
||||
"type": "string", "title": "CPU limit", "default": defaults.cpu_limit
|
||||
},
|
||||
"memory_limit": {
|
||||
"type": "string", "title": "Memory limit", "default": defaults.memory_limit
|
||||
},
|
||||
"inactivity_seconds": {
|
||||
"type": "number",
|
||||
"title": "Stop after inactivity (seconds)",
|
||||
"description": "The agent exits after this long with no work, and can be started again at any time.",
|
||||
"default": DEFAULT_INACTIVITY_SECONDS
|
||||
},
|
||||
"service_account": {
|
||||
"type": "string",
|
||||
"title": "Service account",
|
||||
"description": "Scheduling/RBAC identity only. No API token is mounted."
|
||||
}
|
||||
},
|
||||
"required": ["namespace", "image"]
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn digest_ref() -> String {
|
||||
format!("ghcr.io/block/buzz-sprig@sha256:{}", "a".repeat(64))
|
||||
}
|
||||
|
||||
fn minimal() -> serde_json::Value {
|
||||
serde_json::json!({"namespace": "buzz-agents-abc123", "image": digest_ref()})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_spec_defaults() {
|
||||
let c = parse(&minimal()).unwrap();
|
||||
assert_eq!(c.resources, Resources::default());
|
||||
assert_eq!(c.resources.cpu_request, "1");
|
||||
assert_eq!(c.resources.memory_request, "2Gi");
|
||||
assert_eq!(c.resources.cpu_limit, "2");
|
||||
assert_eq!(c.resources.memory_limit, "4Gi");
|
||||
assert_eq!(c.inactivity_seconds, Some(DEFAULT_INACTIVITY_SECONDS));
|
||||
assert_eq!(c.context, None);
|
||||
assert_eq!(c.service_account, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_four_resources_are_configurable() {
|
||||
let mut cfg = minimal();
|
||||
cfg["cpu_request"] = "500m".into();
|
||||
cfg["memory_request"] = "1Gi".into();
|
||||
cfg["cpu_limit"] = "4".into();
|
||||
cfg["memory_limit"] = "8Gi".into();
|
||||
let c = parse(&cfg).unwrap();
|
||||
assert_eq!(
|
||||
c.resources,
|
||||
Resources {
|
||||
cpu_request: "500m".into(),
|
||||
memory_request: "1Gi".into(),
|
||||
cpu_limit: "4".into(),
|
||||
memory_limit: "8Gi".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// The desktop's form omits blank numeric fields; a hand-crafted payload
|
||||
/// may send a numeric string. Both must mean the same thing.
|
||||
#[test]
|
||||
fn inactivity_accepts_number_string_and_omission() {
|
||||
let mut cfg = minimal();
|
||||
cfg["inactivity_seconds"] = serde_json::json!(300);
|
||||
assert_eq!(parse(&cfg).unwrap().inactivity_seconds, Some(300));
|
||||
|
||||
cfg["inactivity_seconds"] = serde_json::json!("300");
|
||||
assert_eq!(parse(&cfg).unwrap().inactivity_seconds, Some(300));
|
||||
|
||||
cfg["inactivity_seconds"] = serde_json::json!("");
|
||||
assert_eq!(
|
||||
parse(&cfg).unwrap().inactivity_seconds,
|
||||
Some(DEFAULT_INACTIVITY_SECONDS)
|
||||
);
|
||||
}
|
||||
|
||||
/// Indefinite lifetime selects `OnFailure`, which is gated. Refuse rather
|
||||
/// than silently downgrade — a downgraded agent dies on its first crash
|
||||
/// while the user believes they asked for indefinite.
|
||||
#[test]
|
||||
fn refuses_indefinite_lifetime() {
|
||||
let mut cfg = minimal();
|
||||
cfg["inactivity_seconds"] = serde_json::json!(0);
|
||||
let err = parse(&cfg).unwrap_err();
|
||||
assert!(err.contains("inactivity_seconds"), "got: {err}");
|
||||
assert!(
|
||||
err.contains("OnFailure"),
|
||||
"error should name the gate: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_negative_and_non_numeric_inactivity() {
|
||||
for bad in [
|
||||
serde_json::json!(-1),
|
||||
serde_json::json!(1.5),
|
||||
serde_json::json!("soon"),
|
||||
serde_json::json!(true),
|
||||
] {
|
||||
let mut cfg = minimal();
|
||||
cfg["inactivity_seconds"] = bad.clone();
|
||||
assert!(parse(&cfg).is_err(), "accepted {bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_is_required_and_must_be_digest_pinned() {
|
||||
let mut cfg = minimal();
|
||||
cfg.as_object_mut().unwrap().remove("image");
|
||||
assert!(parse(&cfg).unwrap_err().contains("provider_config.image"));
|
||||
|
||||
cfg["image"] = "ghcr.io/block/buzz-sprig:latest".into();
|
||||
assert!(parse(&cfg).unwrap_err().contains("digest-pinned"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_namespace_names() {
|
||||
for bad in [
|
||||
"",
|
||||
"Buzz-Agents",
|
||||
"-leading",
|
||||
"trailing-",
|
||||
"has_underscore",
|
||||
&"n".repeat(64),
|
||||
] {
|
||||
let mut cfg = minimal();
|
||||
cfg["namespace"] = bad.into();
|
||||
assert!(parse(&cfg).is_err(), "accepted namespace {bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// I2 corollary: there is no config path for cluster credentials, so a
|
||||
/// caller that tries to supply one gets no effect from it. Asserting the
|
||||
/// parsed struct has no such field is the closest a test can get to
|
||||
/// "the type makes it impossible".
|
||||
#[test]
|
||||
fn credential_fields_have_no_effect() {
|
||||
let mut cfg = minimal();
|
||||
cfg["token"] = "hunter2".into();
|
||||
cfg["client_key"] = "hunter2".into();
|
||||
let c = parse(&cfg).unwrap();
|
||||
let rendered = format!("{c:?}");
|
||||
assert!(
|
||||
!rendered.contains("hunter2"),
|
||||
"config absorbed a credential: {rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mistyped_string_fields_are_named() {
|
||||
let mut cfg = minimal();
|
||||
cfg["namespace"] = serde_json::json!(42);
|
||||
assert!(parse(&cfg)
|
||||
.unwrap_err()
|
||||
.contains("provider_config.namespace"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_namespaces_are_fresh_and_valid() {
|
||||
let a = generated_namespace();
|
||||
let b = generated_namespace();
|
||||
assert_ne!(a, b, "namespace default is not random");
|
||||
assert!(valid_namespace(&a), "{a} is not a valid namespace");
|
||||
assert!(a.starts_with("buzz-agents-"));
|
||||
assert_eq!(a.len(), "buzz-agents-".len() + 6);
|
||||
}
|
||||
|
||||
/// The schema's own namespace default must be a value the parser accepts —
|
||||
/// otherwise the UI prefills a form that fails on submit.
|
||||
#[test]
|
||||
fn schema_default_namespace_round_trips_through_parse() {
|
||||
let schema = config_schema();
|
||||
let default = schema["properties"]["namespace"]["default"]
|
||||
.as_str()
|
||||
.unwrap();
|
||||
let cfg = serde_json::json!({"namespace": default, "image": digest_ref()});
|
||||
assert_eq!(parse(&cfg).unwrap().namespace, default);
|
||||
}
|
||||
|
||||
/// Same guarantee for the image prefill: the schema's default must be a
|
||||
/// value `image::parse` accepts, or the UI prefills a form that fails on
|
||||
/// submit. Its tag+digest form normalizes to the tagless canonical form.
|
||||
#[test]
|
||||
fn schema_default_image_round_trips_through_parse() {
|
||||
let schema = config_schema();
|
||||
let default = schema["properties"]["image"]["default"].as_str().unwrap();
|
||||
assert_eq!(default, DEFAULT_IMAGE);
|
||||
let cfg = serde_json::json!({"namespace": "buzz-agents-abc123", "image": default});
|
||||
let parsed = parse(&cfg).unwrap();
|
||||
assert_eq!(
|
||||
parsed.image.as_str(),
|
||||
"ghcr.io/block/buzz-sprig@sha256:17facfc7608d8ddb33bc056c9aaba1098f4ef6abe5655702fbfd7584d1f74d76"
|
||||
);
|
||||
}
|
||||
|
||||
/// Nine fields exactly (§`provider_config` v1 fields). The cap is 20; the
|
||||
/// count is pinned so a field added without a spec change is caught here.
|
||||
#[test]
|
||||
fn schema_declares_exactly_the_nine_v1_fields() {
|
||||
let schema = config_schema();
|
||||
let props = schema["properties"].as_object().unwrap();
|
||||
let mut keys: Vec<&str> = props.keys().map(String::as_str).collect();
|
||||
keys.sort();
|
||||
assert_eq!(
|
||||
keys,
|
||||
[
|
||||
"context",
|
||||
"cpu_limit",
|
||||
"cpu_request",
|
||||
"image",
|
||||
"inactivity_seconds",
|
||||
"memory_limit",
|
||||
"memory_request",
|
||||
"namespace",
|
||||
"service_account"
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
schema["required"],
|
||||
serde_json::json!(["namespace", "image"])
|
||||
);
|
||||
}
|
||||
|
||||
/// I2's key lint rejects any field whose word-split contains
|
||||
/// secret|password|token|key|credential. A schema field tripping it would
|
||||
/// make every deploy fail validation desktop-side (`:185-198`).
|
||||
#[test]
|
||||
fn no_schema_field_trips_the_i2_key_lint() {
|
||||
const BANNED: [&str; 5] = ["secret", "password", "token", "key", "credential"];
|
||||
let schema = config_schema();
|
||||
for field in schema["properties"].as_object().unwrap().keys() {
|
||||
for word in field.split(['_', '-']) {
|
||||
assert!(
|
||||
!BANNED.contains(&word),
|
||||
"field {field:?} contains I2-banned word {word:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,732 @@
|
||||
//! Building the pod environment (spec §Launch data, §Entrypoint mapping table).
|
||||
//!
|
||||
//! The three tiers are resolved *here*, before serialization, because a
|
||||
//! Kubernetes Secret's `data` is a flat map with no precedence of its own: if
|
||||
//! two tiers supplied the same key, whichever entry landed in the map would
|
||||
//! win silently. Resolving in-provider makes later-wins explicit and testable.
|
||||
|
||||
use crate::wire::{AgentPayload, LaunchBlock};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Keys the authoritative tier owns.
|
||||
///
|
||||
/// Load-bearing, not documentation: tier 3 *clears* every key on this list
|
||||
/// before writing its own values, so a key the authoritative tier has no value
|
||||
/// for is **removed** rather than left holding a lower-tier value. Plain
|
||||
/// overwrite is not enough — most of these are written conditionally
|
||||
/// (`BUZZ_ACP_AGENT_ARGS` only when `launch.args` is non-empty,
|
||||
/// `BUZZ_ACP_RESPOND_TO` only when set), and without the clear, a lower tier
|
||||
/// could supply the value for exactly the cases the authoritative tier stays
|
||||
/// silent on. Clearing is also what the local spawn does: the desktop strips
|
||||
/// reserved keys from user env before the authoritative layer is written
|
||||
/// (`env_vars.rs:54-57`), so absent-means-absent in both paths.
|
||||
const AUTHORITATIVE_KEYS: &[&str] = &[
|
||||
"BUZZ_RELAY_URL",
|
||||
"BUZZ_PRIVATE_KEY",
|
||||
"NOSTR_PRIVATE_KEY",
|
||||
"BUZZ_AUTH_TAG",
|
||||
"BUZZ_ACP_AGENT_OWNER",
|
||||
"BUZZ_ACP_AGENT_COMMAND",
|
||||
"BUZZ_ACP_AGENT_ARGS",
|
||||
"BUZZ_ACP_RESPOND_TO",
|
||||
"BUZZ_ACP_RESPOND_TO_ALLOWLIST",
|
||||
"BUZZ_ACP_MCP_COMMAND",
|
||||
"BUZZ_ACP_EXIT_AFTER_INACTIVITY",
|
||||
START_NONCE_KEY,
|
||||
];
|
||||
|
||||
/// The attempt's generation, as the harness sees it. Also the Secret's name
|
||||
/// suffix — one generation, one identity — so the reconciler restamps this on
|
||||
/// every create attempt rather than letting the caller's value persist across
|
||||
/// a retry.
|
||||
pub const START_NONCE_KEY: &str = "BUZZ_MANAGED_AGENT_START_NONCE";
|
||||
|
||||
/// Presence is the only remote liveness signal (I3), so a launch that
|
||||
/// suppresses it is non-conforming (L1 item 2) — and unlike a reserved-key
|
||||
/// collision, there is no "authoritative value" to overwrite it with. Refuse.
|
||||
const FORBIDDEN_KEY: &str = "BUZZ_ACP_NO_PRESENCE";
|
||||
|
||||
/// Kubernetes' own cap on the summed value bytes of a Secret
|
||||
/// (`MaxSecretSize`, `pkg/apis/core/types.go`). Enforced here so an oversized
|
||||
/// env surfaces as a named provider error rather than an apiserver rejection
|
||||
/// partway through a deploy.
|
||||
const MAX_SECRET_BYTES: usize = 1024 * 1024;
|
||||
|
||||
/// A POSIX-shaped env var name: `[A-Za-z_][A-Za-z0-9_]*`.
|
||||
///
|
||||
/// Kubernetes validates Secret *keys* as `IsConfigMapKey`
|
||||
/// (`[-._a-zA-Z0-9]+`), which is looser — `foo.bar` is a legal Secret key.
|
||||
/// What the kubelet then does with such a key **changed between versions**:
|
||||
/// through 1.29 it filtered invalid env names out of `envFrom` and emitted an
|
||||
/// `InvalidEnvironmentVariableNames` warning event
|
||||
/// (`pkg/kubelet/kubelet_pods.go:646,654` at v1.29.0); from 1.30 that filter
|
||||
/// is gone (KEP-4369) and the key is injected verbatim. The same manifest
|
||||
/// would silently drop a variable on one cluster and set it on another, so we
|
||||
/// fail closed on the provider side and get one deterministic behavior.
|
||||
fn is_posix_env_key(key: &str) -> bool {
|
||||
let mut chars = key.chars();
|
||||
match chars.next() {
|
||||
Some(c) if c == '_' || c.is_ascii_alphabetic() => {}
|
||||
_ => return false,
|
||||
}
|
||||
chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
|
||||
}
|
||||
|
||||
/// An identity component (L1 item 1) is present only if it is nonempty after
|
||||
/// trimming — and the **trimmed form is what gets stored**. The validator and
|
||||
/// the writer must never disagree about the value: a guard that accepts
|
||||
/// `" wss://relay "` and then writes it with the padding intact has only
|
||||
/// moved the failure from a loud refusal to a connect error in the harness.
|
||||
fn identity_component(value: &str) -> Option<&str> {
|
||||
let trimmed = value.trim();
|
||||
(!trimmed.is_empty()).then_some(trimmed)
|
||||
}
|
||||
|
||||
/// The harness's `allowlist` gate mode, spelled as the desktop serializes
|
||||
/// `RespondTo` (kebab-case) and as `buzz-acp`'s CLI parses it.
|
||||
const RESPOND_TO_ALLOWLIST: &str = "allowlist";
|
||||
|
||||
/// Every gate mode `buzz-acp` accepts, spelled as its `clap::ValueEnum` parses
|
||||
/// them (`config.rs:95-101`, kebab-case via `RespondTo`'s `Display`).
|
||||
///
|
||||
/// Deliberately the **harness's** four and not the desktop's three: the desktop
|
||||
/// rejects `nobody` on purpose (`managed_agents/types.rs:871-880`), but the
|
||||
/// harness starts fine with it. This guard exists to cover non-desktop callers,
|
||||
/// so inheriting a desktop-only narrowing would refuse a launch that works.
|
||||
const RESPOND_TO_MODES: [&str; 4] = ["owner-only", RESPOND_TO_ALLOWLIST, "anyone", "nobody"];
|
||||
|
||||
/// Refuse a respond-to gate the harness will reject at config parse.
|
||||
///
|
||||
/// The local spawn path re-validates this before spawning — "doing it here
|
||||
/// means we never spawn a doomed process" (`runtime.rs:378`) — but the deploy
|
||||
/// path projects the record's fields straight through. Without this, a gate
|
||||
/// the harness refuses becomes a pod that exits 1 at startup; `restartPolicy:
|
||||
/// Never` turns that into `Terminated` → `Delete` → recreate, and each cycle
|
||||
/// leaves a Secret the in-call path never reaps (only a later deploy's orphan
|
||||
/// sweep does, at `ORPHAN_SECRET_MIN_AGE_SECS`). The user-visible ending is
|
||||
/// "startup not confirmed", indistinguishable from a slow cluster.
|
||||
///
|
||||
/// Mirrors `buzz-acp`'s own rules exactly (`config.rs:95-101,996-1004,629-641`),
|
||||
/// deliberately including their asymmetry: the allowlist is validated **only**
|
||||
/// in allowlist mode, and merely warned about otherwise. Validating it in
|
||||
/// every mode would refuse a deploy whose identical local spawn succeeds —
|
||||
/// and a stale list is already harmless here, since
|
||||
/// `BUZZ_ACP_RESPOND_TO_ALLOWLIST` is an authoritative key that tier 3 clears.
|
||||
fn validate_respond_to_gate(respond_to: &str, allowlist: Option<&[String]>) -> Result<(), String> {
|
||||
// Exact, untrimmed: `clap` does not trim, so `" allowlist "` is `rc=2` at
|
||||
// the harness — a parse failure even earlier than the config errors below.
|
||||
if !RESPOND_TO_MODES.contains(&respond_to) {
|
||||
return Err(format!(
|
||||
"deploy refused: respond_to {respond_to:?} is not a mode the \
|
||||
harness accepts (expected one of {}) — the pod would fail to \
|
||||
parse its arguments, be replaced, and leave a Secret behind on \
|
||||
every attempt",
|
||||
RESPOND_TO_MODES.join(", ")
|
||||
));
|
||||
}
|
||||
if respond_to != RESPOND_TO_ALLOWLIST {
|
||||
return Ok(());
|
||||
}
|
||||
let entries = allowlist.unwrap_or_default();
|
||||
if entries.is_empty() {
|
||||
return Err(format!(
|
||||
"deploy refused: respond_to is {RESPOND_TO_ALLOWLIST:?} but the \
|
||||
allowlist is empty — the harness refuses this at startup, so the \
|
||||
pod would fail, be replaced, and leave a Secret behind on every \
|
||||
attempt"
|
||||
));
|
||||
}
|
||||
for entry in entries {
|
||||
let trimmed = entry.trim();
|
||||
if trimmed.len() != 64 || !trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(format!(
|
||||
"deploy refused: invalid pubkey in respond_to_allowlist: \
|
||||
{entry:?} (must be exactly 64 hex characters)"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Inputs the provider itself supplies to the authoritative tier.
|
||||
pub struct AuthoritativeInputs<'a> {
|
||||
/// The attempt's generation token — also the Secret's name suffix, so the
|
||||
/// lifecycle correlator and the Secret generation are one identity.
|
||||
pub generation: &'a str,
|
||||
/// Resolved from `provider_config.inactivity_seconds`; `None` when the
|
||||
/// indefinite opt-in was chosen (which this version refuses elsewhere).
|
||||
pub inactivity_seconds: Option<u64>,
|
||||
}
|
||||
|
||||
/// Resolve the full pod environment.
|
||||
///
|
||||
/// Order is the spec's, and the function body is deliberately three writes in
|
||||
/// that order — tier 1, tier 2, tier 3 — so "later wins" is visible rather
|
||||
/// than argued.
|
||||
pub fn build_env(
|
||||
agent: &AgentPayload,
|
||||
auth: AuthoritativeInputs<'_>,
|
||||
) -> Result<BTreeMap<String, String>, String> {
|
||||
let default_launch = LaunchBlock::default();
|
||||
let launch = agent.launch.as_ref().unwrap_or(&default_launch);
|
||||
|
||||
let mut env: BTreeMap<String, String> = BTreeMap::new();
|
||||
|
||||
// Tier 1 — overridable behavior defaults.
|
||||
env.extend(launch.policy_env.clone());
|
||||
|
||||
// Tier 2 — user/layered env. The descriptor already merged
|
||||
// global < persona < agent, so `agent.env_vars` is NOT re-merged on top
|
||||
// (§Launch data tier 2) — doing so would resurrect a layer the desktop
|
||||
// already resolved. When the desktop predates the `launch` block we fall
|
||||
// back to the legacy field, which is the only case it is the truth.
|
||||
if agent.launch.is_some() {
|
||||
env.extend(launch.env.clone());
|
||||
} else {
|
||||
env.extend(agent.env_vars.clone());
|
||||
}
|
||||
|
||||
// Validate what the lower tiers contributed, before the authoritative
|
||||
// tier overwrites any of it. A reserved-key collision is NOT fatal: the
|
||||
// spec's precedence is later-wins, so tier 3 simply overwrites it, which
|
||||
// is exactly what a local spawn does. Only a key that has no
|
||||
// authoritative counterpart to overwrite it — presence suppression — is
|
||||
// a refusal.
|
||||
for key in env.keys() {
|
||||
if !is_posix_env_key(key) {
|
||||
return Err(format!(
|
||||
"env key {key:?} is not a POSIX environment variable name \
|
||||
([A-Za-z_][A-Za-z0-9_]*); Kubernetes would treat it \
|
||||
inconsistently across cluster versions"
|
||||
));
|
||||
}
|
||||
if key.eq_ignore_ascii_case(FORBIDDEN_KEY) {
|
||||
return Err(format!(
|
||||
"{FORBIDDEN_KEY} must not be set on a remote agent: presence \
|
||||
is the only signal that a remote agent is alive"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3 — authoritative. Every key it owns is cleared first, then the
|
||||
// values it has are written, so it wins at a key whether or not it has a
|
||||
// value there (see [`AUTHORITATIVE_KEYS`]).
|
||||
for key in AUTHORITATIVE_KEYS {
|
||||
env.remove(*key);
|
||||
}
|
||||
// Identity comes from top-level payload fields, never from `env_vars`
|
||||
// (§Reserved-key rule). All three components must be nonempty: an agent
|
||||
// that cannot reach a relay is the identityless launch L1 item 1 exists to
|
||||
// prevent, and a blank field would otherwise sail through into the Secret
|
||||
// and produce a pod that starts, fails to connect, and looks like a
|
||||
// network problem.
|
||||
let Some(relay_url) = identity_component(&agent.relay_url) else {
|
||||
return Err("deploy refused: relay_url is empty — the agent would have \
|
||||
no relay to connect to"
|
||||
.to_string());
|
||||
};
|
||||
env.insert("BUZZ_RELAY_URL".into(), relay_url.to_string());
|
||||
env.insert("BUZZ_PRIVATE_KEY".into(), agent.private_key_nsec.clone());
|
||||
// The git credential/signing helpers read NOSTR_PRIVATE_KEY.
|
||||
env.insert("NOSTR_PRIVATE_KEY".into(), agent.private_key_nsec.clone());
|
||||
|
||||
// Owner: at least one of these must resolve, or the harness cannot match
|
||||
// `!shutdown` and §Stop describes a mechanism that does not work.
|
||||
let auth_tag = agent.auth_tag.as_deref().and_then(identity_component);
|
||||
let owner = launch.owner_pubkey.as_deref().and_then(identity_component);
|
||||
match (auth_tag, owner) {
|
||||
(None, None) => {
|
||||
return Err("deploy refused: neither auth_tag nor launch.owner_pubkey \
|
||||
resolved — without an owner the agent cannot honor \
|
||||
!shutdown"
|
||||
.to_string())
|
||||
}
|
||||
(tag, own) => {
|
||||
if let Some(t) = tag {
|
||||
env.insert("BUZZ_AUTH_TAG".into(), t.to_string());
|
||||
}
|
||||
if let Some(o) = own {
|
||||
env.insert("BUZZ_ACP_AGENT_OWNER".into(), o.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The harness and MCP binaries are resolved against the *image's* PATH.
|
||||
// A host path forwarded from the desktop is guaranteed absent in the
|
||||
// container (§Launch data, host-resolved values).
|
||||
if let Some(command) = launch.command.as_deref().filter(|c| !c.is_empty()) {
|
||||
env.insert("BUZZ_ACP_AGENT_COMMAND".into(), command.to_string());
|
||||
}
|
||||
if !launch.args.is_empty() {
|
||||
// Comma-joined because that is what the harness's CLI parser decodes,
|
||||
// and what the desktop's local spawn does. An argument containing a
|
||||
// comma is unrepresentable in both paths; inventing an escaping
|
||||
// scheme here would produce args the harness cannot decode.
|
||||
env.insert("BUZZ_ACP_AGENT_ARGS".into(), launch.args.join(","));
|
||||
}
|
||||
env.insert("BUZZ_ACP_MCP_COMMAND".into(), "buzz-dev-mcp".into());
|
||||
|
||||
if let Some(respond_to) = agent.respond_to.as_deref().filter(|s| !s.is_empty()) {
|
||||
validate_respond_to_gate(respond_to, agent.respond_to_allowlist.as_deref())?;
|
||||
env.insert("BUZZ_ACP_RESPOND_TO".into(), respond_to.to_string());
|
||||
}
|
||||
if let Some(list) = agent
|
||||
.respond_to_allowlist
|
||||
.as_ref()
|
||||
.filter(|l| !l.is_empty())
|
||||
{
|
||||
env.insert("BUZZ_ACP_RESPOND_TO_ALLOWLIST".into(), list.join(","));
|
||||
}
|
||||
|
||||
if let Some(secs) = auth.inactivity_seconds {
|
||||
env.insert("BUZZ_ACP_EXIT_AFTER_INACTIVITY".into(), secs.to_string());
|
||||
}
|
||||
// The generation token doubles as the lifecycle-frame correlator, so pod
|
||||
// logs and observer frames share one identity (§K8s Secrets).
|
||||
env.insert(START_NONCE_KEY.into(), auth.generation.to_string());
|
||||
|
||||
let total: usize = env.values().map(String::len).sum();
|
||||
if total > MAX_SECRET_BYTES {
|
||||
return Err(format!(
|
||||
"agent environment is {total} bytes; Kubernetes caps Secret data \
|
||||
at {MAX_SECRET_BYTES}"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(env)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn payload_json(extra_agent: serde_json::Value) -> AgentPayload {
|
||||
let mut agent = serde_json::json!({
|
||||
"name": "a",
|
||||
"relay_url": "wss://relay.example",
|
||||
"private_key_nsec": "nsec1example",
|
||||
"auth_tag": "tag-1",
|
||||
});
|
||||
let (serde_json::Value::Object(base), serde_json::Value::Object(extra)) =
|
||||
(&mut agent, extra_agent)
|
||||
else {
|
||||
panic!("expected objects")
|
||||
};
|
||||
base.extend(extra);
|
||||
serde_json::from_value(agent).unwrap()
|
||||
}
|
||||
|
||||
fn build(agent: &AgentPayload) -> Result<BTreeMap<String, String>, String> {
|
||||
build_env(
|
||||
agent,
|
||||
AuthoritativeInputs {
|
||||
generation: "gen0001",
|
||||
inactivity_seconds: Some(7200),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_comes_from_top_level_fields() {
|
||||
let env = build(&payload_json(serde_json::json!({}))).unwrap();
|
||||
assert_eq!(env["BUZZ_RELAY_URL"], "wss://relay.example");
|
||||
assert_eq!(env["BUZZ_PRIVATE_KEY"], "nsec1example");
|
||||
assert_eq!(env["NOSTR_PRIVATE_KEY"], "nsec1example");
|
||||
assert_eq!(env["BUZZ_AUTH_TAG"], "tag-1");
|
||||
}
|
||||
|
||||
/// Wren's amendment, and the spec's later-wins rule: a lower tier that
|
||||
/// spoofs an authoritative key is *overwritten*, not refused. Refusing
|
||||
/// would diverge from the local spawn, where the same env is written
|
||||
/// before the authoritative layer and simply loses.
|
||||
#[test]
|
||||
fn lower_tiers_cannot_spoof_authoritative_values() {
|
||||
let agent = payload_json(serde_json::json!({
|
||||
"launch": {
|
||||
"command": "goose",
|
||||
"policy_env": {
|
||||
"BUZZ_PRIVATE_KEY": "nsec1attacker",
|
||||
"BUZZ_MANAGED_AGENT_START_NONCE": "forged",
|
||||
},
|
||||
"env": {
|
||||
"BUZZ_RELAY_URL": "wss://attacker.example",
|
||||
"NOSTR_PRIVATE_KEY": "nsec1attacker",
|
||||
"BUZZ_AUTH_TAG": "forged-tag",
|
||||
"BUZZ_ACP_AGENT_OWNER": "cafe",
|
||||
"BUZZ_ACP_AGENT_COMMAND": "/bin/sh",
|
||||
"BUZZ_ACP_MCP_COMMAND": "/bin/sh",
|
||||
"BUZZ_ACP_EXIT_AFTER_INACTIVITY": "0",
|
||||
},
|
||||
"owner_pubkey": "beef"
|
||||
}
|
||||
}));
|
||||
let env = build(&agent).unwrap();
|
||||
assert_eq!(env["BUZZ_PRIVATE_KEY"], "nsec1example");
|
||||
assert_eq!(env["NOSTR_PRIVATE_KEY"], "nsec1example");
|
||||
assert_eq!(env["BUZZ_RELAY_URL"], "wss://relay.example");
|
||||
assert_eq!(env["BUZZ_AUTH_TAG"], "tag-1");
|
||||
assert_eq!(env["BUZZ_ACP_AGENT_OWNER"], "beef");
|
||||
assert_eq!(env["BUZZ_ACP_AGENT_COMMAND"], "goose");
|
||||
assert_eq!(env["BUZZ_ACP_MCP_COMMAND"], "buzz-dev-mcp");
|
||||
assert_eq!(env["BUZZ_ACP_EXIT_AFTER_INACTIVITY"], "7200");
|
||||
assert_eq!(env["BUZZ_MANAGED_AGENT_START_NONCE"], "gen0001");
|
||||
}
|
||||
|
||||
/// Tier 1 is *overridable* — user env beats policy defaults, matching the
|
||||
/// local spawn, where the user layer is written after them. Getting this
|
||||
/// backwards would make remote agents ignore overrides local agents honor.
|
||||
#[test]
|
||||
fn user_env_overrides_policy_defaults() {
|
||||
let agent = payload_json(serde_json::json!({
|
||||
"launch": {
|
||||
"policy_env": {"GOOSE_MODE": "auto", "BUZZ_ACP_MODEL": "sonnet"},
|
||||
"env": {"GOOSE_MODE": "chat"},
|
||||
"owner_pubkey": "beef"
|
||||
}
|
||||
}));
|
||||
let env = build(&agent).unwrap();
|
||||
assert_eq!(env["GOOSE_MODE"], "chat");
|
||||
assert_eq!(env["BUZZ_ACP_MODEL"], "sonnet");
|
||||
}
|
||||
|
||||
/// `launch.env` already contains the merged user env, so re-merging the
|
||||
/// legacy field would undo a layering the desktop already resolved.
|
||||
#[test]
|
||||
fn legacy_env_vars_are_not_remerged_when_launch_present() {
|
||||
let agent = payload_json(serde_json::json!({
|
||||
"env_vars": {"STALE": "yes", "SHARED": "legacy"},
|
||||
"launch": {"env": {"SHARED": "resolved"}, "owner_pubkey": "beef"}
|
||||
}));
|
||||
let env = build(&agent).unwrap();
|
||||
assert_eq!(env["SHARED"], "resolved");
|
||||
assert!(!env.contains_key("STALE"), "legacy env_vars re-merged");
|
||||
}
|
||||
|
||||
/// ...but a desktop predating the `launch` block has nothing else to
|
||||
/// offer, so the legacy field is the truth in exactly that case.
|
||||
#[test]
|
||||
fn legacy_env_vars_used_when_launch_absent() {
|
||||
let agent = payload_json(serde_json::json!({"env_vars": {"API": "v"}}));
|
||||
let env = build(&agent).unwrap();
|
||||
assert_eq!(env["API"], "v");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_when_no_owner_resolves() {
|
||||
let agent = payload_json(serde_json::json!({"auth_tag": null}));
|
||||
let err = build(&agent).unwrap_err();
|
||||
assert!(err.contains("!shutdown"), "unhelpful error: {err}");
|
||||
}
|
||||
|
||||
/// An empty string is not an owner. Without this the refusal is
|
||||
/// bypassable by a blank field and the pod launches unable to be stopped.
|
||||
#[test]
|
||||
fn empty_owner_fields_count_as_absent() {
|
||||
for blank in ["", " "] {
|
||||
let agent = payload_json(serde_json::json!({
|
||||
"auth_tag": blank,
|
||||
"launch": {"owner_pubkey": blank}
|
||||
}));
|
||||
assert!(
|
||||
build(&agent).is_err(),
|
||||
"whitespace resolved as an owner: {blank:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The other half of every identity guard: what is *stored*. A validator
|
||||
/// that trims and a writer that doesn't disagree about the value, and the
|
||||
/// padding reaches the harness inside the Secret. Assert on the stored
|
||||
/// string — asserting only that the deploy was accepted passes either way.
|
||||
#[test]
|
||||
fn identity_components_are_stored_trimmed() {
|
||||
let agent = payload_json(serde_json::json!({
|
||||
"relay_url": " wss://relay.example ",
|
||||
"auth_tag": " tag-1 ",
|
||||
"launch": {"owner_pubkey": " beefcafe "}
|
||||
}));
|
||||
let env = build(&agent).unwrap();
|
||||
assert_eq!(env["BUZZ_RELAY_URL"], "wss://relay.example");
|
||||
assert_eq!(env["BUZZ_AUTH_TAG"], "tag-1");
|
||||
assert_eq!(env["BUZZ_ACP_AGENT_OWNER"], "beefcafe");
|
||||
}
|
||||
|
||||
/// L1 item 1's third identity component. The nsec arm is enforced in
|
||||
/// `naming.rs` and the owner arm above; without this one an agent
|
||||
/// deploys with nothing to connect to — a pod that starts, fails at the
|
||||
/// relay, and reads as a network fault rather than a refused launch.
|
||||
#[test]
|
||||
fn refuses_empty_relay_url() {
|
||||
for blank in ["", " "] {
|
||||
let agent = payload_json(serde_json::json!({"relay_url": blank}));
|
||||
let err = build(&agent).unwrap_err();
|
||||
assert!(err.contains("relay_url"), "unhelpful error: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_pubkey_alone_is_sufficient() {
|
||||
let agent = payload_json(serde_json::json!({
|
||||
"auth_tag": null,
|
||||
"launch": {"owner_pubkey": "beefcafe"}
|
||||
}));
|
||||
let env = build(&agent).unwrap();
|
||||
assert_eq!(env["BUZZ_ACP_AGENT_OWNER"], "beefcafe");
|
||||
assert!(!env.contains_key("BUZZ_AUTH_TAG"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_presence_suppression() {
|
||||
let agent = payload_json(serde_json::json!({
|
||||
"launch": {"env": {"BUZZ_ACP_NO_PRESENCE": "1"}, "owner_pubkey": "beef"}
|
||||
}));
|
||||
let err = build(&agent).unwrap_err();
|
||||
assert!(err.contains("BUZZ_ACP_NO_PRESENCE"), "got: {err}");
|
||||
}
|
||||
|
||||
/// `foo.bar` is a legal Secret key but not a legal env name: pre-1.30
|
||||
/// kubelets drop it, 1.30+ inject it. Refuse rather than behave
|
||||
/// differently depending on the cluster.
|
||||
#[test]
|
||||
fn refuses_non_posix_env_keys() {
|
||||
for bad in ["foo.bar", "foo-bar", "1LEADING", "", "has space"] {
|
||||
let agent = payload_json(serde_json::json!({
|
||||
"launch": {"env": {bad: "v"}, "owner_pubkey": "beef"}
|
||||
}));
|
||||
assert!(build(&agent).is_err(), "accepted non-POSIX key {bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn args_are_comma_joined_and_omitted_when_empty() {
|
||||
let agent = payload_json(serde_json::json!({
|
||||
"launch": {"command": "goose", "args": ["run", "--no-session"], "owner_pubkey": "b"}
|
||||
}));
|
||||
let env = build(&agent).unwrap();
|
||||
assert_eq!(env["BUZZ_ACP_AGENT_ARGS"], "run,--no-session");
|
||||
|
||||
let agent = payload_json(serde_json::json!({
|
||||
"launch": {"command": "goose", "args": [], "owner_pubkey": "b"}
|
||||
}));
|
||||
assert!(!build(&agent).unwrap().contains_key("BUZZ_ACP_AGENT_ARGS"));
|
||||
}
|
||||
|
||||
/// The top-level `model`/`provider` fields are display inputs; their
|
||||
/// environment consequence is per-runtime and arrives already resolved
|
||||
/// inside `launch`. A provider-side mapping is wrong for three of the
|
||||
/// four built-in runtimes.
|
||||
#[test]
|
||||
fn provider_never_maps_model_or_provider_itself() {
|
||||
let agent = payload_json(serde_json::json!({
|
||||
"model": "claude-opus", "provider": "anthropic",
|
||||
"launch": {"owner_pubkey": "beef"}
|
||||
}));
|
||||
let env = build(&agent).unwrap();
|
||||
for key in [
|
||||
"BUZZ_AGENT_PROVIDER",
|
||||
"BUZZ_AGENT_MODEL",
|
||||
"GOOSE_PROVIDER",
|
||||
"GOOSE_MODEL",
|
||||
] {
|
||||
assert!(!env.contains_key(key), "provider mapped {key} itself");
|
||||
}
|
||||
}
|
||||
|
||||
/// `turn_timeout_seconds` is deprecated and ignored upstream; the local
|
||||
/// spawn does not emit it either.
|
||||
#[test]
|
||||
fn turn_timeout_is_not_mapped() {
|
||||
let agent = payload_json(serde_json::json!({
|
||||
"turn_timeout_seconds": 30, "launch": {"owner_pubkey": "b"}
|
||||
}));
|
||||
let env = build(&agent).unwrap();
|
||||
assert!(!env.keys().any(|k| k.contains("TURN_TIMEOUT")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inactivity_omitted_when_unset() {
|
||||
let agent = payload_json(serde_json::json!({"launch": {"owner_pubkey": "b"}}));
|
||||
let env = build_env(
|
||||
&agent,
|
||||
AuthoritativeInputs {
|
||||
generation: "g",
|
||||
inactivity_seconds: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!env.contains_key("BUZZ_ACP_EXIT_AFTER_INACTIVITY"));
|
||||
}
|
||||
|
||||
/// Structural guard over the whole authoritative list at once: whatever
|
||||
/// the lower tiers contain, no authoritative key holds a lower-tier value
|
||||
/// — including the conditionally-written ones the authoritative tier has
|
||||
/// nothing to say about, which must be **absent** rather than spoofed.
|
||||
/// This test caught exactly that: `BUZZ_ACP_AGENT_ARGS` is only written
|
||||
/// when `launch.args` is non-empty, so plain later-wins overwrite left the
|
||||
/// spoofed value in place.
|
||||
#[test]
|
||||
fn no_authoritative_key_retains_a_lower_tier_value() {
|
||||
let spoofed: serde_json::Map<String, serde_json::Value> = AUTHORITATIVE_KEYS
|
||||
.iter()
|
||||
.map(|k| ((*k).to_string(), serde_json::json!("SPOOFED")))
|
||||
.collect();
|
||||
// Split across both lower tiers: policy_env and env are separate
|
||||
// insertion points, and a fix that only cleared one would pass a
|
||||
// single-tier test.
|
||||
let agent = payload_json(serde_json::json!({
|
||||
"launch": {
|
||||
"command": "goose",
|
||||
"policy_env": spoofed.clone(),
|
||||
"env": spoofed,
|
||||
"owner_pubkey": "beef"
|
||||
}
|
||||
}));
|
||||
let env = build(&agent).unwrap();
|
||||
for key in AUTHORITATIVE_KEYS {
|
||||
assert_ne!(
|
||||
env.get(*key).map(String::as_str),
|
||||
Some("SPOOFED"),
|
||||
"{key} kept its lower-tier value"
|
||||
);
|
||||
}
|
||||
// The keys the authoritative tier had no value for are gone, not
|
||||
// merely different.
|
||||
for absent in [
|
||||
"BUZZ_ACP_AGENT_ARGS",
|
||||
"BUZZ_ACP_RESPOND_TO",
|
||||
"BUZZ_ACP_RESPOND_TO_ALLOWLIST",
|
||||
] {
|
||||
assert!(!env.contains_key(absent), "{absent} survived the clear");
|
||||
}
|
||||
}
|
||||
|
||||
/// A 64-hex pubkey, the only allowlist entry shape the harness accepts.
|
||||
fn pubkey(fill: char) -> String {
|
||||
std::iter::repeat_n(fill, 64).collect()
|
||||
}
|
||||
|
||||
/// The gate the harness refuses first (`config.rs:996-1004`). Refusing it
|
||||
/// here is the difference between one error message and an unbounded
|
||||
/// fail-replace loop that leaves a Secret per attempt.
|
||||
#[test]
|
||||
fn allowlist_mode_with_an_empty_list_is_refused() {
|
||||
for empty in [serde_json::json!([]), serde_json::Value::Null] {
|
||||
let agent = payload_json(serde_json::json!({
|
||||
"respond_to": "allowlist",
|
||||
"respond_to_allowlist": empty,
|
||||
}));
|
||||
let err = build(&agent).unwrap_err();
|
||||
assert!(
|
||||
err.contains("the allowlist is empty"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `config.rs:629-641` — each entry must be exactly 64 hex characters.
|
||||
/// The rejects are the distinct ways to miss that: too short, right length
|
||||
/// but not hex, empty, and one character short of valid.
|
||||
#[test]
|
||||
fn an_allowlist_entry_that_is_not_64_hex_is_refused() {
|
||||
for bad in ["abc1234", &"z".repeat(64), "", &pubkey('a')[..63]] {
|
||||
let agent = payload_json(serde_json::json!({
|
||||
"respond_to": "allowlist",
|
||||
"respond_to_allowlist": [pubkey('a'), bad],
|
||||
}));
|
||||
let err = build(&agent).unwrap_err();
|
||||
assert!(
|
||||
err.contains("must be exactly 64 hex characters"),
|
||||
"{bad:?} was accepted; error was: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The positive control: the guard refuses bad gates, not every gate.
|
||||
/// Without this, a validator that refused unconditionally would pass both
|
||||
/// tests above.
|
||||
#[test]
|
||||
fn a_valid_allowlist_gate_is_accepted_and_comma_joined() {
|
||||
let agent = payload_json(serde_json::json!({
|
||||
"respond_to": "allowlist",
|
||||
"respond_to_allowlist": [pubkey('a'), pubkey('b')],
|
||||
}));
|
||||
let env = build(&agent).unwrap();
|
||||
assert_eq!(env["BUZZ_ACP_RESPOND_TO"], "allowlist");
|
||||
assert_eq!(
|
||||
env["BUZZ_ACP_RESPOND_TO_ALLOWLIST"],
|
||||
format!("{},{}", pubkey('a'), pubkey('b'))
|
||||
);
|
||||
}
|
||||
|
||||
/// The harness validates the allowlist **only** in allowlist mode and
|
||||
/// merely warns otherwise (`config.rs:1005-1010`). A stricter provider
|
||||
/// would refuse a deploy whose identical local spawn succeeds, so this
|
||||
/// pins the asymmetry rather than leaving it to look like an oversight.
|
||||
#[test]
|
||||
fn a_junk_allowlist_is_tolerated_outside_allowlist_mode() {
|
||||
for mode in ["owner-only", "anyone"] {
|
||||
let agent = payload_json(serde_json::json!({
|
||||
"respond_to": mode,
|
||||
"respond_to_allowlist": ["not-a-pubkey"],
|
||||
}));
|
||||
let env = build(&agent)
|
||||
.unwrap_or_else(|e| panic!("{mode} with a stale list must deploy: {e}"));
|
||||
assert_eq!(env["BUZZ_ACP_RESPOND_TO"], mode);
|
||||
}
|
||||
}
|
||||
|
||||
/// `respond_to` is an opaque `String` on the wire but a `clap::ValueEnum`
|
||||
/// at the harness, so an unrecognized mode dies at `rc=2` — before config
|
||||
/// parsing runs at all, earlier than either refusal above. Measured
|
||||
/// against the built binary: `invalid value 'npub1abc' for '--respond-to'`.
|
||||
/// This is the shape our own fixture carried until it was corrected.
|
||||
#[test]
|
||||
fn a_mode_the_harness_cannot_parse_is_refused() {
|
||||
for bad in ["npub1abc", "OWNER-ONLY", "owner_only", "allowlistt", "x"] {
|
||||
let agent = payload_json(serde_json::json!({ "respond_to": bad }));
|
||||
let err = build(&agent).unwrap_err();
|
||||
assert!(
|
||||
err.contains("is not a mode the harness accepts"),
|
||||
"{bad:?} was accepted; error was: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `clap` does not trim its value-enum input, so a padded mode is `rc=2`
|
||||
/// even though the same string trimmed is valid. Measured: `invalid value
|
||||
/// ' allowlist ' for '--respond-to'`. Trimming here would accept a deploy
|
||||
/// the harness refuses — the exact direction this guard exists to prevent.
|
||||
#[test]
|
||||
fn a_padded_mode_is_refused_because_clap_does_not_trim() {
|
||||
for padded in [" allowlist ", "allowlist ", " owner-only", "\tnobody"] {
|
||||
let agent = payload_json(serde_json::json!({
|
||||
"respond_to": padded,
|
||||
"respond_to_allowlist": [pubkey('a')],
|
||||
}));
|
||||
let err = build(&agent).unwrap_err();
|
||||
assert!(
|
||||
err.contains("is not a mode the harness accepts"),
|
||||
"{padded:?} was accepted; error was: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Positive control for the mode check, and the reason it validates the
|
||||
/// harness's four rather than the desktop's three: `nobody` is rejected by
|
||||
/// `parse_wire` on purpose (`managed_agents/types.rs:871-880`) but starts
|
||||
/// fine at the harness. A guard mirroring the desktop enum would refuse a
|
||||
/// working launch from a non-desktop caller — the callers this guard is
|
||||
/// for. Without this test, refusing `nobody` would pass everything above.
|
||||
#[test]
|
||||
fn every_mode_the_harness_accepts_is_deployable() {
|
||||
for mode in ["owner-only", "allowlist", "anyone", "nobody"] {
|
||||
let agent = payload_json(serde_json::json!({
|
||||
"respond_to": mode,
|
||||
"respond_to_allowlist": [pubkey('a')],
|
||||
}));
|
||||
let env = build(&agent)
|
||||
.unwrap_or_else(|e| panic!("{mode} is valid at the harness but was refused: {e}"));
|
||||
assert_eq!(env["BUZZ_ACP_RESPOND_TO"], mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
//! Preflight garbage collection (spec §K8s GC, `docs/remote-agents.md:1282-1335`).
|
||||
//!
|
||||
//! GC runs on every deploy, after identity derivation and before the state
|
||||
//! transition. It deletes terminated pods (and their referenced Secrets) and
|
||||
//! age-eligible orphan Secrets — every one of which must pass the full-pubkey
|
||||
//! annotation check *and* carry the management marker. An unmarked object is
|
||||
//! never GC'd regardless of its labels.
|
||||
//!
|
||||
//! The decision layer here is pure. The effectful caller supplies the observed
|
||||
//! objects and the apiserver's clock; this module decides what may be deleted.
|
||||
|
||||
use crate::naming::AgentIdentity;
|
||||
use crate::observe::{referenced_secret, secret_is_ours};
|
||||
use chrono::{DateTime, Utc};
|
||||
use k8s_openapi::api::core::v1::{Pod, Secret};
|
||||
|
||||
/// The deploy operation deadline (spec §Deploy: `timeout: 600s`).
|
||||
pub const OPERATION_DEADLINE_SECS: i64 = 600;
|
||||
|
||||
/// An unreferenced Secret is GC-eligible only once it is older than **twice**
|
||||
/// the deploy deadline. Rationale: Secret-create → pod-create is not atomic
|
||||
/// against an independent GC pass, so without the gate a concurrent attempt's
|
||||
/// preflight GC can delete a Secret whose pod has not been created yet and
|
||||
/// strand that deploy. The age bound makes "unreferenced" mean "provably
|
||||
/// abandoned" — any attempt that could still reference it has exceeded its own
|
||||
/// deadline (`:1301-1319`).
|
||||
pub const ORPHAN_SECRET_MIN_AGE_SECS: i64 = 2 * OPERATION_DEADLINE_SECS;
|
||||
|
||||
/// What a GC pass decided to delete. Names only: the caller re-reads each
|
||||
/// object's own fence at delete time.
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub struct GcPlan {
|
||||
/// Terminated, verified, marker-bearing pods.
|
||||
pub pods: Vec<String>,
|
||||
/// Age-eligible, verified, marker-bearing orphan Secrets.
|
||||
pub secrets: Vec<String>,
|
||||
}
|
||||
|
||||
/// Plan a GC pass.
|
||||
///
|
||||
/// `now` is the apiserver's clock — the HTTP `Date` header from the very list
|
||||
/// call that produced `secrets`. `None` means the header was absent or
|
||||
/// unparseable, in which case **orphan-Secret GC is skipped entirely** rather
|
||||
/// than falling back to local time: this provider runs on a user's desktop,
|
||||
/// and a local clock fast by more than the margin does not race — it
|
||||
/// deterministically computes every in-flight Secret as expired, on every
|
||||
/// pass, reopening exactly the interleaving the gate exists to close
|
||||
/// (`:1321-1335`). A deferred cleanup is free; a wrong deletion is not.
|
||||
///
|
||||
/// Terminated-pod GC does not use the clock and is unaffected.
|
||||
pub fn plan(
|
||||
identity: &AgentIdentity,
|
||||
pods: &[Pod],
|
||||
secrets: &[Secret],
|
||||
terminated: impl Fn(&Pod) -> bool,
|
||||
now: Option<DateTime<Utc>>,
|
||||
) -> GcPlan {
|
||||
// Only pods that pass the full fence participate — in either direction.
|
||||
// An unverified pod is neither deleted nor allowed to protect a Secret:
|
||||
// it cannot be ours, so its `envFrom` cannot reference our generation.
|
||||
let ours: Vec<&Pod> = pods
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
crate::observe::verify(p, identity, crate::classify::Startup::Started).is_some()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let doomed_pods: Vec<&&Pod> = ours.iter().filter(|p| terminated(p)).collect();
|
||||
|
||||
// A Secret referenced by ANY existing pod is protected — deliberately
|
||||
// including not-yet-started pods, whose `envFrom` is exactly as
|
||||
// load-bearing as a running pod's (`:1262-1264`). Pods being GC'd in this
|
||||
// same pass are excluded, so their Secrets go with them.
|
||||
let doomed_names: Vec<&str> = doomed_pods
|
||||
.iter()
|
||||
.filter_map(|p| p.metadata.name.as_deref())
|
||||
.collect();
|
||||
let protected: Vec<String> = ours
|
||||
.iter()
|
||||
.filter(|p| !doomed_names.contains(&p.metadata.name.as_deref().unwrap_or_default()))
|
||||
.filter_map(|p| referenced_secret(p))
|
||||
.collect();
|
||||
|
||||
let mut plan = GcPlan {
|
||||
pods: doomed_names.iter().map(|n| n.to_string()).collect(),
|
||||
secrets: doomed_pods
|
||||
.iter()
|
||||
.filter_map(|p| referenced_secret(p))
|
||||
.collect(),
|
||||
};
|
||||
|
||||
// Orphan sweep: only with a server clock.
|
||||
if let Some(now) = now {
|
||||
for secret in secrets {
|
||||
if !secret_is_ours(secret, identity) {
|
||||
continue;
|
||||
}
|
||||
let Some(name) = secret.metadata.name.as_deref() else {
|
||||
continue;
|
||||
};
|
||||
if protected.contains(&name.to_string()) || plan.secrets.iter().any(|s| s == name) {
|
||||
continue;
|
||||
}
|
||||
let Some(created) = secret.metadata.creation_timestamp.as_ref() else {
|
||||
// No server-assigned timestamp means no age proof. Skip.
|
||||
continue;
|
||||
};
|
||||
if (now - created.0).num_seconds() >= ORPHAN_SECRET_MIN_AGE_SECS {
|
||||
plan.secrets.push(name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
plan
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::naming::{ANNOTATION_PUBKEY_FULL, LABEL_MANAGED_BY};
|
||||
use k8s_openapi::api::core::v1::{Container, EnvFromSource, PodSpec, SecretEnvSource};
|
||||
use k8s_openapi::apimachinery::pkg::apis::meta::v1::{ObjectMeta, Time};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn identity() -> AgentIdentity {
|
||||
use nostr::nips::nip19::ToBech32;
|
||||
let keys = nostr::Keys::generate();
|
||||
AgentIdentity::from_nsec(&keys.secret_key().to_bech32().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn pod_named(id: &AgentIdentity, name: &str, secret: Option<&str>) -> Pod {
|
||||
Pod {
|
||||
metadata: ObjectMeta {
|
||||
name: Some(name.into()),
|
||||
uid: Some(format!("uid-{name}")),
|
||||
resource_version: Some("1".into()),
|
||||
labels: Some(id.labels()),
|
||||
annotations: Some(
|
||||
[(
|
||||
ANNOTATION_PUBKEY_FULL.to_string(),
|
||||
id.pubkey_hex().to_string(),
|
||||
)]
|
||||
.into_iter()
|
||||
.collect::<BTreeMap<_, _>>(),
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
spec: secret.map(|s| PodSpec {
|
||||
containers: vec![Container {
|
||||
name: "agent".into(),
|
||||
env_from: Some(vec![EnvFromSource {
|
||||
secret_ref: Some(SecretEnvSource {
|
||||
name: s.into(),
|
||||
optional: Some(false),
|
||||
}),
|
||||
..Default::default()
|
||||
}]),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn secret_named(id: &AgentIdentity, name: &str, age_secs: i64, now: DateTime<Utc>) -> Secret {
|
||||
Secret {
|
||||
metadata: ObjectMeta {
|
||||
name: Some(name.into()),
|
||||
labels: Some(id.labels()),
|
||||
annotations: Some(
|
||||
[(
|
||||
ANNOTATION_PUBKEY_FULL.to_string(),
|
||||
id.pubkey_hex().to_string(),
|
||||
)]
|
||||
.into_iter()
|
||||
.collect::<BTreeMap<_, _>>(),
|
||||
),
|
||||
creation_timestamp: Some(Time(now - chrono::Duration::seconds(age_secs))),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn never(_: &Pod) -> bool {
|
||||
false
|
||||
}
|
||||
fn always(_: &Pod) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminated_pods_and_their_secrets_are_collected_together() {
|
||||
let id = identity();
|
||||
let now = Utc::now();
|
||||
let pod = pod_named(&id, "buzz-agent-dead", Some("buzz-agent-dead-gen1"));
|
||||
let plan = plan(&id, &[pod], &[], always, Some(now));
|
||||
assert_eq!(plan.pods, ["buzz-agent-dead"]);
|
||||
assert_eq!(plan.secrets, ["buzz-agent-dead-gen1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_pods_are_never_collected() {
|
||||
let id = identity();
|
||||
let pod = pod_named(&id, "buzz-agent-live", Some("buzz-agent-live-gen1"));
|
||||
let plan = plan(&id, &[pod], &[], never, Some(Utc::now()));
|
||||
assert_eq!(plan, GcPlan::default());
|
||||
}
|
||||
|
||||
/// The auto-repair fence applies to GC identically: an object that lacks
|
||||
/// the marker, or carries a different pubkey, is never touched — however
|
||||
/// well its labels match.
|
||||
#[test]
|
||||
fn unmarked_and_mismatched_objects_are_never_collected() {
|
||||
let id = identity();
|
||||
let other = identity();
|
||||
let now = Utc::now();
|
||||
|
||||
let mut unmarked = pod_named(&id, "look-alike", Some("look-alike-gen1"));
|
||||
let mut labels = id.labels();
|
||||
labels.remove(LABEL_MANAGED_BY);
|
||||
unmarked.metadata.labels = Some(labels);
|
||||
|
||||
let mut foreign = pod_named(&id, "someone-elses", Some("someone-elses-gen1"));
|
||||
foreign.metadata.annotations = Some(
|
||||
[(
|
||||
ANNOTATION_PUBKEY_FULL.to_string(),
|
||||
other.pubkey_hex().to_string(),
|
||||
)]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
);
|
||||
|
||||
let mut unmarked_secret = secret_named(&id, "orphan-unmarked", 100_000, now);
|
||||
unmarked_secret.metadata.labels = Some(BTreeMap::new());
|
||||
let mut foreign_secret = secret_named(&id, "orphan-foreign", 100_000, now);
|
||||
foreign_secret.metadata.annotations = Some(
|
||||
[(
|
||||
ANNOTATION_PUBKEY_FULL.to_string(),
|
||||
other.pubkey_hex().to_string(),
|
||||
)]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
);
|
||||
|
||||
let plan = plan(
|
||||
&id,
|
||||
&[unmarked, foreign],
|
||||
&[unmarked_secret, foreign_secret],
|
||||
always,
|
||||
Some(now),
|
||||
);
|
||||
assert_eq!(
|
||||
plan,
|
||||
GcPlan::default(),
|
||||
"GC touched an object it does not own"
|
||||
);
|
||||
}
|
||||
|
||||
/// The interleaving the age gate exists to close: attempt A creates its
|
||||
/// Secret; concurrent attempt B's preflight GC runs before A creates its
|
||||
/// pod. Without the gate B deletes A's Secret and strands A.
|
||||
#[test]
|
||||
fn young_unreferenced_secrets_are_protected() {
|
||||
let id = identity();
|
||||
let now = Utc::now();
|
||||
let fresh = secret_named(&id, "buzz-agent-x-gen-inflight", 5, now);
|
||||
assert_eq!(
|
||||
plan(&id, &[], &[fresh], never, Some(now)),
|
||||
GcPlan::default()
|
||||
);
|
||||
}
|
||||
|
||||
/// Past twice the deadline, any attempt that could still reference the
|
||||
/// Secret has exceeded its own deadline — so it is provably abandoned.
|
||||
#[test]
|
||||
fn secrets_older_than_twice_the_deadline_are_collected() {
|
||||
let id = identity();
|
||||
let now = Utc::now();
|
||||
let old = secret_named(
|
||||
&id,
|
||||
"buzz-agent-x-gen-abandoned",
|
||||
ORPHAN_SECRET_MIN_AGE_SECS + 1,
|
||||
now,
|
||||
);
|
||||
let plan = plan(&id, &[], &[old], never, Some(now));
|
||||
assert_eq!(plan.secrets, ["buzz-agent-x-gen-abandoned"]);
|
||||
}
|
||||
|
||||
/// The boundary itself, both sides. `>= 1200s` is eligible.
|
||||
#[test]
|
||||
fn age_gate_boundary_is_exact() {
|
||||
let id = identity();
|
||||
let now = Utc::now();
|
||||
let just_under = secret_named(&id, "under", ORPHAN_SECRET_MIN_AGE_SECS - 1, now);
|
||||
let exactly = secret_named(&id, "exact", ORPHAN_SECRET_MIN_AGE_SECS, now);
|
||||
assert!(plan(&id, &[], &[just_under], never, Some(now))
|
||||
.secrets
|
||||
.is_empty());
|
||||
assert_eq!(
|
||||
plan(&id, &[], &[exactly], never, Some(now)).secrets,
|
||||
["exact"]
|
||||
);
|
||||
}
|
||||
|
||||
/// The same-clock rule. No apiserver `Date` header → skip the orphan
|
||||
/// sweep entirely. A local clock fast by more than the margin would
|
||||
/// silently delete every in-flight Secret on every pass.
|
||||
#[test]
|
||||
fn without_a_server_clock_the_orphan_sweep_is_skipped() {
|
||||
let id = identity();
|
||||
let now = Utc::now();
|
||||
let ancient = secret_named(&id, "buzz-agent-x-gen-ancient", 10_000_000, now);
|
||||
let plan = plan(&id, &[], &[ancient], never, None);
|
||||
assert!(
|
||||
plan.secrets.is_empty(),
|
||||
"orphan swept without a server clock — a fast local clock would delete live Secrets"
|
||||
);
|
||||
}
|
||||
|
||||
/// ...but terminated-pod GC does not consult the clock, so it still runs.
|
||||
#[test]
|
||||
fn terminated_pod_gc_runs_without_a_server_clock() {
|
||||
let id = identity();
|
||||
let pod = pod_named(&id, "buzz-agent-dead", Some("buzz-agent-dead-gen1"));
|
||||
let plan = plan(&id, &[pod], &[], always, None);
|
||||
assert_eq!(plan.pods, ["buzz-agent-dead"]);
|
||||
assert_eq!(plan.secrets, ["buzz-agent-dead-gen1"]);
|
||||
}
|
||||
|
||||
/// "Existing" includes not-yet-started pods: a Secret referenced by a pod
|
||||
/// still pulling its image must not be swept, however old it is.
|
||||
#[test]
|
||||
fn secrets_referenced_by_a_pending_pod_are_protected() {
|
||||
let id = identity();
|
||||
let now = Utc::now();
|
||||
let pending = pod_named(&id, "buzz-agent-pending", Some("buzz-agent-pending-gen1"));
|
||||
let old = secret_named(&id, "buzz-agent-pending-gen1", 10_000_000, now);
|
||||
let plan = plan(&id, &[pending], &[old], never, Some(now));
|
||||
assert!(plan.secrets.is_empty(), "swept a referenced Secret");
|
||||
}
|
||||
|
||||
/// A Secret with no server-assigned creationTimestamp has no age proof,
|
||||
/// so it is skipped rather than assumed old.
|
||||
#[test]
|
||||
fn secrets_without_a_creation_timestamp_are_skipped() {
|
||||
let id = identity();
|
||||
let now = Utc::now();
|
||||
let mut no_timestamp = secret_named(&id, "buzz-agent-x-gen-unknown", 10_000_000, now);
|
||||
no_timestamp.metadata.creation_timestamp = None;
|
||||
assert!(plan(&id, &[], &[no_timestamp], never, Some(now))
|
||||
.secrets
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
/// A Secret belonging to a pod being collected in this same pass goes with
|
||||
/// it, and must not be listed twice.
|
||||
#[test]
|
||||
fn a_collected_pods_secret_is_listed_once() {
|
||||
let id = identity();
|
||||
let now = Utc::now();
|
||||
let dead = pod_named(&id, "buzz-agent-dead", Some("buzz-agent-dead-gen1"));
|
||||
let its_secret = secret_named(&id, "buzz-agent-dead-gen1", 10_000_000, now);
|
||||
let plan = plan(&id, &[dead], &[its_secret], always, Some(now));
|
||||
assert_eq!(plan.secrets, ["buzz-agent-dead-gen1"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
//! Image reference validation (spec §Image).
|
||||
//!
|
||||
//! The object holding this reference runs with an nsec, so the reference must
|
||||
//! be **immutable**. Registry tags are mutable pointers — Kubernetes itself
|
||||
//! distinguishes them from digests for exactly this reason — so a tag-only
|
||||
//! reference is rejected, not just `:latest`.
|
||||
//!
|
||||
//! There is no parse-time fallback: `image` is required, and its absence
|
||||
//! fails closed with a named field. The published `ghcr.io/block/buzz-sprig`
|
||||
//! digest is offered only as a schema `default` (a UI prefill the desktop
|
||||
//! submits explicitly — see `config::DEFAULT_IMAGE`), so the create-intent
|
||||
//! fingerprint never depends on compiled-in provider state.
|
||||
|
||||
/// A validated, digest-qualified image reference.
|
||||
///
|
||||
/// The inner string is always in canonical tagless form `name@sha256:<hex>`:
|
||||
/// `name:tag@sha256:…` normalizes by dropping the tag, because the tag is
|
||||
/// decorative once a digest pins the content, and leaving it in would make
|
||||
/// two references to identical bytes produce different create-intent
|
||||
/// fingerprints.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ImageRef(String);
|
||||
|
||||
impl ImageRef {
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ImageRef {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse and normalize a user-supplied image reference.
|
||||
pub fn parse(raw: &str) -> Result<ImageRef, String> {
|
||||
let reference = raw.trim();
|
||||
if reference.is_empty() {
|
||||
return Err("provider_config.image is required: no image is assumed \
|
||||
at parse time, so the digest-pinned image to run must be \
|
||||
given explicitly"
|
||||
.to_string());
|
||||
}
|
||||
|
||||
let mut parts = reference.split('@');
|
||||
let name_and_tag = parts.next().unwrap_or_default();
|
||||
let digest = match (parts.next(), parts.next()) {
|
||||
(Some(d), None) => d,
|
||||
(None, _) => {
|
||||
return Err(format!(
|
||||
"provider_config.image {reference:?} is not digest-pinned: a \
|
||||
tag is a mutable pointer, and this object runs with the \
|
||||
agent's private key. Use name@sha256:<64 hex chars>"
|
||||
))
|
||||
}
|
||||
(Some(_), Some(_)) => {
|
||||
return Err(format!(
|
||||
"provider_config.image {reference:?} contains more than one '@'"
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let hex = digest.strip_prefix("sha256:").ok_or_else(|| {
|
||||
format!("provider_config.image digest {digest:?} must start with 'sha256:'")
|
||||
})?;
|
||||
// Lowercase only: OCI canonicalizes digest hex, and accepting uppercase
|
||||
// would let two spellings of one digest produce two fingerprints.
|
||||
if hex.len() != 64
|
||||
|| !hex
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
|
||||
{
|
||||
return Err(format!(
|
||||
"provider_config.image digest {digest:?} must be exactly 64 \
|
||||
lowercase hex characters"
|
||||
));
|
||||
}
|
||||
|
||||
// Drop any tag: `name:tag@sha256:…` and `name@sha256:…` name the same
|
||||
// bytes and must fingerprint identically. Only a *final* colon segment
|
||||
// that isn't a port counts as a tag — `host:5000/name` has no tag.
|
||||
let name = match name_and_tag.rfind(':') {
|
||||
Some(colon) if !name_and_tag[colon + 1..].contains('/') => &name_and_tag[..colon],
|
||||
_ => name_and_tag,
|
||||
};
|
||||
if name.is_empty() {
|
||||
return Err(format!(
|
||||
"provider_config.image {reference:?} has no repository name"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(ImageRef(format!("{name}@sha256:{hex}")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn accepts_digest_pinned_reference() {
|
||||
let d = "a".repeat(64);
|
||||
let r = parse(&format!("ghcr.io/block/buzz-sprig@sha256:{d}")).unwrap();
|
||||
assert_eq!(r.as_str(), format!("ghcr.io/block/buzz-sprig@sha256:{d}"));
|
||||
}
|
||||
|
||||
/// The normalization that keeps the fingerprint stable: two spellings of
|
||||
/// the same bytes must produce one reference.
|
||||
#[test]
|
||||
fn strips_tag_from_tag_plus_digest_form() {
|
||||
let d = "b".repeat(64);
|
||||
let tagged = parse(&format!("ghcr.io/block/buzz-sprig:v1.2@sha256:{d}")).unwrap();
|
||||
let plain = parse(&format!("ghcr.io/block/buzz-sprig@sha256:{d}")).unwrap();
|
||||
assert_eq!(tagged, plain);
|
||||
}
|
||||
|
||||
/// A registry port is not a tag. `host:5000/name` must keep its port.
|
||||
#[test]
|
||||
fn registry_port_is_not_mistaken_for_a_tag() {
|
||||
let d = "c".repeat(64);
|
||||
let r = parse(&format!("localhost:5000/buzz-sprig@sha256:{d}")).unwrap();
|
||||
assert_eq!(r.as_str(), format!("localhost:5000/buzz-sprig@sha256:{d}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_and_tag_together_drops_only_the_tag() {
|
||||
let d = "d".repeat(64);
|
||||
let r = parse(&format!("localhost:5000/buzz-sprig:dev@sha256:{d}")).unwrap();
|
||||
assert_eq!(r.as_str(), format!("localhost:5000/buzz-sprig@sha256:{d}"));
|
||||
}
|
||||
|
||||
/// Wren's amendment: *every* tag-only reference is rejected, not just
|
||||
/// `:latest`. A `sha-<gitsha>` tag is traceable but still movable.
|
||||
#[test]
|
||||
fn rejects_every_tag_only_reference() {
|
||||
for bad in [
|
||||
"ghcr.io/block/buzz-sprig:latest",
|
||||
"ghcr.io/block/buzz-sprig:v1.2.3",
|
||||
"ghcr.io/block/buzz-sprig:sha-abc1234",
|
||||
"ghcr.io/block/buzz-sprig",
|
||||
"localhost:5000/buzz-sprig",
|
||||
] {
|
||||
let err = parse(bad).unwrap_err();
|
||||
assert!(err.contains("digest-pinned"), "for {bad:?} got: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Uppercase hex is a second spelling of one digest; accepting it would
|
||||
/// let the same image fingerprint two ways.
|
||||
#[test]
|
||||
fn rejects_uppercase_digest_hex() {
|
||||
let d = "A".repeat(64);
|
||||
assert!(parse(&format!("img@sha256:{d}")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_digests() {
|
||||
let short = "a".repeat(63);
|
||||
let long = "a".repeat(65);
|
||||
let ok = "a".repeat(64);
|
||||
for bad in [
|
||||
format!("img@sha256:{short}"),
|
||||
format!("img@sha256:{long}"),
|
||||
format!("img@sha512:{ok}"),
|
||||
format!("img@{ok}"),
|
||||
format!("img@sha256:{}", "g".repeat(64)),
|
||||
format!("img@sha256:{ok}@sha256:{ok}"),
|
||||
format!("@sha256:{ok}"),
|
||||
] {
|
||||
assert!(parse(&bad).is_err(), "accepted {bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Parsing has no fallback (the schema default is a UI prefill, not a
|
||||
/// parse-time substitute), so an absent image is an error that names the
|
||||
/// field rather than a silent fallback.
|
||||
#[test]
|
||||
fn empty_reference_names_the_field() {
|
||||
for empty in ["", " "] {
|
||||
let err = parse(empty).unwrap_err();
|
||||
assert!(err.contains("provider_config.image"), "got: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
//! The create-intent fingerprint (spec §Deploy State Machine, create-intent
|
||||
//! fingerprint, `docs/remote-agents.md:796-828`).
|
||||
//!
|
||||
//! The fingerprint is an unkeyed SHA-256 over a canonical serialization of the
|
||||
//! provider's non-secret create-intent template. A plain hash is safe *only*
|
||||
//! because of the scope rule: the input covers exactly the provider-controlled
|
||||
//! fields that can affect scheduling or container creation, and **never Secret
|
||||
//! data or attempt identity**. Hashing low-entropy secrets into a
|
||||
//! world-readable annotation would be a dictionary oracle.
|
||||
//!
|
||||
//! That rule is enforced structurally rather than remembered. [`IntentTemplate`]
|
||||
//! is a *pre-binding* type: it has no field that can hold Secret material or a
|
||||
//! generation token, so there is no expression that hashes one. The
|
||||
//! per-attempt Secret name never appears — the pod's `envFrom` is represented
|
||||
//! by the fixed [`SECRET_PLACEHOLDER`], because otherwise every attempt would
|
||||
//! diverge from every other by construction.
|
||||
//!
|
||||
//! Server- and admission-produced output (UID, `resourceVersion`, timestamps,
|
||||
//! defaulted fields, the annotation itself) is excluded the same way: the
|
||||
//! serializer is only ever handed this template, never a live `Pod`, so the
|
||||
//! exclusion is checkable by inspection.
|
||||
|
||||
use crate::image::ImageRef;
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// Stands in for the per-attempt Secret name in the `envFrom` position.
|
||||
/// A real generation token here would make every attempt diverge.
|
||||
const SECRET_PLACEHOLDER: &str = "<per-attempt-secret>";
|
||||
|
||||
/// The recorded/computed create intent: a hex SHA-256 digest.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Fingerprint(String);
|
||||
|
||||
impl Fingerprint {
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Read a fingerprint off a pod annotation. Any recorded string is
|
||||
/// accepted verbatim: comparison is equality against a freshly computed
|
||||
/// value, so a malformed annotation simply reads as divergence — which is
|
||||
/// the correct outcome for a pod this provider version did not write.
|
||||
pub fn from_annotation(value: &str) -> Self {
|
||||
Self(value.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn for_test(seed: &str) -> Self {
|
||||
Self(format!("test-{seed}"))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Fingerprint {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// The non-secret, pre-binding description of the pod this deploy would
|
||||
/// create. Every field is provider-controlled and scheduling-relevant; there
|
||||
/// is deliberately no field for env values, Secret data, or the generation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct IntentTemplate {
|
||||
/// Schema version of the template itself. Bumping it re-fingerprints every
|
||||
/// pod, which is the intended way to roll out a pod-shape change.
|
||||
pub template_version: u32,
|
||||
pub namespace: String,
|
||||
/// Normalized, digest-qualified image reference.
|
||||
pub image: String,
|
||||
pub cpu_request: String,
|
||||
pub memory_request: String,
|
||||
pub cpu_limit: String,
|
||||
pub memory_limit: String,
|
||||
pub service_account: Option<String>,
|
||||
pub restart_policy: &'static str,
|
||||
pub termination_grace_period_seconds: i64,
|
||||
/// Env *keys* only, sorted. Keys are pod-shape (a renamed key changes the
|
||||
/// container's contract); values are Secret material and must not be here.
|
||||
pub env_keys: Vec<String>,
|
||||
/// Fixed placeholder for the per-attempt Secret in `envFrom`.
|
||||
pub env_from_secret: &'static str,
|
||||
pub workspace_mount_path: String,
|
||||
pub run_as_user: i64,
|
||||
pub run_as_group: i64,
|
||||
}
|
||||
|
||||
/// Current template schema version.
|
||||
pub const TEMPLATE_VERSION: u32 = 1;
|
||||
|
||||
impl IntentTemplate {
|
||||
/// Compute the fingerprint. `serde_json` on a struct with declared field
|
||||
/// order plus pre-sorted `env_keys` is a canonical serialization: the same
|
||||
/// template always produces the same bytes.
|
||||
pub fn fingerprint(&self) -> Fingerprint {
|
||||
let canonical = serde_json::to_vec(self).expect("intent template is plain data");
|
||||
Fingerprint(hex::encode(Sha256::digest(&canonical)))
|
||||
}
|
||||
|
||||
/// Build from resolved pod-shape inputs. `env_keys` is sorted here rather
|
||||
/// than at the call site so key ordering can never leak into the digest.
|
||||
///
|
||||
/// The fixed pod-shape constants are read from [`crate::config`] rather
|
||||
/// than passed in: `pod::build_pod` stamps the pod from those same
|
||||
/// constants, so the fingerprint cannot describe a pod shape different
|
||||
/// from the one actually created. Threading them through as arguments
|
||||
/// would make that agreement a thing to test instead of a thing that holds.
|
||||
pub fn new(
|
||||
namespace: &str,
|
||||
image: &ImageRef,
|
||||
resources: &crate::config::Resources,
|
||||
service_account: Option<&str>,
|
||||
env_keys: impl IntoIterator<Item = String>,
|
||||
) -> Self {
|
||||
let mut env_keys: Vec<String> = env_keys.into_iter().collect();
|
||||
env_keys.sort();
|
||||
Self {
|
||||
template_version: TEMPLATE_VERSION,
|
||||
namespace: namespace.to_string(),
|
||||
image: image.as_str().to_string(),
|
||||
cpu_request: resources.cpu_request.clone(),
|
||||
memory_request: resources.memory_request.clone(),
|
||||
cpu_limit: resources.cpu_limit.clone(),
|
||||
memory_limit: resources.memory_limit.clone(),
|
||||
service_account: service_account.map(str::to_string),
|
||||
restart_policy: crate::config::RESTART_POLICY,
|
||||
termination_grace_period_seconds: crate::config::TERMINATION_GRACE_SECONDS,
|
||||
env_keys,
|
||||
env_from_secret: SECRET_PLACEHOLDER,
|
||||
workspace_mount_path: crate::config::WORKSPACE_PATH.to_string(),
|
||||
run_as_user: crate::config::RUN_AS_UID,
|
||||
run_as_group: crate::config::RUN_AS_GID,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Resources;
|
||||
|
||||
fn image(byte: char) -> ImageRef {
|
||||
crate::image::parse(&format!(
|
||||
"ghcr.io/block/buzz-sprig@sha256:{}",
|
||||
byte.to_string().repeat(64)
|
||||
))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn template() -> IntentTemplate {
|
||||
IntentTemplate::new(
|
||||
"buzz-agents",
|
||||
&image('a'),
|
||||
&Resources::default(),
|
||||
None,
|
||||
["BUZZ_RELAY_URL".to_string(), "GOOSE_MODE".to_string()],
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_is_deterministic() {
|
||||
assert_eq!(template().fingerprint(), template().fingerprint());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_is_hex_sha256() {
|
||||
let fp = template().fingerprint();
|
||||
assert_eq!(fp.as_str().len(), 64);
|
||||
assert!(fp.as_str().chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
|
||||
/// Key *order* must not reach the digest, or two identical environments
|
||||
/// built in different orders would look like a config change.
|
||||
#[test]
|
||||
fn env_key_order_does_not_affect_the_digest() {
|
||||
let a = IntentTemplate::new(
|
||||
"ns",
|
||||
&image('a'),
|
||||
&Resources::default(),
|
||||
None,
|
||||
["A".to_string(), "B".to_string(), "C".to_string()],
|
||||
);
|
||||
let b = IntentTemplate::new(
|
||||
"ns",
|
||||
&image('a'),
|
||||
&Resources::default(),
|
||||
None,
|
||||
["C".to_string(), "A".to_string(), "B".to_string()],
|
||||
);
|
||||
assert_eq!(a.fingerprint(), b.fingerprint());
|
||||
}
|
||||
|
||||
/// A mutation applied to a fresh template clone, named for its assertion
|
||||
/// message.
|
||||
type Mutation = (&'static str, Box<dyn Fn(&mut IntentTemplate)>);
|
||||
|
||||
/// Every scheduling-relevant knob must move the digest — this is the
|
||||
/// wedge escape (§Deploy State Machine never-started recoverable row).
|
||||
/// Exhaustive by construction: each mutation is applied to a fresh clone.
|
||||
#[test]
|
||||
fn every_scheduling_field_changes_the_digest() {
|
||||
let base = template();
|
||||
let baseline = base.fingerprint();
|
||||
|
||||
let mutations: Vec<Mutation> = vec![
|
||||
(
|
||||
"template_version",
|
||||
Box::new(|t: &mut IntentTemplate| t.template_version += 1),
|
||||
),
|
||||
(
|
||||
"namespace",
|
||||
Box::new(|t: &mut IntentTemplate| t.namespace = "other".into()),
|
||||
),
|
||||
(
|
||||
"image",
|
||||
Box::new(|t: &mut IntentTemplate| t.image = image('b').as_str().into()),
|
||||
),
|
||||
(
|
||||
"cpu_request",
|
||||
Box::new(|t: &mut IntentTemplate| t.cpu_request = "4".into()),
|
||||
),
|
||||
(
|
||||
"memory_request",
|
||||
Box::new(|t: &mut IntentTemplate| t.memory_request = "8Gi".into()),
|
||||
),
|
||||
(
|
||||
"cpu_limit",
|
||||
Box::new(|t: &mut IntentTemplate| t.cpu_limit = "8".into()),
|
||||
),
|
||||
(
|
||||
"memory_limit",
|
||||
Box::new(|t: &mut IntentTemplate| t.memory_limit = "16Gi".into()),
|
||||
),
|
||||
(
|
||||
"service_account",
|
||||
Box::new(|t: &mut IntentTemplate| t.service_account = Some("sa".into())),
|
||||
),
|
||||
(
|
||||
"restart_policy",
|
||||
Box::new(|t: &mut IntentTemplate| t.restart_policy = "OnFailure"),
|
||||
),
|
||||
(
|
||||
"grace_period",
|
||||
Box::new(|t: &mut IntentTemplate| t.termination_grace_period_seconds = 30),
|
||||
),
|
||||
(
|
||||
"env_keys",
|
||||
Box::new(|t: &mut IntentTemplate| t.env_keys.push("NEW_KEY".into())),
|
||||
),
|
||||
(
|
||||
"workspace_mount_path",
|
||||
Box::new(|t: &mut IntentTemplate| t.workspace_mount_path = "/w".into()),
|
||||
),
|
||||
(
|
||||
"run_as_user",
|
||||
Box::new(|t: &mut IntentTemplate| t.run_as_user = 2000),
|
||||
),
|
||||
(
|
||||
"run_as_group",
|
||||
Box::new(|t: &mut IntentTemplate| t.run_as_group = 2000),
|
||||
),
|
||||
];
|
||||
|
||||
for (name, mutate) in mutations {
|
||||
let mut t = base.clone();
|
||||
mutate(&mut t);
|
||||
assert_ne!(
|
||||
t.fingerprint(),
|
||||
baseline,
|
||||
"{name} did not affect the digest"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The scope rule, asserted on the bytes: no Secret value and no
|
||||
/// generation token can appear in the serialization, because the type has
|
||||
/// nowhere to put them. The placeholder is what `envFrom` contributes.
|
||||
#[test]
|
||||
fn serialization_contains_no_secret_material_or_attempt_identity() {
|
||||
let json = serde_json::to_string(&template()).unwrap();
|
||||
for forbidden in ["nsec1", "SPOOFED", "wss://", "gen0001"] {
|
||||
assert!(
|
||||
!json.contains(forbidden),
|
||||
"template leaked {forbidden}: {json}"
|
||||
);
|
||||
}
|
||||
assert!(json.contains(SECRET_PLACEHOLDER));
|
||||
}
|
||||
|
||||
/// Two attempts for the same agent differ only in generation, which is
|
||||
/// absent from the template — so their fingerprints must be equal, or the
|
||||
/// divergence discriminator would fire on every single deploy.
|
||||
#[test]
|
||||
fn attempts_differing_only_by_generation_do_not_diverge() {
|
||||
// There is no generation input to pass; that *is* the property. The
|
||||
// test states it explicitly so a future field addition breaks here.
|
||||
assert_eq!(template().fingerprint(), template().fingerprint());
|
||||
let json = serde_json::to_string(&template()).unwrap();
|
||||
assert_eq!(json.matches(SECRET_PLACEHOLDER).count(), 1);
|
||||
}
|
||||
|
||||
/// A recorded annotation this provider version did not write reads as
|
||||
/// divergence rather than an error.
|
||||
#[test]
|
||||
fn unrecognized_annotation_reads_as_divergence() {
|
||||
let recorded = Fingerprint::from_annotation("not-a-digest");
|
||||
assert_ne!(recorded, template().fingerprint());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
//! Kubernetes backend provider for Buzz remote agents
|
||||
//! (spec `docs/remote-agents.md`).
|
||||
//!
|
||||
//! One process per operation: read exactly one JSON request from stdin, write
|
||||
//! exactly one JSON response to stdout, exit. The exit code carries exactly
|
||||
//! one bit — 0 for a response that was produced, 1 for a failure to produce
|
||||
//! one. Everything a caller needs to distinguish is *inside* the response's
|
||||
//! `ok` field, because a provider that encoded outcomes in exit codes would
|
||||
//! have a second, redundant error channel to keep in sync (§Provider Protocol).
|
||||
|
||||
mod classify;
|
||||
mod client;
|
||||
mod cluster;
|
||||
mod config;
|
||||
mod env;
|
||||
mod gc;
|
||||
mod image;
|
||||
mod intent;
|
||||
mod naming;
|
||||
mod observe;
|
||||
mod pod;
|
||||
mod reconcile;
|
||||
mod wire;
|
||||
|
||||
use std::io::Read;
|
||||
use wire::{Request, Response};
|
||||
|
||||
/// The provider a shared-compute agent resolves to. Refused here as the
|
||||
/// spec's backstop: a mesh agent runs on the relay's compute, so deploying it
|
||||
/// as a pod would create a second, contending consumer of the same agent
|
||||
/// identity (`:214-219`).
|
||||
const RELAY_MESH_PROVIDER: &str = "relay-mesh";
|
||||
|
||||
fn main() {
|
||||
// rustls needs a process-level provider before the first TLS connection.
|
||||
// The release build compiles every sidecar in one cargo invocation, which
|
||||
// unifies the `ring` and `aws-lc-rs` features and leaves rustls unable to
|
||||
// auto-select — so this is an explicit install, not a default.
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
let mut input = String::new();
|
||||
if let Err(e) = std::io::stdin().read_to_string(&mut input) {
|
||||
// No request means no request_id and no response contract to honor.
|
||||
// This is the one path that exits nonzero.
|
||||
eprintln!("could not read the request from stdin: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let response = respond(&input);
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&response).unwrap_or_else(|e| {
|
||||
// The response types are plain data; this cannot fail in practice,
|
||||
// and a hand-built object is still a conforming response.
|
||||
format!(r#"{{"ok":false,"error":"could not serialize a response: {e}"}}"#)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// Produce the single response for one request. Separated from `main` so the
|
||||
/// whole dispatch is testable without a process.
|
||||
fn respond(input: &str) -> Response {
|
||||
// Parsed as raw JSON first: the relay-mesh refusal below MUST see the wire
|
||||
// value, and `AgentPayload` deliberately does not carry `provider`.
|
||||
let raw: serde_json::Value = match serde_json::from_str(input) {
|
||||
Ok(value) => value,
|
||||
Err(e) => return Response::error(format!("request is not valid JSON: {e}")),
|
||||
};
|
||||
|
||||
if let Some(refusal) = refuse_relay_mesh(&raw) {
|
||||
return Response::error(refusal);
|
||||
}
|
||||
|
||||
let request: Request = match serde_json::from_value(raw) {
|
||||
Ok(request) => request,
|
||||
Err(e) => return Response::error(format!("could not understand the request: {e}")),
|
||||
};
|
||||
|
||||
match request {
|
||||
Request::Info => Response::info(),
|
||||
Request::Deploy(deploy) => {
|
||||
let runtime = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(runtime) => runtime,
|
||||
Err(e) => return Response::error(format!("could not start the runtime: {e}")),
|
||||
};
|
||||
match runtime.block_on(deploy_agent(&deploy)) {
|
||||
Ok(agent_id) => Response::deployed(agent_id),
|
||||
Err(e) => Response::error(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuse a shared-compute agent, reading the **raw wire value**.
|
||||
///
|
||||
/// Trimmed before comparing: the desktop's own layers disagree about padding
|
||||
/// (`relay_mesh.rs:17` and `effective_config/mod.rs:46` trim; the deploy guard
|
||||
/// at `agents_deploy.rs:116` did not), and `non_blank` preserves surrounding
|
||||
/// whitespace on a non-blank value. A backstop that shares its bypass with the
|
||||
/// layer it backs is not a backstop.
|
||||
fn refuse_relay_mesh(raw: &serde_json::Value) -> Option<String> {
|
||||
let provider = raw.get("agent")?.get("provider")?.as_str()?;
|
||||
(provider.trim() == RELAY_MESH_PROVIDER).then(|| {
|
||||
"deploy refused: this agent is configured for shared compute \
|
||||
(relay-mesh), which runs on the relay rather than in a pod. \
|
||||
Switch the agent to a local runtime before deploying it to \
|
||||
Kubernetes."
|
||||
.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
/// Run one deploy to a terminal outcome.
|
||||
async fn deploy_agent(request: &wire::DeployRequest) -> Result<String, String> {
|
||||
let cfg = config::parse(&request.provider_config)?;
|
||||
// Identity before any cluster contact: a malformed nsec is a refusal, not
|
||||
// a failed connection (§Deploy State Machine step 0).
|
||||
let identity = naming::AgentIdentity::from_nsec(&request.agent.private_key_nsec)?;
|
||||
|
||||
// One generation for this operation's first attempt; the reconciler mints
|
||||
// its own per attempt and restamps the correlator to match.
|
||||
let env = env::build_env(
|
||||
&request.agent,
|
||||
env::AuthoritativeInputs {
|
||||
generation: &naming::new_generation(),
|
||||
inactivity_seconds: cfg.inactivity_seconds,
|
||||
},
|
||||
)?;
|
||||
|
||||
let client = client::connect(cfg.context.as_deref()).await?;
|
||||
let substrate = cluster::Cluster::new(client, &cfg.namespace);
|
||||
reconcile::deploy(&substrate, &identity, &cfg, env).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn error_of(response: &Response) -> String {
|
||||
let json = serde_json::to_value(response).unwrap();
|
||||
assert_eq!(json["ok"], false, "expected a refusal: {json}");
|
||||
json["error"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
/// The spec's backstop for the relay-mesh MUST. The desktop refuses first
|
||||
/// (`agents_deploy.rs:116`); this is the layer that owes the obligation.
|
||||
#[test]
|
||||
fn refuses_a_relay_mesh_agent() {
|
||||
let request = r#"{"op":"deploy","agent":{
|
||||
"relay_url":"wss://r","private_key_nsec":"nsec1x","provider":"relay-mesh"},
|
||||
"provider_config":{"namespace":"ns"}}"#;
|
||||
assert!(error_of(&respond(request)).contains("relay-mesh"));
|
||||
}
|
||||
|
||||
/// Padding must not bypass the backstop. Reachable by construction:
|
||||
/// `GlobalConfig.provider` is a bare `Option<String>` with no trim on
|
||||
/// write, and `non_blank` rejects whitespace-only while preserving
|
||||
/// surrounding whitespace on everything else.
|
||||
#[test]
|
||||
fn refuses_a_padded_relay_mesh_agent() {
|
||||
let request = r#"{"op":"deploy","agent":{
|
||||
"relay_url":"wss://r","private_key_nsec":"nsec1x","provider":" relay-mesh "},
|
||||
"provider_config":{"namespace":"ns"}}"#;
|
||||
assert!(error_of(&respond(request)).contains("relay-mesh"));
|
||||
}
|
||||
|
||||
/// The refusal must not fire on a normal agent — a guard that refuses
|
||||
/// everything passes its own test and ships a provider that deploys
|
||||
/// nothing.
|
||||
#[test]
|
||||
fn does_not_refuse_a_normal_provider() {
|
||||
let raw: serde_json::Value =
|
||||
serde_json::from_str(r#"{"agent":{"provider":"openai"}}"#).unwrap();
|
||||
assert!(refuse_relay_mesh(&raw).is_none());
|
||||
// …nor when the field is absent entirely, which is the common case:
|
||||
// `AgentPayload` does not carry `provider`.
|
||||
let bare: serde_json::Value = serde_json::from_str(r#"{"agent":{}}"#).unwrap();
|
||||
assert!(refuse_relay_mesh(&bare).is_none());
|
||||
}
|
||||
|
||||
/// Malformed input still produces exactly one conforming response.
|
||||
#[test]
|
||||
fn malformed_input_is_an_in_band_error() {
|
||||
assert!(error_of(&respond("not json")).contains("valid JSON"));
|
||||
assert!(error_of(&respond(r#"{"op":"undeploy"}"#)).contains("understand"));
|
||||
}
|
||||
|
||||
/// `info` answers without touching a cluster — it is what the desktop
|
||||
/// calls to render the config form, before any kubeconfig exists.
|
||||
#[test]
|
||||
fn info_answers_with_the_protocol_version_and_schema() {
|
||||
let json = serde_json::to_value(respond(r#"{"op":"info"}"#)).unwrap();
|
||||
assert_eq!(json["ok"], true);
|
||||
assert_eq!(json["protocol_version"], wire::PROTOCOL_VERSION);
|
||||
assert!(json["config_schema"]["properties"]["namespace"].is_object());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
//! Identity derivation and the object-naming contract (spec §Pod shape).
|
||||
//!
|
||||
//! Every name, label, and annotation below is derived from the pubkey the
|
||||
//! provider decoded itself from `private_key_nsec` — never from a
|
||||
//! caller-supplied pubkey (§Deploy State Machine step 0).
|
||||
|
||||
use nostr::nips::nip19::FromBech32;
|
||||
|
||||
/// `app.kubernetes.io/managed-by` value: the management marker's identity half.
|
||||
pub const MANAGED_BY: &str = "buzz-backend-kubernetes";
|
||||
|
||||
/// Label key carrying [`MANAGED_BY`].
|
||||
pub const LABEL_MANAGED_BY: &str = "app.kubernetes.io/managed-by";
|
||||
|
||||
/// Label key carrying [`BINDING_VERSION`] — the marker's schema half.
|
||||
pub const LABEL_BINDING_VERSION: &str = "buzz.block.xyz/binding-version";
|
||||
|
||||
/// Schema version of the object layout this provider writes. Bumped when the
|
||||
/// pod/Secret shape changes in a way a older provider would mis-handle.
|
||||
pub const BINDING_VERSION: &str = "1";
|
||||
|
||||
/// Label key: truncated pubkey, the reconciliation and GC selector.
|
||||
pub const LABEL_AGENT_PUBKEY: &str = "buzz.block.xyz/agent-pubkey";
|
||||
|
||||
/// Annotation key: full pubkey. Load-bearing — the truncated label is
|
||||
/// collision-*resistant*, this is what makes it safe (§Deploy State Machine
|
||||
/// step 1).
|
||||
pub const ANNOTATION_PUBKEY_FULL: &str = "buzz.block.xyz/agent-pubkey-full";
|
||||
|
||||
/// Annotation key: the recorded create-intent fingerprint.
|
||||
pub const ANNOTATION_CREATE_INTENT: &str = "buzz.block.xyz/create-intent";
|
||||
|
||||
/// Annotation key: the image reference this generation actually resolved to,
|
||||
/// for post-hoc attribution (§Image).
|
||||
pub const ANNOTATION_IMAGE: &str = "buzz.block.xyz/image";
|
||||
|
||||
/// An agent identity the provider derived itself, plus every name it implies.
|
||||
///
|
||||
/// Constructing this type is the *only* way to obtain the names — so a
|
||||
/// caller-supplied pubkey cannot reach a selector by any path.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AgentIdentity {
|
||||
pubkey_hex: String,
|
||||
}
|
||||
|
||||
impl AgentIdentity {
|
||||
/// Derive from the payload's `private_key_nsec`.
|
||||
///
|
||||
/// Accepts bech32 `nsec1…`; a malformed or undecodable key is an
|
||||
/// immediate error, before any substrate read or mutation
|
||||
/// (§Deploy State Machine step 0).
|
||||
pub fn from_nsec(nsec: &str) -> Result<Self, String> {
|
||||
let secret = nostr::SecretKey::from_bech32(nsec.trim())
|
||||
.map_err(|_| "private_key_nsec is not a decodable nsec1 key".to_string())?;
|
||||
let keys = nostr::Keys::new(secret);
|
||||
Ok(Self {
|
||||
pubkey_hex: keys.public_key().to_hex(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Full 64-hex public key — the annotation value and the comparison
|
||||
/// operand for candidate authentication.
|
||||
pub fn pubkey_hex(&self) -> &str {
|
||||
&self.pubkey_hex
|
||||
}
|
||||
|
||||
/// Selector label value: first 32 hex chars (128 bits). A full hex pubkey
|
||||
/// is 64 chars and label values cap at 63, which is why this is truncated
|
||||
/// and why the annotation check is normative rather than decorative.
|
||||
pub fn label_pubkey(&self) -> &str {
|
||||
&self.pubkey_hex[..32]
|
||||
}
|
||||
|
||||
/// Deterministic pod name, also the returned `agent_id`.
|
||||
pub fn pod_name(&self) -> String {
|
||||
format!("buzz-agent-{}", &self.pubkey_hex[..12])
|
||||
}
|
||||
|
||||
/// Per-attempt Secret name. `generation` is a fresh random token per
|
||||
/// create attempt — never reused — which is what makes payload and Secret
|
||||
/// atomic at the pod-spec boundary (§K8s Secrets).
|
||||
pub fn secret_name(&self, generation: &str) -> String {
|
||||
format!("buzz-agent-{}-{}", &self.pubkey_hex[..12], generation)
|
||||
}
|
||||
|
||||
/// Label selector matching this identity's objects *and* our management
|
||||
/// marker. Selecting on the marker as well as the identity means an
|
||||
/// unmarked look-alike never even enters the candidate list.
|
||||
pub fn selector(&self) -> String {
|
||||
format!(
|
||||
"{LABEL_AGENT_PUBKEY}={},{LABEL_MANAGED_BY}={MANAGED_BY}",
|
||||
self.label_pubkey()
|
||||
)
|
||||
}
|
||||
|
||||
/// The label set stamped on every object this provider creates.
|
||||
pub fn labels(&self) -> std::collections::BTreeMap<String, String> {
|
||||
[
|
||||
(
|
||||
LABEL_AGENT_PUBKEY.to_string(),
|
||||
self.label_pubkey().to_string(),
|
||||
),
|
||||
(LABEL_MANAGED_BY.to_string(), MANAGED_BY.to_string()),
|
||||
(
|
||||
LABEL_BINDING_VERSION.to_string(),
|
||||
BINDING_VERSION.to_string(),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A fresh generation token: 8 lowercase hex chars from the OS RNG.
|
||||
///
|
||||
/// Appears in the Secret name and as `BUZZ_MANAGED_AGENT_START_NONCE`, so the
|
||||
/// Secret generation and the harness's lifecycle-frame correlator are one
|
||||
/// identity (§Launch data tier 3).
|
||||
pub fn new_generation() -> String {
|
||||
use rand::RngExt;
|
||||
let n: u32 = rand::rng().random();
|
||||
format!("{n:08x}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A fixed test key. Deriving the pubkey (rather than hardcoding both
|
||||
/// halves) is the point: the test exercises the same derivation the
|
||||
/// reconciler depends on.
|
||||
fn identity() -> AgentIdentity {
|
||||
let keys = nostr::Keys::generate();
|
||||
let nsec = {
|
||||
use nostr::nips::nip19::ToBech32;
|
||||
keys.secret_key().to_bech32().unwrap()
|
||||
};
|
||||
let id = AgentIdentity::from_nsec(&nsec).unwrap();
|
||||
assert_eq!(id.pubkey_hex(), keys.public_key().to_hex());
|
||||
id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_nsec() {
|
||||
for bad in ["", "nsec1", "not-a-key", "npub1abc"] {
|
||||
assert!(
|
||||
AgentIdentity::from_nsec(bad).is_err(),
|
||||
"accepted malformed key {bad:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tolerates_surrounding_whitespace() {
|
||||
let keys = nostr::Keys::generate();
|
||||
use nostr::nips::nip19::ToBech32;
|
||||
let nsec = keys.secret_key().to_bech32().unwrap();
|
||||
let padded = format!(" {nsec}\n");
|
||||
assert_eq!(
|
||||
AgentIdentity::from_nsec(&padded).unwrap().pubkey_hex(),
|
||||
keys.public_key().to_hex()
|
||||
);
|
||||
}
|
||||
|
||||
/// Kubernetes label *values* cap at 63 chars; a full hex pubkey is 64,
|
||||
/// one over. That one-char overflow is the whole reason the selector is
|
||||
/// truncated, so it gets an explicit test.
|
||||
#[test]
|
||||
fn label_value_fits_kubernetes_limit() {
|
||||
let id = identity();
|
||||
assert_eq!(id.pubkey_hex().len(), 64);
|
||||
assert_eq!(id.label_pubkey().len(), 32);
|
||||
assert!(id.label_pubkey().len() <= 63);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pod_name_is_deterministic_and_dns_safe() {
|
||||
let id = identity();
|
||||
assert_eq!(id.pod_name(), id.pod_name());
|
||||
assert_eq!(
|
||||
id.pod_name(),
|
||||
format!("buzz-agent-{}", &id.pubkey_hex()[..12])
|
||||
);
|
||||
assert!(id.pod_name().len() <= 253);
|
||||
assert!(id
|
||||
.pod_name()
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'));
|
||||
}
|
||||
|
||||
/// Two attempts must never share a Secret name — that uniqueness is what
|
||||
/// stops a losing contender from overwriting the winner's identity.
|
||||
#[test]
|
||||
fn secret_names_are_per_attempt() {
|
||||
let id = identity();
|
||||
let a = id.secret_name(&new_generation());
|
||||
let b = id.secret_name(&new_generation());
|
||||
assert_ne!(a, b);
|
||||
assert!(a.starts_with(&id.pod_name()));
|
||||
assert!(a.len() <= 253);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selector_requires_the_management_marker() {
|
||||
let id = identity();
|
||||
let sel = id.selector();
|
||||
assert!(sel.contains(&format!("{LABEL_AGENT_PUBKEY}={}", id.label_pubkey())));
|
||||
assert!(sel.contains(&format!("{LABEL_MANAGED_BY}={MANAGED_BY}")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_created_object_carries_the_marker() {
|
||||
let labels = identity().labels();
|
||||
assert_eq!(
|
||||
labels.get(LABEL_MANAGED_BY).map(String::as_str),
|
||||
Some(MANAGED_BY)
|
||||
);
|
||||
assert_eq!(
|
||||
labels.get(LABEL_BINDING_VERSION).map(String::as_str),
|
||||
Some(BINDING_VERSION)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,592 @@
|
||||
//! Decoding API objects into verified observations (spec §Deploy State
|
||||
//! Machine step 1).
|
||||
//!
|
||||
//! Pure: `Pod` in, [`VerifiedPod`] out. Keeping the decode here means the
|
||||
//! conformance tests drive the *shipped* decoder with real API types rather
|
||||
//! than a test-only stand-in, and it keeps `classify.rs` free of API types.
|
||||
//!
|
||||
//! Verification is the gate, not a filter: [`verify`] returns `None` for any
|
||||
//! object whose full-pubkey annotation does not equal the derived pubkey or
|
||||
//! that lacks the management marker, so an unverified object cannot reach
|
||||
//! classification, deletion, or the returned `agent_id`.
|
||||
|
||||
use crate::classify::{Fence, PullFailure, Startup, VerifiedPod};
|
||||
use crate::intent::Fingerprint;
|
||||
use crate::naming::{
|
||||
AgentIdentity, ANNOTATION_CREATE_INTENT, ANNOTATION_PUBKEY_FULL, BINDING_VERSION,
|
||||
LABEL_BINDING_VERSION, LABEL_MANAGED_BY, MANAGED_BY,
|
||||
};
|
||||
use k8s_openapi::api::core::v1::{Pod, Secret};
|
||||
|
||||
/// Container name the provider creates; status is read from this container.
|
||||
pub const CONTAINER_NAME: &str = "agent";
|
||||
|
||||
/// The startup state, or a state that cannot be settled without one more read.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum StartupObservation {
|
||||
Resolved(Startup),
|
||||
/// `CreateContainerConfigError` — recoverable *unless* the referenced
|
||||
/// Secret is confirmed absent by a most-recent read. The kubelet's reason
|
||||
/// string is a hint; the provider verifies before treating it as fatal
|
||||
/// (§Deploy State Machine: "provably" means a verified absence, never a
|
||||
/// reason string).
|
||||
ConfigErrorPendingSecretCheck {
|
||||
secret_name: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Does this object carry the management marker (§Pod shape)?
|
||||
///
|
||||
/// Identity labels prove identity; the marker asserts protocol ownership.
|
||||
/// Without it an object that merely matches our schema fails closed.
|
||||
fn has_marker(labels: Option<&std::collections::BTreeMap<String, String>>) -> bool {
|
||||
let Some(labels) = labels else { return false };
|
||||
labels.get(LABEL_MANAGED_BY).map(String::as_str) == Some(MANAGED_BY)
|
||||
&& labels.get(LABEL_BINDING_VERSION).map(String::as_str) == Some(BINDING_VERSION)
|
||||
}
|
||||
|
||||
/// Does the full-pubkey annotation equal the derived pubkey?
|
||||
///
|
||||
/// The 32-hex label is collision-*resistant*, not collision-free, which is
|
||||
/// why this check is normative rather than decorative (`:1152-1166`).
|
||||
fn annotation_matches(
|
||||
annotations: Option<&std::collections::BTreeMap<String, String>>,
|
||||
identity: &AgentIdentity,
|
||||
) -> bool {
|
||||
annotations
|
||||
.and_then(|a| a.get(ANNOTATION_PUBKEY_FULL))
|
||||
.map(|v| v == identity.pubkey_hex())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Is this Secret ours and this identity's? The same fence GC applies before
|
||||
/// deleting anything.
|
||||
pub fn secret_is_ours(secret: &Secret, identity: &AgentIdentity) -> bool {
|
||||
has_marker(secret.metadata.labels.as_ref())
|
||||
&& annotation_matches(secret.metadata.annotations.as_ref(), identity)
|
||||
}
|
||||
|
||||
/// Decode a pod's startup state from its status.
|
||||
///
|
||||
/// "Started" means `state.running` on our container — not pod phase. A pod can
|
||||
/// sit in phase `Running` with a container that never started, and a pod being
|
||||
/// gracefully deleted stays in phase `Running` for its whole grace period.
|
||||
pub fn decode_startup(pod: &Pod) -> StartupObservation {
|
||||
use StartupObservation::Resolved;
|
||||
|
||||
let status = pod.status.as_ref();
|
||||
let phase = status.and_then(|s| s.phase.as_deref());
|
||||
|
||||
let container = status
|
||||
.and_then(|s| s.container_statuses.as_ref())
|
||||
.and_then(|cs| cs.iter().find(|c| c.name == CONTAINER_NAME));
|
||||
|
||||
if let Some(state) = container.and_then(|c| c.state.as_ref()) {
|
||||
if state.running.is_some() {
|
||||
return Resolved(Startup::Started);
|
||||
}
|
||||
if state.terminated.is_some() {
|
||||
return Resolved(Startup::Terminated);
|
||||
}
|
||||
if let Some(waiting) = state.waiting.as_ref() {
|
||||
let reason = waiting.reason.as_deref().unwrap_or_default();
|
||||
let message = waiting.message.as_deref().unwrap_or_default();
|
||||
return match reason {
|
||||
// Structurally invalid reference: no retry can fix it.
|
||||
"InvalidImageName" => Resolved(Startup::NeverStartedProvablyBroken),
|
||||
"ErrImagePull" | "ImagePullBackOff" => match classify_pull_failure(message) {
|
||||
Some(failure) => Resolved(Startup::NeverStartedPullFailing(failure)),
|
||||
None => Resolved(Startup::NeverStartedRecoverable),
|
||||
},
|
||||
"CreateContainerConfigError" => match referenced_secret(pod) {
|
||||
Some(secret_name) => {
|
||||
StartupObservation::ConfigErrorPendingSecretCheck { secret_name }
|
||||
}
|
||||
None => Resolved(Startup::NeverStartedRecoverable),
|
||||
},
|
||||
_ => Resolved(Startup::NeverStartedRecoverable),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// No container status yet (unscheduled, image pulling before the kubelet
|
||||
// reports, quota-blocked). A terminal phase without container status still
|
||||
// means the pod is done.
|
||||
match phase {
|
||||
Some("Succeeded") | Some("Failed") => Resolved(Startup::Terminated),
|
||||
_ => Resolved(Startup::NeverStartedRecoverable),
|
||||
}
|
||||
}
|
||||
|
||||
/// The Secret name this pod's `envFrom` references, if any.
|
||||
pub fn referenced_secret(pod: &Pod) -> Option<String> {
|
||||
pod.spec
|
||||
.as_ref()?
|
||||
.containers
|
||||
.iter()
|
||||
.flat_map(|c| c.env_from.iter().flatten())
|
||||
.find_map(|source| source.secret_ref.as_ref().map(|r| r.name.clone()))
|
||||
}
|
||||
|
||||
/// Classify a pull failure from the kubelet's message.
|
||||
///
|
||||
/// Reporting only — [`PullFailure`] is structurally excluded from
|
||||
/// `Action::Delete`, so a wrong guess here can delay a report but can never
|
||||
/// destroy anything. `None` means "no permanent cause recognized", which
|
||||
/// leaves the pod on the ordinary observational path.
|
||||
fn classify_pull_failure(message: &str) -> Option<PullFailure> {
|
||||
let m = message.to_ascii_lowercase();
|
||||
if m.contains("401")
|
||||
|| m.contains("unauthorized")
|
||||
|| m.contains("403")
|
||||
|| m.contains("denied")
|
||||
|| m.contains("authentication required")
|
||||
{
|
||||
return Some(PullFailure::Unauthorized);
|
||||
}
|
||||
if m.contains("manifest unknown")
|
||||
|| m.contains("not found")
|
||||
|| m.contains("manifest_unknown")
|
||||
|| m.contains("repository does not exist")
|
||||
{
|
||||
return Some(PullFailure::ManifestUnknown);
|
||||
}
|
||||
if m.contains("no match for platform") || m.contains("no matching manifest") {
|
||||
return Some(PullFailure::ArchMismatch);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The redacted, actionable condition text for a pull failure.
|
||||
///
|
||||
/// Names the registry and the immutable reference — never credentials, and
|
||||
/// never the kubelet's raw message, which can echo a registry token.
|
||||
pub fn pull_failure_message(failure: PullFailure, image: &str) -> String {
|
||||
let registry = image.split('/').next().unwrap_or(image);
|
||||
match failure {
|
||||
PullFailure::Unauthorized => format!(
|
||||
"the cluster is not authorized to pull {image} from {registry}. \
|
||||
This pull retries indefinitely and will not succeed on its own: \
|
||||
grant the cluster's nodes access to that registry."
|
||||
),
|
||||
PullFailure::ManifestUnknown => {
|
||||
format!("{registry} has no image at {image}. Check the digest and repository.")
|
||||
}
|
||||
PullFailure::ArchMismatch => format!(
|
||||
"{image} has no variant for the architecture of the nodes it was \
|
||||
scheduled on."
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The latest actionable condition for a pod that has not started, redacted.
|
||||
///
|
||||
/// Two sources, deliberately treated differently:
|
||||
///
|
||||
/// * The container's waiting **reason** is included; its **message** is not.
|
||||
/// Waiting messages are kubelet-composed and echo the thing that failed —
|
||||
/// for a pull that is the registry request, which can carry credential
|
||||
/// material. The reason token alone (`ImagePullBackOff`,
|
||||
/// `CreateContainerConfigError`) is the diagnostic; the message adds
|
||||
/// exposure, not information the user can act on.
|
||||
/// * Pod-condition messages **are** included. They are scheduler- and
|
||||
/// kubelet-composed from the pod's own spec and cluster capacity
|
||||
/// ("0/3 nodes are available: Insufficient memory"), which is precisely the
|
||||
/// actionable half and contains nothing derived from Secret data.
|
||||
pub fn condition(pod: &Pod) -> Option<String> {
|
||||
let status = pod.status.as_ref()?;
|
||||
|
||||
if let Some(state) = status
|
||||
.container_statuses
|
||||
.as_ref()
|
||||
.and_then(|cs| cs.iter().find(|c| c.name == CONTAINER_NAME))
|
||||
.and_then(|c| c.state.as_ref())
|
||||
{
|
||||
if let Some(waiting) = state.waiting.as_ref() {
|
||||
if let Some(reason) = waiting.reason.as_deref() {
|
||||
return Some(format!("the container is waiting, reason {reason}"));
|
||||
}
|
||||
}
|
||||
// Exit code and reason only — the terminated `message` is
|
||||
// process-composed output and falls under the same redaction rule as
|
||||
// waiting messages.
|
||||
if let Some(terminated) = state.terminated.as_ref() {
|
||||
return Some(match terminated.reason.as_deref() {
|
||||
Some(reason) => format!(
|
||||
"the container exited with code {} ({reason})",
|
||||
terminated.exit_code
|
||||
),
|
||||
None => format!("the container exited with code {}", terminated.exit_code),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((type_, reason, message)) = status.conditions.as_ref().and_then(|cs| {
|
||||
cs.iter().find(|c| c.status == "False").map(|c| {
|
||||
(
|
||||
c.type_.clone(),
|
||||
c.reason.clone().unwrap_or_default(),
|
||||
c.message.clone().unwrap_or_default(),
|
||||
)
|
||||
})
|
||||
}) {
|
||||
let detail = [reason, message]
|
||||
.into_iter()
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(": ");
|
||||
return Some(if detail.is_empty() {
|
||||
format!("pod condition {type_} is false")
|
||||
} else {
|
||||
format!("pod condition {type_} is false: {detail}")
|
||||
});
|
||||
}
|
||||
|
||||
status.phase.as_deref().map(|p| format!("the pod is {p}"))
|
||||
}
|
||||
|
||||
/// Verify a label-selected pod and decode it, or reject it.
|
||||
///
|
||||
/// `startup` is supplied by the caller because settling
|
||||
/// `CreateContainerConfigError` needs a most-recent Secret read the pure layer
|
||||
/// must not perform.
|
||||
pub fn verify(pod: &Pod, identity: &AgentIdentity, startup: Startup) -> Option<VerifiedPod> {
|
||||
if !has_marker(pod.metadata.labels.as_ref()) {
|
||||
return None;
|
||||
}
|
||||
if !annotation_matches(pod.metadata.annotations.as_ref(), identity) {
|
||||
return None;
|
||||
}
|
||||
Some(VerifiedPod {
|
||||
name: pod.metadata.name.clone()?,
|
||||
fence: Fence {
|
||||
uid: pod.metadata.uid.clone()?,
|
||||
resource_version: pod.metadata.resource_version.clone()?,
|
||||
},
|
||||
deletion_marked: pod.metadata.deletion_timestamp.is_some(),
|
||||
startup,
|
||||
recorded_intent: pod
|
||||
.metadata
|
||||
.annotations
|
||||
.as_ref()
|
||||
.and_then(|a| a.get(ANNOTATION_CREATE_INTENT))
|
||||
.map(|v| Fingerprint::from_annotation(v)),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use k8s_openapi::api::core::v1::{
|
||||
ContainerState, ContainerStateRunning, ContainerStateTerminated, ContainerStateWaiting,
|
||||
ContainerStatus, PodStatus,
|
||||
};
|
||||
use k8s_openapi::apimachinery::pkg::apis::meta::v1::{ObjectMeta, Time};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn identity() -> AgentIdentity {
|
||||
use nostr::nips::nip19::ToBech32;
|
||||
let keys = nostr::Keys::generate();
|
||||
AgentIdentity::from_nsec(&keys.secret_key().to_bech32().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn base_pod(id: &AgentIdentity) -> Pod {
|
||||
Pod {
|
||||
metadata: ObjectMeta {
|
||||
name: Some(id.pod_name()),
|
||||
uid: Some("uid-1".into()),
|
||||
resource_version: Some("100".into()),
|
||||
labels: Some(id.labels()),
|
||||
annotations: Some(
|
||||
[(
|
||||
ANNOTATION_PUBKEY_FULL.to_string(),
|
||||
id.pubkey_hex().to_string(),
|
||||
)]
|
||||
.into_iter()
|
||||
.collect::<BTreeMap<_, _>>(),
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn with_container_state(mut pod: Pod, state: ContainerState) -> Pod {
|
||||
pod.status = Some(PodStatus {
|
||||
phase: Some("Running".into()),
|
||||
container_statuses: Some(vec![ContainerStatus {
|
||||
name: CONTAINER_NAME.into(),
|
||||
state: Some(state),
|
||||
..Default::default()
|
||||
}]),
|
||||
..Default::default()
|
||||
});
|
||||
pod
|
||||
}
|
||||
|
||||
fn waiting(reason: &str, message: &str) -> ContainerState {
|
||||
ContainerState {
|
||||
waiting: Some(ContainerStateWaiting {
|
||||
reason: Some(reason.into()),
|
||||
message: Some(message.into()),
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_container_is_started() {
|
||||
let id = identity();
|
||||
let pod = with_container_state(
|
||||
base_pod(&id),
|
||||
ContainerState {
|
||||
running: Some(ContainerStateRunning::default()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
decode_startup(&pod),
|
||||
StartupObservation::Resolved(Startup::Started)
|
||||
);
|
||||
}
|
||||
|
||||
/// Pod phase is not the criterion. A pod in phase `Running` whose
|
||||
/// container never started must NOT read as started, or the reconciler
|
||||
/// no-ops on a pod that will never serve.
|
||||
#[test]
|
||||
fn phase_running_with_waiting_container_is_not_started() {
|
||||
let id = identity();
|
||||
let pod = with_container_state(base_pod(&id), waiting("ContainerCreating", ""));
|
||||
assert_eq!(
|
||||
decode_startup(&pod),
|
||||
StartupObservation::Resolved(Startup::NeverStartedRecoverable)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminated_container_is_terminated() {
|
||||
let id = identity();
|
||||
let pod = with_container_state(
|
||||
base_pod(&id),
|
||||
ContainerState {
|
||||
terminated: Some(ContainerStateTerminated {
|
||||
exit_code: 0,
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
decode_startup(&pod),
|
||||
StartupObservation::Resolved(Startup::Terminated)
|
||||
);
|
||||
}
|
||||
|
||||
/// A terminal phase with no container status (evicted before the kubelet
|
||||
/// reported) is still terminated — otherwise the residue is never GC'd.
|
||||
#[test]
|
||||
fn terminal_phase_without_container_status_is_terminated() {
|
||||
let id = identity();
|
||||
for phase in ["Succeeded", "Failed"] {
|
||||
let mut pod = base_pod(&id);
|
||||
pod.status = Some(PodStatus {
|
||||
phase: Some(phase.into()),
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(
|
||||
decode_startup(&pod),
|
||||
StartupObservation::Resolved(Startup::Terminated),
|
||||
"phase {phase}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_image_name_is_provably_broken() {
|
||||
let id = identity();
|
||||
let pod = with_container_state(base_pod(&id), waiting("InvalidImageName", "bad ref"));
|
||||
assert_eq!(
|
||||
decode_startup(&pod),
|
||||
StartupObservation::Resolved(Startup::NeverStartedProvablyBroken)
|
||||
);
|
||||
}
|
||||
|
||||
/// Permanent pull failures are recognized from the message; anything
|
||||
/// unrecognized stays on the ordinary observational path rather than
|
||||
/// being guessed at.
|
||||
#[test]
|
||||
fn permanent_pull_failures_are_classified() {
|
||||
let id = identity();
|
||||
let cases = [
|
||||
("401 Unauthorized", PullFailure::Unauthorized),
|
||||
("pull access denied", PullFailure::Unauthorized),
|
||||
("manifest unknown", PullFailure::ManifestUnknown),
|
||||
(
|
||||
"no match for platform in manifest",
|
||||
PullFailure::ArchMismatch,
|
||||
),
|
||||
];
|
||||
for (message, expected) in cases {
|
||||
let pod = with_container_state(base_pod(&id), waiting("ErrImagePull", message));
|
||||
assert_eq!(
|
||||
decode_startup(&pod),
|
||||
StartupObservation::Resolved(Startup::NeverStartedPullFailing(expected)),
|
||||
"message {message:?}"
|
||||
);
|
||||
}
|
||||
|
||||
let pod = with_container_state(
|
||||
base_pod(&id),
|
||||
waiting("ImagePullBackOff", "dial tcp: i/o timeout"),
|
||||
);
|
||||
assert_eq!(
|
||||
decode_startup(&pod),
|
||||
StartupObservation::Resolved(Startup::NeverStartedRecoverable),
|
||||
"a transient network failure must not be reported as permanent"
|
||||
);
|
||||
}
|
||||
|
||||
/// The kubelet's reason string is a hint, not proof: a config error defers
|
||||
/// to a most-recent Secret read before anything is called broken.
|
||||
#[test]
|
||||
fn config_error_defers_to_a_secret_read() {
|
||||
let id = identity();
|
||||
let mut pod =
|
||||
with_container_state(base_pod(&id), waiting("CreateContainerConfigError", ""));
|
||||
pod.spec = Some(k8s_openapi::api::core::v1::PodSpec {
|
||||
containers: vec![k8s_openapi::api::core::v1::Container {
|
||||
name: CONTAINER_NAME.into(),
|
||||
env_from: Some(vec![k8s_openapi::api::core::v1::EnvFromSource {
|
||||
secret_ref: Some(k8s_openapi::api::core::v1::SecretEnvSource {
|
||||
name: "buzz-agent-abc-gen1".into(),
|
||||
optional: Some(false),
|
||||
}),
|
||||
..Default::default()
|
||||
}]),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(
|
||||
decode_startup(&pod),
|
||||
StartupObservation::ConfigErrorPendingSecretCheck {
|
||||
secret_name: "buzz-agent-abc-gen1".into()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// The auto-repair fence: an object that matches our schema but lacks the
|
||||
/// marker, or carries someone else's pubkey, is never verified — so it can
|
||||
/// never be no-op'd against, deleted, or returned as an `agent_id`.
|
||||
#[test]
|
||||
fn unmarked_or_mismatched_objects_fail_verification() {
|
||||
let id = identity();
|
||||
let other = identity();
|
||||
|
||||
let mut unmarked = base_pod(&id);
|
||||
unmarked.metadata.labels = Some(BTreeMap::new());
|
||||
assert!(
|
||||
verify(&unmarked, &id, Startup::Started).is_none(),
|
||||
"unmarked pod verified"
|
||||
);
|
||||
|
||||
let mut wrong_version = base_pod(&id);
|
||||
let mut labels = id.labels();
|
||||
labels.insert(LABEL_BINDING_VERSION.to_string(), "999".to_string());
|
||||
wrong_version.metadata.labels = Some(labels);
|
||||
assert!(verify(&wrong_version, &id, Startup::Started).is_none());
|
||||
|
||||
let mut mismatched = base_pod(&id);
|
||||
mismatched.metadata.annotations = Some(
|
||||
[(
|
||||
ANNOTATION_PUBKEY_FULL.to_string(),
|
||||
other.pubkey_hex().to_string(),
|
||||
)]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
);
|
||||
assert!(
|
||||
verify(&mismatched, &id, Startup::Started).is_none(),
|
||||
"collision verified"
|
||||
);
|
||||
|
||||
let mut missing = base_pod(&id);
|
||||
missing.metadata.annotations = Some(BTreeMap::new());
|
||||
assert!(verify(&missing, &id, Startup::Started).is_none());
|
||||
|
||||
assert!(
|
||||
verify(&base_pod(&id), &id, Startup::Started).is_some(),
|
||||
"own pod rejected"
|
||||
);
|
||||
}
|
||||
|
||||
/// The fence must come from the observed object, and the deletion mark
|
||||
/// must be read even though the phase says `Running`.
|
||||
#[test]
|
||||
fn verified_pod_carries_the_fence_and_deletion_mark() {
|
||||
let id = identity();
|
||||
let mut pod = base_pod(&id);
|
||||
pod.metadata.deletion_timestamp = Some(Time(chrono::Utc::now()));
|
||||
let verified = verify(&pod, &id, Startup::Started).unwrap();
|
||||
assert_eq!(verified.fence.uid, "uid-1");
|
||||
assert_eq!(verified.fence.resource_version, "100");
|
||||
assert!(verified.deletion_marked);
|
||||
}
|
||||
|
||||
/// A pod with no recorded intent reads as `None`, which the classifier
|
||||
/// groups with divergence.
|
||||
#[test]
|
||||
fn missing_intent_annotation_decodes_as_none() {
|
||||
let id = identity();
|
||||
assert!(verify(&base_pod(&id), &id, Startup::Started)
|
||||
.unwrap()
|
||||
.recorded_intent
|
||||
.is_none());
|
||||
}
|
||||
|
||||
/// A pull-failure report must name the registry and the immutable
|
||||
/// reference and nothing else — never the kubelet's raw message, which
|
||||
/// can echo a registry token.
|
||||
#[test]
|
||||
fn pull_failure_messages_are_actionable_and_redacted() {
|
||||
let image = format!("ghcr.io/block/buzz-sprig@sha256:{}", "a".repeat(64));
|
||||
for failure in [
|
||||
PullFailure::Unauthorized,
|
||||
PullFailure::ManifestUnknown,
|
||||
PullFailure::ArchMismatch,
|
||||
] {
|
||||
let msg = pull_failure_message(failure, &image);
|
||||
assert!(msg.contains("ghcr.io"), "{msg}");
|
||||
assert!(msg.contains(&image), "{msg}");
|
||||
for secret in ["Bearer", "password", "nsec1", "token"] {
|
||||
assert!(!msg.contains(secret), "leaked {secret}: {msg}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_ownership_requires_marker_and_annotation() {
|
||||
let id = identity();
|
||||
let other = identity();
|
||||
let ours = Secret {
|
||||
metadata: ObjectMeta {
|
||||
labels: Some(id.labels()),
|
||||
annotations: Some(
|
||||
[(
|
||||
ANNOTATION_PUBKEY_FULL.to_string(),
|
||||
id.pubkey_hex().to_string(),
|
||||
)]
|
||||
.into(),
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
assert!(secret_is_ours(&ours, &id));
|
||||
assert!(!secret_is_ours(&ours, &other));
|
||||
|
||||
let mut unmarked = ours.clone();
|
||||
unmarked.metadata.labels = Some(BTreeMap::new());
|
||||
assert!(!secret_is_ours(&unmarked, &id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
//! Pod and Secret construction (spec §Pod shape, §K8s Secrets).
|
||||
//!
|
||||
//! The builder is pure: it turns resolved inputs into API objects and performs
|
||||
//! no I/O, so every normative field is a unit assertion.
|
||||
|
||||
use crate::config::{
|
||||
ProviderConfig, RESTART_POLICY, RUN_AS_GID, RUN_AS_UID, TERMINATION_GRACE_SECONDS,
|
||||
WORKSPACE_PATH,
|
||||
};
|
||||
use crate::intent::{Fingerprint, IntentTemplate};
|
||||
use crate::naming::{
|
||||
AgentIdentity, ANNOTATION_CREATE_INTENT, ANNOTATION_IMAGE, ANNOTATION_PUBKEY_FULL,
|
||||
};
|
||||
use k8s_openapi::api::core::v1::{
|
||||
Capabilities, Container, EmptyDirVolumeSource, EnvFromSource, Pod, PodSecurityContext, PodSpec,
|
||||
ResourceRequirements, SeccompProfile, Secret, SecretEnvSource, SecurityContext, Volume,
|
||||
VolumeMount,
|
||||
};
|
||||
use k8s_openapi::apimachinery::pkg::api::resource::Quantity;
|
||||
use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Volume name for the agent's writable workspace.
|
||||
const WORKSPACE_VOLUME: &str = "workspace";
|
||||
|
||||
/// The container name. Fixed: log and exec tooling addresses it by name.
|
||||
const CONTAINER_NAME: &str = "agent";
|
||||
|
||||
/// Build the per-attempt Secret holding the resolved environment.
|
||||
///
|
||||
/// `immutable: true` — the Secret is written once per attempt and never
|
||||
/// updated, which is what lets the pod's `envFrom` reference be treated as an
|
||||
/// atomic binding to this exact payload (§K8s Secrets).
|
||||
pub fn build_secret(
|
||||
identity: &AgentIdentity,
|
||||
namespace: &str,
|
||||
generation: &str,
|
||||
env: BTreeMap<String, String>,
|
||||
) -> Secret {
|
||||
Secret {
|
||||
metadata: ObjectMeta {
|
||||
name: Some(identity.secret_name(generation)),
|
||||
namespace: Some(namespace.to_string()),
|
||||
labels: Some(identity.labels()),
|
||||
annotations: Some(
|
||||
[(
|
||||
ANNOTATION_PUBKEY_FULL.to_string(),
|
||||
identity.pubkey_hex().to_string(),
|
||||
)]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
string_data: Some(env),
|
||||
immutable: Some(true),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the pod for one create attempt.
|
||||
///
|
||||
/// The `fingerprint` is computed from [`intent_template`] over a type that
|
||||
/// cannot contain the generation or any Secret value, so it is stable across
|
||||
/// attempts of the same configuration.
|
||||
pub fn build_pod(
|
||||
identity: &AgentIdentity,
|
||||
cfg: &ProviderConfig,
|
||||
generation: &str,
|
||||
fingerprint: &Fingerprint,
|
||||
) -> Pod {
|
||||
let annotations: BTreeMap<String, String> = [
|
||||
(
|
||||
ANNOTATION_PUBKEY_FULL.to_string(),
|
||||
identity.pubkey_hex().to_string(),
|
||||
),
|
||||
(
|
||||
ANNOTATION_CREATE_INTENT.to_string(),
|
||||
fingerprint.as_str().to_string(),
|
||||
),
|
||||
(ANNOTATION_IMAGE.to_string(), cfg.image.as_str().to_string()),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let requests: BTreeMap<String, Quantity> = [
|
||||
(
|
||||
"cpu".to_string(),
|
||||
Quantity(cfg.resources.cpu_request.clone()),
|
||||
),
|
||||
(
|
||||
"memory".to_string(),
|
||||
Quantity(cfg.resources.memory_request.clone()),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let limits: BTreeMap<String, Quantity> = [
|
||||
("cpu".to_string(), Quantity(cfg.resources.cpu_limit.clone())),
|
||||
(
|
||||
"memory".to_string(),
|
||||
Quantity(cfg.resources.memory_limit.clone()),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let container = Container {
|
||||
name: CONTAINER_NAME.to_string(),
|
||||
image: Some(cfg.image.as_str().to_string()),
|
||||
// No `command`/`args`: the image's entrypoint execs the harness as
|
||||
// PID 1 (§Entrypoint). Overriding it here would be how a provider
|
||||
// accidentally puts a shell in front of the signal receiver.
|
||||
env_from: Some(vec![EnvFromSource {
|
||||
secret_ref: Some(SecretEnvSource {
|
||||
name: identity.secret_name(generation),
|
||||
optional: Some(false),
|
||||
}),
|
||||
..Default::default()
|
||||
}]),
|
||||
resources: Some(ResourceRequirements {
|
||||
requests: Some(requests),
|
||||
limits: Some(limits),
|
||||
..Default::default()
|
||||
}),
|
||||
volume_mounts: Some(vec![VolumeMount {
|
||||
name: WORKSPACE_VOLUME.to_string(),
|
||||
mount_path: WORKSPACE_PATH.to_string(),
|
||||
..Default::default()
|
||||
}]),
|
||||
security_context: Some(SecurityContext {
|
||||
allow_privilege_escalation: Some(false),
|
||||
capabilities: Some(Capabilities {
|
||||
drop: Some(vec!["ALL".to_string()]),
|
||||
..Default::default()
|
||||
}),
|
||||
// `readOnlyRootFilesystem` is deliberately unset: the sprig
|
||||
// toolchain writes outside the workspace mount (§Pod shape).
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
Pod {
|
||||
metadata: ObjectMeta {
|
||||
name: Some(identity.pod_name()),
|
||||
namespace: Some(cfg.namespace.clone()),
|
||||
labels: Some(identity.labels()),
|
||||
annotations: Some(annotations),
|
||||
..Default::default()
|
||||
},
|
||||
spec: Some(PodSpec {
|
||||
containers: vec![container],
|
||||
restart_policy: Some(RESTART_POLICY.to_string()),
|
||||
termination_grace_period_seconds: Some(TERMINATION_GRACE_SECONDS),
|
||||
// The agent runs prompted, untrusted code while holding an nsec;
|
||||
// an ambient ServiceAccount token would be an API-stealable
|
||||
// credential it never needs (§Pod shape hardening). Naming a
|
||||
// service account selects a scheduling/RBAC identity and MUST NOT
|
||||
// re-enable token mounting.
|
||||
automount_service_account_token: Some(false),
|
||||
service_account_name: cfg.service_account.clone(),
|
||||
security_context: Some(PodSecurityContext {
|
||||
run_as_non_root: Some(true),
|
||||
run_as_user: Some(RUN_AS_UID),
|
||||
run_as_group: Some(RUN_AS_GID),
|
||||
fs_group: Some(RUN_AS_GID),
|
||||
seccomp_profile: Some(SeccompProfile {
|
||||
type_: "RuntimeDefault".to_string(),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}),
|
||||
volumes: Some(vec![Volume {
|
||||
name: WORKSPACE_VOLUME.to_string(),
|
||||
empty_dir: Some(EmptyDirVolumeSource::default()),
|
||||
..Default::default()
|
||||
}]),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The create-intent template for this configuration (§Deploy State Machine).
|
||||
pub fn intent_template(
|
||||
cfg: &ProviderConfig,
|
||||
env_keys: impl IntoIterator<Item = String>,
|
||||
) -> IntentTemplate {
|
||||
IntentTemplate::new(
|
||||
&cfg.namespace,
|
||||
&cfg.image,
|
||||
&cfg.resources,
|
||||
cfg.service_account.as_deref(),
|
||||
env_keys,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config;
|
||||
|
||||
fn identity() -> AgentIdentity {
|
||||
use nostr::nips::nip19::ToBech32;
|
||||
let keys = nostr::Keys::generate();
|
||||
AgentIdentity::from_nsec(&keys.secret_key().to_bech32().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn provider_config() -> ProviderConfig {
|
||||
config::parse(&serde_json::json!({
|
||||
"namespace": "buzz-agents-test",
|
||||
"image": format!("ghcr.io/block/buzz-sprig@sha256:{}", "a".repeat(64)),
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn pod() -> Pod {
|
||||
let cfg = provider_config();
|
||||
build_pod(
|
||||
&identity(),
|
||||
&cfg,
|
||||
"gen00001",
|
||||
&intent_template(&cfg, ["BUZZ_RELAY_URL".to_string()]).fingerprint(),
|
||||
)
|
||||
}
|
||||
|
||||
fn spec(pod: &Pod) -> &PodSpec {
|
||||
pod.spec.as_ref().unwrap()
|
||||
}
|
||||
|
||||
/// Every hardening default from §Pod shape, asserted individually so a
|
||||
/// dropped one names itself.
|
||||
#[test]
|
||||
fn hardening_defaults_are_all_present() {
|
||||
let pod = pod();
|
||||
let spec = spec(&pod);
|
||||
assert_eq!(spec.automount_service_account_token, Some(false));
|
||||
|
||||
let sc = spec
|
||||
.security_context
|
||||
.as_ref()
|
||||
.expect("pod security context");
|
||||
assert_eq!(sc.run_as_non_root, Some(true));
|
||||
assert_eq!(sc.run_as_user, Some(RUN_AS_UID));
|
||||
assert_ne!(sc.run_as_user, Some(0), "root UID");
|
||||
assert_eq!(sc.run_as_group, Some(RUN_AS_GID));
|
||||
assert_eq!(
|
||||
sc.seccomp_profile.as_ref().map(|p| p.type_.as_str()),
|
||||
Some("RuntimeDefault")
|
||||
);
|
||||
|
||||
let csc = spec.containers[0]
|
||||
.security_context
|
||||
.as_ref()
|
||||
.expect("container sc");
|
||||
assert_eq!(csc.allow_privilege_escalation, Some(false));
|
||||
assert_eq!(
|
||||
csc.capabilities.as_ref().and_then(|c| c.drop.clone()),
|
||||
Some(vec!["ALL".to_string()])
|
||||
);
|
||||
assert_ne!(csc.privileged, Some(true));
|
||||
}
|
||||
|
||||
/// The forbidden host-namespace and hostPath escapes, asserted as absence.
|
||||
#[test]
|
||||
fn never_uses_host_namespaces_or_host_paths() {
|
||||
let pod = pod();
|
||||
let spec = spec(&pod);
|
||||
assert!(spec.host_pid.is_none() || spec.host_pid == Some(false));
|
||||
assert!(spec.host_network.is_none() || spec.host_network == Some(false));
|
||||
assert!(spec.host_ipc.is_none() || spec.host_ipc == Some(false));
|
||||
for volume in spec.volumes.as_ref().unwrap() {
|
||||
assert!(
|
||||
volume.host_path.is_none(),
|
||||
"hostPath volume {}",
|
||||
volume.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Never` only. `OnFailure` is gated on the harness exit-code contract
|
||||
/// *and* a crash-loop classification row (`:1121-1139`); the config layer
|
||||
/// refuses `inactivity_seconds: 0` so this arm is unreachable, and the
|
||||
/// assertion keeps it that way.
|
||||
#[test]
|
||||
fn restart_policy_is_never() {
|
||||
assert_eq!(spec(&pod()).restart_policy.as_deref(), Some("Never"));
|
||||
}
|
||||
|
||||
/// 60s, not Kubernetes' default 30s — which would SIGKILL the harness
|
||||
/// mid-drain and leave presence stale-online (§Pod shape).
|
||||
#[test]
|
||||
fn declares_the_sixty_second_grace_budget() {
|
||||
assert_eq!(spec(&pod()).termination_grace_period_seconds, Some(60));
|
||||
}
|
||||
|
||||
/// The pod must not override the image's entrypoint: the image execs the
|
||||
/// harness as PID 1, and a `command` here is how a shell ends up in front
|
||||
/// of the signal receiver (§Entrypoint).
|
||||
#[test]
|
||||
fn does_not_override_the_image_entrypoint() {
|
||||
let pod = pod();
|
||||
let container = &spec(&pod).containers[0];
|
||||
assert!(container.command.is_none(), "overrode the entrypoint");
|
||||
assert!(container.args.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_is_an_emptydir_mounted_at_home() {
|
||||
let pod = pod();
|
||||
let spec = spec(&pod);
|
||||
let volume = &spec.volumes.as_ref().unwrap()[0];
|
||||
assert!(volume.empty_dir.is_some());
|
||||
assert!(volume.persistent_volume_claim.is_none());
|
||||
let mount = &spec.containers[0].volume_mounts.as_ref().unwrap()[0];
|
||||
assert_eq!(mount.name, volume.name);
|
||||
assert_eq!(mount.mount_path, WORKSPACE_PATH);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resources_carry_the_configured_requests_and_limits() {
|
||||
let mut cfg = provider_config();
|
||||
cfg.resources.cpu_limit = "4".into();
|
||||
let pod = build_pod(&identity(), &cfg, "g", &Fingerprint::from_annotation("f"));
|
||||
let r = spec(&pod).containers[0].resources.as_ref().unwrap();
|
||||
assert_eq!(r.requests.as_ref().unwrap()["cpu"], Quantity("1".into()));
|
||||
assert_eq!(
|
||||
r.requests.as_ref().unwrap()["memory"],
|
||||
Quantity("2Gi".into())
|
||||
);
|
||||
assert_eq!(r.limits.as_ref().unwrap()["cpu"], Quantity("4".into()));
|
||||
assert_eq!(r.limits.as_ref().unwrap()["memory"], Quantity("4Gi".into()));
|
||||
}
|
||||
|
||||
/// `envFrom` must point at this attempt's Secret and must NOT be optional:
|
||||
/// an optional reference starts the container with no identity at all,
|
||||
/// turning a missing-Secret bug into an agent that silently cannot
|
||||
/// authenticate.
|
||||
#[test]
|
||||
fn env_from_references_this_attempts_secret_and_is_required() {
|
||||
let id = identity();
|
||||
let cfg = provider_config();
|
||||
let pod = build_pod(&id, &cfg, "gen00042", &Fingerprint::from_annotation("f"));
|
||||
let source = &spec(&pod).containers[0].env_from.as_ref().unwrap()[0];
|
||||
let secret_ref = source.secret_ref.as_ref().unwrap();
|
||||
assert_eq!(secret_ref.name, id.secret_name("gen00042"));
|
||||
assert_eq!(secret_ref.optional, Some(false));
|
||||
assert!(source.config_map_ref.is_none());
|
||||
}
|
||||
|
||||
/// Identity, ownership marker, and the recorded intent all travel on the
|
||||
/// pod — the GC and reconciliation fences read exactly these.
|
||||
#[test]
|
||||
fn pod_carries_identity_marker_and_recorded_intent() {
|
||||
let id = identity();
|
||||
let cfg = provider_config();
|
||||
let fp = intent_template(&cfg, ["A".to_string()]).fingerprint();
|
||||
let pod = build_pod(&id, &cfg, "g", &fp);
|
||||
let meta = &pod.metadata;
|
||||
assert_eq!(meta.name.as_deref(), Some(id.pod_name().as_str()));
|
||||
assert_eq!(meta.namespace.as_deref(), Some("buzz-agents-test"));
|
||||
assert_eq!(meta.labels.as_ref().unwrap(), &id.labels());
|
||||
let ann = meta.annotations.as_ref().unwrap();
|
||||
assert_eq!(ann[ANNOTATION_PUBKEY_FULL], id.pubkey_hex());
|
||||
assert_eq!(ann[ANNOTATION_CREATE_INTENT], fp.as_str());
|
||||
assert_eq!(ann[ANNOTATION_IMAGE], cfg.image.as_str());
|
||||
}
|
||||
|
||||
/// The Secret is immutable and marker-bearing: immutability is what makes
|
||||
/// the pod's `envFrom` an atomic binding, and the marker is what GC
|
||||
/// requires before it will delete anything.
|
||||
#[test]
|
||||
fn secret_is_immutable_marked_and_holds_the_env() {
|
||||
let id = identity();
|
||||
let env: BTreeMap<String, String> =
|
||||
[("BUZZ_RELAY_URL".to_string(), "wss://r".to_string())].into();
|
||||
let secret = build_secret(&id, "ns", "gen1", env.clone());
|
||||
assert_eq!(secret.immutable, Some(true));
|
||||
assert_eq!(secret.string_data.as_ref().unwrap(), &env);
|
||||
assert_eq!(
|
||||
secret.metadata.name.as_deref(),
|
||||
Some(id.secret_name("gen1").as_str())
|
||||
);
|
||||
assert_eq!(secret.metadata.labels.as_ref().unwrap(), &id.labels());
|
||||
assert_eq!(
|
||||
secret.metadata.annotations.as_ref().unwrap()[ANNOTATION_PUBKEY_FULL],
|
||||
id.pubkey_hex()
|
||||
);
|
||||
// `data` must stay unset — setting both is an apiserver rejection.
|
||||
assert!(secret.data.is_none());
|
||||
}
|
||||
|
||||
/// Naming a service account selects a scheduling identity; it must not
|
||||
/// re-enable token mounting (§Pod shape hardening, `:1221-1225`).
|
||||
#[test]
|
||||
fn service_account_does_not_re_enable_token_mounting() {
|
||||
let mut cfg = provider_config();
|
||||
cfg.service_account = Some("agent-sa".into());
|
||||
let pod = build_pod(&identity(), &cfg, "g", &Fingerprint::from_annotation("f"));
|
||||
assert_eq!(spec(&pod).service_account_name.as_deref(), Some("agent-sa"));
|
||||
assert_eq!(spec(&pod).automount_service_account_token, Some(false));
|
||||
}
|
||||
|
||||
/// The fingerprint recorded on the pod is the one the classifier will
|
||||
/// recompute — pinned end-to-end so a builder change that forgets to feed
|
||||
/// the template a field cannot pass silently.
|
||||
#[test]
|
||||
fn recorded_fingerprint_matches_a_fresh_computation() {
|
||||
let cfg = provider_config();
|
||||
let keys = ["BUZZ_RELAY_URL".to_string(), "GOOSE_MODE".to_string()];
|
||||
let fp = intent_template(&cfg, keys.clone()).fingerprint();
|
||||
let pod = build_pod(&identity(), &cfg, "gen-a", &fp);
|
||||
let recorded = Fingerprint::from_annotation(
|
||||
&pod.metadata.annotations.as_ref().unwrap()[ANNOTATION_CREATE_INTENT],
|
||||
);
|
||||
assert_eq!(recorded, intent_template(&cfg, keys).fingerprint());
|
||||
}
|
||||
|
||||
/// Two attempts differing only in generation must record the *same*
|
||||
/// fingerprint, or the divergence discriminator fires on every deploy and
|
||||
/// the never-started row deletes healthy pending pods.
|
||||
#[test]
|
||||
fn generation_does_not_change_the_recorded_fingerprint() {
|
||||
let cfg = provider_config();
|
||||
let keys = ["BUZZ_RELAY_URL".to_string()];
|
||||
let a = intent_template(&cfg, keys.clone()).fingerprint();
|
||||
let b = intent_template(&cfg, keys).fingerprint();
|
||||
let id = identity();
|
||||
let pod_a = build_pod(&id, &cfg, "gen-1", &a);
|
||||
let pod_b = build_pod(&id, &cfg, "gen-2", &b);
|
||||
let read =
|
||||
|p: &Pod| p.metadata.annotations.as_ref().unwrap()[ANNOTATION_CREATE_INTENT].clone();
|
||||
assert_eq!(read(&pod_a), read(&pod_b));
|
||||
// ...while the Secret they reference differs.
|
||||
let secret_of = |p: &Pod| {
|
||||
spec(p).containers[0].env_from.as_ref().unwrap()[0]
|
||||
.secret_ref
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.name
|
||||
.clone()
|
||||
};
|
||||
assert_ne!(secret_of(&pod_a), secret_of(&pod_b));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,250 @@
|
||||
//! The stdin/stdout JSON protocol (spec §Provider Protocol).
|
||||
//!
|
||||
//! One process per operation: one JSON object in, one JSON object out.
|
||||
//! These types are this provider's view of the contract; the golden fixtures
|
||||
//! in `tests/fixtures/provider-wire/` are the arbiter shared with the desktop.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// The wire-contract version this provider speaks (spec §Info).
|
||||
pub const PROTOCOL_VERSION: u32 = 1;
|
||||
|
||||
/// Request envelope. `op` discriminates; unknown ops are an in-band error.
|
||||
///
|
||||
/// `request_id` is deliberately absent from every variant. The desktop sends
|
||||
/// it, but the exchange is one request and one response per process, so there
|
||||
/// is nothing to correlate and nothing in the response schema to echo it into
|
||||
/// (`:387`, `:416` — neither response shape carries it). Serde ignores it on
|
||||
/// the way in, so a caller that sends it is accepted; typing it would only
|
||||
/// create a field nothing reads.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "op", rename_all = "lowercase")]
|
||||
pub enum Request {
|
||||
Info,
|
||||
Deploy(Box<DeployRequest>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeployRequest {
|
||||
pub agent: AgentPayload,
|
||||
#[serde(default)]
|
||||
pub provider_config: serde_json::Value,
|
||||
}
|
||||
|
||||
/// The agent payload (spec §Deploy).
|
||||
///
|
||||
/// Only the fields this binding actually consumes are typed. `name` (display
|
||||
/// name), `model`, `provider`, and `turn_timeout_seconds` are deliberately
|
||||
/// absent: object names derive from the pubkey, not the display name
|
||||
/// (`:1150-1152`), the model/provider pair arrives already resolved inside
|
||||
/// `launch`, and the timeout is ignored upstream — typing any of them would
|
||||
/// invite a provider-side remap the spec forbids.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AgentPayload {
|
||||
pub relay_url: String,
|
||||
pub private_key_nsec: String,
|
||||
#[serde(default)]
|
||||
pub auth_tag: Option<String>,
|
||||
#[serde(default)]
|
||||
pub respond_to: Option<String>,
|
||||
#[serde(default)]
|
||||
pub respond_to_allowlist: Option<Vec<String>>,
|
||||
/// User env, already merged global < persona < agent by the desktop and
|
||||
/// already stripped of reserved keys. Superseded by `launch.env` when
|
||||
/// `launch` is present — a provider MUST NOT re-merge it on top
|
||||
/// (§Launch data, precedence tier 2).
|
||||
#[serde(default)]
|
||||
pub env_vars: BTreeMap<String, String>,
|
||||
/// The desktop-resolved launch contract. Absent only from a desktop
|
||||
/// predating Known Defect 3's fix.
|
||||
#[serde(default)]
|
||||
pub launch: Option<LaunchBlock>,
|
||||
}
|
||||
|
||||
/// Desktop-resolved launch data (spec §Launch data).
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct LaunchBlock {
|
||||
/// Command *name*, resolved against the image's PATH — never a host path.
|
||||
#[serde(default)]
|
||||
pub command: Option<String>,
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
/// Layered env: baked → runtime metadata → definition → global → persona
|
||||
/// → agent. Precedence tier 2.
|
||||
#[serde(default)]
|
||||
pub env: BTreeMap<String, String>,
|
||||
/// Overridable behavior defaults. Precedence tier 1 — user env beats
|
||||
/// these, matching the local spawn.
|
||||
#[serde(default)]
|
||||
pub policy_env: BTreeMap<String, String>,
|
||||
/// Resolved workspace owner (hex). The respond-to gate's one
|
||||
/// irreducible input; without it or `auth_tag` the harness cannot match
|
||||
/// `!shutdown`.
|
||||
#[serde(default)]
|
||||
pub owner_pubkey: Option<String>,
|
||||
}
|
||||
|
||||
/// Response envelope. Serialized flat — `{"ok": true, …}` — because the
|
||||
/// desktop reads `ok`, `error`, and `agent_id` off the top level.
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum Response {
|
||||
Info(InfoResponse),
|
||||
Deploy(DeployResponse),
|
||||
Error(ErrorResponse),
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct InfoResponse {
|
||||
pub ok: bool,
|
||||
pub name: &'static str,
|
||||
pub version: &'static str,
|
||||
pub protocol_version: u32,
|
||||
pub description: &'static str,
|
||||
pub config_schema: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DeployResponse {
|
||||
pub ok: bool,
|
||||
pub agent_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ErrorResponse {
|
||||
pub ok: bool,
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
impl Response {
|
||||
pub fn error(message: impl Into<String>) -> Self {
|
||||
Response::Error(ErrorResponse {
|
||||
ok: false,
|
||||
error: message.into(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The provider's self-description (spec §Info). Pure — no cluster
|
||||
/// contact — because the desktop calls it to render the config form
|
||||
/// before a kubeconfig is known to exist.
|
||||
pub fn info() -> Self {
|
||||
Response::Info(InfoResponse {
|
||||
ok: true,
|
||||
name: "kubernetes",
|
||||
version: env!("CARGO_PKG_VERSION"),
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
description: "Runs agents as pods in a Kubernetes cluster",
|
||||
config_schema: crate::config::config_schema(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn deployed(agent_id: impl Into<String>) -> Self {
|
||||
Response::Deploy(DeployResponse {
|
||||
ok: true,
|
||||
agent_id: agent_id.into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_info_request() {
|
||||
let r: Request = serde_json::from_str(r#"{"op":"info","request_id":"abc"}"#).unwrap();
|
||||
assert!(matches!(r, Request::Info));
|
||||
}
|
||||
|
||||
/// The desktop sends `request_id` on every call, but the exchange is 1:1
|
||||
/// per process — a provider that hard-required it would fail a
|
||||
/// conforming-but-minimal caller for no safety gain.
|
||||
#[test]
|
||||
fn request_id_is_optional() {
|
||||
let r: Request = serde_json::from_str(r#"{"op":"info"}"#).unwrap();
|
||||
assert!(matches!(r, Request::Info));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_op() {
|
||||
assert!(serde_json::from_str::<Request>(r#"{"op":"undeploy"}"#).is_err());
|
||||
}
|
||||
|
||||
/// Payload fields this binding does not consume must not break parsing:
|
||||
/// the desktop sends `model`, `provider`, `system_prompt` and more, and a
|
||||
/// provider that rejected them would break on every real deploy.
|
||||
#[test]
|
||||
fn ignores_unconsumed_payload_fields() {
|
||||
let json = r#"{
|
||||
"op":"deploy","request_id":"r1",
|
||||
"agent":{
|
||||
"name":"a","relay_url":"wss://r","private_key_nsec":"nsec1x",
|
||||
"model":"gpt-5","provider":"openai","system_prompt":"hi",
|
||||
"turn_timeout_seconds":30,"parallelism":10,
|
||||
"agent_command":"goose","agent_args":[]
|
||||
},
|
||||
"provider_config":{"namespace":"ns"}
|
||||
}"#;
|
||||
let r: Request = serde_json::from_str(json).unwrap();
|
||||
let Request::Deploy(d) = r else {
|
||||
panic!("wrong op")
|
||||
};
|
||||
assert_eq!(d.agent.relay_url, "wss://r");
|
||||
assert!(d.agent.launch.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_launch_block() {
|
||||
let json = r#"{
|
||||
"op":"deploy",
|
||||
"agent":{
|
||||
"relay_url":"wss://r","private_key_nsec":"nsec1x",
|
||||
"launch":{
|
||||
"command":"goose","args":["run","--x"],
|
||||
"env":{"GOOSE_MODEL":"m"},
|
||||
"policy_env":{"GOOSE_MODE":"auto"},
|
||||
"owner_pubkey":"deadbeef"
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
let Request::Deploy(d) = serde_json::from_str::<Request>(json).unwrap() else {
|
||||
panic!("wrong op")
|
||||
};
|
||||
let l = d.agent.launch.unwrap();
|
||||
assert_eq!(l.command.as_deref(), Some("goose"));
|
||||
assert_eq!(l.args, ["run", "--x"]);
|
||||
assert_eq!(l.env["GOOSE_MODEL"], "m");
|
||||
assert_eq!(l.policy_env["GOOSE_MODE"], "auto");
|
||||
assert_eq!(l.owner_pubkey.as_deref(), Some("deadbeef"));
|
||||
}
|
||||
|
||||
/// A null `owner_pubkey`/`auth_tag` must parse (the refusal is a policy
|
||||
/// decision made later, with a specific message), not fail as a type error.
|
||||
#[test]
|
||||
fn null_owner_fields_parse() {
|
||||
let json = r#"{"op":"deploy","agent":{
|
||||
"relay_url":"wss://r","private_key_nsec":"nsec1x","auth_tag":null,
|
||||
"launch":{"owner_pubkey":null}
|
||||
}}"#;
|
||||
let Request::Deploy(d) = serde_json::from_str::<Request>(json).unwrap() else {
|
||||
panic!("wrong op")
|
||||
};
|
||||
assert!(d.agent.auth_tag.is_none());
|
||||
assert!(d.agent.launch.unwrap().owner_pubkey.is_none());
|
||||
}
|
||||
|
||||
/// The desktop reads `ok`/`error`/`agent_id` off the top level, so the
|
||||
/// enum must serialize flat with no variant tag.
|
||||
#[test]
|
||||
fn responses_serialize_flat() {
|
||||
let v = serde_json::to_value(Response::deployed("buzz-agent-abc")).unwrap();
|
||||
assert_eq!(v["ok"], true);
|
||||
assert_eq!(v["agent_id"], "buzz-agent-abc");
|
||||
assert!(v.get("Deploy").is_none());
|
||||
|
||||
let v = serde_json::to_value(Response::error("boom")).unwrap();
|
||||
assert_eq!(v["ok"], false);
|
||||
assert_eq!(v["error"], "boom");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
# Provider wire fixtures
|
||||
|
||||
The shared arbiter for the stdin/stdout contract between the desktop
|
||||
(`agents_deploy.rs`) and this provider (spec §Provider Protocol).
|
||||
|
||||
Each `*.request.json` is a request the desktop can emit; each matching
|
||||
`*.response.json` is the exact response this provider produces for it. The
|
||||
provider side is asserted by `tests/wire_fixtures.rs`; the desktop side should
|
||||
assert that its emitted payloads parse as the corresponding request.
|
||||
|
||||
Three rules keep these useful rather than decorative:
|
||||
|
||||
* **Requests are recorded, not invented.** A fixture that no caller emits
|
||||
tests a contract nobody has. "Recorded" means *executed and transcribed* —
|
||||
`deploy-full-launch.request.json` is the output of the desktop's real
|
||||
`build_launch_block` → `deploy_payload_json` path, not a shape derived by
|
||||
reading those functions. Deriving it is how this fixture acquired four
|
||||
impossible values at once: a `respond_to` that was a pubkey where the
|
||||
desktop serializes a kebab-case `RespondTo` enum, allowlist and owner
|
||||
values failing `validate_respond_to_allowlist`'s 64-hex rule
|
||||
(`types.rs:897`), an invented `BUZZ_ACP_PARALLELISM` where the emitter
|
||||
writes `BUZZ_ACP_AGENTS` (`runtime.rs:729`), and a `launch.env` key from
|
||||
no layer of `resolve_effective_harness_descriptor`.
|
||||
* **The provider cannot police this file, so the desktop must.** Every field
|
||||
above is one this provider is deliberately indifferent to — `respond_to` is
|
||||
an opaque `Option<String>`, the allowlist an opaque `Vec<String>`,
|
||||
`policy_env` an arbitrary map — so `the_full_desktop_payload_is_accepted`
|
||||
passes on invented data exactly as happily as on recorded data. The
|
||||
enforcement is the desktop's whole-object equality test, which *builds* the
|
||||
payload and compares it to this file. A completeness guard (the case-list
|
||||
directory scan in `wire_fixtures.rs`) stops a case from going missing; it
|
||||
cannot tell you a case is false.
|
||||
* **Responses are byte-compared after key-sorted re-serialization**, so a
|
||||
field rename or a type change fails here rather than in a desktop that
|
||||
silently reads `undefined`.
|
||||
|
||||
`deploy-*` fixtures cover only responses reachable without a cluster —
|
||||
refusals and malformed input. A successful deploy needs an apiserver and is
|
||||
covered by the conformance suite, not by a static fixture.
|
||||
Vendored
+53
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"op": "deploy",
|
||||
"request_id": "req-6",
|
||||
"agent": {
|
||||
"agent_args": [],
|
||||
"agent_command": "goose",
|
||||
"auth_tag": "tag-1",
|
||||
"env_vars": {
|
||||
"USER_KEY": "user-value"
|
||||
},
|
||||
"idle_timeout_seconds": null,
|
||||
"launch": {
|
||||
"args": [
|
||||
"acp"
|
||||
],
|
||||
"command": "goose",
|
||||
"env": {
|
||||
"GOOSE_MODEL": "gpt-5",
|
||||
"GOOSE_PROVIDER": "openai",
|
||||
"USER_KEY": "user-value"
|
||||
},
|
||||
"owner_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"policy_env": {
|
||||
"BUZZ_ACP_AGENTS": "10",
|
||||
"BUZZ_ACP_DISPLAY_NAME": "worker",
|
||||
"BUZZ_ACP_LAZY_POOL": "true",
|
||||
"BUZZ_ACP_MODEL": "gpt-5",
|
||||
"BUZZ_ACP_RELAY_OBSERVER": "true",
|
||||
"BUZZ_ACP_SESSION_TITLE": "worker",
|
||||
"GOOSE_MODE": "auto"
|
||||
}
|
||||
},
|
||||
"max_turn_duration_seconds": null,
|
||||
"model": "gpt-5",
|
||||
"name": "worker",
|
||||
"parallelism": 10,
|
||||
"private_key_nsec": "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5",
|
||||
"provider": "openai",
|
||||
"relay_url": "wss://relay.example",
|
||||
"respond_to": "allowlist",
|
||||
"respond_to_allowlist": [
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
],
|
||||
"system_prompt": null,
|
||||
"turn_timeout_seconds": 300
|
||||
},
|
||||
"provider_config": {
|
||||
"namespace": "buzz-agents-test",
|
||||
"image": "ghcr.io/block/buzz-sprig@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"inactivity_seconds": 3600
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"op": "deploy",
|
||||
"request_id": "req-5",
|
||||
"agent": {
|
||||
"relay_url": "wss://relay.example",
|
||||
"private_key_nsec": "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5"
|
||||
},
|
||||
"provider_config": {"namespace": "buzz-agents-test", "image": "ghcr.io/block/buzz-sprig@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"ok":false,"error":"deploy refused: neither auth_tag nor launch.owner_pubkey resolved — without an owner the agent cannot honor !shutdown"}
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"op": "deploy",
|
||||
"request_id": "req-3",
|
||||
"agent": {
|
||||
"relay_url": "wss://relay.example",
|
||||
"private_key_nsec": "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5",
|
||||
"auth_tag": "tag-1",
|
||||
"provider": " relay-mesh "
|
||||
},
|
||||
"provider_config": {"namespace": "buzz-agents-test", "image": "ghcr.io/block/buzz-sprig@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"ok":false,"error":"deploy refused: this agent is configured for shared compute (relay-mesh), which runs on the relay rather than in a pod. Switch the agent to a local runtime before deploying it to Kubernetes."}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"op": "deploy",
|
||||
"request_id": "req-2",
|
||||
"agent": {
|
||||
"name": "mesh-agent",
|
||||
"relay_url": "wss://relay.example",
|
||||
"private_key_nsec": "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5",
|
||||
"auth_tag": "tag-1",
|
||||
"provider": "relay-mesh"
|
||||
},
|
||||
"provider_config": {"namespace": "buzz-agents-test", "image": "ghcr.io/block/buzz-sprig@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"ok":false,"error":"deploy refused: this agent is configured for shared compute (relay-mesh), which runs on the relay rather than in a pod. Switch the agent to a local runtime before deploying it to Kubernetes."}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"op": "deploy",
|
||||
"request_id": "req-4",
|
||||
"agent": {
|
||||
"relay_url": "wss://relay.example",
|
||||
"private_key_nsec": "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5",
|
||||
"auth_tag": "tag-1"
|
||||
},
|
||||
"provider_config": {"namespace": "buzz-agents-test", "image": "ghcr.io/block/buzz-sprig:latest"}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"ok":false,"error":"provider_config.image \"ghcr.io/block/buzz-sprig:latest\" is not digest-pinned: a tag is a mutable pointer, and this object runs with the agent's private key. Use name@sha256:<64 hex chars>"}
|
||||
@@ -0,0 +1 @@
|
||||
{"op":"info","request_id":"req-1"}
|
||||
@@ -0,0 +1,222 @@
|
||||
//! Golden wire fixtures (spec §Provider Protocol).
|
||||
//!
|
||||
//! These drive the **built binary** over a real pipe rather than calling an
|
||||
//! in-process function: the contract the desktop depends on is
|
||||
//! `stdin → one JSON object on stdout → exit code`, and an in-process test
|
||||
//! would assert the shape of a value while skipping the three things that
|
||||
//! actually break — the process writing nothing, writing two objects, or
|
||||
//! signalling the outcome through the exit code.
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
fn fixtures() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/provider-wire")
|
||||
}
|
||||
|
||||
/// Feed one request to the binary; return `(stdout, exit code)`.
|
||||
fn run(request: &str) -> (String, i32) {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_buzz-backend-kubernetes"))
|
||||
// A kubeconfig that does not exist, so a fixture that accidentally
|
||||
// reaches the cluster fails loudly here instead of depending on
|
||||
// whatever cluster the developer is pointed at.
|
||||
.env("KUBECONFIG", "/nonexistent/kubeconfig-for-fixture-tests")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("could not run the provider binary");
|
||||
child
|
||||
.stdin
|
||||
.take()
|
||||
.expect("no stdin")
|
||||
.write_all(request.as_bytes())
|
||||
.expect("could not write the request");
|
||||
let out = child.wait_with_output().expect("provider did not exit");
|
||||
(
|
||||
String::from_utf8(out.stdout).expect("stdout was not UTF-8"),
|
||||
out.status.code().unwrap_or(-1),
|
||||
)
|
||||
}
|
||||
|
||||
fn read(name: &str) -> String {
|
||||
std::fs::read_to_string(fixtures().join(name))
|
||||
.unwrap_or_else(|e| panic!("could not read fixture {name}: {e}"))
|
||||
}
|
||||
|
||||
/// Every response fixture, byte-compared after key-sorted re-serialization so
|
||||
/// a field rename fails here rather than in a desktop reading `undefined`.
|
||||
#[test]
|
||||
fn responses_match_their_fixtures() {
|
||||
let cases = [
|
||||
"deploy-relay-mesh",
|
||||
"deploy-relay-mesh-padded",
|
||||
"deploy-tag-image",
|
||||
"deploy-no-owner",
|
||||
];
|
||||
// The list must cover every response fixture on disk. A literal array is
|
||||
// never empty, so `!is_empty()` would assert nothing; what can actually go
|
||||
// wrong is a fixture added to the directory and never added here, which
|
||||
// reads as a passing suite that exercises one case fewer than it appears to.
|
||||
let mut on_disk: Vec<String> = std::fs::read_dir(fixtures())
|
||||
.expect("could not read the fixture directory")
|
||||
.filter_map(|entry| entry.ok()?.file_name().into_string().ok())
|
||||
.filter_map(|name| Some(name.strip_suffix(".response.json")?.to_string()))
|
||||
.collect();
|
||||
on_disk.sort();
|
||||
let mut listed: Vec<String> = cases.iter().map(|c| c.to_string()).collect();
|
||||
listed.sort();
|
||||
assert_eq!(on_disk, listed, "response fixtures and cases disagree");
|
||||
|
||||
for case in cases {
|
||||
let (stdout, code) = run(&read(&format!("{case}.request.json")));
|
||||
assert_eq!(code, 0, "{case}: a produced response must exit 0");
|
||||
|
||||
// Exactly one object, terminated by exactly one newline. Two responses
|
||||
// would leave the desktop's reader holding a second one forever.
|
||||
assert_eq!(
|
||||
stdout.matches('\n').count(),
|
||||
1,
|
||||
"{case}: expected exactly one line, got {stdout:?}"
|
||||
);
|
||||
|
||||
let actual: serde_json::Value =
|
||||
serde_json::from_str(&stdout).unwrap_or_else(|e| panic!("{case}: {e}: {stdout:?}"));
|
||||
let expected: serde_json::Value =
|
||||
serde_json::from_str(&read(&format!("{case}.response.json"))).unwrap();
|
||||
assert_eq!(
|
||||
actual, expected,
|
||||
"{case}: response drifted from its fixture"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `info` is checked on the fields the desktop reads rather than byte-for-byte:
|
||||
/// the namespace default is randomly generated per call (§K8s Namespace), so a
|
||||
/// golden copy of it would be a test that fails every run.
|
||||
#[test]
|
||||
fn info_response_carries_the_contract_fields() {
|
||||
let (stdout, code) = run(&read("info.request.json"));
|
||||
assert_eq!(code, 0);
|
||||
let info: serde_json::Value = serde_json::from_str(&stdout).unwrap();
|
||||
assert_eq!(info["ok"], true);
|
||||
assert_eq!(info["protocol_version"], 1);
|
||||
assert_eq!(info["name"], "kubernetes");
|
||||
let schema = &info["config_schema"];
|
||||
assert_eq!(
|
||||
schema["required"],
|
||||
serde_json::json!(["namespace", "image"])
|
||||
);
|
||||
let default = schema["properties"]["namespace"]["default"]
|
||||
.as_str()
|
||||
.expect("no generated namespace default");
|
||||
assert!(
|
||||
default.starts_with("buzz-agents-"),
|
||||
"unexpected namespace default: {default}"
|
||||
);
|
||||
let image_default = schema["properties"]["image"]["default"]
|
||||
.as_str()
|
||||
.expect("no image default");
|
||||
assert!(
|
||||
image_default.starts_with("ghcr.io/block/buzz-sprig:")
|
||||
&& image_default.contains("@sha256:"),
|
||||
"unexpected image default: {image_default}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The desktop's richest payload must parse. No response fixture: this one
|
||||
/// reaches the cluster, so its outcome depends on a kubeconfig. What it
|
||||
/// guards is that every field the desktop sends is *accepted* — a payload the
|
||||
/// provider rejects at parse time is a deploy that never starts.
|
||||
#[test]
|
||||
fn the_full_desktop_payload_is_accepted() {
|
||||
let (stdout, code) = run(&read("deploy-full-launch.request.json"));
|
||||
assert_eq!(code, 0);
|
||||
let response: serde_json::Value = serde_json::from_str(&stdout).unwrap();
|
||||
let error = response["error"].as_str().unwrap_or_default();
|
||||
// It fails — there is no cluster — but it must fail at the *connection*,
|
||||
// having accepted every field above it.
|
||||
assert!(
|
||||
error.contains("kubeconfig"),
|
||||
"the full payload was rejected before reaching the cluster: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Sami's pre-registered respond-to matrix, driven through the built binary.
|
||||
///
|
||||
/// `build_env` runs at `main.rs:124`, `client::connect` at `:132`, so under a
|
||||
/// kubeconfig that cannot exist the error string *is* the ordering assertion:
|
||||
/// "kubeconfig" means the gate passed and we reached the cluster, anything
|
||||
/// else means we refused before writing a Secret. A test asserting only
|
||||
/// `ok: false` would pass on the connection error and prove nothing.
|
||||
///
|
||||
/// The cases are applied to the real full-launch request so each one differs
|
||||
/// from a known-good deploy in exactly the field under test.
|
||||
#[test]
|
||||
fn the_respond_to_gate_matches_the_harness_acceptance_surface() {
|
||||
let key_a = "a".repeat(64);
|
||||
let padded_upper = format!(" {} ", "A".repeat(64));
|
||||
// (name, respond_to, allowlist, must reach the cluster)
|
||||
let cases: Vec<(&str, &str, Option<Vec<String>>, bool)> = vec![
|
||||
("allowlist + []", "allowlist", Some(vec![]), false),
|
||||
("allowlist + absent", "allowlist", None, false),
|
||||
(
|
||||
"allowlist + junk",
|
||||
"allowlist",
|
||||
Some(vec!["beefcafe".into()]),
|
||||
false,
|
||||
),
|
||||
("unparseable mode", "npub1abc", None, false),
|
||||
("padded mode", " allowlist ", None, false),
|
||||
(
|
||||
"allowlist + two valid",
|
||||
"allowlist",
|
||||
Some(vec![key_a.clone(), "b".repeat(64)]),
|
||||
true,
|
||||
),
|
||||
(
|
||||
"owner-only + junk list",
|
||||
"owner-only",
|
||||
Some(vec!["beefcafe".into()]),
|
||||
true,
|
||||
),
|
||||
(
|
||||
"allowlist + padded upper",
|
||||
"allowlist",
|
||||
Some(vec![padded_upper]),
|
||||
true,
|
||||
),
|
||||
("nobody", "nobody", None, true),
|
||||
("anyone", "anyone", None, true),
|
||||
];
|
||||
|
||||
let base: serde_json::Value =
|
||||
serde_json::from_str(&read("deploy-full-launch.request.json")).unwrap();
|
||||
|
||||
for (name, mode, allowlist, reaches_cluster) in cases {
|
||||
let mut request = base.clone();
|
||||
let agent = &mut request["agent"];
|
||||
agent["respond_to"] = serde_json::json!(mode);
|
||||
agent["respond_to_allowlist"] = match &allowlist {
|
||||
Some(list) => serde_json::json!(list),
|
||||
None => serde_json::Value::Null,
|
||||
};
|
||||
|
||||
let (stdout, code) = run(&request.to_string());
|
||||
assert_eq!(code, 0, "{name}: provider did not exit cleanly");
|
||||
let response: serde_json::Value = serde_json::from_str(&stdout).unwrap();
|
||||
let error = response["error"].as_str().unwrap_or_default();
|
||||
let reached = error.contains("kubeconfig");
|
||||
|
||||
assert_eq!(
|
||||
reached, reaches_cluster,
|
||||
"{name}: expected reaches_cluster={reaches_cluster}, got error: {error}"
|
||||
);
|
||||
if !reaches_cluster {
|
||||
assert!(
|
||||
error.contains("deploy refused"),
|
||||
"{name}: refused, but not by the gate: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user