From a02677e2aff4a5f6d7648fbaaf23de514a542ed4 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Fri, 28 Aug 2026 00:26:24 +0800 Subject: [PATCH 1/3] feat(oabctl): expose create.rs's AWS-placement defaults for reuse (studio#111) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First sub-item of #111 ("+ New fleet" first-creation gap). oabctl create's interactive wizard collects VPC/subnet/security-group among other things before it can build a manifest and apply it — but the VPC/subnet/SG parts are already effectively "sensible defaults", not real interactive choices: select_subnets already auto-picks (private+NAT > private > public, up to 3 AZs) with zero prompting, and "Create new (oab-{name})" is the SG wizard's own default suggestion. Makes `list_vpcs`/`VpcInfo`, `select_subnets`/`SubnetInfo`, `list_security_groups`/`SgInfo` pub (was module-private, module itself was `mod create` — now `pub mod create`), and adds one new function, `default_security_group`, extracting the "create new (oab-{name}), reuse if it already exists" logic `run()` has inline into something callable without the interactive prompt path. No behavior change to `run()`/`oabctl create` — pure visibility change plus one new function built from existing, already-working logic (the AWS calls in `default_security_group` are the same calls `run()`'s inline SG-create branch already makes). This is groundwork only — nothing calls these yet. Next: the console-facing provision path needs a VPC choice too (unlike subnet/SG, there's no zero-prompt default for "which VPC" today — `run()` always asks). Then wiring these into a "build a default manifest, apply via deploy_apply's path" function is the actual fix for #111. Local build/test hit the known aws-sdk-ec2 OOM constraint on this machine (same pre-existing environment issue documented in prior PRs' descriptions) — change is a mechanical visibility change + one function built from already-proven inline logic, hand-verified against the diff. CI is the gate. Ref: studio#111. --- crates/oabctl/src/create.rs | 49 +++++++++++++++++++++++++++++++------ crates/oabctl/src/lib.rs | 2 +- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/crates/oabctl/src/create.rs b/crates/oabctl/src/create.rs index 7724128..ac7fff1 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 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(); @@ -218,9 +223,15 @@ async fn list_vpcs(ec2: &Ec2Client) -> Result> { }).collect()) } -struct SubnetInfo { id: String, az: String, kind: String, has_nat: bool } +/// 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 +293,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 +308,29 @@ 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()) +} + 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; From ff4598090839c94da0173da3100d3e6527a07767 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Fri, 28 Aug 2026 00:35:30 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(oabctl):=20add=20default=5Fvpc=20?= =?UTF-8?q?=E2=80=94=20zero-prompt=20VPC=20pick=20when=20the=20account=20h?= =?UTF-8?q?as=20exactly=20one=20default=20(studio#111)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up on this same PR: VpcInfo now carries is_default (was folded into the human-readable label only, not usable programmatically). default_vpc() picks the account/region's default VPC when there's exactly one — the closest zero-prompt equivalent to what select_subnets/default_security_group already give for subnet/SG, since run()'s wizard never had a non-interactive default for VPC choice at all. Deliberately errors (not a heuristic guess) when there's zero or more than one default VPC — a caller with an explicit VPC choice (e.g. future per-fleet config, mirroring how fleets.toml already carries region/profile) should skip this and pass that VPC straight to select_subnets/default_security_group instead. Still groundwork — nothing calls this yet. Ref: studio#111. --- crates/oabctl/src/create.rs | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/oabctl/src/create.rs b/crates/oabctl/src/create.rs index ac7fff1..7ac8a71 100644 --- a/crates/oabctl/src/create.rs +++ b/crates/oabctl/src/create.rs @@ -206,7 +206,7 @@ async fn store_secret(sm: &SmClient, name: &str, value: &str) -> Result<()> { /// `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 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?; @@ -219,10 +219,28 @@ pub 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()) } +/// 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 } From 9f6d7b5f0e4523c5b9aa1b0b1f671c4150f5b4c3 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Fri, 28 Aug 2026 00:42:23 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat(oabctl):=20add=20default=5Fnetworking?= =?UTF-8?q?=20=E2=80=94=20SdkConfig-only=20entry=20point=20for=20VPC/subne?= =?UTF-8?q?t/SG=20defaults=20(studio#111)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up on this same PR. Wraps default_vpc + select_subnets + default_security_group behind one function that takes an aws_config:: SdkConfig, not an Ec2Client — so studio-cp (which doesn't depend on aws-sdk-ec2 directly) can reach it without adding that dependency, keeping Ec2Client an oabctl-internal detail (same "RuntimeDriver is the only layer with vendor terms" boundary ADR-2 already established elsewhere). Still groundwork — nothing calls this yet. Next: build_default_manifest in studio-cp, calling this + spec defaults (resources 256/512, empty secrets, FARGATE/X86_64), then wire provision_from_library to branch create-vs- redeploy based on whether load_manifest finds a stored manifest. Ref: studio#111. --- crates/oabctl/src/create.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/crates/oabctl/src/create.rs b/crates/oabctl/src/create.rs index 7ac8a71..27155dd 100644 --- a/crates/oabctl/src/create.rs +++ b/crates/oabctl/src/create.rs @@ -349,6 +349,31 @@ pub async fn default_security_group(ec2: &Ec2Client, vpc_id: &str, name: &str) - 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]