Skip to main content

max / makenotwork

sando: fail closed on a serving tier with no promotion gate (ultra-fuzz structural CF1) Topology::validate now rejects a provisioned, non-host tier whose gate list contains no promotion gate (boot_smoke / burn_in / manual_confirm). Closes CF1's root cause structurally: an empty or build-time-only gate list waved every promote through because unsatisfied_gates found nothing to check. sandod now refuses to load such a config at startup, so the absence-of-evidence-is-green topology is unconstructible. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-19 01:03 UTC
Commit: c1061378c131202a8258f1bcda2b75aacd19411d
Parent: f42d2d0
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 + }