diff --git a/crates/oabctl/Cargo.toml b/crates/oabctl/Cargo.toml index 4cee9c9..3a0bb13 100644 --- a/crates/oabctl/Cargo.toml +++ b/crates/oabctl/Cargo.toml @@ -30,6 +30,7 @@ aws-sdk-secretsmanager = "1" aws-sdk-apigatewayv2 = "1" aws-sdk-servicediscovery = "1" ecsctl = { git = "https://github.com/oablab/ecsctl.git", rev = "90a6cd1" } +async-trait = "0.1" clap = { version = "4.5", features = ["derive"] } chrono = "0.4" serde = { version = "1.0", features = ["derive"] } diff --git a/crates/oabctl/src/apply.rs b/crates/oabctl/src/apply.rs index 25e1806..a7edf20 100644 --- a/crates/oabctl/src/apply.rs +++ b/crates/oabctl/src/apply.rs @@ -61,7 +61,8 @@ impl From<&OABServiceManifest> for ServiceTarget { pub struct AppliedService { pub namespace: String, pub name: String, - pub ecs_service_name: String, + /// The driver-native resource name (ECS service name today). + pub resource_name: String, pub action: ApplyAction, pub webhook_urls: Vec, pub warnings: Vec, @@ -1063,7 +1064,7 @@ async fn apply_ecs( Ok(AppliedService { namespace: m.metadata.namespace.clone(), name: m.metadata.name.clone(), - ecs_service_name: service_name, + resource_name: service_name, action, webhook_urls, warnings, @@ -1290,7 +1291,7 @@ spec: let completed_service = AppliedService { namespace: "prod".to_string(), name: "done".to_string(), - ecs_service_name: "oab-prod-done".to_string(), + resource_name: "oab-prod-done".to_string(), action: ApplyAction::Updated, webhook_urls: vec!["https://example.test/webhook".to_string()], warnings: vec!["degraded".to_string()], diff --git a/crates/oabctl/src/driver.rs b/crates/oabctl/src/driver.rs new file mode 100644 index 0000000..3d716b9 --- /dev/null +++ b/crates/oabctl/src/driver.rs @@ -0,0 +1,95 @@ +//! Provisioning driver seam (K8s driver, ADR #63 slice 3, sub-slice 3a). +//! +//! `studio_api`'s write path (`provision`/`redeploy`/`scale`/`delete`) is the +//! actual surface `studio-cp` depends on for mutation. This trait carves the +//! ECS specifics out from behind that surface so a `K8sDriver` can be added +//! (slice 3b) without changing `studio_api`'s public signatures again. +//! +//! `EcsDriver` below is a pass-through: it wraps the existing free functions +//! in `apply`/`delete`/`ecsctl` unchanged, so this file changes no ECS +//! behavior. `cluster` lives on the driver instance rather than in a per-call +//! parameter or in `ApplyOptions` — a target (ECS cluster, or later a k8s +//! context+namespace) is a property of *which driver* you built, not +//! something you repeat on every call. `ProvisionOptions` carries only the +//! fields that actually generalize across drivers (bucket, wait); ECS's own +//! `ApplyOptions` (used by `apply_manifests` directly, still ECS-specific) +//! is unaffected. +//! +//! It has exactly one implementation and isn't yet load-bearing for dispatch +//! (there's nothing to dispatch to until 3b) — same shape as +//! `manifest::Runtime::Kubernetes`, which has sat as a validated-but-rejected +//! schema stub since slice-0 for the same reason: declare the seam, fill the +//! second side in when it exists. + +use crate::apply::ApplyReport; +use crate::manifest::OABServiceManifest; +use anyhow::Result; +use async_trait::async_trait; + +/// Generic apply options — the subset of `apply::ApplyOptions` that isn't +/// ECS-specific. `cluster` lives on the driver instance instead (see module +/// docs). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ProvisionOptions { + pub control_plane_bucket: Option, + pub wait: bool, +} + +#[async_trait] +pub trait ProvisionDriver { + /// Create-or-update the given manifests. + async fn apply(&self, manifests: &[OABServiceManifest], opts: &ProvisionOptions) -> Result; + + /// Scale a single service. OAB services carry a single bot token, so + /// `size` must be 0 (off) or 1 (on) — enforced by the implementation. + async fn scale(&self, namespace: &str, name: &str, size: i32) -> Result<()>; + + /// Delete a control-plane resource (`resource` is currently always + /// `"oabservice"`). `control_plane_bucket` is the already-resolved bucket + /// (see `control_plane::resolve_bucket`) — the driver does not resolve it. + async fn delete(&self, resource: &str, name: &str, namespace: &str, control_plane_bucket: &str) -> Result<()>; +} + +/// The ECS implementation. Every method is a thin wrapper over the existing +/// `apply`/`delete`/`ecsctl` free functions — no logic moved or changed. +pub struct EcsDriver<'a> { + pub aws_config: &'a aws_config::SdkConfig, + pub cluster: &'a str, +} + +#[async_trait] +impl<'a> ProvisionDriver for EcsDriver<'a> { + async fn apply(&self, manifests: &[OABServiceManifest], opts: &ProvisionOptions) -> Result { + let mut ecs_opts = crate::apply::ApplyOptions::new(self.cluster).with_wait(opts.wait); + if let Some(bucket) = &opts.control_plane_bucket { + ecs_opts = ecs_opts.with_control_plane_bucket(bucket.clone()); + } + crate::apply::apply_manifests(self.aws_config, manifests, &ecs_opts) + .await + .map_err(|e| anyhow::anyhow!("apply failed [{:?}]: {e}", e.kind)) + } + + async fn scale(&self, 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(self.aws_config); + ecsctl::scale::scale_service(&ecs, self.cluster, &service_name, size, false).await + } + + async fn delete(&self, resource: &str, name: &str, namespace: &str, control_plane_bucket: &str) -> Result<()> { + crate::delete::run_with_bucket( + self.aws_config, + resource, + name, + self.cluster, + namespace, + control_plane_bucket, + ) + .await + } +} diff --git a/crates/oabctl/src/lib.rs b/crates/oabctl/src/lib.rs index 955de1f..6541921 100644 --- a/crates/oabctl/src/lib.rs +++ b/crates/oabctl/src/lib.rs @@ -53,6 +53,7 @@ mod config; mod control_plane; mod create; mod delete; +pub mod driver; pub mod events; mod get; mod ingress; @@ -72,6 +73,7 @@ pub use manifest::{ Runtime, Spec, }; +pub use driver::{EcsDriver, ProvisionDriver, ProvisionOptions}; pub use events::{fetch_ecs_events, EcsEvent, DEFAULT_EVENTS_LOG_GROUP}; pub use status::{instance_status, service_status, InstanceStatus, ServiceStatus}; diff --git a/crates/oabctl/src/studio_api.rs b/crates/oabctl/src/studio_api.rs index 8800cf3..e73ec04 100644 --- a/crates/oabctl/src/studio_api.rs +++ b/crates/oabctl/src/studio_api.rs @@ -12,6 +12,7 @@ //! environment, so an MCP/host process with no oabctl config can still drive //! writes. +use crate::driver::ProvisionDriver; use crate::manifest::{OABFleetManifest, OABServiceManifest, RawManifest}; use anyhow::{Context, Result}; use aws_sdk_s3::primitives::ByteStream; @@ -106,14 +107,15 @@ pub async fn provision( // pulls the task up and reads config/persona/skills from it. push_bundle(config, control_plane_bucket, objects).await?; - // 2. Apply the service manifest at its chosen image tag. `apply_manifests` is - // config-free and reconciles create-or-update. + // 2. Apply the service manifest at its chosen image tag, through the + // provisioning driver seam (config-free, reconciles create-or-update). let manifests = parse_manifests(manifest_yaml)?; - let mut opts = crate::apply::ApplyOptions::new(cluster); - if let Some(bucket) = control_plane_bucket { - opts = opts.with_control_plane_bucket(bucket); - } - crate::apply::apply_manifests(config, &manifests, &opts) + let opts = crate::driver::ProvisionOptions { + control_plane_bucket: control_plane_bucket.map(str::to_string), + wait: false, + }; + crate::driver::EcsDriver { aws_config: config, cluster } + .apply(&manifests, &opts) .await .context("failed to apply manifest during provision") } @@ -199,15 +201,9 @@ pub async fn scale( 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 + crate::driver::EcsDriver { aws_config: config, cluster } + .scale(namespace, name, size) + .await } /// Delete a control-plane resource (currently `oabservice`). @@ -224,7 +220,9 @@ pub async fn delete( control_plane_bucket: Option<&str>, ) -> Result<()> { let bucket = crate::control_plane::resolve_bucket(config, control_plane_bucket).await?; - crate::delete::run_with_bucket(config, resource, name, cluster, namespace, &bucket).await + crate::driver::EcsDriver { aws_config: config, cluster } + .delete(resource, name, namespace, &bucket) + .await } #[cfg(test)]