use anyhow::{bail, Context};
use image::Image;
use serde::{Deserialize, Serialize};
use serde_yaml::{Mapping, Value};
use self::sessiontemplate::SessionTemplate;
#[derive(Deserialize, Serialize, Debug)]
pub struct SatFile {
#[serde(skip_serializing_if = "Option::is_none")]
pub configurations: Option<Vec<configuration::Configuration>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub images: Option<Vec<image::Image>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_templates: Option<Vec<sessiontemplate::SessionTemplate>>,
}
impl SatFile {
pub fn filter(
&mut self,
image_only: bool,
session_template_only: bool,
) -> Result<(), anyhow::Error> {
if image_only {
let image_vec_opt: Option<&[Image]> = self.images.as_deref();
let configuration_name_image_vec: Vec<String> = match image_vec_opt {
Some(image_vec) => image_vec
.iter()
.filter_map(|sat_template_image| {
sat_template_image.configuration.clone()
})
.collect(),
None => {
bail!("'images' section missing in SAT file");
}
};
if let Some(configurations) = self.configurations.as_mut() {
configurations.retain(|configuration| {
configuration_name_image_vec.contains(&configuration.name)
});
}
self.session_templates = None;
}
if session_template_only {
let sessiontemplate_vec_opt: Option<&[SessionTemplate]> =
self.session_templates.as_deref();
let image_name_sessiontemplate_vec: Vec<String> = self
.session_templates
.as_deref()
.unwrap_or_default()
.iter()
.filter_map(|sessiontemplate| match &sessiontemplate.image {
sessiontemplate::Image::ImageRef { image_ref: name } => Some(name),
sessiontemplate::Image::Ims { ims } => match ims {
sessiontemplate::ImsDetails::Name { name } => Some(name),
sessiontemplate::ImsDetails::Id { .. } => None,
},
})
.cloned()
.collect();
if let Some(images) = self.images.as_mut() {
images
.retain(|image| image_name_sessiontemplate_vec.contains(&image.name));
}
if self.images.as_ref().is_some_and(|images| images.is_empty()) {
self.images = None;
}
let configuration_name_sessiontemplate_vec: Vec<String> =
match sessiontemplate_vec_opt {
Some(sessiontemplate_vec) => sessiontemplate_vec
.iter()
.map(|sat_sessiontemplate| {
sat_sessiontemplate.configuration.clone()
})
.collect(),
None => {
bail!(
"'session_templates' section not defined \
in SAT file"
);
}
};
let configuration_name_image_vec: Vec<String> = self
.images
.as_deref()
.unwrap_or_default()
.iter()
.filter_map(|image| image.configuration.as_ref().cloned())
.collect();
let configuration_to_keep_vec = [
configuration_name_image_vec,
configuration_name_sessiontemplate_vec,
]
.concat();
if let Some(configurations) = self.configurations.as_mut() {
configurations.retain(|configuration| {
configuration_to_keep_vec.contains(&configuration.name)
});
}
}
Ok(())
}
}
pub mod sessiontemplate {
use std::collections::HashMap;
use strum_macros::Display;
use serde::{Deserialize, Serialize};
#[derive(Deserialize, Serialize, Debug)]
pub struct SessionTemplate {
pub name: String,
pub image: Image,
pub configuration: String,
pub bos_parameters: BosParamters,
}
#[derive(Deserialize, Serialize, Debug)]
#[serde(untagged)] pub enum ImsDetails {
Name { name: String },
Id { id: String },
}
#[derive(Deserialize, Serialize, Debug)]
#[serde(untagged)] pub enum Image {
Ims { ims: ImsDetails },
ImageRef { image_ref: String },
}
#[derive(Deserialize, Serialize, Debug)]
pub struct BosParamters {
pub boot_sets: HashMap<String, BootSet>,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct BootSet {
#[serde(skip_serializing_if = "Option::is_none")]
pub arch: Option<Arch>,
#[serde(skip_serializing_if = "Option::is_none")]
pub kernel_parameters: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub network: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub node_list: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub node_roles_group: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub node_groups: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rootfs_provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rootfs_provider_passthrough: Option<String>,
}
#[derive(Deserialize, Serialize, Debug, Display)]
#[allow(clippy::upper_case_acronyms)]
pub enum Arch {
X86,
ARM,
Other,
Unknown,
}
}
pub mod image {
use serde::{Deserialize, Serialize};
#[derive(Deserialize, Serialize, Debug)]
pub enum Arch {
#[serde(rename(serialize = "aarch64", deserialize = "aarch64"))]
Aarch64,
#[serde(rename(serialize = "x86_64", deserialize = "x86_64"))]
X86_64,
}
#[derive(Deserialize, Serialize, Debug)]
#[serde(untagged)] pub enum ImageIms {
NameIsRecipe { name: String, is_recipe: bool },
IdIsRecipe { id: String, is_recipe: bool },
}
#[derive(Deserialize, Serialize, Debug)]
#[serde(untagged)] pub enum ImageBaseIms {
NameType { name: String, r#type: String },
IdType { id: String, r#type: String },
BackwardCompatible { is_recipe: Option<bool>, id: String },
}
#[derive(Deserialize, Serialize, Debug)]
#[serde(untagged)] pub enum Filter {
Prefix { prefix: String },
Wildcard { wildcard: String },
Arch { arch: Arch },
}
#[derive(Deserialize, Serialize, Debug)]
pub struct Product {
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
version: Option<String>,
r#type: String,
filter: Filter,
}
#[derive(Deserialize, Serialize, Debug)]
#[serde(untagged)] pub enum Base {
Ims { ims: ImageBaseIms },
Product { product: Product },
ImageRef { image_ref: String },
}
#[derive(Deserialize, Serialize, Debug)]
#[serde(untagged)] pub enum BaseOrIms {
Base { base: Base },
Ims { ims: ImageIms },
}
#[derive(Deserialize, Serialize, Debug)]
pub struct Image {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub ref_name: Option<String>,
#[serde(flatten)]
pub base_or_ims: BaseOrIms,
#[serde(skip_serializing_if = "Option::is_none")]
pub configuration: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub configuration_group_names: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
}
pub mod configuration {
use serde::{Deserialize, Serialize};
#[derive(Deserialize, Serialize, Debug)]
#[serde(untagged)]
#[allow(clippy::enum_variant_names)]
pub enum Product {
ProductVersionBranch {
name: String,
version: Option<String>,
branch: String,
},
ProductVersionCommit {
name: String,
version: Option<String>,
commit: String,
},
ProductVersion {
name: String,
version: String,
},
}
#[derive(Deserialize, Serialize, Debug)]
#[serde(untagged)]
#[allow(clippy::enum_variant_names)]
pub enum Git {
GitCommit { url: String, commit: String },
GitBranch { url: String, branch: String },
GitTag { url: String, tag: String },
}
#[derive(Deserialize, Serialize, Debug)]
pub struct SpecialParameters {
pub ims_require_dkms: bool,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct LayerGit {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub playbook: Option<String>, pub git: Git,
pub special_parameters: Option<SpecialParameters>,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct LayerProduct {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub playbook: Option<String>, pub product: Product,
}
#[derive(Deserialize, Serialize, Debug)]
#[serde(untagged)] pub enum Layer {
LayerGit(LayerGit),
LayerProduct(LayerProduct),
}
#[derive(Deserialize, Serialize, Debug)]
#[serde(untagged)] pub enum Inventory {
InventoryCommit {
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
url: String,
commit: String,
},
InventoryBranch {
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
url: String,
branch: String,
},
}
#[derive(Deserialize, Serialize, Debug)]
pub struct Configuration {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub layers: Vec<Layer>,
#[serde(skip_serializing_if = "Option::is_none")]
pub additional_inventory: Option<Inventory>,
}
}
fn merge_yaml(base: Value, merge: Value) -> Option<Value> {
match (base, merge) {
(Value::Mapping(mut base_map), Value::Mapping(merge_map)) => {
for (key, value) in merge_map {
if let Some(base_value) = base_map.get_mut(&key) {
*base_value = merge_yaml(base_value.clone(), value)?;
} else {
base_map.insert(key, value);
}
}
Some(Value::Mapping(base_map))
}
(Value::Sequence(mut base_seq), Value::Sequence(merge_seq)) => {
base_seq.extend(merge_seq);
Some(Value::Sequence(base_seq))
}
(_, merge) => Some(merge),
}
}
fn dot_notation_to_yaml(
dot_notation: &str,
) -> Result<serde_yaml::Value, anyhow::Error> {
let parts: Vec<&str> = dot_notation.split('=').collect();
if parts.len() != 2 {
bail!("Invalid format");
}
let keys: Vec<&str> = parts[0].trim().split('.').collect();
let value_str = parts[1].trim().trim_matches('"'); let value: Value = Value::String(value_str.to_string());
let mut root = Value::Mapping(Mapping::new());
let mut current_level = &mut root;
for (i, &key) in keys.iter().enumerate() {
if i == keys.len() - 1 {
if let Value::Mapping(map) = current_level {
map.insert(Value::String(key.to_string()), value.clone());
}
} else {
let next_level = if let Value::Mapping(map) = current_level {
if map.contains_key(Value::String(key.to_string())) {
map.get_mut(Value::String(key.to_string())).ok_or_else(|| {
anyhow::anyhow!(
"Failed to get mutable reference to \
existing YAML map entry"
)
})?
} else {
map.insert(
Value::String(key.to_string()),
Value::Mapping(Mapping::new()),
);
map.get_mut(Value::String(key.to_string())).ok_or_else(|| {
anyhow::anyhow!(
"Failed to get mutable reference to \
newly inserted YAML map entry"
)
})?
}
} else {
return Err(anyhow::anyhow!("Unexpected structure encountered"));
};
current_level = next_level;
}
}
Ok(root)
}
pub fn render_jinja2_sat_file_yaml(
sat_file_content: &str,
values_file_content_opt: Option<&str>,
value_cli_vec_opt: Option<&[String]>,
) -> Result<Value, anyhow::Error> {
let mut env = minijinja::Environment::new();
env.set_debug(true);
env.set_syntax(
minijinja::syntax::SyntaxConfig::builder()
.line_comment_prefix("#")
.build()?,
);
env.set_undefined_behavior(minijinja::UndefinedBehavior::Strict);
let mut values_file_yaml: Value = if let Some(values_file_content) =
values_file_content_opt
{
log::info!(
"'Session vars' file provided. Going to process SAT file as a jinja template."
);
log::info!("Expand variables in 'session vars' file");
let values_file_yaml: Value = serde_yaml::from_str(values_file_content)?;
let values_file_rendered = env
.render_str(values_file_content, values_file_yaml)
.context("Error parsing values file to YAML")?;
serde_yaml::from_str(&values_file_rendered)?
} else {
serde_yaml::from_str(sat_file_content)?
};
log::debug!(
"Convert variable values sent by cli argument from dot notation to yaml format"
);
if let Some(value_option_vec) = value_cli_vec_opt {
for value_option in value_option_vec {
let cli_var_context_yaml = dot_notation_to_yaml(value_option)?;
values_file_yaml =
merge_yaml(values_file_yaml.clone(), cli_var_context_yaml).ok_or_else(
|| {
anyhow::Error::msg(
"Failed to merge CLI variable values into \
SAT file YAML",
)
},
)?;
}
}
log::info!("Expand variables in 'SAT file'");
let sat_file_rendered = env.render_str(sat_file_content, values_file_yaml)?;
env.set_debug(false);
let sat_file_yaml: Value = serde_yaml::from_str(&sat_file_rendered)?;
Ok(sat_file_yaml)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_yaml::Value;
#[test]
fn merge_yaml_scalars_overwrite() {
let base = Value::String("old".into());
let merge = Value::String("new".into());
let result = merge_yaml(base, merge).unwrap();
assert_eq!(result, Value::String("new".into()));
}
#[test]
fn merge_yaml_maps_deep_merge() {
let base: Value = serde_yaml::from_str("a:\n b: 1\n c: 2").unwrap();
let merge: Value = serde_yaml::from_str("a:\n b: 99\n d: 3").unwrap();
let result = merge_yaml(base, merge).unwrap();
let a = result.get("a").unwrap();
assert_eq!(a.get("b").unwrap().as_u64(), Some(99));
assert_eq!(a.get("c").unwrap().as_u64(), Some(2));
assert_eq!(a.get("d").unwrap().as_u64(), Some(3));
}
#[test]
fn merge_yaml_sequences_concatenated() {
let base: Value = serde_yaml::from_str("[1, 2]").unwrap();
let merge: Value = serde_yaml::from_str("[3, 4]").unwrap();
let result = merge_yaml(base, merge).unwrap();
let seq = result.as_sequence().unwrap();
assert_eq!(seq.len(), 4);
}
#[test]
fn merge_yaml_adds_new_top_level_keys() {
let base: Value = serde_yaml::from_str("x: 1").unwrap();
let merge: Value = serde_yaml::from_str("y: 2").unwrap();
let result = merge_yaml(base, merge).unwrap();
assert_eq!(result.get("x").unwrap().as_u64(), Some(1));
assert_eq!(result.get("y").unwrap().as_u64(), Some(2));
}
#[test]
fn dot_notation_single_key() {
let result = dot_notation_to_yaml("key=value").unwrap();
assert_eq!(result.get("key").unwrap().as_str(), Some("value"));
}
#[test]
fn dot_notation_nested_keys() {
let result = dot_notation_to_yaml("a.b.c=hello").unwrap();
let a = result.get("a").unwrap();
let b = a.get("b").unwrap();
assert_eq!(b.get("c").unwrap().as_str(), Some("hello"));
}
#[test]
fn dot_notation_strips_quotes_from_value() {
let result = dot_notation_to_yaml("key=\"quoted\"").unwrap();
assert_eq!(result.get("key").unwrap().as_str(), Some("quoted"));
}
#[test]
fn dot_notation_invalid_format_no_equals() {
assert!(dot_notation_to_yaml("no_equals_sign").is_err());
}
#[test]
fn dot_notation_multiple_equals_rejected() {
assert!(dot_notation_to_yaml("a=b=c").is_err());
}
#[test]
fn filter_image_only_removes_session_templates() {
let yaml = r#"
configurations:
- name: cfg-used
layers:
- name: layer1
git:
url: https://example.com/repo.git
branch: main
- name: cfg-unused
layers:
- name: layer2
git:
url: https://example.com/repo.git
branch: main
images:
- name: img1
ims:
is_recipe: false
id: abc-123
configuration: cfg-used
session_templates:
- name: st1
image:
image_ref: img1
configuration: cfg-used
bos_parameters:
boot_sets:
compute:
node_groups:
- group1
"#;
let mut sat: SatFile = serde_yaml::from_str(yaml).unwrap();
sat.filter(true, false).unwrap();
assert!(sat.session_templates.is_none());
let configs = sat.configurations.unwrap();
assert_eq!(configs.len(), 1);
assert_eq!(configs[0].name, "cfg-used");
}
#[test]
fn filter_session_template_only_removes_unused_images() {
let yaml = r#"
configurations:
- name: cfg-st
layers:
- name: layer1
git:
url: https://example.com/repo.git
branch: main
- name: cfg-img-only
layers:
- name: layer2
git:
url: https://example.com/repo.git
branch: main
images:
- name: used-image
ims:
is_recipe: false
id: abc-123
configuration: cfg-img-only
- name: unused-image
ims:
is_recipe: false
id: def-456
session_templates:
- name: st1
image:
image_ref: used-image
configuration: cfg-st
bos_parameters:
boot_sets:
compute:
node_groups:
- group1
"#;
let mut sat: SatFile = serde_yaml::from_str(yaml).unwrap();
sat.filter(false, true).unwrap();
let images = sat.images.unwrap();
assert_eq!(images.len(), 1);
assert_eq!(images[0].name, "used-image");
}
#[test]
fn filter_neither_flag_is_noop() {
let yaml = r#"
configurations:
- name: cfg1
layers:
- name: layer1
git:
url: https://example.com/repo.git
branch: main
images:
- name: img1
ims:
is_recipe: false
id: abc-123
session_templates:
- name: st1
image:
image_ref: img1
configuration: cfg1
bos_parameters:
boot_sets:
compute:
node_groups:
- group1
"#;
let mut sat: SatFile = serde_yaml::from_str(yaml).unwrap();
sat.filter(false, false).unwrap();
assert!(sat.images.is_some());
assert!(sat.session_templates.is_some());
assert!(sat.configurations.is_some());
}
}