From a172159f7eb3050af1a0c627e84bdd5abff37bd6 Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Sat, 15 Aug 2026 16:42:41 -0400 Subject: [PATCH] fix: make deployment output append-only --- Cargo.lock | 107 ----------------- Cargo.toml | 2 - src/main.rs | 29 ++--- src/nix.rs | 2 +- src/nix/evaluate.rs | 38 ++----- src/ui.rs | 272 ++++++++------------------------------------ src/workflow.rs | 30 ++--- 7 files changed, 82 insertions(+), 398 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2feb6ce..a70e07d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -87,10 +87,8 @@ dependencies = [ "clap", "env_logger", "futures", - "indicatif", "log", "openssh", - "owo-colors", "serde", "serde_json", "tokio", @@ -161,18 +159,6 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" -[[package]] -name = "console" -version = "0.16.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" -dependencies = [ - "encode_unicode", - "libc", - "unicode-width", - "windows-sys", -] - [[package]] name = "defmt" version = "1.1.1" @@ -204,12 +190,6 @@ dependencies = [ "thiserror", ] -[[package]] -name = "encode_unicode" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" - [[package]] name = "env_filter" version = "2.0.0" @@ -354,42 +334,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - -[[package]] -name = "indicatif" -version = "0.18.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" -dependencies = [ - "console", - "portable-atomic", - "unicode-width", - "unit-prefix", - "web-time", -] - -[[package]] -name = "is-terminal" -version = "0.4.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" -dependencies = [ - "hermit-abi", - "libc", - "windows-sys", -] - -[[package]] -name = "is_ci" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" - [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -510,16 +454,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "owo-colors" -version = "4.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" -dependencies = [ - "supports-color 2.1.0", - "supports-color 3.0.2", -] - [[package]] name = "pin-project-lite" version = "0.2.17" @@ -694,25 +628,6 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "supports-color" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6398cde53adc3c4557306a96ce67b302968513830a77a95b2b17305d9719a89" -dependencies = [ - "is-terminal", - "is_ci", -] - -[[package]] -name = "supports-color" -version = "3.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" -dependencies = [ - "is_ci", -] - [[package]] name = "syn" version = "2.0.119" @@ -801,18 +716,6 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - -[[package]] -name = "unit-prefix" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" - [[package]] name = "utf8parse" version = "0.2.2" @@ -881,16 +784,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 022893f..9a28764 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,9 +22,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" anyhow = "1" futures = "0.3" -indicatif = "0.18" log = "0.4" env_logger = "0.11" openssh = "0.11" -owo-colors = { version = "4", features = ["supports-colors"] } uuid = { version = "1", features = ["v4"] } diff --git a/src/main.rs b/src/main.rs index b9b03c8..11ca45d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,9 +7,6 @@ mod ssh; mod ui; mod workflow; -use crate::nix::EvaluationProgress; -use crate::ui::Ui; - #[tokio::main] async fn main() -> anyhow::Result<()> { let args = cli::parse_cli(); @@ -23,31 +20,29 @@ async fn main() -> anyhow::Result<()> { let ui = ui::Ui::new(); - let mut evaluation = ui.start_section("Evaluating"); - let eval_result = nix::eval_deployments(&args.flake, |progress| match progress { - EvaluationProgress::JobEvaluated { name } => { - ui.add_job(&mut evaluation, &name, &name); - ui.start_job(&evaluation, &name, &name); - } - EvaluationProgress::MetadataResolved { name } => { - ui.finish_job_success(Ui::job_progress(&evaluation, &name), &name); - } - }) - .await; + ui.start_section("Evaluating"); + let eval_result = nix::eval_deployments(&args.flake, |name| ui.finish_job_success(&name)).await; let jobs = match eval_result { Ok(result) => { - ui.finish_section_success(evaluation); + ui.finish_section_success(); result } Err(error) => { - ui.finish_section_failure(evaluation); + ui.finish_section_failure(); + ui.finalize_failure(); return Err(error); } }; let (op, common) = args.command.into_deploy(); - workflow::run_deploy(op, common, jobs, &ui).await?; + match workflow::run_deploy(op, common, jobs, &ui).await { + Ok(()) => ui.finalize_success(), + Err(error) => { + ui.finalize_failure(); + return Err(error); + } + } Ok(()) } diff --git a/src/nix.rs b/src/nix.rs index 219db86..4e62592 100644 --- a/src/nix.rs +++ b/src/nix.rs @@ -2,4 +2,4 @@ mod deploy; mod evaluate; pub use deploy::{build_closure, deploy_closure}; -pub use evaluate::{eval_deployments, EvaluationProgress}; +pub use evaluate::eval_deployments; diff --git a/src/nix/evaluate.rs b/src/nix/evaluate.rs index 8c03d2f..49a979a 100644 --- a/src/nix/evaluate.rs +++ b/src/nix/evaluate.rs @@ -10,33 +10,21 @@ use crate::process::{run_json, run_json_lines}; const NODE_METADATA_PROJECTION: &str = "node: { hostname = node.hostname or \"\"; user = node.user or \"\"; type = node.type or \"\"; }"; -#[derive(Debug, Clone)] -pub enum EvaluationProgress { - JobEvaluated { name: String }, - MetadataResolved { name: String }, -} - /// Evaluate the flake's `blzrd.nodes` and return enriched `JobSpec`s. -pub async fn eval_deployments( +pub async fn eval_deployments( cfg: &str, - mut on_progress: OnProgress, + mut on_job_completed: OnJobCompleted, ) -> Result> where - OnProgress: FnMut(EvaluationProgress), + OnJobCompleted: FnMut(String), { - let results = run_eval_jobs(cfg, &mut on_progress).await?; + let results = run_eval_jobs(cfg).await?; let basics = collect_basics(&results)?; - build_job_specs(cfg, &results, &basics, &mut on_progress).await + build_job_specs(cfg, &results, &basics, &mut on_job_completed).await } /// Run `nix-eval-jobs` on the flake's `blzrd.nodes`. -async fn run_eval_jobs( - cfg: &str, - on_progress: &mut OnProgress, -) -> Result> -where - OnProgress: FnMut(EvaluationProgress), -{ +async fn run_eval_jobs(cfg: &str) -> Result> { let flake_reference = format!("{cfg}#blzrd.nodes"); let gc_roots_dir = gc_roots_dir()?; @@ -49,15 +37,7 @@ where "--flake", &flake_reference, ], - |result: &NixEvalJobsResult| { - let name = result - .attr_path - .as_ref() - .and_then(|path| path.first()) - .cloned() - .unwrap_or_else(|| result.attr.clone()); - on_progress(EvaluationProgress::JobEvaluated { name }); - }, + |_: &NixEvalJobsResult| {}, ) .await } @@ -120,7 +100,7 @@ async fn build_job_specs( cfg: &str, results: &[NixEvalJobsResult], basics: &HashMap, - on_progress: &mut impl FnMut(EvaluationProgress), + on_job_completed: &mut impl FnMut(String), ) -> Result> { let mut jobs = HashMap::with_capacity(basics.len()); let mut names: Vec<_> = basics.keys().cloned().collect(); @@ -149,7 +129,7 @@ async fn build_job_specs( drv_path, }, ); - on_progress(EvaluationProgress::MetadataResolved { name }); + on_job_completed(name); } Ok(jobs) diff --git a/src/ui.rs b/src/ui.rs index 9cf4b09..a7f5eef 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,259 +1,87 @@ -use std::collections::HashMap; use std::fmt::Display; -use std::io::{self, IsTerminal}; use std::sync::Mutex; -use std::time::Duration; - -use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; -use owo_colors::{OwoColorize, Stream}; pub struct Ui { - progress: MultiProgress, - is_interactive: bool, - retained_progress: Mutex>, - needs_section_separator: Mutex, -} - -pub struct SectionProgress { - name: String, - progress: ProgressBar, - jobs: HashMap, + active_phase: Mutex>, } impl Ui { pub fn new() -> Self { Self { - progress: MultiProgress::new(), - is_interactive: io::stderr().is_terminal(), - retained_progress: Mutex::new(Vec::new()), - needs_section_separator: Mutex::new(false), + active_phase: Mutex::new(None), } } pub fn print_warning(message: impl Display) { - eprintln!("{}", warning(format!("! {message}"))); - } - - pub fn start_section(&self, name: &str) -> SectionProgress { - self.add_section_separator(); - - let heading = phase_heading(name); - if !self.is_interactive { - eprintln!("⠋ {heading}"); - } - - SectionProgress { - name: heading.clone(), - progress: self.new_section_spinner(heading), - jobs: HashMap::new(), - } + eprintln!("! {message}"); } - pub fn add_job(&self, section: &mut SectionProgress, name: &str, display_name: &str) { - if section.jobs.contains_key(name) { - return; - } - - let progress = self.new_job_spinner(display_name); - section.jobs.insert(name.to_owned(), progress); - } - - pub fn start_job( - &self, - section: &SectionProgress, - name: &str, - display_name: &str, - ) -> ProgressBar { - let progress = section - .jobs - .get(name) - .expect("job progress must be registered") - .clone(); - progress.enable_steady_tick(Duration::from_millis(100)); - progress.tick(); - - if !self.is_interactive { - eprintln!(" ⠋ {display_name}"); - } - - progress - } - - pub fn finish_job_success(&self, progress: &ProgressBar, name: &str) { - self.finish_job(progress, format!(" {} {name}", success_marker())); - } - - pub fn finish_job_failure(&self, progress: &ProgressBar, name: &str, error: impl Display) { - self.finish_job(progress, format!(" {} {name}: {error}", failure_marker())); - } - - pub fn job_progress<'a>(section: &'a SectionProgress, name: &str) -> &'a ProgressBar { - section - .jobs - .get(name) - .expect("job progress must be registered") - } - - pub fn finish_section_success(&self, section: SectionProgress) { - self.finish_section(section, true); - } + pub fn start_section(&self, phase: &str) { + let mut active_phase = self + .active_phase + .lock() + .expect("active phase mutex is not poisoned"); + assert!(active_phase.is_none(), "a phase is already active"); - pub fn finish_section_failure(&self, section: SectionProgress) { - self.finish_section(section, false); + eprintln!("{}", phase.to_uppercase()); + *active_phase = Some(phase.to_owned()); } - pub fn print_summary(&self, message: &str) { - self.add_section_separator(); - let message = format!("{} {}", success_marker(), phase_heading(message)); - if self.is_interactive { - let progress = self.progress.add(ProgressBar::new_spinner()); - progress.set_style(completed_style()); - progress.finish_with_message(message); - self.retain_progress(progress); - } else { - eprintln!("{message}"); - } + pub fn finish_job_success(&self, job: &str) { + self.assert_phase_is_active(); + eprintln!(" ✓ {job}"); } - fn new_section_spinner(&self, message: impl Into) -> ProgressBar { - self.new_spinner(message, spinner_style(), true) + pub fn finish_job_failure(&self, job: &str, error: impl Display) { + self.assert_phase_is_active(); + eprintln!(" ✗ {job}: {error}"); } - fn new_job_spinner(&self, message: impl Into) -> ProgressBar { - self.new_spinner(message, job_spinner_style(), false) + pub fn finish_section_success(&self) { + self.take_active_phase(); + eprintln!(); } - fn new_spinner( - &self, - message: impl Into, - style: ProgressStyle, - should_animate: bool, - ) -> ProgressBar { - let progress = self.progress.add(ProgressBar::new_spinner()); - progress.set_style(style); - progress.set_message(message.into()); - progress.tick(); - if should_animate { - progress.enable_steady_tick(Duration::from_millis(100)); - } - progress + pub fn finish_section_failure(&self) { + let phase = self.take_active_phase(); + eprintln!("✗ {} FAILED\n", phase.to_uppercase()); } - fn finish_job(&self, progress: &ProgressBar, message: String) { - if self.is_interactive { - progress.set_style(completed_style()); - progress.finish_with_message(message); - } else { - eprintln!("{message}"); - progress.finish_and_clear(); - } + pub fn finalize_success(&self) { + self.assert_no_phase_is_active(); + eprintln!("✓ DEPLOYMENT COMPLETE"); } - fn finish_section(&self, section: SectionProgress, is_success: bool) { - let message = if is_success { - format!("{} {}", success_marker(), section.name) - } else { - format!("{} {}", failure_marker(), section.name) - }; - - if !is_success { - for progress in section.jobs.values() { - if !progress.is_finished() { - progress.finish_and_clear(); - } - } - } - - if self.is_interactive { - section.progress.set_style(completed_style()); - section.progress.finish_with_message(message); - } else { - eprintln!("{message}"); - section.progress.finish_and_clear(); - } - - self.retain(section); - *self - .needs_section_separator - .lock() - .expect("section separator mutex is not poisoned") = true; + pub fn finalize_failure(&self) { + self.assert_no_phase_is_active(); + eprintln!("✗ DEPLOYMENT FAILED"); } - fn add_section_separator(&self) { - let mut needs_separator = self - .needs_section_separator - .lock() - .expect("section separator mutex is not poisoned"); - if !*needs_separator { - return; - } - *needs_separator = false; - drop(needs_separator); - - if self.is_interactive { - let separator = self.progress.add(ProgressBar::new_spinner()); - separator.set_style(completed_style()); - separator.finish_with_message(" ".to_owned()); - self.retain_progress(separator); - } else { - eprintln!(); - } + fn assert_phase_is_active(&self) { + assert!( + self.active_phase + .lock() + .expect("active phase mutex is not poisoned") + .is_some(), + "a job can only change in an active phase" + ); } - fn retain(&self, section: SectionProgress) { - let mut retained = self - .retained_progress - .lock() - .expect("progress retention mutex is not poisoned"); - retained.push(section.progress); - retained.extend(section.jobs.into_values()); + fn assert_no_phase_is_active(&self) { + assert!( + self.active_phase + .lock() + .expect("active phase mutex is not poisoned") + .is_none(), + "a run cannot finish with an active phase" + ); } - fn retain_progress(&self, progress: ProgressBar) { - self.retained_progress + fn take_active_phase(&self) -> String { + self.active_phase .lock() - .expect("progress retention mutex is not poisoned") - .push(progress); + .expect("active phase mutex is not poisoned") + .take() + .expect("a phase must be active when it finishes") } } - -fn spinner_style() -> ProgressStyle { - ProgressStyle::with_template("{spinner} {msg}").expect("spinner template is valid") -} - -fn job_spinner_style() -> ProgressStyle { - ProgressStyle::with_template(" {spinner} {msg}").expect("spinner template is valid") -} - -fn completed_style() -> ProgressStyle { - ProgressStyle::with_template("{msg}").expect("completed template is valid") -} - -fn success_marker() -> String { - format!( - "{}", - "✓".if_supports_color(Stream::Stderr, |marker| marker.green()) - ) -} - -fn failure_marker() -> String { - format!( - "{}", - "✗".if_supports_color(Stream::Stderr, |marker| marker.red()) - ) -} - -fn phase_heading(name: &str) -> String { - let uppercase_name = name.to_uppercase(); - format!( - "{}", - uppercase_name.if_supports_color(Stream::Stderr, |heading| heading.bold()) - ) -} - -fn warning(message: impl Display) -> String { - format!( - "{}", - message.if_supports_color(Stream::Stderr, |message| message.yellow()) - ) -} diff --git a/src/workflow.rs b/src/workflow.rs index 50d3de7..e486236 100644 --- a/src/workflow.rs +++ b/src/workflow.rs @@ -85,30 +85,26 @@ async fn build_closures( host_key_policy: HostKeyPolicy, ui: &Ui, ) -> anyhow::Result> { - let mut progress = ui.start_section("Building"); + ui.start_section("Building"); let names = sorted_job_names(jobs); - for name in &names { - ui.add_job(&mut progress, name, name); - } let mut closures = HashMap::with_capacity(jobs.len()); for name in names { - let job_progress = ui.start_job(&progress, &name, &name); let result = nix::build_closure(&jobs[&name], &common.build_host, host_key_policy).await; match result { Ok(closure) => { - ui.finish_job_success(&job_progress, &name); + ui.finish_job_success(&name); closures.insert(name, closure); } Err(error) => { - ui.finish_job_failure(&job_progress, &name, &error); - ui.finish_section_failure(progress); + ui.finish_job_failure(&name, &error); + ui.finish_section_failure(); return Err(error).context(format!("building {name}")); } } } - ui.finish_section_success(progress); + ui.finish_section_success(); Ok(closures) } @@ -120,23 +116,18 @@ async fn deploy_closures( host_key_policy: HostKeyPolicy, ui: &Ui, ) -> anyhow::Result<()> { - let mut progress = ui.start_section("Deploying"); + ui.start_section("Deploying"); let jobs = sorted_jobs(jobs); - for (name, spec) in &jobs { - ui.add_job(&mut progress, name, &deployment_label(name, spec)); - } - let deployment_section = &progress; let results = stream::iter(jobs) .map(|(name, spec)| { let closure = closures[&name].clone(); async move { let label = deployment_label(&name, &spec); - let job_progress = ui.start_job(deployment_section, &name, &label); let result = nix::deploy_closure(&spec, &closure, operation, host_key_policy).await; match &result { - Ok(()) => ui.finish_job_success(&job_progress, &label), - Err(error) => ui.finish_job_failure(&job_progress, &label, error), + Ok(()) => ui.finish_job_success(&label), + Err(error) => ui.finish_job_failure(&label, error), } result } @@ -146,12 +137,11 @@ async fn deploy_closures( .await; if results.iter().any(Result::is_err) { - ui.finish_section_failure(progress); + ui.finish_section_failure(); anyhow::bail!("deployment failed"); } - ui.finish_section_success(progress); - ui.print_summary("Deployment complete"); + ui.finish_section_success(); Ok(()) } -- 2.51.2