Skip to main content

manta_shared/image_session/
mod.rs

1//! Provenance metadata attached to an IMS image after a successful CFS
2//! session.
3//!
4//! An IMS image is produced as the output of a CFS session. This module
5//! writes three CFS-derived facts onto the resulting `Image`'s
6//! `metadata` HashMap so the image is self-describing:
7//!
8//! - `manta.image_session.base` — the source/base image id the new
9//!   image was built on top of (from `cfs.target.image_map[0].source_id`)
10//! - `manta.image_session.groups` — JSON-encoded array of HSM group
11//!   names the image targets (from `cfs.target.groups[*].name`)
12//! - `manta.image_session.configuration` — the CFS configuration name
13//!   that was applied (from `cfs.configuration.name`)
14//!
15//! The IMS-side facts (`id`, `created`, `name`, `arch`) are not
16//! duplicated here — they already live on `Image` directly.
17//!
18//! There is intentionally no managed struct: `Image.metadata` is the
19//! storage. CRUD maps to three helpers:
20//!
21//! | Op     | Helper             |
22//! |--------|--------------------|
23//! | Create | `apply`            |
24//! | Read   | `read`             |
25//! | Update | `apply` (full replace) |
26//! | Delete | `clear`            |
27
28use std::collections::HashMap;
29
30use manta_backend_dispatcher::types::cfs::session::CfsSessionGetResponse;
31use manta_backend_dispatcher::types::ims::Image;
32
33use crate::common::error::MantaError;
34
35/// Metadata key for the base (source) image id the IMS image was built on.
36pub const META_BASE: &str = "manta.image_session.base";
37/// Metadata key for the JSON-encoded array of HSM group names.
38pub const META_GROUPS: &str = "manta.image_session.groups";
39/// Metadata key for the CFS configuration name that produced the image.
40pub const META_CONFIGURATION: &str = "manta.image_session.configuration";
41
42/// CFS-derived metadata returned by [`read`].
43///
44/// Holds only the three values read back from the IMS image's metadata
45/// entries. Not stored anywhere; exists so [`read`] can return a named
46/// shape instead of a `(String, Vec<String>, String)` tuple.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct ImageSessionMetadata {
49  /// Source/base image id (`manta.image_session.base`).
50  pub base: String,
51  /// HSM group names (`manta.image_session.groups`, JSON-decoded).
52  pub groups: Vec<String>,
53  /// CFS configuration name (`manta.image_session.configuration`).
54  pub configuration: String,
55}
56
57/// Write the CFS-derived provenance metadata onto `ims`.
58///
59/// Call this after the CFS session that produced `ims` has finished
60/// successfully. Idempotent: calling twice with the same `cfs` is a
61/// no-op; calling with a different `cfs` overwrites all three keys.
62///
63/// Initializes `ims.metadata` to `Some(HashMap::new())` first if it was
64/// `None`, so the writes are never silently dropped.
65///
66/// # Errors
67///
68/// - [`MantaError::MissingField`] if `ims.id` is `None`, if
69///   `cfs.configuration.name` is absent, or if `cfs.target.image_map`
70///   is `None`/empty (no base image id available).
71/// - [`MantaError::SerdeError`] if the `groups` JSON encoding fails
72///   (in practice unreachable for `Vec<String>`).
73pub fn apply(
74  cfs: &CfsSessionGetResponse,
75  ims: &mut Image,
76) -> Result<(), MantaError> {
77  if ims.id.is_none() {
78    return Err(MantaError::MissingField("ims.id".into()));
79  }
80
81  let base = cfs
82    .target
83    .as_ref()
84    .and_then(|t| t.image_map.as_ref())
85    .and_then(|m| m.first())
86    .map(|im| im.source_id.clone())
87    .ok_or_else(|| {
88      MantaError::MissingField("cfs.target.image_map[0].source_id".into())
89    })?;
90
91  let groups = cfs.get_target_hsm().unwrap_or_default();
92
93  let configuration = cfs
94    .get_configuration_name()
95    .ok_or_else(|| MantaError::MissingField("cfs.configuration.name".into()))?;
96
97  let groups_json = serde_json::to_string(&groups)?;
98
99  let metadata = ims.metadata.get_or_insert_with(HashMap::new);
100  metadata.insert(META_BASE.to_string(), base);
101  metadata.insert(META_GROUPS.to_string(), groups_json);
102  metadata.insert(META_CONFIGURATION.to_string(), configuration);
103
104  Ok(())
105}
106
107/// Read the CFS-derived provenance metadata back from an IMS image.
108///
109/// `id` must match `ims.id`; this is a guard so callers can't pass a
110/// mismatched image by accident.
111///
112/// # Errors
113///
114/// - [`MantaError::NotFound`] if `ims.id` is `None` or does not equal `id`.
115/// - [`MantaError::MissingField`] if any of the three
116///   `manta.image_session.*` keys is absent from `ims.metadata`.
117/// - [`MantaError::SerdeError`] if the `groups` value is not a valid
118///   JSON array of strings.
119pub fn read(id: &str, ims: &Image) -> Result<ImageSessionMetadata, MantaError> {
120  check_id(id, ims)?;
121
122  let metadata = ims
123    .metadata
124    .as_ref()
125    .ok_or_else(|| MantaError::MissingField(META_BASE.into()))?;
126
127  let base = metadata
128    .get(META_BASE)
129    .cloned()
130    .ok_or_else(|| MantaError::MissingField(META_BASE.into()))?;
131  let groups_raw = metadata
132    .get(META_GROUPS)
133    .ok_or_else(|| MantaError::MissingField(META_GROUPS.into()))?;
134  let configuration = metadata
135    .get(META_CONFIGURATION)
136    .cloned()
137    .ok_or_else(|| MantaError::MissingField(META_CONFIGURATION.into()))?;
138
139  let groups: Vec<String> = serde_json::from_str(groups_raw)?;
140
141  Ok(ImageSessionMetadata {
142    base,
143    groups,
144    configuration,
145  })
146}
147
148/// Remove the three `manta.image_session.*` keys from `ims.metadata`.
149///
150/// Leaves any other metadata entries untouched. No-op (returns `Ok`)
151/// if the keys aren't present.
152///
153/// # Errors
154///
155/// - [`MantaError::NotFound`] if `ims.id` is `None` or does not equal `id`.
156pub fn clear(id: &str, ims: &mut Image) -> Result<(), MantaError> {
157  check_id(id, ims)?;
158
159  if let Some(metadata) = ims.metadata.as_mut() {
160    metadata.remove(META_BASE);
161    metadata.remove(META_GROUPS);
162    metadata.remove(META_CONFIGURATION);
163  }
164
165  Ok(())
166}
167
168fn check_id(id: &str, ims: &Image) -> Result<(), MantaError> {
169  match ims.id.as_deref() {
170    Some(actual) if actual == id => Ok(()),
171    _ => Err(MantaError::NotFound(format!("image id {id}"))),
172  }
173}
174
175#[cfg(test)]
176mod tests {
177  use super::*;
178  use manta_backend_dispatcher::types::cfs::session::{
179    Configuration, Group, ImageMap, Target,
180  };
181
182  fn sample_cfs(
183    config_name: Option<&str>,
184    base_id: Option<&str>,
185    groups: Vec<&str>,
186  ) -> CfsSessionGetResponse {
187    CfsSessionGetResponse {
188      name: "sess".into(),
189      configuration: config_name.map(|n| Configuration {
190        name: Some(n.into()),
191        limit: None,
192      }),
193      ansible: None,
194      target: Some(Target {
195        definition: Some("image".into()),
196        groups: Some(
197          groups
198            .into_iter()
199            .map(|g| Group {
200              name: g.into(),
201              members: vec![],
202            })
203            .collect(),
204        ),
205        image_map: base_id.map(|b| {
206          vec![ImageMap {
207            source_id: b.into(),
208            result_name: "out".into(),
209          }]
210        }),
211      }),
212      status: None,
213      tags: None,
214      debug_on_failure: false,
215      logs: None,
216    }
217  }
218
219  fn sample_image(id: Option<&str>) -> Image {
220    Image {
221      id: id.map(str::to_owned),
222      created: None,
223      name: "img".into(),
224      link: None,
225      arch: None,
226      metadata: None,
227    }
228  }
229
230  #[test]
231  fn apply_then_read_roundtrip() {
232    let cfs = sample_cfs(Some("cfg-1"), Some("base-1"), vec!["g1", "g2"]);
233    let mut ims = sample_image(Some("img-1"));
234
235    apply(&cfs, &mut ims).unwrap();
236    let got = read("img-1", &ims).unwrap();
237
238    assert_eq!(
239      got,
240      ImageSessionMetadata {
241        base: "base-1".into(),
242        groups: vec!["g1".into(), "g2".into()],
243        configuration: "cfg-1".into(),
244      }
245    );
246  }
247
248  #[test]
249  fn apply_twice_overwrites() {
250    let mut ims = sample_image(Some("img-1"));
251    apply(
252      &sample_cfs(Some("cfg-1"), Some("base-1"), vec!["g1"]),
253      &mut ims,
254    )
255    .unwrap();
256    apply(
257      &sample_cfs(Some("cfg-2"), Some("base-2"), vec!["g2", "g3"]),
258      &mut ims,
259    )
260    .unwrap();
261
262    let got = read("img-1", &ims).unwrap();
263    assert_eq!(got.base, "base-2");
264    assert_eq!(got.groups, vec!["g2".to_string(), "g3".to_string()]);
265    assert_eq!(got.configuration, "cfg-2");
266  }
267
268  #[test]
269  fn read_not_found_on_id_mismatch() {
270    let cfs = sample_cfs(Some("c"), Some("b"), vec!["g"]);
271    let mut ims = sample_image(Some("img-1"));
272    apply(&cfs, &mut ims).unwrap();
273
274    let err = read("img-2", &ims).unwrap_err();
275    assert!(matches!(err, MantaError::NotFound(_)), "got {err:?}");
276  }
277
278  #[test]
279  fn read_not_found_when_image_has_no_id() {
280    let cfs = sample_cfs(Some("c"), Some("b"), vec!["g"]);
281    let mut ims = sample_image(Some("img-1"));
282    apply(&cfs, &mut ims).unwrap();
283    ims.id = None;
284
285    let err = read("img-1", &ims).unwrap_err();
286    assert!(matches!(err, MantaError::NotFound(_)), "got {err:?}");
287  }
288
289  #[test]
290  fn read_missing_field_when_key_absent() {
291    let cfs = sample_cfs(Some("c"), Some("b"), vec!["g"]);
292    let mut ims = sample_image(Some("img-1"));
293    apply(&cfs, &mut ims).unwrap();
294    ims.metadata.as_mut().unwrap().remove(META_GROUPS);
295
296    let err = read("img-1", &ims).unwrap_err();
297    match err {
298      MantaError::MissingField(k) => assert_eq!(k, META_GROUPS),
299      other => panic!("expected MissingField, got {other:?}"),
300    }
301  }
302
303  #[test]
304  fn read_serde_error_on_malformed_groups() {
305    let cfs = sample_cfs(Some("c"), Some("b"), vec!["g"]);
306    let mut ims = sample_image(Some("img-1"));
307    apply(&cfs, &mut ims).unwrap();
308    ims
309      .metadata
310      .as_mut()
311      .unwrap()
312      .insert(META_GROUPS.into(), "not json".into());
313
314    let err = read("img-1", &ims).unwrap_err();
315    assert!(matches!(err, MantaError::SerdeError(_)), "got {err:?}");
316  }
317
318  #[test]
319  fn clear_removes_only_image_session_keys() {
320    let cfs = sample_cfs(Some("c"), Some("b"), vec!["g"]);
321    let mut ims = sample_image(Some("img-1"));
322    apply(&cfs, &mut ims).unwrap();
323    ims
324      .metadata
325      .as_mut()
326      .unwrap()
327      .insert("unrelated".into(), "keep-me".into());
328
329    clear("img-1", &mut ims).unwrap();
330
331    let md = ims.metadata.as_ref().unwrap();
332    assert!(!md.contains_key(META_BASE));
333    assert!(!md.contains_key(META_GROUPS));
334    assert!(!md.contains_key(META_CONFIGURATION));
335    assert_eq!(md.get("unrelated").map(String::as_str), Some("keep-me"));
336  }
337
338  #[test]
339  fn clear_not_found_on_id_mismatch() {
340    let mut ims = sample_image(Some("img-1"));
341    let err = clear("img-2", &mut ims).unwrap_err();
342    assert!(matches!(err, MantaError::NotFound(_)), "got {err:?}");
343  }
344
345  #[test]
346  fn groups_roundtrip_empty() {
347    let cfs = sample_cfs(Some("c"), Some("b"), vec![]);
348    let mut ims = sample_image(Some("img-1"));
349    apply(&cfs, &mut ims).unwrap();
350    assert!(read("img-1", &ims).unwrap().groups.is_empty());
351  }
352
353  #[test]
354  fn groups_roundtrip_single() {
355    let cfs = sample_cfs(Some("c"), Some("b"), vec!["only"]);
356    let mut ims = sample_image(Some("img-1"));
357    apply(&cfs, &mut ims).unwrap();
358    assert_eq!(
359      read("img-1", &ims).unwrap().groups,
360      vec!["only".to_string()]
361    );
362  }
363
364  #[test]
365  fn groups_roundtrip_special_chars() {
366    // HSM names won't have these in practice, but the JSON encoding
367    // tolerates them; verifying so a future malformed group name
368    // doesn't silently corrupt the metadata.
369    let cfs = sample_cfs(Some("c"), Some("b"), vec!["a,b", "with\"quote"]);
370    let mut ims = sample_image(Some("img-1"));
371    apply(&cfs, &mut ims).unwrap();
372    assert_eq!(
373      read("img-1", &ims).unwrap().groups,
374      vec!["a,b".to_string(), "with\"quote".to_string()]
375    );
376  }
377
378  #[test]
379  fn apply_initializes_none_metadata() {
380    let cfs = sample_cfs(Some("c"), Some("b"), vec!["g"]);
381    let mut ims = sample_image(Some("img-1"));
382    assert!(ims.metadata.is_none());
383
384    apply(&cfs, &mut ims).unwrap();
385
386    let md = ims.metadata.as_ref().expect("metadata should be Some");
387    assert!(md.contains_key(META_BASE));
388    assert!(md.contains_key(META_GROUPS));
389    assert!(md.contains_key(META_CONFIGURATION));
390  }
391
392  #[test]
393  fn apply_missing_field_when_ims_id_none() {
394    let cfs = sample_cfs(Some("c"), Some("b"), vec!["g"]);
395    let mut ims = sample_image(None);
396    let err = apply(&cfs, &mut ims).unwrap_err();
397    match err {
398      MantaError::MissingField(f) => assert_eq!(f, "ims.id"),
399      other => panic!("expected MissingField, got {other:?}"),
400    }
401  }
402
403  #[test]
404  fn apply_missing_field_when_image_map_empty() {
405    let cfs = sample_cfs(Some("c"), None, vec!["g"]);
406    let mut ims = sample_image(Some("img-1"));
407    let err = apply(&cfs, &mut ims).unwrap_err();
408    assert!(matches!(err, MantaError::MissingField(_)), "got {err:?}");
409  }
410
411  #[test]
412  fn apply_missing_field_when_configuration_name_absent() {
413    let cfs = sample_cfs(None, Some("b"), vec!["g"]);
414    let mut ims = sample_image(Some("img-1"));
415    let err = apply(&cfs, &mut ims).unwrap_err();
416    match err {
417      MantaError::MissingField(f) => assert_eq!(f, "cfs.configuration.name"),
418      other => panic!("expected MissingField, got {other:?}"),
419    }
420  }
421}