hyperhive/hive-priv-sock/src/lib.rs
atlas be3411e180 feat(#3245): gate rustdoc in nix flake check, and clear the workspace
Nothing in the gate read doc-comments: clippy doesn't check intra-doc
links, cargo test doesn't, and no check built docs. So a [`Foo`] pointing
at a renamed, moved or deleted item rendered as plain text and had no
discoverer but a human happening to read the comment.

That matters here more than in most repos, because the convention is to
put a thing's authoritative description in one doc-comment and point at
it from everywhere else -- the design leans on the pointers being real,
and a dangling link is worse than no link since it names something and
sends the reader looking.

Adds `docs-rustdoc` to nix/checks.nix: craneLib.cargoDoc over
--workspace --no-deps --document-private-items, denying six rustdoc
lints. Listed explicitly rather than -D warnings so a new lint appearing
upstream cannot red the build on a class nobody has triaged.

--document-private-items is load-bearing rather than thoroughness for
its own sake: most of this workspace's doc-comments live on private
items and //! module headers, so without it rustdoc checks a small
fraction of the links and the gate sits green while the rot continues.

Then fixes every error it reports, 40 to 0 across nine crates. The
classes differ and so do the fixes:

- public item, wrong scope -> qualify. Node and Node::parent are both
  public; the link failed only because scheduler.rs does not import
  Node. Six sites become [`crate::Node::parent`].
- private item -> downgrade to backticks. Nothing was made public to
  satisfy a lint; changing API surface to appease a doc check would be
  the tail wagging the dog.
- genuinely dead -> [`JobBuilder::insert_into`] names a method that does
  not exist. Insertion is Scheduler::insert_job.
- prose that looks like markup -> argv[0] parsed as a link, and
  <args>/<hex>/<name> parsed as HTML tags.

Note for future fixes: pub(crate) resolves in an intra-doc link, a plain
private fn in a binary crate does not (wait_for_nodes resolved,
connect_hint did not, same crate, same shape).

The check does not ride the clippy/test artifact cache. It takes
cargoArtifacts, but rustdoc needs its own flavour of dependency
metadata, which cargo build does not produce, so a --no-deps docs build
still compiles dependencies it never documents. Measured at 6m47s cold;
that reasoning is recorded in the check's own comment so the next reader
does not re-derive it.

Verified by running the check's exact command against the pre-cleanup
tree first: 40 errors, build failed. A gate that cannot fail is not
evidence, and building it before the cleanup makes that proof free.
2026-08-14 02:30:55 +02:00

966 lines
44 KiB
Rust

//! Wire types for the `hive-priv` privileged-helper socket
//! (`/run/hive/priv.sock`).
//!
//! Both `hive-priv` (server) and `hive-c0re` (client via `priv_client`)
//! import these so the shapes stay in sync. Split out of `hive-sh4re` so
//! `hive-priv` — the privileged root helper — can depend on just this
//! protocol crate instead of the much larger daemon-shared crate: fewer
//! dependencies in the root-privileged binary's supply chain, and a
//! narrower interface makes the boundary this crate encodes easier to
//! audit. No server/client implementation lives here, only the wire
//! contract (mirrors `hive-host-sock`'s split for the host admin socket).
use serde::{Deserialize, Serialize};
/// Default socket path for the privileged helper.
pub const PRIV_SOCK: &str = "/run/hive/priv.sock";
/// File name of the pause marker inside an agent's harness dir. Defined
/// here — the narrowest crate all three sides already share — because the
/// marker is a two-sided contract with no protocol behind it: hive-priv
/// creates and unlinks it as root, hive-c0re stats it to render the paused
/// indicator, and the in-container harness stats it to gate its turn loop
/// (via the `hive-sh4re::paths` re-export). A private copy on any one side
/// would break pause *silently*, since every reader just sees "no marker" —
/// exactly the failure mode a shared constant exists to prevent.
pub const PAUSED_MARKER_FILE: &str = "paused";
/// Manager logical agent name. The manager's system container name is
/// `h-ruth` (same `h-` prefix convention as every other agent).
pub const MANAGER_NAME: &str = "ruth";
/// Sub-agent container prefix. System container name = `h-<agent_name>`.
pub const AGENT_PREFIX: &str = "h-";
/// Sibling service containers managed by hive-c0re. This doubles as the
/// authoritative allowlist for the requests that name a container as a
/// string (bind-mount edits, journal reads): only these — or a valid agent
/// name — are accepted. `hive-c0re` is deliberately absent; so is the
/// gateway, whose nginx is a plain host unit rather than a container.
/// hive-priv re-validates against this list root-side, so it's authoritative
/// regardless of what the caller sends.
///
/// ⚠️ This is the *container-name* allowlist, not the lifecycle one:
/// [`InfraContainer`] is what gates
/// [`PrivRequest::ControlInfraContainer`], and it has one variant more than
/// this list (the gateway). Keep the distinction — a name that belongs to
/// no container has no business reaching a `-M` / `nixos-container` call.
pub const SIBLING_CONTAINERS: &[&str] = &["hive-forge", "hive-matrix", "hive-ci"];
/// Lifecycle verb for [`PrivRequest::ControlInfraContainer`]. Maps directly
/// to `systemctl <verb> <unit>`, where the unit comes from
/// [`InfraContainer::service_unit`] — usually `container@<name>.service`,
/// but not always (see [`InfraTarget`]).
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InfraAction {
Start,
Stop,
Restart,
}
impl InfraAction {
/// The `systemctl` subcommand this action maps to.
pub fn systemctl_verb(self) -> &'static str {
match self {
InfraAction::Start => "start",
InfraAction::Stop => "stop",
InfraAction::Restart => "restart",
}
}
}
/// A hive infrastructure container that can be controlled (start / stop /
/// restart) via [`PrivRequest::ControlInfraContainer`]. The variants ARE
/// the allowlist: serde rejects any other value at the wire boundary, so an
/// unknown or unsafe target — notably `hive-c0re`, which has no variant and
/// would sever the daemon socket — is *unrepresentable* rather than caught
/// by a runtime check. The c0re↔hive-priv wire form uses serde's default
/// variant naming (`"Ci"`, `"Forge"`, …); it's an internal protocol (both
/// ends rebuild together) so it needn't match the container name.
/// [`name`](Self::name) is the separate stable identity string
/// (`hive-ci`), and [`service_unit`](Self::service_unit) the systemd unit
/// it actually resolves to.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum InfraContainer {
Ci,
Forge,
Gateway,
Matrix,
}
/// What an [`InfraContainer`] resolves to on the host — i.e. the thing a
/// lifecycle verb actually acts on.
///
/// The gateway is why this exists: its nginx + dnsmasq are host
/// services, so "restart the gateway" means a plain host unit. Every
/// other variant is a container.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InfraTarget {
/// An nspawn container, controlled via `container@<name>.service` and
/// readable with `journalctl -M <name>`.
Container(&'static str),
/// A plain host unit. There is no machine to enter: no `-M` journal,
/// no `nixos-container` verb.
HostUnit(&'static str),
}
impl InfraContainer {
/// Every controllable infra target. A superset of
/// [`SIBLING_CONTAINERS`] — not all of these are containers (see the
/// test).
pub const ALL: [InfraContainer; 4] = [
InfraContainer::Ci,
InfraContainer::Forge,
InfraContainer::Gateway,
InfraContainer::Matrix,
];
/// Stable identity string, e.g. `hive-ci`. This is what the operator
/// types, what the dashboard displays, and what [`FromStr`](std::str::FromStr)
/// parses — it stays `hive-gateway` even though the gateway is no
/// longer a container, because it names the *service*, not its
/// implementation. (Distinct from the serde wire form, which is the
/// default variant name `"Ci"`.)
#[must_use]
pub fn name(self) -> &'static str {
match self {
InfraContainer::Ci => "hive-ci",
InfraContainer::Forge => "hive-forge",
InfraContainer::Gateway => "hive-gateway",
InfraContainer::Matrix => "hive-matrix",
}
}
/// Where this target lives on the host.
#[must_use]
pub fn target(self) -> InfraTarget {
match self {
InfraContainer::Gateway => InfraTarget::HostUnit("nginx.service"),
other => InfraTarget::Container(other.name()),
}
}
/// The systemd unit a lifecycle verb acts on.
#[must_use]
pub fn service_unit(self) -> String {
match self.target() {
InfraTarget::Container(name) => format!("container@{name}.service"),
InfraTarget::HostUnit(unit) => unit.to_owned(),
}
}
/// Whether an agent holding `infra_admin` may restart this target.
///
/// The gateway is excluded by operator ruling: nginx now fronts every
/// hive service from the host, so an agent restarting it can take the
/// forge, dashboard and matrix down with it — including the path its
/// own PR would have to travel to fix it. The operator surface
/// (`hivectl`, dashboard) is unaffected.
#[must_use]
pub fn agent_restartable(self) -> bool {
!matches!(self, InfraContainer::Gateway)
}
}
impl std::str::FromStr for InfraContainer {
type Err = ();
/// Parse an infra name (`hive-ci`, …) into a variant. Recognition
/// only — it says the name denotes a hive service, *not* that the
/// caller may act on it. The agent restart path additionally checks
/// [`agent_restartable`](InfraContainer::agent_restartable).
/// `Err(())` for anything that isn't one.
fn from_str(s: &str) -> Result<Self, ()> {
Self::ALL.into_iter().find(|c| c.name() == s).ok_or(())
}
}
/// Host path of the meta flake. The flake ref for agent `<name>` is
/// `{META_DIR}#{name}`, derived by `hive-priv` — never passed over the wire.
/// Must stay in sync with `hive-c0re::paths::meta_root()` (`STATE_ROOT +
/// "/meta"`); the privsep boundary prevents importing across the crate.
pub const META_DIR: &str = "/var/lib/hyperhive/meta";
/// Root of per-agent state directories on the host.
/// Subdirectory layout: `<AGENT_STATE_ROOT>/<name>/state/<file>`.
/// Used by `WriteAgentStateFile` to derive the write path so the
/// exact path is never passed over the wire.
/// Must stay in sync with `hive-c0re::paths::AGENTS_ROOT`; the privsep
/// boundary prevents importing across the crate.
pub const AGENT_STATE_ROOT: &str = "/var/lib/hyperhive/agents";
/// Root of per-agent runtime directories on the host (regenerated each boot
/// by `hive-priv` tmpfiles.d; not persistent). Used by `hive-priv` when
/// creating per-agent subdirs via `nsenter` / tmpfiles.
/// Must stay in sync with `hive-c0re::paths::agent_runtime_root()`
/// (`RUNTIME_ROOT + "/agents"`); the privsep boundary prevents importing
/// across the crate.
pub const AGENT_RUNTIME_ROOT: &str = "/run/hyperhive/agents";
/// Root of the local staging area for `btrfs send` archives
/// (`SendAgentSnapshotToFile`). A sibling of `AGENT_STATE_ROOT`, not inside
/// it — these are exported streams, not live/subvolume state, and don't
/// belong in the tree btrfs quota accounting or the subvolume-per-agent
/// layout cares about. Root-owned; hive-priv creates it on first use.
pub const MIGRATE_STAGING_ROOT: &str = "/var/lib/hyperhive/migrate-staging";
/// Output format for `ReadContainerJournal`. Maps to journalctl
/// `--output=<...>`. Restricted to the two formats hive callers use so
/// the wire type can't smuggle an arbitrary `--output` value.
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum JournalOutput {
/// `short` — the journalctl default (syslog-style timestamps).
#[default]
Short,
/// `short-iso` — ISO 8601 timestamps.
ShortIso,
}
impl JournalOutput {
/// The string journalctl expects after `--output=`.
#[must_use]
pub fn as_journalctl(self) -> &'static str {
match self {
JournalOutput::Short => "short",
JournalOutput::ShortIso => "short-iso",
}
}
}
/// journalctl knobs for `ReadContainerJournal`. Grouped into one value so
/// the read-journal call chain (`priv_client::read_container_journal` →
/// hive-priv's executor) and its several hive-c0re callers pass a single
/// struct instead of eight positional args that travelled together 1:1.
/// `Default` is the common case (last N lines, short format, no filters);
/// callers fill `lines` and override only the knobs they need via
/// `..Default::default()`.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct JournalQuery {
/// `-n <lines>`.
pub lines: u32,
/// `-b` — restrict to the current boot.
#[serde(default)]
pub boot: bool,
/// `--output=<...>`.
#[serde(default)]
pub output: JournalOutput,
/// `-u <unit>`.
#[serde(default)]
pub unit: Option<String>,
/// `-p <priority>`.
#[serde(default)]
pub priority: Option<String>,
/// `--grep=<regex>`.
#[serde(default)]
pub grep: Option<String>,
/// `--since=<ts>`.
#[serde(default)]
pub since: Option<String>,
/// `--until=<ts>`.
#[serde(default)]
pub until: Option<String>,
}
/// One bind-mount entry for `WriteNspawnFlags`.
/// hive-priv constructs `--bind=<host_path>:<container_path>` (or `--bind-ro=`)
/// and validates both paths before writing the conf file.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BindMount {
pub host_path: String,
pub container_path: String,
pub read_only: bool,
}
/// One credential-forwarding entry for `WriteNspawnFlags`. hive-priv
/// constructs `--load-credential=<name>:<host_path>` so systemd-nspawn
/// loads the host secret file into the container's credential store; an
/// inner unit then reads it via `LoadCredential=<name>` (inherit form).
/// The secret never lands in a bind mount, the nix store, or the
/// generated config — only its host path (validated like a bind path)
/// crosses the wire. Used for the hive-wide OTEL auth-header credential.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CredentialMount {
/// systemd credential id (e.g. `otel-headers`); inner units inherit
/// it by this name. Restricted to `[A-Za-z0-9_-]` (no `.`) by hive-priv.
pub name: String,
/// Host path to the secret file, forwarded via nspawn
/// `--load-credential=<name>:<host_path>`.
pub host_path: String,
}
/// Network isolation parameters for `WriteNspawnFlags`. When `Some`,
/// hive-priv writes `PRIVATE_NETWORK=1` + veth bridge wiring instead
/// of the default `PRIVATE_NETWORK=0`. Containers receive their IP
/// dynamically via DHCP from the bridge dnsmasq pool (`networking.useDHCP`
/// in `harness-base.nix`); no static address is pre-assigned here.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetworkIsolation {
/// Host bridge interface name (e.g. `hive0`).
pub bridge: String,
/// Bridge gateway IP (the host-side bridge address, e.g. `10.42.0.1`).
/// Written as `HOST_ADDRESS=` in the nspawn conf so nixos-container's
/// container-side setup installs a default route (`default via <gw>`)
/// before DHCP completes: without it the container has no route off
/// the bridge subnet until the DHCP lease arrives. The same IP runs the
/// hive dnsmasq resolver, so it's also written into the container's
/// `/etc/resolv.conf` (see the isolated-DNS oneshot in `harness-base.nix`,
/// gated on the marker hive-priv drops).
pub gateway_ip: String,
}
/// A request to the privileged helper.
///
/// Wire format: one JSON object per line over `/run/hive/priv.sock`.
/// Every variant is a specific known operation — no pass-through
/// shell commands or arbitrary paths. New privileged ops get new
/// variants.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum PrivRequest {
// --- Container lifecycle ---
/// `nixos-container start <name>`
StartContainer { name: String },
/// `nixos-container stop <name>`
StopContainer { name: String },
/// `machinectl kill <name> --signal=SIGKILL` — force-kills all processes
/// in the container. nixos-container has no kill verb.
KillContainer { name: String },
/// `nixos-container update <name> --flake <flake_ref>`
/// The flake ref is derived from `name` by hive-priv.
/// When `stream` is `true`, hive-priv sends `PrivEvent::Line` messages
/// as the process runs, then a terminal `PrivEvent::Done`.
UpdateContainer {
name: String,
#[serde(default)]
stream: bool,
},
/// `nixos-container create <name> --flake <flake_ref>`
/// The flake ref is derived from `name` by hive-priv.
/// When `stream` is `true`, hive-priv sends `PrivEvent::Line` messages
/// as the process runs, then a terminal `PrivEvent::Done`.
CreateContainer {
name: String,
#[serde(default)]
stream: bool,
},
/// `nixos-container destroy <name>`
DestroyContainer { name: String },
/// `nixos-container list`
ListContainers,
// --- Container journal reads ---
/// Read a container's journal via `journalctl -M <container>`.
/// Requires root: the machine-bus transport enters the container's
/// namespace, so this can't run from the unprivileged hive-c0re
/// process. hive-priv validates `container` against the managed-
/// container allowlist, then runs journalctl and returns its output.
///
/// The filters (`unit` / `priority` / `grep` / `since` / `until`)
/// are applied within the already-authorized machine and passed to
/// journalctl as plain argument values; they can't widen access
/// beyond the validated `container`.
ReadContainerJournal {
/// System container name (`h-<agent>` or a sibling service).
container: String,
/// journalctl knobs (see [`JournalQuery`]).
query: JournalQuery,
},
// --- Config file writes ---
/// Update `/etc/nixos-containers/<container>.conf`: strip old network-isolation
/// vars, write `PRIVATE_NETWORK` + bridge settings, and set `EXTRA_NSPAWN_FLAGS`
/// from the provided bind-mount list. Written by `lifecycle::set_nspawn_flags`.
/// When `isolation` is `Some`, writes `PRIVATE_NETWORK=1` + veth wiring;
/// when `None`, writes `PRIVATE_NETWORK=0`.
WriteNspawnFlags {
container: String,
binds: Vec<BindMount>,
/// `None` = host netns (`PRIVATE_NETWORK=0`). `Some` = private netns with
/// veth on the specified bridge (`PRIVATE_NETWORK=1`).
#[serde(default)]
isolation: Option<NetworkIsolation>,
/// Host secrets forwarded into the container's credential store via
/// nspawn `--load-credential=<name>:<host_path>`. Empty for agents
/// with no credentials configured (the common case). `#[serde(default)]`
/// so a hive-priv built before this field deserialises new requests.
#[serde(default)]
load_credentials: Vec<CredentialMount>,
},
/// Write `/run/systemd/system/container@<container>.service.d/hyperhive-limits.conf`
/// with `[Service]` carrying `MemoryMax=` / `CPUQuota=` (hard caps) and
/// `CPUWeight=` / `IOWeight=` (cgroup v2 relative shares, contention-only).
/// Written by `lifecycle::set_resource_limits`.
WriteResourceLimits {
container: String,
memory_max: String,
cpu_quota: String,
/// cgroup v2 `cpu.weight`, 1..=10000. `None` means "not
/// configured" — the writer omits the line entirely, leaving the
/// kernel default. `#[serde(default)]` so a request from a
/// hive-c0re built before this field existed deserialises to
/// `None` and reproduces the pre-weights drop-in.
#[serde(default)]
cpu_weight: Option<u32>,
/// cgroup v2 `io.weight`, 1..=10000. Same `None` = omit rule as
/// `cpu_weight`.
#[serde(default)]
io_weight: Option<u32>,
},
/// Remove `/run/systemd/system/container@<container>.service.d/` if present.
/// Called by `lifecycle::destroy` to clean up the resource-limits drop-in.
RemoveServiceDropin { container: String },
// --- System ---
/// Run `systemctl daemon-reload`.
DaemonReload,
/// Synchronise the host's nginx unit after an `agents.conf` write.
///
/// hive-priv queries `ActiveState` and dispatches:
/// - `active` → `systemctl reload nginx` (SIGHUP, zero-downtime)
/// - `failed` → `systemctl reset-failed nginx` + `systemctl start nginx`
/// - otherwise → `systemctl start nginx`
///
/// Requires root because hive-c0re runs as the unprivileged
/// `hive-core` user and cannot act on a system unit.
///
/// ⚠️ The unit name is **not** a parameter and must stay that way.
/// nginx is a host unit, so nothing else narrows what this verb can
/// touch: hard-coding `nginx` is the entire containment. A caller
/// cannot name the unit, so this verb cannot be steered at any other
/// service.
ReloadGatewayNginx,
// --- Forge admin CLI ---
/// Run `forgejo admin <args>` inside the `hive-forge` container as the
/// `forgejo` unix user. hive-priv executes:
///
/// nixos-container run hive-forge -- runuser -u forgejo --
/// forgejo --work-path /var/lib/forgejo admin `<args>`
///
/// `args` must not contain null bytes, newlines, or shell metacharacters;
/// hive-priv validates this before spawning the subprocess.
///
/// This operation requires root (to nsenter into the forge container's
/// namespaces); hive-c0re (which runs as `hive-core`) calls it through
/// this route instead of spawning `nixos-container run` directly.
RunForgeAdmin {
/// Argument list appended after `forgejo --work-path /var/lib/forgejo admin`.
/// Each element is a separate argv word — no shell expansion occurs.
args: Vec<String>,
},
// --- Agent turn-loop pause ---
// (marker filename: `PAUSED_MARKER_FILE`, defined at the crate root)
/// Create (`paused: true`) or remove (`paused: false`) the pause marker
/// at `AGENT_STATE_ROOT/<agent_name>/harness/paused`. Its presence parks
/// the agent's turn loop; the harness stats it in-container through the
/// harness bind-mount.
///
/// Required because hive-c0re runs unprivileged: the harness dir is
/// chowned to the agent user on first container boot (mode 0755), so
/// hive-core can stat the marker but cannot create or unlink it. Both
/// directions are idempotent — pausing an already-paused agent rewrites
/// an empty file, and resuming a running one treats `NotFound` as
/// success.
SetAgentPaused {
/// Logical agent name (validated by `validate_agent_name`).
agent_name: String,
/// `true` creates the marker, `false` removes it.
paused: bool,
},
// --- Agent credential writes ---
/// Write `forge-token` into `AGENT_STATE_ROOT/<agent_name>/state/forge-token`.
///
/// hive-priv validates `agent_name`, creates the state dir if absent,
/// writes the file 0600, and chowns it to the state dir's owner so
/// the agent process can read it. Required because hive-c0re runs
/// unprivileged and cannot write to agent-owned state directories.
WriteAgentForgeToken {
/// Logical agent name (validated by `validate_agent_name`).
agent_name: String,
/// Token value. hive-priv appends a trailing newline before writing.
token: String,
},
/// Write a matrix access token into the agent's state dir. With
/// `account: None` it targets the hive-internal `matrix-token`; with
/// `account: Some(name)` it targets `matrix-token-<name>` for an extra
/// (external) account. hive-priv validates both `agent_name` and the
/// `account` suffix as plain identifiers before building the path, so a
/// crafted account name cannot traverse out of the state dir.
///
/// Same write semantics as `WriteAgentForgeToken` — validates names,
/// creates dir, writes 0600, chowns to agent owner.
WriteAgentMatrixToken {
/// Logical agent name (validated by `validate_agent_name`).
agent_name: String,
/// Token value. hive-priv appends a trailing newline before writing.
token: String,
/// Extra-account suffix. `None` → `matrix-token` (the hive account);
/// `Some(name)` → `matrix-token-<name>` (validated as a plain ident).
account: Option<String>,
/// Homeserver URL for an extra account. When `Some` (only meaningful
/// alongside `account: Some`), hive-priv also writes the sidecar
/// `matrix-account-<name>.json` (`{"homeserver": <url>}`, 0600,
/// chowned to the agent) so the daemon can auto-discover the account
/// without a config declaration. `None` → no sidecar written.
#[serde(default)]
homeserver: Option<String>,
},
/// Write `github-token` into `AGENT_STATE_ROOT/<agent_name>/state/github-token`.
///
/// The operator-supplied GitHub personal access token (PAT) for the
/// agent's GitHub integration (`hyperhive.github.enable`). Same write
/// semantics as
/// `WriteAgentForgeToken` — validates `agent_name`, creates the state dir
/// if absent, writes the file 0600, and chowns it to the agent so the
/// `gh` wrapper / git credential helper can read it. No account suffix
/// (single GitHub account per agent).
WriteAgentGithubToken {
/// Logical agent name (validated by `validate_agent_name`).
agent_name: String,
/// PAT value. hive-priv appends a trailing newline before writing.
token: String,
},
/// Write a per-agent account for an external, dashboard-declared forge:
/// the access token to
/// `AGENT_STATE_ROOT/<agent_name>/state/forge-<label>-token` (0600) and
/// a `forge-<label>.json` sidecar (`{"base_url": <base_url>}`, 0600) so
/// the base URL survives without any host-side nix config — the whole
/// account (label + URL + token) is operator-entered on the dashboard,
/// same shape as `WriteAgentMatrixToken`'s homeserver sidecar.
///
/// `label` MUST be validated as a plain identifier (same rule as the
/// matrix `account` suffix) before it goes into the filename — a
/// crafted label could otherwise traverse out of the state dir. Same
/// write semantics as `WriteAgentForgeToken` — validates `agent_name`,
/// creates the state dir if absent, writes both files 0600, chowns to
/// the agent.
WriteAgentExtraForgeAccount {
/// Logical agent name (validated by `validate_agent_name`).
agent_name: String,
/// Dashboard-chosen label for this external forge. Validated as a
/// plain identifier before use.
label: String,
/// Base HTTP(S) URL of the external forge, operator-entered on the
/// dashboard (no host-side config).
base_url: String,
/// Token value. hive-priv appends a trailing newline before writing.
token: String,
},
/// Remove a previously-written `forge-<label>-token` + `forge-<label>.
/// json` from an agent's state dir — the revoke half of
/// `WriteAgentExtraForgeAccount`. Missing files are not an error
/// (idempotent revoke).
DeleteAgentExtraForgeAccount {
/// Logical agent name (validated by `validate_agent_name`).
agent_name: String,
/// The forge label to revoke. Validated as a plain identifier
/// before use.
label: String,
},
/// Restart `hive-matrix-daemon.service` inside an agent container via
/// `systemctl --machine=h-<agent_name> restart hive-matrix-daemon.service`.
/// Used by hive-c0re to kick the daemon after a successful token write
/// so it picks up the new credential without a full container restart.
RestartMatrixDaemon {
/// Logical agent name (validated by `validate_agent_name`).
agent_name: String,
},
/// Register the hive-ci Forgejo Actions runner: write the registration
/// token to the host-side `/run/hive-ci/runner-token` env-file (root-owned,
/// bind-mounted read-only into the container) as `TOKEN=<token>`, then
/// `systemctl --machine=hive-ci restart gitea-runner-hive.service` so the
/// runner picks up the credential. hive-c0re holds the forge admin token
/// and mints the registration token; only the registration token is written
/// here, and only to a host path — the admin token never enters the
/// container. The token is validated single-line + non-empty root-side.
RegisterCiRunner {
/// Forge runner registration token minted by hive-c0re.
token: String,
},
/// Start / stop / restart a hive infrastructure container on the host
/// via `systemctl <action> container@<container>.service`. The
/// [`InfraContainer`] enum is the allowlist — serde rejects unknown /
/// unsafe names (notably `hive-c0re`, which has no variant) at the wire
/// boundary, so no root-side `.contains()` check is needed. Serves both
/// the hive-wide `hivectl stop` / `hivectl start` flow and an
/// `infra_admin` agent's `restart` (with `action = Restart`).
ControlInfraContainer {
container: InfraContainer,
action: InfraAction,
},
// --- Agent state subvolumes (btrfs) ---
/// Ensure the agent's persistent state root
/// (`<AGENT_STATE_ROOT>/<agent_name>`) is a btrfs subvolume — IF the
/// underlying filesystem is btrfs and the root doesn't already exist.
///
/// hive-priv derives the path from `agent_name` (never passed over the
/// wire), validates the name, then:
/// - path already exists (dir or subvol) → no-op (progressive: existing
/// agents are left exactly as they are, never auto-migrated);
/// - parent FS is not btrfs → no-op (hive-c0re's normal `create_dir_all`
/// makes a plain directory, the pre-subvolume behaviour);
/// - else → `btrfs subvolume create <path>` and chown it to the owner of
/// `AGENT_STATE_ROOT` (the `hive-core` user) so hive-c0re can create the
/// `state/` / `claude/` / `harness/` subdirs inside it as before.
///
/// Idempotent and safe to call on every provision. Requires root: btrfs
/// subvolume creation is privileged.
EnsureAgentSubvolume {
/// Logical agent name (validated by `validate_agent_name`).
agent_name: String,
},
/// Delete the agent's persistent state root if — and only if — it is a
/// btrfs subvolume. Called by hive-c0re on the **purge** path only
/// (never on a plain destroy, which keeps state for revival).
///
/// A subvolume root cannot be removed with `rmdir`/`remove_dir_all`, so
/// this routes through hive-priv to run `btrfs subvolume delete`. If the
/// path is a plain directory (pre-subvolume agent) or doesn't exist, it's
/// a no-op — hive-c0re's own `remove_dir_all` handles the plain-dir case.
/// hive-priv derives + validates the path the same way as
/// [`PrivRequest::EnsureAgentSubvolume`]. Requires root.
DeleteAgentSubvolume {
/// Logical agent name (validated by `validate_agent_name`).
agent_name: String,
},
// --- btrfs qgroup accounting + quota (operator opt-in) ---
/// Enable btrfs qgroup accounting on the filesystem holding
/// `AGENT_STATE_ROOT` (`btrfs quota enable <AGENT_STATE_ROOT>`).
/// Prerequisite for per-agent usage reads + quotas. **Operator
/// opt-in** — never run automatically: enabling triggers a full
/// rescan with real I/O cost on a large filesystem. Idempotent
/// (already-enabled is success); a no-op on non-btrfs (statfs gate).
/// Requires root.
EnsureBtrfsQuota,
/// Read an agent state subvolume's btrfs qgroup usage
/// (`btrfs qgroup show -f --raw <AGENT_STATE_ROOT>/<agent_name>`).
/// Returns the raw `qgroup show` row in stdout for hive-c0re to parse
/// (referenced + exclusive bytes). Errors with "quota not enabled" when
/// accounting is off — hive-c0re surfaces that gracefully. Requires root
/// (qgroup show on a subvolume needs `CAP_SYS_ADMIN`).
ReadSubvolumeUsage {
/// Logical agent name (validated by `validate_agent_name`).
agent_name: String,
},
/// Set (or clear) a btrfs qgroup size limit on an agent's state
/// subvolume (`btrfs qgroup limit <bytes|none> <…/agent_name>`).
/// `limit_bytes = Some(n)` caps referenced usage at `n` bytes;
/// `None` clears the limit (`none`). Requires quota enabled first
/// ([`PrivRequest::EnsureBtrfsQuota`]). Requires root.
SetSubvolumeQuota {
/// Logical agent name (validated by `validate_agent_name`).
agent_name: String,
/// Byte cap on referenced usage; `None` clears the limit.
limit_bytes: Option<u64>,
},
/// Convert an existing **plain-directory** agent state root into a btrfs
/// subvolume in place. The operator opt-in counterpart to the progressive
/// `EnsureAgentSubvolume` (which only ever makes *new* agents subvolumes):
/// it migrates an already-existing plain dir so the agent gains the
/// subvolume feature set (snapshots, per-subvol usage/quota, send/receive).
///
/// btrfs cannot promote a directory in place, so the helper does the move:
/// create a fresh subvolume, copy the dir's contents into it preserving
/// ownership/permissions/xattrs (`cp -a --reflink=auto`), then atomically
/// rename the original aside and the subvolume into place, and finally
/// remove the original. The caller (hivectl) MUST stop the agent first so
/// its state bind-mount is gone before the host dir moves, and restart it
/// after. Behaviour:
/// - path missing → error (nothing to upgrade);
/// - already a subvolume → no-op success (idempotent);
/// - parent FS not btrfs → error (subvolumes unsupported here);
/// - any failure before the final swap leaves the original dir untouched
/// (no half-migration). Requires root.
UpgradeAgentSubvolume {
/// Logical agent name (validated by `validate_agent_name`).
agent_name: String,
},
/// Create a read-only snapshot of an agent's state subvolume
/// (`btrfs subvolume snapshot -r <agent_root> <snapshot_path>`). Used as
/// the first step of inter-hive migration (`hivectl migrate`): freezing
/// a consistent point-in-time copy that `btrfs send` can stream from
/// while the source subvolume keeps running underneath the live agent.
///
/// The snapshot is created as a sibling of the agent's state root
/// (`<AGENT_STATE_ROOT>/.<agent_name>.snapshot.<snapshot_name>`, dot-prefixed
/// so it never collides with a real agent name) and its path is returned
/// verbatim in the response's `stdout`. Fails if the agent's state root
/// isn't a btrfs subvolume (nothing to snapshot) or a snapshot with the
/// same name already exists. Requires root.
SnapshotAgentSubvolume {
/// Logical agent name (validated by `validate_agent_name`).
agent_name: String,
/// Snapshot label. Must start with `hive-` — the prefix doubles as
/// an allow-list hive-priv enforces so only hivectl-issued names
/// can reach the `btrfs subvolume snapshot` shellout — and
/// otherwise follows the same charset as a credential name
/// (non-empty `[A-Za-z0-9_-]`, no `.`); becomes part of the
/// snapshot path.
snapshot_name: String,
},
/// Delete a previously-created read-only snapshot
/// (`btrfs subvolume delete <snapshot_path>`). Cleanup counterpart to
/// [`PrivRequest::SnapshotAgentSubvolume`] — called once a migration's
/// `btrfs send` has completed (or aborted) and the frozen copy is no
/// longer needed. No-op if the snapshot path doesn't exist. Requires root.
DeleteAgentSnapshot {
/// Logical agent name (validated by `validate_agent_name`).
agent_name: String,
/// Snapshot label, same validation as `SnapshotAgentSubvolume`.
snapshot_name: String,
},
/// Stream a previously-created read-only snapshot to a local file via
/// `btrfs send [-p <parent>] <snapshot> > <MIGRATE_STAGING_ROOT>/<dest_file_name>`.
/// The local-file half of the inter-hive migration transport: the
/// cross-hive leg (piping into `ssh <peer> btrfs receive`) is a later,
/// separate piece pending the auth/trust design — this variant is
/// useful standalone today as a point-in-time export/backup of a
/// snapshot (full send, no parent) or to validate the incremental
/// (`-p`) path locally before wiring up the network leg.
///
/// `dest_file_name` is a bare filename (not a path) under
/// `MIGRATE_STAGING_ROOT`, which hive-priv creates on first use.
/// Fails if the snapshot doesn't exist, `parent_snapshot_name` is given
/// but doesn't exist, or `dest_file_name` already exists (never
/// silently overwrites an export). Requires root.
SendAgentSnapshotToFile {
/// Logical agent name (validated by `validate_agent_name`).
agent_name: String,
/// Snapshot label to send, same validation as `SnapshotAgentSubvolume`.
snapshot_name: String,
/// Optional parent snapshot label for an incremental
/// (`btrfs send -p`) send — must be an older read-only snapshot of
/// the same agent, still present on disk. `None` sends the full
/// snapshot.
parent_snapshot_name: Option<String>,
/// Bare filename (no path separators) for the exported stream,
/// written under `MIGRATE_STAGING_ROOT`. Same charset as a
/// credential name (`[A-Za-z0-9_-]`).
dest_file_name: String,
},
/// Stream a previously-created read-only snapshot into a file
/// descriptor the caller passes alongside this request (`SCM_RIGHTS`
/// ancillary data on the same socket): `btrfs send [-p <parent>]
/// <snapshot> >&<passed fd>`.
///
/// The network half of the inter-hive migration transport. hive-c0re
/// connects to the peer hive's snapshot store, writes the header
/// itself, and hands the **connected socket** over — so hive-priv
/// never learns an address, a protocol, or that a network is
/// involved, and nobody sits in the data path once the send starts
/// (which is what makes a multi-gigabyte transfer survive a
/// hive-c0re restart).
///
/// Exactly one descriptor must accompany this request. hive-priv
/// rejects the request if none arrived, if more than one did, or if a
/// descriptor arrives alongside any *other* operation — no guessing
/// when the caller didn't say. Requires root.
SendAgentSnapshotToFd {
/// Logical agent name (validated by `validate_agent_name`).
agent_name: String,
/// Snapshot label to send, same validation as `SnapshotAgentSubvolume`.
snapshot_name: String,
/// Optional parent snapshot label for an incremental
/// (`btrfs send -p`) send — must be an older read-only snapshot of
/// the same agent, still present on disk. `None` sends the full
/// snapshot.
parent_snapshot_name: Option<String>,
},
/// Write `/etc/tmpfiles.d/hyperhive-agents.conf` for the given agent set
/// and immediately apply it with `systemd-tmpfiles --create`. Each entry
/// declares the per-agent runtime dirs (`/run/hyperhive/agents/<name>` and
/// `/run/hive-agent/<name>`) so systemd recreates them at every boot before
/// any container units start — preventing bind-mount source missing errors
/// when container@h-* units race hive-c0re after a reboot.
///
/// Called at hive-c0re startup and after every agent spawn / destroy.
/// Agents are logical names (validated by `validate_agent_name`).
SyncAgentTmpfiles {
/// One entry per live agent. hive-priv validates each name before
/// writing any path component derived from it.
agents: Vec<AgentTmpfilesEntry>,
},
}
/// One agent's runtime-dir declaration for `SyncAgentTmpfiles`.
///
/// Carries the container uid/gid so the tmpfiles entry can *declare* who owns
/// `/run/hive-agent/<name>` instead of having it corrected afterwards by a
/// privileged chown. The two mechanisms used to fight: the tmpfiles line wrote
/// `0777 root root` and a follow-up `ChownSocketDir` narrowed it, but any
/// later spawn or destroy re-applied the file and reset *every* agent's dir
/// back to world-writable.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentTmpfilesEntry {
/// Logical agent name (e.g. `"atlas"`, `"ruth"`).
pub name: String,
/// Container uid/gid of the agent user, when known.
///
/// `None` only before the container's `/etc/passwd` has been rendered
/// (first boot). The dir must stay writable by the not-yet-identifiable
/// harness in that window, so hive-priv falls back to the historical
/// permissive mode for that one agent; the next sync tightens it.
pub uid: Option<u32>,
pub gid: Option<u32>,
}
/// Response from the privileged helper.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrivResponse {
pub ok: bool,
#[serde(default)]
pub stdout: String,
#[serde(default)]
pub stderr: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
// ---------------------------------------------------------------------------
// Streaming protocol
// ---------------------------------------------------------------------------
/// Which output stream a `PrivStreamLine` came from.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PrivStream {
Stdout,
Stderr,
}
/// A single line forwarded from a streaming privileged operation.
/// Wire shape: `{"stream":"stdout","data":"..."}` — distinct from
/// `PrivResponse` (which has `ok` but not `stream`/`data`) so that
/// `PrivEvent` can disambiguate with `#[serde(untagged)]`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrivStreamLine {
pub stream: PrivStream,
pub data: String,
}
/// An event in the streaming protocol used by long-running priv ops.
///
/// Wire format (multiple JSON lines over one connection):
/// - Zero or more `Line` events as the subprocess runs.
/// - One terminal `Done` event carrying the final status.
///
/// Non-streaming ops (and old hive-priv) send exactly one `Done` line,
/// which is wire-identical to a bare `PrivResponse` — so old `call()`
/// callers that deserialise straight to `PrivResponse` continue to work
/// with new hive-priv's terminal event.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum PrivEvent {
/// A line of output from the running subprocess.
Line(PrivStreamLine),
/// Terminal event: the operation has finished.
Done(PrivResponse),
}
#[cfg(test)]
mod tests {
use super::{InfraContainer, InfraTarget, SIBLING_CONTAINERS};
#[test]
fn infra_control_allowlist_excludes_c0re_includes_matrix() {
// SIBLING_CONTAINERS is the authoritative allowlist for the
// requests that name a container as a string. hive-c0re must NEVER
// be in it — stopping the daemon would sever the socket the request
// arrived on.
assert!(!SIBLING_CONTAINERS.contains(&"hive-c0re"));
// hive-matrix IS controllable (operator can stop/start/restart it).
assert!(SIBLING_CONTAINERS.contains(&"hive-matrix"));
}
#[test]
fn infra_container_enum_matches_sibling_containers() {
// The two lists are the same set *minus the things that aren't
// containers*: every `Container` variant must appear in
// SIBLING_CONTAINERS and vice versa, so a `-M` journal read or a
// `nixos-container` verb can never be pointed at a name that has no
// machine behind it.
let mut from_enum: Vec<&str> = InfraContainer::ALL
.iter()
.filter_map(|c| match c.target() {
InfraTarget::Container(name) => Some(name),
InfraTarget::HostUnit(_) => None,
})
.collect();
from_enum.sort_unstable();
let mut from_slice: Vec<&str> = SIBLING_CONTAINERS.to_vec();
from_slice.sort_unstable();
assert_eq!(from_enum, from_slice);
// The gateway is the one that is not: a host unit, and absent from
// the container-name allowlist.
assert_eq!(
InfraContainer::Gateway.target(),
InfraTarget::HostUnit("nginx.service")
);
assert!(!SIBLING_CONTAINERS.contains(&"hive-gateway"));
// hive-c0re has no variant — unrepresentable, can't be controlled.
assert!("hive-c0re".parse::<InfraContainer>().is_err());
}
#[test]
fn infra_container_name_round_trips() {
// `name` is the single source of truth for the operator-facing form
// (FromStr keys off it), so a name→variant→name round-trip proves
// the mapping is consistent in both directions. It holds for the
// gateway too: the name is still recognised, it's the *permission*
// that differs (see below), not the parse.
for c in InfraContainer::ALL {
assert_eq!(c.name().parse::<InfraContainer>(), Ok(c));
}
}
#[test]
fn service_unit_wraps_containers_but_not_host_units() {
assert_eq!(
InfraContainer::Ci.service_unit(),
"container@hive-ci.service"
);
assert_eq!(InfraContainer::Gateway.service_unit(), "nginx.service");
}
#[test]
fn only_the_gateway_is_off_limits_to_agents() {
// Recognising a name and being allowed to restart it are separate
// questions — the gateway parses fine and is still refused.
assert!("hive-gateway".parse::<InfraContainer>().is_ok());
assert!(!InfraContainer::Gateway.agent_restartable());
for c in InfraContainer::ALL {
assert_eq!(c.agent_restartable(), c != InfraContainer::Gateway, "{c:?}");
}
}
}