1use crate::actions::deploy::DeployPlanResult;
8use crate::deploy::plan::{CoreRelation, PlanEntry, PlanStatus, pair_tag_keys};
9pub use crate::deploy::target::Redundancy;
10use crate::error::{APPLY_LIST_SHOWN, CoreError};
11
12pub const DRIFT_REASON_MAX_CHARS: usize = 500;
14
15#[derive(Debug, Clone, Default, PartialEq, Eq)]
17pub struct GateFlags {
18 pub plan_sha256: Option<String>,
20 pub allow_destructive: bool,
22 pub allow_drift: Option<String>,
24 pub allow_provider_wipe: Vec<String>,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum GateOutcome {
33 Noop,
35 Ready {
37 prose: String,
39 },
40}
41
42pub fn pair_tags_refusal(plan: &DeployPlanResult, redundancy: Redundancy) -> Option<CoreError> {
54 pair_tags_refusal_with(plan, redundancy, &plan.pair_mode_tag_dirs)
55}
56
57pub fn pair_tags_refusal_with(
63 plan: &DeployPlanResult,
64 redundancy: Redundancy,
65 dirs: &[String],
66) -> Option<CoreError> {
67 if redundancy == Redundancy::Standalone {
68 return None;
69 }
70 let mut keys = pair_tag_keys(&plan.entries, &plan.delete_set);
71 keys.extend(dirs.iter().cloned());
72 keys.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
73 keys.dedup();
74 if keys.is_empty() {
75 return None;
76 }
77 Some(CoreError::DeployPairTagsRefused {
78 mode: plan.mode.clone(),
79 keys,
80 })
81}
82
83pub fn unreadable_refusal(plan: &DeployPlanResult) -> Option<CoreError> {
89 let mut keys: Vec<String> = plan
90 .unreadable
91 .iter()
92 .filter(|u| u.declared || u.in_ledger)
93 .map(|u| u.key.clone())
94 .collect();
95 if keys.is_empty() {
96 return None;
97 }
98 keys.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
99 keys.dedup();
100 Some(CoreError::DeployUnreadable {
101 node: "master".to_string(),
102 keys,
103 })
104}
105
106pub fn evaluate_gates(
140 plan: &DeployPlanResult,
141 flags: &GateFlags,
142 redundancy: Redundancy,
143) -> Result<GateOutcome, CoreError> {
144 if let Some(hash) = &flags.plan_sha256 {
145 validate_plan_sha256(hash)?;
146 }
147 if let Some(reason) = &flags.allow_drift {
148 validate_drift_reason(reason)?;
149 }
150 if let Some(expected) = &flags.plan_sha256
151 && *expected != plan.plan_sha256
152 {
153 return Err(CoreError::PlanStale {
154 expected: expected.clone(),
155 actual: plan.plan_sha256.clone(),
156 detail: "review hash".to_string(),
157 });
158 }
159 if let Some(refusal) = unreadable_refusal(plan) {
160 return Err(refusal);
161 }
162 if let Some(refusal) = pair_tags_refusal(plan, redundancy) {
163 return Err(refusal);
164 }
165 if !plan.summary.has_changes {
166 return Ok(GateOutcome::Noop);
167 }
168
169 let gated = || {
170 plan.entries
171 .iter()
172 .filter(|e| e.status != PlanStatus::Unmanaged)
173 };
174 let blocked = sorted_keys(gated().filter(|e| e.core == CoreRelation::ShadowsRemoval));
175 if !blocked.is_empty() {
176 return Err(CoreError::DeployBlocked { keys: blocked });
177 }
178 if let Some(refusal) = provider_wipe_refusal(plan, &flags.allow_provider_wipe) {
179 return Err(refusal);
180 }
181 if !flags.allow_destructive {
182 let keys = sorted_keys(gated().filter(|e| e.destructive));
183 if !keys.is_empty() {
184 return Err(refusal("destructive", keys));
185 }
186 }
187 if flags.allow_drift.is_none() {
188 let keys = sorted_keys(gated().filter(|e| e.drift == Some(true)));
189 if !keys.is_empty() {
190 return Err(refusal("drift", keys));
191 }
192 }
193 Ok(GateOutcome::Ready {
194 prose: confirmation_prose(plan),
195 })
196}
197
198pub fn provider_wipe_refusal(plan: &DeployPlanResult, allowed: &[String]) -> Option<CoreError> {
206 let keys: Vec<String> = plan
207 .provider_wipes
208 .iter()
209 .filter(|p| !allowed.iter().any(|a| a.eq_ignore_ascii_case(p)))
210 .map(|p| format!("tag provider {p}"))
211 .collect();
212 (!keys.is_empty()).then(|| refusal("provider_wipe", keys))
213}
214
215fn sorted_keys<'a>(entries: impl Iterator<Item = &'a PlanEntry>) -> Vec<String> {
217 let mut keys: Vec<String> = entries.map(|e| e.key.clone()).collect();
218 keys.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
219 keys
220}
221
222fn refusal(gate: &str, mut keys: Vec<String>) -> CoreError {
225 let count = keys.len();
226 keys.truncate(APPLY_LIST_SHOWN);
227 CoreError::DestructiveChangeRefused {
228 gate: gate.to_string(),
229 keys,
230 count,
231 }
232}
233
234pub fn confirmation_prose(plan: &DeployPlanResult) -> String {
238 use std::fmt::Write as _;
239
240 let s = &plan.summary;
241 let mut out = String::new();
242 let _ = writeln!(
243 out,
244 "deploy apply: env {}, gateway {}, mode {}",
245 plan.env, plan.gateway, plan.mode
246 );
247 let _ = writeln!(
248 out,
249 "plan: {} added, {} changed, {} removed, {} same, {} unmanaged",
250 s.added, s.changed, s.removed, s.same, s.unmanaged
251 );
252 for entry in &plan.entries {
253 if matches!(entry.status, PlanStatus::Same | PlanStatus::Unmanaged) {
254 continue;
255 }
256 let mut flags: Vec<&str> = Vec::new();
257 if entry.destructive {
258 flags.push("destructive");
259 }
260 if entry.lockout_risk {
261 flags.push("lockout_risk");
262 }
263 if entry.drift == Some(true) {
264 flags.push("drift");
265 }
266 if !matches!(entry.core, CoreRelation::None | CoreRelation::Identical) {
267 flags.push(entry.core.word());
268 }
269 if flags.is_empty() {
270 let _ = writeln!(out, " {} {}", entry.status.word(), entry.key);
271 } else {
272 let _ = writeln!(
273 out,
274 " {} {} [{}]",
275 entry.status.word(),
276 entry.key,
277 flags.join(", ")
278 );
279 }
280 }
281 let _ = writeln!(out, "delete set: {} file(s)", plan.delete_set.files.len());
282 let _ = writeln!(out, "plan_sha256: {}", plan.plan_sha256);
283 out.push_str(
284 "rerun with --yes to apply this plan; when not interactive (--json, or stdin or \
285 stdout not a terminal) also pass --plan-sha256 with the hash above\n",
286 );
287 out
288}
289
290pub fn validate_drift_reason(reason: &str) -> Result<(), CoreError> {
294 let problem = if reason.is_empty() {
295 Some("must not be empty".to_string())
296 } else if reason.chars().count() > DRIFT_REASON_MAX_CHARS {
297 Some(format!(
298 "must be at most {DRIFT_REASON_MAX_CHARS} characters"
299 ))
300 } else if reason.chars().any(char::is_control) {
301 Some("must not contain control characters".to_string())
302 } else {
303 None
304 };
305 match problem {
306 None => Ok(()),
307 Some(problem) => Err(CoreError::InvalidInput {
308 reason: format!("--allow-drift reason {problem}"),
309 }),
310 }
311}
312
313pub fn validate_plan_sha256(value: &str) -> Result<(), CoreError> {
315 let ok = value.len() == 64
316 && value
317 .bytes()
318 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
319 if ok {
320 Ok(())
321 } else {
322 Err(CoreError::InvalidInput {
323 reason: "--plan-sha256 must be 64 lowercase hex characters (the plan_sha256 \
324 of the reviewed plan)"
325 .to_string(),
326 })
327 }
328}
329
330#[cfg(test)]
331mod tests {
332 use super::{
333 DRIFT_REASON_MAX_CHARS, GateFlags, GateOutcome, Redundancy, confirmation_prose,
334 evaluate_gates, pair_tags_refusal, validate_drift_reason, validate_plan_sha256,
335 };
336 use crate::actions::deploy::DeployPlanResult;
337 use crate::deploy::layout::RelPath;
338 use crate::deploy::plan::{
339 CoreRelation, PlanEntry, PlanFile, PlanStatus, PlanSummary, UnreadableUnit,
340 };
341 use crate::deploy::transport::DeleteSet;
342
343 const HASH: &str = "ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12";
344
345 fn entry(key: &str, status: PlanStatus) -> PlanEntry {
346 PlanEntry {
347 key: key.to_string(),
348 module: "ignition".to_string(),
349 ty: "database-translator".to_string(),
350 name: None,
351 status,
352 live_in: Vec::new(),
353 core: CoreRelation::None,
354 destructive: false,
355 destructive_reason: None,
356 lockout_risk: false,
357 drift: None,
358 files: Vec::new(),
359 diff: None,
360 }
361 }
362
363 fn plan_of(entries: Vec<PlanEntry>) -> DeployPlanResult {
364 let mut summary = PlanSummary::default();
365 for e in &entries {
366 match e.status {
367 PlanStatus::Added => summary.added += 1,
368 PlanStatus::Changed => summary.changed += 1,
369 PlanStatus::Removed => summary.removed += 1,
370 PlanStatus::Same => summary.same += 1,
371 PlanStatus::Unmanaged => summary.unmanaged += 1,
372 }
373 }
374 summary.has_changes = summary.added + summary.changed + summary.removed > 0;
375 DeployPlanResult {
376 env: "prod".to_string(),
377 gateway: "gw".to_string(),
378 mode: "production".to_string(),
379 node: "master".to_string(),
380 transport: "local".to_string(),
381 render_sha256: "0".repeat(64),
382 plan_sha256: HASH.to_string(),
383 summary,
384 entries,
385 delete_set: DeleteSet::default(),
386 base: None,
387 warnings: Vec::new(),
388 excluded_tag_providers: vec!["System".to_string()],
389 unreadable: Vec::new(),
390 provider_wipes: Vec::new(),
391 pair_mode_tag_dirs: Vec::new(),
392 }
393 }
394
395 fn destructive(key: &str) -> PlanEntry {
396 let mut e = entry(key, PlanStatus::Removed);
397 e.destructive = true;
398 e.destructive_reason = Some("removes a resource".to_string());
399 e
400 }
401
402 fn drifted(key: &str) -> PlanEntry {
403 let mut e = entry(key, PlanStatus::Changed);
404 e.drift = Some(true);
405 e
406 }
407
408 fn flags(destructive: bool, drift: Option<&str>) -> GateFlags {
409 GateFlags {
410 plan_sha256: None,
411 allow_destructive: destructive,
412 allow_drift: drift.map(str::to_string),
413 allow_provider_wipe: Vec::new(),
414 }
415 }
416
417 fn refusal(plan: &DeployPlanResult, flags: &GateFlags) -> (String, u8, String) {
418 let err =
419 evaluate_gates(plan, flags, Redundancy::Standalone).expect_err("the gates refuse");
420 (err.code().to_string(), err.exit_code(), err.to_string())
421 }
422
423 #[test]
424 fn plan_sha256_accepts_64_lowercase_hex() {
425 assert!(validate_plan_sha256(&"a1".repeat(32)).is_ok());
426 assert!(validate_plan_sha256(&"0".repeat(64)).is_ok());
427 }
428
429 #[test]
430 fn plan_sha256_refuses_short_uppercase_and_non_hex() {
431 for bad in [
432 "a".repeat(63),
433 "A1".repeat(32),
434 "g".repeat(64),
435 String::new(),
436 format!("{} ", "a".repeat(63)),
437 ] {
438 let err = validate_plan_sha256(&bad).expect_err(&bad);
439 assert_eq!(err.code(), "invalid_input", "{bad:?}");
440 assert_eq!(err.exit_code(), 2);
441 }
442 }
443
444 #[test]
445 fn order_stale_beats_noop() {
446 let plan = plan_of(vec![entry("ignition/a/x", PlanStatus::Same)]);
447 let mut f = flags(false, None);
448 f.plan_sha256 = Some("1".repeat(64));
449 let (code, exit, _) = refusal(&plan, &f);
450 assert_eq!((code.as_str(), exit), ("plan_stale", 6));
451 }
452
453 #[test]
454 fn order_stale_beats_the_destructive_gate() {
455 let plan = plan_of(vec![destructive("ignition/a/x")]);
456 let mut f = flags(false, None);
457 f.plan_sha256 = Some("1".repeat(64));
458 assert_eq!(refusal(&plan, &f).0, "plan_stale");
459 }
460
461 #[test]
462 fn order_empty_plan_is_noop() {
463 let plan = plan_of(vec![
464 entry("ignition/a/x", PlanStatus::Same),
465 entry("ignition/a/y", PlanStatus::Unmanaged),
466 ]);
467 assert_eq!(
468 evaluate_gates(&plan, &flags(false, None), Redundancy::Standalone).expect("noop"),
469 GateOutcome::Noop
470 );
471 let mut matching = flags(false, None);
472 matching.plan_sha256 = Some(HASH.to_string());
473 assert_eq!(
474 evaluate_gates(&plan, &matching, Redundancy::Standalone)
475 .expect("noop with its own hash"),
476 GateOutcome::Noop
477 );
478 }
479
480 fn unreadable(key: &str, declared: bool, in_ledger: bool) -> UnreadableUnit {
481 UnreadableUnit {
482 key: key.to_string(),
483 live_in: vec!["mode".to_string()],
484 declared,
485 in_ledger,
486 paths: vec![format!("{key}/config.json")],
487 longest: 262,
488 }
489 }
490
491 fn with_unreadable(mut plan: DeployPlanResult, units: Vec<UnreadableUnit>) -> DeployPlanResult {
494 plan.summary.unreadable = units.len();
495 plan.summary.has_changes |= units.iter().any(|u| u.declared || u.in_ledger);
496 plan.unreadable = units;
497 plan
498 }
499
500 #[test]
501 fn unreadable_declared_unit_is_refused_deploy_unreadable() {
502 let plan = with_unreadable(
503 plan_of(vec![entry("ignition/a/x", PlanStatus::Same)]),
504 vec![unreadable(
505 "ignition/tag-definition/default/Deep",
506 true,
507 false,
508 )],
509 );
510 assert!(plan.summary.has_changes);
511 let (code, exit, text) = refusal(&plan, &flags(true, Some("ok")));
512 assert_eq!((code.as_str(), exit), ("deploy_unreadable", 6));
513 assert!(
514 text.contains("ignition/tag-definition/default/Deep"),
515 "{text}"
516 );
517 }
518
519 #[test]
520 fn unreadable_in_ledger_unit_is_refused_before_the_noop_return() {
521 let mut plan = with_unreadable(
522 plan_of(vec![entry("ignition/a/x", PlanStatus::Same)]),
523 vec![unreadable("ignition/database-translator/OLD", false, true)],
524 );
525 plan.summary.has_changes = false;
526 let (code, exit, _) = refusal(&plan, &flags(false, None));
527 assert_eq!((code.as_str(), exit), ("deploy_unreadable", 6));
528 }
529
530 #[test]
531 fn unreadable_keys_are_byte_sorted_and_only_the_ones_that_matter() {
532 let plan = with_unreadable(
533 plan_of(Vec::new()),
534 vec![
535 unreadable("ignition/b/z", true, false),
536 unreadable("ignition/a/undeclared", false, false),
537 unreadable("ignition/B/y", false, true),
538 ],
539 );
540 let err = evaluate_gates(&plan, &flags(false, None), Redundancy::Standalone)
541 .expect_err("refused");
542 match err {
543 crate::error::CoreError::DeployUnreadable { node, keys } => {
544 assert_eq!(node, "master");
545 assert_eq!(keys, vec!["ignition/B/y", "ignition/b/z"]);
546 }
547 other => panic!("expected DeployUnreadable, got {other:?}"),
548 }
549 }
550
551 #[test]
552 fn unreadable_stale_review_hash_is_still_plan_stale_first() {
553 let plan = with_unreadable(
554 plan_of(Vec::new()),
555 vec![unreadable("ignition/a/x", true, true)],
556 );
557 let mut f = flags(false, None);
558 f.plan_sha256 = Some("1".repeat(64));
559 assert_eq!(refusal(&plan, &f).0, "plan_stale");
560 }
561
562 #[test]
563 fn unreadable_undeclared_unledgered_unit_is_noop_when_nothing_else_changes() {
564 let plan = with_unreadable(
565 plan_of(vec![entry("ignition/a/x", PlanStatus::Same)]),
566 vec![unreadable(
567 "ignition/tag-definition/MQTT/Edge",
568 false,
569 false,
570 )],
571 );
572 assert_eq!(
573 evaluate_gates(&plan, &flags(false, None), Redundancy::Standalone).expect("noop"),
574 GateOutcome::Noop
575 );
576 }
577
578 #[test]
579 fn unreadable_undeclared_unledgered_unit_is_ready_when_another_entry_changes() {
580 let plan = with_unreadable(
581 plan_of(vec![entry("ignition/a/x", PlanStatus::Changed)]),
582 vec![unreadable(
583 "ignition/tag-definition/MQTT/Edge",
584 false,
585 false,
586 )],
587 );
588 assert!(matches!(
589 evaluate_gates(&plan, &flags(false, None), Redundancy::Standalone).expect("ready"),
590 GateOutcome::Ready { .. }
591 ));
592 }
593
594 fn tag_entry(key: &str, ty: &str, status: PlanStatus, live_in: &[&str]) -> PlanEntry {
595 let mut e = entry(key, status);
596 e.ty = ty.to_string();
597 e.live_in = live_in.iter().map(|c| (*c).to_string()).collect();
598 e
599 }
600
601 fn with_live_files(mut e: PlanEntry, names: &[&str]) -> PlanEntry {
603 e.files = names
604 .iter()
605 .map(|name| PlanFile {
606 path: format!("{}/{name}", e.key),
607 live_sha256: Some("cd".repeat(32)),
608 desired_sha256: None,
609 })
610 .collect();
611 e
612 }
613
614 fn deleting(mut plan: DeployPlanResult, paths: &[String]) -> DeployPlanResult {
616 plan.delete_set = DeleteSet {
617 files: paths
618 .iter()
619 .map(|p| RelPath::new(p).expect("a valid path"))
620 .collect(),
621 };
622 plan
623 }
624
625 fn with_core_rows(mut e: PlanEntry, names: &[&str]) -> PlanEntry {
628 e.files.extend(names.iter().map(|name| PlanFile {
629 path: format!(".core/{}/{name}", e.key),
630 live_sha256: Some("ab".repeat(32)),
631 desired_sha256: Some("ab".repeat(32)),
632 }));
633 e.files
634 .sort_by(|a, b| a.path.as_bytes().cmp(b.path.as_bytes()));
635 e
636 }
637
638 #[test]
644 fn pair_tags_written_or_kept_in_the_mode_collection_are_refused_on_a_pair_only() {
645 let plan = plan_of(vec![
646 with_core_rows(
647 tag_entry(
648 "ignition/tag-definition/default/Added",
649 "tag-definition",
650 PlanStatus::Added,
651 &[],
652 ),
653 &["tags.json", "unary-resource.json"],
654 ),
655 with_core_rows(
656 tag_entry(
657 "ignition/tag-type-definition/default/Changed",
658 "tag-type-definition",
659 PlanStatus::Changed,
660 &["core"],
661 ),
662 &["udts.json"],
663 ),
664 with_core_rows(
665 tag_entry(
666 "ignition/tag-definition/default/CoreOnly",
667 "tag-definition",
668 PlanStatus::Same,
669 &["core"],
670 ),
671 &["tags.json"],
672 ),
673 with_live_files(
675 tag_entry(
676 "ignition/tag-definition/default/KeptInMode",
677 "tag-definition",
678 PlanStatus::Same,
679 &["core", "mode"],
680 ),
681 &["tags.json"],
682 ),
683 with_live_files(
684 tag_entry(
685 "ignition/tag-definition/default/Unmanaged",
686 "tag-definition",
687 PlanStatus::Unmanaged,
688 &["mode"],
689 ),
690 &["tags.json"],
691 ),
692 with_live_files(
693 tag_entry(
694 "ignition/tag-group/default/Fast",
695 "tag-group",
696 PlanStatus::Same,
697 &["mode"],
698 ),
699 &["config.json"],
700 ),
701 entry("ignition/a/x", PlanStatus::Added),
702 ]);
703 let both = flags(true, Some("reviewed"));
704 let err = evaluate_gates(&plan, &both, Redundancy::Pair).expect_err("refused");
705 assert_eq!(
706 (err.code(), err.exit_code()),
707 ("deploy_pair_tags_refused", 6)
708 );
709 match &err {
710 crate::error::CoreError::DeployPairTagsRefused { mode, keys } => {
711 assert_eq!(mode, "production");
712 assert_eq!(
713 keys,
714 &vec!["ignition/tag-definition/default/KeptInMode".to_string()]
715 );
716 }
717 other => panic!("expected DeployPairTagsRefused, got {other:?}"),
718 }
719 assert!(matches!(
720 evaluate_gates(&plan, &both, Redundancy::Standalone).expect("standalone"),
721 GateOutcome::Ready { .. }
722 ));
723 assert!(pair_tags_refusal(&plan, Redundancy::Standalone).is_none());
724
725 let mut clean = plan.clone();
727 clean
728 .entries
729 .retain(|e| e.key != "ignition/tag-definition/default/KeptInMode");
730 assert!(pair_tags_refusal(&clean, Redundancy::Pair).is_none());
731 assert!(matches!(
732 evaluate_gates(&clean, &both, Redundancy::Pair).expect("ready"),
733 GateOutcome::Ready { .. }
734 ));
735 }
736
737 #[test]
741 fn pair_tags_unrouted_plan_is_refused_on_a_pair() {
742 let mut added = tag_entry(
743 "ignition/tag-definition/default/Deploy",
744 "tag-definition",
745 PlanStatus::Added,
746 &[],
747 );
748 added.files = vec![PlanFile {
749 path: "ignition/tag-definition/default/Deploy/tags.json".to_string(),
750 live_sha256: None,
751 desired_sha256: Some("ab".repeat(32)),
752 }];
753 let plan = plan_of(vec![added]);
754 let err = pair_tags_refusal(&plan, Redundancy::Pair).expect("refused");
755 assert_eq!(err.code(), "deploy_pair_tags_refused");
756 assert!(pair_tags_refusal(&plan, Redundancy::Standalone).is_none());
757 }
758
759 #[test]
765 fn pair_tags_refusal_names_mode_type_folders() {
766 let mut plan = plan_of(Vec::new());
767 plan.pair_mode_tag_dirs = vec!["ignition/tag-definition".to_string()];
768 assert!(!plan.summary.has_changes);
769 let err = pair_tags_refusal(&plan, Redundancy::Pair).expect("refused");
770 match &err {
771 crate::error::CoreError::DeployPairTagsRefused { mode, keys } => {
772 assert_eq!(mode, "production");
773 assert_eq!(keys, &vec!["ignition/tag-definition".to_string()]);
774 }
775 other => panic!("expected DeployPairTagsRefused, got {other:?}"),
776 }
777 assert!(pair_tags_refusal(&plan, Redundancy::Standalone).is_none());
778 let both = GateFlags {
779 allow_destructive: true,
780 ..GateFlags::default()
781 };
782 let gated = evaluate_gates(&plan, &both, Redundancy::Pair).expect_err("gate refuses");
783 assert_eq!(gated.code(), "deploy_pair_tags_refused");
784 assert!(matches!(
785 evaluate_gates(&plan, &both, Redundancy::Standalone).expect("standalone"),
786 GateOutcome::Noop
787 ));
788 plan.pair_mode_tag_dirs.clear();
789 assert!(pair_tags_refusal(&plan, Redundancy::Pair).is_none());
790 }
791
792 #[test]
796 fn pair_tags_kept_unit_is_refused_before_the_noop_return() {
797 let kept = with_live_files(
798 with_core_rows(
799 tag_entry(
800 "ignition/tag-definition/default/Deploy",
801 "tag-definition",
802 PlanStatus::Same,
803 &["core", "mode"],
804 ),
805 &["tags.json"],
806 ),
807 &["tags.json"],
808 );
809 let plan = plan_of(vec![kept]);
810 assert!(!plan.summary.has_changes);
811 let (code, exit, text) = {
812 let err =
813 evaluate_gates(&plan, &flags(false, None), Redundancy::Pair).expect_err("refused");
814 (err.code().to_string(), err.exit_code(), err.to_string())
815 };
816 assert_eq!((code.as_str(), exit), ("deploy_pair_tags_refused", 6));
817 assert!(
818 text.contains("ignition/tag-definition/default/Deploy"),
819 "{text}"
820 );
821 assert_eq!(
822 evaluate_gates(&plan, &flags(false, None), Redundancy::Standalone).expect("noop"),
823 GateOutcome::Noop
824 );
825 let core_only = plan_of(vec![with_core_rows(
826 tag_entry(
827 "ignition/tag-definition/default/Deploy",
828 "tag-definition",
829 PlanStatus::Same,
830 &["core"],
831 ),
832 &["tags.json"],
833 )]);
834 assert_eq!(
835 evaluate_gates(&core_only, &flags(false, None), Redundancy::Pair).expect("noop"),
836 GateOutcome::Noop
837 );
838 }
839
840 #[test]
843 fn pair_tags_migration_clearing_the_mode_copy_passes() {
844 let mut moved = with_core_rows(
845 with_live_files(
846 tag_entry(
847 "ignition/tag-definition/default/Deploy",
848 "tag-definition",
849 PlanStatus::Added,
850 &["mode"],
851 ),
852 &["tags.json", "unary-resource.json"],
853 ),
854 &["tags.json", "unary-resource.json"],
855 );
856 moved.destructive = true;
857 let paths: Vec<String> = moved
858 .files
859 .iter()
860 .filter(|f| !f.path.starts_with(".core/"))
861 .map(|f| f.path.clone())
862 .collect();
863 let plan = deleting(plan_of(vec![moved]), &paths);
864 assert!(pair_tags_refusal(&plan, Redundancy::Pair).is_none());
865 assert!(matches!(
866 evaluate_gates(&plan, &flags(true, None), Redundancy::Pair).expect("ready"),
867 GateOutcome::Ready { .. }
868 ));
869 }
870
871 #[test]
875 fn pair_tags_removed_unit_is_the_recovery_and_passes() {
876 let mut removed = with_live_files(
877 tag_entry(
878 "ignition/tag-definition/default/Deploy",
879 "tag-definition",
880 PlanStatus::Removed,
881 &["mode"],
882 ),
883 &["tags.json", "unary-resource.json"],
884 );
885 removed.destructive = true;
886 let paths: Vec<String> = removed.files.iter().map(|f| f.path.clone()).collect();
887 let plan = deleting(plan_of(vec![removed]), &paths);
888 assert!(matches!(
889 evaluate_gates(&plan, &flags(true, None), Redundancy::Pair).expect("ready"),
890 GateOutcome::Ready { .. }
891 ));
892 assert!(pair_tags_refusal(&plan, Redundancy::Pair).is_none());
893 }
894
895 #[test]
900 fn pair_tags_removed_unit_leaving_a_mode_file_is_refused() {
901 let key = "ignition/tag-definition/default/Deploy";
902 let mut removed = with_live_files(
903 tag_entry(key, "tag-definition", PlanStatus::Removed, &["mode"]),
904 &["tags.json", "unary-resource.json", "udts.json"],
905 );
906 removed.destructive = true;
907 let plan = deleting(
908 plan_of(vec![removed]),
909 &[
910 format!("{key}/tags.json"),
911 format!("{key}/unary-resource.json"),
912 ],
913 );
914 let err = evaluate_gates(&plan, &flags(true, None), Redundancy::Pair).expect_err("refused");
915 assert_eq!(
916 (err.code(), err.exit_code()),
917 ("deploy_pair_tags_refused", 6)
918 );
919 match &err {
920 crate::error::CoreError::DeployPairTagsRefused { keys, .. } => {
921 assert_eq!(keys, &vec![key.to_string()]);
922 }
923 other => panic!("expected DeployPairTagsRefused, got {other:?}"),
924 }
925 assert!(matches!(
926 evaluate_gates(&plan, &flags(true, None), Redundancy::Standalone).expect("standalone"),
927 GateOutcome::Ready { .. }
928 ));
929 assert!(pair_tags_refusal(&plan, Redundancy::Standalone).is_none());
930 }
931
932 #[test]
934 fn pair_tags_come_after_plan_stale_and_unreadable() {
935 let tag = tag_entry(
936 "ignition/tag-definition/default/Deploy",
937 "tag-definition",
938 PlanStatus::Added,
939 &[],
940 );
941 let plan = plan_of(vec![tag.clone()]);
942 let mut stale = flags(false, None);
943 stale.plan_sha256 = Some("1".repeat(64));
944 assert_eq!(
945 evaluate_gates(&plan, &stale, Redundancy::Pair)
946 .expect_err("stale")
947 .code(),
948 "plan_stale"
949 );
950 let plan = with_unreadable(
951 plan_of(vec![tag]),
952 vec![unreadable("ignition/database-translator/OLD", true, false)],
953 );
954 assert_eq!(
955 evaluate_gates(&plan, &flags(true, Some("ok")), Redundancy::Pair)
956 .expect_err("unreadable")
957 .code(),
958 "deploy_unreadable"
959 );
960 }
961
962 #[test]
963 fn order_shadows_removal_blocks_even_with_both_flags() {
964 let mut shadowed = destructive("ignition/a/x");
965 shadowed.core = CoreRelation::ShadowsRemoval;
966 let plan = plan_of(vec![shadowed, entry("ignition/a/y", PlanStatus::Added)]);
967 let (code, exit, shown) = refusal(&plan, &flags(true, Some("reviewed drift")));
968 assert_eq!((code.as_str(), exit), ("deploy_blocked", 6));
969 assert!(shown.contains("ignition/a/x"), "{shown}");
970 assert!(!shown.contains("ignition/a/y"), "{shown}");
971 }
972
973 #[test]
974 fn order_shadows_removal_beats_the_destructive_gate() {
975 let mut shadowed = destructive("ignition/a/x");
976 shadowed.core = CoreRelation::ShadowsRemoval;
977 let plan = plan_of(vec![shadowed]);
978 assert_eq!(refusal(&plan, &flags(false, None)).0, "deploy_blocked");
979 }
980
981 #[test]
982 fn order_destructive_without_the_flag_is_refused() {
983 let plan = plan_of(vec![destructive("ignition/a/x")]);
984 let (code, exit, shown) = refusal(&plan, &flags(false, None));
985 assert_eq!((code.as_str(), exit), ("destructive_change_refused", 2));
986 assert!(shown.contains("destructive"), "{shown}");
987 assert!(shown.contains("ignition/a/x"), "{shown}");
988 }
989
990 #[test]
991 fn order_drift_without_the_reason_is_refused() {
992 let plan = plan_of(vec![drifted("ignition/a/x")]);
993 let (code, exit, shown) = refusal(&plan, &flags(true, None));
994 assert_eq!((code.as_str(), exit), ("destructive_change_refused", 2));
995 assert!(shown.contains("drift"), "{shown}");
996 assert!(shown.contains("ignition/a/x"), "{shown}");
997 }
998
999 #[test]
1000 fn order_destructive_is_judged_before_drift() {
1001 let plan = plan_of(vec![drifted("ignition/a/d"), destructive("ignition/a/x")]);
1002 let (_, _, shown) = refusal(&plan, &flags(false, None));
1003 assert!(shown.contains("ignition/a/x"), "{shown}");
1004 assert!(!shown.contains("ignition/a/d"), "{shown}");
1005 let (_, _, shown) = refusal(&plan, &flags(true, None));
1006 assert!(shown.contains("ignition/a/d"), "{shown}");
1007 }
1008
1009 #[test]
1010 fn order_both_flags_make_it_ready() {
1011 let plan = plan_of(vec![destructive("ignition/a/x"), drifted("ignition/a/y")]);
1012 let outcome = evaluate_gates(
1013 &plan,
1014 &flags(true, Some("reviewed on the gateway")),
1015 Redundancy::Standalone,
1016 )
1017 .expect("both flags");
1018 assert!(matches!(outcome, GateOutcome::Ready { .. }), "{outcome:?}");
1019 }
1020
1021 #[test]
1022 fn unmanaged_entries_never_gate() {
1023 let mut odd = entry("ignition/a/u", PlanStatus::Unmanaged);
1024 odd.destructive = true;
1025 odd.drift = Some(true);
1026 odd.core = CoreRelation::ShadowsRemoval;
1027 let plan = plan_of(vec![odd, entry("ignition/a/x", PlanStatus::Added)]);
1028 let outcome =
1029 evaluate_gates(&plan, &flags(false, None), Redundancy::Standalone).expect("ready");
1030 assert!(matches!(outcome, GateOutcome::Ready { .. }), "{outcome:?}");
1031 }
1032
1033 #[test]
1034 fn drift_none_and_false_never_gate() {
1035 let mut no_drift = entry("ignition/a/x", PlanStatus::Changed);
1036 no_drift.drift = Some(false);
1037 let plan = plan_of(vec![no_drift, entry("ignition/a/y", PlanStatus::Added)]);
1038 let outcome =
1039 evaluate_gates(&plan, &flags(false, None), Redundancy::Standalone).expect("ready");
1040 assert!(matches!(outcome, GateOutcome::Ready { .. }), "{outcome:?}");
1041 }
1042
1043 #[test]
1044 fn refusal_keys_are_byte_sorted_and_capped_at_ten() {
1045 let mut entries: Vec<PlanEntry> = (0..12)
1046 .rev()
1047 .map(|i| destructive(&format!("ignition/a/k{i:02}")))
1048 .collect();
1049 entries.push(entry("ignition/a/zz", PlanStatus::Added));
1050 let plan = plan_of(entries);
1051 let (_, _, shown) = refusal(&plan, &flags(false, None));
1052 assert!(shown.contains("12"), "{shown}");
1053 assert!(shown.contains("and 2 more"), "{shown}");
1054 let first = shown.find("ignition/a/k00").expect("k00 shown");
1055 let tenth = shown.find("ignition/a/k09").expect("k09 shown");
1056 assert!(first < tenth, "{shown}");
1057 assert!(!shown.contains("ignition/a/k10"), "{shown}");
1058 assert!(!shown.contains("ignition/a/zz"), "{shown}");
1059 }
1060
1061 #[test]
1062 fn a_malformed_review_hash_is_invalid_input_not_stale() {
1063 let plan = plan_of(vec![entry("ignition/a/x", PlanStatus::Added)]);
1064 let mut f = flags(false, None);
1065 f.plan_sha256 = Some("NOT-A-HASH".to_string());
1066 let (code, exit, _) = refusal(&plan, &f);
1067 assert_eq!((code.as_str(), exit), ("invalid_input", 2));
1068 }
1069
1070 #[test]
1071 fn drift_reason_is_validated_on_every_evaluation() {
1072 let plan = plan_of(vec![entry("ignition/a/x", PlanStatus::Added)]);
1073 for bad in ["", "two\nlines", "nul\0byte"] {
1074 let (code, exit, _) = refusal(&plan, &flags(false, Some(bad)));
1075 assert_eq!((code.as_str(), exit), ("invalid_input", 2), "{bad:?}");
1076 }
1077 }
1078
1079 #[test]
1080 fn drift_reason_rules() {
1081 assert!(validate_drift_reason(&"r".repeat(200)).is_ok());
1082 assert!(validate_drift_reason(&"r".repeat(DRIFT_REASON_MAX_CHARS)).is_ok());
1083 for bad in [
1084 String::new(),
1085 "r".repeat(DRIFT_REASON_MAX_CHARS + 1),
1086 "a\nb".to_string(),
1087 "a\rb".to_string(),
1088 "a\0b".to_string(),
1089 "a\u{1b}[31mb".to_string(),
1090 ] {
1091 let err = validate_drift_reason(&bad).expect_err(&bad);
1092 assert_eq!(err.code(), "invalid_input", "{bad:?}");
1093 assert_eq!(err.exit_code(), 2);
1094 }
1095 }
1096
1097 #[test]
1098 fn drift_reason_length_counts_characters_not_bytes() {
1099 assert!(validate_drift_reason(&"\u{e9}".repeat(DRIFT_REASON_MAX_CHARS)).is_ok());
1100 }
1101
1102 #[test]
1103 fn prose_is_stable_and_carries_no_content() {
1104 let mut d = destructive("ignition/a/x");
1105 d.diff = Some("SECRET-DIFF-BYTES".to_string());
1106 let plan = plan_of(vec![
1107 entry("ignition/a/y", PlanStatus::Added),
1108 d,
1109 entry("ignition/a/s", PlanStatus::Same),
1110 ]);
1111 let one = confirmation_prose(&plan);
1112 assert_eq!(one, confirmation_prose(&plan));
1113 assert!(one.contains(HASH), "{one}");
1114 assert!(one.contains("prod") && one.contains("gw") && one.contains("production"));
1115 assert!(
1116 one.contains("ignition/a/y") && one.contains("ignition/a/x"),
1117 "{one}"
1118 );
1119 assert!(
1120 !one.contains("ignition/a/s"),
1121 "same entries are not listed: {one}"
1122 );
1123 assert!(!one.contains("SECRET-DIFF-BYTES"), "{one}");
1124 assert!(one.contains("--yes"), "{one}");
1125 }
1126}