ALPHA: wire is a tool to deploy nixos systems
wire.althaea.zone/
1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Copyright 2024-2025 wire Contributors
3
4use clap::builder::PossibleValue;
5use clap::{Args, Parser, Subcommand, ValueEnum};
6use clap::{ValueHint, crate_version};
7use clap_complete::CompletionCandidate;
8use clap_complete::engine::ArgValueCompleter;
9use clap_num::number_range;
10use clap_verbosity_flag::InfoLevel;
11use tokio::runtime::Handle;
12use wire_core::SubCommandModifiers;
13use wire_core::commands::common::get_hive_node_names;
14use wire_core::hive::node::{Goal as HiveGoal, HandleUnreachable, Name, SwitchToConfigurationGoal};
15use wire_core::hive::{Hive, get_hive_location};
16
17use std::io::IsTerminal;
18use std::{
19 fmt::{self, Display, Formatter},
20 sync::Arc,
21};
22
23#[allow(clippy::struct_excessive_bools)]
24#[derive(Parser)]
25#[command(
26 name = "wire",
27 bin_name = "wire",
28 about = "a tool to deploy nixos systems",
29 version = format!("{}\nDebug: Hive::SCHEMA_VERSION {}", crate_version!(), Hive::SCHEMA_VERSION)
30)]
31pub struct Cli {
32 #[command(subcommand)]
33 pub command: Commands,
34
35 #[command(flatten)]
36 pub verbose: clap_verbosity_flag::Verbosity<InfoLevel>,
37
38 /// Path or flake reference
39 #[arg(long, global = true, default_value = std::env::current_dir().unwrap().into_os_string(), visible_alias("flake"))]
40 pub path: String,
41
42 /// Hide progress bars.
43 ///
44 /// Defaults to true if stdin does not refer to a tty (unix pipelines, in CI).
45 #[arg(long, global = true, default_value_t = !std::io::stdin().is_terminal())]
46 pub no_progress: bool,
47
48 /// Never accept user input.
49 ///
50 /// Defaults to true if stdin does not refer to a tty (unix pipelines, in CI).
51 #[arg(long, global = true, default_value_t = !std::io::stdin().is_terminal())]
52 pub non_interactive: bool,
53
54 /// Show trace logs
55 #[arg(long, global = true, default_value_t = false)]
56 pub show_trace: bool,
57
58 #[cfg(debug_assertions)]
59 #[arg(long, hide = true, global = true)]
60 pub markdown_help: bool,
61}
62
63#[derive(Clone, Debug)]
64pub enum ApplyTarget {
65 Node(Name),
66 Tag(String),
67 Stdin,
68}
69
70impl From<String> for ApplyTarget {
71 fn from(value: String) -> Self {
72 if value == "-" {
73 return ApplyTarget::Stdin;
74 }
75
76 if let Some(stripped) = value.strip_prefix("@") {
77 ApplyTarget::Tag(stripped.to_string())
78 } else {
79 ApplyTarget::Node(Name(Arc::from(value.as_str())))
80 }
81 }
82}
83
84impl Display for ApplyTarget {
85 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
86 match self {
87 ApplyTarget::Node(name) => name.fmt(f),
88 ApplyTarget::Tag(tag) => write!(f, "@{tag}"),
89 ApplyTarget::Stdin => write!(f, "#stdin"),
90 }
91 }
92}
93
94fn more_than_zero(s: &str) -> Result<usize, String> {
95 number_range(s, 1, usize::MAX)
96}
97
98fn parse_partitions(s: &str) -> Result<Partitions, String> {
99 let parts: [&str; 2] = s
100 .split('/')
101 .collect::<Vec<_>>()
102 .try_into()
103 .map_err(|_| "partition must contain exactly one '/'")?;
104
105 let (current, maximum) =
106 std::array::from_fn(|i| parts[i].parse::<usize>().map_err(|x| x.to_string())).into();
107 let (current, maximum) = (current?, maximum?);
108
109 if current > maximum {
110 return Err("current is more than total".to_string());
111 }
112
113 if current == 0 || maximum == 0 {
114 return Err("partition segments cannot be 0.".to_string());
115 }
116
117 Ok(Partitions { current, maximum })
118}
119
120#[derive(Clone)]
121pub enum HandleUnreachableArg {
122 Ignore,
123 FailNode,
124}
125
126impl Display for HandleUnreachableArg {
127 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
128 match self {
129 Self::Ignore => write!(f, "ignore"),
130 Self::FailNode => write!(f, "fail-node"),
131 }
132 }
133}
134
135impl clap::ValueEnum for HandleUnreachableArg {
136 fn value_variants<'a>() -> &'a [Self] {
137 &[Self::Ignore, Self::FailNode]
138 }
139
140 fn to_possible_value(&self) -> Option<clap::builder::PossibleValue> {
141 match self {
142 Self::Ignore => Some(PossibleValue::new("ignore")),
143 Self::FailNode => Some(PossibleValue::new("fail-node")),
144 }
145 }
146}
147
148impl From<HandleUnreachableArg> for HandleUnreachable {
149 fn from(value: HandleUnreachableArg) -> Self {
150 match value {
151 HandleUnreachableArg::Ignore => Self::Ignore,
152 HandleUnreachableArg::FailNode => Self::FailNode,
153 }
154 }
155}
156
157#[derive(Args)]
158pub struct CommonVerbArgs {
159 /// List of literal node names, a literal `-`, or `@` prefixed tags.
160 ///
161 /// `-` will read additional values from stdin, separated by whitespace.
162 /// Any `-` implies `--non-interactive`.
163 #[arg(short, long, value_name = "NODE | @TAG | `-`", num_args = 1.., add = ArgValueCompleter::new(node_names_completer), value_hint = ValueHint::Unknown)]
164 pub on: Vec<ApplyTarget>,
165
166 #[arg(short, long, default_value_t = 10, value_parser=more_than_zero)]
167 pub parallel: usize,
168}
169
170#[allow(clippy::struct_excessive_bools)]
171#[derive(Args)]
172pub struct ApplyArgs {
173 #[command(flatten)]
174 pub common: CommonVerbArgs,
175
176 #[arg(value_enum, default_value_t)]
177 pub goal: Goal,
178
179 /// Skip key uploads. noop when [GOAL] = Keys
180 #[arg(short, long, default_value_t = false)]
181 pub no_keys: bool,
182
183 /// Overrides deployment.buildOnTarget.
184 #[arg(short, long, value_name = "NODE")]
185 pub always_build_local: Vec<String>,
186
187 /// Reboot the nodes after activation
188 #[arg(short, long, default_value_t = false)]
189 pub reboot: bool,
190
191 /// Enable `--substitute-on-destination` in Nix subcommands.
192 #[arg(short, long, default_value_t = true)]
193 pub substitute_on_destination: bool,
194
195 /// How to handle an unreachable node in the ping step.
196 ///
197 /// This only effects the ping step.
198 /// wire will still fail the node if it becomes unreachable after activation
199 #[arg(long, default_value_t = HandleUnreachableArg::FailNode)]
200 pub handle_unreachable: HandleUnreachableArg,
201
202 /// Unconditionally accept SSH host keys [!!]
203 ///
204 /// Sets `StrictHostKeyChecking` to `no`.
205 /// Vulnerable to man-in-the-middle attacks, use with caution.
206 #[arg(long, default_value_t = false)]
207 pub ssh_accept_host: bool,
208}
209
210#[derive(Clone, Debug)]
211pub struct Partitions {
212 pub current: usize,
213 pub maximum: usize,
214}
215
216impl Default for Partitions {
217 fn default() -> Self {
218 Self {
219 current: 1,
220 maximum: 1,
221 }
222 }
223}
224
225#[derive(Args)]
226pub struct BuildArgs {
227 #[command(flatten)]
228 pub common: CommonVerbArgs,
229
230 /// Partition builds into buckets.
231 ///
232 /// In the format of `current/total`, where 1 <= current <= total.
233 #[arg(short = 'P', default_value="1/1", long, value_parser=parse_partitions)]
234 pub partition: Option<Partitions>,
235}
236
237#[derive(Subcommand)]
238pub enum Commands {
239 /// Deploy nodes
240 Apply(ApplyArgs),
241 /// Build nodes offline
242 ///
243 /// This is distinct from `wire apply build`, as it will not ping or push
244 /// the result, making it useful for CI.
245 ///
246 /// Additionally, you may partition the build jobs into buckets.
247 Build(BuildArgs),
248 /// Inspect hive
249 #[clap(visible_alias = "show")]
250 Inspect {
251 #[arg(value_enum, default_value_t)]
252 selection: Inspection,
253
254 /// Return in JSON format
255 #[arg(short, long, default_value_t = false)]
256 json: bool,
257 },
258}
259
260#[derive(Clone, Debug, Default, ValueEnum, Display)]
261pub enum Inspection {
262 /// Output all data wire has on the entire hive
263 #[default]
264 Full,
265 /// Only output a list of node names
266 Names,
267}
268
269#[derive(Clone, Debug, Default, ValueEnum, Display)]
270pub enum Goal {
271 /// Make the configuration the boot default and activate now
272 #[default]
273 Switch,
274 /// Build the configuration & push the results
275 Build,
276 /// Copy the system derivation to the remote hosts
277 Push,
278 /// Push deployment keys to the remote hosts
279 Keys,
280 /// Activate the system profile on next boot
281 Boot,
282 /// Activate the configuration, but don't make it the boot default
283 Test,
284 /// Show what would be done if this configuration were activated.
285 DryActivate,
286}
287
288impl TryFrom<Goal> for HiveGoal {
289 type Error = miette::Error;
290
291 fn try_from(value: Goal) -> Result<Self, Self::Error> {
292 match value {
293 Goal::Build => Ok(HiveGoal::Build),
294 Goal::Push => Ok(HiveGoal::Push),
295 Goal::Boot => Ok(HiveGoal::SwitchToConfiguration(
296 SwitchToConfigurationGoal::Boot,
297 )),
298 Goal::Switch => Ok(HiveGoal::SwitchToConfiguration(
299 SwitchToConfigurationGoal::Switch,
300 )),
301 Goal::Test => Ok(HiveGoal::SwitchToConfiguration(
302 SwitchToConfigurationGoal::Test,
303 )),
304 Goal::DryActivate => Ok(HiveGoal::SwitchToConfiguration(
305 SwitchToConfigurationGoal::DryActivate,
306 )),
307 Goal::Keys => Ok(HiveGoal::Keys),
308 }
309 }
310}
311
312pub trait ToSubCommandModifiers {
313 fn to_subcommand_modifiers(&self) -> SubCommandModifiers;
314}
315
316impl ToSubCommandModifiers for Cli {
317 fn to_subcommand_modifiers(&self) -> SubCommandModifiers {
318 SubCommandModifiers {
319 show_trace: self.show_trace,
320 non_interactive: self.non_interactive,
321 ssh_accept_host: match &self.command {
322 Commands::Apply(args) if args.ssh_accept_host => {
323 wire_core::StrictHostKeyChecking::No
324 }
325 _ => wire_core::StrictHostKeyChecking::default(),
326 },
327 }
328 }
329}
330
331fn node_names_completer(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
332 tokio::task::block_in_place(|| {
333 let handle = Handle::current();
334 let modifiers = SubCommandModifiers::default();
335 let mut completions = vec![];
336
337 if current.is_empty() || current == "-" {
338 completions.push(
339 CompletionCandidate::new("-").help(Some("Read stdin as --on arguments".into())),
340 );
341 }
342
343 let Ok(current_dir) = std::env::current_dir() else {
344 return completions;
345 };
346
347 let Ok(hive_location) = handle.block_on(get_hive_location(
348 current_dir.display().to_string(),
349 modifiers,
350 )) else {
351 return completions;
352 };
353
354 let Some(current) = current.to_str() else {
355 return completions;
356 };
357
358 if current.starts_with('@') {
359 return vec![];
360 }
361
362 if let Ok(names) =
363 handle.block_on(async { get_hive_node_names(&hive_location, modifiers).await })
364 {
365 for name in names {
366 if name.starts_with(current) {
367 completions.push(CompletionCandidate::new(name));
368 }
369 }
370 }
371
372 completions
373 })
374}
375
376#[cfg(test)]
377mod tests {
378 use std::assert_matches::assert_matches;
379
380 use crate::cli::{Partitions, parse_partitions};
381
382 #[test]
383 fn test_partition_parsing() {
384 assert_matches!(parse_partitions(""), Err(..));
385 assert_matches!(parse_partitions("/"), Err(..));
386 assert_matches!(parse_partitions(" / "), Err(..));
387 assert_matches!(parse_partitions("abc/"), Err(..));
388 assert_matches!(parse_partitions("abc"), Err(..));
389 assert_matches!(parse_partitions("1/1"), Ok(Partitions {
390 current,
391 maximum
392 }) if current == 1 && maximum == 1);
393 assert_matches!(parse_partitions("0/1"), Err(..));
394 assert_matches!(parse_partitions("-11/1"), Err(..));
395 assert_matches!(parse_partitions("100/99"), Err(..));
396 assert_matches!(parse_partitions("5/10"), Ok(Partitions { current, maximum }) if current == 5 && maximum == 10);
397 }
398}