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        status: None,
362    }
363}
364
365/// The `redundancy` check: the backup is linked to its peer, has no sync
366/// queued and, when `require_advance`, its own timestamp advanced past its
367/// own earlier sample. An `Err(code)` is a status that could not be read.
368pub fn redundancy_check(
369    signals: &Result<BackupSignals, String>,
370    require_advance: bool,
371) -> BackupCheck {
372    let signals = match signals {
373        Ok(signals) => signals,
374        Err(code) => {
375            let detail = format!("unreachable: {code}");
376            return BackupCheck {
377                id: BackupCheckId::Redundancy,
378                status: CheckStatus::Fail,
379                findings: vec![finding("backup_unreachable", "redundancy", &detail)],
380                detail,
381            };
382        }
383    };
384    let mut findings = Vec::new();
385    if !signals.peer_connected {
386        findings.push(finding(
387            "peer_not_connected",
388            "peerConnected",
389            "peerConnected is false",
390        ));
391    }
392    if signals.sync_pending {
393        findings.push(finding(
394            "sync_pending",
395            "syncPending",
396            "syncPending is true",
397        ));
398    }
399    if require_advance && !signals.advanced() {
400        findings.push(finding(
401            "sync_not_advanced",
402            "lastSyncTimestamp",
403            "lastSyncTimestamp has not advanced past the pre-apply sample",
404        ));
405    }
406    if findings.is_empty() {
407        let detail = if require_advance {
408            "peerConnected, no sync pending, lastSyncTimestamp advanced"
409        } else {
410            "peerConnected, no sync pending"
411        };
412        return BackupCheck {
413            id: BackupCheckId::Redundancy,
414            status: CheckStatus::Pass,
415            detail: detail.to_string(),
416            findings,
417        };
418    }
419    let names: Vec<&str> = findings.iter().map(|f| f.subject.as_str()).collect();
420    BackupCheck {
421        id: BackupCheckId::Redundancy,
422        status: CheckStatus::Fail,
423        detail: format!("the backup is not settled: {}", names.join(", ")),
424        findings,
425    }
426}
427
428/// A check for a read the backup could not answer. The detail carries the
429/// error code only, never the gateway's words.
430pub fn unreachable_check(id: BackupCheckId, code: &str) -> BackupCheck {
431    let detail = format!("unreachable: {code}");
432    BackupCheck {
433        id,
434        status: CheckStatus::Fail,
435        findings: vec![finding("backup_unreachable", id.word(), &detail)],
436        detail,
437    }
438}
439
440impl BackupCheckId {
441    /// The word the id serializes as.
442    pub fn word(self) -> &'static str {
443        match self {
444            BackupCheckId::Redundancy => "redundancy",
445            BackupCheckId::Mode => "mode",
446            BackupCheckId::ReadBack => "read_back",
447            BackupCheckId::Files => "files",
448        }
449    }
450}
451
452fn backup_check_from(id: BackupCheckId, check: verify::Check) -> BackupCheck {
453    BackupCheck {
454        id,
455        status: check.status,
456        detail: check.detail,
457        findings: check.findings,
458    }
459}
460
461/// The `mode` check of the backup: the master's `mode` evaluation over the
462/// backup's normalized mode.
463pub fn mode_backup_check(expected: &Expected, mode: Option<&str>) -> BackupCheck {
464    backup_check_from(BackupCheckId::Mode, verify::mode_check(expected, mode))
465}
466
467/// The `read_back` check of the backup and the number of resources compared:
468/// the master's `read_back` evaluation over the backup's REST view.
469pub fn read_back_backup_check(
470    expected: &Expected,
471    read_back: &BTreeMap<String, Remote>,
472    warnings: &mut Vec<String>,
473) -> (BackupCheck, usize) {
474    let (check, compared) = verify::read_back_check(expected, read_back, warnings);
475    (backup_check_from(BackupCheckId::ReadBack, check), compared)
476}
477
478/// [`read_back_backup_check`], with the backup's `read_back` judged by
479/// `judge` (quick 261031-bbs): after an apply, against the backup's own
480/// pre-write baseline ([`verify::ReadBackJudge::Baseline`], R1). A warn
481/// finding does not count against the check, so it does not block
482/// [`converged`].
483pub fn read_back_backup_check_with(
484    expected: &Expected,
485    read_back: &BTreeMap<String, Remote>,
486    judge: &verify::ReadBackJudge<'_>,
487    warnings: &mut Vec<String>,
488) -> (BackupCheck, usize) {
489    let (check, compared) = verify::read_back_check_with(expected, read_back, judge, warnings);
490    (backup_check_from(BackupCheckId::ReadBack, check), compared)
491}
492
493// ---- convergence ---------------------------------------------------------------
494
495/// Which signal says the backup has synced (K1).
496#[derive(Debug, Clone, Copy, PartialEq, Eq)]
497pub enum SyncPredicate {
498    /// The backup's own `lastSyncTimestamp` advanced past its own pre-apply
499    /// sample (D-06 as written).
500    TimestampAdvanced,
501    /// The backup was linked and its content equal on this many ticks in a
502    /// row.
503    StableTicks(u8),
504}
505
506/// The predicate in force: K1 a, D-06 as written (24-02).
507pub const SYNC_PREDICATE: SyncPredicate = SyncPredicate::TimestampAdvanced;
508
509/// What one look at the backup found.
510#[derive(Debug, Clone, PartialEq, Eq)]
511pub struct TickOutcome {
512    /// The status signals, or the error code of a status that could not be
513    /// read.
514    pub signals: Result<BackupSignals, String>,
515    /// The log says the gateway gave up syncing (D-10).
516    pub exhausted: bool,
517    /// The mode, read-back and files checks; `None` when not evaluated yet.
518    pub content: Option<Vec<BackupCheck>>,
519    /// Files compared with the master's.
520    pub files_checked: usize,
521    /// Consecutive ticks on which the backup was linked and its content
522    /// equal (only [`SyncPredicate::StableTicks`] reads it).
523    pub stable_ticks: u8,
524}
525
526/// Whether the backup has converged (D-06): it is linked, the sync predicate
527/// holds and no content check failed. A 200 or a timestamp alone never
528/// converges (Pitfall 9). A `warn` (the file check a REST-only backup skips)
529/// does not block.
530pub fn converged(t: &TickOutcome, predicate: SyncPredicate) -> bool {
531    let Ok(signals) = &t.signals else {
532        return false;
533    };
534    if !signals.linked() || !sync_clause_met(t, signals, predicate) {
535        return false;
536    }
537    match &t.content {
538        Some(checks) => checks.iter().all(|c| c.status != CheckStatus::Fail),
539        None => false,
540    }
541}
542
543fn sync_clause_met(t: &TickOutcome, signals: &BackupSignals, predicate: SyncPredicate) -> bool {
544    match predicate {
545        SyncPredicate::TimestampAdvanced => signals.advanced(),
546        SyncPredicate::StableTicks(ticks) => t.stable_ticks >= ticks,
547    }
548}
549
550/// Why the backup is not converged, or `None` when it is (V2). First that
551/// holds: `unreachable` (the status or a content read could not be answered),
552/// `sync_exhausted`, `not_syncing` (not linked, still pending, or the sync
553/// clause unmet), `content_differs`.
554pub fn classify(t: &TickOutcome) -> Option<UnsyncedReason> {
555    if converged(t, SYNC_PREDICATE) {
556        return None;
557    }
558    let unanswered = t
559        .content
560        .iter()
561        .flatten()
562        .any(|c| c.status == CheckStatus::Fail && c.detail.starts_with(UNREACHABLE_PREFIX));
563    if t.signals.is_err() || unanswered {
564        return Some(UnsyncedReason::Unreachable);
565    }
566    if t.exhausted {
567        return Some(UnsyncedReason::SyncExhausted);
568    }
569    if let Ok(signals) = &t.signals
570        && (!signals.linked() || !sync_clause_met(t, signals, SYNC_PREDICATE))
571    {
572        return Some(UnsyncedReason::NotSyncing);
573    }
574    Some(UnsyncedReason::ContentDiffers)
575}
576
577/// The start of a check detail for a read that could not be answered.
578const UNREACHABLE_PREFIX: &str = "unreachable:";
579
580/// What the wait loop does next.
581#[derive(Debug, Clone, Copy, PartialEq, Eq)]
582pub enum PairWaitStep {
583    /// The backup has converged.
584    Converged,
585    /// The gateway gave up syncing: end the wait now (D-10).
586    Exhausted,
587    /// The budget is spent.
588    TimedOut,
589    /// Look again after this long.
590    Sleep(Duration),
591}
592
593/// The decision of the wait loop, with the elapsed time injected. A budget
594/// equal to the elapsed time has passed, and the last sleep never runs past
595/// the budget.
596pub fn wait_step(t: &TickOutcome, elapsed: Duration, policy: &BackupPolicy) -> PairWaitStep {
597    if converged(t, SYNC_PREDICATE) {
598        PairWaitStep::Converged
599    } else if t.exhausted {
600        PairWaitStep::Exhausted
601    } else if elapsed >= policy.budget {
602        PairWaitStep::TimedOut
603    } else {
604        PairWaitStep::Sleep(
605            policy
606                .interval
607                .max(Duration::from_millis(1))
608                .min(policy.budget - elapsed),
609        )
610    }
611}
612
613/// The report of a tick (V2): the redundancy check followed by the content
614/// checks. `before` is the backup's pre-apply sample, kept when the status
615/// could not be read.
616pub fn report(
617    t: &TickOutcome,
618    before: Option<i64>,
619    budget_s: u64,
620    waited_s: u64,
621    resync: Option<ResyncRecord>,
622) -> BackupReport {
623    let require_advance = matches!(SYNC_PREDICATE, SyncPredicate::TimestampAdvanced);
624    let mut checks = vec![redundancy_check(&t.signals, require_advance)];
625    checks.extend(t.content.iter().flatten().cloned());
626    let (sampled, after, project_state) = match &t.signals {
627        Ok(s) => (s.last_sync_before, s.last_sync_now, s.project_state.clone()),
628        Err(_) => (None, None, None),
629    };
630    BackupReport {
631        converged: converged(t, SYNC_PREDICATE),
632        reason: classify(t),
633        budget_s,
634        waited_s,
635        resync,
636        last_sync_before: before.filter(|value| *value >= 0).or(sampled),
637        last_sync_after: after,
638        project_state,
639        files_checked: t.files_checked,
640        checks,
641    }
642}
643
644/// The report of an on-demand look at the backup (`deploy verify --node`, V6).
645///
646/// Verify never waits, so there is no pre-apply sample to compare the sync
647/// timestamp with: the `redundancy` check is `peerConnected` and not
648/// `syncPending` ([`redundancy_check`] with `require_advance` false), and
649/// the backup is converged when no check failed. The reason follows the V2
650/// precedence without the sync clause: `unreachable`, then `not_syncing`
651/// (not linked or still pending), then `content_differs`. Verify does not
652/// read the log, so `sync_exhausted` is never its reason. The budget and the
653/// time waited are 0 and no resync was sent.
654pub fn verify_report(t: &TickOutcome) -> BackupReport {
655    let mut checks = vec![redundancy_check(&t.signals, false)];
656    checks.extend(t.content.iter().flatten().cloned());
657    let converged = t.content.is_some() && checks.iter().all(|c| c.status != CheckStatus::Fail);
658    let reason = if converged {
659        None
660    } else {
661        let unanswered = checks
662            .iter()
663            .any(|c| c.status == CheckStatus::Fail && c.detail.starts_with(UNREACHABLE_PREFIX));
664        Some(if unanswered {
665            UnsyncedReason::Unreachable
666        } else if !t.signals.as_ref().is_ok_and(BackupSignals::linked) {
667            UnsyncedReason::NotSyncing
668        } else {
669            UnsyncedReason::ContentDiffers
670        })
671    };
672    let (after, project_state) = match &t.signals {
673        Ok(s) => (s.last_sync_now, s.project_state.clone()),
674        Err(_) => (None, None),
675    };
676    BackupReport {
677        converged,
678        reason,
679        budget_s: 0,
680        waited_s: 0,
681        resync: None,
682        last_sync_before: None,
683        last_sync_after: after,
684        project_state,
685        files_checked: t.files_checked,
686        checks,
687    }
688}
689
690/// The files the gateway serves from what a read found: the mode collection
691/// over `core`.
692pub fn effective_files(observed: ObservedFiles) -> BTreeMap<RelPath, FileBlob> {
693    let mut files = observed.core;
694    files.extend(observed.mode);
695    files
696}
697
698/// The units a node's read could not deliver (quick-261029-ms3), by unit
699/// key: the shared definition of [`unreadable_unit_keys`].
700pub type UnreadableUnits = BTreeMap<String, UnreadableKey>;
701
702/// The unreadable units of one node's read. Take it before
703/// [`effective_files`] consumes the read.
704pub fn unreadable_units(observed: &ObservedFiles) -> UnreadableUnits {
705    unreadable_unit_keys(observed)
706}
707
708// ---- files -----------------------------------------------------------------------
709
710/// The one file that is compared as a parsed JSON document instead of byte
711/// by byte, as `(file name, members left out of the comparison)` (D-03, K2 b
712/// as revised after 24-10, `## C-P2`). The backup rewrites `resource.json`
713/// with its members in another order or pretty-printed while every value
714/// stays equal. Each excluded member is a dotted path from the document root;
715/// exactly two are left out, `attributes.lastModification` and
716/// `attributes.lastModificationSignature`, because a node that re-saves a
717/// unit whose stamp has no valid signature rewrites both while the other node
718/// keeps the text as written (24-10 finding). Every other member still
719/// counts: a changed value, a missing member or a file that is not JSON
720/// differs. Every other file is byte-equal.
721pub const FILE_NORMALIZATION: &[(&str, &[&str])] = &[(
722    "resource.json",
723    &[
724        "attributes.lastModification",
725        "attributes.lastModificationSignature",
726    ],
727)];
728
729/// How the backup's files compare with the master's live files.
730#[derive(Debug, Clone, Default, PartialEq, Eq)]
731pub struct FileComparison {
732    /// Rendered paths compared.
733    pub checked: usize,
734    /// Paths whose bytes differ, byte-sorted.
735    pub differing: Vec<String>,
736    /// Rendered paths the backup does not have, byte-sorted.
737    pub missing: Vec<String>,
738    /// Paths an apply deleted that the backup still has, byte-sorted.
739    pub unexpected: Vec<String>,
740    /// Rendered or deleted units a node could not read (quick-261029-ms3),
741    /// as `(node, unit key)`, sorted. Their paths are in no other list: the
742    /// copy is unknown, so it is neither equal nor absent. Each fails the
743    /// check.
744    pub unreadable: Vec<(String, String)>,
745    /// Units a node could not read that are neither rendered nor deleted,
746    /// as `(node, unit key)`, sorted: not compared, a warning only.
747    pub unreadable_unchecked: Vec<(String, String)>,
748}
749
750/// Compare the master's live files with the backup's (D-03): every rendered
751/// path in `present` must be on the backup with the same bytes (sha256), and
752/// every path in `absent` (the apply's deletes) must not be. The only
753/// exception is a [`FILE_NORMALIZATION`] row (`resource.json`, compared as
754/// parsed JSON without its two modification-stamp members). Bytes are compared as they are:
755/// no newline or Unicode normalization.
756pub fn compare_files(
757    master: &BTreeMap<RelPath, FileBlob>,
758    backup: &BTreeMap<RelPath, FileBlob>,
759    present: &BTreeSet<RelPath>,
760    absent: &BTreeSet<RelPath>,
761) -> FileComparison {
762    let mut out = FileComparison {
763        checked: present.len(),
764        ..FileComparison::default()
765    };
766    for path in present {
767        let Some(theirs) = backup.get(path) else {
768            out.missing.push(path.as_str().to_string());
769            continue;
770        };
771        let equal = match master.get(path) {
772            Some(ours) => ours.sha256 == theirs.sha256 || same_document(path, ours, theirs),
773            None => false,
774        };
775        if !equal {
776            out.differing.push(path.as_str().to_string());
777        }
778    }
779    for path in absent {
780        if backup.contains_key(path) {
781            out.unexpected.push(path.as_str().to_string());
782        }
783    }
784    // `present` and `absent` iterate in `RelPath` order, which is byte order.
785    out
786}
787
788/// Fold the units each node could not read into `c` (quick-261029-ms3).
789///
790/// A unit of `nodes` that holds a `present` or `absent` path (the same path,
791/// or the same unit key over the compared paths) is moved out of
792/// `differing`, `missing` and `unexpected` into `unreadable`, so a path the
793/// host cannot serve is never reported as equal, missing or gone. This also
794/// closes the gap where a deleted path the backup could not read looked
795/// deleted. Every other unreadable unit goes to `unreadable_unchecked`.
796/// With no unreadable unit, `c` is left exactly as it was.
797pub fn exempt_unreadable(
798    c: &mut FileComparison,
799    present: &BTreeSet<RelPath>,
800    absent: &BTreeSet<RelPath>,
801    nodes: &[(&str, &UnreadableUnits)],
802) {
803    if nodes.iter().all(|(_, units)| units.is_empty()) {
804        return;
805    }
806    let layer: BTreeMap<RelPath, ()> = present
807        .iter()
808        .chain(absent.iter())
809        .map(|p| (p.clone(), ()))
810        .collect();
811    let keyed: Vec<(&RelPath, String)> = layer.keys().map(|p| (p, unit_key(p, &layer))).collect();
812    let mut hit: BTreeSet<String> = BTreeSet::new();
813    let mut overlapping: BTreeSet<(String, String)> = BTreeSet::new();
814    let mut unchecked: BTreeSet<(String, String)> = BTreeSet::new();
815    for (node, units) in nodes {
816        for (key, info) in units.iter() {
817            let covered: Vec<&RelPath> = keyed
818                .iter()
819                .filter(|(p, k)| k == key || info.paths.iter().any(|u| u == p.as_str()))
820                .map(|(p, _)| *p)
821                .collect();
822            let entry = ((*node).to_string(), key.clone());
823            if covered.is_empty() {
824                unchecked.insert(entry);
825            } else {
826                hit.extend(covered.iter().map(|p| p.as_str().to_string()));
827                overlapping.insert(entry);
828            }
829        }
830    }
831    for list in [&mut c.differing, &mut c.missing, &mut c.unexpected] {
832        list.retain(|p| !hit.contains(p));
833    }
834    c.unreadable = overlapping.into_iter().collect();
835    c.unreadable_unchecked = unchecked.into_iter().collect();
836}
837
838/// One warning per unit of `c.unreadable_unchecked`.
839pub fn unreadable_warnings(c: &FileComparison) -> Vec<String> {
840    c.unreadable_unchecked
841        .iter()
842        .map(|(node, key)| {
843            format!(
844                "{node}: {key} is unreadable on the host and is neither rendered nor deleted; it was not compared"
845            )
846        })
847        .collect()
848}
849
850/// Whether `path` is a normalized file and the two copies are the same JSON
851/// document.
852fn same_document(path: &RelPath, ours: &FileBlob, theirs: &FileBlob) -> bool {
853    let Some(name) = path.components().next_back() else {
854        return false;
855    };
856    let Some((_, ignored)) = FILE_NORMALIZATION.iter().find(|(file, _)| *file == name) else {
857        return false;
858    };
859    let (Ok(mut a), Ok(mut b)) = (
860        serde_json::from_slice::<Value>(&ours.bytes),
861        serde_json::from_slice::<Value>(&theirs.bytes),
862    ) else {
863        return false;
864    };
865    for doc in [&mut a, &mut b] {
866        for dotted in *ignored {
867            remove_member(doc, dotted);
868        }
869    }
870    a == b
871}
872
873/// Remove the member at the dotted `path` below `doc`. A step that is not an
874/// object, or a member that is absent, leaves the document as it was.
875fn remove_member(doc: &mut Value, path: &str) {
876    let mut node = doc;
877    let mut steps = path.split('.').peekable();
878    while let Some(step) = steps.next() {
879        let Value::Object(map) = node else {
880            return;
881        };
882        if steps.peek().is_none() {
883            map.remove(step);
884            return;
885        }
886        let Some(next) = map.get_mut(step) else {
887            return;
888        };
889        node = next;
890    }
891}
892
893/// The `files` check of a comparison. Lists at most [`APPLY_LIST_SHOWN`]
894/// paths and the counts.
895pub fn files_check(c: &FileComparison) -> BackupCheck {
896    let kinds: [(&str, &str, &Vec<String>); 3] = [
897        (
898            "file_differs",
899            "differs from the master's live copy",
900            &c.differing,
901        ),
902        ("file_missing_on_backup", "is not on the backup", &c.missing),
903        (
904            "file_unexpected",
905            "was deleted by the apply but is still on the backup",
906            &c.unexpected,
907        ),
908    ];
909    // Unreadable units first, so a capped list always shows them.
910    let unreadable = c.unreadable.iter().map(|(node, key)| Finding {
911        code: "unreadable",
912        subject: key.clone(),
913        detail: format!(
914            "has a live path the {node}'s sftp server cannot read; its copy is unknown, so it is neither equal nor absent"
915        ),
916        status: None,
917    });
918    let findings: Vec<Finding> = unreadable
919        .chain(kinds.iter().flat_map(|(code, what, paths)| {
920            paths.iter().map(move |path| Finding {
921                code,
922                subject: path.clone(),
923                detail: (*what).to_string(),
924                status: None,
925            })
926        }))
927        .take(APPLY_LIST_SHOWN)
928        .collect();
929    let bad = c.differing.len() + c.missing.len() + c.unexpected.len() + c.unreadable.len();
930    let (status, detail) = if bad == 0 && c.unreadable_unchecked.is_empty() {
931        (
932            CheckStatus::Pass,
933            format!("{} file(s) equal the master's live copies", c.checked),
934        )
935    } else if bad == 0 {
936        let shown: Vec<String> = c
937            .unreadable_unchecked
938            .iter()
939            .take(APPLY_LIST_SHOWN)
940            .map(|(node, key)| format!("{key} ({node})"))
941            .collect();
942        let more = c.unreadable_unchecked.len() - shown.len();
943        (
944            CheckStatus::Warn,
945            format!(
946                "{} 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: {}{}",
947                c.checked,
948                c.unreadable_unchecked.len(),
949                shown.join(", "),
950                if more > 0 {
951                    format!(" and {more} more")
952                } else {
953                    String::new()
954                }
955            ),
956        )
957    } else {
958        let mut detail = format!(
959            "{} file(s) compared with the master's live copies: {} differ, {} missing on the backup, {} still present after being deleted",
960            c.checked,
961            c.differing.len(),
962            c.missing.len(),
963            c.unexpected.len()
964        );
965        if !c.unreadable.is_empty() {
966            detail.push_str(&format!(
967                ", {} unit(s) unreadable on a node",
968                c.unreadable.len()
969            ));
970        }
971        (CheckStatus::Fail, detail)
972    };
973    BackupCheck {
974        id: BackupCheckId::Files,
975        status,
976        detail,
977        findings,
978    }
979}
980
981/// The `files` check of a backup that declares no file transport: it cannot
982/// be run, which is a warning and not a failure.
983pub fn files_skipped_check() -> BackupCheck {
984    BackupCheck {
985        id: BackupCheckId::Files,
986        status: CheckStatus::Warn,
987        detail:
988            "nodes.backup declares no ssh or local transport, so the file-hash check did not run"
989                .to_string(),
990        findings: Vec::new(),
991    }
992}
993
994// ---- secret names ----------------------------------------------------------------
995
996/// A secret a render references, by provider and name. Names only.
997#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
998pub struct SecretRef {
999    /// The secret provider.
1000    pub provider: String,
1001    /// The secret's name in it.
1002    pub name: String,
1003}
1004
1005/// Where a secret-name check runs (K8).
1006#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1007pub enum SecretCheckMode {
1008    /// Before an apply: a provider the render itself declares may not exist
1009    /// yet on a node, so it is skipped with its name.
1010    Preflight,
1011    /// After an apply: every referenced provider must exist.
1012    Verify,
1013}
1014
1015/// The secret-name check of one node (V7).
1016#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1017pub struct SecretNodeReport {
1018    /// `master` or `backup`.
1019    pub node: String,
1020    /// `fail` when a name is missing, `warn` when a provider cannot list its
1021    /// names, else `pass`.
1022    pub status: CheckStatus,
1023    /// Referenced providers.
1024    pub providers_checked: usize,
1025    /// Referenced names the node does not have, by provider then name.
1026    pub missing: Vec<SecretRef>,
1027    /// Providers that cannot list their names (K7), byte-sorted.
1028    pub unverifiable: Vec<String>,
1029    /// Providers the render declares that do not exist yet (K8, preflight
1030    /// only), byte-sorted.
1031    #[serde(skip_serializing_if = "Vec::is_empty")]
1032    pub skipped_providers: Vec<String>,
1033}
1034
1035/// The secret-name check of the nodes a command ran it on, master first.
1036#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1037pub struct SecretNamesReport {
1038    /// One report per node.
1039    pub nodes: Vec<SecretNodeReport>,
1040}
1041
1042impl SecretNamesReport {
1043    /// The reports with `master` before `backup`.
1044    pub fn ordered(mut nodes: Vec<SecretNodeReport>) -> Self {
1045        nodes.sort_by_key(|n| match n.node.as_str() {
1046            "master" => 0u8,
1047            "backup" => 1,
1048            _ => 2,
1049        });
1050        SecretNamesReport { nodes }
1051    }
1052}
1053
1054/// Every `(provider, name)` a rendered `.json` file references, byte-ordered.
1055/// A file that does not parse is skipped (lint reports it elsewhere).
1056pub fn collect_referenced_secrets(tree: &RenderedTree) -> BTreeSet<(String, String)> {
1057    let mut out = BTreeSet::new();
1058    for (path, bytes) in &tree.files {
1059        if !path.as_str().ends_with(".json") {
1060            continue;
1061        }
1062        if let Ok((_, parsed)) = node::classify(bytes) {
1063            referenced_secret_names(&parsed, &mut out);
1064        }
1065    }
1066    out
1067}
1068
1069/// The secret providers the render itself declares:
1070/// `ignition/secret-provider/<P>/...`.
1071pub fn declared_secret_providers(tree: &RenderedTree) -> BTreeSet<String> {
1072    let mut out = BTreeSet::new();
1073    for path in tree.files.keys() {
1074        let parts: Vec<&str> = path.components().collect();
1075        if parts.len() >= 4 && parts[0] == "ignition" && parts[1] == "secret-provider" {
1076            out.insert(parts[2].to_string());
1077        }
1078    }
1079    out
1080}
1081
1082/// The `(provider, name)` pairs the render itself defines (K8 extension,
1083/// WR-04): for each provider the render declares, the entries in its own
1084/// `ignition/secret-provider/<P>/config.json`, which are the keys of
1085/// `settings.files` (a file provider) and of `settings.secrets` (an internal
1086/// provider). A deploy that adds such an entry and references it in the same
1087/// render cannot find the name on a node before it is applied, so the
1088/// preflight does not ask the node for it.
1089///
1090/// Names only: the entries' values are never read. A config that does not
1091/// parse, or has no such member, defines nothing.
1092pub fn secrets_defined_by_render(tree: &RenderedTree) -> BTreeSet<(String, String)> {
1093    let mut out = BTreeSet::new();
1094    for (path, bytes) in &tree.files {
1095        let parts: Vec<&str> = path.components().collect();
1096        let [first, second, provider, file] = parts.as_slice() else {
1097            continue;
1098        };
1099        if *first != "ignition" || *second != "secret-provider" || *file != "config.json" {
1100            continue;
1101        }
1102        let Ok(config) = serde_json::from_slice::<Value>(bytes) else {
1103            continue;
1104        };
1105        for member in ["files", "secrets"] {
1106            if let Some(entries) = config
1107                .get("settings")
1108                .and_then(|settings| settings.get(member))
1109                .and_then(Value::as_object)
1110            {
1111                out.extend(
1112                    entries
1113                        .keys()
1114                        .map(|name| ((*provider).to_string(), name.clone())),
1115                );
1116            }
1117        }
1118    }
1119    out
1120}
1121
1122/// Whether `node` has every secret name the render references (D-13).
1123///
1124/// Names compare as exact, case-sensitive strings (K9). A provider that
1125/// cannot list its names is `unverifiable` (K7); a provider that does not
1126/// exist on the node is `skipped` when the render declares it and the mode is
1127/// a preflight (K8), else its names are missing. A name the render defines
1128/// itself is filtered out of `referenced` by the caller before a preflight
1129/// ([`secrets_defined_by_render`]); verify keeps it, since after an apply the
1130/// name must exist. A provider with no entry in
1131/// `listings` reads as not existing.
1132pub fn check_secret_names(
1133    node: &str,
1134    referenced: &BTreeSet<(String, String)>,
1135    listings: &BTreeMap<String, SecretNameListing>,
1136    declared: &BTreeSet<String>,
1137    mode: SecretCheckMode,
1138) -> SecretNodeReport {
1139    let mut by_provider: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
1140    for (provider, name) in referenced {
1141        by_provider
1142            .entry(provider.as_str())
1143            .or_default()
1144            .push(name.as_str());
1145    }
1146    let mut missing = Vec::new();
1147    let mut unverifiable = Vec::new();
1148    let mut skipped = Vec::new();
1149    for (provider, names) in &by_provider {
1150        let all_missing = |missing: &mut Vec<SecretRef>| {
1151            missing.extend(names.iter().map(|name| SecretRef {
1152                provider: (*provider).to_string(),
1153                name: (*name).to_string(),
1154            }));
1155        };
1156        match listings.get(*provider) {
1157            Some(SecretNameListing::Listed(listed)) => {
1158                missing.extend(
1159                    names
1160                        .iter()
1161                        .filter(|name| !listed.contains(**name))
1162                        .map(|name| SecretRef {
1163                            provider: (*provider).to_string(),
1164                            name: (*name).to_string(),
1165                        }),
1166                );
1167            }
1168            Some(SecretNameListing::CannotList) => unverifiable.push((*provider).to_string()),
1169            Some(SecretNameListing::NoProvider) | None => {
1170                if mode == SecretCheckMode::Preflight && declared.contains(*provider) {
1171                    skipped.push((*provider).to_string());
1172                } else {
1173                    all_missing(&mut missing);
1174                }
1175            }
1176        }
1177    }
1178    let status = if !missing.is_empty() {
1179        CheckStatus::Fail
1180    } else if !unverifiable.is_empty() {
1181        CheckStatus::Warn
1182    } else {
1183        CheckStatus::Pass
1184    };
1185    SecretNodeReport {
1186        node: node.to_string(),
1187        status,
1188        providers_checked: by_provider.len(),
1189        missing,
1190        unverifiable,
1191        skipped_providers: skipped,
1192    }
1193}
1194
1195#[cfg(test)]
1196mod tests;