ALPHA: wire is a tool to deploy nixos systems wire.althaea.zone/
at stable 12 kB view raw
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}