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])] = &[
730 (
731 "resource.json",
732 &[
733 "attributes.lastModification",
734 "attributes.lastModificationSignature",
735 ],
736 ),
737 ("unary-resource.json", &[]),
738];
739
740#[derive(Debug, Clone, Default, PartialEq, Eq)]
742pub struct FileComparison {
743 pub checked: usize,
745 pub differing: Vec<String>,
747 pub missing: Vec<String>,
749 pub unexpected: Vec<String>,
751 pub unreadable: Vec<(String, String)>,
756 pub unreadable_unchecked: Vec<(String, String)>,
759}
760
761pub fn compare_files(
769 master: &BTreeMap<RelPath, FileBlob>,
770 backup: &BTreeMap<RelPath, FileBlob>,
771 present: &BTreeSet<RelPath>,
772 absent: &BTreeSet<RelPath>,
773) -> FileComparison {
774 let mut out = FileComparison {
775 checked: present.len(),
776 ..FileComparison::default()
777 };
778 for path in present {
779 let Some(theirs) = backup.get(path) else {
780 out.missing.push(path.as_str().to_string());
781 continue;
782 };
783 let equal = match master.get(path) {
784 Some(ours) => ours.sha256 == theirs.sha256 || same_document(path, ours, theirs),
785 None => false,
786 };
787 if !equal {
788 out.differing.push(path.as_str().to_string());
789 }
790 }
791 for path in absent {
792 if backup.contains_key(path) {
793 out.unexpected.push(path.as_str().to_string());
794 }
795 }
796 out
798}
799
800pub fn exempt_unreadable(
810 c: &mut FileComparison,
811 present: &BTreeSet<RelPath>,
812 absent: &BTreeSet<RelPath>,
813 nodes: &[(&str, &UnreadableUnits)],
814) {
815 if nodes.iter().all(|(_, units)| units.is_empty()) {
816 return;
817 }
818 let layer: BTreeMap<RelPath, ()> = present
819 .iter()
820 .chain(absent.iter())
821 .map(|p| (p.clone(), ()))
822 .collect();
823 let keyed: Vec<(&RelPath, String)> = layer.keys().map(|p| (p, unit_key(p, &layer))).collect();
824 let mut hit: BTreeSet<String> = BTreeSet::new();
825 let mut overlapping: BTreeSet<(String, String)> = BTreeSet::new();
826 let mut unchecked: BTreeSet<(String, String)> = BTreeSet::new();
827 for (node, units) in nodes {
828 for (key, info) in units.iter() {
829 let covered: Vec<&RelPath> = keyed
830 .iter()
831 .filter(|(p, k)| k == key || info.paths.iter().any(|u| u == p.as_str()))
832 .map(|(p, _)| *p)
833 .collect();
834 let entry = ((*node).to_string(), key.clone());
835 if covered.is_empty() {
836 unchecked.insert(entry);
837 } else {
838 hit.extend(covered.iter().map(|p| p.as_str().to_string()));
839 overlapping.insert(entry);
840 }
841 }
842 }
843 for list in [&mut c.differing, &mut c.missing, &mut c.unexpected] {
844 list.retain(|p| !hit.contains(p));
845 }
846 c.unreadable = overlapping.into_iter().collect();
847 c.unreadable_unchecked = unchecked.into_iter().collect();
848}
849
850pub fn unreadable_warnings(c: &FileComparison) -> Vec<String> {
852 c.unreadable_unchecked
853 .iter()
854 .map(|(node, key)| {
855 format!(
856 "{node}: {key} is unreadable on the host and is neither rendered nor deleted; it was not compared"
857 )
858 })
859 .collect()
860}
861
862fn same_document(path: &RelPath, ours: &FileBlob, theirs: &FileBlob) -> bool {
865 let Some(name) = path.components().next_back() else {
866 return false;
867 };
868 let Some((_, ignored)) = FILE_NORMALIZATION.iter().find(|(file, _)| *file == name) else {
869 return false;
870 };
871 let (Ok(mut a), Ok(mut b)) = (
872 serde_json::from_slice::<Value>(&ours.bytes),
873 serde_json::from_slice::<Value>(&theirs.bytes),
874 ) else {
875 return false;
876 };
877 for doc in [&mut a, &mut b] {
878 for dotted in *ignored {
879 remove_member(doc, dotted);
880 }
881 }
882 a == b
883}
884
885fn remove_member(doc: &mut Value, path: &str) {
888 let mut node = doc;
889 let mut steps = path.split('.').peekable();
890 while let Some(step) = steps.next() {
891 let Value::Object(map) = node else {
892 return;
893 };
894 if steps.peek().is_none() {
895 map.remove(step);
896 return;
897 }
898 let Some(next) = map.get_mut(step) else {
899 return;
900 };
901 node = next;
902 }
903}
904
905pub fn files_check(c: &FileComparison) -> BackupCheck {
908 let kinds: [(&str, &str, &Vec<String>); 3] = [
909 (
910 "file_differs",
911 "differs from the master's live copy",
912 &c.differing,
913 ),
914 ("file_missing_on_backup", "is not on the backup", &c.missing),
915 (
916 "file_unexpected",
917 "was deleted by the apply but is still on the backup",
918 &c.unexpected,
919 ),
920 ];
921 let unreadable = c.unreadable.iter().map(|(node, key)| Finding {
923 code: "unreadable",
924 subject: key.clone(),
925 detail: format!(
926 "has a live path the {node}'s sftp server cannot read; its copy is unknown, so it is neither equal nor absent"
927 ),
928 status: None,
929 });
930 let findings: Vec<Finding> = unreadable
931 .chain(kinds.iter().flat_map(|(code, what, paths)| {
932 paths.iter().map(move |path| Finding {
933 code,
934 subject: path.clone(),
935 detail: (*what).to_string(),
936 status: None,
937 })
938 }))
939 .take(APPLY_LIST_SHOWN)
940 .collect();
941 let bad = c.differing.len() + c.missing.len() + c.unexpected.len() + c.unreadable.len();
942 let (status, detail) = if bad == 0 && c.unreadable_unchecked.is_empty() {
943 (
944 CheckStatus::Pass,
945 format!("{} file(s) equal the master's live copies", c.checked),
946 )
947 } else if bad == 0 {
948 let shown: Vec<String> = c
949 .unreadable_unchecked
950 .iter()
951 .take(APPLY_LIST_SHOWN)
952 .map(|(node, key)| format!("{key} ({node})"))
953 .collect();
954 let more = c.unreadable_unchecked.len() - shown.len();
955 (
956 CheckStatus::Warn,
957 format!(
958 "{} 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: {}{}",
959 c.checked,
960 c.unreadable_unchecked.len(),
961 shown.join(", "),
962 if more > 0 {
963 format!(" and {more} more")
964 } else {
965 String::new()
966 }
967 ),
968 )
969 } else {
970 let mut detail = format!(
971 "{} file(s) compared with the master's live copies: {} differ, {} missing on the backup, {} still present after being deleted",
972 c.checked,
973 c.differing.len(),
974 c.missing.len(),
975 c.unexpected.len()
976 );
977 if !c.unreadable.is_empty() {
978 detail.push_str(&format!(
979 ", {} unit(s) unreadable on a node",
980 c.unreadable.len()
981 ));
982 }
983 (CheckStatus::Fail, detail)
984 };
985 BackupCheck {
986 id: BackupCheckId::Files,
987 status,
988 detail,
989 findings,
990 }
991}
992
993pub fn files_skipped_check() -> BackupCheck {
996 BackupCheck {
997 id: BackupCheckId::Files,
998 status: CheckStatus::Warn,
999 detail:
1000 "nodes.backup declares no ssh or local transport, so the file-hash check did not run"
1001 .to_string(),
1002 findings: Vec::new(),
1003 }
1004}
1005
1006#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
1010pub struct SecretRef {
1011 pub provider: String,
1013 pub name: String,
1015}
1016
1017#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1019pub enum SecretCheckMode {
1020 Preflight,
1023 Verify,
1025}
1026
1027#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1029pub struct SecretNodeReport {
1030 pub node: String,
1032 pub status: CheckStatus,
1035 pub providers_checked: usize,
1037 pub missing: Vec<SecretRef>,
1039 pub unverifiable: Vec<String>,
1041 #[serde(skip_serializing_if = "Vec::is_empty")]
1044 pub skipped_providers: Vec<String>,
1045}
1046
1047#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1049pub struct SecretNamesReport {
1050 pub nodes: Vec<SecretNodeReport>,
1052}
1053
1054impl SecretNamesReport {
1055 pub fn ordered(mut nodes: Vec<SecretNodeReport>) -> Self {
1057 nodes.sort_by_key(|n| match n.node.as_str() {
1058 "master" => 0u8,
1059 "backup" => 1,
1060 _ => 2,
1061 });
1062 SecretNamesReport { nodes }
1063 }
1064}
1065
1066pub fn collect_referenced_secrets(tree: &RenderedTree) -> BTreeSet<(String, String)> {
1069 let mut out = BTreeSet::new();
1070 for (path, bytes) in &tree.files {
1071 if !path.as_str().ends_with(".json") {
1072 continue;
1073 }
1074 if let Ok((_, parsed)) = node::classify(bytes) {
1075 referenced_secret_names(&parsed, &mut out);
1076 }
1077 }
1078 out
1079}
1080
1081pub fn declared_secret_providers(tree: &RenderedTree) -> BTreeSet<String> {
1084 let mut out = BTreeSet::new();
1085 for path in tree.files.keys() {
1086 let parts: Vec<&str> = path.components().collect();
1087 if parts.len() >= 4 && parts[0] == "ignition" && parts[1] == "secret-provider" {
1088 out.insert(parts[2].to_string());
1089 }
1090 }
1091 out
1092}
1093
1094pub fn secrets_defined_by_render(tree: &RenderedTree) -> BTreeSet<(String, String)> {
1105 let mut out = BTreeSet::new();
1106 for (path, bytes) in &tree.files {
1107 let parts: Vec<&str> = path.components().collect();
1108 let [first, second, provider, file] = parts.as_slice() else {
1109 continue;
1110 };
1111 if *first != "ignition" || *second != "secret-provider" || *file != "config.json" {
1112 continue;
1113 }
1114 let Ok(config) = serde_json::from_slice::<Value>(bytes) else {
1115 continue;
1116 };
1117 for member in ["files", "secrets"] {
1118 if let Some(entries) = config
1119 .get("settings")
1120 .and_then(|settings| settings.get(member))
1121 .and_then(Value::as_object)
1122 {
1123 out.extend(
1124 entries
1125 .keys()
1126 .map(|name| ((*provider).to_string(), name.clone())),
1127 );
1128 }
1129 }
1130 }
1131 out
1132}
1133
1134pub fn check_secret_names(
1145 node: &str,
1146 referenced: &BTreeSet<(String, String)>,
1147 listings: &BTreeMap<String, SecretNameListing>,
1148 declared: &BTreeSet<String>,
1149 mode: SecretCheckMode,
1150) -> SecretNodeReport {
1151 let mut by_provider: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
1152 for (provider, name) in referenced {
1153 by_provider
1154 .entry(provider.as_str())
1155 .or_default()
1156 .push(name.as_str());
1157 }
1158 let mut missing = Vec::new();
1159 let mut unverifiable = Vec::new();
1160 let mut skipped = Vec::new();
1161 for (provider, names) in &by_provider {
1162 let all_missing = |missing: &mut Vec<SecretRef>| {
1163 missing.extend(names.iter().map(|name| SecretRef {
1164 provider: (*provider).to_string(),
1165 name: (*name).to_string(),
1166 }));
1167 };
1168 match listings.get(*provider) {
1169 Some(SecretNameListing::Listed(listed)) => {
1170 missing.extend(
1171 names
1172 .iter()
1173 .filter(|name| !listed.contains(**name))
1174 .map(|name| SecretRef {
1175 provider: (*provider).to_string(),
1176 name: (*name).to_string(),
1177 }),
1178 );
1179 }
1180 Some(SecretNameListing::CannotList) => unverifiable.push((*provider).to_string()),
1181 Some(SecretNameListing::NoProvider) | None => {
1182 if mode == SecretCheckMode::Preflight && declared.contains(*provider) {
1183 skipped.push((*provider).to_string());
1184 } else {
1185 all_missing(&mut missing);
1186 }
1187 }
1188 }
1189 }
1190 let status = if !missing.is_empty() {
1191 CheckStatus::Fail
1192 } else if !unverifiable.is_empty() {
1193 CheckStatus::Warn
1194 } else {
1195 CheckStatus::Pass
1196 };
1197 SecretNodeReport {
1198 node: node.to_string(),
1199 status,
1200 providers_checked: by_provider.len(),
1201 missing,
1202 unverifiable,
1203 skipped_providers: skipped,
1204 }
1205}
1206
1207#[cfg(test)]
1208mod tests;