remove hive-level infra-container restart from web ui and agents

This commit is contained in:
damocles 2026-08-30 22:12:04 +02:00 committed by mara
commit 7516a4e10e
17 changed files with 112 additions and 272 deletions

View file

@ -482,8 +482,8 @@ impl Coordinator {
crate::build_logs::install(build_logs.clone());
// Audit log shares the same db dir; install its process-wide
// handle so privileged-action recording sites (e.g.
// `socket_server::handle_restart_infra`) write without threading an
// `Arc<AuditLog>` through the agent-request surface.
// `dashboard::infra_containers::post_infra_container`) write
// without threading an `Arc<AuditLog>` through the surface.
let audit_log =
Arc::new(crate::audit_log::AuditLog::open(build_logs_dir).context("open audit_log")?);
crate::audit_log::install(audit_log.clone());

View file

@ -1,13 +1,8 @@
//! Dashboard endpoint for operator-driven infra lifecycle (start / stop /
//! restart on `hive-ci`, `hive-forge`, `hive-gateway`, `hive-matrix`).
//! Parallels the `infra_admin`-gated agent path in
//! `socket_server/lifecycle_handlers.rs::handle_restart_infra`, but this one
//! is reached from the dashboard — already fully operator-authenticated —
//! so no capability check is needed here, just the same audit trail.
//!
//! The two surfaces cover different sets: this endpoint takes all four,
//! while the agent path refuses the gateway — nginx on the host fronts
//! every hive service, so bouncing it is the operator's call.
//! Dashboard endpoint for operator-driven infra lifecycle (start / stop on
//! `hive-ci`, `hive-forge`, `hive-gateway`, `hive-matrix`). Operator-only —
//! there is no agent-facing equivalent for either action.
//! Already fully operator-authenticated by the time a request reaches here,
//! so no capability check is needed, just the audit trail.
use axum::{
extract::{Path as AxumPath, State},
@ -18,21 +13,18 @@ use hive_priv_sock::{InfraAction, InfraContainer};
use super::{AppState, error_response};
/// Start / stop / restart a
/// hive infrastructure container from the dashboard.
/// Start / stop a hive infrastructure container from the dashboard.
///
/// `name` parses into [`InfraContainer`] (the allowlist; unrecognised
/// names 400), `action` into `start` / `stop` / `restart`. Every attempt
/// lands in the audit log (actor `"operator"`, action `start_infra` /
/// `stop_infra` / `restart_infra`) and streams as an `AuditEntryAdded`
/// event, so operator-driven and agent-driven (`infra_admin`) infra
/// actions show up in the same AUDIT view.
/// names 400), `action` into `start` / `stop`. Every attempt lands in the
/// audit log (actor `"operator"`, action `start_infra` / `stop_infra`) and
/// streams as an `AuditEntryAdded` event.
#[utoipa::path(
post,
path = "/api/infra-container/{name}/{action}",
params(
("name" = String, Path, description = "infra service name (hive-ci/hive-forge/hive-gateway/hive-matrix)"),
("action" = String, Path, description = "start | stop | restart"),
("action" = String, Path, description = "start | stop"),
),
responses(
(status = 200, description = "action completed", body = String),
@ -50,11 +42,8 @@ pub(super) async fn post_infra_container(
let (infra_action, action_label) = match action.as_str() {
"start" => (InfraAction::Start, "start_infra"),
"stop" => (InfraAction::Stop, "stop_infra"),
"restart" => (InfraAction::Restart, "restart_infra"),
other => {
return error_response(&format!(
"unknown action: {other} (want start|stop|restart)"
));
return error_response(&format!("unknown action: {other} (want start|stop)"));
}
};
let target = container.name();

View file

@ -38,7 +38,7 @@ use crate::lifecycle;
(name = "approvals", description = "approve/deny pending approval rows"),
(name = "build_logs", description = "build log headers, full rows, and raw text downloads"),
(name = "extra_forges", description = "external (non-internal) forge account provisioning"),
(name = "infra_containers", description = "start/stop/restart of hive infrastructure containers"),
(name = "infra_containers", description = "start/stop of hive infrastructure containers"),
(name = "lifecycle_ops", description = "agent container lifecycle: rebuild/restart/start/stop/pause/limits"),
(name = "matrix_accounts", description = "matrix + github account provisioning for agents"),
(name = "meta_inputs", description = "bulk flake-input update for the meta flake"),

View file

@ -119,8 +119,8 @@ pub(super) struct StateSnapshot {
server_warnings: Vec<crate::host_stats::ServerWarning>,
/// Live running/stopped status for the four hive infra containers
/// (`hive-ci`, `hive-forge`, `hive-gateway`, `hive-matrix`). Feeds the
/// C0R3 page's 1NFR4 sub-tab so the operator can start/stop/restart
/// them without an `infra_admin` agent's `restart` tool.
/// C0R3 page's 1NFR4 sub-tab, the operator-only surface for starting
/// and stopping them.
infra_containers: Vec<InfraContainerView>,
}

View file

@ -390,8 +390,8 @@ mod tests {
entry: crate::audit_log::AuditEntry {
id: 1,
ts_unix: hive_sh4re::wire_time::from_secs(0),
agent: "atlas".into(),
action: "restart_infra".into(),
agent: "operator".into(),
action: "stop_infra".into(),
target: "hive-ci".into(),
outcome: "ok".into(),
detail: None,
@ -419,21 +419,21 @@ mod tests {
entry: crate::audit_log::AuditEntry {
id: 42,
ts_unix: hive_sh4re::wire_time::from_secs(1_700_000_000),
agent: "atlas".into(),
action: "restart_infra".into(),
agent: "operator".into(),
action: "stop_infra".into(),
target: "hive-gateway".into(),
outcome: "err".into(),
detail: Some("denied: missing infra_admin capability".into()),
detail: Some("systemctl stop failed".into()),
},
};
let v: serde_json::Value = serde_json::to_value(&ev).expect("serialise");
assert_eq!(v["kind"], "audit_entry_added");
assert_eq!(v["seq"], 7);
assert_eq!(v["id"], 42);
assert_eq!(v["agent"], "atlas");
assert_eq!(v["agent"], "operator");
assert_eq!(v["target"], "hive-gateway");
assert_eq!(v["outcome"], "err");
assert_eq!(v["detail"], "denied: missing infra_admin capability");
assert_eq!(v["detail"], "systemctl stop failed");
// Not nested — there must be no `entry` sub-object.
assert!(v.get("entry").is_none());
}

View file

@ -477,21 +477,13 @@ pub async fn register_ci_runner(token: &str) -> Result<()> {
.await?)
}
/// Restart a hive infrastructure service on the host (thin wrapper over
/// [`control_infra_container`] with `action = Restart`). Callers must
/// already have checked that the requesting agent holds the `infra_admin`
/// capability *and* that the target is
/// [`agent_restartable`](InfraContainer::agent_restartable).
pub async fn restart_infra_container(container: InfraContainer) -> Result<()> {
control_infra_container(container, InfraAction::Restart).await
}
/// Start / stop / restart a hive infrastructure service (`hive-ci`,
/// `hive-gateway`, `hive-forge`, `hive-matrix`) on the host via `systemctl
/// <action> <unit>`, where the unit is derived root-side from the variant
/// Start / stop a hive infrastructure service (`hive-ci`, `hive-gateway`,
/// `hive-forge`, `hive-matrix`) on the host via `systemctl <action> <unit>`,
/// where the unit is derived root-side from the variant
/// (`container@<name>.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.
/// re-validation. Used by the hive-wide `hivectl stop` / `start` flow and
/// the dashboard's operator-only infra panel. No agent-facing path exists.
pub async fn control_infra_container(container: InfraContainer, action: InfraAction) -> Result<()> {
ok(call(&PrivRequest::ControlInfraContainer { container, action }).await?)
}

View file

@ -1,6 +1,5 @@
//! Container-lifecycle request handlers (`Start` / `Restart` / `Kill` /
//! `Update` / `ListDescendants`), including the capability-gated
//! infra-container restart path. All are topology-guarded via
//! `Update` / `ListDescendants`). All are topology-guarded via
//! `super::require_descendant`.
use std::sync::Arc;
@ -27,20 +26,10 @@ pub(super) async fn handle_start(coord: &Arc<Coordinator>, agent: &str, name: &s
}
/// `Restart` — enqueue a restart for a container. The caller must be an
/// ancestor of `name` in the topology. The infra-container branch is
/// orthogonal: it is gated on the `infra_admin` capability and audited, so it
/// stays ahead of the topology guard.
/// ancestor of `name` in the topology. Agents have no infra-container
/// restart path: an infra name here just falls through to the topology
/// guard like any other non-descendant name.
pub(super) async fn handle_restart(coord: &Arc<Coordinator>, agent: &str, name: &str) -> Response {
// Infra restart: an agent holding the `infra_admin` capability can
// restart a hive infrastructure service (hive-ci / hive-forge /
// hive-matrix) by passing its name to the 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. It recognises `hive-gateway` too, which
// is then refused — a name the agent surface knows but may not act on.
if let Ok(container) = name.parse::<hive_priv_sock::InfraContainer>() {
return handle_restart_infra(coord, agent, container).await;
}
if let Some(err) = require_descendant(agent, name, "restart") {
return err;
}
@ -51,73 +40,6 @@ pub(super) async fn handle_restart(coord: &Arc<Coordinator>, agent: &str, name:
Response::Ok
}
/// Restart a hive infrastructure container on behalf of an agent that
/// holds the `infra_admin` capability. The `container` is already a valid
/// [`hive_priv_sock::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: hive_priv_sock::InfraContainer,
) -> Response {
let name = container.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
// canonical row (or `None` on a sqlite blip), and we stream exactly that
// 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", name, outcome, detail)
{
coord.emit_audit_entry(entry);
}
};
// Some targets are off-limits to agents regardless of capability — the
// gateway, because nginx fronts every hive service from the host and an
// agent bouncing it takes out the forge, the dashboard and matrix at
// once, including the route its own fix would have to travel. Checked
// before the capability so the refusal doesn't read as "ask for
// infra_admin"; no capability grants this.
if !container.agent_restartable() {
tracing::warn!(%agent, %name, "agent: infra restart denied (not agent-restartable)");
audit(
crate::audit_log::AuditOutcome::Err,
Some("denied: target is not agent-restartable"),
);
return Response::Err {
message: format!("`{name}` cannot be restarted by an agent; ask the operator"),
};
}
if !crate::capabilities::has_cap(agent, hive_sh4re::permissions::Capability::InfraAdmin) {
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 Response::Err {
message: format!(
"restarting infra container `{name}` requires the `infra_admin` capability"
),
};
}
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);
Response::Ok
}
Err(e) => {
let msg = format!("{e:#}");
audit(crate::audit_log::AuditOutcome::Err, Some(&msg));
Response::Err { message: msg }
}
}
}
/// `Kill` — kill a container, unregister it, notify the swarm. The caller
/// must be an ancestor of `name` in the topology.
pub(super) async fn handle_kill(coord: &Arc<Coordinator>, agent: &str, name: &str) -> Response {

View file

@ -1,23 +1,20 @@
//! Sqlite-backed audit trail of agent-initiated privileged actions.
//! Sqlite-backed audit trail of privileged actions worth a durable,
//! operator-visible who/what/when record beyond hive-priv's low-level
//! journal trace — currently the dashboard's operator-driven infra
//! container start/stop (`dashboard::infra_containers::post_infra_container`).
//!
//! Surfaces, durably and operator-visibly, the privileged operations
//! hive-c0re performs *on behalf of an agent* — the ones that cross the
//! agent/operator trust boundary and so warrant a who/what/when record
//! beyond hive-priv's low-level journal trace. First entry: infra
//! container restarts via the `infra_admin`-gated `restart` tool (the
//! follow-up audit trail for that capability).
//!
//! Deliberately scoped to *agent-initiated* privileged actions. The bulk
//! of `PrivRequest` traffic (token writes, nspawn-flag edits) fires
//! constantly during normal lifecycle and is hive-c0re's own bookkeeping,
//! not an agent crossing the boundary — logging all of it would drown the
//! signal the operator actually wants.
//! Deliberately narrow: the bulk of `PrivRequest` traffic (token writes,
//! nspawn-flag edits) fires constantly during normal lifecycle and is
//! hive-c0re's own bookkeeping, not a privileged action worth a standalone
//! record — logging all of it would drown the signal the operator
//! actually wants. Nothing agent-initiated lands here today; the module
//! stays generic for whatever privileged action needs this record next.
//!
//! Same process-singleton handle pattern as `build_logs`: installed once
//! at `Coordinator::open`, and fetched by recording sites (e.g.
//! `socket_server::handle_restart_infra`) so they don't have to thread an
//! `Arc<AuditLog>` through every call path. Recording is best-effort: a
//! sqlite blip must never fail the underlying privileged action.
//! at `Coordinator::open`, and fetched by recording sites so they don't
//! have to thread an `Arc<AuditLog>` through every call path. Recording is
//! best-effort: a sqlite blip must never fail the underlying privileged
//! action.
use std::path::Path;
use std::sync::{Arc, Mutex, OnceLock};
@ -85,9 +82,10 @@ impl AuditOutcome {
pub struct AuditEntry {
pub id: i64,
pub ts_unix: DateTime<Utc>,
/// Agent on whose behalf the action was taken.
/// Actor who took the action (e.g. `"operator"`, or an agent name for
/// a future agent-initiated entry).
pub agent: String,
/// What was done (e.g. `restart_infra`).
/// What was done (e.g. `stop_infra`).
pub action: String,
/// What it acted on (e.g. `hive-ci`).
pub target: String,
@ -267,15 +265,15 @@ mod tests {
let (_d, db) = tmpdb();
// record() returns the canonical inserted row (id + ts assigned).
let entry = db
.record("atlas", "restart_infra", "hive-ci", AuditOutcome::Ok, None)
.record("operator", "stop_infra", "hive-ci", AuditOutcome::Ok, None)
.expect("record returns the inserted entry");
assert!(entry.id > 0);
assert_eq!(entry.target, "hive-ci");
assert_eq!(entry.outcome, "ok");
assert!(entry.detail.is_none());
let _ = db.record(
"atlas",
"restart_infra",
"operator",
"stop_infra",
"hive-gateway",
AuditOutcome::Err,
Some("systemctl failed"),
@ -289,8 +287,8 @@ mod tests {
assert_eq!(rows[1].target, "hive-ci");
assert_eq!(rows[1].outcome, "ok");
assert!(rows[1].detail.is_none());
assert_eq!(rows[0].agent, "atlas");
assert_eq!(rows[0].action, "restart_infra");
assert_eq!(rows[0].agent, "operator");
assert_eq!(rows[0].action, "stop_infra");
assert_eq!(db.count_total().expect("count"), 2);
}
@ -305,7 +303,7 @@ mod tests {
#[test]
fn vacuum_drops_only_old_rows() {
let (_d, db) = tmpdb();
let _ = db.record("a", "restart_infra", "hive-ci", AuditOutcome::Ok, None);
let _ = db.record("operator", "stop_infra", "hive-ci", AuditOutcome::Ok, None);
// Backdate it past the retention window.
{
let conn = db.conn.lock().unwrap();
@ -315,7 +313,13 @@ mod tests {
)
.unwrap();
}
let _ = db.record("a", "restart_infra", "hive-forge", AuditOutcome::Ok, None);
let _ = db.record(
"operator",
"stop_infra",
"hive-forge",
AuditOutcome::Ok,
None,
);
let removed = db.vacuum().expect("vacuum");
assert_eq!(removed, 1, "only the backdated row should be reaped");
let rows = db.list_recent(10).expect("list");