manta_server/service/
boot_parameters.rs

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