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