remove hive-level infra-container restart from web ui and agents
This commit is contained in:
parent
ccf9951e5d
commit
7516a4e10e
17 changed files with 112 additions and 272 deletions
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in a new issue