ignition_core/deploy/
manifest.rs1use serde::Deserialize;
11
12use super::component_problem;
13use super::yamlio::{SchemaVersion, strict_parse};
14use crate::error::{CoreError, DeployFinding};
15
16#[derive(Debug, Clone, PartialEq, Deserialize)]
18#[serde(deny_unknown_fields)]
19pub struct Manifest {
20 pub schema_version: SchemaVersion,
22 #[serde(default)]
25 pub standards_version: Option<String>,
26 pub types: Vec<String>,
29 #[serde(default)]
31 pub tag_providers: Vec<String>,
32 #[serde(default)]
34 pub projects: Vec<ProjectPin>,
35 #[serde(default)]
37 pub verify: Option<Verify>,
38}
39
40#[derive(Debug, Clone, PartialEq, Deserialize)]
42#[serde(deny_unknown_fields)]
43pub struct ProjectPin {
44 pub name: String,
46 pub version: String,
48 pub sha256: String,
50 pub artifact: String,
52}
53
54#[derive(Debug, Clone, PartialEq, Deserialize)]
56#[serde(deny_unknown_fields)]
57pub struct Verify {
58 #[serde(default)]
61 pub tag_probes: Vec<String>,
62}
63
64impl Manifest {
65 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 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}