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(
"this manifest does not match the current (v0.6.0) schema: {source}\n\n\
The most likely cause is a pre-v0.6.0 `compute[].spec` written as a raw JSON \
blob — v0.6.0 makes `compute[].spec` the typed `ComputeSpec` (breaking).\n\
If this is a pre-v0.6.0 manifest, add `version: 1` at the top and run \
`boatramp config migrate <file>` to upgrade it in place."
)]
StrictParse {
#[source]
source: ron::error::SpannedError,
},
#[error(
"this JSON manifest does not match the current (v0.6.0) schema: {source}\n\n\
JSON config input is current-schema only. If it was generated from Nickel, \
regenerate it against the current schema (re-run `nickel export`); check for a \
typo'd/unknown field (rejected by `deny_unknown_fields`) or a raw pre-v0.6.0 \
`compute[].spec` shape."
)]
JsonStrictParse {
#[source]
source: serde_json::Error,
},
#[error(
"manifest declares version {declared}, but this build only understands up to \
version {current} (v0.6.0). Upgrade boatramp, or lower the declared version."
)]
VersionTooNew { declared: u32, current: u32 },
#[error(
"JSON config input is current-schema only (declares version {declared}); \
regenerate current-schema JSON (e.g. re-run `nickel export`), or migrate the \
RON source with `boatramp config migrate`"
)]
JsonLegacyVersion { declared: u32 },
#[error("migrating manifest from version {from}: {reason}")]
Migration { from: u32, reason: String },
#[error(
"manifest {kind} name {name:?} is not accepted (v0.7.0 tightened name \
validation): {reason}.\n\n\
Run `boatramp project doctor` to list every non-conforming name and its fix, \
then rename to a valid slug (start/end with a letter or digit; interior may add \
'_' / '-'; 1-63 bytes) and re-apply."
)]
InvalidName {
kind: &'static str,
name: String,
reason: &'static str,
},
#[error(
"site {site}: `[handlers.graphql]` sets both `safelisted_ops` (inline) and \
`safelisted_ops_path` (file) — they are mutually exclusive; use one source of \
safelisted operations"
)]
SafelistConflict { site: String },
#[error("site {site}: reading safelisted_ops_path {path}: {source}")]
SafelistFile {
site: String,
path: String,
#[source]
source: std::io::Error,
},
#[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),
}
pub 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<()>;
async fn compose_subgraphs(&self) -> CpResult<()>;
async fn put_compute(
&self,
name: &str,
body: &serde_json::Value,
) -> CpResult<serde_json::Value>;
async fn put_project_tenancy(
&self,
schema: &boatramp_core::tenancy::TenancySchema,
) -> CpResult<()>;
async fn register_graphql_safelist(&self, query: &str) -> CpResult<()>;
async fn declare_database(
&self,
name: &str,
db: &boatramp_core::compute::ApplyDatabase,
) -> CpResult<()>;
}
#[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<()> {
self.deploy_function(name, body)
.await
.map_err(CpError::Client)
}
async fn compose_subgraphs(&self) -> CpResult<()> {
self.compose_subgraphs().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)
}
async fn put_project_tenancy(
&self,
schema: &boatramp_core::tenancy::TenancySchema,
) -> CpResult<()> {
self.put_project_tenancy(schema)
.await
.map_err(CpError::Client)
}
async fn register_graphql_safelist(&self, query: &str) -> CpResult<()> {
self.register_graphql_safelist(query)
.await
.map_err(CpError::Client)
}
async fn declare_database(
&self,
name: &str,
db: &boatramp_core::compute::ApplyDatabase,
) -> CpResult<()> {
self.declare_database(name, db)
.await
.map_err(CpError::Client)
}
}
pub const CURRENT_MANIFEST_VERSION: u32 = 2;
#[derive(Debug, Default, Deserialize, serde::Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct ApplyManifest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub project: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub sites: Vec<ApplySite>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub functions: Vec<ApplyFunction>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub compute: Vec<ApplyCompute>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tenancy: Option<boatramp_core::tenancy::TenancySchema>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub databases: Vec<boatramp_core::compute::ApplyDatabase>,
}
#[derive(Debug, Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub struct ApplySite {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub build: Option<BuildConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub routing: Option<DeployConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub config: Option<SiteConfig>,
}
#[derive(Debug, Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub struct ApplyFunction {
pub name: String,
pub component: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub webhook_secret_env: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub webhook_publish: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub imports: Vec<String>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub env: std::collections::BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub secrets: std::collections::BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub invoke_targets: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limits: Option<HandlerLimits>,
#[serde(
default,
deserialize_with = "boatramp_core::tenancy::de_opt_tenancy",
skip_serializing_if = "Option::is_none"
)]
pub tenancy: Option<boatramp_core::tenancy::Tenancy>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_claims: Option<boatramp_core::config::HandlerGraphqlTokenClaims>,
}
#[derive(Debug, Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub struct ApplyCompute {
pub name: String,
pub spec: boatramp_core::compute::ComputeSpec,
#[serde(default = "boatramp_core::compute::default_replicas")]
pub replicas: u32,
#[serde(default, skip_serializing_if = "placement_is_default")]
pub placement: boatramp_core::compute::PlacementConstraints,
}
fn placement_is_default(p: &boatramp_core::compute::PlacementConstraints) -> bool {
p.regions.is_empty() && p.labels.is_empty()
}
impl ApplyCompute {
fn to_request(&self) -> boatramp_core::compute::PutComputeRequest {
boatramp_core::compute::PutComputeRequest {
spec: self.spec.clone(),
replicas: self.replicas,
placement: self.placement.clone(),
}
}
}
impl ApplyManifest {
pub fn parse(text: &str) -> Result<Self> {
Self::parse_with_format(text, crate::config::ConfigFormat::Ron)
}
pub fn parse_with_format(text: &str, fmt: crate::config::ConfigFormat) -> Result<Self> {
let declared = crate::apply_migrate::peek_version(text, fmt)?;
let manifest = match declared {
None => Self::parse_strict(text, fmt)?,
Some(v) if v == CURRENT_MANIFEST_VERSION => {
Self::parse_strict(text, fmt)?
}
Some(v) if v > CURRENT_MANIFEST_VERSION => {
return Err(Error::VersionTooNew {
declared: v,
current: CURRENT_MANIFEST_VERSION,
});
}
Some(v) => match fmt {
crate::config::ConfigFormat::Ron => {
crate::apply_migrate::migrate_to_current(text, v)?
}
crate::config::ConfigFormat::Json => {
return Err(Error::JsonLegacyVersion { declared: v });
}
},
};
manifest.compile_check_sites()?;
Ok(manifest)
}
fn parse_strict(text: &str, fmt: crate::config::ConfigFormat) -> Result<Self> {
match fmt {
crate::config::ConfigFormat::Ron => crate::config::ron_options()
.from_str(text)
.map_err(|source| Error::StrictParse { source }),
crate::config::ConfigFormat::Json => {
serde_json::from_str(text).map_err(|source| Error::JsonStrictParse { source })
}
}
}
fn compile_check_sites(&self) -> Result<()> {
for site in &self.sites {
if let Some(routing) = &site.routing {
routing.compile_check().map_err(|source| Error::Routing {
site: site.name.clone(),
source,
})?;
}
}
Ok(())
}
pub fn load(path: &Path, override_: Option<crate::config::ConfigFormat>) -> Result<Self> {
let fmt = crate::config::resolve_format(path, override_);
match std::fs::read_to_string(path) {
Ok(text) => Self::parse_with_format(&text, fmt),
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, value_enum)]
format: Option<crate::config::ConfigFormat>,
#[arg(long, env = "BOATRAMP_SERVER")]
server: Option<String>,
#[arg(long)]
dry_run: bool,
#[arg(long)]
build: bool,
}
fn check_manifest_names(project: &str, manifest: &ApplyManifest) -> Result<()> {
use boatramp_core::project::validate_resource_name;
let check = |kind: &'static str, name: &str| -> Result<()> {
validate_resource_name(kind, name).map_err(|err| Error::InvalidName {
kind,
name: name.to_string(),
reason: err.reason,
})
};
check("project", project)?;
for s in &manifest.sites {
check("site", &s.name)?;
}
for f in &manifest.functions {
check("function", &f.name)?;
}
for c in &manifest.compute {
check("compute", &c.name)?;
}
for d in &manifest.databases {
check("database", &d.name)?;
}
Ok(())
}
pub async fn run(args: ApplyArgs, config: &ProjectConfig) -> Result<()> {
let manifest = ApplyManifest::load(&args.file, args.format)?;
let project = manifest
.project
.clone()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| client::resolve_project(config));
check_manifest_names(&project, &manifest)?;
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), \
{} database(s){}",
args.file.display(),
manifest.sites.len(),
manifest.functions.len(),
manifest.compute.len(),
manifest.databases.len(),
if args.dry_run { " (dry-run)" } else { "" },
);
ensure_project(&cp, &project, args.dry_run).await?;
if let Some(schema) = &manifest.tenancy {
reconcile_tenancy(&cp, schema, args.dry_run).await?;
}
apply_databases(&cp, &manifest.databases, args.dry_run).await?;
let manifest_dir = args
.file
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
for site in &manifest.sites {
apply_site(&cp, site, config, &manifest_dir, args.build, args.dry_run).await?;
}
for function in &manifest.functions {
apply_function(&cp, function, args.dry_run).await?;
}
apply_deferred_compose(&cp, !manifest.functions.is_empty(), 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 reconcile_tenancy<C: ControlPlane>(
cp: &C,
schema: &boatramp_core::tenancy::TenancySchema,
dry_run: bool,
) -> Result<()> {
if dry_run {
println!(
" tenancy: would set schema ({} table(s))",
schema.tables.len()
);
return Ok(());
}
cp.put_project_tenancy(schema).await?;
println!(" tenancy: set schema ({} table(s))", schema.tables.len());
Ok(())
}
async fn apply_databases<C: ControlPlane>(
cp: &C,
databases: &[boatramp_core::compute::ApplyDatabase],
dry_run: bool,
) -> Result<()> {
for db in databases {
if dry_run {
println!(
" database `{}`: would declare + provision ({:?}, {:?}, size {:?})",
db.name, db.kind, db.tenant, db.size
);
continue;
}
cp.declare_database(&db.name, db).await?;
println!(" database `{}`: declared + provisioned", db.name);
}
Ok(())
}
async fn apply_site<C: ControlPlane>(
cp: &C,
site: &ApplySite,
config: &ProjectConfig,
manifest_dir: &Path,
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()),
);
let site_allows = site
.config
.as_ref()
.and_then(|c| c.handlers.as_ref())
.map(|h| h.allow_ceiling_exceptions);
if let Some(routing) = &site.routing {
for h in &routing.handlers {
if matches!(
&h.tenancy,
Some(boatramp_core::tenancy::Tenancy::Scoped {
exceed_site_ceiling: true,
..
})
) {
let route = &h.route;
let methods = h.methods.join(",");
match site_allows {
Some(false) => eprintln!(
" ⚠ route {route:?} [{methods}]: declares `exceed_site_ceiling` but \
this apply's site config does NOT set `allow_ceiling_exceptions` — it \
will be REFUSED at activation. Set it, or narrow the route."
),
_ => eprintln!(
" ⚠ route {route:?} [{methods}]: exceeds the site tenancy ceiling \
(authorized via `exceed_site_ceiling`; the site must set \
`allow_ceiling_exceptions`, and an `all` grant also needs the operator \
posture `allow_cross_tenant_db`)."
),
}
}
}
}
apply_safelists(cp, site, manifest_dir, true).await?;
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);
apply_safelists(cp, site, manifest_dir, false).await?;
Ok(())
}
async fn apply_safelists<C: ControlPlane>(
cp: &C,
site: &ApplySite,
manifest_dir: &Path,
dry_run: bool,
) -> Result<()> {
let Some(gql) = site
.config
.as_ref()
.and_then(|c| c.handlers.as_ref())
.and_then(|h| h.graphql.as_ref())
else {
return Ok(());
};
if !gql.safelisted_ops.is_empty() && gql.safelisted_ops_path.is_some() {
return Err(Error::SafelistConflict {
site: site.name.clone(),
});
}
let ops: Vec<String> = if let Some(rel) = &gql.safelisted_ops_path {
let path = manifest_dir.join(rel);
let text = std::fs::read_to_string(&path).map_err(|source| Error::SafelistFile {
site: site.name.clone(),
path: path.display().to_string(),
source,
})?;
parse_ops_file(&text)
} else {
gql.safelisted_ops.clone()
};
if ops.is_empty() {
return Ok(());
}
if dry_run {
println!(
" site `{}`: would register {} safelisted operation(s) (register-only)",
site.name,
ops.len(),
);
return Ok(());
}
for op in &ops {
cp.register_graphql_safelist(op).await?;
}
println!(
" site `{}`: registered {} safelisted operation(s) (register-only)",
site.name,
ops.len(),
);
Ok(())
}
fn parse_ops_file(text: &str) -> Vec<String> {
if let Ok(arr) = serde_json::from_str::<Vec<String>>(text) {
return arr.into_iter().filter(|op| !op.trim().is_empty()).collect();
}
let single = text.trim();
if single.is_empty() {
Vec::new()
} else {
vec![single.to_string()]
}
}
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 {
let mut webhook = serde_json::Map::new();
webhook.insert("secret_env".to_string(), json!(secret_env));
if let Some(topic) = &function.webhook_publish {
webhook.insert("publish".to_string(), json!(topic));
}
cfg.insert("webhook".to_string(), serde_json::Value::Object(webhook));
}
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.secrets.is_empty() {
cfg.insert("secrets".to_string(), json!(function.secrets));
}
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));
}
if let Some(tenancy) = &function.tenancy {
cfg.insert("tenancy".to_string(), json!(tenancy));
}
if let Some(token_claims) = &function.token_claims {
cfg.insert("token_claims".to_string(), json!(token_claims));
}
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_deferred_compose<C: ControlPlane>(
cp: &C,
any_functions: bool,
dry_run: bool,
) -> Result<()> {
if dry_run || !any_functions {
return Ok(());
}
cp.compose_subgraphs().await?;
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(());
}
let body = serde_json::to_value(compute.to_request())?;
cp.put_compute(&compute.name, &body).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<()> {
self.rec(format!("deploy_function {name}"));
Ok(())
}
async fn compose_subgraphs(&self) -> CpResult<()> {
self.rec("compose_subgraphs".to_string());
Ok(())
}
async fn put_compute(
&self,
name: &str,
_body: &serde_json::Value,
) -> CpResult<serde_json::Value> {
self.rec(format!("put_compute {name}"));
Ok(json!({}))
}
async fn put_project_tenancy(
&self,
schema: &boatramp_core::tenancy::TenancySchema,
) -> CpResult<()> {
self.rec(format!("put_project_tenancy {}", schema.tables.len()));
Ok(())
}
async fn register_graphql_safelist(&self, query: &str) -> CpResult<()> {
self.rec(format!("register_graphql_safelist {query}"));
Ok(())
}
async fn declare_database(
&self,
name: &str,
db: &boatramp_core::compute::ApplyDatabase,
) -> CpResult<()> {
self.rec(format!("declare_database {name} kind={:?}", db.kind));
Ok(())
}
}
fn a_function() -> ApplyFunction {
ApplyFunction {
name: "resize".into(),
component: "resize.wasm".into(),
runtime: None,
webhook_secret_env: None,
webhook_publish: None,
imports: vec![],
env: Default::default(),
secrets: Default::default(),
invoke_targets: vec![],
limits: None,
tenancy: None,
token_claims: 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_deferred_compose_promotes_once_after_functions() {
let mock = MockCp::default();
apply_deferred_compose(&mock, true, false).await.unwrap();
assert_eq!(mock.calls(), ["compose_subgraphs"]);
}
#[tokio::test]
async fn apply_deferred_compose_noop_without_functions_or_on_dry_run() {
let mock = MockCp::default();
apply_deferred_compose(&mock, false, false).await.unwrap();
assert!(mock.calls().is_empty(), "no functions ⇒ no compose");
let mock = MockCp::default();
apply_deferred_compose(&mock, true, true).await.unwrap();
assert!(mock.calls().is_empty(), "dry-run ⇒ no compose");
}
fn an_image_spec() -> boatramp_core::compute::ComputeSpec {
boatramp_core::compute::ComputeSpec {
version: boatramp_core::SCHEMA_VERSION,
root: boatramp_core::compute::RootSource::Image("nginx:latest".into()),
kernel: String::new(),
kernel_cmdline: None,
vcpus: 1,
mem_mib: 256,
entrypoint: vec![],
env: Default::default(),
port: 8080,
restart: Default::default(),
startup_grace_secs: boatramp_core::compute::default_startup_grace_secs(),
scale_to_zero: false,
volumes: vec![],
writable_root: false,
cap_add: vec![],
user: None,
isolation: Default::default(),
prefer_backend: None,
bindings: vec![],
}
}
#[tokio::test]
async fn apply_compute_puts_the_spec() {
let mock = MockCp::default();
let compute = ApplyCompute {
name: "api".into(),
spec: an_image_spec(),
replicas: 2,
placement: Default::default(),
};
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: an_image_spec(),
replicas: boatramp_core::compute::default_replicas(),
placement: Default::default(),
};
apply_compute(&mock, &compute, true).await.unwrap();
assert!(mock.calls().is_empty());
}
#[tokio::test]
async fn reconcile_tenancy_puts_the_schema() {
use boatramp_core::tenancy::{TableScope, TenancySchema};
let mock = MockCp::default();
let mut schema = TenancySchema::default();
schema.tables.insert("orders".into(), TableScope::Tenant);
schema.tables.insert(
"tenant".into(),
TableScope::TenantKeyed { key: "id".into() },
);
reconcile_tenancy(&mock, &schema, false).await.unwrap();
assert_eq!(mock.calls(), ["put_project_tenancy 2"]);
}
#[tokio::test]
async fn reconcile_tenancy_dry_run_mutates_nothing() {
let mock = MockCp::default();
reconcile_tenancy(&mock, &Default::default(), true)
.await
.unwrap();
assert!(mock.calls().is_empty());
}
#[test]
fn manifest_parses_a_tenancy_schema() {
use boatramp_core::tenancy::TableScope;
let manifest = ApplyManifest::parse(
r#"(
project: "acme",
tenancy: (
default_tenant_key: "tenant_id",
tables: {
"orders": (kind: tenant),
"tenant": (kind: tenant_keyed, key: "id"),
"countries": (kind: unscoped),
"oauth_state": (kind: unscoped, writable: true),
},
),
)"#,
)
.expect("manifest with tenancy parses");
let schema = manifest.tenancy.expect("tenancy present");
assert_eq!(schema.default_tenant_key, "tenant_id");
assert_eq!(schema.tables.get("orders"), Some(&TableScope::Tenant));
assert_eq!(
schema.tables.get("tenant"),
Some(&TableScope::TenantKeyed { key: "id".into() })
);
assert_eq!(
schema.tables.get("countries"),
Some(&TableScope::Unscoped { writable: false })
);
assert_eq!(
schema.tables.get("oauth_state"),
Some(&TableScope::Unscoped { writable: true })
);
}
#[test]
fn manifest_parses_per_route_unscoped_writes() {
use boatramp_core::tenancy::Tenancy;
let manifest = ApplyManifest::parse(
r#"(
project: "acme",
functions: [
(
name: "oauth-start",
component: "oauth.wasm",
imports: ["sql"],
tenancy: (mode: "scoped", column: "tenant_id",
sources: [(kind: "token", claim: "tid")],
read: "own", write: "own",
unscoped_writes: ["oauth_state"]),
),
],
)"#,
)
.expect("manifest with per-route unscoped_writes parses");
let f = &manifest.functions[0];
let Some(Tenancy::Scoped { .. }) = f.tenancy.as_ref() else {
panic!("expected a scoped tenancy");
};
assert_eq!(
f.tenancy.as_ref().map(Tenancy::unscoped_writes),
Some(&["oauth_state".to_string()][..])
);
}
#[test]
fn manifest_parses_per_function_tenancy() {
use boatramp_core::tenancy::{AccessMode, Tenancy, TenantSource};
let manifest = ApplyManifest::parse(
r#"(
project: "acme",
functions: [
(
name: "identity",
component: "identity.wasm",
imports: ["sql"],
tenancy: (mode: "scoped", column: "tenant_id",
sources: [(kind: "token", claim: "tid")],
read: "all", write: "own"),
token_claims: (issuer: "https://idp.example",
jwks_url: "https://idp.example/jwks.json",
audience: "acme"),
),
(
name: "concept-worker",
component: "worker.wasm",
imports: ["sql"],
tenancy: (mode: "scoped", column: "tenant_id",
sources: [(kind: "signed_context")],
read: "own", write: "own"),
),
(name: "public", component: "public.wasm", tenancy: (mode: "disabled")),
],
)"#,
)
.expect("manifest with per-function tenancy parses");
let identity = &manifest.functions[0];
match identity.tenancy.as_ref().expect("identity tenancy") {
Tenancy::Scoped {
column,
sources,
read,
write,
..
} => {
assert_eq!(column, "tenant_id");
assert_eq!(
sources,
&vec![TenantSource::Token {
claim: "tid".into()
}]
);
assert_eq!(*read, AccessMode::All);
assert_eq!(*write, AccessMode::Own);
}
other => panic!("expected scoped, got {other:?}"),
}
assert_eq!(
identity.token_claims.as_ref().map(|c| c.issuer.as_str()),
Some("https://idp.example")
);
match manifest.functions[1].tenancy.as_ref().unwrap() {
Tenancy::Scoped { sources, .. } => {
assert_eq!(sources, &vec![TenantSource::SignedContext]);
}
other => panic!("expected scoped, got {other:?}"),
}
assert!(matches!(
manifest.functions[2].tenancy.as_ref().unwrap(),
Tenancy::Disabled
));
}
#[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(),
Path::new("."),
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",
// v0.6.0: the compute spec is the typed `ComputeSpec`
// (`root` is the snake_case newtype variant `image(…)`).
spec: ( root: image("nginx:latest"), vcpus: 1, mem_mib: 256, port: 8080 ),
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].replicas, 2);
assert_eq!(
manifest.compute[0].spec.root,
boatramp_core::compute::RootSource::Image("nginx:latest".into())
);
assert_eq!(manifest.compute[0].spec.port, 8080);
}
#[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, None),
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");
}
#[test]
fn function_secrets_round_trip_as_references() {
let manifest = ApplyManifest::parse(
r#"(
functions: [
(
name: "api", component: "api.wasm",
secrets: { "DB_URL": "PROD_DB_URL" },
),
],
)"#,
)
.expect("manifest with function secrets parses");
let f = &manifest.functions[0];
assert_eq!(
f.secrets.get("DB_URL").map(String::as_str),
Some("PROD_DB_URL")
);
}
#[test]
fn typed_compute_spec_round_trips_from_ron() {
let manifest = ApplyManifest::parse(
r#"(
compute: [
(
name: "db",
spec: (
root: image("pgvector/pgvector:pg16"),
vcpus: 2, mem_mib: 1024, port: 5432,
env: { "POSTGRES_PASSWORD": "x" },
),
replicas: 1,
),
],
)"#,
)
.expect("a typed compute spec parses");
let c = &manifest.compute[0];
assert_eq!(
c.spec.root,
boatramp_core::compute::RootSource::Image("pgvector/pgvector:pg16".into())
);
assert_eq!(c.spec.vcpus, 2);
assert_eq!(c.replicas, 1);
let req = c.to_request();
assert_eq!(req.spec, c.spec);
assert_eq!(req.replicas, 1);
}
#[test]
fn raw_json_compute_spec_now_fails_with_the_wrapped_error() {
let err = ApplyManifest::parse(
r#"(
compute: [
( name: "api", spec: { "root": { "image": "nginx" }, "replicas": 2 } ),
],
)"#,
)
.expect_err("a raw-JSON spec is rejected under the current schema");
assert!(matches!(err, Error::StrictParse { .. }), "got {err:?}");
let msg = err.to_string();
assert!(msg.contains("v0.6.0"), "names the version: {msg}");
assert!(
msg.contains("boatramp config migrate"),
"names the migration verb: {msg}"
);
assert!(
msg.contains("version: 1"),
"tells the user to add version: 1: {msg}"
);
}
#[test]
fn version_too_new_is_refused() {
let err = ApplyManifest::parse(&format!("( version: {} )", CURRENT_MANIFEST_VERSION + 1))
.expect_err("a future version is refused");
assert!(matches!(err, Error::VersionTooNew { .. }), "got {err:?}");
}
#[test]
fn explicit_current_version_parses_strictly() {
let manifest = ApplyManifest::parse(&format!(
"( version: {}, project: \"acme\" )",
CURRENT_MANIFEST_VERSION
))
.expect("current-version manifest parses");
assert_eq!(manifest.project.as_deref(), Some("acme"));
}
#[test]
fn v1_raw_json_compute_manifest_migrates_to_typed() {
let text = r#"(
version: 1,
project: "acme",
compute: [
(
name: "api",
spec: {
"spec": { "root": { "image": "nginx:latest" }, "vcpus": 1, "mem_mib": 256, "port": 8080 },
"replicas": 3,
},
),
],
)"#;
let manifest = ApplyManifest::parse(text).expect("v1 manifest migrates");
assert!(manifest.version.is_none());
assert_eq!(manifest.project.as_deref(), Some("acme"));
let c = &manifest.compute[0];
assert_eq!(
c.spec.root,
boatramp_core::compute::RootSource::Image("nginx:latest".into())
);
assert_eq!(c.spec.port, 8080);
assert_eq!(c.replicas, 3);
}
#[test]
fn v1_migration_produces_a_current_serializable_manifest() {
let text = r#"(
version: 1,
compute: [
( name: "api", spec: { "spec": { "root": { "image": "nginx" }, "vcpus": 1, "mem_mib": 128, "port": 80 } } ),
],
)"#;
let manifest = ApplyManifest::parse(text).unwrap();
let rendered = crate::apply_migrate::render_manifest(&manifest).unwrap();
let header = rendered.lines().take(2).collect::<Vec<_>>().join("\n");
assert!(
!header.contains("version"),
"upgraded manifest header omits version: {header}"
);
let reparsed =
ApplyManifest::parse(&rendered).expect("re-rendered manifest parses as current");
assert!(reparsed.version.is_none());
}
#[test]
fn db_shaped_v1_workload_still_migrates() {
let text = r#"(
version: 1,
compute: [
( name: "vec", spec: { "spec": { "root": { "image": "pgvector/pgvector:pg16" }, "vcpus": 2, "mem_mib": 1024, "port": 5432 } } ),
],
)"#;
let manifest = ApplyManifest::parse(text).expect("DB-shaped v1 workload migrates");
assert_eq!(
manifest.compute[0].spec.root,
boatramp_core::compute::RootSource::Image("pgvector/pgvector:pg16".into())
);
}
fn site_with_graphql(
name: &str,
f: impl FnOnce(&mut boatramp_core::config::HandlerGraphqlConfig),
) -> ApplySite {
let mut gql = boatramp_core::config::HandlerGraphqlConfig {
enabled: true,
..Default::default()
};
f(&mut gql);
let site_config = SiteConfig {
handlers: Some(boatramp_core::config::HandlersSiteConfig {
enabled: true,
graphql: Some(gql),
..Default::default()
}),
..Default::default()
};
ApplySite {
name: name.into(),
path: None,
build: None,
routing: None,
config: Some(site_config),
}
}
#[test]
fn enforce_safelist_rename_parses_from_config() {
let manifest = ApplyManifest::parse(
r#"(
sites: [
( name: "gw", config: ( handlers: ( enabled: true, graphql: ( enabled: true, enforce_safelist: true ) ) ) ),
],
)"#,
)
.expect("enforce_safelist parses");
let gql = manifest.sites[0]
.config
.as_ref()
.unwrap()
.handlers
.as_ref()
.unwrap()
.graphql
.as_ref()
.unwrap();
assert!(gql.enforce_safelist);
}
#[tokio::test]
async fn apply_safelists_registers_each_op_register_only() {
let mock = MockCp::default();
let site = site_with_graphql("gw", |g| {
g.safelisted_ops = vec!["query A { a }".into(), "query B { b }".into()];
});
apply_safelists(&mock, &site, Path::new("."), false)
.await
.unwrap();
let calls = mock.calls();
assert_eq!(
calls,
[
"register_graphql_safelist query A { a }",
"register_graphql_safelist query B { b }",
]
);
assert!(
!calls.iter().any(|c| c.to_lowercase().contains("delete")
|| c.to_lowercase().contains("remove")
|| c.to_lowercase().contains("prune")),
"register-only: never deletes/prunes"
);
}
#[tokio::test]
async fn apply_safelists_dry_run_registers_nothing() {
let mock = MockCp::default();
let site = site_with_graphql("gw", |g| {
g.safelisted_ops = vec!["query A { a }".into()];
});
apply_safelists(&mock, &site, Path::new("."), true)
.await
.unwrap();
assert!(mock.calls().is_empty(), "dry-run registers nothing");
}
#[tokio::test]
async fn apply_safelists_mutual_exclusion_is_an_error() {
let mock = MockCp::default();
let site = site_with_graphql("gw", |g| {
g.safelisted_ops = vec!["query A { a }".into()];
g.safelisted_ops_path = Some("ops.json".into());
});
let err = apply_safelists(&mock, &site, Path::new("."), false)
.await
.expect_err("both sources set is refused");
assert!(matches!(err, Error::SafelistConflict { .. }), "got {err:?}");
assert!(mock.calls().is_empty(), "no registration on a conflict");
}
#[tokio::test]
async fn apply_safelists_reads_ops_from_a_file_client_side() {
let dir = std::env::temp_dir().join(format!("boatramp-safelist-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("ops.json"),
br#"["query A { a }", "query B { b }"]"#,
)
.unwrap();
let mock = MockCp::default();
let site = site_with_graphql("gw", |g| {
g.safelisted_ops_path = Some("ops.json".into());
});
let res = apply_safelists(&mock, &site, &dir, false).await;
let _ = std::fs::remove_dir_all(&dir);
res.unwrap();
assert_eq!(
mock.calls(),
[
"register_graphql_safelist query A { a }",
"register_graphql_safelist query B { b }",
]
);
}
#[test]
fn parse_ops_file_handles_array_and_single_forms() {
assert_eq!(
parse_ops_file(r#"["query A { a }", "query B { b }"]"#),
["query A { a }", "query B { b }"]
);
assert_eq!(parse_ops_file("query Single { s }"), ["query Single { s }"]);
assert!(parse_ops_file(" ").is_empty());
}
#[test]
fn databases_block_parses_the_safe_typed_projection() {
let manifest = ApplyManifest::parse(
r#"(
databases: [
( name: "app", kind: postgres, version: 16, size: medium,
tenant: shared, tenant_scope: project, extensions: ["pgcrypto"],
rls_session: true, tenant_guc: "app.tenant_id", pool_max: 8 ),
],
)"#,
)
.expect("a databases: block parses");
assert_eq!(manifest.databases.len(), 1);
let db = &manifest.databases[0];
assert_eq!(db.name, "app");
assert_eq!(db.kind, boatramp_core::compute::ApplyDatabaseKind::Postgres);
assert_eq!(db.size, boatramp_core::compute::ApplyDatabaseSize::Medium);
assert_eq!(
db.tenant,
boatramp_core::compute::ApplyDatabaseTenant::Shared
);
assert_eq!(db.extensions, ["pgcrypto"]);
assert!(db.rls_session);
assert_eq!(db.tenant_guc.as_deref(), Some("app.tenant_id"));
}
#[test]
fn absent_databases_block_still_parses() {
let manifest = ApplyManifest::parse(r#"( sites: [] )"#).unwrap();
assert!(manifest.databases.is_empty());
}
#[test]
fn databases_block_refuses_the_excluded_credential_fields() {
for excluded in [
r#"( name: "x", kind: postgres, password_env: "PW" )"#,
r#"( name: "x", kind: postgres, url_env: "URL" )"#,
r#"( name: "x", kind: postgres, read_url_env: "URL" )"#,
r#"( name: "x", kind: postgres, migration_url_env: "URL" )"#,
r#"( name: "x", kind: postgres, image: "evil/oci:latest" )"#,
r#"( name: "x", kind: postgres, path: "/etc/passwd" )"#,
r#"( name: "x", kind: postgres, compute: "other-tenants-server" )"#,
] {
let text = format!("( databases: [ {excluded} ] )");
assert!(
ApplyManifest::parse(&text).is_err(),
"a manifest smuggling an excluded field must fail to parse: {excluded}"
);
}
}
#[tokio::test]
async fn apply_databases_puts_each_and_never_deprovisions() {
let mock = MockCp::default();
let dbs = vec![
boatramp_core::compute::ApplyDatabase {
name: "app".into(),
kind: boatramp_core::compute::ApplyDatabaseKind::Postgres,
version: None,
extensions: vec![],
size: Default::default(),
tenant: Default::default(),
tenant_scope: Default::default(),
read_only: false,
rls_session: false,
tenant_guc: None,
session_guc: None,
tenant_all_marker: None,
pool_max: None,
connect_timeout_secs: None,
startup_grace_secs: None,
},
boatramp_core::compute::ApplyDatabase {
name: "metrics".into(),
kind: boatramp_core::compute::ApplyDatabaseKind::Mysql,
version: None,
extensions: vec![],
size: Default::default(),
tenant: Default::default(),
tenant_scope: Default::default(),
read_only: false,
rls_session: false,
tenant_guc: None,
session_guc: None,
tenant_all_marker: None,
pool_max: None,
connect_timeout_secs: None,
startup_grace_secs: None,
},
];
apply_databases(&mock, &dbs, false).await.unwrap();
assert_eq!(
mock.calls(),
[
"declare_database app kind=Postgres",
"declare_database metrics kind=Mysql",
],
"each declared DB is PUT once; no deprovision is ever issued"
);
}
#[tokio::test]
async fn apply_databases_dry_run_declares_nothing() {
let mock = MockCp::default();
let dbs = vec![boatramp_core::compute::ApplyDatabase {
name: "app".into(),
kind: boatramp_core::compute::ApplyDatabaseKind::Postgres,
version: None,
extensions: vec![],
size: Default::default(),
tenant: Default::default(),
tenant_scope: Default::default(),
read_only: false,
rls_session: false,
tenant_guc: None,
session_guc: None,
tenant_all_marker: None,
pool_max: None,
connect_timeout_secs: None,
startup_grace_secs: None,
}];
apply_databases(&mock, &dbs, true).await.unwrap();
assert!(mock.calls().is_empty(), "dry-run declares nothing");
}
#[test]
fn config_json_gate_manifest_ron_and_json_are_equivalent() {
let ron = r#"(
project: "team-x",
compute: [
(
name: "db",
spec: (
root: image("pgvector/pgvector:pg16"),
vcpus: 2, mem_mib: 1024, port: 5432,
restart: always,
),
replicas: 1,
),
],
)"#;
let json = r#"{
"project": "team-x",
"compute": [
{
"name": "db",
"spec": {
"root": { "image": "pgvector/pgvector:pg16" },
"vcpus": 2, "mem_mib": 1024, "port": 5432,
"restart": "always"
},
"replicas": 1
}
]
}"#;
let from_ron =
ApplyManifest::parse_with_format(ron, crate::config::ConfigFormat::Ron).unwrap();
let from_json =
ApplyManifest::parse_with_format(json, crate::config::ConfigFormat::Json).unwrap();
assert_eq!(from_json.project.as_deref(), Some("team-x"));
assert_eq!(
from_json.compute[0].spec.root,
boatramp_core::compute::RootSource::Image("pgvector/pgvector:pg16".into())
);
assert!(matches!(
from_json.compute[0].spec.restart,
boatramp_core::compute::RestartPolicy::Always
));
assert_eq!(
from_ron.compute[0].to_request().spec,
from_json.compute[0].to_request().spec
);
assert_eq!(from_ron.project, from_json.project);
}
#[test]
fn config_json_gate_manifest_json_denies_unknown_fields() {
let json = r#"{ "project": "x", "bogus_field": true }"#;
let err = ApplyManifest::parse_with_format(json, crate::config::ConfigFormat::Json)
.expect_err("an unknown JSON field must be rejected (deny_unknown_fields)");
assert!(
matches!(err, Error::JsonStrictParse { .. }),
"unknown-field JSON is a strict-parse failure, got {err:?}"
);
}
#[test]
fn config_json_gate_manifest_json_bad_route_fails_compile_check() {
let json = r#"{
"sites": [
{
"name": "web",
"path": ".",
"routing": { "redirects": [ { "from": "/a/**/b/**", "to": "/x" } ] }
}
]
}"#;
let err = ApplyManifest::parse_with_format(json, crate::config::ConfigFormat::Json)
.expect_err("a bad route in a JSON manifest must fail the compile-check");
assert!(
matches!(err, Error::Routing { .. }),
"bad-route JSON fails the routing compile-check, got {err:?}"
);
}
#[test]
fn config_json_gate_json_old_version_is_current_schema_only() {
let json = r#"{ "version": 1, "project": "x" }"#;
let err = ApplyManifest::parse_with_format(json, crate::config::ConfigFormat::Json)
.expect_err("an old-version JSON manifest is current-schema only");
assert!(
matches!(err, Error::JsonLegacyVersion { declared: 1 }),
"old-version JSON is refused with the clear current-schema-only error, got {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains("current-schema only") && msg.contains("boatramp config migrate"),
"the error names the cure: {msg}"
);
}
#[test]
fn config_json_gate_json_too_new_version_is_version_too_new() {
let json = format!(
r#"{{ "version": {}, "project": "x" }}"#,
CURRENT_MANIFEST_VERSION + 1
);
let err = ApplyManifest::parse_with_format(&json, crate::config::ConfigFormat::Json)
.expect_err("a future-version JSON manifest is refused");
assert!(
matches!(err, Error::VersionTooNew { .. }),
"too-new JSON is VersionTooNew, got {err:?}"
);
}
#[test]
fn config_json_gate_json_absent_and_current_version_parse_ok() {
ApplyManifest::parse_with_format(
r#"{ "project": "x" }"#,
crate::config::ConfigFormat::Json,
)
.expect("a version-less JSON manifest parses (current schema)");
let cur = format!(r#"{{ "version": {CURRENT_MANIFEST_VERSION}, "project": "x" }}"#);
ApplyManifest::parse_with_format(&cur, crate::config::ConfigFormat::Json)
.expect("a current-version JSON manifest parses");
}
#[test]
fn config_json_gate_format_selection_is_load_bearing() {
let doc = r#"{ "project": "only-valid-as-json" }"#;
let as_json =
ApplyManifest::parse_with_format(doc, crate::config::ConfigFormat::Json).unwrap();
assert_eq!(as_json.project.as_deref(), Some("only-valid-as-json"));
assert!(
ApplyManifest::parse_with_format(doc, crate::config::ConfigFormat::Ron).is_err(),
"the same document must FAIL under Ron — else the fmt selection is hollow"
);
}
}