From 61a334c847011e5577b1407ee612d4b54d7ddc4e Mon Sep 17 00:00:00 2001 From: atlas Date: Sat, 7 Nov 2026 10:02:51 +0100 Subject: [PATCH] swarmctl setup status shows where the setup ceremony stands --- docs/1-getting-started/setup.md | 4 + docs/2-handbook/swarm/swarmctl-cli.md | 35 +- nix/vm-tests/full-setup.nix | 23 + swarmctl/README.md | 33 +- swarmctl/src/main.rs | 48 +- swarmctl/src/setup.rs | 912 ++++++++++++++++++++++++++ 6 files changed, 1052 insertions(+), 3 deletions(-) create mode 100644 swarmctl/src/setup.rs diff --git a/docs/1-getting-started/setup.md b/docs/1-getting-started/setup.md index 60f01206..344211e5 100644 --- a/docs/1-getting-started/setup.md +++ b/docs/1-getting-started/setup.md @@ -8,6 +8,10 @@ required options, is [`nixos.md`](nixos.md). Every command runs as **root on the host**. +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. + > **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 > host's mTLS identity at the secret store. Read [`2-handbook/swarm/credentials.md`](../2-handbook/swarm/credentials.md) first; it diff --git a/docs/2-handbook/swarm/swarmctl-cli.md b/docs/2-handbook/swarm/swarmctl-cli.md index 19b8d269..7be9a822 100644 --- a/docs/2-handbook/swarm/swarmctl-cli.md +++ b/docs/2-handbook/swarm/swarmctl-cli.md @@ -35,6 +35,8 @@ This document contains the help content for the `swarmctl` command-line program. * [`swarmctl user list`↴](#swarmctl-user-list) * [`swarmctl forge`↴](#swarmctl-forge) * [`swarmctl forge make-admin`↴](#swarmctl-forge-make-admin) +* [`swarmctl setup`↴](#swarmctl-setup) +* [`swarmctl setup status`↴](#swarmctl-setup-status) * [`swarmctl login`↴](#swarmctl-login) * [`swarmctl completions`↴](#swarmctl-completions) @@ -50,6 +52,7 @@ swarm-level operator CLI * `schedule` — Manage scheduled prompts * `user` — Manage subjects in the swarm's SSO provider * `forge` — Manage human accounts on the swarm's forge +* `setup` — Show where the first-run setup ceremony stands * `login` — Log in to the swarm named by `--swarm` as your own SSO account * `completions` — Generate a shell completion script for `swarmctl` and print it to stdout @@ -59,7 +62,7 @@ swarm-level operator CLI * `--users-file ` — Host-side path of authelia's users database — that is, the path inside the container, prefixed with the container's root. This is the only user store: it's read before every change and written in place, and `swarm-authelia-bridge` writes the same file. -* `--swarm ` — The swarm's domain, as `https://` or the bare domain. Given, the `agent`, `schedule` and `forge` verbs go through its gateway as the account `swarmctl login` logged in, instead of over the controller's socket. Falls back to `SWARMCTL_SWARM`; `login` then falls back to `SWARMCTL_LOGIN_DEFAULT_SWARM`, which the swarm-controller nix module sets to its host's own swarm +* `--swarm ` — The swarm's domain, as `https://` or the bare domain. Given, the `agent`, `schedule` and `forge` verbs, and the agent checks of `setup status`, go through its gateway as the account `swarmctl login` logged in, instead of over the controller's socket. Falls back to `SWARMCTL_SWARM`; `login` then falls back to `SWARMCTL_LOGIN_DEFAULT_SWARM`, which the swarm-controller nix module sets to its host's own swarm * `--ca-file ` — A PEM CA certificate to trust beside the system roots, for a swarm whose gateway serves a certificate from the swarm's own CA. Falls back to `SWARMCTL_CA_FILE` @@ -638,6 +641,36 @@ The forge creates a human's account on their first SSO login, and this fails unt +## `swarmctl setup` + +Show where the first-run setup ceremony stands + +**Usage:** `swarmctl setup ` + +###### **Subcommands:** + +* `status` — Print each step of the first-run setup, in order, as `done`, `not done` or `unknown`, and what to do next + + + +## `swarmctl setup status` + +Print each step of the first-run setup, in order, 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)`. + +Exits 0 when every step it can see has finished, and 1 otherwise, including when a check failed to run. Step 10, another agent, is optional and never changes the exit code. The steps are those of `docs/1-getting-started/setup.md`. + +**Usage:** `swarmctl setup status [OPTIONS]` + +###### **Options:** + +* `--controller-socket ` — swarm-controller's unix socket. + + Supplied by the nix module that installs this binary, from the same `socketPath` option the daemon binds; falls back to `SWARM_CONTROLLER_SOCKET`. + + + ## `swarmctl login` Log in to the swarm named by `--swarm` as your own SSO account. diff --git a/nix/vm-tests/full-setup.nix b/nix/vm-tests/full-setup.nix index 85bfacf1..6e7e90d8 100644 --- a/nix/vm-tests/full-setup.nix +++ b/nix/vm-tests/full-setup.nix @@ -15,6 +15,7 @@ # 9 first agent The same endpoint, for `iris`: `swarmctl agent create` has no # archetype flag, and a custom agent's config repo would need # inputs the VM cannot resolve offline. +# `swarmctl setup status` is read last: steps 1-6 done, ruth not yet up. # Not scripted yet; a later slice adds them: step 3, the first SSO login to the # forge (an OIDC code flow against authelia), `swarmctl forge make-admin`, and # the agents' deploy up to the point where each says it needs a login. Green @@ -379,6 +380,28 @@ pkgs.testers.runNixOSTest ( committed = machine.succeed("cat /root/meta-flake.nix") assert committed == expected, "the controller's flake differs from the one the toplevels came from:\n" + committed + with subtest("swarmctl setup status reports the steps done so far"): + # 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", + ] + for _ in range(60): + lines = machine.execute("swarmctl setup status")[1].splitlines() + if all(any(line.startswith(prefix) for line in lines) for prefix in reported): + break + machine.sleep(10) + else: + raise Exception("swarmctl setup status did not report the steps done so far:\n" + "\n".join(lines)) + assert machine.execute("swarmctl setup status >/dev/null")[0] == 1 + # The agents' deploy and the check that they need a login are not scripted yet. ''; } diff --git a/swarmctl/README.md b/swarmctl/README.md index defddd16..0d88e7fe 100644 --- a/swarmctl/README.md +++ b/swarmctl/README.md @@ -190,9 +190,40 @@ schedule does once stored is in [`docs/2-handbook/swarm/schedules.md`](../docs/2-handbook/swarm/schedules.md). The bodies come from `swarm-controller-api`, imported in `src/schedule.rs`. +## `setup status` + +```console +# swarmctl setup status + 1 secret store initialised: done + 2 secret store unsealed: not done (the store is sealed) + ... +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. +Read-only, and it needs no token. Where each step is read from: + +| steps | read from | +| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 1, 2 | `bao status -format=json` through the `bao` on `PATH`, which carries the swarm's address, CA and client certificate; exit 0 is unsealed, 2 is sealed or uninitialised, anything else is an error | +| 3 | the `swarm-bao-granter-role` unit being active or its journal logging `Uploaded policy: bao-granter`; or the granting units of step 4 being active, which log in through the granter | +| 4 | `systemctl is-active` over the `swarm-bao-*-policy` units and `swarm-bao-agent-pki`, as the full-setup VM test polls them, except the operator-viewer one, which waits for authelia | +| 5 | steps 2 and 4 both done | +| 6 | an enabled member of `admins` in authelia's users file | +| 9, 10 | `GET /api/agents/status` and `GET /api/agents/ruth/permissions` on the controller; ruth is done when she is declared `up` and holds `manage_root_agent` | +| 7, 8 | not observable: a browser login leaves nothing to read, and the controller has no read route for a forge admin | + +A check that cannot run gives `unknown` with the reason and the rest still +print: `bao` is only on `PATH` on the host running swarm-bao, the users file is +only on the authelia host, and the controller socket only on its host (or +`--swarm`). The exit code is 0 when every step it can see has finished and 1 +otherwise, counting an `unknown` from a failed check; step 10, another agent, +is optional and does not count. + ## Remote use: `swarmctl login` -The `agent`, `schedule` and `forge` verbs also work from any machine, as +The `agent`, `schedule` and `forge` verbs, and the agent checks of `setup status`, also work from any machine, as your own SSO account, through the swarm domain's gateway: ```console diff --git a/swarmctl/src/main.rs b/swarmctl/src/main.rs index fe4dbd95..7a7a748d 100644 --- a/swarmctl/src/main.rs +++ b/swarmctl/src/main.rs @@ -33,6 +33,7 @@ mod login; mod permissions; mod remote; mod schedule; +mod setup; mod users; mod watch; @@ -72,7 +73,7 @@ const LOGIN_SWARM_ENVS: &[&str] = &["SWARMCTL_SWARM", "SWARMCTL_LOGIN_DEFAULT_SW #[derive(Args)] struct RemoteArgs { /// The swarm's domain, as `https://` or the bare domain. Given, - /// the `agent`, `schedule` and `forge` verbs go through its gateway as the + /// the `agent`, `schedule` and `forge` verbs, and the agent checks of `setup status`, go through its gateway as the /// account `swarmctl login` logged in, instead of over the controller's /// socket. Falls back to `SWARMCTL_SWARM`; `login` then falls back to /// `SWARMCTL_LOGIN_DEFAULT_SWARM`, which the swarm-controller nix module @@ -214,6 +215,11 @@ enum Verb { #[command(subcommand)] command: ForgeVerb, }, + /// Show where the first-run setup ceremony stands. + Setup { + #[command(subcommand)] + command: SetupVerb, + }, /// Log in to the swarm named by `--swarm` as your own SSO account. /// /// With no `--swarm` or `SWARMCTL_SWARM`, logs in to @@ -254,6 +260,32 @@ enum Verb { }, } +#[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. + /// + /// 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)`. + /// + /// Exits 0 when every step it can see has finished, and 1 otherwise, including + /// when a check failed to run. Step 10, another agent, is optional and never + /// changes the exit code. The steps are those of + /// `docs/1-getting-started/setup.md`. + Status(SetupStatusArgs), +} + +#[derive(Args)] +struct SetupStatusArgs { + #[command(flatten)] + socket: ControllerSocketArg, +} + #[derive(Subcommand)] enum AgentVerb { /// Queue creation of a new agent on a hive in this swarm. @@ -768,6 +800,20 @@ fn main() -> Result<()> { } // Same socket-resolution reasoning as `Create` above. Verb::Schedule { command } => schedule(&remote, command), + // Neither the controller socket nor the users file is required: a + // host without one reports the steps that need the other. + Verb::Setup { + command: SetupVerb::Status(args), + } => { + let target = args.socket.resolve(&remote); + let users_file = path_from(paths.users_file, "SWARMCTL_AUTHELIA_USERS_FILE"); + let complete = setup::status(target, users_file); + io::stdout().flush().context("writing the report")?; + if !complete { + std::process::exit(1); + } + Ok(()) + } Verb::Login => login::login( &remote .login_swarm()? diff --git a/swarmctl/src/setup.rs b/swarmctl/src/setup.rs new file mode 100644 index 00000000..c69f663d --- /dev/null +++ b/swarmctl/src/setup.rs @@ -0,0 +1,912 @@ +//! `swarmctl setup status` — where the first-run ceremony of +//! `docs/1-getting-started/setup.md` stands. +//! +//! Read-only, and needs no token: every step is judged from something an +//! operator could see without one. Observing and judging are separate on +//! purpose. [`observe`] runs the commands and calls and records what came +//! back in [`Observed`]; [`classify`] turns that into one [`Step`] per +//! ceremony step and is pure, so every step's outcome is tested against +//! hand-built observations. +//! +//! A dependency that cannot be reached makes its steps `unknown ()`, +//! never an error: the verb exists to be run on a half-built host. + +use std::path::Path; +use std::process::Command; + +use anyhow::{Context as _, Result}; +use hive_types::Permission; +use serde::Deserialize; +use swarm_controller_api::permissions::Permissions; + +use crate::agent::send; +use crate::remote::Target; + +/// The manager agent the ceremony creates. +const RUTH: &str = "ruth"; +/// The SSO group the swarm UI and other operator surfaces gate on. +const ADMINS_GROUP: &str = "admins"; +const GRANTER_UNIT: &str = "swarm-bao-granter-role.service"; +/// What `swarm-bao-granter-role` logs when it has written the granter's policy. +const GRANTER_MARKER: &str = "Uploaded policy: bao-granter"; +/// Waits for authelia, which waits for the policies written in step 4, so it +/// is not a signal for step 4 itself. +const POLICY_UNIT_SKIPPED: &str = "operator-viewer"; + +/// What one step's check found. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum Status { + /// The detail may be empty. + Done(String), + NotDone(String), + /// The check could not run, or its answer could not be read. + Unknown(String), + /// Nothing the host or the controller exposes shows this step. + Unobservable(String), +} + +#[derive(Debug)] +pub(crate) struct Step { + pub(crate) number: u8, + pub(crate) title: &'static str, + pub(crate) status: Status, + /// What to do about it: a doc section or a verb. + pub(crate) hint: &'static str, + /// A step the ceremony allows skipping; it never fails the exit code. + pub(crate) optional: bool, +} + +/// `bao status`, read. +#[derive(Debug)] +pub(crate) struct Store { + pub(crate) initialized: bool, + pub(crate) sealed: bool, + /// The seal type: `shamir` needs a person, the others open themselves. + pub(crate) seal: String, +} + +#[derive(Debug)] +pub(crate) struct Granter { + /// systemd's `ActiveState` of `swarm-bao-granter-role`. + pub(crate) state: String, + /// The unit's journal holds [`GRANTER_MARKER`]. + pub(crate) uploaded: bool, +} + +#[derive(Debug)] +pub(crate) struct Unit { + pub(crate) name: String, + pub(crate) state: String, +} + +/// One row of `GET /api/agents/status`, the two fields read. +#[derive(Debug, Deserialize)] +pub(crate) struct AgentRow { + pub(crate) name: String, + #[serde(default)] + pub(crate) wanted: Option, +} + +#[derive(Debug)] +pub(crate) struct Agents { + pub(crate) rows: Vec, + /// Whether the swarm grants ruth `manage_root_agent`; `None` while she + /// has no row. + pub(crate) ruth_manages_root: Option>, +} + +/// Everything [`classify`] judges. Each field is the whole answer of one +/// check, or the reason the check failed. +#[derive(Debug)] +pub(crate) struct Observed { + pub(crate) store: Result, + pub(crate) granter: Result, + pub(crate) policy_units: Result, String>, + /// Whether the SSO store holds an enabled member of [`ADMINS_GROUP`]. + pub(crate) admin_user: Result, + pub(crate) agents: Result, +} +/// Each step's title, what to do about it, and whether it is optional, in +/// ceremony order. +const STEPS: [(&str, &str, bool); 10] = [ + ( + "secret store initialised", + "setup.md § 1: `sudo bao operator init`, on the host running swarm-bao", + false, + ), + ( + "secret store unsealed", + "setup.md § 1: `bao operator unseal`", + false, + ), + ( + "granter bootstrapped", + "setup.md § 1: the bao-bootstrap block, then `systemctl restart swarm-bao-granter-role`", + false, + ), + ( + "granting units active", + "setup.md § 1: `systemctl reset-failed` then `systemctl restart` the `swarm-bao-*-policy` units and `swarm-bao-agent-pki`", + false, + ), + ( + "store unsealed after the granting units", + "setup.md § 1: `bao operator unseal` again after the store restarted", + false, + ), + ( + "SSO account in the admins group", + "setup.md § 2: `swarmctl user add --email
--group admins`", + false, + ), + ( + "first swarm UI and forge login", + "setup.md § 3: sign in at https:///, then to the forge", + false, + ), + ( + "forge site admin", + "setup.md § 3: `swarmctl forge make-admin `", + false, + ), + ( + "ruth created and up", + "setup.md § 4: create ruth from the swarm UI's clippy form, then set her `up` on /agents", + false, + ), + ( + "another agent", + "setup.md § 5: `swarmctl agent create --hive `", + true, + ), +]; + +/// Judge `observed` step by step, in ceremony order. +pub(crate) fn classify(observed: &Observed) -> Vec { + let unsealed = unsealed_status(&observed.store); + let policy_units = policy_units_status(&observed.policy_units); + let granter = granter_status(&observed.granter, &policy_units); + let reunsealed = match (&unsealed, &policy_units) { + (Status::Done(_), Status::Done(_)) => Status::Done(String::new()), + (Status::Unknown(e), _) | (_, Status::Unknown(e)) => Status::Unknown(e.clone()), + (Status::NotDone(why), _) => Status::NotDone(why.clone()), + _ => Status::NotDone("waits for step 4".to_owned()), + }; + let statuses = [ + initialized_status(&observed.store), + unsealed, + granter, + policy_units, + reunsealed, + admin_user_status(&observed.admin_user), + Status::Unobservable("a browser login leaves nothing to read".to_owned()), + Status::Unobservable( + "the controller has no read route for it; `forge make-admin` is safe to re-run" + .to_owned(), + ), + ruth_status(&observed.agents), + others_status(&observed.agents), + ]; + STEPS + .into_iter() + .zip(statuses) + .zip(1..) + .map(|(((title, hint, optional), status), number)| Step { + number, + title, + status, + hint, + optional, + }) + .collect() +} + +fn initialized_status(store: &Result) -> Status { + match store { + Ok(s) if s.initialized => Status::Done(String::new()), + Ok(_) => Status::NotDone("the store is not initialised".to_owned()), + Err(e) => Status::Unknown(e.clone()), + } +} + +fn unsealed_status(store: &Result) -> Status { + match store { + Ok(s) if !s.initialized => Status::NotDone("not initialised yet".to_owned()), + Ok(s) if !s.sealed => Status::Done(String::new()), + Ok(s) if s.seal == "shamir" => Status::NotDone("the store is sealed".to_owned()), + Ok(s) => Status::NotDone(format!( + "the store is sealed and its {} seal did not open it", + s.seal + )), + Err(e) => Status::Unknown(e.clone()), + } +} + +fn admin_user_status(admin_user: &Result) -> Status { + match admin_user { + Ok(true) => Status::Done(String::new()), + Ok(false) => Status::NotDone(format!( + "no enabled user in the {ADMINS_GROUP:?} group; the placeholder is disabled" + )), + Err(e) => Status::Unknown(e.clone()), + } +} + +fn policy_units_status(units: &Result, String>) -> Status { + match units { + Err(e) => Status::Unknown(e.clone()), + Ok(units) => { + let idle: Vec = units + .iter() + .filter(|u| u.state != "active") + .map(|u| format!("{} is {}", u.name, u.state)) + .collect(); + if idle.is_empty() { + Status::Done(format!("{} units", units.len())) + } else { + Status::NotDone(idle.join(", ")) + } + } + } +} + +/// The granter unit deletes its token when it succeeds and is skipped when +/// the token is absent, so after a reboot it is `inactive` whether or not it +/// ever ran. An active granting unit logs in through the granter, so that +/// also proves the granter exists. +fn granter_status(granter: &Result, policy_units: &Status) -> Status { + if matches!(policy_units, Status::Done(_)) { + return Status::Done("the granting units log in through it".to_owned()); + } + match granter { + Err(e) => Status::Unknown(e.clone()), + Ok(g) if g.state == "active" => Status::Done(String::new()), + Ok(g) if g.uploaded => Status::Done("its journal shows the upload".to_owned()), + Ok(g) if g.state == "failed" => { + Status::NotDone("swarm-bao-granter-role failed; see its journal".to_owned()) + } + Ok(_) => Status::NotDone("swarm-bao-granter-role has not uploaded the policy".to_owned()), + } +} + +fn ruth_status(agents: &Result) -> Status { + let agents = match agents { + Ok(a) => a, + Err(e) => return Status::Unknown(e.clone()), + }; + let Some(ruth) = agents.rows.iter().find(|r| r.name == RUTH) else { + return Status::NotDone(format!("{RUTH} is not in the swarm's roster")); + }; + match &agents.ruth_manages_root { + Some(Err(e)) => return Status::Unknown(format!("{RUTH}'s permissions: {e}")), + Some(Ok(false)) | None => { + return Status::NotDone(format!("{RUTH} lacks manage_root_agent")); + } + Some(Ok(true)) => {} + } + match ruth.wanted.as_deref() { + Some("up") => Status::Done(String::new()), + Some(state) => Status::NotDone(format!("{RUTH} is declared {state}")), + None => Status::NotDone(format!("{RUTH} has no declared state")), + } +} + +fn others_status(agents: &Result) -> Status { + match agents { + Err(e) => Status::Unknown(e.clone()), + Ok(a) => { + let others = a.rows.iter().filter(|r| r.name != RUTH).count(); + if others > 0 { + Status::Done(format!("{others} besides {RUTH}")) + } else { + Status::NotDone(format!("no agent besides {RUTH}")) + } + } + } +} + +/// Whether the ceremony is as complete as it can be seen: every step is done, +/// or cannot be observed, or is optional. A step whose check failed counts +/// against it. +pub(crate) fn complete(steps: &[Step]) -> bool { + steps + .iter() + .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 { + 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 + .iter() + .find(|s| !s.optional && matches!(s.status, Status::NotDone(_))) + { + let _ = writeln!(out, "next: step {}, {}", next.number, next.hint); + } else if let Some(blind) = steps + .iter() + .find(|s| !s.optional && matches!(s.status, Status::Unknown(_))) + { + let _ = writeln!( + out, + "next: step {} could not be checked; fix the cause above and run this again", + blind.number + ); + } + out +} + +/// 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. +pub(crate) fn status(target: Result, users_file: Result) -> bool { + let observed = observe(target, users_file); + let steps = classify(&observed); + print!("{}", render(&steps)); + complete(&steps) +} + +fn observe(target: Result, users_file: Result) -> Observed { + let store = bao_status(); + let granter = granter(); + let policy_units = policy_units(); + let admin_user = users_file + .map_err(|e| unset_reason(&e)) + .and_then(|path| has_admin_user(&path).map_err(|e| format!("{e:#}"))); + let agents = target + .map_err(|e| unset_reason(&e)) + .and_then(|t| agents(&t).map_err(|e| format!("{e:#}"))); + Observed { + store, + granter, + policy_units, + admin_user, + agents, + } +} + +/// The part of a resolution error before its first em dash, which names the +/// unset variable; the rest explains the deployment. +fn unset_reason(e: &anyhow::Error) -> String { + let full = format!("{e:#}"); + full.split(" — ").next().unwrap_or(&full).to_owned() +} + +struct Output { + code: Option, + stdout: String, + stderr: String, +} + +fn run(command: &mut Command) -> Result { + let program = command.get_program().to_string_lossy().into_owned(); + let out = command.output().map_err(|e| match e.kind() { + std::io::ErrorKind::NotFound => { + format!("{program} is not on PATH; run this on the host that runs swarm-bao") + } + _ => format!("running {program}: {e}"), + })?; + Ok(Output { + code: out.status.code(), + stdout: String::from_utf8_lossy(&out.stdout).into_owned(), + stderr: String::from_utf8_lossy(&out.stderr).into_owned(), + }) +} + +/// `bao status`: the wrapper on `PATH` carries the swarm's address, CA and +/// client certificate, and the call needs no token. A client timeout is set +/// when the environment has none, so an unreachable store answers. +fn bao_status() -> Result { + let mut command = Command::new("bao"); + command.args(["status", "-format=json"]); + if std::env::var_os("BAO_CLIENT_TIMEOUT").is_none() { + command.env("BAO_CLIENT_TIMEOUT", "10s"); + } + let out = run(&mut command)?; + parse_bao_status(out.code, &out.stdout, &out.stderr) +} + +/// Exit 0 is unsealed, 2 is sealed (an uninitialised store is sealed too, and +/// its JSON says `"initialized": false`), anything else is an error. +fn parse_bao_status(code: Option, stdout: &str, stderr: &str) -> Result { + #[derive(Deserialize)] + struct Status { + initialized: bool, + sealed: bool, + #[serde(rename = "type", default)] + seal: String, + } + match code { + Some(0 | 2) => { + let s: Status = serde_json::from_str(stdout) + .map_err(|e| format!("bao status printed JSON this cannot read: {e}"))?; + Ok(Store { + initialized: s.initialized, + sealed: s.sealed, + seal: s.seal, + }) + } + other => Err(format!( + "bao status {}: {}", + other.map_or_else(|| "was killed".to_owned(), |c| format!("exited {c}")), + stderr.lines().next().unwrap_or("no message") + )), + } +} + +fn granter() -> Result { + let shown = run(Command::new("systemctl").args([ + "show", + GRANTER_UNIT, + "-p", + "LoadState", + "-p", + "ActiveState", + ]))?; + let state = parse_unit_state(&shown.stdout, GRANTER_UNIT)?; + // An unreadable journal counts as no upload: the unit's state decides. + let uploaded = run(Command::new("journalctl").args(["-u", GRANTER_UNIT, "--no-pager", "-q"])) + .is_ok_and(|j| j.stdout.contains(GRANTER_MARKER)); + Ok(Granter { state, uploaded }) +} + +/// `ActiveState` out of `systemctl show -p LoadState -p ActiveState`. +fn parse_unit_state(shown: &str, unit: &str) -> Result { + let field = |name: &str| { + shown + .lines() + .find_map(|l| l.strip_prefix(name).and_then(|v| v.strip_prefix('='))) + }; + match (field("LoadState"), field("ActiveState")) { + (Some("not-found"), _) => Err(format!( + "{unit} does not exist here; run this on the host that runs swarm-bao" + )), + (_, Some(state)) => Ok(state.to_owned()), + _ => Err(format!("systemctl show {unit} printed no ActiveState")), + } +} + +fn policy_units() -> Result, String> { + let listed = run(Command::new("systemctl").args([ + "list-unit-files", + "swarm-bao-*-policy.service", + "swarm-bao-agent-pki.service", + "--no-legend", + ]))?; + let names = parse_unit_files(&listed.stdout); + if names.is_empty() { + return Err( + "no swarm-bao-*-policy units here; run this on the host that runs swarm-bao".to_owned(), + ); + } + // `is-active` exits non-zero when any unit is not active, so its exit + // status says nothing; it prints one state per unit, in order. + let active = run(Command::new("systemctl").arg("is-active").args(&names))?; + let states: Vec<&str> = active.stdout.lines().collect(); + if states.len() != names.len() { + return Err(format!( + "systemctl is-active answered {} states for {} units", + states.len(), + names.len() + )); + } + Ok(names + .into_iter() + .zip(states) + .map(|(name, state)| Unit { + name, + state: state.to_owned(), + }) + .collect()) +} + +/// The unit names in `systemctl list-unit-files --no-legend`, without the one +/// that waits on authelia. +fn parse_unit_files(listed: &str) -> Vec { + listed + .lines() + .filter_map(|l| l.split_whitespace().next()) + .filter(|name| name.ends_with(".service") && !name.contains(POLICY_UNIT_SKIPPED)) + .map(str::to_owned) + .collect() +} + +/// Whether the users database holds an enabled member of [`ADMINS_GROUP`]. +fn has_admin_user(users_file: &Path) -> Result { + if !users_file.exists() { + anyhow::bail!( + "{} does not exist; run this on the host that runs authelia", + users_file.display() + ); + } + let store = crate::load_store(users_file)?; + Ok(store.users.values().any(|u| { + u.groups.iter().any(|g| g == ADMINS_GROUP) + && !matches!( + u.extra.get("disabled"), + Some(serde_norway::Value::Bool(true)) + ) + })) +} + +fn agents(target: &Target) -> Result { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("starting a tokio runtime for the controller request")?; + let rows: Vec = rt.block_on(send( + target, + hyper::Method::GET, + "/api/agents/status", + None::<&()>, + "agents-status", + ))?; + let ruth_manages_root = rows.iter().any(|r| r.name == RUTH).then(|| { + rt.block_on(send::<(), Permissions>( + target, + hyper::Method::GET, + &format!("/api/agents/{RUTH}/permissions"), + None, + "permissions-get", + )) + .map(|p| { + p.permissions + .is_some_and(|granted| granted.contains(&Permission::ManageRootAgent)) + }) + .map_err(|e| format!("{e:#}")) + }); + Ok(Agents { + rows, + ruth_manages_root, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn store(initialized: bool, sealed: bool, seal: &str) -> Store { + Store { + initialized, + sealed, + seal: seal.to_owned(), + } + } + + fn units(states: &[&str]) -> Vec { + states + .iter() + .enumerate() + .map(|(i, s)| Unit { + name: format!("swarm-bao-u{i}-policy.service"), + state: (*s).to_owned(), + }) + .collect() + } + + fn row(name: &str, wanted: Option<&str>) -> AgentRow { + AgentRow { + name: name.to_owned(), + wanted: wanted.map(str::to_owned), + } + } + + fn agents(rows: Vec, ruth_root: Option>) -> Agents { + Agents { + rows, + ruth_manages_root: ruth_root, + } + } + + /// Everything done, as a finished ceremony looks. + fn finished() -> Observed { + Observed { + store: Ok(store(true, false, "shamir")), + granter: Ok(Granter { + state: "active".to_owned(), + uploaded: true, + }), + policy_units: Ok(units(&["active", "active"])), + admin_user: Ok(true), + agents: Ok(agents( + vec![row("ruth", Some("up")), row("iris", Some("paused"))], + Some(Ok(true)), + )), + } + } + + fn status_of(observed: &Observed, number: u8) -> Status { + classify(observed) + .into_iter() + .find(|s| s.number == number) + .unwrap() + .status + } + + fn kind(status: &Status) -> &'static str { + match status { + Status::Done(_) => "done", + Status::NotDone(_) => "not done", + Status::Unknown(_) => "unknown", + Status::Unobservable(_) => "unobservable", + } + } + + fn kinds(observed: &Observed) -> Vec<&'static str> { + classify(observed).iter().map(|s| kind(&s.status)).collect() + } + + #[test] + fn a_finished_ceremony_reads_done_and_exits_clean() { + let steps = classify(&finished()); + assert_eq!( + kinds(&finished()), + [ + "done", + "done", + "done", + "done", + "done", + "done", + "unobservable", + "unobservable", + "done", + "done" + ] + ); + assert!(complete(&steps)); + } + + #[test] + fn an_uninitialised_store_fails_steps_one_two_and_five() { + let mut o = finished(); + o.store = Ok(store(false, true, "shamir")); + o.policy_units = Ok(units(&["inactive"])); + o.granter = Ok(Granter { + state: "inactive".to_owned(), + uploaded: false, + }); + let k = kinds(&o); + assert_eq!(&k[..5], ["not done"; 5]); + assert!(!complete(&classify(&o))); + } + + #[test] + fn a_sealed_store_is_initialised_but_not_unsealed() { + let mut o = finished(); + o.store = Ok(store(true, true, "shamir")); + assert_eq!(kind(&status_of(&o, 1)), "done"); + assert_eq!(kind(&status_of(&o, 2)), "not done"); + // The granting units are active, so only the second unseal is open. + assert_eq!(kind(&status_of(&o, 4)), "done"); + assert_eq!(kind(&status_of(&o, 5)), "not done"); + } + + #[test] + fn a_sealed_auto_unseal_store_names_its_seal() { + let mut o = finished(); + o.store = Ok(store(true, true, "pkcs11")); + assert!(matches!(status_of(&o, 2), Status::NotDone(why) if why.contains("pkcs11"))); + } + + #[test] + fn step_five_waits_for_the_granting_units() { + let mut o = finished(); + o.policy_units = Ok(units(&["active", "failed"])); + assert!(matches!(status_of(&o, 5), Status::NotDone(why) if why.contains("step 4"))); + } + + #[test] + fn the_granter_is_done_on_any_one_of_its_three_signals() { + let mut o = finished(); + o.policy_units = Ok(units(&["failed"])); + let granter = |state: &str, uploaded| { + Ok(Granter { + state: state.to_owned(), + uploaded, + }) + }; + o.granter = granter("active", false); + assert_eq!(kind(&status_of(&o, 3)), "done"); + o.granter = granter("inactive", true); + assert_eq!(kind(&status_of(&o, 3)), "done"); + // The control: neither signal, and the units not active either. + o.granter = granter("inactive", false); + assert_eq!(kind(&status_of(&o, 3)), "not done"); + o.granter = granter("failed", false); + assert!(matches!(status_of(&o, 3), Status::NotDone(why) if why.contains("failed"))); + // Active granting units prove the granter even with no trace of its unit. + o.policy_units = Ok(units(&["active"])); + assert_eq!(kind(&status_of(&o, 3)), "done"); + } + + #[test] + fn one_inactive_policy_unit_fails_step_four_and_names_it() { + let mut o = finished(); + o.policy_units = Ok(units(&["active", "failed", "active"])); + assert!( + matches!(status_of(&o, 4), Status::NotDone(why) if why.contains("u1-policy") && why.contains("failed") && !why.contains("u0")) + ); + } + + #[test] + fn a_missing_admin_account_fails_step_six() { + let mut o = finished(); + o.admin_user = Ok(false); + assert_eq!(kind(&status_of(&o, 6)), "not done"); + } + + #[test] + fn ruth_is_done_only_when_present_permitted_and_up() { + 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)))); + 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"))); + o.agents = Ok(agents(vec![row("ruth", Some("up"))], Some(Ok(false)))); + assert!( + matches!(status_of(&o, 9), Status::NotDone(why) if why.contains("manage_root_agent")) + ); + o.agents = Ok(agents( + vec![row("ruth", Some("up"))], + Some(Err("503".to_owned())), + )); + assert_eq!(kind(&status_of(&o, 9)), "unknown"); + // The control for the four cases above: the same row, permitted, is done. + o.agents = Ok(agents(vec![row("ruth", Some("up"))], Some(Ok(true)))); + assert_eq!(kind(&status_of(&o, 9)), "done"); + } + + #[test] + fn another_agent_is_optional_and_never_counts_ruth() { + let mut o = finished(); + o.agents = Ok(agents(vec![row("ruth", Some("up"))], Some(Ok(true)))); + assert_eq!(kind(&status_of(&o, 10)), "not done"); + // Optional, so the exit code stays clean. + assert!(complete(&classify(&o))); + o.agents = Ok(agents(vec![row("iris", None)], None)); + assert_eq!(kind(&status_of(&o, 10)), "done"); + } + + #[test] + fn an_unreachable_dependency_makes_its_steps_unknown_and_fails_the_exit() { + fn down(what: &str) -> Result { + Err(what.to_owned()) + } + let o = Observed { + store: down("bao is not on PATH"), + granter: down("no unit"), + policy_units: down("no units"), + admin_user: down("no users file"), + agents: down("no socket"), + }; + assert_eq!( + kinds(&o), + [ + "unknown", + "unknown", + "unknown", + "unknown", + "unknown", + "unknown", + "unobservable", + "unobservable", + "unknown", + "unknown" + ] + ); + assert!(!complete(&classify(&o))); + let text = render(&classify(&o)); + assert!(text.contains("unknown (bao is not on PATH)")); + assert!(text.contains("next: step 1 could not be checked")); + } + + #[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:")); + } + + #[test] + fn bao_status_maps_its_exit_codes() { + let json = + |init, sealed| format!(r#"{{"type":"shamir","initialized":{init},"sealed":{sealed}}}"#); + let unsealed = parse_bao_status(Some(0), &json(true, false), "").unwrap(); + assert!(unsealed.initialized && !unsealed.sealed && unsealed.seal == "shamir"); + let sealed = parse_bao_status(Some(2), &json(true, true), "").unwrap(); + assert!(sealed.initialized && sealed.sealed); + let fresh = parse_bao_status(Some(2), &json(false, true), "").unwrap(); + assert!(!fresh.initialized && fresh.sealed); + let down = + parse_bao_status(Some(1), "", "Error checking seal status: refused\nmore").unwrap_err(); + assert_eq!( + down, + "bao status exited 1: Error checking seal status: refused" + ); + assert!( + parse_bao_status(None, "", "") + .unwrap_err() + .contains("killed") + ); + assert!(parse_bao_status(Some(2), "not json", "").is_err()); + } + + #[test] + fn unit_files_drop_the_viewer_and_keep_the_pki_unit() { + let listed = "swarm-bao-controller-policy.service enabled enabled\n\ + swarm-bao-operator-viewer-policy.service enabled enabled\n\ + swarm-bao-agent-pki.service enabled enabled\n"; + assert_eq!( + parse_unit_files(listed), + [ + "swarm-bao-controller-policy.service", + "swarm-bao-agent-pki.service" + ] + ); + assert!(parse_unit_files("").is_empty()); + } + + #[test] + fn unit_state_reads_active_state_and_refuses_a_missing_unit() { + let shown = "LoadState=loaded\nActiveState=inactive\n"; + assert_eq!(parse_unit_state(shown, "u.service").unwrap(), "inactive"); + let missing = "LoadState=not-found\nActiveState=inactive\n"; + assert!( + parse_unit_state(missing, "u.service") + .unwrap_err() + .contains("does not exist") + ); + assert!(parse_unit_state("", "u.service").is_err()); + } + + #[test] + fn admin_membership_ignores_disabled_and_ungrouped_users() { + let dir = std::env::temp_dir().join(format!("swarmctl-setup-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("users.yml"); + let write = |body: &str| std::fs::write(&file, body).unwrap(); + let user = |extra: &str, groups: &str| { + format!(" u:\n displayname: U\n password: x\n{groups}{extra}") + }; + let admins = " groups:\n - admins\n"; + write(&format!("users:\n{}", user(" disabled: true\n", admins))); + assert!(!has_admin_user(&file).unwrap()); + write(&format!("users:\n{}", user("", ""))); + assert!(!has_admin_user(&file).unwrap()); + // The control: the same user, enabled and in the group, is found. + write(&format!("users:\n{}", user("", admins))); + assert!(has_admin_user(&file).unwrap()); + assert!( + has_admin_user(&dir.join("absent.yml")) + .unwrap_err() + .to_string() + .contains("does not exist") + ); + std::fs::remove_dir_all(&dir).unwrap(); + } +}