manta_server/service/kernel_parameters.rs
1//! Kernel boot parameter mutations (add, apply, delete) with SBPS
2//! iSCSI image projection.
3//!
4//! All three mutations share the same two-phase flow:
5//!
6//! 1. `prepare_kernel_params_changes` (crate-private) reads the current
7//! `/v1/bootparameters` records for the target xnames, applies the
8//! `KernelParamOperation` in memory, and records which records
9//! changed (so the caller can target the reboot list). For
10//! `Add`/`Apply` it also walks each unique boot image referenced
11//! by an iSCSI-ready boot parameter and resolves it via
12//! [`futures::future::try_join_all`] — the resolved images are the
13//! SBPS projection candidates.
14//! 2. [`apply_kernel_params_changes`] writes the prepared records
15//! back, then patches each `images_to_project` image so SBPS picks
16//! it up.
17//!
18//! The split exists so the CLI / HTTP layer can show the operator the
19//! exact changeset (and the iSCSI image list) for confirmation before
20//! anything is persisted.
21
22use manta_backend_dispatcher::error::Error;
23use manta_backend_dispatcher::interfaces::bss::BootParametersTrait;
24use manta_backend_dispatcher::interfaces::ims::ImsTrait;
25use manta_backend_dispatcher::types::bss::BootParameters;
26use manta_backend_dispatcher::types::ims::Image;
27use std::collections::HashMap;
28
29use crate::server::common::app_context::InfraContext;
30use crate::service::authorization::validate_user_group_members_access;
31use crate::service::ims_ops::apply_image_patches;
32use crate::service::node_ops;
33pub use manta_shared::types::api::kernel_parameters::GetKernelParametersParams;
34
35/// Fetch BSS kernel parameters for the targets described by `params`.
36///
37/// Targets are resolved through [`node_ops::resolve_target_nodes`]
38/// (host expression → `group_name` → `settings_group_name` fallback
39/// from `cli.toml`). The caller's access to every resolved xname is
40/// validated before the BSS query runs.
41///
42/// # Errors
43///
44/// Any error from [`node_ops::resolve_target_nodes`] plus
45/// [`Error::NetError`] / [`Error::CsmError`] from the backend
46/// `get_bootparameters` call.
47pub async fn get_kernel_parameters(
48 infra: &InfraContext<'_>,
49 token: &str,
50 params: &GetKernelParametersParams,
51) -> Result<Vec<BootParameters>, Error> {
52 let xname_vec = node_ops::resolve_target_nodes(
53 infra,
54 token,
55 params.nodes.as_deref(),
56 params.group_name.as_deref(),
57 params.settings_group_name.as_deref(),
58 )
59 .await?;
60
61 validate_user_group_members_access(infra, token, &xname_vec).await?;
62
63 let boot_parameter_vec =
64 infra.backend.get_bootparameters(token, &xname_vec).await?;
65
66 Ok(boot_parameter_vec)
67}
68
69/// Describes which kernel parameter mutation to apply.
70pub(crate) enum KernelParamOperation<'a> {
71 /// Add kernel parameters, optionally overwriting existing values.
72 Add {
73 /// Space-separated `key=value` pairs to add.
74 params: &'a str,
75 /// When true, replace existing parameters with the same key
76 /// instead of skipping them.
77 overwrite: bool,
78 },
79 /// Replace all kernel parameters with the given value.
80 Apply {
81 /// Space-separated `key=value` pairs that fully replace the
82 /// existing parameter set.
83 params: &'a str,
84 },
85 /// Remove the specified kernel parameters.
86 Delete {
87 /// Space-separated parameter names (or `key=value` pairs) to
88 /// remove.
89 params: &'a str,
90 },
91}
92
93impl<'a> KernelParamOperation<'a> {
94 /// Apply the mutation to a single `BootParameters` entry.
95 /// Returns `true` if the parameters were actually changed.
96 fn mutate(&self, boot_parameter: &mut BootParameters) -> bool {
97 match self {
98 Self::Add { params, overwrite } => {
99 boot_parameter.add_kernel_params(params, *overwrite)
100 }
101 Self::Apply { params } => boot_parameter.apply_kernel_params(params),
102 Self::Delete { params } => boot_parameter.delete_kernel_params(params),
103 }
104 }
105
106 /// Whether this operation should handle SBPS image projection.
107 fn handles_sbps_images(&self) -> bool {
108 match self {
109 Self::Add { .. } | Self::Apply { .. } => true,
110 Self::Delete { .. } => false,
111 }
112 }
113}
114
115/// Result of preparing kernel parameter mutations (before persistence).
116#[derive(serde::Serialize)]
117pub struct KernelParamsChangeset {
118 /// The mutated boot parameters ready to persist.
119 pub boot_params: Vec<BootParameters>,
120 /// Nodes that need rebooting.
121 pub xnames_to_reboot: Vec<String>,
122 /// Whether any changes were detected.
123 pub has_changes: bool,
124 /// SBPS images that need iSCSI projection (image_id -> Image).
125 /// The CLI layer should confirm with the user before persisting these.
126 pub sbps_candidates: Vec<(String, Image)>,
127}
128
129/// Compute the kernel-parameter mutation as a
130/// [`KernelParamsChangeset`] without writing anything.
131///
132/// Pulls the current BSS records for `xname_vec`, applies `operation`
133/// to each in memory, and tracks which xnames actually changed so the
134/// caller can target the reboot list precisely. For `Add`/`Apply`,
135/// each unique boot-image referenced by a changed record is
136/// inspected once: if its root kernel-parameters look iSCSI-ready it
137/// is appended to `sbps_candidates` so the caller can decide whether
138/// to project it through SBPS. The per-image fetches run in parallel
139/// via [`futures::future::try_join_all`] so the total wall-clock cost
140/// stays bounded by the slowest image lookup, not their sum.
141///
142/// # Errors
143///
144/// - [`Error::NotFound`] when an SBPS candidate image id resolves to
145/// an empty image list (i.e. the image was deleted since the boot
146/// parameter was written).
147/// - [`Error::NetError`] / [`Error::CsmError`] from
148/// `get_bootparameters` or any of the per-image `get_images` calls.
149pub(crate) async fn prepare_kernel_params_changes(
150 infra: &InfraContext<'_>,
151 token: &str,
152 xname_vec: &[String],
153 operation: &KernelParamOperation<'_>,
154) -> Result<KernelParamsChangeset, Error> {
155 let mut boot_params: Vec<BootParameters> =
156 infra.backend.get_bootparameters(token, xname_vec).await?;
157
158 let mut has_changes = false;
159 let mut xnames_to_reboot: Vec<String> = Vec::new();
160
161 // First pass: apply the in-memory mutation and gather, in the
162 // order they first appear, the unique image ids referenced by
163 // iSCSI-ready boot parameters. The image fetches happen in the
164 // second pass below — the previous code fetched serially inside
165 // the loop (N HTTPS round-trips for N distinct boot images on a
166 // cluster-scale write).
167 let handles_sbps = operation.handles_sbps_images();
168 let mut sbps_image_ids: Vec<String> = Vec::new();
169 let mut seen_image_ids: std::collections::HashSet<String> =
170 std::collections::HashSet::new();
171
172 for bp in &mut boot_params {
173 let changed = operation.mutate(bp);
174 if changed {
175 has_changes = true;
176 xnames_to_reboot.extend(bp.hosts.iter().cloned());
177 }
178
179 if handles_sbps
180 && bp.is_root_kernel_param_iscsi_ready()
181 && let Some(image_id) = bp.try_get_boot_image_id()
182 && seen_image_ids.insert(image_id.clone())
183 {
184 sbps_image_ids.push(image_id);
185 }
186 }
187
188 // Second pass: resolve each unique image id in parallel.
189 let sbps_candidates: Vec<(String, Image)> = if sbps_image_ids.is_empty() {
190 Vec::new()
191 } else {
192 futures::future::try_join_all(sbps_image_ids.into_iter().map(|id| async {
193 let image = infra
194 .backend
195 .get_images(token, Some(id.as_str()))
196 .await?
197 .first()
198 .ok_or_else(|| {
199 Error::NotFound(format!("No image found for image id '{id}'"))
200 })?
201 .clone();
202 Ok::<_, Error>((id, image))
203 }))
204 .await?
205 };
206
207 Ok(KernelParamsChangeset {
208 boot_params,
209 xnames_to_reboot,
210 has_changes,
211 sbps_candidates,
212 })
213}
214
215/// Write a previously prepared [`KernelParamsChangeset`] back to BSS,
216/// and patch any SBPS images supplied in `images_to_project`.
217///
218/// Access to every reboot-target xname is re-validated before the
219/// first backend write. `images_to_project` is normally built by
220/// [`build_images_to_project`]; pass an empty map (as the delete path
221/// does) to skip SBPS projection entirely.
222///
223/// BSS PUTs and IMS PATCHes are each fanned out concurrently via
224/// [`futures::future::try_join_all`]; the first error from either
225/// phase is propagated to the caller.
226///
227/// # Errors
228///
229/// - [`Error::BadRequest`] when the caller's access to a reboot
230/// target has been revoked since the changeset was prepared.
231/// - [`Error::MissingField`] when one of the `images_to_project`
232/// entries has no `id`.
233/// - [`Error::NetError`] / [`Error::CsmError`] from
234/// `update_bootparameters` or `update_image`.
235pub async fn apply_kernel_params_changes(
236 infra: &InfraContext<'_>,
237 token: &str,
238 changeset: &KernelParamsChangeset,
239 images_to_project: &HashMap<String, Image>,
240) -> Result<(), Error> {
241 validate_user_group_members_access(infra, token, &changeset.xnames_to_reboot)
242 .await?;
243
244 // Fan out all BSS PUTs concurrently; propagate the first error
245 // (matches the original sequential loop's error semantics).
246 futures::future::try_join_all(changeset.boot_params.iter().map(
247 |bp| async move { infra.backend.update_bootparameters(token, bp).await },
248 ))
249 .await?;
250
251 // Update images projected through SBPS
252 apply_image_patches(infra, token, images_to_project).await?;
253
254 Ok(())
255}
256
257/// Build the SBPS images-to-project map from a kernel params changeset.
258///
259/// Marks each candidate image as iSCSI-ready and returns the projection
260/// map. Returns an empty map when `project_sbps` is false.
261pub fn build_images_to_project(
262 changeset: &KernelParamsChangeset,
263 project_sbps: bool,
264) -> HashMap<String, Image> {
265 if !project_sbps {
266 return HashMap::new();
267 }
268 changeset
269 .sbps_candidates
270 .iter()
271 .map(|(id, img)| {
272 let mut img = img.clone();
273 img.set_boot_image_iscsi_ready();
274 (id.clone(), img)
275 })
276 .collect()
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282
283 fn add(params: &str) -> KernelParamOperation<'_> {
284 KernelParamOperation::Add {
285 params,
286 overwrite: false,
287 }
288 }
289 fn add_overwrite(params: &str) -> KernelParamOperation<'_> {
290 KernelParamOperation::Add {
291 params,
292 overwrite: true,
293 }
294 }
295 fn apply(params: &str) -> KernelParamOperation<'_> {
296 KernelParamOperation::Apply { params }
297 }
298 fn delete(params: &str) -> KernelParamOperation<'_> {
299 KernelParamOperation::Delete { params }
300 }
301
302 #[test]
303 fn overwrite_flag_preserved() {
304 match add_overwrite("x") {
305 KernelParamOperation::Add { overwrite, .. } => assert!(overwrite),
306 _ => panic!("wrong variant"),
307 }
308 match add("x") {
309 KernelParamOperation::Add { overwrite, .. } => assert!(!overwrite),
310 _ => panic!("wrong variant"),
311 }
312 }
313
314 #[test]
315 fn handles_sbps_images_only_for_add_and_apply() {
316 assert!(add("quiet").handles_sbps_images());
317 assert!(apply("quiet").handles_sbps_images());
318 assert!(!delete("quiet").handles_sbps_images());
319 }
320}