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

@ -20,7 +20,9 @@
//! 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
//! ([`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
//! admin socket. This crate deliberately does not link `swarm-controller`,
@ -31,11 +33,13 @@ mod agents_md;
mod bao;
mod forge;
mod inference;
mod interactive;
mod login;
mod permissions;
mod remote;
mod schedule;
mod setup;
mod terminal;
mod users;
mod watch;
@ -75,7 +79,7 @@ const LOGIN_SWARM_ENVS: &[&str] = &["SWARMCTL_SWARM", "SWARMCTL_LOGIN_DEFAULT_SW
#[derive(Args)]
struct RemoteArgs {
/// 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
/// socket. Falls back to `SWARMCTL_SWARM`; `login` then falls back to
/// `SWARMCTL_LOGIN_DEFAULT_SWARM`, which the swarm-controller nix module
@ -217,11 +221,20 @@ enum Verb {
#[command(subcommand)]
command: ForgeVerb,
},
/// Show where the first-run setup ceremony stands.
Setup {
#[command(subcommand)]
command: SetupVerb,
},
/// Walk through the first-run setup, interactively.
///
/// 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.
Setup(SetupArgs),
/// Initialise the swarm's secret store and bootstrap its granter.
///
/// 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)]
enum SetupVerb {
/// 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),
// Same socket-resolution reasoning as `Create` above.
Verb::Schedule { command } => schedule(&remote, command),
// Neither the controller socket nor the users file is required: a
// 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::Setup(args) => setup_verb(paths, &remote, args),
Verb::Login => login::login(
&remote
.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<()> {
match command {
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<()> {
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)?;
let _lock = lock_users_file(&paths.users_file)?;
@ -992,11 +1033,7 @@ fn user_add(paths: &Paths, args: AddArgs) -> Result<()> {
);
publish(paths, &mut store)?;
println!("added {} to {}", args.username, paths.users_file.display());
println!("password: {}", generated.password);
println!("this password is stored nowhere — record it now");
Ok(())
Ok(zeroize::Zeroizing::new(generated.password))
}
fn user_reset_password(paths: &Paths, args: &ResetPasswordArgs) -> Result<()> {