From 6e6eea59b4f91e374c1c5f7e212918c767e3a335 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Sat, 22 Aug 2026 09:19:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(studio-cp):=20k8s=20fleet=20binding=20conf?= =?UTF-8?q?ig=20schema=20=E2=80=94=20separate=20fleets-k8s.toml=20(slice?= =?UTF-8?q?=203f)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit K8sFleetBinding/K8sFleetBindings, the k8s counterpart to the existing AWS FleetBinding/FleetBindings: a fleet's context+namespace instead of cluster+profile, resolve_binding_driver -> oabctl::K8sDriver instead of resolve_binding_config -> aws_config::SdkConfig. Deliberately a **separate file** (fleets-k8s.toml, $OAB_K8S_FLEETS_CONFIG), not a second [k8s_fleet.*] table in the existing fleets.toml. Reason: save_bindings_text/save_k8s_bindings_text are both whole-file verbatim writes (the console's TOML editor round-trips exact text, comments and all). Two tables sharing one file means saving either one from the UI would silently clobber the other's edits on the next write -- concretely, editing a k8s fleet in the console could wipe Brett's actual prod fleets.toml ([fleet.orca]/[fleet.mira], the ECS bindings that already gate real AWS credential selection -- see the fleets-toml-binding incident history). Separate files make that class of bug structurally impossible rather than relying on a careful merge in the write path. read_bindings_text/write_bindings_atomic are reused as-is for the k8s file (they're already path+text generic, no AWS types) -- only the TOML-shaped type being parsed differs. Scope: backend config-schema + load/save only. The console UI panel that actually lets an operator pick AWS-vs-k8s and edit fleets-k8s.toml is frontend work, not attempted here. Stacked on #100 (3d) -- resolve_binding_driver needs oabctl::K8sDriver, which only exists on the unmerged 3a/3b branch chain. 8 new tests (23/23 total in studio-cp), clippy introduces no new warnings (2 pre-existing ones elsewhere in studio-cp/studio-compose, untouched). Ref: studio#97 (K8s driver — ADR #63 slice 3, sub-slice tracking) --- crates/studio-cp/src/lib.rs | 237 ++++++++++++++++++++++++++++++++++++ 1 file changed, 237 insertions(+) diff --git a/crates/studio-cp/src/lib.rs b/crates/studio-cp/src/lib.rs index a1590d4..4678795 100644 --- a/crates/studio-cp/src/lib.rs +++ b/crates/studio-cp/src/lib.rs @@ -464,6 +464,152 @@ fn role_identity(arn: &str) -> Option<(String, String)> { Some((account, name)) } +// ---- K8s fleet binding (ADR #63 slice 3f) -------------------------------- +// +// Parallel, additive config surface for k8s-driven fleets — a **separate +// file** (`fleets-k8s.toml`, not a second table in `fleets.toml`). Kept +// separate deliberately: `save_bindings_text`/`save_k8s_bindings_text` are +// both whole-file verbatim writes, so if AWS and k8s bindings shared one +// file, saving either one from the console would silently clobber the +// other's edits (e.g. a k8s-only save wiping Brett's existing prod +// `[fleet.*]` entries). One file per driver makes that class of bug +// structurally impossible instead of relying on callers to merge carefully. +// +// A fleet is either AWS-driven (`FleetBinding`, `fleets.toml`) or k8s-driven +// (`K8sFleetBinding`, `fleets-k8s.toml`); nothing infers one from the other, +// and nothing here reads or writes `fleets.toml`. + +/// A declarative binding of a k8s-driven fleet to the kubeconfig context that +/// should manage it, plus the fleet's members. `context`+`namespace` stand in +/// for `FleetBinding`'s `cluster`+`profile` — there's no AWS account/region +/// here, just "which kubeconfig context, and which namespace within it" +/// (namespace is OAB's own `namespace`, which maps directly onto the k8s +/// namespace — see `k8s_driver`'s module docs upstream in `oabctl`). +#[derive(Debug, Clone, serde::Deserialize)] +pub struct K8sFleetBinding { + /// Fleet name — the `[fleet.]` key. + #[serde(default)] + pub name: String, + /// Kubeconfig context name. `None` = the kubeconfig's current-context — + /// same "ambient default, explicit override" shape `K8sDriver::from_context` + /// and `observe_k8s_identity` already use. + #[serde(default)] + pub context: Option, + /// k8s namespace this fleet's members live in. + pub namespace: String, + /// Agent names in this fleet. Empty ⇒ the whole namespace (mirrors + /// `FleetBinding`'s empty-members-means-everything convention). + #[serde(default)] + pub members: Vec, +} + +impl K8sFleetBinding { + /// Whether `agent_name` belongs to this fleet. An **empty** member list ⇒ + /// the fleet covers the whole namespace (mirrors `FleetBinding::includes`). + pub fn includes(&self, agent_name: &str) -> bool { + self.members.is_empty() || self.members.iter().any(|m| m == agent_name) + } +} + +/// The body of a `[fleet.]` table in `fleets-k8s.toml` — the fields of +/// a [`K8sFleetBinding`] minus `name`, which is the table key. +#[derive(Debug, Clone, serde::Deserialize)] +struct K8sFleetBody { + #[serde(default)] + context: Option, + namespace: String, + #[serde(default)] + members: Vec, +} + +#[derive(serde::Deserialize)] +struct K8sFleetsDoc { + #[serde(default)] + fleet: std::collections::BTreeMap, +} + +impl From for K8sFleetBindings { + fn from(doc: K8sFleetsDoc) -> Self { + K8sFleetBindings { + fleets: doc + .fleet + .into_iter() + .map(|(name, b)| K8sFleetBinding { + name, + context: b.context, + namespace: b.namespace, + members: b.members, + }) + .collect(), + } + } +} + +/// Parsed k8s-fleet-binding file, canonicalized to a list. Deserializes from +/// `[fleet.]` (only form — no legacy array form, unlike `FleetBindings`, +/// since there's no pre-existing k8s config to stay compatible with). +#[derive(Debug, Clone, Default, serde::Deserialize)] +#[serde(from = "K8sFleetsDoc")] +pub struct K8sFleetBindings { + pub fleets: Vec, +} + +impl K8sFleetBindings { + /// The fleet whose explicit `members` contain `agent_name`, if any. + pub fn fleet_for_agent(&self, agent_name: &str) -> Option<&K8sFleetBinding> { + self.fleets.iter().find(|b| b.includes(agent_name)) + } + + /// A fleet by name. + pub fn get(&self, name: &str) -> Option<&K8sFleetBinding> { + self.fleets.iter().find(|b| b.name == name) + } +} + +/// Default k8s-fleet-binding config path: `$OAB_K8S_FLEETS_CONFIG`, else +/// `/oab-studio/fleets-k8s.toml`. Deliberately a different file +/// from `default_bindings_path()` — see module docs above. +pub fn default_k8s_bindings_path() -> Option { + if let Ok(p) = std::env::var("OAB_K8S_FLEETS_CONFIG") { + return Some(std::path::PathBuf::from(p)); + } + dirs::config_dir().map(|d| d.join("oab-studio").join("fleets-k8s.toml")) +} + +/// Load k8s fleet bindings from `path`. A missing file is **not** an error — +/// it yields an empty set, so bindings are strictly opt-in (mirrors +/// `load_bindings`). +pub fn load_k8s_bindings(path: &std::path::Path) -> anyhow::Result { + match std::fs::read_to_string(path) { + Ok(content) => Ok(toml::from_str(&content)?), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(K8sFleetBindings::default()), + Err(e) => Err(e.into()), + } +} + +/// Resolve the k8s driver a binding selects — a `K8sDriver` bound to its +/// kubeconfig context. This is the k8s counterpart to +/// `resolve_binding_config`'s AWS `SdkConfig` resolution: the **switch**, +/// calls for this fleet act against the bound context instead of whatever +/// kubeconfig `current-context` happens to be ambient. Orbstack's local +/// cluster is targeted exactly this way — just a context name, no +/// special-casing. +pub async fn resolve_binding_driver(binding: &K8sFleetBinding) -> anyhow::Result { + oabctl::K8sDriver::from_context(binding.context.as_deref()).await +} + +/// Validate `text` parses as a k8s-bindings file and, if so, persist it +/// verbatim to `fleets-k8s.toml` (never `fleets.toml` — see module docs +/// above), returning the parsed set. Mirrors `save_bindings_text`. +pub fn save_k8s_bindings_text( + path: &std::path::Path, + text: &str, +) -> anyhow::Result { + let parsed: K8sFleetBindings = toml::from_str(text)?; + write_bindings_atomic(path, text)?; + Ok(parsed) +} + // ---- Fleet-binding editing (raw-text, whole-file) ------------------------ // // The write half of the config panel: the operator edits `fleets.toml` as text @@ -875,6 +1021,97 @@ members = ["oab-prod-mira"] .is_empty()); } + #[test] + fn k8s_fleets_parse_with_context_and_members() { + let doc = r#" +[fleet.orbstack-dev] +context = "orbstack" +namespace = "dev" +members = ["scratch-agent"] + +[fleet.orca-k8s] +namespace = "prod" +"#; + let b: K8sFleetBindings = toml::from_str(doc).expect("parse"); + assert_eq!(b.fleets.len(), 2); + let dev = b.get("orbstack-dev").expect("orbstack-dev fleet"); + assert_eq!(dev.context.as_deref(), Some("orbstack")); + assert_eq!(dev.namespace, "dev"); + assert_eq!(dev.members, vec!["scratch-agent".to_string()]); + // context omitted ⇒ None (kubeconfig current-context), same as + // K8sDriver::from_context's "ambient default" contract + let prod = b.get("orca-k8s").expect("orca-k8s fleet"); + assert_eq!(prod.context, None); + } + + #[test] + fn k8s_binding_includes_matches_by_name_or_whole_namespace() { + let scoped = K8sFleetBinding { + name: "dev".into(), + context: Some("orbstack".into()), + namespace: "dev".into(), + members: vec!["scratch-agent".into()], + }; + assert!(scoped.includes("scratch-agent")); + assert!(!scoped.includes("other-agent")); + + let whole = K8sFleetBinding { + name: "prod".into(), + context: None, + namespace: "prod".into(), + members: vec![], + }; + assert!(whole.includes("anything")); + } + + #[test] + fn empty_k8s_config_and_no_fleet_key_parse_to_empty() { + assert!(toml::from_str::("").unwrap().fleets.is_empty()); + assert!(toml::from_str::("# just a comment\n") + .unwrap() + .fleets + .is_empty()); + } + + #[test] + fn load_k8s_bindings_missing_file_is_empty() { + let path = std::env::temp_dir().join("oab-k8s-fleets-does-not-exist-xyz.toml"); + let _ = std::fs::remove_file(&path); + assert!(load_k8s_bindings(&path).unwrap().fleets.is_empty()); + } + + #[test] + fn save_k8s_bindings_round_trips_and_preserves_text_verbatim() { + let dir = std::env::temp_dir().join(format!("oab-k8s-fleets-save-{}", std::process::id())); + let path = dir.join("fleets-k8s.toml"); + let _ = std::fs::remove_dir_all(&dir); + let text = "# my k8s fleets\n\n[fleet.dev]\ncontext = \"orbstack\"\nnamespace = \"dev\"\n"; + let parsed = save_k8s_bindings_text(&path, text).expect("save"); + assert_eq!(parsed.fleets.len(), 1); + assert_eq!(parsed.get("dev").unwrap().namespace, "dev"); + assert_eq!(read_bindings_text(&path).unwrap(), text); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn k8s_bindings_file_is_separate_from_aws_bindings_file() { + // Saving k8s bindings must never touch fleets.toml — the whole reason + // this is a separate file (see module docs on K8sFleetBinding). + let dir = std::env::temp_dir().join(format!("oab-separate-fleets-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let aws_path = dir.join("fleets.toml"); + let k8s_path = dir.join("fleets-k8s.toml"); + + let aws_text = "[fleet.prod]\ncluster = \"oab\"\nprofile = \"oab-fleet\"\n"; + save_bindings_text(&aws_path, aws_text).expect("save aws"); + let k8s_text = "[fleet.dev]\ncontext = \"orbstack\"\nnamespace = \"dev\"\n"; + save_k8s_bindings_text(&k8s_path, k8s_text).expect("save k8s"); + + assert_eq!(read_bindings_text(&aws_path).unwrap(), aws_text); + assert_eq!(read_bindings_text(&k8s_path).unwrap(), k8s_text); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn save_bindings_round_trips_and_preserves_text_verbatim() { let dir = std::env::temp_dir().join(format!("oab-fleets-save-{}", std::process::id()));