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::yamlio::{SchemaVersion, strict_parse};
14use crate::error::{CoreError, DeployFinding};
15
16/// The parsed `manifest.yaml`.
17#[derive(Debug, Clone, PartialEq, Deserialize)]
18#[serde(deny_unknown_fields)]
19pub struct Manifest {
20    /// Always 1; any other value is refused by [`SchemaVersion`].
21    pub schema_version: SchemaVersion,
22    /// Free-form label of the house standard the tree follows. Informational;
23    /// render does not interpret it.
24    #[serde(default)]
25    pub standards_version: Option<String>,
26    /// Allowlist of `module/type` resource types the tree may declare
27    /// (render, Phase 21). Required.
28    pub types: Vec<String>,
29    /// Tag provider directory names under `tags/` (render, Phase 21).
30    #[serde(default)]
31    pub tag_providers: Vec<String>,
32    /// Pinned project artifacts (consumed by Phase 25).
33    #[serde(default)]
34    pub projects: Vec<ProjectPin>,
35    /// Post-deploy verification inputs (consumed by Phase 22).
36    #[serde(default)]
37    pub verify: Option<Verify>,
38}
39
40/// One pinned project artifact (Phase 25).
41#[derive(Debug, Clone, PartialEq, Deserialize)]
42#[serde(deny_unknown_fields)]
43pub struct ProjectPin {
44    /// Project name on the gateway.
45    pub name: String,
46    /// Version label of the artifact.
47    pub version: String,
48    /// Lowercase hex sha256 (64 chars) of the artifact bytes.
49    pub sha256: String,
50    /// Where the artifact is fetched from; opaque to Phase 21.
51    pub artifact: String,
52}
53
54/// Verification inputs (SF-8, consumed by Phase 22). Phase 21 only parses them.
55#[derive(Debug, Clone, PartialEq, Deserialize)]
56#[serde(deny_unknown_fields)]
57pub struct Verify {
58    /// Tag paths read back after a deploy. An absent list is a Phase 22
59    /// warning, not a render concern.
60    #[serde(default)]
61    pub tag_probes: Vec<String>,
62}
63
64impl Manifest {
65    /// Parse and validate `bytes` (labelled `file` in errors).
66    pub fn parse(file: &str, bytes: &[u8]) -> Result<Manifest, CoreError> {
67        let manifest: Manifest = strict_parse(file, bytes)?;
68        manifest.validate(file)?;
69        Ok(manifest)
70    }
71
72    /// Whether `module/ty` is on the declared type allowlist (exact match).
73    pub fn allows_type(&self, module: &str, ty: &str) -> bool {
74        self.types
75            .iter()
76            .any(|t| t.split_once('/') == Some((module, ty)))
77    }
78
79    fn validate(&self, file: &str) -> Result<(), CoreError> {
80        let mut findings = Vec::new();
81        let mut push = |message: String| {
82            findings.push(DeployFinding {
83                file: file.to_string(),
84                line: None,
85                column: None,
86                message,
87            });
88        };
89
90        for (i, t) in self.types.iter().enumerate() {
91            match t.split_once('/') {
92                Some((module, ty)) if !ty.contains('/') => {
93                    for (what, half) in [("module", module), ("type", ty)] {
94                        if let Some(why) = component_problem(half) {
95                            push(format!("types[{i}] {t:?}: the {what} half {why}"));
96                        }
97                    }
98                }
99                _ => push(format!(
100                    "types[{i}] {t:?}: must have the form `module/type`"
101                )),
102            }
103            if self.types[..i].contains(t) {
104                push(format!("types[{i}] {t:?}: duplicate type"));
105            }
106        }
107        for (i, p) in self.tag_providers.iter().enumerate() {
108            if let Some(why) = component_problem(p) {
109                push(format!("tag_providers[{i}] {p:?}: {why}"));
110            }
111            if self.tag_providers[..i].contains(p) {
112                push(format!("tag_providers[{i}] {p:?}: duplicate tag provider"));
113            }
114        }
115        for (i, pin) in self.projects.iter().enumerate() {
116            if pin.name.is_empty() {
117                push(format!("projects[{i}].name: must not be empty"));
118            }
119            let hex = pin.sha256.len() == 64
120                && pin
121                    .sha256
122                    .bytes()
123                    .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
124            if !hex {
125                push(format!(
126                    "projects[{i}].sha256: must be 64 lowercase hex characters"
127                ));
128            }
129        }
130
131        if findings.is_empty() {
132            Ok(())
133        } else {
134            Err(CoreError::deploy_findings(findings))
135        }
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    const SHA: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
144
145    fn findings(e: CoreError) -> Vec<DeployFinding> {
146        match e {
147            CoreError::DeployTreeInvalid(d) => d.findings,
148            other => panic!("expected deploy_tree_invalid, got {other}"),
149        }
150    }
151
152    #[test]
153    fn full_manifest_parses() {
154        let yaml = format!(
155            "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"
156        );
157        let m = Manifest::parse("manifest.yaml", yaml.as_bytes()).expect("parses");
158        assert_eq!(m.standards_version.as_deref(), Some("flux-2026"));
159        assert_eq!(m.types.len(), 2);
160        assert_eq!(m.tag_providers, ["default", "edge"]);
161        assert_eq!(m.projects[0].name, "scada");
162        assert_eq!(m.projects[0].sha256, SHA);
163        assert_eq!(
164            m.verify.expect("verify").tag_probes,
165            ["[default]Plant/Heartbeat"]
166        );
167    }
168
169    #[test]
170    fn minimal_manifest_parses() {
171        let m = Manifest::parse(
172            "manifest.yaml",
173            b"schema_version: 1\ntypes: [ignition/database-connection]\n",
174        )
175        .expect("parses");
176        assert!(m.tag_providers.is_empty());
177        assert!(m.projects.is_empty());
178        assert_eq!(m.verify, None);
179        assert!(m.allows_type("ignition", "database-connection"));
180        assert!(!m.allows_type("ignition", "tag-provider"));
181        assert!(!m.allows_type("ignition", "database"));
182
183        let m = Manifest::parse(
184            "manifest.yaml",
185            b"schema_version: 1\ntypes: []\nverify: {}\n",
186        )
187        .expect("empty verify");
188        assert_eq!(m.verify.expect("verify").tag_probes, Vec::<String>::new());
189    }
190
191    #[test]
192    fn manifest_semantic_findings_collected() {
193        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";
194        let found =
195            findings(Manifest::parse("manifest.yaml", yaml.as_bytes()).expect_err("invalid"));
196        let all: Vec<&str> = found.iter().map(|f| f.message.as_str()).collect();
197        let has = |needle: &str| all.iter().any(|m| m.contains(needle));
198        assert!(has("types[0]") && has("module/type"), "{all:?}");
199        assert!(has("duplicate type"), "{all:?}");
200        assert!(has("duplicate tag provider"), "{all:?}");
201        assert!(has("tag_providers[2]") && has("path separator"), "{all:?}");
202        assert!(has("projects[0].sha256"), "{all:?}");
203        for f in &found {
204            assert_eq!(f.file, "manifest.yaml");
205            assert_eq!((f.line, f.column), (None, None));
206        }
207    }
208
209    #[test]
210    fn manifest_unknown_field_is_positioned() {
211        let e = Manifest::parse(
212            "manifest.yaml",
213            b"schema_version: 1\ntypes: []\nverify:\n  extra: 1\n",
214        )
215        .expect_err("unknown nested field");
216        let f = findings(e).remove(0);
217        assert_eq!((f.line, f.column), (Some(4), Some(3)));
218    }
219}