1use std::collections::{BTreeMap, BTreeSet};
43use std::time::{Duration, SystemTime, UNIX_EPOCH};
44
45use serde::Serialize;
46use serde_json::Value;
47
48use super::layout::RelPath;
49use super::lint::referenced_secret_names;
50use super::node;
51use super::plan::{UnreadableKey, unit_key, unreadable_unit_keys};
52use super::transport::{FileBlob, ObservedFiles, Scope, TreeReader};
53use super::tree::RenderedTree;
54use super::verify::{self, CheckStatus, Expected, Finding, Remote};
55use crate::client::deploy_api::SecretNameListing;
56use crate::client::logs::{DEFAULT_LOG_LIMIT, LogQuery};
57use crate::client::mode::normalize_deployment_mode;
58use crate::client::redundancy::RedundancyStatusWire;
59use crate::client::{DeployApi, GatewayApi};
60use crate::error::{APPLY_LIST_SHOWN, CoreError};
61
62pub const BACKUP_POLL_S: u64 = 5;
65
66pub const BACKUP_TIMEOUT_DEFAULT_S: u64 = 300;
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub struct BackupPolicy {
72 pub budget: Duration,
74 pub interval: Duration,
76}
77
78impl Default for BackupPolicy {
79 fn default() -> Self {
80 BackupPolicy {
81 budget: Duration::from_secs(BACKUP_TIMEOUT_DEFAULT_S),
82 interval: Duration::from_secs(BACKUP_POLL_S),
83 }
84 }
85}
86
87pub struct BackupNode<'a> {
93 gateway: &'a dyn GatewayApi,
94 deploy: &'a dyn DeployApi,
95 reader: Option<&'a dyn TreeReader>,
96}
97
98impl<'a> BackupNode<'a> {
99 pub fn new(
102 gateway: &'a dyn GatewayApi,
103 deploy: &'a dyn DeployApi,
104 reader: Option<&'a dyn TreeReader>,
105 ) -> Self {
106 BackupNode {
107 gateway,
108 deploy,
109 reader,
110 }
111 }
112
113 pub fn has_files(&self) -> bool {
115 self.reader.is_some()
116 }
117
118 pub async fn status(&self) -> Result<RedundancyStatusWire, CoreError> {
120 self.gateway.redundancy_status().await
121 }
122
123 pub async fn deployment_mode(&self) -> Result<Option<String>, CoreError> {
125 Ok(normalize_deployment_mode(
126 self.deploy.deployment_mode().await?,
127 ))
128 }
129
130 pub async fn read_back(
133 &self,
134 expected: &Expected,
135 ) -> Result<BTreeMap<String, Remote>, CoreError> {
136 crate::actions::deploy::gather_read_back(self.deploy, expected).await
137 }
138
139 pub async fn read_files(&self, scope: &Scope) -> Option<Result<ObservedFiles, CoreError>> {
142 match self.reader {
143 Some(reader) => Some(reader.read(scope).await),
144 None => None,
145 }
146 }
147
148 pub async fn secret_names(&self, provider: &str) -> Result<SecretNameListing, CoreError> {
151 self.deploy.secret_names(provider).await
152 }
153
154 pub async fn sync_exhausted(&self, since_ms: i64) -> Result<bool, CoreError> {
171 let query = LogQuery {
172 start_time: Some(since_ms.max(0)),
173 min_level: Some("ERROR".to_string()),
174 limit: DEFAULT_LOG_LIMIT,
175 ..LogQuery::default()
176 };
177 let envelope = self.gateway.logs(&query).await?;
178 Ok(envelope
179 .items
180 .iter()
181 .any(|entry| entry.timestamp >= since_ms && is_sync_exhausted(&entry.message)))
182 }
183}
184
185pub fn wall_clock_ms() -> i64 {
188 SystemTime::now()
189 .duration_since(UNIX_EPOCH)
190 .ok()
191 .and_then(|elapsed| i64::try_from(elapsed.as_millis()).ok())
192 .unwrap_or(0)
193}
194
195const SYNC_EXHAUSTED_TEXT: &str = "exceeds the max attempt count";
201
202pub fn is_sync_exhausted(message: &str) -> bool {
204 message.contains(SYNC_EXHAUSTED_TEXT)
205}
206
207#[derive(Debug, Clone, PartialEq, Eq)]
209pub struct BackupSignals {
210 pub peer_connected: bool,
212 pub sync_pending: bool,
214 pub last_sync_before: Option<i64>,
217 pub last_sync_now: Option<i64>,
219 pub project_state: Option<String>,
221}
222
223impl BackupSignals {
224 pub fn from_status(before: Option<i64>, now: &RedundancyStatusWire) -> Self {
230 BackupSignals {
231 peer_connected: now.peer_connected,
232 sync_pending: now.sync_pending,
233 last_sync_before: before.filter(|value| *value >= 0),
234 last_sync_now: now.last_sync_epoch_ms(),
235 project_state: now.project_state.as_deref().and_then(status_word),
236 }
237 }
238
239 pub fn advanced(&self) -> bool {
242 match (self.last_sync_before, self.last_sync_now) {
243 (_, None) => false,
244 (None, Some(_)) => true,
245 (Some(before), Some(now)) => now > before,
246 }
247 }
248
249 pub fn linked(&self) -> bool {
251 self.peer_connected && !self.sync_pending
252 }
253}
254
255fn status_word(text: &str) -> Option<String> {
258 let plain = !text.is_empty()
259 && text.chars().count() <= 32
260 && text
261 .chars()
262 .all(|c| c.is_alphanumeric() || matches!(c, ' ' | '-' | '_'));
263 plain.then(|| text.to_string())
264}
265
266#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
269#[serde(rename_all = "snake_case")]
270pub enum UnsyncedReason {
271 Unreachable,
273 NotSyncing,
275 ContentDiffers,
277 SyncExhausted,
279}
280
281impl UnsyncedReason {
282 pub fn as_str(self) -> &'static str {
284 match self {
285 UnsyncedReason::Unreachable => "unreachable",
286 UnsyncedReason::NotSyncing => "not_syncing",
287 UnsyncedReason::ContentDiffers => "content_differs",
288 UnsyncedReason::SyncExhausted => "sync_exhausted",
289 }
290 }
291}
292
293#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
295#[serde(rename_all = "snake_case")]
296pub enum BackupCheckId {
297 Redundancy,
299 Mode,
301 ReadBack,
303 Files,
305}
306
307#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
309pub struct BackupCheck {
310 pub id: BackupCheckId,
312 pub status: CheckStatus,
314 pub detail: String,
316 pub findings: Vec<Finding>,
318}
319
320#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
323pub struct ResyncRecord {
324 pub ok: bool,
326 pub code: Option<String>,
328}
329
330#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
333pub struct BackupReport {
334 pub converged: bool,
336 pub reason: Option<UnsyncedReason>,
338 pub budget_s: u64,
340 pub waited_s: u64,
342 pub resync: Option<ResyncRecord>,
344 pub last_sync_before: Option<i64>,
346 pub last_sync_after: Option<i64>,
348 pub project_state: Option<String>,
350 pub files_checked: usize,
352 pub checks: Vec<BackupCheck>,
354}
355
356fn finding(code: &'static str, subject: &str, detail: &str) -> Finding {
357 Finding {
358 code,
359 subject: subject.to_string(),
360 detail: detail.to_string(),
361 }
362}
363
364pub fn redundancy_check(
368 signals: &Result<BackupSignals, String>,
369 require_advance: bool,
370) -> BackupCheck {
371 let signals = match signals {
372 Ok(signals) => signals,
373 Err(code) => {
374 let detail = format!("unreachable: {code}");
375 return BackupCheck {
376 id: BackupCheckId::Redundancy,
377 status: CheckStatus::Fail,
378 findings: vec![finding("backup_unreachable", "redundancy", &detail)],
379 detail,
380 };
381 }
382 };
383 let mut findings = Vec::new();
384 if !signals.peer_connected {
385 findings.push(finding(
386 "peer_not_connected",
387 "peerConnected",
388 "peerConnected is false",
389 ));
390 }
391 if signals.sync_pending {
392 findings.push(finding(
393 "sync_pending",
394 "syncPending",
395 "syncPending is true",
396 ));
397 }
398 if require_advance && !signals.advanced() {
399 findings.push(finding(
400 "sync_not_advanced",
401 "lastSyncTimestamp",
402 "lastSyncTimestamp has not advanced past the pre-apply sample",
403 ));
404 }
405 if findings.is_empty() {
406 let detail = if require_advance {
407 "peerConnected, no sync pending, lastSyncTimestamp advanced"
408 } else {
409 "peerConnected, no sync pending"
410 };
411 return BackupCheck {
412 id: BackupCheckId::Redundancy,
413 status: CheckStatus::Pass,
414 detail: detail.to_string(),
415 findings,
416 };
417 }
418 let names: Vec<&str> = findings.iter().map(|f| f.subject.as_str()).collect();
419 BackupCheck {
420 id: BackupCheckId::Redundancy,
421 status: CheckStatus::Fail,
422 detail: format!("the backup is not settled: {}", names.join(", ")),
423 findings,
424 }
425}
426
427pub fn unreachable_check(id: BackupCheckId, code: &str) -> BackupCheck {
430 let detail = format!("unreachable: {code}");
431 BackupCheck {
432 id,
433 status: CheckStatus::Fail,
434 findings: vec![finding("backup_unreachable", id.word(), &detail)],
435 detail,
436 }
437}
438
439impl BackupCheckId {
440 pub fn word(self) -> &'static str {
442 match self {
443 BackupCheckId::Redundancy => "redundancy",
444 BackupCheckId::Mode => "mode",
445 BackupCheckId::ReadBack => "read_back",
446 BackupCheckId::Files => "files",
447 }
448 }
449}
450
451fn backup_check_from(id: BackupCheckId, check: verify::Check) -> BackupCheck {
452 BackupCheck {
453 id,
454 status: check.status,
455 detail: check.detail,
456 findings: check.findings,
457 }
458}
459
460pub fn mode_backup_check(expected: &Expected, mode: Option<&str>) -> BackupCheck {
463 backup_check_from(BackupCheckId::Mode, verify::mode_check(expected, mode))
464}
465
466pub fn read_back_backup_check(
469 expected: &Expected,
470 read_back: &BTreeMap<String, Remote>,
471 warnings: &mut Vec<String>,
472) -> (BackupCheck, usize) {
473 let (check, compared) = verify::read_back_check(expected, read_back, warnings);
474 (backup_check_from(BackupCheckId::ReadBack, check), compared)
475}
476
477#[derive(Debug, Clone, Copy, PartialEq, Eq)]
481pub enum SyncPredicate {
482 TimestampAdvanced,
485 StableTicks(u8),
488}
489
490pub const SYNC_PREDICATE: SyncPredicate = SyncPredicate::TimestampAdvanced;
492
493#[derive(Debug, Clone, PartialEq, Eq)]
495pub struct TickOutcome {
496 pub signals: Result<BackupSignals, String>,
499 pub exhausted: bool,
501 pub content: Option<Vec<BackupCheck>>,
503 pub files_checked: usize,
505 pub stable_ticks: u8,
508}
509
510pub fn converged(t: &TickOutcome, predicate: SyncPredicate) -> bool {
515 let Ok(signals) = &t.signals else {
516 return false;
517 };
518 if !signals.linked() || !sync_clause_met(t, signals, predicate) {
519 return false;
520 }
521 match &t.content {
522 Some(checks) => checks.iter().all(|c| c.status != CheckStatus::Fail),
523 None => false,
524 }
525}
526
527fn sync_clause_met(t: &TickOutcome, signals: &BackupSignals, predicate: SyncPredicate) -> bool {
528 match predicate {
529 SyncPredicate::TimestampAdvanced => signals.advanced(),
530 SyncPredicate::StableTicks(ticks) => t.stable_ticks >= ticks,
531 }
532}
533
534pub fn classify(t: &TickOutcome) -> Option<UnsyncedReason> {
539 if converged(t, SYNC_PREDICATE) {
540 return None;
541 }
542 let unanswered = t
543 .content
544 .iter()
545 .flatten()
546 .any(|c| c.status == CheckStatus::Fail && c.detail.starts_with(UNREACHABLE_PREFIX));
547 if t.signals.is_err() || unanswered {
548 return Some(UnsyncedReason::Unreachable);
549 }
550 if t.exhausted {
551 return Some(UnsyncedReason::SyncExhausted);
552 }
553 if let Ok(signals) = &t.signals
554 && (!signals.linked() || !sync_clause_met(t, signals, SYNC_PREDICATE))
555 {
556 return Some(UnsyncedReason::NotSyncing);
557 }
558 Some(UnsyncedReason::ContentDiffers)
559}
560
561const UNREACHABLE_PREFIX: &str = "unreachable:";
563
564#[derive(Debug, Clone, Copy, PartialEq, Eq)]
566pub enum PairWaitStep {
567 Converged,
569 Exhausted,
571 TimedOut,
573 Sleep(Duration),
575}
576
577pub fn wait_step(t: &TickOutcome, elapsed: Duration, policy: &BackupPolicy) -> PairWaitStep {
581 if converged(t, SYNC_PREDICATE) {
582 PairWaitStep::Converged
583 } else if t.exhausted {
584 PairWaitStep::Exhausted
585 } else if elapsed >= policy.budget {
586 PairWaitStep::TimedOut
587 } else {
588 PairWaitStep::Sleep(
589 policy
590 .interval
591 .max(Duration::from_millis(1))
592 .min(policy.budget - elapsed),
593 )
594 }
595}
596
597pub fn report(
601 t: &TickOutcome,
602 before: Option<i64>,
603 budget_s: u64,
604 waited_s: u64,
605 resync: Option<ResyncRecord>,
606) -> BackupReport {
607 let require_advance = matches!(SYNC_PREDICATE, SyncPredicate::TimestampAdvanced);
608 let mut checks = vec![redundancy_check(&t.signals, require_advance)];
609 checks.extend(t.content.iter().flatten().cloned());
610 let (sampled, after, project_state) = match &t.signals {
611 Ok(s) => (s.last_sync_before, s.last_sync_now, s.project_state.clone()),
612 Err(_) => (None, None, None),
613 };
614 BackupReport {
615 converged: converged(t, SYNC_PREDICATE),
616 reason: classify(t),
617 budget_s,
618 waited_s,
619 resync,
620 last_sync_before: before.filter(|value| *value >= 0).or(sampled),
621 last_sync_after: after,
622 project_state,
623 files_checked: t.files_checked,
624 checks,
625 }
626}
627
628pub fn verify_report(t: &TickOutcome) -> BackupReport {
639 let mut checks = vec![redundancy_check(&t.signals, false)];
640 checks.extend(t.content.iter().flatten().cloned());
641 let converged = t.content.is_some() && checks.iter().all(|c| c.status != CheckStatus::Fail);
642 let reason = if converged {
643 None
644 } else {
645 let unanswered = checks
646 .iter()
647 .any(|c| c.status == CheckStatus::Fail && c.detail.starts_with(UNREACHABLE_PREFIX));
648 Some(if unanswered {
649 UnsyncedReason::Unreachable
650 } else if !t.signals.as_ref().is_ok_and(BackupSignals::linked) {
651 UnsyncedReason::NotSyncing
652 } else {
653 UnsyncedReason::ContentDiffers
654 })
655 };
656 let (after, project_state) = match &t.signals {
657 Ok(s) => (s.last_sync_now, s.project_state.clone()),
658 Err(_) => (None, None),
659 };
660 BackupReport {
661 converged,
662 reason,
663 budget_s: 0,
664 waited_s: 0,
665 resync: None,
666 last_sync_before: None,
667 last_sync_after: after,
668 project_state,
669 files_checked: t.files_checked,
670 checks,
671 }
672}
673
674pub fn effective_files(observed: ObservedFiles) -> BTreeMap<RelPath, FileBlob> {
677 let mut files = observed.core;
678 files.extend(observed.mode);
679 files
680}
681
682pub type UnreadableUnits = BTreeMap<String, UnreadableKey>;
685
686pub fn unreadable_units(observed: &ObservedFiles) -> UnreadableUnits {
689 unreadable_unit_keys(observed)
690}
691
692pub const FILE_NORMALIZATION: &[(&str, &[&str])] = &[(
706 "resource.json",
707 &[
708 "attributes.lastModification",
709 "attributes.lastModificationSignature",
710 ],
711)];
712
713#[derive(Debug, Clone, Default, PartialEq, Eq)]
715pub struct FileComparison {
716 pub checked: usize,
718 pub differing: Vec<String>,
720 pub missing: Vec<String>,
722 pub unexpected: Vec<String>,
724 pub unreadable: Vec<(String, String)>,
729 pub unreadable_unchecked: Vec<(String, String)>,
732}
733
734pub fn compare_files(
741 master: &BTreeMap<RelPath, FileBlob>,
742 backup: &BTreeMap<RelPath, FileBlob>,
743 present: &BTreeSet<RelPath>,
744 absent: &BTreeSet<RelPath>,
745) -> FileComparison {
746 let mut out = FileComparison {
747 checked: present.len(),
748 ..FileComparison::default()
749 };
750 for path in present {
751 let Some(theirs) = backup.get(path) else {
752 out.missing.push(path.as_str().to_string());
753 continue;
754 };
755 let equal = match master.get(path) {
756 Some(ours) => ours.sha256 == theirs.sha256 || same_document(path, ours, theirs),
757 None => false,
758 };
759 if !equal {
760 out.differing.push(path.as_str().to_string());
761 }
762 }
763 for path in absent {
764 if backup.contains_key(path) {
765 out.unexpected.push(path.as_str().to_string());
766 }
767 }
768 out
770}
771
772pub fn exempt_unreadable(
782 c: &mut FileComparison,
783 present: &BTreeSet<RelPath>,
784 absent: &BTreeSet<RelPath>,
785 nodes: &[(&str, &UnreadableUnits)],
786) {
787 if nodes.iter().all(|(_, units)| units.is_empty()) {
788 return;
789 }
790 let layer: BTreeMap<RelPath, ()> = present
791 .iter()
792 .chain(absent.iter())
793 .map(|p| (p.clone(), ()))
794 .collect();
795 let keyed: Vec<(&RelPath, String)> = layer.keys().map(|p| (p, unit_key(p, &layer))).collect();
796 let mut hit: BTreeSet<String> = BTreeSet::new();
797 let mut overlapping: BTreeSet<(String, String)> = BTreeSet::new();
798 let mut unchecked: BTreeSet<(String, String)> = BTreeSet::new();
799 for (node, units) in nodes {
800 for (key, info) in units.iter() {
801 let covered: Vec<&RelPath> = keyed
802 .iter()
803 .filter(|(p, k)| k == key || info.paths.iter().any(|u| u == p.as_str()))
804 .map(|(p, _)| *p)
805 .collect();
806 let entry = ((*node).to_string(), key.clone());
807 if covered.is_empty() {
808 unchecked.insert(entry);
809 } else {
810 hit.extend(covered.iter().map(|p| p.as_str().to_string()));
811 overlapping.insert(entry);
812 }
813 }
814 }
815 for list in [&mut c.differing, &mut c.missing, &mut c.unexpected] {
816 list.retain(|p| !hit.contains(p));
817 }
818 c.unreadable = overlapping.into_iter().collect();
819 c.unreadable_unchecked = unchecked.into_iter().collect();
820}
821
822pub fn unreadable_warnings(c: &FileComparison) -> Vec<String> {
824 c.unreadable_unchecked
825 .iter()
826 .map(|(node, key)| {
827 format!(
828 "{node}: {key} is unreadable on the host and is neither rendered nor deleted; it was not compared"
829 )
830 })
831 .collect()
832}
833
834fn same_document(path: &RelPath, ours: &FileBlob, theirs: &FileBlob) -> bool {
837 let Some(name) = path.components().next_back() else {
838 return false;
839 };
840 let Some((_, ignored)) = FILE_NORMALIZATION.iter().find(|(file, _)| *file == name) else {
841 return false;
842 };
843 let (Ok(mut a), Ok(mut b)) = (
844 serde_json::from_slice::<Value>(&ours.bytes),
845 serde_json::from_slice::<Value>(&theirs.bytes),
846 ) else {
847 return false;
848 };
849 for doc in [&mut a, &mut b] {
850 for dotted in *ignored {
851 remove_member(doc, dotted);
852 }
853 }
854 a == b
855}
856
857fn remove_member(doc: &mut Value, path: &str) {
860 let mut node = doc;
861 let mut steps = path.split('.').peekable();
862 while let Some(step) = steps.next() {
863 let Value::Object(map) = node else {
864 return;
865 };
866 if steps.peek().is_none() {
867 map.remove(step);
868 return;
869 }
870 let Some(next) = map.get_mut(step) else {
871 return;
872 };
873 node = next;
874 }
875}
876
877pub fn files_check(c: &FileComparison) -> BackupCheck {
880 let kinds: [(&str, &str, &Vec<String>); 3] = [
881 (
882 "file_differs",
883 "differs from the master's live copy",
884 &c.differing,
885 ),
886 ("file_missing_on_backup", "is not on the backup", &c.missing),
887 (
888 "file_unexpected",
889 "was deleted by the apply but is still on the backup",
890 &c.unexpected,
891 ),
892 ];
893 let unreadable = c.unreadable.iter().map(|(node, key)| Finding {
895 code: "unreadable",
896 subject: key.clone(),
897 detail: format!(
898 "has a live path the {node}'s sftp server cannot read; its copy is unknown, so it is neither equal nor absent"
899 ),
900 });
901 let findings: Vec<Finding> = unreadable
902 .chain(kinds.iter().flat_map(|(code, what, paths)| {
903 paths.iter().map(move |path| Finding {
904 code,
905 subject: path.clone(),
906 detail: (*what).to_string(),
907 })
908 }))
909 .take(APPLY_LIST_SHOWN)
910 .collect();
911 let bad = c.differing.len() + c.missing.len() + c.unexpected.len() + c.unreadable.len();
912 let (status, detail) = if bad == 0 && c.unreadable_unchecked.is_empty() {
913 (
914 CheckStatus::Pass,
915 format!("{} file(s) equal the master's live copies", c.checked),
916 )
917 } else if bad == 0 {
918 let shown: Vec<String> = c
919 .unreadable_unchecked
920 .iter()
921 .take(APPLY_LIST_SHOWN)
922 .map(|(node, key)| format!("{key} ({node})"))
923 .collect();
924 let more = c.unreadable_unchecked.len() - shown.len();
925 (
926 CheckStatus::Warn,
927 format!(
928 "{} file(s) equal the master's live copies; {} unit(s) neither rendered nor deleted have a live path the host's sftp server cannot read and were not compared: {}{}",
929 c.checked,
930 c.unreadable_unchecked.len(),
931 shown.join(", "),
932 if more > 0 {
933 format!(" and {more} more")
934 } else {
935 String::new()
936 }
937 ),
938 )
939 } else {
940 let mut detail = format!(
941 "{} file(s) compared with the master's live copies: {} differ, {} missing on the backup, {} still present after being deleted",
942 c.checked,
943 c.differing.len(),
944 c.missing.len(),
945 c.unexpected.len()
946 );
947 if !c.unreadable.is_empty() {
948 detail.push_str(&format!(
949 ", {} unit(s) unreadable on a node",
950 c.unreadable.len()
951 ));
952 }
953 (CheckStatus::Fail, detail)
954 };
955 BackupCheck {
956 id: BackupCheckId::Files,
957 status,
958 detail,
959 findings,
960 }
961}
962
963pub fn files_skipped_check() -> BackupCheck {
966 BackupCheck {
967 id: BackupCheckId::Files,
968 status: CheckStatus::Warn,
969 detail:
970 "nodes.backup declares no ssh or local transport, so the file-hash check did not run"
971 .to_string(),
972 findings: Vec::new(),
973 }
974}
975
976#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
980pub struct SecretRef {
981 pub provider: String,
983 pub name: String,
985}
986
987#[derive(Debug, Clone, Copy, PartialEq, Eq)]
989pub enum SecretCheckMode {
990 Preflight,
993 Verify,
995}
996
997#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
999pub struct SecretNodeReport {
1000 pub node: String,
1002 pub status: CheckStatus,
1005 pub providers_checked: usize,
1007 pub missing: Vec<SecretRef>,
1009 pub unverifiable: Vec<String>,
1011 #[serde(skip_serializing_if = "Vec::is_empty")]
1014 pub skipped_providers: Vec<String>,
1015}
1016
1017#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1019pub struct SecretNamesReport {
1020 pub nodes: Vec<SecretNodeReport>,
1022}
1023
1024impl SecretNamesReport {
1025 pub fn ordered(mut nodes: Vec<SecretNodeReport>) -> Self {
1027 nodes.sort_by_key(|n| match n.node.as_str() {
1028 "master" => 0u8,
1029 "backup" => 1,
1030 _ => 2,
1031 });
1032 SecretNamesReport { nodes }
1033 }
1034}
1035
1036pub fn collect_referenced_secrets(tree: &RenderedTree) -> BTreeSet<(String, String)> {
1039 let mut out = BTreeSet::new();
1040 for (path, bytes) in &tree.files {
1041 if !path.as_str().ends_with(".json") {
1042 continue;
1043 }
1044 if let Ok((_, parsed)) = node::classify(bytes) {
1045 referenced_secret_names(&parsed, &mut out);
1046 }
1047 }
1048 out
1049}
1050
1051pub fn declared_secret_providers(tree: &RenderedTree) -> BTreeSet<String> {
1054 let mut out = BTreeSet::new();
1055 for path in tree.files.keys() {
1056 let parts: Vec<&str> = path.components().collect();
1057 if parts.len() >= 4 && parts[0] == "ignition" && parts[1] == "secret-provider" {
1058 out.insert(parts[2].to_string());
1059 }
1060 }
1061 out
1062}
1063
1064pub fn secrets_defined_by_render(tree: &RenderedTree) -> BTreeSet<(String, String)> {
1075 let mut out = BTreeSet::new();
1076 for (path, bytes) in &tree.files {
1077 let parts: Vec<&str> = path.components().collect();
1078 let [first, second, provider, file] = parts.as_slice() else {
1079 continue;
1080 };
1081 if *first != "ignition" || *second != "secret-provider" || *file != "config.json" {
1082 continue;
1083 }
1084 let Ok(config) = serde_json::from_slice::<Value>(bytes) else {
1085 continue;
1086 };
1087 for member in ["files", "secrets"] {
1088 if let Some(entries) = config
1089 .get("settings")
1090 .and_then(|settings| settings.get(member))
1091 .and_then(Value::as_object)
1092 {
1093 out.extend(
1094 entries
1095 .keys()
1096 .map(|name| ((*provider).to_string(), name.clone())),
1097 );
1098 }
1099 }
1100 }
1101 out
1102}
1103
1104pub fn check_secret_names(
1115 node: &str,
1116 referenced: &BTreeSet<(String, String)>,
1117 listings: &BTreeMap<String, SecretNameListing>,
1118 declared: &BTreeSet<String>,
1119 mode: SecretCheckMode,
1120) -> SecretNodeReport {
1121 let mut by_provider: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
1122 for (provider, name) in referenced {
1123 by_provider
1124 .entry(provider.as_str())
1125 .or_default()
1126 .push(name.as_str());
1127 }
1128 let mut missing = Vec::new();
1129 let mut unverifiable = Vec::new();
1130 let mut skipped = Vec::new();
1131 for (provider, names) in &by_provider {
1132 let all_missing = |missing: &mut Vec<SecretRef>| {
1133 missing.extend(names.iter().map(|name| SecretRef {
1134 provider: (*provider).to_string(),
1135 name: (*name).to_string(),
1136 }));
1137 };
1138 match listings.get(*provider) {
1139 Some(SecretNameListing::Listed(listed)) => {
1140 missing.extend(
1141 names
1142 .iter()
1143 .filter(|name| !listed.contains(**name))
1144 .map(|name| SecretRef {
1145 provider: (*provider).to_string(),
1146 name: (*name).to_string(),
1147 }),
1148 );
1149 }
1150 Some(SecretNameListing::CannotList) => unverifiable.push((*provider).to_string()),
1151 Some(SecretNameListing::NoProvider) | None => {
1152 if mode == SecretCheckMode::Preflight && declared.contains(*provider) {
1153 skipped.push((*provider).to_string());
1154 } else {
1155 all_missing(&mut missing);
1156 }
1157 }
1158 }
1159 }
1160 let status = if !missing.is_empty() {
1161 CheckStatus::Fail
1162 } else if !unverifiable.is_empty() {
1163 CheckStatus::Warn
1164 } else {
1165 CheckStatus::Pass
1166 };
1167 SecretNodeReport {
1168 node: node.to_string(),
1169 status,
1170 providers_checked: by_provider.len(),
1171 missing,
1172 unverifiable,
1173 skipped_providers: skipped,
1174 }
1175}
1176
1177#[cfg(test)]
1178mod tests;