Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 56 additions & 3 deletions crates/buzz-backend-kubernetes/src/config.rs
Original file line number Diff line number Diff line change
@@ -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`).
Expand Down Expand Up @@ -71,6 +71,10 @@ pub struct ProviderConfig {
/// `None` when `inactivity_seconds` was 0 — refused in v1, see [`parse`].
pub inactivity_seconds: Option<u64>,
pub service_account: Option<String>,
/// 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<String>,
}

/// Read an optional non-empty string field. Rejects non-string scalars rather
Expand Down Expand Up @@ -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<ProviderConfig, String> {
if !cfg.is_object() && !cfg.is_null() {
return Err("provider_config must be a JSON object".to_string());
Expand Down Expand Up @@ -165,13 +185,25 @@ pub fn parse(cfg: &serde_json::Value) -> Result<ProviderConfig, String> {
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,
image,
resources,
inactivity_seconds,
service_account: optional_string(cfg, "service_account")?,
environment_ref,
})
}

Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand All @@ -437,6 +489,7 @@ mod tests {
"context",
"cpu_limit",
"cpu_request",
"environment_ref",
"image",
"inactivity_seconds",
"memory_limit",
Expand Down
12 changes: 12 additions & 0 deletions crates/buzz-backend-kubernetes/src/intent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ pub struct IntentTemplate {
pub cpu_limit: String,
pub memory_limit: String,
pub service_account: Option<String>,
/// Optional cluster-managed environment Secret name. Names are not
/// credentials, but changing the reference changes the pod contract.
pub environment_ref: 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
Expand Down Expand Up @@ -110,6 +113,7 @@ impl IntentTemplate {
image: &ImageRef,
resources: &crate::config::Resources,
service_account: Option<&str>,
environment_ref: Option<&str>,
env_keys: impl IntoIterator<Item = String>,
) -> Self {
let mut env_keys: Vec<String> = env_keys.into_iter().collect();
Expand All @@ -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,
Expand Down Expand Up @@ -153,6 +158,7 @@ mod tests {
&image('a'),
&Resources::default(),
None,
None,
["BUZZ_RELAY_URL".to_string(), "GOOSE_MODE".to_string()],
)
}
Expand All @@ -178,13 +184,15 @@ mod tests {
&image('a'),
&Resources::default(),
None,
None,
["A".to_string(), "B".to_string(), "C".to_string()],
);
let b = IntentTemplate::new(
"ns",
&image('a'),
&Resources::default(),
None,
None,
["C".to_string(), "A".to_string(), "B".to_string()],
);
assert_eq!(a.fingerprint(), b.fingerprint());
Expand Down Expand Up @@ -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"),
Expand Down
5 changes: 4 additions & 1 deletion crates/buzz-backend-kubernetes/src/observe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,10 @@ pub fn referenced_secret(pod: &Pod) -> Option<String> {
.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.
Expand Down
72 changes: 63 additions & 9 deletions crates/buzz-backend-kubernetes/src/pod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -192,6 +207,7 @@ pub fn intent_template(
&cfg.image,
&cfg.resources,
cfg.service_account.as_deref(),
cfg.environment_ref.as_deref(),
env_keys,
)
}
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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()
Expand Down
29 changes: 28 additions & 1 deletion crates/buzz-backend-kubernetes/src/reconcile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),

Expand Down Expand Up @@ -684,6 +695,7 @@ mod tests {
resources: Resources::default(),
inactivity_seconds: Some(7200),
service_account: None,
environment_ref: None,
}
}

Expand Down Expand Up @@ -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]
Expand Down
5 changes: 2 additions & 3 deletions desktop/src-tauri/src/commands/agent_models_update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading