hive-priv applies only the path its last RealiseClosure returned for that agent. On drift the swap and create record the pins of the commit the closure was built from.
805 lines
36 KiB
Rust
805 lines
36 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";
|
|
|
|
/// Sub-agent container prefix. System container name = `h-<agent_name>`.
|
|
pub const AGENT_PREFIX: &str = "h-";
|
|
|
|
/// Lifecycle verb for [`PrivRequest::ControlInfraContainer`]. Maps directly
|
|
/// to `systemctl <verb> <unit>`, where the unit comes from
|
|
/// [`InfraContainer::service_unit`].
|
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum InfraAction {
|
|
Start,
|
|
Stop,
|
|
}
|
|
|
|
impl InfraAction {
|
|
/// The `systemctl` subcommand this action maps to.
|
|
#[must_use]
|
|
pub fn systemctl_verb(self) -> &'static str {
|
|
match self {
|
|
InfraAction::Start => "start",
|
|
InfraAction::Stop => "stop",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A hive infrastructure service 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 is *unrepresentable* rather than caught by a
|
|
/// runtime check. Notably absent: `hive-c0re`, whose stop would sever the
|
|
/// daemon socket, and the swarm's forge and matrix, which a hive does not
|
|
/// own. The c0re↔hive-priv wire form uses serde's default variant naming
|
|
/// (`"Gateway"`); it's an internal protocol (both ends rebuild together)
|
|
/// so it needn't match the service name. [`name`](Self::name) is the
|
|
/// separate stable identity string (`hive-gateway`), and
|
|
/// [`service_unit`](Self::service_unit) the systemd unit it resolves to.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum InfraContainer {
|
|
Gateway,
|
|
}
|
|
|
|
impl InfraContainer {
|
|
/// Every controllable infra target.
|
|
pub const ALL: [InfraContainer; 1] = [InfraContainer::Gateway];
|
|
|
|
/// Stable identity string, e.g. `hive-gateway`. This is what the
|
|
/// operator types, what the dashboard displays, and what
|
|
/// [`FromStr`](std::str::FromStr) parses. It names the *service*, not
|
|
/// its implementation: the gateway is the host's nginx, not a
|
|
/// container. (Distinct from the serde wire form, which is the default
|
|
/// variant name `"Gateway"`.)
|
|
#[must_use]
|
|
pub fn name(self) -> &'static str {
|
|
match self {
|
|
InfraContainer::Gateway => "hive-gateway",
|
|
}
|
|
}
|
|
|
|
/// The systemd unit a lifecycle verb acts on.
|
|
#[must_use]
|
|
pub fn service_unit(self) -> &'static str {
|
|
match self {
|
|
InfraContainer::Gateway => "nginx.service",
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::str::FromStr for InfraContainer {
|
|
type Err = ();
|
|
|
|
/// Parse an infra name (`hive-gateway`) into a variant. `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(())
|
|
}
|
|
}
|
|
|
|
/// Whether `rev` is a git commit id: 40 lowercase hex digits.
|
|
#[must_use]
|
|
pub fn valid_meta_rev(rev: &str) -> bool {
|
|
rev.len() == 40 && rev.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
|
|
}
|
|
|
|
/// Whether `path` is a store path in the shape nix prints one: `/nix/store/`,
|
|
/// 32 lowercase letters or digits, `-`, then a name of the characters store
|
|
/// names allow. The path crosses the wire in [`PrivRequest::RealiseClosure`],
|
|
/// [`PrivRequest::UpdateContainer`] and [`PrivRequest::CreateContainer`], so
|
|
/// `hive-priv` checks it before it hands it to nix.
|
|
#[must_use]
|
|
pub fn valid_closure_path(path: &str) -> bool {
|
|
let Some(rest) = path.strip_prefix("/nix/store/") else {
|
|
return false;
|
|
};
|
|
let Some((hash, name)) = rest.split_once('-') else {
|
|
return false;
|
|
};
|
|
hash.len() == 32
|
|
&& hash
|
|
.bytes()
|
|
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
|
|
&& !name.is_empty()
|
|
&& name.bytes().all(|b| {
|
|
b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.' | b'_' | b'?' | b'=')
|
|
})
|
|
}
|
|
|
|
/// What a failed [`PrivRequest::RealiseClosure`] says when nix refused a path
|
|
/// because no trusted key signed it, so a caller can tell that apart from a
|
|
/// cache that is down.
|
|
pub const SIGNATURE_REFUSED: &str = "no signature by the swarm cache's key";
|
|
|
|
/// 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 (tmpfs, not
|
|
/// persistent). hive-c0re creates each `<name>` subdir itself in the start
|
|
/// preamble; `hive-priv` only names it in the limits drop-in's
|
|
/// `ConditionPathIsDirectory=`.
|
|
/// 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";
|
|
|
|
/// 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`. hive-priv writes
|
|
/// `PRIVATE_NETWORK=1` + veth bridge wiring from these; every container
|
|
/// is isolated, so they are required rather than a mode selector.
|
|
/// No static address is pre-assigned here — see `docs/networking/network.md`
|
|
/// § *Container isolation* for how a container gets its address.
|
|
#[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 — see `docs/networking/network.md` § *How the isolated
|
|
/// container gets its resolver*.
|
|
pub gateway_ip: String,
|
|
}
|
|
|
|
/// Facts about the hive a container runs on, for `WriteNspawnFlags`.
|
|
/// hive-priv writes them into the container's `/etc/hyperhive-hive-facts`,
|
|
/// which the agent loads into its environment at boot, so no agent build
|
|
/// carries them. A `None` field leaves that variable unset in the agent.
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
pub struct HiveFacts {
|
|
/// `HYPERHIVE_HIVE_NAME`: the hive's name (`pr1ma`).
|
|
pub name: Option<String>,
|
|
/// `HYPERHIVE_HIVE_DOMAIN`: the hive's DNS domain.
|
|
pub domain: Option<String>,
|
|
/// `HIVE_DASHBOARD_PORT`: the port this hive's dashboard listens on.
|
|
pub dashboard_port: Option<u16>,
|
|
/// `HYPERHIVE_CLAUDE_CODE_PATH`: store path of the `claude-code` the
|
|
/// hive pins for its agents. The host's system closure keeps it alive;
|
|
/// the container only gets the path.
|
|
pub claude_code_path: Option<String>,
|
|
/// `HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES`: comma-separated `k=v`
|
|
/// entries the agent appends to its `OTEL_RESOURCE_ATTRIBUTES`.
|
|
pub otel_extra_resource_attributes: Option<String>,
|
|
/// `HIVE_AGENT_NATS_URL`: where the agent reaches the swarm queue, from
|
|
/// `deploy.hive-controller.queue.agentNatsUrl`.
|
|
pub agent_nats_url: Option<String>,
|
|
/// `HIVE_SQUID_PROXY`: `<bridge IP>:<port>` of this host's squid, which
|
|
/// checks each agent's proxy credential and applies the agent's
|
|
/// `unrestricted_internet` flag. Set only by a hive that carries that
|
|
/// policy; an agent sends nothing through a proxy without it.
|
|
pub squid_proxy: Option<String>,
|
|
/// `HIVE_EGRESS_NO_PROXY`: the comma-separated hosts an agent must reach
|
|
/// directly and never through the squid above.
|
|
pub egress_no_proxy: Option<String>,
|
|
}
|
|
|
|
/// Wall-clock bound hive-priv puts on one [`PrivRequest::MeasureAgentDisk`]
|
|
/// walk. A caller's own wait has to be longer than this, so a slow walk
|
|
/// reaches it as hive-priv's timeout error rather than as its own.
|
|
pub const MEASURE_AGENT_DISK_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(10);
|
|
|
|
/// 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 },
|
|
|
|
/// Point `name`'s container profile at `toplevel`, then reload the unit if
|
|
/// it runs. hive-priv never builds: `toplevel` must be the path the last
|
|
/// successful `RealiseClosure` for `name` returned, and it refuses any other.
|
|
/// 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,
|
|
/// The agent's system closure, checked by `valid_closure_path`.
|
|
toplevel: String,
|
|
},
|
|
|
|
/// `nixos-container create <name> --system-path <toplevel>`
|
|
/// `toplevel` is as for `UpdateContainer`.
|
|
/// 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,
|
|
toplevel: String,
|
|
},
|
|
|
|
/// `nix-store --realise <store_path>` from the swarm's binary cache, never
|
|
/// building: the closure the swarm built for `agent`, downloaded into the
|
|
/// store ahead of the update that applies it.
|
|
///
|
|
/// The request names no cache and no key: hive-priv takes the cache's
|
|
/// address from its own unit's environment and the key and the pull token
|
|
/// from files only root can write, and nix accepts the path only with a
|
|
/// signature by that key. `store_path` is checked by
|
|
/// [`valid_closure_path`]. A path nix could not substitute answers with an
|
|
/// error that starts `not available: `.
|
|
RealiseClosure { agent: String, store_path: String },
|
|
|
|
/// `nixos-container destroy <name>`
|
|
DestroyContainer { name: String },
|
|
|
|
/// `nixos-container list`
|
|
ListContainers,
|
|
|
|
// --- 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`.
|
|
/// Always writes `PRIVATE_NETWORK=1` + veth wiring: isolation is the only
|
|
/// mode, so there is no request shape that yields a container sharing the
|
|
/// host's network namespace.
|
|
///
|
|
/// Also decides whether the container runs on an ephemeral root. When it is
|
|
/// stopped and its root holds anything beyond caches, the root is copied to
|
|
/// the agent's `work/` and renamed aside first, then `--ephemeral` is added
|
|
/// to the flags; see `hive-priv/src/rootfs.rs`.
|
|
WriteNspawnFlags {
|
|
container: String,
|
|
binds: Vec<BindMount>,
|
|
/// Private netns with a veth on the given bridge
|
|
/// (`PRIVATE_NETWORK=1`). Required: isolation is the only
|
|
/// supported mode, so there is no value meaning "host netns".
|
|
/// Deliberately **not** `#[serde(default)]` — a request that
|
|
/// omits it is rejected rather than quietly configuring a
|
|
/// container that shares the host's network namespace.
|
|
isolation: 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>,
|
|
/// Written to the container's `/etc/hyperhive-hive-facts` on every
|
|
/// call, so a fact the hive no longer has is removed, not kept.
|
|
#[serde(default)]
|
|
hive_facts: HiveFacts,
|
|
},
|
|
|
|
/// Create the agent's socket dir `/run/hive-agent/<name>` as `0751
|
|
/// root:root` if it is missing; an existing directory is left as it is.
|
|
/// It is a bind source, so it has to exist before every container start,
|
|
/// and `/run` is a tmpfs. The container's own activation hands it to the
|
|
/// agent user. Called from `lifecycle::set_nspawn_flags`.
|
|
EnsureAgentSocketDir {
|
|
/// Logical agent name (validated by `validate_agent_name`).
|
|
name: String,
|
|
},
|
|
|
|
/// 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),
|
|
/// and an `ExecStartPre=` that runs `hive-priv check-rootfs <container>`
|
|
/// to refuse a start over a root with links where the start script
|
|
/// expects directories. The build that line names is rooted under
|
|
/// `/nix/var/nix/gcroots/hyperhive-check-rootfs/<container>`.
|
|
/// 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,
|
|
/// and the GC root of the hive-priv build its `ExecStartPre=` named.
|
|
/// 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,
|
|
|
|
// --- 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,
|
|
},
|
|
|
|
/// Start / stop a hive infrastructure service on the host via
|
|
/// `systemctl <action> <unit>`, the unit derived from the variant. The
|
|
/// [`InfraContainer`] enum is the allowlist — serde rejects unknown /
|
|
/// unsafe names (notably `hive-c0re`, the forge and the matrix, which
|
|
/// have no variant) at the wire boundary, so no root-side `.contains()`
|
|
/// check is needed. Serves the hive-wide `hivectl stop` / `start` /
|
|
/// `restart` flow. No agent-facing path exists.
|
|
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>,
|
|
},
|
|
|
|
// --- Agent disk usage ---
|
|
/// Measure an agent's on-disk footprint with one bounded
|
|
/// `du -s -x -b -c -P` over its `<AGENT_STATE_ROOT>/<agent_name>/state`
|
|
/// and `…/work` dirs and its container rootfs
|
|
/// (`/var/lib/nixos-containers/h-<agent_name>`). Returns the total in
|
|
/// bytes as a decimal string in `stdout`.
|
|
///
|
|
/// hive-priv derives all three paths from `agent_name`; the request
|
|
/// carries no path. A path that doesn't exist is skipped; an agent with
|
|
/// none of the three has no measurable size and gets an error. A timeout, a
|
|
/// non-zero `du` exit or unparseable output is an error, never a partial
|
|
/// or zero total. Requires root: `hive-core` cannot read the rootfs or
|
|
/// the agent-owned files under `state/` and `work/`.
|
|
MeasureAgentDisk {
|
|
/// Logical agent name (validated by `validate_agent_name`).
|
|
agent_name: String,
|
|
},
|
|
|
|
/// 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>,
|
|
},
|
|
|
|
/// Legacy: an older hive-c0re still sends this. hive-priv only unlinks
|
|
/// `/etc/tmpfiles.d/hyperhive-agents.conf` and returns `Ok`; the `agents`
|
|
/// payload that request carries is ignored on decode. Remove once no
|
|
/// deployed hive-c0re sends it, one release after `EnsureAgentSocketDir`.
|
|
SyncAgentTmpfiles,
|
|
}
|
|
|
|
/// 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, PrivRequest, valid_closure_path, valid_meta_rev};
|
|
|
|
#[test]
|
|
fn infra_control_names_no_c0re_or_swarm_service() {
|
|
// Neither the daemon nor the swarm's forge and matrix have a
|
|
// variant. Control: the gateway's name does parse.
|
|
for name in ["hive-c0re", "hive-forge", "hive-matrix"] {
|
|
assert!(name.parse::<InfraContainer>().is_err(), "{name}");
|
|
}
|
|
assert_eq!(
|
|
"hive-gateway".parse::<InfraContainer>(),
|
|
Ok(InfraContainer::Gateway)
|
|
);
|
|
}
|
|
|
|
#[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.
|
|
for c in InfraContainer::ALL {
|
|
assert_eq!(c.name().parse::<InfraContainer>(), Ok(c));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_resolves_to_host_nginx() {
|
|
assert_eq!(InfraContainer::Gateway.service_unit(), "nginx.service");
|
|
}
|
|
|
|
#[test]
|
|
fn only_a_full_lowercase_commit_id_is_a_meta_rev() {
|
|
assert!(valid_meta_rev(&"a1".repeat(20)));
|
|
for bad in [
|
|
"",
|
|
"main",
|
|
&"a".repeat(39),
|
|
&"a".repeat(41),
|
|
&"A".repeat(40),
|
|
&format!("{}?x", "a".repeat(38)),
|
|
&format!("{}#x", "a".repeat(39)),
|
|
&format!("{} ", "a".repeat(39)),
|
|
] {
|
|
assert!(!valid_meta_rev(bad), "{bad:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn only_a_store_path_is_a_closure_path() {
|
|
let hash = "0123456789abcdfghijklmnpqrsvwxyz";
|
|
for good in [
|
|
format!("/nix/store/{hash}-nixos-system-atlas-26.05.20261026.718f670"),
|
|
format!("/nix/store/{hash}-a"),
|
|
] {
|
|
assert!(valid_closure_path(&good), "{good}");
|
|
}
|
|
for bad in [
|
|
String::new(),
|
|
"/nix/store/".to_owned(),
|
|
format!("/nix/store/{hash}"),
|
|
format!("/nix/store/{hash}-"),
|
|
format!("/nix/store/{}-x", &hash[1..]),
|
|
format!("/nix/store/{hash}a-x"),
|
|
format!("/nix/store/{}-x", hash.to_uppercase()),
|
|
format!("/tmp/{hash}-x"),
|
|
format!("/nix/store/{hash}-x/bin/sh"),
|
|
format!("/nix/store/{hash}-../x"),
|
|
format!("/nix/store/{hash}-x y"),
|
|
format!("/nix/store/{hash}-x\n"),
|
|
format!("/nix/store/../../etc/{hash}-x"),
|
|
format!("/nix/store/{hash}-x;id"),
|
|
] {
|
|
assert!(!valid_closure_path(&bad), "{bad:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_closure_request_names_an_agent_and_a_path() {
|
|
let line = r#"{"op":"realise_closure","agent":"atlas","store_path":"/nix/store/x"}"#;
|
|
assert!(matches!(
|
|
serde_json::from_str::<PrivRequest>(line),
|
|
Ok(PrivRequest::RealiseClosure { .. })
|
|
));
|
|
let without = r#"{"op":"realise_closure","agent":"atlas"}"#;
|
|
assert!(serde_json::from_str::<PrivRequest>(without).is_err());
|
|
}
|
|
|
|
/// `toplevel` is required: hive-priv has no flake to build one from.
|
|
#[test]
|
|
fn a_request_without_a_toplevel_is_refused() {
|
|
for op in ["update_container", "create_container"] {
|
|
let without = format!(r#"{{"op":"{op}","name":"atlas"}}"#);
|
|
assert!(
|
|
serde_json::from_str::<PrivRequest>(&without).is_err(),
|
|
"{op}"
|
|
);
|
|
let with = format!(
|
|
r#"{{"op":"{op}","name":"atlas","toplevel":"/nix/store/{}-nixos-system-atlas"}}"#,
|
|
"b".repeat(32)
|
|
);
|
|
assert!(serde_json::from_str::<PrivRequest>(&with).is_ok(), "{op}");
|
|
}
|
|
}
|
|
}
|