Skip to main content

max / makenotwork

49.3 KB · 1137 lines History Blame Raw
1 //! Daemon and per-product configuration.
2 //!
3 //! Design: wiki [[sando-bento-boundary]].
4 //!
5 //! The split here is the whole of "Sando ships more than one product". One
6 //! daemon, one database, one bind address, one build host — and beneath that, N
7 //! independent pipelines, each with its own repo, tiers, nodes, gates, release
8 //! root and version history. [`DaemonConfig`] is the first set, [`AppConfig`]
9 //! plus a [`Topology`](crate::topology::Topology) is one of the second.
10 //!
11 //! Almost everything that used to be "the config" turned out to be per-product:
12 //! `bin_names`, `release_contents`, `companions`, `test_targets`,
13 //! `migration_checks`, the scratch database, the smoke ports. Only the listen
14 //! address and the DB path are genuinely about the daemon. That imbalance is why
15 //! the single-product assumption was invisible for so long — nearly every field
16 //! was already describing one product, with nothing naming which.
17 //!
18 //! **A config written before any of this still loads.** A file with no `[app.*]`
19 //! tables is read as the single app `mnw`: the same file supplies the daemon
20 //! keys and that app's pipeline. Sando's deployed `sando-daemon.toml` needs no
21 //! edit, which matters because this daemon is the MNW deploy path and a config
22 //! that has to be edited in lockstep with a binary is a way to brick it.
23
24 use crate::domain::AppId;
25 use anyhow::{Context, Result};
26 use serde::Deserialize;
27 use std::collections::BTreeMap;
28 use std::net::{IpAddr, ToSocketAddrs};
29 use std::path::{Path, PathBuf};
30 use std::sync::Arc;
31
32 /// What the daemon itself needs, as opposed to what a product's pipeline needs.
33 ///
34 /// Deliberately small. A key belongs here only if it would be meaningless per
35 /// product: one process binds one address and opens one database, so those two
36 /// are the whole list. `build_host` is a near miss and is not here — it reads
37 /// like a machine property, but "which host may compile this" is a per-product
38 /// answer the moment two products can build on different machines.
39 #[derive(Debug, Clone, Deserialize)]
40 pub struct DaemonConfig {
41 pub listen: String,
42 pub db_path: PathBuf,
43 /// Products this daemon ships, each pointing at its own pipeline config.
44 /// Empty means the legacy single-app layout: this same file is `mnw`.
45 #[serde(default, rename = "app")]
46 pub apps: BTreeMap<String, AppSource>,
47 }
48
49 /// Where one product's pipeline config lives.
50 #[derive(Debug, Clone, Deserialize)]
51 pub struct AppSource {
52 /// Path to the product's [`AppConfig`] TOML. Its `topology_path` points on
53 /// to that product's tiers and nodes, so a product is two files, the same
54 /// shape Sando already had for one.
55 pub config: PathBuf,
56 }
57
58 /// One product's pipeline: what to build, what to prove about it, where it goes.
59 #[derive(Debug, Clone, Deserialize)]
60 pub struct AppConfig {
61 /// Which product this is. Not read from the file: it is the key the daemon
62 /// filed this config under, so a config cannot disagree with its own name.
63 #[serde(skip)]
64 pub id: AppId,
65 pub topology_path: PathBuf,
66 /// What this product's bundles run on, as `os/arch`, when Sando builds them
67 /// itself. Left unset for a product whose artifacts arrive from a builder:
68 /// an intake takes the platform from its record's provenance, which is the
69 /// only place that answer is trustworthy when two architectures ship under
70 /// one version.
71 ///
72 /// Unset is not a wildcard. A node declaring a platform refuses an artifact
73 /// that records none, so setting this on a product means setting it on that
74 /// product's nodes too (see [`crate::deploy::Placement`]).
75 #[serde(default)]
76 pub platform: Option<crate::domain::Platform>,
77 /// The runtime hostname (`/proc/sys/kernel/hostname`) this daemon is
78 /// permitted to build on. `build::run` refuses to compile unless the live
79 /// host matches, so a `sandod` misdeployed onto a prod/serving node (e.g.
80 /// Hetzner) cannot build there — "never build on prod" becomes an invariant
81 /// rather than a code-path accident. There is no safe default.
82 ///
83 /// Unset declares the product **intake-only**: Sando never compiles it, and
84 /// `build::run` refuses rather than picking a host. That is pom, which is
85 /// built natively on two architectures by Bento and only ever handed to
86 /// Sando as finished bytes (wiki [[sando-bento-boundary]]). Naming a build
87 /// host for a product Sando must not build would be a claim the code would
88 /// then be free to act on.
89 #[serde(default)]
90 pub build_host: Option<String>,
91 /// Host-local checkout scratch dir (per-sha worktrees live here).
92 pub workdir: PathBuf,
93 /// Host-local releases dir. Bundles are staged at `staging/<build_id>/`,
94 /// then published content-addressed at `releases/<digest16>/` with
95 /// `current` symlinked to the live one (see `crate::bundle`).
96 pub release_root: PathBuf,
97 /// Scratch postgres DB url used by `migration_dry_run`. Sando drops and
98 /// recreates the schema on every run, so do not point this at anything
99 /// you care about. Validated at load to address loopback only (127.0.0.1,
100 /// `::1`, `localhost`, or a local unix socket): a gate that DROPs a database
101 /// must never reach off-box, and a non-loopback host refuses startup (see
102 /// `validate`).
103 #[serde(default)]
104 pub scratch_db_url: Option<String>,
105 /// Role that owns the restored objects in a prod dump. `pg_dump` emits
106 /// `ALTER ... OWNER TO <role>` for every object, so the role must exist in
107 /// the scratch cluster before `migration_dry_run` restores — a superuser
108 /// connection does not conjure it. `reset_scratch` creates it (NOLOGIN) and
109 /// grants it CREATE on public, so a fresh box needs no manual SQL.
110 ///
111 /// Interpolated into DDL as an identifier, so it is restricted to
112 /// `[A-Za-z0-9_]+` at load (see `validate`) rather than quoted at use.
113 #[serde(default = "default_scratch_owner_role")]
114 pub scratch_owner_role: String,
115 /// Loopback port the `boot_smoke` gate tells the staged artifact to bind
116 /// (`SANDO_BOOT_SMOKE_PORT`), then probes `GET /health` on. Lets the gate
117 /// prove readiness, not just liveness. Fixed rather than ephemeral so the
118 /// gate knows where to probe; builds are serialized so there's no contention.
119 #[serde(default = "default_boot_smoke_port")]
120 pub boot_smoke_port: u16,
121 /// Loopback port the `code_smoke` gate tells the freshly-built binary to
122 /// bind (`HOST=127.0.0.1 PORT=<this>`), then probes `GET /health` on after
123 /// migrating + seeding a throwaway DB. Separate from `boot_smoke_port` only
124 /// for clarity — the two gates never run concurrently (builds are
125 /// serialized).
126 #[serde(default = "default_code_smoke_port")]
127 pub code_smoke_port: u16,
128 /// Names of cargo bin targets the server crate produces (files under
129 /// `target/release/`). First entry is the primary unit (referenced from
130 /// the systemd unit's ExecStart). Defaults to `["server"]`; MNW ships
131 /// `["makenotwork", "mnw-admin"]`.
132 #[serde(default = "default_bin_names")]
133 pub bin_names: Vec<String>,
134 /// Root for per-gate run logs (`<logs_root>/<version>/<gate>.log`).
135 /// Served via `GET /logs/{version}/{gate}`. Defaults to `/srv/sando/logs`.
136 #[serde(default = "default_logs_root")]
137 pub logs_root: PathBuf,
138 /// Shared cargo target dir. When set, every `cargo build`/`cargo test` the
139 /// pipeline runs uses this one `CARGO_TARGET_DIR` instead of each per-sha
140 /// worktree's own `target/`, so a 1-line diff reuses the previous sha's
141 /// compiled dependencies (a ~10-min clean build becomes a 1–2-min
142 /// incremental one). Safe because builds are serialized — a new `/rebuild`
143 /// aborts the in-flight one — so no two cargo invocations ever share the
144 /// dir concurrently. Unset = per-worktree `target/` (the historical
145 /// behavior). Cargo creates the dir if absent.
146 #[serde(default)]
147 pub cargo_target_dir: Option<PathBuf>,
148 /// Non-binary contents to stage into each release dir alongside
149 /// `bin_names`. Each entry copies `worktree/<src>` into
150 /// `<release>/<dst>`. `required=false` makes a missing source a warn
151 /// (older shas missing one of these don't break sando mid-bisect);
152 /// `required=true` errors. Default is empty — projects opt-in via
153 /// daemon config so the sando code stays project-agnostic.
154 #[serde(default)]
155 pub release_contents: Vec<ReleaseEntry>,
156 /// Wall-clock ceiling (seconds) for the `cargo_test` and `migration_dry_run`
157 /// gates. A hung suite (deadlocked test, wedged child) otherwise blocks the
158 /// pipeline until a new `/rebuild` aborts it; past the ceiling the gate is
159 /// killed and fails with `GateFailure::Timeout`. Default 2400s (40 min) —
160 /// generous for a full release test suite, fatal only to a genuine hang.
161 #[serde(default = "default_gate_timeout_secs")]
162 pub gate_timeout_secs: u64,
163 /// Extra crates built from the same worktree/sha as the server and shipped
164 /// in the release bundle, so a service that shares the server's contract
165 /// (e.g. `mnw-cli`, which talks to `/api/internal/*`) can't drift out of
166 /// lockstep. Each is compiled after the server; a companion that fails to
167 /// build fails the whole pipeline — that is the lockstep guarantee. Which
168 /// nodes actually install a given companion is a per-node decision (see
169 /// `Node::companions`); this list only says what to build + stage. Default
170 /// empty, so the sando code stays project-agnostic.
171 #[serde(default, rename = "companion")]
172 pub companions: Vec<Companion>,
173 /// Crates the `cargo_test` gate runs, in order. The gate used to hardcode
174 /// `worktree/server`, so everything else in the repo shipped ungated —
175 /// including `mnw-cli`, which is built as a companion and installed onto
176 /// prod-1. Default is the historical single `server` entry, so a project
177 /// that configures nothing keeps today's behavior.
178 #[serde(default = "default_test_targets", rename = "test_target")]
179 pub test_targets: Vec<TestTarget>,
180 /// Frontend builds the `code_smoke` gate runs, in order, before it touches a
181 /// database. Each is an npm project whose compiled output is served by the
182 /// binary but is not produced by `cargo build` in any way cargo can fail on:
183 /// both MNW frontends compile from a build script that reports a `tsc` error
184 /// as a `cargo::warning` and lets the Rust build succeed against whatever
185 /// `static/dist/` already holds, deliberately, so a type error in a chat
186 /// widget cannot stop the forum from compiling. The cost of that choice is
187 /// that nothing downstream noticed either, and the deploy shipped a stale
188 /// bundle. This is where the same failure is fatal. Default empty, so a
189 /// project that configures nothing keeps today's behavior.
190 #[serde(default, rename = "frontend_build")]
191 pub frontend_builds: Vec<FrontendBuild>,
192 /// Databases the `migration_dry_run` gate dry-runs, in order. The gate used
193 /// to hardcode `worktree/server/migrations` against the one `scratch_db_url`,
194 /// so every other database in the repo shipped with no gate at all —
195 /// including multithreaded's, which `multithreaded/src/main.rs` migrates
196 /// with `sqlx::migrate!()` at boot. That is the same exposure that blocked
197 /// every server deploy 2026-07-27..07-30 (an exorcise sweep rewrote comments
198 /// in 29 already-applied migrations; sqlx checksums whole files), except
199 /// that with no gate it would not have failed a dry run — it would have
200 /// failed to boot in prod. Default is the historical single `server` entry,
201 /// so a project that configures nothing keeps today's behavior.
202 #[serde(default = "default_migration_checks", rename = "migration_check")]
203 pub migration_checks: Vec<MigrationCheck>,
204 /// How old (hours) the fetched prod dump may be before `migration_dry_run`
205 /// refuses to run against it. The gate restores whatever `backups` row is
206 /// newest, and presence alone used to be the only check — so a fetch that
207 /// stopped working left the gate passing green against an ever-older schema,
208 /// which is the failure it exists to catch. Sando ran 45 days that way in
209 /// June-July 2026. Default 48h: a daily fetch may miss one night without
210 /// tripping this.
211 #[serde(default = "default_backup_max_age_hours")]
212 pub backup_max_age_hours: u32,
213 }
214
215 /// One npm project the `code_smoke` gate compiles.
216 #[derive(Debug, Clone, Deserialize)]
217 pub struct FrontendBuild {
218 /// Directory under the worktree holding `package.json`
219 /// (e.g. `server/frontend`).
220 pub dir: PathBuf,
221 /// npm script to run. Defaults to `build`, which is what emits the bundle
222 /// the release actually serves; `typecheck` would prove less (it never
223 /// writes `static/dist/`, so it cannot catch an emit failure).
224 #[serde(default = "default_frontend_script")]
225 pub script: String,
226 }
227
228 fn default_frontend_script() -> String {
229 "build".into()
230 }
231
232 /// One crate's test suite, as run by the `cargo_test` gate.
233 #[derive(Debug, Clone, Deserialize)]
234 pub struct TestTarget {
235 /// Directory holding the crate's `Cargo.toml`, relative to the worktree
236 /// (e.g. `server`, `shared/tagtree`) — or, with `aux_repo` set, relative to
237 /// that repo's checkout. Leave it empty for the root of an aux repo.
238 #[serde(default)]
239 pub dir: PathBuf,
240 /// Resolve `dir` inside this `[[aux_repo]]`'s checkout instead of inside the
241 /// worktree, naming the repo by its topology `name`.
242 ///
243 /// An aux repo is checked out *beside* the per-sha worktree, not under it
244 /// (`<workdir>/<checkout_dir>` vs `<workdir>/<sha>`), so a worktree-relative
245 /// path cannot reach one. Without this, a crate that leaves the repo but
246 /// stays a path dependency silently stops being gated: `shared/docengine`
247 /// became `Libraries/docengine` on 2026-07-30 and its `[[test_target]]`
248 /// turned into a warn-and-skip no-op that read exactly like a bisect skip.
249 ///
250 /// Worth gating even though the code is not in this repo: an aux repo is
251 /// checked out at its branch HEAD and compiled into these binaries, so a
252 /// break there breaks this build, and nothing else stands between the two.
253 #[serde(default)]
254 pub aux_repo: Option<String>,
255 /// Cargo features to enable. MNW's server needs `fast-tests`; most crates
256 /// need none.
257 #[serde(default)]
258 pub features: Vec<String>,
259 /// Pass `--all-features` instead of naming features. Mutually exclusive
260 /// with `features` (rejected at load).
261 #[serde(default)]
262 pub all_features: bool,
263 /// Export `DATABASE_URL` / `TEST_DATABASE_URL` (pointing at
264 /// `scratch_db_url`) for this crate's tests. Off by default: a crate using
265 /// sqlx's offline query data goes *online* when `DATABASE_URL` is set and
266 /// will fail to compile against the wrong database.
267 #[serde(default)]
268 pub scratch_db: bool,
269 }
270
271 impl TestTarget {
272 /// How the target is named in logs, warnings and config errors. `dir` alone
273 /// is ambiguous once two repos are in play, and an empty `dir` (an aux
274 /// repo's root) would otherwise print as nothing at all.
275 pub fn label(&self) -> String {
276 let dir = self.dir.display().to_string();
277 match (self.aux_repo.as_deref(), dir.is_empty()) {
278 (None, _) => dir,
279 (Some(repo), true) => format!("{repo} (aux)"),
280 (Some(repo), false) => format!("{repo}/{dir} (aux)"),
281 }
282 }
283 }
284
285 /// One database's migrations, as dry-run by the `migration_dry_run` gate:
286 /// restore that database's prod dump into a scratch DB, then run the worktree's
287 /// migrations on top.
288 #[derive(Debug, Clone, Deserialize)]
289 pub struct MigrationCheck {
290 /// Migrations directory under the worktree (e.g. `server/migrations`).
291 /// Passed to `sqlx::migrate::Migrator::new`.
292 pub dir: PathBuf,
293 /// Which configured dump to restore, by `[[backup]]` name in the topology.
294 /// Defaults to `server`, matching the historical single `[backup]` table.
295 ///
296 /// It must be that database's *own* dump. Restoring the server's dump under
297 /// another service's migrations would fail on the first migration for the
298 /// least interesting reason (a `_sqlx_migrations` table full of someone
299 /// else's rows), and if it somehow passed it would prove nothing.
300 #[serde(default = "default_backup_name")]
301 pub backup: String,
302 /// Database name on the scratch cluster to restore into. `None` uses
303 /// `scratch_db_url` as configured, which is what the server check does and
304 /// what the `cargo_test` gate then reuses in migrated state. Any other check
305 /// must name its own: two checks sharing a database would each drop the
306 /// other's restore, and the last one to run would decide what `cargo_test`
307 /// sees. The daemon creates it (DROP + CREATE) at the start of the check, so
308 /// no host bootstrap step is owed for a new entry.
309 #[serde(default)]
310 pub scratch_db: Option<String>,
311 /// Role that owns the objects in *this* dump — `pg_dump` emits
312 /// `ALTER ... OWNER TO <role>` for every one, and the role must exist in the
313 /// scratch cluster before the restore. Defaults to `scratch_owner_role`
314 /// (the server's owner). multithreaded's dump is owned by `multithreaded`,
315 /// so its check must say so or the restore fails on the first ALTER.
316 ///
317 /// Interpolated into DDL as an identifier, so it is restricted to
318 /// `[A-Za-z0-9_]+` at load.
319 #[serde(default)]
320 pub owner_role: Option<String>,
321 }
322
323 fn default_backup_name() -> String {
324 "server".into()
325 }
326
327 fn default_migration_checks() -> Vec<MigrationCheck> {
328 vec![MigrationCheck {
329 dir: PathBuf::from("server").join("migrations"),
330 backup: default_backup_name(),
331 scratch_db: None,
332 owner_role: None,
333 }]
334 }
335
336 /// The default check list, for the topology cross-check test.
337 #[cfg(test)]
338 pub(crate) fn default_migration_checks_for_test() -> Vec<MigrationCheck> {
339 default_migration_checks()
340 }
341
342 fn default_test_targets() -> Vec<TestTarget> {
343 vec![TestTarget {
344 dir: PathBuf::from("server"),
345 aux_repo: None,
346 features: vec!["fast-tests".into()],
347 all_features: false,
348 scratch_db: true,
349 }]
350 }
351
352 /// A crate built alongside the server and staged into the release bundle under
353 /// `companions/<name>/<bin>`. Referenced by `Node::companions[].name` to decide
354 /// where (if anywhere) it deploys.
355 #[derive(Debug, Clone, Deserialize)]
356 pub struct Companion {
357 /// Logical id, matched by a node's companion entry. Also the bundle subdir.
358 pub name: String,
359 /// Directory under the worktree holding the crate's `Cargo.toml`
360 /// (e.g. `mnw-cli`). Built with `cargo build --release` in that dir.
361 pub manifest_dir: PathBuf,
362 /// Binary name produced under the crate's `target/release/`.
363 pub bin: String,
364 }
365
366 /// A directory or file copied from the worktree into the staged release dir.
367 /// Multiple entries with the same `dst` are allowed and merged (used by MNW
368 /// to build `docs/` from three different worktree sources).
369 #[derive(Debug, Clone, Deserialize)]
370 pub struct ReleaseEntry {
371 /// Path relative to the worktree root (e.g. `server/static`).
372 pub src: PathBuf,
373 /// Path relative to the release dir (e.g. `static`). Parent dirs are
374 /// created as needed.
375 pub dst: PathBuf,
376 /// If true, a missing source aborts the build. If false, log warn + skip.
377 #[serde(default)]
378 pub required: bool,
379 }
380
381 /// The host component of a `postgres://` URL, or `None` when the URL addresses a
382 /// local unix socket (no authority). Hand-parsed rather than pulling in a URL
383 /// crate, matching the daemon's existing PG-URL handling in `gates.rs`.
384 fn scratch_db_host(url: &str) -> Option<String> {
385 let after = url.find("://").map(|i| i + 3)?;
386 let authority_end = url[after..]
387 .find(['/', '?', '#'])
388 .map_or(url.len(), |i| after + i);
389 let authority = &url[after..authority_end];
390 // Drop userinfo: keep everything after the last '@' (`user:pass@host` → `host`).
391 let host_port = authority.rsplit('@').next().unwrap_or(authority);
392 // Split the host from an optional port. Bracketed IPv6 (`[::1]:5432`) first;
393 // otherwise a hostname or IPv4, neither of which contains ':'.
394 let host = if let Some(rest) = host_port.strip_prefix('[') {
395 rest.split(']').next().unwrap_or("")
396 } else {
397 host_port.split(':').next().unwrap_or("")
398 };
399 if host.is_empty() {
400 None
401 } else {
402 Some(host.to_string())
403 }
404 }
405
406 /// Refuse a `scratch_db_url` that could reach a database off this box.
407 /// `migration_dry_run` DROPs and recreates the scratch schema, so pointing it at
408 /// a remote (a typo, or a config copied from staging) would wipe the wrong
409 /// database. Loopback is absolute here — there is deliberately no
410 /// `allow_remote_scratch_db` escape hatch for a database the daemon destroys.
411 fn assert_scratch_db_loopback(url: &str) -> Result<()> {
412 let Some(host) = scratch_db_host(url) else {
413 return Ok(()); // no authority → local unix socket
414 };
415 // A percent-encoded unix socket path (`postgres://%2Fvar%2Frun%2Fpg/db`) is
416 // local. A decoded path would start with '/'; `%2f` is its encoded form.
417 if host.starts_with('/') || host.to_ascii_lowercase().starts_with("%2f") {
418 return Ok(());
419 }
420 // An IP literal is classified without touching DNS.
421 if let Ok(ip) = host.parse::<IpAddr>() {
422 anyhow::ensure!(
423 ip.is_loopback(),
424 "scratch_db_url host {host} is not loopback ({ip}); migration_dry_run DROPs and \
425 recreates this database, so it must never point off-box (use 127.0.0.1, ::1, \
426 localhost, or a local unix socket)",
427 );
428 return Ok(());
429 }
430 // A hostname: resolve and require every resolved address to be loopback.
431 // Resolution failure fails closed — a name we cannot prove is local is not a
432 // name we let a schema-dropping gate connect to.
433 let addrs: Vec<_> = (host.as_str(), 0u16)
434 .to_socket_addrs()
435 .with_context(|| format!("resolving scratch_db_url host {host} to confirm it is loopback"))?
436 .collect();
437 anyhow::ensure!(
438 !addrs.is_empty(),
439 "scratch_db_url host {host} resolved to no addresses; cannot confirm it is loopback",
440 );
441 anyhow::ensure!(
442 addrs.iter().all(|a| a.ip().is_loopback()),
443 "scratch_db_url host {host} resolves off-box (not loopback); migration_dry_run DROPs and \
444 recreates this database, so it must never point off-box",
445 );
446 Ok(())
447 }
448
449 fn default_bin_names() -> Vec<String> {
450 vec!["server".into()]
451 }
452 fn default_scratch_owner_role() -> String {
453 "makenotwork".into()
454 }
455 fn default_logs_root() -> PathBuf {
456 PathBuf::from("/srv/sando/logs")
457 }
458 fn default_boot_smoke_port() -> u16 {
459 18181
460 }
461 fn default_code_smoke_port() -> u16 {
462 18182
463 }
464 fn default_gate_timeout_secs() -> u64 {
465 2400
466 }
467 fn default_backup_max_age_hours() -> u32 {
468 48
469 }
470
471 impl DaemonConfig {
472 /// Read the daemon config and every product's pipeline beneath it.
473 ///
474 /// Returns the daemon half plus one [`AppConfig`] per product, in declared
475 /// order. A file with no `[app.*]` tables is one app named
476 /// [`DEFAULT_APP`](crate::domain::DEFAULT_APP), read from that same file.
477 pub fn load() -> Result<(Self, BTreeMap<AppId, Arc<AppConfig>>)> {
478 let path = std::env::var("SANDO_CONFIG").unwrap_or_else(|_| "sando-daemon.toml".into());
479 Self::load_from(Path::new(&path))
480 }
481
482 /// [`DaemonConfig::load`] against an explicit path, for tests and for
483 /// `--check-config`.
484 pub fn load_from(path: &Path) -> Result<(Self, BTreeMap<AppId, Arc<AppConfig>>)> {
485 let raw = std::fs::read_to_string(path)
486 .with_context(|| format!("reading daemon config at {}", path.display()))?;
487 let daemon: Self = toml::from_str(&raw)
488 .with_context(|| format!("parsing daemon config at {}", path.display()))?;
489
490 let mut apps: BTreeMap<AppId, Arc<AppConfig>> = BTreeMap::new();
491 if daemon.apps.is_empty() {
492 // Legacy layout: this file is both halves. Parse it again as a
493 // pipeline — unknown keys are ignored on both sides, so `listen` and
494 // `db_path` do not bother the app and `[app.*]` would not bother the
495 // daemon.
496 let id = AppId::default();
497 let mut cfg: AppConfig = toml::from_str(&raw)
498 .with_context(|| format!("parsing {} as app `{id}`", path.display()))?;
499 cfg.id = id.clone();
500 cfg.resolve_paths_against(path);
501 cfg.validate()?;
502 apps.insert(id, Arc::new(cfg));
503 } else {
504 for (name, src) in &daemon.apps {
505 let id = AppId::new(name.clone());
506 let app_path = resolve_against(path, &src.config);
507 let raw = std::fs::read_to_string(&app_path).with_context(|| {
508 format!("reading config for app `{id}` at {}", app_path.display())
509 })?;
510 let mut cfg: AppConfig = toml::from_str(&raw).with_context(|| {
511 format!("parsing config for app `{id}` at {}", app_path.display())
512 })?;
513 cfg.id = id.clone();
514 cfg.resolve_paths_against(&app_path);
515 cfg.validate()
516 .with_context(|| format!("validating app `{id}`"))?;
517 apps.insert(id, Arc::new(cfg));
518 }
519 }
520 anyhow::ensure!(
521 !apps.is_empty(),
522 "no apps configured; a daemon that ships nothing has nothing to do"
523 );
524 Ok((daemon, apps))
525 }
526 }
527
528 /// Resolve `p` relative to the directory holding `base`, so a per-app config can
529 /// name its topology beside itself instead of by absolute path. Absolute paths
530 /// pass through, which is what the deployed config uses.
531 fn resolve_against(base: &Path, p: &Path) -> PathBuf {
532 if p.is_absolute() {
533 return p.to_path_buf();
534 }
535 base.parent().unwrap_or(Path::new(".")).join(p)
536 }
537
538 impl AppConfig {
539 /// Primary binary — the one the systemd unit's ExecStart points at.
540 pub fn primary_bin(&self) -> &str {
541 self.bin_names
542 .first()
543 .map_or("server", std::string::String::as_str)
544 }
545
546 fn resolve_paths_against(&mut self, config_path: &Path) {
547 self.topology_path = resolve_against(config_path, &self.topology_path);
548 }
549
550 /// Invariants the deserializer can't express. Runs at load (and so under
551 /// `--check-config`), never at use — a bad value fails startup once, loudly,
552 /// rather than at the first gate that happens to touch it.
553 pub fn validate(&self) -> Result<()> {
554 anyhow::ensure!(
555 !self.scratch_owner_role.is_empty()
556 && self
557 .scratch_owner_role
558 .bytes()
559 .all(|b| b.is_ascii_alphanumeric() || b == b'_'),
560 "scratch_owner_role must be non-empty and match [A-Za-z0-9_]+ (got {:?}); it is \
561 interpolated into DDL as a bare identifier",
562 self.scratch_owner_role,
563 );
564 anyhow::ensure!(
565 !self.test_targets.is_empty(),
566 "test_target list is empty; cargo_test would run nothing and pass. Omit the \
567 key entirely to get the default `server` target.",
568 );
569 for t in &self.test_targets {
570 anyhow::ensure!(
571 !t.all_features || t.features.is_empty(),
572 "test_target {} sets both all_features and features; pick one",
573 t.dir.display(),
574 );
575 }
576 anyhow::ensure!(
577 !self.migration_checks.is_empty(),
578 "migration_check list is empty; migration_dry_run would restore nothing and pass. \
579 Omit the key entirely to get the default `server/migrations` check.",
580 );
581 for (i, m) in self.migration_checks.iter().enumerate() {
582 anyhow::ensure!(
583 !m.backup.is_empty(),
584 "migration_check {} has an empty backup name; omit the key for the default \
585 `server`",
586 m.dir.display(),
587 );
588 for role in [m.owner_role.as_deref(), m.scratch_db.as_deref()]
589 .into_iter()
590 .flatten()
591 {
592 anyhow::ensure!(
593 !role.is_empty()
594 && role.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_'),
595 "migration_check {} has {role:?} as an owner_role/scratch_db; both are \
596 interpolated into DDL as bare identifiers and must match [A-Za-z0-9_]+",
597 m.dir.display(),
598 );
599 }
600 // A shared dir would run the same migrations twice; a shared
601 // scratch_db (including two `None`s, which both mean scratch_db_url)
602 // would have the second check drop the first one's restore.
603 for prior in &self.migration_checks[..i] {
604 anyhow::ensure!(
605 prior.dir != m.dir,
606 "two migration_check entries share dir {}",
607 m.dir.display(),
608 );
609 anyhow::ensure!(
610 prior.scratch_db != m.scratch_db,
611 "migration_check {} and {} share a scratch database ({}); each check drops \
612 and recreates its own, so they would clobber each other",
613 prior.dir.display(),
614 m.dir.display(),
615 m.scratch_db
616 .as_deref()
617 .unwrap_or("the configured scratch_db_url"),
618 );
619 }
620 }
621 for f in &self.frontend_builds {
622 anyhow::ensure!(
623 !f.script.is_empty(),
624 "frontend_build {} has an empty script; omit the key for the default `build`",
625 f.dir.display(),
626 );
627 }
628 if let Some(url) = self.scratch_db_url.as_deref() {
629 assert_scratch_db_loopback(url)
630 .context("scratch_db_url must address a loopback (on-box) database")?;
631 }
632 Ok(())
633 }
634
635 #[cfg(test)]
636 pub fn for_tests() -> Self {
637 Self {
638 platform: None,
639 id: crate::domain::AppId::default(),
640 topology_path: PathBuf::from("/tmp/sando-test-topology.toml"),
641 build_host: Some("test-host".into()),
642 workdir: PathBuf::from("/tmp/sando-test-workdir"),
643 release_root: PathBuf::from("/tmp/sando-test-release-root"),
644 scratch_db_url: None,
645 scratch_owner_role: default_scratch_owner_role(),
646 boot_smoke_port: default_boot_smoke_port(),
647 code_smoke_port: default_code_smoke_port(),
648 bin_names: vec!["server".into()],
649 logs_root: PathBuf::from("/tmp/sando-test-logs"),
650 release_contents: Vec::new(),
651 cargo_target_dir: None,
652 gate_timeout_secs: default_gate_timeout_secs(),
653 companions: Vec::new(),
654 test_targets: default_test_targets(),
655 migration_checks: default_migration_checks(),
656 frontend_builds: Vec::new(),
657 backup_max_age_hours: default_backup_max_age_hours(),
658 }
659 }
660 }
661
662 #[cfg(test)]
663 mod tests {
664 use super::*;
665
666 const MINIMAL: &str = r#"
667 listen = "127.0.0.1:7766"
668 db_path = "./sando.db"
669 topology_path = "../sando.toml"
670 build_host = "fw13"
671 workdir = "./work"
672 release_root = "./releases"
673 "#;
674
675 /// Write `body` to `dir/name` and return the path.
676 fn file(dir: &Path, name: &str, body: &str) -> PathBuf {
677 let p = dir.join(name);
678 std::fs::write(&p, body).unwrap();
679 p
680 }
681
682 /// A config written before Sando shipped more than one product still loads,
683 /// as the single app `mnw`.
684 ///
685 /// This is the compatibility that matters most in the whole change: sandod
686 /// is the MNW deploy path, and a binary that cannot read the config already
687 /// on the box is a brick (postmortem 2026-07-09 #6). The daemon keys and the
688 /// pipeline keys come out of the same file.
689 #[test]
690 fn a_config_with_no_app_tables_loads_as_the_default_app() {
691 let dir = tempfile::tempdir().unwrap();
692 let path = file(dir.path(), "sando-daemon.toml", MINIMAL);
693 let (daemon, apps) = DaemonConfig::load_from(&path).unwrap();
694
695 assert_eq!(daemon.listen, "127.0.0.1:7766");
696 assert_eq!(apps.len(), 1);
697 let id = AppId::new(crate::domain::DEFAULT_APP);
698 let app = &apps[&id];
699 assert_eq!(app.id, id, "the app must know its own name");
700 assert_eq!(app.build_host.as_deref(), Some("fw13"));
701 // Relative paths resolve against the config file, not the daemon's CWD,
702 // which is what makes a fixture config usable from a test at all.
703 assert_eq!(app.topology_path, dir.path().join("../sando.toml"));
704 }
705
706 /// Two products, each with its own pipeline file.
707 #[test]
708 fn apps_load_their_own_configs_and_keep_their_own_names() {
709 let dir = tempfile::tempdir().unwrap();
710 file(
711 dir.path(),
712 "mnw.toml",
713 r#"
714 topology_path = "mnw-topology.toml"
715 build_host = "fw13"
716 workdir = "./work/mnw"
717 release_root = "./releases/mnw"
718 bin_names = ["makenotwork", "mnw-admin"]
719 "#,
720 );
721 file(
722 dir.path(),
723 "pom.toml",
724 r#"
725 topology_path = "pom-topology.toml"
726 build_host = "fw13"
727 workdir = "./work/pom"
728 release_root = "./releases/pom"
729 bin_names = ["pom"]
730 [[test_target]]
731 dir = "pom"
732 [[migration_check]]
733 dir = "pom/migrations"
734 "#,
735 );
736 let path = file(
737 dir.path(),
738 "sando-daemon.toml",
739 r#"
740 listen = "127.0.0.1:7766"
741 db_path = "./sando.db"
742 [app.mnw]
743 config = "mnw.toml"
744 [app.pom]
745 config = "pom.toml"
746 "#,
747 );
748
749 let (_daemon, apps) = DaemonConfig::load_from(&path).unwrap();
750 assert_eq!(apps.len(), 2);
751 let mnw = &apps[&AppId::new("mnw")];
752 let pom = &apps[&AppId::new("pom")];
753 assert_eq!(mnw.primary_bin(), "makenotwork");
754 assert_eq!(pom.primary_bin(), "pom");
755 // Each app's paths are its own. Two products sharing a release root
756 // would publish into each other's content-addressed history.
757 assert_ne!(mnw.release_root, pom.release_root);
758 assert_ne!(mnw.workdir, pom.workdir);
759 assert_eq!(pom.topology_path, dir.path().join("pom-topology.toml"));
760 assert_eq!(pom.id, AppId::new("pom"));
761 }
762
763 /// A broken app config names which app, and fails at load rather than at
764 /// that app's first build.
765 #[test]
766 fn a_bad_app_config_is_refused_at_load_and_names_the_app() {
767 let dir = tempfile::tempdir().unwrap();
768 file(
769 dir.path(),
770 "pom.toml",
771 r#"
772 topology_path = "t.toml"
773 build_host = "fw13"
774 workdir = "./w"
775 release_root = "./r"
776 scratch_owner_role = "not a valid identifier"
777 "#,
778 );
779 let path = file(
780 dir.path(),
781 "sando-daemon.toml",
782 "listen = \"127.0.0.1:7766\"\ndb_path = \"./sando.db\"\n[app.pom]\nconfig = \"pom.toml\"\n",
783 );
784 let err = format!("{:#}", DaemonConfig::load_from(&path).unwrap_err());
785 assert!(err.contains("pom"), "{err}");
786 assert!(err.contains("scratch_owner_role"), "{err}");
787 }
788
789 #[test]
790 fn an_app_whose_config_is_missing_says_so() {
791 let dir = tempfile::tempdir().unwrap();
792 let path = file(
793 dir.path(),
794 "sando-daemon.toml",
795 "listen = \"127.0.0.1:7766\"\ndb_path = \"./sando.db\"\n[app.pom]\nconfig = \"absent.toml\"\n",
796 );
797 let err = format!("{:#}", DaemonConfig::load_from(&path).unwrap_err());
798 assert!(
799 err.contains("app `pom`") && err.contains("absent.toml"),
800 "{err}"
801 );
802 }
803
804 #[test]
805 fn test_targets_default_to_the_historical_server_entry() {
806 // A project that configures nothing must keep the pre-config behavior:
807 // the server crate, with fast-tests, against the scratch DB.
808 let cfg: AppConfig = toml::from_str(MINIMAL).unwrap();
809 assert_eq!(cfg.test_targets.len(), 1);
810 let t = &cfg.test_targets[0];
811 assert_eq!(t.dir, PathBuf::from("server"));
812 assert_eq!(t.features, ["fast-tests"]);
813 assert!(t.scratch_db);
814 assert!(!t.all_features);
815 }
816
817 #[test]
818 fn migration_checks_default_to_the_historical_server_entry() {
819 // Same contract as test_targets: configure nothing, get exactly what the
820 // gate did when the dir was hardcoded — server/migrations, the `server`
821 // dump, and `scratch_db_url` itself (which cargo_test then reuses).
822 let cfg: AppConfig = toml::from_str(MINIMAL).unwrap();
823 assert_eq!(cfg.migration_checks.len(), 1);
824 let m = &cfg.migration_checks[0];
825 assert_eq!(m.dir, PathBuf::from("server/migrations"));
826 assert_eq!(m.backup, "server");
827 assert!(m.scratch_db.is_none());
828 assert!(m.owner_role.is_none());
829 }
830
831 #[test]
832 fn migration_checks_parse_as_a_list() {
833 let raw = format!(
834 "{MINIMAL}\n\
835 [[migration_check]]\ndir = \"server/migrations\"\n\
836 [[migration_check]]\ndir = \"multithreaded/migrations\"\n\
837 backup = \"multithreaded\"\nscratch_db = \"sando_scratch_mt\"\n\
838 owner_role = \"multithreaded\"\n"
839 );
840 let cfg: AppConfig = toml::from_str(&raw).unwrap();
841 cfg.validate().unwrap();
842 assert_eq!(cfg.migration_checks[0].backup, "server", "backup defaults");
843 let mt = &cfg.migration_checks[1];
844 assert_eq!(mt.scratch_db.as_deref(), Some("sando_scratch_mt"));
845 assert_eq!(mt.owner_role.as_deref(), Some("multithreaded"));
846 }
847
848 #[test]
849 fn two_migration_checks_sharing_a_scratch_db_are_rejected() {
850 // Including the both-unset case, which is the easy one to write by
851 // accident: each check drops and recreates its database, so the second
852 // would destroy the first's restore and cargo_test would inherit
853 // whichever ran last.
854 let raw = format!(
855 "{MINIMAL}\n\
856 [[migration_check]]\ndir = \"server/migrations\"\n\
857 [[migration_check]]\ndir = \"multithreaded/migrations\"\nbackup = \"multithreaded\"\n"
858 );
859 let cfg: AppConfig = toml::from_str(&raw).unwrap();
860 let err = cfg.validate().unwrap_err().to_string();
861 assert!(err.contains("share a scratch database"), "{err}");
862 }
863
864 #[test]
865 fn a_migration_check_scratch_db_that_is_not_an_identifier_is_rejected() {
866 // It is interpolated into `CREATE DATABASE "..."`.
867 let raw =
868 format!("{MINIMAL}\n[[migration_check]]\ndir = \"m\"\nscratch_db = \"drop; --\"\n");
869 let cfg: AppConfig = toml::from_str(&raw).unwrap();
870 let err = cfg.validate().unwrap_err().to_string();
871 assert!(err.contains("[A-Za-z0-9_]+"), "{err}");
872 }
873
874 #[test]
875 fn an_empty_migration_check_list_is_rejected() {
876 // Same fail-closed rule as test_target: an empty list would make the
877 // gate restore nothing and pass, which is worse than not having it.
878 let raw = format!("{MINIMAL}\nmigration_check = []\n");
879 let cfg: AppConfig = toml::from_str(&raw).unwrap();
880 let err = cfg.validate().unwrap_err().to_string();
881 assert!(err.contains("migration_check list is empty"), "{err}");
882 }
883
884 #[test]
885 fn test_targets_parse_as_a_list() {
886 let raw = format!(
887 "{MINIMAL}\nscratch_db_url = \"postgres:///x\"\n\
888 [[test_target]]\ndir = \"server\"\nfeatures = [\"fast-tests\"]\nscratch_db = true\n\
889 [[test_target]]\ndir = \"shared/tagtree\"\n\
890 [[test_target]]\ndir = \"shared/ops-exec\"\nall_features = true\n"
891 );
892 let cfg: AppConfig = toml::from_str(&raw).unwrap();
893 cfg.validate().unwrap();
894 let dirs: Vec<_> = cfg
895 .test_targets
896 .iter()
897 .map(|t| t.dir.display().to_string())
898 .collect();
899 assert_eq!(dirs, ["server", "shared/tagtree", "shared/ops-exec"]);
900 // Defaults for an entry that names only a dir: no features, no DB.
901 assert!(cfg.test_targets[1].features.is_empty());
902 assert!(!cfg.test_targets[1].scratch_db);
903 assert!(cfg.test_targets[2].all_features);
904 }
905
906 #[test]
907 fn validate_rejects_an_empty_test_target_list() {
908 // An explicit empty list would make cargo_test green having run nothing.
909 let raw = format!("{MINIMAL}\ntest_target = []\n");
910 let cfg: AppConfig = toml::from_str(&raw).unwrap();
911 let err = cfg.validate().unwrap_err().to_string();
912 assert!(err.contains("test_target list is empty"), "got: {err}");
913 }
914
915 #[test]
916 fn validate_rejects_all_features_together_with_features() {
917 let raw = format!(
918 "{MINIMAL}\n[[test_target]]\ndir = \"x\"\nall_features = true\nfeatures = [\"y\"]\n"
919 );
920 let err = toml::from_str::<AppConfig>(&raw)
921 .unwrap()
922 .validate()
923 .unwrap_err()
924 .to_string();
925 assert!(err.contains("pick one"), "got: {err}");
926 }
927
928 #[test]
929 fn scratch_db_without_a_url_is_not_a_config_error() {
930 // `scratch_db` means "export the scratch URL if there is one", not
931 // "require one" — the pre-config gate simply skipped the env when
932 // scratch_db_url was unset, and a project with no postgres at all must
933 // still boot.
934 let raw = format!("{MINIMAL}\n[[test_target]]\ndir = \"server\"\nscratch_db = true\n");
935 toml::from_str::<AppConfig>(&raw)
936 .unwrap()
937 .validate()
938 .unwrap();
939 }
940
941 #[test]
942 fn shipped_daemon_config_parses_and_validates() {
943 // The real sando-daemon.toml next to this crate: catches a typo in the
944 // test_target list before it wedges a build on the host.
945 let raw = std::fs::read_to_string(
946 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("sando-daemon.toml"),
947 )
948 .expect("sando-daemon.toml ships with the crate");
949 let cfg: AppConfig = toml::from_str(&raw).expect("shipped config parses");
950 cfg.validate().expect("shipped config validates");
951 assert!(
952 cfg.test_targets
953 .iter()
954 .any(|t| t.dir == std::path::Path::new("mnw-cli")),
955 "the companion that installs onto prod-1 must be gated",
956 );
957 // Both crates serve JS their build scripts compile best-effort, so an
958 // unlisted frontend is an ungated bundle.
959 for dir in ["server/frontend", "multithreaded/frontend"] {
960 assert!(
961 cfg.frontend_builds
962 .iter()
963 .any(|f| f.dir == std::path::Path::new(dir)),
964 "{dir} must be gated: its build script swallows a tsc error",
965 );
966 }
967 }
968
969 #[test]
970 fn frontend_builds_default_to_empty_and_to_the_build_script() {
971 let cfg: AppConfig = toml::from_str(MINIMAL).unwrap();
972 assert!(
973 cfg.frontend_builds.is_empty(),
974 "a project with no frontend must configure nothing"
975 );
976
977 let raw = format!("{MINIMAL}\n[[frontend_build]]\ndir = \"server/frontend\"\n");
978 let cfg: AppConfig = toml::from_str(&raw).unwrap();
979 cfg.validate().unwrap();
980 assert_eq!(cfg.frontend_builds[0].script, "build");
981 }
982
983 #[test]
984 fn validate_rejects_an_empty_frontend_script() {
985 let raw = format!("{MINIMAL}\n[[frontend_build]]\ndir = \"x\"\nscript = \"\"\n");
986 let err = toml::from_str::<AppConfig>(&raw)
987 .unwrap()
988 .validate()
989 .unwrap_err()
990 .to_string();
991 assert!(err.contains("empty script"), "got: {err}");
992 }
993
994 #[test]
995 fn cargo_target_dir_parses_when_present() {
996 let raw = format!("{MINIMAL}\ncargo_target_dir = \"/srv/sando/cargo-target\"\n");
997 let cfg: AppConfig = toml::from_str(&raw).unwrap();
998 assert_eq!(
999 cfg.cargo_target_dir.as_deref(),
1000 Some(std::path::Path::new("/srv/sando/cargo-target"))
1001 );
1002 }
1003
1004 #[test]
1005 fn cargo_target_dir_defaults_to_none() {
1006 let cfg: AppConfig = toml::from_str(MINIMAL).unwrap();
1007 assert!(
1008 cfg.cargo_target_dir.is_none(),
1009 "omitting it keeps the per-worktree target/"
1010 );
1011 }
1012
1013 #[test]
1014 fn gate_timeout_defaults_when_omitted() {
1015 let cfg: AppConfig = toml::from_str(MINIMAL).unwrap();
1016 assert_eq!(
1017 cfg.gate_timeout_secs, 2400,
1018 "omitting it keeps the 40-min ceiling"
1019 );
1020 }
1021
1022 #[test]
1023 fn gate_timeout_parses_when_present() {
1024 let raw = format!("{MINIMAL}\ngate_timeout_secs = 600\n");
1025 let cfg: AppConfig = toml::from_str(&raw).unwrap();
1026 assert_eq!(cfg.gate_timeout_secs, 600);
1027 }
1028
1029 #[test]
1030 fn companions_default_empty_and_parse_when_present() {
1031 let base: AppConfig = toml::from_str(MINIMAL).unwrap();
1032 assert!(
1033 base.companions.is_empty(),
1034 "omitting [[companion]] keeps it empty"
1035 );
1036
1037 let raw = format!(
1038 "{MINIMAL}\n[[companion]]\nname = \"mnw-cli\"\nmanifest_dir = \"mnw-cli\"\nbin = \"mnw-cli\"\n"
1039 );
1040 let cfg: AppConfig = toml::from_str(&raw).unwrap();
1041 assert_eq!(cfg.companions.len(), 1);
1042 assert_eq!(cfg.companions[0].name, "mnw-cli");
1043 assert_eq!(
1044 cfg.companions[0].manifest_dir,
1045 std::path::Path::new("mnw-cli")
1046 );
1047 assert_eq!(cfg.companions[0].bin, "mnw-cli");
1048 }
1049
1050 #[test]
1051 fn scratch_owner_role_defaults_to_makenotwork() {
1052 let cfg: AppConfig = toml::from_str(MINIMAL).unwrap();
1053 assert_eq!(cfg.scratch_owner_role, "makenotwork");
1054 cfg.validate().unwrap();
1055 }
1056
1057 #[test]
1058 fn scratch_owner_role_rejects_non_identifiers() {
1059 // It is interpolated into DDL as a bare identifier, so anything outside
1060 // [A-Za-z0-9_]+ must fail at load rather than reach the scratch cluster.
1061 for bad in ["", "mnw-owner", "own er", "own\"er", "x; DROP ROLE sando"] {
1062 let raw = format!("{MINIMAL}\nscratch_owner_role = {bad:?}\n");
1063 let cfg: AppConfig = toml::from_str(&raw).unwrap();
1064 assert!(cfg.validate().is_err(), "should reject {bad:?}");
1065 }
1066 }
1067
1068 #[test]
1069 fn scratch_owner_role_accepts_a_plain_identifier() {
1070 let raw = format!("{MINIMAL}\nscratch_owner_role = \"app_owner_2\"\n");
1071 let cfg: AppConfig = toml::from_str(&raw).unwrap();
1072 cfg.validate().unwrap();
1073 assert_eq!(cfg.scratch_owner_role, "app_owner_2");
1074 }
1075
1076 #[test]
1077 fn scratch_db_url_accepts_loopback_and_socket_forms() {
1078 // Every shape a legitimate on-box scratch DB takes. IP literals and the
1079 // socket forms are classified without DNS; `localhost` resolves locally.
1080 for ok in [
1081 "postgres://sando@127.0.0.1/sando_scratch", // the shipped form
1082 "postgres://sando:s3cret@127.0.0.1:5432/scratch",
1083 "postgres://sando@[::1]:5432/scratch",
1084 "postgres:///scratch", // unix socket, no host
1085 "postgres://sando@%2Fvar%2Frun%2Fpostgresql/scratch", // encoded socket dir
1086 "postgres://localhost/scratch",
1087 ] {
1088 assert!(
1089 assert_scratch_db_loopback(ok).is_ok(),
1090 "should accept on-box url {ok}"
1091 );
1092 }
1093 }
1094
1095 #[test]
1096 fn scratch_db_url_rejects_off_box_hosts() {
1097 // Non-loopback IP literals reject without any DNS lookup.
1098 for bad in [
1099 "postgres://sando@10.1.2.3/scratch",
1100 "postgres://sando:pw@192.168.1.5:5432/scratch",
1101 "postgres://sando@[2001:db8::1]/scratch",
1102 ] {
1103 let err = assert_scratch_db_loopback(bad).unwrap_err().to_string();
1104 assert!(
1105 err.contains("loopback") || err.contains("off-box"),
1106 "got: {err}"
1107 );
1108 }
1109 }
1110
1111 #[test]
1112 fn validate_rejects_a_non_loopback_scratch_db_url() {
1113 // A hostname that cannot be proven loopback fails closed at load. The
1114 // `.example` TLD (RFC 6761) never resolves, so this exercises the
1115 // resolution-failure path without depending on external DNS shape.
1116 let raw = format!(
1117 "{MINIMAL}\nscratch_db_url = \"postgres://sando@db.internal.example:5432/scratch\"\n"
1118 );
1119 let cfg: AppConfig = toml::from_str(&raw).unwrap();
1120 assert!(
1121 cfg.validate().is_err(),
1122 "a non-loopback scratch_db_url must fail startup"
1123 );
1124 }
1125
1126 #[test]
1127 fn an_absent_build_host_declares_the_product_intake_only() {
1128 // There is still no default host: absent does not mean "build anywhere",
1129 // it means Sando does not build this product at all, and `build::run`
1130 // refuses rather than choosing. The no-build-on-prod guard cannot be
1131 // skipped by omission, because omission removes the build path.
1132 let without = MINIMAL.replace("build_host = \"fw13\"\n", "");
1133 let cfg: AppConfig = toml::from_str(&without).expect("intake-only is a valid product");
1134 assert_eq!(cfg.build_host, None);
1135 }
1136 }
1137