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),
}
#[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 ServerConfig {
pub fn parse(text: &str) -> Result<Self, ConfigError> {
Ok(ron_options().from_str(text)?)
}
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, 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>,
}
pub const BOATRAMP_KERNEL_SIGNING_PUBKEY: &str =
"es256:02c4e4af2e9cba6ba6745c513f193622e6674a8b2d0187ebea5612f5b46a7eade4";
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: vec![
"cf1e590a9e642be3667131ca35fbf390378a457d8908169d2a169608e299d974".to_string(),
"d0dc2098ab2a2a3c1bc72ab61dc85d9e464d798d7e55b6b80525db5ca2f00c5a".to_string(),
],
region: None,
docker_endpoint: boatramp_docker::DockerEndpoint::default(),
docker_volume_mode: boatramp_docker::DockerVolumeMode::default(),
sql_shim_url: None,
}
}
}
#[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,
}
#[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 pool_max: Option<u32>,
pub read_only: bool,
pub allow_preview: bool,
pub connect_timeout_secs: Option<u64>,
}
#[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()
}
#[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);
}
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());
}
}