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}