diff --git a/Cargo.lock b/Cargo.lock index 6ebccc85..bf7ac800 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", +] diff --git a/Cargo.toml b/Cargo.toml index 084f62ea..9b509513 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/hive-c0re/Cargo.toml b/hive-c0re/Cargo.toml index 958dae0c..98a6e459 100644 --- a/hive-c0re/Cargo.toml +++ b/hive-c0re/Cargo.toml @@ -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" diff --git a/hive-c0re/src/dashboard/health.rs b/hive-c0re/src/dashboard/health.rs index 7d5f14d6..f407a4f4 100644 --- a/hive-c0re/src/dashboard/health.rs +++ b/hive-c0re/src/dashboard/health.rs @@ -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, @@ -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"); diff --git a/hive-c0re/src/dashboard/journal.rs b/hive-c0re/src/dashboard/journal.rs index 24b77394..64c7eafa 100644 --- a/hive-c0re/src/dashboard/journal.rs +++ b/hive-c0re/src/dashboard/journal.rs @@ -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, axum::extract::Query(q): axum::extract::Query, @@ -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, ) -> Result { diff --git a/hive-c0re/src/dashboard/mod.rs b/hive-c0re/src/dashboard/mod.rs index 55fe7ec7..cda3ecab 100644 --- a/hive-c0re/src/dashboard/mod.rs +++ b/hive-c0re/src/dashboard/mod.rs @@ -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::::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, diff --git a/hive-c0re/src/stats/host_stats.rs b/hive-c0re/src/stats/host_stats.rs index 059a882c..2654a325 100644 --- a/hive-c0re/src/stats/host_stats.rs +++ b/hive-c0re/src/stats/host_stats.rs @@ -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.