Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/oabctl/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
7 changes: 4 additions & 3 deletions crates/oabctl/src/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
pub warnings: Vec<String>,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()],
Expand Down
95 changes: 95 additions & 0 deletions crates/oabctl/src/driver.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
pub wait: bool,
}

#[async_trait]
pub trait ProvisionDriver {
/// Create-or-update the given manifests.
async fn apply(&self, manifests: &[OABServiceManifest], opts: &ProvisionOptions) -> Result<ApplyReport>;

/// 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<ApplyReport> {
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
}
}
2 changes: 2 additions & 0 deletions crates/oabctl/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ mod config;
mod control_plane;
mod create;
mod delete;
pub mod driver;
pub mod events;
mod get;
mod ingress;
Expand All @@ -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};

Expand Down
32 changes: 15 additions & 17 deletions crates/oabctl/src/studio_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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`).
Expand All @@ -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)]
Expand Down
Loading