use std::collections::{BTreeMap, BTreeSet};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::Serialize;
use serde_json::Value;
use super::layout::RelPath;
use super::lint::referenced_secret_names;
use super::node;
use super::plan::{UnreadableKey, unit_key, unreadable_unit_keys};
use super::transport::{FileBlob, ObservedFiles, Scope, TreeReader};
use super::tree::RenderedTree;
use super::verify::{self, CheckStatus, Expected, Finding, Remote};
use crate::client::deploy_api::SecretNameListing;
use crate::client::logs::{DEFAULT_LOG_LIMIT, LogQuery};
use crate::client::mode::normalize_deployment_mode;
use crate::client::redundancy::RedundancyStatusWire;
use crate::client::{DeployApi, GatewayApi};
use crate::error::{APPLY_LIST_SHOWN, CoreError};
pub const BACKUP_POLL_S: u64 = 5;
pub const BACKUP_TIMEOUT_DEFAULT_S: u64 = 300;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BackupPolicy {
pub budget: Duration,
pub interval: Duration,
}
impl Default for BackupPolicy {
fn default() -> Self {
BackupPolicy {
budget: Duration::from_secs(BACKUP_TIMEOUT_DEFAULT_S),
interval: Duration::from_secs(BACKUP_POLL_S),
}
}
}
pub struct BackupNode<'a> {
gateway: &'a dyn GatewayApi,
deploy: &'a dyn DeployApi,
reader: Option<&'a dyn TreeReader>,
}
impl<'a> BackupNode<'a> {
pub fn new(
gateway: &'a dyn GatewayApi,
deploy: &'a dyn DeployApi,
reader: Option<&'a dyn TreeReader>,
) -> Self {
BackupNode {
gateway,
deploy,
reader,
}
}
pub fn has_files(&self) -> bool {
self.reader.is_some()
}
pub async fn status(&self) -> Result<RedundancyStatusWire, CoreError> {
self.gateway.redundancy_status().await
}
pub async fn deployment_mode(&self) -> Result<Option<String>, CoreError> {
Ok(normalize_deployment_mode(
self.deploy.deployment_mode().await?,
))
}
pub async fn read_back(
&self,
expected: &Expected,
) -> Result<BTreeMap<String, Remote>, CoreError> {
crate::actions::deploy::gather_read_back(self.deploy, expected).await
}
pub async fn read_files(&self, scope: &Scope) -> Option<Result<ObservedFiles, CoreError>> {
match self.reader {
Some(reader) => Some(reader.read(scope).await),
None => None,
}
}
pub async fn secret_names(&self, provider: &str) -> Result<SecretNameListing, CoreError> {
self.deploy.secret_names(provider).await
}
pub async fn sync_exhausted(&self, since_ms: i64) -> Result<bool, CoreError> {
let query = LogQuery {
start_time: Some(since_ms.max(0)),
min_level: Some("ERROR".to_string()),
limit: DEFAULT_LOG_LIMIT,
..LogQuery::default()
};
let envelope = self.gateway.logs(&query).await?;
Ok(envelope
.items
.iter()
.any(|entry| entry.timestamp >= since_ms && is_sync_exhausted(&entry.message)))
}
}
pub fn wall_clock_ms() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.and_then(|elapsed| i64::try_from(elapsed.as_millis()).ok())
.unwrap_or(0)
}
const SYNC_EXHAUSTED_TEXT: &str = "exceeds the max attempt count";
pub fn is_sync_exhausted(message: &str) -> bool {
message.contains(SYNC_EXHAUSTED_TEXT)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackupSignals {
pub peer_connected: bool,
pub sync_pending: bool,
pub last_sync_before: Option<i64>,
pub last_sync_now: Option<i64>,
pub project_state: Option<String>,
}
impl BackupSignals {
pub fn from_status(before: Option<i64>, now: &RedundancyStatusWire) -> Self {
BackupSignals {
peer_connected: now.peer_connected,
sync_pending: now.sync_pending,
last_sync_before: before.filter(|value| *value >= 0),
last_sync_now: now.last_sync_epoch_ms(),
project_state: now.project_state.as_deref().and_then(status_word),
}
}
pub fn advanced(&self) -> bool {
match (self.last_sync_before, self.last_sync_now) {
(_, None) => false,
(None, Some(_)) => true,
(Some(before), Some(now)) => now > before,
}
}
pub fn linked(&self) -> bool {
self.peer_connected && !self.sync_pending
}
}
fn status_word(text: &str) -> Option<String> {
let plain = !text.is_empty()
&& text.chars().count() <= 32
&& text
.chars()
.all(|c| c.is_alphanumeric() || matches!(c, ' ' | '-' | '_'));
plain.then(|| text.to_string())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum UnsyncedReason {
Unreachable,
NotSyncing,
ContentDiffers,
SyncExhausted,
}
impl UnsyncedReason {
pub fn as_str(self) -> &'static str {
match self {
UnsyncedReason::Unreachable => "unreachable",
UnsyncedReason::NotSyncing => "not_syncing",
UnsyncedReason::ContentDiffers => "content_differs",
UnsyncedReason::SyncExhausted => "sync_exhausted",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum BackupCheckId {
Redundancy,
Mode,
ReadBack,
Files,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct BackupCheck {
pub id: BackupCheckId,
pub status: CheckStatus,
pub detail: String,
pub findings: Vec<Finding>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ResyncRecord {
pub ok: bool,
pub code: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct BackupReport {
pub converged: bool,
pub reason: Option<UnsyncedReason>,
pub budget_s: u64,
pub waited_s: u64,
pub resync: Option<ResyncRecord>,
pub last_sync_before: Option<i64>,
pub last_sync_after: Option<i64>,
pub project_state: Option<String>,
pub files_checked: usize,
pub checks: Vec<BackupCheck>,
}
fn finding(code: &'static str, subject: &str, detail: &str) -> Finding {
Finding {
code,
subject: subject.to_string(),
detail: detail.to_string(),
status: None,
}
}
pub fn redundancy_check(
signals: &Result<BackupSignals, String>,
require_advance: bool,
) -> BackupCheck {
let signals = match signals {
Ok(signals) => signals,
Err(code) => {
let detail = format!("unreachable: {code}");
return BackupCheck {
id: BackupCheckId::Redundancy,
status: CheckStatus::Fail,
findings: vec![finding("backup_unreachable", "redundancy", &detail)],
detail,
};
}
};
let mut findings = Vec::new();
if !signals.peer_connected {
findings.push(finding(
"peer_not_connected",
"peerConnected",
"peerConnected is false",
));
}
if signals.sync_pending {
findings.push(finding(
"sync_pending",
"syncPending",
"syncPending is true",
));
}
if require_advance && !signals.advanced() {
findings.push(finding(
"sync_not_advanced",
"lastSyncTimestamp",
"lastSyncTimestamp has not advanced past the pre-apply sample",
));
}
if findings.is_empty() {
let detail = if require_advance {
"peerConnected, no sync pending, lastSyncTimestamp advanced"
} else {
"peerConnected, no sync pending"
};
return BackupCheck {
id: BackupCheckId::Redundancy,
status: CheckStatus::Pass,
detail: detail.to_string(),
findings,
};
}
let names: Vec<&str> = findings.iter().map(|f| f.subject.as_str()).collect();
BackupCheck {
id: BackupCheckId::Redundancy,
status: CheckStatus::Fail,
detail: format!("the backup is not settled: {}", names.join(", ")),
findings,
}
}
pub fn unreachable_check(id: BackupCheckId, code: &str) -> BackupCheck {
let detail = format!("unreachable: {code}");
BackupCheck {
id,
status: CheckStatus::Fail,
findings: vec![finding("backup_unreachable", id.word(), &detail)],
detail,
}
}
impl BackupCheckId {
pub fn word(self) -> &'static str {
match self {
BackupCheckId::Redundancy => "redundancy",
BackupCheckId::Mode => "mode",
BackupCheckId::ReadBack => "read_back",
BackupCheckId::Files => "files",
}
}
}
fn backup_check_from(id: BackupCheckId, check: verify::Check) -> BackupCheck {
BackupCheck {
id,
status: check.status,
detail: check.detail,
findings: check.findings,
}
}
pub fn mode_backup_check(expected: &Expected, mode: Option<&str>) -> BackupCheck {
backup_check_from(BackupCheckId::Mode, verify::mode_check(expected, mode))
}
pub fn read_back_backup_check(
expected: &Expected,
read_back: &BTreeMap<String, Remote>,
warnings: &mut Vec<String>,
) -> (BackupCheck, usize) {
let (check, compared) = verify::read_back_check(expected, read_back, warnings);
(backup_check_from(BackupCheckId::ReadBack, check), compared)
}
pub fn read_back_backup_check_with(
expected: &Expected,
read_back: &BTreeMap<String, Remote>,
judge: &verify::ReadBackJudge<'_>,
warnings: &mut Vec<String>,
) -> (BackupCheck, usize) {
let (check, compared) = verify::read_back_check_with(expected, read_back, judge, warnings);
(backup_check_from(BackupCheckId::ReadBack, check), compared)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SyncPredicate {
TimestampAdvanced,
StableTicks(u8),
}
pub const SYNC_PREDICATE: SyncPredicate = SyncPredicate::TimestampAdvanced;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TickOutcome {
pub signals: Result<BackupSignals, String>,
pub exhausted: bool,
pub content: Option<Vec<BackupCheck>>,
pub files_checked: usize,
pub stable_ticks: u8,
}
pub fn converged(t: &TickOutcome, predicate: SyncPredicate) -> bool {
let Ok(signals) = &t.signals else {
return false;
};
if !signals.linked() || !sync_clause_met(t, signals, predicate) {
return false;
}
match &t.content {
Some(checks) => checks.iter().all(|c| c.status != CheckStatus::Fail),
None => false,
}
}
fn sync_clause_met(t: &TickOutcome, signals: &BackupSignals, predicate: SyncPredicate) -> bool {
match predicate {
SyncPredicate::TimestampAdvanced => signals.advanced(),
SyncPredicate::StableTicks(ticks) => t.stable_ticks >= ticks,
}
}
pub fn classify(t: &TickOutcome) -> Option<UnsyncedReason> {
if converged(t, SYNC_PREDICATE) {
return None;
}
let unanswered = t
.content
.iter()
.flatten()
.any(|c| c.status == CheckStatus::Fail && c.detail.starts_with(UNREACHABLE_PREFIX));
if t.signals.is_err() || unanswered {
return Some(UnsyncedReason::Unreachable);
}
if t.exhausted {
return Some(UnsyncedReason::SyncExhausted);
}
if let Ok(signals) = &t.signals
&& (!signals.linked() || !sync_clause_met(t, signals, SYNC_PREDICATE))
{
return Some(UnsyncedReason::NotSyncing);
}
Some(UnsyncedReason::ContentDiffers)
}
const UNREACHABLE_PREFIX: &str = "unreachable:";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PairWaitStep {
Converged,
Exhausted,
TimedOut,
Sleep(Duration),
}
pub fn wait_step(t: &TickOutcome, elapsed: Duration, policy: &BackupPolicy) -> PairWaitStep {
if converged(t, SYNC_PREDICATE) {
PairWaitStep::Converged
} else if t.exhausted {
PairWaitStep::Exhausted
} else if elapsed >= policy.budget {
PairWaitStep::TimedOut
} else {
PairWaitStep::Sleep(
policy
.interval
.max(Duration::from_millis(1))
.min(policy.budget - elapsed),
)
}
}
pub fn report(
t: &TickOutcome,
before: Option<i64>,
budget_s: u64,
waited_s: u64,
resync: Option<ResyncRecord>,
) -> BackupReport {
let require_advance = matches!(SYNC_PREDICATE, SyncPredicate::TimestampAdvanced);
let mut checks = vec![redundancy_check(&t.signals, require_advance)];
checks.extend(t.content.iter().flatten().cloned());
let (sampled, after, project_state) = match &t.signals {
Ok(s) => (s.last_sync_before, s.last_sync_now, s.project_state.clone()),
Err(_) => (None, None, None),
};
BackupReport {
converged: converged(t, SYNC_PREDICATE),
reason: classify(t),
budget_s,
waited_s,
resync,
last_sync_before: before.filter(|value| *value >= 0).or(sampled),
last_sync_after: after,
project_state,
files_checked: t.files_checked,
checks,
}
}
pub fn verify_report(t: &TickOutcome) -> BackupReport {
let mut checks = vec![redundancy_check(&t.signals, false)];
checks.extend(t.content.iter().flatten().cloned());
let converged = t.content.is_some() && checks.iter().all(|c| c.status != CheckStatus::Fail);
let reason = if converged {
None
} else {
let unanswered = checks
.iter()
.any(|c| c.status == CheckStatus::Fail && c.detail.starts_with(UNREACHABLE_PREFIX));
Some(if unanswered {
UnsyncedReason::Unreachable
} else if !t.signals.as_ref().is_ok_and(BackupSignals::linked) {
UnsyncedReason::NotSyncing
} else {
UnsyncedReason::ContentDiffers
})
};
let (after, project_state) = match &t.signals {
Ok(s) => (s.last_sync_now, s.project_state.clone()),
Err(_) => (None, None),
};
BackupReport {
converged,
reason,
budget_s: 0,
waited_s: 0,
resync: None,
last_sync_before: None,
last_sync_after: after,
project_state,
files_checked: t.files_checked,
checks,
}
}
pub fn effective_files(observed: ObservedFiles) -> BTreeMap<RelPath, FileBlob> {
let mut files = observed.core;
files.extend(observed.mode);
files
}
pub type UnreadableUnits = BTreeMap<String, UnreadableKey>;
pub fn unreadable_units(observed: &ObservedFiles) -> UnreadableUnits {
unreadable_unit_keys(observed)
}
pub const FILE_NORMALIZATION: &[(&str, &[&str])] = &[(
"resource.json",
&[
"attributes.lastModification",
"attributes.lastModificationSignature",
],
)];
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FileComparison {
pub checked: usize,
pub differing: Vec<String>,
pub missing: Vec<String>,
pub unexpected: Vec<String>,
pub unreadable: Vec<(String, String)>,
pub unreadable_unchecked: Vec<(String, String)>,
}
pub fn compare_files(
master: &BTreeMap<RelPath, FileBlob>,
backup: &BTreeMap<RelPath, FileBlob>,
present: &BTreeSet<RelPath>,
absent: &BTreeSet<RelPath>,
) -> FileComparison {
let mut out = FileComparison {
checked: present.len(),
..FileComparison::default()
};
for path in present {
let Some(theirs) = backup.get(path) else {
out.missing.push(path.as_str().to_string());
continue;
};
let equal = match master.get(path) {
Some(ours) => ours.sha256 == theirs.sha256 || same_document(path, ours, theirs),
None => false,
};
if !equal {
out.differing.push(path.as_str().to_string());
}
}
for path in absent {
if backup.contains_key(path) {
out.unexpected.push(path.as_str().to_string());
}
}
out
}
pub fn exempt_unreadable(
c: &mut FileComparison,
present: &BTreeSet<RelPath>,
absent: &BTreeSet<RelPath>,
nodes: &[(&str, &UnreadableUnits)],
) {
if nodes.iter().all(|(_, units)| units.is_empty()) {
return;
}
let layer: BTreeMap<RelPath, ()> = present
.iter()
.chain(absent.iter())
.map(|p| (p.clone(), ()))
.collect();
let keyed: Vec<(&RelPath, String)> = layer.keys().map(|p| (p, unit_key(p, &layer))).collect();
let mut hit: BTreeSet<String> = BTreeSet::new();
let mut overlapping: BTreeSet<(String, String)> = BTreeSet::new();
let mut unchecked: BTreeSet<(String, String)> = BTreeSet::new();
for (node, units) in nodes {
for (key, info) in units.iter() {
let covered: Vec<&RelPath> = keyed
.iter()
.filter(|(p, k)| k == key || info.paths.iter().any(|u| u == p.as_str()))
.map(|(p, _)| *p)
.collect();
let entry = ((*node).to_string(), key.clone());
if covered.is_empty() {
unchecked.insert(entry);
} else {
hit.extend(covered.iter().map(|p| p.as_str().to_string()));
overlapping.insert(entry);
}
}
}
for list in [&mut c.differing, &mut c.missing, &mut c.unexpected] {
list.retain(|p| !hit.contains(p));
}
c.unreadable = overlapping.into_iter().collect();
c.unreadable_unchecked = unchecked.into_iter().collect();
}
pub fn unreadable_warnings(c: &FileComparison) -> Vec<String> {
c.unreadable_unchecked
.iter()
.map(|(node, key)| {
format!(
"{node}: {key} is unreadable on the host and is neither rendered nor deleted; it was not compared"
)
})
.collect()
}
fn same_document(path: &RelPath, ours: &FileBlob, theirs: &FileBlob) -> bool {
let Some(name) = path.components().next_back() else {
return false;
};
let Some((_, ignored)) = FILE_NORMALIZATION.iter().find(|(file, _)| *file == name) else {
return false;
};
let (Ok(mut a), Ok(mut b)) = (
serde_json::from_slice::<Value>(&ours.bytes),
serde_json::from_slice::<Value>(&theirs.bytes),
) else {
return false;
};
for doc in [&mut a, &mut b] {
for dotted in *ignored {
remove_member(doc, dotted);
}
}
a == b
}
fn remove_member(doc: &mut Value, path: &str) {
let mut node = doc;
let mut steps = path.split('.').peekable();
while let Some(step) = steps.next() {
let Value::Object(map) = node else {
return;
};
if steps.peek().is_none() {
map.remove(step);
return;
}
let Some(next) = map.get_mut(step) else {
return;
};
node = next;
}
}
pub fn files_check(c: &FileComparison) -> BackupCheck {
let kinds: [(&str, &str, &Vec<String>); 3] = [
(
"file_differs",
"differs from the master's live copy",
&c.differing,
),
("file_missing_on_backup", "is not on the backup", &c.missing),
(
"file_unexpected",
"was deleted by the apply but is still on the backup",
&c.unexpected,
),
];
let unreadable = c.unreadable.iter().map(|(node, key)| Finding {
code: "unreadable",
subject: key.clone(),
detail: format!(
"has a live path the {node}'s sftp server cannot read; its copy is unknown, so it is neither equal nor absent"
),
status: None,
});
let findings: Vec<Finding> = unreadable
.chain(kinds.iter().flat_map(|(code, what, paths)| {
paths.iter().map(move |path| Finding {
code,
subject: path.clone(),
detail: (*what).to_string(),
status: None,
})
}))
.take(APPLY_LIST_SHOWN)
.collect();
let bad = c.differing.len() + c.missing.len() + c.unexpected.len() + c.unreadable.len();
let (status, detail) = if bad == 0 && c.unreadable_unchecked.is_empty() {
(
CheckStatus::Pass,
format!("{} file(s) equal the master's live copies", c.checked),
)
} else if bad == 0 {
let shown: Vec<String> = c
.unreadable_unchecked
.iter()
.take(APPLY_LIST_SHOWN)
.map(|(node, key)| format!("{key} ({node})"))
.collect();
let more = c.unreadable_unchecked.len() - shown.len();
(
CheckStatus::Warn,
format!(
"{} 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: {}{}",
c.checked,
c.unreadable_unchecked.len(),
shown.join(", "),
if more > 0 {
format!(" and {more} more")
} else {
String::new()
}
),
)
} else {
let mut detail = format!(
"{} file(s) compared with the master's live copies: {} differ, {} missing on the backup, {} still present after being deleted",
c.checked,
c.differing.len(),
c.missing.len(),
c.unexpected.len()
);
if !c.unreadable.is_empty() {
detail.push_str(&format!(
", {} unit(s) unreadable on a node",
c.unreadable.len()
));
}
(CheckStatus::Fail, detail)
};
BackupCheck {
id: BackupCheckId::Files,
status,
detail,
findings,
}
}
pub fn files_skipped_check() -> BackupCheck {
BackupCheck {
id: BackupCheckId::Files,
status: CheckStatus::Warn,
detail:
"nodes.backup declares no ssh or local transport, so the file-hash check did not run"
.to_string(),
findings: Vec::new(),
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub struct SecretRef {
pub provider: String,
pub name: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SecretCheckMode {
Preflight,
Verify,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SecretNodeReport {
pub node: String,
pub status: CheckStatus,
pub providers_checked: usize,
pub missing: Vec<SecretRef>,
pub unverifiable: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub skipped_providers: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SecretNamesReport {
pub nodes: Vec<SecretNodeReport>,
}
impl SecretNamesReport {
pub fn ordered(mut nodes: Vec<SecretNodeReport>) -> Self {
nodes.sort_by_key(|n| match n.node.as_str() {
"master" => 0u8,
"backup" => 1,
_ => 2,
});
SecretNamesReport { nodes }
}
}
pub fn collect_referenced_secrets(tree: &RenderedTree) -> BTreeSet<(String, String)> {
let mut out = BTreeSet::new();
for (path, bytes) in &tree.files {
if !path.as_str().ends_with(".json") {
continue;
}
if let Ok((_, parsed)) = node::classify(bytes) {
referenced_secret_names(&parsed, &mut out);
}
}
out
}
pub fn declared_secret_providers(tree: &RenderedTree) -> BTreeSet<String> {
let mut out = BTreeSet::new();
for path in tree.files.keys() {
let parts: Vec<&str> = path.components().collect();
if parts.len() >= 4 && parts[0] == "ignition" && parts[1] == "secret-provider" {
out.insert(parts[2].to_string());
}
}
out
}
pub fn secrets_defined_by_render(tree: &RenderedTree) -> BTreeSet<(String, String)> {
let mut out = BTreeSet::new();
for (path, bytes) in &tree.files {
let parts: Vec<&str> = path.components().collect();
let [first, second, provider, file] = parts.as_slice() else {
continue;
};
if *first != "ignition" || *second != "secret-provider" || *file != "config.json" {
continue;
}
let Ok(config) = serde_json::from_slice::<Value>(bytes) else {
continue;
};
for member in ["files", "secrets"] {
if let Some(entries) = config
.get("settings")
.and_then(|settings| settings.get(member))
.and_then(Value::as_object)
{
out.extend(
entries
.keys()
.map(|name| ((*provider).to_string(), name.clone())),
);
}
}
}
out
}
pub fn check_secret_names(
node: &str,
referenced: &BTreeSet<(String, String)>,
listings: &BTreeMap<String, SecretNameListing>,
declared: &BTreeSet<String>,
mode: SecretCheckMode,
) -> SecretNodeReport {
let mut by_provider: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for (provider, name) in referenced {
by_provider
.entry(provider.as_str())
.or_default()
.push(name.as_str());
}
let mut missing = Vec::new();
let mut unverifiable = Vec::new();
let mut skipped = Vec::new();
for (provider, names) in &by_provider {
let all_missing = |missing: &mut Vec<SecretRef>| {
missing.extend(names.iter().map(|name| SecretRef {
provider: (*provider).to_string(),
name: (*name).to_string(),
}));
};
match listings.get(*provider) {
Some(SecretNameListing::Listed(listed)) => {
missing.extend(
names
.iter()
.filter(|name| !listed.contains(**name))
.map(|name| SecretRef {
provider: (*provider).to_string(),
name: (*name).to_string(),
}),
);
}
Some(SecretNameListing::CannotList) => unverifiable.push((*provider).to_string()),
Some(SecretNameListing::NoProvider) | None => {
if mode == SecretCheckMode::Preflight && declared.contains(*provider) {
skipped.push((*provider).to_string());
} else {
all_missing(&mut missing);
}
}
}
}
let status = if !missing.is_empty() {
CheckStatus::Fail
} else if !unverifiable.is_empty() {
CheckStatus::Warn
} else {
CheckStatus::Pass
};
SecretNodeReport {
node: node.to_string(),
status,
providers_checked: by_provider.len(),
missing,
unverifiable,
skipped_providers: skipped,
}
}
#[cfg(test)]
mod tests;