max / makenotwork
3 files changed,
+84 insertions,
-4 deletions
| @@ -118,6 +118,11 @@ Promote-time gates run against a tier's deployed nodes or its operator. | |||
| 118 | 118 | | `burn_in` | promote | The tier has held its current version for N hours. Evaluated live against the clock. | | |
| 119 | 119 | | `manual_confirm` | promote | An operator signed off, at or after this version landed on the tier. | | |
| 120 | 120 | ||
| 121 | + | A tier's gates guard promotion **out** of it. So the gate list that stands | |
| 122 | + | between staging and production is tier `a`'s, not tier `b`'s — sign-off for a | |
| 123 | + | prod ship is `POST /confirm/a`, run after the version lands on `a` (the | |
| 124 | + | confirmation must be fresher than that landing) and before `POST /promote/b`. | |
| 125 | + | ||
| 121 | 126 | ### What `cargo_test` runs | |
| 122 | 127 | ||
| 123 | 128 | The gate used to be hardcoded to `worktree/server`, so every other crate in the | |
| @@ -229,7 +234,8 @@ Action results show up in the events log a moment later (the actions themselves | |||
| 229 | 234 | `POST /promote/{tier}` accepts: | |
| 230 | 235 | ||
| 231 | 236 | - `hotfix: true` — skips the `burn_in` gate on the predecessor tier only. All | |
| 232 | - | other gates still apply. | |
| 237 | + | other gates still apply, `manual_confirm` included: a hotfix still needs an | |
| 238 | + | operator sign-off before it reaches production. | |
| 233 | 239 | - `reset_burn_in: true` (default `false`) — additionally nulls | |
| 234 | 240 | `tier_state.burn_in_started_at` on the source tier, restarting the clock | |
| 235 | 241 | for whatever else is still burning in there. Use this only when the hotfix |
| @@ -399,4 +399,58 @@ service_name = "mnw-cli.service" | |||
| 399 | 399 | let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../sando.toml"); | |
| 400 | 400 | Topology::load(&path).expect("shipped sando.toml must validate"); | |
| 401 | 401 | } | |
| 402 | + | ||
| 403 | + | fn shipped() -> Topology { | |
| 404 | + | let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../sando.toml"); | |
| 405 | + | Topology::load(&path).expect("shipped sando.toml must validate") | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | #[test] | |
| 409 | + | fn shipping_to_the_last_provisioned_tier_needs_an_operator_signoff() { | |
| 410 | + | // A tier's gates guard promotion *out* of it, which is the subtlety that | |
| 411 | + | // made manual_confirm inert: it sat on tier b, guarding b -> c, and c is | |
| 412 | + | // not provisioned. So the ship to production was cleared by node_health | |
| 413 | + | // + burn_in alone — and `hotfix: true` skips burn_in. | |
| 414 | + | // | |
| 415 | + | // The gate that matters therefore belongs on the PREDECESSOR of the last | |
| 416 | + | // provisioned tier. Asserted structurally so re-provisioning tiers cannot | |
| 417 | + | // silently strand the sign-off again. | |
| 418 | + | let topo = shipped(); | |
| 419 | + | let last = topo | |
| 420 | + | .tiers | |
| 421 | + | .iter() | |
| 422 | + | .rposition(|t| t.provisioned) | |
| 423 | + | .expect("some tier must be provisioned"); | |
| 424 | + | assert!(last > 0, "the production tier cannot be the first tier"); | |
| 425 | + | let guard = &topo.tiers[last - 1]; | |
| 426 | + | assert!( | |
| 427 | + | guard.gates.iter().any(|g| matches!(g, Gate::ManualConfirm)), | |
| 428 | + | "tier {} guards promotion into the last provisioned tier ({}), so it must require an operator sign-off; its gates are {:?}", | |
| 429 | + | guard.name, | |
| 430 | + | topo.tiers[last].name, | |
| 431 | + | guard | |
| 432 | + | .gates | |
| 433 | + | .iter() | |
| 434 | + | .map(|g| g.kind().as_str()) | |
| 435 | + | .collect::<Vec<_>>(), | |
| 436 | + | ); | |
| 437 | + | } | |
| 438 | + | ||
| 439 | + | #[test] | |
| 440 | + | fn every_serving_node_has_a_readiness_probe() { | |
| 441 | + | // Without health_url, node_health degrades to `systemctl is-active`, | |
| 442 | + | // which a crash-looping binary satisfies between restarts — exactly what | |
| 443 | + | // the 0.10.14 CDN_BASE_URL crash-loop did on prod-1. | |
| 444 | + | let topo = shipped(); | |
| 445 | + | for tier in topo.tiers.iter().filter(|t| t.provisioned) { | |
| 446 | + | for node in &tier.nodes { | |
| 447 | + | assert!( | |
| 448 | + | node.health_url.is_some(), | |
| 449 | + | "node {} on tier {} has no health_url, so node_health proves only that systemd thinks the unit is running", | |
| 450 | + | node.name, | |
| 451 | + | tier.name, | |
| 452 | + | ); | |
| 453 | + | } | |
| 454 | + | } | |
| 455 | + | } | |
| 402 | 456 | } |
| @@ -76,9 +76,20 @@ canary = "sequential" | |||
| 76 | 76 | # node_health (post-deploy) probes the deployed node over its executor — the | |
| 77 | 77 | # gate boot_smoke used to stand in for, but boot_smoke runs on the build host and | |
| 78 | 78 | # proves nothing about testnot-1. boot_smoke stays a host build-time gate only. | |
| 79 | + | # | |
| 80 | + | # A tier's gates guard promotion *out* of it, so THIS list is what stands between | |
| 81 | + | # testnot and production. manual_confirm lives here for that reason. It used to | |
| 82 | + | # sit only on tier b, where it guards b -> c — and c is not provisioned, so it | |
| 83 | + | # gated nothing: a prod ship was cleared by node_health + burn_in alone, and | |
| 84 | + | # hotfix: true skips burn_in, leaving one gate on a production deploy. | |
| 85 | + | # | |
| 86 | + | # hotfix does NOT skip manual_confirm (only burn_in), so every ship to prod-1 | |
| 87 | + | # now needs an explicit POST /confirm/a naming the version. That is the point: | |
| 88 | + | # there was no human sign-off anywhere on the path to production. | |
| 79 | 89 | gates = [ | |
| 80 | 90 | { kind = "node_health" }, | |
| 81 | 91 | { kind = "burn_in", hours = 48 }, | |
| 92 | + | { kind = "manual_confirm" }, | |
| 82 | 93 | ] | |
| 83 | 94 | [[tier.node]] | |
| 84 | 95 | name = "testnot-1" | |
| @@ -91,15 +102,20 @@ service_name = "makenotwork.service" | |||
| 91 | 102 | # fails the promote with the running service intact. The node runs 0.10.14, which | |
| 92 | 103 | # supports the mode; validated end-to-end 2026-07-10. | |
| 93 | 104 | config_check_env_file = "/etc/mnw/makenotwork.env" | |
| 94 | - | # Set health_url to add an HTTP readiness probe on top of `systemctl is-active`, | |
| 95 | - | # e.g. health_url = "http://127.0.0.1:8080/health" (the local port the service | |
| 96 | - | # binds). Unset = unit-active check only. | |
| 105 | + | # Readiness probe on top of `systemctl is-active`. Without this, node_health | |
| 106 | + | # proves only that systemd thinks the unit is running — which a crash-looping | |
| 107 | + | # binary can satisfy between restarts. The node binds HOST=127.0.0.1 PORT=8080 | |
| 108 | + | # (/etc/mnw/makenotwork.env); verified serving 200 on 2026-07-21. | |
| 109 | + | health_url = "http://127.0.0.1:8080/health" | |
| 97 | 110 | ||
| 98 | 111 | # ---- B: prod-1 ---- | |
| 99 | 112 | [[tier]] | |
| 100 | 113 | name = "b" | |
| 101 | 114 | provisioned = true | |
| 102 | 115 | canary = "sequential" | |
| 116 | + | # Guards b -> c. Kept for the day prod-2 is provisioned; it gates nothing today | |
| 117 | + | # (c has provisioned = false). The sign-off that matters for shipping to THIS | |
| 118 | + | # tier lives on tier a. | |
| 103 | 119 | gates = [ | |
| 104 | 120 | { kind = "node_health" }, | |
| 105 | 121 | { kind = "manual_confirm" }, | |
| @@ -116,6 +132,10 @@ service_name = "makenotwork.service" | |||
| 116 | 132 | # 0.10.14 deploy crash-looped on a missing CDN_BASE_URL — the exact miss this | |
| 117 | 133 | # gate closes. First real exercise is the next prod promote. | |
| 118 | 134 | config_check_env_file = "/etc/mnw/makenotwork.env" | |
| 135 | + | # Readiness probe (see testnot-1). The unit binds HOST=0.0.0.0 PORT=3000, so the | |
| 136 | + | # probe dials it on loopback; verified serving 200 on 2026-07-21. This is the | |
| 137 | + | # node whose crash-loop `systemctl is-active` alone would have missed. | |
| 138 | + | health_url = "http://127.0.0.1:3000/health" | |
| 119 | 139 | # Companion: install the mnw-cli built in this same promote (see [[companion]] in | |
| 120 | 140 | # sando-daemon.toml) after the server is up, and restart its unit. Closes the | |
| 121 | 141 | # drift that broke git hosting during the 0.10.14 deploy — mnw-cli now ships from |