use std::collections::BTreeMap;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use boatramp_core::config::DeployConfig;
use serde::Deserialize;
pub fn ron_options() -> ron::Options {
ron::Options::default().with_default_extension(ron::extensions::Extensions::IMPLICIT_SOME)
}
#[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("routing: {0}")]
Routing(#[from] boatramp_core::ConfigError),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error("environment variable {var}: {reason}")]
Env {
var: String,
reason: 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> {
let config: Self = ron_options().from_str(text)?;
config.routing.compile_check()?;
Ok(config)
}
pub fn load(path: &Path) -> Result<Self, ConfigError> {
match std::fs::read_to_string(path) {
Ok(contents) => Self::parse(&contents).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, 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, 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, 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(),
}
}
}
impl ServerConfig {
pub fn parse(text: &str) -> Result<Self, ConfigError> {
Ok(ron_options().from_str(text)?)
}
pub fn load(path: &Path) -> Result<Self, ConfigError> {
let mut config = match std::fs::read_to_string(path) {
Ok(contents) => Self::parse(&contents).map_err(|err| ConfigError::File {
path: path.display().to_string(),
source: Box::new(err),
})?,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Self::default(),
Err(err) => return Err(err.into()),
};
config.apply_env_overrides(&EnvSource::Process)?;
Ok(config)
}
fn apply_env_overrides(&mut self, source: &EnvSource) -> Result<(), ConfigError> {
if source.any(COMPUTE_ENV_VARS) {
let compute = self.compute.get_or_insert_with(ComputeConfig::default);
if let Some(v) = source.get("BOATRAMP_COMPUTE_BRIDGE") {
compute.bridge = v;
}
if let Some(v) = source.get("BOATRAMP_COMPUTE_SUBNET") {
compute.subnet = v;
}
if let Some(v) = source.parse("BOATRAMP_COMPUTE_VCPUS")? {
compute.vcpus = v;
}
if let Some(v) = source.parse("BOATRAMP_COMPUTE_MEM_MIB")? {
compute.mem_mib = v;
}
if let Some(v) = source.get("BOATRAMP_COMPUTE_REGION") {
compute.region = Some(v);
}
if let Some(v) = source.get("BOATRAMP_COMPUTE_SQL_SHIM_URL") {
compute.sql_shim_url = Some(v);
}
if let Some(v) = source.parse_enum(
"BOATRAMP_COMPUTE_MANAGED_DB_PRIVILEGE",
&[
("rootless", ManagedDbPrivilege::Rootless),
("caps", ManagedDbPrivilege::Caps),
],
)? {
compute.managed_db_privilege = v;
}
if let Some(v) = source.parse_enum(
"BOATRAMP_COMPUTE_DOCKER_ENDPOINT",
&[
("published", boatramp_docker::DockerEndpoint::Published),
("bridge", boatramp_docker::DockerEndpoint::Bridge),
],
)? {
compute.docker_endpoint = v;
}
if let Some(v) = source.parse_enum(
"BOATRAMP_COMPUTE_DOCKER_VOLUME_MODE",
&[
("named", boatramp_docker::DockerVolumeMode::Named),
("bind", boatramp_docker::DockerVolumeMode::Bind),
],
)? {
compute.docker_volume_mode = v;
}
if let Some(v) = source.parse_list("BOATRAMP_COMPUTE_KERNEL_SIGNING_PUBKEYS") {
compute.kernel_signing_pubkeys = v;
}
if let Some(v) = source.parse_list("BOATRAMP_COMPUTE_KERNEL_ALLOWED_HASHES") {
compute.kernel_allowed_hashes = v;
}
}
if source.any(SECURITY_ENV_VARS) {
let security = self
.security
.get_or_insert_with(boatramp_core::security::SecurityConfig::default);
if let Some(v) = source.get("BOATRAMP_SECURITY_PROFILE") {
security.profile = Some(v);
}
let o = &mut security.overrides;
if let Some(v) =
source.parse_bool("BOATRAMP_SECURITY_ALLOW_UNAUTHENTICATED_PUBLIC_BIND")?
{
o.allow_unauthenticated_public_bind = Some(v);
}
if let Some(v) = source.parse("BOATRAMP_SECURITY_MAX_UPLOAD_BYTES")? {
o.max_upload_bytes = Some(v);
}
if let Some(v) = source.parse_bool("BOATRAMP_SECURITY_ALLOW_SITE_UNIX_UPSTREAMS")? {
o.allow_site_unix_upstreams = Some(v);
}
if let Some(v) = source.parse_bool("BOATRAMP_SECURITY_ALLOW_SITE_PRIVATE_UPSTREAMS")? {
o.allow_site_private_upstreams = Some(v);
}
if let Some(v) = source.parse_bool("BOATRAMP_SECURITY_ALLOW_GUEST_PRIVATE_EGRESS")? {
o.allow_guest_private_egress = Some(v);
}
if let Some(v) = source.parse_bool("BOATRAMP_SECURITY_ALLOW_GUEST_SELF_EGRESS")? {
o.allow_guest_self_egress = Some(v);
}
if let Some(v) = source.parse("BOATRAMP_SECURITY_MAX_HANDLER_BLOB_BYTES")? {
o.max_handler_blob_bytes = Some(v);
}
if let Some(v) = source.parse("BOATRAMP_SECURITY_MAX_COMPONENT_BYTES")? {
o.max_component_bytes = Some(v);
}
if let Some(v) = source.parse_bool("BOATRAMP_SECURITY_OIDC_REQUIRE_AUDIENCE")? {
o.oidc_require_audience = Some(v);
}
if let Some(v) = source.parse_bool("BOATRAMP_SECURITY_DOMAIN_VERIFY_ALLOW_PRIVATE")? {
o.domain_verify_allow_private = Some(v);
}
if let Some(v) = source.parse_bool("BOATRAMP_SECURITY_DOMAIN_VERIFY_SELF_SERVE")? {
o.domain_verify_self_serve = Some(v);
}
if let Some(v) = source.parse_bool("BOATRAMP_SECURITY_ALLOW_SHARED_KERNEL_COMPUTE")? {
o.allow_shared_kernel_compute = Some(v);
}
if let Some(v) = source.parse_bool("BOATRAMP_SECURITY_ALLOW_COMPUTE_EXEC")? {
o.allow_compute_exec = Some(v);
}
if let Some(v) = source.parse_bool("BOATRAMP_SECURITY_RATELIMIT_FAIL_OPEN")? {
o.ratelimit_fail_open = Some(v);
}
if let Some(v) = source.parse_bool("BOATRAMP_SECURITY_ALLOW_IMPLICIT_ROUTING")? {
o.allow_implicit_routing = Some(v);
}
if let Some(v) = source.parse_bool("BOATRAMP_SECURITY_REQUIRE_POP")? {
o.require_pop = Some(v);
}
if let Some(v) = source.parse_bool("BOATRAMP_SECURITY_REQUIRE_DOMAIN_VERIFICATION")? {
o.require_domain_verification = Some(v);
}
}
if source.any(SQL_ENV_VARS) {
let handlers = self.handlers.get_or_insert_with(HandlersConfig::default);
let sql = handlers
.bindings
.sql
.get_or_insert_with(SqlBindingConfig::default);
if let Some(v) = source.get("BOATRAMP_HANDLERS_SQL_DIR") {
sql.dir = Some(PathBuf::from(v));
}
if let Some(v) = source.get("BOATRAMP_HANDLERS_SQL_URL") {
sql.url = Some(v);
}
if let Some(v) = source.get("BOATRAMP_HANDLERS_SQL_ADMIN_URL") {
sql.admin_url = Some(v);
}
if let Some(v) = source.get("BOATRAMP_HANDLERS_SQL_REPLICA_URL") {
sql.replica_url = Some(v);
}
if let Some(v) = source.get("BOATRAMP_HANDLERS_SQL_TOKEN_ENV") {
sql.token_env = Some(v);
}
if let Some(v) = source.get("BOATRAMP_HANDLERS_SQL_ADMIN_TOKEN_ENV") {
sql.admin_token_env = Some(v);
}
if let Some(v) = source.get("BOATRAMP_HANDLERS_SQL_PREVIEW_MODE") {
sql.preview_mode = Some(v);
}
if let Some(v) = source.get("BOATRAMP_HANDLERS_SQL_PREVIEW_INIT") {
sql.preview_init = Some(PathBuf::from(v));
}
}
if source.any_with_prefix(SQL_DB_ENV_PREFIX) {
let handlers = self.handlers.get_or_insert_with(HandlersConfig::default);
let sql = handlers
.bindings
.sql
.get_or_insert_with(SqlBindingConfig::default);
for name in source.sql_database_names() {
let key = if name == "DEFAULT" {
String::new()
} else {
name.clone()
};
let db = sql.databases.entry(key).or_default();
let prefix = format!("{SQL_DB_ENV_PREFIX}{name}_");
if let Some(v) = source.get(&format!("{prefix}KIND")) {
db.kind = v;
}
if let Some(v) = source.get(&format!("{prefix}URL_ENV")) {
db.url_env = v;
}
if let Some(v) = source.get(&format!("{prefix}READ_URL_ENV")) {
db.read_url_env = Some(v);
}
if let Some(v) = source.get(&format!("{prefix}COMPUTE")) {
db.compute = Some(v);
}
if let Some(v) = source.get(&format!("{prefix}DATABASE")) {
db.database = Some(v);
}
if let Some(v) = source.get(&format!("{prefix}USER")) {
db.user = Some(v);
}
if let Some(v) = source.get(&format!("{prefix}PASSWORD_ENV")) {
db.password_env = Some(v);
}
if let Some(v) = source.parse(&format!("{prefix}POOL_MAX"))? {
db.pool_max = Some(v);
}
if let Some(v) = source.parse_bool(&format!("{prefix}READ_ONLY"))? {
db.read_only = v;
}
if let Some(v) = source.parse_bool(&format!("{prefix}ALLOW_PREVIEW"))? {
db.allow_preview = v;
}
if let Some(v) = source.parse(&format!("{prefix}CONNECT_TIMEOUT_SECS"))? {
db.connect_timeout_secs = Some(v);
}
if let Some(v) = source.get(&format!("{prefix}IMAGE")) {
db.image = Some(v);
}
if let Some(v) = source.parse(&format!("{prefix}VOLUME_SIZE_MIB"))? {
db.volume_size_mib = Some(v);
}
}
}
if source.any(SECRETS_ENV_VARS) {
let secrets = self.secrets.get_or_insert_with(SecretsConfig::default);
if let Some(v) = source.get("BOATRAMP_SECRETS_ENVELOPE") {
secrets.envelope = v;
}
if let Some(v) = source.get("BOATRAMP_SECRETS_KEK_FILE") {
secrets.kek_file = Some(PathBuf::from(v));
}
if source.any(SECRETS_VAULT_ENV_VARS) {
let vault = secrets
.vault
.get_or_insert_with(VaultSecretsConfig::default);
if let Some(v) = source.get("BOATRAMP_SECRETS_VAULT_ADDR") {
vault.addr = v;
}
if let Some(v) = source.get("BOATRAMP_SECRETS_VAULT_KEY") {
vault.key = v;
}
if let Some(v) = source.get("BOATRAMP_SECRETS_VAULT_TOKEN_ENV") {
vault.token_env = v;
}
}
}
if source.any(CLUSTER_ENV_VARS) {
let listen = source.parse::<SocketAddr>("BOATRAMP_CLUSTER_LISTEN")?;
if self.cluster.is_none() {
if let Some(listen) = listen {
self.cluster = Some(ClusterConfig {
listen,
root_pubkeys: Vec::new(),
seeds: Vec::new(),
join_token: None,
store_dir: None,
mesh: None,
});
}
}
if let Some(cluster) = self.cluster.as_mut() {
if let Some(v) = listen {
cluster.listen = v;
}
if let Some(v) = source.parse_list("BOATRAMP_CLUSTER_ROOT_PUBKEYS") {
cluster.root_pubkeys = v;
}
if let Some(v) = source.parse_list("BOATRAMP_CLUSTER_SEEDS") {
cluster.seeds = v;
}
if let Some(v) = source.get("BOATRAMP_CLUSTER_JOIN_TOKEN") {
cluster.join_token = Some(v);
}
if let Some(v) = source.get("BOATRAMP_CLUSTER_STORE_DIR") {
cluster.store_dir = Some(PathBuf::from(v));
}
if source.any(CLUSTER_MESH_ENV_VARS) {
let mesh = cluster.mesh.get_or_insert_with(MeshConfig::default);
if let Some(v) = source.get("BOATRAMP_CLUSTER_MESH_KEY_FILE") {
mesh.key_file = Some(PathBuf::from(v));
}
if let Some(v) = source.get("BOATRAMP_CLUSTER_MESH_KEY_ROTATION") {
mesh.key_rotation = Some(v);
}
if let Some(v) = source.get("BOATRAMP_CLUSTER_MESH_JOIN_TOKEN_TTL") {
mesh.join_token_ttl = Some(v);
}
if let Some(v) =
source.parse_bool("BOATRAMP_CLUSTER_MESH_GATE_CLIENT_WRITES")?
{
mesh.gate_client_writes = Some(v);
}
}
}
}
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_MANAGED_DB_PRIVILEGE",
"BOATRAMP_COMPUTE_DOCKER_ENDPOINT",
"BOATRAMP_COMPUTE_DOCKER_VOLUME_MODE",
"BOATRAMP_COMPUTE_KERNEL_SIGNING_PUBKEYS",
"BOATRAMP_COMPUTE_KERNEL_ALLOWED_HASHES",
];
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_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",
];
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",
];
const SQL_DB_ENV_PREFIX: &str = "BOATRAMP_HANDLERS_SQL_DB_";
const SQL_DB_FIELD_SUFFIXES: &[&str] = &[
"_CONNECT_TIMEOUT_SECS",
"_VOLUME_SIZE_MIB",
"_READ_URL_ENV",
"_PASSWORD_ENV",
"_ALLOW_PREVIEW",
"_URL_ENV",
"_DATABASE",
"_READ_ONLY",
"_POOL_MAX",
"_COMPUTE",
"_IMAGE",
"_KIND",
"_USER",
];
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",
];
const CLUSTER_MESH_ENV_VARS: &[&str] = &[
"BOATRAMP_CLUSTER_MESH_KEY_FILE",
"BOATRAMP_CLUSTER_MESH_KEY_ROTATION",
"BOATRAMP_CLUSTER_MESH_JOIN_TOKEN_TTL",
"BOATRAMP_CLUSTER_MESH_GATE_CLIENT_WRITES",
];
enum EnvSource {
Process,
#[cfg(test)]
Map(BTreeMap<String, String>),
}
impl EnvSource {
fn get(&self, var: &str) -> Option<String> {
let raw = match self {
Self::Process => std::env::var(var).ok(),
#[cfg(test)]
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 any_with_prefix(&self, prefix: &str) -> bool {
self.names()
.any(|name| name.starts_with(prefix) && self.get(&name).is_some())
}
fn names(&self) -> Box<dyn Iterator<Item = String> + '_> {
match self {
Self::Process => Box::new(std::env::vars().map(|(k, _)| k)),
#[cfg(test)]
Self::Map(m) => Box::new(m.keys().cloned()),
}
}
fn parse_enum<T: Copy>(
&self,
var: &str,
variants: &[(&str, T)],
) -> Result<Option<T>, ConfigError> {
match self.get(var) {
Some(raw) => {
let lower = raw.trim().to_ascii_lowercase();
variants
.iter()
.find(|(name, _)| *name == lower)
.map(|(_, v)| Some(*v))
.ok_or_else(|| ConfigError::Env {
var: var.to_string(),
reason: format!(
"expected one of {}, got {raw:?}",
variants
.iter()
.map(|(n, _)| *n)
.collect::<Vec<_>>()
.join("/")
),
})
}
None => Ok(None),
}
}
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 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()
})
}
fn parse<T>(&self, var: &str) -> Result<Option<T>, ConfigError>
where
T: std::str::FromStr,
T::Err: std::fmt::Display,
{
match self.get(var) {
Some(raw) => raw.parse::<T>().map(Some).map_err(|e| ConfigError::Env {
var: var.to_string(),
reason: e.to_string(),
}),
None => Ok(None),
}
}
fn parse_bool(&self, var: &str) -> Result<Option<bool>, ConfigError> {
match self.get(var) {
Some(raw) => match raw.trim().to_ascii_lowercase().as_str() {
"true" | "1" | "yes" | "on" => Ok(Some(true)),
"false" | "0" | "no" | "off" => Ok(Some(false)),
other => Err(ConfigError::Env {
var: var.to_string(),
reason: format!("expected a boolean (true/false), got {other:?}"),
}),
},
None => Ok(None),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ManagedDbPrivilege {
#[default]
Rootless,
Caps,
}
#[derive(Debug, Clone, 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, skip_serializing_if = "Option::is_none")]
#[cfg_attr(not(feature = "handlers"), allow(dead_code))]
pub sql_shim_url: Option<String>,
#[serde(default)]
#[cfg_attr(not(feature = "handlers"), allow(dead_code))]
pub managed_db_privilege: ManagedDbPrivilege,
}
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(),
sql_shim_url: None,
managed_db_privilege: ManagedDbPrivilege::default(),
}
}
}
#[cfg_attr(not(feature = "cluster"), allow(dead_code))]
#[derive(Debug, Clone, 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, 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, Deserialize)]
#[serde(default)]
pub struct HandlersConfig {
pub bindings: BindingsConfig,
pub pooling: bool,
pub sync_max_timeout_ms: Option<u64>,
pub async_max_timeout_ms: Option<u64>,
pub async_max_concurrency: Option<usize>,
pub async_max_fuel: 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>,
}
#[cfg_attr(not(feature = "handlers"), allow(dead_code))]
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct BindingsConfig {
pub sql: Option<SqlBindingConfig>,
}
#[cfg_attr(not(feature = "handlers"), allow(dead_code))]
#[derive(Debug, Clone, Default, 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>,
}
#[cfg_attr(not(feature = "handlers"), allow(dead_code))]
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct ExternalDatabaseConfig {
pub kind: String,
pub url_env: String,
pub read_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>,
}
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());
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, 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, 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, Deserialize)]
#[serde(default)]
pub struct ServeConfig {
pub addr: Option<SocketAddr>,
pub data_dir: Option<PathBuf>,
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>,
}
#[cfg_attr(not(feature = "console"), allow(dead_code))]
#[derive(Debug, Clone, Default, 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)]
pub struct BuildConfig {
pub command: String,
#[serde(default)]
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 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");
match err {
ConfigError::Env { var, .. } => assert_eq!(var, "BOATRAMP_SECURITY_REQUIRE_POP"),
other => panic!("expected ConfigError::Env, got {other:?}"),
}
}
#[test]
fn env_number_parse_error_names_the_variable() {
let mut cfg = ServerConfig::default();
let err = cfg
.apply_env_overrides(&env(&[("BOATRAMP_COMPUTE_VCPUS", "lots")]))
.expect_err("garbage number is rejected");
match err {
ConfigError::Env { var, .. } => assert_eq!(var, "BOATRAMP_COMPUTE_VCPUS"),
other => panic!("expected ConfigError::Env, got {other:?}"),
}
}
#[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("")
.expect("the default `\"\"`-named database was created from DEFAULT");
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 env_enum_parse_error_names_the_variable_and_variants() {
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");
match err {
ConfigError::Env { var, reason } => {
assert_eq!(var, "BOATRAMP_COMPUTE_MANAGED_DB_PRIVILEGE");
assert!(reason.contains("rootless") && reason.contains("caps"));
}
other => panic!("expected ConfigError::Env, got {other:?}"),
}
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();
cfg.apply_env_overrides(&env(&[("BOATRAMP_CLUSTER_SEEDS", "https://10.0.0.1:8080")]))
.expect("applies");
assert!(
cfg.cluster.is_none(),
"no listen + no file section ⇒ no cluster"
);
}
#[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 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_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());
}
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());
}
}