feat: add infra container start/stop/restart tab to C0R3 page

New POST /api/infra-container/{name}/{action} dashboard route (start/
stop/restart on hive-ci/hive-forge/hive-gateway/hive-matrix), reusing
the existing priv_client::control_infra_container helper the
infra_admin agent path already uses, plus an audit_log entry per
attempt. Adds infra_containers to the /api/state StateSnapshot (name +
live running status via systemctl is-active). New 1NFR4 sub-tab on the
C0R3 dashboard page: one row per infra container with a running/
stopped badge and start/stop/restart buttons, polled every 5s while
the sub-tab is open.
This commit is contained in:
iris 2026-07-11 20:28:27 +02:00 committed by mara
commit ef14641b94
7 changed files with 229 additions and 5 deletions

View file

@ -0,0 +1,63 @@
//! Dashboard endpoint for operator-driven infra-container 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.
use axum::{
extract::{Path as AxumPath, State},
http::StatusCode,
response::{IntoResponse, Response},
};
use hive_sh4re::priv_proto::{InfraAction, InfraContainer};
use super::{AppState, error_response};
/// `POST /api/infra-container/{name}/{action}` — start / stop / restart 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.
pub(super) async fn post_infra_container(
State(state): State<AppState>,
AxumPath((name, action)): AxumPath<(String, String)>,
) -> Response {
let Ok(container) = name.parse::<InfraContainer>() else {
return error_response(&format!("unknown infra container: {name}"));
};
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)"
));
}
};
let unit = container.unit_name();
tracing::info!(%unit, %action, "dashboard: infra container action");
let result = crate::priv_client::control_infra_container(container, infra_action).await;
let outcome = if result.is_ok() {
crate::audit_log::AuditOutcome::Ok
} else {
crate::audit_log::AuditOutcome::Err
};
let detail = result.as_ref().err().map(|e| format!("{e:#}"));
if let Some(entry) =
state
.coord
.audit_log
.record("operator", action_label, unit, outcome, detail.as_deref())
{
state.coord.emit_audit_entry(entry);
}
match result {
Ok(()) => (StatusCode::OK, "ok").into_response(),
Err(e) => error_response(&format!("{unit}: {e:#}")),
}
}

View file

@ -18,6 +18,7 @@ use crate::lifecycle;
mod approvals;
mod build_logs;
mod infra_containers;
mod journal;
mod lifecycle_ops;
mod matrix_accounts;
@ -182,6 +183,10 @@ pub async fn serve(
.route("/api/start/{name}", post(lifecycle_ops::post_start))
.route("/api/rebuild/{name}", post(lifecycle_ops::post_rebuild))
.route("/api/update-all", post(lifecycle_ops::post_update_all))
.route(
"/api/infra-container/{name}/{action}",
post(infra_containers::post_infra_container),
)
.route(
"/api/answer-question/{id}",
post(questions::post_answer_question),

View file

@ -126,6 +126,34 @@ pub(super) struct StateSnapshot {
/// `host_stats::server_warnings`; the frontend renders this list
/// generically, so new warning kinds need no frontend change.
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.
infra_containers: Vec<InfraContainerView>,
}
/// One row for the C0R3 page's 1NFR4 sub-tab.
#[derive(Serialize)]
struct InfraContainerView {
/// Container / systemd-unit name (e.g. `"hive-ci"`).
name: &'static str,
running: bool,
}
/// Live running/stopped status for all four hive infra containers.
/// Extracted out of [`api_state`] to keep it under clippy's
/// `too_many_lines` limit.
async fn infra_container_views() -> Vec<InfraContainerView> {
let mut infra_containers =
Vec::with_capacity(hive_sh4re::priv_proto::InfraContainer::ALL.len());
for container in hive_sh4re::priv_proto::InfraContainer::ALL {
infra_containers.push(InfraContainerView {
name: container.unit_name(),
running: crate::lifecycle::infra_is_running(container).await,
});
}
infra_containers
}
/// One peer hive for the P33RS dashboard tab. Derived from
@ -357,6 +385,8 @@ pub(super) async fn api_state(
w
};
let infra_containers = infra_container_views().await;
axum::Json(StateSnapshot {
seq,
hostname,
@ -398,6 +428,7 @@ pub(super) async fn api_state(
.filter(|s| !s.is_empty()),
peer_hives: parse_peer_hives(),
server_warnings,
infra_containers,
})
}

View file

@ -582,6 +582,21 @@ pub async fn is_running(name: &str) -> bool {
.is_ok_and(|s| s.success())
}
/// True when a hive infrastructure container's systemd unit is active.
/// Sibling of [`is_running`] for sub-agents, but infra container/unit names
/// (`hive-ci`, …) already have no `h-` prefix to strip, so this queries
/// `container@<unit_name>.service` directly rather than going through
/// [`container_name`]. Used by the dashboard C0R3 page's 1NFR4 sub-tab to
/// show each infra container's live status dot.
pub async fn infra_is_running(container: hive_sh4re::priv_proto::InfraContainer) -> bool {
let unit = format!("container@{}.service", container.unit_name());
Command::new("systemctl")
.args(["is-active", "--quiet", &unit])
.status()
.await
.is_ok_and(|s| s.success())
}
/// Fully tear down a sub-agent's container: stop + remove via `nixos-container
/// destroy`, then clean our own systemd drop-in. Leaves it to the caller to
/// wipe `/var/lib/hyperhive/...` state and the per-agent runtime dir.