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.
1467 lines
48 KiB
Rust
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"))
|
|
);
|
|
}
|
|
}
|