use anyhow::{Context, Result}; use serde::Deserialize; use std::net::{IpAddr, ToSocketAddrs}; use std::path::PathBuf; #[derive(Debug, Clone, Deserialize)] pub struct Config { pub listen: String, pub db_path: PathBuf, pub topology_path: PathBuf, /// The runtime hostname (`/proc/sys/kernel/hostname`) this daemon is /// permitted to build on. `build::run` refuses to compile unless the live /// host matches, so a `sandod` misdeployed onto a prod/serving node (e.g. /// Hetzner) cannot build there — "never build on prod" becomes an invariant /// rather than a code-path accident. Required: there is no safe default. pub build_host: String, /// MM-local checkout scratch dir (per-sha worktrees live here). pub workdir: PathBuf, /// MM-local releases dir (`releases//` and `current` live here). pub release_root: PathBuf, /// Scratch postgres DB url used by `migration_dry_run`. Sando drops and /// recreates the schema on every run, so do not point this at anything /// you care about. Validated at load to address loopback only (127.0.0.1, /// `::1`, `localhost`, or a local unix socket): a gate that DROPs a database /// must never reach off-box, and a non-loopback host refuses startup (see /// `validate`). #[serde(default)] pub scratch_db_url: Option, /// Role that owns the restored objects in a prod dump. `pg_dump` emits /// `ALTER ... OWNER TO ` for every object, so the role must exist in /// the scratch cluster before `migration_dry_run` restores — a superuser /// connection does not conjure it. `reset_scratch` creates it (NOLOGIN) and /// grants it CREATE on public, so a fresh box needs no manual SQL. /// /// Interpolated into DDL as an identifier, so it is restricted to /// `[A-Za-z0-9_]+` at load (see `validate`) rather than quoted at use. #[serde(default = "default_scratch_owner_role")] pub scratch_owner_role: String, /// Loopback port the `boot_smoke` gate tells the staged artifact to bind /// (`SANDO_BOOT_SMOKE_PORT`), then probes `GET /health` on. Lets the gate /// prove readiness, not just liveness. Fixed rather than ephemeral so the /// gate knows where to probe; builds are serialized so there's no contention. #[serde(default = "default_boot_smoke_port")] pub boot_smoke_port: u16, /// Loopback port the `code_smoke` gate tells the freshly-built binary to /// bind (`HOST=127.0.0.1 PORT=`), then probes `GET /health` on after /// migrating + seeding a throwaway DB. Separate from `boot_smoke_port` only /// for clarity — the two gates never run concurrently (builds are /// serialized). #[serde(default = "default_code_smoke_port")] pub code_smoke_port: u16, /// Names of cargo bin targets the server crate produces (files under /// `target/release/`). First entry is the primary unit (referenced from /// the systemd unit's ExecStart). Defaults to `["server"]`; MNW ships /// `["makenotwork", "mnw-admin"]`. #[serde(default = "default_bin_names")] pub bin_names: Vec, /// Root for per-gate run logs (`//.log`). /// Served via `GET /logs/{version}/{gate}`. Defaults to `/srv/sando/logs`. #[serde(default = "default_logs_root")] pub logs_root: PathBuf, /// Shared cargo target dir. When set, every `cargo build`/`cargo test` the /// pipeline runs uses this one `CARGO_TARGET_DIR` instead of each per-sha /// worktree's own `target/`, so a 1-line diff reuses the previous sha's /// compiled dependencies (a ~10-min clean build becomes a 1–2-min /// incremental one). Safe because builds are serialized — a new `/rebuild` /// aborts the in-flight one — so no two cargo invocations ever share the /// dir concurrently. Unset = per-worktree `target/` (the historical /// behavior). Cargo creates the dir if absent. #[serde(default)] pub cargo_target_dir: Option, /// Non-binary contents to stage into each release dir alongside /// `bin_names`. Each entry copies `worktree/` into /// `/`. `required=false` makes a missing source a warn /// (older shas missing one of these don't break sando mid-bisect); /// `required=true` errors. Default is empty — projects opt-in via /// daemon config so the sando code stays project-agnostic. #[serde(default)] pub release_contents: Vec, /// Wall-clock ceiling (seconds) for the `cargo_test` and `migration_dry_run` /// gates. A hung suite (deadlocked test, wedged child) otherwise blocks the /// pipeline until a new `/rebuild` aborts it; past the ceiling the gate is /// killed and fails with `GateFailure::Timeout`. Default 2400s (40 min) — /// generous for a full release test suite, fatal only to a genuine hang. #[serde(default = "default_gate_timeout_secs")] pub gate_timeout_secs: u64, /// Extra crates built from the same worktree/sha as the server and shipped /// in the release bundle, so a service that shares the server's contract /// (e.g. `mnw-cli`, which talks to `/api/internal/*`) can't drift out of /// lockstep. Each is compiled after the server; a companion that fails to /// build fails the whole pipeline — that is the lockstep guarantee. Which /// nodes actually install a given companion is a per-node decision (see /// `Node::companions`); this list only says what to build + stage. Default /// empty, so the sando code stays project-agnostic. #[serde(default, rename = "companion")] pub companions: Vec, /// Crates the `cargo_test` gate runs, in order. The gate used to hardcode /// `worktree/server`, so everything else in the repo shipped ungated — /// including `mnw-cli`, which is built as a companion and installed onto /// prod-1. Default is the historical single `server` entry, so a project /// that configures nothing keeps today's behavior. #[serde(default = "default_test_targets", rename = "test_target")] pub test_targets: Vec, /// Frontend builds the `code_smoke` gate runs, in order, before it touches a /// database. Each is an npm project whose compiled output is served by the /// binary but is not produced by `cargo build` in any way cargo can fail on: /// both MNW frontends compile from a build script that reports a `tsc` error /// as a `cargo::warning` and lets the Rust build succeed against whatever /// `static/dist/` already holds, deliberately, so a type error in a chat /// widget cannot stop the forum from compiling. The cost of that choice is /// that nothing downstream noticed either, and the deploy shipped a stale /// bundle. This is where the same failure is fatal. Default empty, so a /// project that configures nothing keeps today's behavior. #[serde(default, rename = "frontend_build")] pub frontend_builds: Vec, /// How old (hours) the fetched prod dump may be before `migration_dry_run` /// refuses to run against it. The gate restores whatever `backups` row is /// newest, and presence alone used to be the only check — so a fetch that /// stopped working left the gate passing green against an ever-older schema, /// which is the failure it exists to catch. Sando ran 45 days that way in /// June-July 2026. Default 48h: a daily fetch may miss one night without /// tripping this. #[serde(default = "default_backup_max_age_hours")] pub backup_max_age_hours: u32, } /// One npm project the `code_smoke` gate compiles. #[derive(Debug, Clone, Deserialize)] pub struct FrontendBuild { /// Directory under the worktree holding `package.json` /// (e.g. `server/frontend`). pub dir: PathBuf, /// npm script to run. Defaults to `build`, which is what emits the bundle /// the release actually serves; `typecheck` would prove less (it never /// writes `static/dist/`, so it cannot catch an emit failure). #[serde(default = "default_frontend_script")] pub script: String, } fn default_frontend_script() -> String { "build".into() } /// One crate's test suite, as run by the `cargo_test` gate. #[derive(Debug, Clone, Deserialize)] pub struct TestTarget { /// Directory under the worktree holding the crate's `Cargo.toml` /// (e.g. `server`, `shared/tagtree`). pub dir: PathBuf, /// Cargo features to enable. MNW's server needs `fast-tests`; most crates /// need none. #[serde(default)] pub features: Vec, /// Pass `--all-features` instead of naming features. Mutually exclusive /// with `features` (rejected at load). #[serde(default)] pub all_features: bool, /// Export `DATABASE_URL` / `TEST_DATABASE_URL` (pointing at /// `scratch_db_url`) for this crate's tests. Off by default: a crate using /// sqlx's offline query data goes *online* when `DATABASE_URL` is set and /// will fail to compile against the wrong database. #[serde(default)] pub scratch_db: bool, } fn default_test_targets() -> Vec { vec![TestTarget { dir: PathBuf::from("server"), features: vec!["fast-tests".into()], all_features: false, scratch_db: true, }] } /// A crate built alongside the server and staged into the release bundle under /// `companions//`. Referenced by `Node::companions[].name` to decide /// where (if anywhere) it deploys. #[derive(Debug, Clone, Deserialize)] pub struct Companion { /// Logical id, matched by a node's companion entry. Also the bundle subdir. pub name: String, /// Directory under the worktree holding the crate's `Cargo.toml` /// (e.g. `mnw-cli`). Built with `cargo build --release` in that dir. pub manifest_dir: PathBuf, /// Binary name produced under the crate's `target/release/`. pub bin: String, } /// A directory or file copied from the worktree into the staged release dir. /// Multiple entries with the same `dst` are allowed and merged (used by MNW /// to build `docs/` from three different worktree sources). #[derive(Debug, Clone, Deserialize)] pub struct ReleaseEntry { /// Path relative to the worktree root (e.g. `server/static`). pub src: PathBuf, /// Path relative to the release dir (e.g. `static`). Parent dirs are /// created as needed. pub dst: PathBuf, /// If true, a missing source aborts the build. If false, log warn + skip. #[serde(default)] pub required: bool, } /// The host component of a `postgres://` URL, or `None` when the URL addresses a /// local unix socket (no authority). Hand-parsed rather than pulling in a URL /// crate, matching the daemon's existing PG-URL handling in `gates.rs`. fn scratch_db_host(url: &str) -> Option { let after = url.find("://").map(|i| i + 3)?; let authority_end = url[after..] .find(['/', '?', '#']) .map_or(url.len(), |i| after + i); let authority = &url[after..authority_end]; // Drop userinfo: keep everything after the last '@' (`user:pass@host` → `host`). let host_port = authority.rsplit('@').next().unwrap_or(authority); // Split the host from an optional port. Bracketed IPv6 (`[::1]:5432`) first; // otherwise a hostname or IPv4, neither of which contains ':'. let host = if let Some(rest) = host_port.strip_prefix('[') { rest.split(']').next().unwrap_or("") } else { host_port.split(':').next().unwrap_or("") }; if host.is_empty() { None } else { Some(host.to_string()) } } /// Refuse a `scratch_db_url` that could reach a database off this box. /// `migration_dry_run` DROPs and recreates the scratch schema, so pointing it at /// a remote (a typo, or a config copied from staging) would wipe the wrong /// database. Loopback is absolute here — there is deliberately no /// `allow_remote_scratch_db` escape hatch for a database the daemon destroys. fn assert_scratch_db_loopback(url: &str) -> Result<()> { let Some(host) = scratch_db_host(url) else { return Ok(()); // no authority → local unix socket }; // A percent-encoded unix socket path (`postgres://%2Fvar%2Frun%2Fpg/db`) is // local. A decoded path would start with '/'; `%2f` is its encoded form. if host.starts_with('/') || host.to_ascii_lowercase().starts_with("%2f") { return Ok(()); } // An IP literal is classified without touching DNS. if let Ok(ip) = host.parse::() { anyhow::ensure!( ip.is_loopback(), "scratch_db_url host {host} is not loopback ({ip}); migration_dry_run DROPs and \ recreates this database, so it must never point off-box (use 127.0.0.1, ::1, \ localhost, or a local unix socket)", ); return Ok(()); } // A hostname: resolve and require every resolved address to be loopback. // Resolution failure fails closed — a name we cannot prove is local is not a // name we let a schema-dropping gate connect to. let addrs: Vec<_> = (host.as_str(), 0u16) .to_socket_addrs() .with_context(|| format!("resolving scratch_db_url host {host} to confirm it is loopback"))? .collect(); anyhow::ensure!( !addrs.is_empty(), "scratch_db_url host {host} resolved to no addresses; cannot confirm it is loopback", ); anyhow::ensure!( addrs.iter().all(|a| a.ip().is_loopback()), "scratch_db_url host {host} resolves off-box (not loopback); migration_dry_run DROPs and \ recreates this database, so it must never point off-box", ); Ok(()) } fn default_bin_names() -> Vec { vec!["server".into()] } fn default_scratch_owner_role() -> String { "makenotwork".into() } fn default_logs_root() -> PathBuf { PathBuf::from("/srv/sando/logs") } fn default_boot_smoke_port() -> u16 { 18181 } fn default_code_smoke_port() -> u16 { 18182 } fn default_gate_timeout_secs() -> u64 { 2400 } fn default_backup_max_age_hours() -> u32 { 48 } impl Config { /// Primary binary — the one the systemd unit's ExecStart points at. pub fn primary_bin(&self) -> &str { self.bin_names .first() .map_or("server", std::string::String::as_str) } pub fn load() -> Result { let path = std::env::var("SANDO_CONFIG").unwrap_or_else(|_| "sando-daemon.toml".into()); let raw = std::fs::read_to_string(&path) .with_context(|| format!("reading daemon config at {path}"))?; let cfg: Self = toml::from_str(&raw)?; cfg.validate()?; Ok(cfg) } /// Invariants the deserializer can't express. Runs at load (and so under /// `--check-config`), never at use — a bad value fails startup once, loudly, /// rather than at the first gate that happens to touch it. pub fn validate(&self) -> Result<()> { anyhow::ensure!( !self.scratch_owner_role.is_empty() && self .scratch_owner_role .bytes() .all(|b| b.is_ascii_alphanumeric() || b == b'_'), "scratch_owner_role must be non-empty and match [A-Za-z0-9_]+ (got {:?}); it is \ interpolated into DDL as a bare identifier", self.scratch_owner_role, ); anyhow::ensure!( !self.test_targets.is_empty(), "test_target list is empty; cargo_test would run nothing and pass. Omit the \ key entirely to get the default `server` target.", ); for t in &self.test_targets { anyhow::ensure!( !t.all_features || t.features.is_empty(), "test_target {} sets both all_features and features; pick one", t.dir.display(), ); } for f in &self.frontend_builds { anyhow::ensure!( !f.script.is_empty(), "frontend_build {} has an empty script; omit the key for the default `build`", f.dir.display(), ); } if let Some(url) = self.scratch_db_url.as_deref() { assert_scratch_db_loopback(url) .context("scratch_db_url must address a loopback (on-box) database")?; } Ok(()) } #[cfg(test)] pub fn for_tests() -> Self { Self { listen: "127.0.0.1:0".into(), db_path: PathBuf::from(":memory:"), topology_path: PathBuf::from("/tmp/sando-test-topology.toml"), build_host: "test-host".into(), workdir: PathBuf::from("/tmp/sando-test-workdir"), release_root: PathBuf::from("/tmp/sando-test-release-root"), scratch_db_url: None, scratch_owner_role: default_scratch_owner_role(), boot_smoke_port: default_boot_smoke_port(), code_smoke_port: default_code_smoke_port(), bin_names: vec!["server".into()], logs_root: PathBuf::from("/tmp/sando-test-logs"), release_contents: Vec::new(), cargo_target_dir: None, gate_timeout_secs: default_gate_timeout_secs(), companions: Vec::new(), test_targets: default_test_targets(), frontend_builds: Vec::new(), backup_max_age_hours: default_backup_max_age_hours(), } } } #[cfg(test)] mod tests { use super::*; const MINIMAL: &str = r#" listen = "127.0.0.1:7766" db_path = "./sando.db" topology_path = "../sando.toml" build_host = "fw13" workdir = "./work" release_root = "./releases" "#; #[test] fn test_targets_default_to_the_historical_server_entry() { // A project that configures nothing must keep the pre-config behavior: // the server crate, with fast-tests, against the scratch DB. let cfg: Config = toml::from_str(MINIMAL).unwrap(); assert_eq!(cfg.test_targets.len(), 1); let t = &cfg.test_targets[0]; assert_eq!(t.dir, PathBuf::from("server")); assert_eq!(t.features, ["fast-tests"]); assert!(t.scratch_db); assert!(!t.all_features); } #[test] fn test_targets_parse_as_a_list() { let raw = format!( "{MINIMAL}\nscratch_db_url = \"postgres:///x\"\n\ [[test_target]]\ndir = \"server\"\nfeatures = [\"fast-tests\"]\nscratch_db = true\n\ [[test_target]]\ndir = \"shared/tagtree\"\n\ [[test_target]]\ndir = \"shared/ops-exec\"\nall_features = true\n" ); let cfg: Config = toml::from_str(&raw).unwrap(); cfg.validate().unwrap(); let dirs: Vec<_> = cfg .test_targets .iter() .map(|t| t.dir.display().to_string()) .collect(); assert_eq!(dirs, ["server", "shared/tagtree", "shared/ops-exec"]); // Defaults for an entry that names only a dir: no features, no DB. assert!(cfg.test_targets[1].features.is_empty()); assert!(!cfg.test_targets[1].scratch_db); assert!(cfg.test_targets[2].all_features); } #[test] fn validate_rejects_an_empty_test_target_list() { // An explicit empty list would make cargo_test green having run nothing. let raw = format!("{MINIMAL}\ntest_target = []\n"); let cfg: Config = toml::from_str(&raw).unwrap(); let err = cfg.validate().unwrap_err().to_string(); assert!(err.contains("test_target list is empty"), "got: {err}"); } #[test] fn validate_rejects_all_features_together_with_features() { let raw = format!( "{MINIMAL}\n[[test_target]]\ndir = \"x\"\nall_features = true\nfeatures = [\"y\"]\n" ); let err = toml::from_str::(&raw) .unwrap() .validate() .unwrap_err() .to_string(); assert!(err.contains("pick one"), "got: {err}"); } #[test] fn scratch_db_without_a_url_is_not_a_config_error() { // `scratch_db` means "export the scratch URL if there is one", not // "require one" — the pre-config gate simply skipped the env when // scratch_db_url was unset, and a project with no postgres at all must // still boot. let raw = format!("{MINIMAL}\n[[test_target]]\ndir = \"server\"\nscratch_db = true\n"); toml::from_str::(&raw).unwrap().validate().unwrap(); } #[test] fn shipped_daemon_config_parses_and_validates() { // The real sando-daemon.toml next to this crate: catches a typo in the // test_target list before it wedges a build on the host. let raw = std::fs::read_to_string( std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("sando-daemon.toml"), ) .expect("sando-daemon.toml ships with the crate"); let cfg: Config = toml::from_str(&raw).expect("shipped config parses"); cfg.validate().expect("shipped config validates"); assert!( cfg.test_targets .iter() .any(|t| t.dir == std::path::Path::new("mnw-cli")), "the companion that installs onto prod-1 must be gated", ); // Both crates serve JS their build scripts compile best-effort, so an // unlisted frontend is an ungated bundle. for dir in ["server/frontend", "multithreaded/frontend"] { assert!( cfg.frontend_builds .iter() .any(|f| f.dir == std::path::Path::new(dir)), "{dir} must be gated: its build script swallows a tsc error", ); } } #[test] fn frontend_builds_default_to_empty_and_to_the_build_script() { let cfg: Config = toml::from_str(MINIMAL).unwrap(); assert!( cfg.frontend_builds.is_empty(), "a project with no frontend must configure nothing" ); let raw = format!("{MINIMAL}\n[[frontend_build]]\ndir = \"server/frontend\"\n"); let cfg: Config = toml::from_str(&raw).unwrap(); cfg.validate().unwrap(); assert_eq!(cfg.frontend_builds[0].script, "build"); } #[test] fn validate_rejects_an_empty_frontend_script() { let raw = format!("{MINIMAL}\n[[frontend_build]]\ndir = \"x\"\nscript = \"\"\n"); let err = toml::from_str::(&raw) .unwrap() .validate() .unwrap_err() .to_string(); assert!(err.contains("empty script"), "got: {err}"); } #[test] fn cargo_target_dir_parses_when_present() { let raw = format!("{MINIMAL}\ncargo_target_dir = \"/srv/sando/cargo-target\"\n"); let cfg: Config = toml::from_str(&raw).unwrap(); assert_eq!( cfg.cargo_target_dir.as_deref(), Some(std::path::Path::new("/srv/sando/cargo-target")) ); } #[test] fn cargo_target_dir_defaults_to_none() { let cfg: Config = toml::from_str(MINIMAL).unwrap(); assert!( cfg.cargo_target_dir.is_none(), "omitting it keeps the per-worktree target/" ); } #[test] fn gate_timeout_defaults_when_omitted() { let cfg: Config = toml::from_str(MINIMAL).unwrap(); assert_eq!( cfg.gate_timeout_secs, 2400, "omitting it keeps the 40-min ceiling" ); } #[test] fn gate_timeout_parses_when_present() { let raw = format!("{MINIMAL}\ngate_timeout_secs = 600\n"); let cfg: Config = toml::from_str(&raw).unwrap(); assert_eq!(cfg.gate_timeout_secs, 600); } #[test] fn companions_default_empty_and_parse_when_present() { let base: Config = toml::from_str(MINIMAL).unwrap(); assert!( base.companions.is_empty(), "omitting [[companion]] keeps it empty" ); let raw = format!( "{MINIMAL}\n[[companion]]\nname = \"mnw-cli\"\nmanifest_dir = \"mnw-cli\"\nbin = \"mnw-cli\"\n" ); let cfg: Config = toml::from_str(&raw).unwrap(); assert_eq!(cfg.companions.len(), 1); assert_eq!(cfg.companions[0].name, "mnw-cli"); assert_eq!( cfg.companions[0].manifest_dir, std::path::Path::new("mnw-cli") ); assert_eq!(cfg.companions[0].bin, "mnw-cli"); } #[test] fn scratch_owner_role_defaults_to_makenotwork() { let cfg: Config = toml::from_str(MINIMAL).unwrap(); assert_eq!(cfg.scratch_owner_role, "makenotwork"); cfg.validate().unwrap(); } #[test] fn scratch_owner_role_rejects_non_identifiers() { // It is interpolated into DDL as a bare identifier, so anything outside // [A-Za-z0-9_]+ must fail at load rather than reach the scratch cluster. for bad in ["", "mnw-owner", "own er", "own\"er", "x; DROP ROLE sando"] { let raw = format!("{MINIMAL}\nscratch_owner_role = {bad:?}\n"); let cfg: Config = toml::from_str(&raw).unwrap(); assert!(cfg.validate().is_err(), "should reject {bad:?}"); } } #[test] fn scratch_owner_role_accepts_a_plain_identifier() { let raw = format!("{MINIMAL}\nscratch_owner_role = \"app_owner_2\"\n"); let cfg: Config = toml::from_str(&raw).unwrap(); cfg.validate().unwrap(); assert_eq!(cfg.scratch_owner_role, "app_owner_2"); } #[test] fn scratch_db_url_accepts_loopback_and_socket_forms() { // Every shape a legitimate on-box scratch DB takes. IP literals and the // socket forms are classified without DNS; `localhost` resolves locally. for ok in [ "postgres://sando@127.0.0.1/sando_scratch", // the shipped form "postgres://sando:s3cret@127.0.0.1:5432/scratch", "postgres://sando@[::1]:5432/scratch", "postgres:///scratch", // unix socket, no host "postgres://sando@%2Fvar%2Frun%2Fpostgresql/scratch", // encoded socket dir "postgres://localhost/scratch", ] { assert!( assert_scratch_db_loopback(ok).is_ok(), "should accept on-box url {ok}" ); } } #[test] fn scratch_db_url_rejects_off_box_hosts() { // Non-loopback IP literals reject without any DNS lookup. for bad in [ "postgres://sando@10.1.2.3/scratch", "postgres://sando:pw@192.168.1.5:5432/scratch", "postgres://sando@[2001:db8::1]/scratch", ] { let err = assert_scratch_db_loopback(bad).unwrap_err().to_string(); assert!( err.contains("loopback") || err.contains("off-box"), "got: {err}" ); } } #[test] fn validate_rejects_a_non_loopback_scratch_db_url() { // A hostname that cannot be proven loopback fails closed at load. The // `.example` TLD (RFC 6761) never resolves, so this exercises the // resolution-failure path without depending on external DNS shape. let raw = format!( "{MINIMAL}\nscratch_db_url = \"postgres://sando@db.internal.example:5432/scratch\"\n" ); let cfg: Config = toml::from_str(&raw).unwrap(); assert!( cfg.validate().is_err(), "a non-loopback scratch_db_url must fail startup" ); } #[test] fn build_host_is_required() { // No safe default: a config without build_host must not parse, so the // no-build-on-prod guard can never be silently skipped. let without = MINIMAL.replace("build_host = \"fw13\"\n", ""); assert!(toml::from_str::(&without).is_err()); } }