Skip to main content

ignition_core/deploy/apply/
gates.rs

1//! Pure gate evaluation and refusal prose (DAPL-01, DAPL-02).
2//!
3//! [`evaluate_gates`] decides, from a plan and the caller's flags alone,
4//! whether an apply may proceed. It reads no host, no gateway and no clock.
5
6use crate::actions::deploy::DeployPlanResult;
7use crate::deploy::plan::{CoreRelation, PlanEntry, PlanStatus};
8use crate::error::{APPLY_LIST_SHOWN, CoreError};
9
10/// The longest accepted `--allow-drift` reason, in characters.
11pub const DRIFT_REASON_MAX_CHARS: usize = 500;
12
13/// The caller's gate flags.
14#[derive(Debug, Clone, Default, PartialEq, Eq)]
15pub struct GateFlags {
16    /// `--plan-sha256`: the hash the reviewer saw.
17    pub plan_sha256: Option<String>,
18    /// `--allow-destructive`.
19    pub allow_destructive: bool,
20    /// `--allow-drift <reason>`.
21    pub allow_drift: Option<String>,
22}
23
24/// What the gates decided.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum GateOutcome {
27    /// The plan is empty: nothing to write.
28    Noop,
29    /// Every gate passed; `prose` is the plan as the confirmation text.
30    Ready {
31        /// The refusal and confirmation text, byte-stable for a plan.
32        prose: String,
33    },
34}
35
36/// Evaluate the gates in the confirmed order.
37///
38/// 1. A malformed flag is `invalid_input` (exit 2), whatever the plan says.
39/// 2. A `--plan-sha256` that differs from the recomputed `plan_sha256` is
40///    `plan_stale` (exit 6), so a flag the reviewer gave for an old plan can
41///    never satisfy a gate of a new one.
42/// 3. A plan with nothing to add, change or remove is [`GateOutcome::Noop`].
43/// 4. Any entry whose `core` copy shadows its removal is `deploy_blocked`
44///    (exit 6), whatever the flags.
45/// 5. Destructive entries without `--allow-destructive`, then drifted
46///    entries without `--allow-drift`, are `destructive_change_refused`
47///    (exit 2).
48/// 6. Otherwise [`GateOutcome::Ready`].
49///
50/// `unmanaged` entries never gate, and drift is judged only where
51/// `drift == Some(true)`, which exists only with `--base`.
52pub fn evaluate_gates(
53    plan: &DeployPlanResult,
54    flags: &GateFlags,
55) -> Result<GateOutcome, CoreError> {
56    if let Some(hash) = &flags.plan_sha256 {
57        validate_plan_sha256(hash)?;
58    }
59    if let Some(reason) = &flags.allow_drift {
60        validate_drift_reason(reason)?;
61    }
62    if let Some(expected) = &flags.plan_sha256
63        && *expected != plan.plan_sha256
64    {
65        return Err(CoreError::PlanStale {
66            expected: expected.clone(),
67            actual: plan.plan_sha256.clone(),
68            detail: "review hash".to_string(),
69        });
70    }
71    if !plan.summary.has_changes {
72        return Ok(GateOutcome::Noop);
73    }
74
75    let gated = || {
76        plan.entries
77            .iter()
78            .filter(|e| e.status != PlanStatus::Unmanaged)
79    };
80    let blocked = sorted_keys(gated().filter(|e| e.core == CoreRelation::ShadowsRemoval));
81    if !blocked.is_empty() {
82        return Err(CoreError::DeployBlocked { keys: blocked });
83    }
84    if !flags.allow_destructive {
85        let keys = sorted_keys(gated().filter(|e| e.destructive));
86        if !keys.is_empty() {
87            return Err(refusal("destructive", keys));
88        }
89    }
90    if flags.allow_drift.is_none() {
91        let keys = sorted_keys(gated().filter(|e| e.drift == Some(true)));
92        if !keys.is_empty() {
93            return Err(refusal("drift", keys));
94        }
95    }
96    Ok(GateOutcome::Ready {
97        prose: confirmation_prose(plan),
98    })
99}
100
101/// The entry keys, byte-sorted.
102fn sorted_keys<'a>(entries: impl Iterator<Item = &'a PlanEntry>) -> Vec<String> {
103    let mut keys: Vec<String> = entries.map(|e| e.key.clone()).collect();
104    keys.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
105    keys
106}
107
108/// A `destructive_change_refused` carrying the first
109/// [`APPLY_LIST_SHOWN`] keys and the full count.
110fn refusal(gate: &str, mut keys: Vec<String>) -> CoreError {
111    let count = keys.len();
112    keys.truncate(APPLY_LIST_SHOWN);
113    CoreError::DestructiveChangeRefused {
114        gate: gate.to_string(),
115        keys,
116        count,
117    }
118}
119
120/// The plan as the refusal text: the target, the counts, one line per
121/// changing entry (key, status, flags), the delete-set size and the hash.
122/// Never file content and never a diff; byte-stable for a plan.
123pub fn confirmation_prose(plan: &DeployPlanResult) -> String {
124    use std::fmt::Write as _;
125
126    let s = &plan.summary;
127    let mut out = String::new();
128    let _ = writeln!(
129        out,
130        "deploy apply: env {}, gateway {}, mode {}",
131        plan.env, plan.gateway, plan.mode
132    );
133    let _ = writeln!(
134        out,
135        "plan: {} added, {} changed, {} removed, {} same, {} unmanaged",
136        s.added, s.changed, s.removed, s.same, s.unmanaged
137    );
138    for entry in &plan.entries {
139        if matches!(entry.status, PlanStatus::Same | PlanStatus::Unmanaged) {
140            continue;
141        }
142        let mut flags: Vec<&str> = Vec::new();
143        if entry.destructive {
144            flags.push("destructive");
145        }
146        if entry.lockout_risk {
147            flags.push("lockout_risk");
148        }
149        if entry.drift == Some(true) {
150            flags.push("drift");
151        }
152        if !matches!(entry.core, CoreRelation::None | CoreRelation::Identical) {
153            flags.push(entry.core.word());
154        }
155        if flags.is_empty() {
156            let _ = writeln!(out, "  {} {}", entry.status.word(), entry.key);
157        } else {
158            let _ = writeln!(
159                out,
160                "  {} {} [{}]",
161                entry.status.word(),
162                entry.key,
163                flags.join(", ")
164            );
165        }
166    }
167    let _ = writeln!(out, "delete set: {} file(s)", plan.delete_set.files.len());
168    let _ = writeln!(out, "plan_sha256: {}", plan.plan_sha256);
169    out.push_str(
170        "rerun with --yes to apply this plan; when not interactive (--json, or stdin or \
171         stdout not a terminal) also pass --plan-sha256 with the hash above\n",
172    );
173    out
174}
175
176/// `--allow-drift` needs a non-empty reason of at most
177/// [`DRIFT_REASON_MAX_CHARS`] characters and no control characters. The
178/// reason is recorded in run records and logs, so nothing else may ride it.
179pub fn validate_drift_reason(reason: &str) -> Result<(), CoreError> {
180    let problem = if reason.is_empty() {
181        Some("must not be empty".to_string())
182    } else if reason.chars().count() > DRIFT_REASON_MAX_CHARS {
183        Some(format!(
184            "must be at most {DRIFT_REASON_MAX_CHARS} characters"
185        ))
186    } else if reason.chars().any(char::is_control) {
187        Some("must not contain control characters".to_string())
188    } else {
189        None
190    };
191    match problem {
192        None => Ok(()),
193        Some(problem) => Err(CoreError::InvalidInput {
194            reason: format!("--allow-drift reason {problem}"),
195        }),
196    }
197}
198
199/// `--plan-sha256` must be 64 lowercase hex characters.
200pub fn validate_plan_sha256(value: &str) -> Result<(), CoreError> {
201    let ok = value.len() == 64
202        && value
203            .bytes()
204            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
205    if ok {
206        Ok(())
207    } else {
208        Err(CoreError::InvalidInput {
209            reason: "--plan-sha256 must be 64 lowercase hex characters (the plan_sha256 \
210                     of the reviewed plan)"
211                .to_string(),
212        })
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::{
219        DRIFT_REASON_MAX_CHARS, GateFlags, GateOutcome, confirmation_prose, evaluate_gates,
220        validate_drift_reason, validate_plan_sha256,
221    };
222    use crate::actions::deploy::DeployPlanResult;
223    use crate::deploy::plan::{CoreRelation, PlanEntry, PlanStatus, PlanSummary};
224    use crate::deploy::transport::DeleteSet;
225
226    const HASH: &str = "ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12";
227
228    fn entry(key: &str, status: PlanStatus) -> PlanEntry {
229        PlanEntry {
230            key: key.to_string(),
231            module: "ignition".to_string(),
232            ty: "database-translator".to_string(),
233            name: None,
234            status,
235            live_in: Vec::new(),
236            core: CoreRelation::None,
237            destructive: false,
238            destructive_reason: None,
239            lockout_risk: false,
240            drift: None,
241            files: Vec::new(),
242            diff: None,
243        }
244    }
245
246    fn plan_of(entries: Vec<PlanEntry>) -> DeployPlanResult {
247        let mut summary = PlanSummary::default();
248        for e in &entries {
249            match e.status {
250                PlanStatus::Added => summary.added += 1,
251                PlanStatus::Changed => summary.changed += 1,
252                PlanStatus::Removed => summary.removed += 1,
253                PlanStatus::Same => summary.same += 1,
254                PlanStatus::Unmanaged => summary.unmanaged += 1,
255            }
256        }
257        summary.has_changes = summary.added + summary.changed + summary.removed > 0;
258        DeployPlanResult {
259            env: "prod".to_string(),
260            gateway: "gw".to_string(),
261            mode: "production".to_string(),
262            node: "master".to_string(),
263            transport: "local".to_string(),
264            render_sha256: "0".repeat(64),
265            plan_sha256: HASH.to_string(),
266            summary,
267            entries,
268            delete_set: DeleteSet::default(),
269            base: None,
270            warnings: Vec::new(),
271        }
272    }
273
274    fn destructive(key: &str) -> PlanEntry {
275        let mut e = entry(key, PlanStatus::Removed);
276        e.destructive = true;
277        e.destructive_reason = Some("removes a resource".to_string());
278        e
279    }
280
281    fn drifted(key: &str) -> PlanEntry {
282        let mut e = entry(key, PlanStatus::Changed);
283        e.drift = Some(true);
284        e
285    }
286
287    fn flags(destructive: bool, drift: Option<&str>) -> GateFlags {
288        GateFlags {
289            plan_sha256: None,
290            allow_destructive: destructive,
291            allow_drift: drift.map(str::to_string),
292        }
293    }
294
295    fn refusal(plan: &DeployPlanResult, flags: &GateFlags) -> (String, u8, String) {
296        let err = evaluate_gates(plan, flags).expect_err("the gates refuse");
297        (err.code().to_string(), err.exit_code(), err.to_string())
298    }
299
300    #[test]
301    fn plan_sha256_accepts_64_lowercase_hex() {
302        assert!(validate_plan_sha256(&"a1".repeat(32)).is_ok());
303        assert!(validate_plan_sha256(&"0".repeat(64)).is_ok());
304    }
305
306    #[test]
307    fn plan_sha256_refuses_short_uppercase_and_non_hex() {
308        for bad in [
309            "a".repeat(63),
310            "A1".repeat(32),
311            "g".repeat(64),
312            String::new(),
313            format!("{} ", "a".repeat(63)),
314        ] {
315            let err = validate_plan_sha256(&bad).expect_err(&bad);
316            assert_eq!(err.code(), "invalid_input", "{bad:?}");
317            assert_eq!(err.exit_code(), 2);
318        }
319    }
320
321    #[test]
322    fn order_stale_beats_noop() {
323        let plan = plan_of(vec![entry("ignition/a/x", PlanStatus::Same)]);
324        let mut f = flags(false, None);
325        f.plan_sha256 = Some("1".repeat(64));
326        let (code, exit, _) = refusal(&plan, &f);
327        assert_eq!((code.as_str(), exit), ("plan_stale", 6));
328    }
329
330    #[test]
331    fn order_stale_beats_the_destructive_gate() {
332        let plan = plan_of(vec![destructive("ignition/a/x")]);
333        let mut f = flags(false, None);
334        f.plan_sha256 = Some("1".repeat(64));
335        assert_eq!(refusal(&plan, &f).0, "plan_stale");
336    }
337
338    #[test]
339    fn order_empty_plan_is_noop() {
340        let plan = plan_of(vec![
341            entry("ignition/a/x", PlanStatus::Same),
342            entry("ignition/a/y", PlanStatus::Unmanaged),
343        ]);
344        assert_eq!(
345            evaluate_gates(&plan, &flags(false, None)).expect("noop"),
346            GateOutcome::Noop
347        );
348        let mut matching = flags(false, None);
349        matching.plan_sha256 = Some(HASH.to_string());
350        assert_eq!(
351            evaluate_gates(&plan, &matching).expect("noop with its own hash"),
352            GateOutcome::Noop
353        );
354    }
355
356    #[test]
357    fn order_shadows_removal_blocks_even_with_both_flags() {
358        let mut shadowed = destructive("ignition/a/x");
359        shadowed.core = CoreRelation::ShadowsRemoval;
360        let plan = plan_of(vec![shadowed, entry("ignition/a/y", PlanStatus::Added)]);
361        let (code, exit, shown) = refusal(&plan, &flags(true, Some("reviewed drift")));
362        assert_eq!((code.as_str(), exit), ("deploy_blocked", 6));
363        assert!(shown.contains("ignition/a/x"), "{shown}");
364        assert!(!shown.contains("ignition/a/y"), "{shown}");
365    }
366
367    #[test]
368    fn order_shadows_removal_beats_the_destructive_gate() {
369        let mut shadowed = destructive("ignition/a/x");
370        shadowed.core = CoreRelation::ShadowsRemoval;
371        let plan = plan_of(vec![shadowed]);
372        assert_eq!(refusal(&plan, &flags(false, None)).0, "deploy_blocked");
373    }
374
375    #[test]
376    fn order_destructive_without_the_flag_is_refused() {
377        let plan = plan_of(vec![destructive("ignition/a/x")]);
378        let (code, exit, shown) = refusal(&plan, &flags(false, None));
379        assert_eq!((code.as_str(), exit), ("destructive_change_refused", 2));
380        assert!(shown.contains("destructive"), "{shown}");
381        assert!(shown.contains("ignition/a/x"), "{shown}");
382    }
383
384    #[test]
385    fn order_drift_without_the_reason_is_refused() {
386        let plan = plan_of(vec![drifted("ignition/a/x")]);
387        let (code, exit, shown) = refusal(&plan, &flags(true, None));
388        assert_eq!((code.as_str(), exit), ("destructive_change_refused", 2));
389        assert!(shown.contains("drift"), "{shown}");
390        assert!(shown.contains("ignition/a/x"), "{shown}");
391    }
392
393    #[test]
394    fn order_destructive_is_judged_before_drift() {
395        let plan = plan_of(vec![drifted("ignition/a/d"), destructive("ignition/a/x")]);
396        let (_, _, shown) = refusal(&plan, &flags(false, None));
397        assert!(shown.contains("ignition/a/x"), "{shown}");
398        assert!(!shown.contains("ignition/a/d"), "{shown}");
399        let (_, _, shown) = refusal(&plan, &flags(true, None));
400        assert!(shown.contains("ignition/a/d"), "{shown}");
401    }
402
403    #[test]
404    fn order_both_flags_make_it_ready() {
405        let plan = plan_of(vec![destructive("ignition/a/x"), drifted("ignition/a/y")]);
406        let outcome = evaluate_gates(&plan, &flags(true, Some("reviewed on the gateway")))
407            .expect("both flags");
408        assert!(matches!(outcome, GateOutcome::Ready { .. }), "{outcome:?}");
409    }
410
411    #[test]
412    fn unmanaged_entries_never_gate() {
413        let mut odd = entry("ignition/a/u", PlanStatus::Unmanaged);
414        odd.destructive = true;
415        odd.drift = Some(true);
416        odd.core = CoreRelation::ShadowsRemoval;
417        let plan = plan_of(vec![odd, entry("ignition/a/x", PlanStatus::Added)]);
418        let outcome = evaluate_gates(&plan, &flags(false, None)).expect("ready");
419        assert!(matches!(outcome, GateOutcome::Ready { .. }), "{outcome:?}");
420    }
421
422    #[test]
423    fn drift_none_and_false_never_gate() {
424        let mut no_drift = entry("ignition/a/x", PlanStatus::Changed);
425        no_drift.drift = Some(false);
426        let plan = plan_of(vec![no_drift, entry("ignition/a/y", PlanStatus::Added)]);
427        let outcome = evaluate_gates(&plan, &flags(false, None)).expect("ready");
428        assert!(matches!(outcome, GateOutcome::Ready { .. }), "{outcome:?}");
429    }
430
431    #[test]
432    fn refusal_keys_are_byte_sorted_and_capped_at_ten() {
433        let mut entries: Vec<PlanEntry> = (0..12)
434            .rev()
435            .map(|i| destructive(&format!("ignition/a/k{i:02}")))
436            .collect();
437        entries.push(entry("ignition/a/zz", PlanStatus::Added));
438        let plan = plan_of(entries);
439        let (_, _, shown) = refusal(&plan, &flags(false, None));
440        assert!(shown.contains("12"), "{shown}");
441        assert!(shown.contains("and 2 more"), "{shown}");
442        let first = shown.find("ignition/a/k00").expect("k00 shown");
443        let tenth = shown.find("ignition/a/k09").expect("k09 shown");
444        assert!(first < tenth, "{shown}");
445        assert!(!shown.contains("ignition/a/k10"), "{shown}");
446        assert!(!shown.contains("ignition/a/zz"), "{shown}");
447    }
448
449    #[test]
450    fn a_malformed_review_hash_is_invalid_input_not_stale() {
451        let plan = plan_of(vec![entry("ignition/a/x", PlanStatus::Added)]);
452        let mut f = flags(false, None);
453        f.plan_sha256 = Some("NOT-A-HASH".to_string());
454        let (code, exit, _) = refusal(&plan, &f);
455        assert_eq!((code.as_str(), exit), ("invalid_input", 2));
456    }
457
458    #[test]
459    fn drift_reason_is_validated_on_every_evaluation() {
460        let plan = plan_of(vec![entry("ignition/a/x", PlanStatus::Added)]);
461        for bad in ["", "two\nlines", "nul\0byte"] {
462            let (code, exit, _) = refusal(&plan, &flags(false, Some(bad)));
463            assert_eq!((code.as_str(), exit), ("invalid_input", 2), "{bad:?}");
464        }
465    }
466
467    #[test]
468    fn drift_reason_rules() {
469        assert!(validate_drift_reason(&"r".repeat(200)).is_ok());
470        assert!(validate_drift_reason(&"r".repeat(DRIFT_REASON_MAX_CHARS)).is_ok());
471        for bad in [
472            String::new(),
473            "r".repeat(DRIFT_REASON_MAX_CHARS + 1),
474            "a\nb".to_string(),
475            "a\rb".to_string(),
476            "a\0b".to_string(),
477            "a\u{1b}[31mb".to_string(),
478        ] {
479            let err = validate_drift_reason(&bad).expect_err(&bad);
480            assert_eq!(err.code(), "invalid_input", "{bad:?}");
481            assert_eq!(err.exit_code(), 2);
482        }
483    }
484
485    #[test]
486    fn drift_reason_length_counts_characters_not_bytes() {
487        assert!(validate_drift_reason(&"\u{e9}".repeat(DRIFT_REASON_MAX_CHARS)).is_ok());
488    }
489
490    #[test]
491    fn prose_is_stable_and_carries_no_content() {
492        let mut d = destructive("ignition/a/x");
493        d.diff = Some("SECRET-DIFF-BYTES".to_string());
494        let plan = plan_of(vec![
495            entry("ignition/a/y", PlanStatus::Added),
496            d,
497            entry("ignition/a/s", PlanStatus::Same),
498        ]);
499        let one = confirmation_prose(&plan);
500        assert_eq!(one, confirmation_prose(&plan));
501        assert!(one.contains(HASH), "{one}");
502        assert!(one.contains("prod") && one.contains("gw") && one.contains("production"));
503        assert!(
504            one.contains("ignition/a/y") && one.contains("ignition/a/x"),
505            "{one}"
506        );
507        assert!(
508            !one.contains("ignition/a/s"),
509            "same entries are not listed: {one}"
510        );
511        assert!(!one.contains("SECRET-DIFF-BYTES"), "{one}");
512        assert!(one.contains("--yes"), "{one}");
513    }
514}