use serde_yaml::{Mapping, Value};
use manta_shared::common::error::MantaError as Error;
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<Value, Error> {
let parts: Vec<&str> = dot_notation.split('=').collect();
if parts.len() != 2 {
return Err(Error::InvalidPattern("Invalid format".to_string()));
}
let path = parts[0].trim();
let value_str = parts[1].trim().trim_matches('"');
let leaf = Value::String(value_str.to_string());
Ok(path.split('.').rev().fold(leaf, |acc, key| {
let mut m = Mapping::new();
m.insert(Value::String(key.to_string()), acc);
Value::Mapping(m)
}))
}
pub fn render_jinja2_sat_file_yaml(
sat_file_content: &str,
values_file_content_opt: Option<&str>,
value_cli_vec_opt: Option<&[String]>,
) -> Result<String, Error> {
let mut env = minijinja::Environment::new();
env.set_debug(true);
env.set_syntax(
minijinja::syntax::SyntaxConfig::builder()
.line_comment_prefix("#")
.build()
.map_err(|e| {
Error::TemplateError(format!(
"Failed to build jinja2 syntax config: {e}"
))
})?,
);
env.set_undefined_behavior(minijinja::UndefinedBehavior::Strict);
let mut values_file_yaml: Value = if let Some(values_file_content) =
values_file_content_opt
{
tracing::info!(
"'Session vars' file provided. Going to process SAT file as a jinja template."
);
tracing::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)
.map_err(|e| {
Error::TemplateError(format!("Error parsing values file to YAML: {e}"))
})?;
serde_yaml::from_str(&values_file_rendered)?
} else {
serde_yaml::from_str(sat_file_content)?
};
tracing::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(
|| {
Error::TemplateError(
"Failed to merge CLI variable values into \
SAT file YAML"
.to_string(),
)
},
)?;
}
}
tracing::info!("Expand variables in 'SAT file'");
let sat_file_rendered = env
.render_str(sat_file_content, values_file_yaml)
.map_err(|e| {
Error::TemplateError(format!("Failed to render SAT file template: {e}"))
})?;
Ok(sat_file_rendered)
}
#[cfg(test)]
mod tests {
use super::*;
#[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 test_render_sat_file_yaml_template_with_yaml_values_file() {
let sat_file_content = r#"
name: "{{ config.name }}"
configurations:
- name: "{{ config.name }}-{{ config.version }}"
layers:
- name: ss11
playbook: shs_cassini_install.yml
git:
url: https://api-gw-service-nmn.local/vcs/cray/slingshot-host-software-config-management.git
branch: integration
- name: cos
playbook: site.yml
product:
name: cos
version: 2.3.101
branch: integration
- name: cscs
playbook: site.yml
git:
url: https://api-gw-service-nmn.local/vcs/cray/cscs-config-management.git
branch: cscs-23.06.0
- name: nomad-orchestrator
playbook: site-client.yml
git:
url: https://api-gw-service-nmn.local/vcs/cray/nomad_orchestrator.git
branch: main
images:
- name: zinal-nomad-{{ image.version }}
ims:
is_recipe: false
id: 4bf91021-8d99-4adf-945f-46de2ff50a3d
configuration: "{{ config.name }}-{{ config.version }}"
configuration_group_names:
- Compute
- "{{ hsm.group_name }}"
session_templates:
- name: "{{ bos_st.name }}"
image: zinal-image-v0.5
configuration: "{{ config.name }}-{{ config.version }}"
bos_parameters:
boot_sets:
compute:
kernel_parameters: ip=dhcp quiet spire_join_token=${SPIRE_JOIN_TOKEN}
node_groups:
- "{{ hsm.group_name }}"
"#;
let values_file_content = r#"
hsm:
group_name: "zinal_cta"
config:
name: "test-config"
version: "v1.0.0"
image:
version: "v1.0.5"
bos_st:
name: "deploy-cluster-action"
version: "v1.0"
"#;
let var_content: Vec<String> = vec!["config.name = new-value".to_string()];
let rendered = render_jinja2_sat_file_yaml(
sat_file_content,
Some(values_file_content),
Some(&var_content),
)
.unwrap();
let parsed: Value = serde_yaml::from_str(&rendered).unwrap();
let name = parsed.get("name").unwrap().as_str().unwrap();
assert_eq!(name, "new-value", "CLI --var should override values file");
let configs = parsed.get("configurations").unwrap().as_sequence().unwrap();
assert_eq!(configs.len(), 1);
let config_name = configs[0].get("name").unwrap().as_str().unwrap();
assert_eq!(config_name, "new-value-v1.0.0");
let images = parsed.get("images").unwrap().as_sequence().unwrap();
let image_name = images[0].get("name").unwrap().as_str().unwrap();
assert_eq!(image_name, "zinal-nomad-v1.0.5");
let image_groups = images[0]
.get("configuration_group_names")
.unwrap()
.as_sequence()
.unwrap();
assert_eq!(image_groups[1].as_str().unwrap(), "zinal_cta");
let templates = parsed
.get("session_templates")
.unwrap()
.as_sequence()
.unwrap();
let st_name = templates[0].get("name").unwrap().as_str().unwrap();
assert_eq!(st_name, "deploy-cluster-action");
let st_config =
templates[0].get("configuration").unwrap().as_str().unwrap();
assert_eq!(st_config, "new-value-v1.0.0");
}
#[test]
fn test_render_sat_file_without_values_file() {
let sat_file_content = r#"
name: "{{ config.name }}"
"#;
let result = render_jinja2_sat_file_yaml(sat_file_content, None, None);
assert!(
result.is_err(),
"Rendering with undefined variables should fail"
);
}
#[test]
fn test_render_plain_sat_file_no_variables() {
let sat_file_content = r#"
name: "my-config"
configurations:
- name: "static-config"
layers:
- name: layer1
playbook: site.yml
git:
url: https://example.com/repo.git
branch: main
"#;
let rendered =
render_jinja2_sat_file_yaml(sat_file_content, None, None).unwrap();
let parsed: Value = serde_yaml::from_str(&rendered).unwrap();
assert_eq!(parsed.get("name").unwrap().as_str().unwrap(), "my-config");
let configs = parsed.get("configurations").unwrap().as_sequence().unwrap();
assert_eq!(
configs[0].get("name").unwrap().as_str().unwrap(),
"static-config"
);
}
#[test]
fn test_render_sat_file_with_values_no_overrides() {
let sat_file_content = r#"
name: "{{ app.name }}"
version: "{{ app.version }}"
"#;
let values_file_content = r#"
app:
name: "my-app"
version: "2.0"
"#;
let rendered = render_jinja2_sat_file_yaml(
sat_file_content,
Some(values_file_content),
None,
)
.unwrap();
let parsed: Value = serde_yaml::from_str(&rendered).unwrap();
assert_eq!(parsed.get("name").unwrap().as_str().unwrap(), "my-app");
assert_eq!(parsed.get("version").unwrap().as_str().unwrap(), "2.0");
}
}