Skip to content
49 changes: 44 additions & 5 deletions crates/oab-mcp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ pub fn tools() -> Vec<Tool> {
),
Tool::new(
"deploy_provision",
"Provision an agent from the compose library: compose template ⊕ overlay into a file bundle, push it to the agent's S3 artifacts prefix, and redeploy the ECS service at the chosen image tag. Reuses the agent's stored manifest for networking/resources/secrets, so the agent must already have been created.",
"Provision an agent from the compose library: compose template ⊕ overlay into a file bundle and push it to the agent's S3 artifacts prefix (bundle carrier — shared regardless of provider). If the agent already has a stored manifest, patches its image/bundle and re-applies (networking/resources/secrets/runtime ride along unchanged). If not, builds a fresh manifest with sensible defaults and creates the agent — this now works for a genuinely brand-new agent, not just a redeploy of one already created via `oabctl create`. `provider` (default \"aws\") selects the target: \"aws\" applies via ECS (`fleet`/`cluster` select the credential); \"k8s\" applies via the given kubeconfig `context` instead, with `expected_principal` optionally naming a service account (`system:serviceaccount:<ns>:<name>` — the bare name becomes the pod's serviceAccountName; unset uses the namespace's default).",
as_map(json!({
"type": "object",
"properties": {
Expand All @@ -156,8 +156,11 @@ pub fn tools() -> Vec<Tool> {
"name": { "type": "string", "description": "Agent / service name (service = oab-{namespace}-{name})." },
"namespace": { "type": "string", "description": "Namespace (default \"default\")." },
"image_tag": { "type": "string", "description": "Image tag override (defaults to the bundle's own image tag)." },
"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)." }
"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:<namespace>:<name>` to set the pod's service account; unset uses the namespace's default." }
},
"required": ["library", "template", "name"]
})),
Expand Down Expand Up @@ -546,8 +549,6 @@ impl OabMcp {
}

async fn t_provision(&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 @@ -569,6 +570,44 @@ impl OabMcp {
)
.map_err(|e| anyhow::anyhow!("invalid library: {e}"))?;

// studio#104: k8s dispatch. `context`/`expected_principal` come as
// direct args — the console's identity form already collects them
// (context/namespace/service-account <select>s, #108/#109), there's
// no existing K8sFleetBinding to resolve them from for a brand-new
// fleet (unlike AWS's `fleet` + `self.target()` — a fleet-scoped k8s
// lookup is future work for redeploys into an *existing* k8s fleet,
// not needed for this dispatch to exist).
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_from_library_k8s(
&self.aws,
context,
namespace,
name,
&library,
template,
overlay,
image,
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();

// Same fleet-scope guard as scale/delete: a fleet handle only provisions
// its own members, so a scoped call can't reach a co-located non-member.
let service_name = format!("oab-{namespace}-{name}");
Expand Down
94 changes: 86 additions & 8 deletions crates/oabctl/src/create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,9 +201,14 @@ async fn store_secret(sm: &SmClient, name: &str, value: &str) -> Result<()> {
}
}

struct VpcInfo { id: String, label: String }

async fn list_vpcs(ec2: &Ec2Client) -> Result<Vec<VpcInfo>> {
/// A VPC discovered in the account/region, with a human-readable label
/// (id + Name tag + CIDR + default marker) for interactive selection.
/// `pub` (studio#111): reused by the provision-from-scratch path to pick a
/// sensible default VPC without prompting, same discovery `oabctl create`'s
/// wizard uses interactively.
pub struct VpcInfo { pub id: String, pub label: String, pub is_default: bool }

pub async fn list_vpcs(ec2: &Ec2Client) -> Result<Vec<VpcInfo>> {
let resp = ec2.describe_vpcs().send().await?;
Ok(resp.vpcs().iter().map(|v| {
let id = v.vpc_id().unwrap_or_default().to_string();
Expand All @@ -214,13 +219,37 @@ async fn list_vpcs(ec2: &Ec2Client) -> Result<Vec<VpcInfo>> {
.and_then(|t| t.value())
.unwrap_or("unnamed");
let label = format!("{id} ({name}, {cidr}{})", if is_default { ", default" } else { "" });
VpcInfo { id, label }
VpcInfo { id, label, is_default }
}).collect())
}

struct SubnetInfo { id: String, az: String, kind: String, has_nat: bool }
/// Pick a VPC with **no interactive prompt**, for the provision-from-scratch
/// path (studio#111): the account/region's default VPC, if there is exactly
/// one. Unlike subnet/security-group, `run()`'s wizard has no default here
/// (it always asks) — a "default VPC" is the closest zero-prompt equivalent,
/// but it's genuinely ambiguous when there's none or more than one, so this
/// errors rather than guessing at that point (a caller that has an explicit
/// VPC choice — e.g. from per-fleet config — should skip this and pass it
/// directly to `select_subnets`/`default_security_group` instead).
pub async fn default_vpc(ec2: &Ec2Client) -> Result<VpcInfo> {
let vpcs = list_vpcs(ec2).await?;
let defaults: Vec<VpcInfo> = vpcs.into_iter().filter(|v| v.is_default).collect();
match defaults.len() {
1 => Ok(defaults.into_iter().next().unwrap()),
0 => anyhow::bail!("no default VPC in this account/region — configure a VPC explicitly"),
n => anyhow::bail!("{n} VPCs marked default — configure a VPC explicitly, can't pick automatically"),
}
}

/// A subnet candidate, already classified private/public + NAT reachability.
/// `pub` (studio#111): see `VpcInfo`.
pub struct SubnetInfo { pub id: String, pub az: String, pub kind: String, pub has_nat: bool }

async fn select_subnets(ec2: &Ec2Client, vpc_id: &str) -> Result<Vec<SubnetInfo>> {
/// Auto-select up to 3 subnets (one per AZ), preferring private+NAT >
/// private > public — this already has zero interactive prompting, it's the
/// exact "sensible default" the provision-from-scratch path (studio#111)
/// needs, just needed to be reachable outside `create.rs`.
pub async fn select_subnets(ec2: &Ec2Client, vpc_id: &str) -> Result<Vec<SubnetInfo>> {
let subnets_resp = ec2.describe_subnets()
.filters(aws_sdk_ec2::types::Filter::builder().name("vpc-id").values(vpc_id).build())
.send().await?;
Expand Down Expand Up @@ -282,9 +311,10 @@ async fn select_subnets(ec2: &Ec2Client, vpc_id: &str) -> Result<Vec<SubnetInfo>
Ok(selected)
}

struct SgInfo { id: String, name: String }
/// `pub` (studio#111): see `VpcInfo`.
pub struct SgInfo { pub id: String, pub name: String }

async fn list_security_groups(ec2: &Ec2Client, vpc_id: &str) -> Result<Vec<SgInfo>> {
pub async fn list_security_groups(ec2: &Ec2Client, vpc_id: &str) -> Result<Vec<SgInfo>> {
let resp = ec2.describe_security_groups()
.filters(aws_sdk_ec2::types::Filter::builder().name("vpc-id").values(vpc_id).build())
.send().await?;
Expand All @@ -296,6 +326,54 @@ async fn list_security_groups(ec2: &Ec2Client, vpc_id: &str) -> Result<Vec<SgInf
}).collect())
}

/// Pick a security group for a new agent with **no interactive prompt** —
/// the fully-automatic counterpart to `run()`'s "Create new (oab-{name})"
/// default choice (studio#111: the provision-from-scratch path needs a
/// sensible default here, not a prompt). Reuses an existing `oab-{name}`
/// group if one's already there (idempotent — safe to call again for the
/// same agent), otherwise creates it. Unlike `run()`'s interactive flow,
/// this never offers picking a different existing group; that choice stays
/// CLI-only.
pub async fn default_security_group(ec2: &Ec2Client, vpc_id: &str, name: &str) -> Result<String> {
let sg_name = format!("oab-{name}");
let existing = list_security_groups(ec2, vpc_id).await?;
if let Some(sg) = existing.iter().find(|sg| sg.name == sg_name) {
return Ok(sg.id.clone());
}
let resp = ec2.create_security_group()
.group_name(&sg_name)
.description(format!("OAB agent {name}"))
.vpc_id(vpc_id)
.send().await
.context("failed to create security group")?;
Ok(resp.group_id().unwrap_or_default().to_string())
}

/// Subnet + security-group IDs for a new agent's ECS networking, with no
/// interactive prompt. The single entry point studio#111's provision-from-
/// scratch path needs — takes just an `SdkConfig` (not an `Ec2Client`) so
/// callers outside `oabctl` (e.g. `studio-cp`) don't need `aws-sdk-ec2` as a
/// direct dependency just to reach this; ADR-2's "RuntimeDriver is the only
/// layer with vendor terms" principle extends here too — `Ec2Client` stays
/// an `oabctl`-internal detail.
pub struct DefaultNetworking {
pub vpc_id: String,
pub subnets: Vec<String>,
pub security_groups: Vec<String>,
}

pub async fn default_networking(config: &aws_config::SdkConfig, name: &str) -> Result<DefaultNetworking> {
let ec2 = Ec2Client::new(config);
let vpc = default_vpc(&ec2).await?;
let subnets = select_subnets(&ec2, &vpc.id).await?;
let sg = default_security_group(&ec2, &vpc.id, name).await?;
Ok(DefaultNetworking {
vpc_id: vpc.id,
subnets: subnets.into_iter().map(|s| s.id).collect(),
security_groups: vec![sg],
})
}

fn generate_config(_backend: &str, name: &str, namespace: &str, stt_enabled: bool) -> String {
let stt_section = if stt_enabled {
r#"[stt]
Expand Down
2 changes: 1 addition & 1 deletion crates/oabctl/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ mod bootstrap;
mod cli;
mod config;
mod control_plane;
mod create;
pub mod create;
mod delete;
pub mod driver;
pub mod events;
Expand Down
53 changes: 53 additions & 0 deletions crates/oabctl/src/studio_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,59 @@ pub async fn provision(
.context("failed to apply manifest during provision")
}

/// [`provision`], but takes an already-built [`crate::manifest::OABServiceManifest`]
/// instead of YAML text — for a caller (studio-cp's provision-from-scratch
/// path, studio#111) that constructs one directly rather than starting from
/// a stored manifest's YAML. Keeps the YAML serialization an `oabctl`-internal
/// detail rather than making every caller depend on `serde_yaml` themselves.
pub async fn provision_manifest(
config: &aws_config::SdkConfig,
cluster: &str,
manifest: &crate::manifest::OABServiceManifest,
objects: &[(String, Vec<u8>)],
control_plane_bucket: Option<&str>,
) -> Result<crate::apply::ApplyReport> {
let yaml = serde_yaml::to_string(manifest).context("failed to serialize manifest")?;
provision(config, cluster, &yaml, objects, control_plane_bucket).await
}

/// [`provision_manifest`], but applies through [`crate::k8s_driver::K8sDriver`]
/// instead of [`crate::driver::EcsDriver`] (studio#104's k8s `deploy_provision`
/// dispatch, resumed once #111 landed a create-vs-redeploy branch point that
/// doesn't care which driver applies the result).
///
/// Takes **two** separate credential contexts, unlike every other function in
/// this module — this is inherent to how k8s provisioning works here, not
/// something to simplify away: the bundle carrier (`hooks.pre_seed`, studio#97
/// slice 3c) still goes through S3 regardless of runtime, so `aws_config` is
/// still needed for [`push_bundle`]; `context` is the kubeconfig context
/// [`crate::k8s_driver::K8sDriver::apply`] actually applies the Deployment
/// through. A k8s-provisioned agent's bundle upload and its Deployment apply
/// are two different systems with two different credentials — there's no way
/// to collapse this to a single config the way the ECS path's one
/// `aws_config::SdkConfig` covers both S3 and ECS.
pub async fn provision_k8s(
aws_config: &aws_config::SdkConfig,
context: Option<&str>,
manifest: &crate::manifest::OABServiceManifest,
objects: &[(String, Vec<u8>)],
control_plane_bucket: Option<&str>,
) -> Result<crate::apply::ApplyReport> {
// Same ordering as `provision()`: bundle first (idempotent puts), so the
// carrier is ready before the Deployment comes up and pre_seed runs.
push_bundle(aws_config, control_plane_bucket, objects).await?;

let driver = crate::k8s_driver::K8sDriver::from_context(context).await?;
let opts = crate::driver::ProvisionOptions {
control_plane_bucket: control_plane_bucket.map(str::to_string),
wait: false,
};
driver
.apply(std::slice::from_ref(manifest), &opts)
.await
.context("failed to apply k8s manifest during provision")
}

/// Load the desired `OABService` manifest oabctl persists at
/// `manifests/{namespace}/{name}.yaml` in the control-plane bucket. Returns
/// `Ok(None)` when the agent has no stored manifest yet (never applied); other
Expand Down
Loading
Loading