hive-sh4re: type infra containers as an InfraContainer enum

Replace the stringly-typed infra-control path with an InfraContainer enum
(Ci/Forge/Gateway/Matrix). The variants are the allowlist: serde rejects any
unknown or unsafe name (hive-c0re has no variant) at the wire boundary, so
hive-priv no longer needs a root-side SIBLING_CONTAINERS.contains() check on
ControlInfraContainer — the type enforces it, and 'the daemon can't stop
itself' is a compile-time guarantee.

- priv_proto: InfraContainer enum; manual Serialize/Deserialize + FromStr +
  unit_name() all key off one mapping, so the wire form ('hive-ci', …) is
  unchanged and there's no drift. ControlInfraContainer.container: String ->
  InfraContainer.
- hive-priv / priv_client / server.rs: thread the enum; scoped_infra returns
  Vec<InfraContainer>; the control handler uses unit_name().
- agent_server: the infra_admin restart gate parses the name via FromStr
  instead of a slice .contains().
- SIBLING_CONTAINERS stays (validate_container_name/_system_name still use it
  for journals / general container validation); a test keeps the enum and the
  slice in lockstep.
This commit is contained in:
atlas 2026-06-19 11:43:52 +02:00 committed by mara
commit cd025b3790
5 changed files with 146 additions and 71 deletions

View file

@ -535,10 +535,11 @@ async fn handle_restart_child(coord: &Arc<Coordinator>, agent: &str, name: &str)
// Infra-container restart: an agent holding the `infra_admin`
// capability can restart a hive infrastructure container (hive-ci /
// hive-gateway / hive-forge / hive-matrix) by passing its name to the
// same restart tool. These names are never agent children, so this
// branch is disjoint from the child-restart path below.
if hive_sh4re::priv_proto::SIBLING_CONTAINERS.contains(&name) {
return handle_restart_infra(coord, agent, name).await;
// same restart tool. The `InfraContainer` enum parse both recognises
// these (never agent children, so disjoint from the child path below)
// and yields the typed value the restart path needs.
if let Ok(container) = name.parse::<hive_sh4re::priv_proto::InfraContainer>() {
return handle_restart_infra(coord, agent, container).await;
}
if let Some(err) = require_child(agent, name, "restart") {
return err;
@ -556,15 +557,16 @@ async fn handle_restart_child(coord: &Arc<Coordinator>, agent: &str, name: &str)
}
/// Restart a hive infrastructure container on behalf of an agent that
/// holds the `infra_admin` capability. The container name is already
/// known to be in `SIBLING_CONTAINERS`; this gates on the
/// capability and routes the systemctl restart through hive-priv (which
/// re-validates the name root-side). Direct, not approval-gated.
/// holds the `infra_admin` capability. The `container` is already a valid
/// [`InfraContainer`] (the caller parsed it); this gates on the capability
/// and routes the systemctl restart through hive-priv. Direct, not
/// approval-gated.
async fn handle_restart_infra(
coord: &Arc<Coordinator>,
agent: &str,
container: &str,
container: hive_sh4re::priv_proto::InfraContainer,
) -> AgentResponse {
let name = container.unit_name();
// Record the attempt in the operator-visible privileged-action audit
// trail, then emit a live `AuditEntryAdded` so the dashboard audit view
// appends it off `/dashboard/stream`. Best-effort: `record` returns the
@ -572,27 +574,26 @@ async fn handle_restart_infra(
// row so the stored + streamed views can't drift. `action` is stable so
// the dashboard can group/filter.
let audit = |outcome: crate::audit_log::AuditOutcome, detail: Option<&str>| {
if let Some(entry) =
coord
.audit_log
.record(agent, "restart_infra", container, outcome, detail)
if let Some(entry) = coord
.audit_log
.record(agent, "restart_infra", name, outcome, detail)
{
coord.emit_audit_entry(entry);
}
};
if !crate::capabilities::has_cap(agent, hive_sh4re::Capability::InfraAdmin) {
tracing::warn!(%agent, %container, "agent: infra restart denied (no infra_admin capability)");
tracing::warn!(%agent, %name, "agent: infra restart denied (no infra_admin capability)");
audit(
crate::audit_log::AuditOutcome::Err,
Some("denied: missing infra_admin capability"),
);
return AgentResponse::Err {
message: format!(
"restarting infra container `{container}` requires the `infra_admin` capability"
"restarting infra container `{name}` requires the `infra_admin` capability"
),
};
}
tracing::info!(%agent, %container, "agent: restart infra container");
tracing::info!(%agent, %name, "agent: restart infra container");
match crate::priv_client::restart_infra_container(container).await {
Ok(()) => {
audit(crate::audit_log::AuditOutcome::Ok, None);

View file

@ -8,8 +8,8 @@
use anyhow::{Context as _, Result, bail};
use hive_sh4re::priv_proto::{
BindMount, InfraAction, JournalQuery, NetworkIsolation, PRIV_SOCK, PrivEvent, PrivRequest,
PrivResponse, PrivStream,
BindMount, InfraAction, InfraContainer, JournalQuery, NetworkIsolation, PRIV_SOCK, PrivEvent,
PrivRequest, PrivResponse, PrivStream,
};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
@ -288,21 +288,17 @@ pub async fn restart_matrix_daemon(agent_name: &str) -> Result<()> {
/// re-validates `container` against its root-side allowlist; callers must
/// already have checked the requesting agent holds the `infra_admin`
/// capability.
pub async fn restart_infra_container(container: &str) -> Result<()> {
pub async fn restart_infra_container(container: InfraContainer) -> Result<()> {
control_infra_container(container, InfraAction::Restart).await
}
/// Start / stop / restart a hive infrastructure container (`hive-ci`,
/// `hive-gateway`, `hive-forge`, `hive-matrix`) on the host via `systemctl
/// <action> container@<container>.service`. hive-priv re-validates
/// `container` against its root-side allowlist (`SIBLING_CONTAINERS`). Used
/// by the hive-wide `hivectl stop` / `hivectl start` flow.
pub async fn control_infra_container(container: &str, action: InfraAction) -> Result<()> {
ok(call(&PrivRequest::ControlInfraContainer {
container: container.to_owned(),
action,
})
.await?)
/// <action> container@<container>.service`. The [`InfraContainer`] enum is
/// the allowlist — hive-priv needs no name re-validation. Used by the
/// hive-wide `hivectl stop` / `hivectl start` flow.
pub async fn control_infra_container(container: InfraContainer, action: InfraAction) -> Result<()> {
ok(call(&PrivRequest::ControlInfraContainer { container, action }).await?)
}
/// Ensure the agent's persistent state root is a btrfs subvolume when the

View file

@ -2,7 +2,7 @@ use std::path::Path;
use std::sync::Arc;
use anyhow::{Context, Result};
use hive_sh4re::priv_proto::InfraAction;
use hive_sh4re::priv_proto::{InfraAction, InfraContainer};
use hive_sh4re::{HostRequest, HostResponse, LifecycleScope};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{UnixListener, UnixStream};
@ -229,7 +229,7 @@ async fn handle_restart_all() -> Result<HostResponse> {
async fn handle_stop(
coord: &Arc<Coordinator>,
agents: &[String],
infra: &[&str],
infra: &[InfraContainer],
graceful: bool,
) -> Result<HostResponse> {
tracing::info!(?agents, ?infra, graceful, "stop");
@ -267,11 +267,12 @@ async fn handle_stop(
}
for &container in infra {
let name = container.unit_name();
match crate::priv_client::control_infra_container(container, InfraAction::Stop).await {
Ok(()) => ok_items.push(container.to_owned()),
Ok(()) => ok_items.push(name.to_owned()),
Err(e) => {
tracing::warn!(%container, error = ?e, "stop: infra stop failed");
errors.push(format!("{container}: {e:#}"));
tracing::warn!(%name, error = ?e, "stop: infra stop failed");
errors.push(format!("{name}: {e:#}"));
}
}
}
@ -283,17 +284,18 @@ async fn handle_stop(
/// inverse of [`handle_stop`]. Infra comes up before agents so the agents
/// find forge/matrix/gateway ready. Per-target failures aggregated. Callers
/// resolve the [`LifecycleScope`] to these explicit name lists up front.
async fn handle_start(agents: &[String], infra: &[&str]) -> Result<HostResponse> {
async fn handle_start(agents: &[String], infra: &[InfraContainer]) -> Result<HostResponse> {
tracing::info!(?agents, ?infra, "start");
let mut ok_items: Vec<String> = Vec::new();
let mut errors: Vec<String> = Vec::new();
for &container in infra {
let name = container.unit_name();
match crate::priv_client::control_infra_container(container, InfraAction::Start).await {
Ok(()) => ok_items.push(container.to_owned()),
Ok(()) => ok_items.push(name.to_owned()),
Err(e) => {
tracing::warn!(%container, error = ?e, "start: infra start failed");
errors.push(format!("{container}: {e:#}"));
tracing::warn!(%name, error = ?e, "start: infra start failed");
errors.push(format!("{name}: {e:#}"));
}
}
}
@ -333,23 +335,23 @@ async fn scoped_agents(scope: &LifecycleScope) -> Result<Vec<String>> {
Ok(set.into_iter().collect())
}
/// Resolve which infra container names a scope targets. An "everything"
/// scope (no flags set) selects all controllable infra; otherwise each set
/// flag maps to its container. Fixed order for deterministic output.
fn scoped_infra(scope: &LifecycleScope) -> Vec<&'static str> {
/// 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.
fn scoped_infra(scope: &LifecycleScope) -> Vec<InfraContainer> {
let everything = scope.is_everything();
let mut out = Vec::new();
if everything || scope.ci {
out.push("hive-ci");
out.push(InfraContainer::Ci);
}
if everything || scope.forge {
out.push("hive-forge");
out.push(InfraContainer::Forge);
}
if everything || scope.gateway {
out.push("hive-gateway");
out.push(InfraContainer::Gateway);
}
if everything || scope.matrix {
out.push("hive-matrix");
out.push(InfraContainer::Matrix);
}
out
}