Skip to main content

ignition_core/deploy/apply/
host.rs

1//! The one write seam the apply engine uses (Phase 23, D-01).
2//!
3//! [`ApplyHost`] is everything the engine asks of a gateway host while it
4//! applies and rolls back: one call that does all of the work inside the scan
5//! lock (`commit`), one that undoes it (`restore`), and the read-only and
6//! housekeeping calls around them. Nothing in the lock window costs a
7//! per-file network round trip: `prepare` stages every file before the lock,
8//! `commit` is then a single host call that works from the staged copies.
9//!
10//! Three hosts implement it: the local filesystem (`LocalFsTransport`, native
11//! calls), and a POSIX shell script and a PowerShell script over ssh (23-06,
12//! 23-20). All three are held to one scenario table
13//! (`tests/deploy_host_conformance.rs`).
14//!
15//! # What `commit` does, and in what order
16//!
17//! Nowhere else and in no other order (D-01, D-04):
18//!
19//! 1. create `journal.json` exclusively at phase `snapshotting` (an existing
20//!    journal refuses, nothing written);
21//! 2. check every touched path's live hash against its expected pre-image
22//!    (a mismatch aborts, nothing moved: the journal and the partial snapshot
23//!    are removed and the report is `stale`);
24//! 3. copy each existing pre-image and the prior ledger into
25//!    `snapshots/<run_id>/` and verify the copies;
26//! 4. write `record.json` at state `applying`;
27//! 5. journal phase `ready`, then phase `writing`;
28//! 6. move each staged file over its target;
29//! 7. remove the delete set and the directories the deletes emptied (never
30//!    `<module>/<type>` or above);
31//! 8. swap the ledger last;
32//! 9. journal phase `written`.
33//!
34//! # What `restore` does
35//!
36//! Writes the journal at phase `restoring` first, copies (never moves) each
37//! pre-image back from the snapshot and verifies its hash, deletes each
38//! touched file whose pre-image is absent, restores or removes the ledger,
39//! and leaves the snapshot in place, so a second `restore` gives the same
40//! tree.
41//!
42//! Every path a host writes or deletes is checked with
43//! `mirror_path_problem` before any I/O, and no host follows a symlink.
44
45use std::collections::BTreeMap;
46use std::path::Path;
47
48use super::fingerprint::LEDGER_KEY;
49use super::journal::{Journal, JournalPhase};
50use super::record::{RecordState, RunRecord};
51use super::run_store::RunId;
52use crate::deploy::layout::RelPath;
53use crate::deploy::ledger::LEDGER_MAX_BYTES;
54use crate::deploy::transport::{mirror_mode_problem, mirror_path_problem};
55use crate::deploy::tree::{RenderedTree, sha256_hex};
56use crate::error::CoreError;
57
58/// One file the run writes: a new file or a replacement.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct WriteOp {
61    /// The collection path.
62    pub path: RelPath,
63    /// SHA-256 of the staged bytes, lowercase hex.
64    pub sha256: String,
65    /// The live hash the plan was made against; `None` when the file must be
66    /// absent.
67    pub pre: Option<String>,
68}
69
70/// One file the run deletes.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct DeleteOp {
73    /// The collection path.
74    pub path: RelPath,
75    /// The live hash the plan was made against.
76    pub pre: String,
77}
78
79/// Everything one `prepare` and `commit` need, decided by the engine.
80///
81/// `writes` and `deletes` are byte-sorted and unique and disjoint. The
82/// journal and record are the run's documents as the engine wants them; the
83/// host sets the journal `phase` itself at each step.
84#[derive(Debug, Clone)]
85pub struct BatchSpec {
86    /// The run.
87    pub run_id: RunId,
88    /// The target gateway directory name.
89    pub gateway: String,
90    /// The target mode collection.
91    pub mode: String,
92    /// The files to write.
93    pub writes: Vec<WriteOp>,
94    /// The files to delete.
95    pub deletes: Vec<DeleteOp>,
96    /// The new ledger bytes (already validated with `Ledger::parse`), swapped
97    /// last. `None` leaves the ledger alone.
98    pub ledger_new: Option<Vec<u8>>,
99    /// The live ledger hash the plan was made against; `None` when there is no
100    /// ledger yet.
101    pub ledger_pre: Option<String>,
102    /// The journal of this run.
103    pub journal: Journal,
104    /// The run record at state `applying`.
105    pub record: RunRecord,
106    /// The seconds, from the start of the commit, within which the moves and
107    /// deletes must be done: the scan-lock hold less its margin. A host stops
108    /// before the next move, delete or retry once it is spent and reports
109    /// [`BatchReport::deadline`]. `None` sets no limit.
110    pub commit_budget_s: Option<u32>,
111}
112
113impl BatchSpec {
114    /// The paths this run touches, byte-sorted: every write, every delete and
115    /// [`LEDGER_KEY`] when the ledger is swapped.
116    pub fn touched_paths(&self) -> Vec<String> {
117        let mut paths: Vec<String> = self
118            .writes
119            .iter()
120            .map(|w| w.path.as_str().to_string())
121            .chain(self.deletes.iter().map(|d| d.path.as_str().to_string()))
122            .collect();
123        if self.ledger_new.is_some() {
124            paths.push(LEDGER_KEY.to_string());
125        }
126        paths.sort();
127        paths
128    }
129
130    /// The first thing wrong with this spec, or `None`. A host calls this
131    /// before any I/O; the engine builds a valid spec, so a problem here is a
132    /// defect, reported value-free.
133    pub fn problem(&self) -> Option<String> {
134        self.check().err()
135    }
136
137    fn check(&self) -> Result<(), String> {
138        if let Some(why) = crate::deploy::component_problem(&self.gateway) {
139            return Err(format!("the gateway {why}"));
140        }
141        if let Some(why) = mirror_mode_problem(&self.mode) {
142            return Err(why);
143        }
144        let run = self.run_id.as_str();
145        if self.journal.run_id != run || self.record.run_id != run {
146            return Err("the journal or record names a different run".to_string());
147        }
148        for (what, gateway, mode) in [
149            ("journal", &self.journal.gateway, &self.journal.mode),
150            ("record", &self.record.gateway, &self.record.mode),
151        ] {
152            if *gateway != self.gateway || *mode != self.mode {
153                return Err(format!("the {what} names a different gateway or mode"));
154            }
155        }
156        if self.record.state != RecordState::Applying {
157            return Err("the record is not at state applying".to_string());
158        }
159        sorted_unique(self.writes.iter().map(|w| w.path.as_str()), "writes")?;
160        sorted_unique(self.deletes.iter().map(|d| d.path.as_str()), "deletes")?;
161        for op in &self.writes {
162            if let Some(why) = mirror_path_problem(&op.path) {
163                return Err(why);
164            }
165            if !is_hex64(&op.sha256) || op.pre.as_deref().is_some_and(|p| !is_hex64(p)) {
166                return Err(format!(
167                    "a hash of {} is not 64 lowercase hex digits",
168                    op.path
169                ));
170            }
171        }
172        for op in &self.deletes {
173            if let Some(why) = mirror_path_problem(&op.path) {
174                return Err(why);
175            }
176            if !is_hex64(&op.pre) {
177                return Err(format!("the pre-image hash of {} is not hex", op.path));
178            }
179            if self.writes.iter().any(|w| w.path == op.path) {
180                return Err(format!("{} is both written and deleted", op.path));
181            }
182        }
183        match (&self.ledger_new, &self.ledger_pre) {
184            (None, Some(_)) => {
185                return Err("a prior ledger hash without a new ledger".to_string());
186            }
187            (Some(bytes), _) if bytes.is_empty() || bytes.len() > LEDGER_MAX_BYTES => {
188                return Err("the new ledger is empty or over the size cap".to_string());
189            }
190            _ => {}
191        }
192        if self.ledger_pre.as_deref().is_some_and(|p| !is_hex64(p)) {
193            return Err("the prior ledger hash is not hex".to_string());
194        }
195
196        // The documents describe exactly this batch.
197        let touched = self.touched_paths();
198        if self.journal.touched != touched {
199            return Err("the journal's touched set is not this batch's".to_string());
200        }
201        let record_paths: Vec<&str> = self
202            .record
203            .touched
204            .iter()
205            .map(|e| e.path.as_str())
206            .collect();
207        if record_paths != touched.iter().map(String::as_str).collect::<Vec<_>>() {
208            return Err("the record's touched set is not this batch's".to_string());
209        }
210        for entry in &self.record.touched {
211            let (pre, post): (Option<&str>, Option<String>) = if entry.path == LEDGER_KEY {
212                (
213                    self.ledger_pre.as_deref(),
214                    self.ledger_new.as_deref().map(sha256_hex),
215                )
216            } else if let Some(w) = self.writes.iter().find(|w| w.path.as_str() == entry.path) {
217                (w.pre.as_deref(), Some(w.sha256.clone()))
218            } else if let Some(d) = self.deletes.iter().find(|d| d.path.as_str() == entry.path) {
219                (Some(d.pre.as_str()), None)
220            } else {
221                return Err("the record names a path outside the batch".to_string());
222            };
223            if entry.pre_sha256.as_deref() != pre || entry.post_sha256 != post {
224                return Err(format!(
225                    "the record's hashes for {} are not this batch's",
226                    super::journal::shown(&entry.path)
227                ));
228            }
229        }
230
231        // What gets installed must read back through the strict parsers.
232        for phase in PRERENDERED_PHASES {
233            let journal = self.journal_at(phase);
234            Journal::parse("journal", &journal.to_bytes(), &self.gateway, &self.mode)
235                .map_err(|e| e.to_string())?;
236        }
237        RunRecord::parse("record", &self.record.to_bytes(), &self.gateway, &self.mode)
238            .map_err(|e| e.to_string())?;
239        Ok(())
240    }
241
242    /// The journal of this run at `phase`.
243    pub fn journal_at(&self, phase: JournalPhase) -> Journal {
244        Journal {
245            phase,
246            ..self.journal.clone()
247        }
248    }
249}
250
251/// The journal phases `commit` installs, in order.
252pub const PRERENDERED_PHASES: [JournalPhase; 4] = [
253    JournalPhase::Snapshotting,
254    JournalPhase::Ready,
255    JournalPhase::Writing,
256    JournalPhase::Written,
257];
258
259/// The journal a `restore` of `record` installs first: phase `restoring`,
260/// the record's touched set and pre-run fingerprint.
261pub fn restoring_journal(record: &RunRecord) -> Journal {
262    Journal {
263        schema_version: record.schema_version,
264        run_id: record.run_id.clone(),
265        gateway: record.gateway.clone(),
266        mode: record.mode.clone(),
267        phase: JournalPhase::Restoring,
268        pre_fingerprint: record.pre_fingerprint.clone(),
269        touched: record.touched.iter().map(|e| e.path.clone()).collect(),
270    }
271}
272
273fn is_hex64(text: &str) -> bool {
274    text.len() == 64
275        && text
276            .bytes()
277            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
278}
279
280fn sorted_unique<'a>(paths: impl Iterator<Item = &'a str>, what: &str) -> Result<(), String> {
281    let mut previous: Option<&str> = None;
282    for path in paths {
283        if previous.is_some_and(|p| p.as_bytes() >= path.as_bytes()) {
284            return Err(format!("{what} are not byte-sorted and unique"));
285        }
286        previous = Some(path);
287    }
288    Ok(())
289}
290
291/// What `prepare` staged. Opaque to the engine: it hands the value back to
292/// `commit` unchanged.
293#[derive(Debug, Clone, PartialEq, Eq)]
294pub struct Prepared {
295    run_id: RunId,
296    staging: String,
297    work_sha256: String,
298}
299
300impl Prepared {
301    /// A prepared batch of `run_id` staged at `staging` with the work list
302    /// hashing to `work_sha256`. Called by a host.
303    pub fn new(run_id: RunId, staging: String, work_sha256: String) -> Self {
304        Prepared {
305            run_id,
306            staging,
307            work_sha256,
308        }
309    }
310
311    /// The run.
312    pub fn run_id(&self) -> &RunId {
313        &self.run_id
314    }
315
316    /// Where the batch is staged, as the host spells it.
317    pub fn staging(&self) -> &str {
318        &self.staging
319    }
320
321    /// SHA-256 of the staged work list, lowercase hex.
322    pub fn work_sha256(&self) -> &str {
323        &self.work_sha256
324    }
325}
326
327/// What `commit` or `restore` did. Indices point into the spec's `writes`
328/// and `deletes` for `commit`, and into the record's `touched` for `restore`.
329#[derive(Debug, Clone, PartialEq, Eq)]
330pub struct BatchReport {
331    /// The last journal phase the call installed.
332    pub phase: JournalPhase,
333    /// The pre-image check found a live file that is not what the plan was
334    /// made against. Nothing was moved; `failure` says how many paths and
335    /// which (value-free).
336    pub stale: bool,
337    /// Writes (`commit`) or pre-images put back (`restore`) that completed.
338    pub moved: Vec<usize>,
339    /// Deletes (`commit`) or created files removed (`restore`) that
340    /// completed.
341    pub deleted: Vec<usize>,
342    /// Directories removed because a delete emptied them.
343    pub removed_dirs: Vec<String>,
344    /// Why the call stopped early, naming the entry by index and never its
345    /// content; `None` when it ran to the end.
346    pub failure: Option<String>,
347    /// The call stopped because the spec's `commit_budget_s` ran out, not
348    /// because an entry failed. `failure` is set too. The engine ends such a
349    /// run as a restore with the cause `lock_expired`.
350    pub deadline: bool,
351}
352
353/// What `probe` found.
354#[derive(Debug, Clone, PartialEq, Eq)]
355pub struct ProbeReport {
356    /// The mode collection and the `ign-deploy` root can be written.
357    pub writable: bool,
358    /// What is wrong, value-free; empty when `writable`.
359    pub problems: Vec<String>,
360}
361
362/// The write seam of a gateway host. See the module doc for the order of
363/// `commit` and `restore`.
364#[async_trait::async_trait]
365pub trait ApplyHost: Send + Sync {
366    /// Whether the mode collection and the `ign-deploy` root are writable.
367    /// Outside the lock. Creates and removes one probe file in the
368    /// `ign-deploy` root and never writes into the collection.
369    async fn probe(&self) -> Result<ProbeReport, CoreError>;
370
371    /// [`probe`](Self::probe) for a run that will touch `touched` (collection
372    /// paths and [`LEDGER_KEY`]). A host with limits of its own on what a path
373    /// may be reports them here, before anything is written: the PowerShell
374    /// host refuses a path whose snapshot or staging copy under the account
375    /// home the sftp server could not reach again (23-08, C7b). The default
376    /// has no such limit and answers [`probe`](Self::probe).
377    async fn probe_paths(&self, touched: &[String]) -> Result<ProbeReport, CoreError> {
378        let _ = touched;
379        self.probe().await
380    }
381
382    /// Stage the batch beside its work list: every file of `spec.writes` from
383    /// `tree` (re-hashed after the copy), the new ledger, the work list and
384    /// the pre-rendered journal and record files. Outside the lock. Writes
385    /// nothing into the collection and nothing outside this run's staging
386    /// directory.
387    async fn prepare(&self, spec: &BatchSpec, tree: &RenderedTree) -> Result<Prepared, CoreError>;
388
389    /// Journal, check, snapshot, move, delete, swap the ledger: exactly one
390    /// host call, inside the lock. A refusal that wrote nothing is an `Err`;
391    /// a pre-image mismatch is `Ok` with `stale`; a failure after the first
392    /// move is `Ok` with `failure` naming the entry.
393    async fn commit(&self, prepared: &Prepared) -> Result<BatchReport, CoreError>;
394
395    /// Undo `record`'s run from its snapshot: one call. Callable any time the
396    /// snapshot exists, in or out of the lock. Idempotent.
397    async fn restore(&self, record: &RunRecord) -> Result<BatchReport, CoreError>;
398
399    /// The live hash of each of `paths` (collection paths or
400    /// [`LEDGER_KEY`]), `None` when absent. Read-only; a path that is not a
401    /// regular file is an error, never `None`.
402    async fn fingerprint(
403        &self,
404        paths: &[String],
405    ) -> Result<BTreeMap<String, Option<String>>, CoreError>;
406
407    /// Install `record` as the run's final record, then remove the journal
408    /// and this run's staging. The snapshot stays. Refuses a journal that
409    /// belongs to another run.
410    async fn finalize(&self, record: &RunRecord) -> Result<(), CoreError>;
411
412    /// The run record of `run_id`; `None` when the run has no snapshot
413    /// directory or no record. Fails closed on an unreadable or invalid one.
414    async fn read_record(&self, run_id: &RunId) -> Result<Option<RunRecord>, CoreError>;
415
416    /// Copy `snapshots/<run_id>/` into `<dest>/snapshot/` on the runner,
417    /// files private (0600 on unix), and return each copied file's SHA-256
418    /// keyed by its path below the snapshot directory.
419    async fn fetch_snapshot(
420        &self,
421        run_id: &RunId,
422        dest: &Path,
423    ) -> Result<BTreeMap<String, String>, CoreError>;
424
425    /// Drop a run that never moved a file: remove `journal.json`,
426    /// `snapshots/<run_id>` and `staging/<run_id>`. Never touches the
427    /// collection or the ledger.
428    async fn discard_interrupted(&self, run_id: &RunId) -> Result<(), CoreError>;
429}