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,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"]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user