deploy: move the controller and swarm-ui toggles

One commit rather than two because they are not independent: the UI's
`enable` had the controller's as its literal default, so moving the
controller alone would leave the UI's default naming an option that no
longer exists.

The UI keeps that derivation in its new home — it is a view onto the
controller's state and reaches it over that daemon's unix socket, so the
host running the controller is the host that can serve it.

Three spellings had to move together for the UI, not one: the `default`,
the `defaultText` shown in the options doc, and the description prose
that names the old path in words. A grep for the option path finds the
first two.

The sweep also reached outside nix: `swarm-controller`'s crate README and
its `//!` module doc both named the option, as did this repo's own
CLAUDE.md and four pages under docs/. An option's name is API, and its
documentation lives wherever someone thought to write it down.
This commit is contained in:
atlas 2026-08-30 03:22:42 +02:00 committed by mara
commit 0b7357d4b8
16 changed files with 75 additions and 52 deletions

View file

@ -24,7 +24,10 @@
# spread across the service modules, so the whole move has a single home
# and a single file to delete when the deprecation window closes — the
# shape ./swarm-peers-removed.nix already uses.
{ lib, ... }:
{ lib, config, ... }:
let
deployCfg = config.services.hyperhive.deploy;
in
{
imports = [
# Same type, same meaning, new path — so a rename carries it exactly
@ -42,6 +45,14 @@
[ "services" "hyperhive" "swarm" "victorialogs" "enable" ]
[ "services" "hyperhive" "deploy" "victorialogs" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "controller" "enable" ]
[ "services" "hyperhive" "deploy" "controller" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "ui" "enable" ]
[ "services" "hyperhive" "deploy" "swarm-ui" ]
)
];
options.services.hyperhive.deploy = {
@ -85,5 +96,36 @@
service host is a *client* of this store, not a second one.
'';
};
controller = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Run the swarm-controller daemon on this host.
Off by default and deliberately not derived from
{option}`services.hyperhive.enable`: a swarm has one controller,
so running it is a decision about this host rather than about
whether hyperhive is installed.
'';
};
swarm-ui = lib.mkOption {
type = lib.types.bool;
default = deployCfg.controller;
defaultText = lib.literalExpression "services.hyperhive.deploy.controller";
example = true;
description = ''
Serve the swarm UI from this host.
Derived from {option}`services.hyperhive.deploy.controller` rather
than from
{option}`services.hyperhive.swarm.enableRequiredServices`: 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

@ -669,6 +669,6 @@ in
# in review on this PR; it evaluates and builds clean either way, which
# is exactly why it needed a reviewer rather than a check.
systemd.services.swarm-controller.environment.SWARM_CONTROLLER_OIDC_CA_FILE =
lib.mkIf hyperhiveCfg.swarm.controller.enable "${cfg.stateDir}/trust-bundle.pem";
lib.mkIf hyperhiveCfg.deploy.controller "${cfg.stateDir}/trust-bundle.pem";
};
}

View file

@ -33,7 +33,7 @@ in
shared services
(`services.hyperhive.swarm.enableRequiredServices`), the swarm
CA (`services.hyperhive.swarm.ca.autoConfigure`), the swarm
controller (`services.hyperhive.swarm.controller.enable`), and the
controller (`services.hyperhive.deploy.controller`), and the
host's `/etc/hosts` entries for the names this hive serves
(`services.hyperhive.gateway.localHostsEntry`) with no real DNS
for those names, the operator is browsing them from the same box

View file

@ -42,6 +42,7 @@ let
hyperhiveDomain = hyperhiveCfg.domain;
swarmDomain = hyperhiveCfg.swarm.domain;
uiCfg = hyperhiveCfg.swarm.ui;
deployCfg = hyperhiveCfg.deploy;
forgeCfg = hyperhiveCfg.swarm.forge;
# Group an account must hold to reach operator-only surfaces. Named
@ -1406,7 +1407,7 @@ in
# collector is registered — see `metricsRule` above,
# which is where the reasoning for both halves lives.
lib.optional forgeCfg.behindGateway metricsRule
++ lib.optional uiCfg.enable {
++ lib.optional deployCfg.swarm-ui {
domain = uiCfg.domain;
subject = [ "group:${operatorGroup}" ];
policy = "one_factor";

View file

@ -15,6 +15,7 @@
}:
let
cfg = config.services.hyperhive.swarm.controller;
deployCfg = config.services.hyperhive.deploy;
autheliaCfg = config.services.hyperhive.swarm.authelia;
# What `swarmctl` needs in order to act on authelia from the host.
@ -119,7 +120,7 @@ let
# ⚠️ Correct ONLY while the vhost and this daemon share a host, and they do
# by construction: the UI's `/api/` location proxies
# `http://unix:<socketPath>`, a path that resolves nowhere else, and this
# daemon binds no TCP address at all (see the header). `swarm.ui.enable`
# daemon binds no TCP address at all (see the header). `deploy.swarm-ui`
# 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.
#
@ -129,7 +130,7 @@ let
# the moment to add an explicit `publicUrl` option — not before, because
# until then there is exactly one derivable answer and an option would only
# be a second place to get it wrong.
webhookEnv = lib.optionalAttrs uiCfg.enable {
webhookEnv = lib.optionalAttrs deployCfg.swarm-ui {
SWARM_CONTROLLER_PUBLIC_URL = "https://${uiCfg.domain}";
};
@ -214,7 +215,7 @@ in
name = "swarm-controller";
consumers = [ "swarm-controller" ];
hostUnit = true;
enable = cfg.enable;
enable = deployCfg.controller;
})
];
@ -238,22 +239,12 @@ in
'';
};
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Run the swarm-controller daemon on this host. Off by default and
deliberately not derived from `services.hyperhive.enable`: a swarm
has one controller, so enabling it per hive is a decision about
swarm topology, not about whether hyperhive is installed.
`services.hyperhive.enableAllLocalDefaults` does assert it, and
that is not an exception to the rule above it is the rule
applied. That mode says "this box is the whole deployment", which
answers the topology question outright, where
`services.hyperhive.enable` alone never can.
'';
};
# `enable` moved to `services.hyperhive.deploy.controller` — see
# ./deploy.nix. `services.hyperhive.enableAllLocalDefaults` still
# asserts it, and that was never an exception to "not derived from
# services.hyperhive.enable": that mode says "this box is the whole
# deployment", which answers the topology question outright, where
# `enable` alone never can. What stays here is what the daemon IS.
package = lib.mkOption {
type = lib.types.package;
@ -483,7 +474,7 @@ in
};
};
config = lib.mkIf (config.services.hyperhive.enable && cfg.enable) {
config = lib.mkIf (config.services.hyperhive.enable && deployCfg.controller) {
# The daemon and the oneshot that mints its credential — the second one
# failing leaves the first running and unable to authenticate anywhere.
services.hyperhive.swarm.otel.journaldUnits = [

View file

@ -14,6 +14,7 @@
}:
let
cfg = config.services.hyperhive.swarm.ui;
deployCfg = config.services.hyperhive.deploy;
gatewayCfg = config.services.hyperhive.gateway;
autheliaCfg = config.services.hyperhive.swarm.authelia;
controllerCfg = config.services.hyperhive.swarm.controller;
@ -56,23 +57,11 @@ let
hiveDomain = config.services.hyperhive.domain;
in
{
# `enable` moved to `services.hyperhive.deploy.swarm-ui` — see
# ./deploy.nix, where it still derives from the controller's own deploy
# toggle for the same reason. What stays here is what the UI IS: its
# domain and wiring.
options.services.hyperhive.swarm.ui = {
enable = lib.mkOption {
type = lib.types.bool;
default = swarmCfg.controller.enable;
defaultText = lib.literalExpression "services.hyperhive.swarm.controller.enable";
example = true;
description = ''
Serve the swarm UI from this host.
Derived from `swarm.controller.enable` rather than from
`enableRequiredServices`: 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.
'';
};
domain = lib.mkOption {
type = lib.types.str;
default = if swarmCfg.domain == null then "swarm.invalid" else swarmCfg.domain;
@ -110,7 +99,7 @@ in
};
};
config = lib.mkIf (config.services.hyperhive.enable && cfg.enable) {
config = lib.mkIf (config.services.hyperhive.enable && deployCfg.swarm-ui) {
assertions = [
{
# The `_` default server already answers for the hive domain

View file

@ -46,7 +46,7 @@ let
# `forge.<apex>` is, and no CA in the hierarchy issues for it
# implicitly. Left out, its vhost falls back to the hive leaf and the
# swarm's front page opens with a name mismatch.
++ lib.optional swarmCfg.ui.enable swarmCfg.ui.domain
++ lib.optional deployCfg.swarm-ui swarmCfg.ui.domain
# Every swarm service that claims a gateway name belongs here, and
# these three were missing it. Membership is what `gateway.lib.tlsFor`
# consults to pick the services leaf over the hive one, so a name