diff --git a/docs/scheduler/coordinator.md b/docs/scheduler/coordinator.md index 8fc18d45..56040527 100644 --- a/docs/scheduler/coordinator.md +++ b/docs/scheduler/coordinator.md @@ -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` — rides the queue through that power layer, so intent, lease serialization, 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 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 ⇒ diff --git a/docs/swarm/services.md b/docs/swarm/services.md index bf601cdc..2b40d099 100644 --- a/docs/swarm/services.md +++ b/docs/swarm/services.md @@ -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 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 `forge.`, for example. Only the host running a service answers its name from its own resolver, so on a swarm spread over diff --git a/docs/tools/hivectl-cli.md b/docs/tools/hivectl-cli.md index 4138fd6d..85a7ec03 100644 --- a/docs/tools/hivectl-cli.md +++ b/docs/tools/hivectl-cli.md @@ -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. -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]` @@ -522,9 +522,7 @@ Bare `hivectl stop` stops everything; scope flags narrow it to specific sub-agen * `--agents` — All sub-agent containers * `--agent ` — 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 -* `--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 * `--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`. -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]` @@ -542,9 +540,7 @@ Bare `hivectl start` restores the agents stopped by the last broad-scope `stop` * `--agents` — All sub-agent containers * `--agent ` — 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 -* `--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 @@ -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. -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]` @@ -561,9 +557,7 @@ Bare `hivectl restart` restarts everything; scope flags narrow it. * `--agents` — All sub-agent containers * `--agent ` — 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 -* `--matrix` — The matrix container (`hive-matrix`) * `--graceful` — Gracefully quiesce each agent on the stop half (see `stop --graceful`). Applies to agents only diff --git a/docs/trust-boundary/security.md b/docs/trust-boundary/security.md index 113c3b01..f282684f 100644 --- a/docs/trust-boundary/security.md +++ b/docs/trust-boundary/security.md @@ -297,7 +297,7 @@ known operations; there is no arbitrary command pass-through: | `RemoveServiceDropin` | remove `container@.service.d/` drop-in on destroy | | `DaemonReload` | `systemctl daemon-reload` | | `RunForgeAdmin` | `nixos-container run hive-forge -- runuser -u forgejo -- forgejo admin ` | -| `ControlInfraContainer` | `systemctl 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 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` | | `SetAgentPaused` | create / remove the `//harness/paused` marker that parks an agent's turn loop | | `EnsureAgentSubvolume` | `btrfs subvolume create /` for a new agent — no-op when the path exists or the filesystem isn't btrfs | diff --git a/hive-c0re/src/priv_client.rs b/hive-c0re/src/priv_client.rs index 0cd30e75..d772873b 100644 --- a/hive-c0re/src/priv_client.rs +++ b/hive-c0re/src/priv_client.rs @@ -363,13 +363,12 @@ pub async fn set_agent_paused(agent_name: &str, paused: bool) -> Result<()> { .await?) } -/// Start / stop a hive infrastructure service (`hive-gateway`, `hive-forge`, -/// `hive-matrix`) on the host via `systemctl `, -/// where the unit is derived root-side from the variant -/// (`container@.service`, or `nginx.service` for the gateway). The -/// [`InfraContainer`] enum is the allowlist — hive-priv needs no name -/// re-validation. Used by the hive-wide `hivectl stop` / `start` flow and -/// the dashboard's operator-only infra panel. No agent-facing path exists. +/// Start / stop a hive infrastructure service (`hive-gateway`) on the host +/// via `systemctl `, where the unit is derived root-side from +/// the variant (`nginx.service` for the gateway). The [`InfraContainer`] +/// enum is the allowlist — hive-priv needs no name re-validation. Used by +/// the hive-wide `hivectl stop` / `start` / `restart` flow. No agent-facing +/// path exists. pub async fn control_infra_container(container: InfraContainer, action: InfraAction) -> Result<()> { ok(call(&PrivRequest::ControlInfraContainer { container, action }).await?) } diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index 7e9ac3a7..ae60484b 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -570,9 +570,9 @@ async fn submit_single(coord: &Arc, 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 -/// 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 /// `finish_lifecycle` below. Callers resolve the [`LifecycleScope`] to these /// explicit name lists up front — this never sees the "all" flag. @@ -582,7 +582,7 @@ async fn submit_single(coord: &Arc, name: &str, verb: Verb) -> Host /// a hard stop a plain stop DAG — both persist `wanted = Offline` and /// serialize on the agent's lease so nothing races an in-flight /// 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. async fn handle_stop( coord: &Arc, @@ -608,8 +608,8 @@ async fn handle_stop( } } - // Agents go down before infra so they're not mid-request against a - // forge/matrix that's already gone. Hard stops are quick kills — + // Agents go down before infra so they're not mid-request through a + // gateway that's already gone. Hard stops are quick kills — // await their DAGs (bounded) before touching infra. Graceful stops // keep the immediate return (drains take minutes and the // agents-then-infra race pre-existed there). @@ -659,9 +659,9 @@ async fn await_dags(coord: &Arc, 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 -/// 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. async fn handle_start( coord: &Arc, @@ -713,7 +713,7 @@ async fn handle_start( /// concurrently on their own leases), not N separate DAGs — a hive-wide /// restart is one job. `graceful` prepends signal→drain per agent. No /// 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). async fn handle_restart_scoped( coord: &Arc, @@ -814,22 +814,15 @@ async fn scoped_agents(scope: &LifecycleScope) -> Result> { Ok(set.into_iter().collect()) } -/// Resolve which infra containers a scope targets. An "everything" scope -/// (no flags set) selects all controllable infra; otherwise each set flag -/// maps to its [`InfraContainer`]. Fixed order for deterministic output. +/// Resolve which infra services a scope targets: the gateway, when the +/// scope is "everything" (no flags set) or names it. The swarm's forge and +/// matrix are never a target. fn scoped_infra(scope: &LifecycleScope) -> Vec { - let everything = scope.is_everything(); - let mut out = Vec::new(); - if everything || scope.forge { - out.push(InfraContainer::Forge); + if scope.is_everything() || scope.gateway { + vec![InfraContainer::Gateway] + } else { + 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 @@ -916,4 +909,42 @@ mod tests { assert!(message.starts_with("request timed out"), "{message}"); 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::(&req(field)).unwrap_err(); + assert!(err.to_string().contains("unknown field"), "{field}: {err}"); + } + assert!(matches!( + serde_json::from_str::(&req("gateway")), + Ok(HostRequest::Stop { scope, .. }) if scope.gateway && !scope.is_everything() + )); + } } diff --git a/hive-host-sock/src/lib.rs b/hive-host-sock/src/lib.rs index 31efe349..d490a82d 100644 --- a/hive-host-sock/src/lib.rs +++ b/hive-host-sock/src/lib.rs @@ -136,9 +136,9 @@ pub enum HostRequest { /// follow-up, since nothing durable remembered "finish the restart" /// once the calling process/turn was gone. `GracefulRestart` closes /// that gap the same way `Restart` already does — one DAG, queued up - /// front, that owns the whole sequence. Infra containers have no - /// lease/DAG and restart synchronously (stop then start), same as - /// before. Scope semantics match `Stop`/`Start` (all-false = + /// front, that owns the whole sequence. The gateway (host + /// `nginx.service`) has no lease/DAG and restarts synchronously + /// (stop then start). Scope semantics match `Stop`/`Start` (all-false = /// everything). RestartScoped { #[serde(default)] @@ -204,12 +204,12 @@ pub enum HostRequest { /// [`HostResponse::nodes`]. QueueNodes { ids: Vec }, /// 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 /// bare `hivectl stop`). `graceful` runs the per-agent quiesce (graceful /// agent stop, issue tracker `graceful agent stop`) instead of a hard - /// stop. Agents stop via the lifecycle path; infra containers via the - /// host `container@` units. + /// stop. Agents stop via the lifecycle path; the gateway via the host + /// `nginx.service` unit. Stop { #[serde(default)] scope: LifecycleScope, @@ -346,17 +346,15 @@ pub struct QuotaRow { /// [`HostRequest::Start`] touches. An all-false scope means **everything** /// (the bare `hivectl stop` / `start`); set individual fields to restrict /// (e.g. only `agents` → just the sub-agent containers). `agents` covers -/// every managed sub-agent container; the rest are the named infra -/// services (`hive-forge`, `hive-gateway`, `hive-matrix`) — -/// containers, bar the gateway, which is the host's nginx unit. -// -// A flat bag of independent flag toggles — one bool per selectable -// container class — is exactly the right shape here: each maps 1:1 to a -// `hivectl` `--forge` / `--gateway` / `--matrix` flag, and they're -// 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)] +/// every managed sub-agent container; `gateway` is the host's nginx unit. +/// The swarm's forge and matrix are not in any scope: a hive does not start +/// or stop them. +/// +/// Unknown fields are refused, so a `forge` / `matrix` flag from an older +/// `hivectl` is an error rather than dropped — dropping it would leave an +/// all-false scope, which means everything. #[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct LifecycleScope { /// All sub-agent containers (`--agents`). #[serde(default)] @@ -367,11 +365,7 @@ pub struct LifecycleScope { #[serde(default)] pub agent_names: Vec, #[serde(default)] - pub forge: bool, - #[serde(default)] pub gateway: bool, - #[serde(default)] - pub matrix: bool, } impl LifecycleScope { @@ -379,7 +373,7 @@ impl LifecycleScope { /// classes" (the bare `hivectl stop` / `start` with no scope flags). #[must_use] 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() } } diff --git a/hive-priv-sock/src/lib.rs b/hive-priv-sock/src/lib.rs index 3de8f572..fe56b6de 100644 --- a/hive-priv-sock/src/lib.rs +++ b/hive-priv-sock/src/lib.rs @@ -38,15 +38,14 @@ pub const AGENT_PREFIX: &str = "h-"; /// /// ⚠️ This is the *container-name* allowlist, not the lifecycle one: /// [`InfraContainer`] is what gates -/// [`PrivRequest::ControlInfraContainer`], and it has one variant more than -/// this list (the gateway). Keep the distinction — a name that belongs to -/// no container has no business reaching a `nixos-container` call. +/// [`PrivRequest::ControlInfraContainer`], and no name in this list has a +/// variant there: the forge and matrix are swarm services, started and +/// stopped by systemd on the host that runs them, never from a hive. pub const SIBLING_CONTAINERS: &[&str] = &["hive-forge", "hive-matrix"]; /// Lifecycle verb for [`PrivRequest::ControlInfraContainer`]. Maps directly /// to `systemctl `, where the unit comes from -/// [`InfraContainer::service_unit`] — usually `container@.service`, -/// but not always (see [`InfraTarget`]). +/// [`InfraContainer::service_unit`]. #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] 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 /// the allowlist: serde rejects any other value at the wire boundary, so an -/// unknown or unsafe target — notably `hive-c0re`, which has no variant and -/// would sever the daemon socket — is *unrepresentable* rather than caught -/// by a runtime check. The c0re↔hive-priv wire form uses serde's default -/// variant naming (`"Forge"`, `"Gateway"`, …); it's an internal protocol (both -/// ends rebuild together) so it needn't match the container name. -/// [`name`](Self::name) is the separate stable identity string -/// (`hive-forge`), and [`service_unit`](Self::service_unit) the systemd unit -/// it actually resolves to. +/// 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 { - Forge, Gateway, - Matrix, -} - -/// What an [`InfraContainer`] resolves to on the host — i.e. the thing a -/// lifecycle verb actually acts on. -/// -/// The gateway is why this exists: its nginx + dnsmasq are host -/// services, so "restart the gateway" means a plain host unit. Every -/// other variant is a container. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum InfraTarget { - /// An nspawn container, controlled via `container@.service`. - Container(&'static str), - /// A plain host unit. There is no machine to enter: no - /// `nixos-container` verb. - HostUnit(&'static str), } impl InfraContainer { - /// Every controllable infra target. A superset of - /// [`SIBLING_CONTAINERS`] — not all of these are containers (see the - /// test). - pub const ALL: [InfraContainer; 3] = [ - InfraContainer::Forge, - InfraContainer::Gateway, - InfraContainer::Matrix, - ]; + /// Every controllable infra target. + pub const ALL: [InfraContainer; 1] = [InfraContainer::Gateway]; - /// Stable identity string, e.g. `hive-forge`. This is what the operator - /// types, what the dashboard displays, and what [`FromStr`](std::str::FromStr) - /// parses — it stays `hive-gateway` even though the gateway is no - /// longer a container, because it names the *service*, not its - /// implementation. (Distinct from the serde wire form, which is the - /// default variant name `"Forge"`.) + /// 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::Forge => "hive-forge", InfraContainer::Gateway => "hive-gateway", - InfraContainer::Matrix => "hive-matrix", - } - } - - /// Where this target lives on the host. - #[must_use] - pub fn target(self) -> InfraTarget { - match self { - InfraContainer::Gateway => InfraTarget::HostUnit("nginx.service"), - other => InfraTarget::Container(other.name()), } } /// The systemd unit a lifecycle verb acts on. #[must_use] - pub fn service_unit(self) -> String { - match self.target() { - InfraTarget::Container(name) => format!("container@{name}.service"), - InfraTarget::HostUnit(unit) => unit.to_owned(), + pub fn service_unit(self) -> &'static str { + match self { + InfraContainer::Gateway => "nginx.service", } } } @@ -145,7 +109,7 @@ impl InfraContainer { impl std::str::FromStr for InfraContainer { 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. fn from_str(s: &str) -> Result { Self::ALL.into_iter().find(|c| c.name() == s).ok_or(()) @@ -415,13 +379,13 @@ pub enum PrivRequest { paused: bool, }, - /// Start / stop a hive infrastructure container on the host via - /// `systemctl container@.service`. The + /// Start / stop a hive infrastructure service on the host via + /// `systemctl `, the unit derived from the variant. The /// [`InfraContainer`] enum is the allowlist — serde rejects unknown / - /// unsafe names (notably `hive-c0re`, which has no variant) at the wire - /// boundary, so no root-side `.contains()` check is needed. Serves the - /// hive-wide `hivectl stop` / `hivectl start` flow and the dashboard's - /// operator-only infra panel. No agent-facing path exists. + /// 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, @@ -695,65 +659,43 @@ pub enum PrivEvent { #[cfg(test)] mod tests { - use super::{InfraContainer, InfraTarget, SIBLING_CONTAINERS}; + use super::{InfraContainer, SIBLING_CONTAINERS}; #[test] - fn infra_control_allowlist_excludes_c0re_includes_matrix() { + fn sibling_container_allowlist_excludes_c0re() { // SIBLING_CONTAINERS is the authoritative allowlist for the // requests that name a container as a string. hive-c0re must NEVER // be in it — stopping the daemon would sever the socket the request // arrived on. assert!(!SIBLING_CONTAINERS.contains(&"hive-c0re")); - // hive-matrix IS controllable (operator can stop/start/restart it). assert!(SIBLING_CONTAINERS.contains(&"hive-matrix")); } #[test] - fn infra_container_enum_matches_sibling_containers() { - // The two lists are the same set *minus the things that aren't - // containers*: every `Container` variant must appear in - // SIBLING_CONTAINERS and vice versa, so a `nixos-container` verb can - // never be pointed at a name that has no machine behind it. - let mut from_enum: Vec<&str> = InfraContainer::ALL - .iter() - .filter_map(|c| match c.target() { - InfraTarget::Container(name) => Some(name), - InfraTarget::HostUnit(_) => None, - }) - .collect(); - from_enum.sort_unstable(); - let mut from_slice: Vec<&str> = SIBLING_CONTAINERS.to_vec(); - from_slice.sort_unstable(); - assert_eq!(from_enum, from_slice); - // The gateway is the one that is not: a host unit, and absent from - // the container-name allowlist. + 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::().is_err(), "{name}"); + } assert_eq!( - InfraContainer::Gateway.target(), - InfraTarget::HostUnit("nginx.service") + "hive-gateway".parse::(), + Ok(InfraContainer::Gateway) ); - assert!(!SIBLING_CONTAINERS.contains(&"hive-gateway")); - // hive-c0re has no variant — unrepresentable, can't be controlled. - assert!("hive-c0re".parse::().is_err()); } #[test] fn infra_container_name_round_trips() { // `name` is the single source of truth for the operator-facing form // (FromStr keys off it), so a name→variant→name round-trip proves - // the mapping is consistent in both directions. It holds for the - // gateway too: the name is still recognised, it's the *permission* - // that differs (see below), not the parse. + // the mapping is consistent in both directions. for c in InfraContainer::ALL { assert_eq!(c.name().parse::(), Ok(c)); } } #[test] - fn service_unit_wraps_containers_but_not_host_units() { - assert_eq!( - InfraContainer::Forge.service_unit(), - "container@hive-forge.service" - ); + fn gateway_resolves_to_host_nginx() { assert_eq!(InfraContainer::Gateway.service_unit(), "nginx.service"); } } diff --git a/hive-priv/src/main.rs b/hive-priv/src/main.rs index 052ff7aa..fbffcc53 100644 --- a/hive-priv/src/main.rs +++ b/hive-priv/src/main.rs @@ -1172,16 +1172,13 @@ async fn daemon_reload() -> Result<(String, String)> { /// `ControlInfraContainer` — start/stop a hive infrastructure service via /// `systemctl `. The [`InfraContainer`] enum is the allowlist: -/// serde already rejected any unknown / unsafe name (hive-c0re has no -/// variant, so a stop can't sever the daemon socket) at deserialisation, so -/// no root-side `.contains()` check is needed here. Serves the hive-wide -/// `hivectl stop`/`start` flow and the dashboard's operator-only infra -/// panel. No agent-facing path exists. +/// serde already rejected any unknown / unsafe name (hive-c0re, the forge +/// and the matrix have no variant) at deserialisation, so no root-side +/// `.contains()` check is needed here. Serves the hive-wide +/// `hivectl stop`/`start`/`restart` flow. No agent-facing path exists. /// /// ⚠️ The unit is derived from the variant, never sent by the caller — /// which is what keeps this from being a general `systemctl` pass-through. -/// It is not always `container@.service`: the gateway resolves to the -/// host's `nginx.service`. async fn control_infra_container( container: InfraContainer, action: InfraAction, @@ -1189,7 +1186,7 @@ async fn control_infra_container( let verb = action.systemctl_verb(); let unit = container.service_unit(); let out = Command::new("systemctl") - .args([verb, &unit]) + .args([verb, unit]) .output() .await .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** — /// not their name validation, which only checks characters. A caller cannot -/// name `hive-forge` and reach it: it becomes `h-hive-forge`. Infra containers -/// are reached through [`PrivRequest::ControlInfraContainer`] and the -/// [`InfraContainer`] enum instead, which is why no lifecycle verb here takes -/// a sibling name. +/// name `hive-forge` and reach it: it becomes `h-hive-forge`. No lifecycle +/// verb takes a sibling name: the forge and matrix are swarm services a hive +/// does not start or stop, and the gateway is reached through +/// [`PrivRequest::ControlInfraContainer`] and the [`InfraContainer`] enum. fn container_system_name(name: &str) -> String { format!("{AGENT_PREFIX}{name}") } @@ -3163,13 +3160,13 @@ fn ensure_socket_dir_in(root: &Path, name: &str) -> Result<()> { #[cfg(test)] mod tests { use super::{ - BindMount, BoundedRun, Command, HiveFacts, OwnedFd, PAUSED_MARKER_FILE, PrivRequest, - StagedFile, agent_disk_paths, check_fd_agreement, contains_secret_shaped_run, - describe_forge_admin, du_command, du_total, ensure_plain_filename, ensure_socket_dir_in, - exec_measure_agent_disk, git_overlay_flags, limits_dropin_body, open_dir, open_export_dest, - parse_du_total, partial_name, publish_file, redact_secret_line, remove_marker_in, - render_hive_facts, run_bounded, single_output_path, toplevel_attr, - validate_credential_name, validate_snapshot_name, write_agent_dir_file, + BindMount, BoundedRun, Command, HiveFacts, InfraAction, InfraContainer, OwnedFd, + PAUSED_MARKER_FILE, PrivRequest, StagedFile, agent_disk_paths, check_fd_agreement, + contains_secret_shaped_run, describe_forge_admin, du_command, du_total, + ensure_plain_filename, ensure_socket_dir_in, exec_measure_agent_disk, git_overlay_flags, + limits_dropin_body, open_dir, open_export_dest, parse_du_total, partial_name, publish_file, + redact_secret_line, remove_marker_in, render_hive_facts, run_bounded, single_output_path, + toplevel_attr, validate_credential_name, validate_snapshot_name, write_agent_dir_file, write_bridge_dns_marker_in, }; use std::path::PathBuf; @@ -4044,6 +4041,27 @@ mod tests { assert!(serde_json::from_str::(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::(&req(c)).unwrap_err(); + assert!(err.to_string().contains("unknown variant"), "{c}: {err}"); + } + assert!(matches!( + serde_json::from_str::(&req("Gateway")), + Ok(PrivRequest::ControlInfraContainer { + container: InfraContainer::Gateway, + action: InfraAction::Stop, + }) + )); + } + /// The pause marker round-trips through the same root-only path the /// credential writes use, and BOTH directions are idempotent — the /// dashboard toggle and `hivectl pause|resume` fire blind, without diff --git a/hivectl/src/cli.rs b/hivectl/src/cli.rs index 704147fb..c8b62c68 100644 --- a/hivectl/src/cli.rs +++ b/hivectl/src/cli.rs @@ -99,8 +99,10 @@ pub enum Cmd { }, /// 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. Stop { #[command(flatten)] scope: ScopeArgs, @@ -121,7 +123,7 @@ pub enum Cmd { /// /// Bare `hivectl start` restores the agents stopped by the last /// 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 { #[command(flatten)] scope: ScopeArgs, @@ -133,6 +135,7 @@ pub enum Cmd { /// Restart containers hive-wide — `stop` then `start` over one scope. /// /// Bare `hivectl restart` restarts everything; scope flags narrow it. + /// The swarm's forge and matrix are never in scope. Restart { #[command(flatten)] scope: ScopeArgs, @@ -179,14 +182,10 @@ pub enum OpenTarget { Matrix, } -/// Shared scope flags for `hivectl stop` / `hivectl start`. With no flag -/// set the verb targets **everything** (all sub-agents + every controllable -/// infra container). Setting any flag restricts to the selected classes, +/// Shared scope flags for `hivectl stop` / `start` / `restart`. With no +/// flag set the verb targets **everything** (all sub-agents + the +/// gateway). Setting any flag restricts to the selected classes, /// additively. -// One bool per selectable container class, each mapping 1:1 to a clap flag; -// orthogonal toggles, not a state machine — hence the bools allow (mirrors -// `hive_host_sock::LifecycleScope`). -#[allow(clippy::struct_excessive_bools)] #[derive(Args)] pub struct ScopeArgs { /// All sub-agent containers. @@ -195,15 +194,9 @@ pub struct ScopeArgs { /// A specific sub-agent by name. Repeatable: `--agent a --agent b`. #[arg(long = "agent", value_name = "NAME")] agent: Vec, - /// The forge container (`hive-forge`). - #[arg(long)] - forge: bool, /// The gateway (`hive-gateway`) — nginx on the host, not a container. #[arg(long)] gateway: bool, - /// The matrix container (`hive-matrix`). - #[arg(long)] - matrix: bool, } impl ScopeArgs { @@ -211,9 +204,7 @@ impl ScopeArgs { hive_host_sock::LifecycleScope { agents: self.agents, agent_names: self.agent.clone(), - forge: self.forge, gateway: self.gateway, - matrix: self.matrix, } } } diff --git a/hivectl/src/power.rs b/hivectl/src/power.rs index 4966e248..c82f919e 100644 --- a/hivectl/src/power.rs +++ b/hivectl/src/power.rs @@ -45,7 +45,7 @@ pub(crate) async fn start( /// issue tracker "dagify hivectl commands"). Each targeted agent rides /// exactly one atomic DAG queued up front — `Restart` (mechanical stop + /// reconcile), or `GracefulRestart` with `--graceful` (signal → drain → -/// mechanical stop → reconcile); infra containers restart synchronously. +/// mechanical stop → reconcile); the gateway (host `nginx.service`) restarts synchronously. /// No "submit one DAG, wait for it, submit another" composition on either /// side of the wire: unlike the old client-side stop-then-start compose, a /// dropped `hivectl` connection mid-restart no longer leaves an agent diff --git a/hivectl/src/subvol.rs b/hivectl/src/subvol.rs index 5e901fb2..cb44e484 100644 --- a/hivectl/src/subvol.rs +++ b/hivectl/src/subvol.rs @@ -16,9 +16,7 @@ fn single_agent_scope(name: &str) -> hive_host_sock::LifecycleScope { hive_host_sock::LifecycleScope { agents: false, agent_names: vec![name.to_owned()], - forge: false, gateway: false, - matrix: false, } }