Skip to content

Commit 871e6ce

Browse files
authored
feat(studio-cp,oab-mcp,src-tauri): add deploy_provision_agent (studio#128) (#129)
* feat(studio-cp,oab-mcp,src-tauri): add deploy_provision_agent (studio#128) New Fleet wizard direction (Brett, this thread): drop the compose-library Template/Overlay model, replace with a vendor + chat-platform + ACP flow that composes config.toml directly. Confirmed earlier (#128 investigation) that provision_manifest/provision_k8s don't require a Bundle produced by compose_named — a hand-built Bundle{image_tag, files} works identically. This PR adds the backend capability only (all 3 layers: MCP tool, Tauri bridge, studio-cp core) — purely additive, doesn't touch the existing deploy_provision/compose-library path at all. Console wiring (replacing the Template/Overlay UI with the new wizard) is a separate follow-up PR. - studio-cp: provision_agent / provision_agent_k8s — near-duplicates of provision_from_library[_k8s] from "resolve the bucket" onward (same create-vs-redeploy branch, same pre_seed hook injection, same bundle upload), except the Bundle's config.toml comes from the caller directly instead of studio_compose::compose_named(library, template, overlay). Deliberate duplication over a shared refactor, matching the tradeoff provision_from_library_k8s's own doc comment already made for the same reason (avoid risking the already-landed functions' shape). - oab-mcp: new deploy_provision_agent tool (config_toml + image + name, same provider/context/expected_principal/fleet/cluster args as deploy_provision minus library/template/overlay), dispatches to the new studio-cp functions. Tool-count test updated (17 -> 18). - src-tauri: deploy_provision_agent bridge command, mirroring deploy_provision's shape, registered in generate_handler!. Ref #128. * refactor(studio-cp,oab-mcp,src-tauri): move config.toml generation server-side Brett's catch: this form's output is ultimately a config.toml for the created agent, and that file can also be produced by an admin agent calling the same tool directly (not through Studio's UI) — for those to stay in sync, the actual TOML-rendering logic can't live in the console (TypeScript), it has to be the single server-side source of truth both callers go through. deploy_provision_agent's config_toml:string param is replaced with structured fields (api_key, chat_platform, chat_bot_token, chat_channel_secret) — studio-cp's new generate_agent_config() renders the actual text (the structured-input counterpart of oabctl create's generate_config, generalized from Discord-only to discord/telegram/line). Any caller sending the same fields — the wizard or a future admin agent — gets byte-identical config.toml by construction, not by convention. provision_agent_secrets() stores the secret-bearing fields in the same oab/{namespace}/{name} Secrets Manager convention oabctl create already uses for the Discord token — a separate secret from #127's ACP auth key, since these feed config.toml's [secrets.refs]/${secrets.x} (openab's own resolution, aws-sm:// only) while the ACP key is a container-level env var injected via spec.secrets, a different delivery path entirely. k8s deploys refuse a non-empty chat_platform rather than silently deploying something broken: config.toml's secret resolution only understands aws-sm://, and k8s_driver.rs's build_deployment injects no AWS credentials into the pod at all (confirmed by reading it) — so a k8s pod has no way to actually resolve that URI at runtime. ACP-only k8s deploys are unaffected (already-working, different mechanism). api_key is captured/stored but not yet wired into config.toml — which env var a given vendor's CLI expects it under needs vendor-specific research this round didn't do. Flagged in the tool schema and struct doc comment rather than fabricating a config key nothing reads. Ref #128.
1 parent 0486ce8 commit 871e6ce

3 files changed

Lines changed: 503 additions & 1 deletion

File tree

‎crates/oab-mcp/src/lib.rs‎

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,28 @@ pub fn tools() -> Vec<Tool> {
165165
"required": ["library", "template", "name"]
166166
})),
167167
),
168+
Tool::new(
169+
"deploy_provision_agent",
170+
"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.",
171+
as_map(json!({
172+
"type": "object",
173+
"properties": {
174+
"image": { "type": "string", "description": "Container image (e.g. ghcr.io/openabdev/openab:<tag>-<vendor>)." },
175+
"name": { "type": "string", "description": "Agent / service name (service = oab-{namespace}-{name})." },
176+
"namespace": { "type": "string", "description": "Namespace (default \"default\")." },
177+
"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)." },
178+
"chat_platform": { "type": "string", "description": "Optional: \"discord\" | \"telegram\" | \"line\". Omit for no chat platform (connect via ACP directly). AWS only — refused for k8s deploys." },
179+
"chat_bot_token": { "type": "string", "description": "Discord/Telegram bot token, or LINE's channel access token." },
180+
"chat_channel_secret": { "type": "string", "description": "LINE only." },
181+
"provider": { "type": "string", "description": "\"aws\" (default) or \"k8s\" — which driver applies the result." },
182+
"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." },
183+
"cluster": { "type": "string", "description": "AWS only. ECS cluster (defaults to the server's configured cluster)." },
184+
"context": { "type": "string", "description": "k8s only. Kubeconfig context to apply through. Omit to use the kubeconfig's current-context." },
185+
"expected_principal": { "type": "string", "description": "k8s only, optional. `system:serviceaccount:<namespace>:<name>` to set the pod's service account; unset uses the namespace's default." }
186+
},
187+
"required": ["image", "name"]
188+
})),
189+
),
168190
Tool::new(
169191
"deploy_delete",
170192
"Delete a control-plane resource (e.g. an OABService).",
@@ -399,6 +421,7 @@ impl OabMcp {
399421
"deploy_events" => self.t_events(args).await,
400422
"deploy_apply" => self.t_apply(args).await,
401423
"deploy_provision" => self.t_provision(args).await,
424+
"deploy_provision_agent" => self.t_provision_agent(args).await,
402425
"deploy_scale" => self.t_scale(args).await,
403426
"deploy_delete" => self.t_delete(args).await,
404427
"runtime_context" => self.t_runtime_context(args).await,
@@ -673,6 +696,87 @@ impl OabMcp {
673696
}))
674697
}
675698

699+
/// [`t_provision`], but for `deploy_provision_agent` (studio#128) — no
700+
/// `library`/`template`/`overlay` args, `config_toml` is used as-is.
701+
async fn t_provision_agent(&self, args: &Map<String, Value>) -> Result<Value> {
702+
let namespace = args
703+
.get("namespace")
704+
.and_then(Value::as_str)
705+
.unwrap_or("default");
706+
let name = args
707+
.get("name")
708+
.and_then(Value::as_str)
709+
.ok_or_else(|| anyhow::anyhow!("missing required arg: name"))?;
710+
let image = args
711+
.get("image")
712+
.and_then(Value::as_str)
713+
.ok_or_else(|| anyhow::anyhow!("missing required arg: image"))?;
714+
let input = scp::AgentWizardInput {
715+
api_key: args.get("api_key").and_then(Value::as_str).map(str::to_string),
716+
chat_platform: args.get("chat_platform").and_then(Value::as_str).map(str::to_string),
717+
chat_bot_token: args.get("chat_bot_token").and_then(Value::as_str).map(str::to_string),
718+
chat_channel_secret: args
719+
.get("chat_channel_secret")
720+
.and_then(Value::as_str)
721+
.map(str::to_string),
722+
};
723+
724+
if args.get("provider").and_then(Value::as_str) == Some("k8s") {
725+
let context = args.get("context").and_then(Value::as_str);
726+
let expected_principal = args.get("expected_principal").and_then(Value::as_str);
727+
let outcome = scp::provision_agent_k8s(
728+
&self.aws,
729+
context,
730+
namespace,
731+
name,
732+
image,
733+
input,
734+
expected_principal,
735+
)
736+
.await?;
737+
return Ok(json!({
738+
"ok": true,
739+
"context": context,
740+
"namespace": namespace,
741+
"name": name,
742+
"image": outcome.image,
743+
"digest": outcome.digest,
744+
"objects": outcome.objects,
745+
"action": outcome.action,
746+
"services_applied": outcome.services_applied,
747+
}));
748+
}
749+
750+
let t = self.target(args)?;
751+
let cluster = t.cluster.clone();
752+
753+
let service_name = format!("oab-{namespace}-{name}");
754+
if !t.includes(&service_name, name) {
755+
anyhow::bail!("service {service_name:?} is not a member of the named fleet");
756+
}
757+
758+
let outcome = scp::provision_agent(
759+
&self.aws_for(&cluster).await,
760+
&cluster,
761+
namespace,
762+
name,
763+
image,
764+
input,
765+
)
766+
.await?;
767+
Ok(json!({
768+
"ok": true,
769+
"cluster": cluster,
770+
"namespace": namespace,
771+
"name": name,
772+
"image": outcome.image,
773+
"digest": outcome.digest,
774+
"objects": outcome.objects,
775+
"action": outcome.action,
776+
"services_applied": outcome.services_applied,
777+
}))
778+
}
779+
676780
async fn t_apply(&self, args: &Map<String, Value>) -> Result<Value> {
677781
let cluster = self.target(args)?.cluster;
678782
let manifest = args
@@ -1005,14 +1109,15 @@ mod tests {
10051109
.iter()
10061110
.map(|t| t["name"].as_str().expect("tool has a name").to_string())
10071111
.collect();
1008-
assert_eq!(names.len(), 17);
1112+
assert_eq!(names.len(), 18);
10091113
for expected in [
10101114
"deploy_list",
10111115
"deploy_get",
10121116
"get_agent_states",
10131117
"deploy_events",
10141118
"deploy_apply",
10151119
"deploy_provision",
1120+
"deploy_provision_agent",
10161121
"deploy_scale",
10171122
"deploy_delete",
10181123
"runtime_context",

0 commit comments

Comments
 (0)