manta_shared/shared/
sat_file.rs

1//! Jinja2 rendering helpers for SAT (System Admin Toolkit) template files
2//! and a structure-aware filter that operates on the parsed
3//! [`serde_json::Value`].
4//!
5//! The canonical SAT-file schema lives in csm-rs (which parses the value
6//! into typed structs during apply). The CLI never carries the typed
7//! schema — it only needs to:
8//!
9//! 1. Render Jinja2 templates with a values file + `--var` overrides
10//!    (the renderer takes parsed YAML as Jinja context for the values
11//!    file but produces a string for the SAT file content).
12//! 2. Parse the rendered SAT string into a `serde_json::Value` so the
13//!    server can forward it verbatim.
14//! 3. Apply `--image-only` / `--sessiontemplate-only` filters by
15//!    walking the Value (drop top-level sections plus prune unreferenced
16//!    configurations/images) before sending.
17//!
18//! Both `--image-only` and `--sessiontemplate-only` preserve the
19//! historical CLI semantics:
20//! - `--image-only`: drops `session_templates` + `hardware`; retains
21//!   only configurations referenced by the remaining images.
22//! - `--sessiontemplate-only`: drops `hardware`; retains images only
23//!   if named in a session_template; drops the `images` section
24//!   entirely if no image survives; retains only configurations
25//!   referenced by surviving images or session_templates.
26//!
27//! The walk navigates a small set of field names
28//! (`configurations`, `images`, `session_templates`, `hardware`,
29//! `name`, `configuration`, `image`, `image_ref`, `ims`) — no struct
30//! schema is embedded here.
31
32use std::collections::HashSet;
33
34use serde_json::Value as JsonValue;
35use serde_yaml::{Mapping, Value};
36
37use crate::common::error::MantaError as Error;
38
39/// Apply `--image-only` / `--sessiontemplate-only` filters to a parsed
40/// SAT file in-place.
41///
42/// Mirrors the prune-by-reference semantics historically implemented by
43/// the typed `SatFile::filter` method, but operates on
44/// [`serde_json::Value`] so the CLI does not need to embed the SAT
45/// schema. See the module-level docs for the exact filter rules.
46///
47/// # Errors
48///
49/// - [`Error::MissingField`] when `image_only` is set but no `images`
50///   section is present in the SAT file.
51/// - [`Error::MissingField`] when `session_template_only` is set but no
52///   `session_templates` section is present.
53pub fn apply_sat_file_filters(
54  sat_file: &mut JsonValue,
55  image_only: bool,
56  session_template_only: bool,
57) -> Result<(), Error> {
58  if image_only {
59    let obj = sat_file.as_object_mut().ok_or_else(|| {
60      Error::TemplateError(
61        "SAT file root is not a YAML/JSON mapping".to_string(),
62      )
63    })?;
64
65    if !obj.contains_key("images") {
66      return Err(Error::MissingField(
67        "'images' section missing in SAT file".to_string(),
68      ));
69    }
70
71    obj.remove("session_templates");
72    obj.remove("hardware");
73
74    let referenced: HashSet<String> = obj
75      .get("images")
76      .and_then(JsonValue::as_array)
77      .map(|imgs| {
78        imgs
79          .iter()
80          .filter_map(|img| {
81            img.get("configuration")?.as_str().map(str::to_string)
82          })
83          .collect()
84      })
85      .unwrap_or_default();
86
87    if let Some(configs) =
88      obj.get_mut("configurations").and_then(JsonValue::as_array_mut)
89    {
90      configs.retain(|cfg| {
91        cfg
92          .get("name")
93          .and_then(JsonValue::as_str)
94          .is_some_and(|n| referenced.contains(n))
95      });
96    }
97  }
98
99  if session_template_only {
100    let obj = sat_file.as_object_mut().ok_or_else(|| {
101      Error::TemplateError(
102        "SAT file root is not a YAML/JSON mapping".to_string(),
103      )
104    })?;
105
106    if !obj.contains_key("session_templates") {
107      return Err(Error::MissingField(
108        "'session_templates' section not defined in SAT file".to_string(),
109      ));
110    }
111
112    obj.remove("hardware");
113
114    // Names of images referenced by session_templates (either by
115    // `image_ref` or by `ims.name`).
116    let image_keep: HashSet<String> = obj
117      .get("session_templates")
118      .and_then(JsonValue::as_array)
119      .map(|sts| {
120        sts
121          .iter()
122          .filter_map(image_name_referenced_by_session_template)
123          .collect()
124      })
125      .unwrap_or_default();
126
127    // Retain images by name; drop the section if it ends up empty.
128    let images_empty = if let Some(imgs) =
129      obj.get_mut("images").and_then(JsonValue::as_array_mut)
130    {
131      imgs.retain(|img| {
132        img
133          .get("name")
134          .and_then(JsonValue::as_str)
135          .is_some_and(|n| image_keep.contains(n))
136      });
137      imgs.is_empty()
138    } else {
139      false
140    };
141    if images_empty {
142      obj.remove("images");
143    }
144
145    // Configurations to keep: referenced by surviving images OR by
146    // any session_template.
147    let mut config_keep: HashSet<String> = HashSet::new();
148    if let Some(imgs) = obj.get("images").and_then(JsonValue::as_array) {
149      for img in imgs {
150        if let Some(c) = img.get("configuration").and_then(JsonValue::as_str)
151        {
152          config_keep.insert(c.to_string());
153        }
154      }
155    }
156    if let Some(sts) =
157      obj.get("session_templates").and_then(JsonValue::as_array)
158    {
159      for st in sts {
160        if let Some(c) = st.get("configuration").and_then(JsonValue::as_str) {
161          config_keep.insert(c.to_string());
162        }
163      }
164    }
165
166    if let Some(configs) =
167      obj.get_mut("configurations").and_then(JsonValue::as_array_mut)
168    {
169      configs.retain(|cfg| {
170        cfg
171          .get("name")
172          .and_then(JsonValue::as_str)
173          .is_some_and(|n| config_keep.contains(n))
174      });
175    }
176  }
177
178  Ok(())
179}
180
181/// Extract the image name a session_template entry references, in either
182/// shape:
183/// - `image: { image_ref: "<name>" }`
184/// - `image: { ims: { name: "<name>" } }`
185///
186/// Returns `None` for `image: { ims: { id: "<id>" } }` (pre-built images
187/// referenced by ID — no name to filter on).
188fn image_name_referenced_by_session_template(
189  st: &JsonValue,
190) -> Option<String> {
191  let image = st.get("image")?;
192  if let Some(name) = image.get("image_ref").and_then(JsonValue::as_str) {
193    return Some(name.to_string());
194  }
195  image
196    .get("ims")
197    .and_then(|ims| ims.get("name"))
198    .and_then(JsonValue::as_str)
199    .map(str::to_string)
200}
201
202/// Merges two `serde_yaml::Value`s into a single `serde_yaml::Value`.
203/// `merge` values override `base` values when keys collide; sequences
204/// concatenate. Used to layer CLI `--var` overrides on top of a values
205/// file during Jinja rendering.
206fn merge_yaml(base: Value, merge: Value) -> Option<Value> {
207  match (base, merge) {
208    (Value::Mapping(mut base_map), Value::Mapping(merge_map)) => {
209      for (key, value) in merge_map {
210        if let Some(base_value) = base_map.get_mut(&key) {
211          *base_value = merge_yaml(base_value.clone(), value)?;
212        } else {
213          base_map.insert(key, value);
214        }
215      }
216      Some(Value::Mapping(base_map))
217    }
218    (Value::Sequence(mut base_seq), Value::Sequence(merge_seq)) => {
219      base_seq.extend(merge_seq);
220      Some(Value::Sequence(base_seq))
221    }
222    (_, merge) => Some(merge),
223  }
224}
225
226/// Convert a String dot notation expression into a `serde_yaml::Value`.
227/// eg:
228/// dot notation input like:
229/// ```text
230/// key_1.key_2.key_3=1
231/// ```
232/// would result in a `serde_yaml::Value` equivalent to:
233/// ```text
234/// key_1
235///   key_2
236///     key_3: 1
237/// ```
238fn dot_notation_to_yaml(dot_notation: &str) -> Result<Value, Error> {
239  let parts: Vec<&str> = dot_notation.split('=').collect();
240  if parts.len() != 2 {
241    return Err(Error::InvalidPattern("Invalid format".to_string()));
242  }
243
244  let keys: Vec<&str> = parts[0].trim().split('.').collect();
245  let value_str = parts[1].trim().trim_matches('"');
246  let value: Value = Value::String(value_str.to_string());
247
248  let mut root = Value::Mapping(Mapping::new());
249  let mut current_level = &mut root;
250
251  for (i, &key) in keys.iter().enumerate() {
252    if i == keys.len() - 1 {
253      if let Value::Mapping(map) = current_level {
254        map.insert(Value::String(key.to_string()), value.clone());
255      }
256    } else {
257      let next_level = if let Value::Mapping(map) = current_level {
258        if map.contains_key(Value::String(key.to_string())) {
259          map.get_mut(Value::String(key.to_string())).ok_or_else(|| {
260            Error::TemplateError(
261              "Failed to get mutable reference to existing YAML map entry"
262                .to_string(),
263            )
264          })?
265        } else {
266          map.insert(
267            Value::String(key.to_string()),
268            Value::Mapping(Mapping::new()),
269          );
270          map.get_mut(Value::String(key.to_string())).ok_or_else(|| {
271            Error::TemplateError(
272              "Failed to get mutable reference to newly inserted YAML map entry"
273                .to_string(),
274            )
275          })?
276        }
277      } else {
278        return Err(Error::TemplateError(
279          "Unexpected structure encountered".to_string(),
280        ));
281      };
282      current_level = next_level;
283    }
284  }
285
286  Ok(root)
287}
288
289/// Render a SAT file as a Jinja2 template, optionally merging a values
290/// file and CLI-provided overrides in dot notation. Returns the
291/// rendered SAT YAML as a string — callers parse it into the structured
292/// value they need (CLI parses to [`serde_json::Value`]).
293pub fn render_jinja2_sat_file_yaml(
294  sat_file_content: &str,
295  values_file_content_opt: Option<&str>,
296  value_cli_vec_opt: Option<&[String]>,
297) -> Result<String, Error> {
298  let mut env = minijinja::Environment::new();
299  // Set/enable debug in order to force minijinja to print debug error messages which are more
300  // descriptive. Eg https://github.com/mitsuhiko/minijinja/blob/main/examples/error/src/main.rs#L4-L5
301  env.set_debug(true);
302  // Set lines starting with `#` as comments
303  env.set_syntax(
304    minijinja::syntax::SyntaxConfig::builder()
305      .line_comment_prefix("#")
306      .build()
307      .map_err(|e| {
308        Error::TemplateError(format!(
309          "Failed to build jinja2 syntax config: {e}"
310        ))
311      })?,
312  );
313  // Set 'String' as undefined behaviour meaning, missing values won't pass the template
314  // rendering
315  env.set_undefined_behavior(minijinja::UndefinedBehavior::Strict);
316
317  // Render session values file
318  let mut values_file_yaml: Value = if let Some(values_file_content) =
319    values_file_content_opt
320  {
321    tracing::info!(
322      "'Session vars' file provided. Going to process SAT file as a jinja template."
323    );
324    tracing::info!("Expand variables in 'session vars' file");
325    let values_file_yaml: Value = serde_yaml::from_str(values_file_content)?;
326    let values_file_rendered = env
327      .render_str(values_file_content, values_file_yaml)
328      .map_err(|e| {
329        Error::TemplateError(format!("Error parsing values file to YAML: {e}"))
330      })?;
331    serde_yaml::from_str(&values_file_rendered)?
332  } else {
333    serde_yaml::from_str(sat_file_content)?
334  };
335
336  // Convert variable values sent by cli argument from dot notation to yaml format
337  tracing::debug!(
338    "Convert variable values sent by cli argument from dot notation to yaml format"
339  );
340  if let Some(value_option_vec) = value_cli_vec_opt {
341    for value_option in value_option_vec {
342      let cli_var_context_yaml = dot_notation_to_yaml(value_option)?;
343
344      values_file_yaml =
345        merge_yaml(values_file_yaml.clone(), cli_var_context_yaml).ok_or_else(
346          || {
347            Error::TemplateError(
348              "Failed to merge CLI variable values into \
349             SAT file YAML"
350                .to_string(),
351            )
352          },
353        )?;
354    }
355  }
356
357  // render sat template file
358  tracing::info!("Expand variables in 'SAT file'");
359  let sat_file_rendered = env
360    .render_str(sat_file_content, values_file_yaml)
361    .map_err(|e| {
362      Error::TemplateError(format!("Failed to render SAT file template: {e}"))
363    })?;
364
365  // Disable debug
366  env.set_debug(false);
367
368  Ok(sat_file_rendered)
369}
370
371#[cfg(test)]
372mod tests;