From d55cf9ed80f860c874b6913a8a9d6b759373b908 Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 9 Nov 2026 10:31:27 +0100 Subject: [PATCH] swarmctl setup status shows progress at a glance --- docs/1-getting-started/setup.md | 2 +- docs/2-handbook/swarm/swarmctl-cli.md | 4 +- nix/vm-tests/full-setup.nix | 16 +- swarmctl/README.md | 16 +- swarmctl/src/main.rs | 4 +- swarmctl/src/setup.rs | 281 ++++++++++++++++++++++---- 6 files changed, 271 insertions(+), 52 deletions(-) diff --git a/docs/1-getting-started/setup.md b/docs/1-getting-started/setup.md index 528424c86..458c6a520 100644 --- a/docs/1-getting-started/setup.md +++ b/docs/1-getting-started/setup.md @@ -18,7 +18,7 @@ wait for you. The sections below are the same steps by hand. Run `swarmctl setup status` at any point to see how far you have got. It lists the hand steps below, numbered 1 to 10 in the order the sections run them, marks -each done, not done or unknown, and names the next one. It changes nothing. +each done, not done or unknown, shows a progress bar, and names the next one. It changes nothing. > **Not all-local?** Each step says which host it runs on. A split swarm > also has credentials you can't generate where they're read — each diff --git a/docs/2-handbook/swarm/swarmctl-cli.md b/docs/2-handbook/swarm/swarmctl-cli.md index 049c1fcdb..7e18e6fd8 100644 --- a/docs/2-handbook/swarm/swarmctl-cli.md +++ b/docs/2-handbook/swarm/swarmctl-cli.md @@ -658,7 +658,7 @@ For scripts, use `setup status` and the individual verbs. ###### **Subcommands:** -* `status` — Print each step of the first-run setup, in order, as `done`, `not done` or `unknown`, and what to do next +* `status` — Print the first-run setup as a progress report: a count and a bar, then each step as done, not done or unknown, and what to do next ###### **Options:** @@ -670,7 +670,7 @@ For scripts, use `setup status` and the individual verbs. ## `swarmctl setup status` -Print each step of the first-run setup, in order, as `done`, `not done` or `unknown`, and what to do next. +Print the first-run setup as a progress report: a count and a bar, then each step as done, not done or unknown, and what to do next. Read-only, and needs no token. The secret store's steps come from `bao status` and systemd, so they show only on the host running swarm-bao; the SSO account comes from authelia's users file, on its host; ruth and the other agents come from the swarm-controller. When a check can't run, its steps print `unknown` with the reason, and the rest still print. Steps 7 and 8, the browser login and the forge site admin, always print `unknown (not observable)`. diff --git a/nix/vm-tests/full-setup.nix b/nix/vm-tests/full-setup.nix index 6e7e90d87..ab0fca5a5 100644 --- a/nix/vm-tests/full-setup.nix +++ b/nix/vm-tests/full-setup.nix @@ -384,14 +384,14 @@ pkgs.testers.runNixOSTest ( # Ruth is declared paused and her `up` is a later step, so the report # ends on step 9 not done and the exit status is 1. reported = [ - " 1 secret store initialised: done", - " 2 secret store unsealed: done", - " 3 granter bootstrapped: done", - " 4 granting units active: done", - " 5 store unsealed after the granting units: done", - " 6 SSO account in the admins group: done", - " 9 ruth created and up: not done", - "10 another agent [optional]: done", + "✅ 1 secret store initialised", + "✅ 2 secret store unsealed", + "✅ 3 granter bootstrapped", + "✅ 4 granting units active", + "✅ 5 store unsealed after the granting units", + "✅ 6 SSO account in the admins group", + "👉 9 ruth created and up", + "✅ 10 another agent [optional]", ] for _ in range(60): lines = machine.execute("swarmctl setup status")[1].splitlines() diff --git a/swarmctl/README.md b/swarmctl/README.md index cc0a7c633..5055c3f0d 100644 --- a/swarmctl/README.md +++ b/swarmctl/README.md @@ -240,14 +240,22 @@ and the verbs. The flow is `src/interactive.rs`; the terminal and side effects a ```console # swarmctl setup status - 1 secret store initialised: done - 2 secret store unsealed: not done (the store is sealed) +🏗️ setup: 1/8 steps done █░░░░░░░░░ + +✅ 1 secret store initialised +👉 2 secret store unsealed the store is sealed + ↳ setup.md § 1: `bao operator unseal` ... + next: step 2, setup.md § 1: `bao operator unseal` ``` -One line per step of [`docs/1-getting-started/setup.md`](../docs/1-getting-started/setup.md), -`done`, `not done` or `unknown ()`, and a pointer to the first step left. +A header with the count and a bar, then one line per step of +[`docs/1-getting-started/setup.md`](../docs/1-getting-started/setup.md): `✅` done, +`⬜` not done, `❓` unknown with the reason. The first step left is marked `👉` with its hint, and +the last line is that hint, or `🎉 setup complete`. The count and bar cover the steps the host can +observe; failed checks show as `❓ N unknown` in the header. On a terminal, `checking …` +shows on stderr while the checks run, and colour is on unless `NO_COLOR` is set; piped output is plain. Read-only, and it needs no token. Where each step is read from: | steps | read from | diff --git a/swarmctl/src/main.rs b/swarmctl/src/main.rs index 2fab44691..f7f5297d6 100644 --- a/swarmctl/src/main.rs +++ b/swarmctl/src/main.rs @@ -295,8 +295,8 @@ struct SetupArgs { #[derive(Subcommand)] enum SetupVerb { - /// Print each step of the first-run setup, in order, as `done`, `not done` - /// or `unknown`, and what to do next. + /// Print the first-run setup as a progress report: a count and a bar, then + /// each step as done, not done or unknown, and what to do next. /// /// Read-only, and needs no token. The secret store's steps come from `bao /// status` and systemd, so they show only on the host running swarm-bao; the diff --git a/swarmctl/src/setup.rs b/swarmctl/src/setup.rs index 2170b2f19..23d252a00 100644 --- a/swarmctl/src/setup.rs +++ b/swarmctl/src/setup.rs @@ -314,30 +314,127 @@ pub(crate) fn complete(steps: &[Step]) -> bool { .all(|s| s.optional || matches!(s.status, Status::Done(_) | Status::Unobservable(_))) } -/// One line per step, then what to do next. -pub(crate) fn render(steps: &[Step]) -> String { +const BAR_CELLS: usize = 10; + +/// `done` of `total` as a bar of [`BAR_CELLS`] cells. Full only when `done` +/// equals `total`. +fn bar(done: usize, total: usize) -> String { + let filled = (done * BAR_CELLS).checked_div(total).unwrap_or(0); + format!("{}{}", "█".repeat(filled), "░".repeat(BAR_CELLS - filled)) +} + +/// SGR wrappers; plain text when colour is off. +struct Paint(bool); + +impl Paint { + fn wrap(&self, code: &str, text: &str) -> String { + if self.0 { + format!("\x1b[{code}m{text}\x1b[0m") + } else { + text.to_owned() + } + } + fn green(&self, text: &str) -> String { + self.wrap("32", text) + } + fn yellow(&self, text: &str) -> String { + self.wrap("33", text) + } + fn dim(&self, text: &str) -> String { + self.wrap("2", text) + } +} + +/// Whether stdout takes colour: a terminal, with `NO_COLOR` unset or empty. +fn color_enabled() -> bool { + use std::io::IsTerminal as _; + color_wanted( + std::io::stdout().is_terminal(), + std::env::var_os("NO_COLOR").as_deref(), + ) +} + +fn color_wanted(is_terminal: bool, no_color: Option<&std::ffi::OsStr>) -> bool { + is_terminal && no_color.is_none_or(|v| v.is_empty()) +} + +/// The step report: a header with the count and a bar, one line per step +/// (the first one to do marked, with its hint under it), then what to do +/// next. `color` adds ANSI colour; the emoji are always there. +pub(crate) fn render(steps: &[Step], color: bool) -> String { use std::fmt::Write as _; - let mut out = String::new(); - for step in steps { - let status = match &step.status { - Status::Done(d) if d.is_empty() => "done".to_owned(), - Status::Done(d) => format!("done ({d})"), - Status::NotDone(why) => format!("not done ({why})"), - Status::Unknown(why) => format!("unknown ({why})"), - Status::Unobservable(why) => format!("unknown (not observable: {why})"), - }; - let optional = if step.optional { " [optional]" } else { "" }; - let _ = writeln!( - out, - "{:>2} {}{optional}: {status}", - step.number, step.title - ); - } - if let Some(next) = steps + let paint = Paint(color); + let observable = steps .iter() - .find(|s| !s.optional && matches!(s.status, Status::NotDone(_))) - { + .filter(|s| !matches!(s.status, Status::Unobservable(_))) + .count(); + let done = steps + .iter() + .filter(|s| matches!(s.status, Status::Done(_))) + .count(); + let unknown = steps + .iter() + .filter(|s| matches!(s.status, Status::Unknown(_))) + .count(); + let next = steps + .iter() + .find(|s| !s.optional && matches!(s.status, Status::NotDone(_))); + + let mut out = format!( + "🏗️ setup: {done}/{observable} steps done {}", + bar(done, observable) + ); + if unknown > 0 { + let _ = write!(out, " ❓ {unknown} unknown"); + } + out.push_str("\n\n"); + + let titles: Vec = steps + .iter() + .map(|s| { + if s.optional { + format!("{} [optional]", s.title) + } else { + s.title.to_owned() + } + }) + .collect(); + let width = titles.iter().map(|t| t.chars().count()).max().unwrap_or(0); + for (step, title) in steps.iter().zip(&titles) { + let is_next = next.is_some_and(|n| n.number == step.number); + let (icon, note) = match &step.status { + Status::Done(d) => ("✅", d.clone()), + Status::NotDone(why) if is_next => ("👉", why.clone()), + Status::NotDone(why) => ("⬜", why.clone()), + Status::Unknown(why) => ("❓", format!("unknown ({why})")), + Status::Unobservable(why) => ("❓", format!("unknown (not observable: {why})")), + }; + let label = if note.is_empty() { + format!("{:>2} {title}", step.number) + } else { + format!("{:>2} {title: paint.green(&label), + Status::NotDone(_) => paint.yellow(&label), + _ => paint.dim(&label), + }; + let note = if note.is_empty() { + String::new() + } else { + format!(" {}", paint.dim(¬e)) + }; + let _ = writeln!(out, "{icon} {label}{note}"); + if is_next { + let _ = writeln!(out, " ↳ {}", step.hint); + } + } + out.push('\n'); + + if complete(steps) { + let _ = writeln!(out, "🎉 setup complete"); + } else if let Some(next) = next { let _ = writeln!(out, "next: step {}, {}", next.number, next.hint); } else if let Some(blind) = steps .iter() @@ -355,11 +452,24 @@ pub(crate) fn render(steps: &[Step]) -> String { /// Run `swarmctl setup status`. Returns whether the ceremony is [`complete`]. /// /// `target` and `users_file` arrive as the results of resolving them, so a -/// host that lacks either still reports the steps that need neither. +/// host that lacks either still reports the steps that need neither. On a +/// terminal, a `checking …` line on stderr names the running check +/// and is erased when the report prints; piped output carries only the report. pub(crate) fn status(target: &Result, users_file: &Result) -> bool { - let observed = observe(target, users_file); + use std::io::{IsTerminal as _, Write as _}; + + let live = std::io::stderr().is_terminal(); + let observed = observe_with(target, users_file, &mut |what| { + if live { + eprint!("\r\x1b[K⏳ checking {what}…"); + let _ = std::io::stderr().flush(); + } + }); + if live { + eprint!("\r\x1b[K"); + } let steps = classify(&observed); - print!("{}", render(&steps)); + print!("{}", render(&steps, color_enabled())); complete(&steps) } @@ -369,13 +479,27 @@ pub(crate) fn observe( target: &Result, users_file: &Result, ) -> Observed { + observe_with(target, users_file, &mut |_| {}) +} + +/// [`observe`], calling `progress` with a name before each check starts. +fn observe_with( + target: &Result, + users_file: &Result, + progress: &mut dyn FnMut(&str), +) -> Observed { + progress("the secret store"); let store = bao_status(); + progress("the granter"); let granter = granter(); + progress("the granting units"); let policy_units = policy_units(); + progress("the admin account"); let admin_user = users_file .as_ref() .map_err(unset_reason) .and_then(|path| has_admin_user(path).map_err(|e| format!("{e:#}"))); + progress("the agents"); let agents = target .as_ref() .map_err(unset_reason) @@ -779,7 +903,10 @@ mod tests { let mut o = finished(); o.agents = Ok(agents(vec![], None)); assert!(matches!(status_of(&o, 9), Status::NotDone(why) if why.contains("roster"))); - o.agents = Ok(agents(vec![row("ruth", Some("paused"))], Some(Ok(true)))); + o.agents = Ok(agents( + vec![row("ruth", Some("paused")), row("iris", Some("paused"))], + Some(Ok(true)), + )); assert!(matches!(status_of(&o, 9), Status::NotDone(why) if why.contains("paused"))); o.agents = Ok(agents(vec![row("ruth", None)], Some(Ok(true)))); assert!(matches!(status_of(&o, 9), Status::NotDone(why) if why.contains("no declared"))); @@ -836,21 +963,105 @@ mod tests { ] ); assert!(!complete(&classify(&o))); - let text = render(&classify(&o)); + let text = render(&classify(&o), false); + assert!(text.contains("❓ 1 secret store initialised")); assert!(text.contains("unknown (bao is not on PATH)")); + assert!(text.starts_with("🏗️ setup: 0/8 steps done ░░░░░░░░░░ ❓ 8 unknown\n")); assert!(text.contains("next: step 1 could not be checked")); + assert!(!text.contains("👉")); + } + + fn pending() -> Observed { + let mut o = finished(); + o.admin_user = Ok(false); + o.agents = Ok(agents( + vec![row("ruth", Some("paused")), row("iris", Some("paused"))], + Some(Ok(true)), + )); + o } #[test] - fn the_report_names_the_next_step() { - let mut o = finished(); - o.admin_user = Ok(false); - let text = render(&classify(&o)); - assert!(text.contains(" 6 SSO account in the admins group: not done")); - assert!(text.contains("next: step 6, setup.md § 2")); - assert!(text.contains("unknown (not observable:")); - // Nothing left to do, so no pointer. - assert!(!render(&classify(&finished())).contains("next:")); + fn the_report_marks_the_first_open_step_and_hints_at_it() { + let text = render(&classify(&pending()), false); + assert_eq!( + text, + "\ +🏗️ setup: 6/8 steps done ███████░░░ + +✅ 1 secret store initialised +✅ 2 secret store unsealed +✅ 3 granter bootstrapped the granting units log in through it +✅ 4 granting units active 2 units +✅ 5 store unsealed after the granting units +👉 6 SSO account in the admins group no enabled user in the \"admins\" group; the placeholder is disabled + ↳ setup.md § 2: `swarmctl user add --email
--group admins` +❓ 7 first swarm UI and forge login unknown (not observable: a browser login leaves nothing to read) +❓ 8 forge site admin unknown (not observable: the controller has no read route for it; `forge make-admin` is safe to re-run) +⬜ 9 ruth created and up ruth is declared paused +✅ 10 another agent [optional] 1 besides ruth + +next: step 6, setup.md § 2: `swarmctl user add --email
--group admins` +" + ); + } + + #[test] + fn a_complete_ceremony_ends_on_the_celebration_line() { + let text = render(&classify(&finished()), false); + assert!(text.starts_with("🏗️ setup: 8/8 steps done ██████████\n")); + assert!(text.ends_with("🎉 setup complete\n")); + assert!(!text.contains("next:") && !text.contains("👉")); + } + + #[test] + fn the_header_counts_unknown_steps_apart_from_done_ones() { + let mut o = pending(); + o.agents = Err("no socket".to_owned()); + let text = render(&classify(&o), false); + assert!( + text.starts_with("🏗️ setup: 5/8 steps done ██████░░░░ ❓ 2 unknown\n"), + "{text}" + ); + } + + #[test] + fn colour_only_wraps_the_text_and_the_emoji_stay_either_way() { + let plain = render(&classify(&pending()), false); + let colour = render(&classify(&pending()), true); + assert!(!plain.contains('\x1b')); + assert!(colour.contains("\x1b[32m") && colour.contains("\x1b[33m")); + // Stripping the escapes gives the plain report back. + let mut stripped = String::new(); + let mut chars = colour.chars(); + while let Some(c) = chars.next() { + if c == '\x1b' { + for e in chars.by_ref() { + if e == 'm' { + break; + } + } + } else { + stripped.push(c); + } + } + assert_eq!(stripped, plain); + } + + #[test] + fn colour_needs_a_terminal_and_no_no_color() { + use std::ffi::OsStr; + assert!(color_wanted(true, None)); + assert!(color_wanted(true, Some(OsStr::new("")))); + assert!(!color_wanted(true, Some(OsStr::new("1")))); + assert!(!color_wanted(false, None)); + } + #[test] + fn the_bar_is_full_only_when_everything_is_done() { + assert_eq!(bar(0, 8), "░░░░░░░░░░"); + assert_eq!(bar(7, 8), "████████░░"); + assert_eq!(bar(8, 8), "██████████"); + assert_eq!(bar(0, 0), "░░░░░░░░░░"); } #[test]