1use 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
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
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum Redundancy {
41 Standalone,
43 Pair,
45}
46
47impl Redundancy {
48 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
58pub 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
78pub 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
101pub 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
185fn 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
192fn 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
204pub 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
260pub 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
283pub 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 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 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 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 #[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 #[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 #[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 #[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 #[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}