split hivectl main.rs into per-domain modules (#2509)
This commit is contained in:
parent
673aea4e50
commit
fc7720572b
16 changed files with 1922 additions and 1771 deletions
164
hivectl/src/agents.rs
Normal file
164
hivectl/src/agents.rs
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
//! `hivectl agents` — container lifecycle over the host admin socket
|
||||
//! (list/restart/restart-all/spawn/kill/destroy/rebuild/set-parent).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use hive_host_sock::HostRequest;
|
||||
|
||||
use crate::cli::AgentsCmd;
|
||||
use crate::dag_progress::wait_for_dags;
|
||||
use crate::util::render;
|
||||
|
||||
async fn agents_restart(socket: &Path, name: &str, no_wait: bool) -> Result<()> {
|
||||
let resp = crate::client::request(
|
||||
socket,
|
||||
hive_host_sock::HostRequest::Restart {
|
||||
name: name.to_owned(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("connect to daemon socket {}", socket.display()))?;
|
||||
if resp.ok {
|
||||
println!("restart queued: {name}");
|
||||
wait_for_dags(socket, resp.queued_dags.unwrap_or_default(), no_wait).await
|
||||
} else {
|
||||
bail!(
|
||||
"restart {name}: {}",
|
||||
resp.error.as_deref().unwrap_or("unknown error")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// `hivectl agents list` — fetch the per-agent status roster from the
|
||||
/// daemon (`HostRequest::AgentStatus`) and render it as a padded table,
|
||||
/// or the raw JSON rows with `--json`. Reuses the dashboard's
|
||||
/// `ContainerView` aggregation, so the CLI and the web UI never drift.
|
||||
async fn agents_list(socket: &Path, json: bool) -> Result<()> {
|
||||
let resp = crate::client::request(socket, hive_host_sock::HostRequest::AgentStatus)
|
||||
.await
|
||||
.with_context(|| format!("connect to daemon socket {}", socket.display()))?;
|
||||
if !resp.ok {
|
||||
bail!(
|
||||
"agents list: {}",
|
||||
resp.error.as_deref().unwrap_or("unknown error")
|
||||
);
|
||||
}
|
||||
let rows = resp.agent_statuses.unwrap_or_default();
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&rows)?);
|
||||
return Ok(());
|
||||
}
|
||||
if rows.is_empty() {
|
||||
println!("no managed agents found");
|
||||
return Ok(());
|
||||
}
|
||||
// STATUS collapses the health flags into one space-separated token so
|
||||
// the common case (`running`) stays short and anomalies stand out.
|
||||
let status_of = |r: &hive_sh4re::AgentStatusRow| -> String {
|
||||
let mut s = if r.running { "running" } else { "stopped" }.to_owned();
|
||||
if r.needs_login {
|
||||
s.push_str(" needs-login");
|
||||
}
|
||||
if r.needs_update {
|
||||
s.push_str(" needs-update");
|
||||
}
|
||||
s
|
||||
};
|
||||
let headers = ["NAME", "STATUS", "REV", "PARENT", "REMIND"];
|
||||
let table: Vec<[String; 5]> = rows
|
||||
.iter()
|
||||
.map(|r| {
|
||||
[
|
||||
r.name.clone(),
|
||||
status_of(r),
|
||||
r.deployed_sha.clone().unwrap_or_else(|| "-".to_owned()),
|
||||
r.parent.clone().unwrap_or_else(|| "-".to_owned()),
|
||||
if r.pending_reminders > 0 {
|
||||
r.pending_reminders.to_string()
|
||||
} else {
|
||||
"-".to_owned()
|
||||
},
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
let mut widths = headers.map(str::len);
|
||||
for row in &table {
|
||||
for (i, cell) in row.iter().enumerate() {
|
||||
widths[i] = widths[i].max(cell.len());
|
||||
}
|
||||
}
|
||||
let fmt_row = |cells: &[String]| -> String {
|
||||
cells
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, c)| format!("{c:<w$}", w = widths[i]))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.trim_end()
|
||||
.to_owned()
|
||||
};
|
||||
let header_cells: Vec<String> = headers.iter().map(|h| (*h).to_owned()).collect();
|
||||
println!("{}", fmt_row(&header_cells));
|
||||
for row in &table {
|
||||
println!("{}", fmt_row(row));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn agents_restart_all(socket: &Path, no_wait: bool) -> Result<()> {
|
||||
let resp = crate::client::request(socket, hive_host_sock::HostRequest::RestartAll)
|
||||
.await
|
||||
.with_context(|| format!("connect to daemon socket {}", socket.display()))?;
|
||||
let agents = resp.agents.as_deref().unwrap_or(&[]);
|
||||
if agents.is_empty() {
|
||||
println!("restart-all: no managed containers found");
|
||||
} else {
|
||||
for a in agents {
|
||||
println!("restart queued: {a}");
|
||||
}
|
||||
}
|
||||
if !resp.ok {
|
||||
bail!(
|
||||
"restart-all: {}",
|
||||
resp.error.as_deref().unwrap_or("unknown error")
|
||||
);
|
||||
}
|
||||
wait_for_dags(socket, resp.queued_dags.unwrap_or_default(), no_wait).await
|
||||
}
|
||||
|
||||
/// Dispatch `hivectl agents <verb>` — container lifecycle over the host
|
||||
/// admin socket.
|
||||
pub(crate) async fn run_agents(socket: &Path, cmd: AgentsCmd) -> Result<()> {
|
||||
match cmd {
|
||||
AgentsCmd::List { json } => agents_list(socket, json).await,
|
||||
AgentsCmd::Restart { name, no_wait } => agents_restart(socket, &name, no_wait).await,
|
||||
AgentsCmd::RestartAll { no_wait } => agents_restart_all(socket, no_wait).await,
|
||||
AgentsCmd::Spawn { name } => {
|
||||
render(crate::client::request(socket, HostRequest::Spawn { name }).await?)
|
||||
}
|
||||
AgentsCmd::RequestSpawn { name } => {
|
||||
render(crate::client::request(socket, HostRequest::RequestSpawn { name }).await?)
|
||||
}
|
||||
AgentsCmd::Kill { name } => {
|
||||
render(crate::client::request(socket, HostRequest::Kill { name }).await?)
|
||||
}
|
||||
AgentsCmd::Destroy { name, purge } => {
|
||||
render(crate::client::request(socket, HostRequest::Destroy { name, purge }).await?)
|
||||
}
|
||||
AgentsCmd::Rebuild { name } => {
|
||||
render(crate::client::request(socket, HostRequest::Rebuild { name }).await?)
|
||||
}
|
||||
AgentsCmd::SetParent {
|
||||
child,
|
||||
parent,
|
||||
root,
|
||||
} => {
|
||||
let new_parent = if root { None } else { parent };
|
||||
render(
|
||||
crate::client::request(socket, HostRequest::SetParent { child, new_parent })
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
24
hivectl/src/approvals.rs
Normal file
24
hivectl/src/approvals.rs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
//! `hivectl approvals` — the operator approval queue (pending/approve/deny).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
use hive_host_sock::HostRequest;
|
||||
|
||||
use crate::cli::ApprovalsCmd;
|
||||
use crate::util::render;
|
||||
|
||||
/// Dispatch `hivectl approvals <verb>` — the operator approval queue.
|
||||
pub(crate) async fn run_approvals(socket: &Path, cmd: ApprovalsCmd) -> Result<()> {
|
||||
match cmd {
|
||||
ApprovalsCmd::Pending => {
|
||||
render(crate::client::request(socket, HostRequest::Pending).await?)
|
||||
}
|
||||
ApprovalsCmd::Approve { id } => {
|
||||
render(crate::client::request(socket, HostRequest::Approve { id }).await?)
|
||||
}
|
||||
ApprovalsCmd::Deny { id } => {
|
||||
render(crate::client::request(socket, HostRequest::Deny { id }).await?)
|
||||
}
|
||||
}
|
||||
}
|
||||
74
hivectl/src/choom.rs
Normal file
74
hivectl/src/choom.rs
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
//! `hivectl choom <agent>` — drop into an interactive Claude session inside
|
||||
//! an agent container by exec-ing `machinectl shell` running claude as the
|
||||
//! agent user, mirroring the harness's per-turn claude invocation.
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::process::CommandExt as _;
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
|
||||
use crate::util::agent_exists;
|
||||
|
||||
/// Drop into an interactive Claude session in the agent container.
|
||||
///
|
||||
/// Execs `machinectl shell <name>@h-<name>` running claude as the agent
|
||||
/// user from its state dir, reproducing the harness's per-turn claude
|
||||
/// invocation (flags, session selection, why it never collides with the
|
||||
/// live harness session). See `docs/tools/hivectl.md` (Choom) for the
|
||||
/// full rationale. Inherits the caller's PTY; requires root + a running
|
||||
/// container.
|
||||
pub(crate) fn choom(name: &str, resume_session: Option<&str>) -> Result<()> {
|
||||
if !agent_exists(name)? {
|
||||
bail!(
|
||||
"no such agent: '{name}' (no state dir under {}/)",
|
||||
hive_host_sock::AGENTS_ROOT
|
||||
);
|
||||
}
|
||||
let container = hive_host_sock::container_name(name);
|
||||
// Enter as the agent's unix user (== agent name) so claude reads the
|
||||
// right `$HOME/.claude`.
|
||||
let target = format!("{name}@{container}");
|
||||
let claude = "/run/current-system/sw/bin/claude";
|
||||
// Bind-mounted state dir; matches `hive-agent::paths::state_dir()`.
|
||||
let state_dir = format!("/agents/{name}/state");
|
||||
// Per-turn config the harness writes; matches `paths::config_dir()`.
|
||||
let cfg = "/run/hive-config";
|
||||
// `--resume <value>` passes through as `claude --resume <value>` —
|
||||
// the rejoin-by-id surface. (claude's `--continue` is a bare flag
|
||||
// that resumes the cwd's latest session — the harness's — and would
|
||||
// consume a trailing value as the first PROMPT; choom never uses
|
||||
// it.) The value is single-quoted into the shell script, so reject
|
||||
// an embedded single quote (the only char that breaks
|
||||
// single-quoting) to rule out injection — session ids never
|
||||
// contain one.
|
||||
let session_arg = match resume_session {
|
||||
Some(val) => {
|
||||
if val.contains('\'') {
|
||||
bail!("invalid --resume value '{val}': must not contain a single quote");
|
||||
}
|
||||
format!("set -- --resume '{val}';")
|
||||
}
|
||||
None => "set --;".to_string(),
|
||||
};
|
||||
// Build claude's argv as the harness does, each flag included only
|
||||
// when its file is present so a half-up container degrades to a bare
|
||||
// session. `name` is `[a-z0-9._-]` so there's nothing to quote.
|
||||
let inner = format!(
|
||||
"cd {state_dir} || exit 1; {session_arg} \
|
||||
[ -f {cfg}/claude-settings.json ] && set -- \"$@\" --settings {cfg}/claude-settings.json; \
|
||||
[ -f {cfg}/claude-mcp-config.json ] && set -- \"$@\" --mcp-config {cfg}/claude-mcp-config.json; \
|
||||
[ -f {cfg}/claude-system-prompt.md ] && set -- \"$@\" --system-prompt-file {cfg}/claude-system-prompt.md; \
|
||||
exec {claude} \"$@\""
|
||||
);
|
||||
let mut cmd = std::process::Command::new("machinectl");
|
||||
cmd.arg("shell")
|
||||
.arg(&target)
|
||||
.arg("/bin/sh")
|
||||
.arg("-lc")
|
||||
.arg(&inner);
|
||||
// exec() replaces the current process — we inherit stdin/stdout/stderr
|
||||
// (the caller's PTY) so the Claude session is fully interactive.
|
||||
// This call only returns on error.
|
||||
let err = cmd.exec();
|
||||
Err(anyhow::anyhow!("exec machinectl: {err}"))
|
||||
}
|
||||
591
hivectl/src/cli.rs
Normal file
591
hivectl/src/cli.rs
Normal file
|
|
@ -0,0 +1,591 @@
|
|||
//! hivectl clap command tree: the `Cli` root, the top-level `Cmd`
|
||||
//! verb enum, and every subcommand enum + shared args struct.
|
||||
|
||||
use clap::{Args, Parser, Subcommand};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "hivectl",
|
||||
about = "hyperhive host CLI — operator-facing administration",
|
||||
long_about = "\
|
||||
Sibling to the `hive-c0re` daemon binary. Covers host-side admin \
|
||||
operations that don't go through the broker — manual user \
|
||||
provisioning on the bundled forge + matrix containers, plus future \
|
||||
recovery / debugging verbs.\
|
||||
"
|
||||
)]
|
||||
pub struct Cli {
|
||||
/// Path to the hive-c0re host admin socket, used by the daemon-assisted
|
||||
/// verbs (`agents`, `stop`, `start`). Global: accepted before or after
|
||||
/// the subcommand. Verbs that don't talk to the daemon ignore it.
|
||||
#[arg(long, global = true, default_value = DEFAULT_HOST_SOCKET)]
|
||||
pub(crate) socket: PathBuf,
|
||||
#[command(subcommand)]
|
||||
pub(crate) cmd: Cmd,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum Cmd {
|
||||
/// Forgejo user provisioning.
|
||||
///
|
||||
/// Manual entry point to the same idempotent provisioning c0re runs at
|
||||
/// boot — for recovery, ad-hoc reprovisioning, or fixing one agent
|
||||
/// without bouncing the daemon.
|
||||
Forge {
|
||||
#[command(subcommand)]
|
||||
cmd: ForgeCmd,
|
||||
},
|
||||
/// matrix-tuwunel user provisioning.
|
||||
///
|
||||
/// Manual entry point to the same idempotent provisioning c0re runs at
|
||||
/// boot — for re-registering an agent the boot sweep skipped, or after
|
||||
/// wiping a token file.
|
||||
Matrix {
|
||||
#[command(subcommand)]
|
||||
cmd: MatrixCmd,
|
||||
},
|
||||
/// GitHub account provisioning.
|
||||
///
|
||||
/// Store an operator-supplied personal access token (PAT) for an agent
|
||||
/// so its `gh` and git can authenticate. No account is created — the
|
||||
/// PAT is for an existing GitHub account.
|
||||
Github {
|
||||
#[command(subcommand)]
|
||||
cmd: GithubCmd,
|
||||
},
|
||||
/// Gateway htpasswd user management.
|
||||
///
|
||||
/// Add, remove, or list users for the gateway's HTTP Basic auth.
|
||||
Gateway {
|
||||
#[command(subcommand)]
|
||||
cmd: GatewayCmd,
|
||||
},
|
||||
/// Agent container management.
|
||||
///
|
||||
/// Lifecycle actions on managed agent containers. Needs the hive-c0re
|
||||
/// daemon running.
|
||||
Agents {
|
||||
#[command(subcommand)]
|
||||
cmd: AgentsCmd,
|
||||
},
|
||||
/// Operator approval queue: list, approve, or deny pending requests.
|
||||
///
|
||||
/// Needs the hive-c0re daemon running.
|
||||
Approvals {
|
||||
#[command(subcommand)]
|
||||
cmd: ApprovalsCmd,
|
||||
},
|
||||
/// WireGuard inter-hive mesh setup helpers.
|
||||
///
|
||||
/// Generate this hive's mesh key and print the nix to enable the mesh,
|
||||
/// add a peer, or inspect live interface state.
|
||||
Wg {
|
||||
#[command(subcommand)]
|
||||
cmd: WgCmd,
|
||||
},
|
||||
/// Generate the federation peer-config block for THIS hive.
|
||||
///
|
||||
/// Prints the nix a peer operator pastes into their swarm config to
|
||||
/// trust and reach this hive.
|
||||
PeerConfig {
|
||||
/// This hive's WireGuard mesh address (e.g. `10.42.0.1/32`),
|
||||
/// emitted as `wireguardAddress`. Omit when not running the mesh.
|
||||
#[arg(long)]
|
||||
wg_address: Option<String>,
|
||||
/// This hive's public WireGuard endpoint (`host:port`), emitted as
|
||||
/// `wireguardEndpoint`. Omit when peers dial in / no mesh.
|
||||
#[arg(long)]
|
||||
wg_endpoint: Option<String>,
|
||||
},
|
||||
/// Open an interactive Claude session inside an agent container.
|
||||
///
|
||||
/// A fresh session by default, or resume a prior one. Requires root
|
||||
/// and a running container.
|
||||
Choom {
|
||||
/// Agent name (e.g. `damocles`, `iris`).
|
||||
name: String,
|
||||
/// Resume a prior claude session by its session id, passed
|
||||
/// through as `claude --resume <value>` (claude's `--continue`
|
||||
/// takes no value — it resumes the cwd's latest session, which
|
||||
/// is the harness's, so choom never uses it; this flag matches
|
||||
/// the claude flag it maps to). Omit for a fresh blank session.
|
||||
/// A value is required when the flag is given.
|
||||
#[arg(long = "resume", value_name = "SESSION")]
|
||||
resume_session: Option<String>,
|
||||
},
|
||||
/// Stop containers hive-wide in one operator action.
|
||||
///
|
||||
/// Bare `hivectl stop` stops everything; scope flags narrow it to
|
||||
/// specific sub-agents or infra containers.
|
||||
Stop {
|
||||
#[command(flatten)]
|
||||
scope: ScopeArgs,
|
||||
/// Gracefully quiesce each agent before stopping, instead of a
|
||||
/// hard stop. Each agent gets a graceful-stop DAG on the job
|
||||
/// queue: the harness is signalled, runs one stop-checkpoint
|
||||
/// turn to flush durable `/state`, drains, then the container
|
||||
/// is stopped (bounded by a 3-min timeout that falls back to a
|
||||
/// hard stop). All drains overlap. Applies to agents only.
|
||||
#[arg(long)]
|
||||
graceful: bool,
|
||||
/// Return immediately after the stop DAGs are queued instead of
|
||||
/// waiting for them with live per-node progress.
|
||||
#[arg(long)]
|
||||
no_wait: bool,
|
||||
},
|
||||
/// Start containers hive-wide — the inverse of `hivectl stop`.
|
||||
///
|
||||
/// Bare `hivectl start` restores the agents stopped by the last
|
||||
/// broad-scope `stop` (or starts everything if none); scope flags
|
||||
/// narrow it.
|
||||
Start {
|
||||
#[command(flatten)]
|
||||
scope: ScopeArgs,
|
||||
/// Return immediately after the start DAGs are queued instead
|
||||
/// of waiting for them with live per-node progress.
|
||||
#[arg(long)]
|
||||
no_wait: bool,
|
||||
},
|
||||
/// Restart containers hive-wide — `stop` then `start` over one scope.
|
||||
///
|
||||
/// Bare `hivectl restart` restarts everything; scope flags narrow it.
|
||||
Restart {
|
||||
#[command(flatten)]
|
||||
scope: ScopeArgs,
|
||||
/// Gracefully quiesce each agent on the stop half (see
|
||||
/// `stop --graceful`). Applies to agents only.
|
||||
#[arg(long)]
|
||||
graceful: bool,
|
||||
},
|
||||
/// Per-agent disk accounting + optional quotas via btrfs qgroups.
|
||||
///
|
||||
/// Opt-in: enable qgroup accounting, then report per-agent usage or
|
||||
/// cap an agent. No-op on non-btrfs hosts.
|
||||
Quota {
|
||||
#[command(subcommand)]
|
||||
cmd: QuotaCmd,
|
||||
},
|
||||
/// btrfs subvolume management for agent state dirs.
|
||||
///
|
||||
/// Upgrade an existing plain-dir agent's state into a btrfs subvolume
|
||||
/// so it gains snapshots and per-subvol usage/quota.
|
||||
Subvol {
|
||||
#[command(subcommand)]
|
||||
cmd: SubvolCmd,
|
||||
},
|
||||
/// Print (and best-effort open in a browser) a hive web surface URL.
|
||||
///
|
||||
/// Resolves the URL from the running daemon so custom forge / matrix
|
||||
/// domains work. Bare `hivectl open` opens the operator dashboard.
|
||||
Open {
|
||||
/// Which surface to open. Defaults to the operator dashboard.
|
||||
#[arg(value_enum, default_value_t = OpenTarget::Home)]
|
||||
target: OpenTarget,
|
||||
},
|
||||
/// Emit the full CLI reference as `CommonMark` to stdout.
|
||||
///
|
||||
/// Hidden tooling command used by the docs build to keep the published
|
||||
/// `hivectl` reference in lockstep with the code.
|
||||
#[command(hide = true)]
|
||||
MarkdownDocs,
|
||||
/// Generate a shell completion script for `hivectl` and print it to
|
||||
/// stdout.
|
||||
///
|
||||
/// Supports bash, zsh, fish, elvish, and powershell. The NixOS module
|
||||
/// already installs the zsh script system-wide; this is for ad-hoc or
|
||||
/// other-shell use.
|
||||
Completions {
|
||||
/// Shell to emit completions for.
|
||||
shell: clap_complete::Shell,
|
||||
},
|
||||
}
|
||||
|
||||
/// Which hive web surface `hivectl open` targets.
|
||||
#[derive(Copy, Clone, Debug, clap::ValueEnum)]
|
||||
pub enum OpenTarget {
|
||||
/// The operator dashboard (`https://<domain>/`).
|
||||
Home,
|
||||
/// The forge (Forgejo) web UI.
|
||||
Forge,
|
||||
/// The matrix GUI (fluffychat).
|
||||
Matrix,
|
||||
}
|
||||
|
||||
/// Shared scope flags for `hivectl stop` / `hivectl start`. With no flag
|
||||
/// set the verb targets **everything** (all sub-agents + every controllable
|
||||
/// infra container). Setting any flag restricts to the selected classes,
|
||||
/// additively.
|
||||
// One bool per selectable container class, each mapping 1:1 to a clap flag;
|
||||
// orthogonal toggles, not a state machine — hence the bools allow (mirrors
|
||||
// `hive_host_sock::LifecycleScope`).
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
#[derive(Args)]
|
||||
pub struct ScopeArgs {
|
||||
/// All sub-agent containers.
|
||||
#[arg(long)]
|
||||
agents: bool,
|
||||
/// A specific sub-agent by name. Repeatable: `--agent a --agent b`.
|
||||
#[arg(long = "agent", value_name = "NAME")]
|
||||
agent: Vec<String>,
|
||||
/// The CI runner container (`hive-ci`).
|
||||
#[arg(long)]
|
||||
ci: bool,
|
||||
/// The forge container (`hive-forge`).
|
||||
#[arg(long)]
|
||||
forge: bool,
|
||||
/// The gateway container (`hive-gateway`).
|
||||
#[arg(long)]
|
||||
gateway: bool,
|
||||
/// The matrix container (`hive-matrix`).
|
||||
#[arg(long)]
|
||||
matrix: bool,
|
||||
}
|
||||
|
||||
impl ScopeArgs {
|
||||
pub(crate) fn to_scope(&self) -> hive_host_sock::LifecycleScope {
|
||||
hive_host_sock::LifecycleScope {
|
||||
agents: self.agents,
|
||||
agent_names: self.agent.clone(),
|
||||
ci: self.ci,
|
||||
forge: self.forge,
|
||||
gateway: self.gateway,
|
||||
matrix: self.matrix,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum ForgeCmd {
|
||||
/// Create or refresh the Forgejo account + token for `<name>`.
|
||||
///
|
||||
/// For an existing agent, persists the token to its state dir; for a
|
||||
/// human/other account, prints the token to stdout. Set a password to
|
||||
/// enable forge web-UI login (a random throwaway is used otherwise).
|
||||
CreateUser {
|
||||
/// Forgejo username. For agents: the container/agent name
|
||||
/// (`<n>` in `h-<n>`; manager uses the literal `manager`).
|
||||
/// For humans: any forgejo username — `mara`, `damocles`, etc.
|
||||
name: String,
|
||||
/// Set the account password to this string instead of a random
|
||||
/// throwaway. Use this for operator accounts that need to log
|
||||
/// into the forge web UI. Mutually exclusive with
|
||||
/// `--password-stdin`. WARNING: the password is visible in
|
||||
/// shell history + process listings; prefer `--password-stdin`
|
||||
/// for anything sensitive.
|
||||
#[arg(long)]
|
||||
password: Option<String>,
|
||||
/// Read the password from stdin (single line, trailing newline
|
||||
/// stripped) instead of an inline flag. Mutually exclusive with
|
||||
/// `--password`.
|
||||
#[arg(long, conflicts_with = "password")]
|
||||
password_stdin: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum MatrixCmd {
|
||||
/// Create or refresh the matrix account + access token for `<name>`.
|
||||
///
|
||||
/// For an existing agent, persists the token to its state dir; for a
|
||||
/// human/other account, prints the access token to stdout. Set a
|
||||
/// password to enable matrix web-client login (a random throwaway is
|
||||
/// used otherwise).
|
||||
CreateUser {
|
||||
/// Matrix localpart. For agents: the container/agent name.
|
||||
/// For humans: any matrix localpart — `mara`, `damocles`, etc.
|
||||
name: String,
|
||||
/// Set the account password to this string instead of a random
|
||||
/// throwaway. Use this for operator accounts that need to log
|
||||
/// into matrix web clients via `m.login.password`. Mutually
|
||||
/// exclusive with `--password-stdin`. WARNING: the
|
||||
/// password is visible in shell history + process listings;
|
||||
/// prefer `--password-stdin` for anything sensitive.
|
||||
#[arg(long)]
|
||||
password: Option<String>,
|
||||
/// Read the password from stdin (single line, trailing newline
|
||||
/// stripped) instead of an inline flag. Mutually exclusive with
|
||||
/// `--password`.
|
||||
#[arg(long, conflicts_with = "password")]
|
||||
password_stdin: bool,
|
||||
},
|
||||
/// Provision (or re-provision) the hive system admin matrix account.
|
||||
///
|
||||
/// Runs automatically on startup; run manually to recover a missing
|
||||
/// admin token.
|
||||
SyncAdmin,
|
||||
/// Promote a matrix user to homeserver admin.
|
||||
PromoteUser {
|
||||
/// Matrix localpart of the user to promote (e.g. `argus`).
|
||||
name: String,
|
||||
},
|
||||
/// Reset a matrix user's password via the admin API.
|
||||
///
|
||||
/// Persists the new password so a later `create-user` can re-login.
|
||||
ResetPassword {
|
||||
/// Matrix localpart of the account to reset (e.g. `argus`).
|
||||
name: String,
|
||||
},
|
||||
/// Invite a matrix user to the hive Space, or a specific room with
|
||||
/// `--room`. Idempotent.
|
||||
Invite {
|
||||
/// User to invite: a full id (`@mara:server`) or a bare
|
||||
/// localpart (qualified with the homeserver's `server_name`).
|
||||
user: String,
|
||||
/// Target room id (`!abc:server`) or alias (`#name:server`).
|
||||
/// Omit to invite to the hive Space.
|
||||
#[arg(long)]
|
||||
room: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum GithubCmd {
|
||||
/// Store a GitHub PAT for `<agent>` so its `gh` and git can
|
||||
/// authenticate.
|
||||
///
|
||||
/// Prefer `--token-stdin` — an inline `--token` is visible in shell
|
||||
/// history.
|
||||
SetToken {
|
||||
/// Logical agent name (the container/agent name).
|
||||
agent: String,
|
||||
/// The PAT value inline. Mutually exclusive with `--token-stdin`.
|
||||
#[arg(long)]
|
||||
token: Option<String>,
|
||||
/// Read the PAT from stdin (trailing newline stripped). Mutually
|
||||
/// exclusive with `--token`.
|
||||
#[arg(long, conflicts_with = "token")]
|
||||
token_stdin: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum GatewayCmd {
|
||||
/// Add a user or update an existing user's password in the gateway
|
||||
/// htpasswd.
|
||||
///
|
||||
/// Use `--password-stdin` to keep the password out of shell history.
|
||||
CreateUser {
|
||||
/// Username to add or update.
|
||||
username: String,
|
||||
/// Set the password inline. WARNING: visible in shell history and
|
||||
/// process listings — prefer `--password-stdin` for sensitive input.
|
||||
/// Mutually exclusive with `--password-stdin`.
|
||||
#[arg(long, conflicts_with = "password_stdin")]
|
||||
password: Option<String>,
|
||||
/// Read the password from stdin (single line, trailing newline
|
||||
/// stripped). Mutually exclusive with `--password`.
|
||||
#[arg(long)]
|
||||
password_stdin: bool,
|
||||
},
|
||||
/// Remove a user from the gateway htpasswd.
|
||||
DeleteUser {
|
||||
/// Username to remove.
|
||||
username: String,
|
||||
},
|
||||
/// List all gateway htpasswd usernames, one per line.
|
||||
ListUsers,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum WgCmd {
|
||||
/// Generate this hive's WireGuard key (if absent) and print its public
|
||||
/// key plus the nix to enable the mesh.
|
||||
Init {
|
||||
/// This hive's mesh address (e.g. `10.42.0.1/32`) to bake into the
|
||||
/// printed snippet. Omit to get a placeholder you fill in.
|
||||
#[arg(long)]
|
||||
address: Option<String>,
|
||||
},
|
||||
/// Print the nix to add a peer hive to the mesh.
|
||||
Peer {
|
||||
/// Peer hive's DNS domain (the `swarm.peers` attrset key).
|
||||
domain: String,
|
||||
/// Peer's WireGuard public key (from its `hivectl wg init`).
|
||||
#[arg(long)]
|
||||
pubkey: String,
|
||||
/// Peer's mesh address (e.g. `10.42.0.2/32`).
|
||||
#[arg(long)]
|
||||
address: String,
|
||||
/// Peer's `host:port` endpoint (omit for a peer that only dials out,
|
||||
/// e.g. one behind NAT — it must set an endpoint pointing back here).
|
||||
#[arg(long)]
|
||||
endpoint: Option<String>,
|
||||
},
|
||||
/// Show the live mesh interface state.
|
||||
Status,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum QuotaCmd {
|
||||
/// Enable btrfs qgroup accounting on the agent-state filesystem.
|
||||
///
|
||||
/// Run once before `show` / `limit`. No-op on non-btrfs hosts.
|
||||
Enable,
|
||||
/// Report per-agent disk usage from btrfs qgroups (all agents, or one
|
||||
/// by name).
|
||||
Show {
|
||||
/// Agent to show (omit for all agents with a state subvolume).
|
||||
name: Option<String>,
|
||||
},
|
||||
/// Set or clear an agent's disk-usage quota.
|
||||
Limit {
|
||||
/// Agent whose state subvolume to limit.
|
||||
name: String,
|
||||
/// Size cap (`5G`, `500M`, `1073741824`) or `none` to clear.
|
||||
size: String,
|
||||
},
|
||||
}
|
||||
|
||||
// Default host admin socket path. Shared with `hive-c0re`'s `main.rs`
|
||||
// default via `hive_host_sock::HOST_SOCKET` — the daemon binds there
|
||||
// and `hivectl agents` connects to it.
|
||||
pub(crate) use hive_host_sock::HOST_SOCKET as DEFAULT_HOST_SOCKET;
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum AgentsCmd {
|
||||
/// Show all managed agents with their status and technical state.
|
||||
List {
|
||||
/// Emit the raw JSON rows instead of the padded table (for
|
||||
/// scripting). The table is the default human-readable shape.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Stop and start a single agent container without rebuilding config.
|
||||
Restart {
|
||||
/// Agent name (e.g. `damocles`, `ruth`).
|
||||
name: String,
|
||||
/// Return immediately after the restart DAG is queued.
|
||||
#[arg(long)]
|
||||
no_wait: bool,
|
||||
},
|
||||
/// Restart all managed agent containers.
|
||||
RestartAll {
|
||||
/// Return immediately after the restart DAGs are queued.
|
||||
#[arg(long)]
|
||||
no_wait: bool,
|
||||
},
|
||||
/// Spawn a new agent container directly, bypassing the approval queue.
|
||||
///
|
||||
/// Operator-on-the-host only; use `request-spawn` for an approval-gated
|
||||
/// spawn.
|
||||
Spawn {
|
||||
/// Agent name (e.g. `iris`).
|
||||
name: String,
|
||||
},
|
||||
/// Queue a spawn request for operator approval.
|
||||
RequestSpawn {
|
||||
/// Agent name.
|
||||
name: String,
|
||||
},
|
||||
/// Stop a managed container (graceful).
|
||||
Kill {
|
||||
/// Agent name.
|
||||
name: String,
|
||||
},
|
||||
/// Tear down a sub-agent container, keeping its state by default. No
|
||||
/// undo.
|
||||
Destroy {
|
||||
/// Agent name.
|
||||
name: String,
|
||||
/// Also wipe the agent's state dirs (config + creds + notes).
|
||||
#[arg(long)]
|
||||
purge: bool,
|
||||
},
|
||||
/// Apply pending config to a managed container.
|
||||
Rebuild {
|
||||
/// Agent name.
|
||||
name: String,
|
||||
},
|
||||
/// Move an agent in the topology tree — under a new parent, or to root.
|
||||
SetParent {
|
||||
/// Agent to move.
|
||||
child: String,
|
||||
/// New parent agent name. Mutually exclusive with `--root`.
|
||||
#[arg(long, conflicts_with = "root", required_unless_present = "root")]
|
||||
parent: Option<String>,
|
||||
/// Promote `child` to root (no parent).
|
||||
#[arg(long)]
|
||||
root: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// Operator approval queue: list, approve, or deny pending requests.
|
||||
#[derive(Subcommand)]
|
||||
pub enum ApprovalsCmd {
|
||||
/// List pending approval requests submitted by agents.
|
||||
Pending,
|
||||
/// Approve a pending request by id; the action runs immediately.
|
||||
Approve {
|
||||
/// Approval id (from `hivectl approvals pending`).
|
||||
id: i64,
|
||||
},
|
||||
/// Deny a pending request by id.
|
||||
Deny {
|
||||
/// Approval id.
|
||||
id: i64,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum SubvolCmd {
|
||||
/// Convert a plain-dir agent state root into a btrfs subvolume in
|
||||
/// place, so it gains snapshots and per-subvol usage/quota.
|
||||
///
|
||||
/// Bounces the agent to migrate its state, so it requires `--yes`.
|
||||
Upgrade {
|
||||
/// Agent name (e.g. `damocles`, `iris`).
|
||||
name: String,
|
||||
/// Confirm: this stops the agent, migrates its state dir, and
|
||||
/// restarts it. Required — the command refuses without it.
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
},
|
||||
/// Read-only snapshots of an agent's state subvolume.
|
||||
Snapshot {
|
||||
#[command(subcommand)]
|
||||
cmd: SnapshotCmd,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum SnapshotCmd {
|
||||
/// Create a read-only snapshot (agent must already be a subvolume).
|
||||
Create {
|
||||
/// Agent name (e.g. `damocles`, `iris`).
|
||||
name: String,
|
||||
/// Snapshot label. Mandatory, and must start with `hive-` — the
|
||||
/// prefix doubles as an allow-list hive-priv checks so only
|
||||
/// hivectl-issued snapshot names can reach the `btrfs subvolume
|
||||
/// snapshot` shellout.
|
||||
#[arg(long)]
|
||||
label: String,
|
||||
},
|
||||
/// Delete a snapshot created by `subvol snapshot create`.
|
||||
Delete {
|
||||
/// Agent name the snapshot belongs to.
|
||||
name: String,
|
||||
/// Snapshot label passed to `subvol snapshot create --label`.
|
||||
label: String,
|
||||
},
|
||||
/// Export a snapshot to a local file via `btrfs send` (the local-file
|
||||
/// half of inter-hive migration transport; the cross-hive `ssh ...
|
||||
/// btrfs receive` leg isn't wired up yet). Also useful standalone as a
|
||||
/// point-in-time backup: a full send with no `--parent` produces a
|
||||
/// self-contained archive of the snapshot.
|
||||
Send {
|
||||
/// Agent name the snapshot belongs to.
|
||||
name: String,
|
||||
/// Snapshot label passed to `subvol snapshot create --label`.
|
||||
label: String,
|
||||
/// Optional parent snapshot label for an incremental send
|
||||
/// (`btrfs send -p`) — must be an existing, older snapshot of the
|
||||
/// same agent. Omit for a full send.
|
||||
#[arg(long)]
|
||||
parent: Option<String>,
|
||||
/// Destination filename (not a path) under the migrate-staging
|
||||
/// dir. Refused if it already exists.
|
||||
#[arg(long)]
|
||||
dest: String,
|
||||
},
|
||||
}
|
||||
14
hivectl/src/completions.rs
Normal file
14
hivectl/src/completions.rs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
//! `hivectl completions <shell>` — emit a shell completion script,
|
||||
//! generated from the live clap command tree so it never drifts from the
|
||||
//! actual verbs and flags.
|
||||
|
||||
use crate::cli::Cli;
|
||||
|
||||
/// Emit a shell completion script for `hivectl` to stdout. Walks the clap
|
||||
/// command tree (the single source of truth — same tree `markdown-docs`
|
||||
/// renders) so completions never drift from the actual verbs/flags.
|
||||
pub(crate) fn generate_completions(shell: clap_complete::Shell) {
|
||||
use clap::CommandFactory as _;
|
||||
let mut cmd = Cli::command();
|
||||
clap_complete::generate(shell, &mut cmd, "hivectl", &mut std::io::stdout());
|
||||
}
|
||||
31
hivectl/src/forge.rs
Normal file
31
hivectl/src/forge.rs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
//! `hivectl forge create-user <name>` — provision a Forgejo account via the
|
||||
//! daemon (which owns the forge admin token + account-token persistence);
|
||||
//! hivectl just resolves the password client-side and relays the request.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::util::{daemon_request, resolve_password};
|
||||
|
||||
pub(crate) async fn forge_create_user(
|
||||
socket: &Path,
|
||||
name: &str,
|
||||
password: Option<&str>,
|
||||
password_stdin: bool,
|
||||
) -> Result<()> {
|
||||
// Resolve the password client-side (inline flag or stdin read); the
|
||||
// daemon never touches this process's stdin. The is-present check, the
|
||||
// agent-vs-operator branch, and token persistence now live in the
|
||||
// daemon handler.
|
||||
let password = resolve_password(password, password_stdin)?;
|
||||
daemon_request(
|
||||
socket,
|
||||
hive_host_sock::HostRequest::ForgeCreateUser {
|
||||
name: name.to_owned(),
|
||||
password,
|
||||
},
|
||||
"forge",
|
||||
)
|
||||
.await
|
||||
}
|
||||
50
hivectl/src/gateway.rs
Normal file
50
hivectl/src/gateway.rs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
//! `hivectl gateway` — htpasswd user management for the gateway's HTTP
|
||||
//! Basic auth. The daemon owns the bcrypt hash + htpasswd write (see the
|
||||
//! `Gateway*User` `HostRequest`s); hivectl resolves the password
|
||||
//! client-side and relays the request over the host admin socket.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::util::{daemon_request, resolve_password};
|
||||
|
||||
pub(crate) async fn gateway_create_user(
|
||||
socket: &Path,
|
||||
username: &str,
|
||||
password: Option<&str>,
|
||||
password_stdin: bool,
|
||||
) -> Result<()> {
|
||||
let pw = resolve_password(password, password_stdin)?.ok_or_else(|| {
|
||||
anyhow::anyhow!("a password is required — pass --password or --password-stdin")
|
||||
})?;
|
||||
daemon_request(
|
||||
socket,
|
||||
hive_host_sock::HostRequest::GatewayCreateUser {
|
||||
username: username.to_owned(),
|
||||
password: pw,
|
||||
},
|
||||
"gateway",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn gateway_delete_user(socket: &Path, username: &str) -> Result<()> {
|
||||
daemon_request(
|
||||
socket,
|
||||
hive_host_sock::HostRequest::GatewayDeleteUser {
|
||||
username: username.to_owned(),
|
||||
},
|
||||
"gateway",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn gateway_list_users(socket: &Path) -> Result<()> {
|
||||
daemon_request(
|
||||
socket,
|
||||
hive_host_sock::HostRequest::GatewayListUsers,
|
||||
"gateway",
|
||||
)
|
||||
.await
|
||||
}
|
||||
48
hivectl/src/github.rs
Normal file
48
hivectl/src/github.rs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
//! `hivectl github set-token <agent>` — write an operator-supplied GitHub PAT
|
||||
//! into an agent's `github-token` state file via the daemon's privileged
|
||||
//! helper, so the agent's `gh` wrapper + git credential helper authenticate.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
|
||||
use crate::util::daemon_request;
|
||||
|
||||
/// `hivectl github set-token <agent>`: write an operator-supplied GitHub PAT
|
||||
/// into the agent's `github-token` state file (0600, agent-owned) via
|
||||
/// hive-priv, so the agent's `gh` wrapper + git credential helper can
|
||||
/// authenticate. Read live at invocation, so no rebuild/restart is needed.
|
||||
pub(crate) async fn github_set_token(
|
||||
socket: &Path,
|
||||
agent: &str,
|
||||
token: Option<String>,
|
||||
token_stdin: bool,
|
||||
) -> Result<()> {
|
||||
// Resolve + validate the token client-side (inline flag or stdin read);
|
||||
// the daemon never touches this process's stdin. Persistence happens
|
||||
// daemon-side via the privileged helper.
|
||||
let token = match (token, token_stdin) {
|
||||
(Some(t), _) => t,
|
||||
(None, true) => {
|
||||
let mut s = String::new();
|
||||
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
|
||||
s.trim_end_matches(['\n', '\r']).to_owned()
|
||||
}
|
||||
(None, false) => bail!(
|
||||
"provide the PAT via --token <pat> or --token-stdin (stdin preferred — \
|
||||
an inline token is visible in shell history + process listings)"
|
||||
),
|
||||
};
|
||||
if token.is_empty() {
|
||||
bail!("refusing to write an empty GitHub token for agent '{agent}'");
|
||||
}
|
||||
daemon_request(
|
||||
socket,
|
||||
hive_host_sock::HostRequest::SetAgentGithubToken {
|
||||
agent: agent.to_owned(),
|
||||
token,
|
||||
},
|
||||
"github",
|
||||
)
|
||||
.await
|
||||
}
|
||||
1813
hivectl/src/main.rs
1813
hivectl/src/main.rs
File diff suppressed because it is too large
Load diff
102
hivectl/src/matrix.rs
Normal file
102
hivectl/src/matrix.rs
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
//! `hivectl matrix` — matrix account provisioning verbs. hivectl forwards
|
||||
//! each request to the daemon (which owns the register + admin tokens and
|
||||
//! the matrix creds dir) and renders the reply; it no longer links the
|
||||
//! matrix machinery itself.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
|
||||
use crate::cli::MatrixCmd;
|
||||
use crate::util::resolve_password;
|
||||
|
||||
/// Route a `matrix` subcommand to its handler. Extracted from `main`'s
|
||||
/// dispatch match so the top-level router stays small.
|
||||
pub(crate) async fn run_matrix_cmd(socket: &Path, cmd: MatrixCmd) -> Result<()> {
|
||||
match cmd {
|
||||
MatrixCmd::CreateUser {
|
||||
name,
|
||||
password,
|
||||
password_stdin,
|
||||
} => matrix_create_user(socket, &name, password.as_deref(), password_stdin).await,
|
||||
MatrixCmd::SyncAdmin => matrix_sync_admin(socket).await,
|
||||
MatrixCmd::PromoteUser { name } => matrix_promote_user(socket, &name).await,
|
||||
MatrixCmd::ResetPassword { name } => matrix_reset_password(socket, &name).await,
|
||||
MatrixCmd::Invite { user, room } => matrix_invite(socket, &user, room.as_deref()).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a matrix provisioning request to the daemon and print the
|
||||
/// operator-facing result lines it returns. The daemon owns the register +
|
||||
/// admin tokens and the matrix creds dir, so hivectl no longer links the
|
||||
/// matrix machinery — it just forwards the request and renders the reply.
|
||||
async fn matrix_request(socket: &Path, req: hive_host_sock::HostRequest) -> Result<()> {
|
||||
let resp = crate::client::request(socket, req)
|
||||
.await
|
||||
.with_context(|| format!("connect to daemon socket {}", socket.display()))?;
|
||||
if !resp.ok {
|
||||
bail!(
|
||||
"matrix: {}",
|
||||
resp.error.as_deref().unwrap_or("unknown error")
|
||||
);
|
||||
}
|
||||
for line in &resp.messages {
|
||||
println!("{line}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn matrix_create_user(
|
||||
socket: &Path,
|
||||
name: &str,
|
||||
password: Option<&str>,
|
||||
password_stdin: bool,
|
||||
) -> Result<()> {
|
||||
// Resolve the password client-side (an inline flag or a stdin read);
|
||||
// the daemon never touches this process's stdin. The agent-vs-operator
|
||||
// branch + throwaway-password handling now live in the daemon handler.
|
||||
let password = resolve_password(password, password_stdin)?;
|
||||
matrix_request(
|
||||
socket,
|
||||
hive_host_sock::HostRequest::MatrixCreateUser {
|
||||
name: name.to_owned(),
|
||||
password,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn matrix_sync_admin(socket: &Path) -> Result<()> {
|
||||
matrix_request(socket, hive_host_sock::HostRequest::MatrixSyncAdmin).await
|
||||
}
|
||||
|
||||
async fn matrix_promote_user(socket: &Path, name: &str) -> Result<()> {
|
||||
matrix_request(
|
||||
socket,
|
||||
hive_host_sock::HostRequest::MatrixPromoteUser {
|
||||
name: name.to_owned(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn matrix_invite(socket: &Path, user: &str, room: Option<&str>) -> Result<()> {
|
||||
matrix_request(
|
||||
socket,
|
||||
hive_host_sock::HostRequest::MatrixInvite {
|
||||
user: user.to_owned(),
|
||||
room: room.map(str::to_owned),
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn matrix_reset_password(socket: &Path, name: &str) -> Result<()> {
|
||||
matrix_request(
|
||||
socket,
|
||||
hive_host_sock::HostRequest::MatrixResetPassword {
|
||||
name: name.to_owned(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
46
hivectl/src/open.rs
Normal file
46
hivectl/src/open.rs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
//! `hivectl open <home|forge|matrix>` — resolve a hive surface URL from the
|
||||
//! daemon, print it, and best-effort `xdg-open` it.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
|
||||
use crate::cli::{DEFAULT_HOST_SOCKET, OpenTarget};
|
||||
use crate::util::query_hive_urls;
|
||||
|
||||
/// `open <home|forge|matrix>` — resolve the surface URL from the daemon,
|
||||
/// print it, then best-effort `xdg-open` it. Printing is the reliable
|
||||
/// core (headless / SSH hosts where no browser opener exists); the open
|
||||
/// is convenience on top, so a missing/failed `xdg-open` is not an error.
|
||||
pub(crate) async fn open_url(socket: &Path, target: OpenTarget) -> Result<()> {
|
||||
let urls = query_hive_urls(socket).await.with_context(|| {
|
||||
format!(
|
||||
"could not reach the hive-c0re daemon for URLs — is hive-c0re running? \
|
||||
(the socket is at {DEFAULT_HOST_SOCKET})"
|
||||
)
|
||||
})?;
|
||||
let (url, hint) = match target {
|
||||
OpenTarget::Home => (
|
||||
urls.home,
|
||||
"the dashboard URL needs `services.hyperhive.domain` to be set",
|
||||
),
|
||||
OpenTarget::Forge => (
|
||||
urls.forge,
|
||||
"the public forge URL needs `services.hyperhive.forge.behindGateway = true`",
|
||||
),
|
||||
OpenTarget::Matrix => (
|
||||
urls.matrix,
|
||||
"the matrix GUI URL needs `services.hyperhive.matrix.gui.enable = true`",
|
||||
),
|
||||
};
|
||||
let url = url.with_context(|| format!("no URL available for this surface — {hint}"))?;
|
||||
println!("{url}");
|
||||
// Best-effort: many hosts are headless, so a missing opener or a
|
||||
// non-zero exit is fine — the URL is already printed.
|
||||
match std::process::Command::new("xdg-open").arg(&url).status() {
|
||||
Ok(status) if status.success() => {}
|
||||
Ok(status) => eprintln!("note: xdg-open exited with {status} (URL printed above)"),
|
||||
Err(e) => eprintln!("note: could not run xdg-open ({e}) (URL printed above)"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
71
hivectl/src/power.rs
Normal file
71
hivectl/src/power.rs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
//! `hivectl stop` / `start` / `restart` — hive-wide + scoped container
|
||||
//! lifecycle. Each verb makes one daemon call and renders the queued DAGs.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
|
||||
use crate::dag_progress::wait_for_dags;
|
||||
use crate::util::render_lifecycle;
|
||||
|
||||
pub(crate) async fn stop(
|
||||
socket: &Path,
|
||||
scope: hive_host_sock::LifecycleScope,
|
||||
graceful: bool,
|
||||
no_wait: bool,
|
||||
) -> Result<()> {
|
||||
let resp = crate::client::request(
|
||||
socket,
|
||||
hive_host_sock::HostRequest::Stop { scope, graceful },
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("connect to daemon socket {}", socket.display()))?;
|
||||
// Render first, but even when an infra failure makes it bail,
|
||||
// watch the already-queued agent DAGs before surfacing the error —
|
||||
// they run regardless.
|
||||
let rendered = render_lifecycle(&resp, "stop queued");
|
||||
wait_for_dags(socket, resp.queued_dags.unwrap_or_default(), no_wait).await?;
|
||||
rendered
|
||||
}
|
||||
|
||||
pub(crate) async fn start(
|
||||
socket: &Path,
|
||||
scope: hive_host_sock::LifecycleScope,
|
||||
no_wait: bool,
|
||||
) -> Result<()> {
|
||||
let resp = crate::client::request(socket, hive_host_sock::HostRequest::Start { scope })
|
||||
.await
|
||||
.with_context(|| format!("connect to daemon socket {}", socket.display()))?;
|
||||
let rendered = render_lifecycle(&resp, "start queued");
|
||||
wait_for_dags(socket, resp.queued_dags.unwrap_or_default(), no_wait).await?;
|
||||
rendered
|
||||
}
|
||||
|
||||
/// Restart — one `RestartScoped` daemon call, server-side DAG-based (see
|
||||
/// issue tracker "dagify hivectl commands"). Each targeted agent rides
|
||||
/// exactly one atomic DAG queued up front — `Restart` (mechanical stop +
|
||||
/// reconcile), or `GracefulRestart` with `--graceful` (signal → drain →
|
||||
/// mechanical stop → reconcile); infra containers restart synchronously.
|
||||
/// No "submit one DAG, wait for it, submit another" composition on either
|
||||
/// side of the wire: unlike the old client-side stop-then-start compose, a
|
||||
/// dropped `hivectl` connection mid-restart no longer leaves an agent
|
||||
/// stopped with no automatic follow-up — the daemon owns the whole
|
||||
/// sequence once this call is made.
|
||||
///
|
||||
/// No `--no-wait` here on purpose, same as before: the operator wants to
|
||||
/// see the restart actually land, not just get queued.
|
||||
pub(crate) async fn restart(
|
||||
socket: &Path,
|
||||
scope: hive_host_sock::LifecycleScope,
|
||||
graceful: bool,
|
||||
) -> Result<()> {
|
||||
let resp = crate::client::request(
|
||||
socket,
|
||||
hive_host_sock::HostRequest::RestartScoped { scope, graceful },
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("connect to daemon socket {}", socket.display()))?;
|
||||
let rendered = render_lifecycle(&resp, "restart queued");
|
||||
wait_for_dags(socket, resp.queued_dags.unwrap_or_default(), false).await?;
|
||||
rendered
|
||||
}
|
||||
112
hivectl/src/quota.rs
Normal file
112
hivectl/src/quota.rs
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
//! `hivectl quota` — per-agent disk accounting + optional quotas via
|
||||
//! btrfs qgroups. The daemon holds the privileged helper that reads /
|
||||
//! sets qgroups; hivectl relays the request and formats the reply.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
|
||||
use crate::util::daemon_request;
|
||||
|
||||
/// `quota enable` — turn on btrfs qgroup accounting (operator opt-in).
|
||||
pub(crate) async fn quota_enable(socket: &Path) -> Result<()> {
|
||||
daemon_request(socket, hive_host_sock::HostRequest::QuotaEnable, "quota").await
|
||||
}
|
||||
|
||||
/// `quota show [name]` — report per-agent disk usage from btrfs qgroups.
|
||||
/// The daemon resolves the agent set + reads each subvolume's usage (it
|
||||
/// holds the privileged helper); the client just formats the returned rows.
|
||||
pub(crate) async fn quota_show(socket: &Path, name: Option<&str>) -> Result<()> {
|
||||
let resp = crate::client::request(
|
||||
socket,
|
||||
hive_host_sock::HostRequest::QuotaShow {
|
||||
name: name.map(str::to_owned),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("connect to daemon socket {}", socket.display()))?;
|
||||
if !resp.ok {
|
||||
// Carries the "btrfs quota not enabled — run `hivectl quota enable`
|
||||
// first" hint when qgroups are off.
|
||||
bail!("{}", resp.error.as_deref().unwrap_or("quota show failed"));
|
||||
}
|
||||
let rows = resp.quota.unwrap_or_default();
|
||||
if rows.is_empty() {
|
||||
println!("no agents with a state dir found");
|
||||
return Ok(());
|
||||
}
|
||||
for row in &rows {
|
||||
match (row.referenced, row.exclusive) {
|
||||
(Some(rfer), Some(excl)) => println!(
|
||||
"{:<12} referenced {:>10} exclusive {:>10}",
|
||||
row.agent,
|
||||
human_bytes(rfer),
|
||||
human_bytes(excl)
|
||||
),
|
||||
// Plain-dir agent (no qgroup) — the daemon set an explanatory note.
|
||||
_ => println!(
|
||||
"{:<12} ({})",
|
||||
row.agent,
|
||||
row.note.as_deref().unwrap_or("no qgroup data")
|
||||
),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `quota limit <name> <size>` — set or clear an agent's disk quota.
|
||||
pub(crate) async fn quota_limit(socket: &Path, name: &str, size: &str) -> Result<()> {
|
||||
let limit = parse_quota_size(size)?;
|
||||
daemon_request(
|
||||
socket,
|
||||
hive_host_sock::HostRequest::QuotaLimit {
|
||||
name: name.to_owned(),
|
||||
limit,
|
||||
},
|
||||
"quota",
|
||||
)
|
||||
.await?;
|
||||
match limit {
|
||||
Some(n) => println!("set {name} quota to {} ({n} bytes)", human_bytes(n)),
|
||||
None => println!("cleared {name} quota"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse a quota size: a byte count, a `K`/`M`/`G`/`T`-suffixed value
|
||||
/// (powers of 1024), or `none` to clear. Rejects overflow + bad input.
|
||||
fn parse_quota_size(s: &str) -> Result<Option<u64>> {
|
||||
let t = s.trim();
|
||||
if t.eq_ignore_ascii_case("none") {
|
||||
return Ok(None);
|
||||
}
|
||||
let (num, mult) = match t.chars().last() {
|
||||
Some('K' | 'k') => (&t[..t.len() - 1], 1024u64),
|
||||
Some('M' | 'm') => (&t[..t.len() - 1], 1024 * 1024),
|
||||
Some('G' | 'g') => (&t[..t.len() - 1], 1024 * 1024 * 1024),
|
||||
Some('T' | 't') => (&t[..t.len() - 1], 1024u64 * 1024 * 1024 * 1024),
|
||||
_ => (t, 1),
|
||||
};
|
||||
let n: u64 = num.trim().parse().with_context(|| {
|
||||
format!("invalid size {s:?} — use a byte count, a K/M/G/T suffix (e.g. 5G), or `none`")
|
||||
})?;
|
||||
n.checked_mul(mult)
|
||||
.map(Some)
|
||||
.with_context(|| format!("size {s:?} overflows u64"))
|
||||
}
|
||||
|
||||
/// Format a byte count as a human-friendly KiB/MiB/GiB/TiB string.
|
||||
#[allow(clippy::cast_precision_loss)] // display-only; precision loss is cosmetic
|
||||
fn human_bytes(n: u64) -> String {
|
||||
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
|
||||
if n < 1024 {
|
||||
return format!("{n} B");
|
||||
}
|
||||
let mut v = n as f64;
|
||||
let mut i = 0;
|
||||
while v >= 1024.0 && i < UNITS.len() - 1 {
|
||||
v /= 1024.0;
|
||||
i += 1;
|
||||
}
|
||||
format!("{v:.1} {}", UNITS[i])
|
||||
}
|
||||
222
hivectl/src/subvol.rs
Normal file
222
hivectl/src/subvol.rs
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
//! `hivectl subvol` — btrfs state-subvolume ops: migrate a plain-dir agent
|
||||
//! state root to a subvolume (`upgrade`), and snapshot create/delete/send.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
|
||||
use crate::cli::{SnapshotCmd, SubvolCmd};
|
||||
use crate::dag_progress::wait_for_dags;
|
||||
use crate::util::{agent_exists, daemon_request};
|
||||
|
||||
/// A [`LifecycleScope`](hive_host_sock::LifecycleScope) targeting exactly one
|
||||
/// agent by name (no infra containers, no all-agents flag).
|
||||
fn single_agent_scope(name: &str) -> hive_host_sock::LifecycleScope {
|
||||
hive_host_sock::LifecycleScope {
|
||||
agents: false,
|
||||
agent_names: vec![name.to_owned()],
|
||||
ci: false,
|
||||
forge: false,
|
||||
gateway: false,
|
||||
matrix: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `subvol upgrade <agent>` — migrate an existing plain-dir agent state root
|
||||
/// to a btrfs subvolume. Composed client-side (like `restart`): stop the
|
||||
/// agent so its state bind-mount is released, run the privileged in-place
|
||||
/// migration via hive-priv, then restart it. The restart is attempted
|
||||
/// regardless of the migration outcome so a failed migration never leaves
|
||||
/// the agent down; the migration error (if any) is surfaced afterwards.
|
||||
/// Route a `hivectl subvol …` subcommand. Split out of `main`'s top-level
|
||||
/// match so the CLI router stays within the clippy line budget and the
|
||||
/// subvolume-op subcommands are dispatched in one place.
|
||||
pub(crate) async fn dispatch_subvol(socket: &Path, cmd: SubvolCmd) -> Result<()> {
|
||||
match cmd {
|
||||
SubvolCmd::Upgrade { name, yes } => subvol_upgrade(socket, &name, yes).await,
|
||||
SubvolCmd::Snapshot { cmd } => match cmd {
|
||||
SnapshotCmd::Create { name, label } => {
|
||||
subvol_snapshot_create(socket, &name, label).await
|
||||
}
|
||||
SnapshotCmd::Delete { name, label } => {
|
||||
subvol_snapshot_delete(socket, &name, &label).await
|
||||
}
|
||||
SnapshotCmd::Send {
|
||||
name,
|
||||
label,
|
||||
parent,
|
||||
dest,
|
||||
} => subvol_snapshot_send(socket, &name, &label, parent.as_deref(), &dest).await,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn subvol_upgrade(socket: &Path, name: &str, yes: bool) -> Result<()> {
|
||||
if !agent_exists(name)? {
|
||||
bail!("no agent named {name:?} (no state dir under the agents root)");
|
||||
}
|
||||
if !yes {
|
||||
bail!(
|
||||
"`subvol upgrade {name}` stops the agent, migrates its state dir to a btrfs \
|
||||
subvolume, then restarts it. Re-run with --yes to proceed."
|
||||
);
|
||||
}
|
||||
|
||||
println!("stopping {name} (releasing its state bind-mount)…");
|
||||
let stop_resp = crate::client::request(
|
||||
socket,
|
||||
hive_host_sock::HostRequest::Stop {
|
||||
scope: single_agent_scope(name),
|
||||
graceful: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("connect to daemon socket {}", socket.display()))?;
|
||||
if !stop_resp.ok {
|
||||
bail!(
|
||||
"stop {name}: {}",
|
||||
stop_resp.error.as_deref().unwrap_or("unknown error")
|
||||
);
|
||||
}
|
||||
// The stop is a queued DAG now — the migration below snapshots +
|
||||
// swaps the state dir and MUST NOT run under a live bind mount, so
|
||||
// wait for the stop to actually execute before touching anything.
|
||||
wait_for_dags(socket, stop_resp.queued_dags.unwrap_or_default(), false)
|
||||
.await
|
||||
.with_context(|| format!("waiting for {name} to stop before the migration"))?;
|
||||
|
||||
println!("migrating {name} state dir to a btrfs subvolume…");
|
||||
let upgrade = daemon_request(
|
||||
socket,
|
||||
hive_host_sock::HostRequest::UpgradeSubvolume {
|
||||
name: name.to_owned(),
|
||||
},
|
||||
"upgrade",
|
||||
)
|
||||
.await;
|
||||
|
||||
// Always attempt the restart, even if the migration failed — don't leave
|
||||
// the agent down. Capture the result rather than `?`-ing it so a
|
||||
// start-side failure (incl. the IPC call itself erroring) can't mask the
|
||||
// migration outcome below.
|
||||
println!("starting {name}…");
|
||||
let start_result = crate::client::request(
|
||||
socket,
|
||||
hive_host_sock::HostRequest::Start {
|
||||
scope: single_agent_scope(name),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
// Surface the migration outcome FIRST — it's the meaningful result and
|
||||
// must not be shadowed by a restart-side failure. On migration failure the
|
||||
// original state dir is untouched (the priv op rolls back before the swap).
|
||||
upgrade.with_context(|| format!("upgrade {name} state subvolume"))?;
|
||||
|
||||
// Migration succeeded; now surface any restart problem — either the IPC
|
||||
// call erroring, or the daemon reporting a failed start. The migration is
|
||||
// done regardless, so point at the manual recovery.
|
||||
let start_resp = start_result.with_context(|| {
|
||||
format!(
|
||||
"{name} migrated to a btrfs subvolume, but the restart request to the daemon \
|
||||
socket {} failed — run `hivectl start --agent {name}` to bring it back up",
|
||||
socket.display()
|
||||
)
|
||||
})?;
|
||||
if !start_resp.ok {
|
||||
bail!(
|
||||
"{name} migrated to a btrfs subvolume, but restarting it failed: {} — run \
|
||||
`hivectl start --agent {name}` to retry",
|
||||
start_resp.error.as_deref().unwrap_or("unknown error")
|
||||
);
|
||||
}
|
||||
wait_for_dags(socket, start_resp.queued_dags.unwrap_or_default(), false)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"{name} migrated to a btrfs subvolume, but its restart job failed — run \
|
||||
`hivectl start --agent {name}` to retry"
|
||||
)
|
||||
})?;
|
||||
println!("upgraded {name} to a btrfs subvolume and restarted it");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `subvol snapshot create <agent> --label <label>` — create a read-only
|
||||
/// btrfs snapshot of an agent's state subvolume. Unlike `upgrade`, this does
|
||||
/// NOT stop the agent: btrfs snapshots are atomic + consistent to take
|
||||
/// against a live subvolume. `label` is mandatory, must start with
|
||||
/// `hive-`, and is otherwise restricted to `[A-Za-z0-9_-]` (no `.` at all
|
||||
/// — mara: "we are making up the rules here, lets go strict"). hive-priv
|
||||
/// enforces the same rules server-side, so this check is
|
||||
/// belt-and-suspenders (fail fast client-side with a clear message).
|
||||
async fn subvol_snapshot_create(socket: &Path, name: &str, label: String) -> Result<()> {
|
||||
if !agent_exists(name)? {
|
||||
bail!("no agent named {name:?} (no state dir under the agents root)");
|
||||
}
|
||||
if !label.starts_with("hive-") {
|
||||
bail!("snapshot label {label:?} must start with \"hive-\"");
|
||||
}
|
||||
if !label
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-'))
|
||||
{
|
||||
bail!(
|
||||
"snapshot label {label:?} must be [A-Za-z0-9_-] only (no \".\" — hive-priv rejects it)"
|
||||
);
|
||||
}
|
||||
// The daemon returns the snapshot's host path as a message line, which
|
||||
// `daemon_request` prints — same bare-path output as before.
|
||||
daemon_request(
|
||||
socket,
|
||||
hive_host_sock::HostRequest::SnapshotSubvolume {
|
||||
name: name.to_owned(),
|
||||
label,
|
||||
},
|
||||
"snapshot",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// `subvol snapshot delete <agent> <label>` — remove a snapshot created by
|
||||
/// `subvol snapshot create`.
|
||||
async fn subvol_snapshot_delete(socket: &Path, name: &str, label: &str) -> Result<()> {
|
||||
daemon_request(
|
||||
socket,
|
||||
hive_host_sock::HostRequest::DeleteSnapshot {
|
||||
name: name.to_owned(),
|
||||
label: label.to_owned(),
|
||||
},
|
||||
"snapshot delete",
|
||||
)
|
||||
.await?;
|
||||
println!("deleted snapshot {label:?} for {name}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `subvol snapshot send <agent> <label> [--parent <label>] --dest <file>` —
|
||||
/// export a snapshot to a local file via `btrfs send`. `dest` must be a
|
||||
/// bare filename (hive-priv rejects anything else via the credential-name
|
||||
/// charset check, which excludes `/`), written under the migrate-staging
|
||||
/// dir. Prints the resulting file's host path.
|
||||
async fn subvol_snapshot_send(
|
||||
socket: &Path,
|
||||
name: &str,
|
||||
label: &str,
|
||||
parent: Option<&str>,
|
||||
dest: &str,
|
||||
) -> Result<()> {
|
||||
// The daemon returns the written file's host path as a message line,
|
||||
// which `daemon_request` prints — same bare-path output as before.
|
||||
daemon_request(
|
||||
socket,
|
||||
hive_host_sock::HostRequest::SendSnapshot {
|
||||
name: name.to_owned(),
|
||||
label: label.to_owned(),
|
||||
parent: parent.map(str::to_owned),
|
||||
dest: dest.to_owned(),
|
||||
},
|
||||
"snapshot send",
|
||||
)
|
||||
.await
|
||||
}
|
||||
128
hivectl/src/util.rs
Normal file
128
hivectl/src/util.rs
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
//! Shared helpers used across hivectl's command handling: daemon
|
||||
//! request/response plumbing (`daemon_request`, `render`,
|
||||
//! `render_lifecycle`) and password input (`resolve_password`).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
|
||||
/// Send a provisioning request to the daemon and print its result lines.
|
||||
/// The daemon owns the provisioning logic; hivectl just relays the outcome,
|
||||
/// prefixing any error with `label` (e.g. `forge` / `github`).
|
||||
pub(crate) async fn daemon_request(
|
||||
socket: &Path,
|
||||
req: hive_host_sock::HostRequest,
|
||||
label: &str,
|
||||
) -> Result<()> {
|
||||
let resp = crate::client::request(socket, req)
|
||||
.await
|
||||
.with_context(|| format!("connect to daemon socket {}", socket.display()))?;
|
||||
if !resp.ok {
|
||||
bail!(
|
||||
"{label}: {}",
|
||||
resp.error.as_deref().unwrap_or("unknown error")
|
||||
);
|
||||
}
|
||||
for line in &resp.messages {
|
||||
println!("{line}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve the password the caller asked for, or return `None` to fall
|
||||
/// back to a random throwaway. `--password <PW>` wins outright;
|
||||
/// `--password-stdin` reads one line off stdin (trailing newline
|
||||
/// stripped). Empty stdin is treated as an error so the caller doesn't
|
||||
/// silently provision an empty-password account.
|
||||
pub(crate) fn resolve_password(
|
||||
password: Option<&str>,
|
||||
password_stdin: bool,
|
||||
) -> Result<Option<String>> {
|
||||
if let Some(p) = password {
|
||||
return Ok(Some(p.to_owned()));
|
||||
}
|
||||
if password_stdin {
|
||||
use std::io::BufRead as _;
|
||||
let stdin = std::io::stdin();
|
||||
let mut line = String::new();
|
||||
stdin
|
||||
.lock()
|
||||
.read_line(&mut line)
|
||||
.context("read password from stdin")?;
|
||||
let trimmed = line.trim_end_matches(['\r', '\n']).to_owned();
|
||||
if trimmed.is_empty() {
|
||||
bail!("--password-stdin: empty input");
|
||||
}
|
||||
return Ok(Some(trimmed));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Pretty-print a `HostResponse` as JSON and bail on failure. Used by the
|
||||
/// agent-lifecycle + approval verbs that just relay a daemon result verbatim.
|
||||
pub(crate) fn render(resp: hive_host_sock::HostResponse) -> Result<()> {
|
||||
println!("{}", serde_json::to_string_pretty(&resp)?);
|
||||
if !resp.ok {
|
||||
bail!(resp.error.unwrap_or_else(|| "request failed".to_owned()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Render a hive-wide stop/start/restart response: one `<verb>: <name>`
|
||||
/// line per touched container, then surface any aggregated per-target
|
||||
/// failure as a non-zero exit. `verb` is the past-tense word printed per
|
||||
/// item (`stopped` / `started`).
|
||||
pub(crate) fn render_lifecycle(resp: &hive_host_sock::HostResponse, verb: &str) -> Result<()> {
|
||||
let items = resp.agents.as_deref().unwrap_or(&[]);
|
||||
if items.is_empty() {
|
||||
println!("{verb}: nothing matched the requested scope");
|
||||
} else {
|
||||
for item in items {
|
||||
println!("{verb}: {item}");
|
||||
}
|
||||
}
|
||||
if !resp.ok {
|
||||
bail!(
|
||||
"{verb}: {}",
|
||||
resp.error.as_deref().unwrap_or("unknown error")
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Best-effort query for this hive's domain + browser-facing web URLs
|
||||
/// (`HostRequest::Urls`). `None` when the daemon is unreachable.
|
||||
pub(crate) async fn query_hive_urls(socket: &Path) -> Option<hive_host_sock::HiveUrls> {
|
||||
crate::client::request(socket, hive_host_sock::HostRequest::Urls)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|r| r.urls)
|
||||
}
|
||||
|
||||
/// True when `name` matches an existing hyperhive agent — i.e. it has a
|
||||
/// persistent state dir under `/var/lib/hyperhive/agents/`. We use the
|
||||
/// state dir (not the live container list) so kept-state tombstones
|
||||
/// still resolve as agents — re-provisioning a destroyed-but-kept agent
|
||||
/// should still drop its token in the existing state tree.
|
||||
///
|
||||
/// Uses `try_exists()` rather than `Path::exists()` so a permission
|
||||
/// error reaching the agents root is surfaced, not collapsed into
|
||||
/// `false`. The agents root is `0700 hive-core`, so running hivectl
|
||||
/// without root yields EACCES on traversal — `Path::exists()` would
|
||||
/// silently report `false`, which callers turn into a misleading "no
|
||||
/// such agent" (or, for the create-user paths, a silent misclassify of
|
||||
/// a real agent as a non-agent account). Mapping EACCES to an explicit
|
||||
/// "needs root" error fixes that first-run footgun, where running a
|
||||
/// privileged verb without sudo reported as a missing agent.
|
||||
pub(crate) fn agent_exists(name: &str) -> Result<bool> {
|
||||
let root = hive_host_sock::agent_state_dir(name);
|
||||
match root.try_exists() {
|
||||
Ok(found) => Ok(found),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => bail!(
|
||||
"cannot read the agents root {} (permission denied) - this command needs root; \
|
||||
re-run with sudo",
|
||||
root.parent().unwrap_or(&root).display()
|
||||
),
|
||||
Err(e) => Err(e).with_context(|| format!("check agent state dir {}", root.display())),
|
||||
}
|
||||
}
|
||||
203
hivectl/src/wg.rs
Normal file
203
hivectl/src/wg.rs
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
//! `hivectl wg` / `peer-config` — WireGuard mesh helpers: generate this
|
||||
//! hive's key + the nix snippets to enable the mesh, add a peer, print the
|
||||
//! block a peer pastes to federate with us, and show the live interface.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
|
||||
use crate::util::query_hive_urls;
|
||||
|
||||
/// Host path of this hive's WireGuard private key (matches the
|
||||
/// `privateKeyFile` example in the swarm.wireguard nix options).
|
||||
const WG_KEY_PATH: &str = "/etc/wireguard/hive.key";
|
||||
/// The mesh interface name hive-c0re's nix module brings up.
|
||||
const WG_INTERFACE: &str = "wg-hive";
|
||||
|
||||
/// Host path of this hive's self-signed CA cert (matches the
|
||||
/// `services.hyperhive.tls.stateDir` default in hive-tls.nix). Its
|
||||
/// existence means the gateway serves a self-signed, hive-CA-signed leaf,
|
||||
/// so a federating peer needs this CA via `swarm.peers.<d>.caCert`. Absent
|
||||
/// = ACME / operator cert (trusted by the default CA bundle, no `caCert`).
|
||||
const HIVE_TLS_CA_PATH: &str = "/var/lib/hive-tls/ca.pem";
|
||||
|
||||
/// Best-effort query for this hive's domain from the running daemon
|
||||
/// (`HostRequest::Urls`, which reads `HYPERHIVE_HIVE_DOMAIN` from c0re's
|
||||
/// service env). `None` when the daemon is unreachable or the domain is
|
||||
/// unset — callers decide whether that's fatal.
|
||||
async fn query_hive_domain(socket: &Path) -> Option<String> {
|
||||
query_hive_urls(socket).await.and_then(|u| u.domain)
|
||||
}
|
||||
|
||||
/// Require this hive's domain from the daemon for snippet generation.
|
||||
/// Errors with a clear hint when it can't be resolved, so `peer-config`
|
||||
/// never silently emits a wrong key.
|
||||
pub(crate) async fn require_hive_domain(socket: &Path) -> Result<String> {
|
||||
query_hive_domain(socket).await.context(
|
||||
"could not determine this hive's domain from the daemon — is hive-c0re running \
|
||||
and `services.hyperhive.domain` set?",
|
||||
)
|
||||
}
|
||||
|
||||
/// `wg init` — generate (if absent) the hive's WireGuard key, print its
|
||||
/// public key + the nix snippet to enable the mesh, then (best-effort)
|
||||
/// the `peer-config` block peers paste to federate with this hive, so a
|
||||
/// fresh setup is one command. The domain comes from the daemon; if it
|
||||
/// can't be resolved, the peer block is skipped (init still succeeds —
|
||||
/// its core job is enabling the mesh locally).
|
||||
pub(crate) async fn wg_init(socket: &Path, address: Option<&str>) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
let key_path = Path::new(WG_KEY_PATH);
|
||||
if key_path.exists() {
|
||||
println!(
|
||||
"WireGuard private key already present at {WG_KEY_PATH} — reusing (not regenerating)."
|
||||
);
|
||||
} else {
|
||||
if let Some(parent) = key_path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("create {}", parent.display()))?;
|
||||
std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)).ok();
|
||||
}
|
||||
let out = std::process::Command::new("wg")
|
||||
.arg("genkey")
|
||||
.output()
|
||||
.context("run `wg genkey` (is wireguard-tools installed?)")?;
|
||||
if !out.status.success() {
|
||||
bail!(
|
||||
"wg genkey failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
std::fs::write(key_path, &out.stdout)
|
||||
.with_context(|| format!("write {}", key_path.display()))?;
|
||||
std::fs::set_permissions(key_path, std::fs::Permissions::from_mode(0o400))
|
||||
.with_context(|| format!("chmod 400 {}", key_path.display()))?;
|
||||
println!("Generated WireGuard private key at {WG_KEY_PATH} (0400).");
|
||||
}
|
||||
|
||||
let privkey =
|
||||
std::fs::read(key_path).with_context(|| format!("read {}", key_path.display()))?;
|
||||
let pubkey = wg_pubkey(&privkey)?;
|
||||
let addr = address.unwrap_or("<MESH_ADDRESS e.g. 10.42.0.1/32>");
|
||||
println!("\nPublic key (share this with peer hives — they pass it to `hivectl wg peer`):");
|
||||
println!(" {pubkey}");
|
||||
println!("\nAdd to this hive's NixOS config:");
|
||||
println!(" services.hyperhive.swarm.wireguard = {{");
|
||||
println!(" enable = true;");
|
||||
println!(" privateKeyFile = \"{WG_KEY_PATH}\";");
|
||||
println!(" address = \"{addr}\";");
|
||||
println!(" # listenPort = 51820; # default");
|
||||
println!(" }};");
|
||||
|
||||
// Also print the block a peer pastes to federate with us (CA + this
|
||||
// mesh key) — one-stop setup. Domain comes from the daemon;
|
||||
// best-effort, so init still succeeds when it can't be resolved.
|
||||
if let Some(d) = query_hive_domain(socket).await {
|
||||
println!();
|
||||
peer_config(&d, address, None);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Derive a WireGuard public key from a private key by piping it through
|
||||
/// `wg pubkey`.
|
||||
fn wg_pubkey(privkey: &[u8]) -> Result<String> {
|
||||
use std::io::Write as _;
|
||||
use std::process::{Command, Stdio};
|
||||
let mut child = Command::new("wg")
|
||||
.arg("pubkey")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.context("run `wg pubkey` (is wireguard-tools installed?)")?;
|
||||
child
|
||||
.stdin
|
||||
.take()
|
||||
.context("wg pubkey: stdin unavailable")?
|
||||
.write_all(privkey)
|
||||
.context("write private key to `wg pubkey`")?;
|
||||
let out = child.wait_with_output().context("wait for `wg pubkey`")?;
|
||||
if !out.status.success() {
|
||||
bail!(
|
||||
"wg pubkey failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
|
||||
}
|
||||
|
||||
/// `wg peer` — print the nix snippet to add a peer hive to the mesh.
|
||||
/// Pure output (no fallible work), so it returns `()`; the dispatch arm
|
||||
/// wraps it in `Ok` to match the sibling verbs.
|
||||
pub(crate) fn wg_peer(domain: &str, pubkey: &str, address: &str, endpoint: Option<&str>) {
|
||||
println!("Add to this hive's NixOS config:");
|
||||
println!(" services.hyperhive.swarm.peers.\"{domain}\" = {{");
|
||||
println!(" wireguardPublicKey = \"{pubkey}\";");
|
||||
println!(" wireguardAddress = \"{address}\";");
|
||||
if let Some(ep) = endpoint {
|
||||
println!(" wireguardEndpoint = \"{ep}\";");
|
||||
}
|
||||
println!(" }};");
|
||||
}
|
||||
|
||||
/// `peer-config` — print the `swarm.peers."<domain>"` block a peer
|
||||
/// operator pastes to federate with THIS hive, plus a `cp` line for the
|
||||
/// CA when this hive is self-signed. Reads local state only (the TLS CA
|
||||
/// cert presence + the wg key); prints, never mutates.
|
||||
pub(crate) fn peer_config(domain: &str, wg_address: Option<&str>, wg_endpoint: Option<&str>) {
|
||||
let self_signed = Path::new(HIVE_TLS_CA_PATH).exists();
|
||||
// CA filename derived from the first DNS label so multiple peers'
|
||||
// certs don't collide in the operator's config dir.
|
||||
let ca_file = format!("{}-ca.pem", domain.split('.').next().unwrap_or("peer"));
|
||||
// WireGuard public key, when this hive has a mesh key. Best-effort:
|
||||
// a missing key or absent `wg` binary just omits the mesh lines.
|
||||
let wg_pub = std::fs::read(WG_KEY_PATH)
|
||||
.ok()
|
||||
.and_then(|k| wg_pubkey(&k).ok());
|
||||
|
||||
if self_signed {
|
||||
println!("# 1. copy this hive's CA cert next to the peer's config:");
|
||||
println!("cp {HIVE_TLS_CA_PATH} ./{ca_file}");
|
||||
println!();
|
||||
println!("# 2. paste into the peer hive's NixOS config:");
|
||||
} else {
|
||||
println!("# paste into the peer hive's NixOS config:");
|
||||
}
|
||||
println!("services.hyperhive.swarm.peers.\"{domain}\" = {{");
|
||||
if self_signed {
|
||||
println!(" caCert = ./{ca_file};");
|
||||
}
|
||||
if let Some(pk) = &wg_pub {
|
||||
println!(" wireguardPublicKey = \"{pk}\";");
|
||||
}
|
||||
if let Some(addr) = wg_address {
|
||||
println!(" wireguardAddress = \"{addr}\";");
|
||||
}
|
||||
if let Some(ep) = wg_endpoint {
|
||||
println!(" wireguardEndpoint = \"{ep}\";");
|
||||
}
|
||||
println!("}};");
|
||||
if !self_signed {
|
||||
println!(
|
||||
"# (this hive's cert chains to a public CA — no `caCert` needed; \
|
||||
it's trusted by the default bundle.)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `wg status` — show the live mesh interface (`wg show wg-hive`),
|
||||
/// inheriting stdout so the operator sees it directly.
|
||||
pub(crate) fn wg_status() -> Result<()> {
|
||||
let status = std::process::Command::new("wg")
|
||||
.args(["show", WG_INTERFACE])
|
||||
.status()
|
||||
.context("run `wg show` (is wireguard-tools installed?)")?;
|
||||
if !status.success() {
|
||||
bail!(
|
||||
"`wg show {WG_INTERFACE}` failed — is the mesh enabled + up? \
|
||||
(services.hyperhive.swarm.wireguard.enable = true, then deploy)"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Loading…
Reference in a new issue