@@ -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]\n context = \" orbstack\" \n namespace = \" 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]\n cluster = \" oab\" \n profile = \" oab-fleet\" \n " ;
1106+ save_bindings_text ( & aws_path, aws_text) . expect ( "save aws" ) ;
1107+ let k8s_text = "[fleet.dev]\n context = \" orbstack\" \n namespace = \" 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