use std::{fmt, time::Duration};
use anyhow::{Context, Error, anyhow, bail};
use clap::ArgMatches;
use serde_json::Value;
use crate::common;
use crate::common::app_context::AppContext;
use crate::common::authentication::get_api_token;
use crate::common::clap_ext::ArgMatchesExt;
use crate::http_client::{MantaClient, OpenApiResultExt};
use crate::openapi_client::types::{PowerRequest, PowerTargetType};
use crate::output::action_result;
const POLL_INTERVAL: Duration = Duration::from_secs(3);
const MAX_POLL_ATTEMPTS: usize = 300;
async fn dispatch_power_on_group(
m: &ArgMatches,
ctx: &AppContext<'_>,
token: &str,
) -> Result<(), Error> {
exec_cluster(
ctx,
token,
PowerOpts {
action: PowerAction::On,
target: m.req_str("GROUP_NAME")?,
force: false,
no_wait: m.get_flag("no-wait"),
assume_yes: m.get_flag("assume-yes"),
output: m.req_str("output")?,
},
)
.await
}
async fn dispatch_power_off_group(
m: &ArgMatches,
ctx: &AppContext<'_>,
token: &str,
) -> Result<(), Error> {
let graceful = m
.get_one::<bool>("graceful")
.context("The 'graceful' argument must have a value")?;
exec_cluster(
ctx,
token,
PowerOpts {
action: PowerAction::Off,
target: m.req_str("GROUP_NAME")?,
force: !graceful,
no_wait: m.get_flag("no-wait"),
assume_yes: m.get_flag("assume-yes"),
output: m.req_str("output")?,
},
)
.await
}
async fn dispatch_power_reset_group(
m: &ArgMatches,
ctx: &AppContext<'_>,
token: &str,
) -> Result<(), Error> {
let force = m
.get_one::<bool>("graceful")
.context("The 'graceful' argument must have a value")?;
exec_cluster(
ctx,
token,
PowerOpts {
action: PowerAction::Reset,
target: m.req_str("GROUP_NAME")?,
force: *force,
no_wait: m.get_flag("no-wait"),
assume_yes: m.get_flag("assume-yes"),
output: m.req_str("output")?,
},
)
.await
}
pub async fn handle_power(
cli_power: &ArgMatches,
ctx: &AppContext<'_>,
) -> Result<(), Error> {
let token = get_api_token(ctx).await?;
match cli_power.subcommand() {
Some(("on", m)) => match m.subcommand() {
Some(("group", m)) => dispatch_power_on_group(m, ctx, &token).await?,
Some(("nodes", m)) => {
exec_nodes(
ctx,
&token,
PowerOpts {
action: PowerAction::On,
target: m.req_str("VALUE")?,
force: false,
no_wait: m.get_flag("no-wait"),
assume_yes: m.get_flag("assume-yes"),
output: m.req_str("output")?,
},
)
.await?;
}
Some((other, _)) => bail!("Unknown 'power on' subcommand: {other}"),
None => bail!("No 'power on' subcommand provided"),
},
Some(("off", m)) => match m.subcommand() {
Some(("group", m)) => dispatch_power_off_group(m, ctx, &token).await?,
Some(("nodes", m)) => {
let graceful = m
.get_one::<bool>("graceful")
.context("The 'graceful' argument must have a value")?;
exec_nodes(
ctx,
&token,
PowerOpts {
action: PowerAction::Off,
target: m.req_str("VALUE")?,
force: !graceful,
no_wait: m.get_flag("no-wait"),
assume_yes: m.get_flag("assume-yes"),
output: m.req_str("output")?,
},
)
.await?;
}
Some((other, _)) => bail!("Unknown 'power off' subcommand: {other}"),
None => bail!("No 'power off' subcommand provided"),
},
Some(("reset", m)) => match m.subcommand() {
Some(("group", m)) => dispatch_power_reset_group(m, ctx, &token).await?,
Some(("nodes", m)) => {
let graceful = m
.get_one::<bool>("graceful")
.context("The 'graceful' argument must have a value")?;
exec_nodes(
ctx,
&token,
PowerOpts {
action: PowerAction::Reset,
target: m.req_str("VALUE")?,
force: !graceful,
no_wait: m.get_flag("no-wait"),
assume_yes: m.get_flag("assume-yes"),
output: m.req_str("output")?,
},
)
.await?;
}
Some((other, _)) => bail!("Unknown 'power reset' subcommand: {other}"),
None => bail!("No 'power reset' subcommand provided"),
},
Some((other, _)) => bail!("Unknown 'power' subcommand: {other}"),
None => bail!("No 'power' subcommand provided"),
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PowerAction {
On,
Off,
Reset,
}
impl fmt::Display for PowerAction {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PowerAction::On => write!(f, "power on"),
PowerAction::Off => write!(f, "power off"),
PowerAction::Reset => write!(f, "power reset"),
}
}
}
impl PowerAction {
fn confirmation_text(&self) -> &'static str {
match self {
PowerAction::On => {
"The nodes above will be powered on. \
Please confirm to proceed?"
}
PowerAction::Off => {
"The nodes above will be powered off. \
Please confirm to proceed?"
}
PowerAction::Reset => {
"The nodes above will restart. \
Please confirm to proceed?"
}
}
}
fn wire(self) -> &'static str {
match self {
PowerAction::On => "on",
PowerAction::Off => "off",
PowerAction::Reset => "reset",
}
}
fn to_wire(self) -> crate::openapi_client::types::PowerAction {
match self {
PowerAction::On => crate::openapi_client::types::PowerAction::On,
PowerAction::Off => crate::openapi_client::types::PowerAction::Off,
PowerAction::Reset => crate::openapi_client::types::PowerAction::Reset,
}
}
}
pub struct PowerOpts<'a> {
pub action: PowerAction,
pub target: &'a str,
pub force: bool,
pub no_wait: bool,
pub assume_yes: bool,
pub output: &'a str,
}
pub async fn exec_nodes(
ctx: &AppContext<'_>,
token: &str,
opts: PowerOpts<'_>,
) -> Result<(), Error> {
println!("Nodes expression: {}", opts.target);
if !common::confirm::confirm(opts.action.confirmation_text(), opts.assume_yes)
{
bail!("Operation cancelled by user");
}
dispatch_and_wait(ctx, token, &opts, PowerTargetType::Nodes).await
}
pub async fn exec_cluster(
ctx: &AppContext<'_>,
token: &str,
opts: PowerOpts<'_>,
) -> Result<(), Error> {
println!("Group: {}", opts.target);
if !common::confirm::confirm(opts.action.confirmation_text(), opts.assume_yes)
{
bail!("Operation cancelled by user");
}
dispatch_and_wait(ctx, token, &opts, PowerTargetType::Cluster).await
}
async fn dispatch_and_wait(
ctx: &AppContext<'_>,
token: &str,
opts: &PowerOpts<'_>,
target_type: PowerTargetType,
) -> Result<(), Error> {
let action_str = opts.action.wire();
let client = MantaClient::from_app_ctx(ctx, Some(token))?;
let req = PowerRequest {
action: opts.action.to_wire(),
host_expression: opts.target.to_string(),
target_type,
force: Some(opts.force),
};
let started = client
.openapi
.post_power(client.site_name(), &req)
.await
.into_anyhow()?;
let transition_id = started
.get("transitionID")
.and_then(Value::as_str)
.ok_or_else(|| {
anyhow!("server response did not include a transitionID: {started}")
})?
.to_string();
if opts.no_wait {
action_result::print_with_data(
&format!(
"Power {action_str} transition started: {transition_id}. \
Run `manta power transition show {transition_id}` (or re-POST without --no-wait) to follow."
),
&started,
Some(opts.output),
)?;
return Ok(());
}
let final_snapshot = poll_until_done(&client, &transition_id).await?;
let failed = failed_count(&final_snapshot);
let message = if failed > 0 {
format!("Power {action_str} completed with {failed} failure(s).")
} else {
format!("Power {action_str} completed.")
};
action_result::print_with_data(&message, &final_snapshot, Some(opts.output))?;
if failed > 0 {
bail!("power transition reported {failed} failed task(s)");
}
Ok(())
}
async fn poll_until_done(
client: &MantaClient,
transition_id: &str,
) -> Result<Value, Error> {
let mut snapshot = client
.openapi
.get_power_transition(transition_id, client.site_name())
.await
.into_anyhow()?;
for attempt in 1..=MAX_POLL_ATTEMPTS {
tracing::info!(
"{}",
progress_summary(&snapshot, attempt, MAX_POLL_ATTEMPTS)
);
if is_complete(&snapshot) {
return Ok(snapshot);
}
tokio::time::sleep(POLL_INTERVAL).await;
snapshot = client
.openapi
.get_power_transition(transition_id, client.site_name())
.await
.into_anyhow()?;
}
bail!(
"power transition {transition_id} did not complete after {MAX_POLL_ATTEMPTS} poll attempts \
(interval {:?}); re-run `manta power transition show {transition_id}` to check later",
POLL_INTERVAL
)
}
fn is_complete(snapshot: &Value) -> bool {
snapshot
.get("transitionStatus")
.and_then(Value::as_str)
.is_some_and(|s| s == "completed")
}
fn failed_count(snapshot: &Value) -> u64 {
snapshot
.get("taskCounts")
.and_then(|c| c.get("failed"))
.and_then(Value::as_u64)
.unwrap_or(0)
}
fn progress_summary(
snapshot: &Value,
attempt: usize,
max_attempts: usize,
) -> String {
let status = snapshot
.get("transitionStatus")
.and_then(Value::as_str)
.unwrap_or("unknown");
let operation = snapshot
.get("operation")
.and_then(Value::as_str)
.unwrap_or("?");
let counts = snapshot.get("taskCounts").cloned().unwrap_or(Value::Null);
let count_u64 = |k: &str| counts.get(k).and_then(Value::as_u64).unwrap_or(0);
format!(
"Power '{}' progress (attempt {}/{}) — status: {}, failed: {}, in-progress: {}, succeeded: {}, total: {}",
operation,
attempt,
max_attempts,
status,
count_u64("failed"),
count_u64("in_progress"),
count_u64("succeeded"),
count_u64("total"),
)
}
#[cfg(test)]
mod tests {
use super::{failed_count, is_complete, progress_summary};
use serde_json::json;
#[test]
fn is_complete_true_only_for_completed_status() {
assert!(is_complete(&json!({ "transitionStatus": "completed" })));
assert!(!is_complete(&json!({ "transitionStatus": "in-progress" })));
assert!(!is_complete(&json!({ "transitionStatus": "new" })));
assert!(!is_complete(&json!({})));
assert!(!is_complete(&json!({ "transitionStatus": 42 })));
}
#[test]
fn failed_count_extracts_task_counts_failed() {
let snap = json!({
"taskCounts": { "failed": 3, "succeeded": 10, "total": 13 }
});
assert_eq!(failed_count(&snap), 3);
}
#[test]
fn failed_count_defaults_to_zero_on_missing_fields() {
assert_eq!(failed_count(&json!({})), 0);
assert_eq!(failed_count(&json!({ "taskCounts": {} })), 0);
assert_eq!(
failed_count(&json!({ "taskCounts": { "failed": "not-a-number" } })),
0
);
}
#[test]
fn progress_summary_renders_pcs_fields() {
let snap = json!({
"transitionStatus": "in-progress",
"operation": "Reset",
"taskCounts": {
"total": 17, "failed": 0, "in_progress": 5, "succeeded": 12,
}
});
let line = progress_summary(&snap, 7, 300);
assert!(line.contains("Reset"), "operation missing: {line}");
assert!(line.contains("attempt 7/300"), "attempt missing: {line}");
assert!(
line.contains("status: in-progress"),
"status missing: {line}"
);
assert!(line.contains("failed: 0"), "failed missing: {line}");
assert!(
line.contains("in-progress: 5"),
"in-progress missing: {line}"
);
assert!(line.contains("succeeded: 12"), "succeeded missing: {line}");
assert!(line.contains("total: 17"), "total missing: {line}");
}
#[test]
fn progress_summary_tolerates_missing_task_counts() {
let snap = json!({
"transitionStatus": "new",
"operation": "On",
});
let line = progress_summary(&snap, 1, 300);
assert!(line.contains("status: new"));
assert!(line.contains("failed: 0"));
assert!(line.contains("total: 0"));
}
}