max / makenotwork
3 files changed,
+118 insertions,
-1 deletion
| @@ -246,6 +246,15 @@ struct PromoteBody { | |||
| 246 | 246 | hotfix: bool, | |
| 247 | 247 | #[serde(default)] | |
| 248 | 248 | reset_burn_in: bool, | |
| 249 | + | /// Set when the release carries a database migration. Rollback restores the | |
| 250 | + | /// binary and release_contents only, never the schema (MNW migrates forward | |
| 251 | + | /// on boot and has no down path), so a migration-bearing promote is one-way. | |
| 252 | + | /// This forces a fresh `manual_confirm` on the predecessor tier even if the | |
| 253 | + | /// tier does not configure one, so the operator consciously acknowledges the | |
| 254 | + | /// rollback contract. `hotfix` does not suppress it. See | |
| 255 | + | /// `deploy/README.md` "Rollback contract". | |
| 256 | + | #[serde(default)] | |
| 257 | + | bears_migration: bool, | |
| 249 | 258 | } | |
| 250 | 259 | ||
| 251 | 260 | async fn promote( | |
| @@ -1578,6 +1587,68 @@ mod tests { | |||
| 1578 | 1587 | } | |
| 1579 | 1588 | ||
| 1580 | 1589 | #[tokio::test] | |
| 1590 | + | async fn migration_bearing_promote_requires_fresh_confirm() { | |
| 1591 | + | // The predecessor (host) configures NO gates, so nothing would normally | |
| 1592 | + | // block host -> a. A `bears_migration` promote must still be blocked on a | |
| 1593 | + | // fresh `manual_confirm` it does not have: rollback restores the binary | |
| 1594 | + | // only, so the one-way advance needs a conscious operator sign-off. | |
| 1595 | + | let pool = fresh_pool().await; | |
| 1596 | + | for (i, name) in ["host", "a"].iter().enumerate() { | |
| 1597 | + | sqlx::query( | |
| 1598 | + | "INSERT INTO tiers (name, ord, provisioned, canary) VALUES (?, ?, 1, 'sequential')", | |
| 1599 | + | ) | |
| 1600 | + | .bind(name) | |
| 1601 | + | .bind(i as i64) | |
| 1602 | + | .execute(&pool) | |
| 1603 | + | .await | |
| 1604 | + | .unwrap(); | |
| 1605 | + | sqlx::query("INSERT INTO tier_state (tier) VALUES (?)") | |
| 1606 | + | .bind(name) | |
| 1607 | + | .execute(&pool) | |
| 1608 | + | .await | |
| 1609 | + | .unwrap(); | |
| 1610 | + | } | |
| 1611 | + | let mut topo = test_topo(); | |
| 1612 | + | topo.tiers[0].gates = vec![]; // host configures no gates at all | |
| 1613 | + | let executors = Arc::new(crate::state::build_executors(&topo)); | |
| 1614 | + | let state = AppState { | |
| 1615 | + | pool, | |
| 1616 | + | topo: Arc::new(topo), | |
| 1617 | + | cfg: Arc::new(test_cfg()), | |
| 1618 | + | active_build: Arc::new(tokio::sync::Mutex::new(None)), | |
| 1619 | + | deploy_lock: Arc::new(tokio::sync::Mutex::new(())), | |
| 1620 | + | events: crate::events::channel(), | |
| 1621 | + | executors, | |
| 1622 | + | api_token: None, | |
| 1623 | + | }; | |
| 1624 | + | sqlx::query("INSERT INTO versions (version, git_sha, built_at, artifact_path) VALUES ('0.8.12','sha',datetime('now'),'/tmp/x')") | |
| 1625 | + | .execute(&state.pool).await.unwrap(); | |
| 1626 | + | sqlx::query("UPDATE tier_state SET current_version = '0.8.12' WHERE tier = 'host'") | |
| 1627 | + | .execute(&state.pool) | |
| 1628 | + | .await | |
| 1629 | + | .unwrap(); | |
| 1630 | + | ||
| 1631 | + | let app = router(state); | |
| 1632 | + | let resp = app | |
| 1633 | + | .oneshot( | |
| 1634 | + | Request::builder() | |
| 1635 | + | .method("POST") | |
| 1636 | + | .uri("/promote/a") | |
| 1637 | + | .header("content-type", "application/json") | |
| 1638 | + | .body(Body::from(r#"{"bears_migration": true}"#)) | |
| 1639 | + | .unwrap(), | |
| 1640 | + | ) | |
| 1641 | + | .await | |
| 1642 | + | .unwrap(); | |
| 1643 | + | assert_eq!(resp.status(), StatusCode::CONFLICT); | |
| 1644 | + | let body = body_string(resp).await; | |
| 1645 | + | assert!( | |
| 1646 | + | body.contains("manual_confirm"), | |
| 1647 | + | "expected the migration promote to block on manual_confirm; got: {body}" | |
| 1648 | + | ); | |
| 1649 | + | } | |
| 1650 | + | ||
| 1651 | + | #[tokio::test] | |
| 1581 | 1652 | async fn tier_state_advance_is_atomic_previous_from_old_current() { | |
| 1582 | 1653 | // CF3: the promote advance is a single UPDATE where previous_version is | |
| 1583 | 1654 | // set from the row's *old* current_version (SQLite evaluates RHS against |
| @@ -132,10 +132,25 @@ pub(super) async fn promote_inner( | |||
| 132 | 132 | // build (with optional hotfix override that skips burn_in). Evaluated | |
| 133 | 133 | // against the topology gate list, so a gate that never ran blocks the | |
| 134 | 134 | // promote instead of being treated as green. | |
| 135 | + | // | |
| 136 | + | // A migration-bearing promote (`bears_migration`) forces a fresh | |
| 137 | + | // `manual_confirm` on the predecessor even if the tier does not configure | |
| 138 | + | // one: rollback restores the binary + release_contents only, never the | |
| 139 | + | // schema, so the operator must consciously acknowledge the one-way advance | |
| 140 | + | // (deploy/README.md "Rollback contract"). `hotfix` skips only `burn_in`, so | |
| 141 | + | // it does not suppress this confirm. | |
| 142 | + | let mut effective_gates = source.gates.clone(); | |
| 143 | + | if body.bears_migration | |
| 144 | + | && !effective_gates | |
| 145 | + | .iter() | |
| 146 | + | .any(|g| matches!(g, crate::topology::Gate::ManualConfirm)) | |
| 147 | + | { | |
| 148 | + | effective_gates.push(crate::topology::Gate::ManualConfirm); | |
| 149 | + | } | |
| 135 | 150 | let pending = unsatisfied_gates( | |
| 136 | 151 | &s.pool, | |
| 137 | 152 | &source.name, | |
| 138 | - | &source.gates, | |
| 153 | + | &effective_gates, | |
| 139 | 154 | &version_str, | |
| 140 | 155 | build_id, | |
| 141 | 156 | body.hotfix, |
| @@ -79,6 +79,37 @@ curl -sS -X POST "$BASE/self-update" -H 'Content-Type: application/json' \ | |||
| 79 | 79 | journalctl -u "sando-update@$SHA" -f | |
| 80 | 80 | ``` | |
| 81 | 81 | ||
| 82 | + | ## Rollback contract | |
| 83 | + | ||
| 84 | + | A Sando rollback (canary rollback of a node, or an operator swapping the | |
| 85 | + | `current` symlink back to an older release dir) restores **the binary and the | |
| 86 | + | release contents only**. The database does not roll back: | |
| 87 | + | ||
| 88 | + | - MNW migrates **forward** on boot (`makenotwork.service` runs pending migrations | |
| 89 | + | when it starts). | |
| 90 | + | - `sqlx::migrate::Migrator` has no down path, and Sando never invokes one. | |
| 91 | + | ||
| 92 | + | So rolling, say, `0.10.15` back to `0.10.14` **after** `0.10.15` applied a | |
| 93 | + | migration leaves the old binary running against a newer schema. That is a | |
| 94 | + | one-way door for any release that carries a migration: forward is safe, back is | |
| 95 | + | not. Sando has no restore-to-prod path either — `/backup/fetch` pulls the prod | |
| 96 | + | dump solely as input to the `migration_dry_run` gate, not to restore a live node. | |
| 97 | + | ||
| 98 | + | **Operating rule.** Promote a migration-bearing release with | |
| 99 | + | `{"bears_migration": true}`. That forces a fresh `manual_confirm` on the | |
| 100 | + | predecessor tier before the advance, even on a tier that configures no confirm, | |
| 101 | + | so the one-way advance is a conscious step. `hotfix` does not suppress it (it | |
| 102 | + | skips only `burn_in`). To actually undo a migration-bearing release you must | |
| 103 | + | restore the database from a backup by hand first; a symlink rollback alone will | |
| 104 | + | serve a mismatched schema. | |
| 105 | + | ||
| 106 | + | **Does the backup actually restore?** The `migration_dry_run` gate answers this | |
| 107 | + | on every build: it resets a scratch database, restores the latest | |
| 108 | + | `/srv/sando/backups/latest.sql.gz`, and runs the migrator against it. A failed | |
| 109 | + | restore fails the gate. What is *not* automated is a full restore-to-serving | |
| 110 | + | drill (restore into a throwaway target and confirm the app boots and serves | |
| 111 | + | against it) — that is tracked as an infra task, not wired into the pipeline. | |
| 112 | + | ||
| 82 | 113 | ## Companion services (deploying mnw-cli in lockstep) | |
| 83 | 114 | ||
| 84 | 115 | `mnw-cli` (the public git-SSH server that proxies to `/api/internal/*`) shares |