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

View file

@ -154,6 +154,50 @@ pub fn grant_bootstrap(args: GrantBootstrapArgs) -> Result<()> {
Ok(())
}
/// The verbs above without their printing and prompting, for `swarmctl setup`.
pub(crate) struct Bao {
tools: Tools,
}
impl Bao {
pub(crate) fn new() -> Self {
Self {
tools: ToolArgs { bao_bin: None }.tools(),
}
}
/// Whether the nix module gave this host the bootstrap token path and
/// policy file, which it does only where the store runs.
pub(crate) fn grant_configured() -> bool {
GrantArgs {
bootstrap_token_file: None,
bootstrap_policy: None,
}
.resolve()
.is_ok()
}
/// `swarmctl bao init`, minus delivering the secrets.
pub(crate) fn init(&self) -> Result<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.
///
/// Wiped on drop. The JSON parser and the child's output buffer pass through
@ -186,6 +230,56 @@ fn initialise(
out: &mut impl io::Write,
fallback: &mut impl io::Write,
) -> Result<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"]))?;
match status.status.code() {
Some(0) => bail!(
@ -215,28 +309,7 @@ fn initialise(
if parsed.unseal_keys_b64.is_empty() && parsed.recovery_keys_b64.is_empty() {
bail!("`bao operator init` printed no unseal or recovery keys");
}
eprintln!(
"Record the keys and the root token below off this host now: swarmctl keeps none of \
them and cannot show them again."
);
if let Err(stdout_err) = write_secrets(out, &parsed) {
let delivered = write_secrets(fallback, &parsed);
return Err(match delivered {
Ok(()) => anyhow::anyhow!(
"the store is now initialised, but its keys and root token could not be written \
to stdout ({stdout_err}); they were written to stderr instead, once: record \
them now"
),
Err(stderr_err) => anyhow::anyhow!(
"the store is now initialised, but its keys and root token could not be \
written to stdout ({stdout_err}) or to stderr ({stderr_err}) and are lost; \
see OpenBao's `operator rekey` and `operator generate-root` documentation \
for what a remaining key holder can recover"
),
});
}
Ok(Zeroizing::new(parsed.root_token.clone()))
Ok(Created { parsed })
}
fn write_secrets(out: &mut impl io::Write, parsed: &InitOutput) -> io::Result<()> {
@ -401,7 +474,7 @@ fn failure_text(stderr: &[u8], secrets: &[&str]) -> String {
fn read_root_token() -> Result<Zeroizing<String>> {
let stdin = io::stdin();
let token = if stdin.is_terminal() {
prompt_no_echo(&stdin)?
prompt_no_echo(&stdin, "Root token (input hidden): ")?
} else {
read_token_line(&mut stdin.lock())?
};
@ -411,14 +484,14 @@ fn read_root_token() -> Result<Zeroizing<String>> {
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};
let saved = tcgetattr(stdin).context("reading terminal settings")?;
let mut quiet = saved.clone();
quiet.local_modes.remove(LocalModes::ECHO);
tcsetattr(stdin, OptionalActions::Flush, &quiet).context("turning terminal echo off")?;
eprint!("Root token (input hidden): ");
eprint!("{prompt}");
io::stderr().flush().ok();
let read = read_token_line(&mut stdin.lock());
// Restored before the error, if any, is looked at.