use std::io::{self, BufRead, Write};
use std::path::PathBuf;
use std::time::Duration;
use clap::{Args, Subcommand};
use net_sdk::deck::{AvoidScope, BlastRadius, ChainCommit, DaemonRef, OperatorSignature};
use serde::Serialize;
use crate::context::{resolve_profile, CliContext};
use crate::error::{generic, sdk, CliError, ExitCodeKind};
use crate::parsers::parse_u64_flexible;
use crate::prelude::{emit_value, OutputFormat};
#[derive(Subcommand, Debug)]
pub enum IceCommand {
FreezeCluster(FreezeArgs),
ThawCluster(BareArgs),
FlushAvoidLists(FlushArgs),
ForceEvictReplica(ForceEvictArgs),
ForceRestartDaemon(ForceRestartArgs),
ForceCutover(ForceCutoverArgs),
KillMigration(KillMigrationArgs),
}
#[derive(Args, Debug)]
pub struct FreezeArgs {
#[arg(long, value_parser = crate::humantime::parse_duration)]
pub ttl: Duration,
#[command(flatten)]
pub common: CommonIceArgs,
}
#[derive(Args, Debug)]
pub struct BareArgs {
#[command(flatten)]
pub common: CommonIceArgs,
}
#[derive(Args, Debug)]
pub struct FlushArgs {
#[arg(long)]
pub scope: String,
#[command(flatten)]
pub common: CommonIceArgs,
}
#[derive(Args, Debug)]
pub struct ForceEvictArgs {
#[arg(value_parser = parse_u64_flexible)]
pub chain: u64,
#[arg(value_parser = parse_u64_flexible)]
pub victim: u64,
#[command(flatten)]
pub common: CommonIceArgs,
}
#[derive(Args, Debug)]
pub struct ForceRestartArgs {
#[arg(value_parser = parse_u64_flexible)]
pub daemon_id: u64,
#[arg(long)]
pub name: String,
#[command(flatten)]
pub common: CommonIceArgs,
}
#[derive(Args, Debug)]
pub struct ForceCutoverArgs {
#[arg(value_parser = parse_u64_flexible)]
pub chain: u64,
#[arg(value_parser = parse_u64_flexible)]
pub target: u64,
#[command(flatten)]
pub common: CommonIceArgs,
}
#[derive(Args, Debug)]
pub struct KillMigrationArgs {
#[arg(value_parser = parse_u64_flexible)]
pub migration: u64,
#[command(flatten)]
pub common: CommonIceArgs,
}
#[derive(Args, Debug)]
pub struct CommonIceArgs {
#[arg(long)]
pub dry_run: bool,
#[arg(long)]
pub yes: bool,
#[arg(long = "sig", num_args = 0..)]
pub sigs: Vec<String>,
#[arg(long)]
pub identity: Option<PathBuf>,
#[arg(long, default_value_t = crate::prelude::DEFAULT_SUPERVISOR_NODE)]
pub supervisor_node: u64,
}
fn parse_scope(s: &str) -> Result<AvoidScope, CliError> {
if s == "global" {
return Ok(AvoidScope::Global);
}
if let Some(rest) = s.strip_prefix("local:") {
let node = parse_u64_flexible(rest).map_err(|e| {
crate::error::invalid_args(format!("invalid `local:<NODE>` scope: {e}"))
})?;
return Ok(AvoidScope::Local { node });
}
if let Some(rest) = s.strip_prefix("on-peer:") {
let peer = parse_u64_flexible(rest).map_err(|e| {
crate::error::invalid_args(format!("invalid `on-peer:<PEER>` scope: {e}"))
})?;
return Ok(AvoidScope::OnPeer { peer });
}
Err(crate::error::invalid_args(format!(
"scope must be `global` | `local:<NODE>` | `on-peer:<PEER>`; got {s:?}"
)))
}
pub async fn run(
cmd: IceCommand,
output: Option<OutputFormat>,
config_path: Option<&std::path::Path>,
profile_name: &str,
) -> Result<(), CliError> {
match cmd {
IceCommand::FreezeCluster(args) => {
let common = args.common;
let ttl = args.ttl;
run_ice(common, output, config_path, profile_name, move |deck| {
deck.ice().freeze_cluster(ttl)
})
.await
}
IceCommand::ThawCluster(args) => {
let common = args.common;
run_ice(common, output, config_path, profile_name, move |deck| {
deck.ice().thaw_cluster()
})
.await
}
IceCommand::FlushAvoidLists(args) => {
let scope = parse_scope(&args.scope)?;
let common = args.common;
run_ice(common, output, config_path, profile_name, move |deck| {
deck.ice().flush_avoid_lists(scope)
})
.await
}
IceCommand::ForceEvictReplica(args) => {
let common = args.common;
let chain = args.chain;
let victim = args.victim;
run_ice(common, output, config_path, profile_name, move |deck| {
deck.ice().force_evict_replica(chain, victim)
})
.await
}
IceCommand::ForceRestartDaemon(args) => {
let common = args.common;
let daemon = DaemonRef {
id: args.daemon_id,
name: args.name.clone(),
};
run_ice(common, output, config_path, profile_name, move |deck| {
deck.ice().force_restart_daemon(daemon.clone())
})
.await
}
IceCommand::ForceCutover(args) => {
let common = args.common;
let chain = args.chain;
let target = args.target;
run_ice(common, output, config_path, profile_name, move |deck| {
deck.ice().force_cutover(chain, target)
})
.await
}
IceCommand::KillMigration(args) => {
let common = args.common;
let migration = args.migration;
run_ice(common, output, config_path, profile_name, move |deck| {
deck.ice().kill_migration(migration)
})
.await
}
}
}
async fn run_ice<F>(
common: CommonIceArgs,
output: Option<OutputFormat>,
config_path: Option<&std::path::Path>,
profile_name: &str,
build_proposal: F,
) -> Result<(), CliError>
where
F: for<'a> FnOnce(&'a net_sdk::deck::DeckClient) -> net_sdk::deck::IceProposal<'a>,
{
let profile = resolve_profile(config_path, profile_name).await?;
let ctx = CliContext::build(
&profile,
common.identity.as_deref(),
common.supervisor_node,
true,
)
.await?;
let deck = ctx.deck();
let proposal = build_proposal(deck.as_ref());
let simulated = proposal
.simulate()
.await
.map_err(|e| map_ice_error(&format!("simulate: {e}"), e.kind))?;
let blast = simulated.blast_radius().clone();
let preview = SimulationPreview {
issued_at_ms: simulated.issued_at_ms(),
blast_hash: hex::encode(simulated.blast_hash()),
blast,
};
emit_value(OutputFormat::resolve_oneshot(output), &preview)
.map_err(|e| generic(format!("write ICE preview: {e}")))?;
if common.dry_run {
return Ok(());
}
let mut signatures: Vec<OperatorSignature> = Vec::new();
for raw in &common.sigs {
signatures.push(parse_supplied_sig(raw)?);
}
let stdin_is_tty = std::io::IsTerminal::is_terminal(&io::stdin());
let yes_flag = common.yes;
tokio::task::spawn_blocking(move || check_confirm_gate(stdin_is_tty, yes_flag, prompt_for_yes))
.await
.map_err(|e| generic(format!("confirm-gate task panicked: {e}")))??;
let local_sig = ctx.identity().sign_proposal(
simulated.action(),
simulated.issued_at_ms(),
&simulated.blast_hash(),
);
signatures.push(local_sig);
let commit: ChainCommit = simulated
.commit(&signatures)
.await
.map_err(|e| map_ice_error(&format!("commit: {e}"), e.kind))?;
let payload = ChainCommitMirror {
commit_id: commit.commit_id(),
operator_id: commit.operator_id(),
event_kind: commit.event_kind(),
committed_at_ms: commit
.committed_at()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0),
};
emit_value(OutputFormat::resolve_oneshot(output), &payload)
.map_err(|e| generic(format!("write commit: {e}")))?;
Ok(())
}
fn check_confirm_gate<P>(stdin_is_tty: bool, yes_flag: bool, prompt: P) -> Result<(), CliError>
where
P: FnOnce() -> Result<bool, CliError>,
{
if !stdin_is_tty && !yes_flag {
return Err(CliError::new(
ExitCodeKind::ConfirmationRefused,
"stdin is not a TTY; pass --yes to skip the interactive confirm prompt",
));
}
if stdin_is_tty && !prompt()? {
return Err(crate::error::confirmation_refused());
}
Ok(())
}
fn map_ice_error(msg: &str, kind: &'static str) -> CliError {
match kind {
"simulation_required" => CliError::new(ExitCodeKind::IceSimulationBlocked, msg),
"signature_invalid" => CliError::new(ExitCodeKind::IceSignatureInvalid, msg),
"not_authorized"
| "insufficient_signatures"
| "envelope_expired"
| "envelope_from_future"
| "ice_cooldown_active" => CliError::new(ExitCodeKind::OperatorPolicyRejected, msg),
_ => sdk(msg),
}
}
fn prompt_for_yes() -> Result<bool, CliError> {
let mut stderr = io::stderr();
write!(stderr, "Type YES to confirm ICE commit: ")
.map_err(|e| generic(format!("prompt write: {e}")))?;
stderr
.flush()
.map_err(|e| generic(format!("prompt flush: {e}")))?;
let mut line = String::new();
io::stdin()
.lock()
.read_line(&mut line)
.map_err(|e| generic(format!("prompt read: {e}")))?;
Ok(line.trim() == "YES")
}
fn parse_supplied_sig(raw: &str) -> Result<OperatorSignature, CliError> {
#[derive(serde::Deserialize)]
struct SigJson {
operator_id: u64,
signature_hex: String,
}
let parsed: SigJson = serde_json::from_str(raw)
.map_err(|e| crate::error::invalid_args(format!("--sig must be JSON object: {e}")))?;
let bytes = hex::decode(&parsed.signature_hex).map_err(|e| {
crate::error::invalid_args(format!("--sig signature_hex is not valid hex: {e}"))
})?;
if bytes.len() != 64 {
return Err(crate::error::invalid_args(format!(
"--sig signature_hex decoded to {} bytes; expected 64",
bytes.len()
)));
}
Ok(OperatorSignature {
operator_id: parsed.operator_id,
signature: bytes,
})
}
#[derive(Serialize)]
struct SimulationPreview {
issued_at_ms: u64,
blast_hash: String,
blast: BlastRadius,
}
#[derive(Serialize)]
struct ChainCommitMirror {
commit_id: u64,
operator_id: u64,
event_kind: &'static str,
committed_at_ms: u64,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn non_tty_without_yes_refuses_with_code_8() {
let err = check_confirm_gate(false, false, || panic!("prompt must not run")).unwrap_err();
assert_eq!(err.kind(), ExitCodeKind::ConfirmationRefused);
}
#[test]
fn non_tty_with_yes_passes() {
check_confirm_gate(false, true, || panic!("prompt must not run")).unwrap();
}
#[test]
fn tty_prompt_no_refuses_with_code_8() {
let err = check_confirm_gate(true, false, || Ok(false)).unwrap_err();
assert_eq!(err.kind(), ExitCodeKind::ConfirmationRefused);
}
#[test]
fn tty_prompt_yes_passes() {
check_confirm_gate(true, false, || Ok(true)).unwrap();
}
#[test]
fn tty_always_prompts_even_with_yes_flag() {
let prompted = std::cell::Cell::new(false);
let _ = check_confirm_gate(true, true, || {
prompted.set(true);
Ok(true)
});
assert!(prompted.get(), "TTY path must always prompt");
}
}