From 3072e8fd70b173784aec460673d248aab4c148e9 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Thu, 27 Aug 2026 21:15:24 +0800 Subject: [PATCH] feat(studio-cp,oab-mcp): K8sFleetBinding.expected_principal + k8s_fleet_config_write (studio#104) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third sub-item of #104, stacked on #106. - K8sFleetBinding gets expected_principal: Option, deliberately named to match FleetBinding's AWS-side field — the verify machinery already exists symmetrically (observe_k8s_identity's SelfSubjectReview- derived principal + k8s_principal_kind, same shape as observe_identity/ identity_matches for AWS), this just wires the config schema to it. Typically a system:serviceaccount:: string, or a plain username; unset = no identity check for that fleet. - k8s_fleet_config_write: new MCP tool mirroring fleet_config_write's AWS-side write path (validate text parses, write bytes verbatim so comments/layout survive, return the parsed fleets + raw text). save_k8s_bindings_text (studio-cp/lib.rs) already existed and needed zero changes — it's a generic toml::from_str + verbatim write, so it picked up the new field automatically once added to the schema structs. Unlike AWS bindings, k8s bindings aren't cached anywhere in OabMcp yet (nothing dispatches provisioning to K8sDriver yet either — separate item), so this is a plain validate-then-write, no in-memory state to invalidate. Ref: studio#104. --- crates/oab-mcp/src/lib.rs | 47 ++++++++++++++++++++++++++++++++++++- crates/studio-cp/src/lib.rs | 32 +++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) 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")); }