Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions console/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand Down Expand Up @@ -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();
Expand Down
8 changes: 7 additions & 1 deletion console/src/source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
// The remote reverse-MCP connection (Part B): the management endpoint's parsed
// url + live status for the panel. The editor now edits the registry
Expand Down Expand Up @@ -181,12 +185,14 @@ export class TauriSource implements Source {
size: 0 | 1,
namespace: string,
cluster?: string,
fleet?: string,
): Promise<void> {
await this.invoke()<unknown>("deploy_scale", {
name,
size,
namespace,
cluster,
fleet,
});
}
async remoteConfig(): Promise<RemoteConfig> {
Expand Down
29 changes: 22 additions & 7 deletions crates/oab-mcp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,15 +146,15 @@ pub fn tools() -> Vec<Tool> {
),
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"]
})),
Expand Down Expand Up @@ -918,8 +918,6 @@ impl OabMcp {
}

async fn t_scale(&self, args: &Map<String, Value>) -> Result<Value> {
let t = self.target(args)?;
let cluster = t.cluster.clone();
let namespace = args
.get("namespace")
.and_then(Value::as_str)
Expand All @@ -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");
}
Expand Down
16 changes: 16 additions & 0 deletions crates/studio-cp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -569,13 +569,17 @@ 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>,
name: String,
size: i64,
namespace: Option<String>,
cluster: Option<String>,
fleet: Option<String>,
) -> Result<Value, String> {
let cluster = cluster.unwrap_or_else(default_cluster);
let client = {
Expand All @@ -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) => {
Expand Down
Loading