Skip to main content

max / makenotwork

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