hyperhive/nix/host-modules/deploy.nix
atlas 889a46729b swarm: move both authelia packages to deploy
`package` (which authelia build runs in the container) and
`bridgePackage` (which `swarm-authelia-bridge` build writes the users
file) are both host decisions, so they join the `deploy.authelia` block
that already holds `enable`, `usersFile` and the two host-side paths.

Six readers, and one of them is why this commit touches a second module:
`swarm-controller.nix` reads `autheliaCfg.package` for
`SWARMCTL_AUTHELIA_BIN`. A per-module sweep never sees that — it is a
cross-reference between two movers, and whichever landed second would
have inherited a dangling alias. The alias itself stays: it still has
three live uses for `url` and `bridgeUrl`.

Two comments needed real edits rather than a rename:

- The `deploy.authelia` header said "all three of these are the same
  kind of thing — a filesystem path". It is five options now and two
  are packages, so the sentence distinguishes the builds from the
  paths, and keeps "a hive that does not run it has nothing at any of
  those paths" scoped to the paths it is true of.
- `module-eval.nix` said "Only `usersFile` has a rename entry",
  explaining that authelia's other slice-7 movers are `readOnly` and a
  rename module cannot contribute a definition to those. That reasoning
  is worth keeping, but the claim stopped being true one line above the
  two entries this commit adds.

The fixture gains both old paths; no new case. `autheliaOldPath`'s
existing arms already fail the eval if either shim goes missing.
2026-09-07 20:46:38 +02:00

549 lines
24 KiB
Nix

# "What does THIS host deploy?"
#
# Separated from `services.hyperhive.swarm.*` because those are two
# different kinds of fact and only one of them varies per machine:
#
# swarm.* — swarm-wide truth. The swarm's name, domain, hives, peers,
# CA, and where each service lives. **Identical on every
# host**, byte for byte; a hive needs all of it to be a
# *client* of the swarm.
# deploy.* — this machine's deployment decisions. Necessarily different
# on every host, because that is what a deployment is.
#
# The `enable` toggles live here rather than under `swarm.*` so the
# namespace that is identical everywhere does not carry the one thing
# that must differ per host.
#
# Flat and named for the thing deployed — `deploy.forgejo`, not
# `deploy.swarmServices.forgejo`: grouping by "swarm service" re-encodes
# the service-side taxonomy into a layer that does not care about it.
#
# ⚠️ Each entry is an attrset with an `enable`, not a bare bool, so a
# service that grows a second *deployment* decision has somewhere to put
# it — `deploy.forgejo = { enable; ci; }` is then an ordinary addition
# rather than a migration. `ci` ("does this host run the runner too") is
# exactly that shape, and a bare bool leaves it unrepresentable.
#
# ⚠️ The renames below are deliberately in this one file rather than
# 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, config, ... }:
let
deployCfg = config.services.hyperhive.deploy;
in
{
imports = [
# Same type, same meaning, new path — so a rename carries it exactly
# and existing configs keep evaluating with one warning naming both
# paths. Precedent: ./hive-forge/default.nix, ./hive-matrix.nix.
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "grafana" "enable" ]
[ "services" "hyperhive" "deploy" "grafana" "enable" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "grafana" "socketDir" ]
[ "services" "hyperhive" "deploy" "grafana" "socketDir" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "grafana" "datasourceUrl" ]
[ "services" "hyperhive" "deploy" "grafana" "datasourceUrl" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "grafana" "logsDatasourceUrl" ]
[ "services" "hyperhive" "deploy" "grafana" "logsDatasourceUrl" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "grafana" "plugins" ]
[ "services" "hyperhive" "deploy" "grafana" "plugins" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "victoriametrics" "enable" ]
[ "services" "hyperhive" "deploy" "victoriametrics" "enable" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "victorialogs" "enable" ]
[ "services" "hyperhive" "deploy" "victorialogs" "enable" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "controller" "enable" ]
[ "services" "hyperhive" "deploy" "swarm-controller" "enable" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "controller" "socketPath" ]
[ "services" "hyperhive" "deploy" "swarm-controller" "socketPath" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "controller" "forgeTokenFile" ]
[ "services" "hyperhive" "deploy" "swarm-controller" "forgeTokenFile" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "controller" "authBridgeUrl" ]
[ "services" "hyperhive" "deploy" "swarm-controller" "authBridgeUrl" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "controller" "queue" "clientSecretFile" ]
[ "services" "hyperhive" "deploy" "swarm-controller" "queue" "clientSecretFile" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "ui" "enable" ]
[ "services" "hyperhive" "deploy" "swarm-ui" "enable" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "authelia" "enable" ]
[ "services" "hyperhive" "deploy" "authelia" "enable" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "authelia" "usersFile" ]
[ "services" "hyperhive" "deploy" "authelia" "usersFile" ]
)
# `hostClientSecretDir` and `hostUsersFile` moved in the same commit and
# deliberately have NO entry here. A rename module contributes a
# *definition* to its target, and both are `readOnly`, which refuses a
# second one — so a shim for either makes every evaluation fail with
# "set multiple times", naming the option's own default as the rival
# definition. Nothing could have set them anyway: both are derived and
# read-only, so the shim's only possible customer is a reader of the old
# path, and that reader now gets "option does not exist" instead, which
# says the same thing sooner.
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "nats" "enable" ]
[ "services" "hyperhive" "deploy" "nats" "enable" ]
)
# Two of statusPublish's three coordinates. `tokenEndpoint` stays under
# `swarm.*`: it is the swarm IdP's one address, identical on every hive,
# where these two are "the queue as seen from here" and "the secret on
# this disk". The all-or-nothing assertion in ./swarm.nix spans the split
# deliberately and names all three paths in its message.
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "statusPublish" "natsUrl" ]
[ "services" "hyperhive" "deploy" "hive-controller" "statusPublish" "natsUrl" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "statusPublish" "clientSecretFile" ]
[ "services" "hyperhive" "deploy" "hive-controller" "statusPublish" "clientSecretFile" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "otel" "enable" ]
[ "services" "hyperhive" "deploy" "swarm-otel" "enable" ]
)
# ⚠️ Not to be confused with `services.hyperhive.otel.clientSecretFile`,
# a different option on the hive tier that is not part of this move. A
# bare `clientSecretFile` matches five distinct options across the tree.
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "otel" "clientSecretFile" ]
[ "services" "hyperhive" "deploy" "swarm-otel" "clientSecretFile" ]
)
# The CI runner, and the only entry here that renames more than an
# `enable`: every knob under it describes the runner THIS host would run,
# so leaving `name`/`concurrency`/`labels`/`package` in the namespace that
# must be identical swarm-wide would keep the original defect for four
# more options. Renamed one by one because `ci` is a plain attrset of
# options rather than a submodule type, so there is no parent path to
# rename in a single entry.
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "forge" "ci" "enable" ]
[ "services" "hyperhive" "deploy" "forgejo" "ci" "enable" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "forge" "ci" "name" ]
[ "services" "hyperhive" "deploy" "forgejo" "ci" "name" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "forge" "ci" "concurrency" ]
[ "services" "hyperhive" "deploy" "forgejo" "ci" "concurrency" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "forge" "ci" "labels" ]
[ "services" "hyperhive" "deploy" "forgejo" "ci" "labels" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "forge" "ci" "package" ]
[ "services" "hyperhive" "deploy" "forgejo" "ci" "package" ]
)
# The queue's callout identity. What stays under `swarm.nats` is what the
# queue IS to every hive — the ports it answers on, the client id it is
# registered under, the responder package; these five are what the host
# running it decides.
#
# ⚠️ The two PUBLIC keys move with their seeds. A public key looks
# swarm-wide, and the user key genuinely is published to every client that
# connects — but peers RECEIVE it over the wire, they never configure it,
# and splitting a keypair across two namespaces is worse than either
# placement. `enable` moved earlier; its shim is the one above.
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "nats" "autoGenerateCallout" ]
[ "services" "hyperhive" "deploy" "nats" "autoGenerateCallout" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "nats" "calloutUserPublicKey" ]
[ "services" "hyperhive" "deploy" "nats" "calloutUserPublicKey" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "nats" "calloutIssuerPublicKey" ]
[ "services" "hyperhive" "deploy" "nats" "calloutIssuerPublicKey" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "nats" "calloutUserSeedFile" ]
[ "services" "hyperhive" "deploy" "nats" "calloutUserSeedFile" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "nats" "calloutIssuerSeedFile" ]
[ "services" "hyperhive" "deploy" "nats" "calloutIssuerSeedFile" ]
)
# The rest of the forge split. What stays under `swarm.forge` is what the
# forge IS from any hive's point of view — its package, the names and ports
# it answers on, the URLs it advertises, the client id it is registered
# under; these five are what the host running it decides. `sso` splits
# because its two halves are different facts: the client id must match the
# entry in authelia's register, the secret is a path on this machine.
#
# `mirrors` renames in one entry rather than one per field: it is a single
# option of a list-of-submodule type, so the rename carries its whole
# value. The `ci` block above needs five because it is a plain attrset of
# separate options, which is the case with no parent path to rename.
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "forge" "behindGateway" ]
[ "services" "hyperhive" "deploy" "forgejo" "behindGateway" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "forge" "openFirewall" ]
[ "services" "hyperhive" "deploy" "forgejo" "openFirewall" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "forge" "mirrors" ]
[ "services" "hyperhive" "deploy" "forgejo" "mirrors" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "forge" "sso" "clientSecretFile" ]
[ "services" "hyperhive" "deploy" "forgejo" "sso" "clientSecretFile" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "forge" "hostSwarmControllerTokenFile" ]
[ "services" "hyperhive" "deploy" "forgejo" "hostSwarmControllerTokenFile" ]
)
# The homeserver, split the same way. `swarm.matrix` keeps what it IS to
# every hive — its package, the name it answers to, the ports and URLs it
# is reached on, the client id it is registered under. These six are what
# the host running it decides. ⚠️ `enable` is NOT here: it was renamed
# earlier and its shim lives in ./hive-matrix.nix, so that file is the one
# to check before assuming this table is the whole matrix story.
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "matrix" "openFirewall" ]
[ "services" "hyperhive" "deploy" "matrix" "openFirewall" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "matrix" "trustedServers" ]
[ "services" "hyperhive" "deploy" "matrix" "trustedServers" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "matrix" "maxRequestSize" ]
[ "services" "hyperhive" "deploy" "matrix" "maxRequestSize" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "matrix" "registrationTokenFile" ]
[ "services" "hyperhive" "deploy" "matrix" "registrationTokenFile" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "matrix" "gui" "enable" ]
[ "services" "hyperhive" "deploy" "matrix" "gui" "enable" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "matrix" "sso" "clientSecretFile" ]
[ "services" "hyperhive" "deploy" "matrix" "sso" "clientSecretFile" ]
)
# Retention is read only where the container is defined, so it is a
# decision of the host running the store rather than something the swarm
# agrees on. The two stores keep their domain and port in `swarm.*`,
# because a client hive needs those to reach them; `package` followed
# `enable` and `retentionPeriod` across in a later slice.
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "victoriametrics" "retentionPeriod" ]
[ "services" "hyperhive" "deploy" "victoriametrics" "retentionPeriod" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "victorialogs" "retentionPeriod" ]
[ "services" "hyperhive" "deploy" "victorialogs" "retentionPeriod" ]
)
# The switch over all of the above, and the name changes with the path
# because the old one described the wrong subject: those services are
# required of the SWARM, while the option says whether THIS host runs
# them. `allSwarmServices` is mara's own phrasing of what it means.
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "enableRequiredServices" ]
[ "services" "hyperhive" "deploy" "allSwarmServices" ]
)
# The mode above that one. It sat at the TOP of `services.hyperhive`,
# which is the same defect one tier up: that namespace is everything
# about hyperhive, not the settings of a single hive. The new name says
# what the mode asserts — the whole swarm runs on this host — instead of
# naming its mechanism.
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "enableAllLocalDefaults" ]
[ "services" "hyperhive" "deploy" "singleHostSwarm" ]
)
# The hive CA's own knobs. They sat at the TOP of `services.hyperhive`,
# which is meant to be everything about hyperhive rather than the settings
# of one hive — and where the CA lives, how long it lasts and how long its
# leaves last are decisions of the host that holds the key. `hive-controller`
# is hive-c0re's new name (mara, on the issue), so the daemon that owns the
# CA is what they hang off.
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "tls" "stateDir" ]
[ "services" "hyperhive" "deploy" "hive-controller" "tls" "stateDir" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "tls" "caValidityDays" ]
[ "services" "hyperhive" "deploy" "hive-controller" "tls" "caValidityDays" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "tls" "leafValidityDays" ]
[ "services" "hyperhive" "deploy" "hive-controller" "tls" "leafValidityDays" ]
)
# The WireGuard mesh, whole. Unlike every rename above this one moves a
# namespace rather than a toggle: nothing under it is a fact another hive
# reads. A peer needs this host's `wireguardEndpoint` — the roster entry
# in ./swarm.nix, which stays — and nothing about the interface this host
# brings up. `listenPort` moves for the same reason and is easy to read
# the other way: it is what this host *binds*, while the port a peer
# *dials* is the one inside `wireguardEndpoint`.
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "wireguard" "enable" ]
[ "services" "hyperhive" "deploy" "wireguard" "enable" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "wireguard" "privateKeyFile" ]
[ "services" "hyperhive" "deploy" "wireguard" "privateKeyFile" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "wireguard" "address" ]
[ "services" "hyperhive" "deploy" "wireguard" "address" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "wireguard" "listenPort" ]
[ "services" "hyperhive" "deploy" "wireguard" "listenPort" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "wireguard" "persistentKeepalive" ]
[ "services" "hyperhive" "deploy" "wireguard" "persistentKeepalive" ]
)
# Which build a service runs is a decision of the host that runs it, so
# every `*.package` follows its `enable` across. The split these replace
# was the sharpest case: `gui.enable` had already moved while
# `gui.package` stayed, so one client's two halves sat in two namespaces
# and ./hive-matrix.nix said so in a comment above the option.
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "matrix" "package" ]
[ "services" "hyperhive" "deploy" "matrix" "package" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "matrix" "gui" "package" ]
[ "services" "hyperhive" "deploy" "matrix" "gui" "package" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "grafana" "package" ]
[ "services" "hyperhive" "deploy" "grafana" "package" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "victorialogs" "package" ]
[ "services" "hyperhive" "deploy" "victorialogs" "package" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "victoriametrics" "package" ]
[ "services" "hyperhive" "deploy" "victoriametrics" "package" ]
)
# With this one `services.hyperhive.swarm.ui` holds nothing at all — it had
# exactly two options and both have moved, so the namespace now exists only
# as this entry and the `enable` one above it.
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "ui" "package" ]
[ "services" "hyperhive" "deploy" "swarm-ui" "package" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "nats" "authPackage" ]
[ "services" "hyperhive" "deploy" "nats" "authPackage" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "authelia" "package" ]
[ "services" "hyperhive" "deploy" "authelia" "package" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "authelia" "bridgePackage" ]
[ "services" "hyperhive" "deploy" "authelia" "bridgePackage" ]
)
];
# ⚠️ `deploy.forgejo` is declared in ./hive-ci.nix, not here, and it is the
# one entry with no `enable`: the forge is not optional — it is the canonical
# store for the meta flake and every agent's config repo, so it deploys with
# hyperhive itself. Running the CI runner is the only *deployment* decision
# it has, which is exactly the `{ enable; ci; }` shape the header describes,
# minus the half that does not apply. The knobs live with the module that
# reads them; this file stays the registry of toggles.
options.services.hyperhive.deploy = {
grafana.enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Run the swarm's metrics UI on this host.
Off by default and not derived from
{option}`services.hyperhive.enable`: a swarm has one Grafana, so
running it is a decision about this host rather than about
whether hyperhive is installed.
'';
};
victoriametrics.enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Run the swarm's metrics store on this host.
Paired with
{option}`services.hyperhive.deploy.grafana.enable`: a store
with no UI is unreadable and a UI with no store is empty, so there
is no sensible deployment that takes one and not the other from
that switch. Set either directly to run exactly one.
'';
};
victorialogs.enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Run the swarm's log store on this host.
A hive that is not the service host is a *client* of this store,
not a second one.
'';
};
bao.enable = lib.mkOption {
type = lib.types.bool;
default = false;
example = true;
description = ''
Run the swarm's secret store in a `swarm-bao` container on this
host. A swarm has one store and it has to exist somewhere.
*Where* it runs is a separate question from *that* it runs: set
this directly to put the store on a host of its own, and clients
still reach it by name at
{option}`services.hyperhive.swarm.bao.domain` rather than at a
local address.
With it off, this hive is a *client*: it still reads its own
secrets from whoever runs the store, authenticating with its own
client certificate. Every hive needs the client half; only one
runs the server half, which is why the two live in different
namespaces.
'';
};
authelia.enable = lib.mkOption {
type = lib.types.bool;
default = false;
example = true;
description = ''
Run the swarm's authelia in a `swarm-authelia` container on this
host. A swarm has one SSO provider, and this says it lives here.
With it off, this hive is a *client*:
{option}`services.hyperhive.swarm.authelia.url` still points at
whoever runs it, and no container is created. That asymmetry is
why the two live in different namespaces every hive needs the
client half, only one runs the server half.
'';
};
swarm-otel.enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Run the **swarm's** telemetry collector on this host.
A swarm has one of these, and it belongs wherever the shared
services live rather than on every hive.
Named `swarm-otel` rather than `otel` because there are two
collectors and the tier is the whole distinction:
{option}`services.hyperhive.otel.enable` is the **hive-tier** one,
which every hive runs. A bare `deploy.otel` would not say which
it meant. A hive that does not run the swarm collector still runs
its own, and reaches this one by name at
{option}`services.hyperhive.swarm.otel.domain`.
'';
};
matrix.enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Run the swarm's matrix homeserver matrix-tuwunel, in a
`hive-matrix` container on this host.
Set it directly to put the homeserver somewhere other than the
host holding the rest of the swarm's services.
Client-side settings stay in
{option}`services.hyperhive.swarm.matrix.*`, which every hive
agrees on; this is only the decision to run it here.
'';
};
nats.enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Run the swarm's message queue in a `swarm-nats` container on this
host. A swarm has one queue, so at most one host turns this on
but *which* host is its own decision, not necessarily the one
running the swarm's other shared services.
Off by default, and off means *absent*: no container is created
and nothing else in the evaluated config changes.
'';
};
swarm-controller.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
{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.enable = lib.mkOption {
type = lib.types.bool;
default = deployCfg.swarm-controller.enable;
defaultText = lib.literalExpression "services.hyperhive.deploy.swarm-controller.enable";
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.
'';
};
};
}