manta_server/service/
boot_parameters.rs

1//! BSS boot parameter queries, changeset preparation, and persistence.
2
3use manta_backend_dispatcher::{
4  error::Error,
5  interfaces::{bss::BootParametersTrait, cfs::CfsTrait, ims::ImsTrait},
6  types::{
7    Group,
8    bss::BootParameters,
9    ims::{Image, PatchImage},
10  },
11};
12use std::collections::HashMap;
13
14use crate::server::common::app_context::InfraContext;
15use crate::service::authorization::validate_user_group_members_access;
16use crate::service::ims_ops::get_image_vec_related_cfs_configuration_name;
17use crate::service::node_ops;
18pub use manta_shared::types::api::boot_parameters::{
19  GetBootParametersParams, UpdateBootParametersParams,
20};
21
22/// Fetch BSS boot parameters for the resolved target nodes.
23///
24/// Targets are resolved from `params` in the
25/// [`node_ops::resolve_target_nodes`] priority order (host
26/// expression, then `group_name`, then the `settings_group_name`
27/// fallback from `cli.toml`). An empty resolution returns `BadRequest`
28/// rather than silently querying nothing; otherwise the caller's
29/// access to every resolved xname is validated before hitting the
30/// backend.
31pub async fn get_boot_parameters(
32  infra: &InfraContext<'_>,
33  token: &str,
34  params: &GetBootParametersParams,
35) -> Result<Vec<BootParameters>, Error> {
36  tracing::info!("Get boot parameters");
37
38  let xname_vec = node_ops::resolve_target_nodes(
39    infra,
40    token,
41    params.host_expression.as_deref(),
42    params.group_name.as_deref(),
43    params.settings_group_name.as_deref(),
44  )
45  .await?;
46
47  validate_user_group_members_access(infra, token, &xname_vec).await?;
48
49  if xname_vec.is_empty() {
50    return Err(Error::BadRequest(
51      "The list of nodes to operate is empty. Nothing to do".to_string(),
52    ));
53  }
54
55  validate_user_group_members_access(infra, token, &xname_vec).await?;
56
57  infra.backend.get_bootparameters(token, &xname_vec).await
58}
59
60/// Remove the BSS boot-parameter record for each host in `hosts`.
61///
62/// The caller's access to every host is validated before the delete
63/// is dispatched. The constructed `BootParameters` carries only the
64/// host list — BSS keys deletions by host, so the other fields are
65/// intentionally empty.
66pub async fn delete_boot_parameters(
67  infra: &InfraContext<'_>,
68  token: &str,
69  hosts: Vec<String>,
70) -> Result<(), Error> {
71  let boot_parameters = BootParameters {
72    hosts,
73    macs: None,
74    nids: None,
75    params: String::new(),
76    kernel: String::new(),
77    initrd: String::new(),
78    cloud_init: None,
79  };
80
81  validate_user_group_members_access(infra, token, &boot_parameters.hosts)
82    .await?;
83
84  infra
85    .backend
86    .delete_bootparameters(token, &boot_parameters)
87    .await
88    .map(|_| ())
89}
90
91/// Create a new BSS boot-parameter record for `boot_parameters.hosts`.
92///
93/// The caller's access to every listed host is validated before the
94/// create is dispatched. Use [`update_boot_parameters`] when modifying
95/// an existing record.
96pub async fn add_boot_parameters(
97  infra: &InfraContext<'_>,
98  token: &str,
99  boot_parameters: &BootParameters,
100) -> Result<(), Error> {
101  validate_user_group_members_access(infra, token, &boot_parameters.hosts)
102    .await?;
103
104  infra
105    .backend
106    .add_bootparameters(token, boot_parameters)
107    .await
108}
109
110/// Replace the BSS boot-parameter record for `params.hosts` with the
111/// values carried in `params`.
112///
113/// The caller's access to every host is validated first. `cloud_init`
114/// is intentionally left unset: the update endpoint accepts only the
115/// core boot fields (`params`, `kernel`, `initrd`, `macs`, `nids`).
116pub async fn update_boot_parameters(
117  infra: &InfraContext<'_>,
118  token: &str,
119  params: UpdateBootParametersParams,
120) -> Result<(), Error> {
121  validate_user_group_members_access(infra, token, &params.hosts).await?;
122
123  let boot_parameters = BootParameters {
124    hosts: params.hosts,
125    macs: params.macs,
126    nids: params.nids,
127    params: params.params,
128    kernel: params.kernel,
129    initrd: params.initrd,
130    cloud_init: None,
131  };
132
133  tracing::debug!("new boot params: {:#?}", boot_parameters);
134
135  infra
136    .backend
137    .update_bootparameters(token, &boot_parameters)
138    .await
139}
140
141/// Result of preparing boot configuration changes.
142#[derive(serde::Serialize)]
143pub(crate) struct BootConfigChangeset {
144  /// Resolved target xnames.
145  pub xname_vec: Vec<String>,
146  /// Updated BSS boot parameter records, ready to persist.
147  pub boot_param_vec: Vec<BootParameters>,
148  /// IMS images referenced by the new boot config, keyed by image ID.
149  pub image_vec: HashMap<String, Image>,
150  /// Whether nodes need a reboot to apply the new parameters.
151  pub need_restart: bool,
152}
153
154/// Build a [`BootConfigChangeset`] describing what the requested
155/// boot-config edit would write, without persisting anything.
156///
157/// Resolves `hosts_expression`, fetches the current boot parameters,
158/// applies new kernel parameters (always first — the boot image patch
159/// reads the updated kernel-params), then attaches the new boot image
160/// either by id or by latest image for the named CFS configuration.
161/// The iSCSI-ready flag is propagated from the existing kernel
162/// parameters onto each affected image.
163///
164/// The split between this and [`persist_boot_config`] lets callers
165/// confirm the planned change with the user before any backend write.
166pub(crate) async fn prepare_boot_config(
167  infra: &InfraContext<'_>,
168  token: &str,
169  hosts_expression: &str,
170  new_boot_image_id_opt: Option<&str>,
171  new_boot_image_configuration_opt: Option<&str>,
172  new_kernel_parameters_opt: Option<&str>,
173) -> Result<BootConfigChangeset, Error> {
174  let mut need_restart = false;
175
176  let xname_vec = node_ops::from_user_hosts_expression_to_xname_vec(
177    infra,
178    token,
179    hosts_expression,
180    false,
181  )
182  .await?;
183
184  // Gate before the BSS / IMS lookups below: the dry-run handler
185  // path returns the changeset (boot params + referenced images)
186  // directly to the caller, so an unauthorized resolution would leak
187  // state. `persist_boot_config` re-checks at write time.
188  validate_user_group_members_access(infra, token, &xname_vec).await?;
189
190  let mut current_node_boot_param_vec: Vec<BootParameters> =
191    infra.backend.get_bootparameters(token, &xname_vec).await?;
192
193  let new_boot_image_opt = get_new_boot_image(
194    infra,
195    token,
196    new_boot_image_configuration_opt,
197    new_boot_image_id_opt,
198  )
199  .await?;
200
201  // IMPORTANT: ALWAYS SET KERNEL PARAMS BEFORE BOOT IMAGE
202  if let Some(new_kernel_parameters) = new_kernel_parameters_opt {
203    need_restart |= apply_kernel_params(
204      &mut current_node_boot_param_vec,
205      new_kernel_parameters,
206    )?;
207  }
208
209  let mut image_vec = collect_boot_images(
210    infra,
211    token,
212    &mut current_node_boot_param_vec,
213    new_boot_image_opt,
214    &mut need_restart,
215  )
216  .await?;
217
218  if current_node_boot_param_vec
219    .first()
220    .ok_or_else(|| Error::NotFound("No boot parameters found".to_string()))?
221    .is_root_kernel_param_iscsi_ready()
222  {
223    for image in image_vec.values_mut() {
224      image.set_boot_image_iscsi_ready();
225    }
226  }
227
228  Ok(BootConfigChangeset {
229    xname_vec,
230    boot_param_vec: current_node_boot_param_vec,
231    image_vec,
232    need_restart,
233  })
234}
235
236/// Write a [`BootConfigChangeset`] previously built by
237/// [`prepare_boot_config`].
238///
239/// Validates access to every xname in the changeset, writes each
240/// updated BSS record, then — if `new_runtime_configuration_opt` is
241/// supplied — points the runtime configuration at it and patches the
242/// referenced images so they boot under the new CFS configuration.
243pub(crate) async fn persist_boot_config(
244  infra: &InfraContext<'_>,
245  token: &str,
246  changeset: &BootConfigChangeset,
247  new_runtime_configuration_opt: Option<&str>,
248) -> Result<(), Error> {
249  tracing::info!("Persist changes");
250
251  validate_user_group_members_access(infra, token, &changeset.xname_vec)
252    .await?;
253
254  for boot_parameter in &changeset.boot_param_vec {
255    tracing::debug!("Updating boot parameter:\n{:#?}", boot_parameter);
256    let component_patch_rep = infra
257      .backend
258      .update_bootparameters(token, boot_parameter)
259      .await;
260    tracing::debug!(
261      "Component boot parameters resp:\n{:#?}",
262      component_patch_rep
263    );
264  }
265
266  if let Some(new_runtime_configuration_name) = new_runtime_configuration_opt {
267    tracing::info!(
268      "Updating runtime configuration to '{new_runtime_configuration_name}'"
269    );
270
271    infra
272      .backend
273      .update_runtime_configuration(
274        token,
275        &changeset.xname_vec,
276        new_runtime_configuration_name,
277        true,
278      )
279      .await?;
280
281    for image in changeset.image_vec.values() {
282      let image_id = image.id.clone().ok_or_else(|| {
283        Error::MissingField("Image id is missing".to_string())
284      })?;
285      let patch_image: PatchImage = image.clone().into();
286      infra
287        .backend
288        .update_image(token, &image_id, &patch_image)
289        .await?;
290    }
291  } else {
292    tracing::info!("Runtime configuration does not change.");
293  }
294
295  Ok(())
296}
297
298async fn get_new_boot_image(
299  infra: &InfraContext<'_>,
300  shasta_token: &str,
301  new_boot_image_configuration_opt: Option<&str>,
302  new_boot_image_id_opt: Option<&str>,
303) -> Result<Option<Image>, Error> {
304  let new_boot_image = if let Some(new_boot_image_configuration) =
305    new_boot_image_configuration_opt
306  {
307    tracing::info!(
308      "Boot configuration '{}' provided",
309      new_boot_image_configuration
310    );
311    let mut image_vec = get_image_vec_related_cfs_configuration_name(
312      infra,
313      shasta_token,
314      new_boot_image_configuration.to_string(),
315    )
316    .await?;
317
318    if image_vec.is_empty() {
319      return Err(Error::NotFound(format!(
320        "No boot image found for configuration '{new_boot_image_configuration}'"
321      )));
322    }
323
324    infra.backend.filter_images(&mut image_vec)?;
325
326    let most_recent_image = image_vec.iter().last().ok_or_else(|| {
327      Error::NotFound("No image found for configuration".to_string())
328    })?;
329
330    tracing::debug!(
331      "Boot image id related to configuration '{}' found:\n{:#?}",
332      new_boot_image_configuration,
333      most_recent_image
334    );
335
336    Some(most_recent_image.clone())
337  } else if let Some(boot_image_id) = new_boot_image_id_opt {
338    tracing::info!("Boot image id '{}' provided", boot_image_id);
339    let image_in_csm_vec = infra
340      .backend
341      .get_images(shasta_token, new_boot_image_id_opt)
342      .await?;
343
344    if image_in_csm_vec.is_empty() {
345      return Err(Error::NotFound(format!(
346        "Boot image id '{boot_image_id}' not found"
347      )));
348    }
349
350    image_in_csm_vec.first().cloned()
351  } else {
352    None
353  };
354
355  Ok(new_boot_image)
356}
357
358fn apply_kernel_params(
359  boot_param_vec: &mut [BootParameters],
360  new_kernel_parameters: &str,
361) -> Result<bool, Error> {
362  // One summary log per call; the previous per-iteration `info!`
363  // logged identical content N times (where N = number of nodes)
364  // and logged the running `any_changed` aggregate inside the loop
365  // — at cluster scale that drowned out anything else operators
366  // were trying to read from the info stream.
367  tracing::info!(
368    "Updating kernel parameters to '{}' across {} boot-parameter record(s)",
369    new_kernel_parameters,
370    boot_param_vec.len()
371  );
372
373  let mut any_changed = false;
374
375  for boot_parameter in boot_param_vec.iter_mut() {
376    tracing::debug!(
377      "Updating '{:?}' kernel parameters to '{}'",
378      boot_parameter.hosts,
379      new_kernel_parameters
380    );
381
382    let changed = boot_parameter.apply_kernel_params(new_kernel_parameters);
383    any_changed = changed || any_changed;
384
385    let image_id = boot_parameter.try_get_boot_image_id().ok_or_else(|| {
386      Error::MissingField(format!(
387        "Could not get boot image id from boot parameters for hosts: {:?}",
388        boot_parameter.hosts
389      ))
390    })?;
391
392    boot_parameter
393      .update_boot_image(&image_id, &boot_parameter.get_boot_image_etag())?;
394  }
395
396  Ok(any_changed)
397}
398
399async fn collect_boot_images(
400  infra: &InfraContext<'_>,
401  shasta_token: &str,
402  boot_param_vec: &mut [BootParameters],
403  new_boot_image_opt: Option<Image>,
404  need_restart: &mut bool,
405) -> Result<HashMap<String, Image>, Error> {
406  let mut image_vec = HashMap::<String, Image>::new();
407
408  if let Some(new_boot_image) = new_boot_image_opt {
409    let new_boot_image_id = new_boot_image
410      .id
411      .as_ref()
412      .ok_or_else(|| {
413        Error::MissingField("New boot image id is missing".to_string())
414      })?
415      .clone();
416
417    let new_boot_image_etag = new_boot_image
418      .link
419      .as_ref()
420      .and_then(|link| link.etag.as_ref())
421      .ok_or_else(|| {
422        Error::MissingField("New boot image etag is missing".to_string())
423      })?;
424
425    image_vec.insert(new_boot_image_id.clone(), new_boot_image.clone());
426
427    let any_differ = boot_param_vec.iter().any(|bp| {
428      bp.try_get_boot_image_id().as_deref() != Some(new_boot_image_id.as_str())
429    });
430
431    if any_differ {
432      // Single summary at info; per-host detail at debug. The
433      // previous per-iter `info!` emitted N identical lines for
434      // cluster-scale calls.
435      tracing::info!(
436        "Updating boot image to '{}' across {} boot-parameter record(s)",
437        new_boot_image_id,
438        boot_param_vec.len()
439      );
440      for boot_parameter in boot_param_vec.iter_mut() {
441        tracing::debug!(
442          "Updating '{:?}' boot image to '{}'",
443          boot_parameter.hosts,
444          new_boot_image_id
445        );
446        boot_parameter
447          .update_boot_image(&new_boot_image_id, new_boot_image_etag)?;
448      }
449      *need_restart = true;
450    }
451  } else {
452    // Dedupe boot_image_ids before fetching: a 5k-node group with N
453    // distinct boot images was previously costing N HTTPS round-trips
454    // serialised inside this loop. Now we resolve each id once in
455    // parallel.
456    let mut unique_ids: Vec<String> = Vec::new();
457    let mut seen: std::collections::HashSet<String> =
458      std::collections::HashSet::new();
459    for boot_parameter in boot_param_vec.iter() {
460      let boot_image_id =
461        boot_parameter.try_get_boot_image_id().ok_or_else(|| {
462          Error::MissingField(format!(
463            "Could not get boot image id from boot parameters for hosts: {:?}",
464            boot_parameter.hosts
465          ))
466        })?;
467      if seen.insert(boot_image_id.clone()) {
468        unique_ids.push(boot_image_id);
469      }
470    }
471
472    let fetched: Vec<(String, Image)> =
473      futures::future::try_join_all(unique_ids.iter().map(|id| async move {
474        let image = infra
475          .backend
476          .get_images(shasta_token, Some(id.as_str()))
477          .await?
478          .first()
479          .ok_or_else(|| {
480            Error::NotFound(format!("No image found for boot image id '{id}'"))
481          })?
482          .clone();
483        Ok::<_, Error>((id.clone(), image))
484      }))
485      .await?;
486
487    for (id, image) in fetched {
488      image_vec.insert(id, image);
489    }
490  }
491
492  Ok(image_vec)
493}
494
495/// Return the subset of `boot_parameter_vec` whose `hosts` list
496/// includes at least one member of the groups in `group_available_vec`.
497/// Used by `service::image` to scope image-deletion safety checks to
498/// the boot parameters that name nodes the caller can actually see.
499pub fn get_restricted_boot_parameters(
500  group_available_vec: &[Group],
501  boot_parameter_vec: &[BootParameters],
502) -> Vec<BootParameters> {
503  let group_members: Vec<String> = group_available_vec
504    .iter()
505    .flat_map(Group::get_members)
506    .collect();
507
508  boot_parameter_vec
509    .iter()
510    .filter(|boot_param| {
511      group_members
512        .iter()
513        .any(|gma| boot_param.hosts.contains(gma))
514    })
515    .cloned()
516    .collect::<Vec<BootParameters>>()
517}
518
519#[cfg(test)]
520mod tests {
521  use super::*;
522  use manta_backend_dispatcher::types::Member;
523
524  /// Helper: create a Group with given label and member xnames.
525  fn make_group(label: &str, member_ids: Vec<&str>) -> Group {
526    Group {
527      label: label.to_string(),
528      description: None,
529      tags: None,
530      members: Some(Member {
531        ids: Some(member_ids.into_iter().map(String::from).collect()),
532      }),
533      exclusive_group: None,
534    }
535  }
536
537  /// Helper: create a BootParameters with given hosts.
538  fn make_boot_params(hosts: Vec<&str>) -> BootParameters {
539    BootParameters {
540      hosts: hosts.into_iter().map(String::from).collect(),
541      ..Default::default()
542    }
543  }
544
545  #[test]
546  fn filters_boot_params_by_group_membership() {
547    let groups =
548      vec![make_group("grp1", vec!["x1000c0s0b0n0", "x1000c0s0b0n1"])];
549    let boot_params = vec![
550      make_boot_params(vec!["x1000c0s0b0n0"]),
551      make_boot_params(vec!["x9999c0s0b0n0"]),
552      make_boot_params(vec!["x1000c0s0b0n1"]),
553    ];
554    let result = get_restricted_boot_parameters(&groups, &boot_params);
555    assert_eq!(result.len(), 2);
556    assert_eq!(result[0].hosts, vec!["x1000c0s0b0n0"]);
557    assert_eq!(result[1].hosts, vec!["x1000c0s0b0n1"]);
558  }
559
560  #[test]
561  fn returns_empty_when_no_group_members_match() {
562    let groups = vec![make_group("grp1", vec!["x1000c0s0b0n0"])];
563    let boot_params = vec![make_boot_params(vec!["x9999c0s0b0n0"])];
564    let result = get_restricted_boot_parameters(&groups, &boot_params);
565    assert!(result.is_empty());
566  }
567
568  #[test]
569  fn returns_empty_when_groups_are_empty() {
570    let boot_params = vec![make_boot_params(vec!["x1000c0s0b0n0"])];
571    let result = get_restricted_boot_parameters(&[], &boot_params);
572    assert!(result.is_empty());
573  }
574
575  #[test]
576  fn returns_empty_when_boot_params_are_empty() {
577    let groups = vec![make_group("grp1", vec!["x1000c0s0b0n0"])];
578    let result = get_restricted_boot_parameters(&groups, &[]);
579    assert!(result.is_empty());
580  }
581
582  #[test]
583  fn aggregates_members_across_multiple_groups() {
584    let groups = vec![
585      make_group("grp1", vec!["x1000c0s0b0n0"]),
586      make_group("grp2", vec!["x2000c0s0b0n0"]),
587    ];
588    let boot_params = vec![
589      make_boot_params(vec!["x1000c0s0b0n0"]),
590      make_boot_params(vec!["x2000c0s0b0n0"]),
591      make_boot_params(vec!["x3000c0s0b0n0"]),
592    ];
593    let result = get_restricted_boot_parameters(&groups, &boot_params);
594    assert_eq!(result.len(), 2);
595  }
596}