From ba3a9ed94f1502d9e96a10373023ddc2c9d14963 Mon Sep 17 00:00:00 2001 From: iris Date: Sat, 15 Aug 2026 13:47:24 +0200 Subject: [PATCH] swarm-controller: serve swarm-wide service quick links (hyperhive#3289) New `services.hyperhive.swarm.controller.links` option (listOf {label, icon, url}, same shape as the per-agent hyperhive.dashboardLinks) plus a new GET /api/links route serving it, same pattern as the existing hives/GET /api/hives. Rather than one central hardcoded list, each service's own module contributes its own entry when actually enabled on the controller's host: swarm-authelia.nix, hive-matrix.nix (gated on gui.enable too, since / on that vhost only serves fluffychat then) and hive-forge/default.nix (gated on behindGateway) each push one entry, the same list-merge idiom services.hyperhive.gateway.localNames already uses. swarm-ui.nix contributes a static entry for its own same-origin swagger docs. Adding a future service's link is a nix-only change to that service's own module. Verified: cargo build/clippy/test -p swarm-controller clean, a throwaway nixosSystem eval confirms all 4 entries merge correctly into SWARM_CONTROLLER_LINKS, nix build .#swarm-controller succeeds. --- nix/host-modules/hive-forge/default.nix | 10 +++ nix/host-modules/hive-matrix.nix | 14 ++++ nix/host-modules/swarm-authelia.nix | 12 +++ nix/host-modules/swarm-controller.nix | 51 +++++++++++ nix/host-modules/swarm-ui.nix | 13 +++ swarm-controller/src/main.rs | 107 +++++++++++++++++++++++- 6 files changed, 206 insertions(+), 1 deletion(-) diff --git a/nix/host-modules/hive-forge/default.nix b/nix/host-modules/hive-forge/default.nix index 2527f453..33d096f2 100644 --- a/nix/host-modules/hive-forge/default.nix +++ b/nix/host-modules/hive-forge/default.nix @@ -409,6 +409,16 @@ in # vhost nor answer DNS for it. services.hyperhive.gateway.localNames = lib.optional cfg.behindGateway cfg.domain; + # This swarm-ui quick-links entry, same `behindGateway` guard as the + # vhost/DNS name above — with it off, this host doesn't actually + # serve `cfg.domain`, so linking to it would be dead. See + # `services.hyperhive.swarm.controller.links`'s description. + services.hyperhive.swarm.controller.links = lib.optional cfg.behindGateway { + label = "Forge"; + icon = "⚒"; + url = "https://${cfg.domain}/"; + }; + # `server_name = forge.domain`, proxies all `/` → forgejo. Tuned for # git: `client_max_body_size 1G`, `proxy_read_timeout 1h` (multi-GB # clones). SSH stays direct on `forge.sshPort`. See diff --git a/nix/host-modules/hive-matrix.nix b/nix/host-modules/hive-matrix.nix index 93b6973d..b532c5c6 100644 --- a/nix/host-modules/hive-matrix.nix +++ b/nix/host-modules/hive-matrix.nix @@ -508,6 +508,20 @@ in # every clause below carries that guard. services.hyperhive.gateway.localNames = lib.optional (cfg.gatewayHost != null) cfg.gatewayHost; + # This swarm-ui quick-links entry. Gated on `gui.enable` too, not just + # `gatewayHost != null`: `/` on that vhost only serves fluffychat + # (below) when the GUI is on — otherwise the link would 404, the same + # reason the old dashboard's H0M3 page hides its Matrix tile on + # `state.matrix_gui_enabled` rather than `gatewayHost` alone. See + # `services.hyperhive.swarm.controller.links`'s description. + services.hyperhive.swarm.controller.links = + lib.optional (cfg.gatewayHost != null && cfg.gui.enable) + { + label = "Matrix"; + icon = "💬"; + url = "https://${cfg.gatewayHost}/"; + }; + # Accept-header SPA map, used only by the `/` location below (see # docs/gateway.md "SPA fallback"): text/html → index.html, else a # sentinel so `try_files` falls through to 404. `appendHttpConfig` diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index 3d8a3af5..35653e48 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -485,6 +485,18 @@ in # bridge at that wrong answer. services.hyperhive.gateway.localNames = [ cfg.domain ]; + # This swarm-ui quick-links entry, same guard as the vhost/DNS name + # above (only the host actually running the container claims it — + # see `services.hyperhive.swarm.controller.links`'s description for + # the contribute-your-own-entry idiom). + services.hyperhive.swarm.controller.links = [ + { + label = "Authelia"; + icon = "🔑"; + url = "https://${cfg.domain}/"; + } + ]; + # `server_name = authelia.domain`, all of `/` → authelia. # # ⚠️ The server name must be exactly `cfg.domain`, not a near-miss: diff --git a/nix/host-modules/swarm-controller.nix b/nix/host-modules/swarm-controller.nix index 80993a63..30f7ca86 100644 --- a/nix/host-modules/swarm-controller.nix +++ b/nix/host-modules/swarm-controller.nix @@ -112,6 +112,53 @@ in new directory, not just the daemon. ''; }; + + links = lib.mkOption { + type = lib.types.listOf ( + lib.types.submodule { + options = { + label = lib.mkOption { + type = lib.types.str; + description = "Display label for the link."; + }; + icon = lib.mkOption { + type = lib.types.str; + default = ""; + description = "Optional icon emoji or short glyph."; + }; + url = lib.mkOption { + type = lib.types.str; + description = "Full URL."; + }; + }; + } + ); + default = [ ]; + example = lib.literalExpression '' + [ { label = "Wiki"; icon = "📖"; url = "https://wiki.example.com/"; } ] + ''; + description = '' + Quick links to swarm-wide services, surfaced by the swarm UI's + links menu (`GET /api/links`). Same shape and same + zero-code-change-to-extend idea as `hyperhive.dashboardLinks` + (`nix/agent-modules/dashboard-links.nix`), one level up: rather + than one central hardcoded list, each service's own module + contributes its own entry when it is actually enabled on this + host — `swarm-authelia.nix`, `hive-matrix.nix` and + `hive-forge/default.nix` all do — the same list-merge idiom + `services.hyperhive.gateway.localNames` already uses. A future + service module can push its own entry the same way, and an + operator can add arbitrary extra entries here directly; neither + needs a swarm-controller or swarm-ui change. + + Only meaningful on the host that actually runs the controller — + entries contributed on any other host are computed but never + read. In a swarm that splits `swarm-authelia`/`hive-matrix`/ + `hive-forge` across hosts other than the controller's, this list + only reflects what is enabled locally; see each contributing + module's own activation condition. + ''; + }; }; config = lib.mkIf (config.services.hyperhive.enable && cfg.enable) { @@ -184,6 +231,10 @@ in inherit (h) domain; }) config.services.hyperhive.swarm.hives ); + # The merged links list — see `links`' description above for who + # contributes to it. Consumed by `GET /api/links` + # (swarm-controller/src/main.rs::load_links). + environment.SWARM_CONTROLLER_LINKS = builtins.toJSON cfg.links; }; }; } diff --git a/nix/host-modules/swarm-ui.nix b/nix/host-modules/swarm-ui.nix index 87c8f8a1..62096f90 100644 --- a/nix/host-modules/swarm-ui.nix +++ b/nix/host-modules/swarm-ui.nix @@ -124,6 +124,19 @@ in # makes the name resolve at all. services.hyperhive.gateway.localNames = [ cfg.domain ]; + # This UI's own swagger docs, always same-origin (`/api/docs/` below) + # so — unlike authelia/matrix/forge's entries — this one needs no + # host name and is never conditional on anything but this module + # being enabled at all. See + # `services.hyperhive.swarm.controller.links`'s description. + services.hyperhive.swarm.controller.links = [ + { + label = "API docs"; + icon = "🧬"; + url = "/api/docs/"; + } + ]; + # The swarm's front page, and the FIRST `auth_request` anywhere in # this gateway (everything else is `auth_basic` + htpasswd). # diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index a30bec24..49ab4141 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -65,6 +65,7 @@ fn socket_path() -> PathBuf { tags( (name = "health", description = "liveness probe"), (name = "hives", description = "the swarm's hive directory"), + (name = "links", description = "swarm service quick links"), ) )] struct ApiDoc; @@ -97,6 +98,9 @@ struct AppState { /// Loaded once at startup (`load_hives`); never mutated, so an /// `Arc` clone per request is the whole synchronization story. hives: Arc>, + /// Loaded once at startup (`load_links`); same synchronization story + /// as `hives`. + links: Arc>, } /// Env var the controller's NixOS module sets from @@ -136,6 +140,58 @@ async fn get_hives(State(state): State) -> Json> { Json((*state.hives).clone()) } +/// One quick link to a swarm-wide service (authelia, matrix, forge, this +/// daemon's own swagger UI, …). Deliberately generic rather than named +/// fields per service: each service's own nix module contributes its own +/// entry to `services.hyperhive.swarm.controller.links` (same list-merge +/// idiom `services.hyperhive.gateway.localNames` already uses), so adding +/// a new one is a nix-only change — no new field here, no swarm-ui change. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ToSchema)] +struct ServiceLink { + label: String, + /// Emoji or short glyph. Empty string, not `Option`, when a + /// contributing module has none — one fewer null-vs-absent case for + /// the frontend to handle, and every real contributor sets one today. + icon: String, + url: String, +} + +/// Env var the controller's NixOS module sets from the merged +/// `services.hyperhive.swarm.controller.links` list, JSON-encoded — same +/// shape/rationale as [`HIVES_ENV`]. Consumed by `GET /api/links`. +const LINKS_ENV: &str = "SWARM_CONTROLLER_LINKS"; + +/// Parses [`LINKS_ENV`] into the swarm's service-link list. Same +/// fall-back-to-empty rationale as `load_hives`: a daemon that can't yet +/// see this config should still serve `/health` rather than fail startup. +fn load_links() -> Vec { + let Some(raw) = std::env::var_os(LINKS_ENV) else { + return Vec::new(); + }; + match serde_json::from_str(&raw.to_string_lossy()) { + Ok(links) => links, + Err(e) => { + tracing::warn!(error = %e, env = LINKS_ENV, "failed to parse service links, serving an empty list"); + Vec::new() + } + } +} + +/// Quick links to swarm-wide services (authelia, matrix, forge, this +/// daemon's own swagger UI, …), as contributed by each service's own nix +/// module. Empty when nothing was configured to contribute — a caller +/// renders 0 links the same way it renders any other count, no special +/// "not configured" case. +#[utoipa::path( + get, + path = "/api/links", + responses((status = 200, description = "swarm service quick links", body = Vec)), + tag = "links" +)] +async fn get_links(State(state): State) -> Json> { + Json((*state.links).clone()) +} + #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::fmt() @@ -176,11 +232,13 @@ async fn main() -> Result<()> { let state = AppState { hives: Arc::new(load_hives()), + links: Arc::new(load_links()), }; let (router, api) = OpenApiRouter::::with_openapi(ApiDoc::openapi()) .routes(routes!(health)) .routes(routes!(get_hives)) + .routes(routes!(get_links)) .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 @@ -199,7 +257,9 @@ async fn main() -> Result<()> { #[cfg(test)] mod tests { - use super::{DEFAULT_SOCKET, HIVES_ENV, HiveEntry, load_hives}; + use super::{ + DEFAULT_SOCKET, HIVES_ENV, HiveEntry, LINKS_ENV, ServiceLink, load_hives, load_links, + }; use std::path::Path; /// The socket must not share a directory with anything else, because @@ -277,4 +337,49 @@ mod tests { std::env::remove_var(HIVES_ENV); } } + + /// Same three-state coverage as `load_hives_covers_missing_malformed_and_valid`, + /// same reason for one test rather than three (a shared process env var). + /// + /// SAFETY: single-threaded mutation of a process env var no other test + /// in this crate reads; restored (removed) before returning. + #[test] + fn load_links_covers_missing_malformed_and_valid() { + unsafe { + std::env::remove_var(LINKS_ENV); + } + assert_eq!( + load_links(), + Vec::::new(), + "unset env var is an empty list, not a startup failure" + ); + + unsafe { + std::env::set_var(LINKS_ENV, "not json"); + } + assert_eq!( + load_links(), + Vec::::new(), + "unparseable env var falls back to empty rather than panicking" + ); + + unsafe { + std::env::set_var( + LINKS_ENV, + r#"[{"label":"Authelia","icon":"🔑","url":"https://auth.example.com/"}]"#, + ); + } + assert_eq!( + load_links(), + vec![ServiceLink { + label: "Authelia".to_string(), + icon: "🔑".to_string(), + url: "https://auth.example.com/".to_string(), + }] + ); + + unsafe { + std::env::remove_var(LINKS_ENV); + } + } }