Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarmctl/src/interactive.rs
atlas 44caa72c19 swarmctl: interactive setup walks the first-run ceremony
swarmctl setup with no subcommand checks the setup status first, then takes the open steps one at a time: prompts for input, shows a running action as one live line, prints the store keys, root token and new password under a warning until the operator confirms, and names the deploy option for a step this host cannot do. It calls the code of bao init, bao grant-bootstrap, user add, forge make-admin, agent create, agent permissions set and agent set-state. Refuses without root or a terminal.
2026-11-08 18:11:39 +01:00

1467 lines
48 KiB
Rust

//! `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<NotHere> {
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<Planned> {
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<u8>) -> 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<u8>) -> 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<u8>) -> 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<String>;
/// One line of input that is not echoed.
fn ask_secret(&mut self, prompt: &str) -> Result<Zeroizing<String>>;
/// Runs `f` while showing `what` as one live line, which is gone when it
/// returns.
fn working<T>(&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<String>,
pub(crate) root_token: Zeroizing<String>,
}
/// 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<Initialised>;
fn wait_unsealed(&mut self) -> Result<()>;
fn grant_bootstrap(&mut self, root_token: &str) -> Result<String>;
/// Returns the generated password.
fn add_user(&mut self, user: &NewUser) -> Result<Zeroizing<String>>;
fn make_admin(&mut self, name: &str) -> Result<String>;
/// `ruth`, with the permissions of a manager, declared `up`.
fn declare_ruth(&mut self, hive: &str, exists: bool) -> Result<String>;
fn create_agent(&mut self, name: &str, hive: &str) -> Result<String>;
}
#[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<Zeroizing<String>>,
user: Option<String>,
hive: Option<String>,
}
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<Outcome> {
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<u8>,
skipped: &BTreeSet<u8>,
) -> 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<Planned> {
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<Planned>,
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<Planned>,
number: u8,
wait: Duration,
) -> Option<String> {
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<Next> {
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<String> {
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<Planned>,
(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://<swarm.domain>/ 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<RefCell<Vec<String>>>;
/// 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<Initialised> {
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<String> {
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<Zeroizing<String>> {
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<String> {
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<String> {
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<String> {
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<Initialised> {
self.0.init_store()
}
fn wait_unsealed(&mut self) -> Result<()> {
self.0.wait_unsealed()
}
fn grant_bootstrap(&mut self, root_token: &str) -> Result<String> {
self.0.grant_bootstrap(root_token)
}
fn add_user(&mut self, user: &NewUser) -> Result<Zeroizing<String>> {
self.0.add_user(user)
}
fn make_admin(&mut self, name: &str) -> Result<String> {
self.0.make_admin(name)
}
fn declare_ruth(&mut self, _: &str, _: bool) -> Result<String> {
Ok("declared".to_owned())
}
fn create_agent(&mut self, name: &str, hive: &str) -> Result<String> {
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<String>,
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<String> {
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<Zeroizing<String>> {
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<T>(&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<String> {
log.borrow()
.iter()
.filter(|l| l.starts_with(prefix))
.cloned()
.collect()
}
fn position(log: &Log, call: &str) -> Option<usize> {
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<u8> = 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<String> = 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<usize> = 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"))
);
}
}