1use std::collections::{BTreeMap, BTreeSet};
46
47use serde::Serialize;
48
49use super::component_problem;
50use super::layout::{RelPath, TAG_TYPES, repo_path};
51use super::manifest::Manifest;
52use super::plan::unit_key;
53use super::render::{RenderInput, RenderOutcome, render};
54use super::rules::{is_denied, type_rule};
55use super::target::Target;
56use super::transport::ObservedFiles;
57use super::tree::SourceTree;
58use crate::error::{CoreError, DeployFinding};
59
60pub const PULL_MAX_ROUNDS: usize = 8;
62
63pub const REASON_EMBEDDED_SECRET: &str = "embedded_secret";
65pub const REASON_NOT_ROUND_TRIPPABLE: &str = "not_round_trippable";
67pub const REASON_RENDER_REFUSED: &str = "render_refused";
69pub const REASON_TYPE_WITHOUT_RULE: &str = "type_without_rule";
71pub const REASON_DENIED_TYPE: &str = "denied_type";
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
76pub struct Skipped {
77 pub path: String,
81 pub reason: String,
83}
84
85#[derive(Debug, Clone, Copy)]
87pub struct PullInput<'a> {
88 pub gateway: &'a str,
90 pub env: &'a str,
92 pub target: &'a Target,
94 pub manifest: &'a Manifest,
96 pub observed: &'a ObservedFiles,
98}
99
100#[derive(Debug, Clone)]
102pub struct PullOutcome {
103 pub source: SourceTree,
105 pub rendered: RenderOutcome,
107 pub skipped: Vec<Skipped>,
109}
110
111fn refused(message: &str) -> String {
112 format!("{REASON_RENDER_REFUSED}: {message}")
113}
114
115fn yaml_scalar(value: &str) -> String {
118 let plain = value
119 .chars()
120 .next()
121 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
122 && value
123 .chars()
124 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '/' | '-'))
125 && !matches!(
126 value.to_ascii_lowercase().as_str(),
127 "true" | "false" | "null" | "yes" | "no" | "on" | "off" | "y" | "n"
128 );
129 if plain {
130 value.to_string()
131 } else {
132 serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string())
134 }
135}
136
137pub fn generate_manifest(
148 live_types: &[(String, String)],
149 tag_providers: &[String],
150) -> (String, Vec<Skipped>) {
151 let mut types: BTreeSet<String> = BTreeSet::new();
152 let mut skipped: BTreeMap<String, String> = BTreeMap::new();
153 for (module, ty) in live_types {
154 if module == "ignition" && TAG_TYPES.contains(&ty.as_str()) {
155 continue;
156 }
157 let id = format!("{module}/{ty}");
158 if let Some(why) = component_problem(module).or_else(|| component_problem(ty)) {
159 skipped.insert(id, refused(&format!("the type name {why}")));
160 } else if is_denied(module, ty) {
161 skipped.insert(id, REASON_DENIED_TYPE.to_string());
162 } else if type_rule(module, ty).is_none() {
163 skipped.insert(id, REASON_TYPE_WITHOUT_RULE.to_string());
164 } else {
165 types.insert(id);
166 }
167 }
168 let mut providers: BTreeSet<String> = BTreeSet::new();
169 for name in tag_providers {
170 match component_problem(name) {
171 Some(why) => {
172 skipped.insert(
173 format!("ignition/tag-provider/{name}"),
174 refused(&format!("the tag provider name {why}")),
175 );
176 }
177 None => {
178 providers.insert(name.clone());
179 }
180 }
181 }
182
183 let mut yaml = String::from("schema_version: 1\n");
184 if types.is_empty() {
185 yaml.push_str("types: []\n");
186 } else {
187 yaml.push_str("types:\n");
188 for t in &types {
189 yaml.push_str(&format!(" - {}\n", yaml_scalar(t)));
190 }
191 }
192 if providers.is_empty() {
193 yaml.push_str("tag_providers: []\n");
194 } else {
195 yaml.push_str("tag_providers:\n");
196 for p in &providers {
197 yaml.push_str(&format!(" - {}\n", yaml_scalar(p)));
198 }
199 }
200 let skipped = skipped
201 .into_iter()
202 .map(|(path, reason)| Skipped { path, reason })
203 .collect();
204 (yaml, skipped)
205}
206
207#[derive(Default)]
209struct Unit {
210 core: Vec<RelPath>,
211 mode: Vec<RelPath>,
212}
213
214type Placed<'a> = Vec<(RelPath, &'a [u8])>;
216
217fn place<'a>(unit: &Unit, env: &str, observed: &'a ObservedFiles) -> Result<Placed<'a>, String> {
218 let mut placed = Placed::new();
219 for path in &unit.core {
220 placed.push((repo_path(path)?, observed.core[path].bytes.as_slice()));
221 }
222 for path in &unit.mode {
223 let base = repo_path(path)?;
224 placed.push((
225 RelPath::new(&format!("modes/{env}/{base}"))?,
226 observed.mode[path].bytes.as_slice(),
227 ));
228 }
229 Ok(placed)
230}
231
232fn units_at<'a>(index: &'a BTreeMap<String, &'a str>, path: &str) -> BTreeSet<&'a str> {
234 let trimmed = path.trim_end_matches('/');
235 let mut found = BTreeSet::new();
236 if let Some(unit) = index.get(trimmed) {
237 found.insert(*unit);
238 }
239 let prefix = format!("{trimmed}/");
240 for (file, unit) in index.range(prefix.clone()..) {
241 if !file.starts_with(&prefix) {
242 break;
243 }
244 found.insert(*unit);
245 }
246 found
247}
248
249fn unit_of_secret<'a>(index: &'a BTreeMap<String, &'a str>, location: &str) -> Option<&'a str> {
252 location
253 .match_indices(": ")
254 .find_map(|(at, _)| index.get(&location[..at]).copied())
255}
256
257pub fn pull(input: &PullInput<'_>) -> Result<PullOutcome, CoreError> {
259 let observed = input.observed;
260 let mut units: BTreeMap<String, Unit> = BTreeMap::new();
261 for path in observed.core.keys() {
262 units
263 .entry(unit_key(path, &observed.core))
264 .or_default()
265 .core
266 .push(path.clone());
267 }
268 for path in observed.mode.keys() {
269 units
270 .entry(unit_key(path, &observed.mode))
271 .or_default()
272 .mode
273 .push(path.clone());
274 }
275
276 let prefix = format!("gateways/{}/", input.gateway);
277 let mut skipped: BTreeMap<String, String> = BTreeMap::new();
278 let mut placed: BTreeMap<&str, Placed<'_>> = BTreeMap::new();
279 let mut index: BTreeMap<String, &str> = BTreeMap::new();
280 for (key, unit) in &units {
281 match place(unit, input.env, observed) {
282 Ok(files) => {
283 for (path, _) in &files {
284 index.insert(format!("{prefix}{path}"), key.as_str());
285 }
286 placed.insert(key.as_str(), files);
287 }
288 Err(why) => {
289 skipped.insert(key.clone(), refused(&why));
290 }
291 }
292 }
293
294 for _round in 0..PULL_MAX_ROUNDS {
295 let mut files = BTreeMap::new();
296 for (key, unit_files) in &placed {
297 if skipped.contains_key(*key) {
298 continue;
299 }
300 for (path, bytes) in unit_files {
301 files.insert(path.clone(), bytes.to_vec());
302 }
303 }
304 let source = SourceTree { files };
305
306 let result = render(&RenderInput {
307 gateway: input.gateway,
308 env: input.env,
309 manifest: input.manifest,
310 target: input.target,
311 source: &source,
312 });
313
314 let mut dropped: BTreeMap<String, String> = BTreeMap::new();
315 match result {
316 Ok(rendered) => {
317 let mismatches = mismatched_units(&units, &placed, &skipped, observed, &rendered)?;
318 if mismatches.is_empty() {
319 return Ok(PullOutcome {
320 source,
321 rendered,
322 skipped: skipped
323 .into_iter()
324 .map(|(path, reason)| Skipped { path, reason })
325 .collect(),
326 });
327 }
328 for key in mismatches {
329 dropped.insert(key, REASON_NOT_ROUND_TRIPPABLE.to_string());
330 }
331 }
332 Err(err) => {
333 let (secrets, findings): (&[String], &[DeployFinding]) = match &err {
334 CoreError::EmbeddedSecret(d) => (&d.paths, &d.other_findings),
335 CoreError::DeployTreeInvalid(d) => (&[], &d.findings),
336 _ => return Err(err),
337 };
338 let mut unmapped = false;
339 for location in secrets {
340 match unit_of_secret(&index, location) {
341 Some(key) => {
342 dropped.insert(key.to_string(), REASON_EMBEDDED_SECRET.to_string());
343 }
344 None => unmapped = true,
345 }
346 }
347 for finding in findings {
348 let hit = units_at(&index, &finding.file);
349 if hit.is_empty() {
350 unmapped = true;
351 }
352 for key in hit {
353 dropped
354 .entry(key.to_string())
355 .or_insert_with(|| refused(&finding.message));
356 }
357 }
358 if unmapped || dropped.is_empty() {
361 return Err(err);
362 }
363 }
364 }
365 skipped.extend(dropped);
366 }
367 Err(CoreError::deploy_tree_invalid(
368 format!("gateways/{}", input.gateway),
369 None,
370 None,
371 "pull did not converge",
372 ))
373}
374
375fn mismatched_units(
379 units: &BTreeMap<String, Unit>,
380 placed: &BTreeMap<&str, Placed<'_>>,
381 skipped: &BTreeMap<String, String>,
382 observed: &ObservedFiles,
383 rendered: &RenderOutcome,
384) -> Result<Vec<String>, CoreError> {
385 let mut groups: BTreeMap<String, BTreeMap<&RelPath, &Vec<u8>>> = BTreeMap::new();
386 for (path, bytes) in &rendered.tree.files {
387 groups
388 .entry(unit_key(path, &rendered.tree.files))
389 .or_default()
390 .insert(path, bytes);
391 }
392 let mut mismatched = Vec::new();
393 let mut kept: BTreeSet<&str> = BTreeSet::new();
394 for (key, unit) in units {
395 if skipped.contains_key(key) || !placed.contains_key(key.as_str()) {
396 continue;
397 }
398 kept.insert(key.as_str());
399 let (paths, layer) = if unit.mode.is_empty() {
400 (&unit.core, &observed.core)
401 } else {
402 (&unit.mode, &observed.mode)
403 };
404 let same = groups.get(key).is_some_and(|want| {
405 want.len() == paths.len()
406 && paths
407 .iter()
408 .all(|p| want.get(p).is_some_and(|b| b.as_slice() == layer[p].bytes))
409 });
410 if !same {
411 mismatched.push(key.clone());
412 }
413 }
414 if mismatched.is_empty()
415 && let Some(stray) = groups.keys().find(|k| !kept.contains(k.as_str()))
416 {
417 return Err(CoreError::deploy_tree_invalid(
418 "gateways",
419 None,
420 None,
421 format!("pull could not attribute the rendered unit `{stray}` to a live unit"),
422 ));
423 }
424 Ok(mismatched)
425}