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