use std::path::PathBuf;
use std::process;
use clap::{Parser, Subcommand};
use consortium::node_set::NodeSet;
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(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,
},
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 mut config = match FleetConfig::from_file(&args.config) {
Ok(c) => c,
Err(e) => {
eprintln!("error: {}", 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),
Commands::Deploy {
on,
tag,
action,
builders,
fanout,
} => cmd_deploy(&config, on.as_deref(), &tag, &action, builders, 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,
) -> 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
);
for name in &targets {
println!(" {}", name);
}
let report = 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(())
}