Skip to main content

ignition_core/deploy/
manifest.rs

1//! `manifest.yaml`: the declared tree's root file.
2//!
3//! The schema includes `verify.tag_probes[]` (SF-8) and `projects[]` now so
4//! strict parsing does not reject them when Phases 22 and 25 start reading
5//! them. Every struct is `deny_unknown_fields`; semantic defects (shapes
6//! the type system cannot express) are collected into ONE
7//! `deploy_tree_invalid`, with no line or column because they are not tied
8//! to a single source position.
9
10use serde::Deserialize;
11
12use super::component_problem;
13use super::exclude::list_problems;
14use super::yamlio::{SchemaVersion, strict_parse};
15use crate::error::{CoreError, DeployFinding};
16
17/// The parsed `manifest.yaml`.
18#[derive(Debug, Clone, PartialEq, Deserialize)]
19#[serde(deny_unknown_fields)]
20pub struct Manifest {
21    /// Always 1; any other value is refused by [`SchemaVersion`].
22    pub schema_version: SchemaVersion,
23    /// Free-form label of the house standard the tree follows. Informational;
24    /// render does not interpret it.
25    #[serde(default)]
26    pub standards_version: Option<String>,
27    /// Allowlist of `module/type` resource types the tree may declare
28    /// (render, Phase 21). Required.
29    pub types: Vec<String>,
30    /// Tag provider directory names under `tags/` (render, Phase 21).
31    #[serde(default)]
32    pub tag_providers: Vec<String>,
33    /// Tag provider directory names no deploy verb lists, reads, writes or
34    /// deletes, in every environment (gateway-owned providers such as
35    /// `MQTT Engine`). Optional. Unioned with the target's list and the
36    /// built-in `System` default, and matched case-insensitively; an excluded
37    /// provider cannot also be in `tag_providers`. See [`super::exclude`].
38    #[serde(default)]
39    pub exclude_tag_providers: Vec<String>,
40    /// Pinned project artifacts (consumed by Phase 25).
41    #[serde(default)]
42    pub projects: Vec<ProjectPin>,
43    /// Post-deploy verification inputs (consumed by Phase 22).
44    #[serde(default)]
45    pub verify: Option<Verify>,
46}
47
48/// One pinned project artifact (Phase 25).
49#[derive(Debug, Clone, PartialEq, Deserialize)]
50#[serde(deny_unknown_fields)]
51pub struct ProjectPin {
52    /// Project name on the gateway.
53    pub name: String,
54    /// Version label of the artifact.
55    pub version: String,
56    /// Lowercase hex sha256 (64 chars) of the artifact bytes.
57    pub sha256: String,
58    /// Where the artifact is fetched from; opaque to Phase 21.
59    pub artifact: String,
60}
61
62/// Verification inputs (SF-8, consumed by Phase 22). Phase 21 only parses them.
63#[derive(Debug, Clone, PartialEq, Deserialize)]
64#[serde(deny_unknown_fields)]
65pub struct Verify {
66    /// Tag paths read back after a deploy. An absent list is a Phase 22
67    /// warning, not a render concern.
68    #[serde(default)]
69    pub tag_probes: Vec<String>,
70}
71
72impl Manifest {
73    /// Parse and validate `bytes` (labelled `file` in errors).
74    pub fn parse(file: &str, bytes: &[u8]) -> Result<Manifest, CoreError> {
75        let manifest: Manifest = strict_parse(file, bytes)?;
76        manifest.validate(file)?;
77        Ok(manifest)
78    }
79
80    /// Whether `module/ty` is on the declared type allowlist (exact match).
81    pub fn allows_type(&self, module: &str, ty: &str) -> bool {
82        self.types
83            .iter()
84            .any(|t| t.split_once('/') == Some((module, ty)))
85    }
86
87    fn validate(&self, file: &str) -> Result<(), CoreError> {
88        let mut findings = Vec::new();
89        let mut push = |message: String| {
90            findings.push(DeployFinding {
91                file: file.to_string(),
92                line: None,
93                column: None,
94                message,
95            });
96        };
97
98        for (i, t) in self.types.iter().enumerate() {
99            match t.split_once('/') {
100                Some((module, ty)) if !ty.contains('/') => {
101                    for (what, half) in [("module", module), ("type", ty)] {
102                        if let Some(why) = component_problem(half) {
103                            push(format!("types[{i}] {t:?}: the {what} half {why}"));
104                        }
105                    }
106                }
107                _ => push(format!(
108                    "types[{i}] {t:?}: must have the form `module/type`"
109                )),
110            }
111            if self.types[..i].contains(t) {
112                push(format!("types[{i}] {t:?}: duplicate type"));
113            }
114        }
115        for (i, p) in self.tag_providers.iter().enumerate() {
116            if let Some(why) = component_problem(p) {
117                push(format!("tag_providers[{i}] {p:?}: {why}"));
118            }
119            if self.tag_providers[..i].contains(p) {
120                push(format!("tag_providers[{i}] {p:?}: duplicate tag provider"));
121            }
122        }
123        for problem in list_problems(&self.exclude_tag_providers) {
124            push(problem);
125        }
126        for (i, pin) in self.projects.iter().enumerate() {
127            if pin.name.is_empty() {
128                push(format!("projects[{i}].name: must not be empty"));
129            }
130            let hex = pin.sha256.len() == 64
131                && pin
132                    .sha256
133                    .bytes()
134                    .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
135            if !hex {
136                push(format!(
137                    "projects[{i}].sha256: must be 64 lowercase hex characters"
138                ));
139            }
140        }
141
142        if findings.is_empty() {
143            Ok(())
144        } else {
145            Err(CoreError::deploy_findings(findings))
146        }
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    const SHA: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
155
156    fn findings(e: CoreError) -> Vec<DeployFinding> {
157        match e {
158            CoreError::DeployTreeInvalid(d) => d.findings,
159            other => panic!("expected deploy_tree_invalid, got {other}"),
160        }
161    }
162
163    #[test]
164    fn full_manifest_parses() {
165        let yaml = format!(
166            "schema_version: 1\nstandards_version: flux-2026\ntypes:\n  - ignition/database-connection\n  - ignition/tag-provider\ntag_providers: [default, edge]\nprojects:\n  - name: scada\n    version: 1.2.3\n    sha256: {SHA}\n    artifact: https://example.invalid/scada.zip\nverify:\n  tag_probes:\n    - \"[default]Plant/Heartbeat\"\n"
167        );
168        let m = Manifest::parse("manifest.yaml", yaml.as_bytes()).expect("parses");
169        assert_eq!(m.standards_version.as_deref(), Some("flux-2026"));
170        assert_eq!(m.types.len(), 2);
171        assert_eq!(m.tag_providers, ["default", "edge"]);
172        assert_eq!(m.projects[0].name, "scada");
173        assert_eq!(m.projects[0].sha256, SHA);
174        assert_eq!(
175            m.verify.expect("verify").tag_probes,
176            ["[default]Plant/Heartbeat"]
177        );
178    }
179
180    #[test]
181    fn minimal_manifest_parses() {
182        let m = Manifest::parse(
183            "manifest.yaml",
184            b"schema_version: 1\ntypes: [ignition/database-connection]\n",
185        )
186        .expect("parses");
187        assert!(m.tag_providers.is_empty());
188        assert!(m.projects.is_empty());
189        assert_eq!(m.verify, None);
190        assert!(m.allows_type("ignition", "database-connection"));
191        assert!(!m.allows_type("ignition", "tag-provider"));
192        assert!(!m.allows_type("ignition", "database"));
193
194        let m = Manifest::parse(
195            "manifest.yaml",
196            b"schema_version: 1\ntypes: []\nverify: {}\n",
197        )
198        .expect("empty verify");
199        assert_eq!(m.verify.expect("verify").tag_probes, Vec::<String>::new());
200    }
201
202    #[test]
203    fn manifest_semantic_findings_collected() {
204        let yaml = "schema_version: 1\ntypes:\n  - nosep\n  - ignition/a\n  - ignition/a\ntag_providers: [dup, dup, \"a/b\"]\nprojects:\n  - name: p\n    version: '1'\n    sha256: ABC\n    artifact: x\n";
205        let found =
206            findings(Manifest::parse("manifest.yaml", yaml.as_bytes()).expect_err("invalid"));
207        let all: Vec<&str> = found.iter().map(|f| f.message.as_str()).collect();
208        let has = |needle: &str| all.iter().any(|m| m.contains(needle));
209        assert!(has("types[0]") && has("module/type"), "{all:?}");
210        assert!(has("duplicate type"), "{all:?}");
211        assert!(has("duplicate tag provider"), "{all:?}");
212        assert!(has("tag_providers[2]") && has("path separator"), "{all:?}");
213        assert!(has("projects[0].sha256"), "{all:?}");
214        for f in &found {
215            assert_eq!(f.file, "manifest.yaml");
216            assert_eq!((f.line, f.column), (None, None));
217        }
218    }
219
220    #[test]
221    fn manifest_unknown_field_is_positioned() {
222        let e = Manifest::parse(
223            "manifest.yaml",
224            b"schema_version: 1\ntypes: []\nverify:\n  extra: 1\n",
225        )
226        .expect_err("unknown nested field");
227        let f = findings(e).remove(0);
228        assert_eq!((f.line, f.column), (Some(4), Some(3)));
229    }
230
231    #[test]
232    fn exclude_tag_providers_in_manifest_is_optional_and_parses() {
233        let m =
234            Manifest::parse("manifest.yaml", b"schema_version: 1\ntypes: []\n").expect("absent");
235        assert!(m.exclude_tag_providers.is_empty());
236        let m = Manifest::parse(
237            "manifest.yaml",
238            b"schema_version: 1\ntypes: []\nexclude_tag_providers: [\"MQTT Engine\"]\n",
239        )
240        .expect("listed");
241        assert_eq!(m.exclude_tag_providers, ["MQTT Engine"]);
242    }
243
244    #[test]
245    fn exclude_tag_providers_in_manifest_bad_entries_are_refused() {
246        for (list, index, why) in [
247            ("[\"\"]", 0, "empty"),
248            ("[\"   \"]", 0, "blank"),
249            ("[\" MQTT Engine\"]", 0, "whitespace"),
250            ("[\"a/b\"]", 0, "separator"),
251            ("[\".x\"]", 0, "`.`"),
252            ("[\"MQTT Engine\", \"mqtt engine\"]", 1, "duplicate"),
253        ] {
254            let yaml = format!("schema_version: 1\ntypes: []\nexclude_tag_providers: {list}\n");
255            let found =
256                findings(Manifest::parse("manifest.yaml", yaml.as_bytes()).expect_err("refused"));
257            assert_eq!(found.len(), 1, "{list}: {found:?}");
258            let message = &found[0].message;
259            assert!(
260                message.starts_with(&format!("exclude_tag_providers[{index}] "))
261                    && message.contains(why),
262                "{list}: {message}"
263            );
264        }
265        let e = Manifest::parse(
266            "manifest.yaml",
267            b"schema_version: 1\ntypes: []\nexclude_tag_provider: [x]\n",
268        )
269        .expect_err("misspelled key");
270        let f = findings(e).remove(0);
271        assert_eq!(f.line, Some(3));
272    }
273}