1use std::collections::{BTreeMap, BTreeSet};
44use std::time::{Duration, SystemTime, UNIX_EPOCH};
45
46use serde::Serialize;
47use serde_json::Value;
48
49use super::layout::RelPath;
50use super::lint::referenced_secret_names;
51use super::node;
52use super::plan::{UnreadableKey, unit_key, unreadable_unit_keys};
53use super::transport::{FileBlob, ObservedFiles, Scope, TreeReader};
54use super::tree::RenderedTree;
55use super::verify::{self, CheckStatus, Expected, Finding, Remote};
56use crate::client::deploy_api::SecretNameListing;
57use crate::client::logs::{DEFAULT_LOG_LIMIT, LogQuery};
58use crate::client::mode::normalize_deployment_mode;
59use crate::client::redundancy::RedundancyStatusWire;
60use crate::client::{DeployApi, GatewayApi};
61use crate::error::{APPLY_LIST_SHOWN, CoreError};
62
63pub const BACKUP_POLL_S: u64 = 5;
66
67pub const BACKUP_TIMEOUT_DEFAULT_S: u64 = 300;
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub struct BackupPolicy {
73 pub budget: Duration,
75 pub interval: Duration,
77}
78
79impl Default for BackupPolicy {
80 fn default() -> Self {
81 BackupPolicy {
82 budget: Duration::from_secs(BACKUP_TIMEOUT_DEFAULT_S),
83 interval: Duration::from_secs(BACKUP_POLL_S),
84 }
85 }
86}
87
88pub struct BackupNode<'a> {
94 gateway: &'a dyn GatewayApi,
95 deploy: &'a dyn DeployApi,
96 reader: Option<&'a dyn TreeReader>,
97}
98
99impl<'a> BackupNode<'a> {
100 pub fn new(
103 gateway: &'a dyn GatewayApi,
104 deploy: &'a dyn DeployApi,
105 reader: Option<&'a dyn TreeReader>,
106 ) -> Self {
107 BackupNode {
108 gateway,
109 deploy,
110 reader,
111 }
112 }
113
114 pub fn has_files(&self) -> bool {
116 self.reader.is_some()
117 }
118
119 pub async fn status(&self) -> Result<RedundancyStatusWire, CoreError> {
121 self.gateway.redundancy_status().await
122 }
123
124 pub async fn deployment_mode(&self) -> Result<Option<String>, CoreError> {
126 Ok(normalize_deployment_mode(
127 self.deploy.deployment_mode().await?,
128 ))
129 }
130
131 pub async fn read_back(
134 &self,
135 expected: &Expected,
136 ) -> Result<BTreeMap<String, Remote>, CoreError> {
137 crate::actions::deploy::gather_read_back(self.deploy, expected).await
138 }
139
140 pub async fn read_files(&self, scope: &Scope) -> Option<Result<ObservedFiles, CoreError>> {
143 match self.reader {
144 Some(reader) => Some(reader.read(scope).await),
145 None => None,
146 }
147 }
148
149 pub async fn tag_export(&self, provider: &str) -> Result<Vec<u8>, CoreError> {
153 self.gateway.tags_export_provider(provider).await
154 }
155
156 pub async fn secret_names(&self, provider: &str) -> Result<SecretNameListing, CoreError> {
159 self.deploy.secret_names(provider).await
160 }
161
162 pub async fn sync_exhausted(&self, since_ms: i64) -> Result<bool, CoreError> {
179 let query = LogQuery {
180 start_time: Some(since_ms.max(0)),
181 min_level: Some("ERROR".to_string()),
182 limit: DEFAULT_LOG_LIMIT,
183 ..LogQuery::default()
184 };
185 let envelope = self.gateway.logs(&query).await?;
186 Ok(envelope
187 .items
188 .iter()
189 .any(|entry| entry.timestamp >= since_ms && is_sync_exhausted(&entry.message)))
190 }
191}
192
193pub fn wall_clock_ms() -> i64 {
196 SystemTime::now()
197 .duration_since(UNIX_EPOCH)
198 .ok()
199 .and_then(|elapsed| i64::try_from(elapsed.as_millis()).ok())
200 .unwrap_or(0)
201}
202
203const SYNC_EXHAUSTED_TEXT: &str = "exceeds the max attempt count";
209
210pub fn is_sync_exhausted(message: &str) -> bool {
212 message.contains(SYNC_EXHAUSTED_TEXT)
213}
214
215#[derive(Debug, Clone, PartialEq, Eq)]
217pub struct BackupSignals {
218 pub peer_connected: bool,
220 pub sync_pending: bool,
222 pub last_sync_before: Option<i64>,
225 pub last_sync_now: Option<i64>,
227 pub project_state: Option<String>,
229}
230
231impl BackupSignals {
232 pub fn from_status(before: Option<i64>, now: &RedundancyStatusWire) -> Self {
238 BackupSignals {
239 peer_connected: now.peer_connected,
240 sync_pending: now.sync_pending,
241 last_sync_before: before.filter(|value| *value >= 0),
242 last_sync_now: now.last_sync_epoch_ms(),
243 project_state: now.project_state.as_deref().and_then(status_word),
244 }
245 }
246
247 pub fn advanced(&self) -> bool {
250 match (self.last_sync_before, self.last_sync_now) {
251 (_, None) => false,
252 (None, Some(_)) => true,
253 (Some(before), Some(now)) => now > before,
254 }
255 }
256
257 pub fn linked(&self) -> bool {
259 self.peer_connected && !self.sync_pending
260 }
261}
262
263fn status_word(text: &str) -> Option<String> {
266 let plain = !text.is_empty()
267 && text.chars().count() <= 32
268 && text
269 .chars()
270 .all(|c| c.is_alphanumeric() || matches!(c, ' ' | '-' | '_'));
271 plain.then(|| text.to_string())
272}
273
274#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
277#[serde(rename_all = "snake_case")]
278pub enum UnsyncedReason {
279 Unreachable,
281 NotSyncing,
283 ContentDiffers,
285 SyncExhausted,
287}
288
289impl UnsyncedReason {
290 pub fn as_str(self) -> &'static str {
292 match self {
293 UnsyncedReason::Unreachable => "unreachable",
294 UnsyncedReason::NotSyncing => "not_syncing",
295 UnsyncedReason::ContentDiffers => "content_differs",
296 UnsyncedReason::SyncExhausted => "sync_exhausted",
297 }
298 }
299}
300
301#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
303#[serde(rename_all = "snake_case")]
304pub enum BackupCheckId {
305 Redundancy,
307 Mode,
309 ReadBack,
311 Files,
313}
314
315#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
317pub struct BackupCheck {
318 pub id: BackupCheckId,
320 pub status: CheckStatus,
322 pub detail: String,
324 pub findings: Vec<Finding>,
326}
327
328#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
331pub struct ResyncRecord {
332 pub ok: bool,
334 pub code: Option<String>,
336}
337
338#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
341pub struct BackupReport {
342 pub converged: bool,
344 pub reason: Option<UnsyncedReason>,
346 pub budget_s: u64,
348 pub waited_s: u64,
350 pub resync: Option<ResyncRecord>,
352 pub last_sync_before: Option<i64>,
354 pub last_sync_after: Option<i64>,
356 pub project_state: Option<String>,
358 pub files_checked: usize,
360 pub checks: Vec<BackupCheck>,
362}
363
364fn finding(code: &'static str, subject: &str, detail: &str) -> Finding {
365 Finding {
366 code,
367 subject: subject.to_string(),
368 detail: detail.to_string(),
369 status: None,
370 }
371}
372
373pub fn redundancy_check(
377 signals: &Result<BackupSignals, String>,
378 require_advance: bool,
379) -> BackupCheck {
380 let signals = match signals {
381 Ok(signals) => signals,
382 Err(code) => {
383 let detail = format!("unreachable: {code}");
384 return BackupCheck {
385 id: BackupCheckId::Redundancy,
386 status: CheckStatus::Fail,
387 findings: vec![finding("backup_unreachable", "redundancy", &detail)],
388 detail,
389 };
390 }
391 };
392 let mut findings = Vec::new();
393 if !signals.peer_connected {
394 findings.push(finding(
395 "peer_not_connected",
396 "peerConnected",
397 "peerConnected is false",
398 ));
399 }
400 if signals.sync_pending {
401 findings.push(finding(
402 "sync_pending",
403 "syncPending",
404 "syncPending is true",
405 ));
406 }
407 if require_advance && !signals.advanced() {
408 findings.push(finding(
409 "sync_not_advanced",
410 "lastSyncTimestamp",
411 "lastSyncTimestamp has not advanced past the pre-apply sample",
412 ));
413 }
414 if findings.is_empty() {
415 let detail = if require_advance {
416 "peerConnected, no sync pending, lastSyncTimestamp advanced"
417 } else {
418 "peerConnected, no sync pending"
419 };
420 return BackupCheck {
421 id: BackupCheckId::Redundancy,
422 status: CheckStatus::Pass,
423 detail: detail.to_string(),
424 findings,
425 };
426 }
427 let names: Vec<&str> = findings.iter().map(|f| f.subject.as_str()).collect();
428 BackupCheck {
429 id: BackupCheckId::Redundancy,
430 status: CheckStatus::Fail,
431 detail: format!("the backup is not settled: {}", names.join(", ")),
432 findings,
433 }
434}
435
436pub fn unreachable_check(id: BackupCheckId, code: &str) -> BackupCheck {
439 let detail = format!("unreachable: {code}");
440 BackupCheck {
441 id,
442 status: CheckStatus::Fail,
443 findings: vec![finding("backup_unreachable", id.word(), &detail)],
444 detail,
445 }
446}
447
448impl BackupCheckId {
449 pub fn word(self) -> &'static str {
451 match self {
452 BackupCheckId::Redundancy => "redundancy",
453 BackupCheckId::Mode => "mode",
454 BackupCheckId::ReadBack => "read_back",
455 BackupCheckId::Files => "files",
456 }
457 }
458}
459
460fn backup_check_from(id: BackupCheckId, check: verify::Check) -> BackupCheck {
461 BackupCheck {
462 id,
463 status: check.status,
464 detail: check.detail,
465 findings: check.findings,
466 }
467}
468
469pub fn mode_backup_check(expected: &Expected, mode: Option<&str>) -> BackupCheck {
472 backup_check_from(BackupCheckId::Mode, verify::mode_check(expected, mode))
473}
474
475pub fn read_back_backup_check(
478 expected: &Expected,
479 read_back: &BTreeMap<String, Remote>,
480 warnings: &mut Vec<String>,
481) -> (BackupCheck, usize) {
482 let (check, compared) = verify::read_back_check(expected, read_back, warnings);
483 (backup_check_from(BackupCheckId::ReadBack, check), compared)
484}
485
486pub fn read_back_backup_check_with(
492 expected: &Expected,
493 read_back: &BTreeMap<String, Remote>,
494 judge: &verify::ReadBackJudge<'_>,
495 warnings: &mut Vec<String>,
496) -> (BackupCheck, usize) {
497 let (check, compared) = verify::read_back_check_with(expected, read_back, judge, warnings);
498 (backup_check_from(BackupCheckId::ReadBack, check), compared)
499}
500
501#[derive(Debug, Clone, Copy, PartialEq, Eq)]
505pub enum SyncPredicate {
506 TimestampAdvanced,
509 StableTicks(u8),
512}
513
514pub const SYNC_PREDICATE: SyncPredicate = SyncPredicate::TimestampAdvanced;
516
517#[derive(Debug, Clone, PartialEq, Eq)]
519pub struct TickOutcome {
520 pub signals: Result<BackupSignals, String>,
523 pub exhausted: bool,
525 pub content: Option<Vec<BackupCheck>>,
527 pub files_checked: usize,
529 pub stable_ticks: u8,
532}
533
534pub fn converged(t: &TickOutcome, predicate: SyncPredicate) -> bool {
539 let Ok(signals) = &t.signals else {
540 return false;
541 };
542 if !signals.linked() || !sync_clause_met(t, signals, predicate) {
543 return false;
544 }
545 match &t.content {
546 Some(checks) => checks.iter().all(|c| c.status != CheckStatus::Fail),
547 None => false,
548 }
549}
550
551fn sync_clause_met(t: &TickOutcome, signals: &BackupSignals, predicate: SyncPredicate) -> bool {
552 match predicate {
553 SyncPredicate::TimestampAdvanced => signals.advanced(),
554 SyncPredicate::StableTicks(ticks) => t.stable_ticks >= ticks,
555 }
556}
557
558pub fn classify(t: &TickOutcome) -> Option<UnsyncedReason> {
563 if converged(t, SYNC_PREDICATE) {
564 return None;
565 }
566 let unanswered = t
567 .content
568 .iter()
569 .flatten()
570 .any(|c| c.status == CheckStatus::Fail && c.detail.starts_with(UNREACHABLE_PREFIX));
571 if t.signals.is_err() || unanswered {
572 return Some(UnsyncedReason::Unreachable);
573 }
574 if t.exhausted {
575 return Some(UnsyncedReason::SyncExhausted);
576 }
577 if let Ok(signals) = &t.signals
578 && (!signals.linked() || !sync_clause_met(t, signals, SYNC_PREDICATE))
579 {
580 return Some(UnsyncedReason::NotSyncing);
581 }
582 Some(UnsyncedReason::ContentDiffers)
583}
584
585const UNREACHABLE_PREFIX: &str = "unreachable:";
587
588#[derive(Debug, Clone, Copy, PartialEq, Eq)]
590pub enum PairWaitStep {
591 Converged,
593 Exhausted,
595 TimedOut,
597 Sleep(Duration),
599}
600
601pub fn wait_step(t: &TickOutcome, elapsed: Duration, policy: &BackupPolicy) -> PairWaitStep {
605 if converged(t, SYNC_PREDICATE) {
606 PairWaitStep::Converged
607 } else if t.exhausted {
608 PairWaitStep::Exhausted
609 } else if elapsed >= policy.budget {
610 PairWaitStep::TimedOut
611 } else {
612 PairWaitStep::Sleep(
613 policy
614 .interval
615 .max(Duration::from_millis(1))
616 .min(policy.budget - elapsed),
617 )
618 }
619}
620
621pub fn report(
625 t: &TickOutcome,
626 before: Option<i64>,
627 budget_s: u64,
628 waited_s: u64,
629 resync: Option<ResyncRecord>,
630) -> BackupReport {
631 let require_advance = matches!(SYNC_PREDICATE, SyncPredicate::TimestampAdvanced);
632 let mut checks = vec![redundancy_check(&t.signals, require_advance)];
633 checks.extend(t.content.iter().flatten().cloned());
634 let (sampled, after, project_state) = match &t.signals {
635 Ok(s) => (s.last_sync_before, s.last_sync_now, s.project_state.clone()),
636 Err(_) => (None, None, None),
637 };
638 BackupReport {
639 converged: converged(t, SYNC_PREDICATE),
640 reason: classify(t),
641 budget_s,
642 waited_s,
643 resync,
644 last_sync_before: before.filter(|value| *value >= 0).or(sampled),
645 last_sync_after: after,
646 project_state,
647 files_checked: t.files_checked,
648 checks,
649 }
650}
651
652pub fn verify_report(t: &TickOutcome) -> BackupReport {
663 let mut checks = vec![redundancy_check(&t.signals, false)];
664 checks.extend(t.content.iter().flatten().cloned());
665 let converged = t.content.is_some() && checks.iter().all(|c| c.status != CheckStatus::Fail);
666 let reason = if converged {
667 None
668 } else {
669 let unanswered = checks
670 .iter()
671 .any(|c| c.status == CheckStatus::Fail && c.detail.starts_with(UNREACHABLE_PREFIX));
672 Some(if unanswered {
673 UnsyncedReason::Unreachable
674 } else if !t.signals.as_ref().is_ok_and(BackupSignals::linked) {
675 UnsyncedReason::NotSyncing
676 } else {
677 UnsyncedReason::ContentDiffers
678 })
679 };
680 let (after, project_state) = match &t.signals {
681 Ok(s) => (s.last_sync_now, s.project_state.clone()),
682 Err(_) => (None, None),
683 };
684 BackupReport {
685 converged,
686 reason,
687 budget_s: 0,
688 waited_s: 0,
689 resync: None,
690 last_sync_before: None,
691 last_sync_after: after,
692 project_state,
693 files_checked: t.files_checked,
694 checks,
695 }
696}
697
698pub fn effective_files(observed: ObservedFiles) -> BTreeMap<RelPath, FileBlob> {
701 let mut files = observed.core;
702 files.extend(observed.mode);
703 files
704}
705
706pub type UnreadableUnits = BTreeMap<String, UnreadableKey>;
709
710pub fn unreadable_units(observed: &ObservedFiles) -> UnreadableUnits {
713 unreadable_unit_keys(observed)
714}
715
716pub const FILE_NORMALIZATION: &[(&str, &[&str])] = &[
738 (
739 "resource.json",
740 &[
741 "attributes.lastModification",
742 "attributes.lastModificationSignature",
743 ],
744 ),
745 ("unary-resource.json", &[]),
746];
747
748#[derive(Debug, Clone, Default, PartialEq, Eq)]
750pub struct FileComparison {
751 pub checked: usize,
753 pub differing: Vec<String>,
755 pub missing: Vec<String>,
757 pub unexpected: Vec<String>,
759 pub unreadable: Vec<(String, String)>,
764 pub unreadable_unchecked: Vec<(String, String)>,
767}
768
769pub fn compare_files(
777 master: &BTreeMap<RelPath, FileBlob>,
778 backup: &BTreeMap<RelPath, FileBlob>,
779 present: &BTreeSet<RelPath>,
780 absent: &BTreeSet<RelPath>,
781) -> FileComparison {
782 let mut out = FileComparison {
783 checked: present.len(),
784 ..FileComparison::default()
785 };
786 for path in present {
787 let Some(theirs) = backup.get(path) else {
788 out.missing.push(path.as_str().to_string());
789 continue;
790 };
791 let equal = match master.get(path) {
792 Some(ours) => ours.sha256 == theirs.sha256 || same_document(path, ours, theirs),
793 None => false,
794 };
795 if !equal {
796 out.differing.push(path.as_str().to_string());
797 }
798 }
799 for path in absent {
800 if backup.contains_key(path) {
801 out.unexpected.push(path.as_str().to_string());
802 }
803 }
804 out
806}
807
808pub fn exempt_unreadable(
818 c: &mut FileComparison,
819 present: &BTreeSet<RelPath>,
820 absent: &BTreeSet<RelPath>,
821 nodes: &[(&str, &UnreadableUnits)],
822) {
823 if nodes.iter().all(|(_, units)| units.is_empty()) {
824 return;
825 }
826 let layer: BTreeMap<RelPath, ()> = present
827 .iter()
828 .chain(absent.iter())
829 .map(|p| (p.clone(), ()))
830 .collect();
831 let keyed: Vec<(&RelPath, String)> = layer.keys().map(|p| (p, unit_key(p, &layer))).collect();
832 let mut hit: BTreeSet<String> = BTreeSet::new();
833 let mut overlapping: BTreeSet<(String, String)> = BTreeSet::new();
834 let mut unchecked: BTreeSet<(String, String)> = BTreeSet::new();
835 for (node, units) in nodes {
836 for (key, info) in units.iter() {
837 let covered: Vec<&RelPath> = keyed
838 .iter()
839 .filter(|(p, k)| k == key || info.paths.iter().any(|u| u == p.as_str()))
840 .map(|(p, _)| *p)
841 .collect();
842 let entry = ((*node).to_string(), key.clone());
843 if covered.is_empty() {
844 unchecked.insert(entry);
845 } else {
846 hit.extend(covered.iter().map(|p| p.as_str().to_string()));
847 overlapping.insert(entry);
848 }
849 }
850 }
851 for list in [&mut c.differing, &mut c.missing, &mut c.unexpected] {
852 list.retain(|p| !hit.contains(p));
853 }
854 c.unreadable = overlapping.into_iter().collect();
855 c.unreadable_unchecked = unchecked.into_iter().collect();
856}
857
858pub fn unreadable_warnings(c: &FileComparison) -> Vec<String> {
860 c.unreadable_unchecked
861 .iter()
862 .map(|(node, key)| {
863 format!(
864 "{node}: {key} is unreadable on the host and is neither rendered nor deleted; it was not compared"
865 )
866 })
867 .collect()
868}
869
870fn same_document(path: &RelPath, ours: &FileBlob, theirs: &FileBlob) -> bool {
873 let Some(name) = path.components().next_back() else {
874 return false;
875 };
876 let Some((_, ignored)) = FILE_NORMALIZATION.iter().find(|(file, _)| *file == name) else {
877 return false;
878 };
879 let (Ok(mut a), Ok(mut b)) = (
880 serde_json::from_slice::<Value>(&ours.bytes),
881 serde_json::from_slice::<Value>(&theirs.bytes),
882 ) else {
883 return false;
884 };
885 for doc in [&mut a, &mut b] {
886 for dotted in *ignored {
887 remove_member(doc, dotted);
888 }
889 }
890 a == b
891}
892
893fn remove_member(doc: &mut Value, path: &str) {
896 let mut node = doc;
897 let mut steps = path.split('.').peekable();
898 while let Some(step) = steps.next() {
899 let Value::Object(map) = node else {
900 return;
901 };
902 if steps.peek().is_none() {
903 map.remove(step);
904 return;
905 }
906 let Some(next) = map.get_mut(step) else {
907 return;
908 };
909 node = next;
910 }
911}
912
913pub fn files_check(c: &FileComparison) -> BackupCheck {
916 let kinds: [(&str, &str, &Vec<String>); 3] = [
917 (
918 "file_differs",
919 "differs from the master's live copy",
920 &c.differing,
921 ),
922 ("file_missing_on_backup", "is not on the backup", &c.missing),
923 (
924 "file_unexpected",
925 "was deleted by the apply but is still on the backup",
926 &c.unexpected,
927 ),
928 ];
929 let unreadable = c.unreadable.iter().map(|(node, key)| Finding {
931 code: "unreadable",
932 subject: key.clone(),
933 detail: format!(
934 "has a live path the {node}'s sftp server cannot read; its copy is unknown, so it is neither equal nor absent"
935 ),
936 status: None,
937 });
938 let findings: Vec<Finding> = unreadable
939 .chain(kinds.iter().flat_map(|(code, what, paths)| {
940 paths.iter().map(move |path| Finding {
941 code,
942 subject: path.clone(),
943 detail: (*what).to_string(),
944 status: None,
945 })
946 }))
947 .take(APPLY_LIST_SHOWN)
948 .collect();
949 let bad = c.differing.len() + c.missing.len() + c.unexpected.len() + c.unreadable.len();
950 let (status, detail) = if bad == 0 && c.unreadable_unchecked.is_empty() {
951 (
952 CheckStatus::Pass,
953 format!("{} file(s) equal the master's live copies", c.checked),
954 )
955 } else if bad == 0 {
956 let shown: Vec<String> = c
957 .unreadable_unchecked
958 .iter()
959 .take(APPLY_LIST_SHOWN)
960 .map(|(node, key)| format!("{key} ({node})"))
961 .collect();
962 let more = c.unreadable_unchecked.len() - shown.len();
963 (
964 CheckStatus::Warn,
965 format!(
966 "{} 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: {}{}",
967 c.checked,
968 c.unreadable_unchecked.len(),
969 shown.join(", "),
970 if more > 0 {
971 format!(" and {more} more")
972 } else {
973 String::new()
974 }
975 ),
976 )
977 } else {
978 let mut detail = format!(
979 "{} file(s) compared with the master's live copies: {} differ, {} missing on the backup, {} still present after being deleted",
980 c.checked,
981 c.differing.len(),
982 c.missing.len(),
983 c.unexpected.len()
984 );
985 if !c.unreadable.is_empty() {
986 detail.push_str(&format!(
987 ", {} unit(s) unreadable on a node",
988 c.unreadable.len()
989 ));
990 }
991 (CheckStatus::Fail, detail)
992 };
993 BackupCheck {
994 id: BackupCheckId::Files,
995 status,
996 detail,
997 findings,
998 }
999}
1000
1001pub fn files_skipped_check() -> BackupCheck {
1004 BackupCheck {
1005 id: BackupCheckId::Files,
1006 status: CheckStatus::Warn,
1007 detail:
1008 "nodes.backup declares no ssh or local transport, so the file-hash check did not run"
1009 .to_string(),
1010 findings: Vec::new(),
1011 }
1012}
1013
1014#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
1018pub struct SecretRef {
1019 pub provider: String,
1021 pub name: String,
1023}
1024
1025#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1027pub enum SecretCheckMode {
1028 Preflight,
1031 Verify,
1033}
1034
1035#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1037pub struct SecretNodeReport {
1038 pub node: String,
1040 pub status: CheckStatus,
1043 pub providers_checked: usize,
1045 pub missing: Vec<SecretRef>,
1047 pub unverifiable: Vec<String>,
1049 #[serde(skip_serializing_if = "Vec::is_empty")]
1052 pub skipped_providers: Vec<String>,
1053}
1054
1055#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1057pub struct SecretNamesReport {
1058 pub nodes: Vec<SecretNodeReport>,
1060}
1061
1062impl SecretNamesReport {
1063 pub fn ordered(mut nodes: Vec<SecretNodeReport>) -> Self {
1065 nodes.sort_by_key(|n| match n.node.as_str() {
1066 "master" => 0u8,
1067 "backup" => 1,
1068 _ => 2,
1069 });
1070 SecretNamesReport { nodes }
1071 }
1072}
1073
1074pub fn collect_referenced_secrets(tree: &RenderedTree) -> BTreeSet<(String, String)> {
1077 let mut out = BTreeSet::new();
1078 for (path, bytes) in &tree.files {
1079 if !path.as_str().ends_with(".json") {
1080 continue;
1081 }
1082 if let Ok((_, parsed)) = node::classify(bytes) {
1083 referenced_secret_names(&parsed, &mut out);
1084 }
1085 }
1086 out
1087}
1088
1089pub fn declared_secret_providers(tree: &RenderedTree) -> BTreeSet<String> {
1092 let mut out = BTreeSet::new();
1093 for path in tree.files.keys() {
1094 let parts: Vec<&str> = path.components().collect();
1095 if parts.len() >= 4 && parts[0] == "ignition" && parts[1] == "secret-provider" {
1096 out.insert(parts[2].to_string());
1097 }
1098 }
1099 out
1100}
1101
1102pub fn secrets_defined_by_render(tree: &RenderedTree) -> BTreeSet<(String, String)> {
1113 let mut out = BTreeSet::new();
1114 for (path, bytes) in &tree.files {
1115 let parts: Vec<&str> = path.components().collect();
1116 let [first, second, provider, file] = parts.as_slice() else {
1117 continue;
1118 };
1119 if *first != "ignition" || *second != "secret-provider" || *file != "config.json" {
1120 continue;
1121 }
1122 let Ok(config) = serde_json::from_slice::<Value>(bytes) else {
1123 continue;
1124 };
1125 for member in ["files", "secrets"] {
1126 if let Some(entries) = config
1127 .get("settings")
1128 .and_then(|settings| settings.get(member))
1129 .and_then(Value::as_object)
1130 {
1131 out.extend(
1132 entries
1133 .keys()
1134 .map(|name| ((*provider).to_string(), name.clone())),
1135 );
1136 }
1137 }
1138 }
1139 out
1140}
1141
1142pub fn check_secret_names(
1153 node: &str,
1154 referenced: &BTreeSet<(String, String)>,
1155 listings: &BTreeMap<String, SecretNameListing>,
1156 declared: &BTreeSet<String>,
1157 mode: SecretCheckMode,
1158) -> SecretNodeReport {
1159 let mut by_provider: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
1160 for (provider, name) in referenced {
1161 by_provider
1162 .entry(provider.as_str())
1163 .or_default()
1164 .push(name.as_str());
1165 }
1166 let mut missing = Vec::new();
1167 let mut unverifiable = Vec::new();
1168 let mut skipped = Vec::new();
1169 for (provider, names) in &by_provider {
1170 let all_missing = |missing: &mut Vec<SecretRef>| {
1171 missing.extend(names.iter().map(|name| SecretRef {
1172 provider: (*provider).to_string(),
1173 name: (*name).to_string(),
1174 }));
1175 };
1176 match listings.get(*provider) {
1177 Some(SecretNameListing::Listed(listed)) => {
1178 missing.extend(
1179 names
1180 .iter()
1181 .filter(|name| !listed.contains(**name))
1182 .map(|name| SecretRef {
1183 provider: (*provider).to_string(),
1184 name: (*name).to_string(),
1185 }),
1186 );
1187 }
1188 Some(SecretNameListing::CannotList) => unverifiable.push((*provider).to_string()),
1189 Some(SecretNameListing::NoProvider) | None => {
1190 if mode == SecretCheckMode::Preflight && declared.contains(*provider) {
1191 skipped.push((*provider).to_string());
1192 } else {
1193 all_missing(&mut missing);
1194 }
1195 }
1196 }
1197 }
1198 let status = if !missing.is_empty() {
1199 CheckStatus::Fail
1200 } else if !unverifiable.is_empty() {
1201 CheckStatus::Warn
1202 } else {
1203 CheckStatus::Pass
1204 };
1205 SecretNodeReport {
1206 node: node.to_string(),
1207 status,
1208 providers_checked: by_provider.len(),
1209 missing,
1210 unverifiable,
1211 skipped_providers: skipped,
1212 }
1213}
1214
1215#[cfg(test)]
1216mod tests;