Skip to main content

ignition_core/deploy/
pair.rs

1//! The backup node of a redundant pair, read-only (Phase 24, DPAIR-01 to
2//! DPAIR-03).
3//!
4//! A redundant pair has one master and one backup. The master takes every
5//! write; the backup receives the same configuration through the gateway's
6//! own sync, and this module is how the rest of the deploy code looks at it.
7//!
8//! ## The backup is read, never written (D-01)
9//!
10//! [`BackupNode`] is the only way to reach the backup. Its fields are private
11//! and its methods are reads: [`BackupNode::status`],
12//! [`BackupNode::deployment_mode`], [`BackupNode::read_back`],
13//! [`BackupNode::read_files`], [`BackupNode::secret_names`] and
14//! [`BackupNode::sync_exhausted`]. It holds the backup's read half of a
15//! transport (or nothing for a REST-only backup), never a type that can put a
16//! file on the backup, so a caller cannot express a write to it. The test
17//! `pair_module_never_writes` scans this file for every write the gateway or
18//! the transport offers, and `tests/deploy_pair.rs` runs the handle over a
19//! deploy surface that panics on a write.
20//!
21//! ## What the decisions implement
22//!
23//! - D-03: the backup's files are compared with the master's live files by
24//!   hash ([`compare_files`]). A unit either node's sftp server cannot read
25//!   (quick-261029-ms3) is never equal and never absent: when it is rendered
26//!   or deleted it fails the check, otherwise it is a warning
27//!   ([`exempt_unreadable`]).
28//! - D-06: the backup has converged when its own sync timestamp advanced past
29//!   its own pre-apply sample, it is linked to its peer, and its mode, REST
30//!   read-back and files match ([`converged`]).
31//! - D-09: the report shape ([`BackupReport`]) and the reason a backup did
32//!   not converge ([`classify`]).
33//! - D-10: the gateway's sync-exhausted state ends the wait ([`wait_step`],
34//!   [`is_sync_exhausted`]).
35//! - D-13: the secret names a render references exist on each node
36//!   ([`check_secret_names`]).
37//!
38//! Every decision is a pure function over value-free inputs: no clock, no
39//! I/O, no gateway text. A status, a log line or a sync error never enters a
40//! report; only codes, counts, paths and names do.
41
42use 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
62/// Seconds between two reads of the backup while waiting for it to converge
63/// (V5).
64pub const BACKUP_POLL_S: u64 = 5;
65
66/// Default `--backup-timeout`, in seconds (V5).
67pub const BACKUP_TIMEOUT_DEFAULT_S: u64 = 300;
68
69/// How long to wait for the backup, and how often to look.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub struct BackupPolicy {
72    /// The most the wait may take.
73    pub budget: Duration,
74    /// Time between two reads.
75    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
87/// The read-only handle on a pair's backup node.
88///
89/// Built from the backup's own gateway surface, its own deploy surface and,
90/// when the target gives the backup a file transport, the read half of that
91/// transport. See the module doc for why no method here writes.
92pub 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    /// A handle over the backup's surfaces. `reader` is `None` for a backup
100    /// that declares no file transport (REST only).
101    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    /// Whether the backup's files can be read (it declares a transport).
114    pub fn has_files(&self) -> bool {
115        self.reader.is_some()
116    }
117
118    /// The backup's own redundancy status.
119    pub async fn status(&self) -> Result<RedundancyStatusWire, CoreError> {
120        self.gateway.redundancy_status().await
121    }
122
123    /// The backup's active deployment mode, normalized (`None` is no mode).
124    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    /// Each declared, comparable resource read from the backup's REST
131    /// effective view (the same read the master's verify makes).
132    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    /// The backup's live files within `scope`; `None` when the backup
140    /// declares no file transport (a REST-only backup).
141    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    /// The names a secret provider lists on the backup. Names only: the
149    /// listing carries no value.
150    pub async fn secret_names(&self, provider: &str) -> Result<SecretNameListing, CoreError> {
151        self.deploy.secret_names(provider).await
152    }
153
154    /// Whether the backup's log says the gateway gave up syncing at or after
155    /// `since_ms` (D-10, K5 d, WR-01). The lines are matched in memory and
156    /// dropped; only the answer leaves.
157    ///
158    /// `since_ms` is the epoch-ms the engine took (see [`wall_clock_ms`]) just
159    /// before this run's commit, so a line from a previous deploy or a past
160    /// recovery never ends this run's wait. The bound is applied twice: as the
161    /// log query's `startTime` and again on each entry's own timestamp, which
162    /// is the gateway's clock.
163    ///
164    /// Clock assumption: `since_ms` comes from the CLI host's clock and the
165    /// entries from the gateway's, so they must agree to within the age of the
166    /// stale line (seconds to minutes for a real previous run). A gateway
167    /// clock that runs behind the host by more than the time the sync takes
168    /// to give up can hide a fresh line; that only costs the early exit, and
169    /// the wait ends on its budget with the one resync as before.
170    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
185/// The host's wall clock as epoch milliseconds: the anchor the engine hands to
186/// [`BackupNode::sync_exhausted`]. `0` when the clock is before 1970.
187pub 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
195/// The text the gateway logs when it stops retrying a sync (TEC-5105): `State
196/// sync request failed. Sync operation has failed 5 time(s), which exceeds
197/// the max attempt count. No further synchronization attempts will be made.`
198/// The state was not reachable on the pair rig (24-LIVE-CAPTURES `## C-P8`),
199/// so this matcher is tested against the spike's recorded line only.
200const SYNC_EXHAUSTED_TEXT: &str = "exceeds the max attempt count";
201
202/// Whether one log message is the sync-exhausted line. A bool, never text.
203pub fn is_sync_exhausted(message: &str) -> bool {
204    message.contains(SYNC_EXHAUSTED_TEXT)
205}
206
207/// What the backup's redundancy status says, against its own earlier sample.
208#[derive(Debug, Clone, PartialEq, Eq)]
209pub struct BackupSignals {
210    /// The backup sees its peer.
211    pub peer_connected: bool,
212    /// A sync is queued and has not run.
213    pub sync_pending: bool,
214    /// The backup's own `lastSyncTimestamp` before the apply; `None` is
215    /// never synced (`-1`) or not sampled.
216    pub last_sync_before: Option<i64>,
217    /// The backup's `lastSyncTimestamp` now; `None` is never synced (`-1`).
218    pub last_sync_now: Option<i64>,
219    /// The backup's `projectState`, recorded, not a condition (V9).
220    pub project_state: Option<String>,
221}
222
223impl BackupSignals {
224    /// Signals from the backup's status now and its timestamp before. Both
225    /// samples go through [`RedundancyStatusWire::last_sync_epoch_ms`]'s
226    /// rule, so `-1` reads as never synced on either side (Pitfall 5). The
227    /// timestamps are the same node's own clock: they are never compared with
228    /// the master's.
229    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    /// The backup's timestamp is a real one and is later than its sample
240    /// (or it had never synced before).
241    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    /// The backup sees its peer and has no sync queued.
250    pub fn linked(&self) -> bool {
251        self.peer_connected && !self.sync_pending
252    }
253}
254
255/// `text` when it is a short status word (`Good`, `Out of Date`), else
256/// `None`: the gateway's own text never enters a report beyond that.
257fn 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/// Why a backup did not converge (V2). Precedence, first that holds:
267/// `unreachable`, `sync_exhausted`, `not_syncing`, `content_differs`.
268#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
269#[serde(rename_all = "snake_case")]
270pub enum UnsyncedReason {
271    /// The backup's status or a content read could not be answered.
272    Unreachable,
273    /// The backup is not linked or its sync has not run.
274    NotSyncing,
275    /// The backup syncs but its content differs from the master's.
276    ContentDiffers,
277    /// The gateway gave up syncing (TEC-5105).
278    SyncExhausted,
279}
280
281impl UnsyncedReason {
282    /// The word the reason serializes as.
283    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/// The four checks of a backup report, in report order (V2).
294#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
295#[serde(rename_all = "snake_case")]
296pub enum BackupCheckId {
297    /// The backup is linked to its peer and its sync advanced.
298    Redundancy,
299    /// The backup runs the target's mode.
300    Mode,
301    /// Declared resources read back equal through the backup's REST.
302    ReadBack,
303    /// The backup's files equal the master's live files.
304    Files,
305}
306
307/// One check of a backup report.
308#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
309pub struct BackupCheck {
310    /// Which check.
311    pub id: BackupCheckId,
312    /// Its outcome.
313    pub status: CheckStatus,
314    /// One line of what was evaluated.
315    pub detail: String,
316    /// Value-free observations.
317    pub findings: Vec<Finding>,
318}
319
320/// The one resync request an apply may send (V2): the status of the POST,
321/// never proof that the backup caught up.
322#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
323pub struct ResyncRecord {
324    /// The POST was accepted.
325    pub ok: bool,
326    /// The error code when it was not.
327    pub code: Option<String>,
328}
329
330/// `data.backup` of an apply and of `deploy verify` (V2, D-09). It has no
331/// field for gateway text.
332#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
333pub struct BackupReport {
334    /// The backup holds what the master holds.
335    pub converged: bool,
336    /// Why not, when it does not.
337    pub reason: Option<UnsyncedReason>,
338    /// The wait budget in seconds (0 for a verify).
339    pub budget_s: u64,
340    /// Seconds waited.
341    pub waited_s: u64,
342    /// The resync sent, if any.
343    pub resync: Option<ResyncRecord>,
344    /// The backup's timestamp before the apply, `None` when never synced.
345    pub last_sync_before: Option<i64>,
346    /// The backup's timestamp at the last read.
347    pub last_sync_after: Option<i64>,
348    /// The backup's `projectState` at the last read.
349    pub project_state: Option<String>,
350    /// Files compared with the master's.
351    pub files_checked: usize,
352    /// The checks, in [`BackupCheckId`] order.
353    pub checks: Vec<BackupCheck>,
354}
355
356fn finding(code: &'static str, subject: &str, detail: &str) -> Finding {
357    Finding {
358        code,
359        subject: subject.to_string(),
360        detail: detail.to_string(),
361    }
362}
363
364/// The `redundancy` check: the backup is linked to its peer, has no sync
365/// queued and, when `require_advance`, its own timestamp advanced past its
366/// own earlier sample. An `Err(code)` is a status that could not be read.
367pub fn redundancy_check(
368    signals: &Result<BackupSignals, String>,
369    require_advance: bool,
370) -> BackupCheck {
371    let signals = match signals {
372        Ok(signals) => signals,
373        Err(code) => {
374            let detail = format!("unreachable: {code}");
375            return BackupCheck {
376                id: BackupCheckId::Redundancy,
377                status: CheckStatus::Fail,
378                findings: vec![finding("backup_unreachable", "redundancy", &detail)],
379                detail,
380            };
381        }
382    };
383    let mut findings = Vec::new();
384    if !signals.peer_connected {
385        findings.push(finding(
386            "peer_not_connected",
387            "peerConnected",
388            "peerConnected is false",
389        ));
390    }
391    if signals.sync_pending {
392        findings.push(finding(
393            "sync_pending",
394            "syncPending",
395            "syncPending is true",
396        ));
397    }
398    if require_advance && !signals.advanced() {
399        findings.push(finding(
400            "sync_not_advanced",
401            "lastSyncTimestamp",
402            "lastSyncTimestamp has not advanced past the pre-apply sample",
403        ));
404    }
405    if findings.is_empty() {
406        let detail = if require_advance {
407            "peerConnected, no sync pending, lastSyncTimestamp advanced"
408        } else {
409            "peerConnected, no sync pending"
410        };
411        return BackupCheck {
412            id: BackupCheckId::Redundancy,
413            status: CheckStatus::Pass,
414            detail: detail.to_string(),
415            findings,
416        };
417    }
418    let names: Vec<&str> = findings.iter().map(|f| f.subject.as_str()).collect();
419    BackupCheck {
420        id: BackupCheckId::Redundancy,
421        status: CheckStatus::Fail,
422        detail: format!("the backup is not settled: {}", names.join(", ")),
423        findings,
424    }
425}
426
427/// A check for a read the backup could not answer. The detail carries the
428/// error code only, never the gateway's words.
429pub fn unreachable_check(id: BackupCheckId, code: &str) -> BackupCheck {
430    let detail = format!("unreachable: {code}");
431    BackupCheck {
432        id,
433        status: CheckStatus::Fail,
434        findings: vec![finding("backup_unreachable", id.word(), &detail)],
435        detail,
436    }
437}
438
439impl BackupCheckId {
440    /// The word the id serializes as.
441    pub fn word(self) -> &'static str {
442        match self {
443            BackupCheckId::Redundancy => "redundancy",
444            BackupCheckId::Mode => "mode",
445            BackupCheckId::ReadBack => "read_back",
446            BackupCheckId::Files => "files",
447        }
448    }
449}
450
451fn backup_check_from(id: BackupCheckId, check: verify::Check) -> BackupCheck {
452    BackupCheck {
453        id,
454        status: check.status,
455        detail: check.detail,
456        findings: check.findings,
457    }
458}
459
460/// The `mode` check of the backup: the master's `mode` evaluation over the
461/// backup's normalized mode.
462pub fn mode_backup_check(expected: &Expected, mode: Option<&str>) -> BackupCheck {
463    backup_check_from(BackupCheckId::Mode, verify::mode_check(expected, mode))
464}
465
466/// The `read_back` check of the backup and the number of resources compared:
467/// the master's `read_back` evaluation over the backup's REST view.
468pub fn read_back_backup_check(
469    expected: &Expected,
470    read_back: &BTreeMap<String, Remote>,
471    warnings: &mut Vec<String>,
472) -> (BackupCheck, usize) {
473    let (check, compared) = verify::read_back_check(expected, read_back, warnings);
474    (backup_check_from(BackupCheckId::ReadBack, check), compared)
475}
476
477// ---- convergence ---------------------------------------------------------------
478
479/// Which signal says the backup has synced (K1).
480#[derive(Debug, Clone, Copy, PartialEq, Eq)]
481pub enum SyncPredicate {
482    /// The backup's own `lastSyncTimestamp` advanced past its own pre-apply
483    /// sample (D-06 as written).
484    TimestampAdvanced,
485    /// The backup was linked and its content equal on this many ticks in a
486    /// row.
487    StableTicks(u8),
488}
489
490/// The predicate in force: K1 a, D-06 as written (24-02).
491pub const SYNC_PREDICATE: SyncPredicate = SyncPredicate::TimestampAdvanced;
492
493/// What one look at the backup found.
494#[derive(Debug, Clone, PartialEq, Eq)]
495pub struct TickOutcome {
496    /// The status signals, or the error code of a status that could not be
497    /// read.
498    pub signals: Result<BackupSignals, String>,
499    /// The log says the gateway gave up syncing (D-10).
500    pub exhausted: bool,
501    /// The mode, read-back and files checks; `None` when not evaluated yet.
502    pub content: Option<Vec<BackupCheck>>,
503    /// Files compared with the master's.
504    pub files_checked: usize,
505    /// Consecutive ticks on which the backup was linked and its content
506    /// equal (only [`SyncPredicate::StableTicks`] reads it).
507    pub stable_ticks: u8,
508}
509
510/// Whether the backup has converged (D-06): it is linked, the sync predicate
511/// holds and no content check failed. A 200 or a timestamp alone never
512/// converges (Pitfall 9). A `warn` (the file check a REST-only backup skips)
513/// does not block.
514pub fn converged(t: &TickOutcome, predicate: SyncPredicate) -> bool {
515    let Ok(signals) = &t.signals else {
516        return false;
517    };
518    if !signals.linked() || !sync_clause_met(t, signals, predicate) {
519        return false;
520    }
521    match &t.content {
522        Some(checks) => checks.iter().all(|c| c.status != CheckStatus::Fail),
523        None => false,
524    }
525}
526
527fn sync_clause_met(t: &TickOutcome, signals: &BackupSignals, predicate: SyncPredicate) -> bool {
528    match predicate {
529        SyncPredicate::TimestampAdvanced => signals.advanced(),
530        SyncPredicate::StableTicks(ticks) => t.stable_ticks >= ticks,
531    }
532}
533
534/// Why the backup is not converged, or `None` when it is (V2). First that
535/// holds: `unreachable` (the status or a content read could not be answered),
536/// `sync_exhausted`, `not_syncing` (not linked, still pending, or the sync
537/// clause unmet), `content_differs`.
538pub fn classify(t: &TickOutcome) -> Option<UnsyncedReason> {
539    if converged(t, SYNC_PREDICATE) {
540        return None;
541    }
542    let unanswered = t
543        .content
544        .iter()
545        .flatten()
546        .any(|c| c.status == CheckStatus::Fail && c.detail.starts_with(UNREACHABLE_PREFIX));
547    if t.signals.is_err() || unanswered {
548        return Some(UnsyncedReason::Unreachable);
549    }
550    if t.exhausted {
551        return Some(UnsyncedReason::SyncExhausted);
552    }
553    if let Ok(signals) = &t.signals
554        && (!signals.linked() || !sync_clause_met(t, signals, SYNC_PREDICATE))
555    {
556        return Some(UnsyncedReason::NotSyncing);
557    }
558    Some(UnsyncedReason::ContentDiffers)
559}
560
561/// The start of a check detail for a read that could not be answered.
562const UNREACHABLE_PREFIX: &str = "unreachable:";
563
564/// What the wait loop does next.
565#[derive(Debug, Clone, Copy, PartialEq, Eq)]
566pub enum PairWaitStep {
567    /// The backup has converged.
568    Converged,
569    /// The gateway gave up syncing: end the wait now (D-10).
570    Exhausted,
571    /// The budget is spent.
572    TimedOut,
573    /// Look again after this long.
574    Sleep(Duration),
575}
576
577/// The decision of the wait loop, with the elapsed time injected. A budget
578/// equal to the elapsed time has passed, and the last sleep never runs past
579/// the budget.
580pub fn wait_step(t: &TickOutcome, elapsed: Duration, policy: &BackupPolicy) -> PairWaitStep {
581    if converged(t, SYNC_PREDICATE) {
582        PairWaitStep::Converged
583    } else if t.exhausted {
584        PairWaitStep::Exhausted
585    } else if elapsed >= policy.budget {
586        PairWaitStep::TimedOut
587    } else {
588        PairWaitStep::Sleep(
589            policy
590                .interval
591                .max(Duration::from_millis(1))
592                .min(policy.budget - elapsed),
593        )
594    }
595}
596
597/// The report of a tick (V2): the redundancy check followed by the content
598/// checks. `before` is the backup's pre-apply sample, kept when the status
599/// could not be read.
600pub fn report(
601    t: &TickOutcome,
602    before: Option<i64>,
603    budget_s: u64,
604    waited_s: u64,
605    resync: Option<ResyncRecord>,
606) -> BackupReport {
607    let require_advance = matches!(SYNC_PREDICATE, SyncPredicate::TimestampAdvanced);
608    let mut checks = vec![redundancy_check(&t.signals, require_advance)];
609    checks.extend(t.content.iter().flatten().cloned());
610    let (sampled, after, project_state) = match &t.signals {
611        Ok(s) => (s.last_sync_before, s.last_sync_now, s.project_state.clone()),
612        Err(_) => (None, None, None),
613    };
614    BackupReport {
615        converged: converged(t, SYNC_PREDICATE),
616        reason: classify(t),
617        budget_s,
618        waited_s,
619        resync,
620        last_sync_before: before.filter(|value| *value >= 0).or(sampled),
621        last_sync_after: after,
622        project_state,
623        files_checked: t.files_checked,
624        checks,
625    }
626}
627
628/// The report of an on-demand look at the backup (`deploy verify --node`, V6).
629///
630/// Verify never waits, so there is no pre-apply sample to compare the sync
631/// timestamp with: the `redundancy` check is `peerConnected` and not
632/// `syncPending` ([`redundancy_check`] with `require_advance` false), and
633/// the backup is converged when no check failed. The reason follows the V2
634/// precedence without the sync clause: `unreachable`, then `not_syncing`
635/// (not linked or still pending), then `content_differs`. Verify does not
636/// read the log, so `sync_exhausted` is never its reason. The budget and the
637/// time waited are 0 and no resync was sent.
638pub fn verify_report(t: &TickOutcome) -> BackupReport {
639    let mut checks = vec![redundancy_check(&t.signals, false)];
640    checks.extend(t.content.iter().flatten().cloned());
641    let converged = t.content.is_some() && checks.iter().all(|c| c.status != CheckStatus::Fail);
642    let reason = if converged {
643        None
644    } else {
645        let unanswered = checks
646            .iter()
647            .any(|c| c.status == CheckStatus::Fail && c.detail.starts_with(UNREACHABLE_PREFIX));
648        Some(if unanswered {
649            UnsyncedReason::Unreachable
650        } else if !t.signals.as_ref().is_ok_and(BackupSignals::linked) {
651            UnsyncedReason::NotSyncing
652        } else {
653            UnsyncedReason::ContentDiffers
654        })
655    };
656    let (after, project_state) = match &t.signals {
657        Ok(s) => (s.last_sync_now, s.project_state.clone()),
658        Err(_) => (None, None),
659    };
660    BackupReport {
661        converged,
662        reason,
663        budget_s: 0,
664        waited_s: 0,
665        resync: None,
666        last_sync_before: None,
667        last_sync_after: after,
668        project_state,
669        files_checked: t.files_checked,
670        checks,
671    }
672}
673
674/// The files the gateway serves from what a read found: the mode collection
675/// over `core`.
676pub fn effective_files(observed: ObservedFiles) -> BTreeMap<RelPath, FileBlob> {
677    let mut files = observed.core;
678    files.extend(observed.mode);
679    files
680}
681
682/// The units a node's read could not deliver (quick-261029-ms3), by unit
683/// key: the shared definition of [`unreadable_unit_keys`].
684pub type UnreadableUnits = BTreeMap<String, UnreadableKey>;
685
686/// The unreadable units of one node's read. Take it before
687/// [`effective_files`] consumes the read.
688pub fn unreadable_units(observed: &ObservedFiles) -> UnreadableUnits {
689    unreadable_unit_keys(observed)
690}
691
692// ---- files -----------------------------------------------------------------------
693
694/// The one file that is compared as a parsed JSON document instead of byte
695/// by byte, as `(file name, members left out of the comparison)` (D-03, K2 b
696/// as revised after 24-10, `## C-P2`). The backup rewrites `resource.json`
697/// with its members in another order or pretty-printed while every value
698/// stays equal. Each excluded member is a dotted path from the document root;
699/// exactly two are left out, `attributes.lastModification` and
700/// `attributes.lastModificationSignature`, because a node that re-saves a
701/// unit whose stamp has no valid signature rewrites both while the other node
702/// keeps the text as written (24-10 finding). Every other member still
703/// counts: a changed value, a missing member or a file that is not JSON
704/// differs. Every other file is byte-equal.
705pub const FILE_NORMALIZATION: &[(&str, &[&str])] = &[(
706    "resource.json",
707    &[
708        "attributes.lastModification",
709        "attributes.lastModificationSignature",
710    ],
711)];
712
713/// How the backup's files compare with the master's live files.
714#[derive(Debug, Clone, Default, PartialEq, Eq)]
715pub struct FileComparison {
716    /// Rendered paths compared.
717    pub checked: usize,
718    /// Paths whose bytes differ, byte-sorted.
719    pub differing: Vec<String>,
720    /// Rendered paths the backup does not have, byte-sorted.
721    pub missing: Vec<String>,
722    /// Paths an apply deleted that the backup still has, byte-sorted.
723    pub unexpected: Vec<String>,
724    /// Rendered or deleted units a node could not read (quick-261029-ms3),
725    /// as `(node, unit key)`, sorted. Their paths are in no other list: the
726    /// copy is unknown, so it is neither equal nor absent. Each fails the
727    /// check.
728    pub unreadable: Vec<(String, String)>,
729    /// Units a node could not read that are neither rendered nor deleted,
730    /// as `(node, unit key)`, sorted: not compared, a warning only.
731    pub unreadable_unchecked: Vec<(String, String)>,
732}
733
734/// Compare the master's live files with the backup's (D-03): every rendered
735/// path in `present` must be on the backup with the same bytes (sha256), and
736/// every path in `absent` (the apply's deletes) must not be. The only
737/// exception is a [`FILE_NORMALIZATION`] row (`resource.json`, compared as
738/// parsed JSON without its two modification-stamp members). Bytes are compared as they are:
739/// no newline or Unicode normalization.
740pub fn compare_files(
741    master: &BTreeMap<RelPath, FileBlob>,
742    backup: &BTreeMap<RelPath, FileBlob>,
743    present: &BTreeSet<RelPath>,
744    absent: &BTreeSet<RelPath>,
745) -> FileComparison {
746    let mut out = FileComparison {
747        checked: present.len(),
748        ..FileComparison::default()
749    };
750    for path in present {
751        let Some(theirs) = backup.get(path) else {
752            out.missing.push(path.as_str().to_string());
753            continue;
754        };
755        let equal = match master.get(path) {
756            Some(ours) => ours.sha256 == theirs.sha256 || same_document(path, ours, theirs),
757            None => false,
758        };
759        if !equal {
760            out.differing.push(path.as_str().to_string());
761        }
762    }
763    for path in absent {
764        if backup.contains_key(path) {
765            out.unexpected.push(path.as_str().to_string());
766        }
767    }
768    // `present` and `absent` iterate in `RelPath` order, which is byte order.
769    out
770}
771
772/// Fold the units each node could not read into `c` (quick-261029-ms3).
773///
774/// A unit of `nodes` that holds a `present` or `absent` path (the same path,
775/// or the same unit key over the compared paths) is moved out of
776/// `differing`, `missing` and `unexpected` into `unreadable`, so a path the
777/// host cannot serve is never reported as equal, missing or gone. This also
778/// closes the gap where a deleted path the backup could not read looked
779/// deleted. Every other unreadable unit goes to `unreadable_unchecked`.
780/// With no unreadable unit, `c` is left exactly as it was.
781pub fn exempt_unreadable(
782    c: &mut FileComparison,
783    present: &BTreeSet<RelPath>,
784    absent: &BTreeSet<RelPath>,
785    nodes: &[(&str, &UnreadableUnits)],
786) {
787    if nodes.iter().all(|(_, units)| units.is_empty()) {
788        return;
789    }
790    let layer: BTreeMap<RelPath, ()> = present
791        .iter()
792        .chain(absent.iter())
793        .map(|p| (p.clone(), ()))
794        .collect();
795    let keyed: Vec<(&RelPath, String)> = layer.keys().map(|p| (p, unit_key(p, &layer))).collect();
796    let mut hit: BTreeSet<String> = BTreeSet::new();
797    let mut overlapping: BTreeSet<(String, String)> = BTreeSet::new();
798    let mut unchecked: BTreeSet<(String, String)> = BTreeSet::new();
799    for (node, units) in nodes {
800        for (key, info) in units.iter() {
801            let covered: Vec<&RelPath> = keyed
802                .iter()
803                .filter(|(p, k)| k == key || info.paths.iter().any(|u| u == p.as_str()))
804                .map(|(p, _)| *p)
805                .collect();
806            let entry = ((*node).to_string(), key.clone());
807            if covered.is_empty() {
808                unchecked.insert(entry);
809            } else {
810                hit.extend(covered.iter().map(|p| p.as_str().to_string()));
811                overlapping.insert(entry);
812            }
813        }
814    }
815    for list in [&mut c.differing, &mut c.missing, &mut c.unexpected] {
816        list.retain(|p| !hit.contains(p));
817    }
818    c.unreadable = overlapping.into_iter().collect();
819    c.unreadable_unchecked = unchecked.into_iter().collect();
820}
821
822/// One warning per unit of `c.unreadable_unchecked`.
823pub fn unreadable_warnings(c: &FileComparison) -> Vec<String> {
824    c.unreadable_unchecked
825        .iter()
826        .map(|(node, key)| {
827            format!(
828                "{node}: {key} is unreadable on the host and is neither rendered nor deleted; it was not compared"
829            )
830        })
831        .collect()
832}
833
834/// Whether `path` is a normalized file and the two copies are the same JSON
835/// document.
836fn same_document(path: &RelPath, ours: &FileBlob, theirs: &FileBlob) -> bool {
837    let Some(name) = path.components().next_back() else {
838        return false;
839    };
840    let Some((_, ignored)) = FILE_NORMALIZATION.iter().find(|(file, _)| *file == name) else {
841        return false;
842    };
843    let (Ok(mut a), Ok(mut b)) = (
844        serde_json::from_slice::<Value>(&ours.bytes),
845        serde_json::from_slice::<Value>(&theirs.bytes),
846    ) else {
847        return false;
848    };
849    for doc in [&mut a, &mut b] {
850        for dotted in *ignored {
851            remove_member(doc, dotted);
852        }
853    }
854    a == b
855}
856
857/// Remove the member at the dotted `path` below `doc`. A step that is not an
858/// object, or a member that is absent, leaves the document as it was.
859fn remove_member(doc: &mut Value, path: &str) {
860    let mut node = doc;
861    let mut steps = path.split('.').peekable();
862    while let Some(step) = steps.next() {
863        let Value::Object(map) = node else {
864            return;
865        };
866        if steps.peek().is_none() {
867            map.remove(step);
868            return;
869        }
870        let Some(next) = map.get_mut(step) else {
871            return;
872        };
873        node = next;
874    }
875}
876
877/// The `files` check of a comparison. Lists at most [`APPLY_LIST_SHOWN`]
878/// paths and the counts.
879pub fn files_check(c: &FileComparison) -> BackupCheck {
880    let kinds: [(&str, &str, &Vec<String>); 3] = [
881        (
882            "file_differs",
883            "differs from the master's live copy",
884            &c.differing,
885        ),
886        ("file_missing_on_backup", "is not on the backup", &c.missing),
887        (
888            "file_unexpected",
889            "was deleted by the apply but is still on the backup",
890            &c.unexpected,
891        ),
892    ];
893    // Unreadable units first, so a capped list always shows them.
894    let unreadable = c.unreadable.iter().map(|(node, key)| Finding {
895        code: "unreadable",
896        subject: key.clone(),
897        detail: format!(
898            "has a live path the {node}'s sftp server cannot read; its copy is unknown, so it is neither equal nor absent"
899        ),
900    });
901    let findings: Vec<Finding> = unreadable
902        .chain(kinds.iter().flat_map(|(code, what, paths)| {
903            paths.iter().map(move |path| Finding {
904                code,
905                subject: path.clone(),
906                detail: (*what).to_string(),
907            })
908        }))
909        .take(APPLY_LIST_SHOWN)
910        .collect();
911    let bad = c.differing.len() + c.missing.len() + c.unexpected.len() + c.unreadable.len();
912    let (status, detail) = if bad == 0 && c.unreadable_unchecked.is_empty() {
913        (
914            CheckStatus::Pass,
915            format!("{} file(s) equal the master's live copies", c.checked),
916        )
917    } else if bad == 0 {
918        let shown: Vec<String> = c
919            .unreadable_unchecked
920            .iter()
921            .take(APPLY_LIST_SHOWN)
922            .map(|(node, key)| format!("{key} ({node})"))
923            .collect();
924        let more = c.unreadable_unchecked.len() - shown.len();
925        (
926            CheckStatus::Warn,
927            format!(
928                "{} file(s) equal the master's live copies; {} unit(s) neither rendered nor deleted have a live path the host's sftp server cannot read and were not compared: {}{}",
929                c.checked,
930                c.unreadable_unchecked.len(),
931                shown.join(", "),
932                if more > 0 {
933                    format!(" and {more} more")
934                } else {
935                    String::new()
936                }
937            ),
938        )
939    } else {
940        let mut detail = format!(
941            "{} file(s) compared with the master's live copies: {} differ, {} missing on the backup, {} still present after being deleted",
942            c.checked,
943            c.differing.len(),
944            c.missing.len(),
945            c.unexpected.len()
946        );
947        if !c.unreadable.is_empty() {
948            detail.push_str(&format!(
949                ", {} unit(s) unreadable on a node",
950                c.unreadable.len()
951            ));
952        }
953        (CheckStatus::Fail, detail)
954    };
955    BackupCheck {
956        id: BackupCheckId::Files,
957        status,
958        detail,
959        findings,
960    }
961}
962
963/// The `files` check of a backup that declares no file transport: it cannot
964/// be run, which is a warning and not a failure.
965pub fn files_skipped_check() -> BackupCheck {
966    BackupCheck {
967        id: BackupCheckId::Files,
968        status: CheckStatus::Warn,
969        detail:
970            "nodes.backup declares no ssh or local transport, so the file-hash check did not run"
971                .to_string(),
972        findings: Vec::new(),
973    }
974}
975
976// ---- secret names ----------------------------------------------------------------
977
978/// A secret a render references, by provider and name. Names only.
979#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
980pub struct SecretRef {
981    /// The secret provider.
982    pub provider: String,
983    /// The secret's name in it.
984    pub name: String,
985}
986
987/// Where a secret-name check runs (K8).
988#[derive(Debug, Clone, Copy, PartialEq, Eq)]
989pub enum SecretCheckMode {
990    /// Before an apply: a provider the render itself declares may not exist
991    /// yet on a node, so it is skipped with its name.
992    Preflight,
993    /// After an apply: every referenced provider must exist.
994    Verify,
995}
996
997/// The secret-name check of one node (V7).
998#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
999pub struct SecretNodeReport {
1000    /// `master` or `backup`.
1001    pub node: String,
1002    /// `fail` when a name is missing, `warn` when a provider cannot list its
1003    /// names, else `pass`.
1004    pub status: CheckStatus,
1005    /// Referenced providers.
1006    pub providers_checked: usize,
1007    /// Referenced names the node does not have, by provider then name.
1008    pub missing: Vec<SecretRef>,
1009    /// Providers that cannot list their names (K7), byte-sorted.
1010    pub unverifiable: Vec<String>,
1011    /// Providers the render declares that do not exist yet (K8, preflight
1012    /// only), byte-sorted.
1013    #[serde(skip_serializing_if = "Vec::is_empty")]
1014    pub skipped_providers: Vec<String>,
1015}
1016
1017/// The secret-name check of the nodes a command ran it on, master first.
1018#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1019pub struct SecretNamesReport {
1020    /// One report per node.
1021    pub nodes: Vec<SecretNodeReport>,
1022}
1023
1024impl SecretNamesReport {
1025    /// The reports with `master` before `backup`.
1026    pub fn ordered(mut nodes: Vec<SecretNodeReport>) -> Self {
1027        nodes.sort_by_key(|n| match n.node.as_str() {
1028            "master" => 0u8,
1029            "backup" => 1,
1030            _ => 2,
1031        });
1032        SecretNamesReport { nodes }
1033    }
1034}
1035
1036/// Every `(provider, name)` a rendered `.json` file references, byte-ordered.
1037/// A file that does not parse is skipped (lint reports it elsewhere).
1038pub fn collect_referenced_secrets(tree: &RenderedTree) -> BTreeSet<(String, String)> {
1039    let mut out = BTreeSet::new();
1040    for (path, bytes) in &tree.files {
1041        if !path.as_str().ends_with(".json") {
1042            continue;
1043        }
1044        if let Ok((_, parsed)) = node::classify(bytes) {
1045            referenced_secret_names(&parsed, &mut out);
1046        }
1047    }
1048    out
1049}
1050
1051/// The secret providers the render itself declares:
1052/// `ignition/secret-provider/<P>/...`.
1053pub fn declared_secret_providers(tree: &RenderedTree) -> BTreeSet<String> {
1054    let mut out = BTreeSet::new();
1055    for path in tree.files.keys() {
1056        let parts: Vec<&str> = path.components().collect();
1057        if parts.len() >= 4 && parts[0] == "ignition" && parts[1] == "secret-provider" {
1058            out.insert(parts[2].to_string());
1059        }
1060    }
1061    out
1062}
1063
1064/// The `(provider, name)` pairs the render itself defines (K8 extension,
1065/// WR-04): for each provider the render declares, the entries in its own
1066/// `ignition/secret-provider/<P>/config.json`, which are the keys of
1067/// `settings.files` (a file provider) and of `settings.secrets` (an internal
1068/// provider). A deploy that adds such an entry and references it in the same
1069/// render cannot find the name on a node before it is applied, so the
1070/// preflight does not ask the node for it.
1071///
1072/// Names only: the entries' values are never read. A config that does not
1073/// parse, or has no such member, defines nothing.
1074pub fn secrets_defined_by_render(tree: &RenderedTree) -> BTreeSet<(String, String)> {
1075    let mut out = BTreeSet::new();
1076    for (path, bytes) in &tree.files {
1077        let parts: Vec<&str> = path.components().collect();
1078        let [first, second, provider, file] = parts.as_slice() else {
1079            continue;
1080        };
1081        if *first != "ignition" || *second != "secret-provider" || *file != "config.json" {
1082            continue;
1083        }
1084        let Ok(config) = serde_json::from_slice::<Value>(bytes) else {
1085            continue;
1086        };
1087        for member in ["files", "secrets"] {
1088            if let Some(entries) = config
1089                .get("settings")
1090                .and_then(|settings| settings.get(member))
1091                .and_then(Value::as_object)
1092            {
1093                out.extend(
1094                    entries
1095                        .keys()
1096                        .map(|name| ((*provider).to_string(), name.clone())),
1097                );
1098            }
1099        }
1100    }
1101    out
1102}
1103
1104/// Whether `node` has every secret name the render references (D-13).
1105///
1106/// Names compare as exact, case-sensitive strings (K9). A provider that
1107/// cannot list its names is `unverifiable` (K7); a provider that does not
1108/// exist on the node is `skipped` when the render declares it and the mode is
1109/// a preflight (K8), else its names are missing. A name the render defines
1110/// itself is filtered out of `referenced` by the caller before a preflight
1111/// ([`secrets_defined_by_render`]); verify keeps it, since after an apply the
1112/// name must exist. A provider with no entry in
1113/// `listings` reads as not existing.
1114pub fn check_secret_names(
1115    node: &str,
1116    referenced: &BTreeSet<(String, String)>,
1117    listings: &BTreeMap<String, SecretNameListing>,
1118    declared: &BTreeSet<String>,
1119    mode: SecretCheckMode,
1120) -> SecretNodeReport {
1121    let mut by_provider: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
1122    for (provider, name) in referenced {
1123        by_provider
1124            .entry(provider.as_str())
1125            .or_default()
1126            .push(name.as_str());
1127    }
1128    let mut missing = Vec::new();
1129    let mut unverifiable = Vec::new();
1130    let mut skipped = Vec::new();
1131    for (provider, names) in &by_provider {
1132        let all_missing = |missing: &mut Vec<SecretRef>| {
1133            missing.extend(names.iter().map(|name| SecretRef {
1134                provider: (*provider).to_string(),
1135                name: (*name).to_string(),
1136            }));
1137        };
1138        match listings.get(*provider) {
1139            Some(SecretNameListing::Listed(listed)) => {
1140                missing.extend(
1141                    names
1142                        .iter()
1143                        .filter(|name| !listed.contains(**name))
1144                        .map(|name| SecretRef {
1145                            provider: (*provider).to_string(),
1146                            name: (*name).to_string(),
1147                        }),
1148                );
1149            }
1150            Some(SecretNameListing::CannotList) => unverifiable.push((*provider).to_string()),
1151            Some(SecretNameListing::NoProvider) | None => {
1152                if mode == SecretCheckMode::Preflight && declared.contains(*provider) {
1153                    skipped.push((*provider).to_string());
1154                } else {
1155                    all_missing(&mut missing);
1156                }
1157            }
1158        }
1159    }
1160    let status = if !missing.is_empty() {
1161        CheckStatus::Fail
1162    } else if !unverifiable.is_empty() {
1163        CheckStatus::Warn
1164    } else {
1165        CheckStatus::Pass
1166    };
1167    SecretNodeReport {
1168        node: node.to_string(),
1169        status,
1170        providers_checked: by_provider.len(),
1171        missing,
1172        unverifiable,
1173        skipped_providers: skipped,
1174    }
1175}
1176
1177#[cfg(test)]
1178mod tests;