Skip to main content

ignition_core/deploy/apply/
record.rs

1//! The on-box run record, format v1 (Phase 23, D-04, D-07).
2//!
3//! One record per apply run, kept on the gateway host next to the snapshot
4//! (`snapshots/<run_id>/record.json`). It is what `rollback --run` checks the
5//! live files against, so it must be readable by any later runner: the
6//! layout is one-way and strict (see [`super::journal`] for the rules the two
7//! formats share: 8 MiB cap, strict UTF-8, no duplicate key, no unknown field,
8//! every field present).
9//!
10//! ```json
11//! {
12//!   "schema_version": 1,
13//!   "run_id": "<[A-Za-z0-9._-]{1,128}>",
14//!   "gateway": "<target gateway>",
15//!   "mode": "<target mode>",
16//!   "render_sha256": "<64 lowercase hex>",
17//!   "plan_sha256": "<64 lowercase hex>",
18//!   "state": "applying | applied | rolled_back",
19//!   "rolled_back_by": "<run id>" | null,
20//!   "touched": [
21//!     { "path": "<collection path or ledger.json>",
22//!       "pre_sha256": "<64 hex>" | null, "post_sha256": "<64 hex>" | null }
23//!   ],
24//!   "pre_fingerprint": "<64 lowercase hex>",
25//!   "post_fingerprint": "<64 lowercase hex>" | null
26//! }
27//! ```
28//!
29//! `null` hashes mean the path is absent before or after the run.
30//! `touched` is byte-sorted by path and unique. `post_fingerprint` is null
31//! while the state is `applying`; `rolled_back_by` is null unless the state
32//! is `rolled_back`. [`RunRecord::pre_map`] and [`RunRecord::post_map`] give
33//! the maps [`super::fingerprint::fingerprint`] takes.
34
35use std::collections::BTreeMap;
36
37use serde::{Deserialize, Serialize};
38
39use super::journal::{
40    canonical_bytes, check_header, check_touched, is_hex64, parse_strict, refuse,
41};
42use super::run_store::RunId;
43use crate::deploy::node::Node;
44use crate::error::CoreError;
45
46/// File name of the run record.
47pub const RECORD_FILE: &str = "record.json";
48
49/// The largest record accepted (8 MiB), checked before any parse.
50pub const RECORD_MAX_BYTES: usize = 8_388_608;
51
52/// The record's top-level keys, in write order.
53const FIELDS: [&str; 11] = [
54    "schema_version",
55    "run_id",
56    "gateway",
57    "mode",
58    "render_sha256",
59    "plan_sha256",
60    "state",
61    "rolled_back_by",
62    "touched",
63    "pre_fingerprint",
64    "post_fingerprint",
65];
66
67/// The keys of one `touched` entry.
68const ENTRY_FIELDS: [&str; 3] = ["path", "pre_sha256", "post_sha256"];
69
70/// Where a run stands.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(rename_all = "snake_case")]
73pub enum RecordState {
74    /// Files are being written; the post fingerprint is not known yet.
75    Applying,
76    /// The run finished.
77    Applied,
78    /// A later rollback restored this run's pre-apply files.
79    RolledBack,
80}
81
82/// One touched path with its hash before and after the run.
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(deny_unknown_fields)]
85pub struct TouchedEntry {
86    /// A collection path, or `ledger.json`.
87    pub path: String,
88    /// SHA-256 before the run; `None` when the path did not exist.
89    pub pre_sha256: Option<String>,
90    /// SHA-256 after the run; `None` when the path does not exist.
91    pub post_sha256: Option<String>,
92}
93
94/// The run record.
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(deny_unknown_fields)]
97pub struct RunRecord {
98    /// Always 1.
99    pub schema_version: u32,
100    /// The run this record belongs to.
101    pub run_id: String,
102    /// The gateway directory name (the target's `gateway`).
103    pub gateway: String,
104    /// The deployment mode (the target's `mode`).
105    pub mode: String,
106    /// `render_sha256` of the render this run deployed.
107    pub render_sha256: String,
108    /// `plan_sha256` of the plan this run applied.
109    pub plan_sha256: String,
110    /// Where the run stands.
111    pub state: RecordState,
112    /// The rollback run that restored this one; only when `rolled_back`.
113    pub rolled_back_by: Option<String>,
114    /// The touched paths, byte-sorted and unique.
115    pub touched: Vec<TouchedEntry>,
116    /// The touched-set fingerprint before the run.
117    pub pre_fingerprint: String,
118    /// The touched-set fingerprint after the run; `None` while `applying`.
119    pub post_fingerprint: Option<String>,
120}
121
122impl RunRecord {
123    /// Parse and validate `bytes` read from `location`, for the target
124    /// `gateway` and `mode`.
125    pub fn parse(
126        location: &str,
127        bytes: &[u8],
128        gateway: &str,
129        mode: &str,
130    ) -> Result<RunRecord, CoreError> {
131        let bad = |problem: String| refuse(location, bytes, problem);
132        let (record, root): (RunRecord, Node) = parse_strict(
133            location,
134            bytes,
135            RECORD_MAX_BYTES,
136            &FIELDS,
137            &[("state", &["applying", "applied", "rolled_back"])],
138        )?;
139        // Each touched entry carries all three keys (a missing hash is an
140        // explicit null) and no other.
141        if let Some(Node::Arr(entries)) = root.get("touched") {
142            for (index, entry) in entries.iter().enumerate() {
143                let Node::Obj(members) = entry else {
144                    return Err(bad(format!("touched[{index}] is not a JSON object")));
145                };
146                if let Some((key, _)) = members
147                    .iter()
148                    .find(|(k, _)| !ENTRY_FIELDS.contains(&k.as_str()))
149                {
150                    return Err(bad(format!(
151                        "touched[{index}] has the unknown field `{}`",
152                        super::journal::shown(key)
153                    )));
154                }
155                if let Some(missing) = ENTRY_FIELDS.iter().find(|f| entry.get(f).is_none()) {
156                    return Err(bad(format!("touched[{index}] is missing `{missing}`")));
157                }
158            }
159        }
160        check_header(
161            record.schema_version,
162            &record.run_id,
163            (&record.gateway, gateway),
164            (&record.mode, mode),
165        )
166        .map_err(bad)?;
167        for (name, value) in [
168            ("render_sha256", Some(&record.render_sha256)),
169            ("plan_sha256", Some(&record.plan_sha256)),
170            ("pre_fingerprint", Some(&record.pre_fingerprint)),
171            ("post_fingerprint", record.post_fingerprint.as_ref()),
172        ] {
173            if value.is_some_and(|v| !is_hex64(v)) {
174                return Err(bad(format!("{name} is not 64 lowercase hex digits")));
175            }
176        }
177        if record.state == RecordState::Applying && record.post_fingerprint.is_some() {
178            return Err(bad(
179                "post_fingerprint is set while state is applying".to_string()
180            ));
181        }
182        if let Some(by) = &record.rolled_back_by {
183            if record.state != RecordState::RolledBack {
184                return Err(bad(
185                    "rolled_back_by is set while state is not rolled_back".to_string()
186                ));
187            }
188            if RunId::parse(by).is_err() {
189                return Err(bad("rolled_back_by is not a valid run id".to_string()));
190            }
191        }
192        check_touched(record.touched.iter().map(|e| e.path.as_str())).map_err(bad)?;
193        for (index, entry) in record.touched.iter().enumerate() {
194            for (name, value) in [
195                ("pre_sha256", &entry.pre_sha256),
196                ("post_sha256", &entry.post_sha256),
197            ] {
198                if value.as_deref().is_some_and(|v| !is_hex64(v)) {
199                    return Err(bad(format!(
200                        "touched[{index}].{name} is not 64 lowercase hex digits"
201                    )));
202                }
203            }
204        }
205        Ok(record)
206    }
207
208    /// The canonical bytes: pretty JSON in field order plus one LF.
209    pub fn to_bytes(&self) -> Vec<u8> {
210        canonical_bytes(self)
211    }
212
213    /// Path to pre-run hash, for the fingerprint.
214    pub fn pre_map(&self) -> BTreeMap<String, Option<String>> {
215        self.touched
216            .iter()
217            .map(|e| (e.path.clone(), e.pre_sha256.clone()))
218            .collect()
219    }
220
221    /// Path to post-run hash, for the fingerprint.
222    pub fn post_map(&self) -> BTreeMap<String, Option<String>> {
223        self.touched
224            .iter()
225            .map(|e| (e.path.clone(), e.post_sha256.clone()))
226            .collect()
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use crate::deploy::apply::fingerprint::{LEDGER_KEY, fingerprint};
234    use serde_json::{Value, json};
235
236    const GW: &str = "tracer-gw";
237    const MODE: &str = "dev";
238    const LOC: &str = "~/ign-deploy/tracer-gw/dev/snapshots/20261015T142233Z-a1b2c3/record.json";
239    const H1: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
240    const H2: &str = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
241
242    fn entry(path: &str, pre: Option<&str>, post: Option<&str>) -> TouchedEntry {
243        TouchedEntry {
244            path: path.to_string(),
245            pre_sha256: pre.map(str::to_string),
246            post_sha256: post.map(str::to_string),
247        }
248    }
249
250    fn good() -> RunRecord {
251        RunRecord {
252            schema_version: 1,
253            run_id: "20261015T142233Z-a1b2c3".to_string(),
254            gateway: GW.to_string(),
255            mode: MODE.to_string(),
256            render_sha256: H1.to_string(),
257            plan_sha256: H2.to_string(),
258            state: RecordState::Applied,
259            rolled_back_by: None,
260            touched: vec![
261                entry("ledger.json", None, Some(H1)),
262                entry("mod/type/a.json", Some(H1), Some(H2)),
263                entry("mod/type/b.json", Some(H2), None),
264            ],
265            pre_fingerprint: H1.to_string(),
266            post_fingerprint: Some(H2.to_string()),
267        }
268    }
269
270    fn with(edit: impl FnOnce(&mut Value)) -> Vec<u8> {
271        let mut value: Value = serde_json::from_slice(&good().to_bytes()).expect("good is JSON");
272        edit(&mut value);
273        serde_json::to_vec(&value).expect("serialize")
274    }
275
276    fn refusal(bytes: &[u8]) -> String {
277        let err = RunRecord::parse(LOC, bytes, GW, MODE).expect_err("must be refused");
278        assert_eq!(err.code(), "deploy_run_invalid", "{err}");
279        let text = err.to_string();
280        assert!(text.contains(LOC), "the refusal names the location: {text}");
281        text
282    }
283
284    #[test]
285    fn record_roundtrip() {
286        let record = good();
287        let bytes = record.to_bytes();
288        assert!(bytes.ends_with(b"}\n"));
289        let parsed = RunRecord::parse(LOC, &bytes, GW, MODE).expect("parses");
290        assert_eq!(parsed, record);
291        assert_eq!(parsed.to_bytes(), bytes, "byte-identical");
292
293        let applying = RunRecord {
294            state: RecordState::Applying,
295            post_fingerprint: None,
296            ..good()
297        };
298        assert_eq!(
299            RunRecord::parse(LOC, &applying.to_bytes(), GW, MODE).expect("parses"),
300            applying
301        );
302        let rolled = RunRecord {
303            state: RecordState::RolledBack,
304            rolled_back_by: Some("20261016T090000Z-ffffff".to_string()),
305            ..good()
306        };
307        assert_eq!(
308            RunRecord::parse(LOC, &rolled.to_bytes(), GW, MODE).expect("parses"),
309            rolled
310        );
311    }
312
313    #[test]
314    fn the_layout_is_pinned() {
315        let applying = RunRecord {
316            state: RecordState::Applying,
317            post_fingerprint: None,
318            touched: vec![entry("mod/type/a.json", None, Some(H2))],
319            ..good()
320        };
321        let text = String::from_utf8(applying.to_bytes()).expect("utf-8");
322        let expected = format!(
323            "{{\n  \"schema_version\": 1,\n  \"run_id\": \"20261015T142233Z-a1b2c3\",\n  \"gateway\": \"tracer-gw\",\n  \"mode\": \"dev\",\n  \"render_sha256\": \"{H1}\",\n  \"plan_sha256\": \"{H2}\",\n  \"state\": \"applying\",\n  \"rolled_back_by\": null,\n  \"touched\": [\n    {{\n      \"path\": \"mod/type/a.json\",\n      \"pre_sha256\": null,\n      \"post_sha256\": \"{H2}\"\n    }}\n  ],\n  \"pre_fingerprint\": \"{H1}\",\n  \"post_fingerprint\": null\n}}\n"
324        );
325        assert_eq!(text, expected);
326    }
327
328    #[test]
329    fn the_maps_feed_the_fingerprint() {
330        let record = good();
331        let pre = record.pre_map();
332        let post = record.post_map();
333        assert_eq!(pre.len(), 3);
334        assert_eq!(pre.get(LEDGER_KEY), Some(&None));
335        assert_eq!(pre.get("mod/type/a.json"), Some(&Some(H1.to_string())));
336        assert_eq!(post.get("mod/type/b.json"), Some(&None));
337        assert_eq!(post.get(LEDGER_KEY), Some(&Some(H1.to_string())));
338        assert_ne!(fingerprint(&pre), fingerprint(&post));
339    }
340
341    #[test]
342    fn refuses_an_empty_file() {
343        assert!(refusal(b"").contains("empty"));
344    }
345
346    #[test]
347    fn refuses_a_file_over_the_cap() {
348        assert!(refusal(&vec![b' '; RECORD_MAX_BYTES + 1]).contains("cap"));
349    }
350
351    #[test]
352    fn refuses_invalid_utf8() {
353        refusal(b"{\"schema_version\": 1, \"run_id\": \"\xff\"}");
354    }
355
356    #[test]
357    fn refuses_a_duplicate_key() {
358        let text = String::from_utf8(good().to_bytes()).expect("utf-8");
359        let dup = text.replacen(
360            "\"mode\": \"dev\"",
361            "\"mode\": \"dev\",\n  \"mode\": \"dev\"",
362            1,
363        );
364        refusal(dup.as_bytes());
365    }
366
367    #[test]
368    fn refuses_an_unknown_field_at_either_level() {
369        let top = refusal(&with(|v| v["extra"] = json!(1)));
370        assert!(top.contains("extra"), "{top}");
371        refusal(&with(|v| v["touched"][0]["extra"] = json!(1)));
372    }
373
374    #[test]
375    fn refuses_a_missing_field_at_either_level() {
376        let top = refusal(&with(|v| {
377            v.as_object_mut().expect("object").remove("plan_sha256");
378        }));
379        assert!(top.contains("plan_sha256"), "{top}");
380        refusal(&with(|v| {
381            v["touched"][1]
382                .as_object_mut()
383                .expect("object")
384                .remove("post_sha256");
385        }));
386        refusal(&with(|v| {
387            v.as_object_mut()
388                .expect("object")
389                .remove("post_fingerprint");
390        }));
391    }
392
393    #[test]
394    fn refuses_another_schema_version_gateway_or_mode() {
395        refusal(&with(|v| v["schema_version"] = json!(2)));
396        refusal(&with(|v| v["gateway"] = json!("other-gw")));
397        refusal(&with(|v| v["mode"] = json!("prod")));
398    }
399
400    #[test]
401    fn refuses_a_bad_run_id() {
402        for bad in ["", "../x", ".hidden", "a b"] {
403            refusal(&with(|v| v["run_id"] = json!(bad)));
404        }
405    }
406
407    #[test]
408    fn refuses_an_unknown_state() {
409        let text = refusal(&with(|v| v["state"] = json!("halfway")));
410        assert!(text.contains("state"), "{text}");
411    }
412
413    #[test]
414    fn refuses_unsorted_or_duplicate_touched() {
415        let text = refusal(&with(|v| {
416            let t = v["touched"].as_array_mut().expect("array");
417            t.swap(1, 2);
418        }));
419        assert!(text.contains("sorted"), "{text}");
420        let text = refusal(&with(|v| {
421            let first = v["touched"][1].clone();
422            v["touched"][2] = first;
423        }));
424        assert!(text.contains("duplicate"), "{text}");
425    }
426
427    #[test]
428    fn refuses_a_touched_path_that_is_not_a_collection_path() {
429        for bad in [
430            "a/b",
431            "mod/type/.hidden/x",
432            "mod/type/../x",
433            "/abs/x/y",
434            "ledger.json.bak",
435        ] {
436            refusal(&with(|v| {
437                v["touched"] = json!([{"path": bad, "pre_sha256": null, "post_sha256": null}])
438            }));
439        }
440    }
441
442    #[test]
443    fn refuses_a_hash_that_is_not_hex() {
444        for bad in ["", "abc", &H1.to_uppercase(), &format!("{H1}0")] {
445            refusal(&with(|v| v["render_sha256"] = json!(bad)));
446            refusal(&with(|v| v["plan_sha256"] = json!(bad)));
447            refusal(&with(|v| v["pre_fingerprint"] = json!(bad)));
448            refusal(&with(|v| v["post_fingerprint"] = json!(bad)));
449            refusal(&with(|v| v["touched"][1]["pre_sha256"] = json!(bad)));
450            refusal(&with(|v| v["touched"][1]["post_sha256"] = json!(bad)));
451        }
452    }
453
454    #[test]
455    fn refuses_a_post_fingerprint_while_applying() {
456        let text = refusal(&with(|v| v["state"] = json!("applying")));
457        assert!(text.contains("post_fingerprint"), "{text}");
458    }
459
460    #[test]
461    fn refuses_rolled_back_by_unless_rolled_back() {
462        for state in ["applying", "applied"] {
463            let text = refusal(&with(|v| {
464                v["state"] = json!(state);
465                v["post_fingerprint"] = Value::Null;
466                v["rolled_back_by"] = json!("20261016T090000Z-ffffff");
467            }));
468            assert!(text.contains("rolled_back_by"), "{state}: {text}");
469        }
470    }
471
472    #[test]
473    fn refuses_a_bad_rolled_back_by() {
474        refusal(&with(|v| {
475            v["state"] = json!("rolled_back");
476            v["rolled_back_by"] = json!("../x");
477        }));
478    }
479
480    #[test]
481    fn refuses_a_wrong_typed_field() {
482        refusal(&with(|v| v["touched"] = json!("mod/type/a.json")));
483        refusal(&with(|v| v["touched"][1]["path"] = json!(7)));
484    }
485
486    #[test]
487    fn refuses_a_top_level_that_is_not_an_object() {
488        refusal(b"[]");
489    }
490}