1use crate::actions::deploy::DeployPlanResult;
7use crate::deploy::plan::{CoreRelation, PlanEntry, PlanStatus};
8use crate::error::{APPLY_LIST_SHOWN, CoreError};
9
10pub const DRIFT_REASON_MAX_CHARS: usize = 500;
12
13#[derive(Debug, Clone, Default, PartialEq, Eq)]
15pub struct GateFlags {
16 pub plan_sha256: Option<String>,
18 pub allow_destructive: bool,
20 pub allow_drift: Option<String>,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum GateOutcome {
27 Noop,
29 Ready {
31 prose: String,
33 },
34}
35
36pub fn unreadable_refusal(plan: &DeployPlanResult) -> Option<CoreError> {
42 let mut keys: Vec<String> = plan
43 .unreadable
44 .iter()
45 .filter(|u| u.declared || u.in_ledger)
46 .map(|u| u.key.clone())
47 .collect();
48 if keys.is_empty() {
49 return None;
50 }
51 keys.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
52 keys.dedup();
53 Some(CoreError::DeployUnreadable {
54 node: "master".to_string(),
55 keys,
56 })
57}
58
59pub fn evaluate_gates(
82 plan: &DeployPlanResult,
83 flags: &GateFlags,
84) -> Result<GateOutcome, CoreError> {
85 if let Some(hash) = &flags.plan_sha256 {
86 validate_plan_sha256(hash)?;
87 }
88 if let Some(reason) = &flags.allow_drift {
89 validate_drift_reason(reason)?;
90 }
91 if let Some(expected) = &flags.plan_sha256
92 && *expected != plan.plan_sha256
93 {
94 return Err(CoreError::PlanStale {
95 expected: expected.clone(),
96 actual: plan.plan_sha256.clone(),
97 detail: "review hash".to_string(),
98 });
99 }
100 if let Some(refusal) = unreadable_refusal(plan) {
101 return Err(refusal);
102 }
103 if !plan.summary.has_changes {
104 return Ok(GateOutcome::Noop);
105 }
106
107 let gated = || {
108 plan.entries
109 .iter()
110 .filter(|e| e.status != PlanStatus::Unmanaged)
111 };
112 let blocked = sorted_keys(gated().filter(|e| e.core == CoreRelation::ShadowsRemoval));
113 if !blocked.is_empty() {
114 return Err(CoreError::DeployBlocked { keys: blocked });
115 }
116 if !flags.allow_destructive {
117 let keys = sorted_keys(gated().filter(|e| e.destructive));
118 if !keys.is_empty() {
119 return Err(refusal("destructive", keys));
120 }
121 }
122 if flags.allow_drift.is_none() {
123 let keys = sorted_keys(gated().filter(|e| e.drift == Some(true)));
124 if !keys.is_empty() {
125 return Err(refusal("drift", keys));
126 }
127 }
128 Ok(GateOutcome::Ready {
129 prose: confirmation_prose(plan),
130 })
131}
132
133fn sorted_keys<'a>(entries: impl Iterator<Item = &'a PlanEntry>) -> Vec<String> {
135 let mut keys: Vec<String> = entries.map(|e| e.key.clone()).collect();
136 keys.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
137 keys
138}
139
140fn refusal(gate: &str, mut keys: Vec<String>) -> CoreError {
143 let count = keys.len();
144 keys.truncate(APPLY_LIST_SHOWN);
145 CoreError::DestructiveChangeRefused {
146 gate: gate.to_string(),
147 keys,
148 count,
149 }
150}
151
152pub fn confirmation_prose(plan: &DeployPlanResult) -> String {
156 use std::fmt::Write as _;
157
158 let s = &plan.summary;
159 let mut out = String::new();
160 let _ = writeln!(
161 out,
162 "deploy apply: env {}, gateway {}, mode {}",
163 plan.env, plan.gateway, plan.mode
164 );
165 let _ = writeln!(
166 out,
167 "plan: {} added, {} changed, {} removed, {} same, {} unmanaged",
168 s.added, s.changed, s.removed, s.same, s.unmanaged
169 );
170 for entry in &plan.entries {
171 if matches!(entry.status, PlanStatus::Same | PlanStatus::Unmanaged) {
172 continue;
173 }
174 let mut flags: Vec<&str> = Vec::new();
175 if entry.destructive {
176 flags.push("destructive");
177 }
178 if entry.lockout_risk {
179 flags.push("lockout_risk");
180 }
181 if entry.drift == Some(true) {
182 flags.push("drift");
183 }
184 if !matches!(entry.core, CoreRelation::None | CoreRelation::Identical) {
185 flags.push(entry.core.word());
186 }
187 if flags.is_empty() {
188 let _ = writeln!(out, " {} {}", entry.status.word(), entry.key);
189 } else {
190 let _ = writeln!(
191 out,
192 " {} {} [{}]",
193 entry.status.word(),
194 entry.key,
195 flags.join(", ")
196 );
197 }
198 }
199 let _ = writeln!(out, "delete set: {} file(s)", plan.delete_set.files.len());
200 let _ = writeln!(out, "plan_sha256: {}", plan.plan_sha256);
201 out.push_str(
202 "rerun with --yes to apply this plan; when not interactive (--json, or stdin or \
203 stdout not a terminal) also pass --plan-sha256 with the hash above\n",
204 );
205 out
206}
207
208pub fn validate_drift_reason(reason: &str) -> Result<(), CoreError> {
212 let problem = if reason.is_empty() {
213 Some("must not be empty".to_string())
214 } else if reason.chars().count() > DRIFT_REASON_MAX_CHARS {
215 Some(format!(
216 "must be at most {DRIFT_REASON_MAX_CHARS} characters"
217 ))
218 } else if reason.chars().any(char::is_control) {
219 Some("must not contain control characters".to_string())
220 } else {
221 None
222 };
223 match problem {
224 None => Ok(()),
225 Some(problem) => Err(CoreError::InvalidInput {
226 reason: format!("--allow-drift reason {problem}"),
227 }),
228 }
229}
230
231pub fn validate_plan_sha256(value: &str) -> Result<(), CoreError> {
233 let ok = value.len() == 64
234 && value
235 .bytes()
236 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
237 if ok {
238 Ok(())
239 } else {
240 Err(CoreError::InvalidInput {
241 reason: "--plan-sha256 must be 64 lowercase hex characters (the plan_sha256 \
242 of the reviewed plan)"
243 .to_string(),
244 })
245 }
246}
247
248#[cfg(test)]
249mod tests {
250 use super::{
251 DRIFT_REASON_MAX_CHARS, GateFlags, GateOutcome, confirmation_prose, evaluate_gates,
252 validate_drift_reason, validate_plan_sha256,
253 };
254 use crate::actions::deploy::DeployPlanResult;
255 use crate::deploy::plan::{CoreRelation, PlanEntry, PlanStatus, PlanSummary, UnreadableUnit};
256 use crate::deploy::transport::DeleteSet;
257
258 const HASH: &str = "ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12";
259
260 fn entry(key: &str, status: PlanStatus) -> PlanEntry {
261 PlanEntry {
262 key: key.to_string(),
263 module: "ignition".to_string(),
264 ty: "database-translator".to_string(),
265 name: None,
266 status,
267 live_in: Vec::new(),
268 core: CoreRelation::None,
269 destructive: false,
270 destructive_reason: None,
271 lockout_risk: false,
272 drift: None,
273 files: Vec::new(),
274 diff: None,
275 }
276 }
277
278 fn plan_of(entries: Vec<PlanEntry>) -> DeployPlanResult {
279 let mut summary = PlanSummary::default();
280 for e in &entries {
281 match e.status {
282 PlanStatus::Added => summary.added += 1,
283 PlanStatus::Changed => summary.changed += 1,
284 PlanStatus::Removed => summary.removed += 1,
285 PlanStatus::Same => summary.same += 1,
286 PlanStatus::Unmanaged => summary.unmanaged += 1,
287 }
288 }
289 summary.has_changes = summary.added + summary.changed + summary.removed > 0;
290 DeployPlanResult {
291 env: "prod".to_string(),
292 gateway: "gw".to_string(),
293 mode: "production".to_string(),
294 node: "master".to_string(),
295 transport: "local".to_string(),
296 render_sha256: "0".repeat(64),
297 plan_sha256: HASH.to_string(),
298 summary,
299 entries,
300 delete_set: DeleteSet::default(),
301 base: None,
302 warnings: Vec::new(),
303 excluded_tag_providers: vec!["System".to_string()],
304 unreadable: Vec::new(),
305 }
306 }
307
308 fn destructive(key: &str) -> PlanEntry {
309 let mut e = entry(key, PlanStatus::Removed);
310 e.destructive = true;
311 e.destructive_reason = Some("removes a resource".to_string());
312 e
313 }
314
315 fn drifted(key: &str) -> PlanEntry {
316 let mut e = entry(key, PlanStatus::Changed);
317 e.drift = Some(true);
318 e
319 }
320
321 fn flags(destructive: bool, drift: Option<&str>) -> GateFlags {
322 GateFlags {
323 plan_sha256: None,
324 allow_destructive: destructive,
325 allow_drift: drift.map(str::to_string),
326 }
327 }
328
329 fn refusal(plan: &DeployPlanResult, flags: &GateFlags) -> (String, u8, String) {
330 let err = evaluate_gates(plan, flags).expect_err("the gates refuse");
331 (err.code().to_string(), err.exit_code(), err.to_string())
332 }
333
334 #[test]
335 fn plan_sha256_accepts_64_lowercase_hex() {
336 assert!(validate_plan_sha256(&"a1".repeat(32)).is_ok());
337 assert!(validate_plan_sha256(&"0".repeat(64)).is_ok());
338 }
339
340 #[test]
341 fn plan_sha256_refuses_short_uppercase_and_non_hex() {
342 for bad in [
343 "a".repeat(63),
344 "A1".repeat(32),
345 "g".repeat(64),
346 String::new(),
347 format!("{} ", "a".repeat(63)),
348 ] {
349 let err = validate_plan_sha256(&bad).expect_err(&bad);
350 assert_eq!(err.code(), "invalid_input", "{bad:?}");
351 assert_eq!(err.exit_code(), 2);
352 }
353 }
354
355 #[test]
356 fn order_stale_beats_noop() {
357 let plan = plan_of(vec![entry("ignition/a/x", PlanStatus::Same)]);
358 let mut f = flags(false, None);
359 f.plan_sha256 = Some("1".repeat(64));
360 let (code, exit, _) = refusal(&plan, &f);
361 assert_eq!((code.as_str(), exit), ("plan_stale", 6));
362 }
363
364 #[test]
365 fn order_stale_beats_the_destructive_gate() {
366 let plan = plan_of(vec![destructive("ignition/a/x")]);
367 let mut f = flags(false, None);
368 f.plan_sha256 = Some("1".repeat(64));
369 assert_eq!(refusal(&plan, &f).0, "plan_stale");
370 }
371
372 #[test]
373 fn order_empty_plan_is_noop() {
374 let plan = plan_of(vec![
375 entry("ignition/a/x", PlanStatus::Same),
376 entry("ignition/a/y", PlanStatus::Unmanaged),
377 ]);
378 assert_eq!(
379 evaluate_gates(&plan, &flags(false, None)).expect("noop"),
380 GateOutcome::Noop
381 );
382 let mut matching = flags(false, None);
383 matching.plan_sha256 = Some(HASH.to_string());
384 assert_eq!(
385 evaluate_gates(&plan, &matching).expect("noop with its own hash"),
386 GateOutcome::Noop
387 );
388 }
389
390 fn unreadable(key: &str, declared: bool, in_ledger: bool) -> UnreadableUnit {
391 UnreadableUnit {
392 key: key.to_string(),
393 live_in: vec!["mode".to_string()],
394 declared,
395 in_ledger,
396 paths: vec![format!("{key}/config.json")],
397 longest: 262,
398 }
399 }
400
401 fn with_unreadable(mut plan: DeployPlanResult, units: Vec<UnreadableUnit>) -> DeployPlanResult {
404 plan.summary.unreadable = units.len();
405 plan.summary.has_changes |= units.iter().any(|u| u.declared || u.in_ledger);
406 plan.unreadable = units;
407 plan
408 }
409
410 #[test]
411 fn unreadable_declared_unit_is_refused_deploy_unreadable() {
412 let plan = with_unreadable(
413 plan_of(vec![entry("ignition/a/x", PlanStatus::Same)]),
414 vec![unreadable(
415 "ignition/tag-definition/default/Deep",
416 true,
417 false,
418 )],
419 );
420 assert!(plan.summary.has_changes);
421 let (code, exit, text) = refusal(&plan, &flags(true, Some("ok")));
422 assert_eq!((code.as_str(), exit), ("deploy_unreadable", 6));
423 assert!(
424 text.contains("ignition/tag-definition/default/Deep"),
425 "{text}"
426 );
427 }
428
429 #[test]
430 fn unreadable_in_ledger_unit_is_refused_before_the_noop_return() {
431 let mut plan = with_unreadable(
432 plan_of(vec![entry("ignition/a/x", PlanStatus::Same)]),
433 vec![unreadable("ignition/database-translator/OLD", false, true)],
434 );
435 plan.summary.has_changes = false;
436 let (code, exit, _) = refusal(&plan, &flags(false, None));
437 assert_eq!((code.as_str(), exit), ("deploy_unreadable", 6));
438 }
439
440 #[test]
441 fn unreadable_keys_are_byte_sorted_and_only_the_ones_that_matter() {
442 let plan = with_unreadable(
443 plan_of(Vec::new()),
444 vec![
445 unreadable("ignition/b/z", true, false),
446 unreadable("ignition/a/undeclared", false, false),
447 unreadable("ignition/B/y", false, true),
448 ],
449 );
450 let err = evaluate_gates(&plan, &flags(false, None)).expect_err("refused");
451 match err {
452 crate::error::CoreError::DeployUnreadable { node, keys } => {
453 assert_eq!(node, "master");
454 assert_eq!(keys, vec!["ignition/B/y", "ignition/b/z"]);
455 }
456 other => panic!("expected DeployUnreadable, got {other:?}"),
457 }
458 }
459
460 #[test]
461 fn unreadable_stale_review_hash_is_still_plan_stale_first() {
462 let plan = with_unreadable(
463 plan_of(Vec::new()),
464 vec![unreadable("ignition/a/x", true, true)],
465 );
466 let mut f = flags(false, None);
467 f.plan_sha256 = Some("1".repeat(64));
468 assert_eq!(refusal(&plan, &f).0, "plan_stale");
469 }
470
471 #[test]
472 fn unreadable_undeclared_unledgered_unit_is_noop_when_nothing_else_changes() {
473 let plan = with_unreadable(
474 plan_of(vec![entry("ignition/a/x", PlanStatus::Same)]),
475 vec![unreadable(
476 "ignition/tag-definition/MQTT/Edge",
477 false,
478 false,
479 )],
480 );
481 assert_eq!(
482 evaluate_gates(&plan, &flags(false, None)).expect("noop"),
483 GateOutcome::Noop
484 );
485 }
486
487 #[test]
488 fn unreadable_undeclared_unledgered_unit_is_ready_when_another_entry_changes() {
489 let plan = with_unreadable(
490 plan_of(vec![entry("ignition/a/x", PlanStatus::Changed)]),
491 vec![unreadable(
492 "ignition/tag-definition/MQTT/Edge",
493 false,
494 false,
495 )],
496 );
497 assert!(matches!(
498 evaluate_gates(&plan, &flags(false, None)).expect("ready"),
499 GateOutcome::Ready { .. }
500 ));
501 }
502
503 #[test]
504 fn order_shadows_removal_blocks_even_with_both_flags() {
505 let mut shadowed = destructive("ignition/a/x");
506 shadowed.core = CoreRelation::ShadowsRemoval;
507 let plan = plan_of(vec![shadowed, entry("ignition/a/y", PlanStatus::Added)]);
508 let (code, exit, shown) = refusal(&plan, &flags(true, Some("reviewed drift")));
509 assert_eq!((code.as_str(), exit), ("deploy_blocked", 6));
510 assert!(shown.contains("ignition/a/x"), "{shown}");
511 assert!(!shown.contains("ignition/a/y"), "{shown}");
512 }
513
514 #[test]
515 fn order_shadows_removal_beats_the_destructive_gate() {
516 let mut shadowed = destructive("ignition/a/x");
517 shadowed.core = CoreRelation::ShadowsRemoval;
518 let plan = plan_of(vec![shadowed]);
519 assert_eq!(refusal(&plan, &flags(false, None)).0, "deploy_blocked");
520 }
521
522 #[test]
523 fn order_destructive_without_the_flag_is_refused() {
524 let plan = plan_of(vec![destructive("ignition/a/x")]);
525 let (code, exit, shown) = refusal(&plan, &flags(false, None));
526 assert_eq!((code.as_str(), exit), ("destructive_change_refused", 2));
527 assert!(shown.contains("destructive"), "{shown}");
528 assert!(shown.contains("ignition/a/x"), "{shown}");
529 }
530
531 #[test]
532 fn order_drift_without_the_reason_is_refused() {
533 let plan = plan_of(vec![drifted("ignition/a/x")]);
534 let (code, exit, shown) = refusal(&plan, &flags(true, None));
535 assert_eq!((code.as_str(), exit), ("destructive_change_refused", 2));
536 assert!(shown.contains("drift"), "{shown}");
537 assert!(shown.contains("ignition/a/x"), "{shown}");
538 }
539
540 #[test]
541 fn order_destructive_is_judged_before_drift() {
542 let plan = plan_of(vec![drifted("ignition/a/d"), destructive("ignition/a/x")]);
543 let (_, _, shown) = refusal(&plan, &flags(false, None));
544 assert!(shown.contains("ignition/a/x"), "{shown}");
545 assert!(!shown.contains("ignition/a/d"), "{shown}");
546 let (_, _, shown) = refusal(&plan, &flags(true, None));
547 assert!(shown.contains("ignition/a/d"), "{shown}");
548 }
549
550 #[test]
551 fn order_both_flags_make_it_ready() {
552 let plan = plan_of(vec![destructive("ignition/a/x"), drifted("ignition/a/y")]);
553 let outcome = evaluate_gates(&plan, &flags(true, Some("reviewed on the gateway")))
554 .expect("both flags");
555 assert!(matches!(outcome, GateOutcome::Ready { .. }), "{outcome:?}");
556 }
557
558 #[test]
559 fn unmanaged_entries_never_gate() {
560 let mut odd = entry("ignition/a/u", PlanStatus::Unmanaged);
561 odd.destructive = true;
562 odd.drift = Some(true);
563 odd.core = CoreRelation::ShadowsRemoval;
564 let plan = plan_of(vec![odd, entry("ignition/a/x", PlanStatus::Added)]);
565 let outcome = evaluate_gates(&plan, &flags(false, None)).expect("ready");
566 assert!(matches!(outcome, GateOutcome::Ready { .. }), "{outcome:?}");
567 }
568
569 #[test]
570 fn drift_none_and_false_never_gate() {
571 let mut no_drift = entry("ignition/a/x", PlanStatus::Changed);
572 no_drift.drift = Some(false);
573 let plan = plan_of(vec![no_drift, entry("ignition/a/y", PlanStatus::Added)]);
574 let outcome = evaluate_gates(&plan, &flags(false, None)).expect("ready");
575 assert!(matches!(outcome, GateOutcome::Ready { .. }), "{outcome:?}");
576 }
577
578 #[test]
579 fn refusal_keys_are_byte_sorted_and_capped_at_ten() {
580 let mut entries: Vec<PlanEntry> = (0..12)
581 .rev()
582 .map(|i| destructive(&format!("ignition/a/k{i:02}")))
583 .collect();
584 entries.push(entry("ignition/a/zz", PlanStatus::Added));
585 let plan = plan_of(entries);
586 let (_, _, shown) = refusal(&plan, &flags(false, None));
587 assert!(shown.contains("12"), "{shown}");
588 assert!(shown.contains("and 2 more"), "{shown}");
589 let first = shown.find("ignition/a/k00").expect("k00 shown");
590 let tenth = shown.find("ignition/a/k09").expect("k09 shown");
591 assert!(first < tenth, "{shown}");
592 assert!(!shown.contains("ignition/a/k10"), "{shown}");
593 assert!(!shown.contains("ignition/a/zz"), "{shown}");
594 }
595
596 #[test]
597 fn a_malformed_review_hash_is_invalid_input_not_stale() {
598 let plan = plan_of(vec![entry("ignition/a/x", PlanStatus::Added)]);
599 let mut f = flags(false, None);
600 f.plan_sha256 = Some("NOT-A-HASH".to_string());
601 let (code, exit, _) = refusal(&plan, &f);
602 assert_eq!((code.as_str(), exit), ("invalid_input", 2));
603 }
604
605 #[test]
606 fn drift_reason_is_validated_on_every_evaluation() {
607 let plan = plan_of(vec![entry("ignition/a/x", PlanStatus::Added)]);
608 for bad in ["", "two\nlines", "nul\0byte"] {
609 let (code, exit, _) = refusal(&plan, &flags(false, Some(bad)));
610 assert_eq!((code.as_str(), exit), ("invalid_input", 2), "{bad:?}");
611 }
612 }
613
614 #[test]
615 fn drift_reason_rules() {
616 assert!(validate_drift_reason(&"r".repeat(200)).is_ok());
617 assert!(validate_drift_reason(&"r".repeat(DRIFT_REASON_MAX_CHARS)).is_ok());
618 for bad in [
619 String::new(),
620 "r".repeat(DRIFT_REASON_MAX_CHARS + 1),
621 "a\nb".to_string(),
622 "a\rb".to_string(),
623 "a\0b".to_string(),
624 "a\u{1b}[31mb".to_string(),
625 ] {
626 let err = validate_drift_reason(&bad).expect_err(&bad);
627 assert_eq!(err.code(), "invalid_input", "{bad:?}");
628 assert_eq!(err.exit_code(), 2);
629 }
630 }
631
632 #[test]
633 fn drift_reason_length_counts_characters_not_bytes() {
634 assert!(validate_drift_reason(&"\u{e9}".repeat(DRIFT_REASON_MAX_CHARS)).is_ok());
635 }
636
637 #[test]
638 fn prose_is_stable_and_carries_no_content() {
639 let mut d = destructive("ignition/a/x");
640 d.diff = Some("SECRET-DIFF-BYTES".to_string());
641 let plan = plan_of(vec![
642 entry("ignition/a/y", PlanStatus::Added),
643 d,
644 entry("ignition/a/s", PlanStatus::Same),
645 ]);
646 let one = confirmation_prose(&plan);
647 assert_eq!(one, confirmation_prose(&plan));
648 assert!(one.contains(HASH), "{one}");
649 assert!(one.contains("prod") && one.contains("gw") && one.contains("production"));
650 assert!(
651 one.contains("ignition/a/y") && one.contains("ignition/a/x"),
652 "{one}"
653 );
654 assert!(
655 !one.contains("ignition/a/s"),
656 "same entries are not listed: {one}"
657 );
658 assert!(!one.contains("SECRET-DIFF-BYTES"), "{one}");
659 assert!(one.contains("--yes"), "{one}");
660 }
661}