The three values that differ per host (deploy.hive-controller.dashboardPort, claudeCodePackage and otel.extraResourceAttributes) no longer go into the meta flake. hive-priv writes them into the container's /etc/hyperhive-hive-facts with the hive name and domain, and the agent reads them at boot: - HIVE_DASHBOARD_PORT reaches every unit through the hive-facts generator; the web UI still defaults to 7000 without it. - `claude` on PATH is a wrapper that runs HYPERHIVE_CLAUDE_CODE_PATH's claude when it is there, and the container's own claude-code otherwise. The host's serve.json keeps gc-rooting the pinned build. - OTEL_RESOURCE_ATTRIBUTES moves out of the units' Environment= into a boot-time generator, which appends HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES. The agent options services.hyperhive.agent.claudeCodePath (now a removed option) and otel.extraResourceAttributes go; only the meta render set them. render_flake takes no HiveEnv any more, so the memory-cap render test and render_hive_flake_at, which only existed to vary HiveEnv into the render, go with it. Refs #4857
685 lines
31 KiB
Rust
685 lines
31 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(())
|
|
}
|
|
}
|
|
|
|
/// 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 (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>,
|
|
}
|
|
|
|
/// 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 },
|
|
|
|
/// `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,
|
|
|
|
// --- 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.
|
|
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).
|
|
/// 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,
|
|
},
|
|
|
|
/// 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;
|
|
|
|
#[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");
|
|
}
|
|
}
|