Skip to main content

max / makenotwork

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