Watch
0
0
Fork
You've already forked hyperhive
0

hive: stop controlling the swarm's forge and matrix containers

The forge and matrix are swarm services; per mara's ruling on #5475
(shape a) a hive no longer starts, stops or restarts them.

- hive-priv-sock: InfraContainer keeps only Gateway; InfraTarget goes,
  service_unit maps the gateway to nginx.service directly. A
  ControlInfraContainer naming Forge/Matrix fails to decode.
- hive-host-sock: LifecycleScope drops forge/matrix and refuses unknown
  fields, so an older hivectl's --forge is an error rather than an
  all-false scope meaning everything.
- hive-c0re: scoped_infra selects only the gateway.
- hivectl: --forge/--matrix removed from stop/start/restart.
- docs: services.md says how the forge and matrix are managed;
  security.md, coordinator.md and hivectl-cli.md follow.
- hive-host-sock, hivectl/power.rs: Stop and RestartScoped doc comments name the gateway (host nginx.service), not infra containers.
This commit is contained in:
atlas 2026-10-17 22:52:18 +02:00 • committed by mara
commit d4f3d1ef8e
12 changed files with 177 additions and 203 deletions

View file

@ -178,7 +178,7 @@ Power toggles never commit to the meta repo. Every operator power surface —
dashboard buttons, the MCP tools, and `hivectl stop/start/restart/kill` — dashboard buttons, the MCP tools, and `hivectl stop/start/restart/kill` —
rides the queue through that power layer, so intent, lease serialization, rides the queue through that power layer, so intent, lease serialization,
and crash-watch suppression can't drift per surface; the only direct starts and crash-watch suppression can't drift per surface; the only direct starts
left are infra containers (no lease, no left are the gateway's (no lease, no
harness). Cancelling a still-queued power DAG reverts `wanted` to the harness). Cancelling a still-queued power DAG reverts `wanted` to the
observed state — a cancel means "don't do it," not "do it later." hive-c0re observed state — a cancel means "don't do it," not "do it later." hive-c0re
seeds agents without a row from observed state on first touch (running ⇒ seeds agents without a row from observed state on first touch (running ⇒

View file

@ -44,6 +44,13 @@ config repo, so **exactly one host must turn it on**: `singleHostSwarm`,
host that enables its services one by one without any of those runs no host that enables its services one by one without any of those runs no
forge. forge.
A hive can't start or stop any of these. `hivectl stop`, `start` and
`restart` act on the hive's agents and its gateway only, even on the
host that runs the swarm's services. The forge and matrix containers are
systemd units on that host (`container@hive-forge.service`,
`container@hive-matrix.service`), and the operator manages them there
with `systemctl`.
A hive that runs none of these reaches each one by name — the forge at A hive that runs none of these reaches each one by name — the forge at
`forge.<swarm-domain>`, for example. Only the host running a service `forge.<swarm-domain>`, for example. Only the host running a service
answers its name from its own resolver, so on a swarm spread over answers its name from its own resolver, so on a swarm spread over

View file

@ -514,7 +514,7 @@ Prints the nix a peer operator pastes into their swarm config to trust and reach
Stop containers hive-wide in one operator action. Stop containers hive-wide in one operator action.
Bare `hivectl stop` stops everything; scope flags narrow it to specific sub-agents or infra containers. Bare `hivectl stop` stops every agent and the gateway; scope flags narrow it to specific sub-agents or the gateway. The swarm's forge and matrix are never in scope: systemd on the services host runs them.
**Usage:** `hivectl stop [OPTIONS]` **Usage:** `hivectl stop [OPTIONS]`
@ -522,9 +522,7 @@ Bare `hivectl stop` stops everything; scope flags narrow it to specific sub-agen
* `--agents` — All sub-agent containers * `--agents` — All sub-agent containers
* `--agent <NAME>` — A specific sub-agent by name. Repeatable: `--agent a --agent b` * `--agent <NAME>` — A specific sub-agent by name. Repeatable: `--agent a --agent b`
* `--forge` — The forge container (`hive-forge`)
* `--gateway` — The gateway (`hive-gateway`) — nginx on the host, not a container * `--gateway` — The gateway (`hive-gateway`) — nginx on the host, not a container
* `--matrix` — The matrix container (`hive-matrix`)
* `--graceful` — Gracefully quiesce each agent before stopping, instead of a hard stop. Each agent gets a graceful-stop DAG on the job queue: it signals the harness, which runs one stop-checkpoint turn to flush durable `/state` and drains, then the DAG stops the container (bounded by a 3-min timeout that falls back to a hard stop). All drains overlap. Applies to agents only * `--graceful` — Gracefully quiesce each agent before stopping, instead of a hard stop. Each agent gets a graceful-stop DAG on the job queue: it signals the harness, which runs one stop-checkpoint turn to flush durable `/state` and drains, then the DAG stops the container (bounded by a 3-min timeout that falls back to a hard stop). All drains overlap. Applies to agents only
* `--no-wait` — Return immediately after hivectl queues the stop DAGs instead of waiting for them with live per-node progress * `--no-wait` — Return immediately after hivectl queues the stop DAGs instead of waiting for them with live per-node progress
@ -534,7 +532,7 @@ Bare `hivectl stop` stops everything; scope flags narrow it to specific sub-agen
Start containers hive-wide — the inverse of `hivectl stop`. Start containers hive-wide — the inverse of `hivectl stop`.
Bare `hivectl start` restores the agents stopped by the last broad-scope `stop` (or starts everything if none); scope flags narrow it. Bare `hivectl start` restores the agents stopped by the last broad-scope `stop` (or starts everything if none); scope flags narrow it. The swarm's forge and matrix are never in scope.
**Usage:** `hivectl start [OPTIONS]` **Usage:** `hivectl start [OPTIONS]`
@ -542,9 +540,7 @@ Bare `hivectl start` restores the agents stopped by the last broad-scope `stop`
* `--agents` — All sub-agent containers * `--agents` — All sub-agent containers
* `--agent <NAME>` — A specific sub-agent by name. Repeatable: `--agent a --agent b` * `--agent <NAME>` — A specific sub-agent by name. Repeatable: `--agent a --agent b`
* `--forge` — The forge container (`hive-forge`)
* `--gateway` — The gateway (`hive-gateway`) — nginx on the host, not a container * `--gateway` — The gateway (`hive-gateway`) — nginx on the host, not a container
* `--matrix` — The matrix container (`hive-matrix`)
* `--no-wait` — Return immediately after hivectl queues the start DAGs instead of waiting for them with live per-node progress * `--no-wait` — Return immediately after hivectl queues the start DAGs instead of waiting for them with live per-node progress
@ -553,7 +549,7 @@ Bare `hivectl start` restores the agents stopped by the last broad-scope `stop`
Restart containers hive-wide — `stop` then `start` over one scope. Restart containers hive-wide — `stop` then `start` over one scope.
Bare `hivectl restart` restarts everything; scope flags narrow it. Bare `hivectl restart` restarts everything; scope flags narrow it. The swarm's forge and matrix are never in scope.
**Usage:** `hivectl restart [OPTIONS]` **Usage:** `hivectl restart [OPTIONS]`
@ -561,9 +557,7 @@ Bare `hivectl restart` restarts everything; scope flags narrow it.
* `--agents` — All sub-agent containers * `--agents` — All sub-agent containers
* `--agent <NAME>` — A specific sub-agent by name. Repeatable: `--agent a --agent b` * `--agent <NAME>` — A specific sub-agent by name. Repeatable: `--agent a --agent b`
* `--forge` — The forge container (`hive-forge`)
* `--gateway` — The gateway (`hive-gateway`) — nginx on the host, not a container * `--gateway` — The gateway (`hive-gateway`) — nginx on the host, not a container
* `--matrix` — The matrix container (`hive-matrix`)
* `--graceful` — Gracefully quiesce each agent on the stop half (see `stop --graceful`). Applies to agents only * `--graceful` — Gracefully quiesce each agent on the stop half (see `stop --graceful`). Applies to agents only

View file

@ -297,7 +297,7 @@ known operations; there is no arbitrary command pass-through:
| `RemoveServiceDropin` | remove `container@<name>.service.d/` drop-in on destroy | | `RemoveServiceDropin` | remove `container@<name>.service.d/` drop-in on destroy |
| `DaemonReload` | `systemctl daemon-reload` | | `DaemonReload` | `systemctl daemon-reload` |
| `RunForgeAdmin` | `nixos-container run hive-forge -- runuser -u forgejo -- forgejo admin <args>` | | `RunForgeAdmin` | `nixos-container run hive-forge -- runuser -u forgejo -- forgejo admin <args>` |
| `ControlInfraContainer` | `systemctl <action> container@<container>.service` — the `InfraContainer` enum is the allowlist, and serde rejects unknown names at the wire boundary (`hive-c0re` has no variant, so no request can name it) | | `ControlInfraContainer` | `systemctl <action> nginx.service` for the gateway — the `InfraContainer` enum is the allowlist, and serde rejects unknown names at the wire boundary (`hive-c0re`, the forge and the matrix have no variant, so no request can name them) |
| `SyncAgentTmpfiles` | unlink `/etc/tmpfiles.d/hyperhive-agents.conf` and return `Ok` | | `SyncAgentTmpfiles` | unlink `/etc/tmpfiles.d/hyperhive-agents.conf` and return `Ok` |
| `SetAgentPaused` | create / remove the `<state>/<name>/harness/paused` marker that parks an agent's turn loop | | `SetAgentPaused` | create / remove the `<state>/<name>/harness/paused` marker that parks an agent's turn loop |
| `EnsureAgentSubvolume` | `btrfs subvolume create <state>/<name>` for a new agent — no-op when the path exists or the filesystem isn't btrfs | | `EnsureAgentSubvolume` | `btrfs subvolume create <state>/<name>` for a new agent — no-op when the path exists or the filesystem isn't btrfs |

View file

@ -363,13 +363,12 @@ pub async fn set_agent_paused(agent_name: &str, paused: bool) -> Result<()> {
.await?) .await?)
} }
/// Start / stop a hive infrastructure service (`hive-gateway`, `hive-forge`, /// Start / stop a hive infrastructure service (`hive-gateway`) on the host
/// `hive-matrix`) on the host via `systemctl <action> <unit>`, /// via `systemctl <action> <unit>`, where the unit is derived root-side from
/// where the unit is derived root-side from the variant /// the variant (`nginx.service` for the gateway). The [`InfraContainer`]
/// (`container@<name>.service`, or `nginx.service` for the gateway). The /// enum is the allowlist — hive-priv needs no name re-validation. Used by
/// [`InfraContainer`] enum is the allowlist — hive-priv needs no name /// the hive-wide `hivectl stop` / `start` / `restart` flow. No agent-facing
/// re-validation. Used by the hive-wide `hivectl stop` / `start` flow and /// path exists.
/// the dashboard's operator-only infra panel. No agent-facing path exists.
pub async fn control_infra_container(container: InfraContainer, action: InfraAction) -> Result<()> { pub async fn control_infra_container(container: InfraContainer, action: InfraAction) -> Result<()> {
ok(call(&PrivRequest::ControlInfraContainer { container, action }).await?) ok(call(&PrivRequest::ControlInfraContainer { container, action }).await?)
} }

View file

@ -570,9 +570,9 @@ async fn submit_single(coord: &Arc<Coordinator>, name: &str, verb: Verb) -> Host
} }
} }
/// Stop the given `agents` (resolved logical names) then `infra` containers /// Stop the given `agents` (resolved logical names) then `infra` services
/// (`hivectl stop`). Agents go down before infra so they're not mid-request /// (`hivectl stop`). Agents go down before infra so they're not mid-request
/// against a forge/matrix that's already gone. Per-target failures are /// through a gateway that's already gone. Per-target failures are
/// aggregated rather than aborting on the first error, mirroring /// aggregated rather than aborting on the first error, mirroring
/// `finish_lifecycle` below. Callers resolve the [`LifecycleScope`] to these /// `finish_lifecycle` below. Callers resolve the [`LifecycleScope`] to these
/// explicit name lists up front — this never sees the "all" flag. /// explicit name lists up front — this never sees the "all" flag.
@ -582,7 +582,7 @@ async fn submit_single(coord: &Arc<Coordinator>, name: &str, verb: Verb) -> Host
/// a hard stop a plain stop DAG — both persist `wanted = Offline` and /// a hard stop a plain stop DAG — both persist `wanted = Offline` and
/// serialize on the agent's lease so nothing races an in-flight /// serialize on the agent's lease so nothing races an in-flight
/// rebuild. The response carries the DAG ids so `hivectl` can wait /// rebuild. The response carries the DAG ids so `hivectl` can wait
/// with per-node progress. Infra containers have no harness / lease /// with per-node progress. Infra services have no harness / lease
/// and stay direct + synchronous. /// and stay direct + synchronous.
async fn handle_stop( async fn handle_stop(
coord: &Arc<Coordinator>, coord: &Arc<Coordinator>,
@ -608,8 +608,8 @@ async fn handle_stop(
} }
} }
// Agents go down before infra so they're not mid-request against a // Agents go down before infra so they're not mid-request through a
// forge/matrix that's already gone. Hard stops are quick kills — // gateway that's already gone. Hard stops are quick kills —
// await their DAGs (bounded) before touching infra. Graceful stops // await their DAGs (bounded) before touching infra. Graceful stops
// keep the immediate return (drains take minutes and the // keep the immediate return (drains take minutes and the
// agents-then-infra race pre-existed there). // agents-then-infra race pre-existed there).
@ -659,9 +659,9 @@ async fn await_dags(coord: &Arc<Coordinator>, ids: &[u64], timeout: std::time::D
} }
} }
/// Start the given `infra` containers then `agents` (`hivectl start`) — the /// Start the given `infra` services then `agents` (`hivectl start`) — the
/// inverse of [`handle_stop`]. Infra comes up before agents so the agents /// inverse of [`handle_stop`]. Infra comes up before agents so the agents
/// find forge/matrix/gateway ready. Per-target failures aggregated. Callers /// find the gateway ready. Per-target failures aggregated. Callers
/// resolve the [`LifecycleScope`] to these explicit name lists up front. /// resolve the [`LifecycleScope`] to these explicit name lists up front.
async fn handle_start( async fn handle_start(
coord: &Arc<Coordinator>, coord: &Arc<Coordinator>,
@ -713,7 +713,7 @@ async fn handle_start(
/// concurrently on their own leases), not N separate DAGs — a hive-wide /// concurrently on their own leases), not N separate DAGs — a hive-wide
/// restart is one job. `graceful` prepends signal→drain per agent. No /// restart is one job. `graceful` prepends signal→drain per agent. No
/// client-side stop-then-start composition, so a dropped `hivectl` /// client-side stop-then-start composition, so a dropped `hivectl`
/// connection never strands an agent. Infra containers have no lease/DAG /// connection never strands an agent. Infra services have no lease/DAG
/// and restart synchronously (stop then start). /// and restart synchronously (stop then start).
async fn handle_restart_scoped( async fn handle_restart_scoped(
coord: &Arc<Coordinator>, coord: &Arc<Coordinator>,
@ -814,22 +814,15 @@ async fn scoped_agents(scope: &LifecycleScope) -> Result<Vec<String>> {
Ok(set.into_iter().collect()) Ok(set.into_iter().collect())
} }
/// Resolve which infra containers a scope targets. An "everything" scope /// Resolve which infra services a scope targets: the gateway, when the
/// (no flags set) selects all controllable infra; otherwise each set flag /// scope is "everything" (no flags set) or names it. The swarm's forge and
/// maps to its [`InfraContainer`]. Fixed order for deterministic output. /// matrix are never a target.
fn scoped_infra(scope: &LifecycleScope) -> Vec<InfraContainer> { fn scoped_infra(scope: &LifecycleScope) -> Vec<InfraContainer> {
let everything = scope.is_everything(); if scope.is_everything() || scope.gateway {
let mut out = Vec::new(); vec![InfraContainer::Gateway]
if everything || scope.forge { } else {
out.push(InfraContainer::Forge); Vec::new()
} }
if everything || scope.gateway {
out.push(InfraContainer::Gateway);
}
if everything || scope.matrix {
out.push(InfraContainer::Matrix);
}
out
} }
/// Build the aggregated lifecycle response: `ok` with the touched names when /// Build the aggregated lifecycle response: `ok` with the touched names when
@ -916,4 +909,42 @@ mod tests {
assert!(message.starts_with("request timed out"), "{message}"); assert!(message.starts_with("request timed out"), "{message}");
handler.await.expect("join").expect("clean close"); handler.await.expect("join").expect("clean close");
} }
/// A bare `hivectl stop` / `start` / `restart` touches the gateway and no
/// swarm service. Control: an agents-only scope touches no infra.
#[test]
fn everything_scope_selects_only_the_gateway() {
assert_eq!(
scoped_infra(&LifecycleScope::default()),
[InfraContainer::Gateway]
);
let gateway = LifecycleScope {
gateway: true,
..LifecycleScope::default()
};
assert_eq!(scoped_infra(&gateway), [InfraContainer::Gateway]);
let agents = LifecycleScope {
agents: true,
..LifecycleScope::default()
};
assert!(scoped_infra(&agents).is_empty());
}
/// A stop from an older `hivectl stop --forge` is refused rather than
/// read as an all-false scope, which would stop everything. Control: the
/// same request with `gateway` decodes.
#[test]
fn stop_naming_forge_or_matrix_is_refused() {
let req = |field: &str| {
format!(r#"{{"cmd":"stop","scope":{{"{field}":true}},"graceful":false}}"#)
};
for field in ["forge", "matrix"] {
let err = serde_json::from_str::<HostRequest>(&req(field)).unwrap_err();
assert!(err.to_string().contains("unknown field"), "{field}: {err}");
}
assert!(matches!(
serde_json::from_str::<HostRequest>(&req("gateway")),
Ok(HostRequest::Stop { scope, .. }) if scope.gateway && !scope.is_everything()
));
}
} }

View file

@ -136,9 +136,9 @@ pub enum HostRequest {
/// follow-up, since nothing durable remembered "finish the restart" /// follow-up, since nothing durable remembered "finish the restart"
/// once the calling process/turn was gone. `GracefulRestart` closes /// once the calling process/turn was gone. `GracefulRestart` closes
/// that gap the same way `Restart` already does — one DAG, queued up /// that gap the same way `Restart` already does — one DAG, queued up
/// front, that owns the whole sequence. Infra containers have no /// front, that owns the whole sequence. The gateway (host
/// lease/DAG and restart synchronously (stop then start), same as /// `nginx.service`) has no lease/DAG and restarts synchronously
/// before. Scope semantics match `Stop`/`Start` (all-false = /// (stop then start). Scope semantics match `Stop`/`Start` (all-false =
/// everything). /// everything).
RestartScoped { RestartScoped {
#[serde(default)] #[serde(default)]
@ -204,12 +204,12 @@ pub enum HostRequest {
/// [`HostResponse::nodes`]. /// [`HostResponse::nodes`].
QueueNodes { ids: Vec<u64> }, QueueNodes { ids: Vec<u64> },
/// Stop managed containers hive-wide in one operator action /// Stop managed containers hive-wide in one operator action
/// (`hivectl stop`): agents plus the selected infra containers. `scope` /// (`hivectl stop`): agents plus the gateway when selected. `scope`
/// selects which classes; an all-false scope means **everything** (the /// selects which classes; an all-false scope means **everything** (the
/// bare `hivectl stop`). `graceful` runs the per-agent quiesce (graceful /// bare `hivectl stop`). `graceful` runs the per-agent quiesce (graceful
/// agent stop, issue tracker `graceful agent stop`) instead of a hard /// agent stop, issue tracker `graceful agent stop`) instead of a hard
/// stop. Agents stop via the lifecycle path; infra containers via the /// stop. Agents stop via the lifecycle path; the gateway via the host
/// host `container@<name>` units. /// `nginx.service` unit.
Stop { Stop {
#[serde(default)] #[serde(default)]
scope: LifecycleScope, scope: LifecycleScope,
@ -346,17 +346,15 @@ pub struct QuotaRow {
/// [`HostRequest::Start`] touches. An all-false scope means **everything** /// [`HostRequest::Start`] touches. An all-false scope means **everything**
/// (the bare `hivectl stop` / `start`); set individual fields to restrict /// (the bare `hivectl stop` / `start`); set individual fields to restrict
/// (e.g. only `agents` → just the sub-agent containers). `agents` covers /// (e.g. only `agents` → just the sub-agent containers). `agents` covers
/// every managed sub-agent container; the rest are the named infra /// every managed sub-agent container; `gateway` is the host's nginx unit.
/// services (`hive-forge`, `hive-gateway`, `hive-matrix`) — /// The swarm's forge and matrix are not in any scope: a hive does not start
/// containers, bar the gateway, which is the host's nginx unit. /// or stop them.
// ///
// A flat bag of independent flag toggles — one bool per selectable /// Unknown fields are refused, so a `forge` / `matrix` flag from an older
// container class — is exactly the right shape here: each maps 1:1 to a /// `hivectl` is an error rather than dropped — dropping it would leave an
// `hivectl` `--forge` / `--gateway` / `--matrix` flag, and they're /// all-false scope, which means everything.
// orthogonal (any subset is valid), so a state machine or two-variant enums
// would only obscure the mapping. Hence the `struct_excessive_bools` allow.
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LifecycleScope { pub struct LifecycleScope {
/// All sub-agent containers (`--agents`). /// All sub-agent containers (`--agents`).
#[serde(default)] #[serde(default)]
@ -367,11 +365,7 @@ pub struct LifecycleScope {
#[serde(default)] #[serde(default)]
pub agent_names: Vec<String>, pub agent_names: Vec<String>,
#[serde(default)] #[serde(default)]
pub forge: bool,
#[serde(default)]
pub gateway: bool, pub gateway: bool,
#[serde(default)]
pub matrix: bool,
} }
impl LifecycleScope { impl LifecycleScope {
@ -379,7 +373,7 @@ impl LifecycleScope {
/// classes" (the bare `hivectl stop` / `start` with no scope flags). /// classes" (the bare `hivectl stop` / `start` with no scope flags).
#[must_use] #[must_use]
pub fn is_everything(&self) -> bool { pub fn is_everything(&self) -> bool {
!(self.agents || self.forge || self.gateway || self.matrix) && self.agent_names.is_empty() !(self.agents || self.gateway) && self.agent_names.is_empty()
} }
} }

View file

@ -38,15 +38,14 @@ pub const AGENT_PREFIX: &str = "h-";
/// ///
/// ⚠️ This is the *container-name* allowlist, not the lifecycle one: /// ⚠️ This is the *container-name* allowlist, not the lifecycle one:
/// [`InfraContainer`] is what gates /// [`InfraContainer`] is what gates
/// [`PrivRequest::ControlInfraContainer`], and it has one variant more than /// [`PrivRequest::ControlInfraContainer`], and no name in this list has a
/// this list (the gateway). Keep the distinction — a name that belongs to /// variant there: the forge and matrix are swarm services, started and
/// no container has no business reaching a `nixos-container` call. /// stopped by systemd on the host that runs them, never from a hive.
pub const SIBLING_CONTAINERS: &[&str] = &["hive-forge", "hive-matrix"]; pub const SIBLING_CONTAINERS: &[&str] = &["hive-forge", "hive-matrix"];
/// Lifecycle verb for [`PrivRequest::ControlInfraContainer`]. Maps directly /// Lifecycle verb for [`PrivRequest::ControlInfraContainer`]. Maps directly
/// to `systemctl <verb> <unit>`, where the unit comes from /// to `systemctl <verb> <unit>`, where the unit comes from
/// [`InfraContainer::service_unit`] — usually `container@<name>.service`, /// [`InfraContainer::service_unit`].
/// but not always (see [`InfraTarget`]).
#[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum InfraAction { pub enum InfraAction {
@ -65,79 +64,44 @@ impl InfraAction {
} }
} }
/// A hive infrastructure container that can be controlled (start / stop / /// A hive infrastructure service that can be controlled (start / stop /
/// restart) via [`PrivRequest::ControlInfraContainer`]. The variants ARE /// restart) via [`PrivRequest::ControlInfraContainer`]. The variants ARE
/// the allowlist: serde rejects any other value at the wire boundary, so an /// 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 /// unknown or unsafe target is *unrepresentable* rather than caught by a
/// would sever the daemon socket — is *unrepresentable* rather than caught /// runtime check. Notably absent: `hive-c0re`, whose stop would sever the
/// by a runtime check. The c0re↔hive-priv wire form uses serde's default /// daemon socket, and the swarm's forge and matrix, which a hive does not
/// variant naming (`"Forge"`, `"Gateway"`, …); it's an internal protocol (both /// own. The c0re↔hive-priv wire form uses serde's default variant naming
/// ends rebuild together) so it needn't match the container name. /// (`"Gateway"`); it's an internal protocol (both ends rebuild together)
/// [`name`](Self::name) is the separate stable identity string /// so it needn't match the service name. [`name`](Self::name) is the
/// (`hive-forge`), and [`service_unit`](Self::service_unit) the systemd unit /// separate stable identity string (`hive-gateway`), and
/// it actually resolves to. /// [`service_unit`](Self::service_unit) the systemd unit it resolves to.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum InfraContainer { pub enum InfraContainer {
Forge,
Gateway, 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`.
Container(&'static str),
/// A plain host unit. There is no machine to enter: no
/// `nixos-container` verb.
HostUnit(&'static str),
} }
impl InfraContainer { impl InfraContainer {
/// Every controllable infra target. A superset of /// Every controllable infra target.
/// [`SIBLING_CONTAINERS`] — not all of these are containers (see the pub const ALL: [InfraContainer; 1] = [InfraContainer::Gateway];
/// test).
pub const ALL: [InfraContainer; 3] = [
InfraContainer::Forge,
InfraContainer::Gateway,
InfraContainer::Matrix,
];
/// Stable identity string, e.g. `hive-forge`. This is what the operator /// Stable identity string, e.g. `hive-gateway`. This is what the
/// types, what the dashboard displays, and what [`FromStr`](std::str::FromStr) /// operator types, what the dashboard displays, and what
/// parses — it stays `hive-gateway` even though the gateway is no /// [`FromStr`](std::str::FromStr) parses. It names the *service*, not
/// longer a container, because it names the *service*, not its /// its implementation: the gateway is the host's nginx, not a
/// implementation. (Distinct from the serde wire form, which is the /// container. (Distinct from the serde wire form, which is the default
/// default variant name `"Forge"`.) /// variant name `"Gateway"`.)
#[must_use] #[must_use]
pub fn name(self) -> &'static str { pub fn name(self) -> &'static str {
match self { match self {
InfraContainer::Forge => "hive-forge",
InfraContainer::Gateway => "hive-gateway", 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. /// The systemd unit a lifecycle verb acts on.
#[must_use] #[must_use]
pub fn service_unit(self) -> String { pub fn service_unit(self) -> &'static str {
match self.target() { match self {
InfraTarget::Container(name) => format!("container@{name}.service"), InfraContainer::Gateway => "nginx.service",
InfraTarget::HostUnit(unit) => unit.to_owned(),
} }
} }
} }
@ -145,7 +109,7 @@ impl InfraContainer {
impl std::str::FromStr for InfraContainer { impl std::str::FromStr for InfraContainer {
type Err = (); type Err = ();
/// Parse an infra name (`hive-forge`, …) into a variant. `Err(())` for /// Parse an infra name (`hive-gateway`) into a variant. `Err(())` for
/// anything that isn't one. /// anything that isn't one.
fn from_str(s: &str) -> Result<Self, ()> { fn from_str(s: &str) -> Result<Self, ()> {
Self::ALL.into_iter().find(|c| c.name() == s).ok_or(()) Self::ALL.into_iter().find(|c| c.name() == s).ok_or(())
@ -415,13 +379,13 @@ pub enum PrivRequest {
paused: bool, paused: bool,
}, },
/// Start / stop a hive infrastructure container on the host via /// Start / stop a hive infrastructure service on the host via
/// `systemctl <action> container@<container>.service`. The /// `systemctl <action> <unit>`, the unit derived from the variant. The
/// [`InfraContainer`] enum is the allowlist — serde rejects unknown / /// [`InfraContainer`] enum is the allowlist — serde rejects unknown /
/// unsafe names (notably `hive-c0re`, which has no variant) at the wire /// unsafe names (notably `hive-c0re`, the forge and the matrix, which
/// boundary, so no root-side `.contains()` check is needed. Serves the /// have no variant) at the wire boundary, so no root-side `.contains()`
/// hive-wide `hivectl stop` / `hivectl start` flow and the dashboard's /// check is needed. Serves the hive-wide `hivectl stop` / `start` /
/// operator-only infra panel. No agent-facing path exists. /// `restart` flow. No agent-facing path exists.
ControlInfraContainer { ControlInfraContainer {
container: InfraContainer, container: InfraContainer,
action: InfraAction, action: InfraAction,
@ -695,65 +659,43 @@ pub enum PrivEvent {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{InfraContainer, InfraTarget, SIBLING_CONTAINERS}; use super::{InfraContainer, SIBLING_CONTAINERS};
#[test] #[test]
fn infra_control_allowlist_excludes_c0re_includes_matrix() { fn sibling_container_allowlist_excludes_c0re() {
// SIBLING_CONTAINERS is the authoritative allowlist for the // SIBLING_CONTAINERS is the authoritative allowlist for the
// requests that name a container as a string. hive-c0re must NEVER // requests that name a container as a string. hive-c0re must NEVER
// be in it — stopping the daemon would sever the socket the request // be in it — stopping the daemon would sever the socket the request
// arrived on. // arrived on.
assert!(!SIBLING_CONTAINERS.contains(&"hive-c0re")); assert!(!SIBLING_CONTAINERS.contains(&"hive-c0re"));
// hive-matrix IS controllable (operator can stop/start/restart it).
assert!(SIBLING_CONTAINERS.contains(&"hive-matrix")); assert!(SIBLING_CONTAINERS.contains(&"hive-matrix"));
} }
#[test] #[test]
fn infra_container_enum_matches_sibling_containers() { fn infra_control_names_no_c0re_or_swarm_service() {
// The two lists are the same set *minus the things that aren't // Neither the daemon nor the swarm's forge and matrix have a
// containers*: every `Container` variant must appear in // variant. Control: the gateway's name does parse.
// SIBLING_CONTAINERS and vice versa, so a `nixos-container` verb can for name in ["hive-c0re", "hive-forge", "hive-matrix"] {
// never be pointed at a name that has no machine behind it. assert!(name.parse::<InfraContainer>().is_err(), "{name}");
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!( assert_eq!(
InfraContainer::Gateway.target(), "hive-gateway".parse::<InfraContainer>(),
InfraTarget::HostUnit("nginx.service") Ok(InfraContainer::Gateway)
); );
assert!(!SIBLING_CONTAINERS.contains(&"hive-gateway"));
// hive-c0re has no variant — unrepresentable, can't be controlled.
assert!("hive-c0re".parse::<InfraContainer>().is_err());
} }
#[test] #[test]
fn infra_container_name_round_trips() { fn infra_container_name_round_trips() {
// `name` is the single source of truth for the operator-facing form // `name` is the single source of truth for the operator-facing form
// (FromStr keys off it), so a name→variant→name round-trip proves // (FromStr keys off it), so a name→variant→name round-trip proves
// the mapping is consistent in both directions. It holds for the // the mapping is consistent in both directions.
// gateway too: the name is still recognised, it's the *permission*
// that differs (see below), not the parse.
for c in InfraContainer::ALL { for c in InfraContainer::ALL {
assert_eq!(c.name().parse::<InfraContainer>(), Ok(c)); assert_eq!(c.name().parse::<InfraContainer>(), Ok(c));
} }
} }
#[test] #[test]
fn service_unit_wraps_containers_but_not_host_units() { fn gateway_resolves_to_host_nginx() {
assert_eq!(
InfraContainer::Forge.service_unit(),
"container@hive-forge.service"
);
assert_eq!(InfraContainer::Gateway.service_unit(), "nginx.service"); assert_eq!(InfraContainer::Gateway.service_unit(), "nginx.service");
} }
} }

View file

@ -1172,16 +1172,13 @@ async fn daemon_reload() -> Result<(String, String)> {
/// `ControlInfraContainer` — start/stop a hive infrastructure service via /// `ControlInfraContainer` — start/stop a hive infrastructure service via
/// `systemctl <verb> <unit>`. The [`InfraContainer`] enum is the allowlist: /// `systemctl <verb> <unit>`. The [`InfraContainer`] enum is the allowlist:
/// serde already rejected any unknown / unsafe name (hive-c0re has no /// serde already rejected any unknown / unsafe name (hive-c0re, the forge
/// variant, so a stop can't sever the daemon socket) at deserialisation, so /// and the matrix have no variant) at deserialisation, so no root-side
/// no root-side `.contains()` check is needed here. Serves the hive-wide /// `.contains()` check is needed here. Serves the hive-wide
/// `hivectl stop`/`start` flow and the dashboard's operator-only infra /// `hivectl stop`/`start`/`restart` flow. No agent-facing path exists.
/// panel. No agent-facing path exists.
/// ///
/// ⚠️ The unit is derived from the variant, never sent by the caller — /// ⚠️ The unit is derived from the variant, never sent by the caller —
/// which is what keeps this from being a general `systemctl` pass-through. /// which is what keeps this from being a general `systemctl` pass-through.
/// It is not always `container@<name>.service`: the gateway resolves to the
/// host's `nginx.service`.
async fn control_infra_container( async fn control_infra_container(
container: InfraContainer, container: InfraContainer,
action: InfraAction, action: InfraAction,
@ -1189,7 +1186,7 @@ async fn control_infra_container(
let verb = action.systemctl_verb(); let verb = action.systemctl_verb();
let unit = container.service_unit(); let unit = container.service_unit();
let out = Command::new("systemctl") let out = Command::new("systemctl")
.args([verb, &unit]) .args([verb, unit])
.output() .output()
.await .await
.with_context(|| format!("systemctl {verb} {unit}"))?; .with_context(|| format!("systemctl {verb} {unit}"))?;
@ -2772,10 +2769,10 @@ async fn sync_gateway_nginx() -> Result<(String, String)> {
/// ///
/// **This prefix is what confines the lifecycle verbs to agent containers** — /// **This prefix is what confines the lifecycle verbs to agent containers** —
/// not their name validation, which only checks characters. A caller cannot /// not their name validation, which only checks characters. A caller cannot
/// name `hive-forge` and reach it: it becomes `h-hive-forge`. Infra containers /// name `hive-forge` and reach it: it becomes `h-hive-forge`. No lifecycle
/// are reached through [`PrivRequest::ControlInfraContainer`] and the /// verb takes a sibling name: the forge and matrix are swarm services a hive
/// [`InfraContainer`] enum instead, which is why no lifecycle verb here takes /// does not start or stop, and the gateway is reached through
/// a sibling name. /// [`PrivRequest::ControlInfraContainer`] and the [`InfraContainer`] enum.
fn container_system_name(name: &str) -> String { fn container_system_name(name: &str) -> String {
format!("{AGENT_PREFIX}{name}") format!("{AGENT_PREFIX}{name}")
} }
@ -3163,13 +3160,13 @@ fn ensure_socket_dir_in(root: &Path, name: &str) -> Result<()> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
BindMount, BoundedRun, Command, HiveFacts, OwnedFd, PAUSED_MARKER_FILE, PrivRequest, BindMount, BoundedRun, Command, HiveFacts, InfraAction, InfraContainer, OwnedFd,
StagedFile, agent_disk_paths, check_fd_agreement, contains_secret_shaped_run, PAUSED_MARKER_FILE, PrivRequest, StagedFile, agent_disk_paths, check_fd_agreement,
describe_forge_admin, du_command, du_total, ensure_plain_filename, ensure_socket_dir_in, contains_secret_shaped_run, describe_forge_admin, du_command, du_total,
exec_measure_agent_disk, git_overlay_flags, limits_dropin_body, open_dir, open_export_dest, ensure_plain_filename, ensure_socket_dir_in, exec_measure_agent_disk, git_overlay_flags,
parse_du_total, partial_name, publish_file, redact_secret_line, remove_marker_in, limits_dropin_body, open_dir, open_export_dest, parse_du_total, partial_name, publish_file,
render_hive_facts, run_bounded, single_output_path, toplevel_attr, redact_secret_line, remove_marker_in, render_hive_facts, run_bounded, single_output_path,
validate_credential_name, validate_snapshot_name, write_agent_dir_file, toplevel_attr, validate_credential_name, validate_snapshot_name, write_agent_dir_file,
write_bridge_dns_marker_in, write_bridge_dns_marker_in,
}; };
use std::path::PathBuf; use std::path::PathBuf;
@ -4044,6 +4041,27 @@ mod tests {
assert!(serde_json::from_str::<PrivRequest>(r#"{"op":"sync_agent_tmpfilez"}"#).is_err()); assert!(serde_json::from_str::<PrivRequest>(r#"{"op":"sync_agent_tmpfilez"}"#).is_err());
} }
/// A start/stop naming the forge or matrix container fails to decode, so
/// `run` answers it with a parse error before anything reaches
/// `systemctl`. Control: the same request naming the gateway decodes.
#[test]
fn infra_control_refuses_swarm_services() {
let req = |c: &str| {
format!(r#"{{"op":"control_infra_container","container":"{c}","action":"stop"}}"#)
};
for c in ["Forge", "Matrix"] {
let err = serde_json::from_str::<PrivRequest>(&req(c)).unwrap_err();
assert!(err.to_string().contains("unknown variant"), "{c}: {err}");
}
assert!(matches!(
serde_json::from_str::<PrivRequest>(&req("Gateway")),
Ok(PrivRequest::ControlInfraContainer {
container: InfraContainer::Gateway,
action: InfraAction::Stop,
})
));
}
/// The pause marker round-trips through the same root-only path the /// The pause marker round-trips through the same root-only path the
/// credential writes use, and BOTH directions are idempotent — the /// credential writes use, and BOTH directions are idempotent — the
/// dashboard toggle and `hivectl pause|resume` fire blind, without /// dashboard toggle and `hivectl pause|resume` fire blind, without

View file

@ -99,8 +99,10 @@ pub enum Cmd {
}, },
/// Stop containers hive-wide in one operator action. /// Stop containers hive-wide in one operator action.
/// ///
/// Bare `hivectl stop` stops everything; scope flags narrow it to /// Bare `hivectl stop` stops every agent and the gateway; scope flags
/// specific sub-agents or infra containers. /// narrow it to specific sub-agents or the gateway. The swarm's forge
/// and matrix are never in scope: systemd on the services host runs
/// them.
Stop { Stop {
#[command(flatten)] #[command(flatten)]
scope: ScopeArgs, scope: ScopeArgs,
@ -121,7 +123,7 @@ pub enum Cmd {
/// ///
/// Bare `hivectl start` restores the agents stopped by the last /// Bare `hivectl start` restores the agents stopped by the last
/// broad-scope `stop` (or starts everything if none); scope flags /// broad-scope `stop` (or starts everything if none); scope flags
/// narrow it. /// narrow it. The swarm's forge and matrix are never in scope.
Start { Start {
#[command(flatten)] #[command(flatten)]
scope: ScopeArgs, scope: ScopeArgs,
@ -133,6 +135,7 @@ pub enum Cmd {
/// Restart containers hive-wide — `stop` then `start` over one scope. /// Restart containers hive-wide — `stop` then `start` over one scope.
/// ///
/// Bare `hivectl restart` restarts everything; scope flags narrow it. /// Bare `hivectl restart` restarts everything; scope flags narrow it.
/// The swarm's forge and matrix are never in scope.
Restart { Restart {
#[command(flatten)] #[command(flatten)]
scope: ScopeArgs, scope: ScopeArgs,
@ -179,14 +182,10 @@ pub enum OpenTarget {
Matrix, Matrix,
} }
/// Shared scope flags for `hivectl stop` / `hivectl start`. With no flag /// Shared scope flags for `hivectl stop` / `start` / `restart`. With no
/// set the verb targets **everything** (all sub-agents + every controllable /// flag set the verb targets **everything** (all sub-agents + the
/// infra container). Setting any flag restricts to the selected classes, /// gateway). Setting any flag restricts to the selected classes,
/// additively. /// additively.
// One bool per selectable container class, each mapping 1:1 to a clap flag;
// orthogonal toggles, not a state machine — hence the bools allow (mirrors
// `hive_host_sock::LifecycleScope`).
#[allow(clippy::struct_excessive_bools)]
#[derive(Args)] #[derive(Args)]
pub struct ScopeArgs { pub struct ScopeArgs {
/// All sub-agent containers. /// All sub-agent containers.
@ -195,15 +194,9 @@ pub struct ScopeArgs {
/// A specific sub-agent by name. Repeatable: `--agent a --agent b`. /// A specific sub-agent by name. Repeatable: `--agent a --agent b`.
#[arg(long = "agent", value_name = "NAME")] #[arg(long = "agent", value_name = "NAME")]
agent: Vec<String>, agent: Vec<String>,
/// The forge container (`hive-forge`).
#[arg(long)]
forge: bool,
/// The gateway (`hive-gateway`) — nginx on the host, not a container. /// The gateway (`hive-gateway`) — nginx on the host, not a container.
#[arg(long)] #[arg(long)]
gateway: bool, gateway: bool,
/// The matrix container (`hive-matrix`).
#[arg(long)]
matrix: bool,
} }
impl ScopeArgs { impl ScopeArgs {
@ -211,9 +204,7 @@ impl ScopeArgs {
hive_host_sock::LifecycleScope { hive_host_sock::LifecycleScope {
agents: self.agents, agents: self.agents,
agent_names: self.agent.clone(), agent_names: self.agent.clone(),
forge: self.forge,
gateway: self.gateway, gateway: self.gateway,
matrix: self.matrix,
} }
} }
} }

View file

@ -45,7 +45,7 @@ pub(crate) async fn start(
/// issue tracker "dagify hivectl commands"). Each targeted agent rides /// issue tracker "dagify hivectl commands"). Each targeted agent rides
/// exactly one atomic DAG queued up front — `Restart` (mechanical stop + /// exactly one atomic DAG queued up front — `Restart` (mechanical stop +
/// reconcile), or `GracefulRestart` with `--graceful` (signal → drain → /// reconcile), or `GracefulRestart` with `--graceful` (signal → drain →
/// mechanical stop → reconcile); infra containers restart synchronously. /// mechanical stop → reconcile); the gateway (host `nginx.service`) restarts synchronously.
/// No "submit one DAG, wait for it, submit another" composition on either /// No "submit one DAG, wait for it, submit another" composition on either
/// side of the wire: unlike the old client-side stop-then-start compose, a /// side of the wire: unlike the old client-side stop-then-start compose, a
/// dropped `hivectl` connection mid-restart no longer leaves an agent /// dropped `hivectl` connection mid-restart no longer leaves an agent

View file

@ -16,9 +16,7 @@ fn single_agent_scope(name: &str) -> hive_host_sock::LifecycleScope {
hive_host_sock::LifecycleScope { hive_host_sock::LifecycleScope {
agents: false, agents: false,
agent_names: vec![name.to_owned()], agent_names: vec![name.to_owned()],
forge: false,
gateway: false, gateway: false,
matrix: false,
} }
} }