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    /// Tag providers that are module- or gateway-managed on this gateway and
41    /// so read-only to ign (Phase 26, D-06): no project may own one, and they
42    /// are excluded here as `exclude_tag_providers` are. Optional, validated
43    /// with the same entry rules and matched case-insensitively.
44    #[serde(default)]
45    pub read_only_tag_providers: Vec<String>,
46    /// Pinned project artifacts (consumed by Phase 25).
47    #[serde(default)]
48    pub projects: Vec<ProjectPin>,
49    /// Post-deploy verification inputs (consumed by Phase 22).
50    #[serde(default)]
51    pub verify: Option<Verify>,
52}
53
54/// One pinned project artifact (Phase 25).
55#[derive(Debug, Clone, PartialEq, Deserialize)]
56#[serde(deny_unknown_fields)]
57pub struct ProjectPin {
58    /// Project name on the gateway.
59    pub name: String,
60    /// Version label of the artifact.
61    pub version: String,
62    /// Lowercase hex sha256 (64 chars) of the artifact bytes.
63    pub sha256: String,
64    /// Where the artifact is fetched from; opaque to Phase 21.
65    pub artifact: String,
66}
67
68/// Verification inputs (SF-8, consumed by Phase 22). Phase 21 only parses them.
69#[derive(Debug, Clone, PartialEq, Deserialize)]
70#[serde(deny_unknown_fields)]
71pub struct Verify {
72    /// Tag paths read back after a deploy. An absent list is a Phase 22
73    /// warning, not a render concern.
74    #[serde(default)]
75    pub tag_probes: Vec<String>,
76}
77
78impl Manifest {
79    /// Parse and validate `bytes` (labelled `file` in errors).
80    pub fn parse(file: &str, bytes: &[u8]) -> Result<Manifest, CoreError> {
81        let manifest: Manifest = strict_parse(file, bytes)?;
82        manifest.validate(file)?;
83        Ok(manifest)
84    }
85
86    /// Whether `module/ty` is on the declared type allowlist (exact match).
87    pub fn allows_type(&self, module: &str, ty: &str) -> bool {
88        self.types
89            .iter()
90            .any(|t| t.split_once('/') == Some((module, ty)))
91    }
92
93    fn validate(&self, file: &str) -> Result<(), CoreError> {
94        let mut findings = Vec::new();
95        let mut push = |message: String| {
96            findings.push(DeployFinding {
97                file: file.to_string(),
98                line: None,
99                column: None,
100                message,
101            });
102        };
103
104        for (i, t) in self.types.iter().enumerate() {
105            match t.split_once('/') {
106                Some((module, ty)) if !ty.contains('/') => {
107                    for (what, half) in [("module", module), ("type", ty)] {
108                        if let Some(why) = component_problem(half) {
109                            push(format!("types[{i}] {t:?}: the {what} half {why}"));
110                        }
111                    }
112                }
113                _ => push(format!(
114                    "types[{i}] {t:?}: must have the form `module/type`"
115                )),
116            }
117            if self.types[..i].contains(t) {
118                push(format!("types[{i}] {t:?}: duplicate type"));
119            }
120        }
121        for (i, p) in self.tag_providers.iter().enumerate() {
122            if let Some(why) = component_problem(p) {
123                push(format!("tag_providers[{i}] {p:?}: {why}"));
124            }
125            if self.tag_providers[..i].contains(p) {
126                push(format!("tag_providers[{i}] {p:?}: duplicate tag provider"));
127            }
128        }
129        for problem in list_problems("exclude_tag_providers", &self.exclude_tag_providers)
130            .into_iter()
131            .chain(list_problems(
132                "read_only_tag_providers",
133                &self.read_only_tag_providers,
134            ))
135        {
136            push(problem);
137        }
138        for (i, pin) in self.projects.iter().enumerate() {
139            if pin.name.is_empty() {
140                push(format!("projects[{i}].name: must not be empty"));
141            }
142            let hex = pin.sha256.len() == 64
143                && pin
144                    .sha256
145                    .bytes()
146                    .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
147            if !hex {
148                push(format!(
149                    "projects[{i}].sha256: must be 64 lowercase hex characters"
150                ));
151            }
152        }
153
154        if findings.is_empty() {
155            Ok(())
156        } else {
157            Err(CoreError::deploy_findings(findings))
158        }
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    const SHA: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
167
168    fn findings(e: CoreError) -> Vec<DeployFinding> {
169        match e {
170            CoreError::DeployTreeInvalid(d) => d.findings,
171            other => panic!("expected deploy_tree_invalid, got {other}"),
172        }
173    }
174
175    #[test]
176    fn full_manifest_parses() {
177        let yaml = format!(
178            "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"
179        );
180        let m = Manifest::parse("manifest.yaml", yaml.as_bytes()).expect("parses");
181        assert_eq!(m.standards_version.as_deref(), Some("flux-2026"));
182        assert_eq!(m.types.len(), 2);
183        assert_eq!(m.tag_providers, ["default", "edge"]);
184        assert_eq!(m.projects[0].name, "scada");
185        assert_eq!(m.projects[0].sha256, SHA);
186        assert_eq!(
187            m.verify.expect("verify").tag_probes,
188            ["[default]Plant/Heartbeat"]
189        );
190    }
191
192    #[test]
193    fn minimal_manifest_parses() {
194        let m = Manifest::parse(
195            "manifest.yaml",
196            b"schema_version: 1\ntypes: [ignition/database-connection]\n",
197        )
198        .expect("parses");
199        assert!(m.tag_providers.is_empty());
200        assert!(m.projects.is_empty());
201        assert_eq!(m.verify, None);
202        assert!(m.allows_type("ignition", "database-connection"));
203        assert!(!m.allows_type("ignition", "tag-provider"));
204        assert!(!m.allows_type("ignition", "database"));
205
206        let m = Manifest::parse(
207            "manifest.yaml",
208            b"schema_version: 1\ntypes: []\nverify: {}\n",
209        )
210        .expect("empty verify");
211        assert_eq!(m.verify.expect("verify").tag_probes, Vec::<String>::new());
212    }
213
214    #[test]
215    fn manifest_semantic_findings_collected() {
216        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";
217        let found =
218            findings(Manifest::parse("manifest.yaml", yaml.as_bytes()).expect_err("invalid"));
219        let all: Vec<&str> = found.iter().map(|f| f.message.as_str()).collect();
220        let has = |needle: &str| all.iter().any(|m| m.contains(needle));
221        assert!(has("types[0]") && has("module/type"), "{all:?}");
222        assert!(has("duplicate type"), "{all:?}");
223        assert!(has("duplicate tag provider"), "{all:?}");
224        assert!(has("tag_providers[2]") && has("path separator"), "{all:?}");
225        assert!(has("projects[0].sha256"), "{all:?}");
226        for f in &found {
227            assert_eq!(f.file, "manifest.yaml");
228            assert_eq!((f.line, f.column), (None, None));
229        }
230    }
231
232    #[test]
233    fn manifest_unknown_field_is_positioned() {
234        let e = Manifest::parse(
235            "manifest.yaml",
236            b"schema_version: 1\ntypes: []\nverify:\n  extra: 1\n",
237        )
238        .expect_err("unknown nested field");
239        let f = findings(e).remove(0);
240        assert_eq!((f.line, f.column), (Some(4), Some(3)));
241    }
242
243    #[test]
244    fn exclude_tag_providers_in_manifest_is_optional_and_parses() {
245        let m =
246            Manifest::parse("manifest.yaml", b"schema_version: 1\ntypes: []\n").expect("absent");
247        assert!(m.exclude_tag_providers.is_empty());
248        let m = Manifest::parse(
249            "manifest.yaml",
250            b"schema_version: 1\ntypes: []\nexclude_tag_providers: [\"MQTT Engine\"]\n",
251        )
252        .expect("listed");
253        assert_eq!(m.exclude_tag_providers, ["MQTT Engine"]);
254    }
255
256    #[test]
257    fn exclude_tag_providers_in_manifest_bad_entries_are_refused() {
258        for (list, index, why) in [
259            ("[\"\"]", 0, "empty"),
260            ("[\"   \"]", 0, "blank"),
261            ("[\" MQTT Engine\"]", 0, "whitespace"),
262            ("[\"a/b\"]", 0, "separator"),
263            ("[\".x\"]", 0, "`.`"),
264            ("[\"MQTT Engine\", \"mqtt engine\"]", 1, "duplicate"),
265        ] {
266            let yaml = format!("schema_version: 1\ntypes: []\nexclude_tag_providers: {list}\n");
267            let found =
268                findings(Manifest::parse("manifest.yaml", yaml.as_bytes()).expect_err("refused"));
269            assert_eq!(found.len(), 1, "{list}: {found:?}");
270            let message = &found[0].message;
271            assert!(
272                message.starts_with(&format!("exclude_tag_providers[{index}] "))
273                    && message.contains(why),
274                "{list}: {message}"
275            );
276        }
277        let e = Manifest::parse(
278            "manifest.yaml",
279            b"schema_version: 1\ntypes: []\nexclude_tag_provider: [x]\n",
280        )
281        .expect_err("misspelled key");
282        let f = findings(e).remove(0);
283        assert_eq!(f.line, Some(3));
284    }
285
286    /// 26-05 (D-06): the manifest's `read_only_tag_providers` is optional and
287    /// validated with the `exclude_tag_providers` entry rules.
288    #[test]
289    fn read_only_tag_providers_in_manifest_is_optional_and_validated() {
290        let m =
291            Manifest::parse("manifest.yaml", b"schema_version: 1\ntypes: []\n").expect("absent");
292        assert!(m.read_only_tag_providers.is_empty());
293        let m = Manifest::parse(
294            "manifest.yaml",
295            b"schema_version: 1\ntypes: []\nread_only_tag_providers: [\"Broker Transmission\"]\n",
296        )
297        .expect("listed");
298        assert_eq!(m.read_only_tag_providers, ["Broker Transmission"]);
299        for (list, index, why) in [
300            ("[\"\"]", 0, "empty"),
301            ("[\" EdgeGateway01\"]", 0, "whitespace"),
302            ("[\"a/b\"]", 0, "separator"),
303            ("[EdgeGateway01, edgegateway01]", 1, "duplicate"),
304        ] {
305            let yaml = format!("schema_version: 1\ntypes: []\nread_only_tag_providers: {list}\n");
306            let found =
307                findings(Manifest::parse("manifest.yaml", yaml.as_bytes()).expect_err("refused"));
308            assert_eq!(found.len(), 1, "{list}: {found:?}");
309            let message = &found[0].message;
310            assert!(
311                message.starts_with(&format!("read_only_tag_providers[{index}] "))
312                    && message.contains(why),
313                "{list}: {message}"
314            );
315        }
316    }
317}