max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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(), |