From eb6e7a8d3aef837b6a9026814dedb7a3f9c9a704 Mon Sep 17 00:00:00 2001 From: "Orca (ecs-claude)" Date: Fri, 14 Aug 2026 01:43:16 +0800 Subject: [PATCH] feat(cp): [fleet.] schema with members (fleet grouping, ADR slice 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend for fleet-as-usage-grouping: a fleet is a named group with explicit members, decoupled from the physical cluster — two fleets may share a cluster (and one credential) while listing different members. Lets orca and mira be distinct fleets on cluster `oab`. - studio-cp: FleetBinding gains `members`; FleetBindings deserializes from the new `[fleet.]` map form (name = key) OR the legacy `[[fleet]]` array form (back-compat — existing configs keep parsing unchanged), via an untagged intermediate. `members` empty ⇒ whole cluster (legacy semantics). Adds `fleet_for_service` and `get(name)`; `for_cluster` unchanged (credential resolution stays cluster/account-granular). - oab-mcp: `fleet_config` now reports `members` per fleet. Tests: legacy parse still matches by cluster (members empty); named fleets parse with members + share a cluster; empty/comment-only parse to empty. Verified in isolation that the untagged+toml deserialize round-trips both forms. Slice 2 (config panel: list by fleet, roster filtered to members, switch by fleet) follows. Co-Authored-By: Claude Opus 4.8 --- crates/oab-mcp/src/main.rs | 1 + crates/studio-cp/src/lib.rs | 137 ++++++++++++++++++++++++++++++++++-- 2 files changed, 131 insertions(+), 7 deletions(-) diff --git a/crates/oab-mcp/src/main.rs b/crates/oab-mcp/src/main.rs index a9240f0..2efac4f 100644 --- a/crates/oab-mcp/src/main.rs +++ b/crates/oab-mcp/src/main.rs @@ -437,6 +437,7 @@ impl OabMcp { json!({ "name": b.name, "cluster": b.cluster, + "members": b.members, "region": b.region, "profile": b.profile, "expected_principal": b.expected_principal, diff --git a/crates/studio-cp/src/lib.rs b/crates/studio-cp/src/lib.rs index 975d13e..135512f 100644 --- a/crates/studio-cp/src/lib.rs +++ b/crates/studio-cp/src/lib.rs @@ -260,15 +260,26 @@ pub async fn observe_identity( // (observed membership/lease state); the two may fold together later. Selecting // a binding is credential *selection*, not per-caller authz. -/// A declarative binding of a managed fleet/cluster to the credential that -/// should manage it. Profile-first (assume-role is later work). +/// A declarative binding of a managed fleet to the credential that should manage +/// it, plus the fleet's members. Profile-first (assume-role is later work). +/// +/// A fleet groups agents by *usage*, decoupled from the physical cluster: two +/// fleets may share a `cluster` (and one credential) while listing different +/// `members`. `members` empty ⇒ the fleet covers the whole cluster (back-compat +/// with the old cluster-granular binding). #[derive(Debug, Clone, serde::Deserialize)] pub struct FleetBinding { - /// Fleet label (display / selection). + /// Fleet name — its identity (the `[fleet.]` key, or the `name` field + /// of a legacy `[[fleet]]` entry). #[serde(default)] pub name: String, - /// ECS cluster this binding governs — the match key against a call's target. + /// ECS cluster this fleet's members live in (drives the managing credential; + /// credential resolution stays cluster/account-granular). pub cluster: String, + /// Service names in this fleet (e.g. `oab-prod-orca`). Empty ⇒ the whole + /// cluster (legacy behavior). + #[serde(default)] + pub members: Vec, /// Region to pin for this fleet. #[serde(default)] pub region: Option, @@ -281,18 +292,84 @@ pub struct FleetBinding { pub expected_principal: Option, } -/// Parsed fleet-binding file: a list of `[[fleet]]` tables. +/// The body of a `[fleet.]` table — the fields of a [`FleetBinding`] minus +/// `name`, which is the table key. +#[derive(Debug, Clone, serde::Deserialize)] +struct FleetBody { + cluster: String, + #[serde(default)] + members: Vec, + #[serde(default)] + region: Option, + #[serde(default)] + profile: Option, + #[serde(default)] + expected_principal: Option, +} + +/// Accepts both the current `[fleet.]` map form and the legacy `[[fleet]]` +/// array form, so an existing config keeps parsing unchanged. +#[derive(serde::Deserialize)] +#[serde(untagged)] +enum FleetsDoc { + /// `[fleet.]` — name is the table key. + Named { + #[serde(default)] + fleet: std::collections::BTreeMap, + }, + /// `[[fleet]]` — name is a field (legacy). + Array { + #[serde(default)] + fleet: Vec, + }, +} + +impl From for FleetBindings { + fn from(doc: FleetsDoc) -> Self { + let fleets = match doc { + FleetsDoc::Named { fleet } => fleet + .into_iter() + .map(|(name, b)| FleetBinding { + name, + cluster: b.cluster, + members: b.members, + region: b.region, + profile: b.profile, + expected_principal: b.expected_principal, + }) + .collect(), + FleetsDoc::Array { fleet } => fleet, + }; + FleetBindings { fleets } + } +} + +/// Parsed fleet-binding file, canonicalized to a list. Deserializes from either +/// `[fleet.]` (current) or `[[fleet]]` (legacy). #[derive(Debug, Clone, Default, serde::Deserialize)] +#[serde(from = "FleetsDoc")] pub struct FleetBindings { - #[serde(default, rename = "fleet")] pub fleets: Vec, } impl FleetBindings { - /// The binding governing `cluster`, if any (first match wins). + /// The fleet governing `cluster`, if any (first match) — used for credential + /// resolution, which stays cluster/account-granular. pub fn for_cluster(&self, cluster: &str) -> Option<&FleetBinding> { self.fleets.iter().find(|b| b.cluster == cluster) } + + /// The fleet whose explicit `members` contain `service`, if any. + pub fn fleet_for_service(&self, service: &str) -> Option<&FleetBinding> { + self.fleets + .iter() + .find(|b| b.members.iter().any(|m| m == service)) + } + + /// A fleet by name. + pub fn get(&self, name: &str) -> Option<&FleetBinding> { + self.fleets.iter().find(|b| b.name == name) + } } /// Default fleet-binding config path: `$OAB_FLEETS_CONFIG`, else @@ -637,6 +714,52 @@ profile = "appier-sg" assert_eq!(prod.profile.as_deref(), Some("orca-prod")); assert_eq!(prod.region.as_deref(), Some("ap-east-2")); assert!(b.for_cluster("nope").is_none()); + // legacy entries have no explicit members ⇒ whole-cluster fleet + assert!(prod.members.is_empty()); + } + + #[test] + fn named_fleets_parse_with_members_and_share_a_cluster() { + // The new `[fleet.]` form: orca and mira are two fleets on one + // cluster, grouped by explicit members. + let doc = r#" +[fleet.orca] +cluster = "oab" +region = "ap-east-2" +profile = "oab-fleet" +members = ["oab-prod-orca"] + +[fleet.mira] +cluster = "oab" +region = "ap-east-2" +profile = "oab-fleet" +members = ["oab-prod-mira"] +"#; + let b: FleetBindings = toml::from_str(doc).expect("parse named fleets"); + assert_eq!(b.fleets.len(), 2); + let orca = b.get("orca").expect("orca fleet"); + assert_eq!(orca.cluster, "oab"); + assert_eq!(orca.members, vec!["oab-prod-orca".to_string()]); + assert_eq!(orca.profile.as_deref(), Some("oab-fleet")); + // both fleets resolve to the same cluster (shared credential) + assert_eq!(b.get("mira").unwrap().cluster, "oab"); + // membership routing + assert_eq!( + b.fleet_for_service("oab-prod-mira").map(|f| f.name.as_str()), + Some("mira") + ); + assert!(b.fleet_for_service("oab-prod-nope").is_none()); + // credential resolution still finds a governing fleet by cluster + assert!(b.for_cluster("oab").is_some()); + } + + #[test] + fn empty_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]