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. See `pcs_operation` (crate-private) for the exact
77/// mapping.
78///
79/// # Errors
80///
81/// - [`Error::BadRequest`] when the caller lacks access to one of
82///   `params.xnames`.
83/// - Backend errors from `pcs_transitions_post` (PCS upstream
84///   failure, unknown xname, etc.).
85pub async fn apply_power(
86  infra: &InfraContext<'_>,
87  token: &str,
88  params: &ApplyPowerParams,
89) -> Result<TransitionStartOutput, Error> {
90  validate_user_group_members_access(infra, token, &params.xnames).await?;
91
92  infra
93    .backend
94    .pcs_transitions_post(
95      token,
96      pcs_operation(params.action, params.force),
97      &params.xnames,
98    )
99    .await
100}
101
102/// Map the CLI's typed `(PowerAction, force)` pair to PCS's
103/// wire-level `operation` string. `force` is ignored for `On`
104/// (PCS doesn't model a forceful power-on); for `Off` and `Reset`
105/// it toggles between the graceful (`soft-…`) and forceful
106/// (`force-off` / `hard-restart`) variants.
107pub(crate) fn pcs_operation(action: PowerAction, force: bool) -> &'static str {
108  match (action, force) {
109    (PowerAction::On, _) => "on",
110    (PowerAction::Off, false) => "soft-off",
111    (PowerAction::Off, true) => "force-off",
112    (PowerAction::Reset, false) => "soft-restart",
113    (PowerAction::Reset, true) => "hard-restart",
114  }
115}
116
117/// Fetch the current snapshot of a PCS power transition by id. The
118/// CLI's poll loop calls this every few seconds after `apply_power`
119/// returned the transition id.
120///
121/// Authorization: the caller must have group-access to every xname
122/// listed in the transition's `tasks`. An admin token short-circuits
123/// the check. A transition with no tasks (an unusual edge case the
124/// backend can in principle return) is allowed through; the response
125/// contains no xnames the caller didn't already supply.
126pub async fn get_power_transition(
127  infra: &InfraContext<'_>,
128  token: &str,
129  transition_id: &str,
130) -> Result<TransitionResponse, Error> {
131  let transition = infra
132    .backend
133    .pcs_transitions_get(token, transition_id)
134    .await?;
135
136  let xnames: Vec<String> =
137    transition.tasks.iter().map(|t| t.xname.clone()).collect();
138  validate_user_group_members_access(infra, token, &xnames).await?;
139
140  Ok(transition)
141}
142
143#[cfg(test)]
144mod tests {
145  //! Wire-mapping lock for `(PowerAction, force) -> PCS operation
146  //! string`. PCS rejects anything outside its known set; renaming
147  //! one of these strings would break power for everyone.
148
149  use super::{PowerAction, pcs_operation};
150
151  #[test]
152  fn on_ignores_force_flag() {
153    // PCS doesn't model a forceful "on" — the bool should not change
154    // the wire string.
155    assert_eq!(pcs_operation(PowerAction::On, false), "on");
156    assert_eq!(pcs_operation(PowerAction::On, true), "on");
157  }
158
159  #[test]
160  fn off_distinguishes_soft_from_force() {
161    assert_eq!(pcs_operation(PowerAction::Off, false), "soft-off");
162    assert_eq!(pcs_operation(PowerAction::Off, true), "force-off");
163  }
164
165  #[test]
166  fn reset_distinguishes_soft_from_hard() {
167    assert_eq!(pcs_operation(PowerAction::Reset, false), "soft-restart");
168    assert_eq!(pcs_operation(PowerAction::Reset, true), "hard-restart");
169  }
170}