From 33e63d1bc278e3c48744e8ba0e695f6dc885d2c9 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Fri, 28 Aug 2026 00:26:24 +0800 Subject: [PATCH 1/6] 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 46ad4261561efbd27d2e07fa61bbfc9d01d98848 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Fri, 28 Aug 2026 00:35:30 +0800 Subject: [PATCH 2/6] =?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 36d061f32f7d3ecb91e08fc2d0f125a954202f27 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Fri, 28 Aug 2026 00:42:23 +0800 Subject: [PATCH 3/6] =?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] From 6cdf528a37d465ea011df5dddbc85d79da5cef63 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Fri, 28 Aug 2026 00:48:18 +0800 Subject: [PATCH 4/6] =?UTF-8?q?feat(studio-cp,oabctl):=20build=20a=20defau?= =?UTF-8?q?lt=20manifest=20when=20none=20exists=20yet=20=E2=80=94=20the=20?= =?UTF-8?q?actual=20fix=20for=20#111?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit provision_from_library (backing deploy_provision / the console's "+ New fleet" flow) now checks load_manifest first: if a manifest is already stored, behavior is unchanged (redeploy patches image/bundle_from and re-applies). If none exists, it builds a fresh OABServiceManifest (build_default_manifest) using #112's zero-prompt defaults (VPC/subnet/SG via default_networking, resources 256/512, FARGATE/X86_64, empty secrets — see #111's comment thread for why empty secrets is valid and not a gap this needs to solve) and applies it via provision_manifest, a new oabctl:: studio_api helper that mirrors provision() but takes a structured manifest instead of YAML text (keeps serde_yaml an oabctl-internal detail). This is the actual fix: the console's "+ New fleet" wizard can now create a genuinely new agent end-to-end, not just redeploy an agent someone already created via the CLI. Once merged, k8s's deploy_provision dispatch (#104) lands on this same branch point — load_manifest is provider-agnostic (S3 key, not ECS-specific), so the create-vs-redeploy check doesn't need to change for k8s; only the "build a fresh manifest" + "apply it" halves need a Runtime::Kubernetes(...) branch alongside this Runtime::Ecs(...) one. configFrom for the new manifest points at artifacts/{ns}/{name}/config.toml — the same key Bundle::artifact_objects already uploads a copy of the composed config.toml to, and the same convention oabctl create's wizard uses. Unit-tested (default_config_from_uri_matches_artifact_objects_key). Manually verified every field against crates/oabctl/src/manifest.rs's struct definitions (OABServiceManifest/Metadata/Spec/Resources/Runtime/ EcsRuntime/EcsNetworking) and OABServiceManifest::validate()'s requirements (apiVersion "oab.dev/v2", kind "OABService", CPU "256" is in VALID_ECS_CPU, capacityProvider "FARGATE" is valid) — this is the riskiest change this session (real infra creation), so more care than usual went into checking it by hand given the environment's known aws-sdk-ec2 OOM constraint prevented a local cargo check. CI is the gate. Ref: studio#111. --- crates/oabctl/src/studio_api.rs | 16 +++++ crates/studio-cp/src/lib.rs | 114 ++++++++++++++++++++++++++++---- 2 files changed, 118 insertions(+), 12 deletions(-) diff --git a/crates/oabctl/src/studio_api.rs b/crates/oabctl/src/studio_api.rs index a73679a..ed1b2d5 100644 --- a/crates/oabctl/src/studio_api.rs +++ b/crates/oabctl/src/studio_api.rs @@ -229,6 +229,22 @@ 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)], + control_plane_bucket: Option<&str>, +) -> Result { + let yaml = serde_yaml::to_string(manifest).context("failed to serialize manifest")?; + provision(config, cluster, &yaml, objects, control_plane_bucket).await +} + /// 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 diff --git a/crates/studio-cp/src/lib.rs b/crates/studio-cp/src/lib.rs index a9204c8..073ea80 100644 --- a/crates/studio-cp/src/lib.rs +++ b/crates/studio-cp/src/lib.rs @@ -1021,12 +1021,74 @@ pub struct ProvisionOutcome { pub action: String, } +/// Where a brand-new agent's `spec.configFrom` should point — the same +/// `artifacts/{namespace}/{name}/config.toml` key `Bundle::artifact_objects` +/// already uploads a copy of the composed config.toml to, and the same +/// convention `oabctl create`'s wizard uses for its own generated manifest. +fn default_config_from_uri(bucket: &str, namespace: &str, name: &str) -> String { + format!("s3://{bucket}/{}/config.toml", studio_compose::artifacts_prefix(namespace, name)) +} + +/// Build a fresh `OABServiceManifest` for an agent that has never been +/// provisioned before — `redeploy()` can only patch an *already-stored* +/// manifest (studio#111: it has no "create the first one" path). Fields not +/// derivable from the compose bundle get sensible, zero-prompt defaults: +/// networking from `oabctl::create::default_networking` (same discovery +/// `oabctl create`'s CLI wizard uses, minus the interactive prompts), +/// resources 256/512 (the CLI wizard's own default), `FARGATE`/`X86_64` +/// (the schema's own `#[serde(default)]` values), empty `secrets` (valid — +/// no ECS-level Secrets Manager env injection; whatever the template's own +/// config.toml needs is the operator's concern, unchanged from how +/// `oabctl create` already works), no ingress. +async fn build_default_manifest( + aws_config: &aws_config::SdkConfig, + namespace: &str, + name: &str, + image: &str, + bucket: &str, +) -> anyhow::Result { + let net = oabctl::create::default_networking(aws_config, name).await?; + let config_from = default_config_from_uri(bucket, namespace, name); + Ok(oabctl::manifest::OABServiceManifest { + api_version: "oab.dev/v2".to_string(), + kind: "OABService".to_string(), + metadata: oabctl::manifest::Metadata { + name: name.to_string(), + namespace: namespace.to_string(), + generation: 0, + }, + spec: oabctl::manifest::Spec { + image: image.to_string(), + resources: oabctl::manifest::Resources { + cpu: "256".to_string(), + memory: "512".to_string(), + }, + config_from, + bundle_from: None, + bootstrap_from: None, + secrets: std::collections::HashMap::new(), + runtime: oabctl::manifest::Runtime::Ecs(oabctl::manifest::EcsRuntime { + capacity_provider: "FARGATE".to_string(), + architecture: "X86_64".to_string(), + task_role_arn: None, + networking: oabctl::manifest::EcsNetworking { + subnets: net.subnets, + security_groups: net.security_groups, + assign_public_ip: false, + }, + }), + ingress: None, + }, + }) +} + /// Provision an agent from the compose **library**: compose `template ⊕ overlay`, /// then **redeploy** — push the bundle to the agent's artifacts prefix and apply /// its stored manifest at the chosen image tag (agent-deployment ADR slice 2, /// path A). Networking/resources/secrets ride along from the stored manifest, so -/// this is the "update this agent to new persona / skills / image" path; the -/// agent must already have been `create`d. +/// this is the "update this agent to new persona / skills / image" path — unless +/// the agent has never been provisioned before, in which case a fresh manifest is +/// built instead ([`build_default_manifest`], studio#111). /// /// `image_override` (when non-empty) wins over the bundle's own default image tag. pub async fn provision_from_library( @@ -1075,16 +1137,32 @@ pub async fn provision_from_library( let digest = bundle.digest(); - let report = oabctl::studio_api::redeploy( - aws_config, - cluster, - namespace, - name, - Some(&image), - &objects, - Some(&bucket), - ) - .await?; + // studio#111: `redeploy()` only patches an *existing* stored manifest — + // it errors if this agent has never been provisioned. Check first and + // build a fresh manifest in that case, rather than surfacing that error + // to the console on every "+ New fleet" first deploy. + let existing_manifest = + oabctl::studio_api::load_manifest(aws_config, namespace, name, Some(&bucket)).await?; + let report = match existing_manifest { + Some(_) => { + oabctl::studio_api::redeploy( + aws_config, + cluster, + namespace, + name, + Some(&image), + &objects, + Some(&bucket), + ) + .await? + } + None => { + let mut manifest = build_default_manifest(aws_config, namespace, name, &image, &bucket).await?; + manifest.spec.bundle_from = Some(oabctl::studio_api::bundle_from_uri(&bucket, namespace, name)); + oabctl::studio_api::provision_manifest(aws_config, cluster, &manifest, &objects, Some(&bucket)) + .await? + } + }; Ok(ProvisionOutcome { image, @@ -1607,4 +1685,16 @@ aws_access_key_id = AKIA... let names = parse_aws_credentials_names(text); assert_eq!(names, vec!["default".to_string(), "oab-fleet".to_string()]); } + + #[test] + fn default_config_from_uri_matches_artifact_objects_key() { + // Must land at the same key Bundle::artifact_objects uploads + // config.toml to (and the same convention oabctl create's wizard + // uses for its own generated manifest) — a brand-new agent's + // configFrom pointing anywhere else would read nothing at boot. + assert_eq!( + default_config_from_uri("oab-control-plane-123", "prod", "orca"), + "s3://oab-control-plane-123/artifacts/prod/orca/config.toml", + ); + } } From a404fcd8332a8211b9bf2205973ca750cfb0fe51 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Fri, 28 Aug 2026 00:56:17 +0800 Subject: [PATCH 5/6] feat(studio-cp,oabctl): k8s deploy_provision dispatch (studio#104, resumed after #111) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resumes #104's k8s deploy_provision work now that #111 (#112/#113) gave both drivers a shared, provider-agnostic create-vs-redeploy branch point — load_manifest is just an S3 key lookup, it doesn't care which Runtime variant a stored manifest holds. - oabctl::studio_api::provision_k8s: provision_manifest's k8s counterpart, applies through K8sDriver instead of EcsDriver. Takes two separate credential contexts (aws_config for the S3 bundle carrier — hooks.pre_seed is provider-agnostic, still S3 regardless of runtime — and a kubeconfig context for the actual apply) since k8s provisioning genuinely needs both simultaneously, unlike the ECS path where one SdkConfig covers everything. - studio-cp::build_default_k8s_manifest: Runtime::Kubernetes counterpart to build_default_manifest. No VPC/subnet/SG (ECS-only networking concept); service_account comes from K8sFleetBinding.expected_principal when it names one (k8s_service_account_from_principal extracts the bare name from the system:serviceaccount:: form the New Fleet wizard's service- account picker writes — KubernetesRuntime.service_account wants the bare name, verified against k8s_driver.rs's own build_deployment_wires_service_account_and_node_selector test). - studio-cp::provision_from_library_k8s: provision_from_library's k8s counterpart. Compose/bundle-upload logic is duplicated rather than shared for now (deliberate — avoids reworking provision_from_library's shape again while it's still unmerged; worth revisiting once both paths are proven). Redeploy preserves the stored manifest's k8s runtime config, only bumps the image, same guarantee the AWS path gives. Nothing calls provision_from_library_k8s yet — oab-mcp's deploy_provision tool still needs a provider param to dispatch to it (the OabMcp struct also has no k8s-fleet-binding awareness yet to resolve context/expected_principal from). That wiring is the next piece. Manually verified every field against manifest.rs's struct definitions, same care as #113 given the environment can't locally compile (aws-sdk-ec2 OOM) — CI is the gate. Ref: studio#104, studio#111. --- crates/oabctl/src/studio_api.rs | 37 ++++++++ crates/studio-cp/src/lib.rs | 159 ++++++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+) diff --git a/crates/oabctl/src/studio_api.rs b/crates/oabctl/src/studio_api.rs index ed1b2d5..5097ae0 100644 --- a/crates/oabctl/src/studio_api.rs +++ b/crates/oabctl/src/studio_api.rs @@ -245,6 +245,43 @@ pub async fn provision_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)], + control_plane_bucket: Option<&str>, +) -> Result { + // 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 diff --git a/crates/studio-cp/src/lib.rs b/crates/studio-cp/src/lib.rs index 073ea80..071861a 100644 --- a/crates/studio-cp/src/lib.rs +++ b/crates/studio-cp/src/lib.rs @@ -1177,6 +1177,149 @@ pub async fn provision_from_library( }) } +/// Extract the bare service-account name from an `expected_principal` string +/// in `system:serviceaccount::` form — the format +/// `K8sFleetBinding.expected_principal` holds when the "+ New fleet" wizard's +/// service-account picker set it (studio#104). `KubernetesRuntime. +/// service_account` wants just the bare name (`k8s_driver::build_deployment` +/// sets it as `pod_spec.service_account_name` directly), not the qualified +/// form `k8s_principal_kind` classifies against. `None` — or a plain +/// username, not a service account — means the pod uses the namespace's +/// default service account. +fn k8s_service_account_from_principal(expected_principal: Option<&str>) -> Option { + expected_principal + .and_then(|p| p.strip_prefix("system:serviceaccount:")) + .and_then(|rest| rest.split_once(':')) + .map(|(_namespace, name)| name.to_string()) +} + +/// Build a fresh k8s `OABServiceManifest` — the `Runtime::Kubernetes` +/// counterpart to [`build_default_manifest`]. No VPC/subnet/security-group +/// concept (that's ECS-specific networking); k8s's per-fleet placement is +/// `context` (which cluster) and `namespace` (both already resolved by the +/// caller from `K8sFleetBinding`, not part of the manifest itself — mirrors +/// how AWS's `cluster` is a driver-construction parameter, not a manifest +/// field). `node_selector`/`tolerations` default empty; `service_account` +/// comes from `expected_principal` when it names one, else the namespace's +/// default applies (same "unset = use default" contract `default_security_group`- +/// style AWS defaults don't have an equivalent of, since k8s already has one +/// built in). +async fn build_default_k8s_manifest( + namespace: &str, + name: &str, + image: &str, + bucket: &str, + expected_principal: Option<&str>, +) -> anyhow::Result { + let config_from = default_config_from_uri(bucket, namespace, name); + Ok(oabctl::manifest::OABServiceManifest { + api_version: "oab.dev/v2".to_string(), + kind: "OABService".to_string(), + metadata: oabctl::manifest::Metadata { + name: name.to_string(), + namespace: namespace.to_string(), + generation: 0, + }, + spec: oabctl::manifest::Spec { + image: image.to_string(), + resources: oabctl::manifest::Resources { + cpu: "256".to_string(), + memory: "512".to_string(), + }, + config_from, + bundle_from: None, + bootstrap_from: None, + secrets: std::collections::HashMap::new(), + runtime: oabctl::manifest::Runtime::Kubernetes(oabctl::manifest::KubernetesRuntime { + node_selector: std::collections::HashMap::new(), + service_account: k8s_service_account_from_principal(expected_principal), + tolerations: Vec::new(), + }), + ingress: None, + }, + }) +} + +/// [`provision_from_library`], but for a k8s-driven fleet — studio#104's +/// `deploy_provision` k8s dispatch, resumed once #111 gave both drivers a +/// shared, provider-agnostic create-vs-redeploy branch point +/// (`load_manifest` is just an S3 key, it doesn't care which `Runtime` variant +/// the stored manifest holds). +/// +/// Compose/bundle-upload is identical to the AWS path (the bundle carrier — +/// `hooks.pre_seed`, studio#97 slice 3c — is provider-agnostic, still S3 +/// regardless of where the agent actually runs) — some duplication with +/// `provision_from_library` here is deliberate for now rather than risk +/// reworking that already-landed function's shape again; worth revisiting +/// once both paths are proven. +/// +/// `expected_principal` is `K8sFleetBinding.expected_principal` — see +/// [`k8s_service_account_from_principal`] for how it maps onto the manifest. +pub async fn provision_from_library_k8s( + aws_config: &aws_config::SdkConfig, + context: Option<&str>, + namespace: &str, + name: &str, + library: &Library, + template: &str, + overlay: Option<&str>, + image_override: Option<&str>, + expected_principal: Option<&str>, +) -> anyhow::Result { + let mut bundle = studio_compose::compose_named(library, template, overlay) + .map_err(|e| anyhow::anyhow!("compose failed: {e}"))?; + let image = image_override + .filter(|s| !s.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| bundle.image_tag.clone()); + + let bucket = oabctl::resolve_bucket(aws_config, None).await?; + let zip_uri = oabctl::studio_api::bundle_zip_uri(&bucket, namespace, name); + match bundle.files.get_mut("config.toml") { + Some(bytes) => *bytes = oabctl::studio_api::inject_pre_seed_hook(bytes, &zip_uri)?, + None => anyhow::bail!("composed bundle for {namespace}/{name} has no config.toml — cannot wire hooks.pre_seed"), + } + + let mut objects = bundle.artifact_objects(namespace, name); + let zip_key = format!( + "{}/{}", + studio_compose::artifacts_prefix(namespace, name), + oabctl::studio_api::BUNDLE_ZIP_FILENAME + ); + objects.push((zip_key, bundle.zip_bytes())); + + let digest = bundle.digest(); + + let existing_manifest = + oabctl::studio_api::load_manifest(aws_config, namespace, name, Some(&bucket)).await?; + let mut manifest = match existing_manifest { + // Redeploy: preserve everything else about the stored manifest + // (including its k8s runtime config), only bump the image — same + // guarantee the AWS path's `redeploy()` gives. + Some(mut stored) => { + stored.spec.image = image.clone(); + stored + } + None => build_default_k8s_manifest(namespace, name, &image, &bucket, expected_principal).await?, + }; + manifest.spec.bundle_from = Some(oabctl::studio_api::bundle_from_uri(&bucket, namespace, name)); + + let report = + oabctl::studio_api::provision_k8s(aws_config, context, &manifest, &objects, Some(&bucket)).await?; + + Ok(ProvisionOutcome { + image, + digest, + objects: objects.len(), + services_applied: report.services.len(), + action: report + .services + .first() + .map(|s| format!("{:?}", s.action)) + .unwrap_or_default(), + }) +} + /// Scale an OAB service to `size` replicas (0 = off, 1 = on). /// /// Config-free: `cluster` / `namespace` are explicit (service = `oab-{namespace}-{name}`). @@ -1697,4 +1840,20 @@ aws_access_key_id = AKIA... "s3://oab-control-plane-123/artifacts/prod/orca/config.toml", ); } + + #[test] + fn k8s_service_account_from_principal_extracts_bare_name() { + assert_eq!( + k8s_service_account_from_principal(Some("system:serviceaccount:dev:oab-agent")), + Some("oab-agent".to_string()), + ); + } + + #[test] + fn k8s_service_account_from_principal_none_for_plain_username() { + // A plain username (not a service account) or unset both mean "use + // the namespace's default service account" — not an error. + assert_eq!(k8s_service_account_from_principal(Some("brett.chien")), None); + assert_eq!(k8s_service_account_from_principal(None), None); + } } From 0dc9af51cacb962e16203a1abf9a74b3846ca8d1 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Fri, 28 Aug 2026 01:06:22 +0800 Subject: [PATCH 6/6] feat(oab-mcp): wire deploy_provision's k8s provider dispatch (studio#104) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit t_provision now branches on a new provider arg (default "aws", unchanged behavior): "k8s" calls provision_from_library_k8s (#114) with context/ expected_principal taken as direct args, not resolved via a fleet name — the console's identity form already collects context/namespace/service- account directly (#108/#109), and for a brand-new fleet there's no existing K8sFleetBinding to look up anyway. Fleet-scoped k8s lookup (for redeploying into an already-known k8s fleet) is left as explicit future work, not needed for this dispatch to exist. Also updates deploy_provision's tool description, which was stale after provider/context/expected_principal params documented. NOTE — branch lineage: this stack (#112→#113→#114→this) was cut from `main` before #104's original stack (#105-110) merged, not from #110 — so K8sFleetBinding.expected_principal (originally #107) is re-added here too. Identical field in both places; trivial merge conflict to resolve whenever both land, flagging explicitly rather than silently duplicating without a note. With this, deploy_provision fully supports k8s end-to-end (provisioning side) — the remaining piece for full onboarding is unblocking console's k8s "Next" button (#108/#109's placeholder), a separate follow-up. Ref: studio#104. --- crates/oab-mcp/src/lib.rs | 49 +++++++++++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/crates/oab-mcp/src/lib.rs b/crates/oab-mcp/src/lib.rs index 82f9707..48300ea 100644 --- a/crates/oab-mcp/src/lib.rs +++ b/crates/oab-mcp/src/lib.rs @@ -146,7 +146,7 @@ pub fn tools() -> Vec { ), 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::` — the bare name becomes the pod's serviceAccountName; unset uses the namespace's default).", as_map(json!({ "type": "object", "properties": { @@ -156,8 +156,11 @@ pub fn tools() -> Vec { "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::` to set the pod's service account; unset uses the namespace's default." } }, "required": ["library", "template", "name"] })), @@ -546,8 +549,6 @@ impl OabMcp { } async fn t_provision(&self, args: &Map) -> Result { - let t = self.target(args)?; - let cluster = t.cluster.clone(); let namespace = args .get("namespace") .and_then(Value::as_str) @@ -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