From adb36e8072c7a39ef455d8c4db2058d54174bcb8 Mon Sep 17 00:00:00 2001 From: yamonkjdmac24gb Date: Sun, 13 Sep 2026 21:30:09 +0900 Subject: [PATCH 1/2] fix(desktop): durably fence provider settlements Signed-off-by: yamonkjdmac24gb --- .../src/commands/agent_models_update.rs | 5 +- desktop/src-tauri/src/commands/agents.rs | 41 ++- .../src/commands/agents/provider_deploy.rs | 250 +++++++++++++++++- .../src/commands/agents_lifecycle.rs | 2 +- .../src-tauri/src/commands/agents_pending.rs | 2 +- .../src-tauri/src/managed_agents/retention.rs | 2 + .../retention/provider_settlement.rs | 163 ++++++++++++ 7 files changed, 442 insertions(+), 23 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/retention/provider_settlement.rs diff --git a/desktop/src-tauri/src/commands/agent_models_update.rs b/desktop/src-tauri/src/commands/agent_models_update.rs index 46f0fb10cca..0a6f766a7d1 100644 --- a/desktop/src-tauri/src/commands/agent_models_update.rs +++ b/desktop/src-tauri/src/commands/agent_models_update.rs @@ -328,17 +328,16 @@ pub async fn update_managed_agent( stamp_record_updated_at(record, applied); - save_managed_agents(&app, &records)?; - let record = records .iter() .find(|r| r.pubkey == input.pubkey) .ok_or_else(|| format!("agent {} not found", input.pubkey))?; - // Publish the edit to the relay. After-save, inside the lock, before + // Publish the edit to relay-primary authority before its disk mirror. // any .await. The retention upsert hashes the opt-IN projection, so an // update that touched only runtime/local fields is a no-op publish. super::super::agents::retain_managed_agent_pending(&app, &state, record)?; + save_managed_agents(&app, &records)?; let sync_params = if name_changed { let agent_keys = Keys::parse(&record.private_key_nsec) diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 5b595252c37..c66d201f520 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -130,10 +130,10 @@ pub(super) async fn start_local_agent_pairs_with_preflight( record.updated_at = crate::util::now_iso(); } } - save_managed_agents(app, &records)?; if let Some(saved_record) = records.iter().find(|record| record.pubkey == pubkey) { retain_managed_agent_pending(app, state, saved_record)?; } + save_managed_agents(app, &records)?; } let mut errors = Vec::new(); @@ -215,6 +215,10 @@ pub async fn list_managed_agents(app: AppHandle) -> Result Result path, + Err(error) => { + let conn = crate::managed_agents::retention::open_retention_db(&scope.db_path)?; + crate::managed_agents::retention::provider_settlement::finish( + &conn, + &invocation.owner, + pubkey, + &invocation.invocation_id, + )?; + return Err(error); + } + }; let deployed_agent_json = agent_json.clone(); let config_clone = config.clone(); @@ -123,6 +179,29 @@ pub(crate) async fn deploy_to_provider( .await .map_err(|e| format!("spawn_blocking failed: {e}"))?; + let conn = crate::managed_agents::retention::open_retention_db(&scope.db_path)?; + let backend_agent_id = match deploy_result { + Ok(backend_agent_id) => { + crate::managed_agents::retention::provider_settlement::record_handle( + &conn, + &invocation.owner, + pubkey, + &invocation.invocation_id, + &backend_agent_id, + )?; + backend_agent_id + } + Err(error) => { + crate::managed_agents::retention::provider_settlement::finish( + &conn, + &invocation.owner, + pubkey, + &invocation.invocation_id, + )?; + return Err(error); + } + }; + // Persist result under lock. let _store_guard = state .managed_agents_store_lock @@ -141,15 +220,106 @@ pub(crate) async fn deploy_to_provider( // against relay edits that landed while the provider call was in flight. let resolved = crate::managed_agents::private_config_overlay::resolved_local_record(state, disk_record)?; + let active_scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let current_authority_event_id = private_authority_event_id( + &conn, + &invocation.owner, + pubkey, + )?; + let current_backend = resolved_provider_backend(&resolved).ok(); + let current_payload = build_deploy_payload(app, state, &resolved).ok(); + let current_fingerprint = current_backend + .as_ref() + .zip(current_payload.as_ref()) + .map(|((id, config), payload)| provider_config_fingerprint(id, config, payload)) + .transpose()?; + if !settlement_matches_invocation( + &scope.db_path, + &invocation.owner, + &invocation, + &active_scope.db_path, + &active_scope.owner_keys.public_key().to_hex(), + ¤t_authority_event_id, + current_backend.as_ref().map(|(id, _)| id.as_str()), + current_fingerprint.as_deref(), + ) { + return Err(format!( + "provider returned handle {backend_agent_id}, but managed-agent authority changed during deploy; unresolved cleanup is retained" + )); + } let (settled, result) = - settle_deploy_result(disk_record, resolved, deploy_result, &deployed_agent_json); + settle_deploy_result(disk_record, resolved, Ok(backend_agent_id), &deployed_agent_json); + result?; + // Relay-primary authority is the commit point. The JSON row is only a + // device-local mirror and must never get ahead of the encrypted head. + super::retain_managed_agent_pending(app, state, &settled)?; save_managed_agents(app, &records)?; - if result.is_ok() { - // Author the settlement as the next 30179 head and write it through - // to the overlay, exactly like every other edit this device makes. - super::retain_managed_agent_pending(app, state, &settled)?; + crate::managed_agents::retention::provider_settlement::finish( + &conn, + &invocation.owner, + pubkey, + &invocation.invocation_id, + ) +} + +#[allow(clippy::too_many_arguments)] +fn settlement_matches_invocation( + invoked_db: &std::path::Path, + invoked_owner: &str, + invocation: &crate::managed_agents::retention::provider_settlement::ProviderSettlement, + current_db: &std::path::Path, + current_owner: &str, + current_head: &str, + current_provider: Option<&str>, + current_fingerprint: Option<&str>, +) -> bool { + invoked_db == current_db + && invoked_owner == current_owner + && invocation.authority_event_id == current_head + && current_provider == Some(invocation.provider_id.as_str()) + && current_fingerprint == Some(invocation.config_fingerprint.as_str()) +} + +fn private_authority_event_id( + conn: &rusqlite::Connection, + owner: &str, + agent: &str, +) -> Result { + let row = crate::managed_agents::retention::get_retained_event( + conn, + buzz_core_pkg::kind::KIND_PRIVATE_MANAGED_AGENT, + owner, + agent, + )? + .ok_or_else(|| "managed-agent private authority is not retained".to_string())?; + let event = nostr::Event::from_json(&row.raw_event) + .map_err(|error| format!("invalid retained managed-agent authority: {error}"))?; + Ok(event.id.to_hex()) +} + +fn provider_config_fingerprint( + provider_id: &str, + config: &serde_json::Value, + payload: &serde_json::Value, +) -> Result { + let bytes = serde_json::to_vec(&(provider_id, config, payload)) + .map_err(|error| format!("failed to fingerprint provider invocation: {error}"))?; + Ok(hex::encode(Sha256::digest(bytes))) +} + +pub(super) fn provider_settlement_pending_error( + pending: &crate::managed_agents::retention::provider_settlement::ProviderSettlement, +) -> String { + match pending.backend_agent_id.as_deref() { + Some(handle) => format!( + "unresolved provider settlement for {} handle {handle}; resolve or force-delete before deploying again", + pending.provider_id + ), + None => format!( + "unfinished provider invocation for {}; restart recovery is required before deploying again", + pending.provider_id + ), } - result } /// Apply the deploy outcome to the relay-resolved record (the one to retain) @@ -759,5 +929,63 @@ mod tests { production.matches("retain_managed_agent_pending(").count(), 1 ); + let save = production + .find("save_managed_agents(app, &records)") + .expect("disk mirror save must remain present"); + assert!(retain < save, "relay authority must commit before the disk mirror"); + } + + fn invocation() -> crate::managed_agents::retention::provider_settlement::ProviderSettlement { + crate::managed_agents::retention::provider_settlement::ProviderSettlement { + owner: "owner-a".into(), + agent: "agent".into(), + invocation_id: "call".into(), + authority_event_id: "head-a".into(), + provider_id: "provider-a".into(), + config_fingerprint: "fingerprint-a".into(), + backend_agent_id: Some("handle-a".into()), + } + } + + #[test] + fn delayed_provider_a_result_is_fenced_after_authority_moves_local() { + let dir = tempfile::tempdir().unwrap(); + let invocation = invocation(); + assert!(!settlement_matches_invocation( + dir.path(), + "owner-a", + &invocation, + dir.path(), + "owner-a", + "head-local", + None, + None, + )); + } + + #[test] + fn delayed_provider_a_result_is_fenced_after_authority_moves_provider_b() { + let dir = tempfile::tempdir().unwrap(); + let invocation = invocation(); + assert!(!settlement_matches_invocation( + dir.path(), + "owner-a", + &invocation, + dir.path(), + "owner-a", + "head-b", + Some("provider-b"), + Some("fingerprint-b"), + )); + assert!(settlement_matches_invocation( + dir.path(), + "owner-a", + &invocation, + dir.path(), + "owner-a", + "head-a", + Some("provider-a"), + Some("fingerprint-a"), + )); } } diff --git a/desktop/src-tauri/src/commands/agents_lifecycle.rs b/desktop/src-tauri/src/commands/agents_lifecycle.rs index 39617667234..dcb35a54caf 100644 --- a/desktop/src-tauri/src/commands/agents_lifecycle.rs +++ b/desktop/src-tauri/src/commands/agents_lifecycle.rs @@ -129,11 +129,11 @@ pub(super) async fn start_local_agent_with_preflight( disk_record, &resolved_record, ); - save_managed_agents(app, &records)?; // Retain the relay-resolved configuration. The projection equality guard // makes a runtime-only start a no-op, while avoiding resurrection of stale // disk config when this device is following a newer relay snapshot. retain_managed_agent_pending(app, state, &resolved_record)?; + save_managed_agents(app, &records)?; build_managed_agent_summary( app, &resolved_record, diff --git a/desktop/src-tauri/src/commands/agents_pending.rs b/desktop/src-tauri/src/commands/agents_pending.rs index 757a23ec542..635716d7409 100644 --- a/desktop/src-tauri/src/commands/agents_pending.rs +++ b/desktop/src-tauri/src/commands/agents_pending.rs @@ -10,7 +10,7 @@ use crate::{app_state::AppState, managed_agents::ManagedAgentRecord}; /// Retain a freshly authored managed-agent event in the local store, flagged /// for relay sync. MUST be called inside the `managed_agents_store_lock`-held -/// body after `save_managed_agents`, NEVER across an `.await`: it acquires +/// body before the device-local mirror save, NEVER across an `.await`: it acquires /// `state.keys` and a retention-db connection, both `std::sync` guards, and /// drops them before returning. /// diff --git a/desktop/src-tauri/src/managed_agents/retention.rs b/desktop/src-tauri/src/managed_agents/retention.rs index e50ba43d8f1..44fc4722c15 100644 --- a/desktop/src-tauri/src/managed_agents/retention.rs +++ b/desktop/src-tauri/src/managed_agents/retention.rs @@ -15,6 +15,7 @@ use tauri::AppHandle; use crate::app_state::AppState; pub(crate) mod deletion_intent; +pub(crate) mod provider_settlement; mod legacy_migration; pub use legacy_migration::migrate_legacy_retention_db; @@ -149,6 +150,7 @@ pub fn open_retention_db(path: &Path) -> Result { .map_err(|e| format!("failed to create retention table: {e}"))?; deletion_intent::initialize(&conn)?; + provider_settlement::initialize(&conn)?; Ok(conn) } diff --git a/desktop/src-tauri/src/managed_agents/retention/provider_settlement.rs b/desktop/src-tauri/src/managed_agents/retention/provider_settlement.rs new file mode 100644 index 00000000000..b2960988e44 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/retention/provider_settlement.rs @@ -0,0 +1,163 @@ +//! Durable provider calls and returned handles, scoped to one relay owner. +//! +//! A provider call is an external side effect. This journal is written before +//! invoking it and records a returned handle before any relay/disk settlement, +//! so a crash or a concurrent relay edit cannot make that resource invisible. + +use rusqlite::{params, Connection, OptionalExtension}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ProviderSettlement { + pub owner: String, + pub agent: String, + pub invocation_id: String, + pub authority_event_id: String, + pub provider_id: String, + pub config_fingerprint: String, + pub backend_agent_id: Option, +} + +pub(super) fn initialize(conn: &Connection) -> Result<(), String> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS managed_agent_provider_settlements ( + owner TEXT NOT NULL, + agent TEXT NOT NULL, + invocation_id TEXT NOT NULL, + authority_event_id TEXT NOT NULL, + provider_id TEXT NOT NULL, + config_fingerprint TEXT NOT NULL, + backend_agent_id TEXT, + PRIMARY KEY (owner, agent) + );", + ) + .map_err(|error| format!("failed to initialize provider settlement journal: {error}")) +} + +pub(crate) fn begin(conn: &Connection, row: &ProviderSettlement) -> Result<(), String> { + if pending(conn, &row.owner, &row.agent)?.is_some() { + return Err("an unresolved provider settlement already exists for this agent".into()); + } + conn.execute( + "INSERT INTO managed_agent_provider_settlements + (owner, agent, invocation_id, authority_event_id, provider_id, config_fingerprint, backend_agent_id) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL)", + params![row.owner, row.agent, row.invocation_id, row.authority_event_id, + row.provider_id, row.config_fingerprint], + ) + .map_err(|error| format!("failed to begin provider settlement: {error}"))?; + Ok(()) +} + +pub(crate) fn record_handle( + conn: &Connection, + owner: &str, + agent: &str, + invocation_id: &str, + backend_agent_id: &str, +) -> Result<(), String> { + let changed = conn + .execute( + "UPDATE managed_agent_provider_settlements SET backend_agent_id = ?4 + WHERE owner = ?1 AND agent = ?2 AND invocation_id = ?3", + params![owner, agent, invocation_id, backend_agent_id], + ) + .map_err(|error| format!("failed to record provider handle: {error}"))?; + if changed != 1 { + return Err("provider settlement invocation is no longer current".into()); + } + Ok(()) +} + +pub(crate) fn pending( + conn: &Connection, + owner: &str, + agent: &str, +) -> Result, String> { + conn.query_row( + "SELECT invocation_id, authority_event_id, provider_id, config_fingerprint, backend_agent_id + FROM managed_agent_provider_settlements WHERE owner = ?1 AND agent = ?2", + params![owner, agent], + |row| { + Ok(ProviderSettlement { + owner: owner.to_string(), + agent: agent.to_string(), + invocation_id: row.get(0)?, + authority_event_id: row.get(1)?, + provider_id: row.get(2)?, + config_fingerprint: row.get(3)?, + backend_agent_id: row.get(4)?, + }) + }, + ) + .optional() + .map_err(|error| format!("failed to read provider settlement: {error}")) +} + +pub(crate) fn finish( + conn: &Connection, + owner: &str, + agent: &str, + invocation_id: &str, +) -> Result<(), String> { + conn.execute( + "DELETE FROM managed_agent_provider_settlements + WHERE owner = ?1 AND agent = ?2 AND invocation_id = ?3", + params![owner, agent, invocation_id], + ) + .map_err(|error| format!("failed to finish provider settlement: {error}"))?; + Ok(()) +} + +/// Explicit operator escape after accepting that the provider protocol has no +/// undeploy operation. Ordinary deletion must never call this implicitly. +pub(crate) fn abandon(conn: &Connection, owner: &str, agent: &str) -> Result<(), String> { + conn.execute( + "DELETE FROM managed_agent_provider_settlements WHERE owner = ?1 AND agent = ?2", + params![owner, agent], + ) + .map_err(|error| format!("failed to abandon provider settlement: {error}"))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::managed_agents::retention::open_retention_db; + + fn row() -> ProviderSettlement { + ProviderSettlement { + owner: "owner".into(), + agent: "agent".into(), + invocation_id: "call-1".into(), + authority_event_id: "head-a".into(), + provider_id: "provider-a".into(), + config_fingerprint: "fingerprint-a".into(), + backend_agent_id: None, + } + } + + #[test] + fn returned_handle_survives_restart_until_exact_invocation_finishes() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("retention.db"); + { + let conn = open_retention_db(&path).unwrap(); + begin(&conn, &row()).unwrap(); + record_handle(&conn, "owner", "agent", "call-1", "handle-a").unwrap(); + } + let conn = open_retention_db(&path).unwrap(); + let restored = pending(&conn, "owner", "agent").unwrap().unwrap(); + assert_eq!(restored.backend_agent_id.as_deref(), Some("handle-a")); + assert!(finish(&conn, "owner", "agent", "wrong-call").is_ok()); + assert!(pending(&conn, "owner", "agent").unwrap().is_some()); + finish(&conn, "owner", "agent", "call-1").unwrap(); + assert!(pending(&conn, "owner", "agent").unwrap().is_none()); + } + + #[test] + fn unresolved_call_blocks_a_second_provider_side_effect() { + let conn = open_retention_db(std::path::Path::new(":memory:")).unwrap(); + begin(&conn, &row()).unwrap(); + assert!(begin(&conn, &row()).unwrap_err().contains("unresolved")); + } +} From 637f1bb5d3c798bbf26dd5d3d3c28ce6d5782af9 Mon Sep 17 00:00:00 2001 From: yamonkjdmac24gb Date: Sun, 13 Sep 2026 20:43:25 +0900 Subject: [PATCH 2/2] feat(kubernetes): reference cluster-managed environment Signed-off-by: yamonkjdmac24gb --- crates/buzz-backend-kubernetes/src/config.rs | 59 ++++++++++++++- crates/buzz-backend-kubernetes/src/intent.rs | 12 ++++ crates/buzz-backend-kubernetes/src/observe.rs | 5 +- crates/buzz-backend-kubernetes/src/pod.rs | 72 ++++++++++++++++--- .../buzz-backend-kubernetes/src/reconcile.rs | 29 +++++++- docs/remote-agents.md | 13 +++- 6 files changed, 173 insertions(+), 17 deletions(-) diff --git a/crates/buzz-backend-kubernetes/src/config.rs b/crates/buzz-backend-kubernetes/src/config.rs index 4d96735b7b5..91d17f13d14 100644 --- a/crates/buzz-backend-kubernetes/src/config.rs +++ b/crates/buzz-backend-kubernetes/src/config.rs @@ -1,7 +1,7 @@ //! `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 +//! Ten 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`). @@ -71,6 +71,10 @@ pub struct ProviderConfig { /// `None` when `inactivity_seconds` was 0 — refused in v1, see [`parse`]. pub inactivity_seconds: Option, pub service_account: Option, + /// Optional pre-existing Secret whose variables are loaded before the + /// provider-owned identity Secret. The reference is non-secret metadata; + /// values remain owned by the cluster secret manager. + pub environment_ref: Option, } /// Read an optional non-empty string field. Rejects non-string scalars rather @@ -118,6 +122,22 @@ fn valid_namespace(name: &str) -> bool { .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') } +/// Kubernetes Secret names are DNS subdomains. Keep validation local so a +/// typo fails before the provider creates any per-agent resources. +fn valid_environment_ref(name: &str) -> bool { + !name.is_empty() + && name.len() <= 253 + && name.split('.').all(|part| { + !part.is_empty() + && part.len() <= 63 + && part.starts_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit()) + && part.ends_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit()) + && part + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + }) +} + pub fn parse(cfg: &serde_json::Value) -> Result { if !cfg.is_object() && !cfg.is_null() { return Err("provider_config must be a JSON object".to_string()); @@ -165,6 +185,17 @@ pub fn parse(cfg: &serde_json::Value) -> Result { Some(n) => Some(n), }; + let environment_ref = optional_string(cfg, "environment_ref")?; + if environment_ref + .as_deref() + .is_some_and(|name| !valid_environment_ref(name)) + { + return Err(format!( + "provider_config.environment_ref {:?} is not a valid Kubernetes Secret name", + environment_ref.as_deref().unwrap_or_default() + )); + } + Ok(ProviderConfig { context: optional_string(cfg, "context")?, namespace, @@ -172,6 +203,7 @@ pub fn parse(cfg: &serde_json::Value) -> Result { resources, inactivity_seconds, service_account: optional_string(cfg, "service_account")?, + environment_ref, }) } @@ -236,6 +268,11 @@ pub fn config_schema() -> serde_json::Value { "type": "string", "title": "Service account", "description": "Scheduling/RBAC identity only. No API token is mounted." + }, + "environment_ref": { + "type": "string", + "title": "Existing environment Secret", + "description": "Optional Secret in this namespace managed by External Secrets or another cluster operator. Its values are loaded without being copied into Buzz configuration; Buzz-owned identity variables always win." } }, "required": ["namespace", "image"] @@ -265,6 +302,7 @@ mod tests { assert_eq!(c.inactivity_seconds, Some(DEFAULT_INACTIVITY_SECONDS)); assert_eq!(c.context, None); assert_eq!(c.service_account, None); + assert_eq!(c.environment_ref, None); } #[test] @@ -359,6 +397,20 @@ mod tests { } } + #[test] + fn validates_existing_environment_reference() { + let mut cfg = minimal(); + cfg["environment_ref"] = "yamon-erp-hermes-runtime".into(); + assert_eq!( + parse(&cfg).unwrap().environment_ref.as_deref(), + Some("yamon-erp-hermes-runtime") + ); + for bad in ["UPPER", "-leading", "trailing-", "has_underscore", "a..b"] { + cfg["environment_ref"] = bad.into(); + assert!(parse(&cfg).is_err(), "accepted environment_ref {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 @@ -423,10 +475,10 @@ mod tests { ); } - /// Nine fields exactly (§`provider_config` v1 fields). The cap is 20; the + /// Ten 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() { + fn schema_declares_exactly_the_ten_fields() { let schema = config_schema(); let props = schema["properties"].as_object().unwrap(); let mut keys: Vec<&str> = props.keys().map(String::as_str).collect(); @@ -437,6 +489,7 @@ mod tests { "context", "cpu_limit", "cpu_request", + "environment_ref", "image", "inactivity_seconds", "memory_limit", diff --git a/crates/buzz-backend-kubernetes/src/intent.rs b/crates/buzz-backend-kubernetes/src/intent.rs index 73218f83bff..297330f497d 100644 --- a/crates/buzz-backend-kubernetes/src/intent.rs +++ b/crates/buzz-backend-kubernetes/src/intent.rs @@ -73,6 +73,9 @@ pub struct IntentTemplate { pub cpu_limit: String, pub memory_limit: String, pub service_account: Option, + /// Optional cluster-managed environment Secret name. Names are not + /// credentials, but changing the reference changes the pod contract. + pub environment_ref: Option, pub restart_policy: &'static str, pub termination_grace_period_seconds: i64, /// Env *keys* only, sorted. Keys are pod-shape (a renamed key changes the @@ -110,6 +113,7 @@ impl IntentTemplate { image: &ImageRef, resources: &crate::config::Resources, service_account: Option<&str>, + environment_ref: Option<&str>, env_keys: impl IntoIterator, ) -> Self { let mut env_keys: Vec = env_keys.into_iter().collect(); @@ -123,6 +127,7 @@ impl IntentTemplate { cpu_limit: resources.cpu_limit.clone(), memory_limit: resources.memory_limit.clone(), service_account: service_account.map(str::to_string), + environment_ref: environment_ref.map(str::to_string), restart_policy: crate::config::RESTART_POLICY, termination_grace_period_seconds: crate::config::TERMINATION_GRACE_SECONDS, env_keys, @@ -153,6 +158,7 @@ mod tests { &image('a'), &Resources::default(), None, + None, ["BUZZ_RELAY_URL".to_string(), "GOOSE_MODE".to_string()], ) } @@ -178,6 +184,7 @@ mod tests { &image('a'), &Resources::default(), None, + None, ["A".to_string(), "B".to_string(), "C".to_string()], ); let b = IntentTemplate::new( @@ -185,6 +192,7 @@ mod tests { &image('a'), &Resources::default(), None, + None, ["C".to_string(), "A".to_string(), "B".to_string()], ); assert_eq!(a.fingerprint(), b.fingerprint()); @@ -235,6 +243,10 @@ mod tests { "service_account", Box::new(|t: &mut IntentTemplate| t.service_account = Some("sa".into())), ), + ( + "environment_ref", + Box::new(|t: &mut IntentTemplate| t.environment_ref = Some("external-env".into())), + ), ( "restart_policy", Box::new(|t: &mut IntentTemplate| t.restart_policy = "OnFailure"), diff --git a/crates/buzz-backend-kubernetes/src/observe.rs b/crates/buzz-backend-kubernetes/src/observe.rs index 1c6c8835633..65a1926b1e2 100644 --- a/crates/buzz-backend-kubernetes/src/observe.rs +++ b/crates/buzz-backend-kubernetes/src/observe.rs @@ -125,7 +125,10 @@ pub fn referenced_secret(pod: &Pod) -> Option { .containers .iter() .flat_map(|c| c.env_from.iter().flatten()) - .find_map(|source| source.secret_ref.as_ref().map(|r| r.name.clone())) + // The provider-owned per-attempt identity Secret is always last. An + // optional cluster-managed environment source may precede it. + .filter_map(|source| source.secret_ref.as_ref().map(|r| r.name.clone())) + .next_back() } /// Classify a pull failure from the kubelet's message. diff --git a/crates/buzz-backend-kubernetes/src/pod.rs b/crates/buzz-backend-kubernetes/src/pod.rs index 725f98fc7dd..fa2a39deb28 100644 --- a/crates/buzz-backend-kubernetes/src/pod.rs +++ b/crates/buzz-backend-kubernetes/src/pod.rs @@ -111,13 +111,28 @@ pub fn build_pod( // 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() - }]), + // Cluster-managed application values load first. The provider-owned + // identity Secret loads last so an external Secret can never override + // BUZZ_PRIVATE_KEY, BUZZ_AUTH_TAG, or any other launch authority. + env_from: Some( + cfg.environment_ref + .iter() + .map(|name| EnvFromSource { + secret_ref: Some(SecretEnvSource { + name: name.clone(), + optional: Some(false), + }), + ..Default::default() + }) + .chain(std::iter::once(EnvFromSource { + secret_ref: Some(SecretEnvSource { + name: identity.secret_name(generation), + optional: Some(false), + }), + ..Default::default() + })) + .collect(), + ), resources: Some(ResourceRequirements { requests: Some(requests), limits: Some(limits), @@ -192,6 +207,7 @@ pub fn intent_template( &cfg.image, &cfg.resources, cfg.service_account.as_deref(), + cfg.environment_ref.as_deref(), env_keys, ) } @@ -342,13 +358,46 @@ mod tests { 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 source = spec(&pod).containers[0] + .env_from + .as_ref() + .unwrap() + .last() + .unwrap(); 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()); } + #[test] + fn cluster_environment_loads_before_provider_identity() { + let id = identity(); + let mut cfg = provider_config(); + cfg.environment_ref = Some("erp-hermes-runtime".into()); + let pod = build_pod(&id, &cfg, "gen00042", &Fingerprint::from_annotation("f")); + let sources = spec(&pod).containers[0].env_from.as_ref().unwrap(); + assert_eq!(sources.len(), 2); + assert_eq!( + sources[0] + .secret_ref + .as_ref() + .map(|source| source.name.as_str()), + Some("erp-hermes-runtime") + ); + assert_eq!( + sources[1] + .secret_ref + .as_ref() + .map(|source| source.name.as_str()), + Some(id.secret_name("gen00042").as_str()) + ); + assert_eq!( + sources[0].secret_ref.as_ref().unwrap().optional, + Some(false) + ); + } + /// Identity, ownership marker, and the recorded intent all travel on the /// pod — the GC and reconciliation fences read exactly these. #[test] @@ -434,7 +483,12 @@ mod tests { 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] + spec(p).containers[0] + .env_from + .as_ref() + .unwrap() + .last() + .unwrap() .secret_ref .as_ref() .unwrap() diff --git a/crates/buzz-backend-kubernetes/src/reconcile.rs b/crates/buzz-backend-kubernetes/src/reconcile.rs index df2f99789ff..fda1bf0c111 100644 --- a/crates/buzz-backend-kubernetes/src/reconcile.rs +++ b/crates/buzz-backend-kubernetes/src/reconcile.rs @@ -383,7 +383,18 @@ pub async fn deploy( } let observed = observe_pod(substrate, identity).await?; - match classify::classify(observed.as_ref(), &desired) { + let action = classify::classify(observed.as_ref(), &desired); + if matches!(&action, Action::Create | Action::Delete { .. }) { + if let Some(environment_ref) = cfg.environment_ref.as_deref() { + if !substrate.secret_exists(environment_ref).await? { + return Err(format!( + "provider_config.environment_ref {environment_ref:?} does not exist in namespace {:?}; create or reconcile that Secret before starting the agent", + cfg.namespace + )); + } + } + } + match action { // The only success edge: the harness container is running. Action::NoOp { agent_id } => return Ok(agent_id), @@ -684,6 +695,7 @@ mod tests { resources: Resources::default(), inactivity_seconds: Some(7200), service_account: None, + environment_ref: None, } } @@ -1528,6 +1540,21 @@ mod tests { ); } + #[test] + fn missing_external_environment_fails_before_any_agent_mutation() { + let mut cfg = config(); + cfg.environment_ref = Some("erp-hermes-runtime".into()); + let substrate = Fake::default(); + let error = run(&substrate, &identity(), &cfg).unwrap_err(); + assert!(error.contains("environment_ref"), "got: {error}"); + assert!(error.contains("erp-hermes-runtime"), "got: {error}"); + assert_eq!( + substrate.mutations(), + vec!["ensure_namespace buzz-agents-test"], + "missing external environment must fail before Secret or Pod creation" + ); + } + /// GC failures are hygiene, not deploy failures: a list the user cannot /// perform must not block a deploy they can. #[test] diff --git a/docs/remote-agents.md b/docs/remote-agents.md index 45289ef910a..1a90c4f1353 100644 --- a/docs/remote-agents.md +++ b/docs/remote-agents.md @@ -1396,9 +1396,16 @@ removable with `kubectl delete`. ### `provider_config` v1 fields `context`, `namespace`, `image`, `cpu_request`, `memory_request`, -`cpu_limit`, `memory_limit`, `inactivity_seconds`, `service_account` — -9 of the 20-field validation cap. Node selectors, tolerations, and PVCs are -deliberately baked out of v1 to preserve budget. +`cpu_limit`, `memory_limit`, `inactivity_seconds`, `service_account`, +`environment_ref` — 10 of the 20-field validation cap. `environment_ref` +names an existing Secret in the selected namespace. It lets External Secrets +or another cluster operator own application credentials without copying their +values into Desktop configuration. The provider-owned per-attempt identity +Secret loads after that reference, so cluster-managed values cannot override +the agent nsec, owner authorization, relay, or lifecycle nonce. The provider +fails before creating or replacing agent resources when the referenced Secret +is absent. Node selectors, tolerations, and PVCs are deliberately baked out of +v1 to preserve budget. ### Distribution