diff --git a/crates/oab-mcp/src/lib.rs b/crates/oab-mcp/src/lib.rs index 48503da..0d34b5d 100644 --- a/crates/oab-mcp/src/lib.rs +++ b/crates/oab-mcp/src/lib.rs @@ -165,6 +165,28 @@ pub fn tools() -> Vec { "required": ["library", "template", "name"] })), ), + Tool::new( + "deploy_provision_agent", + "Provision an agent directly from structured inputs — no compose library, no template ⊕ overlay (studio#128: the New Fleet wizard's vendor/chat-platform/ACP flow has no template to point at). config.toml is rendered server-side from these fields (the single source of truth — any caller, wizard or otherwise, that sends the same fields gets byte-identical config.toml, no drift between generators). Same create-vs-redeploy behavior as deploy_provision: patches an existing stored manifest's image/bundle if this agent already has one, otherwise builds a fresh manifest with sensible defaults. `provider` (default \"aws\") selects the target, same as deploy_provision. k8s deploys refuse a non-empty `chat_platform`: config.toml's secret resolution only supports aws-sm:// (AWS Secrets Manager), which a k8s pod has no credential chain to reach — ACP is the k8s connection path today.", + as_map(json!({ + "type": "object", + "properties": { + "image": { "type": "string", "description": "Container image (e.g. ghcr.io/openabdev/openab:-)." }, + "name": { "type": "string", "description": "Agent / service name (service = oab-{namespace}-{name})." }, + "namespace": { "type": "string", "description": "Namespace (default \"default\")." }, + "api_key": { "type": "string", "description": "Optional vendor API key. Captured and stored as a secret; not yet wired into config.toml (which env var a given vendor's CLI expects it under needs vendor-specific follow-up)." }, + "chat_platform": { "type": "string", "description": "Optional: \"discord\" | \"telegram\" | \"line\". Omit for no chat platform (connect via ACP directly). AWS only — refused for k8s deploys." }, + "chat_bot_token": { "type": "string", "description": "Discord/Telegram bot token, or LINE's channel access token." }, + "chat_channel_secret": { "type": "string", "description": "LINE only." }, + "provider": { "type": "string", "description": "\"aws\" (default) or \"k8s\" — which driver applies the result." }, + "fleet": { "type": "string", "description": "AWS only. 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": "AWS only. ECS cluster (defaults to the server's configured cluster)." }, + "context": { "type": "string", "description": "k8s only. Kubeconfig context to apply through. Omit to use the kubeconfig's current-context." }, + "expected_principal": { "type": "string", "description": "k8s only, optional. `system:serviceaccount::` to set the pod's service account; unset uses the namespace's default." } + }, + "required": ["image", "name"] + })), + ), Tool::new( "deploy_delete", "Delete a control-plane resource (e.g. an OABService).", @@ -399,6 +421,7 @@ impl OabMcp { "deploy_events" => self.t_events(args).await, "deploy_apply" => self.t_apply(args).await, "deploy_provision" => self.t_provision(args).await, + "deploy_provision_agent" => self.t_provision_agent(args).await, "deploy_scale" => self.t_scale(args).await, "deploy_delete" => self.t_delete(args).await, "runtime_context" => self.t_runtime_context(args).await, @@ -673,6 +696,87 @@ impl OabMcp { })) } + /// [`t_provision`], but for `deploy_provision_agent` (studio#128) — no + /// `library`/`template`/`overlay` args, `config_toml` is used as-is. + async fn t_provision_agent(&self, args: &Map) -> Result { + let namespace = args + .get("namespace") + .and_then(Value::as_str) + .unwrap_or("default"); + let name = args + .get("name") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("missing required arg: name"))?; + let image = args + .get("image") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("missing required arg: image"))?; + let input = scp::AgentWizardInput { + api_key: args.get("api_key").and_then(Value::as_str).map(str::to_string), + chat_platform: args.get("chat_platform").and_then(Value::as_str).map(str::to_string), + chat_bot_token: args.get("chat_bot_token").and_then(Value::as_str).map(str::to_string), + chat_channel_secret: args + .get("chat_channel_secret") + .and_then(Value::as_str) + .map(str::to_string), + }; + + if args.get("provider").and_then(Value::as_str) == Some("k8s") { + let context = args.get("context").and_then(Value::as_str); + let expected_principal = args.get("expected_principal").and_then(Value::as_str); + let outcome = scp::provision_agent_k8s( + &self.aws, + context, + namespace, + name, + image, + input, + expected_principal, + ) + .await?; + return Ok(json!({ + "ok": true, + "context": context, + "namespace": namespace, + "name": name, + "image": outcome.image, + "digest": outcome.digest, + "objects": outcome.objects, + "action": outcome.action, + "services_applied": outcome.services_applied, + })); + } + + let t = self.target(args)?; + let cluster = t.cluster.clone(); + + 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"); + } + + let outcome = scp::provision_agent( + &self.aws_for(&cluster).await, + &cluster, + namespace, + name, + image, + input, + ) + .await?; + Ok(json!({ + "ok": true, + "cluster": cluster, + "namespace": namespace, + "name": name, + "image": outcome.image, + "digest": outcome.digest, + "objects": outcome.objects, + "action": outcome.action, + "services_applied": outcome.services_applied, + })) + } + async fn t_apply(&self, args: &Map) -> Result { let cluster = self.target(args)?.cluster; let manifest = args @@ -1005,7 +1109,7 @@ mod tests { .iter() .map(|t| t["name"].as_str().expect("tool has a name").to_string()) .collect(); - assert_eq!(names.len(), 17); + assert_eq!(names.len(), 18); for expected in [ "deploy_list", "deploy_get", @@ -1013,6 +1117,7 @@ mod tests { "deploy_events", "deploy_apply", "deploy_provision", + "deploy_provision_agent", "deploy_scale", "deploy_delete", "runtime_context", diff --git a/crates/studio-cp/src/lib.rs b/crates/studio-cp/src/lib.rs index aef6aaa..030c388 100644 --- a/crates/studio-cp/src/lib.rs +++ b/crates/studio-cp/src/lib.rs @@ -1230,6 +1230,337 @@ pub async fn provision_from_library( }) } +/// New Fleet wizard inputs (studio#128) that determine the generated +/// config.toml + which secrets get provisioned alongside it. Deliberately +/// its own UI-facing shape, not the manifest/Bundle types — those are the +/// on-disk/API contract, this is what the wizard actually collects. +/// +/// `api_key` is captured and stored as a secret, but **not yet wired into +/// config.toml** — which env var name a given vendor's CLI actually expects +/// its API key under (`ANTHROPIC_API_KEY`? something else?) needs +/// vendor-specific research this round didn't do (matches the "no generic +/// auth-flow abstraction exists yet" scoping already agreed for vendor auth +/// v1). Fabricating a config key nothing reads would be silently worse than +/// not wiring it at all, so: captured, not yet consumed. Follow-up. +#[derive(Debug, Clone, Default)] +pub struct AgentWizardInput { + pub api_key: Option, + /// "discord" | "telegram" | "line"; `None` (or any other value) means no + /// chat platform — ACP is the connection path (Brett: "we can use acp + /// to do connection directly"). + pub chat_platform: Option, + /// Discord/Telegram bot token, or LINE's channel access token. + pub chat_bot_token: Option, + /// LINE only. + pub chat_channel_secret: Option, +} + +/// Stores [`AgentWizardInput`]'s secret-bearing fields in the same +/// `oab/{namespace}/{name}` Secrets Manager convention `oabctl create`'s +/// CLI wizard already uses for the Discord bot token (studio#128) — reuses +/// `oabctl::create::store_secret`, a single fresh JSON blob for the same +/// "first deploy, nothing else has written here yet" reason +/// `provision_acp_auth_secret` (studio#119) documents. A **separate** +/// secret from the ACP auth key: that one is a container-level env var the +/// gateway reads directly, this one feeds config.toml's `[secrets.refs]` → +/// `${secrets.x}` substitution, openab's own resolution mechanism — two +/// different delivery paths, kept as two different secrets rather than +/// conflating them into one. +async fn provision_agent_secrets( + aws_config: &aws_config::SdkConfig, + namespace: &str, + name: &str, + input: &AgentWizardInput, +) -> anyhow::Result<()> { + let mut obj = serde_json::Map::new(); + if let Some(key) = &input.api_key { + obj.insert("VENDOR_API_KEY".to_string(), serde_json::Value::String(key.clone())); + } + match input.chat_platform.as_deref() { + Some("discord") => { + if let Some(t) = &input.chat_bot_token { + obj.insert("DISCORD_BOT_TOKEN".to_string(), serde_json::Value::String(t.clone())); + } + } + Some("telegram") => { + if let Some(t) = &input.chat_bot_token { + obj.insert("TELEGRAM_BOT_TOKEN".to_string(), serde_json::Value::String(t.clone())); + } + } + Some("line") => { + if let Some(t) = &input.chat_bot_token { + obj.insert( + "LINE_CHANNEL_ACCESS_TOKEN".to_string(), + serde_json::Value::String(t.clone()), + ); + } + if let Some(s) = &input.chat_channel_secret { + obj.insert("LINE_CHANNEL_SECRET".to_string(), serde_json::Value::String(s.clone())); + } + } + _ => {} + } + if obj.is_empty() { + return Ok(()); + } + let sm = aws_sdk_secretsmanager::Client::new(aws_config); + let secret_name = format!("oab/{namespace}/{name}"); + oabctl::create::store_secret(&sm, &secret_name, &serde_json::Value::Object(obj).to_string()).await?; + Ok(()) +} + +/// Renders config.toml for a wizard-composed agent (studio#128) — the +/// structured-input counterpart of `oabctl create`'s `generate_config`, +/// generalized from Discord-only to any of the wizard's chat platforms (or +/// none — ACP-only). Doesn't touch ACP: that's `Spec.acp_enabled`, a +/// container-level env var, not a config.toml key (studio#119 confirmed +/// openab-gateway reads `OPENAB_ACP_ENABLED` from process env only). +/// +/// Single source of truth by construction, not just convention: this is the +/// *only* place that ever renders this text — the console wizard and any +/// other MCP caller (an "admin agent") both go through `deploy_provision_agent`, +/// which calls this, so the same structured input always produces the same +/// file regardless of who's driving. +fn generate_agent_config(namespace: &str, name: &str, input: &AgentWizardInput) -> String { + let secret_ref = |key: &str| format!("aws-sm://oab/{namespace}/{name}#{key}"); + let mut secrets_refs = String::new(); + let mut platform_section = String::new(); + + match input.chat_platform.as_deref() { + Some("discord") => { + secrets_refs.push_str(&format!( + "discord_bot_token = \"{}\"\n", + secret_ref("DISCORD_BOT_TOKEN") + )); + platform_section = r#" +[discord] +bot_token = "${secrets.discord_bot_token}" +allow_all_channels = true +allow_all_users = true +allowed_channels = [] +allowed_users = [] +allow_bot_messages = "mentions" +max_bot_turns = 1000 +message_processing_mode = "per-thread" +"# + .to_string(); + } + Some("telegram") => { + secrets_refs.push_str(&format!( + "telegram_bot_token = \"{}\"\n", + secret_ref("TELEGRAM_BOT_TOKEN") + )); + platform_section = r#" +[telegram] +bot_token = "${secrets.telegram_bot_token}" +"# + .to_string(); + } + Some("line") => { + secrets_refs.push_str(&format!( + "line_channel_access_token = \"{}\"\n", + secret_ref("LINE_CHANNEL_ACCESS_TOKEN") + )); + secrets_refs.push_str(&format!( + "line_channel_secret = \"{}\"\n", + secret_ref("LINE_CHANNEL_SECRET") + )); + platform_section = r#" +[line] +channel_access_token = "${secrets.line_channel_access_token}" +channel_secret = "${secrets.line_channel_secret}" +"# + .to_string(); + } + _ => {} + } + + let secrets_block = if secrets_refs.is_empty() { + String::new() + } else { + format!("[secrets.refs]\n{secrets_refs}\n") + }; + + format!( + r#"{secrets_block}{platform_section} +[agent] +inherit_env = ["AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", "AWS_DEFAULT_REGION", "AWS_EXECUTION_ENV", "AWS_REGION"] + +[pool] +max_sessions = 5 +session_ttl_hours = 1 + +[reactions] +enabled = true +remove_after_reply = false + +[cron] +usercron_enabled = true +usercron_path = "cronjob.toml" +"# + ) +} + +/// [`provision_from_library`], but for the studio#128 wizard's direct path — +/// no compose library, no `template ⊕ overlay`: builds config.toml itself +/// from [`AgentWizardInput`] (via [`generate_agent_config`]) instead of +/// composing `template ⊕ overlay`. Deliberately a near-duplicate of +/// `provision_from_library` (from "resolve the bucket" onward) rather than +/// a shared refactor of that already-landed function — same tradeoff +/// `provision_from_library_k8s`'s own doc comment already made for the same +/// reason. +pub async fn provision_agent( + aws_config: &aws_config::SdkConfig, + cluster: &str, + namespace: &str, + name: &str, + image: &str, + input: AgentWizardInput, +) -> anyhow::Result { + provision_agent_secrets(aws_config, namespace, name, &input).await?; + let config_toml = generate_agent_config(namespace, name, &input).into_bytes(); + let mut bundle = studio_compose::Bundle { + image_tag: image.to_string(), + files: std::collections::BTreeMap::from([("config.toml".to_string(), config_toml)]), + }; + + let bucket = oabctl::resolve_bucket(aws_config, None).await?; + let zip_uri = oabctl::studio_api::bundle_zip_uri(&bucket, namespace, name); + match bundle.files.get_mut("config.toml") { + Some(bytes) => *bytes = oabctl::studio_api::inject_pre_seed_hook(bytes, &zip_uri)?, + None => unreachable!("just inserted above"), + } + + let mut objects = bundle.artifact_objects(namespace, name); + let zip_key = format!( + "{}/{}", + studio_compose::artifacts_prefix(namespace, name), + oabctl::studio_api::BUNDLE_ZIP_FILENAME + ); + objects.push((zip_key, bundle.zip_bytes())); + + let digest = bundle.digest(); + + let existing_manifest = + oabctl::studio_api::load_manifest(aws_config, namespace, name, Some(&bucket)).await?; + let report = match existing_manifest { + Some(_) => { + oabctl::studio_api::redeploy( + aws_config, + cluster, + namespace, + name, + Some(image), + &objects, + Some(&bucket), + ) + .await? + } + None => { + let mut manifest = build_default_manifest(aws_config, namespace, name, image, &bucket).await?; + manifest.spec.bundle_from = Some(oabctl::studio_api::bundle_from_uri(&bucket, namespace, name)); + oabctl::studio_api::provision_manifest(aws_config, cluster, &manifest, &objects, Some(&bucket)) + .await? + } + }; + + Ok(ProvisionOutcome { + image: image.to_string(), + digest, + objects: objects.len(), + services_applied: report.services.len(), + action: report + .services + .first() + .map(|s| format!("{:?}", s.action)) + .unwrap_or_default(), + }) +} + +/// [`provision_agent`], but for a k8s-driven fleet — the studio#128 +/// counterpart of `provision_from_library_k8s`, same relationship +/// `provision_agent` has to `provision_from_library`. +/// +/// **`chat_platform` is refused, not silently dropped.** config.toml's +/// `[secrets.refs]` only understands `aws-sm://` (and `exec://`) — +/// `crates/openab-core/src/secrets.rs`'s own resolver — and a k8s pod +/// (confirmed: `k8s_driver.rs::build_deployment` injects no AWS +/// credentials/region at all, unlike the ECS path's `AWS_REGION` + +/// task-role-via-`AWS_CONTAINER_CREDENTIALS_RELATIVE_URI`) has no AWS +/// credential chain to resolve that URI with at runtime. Wiring a chat +/// platform token through `[secrets.refs]` for a k8s deploy would compose +/// and deploy "successfully" and then fail silently inside the running +/// container — worse than refusing up front. ACP-only k8s deploys are +/// unaffected (that key is delivered as a container-level env var via +/// `spec.secrets`/`k8s-secret://`, a completely different, already-working +/// mechanism — see `provision_acp_auth_k8s_secret`). +pub async fn provision_agent_k8s( + aws_config: &aws_config::SdkConfig, + context: Option<&str>, + namespace: &str, + name: &str, + image: &str, + input: AgentWizardInput, + expected_principal: Option<&str>, +) -> anyhow::Result { + if input.chat_platform.is_some() { + anyhow::bail!( + "chat platform integration isn't available for k8s deploys yet — config.toml's \ + secret resolution needs AWS credentials the pod doesn't have. Use ACP to connect \ + directly, or deploy this agent to ECS instead." + ); + } + provision_agent_secrets(aws_config, namespace, name, &input).await?; + let config_toml = generate_agent_config(namespace, name, &input).into_bytes(); + let mut bundle = studio_compose::Bundle { + image_tag: image.to_string(), + files: std::collections::BTreeMap::from([("config.toml".to_string(), config_toml)]), + }; + + let bucket = oabctl::resolve_bucket(aws_config, None).await?; + let zip_uri = oabctl::studio_api::bundle_zip_uri(&bucket, namespace, name); + match bundle.files.get_mut("config.toml") { + Some(bytes) => *bytes = oabctl::studio_api::inject_pre_seed_hook(bytes, &zip_uri)?, + None => unreachable!("just inserted above"), + } + + let mut objects = bundle.artifact_objects(namespace, name); + let zip_key = format!( + "{}/{}", + studio_compose::artifacts_prefix(namespace, name), + oabctl::studio_api::BUNDLE_ZIP_FILENAME + ); + objects.push((zip_key, bundle.zip_bytes())); + + let digest = bundle.digest(); + + let existing_manifest = + oabctl::studio_api::load_manifest(aws_config, namespace, name, Some(&bucket)).await?; + let mut manifest = match existing_manifest { + Some(mut stored) => { + stored.spec.image = image.to_string(); + stored + } + None => build_default_k8s_manifest(context, namespace, name, image, &bucket, expected_principal).await?, + }; + manifest.spec.bundle_from = Some(oabctl::studio_api::bundle_from_uri(&bucket, namespace, name)); + + let report = + oabctl::studio_api::provision_k8s(aws_config, context, &manifest, &objects, Some(&bucket)).await?; + + Ok(ProvisionOutcome { + image: image.to_string(), + digest, + objects: objects.len(), + services_applied: report.services.len(), + action: report + .services + .first() + .map(|s| format!("{:?}", s.action)) + .unwrap_or_default(), + }) +} + /// Extract the bare service-account name from an `expected_principal` string /// in `system:serviceaccount::` form — the format /// `K8sFleetBinding.expected_principal` holds when the "+ New fleet" wizard's diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b6b379f..62c03ca 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -178,6 +178,71 @@ async fn deploy_provision( } } +/// [`deploy_provision`], but for `deploy_provision_agent` (studio#128) — the +/// New Fleet wizard's direct path. Structured fields, not a pre-rendered +/// `config_toml` — the sidecar renders it server-side (see the MCP tool's +/// own doc comment for why: single source of truth regardless of caller). +#[tauri::command] +async fn deploy_provision_agent( + core: tauri::State<'_, Core>, + image: String, + name: String, + namespace: Option, + api_key: Option, + chat_platform: Option, + chat_bot_token: Option, + chat_channel_secret: Option, + cluster: Option, + provider: Option, + context: Option, + expected_principal: Option, +) -> Result { + let cluster = cluster.unwrap_or_else(default_cluster); + let client = { + let guard = core.0.lock().await; + guard + .as_ref() + .cloned() + .ok_or_else(|| "core not started yet".to_string())? + }; + let mut params = json!({ + "image": image, + "name": name, + "cluster": cluster, + }); + if let Some(ns) = namespace { + params["namespace"] = json!(ns); + } + if let Some(k) = api_key.filter(|s| !s.is_empty()) { + params["api_key"] = json!(k); + } + if let Some(p) = chat_platform.filter(|s| !s.is_empty()) { + params["chat_platform"] = json!(p); + } + if let Some(t) = chat_bot_token.filter(|s| !s.is_empty()) { + params["chat_bot_token"] = json!(t); + } + if let Some(s) = chat_channel_secret.filter(|s| !s.is_empty()) { + params["chat_channel_secret"] = json!(s); + } + if let Some(p) = provider.filter(|s| !s.is_empty()) { + params["provider"] = json!(p); + } + if let Some(c) = context.filter(|s| !s.is_empty()) { + params["context"] = json!(c); + } + if let Some(ep) = expected_principal.filter(|s| !s.is_empty()) { + params["expected_principal"] = json!(ep); + } + match client.call_tool("deploy_provision_agent", params).await { + Ok(v) => Ok(v), + Err(e) => { + client.log("error", &format!("deploy_provision_agent: {e}")); + Err(e) + } + } +} + /// List services (`deploy_list`) then fetch each one's per-instance 6-state /// (`deploy_get`), all over MCP — the two-step the in-process bridge used, /// now over the wire. Console view-model shape is unchanged. @@ -728,6 +793,7 @@ pub fn run() { compose_library_set, compose_preview, deploy_provision, + deploy_provision_agent, deploy_list, runtime_context, fleet_config,