max / makenotwork
11 files changed,
+383 insertions,
-1 deletion
| @@ -14,3 +14,13 @@ release_root = "./releases" | |||
| 14 | 14 | cargo_target_dir = "./cargo-target" | |
| 15 | 15 | # Dropped and recreated on every migration_dry_run. Leave unset to skip. | |
| 16 | 16 | scratch_db_url = "postgres://sando@127.0.0.1/sando_scratch" | |
| 17 | + | ||
| 18 | + | # Companion crates built from the same worktree/sha as the server and staged | |
| 19 | + | # into the release bundle, so a contract-coupled service can't drift out of | |
| 20 | + | # lockstep. mnw-cli is the public git-SSH server that proxies to /api/internal/* | |
| 21 | + | # — a two-month drift is what broke git hosting during the 0.10.14 deploy. Which | |
| 22 | + | # nodes install it is set per-node in the topology (see prod-1 in sando.toml). | |
| 23 | + | [[companion]] | |
| 24 | + | name = "mnw-cli" | |
| 25 | + | manifest_dir = "mnw-cli" | |
| 26 | + | bin = "mnw-cli" |
| @@ -26,6 +26,10 @@ pub struct BuildArtifact { | |||
| 26 | 26 | /// (referenced by the systemd unit's ExecStart). Paths are inside the | |
| 27 | 27 | /// worktree's `target/release/`. | |
| 28 | 28 | pub binary_paths: Vec<PathBuf>, | |
| 29 | + | /// `(companion name, built binary path)` for each `cfg.companions`, built | |
| 30 | + | /// from the same worktree/sha as the server. Staged into the release bundle | |
| 31 | + | /// under `companions/<name>/<bin>` and installed by the nodes that opt in. | |
| 32 | + | pub companion_paths: Vec<(String, PathBuf)>, | |
| 29 | 33 | } | |
| 30 | 34 | ||
| 31 | 35 | /// The live kernel hostname (`/proc/sys/kernel/hostname`, trimmed). Linux-only, | |
| @@ -173,6 +177,16 @@ pub async fn run( | |||
| 173 | 177 | // (everything downstream — promote, rollback — looks it up by version). | |
| 174 | 178 | let primary = binary_paths[0].clone(); | |
| 175 | 179 | ||
| 180 | + | // Companion crates (e.g. mnw-cli): built from the SAME worktree/sha so a | |
| 181 | + | // service that shares the server's internal-API contract cannot drift out of | |
| 182 | + | // lockstep (the 2026-07-09 git-hosting outage). A companion build failure | |
| 183 | + | // fails the whole pipeline — the server never ships without its companions. | |
| 184 | + | let mut companion_paths = Vec::with_capacity(cfg.companions.len()); | |
| 185 | + | for c in &cfg.companions { | |
| 186 | + | let bin = build_companion(&worktree, &cfg, c).await?; | |
| 187 | + | companion_paths.push((c.name.clone(), bin)); | |
| 188 | + | } | |
| 189 | + | ||
| 176 | 190 | sqlx::query( | |
| 177 | 191 | "INSERT OR IGNORE INTO versions (version, git_sha, built_at, artifact_path) | |
| 178 | 192 | VALUES (?, ?, ?, ?)", | |
| @@ -184,7 +198,55 @@ pub async fn run( | |||
| 184 | 198 | .execute(&pool) | |
| 185 | 199 | .await?; | |
| 186 | 200 | ||
| 187 | - | Ok(BuildArtifact { version, git_sha: sha, worktree, binary_paths }) | |
| 201 | + | Ok(BuildArtifact { version, git_sha: sha, worktree, binary_paths, companion_paths }) | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | /// Build one companion crate from the worktree, returning its release binary | |
| 205 | + | /// path. Mirrors the server build's target-dir handling (shared | |
| 206 | + | /// `cargo_target_dir` when set, for incremental reuse; else the crate's own | |
| 207 | + | /// `target/`). A companion is an API client, not a sqlx crate, so it needs no | |
| 208 | + | /// scratch DB. A non-zero exit propagates and fails the pipeline. | |
| 209 | + | async fn build_companion( | |
| 210 | + | worktree: &Path, | |
| 211 | + | cfg: &Config, | |
| 212 | + | c: &crate::config::Companion, | |
| 213 | + | ) -> Result<PathBuf> { | |
| 214 | + | let dir = worktree.join(&c.manifest_dir); | |
| 215 | + | anyhow::ensure!( | |
| 216 | + | dir.join("Cargo.toml").exists(), | |
| 217 | + | "companion {}: no Cargo.toml at {}", | |
| 218 | + | c.name, | |
| 219 | + | dir.display(), | |
| 220 | + | ); | |
| 221 | + | // Match the server build: no `--locked` (the pipeline builds whatever the | |
| 222 | + | // sha pins; a stale lock shouldn't block a deploy the server build allows). | |
| 223 | + | let mut cmd = Command::new("cargo"); | |
| 224 | + | cmd.arg("build").arg("--release").current_dir(&dir).kill_on_drop(true); | |
| 225 | + | let release_dir = if let Some(target) = cfg.cargo_target_dir.as_deref() { | |
| 226 | + | cmd.env("CARGO_TARGET_DIR", target); | |
| 227 | + | target.join("release") | |
| 228 | + | } else { | |
| 229 | + | dir.join("target/release") | |
| 230 | + | }; | |
| 231 | + | tracing::info!(companion = %c.name, dir = %dir.display(), "cargo build --release (companion) start"); | |
| 232 | + | let started = std::time::Instant::now(); | |
| 233 | + | let out = cmd.output().await.context("spawning cargo build for companion")?; | |
| 234 | + | if !out.status.success() { | |
| 235 | + | anyhow::bail!( | |
| 236 | + | "companion {} build failed:\n{}", | |
| 237 | + | c.name, | |
| 238 | + | tail(&out.stderr, 4_000), | |
| 239 | + | ); | |
| 240 | + | } | |
| 241 | + | let bin = release_dir.join(&c.bin); | |
| 242 | + | anyhow::ensure!( | |
| 243 | + | bin.exists(), | |
| 244 | + | "companion {} produced no binary at {} after build", | |
| 245 | + | c.name, | |
| 246 | + | bin.display(), | |
| 247 | + | ); | |
| 248 | + | tracing::info!(companion = %c.name, elapsed_s = started.elapsed().as_secs(), "companion build ok"); | |
| 249 | + | Ok(bin) | |
| 188 | 250 | } | |
| 189 | 251 | ||
| 190 | 252 | /// Full host-tier pipeline: build, stage the bundle into the host's | |
| @@ -218,6 +280,22 @@ pub async fn build_and_run_host( | |||
| 218 | 280 | stage_entry(&art.worktree, &staged, entry).await?; | |
| 219 | 281 | } | |
| 220 | 282 | ||
| 283 | + | // Stage companion binaries as `companions/<name>` (the file itself) so they | |
| 284 | + | // ride the same atomic bundle rsync to the nodes, and a node can locate its | |
| 285 | + | // companion source from the logical name alone — no bin-filename coupling in | |
| 286 | + | // the topology. The nodes that opt in install them post-swap (see | |
| 287 | + | // deploy::deploy_remote). | |
| 288 | + | if !art.companion_paths.is_empty() { | |
| 289 | + | let dst_dir = staged.join("companions"); | |
| 290 | + | tokio::fs::create_dir_all(&dst_dir).await | |
| 291 | + | .with_context(|| format!("create staged companions dir {}", dst_dir.display()))?; | |
| 292 | + | for (name, built) in &art.companion_paths { | |
| 293 | + | let dst = dst_dir.join(name); | |
| 294 | + | tokio::fs::copy(built, &dst).await | |
| 295 | + | .with_context(|| format!("stage companion {name}: {} -> {}", built.display(), dst.display()))?; | |
| 296 | + | } | |
| 297 | + | } | |
| 298 | + | ||
| 221 | 299 | let staged_bin = staged.join(cfg.primary_bin()); | |
| 222 | 300 | sqlx::query("UPDATE versions SET artifact_path = ? WHERE version = ?") | |
| 223 | 301 | .bind(staged_bin.to_string_lossy().as_ref()) |
| @@ -63,6 +63,30 @@ pub struct Config { | |||
| 63 | 63 | /// generous for a full release test suite, fatal only to a genuine hang. | |
| 64 | 64 | #[serde(default = "default_gate_timeout_secs")] | |
| 65 | 65 | pub gate_timeout_secs: u64, | |
| 66 | + | /// Extra crates built from the same worktree/sha as the server and shipped | |
| 67 | + | /// in the release bundle, so a service that shares the server's contract | |
| 68 | + | /// (e.g. `mnw-cli`, which talks to `/api/internal/*`) can't drift out of | |
| 69 | + | /// lockstep. Each is compiled after the server; a companion that fails to | |
| 70 | + | /// build fails the whole pipeline — that is the lockstep guarantee. Which | |
| 71 | + | /// nodes actually install a given companion is a per-node decision (see | |
| 72 | + | /// `Node::companions`); this list only says what to build + stage. Default | |
| 73 | + | /// empty, so the sando code stays project-agnostic. | |
| 74 | + | #[serde(default, rename = "companion")] | |
| 75 | + | pub companions: Vec<Companion>, | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | /// A crate built alongside the server and staged into the release bundle under | |
| 79 | + | /// `companions/<name>/<bin>`. Referenced by `Node::companions[].name` to decide | |
| 80 | + | /// where (if anywhere) it deploys. | |
| 81 | + | #[derive(Debug, Clone, Deserialize)] | |
| 82 | + | pub struct Companion { | |
| 83 | + | /// Logical id, matched by a node's companion entry. Also the bundle subdir. | |
| 84 | + | pub name: String, | |
| 85 | + | /// Directory under the worktree holding the crate's `Cargo.toml` | |
| 86 | + | /// (e.g. `mnw-cli`). Built with `cargo build --release` in that dir. | |
| 87 | + | pub manifest_dir: PathBuf, | |
| 88 | + | /// Binary name produced under the crate's `target/release/`. | |
| 89 | + | pub bin: String, | |
| 66 | 90 | } | |
| 67 | 91 | ||
| 68 | 92 | /// A directory or file copied from the worktree into the staged release dir. | |
| @@ -114,6 +138,7 @@ impl Config { | |||
| 114 | 138 | release_contents: Vec::new(), | |
| 115 | 139 | cargo_target_dir: None, | |
| 116 | 140 | gate_timeout_secs: default_gate_timeout_secs(), | |
| 141 | + | companions: Vec::new(), | |
| 117 | 142 | } | |
| 118 | 143 | } | |
| 119 | 144 | } | |
| @@ -158,6 +183,21 @@ mod tests { | |||
| 158 | 183 | } | |
| 159 | 184 | ||
| 160 | 185 | #[test] | |
| 186 | + | fn companions_default_empty_and_parse_when_present() { | |
| 187 | + | let base: Config = toml::from_str(MINIMAL).unwrap(); | |
| 188 | + | assert!(base.companions.is_empty(), "omitting [[companion]] keeps it empty"); | |
| 189 | + | ||
| 190 | + | let raw = format!( | |
| 191 | + | "{MINIMAL}\n[[companion]]\nname = \"mnw-cli\"\nmanifest_dir = \"mnw-cli\"\nbin = \"mnw-cli\"\n" | |
| 192 | + | ); | |
| 193 | + | let cfg: Config = toml::from_str(&raw).unwrap(); | |
| 194 | + | assert_eq!(cfg.companions.len(), 1); | |
| 195 | + | assert_eq!(cfg.companions[0].name, "mnw-cli"); | |
| 196 | + | assert_eq!(cfg.companions[0].manifest_dir, std::path::Path::new("mnw-cli")); | |
| 197 | + | assert_eq!(cfg.companions[0].bin, "mnw-cli"); | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | #[test] | |
| 161 | 201 | fn build_host_is_required() { | |
| 162 | 202 | // No safe default: a config without build_host must not parse, so the | |
| 163 | 203 | // no-build-on-prod guard can never be silently skipped. |
| @@ -198,6 +198,23 @@ async fn deploy_remote( | |||
| 198 | 198 | let swap_and_restart = swap_and_restart_script(release_root, version, &restart_cmd); | |
| 199 | 199 | run_checked(executor, &swap_and_restart, "symlink swap + systemctl reload-or-restart").await?; | |
| 200 | 200 | ||
| 201 | + | // Companion services (opt-in per node): install each from the just-rsynced | |
| 202 | + | // bundle and restart its unit via the node-side wrapper, AFTER the server is | |
| 203 | + | // up (mnw-cli is `After=makenotwork.service`). They shipped from the SAME sha | |
| 204 | + | // in this SAME bundle — the lockstep guarantee. A failure here fails the | |
| 205 | + | // promote: a companion is part of the deploy, not a best-effort side effect. | |
| 206 | + | for c in &node.companions { | |
| 207 | + | let src = format!( | |
| 208 | + | "{release_root}/releases/{version}/companions/{name}", | |
| 209 | + | name = c.name, | |
| 210 | + | ); | |
| 211 | + | tracing::info!(node = %node.name, companion = %c.name, "deploy: install companion + restart"); | |
| 212 | + | let cmd = install_companion_cmd(&src, &c.install_path, &c.service_name); | |
| 213 | + | run_checked(executor, &cmd, "install companion + restart") | |
| 214 | + | .await | |
| 215 | + | .with_context(|| format!("companion {} deploy failed (server already swapped)", c.name))?; | |
| 216 | + | } | |
| 217 | + | ||
| 201 | 218 | if let Err(e) = gc_remote_releases(executor, release_root).await { | |
| 202 | 219 | tracing::warn!(error = %e, "remote release GC failed (non-fatal)"); | |
| 203 | 220 | } | |
| @@ -205,6 +222,25 @@ async fn deploy_remote( | |||
| 205 | 222 | Ok(PathBuf::from(release_root).join("releases").join(version)) | |
| 206 | 223 | } | |
| 207 | 224 | ||
| 225 | + | /// Absolute path of the node-side companion installer (shipped once per node; | |
| 226 | + | /// granted to the deploy user by a single scoped sudoers line). It installs the | |
| 227 | + | /// staged binary to its `ExecStart` path and restarts the unit — keeping the | |
| 228 | + | /// sudo grant to one script rather than a broad `install`/`systemctl` grant. | |
| 229 | + | const COMPANION_INSTALLER: &str = "/usr/local/lib/mnw/install-companion.sh"; | |
| 230 | + | ||
| 231 | + | /// Command run on the node to install a staged companion binary and restart its | |
| 232 | + | /// unit, via the wrapper. Pure builder so it can be unit-tested; all three args | |
| 233 | + | /// are shell-quoted (paths/unit names, operator config — but quoted regardless). | |
| 234 | + | fn install_companion_cmd(src: &str, install_path: &str, service: &str) -> String { | |
| 235 | + | format!( | |
| 236 | + | "sudo {installer} {src} {dst} {svc}", | |
| 237 | + | installer = sh_quote(COMPANION_INSTALLER), | |
| 238 | + | src = sh_quote(src), | |
| 239 | + | dst = sh_quote(install_path), | |
| 240 | + | svc = sh_quote(service), | |
| 241 | + | ) | |
| 242 | + | } | |
| 243 | + | ||
| 208 | 244 | /// Pre-swap config-drift check: load the node's env file the way systemd loads | |
| 209 | 245 | /// it, then run the freshly-deployed binary in `MNW_CHECK_CONFIG=1` mode (loads | |
| 210 | 246 | /// config, exits 0/1, no DB/migrations/bind). A non-zero exit — a required var | |
| @@ -500,6 +536,7 @@ mod tests { | |||
| 500 | 536 | config_check_env_file: None, | |
| 501 | 537 | actuate: crate::topology::default_actuate(), | |
| 502 | 538 | observe: crate::topology::default_observe(), | |
| 539 | + | companions: Vec::new(), | |
| 503 | 540 | }; | |
| 504 | 541 | let executor = SshExec::new( | |
| 505 | 542 | node.ssh_target.clone(), | |
| @@ -539,6 +576,7 @@ mod tests { | |||
| 539 | 576 | config_check_env_file: None, | |
| 540 | 577 | actuate: crate::topology::default_actuate(), | |
| 541 | 578 | observe: crate::topology::default_observe(), | |
| 579 | + | companions: Vec::new(), | |
| 542 | 580 | }; | |
| 543 | 581 | let executor = local_executor(); | |
| 544 | 582 | ||
| @@ -698,6 +736,40 @@ mod tests { | |||
| 698 | 736 | ); | |
| 699 | 737 | } | |
| 700 | 738 | ||
| 739 | + | // ---- install_companion_cmd: shape + quoting ---- | |
| 740 | + | ||
| 741 | + | #[test] | |
| 742 | + | fn install_companion_cmd_shape_and_quoting() { | |
| 743 | + | let cmd = install_companion_cmd( | |
| 744 | + | "/opt/mnw/releases/0.10.14/companions/mnw-cli", | |
| 745 | + | "/opt/mnw-cli/mnw-cli", | |
| 746 | + | "mnw-cli.service", | |
| 747 | + | ); | |
| 748 | + | // Routes through the wrapper (single sudoers grant), sudo-invoked, with | |
| 749 | + | // src, dst, service in that order. | |
| 750 | + | assert!(cmd.starts_with("sudo "), "must be sudo-invoked: {cmd}"); | |
| 751 | + | assert!(cmd.contains("/usr/local/lib/mnw/install-companion.sh"), "{cmd}"); | |
| 752 | + | let installer_pos = cmd.find("install-companion.sh").unwrap(); | |
| 753 | + | let src_pos = cmd.find("companions/mnw-cli").unwrap(); | |
| 754 | + | let dst_pos = cmd.find("/opt/mnw-cli/mnw-cli").unwrap(); | |
| 755 | + | let svc_pos = cmd.find("mnw-cli.service").unwrap(); | |
| 756 | + | assert!(installer_pos < src_pos && src_pos < dst_pos && dst_pos < svc_pos, "arg order: {cmd}"); | |
| 757 | + | } | |
| 758 | + | ||
| 759 | + | #[test] | |
| 760 | + | fn install_companion_cmd_quotes_metachars() { | |
| 761 | + | // A path with a space/quote must be shell-safe (defense in depth even | |
| 762 | + | // though these come from operator config). | |
| 763 | + | let cmd = install_companion_cmd("/a b/src", "/dst'x", "u.service"); | |
| 764 | + | let out = std::process::Command::new("sh") | |
| 765 | + | .arg("-c") | |
| 766 | + | .arg(format!("set -- {}; echo \"$#\"", cmd.strip_prefix("sudo ").unwrap())) | |
| 767 | + | .output() | |
| 768 | + | .unwrap(); | |
| 769 | + | // installer + 3 args = 4 positional words after quoting. | |
| 770 | + | assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "4", "quoting split wrong: {cmd}"); | |
| 771 | + | } | |
| 772 | + | ||
| 701 | 773 | #[tokio::test] | |
| 702 | 774 | async fn config_check_script_propagates_binary_failure() { | |
| 703 | 775 | // A required var missing (the binary exits non-zero) must fail the check. | |
| @@ -726,6 +798,7 @@ mod tests { | |||
| 726 | 798 | config_check_env_file: None, | |
| 727 | 799 | actuate: vec!["restart".into()], // no deploy | |
| 728 | 800 | observe: vec![], | |
| 801 | + | companions: Vec::new(), | |
| 729 | 802 | }; | |
| 730 | 803 | let executor = LocalExec::new(CapabilitySet::from_tokens(["restart"], Vec::<&str>::new())); | |
| 731 | 804 | let err = deploy_node(&executor, &node, "0.0.1", &staged, "server").await.unwrap_err(); |
| @@ -1240,6 +1240,7 @@ mod tests { | |||
| 1240 | 1240 | config_check_env_file: None, | |
| 1241 | 1241 | actuate: crate::topology::default_actuate(), | |
| 1242 | 1242 | observe: crate::topology::default_observe(), | |
| 1243 | + | companions: Vec::new(), | |
| 1243 | 1244 | }], | |
| 1244 | 1245 | }, | |
| 1245 | 1246 | ], | |
| @@ -1261,6 +1262,7 @@ mod tests { | |||
| 1261 | 1262 | release_contents: vec![], | |
| 1262 | 1263 | cargo_target_dir: None, | |
| 1263 | 1264 | gate_timeout_secs: 2400, | |
| 1265 | + | companions: Vec::new(), | |
| 1264 | 1266 | } | |
| 1265 | 1267 | } | |
| 1266 | 1268 | ||
| @@ -1841,6 +1843,7 @@ mod tests { | |||
| 1841 | 1843 | config_check_env_file: None, | |
| 1842 | 1844 | actuate: default_actuate(), | |
| 1843 | 1845 | observe: default_observe(), | |
| 1846 | + | companions: Vec::new(), | |
| 1844 | 1847 | }); | |
| 1845 | 1848 | } | |
| 1846 | 1849 | ||
| @@ -1881,6 +1884,7 @@ mod tests { | |||
| 1881 | 1884 | config_check_env_file: None, | |
| 1882 | 1885 | actuate: default_actuate(), | |
| 1883 | 1886 | observe: default_observe(), | |
| 1887 | + | companions: Vec::new(), | |
| 1884 | 1888 | }; | |
| 1885 | 1889 | let state = test_state().await; // no versions row for "9.9.9" | |
| 1886 | 1890 | let restored = rollback_deployed_nodes(&state, &tid("a"), &[&node], "9.9.9").await; |
| @@ -156,6 +156,7 @@ mod tests { | |||
| 156 | 156 | config_check_env_file: None, | |
| 157 | 157 | actuate: crate::topology::default_actuate(), | |
| 158 | 158 | observe: crate::topology::default_observe(), | |
| 159 | + | companions: Vec::new(), | |
| 159 | 160 | } | |
| 160 | 161 | } | |
| 161 | 162 |
| @@ -77,6 +77,27 @@ pub struct Node { | |||
| 77 | 77 | /// needs no edit. | |
| 78 | 78 | #[serde(default)] | |
| 79 | 79 | pub health_url: Option<String>, | |
| 80 | + | /// Companion services this node installs from the release bundle after the | |
| 81 | + | /// server is swapped and restarted. Each entry names a `[[companion]]` the | |
| 82 | + | /// daemon builds (see `Config::companions`) and says where its binary lands | |
| 83 | + | /// and which unit to restart — so a contract-coupled service (mnw-cli) ships | |
| 84 | + | /// in the same promote as the server instead of drifting. Empty (default) = | |
| 85 | + | /// server-only node, so an existing `sando.toml` keeps working unedited. | |
| 86 | + | #[serde(default, rename = "companion")] | |
| 87 | + | pub companions: Vec<NodeCompanion>, | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | /// Where a node installs a built companion binary and which unit to bounce. | |
| 91 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 92 | + | pub struct NodeCompanion { | |
| 93 | + | /// Must match a `Config::companions[].name` — the bundle subdir to read from. | |
| 94 | + | pub name: String, | |
| 95 | + | /// Absolute path the companion binary is installed to on the node | |
| 96 | + | /// (the unit's `ExecStart`), e.g. `/opt/mnw-cli/mnw-cli`. | |
| 97 | + | pub install_path: String, | |
| 98 | + | /// systemd unit restarted after the binary is installed, e.g. | |
| 99 | + | /// `mnw-cli.service`. | |
| 100 | + | pub service_name: String, | |
| 80 | 101 | } | |
| 81 | 102 | ||
| 82 | 103 | fn default_service_name() -> String { "makenotwork.service".into() } | |
| @@ -307,6 +328,41 @@ release_root = "/srv/mnw" | |||
| 307 | 328 | } | |
| 308 | 329 | ||
| 309 | 330 | #[test] | |
| 331 | + | fn node_companions_default_empty_and_parse_when_present() { | |
| 332 | + | // A node without [[tier.node.companion]] is server-only. | |
| 333 | + | let plain = topo_with_serving_gates(true, r#"{ kind = "node_health" }"#); | |
| 334 | + | assert!(plain.tiers[0].nodes[0].companions.is_empty()); | |
| 335 | + | ||
| 336 | + | // A node that declares a companion carries its install target + unit. | |
| 337 | + | let raw = r#" | |
| 338 | + | [repo] | |
| 339 | + | bare_path = "/tmp/repo.git" | |
| 340 | + | branch = "main" | |
| 341 | + | [backup] | |
| 342 | + | source = "s" | |
| 343 | + | local_path = "/tmp/d" | |
| 344 | + | [[tier]] | |
| 345 | + | name = "b" | |
| 346 | + | provisioned = true | |
| 347 | + | gates = [{ kind = "node_health" }] | |
| 348 | + | [[tier.node]] | |
| 349 | + | name = "prod-1" | |
| 350 | + | ssh_target = "makenotwork@alpha-west-1" | |
| 351 | + | release_root = "/opt/mnw" | |
| 352 | + | [[tier.node.companion]] | |
| 353 | + | name = "mnw-cli" | |
| 354 | + | install_path = "/opt/mnw-cli/mnw-cli" | |
| 355 | + | service_name = "mnw-cli.service" | |
| 356 | + | "#; | |
| 357 | + | let topo: Topology = toml::from_str(raw).expect("parse companion topology"); | |
| 358 | + | let c = &topo.tiers[0].nodes[0].companions; | |
| 359 | + | assert_eq!(c.len(), 1); | |
| 360 | + | assert_eq!(c[0].name, "mnw-cli"); | |
| 361 | + | assert_eq!(c[0].install_path, "/opt/mnw-cli/mnw-cli"); | |
| 362 | + | assert_eq!(c[0].service_name, "mnw-cli.service"); | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | #[test] | |
| 310 | 366 | fn real_sando_toml_loads_clean() { | |
| 311 | 367 | // The shipped topology must satisfy the invariant — guards against a | |
| 312 | 368 | // regression that would lock sandod out of its own config. |
| @@ -17,6 +17,8 @@ live in the Syncthing private layer (`_private/infra/`, `_private/deploy`). | |||
| 17 | 17 | | `mnw-testnot-refresh.{sh,service,timer}` | Sando host | Daily refresh of the testnot.work staging mirror (05:00 UTC). | | |
| 18 | 18 | | `sando-update@.service` + `sando-self-update.sh` | Sando host | Self-update: rebuild + restart `sandod` to a target sha. | | |
| 19 | 19 | | `10-sando-update.rules` | Sando host | polkit grant letting the `sando` user start (only) `sando-update@*`. | | |
| 20 | + | | `install-companion.sh` | a deploy target | Install a staged companion binary + restart its unit (companion services). | | |
| 21 | + | | `mnw-companion.sudoers` | a deploy target | Scoped sudo grant for the deploy user to run `install-companion.sh`. | | |
| 20 | 22 | ||
| 21 | 23 | ## Self-update (deploying the controller itself) | |
| 22 | 24 | ||
| @@ -77,6 +79,42 @@ curl -sS -X POST "$BASE/self-update" -H 'Content-Type: application/json' \ | |||
| 77 | 79 | journalctl -u "sando-update@$SHA" -f | |
| 78 | 80 | ``` | |
| 79 | 81 | ||
| 82 | + | ## Companion services (deploying mnw-cli in lockstep) | |
| 83 | + | ||
| 84 | + | `mnw-cli` (the public git-SSH server that proxies to `/api/internal/*`) shares | |
| 85 | + | the server's internal-API contract but used to deploy from its own | |
| 86 | + | `mnw-cli/deploy/deploy.sh`. It drifted two months out of lockstep and broke git | |
| 87 | + | hosting the moment the server tightened that contract (0.10.14, postmortem #4). | |
| 88 | + | Sando now builds and ships it in the **same promote** as the server: | |
| 89 | + | ||
| 90 | + | - **Build** — `[[companion]]` in the daemon config lists crates to compile from | |
| 91 | + | the same worktree/sha as the server. Each is built after the server and staged | |
| 92 | + | into the release bundle as `companions/<name>`. A companion that fails to build | |
| 93 | + | fails the whole pipeline — that is the lockstep guarantee. | |
| 94 | + | - **Deploy** — `[[tier.node.companion]]` on a node says which companions it | |
| 95 | + | installs, to what `install_path`, and which unit to restart. After the server | |
| 96 | + | is swapped and back up (mnw-cli is `After=makenotwork.service`), the node | |
| 97 | + | installs the staged binary and restarts the unit. testnot has no mnw-cli, so | |
| 98 | + | only `prod-1` declares it. | |
| 99 | + | ||
| 100 | + | Deploy runs `sudo /usr/local/lib/mnw/install-companion.sh <src> <dst> <service>` | |
| 101 | + | over the node's executor. The wrapper keeps the deploy user's sudo grant to one | |
| 102 | + | auditable script (it validates: src inside a release bundle, dst under `/opt`, | |
| 103 | + | service a bare `*.service`) rather than a broad `install`/`systemctl` grant. | |
| 104 | + | ||
| 105 | + | One-time per node that hosts a companion (currently prod-1), as root: | |
| 106 | + | ||
| 107 | + | ```sh | |
| 108 | + | sudo install -d /usr/local/lib/mnw | |
| 109 | + | sudo install -m 0755 install-companion.sh /usr/local/lib/mnw/install-companion.sh | |
| 110 | + | sudo install -m 0440 mnw-companion.sudoers /etc/sudoers.d/mnw-companion | |
| 111 | + | sudo visudo -cf /etc/sudoers.d/mnw-companion # validate before trusting it | |
| 112 | + | ``` | |
| 113 | + | ||
| 114 | + | After that, every promote that reaches the node ships the server and its | |
| 115 | + | companions together; no separate `mnw-cli` deploy step. The legacy | |
| 116 | + | `mnw-cli/deploy/deploy.sh` is retired once the first lockstep prod promote lands. | |
| 117 | + | ||
| 80 | 118 | ## testnot.work staging mirror | |
| 81 | 119 | ||
| 82 | 120 | testnot is a read-only mirror of production, gated app-side to Fan+/creator |
| @@ -0,0 +1,60 @@ | |||
| 1 | + | #!/usr/bin/env bash | |
| 2 | + | # Install a staged companion binary and restart its unit. Runs as ROOT, invoked | |
| 3 | + | # by Sando's deploy step over the node's executor as: | |
| 4 | + | # | |
| 5 | + | # sudo /usr/local/lib/mnw/install-companion.sh <src> <dst> <service> | |
| 6 | + | # | |
| 7 | + | # where <src> is the companion binary inside the just-rsynced release bundle | |
| 8 | + | # (e.g. /opt/mnw/releases/<ver>/companions/mnw-cli), <dst> is the unit's | |
| 9 | + | # ExecStart path (e.g. /opt/mnw-cli/mnw-cli), and <service> is the systemd unit | |
| 10 | + | # to restart (e.g. mnw-cli.service). This wrapper exists so the deploy user's | |
| 11 | + | # sudo grant is ONE auditable script rather than a broad install/systemctl grant. | |
| 12 | + | # | |
| 13 | + | # Install (one-time per node, as root): | |
| 14 | + | # sudo install -d /usr/local/lib/mnw | |
| 15 | + | # sudo install -m 0755 install-companion.sh /usr/local/lib/mnw/install-companion.sh | |
| 16 | + | # # then add the scoped sudoers line — see mnw-companion.sudoers | |
| 17 | + | # | |
| 18 | + | # The install is atomic (install(1) writes a temp then renames), so a running | |
| 19 | + | # companion never execs a half-written file. The service is restarted only after | |
| 20 | + | # a successful install; a failed install leaves the unit on its current binary. | |
| 21 | + | set -euo pipefail | |
| 22 | + | ||
| 23 | + | if [[ $# -ne 3 ]]; then | |
| 24 | + | echo "usage: install-companion.sh <src-binary> <dst-path> <service>" >&2 | |
| 25 | + | exit 2 | |
| 26 | + | fi | |
| 27 | + | ||
| 28 | + | SRC="$1" | |
| 29 | + | DST="$2" | |
| 30 | + | SERVICE="$3" | |
| 31 | + | ||
| 32 | + | # Guard rails: refuse a src outside a release bundle or a dst outside /opt, and a | |
| 33 | + | # service name that isn't a bare unit. These bound what a caller (already behind | |
| 34 | + | # the single sudoers grant) can install/restart — defense in depth, not the | |
| 35 | + | # primary control. | |
| 36 | + | case "$SRC" in | |
| 37 | + | */releases/*/companions/*) : ;; | |
| 38 | + | *) echo "install-companion: refusing src outside a release bundle: $SRC" >&2; exit 3 ;; | |
| 39 | + | esac | |
| 40 | + | case "$DST" in | |
| 41 | + | /opt/*) : ;; | |
| 42 | + | *) echo "install-companion: refusing dst outside /opt: $DST" >&2; exit 3 ;; | |
| 43 | + | esac | |
| 44 | + | case "$SERVICE" in | |
| 45 | + | *[/[:space:]]*|"") echo "install-companion: bad service name: $SERVICE" >&2; exit 3 ;; | |
| 46 | + | *.service) : ;; | |
| 47 | + | *) echo "install-companion: service must end in .service: $SERVICE" >&2; exit 3 ;; | |
| 48 | + | esac | |
| 49 | + | ||
| 50 | + | if [[ ! -f "$SRC" ]]; then | |
| 51 | + | echo "install-companion: no binary at $SRC" >&2 | |
| 52 | + | exit 4 | |
| 53 | + | fi | |
| 54 | + | ||
| 55 | + | echo "install-companion: installing $SRC -> $DST" | |
| 56 | + | install -m 0755 "$SRC" "$DST" | |
| 57 | + | ||
| 58 | + | echo "install-companion: restarting $SERVICE" | |
| 59 | + | systemctl restart "$SERVICE" | |
| 60 | + | echo "install-companion: done ($SERVICE live on $(basename "$DST"))" |
| @@ -0,0 +1,13 @@ | |||
| 1 | + | # Sando companion-deploy grant. Install to /etc/sudoers.d/ on each node that | |
| 2 | + | # hosts a companion service (currently prod-1 / alpha-west-1): | |
| 3 | + | # | |
| 4 | + | # sudo install -m 0440 mnw-companion.sudoers /etc/sudoers.d/mnw-companion | |
| 5 | + | # sudo visudo -cf /etc/sudoers.d/mnw-companion # validate before trusting it | |
| 6 | + | # | |
| 7 | + | # The deploy user (makenotwork on prod — the same user Sando's executor SSHes as) | |
| 8 | + | # may run ONLY the companion installer, with any args. The args are bounded by | |
| 9 | + | # the script itself (src must be inside a release bundle, dst under /opt, service | |
| 10 | + | # a bare *.service unit), so the wildcard is a script-guarded grant, not a broad | |
| 11 | + | # install/systemctl grant. This is the companion analogue of the existing | |
| 12 | + | # `makenotwork ... /bin/systemctl reload-or-restart makenotwork.service` line. | |
| 13 | + | makenotwork ALL=(root) NOPASSWD: /usr/local/lib/mnw/install-companion.sh * |
| @@ -88,6 +88,15 @@ service_name = "makenotwork.service" | |||
| 88 | 88 | # 0.10.14 deploy crash-looped on a missing CDN_BASE_URL — the exact miss this | |
| 89 | 89 | # gate closes. First real exercise is the next prod promote. | |
| 90 | 90 | config_check_env_file = "/etc/mnw/makenotwork.env" | |
| 91 | + | # Companion: install the mnw-cli built in this same promote (see [[companion]] in | |
| 92 | + | # sando-daemon.toml) after the server is up, and restart its unit. Closes the | |
| 93 | + | # drift that broke git hosting during the 0.10.14 deploy — mnw-cli now ships from | |
| 94 | + | # the same sha as the server. Needs the node-side wrapper + sudoers grant | |
| 95 | + | # (deploy/install-companion.sh, deploy/mnw-companion.sudoers). | |
| 96 | + | [[tier.node.companion]] | |
| 97 | + | name = "mnw-cli" | |
| 98 | + | install_path = "/opt/mnw-cli/mnw-cli" | |
| 99 | + | service_name = "mnw-cli.service" | |
| 91 | 100 | ||
| 92 | 101 | # ---- C: prod-2 (declared, not yet provisioned) ---- | |
| 93 | 102 | [[tier]] |