1use std::collections::{BTreeMap, BTreeSet};
35use std::time::Duration;
36
37use super::{ApplyEvent, Steps};
38use serde_json::Value;
39
40use super::hold::{Clock, HoldModel, hold_for};
41use super::host::ApplyHost;
42use crate::actions::deploy::Declared;
43use crate::client::mode::normalize_deployment_mode;
44use crate::client::query::ListQuery;
45use crate::client::redundancy::{ActiveNode, RedundancyStatusWire, active_node};
46use crate::client::{DeployApi, GatewayApi};
47use crate::deploy::layout::RelPath;
48use crate::deploy::pair::{
49 BackupNode, FileComparison, SecretCheckMode, check_secret_names, collect_referenced_secrets,
50 declared_secret_providers, exempt_unreadable, secrets_defined_by_render, unreadable_units,
51};
52use crate::deploy::target::Target;
53use crate::deploy::transport::{ObservedFiles, Scope, TreeReader};
54use crate::error::{APPLY_LIST_SHOWN, CoreError, PreflightFailure};
55
56pub const DESIGNERS_POLL_S: u64 = 10;
59
60pub const DESIGNERS_TIMEOUT_DEFAULT_S: u64 = 600;
62
63const SHOWN_NAME_CHARS: usize = 64;
65
66const SYSTEM_PROPERTIES_UNIT: &str = "ignition/system-properties";
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum PreflightCheck {
72 ModeMaster,
74 ModeBackup,
76 WrongTarget,
78 WriteRest,
80 WriteHost,
82 Designers,
84 HoldBudget,
86 MasterActive,
88 BackupRead,
90 Secrets,
92}
93
94impl PreflightCheck {
95 pub const ALL: [PreflightCheck; 10] = [
97 PreflightCheck::ModeMaster,
98 PreflightCheck::ModeBackup,
99 PreflightCheck::WrongTarget,
100 PreflightCheck::WriteRest,
101 PreflightCheck::WriteHost,
102 PreflightCheck::Designers,
103 PreflightCheck::HoldBudget,
104 PreflightCheck::MasterActive,
105 PreflightCheck::BackupRead,
106 PreflightCheck::Secrets,
107 ];
108
109 pub fn as_str(self) -> &'static str {
111 match self {
112 PreflightCheck::ModeMaster => "mode_master",
113 PreflightCheck::ModeBackup => "mode_backup",
114 PreflightCheck::WrongTarget => "wrong_target",
115 PreflightCheck::WriteRest => "write_rest",
116 PreflightCheck::WriteHost => "write_host",
117 PreflightCheck::Designers => "designers",
118 PreflightCheck::HoldBudget => "hold_budget",
119 PreflightCheck::MasterActive => "master_active",
120 PreflightCheck::BackupRead => "backup_read",
121 PreflightCheck::Secrets => "secrets",
122 }
123 }
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub enum FindingStatus {
129 Pass,
131 Fail,
133 Skipped,
136}
137
138impl FindingStatus {
139 pub fn as_str(self) -> &'static str {
141 match self {
142 FindingStatus::Pass => "pass",
143 FindingStatus::Fail => "fail",
144 FindingStatus::Skipped => "skipped",
145 }
146 }
147}
148
149#[derive(Debug, Clone, PartialEq, Eq)]
151pub struct PreflightFinding {
152 pub check: PreflightCheck,
154 pub status: FindingStatus,
156 pub detail: String,
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub enum DesignersMode {
163 Fail,
165 Wait,
167 Proceed,
169}
170
171impl DesignersMode {
172 pub fn as_str(self) -> &'static str {
174 match self {
175 DesignersMode::Fail => "fail",
176 DesignersMode::Wait => "wait",
177 DesignersMode::Proceed => "proceed",
178 }
179 }
180}
181
182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184pub struct DesignersPolicy {
185 pub mode: DesignersMode,
187 pub timeout: Duration,
189 pub interval: Duration,
191}
192
193impl Default for DesignersPolicy {
194 fn default() -> Self {
196 DesignersPolicy {
197 mode: DesignersMode::Fail,
198 timeout: Duration::from_secs(DESIGNERS_TIMEOUT_DEFAULT_S),
199 interval: Duration::from_secs(DESIGNERS_POLL_S),
200 }
201 }
202}
203
204pub struct PreflightInput<'a> {
206 pub target: &'a Target,
208 pub declared: Option<&'a Declared>,
211 pub master: &'a dyn DeployApi,
213 pub backup: Option<&'a BackupNode<'a>>,
215 pub gateway: &'a dyn GatewayApi,
217 pub reader: &'a dyn TreeReader,
219 pub host: &'a dyn ApplyHost,
221 pub touched_files: usize,
223 pub touched_paths: &'a [String],
226 pub deleted_paths: &'a [String],
231 pub hold: &'a HoldModel,
233 pub designers: &'a DesignersPolicy,
235 pub clock: &'a dyn Clock,
237}
238
239pub type DesignerSeen = (String, String, String);
241
242#[derive(Debug, Clone, PartialEq, Eq)]
244pub struct PreflightReport {
245 pub findings: Vec<PreflightFinding>,
247 pub designers_seen: Vec<DesignerSeen>,
249 pub backup_unreadable: Vec<String>,
253}
254
255impl PreflightReport {
256 pub fn passed(&self) -> bool {
258 self.findings
259 .iter()
260 .all(|f| f.status != FindingStatus::Fail)
261 }
262
263 pub fn failures(&self) -> Vec<PreflightFailure> {
265 self.findings
266 .iter()
267 .filter(|f| f.status == FindingStatus::Fail)
268 .map(|f| PreflightFailure {
269 check: f.check.as_str().to_string(),
270 detail: f.detail.clone(),
271 })
272 .collect()
273 }
274}
275
276pub async fn run_preflights(
283 input: &PreflightInput<'_>,
284 steps: &mut Steps<'_>,
285) -> Result<PreflightReport, CoreError> {
286 let mut findings = Vec::with_capacity(PreflightCheck::ALL.len());
287 let mode = input.target.mode.as_str();
288
289 let master_seen = normalize_deployment_mode(input.master.deployment_mode().await?);
290 findings.push(mode_finding(
291 PreflightCheck::ModeMaster,
292 "master",
293 Some(master_seen),
294 mode,
295 ));
296 findings.push(if input.target.nodes.backup.is_some() {
297 let backup_seen = match input.backup {
298 Some(backup) => Some(backup.deployment_mode().await?),
299 None => None,
300 };
301 mode_finding(PreflightCheck::ModeBackup, "backup", backup_seen, mode)
302 } else {
303 PreflightFinding {
304 check: PreflightCheck::ModeBackup,
305 status: FindingStatus::Skipped,
306 detail: "the target has no backup node".to_string(),
307 }
308 });
309 findings.push(wrong_target_finding(input).await?);
310 findings.push(write_rest_finding(input.gateway).await?);
311 findings.push(write_host_finding(input.host, input.touched_paths).await?);
312 let (designers, designers_seen) = designers_finding(input, steps).await?;
313 findings.push(designers);
314 findings.push(hold_budget_finding(input.hold, input.touched_files));
315 findings.push(master_active_finding(input).await?);
316 let (backup_read, backup_unreadable) = backup_read_finding(input).await;
317 findings.push(backup_read);
318 findings.push(secrets_finding(input).await?);
319
320 Ok(PreflightReport {
321 findings,
322 designers_seen,
323 backup_unreadable,
324 })
325}
326
327fn finding(check: PreflightCheck, ok: bool, detail: String) -> PreflightFinding {
328 PreflightFinding {
329 check,
330 status: if ok {
331 FindingStatus::Pass
332 } else {
333 FindingStatus::Fail
334 },
335 detail,
336 }
337}
338
339fn printable(text: &str) -> String {
341 let mut out: String = text
342 .chars()
343 .filter(|c| !c.is_control())
344 .take(SHOWN_NAME_CHARS)
345 .collect();
346 if text.chars().count() > SHOWN_NAME_CHARS {
347 out.push_str("...");
348 }
349 out
350}
351
352fn mode_finding(
357 check: PreflightCheck,
358 node: &str,
359 seen: Option<Option<String>>,
360 target_mode: &str,
361) -> PreflightFinding {
362 let Some(seen) = seen else {
363 return finding(
366 check,
367 false,
368 format!("the {node} node has no gateway connection, so its mode cannot be confirmed"),
369 );
370 };
371 let ok = seen.as_deref() == Some(target_mode);
372 let detail = match (&seen, ok) {
373 (_, true) => format!("the {node} runs mode `{}`", printable(target_mode)),
374 (Some(seen), false) => format!(
375 "the {node} runs mode `{}` but the target's mode is `{}`",
376 printable(seen),
377 printable(target_mode)
378 ),
379 (None, false) => format!(
380 "the {node} runs no deployment mode but the target's mode is `{}`",
381 printable(target_mode)
382 ),
383 };
384 finding(check, ok, detail)
385}
386
387async fn host_system_name(
391 reader: &dyn TreeReader,
392 mode: &str,
393) -> Result<Result<String, &'static str>, CoreError> {
394 let (module, ty) = SYSTEM_PROPERTIES_UNIT
395 .split_once('/')
396 .unwrap_or((SYSTEM_PROPERTIES_UNIT, ""));
397 let observed = reader
398 .read(&Scope {
399 mode: mode.to_string(),
400 types: vec![(module.to_string(), ty.to_string())],
401 tag_providers: Vec::new(),
402 })
403 .await?;
404 if observed
407 .local_units
408 .iter()
409 .any(|u| u == SYSTEM_PROPERTIES_UNIT)
410 {
411 return Ok(Err("it is overridden in the local collection"));
412 }
413 let unreadable_in = |collection: &str| {
419 observed.unreadable.iter().any(|u| {
420 u.collection == collection
421 && u.path
422 .as_str()
423 .strip_prefix(SYSTEM_PROPERTIES_UNIT)
424 .is_some_and(|rest| rest.starts_with('/'))
425 })
426 };
427 if unreadable_in("mode") {
428 return Ok(Err(
429 "the mode collection's system-properties file has a path too long for the host's sftp server to read",
430 ));
431 }
432 let path = RelPath::new(&format!("{SYSTEM_PROPERTIES_UNIT}/config.json"))
433 .map_err(|why| CoreError::Internal(format!("system-properties path: {why}")))?;
434 let blob = match observed.mode.get(&path) {
435 Some(blob) => blob,
436 None if unreadable_in("core") => {
437 return Ok(Err(
438 "the core collection's system-properties file has a path too long for the host's sftp server to read",
439 ));
440 }
441 None => match observed.core.get(&path) {
442 Some(blob) => blob,
443 None => return Ok(Err("the host has no system-properties file")),
444 },
445 };
446 let Ok(doc) = serde_json::from_slice::<Value>(&blob.bytes) else {
447 return Ok(Err("the system-properties file is not JSON"));
448 };
449 match doc.get("systemName").and_then(Value::as_str) {
450 Some(name) if !name.trim().is_empty() => Ok(Ok(name.to_string())),
451 _ => Ok(Err("the system-properties file has no systemName")),
452 }
453}
454
455pub(crate) fn wrong_target_problems(
463 rest: Option<&str>,
464 host: Result<&str, &str>,
465 declared: Option<&str>,
466) -> Vec<String> {
467 let mut problems: Vec<String> = Vec::new();
468 match (rest, host) {
469 (None, _) => problems.push("the gateway reports no name over REST".to_string()),
470 (Some(_), Err(why)) => problems.push(format!("the host's systemName is unreadable: {why}")),
471 (Some(rest), Ok(host)) if rest != host => {
472 problems.push("the gateway's REST name is not the host's systemName".to_string())
473 }
474 (Some(_), Ok(_)) => {}
475 }
476 if let (Some(rest), Some(declared)) = (rest, declared)
477 && rest != declared
478 {
479 problems.push("the gateway's REST name is not nodes.master.gateway_name".to_string());
480 }
481 problems
482}
483
484pub(crate) fn wrong_target_failure(problems: &[String]) -> PreflightFailure {
487 PreflightFailure {
488 check: PreflightCheck::WrongTarget.as_str().to_string(),
489 detail: problems.join("; "),
490 }
491}
492
493pub(crate) async fn wrong_target_gate(
498 rest: Option<&str>,
499 reader: &dyn TreeReader,
500 mode: &str,
501 declared: Option<&str>,
502) -> Result<Vec<String>, CoreError> {
503 let host = host_system_name(reader, mode).await?;
504 let host = host.as_ref().map(String::as_str).map_err(|why| *why);
505 Ok(wrong_target_problems(rest, host, declared))
506}
507
508async fn wrong_target_finding(input: &PreflightInput<'_>) -> Result<PreflightFinding, CoreError> {
509 let check = PreflightCheck::WrongTarget;
510 let rest = input
511 .gateway
512 .gateway_info()
513 .await?
514 .name
515 .filter(|n| !n.trim().is_empty());
516 let problems = wrong_target_gate(
517 rest.as_deref(),
518 input.reader,
519 &input.target.mode,
520 input.target.nodes.master.gateway_name.as_deref(),
521 )
522 .await?;
523 if problems.is_empty() {
524 Ok(finding(
525 check,
526 true,
527 "the REST name matches the host's systemName".to_string(),
528 ))
529 } else {
530 Ok(finding(check, false, problems.join("; ")))
531 }
532}
533
534async fn write_rest_finding(gateway: &dyn GatewayApi) -> Result<PreflightFinding, CoreError> {
537 let check = PreflightCheck::WriteRest;
538 match gateway.scan_projects().await {
539 Ok(()) => Ok(finding(
540 check,
541 true,
542 "the project scan was accepted".to_string(),
543 )),
544 Err(CoreError::Auth { status: 403, .. }) => Ok(finding(
545 check,
546 false,
547 "the token is read-only (HTTP 403 on the project scan)".to_string(),
548 )),
549 Err(other) => Err(other),
550 }
551}
552
553async fn write_host_finding(
556 host: &dyn ApplyHost,
557 touched: &[String],
558) -> Result<PreflightFinding, CoreError> {
559 let check = PreflightCheck::WriteHost;
560 let report = host.probe_paths(touched).await?;
561 if report.writable {
562 return Ok(finding(
563 check,
564 true,
565 "the mode collection and the ign-deploy root are writable".to_string(),
566 ));
567 }
568 let detail = if report.problems.is_empty() {
569 "the host is not writable".to_string()
570 } else {
571 report.problems.join("; ")
572 };
573 Ok(finding(check, false, detail))
574}
575
576pub(crate) async fn read_designers(
579 gateway: &dyn GatewayApi,
580) -> Result<Vec<DesignerSeen>, CoreError> {
581 let page = gateway.designers(&ListQuery::default()).await?;
582 Ok(page
583 .items
584 .into_iter()
585 .map(|d| {
586 (
587 printable(&d.user),
588 printable(&d.project),
589 printable(&d.address),
590 )
591 })
592 .collect())
593}
594
595pub(crate) fn designers_detail(open: &[DesignerSeen]) -> String {
597 let each: Vec<String> = open
598 .iter()
599 .map(|(user, project, host)| {
600 let project = if project.is_empty() {
601 "no project"
602 } else {
603 project
604 };
605 format!("{user} on {project} from {host}")
606 })
607 .collect();
608 let take = each.len().min(APPLY_LIST_SHOWN);
609 let mut text = format!(
610 "{} Designer session(s) open: {}",
611 open.len(),
612 each[..take].join(", ")
613 );
614 if each.len() > take {
615 text.push_str(&format!(" and {} more", each.len() - take));
616 }
617 text
618}
619
620#[derive(Debug, Clone, Copy, PartialEq, Eq)]
622pub(crate) enum WaitStep {
623 Clear,
625 TimedOut,
627 Sleep(Duration),
629}
630
631pub(crate) fn wait_step(open: usize, elapsed: Duration, policy: &DesignersPolicy) -> WaitStep {
634 if open == 0 {
635 WaitStep::Clear
636 } else if elapsed >= policy.timeout {
637 WaitStep::TimedOut
638 } else {
639 WaitStep::Sleep(
640 policy
641 .interval
642 .max(Duration::from_millis(1))
643 .min(policy.timeout - elapsed),
644 )
645 }
646}
647
648#[derive(Debug, Clone, Copy, PartialEq, Eq)]
650pub enum DesignersVerdict {
651 Clear,
653 Open,
656 Proceeded,
659}
660
661pub(crate) async fn designers_gate<F, Fut>(
669 mut read: F,
670 policy: &DesignersPolicy,
671 clock: &dyn Clock,
672 on_poll: &mut (dyn FnMut(usize) -> Result<(), CoreError> + Send),
673) -> Result<(DesignersVerdict, Vec<DesignerSeen>), CoreError>
674where
675 F: FnMut() -> Fut,
676 Fut: std::future::Future<Output = Result<Vec<DesignerSeen>, CoreError>>,
677{
678 let started = clock.now();
679 let mut open = read().await?;
680 if policy.mode == DesignersMode::Wait {
681 loop {
682 on_poll(open.len())?;
683 let elapsed = clock.now().saturating_duration_since(started);
684 match wait_step(open.len(), elapsed, policy) {
685 WaitStep::Clear | WaitStep::TimedOut => break,
686 WaitStep::Sleep(pause) => {
687 tokio::time::sleep(pause).await;
688 open = read().await?;
689 }
690 }
691 }
692 }
693 let verdict = if open.is_empty() {
694 DesignersVerdict::Clear
695 } else if policy.mode == DesignersMode::Proceed {
696 DesignersVerdict::Proceeded
697 } else {
698 DesignersVerdict::Open
699 };
700 Ok((verdict, open))
701}
702
703async fn designers_finding(
704 input: &PreflightInput<'_>,
705 steps: &mut Steps<'_>,
706) -> Result<(PreflightFinding, Vec<DesignerSeen>), CoreError> {
707 let check = PreflightCheck::Designers;
708 let (verdict, open) = designers_gate(
709 || read_designers(input.gateway),
710 input.designers,
711 input.clock,
712 &mut |count| steps.emit(ApplyEvent::DesignersPoll { open: count }),
713 )
714 .await?;
715 let found = match verdict {
716 DesignersVerdict::Clear => finding(check, true, "no Designer session is open".to_string()),
717 DesignersVerdict::Proceeded => {
718 steps.emit(ApplyEvent::DesignersProceed {
721 sessions: open.clone(),
722 })?;
723 finding(
724 check,
725 true,
726 format!(
727 "{}; going ahead because --designers proceed was given",
728 designers_detail(&open)
729 ),
730 )
731 }
732 DesignersVerdict::Open => finding(check, false, designers_detail(&open)),
733 };
734 Ok((found, open))
735}
736
737pub(super) fn hold_budget_finding(hold: &HoldModel, files: usize) -> PreflightFinding {
740 let estimate = hold.estimate_commit_s(files);
741 let needed = hold_for(estimate, hold.p95_scan_s);
742 let detail = format!(
743 "{files} file(s): estimated commit {estimate:.1} s and scan {:.1} s need a hold of {needed} s; \
744 the budget is {} s",
745 hold.p95_scan_s, hold.hold_s
746 );
747 finding(PreflightCheck::HoldBudget, needed <= hold.hold_s, detail)
748}
749
750async fn master_active_finding(input: &PreflightInput<'_>) -> Result<PreflightFinding, CoreError> {
763 let check = PreflightCheck::MasterActive;
764 if input.target.nodes.backup.is_none() {
765 return Ok(PreflightFinding {
766 check,
767 status: FindingStatus::Skipped,
768 detail: "the target has no backup node".to_string(),
769 });
770 }
771 let Some(backup) = input.backup else {
772 return Ok(finding(
773 check,
774 false,
775 "the backup node has no gateway connection, so which node is active cannot be confirmed"
776 .to_string(),
777 ));
778 };
779 let master = input.gateway.redundancy_status().await?;
780 let standby = backup.status().await?;
781 let level = |wire: &RedundancyStatusWire| {
782 wire.activity_level
783 .as_deref()
784 .map_or_else(|| "none".to_string(), printable)
785 };
786 let (master_level, backup_level) = (level(&master), level(&standby));
787 let both = format!("master {master_level}, backup {backup_level}");
788 Ok(match active_node(&master, &standby) {
789 ActiveNode::OnlyA => finding(
790 check,
791 true,
792 format!("the master is the active node ({both})"),
793 ),
794 ActiveNode::OnlyB => finding(
795 check,
796 false,
797 format!(
798 "the backup reports activityLevel Active and the master {master_level}: \
799 activity moved to the backup (the pair switched over); apply and rollback write only to the active master ({both})"
800 ),
801 ),
802 ActiveNode::Both => finding(
803 check,
804 false,
805 format!("both nodes report activityLevel Active (split-brain) ({both})"),
806 ),
807 ActiveNode::Neither => finding(
808 check,
809 false,
810 format!("neither node reports activityLevel Active ({both})"),
811 ),
812 })
813}
814
815async fn backup_read_finding(input: &PreflightInput<'_>) -> (PreflightFinding, Vec<String>) {
838 let check = PreflightCheck::BackupRead;
839 let skipped = |detail: &str| {
840 (
841 PreflightFinding {
842 check,
843 status: FindingStatus::Skipped,
844 detail: detail.to_string(),
845 },
846 Vec::new(),
847 )
848 };
849 if input.target.nodes.backup.is_none() {
850 return skipped("the target has no backup node");
851 }
852 let Some(declared) = input.declared else {
853 return skipped("a rollback renders no tree, so the backup's files are not compared");
854 };
855 let Some(backup) = input.backup else {
856 return skipped("the backup node has no gateway connection, so its files are not read");
857 };
858 if !backup.has_files() {
859 return skipped(
860 "nodes.backup declares no ssh or local transport, so the file-hash check did not run",
861 );
862 }
863 let scope = Scope::from_manifest(
864 &declared.manifest,
865 &declared.target.mode,
866 &declared.excluded_tag_providers(),
867 );
868 match backup.read_files(&scope).await {
869 Some(Ok(observed)) => {
870 let blocked = backup_blocked_units(input, declared, &observed);
871 if blocked.is_empty() {
872 return (
873 finding(
874 check,
875 true,
876 format!(
877 "read {} files of the backup's mode collection",
878 observed.core.len() + observed.mode.len()
879 ),
880 ),
881 blocked,
882 );
883 }
884 let take = blocked.len().min(APPLY_LIST_SHOWN);
885 let mut detail = format!(
886 "the backup's sftp server cannot read {} unit(s) this apply writes or deletes: {}",
887 blocked.len(),
888 blocked[..take]
889 .iter()
890 .map(|k| printable(k))
891 .collect::<Vec<_>>()
892 .join(", ")
893 );
894 if blocked.len() > take {
895 detail.push_str(&format!(" and {} more", blocked.len() - take));
896 }
897 (finding(check, false, detail), blocked)
898 }
899 Some(Err(err)) => (
900 finding(
901 check,
902 false,
903 format!(
904 "the backup's mode collection cannot be read: {}",
905 printable(err.code())
906 ),
907 ),
908 Vec::new(),
909 ),
910 None => skipped(
911 "nodes.backup declares no ssh or local transport, so the file-hash check did not run",
912 ),
913 }
914}
915
916fn backup_blocked_units(
920 input: &PreflightInput<'_>,
921 declared: &Declared,
922 observed: &ObservedFiles,
923) -> Vec<String> {
924 let units = unreadable_units(observed);
925 if units.is_empty() {
926 return Vec::new();
927 }
928 let present: BTreeSet<RelPath> = declared.outcome.tree.files.keys().cloned().collect();
929 let absent: BTreeSet<RelPath> = input
930 .deleted_paths
931 .iter()
932 .filter_map(|p| RelPath::new(p).ok())
933 .collect();
934 let mut comparison = FileComparison::default();
935 exempt_unreadable(&mut comparison, &present, &absent, &[("backup", &units)]);
936 let mut keys: Vec<String> = comparison
937 .unreadable
938 .into_iter()
939 .map(|(_, key)| key)
940 .collect();
941 keys.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
942 keys.dedup();
943 keys
944}
945
946async fn secrets_finding(input: &PreflightInput<'_>) -> Result<PreflightFinding, CoreError> {
962 let check = PreflightCheck::Secrets;
963 let skipped = |detail: &str| PreflightFinding {
964 check,
965 status: FindingStatus::Skipped,
966 detail: detail.to_string(),
967 };
968 let Some(declared) = input.declared else {
969 return Ok(skipped(
970 "a rollback renders no tree, so no secret reference is checked",
971 ));
972 };
973 let all_referenced = collect_referenced_secrets(&declared.outcome.tree);
974 if all_referenced.is_empty() {
975 return Ok(skipped("the rendered tree references no secret"));
976 }
977 let defined = secrets_defined_by_render(&declared.outcome.tree);
982 let referenced: BTreeSet<(String, String)> =
983 all_referenced.difference(&defined).cloned().collect();
984 if referenced.is_empty() {
985 return Ok(skipped(
986 "every secret name the rendered tree references is defined by the tree itself",
987 ));
988 }
989 let own_names = all_referenced.len() - referenced.len();
990 let declared_providers = declared_secret_providers(&declared.outcome.tree);
991 let providers: BTreeSet<&str> = referenced.iter().map(|(p, _)| p.as_str()).collect();
992
993 let mut reports = Vec::with_capacity(2);
994 let mut on_master = BTreeMap::new();
995 for provider in &providers {
996 on_master.insert(
997 (*provider).to_string(),
998 input.master.secret_names(provider).await?,
999 );
1000 }
1001 reports.push(check_secret_names(
1002 "master",
1003 &referenced,
1004 &on_master,
1005 &declared_providers,
1006 SecretCheckMode::Preflight,
1007 ));
1008 if input.target.nodes.backup.is_some() {
1009 let Some(backup) = input.backup else {
1010 return Ok(finding(
1011 check,
1012 false,
1013 "the backup node has no gateway connection, so its secret names cannot be checked"
1014 .to_string(),
1015 ));
1016 };
1017 let mut on_backup = BTreeMap::new();
1018 for provider in &providers {
1019 on_backup.insert(
1020 (*provider).to_string(),
1021 backup.secret_names(provider).await?,
1022 );
1023 }
1024 reports.push(check_secret_names(
1025 "backup",
1026 &referenced,
1027 &on_backup,
1028 &declared_providers,
1029 SecretCheckMode::Preflight,
1030 ));
1031 }
1032
1033 let missing: Vec<String> = reports
1034 .iter()
1035 .flat_map(|r| {
1036 r.missing.iter().map(|m| {
1037 format!(
1038 "{}: {}/{}",
1039 r.node,
1040 printable(&m.provider),
1041 printable(&m.name)
1042 )
1043 })
1044 })
1045 .collect();
1046 let mut notes: Vec<String> = Vec::new();
1047 for r in &reports {
1048 if !r.skipped_providers.is_empty() {
1049 notes.push(format!(
1050 "{}: skipped {} (declared by this render, not on the node yet)",
1051 r.node,
1052 provider_list(&r.skipped_providers)
1053 ));
1054 }
1055 if !r.unverifiable.is_empty() {
1056 notes.push(format!(
1057 "{}: unverifiable {} (the provider cannot list its names)",
1058 r.node,
1059 provider_list(&r.unverifiable)
1060 ));
1061 }
1062 }
1063 if missing.is_empty() {
1064 let mut detail = format!(
1065 "{} referenced secret name(s) in {} provider(s) checked on {}",
1066 referenced.len(),
1067 providers.len(),
1068 reports
1069 .iter()
1070 .map(|r| r.node.as_str())
1071 .collect::<Vec<_>>()
1072 .join(" and ")
1073 );
1074 if own_names > 0 {
1075 detail.push_str(&format!(
1076 "; {own_names} name(s) defined by this render were not required on the node"
1077 ));
1078 }
1079 for note in notes {
1080 detail.push_str("; ");
1081 detail.push_str(¬e);
1082 }
1083 return Ok(finding(check, true, detail));
1084 }
1085 let take = missing.len().min(APPLY_LIST_SHOWN);
1086 let mut detail = format!(
1087 "{} referenced secret name(s) missing: {}",
1088 missing.len(),
1089 missing[..take].join(", ")
1090 );
1091 if missing.len() > take {
1092 detail.push_str(&format!(" and {} more", missing.len() - take));
1093 }
1094 for note in notes {
1095 detail.push_str("; ");
1096 detail.push_str(¬e);
1097 }
1098 Ok(finding(check, false, detail))
1099}
1100
1101fn provider_list(providers: &[String]) -> String {
1103 let take = providers.len().min(APPLY_LIST_SHOWN);
1104 let mut text = providers[..take]
1105 .iter()
1106 .map(|p| printable(p))
1107 .collect::<Vec<_>>()
1108 .join(", ");
1109 if providers.len() > take {
1110 text.push_str(&format!(" and {} more", providers.len() - take));
1111 }
1112 text
1113}
1114
1115#[cfg(test)]
1116mod tests {
1117 use super::*;
1118
1119 #[test]
1120 fn ids_are_the_contract_words_in_report_order() {
1121 let ids: Vec<&str> = PreflightCheck::ALL.iter().map(|c| c.as_str()).collect();
1122 assert_eq!(
1123 ids,
1124 [
1125 "mode_master",
1126 "mode_backup",
1127 "wrong_target",
1128 "write_rest",
1129 "write_host",
1130 "designers",
1131 "hold_budget",
1132 "master_active",
1133 "backup_read",
1134 "secrets"
1135 ]
1136 );
1137 }
1138
1139 #[test]
1140 fn production_defaults_are_the_decided_numbers() {
1141 assert_eq!((DESIGNERS_POLL_S, DESIGNERS_TIMEOUT_DEFAULT_S), (10, 600));
1142 let d = DesignersPolicy::default();
1143 assert_eq!(d.mode, DesignersMode::Fail);
1144 assert_eq!(d.timeout, Duration::from_secs(600));
1145 assert_eq!(d.interval, Duration::from_secs(10));
1146 }
1147
1148 #[test]
1149 fn the_session_list_is_capped_and_counts_the_rest() {
1150 let open: Vec<DesignerSeen> = (0..13)
1151 .map(|i| (format!("u{i}"), "P".to_string(), "h".to_string()))
1152 .collect();
1153 let text = designers_detail(&open);
1154 assert!(text.starts_with("13 Designer session(s) open: u0 on P from h"));
1155 assert!(text.ends_with("and 3 more"), "{text}");
1156 assert!(!text.contains("u12"), "{text}");
1157 }
1158
1159 #[test]
1160 fn a_designer_without_a_project_is_named_so() {
1161 let text = designers_detail(&[("bob".into(), String::new(), "h".into())]);
1162 assert!(text.contains("bob on no project from h"), "{text}");
1163 }
1164
1165 #[test]
1166 fn printable_drops_controls_and_caps_length() {
1167 assert_eq!(printable("a\nb\u{1b}c"), "abc");
1168 let long = "x".repeat(SHOWN_NAME_CHARS + 5);
1169 assert_eq!(printable(&long).len(), SHOWN_NAME_CHARS + 3);
1170 }
1171
1172 #[test]
1173 fn hold_budget_boundary_is_inclusive() {
1174 let hold = |hold_s| HoldModel {
1176 hold_s,
1177 commit_fixed_s: 1.0,
1178 commit_per_file_s: 0.5,
1179 p95_scan_s: 1.0,
1180 };
1181 assert_eq!(
1182 hold_budget_finding(&hold(14), 9).status,
1183 FindingStatus::Pass
1184 );
1185 let over = hold_budget_finding(&hold(14), 10);
1186 assert_eq!(over.status, FindingStatus::Fail);
1187 assert!(over.detail.contains("hold of 15 s"), "{}", over.detail);
1188 assert!(over.detail.contains("budget is 14 s"), "{}", over.detail);
1189 assert!(
1190 over.detail.contains("estimated commit 6.0 s"),
1191 "{}",
1192 over.detail
1193 );
1194 }
1195
1196 fn policy(timeout_ms: u64, interval_ms: u64) -> DesignersPolicy {
1197 DesignersPolicy {
1198 mode: DesignersMode::Wait,
1199 timeout: Duration::from_millis(timeout_ms),
1200 interval: Duration::from_millis(interval_ms),
1201 }
1202 }
1203
1204 #[test]
1205 fn wait_with_nothing_open_is_clear_at_any_time() {
1206 let p = policy(1000, 100);
1207 assert_eq!(wait_step(0, Duration::ZERO, &p), WaitStep::Clear);
1208 assert_eq!(wait_step(0, Duration::from_secs(99), &p), WaitStep::Clear);
1209 }
1210
1211 #[test]
1212 fn wait_timeout_equal_to_the_elapsed_time_has_passed() {
1213 let p = policy(1000, 100);
1215 assert_eq!(
1216 wait_step(1, Duration::from_millis(999), &p),
1217 WaitStep::Sleep(Duration::from_millis(1))
1218 );
1219 assert_eq!(
1220 wait_step(1, Duration::from_millis(1000), &p),
1221 WaitStep::TimedOut
1222 );
1223 assert_eq!(
1224 wait_step(1, Duration::from_millis(1001), &p),
1225 WaitStep::TimedOut
1226 );
1227 }
1228
1229 #[test]
1230 fn wait_sleeps_one_interval_unless_the_timeout_is_nearer() {
1231 let p = policy(1000, 100);
1232 assert_eq!(
1233 wait_step(2, Duration::ZERO, &p),
1234 WaitStep::Sleep(Duration::from_millis(100))
1235 );
1236 assert_eq!(
1237 wait_step(2, Duration::from_millis(950), &p),
1238 WaitStep::Sleep(Duration::from_millis(50))
1239 );
1240 }
1241
1242 #[test]
1243 fn a_zero_interval_never_spins() {
1244 let p = policy(1000, 0);
1245 assert_eq!(
1246 wait_step(1, Duration::ZERO, &p),
1247 WaitStep::Sleep(Duration::from_millis(1))
1248 );
1249 }
1250
1251 use std::sync::Mutex;
1254
1255 use crate::deploy::apply::hold::SystemClock;
1256
1257 fn seen(user: &str) -> DesignerSeen {
1258 (user.to_string(), "P".to_string(), "h".to_string())
1259 }
1260
1261 async fn gate_over(
1264 mode: DesignersMode,
1265 timeout_ms: u64,
1266 script: Vec<Vec<DesignerSeen>>,
1267 ) -> (DesignersVerdict, Vec<DesignerSeen>, Vec<usize>, usize) {
1268 let reads = Mutex::new(0usize);
1269 let policy = DesignersPolicy {
1270 mode,
1271 timeout: Duration::from_millis(timeout_ms),
1272 interval: Duration::from_millis(1),
1273 };
1274 let mut polls = Vec::new();
1275 let (verdict, open) = designers_gate(
1276 || {
1277 let mut n = reads.lock().expect("reads");
1278 let list = script[(*n).min(script.len() - 1)].clone();
1279 *n += 1;
1280 async move { Ok(list) }
1281 },
1282 &policy,
1283 &SystemClock,
1284 &mut |count| {
1285 polls.push(count);
1286 Ok(())
1287 },
1288 )
1289 .await
1290 .expect("the gate");
1291 let n = *reads.lock().expect("reads");
1292 (verdict, open, polls, n)
1293 }
1294
1295 #[tokio::test]
1296 async fn designers_gate_shared() {
1297 let (verdict, open, polls, reads) = gate_over(DesignersMode::Fail, 0, vec![vec![]]).await;
1299 assert_eq!(verdict, DesignersVerdict::Clear);
1300 assert!(open.is_empty() && polls.is_empty());
1301 assert_eq!(reads, 1);
1302
1303 let (verdict, open, polls, reads) =
1305 gate_over(DesignersMode::Fail, 0, vec![vec![seen("alice")]]).await;
1306 assert_eq!(verdict, DesignersVerdict::Open);
1307 assert_eq!(open, [seen("alice")]);
1308 assert!(polls.is_empty());
1309 assert_eq!(reads, 1);
1310
1311 let (verdict, open, ..) =
1313 gate_over(DesignersMode::Proceed, 0, vec![vec![seen("alice")]]).await;
1314 assert_eq!(verdict, DesignersVerdict::Proceeded);
1315 assert_eq!(open, [seen("alice")]);
1316
1317 let (verdict, open, polls, reads) = gate_over(
1319 DesignersMode::Wait,
1320 30_000,
1321 vec![vec![seen("alice")], vec![seen("alice")], vec![]],
1322 )
1323 .await;
1324 assert_eq!(verdict, DesignersVerdict::Clear);
1325 assert!(open.is_empty());
1326 assert_eq!(polls, [1, 1, 0]);
1327 assert_eq!(reads, 3);
1328
1329 let (verdict, open, polls, _) =
1331 gate_over(DesignersMode::Wait, 20, vec![vec![seen("alice")]]).await;
1332 assert_eq!(verdict, DesignersVerdict::Open);
1333 assert_eq!(open, [seen("alice")]);
1334 assert!(polls.len() >= 2, "{polls:?}");
1335 }
1336
1337 #[tokio::test]
1338 async fn designers_gate_read_error_is_the_reads_own() {
1339 let policy = DesignersPolicy::default();
1340 let err = designers_gate(
1341 || async {
1342 Err::<Vec<DesignerSeen>, _>(CoreError::TransportFailed {
1343 operation: "read".to_string(),
1344 node: "nodes.master".to_string(),
1345 detail: "down".to_string(),
1346 })
1347 },
1348 &policy,
1349 &SystemClock,
1350 &mut |_| Ok(()),
1351 )
1352 .await
1353 .expect_err("the read fails");
1354 assert_eq!(err.code(), "transport_failed");
1355 }
1356}