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