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, the caller's flags and whether
4//! the target is a redundant pair ([`Redundancy`]) alone, whether an apply may
5//! proceed. It reads no host, no gateway and no clock.
6
7use crate::actions::deploy::DeployPlanResult;
8use crate::deploy::plan::{CoreRelation, PlanEntry, PlanStatus, pair_tag_keys};
9use crate::deploy::target::Target;
10use crate::error::{APPLY_LIST_SHOWN, CoreError};
11
12/// The longest accepted `--allow-drift` reason, in characters.
13pub const DRIFT_REASON_MAX_CHARS: usize = 500;
14
15/// The caller's gate flags.
16#[derive(Debug, Clone, Default, PartialEq, Eq)]
17pub struct GateFlags {
18    /// `--plan-sha256`: the hash the reviewer saw.
19    pub plan_sha256: Option<String>,
20    /// `--allow-destructive`.
21    pub allow_destructive: bool,
22    /// `--allow-drift <reason>`.
23    pub allow_drift: Option<String>,
24}
25
26/// What the gates decided.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum GateOutcome {
29    /// The plan is empty: nothing to write.
30    Noop,
31    /// Every gate passed; `prose` is the plan as the confirmation text.
32    Ready {
33        /// The refusal and confirmation text, byte-stable for a plan.
34        prose: String,
35    },
36}
37
38/// Whether the target is a redundant pair: it declares `nodes.backup`.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum Redundancy {
41    /// No `nodes.backup`.
42    Standalone,
43    /// `nodes.backup` is declared.
44    Pair,
45}
46
47impl Redundancy {
48    /// The redundancy `target` declares.
49    pub fn of(target: &Target) -> Self {
50        if target.nodes.backup.is_some() {
51            Redundancy::Pair
52        } else {
53            Redundancy::Standalone
54        }
55    }
56}
57
58/// The `deploy_pair_tags_refused` refusal for `plan` on a target of
59/// `redundancy` (debug deploy-tag-redundancy-sync), or `None`: on a pair,
60/// every [`pair_tag_keys`] entry. The one definition both [`evaluate_gates`]
61/// and the engine's own entry check ([`run_apply`](super::run_apply), the
62/// WR-04 pattern) use, so the two cannot drift. A standalone gateway loads
63/// tag units from its mode collection normally, so it is never refused.
64pub fn pair_tags_refusal(plan: &DeployPlanResult, redundancy: Redundancy) -> Option<CoreError> {
65    if redundancy == Redundancy::Standalone {
66        return None;
67    }
68    let keys = pair_tag_keys(&plan.entries, &plan.delete_set);
69    if keys.is_empty() {
70        return None;
71    }
72    Some(CoreError::DeployPairTagsRefused {
73        mode: plan.mode.clone(),
74        keys,
75    })
76}
77
78/// The `deploy_unreadable` refusal for `plan` (quick-261029-ms3), or `None`:
79/// every unreadable unit the plan's master read found that is declared or in
80/// the declared-names ledger, byte-sorted and unique. The one definition both
81/// [`evaluate_gates`] and the engine's own entry check
82/// ([`run_apply`](super::run_apply), WR-04) use, so the two cannot drift.
83pub fn unreadable_refusal(plan: &DeployPlanResult) -> Option<CoreError> {
84    let mut keys: Vec<String> = plan
85        .unreadable
86        .iter()
87        .filter(|u| u.declared || u.in_ledger)
88        .map(|u| u.key.clone())
89        .collect();
90    if keys.is_empty() {
91        return None;
92    }
93    keys.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
94    keys.dedup();
95    Some(CoreError::DeployUnreadable {
96        node: "master".to_string(),
97        keys,
98    })
99}
100
101/// Evaluate the gates in the confirmed order.
102///
103/// 1. A malformed flag is `invalid_input` (exit 2), whatever the plan says.
104/// 2. A `--plan-sha256` that differs from the recomputed `plan_sha256` is
105///    `plan_stale` (exit 6), so a flag the reviewer gave for an old plan can
106///    never satisfy a gate of a new one.
107/// 3. An unreadable unit (quick-261029-ms3) that is declared or in the
108///    declared-names ledger is `deploy_unreadable` (exit 6), whatever the
109///    flags. This runs before the no-op return, so a plan whose `has_changes`
110///    is false can never carry such a unit through to an apply.
111/// 4. On a redundant pair, a `tag-definition` or `tag-type-definition` unit
112///    the apply would write into or keep in the mode collection
113///    ([`pair_tags_refusal`]) is `deploy_pair_tags_refused` (exit 6), whatever
114///    the flags, also before the no-op return: a `same` unit kept there
115///    already stops the backup from syncing. A `removed` one passes when the
116///    delete set clears its mode copy.
117/// 5. A plan with nothing to add, change or remove is [`GateOutcome::Noop`].
118/// 6. Any entry whose `core` copy shadows its removal is `deploy_blocked`
119///    (exit 6), whatever the flags.
120/// 7. Destructive entries without `--allow-destructive`, then drifted
121///    entries without `--allow-drift`, are `destructive_change_refused`
122///    (exit 2).
123/// 8. Otherwise [`GateOutcome::Ready`].
124///
125/// `unmanaged` entries never gate, and drift is judged only where
126/// `drift == Some(true)`, which exists only with `--base`. An unreadable
127/// unit that is neither declared nor in the ledger never gates either; its
128/// plan warning reaches the apply result.
129pub fn evaluate_gates(
130    plan: &DeployPlanResult,
131    flags: &GateFlags,
132    redundancy: Redundancy,
133) -> Result<GateOutcome, CoreError> {
134    if let Some(hash) = &flags.plan_sha256 {
135        validate_plan_sha256(hash)?;
136    }
137    if let Some(reason) = &flags.allow_drift {
138        validate_drift_reason(reason)?;
139    }
140    if let Some(expected) = &flags.plan_sha256
141        && *expected != plan.plan_sha256
142    {
143        return Err(CoreError::PlanStale {
144            expected: expected.clone(),
145            actual: plan.plan_sha256.clone(),
146            detail: "review hash".to_string(),
147        });
148    }
149    if let Some(refusal) = unreadable_refusal(plan) {
150        return Err(refusal);
151    }
152    if let Some(refusal) = pair_tags_refusal(plan, redundancy) {
153        return Err(refusal);
154    }
155    if !plan.summary.has_changes {
156        return Ok(GateOutcome::Noop);
157    }
158
159    let gated = || {
160        plan.entries
161            .iter()
162            .filter(|e| e.status != PlanStatus::Unmanaged)
163    };
164    let blocked = sorted_keys(gated().filter(|e| e.core == CoreRelation::ShadowsRemoval));
165    if !blocked.is_empty() {
166        return Err(CoreError::DeployBlocked { keys: blocked });
167    }
168    if !flags.allow_destructive {
169        let keys = sorted_keys(gated().filter(|e| e.destructive));
170        if !keys.is_empty() {
171            return Err(refusal("destructive", keys));
172        }
173    }
174    if flags.allow_drift.is_none() {
175        let keys = sorted_keys(gated().filter(|e| e.drift == Some(true)));
176        if !keys.is_empty() {
177            return Err(refusal("drift", keys));
178        }
179    }
180    Ok(GateOutcome::Ready {
181        prose: confirmation_prose(plan),
182    })
183}
184
185/// The entry keys, byte-sorted.
186fn sorted_keys<'a>(entries: impl Iterator<Item = &'a PlanEntry>) -> Vec<String> {
187    let mut keys: Vec<String> = entries.map(|e| e.key.clone()).collect();
188    keys.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
189    keys
190}
191
192/// A `destructive_change_refused` carrying the first
193/// [`APPLY_LIST_SHOWN`] keys and the full count.
194fn refusal(gate: &str, mut keys: Vec<String>) -> CoreError {
195    let count = keys.len();
196    keys.truncate(APPLY_LIST_SHOWN);
197    CoreError::DestructiveChangeRefused {
198        gate: gate.to_string(),
199        keys,
200        count,
201    }
202}
203
204/// The plan as the refusal text: the target, the counts, one line per
205/// changing entry (key, status, flags), the delete-set size and the hash.
206/// Never file content and never a diff; byte-stable for a plan.
207pub fn confirmation_prose(plan: &DeployPlanResult) -> String {
208    use std::fmt::Write as _;
209
210    let s = &plan.summary;
211    let mut out = String::new();
212    let _ = writeln!(
213        out,
214        "deploy apply: env {}, gateway {}, mode {}",
215        plan.env, plan.gateway, plan.mode
216    );
217    let _ = writeln!(
218        out,
219        "plan: {} added, {} changed, {} removed, {} same, {} unmanaged",
220        s.added, s.changed, s.removed, s.same, s.unmanaged
221    );
222    for entry in &plan.entries {
223        if matches!(entry.status, PlanStatus::Same | PlanStatus::Unmanaged) {
224            continue;
225        }
226        let mut flags: Vec<&str> = Vec::new();
227        if entry.destructive {
228            flags.push("destructive");
229        }
230        if entry.lockout_risk {
231            flags.push("lockout_risk");
232        }
233        if entry.drift == Some(true) {
234            flags.push("drift");
235        }
236        if !matches!(entry.core, CoreRelation::None | CoreRelation::Identical) {
237            flags.push(entry.core.word());
238        }
239        if flags.is_empty() {
240            let _ = writeln!(out, "  {} {}", entry.status.word(), entry.key);
241        } else {
242            let _ = writeln!(
243                out,
244                "  {} {} [{}]",
245                entry.status.word(),
246                entry.key,
247                flags.join(", ")
248            );
249        }
250    }
251    let _ = writeln!(out, "delete set: {} file(s)", plan.delete_set.files.len());
252    let _ = writeln!(out, "plan_sha256: {}", plan.plan_sha256);
253    out.push_str(
254        "rerun with --yes to apply this plan; when not interactive (--json, or stdin or \
255         stdout not a terminal) also pass --plan-sha256 with the hash above\n",
256    );
257    out
258}
259
260/// `--allow-drift` needs a non-empty reason of at most
261/// [`DRIFT_REASON_MAX_CHARS`] characters and no control characters. The
262/// reason is recorded in run records and logs, so nothing else may ride it.
263pub fn validate_drift_reason(reason: &str) -> Result<(), CoreError> {
264    let problem = if reason.is_empty() {
265        Some("must not be empty".to_string())
266    } else if reason.chars().count() > DRIFT_REASON_MAX_CHARS {
267        Some(format!(
268            "must be at most {DRIFT_REASON_MAX_CHARS} characters"
269        ))
270    } else if reason.chars().any(char::is_control) {
271        Some("must not contain control characters".to_string())
272    } else {
273        None
274    };
275    match problem {
276        None => Ok(()),
277        Some(problem) => Err(CoreError::InvalidInput {
278            reason: format!("--allow-drift reason {problem}"),
279        }),
280    }
281}
282
283/// `--plan-sha256` must be 64 lowercase hex characters.
284pub fn validate_plan_sha256(value: &str) -> Result<(), CoreError> {
285    let ok = value.len() == 64
286        && value
287            .bytes()
288            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
289    if ok {
290        Ok(())
291    } else {
292        Err(CoreError::InvalidInput {
293            reason: "--plan-sha256 must be 64 lowercase hex characters (the plan_sha256 \
294                     of the reviewed plan)"
295                .to_string(),
296        })
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::{
303        DRIFT_REASON_MAX_CHARS, GateFlags, GateOutcome, Redundancy, confirmation_prose,
304        evaluate_gates, pair_tags_refusal, validate_drift_reason, validate_plan_sha256,
305    };
306    use crate::actions::deploy::DeployPlanResult;
307    use crate::deploy::layout::RelPath;
308    use crate::deploy::plan::{
309        CoreRelation, PlanEntry, PlanFile, PlanStatus, PlanSummary, UnreadableUnit,
310    };
311    use crate::deploy::transport::DeleteSet;
312
313    const HASH: &str = "ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12";
314
315    fn entry(key: &str, status: PlanStatus) -> PlanEntry {
316        PlanEntry {
317            key: key.to_string(),
318            module: "ignition".to_string(),
319            ty: "database-translator".to_string(),
320            name: None,
321            status,
322            live_in: Vec::new(),
323            core: CoreRelation::None,
324            destructive: false,
325            destructive_reason: None,
326            lockout_risk: false,
327            drift: None,
328            files: Vec::new(),
329            diff: None,
330        }
331    }
332
333    fn plan_of(entries: Vec<PlanEntry>) -> DeployPlanResult {
334        let mut summary = PlanSummary::default();
335        for e in &entries {
336            match e.status {
337                PlanStatus::Added => summary.added += 1,
338                PlanStatus::Changed => summary.changed += 1,
339                PlanStatus::Removed => summary.removed += 1,
340                PlanStatus::Same => summary.same += 1,
341                PlanStatus::Unmanaged => summary.unmanaged += 1,
342            }
343        }
344        summary.has_changes = summary.added + summary.changed + summary.removed > 0;
345        DeployPlanResult {
346            env: "prod".to_string(),
347            gateway: "gw".to_string(),
348            mode: "production".to_string(),
349            node: "master".to_string(),
350            transport: "local".to_string(),
351            render_sha256: "0".repeat(64),
352            plan_sha256: HASH.to_string(),
353            summary,
354            entries,
355            delete_set: DeleteSet::default(),
356            base: None,
357            warnings: Vec::new(),
358            excluded_tag_providers: vec!["System".to_string()],
359            unreadable: Vec::new(),
360        }
361    }
362
363    fn destructive(key: &str) -> PlanEntry {
364        let mut e = entry(key, PlanStatus::Removed);
365        e.destructive = true;
366        e.destructive_reason = Some("removes a resource".to_string());
367        e
368    }
369
370    fn drifted(key: &str) -> PlanEntry {
371        let mut e = entry(key, PlanStatus::Changed);
372        e.drift = Some(true);
373        e
374    }
375
376    fn flags(destructive: bool, drift: Option<&str>) -> GateFlags {
377        GateFlags {
378            plan_sha256: None,
379            allow_destructive: destructive,
380            allow_drift: drift.map(str::to_string),
381        }
382    }
383
384    fn refusal(plan: &DeployPlanResult, flags: &GateFlags) -> (String, u8, String) {
385        let err =
386            evaluate_gates(plan, flags, Redundancy::Standalone).expect_err("the gates refuse");
387        (err.code().to_string(), err.exit_code(), err.to_string())
388    }
389
390    #[test]
391    fn plan_sha256_accepts_64_lowercase_hex() {
392        assert!(validate_plan_sha256(&"a1".repeat(32)).is_ok());
393        assert!(validate_plan_sha256(&"0".repeat(64)).is_ok());
394    }
395
396    #[test]
397    fn plan_sha256_refuses_short_uppercase_and_non_hex() {
398        for bad in [
399            "a".repeat(63),
400            "A1".repeat(32),
401            "g".repeat(64),
402            String::new(),
403            format!("{} ", "a".repeat(63)),
404        ] {
405            let err = validate_plan_sha256(&bad).expect_err(&bad);
406            assert_eq!(err.code(), "invalid_input", "{bad:?}");
407            assert_eq!(err.exit_code(), 2);
408        }
409    }
410
411    #[test]
412    fn order_stale_beats_noop() {
413        let plan = plan_of(vec![entry("ignition/a/x", PlanStatus::Same)]);
414        let mut f = flags(false, None);
415        f.plan_sha256 = Some("1".repeat(64));
416        let (code, exit, _) = refusal(&plan, &f);
417        assert_eq!((code.as_str(), exit), ("plan_stale", 6));
418    }
419
420    #[test]
421    fn order_stale_beats_the_destructive_gate() {
422        let plan = plan_of(vec![destructive("ignition/a/x")]);
423        let mut f = flags(false, None);
424        f.plan_sha256 = Some("1".repeat(64));
425        assert_eq!(refusal(&plan, &f).0, "plan_stale");
426    }
427
428    #[test]
429    fn order_empty_plan_is_noop() {
430        let plan = plan_of(vec![
431            entry("ignition/a/x", PlanStatus::Same),
432            entry("ignition/a/y", PlanStatus::Unmanaged),
433        ]);
434        assert_eq!(
435            evaluate_gates(&plan, &flags(false, None), Redundancy::Standalone).expect("noop"),
436            GateOutcome::Noop
437        );
438        let mut matching = flags(false, None);
439        matching.plan_sha256 = Some(HASH.to_string());
440        assert_eq!(
441            evaluate_gates(&plan, &matching, Redundancy::Standalone)
442                .expect("noop with its own hash"),
443            GateOutcome::Noop
444        );
445    }
446
447    fn unreadable(key: &str, declared: bool, in_ledger: bool) -> UnreadableUnit {
448        UnreadableUnit {
449            key: key.to_string(),
450            live_in: vec!["mode".to_string()],
451            declared,
452            in_ledger,
453            paths: vec![format!("{key}/config.json")],
454            longest: 262,
455        }
456    }
457
458    /// A plan carrying `units`, with `has_changes` and `summary.unreadable`
459    /// set the way the plan engine sets them (P6).
460    fn with_unreadable(mut plan: DeployPlanResult, units: Vec<UnreadableUnit>) -> DeployPlanResult {
461        plan.summary.unreadable = units.len();
462        plan.summary.has_changes |= units.iter().any(|u| u.declared || u.in_ledger);
463        plan.unreadable = units;
464        plan
465    }
466
467    #[test]
468    fn unreadable_declared_unit_is_refused_deploy_unreadable() {
469        let plan = with_unreadable(
470            plan_of(vec![entry("ignition/a/x", PlanStatus::Same)]),
471            vec![unreadable(
472                "ignition/tag-definition/default/Deep",
473                true,
474                false,
475            )],
476        );
477        assert!(plan.summary.has_changes);
478        let (code, exit, text) = refusal(&plan, &flags(true, Some("ok")));
479        assert_eq!((code.as_str(), exit), ("deploy_unreadable", 6));
480        assert!(
481            text.contains("ignition/tag-definition/default/Deep"),
482            "{text}"
483        );
484    }
485
486    #[test]
487    fn unreadable_in_ledger_unit_is_refused_before_the_noop_return() {
488        let mut plan = with_unreadable(
489            plan_of(vec![entry("ignition/a/x", PlanStatus::Same)]),
490            vec![unreadable("ignition/database-translator/OLD", false, true)],
491        );
492        plan.summary.has_changes = false;
493        let (code, exit, _) = refusal(&plan, &flags(false, None));
494        assert_eq!((code.as_str(), exit), ("deploy_unreadable", 6));
495    }
496
497    #[test]
498    fn unreadable_keys_are_byte_sorted_and_only_the_ones_that_matter() {
499        let plan = with_unreadable(
500            plan_of(Vec::new()),
501            vec![
502                unreadable("ignition/b/z", true, false),
503                unreadable("ignition/a/undeclared", false, false),
504                unreadable("ignition/B/y", false, true),
505            ],
506        );
507        let err = evaluate_gates(&plan, &flags(false, None), Redundancy::Standalone)
508            .expect_err("refused");
509        match err {
510            crate::error::CoreError::DeployUnreadable { node, keys } => {
511                assert_eq!(node, "master");
512                assert_eq!(keys, vec!["ignition/B/y", "ignition/b/z"]);
513            }
514            other => panic!("expected DeployUnreadable, got {other:?}"),
515        }
516    }
517
518    #[test]
519    fn unreadable_stale_review_hash_is_still_plan_stale_first() {
520        let plan = with_unreadable(
521            plan_of(Vec::new()),
522            vec![unreadable("ignition/a/x", true, true)],
523        );
524        let mut f = flags(false, None);
525        f.plan_sha256 = Some("1".repeat(64));
526        assert_eq!(refusal(&plan, &f).0, "plan_stale");
527    }
528
529    #[test]
530    fn unreadable_undeclared_unledgered_unit_is_noop_when_nothing_else_changes() {
531        let plan = with_unreadable(
532            plan_of(vec![entry("ignition/a/x", PlanStatus::Same)]),
533            vec![unreadable(
534                "ignition/tag-definition/MQTT/Edge",
535                false,
536                false,
537            )],
538        );
539        assert_eq!(
540            evaluate_gates(&plan, &flags(false, None), Redundancy::Standalone).expect("noop"),
541            GateOutcome::Noop
542        );
543    }
544
545    #[test]
546    fn unreadable_undeclared_unledgered_unit_is_ready_when_another_entry_changes() {
547        let plan = with_unreadable(
548            plan_of(vec![entry("ignition/a/x", PlanStatus::Changed)]),
549            vec![unreadable(
550                "ignition/tag-definition/MQTT/Edge",
551                false,
552                false,
553            )],
554        );
555        assert!(matches!(
556            evaluate_gates(&plan, &flags(false, None), Redundancy::Standalone).expect("ready"),
557            GateOutcome::Ready { .. }
558        ));
559    }
560
561    fn tag_entry(key: &str, ty: &str, status: PlanStatus, live_in: &[&str]) -> PlanEntry {
562        let mut e = entry(key, status);
563        e.ty = ty.to_string();
564        e.live_in = live_in.iter().map(|c| (*c).to_string()).collect();
565        e
566    }
567
568    /// `e` with `names` as its live files under its key (a mode copy).
569    fn with_live_files(mut e: PlanEntry, names: &[&str]) -> PlanEntry {
570        e.files = names
571            .iter()
572            .map(|name| PlanFile {
573                path: format!("{}/{name}", e.key),
574                live_sha256: Some("cd".repeat(32)),
575                desired_sha256: None,
576            })
577            .collect();
578        e
579    }
580
581    /// `plan` with `paths` as its delete set.
582    fn deleting(mut plan: DeployPlanResult, paths: &[String]) -> DeployPlanResult {
583        plan.delete_set = DeleteSet {
584            files: paths
585                .iter()
586                .map(|p| RelPath::new(p).expect("a valid path"))
587                .collect(),
588        };
589        plan
590    }
591
592    /// debug deploy-tag-redundancy-sync: on a pair, every tag-definition or
593    /// tag-type-definition unit the apply would write into (`added`,
594    /// `changed`) or keep in (`same` with a mode copy) the mode collection is
595    /// refused, whatever the flags; a core-only `same` unit, a `removed` one
596    /// whose mode copy the delete set clears, an `unmanaged` one and the
597    /// other tag types are not. A standalone target is never refused for
598    /// them.
599    #[test]
600    fn pair_tags_written_or_kept_in_the_mode_collection_are_refused_on_a_pair_only() {
601        let mut removed = tag_entry(
602            "ignition/tag-definition/default/Removed",
603            "tag-definition",
604            PlanStatus::Removed,
605            &["mode"],
606        );
607        removed.destructive = true;
608        let plan = plan_of(vec![
609            tag_entry(
610                "ignition/tag-definition/default/Added",
611                "tag-definition",
612                PlanStatus::Added,
613                &[],
614            ),
615            tag_entry(
616                "ignition/tag-type-definition/default/Changed",
617                "tag-type-definition",
618                PlanStatus::Changed,
619                &["mode"],
620            ),
621            tag_entry(
622                "ignition/tag-definition/default/KeptInMode",
623                "tag-definition",
624                PlanStatus::Same,
625                &["core", "mode"],
626            ),
627            tag_entry(
628                "ignition/tag-definition/default/CoreOnly",
629                "tag-definition",
630                PlanStatus::Same,
631                &["core"],
632            ),
633            removed,
634            tag_entry(
635                "ignition/tag-definition/default/Unmanaged",
636                "tag-definition",
637                PlanStatus::Unmanaged,
638                &["mode"],
639            ),
640            tag_entry(
641                "ignition/tag-group/default/Fast",
642                "tag-group",
643                PlanStatus::Added,
644                &[],
645            ),
646            tag_entry(
647                "ignition/tag-provider/default",
648                "tag-provider",
649                PlanStatus::Changed,
650                &["mode"],
651            ),
652            entry("ignition/a/x", PlanStatus::Added),
653        ]);
654        let both = flags(true, Some("reviewed"));
655        let err = evaluate_gates(&plan, &both, Redundancy::Pair).expect_err("refused");
656        assert_eq!(
657            (err.code(), err.exit_code()),
658            ("deploy_pair_tags_refused", 6)
659        );
660        match &err {
661            crate::error::CoreError::DeployPairTagsRefused { mode, keys } => {
662                assert_eq!(mode, "production");
663                assert_eq!(
664                    keys,
665                    &vec![
666                        "ignition/tag-definition/default/Added".to_string(),
667                        "ignition/tag-definition/default/KeptInMode".to_string(),
668                        "ignition/tag-type-definition/default/Changed".to_string(),
669                    ]
670                );
671            }
672            other => panic!("expected DeployPairTagsRefused, got {other:?}"),
673        }
674        assert!(
675            !err.to_string().contains("CoreOnly") && !err.to_string().contains("Removed"),
676            "{err}"
677        );
678        assert!(matches!(
679            evaluate_gates(&plan, &both, Redundancy::Standalone).expect("standalone"),
680            GateOutcome::Ready { .. }
681        ));
682        assert!(pair_tags_refusal(&plan, Redundancy::Standalone).is_none());
683    }
684
685    /// A tag unit kept in the mode collection already stops the backup from
686    /// syncing (T6), so it is refused before the no-op return, like an
687    /// unreadable unit.
688    #[test]
689    fn pair_tags_kept_unit_is_refused_before_the_noop_return() {
690        let plan = plan_of(vec![tag_entry(
691            "ignition/tag-definition/default/Deploy",
692            "tag-definition",
693            PlanStatus::Same,
694            &["mode"],
695        )]);
696        assert!(!plan.summary.has_changes);
697        let (code, exit, text) = {
698            let err =
699                evaluate_gates(&plan, &flags(false, None), Redundancy::Pair).expect_err("refused");
700            (err.code().to_string(), err.exit_code(), err.to_string())
701        };
702        assert_eq!((code.as_str(), exit), ("deploy_pair_tags_refused", 6));
703        assert!(
704            text.contains("ignition/tag-definition/default/Deploy"),
705            "{text}"
706        );
707        assert_eq!(
708            evaluate_gates(&plan, &flags(false, None), Redundancy::Standalone).expect("noop"),
709            GateOutcome::Noop
710        );
711    }
712
713    /// Deleting a tag unit from the mode collection is how a pair recovers
714    /// (T1d, T6e): a `removed` tag entry whose mode files are all in the
715    /// delete set passes on a pair with its flag.
716    #[test]
717    fn pair_tags_removed_unit_is_the_recovery_and_passes() {
718        let mut removed = with_live_files(
719            tag_entry(
720                "ignition/tag-definition/default/Deploy",
721                "tag-definition",
722                PlanStatus::Removed,
723                &["mode"],
724            ),
725            &["tags.json", "unary-resource.json"],
726        );
727        removed.destructive = true;
728        let paths: Vec<String> = removed.files.iter().map(|f| f.path.clone()).collect();
729        let plan = deleting(plan_of(vec![removed]), &paths);
730        assert!(matches!(
731            evaluate_gates(&plan, &flags(true, None), Redundancy::Pair).expect("ready"),
732            GateOutcome::Ready { .. }
733        ));
734        assert!(pair_tags_refusal(&plan, Redundancy::Pair).is_none());
735    }
736
737    /// A `removed` tag unit whose mode copy keeps a file the delete set
738    /// skips (here one the ledger does not list) keeps its folder in the mode
739    /// collection, so the backup stays wedged (debug
740    /// deploy-tag-redundancy-sync): refused on a pair, whatever the flags.
741    #[test]
742    fn pair_tags_removed_unit_leaving_a_mode_file_is_refused() {
743        let key = "ignition/tag-definition/default/Deploy";
744        let mut removed = with_live_files(
745            tag_entry(key, "tag-definition", PlanStatus::Removed, &["mode"]),
746            &["tags.json", "unary-resource.json", "udts.json"],
747        );
748        removed.destructive = true;
749        let plan = deleting(
750            plan_of(vec![removed]),
751            &[
752                format!("{key}/tags.json"),
753                format!("{key}/unary-resource.json"),
754            ],
755        );
756        let err = evaluate_gates(&plan, &flags(true, None), Redundancy::Pair).expect_err("refused");
757        assert_eq!(
758            (err.code(), err.exit_code()),
759            ("deploy_pair_tags_refused", 6)
760        );
761        match &err {
762            crate::error::CoreError::DeployPairTagsRefused { keys, .. } => {
763                assert_eq!(keys, &vec![key.to_string()]);
764            }
765            other => panic!("expected DeployPairTagsRefused, got {other:?}"),
766        }
767        assert!(matches!(
768            evaluate_gates(&plan, &flags(true, None), Redundancy::Standalone).expect("standalone"),
769            GateOutcome::Ready { .. }
770        ));
771        assert!(pair_tags_refusal(&plan, Redundancy::Standalone).is_none());
772    }
773
774    /// The order: a stale review hash and an unreadable unit are named first.
775    #[test]
776    fn pair_tags_come_after_plan_stale_and_unreadable() {
777        let tag = tag_entry(
778            "ignition/tag-definition/default/Deploy",
779            "tag-definition",
780            PlanStatus::Added,
781            &[],
782        );
783        let plan = plan_of(vec![tag.clone()]);
784        let mut stale = flags(false, None);
785        stale.plan_sha256 = Some("1".repeat(64));
786        assert_eq!(
787            evaluate_gates(&plan, &stale, Redundancy::Pair)
788                .expect_err("stale")
789                .code(),
790            "plan_stale"
791        );
792        let plan = with_unreadable(
793            plan_of(vec![tag]),
794            vec![unreadable("ignition/database-translator/OLD", true, false)],
795        );
796        assert_eq!(
797            evaluate_gates(&plan, &flags(true, Some("ok")), Redundancy::Pair)
798                .expect_err("unreadable")
799                .code(),
800            "deploy_unreadable"
801        );
802    }
803
804    #[test]
805    fn order_shadows_removal_blocks_even_with_both_flags() {
806        let mut shadowed = destructive("ignition/a/x");
807        shadowed.core = CoreRelation::ShadowsRemoval;
808        let plan = plan_of(vec![shadowed, entry("ignition/a/y", PlanStatus::Added)]);
809        let (code, exit, shown) = refusal(&plan, &flags(true, Some("reviewed drift")));
810        assert_eq!((code.as_str(), exit), ("deploy_blocked", 6));
811        assert!(shown.contains("ignition/a/x"), "{shown}");
812        assert!(!shown.contains("ignition/a/y"), "{shown}");
813    }
814
815    #[test]
816    fn order_shadows_removal_beats_the_destructive_gate() {
817        let mut shadowed = destructive("ignition/a/x");
818        shadowed.core = CoreRelation::ShadowsRemoval;
819        let plan = plan_of(vec![shadowed]);
820        assert_eq!(refusal(&plan, &flags(false, None)).0, "deploy_blocked");
821    }
822
823    #[test]
824    fn order_destructive_without_the_flag_is_refused() {
825        let plan = plan_of(vec![destructive("ignition/a/x")]);
826        let (code, exit, shown) = refusal(&plan, &flags(false, None));
827        assert_eq!((code.as_str(), exit), ("destructive_change_refused", 2));
828        assert!(shown.contains("destructive"), "{shown}");
829        assert!(shown.contains("ignition/a/x"), "{shown}");
830    }
831
832    #[test]
833    fn order_drift_without_the_reason_is_refused() {
834        let plan = plan_of(vec![drifted("ignition/a/x")]);
835        let (code, exit, shown) = refusal(&plan, &flags(true, None));
836        assert_eq!((code.as_str(), exit), ("destructive_change_refused", 2));
837        assert!(shown.contains("drift"), "{shown}");
838        assert!(shown.contains("ignition/a/x"), "{shown}");
839    }
840
841    #[test]
842    fn order_destructive_is_judged_before_drift() {
843        let plan = plan_of(vec![drifted("ignition/a/d"), destructive("ignition/a/x")]);
844        let (_, _, shown) = refusal(&plan, &flags(false, None));
845        assert!(shown.contains("ignition/a/x"), "{shown}");
846        assert!(!shown.contains("ignition/a/d"), "{shown}");
847        let (_, _, shown) = refusal(&plan, &flags(true, None));
848        assert!(shown.contains("ignition/a/d"), "{shown}");
849    }
850
851    #[test]
852    fn order_both_flags_make_it_ready() {
853        let plan = plan_of(vec![destructive("ignition/a/x"), drifted("ignition/a/y")]);
854        let outcome = evaluate_gates(
855            &plan,
856            &flags(true, Some("reviewed on the gateway")),
857            Redundancy::Standalone,
858        )
859        .expect("both flags");
860        assert!(matches!(outcome, GateOutcome::Ready { .. }), "{outcome:?}");
861    }
862
863    #[test]
864    fn unmanaged_entries_never_gate() {
865        let mut odd = entry("ignition/a/u", PlanStatus::Unmanaged);
866        odd.destructive = true;
867        odd.drift = Some(true);
868        odd.core = CoreRelation::ShadowsRemoval;
869        let plan = plan_of(vec![odd, entry("ignition/a/x", PlanStatus::Added)]);
870        let outcome =
871            evaluate_gates(&plan, &flags(false, None), Redundancy::Standalone).expect("ready");
872        assert!(matches!(outcome, GateOutcome::Ready { .. }), "{outcome:?}");
873    }
874
875    #[test]
876    fn drift_none_and_false_never_gate() {
877        let mut no_drift = entry("ignition/a/x", PlanStatus::Changed);
878        no_drift.drift = Some(false);
879        let plan = plan_of(vec![no_drift, entry("ignition/a/y", PlanStatus::Added)]);
880        let outcome =
881            evaluate_gates(&plan, &flags(false, None), Redundancy::Standalone).expect("ready");
882        assert!(matches!(outcome, GateOutcome::Ready { .. }), "{outcome:?}");
883    }
884
885    #[test]
886    fn refusal_keys_are_byte_sorted_and_capped_at_ten() {
887        let mut entries: Vec<PlanEntry> = (0..12)
888            .rev()
889            .map(|i| destructive(&format!("ignition/a/k{i:02}")))
890            .collect();
891        entries.push(entry("ignition/a/zz", PlanStatus::Added));
892        let plan = plan_of(entries);
893        let (_, _, shown) = refusal(&plan, &flags(false, None));
894        assert!(shown.contains("12"), "{shown}");
895        assert!(shown.contains("and 2 more"), "{shown}");
896        let first = shown.find("ignition/a/k00").expect("k00 shown");
897        let tenth = shown.find("ignition/a/k09").expect("k09 shown");
898        assert!(first < tenth, "{shown}");
899        assert!(!shown.contains("ignition/a/k10"), "{shown}");
900        assert!(!shown.contains("ignition/a/zz"), "{shown}");
901    }
902
903    #[test]
904    fn a_malformed_review_hash_is_invalid_input_not_stale() {
905        let plan = plan_of(vec![entry("ignition/a/x", PlanStatus::Added)]);
906        let mut f = flags(false, None);
907        f.plan_sha256 = Some("NOT-A-HASH".to_string());
908        let (code, exit, _) = refusal(&plan, &f);
909        assert_eq!((code.as_str(), exit), ("invalid_input", 2));
910    }
911
912    #[test]
913    fn drift_reason_is_validated_on_every_evaluation() {
914        let plan = plan_of(vec![entry("ignition/a/x", PlanStatus::Added)]);
915        for bad in ["", "two\nlines", "nul\0byte"] {
916            let (code, exit, _) = refusal(&plan, &flags(false, Some(bad)));
917            assert_eq!((code.as_str(), exit), ("invalid_input", 2), "{bad:?}");
918        }
919    }
920
921    #[test]
922    fn drift_reason_rules() {
923        assert!(validate_drift_reason(&"r".repeat(200)).is_ok());
924        assert!(validate_drift_reason(&"r".repeat(DRIFT_REASON_MAX_CHARS)).is_ok());
925        for bad in [
926            String::new(),
927            "r".repeat(DRIFT_REASON_MAX_CHARS + 1),
928            "a\nb".to_string(),
929            "a\rb".to_string(),
930            "a\0b".to_string(),
931            "a\u{1b}[31mb".to_string(),
932        ] {
933            let err = validate_drift_reason(&bad).expect_err(&bad);
934            assert_eq!(err.code(), "invalid_input", "{bad:?}");
935            assert_eq!(err.exit_code(), 2);
936        }
937    }
938
939    #[test]
940    fn drift_reason_length_counts_characters_not_bytes() {
941        assert!(validate_drift_reason(&"\u{e9}".repeat(DRIFT_REASON_MAX_CHARS)).is_ok());
942    }
943
944    #[test]
945    fn prose_is_stable_and_carries_no_content() {
946        let mut d = destructive("ignition/a/x");
947        d.diff = Some("SECRET-DIFF-BYTES".to_string());
948        let plan = plan_of(vec![
949            entry("ignition/a/y", PlanStatus::Added),
950            d,
951            entry("ignition/a/s", PlanStatus::Same),
952        ]);
953        let one = confirmation_prose(&plan);
954        assert_eq!(one, confirmation_prose(&plan));
955        assert!(one.contains(HASH), "{one}");
956        assert!(one.contains("prod") && one.contains("gw") && one.contains("production"));
957        assert!(
958            one.contains("ignition/a/y") && one.contains("ignition/a/x"),
959            "{one}"
960        );
961        assert!(
962            !one.contains("ignition/a/s"),
963            "same entries are not listed: {one}"
964        );
965        assert!(!one.contains("SECRET-DIFF-BYTES"), "{one}");
966        assert!(one.contains("--yes"), "{one}");
967    }
968}