Watch
0
0
Fork
You've already forked hyperhive
0

nix: run swarm-ui wherever swarm-controller runs

Remove deploy.swarm-ui.enable. Every reader (the swarm-ui vhost, the
authelia operator rule for the swarm apex, hive-network's
anyServiceEnabled) now reads deploy.swarm-controller.enable, and the
controller's webhook public URL is gated on the swarm domain alone,
since the vhost it names exists wherever the daemon does.

Both spellings of the old switch (deploy.swarm-ui.enable and the
already-renamed swarm.ui.enable) become mkRemovedOptionModule entries,
so a host that still sets either fails evaluation with a message
naming the controller toggle instead of silently changing.

Ruling (mara, #3742): "swarm-ui always runs wherever controller runs".

Part of #3742
This commit is contained in:
atlas 2026-10-13 08:19:35 +02:00 • committed by mara
commit f9954b91b4
10 changed files with 83 additions and 59 deletions

View file

@ -12,7 +12,7 @@ You rarely switch it on yourself. `gateway.enable` defaults to off, and every mo
| URL | upstream | declared by, when | | URL | upstream | declared by, when |
| --- | --- | --- | | --- | --- | --- |
| `<swarm>/` | swarm-ui dist (static), behind an authelia subrequest | `swarm-ui.nix`, `deploy.swarm-ui.enable` | | `<swarm>/` | swarm-ui dist (static), behind an authelia subrequest | `swarm-ui.nix`, `deploy.swarm-controller.enable` |
| `auth.<swarm>/` | authelia (`9091`) | `swarm-authelia.nix`, `deploy.authelia.enable` | | `auth.<swarm>/` | authelia (`9091`) | `swarm-authelia.nix`, `deploy.authelia.enable` |
| `auth.<swarm>/bridge/*` | swarm-authelia-bridge (`9092`), prefix stripped; answers swarm-controller's client only | `swarm-authelia.nix`, `deploy.authelia.enable` | | `auth.<swarm>/bridge/*` | swarm-authelia-bridge (`9092`), prefix stripped; answers swarm-controller's client only | `swarm-authelia.nix`, `deploy.authelia.enable` |
| `forge.<swarm>/` | forgejo (`3000`) | `hive-forge/`, `deploy.forgejo.enable` | | `forge.<swarm>/` | forgejo (`3000`) | `hive-forge/`, `deploy.forgejo.enable` |

View file

@ -334,10 +334,10 @@ Where each credential lives and who reads it:
## Enabling ## Enabling
```nix ```nix
services.hyperhive.deploy.swarm-ui.enable = true; # defaults to deploy.swarm-controller.enable services.hyperhive.deploy.swarm-controller.enable = true; # the UI comes with it
``` ```
Derived from the controller rather than from `allSwarmServices`: The UI has no switch of its own, and doesn't follow `allSwarmServices`:
the UI is a view onto the controller's state and reaches it over that the UI is a view onto the controller's state and reaches it over that
daemon's socket, so the host that runs the controller is the host that daemon's socket, so the host that runs the controller is the host that
can serve the UI. A hive that merely _uses_ a swarm has nothing to serve. can serve the UI. A hive that merely _uses_ a swarm has nothing to serve.

View file

@ -28,9 +28,13 @@
# spread across the service modules, so all of them have a single home # spread across the service modules, so all of them have a single home
# and a single file to delete when the deprecation window closes — the # and a single file to delete when the deprecation window closes — the
# shape ./swarm-peers-removed.nix already uses. # shape ./swarm-peers-removed.nix already uses.
{ lib, config, ... }: { lib, ... }:
let let
deployCfg = config.services.hyperhive.deploy; # Shared by the two removed spellings of the swarm UI's own switch below.
swarmUiSwitchRemoved = ''
swarm-ui now runs wherever swarm-controller runs. Remove this
definition; services.hyperhive.deploy.swarm-controller.enable decides it.
'';
# Shared by the two removed spellings of the status-publishing secret below. # Shared by the two removed spellings of the status-publishing secret below.
statusSecretRemoved = '' statusSecretRemoved = ''
@ -94,10 +98,20 @@ in
be deleted. be deleted.
'' ''
) )
(lib.mkRenamedOptionModule (lib.mkRemovedOptionModule [
[ "services" "hyperhive" "swarm" "ui" "enable" ] "services"
[ "services" "hyperhive" "deploy" "swarm-ui" "enable" ] "hyperhive"
) "swarm"
"ui"
"enable"
] swarmUiSwitchRemoved)
(lib.mkRemovedOptionModule [
"services"
"hyperhive"
"deploy"
"swarm-ui"
"enable"
] swarmUiSwitchRemoved)
(lib.mkRenamedOptionModule (lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "authelia" "enable" ] [ "services" "hyperhive" "swarm" "authelia" "enable" ]
[ "services" "hyperhive" "deploy" "authelia" "enable" ] [ "services" "hyperhive" "deploy" "authelia" "enable" ]
@ -436,9 +450,8 @@ in
[ "services" "hyperhive" "swarm" "victoriametrics" "package" ] [ "services" "hyperhive" "swarm" "victoriametrics" "package" ]
[ "services" "hyperhive" "deploy" "victoriametrics" "package" ] [ "services" "hyperhive" "deploy" "victoriametrics" "package" ]
) )
# With this one `services.hyperhive.swarm.ui` holds nothing at all — its # With this one `services.hyperhive.swarm.ui` holds nothing at all: its
# two options are both renamed, so the namespace exists only as this # `enable` is removed above and its `package` is renamed here.
# entry and the `enable` one above it.
(lib.mkRenamedOptionModule (lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "ui" "package" ] [ "services" "hyperhive" "swarm" "ui" "package" ]
[ "services" "hyperhive" "deploy" "swarm-ui" "package" ] [ "services" "hyperhive" "deploy" "swarm-ui" "package" ]
@ -688,23 +701,11 @@ in
{option}`services.hyperhive.deploy.hive-controller.enable`: a swarm {option}`services.hyperhive.deploy.hive-controller.enable`: a swarm
has one controller, so running it is a decision about this host has one controller, so running it is a decision about this host
rather than about whether it runs a hive. rather than about whether it runs a hive.
'';
};
swarm-ui.enable = lib.mkOption { Also serves the swarm UI from this host. The UI is a view onto the
type = lib.types.bool; controller's state and reaches it over that daemon's unix socket,
default = deployCfg.swarm-controller.enable; so the host that runs the controller is the only host that can
defaultText = lib.literalExpression "services.hyperhive.deploy.swarm-controller.enable"; serve it.
example = true;
description = ''
Serve the swarm UI from this host.
Derived from
{option}`services.hyperhive.deploy.swarm-controller.enable`: the
UI is a view onto the controller's state and reaches it over that
daemon's unix socket, so the host that runs the controller is the
host that can serve the UI. A hive that merely *uses* a swarm has
nothing to serve here.
''; '';
}; };
}; };

View file

@ -623,7 +623,7 @@ in
# swarm's host was the missing one, and every swarm-controller # swarm's host was the missing one, and every swarm-controller
# delivery was refused for it. # delivery was refused for it.
# #
# Not gated on `deploy.swarm-ui.enable`: in a spread deployment the # Not gated on `deploy.swarm-controller.enable`: in a spread deployment the
# forge and the swarm host are different machines, so that # forge and the swarm host are different machines, so that
# per-host flag is false exactly where this entry is needed. # per-host flag is false exactly where this entry is needed.
webhook.ALLOWED_HOST_LIST = lib.concatStringsSep "," ( webhook.ALLOWED_HOST_LIST = lib.concatStringsSep "," (

View file

@ -40,7 +40,6 @@ let
lib.any lib.id [ lib.any lib.id [
d.hive-controller.enable d.hive-controller.enable
d.swarm-controller.enable d.swarm-controller.enable
d.swarm-ui.enable
d.forgejo.enable d.forgejo.enable
d.ci-runner.enable d.ci-runner.enable
d.matrix.enable d.matrix.enable

View file

@ -1107,7 +1107,7 @@ in
[ metricsRule ] [ metricsRule ]
# Also guarded on the domain being set: without it a null # Also guarded on the domain being set: without it a null
# apex would render a rule matching the string "null". # apex would render a rule matching the string "null".
++ lib.optional (deployCfg.swarm-ui.enable && swarmDomain != null) { ++ lib.optional (deployCfg.swarm-controller.enable && swarmDomain != null) {
domain = swarmDomain; domain = swarmDomain;
subject = [ "group:${operatorGroup}" ]; subject = [ "group:${operatorGroup}" ];
policy = "one_factor"; policy = "one_factor";

View file

@ -204,9 +204,9 @@ let
}; };
# How the forge must address this controller to deliver a swarm-wide # How the forge must address this controller to deliver a swarm-wide
# webhook. Gated on the swarm UI being served *here*, because that module # webhook. ./swarm-ui.nix declares the vhost and the `/webhook/forge/`
# is what declares the vhost and the `/webhook/forge/` location inside it: # location inside it on every host that runs this daemon, so the only gate
# absent, nothing outside this host can reach the endpoint. # left is a swarm domain to name it by.
# #
# 🔑 The gate is the point, not a detail. The daemon registers hooks only # 🔑 The gate is the point, not a detail. The daemon registers hooks only
# when this is set, and a hook whose `target_url` nothing answers is worse # when this is set, and a hook whose `target_url` nothing answers is worse
@ -216,17 +216,15 @@ let
# ⚠️ Correct ONLY while the vhost and this daemon share a host, and they do # ⚠️ Correct ONLY while the vhost and this daemon share a host, and they do
# by construction: the UI's `/api/` location proxies # by construction: the UI's `/api/` location proxies
# `http://unix:<socketPath>`, a path that resolves nowhere else, and this # `http://unix:<socketPath>`, a path that resolves nowhere else, and this
# daemon binds no TCP address at all (see the header). `deploy.swarm-ui` # daemon binds no TCP address at all (see the header).
# therefore is not a guess about *some* host publishing the endpoint — it is
# the flag that declares that vhost, on the box holding the socket.
# #
# ⇒ The day this daemon grows a TCP listener so another host can front it, # ⇒ The day this daemon grows a TCP listener so another host can front it,
# THIS LINE SILENTLY STOPS BEING RIGHT: the env goes unset on the controller # THIS LINE SILENTLY STOPS BEING RIGHT: hooks get registered against a vhost
# host, registration quietly does not happen, and nothing errors. That is # that may no longer be the one fronting the daemon, and nothing errors. That is
# the moment to add an explicit `publicUrl` option — not before, because # the moment to add an explicit `publicUrl` option — not before, because
# until then there is exactly one derivable answer and an option would only # until then there is exactly one derivable answer and an option would only
# be a second place to get it wrong. # be a second place to get it wrong.
webhookEnv = lib.optionalAttrs (deployCfg.swarm-ui.enable && swarmDomain != null) { webhookEnv = lib.optionalAttrs (swarmDomain != null) {
SWARM_CONTROLLER_PUBLIC_URL = "https://${swarmDomain}"; SWARM_CONTROLLER_PUBLIC_URL = "https://${swarmDomain}";
}; };
@ -469,8 +467,8 @@ in
`services.hyperhive.swarm.bao.ui.domain`, and the Matrix entry `services.hyperhive.swarm.bao.ui.domain`, and the Matrix entry
from `services.hyperhive.swarm.matrix.gatewayHost` when it is set, from `services.hyperhive.swarm.matrix.gatewayHost` when it is set,
so they are present whichever host runs each service. so they are present whichever host runs each service.
`swarm-ui.nix` contributes its own API-docs entry wherever the `swarm-ui.nix` contributes its own API-docs entry, since the
swarm UI is enabled on this host. swarm UI runs wherever the controller does.
Read only on the host that runs the controller. Read only on the host that runs the controller.
''; '';

View file

@ -66,10 +66,10 @@ let
apex = if swarmCfg.domain == null then "swarm.invalid" else swarmCfg.domain; apex = if swarmCfg.domain == null then "swarm.invalid" else swarmCfg.domain;
in in
{ {
# `enable` is `services.hyperhive.deploy.swarm-ui.enable` — see # No `enable` of its own: the UI is on wherever
# ./deploy.nix, where it derives from the controller's own deploy # `services.hyperhive.deploy.swarm-controller.enable` is, because it reaches
# toggle for the same reason. There is no `domain` option: the UI answers # that daemon over its unix socket. There is no `domain` option: the UI
# on the swarm apex, which the swarm already declares once. # answers on the swarm apex, which the swarm already declares once.
imports = [ imports = [
(lib.mkRemovedOptionModule [ "services" "hyperhive" "swarm" "ui" "domain" ] '' (lib.mkRemovedOptionModule [ "services" "hyperhive" "swarm" "ui" "domain" ] ''
The swarm UI answers on services.hyperhive.swarm.domain and nothing The swarm UI answers on services.hyperhive.swarm.domain and nothing
@ -83,9 +83,9 @@ in
'') '')
]; ];
# ⚠️ `services.hyperhive.swarm.ui` is not a namespace. Its two options, # ⚠️ `services.hyperhive.swarm.ui` is not a namespace. Its `package` lives
# `enable` and `package`, live under `deploy.swarm-ui`, and the `swarm.ui` # under `deploy.swarm-ui`, and the `swarm.ui` paths survive purely as
# paths survive purely as renames in ./deploy.nix. # rename/removal entries in ./deploy.nix.
options.services.hyperhive.deploy.swarm-ui.package = lib.mkOption { options.services.hyperhive.deploy.swarm-ui.package = lib.mkOption {
type = lib.types.package; type = lib.types.package;
defaultText = lib.literalExpression "hyperhive.packages.\${system}.swarm-ui"; defaultText = lib.literalExpression "hyperhive.packages.\${system}.swarm-ui";
@ -102,7 +102,7 @@ in
''; '';
}; };
config = lib.mkIf deployCfg.swarm-ui.enable { config = lib.mkIf deployCfg.swarm-controller.enable {
assertions = [ assertions = [
{ {
# The `_` default server already answers for the hive domain # The `_` default server already answers for the hive domain

View file

@ -631,11 +631,11 @@ let
ok = !(bare.containers ? swarm-otel) && !(centralToggleOff.containers ? swarm-otel); ok = !(bare.containers ? swarm-otel) && !(centralToggleOff.containers ? swarm-otel);
} }
{ {
# `deploy.swarm-ui.enable`, which defaults to the controller's toggle — # The swarm UI follows `deploy.swarm-controller.enable` — a sibling
# derived from a sibling deployment decision, still not from the hive # deployment decision, not the hive toggle. `t.local` is the fixtures'
# toggle. `t.local` is the fixtures' swarm domain, which is the apex the # swarm domain, which is the apex the UI claims; the arm below is what
# UI claims; the arm below is what proves this vhost renders at all. # proves this vhost renders at all.
name = "the swarm UI vhost is absent unless this host is told to serve it"; name = "the swarm UI vhost is absent unless this host runs the swarm controller";
ok = ok =
!(bare.services.nginx.virtualHosts ? "t.local") !(bare.services.nginx.virtualHosts ? "t.local")
&& !(centralToggleOff.services.nginx.virtualHosts ? "t.local"); && !(centralToggleOff.services.nginx.virtualHosts ? "t.local");

View file

@ -70,13 +70,19 @@ let
network.exposeHostPorts = [ 5432 ]; network.exposeHostPorts = [ 5432 ];
}; };
# The swarm UI where its own toggle is on — the control the absence arm # The swarm UI on the controller's host — the control the absence arm
# needs, since nothing else in this suite renders this vhost and an arm # needs, since nothing else in this suite renders this vhost and an arm
# saying "it is not there" would hold just as well if it were never there. # saying "it is not there" would hold just as well if it were never there.
# Package stubbed per this file's header: the vhost roots at it. # Package stubbed per this file's header: the vhost roots at it.
#
# Also sets both removed UI switches to `false`: the vhost must render
# regardless, and each must fail evaluation through its assertion. Neither
# is read anywhere else, so they change nothing the other arms see.
swarmUiHere = hive { swarmUiHere = hive {
deploy.swarm-ui.enable = true; deploy.swarm-controller.enable = true;
deploy.swarm-ui.package = pkgs.emptyDirectory; deploy.swarm-ui.package = pkgs.emptyDirectory;
deploy.swarm-ui.enable = false;
swarm.ui.enable = false;
}; };
# The swarm's shared services hosted HERE without the all-local mode — a # The swarm's shared services hosted HERE without the all-local mode — a
@ -224,7 +230,7 @@ let
name = "hosting the swarm's shared services does not make a hive the swarm's control plane"; name = "hosting the swarm's shared services does not make a hive the swarm's control plane";
ok = ok =
!swarmServicesHere.services.hyperhive.deploy.swarm-controller.enable !swarmServicesHere.services.hyperhive.deploy.swarm-controller.enable
&& !swarmServicesHere.services.hyperhive.deploy.swarm-ui.enable; && !(swarmServicesHere.services.nginx.virtualHosts ? "t.local");
} }
{ {
# Control for the arm above, and the one place this slice is not inert: # Control for the arm above, and the one place this slice is not inert:
@ -235,9 +241,29 @@ let
ok = builtins.elem 5432 (bridgePortsOrNone exposedPortsNoHive); ok = builtins.elem 5432 (bridgePortsOrNone exposedPortsNoHive);
} }
{ {
name = "the swarm UI claims the swarm apex where this host serves it"; name = "the swarm UI claims the swarm apex wherever this host runs the swarm controller";
ok = swarmUiHere.services.nginx.virtualHosts ? "t.local"; ok = swarmUiHere.services.nginx.virtualHosts ? "t.local";
} }
{
# Control: `swarmServicesHere` sets neither removed switch.
name = "a definition of a removed swarm UI switch fails evaluation, naming the option and the controller";
ok =
let
refused =
m: path:
lib.any (
a:
!a.assertion
&& lib.hasInfix "services.hyperhive.${path}" a.message
&& lib.hasInfix "swarm-ui now runs wherever swarm-controller runs" a.message
) m.assertions;
paths = [
"deploy.swarm-ui.enable"
"swarm.ui.enable"
];
in
lib.all (refused swarmUiHere) paths && !(lib.any (refused swarmServicesHere) paths);
}
{ {
# The swarm-services toggle enables them explicitly, from its own # The swarm-services toggle enables them explicitly, from its own
# module rather than from any of their defaults. # module rather than from any of their defaults.