Skip to content
Closed
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
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
Loading