diff --git a/Cargo.toml b/Cargo.toml index 0c73c80c..0c0d57ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -176,6 +176,8 @@ rustix = { version = "1.1.4", default-features = false, features = [ "fs", # `swarmctl bao grant-bootstrap` turns terminal echo off for the root token. "termios", + # `swarmctl setup` checks that it runs as root. + "process", ] } reqwest = { version = "0.13", default-features = false, features = [ # RFC 7662 introspection posts an urlencoded body; without this, diff --git a/docs/1-getting-started/setup.md b/docs/1-getting-started/setup.md index bde17db8..528424c8 100644 --- a/docs/1-getting-started/setup.md +++ b/docs/1-getting-started/setup.md @@ -8,6 +8,14 @@ required options, is [`nixos.md`](nixos.md). Every command runs as **root on the host**. +**Or just run `swarmctl setup`.** It walks the steps below interactively as root: it checks +where you stand first, so a rerun continues where it stopped; shows `Step n of N`; prompts for +what each step needs; shows a running action as one live line; and prints the secrets you must +record, such as the store's keys and root token, under a ⚠️ warning until you confirm you stored +them. A step this host can't do, because it doesn't run that service, names the deploy option +it needs and the host to run `swarmctl setup` on. Steps with no verb yet print their command and +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. diff --git a/docs/2-handbook/swarm/swarmctl-cli.md b/docs/2-handbook/swarm/swarmctl-cli.md index 4c90df7c..049c1fcd 100644 --- a/docs/2-handbook/swarm/swarmctl-cli.md +++ b/docs/2-handbook/swarm/swarmctl-cli.md @@ -55,7 +55,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 +* `setup` — Walk through the first-run setup, interactively * `bao` — Initialise the swarm's secret store and bootstrap its granter * `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 @@ -66,7 +66,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, 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 +* `--swarm ` — The swarm's domain, as `https://` or the bare domain. Given, the `agent`, `schedule` and `forge` verbs, and the agent checks and steps of `setup`, 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` @@ -647,14 +647,25 @@ 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 +Walk through the first-run setup, interactively. -**Usage:** `swarmctl setup ` +Run it as root, in a terminal. It checks `docs/1-getting-started/setup.md` first, so a rerun continues at the first open step, then takes the open steps one at a time. It prompts for what a step needs, shows a running action as one live line, and prints anything to record in colour under a ⚠️ warning, such as the secret store's keys and root token. It prints those once and stores none of them. A step that needs a service this host doesn't run names the deploy option to enable and the host to run `swarmctl setup` on. A step without a verb yet prints its command and waits until you confirm you ran it. + +For scripts, use `setup status` and the individual verbs. + +**Usage:** `swarmctl setup [OPTIONS] + setup ` ###### **Subcommands:** * `status` — Print each step of the first-run setup, in order, as `done`, `not done` or `unknown`, and what to do next +###### **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 setup status` diff --git a/swarmctl/README.md b/swarmctl/README.md index 45568cf8..cc0a7c63 100644 --- a/swarmctl/README.md +++ b/swarmctl/README.md @@ -207,6 +207,35 @@ 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` + +```console +# swarmctl setup +``` + +Walks the first-run ceremony of +[`docs/1-getting-started/setup.md`](../docs/1-getting-started/setup.md) as root, in a terminal. It +reads the same checks as `setup status` first, so a rerun skips the steps that are done and +continues at the first one left. Each step shows as `Step n of N`, with the number still to do. + +- **Prompts.** Whatever a step needs, such as the SSO account's username, display name and email, + the root token (hidden), or the hive for ruth, it asks for. +- **Live line.** A running action shows as one spinner line with its elapsed time. A failure + prints the whole error and offers retry, skip or quit. +- **Warnings.** The secret store's keys and root token, and the new account's password, print once + under yellow ⚠️ warnings and stay on screen until you type `yes` to say you stored them. swarmctl + writes them nowhere. `NO_COLOR` and a non-terminal turn the colour off. +- **Not on this host.** A step whose service this host does not run is listed with the deploy option + it needs (`services.hyperhive.deploy.bao.enable`, `deploy.authelia.enable`, + `deploy.swarm-controller.enable`) and the host to run `swarmctl setup` on. +- **No verb yet.** Unsealing a `shamir` store, restarting the granting units and the browser login + print the command or instruction and wait for you to confirm, then check again. + +The steps call the code of the `bao init`, `bao grant-bootstrap`, `user add`, `forge make-admin` and +`agent create` verbs, plus `agent permissions set` and `agent set-state` for ruth. They never run +`swarmctl` again. Without a terminal, or without root, it refuses; scripts use `setup status` +and the verbs. The flow is `src/interactive.rs`; the terminal and side effects are `src/terminal.rs`. + ## `setup status` ```console @@ -240,7 +269,7 @@ is optional and does not count. ## Remote use: `swarmctl login` -The `agent`, `schedule` and `forge` verbs, and the agent checks of `setup status`, also work from any machine, as +The `agent`, `schedule` and `forge` verbs, and the agent checks and steps of `setup`, also work from any machine, as your own SSO account, through the swarm domain's gateway: ```console diff --git a/swarmctl/src/agent.rs b/swarmctl/src/agent.rs index 111697e9..5a16e623 100644 --- a/swarmctl/src/agent.rs +++ b/swarmctl/src/agent.rs @@ -57,11 +57,7 @@ pub(crate) fn create( }) .transpose()?; - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .context("starting a tokio runtime for the controller request")?; - let resp = rt.block_on(post_create(target, &name, &hive, agents_md.as_deref()))?; + let resp = request_create(target, &name, &hive, agents_md.as_deref())?; println!("queued: job node {}", resp.node_id); println!( @@ -76,6 +72,21 @@ pub(crate) fn create( Ok(()) } +/// The round trip of [`create`] for an already validated `name` and `hive`, +/// printing nothing. +pub(crate) fn request_create( + target: &Target, + name: &str, + hive: &str, + agents_md: Option<&str>, +) -> Result { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("starting a tokio runtime for the controller request")?; + rt.block_on(post_create(target, name, hive, agents_md)) +} + /// Parse a CLI-supplied name into a validated identifier, naming which /// argument was wrong — `invalid hive` and `invalid agent name` send the /// operator to different flags. @@ -137,8 +148,23 @@ pub(crate) fn set_state( check_confirmed(state, yes)?; let agent = parse_ident(agent, "agent name")?; let hive = parse_ident(hive, "hive")?; - let wire: &'static str = state.into(); + let wire = request_state(target, &agent, &hive, state)?; + println!( + "declared agent {agent:?} {wire} on hive {hive:?}; the hive converges on its own clock" + ); + Ok(()) +} + +/// The round trip of [`set_state`] for an already validated `agent` and `hive`, +/// printing nothing. Returns the state as the wire spells it. +pub(crate) fn request_state( + target: &Target, + agent: &str, + hive: &str, + state: swarm_wire::wanted::AgentState, +) -> Result<&'static str> { + let wire: &'static str = state.into(); let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() @@ -150,11 +176,7 @@ pub(crate) fn set_state( Some(&serde_json::json!({ "state": wire })), "set-state", ))?; - - println!( - "declared agent {agent:?} {wire} on hive {hive:?}; the hive converges on its own clock" - ); - Ok(()) + Ok(wire) } /// One `POST /api/agents` round trip at the controller. diff --git a/swarmctl/src/bao.rs b/swarmctl/src/bao.rs index 314ebc2e..fb97e320 100644 --- a/swarmctl/src/bao.rs +++ b/swarmctl/src/bao.rs @@ -154,6 +154,50 @@ pub fn grant_bootstrap(args: GrantBootstrapArgs) -> Result<()> { Ok(()) } +/// The verbs above without their printing and prompting, for `swarmctl setup`. +pub(crate) struct Bao { + tools: Tools, +} + +impl Bao { + pub(crate) fn new() -> Self { + Self { + tools: ToolArgs { bao_bin: None }.tools(), + } + } + + /// Whether the nix module gave this host the bootstrap token path and + /// policy file, which it does only where the store runs. + pub(crate) fn grant_configured() -> bool { + GrantArgs { + bootstrap_token_file: None, + bootstrap_policy: None, + } + .resolve() + .is_ok() + } + + /// `swarmctl bao init`, minus delivering the secrets. + pub(crate) fn init(&self) -> Result { + create_store(&self.tools) + } + + pub(crate) fn wait_unsealed(&self) -> Result<()> { + wait_unsealed(&self.tools) + } + + /// `swarmctl bao grant-bootstrap` with `root` already in hand. Returns + /// the sentence the verb prints. + pub(crate) fn grant_bootstrap(&self, root: &str) -> Result { + let paths = GrantArgs { + bootstrap_token_file: None, + bootstrap_policy: None, + } + .resolve()?; + Ok(grant_bootstrap_with(&self.tools, &paths, root)?.describe(&paths)) + } +} + /// What `bao operator init -format=json` prints, minus the fields not shown. /// /// Wiped on drop. The JSON parser and the child's output buffer pass through @@ -186,6 +230,56 @@ fn initialise( out: &mut impl io::Write, fallback: &mut impl io::Write, ) -> Result> { + let created = create_store(tools)?; + eprintln!( + "Record the keys and the root token below off this host now: swarmctl keeps none of \ + them and cannot show them again." + ); + if let Err(stdout_err) = write_secrets(out, &created.parsed) { + let delivered = write_secrets(fallback, &created.parsed); + return Err(match delivered { + Ok(()) => anyhow::anyhow!( + "the store is now initialised, but its keys and root token could not be written \ + to stdout ({stdout_err}); they were written to stderr instead, once: record \ + them now" + ), + Err(stderr_err) => anyhow::anyhow!( + "the store is now initialised, but its keys and root token could not be \ + written to stdout ({stdout_err}) or to stderr ({stderr_err}) and are lost; \ + see OpenBao's `operator rekey` and `operator generate-root` documentation \ + for what a remaining key holder can recover" + ), + }); + } + Ok(created.root_token()) +} + +/// A store this process just initialised, holding the only copy of its keys +/// and root token. Wiped on drop, through [`InitOutput`]. +pub(crate) struct Created { + parsed: InitOutput, +} + +impl Created { + pub(crate) fn root_token(&self) -> Zeroizing { + Zeroizing::new(self.parsed.root_token.clone()) + } + + /// The keys and the root token, one labelled line each, as the operator + /// is to record them. + pub(crate) fn rendered(&self) -> Result> { + let mut bytes = Zeroizing::new(Vec::with_capacity(4096)); + write_secrets(&mut *bytes, &self.parsed).context("rendering the keys")?; + let text = String::from_utf8(std::mem::take(&mut *bytes)) + .map_err(|_| anyhow::anyhow!("the keys are not UTF-8"))?; + Ok(Zeroizing::new(text)) + } +} + +/// Runs `bao operator init` against a store that is not initialised yet and +/// reads what it printed. Prints nothing: delivering the secrets is the +/// caller's. +fn create_store(tools: &Tools) -> Result { let status = run(&mut tools.bao_cmd(&["operator", "init", "-status"]))?; match status.status.code() { Some(0) => bail!( @@ -215,28 +309,7 @@ fn initialise( if parsed.unseal_keys_b64.is_empty() && parsed.recovery_keys_b64.is_empty() { bail!("`bao operator init` printed no unseal or recovery keys"); } - - eprintln!( - "Record the keys and the root token below off this host now: swarmctl keeps none of \ - them and cannot show them again." - ); - if let Err(stdout_err) = write_secrets(out, &parsed) { - let delivered = write_secrets(fallback, &parsed); - return Err(match delivered { - Ok(()) => anyhow::anyhow!( - "the store is now initialised, but its keys and root token could not be written \ - to stdout ({stdout_err}); they were written to stderr instead, once: record \ - them now" - ), - Err(stderr_err) => anyhow::anyhow!( - "the store is now initialised, but its keys and root token could not be \ - written to stdout ({stdout_err}) or to stderr ({stderr_err}) and are lost; \ - see OpenBao's `operator rekey` and `operator generate-root` documentation \ - for what a remaining key holder can recover" - ), - }); - } - Ok(Zeroizing::new(parsed.root_token.clone())) + Ok(Created { parsed }) } fn write_secrets(out: &mut impl io::Write, parsed: &InitOutput) -> io::Result<()> { @@ -401,7 +474,7 @@ fn failure_text(stderr: &[u8], secrets: &[&str]) -> String { fn read_root_token() -> Result> { let stdin = io::stdin(); let token = if stdin.is_terminal() { - prompt_no_echo(&stdin)? + prompt_no_echo(&stdin, "Root token (input hidden): ")? } else { read_token_line(&mut stdin.lock())? }; @@ -411,14 +484,14 @@ fn read_root_token() -> Result> { Ok(token) } -fn prompt_no_echo(stdin: &io::Stdin) -> Result> { +pub(crate) fn prompt_no_echo(stdin: &io::Stdin, prompt: &str) -> Result> { use rustix::termios::{LocalModes, OptionalActions, tcgetattr, tcsetattr}; let saved = tcgetattr(stdin).context("reading terminal settings")?; let mut quiet = saved.clone(); quiet.local_modes.remove(LocalModes::ECHO); tcsetattr(stdin, OptionalActions::Flush, &quiet).context("turning terminal echo off")?; - eprint!("Root token (input hidden): "); + eprint!("{prompt}"); io::stderr().flush().ok(); let read = read_token_line(&mut stdin.lock()); // Restored before the error, if any, is looked at. diff --git a/swarmctl/src/forge.rs b/swarmctl/src/forge.rs index 70eb5da2..89ea013b 100644 --- a/swarmctl/src/forge.rs +++ b/swarmctl/src/forge.rs @@ -15,6 +15,12 @@ use crate::remote::Target; /// /// Synchronous for the same reason `agent create` is. pub(crate) fn make_admin(target: &Target, name: &str) -> Result<()> { + println!("{}", request_admin(target, name)?); + Ok(()) +} + +/// The round trip of [`make_admin`]: the sentence it prints, not printed. +pub(crate) fn request_admin(target: &Target, name: &str) -> Result { let name = parse_ident(name, "user name")?; let rt = tokio::runtime::Builder::new_current_thread() @@ -28,9 +34,8 @@ pub(crate) fn make_admin(target: &Target, name: &str) -> Result<()> { "forge-make-admin", ))?; - match resp.change { - ForgeAdminChange::Promoted => println!("forge: {name:?} is now a site admin"), - ForgeAdminChange::AlreadyAdmin => println!("forge: {name:?} was already a site admin"), - } - Ok(()) + Ok(match resp.change { + ForgeAdminChange::Promoted => format!("forge: {name:?} is now a site admin"), + ForgeAdminChange::AlreadyAdmin => format!("forge: {name:?} was already a site admin"), + }) } diff --git a/swarmctl/src/interactive.rs b/swarmctl/src/interactive.rs new file mode 100644 index 00000000..2f524c74 --- /dev/null +++ b/swarmctl/src/interactive.rs @@ -0,0 +1,1467 @@ +//! `swarmctl setup` with no subcommand — the first-run ceremony of +//! `docs/1-getting-started/setup.md`, walked step by step. +//! +//! [`build_plan`] is pure: it turns the [`Observed`] snapshot of `setup status` +//! into one [`Planned`] per ceremony step, saying which are done, which are +//! to do, and which cannot be done on this host and what deploy option that +//! depends on. [`run`] re-plans after every step, so a rerun resumes where the +//! last run stopped. The terminal and the side effects sit behind [`Ui`] and +//! [`Actions`], so the flow is tested with scripted answers. +//! +//! Secrets pass through [`Ui::show_secret`] and nowhere else, and only into +//! the terminal; nothing here writes them to a file, the environment or a +//! log. + +use std::collections::BTreeSet; +use std::time::Duration; + +use anyhow::{Result, anyhow, bail}; +use zeroize::Zeroizing; + +use crate::setup::{Observed, Status, classify}; + +/// What this host has of the secret store. +#[derive(Clone, Copy, Debug)] +pub(crate) struct StoreHost { + /// `bao` is on `PATH`, which the nix module arranges where the store runs. + pub(crate) bao_cli: bool, + /// `swarm-bao-granter-role` exists here, and with it the other granting + /// units. + pub(crate) granter_unit: bool, + /// The nix module gave `swarmctl` the bootstrap token path and policy. + pub(crate) grant_configured: bool, +} + +/// What this host has, as far as the steps' tools go. Detected once. +#[derive(Clone, Copy, Debug)] +pub(crate) struct HostFacts { + pub(crate) store: StoreHost, + /// The nix module gave `swarmctl` authelia's binary and users file. + pub(crate) sso: bool, + /// The controller's socket, or a `--swarm`, is known. + pub(crate) controller: bool, +} + +/// What a step does when it is not done yet. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Task { + Init, + /// `by_hand`: a `shamir` seal, which only a person's keys open. + Unseal { + by_hand: bool, + }, + Grant, + GrantingUnits, + Reunseal { + by_hand: bool, + }, + SsoAccount, + Login, + ForgeAdmin, + /// `exists`: she is in the roster already, so she is not created again. + Ruth { + exists: bool, + }, + OtherAgent, +} + +/// The deploy option a step needs, and where to run `swarmctl setup` to get it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct NotHere { + pub(crate) option: &'static str, + pub(crate) host: &'static str, +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum Disposition { + Done(String), + /// The text says why the check found it open. + Todo(String), + NotHere(NotHere), +} + +#[derive(Debug)] +pub(crate) struct Planned { + pub(crate) number: u8, + pub(crate) title: &'static str, + pub(crate) task: Task, + pub(crate) optional: bool, + pub(crate) disposition: Disposition, +} + +const NEEDS_BAO: NotHere = NotHere { + option: "services.hyperhive.deploy.bao.enable", + host: "the host running swarm-bao", +}; +const NEEDS_BOOTSTRAP: NotHere = NotHere { + option: "services.hyperhive.deploy.bao.bootstrapTokenFile", + host: "the host running swarm-bao", +}; +const NEEDS_SSO: NotHere = NotHere { + option: "services.hyperhive.deploy.authelia.enable", + host: "the host running authelia", +}; +const NEEDS_CONTROLLER: NotHere = NotHere { + option: "services.hyperhive.deploy.swarm-controller.enable", + host: "the host running swarm-controller, or pass --swarm", +}; + +/// Why `task` cannot run on a host with `host`, if it cannot. +fn requires(task: Task, host: HostFacts) -> Option { + let missing = match task { + Task::Init | Task::Unseal { .. } => !host.store.bao_cli, + Task::Reunseal { .. } | Task::Grant => !host.store.bao_cli || !host.store.granter_unit, + Task::GrantingUnits => !host.store.granter_unit, + Task::SsoAccount => return (!host.sso).then_some(NEEDS_SSO), + Task::ForgeAdmin | Task::Ruth { .. } | Task::OtherAgent => { + return (!host.controller).then_some(NEEDS_CONTROLLER); + } + Task::Login => return None, + }; + if missing { + return Some(NEEDS_BAO); + } + (task == Task::Grant && !host.store.grant_configured).then_some(NEEDS_BOOTSTRAP) +} + +/// One entry per ceremony step, in order. +/// +/// A step the check found done stays done wherever it is run. Otherwise a +/// step this host lacks the tools for is [`Disposition::NotHere`], and the +/// rest are to do, including those whose check could not run: every verb +/// behind them refuses a repeat or answers with why it cannot. +pub(crate) fn build_plan(observed: &Observed, host: HostFacts) -> Vec { + let by_hand = observed.store.as_ref().is_ok_and(|s| s.seal == "shamir"); + let ruth_exists = observed + .agents + .as_ref() + .is_ok_and(|a| a.rows.iter().any(|r| r.name == "ruth")); + let tasks = [ + Task::Init, + Task::Unseal { by_hand }, + Task::Grant, + Task::GrantingUnits, + Task::Reunseal { by_hand }, + Task::SsoAccount, + Task::Login, + Task::ForgeAdmin, + Task::Ruth { + exists: ruth_exists, + }, + Task::OtherAgent, + ]; + classify(observed) + .into_iter() + .zip(tasks) + .map(|(step, task)| { + let disposition = match step.status { + Status::Done(detail) => Disposition::Done(detail), + Status::NotDone(why) | Status::Unknown(why) | Status::Unobservable(why) => { + match requires(task, host) { + Some(not_here) => Disposition::NotHere(not_here), + None => Disposition::Todo(why), + } + } + }; + Planned { + number: step.number, + title: step.title, + task, + optional: step.optional, + disposition, + } + }) + .collect() +} + +/// The first step still to do, that the operator has not skipped or finished. +pub(crate) fn next_step<'a>(plan: &'a [Planned], handled: &BTreeSet) -> Option<&'a Planned> { + plan.iter() + .find(|p| matches!(p.disposition, Disposition::Todo(_)) && !handled.contains(&p.number)) +} + +/// How many steps are left to do, the optional ones aside. +pub(crate) fn steps_left(plan: &[Planned], handled: &BTreeSet) -> usize { + plan.iter() + .filter(|p| { + !p.optional + && matches!(p.disposition, Disposition::Todo(_)) + && !handled.contains(&p.number) + }) + .count() +} + +/// How a line is drawn. The terminal maps each to a colour. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Tone { + Plain, + Heading, + Good, + Dim, + /// Something the operator must act on with care; the text carries ⚠️. + Warn, + Bad, +} + +/// Every step as one line, then how many are left. +pub(crate) fn overview(plan: &[Planned], handled: &BTreeSet) -> Vec<(Tone, String)> { + let mut lines = vec![( + Tone::Heading, + format!( + "swarmctl setup: {} steps, {} left to do", + plan.len(), + steps_left(plan, handled) + ), + )]; + for p in plan { + let optional = if p.optional { " (optional)" } else { "" }; + let line = match &p.disposition { + Disposition::Done(_) => ( + Tone::Good, + format!(" ✔ {:>2} {}{optional}", p.number, p.title), + ), + Disposition::Todo(_) => ( + Tone::Plain, + format!(" ○ {:>2} {}{optional}", p.number, p.title), + ), + Disposition::NotHere(n) => ( + Tone::Warn, + format!( + " ⛔ {:>2} {}{optional}: not on this host. Needs `{} = true`; run `swarmctl setup` on {}", + p.number, p.title, n.option, n.host + ), + ), + }; + lines.push(line); + } + lines +} + +/// The terminal, or a script standing in for one. +pub(crate) trait Ui { + fn say(&mut self, tone: Tone, text: &str); + /// Secret text, written to the terminal as is. The only way a key or a + /// password is shown. + fn show_secret(&mut self, text: &str) -> Result<()>; + /// One line of input, without its newline. Fails when input is closed. + fn ask(&mut self, prompt: &str) -> Result; + /// One line of input that is not echoed. + fn ask_secret(&mut self, prompt: &str) -> Result>; + /// Runs `f` while showing `what` as one live line, which is gone when it + /// returns. + fn working(&mut self, what: &str, f: impl FnOnce() -> T) -> T; +} + +/// What `swarmctl bao init` printed, kept for the operator to record. +pub(crate) struct Initialised { + /// Each key and the root token on a labelled line. + pub(crate) text: Zeroizing, + pub(crate) root_token: Zeroizing, +} + +/// What a new SSO account is made from. +pub(crate) struct NewUser { + pub(crate) username: String, + pub(crate) display_name: String, + pub(crate) email: String, +} + +/// Everything setup does to the host or the swarm, and every check it reads. +pub(crate) trait Actions { + fn host(&self) -> HostFacts; + fn observe(&mut self) -> Observed; + fn pause(&mut self, how_long: Duration); + fn init_store(&mut self) -> Result; + fn wait_unsealed(&mut self) -> Result<()>; + fn grant_bootstrap(&mut self, root_token: &str) -> Result; + /// Returns the generated password. + fn add_user(&mut self, user: &NewUser) -> Result>; + fn make_admin(&mut self, name: &str) -> Result; + /// `ruth`, with the permissions of a manager, declared `up`. + fn declare_ruth(&mut self, hive: &str, exists: bool) -> Result; + fn create_agent(&mut self, name: &str, hive: &str) -> Result; +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum Outcome { + /// Every step was walked; `open` counts those still to do. + Finished { open: usize }, + /// The operator quit. + Stopped, +} + +#[derive(Default)] +struct Session { + /// Held from `init` to the granter bootstrap, in memory only. + root_token: Option>, + user: Option, + hive: Option, +} + +const POLL: Duration = Duration::from_secs(2); +/// The granter unit and the units behind it retry every 30 seconds. +const UNITS_WAIT: Duration = Duration::from_secs(90); + +/// How long a step may take to show as done after its action returned. +fn settle(task: Task) -> Duration { + match task { + Task::Grant => UNITS_WAIT, + Task::Ruth { .. } => Duration::from_mins(1), + _ => Duration::ZERO, + } +} + +/// Whether the step's check can show it done afterwards. The browser login +/// and the forge admin leave nothing to read, and a new agent appears with +/// the job queue. +fn observable(task: Task) -> bool { + !matches!(task, Task::Login | Task::ForgeAdmin | Task::OtherAgent) +} + +/// Walk the ceremony. +pub(crate) fn run(ui: &mut impl Ui, actions: &mut impl Actions) -> Result { + let host = actions.host(); + let mut session = Session::default(); + let mut handled = BTreeSet::new(); + let mut skipped = BTreeSet::new(); + + let mut plan = look(ui, actions, host, "checking where setup stands"); + for (tone, line) in overview(&plan, &handled) { + ui.say(tone, &line); + } + + while let Some(step) = next_step(&plan, &handled) { + let (number, title, task) = (step.number, step.title, step.task); + let total = plan.len(); + let left = steps_left(&plan, &handled); + ui.say(Tone::Plain, ""); + ui.say( + Tone::Heading, + &format!("Step {number} of {total}: {title} ({left} left, this one included)"), + ); + if let Disposition::Todo(why) = &step.disposition { + ui.say(Tone::Dim, why); + } + loop { + let done = do_task(ui, actions, host, &mut session, &mut plan, (number, task)) + .and_then(|()| verify(ui, actions, host, &mut plan, number, task)); + match done { + Ok(()) => { + handled.insert(number); + break; + } + Err(e) => { + ui.say(Tone::Bad, &format!("✘ {e:#}")); + match ask_next(ui)? { + Next::Retry => {} + Next::Skip => { + handled.insert(number); + skipped.insert(number); + break; + } + Next::Quit => { + ui.say( + Tone::Plain, + "Stopped. Run `swarmctl setup` again to continue where this left off.", + ); + return Ok(Outcome::Stopped); + } + } + } + } + } + plan = look(ui, actions, host, "checking where setup stands"); + } + + Ok(Outcome::Finished { + open: summarise(ui, &plan, &handled, &skipped), + }) +} + +/// Said at the end. Returns how many required steps are still open. +fn summarise( + ui: &mut impl Ui, + plan: &[Planned], + handled: &BTreeSet, + skipped: &BTreeSet, +) -> usize { + ui.say(Tone::Plain, ""); + let mut open = 0; + for p in plan.iter().filter(|p| !p.optional) { + match &p.disposition { + Disposition::Done(_) => {} + // A step nothing can show done stays to do in the plan; it is open + // only when it was skipped or never reached. + Disposition::Todo(_) if handled.contains(&p.number) && !skipped.contains(&p.number) => { + } + Disposition::Todo(_) => { + open += 1; + ui.say( + Tone::Warn, + &format!("⚠️ Step {} ({}) is still open.", p.number, p.title), + ); + } + Disposition::NotHere(n) => { + open += 1; + ui.say( + Tone::Warn, + &format!( + "⛔ Step {} ({}) is for another host: set `{} = true` and run `swarmctl setup` on {}.", + p.number, p.title, n.option, n.host + ), + ); + } + } + } + if open == 0 { + ui.say( + Tone::Good, + "✔ Setup is complete. Day-to-day operation continues in the swarm UI.", + ); + } else { + ui.say( + Tone::Plain, + "Run `swarmctl setup` again to continue, or `swarmctl setup status` to see where it stands.", + ); + } + open +} + +fn look(ui: &mut impl Ui, actions: &mut impl Actions, host: HostFacts, what: &str) -> Vec { + ui.working(what, || build_plan(&actions.observe(), host)) +} + +/// After a step's action: the step shows as done, giving a slow one up to its +/// [`settle`] time. Steps nothing can show are taken as done. +fn verify( + ui: &mut impl Ui, + actions: &mut impl Actions, + host: HostFacts, + plan: &mut Vec, + number: u8, + task: Task, +) -> Result<()> { + if !observable(task) { + return Ok(()); + } + match wait_done(ui, actions, host, plan, number, settle(task)) { + None => Ok(()), + Some(why) => bail!("the check still finds step {number} open: {why}"), + } +} + +/// Checks the step until it shows done or `wait` has passed. `None` when it is +/// done, otherwise what the last check said. +fn wait_done( + ui: &mut impl Ui, + actions: &mut impl Actions, + host: HostFacts, + plan: &mut Vec, + number: u8, + wait: Duration, +) -> Option { + ui.working("checking", || { + let mut waited = Duration::ZERO; + loop { + *plan = build_plan(&actions.observe(), host); + let open = + plan.iter() + .find(|p| p.number == number) + .and_then(|p| match &p.disposition { + Disposition::Todo(why) => Some(why.clone()), + Disposition::NotHere(n) => { + Some(format!("it needs {} on this host", n.option)) + } + Disposition::Done(_) => None, + }); + if open.is_none() || waited >= wait { + return open; + } + actions.pause(POLL); + waited += POLL; + } + }) +} + +enum Next { + Retry, + Skip, + Quit, +} + +fn ask_next(ui: &mut impl Ui) -> Result { + loop { + let answer = ui.ask("[r]etry, [s]kip this step, or [q]uit? (Enter retries) ")?; + match answer.trim().to_lowercase().as_str() { + "" | "r" | "retry" => return Ok(Next::Retry), + "s" | "skip" => return Ok(Next::Skip), + "q" | "quit" => return Ok(Next::Quit), + _ => ui.say(Tone::Dim, "Answer r, s or q."), + } + } +} + +/// Blocks until the operator types `yes`. Input closing before that is an +/// error, so no later step runs on a guess. +fn confirm_stored(ui: &mut impl Ui, what: &str) -> Result<()> { + loop { + let answer = ui.ask(&format!("Type `yes` once you have stored {what}: "))?; + if answer.trim().eq_ignore_ascii_case("yes") { + return Ok(()); + } + ui.say( + Tone::Warn, + &format!("⚠️ Not confirmed. Nothing else happens until you have stored {what}."), + ); + } +} + +/// Asks until `valid` accepts the answer. +fn ask_valid( + ui: &mut impl Ui, + prompt: &str, + default: Option<&str>, + valid: impl Fn(&str) -> Result<()>, +) -> Result { + let shown = match default { + Some(d) => format!("{prompt} [{d}]: "), + None => format!("{prompt}: "), + }; + loop { + let answer = ui.ask(&shown)?; + let answer = answer.trim(); + let answer = if answer.is_empty() { + default.unwrap_or("") + } else { + answer + }; + match valid(answer) { + Ok(()) => return Ok(answer.to_owned()), + Err(e) => ui.say(Tone::Bad, &format!("{e:#}")), + } + } +} + +fn manual(ui: &mut impl Ui, lines: &[&str]) -> Result<()> { + for line in lines { + ui.say(Tone::Plain, line); + } + ui.ask("Press Enter when done, to check again: ").map(drop) +} + +fn do_task( + ui: &mut impl Ui, + actions: &mut impl Actions, + host: HostFacts, + session: &mut Session, + plan: &mut Vec, + (number, task): (u8, Task), +) -> Result<()> { + match task { + Task::Init => init(ui, actions, session), + Task::Unseal { by_hand: false } | Task::Reunseal { by_hand: false } => { + ui.working("waiting for the store to unseal itself", || { + actions.wait_unsealed() + })?; + ui.say(Tone::Good, "✔ the store is unsealed"); + Ok(()) + } + Task::Unseal { by_hand: true } | Task::Reunseal { by_hand: true } => manual( + ui, + &[ + "No verb unseals a shamir-sealed store yet. In another terminal, as root, run this", + "until it reports the threshold met (it asks for one key per run, input hidden):", + "", + " bao operator unseal", + "", + ], + ), + Task::Grant => grant(ui, actions, session), + Task::GrantingUnits => { + let waiting = wait_done(ui, actions, host, plan, number, UNITS_WAIT); + if waiting.is_none() { + return Ok(()); + } + manual( + ui, + &[ + "The granting units retry on their own every 30 seconds and are not all active", + "yet. To restart them now, in another terminal, as root:", + "", + " systemctl reset-failed 'swarm-bao-*-policy.service' swarm-bao-agent-pki.service", + " systemctl restart 'swarm-bao-*-policy.service' swarm-bao-agent-pki.service", + "", + ], + ) + } + Task::SsoAccount => sso_account(ui, actions, session), + Task::Login => manual( + ui, + &[ + "Open https:/// in a browser and sign in with the account from the", + "last step. Then sign in to the forge once through SSO, which creates your forge", + "account. Nothing on this host can show that you did.", + ], + ), + Task::ForgeAdmin => forge_admin(ui, actions, session), + Task::Ruth { exists } => ruth(ui, actions, session, exists), + Task::OtherAgent => other_agent(ui, actions, session), + } +} + +fn init(ui: &mut impl Ui, actions: &mut impl Actions, session: &mut Session) -> Result<()> { + let initialised = ui.working("initialising the secret store", || actions.init_store())?; + ui.say( + Tone::Warn, + "⚠️ The unseal or recovery keys and the root token follow.", + ); + ui.say( + Tone::Warn, + "⚠️ They are shown once. swarmctl stores none of them and cannot show them again.", + ); + ui.say( + Tone::Warn, + "⚠️ Record them somewhere off this host, in a password manager or on paper, now.", + ); + ui.say(Tone::Plain, ""); + ui.show_secret(&initialised.text)?; + ui.say(Tone::Plain, ""); + session.root_token = Some(initialised.root_token); + confirm_stored(ui, "the keys and the root token off this host") +} + +fn grant(ui: &mut impl Ui, actions: &mut impl Actions, session: &mut Session) -> Result<()> { + if session.root_token.is_none() { + ui.say( + Tone::Warn, + "⚠️ This step needs the root token printed when the store was initialised.", + ); + session.root_token = Some(ui.ask_secret("Root token (input hidden): ")?); + } + let root = session + .root_token + .clone() + .ok_or_else(|| anyhow!("no root token"))?; + let said = ui.working("bootstrapping the granter", || { + actions.grant_bootstrap(&root) + })?; + // The grant token it placed is the granter's from here on. + session.root_token = None; + ui.say(Tone::Good, &format!("✔ {said}")); + ui.say( + Tone::Warn, + "⚠️ The root token is not needed again. Keep it off this host, or revoke it if you never will.", + ); + Ok(()) +} + +fn sso_account(ui: &mut impl Ui, actions: &mut impl Actions, session: &mut Session) -> Result<()> { + ui.say( + Tone::Plain, + "Your SSO account is made in the `admins` group, which the swarm UI gates on.", + ); + let username = ask_valid(ui, "Username", session.user.as_deref(), |name| { + crate::users::validate_username(name) + })?; + let display_name = ask_valid(ui, "Display name", Some(&username), |_| Ok(()))?; + let email = ask_valid(ui, "Email address (the forge needs one)", None, |a| { + if a.contains('@') && !a.contains(char::is_whitespace) { + Ok(()) + } else { + bail!("that is not an email address") + } + })?; + let user = NewUser { + username, + display_name, + email, + }; + let password = ui.working("creating the SSO account", || actions.add_user(&user))?; + ui.say(Tone::Good, &format!("✔ added {}", user.username)); + ui.say( + Tone::Warn, + "⚠️ Its generated password follows. It is shown once and stored nowhere.", + ); + ui.say(Tone::Plain, ""); + ui.show_secret(&Zeroizing::new(format!("password: {}\n", *password)))?; + ui.say(Tone::Plain, ""); + session.user = Some(user.username); + confirm_stored(ui, "the password") +} + +fn forge_admin(ui: &mut impl Ui, actions: &mut impl Actions, session: &mut Session) -> Result<()> { + let name = match &session.user { + Some(name) => name.clone(), + None => ask_valid(ui, "Forge username (your SSO username)", None, |n| { + crate::agent::parse_ident(n, "user name").map(drop) + })?, + }; + let said = ui + .working("making the forge account a site admin", || { + actions.make_admin(&name) + }) + .map_err(|e| { + e.context(format!( + "{name:?} may not have signed in to the forge yet: do that once, then retry" + )) + })?; + ui.say(Tone::Good, &format!("✔ {said}")); + Ok(()) +} + +fn ruth( + ui: &mut impl Ui, + actions: &mut impl Actions, + session: &mut Session, + exists: bool, +) -> Result<()> { + ui.say( + Tone::Plain, + "ruth is the manager agent. She is created with `manage_root_agent` and set `up`.", + ); + let hive = ask_valid( + ui, + "Hive to run her on, for example firsthive", + session.hive.as_deref(), + |h| crate::agent::parse_ident(h, "hive").map(drop), + )?; + let said = ui.working("creating ruth", || actions.declare_ruth(&hive, exists))?; + session.hive = Some(hive); + ui.say(Tone::Good, &format!("✔ {said}")); + Ok(()) +} + +fn other_agent(ui: &mut impl Ui, actions: &mut impl Actions, session: &mut Session) -> Result<()> { + let answer = ui.ask("Create another agent now? [y/N]: ")?; + if !matches!(answer.trim().to_lowercase().as_str(), "y" | "yes") { + return Ok(()); + } + let name = ask_valid(ui, "Agent name", None, |n| { + crate::agent::parse_ident(n, "agent name").map(drop) + })?; + let hive = ask_valid(ui, "Hive", session.hive.as_deref(), |h| { + crate::agent::parse_ident(h, "hive").map(drop) + })?; + let said = ui.working("creating the agent", || actions.create_agent(&name, &hive))?; + session.hive = Some(hive); + ui.say(Tone::Good, &format!("✔ {said}")); + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::collections::VecDeque; + use std::rc::Rc; + + use super::*; + use crate::setup::{AgentRow, Agents, Granter, Store, Unit}; + + const ALL: HostFacts = HostFacts { + store: StoreHost { + bao_cli: true, + granter_unit: true, + grant_configured: true, + }, + sso: true, + controller: true, + }; + + #[derive(Clone, Copy, PartialEq, Eq)] + enum StoreState { + Uninitialised, + Sealed, + Open, + } + + #[derive(Clone, Copy, PartialEq, Eq)] + enum GrantState { + None, + /// The grant token is placed; the granting units have not retried yet. + Placed, + Active, + } + + /// What a half-built host looks like, mutated by the fake actions. + #[derive(Clone)] + struct World { + store: StoreState, + seal: &'static str, + grant: GrantState, + user: bool, + ruth: Option<(bool, Option<&'static str>)>, + others: usize, + } + + impl World { + fn fresh() -> Self { + Self { + store: StoreState::Uninitialised, + seal: "pkcs11", + grant: GrantState::None, + user: false, + ruth: None, + others: 0, + } + } + + fn observed(&self) -> Observed { + let mut rows = Vec::new(); + if let Some((_, wanted)) = self.ruth { + rows.push(AgentRow { + name: "ruth".to_owned(), + wanted: wanted.map(str::to_owned), + }); + } + for i in 0..self.others { + rows.push(AgentRow { + name: format!("iris{i}"), + wanted: Some("up".to_owned()), + }); + } + Observed { + store: Ok(Store { + initialized: self.store != StoreState::Uninitialised, + sealed: self.store != StoreState::Open, + seal: self.seal.to_owned(), + }), + granter: Ok(Granter { + state: if self.grant == GrantState::None { + "inactive" + } else { + "active" + } + .to_owned(), + uploaded: self.grant != GrantState::None, + }), + policy_units: Ok(vec![Unit { + name: "swarm-bao-controller-policy.service".to_owned(), + state: if self.grant == GrantState::Active { + "active" + } else { + "failed" + } + .to_owned(), + }]), + admin_user: Ok(self.user), + agents: Ok(Agents { + rows, + ruth_manages_root: self.ruth.map(|(manage, _)| Ok(manage)), + }), + } + } + } + + type Log = Rc>>; + + /// Actions on a [`World`], logging each call. + struct Fake { + world: World, + host: HostFacts, + log: Log, + /// Fail the next `add_user` once. + add_user_fails: bool, + } + + impl Fake { + fn new(world: World, log: &Log) -> Self { + Self { + world, + host: ALL, + log: Rc::clone(log), + add_user_fails: false, + } + } + + fn note(&self, call: &str) { + self.log.borrow_mut().push(call.to_owned()); + } + } + + impl Actions for Fake { + fn host(&self) -> HostFacts { + self.host + } + + fn observe(&mut self) -> Observed { + self.world.observed() + } + + fn pause(&mut self, _: Duration) { + if self.world.grant == GrantState::Placed { + self.world.grant = GrantState::Active; + } + } + + fn init_store(&mut self) -> Result { + self.note("init"); + self.world.store = StoreState::Sealed; + Ok(Initialised { + text: Zeroizing::new( + "Recovery key 1: RK1\nInitial root token: hvs.ROOT\n".to_owned(), + ), + root_token: Zeroizing::new("hvs.ROOT".to_owned()), + }) + } + + fn wait_unsealed(&mut self) -> Result<()> { + self.note("wait_unsealed"); + self.world.store = StoreState::Open; + Ok(()) + } + + fn grant_bootstrap(&mut self, root_token: &str) -> Result { + self.note(&format!("grant:{root_token}")); + self.world.grant = GrantState::Placed; + Ok("granter bootstrap started".to_owned()) + } + + fn add_user(&mut self, user: &NewUser) -> Result> { + self.note(&format!("add_user:{}:{}", user.username, user.email)); + if std::mem::take(&mut self.add_user_fails) { + bail!("authelia failed"); + } + self.world.user = true; + Ok(Zeroizing::new("PW123".to_owned())) + } + + fn make_admin(&mut self, name: &str) -> Result { + self.note(&format!("make_admin:{name}")); + Ok(format!("forge: {name:?} is now a site admin")) + } + + fn declare_ruth(&mut self, hive: &str, exists: bool) -> Result { + self.note(&format!("ruth:{hive}:exists={exists}")); + self.world.ruth = Some((true, Some("up"))); + Ok("ruth is declared up".to_owned()) + } + + fn create_agent(&mut self, name: &str, hive: &str) -> Result { + self.note(&format!("create_agent:{name}:{hive}")); + self.world.others += 1; + Ok("queued".to_owned()) + } + } + + /// A [`Fake`] whose `declare_ruth` reports success and changes nothing. + struct Lying(Fake); + + impl Actions for Lying { + fn host(&self) -> HostFacts { + self.0.host() + } + fn observe(&mut self) -> Observed { + self.0.observe() + } + fn pause(&mut self, how_long: Duration) { + self.0.pause(how_long); + } + fn init_store(&mut self) -> Result { + self.0.init_store() + } + fn wait_unsealed(&mut self) -> Result<()> { + self.0.wait_unsealed() + } + fn grant_bootstrap(&mut self, root_token: &str) -> Result { + self.0.grant_bootstrap(root_token) + } + fn add_user(&mut self, user: &NewUser) -> Result> { + self.0.add_user(user) + } + fn make_admin(&mut self, name: &str) -> Result { + self.0.make_admin(name) + } + fn declare_ruth(&mut self, _: &str, _: bool) -> Result { + Ok("declared".to_owned()) + } + fn create_agent(&mut self, name: &str, hive: &str) -> Result { + self.0.create_agent(name, hive) + } + } + + /// Answers from a script; closed input once it runs out. + struct Script { + answers: VecDeque<&'static str>, + log: Log, + said: Vec<(Tone, String)>, + shown: Vec, + secret_asks: usize, + } + + impl Script { + fn new(answers: &[&'static str], log: &Log) -> Self { + Self { + answers: answers.iter().copied().collect(), + log: Rc::clone(log), + said: Vec::new(), + shown: Vec::new(), + secret_asks: 0, + } + } + } + + impl Ui for Script { + fn say(&mut self, tone: Tone, text: &str) { + self.said.push((tone, text.to_owned())); + } + + fn show_secret(&mut self, text: &str) -> Result<()> { + self.log.borrow_mut().push("show_secret".to_owned()); + self.shown.push(text.to_owned()); + Ok(()) + } + + fn ask(&mut self, prompt: &str) -> Result { + self.log.borrow_mut().push(format!("ask:{prompt}")); + self.answers + .pop_front() + .map(str::to_owned) + .ok_or_else(|| anyhow!("input closed")) + } + + fn ask_secret(&mut self, prompt: &str) -> Result> { + self.log.borrow_mut().push(format!("ask_secret:{prompt}")); + self.secret_asks += 1; + self.answers + .pop_front() + .map(|a| Zeroizing::new(a.to_owned())) + .ok_or_else(|| anyhow!("input closed")) + } + + fn working(&mut self, _: &str, f: impl FnOnce() -> T) -> T { + f() + } + } + + fn log() -> Log { + Rc::new(RefCell::new(Vec::new())) + } + + fn calls(log: &Log, prefix: &str) -> Vec { + log.borrow() + .iter() + .filter(|l| l.starts_with(prefix)) + .cloned() + .collect() + } + + fn position(log: &Log, call: &str) -> Option { + log.borrow().iter().position(|l| l == call) + } + + fn dispositions(plan: &[Planned]) -> Vec<&Disposition> { + plan.iter().map(|p| &p.disposition).collect() + } + + #[test] + fn a_fresh_host_has_every_step_to_do() { + let plan = build_plan(&World::fresh().observed(), ALL); + assert_eq!(plan.len(), 10); + assert!( + plan.iter() + .all(|p| matches!(p.disposition, Disposition::Todo(_))), + "{:?}", + dispositions(&plan) + ); + assert_eq!(plan[0].task, Task::Init); + assert_eq!(plan[8].task, Task::Ruth { exists: false }); + assert!(plan[9].optional); + assert_eq!(steps_left(&plan, &BTreeSet::new()), 9); + } + + #[test] + fn a_rerun_after_a_partial_run_skips_what_is_done() { + let world = World { + store: StoreState::Open, + grant: GrantState::Active, + ..World::fresh() + }; + let plan = build_plan(&world.observed(), ALL); + let done: Vec = plan + .iter() + .filter(|p| matches!(p.disposition, Disposition::Done(_))) + .map(|p| p.number) + .collect(); + assert_eq!(done, [1, 2, 3, 4, 5]); + let first = next_step(&plan, &BTreeSet::new()).expect("a step is left"); + assert_eq!(first.number, 6); + assert_eq!(first.task, Task::SsoAccount); + assert_eq!(steps_left(&plan, &BTreeSet::new()), 4); + } + + #[test] + fn a_step_the_host_cannot_do_names_the_deploy_option() { + let no_sso = HostFacts { sso: false, ..ALL }; + let plan = build_plan(&World::fresh().observed(), no_sso); + assert_eq!( + plan[5].disposition, + Disposition::NotHere(NEEDS_SSO), + "{:?}", + plan[5] + ); + // The control: the same step on a host that has authelia is to do. + let plan = build_plan(&World::fresh().observed(), ALL); + assert!(matches!(plan[5].disposition, Disposition::Todo(_))); + assert!(NEEDS_SSO.option.ends_with("authelia.enable")); + assert_eq!(NEEDS_SSO.host, "the host running authelia"); + } + + #[test] + fn the_secret_store_steps_need_the_bao_options() { + let bare = HostFacts { + store: StoreHost { + bao_cli: false, + granter_unit: false, + grant_configured: false, + }, + ..ALL + }; + let plan = build_plan(&World::fresh().observed(), bare); + for step in &plan[..5] { + assert_eq!( + step.disposition, + Disposition::NotHere(NEEDS_BAO), + "step {}", + step.number + ); + } + // The steps after the store stay to do. + assert!(matches!(plan[5].disposition, Disposition::Todo(_))); + + let unconfigured = HostFacts { + store: StoreHost { + grant_configured: false, + ..ALL.store + }, + ..ALL + }; + let plan = build_plan(&World::fresh().observed(), unconfigured); + assert_eq!(plan[2].disposition, Disposition::NotHere(NEEDS_BOOTSTRAP)); + assert!(matches!(plan[0].disposition, Disposition::Todo(_))); + } + + #[test] + fn a_done_step_stays_done_on_a_host_that_lacks_its_tools() { + let world = World { + store: StoreState::Open, + ..World::fresh() + }; + let bare = HostFacts { + store: StoreHost { + bao_cli: false, + ..ALL.store + }, + ..ALL + }; + let plan = build_plan(&world.observed(), bare); + assert!(matches!(plan[0].disposition, Disposition::Done(_))); + } + + #[test] + fn a_shamir_seal_is_unsealed_by_hand() { + let world = World { + seal: "shamir", + store: StoreState::Sealed, + ..World::fresh() + }; + let plan = build_plan(&world.observed(), ALL); + assert_eq!(plan[1].task, Task::Unseal { by_hand: true }); + let plan = build_plan(&World::fresh().observed(), ALL); + assert_eq!(plan[1].task, Task::Unseal { by_hand: false }); + } + + #[test] + fn the_next_step_skips_what_was_handled() { + let plan = build_plan(&World::fresh().observed(), ALL); + let mut handled = BTreeSet::new(); + assert_eq!(next_step(&plan, &handled).map(|p| p.number), Some(1)); + handled.insert(1); + assert_eq!(next_step(&plan, &handled).map(|p| p.number), Some(2)); + } + + #[test] + fn the_overview_marks_each_state_and_counts_what_is_left() { + let no_sso = HostFacts { sso: false, ..ALL }; + let world = World { + store: StoreState::Open, + ..World::fresh() + }; + let lines = overview(&build_plan(&world.observed(), no_sso), &BTreeSet::new()); + assert_eq!(lines[0].1, "swarmctl setup: 10 steps, 6 left to do"); + assert!(lines[1].1.contains('✔')); + assert!(lines[3].1.contains('○')); + let not_here = &lines[6].1; + assert!( + not_here.contains("services.hyperhive.deploy.authelia.enable") + && not_here.contains("the host running authelia"), + "{not_here}" + ); + } + + /// The answers a fresh pkcs11 host takes from the first step to the last. + const FULL_RUN: &[&str] = &[ + "yes", // keys stored + "mara", + "", + "mara@example.com", // account + "yes", // password stored + "", // signed in + "firsthive", // ruth's hive + "n", // no other agent + ]; + + #[test] + fn a_fresh_run_walks_every_step_in_order_and_reuses_the_root_token() { + let log = log(); + let mut actions = Fake::new(World::fresh(), &log); + let mut ui = Script::new(FULL_RUN, &log); + let outcome = run(&mut ui, &mut actions).expect("the run finishes"); + assert_eq!(outcome, Outcome::Finished { open: 0 }); + let order: Vec = log + .borrow() + .iter() + .filter(|l| !l.starts_with("ask:") && *l != "show_secret") + .cloned() + .collect(); + assert_eq!( + order, + [ + "init", + "wait_unsealed", + "grant:hvs.ROOT", + "add_user:mara:mara@example.com", + "make_admin:mara", + "ruth:firsthive:exists=false", + ] + ); + assert_eq!( + ui.secret_asks, 0, + "the root token from init is not asked for again" + ); + assert!(ui.said.iter().any(|(_, t)| t.starts_with("Step 1 of 10"))); + assert!(ui.said.iter().any(|(_, t)| t.starts_with("Step 9 of 10"))); + } + + #[test] + fn nothing_after_the_keys_runs_until_the_operator_types_yes() { + let log = log(); + let mut actions = Fake::new(World::fresh(), &log); + let mut script = vec!["no", "", "stored", "yes"]; + script.extend(&FULL_RUN[1..]); + let mut ui = Script::new(&script, &log); + run(&mut ui, &mut actions).expect("the run finishes"); + let gate: Vec = log + .borrow() + .iter() + .enumerate() + .filter(|(_, l)| l.starts_with("ask:Type `yes` once you have stored the keys")) + .map(|(i, _)| i) + .collect(); + assert_eq!(gate.len(), 4, "asked until `yes`"); + let first_after = position(&log, "wait_unsealed").expect("the run went on"); + assert!( + first_after > gate[3], + "wait_unsealed ran before the confirmation" + ); + assert!(position(&log, "grant:hvs.ROOT").expect("granted") > gate[3]); + } + + #[test] + fn closing_input_at_the_gate_stops_the_run_before_anything_else() { + let log = log(); + let mut actions = Fake::new(World::fresh(), &log); + let mut ui = Script::new(&["no"], &log); + let err = run(&mut ui, &mut actions).expect_err("the gate holds"); + assert!(format!("{err:#}").contains("input closed")); + assert_eq!(calls(&log, "init").len(), 1); + assert!(calls(&log, "grant").is_empty()); + assert!(calls(&log, "wait_unsealed").is_empty()); + } + + #[test] + fn keys_and_passwords_reach_only_show_secret() { + let log = log(); + let mut actions = Fake::new(World::fresh(), &log); + let mut ui = Script::new(FULL_RUN, &log); + run(&mut ui, &mut actions).expect("the run finishes"); + assert_eq!(ui.shown.len(), 2); + assert!(ui.shown[0].contains("RK1") && ui.shown[0].contains("hvs.ROOT")); + assert!(ui.shown[1].contains("PW123")); + for (_, said) in &ui.said { + assert!( + !said.contains("RK1") && !said.contains("hvs.ROOT") && !said.contains("PW123"), + "a secret was said: {said}" + ); + } + // Each is announced with a warning before it is shown. + let warns = ui + .said + .iter() + .filter(|(tone, t)| *tone == Tone::Warn && t.contains("⚠️")) + .count(); + assert!(warns >= 4, "{warns} warnings"); + } + + #[test] + fn a_rerun_runs_only_the_steps_that_are_not_done() { + let log = log(); + let world = World { + store: StoreState::Open, + grant: GrantState::Active, + ..World::fresh() + }; + let mut actions = Fake::new(world, &log); + let mut ui = Script::new( + &["mara", "", "mara@example.com", "yes", "", "firsthive", "n"], + &log, + ); + run(&mut ui, &mut actions).expect("the run finishes"); + assert!(calls(&log, "init").is_empty()); + assert!(calls(&log, "wait_unsealed").is_empty()); + assert!(calls(&log, "grant").is_empty()); + assert_eq!(calls(&log, "add_user").len(), 1); + assert!(ui.said.iter().any(|(_, t)| t.starts_with("Step 6 of 10"))); + assert!(!ui.said.iter().any(|(_, t)| t.starts_with("Step 1 of 10"))); + } + + #[test] + fn the_root_token_is_asked_for_when_init_was_an_earlier_run() { + let log = log(); + let world = World { + store: StoreState::Open, + ..World::fresh() + }; + let mut actions = Fake::new(world, &log); + let mut ui = Script::new( + &[ + "hvs.TYPED", + "mara", + "", + "mara@example.com", + "yes", + "", + "firsthive", + "n", + ], + &log, + ); + run(&mut ui, &mut actions).expect("the run finishes"); + assert_eq!(ui.secret_asks, 1); + assert_eq!(calls(&log, "grant"), ["grant:hvs.TYPED"]); + } + + #[test] + fn a_failed_step_can_be_retried() { + let log = log(); + let mut actions = Fake::new(World::fresh(), &log); + actions.add_user_fails = true; + let mut ui = Script::new( + &[ + "yes", // keys + "mara", + "", + "mara@example.com", // first try fails + "r", + "mara", + "", + "mara@example.com", // second try + "yes", + "", + "firsthive", + "n", + ], + &log, + ); + let outcome = run(&mut ui, &mut actions).expect("the run finishes"); + assert_eq!(outcome, Outcome::Finished { open: 0 }); + assert_eq!(calls(&log, "add_user").len(), 2); + assert!( + ui.said + .iter() + .any(|(t, s)| *t == Tone::Bad && s.contains("authelia failed")) + ); + } + + #[test] + fn skipping_a_failed_step_leaves_it_open_in_the_summary() { + let log = log(); + let mut actions = Fake::new(World::fresh(), &log); + actions.add_user_fails = true; + let mut ui = Script::new( + &[ + "yes", + "mara", + "", + "mara@example.com", + "s", + "", + "mara", + "firsthive", + "n", + ], + &log, + ); + let outcome = run(&mut ui, &mut actions).expect("the run finishes"); + assert_eq!(outcome, Outcome::Finished { open: 1 }); + assert!( + ui.said + .iter() + .any(|(_, t)| t.starts_with("⚠️ Step 6") && t.contains("still open")) + ); + } + + #[test] + fn quitting_stops_without_touching_later_steps() { + let log = log(); + let mut actions = Fake::new(World::fresh(), &log); + actions.add_user_fails = true; + let mut ui = Script::new(&["yes", "mara", "", "mara@example.com", "q"], &log); + assert_eq!( + run(&mut ui, &mut actions).expect("stopped"), + Outcome::Stopped + ); + assert!(calls(&log, "make_admin").is_empty()); + } + + #[test] + fn steps_for_another_host_are_reported_not_attempted() { + let log = log(); + let mut actions = Fake::new(World::fresh(), &log); + actions.host = HostFacts { sso: false, ..ALL }; + let mut ui = Script::new(&["yes", "", "mara", "firsthive", "n"], &log); + let outcome = run(&mut ui, &mut actions).expect("the run finishes"); + assert_eq!(outcome, Outcome::Finished { open: 1 }); + assert!(calls(&log, "add_user").is_empty()); + assert!(ui.said.iter().any(|(_, t)| { + t.starts_with("⛔ Step 6") && t.contains("services.hyperhive.deploy.authelia.enable") + })); + } + + #[test] + fn a_step_that_the_check_still_finds_open_is_not_taken_as_done() { + let log = log(); + // `declare_ruth` that changes nothing the roster shows. + let mut lying = Lying(Fake::new(World::fresh(), &log)); + let mut ui = Script::new( + &[ + "yes", + "mara", + "", + "mara@example.com", + "yes", + "", + "firsthive", + "s", + "n", + ], + &log, + ); + let outcome = run(&mut ui, &mut lying).expect("the run finishes"); + assert_eq!(outcome, Outcome::Finished { open: 1 }); + assert!( + ui.said + .iter() + .any(|(t, s)| *t == Tone::Bad && s.contains("still finds step 9 open")) + ); + } +} diff --git a/swarmctl/src/main.rs b/swarmctl/src/main.rs index 6b37f306..2fab4469 100644 --- a/swarmctl/src/main.rs +++ b/swarmctl/src/main.rs @@ -20,7 +20,9 @@ //! reach it over its unix socket on its host, or, with `--swarm`, through //! the swarm domain's gateway as the operator's own SSO account //! ([`remote`]). The `user` verbs act on the local users file only; the `bao` -//! verbs run the `bao` CLI against the secret store. +//! verbs run the `bao` CLI against the secret store. `setup` walks the first-run +//! ceremony interactively by calling the code of those verbs ([`interactive`], +//! [`terminal`]). //! //! Distinct from `hivectl`, which drives one hive's `hive-c0re` over its //! admin socket. This crate deliberately does not link `swarm-controller`, @@ -31,11 +33,13 @@ mod agents_md; mod bao; mod forge; mod inference; +mod interactive; mod login; mod permissions; mod remote; mod schedule; mod setup; +mod terminal; mod users; mod watch; @@ -75,7 +79,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, and the agent checks of `setup status`, go through its gateway as the + /// the `agent`, `schedule` and `forge` verbs, and the agent checks and steps of `setup`, 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 @@ -217,11 +221,20 @@ enum Verb { #[command(subcommand)] command: ForgeVerb, }, - /// Show where the first-run setup ceremony stands. - Setup { - #[command(subcommand)] - command: SetupVerb, - }, + /// Walk through the first-run setup, interactively. + /// + /// Run it as root, in a terminal. It checks `docs/1-getting-started/setup.md` + /// first, so a rerun continues at the first open step, then takes the open + /// steps one at a time. It prompts for what a step needs, shows a running + /// action as one live line, and prints anything to record in colour under a + /// ⚠️ warning, such as the secret store's keys and root token. It prints those + /// once and stores none of them. A step that needs a service this host + /// doesn't run names the deploy option to enable and the host to run + /// `swarmctl setup` on. A step without a verb yet prints its command and + /// waits until you confirm you ran it. + /// + /// For scripts, use `setup status` and the individual verbs. + Setup(SetupArgs), /// Initialise the swarm's secret store and bootstrap its granter. /// /// Runs the `bao` CLI against the store over HTTPS, so the store need @@ -271,6 +284,15 @@ enum Verb { }, } +#[derive(Args)] +#[command(args_conflicts_with_subcommands = true)] +struct SetupArgs { + #[command(subcommand)] + command: Option, + #[command(flatten)] + socket: ControllerSocketArg, +} + #[derive(Subcommand)] enum SetupVerb { /// Print each step of the first-run setup, in order, as `done`, `not done` @@ -837,20 +859,7 @@ fn main() -> Result<()> { } => bao::grant_bootstrap(args), // 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::Setup(args) => setup_verb(paths, &remote, args), Verb::Login => login::login( &remote .login_swarm()? @@ -895,6 +904,28 @@ fn main() -> Result<()> { } } +/// Neither the controller socket nor the users file is required: a host +/// without one still reports, and walks, the steps that need neither. +fn setup_verb(paths: PathArgs, remote: &RemoteArgs, args: SetupArgs) -> Result<()> { + let users_file = path_from(paths.users_file, "SWARMCTL_AUTHELIA_USERS_FILE"); + match args.command { + Some(SetupVerb::Status(status)) => { + let target = status.socket.resolve(remote); + let complete = setup::status(&target, &users_file); + io::stdout().flush().context("writing the report")?; + if !complete { + std::process::exit(1); + } + Ok(()) + } + None => { + let target = args.socket.resolve(remote); + let authelia_bin = path_from(paths.authelia_bin, "SWARMCTL_AUTHELIA_BIN"); + terminal::setup(target, users_file, authelia_bin) + } + } +} + fn inference(remote: &RemoteArgs, command: InferenceVerb) -> Result<()> { match command { InferenceVerb::Get(a) => inference::get(&a.socket.resolve(remote)?, &a.agent), @@ -966,6 +997,16 @@ fn schedule(remote: &RemoteArgs, command: ScheduleVerb) -> Result<()> { } fn user_add(paths: &Paths, args: AddArgs) -> Result<()> { + let username = args.username.clone(); + let password = add_user(paths, args)?; + println!("added {username} to {}", paths.users_file.display()); + println!("password: {}", *password); + println!("this password is stored nowhere — record it now"); + Ok(()) +} + +/// [`user_add`] without its printing: returns the generated password. +fn add_user(paths: &Paths, args: AddArgs) -> Result> { users::validate_username(&args.username)?; let _lock = lock_users_file(&paths.users_file)?; @@ -992,11 +1033,7 @@ fn user_add(paths: &Paths, args: AddArgs) -> Result<()> { ); publish(paths, &mut store)?; - - println!("added {} to {}", args.username, paths.users_file.display()); - println!("password: {}", generated.password); - println!("this password is stored nowhere — record it now"); - Ok(()) + Ok(zeroize::Zeroizing::new(generated.password)) } fn user_reset_password(paths: &Paths, args: &ResetPasswordArgs) -> Result<()> { diff --git a/swarmctl/src/permissions.rs b/swarmctl/src/permissions.rs index 3e7acc74..219dbf35 100644 --- a/swarmctl/src/permissions.rs +++ b/swarmctl/src/permissions.rs @@ -51,17 +51,27 @@ pub(crate) fn set( permissions: &[String], unrestricted_internet: Option, ) -> Result<()> { + let stored = store(target, agent, permissions, unrestricted_internet)?; + print!("{}", render(&stored)); + Ok(()) +} + +/// The round trip of [`set`], printing nothing. +pub(crate) fn store( + target: &Target, + agent: &str, + permissions: &[String], + unrestricted_internet: Option, +) -> Result { let uri = member(agent)?; let wanted = with_permissions(permissions, unrestricted_internet)?; - let stored: Permissions = runtime()?.block_on(send( + runtime()?.block_on(send( target, hyper::Method::PUT, &uri, Some(&wanted), "permissions-set", - ))?; - print!("{}", render(&stored)); - Ok(()) + )) } /// The record granting `permissions`. An empty name, as `--permissions ''` diff --git a/swarmctl/src/setup.rs b/swarmctl/src/setup.rs index 084b99d6..2170b2f1 100644 --- a/swarmctl/src/setup.rs +++ b/swarmctl/src/setup.rs @@ -356,23 +356,30 @@ pub(crate) fn render(steps: &[Step]) -> String { /// /// `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 { +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 { +/// Run every check once. The inputs are borrowed so that `swarmctl setup` can +/// look again after each step. +pub(crate) 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:#}"))); + .as_ref() + .map_err(unset_reason) + .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:#}"))); + .as_ref() + .map_err(unset_reason) + .and_then(|t| agents(t).map_err(|e| format!("{e:#}"))); Observed { store, granter, @@ -467,6 +474,20 @@ fn granter() -> Result { Ok(Granter { state, uploaded }) } +/// Whether this host has the granter unit, which only the host running +/// swarm-bao does. +pub(crate) fn granter_unit_exists() -> bool { + run(Command::new("systemctl").args([ + "show", + GRANTER_UNIT, + "-p", + "LoadState", + "-p", + "ActiveState", + ])) + .is_ok_and(|shown| parse_unit_state(&shown.stdout, GRANTER_UNIT).is_ok()) +} + /// `ActiveState` out of `systemctl show -p LoadState -p ActiveState`. fn parse_unit_state(shown: &str, unit: &str) -> Result { let field = |name: &str| { diff --git a/swarmctl/src/terminal.rs b/swarmctl/src/terminal.rs new file mode 100644 index 00000000..ab1eec89 --- /dev/null +++ b/swarmctl/src/terminal.rs @@ -0,0 +1,405 @@ +//! The real terminal and the real side effects behind `swarmctl setup`'s +//! flow in [`crate::interactive`]. +//! +//! [`Terminal`] draws the flow's lines (coloured unless `NO_COLOR` or a dumb +//! terminal says not), reads answers, and shows a running action as one live +//! line. [`Live`] performs each step by calling the same functions the verbs +//! call: nothing here runs `swarmctl` again or builds a command line of its +//! own. + +use std::fmt::Write as _; +use std::io::{self, IsTerminal as _, Write as _}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +use anyhow::{Context as _, Result, anyhow, bail}; +use hive_types::Permission; +use swarm_wire::wanted::AgentState; +use zeroize::Zeroizing; + +use crate::bao::Bao; +use crate::interactive::{Actions, HostFacts, Initialised, NewUser, StoreHost, Tone, Ui}; +use crate::remote::Target; +use crate::setup::{Observed, granter_unit_exists, observe}; +use crate::{AddArgs, Paths, agent, forge, permissions}; + +const RUTH: &str = "ruth"; +const ADMINS_GROUP: &str = "admins"; + +/// Refuses to start anywhere the flow cannot work. +/// +/// It must be root: the first steps act on the secret store, systemd and +/// authelia's users file before any of the swarm is up to authenticate +/// anyone. It must have a terminal: it prompts, and its secrets are for a +/// person's eyes. +fn precheck(is_root: bool, stdin_tty: bool, stdout_tty: bool) -> Result<()> { + if !is_root { + bail!( + "`swarmctl setup` must run as root: its first steps act on the secret store, systemd \ + and authelia's users file before the swarm is up to log anyone in. Run it with sudo" + ); + } + if !(stdin_tty && stdout_tty) { + bail!( + "`swarmctl setup` is interactive and needs a terminal on stdin and stdout. For \ + scripts, use `swarmctl setup status` and the individual verbs: `swarmctl bao init`, \ + `swarmctl bao grant-bootstrap`, `swarmctl user add`, `swarmctl forge make-admin` and \ + `swarmctl agent create`" + ); + } + Ok(()) +} + +/// Whether to colour output: on a terminal, unless `NO_COLOR` is set and not +/// empty () or the terminal is `dumb`. +fn use_colour(stdout_tty: bool, no_color: Option<&str>, term: Option<&str>) -> bool { + stdout_tty && no_color.is_none_or(str::is_empty) && term != Some("dumb") +} + +/// Run `swarmctl setup`. +pub(crate) fn setup( + target: Result, + users_file: Result, + authelia_bin: Result, +) -> Result<()> { + let stdout_tty = io::stdout().is_terminal(); + precheck( + rustix::process::geteuid().is_root(), + io::stdin().is_terminal(), + stdout_tty, + )?; + let term = std::env::var("TERM").ok(); + let no_color = std::env::var("NO_COLOR").ok(); + let mut ui = Terminal { + colour: use_colour(stdout_tty, no_color.as_deref(), term.as_deref()), + animate: term.as_deref() != Some("dumb"), + }; + let mut actions = Live { + target, + users_file, + authelia_bin, + bao: Bao::new(), + }; + crate::interactive::run(&mut ui, &mut actions).map(drop) +} + +struct Terminal { + colour: bool, + animate: bool, +} + +impl Terminal { + fn paint(&self, tone: Tone, text: &str) -> String { + let code = match tone { + Tone::Plain => return text.to_owned(), + Tone::Heading => "1;36", + Tone::Good => "32", + Tone::Dim => "2", + Tone::Warn => "1;33", + Tone::Bad => "31", + }; + if self.colour { + format!("\x1b[{code}m{text}\x1b[0m") + } else { + text.to_owned() + } + } +} + +const FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +impl Ui for Terminal { + fn say(&mut self, tone: Tone, text: &str) { + println!("{}", self.paint(tone, text)); + } + + fn show_secret(&mut self, text: &str) -> Result<()> { + let mut out = io::stdout().lock(); + out.write_all(text.as_bytes()) + .and_then(|()| out.flush()) + .context("writing the secret to the terminal") + } + + fn ask(&mut self, prompt: &str) -> Result { + print!("{}", self.paint(Tone::Heading, prompt)); + io::stdout().flush().context("writing the prompt")?; + let mut line = String::new(); + let read = io::stdin() + .read_line(&mut line) + .context("reading your answer")?; + if read == 0 { + bail!("input closed before an answer was given"); + } + Ok(line.trim_end_matches(['\n', '\r']).to_owned()) + } + + fn ask_secret(&mut self, prompt: &str) -> Result> { + crate::bao::prompt_no_echo(&io::stdin(), prompt) + } + + fn working(&mut self, what: &str, f: impl FnOnce() -> T) -> T { + if !self.animate { + println!("{what}..."); + return f(); + } + let stop = AtomicBool::new(false); + let started = Instant::now(); + std::thread::scope(|scope| { + let spinner = scope.spawn(|| { + let mut frame = 0; + while !stop.load(Ordering::Relaxed) { + print!( + "\r\x1b[K{} {what} ({}s)", + FRAMES[frame % FRAMES.len()], + started.elapsed().as_secs() + ); + io::stdout().flush().ok(); + frame += 1; + std::thread::sleep(Duration::from_millis(100)); + } + print!("\r\x1b[K"); + io::stdout().flush().ok(); + }); + let result = f(); + stop.store(true, Ordering::Relaxed); + spinner.join().ok(); + result + }) + } +} + +struct Live { + target: Result, + users_file: Result, + authelia_bin: Result, + bao: Bao, +} + +impl Live { + fn target(&self) -> Result<&Target> { + self.target.as_ref().map_err(|e| anyhow!("{e:#}")) + } +} + +/// Calls `f` until it succeeds, up to `attempts` times, `every` apart. The +/// controller declares a new agent in the background, so a request about it +/// right after its creation can arrive first. +fn retry(attempts: usize, every: Duration, mut f: impl FnMut() -> Result) -> Result { + let mut last = None; + for attempt in 0..attempts { + match f() { + Ok(value) => return Ok(value), + Err(e) => last = Some(e), + } + if attempt + 1 < attempts { + std::thread::sleep(every); + } + } + Err(last.unwrap_or_else(|| anyhow!("no attempt was made"))) +} + +/// Whether `program` is an executable file in a `PATH` directory. +fn on_path(program: &str) -> bool { + std::env::var_os("PATH").is_some_and(|path| on_path_in(program, &path)) +} + +fn on_path_in(program: &str, path: &std::ffi::OsStr) -> bool { + use std::os::unix::fs::PermissionsExt as _; + + std::env::split_paths(path).any(|dir| { + std::fs::metadata(dir.join(program)) + .is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0) + }) +} + +impl Actions for Live { + fn host(&self) -> HostFacts { + HostFacts { + store: StoreHost { + bao_cli: on_path("bao"), + granter_unit: granter_unit_exists(), + grant_configured: Bao::grant_configured(), + }, + sso: self.users_file.is_ok() && self.authelia_bin.is_ok(), + controller: self.target.is_ok(), + } + } + + fn observe(&mut self) -> Observed { + observe(&self.target, &self.users_file) + } + + fn pause(&mut self, how_long: Duration) { + std::thread::sleep(how_long); + } + + fn init_store(&mut self) -> Result { + let created = self.bao.init()?; + Ok(Initialised { + text: created.rendered()?, + root_token: created.root_token(), + }) + } + + fn wait_unsealed(&mut self) -> Result<()> { + self.bao.wait_unsealed() + } + + fn grant_bootstrap(&mut self, root_token: &str) -> Result { + self.bao.grant_bootstrap(root_token) + } + + fn add_user(&mut self, user: &NewUser) -> Result> { + let paths = Paths { + authelia_bin: self + .authelia_bin + .as_ref() + .map_err(|e| anyhow!("{e:#}"))? + .clone(), + users_file: self + .users_file + .as_ref() + .map_err(|e| anyhow!("{e:#}"))? + .clone(), + }; + crate::add_user( + &paths, + AddArgs { + username: user.username.clone(), + display_name: Some(user.display_name.clone()), + email: Some(user.email.clone()), + groups: vec![ADMINS_GROUP.to_owned()], + }, + ) + } + + fn make_admin(&mut self, name: &str) -> Result { + forge::request_admin(self.target()?, name) + } + + fn declare_ruth(&mut self, hive: &str, exists: bool) -> Result { + let target = self.target()?; + let hive = agent::parse_ident(hive, "hive")?; + if !exists { + agent::request_create(target, RUTH, &hive, None)?; + } + let granted: Vec = Permission::DEFAULT + .iter() + .chain([&Permission::ManageRootAgent]) + .map(|p| <&str>::from(*p).to_owned()) + .collect(); + let every = Duration::from_secs(2); + retry(30, every, || { + permissions::store(target, RUTH, &granted, None) + })?; + retry(30, every, || { + agent::request_state(target, RUTH, &hive, AgentState::Up) + })?; + Ok(format!( + "{RUTH} is declared up on hive {hive:?} with manage_root_agent; the hive converges on its own clock" + )) + } + + fn create_agent(&mut self, name: &str, hive: &str) -> Result { + let name = agent::parse_ident(name, "agent name")?; + let hive = agent::parse_ident(hive, "hive")?; + let resp = agent::request_create(self.target()?, &name, &hive, None)?; + let mut said = format!( + "queued job node {} to deploy {name:?} to hive {hive:?}", + resp.node_id + ); + for warning in &resp.warnings { + let _ = write!(said, "; warning: {warning}"); + } + Ok(said) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn setup_refuses_anyone_but_root() { + let err = precheck(false, true, true).expect_err("not root"); + assert!(err.to_string().contains("must run as root"), "{err}"); + // The control: root on a terminal is accepted. + precheck(true, true, true).expect("root on a terminal"); + } + + #[test] + fn setup_refuses_without_a_terminal_and_points_at_the_scriptable_verbs() { + for (stdin_tty, stdout_tty) in [(false, true), (true, false), (false, false)] { + let err = precheck(true, stdin_tty, stdout_tty).expect_err("no terminal"); + let message = err.to_string(); + assert!( + message.contains("setup status") && message.contains("swarmctl bao init"), + "{message}" + ); + } + } + + #[test] + fn colour_follows_the_terminal_and_no_color() { + assert!(use_colour(true, None, Some("xterm"))); + assert!(!use_colour(false, None, Some("xterm")), "not a terminal"); + assert!(!use_colour(true, Some("1"), Some("xterm")), "NO_COLOR set"); + assert!( + use_colour(true, Some(""), Some("xterm")), + "an empty NO_COLOR does not count" + ); + assert!(!use_colour(true, None, Some("dumb"))); + } + + #[test] + fn an_uncoloured_terminal_prints_plain_text() { + let plain = Terminal { + colour: false, + animate: false, + }; + assert_eq!(plain.paint(Tone::Warn, "⚠️ careful"), "⚠️ careful"); + let coloured = Terminal { + colour: true, + animate: false, + }; + let painted = coloured.paint(Tone::Warn, "⚠️ careful"); + assert!(painted.starts_with("\x1b[") && painted.contains("⚠️ careful")); + assert_eq!(coloured.paint(Tone::Plain, "x"), "x"); + } + + #[test] + fn retry_gives_up_after_its_attempts_and_stops_at_the_first_success() { + let mut calls = 0; + let out = retry(5, Duration::ZERO, || { + calls += 1; + if calls < 3 { + bail!("not yet"); + } + Ok(calls) + }); + assert_eq!(out.expect("third try works"), 3); + + let mut calls = 0; + let err = retry(4, Duration::ZERO, || -> Result<()> { + calls += 1; + bail!("never") + }) + .expect_err("all fail"); + assert_eq!((calls, err.to_string().as_str()), (4, "never")); + } + + #[test] + fn a_program_on_path_is_found_only_when_it_is_executable() { + let dir = std::env::temp_dir().join(format!("swarmctl-onpath-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("temp dir"); + crate::tests::write_executable(&dir.join("present"), "#!/bin/sh\n"); + std::fs::write(dir.join("plain"), "x").expect("write"); + let path = dir.as_os_str(); + assert!(on_path_in("present", path)); + assert!(!on_path_in("plain", path), "a file without the execute bit"); + assert!(!on_path_in("absent", path)); + std::fs::remove_dir_all(&dir).ok(); + } +}