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:
parent
be92bc0835
commit
d4f3d1ef8e
12 changed files with 177 additions and 203 deletions
|
|
@ -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 ⇒
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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?)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue