From bd3eb504d2cdeda65808fe07f67716e917672497 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 14 Sep 2026 14:24:59 +0800 Subject: [PATCH] fix(deploy_scale): dispatch to K8sDriver for a k8s-runtime fleet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deploy_scale/t_scale never got a k8s branch when studio#146 made the read path (list/get/runtime_context) k8s-aware — start/stop on a k8s-runtime fleet silently fell through to the ECS path with an empty cluster string, which AWS's ECS API treats as the literal "default" cluster, and with no fleet binding to match it, oab-mcp fell back to the local machine's default AWS credential chain. Reproduced live: stopping the "seaturtle" k8s test agent scaled a same-named ECS service in the wrong AWS account instead. t_scale now checks the named fleet's runtime first (mirroring t_list/t_get/t_runtime_context) and calls the new scp::scale_k8s_deployment (K8sDriver::scale) when it's k8s, before ever touching the ECS cluster arg. The console's start/stop button and the Tauri deploy_scale bridge now thread `fleet` through alongside `cluster`, same as the existing list/runtime_context calls. --- console/src/main.ts | 16 +++++++++++----- console/src/source.ts | 8 +++++++- crates/oab-mcp/src/lib.rs | 29 ++++++++++++++++++++++------- crates/studio-cp/src/lib.rs | 16 ++++++++++++++++ src-tauri/src/lib.rs | 7 +++++++ 5 files changed, 63 insertions(+), 13 deletions(-) diff --git a/console/src/main.ts b/console/src/main.ts index 401339f..5df8a73 100644 --- a/console/src/main.ts +++ b/console/src/main.ts @@ -455,10 +455,12 @@ function selectFleet(name: string): void { activeFleet = name; activeMembers = fleet.members; if (fleet.runtime === "k8s") { - // No ECS cluster for a k8s fleet — reads now go by the `fleet` name - // instead (oab-mcp resolves the bound context/namespace itself, - // studio#146 slices 2-3); `activeCluster` stays a plain "" sentinel, - // never read on this path. + // No ECS cluster for a k8s fleet — reads and scale/start/stop now go by + // the `fleet` name instead (oab-mcp resolves the bound context/namespace + // itself, studio#146 + the deploy_scale k8s dispatch fix). `activeCluster` + // stays a plain "" sentinel: still sent alongside `fleet` on every call + // for back-compat with the ECS path, but every k8s-aware tool checks + // `fleet`'s runtime before ever looking at it. activeCluster = ""; const where = `${fleet.context ?? "current-context"}/${fleet.namespace ?? "default"}`; if (clusterLabel) clusterLabel.textContent = `${activeFleet} · ${where}`; @@ -920,7 +922,11 @@ async function scale( ); repaintRoster(); // disable the button immediately try { - await source.scaleDeployment(name, size, namespace, activeCluster); + // `activeCluster` is the "" sentinel for a k8s-runtime fleet (see + // `selectFleet`) — `fleet` carries the same (context, namespace) + // resolution `listDeployments`/`runtimeContext` already rely on, so + // oab-mcp can dispatch by runtime instead of falling through to ECS. + await source.scaleDeployment(name, size, namespace, activeCluster, activeFleet ?? undefined); note("info", `roster: ${action === "start" ? "started" : "stopped"} ${namespace}/${name}`); // tick() observes the new desiredCount and prunes the guard when it flips. await tick(); diff --git a/console/src/source.ts b/console/src/source.ts index d980511..117defe 100644 --- a/console/src/source.ts +++ b/console/src/source.ts @@ -39,12 +39,16 @@ export interface Source { // Scale a deployment on (size 1) or off (size 0) — the start/stop action. // Reversible: ECS keeps the Spec at desiredCount 0, so no state store is // needed. `namespace` is required (the service is `oab-{namespace}-{name}`); - // the managing credential is resolved per-cluster from `cluster`. + // the managing credential is resolved per-cluster from `cluster`. `fleet` + // (same rule as `listDeployments`/`runtimeContext`) is required to reach a + // k8s-runtime fleet — pass it whenever a fleet is active, alongside + // `cluster`, so oab-mcp can dispatch by runtime without a cluster fallback. scaleDeployment( name: string, size: 0 | 1, namespace: string, cluster?: string, + fleet?: string, ): Promise; // The remote reverse-MCP connection (Part B): the management endpoint's parsed // url + live status for the panel. The editor now edits the registry @@ -181,12 +185,14 @@ export class TauriSource implements Source { size: 0 | 1, namespace: string, cluster?: string, + fleet?: string, ): Promise { await this.invoke()("deploy_scale", { name, size, namespace, cluster, + fleet, }); } async remoteConfig(): Promise { diff --git a/crates/oab-mcp/src/lib.rs b/crates/oab-mcp/src/lib.rs index 1ccc5d0..6469f57 100644 --- a/crates/oab-mcp/src/lib.rs +++ b/crates/oab-mcp/src/lib.rs @@ -146,15 +146,15 @@ pub fn tools() -> Vec { ), Tool::new( "deploy_scale", - "Scale an OAB service on or off. OAB services run a single bot token, so size must be 0 (off) or 1 (on).", + "Scale an OAB service on or off. OAB services run a single bot token, so size must be 0 (off) or 1 (on). Works for both ECS and k8s-runtime fleets (pass `fleet` for k8s — required, since a k8s-runtime fleet has no ECS cluster to fall back to).", as_map(json!({ "type": "object", "properties": { "name": { "type": "string", "description": "Agent / service name (service = oab-{namespace}-{name})." }, "size": { "type": "integer", "enum": [0, 1], "description": "0 = off, 1 = on." }, - "fleet": { "type": "string", "description": "Fleet name (see fleet_config): targets the fleet's cluster and managing credential; a write to a service outside the fleet's members is refused. Overrides the cluster arg." }, - "cluster": { "type": "string", "description": "ECS cluster (defaults to the server's configured cluster)." }, - "namespace": { "type": "string", "description": "Namespace (default \"default\")." } + "fleet": { "type": "string", "description": "Fleet name (see fleet_config): targets the fleet's cluster/context and managing credential; a write to a service outside the fleet's members is refused. Overrides the cluster arg. Required for a k8s-runtime fleet." }, + "cluster": { "type": "string", "description": "ECS cluster (defaults to the server's configured cluster). Ignored for a k8s-runtime fleet." }, + "namespace": { "type": "string", "description": "Namespace (default \"default\"). Ignored for a k8s-runtime fleet — the fleet binding's own namespace is used." } }, "required": ["name", "size"] })), @@ -918,8 +918,6 @@ impl OabMcp { } async fn t_scale(&self, args: &Map) -> Result { - let t = self.target(args)?; - let cluster = t.cluster.clone(); let namespace = args .get("namespace") .and_then(Value::as_str) @@ -932,10 +930,27 @@ impl OabMcp { args.get("size") .and_then(Value::as_i64) .ok_or_else(|| anyhow::anyhow!("missing or invalid arg: size"))? as i32; + let service_name = format!("oab-{namespace}-{name}"); + if let Some(b) = self.named_fleet(args)? { + if b.runtime == scp::FleetRuntime::K8s { + // Guard: a fleet handle only operates its own members — same + // reasoning as the ECS path below. + if !b.includes(&service_name, name) { + anyhow::bail!("service {service_name:?} is not a member of the named fleet"); + } + let namespace = b.namespace.clone().unwrap_or_else(|| namespace.to_string()); + scp::scale_k8s_deployment(b.context.as_deref(), &namespace, name, size).await?; + return Ok(json!({ + "ok": true, "cluster": null, "context": b.context, "namespace": namespace, + "name": name, "size": size, + })); + } + } + let t = self.target(args)?; + let cluster = t.cluster.clone(); // Guard: a fleet handle only operates its own members — refuse to scale a // service outside the named fleet (a no-op for unscoped or whole-cluster // calls). Stops a fleet-scoped call from reaching a co-located non-member. - let service_name = format!("oab-{namespace}-{name}"); if !t.includes(&service_name, name) { anyhow::bail!("service {service_name:?} is not a member of the named fleet"); } diff --git a/crates/studio-cp/src/lib.rs b/crates/studio-cp/src/lib.rs index 88b9f5c..b35d885 100644 --- a/crates/studio-cp/src/lib.rs +++ b/crates/studio-cp/src/lib.rs @@ -2448,6 +2448,22 @@ pub async fn scale_deployment( oabctl::studio_api::scale(aws_config, cluster, namespace, name, size).await } +/// Scale a k8s-runtime OAB service to `size` replicas (0 = off, 1 = on) — the +/// k8s counterpart to [`scale_deployment`]. `deploy_scale`/`t_scale` never +/// had a k8s branch (studio#146 only made the *read* path — list/get/ +/// runtime_context — k8s-aware); this closes that gap so a k8s-runtime +/// fleet's start/stop action reaches `K8sDriver::scale` instead of silently +/// falling through to the ECS path with an empty cluster string. +pub async fn scale_k8s_deployment( + context: Option<&str>, + namespace: &str, + name: &str, + size: i32, +) -> anyhow::Result<()> { + use oabctl::ProvisionDriver; + oabctl::K8sDriver::from_context(context).await?.scale(namespace, name, size).await +} + /// Delete a control-plane resource (e.g. an `OABService`). /// /// The control-plane bucket is resolved from the environment / account, not diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0f47aed..3600262 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -569,6 +569,9 @@ async fn list_service_accounts( /// Spec is kept by ECS, so it's reversible). An OAB service runs a single bot /// token, so size is 0/1 only; `namespace` is required upstream to resolve the /// service (`oab-{namespace}-{name}`) and the managing credential is per-cluster. +/// `fleet` (same rule as `deploy_list`/`runtime_context`) is required to reach a +/// k8s-runtime fleet — without it this silently fell through to the ECS path +/// with an empty cluster string. #[tauri::command] async fn deploy_scale( core: tauri::State<'_, Core>, @@ -576,6 +579,7 @@ async fn deploy_scale( size: i64, namespace: Option, cluster: Option, + fleet: Option, ) -> Result { let cluster = cluster.unwrap_or_else(default_cluster); let client = { @@ -589,6 +593,9 @@ async fn deploy_scale( if let Some(ns) = namespace { params["namespace"] = json!(ns); } + if let Some(f) = fleet { + params["fleet"] = json!(f); + } match client.call_tool("deploy_scale", params).await { Ok(v) => Ok(v), Err(e) => {