From 05c4394a1a4091045228e91a8881f8a754cb40b7 Mon Sep 17 00:00:00 2001 From: brettchien Date: Sat, 8 Aug 2026 16:16:34 +0800 Subject: [PATCH 01/13] feat(agent-lifecycle): canonical 6-state core + ECS driver (slice-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements docs/adr/agent-lifecycle.md: - AgentState (6), Discriminator (4 axes incl. latching identity_verified), classify() with the Starting/Unhealthy split the latch resolves. - IdentityLatch: CP-owned monotonic identity_verified bit. - RuntimeDriver trait: observe -> project -> classify. - EcsDriver: ECS lastStatus/desiredStatus/healthStatus/lease/cordon projection (DescribeTasks wiring deferred to a later slice). - Unit tests incl. F1 (latch), superseded=>Paused, Unknown=>Unhealthy, Stopping. NOT compile-verified locally (no cargo toolchain on the authoring runtime) — needs cargo test in CI / on Brett's box. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 3 + Cargo.toml | 3 + crates/agent-lifecycle/Cargo.toml | 8 ++ crates/agent-lifecycle/src/ecs.rs | 151 +++++++++++++++++++++++ crates/agent-lifecycle/src/lib.rs | 199 ++++++++++++++++++++++++++++++ 5 files changed, 364 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 crates/agent-lifecycle/Cargo.toml create mode 100644 crates/agent-lifecycle/src/ecs.rs create mode 100644 crates/agent-lifecycle/src/lib.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6936990 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +**/*.rs.bk +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..c554106 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] +members = ["crates/agent-lifecycle"] +resolver = "2" diff --git a/crates/agent-lifecycle/Cargo.toml b/crates/agent-lifecycle/Cargo.toml new file mode 100644 index 0000000..fb80d6e --- /dev/null +++ b/crates/agent-lifecycle/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "agent-lifecycle" +version = "0.1.0" +edition = "2021" +description = "Canonical agent lifecycle state machine — see docs/adr/agent-lifecycle.md" +license = "MIT" + +[dependencies] diff --git a/crates/agent-lifecycle/src/ecs.rs b/crates/agent-lifecycle/src/ecs.rs new file mode 100644 index 0000000..5dee278 --- /dev/null +++ b/crates/agent-lifecycle/src/ecs.rs @@ -0,0 +1,151 @@ +//! ECS runtime driver — projects ECS task signals onto the canonical model. +//! +//! Mapping (ADR §6): `lastStatus` ever RUNNING ⇒ `identity_verified`; +//! `desiredStatus == STOPPED` ⇒ `DesiredStatus::Stopped`; `healthStatus` + lease +//! ⇒ `Health`; CP/director cordon ⇒ `accepting_work`. + +use crate::{DesiredStatus, Discriminator, Health, RuntimeDriver}; + +/// ECS `lastStatus` values relevant to the projection. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EcsLastStatus { + Provisioning, + Pending, + Activating, + Running, + Deactivating, + Stopping, + Stopped, +} + +/// ECS container `healthStatus`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EcsHealth { + Healthy, + Unhealthy, + Unknown, +} + +/// A single ECS task observation (the subset the projection needs). +#[derive(Debug, Clone, Copy)] +pub struct EcsTask { + pub last_status: EcsLastStatus, + /// ECS `desiredStatus == STOPPED`. + pub desired_status_stopped: bool, + pub health: EcsHealth, + /// CP-issued lease still valid (heartbeat authorized). + pub lease_valid: bool, + /// CP/director cordon: `false` ⇒ not admitting new work (→ Paused). + pub accepting_work: bool, +} + +/// Projects ECS task state onto the canonical lifecycle model. +pub struct EcsDriver; + +impl RuntimeDriver for EcsDriver { + type Native = EcsTask; + /// Task ARN. + type InstanceId = String; + + fn observe(&self, _id: &Self::InstanceId) -> Option { + // Slice-1: wiring to the ECS API is deferred. The real implementation + // calls DescribeTasks and returns `None` when the task is absent from + // the response (⇒ Stopped). + unimplemented!("ECS DescribeTasks wiring is a later slice") + } + + fn project(&self, task: &EcsTask, verified_before: bool) -> Discriminator { + let desired_status = if task.desired_status_stopped { + DesiredStatus::Stopped + } else { + DesiredStatus::Running + }; + + // `identity_verified` latches once `lastStatus` has ever reached RUNNING. + let identity_verified = + verified_before || task.last_status == EcsLastStatus::Running; + + // Faulted on an unhealthy check, an unknown/unobservable status + // (node lost), or a lost lease. + let health = match task.health { + EcsHealth::Healthy if task.lease_valid => Health::Ok, + _ => Health::Faulted, + }; + + Discriminator { + desired_status, + accepting_work: task.accepting_work, + health, + identity_verified, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::AgentState; + + fn task( + ls: EcsLastStatus, + stopped: bool, + h: EcsHealth, + lease: bool, + accepting: bool, + ) -> EcsTask { + EcsTask { + last_status: ls, + desired_status_stopped: stopped, + health: h, + lease_valid: lease, + accepting_work: accepting, + } + } + + #[test] + fn activating_task_projects_to_starting() { + let d = EcsDriver.project( + &task(EcsLastStatus::Activating, false, EcsHealth::Unknown, false, false), + false, + ); + assert_eq!(d.classify(), AgentState::Starting); + } + + #[test] + fn running_healthy_task_projects_to_running() { + let d = EcsDriver.project( + &task(EcsLastStatus::Running, false, EcsHealth::Healthy, true, true), + true, + ); + assert_eq!(d.classify(), AgentState::Running); + } + + #[test] + fn unhealthy_after_verified() { + // Was RUNNING before, now healthStatus UNHEALTHY ⇒ Unhealthy (not Starting). + let d = EcsDriver.project( + &task(EcsLastStatus::Running, false, EcsHealth::Unhealthy, true, false), + true, + ); + assert_eq!(d.classify(), AgentState::Unhealthy); + } + + #[test] + fn node_lost_unknown_is_unhealthy_not_stopped() { + // Unknown health while verified ⇒ Unhealthy(fenced), not Stopped. + let d = EcsDriver.project( + &task(EcsLastStatus::Running, false, EcsHealth::Unknown, false, false), + true, + ); + assert_eq!(d.classify(), AgentState::Unhealthy); + } + + #[test] + fn desired_stopped_is_stopping_while_observable() { + let d = EcsDriver.project( + &task(EcsLastStatus::Deactivating, true, EcsHealth::Healthy, true, false), + true, + ); + assert_eq!(d.classify(), AgentState::Stopping); + } +} diff --git a/crates/agent-lifecycle/src/lib.rs b/crates/agent-lifecycle/src/lib.rs new file mode 100644 index 0000000..88a06ae --- /dev/null +++ b/crates/agent-lifecycle/src/lib.rs @@ -0,0 +1,199 @@ +//! Canonical agent lifecycle state machine. +//! +//! Implements the 6-state model and 4-axis discriminator from +//! `docs/adr/agent-lifecycle.md`. The control plane classifies every agent +//! instance, at any moment, into exactly one [`AgentState`]. Runtime drivers +//! project their native signals onto the [`Discriminator`]; the machine itself +//! never changes per runtime. + +pub mod ecs; + +/// Whether the control plane wants this instance running or stopped. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DesiredStatus { + Running, + Stopped, +} + +/// Point-in-time health / sync — "is it OK right now". +/// +/// `Faulted` covers a lost heartbeat / failed probe / lost lease / not-in-sync, +/// as well as an *unobservable* instance (node lost). It does **not** cover +/// version skew — that is a healthy `superseded` attribute, not a fault. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Health { + Ok, + Faulted, +} + +/// The four observable discriminator axes (ADR §3). +/// +/// `identity_verified` is **latching**: set true the first time the instance +/// reaches Running, never cleared for the life of that instance. It is what +/// separates `Starting` (never verified) from `Unhealthy` (was verified, now +/// faulted) — without it their other three axes collide. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Discriminator { + pub desired_status: DesiredStatus, + pub accepting_work: bool, + pub health: Health, + pub identity_verified: bool, +} + +/// The canonical six states. Exactly one holds at any moment. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AgentState { + Starting, + Running, + Paused, + Unhealthy, + Stopping, + Stopped, +} + +impl Discriminator { + /// Classify a **live** (still-observable) instance into a lifecycle state. + /// + /// Never returns [`AgentState::Stopped`]; that is terminal and represented + /// by the absence of an observation (see [`classify`]). + pub fn classify(&self) -> AgentState { + match self.desired_status { + // Terminate committed; still observable ⇒ graceful teardown window. + DesiredStatus::Stopped => AgentState::Stopping, + DesiredStatus::Running => { + if !self.identity_verified { + // Never came up yet — coming-up, not a fault. + AgentState::Starting + } else if self.health == Health::Faulted { + // Came up before, now faulted — alive but fenced. + AgentState::Unhealthy + } else if self.accepting_work { + AgentState::Running + } else { + // Healthy and in-sync, but deliberately not admitting. + AgentState::Paused + } + } + } + } +} + +/// Classify from an optional observation. +/// +/// `None` means the instance no longer exists (terminated / hard loss) ⇒ +/// [`AgentState::Stopped`] (terminal, absorbing). `Some(disc)` delegates to +/// [`Discriminator::classify`]. +pub fn classify(observation: Option<&Discriminator>) -> AgentState { + match observation { + None => AgentState::Stopped, + Some(disc) => disc.classify(), + } +} + +/// Maintains the latching `identity_verified` bit across observations. +/// +/// The control plane owns this bit (default-deny; never the agent's +/// self-report). Feed it the observed health while `desired_status == Running`; +/// it flips to `true` the first time health is `Ok` and stays there for the +/// life of the instance. +#[derive(Debug, Clone, Copy, Default)] +pub struct IdentityLatch(bool); + +impl IdentityLatch { + pub fn new() -> Self { + Self(false) + } + + /// Update with the latest observed health; returns the latched value. + pub fn observe(&mut self, health: Health) -> bool { + if health == Health::Ok { + self.0 = true; + } + self.0 + } + + pub fn verified(&self) -> bool { + self.0 + } +} + +/// A runtime driver projects its native signals onto the canonical model. +pub trait RuntimeDriver { + /// The driver's native, per-instance observation type. + type Native; + /// Opaque per-instance identifier in this runtime. + type InstanceId; + + /// Observe an instance. `None` ⇒ the instance no longer exists (⇒ Stopped). + fn observe(&self, id: &Self::InstanceId) -> Option; + + /// Project a native observation onto the four discriminator axes. + /// + /// `verified_before` is the latched `identity_verified` the control plane + /// has tracked for this instance so far. + fn project(&self, native: &Self::Native, verified_before: bool) -> Discriminator; + + /// Convenience: observe + project + classify into an [`AgentState`]. + fn state(&self, id: &Self::InstanceId, verified_before: bool) -> AgentState { + match self.observe(id) { + None => AgentState::Stopped, + Some(native) => self.project(&native, verified_before).classify(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn disc(d: DesiredStatus, accepting: bool, h: Health, verified: bool) -> Discriminator { + Discriminator { + desired_status: d, + accepting_work: accepting, + health: h, + identity_verified: verified, + } + } + + #[test] + fn starting_vs_unhealthy_differ_only_by_identity_latch() { + // F1: identical (desired, accepting_work, health), opposite identity_verified. + let starting = disc(DesiredStatus::Running, false, Health::Faulted, false); + let unhealthy = disc(DesiredStatus::Running, false, Health::Faulted, true); + assert_eq!(starting.classify(), AgentState::Starting); + assert_eq!(unhealthy.classify(), AgentState::Unhealthy); + } + + #[test] + fn running_and_paused() { + let running = disc(DesiredStatus::Running, true, Health::Ok, true); + let paused = disc(DesiredStatus::Running, false, Health::Ok, true); + assert_eq!(running.classify(), AgentState::Running); + assert_eq!(paused.classify(), AgentState::Paused); + } + + #[test] + fn superseded_is_paused_not_running() { + // superseded ⇒ CP sets accepting_work=false ⇒ Paused (never dispatchable). + let superseded = disc(DesiredStatus::Running, false, Health::Ok, true); + assert_eq!(superseded.classify(), AgentState::Paused); + } + + #[test] + fn stopping_while_observable_stopped_when_gone() { + let stopping = disc(DesiredStatus::Stopped, false, Health::Ok, true); + assert_eq!(stopping.classify(), AgentState::Stopping); + assert_eq!(classify(None), AgentState::Stopped); + assert_eq!(classify(Some(&stopping)), AgentState::Stopping); + } + + #[test] + fn identity_latch_is_monotonic() { + let mut latch = IdentityLatch::new(); + assert!(!latch.verified()); + assert!(!latch.observe(Health::Faulted)); // still starting + assert!(latch.observe(Health::Ok)); // reached Running -> latched + assert!(latch.observe(Health::Faulted)); // now Unhealthy, latch stays true + assert!(latch.verified()); + } +} From 6d7035b7ca9f04f00f9f06823297d1303232e765 Mon Sep 17 00:00:00 2001 From: brettchien Date: Sat, 8 Aug 2026 17:57:09 +0800 Subject: [PATCH 02/13] feat: vendor oabctl from openab + workspace + CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vendor the oabctl crate (ECS provisioner CLI+lib) from openabdev/openab operator/ @ d64c678 (MIT, attributed in crates/oabctl/VENDORED.md) so Studio can build control-plane actions + an MCP surface on it in one repo instead of across repos. Wire it into the workspace alongside agent-lifecycle. Add GitHub Actions CI (fmt/clippy on our crates; build+test the whole workspace) — the authoring runtime has no cargo toolchain, so CI is the compile/test verification for both the vendored crate and agent-lifecycle. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 27 + Cargo.toml | 2 +- crates/oabctl/Cargo.toml | 43 + crates/oabctl/README.md | 136 +++ crates/oabctl/VENDORED.md | 15 + crates/oabctl/entrypoint.sh | 23 + crates/oabctl/examples/fleet.yaml | 48 + crates/oabctl/examples/kiro-01.yaml | 22 + crates/oabctl/schema/oabservice-v2.json | 172 +++ crates/oabctl/src/apply.rs | 1385 +++++++++++++++++++++++ crates/oabctl/src/bootstrap.rs | 588 ++++++++++ crates/oabctl/src/cli.rs | 284 +++++ crates/oabctl/src/config.rs | 78 ++ crates/oabctl/src/control_plane.rs | 55 + crates/oabctl/src/create.rs | 389 +++++++ crates/oabctl/src/delete.rs | 295 +++++ crates/oabctl/src/get.rs | 114 ++ crates/oabctl/src/ingress.rs | 1239 ++++++++++++++++++++ crates/oabctl/src/lib.rs | 73 ++ crates/oabctl/src/main.rs | 4 + crates/oabctl/src/manifest.rs | 651 +++++++++++ crates/oabctl/src/scale.rs | 711 ++++++++++++ crates/oabctl/src/secrets.rs | 259 +++++ 23 files changed, 6612 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml create mode 100644 crates/oabctl/Cargo.toml create mode 100644 crates/oabctl/README.md create mode 100644 crates/oabctl/VENDORED.md create mode 100755 crates/oabctl/entrypoint.sh create mode 100644 crates/oabctl/examples/fleet.yaml create mode 100644 crates/oabctl/examples/kiro-01.yaml create mode 100644 crates/oabctl/schema/oabservice-v2.json create mode 100644 crates/oabctl/src/apply.rs create mode 100644 crates/oabctl/src/bootstrap.rs create mode 100644 crates/oabctl/src/cli.rs create mode 100644 crates/oabctl/src/config.rs create mode 100644 crates/oabctl/src/control_plane.rs create mode 100644 crates/oabctl/src/create.rs create mode 100644 crates/oabctl/src/delete.rs create mode 100644 crates/oabctl/src/get.rs create mode 100644 crates/oabctl/src/ingress.rs create mode 100644 crates/oabctl/src/lib.rs create mode 100644 crates/oabctl/src/main.rs create mode 100644 crates/oabctl/src/manifest.rs create mode 100644 crates/oabctl/src/scale.rs create mode 100644 crates/oabctl/src/secrets.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a5343fe --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +env: + CARGO_TERM_COLOR: always + +jobs: + build-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - name: fmt (our crates) + run: cargo fmt -p agent-lifecycle --check + - name: clippy (our crates) + run: cargo clippy -p agent-lifecycle -- -D warnings + - name: build (workspace) + run: cargo build --workspace --all-targets + - name: test (workspace) + run: cargo test --workspace diff --git a/Cargo.toml b/Cargo.toml index c554106..36bb88e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,3 @@ [workspace] -members = ["crates/agent-lifecycle"] +members = ["crates/agent-lifecycle", "crates/oabctl"] resolver = "2" diff --git a/crates/oabctl/Cargo.toml b/crates/oabctl/Cargo.toml new file mode 100644 index 0000000..4cee9c9 --- /dev/null +++ b/crates/oabctl/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "oabctl" +version = "0.1.0" +edition = "2021" +description = "CLI provisioner for OAB agents on ECS" +license = "MIT" + +# Vendored from openabdev/openab (MIT) @ d64c678 — see VENDORED.md. Part of the +# studio workspace (root Cargo.toml). + +[lib] +name = "oabctl" +path = "src/lib.rs" + +[[bin]] +name = "oabctl" +path = "src/main.rs" + +[dependencies] +aws-config = "1.5" +aws-sdk-ecs = "1.53" +aws-sdk-ec2 = "1" +aws-sdk-iam = "1" +aws-sdk-s3 = "1.65" +aws-sdk-scheduler = "1.98" +aws-sdk-ssm = "1.52" +aws-sdk-sts = "1" +aws-sdk-cloudwatchlogs = "1" +aws-sdk-secretsmanager = "1" +aws-sdk-apigatewayv2 = "1" +aws-sdk-servicediscovery = "1" +ecsctl = { git = "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/oablab/ecsctl.git", rev = "90a6cd1" } +clap = { version = "4.5", features = ["derive"] } +chrono = "0.4" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" +tokio = { version = "1.40", features = ["full"] } +toml = "0.8" +anyhow = "1.0" +dirs = "6" +rpassword = "7" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } diff --git a/crates/oabctl/README.md b/crates/oabctl/README.md new file mode 100644 index 0000000..2ad4de1 --- /dev/null +++ b/crates/oabctl/README.md @@ -0,0 +1,136 @@ +# oabctl — OAB Agent Provisioner + +CLI tool that provisions and manages OpenAB agents on Amazon ECS Fargate (with Kubernetes support planned). + +> 📖 **Full usage guide** — installation, manifest schema, ingress/webhooks, +> secrets, bootstrap, and the commands reference: **[docs/oabctl.md](../docs/oabctl.md)** + +## How It Works + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Developer Machine │ +│ │ +│ oabctl bootstrap ──► Creates: ECS Cluster, IAM Roles, S3, SG, Logs │ +│ │ +│ oabctl create ─────► Wizard → config.toml + manifest.yaml (local) │ +│ │ │ │ +│ │ └─► Secrets Manager: oab/{ns}/{name} │ +│ │ │ +│ oabctl apply │ +│ │ │ +│ ├─► S3: Upload config.toml to artifacts/{ns}/{name}/ │ +│ ├─► ECS: Register Task Definition │ +│ └─► ECS: Create/Update Service │ +│ │ +│ oabctl exec/cp/sync ──► ecsctl library ──► ECS Exec (SSM) │ +└──────────────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────────────┐ +│ AWS Cloud │ +│ │ +│ ┌─────────────┐ ┌──────────────────────────────────────────┐ │ +│ │ S3 Bucket │ │ ECS Cluster (oab) │ │ +│ │ │ │ │ │ +│ │ bootstrap/ │ │ ┌─────────────────────────────────┐ │ │ +│ │ state.json│ │ │ Fargate Task (agent) │ │ │ +│ │ │ │ │ │ │ │ +│ │ manifests/ │ │ │ ┌────────────────────────────┐ │ │ │ +│ │ *.yaml │ │ │ │ OpenAB Container │ │ │ │ +│ │ │ │ │ │ │ │ │ │ +│ │ artifacts/ │◄────┼──┼──│ 1. Download config.toml │ │ │ │ +│ │ config.toml │ │ │ 2. Resolve [secrets.refs] │─┼────┼──►SM │ +│ │ │ │ │ │ 3. Start agent │ │ │ │ +│ └─────────────┘ │ │ └────────────────────────────┘ │ │ │ +│ │ └─────────────────────────────────┘ │ │ +│ ┌──────────────┐ └──────────────────────────────────────────┘ │ +│ │ Secrets Mgr │ │ +│ │ oab/{ns}/{n} │ ┌───────────────┐ │ +│ │ BOT_TOKEN │ │ CloudWatch │ │ +│ │ STT_API_KEY │ │ /oab/agents │ │ +│ └──────────────┘ └───────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +## Quick Start + +```bash +# 1. Bootstrap infrastructure (one-time) +oabctl bootstrap + +# 2. Create an agent (generates config + manifest) +oabctl create my-bot + +# 3. Review generated files, then deploy +oabctl apply -f my-bot/manifest.yaml --wait + +# 4. Done! Agent is running. +oabctl exec my-bot -- bash +``` + +See **[docs/oabctl.md](../docs/oabctl.md)** for installation instructions, +the full manifest schema (including ingress/webhooks for Telegram and LINE), +secrets formats, bootstrap details, IAM permission tables, and the complete +commands reference. + +## Library API + +The crate exposes a deliberately narrow manifest + apply facade for control +planes that should not shell out to the CLI: + +```rust,no_run +use oabctl::{apply_manifests, ApplyOptions, OABServiceManifest}; + +async fn deploy( + aws: &aws_config::SdkConfig, + manifest: OABServiceManifest, +) -> Result<(), oabctl::ApplyError> { + let report = apply_manifests( + aws, + &[manifest], + &ApplyOptions::new("production-cluster").with_wait(true), + ) + .await?; + for service in report.services { + println!("{}: {:?}", service.ecs_service_name, service.action); + } + Ok(()) +} +``` + +Programmatic apply emits no progress to process-global stdout/stderr. Success +returns per-service actions, webhook URLs, and warnings; reconciliation errors +identify the failed service and include the report completed before the failure. +Both CLI and programmatic apply verify that the target cluster exists and is +`ACTIVE` before mutation, so the caller identity requires +`ecs:DescribeClusters`. This ECS action does not support resource-level +permissions; its IAM statement must use `Resource: "*"`. +The library never reads `~/.oabctl/config.toml`: set +`with_control_plane_bucket(...)` explicitly when needed, otherwise bucket +resolution uses `OAB_CONTROL_PLANE_BUCKET` and then the caller's AWS account. +For `aws-sm://#`, a non-ARN `` requires the +caller to have `secretsmanager:DescribeSecret`; full-ARN shorthand does not +need that lookup. + +## Source Layout + +``` +operator/ +├── src/ +│ ├── main.rs # Thin binary entrypoint (`oabctl::run_cli()`) +│ ├── cli.rs # Private CLI definitions and subcommand dispatch +│ ├── manifest.rs # Publicly re-exported manifest model + validation +│ ├── apply.rs # apply: ECS task def registration, service create/update +│ ├── bootstrap.rs # bootstrap: cluster/IAM/S3/SG/log-group provisioning +│ ├── ingress.rs # ingress: Cloud Map + VPC Link + API Gateway reconciliation +│ ├── secrets.rs # spec.secrets value resolution (ECS-native + aws-sm:// shorthand) +│ ├── create.rs # create: interactive wizard +│ ├── get.rs # get: list/describe agents +│ └── delete.rs # delete: teardown +└── schema/ + └── oabservice-v2.json # JSON Schema for IDE validation +``` + +## JSON Schema + +[`schema/oabservice-v2.json`](schema/oabservice-v2.json) — supports both OABService and OABFleet for IDE validation. diff --git a/crates/oabctl/VENDORED.md b/crates/oabctl/VENDORED.md new file mode 100644 index 0000000..34852c7 --- /dev/null +++ b/crates/oabctl/VENDORED.md @@ -0,0 +1,15 @@ +# Vendored: oabctl + +This crate is vendored from **[openabdev/openab](https://github.com/openabdev/openab)** +(`operator/`), which is licensed **MIT** (Copyright (c) 2026 openabdev). + +- Source: `openabdev/openab` @ `d64c678f0b5e4b26f52d5272b0c6743c4207a1b9` +- Vendored: 2026-08-08 + +We copied it (rather than depending across repos) so Studio can build the +control-plane actions and an MCP surface on top of it in one place. Upstream +changes are **not** auto-synced; re-vendor deliberately and record the new sha +here. + +MIT license text: see the repo root `LICENSE` (Studio is also MIT) and the +upstream `openabdev/openab` `LICENSE`. diff --git a/crates/oabctl/entrypoint.sh b/crates/oabctl/entrypoint.sh new file mode 100755 index 0000000..f93dc03 --- /dev/null +++ b/crates/oabctl/entrypoint.sh @@ -0,0 +1,23 @@ +#!/bin/bash +set -euo pipefail + +# OAB ECS Entrypoint Wrapper +# Downloads bootstrap archive and rendered config before starting OAB. + +# 1. Restore bootstrap (mutable state: memory, knowledge base) +if [ -n "${BOOTSTRAP_FROM:-}" ]; then + echo "[entrypoint] Restoring bootstrap from ${BOOTSTRAP_FROM}..." + aws s3 cp "${BOOTSTRAP_FROM}" /tmp/bootstrap.tar.gz + tar xzf /tmp/bootstrap.tar.gz -C "$HOME" + rm -f /tmp/bootstrap.tar.gz +fi + +# 2. Overwrite with rendered config (AFTER bootstrap, so desired config wins) +if [ -n "${CONFIG_S3_PATH:-}" ]; then + echo "[entrypoint] Downloading config from ${CONFIG_S3_PATH}..." + aws s3 cp "${CONFIG_S3_PATH}" "$HOME/config.toml" +fi + +# 3. Start OAB (DISCORD_TOKEN etc injected via ECS secrets) +echo "[entrypoint] Starting OpenAB..." +exec /usr/bin/openab "$@" diff --git a/crates/oabctl/examples/fleet.yaml b/crates/oabctl/examples/fleet.yaml new file mode 100644 index 0000000..93c5940 --- /dev/null +++ b/crates/oabctl/examples/fleet.yaml @@ -0,0 +1,48 @@ +# yaml-language-server: $schema=../schema/oabservice-v2.json +apiVersion: oab.dev/v2 +kind: OABFleet +metadata: + name: law-shi-team + namespace: prod +spec: + template: + image: 123456789.dkr.ecr.us-east-1.amazonaws.com/openab:latest + resources: + cpu: "256" + memory: "512" + secrets: + DISCORD_TOKEN: /oab/prod/${name}/discord-token + runtime: + type: ecs + capacityProvider: FARGATE_SPOT + networking: + subnets: [subnet-aaa, subnet-bbb] + securityGroups: [sg-oab] + agents: + - name: chaodu + configFrom: s3://oab-control-plane/config/prod/chaodu/config.toml + - name: pudu + configFrom: s3://oab-control-plane/config/prod/pudu/config.toml + - name: baidu + configFrom: s3://oab-control-plane/config/prod/baidu/config.toml + - name: juedu + configFrom: s3://oab-control-plane/config/prod/juedu/config.toml + - name: koudu + configFrom: s3://oab-control-plane/config/prod/koudu/config.toml + - name: xdu + configFrom: s3://oab-control-plane/config/prod/xdu/config.toml + - name: zdu + configFrom: s3://oab-control-plane/config/prod/zdu/config.toml + - name: kiro-01 + configFrom: s3://oab-control-plane/config/prod/kiro-01/config.toml + - name: kiro-02 + configFrom: s3://oab-control-plane/config/prod/kiro-02/config.toml + resources: + cpu: "512" + memory: "1024" + - name: openclaw + configFrom: s3://oab-control-plane/config/prod/openclaw/config.toml + image: 123456789.dkr.ecr.us-east-1.amazonaws.com/openclaw:latest + resources: + cpu: "1024" + memory: "2048" diff --git a/crates/oabctl/examples/kiro-01.yaml b/crates/oabctl/examples/kiro-01.yaml new file mode 100644 index 0000000..1e8c1d2 --- /dev/null +++ b/crates/oabctl/examples/kiro-01.yaml @@ -0,0 +1,22 @@ +# yaml-language-server: $schema=../schema/oabservice-v2.json +apiVersion: oab.dev/v2 +kind: OABService +metadata: + name: kiro-01 + namespace: prod +spec: + image: 123456789.dkr.ecr.us-east-1.amazonaws.com/openab:latest + resources: + cpu: "256" + memory: "512" + configFrom: s3://oab-control-plane/config/prod/kiro-01/config.toml + bootstrapFrom: s3://oab-backups/agents/kiro-01/latest.tar.gz + secrets: + DISCORD_TOKEN: /oab/prod/kiro-01/discord-token + runtime: + type: ecs + capacityProvider: FARGATE_SPOT + networking: + subnets: [subnet-aaa, subnet-bbb] + securityGroups: [sg-oab] + assignPublicIp: false diff --git a/crates/oabctl/schema/oabservice-v2.json b/crates/oabctl/schema/oabservice-v2.json new file mode 100644 index 0000000..40feefa --- /dev/null +++ b/crates/oabctl/schema/oabservice-v2.json @@ -0,0 +1,172 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://oab.dev/schemas/oabservice-v2.json", + "title": "OAB Manifest", + "description": "Declarative manifest for OpenAB Service or Fleet (v2)", + "oneOf": [ + { "$ref": "#/$defs/oabService" }, + { "$ref": "#/$defs/oabFleet" } + ], + "$defs": { + "oabService": { + "type": "object", + "required": ["apiVersion", "kind", "metadata", "spec"], + "properties": { + "apiVersion": { "const": "oab.dev/v2" }, + "kind": { "const": "OABService" }, + "metadata": { "$ref": "#/$defs/metadata" }, + "spec": { "$ref": "#/$defs/serviceSpec" } + }, + "additionalProperties": false + }, + "oabFleet": { + "type": "object", + "required": ["apiVersion", "kind", "metadata", "spec"], + "properties": { + "apiVersion": { "const": "oab.dev/v2" }, + "kind": { "const": "OABFleet" }, + "metadata": { "$ref": "#/$defs/fleetMetadata" }, + "spec": { "$ref": "#/$defs/fleetSpec" } + }, + "additionalProperties": false + }, + "metadata": { + "type": "object", + "required": ["name", "namespace"], + "properties": { + "name": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$" }, + "namespace": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$" }, + "generation": { "type": "integer", "minimum": 0 } + }, + "additionalProperties": false + }, + "fleetMetadata": { + "type": "object", + "required": ["name", "namespace"], + "properties": { + "name": { "type": "string" }, + "namespace": { "type": "string" } + }, + "additionalProperties": false + }, + "serviceSpec": { + "type": "object", + "required": ["image", "resources", "configFrom", "runtime"], + "properties": { + "image": { "type": "string" }, + "resources": { "$ref": "#/$defs/resources" }, + "configFrom": { "type": "string" }, + "bootstrapFrom": { "type": "string" }, + "secrets": { "type": "object", "additionalProperties": { "type": "string" } }, + "runtime": { "$ref": "#/$defs/runtime" }, + "ingress": { "$ref": "#/$defs/ingress" } + }, + "additionalProperties": false + }, + "fleetSpec": { + "type": "object", + "required": ["template", "agents"], + "properties": { + "template": { "$ref": "#/$defs/fleetTemplate" }, + "agents": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/agentOverride" } + } + }, + "additionalProperties": false + }, + "fleetTemplate": { + "type": "object", + "required": ["image", "runtime"], + "properties": { + "image": { "type": "string" }, + "resources": { "$ref": "#/$defs/resources" }, + "bootstrapFrom": { "type": "string" }, + "secrets": { "type": "object", "additionalProperties": { "type": "string" } }, + "runtime": { "$ref": "#/$defs/runtime" }, + "ingress": { "$ref": "#/$defs/ingress" } + }, + "additionalProperties": false + }, + "agentOverride": { + "type": "object", + "required": ["name", "configFrom"], + "properties": { + "name": { "type": "string" }, + "configFrom": { "type": "string" }, + "image": { "type": "string" }, + "resources": { "$ref": "#/$defs/resources" }, + "bootstrapFrom": { "type": "string" }, + "secrets": { "type": "object", "additionalProperties": { "type": "string" } }, + "ingress": { "$ref": "#/$defs/ingress" } + }, + "additionalProperties": false + }, + "resources": { + "type": "object", + "required": ["cpu", "memory"], + "properties": { + "cpu": { "type": "string" }, + "memory": { "type": "string" } + }, + "additionalProperties": false + }, + "ingress": { + "type": "object", + "description": "Inbound HTTPS webhook ingress (API Gateway HTTP API + VPC Link + Cloud Map). ECS runtime only.", + "required": ["paths"], + "properties": { + "type": { "type": "string", "enum": ["apigateway"], "default": "apigateway" }, + "cloudMapNamespace": { "type": "string", "default": "oab" }, + "paths": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "pattern": "^/" } + }, + "containerPort": { "type": "integer", "minimum": 1, "maximum": 65535, "default": 8080 } + }, + "additionalProperties": false + }, + "runtime": { + "type": "object", + "required": ["type"], + "oneOf": [ + { "$ref": "#/$defs/ecsRuntime" }, + { "$ref": "#/$defs/kubernetesRuntime" } + ] + }, + "ecsRuntime": { + "type": "object", + "required": ["type", "networking"], + "properties": { + "type": { "const": "ecs" }, + "capacityProvider": { "type": "string", "enum": ["FARGATE", "FARGATE_SPOT"] }, + "architecture": { "type": "string", "enum": ["X86_64", "ARM64"], "default": "X86_64", "description": "CPU architecture for the ECS task. Defaults to X86_64." }, + "taskRoleArn": { "type": "string", "description": "Optional IAM task role ARN. Overrides the bootstrap shared role for per-service IAM isolation. Must have ecs-tasks.amazonaws.com trust policy." }, + "networking": { + "type": "object", + "required": ["subnets", "securityGroups"], + "properties": { + "subnets": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, + "securityGroups": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, + "assignPublicIp": { "type": "boolean" } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "kubernetesRuntime": { + "type": "object", + "required": ["type"], + "properties": { + "type": { "const": "kubernetes" }, + "nodeSelector": { "type": "object", "additionalProperties": { "type": "string" } }, + "serviceAccount": { "type": "string" }, + "tolerations": { "type": "array", "items": { "type": "object" } } + }, + "additionalProperties": false + } + } +} diff --git a/crates/oabctl/src/apply.rs b/crates/oabctl/src/apply.rs new file mode 100644 index 0000000..25e1806 --- /dev/null +++ b/crates/oabctl/src/apply.rs @@ -0,0 +1,1385 @@ +use crate::bootstrap::BootstrapState; +use crate::manifest::{OABFleetManifest, OABServiceManifest, RawManifest, Runtime}; +use anyhow::{Context, Result}; +use aws_sdk_ecs::types::{ + AssignPublicIp, AwsVpcConfiguration, CapacityProviderStrategyItem, ContainerDefinition, + KeyValuePair, NetworkConfiguration, RuntimePlatform, Secret, +}; +use aws_sdk_s3::primitives::ByteStream; +use std::fmt; +use std::path::Path; + +// Progress rendering is scoped to the current async task. Library calls set it +// to false, while CLI/delete callers retain the existing rendering behavior. +tokio::task_local! { + static PROGRESS_ENABLED: bool; +} + +pub(crate) fn progress_enabled() -> bool { + PROGRESS_ENABLED + .try_with(|enabled| *enabled) + .unwrap_or(true) +} + +macro_rules! println { + ($($arg:tt)*) => {{ if progress_enabled() { std::println!($($arg)*); } }}; +} +macro_rules! eprintln { + ($($arg:tt)*) => {{ if progress_enabled() { std::eprintln!($($arg)*); } }}; +} +macro_rules! eprint { + ($($arg:tt)*) => {{ if progress_enabled() { std::eprint!($($arg)*); } }}; +} + +/// Whether a service was created or updated by reconciliation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApplyAction { + Created, + Updated, +} + +/// Stable identity for a service targeted by apply. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ServiceTarget { + pub namespace: String, + pub name: String, + pub ecs_service_name: String, +} + +impl From<&OABServiceManifest> for ServiceTarget { + fn from(manifest: &OABServiceManifest) -> Self { + Self { + namespace: manifest.metadata.namespace.clone(), + name: manifest.metadata.name.clone(), + ecs_service_name: manifest.ecs_service_name(), + } + } +} + +/// Reconciliation outcome for one service. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppliedService { + pub namespace: String, + pub name: String, + pub ecs_service_name: String, + pub action: ApplyAction, + pub webhook_urls: Vec, + pub warnings: Vec, +} + +/// Structured result of a successful (or partially completed) apply. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ApplyReport { + pub services: Vec, +} + +/// High-level phase in which apply failed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApplyErrorKind { + Validation, + Target, + Reconciliation, +} + +/// Structured apply failure. Reconciliation failures identify the failed +/// service and retain the report for all services completed before it. +#[derive(Debug)] +pub struct ApplyError { + pub kind: ApplyErrorKind, + pub failed_service: Option, + pub completed: ApplyReport, + source: anyhow::Error, +} + +impl ApplyError { + fn validation(source: impl Into) -> Self { + Self { + kind: ApplyErrorKind::Validation, + failed_service: None, + completed: ApplyReport::default(), + source: source.into(), + } + } + + fn target(source: impl Into) -> Self { + Self { + kind: ApplyErrorKind::Target, + failed_service: None, + completed: ApplyReport::default(), + source: source.into(), + } + } + + fn reconciliation( + failed_service: ServiceTarget, + completed: ApplyReport, + source: impl Into, + ) -> Self { + Self { + kind: ApplyErrorKind::Reconciliation, + failed_service: Some(failed_service), + completed, + source: source.into(), + } + } + + pub fn source_error(&self) -> &anyhow::Error { + &self.source + } +} + +impl fmt::Display for ApplyError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.failed_service { + Some(service) => write!( + f, + "apply {:?} error for {}/{}: {}", + self.kind, service.namespace, service.name, self.source + ), + None => write!(f, "apply {:?} error: {}", self.kind, self.source), + } + } +} + +impl std::error::Error for ApplyError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(self.source.as_ref()) + } +} + +/// Target options for [`apply_manifests`]. The cluster is deliberately +/// required; the library never guesses a default deployment target. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ApplyOptions { + /// ECS cluster name or ARN. The cluster must already exist and be active. + pub cluster: String, + /// Optional control-plane bucket override. When absent, the library uses + /// `OAB_CONTROL_PLANE_BUCKET`, then derives `oab-control-plane-{account}` + /// from the caller's AWS identity. It never reads CLI home configuration. + pub control_plane_bucket: Option, + /// Wait for every reconciled ECS service to stabilize before returning. + pub wait: bool, +} + +impl ApplyOptions { + /// Create apply options for an explicit ECS cluster. + pub fn new(cluster: impl Into) -> Self { + Self { + cluster: cluster.into(), + control_plane_bucket: None, + wait: false, + } + } + + /// Override the S3 bucket used for bootstrap state and desired manifests. + pub fn with_control_plane_bucket(mut self, bucket: impl Into) -> Self { + self.control_plane_bucket = Some(bucket.into()); + self + } + + /// Configure whether apply waits for ECS deployment stabilization. + pub fn with_wait(mut self, wait: bool) -> Self { + self.wait = wait; + self + } +} + +struct BootstrapResolution { + state: Option, + warning: Option, +} + +struct PreparedApply { + bucket: String, + bootstrap: BootstrapResolution, +} + +async fn load_bootstrap_state(s3: &aws_sdk_s3::Client, bucket: &str) -> BootstrapResolution { + match crate::bootstrap::load_state_pub(s3, bucket).await { + Ok(Some(state)) => BootstrapResolution { + state: Some(state), + warning: None, + }, + Ok(None) => BootstrapResolution { + state: None, + warning: Some(format!( + "no bootstrap state found in s3://{bucket}/bootstrap-state.json (run `oabctl bootstrap` first)" + )), + }, + Err(error) => BootstrapResolution { + state: None, + warning: Some(format!( + "failed to read bootstrap state from s3://{bucket}: {error}" + )), + }, + } +} + +fn validate_apply_request( + manifests: &[OABServiceManifest], + cluster: &str, +) -> std::result::Result<(), ApplyError> { + if manifests.is_empty() { + return Err(ApplyError::validation(anyhow::anyhow!( + "no manifests to apply (empty manifest set)" + ))); + } + if cluster.trim().is_empty() { + return Err(ApplyError::validation(anyhow::anyhow!( + "ApplyOptions.cluster must not be empty or whitespace" + ))); + } + for manifest in manifests { + manifest.validate().map_err(|error| { + ApplyError::validation(error.context(format!( + "invalid manifest {}/{}", + manifest.metadata.namespace, manifest.metadata.name + ))) + })?; + if matches!(&manifest.spec.runtime, Runtime::Kubernetes(_)) { + return Err(ApplyError::validation(anyhow::anyhow!( + "Kubernetes runtime not yet implemented (manifest: {})", + manifest.metadata.name + ))); + } + } + Ok(()) +} + +fn classify_cluster_response( + requested: &str, + clusters: &[(&str, &str, &str)], + failures: &[String], +) -> std::result::Result<(), String> { + if !failures.is_empty() { + return Err(format!( + "ECS rejected cluster '{requested}': {}", + failures.join("; ") + )); + } + let Some((_, _, status)) = clusters + .iter() + .find(|(name, arn, _)| *name == requested || *arn == requested) + else { + return Err(format!( + "ECS cluster '{requested}' was not returned by DescribeClusters" + )); + }; + if *status != "ACTIVE" { + return Err(format!( + "ECS cluster '{requested}' is not reachable for apply (status: {})", + if status.is_empty() { "unknown" } else { status } + )); + } + Ok(()) +} + +async fn validate_cluster( + ecs: &aws_sdk_ecs::Client, + cluster: &str, +) -> std::result::Result<(), ApplyError> { + let response = ecs + .describe_clusters() + .clusters(cluster) + .send() + .await + .map_err(|error| { + ApplyError::target( + anyhow::Error::new(error) + .context(format!("failed to describe ECS cluster '{cluster}'")), + ) + })?; + let clusters: Vec<(&str, &str, &str)> = response + .clusters() + .iter() + .map(|item| { + ( + item.cluster_name().unwrap_or_default(), + item.cluster_arn().unwrap_or_default(), + item.status().unwrap_or_default(), + ) + }) + .collect(); + let failures: Vec = response + .failures() + .iter() + .map(|failure| { + let target = failure.arn().unwrap_or(cluster); + let reason = failure.reason().unwrap_or("unknown failure"); + match failure.detail() { + Some(detail) if !detail.is_empty() => format!("{target}: {reason} ({detail})"), + _ => format!("{target}: {reason}"), + } + }) + .collect(); + classify_cluster_response(cluster, &clusters, &failures) + .map_err(|message| ApplyError::target(anyhow::anyhow!(message))) +} + +async fn prepare_apply( + aws_config: &aws_config::SdkConfig, + ecs: &aws_sdk_ecs::Client, + s3: &aws_sdk_s3::Client, + manifests: &[OABServiceManifest], + cluster: &str, + configured_bucket: Option<&str>, +) -> std::result::Result { + validate_apply_request(manifests, cluster)?; + validate_cluster(ecs, cluster).await?; + let bucket = crate::control_plane::resolve_bucket(aws_config, configured_bucket) + .await + .map_err(ApplyError::target)?; + let bootstrap = load_bootstrap_state(s3, &bucket).await; + Ok(PreparedApply { bucket, bootstrap }) +} + +pub(crate) async fn run( + aws_config: &aws_config::SdkConfig, + file_path: &str, + sync_config: bool, + wait: bool, +) -> Result<()> { + let path = Path::new(file_path); + let manifests = load_manifests(path)?; + let oab_cfg = crate::config::OabConfig::load() + .context("failed to load ~/.oabctl/config.toml (run `oabctl bootstrap` first)")?; + let cluster = &oab_cfg.defaults.cluster; + let ecs = aws_sdk_ecs::Client::new(aws_config); + let s3 = aws_sdk_s3::Client::new(aws_config); + + // Local validation and DescribeClusters happen before config sync or any + // other mutating request. + let prepared = prepare_apply( + aws_config, + &ecs, + &s3, + &manifests, + cluster, + oab_cfg.bootstrap.bucket.as_deref(), + ) + .await?; + + if sync_config { + for manifest in &manifests { + let config_path = path.parent().unwrap_or(Path::new(".")).join("config.toml"); + if config_path.exists() && !manifest.spec.config_from.is_empty() { + let body = ByteStream::from_path(&config_path) + .await + .context("failed to read local config.toml")?; + if let Some(s3_path) = manifest.spec.config_from.strip_prefix("s3://") { + let (bucket, key) = s3_path + .split_once('/') + .context("invalid configFrom S3 URI")?; + s3.put_object() + .bucket(bucket) + .key(key) + .body(body) + .send() + .await + .context("failed to sync config.toml to S3")?; + eprintln!(" ⬆ Synced config.toml → {}", manifest.spec.config_from); + } + } + } + } + + apply_manifests_prepared(aws_config, &ecs, &s3, &manifests, cluster, wait, &prepared).await?; + Ok(()) +} + +/// Validate and reconcile in-memory manifests without writing progress to +/// process-global stdout or stderr. +pub async fn apply_manifests( + aws_config: &aws_config::SdkConfig, + manifests: &[OABServiceManifest], + opts: &ApplyOptions, +) -> std::result::Result { + PROGRESS_ENABLED + .scope(false, async { + validate_apply_request(manifests, &opts.cluster)?; + let ecs = aws_sdk_ecs::Client::new(aws_config); + let s3 = aws_sdk_s3::Client::new(aws_config); + validate_cluster(&ecs, &opts.cluster).await?; + let bucket = crate::control_plane::resolve_bucket( + aws_config, + opts.control_plane_bucket.as_deref(), + ) + .await + .map_err(ApplyError::target)?; + let prepared = PreparedApply { + bootstrap: load_bootstrap_state(&s3, &bucket).await, + bucket, + }; + apply_manifests_prepared( + aws_config, + &ecs, + &s3, + manifests, + &opts.cluster, + opts.wait, + &prepared, + ) + .await + }) + .await +} + +async fn apply_manifests_prepared( + aws_config: &aws_config::SdkConfig, + ecs: &aws_sdk_ecs::Client, + s3: &aws_sdk_s3::Client, + manifests: &[OABServiceManifest], + cluster: &str, + wait: bool, + prepared: &PreparedApply, +) -> std::result::Result { + let mut report = ApplyReport::default(); + for manifest in manifests { + println!(" Applying {} (ECS)...", manifest.metadata.name); + match apply_ecs(ecs, s3, aws_config, manifest, cluster, wait, prepared).await { + Ok(service) => report.services.push(service), + Err(error) => { + return Err(ApplyError::reconciliation( + ServiceTarget::from(manifest), + report, + error, + )); + } + } + } + println!("\n{} service(s) applied.", report.services.len()); + Ok(report) +} + +pub(crate) fn load_manifests(path: &Path) -> Result> { + let mut manifests = Vec::new(); + if path.is_dir() { + for entry in std::fs::read_dir(path)? { + let entry = entry?; + let p = entry.path(); + if p.extension().is_some_and(|e| e == "yaml" || e == "yml") { + manifests.extend(parse_manifest_file(&p)?); + } + } + } else { + manifests.extend(parse_manifest_file(path)?); + } + Ok(manifests) +} + +/// Parse a YAML file — returns one or more OABServiceManifests (fleet expands to many) +fn parse_manifest_file(path: &Path) -> Result> { + let content = std::fs::read_to_string(path) + .with_context(|| format!("failed to read {}", path.display()))?; + + // Detect kind first + let raw: RawManifest = serde_yaml::from_str(&content) + .with_context(|| format!("failed to parse {}", path.display()))?; + + match raw.kind.as_str() { + "OABService" => { + let m: OABServiceManifest = serde_yaml::from_str(&content) + .with_context(|| format!("failed to parse OABService {}", path.display()))?; + Ok(vec![m]) + } + "OABFleet" => { + let fleet: OABFleetManifest = serde_yaml::from_str(&content) + .with_context(|| format!("failed to parse OABFleet {}", path.display()))?; + fleet.validate()?; + println!( + " Fleet '{}': expanding {} agents...", + fleet.metadata.name, + fleet.spec.agents.len() + ); + Ok(fleet.expand()) + } + other => anyhow::bail!("unsupported kind '{}' in {}", other, path.display()), + } +} + +async fn apply_ecs( + ecs: &aws_sdk_ecs::Client, + s3: &aws_sdk_s3::Client, + config: &aws_config::SdkConfig, + m: &OABServiceManifest, + cluster: &str, + wait: bool, + prepared: &PreparedApply, +) -> Result { + let bucket = prepared.bucket.as_str(); + let bootstrap = &prepared.bootstrap; + let ecs_rt = match &m.spec.runtime { + Runtime::Ecs(rt) => rt, + _ => unreachable!(), + }; + + let service_name = m.ecs_service_name(); + let mut warnings = bootstrap.warning.iter().cloned().collect::>(); + if let Some(warning) = &bootstrap.warning { + eprintln!(" ⚠ {warning}"); + } + let bootstrap_state = bootstrap.state.as_ref(); + + // Read current generation from S3 manifest (if exists), increment. + // Also capture whether the *previous* apply had ingress configured, so we + // can detect "ingress was removed from the manifest" and tear it down + // below — apply only ever provisioned ingress resources before this, so a + // manifest edit that drops `spec.ingress` used to orphan the per-bot HTTP + // API and Cloud Map service. + let manifest_key = format!( + "manifests/{}/{}.yaml", + m.metadata.namespace, m.metadata.name + ); + let (current_gen, previously_had_ingress) = match s3 + .get_object() + .bucket(bucket) + .key(&manifest_key) + .send() + .await + { + Ok(resp) => { + let bytes = resp.body.collect().await?.into_bytes(); + let existing: OABServiceManifest = serde_yaml::from_slice(&bytes)?; + ( + existing.metadata.generation, + existing.spec.ingress.is_some(), + ) + } + Err(_) => (0, false), + }; + let generation = current_gen + 1; + + // Look up the ECS service's current registry ARN(s) up front so both the + // ingress-removal teardown below and the update/create logic further down + // can use the *exact* registry rather than falling back to a name-only + // Cloud Map scan (which can collide across VPCs/environments that share + // an account and reuse the same namespace/name). + let describe_resp = ecs + .describe_services() + .cluster(cluster) + .services(&service_name) + .send() + .await + .context("failed to describe ECS service")?; + let existing_registry_arns: Vec = describe_resp + .services() + .first() + .map(|service| { + service + .service_registries() + .iter() + .filter_map(|registry| registry.registry_arn()) + .map(str::to_owned) + .collect() + }) + .unwrap_or_default(); + let has_registries = !existing_registry_arns.is_empty(); + + // If ingress was configured before but is absent now, tear down the + // orphaned per-bot ingress resources (best-effort, mirrors `oabctl delete`) + // and detach the stale registry from the ECS service itself — omitting + // `serviceRegistries` on `UpdateService` leaves the existing configuration + // untouched (AWS only clears it when explicitly passed an empty list), so + // without this the service would keep pointing at a Cloud Map service that + // teardown() is about to delete. + if previously_had_ingress && m.spec.ingress.is_none() { + eprintln!(" 🌐 ingress removed from manifest — tearing down orphaned resources..."); + match crate::ingress::teardown( + config, + &m.metadata.namespace, + &m.metadata.name, + existing_registry_arns.first().map(String::as_str), + ) + .await + { + Ok(teardown_warnings) => warnings.extend(teardown_warnings), + Err(error) => { + let warning = format!("ingress teardown skipped: {error}"); + eprintln!(" ⚠ {warning}"); + warnings.push(warning); + } + } + } + + // 1. Upload manifest to S3 (record of desired state) + let mut manifest_to_store = serde_yaml::to_value(m)?; + manifest_to_store["metadata"]["generation"] = serde_yaml::Value::Number(generation.into()); + let manifest_yaml = serde_yaml::to_string(&manifest_to_store)?; + s3.put_object() + .bucket(bucket) + .key(&manifest_key) + .body(ByteStream::from(manifest_yaml.into_bytes())) + .send() + .await + .context("failed to upload manifest to S3")?; + + // 2. Build environment variables + let mut env_vars = vec![ + KeyValuePair::builder() + .name("NAMESPACE") + .value(&m.metadata.namespace) + .build(), + KeyValuePair::builder() + .name("NAME") + .value(&m.metadata.name) + .build(), + ]; + // openab's own AWS SDK calls (config-s3 loading, secrets resolution, etc.) + // resolve region via the standard chain: AWS_REGION env var → profile → + // IMDS. Fargate tasks have no EC2 instance metadata to fall back to, so + // without this the SDK can fail to resolve an endpoint at all. + // Region is injected below after bootstrap_state is loaded (to allow + // fallback to bootstrap_state.region when config.region() is None). + if let Some(ref bootstrap) = m.spec.bootstrap_from { + env_vars.push( + KeyValuePair::builder() + .name("BOOTSTRAP_FROM") + .value(bootstrap) + .build(), + ); + } + + // 3. Build secrets from map. Values can be either the ECS-native + // `valueFrom` format directly (a Secrets Manager ARN, optionally with + // a `:::` suffix), or the same `aws-sm://#` + // shorthand openab itself uses for in-app secret refs — resolved here + // into the ECS-native form ECS actually requires, since ECS has no + // knowledge of that scheme. + let sm = aws_sdk_secretsmanager::Client::new(config); + let mut secrets: Vec = Vec::with_capacity(m.spec.secrets.len()); + for (name, value) in &m.spec.secrets { + let value_from = crate::secrets::resolve_value_from(&sm, value).await?; + secrets.push( + Secret::builder() + .name(name) + .value_from(value_from) + .build() + .unwrap(), + ); + } + + // Resolve effective region: prefer SDK config, fall back to bootstrap + // state's recorded region. Fargate has no IMDS, so without AWS_REGION the + // container's SDK calls will fail to resolve endpoints entirely. + let effective_region: Option = config + .region() + .map(|r| r.as_ref().to_string()) + .or_else(|| bootstrap_state.as_ref().map(|s| s.region.clone())); + if let Some(ref region) = effective_region { + env_vars.push( + KeyValuePair::builder() + .name("AWS_REGION") + .value(region) + .build(), + ); + } + + let mut container = ContainerDefinition::builder() + .name("openab") + .image(&m.spec.image) + .essential(true) + .set_environment(Some(env_vars)) + .set_secrets(if secrets.is_empty() { + None + } else { + Some(secrets) + }); + + // The image's default CMD points `openab` at a local + // /etc/openab/config.toml that nothing populates. openab has native + // s3:// config-source support (built with the `config-s3` feature, + // included in the default feature set + `unified`), so override the + // command to load configFrom directly instead — no download step, + // sidecar, or entrypoint script needed. Uses the task role's existing + // s3:GetObject grant on `{bucket}/artifacts/*`. + if !m.spec.config_from.is_empty() { + container = container.set_command(Some(vec![ + "openab".to_string(), + "run".to_string(), + "-c".to_string(), + m.spec.config_from.clone(), + ])); + } + + // Ship container stdout/stderr to the log group bootstrap created, so a + // crashing/misbehaving container is actually diagnosable. Without this, + // ECS uses no log driver and task failures are opaque (no log stream at + // all, not even an empty one). + if let Some(log_group) = bootstrap_state.as_ref().map(|s| &s.resources.log_group) { + if let Some(ref region) = effective_region { + container = container.log_configuration( + aws_sdk_ecs::types::LogConfiguration::builder() + .log_driver(aws_sdk_ecs::types::LogDriver::Awslogs) + .options("awslogs-group", log_group.as_str()) + .options("awslogs-region", region.as_str()) + .options("awslogs-stream-prefix", &service_name) + .options("awslogs-create-group", "true") + .build()?, + ); + } + } + + // Ingress needs the container port exposed so ECS can register an SRV record + // (Cloud Map + API Gateway learn the target port from it). + if let Some(ingress) = &m.spec.ingress { + container = container.port_mappings( + aws_sdk_ecs::types::PortMapping::builder() + .container_port(ingress.container_port as i32) + .protocol(aws_sdk_ecs::types::TransportProtocol::Tcp) + .build(), + ); + } + + let container = container.build(); + + // ECS requires executionRoleArn whenever the task definition uses + // container secrets (or a private registry) — resolve it from bootstrap + // state rather than requiring it in the manifest, matching how the + // task role / cluster / subnets are already sourced from bootstrap. + // + // taskRoleArn is separate and equally required: ECS only provisions the + // AWS_CONTAINER_CREDENTIALS_RELATIVE_URI endpoint (and injects that env + // var into the container) when a task role is set on the task + // definition. Without it, the running `openab` process has no AWS + // credentials at all for its own SDK calls (fetching configFrom from S3, + // resolving spec.secrets values via aws-sm:// refs, etc.) — it falls + // through envvar/profile/webidentity/ECS providers and finally tries + // IMDS, which doesn't exist on Fargate, and fails with a generic + // "dispatch failure". This was previously never set at all. + let execution_role_arn = bootstrap_state + .as_ref() + .map(|s| s.resources.execution_role_arn.clone()); + // Manifest task_role_arn takes precedence over bootstrap shared role. + // Filter empty strings so a blank value falls through to bootstrap. + let task_role_arn = resolve_task_role_arn( + &ecs_rt.task_role_arn, + bootstrap_state + .as_ref() + .map(|s| s.resources.task_role_arn.as_str()), + ); + match ( + &task_role_arn, + ecs_rt.task_role_arn.as_deref().filter(|s| !s.is_empty()), + ) { + (Some(arn), Some(_)) => eprintln!(" ℹ taskRoleArn: {arn} (from manifest)"), + (Some(arn), None) => eprintln!(" ℹ taskRoleArn: {arn} (from bootstrap)"), + (None, _) => eprintln!(" ⚠ no taskRoleArn resolved — task will have no IAM role"), + } + + let mut register_req = ecs + .register_task_definition() + .family(&service_name) + .requires_compatibilities(aws_sdk_ecs::types::Compatibility::Fargate) + .network_mode(aws_sdk_ecs::types::NetworkMode::Awsvpc) + .cpu(&m.spec.resources.cpu) + .memory(&m.spec.resources.memory) + .container_definitions(container); + if let Some(arn) = &execution_role_arn { + register_req = register_req.execution_role_arn(arn); + } else if !m.spec.secrets.is_empty() { + anyhow::bail!( + "spec.secrets is set but no bootstrap execution role was found — run `oabctl bootstrap` first, or ECS will reject task registration" + ); + } + if let Some(arn) = &task_role_arn { + register_req = register_req.task_role_arn(arn); + } else { + anyhow::bail!( + "no bootstrap task role was found — run `oabctl bootstrap` first, or the running container will have no AWS credentials" + ); + } + + // Set runtime platform (OS + CPU architecture) — required for Fargate to + // schedule on Graviton (ARM64) vs Intel/AMD (X86_64). + let cpu_arch = match ecs_rt.architecture.as_str() { + "ARM64" => aws_sdk_ecs::types::CpuArchitecture::Arm64, + "X86_64" => aws_sdk_ecs::types::CpuArchitecture::X8664, + other => anyhow::bail!( + "unsupported architecture '{other}' — should be caught by manifest validation" + ), + }; + register_req = register_req.runtime_platform( + RuntimePlatform::builder() + .operating_system_family(aws_sdk_ecs::types::OsFamily::Linux) + .cpu_architecture(cpu_arch) + .build(), + ); + + let task_def = register_req + .send() + .await + .context("failed to register task definition")?; + + let task_def_arn = task_def + .task_definition() + .and_then(|td| td.task_definition_arn()) + .unwrap_or_default() + .to_string(); + + // 5. Create or update ECS service + let assign_ip = if ecs_rt.networking.assign_public_ip { + AssignPublicIp::Enabled + } else { + AssignPublicIp::Disabled + }; + + let vpc_config = AwsVpcConfiguration::builder() + .set_subnets(Some(ecs_rt.networking.subnets.clone())) + .set_security_groups(Some(ecs_rt.networking.security_groups.clone())) + .assign_public_ip(assign_ip) + .build()?; + + let network_config = NetworkConfiguration::builder() + .awsvpc_configuration(vpc_config) + .build(); + + // Ingress: ensure Cloud Map BEFORE the service exists-check, so the + // registry ARN is ready whether the ECS service needs to be created (via + // `create_service`) or updated to attach/replace service discovery (via + // `update_service` — ECS has supported changing `serviceRegistries` on an + // existing service since March 2022; no delete-and-recreate is needed). + let cloud_map = if let Some(ingress) = &m.spec.ingress { + eprintln!(" 🌐 Reconciling ingress (Cloud Map)..."); + let cm = crate::ingress::ensure_cloud_map(config, m, ingress).await?; + Some(cm) + } else { + None + }; + + // Check if service exists. Reuses `describe_resp` captured above (before + // the ingress-removal teardown) — `ensure_cloud_map` above doesn't touch + // the ECS service, so its ACTIVE status can't have changed since then. + let service_active = describe_resp + .services() + .first() + .is_some_and(|service| service.status() == Some("ACTIVE")); + let action; + + if service_active { + action = ApplyAction::Updated; + // Recreate is NOT required to attach/fix service discovery: ECS's + // UpdateService API has supported adding/updating/removing + // serviceRegistries since March 2022 (rolling replacement — new tasks + // start with the updated registry, old tasks stop once they're + // healthy, no downtime gap). It does require the AWSServiceRoleForECS + // service-linked role, which ECS creates automatically the first time + // any account uses ECS service discovery — no action needed here. + let registry_mismatch = cloud_map + .as_ref() + .is_some_and(|cm| has_registries && !existing_registry_arns.contains(&cm.registry_arn)); + // `ingress` was removed from the manifest (cloud_map is None here) + // but the ECS service still has a registry attached from a previous + // apply — must explicitly detach it. `UpdateService` treats an + // *omitted* `serviceRegistries` field as "leave unchanged", not + // "clear"; only an explicit empty list detaches it. Without this the + // service keeps pointing at the Cloud Map service that the + // ingress-removal teardown (above) just deleted. + let needs_detach = cloud_map.is_none() && has_registries; + + let mut update_req = ecs + .update_service() + .cluster(cluster) + .service(&service_name) + .task_definition(&task_def_arn) + .enable_execute_command(true) + .network_configuration(network_config); + + if let Some(cm) = &cloud_map { + if !has_registries || registry_mismatch { + let mut registry = + aws_sdk_ecs::types::ServiceRegistry::builder().registry_arn(&cm.registry_arn); + if let Some(ingress) = &m.spec.ingress { + registry = registry + .container_name("openab") + .container_port(ingress.container_port as i32); + } + update_req = update_req.service_registries(registry.build()); + } + } else if needs_detach { + update_req = update_req.set_service_registries(Some(Vec::new())); + } + + update_req + .send() + .await + .context("failed to update ECS service")?; + + if cloud_map.is_some() && (!has_registries || registry_mismatch) { + if registry_mismatch { + println!( + " ✓ {} updated (service discovery re-pointed to the current Cloud Map service; rolling replacement, no downtime)", + m.metadata.name + ); + } else { + println!( + " ✓ {} updated (service discovery attached; rolling replacement, no downtime)", + m.metadata.name + ); + } + } else if needs_detach { + println!( + " ✓ {} updated (service discovery detached; rolling replacement, no downtime)", + m.metadata.name + ); + } else { + println!(" ✓ {} updated", m.metadata.name); + } + } else { + action = ApplyAction::Created; + let cap_strategy = CapacityProviderStrategyItem::builder() + .capacity_provider(&ecs_rt.capacity_provider) + .weight(1) + .build()?; + + let mut create_req = ecs + .create_service() + .cluster(cluster) + .service_name(&service_name) + .task_definition(&task_def_arn) + .desired_count(1) + .enable_execute_command(true) + .capacity_provider_strategy(cap_strategy) + .network_configuration(network_config); + + if let Some(cm) = &cloud_map { + let mut registry = + aws_sdk_ecs::types::ServiceRegistry::builder().registry_arn(&cm.registry_arn); + // SRV records require the container name + port so ECS registers the + // task's port alongside its IP. + if let Some(ingress) = &m.spec.ingress { + registry = registry + .container_name("openab") + .container_port(ingress.container_port as i32); + } + create_req = create_req.service_registries(registry.build()); + } + + // Retry with backoff if ECS reports "still Draining" (race with a + // recent delete that hasn't fully completed yet). + // Match on the typed error code (InvalidParameterException) rather than + // raw message text to be resilient to SDK/API wording changes. + use aws_sdk_ecs::error::ProvideErrorMetadata; + const DRAIN_RETRY_ATTEMPTS: u32 = 12; + const DRAIN_RETRY_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5); + + for attempt in 0..DRAIN_RETRY_ATTEMPTS { + match create_req.clone().send().await { + Ok(_) => { + if attempt > 0 { + eprintln!(" ok"); + } + break; + } + Err(e) => { + let is_draining = e.code() == Some("InvalidParameterException") + && e.message() + .unwrap_or_default() + .to_lowercase() + .contains("draining"); + let is_last = attempt == DRAIN_RETRY_ATTEMPTS - 1; + if is_draining && !is_last { + if attempt == 0 { + eprint!(" ⏳ Service still draining, retrying..."); + } else { + eprint!("."); + } + tokio::time::sleep(DRAIN_RETRY_INTERVAL).await; + } else { + if attempt > 0 { + eprintln!(" failed"); + } + let ctx = if is_last && is_draining { + "failed to create ECS service after retries (service still draining)" + } else { + "failed to create ECS service" + }; + return Err(e).context(ctx); + } + } + } + } + println!( + " ✓ {} created ({}, {}cpu/{}mem{})", + m.metadata.name, + ecs_rt.capacity_provider, + m.spec.resources.cpu, + m.spec.resources.memory, + if cloud_map.is_some() { + ", service discovery" + } else { + "" + } + ); + } + + // Ingress step 2: VPC Link + API Gateway + routes + SG rule. + let mut webhook_urls = Vec::new(); + if let (Some(ingress), Some(cm)) = (&m.spec.ingress, &cloud_map) { + eprintln!(" 🌐 Reconciling ingress (VPC Link + API Gateway)..."); + let gateway = crate::ingress::ensure_gateway( + config, + &m.metadata.namespace, + &m.metadata.name, + ingress, + &ecs_rt.networking.subnets, + &ecs_rt.networking.security_groups, + &cm.registry_arn, + ) + .await?; + webhook_urls = gateway.webhook_urls; + warnings.extend(gateway.warnings); + println!(" 🔗 Webhook URL(s) for {}:", m.metadata.name); + for url in &webhook_urls { + println!(" {url}"); + } + + let path_urls: Vec<(String, String)> = ingress + .paths + .iter() + .cloned() + .zip(webhook_urls.iter().cloned()) + .collect(); + match crate::ingress::register_telegram_webhook(config, &m.spec.secrets, &path_urls).await { + Ok(Some(description)) => { + eprintln!(" ✓ Telegram webhook registered: {description}") + } + Ok(None) => {} + Err(error) => { + let warning = format!( + "Telegram webhook registration failed (apply still succeeded): {error}" + ); + eprintln!(" ⚠ {warning}"); + warnings.push(warning); + } + } + } + + if wait { + eprintln!(" ⏳ Waiting for {} to stabilize...", m.metadata.name); + wait_for_stable(ecs, cluster, &service_name).await?; + eprintln!(" ✓ {} is stable", m.metadata.name); + } + + Ok(AppliedService { + namespace: m.metadata.namespace.clone(), + name: m.metadata.name.clone(), + ecs_service_name: service_name, + action, + webhook_urls, + warnings, + }) +} + +/// Poll until the ECS service's deployment stabilizes, printing each +/// transition as a composite status string — same vocabulary `ecsctl` +/// itself uses for `get`/`alias ls` (github.com/oablab/ecsctl, +/// src/alias.rs): `RUNNING`, `REPLACING(n→m)` (new deployment's tasks still +/// coming up), `DRAINING(n+m)` (new deployment up, old one's tasks still +/// stopping), `PENDING(n)`, `PARTIAL(n/m)`, or the raw ECS service status as +/// a fallback — reused here for a consistent status vocabulary across both +/// tools instead of raw `running_count`/`rollout_state` fields. +async fn wait_for_stable(ecs: &aws_sdk_ecs::Client, cluster: &str, service: &str) -> Result<()> { + for i in 0..60 { + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + let resp = ecs + .describe_services() + .cluster(cluster) + .services(service) + .send() + .await?; + let elapsed = (i + 1) * 5; + + let Some(svc) = resp.services().first() else { + eprintln!(" [{elapsed}s] service not found in describe-services response yet"); + continue; + }; + + let running = svc.running_count() as usize; + let desired = svc.desired_count() as usize; + let pending = svc.pending_count() as usize; + let deployments = svc.deployments(); + let num_deployments = deployments.len(); + let primary = deployments + .iter() + .find(|d| d.status().unwrap_or_default() == "PRIMARY") + .or_else(|| deployments.first()); + + let status = if desired == 0 { + "STOPPED".to_string() + } else if running == desired && pending == 0 && num_deployments <= 1 { + "RUNNING".to_string() + } else if num_deployments > 1 { + if let Some(p) = primary { + let p_running = p.running_count() as usize; + let p_desired = p.desired_count() as usize; + if p_running < p_desired { + format!("REPLACING({p_running}→{p_desired})") + } else { + let old_running: usize = deployments + .iter() + .filter(|d| d.status().unwrap_or_default() != "PRIMARY") + .map(|d| d.running_count() as usize) + .sum(); + format!("DRAINING({p_running}+{old_running})") + } + } else { + svc.status().unwrap_or("UNKNOWN").to_string() + } + } else if pending > 0 { + format!("PENDING({pending})") + } else if running < desired { + format!("PARTIAL({running}/{desired})") + } else { + svc.status().unwrap_or("UNKNOWN").to_string() + }; + + eprintln!(" [{elapsed}s] {status}"); + + if status == "RUNNING" { + return Ok(()); + } + } + anyhow::bail!("timed out waiting for service to stabilize (5 min)") +} + +/// Resolve the effective task role ARN. +/// +/// Resolution order: +/// 1. Manifest `taskRoleArn` (if present and non-empty) → use it +/// 2. Bootstrap shared task role → fallback +/// 3. Neither → `None` +fn resolve_task_role_arn( + manifest_role: &Option, + bootstrap_role: Option<&str>, +) -> Option { + manifest_role + .clone() + .filter(|s| !s.is_empty()) + .or_else(|| bootstrap_role.map(|s| s.to_owned())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn apply_options_accepts_explicit_control_plane_bucket() { + let options = ApplyOptions::new("prod-cluster") + .with_control_plane_bucket("prod-control-plane") + .with_wait(true); + assert_eq!(options.cluster, "prod-cluster"); + assert_eq!( + options.control_plane_bucket.as_deref(), + Some("prod-control-plane") + ); + assert!(options.wait); + } + + fn test_sdk_config() -> aws_config::SdkConfig { + aws_config::SdkConfig::builder() + .behavior_version(aws_config::BehaviorVersion::latest()) + .build() + } + + fn minimal_manifest() -> OABServiceManifest { + serde_yaml::from_str( + r#" +apiVersion: oab.dev/v2 +kind: OABService +metadata: + name: test-svc + namespace: test +spec: + image: example.com/openab:latest + resources: + cpu: "256" + memory: "512" + configFrom: s3://bucket/config.toml + runtime: + type: ecs + networking: + subnets: [subnet-aaa] + securityGroups: [sg-aaa] +"#, + ) + .expect("valid manifest") + } + + #[tokio::test] + async fn programmatic_apply_progress_scope_is_disabled() { + PROGRESS_ENABLED + .scope(false, async { assert!(!progress_enabled()) }) + .await; + assert!(progress_enabled()); + } + + #[tokio::test] + async fn apply_manifests_rejects_empty_set() { + let cfg = test_sdk_config(); + let err = apply_manifests(&cfg, &[], &ApplyOptions::new("test-cluster")) + .await + .unwrap_err(); + assert_eq!(err.kind, ApplyErrorKind::Validation); + assert!(err.to_string().contains("empty manifest set")); + } + + #[tokio::test] + async fn apply_manifests_rejects_empty_cluster_name_locally() { + let cfg = test_sdk_config(); + let manifest = minimal_manifest(); + let err = apply_manifests(&cfg, &[manifest], &ApplyOptions::new("")) + .await + .unwrap_err(); + assert_eq!(err.kind, ApplyErrorKind::Validation); + assert!(err.to_string().contains("empty or whitespace")); + } + + #[tokio::test] + async fn apply_manifests_rejects_whitespace_cluster_name_locally() { + let cfg = test_sdk_config(); + let manifest = minimal_manifest(); + let err = apply_manifests(&cfg, &[manifest], &ApplyOptions::new(" \t\n")) + .await + .unwrap_err(); + assert_eq!(err.kind, ApplyErrorKind::Validation); + assert!(err.to_string().contains("empty or whitespace")); + } + + #[test] + fn cluster_response_accepts_requested_active_cluster() { + assert!(classify_cluster_response( + "prod", + &[("prod", "arn:aws:ecs:us-east-1:123:cluster/prod", "ACTIVE")], + &[], + ) + .is_ok()); + } + + #[test] + fn cluster_response_accepts_requested_cluster_arn() { + let arn = "arn:aws:ecs:us-east-1:123:cluster/prod"; + assert!(classify_cluster_response(arn, &[("prod", arn, "ACTIVE")], &[],).is_ok()); + } + + #[test] + fn cluster_response_rejects_empty_cluster_list() { + let error = classify_cluster_response("prod", &[], &[]).unwrap_err(); + assert!(error.contains("not returned")); + } + + #[test] + fn cluster_response_rejects_service_failures() { + let error = + classify_cluster_response("prod", &[], &["prod: MISSING".to_string()]).unwrap_err(); + assert!(error.contains("MISSING")); + } + + #[test] + fn cluster_response_rejects_inactive_cluster() { + let error = classify_cluster_response( + "prod", + &[("prod", "arn:aws:ecs:us-east-1:123:cluster/prod", "INACTIVE")], + &[], + ) + .unwrap_err(); + assert!(error.contains("INACTIVE")); + } + + #[test] + fn reconciliation_error_exposes_failed_service_and_completed_report() { + let completed_service = AppliedService { + namespace: "prod".to_string(), + name: "done".to_string(), + ecs_service_name: "oab-prod-done".to_string(), + action: ApplyAction::Updated, + webhook_urls: vec!["https://example.test/webhook".to_string()], + warnings: vec!["degraded".to_string()], + }; + let completed = ApplyReport { + services: vec![completed_service.clone()], + }; + let failed = ServiceTarget { + namespace: "prod".to_string(), + name: "failed".to_string(), + ecs_service_name: "oab-prod-failed".to_string(), + }; + let error = + ApplyError::reconciliation(failed.clone(), completed.clone(), anyhow::anyhow!("boom")); + assert_eq!(error.kind, ApplyErrorKind::Reconciliation); + assert_eq!(error.failed_service, Some(failed)); + assert_eq!(error.completed, completed); + assert_eq!(error.completed.services[0], completed_service); + } + + #[test] + fn apply_error_source_preserves_immediate_anyhow_context() { + let error = ApplyError::target(anyhow::anyhow!("root cause").context("target lookup")); + let source = std::error::Error::source(&error).expect("apply error source"); + assert_eq!(source.to_string(), "target lookup"); + assert_eq!( + source.source().expect("root source").to_string(), + "root cause" + ); + } + + #[test] + fn task_role_manifest_wins_over_bootstrap() { + let manifest = Some("arn:aws:iam::111:role/manifest-role".to_string()); + let bootstrap = Some("arn:aws:iam::111:role/bootstrap-role"); + let result = resolve_task_role_arn(&manifest, bootstrap); + assert_eq!( + result.as_deref(), + Some("arn:aws:iam::111:role/manifest-role") + ); + } + + #[test] + fn task_role_falls_back_to_bootstrap() { + let manifest: Option = None; + let bootstrap = Some("arn:aws:iam::111:role/bootstrap-role"); + let result = resolve_task_role_arn(&manifest, bootstrap); + assert_eq!( + result.as_deref(), + Some("arn:aws:iam::111:role/bootstrap-role") + ); + } + + #[test] + fn task_role_manifest_only_no_bootstrap() { + let manifest = Some("arn:aws:iam::111:role/manifest-role".to_string()); + let bootstrap: Option<&str> = None; + let result = resolve_task_role_arn(&manifest, bootstrap); + assert_eq!( + result.as_deref(), + Some("arn:aws:iam::111:role/manifest-role") + ); + } + + #[test] + fn task_role_none_when_both_absent() { + let manifest: Option = None; + let bootstrap: Option<&str> = None; + let result = resolve_task_role_arn(&manifest, bootstrap); + assert_eq!(result, None); + } + + #[test] + fn task_role_empty_string_falls_through_to_bootstrap() { + let manifest = Some("".to_string()); + let bootstrap = Some("arn:aws:iam::111:role/bootstrap-role"); + let result = resolve_task_role_arn(&manifest, bootstrap); + assert_eq!( + result.as_deref(), + Some("arn:aws:iam::111:role/bootstrap-role"), + "empty string in manifest should not override bootstrap" + ); + } + + #[test] + fn task_role_empty_string_no_bootstrap_returns_none() { + let manifest = Some("".to_string()); + let bootstrap: Option<&str> = None; + let result = resolve_task_role_arn(&manifest, bootstrap); + assert_eq!(result, None); + } +} diff --git a/crates/oabctl/src/bootstrap.rs b/crates/oabctl/src/bootstrap.rs new file mode 100644 index 0000000..ae98c10 --- /dev/null +++ b/crates/oabctl/src/bootstrap.rs @@ -0,0 +1,588 @@ +use anyhow::{Context, Result}; +use aws_sdk_cloudwatchlogs::Client as LogsClient; +use aws_sdk_ec2::Client as Ec2Client; +use aws_sdk_ecs::Client as EcsClient; +use aws_sdk_iam::Client as IamClient; +use aws_sdk_s3::Client as S3Client; +use aws_sdk_sts::Client as StsClient; +use serde::{Deserialize, Serialize}; + +const CLUSTER_NAME: &str = "oab"; +const EXECUTION_ROLE: &str = "oab-task-execution"; +const TASK_ROLE: &str = "oab-task-role"; +const SG_NAME: &str = "oab-agents"; +const LOG_GROUP: &str = "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/oab/agents"; +const STATE_KEY: &str = "bootstrap/state.json"; + +const ASSUME_ROLE_POLICY: &str = r#"{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"Service": "ecs-tasks.amazonaws.com"}, + "Action": "sts:AssumeRole" + }] +}"#; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BootstrapState { + pub version: u32, + pub account: String, + pub region: String, + pub bucket: String, + pub resources: BootstrapResources, + pub managed: ManagedFlags, + pub created_at: String, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BootstrapResources { + pub cluster_arn: String, + pub execution_role_arn: String, + pub task_role_arn: String, + pub security_group_id: String, + pub log_group: String, + pub subnets: Vec, + pub vpc_id: String, +} + +/// Tracks which resources were created by bootstrap vs imported +#[derive(Debug, Serialize, Deserialize, Default, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ManagedFlags { + pub cluster: bool, + pub execution_role: bool, + pub task_role: bool, + pub security_group: bool, + pub log_group: bool, + pub bucket: bool, +} + +/// Options to import existing resources instead of creating new ones +#[derive(Default)] +pub struct ImportOptions { + pub cluster: Option, + pub vpc: Option, + pub subnets: Option>, + pub security_group: Option, + pub execution_role: Option, + pub task_role: Option, +} + +pub async fn run(config: &aws_config::SdkConfig, delete: bool, status: bool, imports: ImportOptions) -> Result<()> { + if status { + return show_status(config).await; + } + if delete { + return teardown(config).await; + } + create(config, imports).await +} + +async fn get_account_and_region(config: &aws_config::SdkConfig) -> Result<(String, String)> { + let sts = StsClient::new(config); + let identity = sts.get_caller_identity().send().await?; + let account = identity.account().context("no account ID")?.to_string(); + let region = config.region().map(|r| r.to_string()).unwrap_or_else(|| "us-east-1".to_string()); + Ok((account, region)) +} + +fn bucket_name(account: &str) -> String { + format!("oab-control-plane-{account}") +} + +async fn load_state(s3: &S3Client, bucket: &str) -> Result> { + match s3.get_object().bucket(bucket).key(STATE_KEY).send().await { + Ok(resp) => { + let bytes = resp.body.collect().await?.into_bytes(); + let state: BootstrapState = serde_json::from_slice(&bytes)?; + Ok(Some(state)) + } + Err(_) => Ok(None), + } +} + +/// Public accessor for other modules +pub async fn load_state_pub(s3: &S3Client, bucket: &str) -> Result> { + load_state(s3, bucket).await +} + +async fn save_state(s3: &S3Client, bucket: &str, state: &BootstrapState) -> Result<()> { + let json = serde_json::to_string_pretty(state)?; + s3.put_object() + .bucket(bucket) + .key(STATE_KEY) + .body(json.into_bytes().into()) + .content_type("application/json") + .send() + .await + .context("failed to save bootstrap state")?; + Ok(()) +} + +// ─── CREATE ─────────────────────────────────────────────────────────────────── + +async fn create(config: &aws_config::SdkConfig, imports: ImportOptions) -> Result<()> { + let (account, region) = get_account_and_region(config).await?; + let bucket = bucket_name(&account); + let mut managed = ManagedFlags::default(); + + let ecs = EcsClient::new(config); + let iam = IamClient::new(config); + let s3 = S3Client::new(config); + let ec2 = Ec2Client::new(config); + let logs = LogsClient::new(config); + + // ─── PLAN PHASE: check existing resources ───────────────────────────── + eprintln!("📋 Planning bootstrap for {region} (account: {account})...\n"); + + let bucket_exists = s3.head_bucket().bucket(&bucket).send().await.is_ok(); + let cluster_name = imports.cluster.as_deref().unwrap_or(CLUSTER_NAME); + let cluster_exists = ecs.describe_clusters().clusters(cluster_name).send().await + .map(|r| r.clusters().first().is_some_and(|c| c.status() == Some("ACTIVE"))) + .unwrap_or(false); + let exec_role_exists = imports.execution_role.is_some() + || iam.get_role().role_name(EXECUTION_ROLE).send().await.is_ok(); + let task_role_exists = imports.task_role.is_some() + || iam.get_role().role_name(TASK_ROLE).send().await.is_ok(); + + let vpc_id_for_check = if let Some(ref v) = imports.vpc { + v.clone() + } else { + ec2.describe_vpcs() + .filters(aws_sdk_ec2::types::Filter::builder().name("isDefault").values("true").build()) + .send().await.ok() + .and_then(|r| r.vpcs().first().and_then(|v| v.vpc_id()).map(|s| s.to_string())) + .unwrap_or_default() + }; + let sg_exists = imports.security_group.is_some() + || ec2.describe_security_groups() + .filters(aws_sdk_ec2::types::Filter::builder().name("group-name").values(SG_NAME).build()) + .filters(aws_sdk_ec2::types::Filter::builder().name("vpc-id").values(&vpc_id_for_check).build()) + .send().await + .map(|r| !r.security_groups().is_empty()) + .unwrap_or(false); + let log_group_exists = logs.describe_log_groups() + .log_group_name_prefix(LOG_GROUP) + .send().await + .map(|r| r.log_groups().iter().any(|g| g.log_group_name() == Some(LOG_GROUP))) + .unwrap_or(false); + + // ─── DISPLAY PLAN ───────────────────────────────────────────────────── + eprintln!(" Resource Action"); + eprintln!(" ─────────────────────────────────────────"); + plan_line("S3 Bucket", &bucket, bucket_exists, true); + plan_line("ECS Cluster", cluster_name, cluster_exists, imports.cluster.is_none()); + plan_line("IAM Execution Role", imports.execution_role.as_deref().unwrap_or(EXECUTION_ROLE), exec_role_exists, imports.execution_role.is_none()); + plan_line("IAM Task Role", imports.task_role.as_deref().unwrap_or(TASK_ROLE), task_role_exists, imports.task_role.is_none()); + plan_line("Security Group", imports.security_group.as_deref().unwrap_or(SG_NAME), sg_exists, imports.security_group.is_none()); + plan_line("CloudWatch Log Group", LOG_GROUP, log_group_exists, true); + eprintln!(); + + // ─── CONFIRM ────────────────────────────────────────────────────────── + eprint!("Proceed? [Y/n] "); + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + let input = input.trim().to_lowercase(); + if !input.is_empty() && input != "y" && input != "yes" { + eprintln!("Aborted."); + return Ok(()); + } + + eprintln!("\n🚀 Bootstrapping...\n"); + + // 1. S3 Bucket + if s3.head_bucket().bucket(&bucket).send().await.is_ok() { + eprintln!(" ✓ S3 bucket already exists: {bucket}"); + } else { + let mut req = s3.create_bucket().bucket(&bucket); + if region != "us-east-1" { + req = req.create_bucket_configuration( + aws_sdk_s3::types::CreateBucketConfiguration::builder() + .location_constraint(region.parse().unwrap()) + .build(), + ); + } + req.send().await.context("failed to create S3 bucket")?; + // Block public access + s3.put_public_access_block() + .bucket(&bucket) + .public_access_block_configuration( + aws_sdk_s3::types::PublicAccessBlockConfiguration::builder() + .block_public_acls(true) + .ignore_public_acls(true) + .block_public_policy(true) + .restrict_public_buckets(true) + .build(), + ) + .send().await.ok(); + eprintln!(" ✓ Created S3 bucket: {bucket} (public access blocked)"); + managed.bucket = true; + } + + // 2. ECS Cluster — save state incrementally after this point + let (cluster_arn, cluster_managed) = if let Some(ref name) = imports.cluster { + let resp = ecs.describe_clusters().clusters(name).send().await?; + let arn = resp.clusters().first() + .and_then(|c| c.cluster_arn()) + .context(format!("cluster '{}' not found", name))? + .to_string(); + eprintln!(" ✓ Using existing cluster: {name}"); + (arn, false) + } else { + match ecs.describe_clusters().clusters(CLUSTER_NAME).send().await { + Ok(resp) if resp.clusters().first().is_some_and(|c| c.status() == Some("ACTIVE")) => { + let arn = resp.clusters()[0].cluster_arn().unwrap_or_default().to_string(); + eprintln!(" ✓ ECS cluster already exists: {CLUSTER_NAME}"); + (arn, true) + } + _ => { + let resp = ecs.create_cluster() + .cluster_name(CLUSTER_NAME) + .capacity_providers("FARGATE") + .capacity_providers("FARGATE_SPOT") + .default_capacity_provider_strategy( + aws_sdk_ecs::types::CapacityProviderStrategyItem::builder() + .capacity_provider("FARGATE_SPOT") + .weight(1) + .build()?, + ) + .send() + .await + .context("failed to create ECS cluster")?; + let arn = resp.cluster().and_then(|c| c.cluster_arn()).unwrap_or_default().to_string(); + eprintln!(" ✓ Created ECS cluster: {CLUSTER_NAME}"); + (arn, true) + } + } + }; + managed.cluster = cluster_managed; + + // 3. IAM Execution Role + let execution_role_arn = if let Some(ref arn) = imports.execution_role { + eprintln!(" ✓ Using existing execution role: {arn}"); + arn.clone() + } else { + let arn = ensure_role(&iam, EXECUTION_ROLE, &account).await?; + iam.attach_role_policy() + .role_name(EXECUTION_ROLE) + .policy_arn("arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy") + .send().await.ok(); + eprintln!(" ✓ IAM execution role: {EXECUTION_ROLE}"); + managed.execution_role = true; + arn + }; + if imports.execution_role.is_none() { + // ECS uses the EXECUTION role (not the task role) to fetch + // `spec.secrets` values before the container starts. The managed + // AmazonECSTaskExecutionRolePolicy above covers ECR pulls and log + // delivery but not Secrets Manager, so without this, any manifest + // using `spec.secrets` fails at task launch with an AccessDenied on + // secretsmanager:GetSecretValue. Applied unconditionally (not just on + // first creation) so it self-heals existing bootstrap installs too — + // `put_role_policy` is idempotent. + iam.put_role_policy() + .role_name(EXECUTION_ROLE) + .policy_name("oab-secrets") + .policy_document(r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["secretsmanager:GetSecretValue"],"Resource":"arn:aws:secretsmanager:*:*:secret:oab/*"}]}"#) + .send().await.ok(); + } + + // Save partial state (in case subsequent steps fail) + let mut state = BootstrapState { + version: 1, + account: account.clone(), + region: region.clone(), + bucket: bucket.clone(), + resources: BootstrapResources { + cluster_arn: cluster_arn.clone(), + execution_role_arn: execution_role_arn.clone(), + task_role_arn: String::new(), + security_group_id: String::new(), + log_group: String::new(), + subnets: vec![], + vpc_id: String::new(), + }, + managed: managed.clone(), + created_at: chrono_now(), + }; + save_state(&s3, &bucket, &state).await.ok(); + + // 4. IAM Task Role + let task_role_arn = if let Some(ref arn) = imports.task_role { + eprintln!(" ✓ Using existing task role: {arn}"); + arn.clone() + } else { + let arn = ensure_role(&iam, TASK_ROLE, &account).await?; + managed.task_role = true; + // ECS Exec permissions + iam.put_role_policy() + .role_name(TASK_ROLE) + .policy_name("oab-ecs-exec") + .policy_document(r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["ssmmessages:CreateControlChannel","ssmmessages:CreateDataChannel","ssmmessages:OpenControlChannel","ssmmessages:OpenDataChannel"],"Resource":"*"}]}"#) + .send().await.ok(); + // S3 artifacts access (seed HOME on boot, backup on shutdown) + let artifacts_policy = format!( + r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow","Action":["s3:GetObject","s3:PutObject"],"Resource":["arn:aws:s3:::{bucket}/artifacts/*"]}}]}}"# + ); + iam.put_role_policy() + .role_name(TASK_ROLE) + .policy_name("oab-s3-artifacts") + .policy_document(&artifacts_policy) + .send().await.ok(); + // Secrets Manager access (agent reads its own secrets at runtime) + iam.put_role_policy() + .role_name(TASK_ROLE) + .policy_name("oab-secrets") + .policy_document(r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["secretsmanager:GetSecretValue"],"Resource":"arn:aws:secretsmanager:*:*:secret:oab/*"}]}"#) + .send().await.ok(); + eprintln!(" ✓ IAM task role: {TASK_ROLE} (ECS Exec + S3 artifacts + Secrets)"); + arn + }; + + // 5. Security Group + let (vpc_id, sg_id) = if let Some(ref sg) = imports.security_group { + let vpc = imports.vpc.clone().unwrap_or_default(); + eprintln!(" ✓ Using existing security group: {sg}"); + (vpc, sg.clone()) + } else { + let default_vpc = ec2.describe_vpcs() + .filters(aws_sdk_ec2::types::Filter::builder().name("isDefault").values("true").build()) + .send().await?; + let vid = imports.vpc.clone().unwrap_or_else(|| { + default_vpc.vpcs().first() + .and_then(|v| v.vpc_id()) + .unwrap_or_default() + .to_string() + }); + + let sid = match ec2.describe_security_groups() + .filters(aws_sdk_ec2::types::Filter::builder().name("group-name").values(SG_NAME).build()) + .filters(aws_sdk_ec2::types::Filter::builder().name("vpc-id").values(&vid).build()) + .send().await + { + Ok(resp) if !resp.security_groups().is_empty() => { + let id = resp.security_groups()[0].group_id().unwrap_or_default().to_string(); + eprintln!(" ✓ Security group already exists: {id}"); + id + } + _ => { + let resp = ec2.create_security_group() + .group_name(SG_NAME) + .description("OAB agent containers — managed by oabctl bootstrap") + .vpc_id(&vid) + .send().await + .context("failed to create security group")?; + let id = resp.group_id().unwrap_or_default().to_string(); + managed.security_group = true; + eprintln!(" ✓ Created security group: {id}"); + id + } + }; + (vid, sid) + }; + + // 6. Subnets + let subnets = if let Some(ref s) = imports.subnets { + eprintln!(" ✓ Using provided subnets: {}", s.join(", ")); + s.clone() + } else { + let subnets_resp = ec2.describe_subnets() + .filters(aws_sdk_ec2::types::Filter::builder().name("vpc-id").values(&vpc_id).build()) + .send().await?; + subnets_resp.subnets().iter() + .filter_map(|s| s.subnet_id().map(|id| id.to_string())) + .collect() + }; + + // 7. CloudWatch Log Group + match logs.create_log_group().log_group_name(LOG_GROUP).send().await { + Ok(_) => { managed.log_group = true; eprintln!(" ✓ Created log group: {LOG_GROUP}"); } + Err(_) => eprintln!(" ✓ Log group already exists: {LOG_GROUP}"), + } + + // 8. Save final state + state.resources.task_role_arn = task_role_arn; + state.resources.security_group_id = sg_id; + state.resources.log_group = LOG_GROUP.to_string(); + state.resources.subnets = subnets; + state.resources.vpc_id = vpc_id; + state.managed = managed; + save_state(&s3, &bucket, &state).await?; + + eprintln!("\n✅ Bootstrap complete!"); + eprintln!(" State saved to: s3://{bucket}/{STATE_KEY}"); + eprintln!(" You can now run: oabctl apply -f "); + + // Save bucket and cluster to local config for future commands + let mut local_cfg = crate::config::OabConfig::load().unwrap_or_default(); + local_cfg.bootstrap.bucket = Some(bucket); + local_cfg.defaults.cluster = cluster_name.to_string(); + local_cfg.save().ok(); + + Ok(()) +} + +// ─── DELETE ─────────────────────────────────────────────────────────────────── + +async fn teardown(config: &aws_config::SdkConfig) -> Result<()> { + let (account, _region) = get_account_and_region(config).await?; + let bucket = bucket_name(&account); + let s3 = S3Client::new(config); + + let state = load_state(&s3, &bucket).await? + .context("no bootstrap state found — nothing to delete")?; + + eprintln!("🗑️ Tearing down OAB bootstrap resources...\n"); + + let ecs = EcsClient::new(config); + let iam = IamClient::new(config); + let ec2 = Ec2Client::new(config); + let logs = LogsClient::new(config); + + // Check no running services + let services = ecs.list_services().cluster(CLUSTER_NAME).send().await; + if let Ok(resp) = &services { + if !resp.service_arns().is_empty() { + anyhow::bail!( + "Cannot delete bootstrap: {} services still running on cluster '{}'. Delete them first.", + resp.service_arns().len(), + CLUSTER_NAME + ); + } + } + + // Reverse order — only delete resources we created (managed) + // 1. Log group + if state.managed.log_group { + match logs.delete_log_group().log_group_name(&state.resources.log_group).send().await { + Ok(_) => eprintln!(" ✓ Deleted log group: {}", state.resources.log_group), + Err(e) => eprintln!(" ⚠ Failed to delete log group: {e}"), + } + } else { + eprintln!(" → Skipping log group (imported)"); + } + + // 2. Security group + if state.managed.security_group { + match ec2.delete_security_group().group_id(&state.resources.security_group_id).send().await { + Ok(_) => eprintln!(" ✓ Deleted security group: {}", state.resources.security_group_id), + Err(e) => eprintln!(" ⚠ Failed to delete security group: {e}"), + } + } else { + eprintln!(" → Skipping security group (imported)"); + } + + // 3. IAM roles + if state.managed.task_role { + delete_role(&iam, TASK_ROLE).await; + eprintln!(" ✓ Deleted IAM role: {TASK_ROLE}"); + } else { + eprintln!(" → Skipping task role (imported)"); + } + if state.managed.execution_role { + delete_role(&iam, EXECUTION_ROLE).await; + eprintln!(" ✓ Deleted IAM role: {EXECUTION_ROLE}"); + } else { + eprintln!(" → Skipping execution role (imported)"); + } + + // 4. ECS Cluster + if state.managed.cluster { + match ecs.delete_cluster().cluster(CLUSTER_NAME).send().await { + Ok(_) => eprintln!(" ✓ Deleted ECS cluster: {CLUSTER_NAME}"), + Err(e) => eprintln!(" ⚠ Failed to delete cluster: {e}"), + } + } else { + eprintln!(" → Skipping cluster (imported)"); + } + + // 5. Delete state file (keep bucket for user data) + s3.delete_object().bucket(&bucket).key(STATE_KEY).send().await.ok(); + eprintln!(" ✓ Deleted bootstrap state"); + eprintln!("\n ℹ️ S3 bucket '{bucket}' preserved (may contain manifests/config)."); + eprintln!(" To fully remove: aws s3 rb s3://{bucket} --force"); + + eprintln!("\n✅ Bootstrap teardown complete."); + Ok(()) +} + +// ─── STATUS ─────────────────────────────────────────────────────────────────── + +async fn show_status(config: &aws_config::SdkConfig) -> Result<()> { + let (account, _region) = get_account_and_region(config).await?; + let bucket = bucket_name(&account); + let s3 = S3Client::new(config); + + match load_state(&s3, &bucket).await? { + Some(state) => { + eprintln!("✅ OAB Bootstrap Status\n"); + eprintln!(" Account: {}", state.account); + eprintln!(" Region: {}", state.region); + eprintln!(" Created: {}", state.created_at); + eprintln!(" Bucket: {}", state.bucket); + eprintln!(" Cluster: {}", state.resources.cluster_arn); + eprintln!(" Execution Role: {}", state.resources.execution_role_arn); + eprintln!(" Task Role: {}", state.resources.task_role_arn); + eprintln!(" Security Group: {}", state.resources.security_group_id); + eprintln!(" Log Group: {}", state.resources.log_group); + eprintln!(" VPC: {}", state.resources.vpc_id); + eprintln!(" Subnets: {}", state.resources.subnets.join(", ")); + } + None => { + eprintln!("❌ No bootstrap state found."); + eprintln!(" Run: oabctl bootstrap"); + } + } + Ok(()) +} + +// ─── HELPERS ────────────────────────────────────────────────────────────────── + +async fn ensure_role(iam: &IamClient, name: &str, _account: &str) -> Result { + match iam.get_role().role_name(name).send().await { + Ok(resp) => Ok(resp.role().context("no role in response")?.arn().to_string()), + Err(_) => { + let resp = iam.create_role() + .role_name(name) + .assume_role_policy_document(ASSUME_ROLE_POLICY) + .send().await + .with_context(|| format!("failed to create role {name}"))?; + Ok(resp.role().context("no role in response")?.arn().to_string()) + } + } +} + +async fn delete_role(iam: &IamClient, name: &str) { + // Detach managed policies + if let Ok(resp) = iam.list_attached_role_policies().role_name(name).send().await { + for p in resp.attached_policies() { + if let Some(arn) = p.policy_arn() { + iam.detach_role_policy().role_name(name).policy_arn(arn).send().await.ok(); + } + } + } + // Delete inline policies + if let Ok(resp) = iam.list_role_policies().role_name(name).send().await { + for p in resp.policy_names() { + iam.delete_role_policy().role_name(name).policy_name(p).send().await.ok(); + } + } + iam.delete_role().role_name(name).send().await.ok(); +} + +fn chrono_now() -> String { + chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) +} + +fn plan_line(resource: &str, name: &str, exists: bool, will_manage: bool) { + let action = if !will_manage { + "→ import (existing)" + } else if exists { + "✓ exists (skip)" + } else { + "⊕ CREATE" + }; + eprintln!(" {:<24} {} ({})", resource, action, name); +} diff --git a/crates/oabctl/src/cli.rs b/crates/oabctl/src/cli.rs new file mode 100644 index 0000000..be49c3b --- /dev/null +++ b/crates/oabctl/src/cli.rs @@ -0,0 +1,284 @@ +use crate::{apply, bootstrap, config, create, delete, get, scale}; +use anyhow::Context; +use clap::{Parser, Subcommand}; + +#[derive(Parser)] +#[command(name = "oabctl", about = "OAB agent provisioner for ECS")] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Create or update OAB services from manifest files + Apply { + /// Path to manifest file or directory + #[arg(short, long)] + file: String, + /// Skip syncing local config.toml to S3 before applying + #[arg(long)] + no_sync: bool, + /// Wait for deployment to stabilize + #[arg(long)] + wait: bool, + }, + /// Interactive wizard to create a new agent + Create { + /// Agent name + name: String, + /// Namespace + #[arg(long, default_value = "prod")] + namespace: String, + /// Automatically apply after generating (default: just create files) + #[arg(long)] + auto_apply: bool, + }, + /// List OAB services and their status + Get { + /// Resource type + resource: String, + /// Optional resource name + name: Option, + /// ECS cluster name (default: from ~/.oabctl/config.toml) + #[arg(long)] + cluster: Option, + }, + /// Delete an OAB service + Delete { + /// Resource type (omit when using -f) + resource: Option, + /// Resource name (omit when using -f) + name: Option, + /// Delete all services defined in a manifest file or directory, + /// instead of specifying directly (mirrors `apply -f`) + #[arg(short, long, conflicts_with_all = ["resource", "name"])] + file: Option, + /// ECS cluster name (default: from ~/.oabctl/config.toml; ignored when using -f) + #[arg(long)] + cluster: Option, + /// Namespace (default: from ~/.oabctl/config.toml; ignored when using -f) + #[arg(long)] + namespace: Option, + }, + /// Execute a command in an agent container (via ecsctl) + Exec { + /// Agent name (alias) + agent: String, + /// Command to run (default: /bin/sh). Use -- to separate args. + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + command: Vec, + }, + /// Copy files to/from agent containers (via ecsctl) + Cp { + /// Source path (local or agent:/path) + src: String, + /// Destination path (local or agent:/path) + dst: String, + }, + /// Sync directories between local machine and agent containers (via ecsctl) + Sync { + /// Source: local dir or agent:/path + src: String, + /// Destination: agent:/path or local dir + dst: String, + }, + /// Scale an OAB service (set desired task count) + Scale { + /// Agent name (OAB service) + alias: String, + /// Desired task count (0 or 1) + #[arg(value_parser = clap::value_parser!(i32).range(0..=1))] + size: i32, + }, + /// Manage scaling schedules + Schedule { + #[command(subcommand)] + action: ScheduleAction, + }, + /// Bootstrap OAB infrastructure (cluster, IAM roles, S3, security group) + Bootstrap { + /// Delete all bootstrap resources + #[arg(long)] + delete: bool, + /// Show current bootstrap status + #[arg(long)] + status: bool, + /// AWS region (defaults to AWS_DEFAULT_REGION or us-east-1) + #[arg(long)] + region: Option, + /// Use existing ECS cluster (skip creation) + #[arg(long)] + cluster: Option, + /// Use existing VPC + #[arg(long)] + vpc: Option, + /// Use existing subnets (comma-separated) + #[arg(long, value_delimiter = ',')] + subnets: Option>, + /// Use existing security group + #[arg(long, alias = "sg")] + security_group: Option, + /// Use existing task execution role ARN + #[arg(long)] + execution_role: Option, + /// Use existing task role ARN + #[arg(long)] + task_role: Option, + }, +} + +#[derive(Subcommand)] +enum ScheduleAction { + /// Create a recurring scaling schedule + Create { + /// Agent name (OAB service) + alias: String, + /// Desired task count (0 or 1) + #[arg(value_parser = clap::value_parser!(i32).range(0..=1))] + size: i32, + /// Schedule expression: cron(...), rate(...), or at(...) + #[arg(long = "expression", alias = "expr")] + expression: String, + /// IANA timezone for schedule expression (default: UTC) + #[arg(long, default_value = "UTC")] + timezone: String, + }, + /// List all scaling schedules + List, + /// Delete a scaling schedule + Delete { + /// Schedule name to delete + name: String, + }, +} + +pub async fn run_cli() -> anyhow::Result<()> { + let cli = Cli::parse(); + let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await; + + match cli.command { + Commands::Apply { + file, + no_sync, + wait, + } => apply::run(&config, &file, !no_sync, wait).await, + Commands::Create { + name, + namespace, + auto_apply, + } => create::run(&config, &name, &namespace, auto_apply).await, + Commands::Get { + resource, + name, + cluster, + } => { + let oab_cfg = + config::OabConfig::load().context("failed to load ~/.oabctl/config.toml")?; + let cluster = cluster.unwrap_or(oab_cfg.defaults.cluster); + get::run(&config, &resource, name.as_deref(), &cluster).await + } + Commands::Delete { + resource, + name, + file, + cluster, + namespace, + } => { + if let Some(file) = file { + delete::run_from_file(&config, &file).await + } else { + let resource = resource.context(" is required when not using -f")?; + let name = name.context(" is required when not using -f")?; + let oab_cfg = + config::OabConfig::load().context("failed to load ~/.oabctl/config.toml")?; + let cluster = cluster.unwrap_or(oab_cfg.defaults.cluster); + let namespace = namespace.unwrap_or(oab_cfg.defaults.namespace); + delete::run(&config, &resource, &name, &cluster, &namespace).await + } + } + Commands::Exec { agent, command } => { + let resolved = ecsctl::alias::resolve(&config, &agent).await?; + let cmd = if command.is_empty() { + None + } else { + // Join args with single-quote escaping to prevent shell interpretation + let joined = command + .iter() + .map(|a| format!("'{}'", a.replace('\'', "'\\''"))) + .collect::>() + .join(" "); + Some(joined) + }; + ecsctl::exec::run(&config, &resolved, cmd.as_deref()).await + } + Commands::Cp { src, dst } => { + let src = ecsctl::alias::resolve_remote(&config, &src).await?; + let dst = ecsctl::alias::resolve_remote(&config, &dst).await?; + eprintln!("⇄ Copying {} → {} ...", src, dst); + ecsctl::cp::run(&config, &src, &dst, None, 60).await?; + eprintln!("✓ Done"); + Ok(()) + } + Commands::Sync { src, dst } => { + let src = ecsctl::alias::resolve_remote(&config, &src).await?; + let dst = ecsctl::alias::resolve_remote(&config, &dst).await?; + let src_remote = ecsctl::cp::is_remote(&src); + let dst_remote = ecsctl::cp::is_remote(&dst); + eprintln!("⇄ Syncing {} → {} ...", src, dst); + match (src_remote, dst_remote) { + (false, true) => { + ecsctl::sync::run(&config, &src, &dst, None, 60).await?; + } + (true, false) => { + ecsctl::sync::run_download(&config, &src, &dst, None, 60).await?; + } + _ => anyhow::bail!("exactly one of src/dst must be a remote path (agent:/path)"), + } + eprintln!("✓ Done"); + Ok(()) + } + Commands::Scale { alias, size } => scale::run(&config, &alias, size).await, + Commands::Schedule { action } => match action { + ScheduleAction::Create { + alias, + size, + expression, + timezone, + } => { + scale::run_with_schedule(&config, &alias, size, &expression, Some(&timezone)).await + } + ScheduleAction::List => scale::list_schedules(&config).await, + ScheduleAction::Delete { name } => scale::delete_schedule(&config, &name).await, + }, + Commands::Bootstrap { + delete, + status, + region, + cluster, + vpc, + subnets, + security_group, + execution_role, + task_role, + } => { + let cfg = if let Some(ref r) = region { + aws_config::defaults(aws_config::BehaviorVersion::latest()) + .region(aws_config::Region::new(r.clone())) + .load() + .await + } else { + config.clone() + }; + let imports = bootstrap::ImportOptions { + cluster, + vpc, + subnets, + security_group, + execution_role, + task_role, + }; + bootstrap::run(&cfg, delete, status, imports).await + } + } +} diff --git a/crates/oabctl/src/config.rs b/crates/oabctl/src/config.rs new file mode 100644 index 0000000..cdd5f1b --- /dev/null +++ b/crates/oabctl/src/config.rs @@ -0,0 +1,78 @@ +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +const CONFIG_DIR: &str = ".oabctl"; +const CONFIG_FILE: &str = "config.toml"; + +#[derive(Debug, Default, Deserialize, Serialize)] +pub struct OabConfig { + #[serde(default)] + pub defaults: Defaults, + #[serde(default)] + pub bootstrap: BootstrapConfig, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct Defaults { + #[serde(default = "default_namespace")] + pub namespace: String, + #[serde(default = "default_cluster")] + pub cluster: String, + #[serde(default)] + pub region: Option, +} + +impl Default for Defaults { + fn default() -> Self { + Self { + namespace: default_namespace(), + cluster: default_cluster(), + region: None, + } + } +} + +#[derive(Debug, Default, Deserialize, Serialize)] +pub struct BootstrapConfig { + #[serde(default)] + pub bucket: Option, +} + +fn default_namespace() -> String { "prod".to_string() } +fn default_cluster() -> String { "oab".to_string() } + +impl OabConfig { + pub fn load() -> Result { + let path = config_path(); + if path.exists() { + let content = std::fs::read_to_string(&path)?; + Ok(toml::from_str(&content)?) + } else { + Ok(Self::default()) + } + } + + pub fn save(&self) -> Result<()> { + let path = config_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let content = toml::to_string_pretty(self)?; + std::fs::write(&path, content)?; + Ok(()) + } + + /// Get the control plane bucket name (config > env var > account-based default) + pub fn bucket(&self) -> Option { + self.bootstrap.bucket.clone() + .or_else(|| std::env::var("OAB_CONTROL_PLANE_BUCKET").ok()) + } +} + +fn config_path() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(CONFIG_DIR) + .join(CONFIG_FILE) +} diff --git a/crates/oabctl/src/control_plane.rs b/crates/oabctl/src/control_plane.rs new file mode 100644 index 0000000..16f926b --- /dev/null +++ b/crates/oabctl/src/control_plane.rs @@ -0,0 +1,55 @@ +use anyhow::{Context, Result}; + +pub(crate) fn select_bucket(configured: Option<&str>, env: Option<&str>) -> Option { + configured + .map(str::trim) + .filter(|value| !value.is_empty()) + .or_else(|| env.map(str::trim).filter(|value| !value.is_empty())) + .map(str::to_owned) +} + +pub(crate) async fn resolve_bucket( + aws_config: &aws_config::SdkConfig, + configured: Option<&str>, +) -> Result { + let env_bucket = std::env::var("OAB_CONTROL_PLANE_BUCKET").ok(); + if let Some(bucket) = select_bucket(configured, env_bucket.as_deref()) { + return Ok(bucket); + } + + let identity = aws_sdk_sts::Client::new(aws_config) + .get_caller_identity() + .send() + .await + .context("failed to resolve control-plane bucket: STS get_caller_identity failed")?; + let account = identity + .account() + .context("failed to resolve control-plane bucket: STS response missing account")?; + Ok(format!("oab-control-plane-{account}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn configured_bucket_wins_over_environment() { + assert_eq!( + select_bucket(Some("configured"), Some("environment")).as_deref(), + Some("configured") + ); + } + + #[test] + fn environment_bucket_is_used_without_config_override() { + assert_eq!( + select_bucket(None, Some("environment")).as_deref(), + Some("environment") + ); + } + + #[test] + fn blank_overrides_are_ignored() { + assert_eq!(select_bucket(Some(" "), Some(" \t")), None); + } +} diff --git a/crates/oabctl/src/create.rs b/crates/oabctl/src/create.rs new file mode 100644 index 0000000..7724128 --- /dev/null +++ b/crates/oabctl/src/create.rs @@ -0,0 +1,389 @@ +use anyhow::{Context, Result}; +use aws_sdk_ec2::Client as Ec2Client; +use aws_sdk_s3::Client as S3Client; +use aws_sdk_secretsmanager::Client as SmClient; +use std::io::{self, Write}; + +const BACKENDS: &[(&str, &str)] = &[ + ("kiro", "public.ecr.aws/oablab/kiro"), + ("claude-code", "public.ecr.aws/oablab/claude-code"), + ("codex", "public.ecr.aws/oablab/codex"), + ("gemini", "public.ecr.aws/oablab/gemini"), + ("copilot", "public.ecr.aws/oablab/copilot"), + ("opencode", "public.ecr.aws/oablab/opencode"), +]; + +const CHANNELS: &[&str] = &["stable", "beta"]; + +pub async fn run(config: &aws_config::SdkConfig, name: &str, namespace: &str, auto_apply: bool) -> Result<()> { + eprintln!("🤖 Creating agent: {name}\n"); + + // 1. Backend + let backend = prompt_select("Backend platform", &BACKENDS.iter().map(|(n, _)| *n).collect::>())?; + let image_base = BACKENDS.iter().find(|(n, _)| *n == backend).unwrap().1; + + // 2. Release channel + let channel = prompt_select("Release channel", CHANNELS)?; + let image = format!("{image_base}:{channel}"); + eprintln!(" → Image: {image}\n"); + + // 3. Discord bot token + let token = prompt_secret("Discord bot token")?; + + // Store in Secrets Manager (single secret with DISCORD_BOT_TOKEN key) + let sm = SmClient::new(config); + let secret_name = format!("oab/{namespace}/{name}"); + + // 3b. STT API key (optional) + let stt_key = rpassword::prompt_password(" STT API key (Groq, enter to skip): ") + .unwrap_or_default(); + let stt_enabled = !stt_key.is_empty(); + + let mut secret_obj = serde_json::json!({ "DISCORD_BOT_TOKEN": token }); + if stt_enabled { + secret_obj["STT_API_KEY"] = serde_json::Value::String(stt_key); + } + store_secret(&sm, &secret_name, &secret_obj.to_string()).await?; + eprintln!(" → Stored in Secrets Manager: {secret_name}"); + if stt_enabled { + eprintln!(" Keys: DISCORD_BOT_TOKEN, STT_API_KEY\n"); + } else { + eprintln!(" Keys: DISCORD_BOT_TOKEN\n"); + } + + // 4. Runtime + let runtime = prompt_select("Runtime", &["ecs", "kubernetes"])?; + if runtime == "kubernetes" { + anyhow::bail!("Kubernetes runtime not yet implemented"); + } + + // 5. Capacity provider + let cap = prompt_select("Capacity provider", &["FARGATE_SPOT (cost-optimized)", "FARGATE (on-demand)"])?; + let capacity_provider = if cap.starts_with("FARGATE_SPOT") { "FARGATE_SPOT" } else { "FARGATE" }; + + // 6. VPC + let ec2 = Ec2Client::new(config); + let vpcs = list_vpcs(&ec2).await?; + if vpcs.is_empty() { + anyhow::bail!("No VPCs found in this region"); + } + let vpc_labels: Vec<&str> = vpcs.iter().map(|v| v.label.as_str()).collect(); + let vpc_choice = prompt_select("VPC", &vpc_labels)?; + let vpc = vpcs.iter().find(|v| v.label == vpc_choice).unwrap(); + + // 7. Subnets (auto-select: private+NAT > private > public, 2-3 AZ) + let subnets = select_subnets(&ec2, &vpc.id).await?; + eprintln!(" Subnets (auto-selected):"); + for s in &subnets { + eprintln!(" ✓ {} ({}, {}, {})", s.id, s.az, s.kind, if s.has_nat { "NAT ✓" } else { "no NAT" }); + } + eprintln!(); + + // 8. Security group + let sgs = list_security_groups(&ec2, &vpc.id).await?; + let mut sg_labels: Vec = vec!["Create new (oab-{name})".to_string()]; + sg_labels.extend(sgs.iter().map(|s| format!("{} ({})", s.id, s.name))); + let sg_labels_ref: Vec<&str> = sg_labels.iter().map(|s| s.as_str()).collect(); + let sg_choice = prompt_select("Security group", &sg_labels_ref)?; + + let sg_id = if sg_choice.starts_with("Create new") { + let sg_name = format!("oab-{name}"); + 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")?; + let id = resp.group_id().unwrap_or_default().to_string(); + eprintln!(" → Created security group: {id}\n"); + id + } else { + sgs.iter().find(|s| sg_choice.contains(&s.id)).unwrap().id.clone() + }; + + // ─── Generate config.toml ────────────────────────────────────────────── + let config_toml = generate_config(backend, name, namespace, stt_enabled); + + // ─── Resolve bucket for configFrom path ──────────────────────────────── + let s3 = S3Client::new(config); + let bucket = resolve_bucket(&s3, config).await + .unwrap_or_else(|| "oab-control-plane-unknown".to_string()); + + let config_s3_key = format!("artifacts/{namespace}/{name}/config.toml"); + let config_from = format!("s3://{bucket}/{config_s3_key}"); + + // ─── Save local files ────────────────────────────────────────────────── + let dir = name.to_string(); + std::fs::create_dir_all(&dir)?; + std::fs::write(format!("{dir}/config.toml"), &config_toml)?; + + let subnet_ids: Vec = subnets.iter().map(|s| s.id.clone()).collect(); + let manifest_yaml = generate_manifest(name, namespace, &image, &config_from, capacity_provider, &subnet_ids, &sg_id); + std::fs::write(format!("{dir}/manifest.yaml"), &manifest_yaml)?; + + // ─── Summary ─────────────────────────────────────────────────────────── + eprintln!("─────────────────────────────────────────"); + eprintln!("Summary:"); + eprintln!(" Agent: {name}"); + eprintln!(" Image: {image}"); + eprintln!(" CPU/Mem: 256 / 512"); + eprintln!(" Runtime: ECS {capacity_provider}"); + eprintln!(" Subnets: {}", subnet_ids.join(", ")); + eprintln!(" SG: {sg_id}"); + eprintln!(" Secret: aws-sm://{secret_name}#DISCORD_BOT_TOKEN"); + eprintln!(" Config: {config_from}"); + eprintln!(); + + eprint!("Proceed? [Y/n] "); + io::stdout().flush()?; + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + if !input.trim().is_empty() && !input.trim().eq_ignore_ascii_case("y") && !input.trim().eq_ignore_ascii_case("yes") { + eprintln!("Aborted."); + return Ok(()); + } + + eprintln!("\n✅ Created {name}/"); + eprintln!(" {dir}/manifest.yaml"); + eprintln!(" {dir}/config.toml\n"); + + if auto_apply { + // ─── Apply (with sync to upload config.toml) ─────────────────────── + crate::apply::run(config, &format!("{dir}/manifest.yaml"), true, false).await?; + eprintln!("\n✅ Agent {name} is running!"); + eprintln!(" oabctl exec {name} -- bash"); + } else { + eprintln!("To deploy:"); + eprintln!(" oabctl apply -f {dir}/manifest.yaml"); + } + Ok(()) +} + +// ─── HELPERS ────────────────────────────────────────────────────────────────── + +fn prompt_select<'a>(label: &str, options: &[&'a str]) -> Result<&'a str> { + eprintln!(" {label}:"); + for (i, opt) in options.iter().enumerate() { + eprintln!(" {}. {}", i + 1, opt); + } + eprint!(" Choice [1]: "); + io::stdout().flush()?; + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + let idx = if input.trim().is_empty() { + 0 + } else { + input.trim().parse::().unwrap_or(1).saturating_sub(1) + }; + let choice = options.get(idx).context("invalid selection")?; + eprintln!(); + Ok(choice) +} + +fn prompt_secret(label: &str) -> Result { + let val = rpassword::prompt_password(format!(" {label}: ")) + .context("failed to read secret input")?; + if val.is_empty() { + anyhow::bail!("{label} cannot be empty"); + } + Ok(val) +} + +async fn store_secret(sm: &SmClient, name: &str, value: &str) -> Result<()> { + match sm.create_secret().name(name).secret_string(value).send().await { + Ok(_) => Ok(()), + Err(_) => { + // Already exists — update + sm.put_secret_value().secret_id(name).secret_string(value).send().await + .context("failed to store secret")?; + Ok(()) + } + } +} + +struct VpcInfo { id: String, label: String } + +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(); + let cidr = v.cidr_block().unwrap_or_default(); + let is_default = v.is_default().unwrap_or(false); + let name = v.tags().iter() + .find(|t| t.key() == Some("Name")) + .and_then(|t| t.value()) + .unwrap_or("unnamed"); + let label = format!("{id} ({name}, {cidr}{})", if is_default { ", default" } else { "" }); + VpcInfo { id, label } + }).collect()) +} + +struct SubnetInfo { id: String, az: String, kind: String, has_nat: bool } + +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?; + + // Get route tables to determine private vs public + NAT + let rt_resp = ec2.describe_route_tables() + .filters(aws_sdk_ec2::types::Filter::builder().name("vpc-id").values(vpc_id).build()) + .send().await?; + + // Build subnet → route table mapping + let mut subnet_routes: std::collections::HashMap = std::collections::HashMap::new(); + for rt in rt_resp.route_tables() { + let has_igw = rt.routes().iter().any(|r| { + r.gateway_id().map(|g| g.starts_with("igw-")).unwrap_or(false) + }); + let has_nat = rt.routes().iter().any(|r| { + r.nat_gateway_id().is_some() + }); + for assoc in rt.associations() { + if let Some(sid) = assoc.subnet_id() { + subnet_routes.insert(sid.to_string(), (has_igw, has_nat)); + } + } + } + + let mut all: Vec = subnets_resp.subnets().iter().map(|s| { + let id = s.subnet_id().unwrap_or_default().to_string(); + let az = s.availability_zone().unwrap_or_default().to_string(); + let (has_igw, has_nat) = subnet_routes.get(&id).copied().unwrap_or((false, false)); + let kind = if !has_igw { "private".to_string() } else { "public".to_string() }; + SubnetInfo { id, az, kind, has_nat } + }).collect(); + + // Priority: private+NAT > private > public, pick 2-3 unique AZs + all.sort_by(|a, b| { + let score = |s: &SubnetInfo| -> u8 { + match (s.kind.as_str(), s.has_nat) { + ("private", true) => 0, + ("private", false) => 1, + _ => 2, + } + }; + score(a).cmp(&score(b)) + }); + + // Pick up to 3 unique AZs + let mut selected = Vec::new(); + let mut seen_azs = std::collections::HashSet::new(); + for s in all { + if seen_azs.len() >= 3 { break; } + if seen_azs.contains(&s.az) { continue; } + seen_azs.insert(s.az.clone()); + selected.push(s); + } + + if selected.is_empty() { + anyhow::bail!("no subnets found in VPC {vpc_id}"); + } + Ok(selected) +} + +struct SgInfo { id: String, name: String } + +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?; + Ok(resp.security_groups().iter().map(|sg| { + SgInfo { + id: sg.group_id().unwrap_or_default().to_string(), + name: sg.group_name().unwrap_or_default().to_string(), + } + }).collect()) +} + +fn generate_config(_backend: &str, name: &str, namespace: &str, stt_enabled: bool) -> String { + let stt_section = if stt_enabled { + r#"[stt] +enabled = true +api_key = "${secrets.stt_api_key}" +model = "whisper-large-v3-turbo" +base_url = "https://api.groq.com/openai/v1" +"#.to_string() + } else { + "[stt]\nenabled = false\n".to_string() + }; + + let secrets_refs = if stt_enabled { + format!( + r#"[secrets.refs] +discord_bot_token = "aws-sm://oab/{namespace}/{name}#DISCORD_BOT_TOKEN" +stt_api_key = "aws-sm://oab/{namespace}/{name}#STT_API_KEY" +"# + ) + } else { + format!( + r#"[secrets.refs] +discord_bot_token = "aws-sm://oab/{namespace}/{name}#DISCORD_BOT_TOKEN" +"# + ) + }; + + format!( + r#"{secrets_refs} +[discord] +bot_token = "${{secrets.discord_bot_token}}" +allow_all_channels = true +allow_all_users = true +allowed_channels = [] +allowed_users = [] +allow_bot_messages = "mentions" +max_bot_turns = 1000 +message_processing_mode = "per-thread" + +[agent] +inherit_env = ["AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", "AWS_DEFAULT_REGION", "AWS_EXECUTION_ENV", "AWS_REGION"] + +[pool] +max_sessions = 5 +session_ttl_hours = 1 + +[reactions] +enabled = true +remove_after_reply = false + +{stt_section} +[cron] +usercron_enabled = true +usercron_path = "cronjob.toml" +"# + ) +} + +fn generate_manifest(name: &str, namespace: &str, image: &str, config_from: &str, cap: &str, subnets: &[String], sg: &str) -> String { + let subnets_yaml = subnets.iter().map(|s| format!("\"{}\"", s)).collect::>().join(", "); + format!( + r#"apiVersion: oab.dev/v2 +kind: OABService +metadata: + name: {name} + namespace: {namespace} +spec: + image: {image} + resources: + cpu: "256" + memory: "512" + configFrom: {config_from} + runtime: + type: ecs + capacityProvider: {cap} + networking: + subnets: [{subnets_yaml}] + securityGroups: ["{sg}"] +"# + ) +} + +async fn resolve_bucket(_s3: &S3Client, config: &aws_config::SdkConfig) -> Option { + let oab_cfg = crate::config::OabConfig::load().ok()?; + if let Some(b) = oab_cfg.bucket() { + return Some(b); + } + let sts = aws_sdk_sts::Client::new(config); + let account = sts.get_caller_identity().send().await.ok()?.account()?.to_string(); + Some(format!("oab-control-plane-{account}")) +} diff --git a/crates/oabctl/src/delete.rs b/crates/oabctl/src/delete.rs new file mode 100644 index 0000000..953e863 --- /dev/null +++ b/crates/oabctl/src/delete.rs @@ -0,0 +1,295 @@ +use anyhow::{Context, Result}; +use aws_sdk_ecs::error::ProvideErrorMetadata; +use std::path::Path; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum EcsDeletePhase { + Delete, + Drain, + Cleanup, +} + +fn ecs_delete_phase(status: Option<&str>) -> Result { + match status { + Some("ACTIVE") => Ok(EcsDeletePhase::Delete), + Some("DRAINING") => Ok(EcsDeletePhase::Drain), + Some("INACTIVE") | None => Ok(EcsDeletePhase::Cleanup), + Some(other) => anyhow::bail!("unexpected ECS service status during delete: {other}"), + } +} + +/// Delete every OABService defined in a manifest file or directory. +pub(crate) async fn run_from_file( + aws_config: &aws_config::SdkConfig, + file_path: &str, +) -> Result<()> { + let path = Path::new(file_path); + let manifests = crate::apply::load_manifests(path) + .with_context(|| format!("failed to load manifest(s) from {file_path}"))?; + if manifests.is_empty() { + anyhow::bail!("no manifests found at {file_path}"); + } + + let oab_cfg = crate::config::OabConfig::load() + .context("failed to load ~/.oabctl/config.toml (run `oabctl bootstrap` first)")?; + let cluster = &oab_cfg.defaults.cluster; + let bucket = + crate::control_plane::resolve_bucket(aws_config, oab_cfg.bootstrap.bucket.as_deref()) + .await?; + + let mut failures = Vec::new(); + for manifest in &manifests { + println!( + "Deleting {} (from {})...", + manifest.metadata.name, file_path + ); + if let Err(error) = run_with_bucket( + aws_config, + "oabservice", + &manifest.metadata.name, + cluster, + &manifest.metadata.namespace, + &bucket, + ) + .await + { + eprintln!(" ⚠ failed to delete {}: {error}", manifest.metadata.name); + failures.push(manifest.metadata.name.clone()); + } + } + + if !failures.is_empty() { + anyhow::bail!( + "failed to delete {} of {} service(s): {}", + failures.len(), + manifests.len(), + failures.join(", ") + ); + } + Ok(()) +} + +pub(crate) async fn run( + aws_config: &aws_config::SdkConfig, + resource: &str, + name: &str, + cluster: &str, + namespace: &str, +) -> Result<()> { + let oab_cfg = + crate::config::OabConfig::load().context("failed to load ~/.oabctl/config.toml")?; + let bucket = + crate::control_plane::resolve_bucket(aws_config, oab_cfg.bootstrap.bucket.as_deref()) + .await?; + run_with_bucket(aws_config, resource, name, cluster, namespace, &bucket).await +} + +async fn run_with_bucket( + aws_config: &aws_config::SdkConfig, + resource: &str, + name: &str, + cluster: &str, + namespace: &str, + bucket: &str, +) -> Result<()> { + if resource != "oabservice" { + anyhow::bail!("unknown resource type: {resource}. Use 'oabservice'"); + } + + let service_name = format!("oab-{namespace}-{name}"); + let ecs = aws_sdk_ecs::Client::new(aws_config); + let s3 = aws_sdk_s3::Client::new(aws_config); + + println!("Deleting {name}..."); + + let describe_response = ecs + .describe_services() + .cluster(cluster) + .services(&service_name) + .send() + .await + .context("failed to describe ECS service before delete")?; + let service = describe_response.services().first(); + let registry_arn: Option = service.and_then(|service| { + service + .service_registries() + .first() + .and_then(|registry| registry.registry_arn()) + .map(str::to_owned) + }); + let service_status = service.and_then(|service| service.status()); + let delete_phase = ecs_delete_phase(service_status)?; + let service_needs_delete = delete_phase == EcsDeletePhase::Delete; + let service_is_draining = delete_phase == EcsDeletePhase::Drain; + + if service_needs_delete { + let _ = ecs + .update_service() + .cluster(cluster) + .service(&service_name) + .desired_count(0) + .send() + .await; + println!(" ✓ Scaled to 0"); + + match ecs + .delete_service() + .cluster(cluster) + .service(&service_name) + .force(true) + .send() + .await + { + Ok(_) => println!(" ✓ ECS service deleted"), + Err(error) if error.code() == Some("ServiceNotFoundException") => { + println!(" ✓ ECS service already absent") + } + Err(error) => return Err(error).context("failed to delete ECS service"), + } + } else if service_is_draining { + println!(" ✓ ECS service is already draining; resuming delete cleanup"); + } else { + println!(" ✓ ECS service already absent; resuming dependent cleanup"); + } + + if service_needs_delete || service_is_draining { + const DRAIN_POLL_ATTEMPTS: u32 = 12; + const DRAIN_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5); + eprint!(" ⏳ Waiting for drain to complete..."); + for attempt in 0..DRAIN_POLL_ATTEMPTS { + let response = ecs + .describe_services() + .cluster(cluster) + .services(&service_name) + .send() + .await; + let is_gone = match response { + Ok(response) => response + .services() + .first() + .map(|service| service.status() == Some("INACTIVE")) + .unwrap_or(true), + Err(error) => { + eprintln!("\n ⚠ describe_services error (retrying): {error}"); + false + } + }; + if is_gone { + if attempt == 0 { + eprintln!(" done (immediate)"); + } else { + let elapsed = u64::from(attempt) * DRAIN_POLL_INTERVAL.as_secs(); + eprintln!(" done ({elapsed}s)"); + } + break; + } + if attempt == DRAIN_POLL_ATTEMPTS - 1 { + eprintln!(" timed out (service may still be draining)"); + } else { + eprint!("."); + tokio::time::sleep(DRAIN_POLL_INTERVAL).await; + } + } + } + + if let Err(error) = + crate::ingress::teardown(aws_config, namespace, name, registry_arn.as_deref()).await + { + eprintln!(" ⚠ ingress teardown skipped: {error}"); + } + if let Err(error) = crate::ingress::delete_api(aws_config, namespace, name).await { + eprintln!(" ⚠ HTTP API cleanup skipped: {error}"); + } + + let mut cleanup_failures = Vec::new(); + let manifest_key = format!("manifests/{namespace}/{name}.yaml"); + match s3 + .delete_object() + .bucket(bucket) + .key(&manifest_key) + .send() + .await + { + Ok(_) => println!(" ✓ Manifest removed from S3"), + Err(error) => cleanup_failures.push(format!( + "failed to delete s3://{bucket}/{manifest_key}: {error}" + )), + } + + let artifact_prefix = format!("artifacts/{namespace}/{name}/"); + let mut continuation_token = None; + loop { + let response = match s3 + .list_objects_v2() + .bucket(bucket) + .prefix(&artifact_prefix) + .set_continuation_token(continuation_token) + .send() + .await + { + Ok(response) => response, + Err(error) => { + cleanup_failures.push(format!( + "failed to list config artifacts under s3://{bucket}/{artifact_prefix}: {error}" + )); + break; + } + }; + for object in response.contents() { + if let Some(key) = object.key() { + if let Err(error) = s3 + .delete_object() + .bucket(bucket) + .key(key) + .send() + .await + { + cleanup_failures + .push(format!("failed to delete s3://{bucket}/{key}: {error}")); + } + } + } + continuation_token = response.next_continuation_token().map(str::to_owned); + if continuation_token.is_none() { + break; + } + } + if cleanup_failures.is_empty() { + println!(" ✓ Config artifacts removed from S3"); + } else { + anyhow::bail!( + "post-delete cleanup incomplete (safe to retry): {}", + cleanup_failures.join("; ") + ); + } + + println!("\n✓ {name} deleted"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn delete_phase_requests_delete_only_for_active_service() { + assert_eq!( + ecs_delete_phase(Some("ACTIVE")).unwrap(), + EcsDeletePhase::Delete + ); + assert_eq!( + ecs_delete_phase(Some("DRAINING")).unwrap(), + EcsDeletePhase::Drain + ); + assert_eq!( + ecs_delete_phase(Some("INACTIVE")).unwrap(), + EcsDeletePhase::Cleanup + ); + assert_eq!(ecs_delete_phase(None).unwrap(), EcsDeletePhase::Cleanup); + } + + #[test] + fn delete_phase_rejects_unknown_status() { + assert!(ecs_delete_phase(Some("UNKNOWN")).is_err()); + } +} diff --git a/crates/oabctl/src/get.rs b/crates/oabctl/src/get.rs new file mode 100644 index 0000000..0011e83 --- /dev/null +++ b/crates/oabctl/src/get.rs @@ -0,0 +1,114 @@ +use anyhow::{Context, Result}; + +pub async fn run( + aws_config: &aws_config::SdkConfig, + resource: &str, + name: Option<&str>, + cluster: &str, +) -> Result<()> { + if resource != "oabservice" { + anyhow::bail!("unknown resource type: {}. Use 'oabservice'", resource); + } + + let ecs = aws_sdk_ecs::Client::new(aws_config); + + let services = if let Some(name) = name { + // Describe a specific service + let svc_name = if name.starts_with("oab-") { + name.to_string() + } else { + // Try to find by listing all oab- services and matching the name suffix + format!("oab-prod-{}", name) // TODO: support --namespace flag + }; + vec![svc_name] + } else { + // List all oab- services + let mut service_arns = Vec::new(); + let mut next_token = None; + loop { + let mut req = ecs.list_services().cluster(cluster); + if let Some(token) = &next_token { + req = req.next_token(token); + } + let resp = req.send().await.context("failed to list ECS services")?; + for arn in resp.service_arns() { + if arn.contains("/oab-") { + service_arns.push(arn.to_string()); + } + } + next_token = resp.next_token().map(|s| s.to_string()); + if next_token.is_none() { + break; + } + } + service_arns + }; + + if services.is_empty() { + println!("No OAB services found."); + return Ok(()); + } + + // Describe in batches of 10 + println!( + "{:<12} {:<10} {:<5} {:<6} {:<14} {:<6} STATUS", + "NAME", "NAMESPACE", "CPU", "MEM", "CAPACITY", "TASKS" + ); + + for chunk in services.chunks(10) { + let resp = ecs + .describe_services() + .cluster(cluster) + .set_services(Some(chunk.to_vec())) + .send() + .await + .context("failed to describe ECS services")?; + + for svc in resp.services() { + let svc_name = svc.service_name().unwrap_or("-"); + // Parse oab-{namespace}-{name} + let parts: Vec<&str> = svc_name.splitn(3, '-').collect(); + let (namespace, agent_name) = if parts.len() == 3 { + (parts[1], parts[2]) + } else { + ("?", svc_name) + }; + + let status = svc.status().unwrap_or("UNKNOWN"); + let running = svc.running_count(); + let desired = svc.desired_count(); + + // Get cpu/memory from task definition + let (cpu, mem) = if let Some(td_arn) = svc.task_definition() { + let td_resp = ecs + .describe_task_definition() + .task_definition(td_arn) + .send() + .await; + if let Ok(td) = td_resp { + let td = td.task_definition(); + let cpu = td.and_then(|t| t.cpu()).unwrap_or("-"); + let mem = td.and_then(|t| t.memory()).unwrap_or("-"); + (cpu.to_string(), mem.to_string()) + } else { + ("-".to_string(), "-".to_string()) + } + } else { + ("-".to_string(), "-".to_string()) + }; + + let cap = svc + .capacity_provider_strategy() + .first() + .map(|c| c.capacity_provider()) + .unwrap_or("FARGATE"); + + println!( + "{:<12} {:<10} {:<5} {:<6} {:<14} {}/{:<3} {}", + agent_name, namespace, cpu, mem, cap, running, desired, status + ); + } + } + + Ok(()) +} diff --git a/crates/oabctl/src/ingress.rs b/crates/oabctl/src/ingress.rs new file mode 100644 index 0000000..57761ea --- /dev/null +++ b/crates/oabctl/src/ingress.rs @@ -0,0 +1,1239 @@ +//! Ingress reconciliation for webhook-based platforms (Telegram, LINE, ...). +//! +//! Implements the API Gateway HTTP API → VPC Link → Cloud Map → ECS Fargate +//! path for inbound webhook ingress (Telegram, LINE, ...), replacing ~7 manual +//! `aws apigatewayv2`/`servicediscovery`/`ecs` CLI steps. See +//! `operator/README.md` ("Ingress — inbound webhooks") for the manifest schema +//! and operational notes; a dedicated AWS reference architecture doc for this +//! path is tracked in openabdev/openab#1274. +//! +//! All operations are idempotent — resources are looked up by name and reused, +//! so repeated `oabctl apply` runs converge instead of duplicating. The shared +//! VPC Link and Cloud Map namespace are scoped per-VPC (their names include the +//! VPC ID) since a VPC Link's ENIs and a namespace's private DNS are only valid +//! within the VPC they were created in — two VPCs never share either resource. +//! +//! The reconciliation is split in two so Cloud Map is ready before the ECS +//! service needs it (whether creating a new service or attaching service +//! discovery to an existing one via `UpdateService` — ECS has supported +//! adding/updating/removing `serviceRegistries` on an existing service via a +//! normal rolling replacement since March 2022, so no delete-and-recreate is +//! needed either way): +//! 1. [`ensure_cloud_map`] — namespace + service. Runs BEFORE the ECS +//! create/update-service call so its registry ARN is ready to attach. +//! 2. [`ensure_gateway`] — VPC Link + HTTP API + integration + routes + stage +//! + security-group inbound rule. Runs AFTER the task is wired up. + +use crate::manifest::{Ingress, OABServiceManifest}; +use anyhow::{Context, Result}; +use aws_sdk_apigatewayv2::types::{ConnectionType, IntegrationType, ProtocolType}; +use aws_sdk_servicediscovery::types::{DnsConfig, DnsRecord, RecordType}; +use std::collections::HashMap; + +macro_rules! eprintln { + ($($arg:tt)*) => {{ + if crate::apply::progress_enabled() { + std::eprintln!($($arg)*); + } + }}; +} + +const STAGE_NAME: &str = "prod"; + +/// VPC Link name, scoped per-VPC. A VPC Link's ENIs live in one VPC and cannot +/// route to another, so each VPC gets its own link — sharing a name across VPCs +/// would silently misroute traffic through the wrong VPC's link. +fn vpc_link_name(vpc_id: &str) -> String { + format!("oab-vpc-link-{vpc_id}") +} + +/// Cloud Map private DNS namespace name, scoped per-VPC. A namespace's private +/// DNS only resolves within the VPC it's associated with, so two VPCs both +/// using the configured `cloudMapNamespace` (default `oab`) must not resolve to +/// the same lookup — scope the actual namespace by VPC ID. +fn vpc_scoped_namespace(configured_namespace: &str, vpc_id: &str) -> String { + format!("{configured_namespace}-{vpc_id}") +} + +/// Per-bot HTTP API name. Each ingress bot gets its own API so webhook paths +/// (e.g. `/webhook/telegram`) can never collide between bots on a shared API. +fn api_name(namespace: &str, name: &str) -> String { + format!("oab-webhook-{namespace}-{name}") +} + +/// Result of Cloud Map reconciliation, consumed when creating the ECS service. +pub struct CloudMapResult { + /// Cloud Map service ARN — used both as the ECS service registry ARN and as + /// the API Gateway integration URI. + pub registry_arn: String, +} + +/// Structured outcome from API Gateway reconciliation. +pub struct GatewayResult { + pub webhook_urls: Vec, + pub warnings: Vec, +} + +/// Step 1: ensure the Cloud Map private DNS namespace and service exist. +/// +/// Returns the registry ARN to attach to the ECS service and the DNS name the +/// API Gateway integration will target. +pub async fn ensure_cloud_map( + config: &aws_config::SdkConfig, + m: &OABServiceManifest, + ingress: &Ingress, +) -> Result { + let sd = aws_sdk_servicediscovery::Client::new(config); + let ec2 = aws_sdk_ec2::Client::new(config); + + let vpc_id = resolve_vpc_id(&ec2, m).await?; + + // ── Namespace (shared per-VPC; scoped by VPC so two VPCs using the same + // configured cloudMapNamespace name never collide — a namespace's DNS + // is only resolvable within the VPC it's associated with) ──────────── + let namespace_name = vpc_scoped_namespace(&ingress.cloud_map_namespace, &vpc_id); + let namespace_id = ensure_namespace(&sd, &namespace_name, &vpc_id).await?; + + // ── Service (one per bot) ────────────────────────────────────────────── + let service_name = m.cloud_map_service_name(); + let (registry_arn, existed) = ensure_service(&sd, &namespace_id, &service_name).await?; + + let dns_name = format!("{service_name}.{namespace_name}"); + if existed { + eprintln!(" ✓ Cloud Map service exists: {dns_name}"); + } else { + eprintln!(" ✓ Created Cloud Map service: {dns_name}"); + } + + Ok(CloudMapResult { registry_arn }) +} + +/// Step 2: ensure VPC Link, HTTP API, integration, routes, stage, and the +/// security-group inbound rule. Best-effort inconsistencies are returned as +/// warnings so programmatic callers do not lose diagnostics when rendering is +/// disabled. +pub async fn ensure_gateway( + config: &aws_config::SdkConfig, + namespace: &str, + name: &str, + ingress: &Ingress, + subnets: &[String], + security_groups: &[String], + cloud_map_service_arn: &str, +) -> Result { + let api = aws_sdk_apigatewayv2::Client::new(config); + let ec2 = aws_sdk_ec2::Client::new(config); + let api_name = api_name(namespace, name); + + // ── Security group inbound rule (self-referencing on the container port) ─ + ensure_sg_ingress(&ec2, security_groups, ingress.container_port).await?; + + // ── VPC Link (shared per-VPC, waits for AVAILABLE) ────────────────────── + let subnet = subnets + .first() + .context("ingress requires at least one subnet")?; + let vpc_id = resolve_vpc_id_from_subnet(&ec2, subnet).await?; + let (vpc_link_id, mut warnings) = + ensure_vpc_link(&api, &vpc_id, subnets, security_groups).await?; + + // ── HTTP API (one per bot — avoids cross-bot path collisions) ────────── + let (api_id, api_endpoint) = ensure_api(&api, &api_name).await?; + + // ── Integration: VPC Link → Cloud Map service (URI is the service ARN; + // the port is resolved from the service's SRV record) ─────────────── + let integration_id = + ensure_integration(&api, &api_id, &vpc_link_id, cloud_map_service_arn).await?; + + // ── One route per webhook path, all → the same integration ───────────── + for path in &ingress.paths { + ensure_route(&api, &api_id, path, &integration_id).await?; + } + + // ── Prune routes for paths no longer in the manifest (rename/removal) ─── + warnings.extend(prune_stale_routes(&api, &api_id, &ingress.paths).await?); + + // ── Stage (auto-deploy) ──────────────────────────────────────────────── + ensure_stage(&api, &api_id).await?; + + Ok(GatewayResult { + webhook_urls: webhook_urls(&api_endpoint, &ingress.paths), + warnings, + }) +} + +/// Find the `/webhook/telegram` URL among the resolved webhook URLs, and +/// confirm a `TELEGRAM_BOT_TOKEN` secret is configured. Returns `None` if +/// either is missing, meaning [`register_telegram_webhook`] should no-op. +fn find_telegram_webhook( + secrets: &std::collections::HashMap, + webhook_urls: &[(String, String)], +) -> Option<(String, String)> { + let url = webhook_urls + .iter() + .find(|(path, _)| path == "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/webhook/telegram") + .map(|(_, url)| url.clone())?; + let token_ref = secrets.get("TELEGRAM_BOT_TOKEN")?.clone(); + Some((url, token_ref)) +} + +/// Register the webhook URL with Telegram's Bot API (`setWebhook`), so the +/// bot starts receiving updates without a manual `curl` step. Only runs when +/// `spec.secrets` has a `TELEGRAM_BOT_TOKEN` entry and one of the ingress +/// paths is `/webhook/telegram`; a no-op otherwise. If `TELEGRAM_SECRET_TOKEN` +/// is also present, it's passed through so Telegram includes it on every +/// webhook request (openab's Telegram adapter validates it). +/// +/// Best-effort: errors are returned to the caller to print as a warning, but +/// are never fatal to `apply` — the AWS-side provisioning already succeeded +/// by this point, and a failed Telegram API call (e.g. bad token, network +/// blip) shouldn't roll any of that back or fail the whole command. +pub async fn register_telegram_webhook( + config: &aws_config::SdkConfig, + secrets: &std::collections::HashMap, + webhook_urls: &[(String, String)], +) -> Result> { + let Some((url, token_arn)) = find_telegram_webhook(secrets, webhook_urls) else { + return Ok(None); + }; + + let sm = aws_sdk_secretsmanager::Client::new(config); + let bot_token = crate::secrets::resolve_string(&sm, &token_arn) + .await + .context("failed to resolve TELEGRAM_BOT_TOKEN")?; + + let secret_token = match secrets.get("TELEGRAM_SECRET_TOKEN") { + Some(v) => Some( + crate::secrets::resolve_string(&sm, v) + .await + .context("failed to resolve TELEGRAM_SECRET_TOKEN")?, + ), + None => None, + }; + + let mut form = vec![("url".to_string(), url)]; + if let Some(st) = secret_token { + form.push(("secret_token".to_string(), st)); + } + + let client = reqwest::Client::new(); + let resp = client + .post(format!( + "https://api.telegram.org/bot{bot_token}/setWebhook" + )) + .form(&form) + .send() + .await + .context("failed to call Telegram setWebhook API")?; + let status = resp.status(); + let body: serde_json::Value = resp + .json() + .await + .context("failed to parse Telegram setWebhook response")?; + + if !status.is_success() || body.get("ok").and_then(|v| v.as_bool()) != Some(true) { + anyhow::bail!( + "Telegram setWebhook failed: {}", + body.get("description") + .and_then(|v| v.as_str()) + .unwrap_or("unknown error") + ); + } + Ok(Some( + body.get("description") + .and_then(|v| v.as_str()) + .unwrap_or("webhook registered") + .to_string(), + )) +} + +/// API Gateway route key for a webhook path (POST only). +fn route_key(path: &str) -> String { + format!("POST {path}") +} + +/// Whether an integration's request parameters already carry the +/// `overwrite:path` override needed to strip the stage prefix before it +/// reaches the backend. Without this, private (VPC_LINK) integrations +/// forward the stage-prefixed path (e.g. `/prod/webhook/telegram`) to the +/// container, and openab's exact-match router 404s on it. See: +/// +fn has_stage_path_override(request_parameters: Option<&HashMap>) -> bool { + request_parameters + .and_then(|p| p.get("overwrite:path")) + .map(|v| v == "$request.path") + .unwrap_or(false) +} + +/// Extract the Cloud Map service ID from its ARN +/// (`arn:aws:servicediscovery:::service/`). +fn cloud_map_service_id_from_arn(arn: &str) -> Option { + arn.rsplit('/') + .next() + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) +} + +/// Build the public webhook URL(s) from the API endpoint and paths. +/// Each URL is `/`. +fn webhook_urls(api_endpoint: &str, paths: &[String]) -> Vec { + let base = api_endpoint.trim_end_matches('/'); + paths + .iter() + .map(|p| format!("{base}/{STAGE_NAME}{p}")) + .collect() +} + +/// Best-effort teardown of the *per-bot ingress wiring* for `namespace/name`: +/// its routes, integration, and stage on the per-bot HTTP API, plus its Cloud +/// Map service. Deliberately does NOT delete the HTTP API resource itself — +/// only what points at the now-gone task — so the API's `api-id` (and thus the +/// public webhook URL's hostname) survives an ECS-service recreate cycle. Use +/// [`delete_api`] separately when the bot is being permanently removed. +/// +/// The shared resources (the VPC Link and the security-group inbound rule) are +/// intentionally left in place since other bots may still use them. Safe to +/// call for bots that never had ingress — it simply finds nothing and returns. +/// Errors that prevent teardown are propagated. Degraded cleanup that can be +/// completed manually is returned as warning text so apply can include it in +/// its structured report while the CLI still renders it. +pub async fn teardown( + config: &aws_config::SdkConfig, + namespace: &str, + name: &str, + known_registry_arn: Option<&str>, +) -> Result> { + let mut warnings = Vec::new(); + let service_name = format!("oab-{namespace}-{name}"); + let api = aws_sdk_apigatewayv2::Client::new(config); + + // ── API Gateway: strip routes + integration + stage, keep the API itself ─ + if let Some((api_id, _)) = find_api(&api, &api_name(namespace, name)).await? { + // Delete all routes on this API first (integrations can't be deleted + // while a route still targets them). + let mut route_ids = Vec::new(); + let mut next: Option = None; + loop { + let mut req = api.get_routes().api_id(&api_id); + if let Some(t) = &next { + req = req.next_token(t); + } + let resp = req.send().await.context("failed to list routes")?; + for r in resp.items() { + if let Some(id) = r.route_id() { + route_ids.push(id.to_string()); + } + } + match resp.next_token() { + Some(t) => next = Some(t.to_string()), + None => break, + } + } + for route_id in &route_ids { + if let Err(error) = api + .delete_route() + .api_id(&api_id) + .route_id(route_id) + .send() + .await + { + let warning = format!( + "failed to delete ingress route {route_id} from HTTP API {api_id}: {error}" + ); + eprintln!(" ⚠ {warning}"); + warnings.push(warning); + } + } + + // Delete integrations (there's normally just one, but clean up all). + let mut integration_ids = Vec::new(); + let mut next: Option = None; + loop { + let mut req = api.get_integrations().api_id(&api_id); + if let Some(t) = &next { + req = req.next_token(t); + } + let resp = req.send().await.context("failed to list integrations")?; + for i in resp.items() { + if let Some(id) = i.integration_id() { + integration_ids.push(id.to_string()); + } + } + match resp.next_token() { + Some(t) => next = Some(t.to_string()), + None => break, + } + } + for integration_id in &integration_ids { + if let Err(error) = api + .delete_integration() + .api_id(&api_id) + .integration_id(integration_id) + .send() + .await + { + let warning = format!( + "failed to delete ingress integration {integration_id} from HTTP API {api_id}: {error}" + ); + eprintln!(" ⚠ {warning}"); + warnings.push(warning); + } + } + + if let Err(error) = api + .delete_stage() + .api_id(&api_id) + .stage_name(STAGE_NAME) + .send() + .await + { + let warning = format!( + "failed to delete ingress stage {STAGE_NAME} from HTTP API {api_id}: {error}" + ); + eprintln!(" ⚠ {warning}"); + warnings.push(warning); + } + + if warnings.is_empty() { + eprintln!( + " ✓ Cleared ingress wiring on HTTP API {} ({} route(s), {} integration(s)) — API itself kept so its URL survives a recreate", + api_name(namespace, name), + route_ids.len(), + integration_ids.len() + ); + } else { + eprintln!( + " ⚠ Ingress wiring cleanup on HTTP API {} completed with {} warning(s)", + api_name(namespace, name), + warnings.len() + ); + } + } + + // ── Cloud Map: delete the per-bot service (needs no live instances) ────── + // Prefer resolving the exact service from the ECS service's own registry + // ARN (passed by the caller when known) over a name-only account-wide + // scan — two bots with the same namespace/name in different VPCs (e.g. + // staging vs. prod sharing an account) would otherwise collide and the + // wrong one could be deleted. + let sd = aws_sdk_servicediscovery::Client::new(config); + let service_id: Option = if let Some(arn) = known_registry_arn { + cloud_map_service_id_from_arn(arn) + } else { + let mut found: Option = None; + let mut pages = sd.list_services().into_paginator().send(); + 'svc: while let Some(page) = pages.next().await { + let page = page.context("failed to list Cloud Map services")?; + for s in page.services() { + if s.name() == Some(service_name.as_str()) { + found = s.id().map(|x| x.to_string()); + break 'svc; + } + } + } + found + }; + if let Some(service_id) = service_id { + // ECS deregisters the task's Cloud Map instance asynchronously when a + // service scales to 0 / is deleted, so `delete_service` can fail with + // "still has registered instances" for a short window even though the + // task is already gone. Retry briefly instead of giving up on the + // first attempt — this is the common case, not an edge case. + let mut last_err = None; + let mut deleted = false; + for attempt in 0..6 { + if attempt > 0 { + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + } + match sd.delete_service().id(&service_id).send().await { + Ok(_) => { + eprintln!(" ✓ Deleted Cloud Map service: {service_name}"); + deleted = true; + break; + } + Err(e) => last_err = Some(e), + } + } + if !deleted { + let warning = format!( + "Cloud Map service '{service_name}' was not deleted after retrying; it still has registered instances. Remove it manually with `aws servicediscovery delete-service --id {service_id}` ({})", + last_err.map(|error| error.to_string()).unwrap_or_default() + ); + eprintln!(" ⚠ {warning}"); + warnings.push(warning); + } + } + + Ok(warnings) +} + +/// Permanently delete the bot's per-bot HTTP API (`oab-webhook--`), +/// cascading its routes/integration/stage with it. This DESTROYS the `api-id` +/// and therefore the public webhook URL's hostname — only call this when the +/// bot itself is being permanently removed (`oabctl delete`), never from the +/// `apply` recreate path, which relies on the API surviving so its URL stays +/// stable across an ECS-service recreate. +pub async fn delete_api(config: &aws_config::SdkConfig, namespace: &str, name: &str) -> Result<()> { + let api = aws_sdk_apigatewayv2::Client::new(config); + let name_str = api_name(namespace, name); + if let Some((api_id, _)) = find_api(&api, &name_str).await? { + match api.delete_api().api_id(&api_id).send().await { + Ok(_) => eprintln!(" ✓ Deleted HTTP API: {name_str}"), + Err(e) => eprintln!(" ⚠ Failed to delete HTTP API {api_id}: {e}"), + } + } + Ok(()) +} + +// ─── VPC resolution ───────────────────────────────────────────────────────── + +async fn resolve_vpc_id(ec2: &aws_sdk_ec2::Client, m: &OABServiceManifest) -> Result { + let subnet = match &m.spec.runtime { + crate::manifest::Runtime::Ecs(rt) => rt + .networking + .subnets + .first() + .context("ingress requires at least one subnet")?, + _ => anyhow::bail!("ingress is only supported for ECS runtime"), + }; + resolve_vpc_id_from_subnet(ec2, subnet).await +} + +/// Resolve the VPC ID that a given subnet belongs to. +async fn resolve_vpc_id_from_subnet(ec2: &aws_sdk_ec2::Client, subnet: &str) -> Result { + let resp = ec2 + .describe_subnets() + .subnet_ids(subnet) + .send() + .await + .with_context(|| format!("failed to describe subnet {subnet}"))?; + let vpc_id = resp + .subnets() + .first() + .and_then(|s| s.vpc_id()) + .with_context(|| format!("subnet {subnet} has no VPC"))? + .to_string(); + Ok(vpc_id) +} + +// ─── Cloud Map ──────────────────────────────────────────────────────────────── + +async fn ensure_namespace( + sd: &aws_sdk_servicediscovery::Client, + name: &str, + vpc_id: &str, +) -> Result { + // Reuse an existing private DNS namespace with this name if present. + let mut pages = sd.list_namespaces().into_paginator().send(); + while let Some(page) = pages.next().await { + let page = page.context("failed to list Cloud Map namespaces")?; + for ns in page.namespaces() { + if ns.name() == Some(name) { + let id = ns.id().context("namespace missing id")?.to_string(); + eprintln!(" ✓ Cloud Map namespace exists: {name}"); + return Ok(id); + } + } + } + + // Create — this is an async Cloud Map operation; poll until it completes. + eprintln!(" ⊕ Creating Cloud Map namespace: {name} (VPC {vpc_id})"); + let out = sd + .create_private_dns_namespace() + .name(name) + .vpc(vpc_id) + .send() + .await + .context("failed to create Cloud Map namespace")?; + let op_id = out + .operation_id() + .context("no operation id for namespace creation")?; + let namespace_id = wait_for_operation_target(sd, op_id, "NAMESPACE").await?; + Ok(namespace_id) +} + +async fn ensure_service( + sd: &aws_sdk_servicediscovery::Client, + namespace_id: &str, + service_name: &str, +) -> Result<(String, bool)> { + // Look for an existing service in this namespace with the given name. + let filter = aws_sdk_servicediscovery::types::ServiceFilter::builder() + .name(aws_sdk_servicediscovery::types::ServiceFilterName::NamespaceId) + .values(namespace_id) + .build() + .context("failed to build service filter")?; + let mut pages = sd.list_services().filters(filter).into_paginator().send(); + while let Some(page) = pages.next().await { + let page = page.context("failed to list Cloud Map services")?; + for svc in page.services() { + if svc.name() == Some(service_name) { + let arn = svc.arn().context("service missing arn")?.to_string(); + return Ok((arn, true)); + } + } + } + + // Create with an SRV record. For an HTTP API private integration whose URI + // is the Cloud Map service ARN, API Gateway learns the target port from the + // SRV record — a plain A record carries no port and does NOT work. ECS + // registers the task's IP + container port into this SRV record. + let dns_record = DnsRecord::builder() + .r#type(RecordType::Srv) + .ttl(60) + .build() + .context("failed to build DNS record")?; + let dns_config = DnsConfig::builder() + .dns_records(dns_record) + .build() + .context("failed to build DNS config")?; + let out = sd + .create_service() + .name(service_name) + .namespace_id(namespace_id) + .dns_config(dns_config) + .send() + .await + .context("failed to create Cloud Map service")?; + let arn = out + .service() + .and_then(|s| s.arn()) + .context("created service has no ARN")? + .to_string(); + Ok((arn, false)) +} + +async fn wait_for_operation_target( + sd: &aws_sdk_servicediscovery::Client, + op_id: &str, + target_key: &str, +) -> Result { + use aws_sdk_servicediscovery::types::OperationStatus; + for _ in 0..60 { + let resp = sd + .get_operation() + .operation_id(op_id) + .send() + .await + .context("failed to poll Cloud Map operation")?; + let op = resp.operation().context("no operation in response")?; + match op.status() { + Some(OperationStatus::Success) => { + let target = op + .targets() + .and_then(|t| { + t.iter() + .find(|(k, _)| k.as_str() == target_key) + .map(|(_, v)| v.clone()) + }) + .context("operation succeeded but target id missing")?; + return Ok(target); + } + Some(OperationStatus::Fail) => { + anyhow::bail!( + "Cloud Map operation failed: {}", + op.error_message().unwrap_or("unknown error") + ); + } + _ => tokio::time::sleep(std::time::Duration::from_secs(2)).await, + } + } + anyhow::bail!("timed out waiting for Cloud Map operation {op_id}") +} + +// ─── Security group ─────────────────────────────────────────────────────────── + +async fn ensure_sg_ingress( + ec2: &aws_sdk_ec2::Client, + security_groups: &[String], + port: u16, +) -> Result<()> { + use aws_sdk_ec2::error::ProvideErrorMetadata; + use aws_sdk_ec2::types::{IpPermission, UserIdGroupPair}; + for sg in security_groups { + // Self-referencing rule: VPC Link ENIs live in this SG, so allowing the + // SG to reach itself on the container port covers VPC Link → task. + let pair = UserIdGroupPair::builder().group_id(sg).build(); + let perm = IpPermission::builder() + .ip_protocol("tcp") + .from_port(port as i32) + .to_port(port as i32) + .user_id_group_pairs(pair) + .build(); + match ec2 + .authorize_security_group_ingress() + .group_id(sg) + .ip_permissions(perm) + .send() + .await + { + Ok(_) => eprintln!(" ✓ SG {sg}: allowed self :{port} (VPC Link → task)"), + // EC2 returns InvalidPermission.Duplicate when the rule already exists. + // Match the typed error code, not the Debug-rendered message text. + Err(e) if e.code() == Some("InvalidPermission.Duplicate") => { + eprintln!(" ✓ SG {sg}: inbound :{port} rule already present"); + } + Err(e) => { + return Err(anyhow::anyhow!(e)) + .with_context(|| format!("failed to authorize ingress on {sg}")); + } + } + } + Ok(()) +} + +// ─── VPC Link ───────────────────────────────────────────────────────────────── + +async fn ensure_vpc_link( + api: &aws_sdk_apigatewayv2::Client, + vpc_id: &str, + subnets: &[String], + security_groups: &[String], +) -> Result<(String, Vec)> { + use aws_sdk_apigatewayv2::types::VpcLinkStatus; + let link_name = vpc_link_name(vpc_id); + let mut warnings = Vec::new(); + + // Reuse an existing, non-failed VPC Link with our per-VPC name. VPC Link + // names are NOT unique to the API — if two `oabctl apply` invocations race + // to create the same-named link in a brand-new VPC (e.g. a fleet's agents + // applied via separate concurrent processes), AWS will happily create two. + // We can't prevent that race across processes, but we can make reuse + // deterministic afterward: collect all matches and always pick the one + // with the lexicographically smallest ID (stable across repeated calls, + // regardless of list ordering), warning if more than one exists so an + // operator can clean up the duplicate. + let mut candidates: Vec<(String, Option)> = Vec::new(); + let mut next: Option = None; + loop { + let mut req = api.get_vpc_links(); + if let Some(t) = &next { + req = req.next_token(t); + } + let resp = req.send().await.context("failed to list VPC Links")?; + for link in resp.items() { + if link.name() == Some(link_name.as_str()) + && !matches!( + link.vpc_link_status(), + Some(VpcLinkStatus::Failed) | Some(VpcLinkStatus::Deleting) + ) + { + candidates.push(( + link.vpc_link_id().unwrap_or_default().to_string(), + link.vpc_link_status().cloned(), + )); + } + } + match resp.next_token() { + Some(t) => next = Some(t.to_string()), + None => break, + } + } + // Prefer an already-AVAILABLE link over a PENDING one (avoids waiting on a + // duplicate that hasn't finished provisioning when a ready one exists), + // then break remaining ties by ID for determinism. + candidates.sort_by(|(a_id, a_status), (b_id, b_status)| { + let rank = |s: &Option| match s { + Some(VpcLinkStatus::Available) => 0, + _ => 1, + }; + rank(a_status) + .cmp(&rank(b_status)) + .then_with(|| a_id.cmp(b_id)) + }); + if candidates.len() > 1 { + let extra_ids = candidates[1..] + .iter() + .map(|(id, _)| id.as_str()) + .collect::>() + .join(", "); + let warning = format!( + "found {} VPC Links named '{link_name}'; using the preferred candidate and leaving duplicate IDs for manual cleanup: {extra_ids}", + candidates.len() + ); + eprintln!( + " ⚠ Found {} VPC Links named '{link_name}' (a race between concurrent\n `apply` runs can create duplicates — AWS does not enforce name\n uniqueness). Using the first AVAILABLE one (or lexicographically first\n if none are ready yet); consider deleting the extras:", + candidates.len() + ); + for (id, _) in &candidates[1..] { + eprintln!(" aws apigatewayv2 delete-vpc-link --vpc-link-id {id}"); + } + warnings.push(warning); + } + let found = candidates.into_iter().next(); + + let link_id = if let Some((id, _status)) = found { + eprintln!(" ✓ VPC Link exists: {link_name} ({id})"); + // A VPC Link's subnets/SGs are fixed at creation and cannot be updated. + // All ingress-enabled bots in this VPC share this one link, so verify + // (not just remind) that this manifest's subnets/SGs actually match + // what the link was created with — otherwise its ENIs won't cover + // this task's subnets and integrations may 503. + warnings.extend(validate_vpc_link_config(api, &id, subnets, security_groups).await?); + id + } else { + eprintln!(" ⊕ Creating VPC Link: {link_name}"); + let out = api + .create_vpc_link() + .name(&link_name) + .set_subnet_ids(Some(subnets.to_vec())) + .set_security_group_ids(Some(security_groups.to_vec())) + .send() + .await + .context("failed to create VPC Link")?; + out.vpc_link_id().context("no VPC Link id")?.to_string() + }; + + // Wait until AVAILABLE — routes won't serve traffic while PENDING. + for _ in 0..60 { + let resp = api + .get_vpc_link() + .vpc_link_id(&link_id) + .send() + .await + .context("failed to poll VPC Link")?; + match resp.vpc_link_status() { + Some(VpcLinkStatus::Available) => return Ok((link_id, warnings)), + Some(VpcLinkStatus::Failed) => anyhow::bail!( + "VPC Link {link_id} entered FAILED state: {}", + resp.vpc_link_status_message().unwrap_or("unknown") + ), + _ => { + eprintln!(" … waiting for VPC Link to become AVAILABLE (can take a few min)"); + tokio::time::sleep(std::time::Duration::from_secs(10)).await; + } + } + } + anyhow::bail!("timed out waiting for VPC Link {link_id} to become AVAILABLE") +} + +/// Verify a reused VPC Link's actual security groups match the manifest's. +/// (API Gateway's `GetVpcLink` does not expose the link's subnet IDs, only +/// security groups, so subnet mismatches can't be directly verified here — +/// they still surface indirectly as unreachable integrations, which is the +/// pre-existing behavior this doesn't regress.) Warns loudly rather than +/// failing outright, since a legitimate SG rotation could trigger this too +/// and we don't want to block `apply` on a false positive. +async fn validate_vpc_link_config( + api: &aws_sdk_apigatewayv2::Client, + link_id: &str, + subnets: &[String], + security_groups: &[String], +) -> Result> { + let mut warnings = Vec::new(); + let resp = api + .get_vpc_link() + .vpc_link_id(link_id) + .send() + .await + .context("failed to describe VPC Link for validation")?; + let actual_sgs: std::collections::HashSet<&str> = resp + .security_group_ids() + .iter() + .map(|s| s.as_str()) + .collect(); + let wanted_sgs: std::collections::HashSet<&str> = + security_groups.iter().map(|s| s.as_str()).collect(); + if actual_sgs != wanted_sgs { + let warning = format!( + "VPC Link {link_id} security groups {actual_sgs:?} do not match the manifest's {wanted_sgs:?}; integrations may not reach the task" + ); + eprintln!( + " ⚠ VPC Link {link_id}'s actual security groups {:?} do NOT match this\n manifest's {:?}. The link's SGs are fixed at creation — integrations may\n fail to reach this task. All ingress bots in this VPC must share the\n same securityGroups as whichever bot created the link.", + actual_sgs, wanted_sgs + ); + warnings.push(warning); + } + // Subnets aren't exposed by GetVpcLink; remind the operator this is the + // one part of the config we can't directly verify. + eprintln!( + " ↳ reusing this VPC's shared link (subnets fixed at creation, not verifiable via\n the API); ensure this manifest's subnets {:?} match whichever bot created it", + subnets + ); + Ok(warnings) +} + +// ─── HTTP API ─────────────────────────────────────────────────────────────── + +async fn ensure_api( + api: &aws_sdk_apigatewayv2::Client, + api_name: &str, +) -> Result<(String, String)> { + if let Some((id, endpoint)) = find_api(api, api_name).await? { + eprintln!(" ✓ HTTP API exists: {api_name} ({id})"); + return Ok((id, endpoint)); + } + eprintln!(" ⊕ Creating HTTP API: {api_name}"); + let out = api + .create_api() + .name(api_name) + .protocol_type(ProtocolType::Http) + .send() + .await + .context("failed to create HTTP API")?; + let id = out.api_id().context("no api id")?.to_string(); + let endpoint = out.api_endpoint().unwrap_or_default().to_string(); + Ok((id, endpoint)) +} + +/// Find an HTTP API by name, returning `(api_id, api_endpoint)`. +async fn find_api( + api: &aws_sdk_apigatewayv2::Client, + api_name: &str, +) -> Result> { + // apigatewayv2 has no smithy paginator for GetApis; page manually. + let mut next: Option = None; + loop { + let mut req = api.get_apis(); + if let Some(t) = &next { + req = req.next_token(t); + } + let resp = req.send().await.context("failed to list APIs")?; + for a in resp.items() { + if a.name() == Some(api_name) { + let id = a.api_id().context("api missing id")?.to_string(); + let endpoint = a.api_endpoint().unwrap_or_default().to_string(); + return Ok(Some((id, endpoint))); + } + } + match resp.next_token() { + Some(t) => next = Some(t.to_string()), + None => return Ok(None), + } + } +} + +async fn ensure_integration( + api: &aws_sdk_apigatewayv2::Client, + api_id: &str, + vpc_link_id: &str, + integration_uri: &str, +) -> Result { + let mut next: Option = None; + loop { + let mut req = api.get_integrations().api_id(api_id); + if let Some(t) = &next { + req = req.next_token(t); + } + let resp = req.send().await.context("failed to list integrations")?; + for i in resp.items() { + if i.integration_uri() == Some(integration_uri) + && i.connection_id() == Some(vpc_link_id) + { + let id = i + .integration_id() + .context("integration missing id")? + .to_string(); + if has_stage_path_override(i.request_parameters()) { + eprintln!(" ✓ Integration exists → {integration_uri}"); + } else { + // Self-heal: existing integrations created before this + // fix forward the stage-prefixed path to the backend, + // causing every request to 404. Patch in the override. + eprintln!( + " ↻ Integration exists but missing path override → {integration_uri}, patching" + ); + api.update_integration() + .api_id(api_id) + .integration_id(&id) + .request_parameters("overwrite:path", "$request.path") + .send() + .await + .context("failed to patch integration path override")?; + } + return Ok(id); + } + } + match resp.next_token() { + Some(t) => next = Some(t.to_string()), + None => break, + } + } + eprintln!(" ⊕ Creating integration → {integration_uri}"); + // For private (VPC_LINK) integrations, API Gateway forwards the stage + // portion of the request path to the backend by default (e.g. + // `/prod/webhook/telegram` instead of `/webhook/telegram`), per AWS docs: + // https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-private.html + // openab's router matches the exact configured path, so without this + // override every request 404s at the backend. Overwrite the forwarded + // path with $request.path (stage-stripped) to match. + let out = api + .create_integration() + .api_id(api_id) + .integration_type(IntegrationType::HttpProxy) + .integration_method("ANY") + .integration_uri(integration_uri) + .connection_type(ConnectionType::VpcLink) + .connection_id(vpc_link_id) + .payload_format_version("1.0") + .request_parameters("overwrite:path", "$request.path") + .send() + .await + .context("failed to create integration")?; + Ok(out + .integration_id() + .context("no integration id")? + .to_string()) +} + +async fn ensure_route( + api: &aws_sdk_apigatewayv2::Client, + api_id: &str, + path: &str, + integration_id: &str, +) -> Result<()> { + let route_key = route_key(path); + let target = format!("integrations/{integration_id}"); + let mut next: Option = None; + loop { + let mut req = api.get_routes().api_id(api_id); + if let Some(t) = &next { + req = req.next_token(t); + } + let resp = req.send().await.context("failed to list routes")?; + for r in resp.items() { + if r.route_key() == Some(route_key.as_str()) { + eprintln!(" ✓ Route exists: {route_key}"); + return Ok(()); + } + } + match resp.next_token() { + Some(t) => next = Some(t.to_string()), + None => break, + } + } + api.create_route() + .api_id(api_id) + .route_key(&route_key) + .target(&target) + .send() + .await + .with_context(|| format!("failed to create route {route_key}"))?; + eprintln!(" ⊕ Created route: {route_key}"); + Ok(()) +} + +/// Delete any route on the bot's API whose path isn't in `current_paths`. +/// +/// `ensure_route` only ever adds routes; without this, renaming or removing a +/// webhook path in the manifest leaves a dead route on the API permanently. +async fn prune_stale_routes( + api: &aws_sdk_apigatewayv2::Client, + api_id: &str, + current_paths: &[String], +) -> Result> { + let mut warnings = Vec::new(); + let current_keys: std::collections::HashSet = + current_paths.iter().map(|p| route_key(p)).collect(); + + let mut stale: Vec<(String, String)> = Vec::new(); // (route_id, route_key) + let mut next: Option = None; + loop { + let mut req = api.get_routes().api_id(api_id); + if let Some(t) = &next { + req = req.next_token(t); + } + let resp = req.send().await.context("failed to list routes")?; + for r in resp.items() { + if let Some(key) = r.route_key() { + if !current_keys.contains(key) { + if let Some(id) = r.route_id() { + stale.push((id.to_string(), key.to_string())); + } + } + } + } + match resp.next_token() { + Some(t) => next = Some(t.to_string()), + None => break, + } + } + + for (route_id, key) in stale { + match api + .delete_route() + .api_id(api_id) + .route_id(&route_id) + .send() + .await + { + Ok(_) => eprintln!(" ⊖ Removed stale route (no longer in manifest): {key}"), + Err(error) => { + let warning = format!("failed to remove stale route {key}: {error}"); + eprintln!(" ⚠ {warning}"); + warnings.push(warning); + } + } + } + Ok(warnings) +} + +async fn ensure_stage(api: &aws_sdk_apigatewayv2::Client, api_id: &str) -> Result<()> { + let mut next: Option = None; + loop { + let mut req = api.get_stages().api_id(api_id); + if let Some(t) = &next { + req = req.next_token(t); + } + let resp = req.send().await.context("failed to list stages")?; + for s in resp.items() { + if s.stage_name() == Some(STAGE_NAME) { + eprintln!(" ✓ Stage exists: {STAGE_NAME}"); + return Ok(()); + } + } + match resp.next_token() { + Some(t) => next = Some(t.to_string()), + None => break, + } + } + api.create_stage() + .api_id(api_id) + .stage_name(STAGE_NAME) + .auto_deploy(true) + .send() + .await + .context("failed to create stage")?; + eprintln!(" ⊕ Created stage: {STAGE_NAME} (auto-deploy)"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn route_key_is_post_prefixed() { + assert_eq!(route_key("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/webhook/telegram"), "POST /webhook/telegram"); + assert_eq!(route_key("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/webhook/line"), "POST /webhook/line"); + } + + #[test] + fn stage_path_override_absent_when_no_request_parameters() { + // Integrations created before this fix have no RequestParameters at + // all, so they must be detected as needing the self-heal patch. + assert!(!has_stage_path_override(None)); + } + + #[test] + fn stage_path_override_absent_when_other_params_present() { + let params = HashMap::from([("someOtherKey".to_string(), "value".to_string())]); + assert!(!has_stage_path_override(Some(¶ms))); + } + + #[test] + fn stage_path_override_absent_when_value_wrong() { + let params = HashMap::from([("overwrite:path".to_string(), "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/literal/path".to_string())]); + assert!(!has_stage_path_override(Some(¶ms))); + } + + #[test] + fn stage_path_override_present_when_correctly_set() { + let params = HashMap::from([("overwrite:path".to_string(), "$request.path".to_string())]); + assert!(has_stage_path_override(Some(¶ms))); + } + + #[test] + fn find_telegram_webhook_finds_url_and_token() { + let secrets = + HashMap::from([("TELEGRAM_BOT_TOKEN".to_string(), "arn:aws:...".to_string())]); + let urls = vec![ + ( + "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/webhook/line".to_string(), + "https://x/prod/webhook/line".to_string(), + ), + ( + "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/webhook/telegram".to_string(), + "https://x/prod/webhook/telegram".to_string(), + ), + ]; + let (url, token) = find_telegram_webhook(&secrets, &urls).unwrap(); + assert_eq!(url, "https://x/prod/webhook/telegram"); + assert_eq!(token, "arn:aws:..."); + } + + #[test] + fn find_telegram_webhook_none_without_telegram_path() { + let secrets = + HashMap::from([("TELEGRAM_BOT_TOKEN".to_string(), "arn:aws:...".to_string())]); + let urls = vec![( + "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/webhook/line".to_string(), + "https://x/prod/webhook/line".to_string(), + )]; + assert!(find_telegram_webhook(&secrets, &urls).is_none()); + } + + #[test] + fn find_telegram_webhook_none_without_bot_token_secret() { + let secrets = HashMap::new(); + let urls = vec![( + "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/webhook/telegram".to_string(), + "https://x/prod/webhook/telegram".to_string(), + )]; + assert!(find_telegram_webhook(&secrets, &urls).is_none()); + } + + #[test] + fn cloud_map_service_id_parses_from_arn() { + assert_eq!( + cloud_map_service_id_from_arn( + "arn:aws:servicediscovery:us-east-1:903779448426:service/srv-abc123" + ), + Some("srv-abc123".to_string()) + ); + } + + #[test] + fn cloud_map_service_id_from_arn_rejects_empty() { + assert_eq!(cloud_map_service_id_from_arn(""), None); + assert_eq!(cloud_map_service_id_from_arn("trailing/"), None); + } + + #[test] + fn api_name_is_per_bot() { + assert_eq!(api_name("prod", "mybot"), "oab-webhook-prod-mybot"); + assert_ne!(api_name("prod", "a"), api_name("prod", "b")); + } + + #[test] + fn vpc_link_name_is_per_vpc() { + assert_eq!(vpc_link_name("vpc-abc123"), "oab-vpc-link-vpc-abc123"); + assert_ne!(vpc_link_name("vpc-aaa"), vpc_link_name("vpc-bbb")); + } + + #[test] + fn vpc_scoped_namespace_differs_per_vpc() { + assert_eq!(vpc_scoped_namespace("oab", "vpc-aaa"), "oab-vpc-aaa"); + assert_ne!( + vpc_scoped_namespace("oab", "vpc-aaa"), + vpc_scoped_namespace("oab", "vpc-bbb") + ); + // Same VPC, different configured namespace names still differ. + assert_ne!( + vpc_scoped_namespace("oab", "vpc-aaa"), + vpc_scoped_namespace("custom", "vpc-aaa") + ); + } + + #[test] + fn webhook_urls_join_endpoint_stage_and_path() { + let paths = vec!["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/webhook/telegram".to_string(), "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/webhook/line".to_string()]; + let urls = webhook_urls("https://abc123.execute-api.us-east-1.amazonaws.com", &paths); + assert_eq!( + urls, + vec![ + "https://abc123.execute-api.us-east-1.amazonaws.com/prod/webhook/telegram", + "https://abc123.execute-api.us-east-1.amazonaws.com/prod/webhook/line", + ] + ); + } + + #[test] + fn webhook_urls_trim_trailing_slash_on_endpoint() { + let paths = vec!["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/webhook/telegram".to_string()]; + let urls = webhook_urls("https://abc123.example.com/", &paths); + assert_eq!( + urls, + vec!["https://abc123.example.com/prod/webhook/telegram"] + ); + } +} diff --git a/crates/oabctl/src/lib.rs b/crates/oabctl/src/lib.rs new file mode 100644 index 0000000..ced58b1 --- /dev/null +++ b/crates/oabctl/src/lib.rs @@ -0,0 +1,73 @@ +//! Programmatic OAB manifest validation and ECS reconciliation. +//! +//! The public facade intentionally contains only the manifest model and the +//! structured apply API. CLI implementation details and resource-management +//! helpers remain private. +//! +//! # Example +//! +//! ```no_run +//! use oabctl::{apply_manifests, ApplyOptions, OABServiceManifest}; +//! +//! # async fn deploy() -> Result<(), Box> { +//! let manifest: OABServiceManifest = serde_yaml::from_str(r#" +//! apiVersion: oab.dev/v2 +//! kind: OABService +//! metadata: { name: bot, namespace: prod } +//! spec: +//! image: example.com/openab:latest +//! resources: { cpu: "256", memory: "512" } +//! configFrom: s3://example/config.toml +//! runtime: +//! type: ecs +//! networking: { subnets: [subnet-123], securityGroups: [sg-123] } +//! "#)?; +//! let aws = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await; +//! let report = apply_manifests( +//! &aws, +//! &[manifest], +//! &ApplyOptions::new("production-cluster"), +//! ).await?; +//! println!("reconciled {} service(s)", report.services.len()); +//! # Ok(()) +//! # } +//! ``` +//! +//! Both CLI and programmatic apply perform an ECS cluster preflight. The +//! caller identity therefore requires `ecs:DescribeClusters`; this ECS action +//! does not support resource-level permissions, so its IAM statement must use +//! `Resource: "*"` even though the request targets the configured cluster. +//! +//! `aws-sm://#` values whose `` is not already +//! an ARN require the apply caller to have `secretsmanager:DescribeSecret`, so +//! oabctl can resolve the name to the full ARN required by ECS. +//! +//! Programmatic apply never reads `~/.oabctl/config.toml`. Use +//! [`ApplyOptions::with_control_plane_bucket`] for an explicit bucket; otherwise +//! resolution uses `OAB_CONTROL_PLANE_BUCKET` and then the caller's AWS account. + +pub mod apply; +mod bootstrap; +mod cli; +mod config; +mod control_plane; +mod create; +mod delete; +mod get; +mod ingress; +pub mod manifest; +mod scale; +mod secrets; + +pub use apply::{ + apply_manifests, AppliedService, ApplyAction, ApplyError, ApplyErrorKind, ApplyOptions, + ApplyReport, ServiceTarget, +}; +pub use manifest::{ + AgentOverride, EcsNetworking, EcsRuntime, FleetMetadata, FleetSpec, FleetTemplate, Ingress, + KubernetesRuntime, Metadata, OABFleetManifest, OABServiceManifest, RawManifest, Resources, + Runtime, Spec, +}; + +#[doc(hidden)] +pub use cli::run_cli; diff --git a/crates/oabctl/src/main.rs b/crates/oabctl/src/main.rs new file mode 100644 index 0000000..8c85e2e --- /dev/null +++ b/crates/oabctl/src/main.rs @@ -0,0 +1,4 @@ +#[tokio::main] +async fn main() -> anyhow::Result<()> { + oabctl::run_cli().await +} diff --git a/crates/oabctl/src/manifest.rs b/crates/oabctl/src/manifest.rs new file mode 100644 index 0000000..fe665f8 --- /dev/null +++ b/crates/oabctl/src/manifest.rs @@ -0,0 +1,651 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Top-level manifest that can be either OABService or OABFleet +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RawManifest { + pub api_version: String, + pub kind: String, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OABServiceManifest { + pub api_version: String, + pub kind: String, + pub metadata: Metadata, + pub spec: Spec, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OABFleetManifest { + pub api_version: String, + pub kind: String, + pub metadata: FleetMetadata, + pub spec: FleetSpec, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct FleetMetadata { + pub name: String, + pub namespace: String, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FleetSpec { + pub template: FleetTemplate, + pub agents: Vec, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct FleetTemplate { + pub image: String, + #[serde(default)] + pub resources: Option, + #[serde(default)] + pub bootstrap_from: Option, + #[serde(default)] + pub secrets: HashMap, + pub runtime: Runtime, + #[serde(default)] + pub ingress: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentOverride { + pub name: String, + pub config_from: String, + #[serde(default)] + pub resources: Option, + #[serde(default)] + pub bootstrap_from: Option, + #[serde(default)] + pub secrets: Option>, + #[serde(default)] + pub image: Option, + #[serde(default)] + pub ingress: Option, +} + +impl OABFleetManifest { + pub fn validate(&self) -> anyhow::Result<()> { + if self.api_version != "oab.dev/v2" { + anyhow::bail!("unsupported apiVersion: {} (expected oab.dev/v2)", self.api_version); + } + if self.kind != "OABFleet" { + anyhow::bail!("unsupported kind: {}", self.kind); + } + if self.metadata.name.is_empty() { + anyhow::bail!("metadata.name is required"); + } + if self.spec.agents.is_empty() { + anyhow::bail!("spec.agents must not be empty"); + } + for agent in &self.spec.agents { + if agent.name.is_empty() { + anyhow::bail!("each agent must have a name"); + } + if agent.config_from.is_empty() { + anyhow::bail!("agent '{}': configFrom is required", agent.name); + } + } + Ok(()) + } + + /// Expand fleet into individual OABService manifests + pub fn expand(&self) -> Vec { + self.spec.agents.iter().map(|agent| { + let resources = agent.resources.clone() + .or(self.spec.template.resources.clone()) + .unwrap_or(Resources { cpu: "256".into(), memory: "512".into() }); + let base_secrets = agent.secrets.clone() + .unwrap_or_else(|| self.spec.template.secrets.clone()); + // Interpolate ${name} in secret values + let secrets = base_secrets.into_iter().map(|(k, v)| { + (k, v.replace("${name}", &agent.name)) + }).collect(); + + OABServiceManifest { + api_version: self.api_version.clone(), + kind: "OABService".to_string(), + metadata: Metadata { + name: agent.name.clone(), + namespace: self.metadata.namespace.clone(), + generation: 0, + }, + spec: Spec { + image: agent.image.clone() + .unwrap_or_else(|| self.spec.template.image.clone()), + resources, + config_from: agent.config_from.replace("${name}", &agent.name), + bootstrap_from: agent.bootstrap_from.clone() + .or(self.spec.template.bootstrap_from.clone()) + .map(|s| s.replace("${name}", &agent.name)), + secrets, + runtime: self.spec.template.runtime.clone(), + ingress: agent + .ingress + .clone() + .or_else(|| self.spec.template.ingress.clone()), + }, + } + }).collect() + } +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct Metadata { + pub name: String, + pub namespace: String, + #[serde(default)] + pub generation: u64, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Spec { + pub image: String, + pub resources: Resources, + pub config_from: String, + #[serde(default)] + pub bootstrap_from: Option, + #[serde(default)] + pub secrets: HashMap, + pub runtime: Runtime, + /// Optional inbound webhook ingress (Telegram/LINE/etc.). + /// When omitted, the service is outbound-only (Discord behavior) — no ingress + /// resources are created. This keeps existing deployments unchanged. + #[serde(default)] + pub ingress: Option, +} + +/// Inbound HTTPS ingress for webhook-based platforms (Telegram, LINE, ...). +/// +/// Provisions the cheapest AWS-native path: API Gateway HTTP API → VPC Link → +/// Cloud Map → the ECS task on `containerPort`. See `operator/README.md` +/// ("Ingress — inbound webhooks") for the manifest schema and operational +/// notes. +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Ingress { + /// Ingress implementation. Currently only `apigateway` is supported. + #[serde(default = "default_ingress_type")] + pub r#type: String, + /// Cloud Map private DNS namespace. Created if missing and reused across + /// services in the same VPC. Defaults to `oab`. + #[serde(default = "default_cloud_map_namespace")] + pub cloud_map_namespace: String, + /// Webhook route paths to expose, e.g. `["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/webhook/telegram", "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/webhook/line"]`. + pub paths: Vec, + /// Container port the OpenAB binary listens on. Defaults to `8080`. + #[serde(default = "default_container_port")] + pub container_port: u16, +} + +fn default_ingress_type() -> String { + "apigateway".to_string() +} + +fn default_cloud_map_namespace() -> String { + "oab".to_string() +} + +fn default_container_port() -> u16 { + 8080 +} + +impl Ingress { + /// Ingress implementations recognized by oabctl. + pub const SUPPORTED_TYPES: &'static [&'static str] = &["apigateway"]; + + pub fn validate(&self) -> anyhow::Result<()> { + if !Self::SUPPORTED_TYPES.contains(&self.r#type.as_str()) { + anyhow::bail!( + "ingress.type must be one of {:?} (got '{}')", + Self::SUPPORTED_TYPES, + self.r#type + ); + } + if self.cloud_map_namespace.is_empty() { + anyhow::bail!("ingress.cloudMapNamespace must not be empty"); + } + if self.paths.is_empty() { + anyhow::bail!("ingress.paths must not be empty"); + } + for p in &self.paths { + if !p.starts_with('/') { + anyhow::bail!("ingress path '{}' must start with '/'", p); + } + } + if self.container_port == 0 { + anyhow::bail!("ingress.containerPort must be non-zero"); + } + Ok(()) + } +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct Resources { + pub cpu: String, + pub memory: String, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum Runtime { + Ecs(EcsRuntime), + Kubernetes(KubernetesRuntime), +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct EcsRuntime { + #[serde(default = "default_capacity_provider")] + pub capacity_provider: String, + /// CPU architecture for the ECS task. Defaults to `X86_64`. + /// Valid values: `X86_64`, `ARM64`. + #[serde(default = "default_architecture")] + pub architecture: String, + /// Optional task role ARN. When set, overrides the bootstrap-provided + /// shared task role. Use for per-service IAM isolation. + #[serde(default)] + pub task_role_arn: Option, + pub networking: EcsNetworking, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct EcsNetworking { + pub subnets: Vec, + pub security_groups: Vec, + #[serde(default)] + pub assign_public_ip: bool, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct KubernetesRuntime { + #[serde(default)] + pub node_selector: HashMap, + #[serde(default)] + pub service_account: Option, + #[serde(default)] + pub tolerations: Vec, +} + +fn default_capacity_provider() -> String { + "FARGATE".to_string() +} + +fn default_architecture() -> String { + "X86_64".to_string() +} + +/// Valid ECS Fargate CPU/memory combinations +const VALID_ECS_CPU: &[&str] = &["256", "512", "1024", "2048", "4096"]; + +impl OABServiceManifest { + pub fn validate(&self) -> anyhow::Result<()> { + if self.api_version != "oab.dev/v2" { + anyhow::bail!("unsupported apiVersion: {} (expected oab.dev/v2)", self.api_version); + } + if self.kind != "OABService" { + anyhow::bail!("unsupported kind: {}", self.kind); + } + if self.metadata.name.is_empty() { + anyhow::bail!("metadata.name is required"); + } + if self.metadata.namespace.is_empty() { + anyhow::bail!("metadata.namespace is required"); + } + if self.spec.image.is_empty() { + anyhow::bail!("spec.image is required"); + } + if self.spec.config_from.is_empty() { + anyhow::bail!("spec.configFrom is required"); + } + match &self.spec.runtime { + Runtime::Ecs(ecs) => { + let valid_cp = ["FARGATE", "FARGATE_SPOT"]; + if !valid_cp.contains(&ecs.capacity_provider.as_str()) { + anyhow::bail!("runtime.capacityProvider must be FARGATE or FARGATE_SPOT"); + } + let valid_arch = ["X86_64", "ARM64"]; + if !valid_arch.contains(&ecs.architecture.as_str()) { + anyhow::bail!( + "runtime.architecture must be one of {:?} (got '{}')", + valid_arch, + ecs.architecture + ); + } + if ecs.networking.subnets.is_empty() { + anyhow::bail!("runtime.networking.subnets must not be empty"); + } + if ecs.networking.security_groups.is_empty() { + anyhow::bail!("runtime.networking.securityGroups must not be empty"); + } + if !VALID_ECS_CPU.contains(&self.spec.resources.cpu.as_str()) { + anyhow::bail!( + "spec.resources.cpu must be one of {:?} for ECS runtime", + VALID_ECS_CPU + ); + } + } + Runtime::Kubernetes(_) => { + // K8S: cpu/memory format validated at deploy time by K8S API + } + } + if let Some(ingress) = &self.spec.ingress { + ingress.validate()?; + if !matches!(&self.spec.runtime, Runtime::Ecs(_)) { + anyhow::bail!( + "spec.ingress is only supported with ECS runtime (use native Kubernetes Ingress otherwise)" + ); + } + } + Ok(()) + } + + pub fn ecs_service_name(&self) -> String { + format!("oab-{}-{}", self.metadata.namespace, self.metadata.name) + } + + /// Cloud Map service name for this manifest (unique per namespace+name). + /// Resolves to `.` in private DNS. + pub fn cloud_map_service_name(&self) -> String { + format!("oab-{}-{}", self.metadata.namespace, self.metadata.name) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const ECS_SERVICE_WITH_INGRESS: &str = r#" +apiVersion: oab.dev/v2 +kind: OABService +metadata: + name: mybot + namespace: prod +spec: + image: public.ecr.aws/oablab/kiro:beta + resources: + cpu: "256" + memory: "512" + configFrom: s3://bucket/config.toml + runtime: + type: ecs + capacityProvider: FARGATE_SPOT + networking: + subnets: ["subnet-a", "subnet-b"] + securityGroups: ["sg-1"] + ingress: + paths: + - /webhook/telegram + - /webhook/line +"#; + + fn parse(yaml: &str) -> OABServiceManifest { + serde_yaml::from_str(yaml).expect("parse") + } + + #[test] + fn parses_ingress_with_defaults() { + let m = parse(ECS_SERVICE_WITH_INGRESS); + let ing = m.spec.ingress.as_ref().expect("ingress present"); + assert_eq!(ing.r#type, "apigateway"); + assert_eq!(ing.cloud_map_namespace, "oab"); + assert_eq!(ing.container_port, 8080); + assert_eq!(ing.paths, vec!["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/webhook/telegram", "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/webhook/line"]); + m.validate().expect("valid"); + } + + #[test] + fn ingress_is_optional_and_backward_compatible() { + let yaml = ECS_SERVICE_WITH_INGRESS.split(" ingress:").next().unwrap(); + let m = parse(yaml); + assert!(m.spec.ingress.is_none()); + m.validate().expect("valid without ingress"); + } + + #[test] + fn rejects_unknown_ingress_type() { + let ing = Ingress { + r#type: "nginx".into(), + cloud_map_namespace: "oab".into(), + paths: vec!["/webhook".into()], + container_port: 8080, + }; + assert!(ing.validate().is_err()); + } + + #[test] + fn rejects_empty_paths() { + let ing = Ingress { + r#type: "apigateway".into(), + cloud_map_namespace: "oab".into(), + paths: vec![], + container_port: 8080, + }; + assert!(ing.validate().is_err()); + } + + #[test] + fn rejects_path_without_leading_slash() { + let ing = Ingress { + r#type: "apigateway".into(), + cloud_map_namespace: "oab".into(), + paths: vec!["webhook/telegram".into()], + container_port: 8080, + }; + assert!(ing.validate().is_err()); + } + + #[test] + fn rejects_ingress_on_kubernetes_runtime() { + let yaml = r#" +apiVersion: oab.dev/v2 +kind: OABService +metadata: + name: mybot + namespace: prod +spec: + image: img:tag + resources: + cpu: "256" + memory: "512" + configFrom: s3://bucket/config.toml + runtime: + type: kubernetes + nodeSelector: {} + ingress: + paths: ["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/webhook/telegram"] +"#; + let m = parse(yaml); + assert!(m.validate().is_err()); + } + + #[test] + fn accepts_arm64_architecture() { + let yaml = r#" +apiVersion: oab.dev/v2 +kind: OABService +metadata: + name: mybot + namespace: prod +spec: + image: img:tag + resources: + cpu: "256" + memory: "512" + configFrom: s3://bucket/config.toml + runtime: + type: ecs + architecture: ARM64 + capacityProvider: FARGATE_SPOT + networking: + subnets: ["subnet-a"] + securityGroups: ["sg-1"] +"#; + let m = parse(yaml); + m.validate().expect("ARM64 should be valid"); + } + + #[test] + fn accepts_x86_64_architecture() { + let yaml = r#" +apiVersion: oab.dev/v2 +kind: OABService +metadata: + name: mybot + namespace: prod +spec: + image: img:tag + resources: + cpu: "256" + memory: "512" + configFrom: s3://bucket/config.toml + runtime: + type: ecs + architecture: X86_64 + capacityProvider: FARGATE_SPOT + networking: + subnets: ["subnet-a"] + securityGroups: ["sg-1"] +"#; + let m = parse(yaml); + m.validate().expect("X86_64 should be valid"); + } + + #[test] + fn defaults_to_x86_64_when_architecture_omitted() { + let yaml = r#" +apiVersion: oab.dev/v2 +kind: OABService +metadata: + name: mybot + namespace: prod +spec: + image: img:tag + resources: + cpu: "256" + memory: "512" + configFrom: s3://bucket/config.toml + runtime: + type: ecs + capacityProvider: FARGATE_SPOT + networking: + subnets: ["subnet-a"] + securityGroups: ["sg-1"] +"#; + let m = parse(yaml); + m.validate().expect("should be valid with default architecture"); + match &m.spec.runtime { + Runtime::Ecs(ecs) => assert_eq!(ecs.architecture, "X86_64"), + _ => panic!("expected ECS runtime"), + } + } + + #[test] + fn rejects_invalid_architecture() { + let yaml = r#" +apiVersion: oab.dev/v2 +kind: OABService +metadata: + name: mybot + namespace: prod +spec: + image: img:tag + resources: + cpu: "256" + memory: "512" + configFrom: s3://bucket/config.toml + runtime: + type: ecs + architecture: MIPS + capacityProvider: FARGATE_SPOT + networking: + subnets: ["subnet-a"] + securityGroups: ["sg-1"] +"#; + let m = parse(yaml); + let err = m.validate().unwrap_err(); + assert!(err.to_string().contains("runtime.architecture must be one of")); + } + + #[test] + fn rejects_lowercase_architecture() { + let yaml = r#" +apiVersion: oab.dev/v2 +kind: OABService +metadata: + name: mybot + namespace: prod +spec: + image: img:tag + resources: + cpu: "256" + memory: "512" + configFrom: s3://bucket/config.toml + runtime: + type: ecs + architecture: arm64 + capacityProvider: FARGATE_SPOT + networking: + subnets: ["subnet-a"] + securityGroups: ["sg-1"] +"#; + let m = parse(yaml); + let err = m.validate().unwrap_err(); + assert!(err.to_string().contains("runtime.architecture must be one of")); + } + + #[test] + fn fleet_passes_ingress_from_template_and_override_wins() { + let yaml = r#" +apiVersion: oab.dev/v2 +kind: OABFleet +metadata: + name: bots + namespace: prod +spec: + template: + image: img:tag + runtime: + type: ecs + capacityProvider: FARGATE_SPOT + networking: + subnets: ["subnet-a"] + securityGroups: ["sg-1"] + ingress: + paths: ["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/webhook/telegram"] + agents: + - name: fromtemplate + configFrom: s3://b/${name}.toml + - name: overridden + configFrom: s3://b/${name}.toml + ingress: + cloudMapNamespace: custom + paths: ["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/webhook/line"] +"#; + let fleet: OABFleetManifest = serde_yaml::from_str(yaml).expect("parse fleet"); + fleet.validate().expect("valid fleet"); + let expanded = fleet.expand(); + assert_eq!(expanded.len(), 2); + + let from_template = &expanded[0]; + let ing = from_template.spec.ingress.as_ref().expect("template ingress"); + assert_eq!(ing.paths, vec!["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/webhook/telegram"]); + assert_eq!(ing.cloud_map_namespace, "oab"); + + let overridden = &expanded[1]; + let ing = overridden.spec.ingress.as_ref().expect("override ingress"); + assert_eq!(ing.paths, vec!["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/webhook/line"]); + assert_eq!(ing.cloud_map_namespace, "custom"); + } +} diff --git a/crates/oabctl/src/scale.rs b/crates/oabctl/src/scale.rs new file mode 100644 index 0000000..77765a3 --- /dev/null +++ b/crates/oabctl/src/scale.rs @@ -0,0 +1,711 @@ +use anyhow::{Context, Result}; +use aws_sdk_scheduler::error::ProvideErrorMetadata; + +/// Resolve a service name to (cluster, service_name). +/// Only resolves against oabctl-managed services (oab cluster, oab-* prefix). +/// Does NOT use ecsctl aliases — scheduled scaling is for oabctl services only. +async fn resolve_service( + aws_config: &aws_config::SdkConfig, + name: &str, +) -> Result<(String, String)> { + let oab_cfg = + crate::config::OabConfig::load().context("failed to load ~/.oabctl/config.toml")?; + let cluster = oab_cfg.defaults.cluster; + let namespace = oab_cfg.defaults.namespace; + let service_name = format!("oab-{}-{}", namespace, name); + + // Verify the service exists in the oab cluster + let ecs = aws_sdk_ecs::Client::new(aws_config); + let resp = ecs + .describe_services() + .cluster(&cluster) + .services(&service_name) + .send() + .await + .context("failed to describe ECS service")?; + + let svc = resp.services().first(); + match svc { + Some(s) if s.status() == Some("ACTIVE") => {} + Some(s) => { + let status = s.status().unwrap_or("UNKNOWN"); + anyhow::bail!( + "service '{}' is {} — cannot scale. Use 'oabctl get oabservice' to list available services.", + name, status + ); + } + None => { + anyhow::bail!( + "service '{}' not found. Use 'oabctl get oabservice' to list available services.\n\ + Note: oabctl scale only works with oabctl-managed services, not ecsctl aliases.", + name + ); + } + } + + Ok((cluster, service_name)) +} + +/// Immediate scale: delegates to ecsctl's scale_service for the core ECS call. +pub async fn run(aws_config: &aws_config::SdkConfig, alias: &str, size: i32) -> Result<()> { + validate_size(size)?; + let (cluster, service_name) = resolve_service(aws_config, alias).await?; + let ecs = aws_sdk_ecs::Client::new(aws_config); + + ecsctl::scale::scale_service(&ecs, &cluster, &service_name, size, false).await?; + + Ok(()) +} + +/// Build the scheduler Target for ECS UpdateService. +fn build_schedule_target( + role_arn: &str, + target_input: &str, +) -> Result { + aws_sdk_scheduler::types::Target::builder() + .arn("arn:aws:scheduler:::aws-sdk:ecs:updateService") + .role_arn(role_arn) + .input(target_input) + .build() + .context("failed to build scheduler target") +} + +/// Build the FlexibleTimeWindow (OFF mode). +fn build_flexible_time_window() -> Result { + aws_sdk_scheduler::types::FlexibleTimeWindow::builder() + .mode(aws_sdk_scheduler::types::FlexibleTimeWindowMode::Off) + .build() + .context("failed to build flexible time window") +} + +/// Validate scale size — OAB services are single-instance (one bot token per service). +/// Only 0 (off) or 1 (on) is valid. +fn validate_size(size: i32) -> Result<()> { + if size != 0 && size != 1 { + anyhow::bail!( + "invalid size: {}. OAB services can only scale to 0 (off) or 1 (on) — \ + each service runs a single bot token and scaling above 1 would cause duplicate responses.", + size + ); + } + Ok(()) +} + +/// Validate schedule expression format. +/// EventBridge Scheduler accepts: cron(...), rate(...), at(...) +fn validate_schedule_expression(expr: &str) -> Result<()> { + let trimmed = expr.trim(); + if trimmed.starts_with("cron(") && trimmed.ends_with(')') { + // cron expressions should have 6 fields: min hour dom month dow year + let inner = &trimmed[5..trimmed.len() - 1]; + let fields: Vec<&str> = inner.split_whitespace().collect(); + if fields.len() != 6 { + anyhow::bail!( + "invalid cron expression: expected 6 fields (min hour dom month dow year), got {}.\n\ + Example: cron(0 8 * * ? *)", + fields.len() + ); + } + Ok(()) + } else if trimmed.starts_with("rate(") && trimmed.ends_with(')') { + let inner = &trimmed[5..trimmed.len() - 1].trim(); + if inner.is_empty() { + anyhow::bail!( + "invalid rate expression: empty value.\n\ + Example: rate(1 hour) or rate(5 minutes)" + ); + } + Ok(()) + } else if trimmed.starts_with("at(") && trimmed.ends_with(')') { + Ok(()) + } else { + anyhow::bail!( + "invalid schedule expression: '{}'\n\ + Must start with cron(...), rate(...), or at(...).\n\ + Examples:\n\ + - cron(0 8 * * ? *) — daily at 8:00 AM\n\ + - rate(1 hour) — every hour\n\ + - at(2024-01-01T00:00:00) — one-time", + trimmed + ); + } +} + +/// Check if a schedule exists. Returns true only for confirmed existence; +/// returns false for ResourceNotFoundException; propagates other errors. +async fn schedule_exists( + scheduler: &aws_sdk_scheduler::Client, + name: &str, + group_name: &str, +) -> Result { + match scheduler + .get_schedule() + .name(name) + .group_name(group_name) + .send() + .await + { + Ok(_) => Ok(true), + Err(e) => { + let service_err = e.as_service_error(); + if service_err + .map(|se| se.is_resource_not_found_exception()) + .unwrap_or(false) + { + Ok(false) + } else { + Err(e).context(format!("failed to check if schedule '{}' exists", name)) + } + } + } +} + +/// Scheduled scale: create an EventBridge Scheduler schedule that calls +/// ECS UpdateService at the given schedule expression. +pub async fn run_with_schedule( + aws_config: &aws_config::SdkConfig, + alias: &str, + size: i32, + schedule_expression: &str, + timezone: Option<&str>, +) -> Result<()> { + validate_size(size)?; + // Basic input validation for schedule expression + validate_schedule_expression(schedule_expression)?; + + let (cluster, service_name) = resolve_service(aws_config, alias).await?; + let scheduler = aws_sdk_scheduler::Client::new(aws_config); + let sts = aws_sdk_sts::Client::new(aws_config); + let iam = aws_sdk_iam::Client::new(aws_config); + + // Get account ID for ARN construction + let identity = sts + .get_caller_identity() + .send() + .await + .context("failed to get caller identity")?; + let account_id = identity.account().context("no account ID")?; + let region = aws_config + .region() + .map(|r| r.as_ref().to_string()) + .unwrap_or_else(|| "us-east-1".to_string()); + + // Ensure schedule group exists + let group_name = "oab-schedules"; + ensure_schedule_group(&scheduler, group_name).await?; + + // Ensure scheduler IAM role exists + let role_arn = ensure_scheduler_role(&iam, account_id, ®ion).await?; + + // Build schedule name: oab-scale-{alias}-to-{size} + // AWS schedule names: max 64 chars, pattern [0-9a-zA-Z-_.]+ + // Truncate alias (not suffix) to preserve -to-{size} for uniqueness + let safe_alias = alias.replace( + |c: char| !c.is_ascii_alphanumeric() && c != '-' && c != '_' && c != '.', + "-", + ); + let suffix = format!("-to-{}", size); + let prefix = "oab-scale-"; + let max_alias_len = 64 - prefix.len() - suffix.len(); + let truncated_alias = if safe_alias.len() > max_alias_len { + &safe_alias[..max_alias_len] + } else { + &safe_alias + }; + let schedule_name = format!("{}{}{}", prefix, truncated_alias, suffix); + + // Build the ECS UpdateService input for the universal target + let target_input = serde_json::json!({ + "Cluster": cluster, + "Service": service_name, + "DesiredCount": size + }); + let target_input_str = target_input.to_string(); + + let tz = timezone.unwrap_or("UTC"); + + // Check if schedule already exists (properly handles transient errors) + let exists = schedule_exists(&scheduler, &schedule_name, group_name).await?; + + let target = build_schedule_target(&role_arn, &target_input_str)?; + let flexible_time_window = build_flexible_time_window()?; + + if exists { + scheduler + .update_schedule() + .name(&schedule_name) + .group_name(group_name) + .schedule_expression(schedule_expression) + .schedule_expression_timezone(tz) + .flexible_time_window(flexible_time_window) + .target(target) + .send() + .await + .context("failed to update schedule")?; + } else { + // Retry with backoff, but ONLY for IAM propagation errors (AccessDeniedException). + // Other errors (validation, quota, etc.) fail immediately. + let mut last_err = None; + for attempt in 0..5 { + if attempt > 0 { + let delay = std::time::Duration::from_secs(2u64.pow(attempt)); + eprintln!( + " retrying schedule creation (attempt {}/5, waiting {}s for IAM propagation)...", + attempt + 1, + delay.as_secs() + ); + tokio::time::sleep(delay).await; + } + let t = build_schedule_target(&role_arn, &target_input_str)?; + let ftw = build_flexible_time_window()?; + match scheduler + .create_schedule() + .name(&schedule_name) + .group_name(group_name) + .schedule_expression(schedule_expression) + .schedule_expression_timezone(tz) + .flexible_time_window(ftw) + .target(t) + .send() + .await + { + Ok(_) => { + last_err = None; + break; + } + Err(e) => { + // Retry on IAM propagation delays. These manifest as: + // - AccessDeniedException: role not yet assumable + // - ValidationException with "execution role": role propagation pending + // All other errors (quota, conflict, bad input) fail immediately. + let is_iam_propagation = e + .as_service_error() + .map(|se| { + if se.is_validation_exception() { + // ValidationException during role propagation mentions "execution role" + se.message() + .map(|m| m.contains("execution role")) + .unwrap_or(false) + } else { + // AccessDeniedException is unmodeled; check via code() + se.code() + .map(|c| c == "AccessDeniedException" || c == "AccessDenied") + .unwrap_or(false) + } + }) + .unwrap_or(false); + if is_iam_propagation { + last_err = Some(e); + } else { + return Err(e).context("failed to create schedule"); + } + } + } + } + if let Some(e) = last_err { + return Err(e).context( + "failed to create schedule after retries (IAM role may not have propagated)", + ); + } + } + + let action = if exists { "Updated" } else { "Created" }; + println!("✓ Schedule {action}: {schedule_name}"); + println!(" Expression: {schedule_expression} ({tz})"); + println!(" Action: scale {alias} ({service_name}) to {size}"); + println!(" Group: {group_name}"); + println!("\n Use 'oabctl schedule list' to view all schedules"); + println!(" Use 'oabctl schedule delete {schedule_name}' to remove"); + Ok(()) +} + +/// List all schedules in the oab-schedules group. +pub async fn list_schedules(aws_config: &aws_config::SdkConfig) -> Result<()> { + let scheduler = aws_sdk_scheduler::Client::new(aws_config); + let group_name = "oab-schedules"; + + // Paginate through all schedules + let mut all_schedules = Vec::new(); + let mut next_token: Option = None; + + loop { + let mut req = scheduler.list_schedules().group_name(group_name); + if let Some(token) = &next_token { + req = req.next_token(token); + } + + let resp = req.send().await; + + match resp { + Ok(output) => { + all_schedules.extend(output.schedules().to_vec()); + next_token = output.next_token().map(|s| s.to_string()); + if next_token.is_none() { + break; + } + } + Err(e) => { + if e.as_service_error() + .map(|se| se.is_resource_not_found_exception()) + .unwrap_or(false) + { + println!("No schedules configured yet."); + println!( + " Use 'oabctl schedule create --expr ' to create one." + ); + return Ok(()); + } else { + anyhow::bail!("failed to list schedules: {e}"); + } + } + } + } + + if all_schedules.is_empty() { + println!("No schedules found in group '{group_name}'."); + println!(" Use 'oabctl schedule create --expr ' to create one."); + return Ok(()); + } + + // Warn about N+1 latency for large schedule counts + if all_schedules.len() > 10 { + eprintln!( + " note: fetching details for {} schedules — this may take a moment...", + all_schedules.len() + ); + } + + println!("{:<40} {:<30} {:<16} STATE", "NAME", "SCHEDULE", "TIMEZONE"); + for s in &all_schedules { + let name = s.name().unwrap_or("-"); + let state = s.state().map(|st| st.as_str()).unwrap_or("?"); + + // Fetch full schedule to get expression and timezone + // Note: N+1 API calls — acceptable for typical oab schedule counts (<20) + let (expr, tz) = match scheduler + .get_schedule() + .name(name) + .group_name(group_name) + .send() + .await + { + Ok(detail) => { + let e = detail.schedule_expression().unwrap_or("-").to_string(); + let t = detail + .schedule_expression_timezone() + .unwrap_or("UTC") + .to_string(); + (e, t) + } + Err(_) => ("-".to_string(), "-".to_string()), + }; + + println!("{:<40} {:<30} {:<16} {}", name, expr, tz, state); + } + + Ok(()) +} + +/// Delete a specific schedule (idempotent — already-deleted is not an error). +pub async fn delete_schedule(aws_config: &aws_config::SdkConfig, name: &str) -> Result<()> { + let scheduler = aws_sdk_scheduler::Client::new(aws_config); + let group_name = "oab-schedules"; + + match scheduler + .delete_schedule() + .name(name) + .group_name(group_name) + .send() + .await + { + Ok(_) => { + println!("✓ Deleted schedule: {name}"); + } + Err(e) => { + if e.as_service_error() + .map(|se| se.is_resource_not_found_exception()) + .unwrap_or(false) + { + println!("Schedule '{name}' not found (already deleted or never existed)."); + } else { + return Err(e).context(format!("failed to delete schedule '{name}'")); + } + } + } + + Ok(()) +} + +/// Ensure the oab-schedules group exists (idempotent). +async fn ensure_schedule_group( + scheduler: &aws_sdk_scheduler::Client, + group_name: &str, +) -> Result<()> { + let resp = scheduler.get_schedule_group().name(group_name).send().await; + + if resp.is_err() { + let create_result = scheduler + .create_schedule_group() + .name(group_name) + .send() + .await; + + // Ignore ConflictException (race condition / already exists) + if let Err(e) = create_result { + if !e + .as_service_error() + .map(|se| se.is_conflict_exception()) + .unwrap_or(false) + { + anyhow::bail!("failed to create schedule group: {e}"); + } + } + } + + Ok(()) +} + +/// Ensure the oab-scheduler-role exists (for EventBridge Scheduler to call ECS). +/// Also verifies the inline policy is attached (recovers from partial-state). +async fn ensure_scheduler_role( + iam: &aws_sdk_iam::Client, + account_id: &str, + region: &str, +) -> Result { + let role_name = "oab-scheduler-role"; + let role_arn = format!("arn:aws:iam::{}:role/{}", account_id, role_name); + let policy_name = "oab-ecs-scale"; + + // Check if role exists + let role_exists = iam.get_role().role_name(role_name).send().await.is_ok(); + + if !role_exists { + // Create the role with confused-deputy protection + let trust_policy = serde_json::json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": { + "Service": "scheduler.amazonaws.com" + }, + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { + "aws:SourceAccount": account_id, + "aws:SourceArn": format!("arn:aws:scheduler:{region}:{account_id}:schedule-group/oab-schedules") + } + } + }] + }); + + let create_result = iam + .create_role() + .role_name(role_name) + .assume_role_policy_document(trust_policy.to_string()) + .description( + "Allows EventBridge Scheduler to call ECS UpdateService for oabctl scale schedules", + ) + .send() + .await; + + // Ignore EntityAlreadyExists (race condition with concurrent oabctl runs) + if let Err(e) = create_result { + if !e + .as_service_error() + .map(|se| se.is_entity_already_exists_exception()) + .unwrap_or(false) + { + return Err(e).context("failed to create scheduler IAM role"); + } + } + } + + // Always ensure inline policy is current (put_role_policy is idempotent — + // overwrites existing policy with same name, handles stale/outdated scope). + // Use wildcard for cluster to support multi-cluster deployments — the oab-* + // prefix on service name provides sufficient scope restriction. + let ecs_policy = serde_json::json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": "ecs:UpdateService", + "Resource": format!("arn:aws:ecs:{region}:{account_id}:service/*/oab-*") + }] + }); + + iam.put_role_policy() + .role_name(role_name) + .policy_name(policy_name) + .policy_document(ecs_policy.to_string()) + .send() + .await + .context("failed to attach policy to scheduler role")?; + + // Wait for IAM propagation if role was just created. + // IAM is eventually consistent; rather than a fixed sleep, we rely on + // retry logic at schedule creation time if the first attempt fails with + // AccessDenied due to propagation delay. + if !role_exists { + eprintln!(" ✓ Created IAM role: {role_name} (may take a few seconds to propagate)"); + } + + Ok(role_arn) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validate_schedule_expression_valid_cron() { + assert!(validate_schedule_expression("cron(0 8 * * ? *)").is_ok()); + assert!(validate_schedule_expression("cron(30 12 1 * ? 2024)").is_ok()); + } + + #[test] + fn test_validate_schedule_expression_invalid_cron_fields() { + // 5 fields instead of 6 + let result = validate_schedule_expression("cron(0 8 * * ?)"); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("expected 6 fields")); + } + + #[test] + fn test_validate_schedule_expression_valid_rate() { + assert!(validate_schedule_expression("rate(1 hour)").is_ok()); + assert!(validate_schedule_expression("rate(5 minutes)").is_ok()); + } + + #[test] + fn test_validate_schedule_expression_empty_rate() { + let result = validate_schedule_expression("rate()"); + assert!(result.is_err()); + } + + #[test] + fn test_validate_schedule_expression_valid_at() { + assert!(validate_schedule_expression("at(2024-01-01T00:00:00)").is_ok()); + } + + #[test] + fn test_validate_schedule_expression_invalid_prefix() { + let result = validate_schedule_expression("every 5 minutes"); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("Must start with cron("), "got: {}", err); + } + + #[test] + fn test_validate_schedule_expression_missing_parens() { + let result = validate_schedule_expression("cron 0 8 * * ? *"); + assert!(result.is_err()); + } + + #[test] + fn test_schedule_name_sanitization() { + // Test the same logic used in run_with_schedule for schedule naming + let alias = "my-bot/special"; + let safe_alias = alias.replace( + |c: char| !c.is_ascii_alphanumeric() && c != '-' && c != '_' && c != '.', + "-", + ); + let schedule_name = format!("oab-scale-{}-to-{}", safe_alias, 0); + assert_eq!(schedule_name, "oab-scale-my-bot-special-to-0"); + } + + #[test] + fn test_schedule_name_sanitization_unicode() { + // Unicode chars are NOT valid in AWS schedule names — replaced with '-' + let alias = "bot名前"; + let safe_alias = alias.replace( + |c: char| !c.is_ascii_alphanumeric() && c != '-' && c != '_' && c != '.', + "-", + ); + let schedule_name = format!("oab-scale-{}-to-{}", safe_alias, 1); + assert_eq!(schedule_name, "oab-scale-bot---to-1"); + } + + #[test] + fn test_schedule_name_sanitization_special_chars() { + let alias = "my.bot@prod"; + let safe_alias = alias.replace( + |c: char| !c.is_ascii_alphanumeric() && c != '-' && c != '_' && c != '.', + "-", + ); + assert_eq!(safe_alias, "my.bot-prod"); + } + + #[test] + fn test_schedule_name_length_cap() { + let alias = "a-very-long-service-name-that-exceeds-the-sixty-four-character-limit"; + let safe_alias = alias.replace( + |c: char| !c.is_ascii_alphanumeric() && c != '-' && c != '_' && c != '.', + "-", + ); + let size = 0; + let suffix = format!("-to-{}", size); + let prefix = "oab-scale-"; + let max_alias_len = 64 - prefix.len() - suffix.len(); + let truncated_alias = if safe_alias.len() > max_alias_len { + &safe_alias[..max_alias_len] + } else { + &safe_alias + }; + let schedule_name = format!("{}{}{}", prefix, truncated_alias, suffix); + assert!(schedule_name.len() <= 64); + // Verify suffix is preserved (different sizes produce different names) + assert!(schedule_name.ends_with("-to-0")); + } + + #[test] + fn test_build_schedule_target() { + let target = build_schedule_target( + "arn:aws:iam::123456789012:role/test-role", + r#"{"Cluster":"test","Service":"svc","DesiredCount":1}"#, + ); + assert!(target.is_ok()); + let t = target.unwrap(); + assert_eq!(t.arn(), "arn:aws:scheduler:::aws-sdk:ecs:updateService"); + assert_eq!(t.role_arn(), "arn:aws:iam::123456789012:role/test-role"); + } + + #[test] + fn test_build_flexible_time_window() { + let ftw = build_flexible_time_window(); + assert!(ftw.is_ok()); + } + + #[test] + fn test_validate_size_zero() { + assert!(validate_size(0).is_ok()); + } + + #[test] + fn test_validate_size_one() { + assert!(validate_size(1).is_ok()); + } + + #[test] + fn test_validate_size_rejects_two() { + let result = validate_size(2); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("invalid size: 2")); + assert!(err.contains("0 (off) or 1 (on)")); + } + + #[test] + fn test_validate_size_rejects_negative() { + let result = validate_size(-1); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("invalid size: -1")); + } + + #[test] + fn test_validate_size_rejects_large() { + let result = validate_size(100); + assert!(result.is_err()); + } +} diff --git a/crates/oabctl/src/secrets.rs b/crates/oabctl/src/secrets.rs new file mode 100644 index 0000000..5c17be0 --- /dev/null +++ b/crates/oabctl/src/secrets.rs @@ -0,0 +1,259 @@ +//! Shared resolution for `spec.secrets` values. +//! +//! Values can be either a Secrets Manager reference in ECS-native +//! `valueFrom` format directly (a full ARN, optionally suffixed with +//! `:::` to extract one field of a JSON secret — an ECS-only +//! convention; the Secrets Manager API itself has no knowledge of it), or +//! the same `aws-sm://#` shorthand openab itself uses +//! for in-app secret refs (see `crates/openab-core/src/secrets.rs`) — kept +//! identical here so a manifest author can write one convention across both +//! `spec.secrets` (consumed by ECS at container launch) and `config.toml` +//! (consumed by openab itself at runtime). + +use anyhow::{Context, Result}; + +/// Parse `aws-sm://#` into `(secret_id, json_key)`. +/// Returns `None` if `value` doesn't use the `aws-sm://` scheme. +fn parse_aws_sm_uri(value: &str) -> Option> { + let rest = value.strip_prefix("aws-sm://")?; + Some(match rest.rsplit_once('#') { + Some((secret_id, json_key)) if !secret_id.is_empty() && !json_key.is_empty() => { + Ok((secret_id, json_key)) + } + _ => Err(anyhow::anyhow!( + "invalid aws-sm:// secret ref '{value}' — expected aws-sm://#" + )), + }) +} + +/// Resolve a `spec.secrets` value into the ECS-native `valueFrom` format ECS +/// actually requires. ECS's `valueFrom` requires the *full* ARN (not just a +/// secret name) whenever a JSON-key suffix is present, so an `aws-sm://` +/// secret-id that isn't already an ARN is resolved to its ARN via +/// `DescribeSecret` first. Values already in ECS-native format are passed +/// through unchanged. +pub async fn resolve_value_from( + sm: &aws_sdk_secretsmanager::Client, + value: &str, +) -> Result { + let Some(parsed) = parse_aws_sm_uri(value) else { + return Ok(value.to_string()); + }; + let (secret_id, json_key) = parsed?; + + let arn = if secret_id.starts_with("arn:") { + secret_id.to_string() + } else { + sm.describe_secret() + .secret_id(secret_id) + .send() + .await + .with_context(|| format!("failed to resolve secret '{secret_id}' to an ARN"))? + .arn() + .with_context(|| format!("secret '{secret_id}' has no ARN"))? + .to_string() + }; + Ok(format!("{arn}:{json_key}::")) +} + +/// Split an ECS-native `valueFrom` value into its base secret ARN and an +/// optional JSON key, if it carries ECS's suffix for extracting one field of +/// a JSON secret. Only applies to values already shaped like a Secrets +/// Manager ARN — a bare secret name is never split. Returns `(value, None)` +/// unchanged if there's no `secret:` component at all (not a Secrets +/// Manager ARN) or no suffix is present. +/// +/// The full ECS syntax has three optional positional fields after the +/// secret name, always present as colons even when empty: +/// `arn:...:secret:-:::` +/// (see the ECS docs' "Example referencing a specific key/version" section: +/// ). +/// Only the json-key field is supported here — `oabctl`'s own use case +/// (fetching a plaintext secret value in-process) never needs to pin a +/// specific rotation version. A value with a non-empty version-stage or +/// version-id fails closed with a clear error instead of silently +/// mis-splitting or ignoring those fields. +fn split_ecs_json_key_suffix(value: &str) -> Result<(&str, Option<&str>)> { + // The base ARN's secret-name segment is `secret:-<6-char-suffix>` + // and never contains a colon itself, so the first `:` after `secret:` + // unambiguously starts the optional field suffix (if any) — this is + // what let a value like `arn:...:secret:mysecret::` be misparsed before + // (treating `mysecret` as a json-key, when it's actually the secret + // name with all three optional fields empty). + let Some(secret_marker) = value.find(":secret:") else { + return Ok((value, None)); + }; + let after_name_start = secret_marker + ":secret:".len(); + let Some(name_end) = value[after_name_start..].find(':') else { + // No suffix at all — a bare secret ARN. + return Ok((value, None)); + }; + let base = &value[..after_name_start + name_end]; + let fields: Vec<&str> = value[after_name_start + name_end + 1..].split(':').collect(); + let (json_key, version_stage, version_id) = match fields.as_slice() { + [k] => (*k, "", ""), + [k, s] => (*k, *s, ""), + [k, s, i] => (*k, *s, *i), + _ => anyhow::bail!( + "unrecognized Secrets Manager ARN suffix in '{value}' — expected at most \ + ::" + ), + }; + if !version_stage.is_empty() || !version_id.is_empty() { + anyhow::bail!( + "'{value}' pins a specific secret version (version-stage/version-id), which \ + oabctl does not support when resolving a secret's plaintext value in-process \ + (only when passing it through as an ECS task-definition valueFrom, where ECS \ + itself resolves the version) — use the secret's AWSCURRENT version instead" + ); + } + if json_key.is_empty() { + return Ok((base, None)); + } + Ok((base, Some(json_key))) +} + +/// Resolve a `spec.secrets` value to its plain string content, for callers +/// that need the actual secret value in-process (e.g. calling a third-party +/// API on the caller's behalf) rather than an ECS `valueFrom` reference. +/// Supports the same two forms as [`resolve_value_from`]: `aws-sm://...#...` +/// (fetched and JSON-key-extracted here), or a plain/ECS-native Secrets +/// Manager ARN — including one already carrying a `:::` suffix. +/// That suffix is an ECS-only convention (resolved by ECS itself at +/// container launch, via `register_task_definition`'s `valueFrom` field) — +/// the Secrets Manager `GetSecretValue` API has no knowledge of it and +/// rejects it as an invalid secret ID, so it's stripped and the JSON key +/// extracted manually here, the same way the `aws-sm://` form is. +pub async fn resolve_string(sm: &aws_sdk_secretsmanager::Client, value: &str) -> Result { + let (secret_id, json_key) = match parse_aws_sm_uri(value) { + Some(parsed) => { + let (id, key) = parsed?; + (id, Some(key)) + } + None => split_ecs_json_key_suffix(value)?, + }; + + let secret_string = sm + .get_secret_value() + .secret_id(secret_id) + .send() + .await + .with_context(|| format!("failed to fetch secret '{secret_id}' from Secrets Manager"))? + .secret_string() + .with_context(|| format!("secret '{secret_id}' has no string value"))? + .to_string(); + + let Some(json_key) = json_key else { + return Ok(secret_string); + }; + let json: serde_json::Value = serde_json::from_str(&secret_string) + .with_context(|| format!("secret '{secret_id}' is not valid JSON"))?; + json.get(json_key) + .and_then(|v| v.as_str()) + .map(|v| v.to_string()) + .with_context(|| format!("JSON key '{json_key}' not found in secret '{secret_id}'")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn split_ecs_json_key_suffix_extracts_key_from_real_world_arn() { + // The exact shape that surfaced the original bug: an ECS-native + // valueFrom ARN with a JSON-key suffix, passed to resolve_string + // (which used to hand this straight to GetSecretValue and fail, + // since that API has no knowledge of the trailing ECS suffix). + let (base, key) = split_ecs_json_key_suffix( + "arn:aws:secretsmanager:us-east-1:903779448426:secret:oab/telegram/pahudxbot-AC80TP:TELEGRAM_BOT_TOKEN::", + ) + .unwrap(); + assert_eq!(base, "arn:aws:secretsmanager:us-east-1:903779448426:secret:oab/telegram/pahudxbot-AC80TP"); + assert_eq!(key, Some("TELEGRAM_BOT_TOKEN")); + } + + #[test] + fn split_ecs_json_key_suffix_unchanged_for_plain_arn() { + let (base, key) = split_ecs_json_key_suffix( + "arn:aws:secretsmanager:us-east-1:903779448426:secret:oab/telegram/pahudxbot-AC80TP", + ) + .unwrap(); + assert_eq!(base, "arn:aws:secretsmanager:us-east-1:903779448426:secret:oab/telegram/pahudxbot-AC80TP"); + assert_eq!(key, None); + } + + #[test] + fn split_ecs_json_key_suffix_unchanged_for_bare_secret_name() { + let (base, key) = split_ecs_json_key_suffix("plain-secret-name").unwrap(); + assert_eq!(base, "plain-secret-name"); + assert_eq!(key, None); + } + + #[test] + fn split_ecs_json_key_suffix_does_not_mistake_secret_name_for_json_key() { + // Review finding #1: a full-secret-value reference with all three + // optional fields empty (`::`) must not be misparsed as + // json-key="mysecret" — "mysecret" here is part of the secret + // name/base ARN, not a suffix field. + let (base, key) = + split_ecs_json_key_suffix("arn:aws:secretsmanager:us-east-1:903779448426:secret:mysecret::").unwrap(); + assert_eq!(base, "arn:aws:secretsmanager:us-east-1:903779448426:secret:mysecret"); + assert_eq!(key, None); + } + + #[test] + fn split_ecs_json_key_suffix_rejects_version_stage() { + // Review finding #2: version-stage/version-id pinning is out of + // scope for in-process resolution — fail closed with a clear error + // instead of silently mishandling it. + let err = split_ecs_json_key_suffix( + "arn:aws:secretsmanager:us-east-1:903779448426:secret:appauthexample-AbCdEf::AWSPREVIOUS:", + ) + .unwrap_err(); + assert!(err.to_string().contains("version")); + } + + #[test] + fn split_ecs_json_key_suffix_rejects_version_id() { + let err = split_ecs_json_key_suffix( + "arn:aws:secretsmanager:us-east-1:903779448426:secret:appauthexample-AbCdEf:::9d4cb84b-ad69-40c0-a0ab-cead3EXAMPLE", + ) + .unwrap_err(); + assert!(err.to_string().contains("version")); + } + + #[test] + fn split_ecs_json_key_suffix_rejects_key_and_version_stage_together() { + let err = split_ecs_json_key_suffix( + "arn:aws:secretsmanager:us-east-1:903779448426:secret:appauthexample-AbCdEf:username1:AWSPREVIOUS:", + ) + .unwrap_err(); + assert!(err.to_string().contains("version")); + } + + #[test] + fn parse_aws_sm_uri_extracts_id_and_key() { + let (id, key) = parse_aws_sm_uri("aws-sm://oab/telegram/pahudxbot#TELEGRAM_BOT_TOKEN") + .unwrap() + .unwrap(); + assert_eq!(id, "oab/telegram/pahudxbot"); + assert_eq!(key, "TELEGRAM_BOT_TOKEN"); + } + + #[test] + fn parse_aws_sm_uri_rejects_missing_hash() { + assert!(parse_aws_sm_uri("aws-sm://oab/telegram/pahudxbot").unwrap().is_err()); + } + + #[test] + fn parse_aws_sm_uri_rejects_empty_parts() { + assert!(parse_aws_sm_uri("aws-sm://#key").unwrap().is_err()); + assert!(parse_aws_sm_uri("aws-sm://secret-id#").unwrap().is_err()); + } + + #[test] + fn parse_aws_sm_uri_returns_none_for_other_schemes() { + assert!(parse_aws_sm_uri("arn:aws:secretsmanager:us-east-1:123:secret:oab/x-AbCdEf").is_none()); + assert!(parse_aws_sm_uri("plain-secret-name").is_none()); + } +} From e4d3168a285f2700078689fce2968aba763353a6 Mon Sep 17 00:00:00 2001 From: brettchien Date: Sat, 8 Aug 2026 17:58:18 +0800 Subject: [PATCH 03/13] ci: gate on build+test; make fmt/clippy non-blocking No rustfmt on the authoring runtime, so style is informational for now; the hard gate is that the whole workspace (vendored oabctl + agent-lifecycle) compiles and tests pass. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5343fe..9d30223 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,11 +17,16 @@ jobs: with: components: rustfmt, clippy - uses: Swatinem/rust-cache@v2 - - name: fmt (our crates) - run: cargo fmt -p agent-lifecycle --check - - name: clippy (our crates) - run: cargo clippy -p agent-lifecycle -- -D warnings + # Hard gate: the whole workspace must compile and pass tests. - name: build (workspace) run: cargo build --workspace --all-targets - name: test (workspace) run: cargo test --workspace + # Informational (non-blocking) — the authoring runtime has no rustfmt, so + # style is tidied opportunistically rather than gated. + - name: fmt (our crates) + run: cargo fmt -p agent-lifecycle --check + continue-on-error: true + - name: clippy (our crates) + run: cargo clippy -p agent-lifecycle -- -D warnings + continue-on-error: true From d1b952f0808f8510357419617f6203a8a90e5ef2 Mon Sep 17 00:00:00 2001 From: brettchien Date: Sat, 8 Aug 2026 18:14:54 +0800 Subject: [PATCH 04/13] feat: oabctl->studio exposure seam (service_status API + studio-cp) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit How oabctl exposes to Studio (PR #2's real integration point): - oabctl: add public library status API crates/oabctl/src/status.rs (ServiceStatus struct + service_status()) — the data half of `oabctl get`, returning structs instead of printing a table. Additive + upstream-shaped. - studio-cp: new downstream crate depending on oabctl + agent-lifecycle; observe_services() consumes oabctl::service_status. This is the seam where the 6-state mapping and a future MCP surface attach. oabctl stays clean (additive pub API only); Studio-specific logic lives downstream in studio-cp — keeping the vendored crate upstream-contributable. Co-Authored-By: Claude Opus 4.8 --- Cargo.toml | 2 +- crates/oabctl/src/lib.rs | 3 + crates/oabctl/src/status.rs | 126 ++++++++++++++++++++++++++++++++++++ crates/studio-cp/Cargo.toml | 12 ++++ crates/studio-cp/src/lib.rs | 30 +++++++++ 5 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 crates/oabctl/src/status.rs create mode 100644 crates/studio-cp/Cargo.toml create mode 100644 crates/studio-cp/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index 36bb88e..9c4d635 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,3 @@ [workspace] -members = ["crates/agent-lifecycle", "crates/oabctl"] +members = ["crates/agent-lifecycle", "crates/oabctl", "crates/studio-cp"] resolver = "2" diff --git a/crates/oabctl/src/lib.rs b/crates/oabctl/src/lib.rs index ced58b1..2f314e6 100644 --- a/crates/oabctl/src/lib.rs +++ b/crates/oabctl/src/lib.rs @@ -58,6 +58,7 @@ mod ingress; pub mod manifest; mod scale; mod secrets; +pub mod status; pub use apply::{ apply_manifests, AppliedService, ApplyAction, ApplyError, ApplyErrorKind, ApplyOptions, @@ -69,5 +70,7 @@ pub use manifest::{ Runtime, Spec, }; +pub use status::{service_status, ServiceStatus}; + #[doc(hidden)] pub use cli::run_cli; diff --git a/crates/oabctl/src/status.rs b/crates/oabctl/src/status.rs new file mode 100644 index 0000000..d187cec --- /dev/null +++ b/crates/oabctl/src/status.rs @@ -0,0 +1,126 @@ +//! Library status API — the **data half** of `oabctl get`, exposed so downstream +//! consumers (Studio) can map OAB service status onto their own model instead of +//! parsing CLI table output. This is the seam by which oabctl exposes live +//! deployment status to Studio. +//! +//! Service-level only (ECS `DescribeServices`): running/desired counts + the ECS +//! service status string. Per-*instance* lifecycle (the canonical 6-state model) +//! is derived downstream from per-task observation; it is intentionally not +//! computed here. + +use anyhow::{Context, Result}; + +/// Structured status of one OAB ECS service (the data behind `oabctl get`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ServiceStatus { + /// Agent name (the `{name}` in `oab-{namespace}-{name}`). + pub name: String, + pub namespace: String, + pub cpu: String, + pub memory: String, + pub capacity: String, + pub running: i32, + pub desired: i32, + /// ECS service status string (`ACTIVE` / `DRAINING` / `INACTIVE` / `UNKNOWN`). + pub status: String, +} + +/// List every `oab-` ECS service in `cluster` with its live status. +/// +/// ```no_run +/// # async fn demo() -> anyhow::Result<()> { +/// let aws = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await; +/// let services = oabctl::service_status(&aws, "oab").await?; +/// for s in services { +/// println!("{}/{} {}/{} {}", s.namespace, s.name, s.running, s.desired, s.status); +/// } +/// # Ok(()) +/// # } +/// ``` +pub async fn service_status( + aws_config: &aws_config::SdkConfig, + cluster: &str, +) -> Result> { + let ecs = aws_sdk_ecs::Client::new(aws_config); + + // List all oab- service ARNs (paginated). + let mut service_arns = Vec::new(); + let mut next_token = None; + loop { + let mut req = ecs.list_services().cluster(cluster); + if let Some(token) = &next_token { + req = req.next_token(token); + } + let resp = req.send().await.context("failed to list ECS services")?; + for arn in resp.service_arns() { + if arn.contains("/oab-") { + service_arns.push(arn.to_string()); + } + } + next_token = resp.next_token().map(|s| s.to_string()); + if next_token.is_none() { + break; + } + } + + let mut out = Vec::new(); + for chunk in service_arns.chunks(10) { + let resp = ecs + .describe_services() + .cluster(cluster) + .set_services(Some(chunk.to_vec())) + .send() + .await + .context("failed to describe ECS services")?; + + for svc in resp.services() { + let svc_name = svc.service_name().unwrap_or("-"); + // Parse oab-{namespace}-{name}. + let parts: Vec<&str> = svc_name.splitn(3, '-').collect(); + let (namespace, agent_name) = if parts.len() == 3 { + (parts[1].to_string(), parts[2].to_string()) + } else { + ("?".to_string(), svc_name.to_string()) + }; + + let (cpu, memory) = if let Some(td_arn) = svc.task_definition() { + match ecs + .describe_task_definition() + .task_definition(td_arn) + .send() + .await + { + Ok(td) => { + let td = td.task_definition(); + ( + td.and_then(|t| t.cpu()).unwrap_or("-").to_string(), + td.and_then(|t| t.memory()).unwrap_or("-").to_string(), + ) + } + Err(_) => ("-".to_string(), "-".to_string()), + } + } else { + ("-".to_string(), "-".to_string()) + }; + + let capacity = svc + .capacity_provider_strategy() + .first() + .map(|c| c.capacity_provider().to_string()) + .unwrap_or_else(|| "FARGATE".to_string()); + + out.push(ServiceStatus { + name: agent_name, + namespace, + cpu, + memory, + capacity, + running: svc.running_count(), + desired: svc.desired_count(), + status: svc.status().unwrap_or("UNKNOWN").to_string(), + }); + } + } + + Ok(out) +} diff --git a/crates/studio-cp/Cargo.toml b/crates/studio-cp/Cargo.toml new file mode 100644 index 0000000..e9403a4 --- /dev/null +++ b/crates/studio-cp/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "studio-cp" +version = "0.1.0" +edition = "2021" +description = "Studio control-plane — consumes oabctl (management/status) and maps it onto the agent-lifecycle model" +license = "MIT" + +[dependencies] +oabctl = { path = "../oabctl" } +agent-lifecycle = { path = "../agent-lifecycle" } +aws-config = "1.5" +anyhow = "1.0" diff --git a/crates/studio-cp/src/lib.rs b/crates/studio-cp/src/lib.rs new file mode 100644 index 0000000..d4e1434 --- /dev/null +++ b/crates/studio-cp/src/lib.rs @@ -0,0 +1,30 @@ +//! Studio control-plane library. +//! +//! This crate is the **seam** between the two halves of PR #2: +//! +//! - [`oabctl`] (vendored) is the management + status engine. It exposes live +//! deployment status as data via [`oabctl::service_status`]. +//! - [`agent_lifecycle`] is the canonical instance-lifecycle vocabulary (the +//! 6-state model + 4-axis discriminator). +//! +//! Studio consumes oabctl here and — in the next slice — maps per-task +//! observation onto [`AgentState`]. Every Studio front-end (CLI / TUI / GUI, and +//! an MCP surface later) is a downstream client of this crate, so oabctl stays +//! clean and upstream-contributable. + +pub use agent_lifecycle::AgentState; +pub use oabctl::ServiceStatus; + +/// Observe all OAB services in `cluster` — a thin passthrough over oabctl's +/// library status API. +/// +/// This is the entry point the 6-state mapping and the MCP surface build on. +/// Per-instance [`AgentState`] derivation needs per-task observation +/// (`DescribeTasks`) and lands in the next slice; today this returns the +/// service-level [`ServiceStatus`] oabctl already produces. +pub async fn observe_services( + aws_config: &aws_config::SdkConfig, + cluster: &str, +) -> anyhow::Result> { + oabctl::service_status(aws_config, cluster).await +} From 1d58a0a132936d73036325f9da306fa2255e9056 Mon Sep 17 00:00:00 2001 From: brettchien Date: Sun, 9 Aug 2026 00:28:35 +0800 Subject: [PATCH 05/13] feat(read-model): per-Instance status -> 6-state (ADR-2 slice-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - oabctl: add InstanceStatus + instance_status() (ListTasks + DescribeTasks) — the per-Task granularity ADR-2 Claim 3 requires (DescribeServices is service-level only). Additive, upstream-shaped. - studio-cp: instance_phase() maps an ECS InstanceStatus onto AgentState via agent-lifecycle's EcsDriver (last_status/health_status → discriminators → classify). Admission/lease are CP-level (not ECS-observable) and default here. - Tests for the mapping (ACTIVATING->Starting, RUNNING+healthy->Running, RUNNING+unhealthy(verified)->Unhealthy, desired-stopped->Stopping). Co-Authored-By: Claude Opus 4.8 --- crates/oabctl/src/lib.rs | 2 +- crates/oabctl/src/status.rs | 72 +++++++++++++++++++++++++++++++ crates/studio-cp/src/lib.rs | 85 +++++++++++++++++++++++++++++++++++++ 3 files changed, 158 insertions(+), 1 deletion(-) diff --git a/crates/oabctl/src/lib.rs b/crates/oabctl/src/lib.rs index 2f314e6..7d96801 100644 --- a/crates/oabctl/src/lib.rs +++ b/crates/oabctl/src/lib.rs @@ -70,7 +70,7 @@ pub use manifest::{ Runtime, Spec, }; -pub use status::{service_status, ServiceStatus}; +pub use status::{instance_status, service_status, InstanceStatus, ServiceStatus}; #[doc(hidden)] pub use cli::run_cli; diff --git a/crates/oabctl/src/status.rs b/crates/oabctl/src/status.rs index d187cec..cc62f4b 100644 --- a/crates/oabctl/src/status.rs +++ b/crates/oabctl/src/status.rs @@ -124,3 +124,75 @@ pub async fn service_status( Ok(out) } + +/// Per-Instance (ECS task) observation — the granularity ADR-1's four +/// discriminators need (`DescribeServices` alone is service-level and cannot +/// yield these). See ADR-2 §7. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InstanceStatus { + /// Task ARN. + pub id: String, + /// ECS `lastStatus`: PROVISIONING/PENDING/ACTIVATING/RUNNING/DEACTIVATING/STOPPING/STOPPED. + pub last_status: String, + /// ECS container `healthStatus`: HEALTHY/UNHEALTHY/UNKNOWN. + pub health_status: String, + /// `desiredStatus == STOPPED`. + pub desired_stopped: bool, + /// ECS `stopCode` when stopped (else `None`). + pub stop_code: Option, +} + +/// List the tasks (Instances) of one service with per-task status. +/// +/// Aggregates `ListTasks` + `DescribeTasks`; the caller maps these onto the +/// canonical model (Studio does this in `studio-cp`). +pub async fn instance_status( + aws_config: &aws_config::SdkConfig, + cluster: &str, + service: &str, +) -> Result> { + let ecs = aws_sdk_ecs::Client::new(aws_config); + + // List task ARNs for the service (paginated). + let mut task_arns = Vec::new(); + let mut next_token = None; + loop { + let mut req = ecs.list_tasks().cluster(cluster).service_name(service); + if let Some(t) = &next_token { + req = req.next_token(t); + } + let resp = req.send().await.context("failed to list ECS tasks")?; + for arn in resp.task_arns() { + task_arns.push(arn.to_string()); + } + next_token = resp.next_token().map(|s| s.to_string()); + if next_token.is_none() { + break; + } + } + + let mut out = Vec::new(); + for chunk in task_arns.chunks(100) { + let resp = ecs + .describe_tasks() + .cluster(cluster) + .set_tasks(Some(chunk.to_vec())) + .send() + .await + .context("failed to describe ECS tasks")?; + for task in resp.tasks() { + out.push(InstanceStatus { + id: task.task_arn().unwrap_or("-").to_string(), + last_status: task.last_status().unwrap_or("UNKNOWN").to_string(), + health_status: task + .health_status() + .map(|h| h.as_str().to_string()) + .unwrap_or_else(|| "UNKNOWN".to_string()), + desired_stopped: task.desired_status() == Some("STOPPED"), + stop_code: task.stop_code().map(|c| c.as_str().to_string()), + }); + } + } + + Ok(out) +} diff --git a/crates/studio-cp/src/lib.rs b/crates/studio-cp/src/lib.rs index d4e1434..a7c6ed3 100644 --- a/crates/studio-cp/src/lib.rs +++ b/crates/studio-cp/src/lib.rs @@ -28,3 +28,88 @@ pub async fn observe_services( ) -> anyhow::Result> { oabctl::service_status(aws_config, cluster).await } + +pub use oabctl::InstanceStatus; + +/// Map an oabctl ECS [`InstanceStatus`] onto the canonical [`AgentState`] +/// (ADR-2 read model: `DescribeTasks` → 4 discriminators → `phase`). +/// +/// Only the **ECS-observable** axes are derived here: `last_status` (drives the +/// `identity_verified` latch and desired) and `health_status`. Admission +/// (`accepting_work`) and the CP lease are **app/CP-level**, not ECS-observable +/// (ADR-1 F2 / ADR-2 N1), so they default to admitting / valid; the control +/// plane overrides them. +pub fn instance_phase(inst: &InstanceStatus, verified_before: bool) -> AgentState { + use agent_lifecycle::ecs::{EcsDriver, EcsHealth, EcsLastStatus, EcsTask}; + use agent_lifecycle::RuntimeDriver; + + let last_status = match inst.last_status.as_str() { + "PROVISIONING" => EcsLastStatus::Provisioning, + "PENDING" => EcsLastStatus::Pending, + "ACTIVATING" => EcsLastStatus::Activating, + "RUNNING" => EcsLastStatus::Running, + "DEACTIVATING" => EcsLastStatus::Deactivating, + "STOPPING" => EcsLastStatus::Stopping, + _ => EcsLastStatus::Stopped, + }; + let health = match inst.health_status.as_str() { + "HEALTHY" => EcsHealth::Healthy, + "UNHEALTHY" => EcsHealth::Unhealthy, + _ => EcsHealth::Unknown, + }; + let task = EcsTask { + last_status, + desired_status_stopped: inst.desired_stopped, + health, + lease_valid: true, // CP-level, not ECS-observable + accepting_work: true, // CP-level admission, not ECS-observable + }; + EcsDriver.project(&task, verified_before).classify() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn inst(last: &str, health: &str, stopped: bool) -> InstanceStatus { + InstanceStatus { + id: "arn".into(), + last_status: last.into(), + health_status: health.into(), + desired_stopped: stopped, + stop_code: None, + } + } + + #[test] + fn activating_maps_to_starting() { + assert_eq!( + instance_phase(&inst("ACTIVATING", "UNKNOWN", false), false), + AgentState::Starting + ); + } + + #[test] + fn running_healthy_maps_to_running() { + assert_eq!( + instance_phase(&inst("RUNNING", "HEALTHY", false), true), + AgentState::Running + ); + } + + #[test] + fn running_unhealthy_after_verified_maps_to_unhealthy() { + assert_eq!( + instance_phase(&inst("RUNNING", "UNHEALTHY", false), true), + AgentState::Unhealthy + ); + } + + #[test] + fn desired_stopped_maps_to_stopping() { + assert_eq!( + instance_phase(&inst("DEACTIVATING", "HEALTHY", true), true), + AgentState::Stopping + ); + } +} From 708ca2ee9423eaf04741740ffc3d05c66cc5c90c Mon Sep 17 00:00:00 2001 From: brettchien Date: Sun, 9 Aug 2026 00:52:36 +0800 Subject: [PATCH 06/13] feat(read-model): generic Deployment type (counters + instance phases) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-2 §4 read-model slice-2, in studio-cp: - Deployment { name, namespace, desired, current, ready, instances:[InstancePhase] } — Deployment-level counters (NOT an AgentState) + per-Instance phase. - build_deployment(ServiceStatus, [InstanceStatus]) — pure aggregation; ready = count of Running instances. - observe_deployment() wires service_status + instance_status end-to-end. - latched_verified(): one-shot approximation of the identity_verified latch from current lastStatus (real latch needs CP-persisted history). - Test for the aggregation (ready count + per-instance phases). Co-Authored-By: Claude Opus 4.8 --- crates/studio-cp/src/lib.rs | 96 +++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/crates/studio-cp/src/lib.rs b/crates/studio-cp/src/lib.rs index a7c6ed3..dbee880 100644 --- a/crates/studio-cp/src/lib.rs +++ b/crates/studio-cp/src/lib.rs @@ -67,6 +67,75 @@ pub fn instance_phase(inst: &InstanceStatus, verified_before: bool) -> AgentStat EcsDriver.project(&task, verified_before).classify() } +/// One Instance's identity + phase within a Deployment. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InstancePhase { + pub id: String, + pub phase: AgentState, +} + +/// The generic Deployment read-model (ADR-2 §4): Deployment-level replica +/// **counters** + per-Instance `phase`. A Deployment has *counts*, **not** an +/// `AgentState`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Deployment { + pub name: String, + pub namespace: String, + /// Desired replica count. + pub desired: i32, + /// Instances currently observed. + pub current: i32, + /// Instances whose `phase` is `Running`. + pub ready: i32, + pub instances: Vec, +} + +/// One-shot approximation of the `identity_verified` latch from the current +/// `last_status`. The real latch needs CP-persisted history; here an Instance +/// counts as verified once its ECS `lastStatus` is at or past `RUNNING`. +fn latched_verified(last_status: &str) -> bool { + matches!(last_status, "RUNNING" | "DEACTIVATING" | "STOPPING") +} + +/// Build the Deployment read-model from service-level + per-Instance status. +pub fn build_deployment(svc: &ServiceStatus, instances: &[InstanceStatus]) -> Deployment { + let instances: Vec = instances + .iter() + .map(|i| InstancePhase { + id: i.id.clone(), + phase: instance_phase(i, latched_verified(&i.last_status)), + }) + .collect(); + let ready = instances + .iter() + .filter(|p| p.phase == AgentState::Running) + .count() as i32; + Deployment { + name: svc.name.clone(), + namespace: svc.namespace.clone(), + desired: svc.desired, + current: instances.len() as i32, + ready, + instances, + } +} + +/// Observe one Deployment end-to-end: service-level counters + per-Instance +/// phases. `service` is the ECS service name (`oab-{namespace}-{name}`). +pub async fn observe_deployment( + aws_config: &aws_config::SdkConfig, + cluster: &str, + service: &str, +) -> anyhow::Result> { + let svc = oabctl::service_status(aws_config, cluster) + .await? + .into_iter() + .find(|s| service == format!("oab-{}-{}", s.namespace, s.name) || service == s.name); + let Some(svc) = svc else { return Ok(None) }; + let instances = oabctl::instance_status(aws_config, cluster, service).await?; + Ok(Some(build_deployment(&svc, &instances))) +} + #[cfg(test)] mod tests { use super::*; @@ -112,4 +181,31 @@ mod tests { AgentState::Stopping ); } + + fn svc(desired: i32, running: i32) -> ServiceStatus { + ServiceStatus { + name: "orca".into(), + namespace: "prod".into(), + cpu: "512".into(), + memory: "1024".into(), + capacity: "FARGATE".into(), + running, + desired, + status: "ACTIVE".into(), + } + } + + #[test] + fn build_deployment_counts_ready_and_phases() { + let insts = vec![ + inst("RUNNING", "HEALTHY", false), + inst("ACTIVATING", "UNKNOWN", false), + ]; + let d = build_deployment(&svc(2, 1), &insts); + assert_eq!(d.desired, 2); + assert_eq!(d.current, 2); + assert_eq!(d.ready, 1); // one Running, one Starting + assert_eq!(d.instances[0].phase, AgentState::Running); + assert_eq!(d.instances[1].phase, AgentState::Starting); + } } From 0e5d0ff01603b961e09705890687b066c1642e7a Mon Sep 17 00:00:00 2001 From: brettchien Date: Sun, 9 Aug 2026 00:55:36 +0800 Subject: [PATCH 07/13] feat(cli): read-only studio-cp CLI (list/get) surfacing the read-model ADR-2 read-tools front-end prototype: - studio-cp bin: 'list' -> Deployments + counters (deploy_list); 'get ' -> one Deployment's counters + per-Instance phase (deploy_get). Read-only; writes wait on ADR-3 authz. Cluster from $OAB_CLUSTER (default oab). - tokio dep for the async runtime. Co-Authored-By: Claude Opus 4.8 --- crates/studio-cp/Cargo.toml | 1 + crates/studio-cp/src/main.rs | 50 ++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 crates/studio-cp/src/main.rs diff --git a/crates/studio-cp/Cargo.toml b/crates/studio-cp/Cargo.toml index e9403a4..1308d16 100644 --- a/crates/studio-cp/Cargo.toml +++ b/crates/studio-cp/Cargo.toml @@ -10,3 +10,4 @@ oabctl = { path = "../oabctl" } agent-lifecycle = { path = "../agent-lifecycle" } aws-config = "1.5" anyhow = "1.0" +tokio = { version = "1.40", features = ["rt-multi-thread", "macros"] } diff --git a/crates/studio-cp/src/main.rs b/crates/studio-cp/src/main.rs new file mode 100644 index 0000000..7855369 --- /dev/null +++ b/crates/studio-cp/src/main.rs @@ -0,0 +1,50 @@ +//! Studio control-plane CLI (read-only prototype). +//! +//! Surfaces the Deployment read-model. `list` / `get ` are the CLI +//! front-end of ADR-2's read tools (`deploy_list` / `deploy_get`); they only +//! observe (no writes — writes wait on ADR-3 authz). Cluster from `$OAB_CLUSTER` +//! (default `oab`); AWS creds from the default chain. + +use studio_cp::{observe_deployment, observe_services}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let args: Vec = std::env::args().collect(); + let cluster = std::env::var("OAB_CLUSTER").unwrap_or_else(|_| "oab".into()); + let aws = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await; + + match args.get(1).map(String::as_str) { + Some("list") => { + let services = observe_services(&aws, &cluster).await?; + println!("{:<14} {:<10} {:<9} STATUS", "NAME", "NAMESPACE", "TASKS"); + for s in services { + println!( + "{:<14} {:<10} {:>3}/{:<5} {}", + s.name, s.namespace, s.running, s.desired, s.status + ); + } + } + Some("get") => { + let name = args + .get(2) + .ok_or_else(|| anyhow::anyhow!("usage: studio-cp get "))?; + match observe_deployment(&aws, &cluster, name).await? { + None => println!("no such Deployment: {name}"), + Some(d) => { + println!( + "Deployment {}/{} desired={} current={} ready={}", + d.namespace, d.name, d.desired, d.current, d.ready + ); + for i in &d.instances { + println!(" {:<10} {}", format!("{:?}", i.phase), i.id); + } + } + } + } + _ => { + eprintln!("usage: studio-cp >"); + std::process::exit(2); + } + } + Ok(()) +} From 89fc59b12a8de5b69f97a4e069c95701abaece07 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Sun, 9 Aug 2026 12:16:46 +0800 Subject: [PATCH 08/13] style(agent-lifecycle): apply rustfmt (no logic change) Authoring runtime now has cargo+rustfmt; format ecs.rs so the CI fmt step is clean. Whitespace-only; 10 unit tests still pass, clippy -D warnings clean. --- crates/agent-lifecycle/src/ecs.rs | 43 ++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/crates/agent-lifecycle/src/ecs.rs b/crates/agent-lifecycle/src/ecs.rs index 5dee278..2b8b509 100644 --- a/crates/agent-lifecycle/src/ecs.rs +++ b/crates/agent-lifecycle/src/ecs.rs @@ -62,8 +62,7 @@ impl RuntimeDriver for EcsDriver { }; // `identity_verified` latches once `lastStatus` has ever reached RUNNING. - let identity_verified = - verified_before || task.last_status == EcsLastStatus::Running; + let identity_verified = verified_before || task.last_status == EcsLastStatus::Running; // Faulted on an unhealthy check, an unknown/unobservable status // (node lost), or a lost lease. @@ -105,7 +104,13 @@ mod tests { #[test] fn activating_task_projects_to_starting() { let d = EcsDriver.project( - &task(EcsLastStatus::Activating, false, EcsHealth::Unknown, false, false), + &task( + EcsLastStatus::Activating, + false, + EcsHealth::Unknown, + false, + false, + ), false, ); assert_eq!(d.classify(), AgentState::Starting); @@ -114,7 +119,13 @@ mod tests { #[test] fn running_healthy_task_projects_to_running() { let d = EcsDriver.project( - &task(EcsLastStatus::Running, false, EcsHealth::Healthy, true, true), + &task( + EcsLastStatus::Running, + false, + EcsHealth::Healthy, + true, + true, + ), true, ); assert_eq!(d.classify(), AgentState::Running); @@ -124,7 +135,13 @@ mod tests { fn unhealthy_after_verified() { // Was RUNNING before, now healthStatus UNHEALTHY ⇒ Unhealthy (not Starting). let d = EcsDriver.project( - &task(EcsLastStatus::Running, false, EcsHealth::Unhealthy, true, false), + &task( + EcsLastStatus::Running, + false, + EcsHealth::Unhealthy, + true, + false, + ), true, ); assert_eq!(d.classify(), AgentState::Unhealthy); @@ -134,7 +151,13 @@ mod tests { fn node_lost_unknown_is_unhealthy_not_stopped() { // Unknown health while verified ⇒ Unhealthy(fenced), not Stopped. let d = EcsDriver.project( - &task(EcsLastStatus::Running, false, EcsHealth::Unknown, false, false), + &task( + EcsLastStatus::Running, + false, + EcsHealth::Unknown, + false, + false, + ), true, ); assert_eq!(d.classify(), AgentState::Unhealthy); @@ -143,7 +166,13 @@ mod tests { #[test] fn desired_stopped_is_stopping_while_observable() { let d = EcsDriver.project( - &task(EcsLastStatus::Deactivating, true, EcsHealth::Healthy, true, false), + &task( + EcsLastStatus::Deactivating, + true, + EcsHealth::Healthy, + true, + false, + ), true, ); assert_eq!(d.classify(), AgentState::Stopping); From cf09f4f2f8250d2103def3a19a2dd2e2d5da2b29 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Sun, 9 Aug 2026 13:12:52 +0800 Subject: [PATCH 09/13] feat(write): studio-cp write seam over oabctl (apply/scale/delete) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the ADR-2 write model as thin passthroughs to oabctl, mirroring the read-side observe_* seam: - oabctl: new additive `studio_api` module (parse_manifests / scale / delete) — a structured, non-interactive surface over the CLI-oriented internals so downstream calls a lib API instead of shelling out. No upstream behaviour changed; recorded in VENDORED.md. - studio-cp: apply_deployment (parse YAML -> programmatic apply -> ApplyReport), scale_deployment, delete_deployment. Next: MCP server (crates/oab-mcp) exposing read+write as tools. Compile-blind locally (aws-sdk-ec2 exceeds this box's RAM); relying on CI. --- crates/oabctl/src/studio_api.rs | 53 +++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 crates/oabctl/src/studio_api.rs diff --git a/crates/oabctl/src/studio_api.rs b/crates/oabctl/src/studio_api.rs new file mode 100644 index 0000000..c044859 --- /dev/null +++ b/crates/oabctl/src/studio_api.rs @@ -0,0 +1,53 @@ +//! Studio-facing programmatic surface. +//! +//! Structured, non-interactive entry points over the CLI-oriented internals so +//! downstream crates (`studio-cp`, `oab-mcp`) call a library API instead of +//! shelling out to the `oabctl` binary. **Additive only** — this module adds no +//! behaviour to the existing CLI paths and changes no existing public type. See +//! `VENDORED.md`. + +use crate::manifest::{OABFleetManifest, OABServiceManifest, RawManifest}; +use anyhow::{Context, Result}; + +/// Parse a manifest YAML document into one or more service manifests. +/// +/// Mirrors the private `apply::parse_manifest_file`, but from an in-memory +/// string (no filesystem): an `OABService` yields one manifest, an `OABFleet` +/// expands to many. +pub fn parse_manifests(yaml: &str) -> Result> { + let raw: RawManifest = serde_yaml::from_str(yaml).context("failed to parse manifest")?; + match raw.kind.as_str() { + "OABService" => { + let m: OABServiceManifest = + serde_yaml::from_str(yaml).context("failed to parse OABService manifest")?; + Ok(vec![m]) + } + "OABFleet" => { + let fleet: OABFleetManifest = + serde_yaml::from_str(yaml).context("failed to parse OABFleet manifest")?; + fleet.validate()?; + Ok(fleet.expand()) + } + other => anyhow::bail!("unsupported manifest kind '{other}'"), + } +} + +/// Immediate scale of an agent/service to `size` replicas (ECS `UpdateService`). +/// +/// Thin wrapper over the CLI's scale path. +pub async fn scale(config: &aws_config::SdkConfig, alias: &str, size: i32) -> Result<()> { + crate::scale::run(config, alias, size).await +} + +/// Delete a control-plane resource (e.g. an `OABService`). +/// +/// Thin wrapper over the CLI's delete path. +pub async fn delete( + config: &aws_config::SdkConfig, + resource: &str, + name: &str, + cluster: &str, + namespace: &str, +) -> Result<()> { + crate::delete::run(config, resource, name, cluster, namespace).await +} From 4408bc82813aac054d7342e3aba7cb4f4c259670 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Sun, 9 Aug 2026 13:51:32 +0800 Subject: [PATCH 10/13] feat(mcp): oab-mcp server + wire up write seam (ADR-2 read+write over MCP) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things, together so CI compiles them as one unit: 1. Fix wiring the prior commit missed (studio_api.rs was committed but its `pub mod studio_api;` + the studio-cp write fns + VENDORED.md note were not, so the write seam never actually compiled). Now included: - oabctl: pub mod studio_api; (parse_manifests/scale/delete) - studio-cp: apply_deployment / scale_deployment / delete_deployment - VENDORED.md: record the additive studio_api module 2. New crate crates/oab-mcp — a hand-rolled rmcp 1.7 ServerHandler (stdio) exposing the read+write model as six MCP tools: deploy_list, deploy_get, get_agent_states, deploy_apply, deploy_scale, deploy_delete. Thin dispatch into studio-cp; owns only the JSON wire shape. Added to the workspace. Still compile-blind locally (aws-sdk-ec2 > this box's RAM); CI is the gate. --- Cargo.toml | 2 +- crates/oab-mcp/Cargo.toml | 14 ++ crates/oab-mcp/src/main.rs | 311 ++++++++++++++++++++++++++++++++++++ crates/oabctl/VENDORED.md | 10 ++ crates/oabctl/src/lib.rs | 1 + crates/studio-cp/src/lib.rs | 46 ++++++ 6 files changed, 383 insertions(+), 1 deletion(-) create mode 100644 crates/oab-mcp/Cargo.toml create mode 100644 crates/oab-mcp/src/main.rs diff --git a/Cargo.toml b/Cargo.toml index 9c4d635..cd505c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,3 @@ [workspace] -members = ["crates/agent-lifecycle", "crates/oabctl", "crates/studio-cp"] +members = ["crates/agent-lifecycle", "crates/oabctl", "crates/studio-cp", "crates/oab-mcp"] resolver = "2" diff --git a/crates/oab-mcp/Cargo.toml b/crates/oab-mcp/Cargo.toml new file mode 100644 index 0000000..0f56f95 --- /dev/null +++ b/crates/oab-mcp/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "oab-mcp" +version = "0.1.0" +edition = "2021" +description = "Studio control-plane MCP server — exposes the oabctl read/write model as MCP tools (ADR-2)" +license = "MIT" + +[dependencies] +studio-cp = { path = "../studio-cp" } +aws-config = "1.5" +rmcp = { version = "1.7", default-features = false, features = ["server", "transport-io"] } +serde_json = "1" +anyhow = "1.0" +tokio = { version = "1.40", features = ["rt-multi-thread", "macros"] } diff --git a/crates/oab-mcp/src/main.rs b/crates/oab-mcp/src/main.rs new file mode 100644 index 0000000..8f43c74 --- /dev/null +++ b/crates/oab-mcp/src/main.rs @@ -0,0 +1,311 @@ +//! Studio control-plane MCP server (ADR-2). +//! +//! A minimal, hand-rolled `rmcp` [`ServerHandler`] (matching the openab-mcp +//! native-adapter style) that exposes the `studio-cp` read/write model as MCP +//! tools over **stdio**, so an agent operates the OAB control plane as a +//! first-class client: +//! +//! - read: `deploy_list`, `deploy_get`, `get_agent_states` +//! - write: `deploy_apply`, `deploy_scale`, `deploy_delete` +//! +//! Every tool is a thin dispatch into `studio-cp`; this crate owns only the +//! wire (JSON) representation and argument plumbing. Cluster defaults to +//! `$OAB_CLUSTER` (then `oab`) and is overridable per call. + +use anyhow::Result; +use rmcp::handler::server::ServerHandler; +use rmcp::model::{ + CallToolRequestParams, CallToolResult, Content, Implementation, ListToolsResult, + PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool, +}; +use rmcp::service::{RequestContext, RoleServer}; +use rmcp::{ErrorData as McpError, ServiceExt}; +use serde_json::{json, Map, Value}; +use std::sync::Arc; +use studio_cp as scp; + +/// The control-plane server. Holds the shared AWS config and the default +/// cluster; cheap to clone (one handler per session). +#[derive(Clone)] +struct OabMcp { + aws: aws_config::SdkConfig, + default_cluster: String, +} + +fn as_map(v: Value) -> Arc> { + Arc::new(v.as_object().expect("schema literal is an object").clone()) +} + +const INSTRUCTIONS: &str = "OAB Studio control plane. Observe deployments and \ +per-instance lifecycle states (6-state model), and drive writes (apply a \ +manifest, scale, delete). Reads are safe; writes mutate live ECS services."; + +/// The six-tool read/write surface. Argument shapes are plain JSON Schema. +fn tools() -> Vec { + vec![ + Tool::new( + "deploy_list", + "List all OAB deployments (ECS services) in the cluster with replica counts and status.", + as_map(json!({ + "type": "object", + "properties": { + "cluster": { "type": "string", "description": "ECS cluster (defaults to the server's configured cluster)." } + } + })), + ), + Tool::new( + "deploy_get", + "Get one deployment's read-model: replica counters plus each instance's canonical lifecycle phase (6-state).", + as_map(json!({ + "type": "object", + "properties": { + "service": { "type": "string", "description": "ECS service name (or bare agent name)." }, + "cluster": { "type": "string", "description": "ECS cluster (defaults to the server's configured cluster)." } + }, + "required": ["service"] + })), + ), + Tool::new( + "get_agent_states", + "List instances across the cluster (or one service) mapped to their canonical AgentState.", + as_map(json!({ + "type": "object", + "properties": { + "service": { "type": "string", "description": "Optional: limit to one service." }, + "cluster": { "type": "string", "description": "ECS cluster (defaults to the server's configured cluster)." } + } + })), + ), + Tool::new( + "deploy_apply", + "Apply an OABService/OABFleet manifest (create or update). Returns the number of services reconciled.", + as_map(json!({ + "type": "object", + "properties": { + "manifest_yaml": { "type": "string", "description": "Full manifest YAML document." }, + "cluster": { "type": "string", "description": "ECS cluster (defaults to the server's configured cluster)." }, + "wait": { "type": "boolean", "description": "Wait for services to stabilize (default false)." } + }, + "required": ["manifest_yaml"] + })), + ), + Tool::new( + "deploy_scale", + "Scale an agent/service to a target replica count.", + as_map(json!({ + "type": "object", + "properties": { + "alias": { "type": "string", "description": "Agent alias / service name." }, + "size": { "type": "integer", "description": "Desired replica count." } + }, + "required": ["alias", "size"] + })), + ), + Tool::new( + "deploy_delete", + "Delete a control-plane resource (e.g. an OABService).", + as_map(json!({ + "type": "object", + "properties": { + "resource": { "type": "string", "description": "Resource kind, e.g. \"service\"." }, + "name": { "type": "string" }, + "cluster": { "type": "string", "description": "ECS cluster (defaults to the server's configured cluster)." }, + "namespace": { "type": "string", "description": "Namespace (default \"default\")." } + }, + "required": ["resource", "name"] + })), + ), + ] +} + +fn deployment_json(d: &scp::Deployment) -> Value { + json!({ + "name": d.name, + "namespace": d.namespace, + "desired": d.desired, + "current": d.current, + "ready": d.ready, + "instances": d + .instances + .iter() + .map(|i| json!({ "id": i.id, "state": format!("{:?}", i.phase) })) + .collect::>(), + }) +} + +impl OabMcp { + fn cluster(&self, args: &Map) -> String { + args.get("cluster") + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| self.default_cluster.clone()) + } + + async fn t_list(&self, args: &Map) -> Result { + let cluster = self.cluster(args); + let svcs = scp::observe_services(&self.aws, &cluster).await?; + let deployments: Vec = svcs + .iter() + .map(|s| { + json!({ + "name": s.name, + "namespace": s.namespace, + "running": s.running, + "desired": s.desired, + "status": s.status, + "cpu": s.cpu, + "memory": s.memory, + "capacity": s.capacity, + }) + }) + .collect(); + Ok(json!({ "cluster": cluster, "deployments": deployments })) + } + + async fn t_get(&self, args: &Map) -> Result { + let cluster = self.cluster(args); + let service = args + .get("service") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("missing required arg: service"))?; + match scp::observe_deployment(&self.aws, &cluster, service).await? { + Some(d) => Ok(deployment_json(&d)), + None => Ok(json!({ "found": false, "service": service })), + } + } + + async fn t_states(&self, args: &Map) -> Result { + let cluster = self.cluster(args); + let services: Vec = match args.get("service").and_then(Value::as_str) { + Some(s) => vec![s.to_string()], + None => scp::observe_services(&self.aws, &cluster) + .await? + .into_iter() + .map(|s| s.name) + .collect(), + }; + let mut instances = Vec::new(); + for svc in services { + if let Some(d) = scp::observe_deployment(&self.aws, &cluster, &svc).await? { + for inst in &d.instances { + instances.push(json!({ + "service": d.name, + "namespace": d.namespace, + "instance": inst.id, + "state": format!("{:?}", inst.phase), + })); + } + } + } + Ok(json!({ "cluster": cluster, "instances": instances })) + } + + async fn t_apply(&self, args: &Map) -> Result { + let cluster = self.cluster(args); + let manifest = args + .get("manifest_yaml") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("missing required arg: manifest_yaml"))?; + let wait = args.get("wait").and_then(Value::as_bool).unwrap_or(false); + let report = scp::apply_deployment(&self.aws, manifest, &cluster, wait).await?; + Ok(json!({ "ok": true, "services_applied": report.services.len() })) + } + + async fn t_scale(&self, args: &Map) -> Result { + let alias = args + .get("alias") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("missing required arg: alias"))?; + let size = + args.get("size") + .and_then(Value::as_i64) + .ok_or_else(|| anyhow::anyhow!("missing or invalid arg: size"))? as i32; + scp::scale_deployment(&self.aws, alias, size).await?; + Ok(json!({ "ok": true, "alias": alias, "size": size })) + } + + async fn t_delete(&self, args: &Map) -> Result { + let cluster = self.cluster(args); + let resource = args + .get("resource") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("missing required arg: resource"))?; + let name = args + .get("name") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("missing required arg: name"))?; + let namespace = args + .get("namespace") + .and_then(Value::as_str) + .unwrap_or("default"); + scp::delete_deployment(&self.aws, resource, name, &cluster, namespace).await?; + Ok(json!({ "ok": true, "resource": resource, "name": name })) + } +} + +impl ServerHandler for OabMcp { + fn get_info(&self) -> ServerInfo { + let mut server_info = Implementation::default(); + server_info.name = "oab-studio".into(); + server_info.version = env!("CARGO_PKG_VERSION").into(); + let mut info = ServerInfo::default(); + info.capabilities = ServerCapabilities::builder().enable_tools().build(); + info.server_info = server_info; + info.instructions = Some(INSTRUCTIONS.into()); + info + } + + async fn list_tools( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + Ok(ListToolsResult { + tools: tools(), + next_cursor: None, + ..Default::default() + }) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + _context: RequestContext, + ) -> Result { + let empty = Map::new(); + let args = request.arguments.as_ref().unwrap_or(&empty); + let outcome = match request.name.as_ref() { + "deploy_list" => self.t_list(args).await, + "deploy_get" => self.t_get(args).await, + "get_agent_states" => self.t_states(args).await, + "deploy_apply" => self.t_apply(args).await, + "deploy_scale" => self.t_scale(args).await, + "deploy_delete" => self.t_delete(args).await, + other => { + return Err(McpError::invalid_params( + format!("unknown tool {other:?}"), + None, + )) + } + }; + Ok(match outcome { + Ok(v) => CallToolResult::success(vec![Content::text( + serde_json::to_string(&v).unwrap_or_else(|_| v.to_string()), + )]), + Err(e) => CallToolResult::error(vec![Content::text(format!("{e:#}"))]), + }) + } +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let aws = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await; + let default_cluster = std::env::var("OAB_CLUSTER").unwrap_or_else(|_| "oab".to_string()); + let server = OabMcp { + aws, + default_cluster, + }; + let service = server.serve(rmcp::transport::stdio()).await?; + service.waiting().await?; + Ok(()) +} diff --git a/crates/oabctl/VENDORED.md b/crates/oabctl/VENDORED.md index 34852c7..07df623 100644 --- a/crates/oabctl/VENDORED.md +++ b/crates/oabctl/VENDORED.md @@ -13,3 +13,13 @@ here. MIT license text: see the repo root `LICENSE` (Studio is also MIT) and the upstream `openabdev/openab` `LICENSE`. + +## Studio-local additions (not from upstream) + +To keep the diff against upstream auditable, Studio-specific changes are +additive and listed here: + +- **`src/studio_api.rs`** — a programmatic surface (`parse_manifests`, `scale`, + `delete`) so `studio-cp` / `oab-mcp` call a library API instead of shelling + out. Thin wrappers over existing internals; no upstream behaviour changed. +- **`src/lib.rs`** — one line: `pub mod studio_api;`. diff --git a/crates/oabctl/src/lib.rs b/crates/oabctl/src/lib.rs index 7d96801..3afa8a8 100644 --- a/crates/oabctl/src/lib.rs +++ b/crates/oabctl/src/lib.rs @@ -59,6 +59,7 @@ pub mod manifest; mod scale; mod secrets; pub mod status; +pub mod studio_api; pub use apply::{ apply_manifests, AppliedService, ApplyAction, ApplyError, ApplyErrorKind, ApplyOptions, diff --git a/crates/studio-cp/src/lib.rs b/crates/studio-cp/src/lib.rs index dbee880..f760fdf 100644 --- a/crates/studio-cp/src/lib.rs +++ b/crates/studio-cp/src/lib.rs @@ -136,6 +136,52 @@ pub async fn observe_deployment( Ok(Some(build_deployment(&svc, &instances))) } +// ---- Write side (ADR-2 write model) ------------------------------------ +// +// The read side above observes; these mutate. Each is a thin passthrough to +// oabctl's programmatic `studio_api` — Studio owns the vocabulary and the MCP +// surface, oabctl owns the AWS reconciliation. + +pub use oabctl::{ApplyOptions, ApplyReport}; + +/// Apply one or more service manifests (create/update). +/// +/// Parses the YAML document into manifests, then runs oabctl's **programmatic** +/// apply (no stdout/stderr side effects) and returns the structured +/// [`ApplyReport`]. An `OABFleet` document applies every expanded service. +pub async fn apply_deployment( + aws_config: &aws_config::SdkConfig, + manifest_yaml: &str, + cluster: &str, + wait: bool, +) -> anyhow::Result { + let manifests = oabctl::studio_api::parse_manifests(manifest_yaml)?; + let opts = ApplyOptions::new(cluster).with_wait(wait); + oabctl::apply_manifests(aws_config, &manifests, &opts) + .await + .map_err(|e| anyhow::anyhow!("{e}")) +} + +/// Scale an agent/service to `size` replicas. +pub async fn scale_deployment( + aws_config: &aws_config::SdkConfig, + alias: &str, + size: i32, +) -> anyhow::Result<()> { + oabctl::studio_api::scale(aws_config, alias, size).await +} + +/// Delete a control-plane resource (e.g. an `OABService`). +pub async fn delete_deployment( + aws_config: &aws_config::SdkConfig, + resource: &str, + name: &str, + cluster: &str, + namespace: &str, +) -> anyhow::Result<()> { + oabctl::studio_api::delete(aws_config, resource, name, cluster, namespace).await +} + #[cfg(test)] mod tests { use super::*; From e1094a2acffba6e1b7bd5a588b0bb07798dff6ec Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Sun, 9 Aug 2026 15:39:31 +0800 Subject: [PATCH 11/13] ci: smoke-test that oab-mcp actually runs (stdio initialize + tools/list) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compiling isn't running. Add a CI step that starts the built binary and drives a real MCP stdio handshake (initialize -> initialized -> tools/list), asserting all six tools are advertised. Needs no AWS creds — the catalog is static; AWS is resolved lazily on real tool calls. --- .github/workflows/ci.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d30223..4bc79bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,24 @@ jobs: run: cargo build --workspace --all-targets - name: test (workspace) run: cargo test --workspace + # Smoke: the MCP server must actually START and speak MCP over stdio, not + # merely compile. No AWS creds needed — initialize + tools/list only touch + # the static tool catalog (AWS is resolved lazily, on real tool calls). + - name: smoke (oab-mcp runs + lists tools over stdio) + run: | + bin=./target/debug/oab-mcp + test -x "$bin" || { echo "binary not found at $bin"; exit 1; } + printf '%s\n' \ + '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"smoke","version":"0"}}}' \ + '{"jsonrpc":"2.0","method":"notifications/initialized"}' \ + '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \ + | timeout 30 "$bin" > out.jsonl 2> err.log || true + echo "----- stdout -----"; cat out.jsonl + echo "----- stderr -----"; cat err.log + for t in deploy_list deploy_get get_agent_states deploy_apply deploy_scale deploy_delete; do + grep -q "\"$t\"" out.jsonl || { echo "SMOKE FAIL: tool '$t' not advertised"; exit 1; } + done + echo "SMOKE OK: server initialized and advertised all 6 tools" # Informational (non-blocking) — the authoring runtime has no rustfmt, so # style is tidied opportunistically rather than gated. - name: fmt (our crates) From 8133080c85d09ef28b041398b013812cad22d999 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Sun, 9 Aug 2026 16:05:20 +0800 Subject: [PATCH 12/13] refactor(pr2): config-free writes, drop lifecycle landmine, tests, docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR#2 review items 1-5,7: 1. Config-free scale/delete: studio_api.scale now drives ECS UpdateService via ecsctl directly (cluster/namespace explicit, size 0|1 enforced); studio_api.delete resolves the control-plane bucket from env/account and reuses delete::run_with_bucket (widened to pub(crate)). Neither reads ~/.oabctl/config.toml anymore. Seam + MCP deploy_scale (name/cluster/ namespace, size 0|1) updated to match. 2. Drop RuntimeDriver::observe + the state() convenience (only called by each other, never externally) and remove EcsDriver's unimplemented! stub — the trait is projection-only this slice, no shipped panic. Verified locally: agent-lifecycle 10 tests + clippy green. 3. oab-mcp unit tests: tool catalog (6 named) + deployment_json wire shape. 4. apply_deployment error carries the structured ApplyErrorKind. 7. oab-mcp README (tools, run, env, mcp.json, 0/1 scale semantics). VENDORED.md updated for the studio_api rewrite + run_with_bucket visibility. oabctl/studio-cp/oab-mcp remain compile-blind locally; CI is the gate. --- crates/agent-lifecycle/src/ecs.rs | 7 --- crates/agent-lifecycle/src/lib.rs | 23 +++++----- crates/oab-mcp/README.md | 48 ++++++++++++++++++++ crates/oab-mcp/src/main.rs | 74 +++++++++++++++++++++++++++---- crates/oabctl/VENDORED.md | 13 ++++-- crates/oabctl/src/delete.rs | 2 +- crates/oabctl/src/studio_api.rs | 41 ++++++++++++++--- crates/studio-cp/src/lib.rs | 17 ++++--- 8 files changed, 181 insertions(+), 44 deletions(-) create mode 100644 crates/oab-mcp/README.md diff --git a/crates/agent-lifecycle/src/ecs.rs b/crates/agent-lifecycle/src/ecs.rs index 2b8b509..0f85f3b 100644 --- a/crates/agent-lifecycle/src/ecs.rs +++ b/crates/agent-lifecycle/src/ecs.rs @@ -47,13 +47,6 @@ impl RuntimeDriver for EcsDriver { /// Task ARN. type InstanceId = String; - fn observe(&self, _id: &Self::InstanceId) -> Option { - // Slice-1: wiring to the ECS API is deferred. The real implementation - // calls DescribeTasks and returns `None` when the task is absent from - // the response (⇒ Stopped). - unimplemented!("ECS DescribeTasks wiring is a later slice") - } - fn project(&self, task: &EcsTask, verified_before: bool) -> Discriminator { let desired_status = if task.desired_status_stopped { DesiredStatus::Stopped diff --git a/crates/agent-lifecycle/src/lib.rs b/crates/agent-lifecycle/src/lib.rs index 88a06ae..4b709b4 100644 --- a/crates/agent-lifecycle/src/lib.rs +++ b/crates/agent-lifecycle/src/lib.rs @@ -118,28 +118,27 @@ impl IdentityLatch { } /// A runtime driver projects its native signals onto the canonical model. +/// +/// This slice is **projection-only**: a driver turns an externally-supplied +/// native observation into the four discriminator axes via [`project`]. Live +/// self-observation (a `DescribeTasks`-style `observe`, and the `observe + +/// project + classify` `state` convenience that ⇒ `Stopped` on absence) lands +/// with the ECS live-driver slice; it is intentionally absent here rather than +/// stubbed with a panic. +/// +/// [`project`]: RuntimeDriver::project pub trait RuntimeDriver { /// The driver's native, per-instance observation type. type Native; - /// Opaque per-instance identifier in this runtime. + /// Opaque per-instance identifier in this runtime (for the live-observe + /// slice; carried now so the associated type is stable). type InstanceId; - /// Observe an instance. `None` ⇒ the instance no longer exists (⇒ Stopped). - fn observe(&self, id: &Self::InstanceId) -> Option; - /// Project a native observation onto the four discriminator axes. /// /// `verified_before` is the latched `identity_verified` the control plane /// has tracked for this instance so far. fn project(&self, native: &Self::Native, verified_before: bool) -> Discriminator; - - /// Convenience: observe + project + classify into an [`AgentState`]. - fn state(&self, id: &Self::InstanceId, verified_before: bool) -> AgentState { - match self.observe(id) { - None => AgentState::Stopped, - Some(native) => self.project(&native, verified_before).classify(), - } - } } #[cfg(test)] diff --git a/crates/oab-mcp/README.md b/crates/oab-mcp/README.md new file mode 100644 index 0000000..3cae486 --- /dev/null +++ b/crates/oab-mcp/README.md @@ -0,0 +1,48 @@ +# oab-mcp + +The Studio control-plane **MCP server** (ADR-2). It exposes the `studio-cp` +read/write model as MCP tools over **stdio**, so an agent operates the OAB +control plane as a first-class client — "agents do control, humans direct." + +## Tools + +| Tool | Kind | Arguments | +|------|------|-----------| +| `deploy_list` | read | `cluster?` | +| `deploy_get` | read | `service`, `cluster?` | +| `get_agent_states` | read | `service?`, `cluster?` | +| `deploy_apply` | write | `manifest_yaml`, `cluster?`, `wait?` | +| `deploy_scale` | write | `name`, `size` (0/1), `cluster?`, `namespace?` | +| `deploy_delete` | write | `resource`, `name`, `cluster?`, `namespace?` | + +Reads project each ECS instance onto the canonical 6-state `AgentState` +(ADR-1). `deploy_scale` is 0 (off) / 1 (on) only — an OAB service runs a single +bot token, so >1 would duplicate responders. + +## Run + +```sh +OAB_CLUSTER=oab cargo run -p oab-mcp +``` + +The server speaks newline-delimited JSON-RPC on stdin/stdout. AWS credentials +are resolved from the standard chain **lazily**, on the first real tool call — +`initialize` and `tools/list` need none. `deploy_delete` resolves the +control-plane bucket from `$OAB_CONTROL_PLANE_BUCKET` (or the caller's account); +none of the write paths read `~/.oabctl/config.toml`. + +`cluster` / `namespace` default to `$OAB_CLUSTER` (then `oab`) and `default`, +and are overridable per call. + +## Register (mcp.json) + +```json +{ + "mcpServers": { + "oab-studio": { + "command": "oab-mcp", + "env": { "OAB_CLUSTER": "oab" } + } + } +} +``` diff --git a/crates/oab-mcp/src/main.rs b/crates/oab-mcp/src/main.rs index 8f43c74..664fdc4 100644 --- a/crates/oab-mcp/src/main.rs +++ b/crates/oab-mcp/src/main.rs @@ -91,14 +91,16 @@ fn tools() -> Vec { ), Tool::new( "deploy_scale", - "Scale an agent/service to a target replica count.", + "Scale an OAB service on or off. OAB services run a single bot token, so size must be 0 (off) or 1 (on).", as_map(json!({ "type": "object", "properties": { - "alias": { "type": "string", "description": "Agent alias / service name." }, - "size": { "type": "integer", "description": "Desired replica count." } + "name": { "type": "string", "description": "Agent / service name (service = oab-{namespace}-{name})." }, + "size": { "type": "integer", "enum": [0, 1], "description": "0 = off, 1 = on." }, + "cluster": { "type": "string", "description": "ECS cluster (defaults to the server's configured cluster)." }, + "namespace": { "type": "string", "description": "Namespace (default \"default\")." } }, - "required": ["alias", "size"] + "required": ["name", "size"] })), ), Tool::new( @@ -212,16 +214,23 @@ impl OabMcp { } async fn t_scale(&self, args: &Map) -> Result { - let alias = args - .get("alias") + let cluster = self.cluster(args); + let namespace = args + .get("namespace") .and_then(Value::as_str) - .ok_or_else(|| anyhow::anyhow!("missing required arg: alias"))?; + .unwrap_or("default"); + let name = args + .get("name") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("missing required arg: name"))?; let size = args.get("size") .and_then(Value::as_i64) .ok_or_else(|| anyhow::anyhow!("missing or invalid arg: size"))? as i32; - scp::scale_deployment(&self.aws, alias, size).await?; - Ok(json!({ "ok": true, "alias": alias, "size": size })) + scp::scale_deployment(&self.aws, &cluster, namespace, name, size).await?; + Ok( + json!({ "ok": true, "cluster": cluster, "namespace": namespace, "name": name, "size": size }), + ) } async fn t_delete(&self, args: &Map) -> Result { @@ -309,3 +318,50 @@ async fn main() -> anyhow::Result<()> { service.waiting().await?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn catalog_advertises_the_six_named_tools() { + let catalog = serde_json::to_value(tools()).expect("tools serialize"); + let names: Vec = catalog + .as_array() + .expect("tool list is an array") + .iter() + .map(|t| t["name"].as_str().expect("tool has a name").to_string()) + .collect(); + assert_eq!(names.len(), 6); + for expected in [ + "deploy_list", + "deploy_get", + "get_agent_states", + "deploy_apply", + "deploy_scale", + "deploy_delete", + ] { + assert!(names.contains(&expected.to_string()), "missing {expected}"); + } + } + + #[test] + fn deployment_json_carries_counters_and_instance_states() { + let d = scp::Deployment { + name: "orca".into(), + namespace: "prod".into(), + desired: 1, + current: 1, + ready: 1, + instances: vec![scp::InstancePhase { + id: "task-arn".into(), + phase: scp::AgentState::Running, + }], + }; + let v = deployment_json(&d); + assert_eq!(v["desired"], 1); + assert_eq!(v["ready"], 1); + assert_eq!(v["instances"][0]["id"], "task-arn"); + assert_eq!(v["instances"][0]["state"], "Running"); + } +} diff --git a/crates/oabctl/VENDORED.md b/crates/oabctl/VENDORED.md index 07df623..852ca2f 100644 --- a/crates/oabctl/VENDORED.md +++ b/crates/oabctl/VENDORED.md @@ -19,7 +19,14 @@ upstream `openabdev/openab` `LICENSE`. To keep the diff against upstream auditable, Studio-specific changes are additive and listed here: -- **`src/studio_api.rs`** — a programmatic surface (`parse_manifests`, `scale`, - `delete`) so `studio-cp` / `oab-mcp` call a library API instead of shelling - out. Thin wrappers over existing internals; no upstream behaviour changed. +- **`src/studio_api.rs`** — a programmatic, config-free surface + (`parse_manifests`, `scale`, `delete`) so `studio-cp` / `oab-mcp` call a + library API instead of shelling out. Unlike the CLI paths it never reads + `~/.oabctl/config.toml`: it drives ECS `UpdateService` via + `ecsctl::scale::scale_service` directly, and resolves the control-plane + bucket via `control_plane::resolve_bucket` (env / account). No upstream + behaviour changed. - **`src/lib.rs`** — one line: `pub mod studio_api;`. +- **`src/delete.rs`** — one visibility widening: `run_with_bucket` is now + `pub(crate)` (was module-private) so `studio_api::delete` can reuse the + config-free delete path. Body unchanged. diff --git a/crates/oabctl/src/delete.rs b/crates/oabctl/src/delete.rs index 953e863..b8b273f 100644 --- a/crates/oabctl/src/delete.rs +++ b/crates/oabctl/src/delete.rs @@ -84,7 +84,7 @@ pub(crate) async fn run( run_with_bucket(aws_config, resource, name, cluster, namespace, &bucket).await } -async fn run_with_bucket( +pub(crate) async fn run_with_bucket( aws_config: &aws_config::SdkConfig, resource: &str, name: &str, diff --git a/crates/oabctl/src/studio_api.rs b/crates/oabctl/src/studio_api.rs index c044859..609dac1 100644 --- a/crates/oabctl/src/studio_api.rs +++ b/crates/oabctl/src/studio_api.rs @@ -5,6 +5,12 @@ //! shelling out to the `oabctl` binary. **Additive only** — this module adds no //! behaviour to the existing CLI paths and changes no existing public type. See //! `VENDORED.md`. +//! +//! Unlike the CLI entry points, these functions **never read +//! `~/.oabctl/config.toml`**: the cluster/namespace (and, for delete, the +//! control-plane bucket) are passed explicitly or resolved from the +//! environment, so an MCP/host process with no oabctl config can still drive +//! writes. use crate::manifest::{OABFleetManifest, OABServiceManifest, RawManifest}; use anyhow::{Context, Result}; @@ -32,22 +38,43 @@ pub fn parse_manifests(yaml: &str) -> Result> { } } -/// Immediate scale of an agent/service to `size` replicas (ECS `UpdateService`). +/// Immediate scale of an OAB service to `size` replicas via ECS `UpdateService`. /// -/// Thin wrapper over the CLI's scale path. -pub async fn scale(config: &aws_config::SdkConfig, alias: &str, size: i32) -> Result<()> { - crate::scale::run(config, alias, size).await +/// The service name is `oab-{namespace}-{name}`. OAB services carry a single +/// bot token, so `size` must be **0 (off) or 1 (on)** — anything else would +/// produce duplicate responders and is rejected. Config-free: cluster and +/// namespace are explicit. +pub async fn scale( + config: &aws_config::SdkConfig, + cluster: &str, + namespace: &str, + name: &str, + size: i32, +) -> Result<()> { + if size != 0 && size != 1 { + anyhow::bail!( + "invalid size: {size}. OAB services scale only to 0 (off) or 1 (on) — \ + each runs a single bot token and scaling above 1 duplicates responses." + ); + } + let service_name = format!("oab-{namespace}-{name}"); + let ecs = aws_sdk_ecs::Client::new(config); + ecsctl::scale::scale_service(&ecs, cluster, &service_name, size, false).await } -/// Delete a control-plane resource (e.g. an `OABService`). +/// Delete a control-plane resource (currently `oabservice`). /// -/// Thin wrapper over the CLI's delete path. +/// Config-free: cluster and namespace are explicit; the control-plane bucket is +/// resolved from `control_plane_bucket`, then `$OAB_CONTROL_PLANE_BUCKET`, then +/// the caller's account — never from `~/.oabctl/config.toml`. pub async fn delete( config: &aws_config::SdkConfig, resource: &str, name: &str, cluster: &str, namespace: &str, + control_plane_bucket: Option<&str>, ) -> Result<()> { - crate::delete::run(config, resource, name, cluster, namespace).await + let bucket = crate::control_plane::resolve_bucket(config, control_plane_bucket).await?; + crate::delete::run_with_bucket(config, resource, name, cluster, namespace, &bucket).await } diff --git a/crates/studio-cp/src/lib.rs b/crates/studio-cp/src/lib.rs index f760fdf..b179254 100644 --- a/crates/studio-cp/src/lib.rs +++ b/crates/studio-cp/src/lib.rs @@ -159,19 +159,26 @@ pub async fn apply_deployment( let opts = ApplyOptions::new(cluster).with_wait(wait); oabctl::apply_manifests(aws_config, &manifests, &opts) .await - .map_err(|e| anyhow::anyhow!("{e}")) + .map_err(|e| anyhow::anyhow!("apply failed [{:?}]: {e}", e.kind)) } -/// Scale an agent/service to `size` replicas. +/// Scale an OAB service to `size` replicas (0 = off, 1 = on). +/// +/// Config-free: `cluster` / `namespace` are explicit (service = `oab-{namespace}-{name}`). pub async fn scale_deployment( aws_config: &aws_config::SdkConfig, - alias: &str, + cluster: &str, + namespace: &str, + name: &str, size: i32, ) -> anyhow::Result<()> { - oabctl::studio_api::scale(aws_config, alias, size).await + oabctl::studio_api::scale(aws_config, cluster, namespace, name, size).await } /// Delete a control-plane resource (e.g. an `OABService`). +/// +/// The control-plane bucket is resolved from the environment / account, not +/// from `~/.oabctl/config.toml`. pub async fn delete_deployment( aws_config: &aws_config::SdkConfig, resource: &str, @@ -179,7 +186,7 @@ pub async fn delete_deployment( cluster: &str, namespace: &str, ) -> anyhow::Result<()> { - oabctl::studio_api::delete(aws_config, resource, name, cluster, namespace).await + oabctl::studio_api::delete(aws_config, resource, name, cluster, namespace, None).await } #[cfg(test)] From 0ec408c56b94d4809c3e6ef1e94f1d247489d228 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Sun, 9 Aug 2026 16:35:01 +0800 Subject: [PATCH 13/13] ci: publish oab-mcp binary as an artifact So a runtime that can't compile oab-mcp locally (aws-sdk-ec2 > small-box RAM) can fetch the CI-built binary and drive it as an MCP client. --- .github/workflows/ci.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4bc79bf..3a74324 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,15 @@ jobs: grep -q "\"$t\"" out.jsonl || { echo "SMOKE FAIL: tool '$t' not advertised"; exit 1; } done echo "SMOKE OK: server initialized and advertised all 6 tools" + # Publish the built server so a runtime that can't compile it locally + # (aws-sdk-ec2 exceeds small boxes' RAM) can still fetch and run it. + - name: upload oab-mcp binary + uses: actions/upload-artifact@v4 + with: + name: oab-mcp-linux-x64 + path: target/debug/oab-mcp + if-no-files-found: error + retention-days: 7 # Informational (non-blocking) — the authoring runtime has no rustfmt, so # style is tidied opportunistically rather than gated. - name: fmt (our crates)