ALPHA: wire is a tool to deploy nixos systems wire.althaea.zone/
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

add `wire build` command (#383)

authored by

marshmallow and committed by
GitHub
27e86c43 4a0be1b3

+632 -131
+7
CHANGELOG.md
··· 11 11 12 12 - Add a `--substitute-on-destination` argument. 13 13 - Add the `meta.nodeSpecialArgs` meta option. 14 + - Add `wire build`, a new command to build nodes offline. 15 + It is distinct from `wire apply build`, as it will not ping 16 + or push the result, making it useful for CI. 17 + 18 + ### Changed 19 + 20 + - Build store paths will be output to std out 14 21 15 22 ### Fixed 16 23
+208 -42
crates/cli/src/apply.rs
··· 4 4 use futures::{FutureExt, StreamExt}; 5 5 use itertools::{Either, Itertools}; 6 6 use miette::{Diagnostic, IntoDiagnostic, Result}; 7 + use std::any::Any; 7 8 use std::collections::HashSet; 8 9 use std::io::{Read, stderr}; 9 10 use std::sync::Arc; 10 11 use std::sync::atomic::AtomicBool; 11 12 use thiserror::Error; 12 - use tracing::{Span, error, info}; 13 - use wire_core::hive::node::{Context, GoalExecutor, Name, StepState, should_apply_locally}; 13 + use tracing::{error, info}; 14 + use wire_core::hive::node::{Context, GoalExecutor, Name, Node, Objective, StepState}; 14 15 use wire_core::hive::{Hive, HiveLocation}; 15 16 use wire_core::status::STATUS; 16 17 use wire_core::{SubCommandModifiers, errors::HiveLibError}; 17 18 18 - use crate::cli::{ApplyArgs, ApplyTarget}; 19 + use crate::cli::{ApplyTarget, CommonVerbArgs, Partitions}; 19 20 20 21 #[derive(Debug, Error, Diagnostic)] 21 22 #[error("node {} failed to apply", .0)] ··· 49 50 })) 50 51 } 51 52 52 - // #[instrument(skip_all, fields(goal = %args.goal, on = %args.on.iter().join(", ")))] 53 - pub async fn apply( 54 - hive: &mut Hive, 55 - should_shutdown: Arc<AtomicBool>, 56 - location: HiveLocation, 57 - args: ApplyArgs, 58 - mut modifiers: SubCommandModifiers, 59 - ) -> Result<()> { 60 - let header_span = Span::current(); 61 - let location = Arc::new(location); 62 - 63 - // Respect user's --always-build-local arg 64 - hive.force_always_local(args.always_build_local)?; 65 - 66 - let header_span_enter = header_span.enter(); 67 - 68 - let (tags, names) = args.on.iter().fold( 53 + fn resolve_targets( 54 + on: &[ApplyTarget], 55 + modifiers: &mut SubCommandModifiers, 56 + ) -> (HashSet<String>, HashSet<Name>) { 57 + on.iter().fold( 69 58 (HashSet::new(), HashSet::new()), 70 59 |(mut tags, mut names), target| { 71 60 match target { ··· 86 75 } 87 76 (tags, names) 88 77 }, 89 - ); 78 + ) 79 + } 80 + 81 + fn partition_arr<T>(arr: Vec<T>, partition: &Partitions) -> Vec<T> 82 + where 83 + T: Any + Clone, 84 + { 85 + if arr.is_empty() { 86 + return arr; 87 + } 88 + 89 + let items_per_chunk = arr.len().div_ceil(partition.maximum); 90 + 91 + arr.chunks(items_per_chunk) 92 + .nth(partition.current - 1) 93 + .unwrap_or(&[]) 94 + .to_vec() 95 + } 96 + 97 + pub async fn apply<F>( 98 + hive: &mut Hive, 99 + should_shutdown: Arc<AtomicBool>, 100 + location: HiveLocation, 101 + args: CommonVerbArgs, 102 + partition: Partitions, 103 + make_objective: F, 104 + mut modifiers: SubCommandModifiers, 105 + ) -> Result<()> 106 + where 107 + F: Fn(&Name, &Node) -> Objective, 108 + { 109 + let location = Arc::new(location); 110 + 111 + let (tags, names) = resolve_targets(&args.on, &mut modifiers); 90 112 91 - let selected_nodes: Vec<_> = hive 113 + let selected_names: Vec<_> = hive 92 114 .nodes 93 - .iter_mut() 115 + .iter() 94 116 .filter(|(name, node)| { 95 117 args.on.is_empty() 96 118 || names.contains(name) 97 119 || node.tags.iter().any(|tag| tags.contains(tag)) 98 120 }) 121 + .sorted_by_key(|(name, _)| *name) 122 + .map(|(name, _)| name.clone()) 99 123 .collect(); 100 124 101 - STATUS.lock().add_many( 102 - &selected_nodes 103 - .iter() 104 - .map(|(name, _)| *name) 105 - .collect::<Vec<_>>(), 106 - ); 125 + let num_selected = selected_names.len(); 126 + 127 + let partitioned_names = partition_arr(selected_names, &partition); 128 + 129 + if num_selected != partitioned_names.len() { 130 + info!( 131 + "Partitioning reduced selected number of nodes from {num_selected} to {}", 132 + partitioned_names.len() 133 + ); 134 + } 135 + 136 + STATUS 137 + .lock() 138 + .add_many(&partitioned_names.iter().collect::<Vec<_>>()); 107 139 108 - let mut set = selected_nodes 109 - .into_iter() 140 + let mut set = hive 141 + .nodes 142 + .iter_mut() 143 + .filter(|(name, _)| partitioned_names.contains(name)) 110 144 .map(|(name, node)| { 111 145 info!("Resolved {:?} to include {}", args.on, name); 112 146 113 - let should_apply_locally = should_apply_locally(node.allow_local_deployment, &name.0); 147 + let objective = make_objective(name, node); 114 148 115 149 let context = Context { 116 150 node, 117 151 name, 118 - goal: args.goal.clone().try_into().unwrap(), 152 + objective, 119 153 state: StepState::default(), 120 - no_keys: args.no_keys, 121 154 hive_location: location.clone(), 122 155 modifiers, 123 - reboot: args.reboot, 124 - substitute_on_destination: args.substitute_on_destination, 125 - should_apply_locally, 126 - handle_unreachable: args.handle_unreachable.clone().into(), 127 - should_shutdown: should_shutdown.clone(), 156 + should_quit: should_shutdown.clone(), 128 157 }; 129 158 130 159 GoalExecutor::new(context) ··· 154 183 successful 155 184 ); 156 185 } 157 - 158 - std::mem::drop(header_span_enter); 159 - std::mem::drop(header_span); 160 186 161 187 if !errors.is_empty() { 162 188 // clear the status bar if we are about to print error messages ··· 173 199 174 200 Ok(()) 175 201 } 202 + 203 + #[cfg(test)] 204 + mod tests { 205 + use super::*; 206 + 207 + #[test] 208 + #[allow(clippy::too_many_lines)] 209 + fn test_partitioning() { 210 + let arr = (1..=10).collect::<Vec<_>>(); 211 + assert_eq!(arr, partition_arr(arr.clone(), &Partitions::default())); 212 + 213 + assert_eq!( 214 + vec![1, 2, 3, 4, 5], 215 + partition_arr( 216 + arr.clone(), 217 + &Partitions { 218 + current: 1, 219 + maximum: 2 220 + } 221 + ) 222 + ); 223 + assert_eq!( 224 + vec![6, 7, 8, 9, 10], 225 + partition_arr( 226 + arr, 227 + &Partitions { 228 + current: 2, 229 + maximum: 2 230 + } 231 + ) 232 + ); 233 + 234 + // test odd number 235 + let arr = (1..10).collect::<Vec<_>>(); 236 + assert_eq!( 237 + arr.clone(), 238 + partition_arr(arr.clone(), &Partitions::default()) 239 + ); 240 + 241 + assert_eq!( 242 + vec![1, 2, 3, 4, 5], 243 + partition_arr( 244 + arr.clone(), 245 + &Partitions { 246 + current: 1, 247 + maximum: 2 248 + } 249 + ) 250 + ); 251 + assert_eq!( 252 + vec![6, 7, 8, 9], 253 + partition_arr( 254 + arr.clone(), 255 + &Partitions { 256 + current: 2, 257 + maximum: 2 258 + } 259 + ) 260 + ); 261 + 262 + // test large number of partitions 263 + let arr = (1..=10).collect::<Vec<_>>(); 264 + assert_eq!( 265 + arr.clone(), 266 + partition_arr(arr.clone(), &Partitions::default()) 267 + ); 268 + 269 + for i in 1..=10 { 270 + assert_eq!( 271 + vec![i], 272 + partition_arr( 273 + arr.clone(), 274 + &Partitions { 275 + current: i, 276 + maximum: 10 277 + } 278 + ) 279 + ); 280 + 281 + assert_eq!( 282 + vec![i], 283 + partition_arr( 284 + arr.clone(), 285 + &Partitions { 286 + current: i, 287 + maximum: 15 288 + } 289 + ) 290 + ); 291 + } 292 + 293 + // stretching thin with higher partitions will start to leave higher ones empty 294 + assert_eq!( 295 + Vec::<usize>::new(), 296 + partition_arr( 297 + arr, 298 + &Partitions { 299 + current: 11, 300 + maximum: 15 301 + } 302 + ) 303 + ); 304 + 305 + // test the above holds for a lot of numbers 306 + for i in 1..1000 { 307 + let arr: Vec<usize> = (0..i).collect(); 308 + let total = arr.len(); 309 + 310 + assert_eq!( 311 + arr.clone(), 312 + partition_arr(arr.clone(), &Partitions::default()), 313 + ); 314 + 315 + let buckets = 2; 316 + let chunk_size = total.div_ceil(buckets); 317 + let split_index = std::cmp::min(chunk_size, total); 318 + 319 + assert_eq!( 320 + &arr.clone()[..split_index], 321 + partition_arr( 322 + arr.clone(), 323 + &Partitions { 324 + current: 1, 325 + maximum: 2 326 + } 327 + ), 328 + ); 329 + assert_eq!( 330 + &arr.clone()[split_index..], 331 + partition_arr( 332 + arr.clone(), 333 + &Partitions { 334 + current: 2, 335 + maximum: 2 336 + } 337 + ), 338 + ); 339 + } 340 + } 341 + }
+95 -9
crates/cli/src/cli.rs
··· 95 95 number_range(s, 1, usize::MAX) 96 96 } 97 97 98 + fn 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 + 98 120 #[derive(Clone)] 99 121 pub enum HandleUnreachableArg { 100 122 Ignore, ··· 132 154 } 133 155 } 134 156 135 - #[allow(clippy::struct_excessive_bools)] 136 157 #[derive(Args)] 137 - pub struct ApplyArgs { 138 - #[arg(value_enum, default_value_t)] 139 - pub goal: Goal, 140 - 158 + pub struct CommonVerbArgs { 141 159 /// List of literal node names, a literal `-`, or `@` prefixed tags. 142 160 /// 143 161 /// `-` will read additional values from stdin, separated by whitespace. ··· 147 165 148 166 #[arg(short, long, default_value_t = 10, value_parser=more_than_zero)] 149 167 pub parallel: usize, 168 + } 169 + 170 + #[allow(clippy::struct_excessive_bools)] 171 + #[derive(Args)] 172 + pub struct ApplyArgs { 173 + #[command(flatten)] 174 + pub common: CommonVerbArgs, 175 + 176 + #[arg(value_enum, default_value_t)] 177 + pub goal: Goal, 150 178 151 179 /// Skip key uploads. noop when [GOAL] = Keys 152 180 #[arg(short, long, default_value_t = false)] ··· 179 207 pub ssh_accept_host: bool, 180 208 } 181 209 210 + #[derive(Clone, Debug)] 211 + pub struct Partitions { 212 + pub current: usize, 213 + pub maximum: usize, 214 + } 215 + 216 + impl Default for Partitions { 217 + fn default() -> Self { 218 + Self { 219 + current: 1, 220 + maximum: 1, 221 + } 222 + } 223 + } 224 + 225 + #[derive(Args)] 226 + pub 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 + 182 237 #[derive(Subcommand)] 183 238 pub enum Commands { 184 239 /// Deploy nodes 185 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), 186 248 /// Inspect hive 187 249 #[clap(visible_alias = "show")] 188 250 Inspect { ··· 209 271 /// Make the configuration the boot default and activate now 210 272 #[default] 211 273 Switch, 212 - /// Build the configuration but do nothing with it 274 + /// Build the configuration & push the results 213 275 Build, 214 - /// Copy system derivation to remote hosts 276 + /// Copy the system derivation to the remote hosts 215 277 Push, 216 - /// Push deployment keys to remote hosts 278 + /// Push deployment keys to the remote hosts 217 279 Keys, 218 - /// Activate system profile on next boot 280 + /// Activate the system profile on next boot 219 281 Boot, 220 282 /// Activate the configuration, but don't make it the boot default 221 283 Test, ··· 310 372 completions 311 373 }) 312 374 } 375 + 376 + #[cfg(test)] 377 + mod 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 + }
+47 -1
crates/cli/src/main.rs
··· 1 1 // SPDX-License-Identifier: AGPL-3.0-or-later 2 2 // Copyright 2024-2025 wire Contributors 3 3 4 + #![deny(clippy::pedantic)] 4 5 #![feature(sync_nonpoison)] 5 6 #![feature(nonpoison_mutex)] 7 + #![feature(assert_matches)] 6 8 7 9 use std::process::Command; 8 10 use std::sync::Arc; 9 11 use std::sync::atomic::AtomicBool; 10 12 11 13 use crate::cli::Cli; 14 + use crate::cli::Partitions; 12 15 use crate::cli::ToSubCommandModifiers; 13 16 use crate::sigint::handle_signals; 14 17 use crate::tracing_setup::setup_logging; ··· 25 28 use wire_core::commands::common::get_hive_node_names; 26 29 use wire_core::hive::Hive; 27 30 use wire_core::hive::get_hive_location; 31 + use wire_core::hive::node::ApplyObjective; 32 + use wire_core::hive::node::Objective; 33 + use wire_core::hive::node::should_apply_locally; 28 34 29 35 #[macro_use] 30 36 extern crate enum_display_derive; ··· 74 80 match args.command { 75 81 cli::Commands::Apply(apply_args) => { 76 82 let mut hive = Hive::new_from_path(&location, cache.clone(), modifiers).await?; 77 - apply::apply(&mut hive, should_shutdown, location, apply_args, modifiers).await?; 83 + let goal: wire_core::hive::node::Goal = apply_args.goal.clone().try_into().unwrap(); 84 + 85 + // Respect user's --always-build-local arg 86 + hive.force_always_local(apply_args.always_build_local)?; 87 + 88 + apply::apply( 89 + &mut hive, 90 + should_shutdown, 91 + location, 92 + apply_args.common, 93 + Partitions::default(), 94 + |name, node| { 95 + Objective::Apply(ApplyObjective { 96 + goal, 97 + no_keys: apply_args.no_keys, 98 + reboot: apply_args.reboot, 99 + substitute_on_destination: apply_args.substitute_on_destination, 100 + should_apply_locally: should_apply_locally( 101 + node.allow_local_deployment, 102 + &name.0, 103 + ), 104 + handle_unreachable: apply_args.handle_unreachable.clone().into(), 105 + }) 106 + }, 107 + modifiers, 108 + ) 109 + .await?; 110 + } 111 + cli::Commands::Build(build_args) => { 112 + let mut hive = Hive::new_from_path(&location, cache.clone(), modifiers).await?; 113 + 114 + apply::apply( 115 + &mut hive, 116 + should_shutdown, 117 + location, 118 + build_args.common, 119 + build_args.partition.unwrap_or_default(), 120 + |_name, _node| Objective::BuildLocally, 121 + modifiers, 122 + ) 123 + .await?; 78 124 } 79 125 cli::Commands::Inspect { json, selection } => println!("{}", { 80 126 match selection {
+7 -5
crates/core/src/commands/common.rs
··· 14 14 errors::{CommandError, HiveInitialisationError, HiveLibError}, 15 15 hive::{ 16 16 HiveLocation, 17 - node::{Context, Push}, 17 + node::{Context, Objective, Push}, 18 18 }, 19 19 }; 20 20 ··· 32 32 let mut command_string = CommandStringBuilder::nix(); 33 33 34 34 command_string.args(&["--extra-experimental-features", "nix-command", "copy"]); 35 - command_string.opt_arg( 36 - context.substitute_on_destination, 37 - "--substitute-on-destination", 38 - ); 35 + if let Objective::Apply(apply_objective) = context.objective { 36 + command_string.opt_arg( 37 + apply_objective.substitute_on_destination, 38 + "--substitute-on-destination", 39 + ); 40 + } 39 41 command_string.arg("--to"); 40 42 command_string.args(&[ 41 43 format!(
-1
crates/core/src/hive/mod.rs
··· 209 209 ) -> Result<HiveLocation, HiveLibError> { 210 210 let mut command_string = CommandStringBuilder::nix(); 211 211 command_string.args(&[ 212 - "nix", 213 212 "flake", 214 213 "prefetch", 215 214 "--extra-experimental-features",
+96 -37
crates/core/src/hive/node.rs
··· 29 29 use super::HiveLibError; 30 30 use super::steps::activate::SwitchToConfiguration; 31 31 32 - #[derive(Serialize, Deserialize, Clone, Debug, Hash, Eq, PartialEq, derive_more::Display)] 32 + #[derive( 33 + Serialize, Deserialize, Clone, Debug, Hash, Eq, PartialEq, PartialOrd, Ord, derive_more::Display, 34 + )] 33 35 pub struct Name(pub Arc<str>); 34 36 35 37 #[derive(Serialize, Deserialize, Clone, Debug, Hash, Eq, PartialEq)] ··· 111 113 node, 112 114 hive_location: Arc::new(hive_location), 113 115 modifiers: SubCommandModifiers::default(), 114 - no_keys: false, 116 + objective: Objective::Apply(ApplyObjective { 117 + goal: Goal::SwitchToConfiguration(SwitchToConfigurationGoal::Switch), 118 + no_keys: false, 119 + reboot: false, 120 + should_apply_locally: false, 121 + substitute_on_destination: false, 122 + handle_unreachable: HandleUnreachable::default(), 123 + }), 115 124 state: StepState::default(), 116 - goal: Goal::SwitchToConfiguration(SwitchToConfigurationGoal::Switch), 117 - reboot: false, 118 - should_apply_locally: false, 119 - substitute_on_destination: false, 120 - handle_unreachable: HandleUnreachable::default(), 121 - should_shutdown: Arc::new(AtomicBool::new(false)), 125 + should_quit: Arc::new(AtomicBool::new(false)), 122 126 } 123 127 } 124 128 } ··· 273 277 Keys, 274 278 } 275 279 280 + // TODO: Get rid of this allow and resolve it 281 + #[allow(clippy::struct_excessive_bools)] 282 + #[derive(Clone, Copy)] 283 + pub struct ApplyObjective { 284 + pub goal: Goal, 285 + pub no_keys: bool, 286 + pub reboot: bool, 287 + pub should_apply_locally: bool, 288 + pub substitute_on_destination: bool, 289 + pub handle_unreachable: HandleUnreachable, 290 + } 291 + 292 + #[derive(Clone, Copy)] 293 + pub enum Objective { 294 + Apply(ApplyObjective), 295 + BuildLocally, 296 + } 297 + 276 298 #[enum_dispatch] 277 299 pub(crate) trait ExecuteStep: Send + Sync + Display + std::fmt::Debug { 278 300 async fn execute(&self, ctx: &mut Context<'_>) -> Result<(), HiveLibError>; ··· 282 304 283 305 // may include other options such as FailAll in the future 284 306 #[non_exhaustive] 285 - #[derive(Clone, Default)] 307 + #[derive(Clone, Copy, Default)] 286 308 pub enum HandleUnreachable { 287 309 Ignore, 288 310 #[default] ··· 297 319 pub key_agent_directory: Option<String>, 298 320 } 299 321 300 - // TODO: Get rid of this allow and resolve it 301 - #[allow(clippy::struct_excessive_bools)] 302 322 pub struct Context<'a> { 303 323 pub name: &'a Name, 304 324 pub node: &'a mut Node, 305 325 pub hive_location: Arc<HiveLocation>, 306 326 pub modifiers: SubCommandModifiers, 307 - pub no_keys: bool, 308 327 pub state: StepState, 309 - pub goal: Goal, 310 - pub reboot: bool, 311 - pub should_apply_locally: bool, 312 - pub substitute_on_destination: bool, 313 - pub handle_unreachable: HandleUnreachable, 314 - pub should_shutdown: Arc<AtomicBool>, 328 + pub should_quit: Arc<AtomicBool>, 329 + pub objective: Objective, 315 330 } 316 331 317 332 #[enum_dispatch(ExecuteStep)] ··· 352 367 /// returns Err if the application should shut down. 353 368 fn app_shutdown_guard(context: &Context) -> Result<(), HiveLibError> { 354 369 if context 355 - .should_shutdown 370 + .should_quit 356 371 .load(std::sync::atomic::Ordering::Relaxed) 357 372 { 358 373 return Err(HiveLibError::Sigint); ··· 382 397 Step::Keys(Keys { 383 398 filter: UploadKeyAt::PostActivation, 384 399 }), 385 - Step::CleanUp(CleanUp), 386 400 ], 387 401 context, 388 402 } ··· 427 441 .is_some() 428 442 ); 429 443 430 - if !matches!(self.context.goal, Goal::Keys) { 444 + let spawn_evaluator = match self.context.objective { 445 + Objective::Apply(apply_objective) => !matches!(apply_objective.goal, Goal::Keys), 446 + Objective::BuildLocally => true, 447 + }; 448 + 449 + if spawn_evaluator { 431 450 tokio::spawn( 432 451 GoalExecutor::evaluate_task( 433 452 tx, ··· 468 487 // discard error from cleanup 469 488 let _ = CleanUp.execute(&mut self.context).await; 470 489 471 - if matches!(step, Step::Ping(..)) 472 - && matches!(self.context.handle_unreachable, HandleUnreachable::Ignore) 490 + if let Objective::Apply(apply_objective) = self.context.objective 491 + && matches!(step, Step::Ping(..)) 492 + && matches!( 493 + apply_objective.handle_unreachable, 494 + HandleUnreachable::Ignore, 495 + ) 473 496 { 474 497 return Ok(()); 475 498 } ··· 564 587 filter: UploadKeyAt::PostActivation 565 588 } 566 589 .into(), 567 - CleanUp.into() 568 590 ] 569 591 ); 570 592 } ··· 576 598 let name = &Name(function_name!().into()); 577 599 let mut context = Context::create_test_context(location, name, &mut node); 578 600 579 - context.goal = Goal::Keys; 601 + let Objective::Apply(ref mut apply_objective) = context.objective else { 602 + unreachable!() 603 + }; 604 + 605 + apply_objective.goal = Goal::Keys; 580 606 581 607 let executor = GoalExecutor::new(context); 582 608 let steps = get_steps(executor); ··· 590 616 filter: UploadKeyAt::NoFilter 591 617 } 592 618 .into(), 593 - CleanUp.into() 594 619 ] 595 620 ); 596 621 } 597 622 598 623 #[tokio::test] 599 - async fn order_build_only() { 624 + async fn order_build() { 600 625 let location = location!(get_test_path!()); 601 626 let mut node = Node::default(); 602 627 let name = &Name(function_name!().into()); 603 628 let mut context = Context::create_test_context(location, name, &mut node); 604 629 605 - context.goal = Goal::Build; 630 + let Objective::Apply(ref mut apply_objective) = context.objective else { 631 + unreachable!() 632 + }; 633 + apply_objective.goal = Goal::Build; 606 634 607 635 let executor = GoalExecutor::new(context); 608 636 let steps = get_steps(executor); ··· 614 642 crate::hive::steps::evaluate::Evaluate.into(), 615 643 crate::hive::steps::build::Build.into(), 616 644 crate::hive::steps::push::PushBuildOutput.into(), 617 - CleanUp.into() 618 645 ] 619 646 ); 620 647 } ··· 626 653 let name = &Name(function_name!().into()); 627 654 let mut context = Context::create_test_context(location, name, &mut node); 628 655 629 - context.goal = Goal::Push; 656 + let Objective::Apply(ref mut apply_objective) = context.objective else { 657 + unreachable!() 658 + }; 659 + apply_objective.goal = Goal::Push; 630 660 631 661 let executor = GoalExecutor::new(context); 632 662 let steps = get_steps(executor); ··· 637 667 Ping.into(), 638 668 crate::hive::steps::evaluate::Evaluate.into(), 639 669 crate::hive::steps::push::PushEvaluatedOutput.into(), 640 - CleanUp.into() 641 670 ] 642 671 ); 643 672 } ··· 671 700 filter: UploadKeyAt::PostActivation 672 701 } 673 702 .into(), 674 - CleanUp.into() 675 703 ] 676 704 ); 677 705 } ··· 683 711 684 712 let name = &Name(function_name!().into()); 685 713 let mut context = Context::create_test_context(location, name, &mut node); 686 - context.no_keys = true; 714 + 715 + let Objective::Apply(ref mut apply_objective) = context.objective else { 716 + unreachable!() 717 + }; 718 + apply_objective.no_keys = true; 719 + 687 720 let executor = GoalExecutor::new(context); 688 721 let steps = get_steps(executor); 689 722 ··· 695 728 crate::hive::steps::build::Build.into(), 696 729 crate::hive::steps::push::PushBuildOutput.into(), 697 730 SwitchToConfiguration.into(), 698 - CleanUp.into() 699 731 ] 700 732 ); 701 733 } ··· 707 739 708 740 let name = &Name(function_name!().into()); 709 741 let mut context = Context::create_test_context(location, name, &mut node); 710 - context.no_keys = true; 711 - context.should_apply_locally = true; 742 + 743 + let Objective::Apply(ref mut apply_objective) = context.objective else { 744 + unreachable!() 745 + }; 746 + apply_objective.no_keys = true; 747 + apply_objective.should_apply_locally = true; 748 + 712 749 let executor = GoalExecutor::new(context); 713 750 let steps = get_steps(executor); 714 751 ··· 718 755 crate::hive::steps::evaluate::Evaluate.into(), 719 756 crate::hive::steps::build::Build.into(), 720 757 SwitchToConfiguration.into(), 758 + ] 759 + ); 760 + } 761 + 762 + #[tokio::test] 763 + async fn order_build_only() { 764 + let location = location!(get_test_path!()); 765 + let mut node = Node::default(); 766 + 767 + let name = &Name(function_name!().into()); 768 + let mut context = Context::create_test_context(location, name, &mut node); 769 + 770 + context.objective = Objective::BuildLocally; 771 + 772 + let executor = GoalExecutor::new(context); 773 + let steps = get_steps(executor); 774 + 775 + assert_eq!( 776 + steps, 777 + vec![ 778 + crate::hive::steps::evaluate::Evaluate.into(), 779 + crate::hive::steps::build::Build.into() 721 780 ] 722 781 ); 723 782 } ··· 870 929 let name = &Name(function_name!().into()); 871 930 let context = Context::create_test_context(location, name, &mut node); 872 931 context 873 - .should_shutdown 932 + .should_quit 874 933 .store(true, std::sync::atomic::Ordering::Relaxed); 875 934 let executor = GoalExecutor::new(context); 876 935 let status = executor.execute().await;
+21 -8
crates/core/src/hive/steps/activate.rs
··· 9 9 HiveLibError, 10 10 commands::{CommandArguments, WireCommandChip, builder::CommandStringBuilder, run_command}, 11 11 errors::{ActivationError, NetworkError}, 12 - hive::node::{Context, ExecuteStep, Goal, SwitchToConfigurationGoal}, 12 + hive::node::{Context, ExecuteStep, Goal, Objective, SwitchToConfigurationGoal}, 13 13 }; 14 14 15 15 #[derive(Debug, PartialEq)] ··· 51 51 command_string.args(&["-p", "/nix/var/nix/profiles/system", "--set"]); 52 52 command_string.arg(built_path); 53 53 54 + let Objective::Apply(apply_objective) = ctx.objective else { 55 + unreachable!() 56 + }; 57 + 54 58 let child = run_command( 55 59 &CommandArguments::new(command_string, ctx.modifiers) 56 60 .mode(crate::commands::ChildOutputMode::Nix) 57 - .on_target(if ctx.should_apply_locally { 61 + .on_target(if apply_objective.should_apply_locally { 58 62 None 59 63 } else { 60 64 Some(&ctx.node.target) ··· 75 79 76 80 impl ExecuteStep for SwitchToConfiguration { 77 81 fn should_execute(&self, ctx: &Context) -> bool { 78 - matches!(ctx.goal, Goal::SwitchToConfiguration(..)) 82 + let Objective::Apply(apply_objective) = ctx.objective else { 83 + return false; 84 + }; 85 + 86 + matches!(apply_objective.goal, Goal::SwitchToConfiguration(..)) 79 87 } 80 88 89 + #[allow(clippy::too_many_lines)] 81 90 #[instrument(skip_all, name = "activate")] 82 91 async fn execute(&self, ctx: &mut Context<'_>) -> Result<(), HiveLibError> { 83 92 let built_path = ctx.state.build.as_ref().unwrap(); 84 93 85 - let Goal::SwitchToConfiguration(goal) = &ctx.goal else { 94 + let Objective::Apply(apply_objective) = ctx.objective else { 95 + unreachable!() 96 + }; 97 + 98 + let Goal::SwitchToConfiguration(goal) = &apply_objective.goal else { 86 99 unreachable!("Cannot reach as guarded by should_execute") 87 100 }; 88 101 ··· 108 121 109 122 let child = run_command( 110 123 &CommandArguments::new(command_string, ctx.modifiers) 111 - .on_target(if ctx.should_apply_locally { 124 + .on_target(if apply_objective.should_apply_locally { 112 125 None 113 126 } else { 114 127 Some(&ctx.node.target) ··· 122 135 123 136 match result { 124 137 Ok(_) => { 125 - if !ctx.reboot { 138 + if !apply_objective.reboot { 126 139 return Ok(()); 127 140 } 128 141 129 - if ctx.should_apply_locally { 142 + if apply_objective.should_apply_locally { 130 143 error!("Refusing to reboot local machine!"); 131 144 132 145 return Ok(()); ··· 176 189 // Bail if the command couldn't of broken the system 177 190 // and don't try to regain connection to localhost 178 191 if matches!(goal, SwitchToConfigurationGoal::DryActivate) 179 - || ctx.should_apply_locally 192 + || apply_objective.should_apply_locally 180 193 { 181 194 return Err(HiveLibError::ActivationError( 182 195 ActivationError::SwitchToConfigurationError(*goal, ctx.name.clone(), error),
+24 -9
crates/core/src/hive/steps/build.rs
··· 11 11 CommandArguments, Either, WireCommandChip, builder::CommandStringBuilder, 12 12 run_command_with_env, 13 13 }, 14 - hive::node::{Context, ExecuteStep, Goal}, 14 + hive::node::{Context, ExecuteStep, Goal, Objective}, 15 15 }; 16 16 17 17 #[derive(Debug, PartialEq)] ··· 25 25 26 26 impl ExecuteStep for Build { 27 27 fn should_execute(&self, ctx: &Context) -> bool { 28 - !matches!(ctx.goal, Goal::Keys | Goal::Push) 28 + match ctx.objective { 29 + Objective::Apply(apply_objective) => { 30 + !matches!(apply_objective.goal, Goal::Keys | Goal::Push) 31 + } 32 + Objective::BuildLocally => true, 33 + } 29 34 } 30 35 31 36 #[instrument(skip_all, name = "build")] ··· 46 51 let status = run_command_with_env( 47 52 &CommandArguments::new(command_string, ctx.modifiers) 48 53 // build remotely if asked for AND we arent applying locally 49 - // building remotely but applying locally does not logically 50 - // make any sense 51 - .on_target(if ctx.node.build_remotely && !ctx.should_apply_locally { 52 - Some(&ctx.node.target) 53 - } else { 54 - None 55 - }) 54 + // 55 + // (building remotely but applying locally does not logically 56 + // make any sense) 57 + .on_target( 58 + if ctx.node.build_remotely 59 + && let Objective::Apply(apply_objective) = ctx.objective 60 + && apply_objective.should_apply_locally 61 + { 62 + Some(&ctx.node.target) 63 + } else { 64 + None 65 + }, 66 + ) 56 67 .mode(crate::commands::ChildOutputMode::Nix) 57 68 .log_stdout(), 58 69 std::collections::HashMap::new(), ··· 70 81 }; 71 82 72 83 info!("Built output: {stdout:?}"); 84 + 85 + // print built path to stdout 86 + println!("{stdout}"); 87 + 73 88 ctx.state.build = Some(stdout); 74 89 75 90 Ok(())
+2 -2
crates/core/src/hive/steps/cleanup.rs
··· 18 18 } 19 19 20 20 impl ExecuteStep for CleanUp { 21 - fn should_execute(&self, ctx: &Context) -> bool { 22 - !ctx.should_apply_locally 21 + fn should_execute(&self, _ctx: &Context) -> bool { 22 + false 23 23 } 24 24 25 25 async fn execute(&self, _ctx: &mut Context<'_>) -> Result<(), HiveLibError> {
+5 -2
crates/core/src/hive/steps/evaluate.rs
··· 7 7 8 8 use crate::{ 9 9 HiveLibError, 10 - hive::node::{Context, ExecuteStep, Goal}, 10 + hive::node::{Context, ExecuteStep, Goal, Objective}, 11 11 }; 12 12 13 13 #[derive(Debug, PartialEq)] ··· 21 21 22 22 impl ExecuteStep for Evaluate { 23 23 fn should_execute(&self, ctx: &Context) -> bool { 24 - !matches!(ctx.goal, Goal::Keys) 24 + match ctx.objective { 25 + Objective::Apply(apply_objective) => !matches!(apply_objective.goal, Goal::Keys), 26 + Objective::BuildLocally => true, 27 + } 25 28 } 26 29 27 30 #[instrument(skip_all, name = "eval")]
+23 -7
crates/core/src/hive/steps/keys.rs
··· 31 31 use crate::commands::common::push; 32 32 use crate::commands::{CommandArguments, WireCommandChip, run_command}; 33 33 use crate::errors::KeyError; 34 - use crate::hive::node::{Context, ExecuteStep, Goal, Push, SwitchToConfigurationGoal}; 34 + use crate::hive::node::{Context, ExecuteStep, Goal, Objective, Push, SwitchToConfigurationGoal}; 35 35 36 36 #[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, Hash)] 37 37 #[serde(tag = "t", content = "c")] ··· 226 226 227 227 impl ExecuteStep for Keys { 228 228 fn should_execute(&self, ctx: &Context) -> bool { 229 - if ctx.no_keys { 229 + let Objective::Apply(apply_objective) = ctx.objective else { 230 + return false; 231 + }; 232 + 233 + if apply_objective.no_keys { 230 234 return false; 231 235 } 232 236 233 237 // should execute if no filter, and the goal is keys. 234 238 // otherwise, only execute if the goal is switch and non-nofilter 235 239 matches!( 236 - (&self.filter, &ctx.goal), 240 + (&self.filter, &apply_objective.goal), 237 241 (UploadKeyAt::NoFilter, Goal::Keys) 238 242 | ( 239 243 UploadKeyAt::PreActivation | UploadKeyAt::PostActivation, ··· 256 260 let command_string = 257 261 CommandStringBuilder::new(format!("{agent_directory}/bin/wire-key-agent")); 258 262 263 + let Objective::Apply(apply_objective) = ctx.objective else { 264 + unreachable!() 265 + }; 266 + 259 267 let mut child = run_command( 260 268 &CommandArguments::new(command_string, ctx.modifiers) 261 - .on_target(if ctx.should_apply_locally { 269 + .on_target(if apply_objective.should_apply_locally { 262 270 None 263 271 } else { 264 272 Some(&ctx.node.target) ··· 321 329 322 330 impl ExecuteStep for PushKeyAgent { 323 331 fn should_execute(&self, ctx: &Context) -> bool { 324 - if ctx.no_keys { 332 + let Objective::Apply(apply_objective) = ctx.objective else { 333 + return false; 334 + }; 335 + 336 + if apply_objective.no_keys { 325 337 return false; 326 338 } 327 339 328 340 matches!( 329 - &ctx.goal, 341 + &apply_objective.goal, 330 342 Goal::Keys | Goal::SwitchToConfiguration(SwitchToConfigurationGoal::Switch) 331 343 ) 332 344 } ··· 347 359 ), 348 360 }; 349 361 350 - if !ctx.should_apply_locally { 362 + let Objective::Apply(apply_objective) = ctx.objective else { 363 + unreachable!() 364 + }; 365 + 366 + if !apply_objective.should_apply_locally { 351 367 push(ctx, Push::Path(&agent_directory)).await?; 352 368 } 353 369
+6 -2
crates/core/src/hive/steps/ping.rs
··· 7 7 8 8 use crate::{ 9 9 HiveLibError, 10 - hive::node::{Context, ExecuteStep}, 10 + hive::node::{Context, ExecuteStep, Objective}, 11 11 }; 12 12 13 13 #[derive(Debug, PartialEq)] ··· 21 21 22 22 impl ExecuteStep for Ping { 23 23 fn should_execute(&self, ctx: &Context) -> bool { 24 - !ctx.should_apply_locally 24 + let Objective::Apply(apply_objective) = ctx.objective else { 25 + return false; 26 + }; 27 + 28 + !apply_objective.should_apply_locally 25 29 } 26 30 27 31 #[instrument(skip_all, name = "ping")]
+14 -6
crates/core/src/hive/steps/push.rs
··· 8 8 use crate::{ 9 9 HiveLibError, 10 10 commands::common::push, 11 - hive::node::{Context, ExecuteStep, Goal}, 11 + hive::node::{Context, ExecuteStep, Goal, Objective}, 12 12 }; 13 13 14 14 #[derive(Debug, PartialEq)] ··· 30 30 31 31 impl ExecuteStep for PushEvaluatedOutput { 32 32 fn should_execute(&self, ctx: &Context) -> bool { 33 - !matches!(ctx.goal, Goal::Keys) 34 - && !ctx.should_apply_locally 35 - && (ctx.node.build_remotely | matches!(ctx.goal, Goal::Push)) 33 + let Objective::Apply(apply_objective) = ctx.objective else { 34 + return false; 35 + }; 36 + 37 + !matches!(apply_objective.goal, Goal::Keys) 38 + && !apply_objective.should_apply_locally 39 + && (ctx.node.build_remotely | matches!(apply_objective.goal, Goal::Push)) 36 40 } 37 41 38 42 #[instrument(skip_all, name = "push_eval")] ··· 47 51 48 52 impl ExecuteStep for PushBuildOutput { 49 53 fn should_execute(&self, ctx: &Context) -> bool { 50 - if matches!(ctx.goal, Goal::Keys | Goal::Push) { 54 + let Objective::Apply(apply_objective) = ctx.objective else { 55 + return false; 56 + }; 57 + 58 + if matches!(apply_objective.goal, Goal::Keys | Goal::Push) { 51 59 // skip if we are not building 52 60 return false; 53 61 } ··· 57 65 return false; 58 66 } 59 67 60 - if ctx.should_apply_locally { 68 + if apply_objective.should_apply_locally { 61 69 // skip step if we are applying locally 62 70 return false; 63 71 }
+1
doc/.vitepress/config.ts
··· 119 119 }, 120 120 { text: "Apply your Config", link: "/guides/apply" }, 121 121 { text: "Target Nodes", link: "/guides/targeting" }, 122 + { text: "Build in CI", link: "/guides/build-in-ci" }, 122 123 { 123 124 text: "Features", 124 125 items: [
+36
doc/guides/build-in-ci.md
··· 1 + --- 2 + comment: true 3 + title: Build in CI 4 + --- 5 + 6 + # Build in CI 7 + 8 + ## The `wire build` command <Badge type="tip" text="^1.1.0" /> 9 + 10 + `wire build` builds nodes locally. It is distinct from 11 + `wire apply build`, as it will not ping or push the result, 12 + making it useful for CI. 13 + 14 + It accepts the same `--on` argument as `wire apply` does. 15 + 16 + ## Partitioning builds 17 + 18 + `wire build` accepts a `--partition` option inspired by 19 + [cargo-nextest](https://nexte.st/docs/ci-features/partitioning/), which splits 20 + selected nodes into buckets to be built separately. 21 + 22 + It accepts values in the format `--partition current/total`, where 1 ≤ current ≤ total. 23 + 24 + For example, these two commands will build the entire hive in two invocations: 25 + 26 + ```sh 27 + wire build --partition 1/2 28 + 29 + # later or synchronously: 30 + 31 + wire build --partition 2/2 32 + ``` 33 + 34 + ## Example: Build in Github Actions 35 + 36 + <<< @/snippets/guides/example-action.yml [.github/workflows/build.yml]
+40
doc/snippets/guides/example-action.yml
··· 1 + name: Build 2 + 3 + on: 4 + push: 5 + branches: [main] 6 + 7 + jobs: 8 + build-partitioned: 9 + name: Build Partitioned 10 + runs-on: ubuntu-latest 11 + permissions: {} 12 + strategy: 13 + matrix: 14 + # Break into 4 partitions 15 + partition: [1, 2, 3, 4] 16 + steps: 17 + - uses: actions/checkout@v6 18 + with: 19 + persist-credentials: false 20 + # This will likely be required if you have multiple architectures 21 + # in your hive. 22 + - name: Set up QEMU 23 + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 24 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 25 + with: 26 + nix_path: nixpkgs=channel:nixos-unstable 27 + extra_nix_config: | 28 + # Install binary cache as described in the install wire guide 29 + trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= cache.garnix.io:CTFPyKSLcx5RMJKfLo5EEPUObbA78b0YQ2DTCJXqr9g= 30 + substituters = https://cache.nixos.org/ https://cache.garnix.io 31 + 32 + # Again, include additional architectures if you have multiple 33 + # architectures in your hive 34 + extra-platforms = aarch64-linux i686-linux 35 + # Uses wire from your shell (as described in the install wire guide). 36 + - name: Build partition ${{ matrix.partition }} 37 + run: nix develop -Lvc wire \ 38 + build \ 39 + --parallel 1 \ 40 + --partition ${{ matrix.partition }}/4