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