From 62636d3777133db188cdd9f4e5ff8c60c3df9d08 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Thu, 27 Aug 2026 21:05:01 +0800 Subject: [PATCH 1/5] feat(studio-cp,oab-mcp): list_namespaces / list_service_accounts tools (studio#104) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second sub-item of #104 (stacked on #105's list_aws_profiles/list_k8s_contexts, same tools() vec / same design). Both are context-scoped k8s discovery: - list_namespaces(context?): Api::list() for the given/current kubeconfig context. Backs the New Fleet wizard's namespace . Per the design, any failure here (including an RBAC-denied list, which is common against a restricted-scope cluster identity) should read to the caller as "leave it unset" (the namespace's default service account applies), not as an error to surface — so unlike list_aws_profiles/list_k8s_contexts this one doesn't split exists/error, it just errors normally. Same k8s_client_for() helper factors out the from_kubeconfig+Client::try_from boilerplate observe_k8s_identity already had inlined. Ref: studio#104. --- crates/oab-mcp/src/lib.rs | 50 +++++++++++++++++++++++++++++++++- crates/studio-cp/src/lib.rs | 54 ++++++++++++++++++++++++++++++++----- 2 files changed, 97 insertions(+), 7 deletions(-) diff --git a/crates/oab-mcp/src/lib.rs b/crates/oab-mcp/src/lib.rs index 3b8d8cd..f05fba5 100644 --- a/crates/oab-mcp/src/lib.rs +++ b/crates/oab-mcp/src/lib.rs @@ -223,6 +223,28 @@ pub fn tools() -> Vec { "properties": {} })), ), + Tool::new( + "list_namespaces", + "List namespaces in the cluster a kubeconfig context resolves to. Read-only; backs the New Fleet wizard's namespace . Any failure here (including an RBAC-denied list) should be treated by the caller as \"leave it unset\" — the namespace's `default` service account applies — not surfaced as an error.", + as_map(json!({ + "type": "object", + "properties": { + "context": { "type": "string", "description": "Kubeconfig context name. Omit to use the kubeconfig's current-context." }, + "namespace": { "type": "string", "description": "k8s namespace to list service accounts in." } + }, + "required": ["namespace"] + })), + ), ] } @@ -337,6 +359,8 @@ impl OabMcp { "fleet_config_write" => self.t_fleet_write(args), "list_aws_profiles" => self.t_list_aws_profiles(args), "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, other => anyhow::bail!("unknown tool {other:?}"), } } @@ -748,6 +772,28 @@ impl OabMcp { })) } + /// Namespace discovery (studio#104): backs the New Fleet wizard's + /// namespace ``. Errors (including RBAC-denied) + /// propagate as a normal tool error — per the design, the caller treats + /// any failure here as "leave it unset", not something to surface. + async fn t_list_service_accounts(&self, args: &Map) -> Result { + let context = args.get("context").and_then(Value::as_str); + let namespace = args + .get("namespace") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("missing required arg: namespace"))?; + let service_accounts = scp::list_service_accounts(context, namespace).await?; + Ok(json!({ "service_accounts": service_accounts })) + } + async fn t_delete(&self, args: &Map) -> Result { let t = self.target(args)?; let cluster = t.cluster.clone(); @@ -830,7 +876,7 @@ mod tests { .iter() .map(|t| t["name"].as_str().expect("tool has a name").to_string()) .collect(); - assert_eq!(names.len(), 13); + assert_eq!(names.len(), 15); for expected in [ "deploy_list", "deploy_get", @@ -845,6 +891,8 @@ mod tests { "fleet_config_write", "list_aws_profiles", "list_k8s_contexts", + "list_namespaces", + "list_service_accounts", ] { 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 bbfd49e..c883e61 100644 --- a/crates/studio-cp/src/lib.rs +++ b/crates/studio-cp/src/lib.rs @@ -295,14 +295,9 @@ pub async fn observe_k8s_identity(context: Option<&str>) -> anyhow::Result String::new(), }; - let options = kube::config::KubeConfigOptions { - context: context.map(str::to_string), - ..Default::default() - }; - let config = kube::Config::from_kubeconfig(&options) + let client = k8s_client_for(context) .await .map_err(|e| anyhow::anyhow!("failed to resolve kubeconfig context '{context_name}': {e}"))?; - let client = kube::Client::try_from(config).map_err(|e| anyhow::anyhow!("failed to build k8s client: {e}"))?; let api: Api = Api::all(client); let review = api @@ -519,6 +514,53 @@ pub fn list_k8s_contexts() -> K8sContextsResult { } } +async fn k8s_client_for(context: Option<&str>) -> anyhow::Result { + let options = kube::config::KubeConfigOptions { + context: context.map(str::to_string), + ..Default::default() + }; + let config = kube::Config::from_kubeconfig(&options) + .await + .map_err(|e| anyhow::anyhow!("failed to resolve kubeconfig context: {e}"))?; + kube::Client::try_from(config).map_err(|e| anyhow::anyhow!("failed to build k8s client: {e}")) +} + +/// List namespaces in the cluster the given kubeconfig context (or the +/// ambient current-context) resolves to. Backs the New Fleet wizard's +/// namespace `` — the +/// caller falls back to leaving it unset (the namespace's `default` service +/// account applies) on any error here, including an RBAC-denied `list`, so +/// this deliberately doesn't distinguish failure reasons the way +/// `list_aws_profiles`/`list_k8s_contexts` do. +pub async fn list_service_accounts(context: Option<&str>, namespace: &str) -> anyhow::Result> { + use k8s_openapi::api::core::v1::ServiceAccount; + use kube::api::{Api, ListParams}; + + let client = k8s_client_for(context).await?; + let api: Api = Api::namespaced(client, namespace); + let list = api + .list(&ListParams::default()) + .await + .map_err(|e| anyhow::anyhow!("failed to list service accounts: {e}"))?; + Ok(list.items.into_iter().filter_map(|sa| sa.metadata.name).collect()) +} + // ---- Fleet → managing-credential binding (ADR: Per-Fleet managing identity) -- // // The *declarative* side of the loop: which credential should manage which From 3c1693d7570d7c5993c4058e7c8f265a7d30290a Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Thu, 27 Aug 2026 21:15:24 +0800 Subject: [PATCH 2/5] 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")); } From 4ab5edd959b05f132ca764c41938b7efb950476a Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Thu, 27 Aug 2026 21:33:40 +0800 Subject: [PATCH 3/5] feat(console): provider picker + k8s context/namespace fields in New Fleet wizard (studio#104) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth sub-item of #104, stacked on #107. Adds the console-side UI half of the k8s onboarding design — the "+ New fleet" identity form (previously AWS-only: Region/Credential profile/Principal) now starts with a Provider select (AWS / Kubernetes), toggling between the existing AWS field group and a new k8s group: - Context: , since a brand-new namespace is a valid choice per #104's design and a select can't express "not in this list yet". - Service account (optional): plain text input for now (list_service_accounts wiring is a smaller follow-up; per #104's design any failure there should silently fall back to the namespace's default SA anyway, so a live - - +
+ + + +
+
diff --git a/console/src/deploy.ts b/console/src/deploy.ts index 0e181bf..e190f3b 100644 --- a/console/src/deploy.ts +++ b/console/src/deploy.ts @@ -37,6 +37,16 @@ function fillOptions(sel: HTMLSelectElement, names: string[], keepNoneFirst: boo sel.innerHTML = opts.join(""); } +// list_k8s_contexts / list_namespaces response shapes (oab-mcp, studio#104) — +// kept minimal (just what this panel reads), not the tools' full contract. +interface K8sContextsResponse { + contexts: { name: string }[]; + current_context: string | null; +} +interface K8sNamespacesResponse { + namespaces: string[]; +} + export type DeployMode = { kind: "new-fleet" } | { kind: "add-instance"; fleetName: string }; // What the panel reports back once a deploy + fleets.toml write both succeed — @@ -75,9 +85,18 @@ export function initDeployPanel(deps: DeployPanelDeps): DeployPanelHandle | null const cancelBtn = document.getElementById("deploy-cancel") as HTMLButtonElement | null; const identityForm = document.getElementById("deploy-identity-form") as HTMLFormElement | null; const nameInput = document.getElementById("deploy-fleet-name") as HTMLInputElement | null; + const providerSel = document.getElementById("deploy-provider") as HTMLSelectElement | null; + const awsFieldsEl = document.getElementById("deploy-aws-fields"); const regionInput = document.getElementById("deploy-region") as HTMLInputElement | null; const profileInput = document.getElementById("deploy-profile") as HTMLInputElement | null; const principalInput = document.getElementById("deploy-principal") as HTMLInputElement | null; + const k8sFieldsEl = document.getElementById("deploy-k8s-fields"); + const k8sContextSel = document.getElementById("deploy-k8s-context") as HTMLSelectElement | null; + // Namespace is a text input with a of what already exists (not a + // back to its + // `selected` default ("aws"), but doesn't touch the field-group `hidden` + // attributes this panel manages by hand — sync those too. + showProviderFields(providerSel.value); + }; + + // Toggle the AWS/k8s field groups per studio#104's design: switching + // providers resets which group is visible; field *values* aren't cleared + // here (identityForm.reset() already did that on open/close) since the + // two groups have no overlapping semantics to accidentally carry over. + const showProviderFields = (provider: string): void => { + awsFieldsEl.hidden = provider !== "aws"; + k8sFieldsEl.hidden = provider !== "k8s"; + }; + + const loadK8sNamespaces = async (): Promise => { + const invoke = tauriInvoke(); + if (!invoke) return; + const context = k8sContextSel.value || undefined; + try { + const res = await invoke( + "list_namespaces", + context ? { context } : {}, + ); + k8sNamespaceOptions.innerHTML = res.namespaces + .map((n) => ``) + .join(""); + } catch (e) { + // Non-fatal: the namespace field is a free-text input either way (list_ + // namespaces failing just means no autocomplete suggestions). + setStatus(identityStatusEl, `namespace list unavailable: ${errText(e)}`, "err"); + } }; + const loadK8sContexts = async (): Promise => { + const invoke = tauriInvoke(); + if (!invoke) return; + try { + const res = await invoke("list_k8s_contexts"); + const opts = ['']; + for (const c of res.contexts) { + const label = c.name === res.current_context ? `${c.name} (current)` : c.name; + opts.push(``); + } + k8sContextSel.innerHTML = opts.join(""); + } catch (e) { + setStatus(identityStatusEl, `k8s context list unavailable: ${errText(e)}`, "err"); + } + void loadK8sNamespaces(); + }; + + providerSel.addEventListener("change", () => { + showProviderFields(providerSel.value); + if (providerSel.value === "k8s") void loadK8sContexts(); + }); + k8sContextSel.addEventListener("change", () => void loadK8sNamespaces()); + const loadLibraryAndPickers = async (): Promise => { const invoke = tauriInvoke(); if (!invoke) { @@ -184,6 +264,18 @@ export function initDeployPanel(deps: DeployPanelDeps): DeployPanelHandle | null setStatus(identityStatusEl, "fleet name is required", "err"); return; } + // k8s provisioning isn't wired end-to-end yet (deploy_provision has no + // k8s dispatch — see openabdev/studio#104's discussion of why this isn't + // just "swap the driver"). Block here rather than let the wizard proceed + // into a Compose step that would fail at the final deploy_provision call. + if (providerSel.value === "k8s") { + setStatus( + identityStatusEl, + "Kubernetes provisioning isn't available yet — tracked in openabdev/studio#104", + "err", + ); + return; + } identityForm.hidden = true; composeSection.hidden = false; if (composeHeading) composeHeading.textContent = "Step 2 — first instance"; From 83f9127c752a7eec65bdb61f20fe841d520cbcfb Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Thu, 27 Aug 2026 21:42:14 +0800 Subject: [PATCH 4/5] feat(console): wire list_service_accounts into New Fleet's k8s field group (studio#104) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fifth sub-item of #104, stacked on #108. Service account (optional) becomes a + ) a plain select with no free-text escape hatch is the right shape here, matching context's treatment. Reload triggers: context change (cascades into namespace + service-account reload) and namespace field's "change" event (fires on blur/commit, not per keystroke — avoids a tool call per character typed). Per #104's design this tool's failures are deliberately silent — unlike list_k8s_contexts/list_namespaces (which show a status message on failure), any error here, including an RBAC-denied list (common against a scoped-down cluster identity), just falls back to the "namespace default" option with no status shown. The field is optional and the whole point of default-SA fallback is that it's fine not to have a definitive answer here. Verified locally: npm run typecheck clean, npm test 102/102, npm run build succeeds. Ref: studio#104. --- console/index.html | 4 +++- console/src/deploy.ts | 45 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/console/index.html b/console/index.html index ca357aa..6329d00 100644 --- a/console/index.html +++ b/console/index.html @@ -124,7 +124,9 @@
diff --git a/console/src/deploy.ts b/console/src/deploy.ts index e190f3b..5f3dc0d 100644 --- a/console/src/deploy.ts +++ b/console/src/deploy.ts @@ -46,6 +46,9 @@ interface K8sContextsResponse { interface K8sNamespacesResponse { namespaces: string[]; } +interface K8sServiceAccountsResponse { + service_accounts: string[]; +} export type DeployMode = { kind: "new-fleet" } | { kind: "add-instance"; fleetName: string }; @@ -97,6 +100,10 @@ export function initDeployPanel(deps: DeployPanelDeps): DeployPanelHandle | null // choice and a plain select can't express "not in this list yet". const k8sNamespaceInput = document.getElementById("deploy-k8s-namespace") as HTMLInputElement | null; const k8sNamespaceOptions = document.getElementById("deploy-k8s-namespace-options") as HTMLDataListElement | null; + // Service account, unlike namespace, must already exist for k8s to accept + // it as a pod's serviceAccountName — so (unlike namespace) a plain