456 lines
18 KiB
Rust
456 lines
18 KiB
Rust
//! Hyperhive dashboard. Lists managed containers (with deep-links to each
|
|
//! container's web UI), pending approvals (with unified diff vs the applied
|
|
//! repo, plus approve/deny buttons), and the manager.
|
|
|
|
use std::net::SocketAddr;
|
|
use std::sync::Arc;
|
|
|
|
use anyhow::{Context, Result};
|
|
use axum::{
|
|
Router,
|
|
http::StatusCode,
|
|
response::{IntoResponse, Response},
|
|
routing::{get, post},
|
|
};
|
|
use utoipa::OpenApi;
|
|
use utoipa_axum::{router::OpenApiRouter, routes};
|
|
use utoipa_swagger_ui::SwaggerUi;
|
|
|
|
use crate::coordinator::Coordinator;
|
|
use crate::lifecycle;
|
|
|
|
/// Root of the auto-generated `OpenAPI` spec (`/api/openapi.json`, browsable
|
|
/// at `/api/docs`) — see [`utoipa`]. Only routes carrying a
|
|
/// `#[utoipa::path(...)]` annotation show up; the rest of the (much
|
|
/// larger) route table below is undocumented for now. Deliberately
|
|
/// incremental: an unannotated route just doesn't appear in the spec,
|
|
/// nothing breaks, so routes get annotated as a series of small
|
|
/// follow-ups rather than one mega-diff.
|
|
#[derive(OpenApi)]
|
|
#[openapi(
|
|
info(
|
|
title = "hyperhive dashboard API",
|
|
description = "hive-c0re's HTTP surface, served on the loopback \
|
|
dashboard port behind the gateway's /api/ + \
|
|
/health/ proxy prefixes."
|
|
),
|
|
tags(
|
|
(name = "health", description = "hive-wide liveness/readiness probes"),
|
|
(name = "journal", description = "container + host journal reads"),
|
|
(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 = "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"),
|
|
(name = "misc_api", description = "operator inbox, compose, spawn-request, hive stats, audit log"),
|
|
(name = "permissions", description = "tool-group + capability assignment for agents"),
|
|
(name = "questions", description = "answer/cancel pending operator questions"),
|
|
(name = "schedules", description = "scheduled-prompt + rebuild-queue CRUD"),
|
|
(name = "state_files", description = "proxied reads of allow-listed per-agent state files"),
|
|
(name = "state_snapshot", description = "cold-load dashboard snapshot"),
|
|
(name = "tombstones", description = "purge of retained state for destroyed agents"),
|
|
(name = "topology", description = "operator-driven agent reparenting"),
|
|
(name = "webhook", description = "forgejo webhook receivers"),
|
|
)
|
|
)]
|
|
struct ApiDoc;
|
|
|
|
mod approvals;
|
|
mod build_logs;
|
|
mod extra_forges;
|
|
// The single validated identifier type — homed in `hive-host-sock` (the crate
|
|
// owning agent-path facts) so every dashboard path-param validates through the
|
|
// same type used to build agent paths. Re-exported so submodules + the socket
|
|
// server reach it as `crate::dashboard::Ident`.
|
|
pub(crate) use hive_types::Ident;
|
|
mod health;
|
|
mod infra_containers;
|
|
mod journal;
|
|
mod lifecycle_ops;
|
|
mod matrix_accounts;
|
|
mod meta_inputs;
|
|
mod misc_api;
|
|
pub(crate) mod permissions;
|
|
mod questions;
|
|
mod schedules;
|
|
mod state_files;
|
|
mod state_snapshot;
|
|
mod tombstones;
|
|
mod topology;
|
|
mod webhook;
|
|
|
|
// Run after lock bumps by the job queue (`job_queue/exec.rs`); the view
|
|
// type feeds `DashboardEvent::MetaInputsChanged` (`dashboard_events.rs`).
|
|
// Re-exported to preserve the `crate::dashboard::*` paths across the split.
|
|
pub use meta_inputs::MetaInputView;
|
|
pub(crate) use meta_inputs::emit_meta_inputs_snapshot;
|
|
// Run at broker-message ingest by the coordinator + the operator-msg path
|
|
// (`main.rs`); re-exported to preserve the `crate::dashboard::scan_validated_paths`
|
|
// path across the split.
|
|
pub use state_files::scan_validated_paths;
|
|
// Called after destroy/purge/spawn finalisation (`actions.rs`); the view
|
|
// type feeds `DashboardEvent::TombstonesChanged` (`dashboard_events.rs`).
|
|
// Re-exported to preserve the `crate::dashboard::*` paths across the split.
|
|
pub use tombstones::TombstoneView;
|
|
pub(crate) use tombstones::emit_tombstones_snapshot;
|
|
|
|
#[derive(Clone)]
|
|
struct AppState {
|
|
coord: Arc<Coordinator>,
|
|
/// HMAC-SHA256 secret shared with Forgejo webhook registrations.
|
|
/// Verified on every incoming `/webhook/*` POST.
|
|
/// `None` when the secret could not be loaded at startup — all
|
|
/// `/webhook/*` requests are rejected with 503 in that case.
|
|
webhook_secret: Option<String>,
|
|
}
|
|
|
|
#[allow(
|
|
clippy::too_many_lines,
|
|
reason = "the body is dominated by the flat axum route table — one line \
|
|
per endpoint mapping a URL to its (now per-concern submodule) \
|
|
handler; splitting that exhaustive list across helpers would \
|
|
obscure the route map for no readability gain"
|
|
)]
|
|
pub async fn serve(
|
|
port: u16,
|
|
coord: Arc<Coordinator>,
|
|
webhook_secret: Option<String>,
|
|
) -> Result<()> {
|
|
// API-only: the gateway static-serves the dashboard dist and proxies
|
|
// non-static requests here (see hive-gateway.nix). Unmatched paths 404.
|
|
// The four `#[utoipa::path]`-annotated routes are registered further
|
|
// down via `OpenApiRouter` instead of the plain `.route(...)` calls
|
|
// below — see the `router`/`api` split at the end of this fn.
|
|
let app = Router::new()
|
|
.route("/api/state", get(state_snapshot::api_state))
|
|
.route("/api/state-file", get(state_files::get_state_file))
|
|
.route(
|
|
"/api/matrix-accounts",
|
|
get(matrix_accounts::get_matrix_accounts),
|
|
)
|
|
.route("/api/extra-forges", get(extra_forges::get_extra_forges))
|
|
.route(
|
|
"/api/extra-forge-account",
|
|
post(extra_forges::post_extra_forge_account),
|
|
)
|
|
.route("/api/operator-inbox", get(misc_api::api_operator_inbox))
|
|
.route("/api/stats-hive", get(misc_api::api_stats_hive))
|
|
.route(
|
|
"/api/container-resources",
|
|
get(misc_api::api_container_resources),
|
|
)
|
|
.route("/api/audit-log", get(misc_api::api_audit_log))
|
|
.route("/api/build-logs", get(build_logs::get_build_logs_all))
|
|
.route(
|
|
"/api/build-log/{node_id}",
|
|
get(build_logs::get_build_log_for_node),
|
|
)
|
|
.route(
|
|
"/api/build-log/{node_id}/raw",
|
|
get(build_logs::get_build_log_raw_for_node),
|
|
)
|
|
.route(
|
|
"/api/build-logs/{agent}",
|
|
get(build_logs::get_build_logs_agent),
|
|
)
|
|
.route(
|
|
"/api/build-logs/id/{id}",
|
|
get(build_logs::get_build_log_full),
|
|
)
|
|
.route(
|
|
"/api/build-logs/id/{id}/stream",
|
|
get(build_logs::get_build_log_stream),
|
|
)
|
|
.route(
|
|
"/api/build-logs/id/{id}/raw",
|
|
get(build_logs::get_build_log_raw),
|
|
)
|
|
.route(
|
|
"/api/agent/{name}/mark-all-read",
|
|
post(misc_api::post_mark_all_read),
|
|
)
|
|
.route("/api/topology/set-parent", post(topology::post_set_parent))
|
|
.route(
|
|
"/api/topology/set-parent-bulk",
|
|
post(topology::post_set_parent_bulk),
|
|
)
|
|
.route("/api/tool-groups", get(permissions::get_tool_groups))
|
|
.route(
|
|
"/api/tool-groups/{agent}",
|
|
post(permissions::post_tool_groups),
|
|
)
|
|
.route("/api/capabilities", get(permissions::get_capabilities))
|
|
.route(
|
|
"/api/capabilities/{agent}",
|
|
post(permissions::post_capabilities),
|
|
)
|
|
.route("/api/permissions", post(permissions::post_permissions))
|
|
.route(
|
|
"/api/permissions/stale",
|
|
get(permissions::get_stale_permissions),
|
|
)
|
|
.route(
|
|
"/api/permissions/{agent}",
|
|
axum::routing::delete(permissions::delete_agent_permissions),
|
|
)
|
|
.route(
|
|
"/api/schedules",
|
|
get(schedules::api_schedules).post(schedules::post_schedule_new),
|
|
)
|
|
.route(
|
|
"/api/schedules/{id}",
|
|
axum::routing::patch(schedules::patch_schedule),
|
|
)
|
|
.route(
|
|
"/api/schedules/{id}/cancel",
|
|
post(schedules::post_schedule_cancel),
|
|
)
|
|
.route(
|
|
"/api/schedules/{id}/pause",
|
|
post(schedules::post_schedule_pause),
|
|
)
|
|
.route(
|
|
"/api/schedules/{id}/resume",
|
|
post(schedules::post_schedule_resume),
|
|
)
|
|
.route(
|
|
"/api/schedules/{id}/fire-now",
|
|
post(schedules::post_schedule_fire_now),
|
|
)
|
|
.route(
|
|
"/api/rebuild-queue/{id}/cancel",
|
|
post(schedules::post_rebuild_queue_cancel),
|
|
)
|
|
.route("/webhook/knowledge", post(webhook::post_webhook_knowledge))
|
|
.route("/webhook/config-pr", post(webhook::post_webhook_config_pr))
|
|
// Backend routes — the frontend calls these `/api/` paths. The
|
|
// transitional bare top-level aliases were removed once the
|
|
// frontend migrated. `/webhook/*` keeps its own prefix
|
|
// (forge-driven, not the SPA).
|
|
.route("/api/approve/{id}", post(approvals::post_approve))
|
|
.route("/api/deny/{id}", post(approvals::post_deny))
|
|
.route("/api/destroy/{name}", post(lifecycle_ops::post_destroy))
|
|
.route("/api/kill/{name}", post(lifecycle_ops::post_kill))
|
|
.route("/api/restart/{name}", post(lifecycle_ops::post_restart))
|
|
.route("/api/start/{name}", post(lifecycle_ops::post_start))
|
|
.route("/api/rebuild/{name}", post(lifecycle_ops::post_rebuild))
|
|
.route("/api/pause/{name}", post(lifecycle_ops::post_pause))
|
|
.route("/api/resume/{name}", post(lifecycle_ops::post_resume))
|
|
.route(
|
|
"/api/resource-limits/{name}",
|
|
post(lifecycle_ops::post_resource_limits),
|
|
)
|
|
.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),
|
|
)
|
|
.route(
|
|
"/api/cancel-question/{id}",
|
|
post(questions::post_cancel_question),
|
|
)
|
|
.route(
|
|
"/api/purge-tombstone/{name}",
|
|
post(tombstones::post_purge_tombstone),
|
|
)
|
|
.route(
|
|
"/api/matrix-account-login",
|
|
post(matrix_accounts::post_matrix_account_login),
|
|
)
|
|
.route(
|
|
"/api/github-account",
|
|
post(matrix_accounts::post_github_account).get(matrix_accounts::get_github_account),
|
|
)
|
|
.route("/api/request-spawn", post(misc_api::post_request_spawn))
|
|
.route("/api/op-send", post(misc_api::post_op_send))
|
|
.route("/api/meta-update", post(meta_inputs::post_meta_update))
|
|
.route(
|
|
"/api/dashboard/stream",
|
|
get(state_snapshot::dashboard_stream),
|
|
)
|
|
.route(
|
|
"/api/dashboard/history",
|
|
get(state_snapshot::dashboard_history),
|
|
)
|
|
// No static fallback — the gateway owns the dist; unmatched paths 404.
|
|
;
|
|
|
|
let (router, api) = OpenApiRouter::<AppState>::with_openapi(ApiDoc::openapi())
|
|
.routes(routes!(
|
|
health::get_health_live,
|
|
health::get_health_ready,
|
|
journal::get_journal,
|
|
journal::get_journal_host,
|
|
))
|
|
.merge(app.into())
|
|
.split_for_parts();
|
|
let app = router
|
|
.merge(SwaggerUi::new("/api/docs").url("/api/openapi.json", api))
|
|
.with_state(AppState {
|
|
coord,
|
|
webhook_secret,
|
|
});
|
|
// Binds loopback-only; external access via gateway.
|
|
// Rationale: docs/gateway.md::Firewall posture.
|
|
let addr = SocketAddr::from(([127, 0, 0, 1], port));
|
|
let listener = bind_with_retry(addr).await?;
|
|
tracing::info!(%addr, "dashboard listening");
|
|
axum::serve(listener, app).await?;
|
|
Ok(())
|
|
}
|
|
|
|
// SPA shape + SSE channels: docs/web-ui/shape.md.
|
|
|
|
/// `SO_REUSEADDR` bind with retry. Retry mechanics, attempt-cap
|
|
/// rationale, and log-level cadence: `docs/web-ui/shape.md::Listener bind`.
|
|
async fn bind_with_retry(addr: SocketAddr) -> Result<tokio::net::TcpListener> {
|
|
let mut delay_ms = 250u64;
|
|
let mut attempts = 0u32;
|
|
loop {
|
|
match try_bind(addr) {
|
|
Ok(l) => {
|
|
if attempts > 0 {
|
|
tracing::info!(
|
|
%addr, attempts,
|
|
"dashboard: bind succeeded after retry"
|
|
);
|
|
}
|
|
return Ok(l);
|
|
}
|
|
Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => {
|
|
let attempt = attempts + 1;
|
|
if attempt <= 12 {
|
|
tracing::warn!(
|
|
%addr, attempt,
|
|
"dashboard: AddrInUse, retrying in {delay_ms}ms"
|
|
);
|
|
} else {
|
|
tracing::info!(
|
|
%addr, attempt,
|
|
"dashboard: AddrInUse still holding, retrying in {delay_ms}ms"
|
|
);
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
|
|
attempts += 1;
|
|
delay_ms = (delay_ms * 2).min(2000);
|
|
}
|
|
Err(e) => {
|
|
return Err(e).with_context(|| format!("bind dashboard on {addr}"));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn try_bind(addr: SocketAddr) -> std::io::Result<tokio::net::TcpListener> {
|
|
let sock = match addr {
|
|
SocketAddr::V4(_) => tokio::net::TcpSocket::new_v4()?,
|
|
SocketAddr::V6(_) => tokio::net::TcpSocket::new_v6()?,
|
|
};
|
|
sock.set_reuseaddr(true)?;
|
|
sock.bind(addr)?;
|
|
sock.listen(1024)
|
|
}
|
|
|
|
/// Two-axis path-param guard for write routes. Combines:
|
|
///
|
|
/// 1. **format validation** ([`Ident::parse`]) — rejects
|
|
/// path traversal / unicode homoglyphs / empty + too-long names with
|
|
/// HTTP 400.
|
|
/// 2. **existence check** — looks up `name` in the coordinator's
|
|
/// container snapshot; unknown name → HTTP 404 with a clear
|
|
/// "no such agent" message. catches the operator-typo case where
|
|
/// a destructive POST would otherwise hit silently (mark-all-read
|
|
/// returning 0) or hit downstream lifecycle code that fails with
|
|
/// a confusing nspawn error.
|
|
///
|
|
/// Returns `None` when both checks pass (caller proceeds), `Some(Response)`
|
|
/// when the request should be rejected. Use at the top of every write
|
|
/// handler taking a name path-param. Read-only GET handlers and
|
|
/// handlers that legitimately operate on tombstoned agents (e.g.
|
|
/// `mark-all-read` on broker rows for a destroyed agent) call
|
|
/// [`Ident::parse`] directly and skip the existence check.
|
|
async fn guard_agent_name(state: &AppState, name: &str) -> Option<Response> {
|
|
if let Err(reason) = Ident::parse(name) {
|
|
return Some(
|
|
(StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response(),
|
|
);
|
|
}
|
|
let snapshot = state.coord.containers_snapshot().await;
|
|
if !snapshot.iter().any(|c| c.name == name) {
|
|
return Some((StatusCode::NOT_FOUND, format!("no such agent: {name}")).into_response());
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Convert either a logical name or a container name back to the logical
|
|
/// name. Sub-agents are `h-foo` → `foo`; manager stays `root`.
|
|
fn strip_container_prefix(name: &str) -> String {
|
|
name.strip_prefix(lifecycle::AGENT_PREFIX)
|
|
.unwrap_or(name)
|
|
.to_owned()
|
|
}
|
|
|
|
/// The common internal-error case as a `ProblemDetails`: a 500 RFC 9457
|
|
/// (`application/problem+json`) value via the `problem_details` crate.
|
|
/// `from_status_code` sets `status` + `title` (the canonical reason phrase)
|
|
/// and leaves `type` as the default `about:blank`; `with_detail` carries the
|
|
/// caller message; the crate's axum `IntoResponse` emits the
|
|
/// `application/problem+json` body the frontend parses (it reads `detail`).
|
|
/// Handlers that surface client failures return `Result<_, ProblemDetails>`
|
|
/// and hand this (or an inline `from_status_code(4xx)`) straight to `Err` —
|
|
/// no manual `.into_response()`.
|
|
fn error_problem(message: &str) -> problem_details::ProblemDetails {
|
|
problem_details::ProblemDetails::from_status_code(StatusCode::INTERNAL_SERVER_ERROR)
|
|
.with_detail(message)
|
|
}
|
|
|
|
/// `Response` wrapper around [`error_problem`] for the many handlers typed
|
|
/// `-> Response` whose only failure mode is a 500 — they funnel errors
|
|
/// through here rather than threading a `Result` return type.
|
|
fn error_response(message: &str) -> Response {
|
|
error_problem(message).into_response()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn problem_details_carry_rfc9457_status_and_detail() {
|
|
// Contract the frontend depends on: the problem_details crate
|
|
// serialises the RFC 9457 members we rely on — `status` (numeric)
|
|
// and `detail` (the caller message; the FE reads `.detail`).
|
|
let pd = problem_details::ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
|
|
.with_detail("bad input");
|
|
let v = serde_json::to_value(&pd).expect("problem details serialise");
|
|
assert_eq!(v["status"], 400);
|
|
assert_eq!(v["detail"], "bad input");
|
|
// The 500 wrapper path carries the internal-error status.
|
|
let five =
|
|
problem_details::ProblemDetails::from_status_code(StatusCode::INTERNAL_SERVER_ERROR)
|
|
.with_detail("boom");
|
|
let fv = serde_json::to_value(&five).expect("problem details serialise");
|
|
assert_eq!(fv["status"], 500);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod router_build_probe {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn probe_multi_path_routes_macro_does_not_panic() {
|
|
let _ = OpenApiRouter::<AppState>::with_openapi(ApiDoc::openapi()).routes(routes!(
|
|
health::get_health_live,
|
|
health::get_health_ready,
|
|
journal::get_journal,
|
|
journal::get_journal_host,
|
|
));
|
|
}
|
|
}
|