Skip to content

Commit 6e6eea5

Browse files
committed
feat(studio-cp): k8s fleet binding config schema — separate fleets-k8s.toml (slice 3f)
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)
1 parent d56e818 commit 6e6eea5

1 file changed

Lines changed: 237 additions & 0 deletions

File tree

crates/studio-cp/src/lib.rs

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,152 @@ fn role_identity(arn: &str) -> Option<(String, String)> {
464464
Some((account, name))
465465
}
466466

467+
// ---- K8s fleet binding (ADR #63 slice 3f) --------------------------------
468+
//
469+
// Parallel, additive config surface for k8s-driven fleets — a **separate
470+
// file** (`fleets-k8s.toml`, not a second table in `fleets.toml`). Kept
471+
// separate deliberately: `save_bindings_text`/`save_k8s_bindings_text` are
472+
// both whole-file verbatim writes, so if AWS and k8s bindings shared one
473+
// file, saving either one from the console would silently clobber the
474+
// other's edits (e.g. a k8s-only save wiping Brett's existing prod
475+
// `[fleet.*]` entries). One file per driver makes that class of bug
476+
// structurally impossible instead of relying on callers to merge carefully.
477+
//
478+
// A fleet is either AWS-driven (`FleetBinding`, `fleets.toml`) or k8s-driven
479+
// (`K8sFleetBinding`, `fleets-k8s.toml`); nothing infers one from the other,
480+
// and nothing here reads or writes `fleets.toml`.
481+
482+
/// A declarative binding of a k8s-driven fleet to the kubeconfig context that
483+
/// should manage it, plus the fleet's members. `context`+`namespace` stand in
484+
/// for `FleetBinding`'s `cluster`+`profile` — there's no AWS account/region
485+
/// here, just "which kubeconfig context, and which namespace within it"
486+
/// (namespace is OAB's own `namespace`, which maps directly onto the k8s
487+
/// namespace — see `k8s_driver`'s module docs upstream in `oabctl`).
488+
#[derive(Debug, Clone, serde::Deserialize)]
489+
pub struct K8sFleetBinding {
490+
/// Fleet name — the `[fleet.<name>]` key.
491+
#[serde(default)]
492+
pub name: String,
493+
/// Kubeconfig context name. `None` = the kubeconfig's current-context —
494+
/// same "ambient default, explicit override" shape `K8sDriver::from_context`
495+
/// and `observe_k8s_identity` already use.
496+
#[serde(default)]
497+
pub context: Option<String>,
498+
/// k8s namespace this fleet's members live in.
499+
pub namespace: String,
500+
/// Agent names in this fleet. Empty ⇒ the whole namespace (mirrors
501+
/// `FleetBinding`'s empty-members-means-everything convention).
502+
#[serde(default)]
503+
pub members: Vec<String>,
504+
}
505+
506+
impl K8sFleetBinding {
507+
/// Whether `agent_name` belongs to this fleet. An **empty** member list ⇒
508+
/// the fleet covers the whole namespace (mirrors `FleetBinding::includes`).
509+
pub fn includes(&self, agent_name: &str) -> bool {
510+
self.members.is_empty() || self.members.iter().any(|m| m == agent_name)
511+
}
512+
}
513+
514+
/// The body of a `[fleet.<name>]` table in `fleets-k8s.toml` — the fields of
515+
/// a [`K8sFleetBinding`] minus `name`, which is the table key.
516+
#[derive(Debug, Clone, serde::Deserialize)]
517+
struct K8sFleetBody {
518+
#[serde(default)]
519+
context: Option<String>,
520+
namespace: String,
521+
#[serde(default)]
522+
members: Vec<String>,
523+
}
524+
525+
#[derive(serde::Deserialize)]
526+
struct K8sFleetsDoc {
527+
#[serde(default)]
528+
fleet: std::collections::BTreeMap<String, K8sFleetBody>,
529+
}
530+
531+
impl From<K8sFleetsDoc> for K8sFleetBindings {
532+
fn from(doc: K8sFleetsDoc) -> Self {
533+
K8sFleetBindings {
534+
fleets: doc
535+
.fleet
536+
.into_iter()
537+
.map(|(name, b)| K8sFleetBinding {
538+
name,
539+
context: b.context,
540+
namespace: b.namespace,
541+
members: b.members,
542+
})
543+
.collect(),
544+
}
545+
}
546+
}
547+
548+
/// Parsed k8s-fleet-binding file, canonicalized to a list. Deserializes from
549+
/// `[fleet.<name>]` (only form — no legacy array form, unlike `FleetBindings`,
550+
/// since there's no pre-existing k8s config to stay compatible with).
551+
#[derive(Debug, Clone, Default, serde::Deserialize)]
552+
#[serde(from = "K8sFleetsDoc")]
553+
pub struct K8sFleetBindings {
554+
pub fleets: Vec<K8sFleetBinding>,
555+
}
556+
557+
impl K8sFleetBindings {
558+
/// The fleet whose explicit `members` contain `agent_name`, if any.
559+
pub fn fleet_for_agent(&self, agent_name: &str) -> Option<&K8sFleetBinding> {
560+
self.fleets.iter().find(|b| b.includes(agent_name))
561+
}
562+
563+
/// A fleet by name.
564+
pub fn get(&self, name: &str) -> Option<&K8sFleetBinding> {
565+
self.fleets.iter().find(|b| b.name == name)
566+
}
567+
}
568+
569+
/// Default k8s-fleet-binding config path: `$OAB_K8S_FLEETS_CONFIG`, else
570+
/// `<config-dir>/oab-studio/fleets-k8s.toml`. Deliberately a different file
571+
/// from `default_bindings_path()` — see module docs above.
572+
pub fn default_k8s_bindings_path() -> Option<std::path::PathBuf> {
573+
if let Ok(p) = std::env::var("OAB_K8S_FLEETS_CONFIG") {
574+
return Some(std::path::PathBuf::from(p));
575+
}
576+
dirs::config_dir().map(|d| d.join("oab-studio").join("fleets-k8s.toml"))
577+
}
578+
579+
/// Load k8s fleet bindings from `path`. A missing file is **not** an error —
580+
/// it yields an empty set, so bindings are strictly opt-in (mirrors
581+
/// `load_bindings`).
582+
pub fn load_k8s_bindings(path: &std::path::Path) -> anyhow::Result<K8sFleetBindings> {
583+
match std::fs::read_to_string(path) {
584+
Ok(content) => Ok(toml::from_str(&content)?),
585+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(K8sFleetBindings::default()),
586+
Err(e) => Err(e.into()),
587+
}
588+
}
589+
590+
/// Resolve the k8s driver a binding selects — a `K8sDriver` bound to its
591+
/// kubeconfig context. This is the k8s counterpart to
592+
/// `resolve_binding_config`'s AWS `SdkConfig` resolution: the **switch**,
593+
/// calls for this fleet act against the bound context instead of whatever
594+
/// kubeconfig `current-context` happens to be ambient. Orbstack's local
595+
/// cluster is targeted exactly this way — just a context name, no
596+
/// special-casing.
597+
pub async fn resolve_binding_driver(binding: &K8sFleetBinding) -> anyhow::Result<oabctl::K8sDriver> {
598+
oabctl::K8sDriver::from_context(binding.context.as_deref()).await
599+
}
600+
601+
/// Validate `text` parses as a k8s-bindings file and, if so, persist it
602+
/// verbatim to `fleets-k8s.toml` (never `fleets.toml` — see module docs
603+
/// above), returning the parsed set. Mirrors `save_bindings_text`.
604+
pub fn save_k8s_bindings_text(
605+
path: &std::path::Path,
606+
text: &str,
607+
) -> anyhow::Result<K8sFleetBindings> {
608+
let parsed: K8sFleetBindings = toml::from_str(text)?;
609+
write_bindings_atomic(path, text)?;
610+
Ok(parsed)
611+
}
612+
467613
// ---- Fleet-binding editing (raw-text, whole-file) ------------------------
468614
//
469615
// The write half of the config panel: the operator edits `fleets.toml` as text
@@ -875,6 +1021,97 @@ members = ["oab-prod-mira"]
8751021
.is_empty());
8761022
}
8771023

1024+
#[test]
1025+
fn k8s_fleets_parse_with_context_and_members() {
1026+
let doc = r#"
1027+
[fleet.orbstack-dev]
1028+
context = "orbstack"
1029+
namespace = "dev"
1030+
members = ["scratch-agent"]
1031+
1032+
[fleet.orca-k8s]
1033+
namespace = "prod"
1034+
"#;
1035+
let b: K8sFleetBindings = toml::from_str(doc).expect("parse");
1036+
assert_eq!(b.fleets.len(), 2);
1037+
let dev = b.get("orbstack-dev").expect("orbstack-dev fleet");
1038+
assert_eq!(dev.context.as_deref(), Some("orbstack"));
1039+
assert_eq!(dev.namespace, "dev");
1040+
assert_eq!(dev.members, vec!["scratch-agent".to_string()]);
1041+
// context omitted ⇒ None (kubeconfig current-context), same as
1042+
// K8sDriver::from_context's "ambient default" contract
1043+
let prod = b.get("orca-k8s").expect("orca-k8s fleet");
1044+
assert_eq!(prod.context, None);
1045+
}
1046+
1047+
#[test]
1048+
fn k8s_binding_includes_matches_by_name_or_whole_namespace() {
1049+
let scoped = K8sFleetBinding {
1050+
name: "dev".into(),
1051+
context: Some("orbstack".into()),
1052+
namespace: "dev".into(),
1053+
members: vec!["scratch-agent".into()],
1054+
};
1055+
assert!(scoped.includes("scratch-agent"));
1056+
assert!(!scoped.includes("other-agent"));
1057+
1058+
let whole = K8sFleetBinding {
1059+
name: "prod".into(),
1060+
context: None,
1061+
namespace: "prod".into(),
1062+
members: vec![],
1063+
};
1064+
assert!(whole.includes("anything"));
1065+
}
1066+
1067+
#[test]
1068+
fn empty_k8s_config_and_no_fleet_key_parse_to_empty() {
1069+
assert!(toml::from_str::<K8sFleetBindings>("").unwrap().fleets.is_empty());
1070+
assert!(toml::from_str::<K8sFleetBindings>("# just a comment\n")
1071+
.unwrap()
1072+
.fleets
1073+
.is_empty());
1074+
}
1075+
1076+
#[test]
1077+
fn load_k8s_bindings_missing_file_is_empty() {
1078+
let path = std::env::temp_dir().join("oab-k8s-fleets-does-not-exist-xyz.toml");
1079+
let _ = std::fs::remove_file(&path);
1080+
assert!(load_k8s_bindings(&path).unwrap().fleets.is_empty());
1081+
}
1082+
1083+
#[test]
1084+
fn save_k8s_bindings_round_trips_and_preserves_text_verbatim() {
1085+
let dir = std::env::temp_dir().join(format!("oab-k8s-fleets-save-{}", std::process::id()));
1086+
let path = dir.join("fleets-k8s.toml");
1087+
let _ = std::fs::remove_dir_all(&dir);
1088+
let text = "# my k8s fleets\n\n[fleet.dev]\ncontext = \"orbstack\"\nnamespace = \"dev\"\n";
1089+
let parsed = save_k8s_bindings_text(&path, text).expect("save");
1090+
assert_eq!(parsed.fleets.len(), 1);
1091+
assert_eq!(parsed.get("dev").unwrap().namespace, "dev");
1092+
assert_eq!(read_bindings_text(&path).unwrap(), text);
1093+
let _ = std::fs::remove_dir_all(&dir);
1094+
}
1095+
1096+
#[test]
1097+
fn k8s_bindings_file_is_separate_from_aws_bindings_file() {
1098+
// Saving k8s bindings must never touch fleets.toml — the whole reason
1099+
// this is a separate file (see module docs on K8sFleetBinding).
1100+
let dir = std::env::temp_dir().join(format!("oab-separate-fleets-{}", std::process::id()));
1101+
let _ = std::fs::remove_dir_all(&dir);
1102+
let aws_path = dir.join("fleets.toml");
1103+
let k8s_path = dir.join("fleets-k8s.toml");
1104+
1105+
let aws_text = "[fleet.prod]\ncluster = \"oab\"\nprofile = \"oab-fleet\"\n";
1106+
save_bindings_text(&aws_path, aws_text).expect("save aws");
1107+
let k8s_text = "[fleet.dev]\ncontext = \"orbstack\"\nnamespace = \"dev\"\n";
1108+
save_k8s_bindings_text(&k8s_path, k8s_text).expect("save k8s");
1109+
1110+
assert_eq!(read_bindings_text(&aws_path).unwrap(), aws_text);
1111+
assert_eq!(read_bindings_text(&k8s_path).unwrap(), k8s_text);
1112+
let _ = std::fs::remove_dir_all(&dir);
1113+
}
1114+
8781115
#[test]
8791116
fn save_bindings_round_trips_and_preserves_text_verbatim() {
8801117
let dir = std::env::temp_dir().join(format!("oab-fleets-save-{}", std::process::id()));

0 commit comments

Comments
 (0)