use std::path::PathBuf;
use std::process;
use clap::{Parser, Subcommand};
use consortium::node_set::NodeSet;
use consortium_cli::output::{CliOutput, OutputArgs};
use consortium_nix::config::{DeployAction, FleetConfig};
use consortium_nix::health;
#[derive(Parser)]
#[command(name = "cast", version, about)]
struct Args {
#[arg(short, long, default_value = "fleet.json")]
config: PathBuf,
#[arg(long)]
flake: Option<String>,
#[command(flatten)]
output: OutputArgs,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Eval {
#[arg(short = 'w', long = "on")]
on: Option<String>,
#[arg(short = 'g', long = "tag")]
tag: Vec<String>,
},
Build {
#[arg(short = 'w', long = "on")]
on: Option<String>,
#[arg(short = 'g', long = "tag")]
tag: Vec<String>,
#[arg(long)]
builders: bool,
#[arg(short = 'f', long = "fanout", default_value = "4")]
fanout: usize,
},
Deploy {
#[arg(short = 'w', long = "on")]
on: Option<String>,
#[arg(short = 'g', long = "tag")]
tag: Vec<String>,
#[arg(default_value = "switch")]
action: String,
#[arg(long)]
builders: bool,
#[arg(short = 'f', long = "fanout", default_value = "4")]
fanout: usize,
#[arg(long = "cascade")]
cascade: bool,
#[arg(long = "cascade-fanout", default_value = "2")]
cascade_fanout: u32,
},
Health,
Status {
#[arg(short = 'w', long = "on")]
on: Option<String>,
#[arg(short = 'g', long = "tag")]
tag: Vec<String>,
},
}
fn main() {
let args = Args::parse();
let out = CliOutput::from_args(&args.output);
let mut config = match FleetConfig::from_file(&args.config) {
Ok(c) => c,
Err(e) => {
out.error(format!("{}", e));
eprintln!(
"hint: build fleet config first with: nix build .#fleet-config && cp result fleet.json"
);
process::exit(1);
}
};
if let Some(ref flake) = args.flake {
config.flake_uri = flake.clone();
}
let result = match args.command {
Commands::Eval { on, tag } => cmd_eval(&config, on.as_deref(), &tag),
Commands::Build {
on,
tag,
builders,
fanout,
} => cmd_deploy(
&config,
on.as_deref(),
&tag,
"build",
builders,
fanout,
false,
2,
),
Commands::Deploy {
on,
tag,
action,
builders,
fanout,
cascade,
cascade_fanout,
} => cmd_deploy(
&config,
on.as_deref(),
&tag,
&action,
builders,
fanout,
cascade,
cascade_fanout,
),
Commands::Health => cmd_health(&config),
Commands::Status { on, tag } => cmd_status(&config, on.as_deref(), &tag),
};
if let Err(e) = result {
eprintln!("error: {}", e);
process::exit(1);
}
}
fn resolve_targets(
config: &FleetConfig,
on: Option<&str>,
tags: &[String],
) -> anyhow::Result<Vec<String>> {
let mut targets = Vec::new();
if let Some(pattern) = on {
let ns = NodeSet::parse(pattern)
.map_err(|e| anyhow::anyhow!("invalid node pattern '{}': {}", pattern, e))?;
targets.extend(ns.iter());
}
if !tags.is_empty() {
let tag_nodes = config.nodes_by_tags(tags);
for node in tag_nodes {
if !targets.contains(&node.name) {
targets.push(node.name.clone());
}
}
}
if targets.is_empty() {
targets = config.node_names();
}
for name in &targets {
if !config.nodes.contains_key(name) {
anyhow::bail!(
"unknown node '{}' (available: {})",
name,
config.node_names().join(", ")
);
}
}
Ok(targets)
}
fn cmd_eval(config: &FleetConfig, on: Option<&str>, tags: &[String]) -> anyhow::Result<()> {
let targets = resolve_targets(config, on, tags)?;
println!("Evaluating {} host(s):", targets.len());
for name in &targets {
let node = &config.nodes[name];
println!(
" {} → {} ({}@{}, tags: [{}])",
name,
node.system,
node.target_user,
node.target_host,
node.tags.join(", ")
);
if let Some(ref drv) = node.drv_path {
println!(" drv: {}", drv);
}
}
Ok(())
}
fn cmd_deploy(
config: &FleetConfig,
on: Option<&str>,
tags: &[String],
action_str: &str,
use_builders: bool,
fanout: usize,
cascade: bool,
cascade_fanout: u32,
) -> anyhow::Result<()> {
let targets = resolve_targets(config, on, tags)?;
let action: DeployAction = action_str.parse().map_err(|_| {
anyhow::anyhow!(
"invalid action '{}' (try: switch, boot, test, dry-activate, build)",
action_str
)
})?;
println!(
"Deploying {} host(s) with action '{}'{}:",
targets.len(),
action,
if cascade {
format!(
" [cascade copy fanout={} — peer-to-peer fan-out]",
cascade_fanout
)
} else {
String::new()
}
);
for name in &targets {
println!(" {}", name);
}
let report = if cascade && action != DeployAction::Build {
let seed_addr = std::env::var("USER")
.map(|u| format!("{}@localhost", u))
.unwrap_or_else(|_| "localhost".into());
consortium_nix::deploy_with_cascade(
config,
&targets,
action,
fanout,
use_builders,
cascade_fanout,
&seed_addr,
None, )?
} else {
consortium_nix::deploy(config, &targets, action, fanout, use_builders)?
};
println!();
if report.is_success() {
println!("Deployment successful!");
println!(" Built: {}", report.built.len());
if action != DeployAction::Build {
println!(" Copied: {}", report.copied.len());
println!(" Activated: {}", report.activated.len());
}
} else {
println!(
"Deployment completed with {} failure(s):",
report.failure_count()
);
for (name, err) in &report.build_failures {
eprintln!(" build failed on {}: {}", name, err);
}
for (name, err) in &report.copy_failures {
eprintln!(" copy failed to {}: {}", name, err);
}
for (name, err) in &report.activation_failures {
eprintln!(" activation failed on {}: {}", name, err);
}
process::exit(1);
}
Ok(())
}
fn cmd_health(config: &FleetConfig) -> anyhow::Result<()> {
if config.builders.is_empty() {
println!("No builders configured.");
return Ok(());
}
println!("Probing {} builder(s):", config.builders.len());
let statuses = health::check_builders(config);
let healthy_count = statuses.iter().filter(|s| s.healthy).count();
for status in &statuses {
let icon = if status.healthy { "ok" } else { "FAIL" };
let latency = status
.latency_ms
.map(|ms| format!(" ({}ms)", ms))
.unwrap_or_default();
let error = status
.error
.as_ref()
.map(|e| format!(" - {}", e))
.unwrap_or_default();
println!(
" [{}] {}@{}{}{} (jobs:{}, speed:{}x)",
icon,
status.builder.user,
status.builder.host,
latency,
error,
status.builder.max_jobs,
status.builder.speed_factor,
);
}
println!("\n{}/{} builders healthy", healthy_count, statuses.len());
Ok(())
}
fn cmd_status(config: &FleetConfig, on: Option<&str>, tags: &[String]) -> anyhow::Result<()> {
let targets = resolve_targets(config, on, tags)?;
println!("Querying {} host(s):", targets.len());
for name in &targets {
let node = &config.nodes[name];
match consortium_nix::eval::query_current_system(&node.target_host, &node.target_user) {
Ok(Some(path)) => println!(" {} → {}", name, path),
Ok(None) => println!(" {} → (unknown)", name),
Err(e) => println!(" {} → error: {}", name, e),
}
}
Ok(())
}