max / makenotwork
4 files changed,
+84 insertions,
-1 deletion
| @@ -180,6 +180,19 @@ async fn deploy_remote( | |||
| 180 | 180 | .await | |
| 181 | 181 | .context("deployed binary architecture does not match the target node (current symlink left intact)")?; | |
| 182 | 182 | ||
| 183 | + | // Config-drift guard (opt-in per node). Runs the freshly-rsynced binary in | |
| 184 | + | // config-only mode with the node's env sourced, BEFORE the swap, so a | |
| 185 | + | // required var missing on this node fails here — service still intact — | |
| 186 | + | // rather than after the restart, which would crash-loop it (how testnot | |
| 187 | + | // went down on a missing CDN_BASE_URL). Skipped unless the node sets | |
| 188 | + | // `config_check_env_file`. | |
| 189 | + | if let Some(env_file) = node.config_check_env_file.as_deref() { | |
| 190 | + | tracing::info!(node = %node.name, version, "deploy: pre-swap config check"); | |
| 191 | + | check_target_config(executor, &deployed_bin, env_file) | |
| 192 | + | .await | |
| 193 | + | .context("pre-swap config check failed (current symlink left intact)")?; | |
| 194 | + | } | |
| 195 | + | ||
| 183 | 196 | tracing::info!(node = %node.name, version, "deploy: symlink swap + service reload"); | |
| 184 | 197 | let restart_cmd = format!("sudo /bin/systemctl reload-or-restart {}", sh_quote(service)); | |
| 185 | 198 | let swap_and_restart = swap_and_restart_script(release_root, version, &restart_cmd); | |
| @@ -192,6 +205,39 @@ async fn deploy_remote( | |||
| 192 | 205 | Ok(PathBuf::from(release_root).join("releases").join(version)) | |
| 193 | 206 | } | |
| 194 | 207 | ||
| 208 | + | /// Pre-swap config-drift check: source the node's env file, then run the | |
| 209 | + | /// freshly-deployed binary in `MNW_CHECK_CONFIG=1` mode (loads config, exits | |
| 210 | + | /// 0/1, no DB/migrations/bind). A non-zero exit — a required var missing — is | |
| 211 | + | /// surfaced by `run_checked` as an error, failing the promote before the swap. | |
| 212 | + | /// | |
| 213 | + | /// Bounded by a timeout as a backstop: a binary predating `MNW_CHECK_CONFIG` | |
| 214 | + | /// would ignore the var and try to start normally, which must not hang the | |
| 215 | + | /// deploy. A timeout is reported as a failure (fail closed) — the operator only | |
| 216 | + | /// opts a node in once a check-capable version is deployed, so a timeout means | |
| 217 | + | /// something is wrong, not a routine older binary. | |
| 218 | + | async fn check_target_config( | |
| 219 | + | executor: &dyn Executor, | |
| 220 | + | deployed_bin: &str, | |
| 221 | + | env_file: &str, | |
| 222 | + | ) -> Result<()> { | |
| 223 | + | // `set -a` exports every var the env file assigns so Config::from_env sees | |
| 224 | + | // them; the binary then validates and exits. Quoted paths; the env file is | |
| 225 | + | // operator-controlled config, not user input. | |
| 226 | + | let script = format!( | |
| 227 | + | "set -eu; set -a; . {env}; set +a; MNW_CHECK_CONFIG=1 {bin}", | |
| 228 | + | env = sh_quote(env_file), | |
| 229 | + | bin = sh_quote(deployed_bin), | |
| 230 | + | ); | |
| 231 | + | let fut = run_checked(executor, &script, "pre-swap config check"); | |
| 232 | + | match tokio::time::timeout(std::time::Duration::from_secs(20), fut).await { | |
| 233 | + | Ok(result) => result.map(|_| ()), | |
| 234 | + | Err(_) => anyhow::bail!( | |
| 235 | + | "pre-swap config check timed out after 20s — the binary may predate \ | |
| 236 | + | MNW_CHECK_CONFIG or the check hung; refusing to swap" | |
| 237 | + | ), | |
| 238 | + | } | |
| 239 | + | } | |
| 240 | + | ||
| 195 | 241 | /// Build the swap-and-restart shell script for a remote node. | |
| 196 | 242 | /// | |
| 197 | 243 | /// The symlink swap is atomic via `mv -T` of a freshly-created symlink over the | |
| @@ -429,6 +475,7 @@ mod tests { | |||
| 429 | 475 | release_root: "/opt/never".into(), | |
| 430 | 476 | service_name: "makenotwork.service".into(), | |
| 431 | 477 | health_url: None, | |
| 478 | + | config_check_env_file: None, | |
| 432 | 479 | actuate: crate::topology::default_actuate(), | |
| 433 | 480 | observe: crate::topology::default_observe(), | |
| 434 | 481 | }; | |
| @@ -467,6 +514,7 @@ mod tests { | |||
| 467 | 514 | release_root: release_root.to_string_lossy().into_owned(), | |
| 468 | 515 | service_name: "makenotwork.service".into(), | |
| 469 | 516 | health_url: None, | |
| 517 | + | config_check_env_file: None, | |
| 470 | 518 | actuate: crate::topology::default_actuate(), | |
| 471 | 519 | observe: crate::topology::default_observe(), | |
| 472 | 520 | }; | |
| @@ -593,6 +641,7 @@ mod tests { | |||
| 593 | 641 | release_root: release_root.to_string_lossy().into_owned(), | |
| 594 | 642 | service_name: "makenotwork.service".into(), | |
| 595 | 643 | health_url: None, | |
| 644 | + | config_check_env_file: None, | |
| 596 | 645 | actuate: vec!["restart".into()], // no deploy | |
| 597 | 646 | observe: vec![], | |
| 598 | 647 | }; |
| @@ -246,8 +246,27 @@ async fn promote( | |||
| 246 | 246 | body: Option<Json<PromoteBody>>, | |
| 247 | 247 | ) -> Result<Json<serde_json::Value>> { | |
| 248 | 248 | let body = body.map(|Json(b)| b).unwrap_or_default(); | |
| 249 | + | // Run the deploy in a detached task, not inline in the request future. | |
| 250 | + | // A promote's rsync + restart can outlast a client's socket timeout; if the | |
| 251 | + | // client disconnects, axum drops the handler future — and if the deploy ran | |
| 252 | + | // inline that would abandon the rollout mid-flight (symlink half-swapped, | |
| 253 | + | // tier_state not advanced, and — before the kill_on_drop fix — an orphaned | |
| 254 | + | // rsync). Spawning detaches the work from the connection: the task owns the | |
| 255 | + | // deploy lock and runs to completion regardless, while a still-connected | |
| 256 | + | // client still receives the result. Mirrors how /rebuild already runs its | |
| 257 | + | // build off-request. | |
| 258 | + | tokio::spawn(promote_inner(s, tier, body)) | |
| 259 | + | .await | |
| 260 | + | .map_err(|e| crate::error::Error::Other(anyhow::anyhow!("promote task failed to join: {e}")))? | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | async fn promote_inner( | |
| 264 | + | s: AppState, | |
| 265 | + | tier: String, | |
| 266 | + | body: PromoteBody, | |
| 267 | + | ) -> Result<Json<serde_json::Value>> { | |
| 249 | 268 | // Serialize the whole check -> deploy -> advance against any concurrent | |
| 250 | - | // promote/rollback (CF3). Held for the entire handler. | |
| 269 | + | // promote/rollback (CF3). Held for the entire task. | |
| 251 | 270 | let _deploy_guard = s.deploy_lock.lock().await; | |
| 252 | 271 | let tier = crate::domain::TierId::new(tier); | |
| 253 | 272 | let idx = s.topo.tiers.iter().position(|t| t.name == tier) | |
| @@ -1218,6 +1237,7 @@ mod tests { | |||
| 1218 | 1237 | release_root: "/tmp/a-node".into(), | |
| 1219 | 1238 | service_name: "makenotwork.service".into(), | |
| 1220 | 1239 | health_url: None, | |
| 1240 | + | config_check_env_file: None, | |
| 1221 | 1241 | actuate: crate::topology::default_actuate(), | |
| 1222 | 1242 | observe: crate::topology::default_observe(), | |
| 1223 | 1243 | }], | |
| @@ -1818,6 +1838,7 @@ mod tests { | |||
| 1818 | 1838 | release_root: rr.to_string_lossy().into_owned(), | |
| 1819 | 1839 | service_name: "x.service".into(), | |
| 1820 | 1840 | health_url: None, | |
| 1841 | + | config_check_env_file: None, | |
| 1821 | 1842 | actuate: default_actuate(), | |
| 1822 | 1843 | observe: default_observe(), | |
| 1823 | 1844 | }); | |
| @@ -1857,6 +1878,7 @@ mod tests { | |||
| 1857 | 1878 | release_root: rr.to_string_lossy().into_owned(), | |
| 1858 | 1879 | service_name: "x.service".into(), | |
| 1859 | 1880 | health_url: None, | |
| 1881 | + | config_check_env_file: None, | |
| 1860 | 1882 | actuate: default_actuate(), | |
| 1861 | 1883 | observe: default_observe(), | |
| 1862 | 1884 | }; |
| @@ -153,6 +153,7 @@ mod tests { | |||
| 153 | 153 | release_root: "/opt/mnw".into(), | |
| 154 | 154 | service_name: "makenotwork.service".into(), | |
| 155 | 155 | health_url: None, | |
| 156 | + | config_check_env_file: None, | |
| 156 | 157 | actuate: crate::topology::default_actuate(), | |
| 157 | 158 | observe: crate::topology::default_observe(), | |
| 158 | 159 | } |
| @@ -51,6 +51,17 @@ pub struct Node { | |||
| 51 | 51 | /// Defaults to "makenotwork.service" because that's MNW's prod unit. | |
| 52 | 52 | #[serde(default = "default_service_name")] | |
| 53 | 53 | pub service_name: String, | |
| 54 | + | /// Opt-in config-drift guard. When set to the node's env-file path (e.g. | |
| 55 | + | /// `/etc/mnw/makenotwork.env`), the deploy sources that file and runs the | |
| 56 | + | /// freshly-rsynced binary in `MNW_CHECK_CONFIG=1` mode BEFORE the symlink | |
| 57 | + | /// swap. A required var missing on this node (a var added upstream but never | |
| 58 | + | /// added to the node's env — how testnot crash-looped on CDN_BASE_URL) then | |
| 59 | + | /// fails the promote with the running service still intact, instead of after | |
| 60 | + | /// the swap+restart. Unset (default) skips the check, so an existing | |
| 61 | + | /// `sando.toml` keeps working and it never runs against a binary too old to | |
| 62 | + | /// support the mode; enable it once a check-capable version is deployed. | |
| 63 | + | #[serde(default)] | |
| 64 | + | pub config_check_env_file: Option<String>, | |
| 54 | 65 | /// Capability grant for this node's executor (see `ops_exec`). Defaults to | |
| 55 | 66 | /// the current behavior of every Sando node — actuate deploy+restart, | |
| 56 | 67 | /// observe health — so an existing `sando.toml` keeps working unedited. |