//! Daemon and per-product configuration. //! //! Design: wiki [[sando-bento-boundary]]. //! //! The split here is the whole of "Sando ships more than one product". One //! daemon, one database, one bind address, one build host — and beneath that, N //! independent pipelines, each with its own repo, tiers, nodes, gates, release //! root and version history. [`DaemonConfig`] is the first set, [`AppConfig`] //! plus a [`Topology`](crate::topology::Topology) is one of the second. //! //! Almost everything that used to be "the config" turned out to be per-product: //! `bin_names`, `release_contents`, `companions`, `test_targets`, //! `migration_checks`, the scratch database, the smoke ports. Only the listen //! address and the DB path are genuinely about the daemon. That imbalance is why //! the single-product assumption was invisible for so long — nearly every field //! was already describing one product, with nothing naming which. //! //! **A config written before any of this still loads.** A file with no `[app.*]` //! tables is read as the single app `mnw`: the same file supplies the daemon //! keys and that app's pipeline. Sando's deployed `sando-daemon.toml` needs no //! edit, which matters because this daemon is the MNW deploy path and a config //! that has to be edited in lockstep with a binary is a way to brick it. use crate::domain::AppId; use anyhow::{Context, Result}; use serde::Deserialize; use std::collections::BTreeMap; use std::net::{IpAddr, ToSocketAddrs}; use std::path::{Path, PathBuf}; use std::sync::Arc; /// What the daemon itself needs, as opposed to what a product's pipeline needs. /// /// Deliberately small. A key belongs here only if it would be meaningless per /// product: one process binds one address and opens one database, so those two /// are the whole list. `build_host` is a near miss and is not here — it reads /// like a machine property, but "which host may compile this" is a per-product /// answer the moment two products can build on different machines. #[derive(Debug, Clone, Deserialize)] pub struct DaemonConfig { pub listen: String, pub db_path: PathBuf, /// Products this daemon ships, each pointing at its own pipeline config. /// Empty means the legacy single-app layout: this same file is `mnw`. #[serde(default, rename = "app")] pub apps: BTreeMap, } /// Where one product's pipeline config lives. #[derive(Debug, Clone, Deserialize)] pub struct AppSource { /// Path to the product's [`AppConfig`] TOML. Its `topology_path` points on /// to that product's tiers and nodes, so a product is two files, the same /// shape Sando already had for one. pub config: PathBuf, } /// One product's pipeline: what to build, what to prove about it, where it goes. #[derive(Debug, Clone, Deserialize)] pub struct AppConfig { /// Command the `page_smoke` gate runs, when the product has one. /// /// A shell command, run on the daemon host with `BASE` set to the tier's /// `public_url`. Red on a non-zero exit; its stdout becomes the gate log. /// /// Optional because a page smoke is a *web product's* gate. pom is a /// service with no pages, so leaving this unset is how it says so, and the /// gate reports blocked rather than inventing a pass. /// /// Not derived from the worktree: post-deploy gates have no checkout (the /// artifact may have been built elsewhere entirely), so the script has to /// live somewhere stable on the host and be named here. #[serde(default)] pub page_smoke_cmd: Option, /// Which product this is. Not read from the file: it is the key the daemon /// filed this config under, so a config cannot disagree with its own name. #[serde(skip)] pub id: AppId, pub topology_path: PathBuf, /// What this product's bundles run on, as `os/arch`, when Sando builds them /// itself. Left unset for a product whose artifacts arrive from a builder: /// an intake takes the platform from its record's provenance, which is the /// only place that answer is trustworthy when two architectures ship under /// one version. /// /// Unset is not a wildcard. A node declaring a platform refuses an artifact /// that records none, so setting this on a product means setting it on that /// product's nodes too (see [`crate::deploy::Placement`]). #[serde(default)] pub platform: Option, /// 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. There is no safe default. /// /// Unset declares the product **intake-only**: Sando never compiles it, and /// `build::run` refuses rather than picking a host. That is pom, which is /// built natively on two architectures by Bento and only ever handed to /// Sando as finished bytes (wiki [[sando-bento-boundary]]). Naming a build /// host for a product Sando must not build would be a claim the code would /// then be free to act on. #[serde(default)] pub build_host: Option, /// Host-local checkout scratch dir (per-sha worktrees live here). pub workdir: PathBuf, /// Host-local releases dir. Bundles are staged at `staging//`, /// then published content-addressed at `releases//` with /// `current` symlinked to the live one (see `crate::bundle`). 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/{run}/{gate}`, where `run` is the `build_runs.id` the /// gate ran for. 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, /// Extra environment the `code_smoke` gate hands the binary it just built, /// on top of the fixed set every invocation shares. Project-supplied, so /// sando itself stays product-agnostic: the daemon knows it is passing /// `KEY=VALUE` through and nothing about what any key means. /// /// The fixed set wins on a collision. `DATABASE_URL`, `HOST`, `PORT` and /// the rest are what point the gate at its own throwaway DB and loopback /// port, and a typo here must not be able to aim a smoke run at something /// real. /// /// What it exists for: a gate that reaches the network on every run is a /// gate that fails on someone else's outage. MNW's example seed fetches 34 /// third-party media assets and caches them by digest, but the daemon runs /// under `PrivateTmp=true`, so the cache's default home in `/tmp` is a /// fresh empty directory for every build and the gate re-downloads all 34 /// every time. Pointing `SEED_MEDIA_CACHE` at a persistent directory makes /// the cache do the job it was written for. #[serde(default)] pub code_smoke_env: BTreeMap, /// 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, /// Databases the `migration_dry_run` gate dry-runs, in order. Every database /// in the repo needs an entry or it ships ungated, multithreaded's included, /// which `multithreaded/src/main.rs` migrates with `sqlx::migrate!()` at /// boot. sqlx checksums whole migration files, so an edited already-applied /// migration that no check covers fails to boot in prod rather than failing a /// dry run. Default is a single `server` entry. #[serde(default = "default_migration_checks", rename = "migration_check")] pub migration_checks: 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 holding the crate's `Cargo.toml`, relative to the worktree /// (e.g. `server`, `shared/tagtree`) — or, with `aux_repo` set, relative to /// that repo's checkout. Leave it empty for the root of an aux repo. #[serde(default)] pub dir: PathBuf, /// Resolve `dir` inside this `[[aux_repo]]`'s checkout instead of inside the /// worktree, naming the repo by its topology `name`. /// /// An aux repo is checked out *beside* the per-sha worktree, not under it /// (`/` vs `/`), so a worktree-relative /// path cannot reach one. Without this, a crate that leaves the repo but /// stays a path dependency silently stops being gated: its `[[test_target]]` /// turns into a warn-and-skip no-op that reads exactly like a bisect skip. /// /// Worth gating even though the code is not in this repo: an aux repo is /// checked out at its branch HEAD and compiled into these binaries, so a /// break there breaks this build, and nothing else stands between the two. #[serde(default)] pub aux_repo: Option, /// 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, } impl TestTarget { /// How the target is named in logs, warnings and config errors. `dir` alone /// is ambiguous once two repos are in play, and an empty `dir` (an aux /// repo's root) would otherwise print as nothing at all. pub fn label(&self) -> String { let dir = self.dir.display().to_string(); match (self.aux_repo.as_deref(), dir.is_empty()) { (None, _) => dir, (Some(repo), true) => format!("{repo} (aux)"), (Some(repo), false) => format!("{repo}/{dir} (aux)"), } } } /// One database's migrations, as dry-run by the `migration_dry_run` gate: /// restore that database's prod dump into a scratch DB, then run the worktree's /// migrations on top. #[derive(Debug, Clone, Deserialize)] pub struct MigrationCheck { /// Migrations directory under the worktree (e.g. `server/migrations`). /// Passed to `sqlx::migrate::Migrator::new`. pub dir: PathBuf, /// Which configured dump to restore, by `[[backup]]` name in the topology. /// Defaults to `server`, matching the historical single `[backup]` table. /// /// It must be that database's *own* dump. Restoring the server's dump under /// another service's migrations would fail on the first migration for the /// least interesting reason (a `_sqlx_migrations` table full of someone /// else's rows), and if it somehow passed it would prove nothing. #[serde(default = "default_backup_name")] pub backup: String, /// Database name on the scratch cluster to restore into. `None` uses /// `scratch_db_url` as configured, which is what the server check does and /// what the `cargo_test` gate then reuses in migrated state. Any other check /// must name its own: two checks sharing a database would each drop the /// other's restore, and the last one to run would decide what `cargo_test` /// sees. The daemon creates it (DROP + CREATE) at the start of the check, so /// no host bootstrap step is owed for a new entry. #[serde(default)] pub scratch_db: Option, /// Role that owns the objects in *this* dump — `pg_dump` emits /// `ALTER ... OWNER TO ` for every one, and the role must exist in the /// scratch cluster before the restore. Defaults to `scratch_owner_role` /// (the server's owner). multithreaded's dump is owned by `multithreaded`, /// so its check must say so or the restore fails on the first ALTER. /// /// Interpolated into DDL as an identifier, so it is restricted to /// `[A-Za-z0-9_]+` at load. #[serde(default)] pub owner_role: Option, } fn default_backup_name() -> String { "server".into() } fn default_migration_checks() -> Vec { vec![MigrationCheck { dir: PathBuf::from("server").join("migrations"), backup: default_backup_name(), scratch_db: None, owner_role: None, }] } /// The default check list, for the topology cross-check test. #[cfg(test)] pub(crate) fn default_migration_checks_for_test() -> Vec { default_migration_checks() } fn default_test_targets() -> Vec { vec![TestTarget { dir: PathBuf::from("server"), aux_repo: None, 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 DaemonConfig { /// Read the daemon config and every product's pipeline beneath it. /// /// Returns the daemon half plus one [`AppConfig`] per product, in declared /// order. A file with no `[app.*]` tables is one app named /// [`DEFAULT_APP`](crate::domain::DEFAULT_APP), read from that same file. pub fn load() -> Result<(Self, BTreeMap>)> { let path = std::env::var("SANDO_CONFIG").unwrap_or_else(|_| "sando-daemon.toml".into()); Self::load_from(Path::new(&path)) } /// [`DaemonConfig::load`] against an explicit path, for tests and for /// `--check-config`. pub fn load_from(path: &Path) -> Result<(Self, BTreeMap>)> { let raw = std::fs::read_to_string(path) .with_context(|| format!("reading daemon config at {}", path.display()))?; let daemon: Self = toml::from_str(&raw) .with_context(|| format!("parsing daemon config at {}", path.display()))?; let mut apps: BTreeMap> = BTreeMap::new(); if daemon.apps.is_empty() { // Legacy layout: this file is both halves. Parse it again as a // pipeline — unknown keys are ignored on both sides, so `listen` and // `db_path` do not bother the app and `[app.*]` would not bother the // daemon. let id = AppId::default(); let mut cfg: AppConfig = toml::from_str(&raw) .with_context(|| format!("parsing {} as app `{id}`", path.display()))?; cfg.id = id.clone(); cfg.resolve_paths_against(path); cfg.validate()?; apps.insert(id, Arc::new(cfg)); } else { for (name, src) in &daemon.apps { let id = AppId::new(name.clone()); let app_path = resolve_against(path, &src.config); let raw = std::fs::read_to_string(&app_path).with_context(|| { format!("reading config for app `{id}` at {}", app_path.display()) })?; let mut cfg: AppConfig = toml::from_str(&raw).with_context(|| { format!("parsing config for app `{id}` at {}", app_path.display()) })?; cfg.id = id.clone(); cfg.resolve_paths_against(&app_path); cfg.validate() .with_context(|| format!("validating app `{id}`"))?; apps.insert(id, Arc::new(cfg)); } } anyhow::ensure!( !apps.is_empty(), "no apps configured; a daemon that ships nothing has nothing to do" ); Ok((daemon, apps)) } } /// Resolve `p` relative to the directory holding `base`, so a per-app config can /// name its topology beside itself instead of by absolute path. Absolute paths /// pass through, which is what the deployed config uses. fn resolve_against(base: &Path, p: &Path) -> PathBuf { if p.is_absolute() { return p.to_path_buf(); } base.parent().unwrap_or(Path::new(".")).join(p) } impl AppConfig { /// 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) } fn resolve_paths_against(&mut self, config_path: &Path) { self.topology_path = resolve_against(config_path, &self.topology_path); } /// 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(), ); } anyhow::ensure!( !self.migration_checks.is_empty(), "migration_check list is empty; migration_dry_run would restore nothing and pass. \ Omit the key entirely to get the default `server/migrations` check.", ); for (i, m) in self.migration_checks.iter().enumerate() { anyhow::ensure!( !m.backup.is_empty(), "migration_check {} has an empty backup name; omit the key for the default \ `server`", m.dir.display(), ); for role in [m.owner_role.as_deref(), m.scratch_db.as_deref()] .into_iter() .flatten() { anyhow::ensure!( !role.is_empty() && role.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_'), "migration_check {} has {role:?} as an owner_role/scratch_db; both are \ interpolated into DDL as bare identifiers and must match [A-Za-z0-9_]+", m.dir.display(), ); } // A shared dir would run the same migrations twice; a shared // scratch_db (including two `None`s, which both mean scratch_db_url) // would have the second check drop the first one's restore. for prior in &self.migration_checks[..i] { anyhow::ensure!( prior.dir != m.dir, "two migration_check entries share dir {}", m.dir.display(), ); anyhow::ensure!( prior.scratch_db != m.scratch_db, "migration_check {} and {} share a scratch database ({}); each check drops \ and recreates its own, so they would clobber each other", prior.dir.display(), m.dir.display(), m.scratch_db .as_deref() .unwrap_or("the configured scratch_db_url"), ); } } 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 { page_smoke_cmd: None, platform: None, id: crate::domain::AppId::default(), topology_path: PathBuf::from("/tmp/sando-test-topology.toml"), build_host: Some("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, code_smoke_env: BTreeMap::new(), gate_timeout_secs: default_gate_timeout_secs(), companions: Vec::new(), test_targets: default_test_targets(), migration_checks: default_migration_checks(), 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" "#; /// Write `body` to `dir/name` and return the path. fn file(dir: &Path, name: &str, body: &str) -> PathBuf { let p = dir.join(name); std::fs::write(&p, body).unwrap(); p } /// A config with no `[app.*]` tables loads as the single app `mnw`. /// /// sandod is the MNW deploy path, and a binary that cannot read the config /// already on the box is a brick. The daemon keys and the pipeline keys come /// out of the same file. #[test] fn a_config_with_no_app_tables_loads_as_the_default_app() { let dir = tempfile::tempdir().unwrap(); let path = file(dir.path(), "sando-daemon.toml", MINIMAL); let (daemon, apps) = DaemonConfig::load_from(&path).unwrap(); assert_eq!(daemon.listen, "127.0.0.1:7766"); assert_eq!(apps.len(), 1); let id = AppId::new(crate::domain::DEFAULT_APP); let app = &apps[&id]; assert_eq!(app.id, id, "the app must know its own name"); assert_eq!(app.build_host.as_deref(), Some("fw13")); // Relative paths resolve against the config file, not the daemon's CWD, // which is what makes a fixture config usable from a test at all. assert_eq!(app.topology_path, dir.path().join("../sando.toml")); } /// Two products, each with its own pipeline file. #[test] fn apps_load_their_own_configs_and_keep_their_own_names() { let dir = tempfile::tempdir().unwrap(); file( dir.path(), "mnw.toml", r#" topology_path = "mnw-topology.toml" build_host = "fw13" workdir = "./work/mnw" release_root = "./releases/mnw" bin_names = ["makenotwork", "mnw-admin"] "#, ); file( dir.path(), "pom.toml", r#" topology_path = "pom-topology.toml" build_host = "fw13" workdir = "./work/pom" release_root = "./releases/pom" bin_names = ["pom"] [[test_target]] dir = "pom" [[migration_check]] dir = "pom/migrations" "#, ); let path = file( dir.path(), "sando-daemon.toml", r#" listen = "127.0.0.1:7766" db_path = "./sando.db" [app.mnw] config = "mnw.toml" [app.pom] config = "pom.toml" "#, ); let (_daemon, apps) = DaemonConfig::load_from(&path).unwrap(); assert_eq!(apps.len(), 2); let mnw = &apps[&AppId::new("mnw")]; let pom = &apps[&AppId::new("pom")]; assert_eq!(mnw.primary_bin(), "makenotwork"); assert_eq!(pom.primary_bin(), "pom"); // Each app's paths are its own. Two products sharing a release root // would publish into each other's content-addressed history. assert_ne!(mnw.release_root, pom.release_root); assert_ne!(mnw.workdir, pom.workdir); assert_eq!(pom.topology_path, dir.path().join("pom-topology.toml")); assert_eq!(pom.id, AppId::new("pom")); } /// A broken app config names which app, and fails at load rather than at /// that app's first build. #[test] fn a_bad_app_config_is_refused_at_load_and_names_the_app() { let dir = tempfile::tempdir().unwrap(); file( dir.path(), "pom.toml", r#" topology_path = "t.toml" build_host = "fw13" workdir = "./w" release_root = "./r" scratch_owner_role = "not a valid identifier" "#, ); let path = file( dir.path(), "sando-daemon.toml", "listen = \"127.0.0.1:7766\"\ndb_path = \"./sando.db\"\n[app.pom]\nconfig = \"pom.toml\"\n", ); let err = format!("{:#}", DaemonConfig::load_from(&path).unwrap_err()); assert!(err.contains("pom"), "{err}"); assert!(err.contains("scratch_owner_role"), "{err}"); } #[test] fn an_app_whose_config_is_missing_says_so() { let dir = tempfile::tempdir().unwrap(); let path = file( dir.path(), "sando-daemon.toml", "listen = \"127.0.0.1:7766\"\ndb_path = \"./sando.db\"\n[app.pom]\nconfig = \"absent.toml\"\n", ); let err = format!("{:#}", DaemonConfig::load_from(&path).unwrap_err()); assert!( err.contains("app `pom`") && err.contains("absent.toml"), "{err}" ); } #[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: AppConfig = 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 migration_checks_default_to_the_historical_server_entry() { // Same contract as test_targets: configure nothing, get exactly what the // gate did when the dir was hardcoded — server/migrations, the `server` // dump, and `scratch_db_url` itself (which cargo_test then reuses). let cfg: AppConfig = toml::from_str(MINIMAL).unwrap(); assert_eq!(cfg.migration_checks.len(), 1); let m = &cfg.migration_checks[0]; assert_eq!(m.dir, PathBuf::from("server/migrations")); assert_eq!(m.backup, "server"); assert!(m.scratch_db.is_none()); assert!(m.owner_role.is_none()); } #[test] fn migration_checks_parse_as_a_list() { let raw = format!( "{MINIMAL}\n\ [[migration_check]]\ndir = \"server/migrations\"\n\ [[migration_check]]\ndir = \"multithreaded/migrations\"\n\ backup = \"multithreaded\"\nscratch_db = \"sando_scratch_mt\"\n\ owner_role = \"multithreaded\"\n" ); let cfg: AppConfig = toml::from_str(&raw).unwrap(); cfg.validate().unwrap(); assert_eq!(cfg.migration_checks[0].backup, "server", "backup defaults"); let mt = &cfg.migration_checks[1]; assert_eq!(mt.scratch_db.as_deref(), Some("sando_scratch_mt")); assert_eq!(mt.owner_role.as_deref(), Some("multithreaded")); } #[test] fn two_migration_checks_sharing_a_scratch_db_are_rejected() { // Including the both-unset case, which is the easy one to write by // accident: each check drops and recreates its database, so the second // would destroy the first's restore and cargo_test would inherit // whichever ran last. let raw = format!( "{MINIMAL}\n\ [[migration_check]]\ndir = \"server/migrations\"\n\ [[migration_check]]\ndir = \"multithreaded/migrations\"\nbackup = \"multithreaded\"\n" ); let cfg: AppConfig = toml::from_str(&raw).unwrap(); let err = cfg.validate().unwrap_err().to_string(); assert!(err.contains("share a scratch database"), "{err}"); } #[test] fn a_migration_check_scratch_db_that_is_not_an_identifier_is_rejected() { // It is interpolated into `CREATE DATABASE "..."`. let raw = format!("{MINIMAL}\n[[migration_check]]\ndir = \"m\"\nscratch_db = \"drop; --\"\n"); let cfg: AppConfig = toml::from_str(&raw).unwrap(); let err = cfg.validate().unwrap_err().to_string(); assert!(err.contains("[A-Za-z0-9_]+"), "{err}"); } #[test] fn an_empty_migration_check_list_is_rejected() { // Same fail-closed rule as test_target: an empty list would make the // gate restore nothing and pass, which is worse than not having it. let raw = format!("{MINIMAL}\nmigration_check = []\n"); let cfg: AppConfig = toml::from_str(&raw).unwrap(); let err = cfg.validate().unwrap_err().to_string(); assert!(err.contains("migration_check list is empty"), "{err}"); } #[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: AppConfig = 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: AppConfig = 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: AppConfig = 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", ); // serde ignores unknown keys, so a misspelled entry here is not a parse // error — it is a setting that silently does nothing, and code_smoke // would go on re-downloading 34 media assets per build with nothing // saying so. Assert the key by name. assert_eq!( cfg.code_smoke_env .get("SEED_MEDIA_CACHE") .map(String::as_str), Some("/srv/sando/seed-media-cache"), "the seed's media cache must point somewhere that survives \ PrivateTmp=true", ); // 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: AppConfig = 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: AppConfig = 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: AppConfig = 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: AppConfig = 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: AppConfig = 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: AppConfig = toml::from_str(&raw).unwrap(); assert_eq!(cfg.gate_timeout_secs, 600); } #[test] fn companions_default_empty_and_parse_when_present() { let base: AppConfig = 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: AppConfig = 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: AppConfig = 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: AppConfig = 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: AppConfig = 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: AppConfig = toml::from_str(&raw).unwrap(); assert!( cfg.validate().is_err(), "a non-loopback scratch_db_url must fail startup" ); } #[test] fn an_absent_build_host_declares_the_product_intake_only() { // There is still no default host: absent does not mean "build anywhere", // it means Sando does not build this product at all, and `build::run` // refuses rather than choosing. The no-build-on-prod guard cannot be // skipped by omission, because omission removes the build path. let without = MINIMAL.replace("build_host = \"fw13\"\n", ""); let cfg: AppConfig = toml::from_str(&without).expect("intake-only is a valid product"); assert_eq!(cfg.build_host, None); } }