swarmctl setup status shows progress at a glance
This commit is contained in:
parent
1c3b37e46a
commit
d55cf9ed80
6 changed files with 269 additions and 50 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 (<why>)`, 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 <what>…`
|
||||
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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(¬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 <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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue