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:
parent
4fae6a7766
commit
44caa72c19
12 changed files with 2172 additions and 82 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
1467
swarmctl/src/interactive.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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<()> {
|
||||||
|
|
|
||||||
|
|
@ -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 ''`
|
||||||
|
|
|
||||||
|
|
@ -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
405
swarmctl/src/terminal.rs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue