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
92    .get_target_hsm()
93    .unwrap_or_default();
94
95  let configuration = cfs
96    .get_configuration_name()
97    .ok_or_else(|| MantaError::MissingField("cfs.configuration.name".into()))?;
98
99  let groups_json = serde_json::to_string(&groups)?;
100
101  let metadata = ims.metadata.get_or_insert_with(HashMap::new);
102  metadata.insert(META_BASE.to_string(), base);
103  metadata.insert(META_GROUPS.to_string(), groups_json);
104  metadata.insert(META_CONFIGURATION.to_string(), configuration);
105
106  Ok(())
107}
108
109/// Read the CFS-derived provenance metadata back from an IMS image.
110///
111/// `id` must match `ims.id`; this is a guard so callers can't pass a
112/// mismatched image by accident.
113///
114/// # Errors
115///
116/// - [`MantaError::NotFound`] if `ims.id` is `None` or does not equal `id`.
117/// - [`MantaError::MissingField`] if any of the three
118///   `manta.image_session.*` keys is absent from `ims.metadata`.
119/// - [`MantaError::SerdeError`] if the `groups` value is not a valid
120///   JSON array of strings.
121pub fn read(id: &str, ims: &Image) -> Result<ImageSessionMetadata, MantaError> {
122  check_id(id, ims)?;
123
124  let metadata = ims
125    .metadata
126    .as_ref()
127    .ok_or_else(|| MantaError::MissingField(META_BASE.into()))?;
128
129  let base = metadata
130    .get(META_BASE)
131    .cloned()
132    .ok_or_else(|| MantaError::MissingField(META_BASE.into()))?;
133  let groups_raw = metadata
134    .get(META_GROUPS)
135    .ok_or_else(|| MantaError::MissingField(META_GROUPS.into()))?;
136  let configuration = metadata
137    .get(META_CONFIGURATION)
138    .cloned()
139    .ok_or_else(|| MantaError::MissingField(META_CONFIGURATION.into()))?;
140
141  let groups: Vec<String> = serde_json::from_str(groups_raw)?;
142
143  Ok(ImageSessionMetadata {
144    base,
145    groups,
146    configuration,
147  })
148}
149
150/// Remove the three `manta.image_session.*` keys from `ims.metadata`.
151///
152/// Leaves any other metadata entries untouched. No-op (returns `Ok`)
153/// if the keys aren't present.
154///
155/// # Errors
156///
157/// - [`MantaError::NotFound`] if `ims.id` is `None` or does not equal `id`.
158pub fn clear(id: &str, ims: &mut Image) -> Result<(), MantaError> {
159  check_id(id, ims)?;
160
161  if let Some(metadata) = ims.metadata.as_mut() {
162    metadata.remove(META_BASE);
163    metadata.remove(META_GROUPS);
164    metadata.remove(META_CONFIGURATION);
165  }
166
167  Ok(())
168}
169
170fn check_id(id: &str, ims: &Image) -> Result<(), MantaError> {
171  match ims.id.as_deref() {
172    Some(actual) if actual == id => Ok(()),
173    _ => Err(MantaError::NotFound(format!("image id {id}"))),
174  }
175}
176
177#[cfg(test)]
178mod tests {
179  use super::*;
180  use manta_backend_dispatcher::types::cfs::session::{
181    Configuration, Group, ImageMap, Target,
182  };
183
184  fn sample_cfs(
185    config_name: Option<&str>,
186    base_id: Option<&str>,
187    groups: Vec<&str>,
188  ) -> CfsSessionGetResponse {
189    CfsSessionGetResponse {
190      name: "sess".into(),
191      configuration: config_name.map(|n| Configuration {
192        name: Some(n.into()),
193        limit: None,
194      }),
195      ansible: None,
196      target: Some(Target {
197        definition: Some("image".into()),
198        groups: Some(
199          groups
200            .into_iter()
201            .map(|g| Group {
202              name: g.into(),
203              members: vec![],
204            })
205            .collect(),
206        ),
207        image_map: base_id.map(|b| {
208          vec![ImageMap {
209            source_id: b.into(),
210            result_name: "out".into(),
211          }]
212        }),
213      }),
214      status: None,
215      tags: None,
216      debug_on_failure: false,
217      logs: None,
218    }
219  }
220
221  fn sample_image(id: Option<&str>) -> Image {
222    Image {
223      id: id.map(str::to_owned),
224      created: None,
225      name: "img".into(),
226      link: None,
227      arch: None,
228      metadata: None,
229    }
230  }
231
232  #[test]
233  fn apply_then_read_roundtrip() {
234    let cfs = sample_cfs(Some("cfg-1"), Some("base-1"), vec!["g1", "g2"]);
235    let mut ims = sample_image(Some("img-1"));
236
237    apply(&cfs, &mut ims).unwrap();
238    let got = read("img-1", &ims).unwrap();
239
240    assert_eq!(
241      got,
242      ImageSessionMetadata {
243        base: "base-1".into(),
244        groups: vec!["g1".into(), "g2".into()],
245        configuration: "cfg-1".into(),
246      }
247    );
248  }
249
250  #[test]
251  fn apply_twice_overwrites() {
252    let mut ims = sample_image(Some("img-1"));
253    apply(
254      &sample_cfs(Some("cfg-1"), Some("base-1"), vec!["g1"]),
255      &mut ims,
256    )
257    .unwrap();
258    apply(
259      &sample_cfs(Some("cfg-2"), Some("base-2"), vec!["g2", "g3"]),
260      &mut ims,
261    )
262    .unwrap();
263
264    let got = read("img-1", &ims).unwrap();
265    assert_eq!(got.base, "base-2");
266    assert_eq!(got.groups, vec!["g2".to_string(), "g3".to_string()]);
267    assert_eq!(got.configuration, "cfg-2");
268  }
269
270  #[test]
271  fn read_not_found_on_id_mismatch() {
272    let cfs = sample_cfs(Some("c"), Some("b"), vec!["g"]);
273    let mut ims = sample_image(Some("img-1"));
274    apply(&cfs, &mut ims).unwrap();
275
276    let err = read("img-2", &ims).unwrap_err();
277    assert!(matches!(err, MantaError::NotFound(_)), "got {err:?}");
278  }
279
280  #[test]
281  fn read_not_found_when_image_has_no_id() {
282    let cfs = sample_cfs(Some("c"), Some("b"), vec!["g"]);
283    let mut ims = sample_image(Some("img-1"));
284    apply(&cfs, &mut ims).unwrap();
285    ims.id = None;
286
287    let err = read("img-1", &ims).unwrap_err();
288    assert!(matches!(err, MantaError::NotFound(_)), "got {err:?}");
289  }
290
291  #[test]
292  fn read_missing_field_when_key_absent() {
293    let cfs = sample_cfs(Some("c"), Some("b"), vec!["g"]);
294    let mut ims = sample_image(Some("img-1"));
295    apply(&cfs, &mut ims).unwrap();
296    ims.metadata.as_mut().unwrap().remove(META_GROUPS);
297
298    let err = read("img-1", &ims).unwrap_err();
299    match err {
300      MantaError::MissingField(k) => assert_eq!(k, META_GROUPS),
301      other => panic!("expected MissingField, got {other:?}"),
302    }
303  }
304
305  #[test]
306  fn read_serde_error_on_malformed_groups() {
307    let cfs = sample_cfs(Some("c"), Some("b"), vec!["g"]);
308    let mut ims = sample_image(Some("img-1"));
309    apply(&cfs, &mut ims).unwrap();
310    ims
311      .metadata
312      .as_mut()
313      .unwrap()
314      .insert(META_GROUPS.into(), "not json".into());
315
316    let err = read("img-1", &ims).unwrap_err();
317    assert!(matches!(err, MantaError::SerdeError(_)), "got {err:?}");
318  }
319
320  #[test]
321  fn clear_removes_only_image_session_keys() {
322    let cfs = sample_cfs(Some("c"), Some("b"), vec!["g"]);
323    let mut ims = sample_image(Some("img-1"));
324    apply(&cfs, &mut ims).unwrap();
325    ims
326      .metadata
327      .as_mut()
328      .unwrap()
329      .insert("unrelated".into(), "keep-me".into());
330
331    clear("img-1", &mut ims).unwrap();
332
333    let md = ims.metadata.as_ref().unwrap();
334    assert!(!md.contains_key(META_BASE));
335    assert!(!md.contains_key(META_GROUPS));
336    assert!(!md.contains_key(META_CONFIGURATION));
337    assert_eq!(md.get("unrelated").map(String::as_str), Some("keep-me"));
338  }
339
340  #[test]
341  fn clear_not_found_on_id_mismatch() {
342    let mut ims = sample_image(Some("img-1"));
343    let err = clear("img-2", &mut ims).unwrap_err();
344    assert!(matches!(err, MantaError::NotFound(_)), "got {err:?}");
345  }
346
347  #[test]
348  fn groups_roundtrip_empty() {
349    let cfs = sample_cfs(Some("c"), Some("b"), vec![]);
350    let mut ims = sample_image(Some("img-1"));
351    apply(&cfs, &mut ims).unwrap();
352    assert!(read("img-1", &ims).unwrap().groups.is_empty());
353  }
354
355  #[test]
356  fn groups_roundtrip_single() {
357    let cfs = sample_cfs(Some("c"), Some("b"), vec!["only"]);
358    let mut ims = sample_image(Some("img-1"));
359    apply(&cfs, &mut ims).unwrap();
360    assert_eq!(read("img-1", &ims).unwrap().groups, vec!["only".to_string()]);
361  }
362
363  #[test]
364  fn groups_roundtrip_special_chars() {
365    // HSM names won't have these in practice, but the JSON encoding
366    // tolerates them; verifying so a future malformed group name
367    // doesn't silently corrupt the metadata.
368    let cfs = sample_cfs(Some("c"), Some("b"), vec!["a,b", "with\"quote"]);
369    let mut ims = sample_image(Some("img-1"));
370    apply(&cfs, &mut ims).unwrap();
371    assert_eq!(
372      read("img-1", &ims).unwrap().groups,
373      vec!["a,b".to_string(), "with\"quote".to_string()]
374    );
375  }
376
377  #[test]
378  fn apply_initializes_none_metadata() {
379    let cfs = sample_cfs(Some("c"), Some("b"), vec!["g"]);
380    let mut ims = sample_image(Some("img-1"));
381    assert!(ims.metadata.is_none());
382
383    apply(&cfs, &mut ims).unwrap();
384
385    let md = ims.metadata.as_ref().expect("metadata should be Some");
386    assert!(md.contains_key(META_BASE));
387    assert!(md.contains_key(META_GROUPS));
388    assert!(md.contains_key(META_CONFIGURATION));
389  }
390
391  #[test]
392  fn apply_missing_field_when_ims_id_none() {
393    let cfs = sample_cfs(Some("c"), Some("b"), vec!["g"]);
394    let mut ims = sample_image(None);
395    let err = apply(&cfs, &mut ims).unwrap_err();
396    match err {
397      MantaError::MissingField(f) => assert_eq!(f, "ims.id"),
398      other => panic!("expected MissingField, got {other:?}"),
399    }
400  }
401
402  #[test]
403  fn apply_missing_field_when_image_map_empty() {
404    let cfs = sample_cfs(Some("c"), None, vec!["g"]);
405    let mut ims = sample_image(Some("img-1"));
406    let err = apply(&cfs, &mut ims).unwrap_err();
407    assert!(matches!(err, MantaError::MissingField(_)), "got {err:?}");
408  }
409
410  #[test]
411  fn apply_missing_field_when_configuration_name_absent() {
412    let cfs = sample_cfs(None, Some("b"), vec!["g"]);
413    let mut ims = sample_image(Some("img-1"));
414    let err = apply(&cfs, &mut ims).unwrap_err();
415    match err {
416      MantaError::MissingField(f) => assert_eq!(f, "cfs.configuration.name"),
417      other => panic!("expected MissingField, got {other:?}"),
418    }
419  }
420}