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) => {