Skip to main content

manta_shared/shared/
sat_file.rs

1//! Deserialization types for HPE Cray SAT (System Admin Toolkit) YAML files.
2
3use crate::common::error::MantaError as Error;
4use image::Image;
5use serde::{Deserialize, Serialize};
6use serde_yaml::{Mapping, Value};
7
8use self::sessiontemplate::SessionTemplate;
9
10/// Top-level representation of a SAT YAML file.
11#[derive(Deserialize, Serialize, Debug)]
12pub struct SatFile {
13  /// CFS configurations to create or update.
14  #[serde(skip_serializing_if = "Option::is_none")]
15  pub configurations: Option<Vec<configuration::Configuration>>,
16  /// IMS images to build.
17  #[serde(skip_serializing_if = "Option::is_none")]
18  pub images: Option<Vec<image::Image>>,
19  /// BOS session templates to apply.
20  #[serde(skip_serializing_if = "Option::is_none")]
21  pub session_templates: Option<Vec<sessiontemplate::SessionTemplate>>,
22}
23
24impl SatFile {
25  /// Filter either images or session_templates section according to user request
26  pub fn filter(
27    &mut self,
28    image_only: bool,
29    session_template_only: bool,
30  ) -> Result<(), Error> {
31    // Clean SAT template file if user only wan'ts to process the 'images' section. In this case,
32    // we will remove 'session_templates' section from SAT fiel and also the entries in
33    // 'configurations' section not used
34    if image_only {
35      let image_vec_opt: Option<&[Image]> = self.images.as_deref();
36
37      let configuration_name_image_vec: Vec<String> = match image_vec_opt {
38        Some(image_vec) => image_vec
39          .iter()
40          .filter_map(|sat_template_image| {
41            sat_template_image.configuration.clone()
42          })
43          .collect(),
44        None => {
45          return Err(Error::MissingField(
46            "'images' section missing in SAT file".to_string(),
47          ));
48        }
49      };
50
51      // Remove configurations not used by any image
52      if let Some(configurations) = self.configurations.as_mut() {
53        configurations.retain(|configuration| {
54          configuration_name_image_vec.contains(&configuration.name)
55        });
56      }
57
58      // Remove section "session_templates"
59      self.session_templates = None;
60    }
61
62    // Clean SAT template file if user only wan'ts to process the 'session_template' section. In this case,
63    // we will remove 'images' section from SAT fiel and also the entries in
64    // 'configurations' section not used
65    if session_template_only {
66      let sessiontemplate_vec_opt: Option<&[SessionTemplate]> =
67        self.session_templates.as_deref();
68
69      let image_name_sessiontemplate_vec: Vec<String> = self
70        .session_templates
71        .as_deref()
72        .unwrap_or_default()
73        .iter()
74        .filter_map(|sessiontemplate| match &sessiontemplate.image {
75          sessiontemplate::Image::ImageRef { image_ref: name } => Some(name),
76          sessiontemplate::Image::Ims { ims } => match ims {
77            sessiontemplate::ImsDetails::Name { name } => Some(name),
78            sessiontemplate::ImsDetails::Id { .. } => None,
79          },
80        })
81        .cloned()
82        .collect();
83
84      // Remove images not used by any sessiontemplate
85      if let Some(images) = self.images.as_mut() {
86        images
87          .retain(|image| image_name_sessiontemplate_vec.contains(&image.name));
88      }
89
90      if self.images.as_ref().is_some_and(|images| images.is_empty()) {
91        self.images = None;
92      }
93
94      // Get configuration names from session templates
95      let configuration_name_sessiontemplate_vec: Vec<String> =
96        match sessiontemplate_vec_opt {
97          Some(sessiontemplate_vec) => sessiontemplate_vec
98            .iter()
99            .map(|sat_sessiontemplate| {
100              sat_sessiontemplate.configuration.clone()
101            })
102            .collect(),
103          None => {
104            return Err(Error::MissingField(
105              "'session_templates' section not defined \
106               in SAT file"
107                .to_string(),
108            ));
109          }
110        };
111
112      // Get configuration names from images used by the session templates
113      let configuration_name_image_vec: Vec<String> = self
114        .images
115        .as_deref()
116        .unwrap_or_default()
117        .iter()
118        .filter_map(|image| image.configuration.as_ref().cloned())
119        .collect();
120
121      // Merge configuration names from images and session templates
122      let configuration_to_keep_vec = [
123        configuration_name_image_vec,
124        configuration_name_sessiontemplate_vec,
125      ]
126      .concat();
127
128      // Remove configurations not used by any sessiontemplate or image used by the
129      // sessiontemplate
130
131      if let Some(configurations) = self.configurations.as_mut() {
132        configurations.retain(|configuration| {
133          configuration_to_keep_vec.contains(&configuration.name)
134        });
135      }
136    }
137
138    Ok(())
139  }
140}
141
142/// struct to represent the `session_templates` section in SAT file
143pub mod sessiontemplate {
144  use std::collections::HashMap;
145  use strum_macros::Display;
146
147  use serde::{Deserialize, Serialize};
148
149  /// A BOS session template linking an image, configuration,
150  /// and boot parameters for a set of nodes.
151  #[derive(Deserialize, Serialize, Debug)]
152  pub struct SessionTemplate {
153    pub name: String,
154    pub image: Image,
155    pub configuration: String,
156    pub bos_parameters: BosParamters,
157  }
158
159  /// How the IMS image is referenced — by name or by UUID.
160  #[derive(Deserialize, Serialize, Debug)]
161  #[serde(untagged)] // <-- this is important. More info https://serde.rs/enum-representations.html#untagged
162  pub enum ImsDetails {
163    /// Reference by human-readable IMS image name.
164    Name { name: String },
165    /// Reference by IMS image UUID.
166    Id { id: String },
167  }
168
169  /// Image reference within a session template — either an
170  /// IMS image or a cross-reference to another SAT image.
171  #[derive(Deserialize, Serialize, Debug)]
172  #[serde(untagged)] // <-- this is important. More info https://serde.rs/enum-representations.html#untagged
173  pub enum Image {
174    /// Directly identifies an IMS image via name or UUID.
175    Ims { ims: ImsDetails },
176    /// Cross-references the `name` of another image in the SAT `images` section.
177    ImageRef { image_ref: String },
178  }
179
180  /// BOS boot parameters containing a map of named boot sets.
181  #[derive(Deserialize, Serialize, Debug)]
182  pub struct BosParamters {
183    pub boot_sets: HashMap<String, BootSet>,
184  }
185
186  /// A single boot set defining the kernel, network, and node
187  /// targeting for a BOS session template.
188  #[derive(Deserialize, Serialize, Debug)]
189  pub struct BootSet {
190    #[serde(skip_serializing_if = "Option::is_none")]
191    pub arch: Option<Arch>,
192    #[serde(skip_serializing_if = "Option::is_none")]
193    pub kernel_parameters: Option<String>,
194    #[serde(skip_serializing_if = "Option::is_none")]
195    pub network: Option<String>,
196    #[serde(skip_serializing_if = "Option::is_none")]
197    pub node_list: Option<Vec<String>>,
198    #[serde(skip_serializing_if = "Option::is_none")]
199    pub node_roles_group: Option<Vec<String>>,
200    #[serde(skip_serializing_if = "Option::is_none")]
201    pub node_groups: Option<Vec<String>>,
202    #[serde(skip_serializing_if = "Option::is_none")]
203    pub rootfs_provider: Option<String>,
204    #[serde(skip_serializing_if = "Option::is_none")]
205    pub rootfs_provider_passthrough: Option<String>,
206  }
207
208  /// Processor architecture for a boot set.
209  #[derive(Deserialize, Serialize, Debug, Display)]
210  #[allow(clippy::upper_case_acronyms)]
211  pub enum Arch {
212    /// x86-64 nodes.
213    X86,
214    /// AArch64 / ARM nodes.
215    ARM,
216    /// Any other architecture.
217    Other,
218    /// Architecture could not be determined.
219    Unknown,
220  }
221}
222
223/// struct to represent the `images` section in SAT file
224pub mod image {
225  use serde::{Deserialize, Serialize};
226
227  /// Processor architecture for an IMS image build.
228  #[derive(Deserialize, Serialize, Debug)]
229  pub enum Arch {
230    /// 64-bit ARM (serialized as `"aarch64"`).
231    #[serde(rename(serialize = "aarch64", deserialize = "aarch64"))]
232    Aarch64,
233    /// x86-64 (serialized as `"x86_64"`).
234    #[serde(rename(serialize = "x86_64", deserialize = "x86_64"))]
235    X86_64,
236  }
237
238  /// Legacy IMS image reference with a recipe flag (older SAT format).
239  #[derive(Deserialize, Serialize, Debug)]
240  #[serde(untagged)] // <-- this is important. More info https://serde.rs/enum-representations.html#untagged
241  pub enum ImageIms {
242    /// Image identified by name; `is_recipe` indicates whether it is an IMS recipe.
243    NameIsRecipe { name: String, is_recipe: bool },
244    /// Image identified by UUID; `is_recipe` indicates whether it is an IMS recipe.
245    IdIsRecipe { id: String, is_recipe: bool },
246  }
247
248  /// Base IMS image reference used in newer SAT file format.
249  #[derive(Deserialize, Serialize, Debug)]
250  #[serde(untagged)] // <-- this is important. More info https://serde.rs/enum-representations.html#untagged
251  pub enum ImageBaseIms {
252    /// Image identified by name and type string.
253    NameType { name: String, r#type: String },
254    /// Image identified by UUID and type string.
255    IdType { id: String, r#type: String },
256    /// Older format with UUID and optional `is_recipe` flag.
257    BackwardCompatible { is_recipe: Option<bool>, id: String },
258  }
259
260  /// Criteria for filtering product catalog images.
261  #[derive(Deserialize, Serialize, Debug)]
262  #[serde(untagged)] // <-- this is important. More info https://serde.rs/enum-representations.html#untagged
263  pub enum Filter {
264    /// Match images whose name starts with `prefix`.
265    Prefix { prefix: String },
266    /// Match images whose name matches the `wildcard` glob.
267    Wildcard { wildcard: String },
268    /// Match images built for the given architecture.
269    Arch { arch: Arch },
270  }
271
272  /// A product catalog entry used as an image source.
273  #[derive(Deserialize, Serialize, Debug)]
274  pub struct Product {
275    name: String,
276    #[serde(skip_serializing_if = "Option::is_none")]
277    version: Option<String>,
278    r#type: String,
279    filter: Filter,
280  }
281
282  /// Source for a base image — IMS, product catalog, or cross-reference.
283  #[derive(Deserialize, Serialize, Debug)]
284  #[serde(untagged)] // <-- this is important. More info https://serde.rs/enum-representations.html#untagged
285  pub enum Base {
286    /// Directly references an IMS image by name, UUID, or type.
287    Ims { ims: ImageBaseIms },
288    /// Pulls the latest matching image from the product catalog.
289    Product { product: Product },
290    /// Cross-references the `name` of another image in the SAT `images` section.
291    ImageRef { image_ref: String },
292  }
293
294  /// Wrapper bridging the older `ims` key and the newer `base` key in SAT image entries.
295  #[derive(Deserialize, Serialize, Debug)]
296  #[serde(untagged)] // <-- this is important. More info https://serde.rs/enum-representations.html#untagged
297  pub enum BaseOrIms {
298    /// Newer format using the `base` key.
299    Base { base: Base },
300    /// Legacy format using the `ims` key with a recipe flag.
301    Ims { ims: ImageIms },
302  }
303
304  /// An image definition in the SAT file `images` section.
305  #[derive(Deserialize, Serialize, Debug)]
306  pub struct Image {
307    /// Unique name for this image; used as the cross-reference target for `image_ref`.
308    pub name: String,
309    /// Optional alias used to reference this image from session templates.
310    #[serde(skip_serializing_if = "Option::is_none")]
311    pub ref_name: Option<String>,
312    /// Base image source (IMS or product catalog), in legacy or current format.
313    #[serde(flatten)]
314    pub base_or_ims: BaseOrIms,
315    /// CFS configuration to apply when building the image.
316    #[serde(skip_serializing_if = "Option::is_none")]
317    pub configuration: Option<String>,
318    /// HSM group names passed as Ansible group vars during the image build.
319    #[serde(skip_serializing_if = "Option::is_none")]
320    pub configuration_group_names: Option<Vec<String>>,
321    #[serde(skip_serializing_if = "Option::is_none")]
322    pub description: Option<String>,
323  }
324}
325
326/// struct to represent the `configurations` section in SAT file
327pub mod configuration {
328  use serde::{Deserialize, Serialize};
329
330  /// A product reference within a CFS configuration layer.
331  /// Variants capture different ways to pin a version/branch.
332  #[derive(Deserialize, Serialize, Debug)]
333  #[serde(untagged)]
334  // <-- this is important. More info https://serde.rs/enum-representations.html#untagged
335  #[allow(clippy::enum_variant_names)]
336  pub enum Product {
337    /// Product pinned to a specific branch (and optionally a version).
338    ProductVersionBranch {
339      name: String,
340      version: Option<String>,
341      branch: String,
342    },
343    /// Product pinned to a specific commit (and optionally a version).
344    ProductVersionCommit {
345      name: String,
346      version: Option<String>,
347      commit: String,
348    },
349    /// Product pinned by exact version string.
350    ProductVersion { name: String, version: String },
351  }
352
353  /// A Git repository reference within a CFS configuration
354  /// layer, pinned by commit, branch, or tag.
355  #[derive(Deserialize, Serialize, Debug)]
356  #[serde(untagged)]
357  // <-- this is important. More info https://serde.rs/enum-representations.html#untagged
358  #[allow(clippy::enum_variant_names)]
359  pub enum Git {
360    /// Layer pinned to an exact commit SHA.
361    GitCommit { url: String, commit: String },
362    /// Layer pinned to a branch HEAD.
363    GitBranch { url: String, branch: String },
364    /// Layer pinned to a tag.
365    GitTag { url: String, tag: String },
366  }
367
368  /// Extra CFS layer parameters (e.g., requiring DKMS).
369  #[derive(Deserialize, Serialize, Debug)]
370  pub struct SpecialParameters {
371    pub ims_require_dkms: bool,
372  }
373
374  /// A CFS configuration layer sourced from a Git repo.
375  #[derive(Deserialize, Serialize, Debug)]
376  pub struct LayerGit {
377    #[serde(skip_serializing_if = "Option::is_none")]
378    pub name: Option<String>,
379    #[serde(skip_serializing_if = "Option::is_none")]
380    pub playbook: Option<String>, // This field is optional but with default value. Therefore we won't
381    pub git: Git,
382    pub special_parameters: Option<SpecialParameters>,
383  }
384
385  /// A CFS configuration layer sourced from a product catalog.
386  #[derive(Deserialize, Serialize, Debug)]
387  pub struct LayerProduct {
388    #[serde(skip_serializing_if = "Option::is_none")]
389    pub name: Option<String>,
390    #[serde(skip_serializing_if = "Option::is_none")]
391    pub playbook: Option<String>, // This field is optional but with default value. Therefore we won't
392    pub product: Product,
393  }
394
395  /// A CFS configuration layer — either Git-based or
396  /// product-catalog-based.
397  #[derive(Deserialize, Serialize, Debug)]
398  #[serde(untagged)] // <-- this is important. More info https://serde.rs/enum-representations.html#untagged
399  pub enum Layer {
400    /// CFS configuration layer sourced from a Git repository.
401    LayerGit(LayerGit),
402    /// CFS configuration layer sourced from a product catalog entry.
403    LayerProduct(LayerProduct),
404  }
405
406  /// An Ansible inventory source for a CFS configuration,
407  /// pinned by commit or branch.
408  #[derive(Deserialize, Serialize, Debug)]
409  #[serde(untagged)] // <-- this is important. More info https://serde.rs/enum-representations.html#untagged
410  pub enum Inventory {
411    /// Inventory repository pinned to a specific commit SHA.
412    InventoryCommit {
413      #[serde(skip_serializing_if = "Option::is_none")]
414      name: Option<String>,
415      url: String,
416      commit: String,
417    },
418    /// Inventory repository pinned to a branch HEAD.
419    InventoryBranch {
420      #[serde(skip_serializing_if = "Option::is_none")]
421      name: Option<String>,
422      url: String,
423      branch: String,
424    },
425  }
426
427  /// A CFS configuration definition in the SAT file
428  /// `configurations` section.
429  #[derive(Deserialize, Serialize, Debug)]
430  pub struct Configuration {
431    pub name: String,
432    #[serde(skip_serializing_if = "Option::is_none")]
433    pub description: Option<String>,
434    pub layers: Vec<Layer>,
435    #[serde(skip_serializing_if = "Option::is_none")]
436    pub additional_inventory: Option<Inventory>,
437  }
438}
439
440// Removed unused module sat_file_image_old which contained Ims and Product structs
441
442/// Merge 2 yamls, 'b' values will overwrite 'a' values
443/// eg:
444/// having a:
445///
446/// ```text
447/// key_1
448///   key_1_1: value_1_1
449///   key_1_2: value_1_2
450/// key_2: value_2
451/// key_3: value_3
452/// ```
453/// and b:
454/// ```text
455/// key_1
456///   key_1_1: new_value_1_1
457///   key_1_2: value_1_2
458///   key_1_3: new_value_1_3
459/// key_2: new_value_2
460/// key_4: new_value_4
461/// ```
462/// would convert a into:
463/// ```text
464/// key_1
465///   key_1_1: new_value_1_1
466///   key_1_3: new_value_1_3
467/// key_2: new_value_2
468/// key_3: value_3
469/// key_4: new_value_4
470/// ```
471fn merge_yaml(base: Value, merge: Value) -> Option<Value> {
472  match (base, merge) {
473    (Value::Mapping(mut base_map), Value::Mapping(merge_map)) => {
474      for (key, value) in merge_map {
475        if let Some(base_value) = base_map.get_mut(&key) {
476          *base_value = merge_yaml(base_value.clone(), value)?;
477        } else {
478          base_map.insert(key, value);
479        }
480      }
481      Some(Value::Mapping(base_map))
482    }
483    (Value::Sequence(mut base_seq), Value::Sequence(merge_seq)) => {
484      base_seq.extend(merge_seq);
485      Some(Value::Sequence(base_seq))
486    }
487    (_, merge) => Some(merge),
488  }
489}
490
491/// Convert a String dot notation expression into a serde_yaml::Value.
492/// eg:
493/// dot notation input like:
494/// ```text
495/// key_1.key_2.key_3=1
496/// ```
497/// would result in a serde_yaml::Value equivalent to:
498/// ```text
499/// key_1
500///   key_2
501///     key_3: 1
502/// ```
503fn dot_notation_to_yaml(
504  dot_notation: &str,
505) -> Result<serde_yaml::Value, Error> {
506  let parts: Vec<&str> = dot_notation.split('=').collect();
507  if parts.len() != 2 {
508    return Err(Error::InvalidPattern("Invalid format".to_string()));
509  }
510
511  let keys: Vec<&str> = parts[0].trim().split('.').collect();
512  let value_str = parts[1].trim().trim_matches('"'); // Remove leading and trailing quotes
513  let value: Value = Value::String(value_str.to_string());
514
515  let mut root = Value::Mapping(Mapping::new());
516  let mut current_level = &mut root;
517
518  for (i, &key) in keys.iter().enumerate() {
519    if i == keys.len() - 1 {
520      // Last key, assign the value
521      if let Value::Mapping(map) = current_level {
522        map.insert(Value::String(key.to_string()), value.clone());
523      }
524    } else {
525      // Not the last key, create or use existing map
526      let next_level = if let Value::Mapping(map) = current_level {
527        if map.contains_key(Value::String(key.to_string())) {
528          // Use existing map
529          map.get_mut(Value::String(key.to_string())).ok_or_else(|| {
530            Error::TemplateError(
531              "Failed to get mutable reference to \
532               existing YAML map entry"
533                .to_string(),
534            )
535          })?
536        } else {
537          // Create new map and insert
538          map.insert(
539            Value::String(key.to_string()),
540            Value::Mapping(Mapping::new()),
541          );
542          map.get_mut(Value::String(key.to_string())).ok_or_else(|| {
543            Error::TemplateError(
544              "Failed to get mutable reference to \
545               newly inserted YAML map entry"
546                .to_string(),
547            )
548          })?
549        }
550      } else {
551        return Err(Error::TemplateError(
552          "Unexpected structure encountered".to_string(),
553        ));
554      };
555      current_level = next_level;
556    }
557  }
558
559  Ok(root)
560}
561
562/// Render a SAT file as a Jinja2 template, optionally
563/// merging a values file and CLI-provided overrides in dot
564/// notation.
565pub fn render_jinja2_sat_file_yaml(
566  sat_file_content: &str,
567  values_file_content_opt: Option<&str>,
568  value_cli_vec_opt: Option<&[String]>,
569) -> Result<Value, Error> {
570  let mut env = minijinja::Environment::new();
571  // Set/enable debug in order to force minijinja to print debug error messages which are more
572  // descriptive. Eg https://github.com/mitsuhiko/minijinja/blob/main/examples/error/src/main.rs#L4-L5
573  env.set_debug(true);
574  // Set lines starting with `#` as comments
575  env.set_syntax(
576    minijinja::syntax::SyntaxConfig::builder()
577      .line_comment_prefix("#")
578      .build()
579      .map_err(|e| {
580        Error::TemplateError(format!(
581          "Failed to build jinja2 syntax config: {e}"
582        ))
583      })?,
584  );
585  // Set 'String' as undefined behaviour meaning, missing values won't pass the template
586  // rendering
587  env.set_undefined_behavior(minijinja::UndefinedBehavior::Strict);
588
589  // Render session values file
590  let mut values_file_yaml: Value = if let Some(values_file_content) =
591    values_file_content_opt
592  {
593    tracing::info!(
594      "'Session vars' file provided. Going to process SAT file as a jinja template."
595    );
596    tracing::info!("Expand variables in 'session vars' file");
597    // Read sesson vars file and parse it to YAML
598    let values_file_yaml: Value = serde_yaml::from_str(values_file_content)?;
599    // Render session vars file with itself (copying ansible behaviour where the ansible vars
600    // file is also a jinja template and combine both vars and values in it)
601    let values_file_rendered = env
602      .render_str(values_file_content, values_file_yaml)
603      .map_err(|e| {
604        Error::TemplateError(format!("Error parsing values file to YAML: {e}"))
605      })?;
606    serde_yaml::from_str(&values_file_rendered)?
607  } else {
608    serde_yaml::from_str(sat_file_content)?
609  };
610
611  // Convert variable values sent by cli argument from dot notation to yaml format
612  tracing::debug!(
613    "Convert variable values sent by cli argument from dot notation to yaml format"
614  );
615  if let Some(value_option_vec) = value_cli_vec_opt {
616    for value_option in value_option_vec {
617      let cli_var_context_yaml = dot_notation_to_yaml(value_option)?;
618
619      values_file_yaml =
620        merge_yaml(values_file_yaml.clone(), cli_var_context_yaml).ok_or_else(
621          || {
622            Error::TemplateError(
623              "Failed to merge CLI variable values into \
624             SAT file YAML"
625                .to_string(),
626            )
627          },
628        )?;
629    }
630  }
631
632  // render sat template file
633  tracing::info!("Expand variables in 'SAT file'");
634  let sat_file_rendered = env
635    .render_str(sat_file_content, values_file_yaml)
636    .map_err(|e| {
637      Error::TemplateError(format!("Failed to render SAT file template: {e}"))
638    })?;
639
640  // Disable debug
641  env.set_debug(false);
642
643  Ok(serde_yaml::from_str(&sat_file_rendered)?)
644}
645
646#[cfg(test)]
647mod tests;