Skip to main content

ignition_core/deploy/apply/
mod.rs

1//! The guarded write half of `deploy` (Phase 23): gates, journal, snapshots,
2//! the apply engine and rollback.
3//!
4//! This module and `deploy/transport/` are the only places that name the
5//! write half. Every decision made here is pure or sits behind a seam, so a
6//! test can drive it without a gateway. The vocabulary (error slugs and
7//! exits, the gate order, the on-box journal, record and staging layouts, the
8//! run-directory rule, the outcome, cause and preflight check ids) is the one
9//! the user confirmed at the 23-02 checkpoint; see `23-02-SUMMARY.md`
10//! `## Checkpoint outcome`.
11//!
12//! # The engine (23-05): the green path
13//!
14//! [`run_apply`] takes a plan the gates have passed and drives it, in this
15//! order, over the seams in [`ApplyContext`]:
16//!
17//! 1. the run directory (the git-ignore guard first) and the events log;
18//! 2. the [`WriteSet`];
19//! 3. the preflights ([`preflight::run_preflights`], 23-07): every failure
20//!    in one `deploy_preflight_failed` refusal, nothing staged or locked;
21//!    then `run.json` (once, pass or fail) and `plan.json`; then the `.gwbk`
22//!    (23-09, D-13), downloaded into the run directory before anything is
23//!    staged or locked so that it never spends hold budget: a failed, empty
24//!    or truncated one is `deploy_preflight_failed` check `gwbk`, and only
25//!    `--skip-gwbk` (recorded in `run.json`) bypasses it;
26//! 4. the new ledger, validated with `Ledger::parse`;
27//! 5. `ApplyHost::prepare`, outside the lock;
28//! 6. [`hold::HeldLock::acquire`];
29//! 7. exactly one `ApplyHost::commit`;
30//! 8. [`hold::HeldLock::check`], which must say `Held`;
31//! 9. `scan_and_wait` on the config scan, once (C11: a tag provider and its
32//!    definitions load in one scan);
33//! 10. `deploy_verify`, which must be green;
34//! 11. the post fingerprint, then `ApplyHost::finalize`;
35//! 12. the snapshot download into the run directory (a failure is a warning;
36//!     each copied file's SHA-256 is checked against the record's pre-image,
37//!     and a difference is a warning naming the path);
38//! 13. with a backup node, the backup phase ([`converge::converge_backup`],
39//!     Phase 24): outside the lock, polling only the backup's own reads until
40//!     it proves it holds the master's configuration, with one resync to the
41//!     master after the first budget (D-07). A backup that does not converge
42//!     ends the run `applied_backup_unsynced` (`ok` false, slug
43//!     [`BACKUP_UNSYNCED_SLUG`], exit 6) with the reason in `data.backup`. The
44//!     master is never restored for it: the deploy is live and verified, the
45//!     on-box record stays `applied`, and no restore event is logged
46//!     (D-08, DPAIR-02). The sync-exhausted state (TEC-5105) is its own
47//!     reason and never rolls the master back either.
48//!
49//! The pending-restart list is read before staging and again after the
50//! run (23-09, DAPL-10): [`ApplyOutcome::restart_pending`] is the list
51//! after, [`ApplyOutcome::restart_pending_new`] what was not there before.
52//! Reading is all it does: no restart or failover route is called under any
53//! outcome (SF-3), and an unreadable list is the warning
54//! `restart_pending_unavailable`, never a failure.
55//!
56//! Only a green verify makes the outcome `applied`. Every other way the
57//! under-lock window can end (23-17):
58//!
59//! | Branch | Ends as |
60//! |---|---|
61//! | the under-lock pre-image check finds a changed file (nothing moved) | the run's staging is dropped, one no-op scan releases the lock (D12), `plan_stale` |
62//! | the lock is refused (for example `scan_lock_held`) | the run's own staging is dropped, the refusal is returned, nothing was written |
63//! | the commit fails after it moved files | [`restore_snapshot`], outcome `rolled_back`, cause `write_failed` |
64//! | the commit runs past its time budget (the hold less [`hold::LOCK_MARGIN_S`]; the host stops before the next move, delete or retry and reports `deadline`) | [`restore_snapshot`], cause `lock_expired`, no scan before it (review WR-04) |
65//! | the lock is not ours or has no margin left after the commit (either clock) | [`restore_snapshot`], cause `lock_expired`, no scan before it |
66//! | the scan does not complete | [`restore_snapshot`], cause `scan_incomplete` |
67//! | the post-apply verify is red | [`restore_snapshot`], cause `verify_failed` |
68//! | a step that cannot be answered before the verdict (the lock read, the scan or the verify fails with an `Err`) | the same restore as a red verify, with the closest cause (`lock_expired`, `scan_incomplete`, `verify_failed`); the step and the error's code are the warning `post_commit_error (<step>: <code>)` (review CR-01) |
69//! | a host call fails after a green verify (the fingerprint, the finalize) | the interrupted run: `deploy_journal_present` with `ign deploy rollback --run <id> --env <env>`, the tree left as written; a finalize whose answer was lost but whose journal is gone is `applied` with the warning `finalize_reported_error (<code>)` (review CR-01) |
70//! | a restore that cannot finish | `rollback_failed`, the journal stays on the box (D-12) |
71//!
72//! A host that refuses a commit before it wrote anything (a journal of
73//! another run, an invalid spec) is returned as its own typed error and the
74//! lock is left to lapse: a scan there could load another run's partial
75//! state. Added by later plans: the journal refusal (23-12).
76//!
77//! Once the run directory exists, `run_apply` leaves through one exit point
78//! that writes `result.json` once.
79//!
80//! # Manual rollback (23-11)
81//!
82//! [`run_rollback`] undoes a past run from its on-box record alone, through
83//! the same [`restore_snapshot`]; see the [`rollback`] module doc for the
84//! checks and the run it makes. It needs no declared repo, which is why
85//! [`restore_snapshot`] takes a [`RestoreContext`] and not an
86//! [`ApplyContext`].
87
88pub mod converge;
89pub mod fingerprint;
90pub mod gates;
91pub mod hold;
92pub mod host;
93pub mod journal;
94pub mod preflight;
95pub mod record;
96pub mod rollback;
97pub mod run_store;
98
99use std::collections::{BTreeMap, BTreeSet};
100use std::path::{Path, PathBuf};
101
102use serde::Serialize;
103use serde_json::{Value, json};
104
105use crate::actions::backup::backup_download;
106use crate::actions::deploy::{
107    Declared, DeployPlanResult, PlanOptions, VerifyOptions, deploy_plan, deploy_verify,
108};
109use crate::actions::workspace::rfc3339_now_utc;
110use crate::client::backup::BackupType;
111use crate::client::deploy_api::scan_and_wait;
112use crate::client::scan::{LockVerdict, ScanKind, ScanOutcome};
113use crate::client::{DeployApi, GatewayApi};
114use crate::deploy::layout::RelPath;
115use crate::deploy::ledger::{Ledger, LedgerRoot, journal_location, ledger_location};
116use crate::deploy::pair::{BackupNode, BackupPolicy, BackupReport, wall_clock_ms};
117use crate::deploy::plan::PlanStatus;
118use crate::deploy::target::NodeTarget;
119use crate::deploy::transport::TreeReader;
120use crate::deploy::transport::batch::BUDGET_MAX_S;
121use crate::deploy::tree::sha256_hex;
122use crate::error::{CoreError, PreflightFailure, RollbackFailure};
123use crate::poll::PollConfig;
124
125use self::fingerprint::{LEDGER_KEY, fingerprint};
126use self::gates::GateFlags;
127use self::hold::{Clock, HeldLock, HoldModel, LOCK_MARGIN_S};
128use self::host::{ApplyHost, BatchSpec, DeleteOp, WriteOp};
129use self::journal::{Journal, JournalPhase, journal_on_box};
130use self::preflight::{DesignersPolicy, PreflightInput, run_preflights};
131use self::record::{RecordState, RunRecord, TouchedEntry};
132use self::run_store::{EventLog, RunDir, RunId};
133
134pub use self::rollback::{
135    InterruptedRun, RollbackChecked, RollbackContext, RollbackKind, RollbackOptions,
136    RollbackOutcome, check_rollback, restore_set, run_rollback,
137};
138
139/// The longest the in-lock backup sample (WR-02) may take before the first
140/// sample stands as the baseline. The lock clock is running and the hold
141/// model has no allowance for this read.
142const LOCKED_SAMPLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
143
144/// The slug of the rolled-back apply verdict.
145///
146/// Decision A: `deploy_rolled_back` rides the verdict payload (`data.slug`,
147/// exit 6) and is never a [`crate::error::CoreError`] variant, never in
148/// [`crate::error::CoreError::DEPLOY_SLUGS`] and never a row of the README
149/// exit table. It follows the `deploy_verify_failed` precedent
150/// ([`crate::deploy::verify::VERIFY_FAILED_SLUG`]).
151pub const ROLLED_BACK_SLUG: &str = "deploy_rolled_back";
152
153/// The slug of the backup-unsynced apply verdict (Phase 24, D-08).
154///
155/// Decision A, as for [`ROLLED_BACK_SLUG`]: `deploy_backup_unsynced` rides the
156/// verdict payload (`data.slug`, exit 6) and is never a
157/// [`crate::error::CoreError`] variant, never in
158/// [`crate::error::CoreError::DEPLOY_SLUGS`] and never a row of the README
159/// exit table. The master deployed and verified; only the backup is unproven,
160/// so the master is not restored.
161pub const BACKUP_UNSYNCED_SLUG: &str = "deploy_backup_unsynced";
162
163/// The preflight check id of the `.gwbk` step (23-02 item 8).
164const GWBK_CHECK: &str = "gwbk";
165
166/// The warning for a pending-restart list that could not be read (23-02
167/// item 9).
168const RESTART_UNAVAILABLE: &str = "restart_pending_unavailable";
169
170/// The warning for an event log that could not be appended to after the run
171/// was recorded (the deploy is live and verified; the log is the casualty).
172const EVENTS_UNWRITABLE: &str = "events_log_unwritable";
173
174/// How a run ended.
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
176#[serde(rename_all = "snake_case")]
177pub enum OutcomeKind {
178    /// The change is live and verified.
179    Applied,
180    /// The plan was empty; nothing was touched.
181    Noop,
182    /// The change was undone (23-17).
183    RolledBack,
184    /// The master applied and verified, but the backup did not prove it holds
185    /// the master's configuration within the budget (Phase 24, D-08). The
186    /// master is left as verified.
187    AppliedBackupUnsynced,
188}
189
190impl OutcomeKind {
191    /// The `data.outcome` word.
192    pub fn as_str(self) -> &'static str {
193        match self {
194            OutcomeKind::Applied => "applied",
195            OutcomeKind::Noop => "noop",
196            OutcomeKind::RolledBack => "rolled_back",
197            OutcomeKind::AppliedBackupUnsynced => "applied_backup_unsynced",
198        }
199    }
200}
201
202/// Why a run rolled back (23-17).
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
204#[serde(rename_all = "snake_case")]
205pub enum RollbackCause {
206    /// The scan-lock lapsed before the scan.
207    LockExpired,
208    /// The commit failed after it moved files.
209    WriteFailed,
210    /// The scan did not complete.
211    ScanIncomplete,
212    /// The post-apply verify was red.
213    VerifyFailed,
214}
215
216impl RollbackCause {
217    /// The `data.cause` word.
218    pub fn as_str(self) -> &'static str {
219        match self {
220            RollbackCause::LockExpired => "lock_expired",
221            RollbackCause::WriteFailed => "write_failed",
222            RollbackCause::ScanIncomplete => "scan_incomplete",
223            RollbackCause::VerifyFailed => "verify_failed",
224        }
225    }
226}
227
228/// The files one run writes and deletes.
229#[derive(Debug, Clone, PartialEq, Eq)]
230pub struct WriteSet {
231    /// Byte-sorted, each with its expected pre-image.
232    pub writes: Vec<WriteOp>,
233    /// Byte-sorted, each with its expected pre-image.
234    pub deletes: Vec<DeleteOp>,
235}
236
237/// The write set of `plan` (research Pattern 3).
238///
239/// Every rendered file of every `added` or `changed` unit is written, whole
240/// units and never a partial one in the mode collection; `same` and
241/// `unmanaged` units are never written. The deletes are the plan's delete
242/// set. Each touched path carries the hash the plan saw in the MODE
243/// collection: `live_sha256` where the entry is live in `mode`, else none
244/// (a copy that only `core` holds is not a file of the mode collection). A
245/// delete without such a hash, or a rendered file whose bytes are not the
246/// ones the plan hashed, is a defect, reported as
247/// [`CoreError::Internal`].
248pub fn write_set(plan: &DeployPlanResult, declared: &Declared) -> Result<WriteSet, CoreError> {
249    let defect = |why: String| CoreError::Internal(format!("write set: {why}"));
250    let mut writes = Vec::new();
251    // Path to (live in the mode collection, effective live hash).
252    let mut live: BTreeMap<&str, (bool, Option<&str>)> = BTreeMap::new();
253    for entry in &plan.entries {
254        let in_mode = entry.live_in.iter().any(|c| c == "mode");
255        for file in &entry.files {
256            live.insert(file.path.as_str(), (in_mode, file.live_sha256.as_deref()));
257        }
258        if !matches!(entry.status, PlanStatus::Added | PlanStatus::Changed) {
259            continue;
260        }
261        for file in &entry.files {
262            let Some(desired) = &file.desired_sha256 else {
263                continue;
264            };
265            let path = RelPath::new(&file.path)
266                .map_err(|why| defect(format!("the plan names an invalid path ({why})")))?;
267            let bytes =
268                declared.outcome.tree.files.get(&path).ok_or_else(|| {
269                    defect(format!("{path} is in the plan but not in the render"))
270                })?;
271            if sha256_hex(bytes) != *desired {
272                return Err(defect(format!(
273                    "{path} was rendered differently from the plan"
274                )));
275            }
276            writes.push(WriteOp {
277                path,
278                sha256: desired.clone(),
279                pre: if in_mode {
280                    file.live_sha256.clone()
281                } else {
282                    None
283                },
284            });
285        }
286    }
287    let mut deletes = Vec::new();
288    for path in &plan.delete_set.files {
289        let Some((true, Some(pre))) = live.get(path.as_str()) else {
290            return Err(defect(format!(
291                "{path} is in the delete set without a pre-image in the plan"
292            )));
293        };
294        deletes.push(DeleteOp {
295            path: path.clone(),
296            pre: (*pre).to_string(),
297        });
298    }
299    writes.sort_by(|a, b| a.path.as_str().as_bytes().cmp(b.path.as_str().as_bytes()));
300    deletes.sort_by(|a, b| a.path.as_str().as_bytes().cmp(b.path.as_str().as_bytes()));
301    Ok(WriteSet { writes, deletes })
302}
303
304/// The ledger after this run: the old files minus the delete set, plus the
305/// written files, byte-sorted (the ledger swap is the last move).
306fn new_ledger(
307    plan: &DeployPlanResult,
308    old: Option<&Ledger>,
309    set: &WriteSet,
310    run_id: &RunId,
311) -> Ledger {
312    let mut files: BTreeSet<String> = old
313        .map(|ledger| ledger.files.iter().cloned().collect())
314        .unwrap_or_default();
315    for op in &set.deletes {
316        files.remove(op.path.as_str());
317    }
318    for op in &set.writes {
319        files.insert(op.path.as_str().to_string());
320    }
321    Ledger {
322        schema_version: 1,
323        gateway: plan.gateway.clone(),
324        mode: plan.mode.clone(),
325        render_sha256: plan.render_sha256.clone(),
326        run_id: run_id.as_str().to_string(),
327        files: files.into_iter().collect(),
328    }
329}
330
331/// The seconds a commit may spend moving and deleting: the scan-lock hold less
332/// [`LOCK_MARGIN_S`], so that a scan can still be triggered inside the hold
333/// when the host stops exactly at the budget. At least one second, at most
334/// [`BUDGET_MAX_S`]. The host counts from the start of its commit and the hold
335/// from the grant, so the ssh connect and the script start are what the margin
336/// and the post-commit lock check absorb (WR-04).
337fn commit_budget_s(hold: &HoldModel) -> u32 {
338    let seconds = hold.hold_s.saturating_sub(LOCK_MARGIN_S as u64);
339    u32::try_from(seconds.clamp(1, u64::from(BUDGET_MAX_S))).unwrap_or(BUDGET_MAX_S)
340}
341
342/// The batch the host will commit: the writes, the deletes, the new ledger
343/// and the journal and record that describe exactly them.
344fn batch_spec(
345    plan: &DeployPlanResult,
346    run_id: &RunId,
347    set: &WriteSet,
348    ledger_new: Vec<u8>,
349    ledger_pre: Option<String>,
350    commit_budget_s: u32,
351) -> BatchSpec {
352    let mut touched: Vec<TouchedEntry> = set
353        .writes
354        .iter()
355        .map(|w| TouchedEntry {
356            path: w.path.as_str().to_string(),
357            pre_sha256: w.pre.clone(),
358            post_sha256: Some(w.sha256.clone()),
359        })
360        .chain(set.deletes.iter().map(|d| TouchedEntry {
361            path: d.path.as_str().to_string(),
362            pre_sha256: Some(d.pre.clone()),
363            post_sha256: None,
364        }))
365        .collect();
366    touched.push(TouchedEntry {
367        path: LEDGER_KEY.to_string(),
368        pre_sha256: ledger_pre.clone(),
369        post_sha256: Some(sha256_hex(&ledger_new)),
370    });
371    touched.sort_by(|a, b| a.path.as_bytes().cmp(b.path.as_bytes()));
372    let pre_fingerprint = fingerprint(
373        &touched
374            .iter()
375            .map(|e| (e.path.clone(), e.pre_sha256.clone()))
376            .collect(),
377    );
378    let run = run_id.as_str().to_string();
379    BatchSpec {
380        run_id: run_id.clone(),
381        gateway: plan.gateway.clone(),
382        mode: plan.mode.clone(),
383        writes: set.writes.clone(),
384        deletes: set.deletes.clone(),
385        ledger_new: Some(ledger_new),
386        ledger_pre,
387        commit_budget_s: Some(commit_budget_s),
388        journal: Journal {
389            schema_version: 1,
390            run_id: run.clone(),
391            gateway: plan.gateway.clone(),
392            mode: plan.mode.clone(),
393            phase: JournalPhase::Snapshotting,
394            pre_fingerprint: pre_fingerprint.clone(),
395            touched: touched.iter().map(|e| e.path.clone()).collect(),
396        },
397        record: RunRecord {
398            schema_version: 1,
399            run_id: run,
400            gateway: plan.gateway.clone(),
401            mode: plan.mode.clone(),
402            render_sha256: plan.render_sha256.clone(),
403            plan_sha256: plan.plan_sha256.clone(),
404            state: RecordState::Applying,
405            rolled_back_by: None,
406            touched,
407            pre_fingerprint,
408            post_fingerprint: None,
409        },
410    }
411}
412
413/// One step of a run, value-free: ids, counts and flags only.
414#[derive(Debug, Clone, PartialEq, Eq)]
415pub enum ApplyEvent {
416    /// The run directory exists.
417    RunStarted {
418        /// The run.
419        run_id: String,
420        /// Files to write.
421        writes: usize,
422        /// Files to delete.
423        deletes: usize,
424    },
425    /// `prepare` staged the batch.
426    Staged {
427        /// Files staged.
428        files: usize,
429    },
430    /// The scan-lock was taken.
431    LockAcquired {
432        /// Seconds requested.
433        hold_s: u64,
434    },
435    /// `commit` returned.
436    Committed {
437        /// Writes moved.
438        moved: usize,
439        /// Deletes done.
440        deleted: usize,
441    },
442    /// The lock was judged after the commit.
443    LockVerdict {
444        /// Whether it was held with the margin left.
445        held: bool,
446    },
447    /// The config scan completed.
448    ScanDone,
449    /// `deploy_verify` answered.
450    VerifyDone {
451        /// Whether it was green.
452        ok: bool,
453    },
454    /// The run was finalized.
455    Finalized,
456    /// `commit` returned an error and this run's journal is still on the box:
457    /// what is on the host is unknown and the run is recovered with
458    /// `rollback --run` (23-12). Carries the code of the original failure.
459    CommitInterrupted {
460        /// The error code `commit` failed with.
461        code: String,
462    },
463    /// The checks of a red verdict, by id and status.
464    VerifyChecks {
465        /// `(check id, status)` in report order.
466        checks: Vec<(String, String)>,
467    },
468    /// A restore began (23-17). `None` is a manual rollback (23-11): the
469    /// closed `data.cause` vocabulary of an apply is unchanged.
470    RestoreStarted {
471        /// Why an apply is being undone; `None` for a manual rollback.
472        cause: Option<RollbackCause>,
473    },
474    /// A rollback went ahead over live changes made after the run
475    /// (`--discard-live-changes`, 23-11): the first paths and the count.
476    LiveChangesDiscarded {
477        /// How many touched paths had changed.
478        count: usize,
479        /// The first [`crate::error::APPLY_LIST_SHOWN`], byte-sorted.
480        paths: Vec<String>,
481    },
482    /// The restore finished (23-17).
483    Restored,
484    /// A recovery removed an interrupted run that had changed nothing
485    /// (23-12, D-10).
486    Cleared,
487    /// A rollback found that the run it recovers was interrupted (23-12,
488    /// D-09, D-11): the journal's phase, what recovery will do and the live
489    /// hash of each path of the journal's touched list (`None` when absent).
490    InterruptedRun {
491        /// The journal's phase.
492        phase: &'static str,
493        /// `restore` or `clear`.
494        action: &'static str,
495        /// Path to live hash.
496        hashes: BTreeMap<String, Option<String>>,
497    },
498    /// The preflights found a failed check; every verdict, by id and status.
499    PreflightChecks {
500        /// `(check id, status)` in report order.
501        checks: Vec<(String, String)>,
502    },
503    /// The Designer list was read under `--designers wait`.
504    DesignersPoll {
505        /// Sessions open at this read.
506        open: usize,
507    },
508    /// `--designers proceed` went ahead over these open sessions (D-15).
509    DesignersProceed {
510        /// `(user, project, host)` of each open session.
511        sessions: Vec<preflight::DesignerSeen>,
512    },
513    /// The `.gwbk` was downloaded and read as a ZIP (23-09).
514    GwbkDownloaded {
515        /// Its size in bytes.
516        bytes: u64,
517    },
518    /// `--skip-gwbk`: no `.gwbk` was taken (23-09).
519    GwbkSkipped,
520    /// The backup's sync timestamp was sampled (V3, D-05). Twice per run:
521    /// after the preflights (the fail-fast read) and again once the lock is
522    /// held, just before the commit (WR-02); the second is the baseline.
523    BackupSampled {
524        /// The backup's `lastSyncTimestamp`; `None` is never synced.
525        last_sync: Option<i64>,
526    },
527    /// The wait for the backup began, after the snapshot download (V3).
528    BackupWaitStarted {
529        /// The budget in seconds.
530        budget_s: u64,
531    },
532    /// One look at the backup's status (V3).
533    BackupPoll {
534        /// The backup sees its peer.
535        peer_connected: bool,
536        /// A sync is queued on the backup.
537        sync_pending: bool,
538        /// The backup's timestamp advanced past its sample.
539        advanced: bool,
540    },
541    /// The one resync request was sent to the master (V3, D-07).
542    BackupResync {
543        /// The POST was accepted.
544        ok: bool,
545        /// The error code when it was not.
546        code: Option<String>,
547    },
548    /// The backup phase ended (V3).
549    BackupVerdict {
550        /// The backup converged.
551        converged: bool,
552        /// Why not, when it did not.
553        reason: Option<&'static str>,
554    },
555    /// The run failed.
556    Failed {
557        /// The error code slug.
558        code: String,
559    },
560}
561
562impl ApplyEvent {
563    /// The event's name in `events.ndjson`.
564    pub fn name(&self) -> &'static str {
565        match self {
566            ApplyEvent::RunStarted { .. } => "run_started",
567            ApplyEvent::Staged { .. } => "staged",
568            ApplyEvent::LockAcquired { .. } => "lock_acquired",
569            ApplyEvent::Committed { .. } => "committed",
570            ApplyEvent::LockVerdict { .. } => "lock_verdict",
571            ApplyEvent::ScanDone => "scan_done",
572            ApplyEvent::VerifyDone { .. } => "verify_done",
573            ApplyEvent::VerifyChecks { .. } => "verify_checks",
574            ApplyEvent::Finalized => "finalized",
575            ApplyEvent::CommitInterrupted { .. } => "commit_interrupted",
576            ApplyEvent::RestoreStarted { .. } => "restore_started",
577            ApplyEvent::Restored => "restored",
578            ApplyEvent::Cleared => "cleared",
579            ApplyEvent::InterruptedRun { .. } => "interrupted_run",
580            ApplyEvent::LiveChangesDiscarded { .. } => "live_changes_discarded",
581            ApplyEvent::PreflightChecks { .. } => "preflight_checks",
582            ApplyEvent::DesignersPoll { .. } => "designers_poll",
583            ApplyEvent::DesignersProceed { .. } => "designers_proceed",
584            ApplyEvent::GwbkDownloaded { .. } => "gwbk_downloaded",
585            ApplyEvent::GwbkSkipped => "gwbk_skipped",
586            ApplyEvent::BackupSampled { .. } => "backup_sampled",
587            ApplyEvent::BackupWaitStarted { .. } => "backup_wait_started",
588            ApplyEvent::BackupPoll { .. } => "backup_poll",
589            ApplyEvent::BackupResync { .. } => "backup_resync",
590            ApplyEvent::BackupVerdict { .. } => "backup_verdict",
591            ApplyEvent::Failed { .. } => "failed",
592        }
593    }
594
595    /// The event's fields: ids, counts and flags, never a value.
596    pub fn fields(&self) -> Value {
597        match self {
598            ApplyEvent::RunStarted {
599                run_id,
600                writes,
601                deletes,
602            } => json!({"run_id": run_id, "writes": writes, "deletes": deletes}),
603            ApplyEvent::Staged { files } => json!({"files": files}),
604            ApplyEvent::LockAcquired { hold_s } => json!({"hold_s": hold_s}),
605            ApplyEvent::Committed { moved, deleted } => {
606                json!({"moved": moved, "deleted": deleted})
607            }
608            ApplyEvent::LockVerdict { held } => json!({"held": held}),
609            ApplyEvent::VerifyDone { ok } => json!({"ok": ok}),
610            ApplyEvent::VerifyChecks { checks } => json!({
611                "checks": checks
612                    .iter()
613                    .map(|(id, status)| json!({"id": id, "status": status}))
614                    .collect::<Vec<_>>(),
615            }),
616            ApplyEvent::PreflightChecks { checks } => json!({
617                "checks": checks
618                    .iter()
619                    .map(|(id, status)| json!({"id": id, "status": status}))
620                    .collect::<Vec<_>>(),
621            }),
622            ApplyEvent::DesignersPoll { open } => json!({"open": open}),
623            ApplyEvent::DesignersProceed { sessions } => json!({
624                "sessions": sessions
625                    .iter()
626                    .map(|(user, project, host)| json!({"user": user, "project": project, "host": host}))
627                    .collect::<Vec<_>>(),
628            }),
629            ApplyEvent::RestoreStarted { cause } => {
630                json!({"cause": cause.map(RollbackCause::as_str)})
631            }
632            ApplyEvent::LiveChangesDiscarded { count, paths } => {
633                json!({"count": count, "paths": paths})
634            }
635            ApplyEvent::InterruptedRun {
636                phase,
637                action,
638                hashes,
639            } => json!({
640                "phase": phase,
641                "action": action,
642                "touched": hashes.len(),
643                "hashes": hashes,
644            }),
645            ApplyEvent::GwbkDownloaded { bytes } => json!({"bytes": bytes}),
646            ApplyEvent::BackupSampled { last_sync } => json!({"last_sync": last_sync}),
647            ApplyEvent::BackupWaitStarted { budget_s } => json!({"budget_s": budget_s}),
648            ApplyEvent::BackupPoll {
649                peer_connected,
650                sync_pending,
651                advanced,
652            } => json!({
653                "peer_connected": peer_connected,
654                "sync_pending": sync_pending,
655                "advanced": advanced,
656            }),
657            ApplyEvent::BackupResync { ok, code } => json!({"ok": ok, "code": code}),
658            ApplyEvent::BackupVerdict { converged, reason } => {
659                json!({"converged": converged, "reason": reason})
660            }
661            ApplyEvent::CommitInterrupted { code } | ApplyEvent::Failed { code } => {
662                json!({"code": code})
663            }
664            ApplyEvent::ScanDone
665            | ApplyEvent::Finalized
666            | ApplyEvent::Restored
667            | ApplyEvent::Cleared
668            | ApplyEvent::GwbkSkipped => Value::Null,
669        }
670    }
671}
672
673/// Hears each [`ApplyEvent`] as it happens.
674pub trait ApplyObserver: Send + Sync {
675    /// One step happened.
676    fn event(&self, e: &ApplyEvent);
677}
678
679/// An observer that ignores everything.
680#[derive(Debug, Clone, Copy, Default)]
681pub struct NoObserver;
682
683impl ApplyObserver for NoObserver {
684    fn event(&self, _e: &ApplyEvent) {}
685}
686
687/// What the engine drives.
688pub struct ApplyContext<'a> {
689    /// The declared repo, rendered.
690    pub declared: &'a Declared,
691    /// The repo root (the run directory lives below it).
692    pub repo: &'a std::path::Path,
693    /// The read half of the master's host.
694    pub reader: &'a dyn TreeReader,
695    /// The write seam of the master's host.
696    pub host: &'a dyn ApplyHost,
697    /// The gateway REST surface (lock, scan, mode, read-back).
698    pub deploy: &'a dyn DeployApi,
699    /// The gateway log and WebDev surface (verify).
700    pub gateway: &'a dyn GatewayApi,
701    /// The backup node (a read-only handle), when the target has a backup
702    /// node (the `mode_backup` preflight).
703    pub backup: Option<&'a BackupNode<'a>>,
704    /// The monotonic clock the lock is judged by.
705    pub clock: &'a dyn Clock,
706    /// Hears each step.
707    pub observer: &'a dyn ApplyObserver,
708}
709
710impl<'a> ApplyContext<'a> {
711    /// What [`restore_snapshot`] needs from this context.
712    fn restore_context(&'a self, opts: &'a ApplyOptions) -> RestoreContext<'a> {
713        RestoreContext {
714            env: &self.declared.env,
715            master: &self.declared.target.nodes.master,
716            host: self.host,
717            deploy: self.deploy,
718            clock: self.clock,
719            hold: &opts.hold,
720            poll: &opts.poll,
721        }
722    }
723}
724
725/// What a run needs besides its context.
726#[derive(Debug, Clone)]
727pub struct ApplyOptions {
728    /// The `--base` the plan was drawn against.
729    pub base: Option<PathBuf>,
730    /// The gate flags the plan passed with.
731    pub gates: GateFlags,
732    /// The WebDev project the verify probes read through.
733    pub project: String,
734    /// The hold the lock is requested with.
735    pub hold: HoldModel,
736    /// How the scan wait polls.
737    pub poll: PollConfig,
738    /// How the post-apply verify waits.
739    pub verify: VerifyOptions,
740    /// What to do about open Designer sessions (D-14, D-15).
741    pub designers: DesignersPolicy,
742    /// `--skip-gwbk`: take no `.gwbk` (D-13). Recorded in `run.json`.
743    pub skip_gwbk: bool,
744    /// How long the backup phase waits and how often it looks (D-05, V5).
745    /// Unused without a backup node.
746    pub backup: BackupPolicy,
747}
748
749/// How a run ended and what it did.
750#[derive(Debug, Clone, PartialEq, Serialize)]
751pub struct ApplyOutcome {
752    /// `applied`, `noop`, `rolled_back` or `applied_backup_unsynced`.
753    #[serde(rename = "outcome")]
754    pub kind: OutcomeKind,
755    /// The run; absent for a no-op.
756    pub run_id: Option<String>,
757    /// The run directory; absent for a no-op.
758    pub run_dir: Option<String>,
759    /// Why the run rolled back.
760    pub cause: Option<RollbackCause>,
761    /// `render_sha256` of the render.
762    pub render_sha256: String,
763    /// `plan_sha256` of the plan.
764    pub plan_sha256: String,
765    /// Collection paths written.
766    pub written: Vec<String>,
767    /// Collection paths deleted.
768    pub deleted: Vec<String>,
769    /// The touched-set fingerprint after the run.
770    pub post_fingerprint: Option<String>,
771    /// Non-failing notes of the engine.
772    pub warnings: Vec<String>,
773    /// The `.gwbk` file name in the run directory; absent for a no-op, for
774    /// `--skip-gwbk` and for a rolled-back run that never took one.
775    pub gwbk: Option<String>,
776    /// Whether the run was started with `--skip-gwbk`.
777    pub skip_gwbk: bool,
778    /// The pending restarts after the run (`[]` when none or unreadable).
779    pub restart_pending: Vec<String>,
780    /// The pending restarts the run added: those not listed before it.
781    pub restart_pending_new: Vec<String>,
782    /// SHA-256 of each file copied into the run directory's `snapshot/`,
783    /// keyed by its path below it (`files/<path>`, `ledger.json`,
784    /// `record.json`).
785    pub snapshot_sha256: BTreeMap<String, String>,
786    /// What the backup phase found; `None` without a backup node, for a
787    /// no-op and for a rolled-back run (V10).
788    pub backup: Option<BackupReport>,
789}
790
791/// Apply `plan` (see the module doc for the order).
792///
793/// A plan carrying an unreadable unit that is declared or in the ledger is
794/// refused `deploy_unreadable` first, before anything else, whatever route
795/// the plan took here (WR-04): the gate in `deploy_apply_prepare` is not the
796/// only guard of that rule. An empty plan returns [`OutcomeKind::Noop`] before anything is touched:
797/// no run directory, no host call, no lock, no scan. Otherwise the run
798/// directory is created first (the git-ignore guard is part of that), and
799/// from then on every way out passes through the one exit point at the end
800/// of this function, which writes `result.json` once.
801pub async fn run_apply(
802    ctx: &ApplyContext<'_>,
803    plan: &DeployPlanResult,
804    opts: &ApplyOptions,
805) -> Result<ApplyOutcome, CoreError> {
806    if let Some(refusal) = gates::unreadable_refusal(plan) {
807        return Err(refusal);
808    }
809    if !plan.summary.has_changes {
810        return Ok(ApplyOutcome {
811            kind: OutcomeKind::Noop,
812            run_id: None,
813            run_dir: None,
814            cause: None,
815            render_sha256: plan.render_sha256.clone(),
816            plan_sha256: plan.plan_sha256.clone(),
817            written: Vec::new(),
818            deleted: Vec::new(),
819            post_fingerprint: None,
820            warnings: Vec::new(),
821            gwbk: None,
822            skip_gwbk: false,
823            restart_pending: Vec::new(),
824            restart_pending_new: Vec::new(),
825            snapshot_sha256: BTreeMap::new(),
826            backup: None,
827        });
828    }
829    let (run_id, run_dir) = RunDir::create_fresh(ctx.repo, &plan.gateway)?;
830    let mut steps = Steps::new(None, ctx.observer);
831    let result = drive(ctx, plan, opts, &run_id, &run_dir, &mut steps).await;
832
833    // The one exit point.
834    if let Err(e) = &result {
835        let _ = steps.emit(ApplyEvent::Failed {
836            code: e.code().to_string(),
837        });
838    }
839    let doc = match &result {
840        Ok(outcome) => {
841            let mut doc = serde_json::to_value(outcome).unwrap_or(Value::Null);
842            doc["ok"] = Value::Bool(!matches!(
843                outcome.kind,
844                OutcomeKind::RolledBack | OutcomeKind::AppliedBackupUnsynced
845            ));
846            match outcome.kind {
847                OutcomeKind::RolledBack => {
848                    doc["slug"] = Value::String(ROLLED_BACK_SLUG.to_string());
849                }
850                OutcomeKind::AppliedBackupUnsynced => {
851                    doc["slug"] = Value::String(BACKUP_UNSYNCED_SLUG.to_string());
852                }
853                OutcomeKind::Applied | OutcomeKind::Noop => {}
854            }
855            doc
856        }
857        Err(e) => json!({
858            "ok": false,
859            "run_id": run_id.as_str(),
860            "error": {"code": e.code(), "message": e.to_string()},
861        }),
862    };
863    let written = run_dir.write_json("result.json", &doc);
864    match result {
865        Ok(mut outcome) => {
866            if written.is_err() {
867                outcome.warnings.push("result_json_unwritable".to_string());
868            }
869            Ok(outcome)
870        }
871        Err(e) => Err(e),
872    }
873}
874
875/// The run's events: appended to `events.ndjson` once it is open, and told
876/// to the observer.
877pub struct Steps<'a> {
878    log: Option<EventLog>,
879    observer: &'a dyn ApplyObserver,
880}
881
882impl<'a> Steps<'a> {
883    /// Events go to `log` (when open) and to `observer`.
884    pub fn new(log: Option<EventLog>, observer: &'a dyn ApplyObserver) -> Self {
885        Steps { log, observer }
886    }
887
888    /// Report one step.
889    pub fn emit(&mut self, event: ApplyEvent) -> Result<(), CoreError> {
890        self.observer.event(&event);
891        match &mut self.log {
892            Some(log) => log.append(event.name(), event.fields()),
893            None => Ok(()),
894        }
895    }
896}
897
898/// A host cannot know the `--env` name; the engine does.
899fn with_env(err: CoreError, env: &str) -> CoreError {
900    match err {
901        CoreError::DeployJournalPresent {
902            run_id, location, ..
903        } => CoreError::DeployJournalPresent {
904            run_id,
905            env: env.to_string(),
906            location,
907        },
908        other => other,
909    }
910}
911
912/// What this run's journal says about the box after a failure whose effect on
913/// the box is not known.
914enum BoxState {
915    /// This run's own journal is there.
916    Mine(Journal),
917    /// Another run's journal is there.
918    Other,
919    /// The box was read and holds no journal.
920    Absent,
921    /// The box (or the journal on it) could not be read.
922    Unknown,
923}
924
925async fn box_state(ctx: &ApplyContext<'_>, plan: &DeployPlanResult, run_id: &RunId) -> BoxState {
926    let master = &ctx.declared.target.nodes.master;
927    match journal_on_box(ctx.reader, master, &plan.gateway, &plan.mode).await {
928        Ok(Some(journal)) if journal.run_id == run_id.as_str() => BoxState::Mine(journal),
929        Ok(Some(_)) => BoxState::Other,
930        Ok(None) => BoxState::Absent,
931        Err(_) => BoxState::Unknown,
932    }
933}
934
935/// This run's journal is on the box: the run is interrupted. Reported as
936/// `deploy_journal_present` with the recovery command, and the original
937/// failure's code in a `commit_interrupted` event.
938fn interrupted(
939    ctx: &ApplyContext<'_>,
940    plan: &DeployPlanResult,
941    steps: &mut Steps<'_>,
942    journal: &Journal,
943    err: &CoreError,
944) -> CoreError {
945    let _ = steps.emit(ApplyEvent::CommitInterrupted {
946        code: err.code().to_string(),
947    });
948    journal::journal_present(
949        journal,
950        &ctx.declared.env,
951        &ctx.declared.target.nodes.master,
952        &plan.gateway,
953        &plan.mode,
954    )
955}
956
957/// `commit` returned an `Err`. Over ssh a dropped connection is one, and what
958/// is on the box is unknown: the remote script may have moved files or may
959/// still be running (23-17 carry-over, DAPL-09).
960///
961/// Only the journal tells. When this run's own journal is on the box the run
962/// is reported as interrupted: `deploy_journal_present` with the recovery
963/// command (`ign deploy rollback --run <id> --env <env>`), and the original
964/// failure's code in a `commit_interrupted` event. Nothing is restored here: a
965/// restore on a guess could race a script that is still moving files, and the
966/// recovery path reads the snapshot before it writes. When no journal of this
967/// run is there, nothing is known to have been written and the original
968/// error stands. When the box cannot be read at all, nothing is known either
969/// way: the error keeps its code and names the recovery command (WR-05). A
970/// refusal that is already
971/// `deploy_journal_present` (another run's journal) is kept as it is.
972async fn commit_failed(
973    ctx: &ApplyContext<'_>,
974    run_id: &RunId,
975    plan: &DeployPlanResult,
976    steps: &mut Steps<'_>,
977    err: CoreError,
978) -> CoreError {
979    if matches!(err, CoreError::DeployJournalPresent { .. }) {
980        return err;
981    }
982    match box_state(ctx, plan, run_id).await {
983        BoxState::Mine(journal) => interrupted(ctx, plan, steps, &journal, &err),
984        BoxState::Unknown => {
985            // An unreadable box is not an empty one, and the usual reason it
986            // cannot be read is the dropped connection that failed the commit
987            // (WR-05): the journal may be there, so the recovery is named.
988            let _ = steps.emit(ApplyEvent::CommitInterrupted {
989                code: format!("{}; journal state unknown", err.code()),
990            });
991            recovery_pointer(ctx, plan, run_id, err)
992        }
993        BoxState::Other | BoxState::Absent => err,
994    }
995}
996
997/// A host call failed after a green verify: the deploy is live and verified
998/// but not recorded (CR-01). It is never a plain error. This run's own
999/// journal on the box makes it the interrupted run (`deploy_journal_present`
1000/// with the recovery command: `rollback --run` restores the pre-apply files,
1001/// as for a kill past a green verify, A9). When the journal cannot be told
1002/// from the error alone the recovery command is named all the same.
1003async fn unfinished_after_green(
1004    ctx: &ApplyContext<'_>,
1005    run_id: &RunId,
1006    plan: &DeployPlanResult,
1007    steps: &mut Steps<'_>,
1008    err: CoreError,
1009) -> CoreError {
1010    match box_state(ctx, plan, run_id).await {
1011        BoxState::Mine(journal) => interrupted(ctx, plan, steps, &journal, &err),
1012        _ => recovery_pointer(ctx, plan, run_id, err),
1013    }
1014}
1015
1016/// `err`, saying that this run may have left a journal on the host and how to
1017/// recover it. A transport failure keeps its slug and exit code (4) and gains
1018/// the sentence; anything else becomes `deploy_journal_present` for this run,
1019/// whose message carries the same command.
1020fn recovery_pointer(
1021    ctx: &ApplyContext<'_>,
1022    plan: &DeployPlanResult,
1023    run_id: &RunId,
1024    err: CoreError,
1025) -> CoreError {
1026    let env = &ctx.declared.env;
1027    match err {
1028        CoreError::TransportFailed {
1029            operation,
1030            node,
1031            detail,
1032        } => CoreError::TransportFailed {
1033            operation,
1034            node,
1035            detail: format!(
1036                "{detail}; this run's journal may be on the host: recover it with: \
1037                 ign deploy rollback --run {run} --env {env}",
1038                run = run_id.as_str()
1039            ),
1040        },
1041        _ => CoreError::DeployJournalPresent {
1042            run_id: run_id.as_str().to_string(),
1043            env: env.clone(),
1044            location: journal_location(
1045                LedgerRoot::for_node(&ctx.declared.target.nodes.master),
1046                &plan.gateway,
1047                &plan.mode,
1048            ),
1049        },
1050    }
1051}
1052
1053/// The steps of the module doc, in order. Returns `Ok` only for a verified,
1054/// finalized deploy: `applied`, or `applied_backup_unsynced` when the backup
1055/// phase could not prove the backup (the master is not restored for it).
1056async fn drive(
1057    ctx: &ApplyContext<'_>,
1058    plan: &DeployPlanResult,
1059    opts: &ApplyOptions,
1060    run_id: &RunId,
1061    run_dir: &RunDir,
1062    steps: &mut Steps<'_>,
1063) -> Result<ApplyOutcome, CoreError> {
1064    let declared = ctx.declared;
1065    let env = declared.env.as_str();
1066    let (gateway, mode) = (plan.gateway.as_str(), plan.mode.as_str());
1067
1068    let started_at = rfc3339_now_utc();
1069    steps.log = Some(EventLog::open(run_dir)?);
1070
1071    let set = write_set(plan, declared)?;
1072    steps.emit(ApplyEvent::RunStarted {
1073        run_id: run_id.as_str().to_string(),
1074        writes: set.writes.len(),
1075        deletes: set.deletes.len(),
1076    })?;
1077
1078    // Every preflight, before anything is staged, locked or written. A check
1079    // that could not be answered at all (an unreachable gateway or host) is
1080    // its own error, after the run record has been written.
1081    let touched_paths: Vec<String> = set
1082        .writes
1083        .iter()
1084        .map(|w| w.path.as_str().to_string())
1085        .chain(set.deletes.iter().map(|d| d.path.as_str().to_string()))
1086        .collect();
1087    let deleted_paths: Vec<String> = set
1088        .deletes
1089        .iter()
1090        .map(|d| d.path.as_str().to_string())
1091        .collect();
1092    let preflights = run_preflights(
1093        &PreflightInput {
1094            target: &declared.target,
1095            declared: Some(declared),
1096            master: ctx.deploy,
1097            backup: ctx.backup,
1098            gateway: ctx.gateway,
1099            reader: ctx.reader,
1100            host: ctx.host,
1101            touched_files: set.writes.len() + set.deletes.len(),
1102            touched_paths: &touched_paths,
1103            deleted_paths: &deleted_paths,
1104            hold: &opts.hold,
1105            designers: &opts.designers,
1106            clock: ctx.clock,
1107        },
1108        steps,
1109    )
1110    .await;
1111    let seen = preflights.as_ref().ok();
1112    run_dir.write_json(
1113        "run.json",
1114        &json!({
1115            "schema_version": 1,
1116            "run_id": run_id.as_str(),
1117            "env": env,
1118            "gateway": gateway,
1119            "mode": mode,
1120            "node": plan.node,
1121            "transport": plan.transport,
1122            "started_at": started_at,
1123            "run_dir_git": run_dir.git_status().as_record(),
1124            "render_sha256": plan.render_sha256,
1125            "plan_sha256": plan.plan_sha256,
1126            "hold_s": opts.hold.hold_s,
1127            "allow_destructive": opts.gates.allow_destructive,
1128            "allow_drift": opts.gates.allow_drift,
1129            "skip_gwbk": opts.skip_gwbk,
1130            "designers": opts.designers.mode.as_str(),
1131            "designers_timeout_s": opts.designers.timeout.as_secs(),
1132            "designers_seen": seen.map(|r| r.designers_seen
1133                .iter()
1134                .map(|(user, project, host)| json!({"user": user, "project": project, "host": host}))
1135                .collect::<Vec<_>>()),
1136            "preflights": seen.map(|r| r.findings
1137                .iter()
1138                .map(|f| json!({"check": f.check.as_str(), "status": f.status.as_str()}))
1139                .collect::<Vec<_>>()),
1140        }),
1141    )?;
1142    run_dir.write_json("plan.json", plan)?;
1143    let preflights = preflights?;
1144    if !preflights.passed() {
1145        steps.emit(ApplyEvent::PreflightChecks {
1146            checks: preflights
1147                .findings
1148                .iter()
1149                .map(|f| (f.check.as_str().to_string(), f.status.as_str().to_string()))
1150                .collect(),
1151        })?;
1152        // A unit the apply writes or deletes that the backup cannot read
1153        // (WR-02) is the same refusal as one the master cannot read, whatever
1154        // else failed: no flag permits it, and the backup's copy could never
1155        // be confirmed after the master was written.
1156        if !preflights.backup_unreadable.is_empty() {
1157            return Err(CoreError::DeployUnreadable {
1158                node: "backup".to_string(),
1159                keys: preflights.backup_unreadable,
1160            });
1161        }
1162        return Err(CoreError::DeployPreflightFailed {
1163            failures: preflights.failures().into(),
1164        });
1165    }
1166
1167    // The backup's own sync timestamp, sampled after the preflights read both
1168    // nodes and before anything is written (D-05, V5). This first sample is
1169    // the fail-fast read: one that cannot be read ends the run here, with its
1170    // own error, before any write. It is sampled again once the lock is held,
1171    // and that second sample is the baseline the backup must move past.
1172    let mut backup_before = match ctx.backup {
1173        Some(backup) => {
1174            let sampled = backup.status().await?.last_sync_epoch_ms();
1175            steps.emit(ApplyEvent::BackupSampled { last_sync: sampled })?;
1176            sampled
1177        }
1178        None => None,
1179    };
1180    // The host's clock at the same moment: a sync-exhausted log line older
1181    // than this run never ends its wait (WR-01). Moved up again at the second
1182    // sample below.
1183    let mut backup_log_since_ms = wall_clock_ms();
1184
1185    // The `.gwbk`, before staging and the lock so that it never spends hold
1186    // budget (D-13). A refusal here leaves the run record and writes nothing.
1187    let gwbk = take_gwbk(ctx, opts, run_id, run_dir, steps).await?;
1188
1189    // What is already waiting for a restart, so the run's own additions can
1190    // be told apart afterwards (an entry persists until the gateway restarts,
1191    // C-restart). Unreadable is `None`.
1192    let restart_before = ctx.deploy.restart_pending().await.ok();
1193
1194    // The ledger: what it lists now, and what it will list.
1195    let old_bytes = ctx.reader.read_ledger(gateway, mode).await?;
1196    let old_ledger = match &old_bytes {
1197        Some(bytes) => Some(Ledger::parse(
1198            &ledger_location(
1199                LedgerRoot::for_node(&declared.target.nodes.master),
1200                gateway,
1201                mode,
1202            ),
1203            bytes,
1204            gateway,
1205            mode,
1206        )?),
1207        None => None,
1208    };
1209    let ledger = new_ledger(plan, old_ledger.as_ref(), &set, run_id);
1210    let ledger_bytes = ledger.to_bytes();
1211    Ledger::parse(
1212        &ledger_location(
1213            LedgerRoot::for_node(&declared.target.nodes.master),
1214            gateway,
1215            mode,
1216        ),
1217        &ledger_bytes,
1218        gateway,
1219        mode,
1220    )?;
1221    let spec = batch_spec(
1222        plan,
1223        run_id,
1224        &set,
1225        ledger_bytes,
1226        old_bytes.as_deref().map(sha256_hex),
1227        commit_budget_s(&opts.hold),
1228    );
1229    if let Some(problem) = spec.problem() {
1230        return Err(CoreError::Internal(format!(
1231            "the batch the engine built is invalid: {problem}"
1232        )));
1233    }
1234
1235    let scope = RunScope {
1236        plan,
1237        run_id,
1238        run_dir,
1239        gwbk: gwbk.as_deref(),
1240        restart_before: restart_before.as_deref(),
1241        record: &spec.record,
1242    };
1243
1244    // Stage outside the lock, then hold the lock for the one commit.
1245    let prepared = ctx
1246        .host
1247        .prepare(&spec, &declared.outcome.tree)
1248        .await
1249        .map_err(|e| with_env(e, env))?;
1250    steps.emit(ApplyEvent::Staged {
1251        files: set.writes.len(),
1252    })?;
1253    let lock =
1254        match HeldLock::acquire(ctx.deploy, ScanKind::Config, opts.hold.hold_s, ctx.clock).await {
1255            Ok(lock) => lock,
1256            Err(refused) => {
1257                // Nothing was written into the collection. Drop this run's own
1258                // staging and nothing else; a failed cleanup must not hide why
1259                // the lock was refused.
1260                let _ = ctx.host.discard_interrupted(run_id).await;
1261                return Err(refused);
1262            }
1263        };
1264    steps.emit(ApplyEvent::LockAcquired {
1265        hold_s: opts.hold.hold_s,
1266    })?;
1267    // The baseline of the sync signal (WR-02): the staging above and the
1268    // `.gwbk` before it can take minutes, and a sync that landed on the
1269    // backup meanwhile (a Designer save, a provider change) is not this
1270    // deploy reaching it. Sampled after `LockAcquired` was delivered, so a
1271    // link cut by that observer (K3 b) is seen as it stands, and before the
1272    // commit, so only a sync after the first write counts as an advance. A
1273    // read that cannot be answered now keeps the first sample: the lock is
1274    // held and nothing has been written, and the backup phase reports the
1275    // unreadable backup itself. The read is bounded by
1276    // `LOCKED_SAMPLE_TIMEOUT`: the hold budget has no allowance for it, so a
1277    // slow backup must not spend the lock the commit needs.
1278    if let Some(backup) = ctx.backup {
1279        let since_ms = wall_clock_ms();
1280        if let Ok(Ok(status)) = tokio::time::timeout(LOCKED_SAMPLE_TIMEOUT, backup.status()).await {
1281            let sampled = status.last_sync_epoch_ms();
1282            steps.emit(ApplyEvent::BackupSampled { last_sync: sampled })?;
1283            backup_before = sampled;
1284            backup_log_since_ms = since_ms;
1285        }
1286    }
1287    let report = match ctx.host.commit(&prepared).await {
1288        Ok(report) => report,
1289        Err(e) => return Err(commit_failed(ctx, run_id, plan, steps, with_env(e, env)).await),
1290    };
1291    if let Err(e) = steps.emit(ApplyEvent::Committed {
1292        moved: report.moved.len(),
1293        deleted: report.deleted.len(),
1294    }) {
1295        // The event log failed with the journal possibly on the box: no
1296        // restore can be logged either, so the run is reported as it stands.
1297        return Err(commit_failed(ctx, run_id, plan, steps, e).await);
1298    }
1299    if report.stale {
1300        return Err(plan_stale(ctx, plan, opts, run_id).await);
1301    }
1302    if report.failure.is_some() {
1303        // The failure names an entry by index and never its content; the
1304        // restore puts back every touched path from the snapshot. A commit
1305        // that stopped on its time budget ran past the hold (WR-04): the
1306        // hold, not a file, is what failed.
1307        let cause = if report.deadline {
1308            RollbackCause::LockExpired
1309        } else {
1310            RollbackCause::WriteFailed
1311        };
1312        return roll_back(ctx, opts, &scope, Some(&lock), cause, steps, Vec::new()).await;
1313    }
1314
1315    // From here the files have moved and the journal is on the box: every
1316    // way out is a restore, a typed refusal or the verified deploy (CR-01).
1317    let mut warnings = match confirm(ctx, opts, &lock, steps).await {
1318        Ok(Confirmed::Green { warnings }) => warnings,
1319        Ok(Confirmed::Abort { cause, warnings }) => {
1320            return roll_back(ctx, opts, &scope, Some(&lock), cause, steps, warnings).await;
1321        }
1322        Err(Unconfirmed::Restore { cause, step, error }) => {
1323            // The deploy could not be shown good; fail closed like a red
1324            // verify. The step and the error's code (never its text) are kept.
1325            let note = format!("post_commit_error ({step}: {})", error.code());
1326            return roll_back(ctx, opts, &scope, Some(&lock), cause, steps, vec![note]).await;
1327        }
1328        Err(Unconfirmed::Green(error)) => {
1329            return Err(unfinished_after_green(ctx, run_id, plan, steps, error).await);
1330        }
1331    };
1332
1333    // Green: record what is live now, finalize, keep the snapshot locally.
1334    let post = match ctx.host.fingerprint(&spec.touched_paths()).await {
1335        Ok(post) => post,
1336        Err(e) => return Err(unfinished_after_green(ctx, run_id, plan, steps, e).await),
1337    };
1338    let post_fingerprint = fingerprint(&post);
1339    let mut record = spec.record.clone();
1340    record.state = RecordState::Applied;
1341    record.post_fingerprint = Some(post_fingerprint.clone());
1342    if let Err(e) = ctx.host.finalize(&record).await {
1343        // The record is installed before the journal is removed, so a journal
1344        // that is gone means the box finished and only the answer was lost.
1345        if !matches!(box_state(ctx, plan, run_id).await, BoxState::Absent) {
1346            return Err(unfinished_after_green(ctx, run_id, plan, steps, e).await);
1347        }
1348        warnings.push(format!("finalize_reported_error ({})", e.code()));
1349    }
1350    if steps.emit(ApplyEvent::Finalized).is_err() {
1351        warnings.push(EVENTS_UNWRITABLE.to_string());
1352    }
1353
1354    let (restart_pending, restart_pending_new) =
1355        restart_report(ctx.deploy, restart_before.as_deref(), &mut warnings).await;
1356    let mut snapshot_sha256 = BTreeMap::new();
1357    match ctx.host.fetch_snapshot(run_id, run_dir.path()).await {
1358        Ok(hashes) => {
1359            warnings.extend(snapshot_warnings(&spec.record, &hashes));
1360            snapshot_sha256 = hashes;
1361        }
1362        Err(e) => warnings.push(format!("snapshot_download_failed ({})", e.code())),
1363    }
1364
1365    // The backup phase, last: outside the scan-lock, after the master is
1366    // finalized and reported. It never undoes the master (D-08).
1367    let backup_report = match ctx.backup {
1368        Some(backup) => Some(
1369            converge::converge_backup(
1370                ctx,
1371                backup,
1372                opts,
1373                &set,
1374                converge::Baseline {
1375                    last_sync: backup_before,
1376                    log_since_ms: backup_log_since_ms,
1377                },
1378                steps,
1379                &mut warnings,
1380            )
1381            .await,
1382        ),
1383        None => None,
1384    };
1385    let kind = match &backup_report {
1386        Some(report) if !report.converged => OutcomeKind::AppliedBackupUnsynced,
1387        _ => OutcomeKind::Applied,
1388    };
1389    Ok(ApplyOutcome {
1390        kind,
1391        run_id: Some(run_id.as_str().to_string()),
1392        run_dir: Some(run_dir.path().to_string_lossy().into_owned()),
1393        cause: None,
1394        render_sha256: plan.render_sha256.clone(),
1395        plan_sha256: plan.plan_sha256.clone(),
1396        written: set
1397            .writes
1398            .iter()
1399            .map(|w| w.path.as_str().to_string())
1400            .collect(),
1401        deleted: set
1402            .deletes
1403            .iter()
1404            .map(|d| d.path.as_str().to_string())
1405            .collect(),
1406        post_fingerprint: Some(post_fingerprint),
1407        warnings,
1408        gwbk,
1409        skip_gwbk: opts.skip_gwbk,
1410        restart_pending,
1411        restart_pending_new,
1412        snapshot_sha256,
1413        backup: backup_report,
1414    })
1415}
1416
1417/// How the under-lock window after a clean commit ended when every step could
1418/// be answered.
1419enum Confirmed {
1420    /// The lock held, the scan completed and the verify is green.
1421    Green { warnings: Vec<String> },
1422    /// The deploy must not stay: restore with `cause`.
1423    Abort {
1424        cause: RollbackCause,
1425        warnings: Vec<String>,
1426    },
1427}
1428
1429/// A step after the commit that could not be answered at all.
1430enum Unconfirmed {
1431    /// Before a green verdict: the deploy is unproven, so it is restored
1432    /// like a red verify. `step` names the step, `cause` is the closest
1433    /// option-a cause.
1434    Restore {
1435        cause: RollbackCause,
1436        step: &'static str,
1437        error: CoreError,
1438    },
1439    /// After a green verdict (the event log): the deploy is verified.
1440    Green(CoreError),
1441}
1442
1443/// Judge the files the commit moved: the lock must still be ours with its
1444/// margin, the config scan must complete, the verify must be green. A step
1445/// that fails with an `Err` (a gateway or log error, an event that cannot be
1446/// written) is [`Unconfirmed::Restore`] with the closest cause: the lock read
1447/// is `lock_expired`, the scan `scan_incomplete`, the verify and its events
1448/// `verify_failed`.
1449async fn confirm(
1450    ctx: &ApplyContext<'_>,
1451    opts: &ApplyOptions,
1452    lock: &HeldLock,
1453    steps: &mut Steps<'_>,
1454) -> Result<Confirmed, Unconfirmed> {
1455    fn undone(cause: RollbackCause, step: &'static str) -> impl FnOnce(CoreError) -> Unconfirmed {
1456        move |error| Unconfirmed::Restore { cause, step, error }
1457    }
1458    let abort = |cause, warnings| Ok(Confirmed::Abort { cause, warnings });
1459
1460    // Only a lock still held, with the margin left, may be followed by a scan.
1461    let verdict = lock
1462        .check(ctx.deploy, LOCK_MARGIN_S, ctx.clock)
1463        .await
1464        .map_err(undone(RollbackCause::LockExpired, "lock_check"))?;
1465    let held = matches!(verdict, LockVerdict::Held { .. });
1466    steps
1467        .emit(ApplyEvent::LockVerdict { held })
1468        .map_err(undone(RollbackCause::LockExpired, "events"))?;
1469    if !held {
1470        // No scan before the restore: a scan would load the partial tree.
1471        return abort(RollbackCause::LockExpired, Vec::new());
1472    }
1473    // C11: a tag provider and its definitions load in one scan.
1474    match scan_and_wait(ctx.deploy, ScanKind::Config, opts.poll.clone())
1475        .await
1476        .map_err(undone(RollbackCause::ScanIncomplete, "scan"))?
1477    {
1478        ScanOutcome::Complete(_) => steps
1479            .emit(ApplyEvent::ScanDone)
1480            .map_err(undone(RollbackCause::ScanIncomplete, "events"))?,
1481        ScanOutcome::NotComplete { .. } => {
1482            return abort(RollbackCause::ScanIncomplete, Vec::new());
1483        }
1484    }
1485
1486    let verify = deploy_verify(
1487        ctx.declared,
1488        &opts.project,
1489        ctx.deploy,
1490        ctx.gateway,
1491        ctx.reader,
1492        &opts.verify,
1493    )
1494    .await
1495    .map_err(undone(RollbackCause::VerifyFailed, "verify"))?;
1496    if !verify.ok {
1497        steps
1498            .emit(ApplyEvent::VerifyDone { ok: false })
1499            .map_err(undone(RollbackCause::VerifyFailed, "events"))?;
1500        // The verdict is kept in the log by check id and status only.
1501        steps
1502            .emit(ApplyEvent::VerifyChecks {
1503                checks: verify
1504                    .checks
1505                    .iter()
1506                    .map(|c| (c.id.word().to_string(), c.status.word().to_string()))
1507                    .collect(),
1508            })
1509            .map_err(undone(RollbackCause::VerifyFailed, "events"))?;
1510        return abort(RollbackCause::VerifyFailed, verify.warnings);
1511    }
1512    steps
1513        .emit(ApplyEvent::VerifyDone { ok: true })
1514        .map_err(Unconfirmed::Green)?;
1515    Ok(Confirmed::Green {
1516        warnings: verify.warnings,
1517    })
1518}
1519
1520/// Read the pending restarts again and compare with what was pending before
1521/// the run. Returns `(pending now, pending that were not there before)`, both
1522/// `[]` when there are none. A list that could not be read, before or now, is
1523/// the one warning `restart_pending_unavailable`: an unreadable "before" is
1524/// treated as empty, so everything now pending is reported as new rather than
1525/// hidden. Read only (SF-3).
1526async fn restart_report(
1527    deploy: &dyn DeployApi,
1528    before: Option<&[String]>,
1529    warnings: &mut Vec<String>,
1530) -> (Vec<String>, Vec<String>) {
1531    let after = deploy.restart_pending().await.ok();
1532    if before.is_none() || after.is_none() {
1533        warnings.push(RESTART_UNAVAILABLE.to_string());
1534    }
1535    let pending = after.unwrap_or_default();
1536    let known = before.unwrap_or_default();
1537    let added = pending
1538        .iter()
1539        .filter(|entry| !known.contains(entry))
1540        .cloned()
1541        .collect();
1542    (pending, added)
1543}
1544
1545/// Warnings for the paths whose copy in the run directory is not the
1546/// pre-image the record holds (the on-box snapshot stays authoritative,
1547/// D-04). A created path has no pre-image and no copy, so it never counts.
1548fn snapshot_warnings(record: &RunRecord, copied: &BTreeMap<String, String>) -> Vec<String> {
1549    record
1550        .pre_map()
1551        .into_iter()
1552        .filter_map(|(path, pre)| {
1553            let pre = pre?;
1554            let key = if path == LEDGER_KEY {
1555                "ledger.json".to_string()
1556            } else {
1557                format!("files/{path}")
1558            };
1559            (copied.get(&key) != Some(&pre)).then(|| format!("snapshot_copy_differs ({path})"))
1560        })
1561        .collect()
1562}
1563
1564/// The `.gwbk` step (23-09, D-13, SF-6): unless `--skip-gwbk`, download the
1565/// gateway backup into `<run id>.gwbk` in the run directory and judge that it
1566/// is whole. Returns the file name, or `None` when skipped.
1567///
1568/// The file is created exclusively at 0600 before a byte is written, so it is
1569/// never readable by anyone else even for a moment (the download opens an
1570/// existing file and keeps its mode). `BackupType::All` because the archive is
1571/// a restore point for this same gateway (FA-23-06). It goes from the gateway
1572/// to the run directory and nowhere else.
1573///
1574/// Whole means: the ZIP central directory reads and has an entry (C-gwbk: the
1575/// gateway sends no Content-Length, and a cut copy has no central
1576/// directory). Where the gateway does send a length, the HTTP stack already
1577/// fails a body that stops short, which surfaces here as a failed download.
1578/// Any failure removes the file (it is useless and holds secrets) and is
1579/// `deploy_preflight_failed` with check `gwbk`; the detail names no content.
1580async fn take_gwbk(
1581    ctx: &ApplyContext<'_>,
1582    opts: &ApplyOptions,
1583    run_id: &RunId,
1584    run_dir: &RunDir,
1585    steps: &mut Steps<'_>,
1586) -> Result<Option<String>, CoreError> {
1587    if opts.skip_gwbk {
1588        steps.emit(ApplyEvent::GwbkSkipped)?;
1589        return Ok(None);
1590    }
1591    let refuse = |detail: String| CoreError::DeployPreflightFailed {
1592        failures: vec![PreflightFailure {
1593            check: GWBK_CHECK.to_string(),
1594            detail,
1595        }]
1596        .into(),
1597    };
1598    let name = format!("{}.gwbk", run_id.as_str());
1599    drop(run_dir.create_private_file(&name)?);
1600    let path = run_dir.path().join(&name);
1601    let verdict = match backup_download(ctx.gateway, Some(&path), "", BackupType::All).await {
1602        Ok(_) => validate_gwbk(&path),
1603        Err(e) => Err(format!("the .gwbk was not downloaded ({})", e.code())),
1604    };
1605    match verdict {
1606        Ok(bytes) => {
1607            steps.emit(ApplyEvent::GwbkDownloaded { bytes })?;
1608            Ok(Some(name))
1609        }
1610        Err(detail) => {
1611            let _ = std::fs::remove_file(&path);
1612            Err(refuse(detail))
1613        }
1614    }
1615}
1616
1617/// Judge a downloaded `.gwbk` by the file itself (C-gwbk): not empty, a ZIP
1618/// whose central directory reads, at least one entry. Returns its size.
1619fn validate_gwbk(path: &Path) -> Result<u64, String> {
1620    let bytes = std::fs::metadata(path)
1621        .map_err(|_| "the downloaded .gwbk cannot be read back".to_string())?
1622        .len();
1623    if bytes == 0 {
1624        return Err("the downloaded .gwbk is empty".to_string());
1625    }
1626    let file = std::fs::File::open(path)
1627        .map_err(|_| "the downloaded .gwbk cannot be read back".to_string())?;
1628    let archive = zip::ZipArchive::new(file).map_err(|_| {
1629        "the downloaded .gwbk is not a readable ZIP (no central directory), so the download is incomplete"
1630            .to_string()
1631    })?;
1632    if archive.is_empty() {
1633        return Err("the downloaded .gwbk is a ZIP with no entries".to_string());
1634    }
1635    Ok(bytes)
1636}
1637
1638/// What an abort needs to know about the run that is being undone.
1639struct RunScope<'a> {
1640    plan: &'a DeployPlanResult,
1641    run_id: &'a RunId,
1642    run_dir: &'a RunDir,
1643    /// The `.gwbk` taken before staging, if any.
1644    gwbk: Option<&'a str>,
1645    /// The pending restarts read before staging; `None` when unreadable.
1646    restart_before: Option<&'a [String]>,
1647    /// The record at state `applying`: its pre-images are what the restore
1648    /// puts back.
1649    record: &'a RunRecord,
1650}
1651
1652/// End a run that must not stay applied: restore the snapshot through
1653/// [`restore_snapshot`] and report the cause. Never `applied`.
1654async fn roll_back(
1655    ctx: &ApplyContext<'_>,
1656    opts: &ApplyOptions,
1657    scope: &RunScope<'_>,
1658    held: Option<&HeldLock>,
1659    cause: RollbackCause,
1660    steps: &mut Steps<'_>,
1661    mut warnings: Vec<String>,
1662) -> Result<ApplyOutcome, CoreError> {
1663    let restored = restore_snapshot(
1664        &ctx.restore_context(opts),
1665        &RestoreInput {
1666            run_id: scope.run_id,
1667            run_dir: scope.run_dir,
1668            record: scope.record,
1669            held,
1670            cause: Some(cause),
1671        },
1672        steps,
1673    )
1674    .await?;
1675    warnings.extend(restored.warnings);
1676    let (restart_pending, restart_pending_new) =
1677        restart_report(ctx.deploy, scope.restart_before, &mut warnings).await;
1678    Ok(ApplyOutcome {
1679        kind: OutcomeKind::RolledBack,
1680        run_id: Some(scope.run_id.as_str().to_string()),
1681        run_dir: Some(scope.run_dir.path().to_string_lossy().into_owned()),
1682        cause: Some(cause),
1683        render_sha256: scope.plan.render_sha256.clone(),
1684        plan_sha256: scope.plan.plan_sha256.clone(),
1685        written: Vec::new(),
1686        deleted: Vec::new(),
1687        post_fingerprint: Some(scope.record.pre_fingerprint.clone()),
1688        warnings,
1689        gwbk: scope.gwbk.map(str::to_string),
1690        skip_gwbk: opts.skip_gwbk,
1691        restart_pending,
1692        restart_pending_new,
1693        snapshot_sha256: restored.snapshot_sha256,
1694        backup: None,
1695    })
1696}
1697
1698/// What [`restore_snapshot`] is asked to undo.
1699pub struct RestoreInput<'a> {
1700    /// The run doing the restoring: for an abort or an auto-rollback the run
1701    /// itself, for a manual rollback the rollback run (it becomes the
1702    /// record's `rolled_back_by`).
1703    pub run_id: &'a RunId,
1704    /// That run's directory (named in `rollback_failed`; the snapshot copy
1705    /// is downloaded into it).
1706    pub run_dir: &'a RunDir,
1707    /// The record of the run being undone: its touched set and pre-images.
1708    pub record: &'a RunRecord,
1709    /// The scan-lock this engine still holds, when it does.
1710    pub held: Option<&'a HeldLock>,
1711    /// Why an apply is being undone; `None` for a manual rollback (23-11),
1712    /// which keeps the closed `data.cause` vocabulary of an apply as it is.
1713    pub cause: Option<RollbackCause>,
1714}
1715
1716/// What [`restore_snapshot`] drives. It names no declared repo: a manual
1717/// rollback (23-11) has only the target file and a run id.
1718pub struct RestoreContext<'a> {
1719    /// The `--env` value, named in the retry command of a `rollback_failed`.
1720    pub env: &'a str,
1721    /// The target's master node (where the journal lives, for the error).
1722    pub master: &'a NodeTarget,
1723    /// The write seam of the master's host.
1724    pub host: &'a dyn ApplyHost,
1725    /// The gateway REST surface (lock, scan, mode).
1726    pub deploy: &'a dyn DeployApi,
1727    /// The monotonic clock the lock is judged by.
1728    pub clock: &'a dyn Clock,
1729    /// The hold a fresh lock is requested with, and its estimates.
1730    pub hold: &'a HoldModel,
1731    /// How the scan wait polls.
1732    pub poll: &'a PollConfig,
1733}
1734
1735/// What a finished restore did.
1736#[derive(Debug, Clone, PartialEq, Eq)]
1737pub struct RestoreReport {
1738    /// Paths put back from the snapshot.
1739    pub restored: usize,
1740    /// Files this run created that the restore removed.
1741    pub removed: usize,
1742    /// Whether the restore took a fresh scan-lock instead of reusing the
1743    /// held one.
1744    pub fresh_lock: bool,
1745    /// Non-failing notes.
1746    pub warnings: Vec<String>,
1747    /// SHA-256 of each file copied into the run directory's `snapshot/`,
1748    /// keyed by its path below it.
1749    pub snapshot_sha256: BTreeMap<String, String>,
1750}
1751
1752/// The one restore path (abort, auto-rollback and, from 23-11, manual
1753/// rollback). In this order:
1754///
1755/// 1. `restore_started`;
1756/// 2. the lock: the held one is reused when the gateway says it is still
1757///    ours with the restore's estimated time plus [`LOCK_MARGIN_S`] left (the
1758///    local clock is not consulted: it can only read shorter); if it is
1759///    ours but too short the restore waits for it to lapse; then, or when
1760///    there is no held lock, a fresh hold is taken (a holder's own
1761///    re-acquire is a 409, C1d, so it is never tried while the hold is still
1762///    ours);
1763/// 3. exactly one `ApplyHost::restore`;
1764/// 4. exactly one config scan, only now (research Pitfall 1);
1765/// 5. the restore check: the touched set's live hashes equal the record's
1766///    pre-images, the scan completed, `deploymentMode` is the target's.
1767///    Never `deploy_verify`: after a restore the declared tree is no longer
1768///    the expected state (research Pitfall 9);
1769/// 6. the record to `rolled_back`, `ApplyHost::finalize` (installs it, drops
1770///    the journal and staging), `restored`, and the snapshot copy into the
1771///    run directory (a failure is a warning).
1772///
1773/// Any failure is [`CoreError::RollbackFailed`] naming the run, its
1774/// directory and the on-box journal, which stays (D-12).
1775pub async fn restore_snapshot(
1776    ctx: &RestoreContext<'_>,
1777    input: &RestoreInput<'_>,
1778    steps: &mut Steps<'_>,
1779) -> Result<RestoreReport, CoreError> {
1780    let record = input.record;
1781    match restore_steps(ctx, input, steps).await {
1782        Ok(report) => Ok(report),
1783        Err(cause) => Err(CoreError::RollbackFailed(Box::new(RollbackFailure {
1784            run_id: record.run_id.clone(),
1785            env: ctx.env.to_string(),
1786            run_dir: Some(input.run_dir.path().to_string_lossy().into_owned()),
1787            journal: journal_location(
1788                LedgerRoot::for_node(ctx.master),
1789                &record.gateway,
1790                &record.mode,
1791            ),
1792            cause,
1793        }))),
1794    }
1795}
1796
1797/// The steps of [`restore_snapshot`]; an `Err` is a value-free reason.
1798async fn restore_steps(
1799    ctx: &RestoreContext<'_>,
1800    input: &RestoreInput<'_>,
1801    steps: &mut Steps<'_>,
1802) -> Result<RestoreReport, String> {
1803    // A gateway error is named by its code only; a host error already names
1804    // entries by index and never their content.
1805    let gw = |step: &'static str| move |e: CoreError| format!("{step}: {}", e.code());
1806    let host = |step: &'static str| move |e: CoreError| format!("{step}: {}", with_env(e, ctx.env));
1807    let event = |e: CoreError| format!("events: {}", e.code());
1808
1809    let record = input.record;
1810    let deploy = ctx.deploy;
1811    steps
1812        .emit(ApplyEvent::RestoreStarted { cause: input.cause })
1813        .map_err(event)?;
1814
1815    // The lock.
1816    let need_s = ctx.hold.estimate_commit_s(record.touched.len()).ceil() as i64 + LOCK_MARGIN_S;
1817    let reuse = match input.held {
1818        Some(lock) => match lock
1819            .check_gateway(deploy, need_s)
1820            .await
1821            .map_err(gw("checking the scan-lock"))?
1822        {
1823            LockVerdict::Held { .. } => true,
1824            LockVerdict::Expiring { .. } => {
1825                lock.wait_for_lapse(deploy, ctx.poll.interval)
1826                    .await
1827                    .map_err(gw("waiting for the scan-lock to lapse"))?;
1828                false
1829            }
1830            LockVerdict::NotHeld => false,
1831        },
1832        None => false,
1833    };
1834    if !reuse {
1835        HeldLock::acquire(deploy, ScanKind::Config, ctx.hold.hold_s, ctx.clock)
1836            .await
1837            .map_err(gw("taking the scan-lock"))?;
1838        steps
1839            .emit(ApplyEvent::LockAcquired {
1840                hold_s: ctx.hold.hold_s,
1841            })
1842            .map_err(event)?;
1843    }
1844
1845    // One restore call, then one scan.
1846    let report = ctx.host.restore(record).await.map_err(host("restore"))?;
1847    if let Some(failure) = &report.failure {
1848        return Err(format!("restore: {failure}"));
1849    }
1850    if report.stale {
1851        return Err("restore: the host reported a stale pre-image".to_string());
1852    }
1853    match scan_and_wait(deploy, ScanKind::Config, ctx.poll.clone())
1854        .await
1855        .map_err(gw("the scan after the restore"))?
1856    {
1857        ScanOutcome::Complete(_) => steps.emit(ApplyEvent::ScanDone).map_err(event)?,
1858        ScanOutcome::NotComplete { .. } => {
1859            return Err("the scan after the restore did not complete".to_string());
1860        }
1861    }
1862
1863    // The restore check (Pitfall 9).
1864    let touched: Vec<String> = record.touched.iter().map(|e| e.path.clone()).collect();
1865    let live = ctx
1866        .host
1867        .fingerprint(&touched)
1868        .await
1869        .map_err(host("reading the restored files"))?;
1870    let pre = record.pre_map();
1871    let differing: Vec<String> = pre
1872        .iter()
1873        .filter(|(path, hash)| live.get(*path) != Some(*hash))
1874        .map(|(path, _)| path.clone())
1875        .collect();
1876    if !differing.is_empty() {
1877        return Err(format!(
1878            "{} restored path(s) differ from the snapshot: {}",
1879            differing.len(),
1880            listed(&differing)
1881        ));
1882    }
1883    let mode = deploy
1884        .deployment_mode()
1885        .await
1886        .map_err(gw("reading the deployment mode"))?;
1887    if mode.as_deref() != Some(record.mode.as_str()) {
1888        return Err("the gateway's deployment mode is not the target's".to_string());
1889    }
1890
1891    // Done: install the final record, drop the journal and the staging.
1892    let mut rolled_back = record.clone();
1893    rolled_back.state = RecordState::RolledBack;
1894    rolled_back.rolled_back_by = Some(input.run_id.as_str().to_string());
1895    rolled_back.post_fingerprint = Some(record.pre_fingerprint.clone());
1896    ctx.host
1897        .finalize(&rolled_back)
1898        .await
1899        .map_err(host("finalize"))?;
1900    steps.emit(ApplyEvent::Restored).map_err(event)?;
1901
1902    let mut warnings = Vec::new();
1903    let mut snapshot_sha256 = BTreeMap::new();
1904    match RunId::parse(&record.run_id) {
1905        Ok(restored_run) => {
1906            match ctx
1907                .host
1908                .fetch_snapshot(&restored_run, input.run_dir.path())
1909                .await
1910            {
1911                Ok(hashes) => {
1912                    warnings.extend(snapshot_warnings(record, &hashes));
1913                    snapshot_sha256 = hashes;
1914                }
1915                Err(e) => warnings.push(format!("snapshot_download_failed ({})", e.code())),
1916            }
1917        }
1918        Err(e) => warnings.push(format!("snapshot_download_failed ({})", e.code())),
1919    }
1920    Ok(RestoreReport {
1921        restored: report.moved.len(),
1922        removed: report.deleted.len(),
1923        fresh_lock: !reuse,
1924        warnings,
1925        snapshot_sha256,
1926    })
1927}
1928
1929/// Up to [`crate::error::APPLY_LIST_SHOWN`] paths, then `and N more`.
1930fn listed(paths: &[String]) -> String {
1931    let shown = paths.len().min(crate::error::APPLY_LIST_SHOWN);
1932    let mut out = paths[..shown].join(", ");
1933    if paths.len() > shown {
1934        out.push_str(&format!(" and {} more", paths.len() - shown));
1935    }
1936    out
1937}
1938
1939/// The pre-image check under the lock found a live file that is not what the
1940/// plan was made against. The host moved nothing and removed its journal and
1941/// partial snapshot; this drops the run's staging, issues one no-op scan to
1942/// release the lock (D12) and names the plan as it reads now.
1943async fn plan_stale(
1944    ctx: &ApplyContext<'_>,
1945    plan: &DeployPlanResult,
1946    opts: &ApplyOptions,
1947    run_id: &RunId,
1948) -> CoreError {
1949    // Best effort: the refusal below is the answer either way, and an
1950    // unreleased lock lapses by itself.
1951    let _ = ctx.host.discard_interrupted(run_id).await;
1952    let _ = ctx.deploy.scan_request(ScanKind::Config).await;
1953    let actual = match deploy_plan(
1954        ctx.declared,
1955        ctx.reader,
1956        &PlanOptions {
1957            base: opts.base.clone(),
1958            diff: false,
1959        },
1960    )
1961    .await
1962    {
1963        Ok(now) => now.plan_sha256,
1964        Err(_) => "unavailable".to_string(),
1965    };
1966    CoreError::PlanStale {
1967        expected: plan.plan_sha256.clone(),
1968        actual,
1969        detail: "a live file changed between the plan and the lock; nothing was written"
1970            .to_string(),
1971    }
1972}
1973
1974#[cfg(test)]
1975mod tests {
1976    use super::*;
1977
1978    fn record(touched: &[(&str, Option<&str>)]) -> RunRecord {
1979        let touched: Vec<TouchedEntry> = touched
1980            .iter()
1981            .map(|(path, pre)| TouchedEntry {
1982                path: (*path).to_string(),
1983                pre_sha256: pre.map(str::to_string),
1984                post_sha256: Some("f".repeat(64)),
1985            })
1986            .collect();
1987        RunRecord {
1988            schema_version: 1,
1989            run_id: "20261016T000000Z-aaaaaa".to_string(),
1990            gateway: "gw".to_string(),
1991            mode: "dev".to_string(),
1992            render_sha256: "a".repeat(64),
1993            plan_sha256: "b".repeat(64),
1994            state: RecordState::Applying,
1995            rolled_back_by: None,
1996            touched,
1997            pre_fingerprint: "c".repeat(64),
1998            post_fingerprint: None,
1999        }
2000    }
2001
2002    #[test]
2003    fn the_commit_budget_is_the_hold_less_its_margin_and_never_zero() {
2004        let hold = |hold_s| HoldModel {
2005            hold_s,
2006            ..HoldModel::MEASURED
2007        };
2008        // 176 s of hold leaves 174 s: the scan can still be triggered inside
2009        // the hold when the host stops at its budget.
2010        assert_eq!(commit_budget_s(&hold(176)), 174);
2011        assert_eq!(commit_budget_s(&HoldModel::MEASURED), 174);
2012        // A tiny hold still gives the commit one second, and a huge one is
2013        // capped at what a work list can carry.
2014        assert_eq!(commit_budget_s(&hold(2)), 1);
2015        assert_eq!(commit_budget_s(&hold(0)), 1);
2016        assert_eq!(commit_budget_s(&hold(1_000_000)), BUDGET_MAX_S);
2017    }
2018
2019    #[test]
2020    fn snapshot_warnings_name_a_differing_or_missing_copy_and_skip_created_paths() {
2021        let (h1, h2) = ("1".repeat(64), "2".repeat(64));
2022        let rec = record(&[
2023            ("a/config.json", Some(&h1)),
2024            ("b/config.json", Some(&h1)),
2025            ("c/config.json", None),
2026            (LEDGER_KEY, Some(&h2)),
2027        ]);
2028        let mut copied = BTreeMap::new();
2029        copied.insert("files/a/config.json".to_string(), h1.clone());
2030        copied.insert("files/b/config.json".to_string(), h2.clone());
2031        copied.insert("ledger.json".to_string(), h2);
2032        // a matches, b differs, c was created (no copy needed), the ledger
2033        // matches.
2034        assert_eq!(
2035            snapshot_warnings(&rec, &copied),
2036            ["snapshot_copy_differs (b/config.json)"]
2037        );
2038        // A copy that is missing counts as different.
2039        copied.remove("files/a/config.json");
2040        assert_eq!(
2041            snapshot_warnings(&rec, &copied),
2042            [
2043                "snapshot_copy_differs (a/config.json)",
2044                "snapshot_copy_differs (b/config.json)"
2045            ]
2046        );
2047    }
2048}