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