Watch
0
0
Fork
You've already forked hyperhive
0

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.
This commit is contained in:
atlas 2026-11-08 13:28:09 +01:00 • committed by mara
commit 44caa72c19
12 changed files with 2172 additions and 82 deletions

View file

@ -176,6 +176,8 @@ rustix = { version = "1.1.4", default-features = false, features = [
"fs", "fs",
# `swarmctl bao grant-bootstrap` turns terminal echo off for the root token. # `swarmctl bao grant-bootstrap` turns terminal echo off for the root token.
"termios", "termios",
# `swarmctl setup` checks that it runs as root.
"process",
] } ] }
reqwest = { version = "0.13", default-features = false, features = [ reqwest = { version = "0.13", default-features = false, features = [
# RFC 7662 introspection posts an urlencoded body; without this, # RFC 7662 introspection posts an urlencoded body; without this,

View file

@ -8,6 +8,14 @@ required options, is [`nixos.md`](nixos.md).
Every command runs as **root on the host**. 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 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 the hand steps below, numbered 1 to 10 in the order the sections run them, marks
each done, not done or unknown, and names the next one. It changes nothing. each done, not done or unknown, and names the next one. It changes nothing.

View file

@ -55,7 +55,7 @@ swarm-level operator CLI
* `schedule` — Manage scheduled prompts * `schedule` — Manage scheduled prompts
* `user` — Manage subjects in the swarm's SSO provider * `user` — Manage subjects in the swarm's SSO provider
* `forge` — Manage human accounts on the swarm's forge * `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 * `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 * `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 * `completions` — Generate a shell completion script for `swarmctl` and print it to stdout
@ -66,7 +66,7 @@ swarm-level operator CLI
* `--users-file <PATH>` — Host-side path of authelia's users database — that is, the path inside the container, prefixed with the container's root. * `--users-file <PATH>` — 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. 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 <URL>` — The swarm's domain, as `https://<domain>` 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 <URL>` — The swarm's domain, as `https://<domain>` 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 <PATH>` — 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` * `--ca-file <PATH>` — 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` ## `swarmctl setup`
Show where the first-run setup ceremony stands Walk through the first-run setup, interactively.
**Usage:** `swarmctl setup <COMMAND>` 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 <COMMAND>`
###### **Subcommands:** ###### **Subcommands:**
* `status` — Print each step of the first-run setup, in order, as `done`, `not done` or `unknown`, and what to do next * `status` — Print each step of the first-run setup, in order, as `done`, `not done` or `unknown`, and what to do next
###### **Options:**
* `--controller-socket <PATH>` — 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` ## `swarmctl setup status`

View file

@ -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 [`docs/2-handbook/swarm/schedules.md`](../docs/2-handbook/swarm/schedules.md). The bodies come
from `swarm-controller-api`, imported in `src/schedule.rs`. 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` ## `setup status`
```console ```console
@ -240,7 +269,7 @@ is optional and does not count.
## Remote use: `swarmctl login` ## 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: your own SSO account, through the swarm domain's gateway:
```console ```console

View file

@ -57,11 +57,7 @@ pub(crate) fn create(
}) })
.transpose()?; .transpose()?;
let rt = tokio::runtime::Builder::new_current_thread() let resp = request_create(target, &name, &hive, agents_md.as_deref())?;
.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()))?;
println!("queued: job node {}", resp.node_id); println!("queued: job node {}", resp.node_id);
println!( println!(
@ -76,6 +72,21 @@ pub(crate) fn create(
Ok(()) 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<CreateAgentResponse> {
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 /// Parse a CLI-supplied name into a validated identifier, naming which
/// argument was wrong — `invalid hive` and `invalid agent name` send the /// argument was wrong — `invalid hive` and `invalid agent name` send the
/// operator to different flags. /// operator to different flags.
@ -137,8 +148,23 @@ pub(crate) fn set_state(
check_confirmed(state, yes)?; check_confirmed(state, yes)?;
let agent = parse_ident(agent, "agent name")?; let agent = parse_ident(agent, "agent name")?;
let hive = parse_ident(hive, "hive")?; 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() let rt = tokio::runtime::Builder::new_current_thread()
.enable_all() .enable_all()
.build() .build()
@ -150,11 +176,7 @@ pub(crate) fn set_state(
Some(&serde_json::json!({ "state": wire })), Some(&serde_json::json!({ "state": wire })),
"set-state", "set-state",
))?; ))?;
Ok(wire)
println!(
"declared agent {agent:?} {wire} on hive {hive:?}; the hive converges on its own clock"
);
Ok(())
} }
/// One `POST /api/agents` round trip at the controller. /// One `POST /api/agents` round trip at the controller.

View file

@ -154,6 +154,50 @@ pub fn grant_bootstrap(args: GrantBootstrapArgs) -> Result<()> {
Ok(()) 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<Created> {
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<String> {
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. /// 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 /// 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, out: &mut impl io::Write,
fallback: &mut impl io::Write, fallback: &mut impl io::Write,
) -> Result<Zeroizing<String>> { ) -> Result<Zeroizing<String>> {
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<String> {
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<Zeroizing<String>> {
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<Created> {
let status = run(&mut tools.bao_cmd(&["operator", "init", "-status"]))?; let status = run(&mut tools.bao_cmd(&["operator", "init", "-status"]))?;
match status.status.code() { match status.status.code() {
Some(0) => bail!( Some(0) => bail!(
@ -215,28 +309,7 @@ fn initialise(
if parsed.unseal_keys_b64.is_empty() && parsed.recovery_keys_b64.is_empty() { if parsed.unseal_keys_b64.is_empty() && parsed.recovery_keys_b64.is_empty() {
bail!("`bao operator init` printed no unseal or recovery keys"); bail!("`bao operator init` printed no unseal or recovery keys");
} }
Ok(Created { parsed })
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()))
} }
fn write_secrets(out: &mut impl io::Write, parsed: &InitOutput) -> io::Result<()> { 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<Zeroizing<String>> { fn read_root_token() -> Result<Zeroizing<String>> {
let stdin = io::stdin(); let stdin = io::stdin();
let token = if stdin.is_terminal() { let token = if stdin.is_terminal() {
prompt_no_echo(&stdin)? prompt_no_echo(&stdin, "Root token (input hidden): ")?
} else { } else {
read_token_line(&mut stdin.lock())? read_token_line(&mut stdin.lock())?
}; };
@ -411,14 +484,14 @@ fn read_root_token() -> Result<Zeroizing<String>> {
Ok(token) Ok(token)
} }
fn prompt_no_echo(stdin: &io::Stdin) -> Result<Zeroizing<String>> { pub(crate) fn prompt_no_echo(stdin: &io::Stdin, prompt: &str) -> Result<Zeroizing<String>> {
use rustix::termios::{LocalModes, OptionalActions, tcgetattr, tcsetattr}; use rustix::termios::{LocalModes, OptionalActions, tcgetattr, tcsetattr};
let saved = tcgetattr(stdin).context("reading terminal settings")?; let saved = tcgetattr(stdin).context("reading terminal settings")?;
let mut quiet = saved.clone(); let mut quiet = saved.clone();
quiet.local_modes.remove(LocalModes::ECHO); quiet.local_modes.remove(LocalModes::ECHO);
tcsetattr(stdin, OptionalActions::Flush, &quiet).context("turning terminal echo off")?; tcsetattr(stdin, OptionalActions::Flush, &quiet).context("turning terminal echo off")?;
eprint!("Root token (input hidden): "); eprint!("{prompt}");
io::stderr().flush().ok(); io::stderr().flush().ok();
let read = read_token_line(&mut stdin.lock()); let read = read_token_line(&mut stdin.lock());
// Restored before the error, if any, is looked at. // Restored before the error, if any, is looked at.

View file

@ -15,6 +15,12 @@ use crate::remote::Target;
/// ///
/// Synchronous for the same reason `agent create` is. /// Synchronous for the same reason `agent create` is.
pub(crate) fn make_admin(target: &Target, name: &str) -> Result<()> { 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<String> {
let name = parse_ident(name, "user name")?; let name = parse_ident(name, "user name")?;
let rt = tokio::runtime::Builder::new_current_thread() 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", "forge-make-admin",
))?; ))?;
match resp.change { Ok(match resp.change {
ForgeAdminChange::Promoted => println!("forge: {name:?} is now a site admin"), ForgeAdminChange::Promoted => format!("forge: {name:?} is now a site admin"),
ForgeAdminChange::AlreadyAdmin => println!("forge: {name:?} was already a site admin"), ForgeAdminChange::AlreadyAdmin => format!("forge: {name:?} was already a site admin"),
} })
Ok(())
} }

1467
swarmctl/src/interactive.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -20,7 +20,9 @@
//! reach it over its unix socket on its host, or, with `--swarm`, through //! 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 //! 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` //! ([`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 //! Distinct from `hivectl`, which drives one hive's `hive-c0re` over its
//! admin socket. This crate deliberately does not link `swarm-controller`, //! admin socket. This crate deliberately does not link `swarm-controller`,
@ -31,11 +33,13 @@ mod agents_md;
mod bao; mod bao;
mod forge; mod forge;
mod inference; mod inference;
mod interactive;
mod login; mod login;
mod permissions; mod permissions;
mod remote; mod remote;
mod schedule; mod schedule;
mod setup; mod setup;
mod terminal;
mod users; mod users;
mod watch; mod watch;
@ -75,7 +79,7 @@ const LOGIN_SWARM_ENVS: &[&str] = &["SWARMCTL_SWARM", "SWARMCTL_LOGIN_DEFAULT_SW
#[derive(Args)] #[derive(Args)]
struct RemoteArgs { struct RemoteArgs {
/// The swarm's domain, as `https://<domain>` or the bare domain. Given, /// The swarm's domain, as `https://<domain>` 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 /// account `swarmctl login` logged in, instead of over the controller's
/// socket. Falls back to `SWARMCTL_SWARM`; `login` then falls back to /// socket. Falls back to `SWARMCTL_SWARM`; `login` then falls back to
/// `SWARMCTL_LOGIN_DEFAULT_SWARM`, which the swarm-controller nix module /// `SWARMCTL_LOGIN_DEFAULT_SWARM`, which the swarm-controller nix module
@ -217,11 +221,20 @@ enum Verb {
#[command(subcommand)] #[command(subcommand)]
command: ForgeVerb, command: ForgeVerb,
}, },
/// Show where the first-run setup ceremony stands. /// Walk through the first-run setup, interactively.
Setup { ///
#[command(subcommand)] /// Run it as root, in a terminal. It checks `docs/1-getting-started/setup.md`
command: SetupVerb, /// 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. /// Initialise the swarm's secret store and bootstrap its granter.
/// ///
/// Runs the `bao` CLI against the store over HTTPS, so the store need /// 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<SetupVerb>,
#[command(flatten)]
socket: ControllerSocketArg,
}
#[derive(Subcommand)] #[derive(Subcommand)]
enum SetupVerb { enum SetupVerb {
/// Print each step of the first-run setup, in order, as `done`, `not done` /// 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), } => bao::grant_bootstrap(args),
// Same socket-resolution reasoning as `Create` above. // Same socket-resolution reasoning as `Create` above.
Verb::Schedule { command } => schedule(&remote, command), Verb::Schedule { command } => schedule(&remote, command),
// Neither the controller socket nor the users file is required: a Verb::Setup(args) => setup_verb(paths, &remote, args),
// host without one reports the steps that need the other.
Verb::Setup {
command: SetupVerb::Status(args),
} => {
let target = args.socket.resolve(&remote);
let users_file = path_from(paths.users_file, "SWARMCTL_AUTHELIA_USERS_FILE");
let complete = setup::status(target, users_file);
io::stdout().flush().context("writing the report")?;
if !complete {
std::process::exit(1);
}
Ok(())
}
Verb::Login => login::login( Verb::Login => login::login(
&remote &remote
.login_swarm()? .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<()> { fn inference(remote: &RemoteArgs, command: InferenceVerb) -> Result<()> {
match command { match command {
InferenceVerb::Get(a) => inference::get(&a.socket.resolve(remote)?, &a.agent), 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<()> { 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<zeroize::Zeroizing<String>> {
users::validate_username(&args.username)?; users::validate_username(&args.username)?;
let _lock = lock_users_file(&paths.users_file)?; let _lock = lock_users_file(&paths.users_file)?;
@ -992,11 +1033,7 @@ fn user_add(paths: &Paths, args: AddArgs) -> Result<()> {
); );
publish(paths, &mut store)?; publish(paths, &mut store)?;
Ok(zeroize::Zeroizing::new(generated.password))
println!("added {} to {}", args.username, paths.users_file.display());
println!("password: {}", generated.password);
println!("this password is stored nowhere — record it now");
Ok(())
} }
fn user_reset_password(paths: &Paths, args: &ResetPasswordArgs) -> Result<()> { fn user_reset_password(paths: &Paths, args: &ResetPasswordArgs) -> Result<()> {

View file

@ -51,17 +51,27 @@ pub(crate) fn set(
permissions: &[String], permissions: &[String],
unrestricted_internet: Option<bool>, unrestricted_internet: Option<bool>,
) -> Result<()> { ) -> 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<bool>,
) -> Result<Permissions> {
let uri = member(agent)?; let uri = member(agent)?;
let wanted = with_permissions(permissions, unrestricted_internet)?; let wanted = with_permissions(permissions, unrestricted_internet)?;
let stored: Permissions = runtime()?.block_on(send( runtime()?.block_on(send(
target, target,
hyper::Method::PUT, hyper::Method::PUT,
&uri, &uri,
Some(&wanted), Some(&wanted),
"permissions-set", "permissions-set",
))?; ))
print!("{}", render(&stored));
Ok(())
} }
/// The record granting `permissions`. An empty name, as `--permissions ''` /// The record granting `permissions`. An empty name, as `--permissions ''`

View file

@ -356,23 +356,30 @@ pub(crate) fn render(steps: &[Step]) -> String {
/// ///
/// `target` and `users_file` arrive as the results of resolving them, so a /// `target` and `users_file` arrive as the results of resolving them, so a
/// host that lacks either still reports the steps that need neither. /// host that lacks either still reports the steps that need neither.
pub(crate) fn status(target: Result<Target>, users_file: Result<std::path::PathBuf>) -> bool { pub(crate) fn status(target: &Result<Target>, users_file: &Result<std::path::PathBuf>) -> bool {
let observed = observe(target, users_file); let observed = observe(target, users_file);
let steps = classify(&observed); let steps = classify(&observed);
print!("{}", render(&steps)); print!("{}", render(&steps));
complete(&steps) complete(&steps)
} }
fn observe(target: Result<Target>, users_file: Result<std::path::PathBuf>) -> 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<Target>,
users_file: &Result<std::path::PathBuf>,
) -> Observed {
let store = bao_status(); let store = bao_status();
let granter = granter(); let granter = granter();
let policy_units = policy_units(); let policy_units = policy_units();
let admin_user = users_file let admin_user = users_file
.map_err(|e| unset_reason(&e)) .as_ref()
.and_then(|path| has_admin_user(&path).map_err(|e| format!("{e:#}"))); .map_err(unset_reason)
.and_then(|path| has_admin_user(path).map_err(|e| format!("{e:#}")));
let agents = target let agents = target
.map_err(|e| unset_reason(&e)) .as_ref()
.and_then(|t| agents(&t).map_err(|e| format!("{e:#}"))); .map_err(unset_reason)
.and_then(|t| agents(t).map_err(|e| format!("{e:#}")));
Observed { Observed {
store, store,
granter, granter,
@ -467,6 +474,20 @@ fn granter() -> Result<Granter, String> {
Ok(Granter { state, uploaded }) 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`. /// `ActiveState` out of `systemctl show -p LoadState -p ActiveState`.
fn parse_unit_state(shown: &str, unit: &str) -> Result<String, String> { fn parse_unit_state(shown: &str, unit: &str) -> Result<String, String> {
let field = |name: &str| { let field = |name: &str| {

405
swarmctl/src/terminal.rs Normal file
View file

@ -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 (<https://no-color.org>) 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<Target>,
users_file: Result<PathBuf>,
authelia_bin: Result<PathBuf>,
) -> 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<String> {
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<Zeroizing<String>> {
crate::bao::prompt_no_echo(&io::stdin(), prompt)
}
fn working<T>(&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<Target>,
users_file: Result<PathBuf>,
authelia_bin: Result<PathBuf>,
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<T>(attempts: usize, every: Duration, mut f: impl FnMut() -> Result<T>) -> Result<T> {
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<Initialised> {
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<String> {
self.bao.grant_bootstrap(root_token)
}
fn add_user(&mut self, user: &NewUser) -> Result<Zeroizing<String>> {
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<String> {
forge::request_admin(self.target()?, name)
}
fn declare_ruth(&mut self, hive: &str, exists: bool) -> Result<String> {
let target = self.target()?;
let hive = agent::parse_ident(hive, "hive")?;
if !exists {
agent::request_create(target, RUTH, &hive, None)?;
}
let granted: Vec<String> = 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<String> {
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();
}
}