Files
buzz/crates/buzz-backend-kubernetes/src/gc.rs
T
cls 9dfa06ffee
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
feat: import Chinese-localized Buzz source snapshot
Signed-off-by: cls_宁波本机 <908705107@qq.com>
2026-08-13 18:34:25 +08:00

369 lines
14 KiB
Rust

//! 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"]);
}
}