Watch
0
0
Fork
You've already forked hyperhive
0

swarmctl setup status shows progress at a glance

This commit is contained in:
atlas 2026-11-09 10:31:27 +01:00 • committed by mara
commit d55cf9ed80
2 changed files with 269 additions and 50 deletions

View file

@ -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

View file

@ -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<String> = 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:<width$}", step.number)
};
let label = match &step.status {
Status::Done(_) => paint.green(&label),
Status::NotDone(_) => paint.yellow(&label),
_ => paint.dim(&label),
};
let note = if note.is_empty() {
String::new()
} else {
format!(" {}", paint.dim(&note))
};
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 <what>…` 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<Target>, users_file: &Result<std::path::PathBuf>) -> 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<Target>,
users_file: &Result<std::path::PathBuf>,
) -> Observed {
observe_with(target, users_file, &mut |_| {})
}
/// [`observe`], calling `progress` with a name before each check starts.
fn observe_with(
target: &Result<Target>,
users_file: &Result<std::path::PathBuf>,
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 <name> --email <address> --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 <name> --email <address> --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]