manta_server/service/
power.rs

1//! Power on/off/reset operations against PCS.
2//!
3//! `POST /power` (handler `post_power`) now returns immediately with
4//! the PCS transition id; the polling loop that used to live in
5//! `pcs_transitions_post_block` runs CLI-side. The CLI snapshots the
6//! transition with `GET /power/transitions/{id}` (handler
7//! `get_power_transition`) every few seconds until it completes.
8
9use manta_backend_dispatcher::error::Error;
10use manta_backend_dispatcher::interfaces::hsm::group::GroupTrait;
11use manta_backend_dispatcher::interfaces::pcs::PCSTrait;
12use manta_backend_dispatcher::types::pcs::transitions::types::{
13  TransitionResponse, TransitionStartOutput,
14};
15
16use crate::server::common::app_context::InfraContext;
17use crate::service::authorization::validate_user_group_members_access;
18use crate::service::node_ops;
19pub use manta_shared::types::api::power::{
20  ApplyPowerParams, PowerAction, PowerTargetType,
21};
22
23/// Resolve `host_expression` into the concrete xname list to pass to
24/// [`apply_power`].
25///
26/// For [`PowerTargetType::Cluster`] the expression is a single HSM
27/// group name and we fetch its members; for [`PowerTargetType::Nodes`]
28/// it's a hostlist / NID / xname expression resolved through
29/// [`node_ops::from_user_hosts_expression_to_xname_vec`]. The caller's group access
30/// to every resolved xname is validated before return. An empty
31/// resolution yields `Error::BadRequest` so PCS is never called with
32/// nothing to do.
33pub async fn resolve_target_xnames(
34  infra: &InfraContext<'_>,
35  token: &str,
36  target_type: PowerTargetType,
37  host_expression: &str,
38) -> Result<Vec<String>, Error> {
39  let xnames = match target_type {
40    PowerTargetType::Cluster => {
41      infra
42        .backend
43        .get_member_vec_from_group_name_vec(
44          token,
45          std::slice::from_ref(&host_expression.to_string()),
46        )
47        .await?
48    }
49    PowerTargetType::Nodes => {
50      node_ops::from_user_hosts_expression_to_xname_vec(
51        infra,
52        token,
53        host_expression,
54        false,
55      )
56      .await?
57    }
58  };
59
60  validate_user_group_members_access(infra, token, &xnames).await?;
61
62  if xnames.is_empty() {
63    return Err(Error::BadRequest("No nodes to operate on".into()));
64  }
65
66  Ok(xnames)
67}
68
69/// Start a PCS power transition (`on`, `soft-off`, `force-off`,
70/// `soft-restart`, `hard-restart`) against `params.xnames` and return
71/// the transition id immediately. The CLI is responsible for polling
72/// `get_power_transition` until the transition reports `completed`.
73///
74/// `params.force` only changes the wire-level PCS operation for
75/// `Off` and `Reset` — it's ignored for `On`, matching today's
76/// behaviour.
77pub async fn apply_power(
78  infra: &InfraContext<'_>,
79  token: &str,
80  params: &ApplyPowerParams,
81) -> Result<TransitionStartOutput, Error> {
82  validate_user_group_members_access(infra, token, &params.xnames).await?;
83
84  infra
85    .backend
86    .pcs_transitions_post(
87      token,
88      pcs_operation(params.action, params.force),
89      &params.xnames,
90    )
91    .await
92}
93
94/// Map the CLI's typed `(PowerAction, force)` pair to PCS's
95/// wire-level `operation` string. `force` is ignored for `On`
96/// (PCS doesn't model a forceful power-on); for `Off` and `Reset`
97/// it toggles between the graceful (`soft-…`) and forceful
98/// (`force-off` / `hard-restart`) variants.
99pub(crate) fn pcs_operation(action: PowerAction, force: bool) -> &'static str {
100  match (action, force) {
101    (PowerAction::On, _) => "on",
102    (PowerAction::Off, false) => "soft-off",
103    (PowerAction::Off, true) => "force-off",
104    (PowerAction::Reset, false) => "soft-restart",
105    (PowerAction::Reset, true) => "hard-restart",
106  }
107}
108
109/// Fetch the current snapshot of a PCS power transition by id. The
110/// CLI's poll loop calls this every few seconds after `apply_power`
111/// returned the transition id.
112///
113/// Authorization: the caller must have group-access to every xname
114/// listed in the transition's `tasks`. An admin token short-circuits
115/// the check. A transition with no tasks (an unusual edge case the
116/// backend can in principle return) is allowed through; the response
117/// contains no xnames the caller didn't already supply.
118pub async fn get_power_transition(
119  infra: &InfraContext<'_>,
120  token: &str,
121  transition_id: &str,
122) -> Result<TransitionResponse, Error> {
123  let transition = infra
124    .backend
125    .pcs_transitions_get(token, transition_id)
126    .await?;
127
128  let xnames: Vec<String> =
129    transition.tasks.iter().map(|t| t.xname.clone()).collect();
130  validate_user_group_members_access(infra, token, &xnames).await?;
131
132  Ok(transition)
133}
134
135#[cfg(test)]
136mod tests {
137  //! Wire-mapping lock for `(PowerAction, force) -> PCS operation
138  //! string`. PCS rejects anything outside its known set; renaming
139  //! one of these strings would break power for everyone.
140
141  use super::{PowerAction, pcs_operation};
142
143  #[test]
144  fn on_ignores_force_flag() {
145    // PCS doesn't model a forceful "on" — the bool should not change
146    // the wire string.
147    assert_eq!(pcs_operation(PowerAction::On, false), "on");
148    assert_eq!(pcs_operation(PowerAction::On, true), "on");
149  }
150
151  #[test]
152  fn off_distinguishes_soft_from_force() {
153    assert_eq!(pcs_operation(PowerAction::Off, false), "soft-off");
154    assert_eq!(pcs_operation(PowerAction::Off, true), "force-off");
155  }
156
157  #[test]
158  fn reset_distinguishes_soft_from_hard() {
159    assert_eq!(pcs_operation(PowerAction::Reset, false), "soft-restart");
160    assert_eq!(pcs_operation(PowerAction::Reset, true), "hard-restart");
161  }
162}