Skip to main content

max / makenotwork

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