nix: gate hive-c0re on deploy.hive-controller.enable, drop hyperhive.enable

`services.hyperhive.enable` and `services.hyperhive.c0re.enable` are gone.
One switch, `services.hyperhive.deploy.hive-controller.enable` (default
false, as the old toggle was), now gates hive-c0re and hive-priv. Both old
paths are `mkRenamedOptionModule` shims in deploy.nix, so a host config
that still sets either evaluates as before and gets a rename warning.

Every other read of the old toggle is resolved, including the 29 made
through the `hyperhiveCfg`/`hiveCfg` aliases:

- Dropped: each swarm service and its glue keeps only its own deploy
  toggle (authelia, bao and its PKI glue, grafana, victorialogs,
  victoriametrics, the secret publisher, swarm-ca, the OIDC client rows,
  the controller/nats/matrix-ctl/publisher/services-issuer identities),
  the forge, and the `domain` deprecation warning.
- To deploy.hive-controller.enable: the queue-agent credential reader and
  its assertion, which feed hive-c0re and write under its state dir, plus
  their policy-order entry; the network identity assertions; hive-tls's
  two writes into hive-c0re's environment.
- hive-tls runs where the gateway runs self-signed
  (`gateway.enable && useSelfSigned`), not on every host.
- The matrix appservice-token reader and its assertion stay on
  `deploy.matrix.enable` plus their client-identity checks. They read
  deploy.matrix's token file and registration script; their deploy.bao
  inputs are the client-half options a hive sets to read a store it does
  not run, so gating on deploy.bao.enable would drop the tested
  remote-reader case.
- The `hiveName` assertion moves from hive-network.nix to hyperhive.nix
  and fires wherever the hive, the store or the homeserver runs: each
  turns the name into an identifier with no fallback.

On a host with `deploy.allSwarmServices` and no hive, the documented
services-host recipe, authelia, bao, grafana, victorialogs,
victoriametrics, the OIDC client rows and the hive CA now render; before,
the old toggle being off left them out.

Refs #4500
This commit is contained in:
atlas 2026-09-26 00:32:32 +02:00
commit 3202cde704
43 changed files with 292 additions and 162 deletions

View file

@ -77,7 +77,7 @@ Minimal `flake.nix` for a host that runs hive-c0re:
modules = [ modules = [
hyperhive.nixosModules.default # hive-c0re + hive-forge + hive-gateway in one import hyperhive.nixosModules.default # hive-c0re + hive-forge + hive-gateway in one import
({ ... }: { ({ ... }: {
services.hyperhive.enable = true; services.hyperhive.deploy.hive-controller.enable = true;
# services.hyperhive.c0re.operatorPronouns = "they/them"; # default: "she/her" # services.hyperhive.c0re.operatorPronouns = "they/them"; # default: "she/her"
# ... rest of your host config # ... rest of your host config

View file

@ -90,7 +90,7 @@ listener on `bridgeIp` is on the host's bridge interface.
```nix ```nix
{ {
services.hyperhive = { services.hyperhive = {
enable = true; deploy.hive-controller.enable = true;
hiveName = "pr1ma"; hiveName = "pr1ma";
swarm.domain = "darkest.space"; swarm.domain = "darkest.space";
swarm.hives.pr1ma = { }; # -> domain = pr1ma.darkest.space swarm.hives.pr1ma = { }; # -> domain = pr1ma.darkest.space

View file

@ -38,7 +38,7 @@ The store host is a swarm member like any other: it gets an entry in
[swarm/](../swarm/README.md) for the mesh itself. [swarm/](../swarm/README.md) for the mesh itself.
Note that `deploy.wireguard.enable` gates the mesh, **not** Note that `deploy.wireguard.enable` gates the mesh, **not**
`c0re.enable` --- a store host runs no hive and would otherwise get no `deploy.hive-controller.enable` --- a store host runs no hive and would otherwise get no
`wg-hive` interface at all. `wg-hive` interface at all.
## Pointing a hive at it ## Pointing a hive at it

View file

@ -299,9 +299,9 @@ migrating agent keeps one unbroken incremental chain. See
`services.hyperhive.deploy.swarm-controller.enable` runs the `swarm-controller` `services.hyperhive.deploy.swarm-controller.enable` runs the `swarm-controller`
daemon on this host. **Off by default and deliberately not derived from daemon on this host. **Off by default and deliberately not derived from
`services.hyperhive.enable`**: a swarm has one controller, so enabling it `services.hyperhive.deploy.hive-controller.enable`**: a swarm has one
is a statement about swarm topology, not about whether the operator has controller, so enabling it states a fact about swarm topology, not about
installed hyperhive. Every hive runs `hive-c0re` (the agents on that host); one whether this host runs a hive. Every hive runs `hive-c0re` (the agents on that host); one
hive additionally runs this (what's true across hives). hive additionally runs this (what's true across hives).
What it serves, why it's a unix socket rather than a port, and the What it serves, why it's a unix socket rather than a port, and the

View file

@ -119,7 +119,7 @@
# an operator override still wins. Intended usage: # an operator override still wins. Intended usage:
# #
# imports = [ hyperhive.nixosModules.default ]; # imports = [ hyperhive.nixosModules.default ];
# services.hyperhive.enable = true; # services.hyperhive.deploy.hive-controller.enable = true;
# #
default = default =
{ lib, pkgs, ... }: { lib, pkgs, ... }:

View file

@ -54,7 +54,7 @@ let
}; };
boot.loader.grub.enable = false; boot.loader.grub.enable = false;
system.stateVersion = "25.11"; system.stateVersion = "25.11";
services.hyperhive.enable = lib.mkForce false; services.hyperhive.deploy.hive-controller.enable = lib.mkForce false;
services.hyperhive.deploy.matrix.enable = lib.mkForce false; services.hyperhive.deploy.matrix.enable = lib.mkForce false;
} }
) )

View file

@ -1,7 +1,8 @@
# The full hyperhive host stack, pulled together in one place — this # The full hyperhive host stack, pulled together in one place — this
# is what the flake exports as `nixosModules.default` (wrapped with # is what the flake exports as `nixosModules.default` (wrapped with
# the package/source wiring; see flake.nix). One import covers # the package/source wiring; see flake.nix). One import covers
# everything; `services.hyperhive.enable = true` turns the stack on. # everything; `services.hyperhive.deploy.hive-controller.enable = true`
# runs a hive on this host.
# #
# Every swarm needs the forge — hive-c0re mirrors every agent's applied # Every swarm needs the forge — hive-c0re mirrors every agent's applied
# config repo into it and it's the canonical store for the meta flake # config repo into it and it's the canonical store for the meta flake
@ -9,7 +10,7 @@
# runs it (derived from `deploy.allSwarmServices`, see # runs it (derived from `deploy.allSwarmServices`, see
# ./swarm-required-services.nix). hive-matrix is opt-in (off by default). All # ./swarm-required-services.nix). hive-matrix is opt-in (off by default). All
# subsystems rely on `services.hyperhive.domain`, which is required # subsystems rely on `services.hyperhive.domain`, which is required
# (asserted in hive-network.nix) whenever hyperhive is enabled. # (asserted in hive-network.nix) wherever this host runs a hive.
{ {
imports = [ imports = [
./hyperhive.nix ./hyperhive.nix

View file

@ -119,6 +119,15 @@ in
[ "services" "hyperhive" "swarm" "statusPublish" "clientSecretFile" ] [ "services" "hyperhive" "swarm" "statusPublish" "clientSecretFile" ]
[ "services" "hyperhive" "deploy" "hive-controller" "statusPublish" "clientSecretFile" ] [ "services" "hyperhive" "deploy" "hive-controller" "statusPublish" "clientSecretFile" ]
) )
# Both old spellings of "run a hive here" forward to the one switch.
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "enable" ]
[ "services" "hyperhive" "deploy" "hive-controller" "enable" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "c0re" "enable" ]
[ "services" "hyperhive" "deploy" "hive-controller" "enable" ]
)
(lib.mkRenamedOptionModule (lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "otel" "enable" ] [ "services" "hyperhive" "swarm" "otel" "enable" ]
[ "services" "hyperhive" "deploy" "swarm-otel" "enable" ] [ "services" "hyperhive" "deploy" "swarm-otel" "enable" ]
@ -452,9 +461,9 @@ in
Run the swarm's metrics UI on this host. Run the swarm's metrics UI on this host.
Off by default and not derived from Off by default and not derived from
{option}`services.hyperhive.enable`: a swarm has one Grafana, so {option}`services.hyperhive.deploy.hive-controller.enable`: a swarm
running it is a decision about this host rather than about has one Grafana, so running it is a decision about this host rather
whether hyperhive is installed. than about whether it runs a hive.
''; '';
}; };
@ -570,6 +579,21 @@ in
''; '';
}; };
hive-controller.enable = lib.mkOption {
type = lib.types.bool;
default = false;
example = true;
description = ''
Run a hive on this host: the `hive-c0re` coordinator that owns every
agent container here, its `hive-priv` helper, and the bridge,
resolver and gateway the agents hang off.
Every hive turns this on. A host that only runs the swarm's shared
services ({option}`services.hyperhive.deploy.allSwarmServices`)
leaves it off.
'';
};
swarm-controller.enable = lib.mkOption { swarm-controller.enable = lib.mkOption {
type = lib.types.bool; type = lib.types.bool;
default = false; default = false;
@ -577,9 +601,9 @@ in
Run the swarm-controller daemon on this host. Run the swarm-controller daemon on this host.
Off by default and deliberately not derived from Off by default and deliberately not derived from
{option}`services.hyperhive.enable`: a swarm has one controller, {option}`services.hyperhive.deploy.hive-controller.enable`: a swarm
so running it is a decision about this host rather than about has one controller, so running it is a decision about this host
whether hyperhive is installed. rather than about whether it runs a hive.
''; '';
}; };

View file

@ -31,22 +31,21 @@ let
readersHere = { readersHere = {
# ./glue-matrix-bao-token.nix # ./glue-matrix-bao-token.nix
swarm-bao-matrix-token = swarm-bao-matrix-token =
hyperhiveCfg.enable havePair baoDeploy.matrixTokenClientCertFile baoDeploy.matrixTokenClientKeyFile
&& havePair baoDeploy.matrixTokenClientCertFile baoDeploy.matrixTokenClientKeyFile
&& deployCfg.matrix.enable; && deployCfg.matrix.enable;
# ./glue-queue-agent-credential.nix # ./glue-queue-agent-credential.nix
swarm-bao-queue-agent = swarm-bao-queue-agent =
hyperhiveCfg.enable deployCfg.hive-controller.enable
&& havePair baoDeploy.queueAgentClientCertFile baoDeploy.queueAgentClientKeyFile; && havePair baoDeploy.queueAgentClientCertFile baoDeploy.queueAgentClientKeyFile;
# ./swarm-grafana.nix # ./swarm-grafana.nix
swarm-bao-grafana-oidc = hyperhiveCfg.enable && deployCfg.grafana.enable; swarm-bao-grafana-oidc = deployCfg.grafana.enable;
# ./swarm-otel.nix # ./swarm-otel.nix
swarm-bao-otel-oidc = swarm-bao-otel-oidc =
deployCfg.swarm-otel.enable deployCfg.swarm-otel.enable
&& havePair baoDeploy.otelOidcClientCertFile baoDeploy.otelOidcClientKeyFile; && havePair baoDeploy.otelOidcClientCertFile baoDeploy.otelOidcClientKeyFile;
# ./swarm-bao.nix: its block is gated on the store, which `orderAfterPolicy` # ./swarm-bao.nix: its block is gated on the store, which `orderAfterPolicy`
# already checks. # already checks.
swarm-bao-forwarder-oidc = hyperhiveCfg.enable; swarm-bao-forwarder-oidc = true;
# ./swarm-nats.nix: the queue's TLS leaf, not a secret, but the same wait. # ./swarm-nats.nix: the queue's TLS leaf, not a secret, but the same wait.
swarm-bao-nats-tls = deployCfg.nats.enable; swarm-bao-nats-tls = deployCfg.nats.enable;
}; };

View file

@ -43,9 +43,10 @@ let
# What a reader calls itself to the store. The hive's name, because a bao # What a reader calls itself to the store. The hive's name, because a bao
# cert-auth role matches on the CN — this is an interface, not a label. # cert-auth role matches on the CN — this is an interface, not a label.
# No fallback: `hiveName` is asserted set for every hyperhive host, which is # No fallback: `hiveName` is asserted set wherever `deploy.bao.enable` is
# the same condition this file's `config` is gated on. A fallback here reads # (./hyperhive.nix), the same condition this file's `config` is gated on. A
# as a second supported spelling and there is no such thing. # fallback here reads as a second supported spelling and there is no such
# thing.
clientCn = hyperhiveCfg.hiveName; clientCn = hyperhiveCfg.hiveName;
# $1 dir $2 basename $3 CN $4 SAN or "" $5 EKU # $1 dir $2 basename $3 CN $4 SAN or "" $5 EKU
@ -71,7 +72,7 @@ let
''; '';
in in
{ {
config = lib.mkIf (hyperhiveCfg.enable && deployCfg.bao.enable) { config = lib.mkIf deployCfg.bao.enable {
services.hyperhive.deploy.bao = { services.hyperhive.deploy.bao = {
serverCertFile = lib.mkDefault "${pkiDir}/server.pem"; serverCertFile = lib.mkDefault "${pkiDir}/server.pem";
serverKeyFile = lib.mkDefault "${pkiDir}/server-key.pem"; serverKeyFile = lib.mkDefault "${pkiDir}/server-key.pem";

View file

@ -32,7 +32,7 @@ let
pkiDir = if haveMintedPki then builtins.dirOf baoDeploy.clientCertFile else null; pkiDir = if haveMintedPki then builtins.dirOf baoDeploy.clientCertFile else null;
in in
{ {
config = lib.mkIf (hyperhiveCfg.enable && deployCfg.swarm-controller.enable && haveMintedPki) { config = lib.mkIf (deployCfg.swarm-controller.enable && haveMintedPki) {
services.hyperhive.deploy.swarm-controller = { services.hyperhive.deploy.swarm-controller = {
baoClientCertFile = lib.mkDefault "${pkiDir}/controller.pem"; baoClientCertFile = lib.mkDefault "${pkiDir}/controller.pem";
baoClientKeyFile = lib.mkDefault "${pkiDir}/controller-key.pem"; baoClientKeyFile = lib.mkDefault "${pkiDir}/controller-key.pem";

View file

@ -24,7 +24,7 @@ let
forgeCfg = hyperhiveCfg.swarm.forge; forgeCfg = hyperhiveCfg.swarm.forge;
in in
{ {
config = lib.mkIf (hyperhiveCfg.enable && deployCfg.authelia.enable) { config = lib.mkIf deployCfg.authelia.enable {
# One declaration, two readers. The forge knows its own callback URL; # One declaration, two readers. The forge knows its own callback URL;
# making the operator restate it in authelia's client list would be a # making the operator restate it in authelia's client list would be a
# second source of truth for a string whose mismatch is a silent # second source of truth for a string whose mismatch is a silent

View file

@ -32,7 +32,7 @@ let
grafanaCfg = hyperhiveCfg.swarm.grafana; grafanaCfg = hyperhiveCfg.swarm.grafana;
in in
{ {
config = lib.mkIf (hyperhiveCfg.enable && deployCfg.authelia.enable) { config = lib.mkIf deployCfg.authelia.enable {
# One declaration, two readers. Grafana's callback URL is format-locked to # One declaration, two readers. Grafana's callback URL is format-locked to
# its own root URL, and the read-only option it is taken from is where that # its own root URL, and the read-only option it is taken from is where that
# format is spelled — restating it here would be a second source of truth # format is spelled — restating it here would be a second source of truth

View file

@ -69,7 +69,8 @@ let
# pieces; this literal is the nix half of that one agreement. # pieces; this literal is the nix half of that one agreement.
# #
# `hiveName` has no fallback here for the reason ./glue-bao-tls.nix gives at # `hiveName` has no fallback here for the reason ./glue-bao-tls.nix gives at
# its own use of it: it is asserted set for every hyperhive host. # its own use of it: it is asserted set on every host that runs the
# homeserver.
tokenPath = "secret/swarm/hives/${hyperhiveCfg.hiveName}/matrix/appservice-token"; tokenPath = "secret/swarm/hives/${hyperhiveCfg.hiveName}/matrix/appservice-token";
# A literal, not an option — ./hive-matrix.nix names its container # A literal, not an option — ./hive-matrix.nix names its container
@ -94,7 +95,7 @@ in
# is a hive that has no store, which is supported. So this one additionally # is a hive that has no store, which is supported. So this one additionally
# requires `hiveReaderIdentity`: the host demonstrably reads the store, and # requires `hiveReaderIdentity`: the host demonstrably reads the store, and
# named every option but this pair. # named every option but this pair.
(lib.mkIf (hyperhiveCfg.enable && deployCfg.matrix.enable && hiveReaderIdentity) { (lib.mkIf (deployCfg.matrix.enable && hiveReaderIdentity) {
assertions = [ assertions = [
{ {
assertion = haveClientIdentity; assertion = haveClientIdentity;
@ -124,7 +125,7 @@ in
]; ];
}) })
(lib.mkIf (hyperhiveCfg.enable && haveClientIdentity && deployCfg.matrix.enable) { (lib.mkIf (haveClientIdentity && deployCfg.matrix.enable) {
# Same rule as the unit's own gate: this reader exists on a host that has a # Same rule as the unit's own gate: this reader exists on a host that has a
# client identity and a homeserver, which is not every host that runs the # client identity and a homeserver, which is not every host that runs the
# store, so the store's module cannot name it. # store, so the store's module cannot name it.

View file

@ -31,7 +31,7 @@ let
pkiDir = if haveMintedPki then builtins.dirOf baoDeploy.clientCertFile else null; pkiDir = if haveMintedPki then builtins.dirOf baoDeploy.clientCertFile else null;
in in
{ {
config = lib.mkIf (hyperhiveCfg.enable && deployCfg.matrix.enable && haveMintedPki) { config = lib.mkIf (deployCfg.matrix.enable && haveMintedPki) {
services.hyperhive.deploy.matrix = { services.hyperhive.deploy.matrix = {
ctlBaoClientCertFile = lib.mkDefault "${pkiDir}/matrix-ctl.pem"; ctlBaoClientCertFile = lib.mkDefault "${pkiDir}/matrix-ctl.pem";
ctlBaoClientKeyFile = lib.mkDefault "${pkiDir}/matrix-ctl-key.pem"; ctlBaoClientKeyFile = lib.mkDefault "${pkiDir}/matrix-ctl-key.pem";

View file

@ -31,7 +31,7 @@ let
pkiDir = if haveMintedPki then builtins.dirOf baoDeploy.clientCertFile else null; pkiDir = if haveMintedPki then builtins.dirOf baoDeploy.clientCertFile else null;
in in
{ {
config = lib.mkIf (hyperhiveCfg.enable && deployCfg.nats.enable && haveMintedPki) { config = lib.mkIf (deployCfg.nats.enable && haveMintedPki) {
services.hyperhive.deploy.nats = { services.hyperhive.deploy.nats = {
baoClientCertFile = lib.mkDefault "${pkiDir}/nats.pem"; baoClientCertFile = lib.mkDefault "${pkiDir}/nats.pem";
baoClientKeyFile = lib.mkDefault "${pkiDir}/nats-key.pem"; baoClientKeyFile = lib.mkDefault "${pkiDir}/nats-key.pem";

View file

@ -20,10 +20,10 @@
# into one that is refused, which reaches the agent as a timeout. # into one that is refused, which reaches the agent as a timeout.
# #
# 📌 This runs wherever a client identity is configured, NOT only where the # 📌 This runs wherever a client identity is configured, NOT only where the
# store is — the rule ./glue-matrix-bao-token.nix states in full. There is no # store is — the rule ./glue-matrix-bao-token.nix states in full — and
# "this hive runs agents" condition to gate it on as well: agent containers # wherever this host runs a hive (`deploy.hive-controller.enable`): the
# are created at runtime by hive-c0re, so every hyperhive host is a host that # credential is for the agents hive-c0re creates here at runtime, and the unit
# may run one. # writes under hive-c0re's own state directory.
{ {
pkgs, pkgs,
lib, lib,
@ -74,7 +74,8 @@ let
# one agreement. # one agreement.
# #
# `hiveName` has no fallback here for the reason ./glue-bao-tls.nix gives at # `hiveName` has no fallback here for the reason ./glue-bao-tls.nix gives at
# its own use of it: it is asserted set for every hyperhive host. # its own use of it: it is asserted set wherever
# `deploy.hive-controller.enable` is.
credentialPath = "secret/swarm/hives/${hyperhiveCfg.hiveName}/queue/agent"; credentialPath = "secret/swarm/hives/${hyperhiveCfg.hiveName}/queue/agent";
in in
{ {
@ -110,11 +111,11 @@ in
# #
# Shaped after ./swarm-grafana.nix's `haveClientIdentity` assertion — the # Shaped after ./swarm-grafana.nix's `haveClientIdentity` assertion — the
# same refusal, named to this principal's own pair. Where Grafana's gate # same refusal, named to this principal's own pair. Where Grafana's gate
# is `deploy.grafana.enable`, this reader has no toggle of its own to # is `deploy.grafana.enable`, this reader's is the hive's own: every hive
# check: every hive runs one for its own agents, so reading the store at # runs one for its own agents, so on a hive, reading the store at all is
# all is what asks for it. Hence `hiveReaderIdentity` alone — a host # what asks for it. Hence `hiveReaderIdentity` — a host holding no hive
# holding no hive leaf has no store to read and nothing is missing. # leaf has no store to read and nothing is missing.
(lib.mkIf (hyperhiveCfg.enable && hiveReaderIdentity) { (lib.mkIf (deployCfg.hive-controller.enable && hiveReaderIdentity) {
assertions = [ assertions = [
{ {
assertion = haveClientIdentity; assertion = haveClientIdentity;
@ -149,7 +150,7 @@ in
]; ];
}) })
(lib.mkIf (hyperhiveCfg.enable && haveClientIdentity) { (lib.mkIf (deployCfg.hive-controller.enable && haveClientIdentity) {
# Same rule as the unit's own gate: this reader exists on any host holding # Same rule as the unit's own gate: this reader exists on any host holding
# a client identity, which is not every host that runs the store, so the # a client identity, which is not every host that runs the store, so the
# store's module cannot name it. # store's module cannot name it.

View file

@ -31,12 +31,10 @@ let
pkiDir = if haveMintedPki then builtins.dirOf baoDeploy.clientCertFile else null; pkiDir = if haveMintedPki then builtins.dirOf baoDeploy.clientCertFile else null;
in in
{ {
config = config = lib.mkIf (deployCfg.swarm-secret-publisher.enable && haveMintedPki) {
lib.mkIf (hyperhiveCfg.enable && deployCfg.swarm-secret-publisher.enable && haveMintedPki) services.hyperhive.deploy.swarm-secret-publisher = {
{ baoClientCertFile = lib.mkDefault "${pkiDir}/secret-publisher.pem";
services.hyperhive.deploy.swarm-secret-publisher = { baoClientKeyFile = lib.mkDefault "${pkiDir}/secret-publisher-key.pem";
baoClientCertFile = lib.mkDefault "${pkiDir}/secret-publisher.pem"; };
baoClientKeyFile = lib.mkDefault "${pkiDir}/secret-publisher-key.pem"; };
};
};
} }

View file

@ -34,7 +34,7 @@ let
pkiDir = if haveMintedPki then builtins.dirOf baoDeploy.clientCertFile else null; pkiDir = if haveMintedPki then builtins.dirOf baoDeploy.clientCertFile else null;
in in
{ {
config = lib.mkIf (hyperhiveCfg.enable && haveMintedPki) { config = lib.mkIf haveMintedPki {
services.hyperhive.deploy.hive-controller.tls = { services.hyperhive.deploy.hive-controller.tls = {
baoClientCertFile = lib.mkDefault "${pkiDir}/services-issuer.pem"; baoClientCertFile = lib.mkDefault "${pkiDir}/services-issuer.pem";
baoClientKeyFile = lib.mkDefault "${pkiDir}/services-issuer-key.pem"; baoClientKeyFile = lib.mkDefault "${pkiDir}/services-issuer-key.pem";

View file

@ -29,7 +29,7 @@ let
baoCfg = hyperhiveCfg.swarm.bao; baoCfg = hyperhiveCfg.swarm.bao;
in in
{ {
config = lib.mkIf (hyperhiveCfg.enable && deployCfg.authelia.enable) { config = lib.mkIf deployCfg.authelia.enable {
# One declaration, two readers: `clientId` is a read-only option # One declaration, two readers: `clientId` is a read-only option
# ./swarm-bao.nix owns, and ./swarm-otel.nix builds this principal's # ./swarm-bao.nix owns, and ./swarm-otel.nix builds this principal's
# authenticator audience from the same option. # authenticator audience from the same option.

View file

@ -32,7 +32,7 @@ let
otelCfg = hyperhiveCfg.swarm.otel; otelCfg = hyperhiveCfg.swarm.otel;
in in
{ {
config = lib.mkIf (hyperhiveCfg.enable && deployCfg.authelia.enable) { config = lib.mkIf deployCfg.authelia.enable {
# One declaration, two readers: `clientId` and `audience` are read-only # One declaration, two readers: `clientId` and `audience` are read-only
# options ./swarm-otel.nix derives from the scrape/push targets it owns, # options ./swarm-otel.nix derives from the scrape/push targets it owns,
# so this file states neither formula a second time. # so this file states neither formula a second time.

View file

@ -153,11 +153,11 @@ in
name = "hive-c0re"; name = "hive-c0re";
consumers = [ "hive-c0re" ]; consumers = [ "hive-c0re" ];
hostUnit = true; hostUnit = true;
enable = cfg.enable; enable = config.services.hyperhive.deploy.hive-controller.enable;
}) })
]; ];
config = lib.mkIf cfg.enable { config = lib.mkIf config.services.hyperhive.deploy.hive-controller.enable {
# This module is the host's only knowledge that agent containers # This module is the host's only knowledge that agent containers
# exist: they hang off the bridge, resolve through dnsmasq, and their # exist: they hang off the bridge, resolve through dnsmasq, and their
# UIs — plus the operator dashboard — are served by the gateway. # UIs — plus the operator dashboard — are served by the gateway.

View file

@ -7,17 +7,10 @@
{ {
pkgs, pkgs,
lib, lib,
config,
... ...
}: }:
{ {
options.services.hyperhive.c0re = { options.services.hyperhive.c0re = {
enable = lib.mkOption {
type = lib.types.bool;
default = config.services.hyperhive.enable;
defaultText = lib.literalExpression "config.services.hyperhive.enable";
description = "Enable hive-c0re coordinator daemon (auto-enabled by services.hyperhive.enable).";
};
package = lib.mkOption { package = lib.mkOption {
type = lib.types.package; type = lib.types.package;
defaultText = lib.literalExpression "hyperhive.packages.\${system}.default"; defaultText = lib.literalExpression "hyperhive.packages.\${system}.default";

View file

@ -459,7 +459,7 @@ in
}; };
}; };
config = lib.mkIf (config.services.hyperhive.enable && deployCfg.forgejo.enable) { config = lib.mkIf deployCfg.forgejo.enable {
# Same principle as the vhost below — this service's own surface lives # Same principle as the vhost below — this service's own surface lives
# with the service. The SSO source is named alongside forgejo because # with the service. The SSO source is named alongside forgejo because
# its failure mode is a login that silently falls back, not an error. # its failure mode is a login that silently falls back, not an error.

View file

@ -86,7 +86,8 @@ let
# saying so, as the token path below. # saying so, as the token path below.
# #
# `hiveName` has no fallback here for the reason ./glue-matrix-bao-token.nix # `hiveName` has no fallback here for the reason ./glue-matrix-bao-token.nix
# gives at its own use of it: it is asserted set for every hyperhive host. # gives at its own use of it: it is asserted set on every host that runs the
# homeserver.
hiveLocalpart = "hive-${toString config.services.hyperhive.hiveName}"; hiveLocalpart = "hive-${toString config.services.hyperhive.hiveName}";
# The `as_token`, and the `hs_token` the spec requires alongside it. Both # The `as_token`, and the `hs_token` the spec requires alongside it. Both

View file

@ -35,7 +35,7 @@ in
imports = [ imports = [
(lib.mkRemovedOptionModule [ "services" "hyperhive" "network" "isolateContainers" ] '' (lib.mkRemovedOptionModule [ "services" "hyperhive" "network" "isolateContainers" ] ''
Network isolation is the only mode and is always on whenever Network isolation is the only mode and is always on whenever
hyperhive is enabled; the shared-netns path was removed. Remove the hive runs; the shared-netns path was removed. Remove
the setting. the setting.
'') '')
(lib.mkRemovedOptionModule [ "services" "hyperhive" "network" "upstreamDns" ] '' (lib.mkRemovedOptionModule [ "services" "hyperhive" "network" "upstreamDns" ] ''
@ -148,11 +148,12 @@ in
}; };
config = lib.mkMerge [ config = lib.mkMerge [
# Identity checks belong to hyperhive being on, not to the bridge # Identity checks belong to the hive being on, not to the bridge
# being up: a hive with no domain is misconfigured either way, and # being up: a hive with no domain is misconfigured either way, and
# moving them under the bridge gate would hide them on exactly the # moving them under the bridge gate would hide them on exactly the
# hosts that are hardest to debug. # hosts that are hardest to debug. `hiveName` is asserted in
(lib.mkIf config.services.hyperhive.enable { # ./hyperhive.nix, where more than the hive depends on it.
(lib.mkIf config.services.hyperhive.deploy.hive-controller.enable {
# This message is only useful if an operator can actually reach # This message is only useful if an operator can actually reach
# it, and an assertion competes with every eager default that # it, and an assertion competes with every eager default that
# reads the value it guards: option defaults that interpolate the # reads the value it guards: option defaults that interpolate the
@ -196,16 +197,6 @@ in
`services.hyperhive.domain` for you. `services.hyperhive.domain` for you.
''; '';
} }
{
assertion = config.services.hyperhive.hiveName != null;
message = ''
hyperhive requires services.hyperhive.hiveName to be set —
it is this hive's label within the swarm, and the leftmost
part of the domain it is addressed by
(`<hiveName>.<swarm.domain>`), not only a display name.
Set it (`services.hyperhive.hiveName = "pr1ma";`).
'';
}
]; ];
}) })

View file

@ -19,7 +19,7 @@ let
''; '';
in in
{ {
config = lib.mkIf cfg.enable { config = lib.mkIf config.services.hyperhive.deploy.hive-controller.enable {
# Socket unit for hive-priv — the narrow root helper that executes # Socket unit for hive-priv — the narrow root helper that executes
# privileged operations on behalf of hive-c0re. Systemd creates and # privileged operations on behalf of hive-c0re. Systemd creates and
# holds `/run/hive/priv.sock` before the first connection arrives. # holds `/run/hive/priv.sock` before the first connection arrives.

View file

@ -37,12 +37,11 @@ let
# The host-managed hive CA is the trust anchor for self-signed mode. # The host-managed hive CA is the trust anchor for self-signed mode.
# It is only stood up when the gateway actually serves a self-signed # It is only stood up when the gateway actually serves a self-signed
# cert: the gateway must be in self-signed mode. `domain` is required # cert: the gateway must run here and be in self-signed mode. The
# (asserted in hive-network.nix), so the leaf SANs always have a # self-signed condition is the gateway module's single source of truth
# domain to derive from. The self-signed condition is the gateway # (`gateway.useSelfSigned`): true when neither an operator cert
# module's single source of truth (`gateway.useSelfSigned`): true when # (`tls.certDir`) nor ACME is set.
# neither an operator cert (`tls.certDir`) nor ACME is set. active = gatewayCfg.enable && gatewayCfg.useSelfSigned;
active = hyperhiveCfg.enable && gatewayCfg.useSelfSigned;
# How this hive's CA comes into existence when it is missing — and it # How this hive's CA comes into existence when it is missing — and it
# is one of exactly two things, chosen by config rather than by what # is one of exactly two things, chosen by config rather than by what
@ -1022,7 +1021,14 @@ in
# bundle. It is the ANCHOR bundle rather than `ca.pem` for the reason # bundle. It is the ANCHOR bundle rather than `ca.pem` for the reason
# spelled out where the bundle is written above; no key path is ever # spelled out where the bundle is written above; no key path is ever
# exposed (an agent that could read one could mint trusted certs). # exposed (an agent that could read one could mint trusted certs).
systemd.services.hive-c0re.environment.HIVE_TLS_CA_PATH = "${cfg.stateDir}/trust-bundle.pem"; #
# ⚠️ Every line here that names another daemon's unit is gated on that
# daemon's own enable: the gateway also runs on hosts with no hive and no
# controller, and defining an environment key on a unit that does not
# exist CREATES a unit fragment for it — inert (no `ExecStart`, empty
# `wantedBy`, never activated), and it evaluates and builds clean.
systemd.services.hive-c0re.environment.HIVE_TLS_CA_PATH =
lib.mkIf hyperhiveCfg.deploy.hive-controller.enable "${cfg.stateDir}/trust-bundle.pem";
# The same anchor, named for the swarm-queue clients that need it when # The same anchor, named for the swarm-queue clients that need it when
# they mint a token from authelia over TLS. Declared HERE, beside the # they mint a token from authelia over TLS. Declared HERE, beside the
@ -1038,18 +1044,11 @@ in
# variable name. The anchor was never missing; nothing pointed the queue # variable name. The anchor was never missing; nothing pointed the queue
# client at it. # client at it.
# #
# Set unconditionally within this module's `active` guard, exactly like # Where there is no hive CA this module contributes nothing at all, and
# the line above: where there is no hive CA this module contributes # the clients then fall back to the platform roots — which is correct for
# nothing at all, and the clients then fall back to the platform roots — # a swarm fronted by a public certificate.
# which is correct for a swarm fronted by a public certificate. systemd.services.hive-c0re.environment.HIVE_C0RE_OIDC_CA_FILE =
systemd.services.hive-c0re.environment.HIVE_C0RE_OIDC_CA_FILE = "${cfg.stateDir}/trust-bundle.pem"; lib.mkIf hyperhiveCfg.deploy.hive-controller.enable "${cfg.stateDir}/trust-bundle.pem";
# ⚠️ Gated, where the hive-c0re line above is not, and the asymmetry is
# the point: hive-c0re runs on every hive, the controller runs on one.
# Defining an environment key on a unit that does not exist CREATES a
# unit fragment for it — inert (no `ExecStart`, empty `wantedBy`, never
# activated) but present on every non-controller hive with a CA. Caught
# 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 = systemd.services.swarm-controller.environment.SWARM_CONTROLLER_OIDC_CA_FILE =
lib.mkIf hyperhiveCfg.deploy.swarm-controller.enable "${cfg.stateDir}/trust-bundle.pem"; lib.mkIf hyperhiveCfg.deploy.swarm-controller.enable "${cfg.stateDir}/trust-bundle.pem";
}; };

View file

@ -1,7 +1,6 @@
# Top-level, cross-cutting hyperhive options: the master enable # Top-level, cross-cutting hyperhive options: the hive's identity
# switch, the hive's identity (domain + display names), and hive-wide # (domain + display names) and hive-wide feature toggles read by several
# feature toggles read by several subsystem modules. Imported by the # subsystem modules. Imported by the ./default.nix aggregator.
# ./default.nix aggregator.
{ {
lib, lib,
config, config,
@ -22,13 +21,9 @@ in
) )
]; ];
# Top-level hyperhive enable flag. When true, automatically enables
# hive-c0re and the on-by-default hyperhive subsystems.
options.services.hyperhive.enable = lib.mkEnableOption "hyperhive — the agent swarm coordinator";
# Canonical hive DNS domain shared by every subsystem that needs a # Canonical hive DNS domain shared by every subsystem that needs a
# stable hostname. Typed nullOr (default null) so the option always # stable hostname. Typed nullOr (default null) so the option always
# exists, but it's REQUIRED whenever hyperhive is enabled — an # exists, but it's REQUIRED wherever this host runs a hive — an
# assertion in hive-network.nix fails eval when it's unset, since # assertion in hive-network.nix fails eval when it's unset, since
# matrix bakes it in on first boot and the gateway/forge/agent URLs all # matrix bakes it in on first boot and the gateway/forge/agent URLs all
# derive from it (no safe default). Full identity-surface # derive from it (no safe default). Full identity-surface
@ -61,7 +56,8 @@ in
stable name (currently: `services.hyperhive.swarm.matrix.serverName` stable name (currently: `services.hyperhive.swarm.matrix.serverName`
derives from this, defaulting to derives from this, defaulting to
`matrix.''${services.hyperhive.domain}` when `serverName` is `matrix.''${services.hyperhive.domain}` when `serverName` is
null). **Required** when `services.hyperhive.enable` — eval fails null). **Required** when
`services.hyperhive.deploy.hive-controller.enable` — eval fails
with a helpful message if it's unset (it's baked into matrix on with a helpful message if it's unset (it's baked into matrix on
first boot and drives the gateway/forge/agent URLs, with no safe first boot and drives the gateway/forge/agent URLs, with no safe
default; changing it later is destructive). Exposed to agents as default; changing it later is destructive). Exposed to agents as
@ -88,24 +84,22 @@ in
# nothing — measured, after this warning fired on the conventional # nothing — measured, after this warning fired on the conventional
# case. `mkOptionDefault` is priority 1500, so anything lower is a # case. `mkOptionDefault` is priority 1500, so anything lower is a
# definition someone actually wrote (100 plain, 1000 mkDefault). # definition someone actually wrote (100 plain, 1000 mkDefault).
config.warnings = config.warnings = lib.optional (options.services.hyperhive.domain.highestPrio < 1500) ''
lib.optional (hiveCfg.enable && options.services.hyperhive.domain.highestPrio < 1500) services.hyperhive.domain is set explicitly and is deprecated. This
'' hive's address belongs in the swarm directory, which every hive in
services.hyperhive.domain is set explicitly and is deprecated. This the swarm shares a copy of:
hive's address belongs in the swarm directory, which every hive in
the swarm shares a copy of:
services.hyperhive.swarm.hives."${toString hiveCfg.hiveName}".domain = "${toString hiveCfg.domain}"; services.hyperhive.swarm.hives."${toString hiveCfg.hiveName}".domain = "${toString hiveCfg.domain}";
(Or drop the value entirely if it is the conventional (Or drop the value entirely if it is the conventional
`<hiveName>.<swarm.domain>` — that is the directory entry's own `<hiveName>.<swarm.domain>` — that is the directory entry's own
default.) default.)
Setting it here still wins, so nothing is broken right now. What it Setting it here still wins, so nothing is broken right now. What it
does not do is tell the other hives: they read this hive's address does not do is tell the other hives: they read this hive's address
out of their copy of the directory, so a value written only here out of their copy of the directory, so a value written only here
leaves them pointing somewhere else with nothing detecting it. leaves them pointing somewhere else with nothing detecting it.
''; '';
# Where the swarm lives. Declared beside the hive's own identity # Where the swarm lives. Declared beside the hive's own identity
# because it is what that identity is derived FROM — every hive in a # because it is what that identity is derived FROM — every hive in a
@ -123,7 +117,8 @@ in
`services.hyperhive.hiveName`, list the hives by name, and no `services.hyperhive.hiveName`, list the hives by name, and no
hive in the swarm states an address at all. hive in the swarm states an address at all.
**Required** when `services.hyperhive.enable`, and deliberately **Required** when
`services.hyperhive.deploy.hive-controller.enable`, and deliberately
not defaulted: there is no fallback worth having. A guessed not defaulted: there is no fallback worth having. A guessed
swarm domain is a wrong hostname that evaluates cleanly and swarm domain is a wrong hostname that evaluates cleanly and
deploys, which is worse than an eval failure telling an deploys, which is worse than an eval failure telling an
@ -144,7 +139,10 @@ in
example = "pr1ma"; example = "pr1ma";
description = '' description = ''
Human-readable name of this single-host hive instance. Human-readable name of this single-host hive instance.
**Required** when `services.hyperhive.enable`. Distinct from **Required** when this host runs a hive, the swarm's secret store
or its homeserver
(`services.hyperhive.deploy.hive-controller.enable`,
`deploy.bao.enable`, `deploy.matrix.enable`). Distinct from
`services.hyperhive.domain` (the machine-addressable DNS name) `services.hyperhive.domain` (the machine-addressable DNS name)
but no longer merely cosmetic: a hive occupies but no longer merely cosmetic: a hive occupies
`<hiveName>.<swarm.domain>`, so this is the label the hive is `<hiveName>.<swarm.domain>`, so this is the label the hive is
@ -154,6 +152,28 @@ in
''; '';
}; };
# Each of these deployments turns `hiveName` into an identifier with no
# fallback: hive-c0re's OIDC client and matrix localpart, the CN of the
# store's `client.pem` (./glue-bao-tls.nix), and the `hives/<name>` path
# the matrix token and queue credential readers fetch. A null there renders
# as an empty string that evaluates and deploys.
config.assertions = [
{
assertion =
!(
hiveCfg.deploy.hive-controller.enable || hiveCfg.deploy.bao.enable || hiveCfg.deploy.matrix.enable
)
|| hiveCfg.hiveName != null;
message = ''
hyperhive requires services.hyperhive.hiveName to be set on a host
that runs a hive, the swarm's secret store or its homeserver — it is
this hive's label within the swarm, and the leftmost part of the
domain it is addressed by (`<hiveName>.<swarm.domain>`), not only a
display name. Set it (`services.hyperhive.hiveName = "pr1ma";`).
'';
}
];
# The one hive-level option that describes something ABOVE the hive, # The one hive-level option that describes something ABOVE the hive,
# which is why it sits under `swarm` with the swarm-global services # which is why it sits under `swarm` with the swarm-global services
# rather than beside `hiveName`. # rather than beside `hiveName`.

View file

@ -919,7 +919,7 @@ in
}; };
}; };
config = lib.mkIf (hyperhiveCfg.enable && deployCfg.authelia.enable) { config = lib.mkIf deployCfg.authelia.enable {
# The derived half of the client list, declared the same way an # The derived half of the client list, declared the same way an
# operator declares one. Everything downstream then reads a single # operator declares one. Everything downstream then reads a single
# uniformly-typed `cfg.oidc.clients` and cannot tell the parts apart — # uniformly-typed `cfg.oidc.clients` and cannot tell the parts apart —

View file

@ -1573,7 +1573,7 @@ in
# they still fire when the cert paths are unset — the arm below is not # they still fire when the cert paths are unset — the arm below is not
# evaluated in that case, and an assertion that disappears exactly when # evaluated in that case, and an assertion that disappears exactly when
# its subject is broken would be worse than none. # its subject is broken would be worse than none.
(lib.mkIf (hyperhiveCfg.enable && deployCfg.bao.enable) { (lib.mkIf deployCfg.bao.enable {
assertions = [ assertions = [
{ {
assertion = lib.versionOlder baoDeploy.package.version "2.7.0"; assertion = lib.versionOlder baoDeploy.package.version "2.7.0";
@ -1655,7 +1655,7 @@ in
services.hyperhive.network.enable = lib.mkDefault true; services.hyperhive.network.enable = lib.mkDefault true;
}) })
(lib.mkIf (hyperhiveCfg.enable && deployCfg.bao.enable) { (lib.mkIf deployCfg.bao.enable {
# The wrapper alone, never `baoDeploy.package`: openbao's own module # The wrapper alone, never `baoDeploy.package`: openbao's own module
# installs the CLI *inside* the container, and this host had none at all, # installs the CLI *inside* the container, and this host had none at all,
# so an operator either hopped into the container or re-typed the # so an operator either hopped into the container or re-typed the
@ -1845,7 +1845,7 @@ in
}; };
}) })
(lib.mkIf (hyperhiveCfg.enable && deployCfg.bao.enable && haveServerTls) { (lib.mkIf (deployCfg.bao.enable && haveServerTls) {
# Provisions the TPM-backed token the seal above names. One-shot and # Provisions the TPM-backed token the seal above names. One-shot and
# idempotent on ABSENCE, never on content: regenerating a PIN would # idempotent on ABSENCE, never on content: regenerating a PIN would
# orphan an already-sealed store, so a rebuild must not rotate it. # orphan an already-sealed store, so a rebuild must not rotate it.

View file

@ -106,7 +106,7 @@ in
}; };
}; };
config = lib.mkIf (hyperhiveCfg.enable && cfg.autoConfigure) { config = lib.mkIf cfg.autoConfigure {
# A CA that fails to issue is invisible until something makes a TLS call # A CA that fails to issue is invisible until something makes a TLS call
# hours later, so this oneshot is worth more than most services. # hours later, so this oneshot is worth more than most services.
services.hyperhive.swarm.otel.journaldUnits = [ "swarm-ca" ]; services.hyperhive.swarm.otel.journaldUnits = [ "swarm-ca" ];

View file

@ -349,7 +349,7 @@ in
# `enable` moved to `services.hyperhive.deploy.swarm-controller.enable` — see # `enable` moved to `services.hyperhive.deploy.swarm-controller.enable` — see
# ./deploy.nix. `services.hyperhive.deploy.singleHostSwarm` still # ./deploy.nix. `services.hyperhive.deploy.singleHostSwarm` still
# asserts it, and that was never an exception to "not derived from # asserts it, and that was never an exception to "not derived from
# services.hyperhive.enable": that mode says "this box is the whole # deploy.hive-controller.enable": that mode says "this box is the whole
# deployment", which answers the topology question outright, where # deployment", which answers the topology question outright, where
# `enable` alone never can. Both packages moved to that same # `enable` alone never can. Both packages moved to that same
# `deploy.swarm-controller` block. What stays here is what the daemon IS. # `deploy.swarm-controller` block. What stays here is what the daemon IS.

View file

@ -405,7 +405,7 @@ in
}; };
}; };
config = lib.mkIf (hyperhiveCfg.enable && deployCfg.grafana.enable) { config = lib.mkIf deployCfg.grafana.enable {
# The gateway name and the quick-link, both inside `deploy.grafana` — that # The gateway name and the quick-link, both inside `deploy.grafana` — that
# guard is the load-bearing part. Every hive in a swarm may know this UI # guard is the load-bearing part. Every hive in a swarm may know this UI
# exists, but only the host that RUNS it may claim the name; a client # exists, but only the host that RUNS it may claim the name; a client

View file

@ -47,7 +47,7 @@ let
# Both halves have to be here: the mint (authelia, for the plaintext) and an # Both halves have to be here: the mint (authelia, for the plaintext) and an
# identity (for the store). Neither implies the other. # identity (for the store). Neither implies the other.
active = hyperhiveCfg.enable && cfg.enable && deployCfg.authelia.enable && haveClientIdentity; active = cfg.enable && deployCfg.authelia.enable && haveClientIdentity;
hiveNames = lib.attrNames hyperhiveCfg.swarm.hives; hiveNames = lib.attrNames hyperhiveCfg.swarm.hives;

View file

@ -134,7 +134,7 @@ in
''; '';
}; };
config = lib.mkIf (hyperhiveCfg.enable && deployCfg.victorialogs.enable) { config = lib.mkIf deployCfg.victorialogs.enable {
# This store publishes its own health as prometheus metrics on the same # This store publishes its own health as prometheus metrics on the same
# listener it serves queries on, so the swarm's collector scrapes it with # listener it serves queries on, so the swarm's collector scrapes it with
# no exporter and no extra port — same arrangement as the metrics store. # no exporter and no extra port — same arrangement as the metrics store.

View file

@ -95,7 +95,7 @@ in
''; '';
}; };
config = lib.mkIf (hyperhiveCfg.enable && deployCfg.victoriametrics.enable) { config = lib.mkIf deployCfg.victoriametrics.enable {
# The gateway name and the quick-link, both inside `deployCfg.victoriametrics.enable` — that # The gateway name and the quick-link, both inside `deployCfg.victoriametrics.enable` — that
# guard is the load-bearing part. Every hive in a swarm may know this # guard is the load-bearing part. Every hive in a swarm may know this
# store exists, but only the host that RUNS it may claim the name; a # store exists, but only the host that RUNS it may claim the name; a

View file

@ -84,13 +84,11 @@
}; };
}; };
# Gated on the mesh itself, NOT on the c0re daemon. The mesh is host # Gated on the mesh itself, NOT on `deploy.hive-controller.enable`. The
# networking, not a c0re feature: a swarm host that runs no hive — # mesh is host networking, not a c0re feature: a swarm host that runs no
# the snapshot store, for one — still has to join the mesh, and under # hive — the snapshot store, for one — still has to join the mesh.
# the old `c0re.enable` gate it silently got no `wg-hive` interface # Nothing below is c0re-specific; the peer data c0re consumes
# at all. Nothing below is c0re-specific; the peer data c0re consumes # (HIVE_PEER_CA_PATHS) is rendered in ./hive-c0re and stays gated there.
# (HIVE_PEER_CA_PATHS) is rendered in ./hive-c0re and stays gated
# there.
config = lib.mkIf config.services.hyperhive.deploy.wireguard.enable { config = lib.mkIf config.services.hyperhive.deploy.wireguard.enable {
assertions = [ assertions = [
{ {

View file

@ -70,7 +70,8 @@ let
# The same omission on a hive that does NOT run a homeserver. Separates the # The same omission on a hive that does NOT run a homeserver. Separates the
# matrix refusal's `deploy.matrix.enable` clause from the queue refusal, which # matrix refusal's `deploy.matrix.enable` clause from the queue refusal, which
# has no toggle to check — an arm below reads exactly one refusal off it. # checks only the hive's own toggle — an arm below reads exactly one refusal
# off it.
remoteReaderNoMatrixMissingLeaves = hive { remoteReaderNoMatrixMissingLeaves = hive {
deploy.bao.clientCertFile = "/etc/pki/bao-client.pem"; deploy.bao.clientCertFile = "/etc/pki/bao-client.pem";
deploy.bao.clientKeyFile = "/etc/pki/bao-client-key.pem"; deploy.bao.clientKeyFile = "/etc/pki/bao-client-key.pem";

View file

@ -30,17 +30,17 @@ let
bare = hive { }; bare = hive { };
# The same stub with the central toggle off. Paired with `bare` below to pin # The same stub with the central toggle (`deploy.hive-controller.enable`)
# the defaults that used to read `services.hyperhive.enable` and no longer # off. Paired with `bare` below to pin the defaults that do not read it:
# do: each is asserted to hold the SAME literal in both, so a future edit # each is asserted to hold the SAME literal in both, so a future edit
# that quietly re-introduces the dependency — or that changes what the # that quietly introduces the dependency — or that changes what the
# default renders for a hive with the toggle on — fails here. Reading an # default renders for a hive with the toggle on — fails here. Reading an
# option off this fixture forces that option only, not the config, so the # option off this fixture forces that option only, not the config, so the
# toggle being off costs nothing. Also the "installs the modules and turns # toggle being off costs nothing. Also the "installs the modules and turns
# nothing on" host the swarm-service absences below read: none of the # nothing on" host the swarm-service absences below read: none of the
# per-service deployment toggles derives from the hive being on, so it # per-service deployment toggles derives from the hive being on, so it
# renders the same absences `bare` does. # renders the same absences `bare` does.
centralToggleOff = hive { enable = false; }; centralToggleOff = hive { deploy.hive-controller.enable = false; };
# Every hive defaults the agents' queue address to the queue's name, so the # Every hive defaults the agents' queue address to the queue's name, so the
# only hive without one is a hive told to have none. # only hive without one is a hive told to have none.
@ -104,7 +104,7 @@ let
name = "the swarm controller's forgeTokenFile default does not consult the central toggle"; name = "the swarm controller's forgeTokenFile default does not consult the central toggle";
ok = ok =
(hive { (hive {
enable = false; deploy.hive-controller.enable = false;
deploy.forgejo.enable = true; deploy.forgejo.enable = true;
}).services.hyperhive.deploy.swarm-controller.forgeTokenFile }).services.hyperhive.deploy.swarm-controller.forgeTokenFile
== "/var/lib/hyperhive-forge/swarm-controller.token"; == "/var/lib/hyperhive-forge/swarm-controller.token";
@ -453,6 +453,55 @@ let
&& bare.services.dnsmasq.enable && bare.services.dnsmasq.enable
&& bare.networking.bridges ? hive-br0; && bare.networking.bridges ? hive-br0;
} }
{
# A host config written against either old spelling still runs its hive.
# The builder's own `true` is lowered to `mkDefault false`, so only the
# forwarded definition can turn it on.
name = "the old hive toggles forward to deploy.hive-controller.enable and warn";
ok =
let
renamedFrom =
old: m:
lib.any (
w: lib.hasInfix old w && lib.hasInfix "services.hyperhive.deploy.hive-controller.enable" w
) m.warnings;
viaOld =
extra:
hive (
extra
// {
deploy.hive-controller.enable = lib.mkDefault false;
}
);
oldEnable = viaOld { enable = true; };
oldC0re = viaOld { c0re.enable = true; };
in
oldEnable.services.hyperhive.deploy.hive-controller.enable
&& oldEnable.systemd.services ? hive-c0re
&& renamedFrom "services.hyperhive.enable" oldEnable
&& oldC0re.services.hyperhive.deploy.hive-controller.enable
&& oldC0re.systemd.services ? hive-c0re
&& renamedFrom "services.hyperhive.c0re.enable" oldC0re;
}
{
# Refused wherever something turns the name into an identifier, and only
# there. The store host is the control that the refusal is not the hive's
# alone; the host running neither is the control that it is gated at all.
name = "a missing hiveName is refused on a hive and on a store host, not elsewhere";
ok =
let
refusedHiveName =
extra:
lib.any (a: !a.assertion && lib.hasInfix "services.hyperhive.hiveName to be set" a.message)
(hive ({ hiveName = null; } // extra)).assertions;
in
refusedHiveName { }
&& refusedHiveName {
deploy.hive-controller.enable = false;
deploy.bao.enable = true;
}
&& !(refusedHiveName { deploy.hive-controller.enable = false; });
}
]; ];
in in
runGroup "core-toggle" cases runGroup "core-toggle" cases

View file

@ -59,7 +59,7 @@ let
# just one level down. `recursiveUpdate` merges nested attrsets # just one level down. `recursiveUpdate` merges nested attrsets
# all the way down instead. # all the way down instead.
services.hyperhive = lib.recursiveUpdate { services.hyperhive = lib.recursiveUpdate {
enable = true; deploy.hive-controller.enable = true;
hiveName = "h1"; hiveName = "h1";
swarm.domain = "t.local"; swarm.domain = "t.local";
swarm.hives.h1.domain = "h1.t.local"; swarm.hives.h1.domain = "h1.t.local";

View file

@ -23,20 +23,37 @@ let
swarmServiceEnables swarmServiceEnables
; ;
# The swarm-services toggle with the central one off, so the only thing # A dedicated services host with hives elsewhere: the swarm-services
# that can enable the gateway/resolver/bridge here is that toggle's own # toggle and no hive, the recipe docs/swarm/services.md gives.
# module — every other module that asserts them is behind `enable`.
swarmServicesOnly = hive { swarmServicesOnly = hive {
enable = false; deploy.hive-controller.enable = false;
deploy.allSwarmServices = true; deploy.allSwarmServices = true;
}; };
# The same switch with every service it owns placed elsewhere, so the only
# thing that can enable the gateway/resolver/bridge here is that toggle's
# own module — each service module asserts them from its own `enable`.
swarmServicesSwitchAlone = hive {
deploy.hive-controller.enable = false;
deploy.allSwarmServices = true;
deploy.matrix.enable = false;
otel.enable = false;
deploy.authelia.enable = false;
deploy.nats.enable = false;
deploy.swarm-otel.enable = false;
deploy.victoriametrics.enable = false;
deploy.grafana.enable = false;
deploy.victorialogs.enable = false;
deploy.bao.enable = false;
deploy.forgejo.enable = false;
};
# Ports asked for on such a host. The request is the whole condition: the # Ports asked for on such a host. The request is the whole condition: the
# firewall hole exists because an operator named a port, not because the # firewall hole exists because an operator named a port, not because the
# hive is running, and an agent reaching a host service is a claim about # hive is running, and an agent reaching a host service is a claim about
# the host's own listeners either way. # the host's own listeners either way.
exposedPortsNoHive = hive { exposedPortsNoHive = hive {
enable = false; deploy.hive-controller.enable = false;
network.exposeHostPorts = [ 5432 ]; network.exposeHostPorts = [ 5432 ];
}; };
@ -129,9 +146,44 @@ let
# module rather than from any of their defaults. # module rather than from any of their defaults.
name = "the swarm-services toggle turns on the gateway, resolver and bridge by itself"; name = "the swarm-services toggle turns on the gateway, resolver and bridge by itself";
ok = ok =
swarmServicesOnly.services.hyperhive.gateway.enable swarmServicesSwitchAlone.services.hyperhive.gateway.enable
&& swarmServicesOnly.services.hyperhive.gateway.dns.enable && swarmServicesSwitchAlone.services.hyperhive.gateway.dns.enable
&& swarmServicesOnly.services.hyperhive.network.enable; && swarmServicesSwitchAlone.services.hyperhive.network.enable;
}
{
# Each service is gated on its own deployment toggle alone, so a host
# that runs no hive still runs every shared service the switch turns
# on, plus the plumbing and the self-signed CA they are served through.
# Nothing starts the hive's own daemons. `hive-c0re` is checked by what
# starts it rather than by key: ../host-modules/hive-network.nix's bridge
# block writes to its environment wherever the bridge is up.
name = "a services-only host runs the swarm's shared services and no hive";
ok =
let
c = swarmServicesOnly.containers;
s = swarmServicesOnly.systemd.services;
h = swarmServicesOnly.services.hyperhive;
in
lib.all (m: c ? ${m}) [
"swarm-authelia"
"swarm-bao"
"swarm-grafana"
"swarm-victorialogs"
"swarm-victoriametrics"
"swarm-otel"
"swarm-nats"
"hive-matrix"
"hive-forge"
]
&& h.gateway.enable
&& h.gateway.dns.enable
&& h.network.enable
&& s ? hive-tls-ca
&& s ? swarm-bao-pki
&& (s.hive-c0re.wantedBy or [ ]) == [ ]
&& !(swarmServicesOnly.systemd.sockets ? hive-c0re)
&& !(swarmServicesOnly.systemd.sockets ? hive-priv)
&& !(s ? swarm-bao-queue-agent);
} }
{ {
# An operator's explicit `false` beats every `mkDefault` assertion, # An operator's explicit `false` beats every `mkDefault` assertion,

View file

@ -5,8 +5,9 @@ owns what is true _across_ hives — so a swarm runs one of them and most hives
leave it off. leave it off.
Opt-in per host via `services.hyperhive.deploy.swarm-controller.enable`, which is Opt-in per host via `services.hyperhive.deploy.swarm-controller.enable`, which is
deliberately **not** derived from `services.hyperhive.enable`: turning it on is deliberately **not** derived from
a statement about swarm topology, not about whether hyperhive is installed. `services.hyperhive.deploy.hive-controller.enable`: turning it on is a statement
about swarm topology, not about whether this host runs a hive.
## What it does today ## What it does today