Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarmctl/src/main.rs
atlas 7b5400ecb8 grant manage_root_agent by stored permission only, not by the name ruth
Permission::default_for and hive-sh4re's MANAGER_AGENT are gone: every
agent with no permissions key holds Permission::DEFAULT, ruth included.
The agent-name parameters that only fed default_for go with it
(permissions::effective, HiveCopy::before_read/after_read and their
callers).

swarm-controller seeds the grant ruth held by name: at start, a declared
ruth whose permissions are unset gets DEFAULT plus manage_root_agent
written into her key, once, logged. A set key is never touched. It is a
one-shot to delete once every swarm has passed it.

The swarm UI setup assistant opens the create form with
manage_root_agent checked, so a new ruth is created with the grant.
permissions_import no longer adds ruth to the import or splits her
default. Docs and help text no longer say ruth holds the grant by
default; swarmctl-cli.md regenerated.
2026-10-17 08:10:08 +02:00

1446 lines
54 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! `swarmctl` — the swarm operator's local CLI.
//!
//! Runs as **root on the host the swarm-controller runs on**, and acts
//! directly. That is a deliberate scope, not a shortcut: the alternative
//! examined for the first verb was to make the write rootless by moving
//! authelia's users database into a directory the controller owns, and it
//! does not work — relocating the file only turns a write problem into a
//! read problem, because authelia then has to reach *across the same
//! boundary in the other direction*. Making that read work needs either a
//! hand-pinned gid (the container's uids are allocated inside it, at
//! activation) or world-readable password hashes. Both are worse than
//! root.
//!
//! So this binary **serves** no socket, publishes no HTTP route and has
//! no privileged helper.
//!
//! Acting directly is not the same as acting *alone*, though: the `agent`,
//! `schedule` and `forge` verbs are clients of the controller, because the
//! work they ask for is only the controller's to do (see [`agent`]). They
//! 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.
//!
//! Distinct from `hivectl`, which drives one hive's `hive-c0re` over its
//! admin socket. This crate deliberately does not link `swarm-controller`,
//! for the same reason `hivectl` does not link `hive-c0re`.
mod agent;
mod forge;
mod inference;
mod login;
mod permissions;
mod remote;
mod schedule;
mod users;
use std::fs::{self, File, Permissions};
use std::io::Write as _;
use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context, Result, bail};
use clap::{Args, Parser, Subcommand};
use remote::Target;
use users::{User, UserStore};
#[derive(Parser)]
#[command(name = "swarmctl", version, about = "swarm-level operator CLI")]
struct Cli {
#[command(flatten)]
paths: PathArgs,
#[command(flatten)]
remote: RemoteArgs,
#[command(subcommand)]
command: Verb,
}
/// Where a verb that can reach a swarm looks for it when `--swarm` is absent.
const SWARM_ENVS: &[&str] = &["SWARMCTL_SWARM"];
/// The same for `login`. The swarm-controller nix module sets
/// `SWARMCTL_LOGIN_DEFAULT_SWARM` to its host's own swarm; setting
/// `SWARMCTL_SWARM` there instead would send every other verb through the
/// gateway rather than the local socket.
const LOGIN_SWARM_ENVS: &[&str] = &["SWARMCTL_SWARM", "SWARMCTL_LOGIN_DEFAULT_SWARM"];
/// Which swarm to reach through its domain rather than the local socket.
#[derive(Args)]
struct RemoteArgs {
/// The swarm's domain, as `https://<domain>` or the bare domain. Given,
/// the `agent`, `schedule` and `forge` verbs 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.
#[arg(long, value_name = "URL", global = true)]
swarm: Option<String>,
/// 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`.
#[arg(long, value_name = "PATH", global = true)]
ca_file: Option<PathBuf>,
}
impl RemoteArgs {
/// `--swarm`, else the first of `envs` that `env` finds set and
/// non-empty. An empty `--swarm` names no swarm and skips the env.
fn swarm_url(&self, envs: &[&str], env: impl Fn(&str) -> Option<String>) -> Option<String> {
match &self.swarm {
Some(flag) => Some(flag.clone()),
None => envs
.iter()
.find_map(|name| env(name).filter(|u| !u.is_empty())),
}
.filter(|u| !u.is_empty())
}
fn swarm(&self) -> Result<Option<remote::Swarm>> {
self.swarm_from(SWARM_ENVS)
}
fn login_swarm(&self) -> Result<Option<remote::Swarm>> {
self.swarm_from(LOGIN_SWARM_ENVS)
}
fn swarm_from(&self, envs: &[&str]) -> Result<Option<remote::Swarm>> {
let url = self.swarm_url(envs, |name| std::env::var(name).ok());
let ca_file = self
.ca_file
.clone()
.or_else(|| std::env::var_os("SWARMCTL_CA_FILE").map(PathBuf::from));
url.map(|u| remote::Swarm::parse(&u, ca_file, remote::state_dir()?))
.transpose()
}
/// The swarm when one is named, the controller's socket otherwise.
fn target(&self, socket: Option<PathBuf>) -> Result<Target> {
Ok(match self.swarm()? {
Some(swarm) => Target::Swarm(swarm),
None => Target::Socket(path_from(socket, "SWARM_CONTROLLER_SOCKET")?),
})
}
}
/// Where the deployment put the things this CLI has to touch.
///
/// Every one of these is supplied by the nix module that installs
/// `swarmctl`, because every one of them is derived from options the
/// module owns (the container name, the authelia instance name, the
/// package). They are **required rather than defaulted**: a default here
/// would be an address we hope to find something at, and one that
/// resolves cleanly to the wrong place is worse than an error.
#[derive(Args)]
struct PathArgs {
/// authelia binary used to hash passwords. The argon2 parameters must
/// match the verifier's, so this has to be the *configured* package
/// rather than whatever is on `PATH`.
#[arg(long, value_name = "PATH")]
authelia_bin: Option<PathBuf>,
/// 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.
//
// The `--store` flag that named a second, private JSON store is gone
// rather than deprecated — a flag whose only remaining effect would be
// nothing reads as accepted and does nothing, where an unknown-argument
// error is loud. Not in the doc comment: `--help` is an operator
// surface, and the removal's reasoning belongs in the README.
#[arg(long, value_name = "PATH")]
users_file: Option<PathBuf>,
}
struct Paths {
authelia_bin: PathBuf,
users_file: PathBuf,
}
impl PathArgs {
fn resolve(self) -> Result<Paths> {
Ok(Paths {
authelia_bin: path_from(self.authelia_bin, "SWARMCTL_AUTHELIA_BIN")?,
users_file: path_from(self.users_file, "SWARMCTL_AUTHELIA_USERS_FILE")?,
})
}
}
fn path_from(flag: Option<PathBuf>, env: &str) -> Result<PathBuf> {
flag.or_else(|| std::env::var_os(env).map(PathBuf::from))
.with_context(|| missing(env))
}
fn missing(env: &str) -> String {
format!(
"{env} is unset and no flag was given — swarmctl is installed and \
configured by the swarm-controller nix module, which supplies it; \
running outside that deployment needs the value passed explicitly"
)
}
/// Named `Verb` rather than the conventional `Command` because
/// [`std::process::Command`] is in scope here and the clash is a
/// confusing one — the compiler reports it as an orphan-rule violation on
/// a derive, several errors away from the actual cause.
#[derive(Subcommand)]
enum Verb {
/// Manage agents across the swarm.
Agent {
#[command(subcommand)]
command: AgentVerb,
},
/// Manage scheduled prompts.
///
/// The swarm keeps each prompt, and every agent it names fires it in its
/// own container. Fire times are the schedule's start time plus whole
/// intervals: changing the body never moves them, and an agent that was
/// down fires the latest missed one once.
Schedule {
#[command(subcommand)]
command: ScheduleVerb,
},
/// Manage subjects in the swarm's SSO provider.
User {
#[command(subcommand)]
command: UserVerb,
},
/// Manage human accounts on the swarm's forge.
Forge {
#[command(subcommand)]
command: ForgeVerb,
},
/// Log in to the swarm named by `--swarm` as your own SSO account.
///
/// With no `--swarm` or `SWARMCTL_SWARM`, logs in to
/// `SWARMCTL_LOGIN_DEFAULT_SWARM`: on a swarm-controller host, the swarm
/// that host belongs to.
///
/// Opens the swarm's SSO login in your browser (and prints the URL) and
/// receives the answer on a loopback port, so run it where that browser
/// runs. swarmctl keeps the login, mode 0600, under
/// `$XDG_STATE_HOME/swarmctl/` and refreshes it as it's used. To log out,
/// delete that file.
Login,
/// Emit the full CLI reference as `CommonMark` to stdout.
///
/// Hidden tooling command used by the docs build to keep the published
/// `swarmctl` reference in lockstep with the code — same pattern as
/// `hivectl markdown-docs` (`hivectl/src/main.rs`). Deliberately
/// dispatched *before* `PathArgs::resolve()` in `main` below: this
/// verb needs none of the `SWARMCTL_AUTHELIA_*` deployment env vars,
/// and requiring them here would make `swarmctl markdown-docs` fail
/// outside a real deployment — exactly where the docs build runs it.
#[command(hide = true)]
MarkdownDocs,
/// Generate a shell completion script for `swarmctl` and print it to
/// stdout.
///
/// Supports bash, zsh, fish, elvish and powershell. The nix package
/// already installs bash/zsh/fish system-wide; this is for ad-hoc or
/// other-shell use.
///
/// Dispatched before `PathArgs::resolve()` for the same reason as
/// `markdown-docs`: emitting a completion script needs none of the
/// `SWARMCTL_AUTHELIA_*` deployment env vars, and requiring them would
/// make the package's own build-time invocation fail.
Completions {
/// Shell to emit completions for.
shell: clap_complete::Shell,
},
}
#[derive(Subcommand)]
enum AgentVerb {
/// Queue creation of a new agent on a hive in this swarm.
///
/// Asks the swarm-controller to insert its agent-creation job graph —
/// SSO identity, forge user, config repo, and the deploy message that
/// puts the agent on `--hive` — and prints the queued job's node id.
///
/// **This returns as soon as it queues the work.** It doesn't wait,
/// and a finished graph would not mean the agent is up either: the
/// last node publishes a deploy, after which the hive converges on its
/// own clock. Watch the swarm UI's job view, or the hive itself, for
/// the rest.
///
/// No approval gate guards this: the caller is root on the controller's
/// host, or logged in through the swarm domain's SSO gate.
Create(AgentCreateArgs),
/// Check one agent's forge token, and mint it if it's missing or stale.
///
/// swarm-controller does this for every agent with a store identity at
/// start and every five minutes; this is for when waiting isn't an
/// option. It leaves a current token alone.
///
/// Queues and returns, the same way `agent create` does — watch the
/// swarm UI's job view for the outcome.
MintForgeToken(AgentMintForgeTokenArgs),
/// Show the model, effort, subagent models and subagent cap the swarm sets
/// for an agent, or change the model, effort and subagent models.
///
/// `get` prints the subagent cap (`max_subagents`); `set` has no flag for
/// it and keeps a stored one. Only swarm-ui sets it.
///
/// A field the swarm sets wins over the agent's own value (its agent.nix
/// or a `hyperhive-model`/`hyperhive-effort` file) from its next turn on;
/// the agent reads its settings every ten seconds. A field the swarm
/// doesn't set, or clears, leaves the agent on its own value.
Inference {
#[command(subcommand)]
command: InferenceVerb,
},
/// Show or change the permissions the swarm grants an agent.
///
/// The agent applies its tool permissions at its next turn; the hive
/// running it restarts it, after its current turn, when
/// `manage_root_agent` changes. Unset leaves the agent on the default:
/// `messaging`, `meta`, `inbox`, `execution`, whatever its name.
Permissions {
#[command(subcommand)]
command: PermissionsVerb,
},
}
#[derive(Subcommand)]
enum PermissionsVerb {
/// Show the permissions the swarm grants an agent, or that it sets none.
Get(InferenceAgentArgs),
/// Replace the permissions the swarm grants an agent.
Set(PermissionsSetArgs),
/// Clear the agent's permissions, so the default applies again.
Clear(InferenceAgentArgs),
}
#[derive(Args)]
struct PermissionsSetArgs {
/// Agent name. A hive must declare it.
agent: String,
/// The permissions to grant, comma-separated: any of `messaging`,
/// `meta`, `inbox`, `execution`, `web_tools`, `manage_root_agent`.
/// `--permissions ''` grants none.
#[arg(
long,
value_name = "PERMISSIONS",
value_delimiter = ',',
required = true
)]
permissions: Vec<String>,
#[command(flatten)]
socket: ControllerSocketArg,
}
#[derive(Subcommand)]
enum InferenceVerb {
/// Show what the swarm sets for an agent, naming the fields it doesn't
/// set, and the model and effort the agent runs on, each with where it
/// comes from.
Get(InferenceAgentArgs),
/// Set fields for an agent. Only the flags given change; the others keep
/// what's stored.
Set(InferenceSetArgs),
/// Clear every field for an agent, so it goes back to its own values.
Clear(InferenceAgentArgs),
}
#[derive(Args)]
struct InferenceAgentArgs {
/// Agent name.
agent: String,
#[command(flatten)]
socket: ControllerSocketArg,
}
#[derive(Args)]
struct InferenceSetArgs {
/// Agent name. A hive must declare it.
agent: String,
/// The model its turns run on, as claude's `--model` takes it.
#[arg(long, value_name = "MODEL")]
model: Option<String>,
/// The reasoning effort its turns run at.
#[arg(long, value_name = "LEVEL", value_parser = ["low", "medium", "high", "xhigh", "max"])]
effort: Option<String>,
/// The models it may start subagents on, comma-separated. Stored and
/// shown; the agent doesn't apply it yet.
#[arg(long, value_name = "MODELS", value_delimiter = ',')]
subagent_models: Option<Vec<String>>,
#[command(flatten)]
socket: ControllerSocketArg,
}
#[derive(Args)]
struct AgentCreateArgs {
/// Name for the new agent: 1–63 characters of `[a-z0-9-]`.
///
/// Becomes an SSO subject, a forge user and a repository name, so
/// it's validated here before queuing. The controller refuses a name
/// the swarm has already placed on a different hive; the same name on
/// the same hive re-creates that agent.
name: String,
/// Hive in this swarm to deploy the agent to.
///
/// Required, and deliberately not defaulted: it's an *address* — the
/// hive that gets the deploy message — and only the operator knows
/// which one they mean. The controller checks it against the swarm's
/// hive roster and names the known hives if it misses.
#[arg(long, value_name = "HIVE")]
hive: String,
/// 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`.
#[arg(long, value_name = "PATH")]
controller_socket: Option<PathBuf>,
}
#[derive(Args)]
struct AgentMintForgeTokenArgs {
/// Name of an agent that already exists.
name: String,
/// 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`.
#[arg(long, value_name = "PATH")]
controller_socket: Option<PathBuf>,
}
#[derive(Subcommand)]
enum ForgeVerb {
/// Make an existing forge user a site admin.
///
/// The forge creates a human's account on their first SSO login, and
/// this fails until that login has happened. Running it on a site admin
/// changes nothing. Refused for an agent.
MakeAdmin(ForgeMakeAdminArgs),
}
#[derive(Args)]
struct ForgeMakeAdminArgs {
/// Forge username, the same as the user's SSO username.
name: String,
/// 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`.
#[arg(long, value_name = "PATH")]
controller_socket: Option<PathBuf>,
}
#[derive(Subcommand)]
enum ScheduleVerb {
/// Add a scheduled prompt and print it with its new id.
///
/// Without `--every` it fires once. Without `--at` its first fire is now.
Add(ScheduleAddArgs),
/// List scheduled prompts, one row each, with their agents and next fire
/// time.
List(ScheduleListArgs),
/// Change a scheduled prompt.
///
/// Only the flags given change, and the id stays. `--at` and `--every`
/// move the fire times; `--name`, `--body` and the agent flags don't.
Edit(ScheduleEditArgs),
/// Stop a scheduled prompt firing. Times that pass while it's paused
/// aren't fired when it resumes.
Pause(ScheduleIdArgs),
/// Let a paused scheduled prompt fire again, from its next time on.
Resume(ScheduleIdArgs),
/// Remove a scheduled prompt from every agent it names.
Rm(ScheduleIdArgs),
}
#[derive(Args)]
struct ScheduleAddArgs {
/// The prompt's title.
#[arg(long, value_name = "TEXT")]
name: String,
/// Agent to fire it in. Repeatable; at least one.
#[arg(long = "agent", value_name = "AGENT", required = true)]
agents: Vec<String>,
/// The prompt, delivered to each agent verbatim.
#[arg(long, value_name = "TEXT")]
body: String,
/// First fire time: an RFC 3339 time with its offset, such as
/// `2026-10-05T09:00:00+02:00`, or unix seconds. Defaults to now.
#[arg(long, value_name = "TIME")]
at: Option<String>,
/// Time between fires: a count with a unit of `s`, `m`, `h` or `d`,
/// such as `1h` or `7d`. Leave out for a one-shot.
#[arg(long, value_name = "DURATION")]
every: Option<String>,
/// Add it paused.
#[arg(long)]
paused: bool,
#[command(flatten)]
socket: ControllerSocketArg,
}
#[derive(Args)]
struct ScheduleListArgs {
/// Show only the prompts that fire in this agent.
#[arg(long, value_name = "AGENT")]
agent: Option<String>,
#[command(flatten)]
socket: ControllerSocketArg,
}
#[derive(Args)]
struct ScheduleEditArgs {
/// The prompt's id, as `schedule list` shows it.
id: String,
/// New title.
#[arg(long, value_name = "TEXT")]
name: Option<String>,
/// New prompt text.
#[arg(long, value_name = "TEXT")]
body: Option<String>,
/// New first fire time, in the form `schedule add --at` takes.
#[arg(long, value_name = "TIME")]
at: Option<String>,
/// New time between fires, in the form `schedule add --every` takes.
#[arg(long, value_name = "DURATION", conflicts_with = "once")]
every: Option<String>,
/// Make it a one-shot.
#[arg(long)]
once: bool,
/// Agent to start firing it in. Repeatable.
#[arg(long = "add-agent", value_name = "AGENT")]
add_agents: Vec<String>,
/// Agent to stop firing it in. Repeatable. Fails if the agent isn't a
/// target, or if no agent would remain.
#[arg(long = "remove-agent", value_name = "AGENT")]
remove_agents: Vec<String>,
#[command(flatten)]
socket: ControllerSocketArg,
}
#[derive(Args)]
struct ScheduleIdArgs {
/// The prompt's id, as `schedule list` shows it.
id: String,
#[command(flatten)]
socket: ControllerSocketArg,
}
#[derive(Args)]
struct ControllerSocketArg {
/// 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`.
#[arg(long, value_name = "PATH")]
controller_socket: Option<PathBuf>,
}
impl ControllerSocketArg {
fn resolve(self, remote: &RemoteArgs) -> Result<Target> {
remote.target(self.controller_socket)
}
}
#[derive(Subcommand)]
enum UserVerb {
/// Add a user, generating a password for them.
Add(AddArgs),
/// Set a new password for an existing user, generating it the same way
/// `user add` does.
///
/// authelia's file store has no self-service reset (no SMTP notifier),
/// so this is the only way a human account gets a new password once an
/// operator forgets the old one. Refuses if the user doesn't exist —
/// there's no separate "create" path here.
ResetPassword(ResetPasswordArgs),
/// Change an existing user's attributes.
///
/// Every flag is optional and they compose, so one call can set
/// multiple things at once. Deliberately **doesn't** touch the
/// password: regenerating a credential is a different intent from
/// editing an attribute, and folded together an attribute edit can
/// invalidate a login by accident.
Update(UpdateArgs),
/// List every user in authelia's users database.
///
/// Read-only: it never writes the file. Shows every subject in it,
/// including agent identities `swarm-authelia-bridge` created — one
/// line per user: username, display name, email (if set), groups (if
/// any).
List,
}
#[derive(Args)]
struct AddArgs {
/// Login name. Conservative ASCII only — it's a YAML map key and
/// reaches access-control rules and logs.
username: String,
/// Name shown in the SSO UI. Defaults to the username.
#[arg(long, value_name = "TEXT")]
display_name: Option<String>,
#[arg(long, value_name = "ADDRESS")]
email: Option<String>,
/// Repeatable.
#[arg(long = "group", value_name = "GROUP")]
groups: Vec<String>,
}
#[derive(Args)]
struct ResetPasswordArgs {
/// Login name of an existing user.
username: String,
}
#[derive(Args)]
struct UpdateArgs {
/// Login name of an existing user.
username: String,
/// Name shown in the SSO UI.
#[arg(long, value_name = "TEXT")]
display_name: Option<String>,
#[arg(long, value_name = "ADDRESS")]
email: Option<String>,
/// Repeatable. Adding a group the user is already in isn't an error.
#[arg(long = "add-group", value_name = "GROUP")]
add_groups: Vec<String>,
/// Repeatable. Fails if the user isn't in the group — a revocation
/// that reports success without revoking is the failure nobody
/// re-checks.
#[arg(long = "remove-group", value_name = "GROUP")]
remove_groups: Vec<String>,
}
fn main() -> Result<()> {
let Cli {
paths,
remote,
command,
} = Cli::parse();
match command {
// Resolves its own socket path, not `PathArgs`: this verb needs
// none of the `SWARMCTL_AUTHELIA_*` values, and requiring them
// would make agent creation fail on a controller host that is not
// also the swarm's SSO host.
Verb::Agent {
command: AgentVerb::Create(args),
} => {
let target = remote.target(args.controller_socket)?;
agent::create(&target, &args.name, &args.hive)
}
// Same socket-resolution reasoning as `Create` above.
Verb::Agent {
command: AgentVerb::MintForgeToken(args),
} => {
let target = remote.target(args.controller_socket)?;
agent::mint_forge_token(&target, &args.name)
}
Verb::Agent {
command: AgentVerb::Inference { command },
} => inference(&remote, command),
Verb::Agent {
command: AgentVerb::Permissions { command },
} => permissions(&remote, command),
// Same socket-resolution reasoning as `Create` above.
Verb::Forge {
command: ForgeVerb::MakeAdmin(args),
} => {
let target = remote.target(args.controller_socket)?;
forge::make_admin(&target, &args.name)
}
// Same socket-resolution reasoning as `Create` above.
Verb::Schedule { command } => schedule(&remote, command),
Verb::Login => login::login(
&remote
.login_swarm()?
.context("`swarmctl login` needs `--swarm` or `SWARMCTL_SWARM`")?,
),
// Resolved lazily, inside the one arm that actually touches the
// deployment env vars — see the `MarkdownDocs` doc comment above
// for why an unconditional resolve up front would be wrong.
Verb::User {
command: UserVerb::Add(args),
} => user_add(&paths.resolve()?, args),
Verb::User {
command: UserVerb::ResetPassword(args),
} => user_reset_password(&paths.resolve()?, &args),
Verb::User {
command: UserVerb::Update(args),
} => user_update(&paths.resolve()?, args),
Verb::User {
command: UserVerb::List,
} => user_list(&paths.resolve()?),
Verb::MarkdownDocs => {
// `show_footer(false)`: drop clap-markdown's own fixed
// "This document was generated automatically by..." footer —
// it's the one string in this doc that isn't ours to reword.
let options = clap_markdown::MarkdownOptions::new().show_footer(false);
print!("{}", clap_markdown::help_markdown_custom::<Cli>(&options));
Ok(())
}
Verb::Completions { shell } => {
// Generated from the live clap tree — the same single source
// of truth `markdown-docs` renders — so completions cannot
// drift from the actual verbs and flags.
use clap::CommandFactory as _;
clap_complete::generate(
shell,
&mut Cli::command(),
"swarmctl",
&mut std::io::stdout(),
);
Ok(())
}
}
}
fn inference(remote: &RemoteArgs, command: InferenceVerb) -> Result<()> {
match command {
InferenceVerb::Get(a) => inference::get(&a.socket.resolve(remote)?, &a.agent),
InferenceVerb::Set(a) => inference::set(
&a.socket.resolve(remote)?,
&a.agent,
inference::SetFlags {
model: a.model,
effort: a.effort,
subagent_models: a.subagent_models,
},
),
InferenceVerb::Clear(a) => inference::clear(&a.socket.resolve(remote)?, &a.agent),
}
}
fn permissions(remote: &RemoteArgs, command: PermissionsVerb) -> Result<()> {
match command {
PermissionsVerb::Get(a) => permissions::get(&a.socket.resolve(remote)?, &a.agent),
PermissionsVerb::Set(a) => {
permissions::set(&a.socket.resolve(remote)?, &a.agent, a.permissions)
}
PermissionsVerb::Clear(a) => permissions::clear(&a.socket.resolve(remote)?, &a.agent),
}
}
fn schedule(remote: &RemoteArgs, command: ScheduleVerb) -> Result<()> {
match command {
ScheduleVerb::Add(a) => schedule::add(
&a.socket.resolve(remote)?,
&schedule::AddFlags {
name: &a.name,
agents: &a.agents,
body: &a.body,
at: a.at.as_deref(),
every: a.every.as_deref(),
paused: a.paused,
},
),
ScheduleVerb::List(a) => schedule::list(&a.socket.resolve(remote)?, a.agent.as_deref()),
ScheduleVerb::Edit(a) => {
let edit = schedule::EditRequest::new(schedule::EditFlags {
name: a.name,
body: a.body,
at: a.at.as_deref(),
every: a.every.as_deref(),
once: a.once,
add_agents: a.add_agents,
remove_agents: a.remove_agents,
})?;
schedule::edit(&a.socket.resolve(remote)?, &a.id, &edit)
}
ScheduleVerb::Pause(a) => schedule::set_paused(&a.socket.resolve(remote)?, &a.id, true),
ScheduleVerb::Resume(a) => schedule::set_paused(&a.socket.resolve(remote)?, &a.id, false),
ScheduleVerb::Rm(a) => schedule::remove(&a.socket.resolve(remote)?, &a.id),
}
}
fn user_add(paths: &Paths, args: AddArgs) -> Result<()> {
users::validate_username(&args.username)?;
let mut store = load_store(&paths.users_file)?;
if store.users.contains_key(&args.username) {
bail!(
"user {:?} already exists in {}",
args.username,
paths.users_file.display()
);
}
let generated = generate_password(&paths.authelia_bin)?;
let display_name = args.display_name.unwrap_or_else(|| args.username.clone());
store.users.insert(
args.username.clone(),
User {
displayname: display_name,
password: generated.digest,
email: args.email,
groups: args.groups,
extra: std::collections::BTreeMap::new(),
},
);
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(())
}
fn user_reset_password(paths: &Paths, args: &ResetPasswordArgs) -> Result<()> {
let mut store = load_store(&paths.users_file)?;
let Some(user) = store.users.get_mut(&args.username) else {
bail!(
"no user {:?} in {} — `swarmctl user add` creates one",
args.username,
paths.users_file.display()
);
};
let generated = generate_password(&paths.authelia_bin)?;
users::reset_password(user, generated.digest);
publish(paths, &mut store)?;
println!(
"reset password for {} in {}",
args.username,
paths.users_file.display()
);
println!("password: {}", generated.password);
println!("this password is stored nowhere — record it now");
Ok(())
}
fn user_update(paths: &Paths, args: UpdateArgs) -> Result<()> {
let mut store = load_store(&paths.users_file)?;
let Some(user) = store.users.get_mut(&args.username) else {
bail!(
"no user {:?} in {} — `swarmctl user add` creates one",
args.username,
paths.users_file.display()
);
};
let changes = users::apply_update(
user,
&users::UserUpdate {
displayname: args.display_name,
email: args.email,
add_groups: args.add_groups,
remove_groups: args.remove_groups,
},
)?;
// Read back before the borrow ends: this is what the operator gets
// instead of a group registry we don't have — a typo'd `--add-group`
// is a real group with nobody reading it, and seeing the resulting
// list is the only way to notice.
let groups = users::fmt_groups(&user.groups);
publish(paths, &mut store)?;
for change in &changes {
println!("{change}");
}
println!("{} is now in groups: {groups}", args.username);
Ok(())
}
/// `swarmctl user list` — read-only: it loads authelia's users file and
/// writes nothing. One line per user, agent identities included.
fn user_list(paths: &Paths) -> Result<()> {
use std::fmt::Write as _;
let store = load_store(&paths.users_file)?;
if store.users.is_empty() {
println!("no users in {}", paths.users_file.display());
return Ok(());
}
for (username, user) in &store.users {
let mut line = format!("{username}\t{}", user.displayname);
if let Some(email) = &user.email {
let _ = write!(line, "\t{email}");
}
if !user.groups.is_empty() {
let _ = write!(line, "\tgroups: {}", users::fmt_groups(&user.groups));
}
println!("{line}");
}
Ok(())
}
/// Write the users database.
///
/// Shared by every verb that mutates it, so the rules below hold for all of
/// them rather than for whichever one was written first.
fn publish(paths: &Paths, store: &mut UserStore) -> Result<()> {
// Before rendering, not at creation: a user can also arrive by being
// *read* — from a file the bridge wrote, or one an operator edited —
// and an authelia subject with no `email` breaks any relying party that
// asks for the claim (grafana's OIDC login is the measured case: it
// fails outright rather than degrading). Filling it here is the only
// place no entry point can skip.
for name in users::fill_missing_emails(store) {
println!("note: {name} had no email; set to a synthetic address");
}
// Render before writing: a value this refuses to emit should stop the
// whole operation rather than land a partial file.
let rendered = users::render_yaml(store)?;
write_atomic(&paths.users_file, &rendered)?;
// No restart: authelia watches this file
// (`authentication_backend.file.watch`). Deliberately not `swarmctl`'s job
// — `swarm-authelia-bridge` writes the same file and *cannot* restart
// anything, since running unprivileged is the whole reason it may write
// it. A reload that depends on which process wrote is not a reload.
Ok(())
}
/// Load the users database, or start empty when it does not exist yet.
///
/// ⚠️ **The overwrite guard that used to live here is gone, and its absence
/// is the fix rather than a regression.** It refused to write when this
/// crate's own JSON store was missing but `users.yml` held users — it
/// existed to police *two* stores that could disagree, and there is now one.
/// Reading the same file we are about to write means we cannot clobber users
/// we did not know about: we just read them.
///
/// An absent or empty file is an empty store, not an error, and so is
/// `users: {}`, with no special case. The `swarm-authelia` module seeds an
/// empty database with one disabled placeholder subject, which loads like any
/// other user.
fn load_store(users_file: &Path) -> Result<UserStore> {
match fs::read_to_string(users_file) {
Ok(raw) if raw.trim().is_empty() => Ok(UserStore::default()),
Ok(raw) => serde_norway::from_str(&raw)
.with_context(|| format!("parsing the users database at {}", users_file.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(UserStore::default()),
Err(e) => Err(e).with_context(|| format!("reading {}", users_file.display())),
}
}
struct Generated {
password: String,
digest: String,
}
/// Generate a password and its argon2 digest using the configured
/// authelia.
///
/// 🚨 `--random` rather than `--password <pw>` is a security requirement,
/// not a convenience: `/proc/<pid>/cmdline` is world-readable, so a
/// password passed on argv is readable by any local process for the
/// lifetime of the call. Letting authelia generate it means the plaintext
/// never crosses a command line at all.
fn generate_password(bin: &Path) -> Result<Generated> {
let out = Command::new(bin)
.args(["crypto", "hash", "generate", "argon2", "--random"])
.output()
.with_context(|| format!("running {}", bin.display()))?;
if !out.status.success() {
bail!(
"{} failed ({}): {}",
bin.display(),
out.status,
String::from_utf8_lossy(&out.stderr).trim()
);
}
let stdout = String::from_utf8(out.stdout).context("authelia printed non-UTF-8 output")?;
let password = parse_field(&stdout, "Random Password:");
let digest = parse_field(&stdout, "Digest:");
// The raw output is deliberately NOT included in this error: it
// contains the freshly generated plaintext, and an error message is
// exactly the thing that ends up in a log or a bug report. Naming the
// missing marker is enough to diagnose an upstream format change —
// run the command by hand to see the rest.
match (password, digest) {
(Some(password), Some(digest)) => Ok(Generated { password, digest }),
(password, digest) => bail!(
"could not parse {}'s output: {}{}missing",
bin.display(),
if password.is_none() {
"'Random Password:' "
} else {
""
},
if digest.is_none() { "'Digest:' " } else { "" }
),
}
}
fn parse_field(stdout: &str, marker: &str) -> Option<String> {
stdout
.lines()
.find_map(|line| line.trim().strip_prefix(marker))
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty())
}
/// Replace `path`'s contents atomically, preserving the existing owner
/// and mode.
///
/// Atomic because a reader must never see a half-written user database,
/// and because the temp file is created in the *same directory* —
/// `rename(2)` is only atomic within a filesystem.
///
/// Owner and mode are read off the existing file rather than asserted:
/// authelia's file is created by its own unit as its own user, and
/// stamping our idea of the right values onto it would silently
/// re-permission a file another service opens. Both are applied to the
/// temp file *before* the rename, so the finished file is never visible
/// with the wrong ones.
fn write_atomic(path: &Path, contents: &str) -> Result<()> {
let dir = path
.parent()
.with_context(|| format!("{} has no parent directory", path.display()))?;
fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
let name = path
.file_name()
.with_context(|| format!("{} has no file name", path.display()))?;
let tmp = dir.join(format!(".{}.swarmctl.tmp", name.to_string_lossy()));
let existing = fs::metadata(path).ok();
// 0600 only when the file does not exist yet: this content is
// password hashes, so the conservative value is the right default and
// the existing value is the right answer.
let mode = existing
.as_ref()
.map_or(0o600, |meta| meta.permissions().mode() & 0o7777);
let mut file = File::create(&tmp).with_context(|| format!("creating {}", tmp.display()))?;
file.write_all(contents.as_bytes())
.with_context(|| format!("writing {}", tmp.display()))?;
file.sync_all()
.with_context(|| format!("flushing {}", tmp.display()))?;
drop(file);
fs::set_permissions(&tmp, Permissions::from_mode(mode))
.with_context(|| format!("setting mode on {}", tmp.display()))?;
if let Some(meta) = existing.as_ref() {
std::os::unix::fs::chown(&tmp, Some(meta.uid()), Some(meta.gid()))
.with_context(|| format!("setting owner on {}", tmp.display()))?;
}
fs::rename(&tmp, path).with_context(|| format!("renaming {} into place", tmp.display()))?;
// The rename itself is metadata: without this the file can survive a
// crash while the directory entry pointing at it does not.
File::open(dir)
.and_then(|d| d.sync_all())
.with_context(|| format!("flushing directory {}", dir.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// Root on the controller host, with no `--swarm` and no login: the verbs
/// still go over the unix socket, unchanged by the remote path.
#[test]
fn with_no_swarm_named_the_verbs_use_the_controller_socket() {
assert!(
std::env::var_os("SWARMCTL_SWARM").is_none(),
"this test needs SWARMCTL_SWARM unset"
);
let dir = std::env::temp_dir().join(format!("swarmctl-socket-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("dir");
let socket = dir.join("controller.sock");
let listener = std::os::unix::net::UnixListener::bind(&socket).expect("bind");
listener.set_nonblocking(true).expect("nonblocking");
let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let server_seen = std::sync::Arc::clone(&seen);
std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("runtime");
rt.block_on(async move {
let listener = tokio::net::UnixListener::from_std(listener).expect("listener");
let (stream, _) = listener.accept().await.expect("accept");
let service = hyper::service::service_fn(
move |req: hyper::Request<hyper::body::Incoming>| {
server_seen.lock().expect("seen").push(format!(
"{} {} auth={}",
req.method(),
req.uri(),
req.headers().contains_key(hyper::header::AUTHORIZATION)
));
async {
Ok::<_, std::convert::Infallible>(hyper::Response::new(
http_body_util::Full::new(bytes::Bytes::from_static(b"[]")),
))
}
},
);
let _ = hyper::server::conn::http1::Builder::new()
.serve_connection(hyper_util::rt::TokioIo::new(stream), service)
.await;
});
});
let cli = Cli::try_parse_from([
"swarmctl",
"schedule",
"list",
"--controller-socket",
socket.to_str().expect("utf-8"),
])
.expect("parses");
let Verb::Schedule {
command: ScheduleVerb::List(args),
} = cli.command
else {
panic!("parsed as another verb");
};
let target = args.socket.resolve(&cli.remote).expect("resolves");
assert!(matches!(&target, Target::Socket(p) if *p == socket));
schedule::list(&target, None).expect("lists over the socket");
assert_eq!(
*seen.lock().expect("seen"),
["GET /api/schedules auth=false"]
);
fs::remove_dir_all(&dir).expect("cleanup");
}
/// clap's own consistency checks over the whole tree — a duplicated
/// long flag or a malformed `value_name` panics at parse time in
/// production and is otherwise only found by running the binary.
#[test]
fn the_clap_tree_is_well_formed() {
use clap::CommandFactory as _;
Cli::command().debug_assert();
}
#[test]
fn swarm_parses_before_and_after_the_subcommand() {
for argv in [
["swarmctl", "--swarm", "s.test", "login"],
["swarmctl", "login", "--swarm", "s.test"],
] {
let cli = Cli::try_parse_from(argv).expect("parses");
assert_eq!(cli.remote.swarm.as_deref(), Some("s.test"), "{argv:?}");
}
let cli = Cli::try_parse_from(["swarmctl", "schedule", "list", "--ca-file", "/ca.pem"])
.expect("parses");
assert_eq!(cli.remote.ca_file.as_deref(), Some(Path::new("/ca.pem")));
}
#[test]
fn login_help_lists_swarm_and_its_env_fallback() {
let Err(err) = Cli::try_parse_from(["swarmctl", "login", "--help"]) else {
panic!("--help parsed as a command");
};
assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
let help = err.to_string();
assert!(help.contains("--swarm <URL>"), "{help}");
assert!(help.contains("SWARMCTL_SWARM"), "{help}");
}
/// `--swarm` over `SWARMCTL_SWARM` over the login-only default, and only
/// `login` reads the default: anywhere else it would turn the socket verbs
/// into gateway calls.
#[test]
fn the_swarm_resolves_flag_then_env_then_the_login_default() {
let env = |set: &'static [(&'static str, &'static str)]| {
move |name: &str| {
set.iter()
.find(|(k, _)| *k == name)
.map(|(_, v)| (*v).to_owned())
}
};
let both = env(&[
("SWARMCTL_SWARM", "env.test"),
("SWARMCTL_LOGIN_DEFAULT_SWARM", "default.test"),
]);
let only_default = env(&[
("SWARMCTL_SWARM", ""),
("SWARMCTL_LOGIN_DEFAULT_SWARM", "default.test"),
]);
let flag =
Cli::try_parse_from(["swarmctl", "login", "--swarm", "flag.test"]).expect("parses");
assert_eq!(
flag.remote.swarm_url(LOGIN_SWARM_ENVS, both).as_deref(),
Some("flag.test")
);
let bare = Cli::try_parse_from(["swarmctl", "login"]).expect("parses");
assert_eq!(
bare.remote.swarm_url(LOGIN_SWARM_ENVS, both).as_deref(),
Some("env.test")
);
assert_eq!(
bare.remote.swarm_url(SWARM_ENVS, both).as_deref(),
Some("env.test")
);
assert_eq!(
bare.remote
.swarm_url(LOGIN_SWARM_ENVS, only_default)
.as_deref(),
Some("default.test")
);
assert_eq!(bare.remote.swarm_url(SWARM_ENVS, only_default), None);
let empty = Cli::try_parse_from(["swarmctl", "login", "--swarm", ""]).expect("parses");
assert_eq!(empty.remote.swarm_url(LOGIN_SWARM_ENVS, both), None);
}
/// A schedule with no agent would fire nowhere, so `--agent` is required
/// at parse time, and repeats collect into the target list.
#[test]
fn schedule_add_takes_one_or_more_agents() {
let base = ["swarmctl", "schedule", "add", "--name", "n", "--body", "b"];
assert!(Cli::try_parse_from(base).is_err());
let cli = Cli::try_parse_from(base.iter().copied().chain(["--agent", "a", "--agent", "b"]))
.expect("parses");
let Verb::Schedule {
command: ScheduleVerb::Add(args),
} = cli.command
else {
panic!("parsed as another verb");
};
assert_eq!(args.agents, ["a", "b"]);
}
/// `--hive` carries the address the agent is deployed to and the
/// endpoint has no default for it, so omitting it has to fail at parse
/// time rather than reach the controller as an empty string.
#[test]
fn agent_create_requires_a_hive() {
assert!(
Cli::try_parse_from(["swarmctl", "agent", "create", "scribe"]).is_err(),
"a create with no --hive must not parse"
);
}
#[test]
fn agent_create_parses_its_name_hive_and_socket() {
let cli = Cli::try_parse_from([
"swarmctl",
"agent",
"create",
"scribe",
"--hive",
"alpha",
"--controller-socket",
"/run/elsewhere/controller.sock",
])
.expect("the full form parses");
let Verb::Agent {
command: AgentVerb::Create(args),
} = cli.command
else {
panic!("expected `agent create`");
};
assert_eq!(args.name, "scribe");
assert_eq!(args.hive, "alpha");
assert_eq!(
args.controller_socket.as_deref(),
Some(Path::new("/run/elsewhere/controller.sock"))
);
}
/// The socket is optional on the command line because the nix wrapper
/// sets `SWARM_CONTROLLER_SOCKET`; `path_from` is what turns an absent
/// pair into an error rather than a guess.
#[test]
fn an_omitted_socket_flag_leaves_the_env_to_supply_it() {
let cli = Cli::try_parse_from(["swarmctl", "agent", "create", "scribe", "--hive", "alpha"])
.expect("the minimal form parses");
let Verb::Agent {
command: AgentVerb::Create(args),
} = cli.command
else {
panic!("expected `agent create`");
};
assert!(args.controller_socket.is_none());
}
#[test]
fn the_forge_token_verb_takes_an_agent_and_no_hive() {
let cli = Cli::try_parse_from(["swarmctl", "agent", "mint-forge-token", "scribe"])
.expect("the minimal form parses");
let Verb::Agent {
command: AgentVerb::MintForgeToken(args),
} = cli.command
else {
panic!("expected `agent mint-forge-token`");
};
assert_eq!(args.name, "scribe");
assert!(
Cli::try_parse_from([
"swarmctl",
"agent",
"mint-forge-token",
"scribe",
"--hive",
"a"
])
.is_err(),
"the token has no hive, so the verb must not take one"
);
}
#[test]
fn the_make_admin_verb_takes_a_user_name() {
let cli = Cli::try_parse_from(["swarmctl", "forge", "make-admin", "mara"])
.expect("the minimal form parses");
let Verb::Forge {
command: ForgeVerb::MakeAdmin(args),
} = cli.command
else {
panic!("expected `forge make-admin`");
};
assert_eq!(args.name, "mara");
assert!(args.controller_socket.is_none());
assert!(
Cli::try_parse_from(["swarmctl", "forge", "make-admin"]).is_err(),
"the user must be named"
);
}
#[test]
fn reset_password_takes_only_a_username() {
let cli = Cli::try_parse_from(["swarmctl", "user", "reset-password", "mara"])
.expect("the minimal form parses");
let Verb::User {
command: UserVerb::ResetPassword(args),
} = cli.command
else {
panic!("expected `user reset-password`");
};
assert_eq!(args.username, "mara");
assert!(
Cli::try_parse_from(["swarmctl", "user", "reset-password"]).is_err(),
"the user must be named"
);
}
/// The unknown-user check runs before authelia is ever invoked, so this
/// exercises it with a bogus `authelia_bin` — it would fail loudly if
/// this ever started shelling out before checking the user exists.
#[test]
fn reset_password_on_an_unknown_user_is_an_error_before_touching_authelia() {
let dir = std::env::temp_dir().join(format!("swarmctl-reset-{}", std::process::id()));
fs::create_dir_all(&dir).expect("temp dir");
let users_file = dir.join("users.yml");
fs::write(&users_file, "users: {}\n").expect("seed");
let paths = Paths {
authelia_bin: PathBuf::from("/nonexistent/authelia"),
users_file,
};
let err = user_reset_password(
&paths,
&ResetPasswordArgs {
username: "mara".to_owned(),
},
)
.expect_err("resetting a user that doesn't exist must fail");
assert!(err.to_string().contains("no user"), "{err}");
fs::remove_dir_all(&dir).ok();
}
#[test]
fn parses_authelia_hash_output() {
let out = "Random Password: hunter2\nDigest: $argon2id$v=19$m=65536$abc\n";
assert_eq!(
parse_field(out, "Random Password:").as_deref(),
Some("hunter2")
);
assert_eq!(
parse_field(out, "Digest:").as_deref(),
Some("$argon2id$v=19$m=65536$abc")
);
}
/// An upstream format change must read as "missing", not as an empty
/// password silently written into the store.
#[test]
fn an_empty_field_reads_as_missing() {
assert_eq!(parse_field("Digest: \n", "Digest:"), None);
assert_eq!(parse_field("nothing here\n", "Digest:"), None);
}
#[test]
fn write_atomic_preserves_an_existing_files_mode() {
let dir = std::env::temp_dir().join(format!("swarmctl-test-{}", std::process::id()));
fs::create_dir_all(&dir).expect("temp dir");
let path = dir.join("users.yml");
fs::write(&path, "users: {}\n").expect("seed");
fs::set_permissions(&path, Permissions::from_mode(0o640)).expect("chmod");
write_atomic(&path, "users:\n mara:\n").expect("rewrite");
let mode = fs::metadata(&path).expect("stat").permissions().mode() & 0o7777;
assert_eq!(mode, 0o640, "the existing mode must survive the replace");
assert_eq!(
fs::read_to_string(&path).expect("read"),
"users:\n mara:\n"
);
fs::remove_dir_all(&dir).ok();
}
#[test]
fn write_atomic_defaults_a_new_file_to_0600() {
let dir = std::env::temp_dir().join(format!("swarmctl-new-{}", std::process::id()));
fs::create_dir_all(&dir).expect("temp dir");
let path = dir.join("users.json");
write_atomic(&path, "{}\n").expect("write");
let mode = fs::metadata(&path).expect("stat").permissions().mode() & 0o7777;
assert_eq!(mode, 0o600, "a new store holds password hashes");
fs::remove_dir_all(&dir).ok();
}
/// Replaces `load_store_refuses_to_take_over_a_populated_users_file`,
/// which pinned the guard this change removes.
///
/// The guard refused to write when this crate's own JSON store was
/// missing but `users.yml` held users — the state every real hive is in,
/// and the reason `swarm agent create` failed. Deleting the test with
/// the guard would have left the *new* behaviour unpinned, so it is
/// inverted rather than dropped: a populated file must now be **read**,
/// and users somebody else wrote must survive.
#[test]
fn a_populated_users_file_is_read_rather_than_refused() {
let dir = std::env::temp_dir().join(format!("swarmctl-takeover-{}", std::process::id()));
fs::create_dir_all(&dir).expect("temp dir");
let users_file = dir.join("users.yml");
fs::write(
&users_file,
"users:\n mara:\n displayname: \"mara\"\n password: \"x\"\n",
)
.expect("write");
let store = load_store(&users_file).expect("a populated file is readable");
assert!(
store.users.contains_key("mara"),
"a user this process did not write must be read, not refused"
);
fs::write(&users_file, "users: {}\n").expect("seed");
assert!(
load_store(&users_file)
.expect("`users: {}` loads")
.users
.is_empty(),
"`users: {{}}` is an empty store, with no special case"
);
fs::remove_dir_all(&dir).ok();
}
}