Skip to main content

consortium_cli/
cast.rs

1//! cast — NixOS / nix-darwin deployment orchestration CLI.
2//!
3//! Usage:
4//!   cast --flake . deploy --on @darwin switch   # build, copy, activate a group
5//!   cast deploy --on hp[01-03] switch           # bracket notation
6//!   cast build --on contra                      # build only
7//!   cast eval --on mm[01-05]                    # show what would be built
8//!   cast health                                 # probe all builders
9//!   cast status --on hp[01-03]                  # show current system versions
10//!
11//! Fleet source, in order of precedence: `--config FILE`, `./fleet.json`
12//! when it exists, the flake's `fleet` output (an `mkFleet` result), and
13//! finally nodes derived from the flake's `darwinConfigurations` /
14//! `nixosConfigurations` names. `--on` accepts `@group` references
15//! resolved through ClusterShell `groups.conf` / `groups.d`. Before a
16//! deploy, every remote target's ssh endpoint is resolved live (see
17//! [`consortium_nix::endpoint`]); a target that is this machine is
18//! activated locally without copy or ssh.
19
20use std::path::{Path, PathBuf};
21use std::process;
22use std::sync::Arc;
23use std::time::Duration;
24
25use clap::{Parser, Subcommand};
26
27use crate::cascade::{self, CascadeCommands, LiveArgs, RenderArgs, TreeArgs, VerifyArgs};
28use crate::groups;
29use crate::output::{CliOutput, OutputArgs};
30use consortium_integration::exec::{Executor, ProcessExecutor};
31use consortium_nix::config::{DeployAction, FleetConfig};
32use consortium_nix::endpoint::{self, EndpointResolver};
33use consortium_nix::fleet_source;
34use consortium_nix::health;
35use consortium_nix::{DeployOptions, NixArgs};
36
37/// cast — NixOS / nix-darwin deployment orchestration powered by consortium.
38#[derive(Parser)]
39#[command(name = "cast", version, about)]
40pub struct Args {
41    /// Fleet configuration JSON file. Default: ./fleet.json when it exists,
42    /// otherwise the fleet is discovered from --flake (its `fleet` output,
43    /// else its darwinConfigurations / nixosConfigurations).
44    #[arg(short, long)]
45    config: Option<PathBuf>,
46
47    /// Flake to deploy (e.g. /home/user/nixlab or github:user/repo);
48    /// overrides the fleet config's flakeUri. Default: the current directory.
49    #[arg(long)]
50    flake: Option<String>,
51
52    /// SSH user for every target, overriding the fleet's targetUser.
53    /// Flake-derived nodes default to $USER.
54    #[arg(long)]
55    user: Option<String>,
56
57    /// Extra words for every `nix eval` / `nix build` (whitespace-split,
58    /// repeatable), e.g. --nix-args '--option builders ""'.
59    #[arg(long = "nix-args", value_name = "WORDS", allow_hyphen_values = true)]
60    nix_args: Vec<String>,
61
62    /// Extra words for nix-darwin hosts' `nix eval` / `nix build` only
63    /// (whitespace-split, repeatable), e.g. '--override-input foo path:./stub'.
64    #[arg(
65        long = "darwin-nix-args",
66        value_name = "WORDS",
67        allow_hyphen_values = true
68    )]
69    darwin_nix_args: Vec<String>,
70
71    /// Extra words for NixOS hosts' `nix eval` / `nix build` only
72    /// (whitespace-split, repeatable).
73    #[arg(
74        long = "nixos-nix-args",
75        value_name = "WORDS",
76        allow_hyphen_values = true
77    )]
78    nixos_nix_args: Vec<String>,
79
80    #[command(flatten)]
81    output: OutputArgs,
82
83    #[command(subcommand)]
84    command: Commands,
85}
86
87#[derive(Subcommand)]
88enum Commands {
89    /// Evaluate which hosts need deployment.
90    Eval {
91        /// Target nodes (bracket notation, e.g. hp[01-03]; @group references).
92        #[arg(short = 'w', long = "on")]
93        on: Option<String>,
94
95        /// Target nodes by tag.
96        #[arg(short = 'g', long = "tag")]
97        tag: Vec<String>,
98    },
99
100    /// Build system closures.
101    Build {
102        /// Target nodes (bracket notation; @group references).
103        #[arg(short = 'w', long = "on")]
104        on: Option<String>,
105
106        /// Target nodes by tag.
107        #[arg(short = 'g', long = "tag")]
108        tag: Vec<String>,
109
110        /// Use distributed builders.
111        #[arg(long)]
112        builders: bool,
113
114        /// Maximum parallel builds.
115        #[arg(short = 'f', long = "fanout", default_value = "4")]
116        fanout: usize,
117    },
118
119    /// Deploy to targets (build + copy + activate).
120    Deploy {
121        /// Target nodes (bracket notation; @group references).
122        #[arg(short = 'w', long = "on")]
123        on: Option<String>,
124
125        /// Target nodes by tag.
126        #[arg(short = 'g', long = "tag")]
127        tag: Vec<String>,
128
129        /// Deployment action.
130        #[arg(default_value = "switch")]
131        action: String,
132
133        /// Use distributed builders.
134        #[arg(long)]
135        builders: bool,
136
137        /// Maximum parallel operations.
138        #[arg(short = 'f', long = "fanout", default_value = "4")]
139        fanout: usize,
140
141        /// Use the cascade primitive for the copy stage — peer-to-peer
142        /// fan-out instead of per-host serial. Each host that has the
143        /// closure joins the source pool for the next round, dropping
144        /// copy time from O(N) to O(log N) for hosts sharing a toplevel.
145        #[arg(long = "cascade")]
146        cascade: bool,
147
148        /// Cascade tree fanout (children per node). Only meaningful with
149        /// --cascade. 2 = binary tree (default); higher values trade
150        /// per-source bandwidth contention for fewer rounds.
151        #[arg(long = "cascade-fanout", default_value = "2")]
152        cascade_fanout: u32,
153
154        /// Kill any single activation command on a host after this many
155        /// seconds and mark that host failed; other hosts continue. By
156        /// default activation may run indefinitely.
157        #[arg(long = "activate-timeout", value_name = "SECONDS")]
158        activate_timeout: Option<u64>,
159    },
160
161    /// Probe builder health.
162    Health,
163
164    /// Show current system versions on targets.
165    Status {
166        /// Target nodes (bracket notation; @group references).
167        #[arg(short = 'w', long = "on")]
168        on: Option<String>,
169
170        /// Target nodes by tag.
171        #[arg(short = 'g', long = "tag")]
172        tag: Vec<String>,
173    },
174
175    /// Inspect and verify cascade event streams (the former cascade-viz).
176    Cascade {
177        #[command(subcommand)]
178        command: CascadeCommand,
179    },
180}
181
182/// `cast cascade` subcommand routing.
183///
184/// [`CascadeCommands`] lives in the published library crate, so its
185/// variant set is frozen API: cargo-semver-checks treats a new variant
186/// as a major break for downstream `match` arms. Growth happens here
187/// instead — this enum is private to the binary, so adding a variant
188/// or a flag changes nothing outside the workspace. `live` is
189/// re-enumerated here (rather than flattened) so `--order` can ride on
190/// the private [`LiveOrderedArgs`]: the public [`LiveArgs`] is
191/// exhaustively constructible downstream, so a new field there is
192/// equally major. `strategies` is pure discovery: no fleet, no trace
193/// file, one registry read.
194#[derive(Subcommand)]
195enum CascadeCommand {
196    /// Replay a JSONL trace file and render
197    Tree(TreeArgs),
198
199    /// Verify a trace relayed peer-to-peer: tree shape, depth, and rounds
200    /// must come from a relay, not a host push. Prints the topology summary
201    /// as JSON on success; exits non-zero with a specific diagnostic on
202    /// failure.
203    Verify(VerifyArgs),
204
205    /// Run a fresh scenario and render
206    Live {
207        #[command(flatten)]
208        live: LiveOrderedArgs,
209    },
210
211    /// List every cascade strategy with its aliases.
212    Strategies,
213}
214
215/// `cast cascade live` arguments: the public [`LiveArgs`] + [`RenderArgs`]
216/// exactly as [`CascadeCommands::Live`] flattens them, plus the private
217/// `--order` flag. Carrying the flag HERE keeps the public args structs'
218/// field sets frozen — the semver gate rejects a field on either.
219#[derive(clap::Args)]
220struct LiveOrderedArgs {
221    #[command(flatten)]
222    live: LiveArgs,
223
224    /// Fanout traversal order: which source serves which target within
225    /// a round. `default` keeps the historical id-order pairing; `bfs`
226    /// favours sources nearest the root; `dfs` extends the deepest
227    /// branch first. Parameterizes the log2 fanout pairing — other
228    /// strategies reject it. See the table with
229    /// `cast cascade strategies`.
230    #[arg(long = "order", default_value = "default")]
231    order: String,
232
233    #[command(flatten)]
234    render: RenderArgs,
235}
236
237pub fn run() {
238    let args = Args::parse();
239    let out = CliOutput::from_args(&args.output);
240    let exec = Arc::new(ProcessExecutor::new());
241
242    let result = match args.command {
243        // Cascade streams are self-contained: replay / verify / live never
244        // touch the fleet, so this arm runs before fleet discovery.
245        Commands::Cascade { command } => match command {
246            CascadeCommand::Tree(args) => cascade::dispatch(CascadeCommands::Tree(args)),
247            CascadeCommand::Verify(args) => cascade::dispatch(CascadeCommands::Verify(args)),
248            CascadeCommand::Live { live } => {
249                consortium_nix::cascade::parse_fanout_order(&live.order)
250                    .map_err(anyhow::Error::new)
251                    .and_then(|order| cascade::run_live_with_order(&live.live, &live.render, order))
252            }
253            // Generated from the registry — the same table parse_strategy
254            // and the unknown-name errors read — so it cannot drift.
255            CascadeCommand::Strategies => {
256                for spec in consortium_nix::cascade_strategies::STRATEGIES {
257                    if spec.aliases.is_empty() {
258                        println!("{}", spec.canonical);
259                    } else {
260                        println!("{} (aliases: {})", spec.canonical, spec.aliases.join(", "));
261                    }
262                }
263                // The fanout's orthogonal dimension: traversal orders,
264                // generated from the same table parse_fanout_order and
265                // the unknown-order errors read.
266                println!();
267                println!("traversal orders (--order; parameterize the log2 fanout pairing):");
268                for spec in consortium_nix::cascade::FANOUT_ORDERS {
269                    let aliases = if spec.aliases.is_empty() {
270                        String::new()
271                    } else {
272                        format!(" (aliases: {})", spec.aliases.join(", "))
273                    };
274                    println!("{}{} — {}", spec.canonical, aliases, spec.description);
275                }
276                Ok(())
277            }
278        },
279        command => {
280            let request = FleetRequest {
281                config: args.config.as_deref(),
282                flake: args.flake.as_deref(),
283                user: args.user.as_deref(),
284                cwd: Path::new("."),
285            };
286            let (config, source) = match load_fleet(&request, &*exec) {
287                Ok(loaded) => loaded,
288                Err(e) => {
289                    out.error(format!("{}", e));
290                    eprintln!(
291                        "hint: pass --config FILE, or run inside a flake (or pass --flake REF) \
292                         that has a `fleet` output or darwinConfigurations / nixosConfigurations"
293                    );
294                    process::exit(1);
295                }
296            };
297            if args.output.verbose > 0 {
298                eprintln!("fleet: {} ({} node(s))", source, config.nodes.len());
299            }
300
301            let nix_args =
302                nix_args_from_flags(&args.nix_args, &args.darwin_nix_args, &args.nixos_nix_args);
303
304            match command {
305                Commands::Eval { on, tag } => cmd_eval(&config, on.as_deref(), &tag),
306                Commands::Build {
307                    on,
308                    tag,
309                    builders,
310                    fanout,
311                } => cmd_deploy(
312                    exec,
313                    &config,
314                    on.as_deref(),
315                    &tag,
316                    "build",
317                    builders,
318                    fanout,
319                    false,
320                    2,
321                    None,
322                    nix_args,
323                ),
324                Commands::Deploy {
325                    on,
326                    tag,
327                    action,
328                    builders,
329                    fanout,
330                    cascade,
331                    cascade_fanout,
332                    activate_timeout,
333                } => cmd_deploy(
334                    exec,
335                    &config,
336                    on.as_deref(),
337                    &tag,
338                    &action,
339                    builders,
340                    fanout,
341                    cascade,
342                    cascade_fanout,
343                    activate_timeout.map(Duration::from_secs),
344                    nix_args,
345                ),
346                Commands::Health => cmd_health(&config),
347                Commands::Status { on, tag } => cmd_status(&config, on.as_deref(), &tag),
348                // Handled before fleet discovery; unreachable here.
349                Commands::Cascade { .. } => unreachable!(),
350            }
351        }
352    };
353
354    if let Err(e) = result {
355        eprintln!("error: {}", e);
356        process::exit(1);
357    }
358}
359
360/// Inputs deciding where the fleet comes from.
361struct FleetRequest<'a> {
362    /// `--config FILE`.
363    config: Option<&'a Path>,
364    /// `--flake REF`.
365    flake: Option<&'a str>,
366    /// `--user USER`.
367    user: Option<&'a str>,
368    /// Directory holding the implicit `fleet.json`.
369    cwd: &'a Path,
370}
371
372/// Load the fleet by precedence: `--config`, `<cwd>/fleet.json`, then
373/// discovery from the flake (`fleet` output, else configuration names).
374/// Applies the `--flake` and `--user` overrides. Returns the config and a
375/// description of its source.
376fn load_fleet(
377    req: &FleetRequest<'_>,
378    exec: &dyn Executor,
379) -> anyhow::Result<(FleetConfig, String)> {
380    let default_user = req
381        .user
382        .map(str::to_string)
383        .or_else(|| std::env::var("USER").ok())
384        .unwrap_or_else(|| "root".to_string());
385
386    let (mut config, source) = if let Some(path) = req.config {
387        (FleetConfig::from_file(path)?, path.display().to_string())
388    } else {
389        let implicit = req.cwd.join("fleet.json");
390        if implicit.is_file() {
391            (
392                FleetConfig::from_file(&implicit)?,
393                implicit.display().to_string(),
394            )
395        } else {
396            let flake = req.flake.unwrap_or(".");
397            let (config, origin) = fleet_source::discover_fleet(exec, flake, &default_user)?;
398            (config, format!("{} of {}", origin, flake))
399        }
400    };
401
402    if let Some(flake) = req.flake {
403        config.flake_uri = flake.to_string();
404    }
405    if let Some(user) = req.user {
406        for node in config.nodes.values_mut() {
407            node.target_user = user.to_string();
408        }
409    }
410    Ok((config, source))
411}
412
413/// Whitespace-split the repeatable `--*-nix-args` flags into [`NixArgs`];
414/// `--nix-args` words precede the platform-specific ones.
415fn nix_args_from_flags(all: &[String], darwin: &[String], nixos: &[String]) -> NixArgs {
416    let words = |groups: &[&[String]]| -> Vec<String> {
417        groups
418            .iter()
419            .flat_map(|g| g.iter())
420            .flat_map(|s| s.split_whitespace())
421            .map(str::to_string)
422            .collect()
423    };
424    NixArgs {
425        darwin: words(&[all, darwin]),
426        nixos: words(&[all, nixos]),
427    }
428}
429
430/// Resolve target nodes from --on and --tag flags, falling back to all nodes.
431///
432/// `--on` tokens may be `@group` references; expanded names not found in
433/// the fleet fall back to their bare form (`host.local` → `host`).
434fn resolve_targets(
435    config: &FleetConfig,
436    on: Option<&str>,
437    tags: &[String],
438) -> anyhow::Result<Vec<String>> {
439    let mut targets = Vec::new();
440
441    if let Some(pattern) = on {
442        let ns = groups::expand_pattern(pattern, &groups::group_nodes)?;
443        for name in ns.iter() {
444            let bare = endpoint::bare_name(&name).to_string();
445            let resolved = if config.nodes.contains_key(&name) {
446                name
447            } else if config.nodes.contains_key(&bare) {
448                bare
449            } else {
450                name
451            };
452            if !targets.contains(&resolved) {
453                targets.push(resolved);
454            }
455        }
456    }
457
458    if !tags.is_empty() {
459        let tag_nodes = config.nodes_by_tags(tags);
460        for node in tag_nodes {
461            if !targets.contains(&node.name) {
462                targets.push(node.name.clone());
463            }
464        }
465    }
466
467    // Default to all nodes if nothing specified
468    if targets.is_empty() {
469        targets = config.node_names();
470    }
471
472    // Validate all targets exist in config
473    for name in &targets {
474        if !config.nodes.contains_key(name) {
475            anyhow::bail!(
476                "unknown node '{}' (available: {})",
477                name,
478                config.node_names().join(", ")
479            );
480        }
481    }
482
483    Ok(targets)
484}
485
486fn cmd_eval(config: &FleetConfig, on: Option<&str>, tags: &[String]) -> anyhow::Result<()> {
487    let targets = resolve_targets(config, on, tags)?;
488
489    println!("Evaluating {} host(s):", targets.len());
490    for name in &targets {
491        let node = &config.nodes[name];
492        println!(
493            "  {} → {} ({}@{}, tags: [{}])",
494            name,
495            node.system,
496            node.target_user,
497            node.target_host,
498            node.tags.join(", ")
499        );
500        if let Some(drv) = &node.drv_path {
501            println!("    drv: {}", drv);
502        }
503    }
504
505    Ok(())
506}
507
508/// Live endpoint resolution for a deploy: hosts that are this machine are
509/// activated locally; every other host's `target_host` is replaced by the
510/// first endpoint accepting ssh (candidates extended through `lookup`).
511/// Returns the local hosts and the hosts that could not be reached (with
512/// the error to report).
513fn resolve_endpoints(
514    exec: &dyn Executor,
515    lookup: endpoint::Lookup<'_>,
516    config: &mut FleetConfig,
517    targets: &[String],
518) -> (Vec<String>, Vec<(String, String)>) {
519    let local_hostname = endpoint::local_hostname(exec);
520    let mut local_hosts = Vec::new();
521    let mut remote: Vec<String> = Vec::new();
522    for name in targets {
523        match &local_hostname {
524            Some(local) if endpoint::is_local_node(name, local) => local_hosts.push(name.clone()),
525            _ => remote.push(name.clone()),
526        }
527    }
528
529    let resolver = EndpointResolver::new(exec, lookup);
530    let results: Vec<(String, consortium_nix::Result<String>)> = std::thread::scope(|scope| {
531        let handles: Vec<_> = remote
532            .iter()
533            .map(|name| {
534                let node = &config.nodes[name];
535                let resolver = &resolver;
536                scope.spawn(move || (name.clone(), resolver.resolve(node)))
537            })
538            .collect();
539        handles
540            .into_iter()
541            .map(|h| h.join().expect("endpoint resolution thread panicked"))
542            .collect()
543    });
544
545    let mut unreachable = Vec::new();
546    for (name, result) in results {
547        match result {
548            Ok(host) => {
549                config
550                    .nodes
551                    .get_mut(&name)
552                    .expect("validated target")
553                    .target_host = host
554            }
555            Err(e) => unreachable.push((name, e.to_string())),
556        }
557    }
558    (local_hosts, unreachable)
559}
560
561#[allow(clippy::too_many_arguments)]
562fn cmd_deploy(
563    exec: Arc<ProcessExecutor>,
564    config: &FleetConfig,
565    on: Option<&str>,
566    tags: &[String],
567    action_str: &str,
568    use_builders: bool,
569    fanout: usize,
570    cascade: bool,
571    cascade_fanout: u32,
572    activate_timeout: Option<Duration>,
573    nix_args: NixArgs,
574) -> anyhow::Result<()> {
575    let mut targets = resolve_targets(config, on, tags)?;
576    let action: DeployAction = action_str.parse().map_err(|_| {
577        anyhow::anyhow!(
578            "invalid action '{}' (try: switch, boot, test, dry-activate, build)",
579            action_str
580        )
581    })?;
582
583    let mut config = config.clone();
584    let mut options = DeployOptions::new().nix_args(nix_args);
585    if let Some(limit) = activate_timeout {
586        options = options.activate_timeout(limit);
587    }
588    let mut unreachable = Vec::new();
589    if action != DeployAction::Build {
590        println!("Resolving ssh endpoints for {} host(s)...", targets.len());
591        let (local_hosts, failed) =
592            resolve_endpoints(&*exec, &endpoint::system_lookup, &mut config, &targets);
593        unreachable = failed;
594        targets.retain(|t| !unreachable.iter().any(|(u, _)| u == t));
595        options = options.local_hosts(local_hosts);
596    }
597
598    println!(
599        "Deploying {} host(s) with action '{}'{}:",
600        targets.len(),
601        action,
602        if cascade {
603            format!(
604                " [cascade copy fanout={} — peer-to-peer fan-out]",
605                cascade_fanout
606            )
607        } else {
608            String::new()
609        }
610    );
611    for name in &targets {
612        let node = &config.nodes[name];
613        if options.is_local(name) {
614            println!("  {} → local (this machine)", name);
615        } else if action == DeployAction::Build {
616            println!("  {}", name);
617        } else {
618            println!("  {} → {}@{}", name, node.target_user, node.target_host);
619        }
620    }
621    for (name, err) in &unreachable {
622        eprintln!("  skipping {}: {}", name, err);
623    }
624
625    let report = if targets.is_empty() {
626        None
627    } else if cascade && action != DeployAction::Build {
628        // Determine seed addr — the host running cast IS the seed
629        // (closure was built locally, or fetched here). The display
630        // name is just for the live UI; NixCopyExecutor uses local
631        // `nix copy` regardless.
632        let seed_addr = std::env::var("USER")
633            .map(|u| format!("{}@localhost", u))
634            .unwrap_or_else(|_| "localhost".into());
635        Some(consortium_nix::deploy_with_cascade_options(
636            exec,
637            &config,
638            &targets,
639            action,
640            fanout,
641            use_builders,
642            cascade_fanout,
643            &seed_addr,
644            None, // event sink — deferred until LiveTreeRenderer wiring
645            &options,
646        )?)
647    } else {
648        Some(consortium_nix::deploy_with_options(
649            exec,
650            &config,
651            &targets,
652            action,
653            fanout,
654            use_builders,
655            &options,
656        )?)
657    };
658
659    println!();
660    let mut failed = unreachable.len();
661    if let Some(report) = report {
662        failed += report.failure_count();
663        if report.is_success() {
664            println!("Deployment successful!");
665            println!("  Built: {}", report.built.len());
666            if action != DeployAction::Build {
667                println!("  Copied: {}", report.copied.len());
668                println!("  Activated: {}", report.activated.len());
669            }
670        } else {
671            println!(
672                "Deployment completed with {} failure(s):",
673                report.failure_count()
674            );
675            for (name, err) in &report.eval_failures {
676                eprintln!("  eval failed on {}: {}", name, err);
677            }
678            for (name, err) in &report.build_failures {
679                eprintln!("  build failed on {}: {}", name, err);
680            }
681            for (name, err) in &report.copy_failures {
682                eprintln!("  copy failed to {}: {}", name, err);
683            }
684            for (name, err) in &report.activation_failures {
685                eprintln!("  activation failed on {}: {}", name, err);
686            }
687        }
688    }
689    if !unreachable.is_empty() {
690        println!("Unreachable ({} host(s)):", unreachable.len());
691        for (name, err) in &unreachable {
692            eprintln!("  {}: {}", name, err);
693        }
694    }
695    if failed > 0 {
696        process::exit(1);
697    }
698
699    Ok(())
700}
701
702fn cmd_health(config: &FleetConfig) -> anyhow::Result<()> {
703    if config.builders.is_empty() {
704        println!("No builders configured.");
705        return Ok(());
706    }
707
708    println!("Probing {} builder(s):", config.builders.len());
709
710    let statuses = health::check_builders(config);
711    let healthy_count = statuses.iter().filter(|s| s.healthy).count();
712
713    for status in &statuses {
714        let icon = if status.healthy { "ok" } else { "FAIL" };
715        let latency = status
716            .latency_ms
717            .map(|ms| format!(" ({}ms)", ms))
718            .unwrap_or_default();
719        let error = status
720            .error
721            .as_ref()
722            .map(|e| format!(" - {}", e))
723            .unwrap_or_default();
724
725        println!(
726            "  [{}] {}@{}{}{} (jobs:{}, speed:{}x)",
727            icon,
728            status.builder.user,
729            status.builder.host,
730            latency,
731            error,
732            status.builder.max_jobs,
733            status.builder.speed_factor,
734        );
735    }
736
737    println!("\n{}/{} builders healthy", healthy_count, statuses.len());
738
739    Ok(())
740}
741
742fn cmd_status(config: &FleetConfig, on: Option<&str>, tags: &[String]) -> anyhow::Result<()> {
743    let targets = resolve_targets(config, on, tags)?;
744
745    println!("Querying {} host(s):", targets.len());
746
747    for name in &targets {
748        let node = &config.nodes[name];
749        match consortium_nix::eval::query_current_system(
750            &ProcessExecutor::new(),
751            &node.target_host,
752            &node.target_user,
753        ) {
754            Ok(Some(path)) => println!("  {} → {}", name, path),
755            Ok(None) => println!("  {} → (unknown)", name),
756            Err(e) => println!("  {} → error: {}", name, e),
757        }
758    }
759
760    Ok(())
761}
762
763#[cfg(test)]
764mod tests {
765    use super::*;
766    use consortium_integration::exec::{ExecOutput, ScriptedExecutor};
767    use consortium_nix::ProfileType;
768
769    const FLEET_JSON: &str = r#"{"nodes":{"filehost":{"name":"filehost","targetHost":"192.0.2.1",
770        "targetUser":"root","targetPort":null,"system":"x86_64-linux","profileType":"nixos",
771        "buildOnTarget":false,"tags":[]}},"builders":{},"flakeUri":"."}"#;
772
773    const MISSING: &str = "error: flake 'git+file:///cfg' does not provide attribute 'fleet'";
774
775    /// A flake without a `fleet` output but with one darwin and one nixos
776    /// configuration.
777    fn flake_exec() -> ScriptedExecutor {
778        ScriptedExecutor::new()
779            .on("#fleet --apply", ExecOutput::new(1, "", MISSING))
780            .on("#darwinConfigurations", ExecOutput::ok(r#"["mac01"]"#))
781            .on("#nixosConfigurations", ExecOutput::ok(r#"["box01"]"#))
782    }
783
784    #[test]
785    fn explicit_config_beats_implicit_fleet_json_and_flake() {
786        let dir = tempfile::tempdir().unwrap();
787        std::fs::write(dir.path().join("fleet.json"), FLEET_JSON).unwrap();
788        let explicit = dir.path().join("other.json");
789        std::fs::write(&explicit, FLEET_JSON.replace("filehost", "explicit")).unwrap();
790        let exec = flake_exec();
791
792        let req = FleetRequest {
793            config: Some(&explicit),
794            flake: None,
795            user: None,
796            cwd: dir.path(),
797        };
798        let (config, source) = load_fleet(&req, &exec).unwrap();
799        assert_eq!(config.node_names(), vec!["explicit"]);
800        assert_eq!(source, explicit.display().to_string());
801        assert_eq!(exec.invocation_count(), 0, "no nix when a file is given");
802    }
803
804    #[test]
805    fn implicit_fleet_json_in_cwd_beats_flake_discovery() {
806        let dir = tempfile::tempdir().unwrap();
807        std::fs::write(dir.path().join("fleet.json"), FLEET_JSON).unwrap();
808        let exec = flake_exec();
809
810        let req = FleetRequest {
811            config: None,
812            flake: Some("github:me/cfg"),
813            user: None,
814            cwd: dir.path(),
815        };
816        let (config, _) = load_fleet(&req, &exec).unwrap();
817        assert_eq!(config.node_names(), vec!["filehost"]);
818        // --flake still overrides the file's flakeUri.
819        assert_eq!(config.flake_uri, "github:me/cfg");
820        assert_eq!(exec.invocation_count(), 0);
821    }
822
823    #[test]
824    fn without_any_file_the_fleet_is_derived_from_flake_configurations() {
825        let dir = tempfile::tempdir().unwrap();
826        let exec = flake_exec();
827
828        let req = FleetRequest {
829            config: None,
830            flake: Some("github:me/cfg"),
831            user: Some("olive"),
832            cwd: dir.path(),
833        };
834        let (config, source) = load_fleet(&req, &exec).unwrap();
835        assert_eq!(config.node_names(), vec!["box01", "mac01"]);
836        assert_eq!(config.nodes["mac01"].profile_type, ProfileType::NixDarwin);
837        assert_eq!(config.nodes["mac01"].target_user, "olive");
838        assert_eq!(config.flake_uri, "github:me/cfg");
839        assert!(source.contains("nixosConfigurations"), "{source}");
840        exec.assert_invoked_containing(
841            "nix eval --json github:me/cfg#nixosConfigurations --apply builtins.attrNames",
842        );
843    }
844
845    #[test]
846    fn user_flag_overrides_every_node() {
847        let dir = tempfile::tempdir().unwrap();
848        std::fs::write(dir.path().join("fleet.json"), FLEET_JSON).unwrap();
849        let req = FleetRequest {
850            config: None,
851            flake: None,
852            user: Some("deploy"),
853            cwd: dir.path(),
854        };
855        let (config, _) = load_fleet(&req, &ScriptedExecutor::new()).unwrap();
856        assert_eq!(config.nodes["filehost"].target_user, "deploy");
857    }
858
859    #[test]
860    fn nix_args_flags_split_words_and_layer_platforms() {
861        let args = nix_args_from_flags(
862            &["--option builders ''".to_string()],
863            &[
864                "--override-input foo path:./stub".to_string(),
865                "-L".to_string(),
866            ],
867            &[],
868        );
869        assert_eq!(
870            args.darwin,
871            vec![
872                "--option",
873                "builders",
874                "''",
875                "--override-input",
876                "foo",
877                "path:./stub",
878                "-L"
879            ]
880        );
881        assert_eq!(args.nixos, vec!["--option", "builders", "''"]);
882    }
883
884    #[test]
885    fn resolve_targets_maps_group_entries_to_bare_node_names() {
886        let config = FleetConfig::from_json(FLEET_JSON).unwrap();
887        let targets = resolve_targets(&config, Some("filehost.local"), &[]).unwrap();
888        assert_eq!(targets, vec!["filehost"]);
889        let err = resolve_targets(&config, Some("ghost.local"), &[]).unwrap_err();
890        assert!(
891            err.to_string().contains("unknown node 'ghost.local'"),
892            "{err}"
893        );
894    }
895
896    /// Regression for nixos-config's retired cast-on script: a comma list was
897    /// kept as ONE target whose flake attr was cut at the first dot, so every
898    /// host got the first host's system. Each listed host must resolve to its
899    /// own node.
900    #[test]
901    fn resolve_targets_splits_comma_lists_into_distinct_nodes() {
902        let mut config = FleetConfig::from_json(FLEET_JSON).unwrap();
903        let mut second = config.nodes["filehost"].clone();
904        second.name = "gpuhost".into();
905        second.target_host = "gpuhost".into();
906        config.nodes.insert("gpuhost".into(), second);
907        let targets = resolve_targets(&config, Some("filehost.local,gpuhost.local"), &[]).unwrap();
908        assert_eq!(targets, vec!["filehost", "gpuhost"]);
909    }
910
911    #[test]
912    fn resolve_endpoints_splits_local_and_rewrites_remote_target_host() {
913        let mut config = FleetConfig::from_json(FLEET_JSON).unwrap();
914        // Add a node whose name is this machine.
915        let mut me = config.nodes["filehost"].clone();
916        me.name = "Workstation".into();
917        me.target_host = "Workstation".into();
918        config.nodes.insert("Workstation".into(), me);
919        let mut dead = config.nodes["filehost"].clone();
920        dead.name = "dead".into();
921        dead.target_host = "198.51.100.7".into();
922        config.nodes.insert("dead".into(), dead);
923
924        let exec = ScriptedExecutor::new()
925            .on("hostname", ExecOutput::ok("workstation.lan\n"))
926            .on("198.51.100.7", ExecOutput::new(255, "", "refused"))
927            .on("dead.local", ExecOutput::new(255, "", "refused"))
928            .on(" dead 'true'", ExecOutput::new(255, "", "refused"))
929            .on("192.0.2.1", ExecOutput::new(255, "", "stale"))
930            .on("filehost.local", ExecOutput::ok(""));
931        let targets = vec![
932            "filehost".to_string(),
933            "Workstation".to_string(),
934            "dead".to_string(),
935        ];
936        let (local, unreachable) =
937            resolve_endpoints(&exec, &|_: &str| vec![], &mut config, &targets);
938
939        assert_eq!(local, vec!["Workstation"]);
940        assert_eq!(config.nodes["filehost"].target_host, "filehost.local");
941        assert_eq!(unreachable.len(), 1);
942        assert_eq!(unreachable[0].0, "dead");
943        assert!(
944            unreachable[0]
945                .1
946                .contains("tried: 198.51.100.7, dead.local, dead"),
947            "{}",
948            unreachable[0].1
949        );
950        // The local host was never probed over ssh.
951        exec.assert_not_invoked_containing("Workstation");
952    }
953}