From f68f1f6023d5d2956a59738b7cc93bc584082426 Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Tue, 4 Aug 2026 23:03:52 -0400 Subject: [PATCH] drop test+activate, launch acitvations with systemd-run and poll success --- Cargo.lock | 109 +++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 1 + README.md | 2 - src/cli.rs | 6 --- src/main.rs | 7 +--- src/nix.rs | 119 ++++++++++++++++++++++++++++++++++++++++++++++------ src/op.rs | 42 +++---------------- 7 files changed, 223 insertions(+), 63 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e301723..d60e890 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -85,14 +85,27 @@ dependencies = [ "serde", "serde_json", "tokio", + "uuid", ] +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "bytes" version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + [[package]] name = "clap" version = "4.6.5" @@ -291,6 +304,17 @@ dependencies = [ "slab", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + [[package]] name = "heck" version = "0.5.0" @@ -345,6 +369,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "libc" version = "0.2.189" @@ -374,6 +409,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "once_cell_polyfill" version = "1.70.2" @@ -419,6 +460,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "regex" version = "1.13.1" @@ -448,6 +495,12 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "serde" version = "1.0.229" @@ -593,12 +646,68 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index bff0e98..d716626 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,3 +23,4 @@ anyhow = "1" futures = "0.3" log = "0.4" env_logger = "0.11" +uuid = { version = "1", features = ["v4"] } diff --git a/README.md b/README.md index 6685264..0f80d0b 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,6 @@ blzrd switch --flake - `switch`: Activate the new configuration and make it the boot default. - `boot`: Set the new configuration as the boot default without activating. -- `test`: Activate the new configuration without changing the boot default (NixOS). -- `activate`: (Darwin) Run the activation script only. - `list`: List nodes declared in the flake without deploying anything. ### Options diff --git a/src/cli.rs b/src/cli.rs index 44c8cd3..d1a94a9 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -36,10 +36,6 @@ pub enum Command { Switch(CommonArgs), /// Set the new configuration as the boot default without activating it. Boot(CommonArgs), - /// Activate the new configuration without changing the boot default. - Test(CommonArgs), - /// (darwin) Run the activation script only. - Activate(CommonArgs), /// List nodes declared in the flake without deploying anything. List, } @@ -51,8 +47,6 @@ impl Command { match self { Command::Switch(a) => Some((Operation::Switch, a)), Command::Boot(a) => Some((Operation::Boot, a)), - Command::Test(a) => Some((Operation::Test, a)), - Command::Activate(a) => Some((Operation::Activate, a)), Command::List => None, } } diff --git a/src/main.rs b/src/main.rs index 2e0c400..3250d36 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,7 +31,7 @@ async fn main() -> anyhow::Result<()> { print_nodes(&jobs); return Ok(()); } - cmd @ (Command::Switch(_) | Command::Boot(_) | Command::Test(_) | Command::Activate(_)) => { + cmd @ (Command::Switch(_) | Command::Boot(_)) => { let (op, common) = cmd.into_deploy().expect("deploy command"); run_deploy(op, common, jobs, start).await?; } @@ -92,10 +92,7 @@ async fn run_deploy( let jobs = filter_jobs(jobs, &common)?; print_nodes(&jobs); - let warnings = op::validate(&jobs, op)?; - for warning in &warnings { - log::warn!("{warning}"); - } + op::validate(&jobs, op)?; log::info!("Operation {} is valid for all nodes", op); diff --git a/src/nix.rs b/src/nix.rs index d2ce43c..4d85b0c 100644 --- a/src/nix.rs +++ b/src/nix.rs @@ -1,7 +1,10 @@ use std::collections::HashMap; use std::path::PathBuf; +use std::time::Duration; use anyhow::{Context, Result}; +use tokio::time::{sleep, Instant}; +use uuid::Uuid; use crate::models::{BuildResult, DebugInfo, JobSpec, NixEvalJobsResult, SystemType}; use crate::op::Operation; @@ -210,16 +213,88 @@ pub async fn build_closure(spec: &JobSpec, build_host: &str) -> Result<(String, Ok((out, debug)) } +/// Poll a remote `blzrd-activate-*` transient systemd unit until it reaches a +/// terminal state, then return. Exponential backoff 5 -> 60 seconds, 5-minute deadline. +async fn poll_activation(target: &str, unit: &str) -> Result<()> { + let mut sleep_val = Duration::from_secs(5); + let deadline = Instant::now() + Duration::from_secs(300); + + loop { + if Instant::now() >= deadline { + anyhow::bail!("activation on {target}: timed out waiting for {unit} unit"); + } + + let args: Vec<&str> = vec![ + "-o", + "ConnectTimeout=5", + "-o", + "BatchMode=yes", + target, + "systemctl", + "show", + unit, + ]; + let result = run("ssh", &args).await.ok(); + + let mut sub_state: Option = None; + let mut exec_status: Option = None; + if let Some((out, debug_info)) = result { + let text = String::from_utf8_lossy(&out); + log::debug!( + "{unit} on {target}:\n{}stderr: {}", + text.trim(), + debug_info.std_err.trim() + ); + for line in text.lines() { + if let Some(v) = line.strip_prefix("SubState=") { + sub_state = Some(v.trim().to_string()); + } else if let Some(v) = line.strip_prefix("ExecMainStatus=") { + exec_status = v.trim().parse::().ok(); + } + } + } + + // With --remain-after-exit, the unit stays active but transitions to + // SubState=exited once the main process terminates. + match (sub_state.as_deref(), exec_status.unwrap_or(0)) { + (Some("exited"), 0) => break, + (Some("exited"), code) => { + anyhow::bail!( + "activation on {target}: switch-to-configuration exited with code {code}" + ); + } + (Some("failed"), _) => { + anyhow::bail!( + "activation on {target}: unit failed (ExecMainStatus={})", + exec_status.unwrap_or(-1) + ); + } + _ => { + sleep(sleep_val).await; + sleep_val = (sleep_val * 2).min(Duration::from_secs(60)); + } + } + + if sleep_val > Duration::from_secs(60) { + anyhow::bail!("activation on {target}: gave up after backoff cap exceeded"); + } + } + + Ok(()) +} + pub async fn deploy_closure(spec: &JobSpec, out_path: &str, op: Operation) -> Result { let target = spec.target(); let path = out_path.to_string(); + let unit = format!("blzrd-activate-{}", Uuid::new_v4().simple()); + let mut cmds: Vec> = Vec::new(); let sys = spec.system; match (sys, op) { - (SystemType::Darwin, Operation::Switch | Operation::Test) => { + (SystemType::Darwin, Operation::Switch) => { cmds.push(vec![ "ssh".into(), target.clone(), @@ -240,17 +315,14 @@ pub async fn deploy_closure(spec: &JobSpec, out_path: &str, op: Operation) -> Re ]); } - (SystemType::Darwin, Operation::Activate) => { - cmds.push(vec![ - "ssh".into(), - target.clone(), - "PATH=/run/current-system/sw/bin:$PATH".into(), - "sudo".into(), - format!("{path}/activate"), - ]); + (SystemType::Darwin, Operation::Boot) => { + anyhow::bail!( + "job {}: 'boot' is not a valid darwin operation", + spec.hostname + ); } - (SystemType::Nixos, Operation::Switch | Operation::Boot) => { + (SystemType::Nixos, Operation::Switch) => { cmds.push(vec![ "ssh".into(), target.clone(), @@ -265,21 +337,36 @@ pub async fn deploy_closure(spec: &JobSpec, out_path: &str, op: Operation) -> Re "ssh".into(), target.clone(), "sudo".into(), + "systemd-run".into(), + "--unit".into(), + unit.clone(), + "--remain-after-exit".into(), + "--no-block".into(), + "--".into(), format!("{path}/bin/switch-to-configuration"), op.to_string(), ]); } - (SystemType::Nixos, Operation::Test) => { + (SystemType::Nixos, Operation::Boot) => { + cmds.push(vec![ + "ssh".into(), + target.clone(), + "sudo".into(), + "nix-env".into(), + "-p".into(), + "/nix/var/nix/profiles/system".into(), + "--set".into(), + path.clone(), + ]); cmds.push(vec![ "ssh".into(), target.clone(), "sudo".into(), format!("{path}/bin/switch-to-configuration"), - "test".into(), + op.to_string(), ]); } - _ => {} } // 1. Copy the closure to the target. @@ -304,5 +391,11 @@ pub async fn deploy_closure(spec: &JobSpec, out_path: &str, op: Operation) -> Re .with_context(|| format!("activation on {target}"))?; } + if matches!((sys, op), (SystemType::Nixos, Operation::Switch)) { + poll_activation(&target, &unit) + .await + .with_context(|| format!("activation on {target}"))?; + } + Ok(debug) } diff --git a/src/op.rs b/src/op.rs index 02434a1..c184745 100644 --- a/src/op.rs +++ b/src/op.rs @@ -7,8 +7,6 @@ use crate::models::{JobSpec, SystemType}; pub enum Operation { Switch, Boot, - Test, - Activate, } impl fmt::Display for Operation { @@ -16,36 +14,22 @@ impl fmt::Display for Operation { match self { Operation::Switch => write!(f, "switch"), Operation::Boot => write!(f, "boot"), - Operation::Test => write!(f, "test"), - Operation::Activate => write!(f, "activate"), } } } -pub fn validate(jobs: &HashMap, op: Operation) -> anyhow::Result> { - let mut warnings = Vec::new(); - +pub fn validate(jobs: &HashMap, op: Operation) -> anyhow::Result<()> { for (name, spec) in jobs { match (spec.system, op) { - (SystemType::Darwin, Operation::Switch | Operation::Activate | Operation::Test) => { - if op == Operation::Test { - warnings.push( - "Nix-darwin does not support 'test' operation, using 'switch' instead." - .to_string(), - ); - } - } + (SystemType::Darwin, Operation::Switch) => {} (SystemType::Darwin, Operation::Boot) => { anyhow::bail!("job {name}: 'boot' is not a valid darwin operation"); } - (SystemType::Nixos, Operation::Boot | Operation::Switch | Operation::Test) => {} - (SystemType::Nixos, Operation::Activate) => { - anyhow::bail!("job {name}: 'activate' is not a valid NixOS operation"); - } + (SystemType::Nixos, Operation::Boot | Operation::Switch) => {} } } - Ok(warnings) + Ok(()) } #[cfg(test)] @@ -68,15 +52,7 @@ mod tests { fn validate_nixos_switch_ok() { let mut jobs = HashMap::new(); jobs.insert("nixos-host".into(), job(SystemType::Nixos)); - let warnings = validate(&jobs, Operation::Switch).unwrap(); - assert!(warnings.is_empty()); - } - - #[test] - fn validate_nixos_activate_errors() { - let mut jobs = HashMap::new(); - jobs.insert("nixos-host".into(), job(SystemType::Nixos)); - assert!(validate(&jobs, Operation::Activate).is_err()); + validate(&jobs, Operation::Switch).unwrap(); } #[test] @@ -86,14 +62,6 @@ mod tests { assert!(validate(&jobs, Operation::Boot).is_err()); } - #[test] - fn validate_darwin_test_warns_but_ok() { - let mut jobs = HashMap::new(); - jobs.insert("mac".into(), job(SystemType::Darwin)); - let warnings = validate(&jobs, Operation::Test).unwrap(); - assert_eq!(warnings.len(), 1); - } - #[test] fn target_formats_user_at_host() { let spec = job(SystemType::Nixos); -- 2.51.2