max / makenotwork
1 file changed,
+100 insertions,
-1 deletion
| @@ -118,6 +118,16 @@ impl Gate { | |||
| 118 | 118 | pub fn runs_post_deploy(&self) -> bool { | |
| 119 | 119 | matches!(self, Gate::BootSmoke) | |
| 120 | 120 | } | |
| 121 | + | ||
| 122 | + | /// Gates evaluated at promote time against the deployed artifact or the | |
| 123 | + | /// operator, as opposed to build-time gates (`cargo_test`, | |
| 124 | + | /// `migration_dry_run`) that run once on the build host and prove nothing | |
| 125 | + | /// about a promote. A serving tier whose gate list contains none of these | |
| 126 | + | /// would wave every promote straight through; `Topology::validate` refuses | |
| 127 | + | /// to load such a config (the structural form of CF1's fail-closed default). | |
| 128 | + | pub fn guards_promotion(&self) -> bool { | |
| 129 | + | matches!(self, Gate::BootSmoke | Gate::BurnIn { .. } | Gate::ManualConfirm) | |
| 130 | + | } | |
| 121 | 131 | } | |
| 122 | 132 | ||
| 123 | 133 | impl Topology { | |
| @@ -129,13 +139,102 @@ impl Topology { | |||
| 129 | 139 | Ok(topo) | |
| 130 | 140 | } | |
| 131 | 141 | ||
| 142 | + | #[cfg(test)] | |
| 143 | + | pub(crate) fn validate_for_test(&self) -> Result<()> { | |
| 144 | + | self.validate() | |
| 145 | + | } | |
| 146 | + | ||
| 132 | 147 | fn validate(&self) -> Result<()> { | |
| 133 | 148 | anyhow::ensure!(!self.tiers.is_empty(), "topology must declare at least one tier"); | |
| 134 | 149 | for t in &self.tiers { | |
| 135 | - | if t.provisioned && t.nodes.is_empty() && t.name.as_str() != "host" { | |
| 150 | + | // The `host` tier is the build tier (cargo_test / migration_dry_run / | |
| 151 | + | // boot_smoke run once on the host); every other tier serves an | |
| 152 | + | // artifact to nodes, so it is exempted from the node and | |
| 153 | + | // promotion-gate checks the same way. | |
| 154 | + | let is_build_tier = t.name.as_str() == "host"; | |
| 155 | + | if t.provisioned && t.nodes.is_empty() && !is_build_tier { | |
| 136 | 156 | anyhow::bail!("tier {} is provisioned but has no nodes", t.name); | |
| 137 | 157 | } | |
| 158 | + | // Fail closed by default: a provisioned serving tier must declare at | |
| 159 | + | // least one gate that actually guards a promote. Without this, an | |
| 160 | + | // empty (or build-time-only) gate list waves every promote through | |
| 161 | + | // because `unsatisfied_gates` finds nothing to check (CF1 root cause). | |
| 162 | + | if t.provisioned && !is_build_tier && !t.gates.iter().any(Gate::guards_promotion) { | |
| 163 | + | anyhow::bail!( | |
| 164 | + | "tier {} is provisioned to serve but declares no promotion gate \ | |
| 165 | + | (need at least one of boot_smoke / burn_in / manual_confirm)", | |
| 166 | + | t.name | |
| 167 | + | ); | |
| 168 | + | } | |
| 138 | 169 | } | |
| 139 | 170 | Ok(()) | |
| 140 | 171 | } | |
| 141 | 172 | } | |
| 173 | + | ||
| 174 | + | #[cfg(test)] | |
| 175 | + | mod tests { | |
| 176 | + | use super::*; | |
| 177 | + | ||
| 178 | + | /// A minimal topology with one serving tier whose gate block is `gates`. | |
| 179 | + | fn topo_with_serving_gates(provisioned: bool, gates: &str) -> Topology { | |
| 180 | + | let raw = format!( | |
| 181 | + | r#" | |
| 182 | + | [repo] | |
| 183 | + | bare_path = "/tmp/repo.git" | |
| 184 | + | branch = "main" | |
| 185 | + | ||
| 186 | + | [backup] | |
| 187 | + | source = "ssh://prod/dump.sql.gz" | |
| 188 | + | local_path = "/tmp/dump.sql.gz" | |
| 189 | + | ||
| 190 | + | [[tier]] | |
| 191 | + | name = "b" | |
| 192 | + | provisioned = {provisioned} | |
| 193 | + | gates = [{gates}] | |
| 194 | + | [[tier.node]] | |
| 195 | + | name = "prod-1" | |
| 196 | + | ssh_target = "prod-1" | |
| 197 | + | release_root = "/srv/mnw" | |
| 198 | + | "# | |
| 199 | + | ); | |
| 200 | + | toml::from_str(&raw).expect("parse test topology") | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | #[test] | |
| 204 | + | fn provisioned_serving_tier_with_no_gates_is_rejected() { | |
| 205 | + | let topo = topo_with_serving_gates(true, ""); | |
| 206 | + | let err = topo.validate_for_test().unwrap_err().to_string(); | |
| 207 | + | assert!(err.contains("no promotion gate"), "{err}"); | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | #[test] | |
| 211 | + | fn provisioned_serving_tier_with_only_build_gates_is_rejected() { | |
| 212 | + | // cargo_test / migration_dry_run are build-time and prove nothing about a | |
| 213 | + | // promote, so a serving tier carrying only them still fails closed. | |
| 214 | + | let topo = topo_with_serving_gates(true, r#"{ kind = "cargo_test" }, { kind = "migration_dry_run" }"#); | |
| 215 | + | let err = topo.validate_for_test().unwrap_err().to_string(); | |
| 216 | + | assert!(err.contains("no promotion gate"), "{err}"); | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | #[test] | |
| 220 | + | fn provisioned_serving_tier_with_a_promotion_gate_is_accepted() { | |
| 221 | + | let topo = topo_with_serving_gates(true, r#"{ kind = "boot_smoke" }"#); | |
| 222 | + | assert!(topo.validate_for_test().is_ok()); | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | #[test] | |
| 226 | + | fn unprovisioned_tier_with_empty_gates_is_skipped() { | |
| 227 | + | // A declared-but-not-yet-provisioned tier (e.g. tier c) carries no | |
| 228 | + | // promote authority, so the gate requirement does not apply yet. | |
| 229 | + | let topo = topo_with_serving_gates(false, ""); | |
| 230 | + | assert!(topo.validate_for_test().is_ok()); | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | #[test] | |
| 234 | + | fn real_sando_toml_loads_clean() { | |
| 235 | + | // The shipped topology must satisfy the invariant — guards against a | |
| 236 | + | // regression that would lock sandod out of its own config. | |
| 237 | + | let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../sando.toml"); | |
| 238 | + | Topology::load(&path).expect("shipped sando.toml must validate"); | |
| 239 | + | } | |
| 240 | + | } |