max / makenotwork
- Co-Authored-By
- Claude Opus 4.8 (1M context) <noreply@anthropic.com>
7 files changed,
+413 insertions,
-3 deletions
| @@ -21,6 +21,21 @@ | |||
| 21 | 21 | # Leave unset for push-based hosts (commits arrive via the bare-repo hook). | |
| 22 | 22 | upstream = "git@ssh.makenot.work:max/makenotwork.git" | |
| 23 | 23 | ||
| 24 | + | # ---- auxiliary repos ---- | |
| 25 | + | # Extra repos fetched and checked out beside the per-sha worktree so a cross-repo | |
| 26 | + | # path dependency resolves at build time. The mnw-cli companion (built from the | |
| 27 | + | # MNW worktree) carries `synckit-client = { path = "../../synckit/synckit-client" }` | |
| 28 | + | # after synckit moved to its own repo; from <workdir>/<sha>/mnw-cli that resolves | |
| 29 | + | # to <workdir>/synckit, so synckit must be checked out there. checkout_dir is a | |
| 30 | + | # single component under the workdir; the checkout is shared across shas and | |
| 31 | + | # refreshed to `branch` HEAD each build. See the maintainer wiki, sando-overview. | |
| 32 | + | [[aux_repo]] | |
| 33 | + | name = "synckit" | |
| 34 | + | bare_path = "/srv/sando/synckit.git" | |
| 35 | + | upstream = "git@ssh.makenot.work:max/synckit.git" | |
| 36 | + | branch = "main" | |
| 37 | + | checkout_dir = "synckit" | |
| 38 | + | ||
| 24 | 39 | [backup] | |
| 25 | 40 | # Source of the prod-backup clone used by migration_dry_run on the Sando host. | |
| 26 | 41 | # For localhost dev this can be a file:// path to a fixture dump. In prod we |
| @@ -288,6 +288,7 @@ | |||
| 288 | 288 | }, | |
| 289 | 289 | backup: BackupConfig { source, local_path }, | |
| 290 | 290 | tiers: vec![], | |
| 291 | + | aux_repos: Vec::new(), | |
| 291 | 292 | } | |
| 292 | 293 | } | |
| 293 | 294 |
| @@ -98,6 +98,12 @@ | |||
| 98 | 98 | ||
| 99 | 99 | git::checkout_worktree(&bare, sha.as_str(), &worktree).await?; | |
| 100 | 100 | ||
| 101 | + | // Check out any auxiliary repos (e.g. synckit) beside the worktree so a | |
| 102 | + | // cross-repo path dependency in the server or a companion resolves. Fails the | |
| 103 | + | // build if an aux repo can't be assembled — a companion that silently fails to | |
| 104 | + | // find its source would fail the compile downstream with a worse message. | |
| 105 | + | checkout_aux_repos(&cfg, &topo).await?; | |
| 106 | + | ||
| 101 | 107 | let server_dir = worktree.join("server"); | |
| 102 | 108 | let version = read_pkg_version(&server_dir.join("Cargo.toml")) | |
| 103 | 109 | .await | |
| @@ -224,6 +230,54 @@ | |||
| 224 | 230 | }) | |
| 225 | 231 | } | |
| 226 | 232 | ||
| 233 | + | /// Fetch and check out every configured auxiliary repo at `cfg.workdir/<checkout_dir>`, | |
| 234 | + | /// so a cross-repo path dependency built from the main worktree resolves (wiki | |
| 235 | + | /// [[sando-overview]]; the synckit split, task sando-18cdb32f). | |
| 236 | + | /// | |
| 237 | + | /// Each aux repo is a fixed, shared checkout refreshed to `branch` HEAD — not | |
| 238 | + | /// per-sha — because the dependent's relative path resolves to that fixed spot | |
| 239 | + | /// regardless of the main sha, and builds serialize. A fetch failure is a warning | |
| 240 | + | /// (the branch may already be present from a prior build); an unresolvable branch | |
| 241 | + | /// after that is fatal, as is a failed worktree — a half-assembled source tree | |
| 242 | + | /// must fail the build here, loudly, not as a downstream compile error. | |
| 243 | + | pub async fn checkout_aux_repos(cfg: &Config, topo: &Topology) -> Result<()> { | |
| 244 | + | for aux in &topo.aux_repos { | |
| 245 | + | let bare = PathBuf::from(&aux.bare_path); | |
| 246 | + | git::ensure_bare_repo_no_hook(&bare) | |
| 247 | + | .await | |
| 248 | + | .with_context(|| format!("aux repo {}: init bare {}", aux.name, aux.bare_path))?; | |
| 249 | + | if let Err(e) = git::fetch_upstream(&bare, &aux.upstream, &aux.branch).await { | |
| 250 | + | tracing::warn!( | |
| 251 | + | aux = %aux.name, error = %e, | |
| 252 | + | "aux repo fetch failed; proceeding with current bare-repo state", | |
| 253 | + | ); | |
| 254 | + | } | |
| 255 | + | let sha = git::resolve_ref(&bare, &aux.branch).await.with_context(|| { | |
| 256 | + | format!( | |
| 257 | + | "aux repo {}: branch {} not resolvable after fetch — is {} reachable with that branch?", | |
| 258 | + | aux.name, aux.branch, aux.upstream, | |
| 259 | + | ) | |
| 260 | + | })?; | |
| 261 | + | let dest = cfg.workdir.join(&aux.checkout_dir); | |
| 262 | + | git::checkout_worktree(&bare, &sha, &dest) | |
| 263 | + | .await | |
| 264 | + | .with_context(|| { | |
| 265 | + | format!( | |
| 266 | + | "aux repo {}: checking out {} ({}) at {}", | |
| 267 | + | aux.name, | |
| 268 | + | aux.branch, | |
| 269 | + | sha, | |
| 270 | + | dest.display() | |
| 271 | + | ) | |
| 272 | + | })?; | |
| 273 | + | tracing::info!( | |
| 274 | + | aux = %aux.name, branch = %aux.branch, sha = %sha, dest = %dest.display(), | |
| 275 | + | "aux repo checked out beside worktree", | |
| 276 | + | ); | |
| 277 | + | } | |
| 278 | + | Ok(()) | |
| 279 | + | } | |
| 280 | + | ||
| 227 | 281 | /// Build one companion crate from the worktree, returning its release binary | |
| 228 | 282 | /// path. Mirrors the server build's target-dir handling (shared | |
| 229 | 283 | /// `cargo_target_dir` when set, for incremental reuse; else the crate's own | |
| @@ -544,10 +598,12 @@ | |||
| 544 | 598 | ||
| 545 | 599 | #[cfg(test)] | |
| 546 | 600 | mod tests { | |
| 547 | - | use super::{BuildArtifact, check_build_host, runtime_hostname, stage_and_gate, tail}; | |
| 601 | + | use super::{ | |
| 602 | + | BuildArtifact, check_build_host, checkout_aux_repos, runtime_hostname, stage_and_gate, tail, | |
| 603 | + | }; | |
| 548 | 604 | use crate::config::{Config, TestTarget}; | |
| 549 | 605 | use crate::domain::{GitSha, RunId, Version}; | |
| 550 | - | use crate::topology::{BackupConfig, CanaryPolicy, Gate, RepoConfig, Tier, Topology}; | |
| 606 | + | use crate::topology::{AuxRepo, BackupConfig, CanaryPolicy, Gate, RepoConfig, Tier, Topology}; | |
| 551 | 607 | use sqlx::SqlitePool; | |
| 552 | 608 | use sqlx::sqlite::SqlitePoolOptions; | |
| 553 | 609 | use std::path::PathBuf; | |
| @@ -658,6 +714,7 @@ | |||
| 658 | 714 | canary: CanaryPolicy::Sequential, | |
| 659 | 715 | nodes: Vec::new(), | |
| 660 | 716 | }], | |
| 717 | + | aux_repos: Vec::new(), | |
| 661 | 718 | }; | |
| 662 | 719 | ||
| 663 | 720 | let art = BuildArtifact { | |
| @@ -679,6 +736,150 @@ | |||
| 679 | 736 | ) | |
| 680 | 737 | } | |
| 681 | 738 | ||
| 739 | + | // ---- checkout_aux_repos ---- | |
| 740 | + | ||
| 741 | + | async fn git_in(dir: &std::path::Path, args: &[&str]) { | |
| 742 | + | let out = tokio::process::Command::new("git") | |
| 743 | + | .args(["-c", "user.email=t@t", "-c", "user.name=t"]) | |
| 744 | + | .current_dir(dir) | |
| 745 | + | .args(args) | |
| 746 | + | .output() | |
| 747 | + | .await | |
| 748 | + | .unwrap(); | |
| 749 | + | assert!( | |
| 750 | + | out.status.success(), | |
| 751 | + | "git {args:?}: {}", | |
| 752 | + | String::from_utf8_lossy(&out.stderr) | |
| 753 | + | ); | |
| 754 | + | } | |
| 755 | + | ||
| 756 | + | /// A minimal `Config` whose only field this test path reads is `workdir`. | |
| 757 | + | fn cfg_with_workdir(workdir: PathBuf) -> Config { | |
| 758 | + | Config { | |
| 759 | + | listen: "127.0.0.1:0".into(), | |
| 760 | + | db_path: PathBuf::from(":memory:"), | |
| 761 | + | topology_path: PathBuf::from("/tmp/test-sando.toml"), | |
| 762 | + | build_host: "test-host".into(), | |
| 763 | + | workdir, | |
| 764 | + | release_root: PathBuf::from("/tmp/rr"), | |
| 765 | + | scratch_db_url: None, | |
| 766 | + | scratch_owner_role: "makenotwork".into(), | |
| 767 | + | boot_smoke_port: 18181, | |
| 768 | + | code_smoke_port: 18182, | |
| 769 | + | bin_names: vec!["makenotwork".into()], | |
| 770 | + | logs_root: PathBuf::from("/tmp/logs"), | |
| 771 | + | release_contents: vec![], | |
| 772 | + | cargo_target_dir: None, | |
| 773 | + | gate_timeout_secs: 2400, | |
| 774 | + | companions: Vec::new(), | |
| 775 | + | test_targets: vec![], | |
| 776 | + | } | |
| 777 | + | } | |
| 778 | + | ||
| 779 | + | fn topo_with_aux(aux_repos: Vec<AuxRepo>) -> Topology { | |
| 780 | + | Topology { | |
| 781 | + | repo: RepoConfig { | |
| 782 | + | bare_path: "/tmp/x.git".into(), | |
| 783 | + | branch: "main".into(), | |
| 784 | + | upstream: None, | |
| 785 | + | }, | |
| 786 | + | backup: BackupConfig { | |
| 787 | + | source: "s".into(), | |
| 788 | + | local_path: "/tmp/d".into(), | |
| 789 | + | }, | |
| 790 | + | tiers: vec![], | |
| 791 | + | aux_repos, | |
| 792 | + | } | |
| 793 | + | } | |
| 794 | + | ||
| 795 | + | #[tokio::test] | |
| 796 | + | async fn checkout_aux_repos_places_repo_beside_worktree_and_refreshes_to_branch_head() { | |
| 797 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 798 | + | ||
| 799 | + | // An "upstream" source repo with a marker file on main. | |
| 800 | + | let src = tmp.path().join("synckit-src"); | |
| 801 | + | tokio::fs::create_dir_all(&src).await.unwrap(); | |
| 802 | + | git_in(&src, &["init", "-q", "-b", "main"]).await; | |
| 803 | + | tokio::fs::write(src.join("VERSION"), b"v1").await.unwrap(); | |
| 804 | + | git_in(&src, &["add", "."]).await; | |
| 805 | + | git_in(&src, &["commit", "-q", "-m", "one"]).await; | |
| 806 | + | ||
| 807 | + | let workdir = tmp.path().join("work"); | |
| 808 | + | tokio::fs::create_dir_all(&workdir).await.unwrap(); | |
| 809 | + | let cfg = cfg_with_workdir(workdir.clone()); | |
| 810 | + | let topo = topo_with_aux(vec![AuxRepo { | |
| 811 | + | name: "synckit".into(), | |
| 812 | + | bare_path: tmp | |
| 813 | + | .path() | |
| 814 | + | .join("synckit.git") | |
| 815 | + | .to_string_lossy() | |
| 816 | + | .into_owned(), | |
| 817 | + | upstream: src.to_string_lossy().into_owned(), | |
| 818 | + | branch: "main".into(), | |
| 819 | + | checkout_dir: "synckit".into(), | |
| 820 | + | }]); | |
| 821 | + | ||
| 822 | + | // First build: the aux repo lands at workdir/synckit at v1. | |
| 823 | + | checkout_aux_repos(&cfg, &topo).await.unwrap(); | |
| 824 | + | let dest = workdir.join("synckit"); | |
| 825 | + | assert_eq!( | |
| 826 | + | tokio::fs::read(dest.join("VERSION")).await.unwrap(), | |
| 827 | + | b"v1", | |
| 828 | + | "aux repo checked out beside the worktree", | |
| 829 | + | ); | |
| 830 | + | ||
| 831 | + | // Upstream advances; a later build refreshes the shared checkout to HEAD. | |
| 832 | + | tokio::fs::write(src.join("VERSION"), b"v2").await.unwrap(); | |
| 833 | + | git_in(&src, &["add", "."]).await; | |
| 834 | + | git_in(&src, &["commit", "-q", "-m", "two"]).await; | |
| 835 | + | checkout_aux_repos(&cfg, &topo).await.unwrap(); | |
| 836 | + | assert_eq!( | |
| 837 | + | tokio::fs::read(dest.join("VERSION")).await.unwrap(), | |
| 838 | + | b"v2", | |
| 839 | + | "aux checkout refreshed to the new branch HEAD", | |
| 840 | + | ); | |
| 841 | + | ||
| 842 | + | // The aux bare carries no build-trigger hook. | |
| 843 | + | assert!( | |
| 844 | + | !tmp.path().join("synckit.git/hooks/post-receive").exists(), | |
| 845 | + | "aux bare must be hookless", | |
| 846 | + | ); | |
| 847 | + | } | |
| 848 | + | ||
| 849 | + | #[tokio::test] | |
| 850 | + | async fn checkout_aux_repos_is_a_noop_without_aux_repos() { | |
| 851 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 852 | + | let cfg = cfg_with_workdir(tmp.path().to_path_buf()); | |
| 853 | + | checkout_aux_repos(&cfg, &topo_with_aux(vec![])) | |
| 854 | + | .await | |
| 855 | + | .unwrap(); | |
| 856 | + | } | |
| 857 | + | ||
| 858 | + | #[tokio::test] | |
| 859 | + | async fn checkout_aux_repos_fails_on_an_unresolvable_branch() { | |
| 860 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 861 | + | let src = tmp.path().join("src"); | |
| 862 | + | tokio::fs::create_dir_all(&src).await.unwrap(); | |
| 863 | + | git_in(&src, &["init", "-q", "-b", "main"]).await; | |
| 864 | + | tokio::fs::write(src.join("f"), b"x").await.unwrap(); | |
| 865 | + | git_in(&src, &["add", "."]).await; | |
| 866 | + | git_in(&src, &["commit", "-q", "-m", "c"]).await; | |
| 867 | + | ||
| 868 | + | let cfg = cfg_with_workdir(tmp.path().join("work")); | |
| 869 | + | let topo = topo_with_aux(vec![AuxRepo { | |
| 870 | + | name: "synckit".into(), | |
| 871 | + | bare_path: tmp.path().join("s.git").to_string_lossy().into_owned(), | |
| 872 | + | upstream: src.to_string_lossy().into_owned(), | |
| 873 | + | branch: "nonexistent".into(), | |
| 874 | + | checkout_dir: "synckit".into(), | |
| 875 | + | }]); | |
| 876 | + | let err = checkout_aux_repos(&cfg, &topo).await.unwrap_err(); | |
| 877 | + | assert!( | |
| 878 | + | format!("{err:#}").contains("synckit"), | |
| 879 | + | "error names the aux repo: {err:#}", | |
| 880 | + | ); | |
| 881 | + | } | |
| 882 | + | ||
| 682 | 883 | async fn tier_versions(pool: &SqlitePool, tier: &str) -> (Option<String>, Option<String>) { | |
| 683 | 884 | sqlx::query_as("SELECT current_version, previous_version FROM tier_state WHERE tier = ?") | |
| 684 | 885 | .bind(tier) |
| @@ -9,6 +9,21 @@ | |||
| 9 | 9 | const POST_RECEIVE: &str = include_str!("../../hooks/post-receive"); | |
| 10 | 10 | ||
| 11 | 11 | pub async fn ensure_bare_repo(path: &Path) -> Result<()> { | |
| 12 | + | init_bare_repo(path).await?; | |
| 13 | + | install_hook(path).await?; | |
| 14 | + | Ok(()) | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | /// Like [`ensure_bare_repo`] but installs no `post-receive` hook. For an | |
| 18 | + | /// auxiliary repo (e.g. synckit) Sando only ever *fetches* — nobody pushes to | |
| 19 | + | /// its bare — so the build-triggering hook would be dead weight, and worse, if | |
| 20 | + | /// it ever did fire it would kick an MNW build. Keep the aux bare inert. | |
| 21 | + | pub async fn ensure_bare_repo_no_hook(path: &Path) -> Result<()> { | |
| 22 | + | init_bare_repo(path).await | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | /// `git init --bare` at `path` if it isn't already a repo. Idempotent. | |
| 26 | + | async fn init_bare_repo(path: &Path) -> Result<()> { | |
| 12 | 27 | if !path.join("HEAD").exists() { | |
| 13 | 28 | tokio::fs::create_dir_all(path).await?; | |
| 14 | 29 | let out = Command::new("git") | |
| @@ -23,7 +38,6 @@ | |||
| 23 | 38 | String::from_utf8_lossy(&out.stderr), | |
| 24 | 39 | ); | |
| 25 | 40 | } | |
| 26 | - | install_hook(path).await?; | |
| 27 | 41 | Ok(()) | |
| 28 | 42 | } | |
| 29 | 43 | ||
| @@ -267,6 +281,27 @@ | |||
| 267 | 281 | (tmp, gitdir, sha1, sha2) | |
| 268 | 282 | } | |
| 269 | 283 | ||
| 284 | + | #[tokio::test] | |
| 285 | + | async fn ensure_bare_repo_installs_hook_but_no_hook_variant_does_not() { | |
| 286 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 287 | + | let with_hook = tmp.path().join("hooked.git"); | |
| 288 | + | let without = tmp.path().join("bare.git"); | |
| 289 | + | ||
| 290 | + | ensure_bare_repo(&with_hook).await.unwrap(); | |
| 291 | + | ensure_bare_repo_no_hook(&without).await.unwrap(); | |
| 292 | + | ||
| 293 | + | // Both are real bare repos. | |
| 294 | + | assert!(with_hook.join("HEAD").exists()); | |
| 295 | + | assert!(without.join("HEAD").exists()); | |
| 296 | + | // Only the main-repo variant carries the build-trigger hook. | |
| 297 | + | assert!(with_hook.join("hooks/post-receive").exists()); | |
| 298 | + | assert!(!without.join("hooks/post-receive").exists()); | |
| 299 | + | ||
| 300 | + | // Both are idempotent. | |
| 301 | + | ensure_bare_repo_no_hook(&without).await.unwrap(); | |
| 302 | + | assert!(!without.join("hooks/post-receive").exists()); | |
| 303 | + | } | |
| 304 | + | ||
| 270 | 305 | #[tokio::test] | |
| 271 | 306 | async fn checkout_worktree_creates_at_sha() { | |
| 272 | 307 | let (tmp, gitdir, sha1, _sha2) = two_commit_repo().await; |
| @@ -140,6 +140,7 @@ | |||
| 140 | 140 | local_path: "/tmp/b".into(), | |
| 141 | 141 | }, | |
| 142 | 142 | tiers, | |
| 143 | + | aux_repos: Vec::new(), | |
| 143 | 144 | } | |
| 144 | 145 | } | |
| 145 | 146 |
| @@ -9,6 +9,45 @@ | |||
| 9 | 9 | pub backup: BackupConfig, | |
| 10 | 10 | #[serde(rename = "tier")] | |
| 11 | 11 | pub tiers: Vec<Tier>, | |
| 12 | + | /// Extra repos to fetch and check out beside the main worktree before a | |
| 13 | + | /// build, so a path dependency that reaches across the repo split resolves. | |
| 14 | + | /// Empty (default) keeps an existing `sando.toml` working unedited. See | |
| 15 | + | /// [`AuxRepo`] and [`crate::build::checkout_aux_repos`]. | |
| 16 | + | #[serde(default, rename = "aux_repo")] | |
| 17 | + | pub aux_repos: Vec<AuxRepo>, | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | /// An auxiliary repo checked out beside the per-sha worktree so cross-repo path | |
| 21 | + | /// dependencies resolve at build time. | |
| 22 | + | /// | |
| 23 | + | /// The concrete case (2026-07-24): `mnw-cli` — a companion built from the MNW | |
| 24 | + | /// worktree — carries `synckit-client = { path = "../../synckit/synckit-client" }` | |
| 25 | + | /// after synckit moved to its own repo. From the companion crate at | |
| 26 | + | /// `<workdir>/<sha>/mnw-cli`, that path resolves to `<workdir>/synckit`, a sibling | |
| 27 | + | /// of the per-sha worktree that Sando otherwise never creates, so the companion | |
| 28 | + | /// build failed with "No such file or directory". An `aux_repo` named to land at | |
| 29 | + | /// `checkout_dir = "synckit"` puts the synckit source exactly there. | |
| 30 | + | /// | |
| 31 | + | /// The checkout is at the FIXED `<workdir>/<checkout_dir>`, not per-sha: the path | |
| 32 | + | /// dependency resolves to that spot regardless of the MNW sha, and builds | |
| 33 | + | /// serialize, so a single shared checkout refreshed to `branch` HEAD each build | |
| 34 | + | /// is correct. Because a path dep has no lockfile pin, "branch HEAD" is the honest | |
| 35 | + | /// resolution — the same contract as the dev working copy. | |
| 36 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 37 | + | pub struct AuxRepo { | |
| 38 | + | /// Human label for logs and errors (e.g. `synckit`). | |
| 39 | + | pub name: String, | |
| 40 | + | /// Bare repo Sando fetches into and worktrees from, e.g. | |
| 41 | + | /// `/srv/sando/synckit.git`. Auto-created (hookless) on first build. | |
| 42 | + | pub bare_path: String, | |
| 43 | + | /// Canonical git remote fetched before checkout. Like [`RepoConfig::upstream`] | |
| 44 | + | /// but required here: an aux repo is pull-based (nobody pushes to its bare). | |
| 45 | + | pub upstream: String, | |
| 46 | + | /// Branch whose HEAD is checked out. | |
| 47 | + | pub branch: String, | |
| 48 | + | /// Where the worktree lands, relative to `cfg.workdir`. Must be a single safe | |
| 49 | + | /// path component (no `..`, not absolute) so it stays under the workdir. | |
| 50 | + | pub checkout_dir: String, | |
| 12 | 51 | } | |
| 13 | 52 | ||
| 14 | 53 | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| @@ -263,6 +302,31 @@ | |||
| 263 | 302 | ); | |
| 264 | 303 | } | |
| 265 | 304 | } | |
| 305 | + | let mut seen_dirs = std::collections::HashSet::new(); | |
| 306 | + | for aux in &self.aux_repos { | |
| 307 | + | anyhow::ensure!( | |
| 308 | + | !aux.name.is_empty() && !aux.bare_path.is_empty() && !aux.branch.is_empty(), | |
| 309 | + | "aux_repo entry has an empty name/bare_path/branch" | |
| 310 | + | ); | |
| 311 | + | // `checkout_dir` becomes a `workdir.join(..)`; keep it a single safe | |
| 312 | + | // component so an aux repo can never write outside the workdir or | |
| 313 | + | // collide with a per-sha worktree dir. | |
| 314 | + | let dir = &aux.checkout_dir; | |
| 315 | + | anyhow::ensure!( | |
| 316 | + | !dir.is_empty() | |
| 317 | + | && !dir.contains('/') | |
| 318 | + | && !dir.contains('\\') | |
| 319 | + | && dir != "." | |
| 320 | + | && dir != "..", | |
| 321 | + | "aux_repo {} has an unsafe checkout_dir {dir:?} (must be a single path component, \ | |
| 322 | + | no separators or dot-dot)", | |
| 323 | + | aux.name, | |
| 324 | + | ); | |
| 325 | + | anyhow::ensure!( | |
| 326 | + | seen_dirs.insert(dir.as_str()), | |
| 327 | + | "two aux_repo entries share checkout_dir {dir:?}; they would clobber each other" | |
| 328 | + | ); | |
| 329 | + | } | |
| 266 | 330 | Ok(()) | |
| 267 | 331 | } | |
| 268 | 332 | } | |
| @@ -392,6 +456,98 @@ | |||
| 392 | 456 | assert_eq!(c[0].service_name, "mnw-cli.service"); | |
| 393 | 457 | } | |
| 394 | 458 | ||
| 459 | + | fn topo_with_aux(aux_block: &str) -> Result<Topology> { | |
| 460 | + | let raw = format!( | |
| 461 | + | r#" | |
| 462 | + | [repo] | |
| 463 | + | bare_path = "/tmp/repo.git" | |
| 464 | + | branch = "main" | |
| 465 | + | [backup] | |
| 466 | + | source = "s" | |
| 467 | + | local_path = "/tmp/d" | |
| 468 | + | [[tier]] | |
| 469 | + | name = "b" | |
| 470 | + | provisioned = true | |
| 471 | + | gates = [{{ kind = "node_health" }}] | |
| 472 | + | [[tier.node]] | |
| 473 | + | name = "prod-1" | |
| 474 | + | ssh_target = "prod-1" | |
| 475 | + | release_root = "/srv/mnw" | |
| 476 | + | {aux_block} | |
| 477 | + | "# | |
| 478 | + | ); | |
| 479 | + | let topo: Topology = toml::from_str(&raw)?; | |
| 480 | + | topo.validate_for_test()?; | |
| 481 | + | Ok(topo) | |
| 482 | + | } | |
| 483 | + | ||
| 484 | + | #[test] | |
| 485 | + | fn aux_repos_default_empty() { | |
| 486 | + | let topo = topo_with_aux("").expect("no aux_repo block is fine"); | |
| 487 | + | assert!(topo.aux_repos.is_empty()); | |
| 488 | + | } | |
| 489 | + | ||
| 490 | + | #[test] | |
| 491 | + | fn aux_repo_parses_all_fields() { | |
| 492 | + | let topo = topo_with_aux( | |
| 493 | + | r#" | |
| 494 | + | [[aux_repo]] | |
| 495 | + | name = "synckit" | |
| 496 | + | bare_path = "/srv/sando/synckit.git" | |
| 497 | + | upstream = "git@ssh.makenot.work:max/synckit.git" | |
| 498 | + | branch = "main" | |
| 499 | + | checkout_dir = "synckit""#, | |
| 500 | + | ) | |
| 501 | + | .expect("valid aux_repo parses"); | |
| 502 | + | assert_eq!(topo.aux_repos.len(), 1); | |
| 503 | + | let a = &topo.aux_repos[0]; | |
| 504 | + | assert_eq!(a.name, "synckit"); | |
| 505 | + | assert_eq!(a.bare_path, "/srv/sando/synckit.git"); | |
| 506 | + | assert_eq!(a.upstream, "git@ssh.makenot.work:max/synckit.git"); | |
| 507 | + | assert_eq!(a.branch, "main"); | |
| 508 | + | assert_eq!(a.checkout_dir, "synckit"); | |
| 509 | + | } | |
| 510 | + | ||
| 511 | + | #[test] | |
| 512 | + | fn aux_repo_with_traversing_checkout_dir_is_rejected() { | |
| 513 | + | for bad in ["../escape", "a/b", "..", "."] { | |
| 514 | + | let err = topo_with_aux(&format!( | |
| 515 | + | r#" | |
| 516 | + | [[aux_repo]] | |
| 517 | + | name = "x" | |
| 518 | + | bare_path = "/srv/sando/x.git" | |
| 519 | + | upstream = "u" | |
| 520 | + | branch = "main" | |
| 521 | + | checkout_dir = "{bad}""#, | |
| 522 | + | )) | |
| 523 | + | .unwrap_err() | |
| 524 | + | .to_string(); | |
| 525 | + | assert!(err.contains("unsafe checkout_dir"), "for {bad:?}: {err}"); | |
| 526 | + | } | |
| 527 | + | } | |
| 528 | + | ||
| 529 | + | #[test] | |
| 530 | + | fn aux_repos_sharing_a_checkout_dir_are_rejected() { | |
| 531 | + | let err = topo_with_aux( | |
| 532 | + | r#" | |
| 533 | + | [[aux_repo]] | |
| 534 | + | name = "one" | |
| 535 | + | bare_path = "/srv/sando/one.git" | |
| 536 | + | upstream = "u" | |
| 537 | + | branch = "main" | |
| 538 | + | checkout_dir = "shared" | |
| 539 | + | [[aux_repo]] | |
| 540 | + | name = "two" | |
| 541 | + | bare_path = "/srv/sando/two.git" | |
| 542 | + | upstream = "u" | |
| 543 | + | branch = "main" | |
| 544 | + | checkout_dir = "shared""#, | |
| 545 | + | ) | |
| 546 | + | .unwrap_err() | |
| 547 | + | .to_string(); | |
| 548 | + | assert!(err.contains("share checkout_dir"), "{err}"); | |
| 549 | + | } | |
| 550 | + | ||
| 395 | 551 | #[test] | |
| 396 | 552 | fn real_sando_toml_loads_clean() { | |
| 397 | 553 | // The shipped topology must satisfy the invariant — guards against a |
| @@ -905,6 +905,7 @@ | |||
| 905 | 905 | }], | |
| 906 | 906 | }, | |
| 907 | 907 | ], | |
| 908 | + | aux_repos: Vec::new(), | |
| 908 | 909 | } | |
| 909 | 910 | } | |
| 910 | 911 |