manta_server/service/
node.rs

1//! HSM node queries, registration, and deletion, with rollback on
2//! partial failure.
3//!
4//! [`add_node`] performs three independent backend writes
5//! (`post_nodes`, optional `post_inventory_hardware`, `post_member`).
6//! Every failure after the initial `post_nodes` triggers a best-effort
7//! rollback that deletes the stub component, so a partial create
8//! does not leave the operator with an unrecoverable mid-state.
9
10use manta_backend_dispatcher::error::Error;
11use manta_backend_dispatcher::interfaces::hsm::{
12  component::ComponentTrait, group::GroupTrait,
13  hardware_inventory::HardwareInventory,
14};
15use manta_backend_dispatcher::types::{
16  ComponentArrayPostArray, ComponentCreate, HWInventoryByLocationList,
17};
18use manta_shared::types::dto::NodeDetails;
19use std::path::PathBuf;
20
21use crate::server::common::app_context::InfraContext;
22use crate::service::authorization::validate_user_group_members_access;
23use crate::service::node_details;
24use crate::service::node_ops::from_user_hosts_expression_to_xname_vec;
25pub use manta_shared::types::api::node::GetNodesParams;
26
27/// Fetch HSM node details for the targets named by
28/// `params.host_expression`.
29///
30/// The expression is parsed by
31/// [`crate::service::node_ops::from_user_hosts_expression_to_xname_vec`];
32/// when `params.include_siblings` is set, the resulting xnames are
33/// expanded to cover every node on the same BMC. Access to the
34/// resolved set is validated before the (relatively slow) per-node
35/// detail fetch. The optional `status_filter` matches case-insensitively
36/// against either the power or configuration status. Results are
37/// sorted by xname for stable output.
38pub async fn get_nodes(
39  infra: &InfraContext<'_>,
40  token: &str,
41  params: &GetNodesParams,
42) -> Result<Vec<NodeDetails>, Error> {
43  let node_list = from_user_hosts_expression_to_xname_vec(
44    infra,
45    token,
46    &params.host_expression,
47    params.include_siblings,
48  )
49  .await?;
50
51  if node_list.is_empty() {
52    return Err(Error::BadRequest(
53      "The list of nodes to operate is empty. Nothing to do".to_string(),
54    ));
55  }
56
57  // Validate xnames
58  validate_user_group_members_access(infra, token, &node_list).await?;
59
60  let mut node_details_list =
61    node_details::get_node_details(infra, token, &node_list).await?;
62
63  // Apply status filter
64  if let Some(ref status) = params.status_filter {
65    node_details_list.retain(|nd| {
66      nd.power_status.eq_ignore_ascii_case(status)
67        || nd.configuration_status.eq_ignore_ascii_case(status)
68    });
69  }
70
71  node_details_list.sort_by(|a, b| a.xname.cmp(&b.xname));
72
73  Ok(node_details_list)
74}
75
76// `compute_summary_status` moved to `manta_shared::types::cluster_status` —
77// only CLI display code calls it.
78
79/// Remove the HSM component with id `id` (typically an xname).
80///
81/// The caller's group access to the node is validated before the
82/// delete is dispatched.
83pub async fn delete_node(
84  infra: &InfraContext<'_>,
85  token: &str,
86  id: &str,
87) -> Result<(), Error> {
88  validate_user_group_members_access(infra, token, &[id.to_string()]).await?;
89
90  infra.backend.delete_node(token, id).await.map(|_| ())
91}
92
93/// Register a new HSM component, attach an optional hardware
94/// inventory file, and add it to the named group.
95///
96/// The flow is three writes: `post_nodes`, then (if
97/// `hardware_file_path` is supplied) `post_inventory_hardware` after
98/// parsing the JSON file, then `post_member`. Each failure after the
99/// initial create rolls back by deleting the node, so a partial
100/// failure does not leave a stub component behind. Hardware-file
101/// parse errors are reported as the original IO / serde error, with
102/// the same rollback applied.
103pub async fn add_node(
104  infra: &InfraContext<'_>,
105  token: &str,
106  id: &str,
107  group: &str,
108  enabled: bool,
109  arch_opt: Option<String>,
110  hardware_file_path: Option<&PathBuf>,
111) -> Result<(), Error> {
112  validate_user_group_members_access(infra, token, &[id.to_string()]).await?;
113
114  // Create node
115  let component = ComponentCreate {
116    id: id.to_string(),
117    state: "Unknown".to_string(),
118    flag: None,
119    enabled: Some(enabled),
120    software_status: None,
121    role: None,
122    sub_role: None,
123    nid: None,
124    subtype: None,
125    net_type: None,
126    arch: arch_opt,
127    class: None,
128  };
129
130  let components = ComponentArrayPostArray {
131    components: vec![component],
132    force: Some(true),
133  };
134
135  infra.backend.post_nodes(token, components).await?;
136
137  tracing::info!("Node saved '{}'", id);
138
139  // Parse and add hardware inventory if provided.
140  //
141  // HW inventory files are operator-supplied JSON that can run to
142  // several MB. Reading them with the sync `std::fs::File` +
143  // `serde_json::from_reader` chain parked the Tokio worker for the
144  // duration of the read and parse, stalling unrelated requests
145  // queued behind it on the same worker. `tokio::fs::read` does the
146  // I/O on a blocking pool; the in-memory `from_slice` parse stays
147  // on the worker but is bounded by file size.
148  let hw_inventory_opt: Option<HWInventoryByLocationList> =
149    if let Some(hardware_file) = hardware_file_path {
150      match read_hw_inventory(hardware_file).await {
151        Ok(inv) => Some(inv),
152        Err(e) => {
153          rollback_node(infra, token, id).await;
154          return Err(e);
155        }
156      }
157    } else {
158      None
159    };
160
161  if let Some(hw_inventory) = hw_inventory_opt {
162    tracing::info!("Adding hardware inventory for '{}'", id);
163    if let Err(error) = infra
164      .backend
165      .post_inventory_hardware(token, hw_inventory)
166      .await
167      .map(|_| ())
168    {
169      rollback_node(infra, token, id).await;
170      return Err(error);
171    }
172  }
173
174  // Add node to group
175  if let Err(error) = infra
176    .backend
177    .post_member(token, group, id)
178    .await
179    .map(|_| ())
180  {
181    rollback_node(infra, token, id).await;
182    return Err(error);
183  }
184
185  Ok(())
186}
187
188/// Read and parse a hardware-inventory JSON file off the Tokio
189/// reactor. The two-step `Value` → `from_value` round-trip is kept
190/// so the surfaced parse error still names the bad field (csm-rs
191/// uses `#[serde(rename = "ID")]` etc. and the direct `from_slice`
192/// path produces less helpful errors when a key is mistyped).
193async fn read_hw_inventory(
194  path: &PathBuf,
195) -> Result<HWInventoryByLocationList, Error> {
196  let bytes = tokio::fs::read(path).await?;
197  let value: serde_json::Value = serde_json::from_slice(&bytes)?;
198  let inv = serde_json::from_value::<HWInventoryByLocationList>(value)?;
199  Ok(inv)
200}
201
202/// Rollback helper: attempt to delete a node that was partially created.
203async fn rollback_node(infra: &InfraContext<'_>, token: &str, id: &str) {
204  tracing::warn!("Rolling back: attempting to delete node '{}'", id);
205  let delete_node_rslt = infra.backend.delete_node(token, id).await;
206  if delete_node_rslt.is_ok() {
207    tracing::info!("Rollback: node '{}' deleted", id);
208  }
209}