Skip to main content

max / makenotwork

Give Sando a product dimension in its configuration Sando was single-product for its whole life and nothing said so, which is why nothing had to change when a second product arrived and then everything had to change at once. AppId is that missing "which product", made explicit. Splitting the config showed how lopsided the assumption was. Only listen and db_path are genuinely about the daemon; bin_names, release_contents, companions, test_targets, migration_checks, the scratch database and the smoke ports were all already describing one product, with nothing naming which. So DaemonConfig is two keys, and AppConfig is everything else, one per product, each with its own topology, release root and workdir. build_host stays per-product deliberately. It reads like a machine property, but which host may compile a thing is a per-product answer as soon as two products can build on different machines. A config written before this still loads. No [app.*] tables means the file is both halves and describes the single app mnw, so the deployed sando-daemon.toml needs no edit. That matters more here than tidiness: this daemon is the MNW deploy path, and a config that must be edited in lockstep with a binary is a way to brick it. --check-config now validates every product rather than the first. A daemon that understands one config and not another still crash-loops, which is the failure that check exists to prevent.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-06 23:32 UTC
Signed with PGP, not checked
Commit: 7a7665196924818c6340ac610ff44dc3edec9980
Parent: 8c72c5a
8 files changed, +537 insertions, -117 deletions
@@ -10,7 +10,7 @@
10 10 //! is not implicit in promote. That keeps the slowest, most failure-prone step
11 11 //! visible in the TUI rather than buried inside a deploy.
12 12
13 - use crate::config::Config;
13 + use crate::config::AppConfig;
14 14 use crate::topology::Topology;
15 15 use anyhow::{Context, Result, bail};
16 16 use chrono::Utc;
@@ -156,7 +156,7 @@
156 156 /// retry strategy.
157 157 pub async fn fetch(
158 158 pool: &SqlitePool,
159 - cfg: &Arc<Config>,
159 + cfg: &Arc<AppConfig>,
160 160 topo: &Arc<Topology>,
161 161 force: bool,
162 162 only: Option<&str>,
@@ -199,7 +199,7 @@
199 199 /// Pull one configured dump into its `local_path`.
200 200 async fn fetch_one(
201 201 pool: &SqlitePool,
202 - _cfg: &Arc<Config>,
202 + _cfg: &Arc<AppConfig>,
203 203 backup: &crate::topology::BackupConfig,
204 204 force: bool,
205 205 ) -> Result<FetchedBackup> {
@@ -416,7 +416,7 @@
416 416 dest.to_string_lossy().into_owned(),
417 417 ));
418 418 let pool = mem_pool().await;
419 - let cfg = Arc::new(Config::for_tests());
419 + let cfg = Arc::new(AppConfig::for_tests());
420 420
421 421 let fb = fetch(&pool, &cfg, &topo, false, None)
422 422 .await
@@ -449,7 +449,7 @@
449 449 dest.to_string_lossy().into_owned(),
450 450 ));
451 451 let pool = mem_pool().await;
452 - let cfg = Arc::new(Config::for_tests());
452 + let cfg = Arc::new(AppConfig::for_tests());
453 453
454 454 // Seed a prior verified backup far larger than the incoming one; the floor
455 455 // becomes 500_000, well above the ~4 KB dump.
@@ -492,7 +492,7 @@
492 492 topo.backup[0].name = "multithreaded".into();
493 493 let topo = Arc::new(topo);
494 494 let pool = mem_pool().await;
495 - let cfg = Arc::new(Config::for_tests());
495 + let cfg = Arc::new(AppConfig::for_tests());
496 496
497 497 sqlx::query(
498 498 "INSERT INTO backups (name, fetched_at, source, local_path, byte_size) \
@@ -526,7 +526,7 @@
526 526 tmp.path().join("x.sql.gz").to_string_lossy().into_owned(),
527 527 ));
528 528 let pool = mem_pool().await;
529 - let cfg = Arc::new(Config::for_tests());
529 + let cfg = Arc::new(AppConfig::for_tests());
530 530
531 531 let err = fetch(&pool, &cfg, &topo, false, Some("mt"))
532 532 .await
@@ -557,7 +557,7 @@
557 557 });
558 558 let topo = Arc::new(topo);
559 559 let pool = mem_pool().await;
560 - let cfg = Arc::new(Config::for_tests());
560 + let cfg = Arc::new(AppConfig::for_tests());
561 561
562 562 let err = fetch(&pool, &cfg, &topo, false, None)
563 563 .await
@@ -585,7 +585,7 @@
585 585 dest.to_string_lossy().into_owned(),
586 586 ));
587 587 let pool = mem_pool().await;
588 - let cfg = Arc::new(Config::for_tests());
588 + let cfg = Arc::new(AppConfig::for_tests());
589 589 sqlx::query("INSERT INTO backups (fetched_at, source, local_path, byte_size) VALUES (?, 'x', ?, 1000000)")
590 590 .bind(Utc::now().to_rfc3339())
591 591 .bind(dest.to_string_lossy().into_owned())
@@ -627,7 +627,7 @@
627 627 dest.to_string_lossy().into_owned(),
628 628 ));
629 629 let pool = mem_pool().await;
630 - let cfg = Arc::new(Config::for_tests());
630 + let cfg = Arc::new(AppConfig::for_tests());
631 631
632 632 let err = fetch(&pool, &cfg, &topo, true, None)
633 633 .await
@@ -661,7 +661,7 @@
661 661 dest.to_string_lossy().into_owned(),
662 662 ));
663 663 let pool = mem_pool().await;
664 - let cfg = Arc::new(Config::for_tests());
664 + let cfg = Arc::new(AppConfig::for_tests());
665 665
666 666 let res = fetch(&pool, &cfg, &topo, false, None).await;
667 667 assert!(res.is_err(), "a truncated gzip must fail the fetch");
@@ -4,7 +4,7 @@
4 4 //! Runs as a tokio task spawned from `POST /rebuild`; the HTTP request
5 5 //! returns the version id immediately and the task drives the rest.
6 6
7 - use crate::config::Config;
7 + use crate::config::AppConfig;
8 8 use crate::deploy;
9 9 use crate::domain::{GitSha, RunId, TierId, Version};
10 10 use crate::gates::{self, GateCtx};
@@ -60,7 +60,7 @@
60 60
61 61 pub async fn run(
62 62 pool: SqlitePool,
63 - cfg: Arc<Config>,
63 + cfg: Arc<AppConfig>,
64 64 topo: Arc<Topology>,
65 65 sha: GitSha,
66 66 events: crate::events::EventTx,
@@ -243,13 +243,13 @@
243 243 /// Where an aux repo's checkout lands. The single derivation: `checkout_aux_repos`
244 244 /// creates it here and `GateCtx::aux_dirs` resolves `test_target`s against it, so
245 245 /// the two cannot drift into looking in different places.
246 - pub fn aux_checkout_dir(cfg: &Config, aux: &crate::topology::AuxRepo) -> PathBuf {
246 + pub fn aux_checkout_dir(cfg: &AppConfig, aux: &crate::topology::AuxRepo) -> PathBuf {
247 247 cfg.workdir.join(&aux.checkout_dir)
248 248 }
249 249
250 250 /// Every aux repo's checkout dir, keyed by name — what `GateCtx::aux_dirs` holds.
251 251 pub fn aux_checkout_dirs(
252 - cfg: &Config,
252 + cfg: &AppConfig,
253 253 topo: &Topology,
254 254 ) -> std::collections::HashMap<String, PathBuf> {
255 255 topo.aux_repos
@@ -258,7 +258,7 @@
258 258 .collect()
259 259 }
260 260
261 - pub async fn checkout_aux_repos(cfg: &Config, topo: &Topology) -> Result<()> {
261 + pub async fn checkout_aux_repos(cfg: &AppConfig, topo: &Topology) -> Result<()> {
262 262 for aux in &topo.aux_repos {
263 263 let bare = PathBuf::from(&aux.bare_path);
264 264 git::ensure_bare_repo_no_hook(&bare)
@@ -303,7 +303,7 @@
303 303 /// scratch DB. A non-zero exit propagates and fails the pipeline.
304 304 async fn build_companion(
305 305 worktree: &Path,
306 - cfg: &Config,
306 + cfg: &AppConfig,
307 307 c: &crate::config::Companion,
308 308 ) -> Result<PathBuf> {
309 309 let dir = worktree.join(&c.manifest_dir);
@@ -357,7 +357,7 @@
357 357 /// since sandod runs on whatever machine ends up being the Sando host.)
358 358 pub async fn build_and_run_host(
359 359 pool: SqlitePool,
360 - cfg: Arc<Config>,
360 + cfg: Arc<AppConfig>,
361 361 topo: Arc<Topology>,
362 362 sha: GitSha,
363 363 events: crate::events::EventTx,
@@ -384,7 +384,7 @@
384 384 /// synthetic [`BuildArtifact`] — no real `cargo build --release` required.
385 385 pub async fn stage_and_gate(
386 386 pool: SqlitePool,
387 - cfg: Arc<Config>,
387 + cfg: Arc<AppConfig>,
388 388 topo: Arc<Topology>,
389 389 art: BuildArtifact,
390 390 events: crate::events::EventTx,
@@ -622,7 +622,7 @@
622 622 use super::{
623 623 BuildArtifact, check_build_host, checkout_aux_repos, runtime_hostname, stage_and_gate, tail,
624 624 };
625 - use crate::config::{Config, TestTarget};
625 + use crate::config::{AppConfig, TestTarget};
626 626 use crate::domain::{GitSha, RunId, Version};
627 627 use crate::topology::{AuxRepo, BackupConfig, CanaryPolicy, Gate, RepoConfig, Tier, Topology};
628 628 use sqlx::SqlitePool;
@@ -642,7 +642,7 @@
642 642 gates: Vec<Gate>,
643 643 ) -> (
644 644 SqlitePool,
645 - Arc<Config>,
645 + Arc<AppConfig>,
646 646 Arc<Topology>,
647 647 BuildArtifact,
648 648 RunId,
@@ -693,9 +693,8 @@
693 693 .await
694 694 .unwrap();
695 695
696 - let cfg = Config {
697 - listen: "127.0.0.1:0".into(),
698 - db_path: PathBuf::from(":memory:"),
696 + let cfg = AppConfig {
697 + id: crate::domain::AppId::default(),
699 698 topology_path: PathBuf::from("/tmp/test-sando.toml"),
700 699 build_host: "test-host".into(),
701 700 workdir: tmp.path().to_path_buf(),
@@ -780,10 +779,9 @@
780 779 }
781 780
782 781 /// A minimal `Config` whose only field this test path reads is `workdir`.
783 - fn cfg_with_workdir(workdir: PathBuf) -> Config {
784 - Config {
785 - listen: "127.0.0.1:0".into(),
786 - db_path: PathBuf::from(":memory:"),
782 + fn cfg_with_workdir(workdir: PathBuf) -> AppConfig {
783 + AppConfig {
784 + id: crate::domain::AppId::default(),
787 785 topology_path: PathBuf::from("/tmp/test-sando.toml"),
788 786 build_host: "test-host".into(),
789 787 workdir,
@@ -1,12 +1,67 @@
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;
1 25 use anyhow::{Context, Result};
2 26 use serde::Deserialize;
27 + use std::collections::BTreeMap;
3 28 use std::net::{IpAddr, ToSocketAddrs};
4 - use std::path::PathBuf;
29 + use std::path::{Path, PathBuf};
30 + use std::sync::Arc;
5 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.
6 39 #[derive(Debug, Clone, Deserialize)]
7 - pub struct Config {
40 + pub struct DaemonConfig {
8 41 pub listen: String,
9 42 pub db_path: PathBuf,
43 + /// Products this daemon ships, each pointing at its own pipeline config.
44 + /// Empty means the legacy single-app layout: this same file is `mnw`.
45 + #[serde(default, rename = "app")]
46 + pub apps: BTreeMap<String, AppSource>,
47 + }
48 +
49 + /// Where one product's pipeline config lives.
50 + #[derive(Debug, Clone, Deserialize)]
51 + pub struct AppSource {
52 + /// Path to the product's [`AppConfig`] TOML. Its `topology_path` points on
53 + /// to that product's tiers and nodes, so a product is two files, the same
54 + /// shape Sando already had for one.
55 + pub config: PathBuf,
56 + }
57 +
58 + /// One product's pipeline: what to build, what to prove about it, where it goes.
59 + #[derive(Debug, Clone, Deserialize)]
60 + pub struct AppConfig {
61 + /// Which product this is. Not read from the file: it is the key the daemon
62 + /// filed this config under, so a config cannot disagree with its own name.
63 + #[serde(skip)]
64 + pub id: AppId,
10 65 pub topology_path: PathBuf,
11 66 /// The runtime hostname (`/proc/sys/kernel/hostname`) this daemon is
12 67 /// permitted to build on. `build::run` refuses to compile unless the live
@@ -394,7 +449,74 @@
394 449 48
395 450 }
396 451
397 - impl Config {
452 + impl DaemonConfig {
453 + /// Read the daemon config and every product's pipeline beneath it.
454 + ///
455 + /// Returns the daemon half plus one [`AppConfig`] per product, in declared
456 + /// order. A file with no `[app.*]` tables is one app named
457 + /// [`DEFAULT_APP`](crate::domain::DEFAULT_APP), read from that same file.
458 + pub fn load() -> Result<(Self, BTreeMap<AppId, Arc<AppConfig>>)> {
459 + let path = std::env::var("SANDO_CONFIG").unwrap_or_else(|_| "sando-daemon.toml".into());
460 + Self::load_from(Path::new(&path))
461 + }
462 +
463 + /// [`DaemonConfig::load`] against an explicit path, for tests and for
464 + /// `--check-config`.
465 + pub fn load_from(path: &Path) -> Result<(Self, BTreeMap<AppId, Arc<AppConfig>>)> {
466 + let raw = std::fs::read_to_string(path)
467 + .with_context(|| format!("reading daemon config at {}", path.display()))?;
468 + let daemon: Self = toml::from_str(&raw)
469 + .with_context(|| format!("parsing daemon config at {}", path.display()))?;
470 +
471 + let mut apps: BTreeMap<AppId, Arc<AppConfig>> = BTreeMap::new();
472 + if daemon.apps.is_empty() {
473 + // Legacy layout: this file is both halves. Parse it again as a
474 + // pipeline — unknown keys are ignored on both sides, so `listen` and
475 + // `db_path` do not bother the app and `[app.*]` would not bother the
476 + // daemon.
477 + let id = AppId::default();
478 + let mut cfg: AppConfig = toml::from_str(&raw)
479 + .with_context(|| format!("parsing {} as app `{id}`", path.display()))?;
480 + cfg.id = id.clone();
481 + cfg.resolve_paths_against(path);
482 + cfg.validate()?;
483 + apps.insert(id, Arc::new(cfg));
484 + } else {
485 + for (name, src) in &daemon.apps {
486 + let id = AppId::new(name.clone());
487 + let app_path = resolve_against(path, &src.config);
488 + let raw = std::fs::read_to_string(&app_path).with_context(|| {
489 + format!("reading config for app `{id}` at {}", app_path.display())
490 + })?;
491 + let mut cfg: AppConfig = toml::from_str(&raw).with_context(|| {
492 + format!("parsing config for app `{id}` at {}", app_path.display())
493 + })?;
494 + cfg.id = id.clone();
495 + cfg.resolve_paths_against(&app_path);
496 + cfg.validate()
497 + .with_context(|| format!("validating app `{id}`"))?;
498 + apps.insert(id, Arc::new(cfg));
499 + }
500 + }
501 + anyhow::ensure!(
502 + !apps.is_empty(),
503 + "no apps configured; a daemon that ships nothing has nothing to do"
504 + );
505 + Ok((daemon, apps))
506 + }
507 + }
508 +
509 + /// Resolve `p` relative to the directory holding `base`, so a per-app config can
510 + /// name its topology beside itself instead of by absolute path. Absolute paths
511 + /// pass through, which is what the deployed config uses.
512 + fn resolve_against(base: &Path, p: &Path) -> PathBuf {
513 + if p.is_absolute() {
514 + return p.to_path_buf();
515 + }
516 + base.parent().unwrap_or(Path::new(".")).join(p)
517 + }
518 +
519 + impl AppConfig {
398 520 /// Primary binary — the one the systemd unit's ExecStart points at.
399 521 pub fn primary_bin(&self) -> &str {
400 522 self.bin_names
@@ -402,13 +524,8 @@
402 524 .map_or("server", std::string::String::as_str)
403 525 }
404 526
405 - pub fn load() -> Result<Self> {
406 - let path = std::env::var("SANDO_CONFIG").unwrap_or_else(|_| "sando-daemon.toml".into());
407 - let raw = std::fs::read_to_string(&path)
408 - .with_context(|| format!("reading daemon config at {path}"))?;
409 - let cfg: Self = toml::from_str(&raw)?;
410 - cfg.validate()?;
411 - Ok(cfg)
527 + fn resolve_paths_against(&mut self, config_path: &Path) {
528 + self.topology_path = resolve_against(config_path, &self.topology_path);
412 529 }
413 530
414 531 /// Invariants the deserializer can't express. Runs at load (and so under
@@ -499,8 +616,7 @@
499 616 #[cfg(test)]
500 617 pub fn for_tests() -> Self {
501 618 Self {
502 - listen: "127.0.0.1:0".into(),
503 - db_path: PathBuf::from(":memory:"),
619 + id: crate::domain::AppId::default(),
504 620 topology_path: PathBuf::from("/tmp/sando-test-topology.toml"),
505 621 build_host: "test-host".into(),
506 622 workdir: PathBuf::from("/tmp/sando-test-workdir"),
@@ -536,11 +652,140 @@
536 652 release_root = "./releases"
537 653 "#;
538 654
655 + /// Write `body` to `dir/name` and return the path.
656 + fn file(dir: &Path, name: &str, body: &str) -> PathBuf {
657 + let p = dir.join(name);
658 + std::fs::write(&p, body).unwrap();
659 + p
660 + }
661 +
662 + /// A config written before Sando shipped more than one product still loads,
663 + /// as the single app `mnw`.
664 + ///
665 + /// This is the compatibility that matters most in the whole change: sandod
666 + /// is the MNW deploy path, and a binary that cannot read the config already
667 + /// on the box is a brick (postmortem 2026-07-09 #6). The daemon keys and the
668 + /// pipeline keys come out of the same file.
669 + #[test]
670 + fn a_config_with_no_app_tables_loads_as_the_default_app() {
671 + let dir = tempfile::tempdir().unwrap();
672 + let path = file(dir.path(), "sando-daemon.toml", MINIMAL);
673 + let (daemon, apps) = DaemonConfig::load_from(&path).unwrap();
674 +
675 + assert_eq!(daemon.listen, "127.0.0.1:7766");
676 + assert_eq!(apps.len(), 1);
677 + let id = AppId::new(crate::domain::DEFAULT_APP);
678 + let app = &apps[&id];
679 + assert_eq!(app.id, id, "the app must know its own name");
680 + assert_eq!(app.build_host, "fw13");
681 + // Relative paths resolve against the config file, not the daemon's CWD,
682 + // which is what makes a fixture config usable from a test at all.
683 + assert_eq!(app.topology_path, dir.path().join("../sando.toml"));
684 + }
685 +
686 + /// Two products, each with its own pipeline file.
687 + #[test]
688 + fn apps_load_their_own_configs_and_keep_their_own_names() {
689 + let dir = tempfile::tempdir().unwrap();
690 + file(
691 + dir.path(),
692 + "mnw.toml",
693 + r#"
694 + topology_path = "mnw-topology.toml"
695 + build_host = "fw13"
696 + workdir = "./work/mnw"
697 + release_root = "./releases/mnw"
698 + bin_names = ["makenotwork", "mnw-admin"]
699 + "#,
700 + );
701 + file(
702 + dir.path(),
703 + "pom.toml",
704 + r#"
705 + topology_path = "pom-topology.toml"
706 + build_host = "fw13"
707 + workdir = "./work/pom"
708 + release_root = "./releases/pom"
709 + bin_names = ["pom"]
710 + [[test_target]]
711 + dir = "pom"
712 + [[migration_check]]
713 + dir = "pom/migrations"
714 + "#,
715 + );
716 + let path = file(
717 + dir.path(),
718 + "sando-daemon.toml",
719 + r#"
720 + listen = "127.0.0.1:7766"
721 + db_path = "./sando.db"
722 + [app.mnw]
723 + config = "mnw.toml"
724 + [app.pom]
725 + config = "pom.toml"
726 + "#,
727 + );
728 +
729 + let (_daemon, apps) = DaemonConfig::load_from(&path).unwrap();
730 + assert_eq!(apps.len(), 2);
731 + let mnw = &apps[&AppId::new("mnw")];
732 + let pom = &apps[&AppId::new("pom")];
733 + assert_eq!(mnw.primary_bin(), "makenotwork");
734 + assert_eq!(pom.primary_bin(), "pom");
735 + // Each app's paths are its own. Two products sharing a release root
736 + // would publish into each other's content-addressed history.
737 + assert_ne!(mnw.release_root, pom.release_root);
738 + assert_ne!(mnw.workdir, pom.workdir);
739 + assert_eq!(pom.topology_path, dir.path().join("pom-topology.toml"));
740 + assert_eq!(pom.id, AppId::new("pom"));
741 + }
742 +
743 + /// A broken app config names which app, and fails at load rather than at
744 + /// that app's first build.
745 + #[test]
746 + fn a_bad_app_config_is_refused_at_load_and_names_the_app() {
747 + let dir = tempfile::tempdir().unwrap();
748 + file(
749 + dir.path(),
750 + "pom.toml",
751 + r#"
752 + topology_path = "t.toml"
753 + build_host = "fw13"
754 + workdir = "./w"
755 + release_root = "./r"
756 + scratch_owner_role = "not a valid identifier"
757 + "#,
758 + );
759 + let path = file(
760 + dir.path(),
761 + "sando-daemon.toml",
762 + "listen = \"127.0.0.1:7766\"\ndb_path = \"./sando.db\"\n[app.pom]\nconfig = \"pom.toml\"\n",
763 + );
764 + let err = format!("{:#}", DaemonConfig::load_from(&path).unwrap_err());
765 + assert!(err.contains("pom"), "{err}");
766 + assert!(err.contains("scratch_owner_role"), "{err}");
767 + }
768 +
769 + #[test]
770 + fn an_app_whose_config_is_missing_says_so() {
771 + let dir = tempfile::tempdir().unwrap();
772 + let path = file(
773 + dir.path(),
774 + "sando-daemon.toml",
775 + "listen = \"127.0.0.1:7766\"\ndb_path = \"./sando.db\"\n[app.pom]\nconfig = \"absent.toml\"\n",
776 + );
777 + let err = format!("{:#}", DaemonConfig::load_from(&path).unwrap_err());
778 + assert!(
779 + err.contains("app `pom`") && err.contains("absent.toml"),
780 + "{err}"
781 + );
782 + }
783 +
539 784 #[test]
540 785 fn test_targets_default_to_the_historical_server_entry() {
541 786 // A project that configures nothing must keep the pre-config behavior:
542 787 // the server crate, with fast-tests, against the scratch DB.
543 - let cfg: Config = toml::from_str(MINIMAL).unwrap();
788 + let cfg: AppConfig = toml::from_str(MINIMAL).unwrap();
544 789 assert_eq!(cfg.test_targets.len(), 1);
545 790 let t = &cfg.test_targets[0];
546 791 assert_eq!(t.dir, PathBuf::from("server"));
@@ -554,7 +799,7 @@
554 799 // Same contract as test_targets: configure nothing, get exactly what the
555 800 // gate did when the dir was hardcoded — server/migrations, the `server`
556 801 // dump, and `scratch_db_url` itself (which cargo_test then reuses).
557 - let cfg: Config = toml::from_str(MINIMAL).unwrap();
802 + let cfg: AppConfig = toml::from_str(MINIMAL).unwrap();
558 803 assert_eq!(cfg.migration_checks.len(), 1);
559 804 let m = &cfg.migration_checks[0];
560 805 assert_eq!(m.dir, PathBuf::from("server/migrations"));
@@ -572,7 +817,7 @@
572 817 backup = \"multithreaded\"\nscratch_db = \"sando_scratch_mt\"\n\
573 818 owner_role = \"multithreaded\"\n"
574 819 );
575 - let cfg: Config = toml::from_str(&raw).unwrap();
820 + let cfg: AppConfig = toml::from_str(&raw).unwrap();
576 821 cfg.validate().unwrap();
577 822 assert_eq!(cfg.migration_checks[0].backup, "server", "backup defaults");
578 823 let mt = &cfg.migration_checks[1];
@@ -591,7 +836,7 @@
591 836 [[migration_check]]\ndir = \"server/migrations\"\n\
592 837 [[migration_check]]\ndir = \"multithreaded/migrations\"\nbackup = \"multithreaded\"\n"
593 838 );
594 - let cfg: Config = toml::from_str(&raw).unwrap();
839 + let cfg: AppConfig = toml::from_str(&raw).unwrap();
595 840 let err = cfg.validate().unwrap_err().to_string();
596 841 assert!(err.contains("share a scratch database"), "{err}");
597 842 }
@@ -601,7 +846,7 @@
601 846 // It is interpolated into `CREATE DATABASE "..."`.
602 847 let raw =
603 848 format!("{MINIMAL}\n[[migration_check]]\ndir = \"m\"\nscratch_db = \"drop; --\"\n");
604 - let cfg: Config = toml::from_str(&raw).unwrap();
849 + let cfg: AppConfig = toml::from_str(&raw).unwrap();
605 850 let err = cfg.validate().unwrap_err().to_string();
606 851 assert!(err.contains("[A-Za-z0-9_]+"), "{err}");
607 852 }
@@ -611,7 +856,7 @@
611 856 // Same fail-closed rule as test_target: an empty list would make the
612 857 // gate restore nothing and pass, which is worse than not having it.
613 858 let raw = format!("{MINIMAL}\nmigration_check = []\n");
614 - let cfg: Config = toml::from_str(&raw).unwrap();
859 + let cfg: AppConfig = toml::from_str(&raw).unwrap();
615 860 let err = cfg.validate().unwrap_err().to_string();
616 861 assert!(err.contains("migration_check list is empty"), "{err}");
617 862 }
@@ -624,7 +869,7 @@
624 869 [[test_target]]\ndir = \"shared/tagtree\"\n\
625 870 [[test_target]]\ndir = \"shared/ops-exec\"\nall_features = true\n"
626 871 );
627 - let cfg: Config = toml::from_str(&raw).unwrap();
872 + let cfg: AppConfig = toml::from_str(&raw).unwrap();
628 873 cfg.validate().unwrap();
629 874 let dirs: Vec<_> = cfg
630 875 .test_targets
@@ -642,7 +887,7 @@
642 887 fn validate_rejects_an_empty_test_target_list() {
643 888 // An explicit empty list would make cargo_test green having run nothing.
644 889 let raw = format!("{MINIMAL}\ntest_target = []\n");
645 - let cfg: Config = toml::from_str(&raw).unwrap();
890 + let cfg: AppConfig = toml::from_str(&raw).unwrap();
646 891 let err = cfg.validate().unwrap_err().to_string();
647 892 assert!(err.contains("test_target list is empty"), "got: {err}");
648 893 }
@@ -652,7 +897,7 @@
652 897 let raw = format!(
653 898 "{MINIMAL}\n[[test_target]]\ndir = \"x\"\nall_features = true\nfeatures = [\"y\"]\n"
654 899 );
655 - let err = toml::from_str::<Config>(&raw)
900 + let err = toml::from_str::<AppConfig>(&raw)
656 901 .unwrap()
657 902 .validate()
658 903 .unwrap_err()
@@ -667,7 +912,10 @@
667 912 // scratch_db_url was unset, and a project with no postgres at all must
668 913 // still boot.
669 914 let raw = format!("{MINIMAL}\n[[test_target]]\ndir = \"server\"\nscratch_db = true\n");
670 - toml::from_str::<Config>(&raw).unwrap().validate().unwrap();
915 + toml::from_str::<AppConfig>(&raw)
916 + .unwrap()
917 + .validate()
918 + .unwrap();
671 919 }
672 920
673 921 #[test]
@@ -678,7 +926,7 @@
678 926 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("sando-daemon.toml"),
679 927 )
680 928 .expect("sando-daemon.toml ships with the crate");
681 - let cfg: Config = toml::from_str(&raw).expect("shipped config parses");
929 + let cfg: AppConfig = toml::from_str(&raw).expect("shipped config parses");
682 930 cfg.validate().expect("shipped config validates");
683 931 assert!(
684 932 cfg.test_targets
@@ -700,14 +948,14 @@
700 948
701 949 #[test]
702 950 fn frontend_builds_default_to_empty_and_to_the_build_script() {
703 - let cfg: Config = toml::from_str(MINIMAL).unwrap();
951 + let cfg: AppConfig = toml::from_str(MINIMAL).unwrap();
704 952 assert!(
705 953 cfg.frontend_builds.is_empty(),
706 954 "a project with no frontend must configure nothing"
707 955 );
708 956
709 957 let raw = format!("{MINIMAL}\n[[frontend_build]]\ndir = \"server/frontend\"\n");
710 - let cfg: Config = toml::from_str(&raw).unwrap();
958 + let cfg: AppConfig = toml::from_str(&raw).unwrap();
711 959 cfg.validate().unwrap();
712 960 assert_eq!(cfg.frontend_builds[0].script, "build");
713 961 }
@@ -715,7 +963,7 @@
715 963 #[test]
716 964 fn validate_rejects_an_empty_frontend_script() {
717 965 let raw = format!("{MINIMAL}\n[[frontend_build]]\ndir = \"x\"\nscript = \"\"\n");
718 - let err = toml::from_str::<Config>(&raw)
966 + let err = toml::from_str::<AppConfig>(&raw)
719 967 .unwrap()
720 968 .validate()
721 969 .unwrap_err()
@@ -726,7 +974,7 @@
726 974 #[test]
727 975 fn cargo_target_dir_parses_when_present() {
728 976 let raw = format!("{MINIMAL}\ncargo_target_dir = \"/srv/sando/cargo-target\"\n");
729 - let cfg: Config = toml::from_str(&raw).unwrap();
977 + let cfg: AppConfig = toml::from_str(&raw).unwrap();
730 978 assert_eq!(
731 979 cfg.cargo_target_dir.as_deref(),
732 980 Some(std::path::Path::new("/srv/sando/cargo-target"))
@@ -735,7 +983,7 @@
735 983
736 984 #[test]
737 985 fn cargo_target_dir_defaults_to_none() {
738 - let cfg: Config = toml::from_str(MINIMAL).unwrap();
986 + let cfg: AppConfig = toml::from_str(MINIMAL).unwrap();
739 987 assert!(
740 988 cfg.cargo_target_dir.is_none(),
741 989 "omitting it keeps the per-worktree target/"
@@ -744,7 +992,7 @@
744 992
745 993 #[test]
746 994 fn gate_timeout_defaults_when_omitted() {
747 - let cfg: Config = toml::from_str(MINIMAL).unwrap();
995 + let cfg: AppConfig = toml::from_str(MINIMAL).unwrap();
748 996 assert_eq!(
749 997 cfg.gate_timeout_secs, 2400,
750 998 "omitting it keeps the 40-min ceiling"
@@ -754,13 +1002,13 @@
754 1002 #[test]
755 1003 fn gate_timeout_parses_when_present() {
756 1004 let raw = format!("{MINIMAL}\ngate_timeout_secs = 600\n");
757 - let cfg: Config = toml::from_str(&raw).unwrap();
1005 + let cfg: AppConfig = toml::from_str(&raw).unwrap();
758 1006 assert_eq!(cfg.gate_timeout_secs, 600);
759 1007 }
760 1008
761 1009 #[test]
762 1010 fn companions_default_empty_and_parse_when_present() {
763 - let base: Config = toml::from_str(MINIMAL).unwrap();
1011 + let base: AppConfig = toml::from_str(MINIMAL).unwrap();
764 1012 assert!(
765 1013 base.companions.is_empty(),
766 1014 "omitting [[companion]] keeps it empty"
@@ -769,7 +1017,7 @@
769 1017 let raw = format!(
770 1018 "{MINIMAL}\n[[companion]]\nname = \"mnw-cli\"\nmanifest_dir = \"mnw-cli\"\nbin = \"mnw-cli\"\n"
771 1019 );
772 - let cfg: Config = toml::from_str(&raw).unwrap();
1020 + let cfg: AppConfig = toml::from_str(&raw).unwrap();
773 1021 assert_eq!(cfg.companions.len(), 1);
774 1022 assert_eq!(cfg.companions[0].name, "mnw-cli");
775 1023 assert_eq!(
@@ -781,7 +1029,7 @@
781 1029
782 1030 #[test]
783 1031 fn scratch_owner_role_defaults_to_makenotwork() {
784 - let cfg: Config = toml::from_str(MINIMAL).unwrap();
1032 + let cfg: AppConfig = toml::from_str(MINIMAL).unwrap();
785 1033 assert_eq!(cfg.scratch_owner_role, "makenotwork");
786 1034 cfg.validate().unwrap();
787 1035 }
@@ -792,7 +1040,7 @@
792 1040 // [A-Za-z0-9_]+ must fail at load rather than reach the scratch cluster.
793 1041 for bad in ["", "mnw-owner", "own er", "own\"er", "x; DROP ROLE sando"] {
794 1042 let raw = format!("{MINIMAL}\nscratch_owner_role = {bad:?}\n");
795 - let cfg: Config = toml::from_str(&raw).unwrap();
1043 + let cfg: AppConfig = toml::from_str(&raw).unwrap();
796 1044 assert!(cfg.validate().is_err(), "should reject {bad:?}");
797 1045 }
798 1046 }
@@ -800,7 +1048,7 @@
800 1048 #[test]
801 1049 fn scratch_owner_role_accepts_a_plain_identifier() {
802 1050 let raw = format!("{MINIMAL}\nscratch_owner_role = \"app_owner_2\"\n");
803 - let cfg: Config = toml::from_str(&raw).unwrap();
1051 + let cfg: AppConfig = toml::from_str(&raw).unwrap();
804 1052 cfg.validate().unwrap();
805 1053 assert_eq!(cfg.scratch_owner_role, "app_owner_2");
806 1054 }
@@ -848,7 +1096,7 @@
848 1096 let raw = format!(
849 1097 "{MINIMAL}\nscratch_db_url = \"postgres://sando@db.internal.example:5432/scratch\"\n"
850 1098 );
851 - let cfg: Config = toml::from_str(&raw).unwrap();
1099 + let cfg: AppConfig = toml::from_str(&raw).unwrap();
852 1100 assert!(
853 1101 cfg.validate().is_err(),
854 1102 "a non-loopback scratch_db_url must fail startup"
@@ -860,6 +1108,6 @@
860 1108 // No safe default: a config without build_host must not parse, so the
861 1109 // no-build-on-prod guard can never be silently skipped.
862 1110 let without = MINIMAL.replace("build_host = \"fw13\"\n", "");
863 - assert!(toml::from_str::<Config>(&without).is_err());
1111 + assert!(toml::from_str::<AppConfig>(&without).is_err());
Lines truncated
@@ -25,6 +25,67 @@
25 25 // String-backed identifiers
26 26 // ---------------------------------------------------------------------
27 27
28 + /// A product Sando ships (e.g. "mnw", "pom").
29 + ///
30 + /// Sando was single-product for its whole life, so every keyed table, every
31 + /// tier, and every release root was implicitly about the MNW server. Nothing
32 + /// said so, which is why nothing had to be changed when a second product
33 + /// arrived and everything had to be changed at once. This is that "which
34 + /// product", made explicit and threaded rather than assumed.
35 + ///
36 + /// [`DEFAULT_APP`] is what a pre-multi-app config and every pre-multi-app row
37 + /// mean, so an existing deployment keeps working unedited.
38 + #[derive(
39 + Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, sqlx::Type,
40 + )]
41 + #[sqlx(transparent)]
42 + #[serde(transparent)]
43 + pub struct AppId(String);
44 +
45 + /// The app a config with no `[app.*]` tables describes, and the value migration
46 + /// 011 backfills every pre-existing row to. Sando's one product until 2026-08-06.
47 + pub const DEFAULT_APP: &str = "mnw";
48 +
49 + impl AppId {
50 + pub fn new(s: impl Into<String>) -> Self {
51 + Self(s.into())
52 + }
53 + pub fn as_str(&self) -> &str {
54 + &self.0
55 + }
56 + }
57 +
58 + impl Default for AppId {
59 + fn default() -> Self {
60 + Self(DEFAULT_APP.to_owned())
61 + }
62 + }
63 +
64 + impl fmt::Display for AppId {
65 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66 + self.0.fmt(f)
67 + }
68 + }
69 +
70 + impl FromStr for AppId {
71 + type Err = std::convert::Infallible;
72 + fn from_str(s: &str) -> Result<Self, Self::Err> {
73 + Ok(Self(s.to_owned()))
74 + }
75 + }
76 +
77 + impl From<&str> for AppId {
78 + fn from(s: &str) -> Self {
79 + Self(s.to_owned())
80 + }
81 + }
82 +
83 + impl From<String> for AppId {
84 + fn from(s: String) -> Self {
85 + Self(s)
86 + }
87 + }
88 +
28 89 /// A tier in the deploy topology (e.g. "host", "a", "b").
29 90 ///
30 91 /// Construction does no cross-validation against the loaded `Topology` —
@@ -4,7 +4,7 @@
4 4 //! and the TUI can show them.
5 5
6 6 use crate::classify;
7 - use crate::config::Config;
7 + use crate::config::AppConfig;
8 8 use crate::domain::{GateKind, GateRunId, TierId, Version};
9 9 use crate::events::{self, Event, EventTx};
10 10 use crate::outcome::{GateBlocker, GateFailure, GateOutcome, LogRef, PassNote};
@@ -40,7 +40,7 @@
40 40
41 41 pub struct GateCtx {
42 42 pub pool: SqlitePool,
43 - pub cfg: Arc<Config>,
43 + pub cfg: Arc<AppConfig>,
44 44 pub tier: TierId,
45 45 pub version: Version,
46 46 pub worktree: PathBuf,
@@ -2438,7 +2438,7 @@
2438 2438 fn resolving_ctx(worktree: &str, aux: &[(&str, &str)]) -> GateCtx {
2439 2439 GateCtx {
2440 2440 pool: SqlitePool::connect_lazy("sqlite::memory:").unwrap(),
2441 - cfg: std::sync::Arc::new(crate::config::Config::for_tests()),
2441 + cfg: std::sync::Arc::new(crate::config::AppConfig::for_tests()),
2442 2442 tier: TierId::new("host"),
2443 2443 version: "0.1.0".parse().unwrap(),
2444 2444 worktree: PathBuf::from(worktree),
@@ -2500,7 +2500,7 @@
2500 2500 /// A `GateCtx` over `worktree` with the given frontend projects configured.
2501 2501 /// No DB, no artifact — `code_smoke_frontends` touches neither.
2502 2502 async fn frontend_ctx(worktree: &std::path::Path, dirs: &[&str]) -> GateCtx {
2503 - let mut cfg = crate::config::Config::for_tests();
2503 + let mut cfg = crate::config::AppConfig::for_tests();
2504 2504 cfg.frontend_builds = dirs
2505 2505 .iter()
2506 2506 .map(|d| crate::config::FrontendBuild {
@@ -2531,7 +2531,7 @@
2531 2531 /// reaches the backup lookup instead of bailing on config. Nothing here
2532 2532 /// touches postgres — every assertion below blocks before `reset_scratch`.
2533 2533 async fn dry_run_ctx(worktree: &std::path::Path, max_age_hours: u32) -> GateCtx {
2534 - let mut cfg = crate::config::Config::for_tests();
2534 + let mut cfg = crate::config::AppConfig::for_tests();
2535 2535 cfg.scratch_db_url = Some("postgres:///sando_scratch".into());
2536 2536 cfg.backup_max_age_hours = max_age_hours;
2537 2537 cfg.logs_root = worktree.join("logs");
@@ -2585,7 +2585,7 @@
2585 2585
2586 2586 /// Re-point a `dry_run_ctx` at one check, keeping its pool and scratch URL.
2587 2587 fn with_check(ctx: &mut GateCtx, check: crate::config::MigrationCheck) {
2588 - let mut cfg = crate::config::Config::for_tests();
2588 + let mut cfg = crate::config::AppConfig::for_tests();
2589 2589 cfg.scratch_db_url = ctx.cfg.scratch_db_url.clone();
2590 2590 cfg.backup_max_age_hours = ctx.cfg.backup_max_age_hours;
2591 2591 cfg.logs_root = ctx.cfg.logs_root.clone();
@@ -2859,7 +2859,7 @@
2859 2859 // passed" having run none. Uses an empty tempdir as the worktree, so
2860 2860 // no cargo process is ever spawned.
2861 2861 let tmp = tempfile::tempdir().unwrap();
2862 - let mut cfg = crate::config::Config::for_tests();
2862 + let mut cfg = crate::config::AppConfig::for_tests();
2863 2863 cfg.test_targets = vec![target("server"), target("mnw-cli")];
2864 2864 cfg.logs_root = tmp.path().join("logs");
2865 2865 let pool = SqlitePoolOptions::new()
@@ -2898,7 +2898,7 @@
2898 2898 async fn scratch_db_env_is_opt_in_per_target() {
2899 2899 // Exporting DATABASE_URL knocks sqlx out of offline mode, so a crate
2900 2900 // shipping .sqlx data must not see it.
2901 - let mut cfg = crate::config::Config::for_tests();
2901 + let mut cfg = crate::config::AppConfig::for_tests();
2902 2902 cfg.scratch_db_url = Some("postgres://sando@127.0.0.1/sando_scratch".into());
2903 2903 let ctx = GateCtx {
2904 2904 pool: SqlitePoolOptions::new()
@@ -2939,7 +2939,7 @@
2939 2939 .max_connections(1)
2940 2940 .connect_lazy("sqlite::memory:")
2941 2941 .unwrap(),
2942 - cfg: std::sync::Arc::new(crate::config::Config::for_tests()),
2942 + cfg: std::sync::Arc::new(crate::config::AppConfig::for_tests()),
2943 2943 tier: TierId::new("host"),
2944 2944 version: "0.1.0".parse().unwrap(),
2945 2945 worktree: std::path::PathBuf::from("/tmp/wt"),
@@ -3019,7 +3019,7 @@
3019 3019 std::fs::create_dir_all(&crate_dir).unwrap();
3020 3020 std::fs::write(crate_dir.join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap();
3021 3021
3022 - let mut cfg = crate::config::Config::for_tests();
3022 + let mut cfg = crate::config::AppConfig::for_tests();
3023 3023 cfg.test_targets = vec![target("server")];
3024 3024 cfg.logs_root = tmp.path().join("logs");
3025 3025 let ctx = GateCtx {
@@ -3104,7 +3104,7 @@
3104 3104 .max_connections(1)
3105 3105 .connect_lazy("sqlite::memory:")
3106 3106 .unwrap(),
3107 - cfg: std::sync::Arc::new(crate::config::Config::for_tests()),
3107 + cfg: std::sync::Arc::new(crate::config::AppConfig::for_tests()),
3108 3108 tier: TierId::new("host"),
3109 3109 version: "0.1.0".parse().unwrap(),
3110 3110 worktree: std::path::PathBuf::from("/tmp/wt"),
@@ -3236,7 +3236,7 @@
3236 3236 sqlx::query("INSERT INTO versions (version, git_sha, built_at, artifact_path) VALUES ('0.1.0', 'abc1234', '2026-01-01T00:00:00Z', '/tmp/x')")
3237 3237 .execute(&pool).await.unwrap();
3238 3238
3239 - let cfg = std::sync::Arc::new(crate::config::Config::for_tests());
3239 + let cfg = std::sync::Arc::new(crate::config::AppConfig::for_tests());
3240 3240 let ctx = GateCtx {
3241 3241 pool: pool.clone(),
3242 3242 cfg,
@@ -3291,7 +3291,7 @@
3291 3291 sqlx::query("INSERT INTO versions (version, git_sha, built_at, artifact_path) VALUES ('0.1.0', 'abc1234', '2026-01-01T00:00:00Z', '/tmp/x')")
3292 3292 .execute(&pool).await.unwrap();
3293 3293
3294 - let cfg = std::sync::Arc::new(crate::config::Config::for_tests());
3294 + let cfg = std::sync::Arc::new(crate::config::AppConfig::for_tests());
3295 3295 let ctx = GateCtx {
3296 3296 pool: pool.clone(),
3297 3297 cfg,
@@ -3657,7 +3657,7 @@
3657 3657 sqlx::query("INSERT INTO versions (version, git_sha, built_at, artifact_path) VALUES ('0.1.0', 'abc1234', '2026-01-01T00:00:00Z', '/tmp/x')")
3658 3658 .execute(&pool).await.unwrap();
3659 3659
3660 - let cfg = std::sync::Arc::new(crate::config::Config::for_tests()); // scratch_db_url: None
3660 + let cfg = std::sync::Arc::new(crate::config::AppConfig::for_tests()); // scratch_db_url: None
3661 3661 let ctx = GateCtx {
3662 3662 pool: pool.clone(),
3663 3663 cfg,
@@ -32,20 +32,26 @@
32 32 /// pass here guarantees `run` gets past them. Errors carry anyhow's context
33 33 /// chain and exit non-zero via `main`'s `Result`.
34 34 fn check_config() -> Result<()> {
35 - let cfg = config::Config::load()?;
36 - let topo = validate_loaded(&cfg)?;
37 - println!(
38 - "sandod --check-config: OK — {} tier(s), topology {}",
39 - topo.tiers.len(),
40 - cfg.topology_path.display()
41 - );
35 + let (_daemon, apps) = config::DaemonConfig::load()?;
36 + // Every product, not just the first. A daemon that can parse one config and
37 + // not another still crash-loops, and the whole point of this check is that a
38 + // binary which cannot understand what it is about to run never gets
39 + // installed.
40 + for (id, cfg) in &apps {
41 + let topo = validate_loaded(cfg)?;
42 + println!(
43 + "sandod --check-config: OK — app `{id}`, {} tier(s), topology {}",
44 + topo.tiers.len(),
45 + cfg.topology_path.display()
46 + );
47 + }
42 48 Ok(())
43 49 }
44 50
45 51 /// The parse + invariant work `check_config` does once the daemon config is in
46 52 /// hand. Split out so it can be exercised against a fixture config without
47 - /// touching process env (`Config::load` reads `SANDO_CONFIG`/CWD).
48 - fn validate_loaded(cfg: &config::Config) -> Result<topology::Topology> {
53 + /// touching process env (`AppConfig::load` reads `SANDO_CONFIG`/CWD).
54 + fn validate_loaded(cfg: &config::AppConfig) -> Result<topology::Topology> {
49 55 cfg.validate()?;
50 56 let topo = topology::Topology::load(&cfg.topology_path)?;
51 57 topo.ensure_build_host_not_serving(&cfg.build_host)?;
@@ -71,16 +77,43 @@
71 77 )
72 78 .init();
73 79
74 - let cfg = Arc::new(config::Config::load()?);
75 - let topo = Arc::new(topology::Topology::load(&cfg.topology_path)?);
76 - topo.ensure_build_host_not_serving(&cfg.build_host)?;
77 - topo.ensure_migration_checks_have_backups(&cfg.migration_checks)?;
78 - topo.ensure_node_companions_are_built(&cfg.companions)?;
79 - topo.ensure_test_target_aux_repos_exist(&cfg.test_targets)?;
80 - tokio::fs::create_dir_all(&cfg.workdir).await?;
81 - tokio::fs::create_dir_all(&cfg.release_root).await?;
82 - git::ensure_bare_repo(Path::new(&topo.repo.bare_path)).await?;
83 - let pool = db::connect(&cfg.db_path).await?;
80 + let (daemon, app_cfgs) = config::DaemonConfig::load()?;
81 + // Bring every product up before binding: its topology parsed, its
82 + // cross-file invariants checked, its directories made, its bare repo
83 + // present. A product that cannot start is a startup failure, not a surprise
84 + // at its first /rebuild.
85 + let mut apps: state::AppMap = std::collections::BTreeMap::new();
86 + for (id, cfg) in app_cfgs {
87 + let topo = Arc::new(topology::Topology::load(&cfg.topology_path)?);
88 + topo.ensure_build_host_not_serving(&cfg.build_host)?;
89 + topo.ensure_migration_checks_have_backups(&cfg.migration_checks)?;
90 + topo.ensure_node_companions_are_built(&cfg.companions)?;
91 + topo.ensure_test_target_aux_repos_exist(&cfg.test_targets)?;
92 + tokio::fs::create_dir_all(&cfg.workdir).await?;
93 + tokio::fs::create_dir_all(&cfg.release_root).await?;
94 + git::ensure_bare_repo(Path::new(&topo.repo.bare_path)).await?;
95 + let executors = Arc::new(state::build_executors(&topo));
96 + tracing::info!(app = %id, tiers = topo.tiers.len(), bare = %topo.repo.bare_path, "app loaded");
97 + apps.insert(
98 + id,
99 + Arc::new(state::App {
100 + cfg,
101 + topo,
102 + executors,
103 + }),
104 + );
105 + }
106 + let apps = Arc::new(apps);
107 + // The product a request that names none is about. First in declared order,
108 + // which for a legacy config is the only one there is.
109 + let default_app = apps
110 + .keys()
111 + .next()
112 + .cloned()
113 + .expect("DaemonConfig::load refuses an empty app set");
114 + let default = apps[&default_app].clone();
115 + let (cfg, topo) = (default.cfg.clone(), default.topo.clone());
116 + let pool = db::connect(&daemon.db_path).await?;
84 117 db::migrate(&pool).await?;
85 118 // Reconcile any build_runs left 'building' by a previous daemon that died
86 119 // mid-build, so a restart can't leave a run wedged 'building' forever.
@@ -94,8 +127,10 @@
94 127 tracing::error!(error = %e, "failed to reconcile orphaned 'building' runs at startup");
95 128 }
96 129 }
97 - sync::sync(&pool, &topo).await?;
98 - tracing::info!(tiers = topo.tiers.len(), bare = %topo.repo.bare_path, "topology synced");
130 + for (id, app) in apps.iter() {
131 + sync::sync(&pool, &app.topo).await?;
132 + tracing::debug!(app = %id, "topology synced");
133 + }
99 134
100 135 // Reconcile deploys against tier_state (after sync, so every tier_state row
101 136 // exists): catch a promote that landed on the nodes but died before advancing
@@ -121,7 +156,7 @@
121 156 sando_daemon::gates::preflight_scratch_privileges(scratch_url).await?;
122 157 }
123 158
124 - let addr: SocketAddr = cfg.listen.parse()?;
159 + let addr: SocketAddr = daemon.listen.parse()?;
125 160
126 161 // Deploy-API auth (CF2). Token comes from SANDO_API_TOKEN (systemd
127 162 // EnvironmentFile). Refuse to expose the mutators unauthenticated on a
@@ -145,9 +180,11 @@
145 180 }
146 181 }
147 182
148 - let executors = Arc::new(state::build_executors(&topo));
183 + let executors = default.executors.clone();
149 184 let app_state = state::AppState {
150 185 pool,
186 + apps,
187 + default_app,
151 188 topo,
152 189 cfg,
153 190 active_build: Arc::new(tokio::sync::Mutex::new(None)),
@@ -197,7 +234,7 @@
197 234 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
198 235 let raw = std::fs::read_to_string(root.join("../deploy/sando-daemon.toml.example"))
199 236 .expect("reading the shipped production daemon config");
200 - let mut cfg: super::config::Config =
237 + let mut cfg: super::config::AppConfig =
201 238 toml::from_str(&raw).expect("shipped daemon config must parse");
202 239 cfg.topology_path = root.join("../sando.toml");
203 240 validate_loaded(&cfg).expect("shipped config must pass --check-config");
@@ -1,5 +1,5 @@
1 - use crate::config::Config;
2 - use crate::domain::NodeId;
1 + use crate::config::AppConfig;
2 + use crate::domain::{AppId, NodeId};
3 3 use crate::events::EventTx;
4 4 use crate::topology::{Node, Topology};
5 5 use ops_exec::{CapabilitySet, Executor, LocalExec, SshExec};
@@ -21,11 +21,33 @@
21 21 pub run_id: crate::domain::RunId,
22 22 }
23 23
24 + /// One product, fully resolved: its pipeline config, its tiers and nodes, and
25 + /// the executors for those nodes.
26 + ///
27 + /// Everything a handler needs to act on a product, reachable from its
28 + /// [`AppId`] alone. Bundled rather than left as three parallel maps because the
29 + /// three are only ever correct together — an executor map built from another
30 + /// product's topology would address the wrong machines.
31 + pub struct App {
32 + pub cfg: Arc<AppConfig>,
33 + pub topo: Arc<Topology>,
34 + pub executors: Arc<ExecutorMap>,
35 + }
36 +
37 + /// Every product this daemon ships, by id.
38 + pub type AppMap = std::collections::BTreeMap<AppId, Arc<App>>;
39 +
24 40 #[derive(Clone)]
25 41 pub struct AppState {
26 42 pub pool: SqlitePool,
43 + /// Every configured product. The authority; the three fields below are one
44 + /// product's view of it.
45 + pub apps: Arc<AppMap>,
46 + /// The product a request that names none is about, and the only one a
47 + /// pre-multi-app config declares.
48 + pub default_app: AppId,
27 49 pub topo: Arc<Topology>,
28 - pub cfg: Arc<Config>,
50 + pub cfg: Arc<AppConfig>,
29 51 /// Single-slot guard for the build pipeline. A new /rebuild aborts any
30 52 /// in-flight build (cargo + gates) so the latest push always wins. Carries
31 53 /// the run id alongside the handle so the aborting `/rebuild` can settle
@@ -49,6 +71,43 @@
49 71 pub api_token: Option<Arc<str>>,
50 72 }
51 73
74 + /// The `apps` map and default id for a daemon shipping exactly one product.
75 + ///
76 + /// The common case, and the only one a config written before 2026-08-06
77 + /// describes. Kept as a constructor so the single-product path builds the same
78 + /// map the multi-product path does, rather than a special case that can drift
79 + /// from it.
80 + pub fn one_app(
81 + cfg: Arc<AppConfig>,
82 + topo: Arc<Topology>,
83 + executors: Arc<ExecutorMap>,
84 + ) -> (Arc<AppMap>, AppId) {
85 + let id = cfg.id.clone();
86 + let mut apps = AppMap::new();
87 + apps.insert(
88 + id.clone(),
89 + Arc::new(App {
90 + cfg,
91 + topo,
92 + executors,
93 + }),
94 + );
95 + (Arc::new(apps), id)
96 + }
97 +
98 + impl AppState {
99 + /// The product `id` names, or `None` if this daemon does not ship it.
100 + pub fn app(&self, id: &AppId) -> Option<&Arc<App>> {
101 + self.apps.get(id)
102 + }
103 +
104 + /// Every product id, in declared order. Stable, because it keys the
105 + /// operator-facing surface.
106 + pub fn app_ids(&self) -> Vec<AppId> {
107 + self.apps.keys().cloned().collect()
108 + }
109 + }
110 +
52 111 /// Build the executor for one node: a `LocalExec` for the `local` fast-path, an
53 112 /// `SshExec` otherwise, each granted the node's declared capabilities (which
54 113 /// default to deploy+restart / observe health — the historical behavior).
@@ -873,7 +873,7 @@
873 873 mod tests {
874 874 use super::promotion::{RollbackReport, rollback_deployed_nodes, unsatisfied_gates};
875 875 use super::*;
876 - use crate::config::Config;
876 + use crate::config::AppConfig;
877 877 use crate::topology::{BackupConfig, CanaryPolicy, Gate, Node, RepoConfig, Tier, Topology};
878 878 use async_trait::async_trait;
879 879 use axum::body::Body;
@@ -942,10 +942,9 @@
942 942 }
943 943 }
944 944
945 - fn test_cfg() -> Config {
946 - Config {
947 - listen: "127.0.0.1:0".into(),
948 - db_path: PathBuf::from(":memory:"),
945 + fn test_cfg() -> AppConfig {
946 + AppConfig {
947 + id: crate::domain::AppId::default(),
949 948 topology_path: PathBuf::from("/tmp/test-sando.toml"),
950 949 build_host: "test-host".into(),
951 950 workdir: PathBuf::from("/tmp/sando-work"),
@@ -995,10 +994,16 @@
995 994 // and conflicts when tests run in parallel.
996 995 let topo = test_topo();
997 996 let executors = Arc::new(crate::state::build_executors(&topo));
997 + let topo = Arc::new(topo);
998 + let cfg = Arc::new(test_cfg());
999 + let (apps, default_app) =
1000 + crate::state::one_app(cfg.clone(), topo.clone(), executors.clone());
998 1001 AppState {
999 1002 pool,
1000 - topo: Arc::new(topo),
1001 - cfg: Arc::new(test_cfg()),
1003 + apps,
1004 + default_app,
1005 + topo,
1006 + cfg,
1002 1007 active_build: Arc::new(tokio::sync::Mutex::new(None)),
1003 1008 deploy_lock: Arc::new(tokio::sync::Mutex::new(())),
1004 1009 events: crate::events::channel(),
@@ -1600,10 +1605,16 @@
1600 1605 let mut topo = test_topo();
1601 1606 topo.tiers[0].gates = vec![Gate::BootSmoke]; // host configures a gate...
1602 1607 let executors = Arc::new(crate::state::build_executors(&topo));
1608 + let topo = Arc::new(topo);
1609 + let cfg = Arc::new(test_cfg());
1610 + let (apps, default_app) =
1611 + crate::state::one_app(cfg.clone(), topo.clone(), executors.clone());
1603 1612 let state = AppState {
1604 1613 pool,
1605 - topo: Arc::new(topo),
1606 - cfg: Arc::new(test_cfg()),
1614 + apps,
1615 + default_app,
1616 + topo,
1617 + cfg,
1607 1618 active_build: Arc::new(tokio::sync::Mutex::new(None)),
1608 1619 deploy_lock: Arc::new(tokio::sync::Mutex::new(())),
1609 1620 events: crate::events::channel(),
@@ -1661,10 +1672,16 @@
1661 1672 let mut topo = test_topo();
1662 1673 topo.tiers[0].gates = vec![]; // host configures no gates at all
1663 1674 let executors = Arc::new(crate::state::build_executors(&topo));
1675 + let topo = Arc::new(topo);
1676 + let cfg = Arc::new(test_cfg());
1677 + let (apps, default_app) =
1678 + crate::state::one_app(cfg.clone(), topo.clone(), executors.clone());
1664 1679 let state = AppState {
1665 1680 pool,
1666 - topo: Arc::new(topo),
1667 - cfg: Arc::new(test_cfg()),
1681 + apps,
1682 + default_app,
1683 + topo,
1684 + cfg,
1668 1685 active_build: Arc::new(tokio::sync::Mutex::new(None)),
1669 1686 deploy_lock: Arc::new(tokio::sync::Mutex::new(())),
1670 1687 events: crate::events::channel(),