use std::collections::BTreeMap;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use boatramp_core::config::DeployConfig;
use serde::{Deserialize, Serialize};
pub fn ron_options() -> ron::Options {
ron::Options::default().with_default_extension(ron::extensions::Extensions::IMPLICIT_SOME)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
pub enum ConfigFormat {
#[default]
Ron,
Json,
}
pub fn resolve_format(path: &Path, override_: Option<ConfigFormat>) -> ConfigFormat {
if let Some(fmt) = override_ {
return fmt;
}
match path.extension().and_then(|e| e.to_str()) {
Some(ext) if ext.eq_ignore_ascii_case("json") => ConfigFormat::Json,
_ => ConfigFormat::Ron,
}
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("{path}: {source}")]
File {
path: String,
#[source]
source: Box<Self>,
},
#[error("invalid config syntax: {0}")]
Ron(#[from] ron::error::SpannedError),
#[error("invalid JSON config: {0}")]
Json(#[from] serde_json::Error),
#[error("routing: {0}")]
Routing(#[from] boatramp_core::ConfigError),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error("environment variable {var}: {reason}")]
Env {
var: String,
reason: String,
},
#[error("SQL database binding {name:?}: {reason}{cure}")]
InvalidDbName {
name: String,
reason: &'static str,
cure: &'static str,
},
#[error("per-project blob backend {name:?}: {reason}{cure}")]
InvalidBlobBackendProject {
name: String,
reason: &'static str,
cure: &'static str,
},
#[error("inbound TCP listener {name:?}: {reason}{cure}")]
InvalidListener {
name: String,
reason: &'static str,
cure: &'static str,
},
#[error("config resolution: {0}")]
Resolve(String),
}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct ProjectConfig {
pub publish: PublishConfig,
pub build: Option<BuildConfig>,
pub bundle: Option<BundleConfig>,
pub routing: DeployConfig,
}
impl ProjectConfig {
pub fn parse(text: &str) -> Result<Self, ConfigError> {
Self::parse_with_format(text, ConfigFormat::Ron)
}
pub fn parse_with_format(text: &str, fmt: ConfigFormat) -> Result<Self, ConfigError> {
let config: Self = match fmt {
ConfigFormat::Ron => ron_options().from_str(text)?,
ConfigFormat::Json => serde_json::from_str(text)?,
};
config.routing.compile_check()?;
Ok(config)
}
pub fn load(path: &Path, override_: Option<ConfigFormat>) -> Result<Self, ConfigError> {
let fmt = resolve_format(path, override_);
match std::fs::read_to_string(path) {
Ok(contents) => {
Self::parse_with_format(&contents, fmt).map_err(|err| ConfigError::File {
path: path.display().to_string(),
source: Box::new(err),
})
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
Err(err) => Err(err.into()),
}
}
}
#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct ServerConfig {
pub serve: Option<ServeConfig>,
pub handlers: Option<HandlersConfig>,
pub cluster: Option<ClusterConfig>,
pub compute: Option<ComputeConfig>,
pub security: Option<boatramp_core::security::SecurityConfig>,
pub secrets: Option<SecretsConfig>,
}
#[cfg_attr(not(feature = "cluster"), allow(dead_code))]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct SecretsConfig {
pub envelope: String,
pub kek_file: Option<PathBuf>,
pub vault: Option<VaultSecretsConfig>,
}
#[cfg_attr(not(all(feature = "cluster", feature = "acme-dns")), allow(dead_code))]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct VaultSecretsConfig {
pub addr: String,
pub key: String,
#[serde(default = "default_vault_token_env")]
pub token_env: String,
}
fn default_vault_token_env() -> String {
"VAULT_TOKEN".to_string()
}
impl Default for VaultSecretsConfig {
fn default() -> Self {
Self {
addr: String::new(),
key: String::new(),
token_env: default_vault_token_env(),
}
}
}
const RESERVED_MANAGED_DB_COMPUTE_PREFIX: &str = "bramp-db-";
impl ServerConfig {
pub fn parse(text: &str) -> Result<Self, ConfigError> {
Self::parse_with_format(text, ConfigFormat::Ron)
}
pub fn parse_with_format(text: &str, fmt: ConfigFormat) -> Result<Self, ConfigError> {
let config: Self = match fmt {
ConfigFormat::Ron => ron_options().from_str(text)?,
ConfigFormat::Json => serde_json::from_str(text)?,
};
config.validate_sql_db_names()?;
config.validate_blob_backend_projects()?;
config.validate_listeners()?;
Ok(config)
}
pub fn load(path: &Path, override_: Option<ConfigFormat>) -> Result<Self, ConfigError> {
figment_resolve::load_with_figment(
path,
override_,
&EnvSource::Process,
figment::value::Dict::new(),
)
}
pub fn load_with_flags(
path: &Path,
override_: Option<ConfigFormat>,
cli_flags: &BTreeMap<String, String>,
) -> Result<Self, ConfigError> {
let flags = figment_resolve::flag_overlay_dict(cli_flags);
figment_resolve::load_with_figment(path, override_, &EnvSource::Process, flags)
}
pub fn dump_redacted(&self) -> Result<String, ConfigError> {
figment_resolve::dump_redacted(self)
}
#[cfg(test)]
fn apply_env_overrides(&mut self, source: &EnvSource) -> Result<(), ConfigError> {
*self = figment_resolve::resolve_env_over(self, source)?;
Ok(())
}
fn validate_sql_db_names(&self) -> Result<(), ConfigError> {
let Some(databases) = self
.handlers
.as_ref()
.and_then(|h| h.bindings.sql.as_ref())
.map(|s| &s.databases)
else {
return Ok(());
};
for (name, db) in databases {
if let Err(err) = boatramp_core::project::validate_resource_name("database", name) {
let cure = if name.is_empty() {
" — name your default binding `default` (or set \
BOATRAMP_HANDLERS_SQL_DB_DEFAULT_*, which now maps to `default` \
automatically); see the v0.5.0 CHANGELOG"
} else {
boatramp_core::project::INVALID_NAME_CURE
};
return Err(ConfigError::InvalidDbName {
name: err.value,
reason: err.reason,
cure,
});
}
if let Some(compute) = db.compute.as_deref()
&& compute.starts_with(RESERVED_MANAGED_DB_COMPUTE_PREFIX)
{
return Err(ConfigError::InvalidDbName {
name: compute.to_string(),
reason: "the `bramp-db-` compute-workload prefix is reserved for \
boatramp-derived managed-database servers",
cure: " — rename the `compute` workload to not start with `bramp-db-`",
});
}
}
Ok(())
}
fn validate_blob_backend_projects(&self) -> Result<(), ConfigError> {
let Some(serve) = self.serve.as_ref() else {
return Ok(());
};
for name in serve.blob_backends.keys() {
if name == boatramp_core::project::DEFAULT_PROJECT {
return Err(ConfigError::InvalidBlobBackendProject {
name: name.clone(),
reason: "the reserved `default` project always resolves the global [serve] blob \
backend",
cure: " — configure the node-wide backend under [serve] (blobs/s3_*), not \
[serve.blob_backends.default]",
});
}
if let Err(err) = boatramp_core::project::validate_resource_name("project", name) {
return Err(ConfigError::InvalidBlobBackendProject {
name: err.value,
reason: err.reason,
cure: boatramp_core::project::INVALID_NAME_CURE,
});
}
}
Ok(())
}
fn validate_listeners(&self) -> Result<(), ConfigError> {
let Some(serve) = self.serve.as_ref() else {
return Ok(());
};
for (name, l) in &serve.listeners {
let err = |reason: &'static str, cure: &'static str| {
Err(ConfigError::InvalidListener {
name: name.clone(),
reason,
cure,
})
};
if l.bind.is_none() {
return err(
"missing `bind` address",
" — set `bind = \"0.0.0.0:<port>\"`",
);
}
if l.component.trim().is_empty() {
return err("missing `component` (the guest .wasm path)", "");
}
if l.project.trim().is_empty() {
return err("missing `project`", "");
}
if l.site.trim().is_empty() {
return err("missing `site`", "");
}
if l.max_conns == Some(0) {
return err("`max_conns` must be > 0", " — omit it for the default");
}
if l.idle_timeout_ms == Some(0) {
return err(
"`idle_timeout_ms` must be > 0",
" — omit it for the default",
);
}
if l.max_conn_duration_ms == Some(0) {
return err(
"`max_conn_duration_ms` must be > 0",
" — omit it for the default",
);
}
if l.tls.is_some() {
return err(
"per-listener TLS is not implemented in this release (plaintext-only)",
" — terminate TLS in front of boatramp, or remove the `tls` block",
);
}
if !l.egress_allowlist.is_empty() {
return err(
"per-listener outbound `egress_allowlist` is not supported (a tcp-connection-host \
guest imports only wasi:io — it cannot hold wasi:sockets)",
" — remove `egress_allowlist`",
);
}
}
Ok(())
}
}
const COMPUTE_ENV_VARS: &[&str] = &[
"BOATRAMP_COMPUTE_BRIDGE",
"BOATRAMP_COMPUTE_SUBNET",
"BOATRAMP_COMPUTE_VCPUS",
"BOATRAMP_COMPUTE_MEM_MIB",
"BOATRAMP_COMPUTE_REGION",
"BOATRAMP_COMPUTE_SQL_SHIM_URL",
"BOATRAMP_COMPUTE_BLOB_SHIM_URL",
"BOATRAMP_COMPUTE_BLOB_SHIM_MAX_OBJECT_BYTES",
"BOATRAMP_COMPUTE_MANAGED_DB_PRIVILEGE",
"BOATRAMP_COMPUTE_DOCKER_ENDPOINT",
"BOATRAMP_COMPUTE_DOCKER_VOLUME_MODE",
"BOATRAMP_COMPUTE_KERNEL_SIGNING_PUBKEYS",
"BOATRAMP_COMPUTE_KERNEL_ALLOWED_HASHES",
"BOATRAMP_COMPUTE_INTERNAL_DNS",
"BOATRAMP_COMPUTE_DNS_UPSTREAM",
"BOATRAMP_COMPUTE_DNS_DOMAIN",
"BOATRAMP_COMPUTE_PG_CLIENT_IMAGE",
"BOATRAMP_COMPUTE_MYSQL_CLIENT_IMAGE",
];
const SECURITY_ENV_VARS: &[&str] = &[
"BOATRAMP_SECURITY_PROFILE",
"BOATRAMP_SECURITY_ALLOW_UNAUTHENTICATED_PUBLIC_BIND",
"BOATRAMP_SECURITY_MAX_UPLOAD_BYTES",
"BOATRAMP_SECURITY_ALLOW_SITE_UNIX_UPSTREAMS",
"BOATRAMP_SECURITY_ALLOW_SITE_PRIVATE_UPSTREAMS",
"BOATRAMP_SECURITY_ALLOW_GUEST_PRIVATE_EGRESS",
"BOATRAMP_SECURITY_ALLOW_GUEST_SELF_EGRESS",
"BOATRAMP_SECURITY_ALLOW_GUEST_EGRESS_EXTRA_CA",
"BOATRAMP_SECURITY_ALLOW_GUEST_TCP_EGRESS",
"BOATRAMP_SECURITY_ALLOW_GUEST_TCP_LISTENER",
"BOATRAMP_SECURITY_MAX_HANDLER_BLOB_BYTES",
"BOATRAMP_SECURITY_MAX_COMPONENT_BYTES",
"BOATRAMP_SECURITY_OIDC_REQUIRE_AUDIENCE",
"BOATRAMP_SECURITY_DOMAIN_VERIFY_ALLOW_PRIVATE",
"BOATRAMP_SECURITY_DOMAIN_VERIFY_SELF_SERVE",
"BOATRAMP_SECURITY_ALLOW_SHARED_KERNEL_COMPUTE",
"BOATRAMP_SECURITY_ALLOW_COMPUTE_EXEC",
"BOATRAMP_SECURITY_RATELIMIT_FAIL_OPEN",
"BOATRAMP_SECURITY_ALLOW_IMPLICIT_ROUTING",
"BOATRAMP_SECURITY_REQUIRE_POP",
"BOATRAMP_SECURITY_REQUIRE_DOMAIN_VERIFICATION",
"BOATRAMP_SECURITY_ALLOW_ENV_SECRET_REFS",
"BOATRAMP_SECURITY_REQUIRE_TENANCY_DECLARATION",
"BOATRAMP_SECURITY_ALLOW_CROSS_TENANT_DB",
"BOATRAMP_SECURITY_ALLOW_GUEST_MINT_CAPABILITY",
"BOATRAMP_SECURITY_MAX_GUEST_CAPABILITY_TTL_SECS",
"BOATRAMP_SECURITY_ALLOW_GUEST_EMAIL",
"BOATRAMP_SECURITY_ALLOW_GUEST_ADMIN_DOMAINS",
"BOATRAMP_SECURITY_ALLOW_GUEST_ADMIN_EMAIL",
"BOATRAMP_SECURITY_ALLOW_GUEST_ADMIN_SITE",
"BOATRAMP_SECURITY_ALLOW_GUEST_ADMIN_SECRETS",
];
const SQL_ENV_VARS: &[&str] = &[
"BOATRAMP_HANDLERS_SQL_DIR",
"BOATRAMP_HANDLERS_SQL_URL",
"BOATRAMP_HANDLERS_SQL_ADMIN_URL",
"BOATRAMP_HANDLERS_SQL_REPLICA_URL",
"BOATRAMP_HANDLERS_SQL_TOKEN_ENV",
"BOATRAMP_HANDLERS_SQL_ADMIN_TOKEN_ENV",
"BOATRAMP_HANDLERS_SQL_PREVIEW_MODE",
"BOATRAMP_HANDLERS_SQL_PREVIEW_INIT",
"BOATRAMP_HANDLERS_SQL_DEPROVISION_GRACE_SECS",
"BOATRAMP_HANDLERS_SQL_MIGRATE_TRUSTED_EXTENSIONS",
"BOATRAMP_HANDLERS_SQL_MAX_DECLARED_DATABASES",
"BOATRAMP_HANDLERS_SQL_MAX_DECLARED_VOLUME_MIB",
];
const JWKS_ENV_VARS: &[&str] = &[
"BOATRAMP_HANDLERS_JWKS_REFRESH_SECS",
"BOATRAMP_HANDLERS_JWKS_HARD_TTL_SECS",
"BOATRAMP_HANDLERS_JWKS_FOREIGN_REFRESH_SECS",
"BOATRAMP_HANDLERS_JWKS_FOREIGN_HARD_TTL_SECS",
"BOATRAMP_HANDLERS_JWKS_PREWARM",
];
const HANDLER_CONCURRENCY_ENV_VARS: &[&str] = &[
"BOATRAMP_HANDLERS_SERVE_CONCURRENCY",
"BOATRAMP_HANDLERS_SYNC_MAX_CONCURRENCY",
"BOATRAMP_HANDLERS_ASYNC_MAX_CONCURRENCY",
];
const HANDLER_BLOB_HEDGE_ENV_VARS: &[&str] = &[
"BOATRAMP_HANDLERS_BLOB_HEDGE_DELAY_MS",
"BOATRAMP_HANDLERS_BLOB_HEDGE_MAX_INFLIGHT",
];
const S3_INGRESS_CLOUD_ENV_VARS: &[&str] = &[
"BOATRAMP_S3_INGRESS_CLOUD_AWS_ROLE_ARN",
"BOATRAMP_S3_INGRESS_CLOUD_AWS_USE_FEDERATION_TOKEN",
"BOATRAMP_S3_INGRESS_CLOUD_AWS_PRESIGNED_ONLY",
"BOATRAMP_S3_INGRESS_CLOUD_GCS_CLIENT_EMAIL",
"BOATRAMP_S3_INGRESS_CLOUD_AZURE_ACCOUNT",
"BOATRAMP_S3_INGRESS_CLOUD_AZURE_SERVICE_URL",
"BOATRAMP_S3_INGRESS_CLOUD_AZURE_HNS",
];
const SQL_DB_ENV_PREFIX: &str = "BOATRAMP_HANDLERS_SQL_DB_";
const SQL_DB_FIELD_SUFFIXES: &[&str] = &[
"_STARTUP_GRACE_SECS",
"_CONNECT_TIMEOUT_SECS",
"_SESSION_GUC",
"_TENANT_ALL_MARKER",
"_TENANT_GUC",
"_VOLUME_SIZE_MIB",
"_READ_URL_ENV",
"_PASSWORD_ENV",
"_ALLOW_PREVIEW",
"_URL_ENV",
"_DATABASE",
"_READ_ONLY",
"_POOL_MAX",
"_RLS_SESSION",
"_TENANT_SCOPE",
"_COMPUTE",
"_TENANT",
"_IMAGE",
"_KIND",
"_USER",
];
const BLOB_BACKEND_ENV_PREFIX: &str = "BOATRAMP_SERVE_BLOB_BACKEND_";
const BLOB_BACKEND_FIELD_SUFFIXES: &[&str] = &[
"_S3_SECRET_ACCESS_KEY",
"_S3_ACCESS_KEY_ID",
"_S3_PATH_STYLE",
"_S3_ENDPOINT",
"_S3_BUCKET",
"_S3_REGION",
"_KIND",
];
const LISTENER_ENV_PREFIX: &str = "BOATRAMP_SERVE_LISTENER_";
const LISTENER_FIELD_SUFFIXES: &[&str] = &[
"_MAX_CONN_DURATION_MS",
"_IDLE_TIMEOUT_MS",
"_MAX_CONNS",
"_COMPONENT",
"_IMPORTS",
"_PROJECT",
"_BIND",
"_SITE",
];
const SECRETS_ENV_VARS: &[&str] = &[
"BOATRAMP_SECRETS_ENVELOPE",
"BOATRAMP_SECRETS_KEK_FILE",
"BOATRAMP_SECRETS_VAULT_ADDR",
"BOATRAMP_SECRETS_VAULT_KEY",
"BOATRAMP_SECRETS_VAULT_TOKEN_ENV",
];
const SECRETS_VAULT_ENV_VARS: &[&str] = &[
"BOATRAMP_SECRETS_VAULT_ADDR",
"BOATRAMP_SECRETS_VAULT_KEY",
"BOATRAMP_SECRETS_VAULT_TOKEN_ENV",
];
const CLUSTER_ENV_VARS: &[&str] = &[
"BOATRAMP_CLUSTER_LISTEN",
"BOATRAMP_CLUSTER_ROOT_PUBKEYS",
"BOATRAMP_CLUSTER_SEEDS",
"BOATRAMP_CLUSTER_JOIN_TOKEN",
"BOATRAMP_CLUSTER_STORE_DIR",
"BOATRAMP_CLUSTER_MESH_KEY_FILE",
"BOATRAMP_CLUSTER_MESH_KEY_ROTATION",
"BOATRAMP_CLUSTER_MESH_JOIN_TOKEN_TTL",
"BOATRAMP_CLUSTER_MESH_GATE_CLIENT_WRITES",
];
pub(crate) enum EnvSource {
Process,
Map(BTreeMap<String, String>),
}
impl EnvSource {
fn get(&self, var: &str) -> Option<String> {
let raw = match self {
Self::Process => std::env::var(var).ok(),
Self::Map(m) => m.get(var).cloned(),
};
raw.filter(|v| !v.is_empty())
}
fn any(&self, vars: &[&str]) -> bool {
vars.iter().any(|v| self.get(v).is_some())
}
fn names(&self) -> Box<dyn Iterator<Item = String> + '_> {
match self {
Self::Process => Box::new(std::env::vars().map(|(k, _)| k)),
Self::Map(m) => Box::new(m.keys().cloned()),
}
}
fn sql_database_names(&self) -> Vec<String> {
let mut names: Vec<String> = self
.names()
.filter(|n| n.starts_with(SQL_DB_ENV_PREFIX) && self.get(n).is_some())
.filter_map(|n| {
let rest = n.strip_prefix(SQL_DB_ENV_PREFIX)?;
SQL_DB_FIELD_SUFFIXES
.iter()
.find_map(|suffix| rest.strip_suffix(suffix))
.filter(|name| !name.is_empty())
.map(str::to_string)
})
.collect();
names.sort();
names.dedup();
names
}
fn blob_backend_projects(&self) -> Vec<String> {
let mut names: Vec<String> = self
.names()
.filter(|n| n.starts_with(BLOB_BACKEND_ENV_PREFIX) && self.get(n).is_some())
.filter_map(|n| {
let rest = n.strip_prefix(BLOB_BACKEND_ENV_PREFIX)?;
BLOB_BACKEND_FIELD_SUFFIXES
.iter()
.find_map(|suffix| rest.strip_suffix(suffix))
.filter(|name| !name.is_empty())
.map(str::to_string)
})
.collect();
names.sort();
names.dedup();
names
}
fn listener_names(&self) -> Vec<String> {
let mut names: Vec<String> = self
.names()
.filter(|n| n.starts_with(LISTENER_ENV_PREFIX) && self.get(n).is_some())
.filter_map(|n| {
let rest = n.strip_prefix(LISTENER_ENV_PREFIX)?;
LISTENER_FIELD_SUFFIXES
.iter()
.find_map(|suffix| rest.strip_suffix(suffix))
.filter(|name| !name.is_empty())
.map(str::to_string)
})
.collect();
names.sort();
names.dedup();
names
}
fn parse_list(&self, var: &str) -> Option<Vec<String>> {
self.get(var).map(|raw| {
raw.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect()
})
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ManagedDbPrivilege {
#[default]
Rootless,
Caps,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ComputeConfig {
pub bridge: String,
pub subnet: String,
pub vcpus: u32,
pub mem_mib: u32,
pub kernel_signing_pubkeys: Vec<String>,
pub kernel_allowed_hashes: Vec<String>,
pub region: Option<String>,
pub docker_endpoint: boatramp_docker::DockerEndpoint,
pub docker_volume_mode: boatramp_docker::DockerVolumeMode,
#[serde(default)]
pub docker_ephemeral_timeout_secs: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(not(feature = "handlers"), allow(dead_code))]
pub sql_shim_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(not(feature = "handlers"), allow(dead_code))]
pub blob_shim_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(not(feature = "handlers"), allow(dead_code))]
pub blob_shim_max_object_bytes: Option<u64>,
#[serde(default)]
#[cfg_attr(not(feature = "handlers"), allow(dead_code))]
pub managed_db_privilege: ManagedDbPrivilege,
#[serde(default = "default_internal_dns")]
pub internal_dns: bool,
#[serde(default = "default_dns_upstream")]
pub dns_upstream: String,
#[serde(default = "default_dns_domain")]
pub dns_domain: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub client_image_rootfs: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub pg_client_image: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub mysql_client_image: Option<String>,
}
fn default_internal_dns() -> bool {
true
}
fn default_dns_upstream() -> String {
"1.1.1.1:53".to_string()
}
fn default_dns_domain() -> String {
boatramp_container::dns::DEFAULT_INTERNAL_DOMAIN.to_string()
}
pub const BOATRAMP_KERNEL_SIGNING_PUBKEY: &str =
"es256:02c4e4af2e9cba6ba6745c513f193622e6674a8b2d0187ebea5612f5b46a7eade4";
fn default_allowed_kernel_hashes() -> Vec<String> {
#[cfg(target_arch = "x86_64")]
{
vec![
"cf1e590a9e642be3667131ca35fbf390378a457d8908169d2a169608e299d974".to_string(),
"d0dc2098ab2a2a3c1bc72ab61dc85d9e464d798d7e55b6b80525db5ca2f00c5a".to_string(),
]
}
#[cfg(target_arch = "aarch64")]
{
vec![
"d785a48d754e65a4630443301f1fb84cb69cf882336d3cf37055e437b3d8e21f".to_string(),
]
}
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
{
Vec::new()
}
}
impl Default for ComputeConfig {
fn default() -> Self {
Self {
bridge: "br-boatramp".to_string(),
subnet: "10.0.0.0/24".to_string(),
vcpus: 0,
mem_mib: 0,
kernel_signing_pubkeys: vec![BOATRAMP_KERNEL_SIGNING_PUBKEY.to_string()],
kernel_allowed_hashes: default_allowed_kernel_hashes(),
region: None,
docker_endpoint: boatramp_docker::DockerEndpoint::default(),
docker_volume_mode: boatramp_docker::DockerVolumeMode::default(),
docker_ephemeral_timeout_secs: 0,
sql_shim_url: None,
blob_shim_url: None,
blob_shim_max_object_bytes: None,
managed_db_privilege: ManagedDbPrivilege::default(),
internal_dns: default_internal_dns(),
dns_upstream: default_dns_upstream(),
dns_domain: default_dns_domain(),
client_image_rootfs: BTreeMap::new(),
pg_client_image: None,
mysql_client_image: None,
}
}
}
#[cfg_attr(not(feature = "cluster"), allow(dead_code))]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClusterConfig {
pub listen: SocketAddr,
#[serde(default)]
pub root_pubkeys: Vec<String>,
#[serde(default)]
pub seeds: Vec<String>,
#[serde(default)]
pub join_token: Option<String>,
#[serde(default)]
pub store_dir: Option<PathBuf>,
#[serde(default)]
pub mesh: Option<MeshConfig>,
}
#[cfg_attr(not(feature = "cluster"), allow(dead_code))]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct MeshConfig {
pub key_file: Option<PathBuf>,
pub key_rotation: Option<String>,
pub join_token_ttl: Option<String>,
pub gate_client_writes: Option<bool>,
}
#[cfg_attr(not(feature = "handlers"), allow(dead_code))]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct HandlersConfig {
pub bindings: BindingsConfig,
pub pooling: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub instance_cache_size: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blob_marker_cache_entries: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blob_hedge_delay_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blob_hedge_max_inflight: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub serve_concurrency: Option<usize>,
pub sync_max_timeout_ms: Option<u64>,
pub sync_max_concurrency: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server_timing: Option<bool>,
pub async_max_timeout_ms: Option<u64>,
pub async_max_concurrency: Option<usize>,
pub async_max_fuel: Option<u64>,
pub async_max_memory_mb: Option<usize>,
pub sync_max_memory_mb: Option<usize>,
pub streaming_max_memory_mb: Option<usize>,
pub messaging_max_unflushed_msgs: Option<usize>,
pub messaging_safetynet_interval_ms: Option<u64>,
pub messaging_readyset_rebuild_interval_ms: Option<u64>,
pub streaming_max_timeout_ms: Option<u64>,
pub streaming_max_concurrency: Option<usize>,
pub streaming_max_fuel: Option<u64>,
pub outbound_timeout_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub jwks_refresh_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub jwks_hard_ttl_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub jwks_foreign_refresh_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub jwks_foreign_hard_ttl_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub jwks_prewarm: Option<bool>,
}
#[cfg_attr(not(feature = "handlers"), allow(dead_code))]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct BindingsConfig {
pub sql: Option<SqlBindingConfig>,
}
#[cfg_attr(not(feature = "handlers"), allow(dead_code))]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct SqlBindingConfig {
pub dir: Option<PathBuf>,
pub url: Option<String>,
pub admin_url: Option<String>,
pub replica_url: Option<String>,
pub token_env: Option<String>,
pub admin_token_env: Option<String>,
pub preview_mode: Option<String>,
pub preview_init: Option<PathBuf>,
pub databases: BTreeMap<String, ExternalDatabaseConfig>,
pub deprovision_grace_secs: Option<u64>,
pub migrate_trusted_extensions: Option<Vec<String>>,
pub max_declared_databases: Option<usize>,
pub max_declared_volume_mib: Option<u64>,
}
#[cfg_attr(not(feature = "handlers"), allow(dead_code))]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct ExternalDatabaseConfig {
pub kind: String,
pub url_env: String,
pub read_url_env: Option<String>,
pub migration_url_env: Option<String>,
pub compute: Option<String>,
pub database: Option<String>,
pub user: Option<String>,
pub password_env: Option<String>,
pub pool_max: Option<u32>,
pub read_only: bool,
pub allow_preview: bool,
pub connect_timeout_secs: Option<u64>,
pub image: Option<String>,
pub volume_size_mib: Option<u32>,
pub startup_grace_secs: Option<u32>,
pub tenant: TenantIsolation,
pub tenant_scope: TenantScope,
pub rls_session: bool,
#[serde(default)]
pub tenant_guc: Option<String>,
#[serde(default)]
pub session_guc: Option<String>,
#[serde(default)]
pub tenant_all_marker: Option<String>,
#[serde(default)]
pub path: Option<PathBuf>,
}
#[cfg_attr(not(feature = "handlers"), allow(dead_code))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TenantIsolation {
#[default]
Single,
Shared,
}
#[cfg_attr(not(feature = "handlers"), allow(dead_code))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TenantScope {
#[default]
Project,
Site,
}
impl ExternalDatabaseConfig {
#[cfg_attr(not(feature = "handlers"), allow(dead_code))]
pub fn validate(&self, name: &str) -> Result<(), String> {
let has_url = !self.url_env.is_empty();
let has_compute = self.compute.as_deref().is_some_and(|c| !c.is_empty());
let has_path = self
.path
.as_deref()
.is_some_and(|p| !p.as_os_str().is_empty());
let is_libsql = matches!(
self.kind.trim().to_ascii_lowercase().as_str(),
"libsql" | "sqlite" | "sqlite3"
);
if is_libsql {
if has_compute {
return Err(format!(
"sql database {name:?}: a `libsql` database is the embedded engine — set `path` \
(single-node file) or `url_env` (remote sqld), not `compute`"
));
}
return match (has_path, has_url) {
(true, true) => Err(format!(
"sql database {name:?}: a `libsql` database sets exactly one of `path` \
(single-node file) or `url_env` (remote sqld), not both"
)),
(false, false) => Err(format!(
"sql database {name:?}: a `libsql` database needs a source — set `path` \
(single-node file) or `url_env` (remote sqld)"
)),
_ => Ok(()),
};
}
if has_path {
return Err(format!(
"sql database {name:?}: `path` is only valid for a `libsql` database (the embedded \
engine); a {} binding uses `url_env` or `compute`",
self.kind
));
}
match (has_url, has_compute) {
(true, true) => Err(format!(
"sql database {name:?}: set exactly one of `url_env` or `compute`, not both"
)),
(false, false) => Err(format!(
"sql database {name:?}: needs a source — set `url_env` (bring-your-own) or \
`compute` (a database boatramp runs)"
)),
(false, true) => {
for (field, val) in [("database", &self.database), ("user", &self.user)] {
if val.as_deref().is_none_or(str::is_empty) {
return Err(format!(
"sql database {name:?}: a `compute`-backed database requires `{field}`"
));
}
}
Ok(())
}
(true, false) => Ok(()),
}
}
#[cfg_attr(not(feature = "handlers"), allow(dead_code))]
pub fn is_managed_credential(&self) -> bool {
self.compute.as_deref().is_some_and(|c| !c.is_empty())
&& self.password_env.as_deref().is_none_or(str::is_empty)
}
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
pub enum SignerAlg {
#[default]
Es256,
Ed25519,
}
impl SignerAlg {
fn to_token_alg(self) -> boatramp_core::cose::TokenAlg {
match self {
Self::Es256 => boatramp_core::cose::TokenAlg::Es256,
Self::Ed25519 => boatramp_core::cose::TokenAlg::Ed25519,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum AuthSignerConfig {
Local {
private_key: String,
},
Vault {
address: String,
key: String,
token_env: String,
#[serde(default)]
alg: SignerAlg,
},
AwsKms {
key_id: String,
#[serde(default)]
region: Option<String>,
},
GcpKms {
key_version: String,
access_token_env: String,
},
AzureKv {
vault_url: String,
key: String,
key_version: String,
access_token_env: String,
},
Pkcs11 {
module: String,
token_label: String,
key_label: String,
pin_env: String,
#[serde(default)]
alg: SignerAlg,
},
}
impl AuthSignerConfig {
pub fn to_signer_config(&self) -> boatramp_server::signer::SignerConfig {
use boatramp_server::signer::SignerConfig;
match self {
Self::Local { private_key } => SignerConfig::Local {
private_key: private_key.clone(),
},
Self::Vault {
address,
key,
token_env,
alg,
} => SignerConfig::Vault {
address: address.clone(),
key: key.clone(),
token_env: token_env.clone(),
alg: alg.to_token_alg(),
},
Self::AwsKms { key_id, region } => SignerConfig::AwsKms {
key_id: key_id.clone(),
region: region.clone(),
},
Self::GcpKms {
key_version,
access_token_env,
} => SignerConfig::GcpKms {
key_version: key_version.clone(),
access_token_env: access_token_env.clone(),
},
Self::AzureKv {
vault_url,
key,
key_version,
access_token_env,
} => SignerConfig::AzureKv {
vault_url: vault_url.clone(),
key: key.clone(),
key_version: key_version.clone(),
access_token_env: access_token_env.clone(),
},
Self::Pkcs11 {
module,
token_label,
key_label,
pin_env,
alg,
} => SignerConfig::Pkcs11 {
module: module.clone(),
token_label: token_label.clone(),
key_label: key_label.clone(),
pin_env: pin_env.clone(),
alg: alg.to_token_alg(),
},
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct ServeConfig {
pub addr: Option<SocketAddr>,
pub data_dir: Option<PathBuf>,
pub blobs: Option<crate::backends::BlobBackend>,
pub s3_bucket: Option<String>,
pub s3_endpoint: Option<String>,
pub s3_region: Option<String>,
pub s3_path_style: bool,
pub gcs_bucket: Option<String>,
pub gcs_endpoint: Option<String>,
pub gcs_anonymous: bool,
pub azure_account: Option<String>,
pub azure_container: Option<String>,
pub azure_access_key: Option<String>,
pub azure_emulator: bool,
pub blob_fallback: Option<BlobFallbackConfig>,
pub blob_backends: BTreeMap<String, BlobBackendConfig>,
pub auth_root_private_key: Option<String>,
pub auth_root_public_key: Option<String>,
pub bootstrap_secret: Option<String>,
pub signer: Option<AuthSignerConfig>,
pub max_upload_bytes: Option<u64>,
pub upload_idle_timeout_secs: Option<u64>,
pub max_concurrent_uploads: Option<usize>,
#[cfg_attr(not(feature = "tls"), allow(dead_code))]
pub http_redirect_addr: Option<SocketAddr>,
pub default_site: Option<String>,
pub pop_origin: Option<String>,
pub protect_previews: bool,
pub cluster_rate_limit: bool,
pub shared_cache_coherence: bool,
pub blob_notify_tier: Option<boatramp_core::blob_notify::ProvisionTier>,
pub blob_notify_account_id: Option<String>,
pub console: Option<ConsoleConfig>,
pub s3_ingress_addr: Option<SocketAddr>,
pub s3_ingress_secret_file: Option<PathBuf>,
pub s3_ingress_public_url: Option<String>,
pub s3_ingress_mint_max_ttl_secs: Option<u64>,
pub s3_ingress_mint_max_bytes: Option<u64>,
pub s3_ingress_cloud: Option<S3IngressCloud>,
pub s3_credential: Option<S3CredentialConfig>,
pub kv: Option<KvConfig>,
#[serde(default)]
pub listeners: BTreeMap<String, ListenerConfig>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ListenerConfig {
pub bind: Option<SocketAddr>,
pub component: String,
pub project: String,
pub site: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub imports: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub env: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub egress_allowlist: Vec<String>,
#[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 tls: Option<ListenerTlsConfig>,
pub max_conns: Option<usize>,
pub idle_timeout_ms: Option<u64>,
pub max_conn_duration_ms: Option<u64>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ListenerTlsConfig {
pub cert_file: Option<PathBuf>,
pub key_file: Option<PathBuf>,
}
pub const DEFAULT_LISTENER_MAX_CONNS: usize = 256;
pub const DEFAULT_LISTENER_IDLE_TIMEOUT_MS: u64 = 60_000;
pub const DEFAULT_LISTENER_MAX_CONN_DURATION_MS: u64 = 3_600_000;
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct S3CredentialConfig {
pub access_key_id: String,
pub secret_access_key: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BlobBackendConfig {
pub blobs: Option<crate::backends::BlobBackend>,
pub s3_bucket: Option<String>,
pub s3_endpoint: Option<String>,
pub s3_region: Option<String>,
#[serde(default)]
pub s3_path_style: bool,
pub s3_credential: Option<S3CredentialConfig>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BlobFallbackConfig {
pub blobs: Option<crate::backends::BlobBackend>,
pub s3_bucket: Option<String>,
pub s3_endpoint: Option<String>,
pub s3_region: Option<String>,
#[serde(default)]
pub s3_path_style: bool,
pub s3_credential: Option<S3CredentialConfig>,
pub gcs_bucket: Option<String>,
pub gcs_endpoint: Option<String>,
#[serde(default)]
pub gcs_anonymous: bool,
pub azure_account: Option<String>,
pub azure_container: Option<String>,
pub azure_access_key: Option<String>,
#[serde(default)]
pub azure_emulator: bool,
pub secondary_timeout_secs: Option<u64>,
}
pub const DEFAULT_BLOB_FALLBACK_TIMEOUT_SECS: u64 = 5;
pub const DEFAULT_KV_CHECKPOINT_INTERVAL_SECS: u64 = 10;
pub const DEFAULT_KV_CLOSE_DEADLINE_SECS: u64 = 20;
pub const DEFAULT_KV_GC_INTERVAL_SECS: u64 = 900;
pub const DEFAULT_KV_GC_MIN_AGE_SECS: u64 = 3600;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct KvConfig {
pub slatedb: Option<SlatedbKvConfig>,
pub sql: Option<SqlKvConfig>,
}
impl KvConfig {
pub fn checkpoint_interval(&self) -> Option<std::time::Duration> {
match self.slatedb.as_ref().and_then(|s| s.checkpoint_interval) {
Some(0) => None,
Some(secs) => Some(std::time::Duration::from_secs(secs)),
None => Some(std::time::Duration::from_secs(
DEFAULT_KV_CHECKPOINT_INTERVAL_SECS,
)),
}
}
pub fn close_deadline(&self) -> std::time::Duration {
match self.slatedb.as_ref().and_then(|s| s.close_deadline) {
Some(secs) if secs > 0 => std::time::Duration::from_secs(secs),
_ => std::time::Duration::from_secs(DEFAULT_KV_CLOSE_DEADLINE_SECS),
}
}
pub fn gc_interval(&self) -> Option<std::time::Duration> {
match self.slatedb.as_ref().and_then(|s| s.gc_interval) {
Some(0) => None,
Some(secs) => Some(std::time::Duration::from_secs(secs)),
None => Some(std::time::Duration::from_secs(DEFAULT_KV_GC_INTERVAL_SECS)),
}
}
pub fn gc_min_age(&self) -> std::time::Duration {
match self.slatedb.as_ref().and_then(|s| s.gc_min_age) {
Some(secs) if secs > 0 => std::time::Duration::from_secs(secs),
_ => std::time::Duration::from_secs(DEFAULT_KV_GC_MIN_AGE_SECS),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct SlatedbKvConfig {
pub checkpoint_interval: Option<u64>,
pub close_deadline: Option<u64>,
pub gc_interval: Option<u64>,
pub gc_min_age: Option<u64>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct SqlKvConfig {
pub kind: String,
pub url_env: Option<String>,
pub path: Option<String>,
pub pool_max: Option<u32>,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct S3IngressCloud {
pub aws_role_arn: Option<String>,
#[serde(default)]
pub aws_use_federation_token: bool,
#[serde(default)]
pub aws_presigned_only: bool,
pub gcs_client_email: Option<String>,
pub azure_account: Option<String>,
pub azure_service_url: Option<String>,
#[serde(default)]
pub azure_hns: bool,
}
pub const DEFAULT_S3_INGRESS_MINT_MAX_TTL_SECS: u64 = 3600;
#[cfg_attr(not(feature = "console"), allow(dead_code))]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ConsoleConfig {
pub enabled: bool,
pub host: Option<String>,
pub path: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct PublishConfig {
pub server: Option<String>,
pub site: Option<String>,
pub token: Option<String>,
pub project: Option<String>,
}
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
pub struct BuildConfig {
pub command: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct BundleConfig {
#[serde(default = "default_bundle_outdir")]
pub outdir: String,
pub js: Vec<String>,
pub css: Vec<String>,
#[serde(default = "default_true")]
pub minify: bool,
}
fn default_bundle_outdir() -> String {
"dist".to_string()
}
fn default_true() -> bool {
true
}
#[cfg(test)]
mod tests {
use super::*;
fn project(text: &str) -> ProjectConfig {
ron_options().from_str(text).unwrap()
}
fn server(text: &str) -> ServerConfig {
ron_options().from_str(text).unwrap()
}
fn env(pairs: &[(&str, &str)]) -> EnvSource {
EnvSource::Map(
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
)
}
#[test]
fn env_overrides_configure_all_three_sections_with_no_file() {
let mut cfg = ServerConfig::default();
assert!(cfg.compute.is_none() && cfg.security.is_none() && cfg.handlers.is_none());
cfg.apply_env_overrides(&env(&[
("BOATRAMP_COMPUTE_VCPUS", "8"),
("BOATRAMP_COMPUTE_MEM_MIB", "4096"),
("BOATRAMP_COMPUTE_REGION", "eu-central"),
("BOATRAMP_SECURITY_PROFILE", "single-tenant"),
("BOATRAMP_SECURITY_ALLOW_SITE_PRIVATE_UPSTREAMS", "true"),
("BOATRAMP_SECURITY_MAX_UPLOAD_BYTES", "1048576"),
("BOATRAMP_HANDLERS_SQL_URL", "http://sqld:8080"),
("BOATRAMP_HANDLERS_SQL_ADMIN_URL", "http://sqld:9090"),
]))
.expect("valid env overrides apply");
let compute = cfg.compute.expect("compute materialised from env");
assert_eq!(compute.vcpus, 8);
assert_eq!(compute.mem_mib, 4096);
assert_eq!(compute.region.as_deref(), Some("eu-central"));
assert_eq!(compute.bridge, "br-boatramp");
let security = cfg.security.expect("security materialised from env");
assert_eq!(security.profile.as_deref(), Some("single-tenant"));
let posture = security.resolve().expect("resolves");
assert!(posture.allow_site_private_upstreams);
assert_eq!(posture.max_upload_bytes, 1_048_576);
let sql = cfg
.handlers
.expect("handlers materialised from env")
.bindings
.sql
.expect("sql binding materialised from env");
assert_eq!(sql.url.as_deref(), Some("http://sqld:8080"));
assert_eq!(sql.admin_url.as_deref(), Some("http://sqld:9090"));
}
#[test]
fn internal_dns_defaults_on_with_the_standard_upstream_and_domain() {
let compute = ComputeConfig::default();
assert!(compute.internal_dns, "internal DNS is on by default");
assert_eq!(compute.dns_upstream, "1.1.1.1:53");
assert_eq!(compute.dns_domain, "boatramp.internal");
}
#[test]
fn internal_dns_knobs_parse_from_a_file() {
let cfg = server(
r#"(
compute: ( internal_dns: false, dns_upstream: "10.0.0.53:53", dns_domain: "svc.internal" ),
)"#,
);
let compute = cfg.compute.expect("compute section");
assert!(!compute.internal_dns);
assert_eq!(compute.dns_upstream, "10.0.0.53:53");
assert_eq!(compute.dns_domain, "svc.internal");
}
#[test]
fn internal_dns_knobs_are_env_settable() {
let mut cfg = ServerConfig::default();
cfg.apply_env_overrides(&env(&[
("BOATRAMP_COMPUTE_INTERNAL_DNS", "off"),
("BOATRAMP_COMPUTE_DNS_UPSTREAM", "9.9.9.9:53"),
("BOATRAMP_COMPUTE_DNS_DOMAIN", "corp.internal"),
]))
.expect("valid env overrides apply");
let compute = cfg.compute.expect("compute materialised from env");
assert!(!compute.internal_dns, "env `off` disables internal DNS");
assert_eq!(compute.dns_upstream, "9.9.9.9:53");
assert_eq!(compute.dns_domain, "corp.internal");
}
#[test]
fn env_override_wins_over_file_value_but_unset_defers() {
let mut cfg = server(
r#"(
compute: ( vcpus: 2, mem_mib: 512, region: "us-east" ),
security: ( profile: "multi-tenant" ),
handlers: ( bindings: ( sql: ( url: "http://file:8080", admin_url: "http://file:9090" ) ) ),
)"#,
);
cfg.apply_env_overrides(&env(&[
("BOATRAMP_COMPUTE_VCPUS", "16"),
("BOATRAMP_SECURITY_PROFILE", "dev"),
("BOATRAMP_HANDLERS_SQL_URL", "http://env:8080"),
]))
.expect("valid env overrides apply");
let compute = cfg.compute.unwrap();
assert_eq!(compute.vcpus, 16, "env wins over the file vcpus");
assert_eq!(compute.mem_mib, 512, "unset env defers to the file mem_mib");
assert_eq!(
compute.region.as_deref(),
Some("us-east"),
"unset env defers to the file region"
);
assert_eq!(
cfg.security.unwrap().profile.as_deref(),
Some("dev"),
"env profile wins over the file profile"
);
let sql = cfg.handlers.unwrap().bindings.sql.unwrap();
assert_eq!(
sql.url.as_deref(),
Some("http://env:8080"),
"env wins over the file sql url"
);
assert_eq!(
sql.admin_url.as_deref(),
Some("http://file:9090"),
"unset env defers to the file sql admin_url"
);
}
#[test]
fn env_overrides_leave_unmentioned_sections_absent() {
let mut cfg = ServerConfig::default();
cfg.apply_env_overrides(&env(&[("SOME_UNRELATED_VAR", "x")]))
.expect("no-op env applies");
assert!(cfg.compute.is_none());
assert!(cfg.security.is_none());
assert!(cfg.handlers.is_none());
assert!(cfg.secrets.is_none());
assert!(cfg.cluster.is_none());
}
#[test]
fn env_bool_accepts_common_spellings_and_rejects_garbage() {
for (raw, want) in [
("true", true),
("1", true),
("YES", true),
("On", true),
("false", false),
("0", false),
("no", false),
("OFF", false),
] {
let mut cfg = ServerConfig::default();
cfg.apply_env_overrides(&env(&[("BOATRAMP_SECURITY_REQUIRE_POP", raw)]))
.expect("boolean parses");
assert_eq!(
cfg.security.unwrap().overrides.require_pop,
Some(want),
"{raw:?} ⇒ {want}"
);
}
let mut cfg = ServerConfig::default();
let err = cfg
.apply_env_overrides(&env(&[("BOATRAMP_SECURITY_REQUIRE_POP", "maybe")]))
.expect_err("garbage boolean is rejected");
assert!(
matches!(err, ConfigError::Resolve(_)),
"garbage bool must fail loudly, got {err:?}"
);
}
#[test]
fn env_number_parse_error_is_loud() {
let mut cfg = ServerConfig::default();
let err = cfg
.apply_env_overrides(&env(&[("BOATRAMP_COMPUTE_VCPUS", "lots")]))
.expect_err("garbage number is rejected");
assert!(
matches!(err, ConfigError::Resolve(_)),
"garbage number must fail loudly, got {err:?}"
);
}
#[test]
fn empty_env_value_is_treated_as_unset() {
let mut cfg = server(r#"( compute: ( region: "us-east" ) )"#);
cfg.apply_env_overrides(&env(&[("BOATRAMP_COMPUTE_REGION", "")]))
.expect("empty env applies as a no-op");
assert_eq!(
cfg.compute.unwrap().region.as_deref(),
Some("us-east"),
"an empty env value leaves the file value in place"
);
}
#[test]
fn env_configures_managed_postgres_secrets_and_privilege_with_no_file() {
let mut cfg = ServerConfig::default();
assert!(cfg.handlers.is_none() && cfg.secrets.is_none() && cfg.compute.is_none());
cfg.apply_env_overrides(&env(&[
("BOATRAMP_HANDLERS_SQL_DB_DEFAULT_KIND", "postgres"),
("BOATRAMP_HANDLERS_SQL_DB_DEFAULT_COMPUTE", "pg"),
("BOATRAMP_HANDLERS_SQL_DB_DEFAULT_DATABASE", "appdb"),
("BOATRAMP_HANDLERS_SQL_DB_DEFAULT_USER", "app"),
("BOATRAMP_SECRETS_ENVELOPE", "local"),
("BOATRAMP_SECRETS_KEK_FILE", "/var/lib/boatramp/secrets/kek"),
("BOATRAMP_COMPUTE_MANAGED_DB_PRIVILEGE", "rootless"),
]))
.expect("valid env overrides apply");
let sql = cfg
.handlers
.expect("handlers materialised from env")
.bindings
.sql
.expect("sql binding materialised from env");
let db = sql
.databases
.get(boatramp_core::project::DEFAULT_DB_NAME)
.expect("the default `default`-named database was created from DEFAULT");
assert!(
!sql.databases.contains_key(""),
"the empty-string key is never produced (v0.5.0)"
);
assert_eq!(db.kind, "postgres");
assert_eq!(db.compute.as_deref(), Some("pg"));
assert_eq!(db.database.as_deref(), Some("appdb"));
assert_eq!(db.user.as_deref(), Some("app"));
assert!(db.password_env.is_none());
assert!(db.is_managed_credential());
assert!(db.validate("").is_ok());
let secrets = cfg.secrets.expect("secrets materialised from env");
assert_eq!(secrets.envelope, "local");
assert_eq!(
secrets.kek_file.as_deref(),
Some(Path::new("/var/lib/boatramp/secrets/kek"))
);
assert!(
secrets.vault.is_none(),
"no vault vars ⇒ no vault sub-config"
);
let compute = cfg.compute.expect("compute materialised from env");
assert_eq!(compute.managed_db_privilege, ManagedDbPrivilege::Rootless);
}
#[test]
fn env_declares_named_databases_and_merges_over_the_file() {
let mut cfg = server(
r#"(
handlers: ( bindings: ( sql: (
databases: {
"analytics": ( kind: "postgres", url_env: "FILE_PG_URL", pool_max: 4 ),
},
) ) ),
)"#,
);
cfg.apply_env_overrides(&env(&[
("BOATRAMP_HANDLERS_SQL_DB_analytics_POOL_MAX", "32"),
("BOATRAMP_HANDLERS_SQL_DB_events_log_KIND", "mysql"),
("BOATRAMP_HANDLERS_SQL_DB_events_log_URL_ENV", "EVENTS_URL"),
(
"BOATRAMP_HANDLERS_SQL_DB_events_log_READ_URL_ENV",
"EVENTS_RO_URL",
),
("BOATRAMP_HANDLERS_SQL_DB_events_log_READ_ONLY", "true"),
]))
.expect("valid env overrides apply");
let sql = cfg.handlers.unwrap().bindings.sql.unwrap();
assert_eq!(sql.databases.len(), 2);
let analytics = &sql.databases["analytics"];
assert_eq!(
analytics.pool_max,
Some(32),
"env pool_max wins over the file"
);
assert_eq!(
analytics.url_env, "FILE_PG_URL",
"the file's url_env survives (env didn't touch it)"
);
let events = &sql.databases["events_log"];
assert_eq!(events.kind, "mysql");
assert_eq!(events.url_env, "EVENTS_URL");
assert_eq!(events.read_url_env.as_deref(), Some("EVENTS_RO_URL"));
assert!(events.read_only);
}
#[test]
fn default_token_maps_to_the_reserved_default_name_not_empty() {
let mut cfg = ServerConfig::default();
cfg.apply_env_overrides(&env(&[
("BOATRAMP_HANDLERS_SQL_DB_DEFAULT_KIND", "postgres"),
("BOATRAMP_HANDLERS_SQL_DB_DEFAULT_URL_ENV", "PG_URL"),
]))
.expect("valid env overrides apply");
let databases = cfg.handlers.unwrap().bindings.sql.unwrap().databases;
assert!(
databases.contains_key(boatramp_core::project::DEFAULT_DB_NAME),
"DEFAULT → the reserved `default` key"
);
assert!(
!databases.contains_key(""),
"the empty-string key is never produced"
);
}
#[test]
fn empty_string_db_binding_name_is_rejected_with_a_cure() {
let err = ServerConfig::parse(
r#"(
handlers: ( bindings: ( sql: (
databases: {
"": ( kind: "postgres", url_env: "PG_URL" ),
},
) ) ),
)"#,
)
.expect_err("an empty-string db binding name is refused");
match err {
ConfigError::InvalidDbName { name, reason, cure } => {
assert_eq!(name, "");
assert!(reason.contains("empty"), "reason names the emptiness");
assert!(
cure.contains("default") && cure.contains("CHANGELOG"),
"the cure points at `default` + the CHANGELOG"
);
}
other => panic!("expected ConfigError::InvalidDbName, got {other:?}"),
}
}
#[test]
fn path_separator_db_binding_name_is_rejected() {
let err = ServerConfig::parse(
r#"(
handlers: ( bindings: ( sql: (
databases: {
"a/b": ( kind: "postgres", url_env: "PG_URL" ),
},
) ) ),
)"#,
)
.expect_err("a `/`-bearing db binding name is refused");
match err {
ConfigError::InvalidDbName { name, reason, cure } => {
assert_eq!(name, "a/b");
assert!(
reason.contains("valid slug"),
"reason states the slug rule, got {reason:?}"
);
assert_eq!(cure, boatramp_core::project::INVALID_NAME_CURE);
assert!(!cure.contains("CHANGELOG"), "not the empty-default cure");
}
other => panic!("expected ConfigError::InvalidDbName, got {other:?}"),
}
}
#[test]
fn reserved_bramp_db_compute_prefix_is_rejected() {
let err = ServerConfig::parse(
r#"(
handlers: ( bindings: ( sql: (
databases: {
"byo": ( kind: "postgres", compute: "bramp-db-acme-app", database: "d", user: "u" ),
},
) ) ),
)"#,
)
.expect_err("a `bramp-db-` compute workload is refused");
match err {
ConfigError::InvalidDbName { name, reason, cure } => {
assert_eq!(name, "bramp-db-acme-app");
assert!(reason.contains("reserved"), "reason names the reservation");
assert!(cure.contains("bramp-db-"), "the cure names the prefix");
}
other => panic!("expected ConfigError::InvalidDbName, got {other:?}"),
}
ServerConfig::parse(
r#"(
handlers: ( bindings: ( sql: (
databases: {
"byo": ( kind: "postgres", compute: "my-pg", database: "d", user: "u" ),
},
) ) ),
)"#,
)
.expect("a non-reserved compute workload loads");
}
#[test]
fn env_sets_managed_db_startup_grace() {
let mut cfg = ServerConfig::default();
cfg.apply_env_overrides(&env(&[
("BOATRAMP_HANDLERS_SQL_DB_DEFAULT_KIND", "postgres"),
("BOATRAMP_HANDLERS_SQL_DB_DEFAULT_COMPUTE", "pg"),
("BOATRAMP_HANDLERS_SQL_DB_DEFAULT_DATABASE", "appdb"),
("BOATRAMP_HANDLERS_SQL_DB_DEFAULT_USER", "app"),
("BOATRAMP_HANDLERS_SQL_DB_DEFAULT_STARTUP_GRACE_SECS", "90"),
]))
.expect("valid env overrides apply");
let db = &cfg.handlers.unwrap().bindings.sql.unwrap().databases
[boatramp_core::project::DEFAULT_DB_NAME];
assert_eq!(db.startup_grace_secs, Some(90));
}
#[test]
fn env_sets_rls_gucs_and_all_marker_without_suffix_collision() {
let mut cfg = ServerConfig::default();
cfg.apply_env_overrides(&env(&[
("BOATRAMP_HANDLERS_SQL_DB_DEFAULT_KIND", "postgres"),
("BOATRAMP_HANDLERS_SQL_DB_DEFAULT_DATABASE", "appdb"),
(
"BOATRAMP_HANDLERS_SQL_DB_DEFAULT_TENANT_GUC",
"app.tenant_id",
),
(
"BOATRAMP_HANDLERS_SQL_DB_DEFAULT_SESSION_GUC",
"app.session_id",
),
("BOATRAMP_HANDLERS_SQL_DB_DEFAULT_TENANT_ALL_MARKER", "*"),
]))
.expect("valid env overrides apply");
let db = &cfg.handlers.unwrap().bindings.sql.unwrap().databases
[boatramp_core::project::DEFAULT_DB_NAME];
assert_eq!(db.tenant_guc.as_deref(), Some("app.tenant_id"));
assert_eq!(db.session_guc.as_deref(), Some("app.session_id"));
assert_eq!(db.tenant_all_marker.as_deref(), Some("*"));
}
#[test]
fn env_enum_parse_error_is_loud() {
let mut cfg = ServerConfig::default();
let err = cfg
.apply_env_overrides(&env(&[(
"BOATRAMP_COMPUTE_MANAGED_DB_PRIVILEGE",
"superuser",
)]))
.expect_err("unknown enum variant is rejected");
assert!(
matches!(err, ConfigError::Resolve(_)),
"unknown enum variant must fail loudly, got {err:?}"
);
let mut cfg = ServerConfig::default();
cfg.apply_env_overrides(&env(&[
("BOATRAMP_COMPUTE_DOCKER_ENDPOINT", "bridge"),
("BOATRAMP_COMPUTE_DOCKER_VOLUME_MODE", "bind"),
]))
.expect("known enum variants parse");
let compute = cfg.compute.unwrap();
assert_eq!(
compute.docker_endpoint,
boatramp_docker::DockerEndpoint::Bridge
);
assert_eq!(
compute.docker_volume_mode,
boatramp_docker::DockerVolumeMode::Bind
);
}
#[test]
fn env_trust_anchors_parse_as_a_comma_separated_list() {
let mut cfg = ServerConfig::default();
cfg.apply_env_overrides(&env(&[
(
"BOATRAMP_COMPUTE_KERNEL_SIGNING_PUBKEYS",
" es256:aa , es256:bb ,",
),
("BOATRAMP_COMPUTE_KERNEL_ALLOWED_HASHES", "deadbeef"),
]))
.expect("valid list env applies");
let compute = cfg.compute.unwrap();
assert_eq!(
compute.kernel_signing_pubkeys,
vec!["es256:aa".to_string(), "es256:bb".to_string()],
"trimmed, comma-split, trailing-empty dropped, defaults replaced"
);
assert_eq!(
compute.kernel_allowed_hashes,
vec!["deadbeef".to_string()],
"a single value is a one-element list"
);
}
#[test]
fn env_configures_secrets_vault_subconfig() {
let mut cfg = ServerConfig::default();
cfg.apply_env_overrides(&env(&[
("BOATRAMP_SECRETS_ENVELOPE", "vault"),
("BOATRAMP_SECRETS_VAULT_ADDR", "https://vault:8200"),
("BOATRAMP_SECRETS_VAULT_KEY", "certs"),
]))
.expect("valid env overrides apply");
let secrets = cfg.secrets.unwrap();
assert_eq!(secrets.envelope, "vault");
let vault = secrets.vault.expect("vault sub-config materialised");
assert_eq!(vault.addr, "https://vault:8200");
assert_eq!(vault.key, "certs");
assert_eq!(vault.token_env, "VAULT_TOKEN");
}
#[test]
fn env_materialises_and_overrides_the_cluster_section() {
let mut cfg = ServerConfig::default();
cfg.apply_env_overrides(&env(&[
("BOATRAMP_CLUSTER_LISTEN", "10.0.0.2:7000"),
("BOATRAMP_CLUSTER_ROOT_PUBKEYS", "es256:aa,es256:bb"),
("BOATRAMP_CLUSTER_SEEDS", "https://10.0.0.1:8080"),
("BOATRAMP_CLUSTER_JOIN_TOKEN", "env:BOATRAMP_JOIN_TOKEN"),
("BOATRAMP_CLUSTER_STORE_DIR", "/var/lib/boatramp/raft"),
("BOATRAMP_CLUSTER_MESH_GATE_CLIENT_WRITES", "true"),
]))
.expect("valid env overrides apply");
let cluster = cfg.cluster.expect("cluster materialised from env");
assert_eq!(
cluster.listen,
"10.0.0.2:7000".parse::<std::net::SocketAddr>().unwrap()
);
assert_eq!(
cluster.root_pubkeys,
vec!["es256:aa".to_string(), "es256:bb".to_string()]
);
assert_eq!(cluster.seeds, vec!["https://10.0.0.1:8080".to_string()]);
assert_eq!(
cluster.join_token.as_deref(),
Some("env:BOATRAMP_JOIN_TOKEN")
);
assert_eq!(
cluster.store_dir.as_deref(),
Some(Path::new("/var/lib/boatramp/raft"))
);
assert_eq!(
cluster.mesh.expect("mesh sub-config").gate_client_writes,
Some(true)
);
let mut cfg = ServerConfig::default();
let err = cfg
.apply_env_overrides(&env(&[("BOATRAMP_CLUSTER_SEEDS", "https://10.0.0.1:8080")]))
.expect_err("a cluster section without `listen` must fail loudly");
assert!(
matches!(err, ConfigError::Resolve(_)),
"cluster-without-listen must be a loud resolution error, got {err:?}"
);
}
#[test]
fn env_cluster_listen_overrides_a_file_section() {
let mut cfg = server(r#"( cluster: ( listen: "0.0.0.0:7000" ) )"#);
cfg.apply_env_overrides(&env(&[
("BOATRAMP_CLUSTER_LISTEN", "10.0.0.9:7000"),
("BOATRAMP_CLUSTER_SEEDS", "https://seed:8080"),
]))
.expect("applies");
let cluster = cfg.cluster.unwrap();
assert_eq!(
cluster.listen,
"10.0.0.9:7000".parse::<std::net::SocketAddr>().unwrap(),
"env listen wins over the file"
);
assert_eq!(cluster.seeds, vec!["https://seed:8080".to_string()]);
}
#[test]
fn empty_project_config_is_default() {
let cfg = project("()");
assert!(cfg.publish.server.is_none());
assert!(cfg.publish.site.is_none());
assert!(cfg.build.is_none());
assert!(cfg.bundle.is_none());
assert_eq!(cfg.routing.version, 1);
assert_eq!(cfg.routing.index, vec!["index.html".to_string()]);
}
#[test]
fn serve_signer_config_parses_and_maps_each_backend() {
use boatramp_core::cose::TokenAlg;
use boatramp_server::signer::SignerConfig;
let vault = server(
r#"( serve: ( signer: Vault(
address: "https://vault.example:8200",
key: "boatramp-root",
token_env: "VAULT_TOKEN",
alg: Ed25519,
) ) )"#,
);
match vault.serve.unwrap().signer.unwrap().to_signer_config() {
SignerConfig::Vault {
address,
key,
token_env,
alg,
} => {
assert_eq!(address, "https://vault.example:8200");
assert_eq!(key, "boatramp-root");
assert_eq!(token_env, "VAULT_TOKEN");
assert_eq!(alg, TokenAlg::Ed25519);
}
other => panic!("expected Vault, got {other:?}"),
}
let aws =
server(r#"( serve: ( signer: AwsKms(key_id: "arn:aws:kms:eu-west-1:1:key/abc") ) )"#);
assert!(matches!(
aws.serve.unwrap().signer.unwrap().to_signer_config(),
SignerConfig::AwsKms { region: None, .. }
));
let hsm = server(
r#"( serve: ( signer: Pkcs11(
module: "/usr/lib/softhsm/libsofthsm2.so",
token_label: "boatramp",
key_label: "root",
pin_env: "HSM_PIN",
) ) )"#,
);
match hsm.serve.unwrap().signer.unwrap().to_signer_config() {
SignerConfig::Pkcs11 { alg, .. } => assert_eq!(alg, TokenAlg::Es256),
other => panic!("expected Pkcs11, got {other:?}"),
}
}
#[test]
fn project_config_parses_publish_build_and_routing() {
let cfg = project(
r#"(
publish: ( server: "http://127.0.0.1:8080", site: "demo" ),
build: ( command: "npm run build", output: "dist" ),
routing: (
clean_urls: true,
redirects: [ (from: "/old/:slug", to: "/new/:slug", status: 301) ],
),
)"#,
);
assert_eq!(cfg.publish.server.as_deref(), Some("http://127.0.0.1:8080"));
assert_eq!(cfg.publish.site.as_deref(), Some("demo"));
let build = cfg.build.unwrap();
assert_eq!(build.command, "npm run build");
assert_eq!(build.output.as_deref(), Some("dist"));
assert!(cfg.routing.clean_urls);
assert_eq!(cfg.routing.redirects.len(), 1);
assert_eq!(cfg.routing.redirects[0].status, 301);
}
#[test]
fn project_config_rejects_bad_routing_pattern() {
let cfg = project(r#"( routing: ( redirects: [ (from: "/a/**/b/**", to: "/x") ] ) )"#);
assert!(cfg.routing.compile_check().is_err());
}
#[test]
fn empty_server_config_has_no_sections() {
let cfg = server("()");
assert!(cfg.serve.is_none());
assert!(cfg.handlers.is_none());
assert!(cfg.cluster.is_none());
assert!(cfg.security.is_none());
}
#[test]
fn security_section_parses_and_resolves() {
let cfg = server(
r#"(
security: (
profile: "dev",
overrides: (
oidc_require_audience: true,
max_upload_bytes: 0,
),
)
)"#,
);
let posture = cfg.security.unwrap().resolve().expect("resolves");
assert!(posture.allow_unauthenticated_public_bind);
assert!(posture.oidc_require_audience);
assert_eq!(posture.max_upload_bytes, 0); }
#[test]
fn env_wires_allow_env_secret_refs_over_the_multi_tenant_default() {
let mut cfg = ServerConfig::default();
cfg.apply_env_overrides(&env(&[("BOATRAMP_SECURITY_ALLOW_ENV_SECRET_REFS", "true")]))
.expect("valid env override applies");
let posture = cfg
.security
.expect("security materialised from env")
.resolve()
.expect("resolves");
assert!(posture.allow_env_secret_refs);
}
#[test]
fn env_wires_the_multi_tenant_subknobs_over_the_default() {
let mut cfg = ServerConfig::default();
cfg.apply_env_overrides(&env(&[
("BOATRAMP_SECURITY_PROFILE", "multi-tenant"),
("BOATRAMP_SECURITY_REQUIRE_TENANCY_DECLARATION", "true"),
("BOATRAMP_SECURITY_ALLOW_CROSS_TENANT_DB", "true"),
("BOATRAMP_SECURITY_ALLOW_GUEST_MINT_CAPABILITY", "true"),
("BOATRAMP_SECURITY_MAX_GUEST_CAPABILITY_TTL_SECS", "1800"),
]))
.expect("valid env overrides apply");
let posture = cfg
.security
.expect("security materialised from env")
.resolve()
.expect("resolves");
assert!(posture.require_tenancy_declaration);
assert!(posture.allow_cross_tenant_db);
assert!(posture.allow_guest_mint_capability);
assert_eq!(posture.max_guest_capability_ttl_secs, 1800);
}
#[test]
fn env_wires_the_remaining_guest_feature_knobs_over_the_multi_tenant_default() {
let mut cfg = ServerConfig::default();
cfg.apply_env_overrides(&env(&[
("BOATRAMP_SECURITY_PROFILE", "multi-tenant"),
("BOATRAMP_SECURITY_ALLOW_GUEST_EMAIL", "true"),
("BOATRAMP_SECURITY_ALLOW_GUEST_EGRESS_EXTRA_CA", "true"),
("BOATRAMP_SECURITY_ALLOW_GUEST_ADMIN_DOMAINS", "true"),
("BOATRAMP_SECURITY_ALLOW_GUEST_ADMIN_EMAIL", "true"),
("BOATRAMP_SECURITY_ALLOW_GUEST_ADMIN_SITE", "true"),
("BOATRAMP_SECURITY_ALLOW_GUEST_ADMIN_SECRETS", "true"),
]))
.expect("valid env overrides apply");
let security = cfg.security.expect("security materialised from env");
let posture = security.resolve().expect("resolves");
assert!(posture.allow_guest_email, "guest email re-enabled via env");
assert!(posture.allow_guest_egress_extra_ca);
assert!(posture.allow_guest_admin_domains);
assert!(posture.allow_guest_admin_email);
assert!(posture.allow_guest_admin_site);
assert!(posture.allow_guest_admin_secrets);
let explained = security.explain().expect("explains");
for knob in [
"allow_guest_email",
"allow_guest_egress_extra_ca",
"allow_guest_admin_domains",
"allow_guest_admin_email",
"allow_guest_admin_site",
"allow_guest_admin_secrets",
] {
assert!(
explained
.lines()
.any(|l| l.contains(knob) && l.contains("true") && l.contains("(override)")),
"explain shows {knob} = true (override): \n{explained}"
);
}
}
#[test]
fn each_guest_feature_env_var_moves_its_own_posture_field() {
for (var, check) in [
(
"BOATRAMP_SECURITY_ALLOW_GUEST_EMAIL",
(|p: &boatramp_core::security::SecurityPosture| p.allow_guest_email)
as fn(&boatramp_core::security::SecurityPosture) -> bool,
),
("BOATRAMP_SECURITY_ALLOW_GUEST_EGRESS_EXTRA_CA", |p| {
p.allow_guest_egress_extra_ca
}),
("BOATRAMP_SECURITY_ALLOW_GUEST_TCP_EGRESS", |p| {
p.allow_guest_tcp_egress
}),
("BOATRAMP_SECURITY_ALLOW_GUEST_TCP_LISTENER", |p| {
p.allow_guest_tcp_listener
}),
("BOATRAMP_SECURITY_ALLOW_GUEST_ADMIN_DOMAINS", |p| {
p.allow_guest_admin_domains
}),
("BOATRAMP_SECURITY_ALLOW_GUEST_ADMIN_EMAIL", |p| {
p.allow_guest_admin_email
}),
("BOATRAMP_SECURITY_ALLOW_GUEST_ADMIN_SITE", |p| {
p.allow_guest_admin_site
}),
("BOATRAMP_SECURITY_ALLOW_GUEST_ADMIN_SECRETS", |p| {
p.allow_guest_admin_secrets
}),
] {
let mut cfg = ServerConfig::default();
cfg.apply_env_overrides(&env(&[
("BOATRAMP_SECURITY_PROFILE", "multi-tenant"),
(var, "true"),
]))
.unwrap_or_else(|e| panic!("{var} applies: {e}"));
let posture = cfg
.security
.unwrap_or_else(|| panic!("{var} materialises the security section"))
.resolve()
.expect("resolves");
assert!(check(&posture), "{var} did not flip the resolved posture");
}
}
#[test]
fn every_posture_override_field_has_an_env_mapping() {
let all = serde_json::to_value(boatramp_core::security::PostureOverrides::default())
.expect("PostureOverrides serializes");
let fields = all.as_object().expect("a struct is a JSON object");
assert!(!fields.is_empty(), "expected some override fields");
for field in fields.keys() {
let var = format!("BOATRAMP_SECURITY_{}", field.to_uppercase());
assert!(
SECURITY_ENV_VARS.contains(&var.as_str()),
"PostureOverrides field `{field}` has no env mapping — add `{var}` to \
SECURITY_ENV_VARS + an arm in apply_env_overrides (the v0.4.18 forgot-a-knob guard)"
);
}
}
#[test]
fn cluster_section_parses_the_dynamic_join_shape() {
let cfg = server(
r#"(
cluster: (
listen: "10.0.0.2:7000",
root_pubkeys: ["es256:03a1"],
seeds: ["https://10.0.0.1:8080"],
join_token: "env:BOATRAMP_JOIN_TOKEN",
),
)"#,
);
let cluster = cfg.cluster.unwrap();
assert_eq!(
cluster.listen,
"10.0.0.2:7000".parse::<std::net::SocketAddr>().unwrap()
);
assert_eq!(cluster.root_pubkeys, vec!["es256:03a1".to_string()]);
assert_eq!(cluster.seeds, vec!["https://10.0.0.1:8080".to_string()]);
assert_eq!(
cluster.join_token.as_deref(),
Some("env:BOATRAMP_JOIN_TOKEN")
);
assert!(cluster.store_dir.is_none());
}
#[test]
fn cluster_section_founds_with_just_a_listen_addr() {
let cfg = server(r#"( cluster: ( listen: "0.0.0.0:7000" ) )"#);
let cluster = cfg.cluster.unwrap();
assert!(cluster.seeds.is_empty());
assert!(cluster.root_pubkeys.is_empty());
assert!(cluster.join_token.is_none());
}
#[test]
fn sql_binding_single_node_defaults() {
let cfg = server(r#"( handlers: ( bindings: ( sql: () ) ) )"#);
let sql = cfg.handlers.unwrap().bindings.sql.unwrap();
assert!(sql.url.is_none());
assert!(sql.dir.is_none());
}
#[test]
fn sql_binding_single_node_custom_dir() {
let cfg =
server(r#"( handlers: ( bindings: ( sql: ( dir: "/var/lib/boatramp/sql" ) ) ) )"#);
let sql = cfg.handlers.unwrap().bindings.sql.unwrap();
assert_eq!(sql.dir.as_deref(), Some(Path::new("/var/lib/boatramp/sql")));
assert!(sql.url.is_none());
}
#[test]
fn sql_binding_cluster() {
let cfg = server(
r#"(
handlers: ( bindings: ( sql: (
url: "http://sqld:8080",
admin_url: "http://sqld:9090",
token_env: "BOATRAMP_SQL_TOKEN",
) ) ),
)"#,
);
let sql = cfg.handlers.unwrap().bindings.sql.unwrap();
assert_eq!(sql.url.as_deref(), Some("http://sqld:8080"));
assert_eq!(sql.admin_url.as_deref(), Some("http://sqld:9090"));
assert_eq!(sql.token_env.as_deref(), Some("BOATRAMP_SQL_TOKEN"));
assert_eq!(sql.admin_token_env, None);
}
#[test]
fn sql_binding_env_only_sets_trusted_extensions_and_declared_caps() {
let mut cfg = ServerConfig::default();
assert!(cfg.handlers.is_none());
cfg.apply_env_overrides(&env(&[
(
"BOATRAMP_HANDLERS_SQL_MIGRATE_TRUSTED_EXTENSIONS",
"pg_trgm, pgcrypto",
),
("BOATRAMP_HANDLERS_SQL_MAX_DECLARED_DATABASES", "32"),
("BOATRAMP_HANDLERS_SQL_MAX_DECLARED_VOLUME_MIB", "1048576"),
]))
.expect("valid env overrides apply");
let sql = cfg
.handlers
.expect("handlers materialised from env")
.bindings
.sql
.expect("sql binding materialised from the trusted-extensions var alone");
assert_eq!(
sql.migrate_trusted_extensions.as_deref(),
Some(&["pg_trgm".to_string(), "pgcrypto".to_string()][..]),
"the allowlist is the comma-split, trimmed env list (source = env)"
);
assert_eq!(sql.max_declared_databases, Some(32));
assert_eq!(sql.max_declared_volume_mib, Some(1_048_576));
}
#[test]
fn sql_binding_env_over_file_for_trusted_extensions() {
let mut cfg = server(
r#"(
handlers: ( bindings: ( sql: (
migrate_trusted_extensions: ["citext"],
max_declared_databases: 8,
) ) ),
)"#,
);
cfg.apply_env_overrides(&env(&[(
"BOATRAMP_HANDLERS_SQL_MIGRATE_TRUSTED_EXTENSIONS",
"pg_trgm",
)]))
.expect("env overrides apply");
let sql = cfg.handlers.unwrap().bindings.sql.unwrap();
assert_eq!(
sql.migrate_trusted_extensions.as_deref(),
Some(&["pg_trgm".to_string()][..]),
"env wins over the file allowlist for the var that is set"
);
assert_eq!(
sql.max_declared_databases,
Some(8),
"a var left unset keeps the file value (not clobbered)"
);
}
#[test]
fn every_sql_binding_scalar_has_an_env_mapping() {
let SqlBindingConfig {
dir: _,
url: _,
admin_url: _,
replica_url: _,
token_env: _,
admin_token_env: _,
preview_mode: _,
preview_init: _,
databases: _,
deprovision_grace_secs: _,
migrate_trusted_extensions: _,
max_declared_databases: _,
max_declared_volume_mib: _,
} = SqlBindingConfig::default();
for var in [
"BOATRAMP_HANDLERS_SQL_DIR",
"BOATRAMP_HANDLERS_SQL_URL",
"BOATRAMP_HANDLERS_SQL_ADMIN_URL",
"BOATRAMP_HANDLERS_SQL_REPLICA_URL",
"BOATRAMP_HANDLERS_SQL_TOKEN_ENV",
"BOATRAMP_HANDLERS_SQL_ADMIN_TOKEN_ENV",
"BOATRAMP_HANDLERS_SQL_PREVIEW_MODE",
"BOATRAMP_HANDLERS_SQL_PREVIEW_INIT",
"BOATRAMP_HANDLERS_SQL_DEPROVISION_GRACE_SECS",
"BOATRAMP_HANDLERS_SQL_MIGRATE_TRUSTED_EXTENSIONS",
"BOATRAMP_HANDLERS_SQL_MAX_DECLARED_DATABASES",
"BOATRAMP_HANDLERS_SQL_MAX_DECLARED_VOLUME_MIB",
] {
assert!(
SQL_ENV_VARS.contains(&var),
"{var} missing from SQL_ENV_VARS — a sql-binding scalar lost its env lever \
(the forgot-a-knob gap); add it to the registry + an overlay arm"
);
}
}
#[test]
fn env_wires_the_jwks_freshness_knobs_over_the_default() {
let mut cfg = ServerConfig::default();
assert!(cfg.handlers.is_none());
cfg.apply_env_overrides(&env(&[
("BOATRAMP_HANDLERS_JWKS_REFRESH_SECS", "90"),
("BOATRAMP_HANDLERS_JWKS_HARD_TTL_SECS", "450"),
("BOATRAMP_HANDLERS_JWKS_FOREIGN_REFRESH_SECS", "30"),
("BOATRAMP_HANDLERS_JWKS_FOREIGN_HARD_TTL_SECS", "150"),
("BOATRAMP_HANDLERS_JWKS_PREWARM", "false"),
]))
.expect("valid env overrides apply");
let h = cfg.handlers.expect("handlers materialised from env");
assert_eq!(h.jwks_refresh_secs, Some(90));
assert_eq!(h.jwks_hard_ttl_secs, Some(450));
assert_eq!(h.jwks_foreign_refresh_secs, Some(30));
assert_eq!(h.jwks_foreign_hard_ttl_secs, Some(150));
assert_eq!(h.jwks_prewarm, Some(false));
for var in [
"BOATRAMP_HANDLERS_JWKS_REFRESH_SECS",
"BOATRAMP_HANDLERS_JWKS_HARD_TTL_SECS",
"BOATRAMP_HANDLERS_JWKS_FOREIGN_REFRESH_SECS",
"BOATRAMP_HANDLERS_JWKS_FOREIGN_HARD_TTL_SECS",
"BOATRAMP_HANDLERS_JWKS_PREWARM",
] {
assert!(
JWKS_ENV_VARS.contains(&var),
"{var} missing from JWKS_ENV_VARS — a JWKS freshness knob lost its env lever"
);
}
}
#[test]
fn env_wires_the_handler_concurrency_knobs_over_the_default() {
let mut cfg = ServerConfig::default();
assert!(cfg.handlers.is_none());
cfg.apply_env_overrides(&env(&[
("BOATRAMP_HANDLERS_SERVE_CONCURRENCY", "0"),
("BOATRAMP_HANDLERS_SYNC_MAX_CONCURRENCY", "128"),
("BOATRAMP_HANDLERS_ASYNC_MAX_CONCURRENCY", "16"),
]))
.expect("valid env overrides apply");
let h = cfg.handlers.expect("handlers materialised from env");
assert_eq!(h.serve_concurrency, Some(0), "0 = disable must round-trip");
assert_eq!(h.sync_max_concurrency, Some(128));
assert_eq!(h.async_max_concurrency, Some(16));
for var in [
"BOATRAMP_HANDLERS_SERVE_CONCURRENCY",
"BOATRAMP_HANDLERS_SYNC_MAX_CONCURRENCY",
"BOATRAMP_HANDLERS_ASYNC_MAX_CONCURRENCY",
] {
assert!(
HANDLER_CONCURRENCY_ENV_VARS.contains(&var),
"{var} missing from HANDLER_CONCURRENCY_ENV_VARS — a concurrency knob lost its env lever"
);
}
}
#[test]
fn env_wires_the_blob_hedge_knobs_over_the_default() {
let mut cfg = ServerConfig::default();
assert!(cfg.handlers.is_none());
cfg.apply_env_overrides(&env(&[
("BOATRAMP_HANDLERS_BLOB_HEDGE_DELAY_MS", "80"),
("BOATRAMP_HANDLERS_BLOB_HEDGE_MAX_INFLIGHT", "24"),
]))
.expect("valid env overrides apply");
let h = cfg.handlers.expect("handlers materialised from env");
assert_eq!(h.blob_hedge_delay_ms, Some(80));
assert_eq!(h.blob_hedge_max_inflight, Some(24));
for var in [
"BOATRAMP_HANDLERS_BLOB_HEDGE_DELAY_MS",
"BOATRAMP_HANDLERS_BLOB_HEDGE_MAX_INFLIGHT",
] {
assert!(
HANDLER_BLOB_HEDGE_ENV_VARS.contains(&var),
"{var} missing from HANDLER_BLOB_HEDGE_ENV_VARS — a hedge knob lost its env lever"
);
}
}
#[test]
fn env_wires_the_s3_ingress_cloud_knobs_over_the_default() {
let mut cfg = ServerConfig::default();
assert!(cfg.serve.is_none());
cfg.apply_env_overrides(&env(&[(
"BOATRAMP_S3_INGRESS_CLOUD_AWS_PRESIGNED_ONLY",
"true",
)]))
.expect("valid env overrides apply");
let ic = cfg
.serve
.expect("serve materialised from env")
.s3_ingress_cloud
.expect("s3_ingress_cloud materialised from env");
assert!(ic.aws_presigned_only, "presigned-only toggled on via env");
assert!(
!ic.aws_use_federation_token,
"untouched toggle stays default"
);
assert!(ic.aws_role_arn.is_none(), "untouched field stays None");
for var in S3_INGRESS_CLOUD_ENV_VARS {
assert!(
var.starts_with("BOATRAMP_S3_INGRESS_CLOUD_"),
"{var} is not an s3_ingress_cloud lever"
);
}
}
#[test]
fn env_wires_the_per_project_blob_backend_over_the_default() {
let mut cfg = ServerConfig::default();
assert!(cfg.serve.is_none());
cfg.apply_env_overrides(&env(&[
("BOATRAMP_SERVE_BLOB_BACKEND_acme_eu_KIND", "s3"),
(
"BOATRAMP_SERVE_BLOB_BACKEND_acme_eu_S3_BUCKET",
"acme-eu-blobs",
),
(
"BOATRAMP_SERVE_BLOB_BACKEND_acme_eu_S3_ENDPOINT",
"https://s3.eu.example.com",
),
("BOATRAMP_SERVE_BLOB_BACKEND_acme_eu_S3_REGION", "eu-west-1"),
("BOATRAMP_SERVE_BLOB_BACKEND_acme_eu_S3_PATH_STYLE", "true"),
(
"BOATRAMP_SERVE_BLOB_BACKEND_acme_eu_S3_ACCESS_KEY_ID",
"AKIAACME",
),
(
"BOATRAMP_SERVE_BLOB_BACKEND_acme_eu_S3_SECRET_ACCESS_KEY",
"boatramp:acme-eu-s3",
),
]))
.expect("valid per-project blob-backend env overrides apply");
let serve = cfg.serve.expect("serve materialised from env");
let be = serve.blob_backends.get("acme_eu").expect(
"the acme_eu per-project backend was discovered from env (underscore survives name \
isolation)",
);
assert_eq!(be.blobs, Some(crate::backends::BlobBackend::S3));
assert_eq!(be.s3_bucket.as_deref(), Some("acme-eu-blobs"));
assert_eq!(be.s3_endpoint.as_deref(), Some("https://s3.eu.example.com"));
assert_eq!(be.s3_region.as_deref(), Some("eu-west-1"));
assert!(be.s3_path_style);
let cred = be
.s3_credential
.as_ref()
.expect("sealed credential materialised from its two refs");
assert_eq!(cred.access_key_id, "AKIAACME");
assert_eq!(cred.secret_access_key, "boatramp:acme-eu-s3");
}
#[test]
fn blob_backend_env_covers_every_field() {
let expected: &[&str] = &[
"_KIND",
"_S3_BUCKET",
"_S3_ENDPOINT",
"_S3_REGION",
"_S3_PATH_STYLE",
"_S3_ACCESS_KEY_ID",
"_S3_SECRET_ACCESS_KEY",
];
for suffix in expected {
assert!(
BLOB_BACKEND_FIELD_SUFFIXES.contains(suffix),
"{suffix} missing from BLOB_BACKEND_FIELD_SUFFIXES — a blob-backend field lost its \
env lever"
);
}
assert_eq!(
BLOB_BACKEND_FIELD_SUFFIXES.len(),
expected.len(),
"BLOB_BACKEND_FIELD_SUFFIXES has a suffix with no matching expected field (stale lever)"
);
}
#[test]
fn env_wires_an_inbound_tcp_listener_with_no_file() {
let mut cfg = ServerConfig::default();
assert!(cfg.serve.is_none());
cfg.apply_env_overrides(&env(&[
("BOATRAMP_SERVE_LISTENER_edge_api_BIND", "0.0.0.0:9000"),
("BOATRAMP_SERVE_LISTENER_edge_api_COMPONENT", "echo.wasm"),
("BOATRAMP_SERVE_LISTENER_edge_api_PROJECT", "acme"),
("BOATRAMP_SERVE_LISTENER_edge_api_SITE", "prod"),
(
"BOATRAMP_SERVE_LISTENER_edge_api_IMPORTS",
"sql,wasi:keyvalue",
),
("BOATRAMP_SERVE_LISTENER_edge_api_MAX_CONNS", "32"),
("BOATRAMP_SERVE_LISTENER_edge_api_IDLE_TIMEOUT_MS", "15000"),
(
"BOATRAMP_SERVE_LISTENER_edge_api_MAX_CONN_DURATION_MS",
"120000",
),
]))
.expect("valid inbound-TCP listener env overrides apply + validate");
let serve = cfg.serve.expect("serve materialised from env");
let l = serve.listeners.get("edge_api").expect(
"the edge_api listener was discovered from env (underscore survives isolation)",
);
assert_eq!(l.bind, Some("0.0.0.0:9000".parse().unwrap()));
assert_eq!(l.component, "echo.wasm");
assert_eq!(l.project, "acme");
assert_eq!(l.site, "prod");
assert_eq!(
l.imports,
vec!["sql".to_string(), "wasi:keyvalue".to_string()]
);
assert_eq!(l.max_conns, Some(32));
assert_eq!(l.idle_timeout_ms, Some(15000));
assert_eq!(l.max_conn_duration_ms, Some(120000));
}
#[test]
fn listener_validation_fails_closed() {
let err = ServerConfig::parse(
r#"( serve: ( listeners: { "l": ( component: "c.wasm", project: "p", site: "s" ) } ) )"#,
)
.expect_err("a listener with no bind must be refused");
assert!(
matches!(err, ConfigError::InvalidListener { .. }),
"{err:?}"
);
let err = ServerConfig::parse(
r#"( serve: ( listeners: { "l": ( bind: "0.0.0.0:9000", component: "c.wasm", project: "p", site: "s", tls: ( cert_file: "x.pem", key_file: "y.pem" ) ) } ) )"#,
)
.expect_err("a declared tls block must be refused in Phase 1 (plaintext-only)");
assert!(
matches!(err, ConfigError::InvalidListener { .. }),
"{err:?}"
);
let err = ServerConfig::parse(
r#"( serve: ( listeners: { "l": ( bind: "0.0.0.0:9000", component: "c.wasm", project: "p", site: "s", egress_allowlist: ["x:443"] ) } ) )"#,
)
.expect_err("a non-empty per-listener egress_allowlist must be refused in Phase 1");
assert!(
matches!(err, ConfigError::InvalidListener { .. }),
"{err:?}"
);
ServerConfig::parse(
r#"( serve: ( listeners: { "l": ( bind: "0.0.0.0:9000", component: "c.wasm", project: "p", site: "s" ) } ) )"#,
)
.expect("a fully-declared plaintext listener is valid");
}
#[test]
fn listener_env_covers_every_scalar_field() {
let expected: &[&str] = &[
"_BIND",
"_COMPONENT",
"_PROJECT",
"_SITE",
"_IMPORTS",
"_MAX_CONNS",
"_IDLE_TIMEOUT_MS",
"_MAX_CONN_DURATION_MS",
];
for suffix in expected {
assert!(
LISTENER_FIELD_SUFFIXES.contains(suffix),
"{suffix} missing from LISTENER_FIELD_SUFFIXES — a listener field lost its env lever"
);
}
assert_eq!(
LISTENER_FIELD_SUFFIXES.len(),
expected.len(),
"LISTENER_FIELD_SUFFIXES has a suffix with no matching expected field (stale lever)"
);
}
#[test]
fn blob_backend_rejects_the_reserved_default_project() {
let err = ServerConfig::parse(
r#"( serve: ( blob_backends: { "default": ( blobs: s3, s3_bucket: "x" ) } ) )"#,
)
.expect_err("a `default`-keyed per-project blob backend must be refused");
assert!(
matches!(err, ConfigError::InvalidBlobBackendProject { .. }),
"reserved-default blob backend is InvalidBlobBackendProject, got {err:?}"
);
}
#[test]
fn blob_backend_rejects_an_unsafe_project_name() {
let err = ServerConfig::parse(
r#"( serve: ( blob_backends: { "../evil": ( blobs: s3, s3_bucket: "x" ) } ) )"#,
)
.expect_err("a path-traversal project key must be refused");
assert!(
matches!(err, ConfigError::InvalidBlobBackendProject { .. }),
"unsafe blob-backend project name is InvalidBlobBackendProject, got {err:?}"
);
}
#[test]
fn blob_backend_config_rejects_unknown_fields_and_inlined_secret_stays_a_ref() {
let cfg = ServerConfig::parse(
r#"( serve: ( blob_backends: { "acme": (
blobs: s3,
s3_bucket: "acme-blobs",
s3_credential: ( access_key_id: "AKIA", secret_access_key: "boatramp:acme" ),
) } ) )"#,
)
.expect("a well-formed per-project blob backend parses");
let be = cfg
.serve
.unwrap()
.blob_backends
.remove("acme")
.expect("acme backend parsed");
assert_eq!(be.blobs, Some(crate::backends::BlobBackend::S3));
assert_eq!(
be.s3_credential.unwrap().secret_access_key,
"boatramp:acme",
"the secret stays the indirect reference, never an inlined secret"
);
let bad = ServerConfig::parse(
r#"( serve: ( blob_backends: { "acme": ( blobs: s3, bogus: 1 ) } ) )"#,
);
assert!(
bad.is_err(),
"an unknown [serve.blob_backends.<p>] field must be rejected (deny_unknown_fields)"
);
}
#[test]
fn sql_binding_preview_policy() {
let cfg = server(
r#"(
handlers: ( bindings: ( sql: (
preview_mode: "branch",
preview_init: "/etc/boatramp/seed.sql",
) ) ),
)"#,
);
let sql = cfg.handlers.unwrap().bindings.sql.unwrap();
assert_eq!(sql.preview_mode.as_deref(), Some("branch"));
assert_eq!(
sql.preview_init.as_deref(),
Some(Path::new("/etc/boatramp/seed.sql"))
);
}
#[test]
fn sql_binding_external_databases() {
let cfg = server(
r#"(
handlers: ( bindings: ( sql: (
databases: {
"analytics": (
kind: "postgres",
url_env: "ANALYTICS_PG_URL",
pool_max: 16,
read_only: true,
),
"events": (
kind: "mysql",
url_env: "EVENTS_MYSQL_URL",
read_url_env: "EVENTS_MYSQL_REPLICA_URL",
allow_preview: true,
),
},
) ) ),
)"#,
);
let sql = cfg.handlers.unwrap().bindings.sql.unwrap();
assert_eq!(sql.databases.len(), 2);
let analytics = &sql.databases["analytics"];
assert_eq!(analytics.kind, "postgres");
assert_eq!(analytics.url_env, "ANALYTICS_PG_URL");
assert_eq!(analytics.pool_max, Some(16));
assert!(analytics.read_only);
assert!(!analytics.allow_preview);
assert!(analytics.read_url_env.is_none());
let events = &sql.databases["events"];
assert_eq!(events.kind, "mysql");
assert_eq!(
events.read_url_env.as_deref(),
Some("EVENTS_MYSQL_REPLICA_URL")
);
assert!(events.allow_preview);
assert!(!events.read_only);
}
#[test]
fn sql_binding_compute_backed_database() {
let cfg = server(
r#"(
handlers: ( bindings: ( sql: (
databases: {
"analytics": (
kind: "postgres",
compute: "pg",
database: "analytics",
user: "app",
password_env: "PG_APP_PW",
),
},
) ) ),
)"#,
);
let db = &cfg.handlers.unwrap().bindings.sql.unwrap().databases["analytics"];
assert_eq!(db.kind, "postgres");
assert_eq!(db.compute.as_deref(), Some("pg"));
assert_eq!(db.database.as_deref(), Some("analytics"));
assert_eq!(db.user.as_deref(), Some("app"));
assert_eq!(db.password_env.as_deref(), Some("PG_APP_PW"));
assert!(db.url_env.is_empty(), "compute-backed has no url_env");
assert!(db.validate("analytics").is_ok());
}
#[test]
fn sql_binding_source_is_exactly_one_of_url_or_compute() {
assert!(ExternalDatabaseConfig::default().validate("db").is_err());
let both = ExternalDatabaseConfig {
kind: "postgres".into(),
url_env: "PG_URL".into(),
compute: Some("pg".into()),
..Default::default()
};
assert!(both.validate("db").is_err());
let url = ExternalDatabaseConfig {
kind: "postgres".into(),
url_env: "PG_URL".into(),
..Default::default()
};
assert!(url.validate("db").is_ok());
let bare = ExternalDatabaseConfig {
kind: "postgres".into(),
compute: Some("pg".into()),
..Default::default()
};
assert!(bare.validate("db").is_err());
let byo = ExternalDatabaseConfig {
kind: "postgres".into(),
compute: Some("pg".into()),
database: Some("analytics".into()),
user: Some("app".into()),
password_env: Some("PG_APP_PW".into()),
..Default::default()
};
assert!(byo.validate("db").is_ok());
assert!(!byo.is_managed_credential());
let managed = ExternalDatabaseConfig {
kind: "postgres".into(),
compute: Some("pg".into()),
database: Some("analytics".into()),
user: Some("app".into()),
..Default::default()
};
assert!(managed.validate("db").is_ok());
assert!(managed.is_managed_credential());
}
#[test]
fn libsql_source_is_exactly_one_of_path_or_url() {
let file = ExternalDatabaseConfig {
kind: "libsql".into(),
path: Some("/data/app.db".into()),
..Default::default()
};
assert!(file.validate("app").is_ok());
let sqlite = ExternalDatabaseConfig {
kind: "sqlite".into(),
path: Some("/data/app.db".into()),
..Default::default()
};
assert!(sqlite.validate("app").is_ok());
let remote = ExternalDatabaseConfig {
kind: "libsql".into(),
url_env: "LIBSQL_URL".into(),
..Default::default()
};
assert!(remote.validate("app").is_ok());
let neither = ExternalDatabaseConfig {
kind: "libsql".into(),
..Default::default()
};
assert!(neither.validate("app").is_err());
let both = ExternalDatabaseConfig {
kind: "libsql".into(),
path: Some("/data/app.db".into()),
url_env: "LIBSQL_URL".into(),
..Default::default()
};
assert!(both.validate("app").is_err());
let compute = ExternalDatabaseConfig {
kind: "libsql".into(),
compute: Some("x".into()),
path: Some("/data/app.db".into()),
..Default::default()
};
assert!(compute.validate("app").is_err());
let pg_path = ExternalDatabaseConfig {
kind: "postgres".into(),
url_env: "PG_URL".into(),
path: Some("/data/app.db".into()),
..Default::default()
};
assert!(pg_path.validate("db").is_err());
}
fn repo_root_file(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join(name)
}
#[test]
fn shipped_project_example_parses() {
let text = std::fs::read_to_string(repo_root_file("examples/site/project.cfg.example"))
.expect("example project config is present");
let cfg = ProjectConfig::parse(&text).expect("example project config parses");
assert_eq!(cfg.publish.server.as_deref(), Some("http://127.0.0.1:8080"));
assert_eq!(cfg.build.as_ref().unwrap().command, "npm run build");
assert_eq!(
cfg.routing.error_documents.get(&404).map(String::as_str),
Some("/404.html")
);
}
#[test]
fn shipped_server_example_parses() {
let text = std::fs::read_to_string(repo_root_file("boatramp.cfg.example"))
.expect("example server config is present");
let cfg = ServerConfig::parse(&text).expect("example server config parses");
let serve = cfg.serve.expect("example sets a serve section");
assert_eq!(
serve.addr,
Some("0.0.0.0:8080".parse::<std::net::SocketAddr>().unwrap())
);
}
#[test]
fn secrets_section_parses_local_and_vault() {
let local = server(r#"( secrets: ( envelope: "local", kek_file: "/k/kek" ) )"#)
.secrets
.expect("secrets section");
assert_eq!(local.envelope, "local");
assert_eq!(
local.kek_file.as_deref(),
Some(std::path::Path::new("/k/kek"))
);
let vault = server(
r#"( secrets: ( envelope: "vault", vault: ( addr: "https://vault:8200", key: "certs" ) ) )"#,
)
.secrets
.expect("secrets section");
let v = vault.vault.expect("vault subsection");
assert_eq!(v.addr, "https://vault:8200");
assert_eq!(v.key, "certs");
assert_eq!(v.token_env, "VAULT_TOKEN");
}
#[test]
fn serve_section_partial_parses() {
let cfg = server(r#"( serve: ( addr: "0.0.0.0:8080", protect_previews: true ) )"#);
let serve = cfg.serve.unwrap();
assert_eq!(
serve.addr,
Some("0.0.0.0:8080".parse::<std::net::SocketAddr>().unwrap())
);
assert!(serve.protect_previews);
assert!(!serve.cluster_rate_limit);
assert!(serve.data_dir.is_none());
}
#[test]
fn serve_console_config_parses() {
let cfg = server(r#"( serve: ( addr: "0.0.0.0:8080" ) )"#);
assert!(cfg.serve.unwrap().console.is_none());
let cfg = server(
r#"( serve: ( console: (
enabled: true,
host: "console.example.com",
path: "/_console",
) ) )"#,
);
let console = cfg.serve.unwrap().console.unwrap();
assert!(console.enabled);
assert_eq!(console.host.as_deref(), Some("console.example.com"));
assert_eq!(console.path.as_deref(), Some("/_console"));
let cfg = server(r#"( serve: ( console: ( enabled: true ) ) )"#);
let console = cfg.serve.unwrap().console.unwrap();
assert!(console.enabled);
assert!(console.host.is_none() && console.path.is_none());
}
#[test]
fn serve_s3_credential_config_parses() {
let cfg = server(r#"( serve: ( addr: "0.0.0.0:8080" ) )"#);
assert!(cfg.serve.unwrap().s3_credential.is_none());
let cfg = server(
r#"( serve: ( s3_credential: (
access_key_id: "tid_public_akid",
secret_access_key: "boatramp:tigris-secret",
) ) )"#,
);
let cred = cfg.serve.unwrap().s3_credential.unwrap();
assert_eq!(cred.access_key_id, "tid_public_akid");
assert_eq!(cred.secret_access_key, "boatramp:tigris-secret");
let err: Result<ServerConfig, _> = ron_options().from_str(
r#"( serve: ( s3_credential: ( access_key_id: "x", secret_access_key: "boatramp:y", bogus: 1 ) ) )"#,
);
assert!(
err.is_err(),
"unknown [serve.s3_credential] field must be rejected"
);
}
#[test]
fn serve_blob_fallback_config_parses() {
let cfg = server(r#"( serve: ( addr: "0.0.0.0:8080" ) )"#);
assert!(cfg.serve.unwrap().blob_fallback.is_none());
let cfg = server(
r#"( serve: (
blobs: s3,
s3_bucket: "new-bucket",
blob_fallback: (
blobs: fs,
secondary_timeout_secs: 3,
s3_credential: (
access_key_id: "old_akid",
secret_access_key: "boatramp:old-secret",
),
),
) )"#,
);
let serve = cfg.serve.unwrap();
let fb = serve.blob_fallback.unwrap();
assert_eq!(fb.blobs, Some(crate::backends::BlobBackend::Fs));
assert_eq!(fb.secondary_timeout_secs, Some(3));
let cred = fb.s3_credential.unwrap();
assert_eq!(cred.access_key_id, "old_akid");
assert_eq!(cred.secret_access_key, "boatramp:old-secret");
let err: Result<ServerConfig, _> =
ron_options().from_str(r#"( serve: ( blob_fallback: ( blobs: fs, bogus: 1 ) ) )"#);
assert!(
err.is_err(),
"unknown [serve.blob_fallback] field must be rejected"
);
}
#[test]
fn serve_kv_config_parses_and_defaults() {
let cfg = server(r#"( serve: ( addr: "0.0.0.0:8080" ) )"#);
assert!(cfg.serve.unwrap().kv.is_none());
let default_kv = super::KvConfig::default();
assert_eq!(
default_kv.checkpoint_interval(),
Some(std::time::Duration::from_secs(
super::DEFAULT_KV_CHECKPOINT_INTERVAL_SECS
)),
"an absent/unset checkpoint_interval defaults to the ON cadence"
);
assert_eq!(
default_kv.close_deadline(),
std::time::Duration::from_secs(super::DEFAULT_KV_CLOSE_DEADLINE_SECS),
"an absent/unset close_deadline defaults to the generous budget"
);
assert_eq!(
default_kv.gc_interval(),
Some(std::time::Duration::from_secs(
super::DEFAULT_KV_GC_INTERVAL_SECS
)),
"an absent/unset gc_interval defaults to the ON cadence"
);
assert_eq!(
default_kv.gc_min_age(),
std::time::Duration::from_secs(super::DEFAULT_KV_GC_MIN_AGE_SECS),
"an absent/unset gc_min_age defaults to the conservative 1h floor"
);
let cfg = server(
r#"( serve: ( kv: ( slatedb: ( checkpoint_interval: 5, close_deadline: 30, gc_interval: 300, gc_min_age: 600 ) ) ) )"#,
);
let kv = cfg.serve.unwrap().kv.unwrap();
assert_eq!(
kv.checkpoint_interval(),
Some(std::time::Duration::from_secs(5))
);
assert_eq!(kv.close_deadline(), std::time::Duration::from_secs(30));
assert_eq!(kv.gc_interval(), Some(std::time::Duration::from_secs(300)));
assert_eq!(kv.gc_min_age(), std::time::Duration::from_secs(600));
let cfg = server(
r#"( serve: ( kv: ( slatedb: ( checkpoint_interval: 0, close_deadline: 0, gc_interval: 0, gc_min_age: 0 ) ) ) )"#,
);
let kv = cfg.serve.unwrap().kv.unwrap();
assert_eq!(kv.checkpoint_interval(), None, "0 disables the cadence");
assert_eq!(
kv.close_deadline(),
std::time::Duration::from_secs(super::DEFAULT_KV_CLOSE_DEADLINE_SECS),
"a 0 close_deadline is clamped up to the default, never zero"
);
assert_eq!(kv.gc_interval(), None, "0 disables the maintenance GC task");
assert_eq!(
kv.gc_min_age(),
std::time::Duration::from_secs(super::DEFAULT_KV_GC_MIN_AGE_SECS),
"a 0 gc_min_age is clamped up to the conservative default, never zero"
);
let cfg = server(r#"( serve: ( kv: ( sql: ( kind: "sqlite", path: "/data/kv.db" ) ) ) )"#);
let sql = cfg.serve.unwrap().kv.unwrap().sql.unwrap();
assert_eq!(sql.kind, "sqlite");
assert_eq!(sql.path.as_deref(), Some("/data/kv.db"));
let err: Result<ServerConfig, _> =
ron_options().from_str(r#"( serve: ( kv: ( checkpoint_interval: 5 ) ) )"#);
assert!(
err.is_err(),
"a top-level checkpoint_interval must be rejected (moved under [serve.kv.slatedb])"
);
let err: Result<ServerConfig, _> = ron_options()
.from_str(r#"( serve: ( kv: ( slatedb: ( checkpoint_interval: 5, bogus: 1 ) ) ) )"#);
assert!(
err.is_err(),
"unknown [serve.kv.slatedb] field must be rejected"
);
}
#[test]
fn config_json_gate_resolve_format_table() {
use std::path::Path;
assert_eq!(
resolve_format(Path::new("boatramp.json"), None),
ConfigFormat::Json
);
assert_eq!(
resolve_format(Path::new("apply.cfg"), None),
ConfigFormat::Ron
);
assert_eq!(
resolve_format(Path::new("apply.ron"), None),
ConfigFormat::Ron
);
assert_eq!(resolve_format(Path::new("apply"), None), ConfigFormat::Ron);
assert_eq!(
resolve_format(Path::new("x.JSON"), None),
ConfigFormat::Json
);
assert_eq!(
resolve_format(Path::new("x.json"), Some(ConfigFormat::Ron)),
ConfigFormat::Ron
);
assert_eq!(
resolve_format(Path::new("x.cfg"), Some(ConfigFormat::Json)),
ConfigFormat::Json
);
}
#[test]
fn config_json_gate_serve_ron_and_json_are_equivalent() {
let ron = r#"( serve: ( addr: "0.0.0.0:8080", blobs: s3, s3_bucket: "b" ) )"#;
let json = r#"{ "serve": { "addr": "0.0.0.0:8080", "blobs": "s3", "s3_bucket": "b" } }"#;
let from_ron = ServerConfig::parse_with_format(ron, ConfigFormat::Ron).unwrap();
let from_json = ServerConfig::parse_with_format(json, ConfigFormat::Json).unwrap();
let s_ron = from_ron.serve.unwrap();
let s_json = from_json.serve.unwrap();
assert_eq!(s_ron.blobs, Some(crate::backends::BlobBackend::S3));
assert_eq!(s_json.blobs, Some(crate::backends::BlobBackend::S3));
assert_eq!(s_ron.blobs, s_json.blobs, "the enum variant survives JSON");
assert_eq!(s_ron.addr, s_json.addr);
assert_eq!(s_ron.s3_bucket, s_json.s3_bucket);
assert_eq!(s_json.s3_bucket.as_deref(), Some("b"));
}
#[test]
fn config_json_gate_project_ron_and_json_are_equivalent() {
let ron = r#"( publish: ( project: "team-x" ) )"#;
let json = r#"{ "publish": { "project": "team-x" } }"#;
let from_ron = ProjectConfig::parse_with_format(ron, ConfigFormat::Ron).unwrap();
let from_json = ProjectConfig::parse_with_format(json, ConfigFormat::Json).unwrap();
assert_eq!(from_ron.publish.project.as_deref(), Some("team-x"));
assert_eq!(from_ron.publish.project, from_json.publish.project);
}
#[test]
fn config_json_gate_serve_json_denies_unknown_fields() {
let json = r#"{ "serve": { "blob_fallback": { "blobs": "fs", "bogus": 1 } } }"#;
assert!(
ServerConfig::parse_with_format(json, ConfigFormat::Json).is_err(),
"an unknown JSON field must be rejected (deny_unknown_fields), same as RON"
);
}
#[test]
fn config_json_gate_json_bad_db_name_is_rejected_like_ron() {
let json = r#"{ "handlers": { "bindings": { "sql": {
"databases": { "a/b": { "kind": "postgres", "url_env": "DB_URL" } }
} } } }"#;
let ron = r#"( handlers: ( bindings: ( sql: (
databases: { "a/b": ( kind: "postgres", url_env: "DB_URL" ) }
) ) ) )"#;
let json_err = ServerConfig::parse_with_format(json, ConfigFormat::Json)
.expect_err("a bad db-name in a JSON config must be rejected");
let ron_err = ServerConfig::parse_with_format(ron, ConfigFormat::Ron)
.expect_err("a bad db-name in a RON config must be rejected");
assert!(
matches!(json_err, ConfigError::InvalidDbName { .. }),
"JSON bad db-name is InvalidDbName, got {json_err:?}"
);
assert!(
matches!(ron_err, ConfigError::InvalidDbName { .. }),
"RON bad db-name is InvalidDbName, got {ron_err:?}"
);
}
#[test]
fn config_json_gate_serve_format_selection_is_load_bearing() {
let doc = r#"{ "serve": { "s3_bucket": "only-json" } }"#;
let as_json = ServerConfig::parse_with_format(doc, ConfigFormat::Json).unwrap();
assert_eq!(
as_json.serve.unwrap().s3_bucket.as_deref(),
Some("only-json")
);
assert!(
ServerConfig::parse_with_format(doc, ConfigFormat::Ron).is_err(),
"the same document must FAIL under Ron — else the fmt selection is hollow"
);
}
}
pub(crate) mod figment_resolve {
use super::*;
use figment::providers::Serialized;
use figment::value::{Dict, Map as FigMap, Tag, Value};
use figment::{Error, Figment, Metadata, Profile, Provider};
const LIST_VARS: &[&str] = &[
"BOATRAMP_COMPUTE_KERNEL_SIGNING_PUBKEYS",
"BOATRAMP_COMPUTE_KERNEL_ALLOWED_HASHES",
"BOATRAMP_HANDLERS_SQL_MIGRATE_TRUSTED_EXTENSIONS",
"BOATRAMP_CLUSTER_ROOT_PUBKEYS",
"BOATRAMP_CLUSTER_SEEDS",
];
const ENUM_VARS: &[&str] = &[
"BOATRAMP_BLOBS",
"BOATRAMP_COMPUTE_MANAGED_DB_PRIVILEGE",
"BOATRAMP_COMPUTE_DOCKER_ENDPOINT",
"BOATRAMP_COMPUTE_DOCKER_VOLUME_MODE",
];
const SERVE_SCALARS: &[(&str, &[&str])] = &[
("BOATRAMP_BLOBS", &["serve", "blobs"]),
("BOATRAMP_S3_BUCKET", &["serve", "s3_bucket"]),
("BOATRAMP_S3_ENDPOINT", &["serve", "s3_endpoint"]),
("BOATRAMP_S3_REGION", &["serve", "s3_region"]),
("BOATRAMP_S3_PATH_STYLE", &["serve", "s3_path_style"]),
("BOATRAMP_ADDR", &["serve", "addr"]),
("BOATRAMP_DATA_DIR", &["serve", "data_dir"]),
("BOATRAMP_GCS_BUCKET", &["serve", "gcs_bucket"]),
("BOATRAMP_GCS_ENDPOINT", &["serve", "gcs_endpoint"]),
("BOATRAMP_GCS_ANONYMOUS", &["serve", "gcs_anonymous"]),
("BOATRAMP_AZURE_ACCOUNT", &["serve", "azure_account"]),
("BOATRAMP_AZURE_CONTAINER", &["serve", "azure_container"]),
("BOATRAMP_AZURE_ACCESS_KEY", &["serve", "azure_access_key"]),
("BOATRAMP_AZURE_EMULATOR", &["serve", "azure_emulator"]),
(
"BOATRAMP_AUTH_ROOT_PRIVATE_KEY",
&["serve", "auth_root_private_key"],
),
(
"BOATRAMP_AUTH_ROOT_PUBLIC_KEY",
&["serve", "auth_root_public_key"],
),
("BOATRAMP_BOOTSTRAP_SECRET", &["serve", "bootstrap_secret"]),
("BOATRAMP_MAX_UPLOAD_BYTES", &["serve", "max_upload_bytes"]),
(
"BOATRAMP_UPLOAD_IDLE_TIMEOUT",
&["serve", "upload_idle_timeout_secs"],
),
(
"BOATRAMP_MAX_CONCURRENT_UPLOADS",
&["serve", "max_concurrent_uploads"],
),
(
"BOATRAMP_HTTP_REDIRECT_ADDR",
&["serve", "http_redirect_addr"],
),
("BOATRAMP_DEFAULT_SITE", &["serve", "default_site"]),
("BOATRAMP_POP_ORIGIN", &["serve", "pop_origin"]),
("BOATRAMP_PROTECT_PREVIEWS", &["serve", "protect_previews"]),
(
"BOATRAMP_CLUSTER_RATE_LIMIT",
&["serve", "cluster_rate_limit"],
),
(
"BOATRAMP_SHARED_CACHE_COHERENCE",
&["serve", "shared_cache_coherence"],
),
];
const SERVE_KV: &[(&str, &[&str])] = &[
(
"BOATRAMP_KV_CHECKPOINT_INTERVAL",
&["serve", "kv", "slatedb", "checkpoint_interval"],
),
(
"BOATRAMP_KV_CLOSE_DEADLINE",
&["serve", "kv", "slatedb", "close_deadline"],
),
(
"BOATRAMP_KV_GC_INTERVAL",
&["serve", "kv", "slatedb", "gc_interval"],
),
(
"BOATRAMP_KV_GC_MIN_AGE",
&["serve", "kv", "slatedb", "gc_min_age"],
),
("BOATRAMP_KV_SQL_KIND", &["serve", "kv", "sql", "kind"]),
("BOATRAMP_KV_SQL_PATH", &["serve", "kv", "sql", "path"]),
(
"BOATRAMP_KV_SQL_URL_ENV",
&["serve", "kv", "sql", "url_env"],
),
(
"BOATRAMP_KV_SQL_POOL_MAX",
&["serve", "kv", "sql", "pool_max"],
),
];
fn put(dict: &mut Dict, path: &[&str], value: Value) {
let (last, parents) = path.split_last().expect("path is non-empty");
let mut cursor = dict;
for seg in parents {
let entry = cursor
.entry((*seg).to_string())
.or_insert_with(|| Value::Dict(Tag::Default, Dict::new()));
if !matches!(entry, Value::Dict(..)) {
*entry = Value::Dict(Tag::Default, Dict::new());
}
cursor = match entry {
Value::Dict(_, inner) => inner,
_ => unreachable!("entry was just forced to a Dict"),
};
}
cursor.insert((*last).to_string(), value);
}
fn emit(dict: &mut Dict, source: &EnvSource, var: &str, path: &[&str]) {
if LIST_VARS.contains(&var) {
if let Some(items) = source.parse_list(var) {
put(dict, path, Value::from(items));
}
} else if ENUM_VARS.contains(&var) {
if let Some(v) = source.get(var) {
put(dict, path, Value::from(v.trim().to_ascii_lowercase()));
}
} else if let Some(v) = source.get(var) {
let trimmed = v.trim();
if !trimmed.is_empty() {
put(dict, path, Value::from(trimmed.to_string()));
}
}
}
fn field_of(var: &str, prefix: &str) -> String {
var.strip_prefix(prefix)
.expect("var belongs to this section's prefix")
.to_ascii_lowercase()
}
pub(crate) fn env_overlay_dict(source: &EnvSource) -> Dict {
let mut d = Dict::new();
for (var, path) in SERVE_SCALARS.iter().chain(SERVE_KV) {
emit(&mut d, source, var, path);
}
for var in COMPUTE_ENV_VARS {
let field = field_of(var, "BOATRAMP_COMPUTE_");
emit(&mut d, source, var, &["compute", field.as_str()]);
}
for var in SECURITY_ENV_VARS {
if *var == "BOATRAMP_SECURITY_PROFILE" {
emit(&mut d, source, var, &["security", "profile"]);
} else {
let field = field_of(var, "BOATRAMP_SECURITY_");
emit(
&mut d,
source,
var,
&["security", "overrides", field.as_str()],
);
}
}
for var in SQL_ENV_VARS {
let field = field_of(var, "BOATRAMP_HANDLERS_SQL_");
emit(
&mut d,
source,
var,
&["handlers", "bindings", "sql", field.as_str()],
);
}
for var in JWKS_ENV_VARS
.iter()
.chain(HANDLER_CONCURRENCY_ENV_VARS)
.chain(HANDLER_BLOB_HEDGE_ENV_VARS)
{
let field = field_of(var, "BOATRAMP_HANDLERS_");
emit(&mut d, source, var, &["handlers", field.as_str()]);
}
for var in S3_INGRESS_CLOUD_ENV_VARS {
let field = field_of(var, "BOATRAMP_S3_INGRESS_CLOUD_");
emit(
&mut d,
source,
var,
&["serve", "s3_ingress_cloud", field.as_str()],
);
}
for var in SECRETS_ENV_VARS {
if let Some(rest) = var.strip_prefix("BOATRAMP_SECRETS_VAULT_") {
emit(
&mut d,
source,
var,
&["secrets", "vault", rest.to_ascii_lowercase().as_str()],
);
} else {
let field = field_of(var, "BOATRAMP_SECRETS_");
emit(&mut d, source, var, &["secrets", field.as_str()]);
}
}
for var in CLUSTER_ENV_VARS {
if let Some(rest) = var.strip_prefix("BOATRAMP_CLUSTER_MESH_") {
emit(
&mut d,
source,
var,
&["cluster", "mesh", rest.to_ascii_lowercase().as_str()],
);
} else {
let field = field_of(var, "BOATRAMP_CLUSTER_");
emit(&mut d, source, var, &["cluster", field.as_str()]);
}
}
blob_backends_overlay(&mut d, source);
sql_databases_overlay(&mut d, source);
listeners_overlay(&mut d, source);
d
}
fn blob_backends_overlay(d: &mut Dict, source: &EnvSource) {
for name in source.blob_backend_projects() {
let prefix = format!("{BLOB_BACKEND_ENV_PREFIX}{name}_");
let n = name.as_str();
if let Some(v) = source.get(&format!("{prefix}KIND")) {
put(
d,
&["serve", "blob_backends", n, "blobs"],
Value::from(v.trim().to_ascii_lowercase()),
);
}
for (suffix, field) in [
("S3_BUCKET", "s3_bucket"),
("S3_ENDPOINT", "s3_endpoint"),
("S3_REGION", "s3_region"),
("S3_PATH_STYLE", "s3_path_style"),
] {
if let Some(v) = source.get(&format!("{prefix}{suffix}")) {
put(d, &["serve", "blob_backends", n, field], Value::from(v));
}
}
if let Some(v) = source.get(&format!("{prefix}S3_ACCESS_KEY_ID")) {
put(
d,
&[
"serve",
"blob_backends",
name.as_str(),
"s3_credential",
"access_key_id",
],
Value::from(v),
);
}
if let Some(v) = source.get(&format!("{prefix}S3_SECRET_ACCESS_KEY")) {
put(
d,
&[
"serve",
"blob_backends",
name.as_str(),
"s3_credential",
"secret_access_key",
],
Value::from(v),
);
}
}
}
fn sql_databases_overlay(d: &mut Dict, source: &EnvSource) {
for name in source.sql_database_names() {
let key = if name == "DEFAULT" {
boatramp_core::project::DEFAULT_DB_NAME.to_string()
} else {
name.clone()
};
let prefix = format!("{SQL_DB_ENV_PREFIX}{name}_");
let k = key.as_str();
for (suffix, field) in [
("KIND", "kind"),
("URL_ENV", "url_env"),
("READ_URL_ENV", "read_url_env"),
("COMPUTE", "compute"),
("DATABASE", "database"),
("USER", "user"),
("PASSWORD_ENV", "password_env"),
("POOL_MAX", "pool_max"),
("READ_ONLY", "read_only"),
("ALLOW_PREVIEW", "allow_preview"),
("CONNECT_TIMEOUT_SECS", "connect_timeout_secs"),
("IMAGE", "image"),
("VOLUME_SIZE_MIB", "volume_size_mib"),
("STARTUP_GRACE_SECS", "startup_grace_secs"),
("RLS_SESSION", "rls_session"),
("TENANT_GUC", "tenant_guc"),
("SESSION_GUC", "session_guc"),
("TENANT_ALL_MARKER", "tenant_all_marker"),
] {
if let Some(v) = source.get(&format!("{prefix}{suffix}")) {
put(
d,
&["handlers", "bindings", "sql", "databases", k, field],
Value::from(v),
);
}
}
for (suffix, field) in [("TENANT", "tenant"), ("TENANT_SCOPE", "tenant_scope")] {
if let Some(v) = source.get(&format!("{prefix}{suffix}")) {
put(
d,
&["handlers", "bindings", "sql", "databases", k, field],
Value::from(v.trim().to_ascii_lowercase()),
);
}
}
}
}
fn listeners_overlay(d: &mut Dict, source: &EnvSource) {
for name in source.listener_names() {
let prefix = format!("{LISTENER_ENV_PREFIX}{name}_");
let n = name.as_str();
for (suffix, field) in [
("BIND", "bind"),
("COMPONENT", "component"),
("PROJECT", "project"),
("SITE", "site"),
("MAX_CONNS", "max_conns"),
("IDLE_TIMEOUT_MS", "idle_timeout_ms"),
("MAX_CONN_DURATION_MS", "max_conn_duration_ms"),
] {
if let Some(v) = source.get(&format!("{prefix}{suffix}")) {
put(d, &["serve", "listeners", n, field], Value::from(v));
}
}
if let Some(items) = source.parse_list(&format!("{prefix}IMPORTS")) {
put(d, &["serve", "listeners", n, "imports"], Value::from(items));
}
}
}
pub(crate) struct BoatrampEnv(Dict);
impl BoatrampEnv {
pub(crate) fn new(source: &EnvSource) -> Self {
Self(env_overlay_dict(source))
}
}
impl Provider for BoatrampEnv {
fn metadata(&self) -> Metadata {
Metadata::named("BOATRAMP_* environment")
}
fn data(&self) -> Result<FigMap<Profile, Dict>, Error> {
let mut map = FigMap::new();
map.insert(Profile::Default, self.0.clone());
Ok(map)
}
}
struct DictLayer {
name: &'static str,
dict: Dict,
}
impl Provider for DictLayer {
fn metadata(&self) -> Metadata {
Metadata::named(self.name)
}
fn data(&self) -> Result<FigMap<Profile, Dict>, Error> {
let mut map = FigMap::new();
map.insert(Profile::Default, self.dict.clone());
Ok(map)
}
}
fn prune_empty(dict: Dict) -> Dict {
dict.into_iter()
.filter_map(|(k, v)| match v {
Value::Empty(..) => None,
Value::Dict(t, inner) => Some((k, Value::Dict(t, prune_empty(inner)))),
other => Some((k, other)),
})
.collect()
}
fn to_dict(value: &impl Serialize) -> Result<Dict, Box<Error>> {
let mut data = Serialized::defaults(value).data().map_err(Box::new)?;
Ok(data
.remove(&Profile::Default)
.or_else(|| data.into_values().next())
.unwrap_or_default())
}
fn file_layer(cfg: &ServerConfig) -> Result<Dict, Box<Error>> {
Ok(prune_empty(to_dict(cfg)?))
}
fn base_seed(source: &EnvSource) -> Result<Dict, Box<Error>> {
let mut d = Dict::new();
if source.any(SECRETS_VAULT_ENV_VARS) {
put(
&mut d,
&["secrets", "vault"],
Value::Dict(Tag::Default, to_dict(&VaultSecretsConfig::default())?),
);
}
for name in source.blob_backend_projects() {
let prefix = format!("{BLOB_BACKEND_ENV_PREFIX}{name}_");
let has_cred = source.get(&format!("{prefix}S3_ACCESS_KEY_ID")).is_some()
|| source
.get(&format!("{prefix}S3_SECRET_ACCESS_KEY"))
.is_some();
if has_cred {
put(
&mut d,
&["serve", "blob_backends", name.as_str(), "s3_credential"],
Value::Dict(Tag::Default, to_dict(&S3CredentialConfig::default())?),
);
}
}
Ok(d)
}
pub(crate) fn load_with_figment(
path: &Path,
override_: Option<ConfigFormat>,
source: &EnvSource,
flags: Dict,
) -> Result<ServerConfig, ConfigError> {
let fmt = resolve_format(path, override_);
let file_cfg = match std::fs::read_to_string(path) {
Ok(contents) => ServerConfig::parse_with_format(&contents, fmt).map_err(|err| {
ConfigError::File {
path: path.display().to_string(),
source: Box::new(err),
}
})?,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => ServerConfig::default(),
Err(err) => return Err(err.into()),
};
let base = base_seed(source).map_err(|e| ConfigError::Resolve(e.to_string()))?;
let file = file_layer(&file_cfg).map_err(|e| ConfigError::Resolve(e.to_string()))?;
let cfg: ServerConfig = Figment::new()
.merge(DictLayer {
name: "base defaults",
dict: base,
})
.merge(DictLayer {
name: "config file",
dict: file,
})
.merge(BoatrampEnv::new(source))
.merge(DictLayer {
name: "cli flags",
dict: flags,
})
.extract_lossy()
.map_err(|e| ConfigError::Resolve(e.to_string()))?;
cfg.validate_sql_db_names()?;
cfg.validate_blob_backend_projects()?;
cfg.validate_listeners()?;
Ok(cfg)
}
#[cfg(test)]
pub(crate) fn resolve_env_over(
base: &ServerConfig,
source: &EnvSource,
) -> Result<ServerConfig, ConfigError> {
let seed = base_seed(source).map_err(|e| ConfigError::Resolve(e.to_string()))?;
let file = file_layer(base).map_err(|e| ConfigError::Resolve(e.to_string()))?;
let cfg: ServerConfig = Figment::new()
.merge(DictLayer {
name: "base defaults",
dict: seed,
})
.merge(DictLayer {
name: "base config",
dict: file,
})
.merge(BoatrampEnv::new(source))
.extract_lossy()
.map_err(|e| ConfigError::Resolve(e.to_string()))?;
cfg.validate_sql_db_names()?;
cfg.validate_blob_backend_projects()?;
cfg.validate_listeners()?;
Ok(cfg)
}
pub(crate) fn flag_overlay_dict(cli_flags: &BTreeMap<String, String>) -> Dict {
env_overlay_dict(&EnvSource::Map(cli_flags.clone()))
}
const SECRET_FIELDS: &[&str] = &[
"auth_root_private_key",
"bootstrap_secret",
"azure_access_key",
"secret_access_key",
"private_key",
"join_token",
];
fn redact(value: &mut serde_json::Value) {
match value {
serde_json::Value::Object(map) => {
for (k, v) in map.iter_mut() {
if SECRET_FIELDS.contains(&k.as_str()) && !v.is_null() {
*v = serde_json::Value::String("<redacted>".to_string());
} else {
redact(v);
}
}
}
serde_json::Value::Array(items) => items.iter_mut().for_each(redact),
_ => {}
}
}
pub(crate) fn dump_redacted(cfg: &ServerConfig) -> Result<String, ConfigError> {
let mut v = serde_json::to_value(cfg).map_err(ConfigError::Json)?;
redact(&mut v);
serde_json::to_string_pretty(&v).map_err(ConfigError::Json)
}
#[cfg(test)]
pub(crate) fn resolve_from_env(source: &EnvSource) -> Result<ServerConfig, Box<Error>> {
Figment::new()
.merge(BoatrampEnv::new(source))
.extract_lossy()
.map_err(Box::new)
}
#[cfg(test)]
mod tests {
use super::*;
fn map(pairs: &[(&str, &str)]) -> EnvSource {
EnvSource::Map(
pairs
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect(),
)
}
#[test]
fn overlay_honors_every_loader_b_fixed_var() {
let all: Vec<&str> = COMPUTE_ENV_VARS
.iter()
.chain(SECURITY_ENV_VARS)
.chain(SQL_ENV_VARS)
.chain(JWKS_ENV_VARS)
.chain(HANDLER_CONCURRENCY_ENV_VARS)
.chain(HANDLER_BLOB_HEDGE_ENV_VARS)
.chain(S3_INGRESS_CLOUD_ENV_VARS)
.chain(SECRETS_ENV_VARS)
.chain(CLUSTER_ENV_VARS)
.copied()
.collect();
let mut unhandled = Vec::new();
for var in all {
let dict = env_overlay_dict(&map(&[(var, "x")]));
if dict.is_empty() {
unhandled.push(var);
}
}
assert!(
unhandled.is_empty(),
"overlay produced nothing for these registered Loader-B vars \
(a drift gap — wire them into env_overlay_dict): {unhandled:?}"
);
}
#[test]
fn bool_spellings_round_trip_like_parse_bool() {
for (raw, expect) in [
("on", true),
("1", true),
("yes", true),
("TRUE", true),
("off", false),
("0", false),
("no", false),
] {
let cfg = resolve_from_env(&map(&[(
"BOATRAMP_SECURITY_ALLOW_SHARED_KERNEL_COMPUTE",
raw,
)]))
.expect("extract");
assert_eq!(
cfg.security
.expect("security materialised")
.overrides
.allow_shared_kernel_compute,
Some(expect),
"bool spelling {raw:?} must parse like parse_bool"
);
}
}
#[test]
fn empty_value_is_unset() {
let dict = env_overlay_dict(&map(&[("BOATRAMP_S3_BUCKET", "")]));
assert!(dict.is_empty(), "an empty value must be treated as unset");
}
#[test]
fn compute_resolves_from_env() {
let c = resolve_from_env(&map(&[
("BOATRAMP_COMPUTE_VCPUS", "4"),
("BOATRAMP_COMPUTE_MEM_MIB", "2048"),
("BOATRAMP_COMPUTE_REGION", "ams"),
(
"BOATRAMP_COMPUTE_KERNEL_SIGNING_PUBKEYS",
"es256:aa, es256:bb",
),
("BOATRAMP_COMPUTE_MANAGED_DB_PRIVILEGE", "rootless"),
("BOATRAMP_COMPUTE_INTERNAL_DNS", "on"),
]))
.expect("figment extract")
.compute
.expect("compute materialised from env");
assert_eq!(c.vcpus, 4);
assert_eq!(c.mem_mib, 2048);
assert_eq!(c.region.as_deref(), Some("ams"));
assert_eq!(
c.kernel_signing_pubkeys,
vec!["es256:aa".to_string(), "es256:bb".to_string()],
"comma-list trimmed + split"
);
assert_eq!(c.managed_db_privilege, ManagedDbPrivilege::Rootless);
assert!(c.internal_dns, "bool coerced from \"on\"");
}
#[test]
fn serve_s3_scalars_extract() {
let cfg = resolve_from_env(&map(&[
("BOATRAMP_BLOBS", "s3"),
("BOATRAMP_S3_BUCKET", "construens-blobs"),
("BOATRAMP_S3_ENDPOINT", "https://fly.storage.tigris.dev"),
("BOATRAMP_S3_REGION", "auto"),
("BOATRAMP_S3_PATH_STYLE", "true"),
]))
.expect("extract");
let serve = cfg.serve.expect("serve materialised from env");
assert_eq!(serve.blobs, Some(crate::backends::BlobBackend::S3));
assert_eq!(serve.s3_bucket.as_deref(), Some("construens-blobs"));
assert_eq!(
serve.s3_endpoint.as_deref(),
Some("https://fly.storage.tigris.dev")
);
assert_eq!(serve.s3_region.as_deref(), Some("auto"));
assert!(serve.s3_path_style, "s3_path_style coerced from \"true\"");
}
#[test]
fn blob_backends_dynamic_map_shape() {
let dict = env_overlay_dict(&map(&[
("BOATRAMP_SERVE_BLOB_BACKEND_myproj_KIND", "s3"),
("BOATRAMP_SERVE_BLOB_BACKEND_myproj_S3_BUCKET", "p-blobs"),
(
"BOATRAMP_SERVE_BLOB_BACKEND_myproj_S3_ACCESS_KEY_ID",
"AKIA",
),
]));
let serve = match dict.get("serve") {
Some(Value::Dict(_, s)) => s,
_ => panic!("serve dict present"),
};
let backends = match serve.get("blob_backends") {
Some(Value::Dict(_, b)) => b,
_ => panic!("blob_backends dict present"),
};
let myproj = match backends.get("myproj") {
Some(Value::Dict(_, p)) => p,
_ => panic!("myproj backend present"),
};
assert!(matches!(myproj.get("blobs"), Some(Value::String(_, s)) if s == "s3"));
assert!(matches!(myproj.get("s3_bucket"), Some(Value::String(_, s)) if s == "p-blobs"));
match myproj.get("s3_credential") {
Some(Value::Dict(_, cred)) => assert!(
matches!(cred.get("access_key_id"), Some(Value::String(_, s)) if s == "AKIA")
),
_ => panic!("s3_credential flattened under the backend"),
}
}
#[test]
fn sql_db_default_token_maps_to_default_key() {
let dict = env_overlay_dict(&map(&[(
"BOATRAMP_HANDLERS_SQL_DB_DEFAULT_KIND",
"postgres",
)]));
let mut cur = &dict;
for seg in ["handlers", "bindings", "sql", "databases", "default"] {
cur = match cur.get(seg) {
Some(Value::Dict(_, inner)) => inner,
_ => panic!("missing dict segment {seg}"),
};
}
assert!(matches!(cur.get("kind"), Some(Value::String(_, s)) if s == "postgres"));
}
fn figment_env(pairs: &[(&str, &str)]) -> ServerConfig {
load_with_figment(
std::path::Path::new("/nonexistent/boatramp.cfg"),
None,
&map(pairs),
Dict::new(),
)
.expect("figment load_with_figment")
}
fn serve_from_env(pairs: &[(&str, &str)]) -> ServeConfig {
figment_env(pairs)
.serve
.expect("serve materialised from env")
}
#[test]
fn serve_scalars_full_extract() {
let s = serve_from_env(&[
("BOATRAMP_ADDR", "0.0.0.0:8080"),
("BOATRAMP_DATA_DIR", "/data"),
("BOATRAMP_BLOBS", "s3"),
("BOATRAMP_S3_BUCKET", "b"),
("BOATRAMP_S3_PATH_STYLE", "true"),
("BOATRAMP_GCS_BUCKET", "gb"),
("BOATRAMP_GCS_ENDPOINT", "http://gcs"),
("BOATRAMP_GCS_ANONYMOUS", "yes"),
("BOATRAMP_AZURE_ACCOUNT", "acct"),
("BOATRAMP_AZURE_CONTAINER", "cont"),
("BOATRAMP_AZURE_ACCESS_KEY", "secret-key"),
("BOATRAMP_AZURE_EMULATOR", "true"),
("BOATRAMP_AUTH_ROOT_PRIVATE_KEY", "deadbeef"),
("BOATRAMP_AUTH_ROOT_PUBLIC_KEY", "cafe"),
("BOATRAMP_BOOTSTRAP_SECRET", "boot"),
("BOATRAMP_MAX_UPLOAD_BYTES", "1048576"),
("BOATRAMP_UPLOAD_IDLE_TIMEOUT", "30"),
("BOATRAMP_MAX_CONCURRENT_UPLOADS", "16"),
("BOATRAMP_HTTP_REDIRECT_ADDR", "0.0.0.0:80"),
("BOATRAMP_DEFAULT_SITE", "construens"),
("BOATRAMP_POP_ORIGIN", "https://cp.example.com"),
("BOATRAMP_PROTECT_PREVIEWS", "true"),
("BOATRAMP_CLUSTER_RATE_LIMIT", "true"),
("BOATRAMP_SHARED_CACHE_COHERENCE", "true"),
]);
assert_eq!(s.addr, Some("0.0.0.0:8080".parse().unwrap()));
assert_eq!(s.data_dir.as_deref(), Some(std::path::Path::new("/data")));
assert!(matches!(s.blobs, Some(crate::backends::BlobBackend::S3)));
assert_eq!(s.s3_bucket.as_deref(), Some("b"));
assert!(s.s3_path_style);
assert_eq!(s.gcs_bucket.as_deref(), Some("gb"));
assert!(s.gcs_anonymous, "GCS anonymous coerced from \"yes\"");
assert_eq!(s.azure_account.as_deref(), Some("acct"));
assert_eq!(s.azure_access_key.as_deref(), Some("secret-key"));
assert!(s.azure_emulator);
assert_eq!(s.auth_root_private_key.as_deref(), Some("deadbeef"));
assert_eq!(s.auth_root_public_key.as_deref(), Some("cafe"));
assert_eq!(s.bootstrap_secret.as_deref(), Some("boot"));
assert_eq!(s.max_upload_bytes, Some(1_048_576));
assert_eq!(s.upload_idle_timeout_secs, Some(30));
assert_eq!(s.max_concurrent_uploads, Some(16));
assert_eq!(s.http_redirect_addr, Some("0.0.0.0:80".parse().unwrap()));
assert_eq!(s.default_site.as_deref(), Some("construens"));
assert_eq!(s.pop_origin.as_deref(), Some("https://cp.example.com"));
assert!(s.protect_previews);
assert!(s.cluster_rate_limit);
assert!(s.shared_cache_coherence);
}
#[test]
fn serve_kv_knobs_full_extract() {
let s = serve_from_env(&[
("BOATRAMP_KV_CHECKPOINT_INTERVAL", "0"),
("BOATRAMP_KV_CLOSE_DEADLINE", "30"),
("BOATRAMP_KV_GC_INTERVAL", "600"),
("BOATRAMP_KV_GC_MIN_AGE", "3600"),
("BOATRAMP_KV_SQL_KIND", "sqlite"),
("BOATRAMP_KV_SQL_PATH", "/data/kv.db"),
("BOATRAMP_KV_SQL_URL_ENV", "KV_URL"),
("BOATRAMP_KV_SQL_POOL_MAX", "8"),
]);
let kv = s.kv.expect("serve.kv materialised");
assert_eq!(
kv.checkpoint_interval(),
None,
"Some(0) disables the cadence"
);
let slate = kv.slatedb.clone().expect("serve.kv.slatedb materialised");
assert_eq!(slate.checkpoint_interval, Some(0));
assert_eq!(slate.close_deadline, Some(30));
assert_eq!(slate.gc_interval, Some(600));
assert_eq!(slate.gc_min_age, Some(3600));
let sql = kv.sql.expect("serve.kv.sql materialised");
assert_eq!(sql.kind, "sqlite");
assert_eq!(sql.path.as_deref(), Some("/data/kv.db"));
assert_eq!(sql.url_env.as_deref(), Some("KV_URL"));
assert_eq!(sql.pool_max, Some(8));
}
#[test]
fn overlay_honors_every_serve_var() {
let mut unhandled = Vec::new();
for (var, _) in SERVE_SCALARS.iter().chain(SERVE_KV) {
if env_overlay_dict(&map(&[(var, "x")])).is_empty() {
unhandled.push(*var);
}
}
assert!(
unhandled.is_empty(),
"overlay produced nothing for these serve vars: {unhandled:?}"
);
}
#[test]
fn flag_env_file_precedence() {
let path = std::env::temp_dir().join(format!(
"brtest-figment-{}-{:?}.cfg",
std::process::id(),
std::thread::current().id()
));
std::fs::write(
&path,
r#"( serve: ( s3_bucket: "from-file", s3_region: "file-region", s3_endpoint: "file-endpoint" ) )"#,
)
.unwrap();
let env = map(&[
("BOATRAMP_S3_BUCKET", "from-env"),
("BOATRAMP_S3_ENDPOINT", "from-env-endpoint"),
]);
let flags: BTreeMap<String, String> =
[("BOATRAMP_S3_BUCKET".to_string(), "from-flag".to_string())]
.into_iter()
.collect();
let cfg =
load_with_figment(&path, None, &env, flag_overlay_dict(&flags)).expect("resolve");
let serve = cfg.serve.expect("serve");
assert_eq!(
serve.s3_bucket.as_deref(),
Some("from-flag"),
"flag beats env+file"
);
assert_eq!(
serve.s3_endpoint.as_deref(),
Some("from-env-endpoint"),
"env beats file when no flag"
);
assert_eq!(
serve.s3_region.as_deref(),
Some("file-region"),
"file survives when neither env nor flag set it"
);
std::fs::remove_file(&path).ok();
}
#[test]
fn dump_redacts_secrets() {
let cfg = figment_env(&[
("BOATRAMP_AUTH_ROOT_PRIVATE_KEY", "deadbeefsecret"),
("BOATRAMP_BOOTSTRAP_SECRET", "topsecretboot"),
("BOATRAMP_AZURE_ACCESS_KEY", "azuresecretkey"),
("BOATRAMP_DEFAULT_SITE", "construens"),
]);
let dump = dump_redacted(&cfg).expect("dump");
for leaked in ["deadbeefsecret", "topsecretboot", "azuresecretkey"] {
assert!(
!dump.contains(leaked),
"config dump leaked a secret value: {leaked}\n{dump}"
);
}
assert!(dump.contains("<redacted>"), "secrets shown as <redacted>");
assert!(dump.contains("construens"), "non-secret values are shown");
}
#[test]
fn dump_redacts_signer_key_and_join_token() {
let file = r#"(
serve: ( signer: Local( private_key: "SIGNERPRIVKEYSECRET" ) ),
cluster: ( listen: "10.0.0.2:7000", join_token: "RAWJOINTOKENSECRET" ),
)"#;
let cfg = ServerConfig::parse_with_format(file, ConfigFormat::Ron).expect("parse");
let dump = cfg.dump_redacted().expect("dump");
assert!(
!dump.contains("SIGNERPRIVKEYSECRET"),
"signer private_key leaked:\n{dump}"
);
assert!(
!dump.contains("RAWJOINTOKENSECRET"),
"cluster join_token leaked:\n{dump}"
);
assert!(dump.contains("<redacted>"));
}
#[test]
fn trailing_whitespace_on_bool_and_enum_still_parses() {
let cfg = resolve_from_env(&map(&[
("BOATRAMP_SECURITY_REQUIRE_TENANCY_DECLARATION", "true\n"),
("BOATRAMP_COMPUTE_MANAGED_DB_PRIVILEGE", " rootless \n"),
]))
.expect("extract despite trailing whitespace");
assert_eq!(
cfg.security
.expect("security")
.overrides
.require_tenancy_declaration,
Some(true),
"a trailing-newline bool must parse as true"
);
assert_eq!(
cfg.compute.expect("compute").managed_db_privilege,
ManagedDbPrivilege::Rootless,
"a whitespace-padded enum must parse"
);
}
#[test]
fn file_only_structured_fields_round_trip() {
let file = r#"(
serve: ( listeners: {
"edge": (
component: "tcp-echo", project: "proj", site: "site",
bind: "0.0.0.0:7100",
tenancy: (mode: "scoped", column: "tenant_id"),
),
} ),
security: (
profile: "multi-tenant",
profiles: { "preview": ( allow_guest_email: true ) },
projects: { "proj": ( allow_cross_tenant_db: true ) },
),
)"#;
let cfg = load_with_figment(
write_temp_cfg(file).as_path(),
None,
&EnvSource::Map(BTreeMap::new()),
Dict::new(),
)
.expect("resolve file through the pipeline");
let serve = cfg.serve.expect("serve");
let l = serve.listeners.get("edge").expect("listener edge survived");
assert_eq!(l.component, "tcp-echo");
match l.tenancy.as_ref().expect("tenancy survived (Some)") {
boatramp_core::tenancy::Tenancy::Scoped { column, .. } => {
assert_eq!(column, "tenant_id");
}
other => panic!("tenancy did not round-trip as Scoped: {other:?}"),
}
let sec = cfg.security.expect("security");
assert!(
sec.profiles
.get("preview")
.expect("profile survived")
.allow_guest_email
== Some(true)
);
assert!(
sec.projects
.get("proj")
.expect("project override survived")
.allow_cross_tenant_db
== Some(true)
);
}
fn write_temp_cfg(text: &str) -> std::path::PathBuf {
use std::sync::atomic::{AtomicU32, Ordering};
static N: AtomicU32 = AtomicU32::new(0);
let path = std::env::temp_dir().join(format!(
"brtest-rt-{}-{}.cfg",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
std::fs::write(&path, text).expect("write temp cfg");
path
}
}
}