diff --git a/crates/oab-mcp/src/lib.rs b/crates/oab-mcp/src/lib.rs index f05fba5..82f9707 100644 --- a/crates/oab-mcp/src/lib.rs +++ b/crates/oab-mcp/src/lib.rs @@ -245,6 +245,17 @@ pub fn tools() -> Vec { "required": ["namespace"] })), ), + Tool::new( + "k8s_fleet_config_write", + "Persist the whole k8s fleet-binding config (fleets-k8s.toml, separate from AWS's fleets.toml) from raw TOML `text`. Validates the text parses before writing — a bad edit never lands on disk — and the bytes are stored verbatim, so comments/layout are preserved. Returns the parsed fleets (name, context, namespace, members, expected_principal) plus the raw text. Write tool: overwrites the operator's fleets-k8s.toml.", + as_map(json!({ + "type": "object", + "properties": { + "text": { "type": "string", "description": "Full TOML document for fleets-k8s.toml (a list of [fleet.] tables)." } + }, + "required": ["text"] + })), + ), ] } @@ -361,6 +372,7 @@ impl OabMcp { "list_k8s_contexts" => self.t_list_k8s_contexts(args), "list_namespaces" => self.t_list_namespaces(args).await, "list_service_accounts" => self.t_list_service_accounts(args).await, + "k8s_fleet_config_write" => self.t_k8s_fleet_write(args), other => anyhow::bail!("unknown tool {other:?}"), } } @@ -794,6 +806,38 @@ impl OabMcp { Ok(json!({ "service_accounts": service_accounts })) } + /// Write tool: persist the whole `fleets-k8s.toml` from the editor's + /// `text` after validating it parses, mirroring `t_fleet_write`'s + /// AWS-side shape. Unlike AWS bindings, k8s bindings aren't cached + /// anywhere in `OabMcp` yet (nothing here dispatches provisioning to + /// `K8sDriver` yet either — that's a separate item), so this is a plain + /// validate-then-write with no in-memory state to invalidate. + fn t_k8s_fleet_write(&self, args: &Map) -> Result { + let text = args + .get("text") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("missing required arg: text"))?; + let path = scp::default_k8s_bindings_path() + .ok_or_else(|| anyhow::anyhow!("no k8s fleet config path resolved; cannot write bindings"))?; + let bindings = scp::save_k8s_bindings_text(&path, text)?; + let fleets: Vec = bindings + .fleets + .iter() + .map(|b| json!({ + "name": b.name, + "context": b.context, + "namespace": b.namespace, + "members": b.members, + "expected_principal": b.expected_principal, + })) + .collect(); + Ok(json!({ + "path": path.display().to_string(), + "fleets": fleets, + "text": text, + })) + } + async fn t_delete(&self, args: &Map) -> Result { let t = self.target(args)?; let cluster = t.cluster.clone(); @@ -876,7 +920,7 @@ mod tests { .iter() .map(|t| t["name"].as_str().expect("tool has a name").to_string()) .collect(); - assert_eq!(names.len(), 15); + assert_eq!(names.len(), 16); for expected in [ "deploy_list", "deploy_get", @@ -893,6 +937,7 @@ mod tests { "list_k8s_contexts", "list_namespaces", "list_service_accounts", + "k8s_fleet_config_write", ] { assert!(names.contains(&expected.to_string()), "missing {expected}"); } diff --git a/crates/studio-cp/src/lib.rs b/crates/studio-cp/src/lib.rs index c883e61..a9204c8 100644 --- a/crates/studio-cp/src/lib.rs +++ b/crates/studio-cp/src/lib.rs @@ -809,6 +809,15 @@ pub struct K8sFleetBinding { /// `FleetBinding`'s empty-members-means-everything convention). #[serde(default)] pub members: Vec, + /// Expected principal to verify the resolved k8s identity against — + /// same name and verify semantics as `FleetBinding::expected_principal` + /// (`observe_identity`/`identity_matches` for AWS), just compared against + /// `observe_k8s_identity`'s `SelfSubjectReview`-derived principal instead + /// of an STS caller ARN. Typically `system:serviceaccount::` + /// for a service account, or a plain username. `None` = no identity + /// verification for this fleet (same "unset = don't check" contract). + #[serde(default)] + pub expected_principal: Option, } impl K8sFleetBinding { @@ -828,6 +837,8 @@ struct K8sFleetBody { namespace: String, #[serde(default)] members: Vec, + #[serde(default)] + expected_principal: Option, } #[derive(serde::Deserialize)] @@ -847,6 +858,7 @@ impl From for K8sFleetBindings { context: b.context, namespace: b.namespace, members: b.members, + expected_principal: b.expected_principal, }) .collect(), } @@ -1389,6 +1401,24 @@ namespace = "prod" assert_eq!(prod.context, None); } + #[test] + fn k8s_fleet_expected_principal_parses_and_defaults_to_none() { + let doc = r#" +[fleet.dev] +namespace = "dev" +expected_principal = "system:serviceaccount:dev:oab-agent" + +[fleet.unset] +namespace = "prod" +"#; + let b: K8sFleetBindings = toml::from_str(doc).expect("parse"); + assert_eq!( + b.get("dev").expect("dev fleet").expected_principal.as_deref(), + Some("system:serviceaccount:dev:oab-agent") + ); + assert_eq!(b.get("unset").expect("unset fleet").expected_principal, None); + } + #[test] fn k8s_binding_includes_matches_by_name_or_whole_namespace() { let scoped = K8sFleetBinding { @@ -1396,6 +1426,7 @@ namespace = "prod" context: Some("orbstack".into()), namespace: "dev".into(), members: vec!["scratch-agent".into()], + expected_principal: None, }; assert!(scoped.includes("scratch-agent")); assert!(!scoped.includes("other-agent")); @@ -1405,6 +1436,7 @@ namespace = "prod" context: None, namespace: "prod".into(), members: vec![], + expected_principal: None, }; assert!(whole.includes("anything")); }