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 status: None,
362 }
363}
364
365pub fn redundancy_check(
369 signals: &Result<BackupSignals, String>,
370 require_advance: bool,
371) -> BackupCheck {
372 let signals = match signals {
373 Ok(signals) => signals,
374 Err(code) => {
375 let detail = format!("unreachable: {code}");
376 return BackupCheck {
377 id: BackupCheckId::Redundancy,
378 status: CheckStatus::Fail,
379 findings: vec![finding("backup_unreachable", "redundancy", &detail)],
380 detail,
381 };
382 }
383 };
384 let mut findings = Vec::new();
385 if !signals.peer_connected {
386 findings.push(finding(
387 "peer_not_connected",
388 "peerConnected",
389 "peerConnected is false",
390 ));
391 }
392 if signals.sync_pending {
393 findings.push(finding(
394 "sync_pending",
395 "syncPending",
396 "syncPending is true",
397 ));
398 }
399 if require_advance && !signals.advanced() {
400 findings.push(finding(
401 "sync_not_advanced",
402 "lastSyncTimestamp",
403 "lastSyncTimestamp has not advanced past the pre-apply sample",
404 ));
405 }
406 if findings.is_empty() {
407 let detail = if require_advance {
408 "peerConnected, no sync pending, lastSyncTimestamp advanced"
409 } else {
410 "peerConnected, no sync pending"
411 };
412 return BackupCheck {
413 id: BackupCheckId::Redundancy,
414 status: CheckStatus::Pass,
415 detail: detail.to_string(),
416 findings,
417 };
418 }
419 let names: Vec<&str> = findings.iter().map(|f| f.subject.as_str()).collect();
420 BackupCheck {
421 id: BackupCheckId::Redundancy,
422 status: CheckStatus::Fail,
423 detail: format!("the backup is not settled: {}", names.join(", ")),
424 findings,
425 }
426}
427
428pub fn unreachable_check(id: BackupCheckId, code: &str) -> BackupCheck {
431 let detail = format!("unreachable: {code}");
432 BackupCheck {
433 id,
434 status: CheckStatus::Fail,
435 findings: vec![finding("backup_unreachable", id.word(), &detail)],
436 detail,
437 }
438}
439
440impl BackupCheckId {
441 pub fn word(self) -> &'static str {
443 match self {
444 BackupCheckId::Redundancy => "redundancy",
445 BackupCheckId::Mode => "mode",
446 BackupCheckId::ReadBack => "read_back",
447 BackupCheckId::Files => "files",
448 }
449 }
450}
451
452fn backup_check_from(id: BackupCheckId, check: verify::Check) -> BackupCheck {
453 BackupCheck {
454 id,
455 status: check.status,
456 detail: check.detail,
457 findings: check.findings,
458 }
459}
460
461pub fn mode_backup_check(expected: &Expected, mode: Option<&str>) -> BackupCheck {
464 backup_check_from(BackupCheckId::Mode, verify::mode_check(expected, mode))
465}
466
467pub fn read_back_backup_check(
470 expected: &Expected,
471 read_back: &BTreeMap<String, Remote>,
472 warnings: &mut Vec<String>,
473) -> (BackupCheck, usize) {
474 let (check, compared) = verify::read_back_check(expected, read_back, warnings);
475 (backup_check_from(BackupCheckId::ReadBack, check), compared)
476}
477
478pub fn read_back_backup_check_with(
484 expected: &Expected,
485 read_back: &BTreeMap<String, Remote>,
486 judge: &verify::ReadBackJudge<'_>,
487 warnings: &mut Vec<String>,
488) -> (BackupCheck, usize) {
489 let (check, compared) = verify::read_back_check_with(expected, read_back, judge, warnings);
490 (backup_check_from(BackupCheckId::ReadBack, check), compared)
491}
492
493#[derive(Debug, Clone, Copy, PartialEq, Eq)]
497pub enum SyncPredicate {
498 TimestampAdvanced,
501 StableTicks(u8),
504}
505
506pub const SYNC_PREDICATE: SyncPredicate = SyncPredicate::TimestampAdvanced;
508
509#[derive(Debug, Clone, PartialEq, Eq)]
511pub struct TickOutcome {
512 pub signals: Result<BackupSignals, String>,
515 pub exhausted: bool,
517 pub content: Option<Vec<BackupCheck>>,
519 pub files_checked: usize,
521 pub stable_ticks: u8,
524}
525
526pub fn converged(t: &TickOutcome, predicate: SyncPredicate) -> bool {
531 let Ok(signals) = &t.signals else {
532 return false;
533 };
534 if !signals.linked() || !sync_clause_met(t, signals, predicate) {
535 return false;
536 }
537 match &t.content {
538 Some(checks) => checks.iter().all(|c| c.status != CheckStatus::Fail),
539 None => false,
540 }
541}
542
543fn sync_clause_met(t: &TickOutcome, signals: &BackupSignals, predicate: SyncPredicate) -> bool {
544 match predicate {
545 SyncPredicate::TimestampAdvanced => signals.advanced(),
546 SyncPredicate::StableTicks(ticks) => t.stable_ticks >= ticks,
547 }
548}
549
550pub fn classify(t: &TickOutcome) -> Option<UnsyncedReason> {
555 if converged(t, SYNC_PREDICATE) {
556 return None;
557 }
558 let unanswered = t
559 .content
560 .iter()
561 .flatten()
562 .any(|c| c.status == CheckStatus::Fail && c.detail.starts_with(UNREACHABLE_PREFIX));
563 if t.signals.is_err() || unanswered {
564 return Some(UnsyncedReason::Unreachable);
565 }
566 if t.exhausted {
567 return Some(UnsyncedReason::SyncExhausted);
568 }
569 if let Ok(signals) = &t.signals
570 && (!signals.linked() || !sync_clause_met(t, signals, SYNC_PREDICATE))
571 {
572 return Some(UnsyncedReason::NotSyncing);
573 }
574 Some(UnsyncedReason::ContentDiffers)
575}
576
577const UNREACHABLE_PREFIX: &str = "unreachable:";
579
580#[derive(Debug, Clone, Copy, PartialEq, Eq)]
582pub enum PairWaitStep {
583 Converged,
585 Exhausted,
587 TimedOut,
589 Sleep(Duration),
591}
592
593pub fn wait_step(t: &TickOutcome, elapsed: Duration, policy: &BackupPolicy) -> PairWaitStep {
597 if converged(t, SYNC_PREDICATE) {
598 PairWaitStep::Converged
599 } else if t.exhausted {
600 PairWaitStep::Exhausted
601 } else if elapsed >= policy.budget {
602 PairWaitStep::TimedOut
603 } else {
604 PairWaitStep::Sleep(
605 policy
606 .interval
607 .max(Duration::from_millis(1))
608 .min(policy.budget - elapsed),
609 )
610 }
611}
612
613pub fn report(
617 t: &TickOutcome,
618 before: Option<i64>,
619 budget_s: u64,
620 waited_s: u64,
621 resync: Option<ResyncRecord>,
622) -> BackupReport {
623 let require_advance = matches!(SYNC_PREDICATE, SyncPredicate::TimestampAdvanced);
624 let mut checks = vec![redundancy_check(&t.signals, require_advance)];
625 checks.extend(t.content.iter().flatten().cloned());
626 let (sampled, after, project_state) = match &t.signals {
627 Ok(s) => (s.last_sync_before, s.last_sync_now, s.project_state.clone()),
628 Err(_) => (None, None, None),
629 };
630 BackupReport {
631 converged: converged(t, SYNC_PREDICATE),
632 reason: classify(t),
633 budget_s,
634 waited_s,
635 resync,
636 last_sync_before: before.filter(|value| *value >= 0).or(sampled),
637 last_sync_after: after,
638 project_state,
639 files_checked: t.files_checked,
640 checks,
641 }
642}
643
644pub fn verify_report(t: &TickOutcome) -> BackupReport {
655 let mut checks = vec![redundancy_check(&t.signals, false)];
656 checks.extend(t.content.iter().flatten().cloned());
657 let converged = t.content.is_some() && checks.iter().all(|c| c.status != CheckStatus::Fail);
658 let reason = if converged {
659 None
660 } else {
661 let unanswered = checks
662 .iter()
663 .any(|c| c.status == CheckStatus::Fail && c.detail.starts_with(UNREACHABLE_PREFIX));
664 Some(if unanswered {
665 UnsyncedReason::Unreachable
666 } else if !t.signals.as_ref().is_ok_and(BackupSignals::linked) {
667 UnsyncedReason::NotSyncing
668 } else {
669 UnsyncedReason::ContentDiffers
670 })
671 };
672 let (after, project_state) = match &t.signals {
673 Ok(s) => (s.last_sync_now, s.project_state.clone()),
674 Err(_) => (None, None),
675 };
676 BackupReport {
677 converged,
678 reason,
679 budget_s: 0,
680 waited_s: 0,
681 resync: None,
682 last_sync_before: None,
683 last_sync_after: after,
684 project_state,
685 files_checked: t.files_checked,
686 checks,
687 }
688}
689
690pub fn effective_files(observed: ObservedFiles) -> BTreeMap<RelPath, FileBlob> {
693 let mut files = observed.core;
694 files.extend(observed.mode);
695 files
696}
697
698pub type UnreadableUnits = BTreeMap<String, UnreadableKey>;
701
702pub fn unreadable_units(observed: &ObservedFiles) -> UnreadableUnits {
705 unreadable_unit_keys(observed)
706}
707
708pub const FILE_NORMALIZATION: &[(&str, &[&str])] = &[(
722 "resource.json",
723 &[
724 "attributes.lastModification",
725 "attributes.lastModificationSignature",
726 ],
727)];
728
729#[derive(Debug, Clone, Default, PartialEq, Eq)]
731pub struct FileComparison {
732 pub checked: usize,
734 pub differing: Vec<String>,
736 pub missing: Vec<String>,
738 pub unexpected: Vec<String>,
740 pub unreadable: Vec<(String, String)>,
745 pub unreadable_unchecked: Vec<(String, String)>,
748}
749
750pub fn compare_files(
757 master: &BTreeMap<RelPath, FileBlob>,
758 backup: &BTreeMap<RelPath, FileBlob>,
759 present: &BTreeSet<RelPath>,
760 absent: &BTreeSet<RelPath>,
761) -> FileComparison {
762 let mut out = FileComparison {
763 checked: present.len(),
764 ..FileComparison::default()
765 };
766 for path in present {
767 let Some(theirs) = backup.get(path) else {
768 out.missing.push(path.as_str().to_string());
769 continue;
770 };
771 let equal = match master.get(path) {
772 Some(ours) => ours.sha256 == theirs.sha256 || same_document(path, ours, theirs),
773 None => false,
774 };
775 if !equal {
776 out.differing.push(path.as_str().to_string());
777 }
778 }
779 for path in absent {
780 if backup.contains_key(path) {
781 out.unexpected.push(path.as_str().to_string());
782 }
783 }
784 out
786}
787
788pub fn exempt_unreadable(
798 c: &mut FileComparison,
799 present: &BTreeSet<RelPath>,
800 absent: &BTreeSet<RelPath>,
801 nodes: &[(&str, &UnreadableUnits)],
802) {
803 if nodes.iter().all(|(_, units)| units.is_empty()) {
804 return;
805 }
806 let layer: BTreeMap<RelPath, ()> = present
807 .iter()
808 .chain(absent.iter())
809 .map(|p| (p.clone(), ()))
810 .collect();
811 let keyed: Vec<(&RelPath, String)> = layer.keys().map(|p| (p, unit_key(p, &layer))).collect();
812 let mut hit: BTreeSet<String> = BTreeSet::new();
813 let mut overlapping: BTreeSet<(String, String)> = BTreeSet::new();
814 let mut unchecked: BTreeSet<(String, String)> = BTreeSet::new();
815 for (node, units) in nodes {
816 for (key, info) in units.iter() {
817 let covered: Vec<&RelPath> = keyed
818 .iter()
819 .filter(|(p, k)| k == key || info.paths.iter().any(|u| u == p.as_str()))
820 .map(|(p, _)| *p)
821 .collect();
822 let entry = ((*node).to_string(), key.clone());
823 if covered.is_empty() {
824 unchecked.insert(entry);
825 } else {
826 hit.extend(covered.iter().map(|p| p.as_str().to_string()));
827 overlapping.insert(entry);
828 }
829 }
830 }
831 for list in [&mut c.differing, &mut c.missing, &mut c.unexpected] {
832 list.retain(|p| !hit.contains(p));
833 }
834 c.unreadable = overlapping.into_iter().collect();
835 c.unreadable_unchecked = unchecked.into_iter().collect();
836}
837
838pub fn unreadable_warnings(c: &FileComparison) -> Vec<String> {
840 c.unreadable_unchecked
841 .iter()
842 .map(|(node, key)| {
843 format!(
844 "{node}: {key} is unreadable on the host and is neither rendered nor deleted; it was not compared"
845 )
846 })
847 .collect()
848}
849
850fn same_document(path: &RelPath, ours: &FileBlob, theirs: &FileBlob) -> bool {
853 let Some(name) = path.components().next_back() else {
854 return false;
855 };
856 let Some((_, ignored)) = FILE_NORMALIZATION.iter().find(|(file, _)| *file == name) else {
857 return false;
858 };
859 let (Ok(mut a), Ok(mut b)) = (
860 serde_json::from_slice::<Value>(&ours.bytes),
861 serde_json::from_slice::<Value>(&theirs.bytes),
862 ) else {
863 return false;
864 };
865 for doc in [&mut a, &mut b] {
866 for dotted in *ignored {
867 remove_member(doc, dotted);
868 }
869 }
870 a == b
871}
872
873fn remove_member(doc: &mut Value, path: &str) {
876 let mut node = doc;
877 let mut steps = path.split('.').peekable();
878 while let Some(step) = steps.next() {
879 let Value::Object(map) = node else {
880 return;
881 };
882 if steps.peek().is_none() {
883 map.remove(step);
884 return;
885 }
886 let Some(next) = map.get_mut(step) else {
887 return;
888 };
889 node = next;
890 }
891}
892
893pub fn files_check(c: &FileComparison) -> BackupCheck {
896 let kinds: [(&str, &str, &Vec<String>); 3] = [
897 (
898 "file_differs",
899 "differs from the master's live copy",
900 &c.differing,
901 ),
902 ("file_missing_on_backup", "is not on the backup", &c.missing),
903 (
904 "file_unexpected",
905 "was deleted by the apply but is still on the backup",
906 &c.unexpected,
907 ),
908 ];
909 let unreadable = c.unreadable.iter().map(|(node, key)| Finding {
911 code: "unreadable",
912 subject: key.clone(),
913 detail: format!(
914 "has a live path the {node}'s sftp server cannot read; its copy is unknown, so it is neither equal nor absent"
915 ),
916 status: None,
917 });
918 let findings: Vec<Finding> = unreadable
919 .chain(kinds.iter().flat_map(|(code, what, paths)| {
920 paths.iter().map(move |path| Finding {
921 code,
922 subject: path.clone(),
923 detail: (*what).to_string(),
924 status: None,
925 })
926 }))
927 .take(APPLY_LIST_SHOWN)
928 .collect();
929 let bad = c.differing.len() + c.missing.len() + c.unexpected.len() + c.unreadable.len();
930 let (status, detail) = if bad == 0 && c.unreadable_unchecked.is_empty() {
931 (
932 CheckStatus::Pass,
933 format!("{} file(s) equal the master's live copies", c.checked),
934 )
935 } else if bad == 0 {
936 let shown: Vec<String> = c
937 .unreadable_unchecked
938 .iter()
939 .take(APPLY_LIST_SHOWN)
940 .map(|(node, key)| format!("{key} ({node})"))
941 .collect();
942 let more = c.unreadable_unchecked.len() - shown.len();
943 (
944 CheckStatus::Warn,
945 format!(
946 "{} 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: {}{}",
947 c.checked,
948 c.unreadable_unchecked.len(),
949 shown.join(", "),
950 if more > 0 {
951 format!(" and {more} more")
952 } else {
953 String::new()
954 }
955 ),
956 )
957 } else {
958 let mut detail = format!(
959 "{} file(s) compared with the master's live copies: {} differ, {} missing on the backup, {} still present after being deleted",
960 c.checked,
961 c.differing.len(),
962 c.missing.len(),
963 c.unexpected.len()
964 );
965 if !c.unreadable.is_empty() {
966 detail.push_str(&format!(
967 ", {} unit(s) unreadable on a node",
968 c.unreadable.len()
969 ));
970 }
971 (CheckStatus::Fail, detail)
972 };
973 BackupCheck {
974 id: BackupCheckId::Files,
975 status,
976 detail,
977 findings,
978 }
979}
980
981pub fn files_skipped_check() -> BackupCheck {
984 BackupCheck {
985 id: BackupCheckId::Files,
986 status: CheckStatus::Warn,
987 detail:
988 "nodes.backup declares no ssh or local transport, so the file-hash check did not run"
989 .to_string(),
990 findings: Vec::new(),
991 }
992}
993
994#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
998pub struct SecretRef {
999 pub provider: String,
1001 pub name: String,
1003}
1004
1005#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1007pub enum SecretCheckMode {
1008 Preflight,
1011 Verify,
1013}
1014
1015#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1017pub struct SecretNodeReport {
1018 pub node: String,
1020 pub status: CheckStatus,
1023 pub providers_checked: usize,
1025 pub missing: Vec<SecretRef>,
1027 pub unverifiable: Vec<String>,
1029 #[serde(skip_serializing_if = "Vec::is_empty")]
1032 pub skipped_providers: Vec<String>,
1033}
1034
1035#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1037pub struct SecretNamesReport {
1038 pub nodes: Vec<SecretNodeReport>,
1040}
1041
1042impl SecretNamesReport {
1043 pub fn ordered(mut nodes: Vec<SecretNodeReport>) -> Self {
1045 nodes.sort_by_key(|n| match n.node.as_str() {
1046 "master" => 0u8,
1047 "backup" => 1,
1048 _ => 2,
1049 });
1050 SecretNamesReport { nodes }
1051 }
1052}
1053
1054pub fn collect_referenced_secrets(tree: &RenderedTree) -> BTreeSet<(String, String)> {
1057 let mut out = BTreeSet::new();
1058 for (path, bytes) in &tree.files {
1059 if !path.as_str().ends_with(".json") {
1060 continue;
1061 }
1062 if let Ok((_, parsed)) = node::classify(bytes) {
1063 referenced_secret_names(&parsed, &mut out);
1064 }
1065 }
1066 out
1067}
1068
1069pub fn declared_secret_providers(tree: &RenderedTree) -> BTreeSet<String> {
1072 let mut out = BTreeSet::new();
1073 for path in tree.files.keys() {
1074 let parts: Vec<&str> = path.components().collect();
1075 if parts.len() >= 4 && parts[0] == "ignition" && parts[1] == "secret-provider" {
1076 out.insert(parts[2].to_string());
1077 }
1078 }
1079 out
1080}
1081
1082pub fn secrets_defined_by_render(tree: &RenderedTree) -> BTreeSet<(String, String)> {
1093 let mut out = BTreeSet::new();
1094 for (path, bytes) in &tree.files {
1095 let parts: Vec<&str> = path.components().collect();
1096 let [first, second, provider, file] = parts.as_slice() else {
1097 continue;
1098 };
1099 if *first != "ignition" || *second != "secret-provider" || *file != "config.json" {
1100 continue;
1101 }
1102 let Ok(config) = serde_json::from_slice::<Value>(bytes) else {
1103 continue;
1104 };
1105 for member in ["files", "secrets"] {
1106 if let Some(entries) = config
1107 .get("settings")
1108 .and_then(|settings| settings.get(member))
1109 .and_then(Value::as_object)
1110 {
1111 out.extend(
1112 entries
1113 .keys()
1114 .map(|name| ((*provider).to_string(), name.clone())),
1115 );
1116 }
1117 }
1118 }
1119 out
1120}
1121
1122pub fn check_secret_names(
1133 node: &str,
1134 referenced: &BTreeSet<(String, String)>,
1135 listings: &BTreeMap<String, SecretNameListing>,
1136 declared: &BTreeSet<String>,
1137 mode: SecretCheckMode,
1138) -> SecretNodeReport {
1139 let mut by_provider: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
1140 for (provider, name) in referenced {
1141 by_provider
1142 .entry(provider.as_str())
1143 .or_default()
1144 .push(name.as_str());
1145 }
1146 let mut missing = Vec::new();
1147 let mut unverifiable = Vec::new();
1148 let mut skipped = Vec::new();
1149 for (provider, names) in &by_provider {
1150 let all_missing = |missing: &mut Vec<SecretRef>| {
1151 missing.extend(names.iter().map(|name| SecretRef {
1152 provider: (*provider).to_string(),
1153 name: (*name).to_string(),
1154 }));
1155 };
1156 match listings.get(*provider) {
1157 Some(SecretNameListing::Listed(listed)) => {
1158 missing.extend(
1159 names
1160 .iter()
1161 .filter(|name| !listed.contains(**name))
1162 .map(|name| SecretRef {
1163 provider: (*provider).to_string(),
1164 name: (*name).to_string(),
1165 }),
1166 );
1167 }
1168 Some(SecretNameListing::CannotList) => unverifiable.push((*provider).to_string()),
1169 Some(SecretNameListing::NoProvider) | None => {
1170 if mode == SecretCheckMode::Preflight && declared.contains(*provider) {
1171 skipped.push((*provider).to_string());
1172 } else {
1173 all_missing(&mut missing);
1174 }
1175 }
1176 }
1177 }
1178 let status = if !missing.is_empty() {
1179 CheckStatus::Fail
1180 } else if !unverifiable.is_empty() {
1181 CheckStatus::Warn
1182 } else {
1183 CheckStatus::Pass
1184 };
1185 SecretNodeReport {
1186 node: node.to_string(),
1187 status,
1188 providers_checked: by_provider.len(),
1189 missing,
1190 unverifiable,
1191 skipped_providers: skipped,
1192 }
1193}
1194
1195#[cfg(test)]
1196mod tests;