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, SecretCheckMode, check_secret_names, collect_referenced_secrets,
50 declared_secret_providers, secrets_defined_by_render,
51};
52use crate::deploy::target::Target;
53use crate::deploy::transport::{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 hold: &'a HoldModel,
228 pub designers: &'a DesignersPolicy,
230 pub clock: &'a dyn Clock,
232}
233
234pub type DesignerSeen = (String, String, String);
236
237#[derive(Debug, Clone, PartialEq, Eq)]
239pub struct PreflightReport {
240 pub findings: Vec<PreflightFinding>,
242 pub designers_seen: Vec<DesignerSeen>,
244}
245
246impl PreflightReport {
247 pub fn passed(&self) -> bool {
249 self.findings
250 .iter()
251 .all(|f| f.status != FindingStatus::Fail)
252 }
253
254 pub fn failures(&self) -> Vec<PreflightFailure> {
256 self.findings
257 .iter()
258 .filter(|f| f.status == FindingStatus::Fail)
259 .map(|f| PreflightFailure {
260 check: f.check.as_str().to_string(),
261 detail: f.detail.clone(),
262 })
263 .collect()
264 }
265}
266
267pub async fn run_preflights(
274 input: &PreflightInput<'_>,
275 steps: &mut Steps<'_>,
276) -> Result<PreflightReport, CoreError> {
277 let mut findings = Vec::with_capacity(PreflightCheck::ALL.len());
278 let mode = input.target.mode.as_str();
279
280 let master_seen = normalize_deployment_mode(input.master.deployment_mode().await?);
281 findings.push(mode_finding(
282 PreflightCheck::ModeMaster,
283 "master",
284 Some(master_seen),
285 mode,
286 ));
287 findings.push(if input.target.nodes.backup.is_some() {
288 let backup_seen = match input.backup {
289 Some(backup) => Some(backup.deployment_mode().await?),
290 None => None,
291 };
292 mode_finding(PreflightCheck::ModeBackup, "backup", backup_seen, mode)
293 } else {
294 PreflightFinding {
295 check: PreflightCheck::ModeBackup,
296 status: FindingStatus::Skipped,
297 detail: "the target has no backup node".to_string(),
298 }
299 });
300 findings.push(wrong_target_finding(input).await?);
301 findings.push(write_rest_finding(input.gateway).await?);
302 findings.push(write_host_finding(input.host, input.touched_paths).await?);
303 let (designers, designers_seen) = designers_finding(input, steps).await?;
304 findings.push(designers);
305 findings.push(hold_budget_finding(input.hold, input.touched_files));
306 findings.push(master_active_finding(input).await?);
307 findings.push(backup_read_finding(input).await);
308 findings.push(secrets_finding(input).await?);
309
310 Ok(PreflightReport {
311 findings,
312 designers_seen,
313 })
314}
315
316fn finding(check: PreflightCheck, ok: bool, detail: String) -> PreflightFinding {
317 PreflightFinding {
318 check,
319 status: if ok {
320 FindingStatus::Pass
321 } else {
322 FindingStatus::Fail
323 },
324 detail,
325 }
326}
327
328fn printable(text: &str) -> String {
330 let mut out: String = text
331 .chars()
332 .filter(|c| !c.is_control())
333 .take(SHOWN_NAME_CHARS)
334 .collect();
335 if text.chars().count() > SHOWN_NAME_CHARS {
336 out.push_str("...");
337 }
338 out
339}
340
341fn mode_finding(
346 check: PreflightCheck,
347 node: &str,
348 seen: Option<Option<String>>,
349 target_mode: &str,
350) -> PreflightFinding {
351 let Some(seen) = seen else {
352 return finding(
355 check,
356 false,
357 format!("the {node} node has no gateway connection, so its mode cannot be confirmed"),
358 );
359 };
360 let ok = seen.as_deref() == Some(target_mode);
361 let detail = match (&seen, ok) {
362 (_, true) => format!("the {node} runs mode `{}`", printable(target_mode)),
363 (Some(seen), false) => format!(
364 "the {node} runs mode `{}` but the target's mode is `{}`",
365 printable(seen),
366 printable(target_mode)
367 ),
368 (None, false) => format!(
369 "the {node} runs no deployment mode but the target's mode is `{}`",
370 printable(target_mode)
371 ),
372 };
373 finding(check, ok, detail)
374}
375
376async fn host_system_name(
380 reader: &dyn TreeReader,
381 mode: &str,
382) -> Result<Result<String, &'static str>, CoreError> {
383 let (module, ty) = SYSTEM_PROPERTIES_UNIT
384 .split_once('/')
385 .unwrap_or((SYSTEM_PROPERTIES_UNIT, ""));
386 let observed = reader
387 .read(&Scope {
388 mode: mode.to_string(),
389 types: vec![(module.to_string(), ty.to_string())],
390 tag_providers: Vec::new(),
391 })
392 .await?;
393 if observed
396 .local_units
397 .iter()
398 .any(|u| u == SYSTEM_PROPERTIES_UNIT)
399 {
400 return Ok(Err("it is overridden in the local collection"));
401 }
402 let path = RelPath::new(&format!("{SYSTEM_PROPERTIES_UNIT}/config.json"))
403 .map_err(|why| CoreError::Internal(format!("system-properties path: {why}")))?;
404 let Some(blob) = observed
405 .mode
406 .get(&path)
407 .or_else(|| observed.core.get(&path))
408 else {
409 return Ok(Err("the host has no system-properties file"));
410 };
411 let Ok(doc) = serde_json::from_slice::<Value>(&blob.bytes) else {
412 return Ok(Err("the system-properties file is not JSON"));
413 };
414 match doc.get("systemName").and_then(Value::as_str) {
415 Some(name) if !name.trim().is_empty() => Ok(Ok(name.to_string())),
416 _ => Ok(Err("the system-properties file has no systemName")),
417 }
418}
419
420pub(crate) fn wrong_target_problems(
428 rest: Option<&str>,
429 host: Result<&str, &str>,
430 declared: Option<&str>,
431) -> Vec<String> {
432 let mut problems: Vec<String> = Vec::new();
433 match (rest, host) {
434 (None, _) => problems.push("the gateway reports no name over REST".to_string()),
435 (Some(_), Err(why)) => problems.push(format!("the host's systemName is unreadable: {why}")),
436 (Some(rest), Ok(host)) if rest != host => {
437 problems.push("the gateway's REST name is not the host's systemName".to_string())
438 }
439 (Some(_), Ok(_)) => {}
440 }
441 if let (Some(rest), Some(declared)) = (rest, declared)
442 && rest != declared
443 {
444 problems.push("the gateway's REST name is not nodes.master.gateway_name".to_string());
445 }
446 problems
447}
448
449pub(crate) fn wrong_target_failure(problems: &[String]) -> PreflightFailure {
452 PreflightFailure {
453 check: PreflightCheck::WrongTarget.as_str().to_string(),
454 detail: problems.join("; "),
455 }
456}
457
458pub(crate) async fn wrong_target_gate(
463 rest: Option<&str>,
464 reader: &dyn TreeReader,
465 mode: &str,
466 declared: Option<&str>,
467) -> Result<Vec<String>, CoreError> {
468 let host = host_system_name(reader, mode).await?;
469 let host = host.as_ref().map(String::as_str).map_err(|why| *why);
470 Ok(wrong_target_problems(rest, host, declared))
471}
472
473async fn wrong_target_finding(input: &PreflightInput<'_>) -> Result<PreflightFinding, CoreError> {
474 let check = PreflightCheck::WrongTarget;
475 let rest = input
476 .gateway
477 .gateway_info()
478 .await?
479 .name
480 .filter(|n| !n.trim().is_empty());
481 let problems = wrong_target_gate(
482 rest.as_deref(),
483 input.reader,
484 &input.target.mode,
485 input.target.nodes.master.gateway_name.as_deref(),
486 )
487 .await?;
488 if problems.is_empty() {
489 Ok(finding(
490 check,
491 true,
492 "the REST name matches the host's systemName".to_string(),
493 ))
494 } else {
495 Ok(finding(check, false, problems.join("; ")))
496 }
497}
498
499async fn write_rest_finding(gateway: &dyn GatewayApi) -> Result<PreflightFinding, CoreError> {
502 let check = PreflightCheck::WriteRest;
503 match gateway.scan_projects().await {
504 Ok(()) => Ok(finding(
505 check,
506 true,
507 "the project scan was accepted".to_string(),
508 )),
509 Err(CoreError::Auth { status: 403, .. }) => Ok(finding(
510 check,
511 false,
512 "the token is read-only (HTTP 403 on the project scan)".to_string(),
513 )),
514 Err(other) => Err(other),
515 }
516}
517
518async fn write_host_finding(
521 host: &dyn ApplyHost,
522 touched: &[String],
523) -> Result<PreflightFinding, CoreError> {
524 let check = PreflightCheck::WriteHost;
525 let report = host.probe_paths(touched).await?;
526 if report.writable {
527 return Ok(finding(
528 check,
529 true,
530 "the mode collection and the ign-deploy root are writable".to_string(),
531 ));
532 }
533 let detail = if report.problems.is_empty() {
534 "the host is not writable".to_string()
535 } else {
536 report.problems.join("; ")
537 };
538 Ok(finding(check, false, detail))
539}
540
541pub(crate) async fn read_designers(
544 gateway: &dyn GatewayApi,
545) -> Result<Vec<DesignerSeen>, CoreError> {
546 let page = gateway.designers(&ListQuery::default()).await?;
547 Ok(page
548 .items
549 .into_iter()
550 .map(|d| {
551 (
552 printable(&d.user),
553 printable(&d.project),
554 printable(&d.address),
555 )
556 })
557 .collect())
558}
559
560pub(crate) fn designers_detail(open: &[DesignerSeen]) -> String {
562 let each: Vec<String> = open
563 .iter()
564 .map(|(user, project, host)| {
565 let project = if project.is_empty() {
566 "no project"
567 } else {
568 project
569 };
570 format!("{user} on {project} from {host}")
571 })
572 .collect();
573 let take = each.len().min(APPLY_LIST_SHOWN);
574 let mut text = format!(
575 "{} Designer session(s) open: {}",
576 open.len(),
577 each[..take].join(", ")
578 );
579 if each.len() > take {
580 text.push_str(&format!(" and {} more", each.len() - take));
581 }
582 text
583}
584
585#[derive(Debug, Clone, Copy, PartialEq, Eq)]
587pub(crate) enum WaitStep {
588 Clear,
590 TimedOut,
592 Sleep(Duration),
594}
595
596pub(crate) fn wait_step(open: usize, elapsed: Duration, policy: &DesignersPolicy) -> WaitStep {
599 if open == 0 {
600 WaitStep::Clear
601 } else if elapsed >= policy.timeout {
602 WaitStep::TimedOut
603 } else {
604 WaitStep::Sleep(
605 policy
606 .interval
607 .max(Duration::from_millis(1))
608 .min(policy.timeout - elapsed),
609 )
610 }
611}
612
613#[derive(Debug, Clone, Copy, PartialEq, Eq)]
615pub enum DesignersVerdict {
616 Clear,
618 Open,
621 Proceeded,
624}
625
626pub(crate) async fn designers_gate<F, Fut>(
634 mut read: F,
635 policy: &DesignersPolicy,
636 clock: &dyn Clock,
637 on_poll: &mut (dyn FnMut(usize) -> Result<(), CoreError> + Send),
638) -> Result<(DesignersVerdict, Vec<DesignerSeen>), CoreError>
639where
640 F: FnMut() -> Fut,
641 Fut: std::future::Future<Output = Result<Vec<DesignerSeen>, CoreError>>,
642{
643 let started = clock.now();
644 let mut open = read().await?;
645 if policy.mode == DesignersMode::Wait {
646 loop {
647 on_poll(open.len())?;
648 let elapsed = clock.now().saturating_duration_since(started);
649 match wait_step(open.len(), elapsed, policy) {
650 WaitStep::Clear | WaitStep::TimedOut => break,
651 WaitStep::Sleep(pause) => {
652 tokio::time::sleep(pause).await;
653 open = read().await?;
654 }
655 }
656 }
657 }
658 let verdict = if open.is_empty() {
659 DesignersVerdict::Clear
660 } else if policy.mode == DesignersMode::Proceed {
661 DesignersVerdict::Proceeded
662 } else {
663 DesignersVerdict::Open
664 };
665 Ok((verdict, open))
666}
667
668async fn designers_finding(
669 input: &PreflightInput<'_>,
670 steps: &mut Steps<'_>,
671) -> Result<(PreflightFinding, Vec<DesignerSeen>), CoreError> {
672 let check = PreflightCheck::Designers;
673 let (verdict, open) = designers_gate(
674 || read_designers(input.gateway),
675 input.designers,
676 input.clock,
677 &mut |count| steps.emit(ApplyEvent::DesignersPoll { open: count }),
678 )
679 .await?;
680 let found = match verdict {
681 DesignersVerdict::Clear => finding(check, true, "no Designer session is open".to_string()),
682 DesignersVerdict::Proceeded => {
683 steps.emit(ApplyEvent::DesignersProceed {
686 sessions: open.clone(),
687 })?;
688 finding(
689 check,
690 true,
691 format!(
692 "{}; going ahead because --designers proceed was given",
693 designers_detail(&open)
694 ),
695 )
696 }
697 DesignersVerdict::Open => finding(check, false, designers_detail(&open)),
698 };
699 Ok((found, open))
700}
701
702pub(super) fn hold_budget_finding(hold: &HoldModel, files: usize) -> PreflightFinding {
705 let estimate = hold.estimate_commit_s(files);
706 let needed = hold_for(estimate, hold.p95_scan_s);
707 let detail = format!(
708 "{files} file(s): estimated commit {estimate:.1} s and scan {:.1} s need a hold of {needed} s; \
709 the budget is {} s",
710 hold.p95_scan_s, hold.hold_s
711 );
712 finding(PreflightCheck::HoldBudget, needed <= hold.hold_s, detail)
713}
714
715async fn master_active_finding(input: &PreflightInput<'_>) -> Result<PreflightFinding, CoreError> {
728 let check = PreflightCheck::MasterActive;
729 if input.target.nodes.backup.is_none() {
730 return Ok(PreflightFinding {
731 check,
732 status: FindingStatus::Skipped,
733 detail: "the target has no backup node".to_string(),
734 });
735 }
736 let Some(backup) = input.backup else {
737 return Ok(finding(
738 check,
739 false,
740 "the backup node has no gateway connection, so which node is active cannot be confirmed"
741 .to_string(),
742 ));
743 };
744 let master = input.gateway.redundancy_status().await?;
745 let standby = backup.status().await?;
746 let level = |wire: &RedundancyStatusWire| {
747 wire.activity_level
748 .as_deref()
749 .map_or_else(|| "none".to_string(), printable)
750 };
751 let (master_level, backup_level) = (level(&master), level(&standby));
752 let both = format!("master {master_level}, backup {backup_level}");
753 Ok(match active_node(&master, &standby) {
754 ActiveNode::OnlyA => finding(
755 check,
756 true,
757 format!("the master is the active node ({both})"),
758 ),
759 ActiveNode::OnlyB => finding(
760 check,
761 false,
762 format!(
763 "the backup reports activityLevel Active and the master {master_level}: \
764 activity moved to the backup (the pair switched over); apply and rollback write only to the active master ({both})"
765 ),
766 ),
767 ActiveNode::Both => finding(
768 check,
769 false,
770 format!("both nodes report activityLevel Active (split-brain) ({both})"),
771 ),
772 ActiveNode::Neither => finding(
773 check,
774 false,
775 format!("neither node reports activityLevel Active ({both})"),
776 ),
777 })
778}
779
780async fn backup_read_finding(input: &PreflightInput<'_>) -> PreflightFinding {
794 let check = PreflightCheck::BackupRead;
795 let skipped = |detail: &str| PreflightFinding {
796 check,
797 status: FindingStatus::Skipped,
798 detail: detail.to_string(),
799 };
800 if input.target.nodes.backup.is_none() {
801 return skipped("the target has no backup node");
802 }
803 let Some(declared) = input.declared else {
804 return skipped("a rollback renders no tree, so the backup's files are not compared");
805 };
806 let Some(backup) = input.backup else {
807 return skipped("the backup node has no gateway connection, so its files are not read");
808 };
809 if !backup.has_files() {
810 return skipped(
811 "nodes.backup declares no ssh or local transport, so the file-hash check did not run",
812 );
813 }
814 let scope = Scope::from_manifest(&declared.manifest, &declared.target.mode);
815 match backup.read_files(&scope).await {
816 Some(Ok(observed)) => finding(
817 check,
818 true,
819 format!(
820 "read {} files of the backup's mode collection",
821 observed.core.len() + observed.mode.len()
822 ),
823 ),
824 Some(Err(err)) => finding(
825 check,
826 false,
827 format!(
828 "the backup's mode collection cannot be read: {}",
829 printable(err.code())
830 ),
831 ),
832 None => skipped(
833 "nodes.backup declares no ssh or local transport, so the file-hash check did not run",
834 ),
835 }
836}
837
838async fn secrets_finding(input: &PreflightInput<'_>) -> Result<PreflightFinding, CoreError> {
854 let check = PreflightCheck::Secrets;
855 let skipped = |detail: &str| PreflightFinding {
856 check,
857 status: FindingStatus::Skipped,
858 detail: detail.to_string(),
859 };
860 let Some(declared) = input.declared else {
861 return Ok(skipped(
862 "a rollback renders no tree, so no secret reference is checked",
863 ));
864 };
865 let all_referenced = collect_referenced_secrets(&declared.outcome.tree);
866 if all_referenced.is_empty() {
867 return Ok(skipped("the rendered tree references no secret"));
868 }
869 let defined = secrets_defined_by_render(&declared.outcome.tree);
874 let referenced: BTreeSet<(String, String)> =
875 all_referenced.difference(&defined).cloned().collect();
876 if referenced.is_empty() {
877 return Ok(skipped(
878 "every secret name the rendered tree references is defined by the tree itself",
879 ));
880 }
881 let own_names = all_referenced.len() - referenced.len();
882 let declared_providers = declared_secret_providers(&declared.outcome.tree);
883 let providers: BTreeSet<&str> = referenced.iter().map(|(p, _)| p.as_str()).collect();
884
885 let mut reports = Vec::with_capacity(2);
886 let mut on_master = BTreeMap::new();
887 for provider in &providers {
888 on_master.insert(
889 (*provider).to_string(),
890 input.master.secret_names(provider).await?,
891 );
892 }
893 reports.push(check_secret_names(
894 "master",
895 &referenced,
896 &on_master,
897 &declared_providers,
898 SecretCheckMode::Preflight,
899 ));
900 if input.target.nodes.backup.is_some() {
901 let Some(backup) = input.backup else {
902 return Ok(finding(
903 check,
904 false,
905 "the backup node has no gateway connection, so its secret names cannot be checked"
906 .to_string(),
907 ));
908 };
909 let mut on_backup = BTreeMap::new();
910 for provider in &providers {
911 on_backup.insert(
912 (*provider).to_string(),
913 backup.secret_names(provider).await?,
914 );
915 }
916 reports.push(check_secret_names(
917 "backup",
918 &referenced,
919 &on_backup,
920 &declared_providers,
921 SecretCheckMode::Preflight,
922 ));
923 }
924
925 let missing: Vec<String> = reports
926 .iter()
927 .flat_map(|r| {
928 r.missing.iter().map(|m| {
929 format!(
930 "{}: {}/{}",
931 r.node,
932 printable(&m.provider),
933 printable(&m.name)
934 )
935 })
936 })
937 .collect();
938 let mut notes: Vec<String> = Vec::new();
939 for r in &reports {
940 if !r.skipped_providers.is_empty() {
941 notes.push(format!(
942 "{}: skipped {} (declared by this render, not on the node yet)",
943 r.node,
944 provider_list(&r.skipped_providers)
945 ));
946 }
947 if !r.unverifiable.is_empty() {
948 notes.push(format!(
949 "{}: unverifiable {} (the provider cannot list its names)",
950 r.node,
951 provider_list(&r.unverifiable)
952 ));
953 }
954 }
955 if missing.is_empty() {
956 let mut detail = format!(
957 "{} referenced secret name(s) in {} provider(s) checked on {}",
958 referenced.len(),
959 providers.len(),
960 reports
961 .iter()
962 .map(|r| r.node.as_str())
963 .collect::<Vec<_>>()
964 .join(" and ")
965 );
966 if own_names > 0 {
967 detail.push_str(&format!(
968 "; {own_names} name(s) defined by this render were not required on the node"
969 ));
970 }
971 for note in notes {
972 detail.push_str("; ");
973 detail.push_str(¬e);
974 }
975 return Ok(finding(check, true, detail));
976 }
977 let take = missing.len().min(APPLY_LIST_SHOWN);
978 let mut detail = format!(
979 "{} referenced secret name(s) missing: {}",
980 missing.len(),
981 missing[..take].join(", ")
982 );
983 if missing.len() > take {
984 detail.push_str(&format!(" and {} more", missing.len() - take));
985 }
986 for note in notes {
987 detail.push_str("; ");
988 detail.push_str(¬e);
989 }
990 Ok(finding(check, false, detail))
991}
992
993fn provider_list(providers: &[String]) -> String {
995 let take = providers.len().min(APPLY_LIST_SHOWN);
996 let mut text = providers[..take]
997 .iter()
998 .map(|p| printable(p))
999 .collect::<Vec<_>>()
1000 .join(", ");
1001 if providers.len() > take {
1002 text.push_str(&format!(" and {} more", providers.len() - take));
1003 }
1004 text
1005}
1006
1007#[cfg(test)]
1008mod tests {
1009 use super::*;
1010
1011 #[test]
1012 fn ids_are_the_contract_words_in_report_order() {
1013 let ids: Vec<&str> = PreflightCheck::ALL.iter().map(|c| c.as_str()).collect();
1014 assert_eq!(
1015 ids,
1016 [
1017 "mode_master",
1018 "mode_backup",
1019 "wrong_target",
1020 "write_rest",
1021 "write_host",
1022 "designers",
1023 "hold_budget",
1024 "master_active",
1025 "backup_read",
1026 "secrets"
1027 ]
1028 );
1029 }
1030
1031 #[test]
1032 fn production_defaults_are_the_decided_numbers() {
1033 assert_eq!((DESIGNERS_POLL_S, DESIGNERS_TIMEOUT_DEFAULT_S), (10, 600));
1034 let d = DesignersPolicy::default();
1035 assert_eq!(d.mode, DesignersMode::Fail);
1036 assert_eq!(d.timeout, Duration::from_secs(600));
1037 assert_eq!(d.interval, Duration::from_secs(10));
1038 }
1039
1040 #[test]
1041 fn the_session_list_is_capped_and_counts_the_rest() {
1042 let open: Vec<DesignerSeen> = (0..13)
1043 .map(|i| (format!("u{i}"), "P".to_string(), "h".to_string()))
1044 .collect();
1045 let text = designers_detail(&open);
1046 assert!(text.starts_with("13 Designer session(s) open: u0 on P from h"));
1047 assert!(text.ends_with("and 3 more"), "{text}");
1048 assert!(!text.contains("u12"), "{text}");
1049 }
1050
1051 #[test]
1052 fn a_designer_without_a_project_is_named_so() {
1053 let text = designers_detail(&[("bob".into(), String::new(), "h".into())]);
1054 assert!(text.contains("bob on no project from h"), "{text}");
1055 }
1056
1057 #[test]
1058 fn printable_drops_controls_and_caps_length() {
1059 assert_eq!(printable("a\nb\u{1b}c"), "abc");
1060 let long = "x".repeat(SHOWN_NAME_CHARS + 5);
1061 assert_eq!(printable(&long).len(), SHOWN_NAME_CHARS + 3);
1062 }
1063
1064 #[test]
1065 fn hold_budget_boundary_is_inclusive() {
1066 let hold = |hold_s| HoldModel {
1068 hold_s,
1069 commit_fixed_s: 1.0,
1070 commit_per_file_s: 0.5,
1071 p95_scan_s: 1.0,
1072 };
1073 assert_eq!(
1074 hold_budget_finding(&hold(14), 9).status,
1075 FindingStatus::Pass
1076 );
1077 let over = hold_budget_finding(&hold(14), 10);
1078 assert_eq!(over.status, FindingStatus::Fail);
1079 assert!(over.detail.contains("hold of 15 s"), "{}", over.detail);
1080 assert!(over.detail.contains("budget is 14 s"), "{}", over.detail);
1081 assert!(
1082 over.detail.contains("estimated commit 6.0 s"),
1083 "{}",
1084 over.detail
1085 );
1086 }
1087
1088 fn policy(timeout_ms: u64, interval_ms: u64) -> DesignersPolicy {
1089 DesignersPolicy {
1090 mode: DesignersMode::Wait,
1091 timeout: Duration::from_millis(timeout_ms),
1092 interval: Duration::from_millis(interval_ms),
1093 }
1094 }
1095
1096 #[test]
1097 fn wait_with_nothing_open_is_clear_at_any_time() {
1098 let p = policy(1000, 100);
1099 assert_eq!(wait_step(0, Duration::ZERO, &p), WaitStep::Clear);
1100 assert_eq!(wait_step(0, Duration::from_secs(99), &p), WaitStep::Clear);
1101 }
1102
1103 #[test]
1104 fn wait_timeout_equal_to_the_elapsed_time_has_passed() {
1105 let p = policy(1000, 100);
1107 assert_eq!(
1108 wait_step(1, Duration::from_millis(999), &p),
1109 WaitStep::Sleep(Duration::from_millis(1))
1110 );
1111 assert_eq!(
1112 wait_step(1, Duration::from_millis(1000), &p),
1113 WaitStep::TimedOut
1114 );
1115 assert_eq!(
1116 wait_step(1, Duration::from_millis(1001), &p),
1117 WaitStep::TimedOut
1118 );
1119 }
1120
1121 #[test]
1122 fn wait_sleeps_one_interval_unless_the_timeout_is_nearer() {
1123 let p = policy(1000, 100);
1124 assert_eq!(
1125 wait_step(2, Duration::ZERO, &p),
1126 WaitStep::Sleep(Duration::from_millis(100))
1127 );
1128 assert_eq!(
1129 wait_step(2, Duration::from_millis(950), &p),
1130 WaitStep::Sleep(Duration::from_millis(50))
1131 );
1132 }
1133
1134 #[test]
1135 fn a_zero_interval_never_spins() {
1136 let p = policy(1000, 0);
1137 assert_eq!(
1138 wait_step(1, Duration::ZERO, &p),
1139 WaitStep::Sleep(Duration::from_millis(1))
1140 );
1141 }
1142
1143 use std::sync::Mutex;
1146
1147 use crate::deploy::apply::hold::SystemClock;
1148
1149 fn seen(user: &str) -> DesignerSeen {
1150 (user.to_string(), "P".to_string(), "h".to_string())
1151 }
1152
1153 async fn gate_over(
1156 mode: DesignersMode,
1157 timeout_ms: u64,
1158 script: Vec<Vec<DesignerSeen>>,
1159 ) -> (DesignersVerdict, Vec<DesignerSeen>, Vec<usize>, usize) {
1160 let reads = Mutex::new(0usize);
1161 let policy = DesignersPolicy {
1162 mode,
1163 timeout: Duration::from_millis(timeout_ms),
1164 interval: Duration::from_millis(1),
1165 };
1166 let mut polls = Vec::new();
1167 let (verdict, open) = designers_gate(
1168 || {
1169 let mut n = reads.lock().expect("reads");
1170 let list = script[(*n).min(script.len() - 1)].clone();
1171 *n += 1;
1172 async move { Ok(list) }
1173 },
1174 &policy,
1175 &SystemClock,
1176 &mut |count| {
1177 polls.push(count);
1178 Ok(())
1179 },
1180 )
1181 .await
1182 .expect("the gate");
1183 let n = *reads.lock().expect("reads");
1184 (verdict, open, polls, n)
1185 }
1186
1187 #[tokio::test]
1188 async fn designers_gate_shared() {
1189 let (verdict, open, polls, reads) = gate_over(DesignersMode::Fail, 0, vec![vec![]]).await;
1191 assert_eq!(verdict, DesignersVerdict::Clear);
1192 assert!(open.is_empty() && polls.is_empty());
1193 assert_eq!(reads, 1);
1194
1195 let (verdict, open, polls, reads) =
1197 gate_over(DesignersMode::Fail, 0, vec![vec![seen("alice")]]).await;
1198 assert_eq!(verdict, DesignersVerdict::Open);
1199 assert_eq!(open, [seen("alice")]);
1200 assert!(polls.is_empty());
1201 assert_eq!(reads, 1);
1202
1203 let (verdict, open, ..) =
1205 gate_over(DesignersMode::Proceed, 0, vec![vec![seen("alice")]]).await;
1206 assert_eq!(verdict, DesignersVerdict::Proceeded);
1207 assert_eq!(open, [seen("alice")]);
1208
1209 let (verdict, open, polls, reads) = gate_over(
1211 DesignersMode::Wait,
1212 30_000,
1213 vec![vec![seen("alice")], vec![seen("alice")], vec![]],
1214 )
1215 .await;
1216 assert_eq!(verdict, DesignersVerdict::Clear);
1217 assert!(open.is_empty());
1218 assert_eq!(polls, [1, 1, 0]);
1219 assert_eq!(reads, 3);
1220
1221 let (verdict, open, polls, _) =
1223 gate_over(DesignersMode::Wait, 20, vec![vec![seen("alice")]]).await;
1224 assert_eq!(verdict, DesignersVerdict::Open);
1225 assert_eq!(open, [seen("alice")]);
1226 assert!(polls.len() >= 2, "{polls:?}");
1227 }
1228
1229 #[tokio::test]
1230 async fn designers_gate_read_error_is_the_reads_own() {
1231 let policy = DesignersPolicy::default();
1232 let err = designers_gate(
1233 || async {
1234 Err::<Vec<DesignerSeen>, _>(CoreError::TransportFailed {
1235 operation: "read".to_string(),
1236 node: "nodes.master".to_string(),
1237 detail: "down".to_string(),
1238 })
1239 },
1240 &policy,
1241 &SystemClock,
1242 &mut |_| Ok(()),
1243 )
1244 .await
1245 .expect_err("the read fails");
1246 assert_eq!(err.code(), "transport_failed");
1247 }
1248}