From bfec3543ada79ac72db91522f6a8281204ebdcaf Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Fri, 28 Aug 2026 00:48:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(studio-cp,oabctl):=20build=20a=20default?= =?UTF-8?q?=20manifest=20when=20none=20exists=20yet=20=E2=80=94=20the=20ac?= =?UTF-8?q?tual=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 f0d5669..36bd194 100644 --- a/crates/studio-cp/src/lib.rs +++ b/crates/studio-cp/src/lib.rs @@ -774,12 +774,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( @@ -828,16 +890,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, @@ -1284,4 +1362,16 @@ namespace = "prod" // this is the cross-crate seam that catches drift instead. assert_eq!(oabctl::studio_api::BUNDLE_ZIP_FILENAME, studio_compose::Bundle::ZIP_FILENAME); } + + #[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", + ); + } }