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