hive-c0re: wire up openapi spec + swagger ui (#2872)

This commit is contained in:
damocles 2026-07-31 21:39:46 +02:00 committed by mara
commit 44651544a8
7 changed files with 256 additions and 8 deletions

159
Cargo.lock generated
View file

@ -135,6 +135,15 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "archery"
version = "1.2.2"
@ -981,6 +990,17 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "derive_more"
version = "1.0.0"
@ -1256,6 +1276,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
"zlib-rs",
]
[[package]]
@ -1656,6 +1677,9 @@ dependencies = [
"tracing",
"tracing-subscriber",
"url",
"utoipa",
"utoipa-axum",
"utoipa-swagger-ui",
]
[[package]]
@ -3075,6 +3099,12 @@ dependencies = [
"windows-link",
]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pastey"
version = "0.2.3"
@ -3890,6 +3920,41 @@ dependencies = [
"smallvec",
]
[[package]]
name = "rust-embed"
version = "8.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9"
dependencies = [
"rust-embed-impl",
"rust-embed-utils",
"walkdir",
]
[[package]]
name = "rust-embed-impl"
version = "8.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097"
dependencies = [
"mime_guess",
"proc-macro2",
"quote",
"rust-embed-utils",
"syn 2.0.119",
"walkdir",
]
[[package]]
name = "rust-embed-utils"
version = "8.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0"
dependencies = [
"sha2 0.11.0",
"walkdir",
]
[[package]]
name = "rustc-hash"
version = "2.1.3"
@ -4972,6 +5037,68 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "utoipa"
version = "5.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160"
dependencies = [
"indexmap",
"serde",
"serde_json",
"utoipa-gen",
]
[[package]]
name = "utoipa-axum"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c25bae5bccc842449ec0c5ddc5cbb6a3a1eaeac4503895dc105a1138f8234a0"
dependencies = [
"axum",
"paste",
"tower-layer",
"tower-service",
"utoipa",
]
[[package]]
name = "utoipa-gen"
version = "5.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8"
dependencies = [
"proc-macro2",
"quote",
"regex",
"syn 2.0.119",
]
[[package]]
name = "utoipa-swagger-ui"
version = "9.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d047458f1b5b65237c2f6dc6db136945667f40a7668627b3490b9513a3d43a55"
dependencies = [
"axum",
"base64",
"mime_guess",
"regex",
"rust-embed",
"serde",
"serde_json",
"url",
"utoipa",
"utoipa-swagger-ui-vendored",
"zip",
]
[[package]]
name = "utoipa-swagger-ui-vendored"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2eebbbfe4093922c2b6734d7c679ebfebd704a0d7e56dfcb0d05818ce28977d"
[[package]]
name = "uuid"
version = "1.24.0"
@ -5614,8 +5741,40 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "zip"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12598812502ed0105f607f941c386f43d441e00148fce9dec3ca5ffb0bde9308"
dependencies = [
"arbitrary",
"crc32fast",
"flate2",
"indexmap",
"memchr",
"zopfli",
]
[[package]]
name = "zlib-rs"
version = "0.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]

View file

@ -112,3 +112,10 @@ matrix-sdk = { version = "0.18", default-features = false, features = [
futures-util = "0.3"
hmac = "0.13"
sha2 = "0.11"
utoipa = { version = "5", features = ["axum_extras"] }
utoipa-axum = "0.2"
# "vendored" bundles a pinned Swagger UI dist via utoipa-swagger-ui-vendored
# instead of the crate's default curl-download build script — required in
# the nix sandbox, which has no network at build time outside the
# Cargo.lock-pinned dependency fetch.
utoipa-swagger-ui = { version = "9", features = ["axum", "vendored"] }

View file

@ -51,6 +51,9 @@ tokio-stream.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
problem_details = { version = "0.9.0", features = ["axum"] }
utoipa.workspace = true
utoipa-axum.workspace = true
utoipa-swagger-ui.workspace = true
[dev-dependencies]
tempfile = "3"

View file

@ -24,10 +24,17 @@ use axum::{
response::{IntoResponse, Response},
};
use serde::Serialize;
use utoipa::ToSchema;
use crate::host_stats::ServerWarning;
/// `GET /health/live` — liveness. Always `200`; no further checks.
#[utoipa::path(
get,
path = "/health/live",
responses((status = 200, description = "process is up", body = serde_json::Value)),
tag = "health"
)]
pub(super) async fn get_health_live() -> Response {
(
StatusCode::OK,
@ -36,7 +43,7 @@ pub(super) async fn get_health_live() -> Response {
.into_response()
}
#[derive(Serialize)]
#[derive(Serialize, ToSchema)]
struct ReadyBody {
status: &'static str,
warnings: Vec<ServerWarning>,
@ -48,6 +55,15 @@ struct ReadyBody {
/// `{"status":"degraded", ...}`. `warnings` always carries the full
/// current list (including `warn`-level entries not affecting the
/// status) so a poller gets detail either way.
#[utoipa::path(
get,
path = "/health/ready",
responses(
(status = 200, description = "no crit-level warning set", body = ReadyBody),
(status = 503, description = "at least one crit-level warning set", body = ReadyBody),
),
tag = "health"
)]
pub(super) async fn get_health_ready() -> Response {
let warnings = crate::warnings::snapshot();
let degraded = warnings.iter().any(|w| w.level == "crit");

View file

@ -15,13 +15,14 @@ use axum::{
response::{IntoResponse, Response},
};
use serde::Deserialize;
use utoipa::IntoParams;
use problem_details::ProblemDetails;
use super::{Ident, error_problem, strip_container_prefix};
use crate::lifecycle;
#[derive(Deserialize)]
#[derive(Deserialize, IntoParams)]
pub(super) struct JournalQuery {
/// Optional systemd unit filter — e.g. `hive-agent.service`. When
/// omitted, returns the full machine journal.
@ -43,6 +44,20 @@ pub(super) struct JournalQuery {
/// [`hive_priv_sock::InfraContainer`]). Infra containers don't run the
/// per-agent hive daemons, so `unit` is ignored for them — always the
/// full machine journal.
#[utoipa::path(
get,
path = "/api/journal/{name}",
params(
("name" = String, Path, description = "agent name, or one of the four infra container names"),
JournalQuery,
),
responses(
(status = 200, description = "journal text", body = String, content_type = "text/plain"),
(status = 400, description = "bad agent name or unknown unit"),
(status = 404, description = "no such managed container"),
),
tag = "journal"
)]
pub(super) async fn get_journal(
AxumPath(name): AxumPath<String>,
axum::extract::Query(q): axum::extract::Query<JournalQuery>,
@ -134,7 +149,7 @@ async fn read_journal_response(
}
}
#[derive(Deserialize)]
#[derive(Deserialize, IntoParams)]
pub(super) struct JournalHostQuery {
/// Service unit name to filter to. If omitted, returns all logs.
#[serde(default)]
@ -148,6 +163,16 @@ pub(super) struct JournalHostQuery {
/// `-M` container flag). Restricted to an allow-list of known host services
/// so arbitrary unit names can't be probed. Operator-only by virtue of the
/// dashboard binding to a host-only port.
#[utoipa::path(
get,
path = "/api/journal-host",
params(JournalHostQuery),
responses(
(status = 200, description = "journal text", body = String, content_type = "text/plain"),
(status = 400, description = "unknown unit"),
),
tag = "journal"
)]
pub(super) async fn get_journal_host(
axum::extract::Query(q): axum::extract::Query<JournalHostQuery>,
) -> Result<Response, ProblemDetails> {

View file

@ -12,10 +12,35 @@ use axum::{
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"),
)
)]
struct ApiDoc;
mod approvals;
mod build_logs;
mod extra_forges;
@ -79,12 +104,11 @@ pub async fn serve(
) -> 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("/health/live", get(health::get_health_live))
.route("/health/ready", get(health::get_health_ready))
.route("/api/state", get(state_snapshot::api_state))
.route("/api/journal/{name}", get(journal::get_journal))
.route("/api/journal-host", get(journal::get_journal_host))
.route("/api/state-file", get(state_files::get_state_file))
.route(
"/api/matrix-accounts",
@ -239,6 +263,19 @@ pub async fn serve(
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,

View file

@ -18,11 +18,12 @@
use std::collections::HashMap;
use serde::Serialize;
use utoipa::ToSchema;
use crate::container_view::ContainerView;
/// One server-level warning for the dashboard's top-of-page banner.
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct ServerWarning {
/// Stable kind id (e.g. `"disk_pressure"`) — lets the frontend dedupe
/// or special-case without parsing the message.