Skip to main content

max / makenotwork

27.3 KB · 650 lines History Blame Raw
1 use anyhow::{Context, Result};
2 use serde::Deserialize;
3 use std::net::{IpAddr, ToSocketAddrs};
4 use std::path::PathBuf;
5
6 #[derive(Debug, Clone, Deserialize)]
7 pub struct Config {
8 pub listen: String,
9 pub db_path: PathBuf,
10 pub topology_path: PathBuf,
11 /// The runtime hostname (`/proc/sys/kernel/hostname`) this daemon is
12 /// permitted to build on. `build::run` refuses to compile unless the live
13 /// host matches, so a `sandod` misdeployed onto a prod/serving node (e.g.
14 /// Hetzner) cannot build there — "never build on prod" becomes an invariant
15 /// rather than a code-path accident. Required: there is no safe default.
16 pub build_host: String,
17 /// MM-local checkout scratch dir (per-sha worktrees live here).
18 pub workdir: PathBuf,
19 /// MM-local releases dir (`releases/<version>/` and `current` live here).
20 pub release_root: PathBuf,
21 /// Scratch postgres DB url used by `migration_dry_run`. Sando drops and
22 /// recreates the schema on every run, so do not point this at anything
23 /// you care about. Validated at load to address loopback only (127.0.0.1,
24 /// `::1`, `localhost`, or a local unix socket): a gate that DROPs a database
25 /// must never reach off-box, and a non-loopback host refuses startup (see
26 /// `validate`).
27 #[serde(default)]
28 pub scratch_db_url: Option<String>,
29 /// Role that owns the restored objects in a prod dump. `pg_dump` emits
30 /// `ALTER ... OWNER TO <role>` for every object, so the role must exist in
31 /// the scratch cluster before `migration_dry_run` restores — a superuser
32 /// connection does not conjure it. `reset_scratch` creates it (NOLOGIN) and
33 /// grants it CREATE on public, so a fresh box needs no manual SQL.
34 ///
35 /// Interpolated into DDL as an identifier, so it is restricted to
36 /// `[A-Za-z0-9_]+` at load (see `validate`) rather than quoted at use.
37 #[serde(default = "default_scratch_owner_role")]
38 pub scratch_owner_role: String,
39 /// Loopback port the `boot_smoke` gate tells the staged artifact to bind
40 /// (`SANDO_BOOT_SMOKE_PORT`), then probes `GET /health` on. Lets the gate
41 /// prove readiness, not just liveness. Fixed rather than ephemeral so the
42 /// gate knows where to probe; builds are serialized so there's no contention.
43 #[serde(default = "default_boot_smoke_port")]
44 pub boot_smoke_port: u16,
45 /// Loopback port the `code_smoke` gate tells the freshly-built binary to
46 /// bind (`HOST=127.0.0.1 PORT=<this>`), then probes `GET /health` on after
47 /// migrating + seeding a throwaway DB. Separate from `boot_smoke_port` only
48 /// for clarity — the two gates never run concurrently (builds are
49 /// serialized).
50 #[serde(default = "default_code_smoke_port")]
51 pub code_smoke_port: u16,
52 /// Names of cargo bin targets the server crate produces (files under
53 /// `target/release/`). First entry is the primary unit (referenced from
54 /// the systemd unit's ExecStart). Defaults to `["server"]`; MNW ships
55 /// `["makenotwork", "mnw-admin"]`.
56 #[serde(default = "default_bin_names")]
57 pub bin_names: Vec<String>,
58 /// Root for per-gate run logs (`<logs_root>/<version>/<gate>.log`).
59 /// Served via `GET /logs/{version}/{gate}`. Defaults to `/srv/sando/logs`.
60 #[serde(default = "default_logs_root")]
61 pub logs_root: PathBuf,
62 /// Shared cargo target dir. When set, every `cargo build`/`cargo test` the
63 /// pipeline runs uses this one `CARGO_TARGET_DIR` instead of each per-sha
64 /// worktree's own `target/`, so a 1-line diff reuses the previous sha's
65 /// compiled dependencies (a ~10-min clean build becomes a 1–2-min
66 /// incremental one). Safe because builds are serialized — a new `/rebuild`
67 /// aborts the in-flight one — so no two cargo invocations ever share the
68 /// dir concurrently. Unset = per-worktree `target/` (the historical
69 /// behavior). Cargo creates the dir if absent.
70 #[serde(default)]
71 pub cargo_target_dir: Option<PathBuf>,
72 /// Non-binary contents to stage into each release dir alongside
73 /// `bin_names`. Each entry copies `worktree/<src>` into
74 /// `<release>/<dst>`. `required=false` makes a missing source a warn
75 /// (older shas missing one of these don't break sando mid-bisect);
76 /// `required=true` errors. Default is empty — projects opt-in via
77 /// daemon config so the sando code stays project-agnostic.
78 #[serde(default)]
79 pub release_contents: Vec<ReleaseEntry>,
80 /// Wall-clock ceiling (seconds) for the `cargo_test` and `migration_dry_run`
81 /// gates. A hung suite (deadlocked test, wedged child) otherwise blocks the
82 /// pipeline until a new `/rebuild` aborts it; past the ceiling the gate is
83 /// killed and fails with `GateFailure::Timeout`. Default 2400s (40 min) —
84 /// generous for a full release test suite, fatal only to a genuine hang.
85 #[serde(default = "default_gate_timeout_secs")]
86 pub gate_timeout_secs: u64,
87 /// Extra crates built from the same worktree/sha as the server and shipped
88 /// in the release bundle, so a service that shares the server's contract
89 /// (e.g. `mnw-cli`, which talks to `/api/internal/*`) can't drift out of
90 /// lockstep. Each is compiled after the server; a companion that fails to
91 /// build fails the whole pipeline — that is the lockstep guarantee. Which
92 /// nodes actually install a given companion is a per-node decision (see
93 /// `Node::companions`); this list only says what to build + stage. Default
94 /// empty, so the sando code stays project-agnostic.
95 #[serde(default, rename = "companion")]
96 pub companions: Vec<Companion>,
97 /// Crates the `cargo_test` gate runs, in order. The gate used to hardcode
98 /// `worktree/server`, so everything else in the repo shipped ungated —
99 /// including `mnw-cli`, which is built as a companion and installed onto
100 /// prod-1. Default is the historical single `server` entry, so a project
101 /// that configures nothing keeps today's behavior.
102 #[serde(default = "default_test_targets", rename = "test_target")]
103 pub test_targets: Vec<TestTarget>,
104 /// Frontend builds the `code_smoke` gate runs, in order, before it touches a
105 /// database. Each is an npm project whose compiled output is served by the
106 /// binary but is not produced by `cargo build` in any way cargo can fail on:
107 /// both MNW frontends compile from a build script that reports a `tsc` error
108 /// as a `cargo::warning` and lets the Rust build succeed against whatever
109 /// `static/dist/` already holds, deliberately, so a type error in a chat
110 /// widget cannot stop the forum from compiling. The cost of that choice is
111 /// that nothing downstream noticed either, and the deploy shipped a stale
112 /// bundle. This is where the same failure is fatal. Default empty, so a
113 /// project that configures nothing keeps today's behavior.
114 #[serde(default, rename = "frontend_build")]
115 pub frontend_builds: Vec<FrontendBuild>,
116 /// How old (hours) the fetched prod dump may be before `migration_dry_run`
117 /// refuses to run against it. The gate restores whatever `backups` row is
118 /// newest, and presence alone used to be the only check — so a fetch that
119 /// stopped working left the gate passing green against an ever-older schema,
120 /// which is the failure it exists to catch. Sando ran 45 days that way in
121 /// June-July 2026. Default 48h: a daily fetch may miss one night without
122 /// tripping this.
123 #[serde(default = "default_backup_max_age_hours")]
124 pub backup_max_age_hours: u32,
125 }
126
127 /// One npm project the `code_smoke` gate compiles.
128 #[derive(Debug, Clone, Deserialize)]
129 pub struct FrontendBuild {
130 /// Directory under the worktree holding `package.json`
131 /// (e.g. `server/frontend`).
132 pub dir: PathBuf,
133 /// npm script to run. Defaults to `build`, which is what emits the bundle
134 /// the release actually serves; `typecheck` would prove less (it never
135 /// writes `static/dist/`, so it cannot catch an emit failure).
136 #[serde(default = "default_frontend_script")]
137 pub script: String,
138 }
139
140 fn default_frontend_script() -> String {
141 "build".into()
142 }
143
144 /// One crate's test suite, as run by the `cargo_test` gate.
145 #[derive(Debug, Clone, Deserialize)]
146 pub struct TestTarget {
147 /// Directory under the worktree holding the crate's `Cargo.toml`
148 /// (e.g. `server`, `shared/tagtree`).
149 pub dir: PathBuf,
150 /// Cargo features to enable. MNW's server needs `fast-tests`; most crates
151 /// need none.
152 #[serde(default)]
153 pub features: Vec<String>,
154 /// Pass `--all-features` instead of naming features. Mutually exclusive
155 /// with `features` (rejected at load).
156 #[serde(default)]
157 pub all_features: bool,
158 /// Export `DATABASE_URL` / `TEST_DATABASE_URL` (pointing at
159 /// `scratch_db_url`) for this crate's tests. Off by default: a crate using
160 /// sqlx's offline query data goes *online* when `DATABASE_URL` is set and
161 /// will fail to compile against the wrong database.
162 #[serde(default)]
163 pub scratch_db: bool,
164 }
165
166 fn default_test_targets() -> Vec<TestTarget> {
167 vec![TestTarget {
168 dir: PathBuf::from("server"),
169 features: vec!["fast-tests".into()],
170 all_features: false,
171 scratch_db: true,
172 }]
173 }
174
175 /// A crate built alongside the server and staged into the release bundle under
176 /// `companions/<name>/<bin>`. Referenced by `Node::companions[].name` to decide
177 /// where (if anywhere) it deploys.
178 #[derive(Debug, Clone, Deserialize)]
179 pub struct Companion {
180 /// Logical id, matched by a node's companion entry. Also the bundle subdir.
181 pub name: String,
182 /// Directory under the worktree holding the crate's `Cargo.toml`
183 /// (e.g. `mnw-cli`). Built with `cargo build --release` in that dir.
184 pub manifest_dir: PathBuf,
185 /// Binary name produced under the crate's `target/release/`.
186 pub bin: String,
187 }
188
189 /// A directory or file copied from the worktree into the staged release dir.
190 /// Multiple entries with the same `dst` are allowed and merged (used by MNW
191 /// to build `docs/` from three different worktree sources).
192 #[derive(Debug, Clone, Deserialize)]
193 pub struct ReleaseEntry {
194 /// Path relative to the worktree root (e.g. `server/static`).
195 pub src: PathBuf,
196 /// Path relative to the release dir (e.g. `static`). Parent dirs are
197 /// created as needed.
198 pub dst: PathBuf,
199 /// If true, a missing source aborts the build. If false, log warn + skip.
200 #[serde(default)]
201 pub required: bool,
202 }
203
204 /// The host component of a `postgres://` URL, or `None` when the URL addresses a
205 /// local unix socket (no authority). Hand-parsed rather than pulling in a URL
206 /// crate, matching the daemon's existing PG-URL handling in `gates.rs`.
207 fn scratch_db_host(url: &str) -> Option<String> {
208 let after = url.find("://").map(|i| i + 3)?;
209 let authority_end = url[after..]
210 .find(['/', '?', '#'])
211 .map_or(url.len(), |i| after + i);
212 let authority = &url[after..authority_end];
213 // Drop userinfo: keep everything after the last '@' (`user:pass@host` → `host`).
214 let host_port = authority.rsplit('@').next().unwrap_or(authority);
215 // Split the host from an optional port. Bracketed IPv6 (`[::1]:5432`) first;
216 // otherwise a hostname or IPv4, neither of which contains ':'.
217 let host = if let Some(rest) = host_port.strip_prefix('[') {
218 rest.split(']').next().unwrap_or("")
219 } else {
220 host_port.split(':').next().unwrap_or("")
221 };
222 if host.is_empty() {
223 None
224 } else {
225 Some(host.to_string())
226 }
227 }
228
229 /// Refuse a `scratch_db_url` that could reach a database off this box.
230 /// `migration_dry_run` DROPs and recreates the scratch schema, so pointing it at
231 /// a remote (a typo, or a config copied from staging) would wipe the wrong
232 /// database. Loopback is absolute here — there is deliberately no
233 /// `allow_remote_scratch_db` escape hatch for a database the daemon destroys.
234 fn assert_scratch_db_loopback(url: &str) -> Result<()> {
235 let Some(host) = scratch_db_host(url) else {
236 return Ok(()); // no authority → local unix socket
237 };
238 // A percent-encoded unix socket path (`postgres://%2Fvar%2Frun%2Fpg/db`) is
239 // local. A decoded path would start with '/'; `%2f` is its encoded form.
240 if host.starts_with('/') || host.to_ascii_lowercase().starts_with("%2f") {
241 return Ok(());
242 }
243 // An IP literal is classified without touching DNS.
244 if let Ok(ip) = host.parse::<IpAddr>() {
245 anyhow::ensure!(
246 ip.is_loopback(),
247 "scratch_db_url host {host} is not loopback ({ip}); migration_dry_run DROPs and \
248 recreates this database, so it must never point off-box (use 127.0.0.1, ::1, \
249 localhost, or a local unix socket)",
250 );
251 return Ok(());
252 }
253 // A hostname: resolve and require every resolved address to be loopback.
254 // Resolution failure fails closed — a name we cannot prove is local is not a
255 // name we let a schema-dropping gate connect to.
256 let addrs: Vec<_> = (host.as_str(), 0u16)
257 .to_socket_addrs()
258 .with_context(|| format!("resolving scratch_db_url host {host} to confirm it is loopback"))?
259 .collect();
260 anyhow::ensure!(
261 !addrs.is_empty(),
262 "scratch_db_url host {host} resolved to no addresses; cannot confirm it is loopback",
263 );
264 anyhow::ensure!(
265 addrs.iter().all(|a| a.ip().is_loopback()),
266 "scratch_db_url host {host} resolves off-box (not loopback); migration_dry_run DROPs and \
267 recreates this database, so it must never point off-box",
268 );
269 Ok(())
270 }
271
272 fn default_bin_names() -> Vec<String> {
273 vec!["server".into()]
274 }
275 fn default_scratch_owner_role() -> String {
276 "makenotwork".into()
277 }
278 fn default_logs_root() -> PathBuf {
279 PathBuf::from("/srv/sando/logs")
280 }
281 fn default_boot_smoke_port() -> u16 {
282 18181
283 }
284 fn default_code_smoke_port() -> u16 {
285 18182
286 }
287 fn default_gate_timeout_secs() -> u64 {
288 2400
289 }
290 fn default_backup_max_age_hours() -> u32 {
291 48
292 }
293
294 impl Config {
295 /// Primary binary — the one the systemd unit's ExecStart points at.
296 pub fn primary_bin(&self) -> &str {
297 self.bin_names
298 .first()
299 .map_or("server", std::string::String::as_str)
300 }
301
302 pub fn load() -> Result<Self> {
303 let path = std::env::var("SANDO_CONFIG").unwrap_or_else(|_| "sando-daemon.toml".into());
304 let raw = std::fs::read_to_string(&path)
305 .with_context(|| format!("reading daemon config at {path}"))?;
306 let cfg: Self = toml::from_str(&raw)?;
307 cfg.validate()?;
308 Ok(cfg)
309 }
310
311 /// Invariants the deserializer can't express. Runs at load (and so under
312 /// `--check-config`), never at use — a bad value fails startup once, loudly,
313 /// rather than at the first gate that happens to touch it.
314 pub fn validate(&self) -> Result<()> {
315 anyhow::ensure!(
316 !self.scratch_owner_role.is_empty()
317 && self
318 .scratch_owner_role
319 .bytes()
320 .all(|b| b.is_ascii_alphanumeric() || b == b'_'),
321 "scratch_owner_role must be non-empty and match [A-Za-z0-9_]+ (got {:?}); it is \
322 interpolated into DDL as a bare identifier",
323 self.scratch_owner_role,
324 );
325 anyhow::ensure!(
326 !self.test_targets.is_empty(),
327 "test_target list is empty; cargo_test would run nothing and pass. Omit the \
328 key entirely to get the default `server` target.",
329 );
330 for t in &self.test_targets {
331 anyhow::ensure!(
332 !t.all_features || t.features.is_empty(),
333 "test_target {} sets both all_features and features; pick one",
334 t.dir.display(),
335 );
336 }
337 for f in &self.frontend_builds {
338 anyhow::ensure!(
339 !f.script.is_empty(),
340 "frontend_build {} has an empty script; omit the key for the default `build`",
341 f.dir.display(),
342 );
343 }
344 if let Some(url) = self.scratch_db_url.as_deref() {
345 assert_scratch_db_loopback(url)
346 .context("scratch_db_url must address a loopback (on-box) database")?;
347 }
348 Ok(())
349 }
350
351 #[cfg(test)]
352 pub fn for_tests() -> Self {
353 Self {
354 listen: "127.0.0.1:0".into(),
355 db_path: PathBuf::from(":memory:"),
356 topology_path: PathBuf::from("/tmp/sando-test-topology.toml"),
357 build_host: "test-host".into(),
358 workdir: PathBuf::from("/tmp/sando-test-workdir"),
359 release_root: PathBuf::from("/tmp/sando-test-release-root"),
360 scratch_db_url: None,
361 scratch_owner_role: default_scratch_owner_role(),
362 boot_smoke_port: default_boot_smoke_port(),
363 code_smoke_port: default_code_smoke_port(),
364 bin_names: vec!["server".into()],
365 logs_root: PathBuf::from("/tmp/sando-test-logs"),
366 release_contents: Vec::new(),
367 cargo_target_dir: None,
368 gate_timeout_secs: default_gate_timeout_secs(),
369 companions: Vec::new(),
370 test_targets: default_test_targets(),
371 frontend_builds: Vec::new(),
372 backup_max_age_hours: default_backup_max_age_hours(),
373 }
374 }
375 }
376
377 #[cfg(test)]
378 mod tests {
379 use super::*;
380
381 const MINIMAL: &str = r#"
382 listen = "127.0.0.1:7766"
383 db_path = "./sando.db"
384 topology_path = "../sando.toml"
385 build_host = "fw13"
386 workdir = "./work"
387 release_root = "./releases"
388 "#;
389
390 #[test]
391 fn test_targets_default_to_the_historical_server_entry() {
392 // A project that configures nothing must keep the pre-config behavior:
393 // the server crate, with fast-tests, against the scratch DB.
394 let cfg: Config = toml::from_str(MINIMAL).unwrap();
395 assert_eq!(cfg.test_targets.len(), 1);
396 let t = &cfg.test_targets[0];
397 assert_eq!(t.dir, PathBuf::from("server"));
398 assert_eq!(t.features, ["fast-tests"]);
399 assert!(t.scratch_db);
400 assert!(!t.all_features);
401 }
402
403 #[test]
404 fn test_targets_parse_as_a_list() {
405 let raw = format!(
406 "{MINIMAL}\nscratch_db_url = \"postgres:///x\"\n\
407 [[test_target]]\ndir = \"server\"\nfeatures = [\"fast-tests\"]\nscratch_db = true\n\
408 [[test_target]]\ndir = \"shared/tagtree\"\n\
409 [[test_target]]\ndir = \"shared/ops-exec\"\nall_features = true\n"
410 );
411 let cfg: Config = toml::from_str(&raw).unwrap();
412 cfg.validate().unwrap();
413 let dirs: Vec<_> = cfg
414 .test_targets
415 .iter()
416 .map(|t| t.dir.display().to_string())
417 .collect();
418 assert_eq!(dirs, ["server", "shared/tagtree", "shared/ops-exec"]);
419 // Defaults for an entry that names only a dir: no features, no DB.
420 assert!(cfg.test_targets[1].features.is_empty());
421 assert!(!cfg.test_targets[1].scratch_db);
422 assert!(cfg.test_targets[2].all_features);
423 }
424
425 #[test]
426 fn validate_rejects_an_empty_test_target_list() {
427 // An explicit empty list would make cargo_test green having run nothing.
428 let raw = format!("{MINIMAL}\ntest_target = []\n");
429 let cfg: Config = toml::from_str(&raw).unwrap();
430 let err = cfg.validate().unwrap_err().to_string();
431 assert!(err.contains("test_target list is empty"), "got: {err}");
432 }
433
434 #[test]
435 fn validate_rejects_all_features_together_with_features() {
436 let raw = format!(
437 "{MINIMAL}\n[[test_target]]\ndir = \"x\"\nall_features = true\nfeatures = [\"y\"]\n"
438 );
439 let err = toml::from_str::<Config>(&raw)
440 .unwrap()
441 .validate()
442 .unwrap_err()
443 .to_string();
444 assert!(err.contains("pick one"), "got: {err}");
445 }
446
447 #[test]
448 fn scratch_db_without_a_url_is_not_a_config_error() {
449 // `scratch_db` means "export the scratch URL if there is one", not
450 // "require one" — the pre-config gate simply skipped the env when
451 // scratch_db_url was unset, and a project with no postgres at all must
452 // still boot.
453 let raw = format!("{MINIMAL}\n[[test_target]]\ndir = \"server\"\nscratch_db = true\n");
454 toml::from_str::<Config>(&raw).unwrap().validate().unwrap();
455 }
456
457 #[test]
458 fn shipped_daemon_config_parses_and_validates() {
459 // The real sando-daemon.toml next to this crate: catches a typo in the
460 // test_target list before it wedges a build on the host.
461 let raw = std::fs::read_to_string(
462 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("sando-daemon.toml"),
463 )
464 .expect("sando-daemon.toml ships with the crate");
465 let cfg: Config = toml::from_str(&raw).expect("shipped config parses");
466 cfg.validate().expect("shipped config validates");
467 assert!(
468 cfg.test_targets
469 .iter()
470 .any(|t| t.dir == std::path::Path::new("mnw-cli")),
471 "the companion that installs onto prod-1 must be gated",
472 );
473 // Both crates serve JS their build scripts compile best-effort, so an
474 // unlisted frontend is an ungated bundle.
475 for dir in ["server/frontend", "multithreaded/frontend"] {
476 assert!(
477 cfg.frontend_builds
478 .iter()
479 .any(|f| f.dir == std::path::Path::new(dir)),
480 "{dir} must be gated: its build script swallows a tsc error",
481 );
482 }
483 }
484
485 #[test]
486 fn frontend_builds_default_to_empty_and_to_the_build_script() {
487 let cfg: Config = toml::from_str(MINIMAL).unwrap();
488 assert!(
489 cfg.frontend_builds.is_empty(),
490 "a project with no frontend must configure nothing"
491 );
492
493 let raw = format!("{MINIMAL}\n[[frontend_build]]\ndir = \"server/frontend\"\n");
494 let cfg: Config = toml::from_str(&raw).unwrap();
495 cfg.validate().unwrap();
496 assert_eq!(cfg.frontend_builds[0].script, "build");
497 }
498
499 #[test]
500 fn validate_rejects_an_empty_frontend_script() {
501 let raw = format!("{MINIMAL}\n[[frontend_build]]\ndir = \"x\"\nscript = \"\"\n");
502 let err = toml::from_str::<Config>(&raw)
503 .unwrap()
504 .validate()
505 .unwrap_err()
506 .to_string();
507 assert!(err.contains("empty script"), "got: {err}");
508 }
509
510 #[test]
511 fn cargo_target_dir_parses_when_present() {
512 let raw = format!("{MINIMAL}\ncargo_target_dir = \"/srv/sando/cargo-target\"\n");
513 let cfg: Config = toml::from_str(&raw).unwrap();
514 assert_eq!(
515 cfg.cargo_target_dir.as_deref(),
516 Some(std::path::Path::new("/srv/sando/cargo-target"))
517 );
518 }
519
520 #[test]
521 fn cargo_target_dir_defaults_to_none() {
522 let cfg: Config = toml::from_str(MINIMAL).unwrap();
523 assert!(
524 cfg.cargo_target_dir.is_none(),
525 "omitting it keeps the per-worktree target/"
526 );
527 }
528
529 #[test]
530 fn gate_timeout_defaults_when_omitted() {
531 let cfg: Config = toml::from_str(MINIMAL).unwrap();
532 assert_eq!(
533 cfg.gate_timeout_secs, 2400,
534 "omitting it keeps the 40-min ceiling"
535 );
536 }
537
538 #[test]
539 fn gate_timeout_parses_when_present() {
540 let raw = format!("{MINIMAL}\ngate_timeout_secs = 600\n");
541 let cfg: Config = toml::from_str(&raw).unwrap();
542 assert_eq!(cfg.gate_timeout_secs, 600);
543 }
544
545 #[test]
546 fn companions_default_empty_and_parse_when_present() {
547 let base: Config = toml::from_str(MINIMAL).unwrap();
548 assert!(
549 base.companions.is_empty(),
550 "omitting [[companion]] keeps it empty"
551 );
552
553 let raw = format!(
554 "{MINIMAL}\n[[companion]]\nname = \"mnw-cli\"\nmanifest_dir = \"mnw-cli\"\nbin = \"mnw-cli\"\n"
555 );
556 let cfg: Config = toml::from_str(&raw).unwrap();
557 assert_eq!(cfg.companions.len(), 1);
558 assert_eq!(cfg.companions[0].name, "mnw-cli");
559 assert_eq!(
560 cfg.companions[0].manifest_dir,
561 std::path::Path::new("mnw-cli")
562 );
563 assert_eq!(cfg.companions[0].bin, "mnw-cli");
564 }
565
566 #[test]
567 fn scratch_owner_role_defaults_to_makenotwork() {
568 let cfg: Config = toml::from_str(MINIMAL).unwrap();
569 assert_eq!(cfg.scratch_owner_role, "makenotwork");
570 cfg.validate().unwrap();
571 }
572
573 #[test]
574 fn scratch_owner_role_rejects_non_identifiers() {
575 // It is interpolated into DDL as a bare identifier, so anything outside
576 // [A-Za-z0-9_]+ must fail at load rather than reach the scratch cluster.
577 for bad in ["", "mnw-owner", "own er", "own\"er", "x; DROP ROLE sando"] {
578 let raw = format!("{MINIMAL}\nscratch_owner_role = {bad:?}\n");
579 let cfg: Config = toml::from_str(&raw).unwrap();
580 assert!(cfg.validate().is_err(), "should reject {bad:?}");
581 }
582 }
583
584 #[test]
585 fn scratch_owner_role_accepts_a_plain_identifier() {
586 let raw = format!("{MINIMAL}\nscratch_owner_role = \"app_owner_2\"\n");
587 let cfg: Config = toml::from_str(&raw).unwrap();
588 cfg.validate().unwrap();
589 assert_eq!(cfg.scratch_owner_role, "app_owner_2");
590 }
591
592 #[test]
593 fn scratch_db_url_accepts_loopback_and_socket_forms() {
594 // Every shape a legitimate on-box scratch DB takes. IP literals and the
595 // socket forms are classified without DNS; `localhost` resolves locally.
596 for ok in [
597 "postgres://sando@127.0.0.1/sando_scratch", // the shipped form
598 "postgres://sando:s3cret@127.0.0.1:5432/scratch",
599 "postgres://sando@[::1]:5432/scratch",
600 "postgres:///scratch", // unix socket, no host
601 "postgres://sando@%2Fvar%2Frun%2Fpostgresql/scratch", // encoded socket dir
602 "postgres://localhost/scratch",
603 ] {
604 assert!(
605 assert_scratch_db_loopback(ok).is_ok(),
606 "should accept on-box url {ok}"
607 );
608 }
609 }
610
611 #[test]
612 fn scratch_db_url_rejects_off_box_hosts() {
613 // Non-loopback IP literals reject without any DNS lookup.
614 for bad in [
615 "postgres://sando@10.1.2.3/scratch",
616 "postgres://sando:pw@192.168.1.5:5432/scratch",
617 "postgres://sando@[2001:db8::1]/scratch",
618 ] {
619 let err = assert_scratch_db_loopback(bad).unwrap_err().to_string();
620 assert!(
621 err.contains("loopback") || err.contains("off-box"),
622 "got: {err}"
623 );
624 }
625 }
626
627 #[test]
628 fn validate_rejects_a_non_loopback_scratch_db_url() {
629 // A hostname that cannot be proven loopback fails closed at load. The
630 // `.example` TLD (RFC 6761) never resolves, so this exercises the
631 // resolution-failure path without depending on external DNS shape.
632 let raw = format!(
633 "{MINIMAL}\nscratch_db_url = \"postgres://sando@db.internal.example:5432/scratch\"\n"
634 );
635 let cfg: Config = toml::from_str(&raw).unwrap();
636 assert!(
637 cfg.validate().is_err(),
638 "a non-loopback scratch_db_url must fail startup"
639 );
640 }
641
642 #[test]
643 fn build_host_is_required() {
644 // No safe default: a config without build_host must not parse, so the
645 // no-build-on-prod guard can never be silently skipped.
646 let without = MINIMAL.replace("build_host = \"fw13\"\n", "");
647 assert!(toml::from_str::<Config>(&without).is_err());
648 }
649 }
650