diff --git a/crates/oabctl/src/create.rs b/crates/oabctl/src/create.rs index 7724128..27155dd 100644 --- a/crates/oabctl/src/create.rs +++ b/crates/oabctl/src/create.rs @@ -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> { +/// 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> { let resp = ec2.describe_vpcs().send().await?; Ok(resp.vpcs().iter().map(|v| { let id = v.vpc_id().unwrap_or_default().to_string(); @@ -214,13 +219,37 @@ async fn list_vpcs(ec2: &Ec2Client) -> Result> { .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 { + let vpcs = list_vpcs(ec2).await?; + let defaults: Vec = 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> { +/// 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> { let subnets_resp = ec2.describe_subnets() .filters(aws_sdk_ec2::types::Filter::builder().name("vpc-id").values(vpc_id).build()) .send().await?; @@ -282,9 +311,10 @@ async fn select_subnets(ec2: &Ec2Client, vpc_id: &str) -> Result 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> { +pub async fn list_security_groups(ec2: &Ec2Client, vpc_id: &str) -> Result> { let resp = ec2.describe_security_groups() .filters(aws_sdk_ec2::types::Filter::builder().name("vpc-id").values(vpc_id).build()) .send().await?; @@ -296,6 +326,54 @@ async fn list_security_groups(ec2: &Ec2Client, vpc_id: &str) -> Result Result { + 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, + pub security_groups: Vec, +} + +pub async fn default_networking(config: &aws_config::SdkConfig, name: &str) -> Result { + 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] diff --git a/crates/oabctl/src/lib.rs b/crates/oabctl/src/lib.rs index ef552c3..02c3a49 100644 --- a/crates/oabctl/src/lib.rs +++ b/crates/oabctl/src/lib.rs @@ -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;