use std::path::{Path, PathBuf};
use boatramp_core::config::{DeployConfig, HandlerLimits, SiteConfig};
use serde::Deserialize;
use serde_json::json;
use crate::client;
use crate::config::{BuildConfig, ProjectConfig};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("no manifest at {0} (apply needs a manifest to reconcile)")]
Missing(String),
#[error("invalid manifest syntax: {0}")]
Ron(#[from] ron::error::SpannedError),
#[error("site {site}: routing: {source}")]
Routing {
site: String,
#[source]
source: boatramp_core::ConfigError,
},
#[error(transparent)]
Client(#[from] crate::client::ClientError),
#[error(transparent)]
Cp(#[from] CpError),
#[error(transparent)]
Config(#[from] crate::config::ConfigError),
#[error(transparent)]
Build(#[from] crate::build::Error),
#[error(transparent)]
Sync(#[from] crate::sync::Error),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Json(#[from] serde_json::Error),
}
type Result<T> = std::result::Result<T, Error>;
#[allow(async_fn_in_trait)] trait ControlPlane {
async fn get_project(&self, name: &str) -> CpResult<serde_json::Value>;
async fn create_project(&self, body: &serde_json::Value) -> CpResult<serde_json::Value>;
async fn create_deployment(
&self,
site: &str,
manifest: &boatramp_core::deploy::Manifest,
) -> CpResult<crate::client::CreateDeploymentResponse>;
async fn upload_blob_source(
&self,
hash: &str,
source: &crate::sync::BlobSource,
) -> CpResult<()>;
async fn put_site_config(&self, site: &str, config: &SiteConfig) -> CpResult<()>;
async fn activate(&self, site: &str, id: &str) -> CpResult<()>;
async fn put_file_blob(&self, path: &Path) -> CpResult<String>;
async fn deploy_function(
&self,
name: &str,
body: &serde_json::Value,
) -> CpResult<serde_json::Value>;
async fn put_compute(
&self,
name: &str,
body: &serde_json::Value,
) -> CpResult<serde_json::Value>;
}
#[derive(Debug, thiserror::Error)]
pub enum CpError {
#[error("control-plane resource not found (HTTP 404)")]
NotFound,
#[error("control-plane resource already exists (HTTP 409)")]
Conflict,
#[error(transparent)]
Client(#[from] crate::client::ClientError),
}
type CpResult<T> = std::result::Result<T, CpError>;
impl ControlPlane for client::ControlPlane {
async fn get_project(&self, name: &str) -> CpResult<serde_json::Value> {
self.get_project(name).await.map_err(|e| {
if is_not_found(&e) {
CpError::NotFound
} else {
CpError::Client(e)
}
})
}
async fn create_project(&self, body: &serde_json::Value) -> CpResult<serde_json::Value> {
self.create_project(body).await.map_err(|e| {
if is_conflict(&e) {
CpError::Conflict
} else {
CpError::Client(e)
}
})
}
async fn create_deployment(
&self,
site: &str,
manifest: &boatramp_core::deploy::Manifest,
) -> CpResult<crate::client::CreateDeploymentResponse> {
self.create_deployment(site, manifest, &[])
.await
.map_err(CpError::Client)
}
async fn upload_blob_source(
&self,
hash: &str,
source: &crate::sync::BlobSource,
) -> CpResult<()> {
self.upload_blob_source(hash, source)
.await
.map_err(CpError::Client)
}
async fn put_site_config(&self, site: &str, config: &SiteConfig) -> CpResult<()> {
self.put_site_config(site, config)
.await
.map_err(CpError::Client)
}
async fn activate(&self, site: &str, id: &str) -> CpResult<()> {
self.activate(site, id).await.map_err(CpError::Client)
}
async fn put_file_blob(&self, path: &Path) -> CpResult<String> {
self.put_file_blob(path).await.map_err(CpError::Client)
}
async fn deploy_function(
&self,
name: &str,
body: &serde_json::Value,
) -> CpResult<serde_json::Value> {
self.deploy_function(name, body)
.await
.map_err(CpError::Client)
}
async fn put_compute(
&self,
name: &str,
body: &serde_json::Value,
) -> CpResult<serde_json::Value> {
self.put_compute(name, body).await.map_err(CpError::Client)
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ApplyManifest {
pub project: Option<String>,
pub sites: Vec<ApplySite>,
pub functions: Vec<ApplyFunction>,
pub compute: Vec<ApplyCompute>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ApplySite {
pub name: String,
#[serde(default)]
pub path: Option<String>,
#[serde(default)]
pub build: Option<BuildConfig>,
#[serde(default)]
pub routing: Option<DeployConfig>,
#[serde(default)]
pub config: Option<SiteConfig>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ApplyFunction {
pub name: String,
pub component: String,
#[serde(default)]
pub runtime: Option<String>,
#[serde(default)]
pub webhook_secret_env: Option<String>,
#[serde(default)]
pub imports: Vec<String>,
#[serde(default)]
pub env: std::collections::BTreeMap<String, String>,
#[serde(default)]
pub invoke_targets: Vec<String>,
#[serde(default)]
pub limits: Option<HandlerLimits>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ApplyCompute {
pub name: String,
pub spec: serde_json::Value,
}
impl ApplyManifest {
pub fn parse(text: &str) -> Result<Self> {
let manifest: Self = crate::config::ron_options().from_str(text)?;
for site in &manifest.sites {
if let Some(routing) = &site.routing {
routing.compile_check().map_err(|source| Error::Routing {
site: site.name.clone(),
source,
})?;
}
}
Ok(manifest)
}
pub fn load(path: &Path) -> Result<Self> {
match std::fs::read_to_string(path) {
Ok(text) => Self::parse(&text),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
Err(Error::Missing(path.display().to_string()))
}
Err(err) => Err(err.into()),
}
}
}
#[derive(Debug, clap::Args)]
pub struct ApplyArgs {
#[arg(short = 'f', long, default_value = "apply.cfg")]
file: PathBuf,
#[arg(long, env = "BOATRAMP_SERVER")]
server: Option<String>,
#[arg(long)]
dry_run: bool,
#[arg(long)]
build: bool,
}
pub async fn run(args: ApplyArgs, config: &ProjectConfig) -> Result<()> {
let manifest = ApplyManifest::load(&args.file)?;
let project = manifest
.project
.clone()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| client::resolve_project(config));
let (server, http) = client::connect(args.server.clone(), config)?;
let cp = client::ControlPlane::new(server, http, project.clone());
println!(
"applying {} to project `{project}`: {} site(s), {} function(s), {} compute workload(s){}",
args.file.display(),
manifest.sites.len(),
manifest.functions.len(),
manifest.compute.len(),
if args.dry_run { " (dry-run)" } else { "" },
);
ensure_project(&cp, &project, args.dry_run).await?;
for site in &manifest.sites {
apply_site(&cp, site, config, args.build, args.dry_run).await?;
}
for function in &manifest.functions {
apply_function(&cp, function, args.dry_run).await?;
}
for compute in &manifest.compute {
apply_compute(&cp, compute, args.dry_run).await?;
}
println!("apply complete");
Ok(())
}
async fn ensure_project<C: ControlPlane>(cp: &C, project: &str, dry_run: bool) -> Result<()> {
if project == boatramp_core::project::DEFAULT_PROJECT {
return Ok(());
}
if dry_run {
println!(" project `{project}`: ensure exists");
return Ok(());
}
match cp.get_project(project).await {
Ok(_) => Ok(()),
Err(CpError::NotFound) => match cp.create_project(&json!({ "name": project })).await {
Ok(_) => {
println!(" created project `{project}`");
Ok(())
}
Err(CpError::Conflict) => Ok(()),
Err(err) => Err(err.into()),
},
Err(err) => Err(err.into()),
}
}
async fn apply_site<C: ControlPlane>(
cp: &C,
site: &ApplySite,
config: &ProjectConfig,
build_flag: bool,
dry_run: bool,
) -> Result<()> {
let dir = site_content_dir(site);
if dry_run {
println!(
" site `{}`: would deploy {} (build: {}, routing: {}, config: {})",
site.name,
dir.display(),
yes_no(site.build.is_some() || build_flag),
yes_no(site.routing.is_some()),
yes_no(site.config.is_some()),
);
return Ok(());
}
if let Some(build) = &site.build {
crate::build::run_command(&build.command).await?;
} else if build_flag {
let command = crate::build::resolve_command(None, config)?;
crate::build::run_command(&command).await?;
}
if !dir.is_dir() {
return Err(Error::Sync(crate::sync::Error::NotADirectory(
dir.display().to_string(),
)));
}
let (mut manifest, blobs_by_hash) = crate::sync::build_manifest(&dir).await?;
if let Some(routing) = &site.routing {
manifest.config = routing.clone();
}
let created = cp.create_deployment(&site.name, &manifest).await?;
println!(
" site `{}`: deployment {} — uploading {} new blob(s)",
site.name,
created.id,
created.missing.len(),
);
for hash in &created.missing {
let source = blobs_by_hash
.get(hash)
.ok_or_else(|| Error::Sync(crate::sync::Error::NoLocalSource(hash.clone())))?;
cp.upload_blob_source(hash, source).await?;
}
if let Some(site_config) = &site.config {
cp.put_site_config(&site.name, site_config).await?;
println!(" site `{}`: config applied", site.name);
}
cp.activate(&site.name, &created.id).await?;
println!(" site `{}`: activated {}", site.name, created.id);
Ok(())
}
async fn apply_function<C: ControlPlane>(
cp: &C,
function: &ApplyFunction,
dry_run: bool,
) -> Result<()> {
if dry_run {
println!(
" function `{}`: would deploy from {}",
function.name, function.component,
);
return Ok(());
}
let hash = cp.put_file_blob(Path::new(&function.component)).await?;
let mut cfg = serde_json::Map::new();
if let Some(runtime) = &function.runtime {
cfg.insert("runtime".to_string(), json!(runtime));
}
if let Some(secret_env) = &function.webhook_secret_env {
cfg.insert("webhook".to_string(), json!({ "secret_env": secret_env }));
}
if !function.imports.is_empty() {
cfg.insert("imports".to_string(), json!(function.imports));
}
if !function.env.is_empty() {
cfg.insert("env".to_string(), json!(function.env));
}
if !function.invoke_targets.is_empty() {
cfg.insert("invoke_targets".to_string(), json!(function.invoke_targets));
}
if let Some(limits) = &function.limits {
cfg.insert("limits".to_string(), json!(limits));
}
let body = json!({
"component": hash,
"config": serde_json::Value::Object(cfg),
"lifecycle": "independent",
});
cp.deploy_function(&function.name, &body).await?;
println!(" function `{}`: deployed", function.name);
Ok(())
}
async fn apply_compute<C: ControlPlane>(
cp: &C,
compute: &ApplyCompute,
dry_run: bool,
) -> Result<()> {
if dry_run {
println!(" compute `{}`: would apply spec", compute.name);
return Ok(());
}
cp.put_compute(&compute.name, &compute.spec).await?;
println!(" compute `{}`: applied", compute.name);
Ok(())
}
fn site_content_dir(site: &ApplySite) -> PathBuf {
site.path
.clone()
.or_else(|| site.build.as_ref().and_then(|b| b.output.clone()))
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."))
}
fn is_not_found(err: &client::ClientError) -> bool {
matches!(
err,
client::ClientError::Http(e) if e.status() == Some(reqwest::StatusCode::NOT_FOUND)
)
}
fn is_conflict(err: &client::ClientError) -> bool {
matches!(
err,
client::ClientError::Http(e) if e.status() == Some(reqwest::StatusCode::CONFLICT)
)
}
fn yes_no(b: bool) -> &'static str {
if b {
"yes"
} else {
"no"
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
#[derive(Default)]
struct MockCp {
calls: Mutex<Vec<String>>,
project_exists: bool,
}
impl MockCp {
fn rec(&self, line: impl Into<String>) {
self.calls.lock().unwrap().push(line.into());
}
fn calls(&self) -> Vec<String> {
self.calls.lock().unwrap().clone()
}
}
impl ControlPlane for MockCp {
async fn get_project(&self, name: &str) -> CpResult<serde_json::Value> {
self.rec(format!("get_project {name}"));
if self.project_exists {
Ok(json!({ "name": name }))
} else {
Err(CpError::NotFound)
}
}
async fn create_project(&self, body: &serde_json::Value) -> CpResult<serde_json::Value> {
self.rec(format!(
"create_project {}",
body["name"].as_str().unwrap_or_default()
));
Ok(body.clone())
}
async fn create_deployment(
&self,
site: &str,
_manifest: &boatramp_core::deploy::Manifest,
) -> CpResult<crate::client::CreateDeploymentResponse> {
self.rec(format!("create_deployment {site}"));
Ok(crate::client::CreateDeploymentResponse {
id: "dep-1".into(),
missing: vec![],
})
}
async fn upload_blob_source(
&self,
hash: &str,
_source: &crate::sync::BlobSource,
) -> CpResult<()> {
self.rec(format!("upload_blob_source {hash}"));
Ok(())
}
async fn put_site_config(&self, site: &str, _config: &SiteConfig) -> CpResult<()> {
self.rec(format!("put_site_config {site}"));
Ok(())
}
async fn activate(&self, site: &str, id: &str) -> CpResult<()> {
self.rec(format!("activate {site} {id}"));
Ok(())
}
async fn put_file_blob(&self, path: &Path) -> CpResult<String> {
self.rec(format!("put_file_blob {}", path.display()));
Ok("deadbeef".into())
}
async fn deploy_function(
&self,
name: &str,
_body: &serde_json::Value,
) -> CpResult<serde_json::Value> {
self.rec(format!("deploy_function {name}"));
Ok(json!({}))
}
async fn put_compute(
&self,
name: &str,
_body: &serde_json::Value,
) -> CpResult<serde_json::Value> {
self.rec(format!("put_compute {name}"));
Ok(json!({}))
}
}
fn a_function() -> ApplyFunction {
ApplyFunction {
name: "resize".into(),
component: "resize.wasm".into(),
runtime: None,
webhook_secret_env: None,
imports: vec![],
env: Default::default(),
invoke_targets: vec![],
limits: None,
}
}
#[tokio::test]
async fn ensure_project_creates_on_404() {
let mock = MockCp::default(); ensure_project(&mock, "acme", false).await.unwrap();
assert_eq!(mock.calls(), ["get_project acme", "create_project acme"]);
}
#[tokio::test]
async fn ensure_project_existing_does_not_create() {
let mock = MockCp {
project_exists: true,
..Default::default()
};
ensure_project(&mock, "acme", false).await.unwrap();
assert_eq!(mock.calls(), ["get_project acme"]);
}
#[tokio::test]
async fn ensure_project_skips_the_reserved_default() {
let mock = MockCp::default();
ensure_project(&mock, boatramp_core::project::DEFAULT_PROJECT, false)
.await
.unwrap();
assert!(mock.calls().is_empty(), "default is never created");
}
#[tokio::test]
async fn ensure_project_dry_run_mutates_nothing() {
let mock = MockCp::default();
ensure_project(&mock, "acme", true).await.unwrap();
assert!(mock.calls().is_empty(), "dry-run issues no requests");
}
#[tokio::test]
async fn apply_function_stages_blob_then_deploys() {
let mock = MockCp::default();
apply_function(&mock, &a_function(), false).await.unwrap();
assert_eq!(
mock.calls(),
["put_file_blob resize.wasm", "deploy_function resize"]
);
}
#[tokio::test]
async fn apply_function_dry_run_mutates_nothing() {
let mock = MockCp::default();
apply_function(&mock, &a_function(), true).await.unwrap();
assert!(mock.calls().is_empty());
}
#[tokio::test]
async fn apply_compute_puts_the_spec() {
let mock = MockCp::default();
let compute = ApplyCompute {
name: "api".into(),
spec: json!({ "replicas": 2 }),
};
apply_compute(&mock, &compute, false).await.unwrap();
assert_eq!(mock.calls(), ["put_compute api"]);
}
#[tokio::test]
async fn apply_compute_dry_run_mutates_nothing() {
let mock = MockCp::default();
let compute = ApplyCompute {
name: "api".into(),
spec: json!({}),
};
apply_compute(&mock, &compute, true).await.unwrap();
assert!(mock.calls().is_empty());
}
#[tokio::test]
async fn apply_site_applies_config_before_activate() {
let dir = std::env::temp_dir().join(format!(
"boatramp-apply-site-{}-{}",
std::process::id(),
"www"
));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("index.html"), b"<h1>hi</h1>").unwrap();
let mock = MockCp::default();
let site = ApplySite {
name: "www".into(),
path: Some(dir.display().to_string()),
build: None,
routing: None,
config: Some(SiteConfig::default()),
};
let result = apply_site(&mock, &site, &ProjectConfig::default(), false, false).await;
let _ = std::fs::remove_dir_all(&dir);
result.unwrap();
assert_eq!(
mock.calls(),
[
"create_deployment www",
"put_site_config www",
"activate www dep-1"
]
);
}
#[test]
fn manifest_round_trips_sites_functions_and_compute() {
let manifest = ApplyManifest::parse(
r#"(
project: "acme",
sites: [
(
name: "www",
path: "dist",
routing: ( clean_urls: true ),
),
(
name: "docs",
build: ( command: "npm run docs", output: "site" ),
config: ( domains: ( primary: "docs.acme.com" ) ),
),
],
functions: [
(
name: "resize", component: "resize.wasm", runtime: "wasm",
imports: ["sql", "invoke"],
env: { "IDP_JWKS": "https://idp/.well-known/jwks.json" },
invoke_targets: ["thumbnail", "img-*"],
),
],
compute: [
( name: "api", spec: { "replicas": 2 } ),
],
)"#,
)
.expect("manifest parses");
assert_eq!(manifest.project.as_deref(), Some("acme"));
assert_eq!(manifest.sites.len(), 2);
assert_eq!(manifest.sites[0].name, "www");
assert_eq!(manifest.sites[0].path.as_deref(), Some("dist"));
assert!(manifest.sites[0].routing.as_ref().unwrap().clean_urls);
assert_eq!(manifest.sites[1].name, "docs");
let build = manifest.sites[1].build.as_ref().unwrap();
assert_eq!(build.command, "npm run docs");
assert_eq!(build.output.as_deref(), Some("site"));
assert_eq!(
manifest.sites[1]
.config
.as_ref()
.unwrap()
.domains
.primary
.as_deref(),
Some("docs.acme.com"),
);
assert_eq!(manifest.functions.len(), 1);
let f = &manifest.functions[0];
assert_eq!(f.name, "resize");
assert_eq!(f.component, "resize.wasm");
assert_eq!(f.runtime.as_deref(), Some("wasm"));
assert_eq!(f.imports, ["sql", "invoke"]);
assert_eq!(
f.env.get("IDP_JWKS").map(String::as_str),
Some("https://idp/.well-known/jwks.json")
);
assert_eq!(f.invoke_targets, ["thumbnail", "img-*"]);
assert_eq!(manifest.compute.len(), 1);
assert_eq!(manifest.compute[0].name, "api");
assert_eq!(manifest.compute[0].spec["replicas"], json!(2));
}
#[test]
fn empty_manifest_is_the_default() {
let manifest = ApplyManifest::parse("()").expect("empty manifest parses");
assert!(manifest.project.is_none());
assert!(manifest.sites.is_empty());
assert!(manifest.functions.is_empty());
assert!(manifest.compute.is_empty());
}
#[test]
fn missing_manifest_is_an_error() {
let path =
std::env::temp_dir().join(format!("boatramp-apply-missing-{}.cfg", std::process::id()));
let _ = std::fs::remove_file(&path);
assert!(matches!(ApplyManifest::load(&path), Err(Error::Missing(_))));
}
#[test]
fn bad_routing_fails_the_compile_check() {
let err = ApplyManifest::parse(
r#"(
sites: [
( name: "www", routing: ( redirects: [ (from: "/a/**/b/**", to: "/x") ] ) ),
],
)"#,
)
.expect_err("bad routing is rejected");
assert!(matches!(err, Error::Routing { .. }));
}
#[test]
fn manifest_project_wins_over_config() {
let mut config = ProjectConfig::default();
config.publish.project = Some("acme".into());
let manifest = ApplyManifest::parse(r#"( project: "team-x" )"#).unwrap();
let resolved = manifest
.project
.clone()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| client::resolve_project(&config));
assert_eq!(resolved, "team-x");
let manifest = ApplyManifest::parse("()").unwrap();
let resolved = manifest
.project
.clone()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| client::resolve_project(&config));
assert_eq!(resolved, "acme");
}
}