1use 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#[derive(Parser)]
39#[command(name = "cast", version, about)]
40pub struct Args {
41 #[arg(short, long)]
45 config: Option<PathBuf>,
46
47 #[arg(long)]
50 flake: Option<String>,
51
52 #[arg(long)]
55 user: Option<String>,
56
57 #[arg(long = "nix-args", value_name = "WORDS", allow_hyphen_values = true)]
60 nix_args: Vec<String>,
61
62 #[arg(
65 long = "darwin-nix-args",
66 value_name = "WORDS",
67 allow_hyphen_values = true
68 )]
69 darwin_nix_args: Vec<String>,
70
71 #[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 Eval {
91 #[arg(short = 'w', long = "on")]
93 on: Option<String>,
94
95 #[arg(short = 'g', long = "tag")]
97 tag: Vec<String>,
98 },
99
100 Build {
102 #[arg(short = 'w', long = "on")]
104 on: Option<String>,
105
106 #[arg(short = 'g', long = "tag")]
108 tag: Vec<String>,
109
110 #[arg(long)]
112 builders: bool,
113
114 #[arg(short = 'f', long = "fanout", default_value = "4")]
116 fanout: usize,
117 },
118
119 Deploy {
121 #[arg(short = 'w', long = "on")]
123 on: Option<String>,
124
125 #[arg(short = 'g', long = "tag")]
127 tag: Vec<String>,
128
129 #[arg(default_value = "switch")]
131 action: String,
132
133 #[arg(long)]
135 builders: bool,
136
137 #[arg(short = 'f', long = "fanout", default_value = "4")]
139 fanout: usize,
140
141 #[arg(long = "cascade")]
146 cascade: bool,
147
148 #[arg(long = "cascade-fanout", default_value = "2")]
152 cascade_fanout: u32,
153
154 #[arg(long = "activate-timeout", value_name = "SECONDS")]
158 activate_timeout: Option<u64>,
159 },
160
161 Health,
163
164 Status {
166 #[arg(short = 'w', long = "on")]
168 on: Option<String>,
169
170 #[arg(short = 'g', long = "tag")]
172 tag: Vec<String>,
173 },
174
175 Cascade {
177 #[command(subcommand)]
178 command: CascadeCommand,
179 },
180}
181
182#[derive(Subcommand)]
195enum CascadeCommand {
196 Tree(TreeArgs),
198
199 Verify(VerifyArgs),
204
205 Live {
207 #[command(flatten)]
208 live: LiveOrderedArgs,
209 },
210
211 Strategies,
213}
214
215#[derive(clap::Args)]
220struct LiveOrderedArgs {
221 #[command(flatten)]
222 live: LiveArgs,
223
224 #[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 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 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 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 Commands::Cascade { .. } => unreachable!(),
350 }
351 }
352 };
353
354 if let Err(e) = result {
355 eprintln!("error: {}", e);
356 process::exit(1);
357 }
358}
359
360struct FleetRequest<'a> {
362 config: Option<&'a Path>,
364 flake: Option<&'a str>,
366 user: Option<&'a str>,
368 cwd: &'a Path,
370}
371
372fn 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
413fn 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
430fn 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 if targets.is_empty() {
469 targets = config.node_names();
470 }
471
472 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
508fn 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 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, &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 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 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 #[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 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 exec.assert_not_invoked_containing("Workstation");
952 }
953}