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 evaluate_gates(
53 plan: &DeployPlanResult,
54 flags: &GateFlags,
55) -> Result<GateOutcome, CoreError> {
56 if let Some(hash) = &flags.plan_sha256 {
57 validate_plan_sha256(hash)?;
58 }
59 if let Some(reason) = &flags.allow_drift {
60 validate_drift_reason(reason)?;
61 }
62 if let Some(expected) = &flags.plan_sha256
63 && *expected != plan.plan_sha256
64 {
65 return Err(CoreError::PlanStale {
66 expected: expected.clone(),
67 actual: plan.plan_sha256.clone(),
68 detail: "review hash".to_string(),
69 });
70 }
71 if !plan.summary.has_changes {
72 return Ok(GateOutcome::Noop);
73 }
74
75 let gated = || {
76 plan.entries
77 .iter()
78 .filter(|e| e.status != PlanStatus::Unmanaged)
79 };
80 let blocked = sorted_keys(gated().filter(|e| e.core == CoreRelation::ShadowsRemoval));
81 if !blocked.is_empty() {
82 return Err(CoreError::DeployBlocked { keys: blocked });
83 }
84 if !flags.allow_destructive {
85 let keys = sorted_keys(gated().filter(|e| e.destructive));
86 if !keys.is_empty() {
87 return Err(refusal("destructive", keys));
88 }
89 }
90 if flags.allow_drift.is_none() {
91 let keys = sorted_keys(gated().filter(|e| e.drift == Some(true)));
92 if !keys.is_empty() {
93 return Err(refusal("drift", keys));
94 }
95 }
96 Ok(GateOutcome::Ready {
97 prose: confirmation_prose(plan),
98 })
99}
100
101fn sorted_keys<'a>(entries: impl Iterator<Item = &'a PlanEntry>) -> Vec<String> {
103 let mut keys: Vec<String> = entries.map(|e| e.key.clone()).collect();
104 keys.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
105 keys
106}
107
108fn refusal(gate: &str, mut keys: Vec<String>) -> CoreError {
111 let count = keys.len();
112 keys.truncate(APPLY_LIST_SHOWN);
113 CoreError::DestructiveChangeRefused {
114 gate: gate.to_string(),
115 keys,
116 count,
117 }
118}
119
120pub fn confirmation_prose(plan: &DeployPlanResult) -> String {
124 use std::fmt::Write as _;
125
126 let s = &plan.summary;
127 let mut out = String::new();
128 let _ = writeln!(
129 out,
130 "deploy apply: env {}, gateway {}, mode {}",
131 plan.env, plan.gateway, plan.mode
132 );
133 let _ = writeln!(
134 out,
135 "plan: {} added, {} changed, {} removed, {} same, {} unmanaged",
136 s.added, s.changed, s.removed, s.same, s.unmanaged
137 );
138 for entry in &plan.entries {
139 if matches!(entry.status, PlanStatus::Same | PlanStatus::Unmanaged) {
140 continue;
141 }
142 let mut flags: Vec<&str> = Vec::new();
143 if entry.destructive {
144 flags.push("destructive");
145 }
146 if entry.lockout_risk {
147 flags.push("lockout_risk");
148 }
149 if entry.drift == Some(true) {
150 flags.push("drift");
151 }
152 if !matches!(entry.core, CoreRelation::None | CoreRelation::Identical) {
153 flags.push(entry.core.word());
154 }
155 if flags.is_empty() {
156 let _ = writeln!(out, " {} {}", entry.status.word(), entry.key);
157 } else {
158 let _ = writeln!(
159 out,
160 " {} {} [{}]",
161 entry.status.word(),
162 entry.key,
163 flags.join(", ")
164 );
165 }
166 }
167 let _ = writeln!(out, "delete set: {} file(s)", plan.delete_set.files.len());
168 let _ = writeln!(out, "plan_sha256: {}", plan.plan_sha256);
169 out.push_str(
170 "rerun with --yes to apply this plan; when not interactive (--json, or stdin or \
171 stdout not a terminal) also pass --plan-sha256 with the hash above\n",
172 );
173 out
174}
175
176pub fn validate_drift_reason(reason: &str) -> Result<(), CoreError> {
180 let problem = if reason.is_empty() {
181 Some("must not be empty".to_string())
182 } else if reason.chars().count() > DRIFT_REASON_MAX_CHARS {
183 Some(format!(
184 "must be at most {DRIFT_REASON_MAX_CHARS} characters"
185 ))
186 } else if reason.chars().any(char::is_control) {
187 Some("must not contain control characters".to_string())
188 } else {
189 None
190 };
191 match problem {
192 None => Ok(()),
193 Some(problem) => Err(CoreError::InvalidInput {
194 reason: format!("--allow-drift reason {problem}"),
195 }),
196 }
197}
198
199pub fn validate_plan_sha256(value: &str) -> Result<(), CoreError> {
201 let ok = value.len() == 64
202 && value
203 .bytes()
204 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
205 if ok {
206 Ok(())
207 } else {
208 Err(CoreError::InvalidInput {
209 reason: "--plan-sha256 must be 64 lowercase hex characters (the plan_sha256 \
210 of the reviewed plan)"
211 .to_string(),
212 })
213 }
214}
215
216#[cfg(test)]
217mod tests {
218 use super::{
219 DRIFT_REASON_MAX_CHARS, GateFlags, GateOutcome, confirmation_prose, evaluate_gates,
220 validate_drift_reason, validate_plan_sha256,
221 };
222 use crate::actions::deploy::DeployPlanResult;
223 use crate::deploy::plan::{CoreRelation, PlanEntry, PlanStatus, PlanSummary};
224 use crate::deploy::transport::DeleteSet;
225
226 const HASH: &str = "ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12ab12";
227
228 fn entry(key: &str, status: PlanStatus) -> PlanEntry {
229 PlanEntry {
230 key: key.to_string(),
231 module: "ignition".to_string(),
232 ty: "database-translator".to_string(),
233 name: None,
234 status,
235 live_in: Vec::new(),
236 core: CoreRelation::None,
237 destructive: false,
238 destructive_reason: None,
239 lockout_risk: false,
240 drift: None,
241 files: Vec::new(),
242 diff: None,
243 }
244 }
245
246 fn plan_of(entries: Vec<PlanEntry>) -> DeployPlanResult {
247 let mut summary = PlanSummary::default();
248 for e in &entries {
249 match e.status {
250 PlanStatus::Added => summary.added += 1,
251 PlanStatus::Changed => summary.changed += 1,
252 PlanStatus::Removed => summary.removed += 1,
253 PlanStatus::Same => summary.same += 1,
254 PlanStatus::Unmanaged => summary.unmanaged += 1,
255 }
256 }
257 summary.has_changes = summary.added + summary.changed + summary.removed > 0;
258 DeployPlanResult {
259 env: "prod".to_string(),
260 gateway: "gw".to_string(),
261 mode: "production".to_string(),
262 node: "master".to_string(),
263 transport: "local".to_string(),
264 render_sha256: "0".repeat(64),
265 plan_sha256: HASH.to_string(),
266 summary,
267 entries,
268 delete_set: DeleteSet::default(),
269 base: None,
270 warnings: Vec::new(),
271 }
272 }
273
274 fn destructive(key: &str) -> PlanEntry {
275 let mut e = entry(key, PlanStatus::Removed);
276 e.destructive = true;
277 e.destructive_reason = Some("removes a resource".to_string());
278 e
279 }
280
281 fn drifted(key: &str) -> PlanEntry {
282 let mut e = entry(key, PlanStatus::Changed);
283 e.drift = Some(true);
284 e
285 }
286
287 fn flags(destructive: bool, drift: Option<&str>) -> GateFlags {
288 GateFlags {
289 plan_sha256: None,
290 allow_destructive: destructive,
291 allow_drift: drift.map(str::to_string),
292 }
293 }
294
295 fn refusal(plan: &DeployPlanResult, flags: &GateFlags) -> (String, u8, String) {
296 let err = evaluate_gates(plan, flags).expect_err("the gates refuse");
297 (err.code().to_string(), err.exit_code(), err.to_string())
298 }
299
300 #[test]
301 fn plan_sha256_accepts_64_lowercase_hex() {
302 assert!(validate_plan_sha256(&"a1".repeat(32)).is_ok());
303 assert!(validate_plan_sha256(&"0".repeat(64)).is_ok());
304 }
305
306 #[test]
307 fn plan_sha256_refuses_short_uppercase_and_non_hex() {
308 for bad in [
309 "a".repeat(63),
310 "A1".repeat(32),
311 "g".repeat(64),
312 String::new(),
313 format!("{} ", "a".repeat(63)),
314 ] {
315 let err = validate_plan_sha256(&bad).expect_err(&bad);
316 assert_eq!(err.code(), "invalid_input", "{bad:?}");
317 assert_eq!(err.exit_code(), 2);
318 }
319 }
320
321 #[test]
322 fn order_stale_beats_noop() {
323 let plan = plan_of(vec![entry("ignition/a/x", PlanStatus::Same)]);
324 let mut f = flags(false, None);
325 f.plan_sha256 = Some("1".repeat(64));
326 let (code, exit, _) = refusal(&plan, &f);
327 assert_eq!((code.as_str(), exit), ("plan_stale", 6));
328 }
329
330 #[test]
331 fn order_stale_beats_the_destructive_gate() {
332 let plan = plan_of(vec![destructive("ignition/a/x")]);
333 let mut f = flags(false, None);
334 f.plan_sha256 = Some("1".repeat(64));
335 assert_eq!(refusal(&plan, &f).0, "plan_stale");
336 }
337
338 #[test]
339 fn order_empty_plan_is_noop() {
340 let plan = plan_of(vec![
341 entry("ignition/a/x", PlanStatus::Same),
342 entry("ignition/a/y", PlanStatus::Unmanaged),
343 ]);
344 assert_eq!(
345 evaluate_gates(&plan, &flags(false, None)).expect("noop"),
346 GateOutcome::Noop
347 );
348 let mut matching = flags(false, None);
349 matching.plan_sha256 = Some(HASH.to_string());
350 assert_eq!(
351 evaluate_gates(&plan, &matching).expect("noop with its own hash"),
352 GateOutcome::Noop
353 );
354 }
355
356 #[test]
357 fn order_shadows_removal_blocks_even_with_both_flags() {
358 let mut shadowed = destructive("ignition/a/x");
359 shadowed.core = CoreRelation::ShadowsRemoval;
360 let plan = plan_of(vec![shadowed, entry("ignition/a/y", PlanStatus::Added)]);
361 let (code, exit, shown) = refusal(&plan, &flags(true, Some("reviewed drift")));
362 assert_eq!((code.as_str(), exit), ("deploy_blocked", 6));
363 assert!(shown.contains("ignition/a/x"), "{shown}");
364 assert!(!shown.contains("ignition/a/y"), "{shown}");
365 }
366
367 #[test]
368 fn order_shadows_removal_beats_the_destructive_gate() {
369 let mut shadowed = destructive("ignition/a/x");
370 shadowed.core = CoreRelation::ShadowsRemoval;
371 let plan = plan_of(vec![shadowed]);
372 assert_eq!(refusal(&plan, &flags(false, None)).0, "deploy_blocked");
373 }
374
375 #[test]
376 fn order_destructive_without_the_flag_is_refused() {
377 let plan = plan_of(vec![destructive("ignition/a/x")]);
378 let (code, exit, shown) = refusal(&plan, &flags(false, None));
379 assert_eq!((code.as_str(), exit), ("destructive_change_refused", 2));
380 assert!(shown.contains("destructive"), "{shown}");
381 assert!(shown.contains("ignition/a/x"), "{shown}");
382 }
383
384 #[test]
385 fn order_drift_without_the_reason_is_refused() {
386 let plan = plan_of(vec![drifted("ignition/a/x")]);
387 let (code, exit, shown) = refusal(&plan, &flags(true, None));
388 assert_eq!((code.as_str(), exit), ("destructive_change_refused", 2));
389 assert!(shown.contains("drift"), "{shown}");
390 assert!(shown.contains("ignition/a/x"), "{shown}");
391 }
392
393 #[test]
394 fn order_destructive_is_judged_before_drift() {
395 let plan = plan_of(vec![drifted("ignition/a/d"), destructive("ignition/a/x")]);
396 let (_, _, shown) = refusal(&plan, &flags(false, None));
397 assert!(shown.contains("ignition/a/x"), "{shown}");
398 assert!(!shown.contains("ignition/a/d"), "{shown}");
399 let (_, _, shown) = refusal(&plan, &flags(true, None));
400 assert!(shown.contains("ignition/a/d"), "{shown}");
401 }
402
403 #[test]
404 fn order_both_flags_make_it_ready() {
405 let plan = plan_of(vec![destructive("ignition/a/x"), drifted("ignition/a/y")]);
406 let outcome = evaluate_gates(&plan, &flags(true, Some("reviewed on the gateway")))
407 .expect("both flags");
408 assert!(matches!(outcome, GateOutcome::Ready { .. }), "{outcome:?}");
409 }
410
411 #[test]
412 fn unmanaged_entries_never_gate() {
413 let mut odd = entry("ignition/a/u", PlanStatus::Unmanaged);
414 odd.destructive = true;
415 odd.drift = Some(true);
416 odd.core = CoreRelation::ShadowsRemoval;
417 let plan = plan_of(vec![odd, entry("ignition/a/x", PlanStatus::Added)]);
418 let outcome = evaluate_gates(&plan, &flags(false, None)).expect("ready");
419 assert!(matches!(outcome, GateOutcome::Ready { .. }), "{outcome:?}");
420 }
421
422 #[test]
423 fn drift_none_and_false_never_gate() {
424 let mut no_drift = entry("ignition/a/x", PlanStatus::Changed);
425 no_drift.drift = Some(false);
426 let plan = plan_of(vec![no_drift, entry("ignition/a/y", PlanStatus::Added)]);
427 let outcome = evaluate_gates(&plan, &flags(false, None)).expect("ready");
428 assert!(matches!(outcome, GateOutcome::Ready { .. }), "{outcome:?}");
429 }
430
431 #[test]
432 fn refusal_keys_are_byte_sorted_and_capped_at_ten() {
433 let mut entries: Vec<PlanEntry> = (0..12)
434 .rev()
435 .map(|i| destructive(&format!("ignition/a/k{i:02}")))
436 .collect();
437 entries.push(entry("ignition/a/zz", PlanStatus::Added));
438 let plan = plan_of(entries);
439 let (_, _, shown) = refusal(&plan, &flags(false, None));
440 assert!(shown.contains("12"), "{shown}");
441 assert!(shown.contains("and 2 more"), "{shown}");
442 let first = shown.find("ignition/a/k00").expect("k00 shown");
443 let tenth = shown.find("ignition/a/k09").expect("k09 shown");
444 assert!(first < tenth, "{shown}");
445 assert!(!shown.contains("ignition/a/k10"), "{shown}");
446 assert!(!shown.contains("ignition/a/zz"), "{shown}");
447 }
448
449 #[test]
450 fn a_malformed_review_hash_is_invalid_input_not_stale() {
451 let plan = plan_of(vec![entry("ignition/a/x", PlanStatus::Added)]);
452 let mut f = flags(false, None);
453 f.plan_sha256 = Some("NOT-A-HASH".to_string());
454 let (code, exit, _) = refusal(&plan, &f);
455 assert_eq!((code.as_str(), exit), ("invalid_input", 2));
456 }
457
458 #[test]
459 fn drift_reason_is_validated_on_every_evaluation() {
460 let plan = plan_of(vec![entry("ignition/a/x", PlanStatus::Added)]);
461 for bad in ["", "two\nlines", "nul\0byte"] {
462 let (code, exit, _) = refusal(&plan, &flags(false, Some(bad)));
463 assert_eq!((code.as_str(), exit), ("invalid_input", 2), "{bad:?}");
464 }
465 }
466
467 #[test]
468 fn drift_reason_rules() {
469 assert!(validate_drift_reason(&"r".repeat(200)).is_ok());
470 assert!(validate_drift_reason(&"r".repeat(DRIFT_REASON_MAX_CHARS)).is_ok());
471 for bad in [
472 String::new(),
473 "r".repeat(DRIFT_REASON_MAX_CHARS + 1),
474 "a\nb".to_string(),
475 "a\rb".to_string(),
476 "a\0b".to_string(),
477 "a\u{1b}[31mb".to_string(),
478 ] {
479 let err = validate_drift_reason(&bad).expect_err(&bad);
480 assert_eq!(err.code(), "invalid_input", "{bad:?}");
481 assert_eq!(err.exit_code(), 2);
482 }
483 }
484
485 #[test]
486 fn drift_reason_length_counts_characters_not_bytes() {
487 assert!(validate_drift_reason(&"\u{e9}".repeat(DRIFT_REASON_MAX_CHARS)).is_ok());
488 }
489
490 #[test]
491 fn prose_is_stable_and_carries_no_content() {
492 let mut d = destructive("ignition/a/x");
493 d.diff = Some("SECRET-DIFF-BYTES".to_string());
494 let plan = plan_of(vec![
495 entry("ignition/a/y", PlanStatus::Added),
496 d,
497 entry("ignition/a/s", PlanStatus::Same),
498 ]);
499 let one = confirmation_prose(&plan);
500 assert_eq!(one, confirmation_prose(&plan));
501 assert!(one.contains(HASH), "{one}");
502 assert!(one.contains("prod") && one.contains("gw") && one.contains("production"));
503 assert!(
504 one.contains("ignition/a/y") && one.contains("ignition/a/x"),
505 "{one}"
506 );
507 assert!(
508 !one.contains("ignition/a/s"),
509 "same entries are not listed: {one}"
510 );
511 assert!(!one.contains("SECRET-DIFF-BYTES"), "{one}");
512 assert!(one.contains("--yes"), "{one}");
513 }
514}