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}