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
|
|
@ -365,7 +365,6 @@ that allows the underlying resource access.
|
|||
| `manage_root_agent` | may lifecycle-manage the root/manager agent via `kill`/`start`/`restart` |
|
||||
| `read_host_journal` | `get_host_journal` MCP tool is registered + `GET /journal-host` requests are served |
|
||||
| `query_agent_state` | may call `get_loose_ends` / `CountPendingReminders` targeting non-child agents |
|
||||
| `infra_admin` | may call `restart(name)` on hive infrastructure containers (`hive-ci`, `hive-forge`, `hive-matrix` — **not** `hive-gateway`, which is the host's nginx and is operator-only); each restart is logged to the dashboard AUDIT trail |
|
||||
|
||||
**Config storage** — per-agent capabilities live in
|
||||
`/var/lib/hyperhive/meta/capabilities.json` alongside `tool-groups.json`.
|
||||
|
|
|
|||
|
|
@ -159,24 +159,17 @@ omitted — agents share the host netns, so there is no per-container net
|
|||
counter (per-agent network needs the netns-isolation roadmap in
|
||||
`docs/network.md`).
|
||||
|
||||
**1NFR4** — start / stop / restart the four hive infrastructure services
|
||||
(`hive-ci`, `hive-forge`, `hive-gateway`, `hive-matrix`) directly from the
|
||||
dashboard, without needing an `infra_admin` agent's `restart` MCP tool.
|
||||
Three are containers; `hive-gateway` is the host's `nginx.service`, and is
|
||||
the one an agent may **not** restart — this panel is the way it gets
|
||||
bounced. One row per service: name, a `badge-ok`/`badge-fail`
|
||||
running/stopped dot, and `↺ R3ST4RT` + `■ ST0P` (running) or `▶ ST4RT`
|
||||
(stopped) buttons, same themed-confirm pattern as the K3PT ST4T3
|
||||
tombstone actions. Backed by
|
||||
`POST /api/infra-container/{name}/{action}` (`action` ∈
|
||||
`start|stop|restart`), which calls the same
|
||||
`priv_client::control_infra_container` helper the agent-facing
|
||||
`infra_admin` path uses — no new privileged-helper surface, no
|
||||
capability check (the dashboard is already operator-authenticated).
|
||||
**1NFR4** — start / stop the four hive infrastructure services (`hive-ci`,
|
||||
`hive-forge`, `hive-gateway`, `hive-matrix`) directly from the dashboard —
|
||||
operator-only, no agent-facing equivalent. One row per service: name, a
|
||||
`badge-ok`/`badge-fail` running/stopped dot, and `■ ST0P` (running) or
|
||||
`▶ ST4RT` (stopped) buttons, same themed-confirm pattern as the K3PT ST4T3
|
||||
tombstone actions. Backed by `POST /api/infra-container/{name}/{action}`
|
||||
(`action` ∈ `start|stop`), which calls `priv_client::control_infra_container`
|
||||
— no capability check (the dashboard is already operator-authenticated).
|
||||
Every attempt is written to the audit log (actor `"operator"`, action
|
||||
`start_infra`/`stop_infra`/`restart_infra`) alongside agent-driven infra
|
||||
restarts. Status rows ride the `infra_containers` field on
|
||||
`GET /api/state`'s `StateSnapshot` (`{name, running}`, live
|
||||
`start_infra`/`stop_infra`). Status rows ride the `infra_containers` field
|
||||
on `GET /api/state`'s `StateSnapshot` (`{name, running}`, live
|
||||
`systemctl is-active container@<unit>.service` read); `core.js` polls
|
||||
`/api/state` every 5 s only while the 1NFR4 sub-tab is active, same
|
||||
cadence/lifecycle as C0NT41N3R L04D's polling.
|
||||
|
|
@ -440,7 +433,6 @@ The current capabilities are:
|
|||
| `manage_root_agent` | allows the `set_status` / lifecycle tools on the root agent |
|
||||
| `read_host_journal` | unlocks `get_host_journal` to read journald from inside a container |
|
||||
| `query_agent_state` | allows `get_loose_ends(agent: "<name>")` calls targeting other agents |
|
||||
| `infra_admin` | allows `restart` on hive infrastructure containers (`hive-ci`, `hive-forge`, `hive-matrix`; the gateway is operator-only); each restart is logged to the AUDIT trail |
|
||||
|
||||
Each row is one agent. Columns are the capability names returned by
|
||||
`GET /api/capabilities` as `caps: Vec<String>`. Checking or unchecking
|
||||
|
|
@ -754,17 +746,20 @@ chip ticks every 30 s. Available to the operator unconditionally (not
|
|||
capability-gated — the endpoint lives on the hive-c0re dashboard,
|
||||
behind the gateway).
|
||||
|
||||
**AUDIT sub-tab** — operator-visible trail of agent-initiated
|
||||
privileged actions (e.g. infra-container restarts via `infra_admin`).
|
||||
Lazy-fetched on tab show (like SYSTEM) from `GET /api/audit-log`, which
|
||||
returns `{ entries, total }` — `entries` newest-first, server-clamped to
|
||||
the latest 500; `total` drives a "latest 500 of N" count so the clamp is
|
||||
never silent. Rendered as a filterable table (when / agent / action /
|
||||
target / outcome / detail); the filter box is a client-side substring
|
||||
match over the cached rows. The outcome badge colours `ok` green and
|
||||
`err` red, with an `err` whose `detail` starts `denied:` (a capability
|
||||
refusal) shown amber and labelled `denied` so it reads apart from an
|
||||
execution failure. `ts_unix` is an RFC 3339 string; a 30 s ticker keeps the
|
||||
**AUDIT sub-tab** — operator-visible trail of privileged actions worth a
|
||||
durable who/what/when record (currently: infra-container start/stop from
|
||||
the 1NFR4 panel — see `hive-c0re/src/stores/audit_log.rs`'s doc comment
|
||||
for what's in scope). Lazy-fetched on tab show (like SYSTEM) from
|
||||
`GET /api/audit-log`, which returns `{ entries, total }` — `entries`
|
||||
newest-first, server-clamped to the latest 500; `total` drives a "latest
|
||||
500 of N" count so the clamp is never silent. Rendered as a filterable
|
||||
table (when / agent / action / target / outcome / detail); the filter box
|
||||
is a client-side substring match over the cached rows. The outcome badge
|
||||
colours `ok` green and `err` red, with an `err` whose `detail` starts
|
||||
`denied:` (a capability refusal) shown amber and labelled `denied` so it
|
||||
reads apart from an execution failure — generic styling for whichever
|
||||
future privileged action writes that prefix, nothing currently produces
|
||||
it. `ts_unix` is an RFC 3339 string; a 30 s ticker keeps the
|
||||
relative "ago" column honest while the tab is in view. The backing
|
||||
`audit_log` store records every privileged-action attempt (ok / err /
|
||||
denied). New entries live-append without a refresh: an `audit_entry_added`
|
||||
|
|
@ -1254,16 +1249,14 @@ below — some endpoints aren't in it yet.
|
|||
a background `du -sxb` of the agent's state dir + container writable
|
||||
rootfs every ~5 min, `-x` excluding the shared read-only nix store.
|
||||
`null` until the first sample lands.
|
||||
- `POST /api/infra-container/{name}/{action}` — start / stop / restart a
|
||||
hive infra service (C0R3 › 1NFR4 panel). `name` parses into the
|
||||
- `POST /api/infra-container/{name}/{action}` — start / stop a hive infra
|
||||
service (C0R3 › 1NFR4 panel, operator-only). `name` parses into the
|
||||
`InfraContainer` allowlist (`hive-ci`/`hive-forge`/`hive-gateway`/
|
||||
`hive-matrix`, 400 on unknown), and the variant decides the unit —
|
||||
`container@<name>.service`, or `nginx.service` for the gateway.
|
||||
`action` ∈ `start|stop|restart`. Calls
|
||||
the same `priv_client::control_infra_container` helper the
|
||||
`infra_admin` agent path uses; records an `audit_log` entry
|
||||
(`start_infra`/`stop_infra`/`restart_infra`, actor `"operator"`) either
|
||||
way.
|
||||
`action` ∈ `start|stop`. Calls `priv_client::control_infra_container`;
|
||||
records an `audit_log` entry (`start_infra`/`stop_infra`, actor
|
||||
`"operator"`) either way.
|
||||
- `POST /api/cancel-reminder/{id}` — hard-delete a pending reminder.
|
||||
- `POST /api/retry-reminder/{id}` — re-arm a reminder whose delivery
|
||||
failed (clears the failure state so the scheduler retries).
|
||||
|
|
|
|||
|
|
@ -55,11 +55,11 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 1NFR4: start / stop / restart the four hive infrastructure
|
||||
containers (hive-ci, hive-forge, hive-gateway, hive-matrix)
|
||||
directly from the dashboard, mirroring what an infra_admin
|
||||
agent's `restart` tool already reaches. Status polled every 5s
|
||||
while this sub-tab is open, same cadence as C0NT41N3R L04D. -->
|
||||
<!-- 1NFR4: start / stop the four hive infrastructure containers
|
||||
(hive-ci, hive-forge, hive-gateway, hive-matrix) directly from
|
||||
the dashboard — operator-only, no agent-facing equivalent.
|
||||
Status polled every 5s while this sub-tab is open, same cadence
|
||||
as C0NT41N3R L04D. -->
|
||||
<section class="core-pane" id="core-pane-infra" data-tab-pane="infra"
|
||||
role="tabpanel" aria-labelledby="core-tab-infra">
|
||||
<p class="meta">hive infrastructure containers — ci, forge, gateway, matrix. actions are logged to the AUDIT log same as agent-driven restarts.</p>
|
||||
|
|
|
|||
|
|
@ -381,7 +381,7 @@ function stopContainerLoadPolling() {
|
|||
if (containerLoadTimer) { clearInterval(containerLoadTimer); containerLoadTimer = null; }
|
||||
}
|
||||
|
||||
// ─── infra containers (start/stop/restart the 4 hive infra containers) ────
|
||||
// ─── infra containers (start/stop the 4 hive infra containers) ────────────
|
||||
let infraTimer = null;
|
||||
|
||||
function renderInfraContainers(rows) {
|
||||
|
|
@ -406,8 +406,6 @@ function renderInfraContainers(rows) {
|
|||
const actions = el('div', { class: 'actions' });
|
||||
const base = '/api/infra-container/' + encodeURIComponent(c.name) + '/';
|
||||
if (c.running) {
|
||||
actions.append(form(base + 'restart', 'btn-restart', '↺ R3ST4RT',
|
||||
'restart ' + c.name + '?'));
|
||||
actions.append(form(base + 'stop', 'btn-stop', '■ ST0P',
|
||||
'stop ' + c.name + '?'));
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -564,11 +564,7 @@ impl AgentServer {
|
|||
#[tool(
|
||||
description = "Restart a direct child sub-agent container (stop + start). \
|
||||
Only succeeds if `name` is a direct child of this agent in the topology \
|
||||
tree — the server enforces this. No approval required. \
|
||||
Agents holding the `infra_admin` capability may also pass a hive \
|
||||
infrastructure container name (`hive-ci`, `hive-forge`, `hive-matrix`) \
|
||||
to restart it directly via the privileged helper. The gateway is \
|
||||
not restartable by an agent — ask the operator."
|
||||
tree — the server enforces this. No approval required."
|
||||
)]
|
||||
async fn restart(&self, Parameters(args): Parameters<RestartArgs>) -> String {
|
||||
let log = format!("{args:?}");
|
||||
|
|
|
|||
|
|
@ -59,13 +59,6 @@ fn allowed_capability_tools() -> Vec<String> {
|
|||
let t = token.trim().to_ascii_lowercase();
|
||||
match t.as_str() {
|
||||
"read_host_journal" => tools.push("get_host_journal".to_owned()),
|
||||
// infra_admin lets an agent restart hive infrastructure
|
||||
// containers (hive-ci / hive-forge / hive-matrix — not the
|
||||
// gateway) through the existing `restart` tool. Unlock it here so agents that hold
|
||||
// the capability without the full `lifecycle` group can still
|
||||
// call it; c0re re-checks the capability server-side and only
|
||||
// honours infra-container names via this path.
|
||||
"infra_admin" => tools.push("restart".to_owned()),
|
||||
// manage_root_agent / query_agent_state don't expose new MCP
|
||||
// tools: manage_root_agent gates existing lifecycle tools via
|
||||
// topology enforcement; query_agent_state unlocks the `agent`
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -56,7 +56,6 @@ pub const SIBLING_CONTAINERS: &[&str] = &["hive-forge", "hive-matrix", "hive-ci"
|
|||
pub enum InfraAction {
|
||||
Start,
|
||||
Stop,
|
||||
Restart,
|
||||
}
|
||||
|
||||
impl InfraAction {
|
||||
|
|
@ -66,7 +65,6 @@ impl InfraAction {
|
|||
match self {
|
||||
InfraAction::Start => "start",
|
||||
InfraAction::Stop => "stop",
|
||||
InfraAction::Restart => "restart",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -150,28 +148,13 @@ impl InfraContainer {
|
|||
InfraTarget::HostUnit(unit) => unit.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an agent holding `infra_admin` may restart this target.
|
||||
///
|
||||
/// The gateway is excluded by operator ruling: nginx now fronts every
|
||||
/// hive service from the host, so an agent restarting it can take the
|
||||
/// forge, dashboard and matrix down with it — including the path its
|
||||
/// own PR would have to travel to fix it. The operator surface
|
||||
/// (`hivectl`, dashboard) is unaffected.
|
||||
#[must_use]
|
||||
pub fn agent_restartable(self) -> bool {
|
||||
!matches!(self, InfraContainer::Gateway)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for InfraContainer {
|
||||
type Err = ();
|
||||
|
||||
/// Parse an infra name (`hive-ci`, …) into a variant. Recognition
|
||||
/// only — it says the name denotes a hive service, *not* that the
|
||||
/// caller may act on it. The agent restart path additionally checks
|
||||
/// [`agent_restartable`](InfraContainer::agent_restartable).
|
||||
/// `Err(())` for anything that isn't one.
|
||||
/// Parse an infra name (`hive-ci`, …) into a variant. `Err(())` for
|
||||
/// anything that isn't one.
|
||||
fn from_str(s: &str) -> Result<Self, ()> {
|
||||
Self::ALL.into_iter().find(|c| c.name() == s).ok_or(())
|
||||
}
|
||||
|
|
@ -601,13 +584,13 @@ pub enum PrivRequest {
|
|||
token: String,
|
||||
},
|
||||
|
||||
/// Start / stop / restart a hive infrastructure container on the host
|
||||
/// via `systemctl <action> container@<container>.service`. The
|
||||
/// Start / stop a hive infrastructure container on the host via
|
||||
/// `systemctl <action> container@<container>.service`. The
|
||||
/// [`InfraContainer`] enum is the allowlist — serde rejects unknown /
|
||||
/// unsafe names (notably `hive-c0re`, which has no variant) at the wire
|
||||
/// boundary, so no root-side `.contains()` check is needed. Serves both
|
||||
/// the hive-wide `hivectl stop` / `hivectl start` flow and an
|
||||
/// `infra_admin` agent's `restart` (with `action = Restart`).
|
||||
/// boundary, so no root-side `.contains()` check is needed. Serves the
|
||||
/// hive-wide `hivectl stop` / `hivectl start` flow and the dashboard's
|
||||
/// operator-only infra panel. No agent-facing path exists.
|
||||
ControlInfraContainer {
|
||||
container: InfraContainer,
|
||||
action: InfraAction,
|
||||
|
|
@ -956,15 +939,4 @@ mod tests {
|
|||
);
|
||||
assert_eq!(InfraContainer::Gateway.service_unit(), "nginx.service");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_gateway_is_off_limits_to_agents() {
|
||||
// Recognising a name and being allowed to restart it are separate
|
||||
// questions — the gateway parses fine and is still refused.
|
||||
assert!("hive-gateway".parse::<InfraContainer>().is_ok());
|
||||
assert!(!InfraContainer::Gateway.agent_restartable());
|
||||
for c in InfraContainer::ALL {
|
||||
assert_eq!(c.agent_restartable(), c != InfraContainer::Gateway, "{c:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1318,13 +1318,13 @@ async fn register_ci_runner(token: &str) -> Result<(String, String)> {
|
|||
))
|
||||
}
|
||||
|
||||
/// `ControlInfraContainer` — start/stop/restart a hive infrastructure
|
||||
/// service via `systemctl <verb> <unit>`. The [`InfraContainer`] enum is
|
||||
/// the allowlist: serde already rejected any unknown / unsafe name
|
||||
/// (hive-c0re has no variant, so a stop can't sever the daemon socket) at
|
||||
/// deserialisation, so no root-side `.contains()` check is needed here.
|
||||
/// Serves both the hive-wide `hivectl stop`/`start` flow and an
|
||||
/// `infra_admin` agent's `restart` (action = Restart).
|
||||
/// `ControlInfraContainer` — start/stop a hive infrastructure service via
|
||||
/// `systemctl <verb> <unit>`. The [`InfraContainer`] enum is the allowlist:
|
||||
/// serde already rejected any unknown / unsafe name (hive-c0re has no
|
||||
/// variant, so a stop can't sever the daemon socket) at deserialisation, so
|
||||
/// no root-side `.contains()` check is needed here. Serves the hive-wide
|
||||
/// `hivectl stop`/`start` flow and the dashboard's operator-only infra
|
||||
/// panel. No agent-facing path exists.
|
||||
///
|
||||
/// ⚠️ The unit is derived from the variant, never sent by the caller —
|
||||
/// which is what keeps this from being a general `systemctl` pass-through.
|
||||
|
|
|
|||
|
|
@ -223,19 +223,6 @@ pub enum Capability {
|
|||
/// available on the agent socket even with this capability — use the
|
||||
/// manager socket for swarm-wide scans.
|
||||
QueryAgentState,
|
||||
/// Agent can restart hive infrastructure containers (hive-ci,
|
||||
/// hive-forge, hive-matrix) via the `restart` MCP tool. hive-c0re
|
||||
/// checks this capability before routing the restart through
|
||||
/// hive-priv; the concrete service allowlist lives root-side in
|
||||
/// hive-priv. Deliberately generic ("infra admin") so future
|
||||
/// privileged infra ops can hang off the same grant.
|
||||
///
|
||||
/// ⚠️ The gateway is **not** in reach of this capability, by operator
|
||||
/// ruling — it is the host's nginx and fronts the forge, dashboard and
|
||||
/// matrix, so an agent restarting it can cut the path its own fix
|
||||
/// travels. That refusal is a property of the target, not of the
|
||||
/// grant: no capability re-opens it.
|
||||
InfraAdmin,
|
||||
}
|
||||
|
||||
impl Capability {
|
||||
|
|
@ -245,7 +232,6 @@ impl Capability {
|
|||
Self::ManageRootAgent,
|
||||
Self::ReadHostJournal,
|
||||
Self::QueryAgentState,
|
||||
Self::InfraAdmin,
|
||||
];
|
||||
|
||||
/// Canonical `snake_case` name for this capability (matches serde).
|
||||
|
|
@ -255,7 +241,6 @@ impl Capability {
|
|||
Self::ManageRootAgent => "manage_root_agent",
|
||||
Self::ReadHostJournal => "read_host_journal",
|
||||
Self::QueryAgentState => "query_agent_state",
|
||||
Self::InfraAdmin => "infra_admin",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -270,9 +255,6 @@ impl Capability {
|
|||
Self::QueryAgentState => {
|
||||
"query non-child agents' loose ends and reminder state via get_loose_ends"
|
||||
}
|
||||
Self::InfraAdmin => {
|
||||
"restart hive infrastructure containers (hive-ci, hive-forge, hive-matrix; not the gateway) via the restart tool"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue