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, split_key};
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 let detail = if crate::deploy::transport::core_tag_types(touched).is_empty() {
564 "the mode collection and the ign-deploy root are writable"
565 } else {
566 "the mode collection, the core tag folders and the ign-deploy root are writable"
567 };
568 return Ok(finding(check, true, detail.to_string()));
569 }
570 let detail = if report.problems.is_empty() {
571 "the host is not writable".to_string()
572 } else {
573 report.problems.join("; ")
574 };
575 Ok(finding(check, false, detail))
576}
577
578pub(crate) async fn read_designers(
581 gateway: &dyn GatewayApi,
582) -> Result<Vec<DesignerSeen>, CoreError> {
583 let page = gateway.designers(&ListQuery::default()).await?;
584 Ok(page
585 .items
586 .into_iter()
587 .map(|d| {
588 (
589 printable(&d.user),
590 printable(&d.project),
591 printable(&d.address),
592 )
593 })
594 .collect())
595}
596
597pub(crate) fn designers_detail(open: &[DesignerSeen]) -> String {
599 let each: Vec<String> = open
600 .iter()
601 .map(|(user, project, host)| {
602 let project = if project.is_empty() {
603 "no project"
604 } else {
605 project
606 };
607 format!("{user} on {project} from {host}")
608 })
609 .collect();
610 let take = each.len().min(APPLY_LIST_SHOWN);
611 let mut text = format!(
612 "{} Designer session(s) open: {}",
613 open.len(),
614 each[..take].join(", ")
615 );
616 if each.len() > take {
617 text.push_str(&format!(" and {} more", each.len() - take));
618 }
619 text
620}
621
622#[derive(Debug, Clone, Copy, PartialEq, Eq)]
624pub(crate) enum WaitStep {
625 Clear,
627 TimedOut,
629 Sleep(Duration),
631}
632
633pub(crate) fn wait_step(open: usize, elapsed: Duration, policy: &DesignersPolicy) -> WaitStep {
636 if open == 0 {
637 WaitStep::Clear
638 } else if elapsed >= policy.timeout {
639 WaitStep::TimedOut
640 } else {
641 WaitStep::Sleep(
642 policy
643 .interval
644 .max(Duration::from_millis(1))
645 .min(policy.timeout - elapsed),
646 )
647 }
648}
649
650#[derive(Debug, Clone, Copy, PartialEq, Eq)]
652pub enum DesignersVerdict {
653 Clear,
655 Open,
658 Proceeded,
661}
662
663pub(crate) async fn designers_gate<F, Fut>(
671 mut read: F,
672 policy: &DesignersPolicy,
673 clock: &dyn Clock,
674 on_poll: &mut (dyn FnMut(usize) -> Result<(), CoreError> + Send),
675) -> Result<(DesignersVerdict, Vec<DesignerSeen>), CoreError>
676where
677 F: FnMut() -> Fut,
678 Fut: std::future::Future<Output = Result<Vec<DesignerSeen>, CoreError>>,
679{
680 let started = clock.now();
681 let mut open = read().await?;
682 if policy.mode == DesignersMode::Wait {
683 loop {
684 on_poll(open.len())?;
685 let elapsed = clock.now().saturating_duration_since(started);
686 match wait_step(open.len(), elapsed, policy) {
687 WaitStep::Clear | WaitStep::TimedOut => break,
688 WaitStep::Sleep(pause) => {
689 tokio::time::sleep(pause).await;
690 open = read().await?;
691 }
692 }
693 }
694 }
695 let verdict = if open.is_empty() {
696 DesignersVerdict::Clear
697 } else if policy.mode == DesignersMode::Proceed {
698 DesignersVerdict::Proceeded
699 } else {
700 DesignersVerdict::Open
701 };
702 Ok((verdict, open))
703}
704
705async fn designers_finding(
706 input: &PreflightInput<'_>,
707 steps: &mut Steps<'_>,
708) -> Result<(PreflightFinding, Vec<DesignerSeen>), CoreError> {
709 let check = PreflightCheck::Designers;
710 let (verdict, open) = designers_gate(
711 || read_designers(input.gateway),
712 input.designers,
713 input.clock,
714 &mut |count| steps.emit(ApplyEvent::DesignersPoll { open: count }),
715 )
716 .await?;
717 let found = match verdict {
718 DesignersVerdict::Clear => finding(check, true, "no Designer session is open".to_string()),
719 DesignersVerdict::Proceeded => {
720 steps.emit(ApplyEvent::DesignersProceed {
723 sessions: open.clone(),
724 })?;
725 finding(
726 check,
727 true,
728 format!(
729 "{}; going ahead because --designers proceed was given",
730 designers_detail(&open)
731 ),
732 )
733 }
734 DesignersVerdict::Open => finding(check, false, designers_detail(&open)),
735 };
736 Ok((found, open))
737}
738
739pub(super) fn hold_budget_finding(hold: &HoldModel, files: usize) -> PreflightFinding {
742 let estimate = hold.estimate_commit_s(files);
743 let needed = hold_for(estimate, hold.p95_scan_s);
744 let detail = format!(
745 "{files} file(s): estimated commit {estimate:.1} s and scan {:.1} s need a hold of {needed} s; \
746 the budget is {} s",
747 hold.p95_scan_s, hold.hold_s
748 );
749 finding(PreflightCheck::HoldBudget, needed <= hold.hold_s, detail)
750}
751
752async fn master_active_finding(input: &PreflightInput<'_>) -> Result<PreflightFinding, CoreError> {
765 let check = PreflightCheck::MasterActive;
766 if input.target.nodes.backup.is_none() {
767 return Ok(PreflightFinding {
768 check,
769 status: FindingStatus::Skipped,
770 detail: "the target has no backup node".to_string(),
771 });
772 }
773 let Some(backup) = input.backup else {
774 return Ok(finding(
775 check,
776 false,
777 "the backup node has no gateway connection, so which node is active cannot be confirmed"
778 .to_string(),
779 ));
780 };
781 let master = input.gateway.redundancy_status().await?;
782 let standby = backup.status().await?;
783 let level = |wire: &RedundancyStatusWire| {
784 wire.activity_level
785 .as_deref()
786 .map_or_else(|| "none".to_string(), printable)
787 };
788 let (master_level, backup_level) = (level(&master), level(&standby));
789 let both = format!("master {master_level}, backup {backup_level}");
790 Ok(match active_node(&master, &standby) {
791 ActiveNode::OnlyA => finding(
792 check,
793 true,
794 format!("the master is the active node ({both})"),
795 ),
796 ActiveNode::OnlyB => finding(
797 check,
798 false,
799 format!(
800 "the backup reports activityLevel Active and the master {master_level}: \
801 activity moved to the backup (the pair switched over); apply and rollback write only to the active master ({both})"
802 ),
803 ),
804 ActiveNode::Both => finding(
805 check,
806 false,
807 format!("both nodes report activityLevel Active (split-brain) ({both})"),
808 ),
809 ActiveNode::Neither => finding(
810 check,
811 false,
812 format!("neither node reports activityLevel Active ({both})"),
813 ),
814 })
815}
816
817async fn backup_read_finding(input: &PreflightInput<'_>) -> (PreflightFinding, Vec<String>) {
840 let check = PreflightCheck::BackupRead;
841 let skipped = |detail: &str| {
842 (
843 PreflightFinding {
844 check,
845 status: FindingStatus::Skipped,
846 detail: detail.to_string(),
847 },
848 Vec::new(),
849 )
850 };
851 if input.target.nodes.backup.is_none() {
852 return skipped("the target has no backup node");
853 }
854 let Some(declared) = input.declared else {
855 return skipped("a rollback renders no tree, so the backup's files are not compared");
856 };
857 let Some(backup) = input.backup else {
858 return skipped("the backup node has no gateway connection, so its files are not read");
859 };
860 if !backup.has_files() {
861 return skipped(
862 "nodes.backup declares no ssh or local transport, so the file-hash check did not run",
863 );
864 }
865 let scope = Scope::from_manifest(
866 &declared.manifest,
867 &declared.target.mode,
868 &declared.excluded_tag_providers(),
869 );
870 match backup.read_files(&scope).await {
871 Some(Ok(observed)) => {
872 let blocked = backup_blocked_units(input, declared, &observed);
873 if blocked.is_empty() {
874 return (
875 finding(
876 check,
877 true,
878 format!(
879 "read {} files of the backup's mode collection",
880 observed.core.len() + observed.mode.len()
881 ),
882 ),
883 blocked,
884 );
885 }
886 let take = blocked.len().min(APPLY_LIST_SHOWN);
887 let mut detail = format!(
888 "the backup's sftp server cannot read {} unit(s) this apply writes or deletes: {}",
889 blocked.len(),
890 blocked[..take]
891 .iter()
892 .map(|k| printable(k))
893 .collect::<Vec<_>>()
894 .join(", ")
895 );
896 if blocked.len() > take {
897 detail.push_str(&format!(" and {} more", blocked.len() - take));
898 }
899 (finding(check, false, detail), blocked)
900 }
901 Some(Err(err)) => (
902 finding(
903 check,
904 false,
905 format!(
906 "the backup's mode collection cannot be read: {}",
907 printable(err.code())
908 ),
909 ),
910 Vec::new(),
911 ),
912 None => skipped(
913 "nodes.backup declares no ssh or local transport, so the file-hash check did not run",
914 ),
915 }
916}
917
918fn backup_blocked_units(
922 input: &PreflightInput<'_>,
923 declared: &Declared,
924 observed: &ObservedFiles,
925) -> Vec<String> {
926 let units = unreadable_units(observed);
927 if units.is_empty() {
928 return Vec::new();
929 }
930 let present: BTreeSet<RelPath> = declared.outcome.tree.files.keys().cloned().collect();
931 let absent: BTreeSet<RelPath> = input
934 .deleted_paths
935 .iter()
936 .filter_map(|p| RelPath::new(split_key(p).1).ok())
937 .collect();
938 let mut comparison = FileComparison::default();
939 exempt_unreadable(&mut comparison, &present, &absent, &[("backup", &units)]);
940 let mut keys: Vec<String> = comparison
941 .unreadable
942 .into_iter()
943 .map(|(_, key)| key)
944 .collect();
945 keys.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
946 keys.dedup();
947 keys
948}
949
950async fn secrets_finding(input: &PreflightInput<'_>) -> Result<PreflightFinding, CoreError> {
966 let check = PreflightCheck::Secrets;
967 let skipped = |detail: &str| PreflightFinding {
968 check,
969 status: FindingStatus::Skipped,
970 detail: detail.to_string(),
971 };
972 let Some(declared) = input.declared else {
973 return Ok(skipped(
974 "a rollback renders no tree, so no secret reference is checked",
975 ));
976 };
977 let all_referenced = collect_referenced_secrets(&declared.outcome.tree);
978 if all_referenced.is_empty() {
979 return Ok(skipped("the rendered tree references no secret"));
980 }
981 let defined = secrets_defined_by_render(&declared.outcome.tree);
986 let referenced: BTreeSet<(String, String)> =
987 all_referenced.difference(&defined).cloned().collect();
988 if referenced.is_empty() {
989 return Ok(skipped(
990 "every secret name the rendered tree references is defined by the tree itself",
991 ));
992 }
993 let own_names = all_referenced.len() - referenced.len();
994 let declared_providers = declared_secret_providers(&declared.outcome.tree);
995 let providers: BTreeSet<&str> = referenced.iter().map(|(p, _)| p.as_str()).collect();
996
997 let mut reports = Vec::with_capacity(2);
998 let mut on_master = BTreeMap::new();
999 for provider in &providers {
1000 on_master.insert(
1001 (*provider).to_string(),
1002 input.master.secret_names(provider).await?,
1003 );
1004 }
1005 reports.push(check_secret_names(
1006 "master",
1007 &referenced,
1008 &on_master,
1009 &declared_providers,
1010 SecretCheckMode::Preflight,
1011 ));
1012 if input.target.nodes.backup.is_some() {
1013 let Some(backup) = input.backup else {
1014 return Ok(finding(
1015 check,
1016 false,
1017 "the backup node has no gateway connection, so its secret names cannot be checked"
1018 .to_string(),
1019 ));
1020 };
1021 let mut on_backup = BTreeMap::new();
1022 for provider in &providers {
1023 on_backup.insert(
1024 (*provider).to_string(),
1025 backup.secret_names(provider).await?,
1026 );
1027 }
1028 reports.push(check_secret_names(
1029 "backup",
1030 &referenced,
1031 &on_backup,
1032 &declared_providers,
1033 SecretCheckMode::Preflight,
1034 ));
1035 }
1036
1037 let missing: Vec<String> = reports
1038 .iter()
1039 .flat_map(|r| {
1040 r.missing.iter().map(|m| {
1041 format!(
1042 "{}: {}/{}",
1043 r.node,
1044 printable(&m.provider),
1045 printable(&m.name)
1046 )
1047 })
1048 })
1049 .collect();
1050 let mut notes: Vec<String> = Vec::new();
1051 for r in &reports {
1052 if !r.skipped_providers.is_empty() {
1053 notes.push(format!(
1054 "{}: skipped {} (declared by this render, not on the node yet)",
1055 r.node,
1056 provider_list(&r.skipped_providers)
1057 ));
1058 }
1059 if !r.unverifiable.is_empty() {
1060 notes.push(format!(
1061 "{}: unverifiable {} (the provider cannot list its names)",
1062 r.node,
1063 provider_list(&r.unverifiable)
1064 ));
1065 }
1066 }
1067 if missing.is_empty() {
1068 let mut detail = format!(
1069 "{} referenced secret name(s) in {} provider(s) checked on {}",
1070 referenced.len(),
1071 providers.len(),
1072 reports
1073 .iter()
1074 .map(|r| r.node.as_str())
1075 .collect::<Vec<_>>()
1076 .join(" and ")
1077 );
1078 if own_names > 0 {
1079 detail.push_str(&format!(
1080 "; {own_names} name(s) defined by this render were not required on the node"
1081 ));
1082 }
1083 for note in notes {
1084 detail.push_str("; ");
1085 detail.push_str(¬e);
1086 }
1087 return Ok(finding(check, true, detail));
1088 }
1089 let take = missing.len().min(APPLY_LIST_SHOWN);
1090 let mut detail = format!(
1091 "{} referenced secret name(s) missing: {}",
1092 missing.len(),
1093 missing[..take].join(", ")
1094 );
1095 if missing.len() > take {
1096 detail.push_str(&format!(" and {} more", missing.len() - take));
1097 }
1098 for note in notes {
1099 detail.push_str("; ");
1100 detail.push_str(¬e);
1101 }
1102 Ok(finding(check, false, detail))
1103}
1104
1105fn provider_list(providers: &[String]) -> String {
1107 let take = providers.len().min(APPLY_LIST_SHOWN);
1108 let mut text = providers[..take]
1109 .iter()
1110 .map(|p| printable(p))
1111 .collect::<Vec<_>>()
1112 .join(", ");
1113 if providers.len() > take {
1114 text.push_str(&format!(" and {} more", providers.len() - take));
1115 }
1116 text
1117}
1118
1119#[cfg(test)]
1120mod tests {
1121 use super::*;
1122
1123 #[test]
1124 fn ids_are_the_contract_words_in_report_order() {
1125 let ids: Vec<&str> = PreflightCheck::ALL.iter().map(|c| c.as_str()).collect();
1126 assert_eq!(
1127 ids,
1128 [
1129 "mode_master",
1130 "mode_backup",
1131 "wrong_target",
1132 "write_rest",
1133 "write_host",
1134 "designers",
1135 "hold_budget",
1136 "master_active",
1137 "backup_read",
1138 "secrets"
1139 ]
1140 );
1141 }
1142
1143 #[test]
1144 fn production_defaults_are_the_decided_numbers() {
1145 assert_eq!((DESIGNERS_POLL_S, DESIGNERS_TIMEOUT_DEFAULT_S), (10, 600));
1146 let d = DesignersPolicy::default();
1147 assert_eq!(d.mode, DesignersMode::Fail);
1148 assert_eq!(d.timeout, Duration::from_secs(600));
1149 assert_eq!(d.interval, Duration::from_secs(10));
1150 }
1151
1152 #[test]
1153 fn the_session_list_is_capped_and_counts_the_rest() {
1154 let open: Vec<DesignerSeen> = (0..13)
1155 .map(|i| (format!("u{i}"), "P".to_string(), "h".to_string()))
1156 .collect();
1157 let text = designers_detail(&open);
1158 assert!(text.starts_with("13 Designer session(s) open: u0 on P from h"));
1159 assert!(text.ends_with("and 3 more"), "{text}");
1160 assert!(!text.contains("u12"), "{text}");
1161 }
1162
1163 #[test]
1164 fn a_designer_without_a_project_is_named_so() {
1165 let text = designers_detail(&[("bob".into(), String::new(), "h".into())]);
1166 assert!(text.contains("bob on no project from h"), "{text}");
1167 }
1168
1169 #[test]
1170 fn printable_drops_controls_and_caps_length() {
1171 assert_eq!(printable("a\nb\u{1b}c"), "abc");
1172 let long = "x".repeat(SHOWN_NAME_CHARS + 5);
1173 assert_eq!(printable(&long).len(), SHOWN_NAME_CHARS + 3);
1174 }
1175
1176 #[test]
1177 fn hold_budget_boundary_is_inclusive() {
1178 let hold = |hold_s| HoldModel {
1180 hold_s,
1181 commit_fixed_s: 1.0,
1182 commit_per_file_s: 0.5,
1183 p95_scan_s: 1.0,
1184 };
1185 assert_eq!(
1186 hold_budget_finding(&hold(14), 9).status,
1187 FindingStatus::Pass
1188 );
1189 let over = hold_budget_finding(&hold(14), 10);
1190 assert_eq!(over.status, FindingStatus::Fail);
1191 assert!(over.detail.contains("hold of 15 s"), "{}", over.detail);
1192 assert!(over.detail.contains("budget is 14 s"), "{}", over.detail);
1193 assert!(
1194 over.detail.contains("estimated commit 6.0 s"),
1195 "{}",
1196 over.detail
1197 );
1198 }
1199
1200 fn policy(timeout_ms: u64, interval_ms: u64) -> DesignersPolicy {
1201 DesignersPolicy {
1202 mode: DesignersMode::Wait,
1203 timeout: Duration::from_millis(timeout_ms),
1204 interval: Duration::from_millis(interval_ms),
1205 }
1206 }
1207
1208 #[test]
1209 fn wait_with_nothing_open_is_clear_at_any_time() {
1210 let p = policy(1000, 100);
1211 assert_eq!(wait_step(0, Duration::ZERO, &p), WaitStep::Clear);
1212 assert_eq!(wait_step(0, Duration::from_secs(99), &p), WaitStep::Clear);
1213 }
1214
1215 #[test]
1216 fn wait_timeout_equal_to_the_elapsed_time_has_passed() {
1217 let p = policy(1000, 100);
1219 assert_eq!(
1220 wait_step(1, Duration::from_millis(999), &p),
1221 WaitStep::Sleep(Duration::from_millis(1))
1222 );
1223 assert_eq!(
1224 wait_step(1, Duration::from_millis(1000), &p),
1225 WaitStep::TimedOut
1226 );
1227 assert_eq!(
1228 wait_step(1, Duration::from_millis(1001), &p),
1229 WaitStep::TimedOut
1230 );
1231 }
1232
1233 #[test]
1234 fn wait_sleeps_one_interval_unless_the_timeout_is_nearer() {
1235 let p = policy(1000, 100);
1236 assert_eq!(
1237 wait_step(2, Duration::ZERO, &p),
1238 WaitStep::Sleep(Duration::from_millis(100))
1239 );
1240 assert_eq!(
1241 wait_step(2, Duration::from_millis(950), &p),
1242 WaitStep::Sleep(Duration::from_millis(50))
1243 );
1244 }
1245
1246 #[test]
1247 fn a_zero_interval_never_spins() {
1248 let p = policy(1000, 0);
1249 assert_eq!(
1250 wait_step(1, Duration::ZERO, &p),
1251 WaitStep::Sleep(Duration::from_millis(1))
1252 );
1253 }
1254
1255 use std::sync::Mutex;
1258
1259 use crate::deploy::apply::hold::SystemClock;
1260
1261 fn seen(user: &str) -> DesignerSeen {
1262 (user.to_string(), "P".to_string(), "h".to_string())
1263 }
1264
1265 async fn gate_over(
1268 mode: DesignersMode,
1269 timeout_ms: u64,
1270 script: Vec<Vec<DesignerSeen>>,
1271 ) -> (DesignersVerdict, Vec<DesignerSeen>, Vec<usize>, usize) {
1272 let reads = Mutex::new(0usize);
1273 let policy = DesignersPolicy {
1274 mode,
1275 timeout: Duration::from_millis(timeout_ms),
1276 interval: Duration::from_millis(1),
1277 };
1278 let mut polls = Vec::new();
1279 let (verdict, open) = designers_gate(
1280 || {
1281 let mut n = reads.lock().expect("reads");
1282 let list = script[(*n).min(script.len() - 1)].clone();
1283 *n += 1;
1284 async move { Ok(list) }
1285 },
1286 &policy,
1287 &SystemClock,
1288 &mut |count| {
1289 polls.push(count);
1290 Ok(())
1291 },
1292 )
1293 .await
1294 .expect("the gate");
1295 let n = *reads.lock().expect("reads");
1296 (verdict, open, polls, n)
1297 }
1298
1299 #[tokio::test]
1300 async fn designers_gate_shared() {
1301 let (verdict, open, polls, reads) = gate_over(DesignersMode::Fail, 0, vec![vec![]]).await;
1303 assert_eq!(verdict, DesignersVerdict::Clear);
1304 assert!(open.is_empty() && polls.is_empty());
1305 assert_eq!(reads, 1);
1306
1307 let (verdict, open, polls, reads) =
1309 gate_over(DesignersMode::Fail, 0, vec![vec![seen("alice")]]).await;
1310 assert_eq!(verdict, DesignersVerdict::Open);
1311 assert_eq!(open, [seen("alice")]);
1312 assert!(polls.is_empty());
1313 assert_eq!(reads, 1);
1314
1315 let (verdict, open, ..) =
1317 gate_over(DesignersMode::Proceed, 0, vec![vec![seen("alice")]]).await;
1318 assert_eq!(verdict, DesignersVerdict::Proceeded);
1319 assert_eq!(open, [seen("alice")]);
1320
1321 let (verdict, open, polls, reads) = gate_over(
1323 DesignersMode::Wait,
1324 30_000,
1325 vec![vec![seen("alice")], vec![seen("alice")], vec![]],
1326 )
1327 .await;
1328 assert_eq!(verdict, DesignersVerdict::Clear);
1329 assert!(open.is_empty());
1330 assert_eq!(polls, [1, 1, 0]);
1331 assert_eq!(reads, 3);
1332
1333 let (verdict, open, polls, _) =
1335 gate_over(DesignersMode::Wait, 20, vec![vec![seen("alice")]]).await;
1336 assert_eq!(verdict, DesignersVerdict::Open);
1337 assert_eq!(open, [seen("alice")]);
1338 assert!(polls.len() >= 2, "{polls:?}");
1339 }
1340
1341 #[tokio::test]
1342 async fn designers_gate_read_error_is_the_reads_own() {
1343 let policy = DesignersPolicy::default();
1344 let err = designers_gate(
1345 || async {
1346 Err::<Vec<DesignerSeen>, _>(CoreError::TransportFailed {
1347 operation: "read".to_string(),
1348 node: "nodes.master".to_string(),
1349 detail: "down".to_string(),
1350 })
1351 },
1352 &policy,
1353 &SystemClock,
1354 &mut |_| Ok(()),
1355 )
1356 .await
1357 .expect_err("the read fails");
1358 assert_eq!(err.code(), "transport_failed");
1359 }
1360}