use std::collections::{BTreeMap, BTreeSet};
use serde::Serialize;
use super::component_problem;
use super::layout::{RelPath, TAG_TYPES, repo_path};
use super::manifest::Manifest;
use super::plan::{unit_key, unit_key_layer, unreadable_unit_keys};
use super::render::{RenderInput, RenderOutcome, render};
use super::rules::{is_denied, type_rule};
use super::target::Target;
use super::transport::ObservedFiles;
use super::tree::SourceTree;
use crate::error::{CoreError, DeployFinding};
pub const PULL_MAX_ROUNDS: usize = 8;
pub const REASON_EMBEDDED_SECRET: &str = "embedded_secret";
pub const REASON_NOT_ROUND_TRIPPABLE: &str = "not_round_trippable";
pub const REASON_RENDER_REFUSED: &str = "render_refused";
pub const REASON_TYPE_WITHOUT_RULE: &str = "type_without_rule";
pub const REASON_DENIED_TYPE: &str = "denied_type";
pub const REASON_PATH_TOO_LONG: &str = "path_too_long";
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Skipped {
pub path: String,
pub reason: String,
}
#[derive(Debug, Clone, Copy)]
pub struct PullInput<'a> {
pub gateway: &'a str,
pub env: &'a str,
pub target: &'a Target,
pub manifest: &'a Manifest,
pub observed: &'a ObservedFiles,
}
#[derive(Debug, Clone)]
pub struct PullOutcome {
pub source: SourceTree,
pub rendered: RenderOutcome,
pub skipped: Vec<Skipped>,
}
fn refused(message: &str) -> String {
format!("{REASON_RENDER_REFUSED}: {message}")
}
fn yaml_scalar(value: &str) -> String {
let plain = value
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& value
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '/' | '-'))
&& !matches!(
value.to_ascii_lowercase().as_str(),
"true" | "false" | "null" | "yes" | "no" | "on" | "off" | "y" | "n"
);
if plain {
value.to_string()
} else {
serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string())
}
}
pub fn generate_manifest(
live_types: &[(String, String)],
tag_providers: &[String],
) -> (String, Vec<Skipped>) {
let mut types: BTreeSet<String> = BTreeSet::new();
let mut skipped: BTreeMap<String, String> = BTreeMap::new();
for (module, ty) in live_types {
if module == "ignition" && TAG_TYPES.contains(&ty.as_str()) {
continue;
}
let id = format!("{module}/{ty}");
if let Some(why) = component_problem(module).or_else(|| component_problem(ty)) {
skipped.insert(id, refused(&format!("the type name {why}")));
} else if is_denied(module, ty) {
skipped.insert(id, REASON_DENIED_TYPE.to_string());
} else if type_rule(module, ty).is_none() {
skipped.insert(id, REASON_TYPE_WITHOUT_RULE.to_string());
} else {
types.insert(id);
}
}
let mut providers: BTreeSet<String> = BTreeSet::new();
for name in tag_providers {
match component_problem(name) {
Some(why) => {
skipped.insert(
format!("ignition/tag-provider/{name}"),
refused(&format!("the tag provider name {why}")),
);
}
None => {
providers.insert(name.clone());
}
}
}
let mut yaml = String::from("schema_version: 1\n");
if types.is_empty() {
yaml.push_str("types: []\n");
} else {
yaml.push_str("types:\n");
for t in &types {
yaml.push_str(&format!(" - {}\n", yaml_scalar(t)));
}
}
if providers.is_empty() {
yaml.push_str("tag_providers: []\n");
} else {
yaml.push_str("tag_providers:\n");
for p in &providers {
yaml.push_str(&format!(" - {}\n", yaml_scalar(p)));
}
}
let skipped = skipped
.into_iter()
.map(|(path, reason)| Skipped { path, reason })
.collect();
(yaml, skipped)
}
#[derive(Default)]
struct Unit {
core: Vec<RelPath>,
mode: Vec<RelPath>,
}
type Placed<'a> = Vec<(RelPath, &'a [u8])>;
fn place<'a>(unit: &Unit, env: &str, observed: &'a ObservedFiles) -> Result<Placed<'a>, String> {
let mut placed = Placed::new();
for path in &unit.core {
placed.push((repo_path(path)?, observed.core[path].bytes.as_slice()));
}
for path in &unit.mode {
let base = repo_path(path)?;
placed.push((
RelPath::new(&format!("modes/{env}/{base}"))?,
observed.mode[path].bytes.as_slice(),
));
}
Ok(placed)
}
fn units_at<'a>(index: &'a BTreeMap<String, &'a str>, path: &str) -> BTreeSet<&'a str> {
let trimmed = path.trim_end_matches('/');
let mut found = BTreeSet::new();
if let Some(unit) = index.get(trimmed) {
found.insert(*unit);
}
let prefix = format!("{trimmed}/");
for (file, unit) in index.range(prefix.clone()..) {
if !file.starts_with(&prefix) {
break;
}
found.insert(*unit);
}
found
}
fn unit_of_secret<'a>(index: &'a BTreeMap<String, &'a str>, location: &str) -> Option<&'a str> {
location
.match_indices(": ")
.find_map(|(at, _)| index.get(&location[..at]).copied())
}
pub fn pull(input: &PullInput<'_>) -> Result<PullOutcome, CoreError> {
let observed = input.observed;
let core_layer = unit_key_layer(observed, "core");
let mode_layer = unit_key_layer(observed, "mode");
let mut units: BTreeMap<String, Unit> = BTreeMap::new();
for path in observed.core.keys() {
units
.entry(unit_key(path, &core_layer))
.or_default()
.core
.push(path.clone());
}
for path in observed.mode.keys() {
units
.entry(unit_key(path, &mode_layer))
.or_default()
.mode
.push(path.clone());
}
let prefix = format!("gateways/{}/", input.gateway);
let mut skipped: BTreeMap<String, String> = BTreeMap::new();
for key in unreadable_unit_keys(observed).into_keys() {
units.remove(&key);
skipped.insert(key, REASON_PATH_TOO_LONG.to_string());
}
let mut placed: BTreeMap<&str, Placed<'_>> = BTreeMap::new();
let mut index: BTreeMap<String, &str> = BTreeMap::new();
for (key, unit) in &units {
match place(unit, input.env, observed) {
Ok(files) => {
for (path, _) in &files {
index.insert(format!("{prefix}{path}"), key.as_str());
}
placed.insert(key.as_str(), files);
}
Err(why) => {
skipped.insert(key.clone(), refused(&why));
}
}
}
for _round in 0..PULL_MAX_ROUNDS {
let mut files = BTreeMap::new();
for (key, unit_files) in &placed {
if skipped.contains_key(*key) {
continue;
}
for (path, bytes) in unit_files {
files.insert(path.clone(), bytes.to_vec());
}
}
let source = SourceTree { files };
let result = render(&RenderInput {
gateway: input.gateway,
env: input.env,
manifest: input.manifest,
target: input.target,
source: &source,
});
let mut dropped: BTreeMap<String, String> = BTreeMap::new();
match result {
Ok(rendered) => {
let mismatches = mismatched_units(&units, &placed, &skipped, observed, &rendered)?;
if mismatches.is_empty() {
return Ok(PullOutcome {
source,
rendered,
skipped: skipped
.into_iter()
.map(|(path, reason)| Skipped { path, reason })
.collect(),
});
}
for key in mismatches {
dropped.insert(key, REASON_NOT_ROUND_TRIPPABLE.to_string());
}
}
Err(err) => {
let (secrets, findings): (&[String], &[DeployFinding]) = match &err {
CoreError::EmbeddedSecret(d) => (&d.paths, &d.other_findings),
CoreError::DeployTreeInvalid(d) => (&[], &d.findings),
_ => return Err(err),
};
let mut unmapped = false;
for location in secrets {
match unit_of_secret(&index, location) {
Some(key) => {
dropped.insert(key.to_string(), REASON_EMBEDDED_SECRET.to_string());
}
None => unmapped = true,
}
}
for finding in findings {
let hit = units_at(&index, &finding.file);
if hit.is_empty() {
unmapped = true;
}
for key in hit {
dropped
.entry(key.to_string())
.or_insert_with(|| refused(&finding.message));
}
}
if unmapped || dropped.is_empty() {
return Err(err);
}
}
}
skipped.extend(dropped);
}
Err(CoreError::deploy_tree_invalid(
format!("gateways/{}", input.gateway),
None,
None,
"pull did not converge",
))
}
fn mismatched_units(
units: &BTreeMap<String, Unit>,
placed: &BTreeMap<&str, Placed<'_>>,
skipped: &BTreeMap<String, String>,
observed: &ObservedFiles,
rendered: &RenderOutcome,
) -> Result<Vec<String>, CoreError> {
let mut groups: BTreeMap<String, BTreeMap<&RelPath, &Vec<u8>>> = BTreeMap::new();
for (path, bytes) in &rendered.tree.files {
groups
.entry(unit_key(path, &rendered.tree.files))
.or_default()
.insert(path, bytes);
}
let mut mismatched = Vec::new();
let mut kept: BTreeSet<&str> = BTreeSet::new();
for (key, unit) in units {
if skipped.contains_key(key) || !placed.contains_key(key.as_str()) {
continue;
}
kept.insert(key.as_str());
let (paths, layer) = if unit.mode.is_empty() {
(&unit.core, &observed.core)
} else {
(&unit.mode, &observed.mode)
};
let same = groups.get(key).is_some_and(|want| {
want.len() == paths.len()
&& paths
.iter()
.all(|p| want.get(p).is_some_and(|b| b.as_slice() == layer[p].bytes))
});
if !same {
mismatched.push(key.clone());
}
}
if mismatched.is_empty()
&& let Some(stray) = groups.keys().find(|k| !kept.contains(k.as_str()))
{
return Err(CoreError::deploy_tree_invalid(
"gateways",
None,
None,
format!("pull could not attribute the rendered unit `{stray}` to a live unit"),
));
}
Ok(mismatched)
}