Skip to main content

max / makenotwork

sando: build + deploy companion services (mnw-cli) in lockstep mnw-cli shares the server's internal-API contract but deployed from its own script, so it drifted two months and broke git hosting the moment the server tightened that contract (0.10.14, postmortem #4). Sando now ships it in the same promote as the server. - Config: [[companion]] in the daemon config lists crates to build from the same worktree/sha; [[tier.node.companion]] on a node says where to install each and which unit to restart. Both default-empty, so the sando code stays project-agnostic. - Build: each companion is compiled after the server (native on fw13, no more macOS cross-compile) and staged into the release bundle as companions/<name>. A companion build failure fails the whole pipeline — the lockstep guarantee. - Deploy: after the server is swapped and back up, each node installs its companions from the just-rsynced bundle and restarts their units via a node-side wrapper (install-companion.sh) behind one scoped sudoers grant, rather than a broad install/systemctl grant. testnot has no mnw-cli, so only prod-1 declares it. Wired mnw-cli as the first companion (build entry + prod-1 node entry). Node bootstrap (wrapper + sudoers) documented in deploy/README.md. 164 daemon tests pass, clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-10 14:25 UTC
Commit: 53656b31e0a3a36e0c2ff03ff56cac5d4a9650e9
Parent: 907a1f7
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]]