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.
259///
260/// `enabled_opt` controls the CFS component `enabled` flag written by
261/// `update_runtime_configuration`. `None` preserves the historical
262/// default of `true` (CFS applies on its next pass); `Some(false)`
263/// stages the desired configuration without enabling CFS. Only
264/// consulted when `new_runtime_configuration_opt` is `Some`.
265pub(crate) async fn persist_boot_config(
266  infra: &InfraContext<'_>,
267  token: &str,
268  changeset: &BootConfigChangeset,
269  new_runtime_configuration_opt: Option<&str>,
270  enabled_opt: Option<bool>,
271) -> Result<(), Error> {
272  tracing::info!("Persist changes");
273
274  validate_user_group_members_access(infra, token, &changeset.xname_vec)
275    .await?;
276
277  // Fan out all BSS PUTs concurrently. The original loop swallowed
278  // errors (only debug-logged them); join_all preserves that behaviour.
279  futures::future::join_all(changeset.boot_param_vec.iter().map(
280    |boot_parameter| async move {
281      tracing::debug!("Updating boot parameter:\n{:#?}", boot_parameter);
282      let component_patch_rep = infra
283        .backend
284        .update_bootparameters(token, boot_parameter)
285        .await;
286      tracing::debug!(
287        "Component boot parameters resp:\n{:#?}",
288        component_patch_rep
289      );
290    },
291  ))
292  .await;
293
294  if let Some(new_runtime_configuration_name) = new_runtime_configuration_opt {
295    tracing::info!(
296      "Updating runtime configuration to '{new_runtime_configuration_name}'"
297    );
298
299    infra
300      .backend
301      .update_runtime_configuration(
302        token,
303        &changeset.xname_vec,
304        new_runtime_configuration_name,
305        enabled_opt.unwrap_or(true),
306      )
307      .await?;
308
309    apply_image_patches(infra, token, &changeset.image_vec).await?;
310  } else {
311    tracing::info!("Runtime configuration does not change.");
312  }
313
314  Ok(())
315}
316
317async fn get_new_boot_image(
318  infra: &InfraContext<'_>,
319  shasta_token: &str,
320  new_boot_image_configuration_opt: Option<&str>,
321  new_boot_image_id_opt: Option<&str>,
322) -> Result<Option<Image>, Error> {
323  let new_boot_image = if let Some(new_boot_image_configuration) =
324    new_boot_image_configuration_opt
325  {
326    tracing::info!(
327      "Boot configuration '{}' provided",
328      new_boot_image_configuration
329    );
330    let mut image_vec = get_image_vec_related_cfs_configuration_name(
331      infra,
332      shasta_token,
333      new_boot_image_configuration.to_string(),
334    )
335    .await?;
336
337    if image_vec.is_empty() {
338      return Err(Error::NotFound(format!(
339        "No boot image found for configuration '{new_boot_image_configuration}'"
340      )));
341    }
342
343    infra.backend.filter_images(&mut image_vec)?;
344
345    let most_recent_image = image_vec.iter().last().ok_or_else(|| {
346      Error::NotFound("No image found for configuration".to_string())
347    })?;
348
349    tracing::debug!(
350      "Boot image id related to configuration '{}' found:\n{:#?}",
351      new_boot_image_configuration,
352      most_recent_image
353    );
354
355    Some(most_recent_image.clone())
356  } else if let Some(boot_image_id) = new_boot_image_id_opt {
357    tracing::info!("Boot image id '{}' provided", boot_image_id);
358    let image_in_csm_vec = infra
359      .backend
360      .get_images(shasta_token, new_boot_image_id_opt)
361      .await?;
362
363    if image_in_csm_vec.is_empty() {
364      return Err(Error::NotFound(format!(
365        "Boot image id '{boot_image_id}' not found"
366      )));
367    }
368
369    image_in_csm_vec.first().cloned()
370  } else {
371    None
372  };
373
374  Ok(new_boot_image)
375}
376
377fn apply_kernel_params(
378  boot_param_vec: &mut [BootParameters],
379  new_kernel_parameters: &str,
380) -> Result<bool, Error> {
381  // One summary log per call; the previous per-iteration `info!`
382  // logged identical content N times (where N = number of nodes)
383  // and logged the running `any_changed` aggregate inside the loop
384  // — at cluster scale that drowned out anything else operators
385  // were trying to read from the info stream.
386  tracing::info!(
387    "Updating kernel parameters to '{}' across {} boot-parameter record(s)",
388    new_kernel_parameters,
389    boot_param_vec.len()
390  );
391
392  let mut any_changed = false;
393
394  for boot_parameter in boot_param_vec.iter_mut() {
395    tracing::debug!(
396      "Updating '{:?}' kernel parameters to '{}'",
397      boot_parameter.hosts,
398      new_kernel_parameters
399    );
400
401    let changed = boot_parameter.apply_kernel_params(new_kernel_parameters);
402    any_changed = changed || any_changed;
403
404    let image_id = boot_parameter.try_get_boot_image_id().ok_or_else(|| {
405      Error::MissingField(format!(
406        "Could not get boot image id from boot parameters for hosts: {:?}",
407        boot_parameter.hosts
408      ))
409    })?;
410
411    boot_parameter
412      .update_boot_image(&image_id, &boot_parameter.get_boot_image_etag())?;
413  }
414
415  Ok(any_changed)
416}
417
418async fn collect_boot_images(
419  infra: &InfraContext<'_>,
420  shasta_token: &str,
421  boot_param_vec: &mut [BootParameters],
422  new_boot_image_opt: Option<Image>,
423  need_restart: &mut bool,
424) -> Result<HashMap<String, Image>, Error> {
425  let mut image_vec = HashMap::<String, Image>::new();
426
427  if let Some(new_boot_image) = new_boot_image_opt {
428    let new_boot_image_id = new_boot_image
429      .id
430      .as_ref()
431      .ok_or_else(|| {
432        Error::MissingField("New boot image id is missing".to_string())
433      })?
434      .clone();
435
436    let new_boot_image_etag = new_boot_image
437      .link
438      .as_ref()
439      .and_then(|link| link.etag.as_ref())
440      .ok_or_else(|| {
441        Error::MissingField("New boot image etag is missing".to_string())
442      })?;
443
444    image_vec.insert(new_boot_image_id.clone(), new_boot_image.clone());
445
446    let any_differ = boot_param_vec.iter().any(|bp| {
447      bp.try_get_boot_image_id().as_deref() != Some(new_boot_image_id.as_str())
448    });
449
450    if any_differ {
451      // Single summary at info; per-host detail at debug. The
452      // previous per-iter `info!` emitted N identical lines for
453      // cluster-scale calls.
454      tracing::info!(
455        "Updating boot image to '{}' across {} boot-parameter record(s)",
456        new_boot_image_id,
457        boot_param_vec.len()
458      );
459      for boot_parameter in boot_param_vec.iter_mut() {
460        tracing::debug!(
461          "Updating '{:?}' boot image to '{}'",
462          boot_parameter.hosts,
463          new_boot_image_id
464        );
465        boot_parameter
466          .update_boot_image(&new_boot_image_id, new_boot_image_etag)?;
467      }
468      *need_restart = true;
469    }
470  } else {
471    // Dedupe boot_image_ids before fetching: a 5k-node group with N
472    // distinct boot images was previously costing N HTTPS round-trips
473    // serialised inside this loop. Now we resolve each id once in
474    // parallel.
475    let mut unique_ids: Vec<String> = Vec::new();
476    let mut seen: std::collections::HashSet<String> =
477      std::collections::HashSet::new();
478    for boot_parameter in boot_param_vec.iter() {
479      let boot_image_id =
480        boot_parameter.try_get_boot_image_id().ok_or_else(|| {
481          Error::MissingField(format!(
482            "Could not get boot image id from boot parameters for hosts: {:?}",
483            boot_parameter.hosts
484          ))
485        })?;
486      if seen.insert(boot_image_id.clone()) {
487        unique_ids.push(boot_image_id);
488      }
489    }
490
491    let fetched: Vec<(String, Image)> =
492      futures::future::try_join_all(unique_ids.iter().map(|id| async move {
493        let image = infra
494          .backend
495          .get_images(shasta_token, Some(id.as_str()))
496          .await?
497          .first()
498          .ok_or_else(|| {
499            Error::NotFound(format!("No image found for boot image id '{id}'"))
500          })?
501          .clone();
502        Ok::<_, Error>((id.clone(), image))
503      }))
504      .await?;
505
506    for (id, image) in fetched {
507      image_vec.insert(id, image);
508    }
509  }
510
511  Ok(image_vec)
512}
513
514/// Return the subset of `boot_parameter_vec` whose `hosts` list
515/// includes at least one member of the groups in `group_available_vec`.
516/// Used by `service::image` to scope image-deletion safety checks to
517/// the boot parameters that name nodes the caller can actually see.
518pub fn get_restricted_boot_parameters(
519  group_available_vec: &[Group],
520  boot_parameter_vec: &[BootParameters],
521) -> Vec<BootParameters> {
522  // Build a HashSet<String> once so the per-boot-param filter is O(H)
523  // rather than O(G×H) for G group members, H hosts per boot param.
524  // HashSet<String> supports contains(&str) via String: Borrow<str>.
525  let member_set: HashSet<String> = group_available_vec
526    .iter()
527    .flat_map(Group::get_members)
528    .collect();
529
530  boot_parameter_vec
531    .iter()
532    .filter(|boot_param| {
533      boot_param
534        .hosts
535        .iter()
536        .any(|h| member_set.contains(h.as_str()))
537    })
538    .cloned()
539    .collect::<Vec<BootParameters>>()
540}
541
542#[cfg(test)]
543mod tests {
544  use super::*;
545  use manta_backend_dispatcher::types::Member;
546
547  /// Helper: create a Group with given label and member xnames.
548  fn make_group(label: &str, member_ids: Vec<&str>) -> Group {
549    Group {
550      label: label.to_string(),
551      description: None,
552      tags: None,
553      members: Some(Member {
554        ids: Some(member_ids.into_iter().map(String::from).collect()),
555      }),
556      exclusive_group: None,
557    }
558  }
559
560  /// Helper: create a BootParameters with given hosts.
561  fn make_boot_params(hosts: Vec<&str>) -> BootParameters {
562    BootParameters {
563      hosts: hosts.into_iter().map(String::from).collect(),
564      ..Default::default()
565    }
566  }
567
568  #[test]
569  fn filters_boot_params_by_group_membership() {
570    let groups =
571      vec![make_group("grp1", vec!["x1000c0s0b0n0", "x1000c0s0b0n1"])];
572    let boot_params = vec![
573      make_boot_params(vec!["x1000c0s0b0n0"]),
574      make_boot_params(vec!["x9999c0s0b0n0"]),
575      make_boot_params(vec!["x1000c0s0b0n1"]),
576    ];
577    let result = get_restricted_boot_parameters(&groups, &boot_params);
578    assert_eq!(result.len(), 2);
579    assert_eq!(result[0].hosts, vec!["x1000c0s0b0n0"]);
580    assert_eq!(result[1].hosts, vec!["x1000c0s0b0n1"]);
581  }
582
583  #[test]
584  fn returns_empty_when_no_group_members_match() {
585    let groups = vec![make_group("grp1", vec!["x1000c0s0b0n0"])];
586    let boot_params = vec![make_boot_params(vec!["x9999c0s0b0n0"])];
587    let result = get_restricted_boot_parameters(&groups, &boot_params);
588    assert!(result.is_empty());
589  }
590
591  #[test]
592  fn returns_empty_when_groups_are_empty() {
593    let boot_params = vec![make_boot_params(vec!["x1000c0s0b0n0"])];
594    let result = get_restricted_boot_parameters(&[], &boot_params);
595    assert!(result.is_empty());
596  }
597
598  #[test]
599  fn returns_empty_when_boot_params_are_empty() {
600    let groups = vec![make_group("grp1", vec!["x1000c0s0b0n0"])];
601    let result = get_restricted_boot_parameters(&groups, &[]);
602    assert!(result.is_empty());
603  }
604
605  #[test]
606  fn aggregates_members_across_multiple_groups() {
607    let groups = vec![
608      make_group("grp1", vec!["x1000c0s0b0n0"]),
609      make_group("grp2", vec!["x2000c0s0b0n0"]),
610    ];
611    let boot_params = vec![
612      make_boot_params(vec!["x1000c0s0b0n0"]),
613      make_boot_params(vec!["x2000c0s0b0n0"]),
614      make_boot_params(vec!["x3000c0s0b0n0"]),
615    ];
616    let result = get_restricted_boot_parameters(&groups, &boot_params);
617    assert_eq!(result.len(), 2);
618  }
619}