swarm-controller: OpenAPI spec + gateway swagger UI wiring

Rust half mirrors hive-c0re/src/dashboard/mod.rs's utoipa pattern
exactly: an ApiDoc root, #[utoipa::path(...)] on /health (the one
existing route), and a raw JSON route at /api/openapi.json served
via OpenApiRouter::split_for_parts(). Only annotated routes appear
in the spec.

Gateway wiring extends the swarm-UI vhost (the only vhost swarm-
controller is reachable from) with:
- /api/ — proxied to the controller's unix socket untouched (no URI
  segment after the socket path), so a route swarm-controller
  registers is the path nginx forwards, no prefix-stripping to keep
  in sync by hand.
- /api/docs/ (+ the bare /api/docs redirect) — the same
  swagger-ui-theme dist the per-hive dashboard already serves at its
  own /api/docs/, reused as-is since it's generic.

Both new locations reuse the same auth_request block the vhost's own
'/' already applies, factored into a shared swarmAuthRequest string —
auth_request does not inherit across sibling nginx locations, so
without this the page itself would be gated while its own API and
API docs sat open.

cargo test -p swarm-controller + cargo clippy --all-targets both
clean. Verified the new nginx wiring evaluates correctly with a
throwaway nixosSystem eval (services.hyperhive.swarm.{controller,ui}
enabled): /api/ proxies to the socket, /api/docs redirects, and both
require auth_request the same as the vhost root.

Fixes hyperhive#3212
This commit is contained in:
iris 2026-08-12 21:18:16 +02:00 committed by mara
commit 4828c96957
5 changed files with 94 additions and 9 deletions

View file

@ -11,12 +11,23 @@
//!
//! Distinct from `hive-c0re`, which is per-hive: c0re owns the agents on
//! one host, this owns what is true across hives.
//!
//! `OpenAPI` spec generation mirrors `hive-c0re/src/dashboard/mod.rs`
//! exactly: `#[utoipa::path(...)]` per handler, an `ApiDoc` root, and a
//! raw JSON route at `/api/openapi.json`. Swagger UI itself is
//! nginx-hosted from the nix store, same shape as the per-hive
//! dashboard's (`nix/host-modules/hive-gateway/vhosts.nix`'s
//! `swarmUiVhost` — a swagger-ui-theme dist under `/api/docs/`, no
//! fallback to this daemon). Only annotated routes appear in the spec;
//! an unannotated one just doesn't show up, nothing breaks.
use std::os::unix::fs::PermissionsExt as _;
use std::path::PathBuf;
use anyhow::{Context, Result};
use axum::{Router, routing::get};
use axum::{Json, routing::get};
use utoipa::OpenApi;
use utoipa_axum::{router::OpenApiRouter, routes};
/// Where the daemon binds, overridable via `SWARM_CONTROLLER_SOCKET`.
///
@ -38,8 +49,29 @@ fn socket_path() -> PathBuf {
.map_or_else(|| PathBuf::from(DEFAULT_SOCKET), PathBuf::from)
}
/// Root of the auto-generated `OpenAPI` spec, served raw at
/// `/api/openapi.json` — see the module doc comment above. One tag today
/// (`health`); grows alongside the swarm-level surfaces this daemon picks
/// up, same as `hive-c0re::dashboard::ApiDoc`'s tag list did.
#[derive(OpenApi)]
#[openapi(
info(
title = "hyperhive swarm-controller API",
description = "swarm-controller's HTTP surface, served over its unix \
socket behind the gateway's swarm-UI vhost."
),
tags((name = "health", description = "liveness probe"))
)]
struct ApiDoc;
/// Liveness probe. Returns the build's version so an operator can tell
/// *which* controller answered without shelling onto the host.
#[utoipa::path(
get,
path = "/health",
responses((status = 200, description = "process is up, body is \"swarm-controller <version>\"", body = String)),
tag = "health"
)]
async fn health() -> &'static str {
concat!("swarm-controller ", env!("CARGO_PKG_VERSION"), "\n")
}
@ -82,7 +114,17 @@ async fn main() -> Result<()> {
.with_context(|| format!("chmod {}", path.display()))?;
tracing::info!(socket = %path.display(), "swarm-controller listening");
let app = Router::new().route("/health", get(health));
let (router, api) = OpenApiRouter::with_openapi(ApiDoc::openapi())
.routes(routes!(health))
.split_for_parts();
// Just the JSON, not the UI — Swagger UI itself is nginx-hosted from
// the nix store (see the module doc comment above). `api` is
// `Clone`; each request gets its own owned copy for `Json` to
// serialize, same as `hive-c0re::dashboard::serve`.
let app = router.route(
"/api/openapi.json",
get(move || async move { Json(api.clone()) }),
);
axum::serve(listener, app)
.await
.context("serving swarm-controller")