1use std::collections::{BTreeMap, BTreeSet};
40use std::time::{Duration, SystemTime, UNIX_EPOCH};
41
42use serde::Serialize;
43use serde_json::Value;
44
45use super::layout::RelPath;
46use super::lint::referenced_secret_names;
47use super::node;
48use super::transport::{FileBlob, ObservedFiles, Scope, TreeReader};
49use super::tree::RenderedTree;
50use super::verify::{self, CheckStatus, Expected, Finding, Remote};
51use crate::client::deploy_api::SecretNameListing;
52use crate::client::logs::{DEFAULT_LOG_LIMIT, LogQuery};
53use crate::client::mode::normalize_deployment_mode;
54use crate::client::redundancy::RedundancyStatusWire;
55use crate::client::{DeployApi, GatewayApi};
56use crate::error::{APPLY_LIST_SHOWN, CoreError};
57
58pub const BACKUP_POLL_S: u64 = 5;
61
62pub const BACKUP_TIMEOUT_DEFAULT_S: u64 = 300;
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub struct BackupPolicy {
68 pub budget: Duration,
70 pub interval: Duration,
72}
73
74impl Default for BackupPolicy {
75 fn default() -> Self {
76 BackupPolicy {
77 budget: Duration::from_secs(BACKUP_TIMEOUT_DEFAULT_S),
78 interval: Duration::from_secs(BACKUP_POLL_S),
79 }
80 }
81}
82
83pub struct BackupNode<'a> {
89 gateway: &'a dyn GatewayApi,
90 deploy: &'a dyn DeployApi,
91 reader: Option<&'a dyn TreeReader>,
92}
93
94impl<'a> BackupNode<'a> {
95 pub fn new(
98 gateway: &'a dyn GatewayApi,
99 deploy: &'a dyn DeployApi,
100 reader: Option<&'a dyn TreeReader>,
101 ) -> Self {
102 BackupNode {
103 gateway,
104 deploy,
105 reader,
106 }
107 }
108
109 pub fn has_files(&self) -> bool {
111 self.reader.is_some()
112 }
113
114 pub async fn status(&self) -> Result<RedundancyStatusWire, CoreError> {
116 self.gateway.redundancy_status().await
117 }
118
119 pub async fn deployment_mode(&self) -> Result<Option<String>, CoreError> {
121 Ok(normalize_deployment_mode(
122 self.deploy.deployment_mode().await?,
123 ))
124 }
125
126 pub async fn read_back(
129 &self,
130 expected: &Expected,
131 ) -> Result<BTreeMap<String, Remote>, CoreError> {
132 crate::actions::deploy::gather_read_back(self.deploy, expected).await
133 }
134
135 pub async fn read_files(&self, scope: &Scope) -> Option<Result<ObservedFiles, CoreError>> {
138 match self.reader {
139 Some(reader) => Some(reader.read(scope).await),
140 None => None,
141 }
142 }
143
144 pub async fn secret_names(&self, provider: &str) -> Result<SecretNameListing, CoreError> {
147 self.deploy.secret_names(provider).await
148 }
149
150 pub async fn sync_exhausted(&self, since_ms: i64) -> Result<bool, CoreError> {
167 let query = LogQuery {
168 start_time: Some(since_ms.max(0)),
169 min_level: Some("ERROR".to_string()),
170 limit: DEFAULT_LOG_LIMIT,
171 ..LogQuery::default()
172 };
173 let envelope = self.gateway.logs(&query).await?;
174 Ok(envelope
175 .items
176 .iter()
177 .any(|entry| entry.timestamp >= since_ms && is_sync_exhausted(&entry.message)))
178 }
179}
180
181pub fn wall_clock_ms() -> i64 {
184 SystemTime::now()
185 .duration_since(UNIX_EPOCH)
186 .ok()
187 .and_then(|elapsed| i64::try_from(elapsed.as_millis()).ok())
188 .unwrap_or(0)
189}
190
191const SYNC_EXHAUSTED_TEXT: &str = "exceeds the max attempt count";
197
198pub fn is_sync_exhausted(message: &str) -> bool {
200 message.contains(SYNC_EXHAUSTED_TEXT)
201}
202
203#[derive(Debug, Clone, PartialEq, Eq)]
205pub struct BackupSignals {
206 pub peer_connected: bool,
208 pub sync_pending: bool,
210 pub last_sync_before: Option<i64>,
213 pub last_sync_now: Option<i64>,
215 pub project_state: Option<String>,
217}
218
219impl BackupSignals {
220 pub fn from_status(before: Option<i64>, now: &RedundancyStatusWire) -> Self {
226 BackupSignals {
227 peer_connected: now.peer_connected,
228 sync_pending: now.sync_pending,
229 last_sync_before: before.filter(|value| *value >= 0),
230 last_sync_now: now.last_sync_epoch_ms(),
231 project_state: now.project_state.as_deref().and_then(status_word),
232 }
233 }
234
235 pub fn advanced(&self) -> bool {
238 match (self.last_sync_before, self.last_sync_now) {
239 (_, None) => false,
240 (None, Some(_)) => true,
241 (Some(before), Some(now)) => now > before,
242 }
243 }
244
245 pub fn linked(&self) -> bool {
247 self.peer_connected && !self.sync_pending
248 }
249}
250
251fn status_word(text: &str) -> Option<String> {
254 let plain = !text.is_empty()
255 && text.chars().count() <= 32
256 && text
257 .chars()
258 .all(|c| c.is_alphanumeric() || matches!(c, ' ' | '-' | '_'));
259 plain.then(|| text.to_string())
260}
261
262#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
265#[serde(rename_all = "snake_case")]
266pub enum UnsyncedReason {
267 Unreachable,
269 NotSyncing,
271 ContentDiffers,
273 SyncExhausted,
275}
276
277impl UnsyncedReason {
278 pub fn as_str(self) -> &'static str {
280 match self {
281 UnsyncedReason::Unreachable => "unreachable",
282 UnsyncedReason::NotSyncing => "not_syncing",
283 UnsyncedReason::ContentDiffers => "content_differs",
284 UnsyncedReason::SyncExhausted => "sync_exhausted",
285 }
286 }
287}
288
289#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
291#[serde(rename_all = "snake_case")]
292pub enum BackupCheckId {
293 Redundancy,
295 Mode,
297 ReadBack,
299 Files,
301}
302
303#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
305pub struct BackupCheck {
306 pub id: BackupCheckId,
308 pub status: CheckStatus,
310 pub detail: String,
312 pub findings: Vec<Finding>,
314}
315
316#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
319pub struct ResyncRecord {
320 pub ok: bool,
322 pub code: Option<String>,
324}
325
326#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
329pub struct BackupReport {
330 pub converged: bool,
332 pub reason: Option<UnsyncedReason>,
334 pub budget_s: u64,
336 pub waited_s: u64,
338 pub resync: Option<ResyncRecord>,
340 pub last_sync_before: Option<i64>,
342 pub last_sync_after: Option<i64>,
344 pub project_state: Option<String>,
346 pub files_checked: usize,
348 pub checks: Vec<BackupCheck>,
350}
351
352fn finding(code: &'static str, subject: &str, detail: &str) -> Finding {
353 Finding {
354 code,
355 subject: subject.to_string(),
356 detail: detail.to_string(),
357 }
358}
359
360pub fn redundancy_check(
364 signals: &Result<BackupSignals, String>,
365 require_advance: bool,
366) -> BackupCheck {
367 let signals = match signals {
368 Ok(signals) => signals,
369 Err(code) => {
370 let detail = format!("unreachable: {code}");
371 return BackupCheck {
372 id: BackupCheckId::Redundancy,
373 status: CheckStatus::Fail,
374 findings: vec![finding("backup_unreachable", "redundancy", &detail)],
375 detail,
376 };
377 }
378 };
379 let mut findings = Vec::new();
380 if !signals.peer_connected {
381 findings.push(finding(
382 "peer_not_connected",
383 "peerConnected",
384 "peerConnected is false",
385 ));
386 }
387 if signals.sync_pending {
388 findings.push(finding(
389 "sync_pending",
390 "syncPending",
391 "syncPending is true",
392 ));
393 }
394 if require_advance && !signals.advanced() {
395 findings.push(finding(
396 "sync_not_advanced",
397 "lastSyncTimestamp",
398 "lastSyncTimestamp has not advanced past the pre-apply sample",
399 ));
400 }
401 if findings.is_empty() {
402 let detail = if require_advance {
403 "peerConnected, no sync pending, lastSyncTimestamp advanced"
404 } else {
405 "peerConnected, no sync pending"
406 };
407 return BackupCheck {
408 id: BackupCheckId::Redundancy,
409 status: CheckStatus::Pass,
410 detail: detail.to_string(),
411 findings,
412 };
413 }
414 let names: Vec<&str> = findings.iter().map(|f| f.subject.as_str()).collect();
415 BackupCheck {
416 id: BackupCheckId::Redundancy,
417 status: CheckStatus::Fail,
418 detail: format!("the backup is not settled: {}", names.join(", ")),
419 findings,
420 }
421}
422
423pub fn unreachable_check(id: BackupCheckId, code: &str) -> BackupCheck {
426 let detail = format!("unreachable: {code}");
427 BackupCheck {
428 id,
429 status: CheckStatus::Fail,
430 findings: vec![finding("backup_unreachable", id.word(), &detail)],
431 detail,
432 }
433}
434
435impl BackupCheckId {
436 pub fn word(self) -> &'static str {
438 match self {
439 BackupCheckId::Redundancy => "redundancy",
440 BackupCheckId::Mode => "mode",
441 BackupCheckId::ReadBack => "read_back",
442 BackupCheckId::Files => "files",
443 }
444 }
445}
446
447fn backup_check_from(id: BackupCheckId, check: verify::Check) -> BackupCheck {
448 BackupCheck {
449 id,
450 status: check.status,
451 detail: check.detail,
452 findings: check.findings,
453 }
454}
455
456pub fn mode_backup_check(expected: &Expected, mode: Option<&str>) -> BackupCheck {
459 backup_check_from(BackupCheckId::Mode, verify::mode_check(expected, mode))
460}
461
462pub fn read_back_backup_check(
465 expected: &Expected,
466 read_back: &BTreeMap<String, Remote>,
467 warnings: &mut Vec<String>,
468) -> (BackupCheck, usize) {
469 let (check, compared) = verify::read_back_check(expected, read_back, warnings);
470 (backup_check_from(BackupCheckId::ReadBack, check), compared)
471}
472
473#[derive(Debug, Clone, Copy, PartialEq, Eq)]
477pub enum SyncPredicate {
478 TimestampAdvanced,
481 StableTicks(u8),
484}
485
486pub const SYNC_PREDICATE: SyncPredicate = SyncPredicate::TimestampAdvanced;
488
489#[derive(Debug, Clone, PartialEq, Eq)]
491pub struct TickOutcome {
492 pub signals: Result<BackupSignals, String>,
495 pub exhausted: bool,
497 pub content: Option<Vec<BackupCheck>>,
499 pub files_checked: usize,
501 pub stable_ticks: u8,
504}
505
506pub fn converged(t: &TickOutcome, predicate: SyncPredicate) -> bool {
511 let Ok(signals) = &t.signals else {
512 return false;
513 };
514 if !signals.linked() || !sync_clause_met(t, signals, predicate) {
515 return false;
516 }
517 match &t.content {
518 Some(checks) => checks.iter().all(|c| c.status != CheckStatus::Fail),
519 None => false,
520 }
521}
522
523fn sync_clause_met(t: &TickOutcome, signals: &BackupSignals, predicate: SyncPredicate) -> bool {
524 match predicate {
525 SyncPredicate::TimestampAdvanced => signals.advanced(),
526 SyncPredicate::StableTicks(ticks) => t.stable_ticks >= ticks,
527 }
528}
529
530pub fn classify(t: &TickOutcome) -> Option<UnsyncedReason> {
535 if converged(t, SYNC_PREDICATE) {
536 return None;
537 }
538 let unanswered = t
539 .content
540 .iter()
541 .flatten()
542 .any(|c| c.status == CheckStatus::Fail && c.detail.starts_with(UNREACHABLE_PREFIX));
543 if t.signals.is_err() || unanswered {
544 return Some(UnsyncedReason::Unreachable);
545 }
546 if t.exhausted {
547 return Some(UnsyncedReason::SyncExhausted);
548 }
549 if let Ok(signals) = &t.signals
550 && (!signals.linked() || !sync_clause_met(t, signals, SYNC_PREDICATE))
551 {
552 return Some(UnsyncedReason::NotSyncing);
553 }
554 Some(UnsyncedReason::ContentDiffers)
555}
556
557const UNREACHABLE_PREFIX: &str = "unreachable:";
559
560#[derive(Debug, Clone, Copy, PartialEq, Eq)]
562pub enum PairWaitStep {
563 Converged,
565 Exhausted,
567 TimedOut,
569 Sleep(Duration),
571}
572
573pub fn wait_step(t: &TickOutcome, elapsed: Duration, policy: &BackupPolicy) -> PairWaitStep {
577 if converged(t, SYNC_PREDICATE) {
578 PairWaitStep::Converged
579 } else if t.exhausted {
580 PairWaitStep::Exhausted
581 } else if elapsed >= policy.budget {
582 PairWaitStep::TimedOut
583 } else {
584 PairWaitStep::Sleep(
585 policy
586 .interval
587 .max(Duration::from_millis(1))
588 .min(policy.budget - elapsed),
589 )
590 }
591}
592
593pub fn report(
597 t: &TickOutcome,
598 before: Option<i64>,
599 budget_s: u64,
600 waited_s: u64,
601 resync: Option<ResyncRecord>,
602) -> BackupReport {
603 let require_advance = matches!(SYNC_PREDICATE, SyncPredicate::TimestampAdvanced);
604 let mut checks = vec![redundancy_check(&t.signals, require_advance)];
605 checks.extend(t.content.iter().flatten().cloned());
606 let (sampled, after, project_state) = match &t.signals {
607 Ok(s) => (s.last_sync_before, s.last_sync_now, s.project_state.clone()),
608 Err(_) => (None, None, None),
609 };
610 BackupReport {
611 converged: converged(t, SYNC_PREDICATE),
612 reason: classify(t),
613 budget_s,
614 waited_s,
615 resync,
616 last_sync_before: before.filter(|value| *value >= 0).or(sampled),
617 last_sync_after: after,
618 project_state,
619 files_checked: t.files_checked,
620 checks,
621 }
622}
623
624pub fn verify_report(t: &TickOutcome) -> BackupReport {
635 let mut checks = vec![redundancy_check(&t.signals, false)];
636 checks.extend(t.content.iter().flatten().cloned());
637 let converged = t.content.is_some() && checks.iter().all(|c| c.status != CheckStatus::Fail);
638 let reason = if converged {
639 None
640 } else {
641 let unanswered = checks
642 .iter()
643 .any(|c| c.status == CheckStatus::Fail && c.detail.starts_with(UNREACHABLE_PREFIX));
644 Some(if unanswered {
645 UnsyncedReason::Unreachable
646 } else if !t.signals.as_ref().is_ok_and(BackupSignals::linked) {
647 UnsyncedReason::NotSyncing
648 } else {
649 UnsyncedReason::ContentDiffers
650 })
651 };
652 let (after, project_state) = match &t.signals {
653 Ok(s) => (s.last_sync_now, s.project_state.clone()),
654 Err(_) => (None, None),
655 };
656 BackupReport {
657 converged,
658 reason,
659 budget_s: 0,
660 waited_s: 0,
661 resync: None,
662 last_sync_before: None,
663 last_sync_after: after,
664 project_state,
665 files_checked: t.files_checked,
666 checks,
667 }
668}
669
670pub fn effective_files(observed: ObservedFiles) -> BTreeMap<RelPath, FileBlob> {
673 let mut files = observed.core;
674 files.extend(observed.mode);
675 files
676}
677
678pub const FILE_NORMALIZATION: &[(&str, &[&str])] = &[(
692 "resource.json",
693 &[
694 "attributes.lastModification",
695 "attributes.lastModificationSignature",
696 ],
697)];
698
699#[derive(Debug, Clone, Default, PartialEq, Eq)]
701pub struct FileComparison {
702 pub checked: usize,
704 pub differing: Vec<String>,
706 pub missing: Vec<String>,
708 pub unexpected: Vec<String>,
710}
711
712pub fn compare_files(
719 master: &BTreeMap<RelPath, FileBlob>,
720 backup: &BTreeMap<RelPath, FileBlob>,
721 present: &BTreeSet<RelPath>,
722 absent: &BTreeSet<RelPath>,
723) -> FileComparison {
724 let mut out = FileComparison {
725 checked: present.len(),
726 ..FileComparison::default()
727 };
728 for path in present {
729 let Some(theirs) = backup.get(path) else {
730 out.missing.push(path.as_str().to_string());
731 continue;
732 };
733 let equal = match master.get(path) {
734 Some(ours) => ours.sha256 == theirs.sha256 || same_document(path, ours, theirs),
735 None => false,
736 };
737 if !equal {
738 out.differing.push(path.as_str().to_string());
739 }
740 }
741 for path in absent {
742 if backup.contains_key(path) {
743 out.unexpected.push(path.as_str().to_string());
744 }
745 }
746 out
748}
749
750fn same_document(path: &RelPath, ours: &FileBlob, theirs: &FileBlob) -> bool {
753 let Some(name) = path.components().next_back() else {
754 return false;
755 };
756 let Some((_, ignored)) = FILE_NORMALIZATION.iter().find(|(file, _)| *file == name) else {
757 return false;
758 };
759 let (Ok(mut a), Ok(mut b)) = (
760 serde_json::from_slice::<Value>(&ours.bytes),
761 serde_json::from_slice::<Value>(&theirs.bytes),
762 ) else {
763 return false;
764 };
765 for doc in [&mut a, &mut b] {
766 for dotted in *ignored {
767 remove_member(doc, dotted);
768 }
769 }
770 a == b
771}
772
773fn remove_member(doc: &mut Value, path: &str) {
776 let mut node = doc;
777 let mut steps = path.split('.').peekable();
778 while let Some(step) = steps.next() {
779 let Value::Object(map) = node else {
780 return;
781 };
782 if steps.peek().is_none() {
783 map.remove(step);
784 return;
785 }
786 let Some(next) = map.get_mut(step) else {
787 return;
788 };
789 node = next;
790 }
791}
792
793pub fn files_check(c: &FileComparison) -> BackupCheck {
796 let kinds: [(&str, &str, &Vec<String>); 3] = [
797 (
798 "file_differs",
799 "differs from the master's live copy",
800 &c.differing,
801 ),
802 ("file_missing_on_backup", "is not on the backup", &c.missing),
803 (
804 "file_unexpected",
805 "was deleted by the apply but is still on the backup",
806 &c.unexpected,
807 ),
808 ];
809 let findings: Vec<Finding> = kinds
810 .iter()
811 .flat_map(|(code, what, paths)| {
812 paths.iter().map(move |path| Finding {
813 code,
814 subject: path.clone(),
815 detail: (*what).to_string(),
816 })
817 })
818 .take(APPLY_LIST_SHOWN)
819 .collect();
820 let bad = c.differing.len() + c.missing.len() + c.unexpected.len();
821 let (status, detail) = if bad == 0 {
822 (
823 CheckStatus::Pass,
824 format!("{} file(s) equal the master's live copies", c.checked),
825 )
826 } else {
827 (
828 CheckStatus::Fail,
829 format!(
830 "{} file(s) compared with the master's live copies: {} differ, {} missing on the backup, {} still present after being deleted",
831 c.checked,
832 c.differing.len(),
833 c.missing.len(),
834 c.unexpected.len()
835 ),
836 )
837 };
838 BackupCheck {
839 id: BackupCheckId::Files,
840 status,
841 detail,
842 findings,
843 }
844}
845
846pub fn files_skipped_check() -> BackupCheck {
849 BackupCheck {
850 id: BackupCheckId::Files,
851 status: CheckStatus::Warn,
852 detail:
853 "nodes.backup declares no ssh or local transport, so the file-hash check did not run"
854 .to_string(),
855 findings: Vec::new(),
856 }
857}
858
859#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
863pub struct SecretRef {
864 pub provider: String,
866 pub name: String,
868}
869
870#[derive(Debug, Clone, Copy, PartialEq, Eq)]
872pub enum SecretCheckMode {
873 Preflight,
876 Verify,
878}
879
880#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
882pub struct SecretNodeReport {
883 pub node: String,
885 pub status: CheckStatus,
888 pub providers_checked: usize,
890 pub missing: Vec<SecretRef>,
892 pub unverifiable: Vec<String>,
894 #[serde(skip_serializing_if = "Vec::is_empty")]
897 pub skipped_providers: Vec<String>,
898}
899
900#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
902pub struct SecretNamesReport {
903 pub nodes: Vec<SecretNodeReport>,
905}
906
907impl SecretNamesReport {
908 pub fn ordered(mut nodes: Vec<SecretNodeReport>) -> Self {
910 nodes.sort_by_key(|n| match n.node.as_str() {
911 "master" => 0u8,
912 "backup" => 1,
913 _ => 2,
914 });
915 SecretNamesReport { nodes }
916 }
917}
918
919pub fn collect_referenced_secrets(tree: &RenderedTree) -> BTreeSet<(String, String)> {
922 let mut out = BTreeSet::new();
923 for (path, bytes) in &tree.files {
924 if !path.as_str().ends_with(".json") {
925 continue;
926 }
927 if let Ok((_, parsed)) = node::classify(bytes) {
928 referenced_secret_names(&parsed, &mut out);
929 }
930 }
931 out
932}
933
934pub fn declared_secret_providers(tree: &RenderedTree) -> BTreeSet<String> {
937 let mut out = BTreeSet::new();
938 for path in tree.files.keys() {
939 let parts: Vec<&str> = path.components().collect();
940 if parts.len() >= 4 && parts[0] == "ignition" && parts[1] == "secret-provider" {
941 out.insert(parts[2].to_string());
942 }
943 }
944 out
945}
946
947pub fn secrets_defined_by_render(tree: &RenderedTree) -> BTreeSet<(String, String)> {
958 let mut out = BTreeSet::new();
959 for (path, bytes) in &tree.files {
960 let parts: Vec<&str> = path.components().collect();
961 let [first, second, provider, file] = parts.as_slice() else {
962 continue;
963 };
964 if *first != "ignition" || *second != "secret-provider" || *file != "config.json" {
965 continue;
966 }
967 let Ok(config) = serde_json::from_slice::<Value>(bytes) else {
968 continue;
969 };
970 for member in ["files", "secrets"] {
971 if let Some(entries) = config
972 .get("settings")
973 .and_then(|settings| settings.get(member))
974 .and_then(Value::as_object)
975 {
976 out.extend(
977 entries
978 .keys()
979 .map(|name| ((*provider).to_string(), name.clone())),
980 );
981 }
982 }
983 }
984 out
985}
986
987pub fn check_secret_names(
998 node: &str,
999 referenced: &BTreeSet<(String, String)>,
1000 listings: &BTreeMap<String, SecretNameListing>,
1001 declared: &BTreeSet<String>,
1002 mode: SecretCheckMode,
1003) -> SecretNodeReport {
1004 let mut by_provider: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
1005 for (provider, name) in referenced {
1006 by_provider
1007 .entry(provider.as_str())
1008 .or_default()
1009 .push(name.as_str());
1010 }
1011 let mut missing = Vec::new();
1012 let mut unverifiable = Vec::new();
1013 let mut skipped = Vec::new();
1014 for (provider, names) in &by_provider {
1015 let all_missing = |missing: &mut Vec<SecretRef>| {
1016 missing.extend(names.iter().map(|name| SecretRef {
1017 provider: (*provider).to_string(),
1018 name: (*name).to_string(),
1019 }));
1020 };
1021 match listings.get(*provider) {
1022 Some(SecretNameListing::Listed(listed)) => {
1023 missing.extend(
1024 names
1025 .iter()
1026 .filter(|name| !listed.contains(**name))
1027 .map(|name| SecretRef {
1028 provider: (*provider).to_string(),
1029 name: (*name).to_string(),
1030 }),
1031 );
1032 }
1033 Some(SecretNameListing::CannotList) => unverifiable.push((*provider).to_string()),
1034 Some(SecretNameListing::NoProvider) | None => {
1035 if mode == SecretCheckMode::Preflight && declared.contains(*provider) {
1036 skipped.push((*provider).to_string());
1037 } else {
1038 all_missing(&mut missing);
1039 }
1040 }
1041 }
1042 }
1043 let status = if !missing.is_empty() {
1044 CheckStatus::Fail
1045 } else if !unverifiable.is_empty() {
1046 CheckStatus::Warn
1047 } else {
1048 CheckStatus::Pass
1049 };
1050 SecretNodeReport {
1051 node: node.to_string(),
1052 status,
1053 providers_checked: by_provider.len(),
1054 missing,
1055 unverifiable,
1056 skipped_providers: skipped,
1057 }
1058}
1059
1060#[cfg(test)]
1061mod tests;