Compare commits
3 changed files with 34 additions and 185 deletions
|
|
@ -88,33 +88,5 @@ in
|
||||||
# instead would be wrong: a hive in a larger swarm can legitimately
|
# instead would be wrong: a hive in a larger swarm can legitimately
|
||||||
# want the shared services without being the host that controls them.
|
# want the shared services without being the host that controls them.
|
||||||
controller.enable = lib.mkDefault cfg.enableAllLocalDefaults;
|
controller.enable = lib.mkDefault cfg.enableAllLocalDefaults;
|
||||||
|
|
||||||
# The controller's queue coordinates. Co-location is what makes these
|
|
||||||
# derivable at all — loopback only reaches the queue when the queue is
|
|
||||||
# here, and the minted client secret only exists on the host authelia
|
|
||||||
# ran its first boot on — so they belong to the mode that asserts this
|
|
||||||
# box is the whole deployment, not to the options' own `default`.
|
|
||||||
#
|
|
||||||
# Deriving them from `swarm.nats.enable` / `swarm.authelia.enable`
|
|
||||||
# inside those defaults is the mixing this file exists to prevent: the
|
|
||||||
# option would be describing a deployment shape instead of describing
|
|
||||||
# itself, and "what does all-local turn on?" would stop having one
|
|
||||||
# answer.
|
|
||||||
#
|
|
||||||
# ⚠️ Must live INSIDE this attrset, not as a second
|
|
||||||
# `config.services.hyperhive.swarm.…` path beside it — written that
|
|
||||||
# way the two definitions of `swarm` collide and the nested one is
|
|
||||||
# silently lost. The gate caught exactly that: mode on, `natsUrl`
|
|
||||||
# still "".
|
|
||||||
#
|
|
||||||
# The *requirement* stays in `swarm-controller.nix` as an assertion:
|
|
||||||
# needing a queue is the controller's own property in every topology,
|
|
||||||
# and only the convenience is local.
|
|
||||||
controller.queue.natsUrl = lib.mkIf cfg.enableAllLocalDefaults (
|
|
||||||
lib.mkDefault "nats://127.0.0.1:${toString config.services.hyperhive.swarm.nats.port}"
|
|
||||||
);
|
|
||||||
controller.queue.clientSecretFile = lib.mkIf cfg.enableAllLocalDefaults (
|
|
||||||
lib.mkDefault "${config.services.hyperhive.swarm.authelia.hostClientSecretDir}/swarm-controller.secret"
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,43 +43,39 @@ let
|
||||||
# the swarm has one.
|
# the swarm has one.
|
||||||
queueClientId = "swarm-controller";
|
queueClientId = "swarm-controller";
|
||||||
|
|
||||||
|
# Both halves have to be here: authelia to have minted the secret, and the
|
||||||
|
# queue to connect to. Same guard, and the same reasoning, as `autheliaEnv`
|
||||||
|
# above — a value set on a host that runs neither would point at a file
|
||||||
|
# that does not exist and produce a daemon that retries forever.
|
||||||
|
queueLocal = autheliaCfg.enable && natsCfg.enable;
|
||||||
|
|
||||||
# `LoadCredential` and not a copy-oneshot, which is where this deliberately
|
# `LoadCredential` and not a copy-oneshot, which is where this deliberately
|
||||||
# differs from the callout responder: that one delivers INTO a container,
|
# differs from the callout responder: that one delivers INTO a container,
|
||||||
# so it has to copy across a filesystem boundary. The controller is a plain
|
# so it has to copy across a filesystem boundary. The controller is a plain
|
||||||
# host unit, so systemd can hand it the file directly — fewer moving parts,
|
# host unit, so systemd can hand it the file directly — fewer moving parts,
|
||||||
# and the secret never gains a second on-disk copy to forget about.
|
# and the secret never gains a second on-disk copy to forget about.
|
||||||
#
|
queueEnv = lib.optionalAttrs queueLocal {
|
||||||
# Every value here comes from an option rather than from what happens to
|
# The queue container shares the host netns, so loopback is correct here
|
||||||
# run on this host. The queue is not optional for a controller, but
|
# and is not the `localhost`-means-the-wrong-thing trap that applies
|
||||||
# *co-location with it* is — "controller needs queue, but it may not run on
|
# inside agent containers.
|
||||||
# same host if configured properly by operator" is the rule. The
|
SWARM_CONTROLLER_NATS_URL = "nats://127.0.0.1:${toString natsCfg.port}";
|
||||||
# defaults below cover the co-located case so nobody types a path they
|
SWARM_CONTROLLER_OIDC_TOKEN_ENDPOINT = "${autheliaCfg.url}/api/oidc/token";
|
||||||
# didn't need to; the assertions cover the split case so nobody gets a
|
|
||||||
# daemon that silently never connects.
|
|
||||||
queueEnv = {
|
|
||||||
SWARM_CONTROLLER_NATS_URL = cfg.queue.natsUrl;
|
|
||||||
SWARM_CONTROLLER_OIDC_TOKEN_ENDPOINT = cfg.queue.tokenEndpoint;
|
|
||||||
SWARM_CONTROLLER_OIDC_CLIENT_ID = queueClientId;
|
SWARM_CONTROLLER_OIDC_CLIENT_ID = queueClientId;
|
||||||
# `%d` is systemd's credentials directory: root reads the plaintext at
|
# `%d` is systemd's credentials directory: root reads the plaintext at
|
||||||
# unit start and the daemon's own user sees it 0400, without the unit
|
# unit start and the daemon's own user sees it 0400, without the unit
|
||||||
# ever being able to read the rest of whatever directory the secret
|
# ever being able to read the rest of authelia's state dir.
|
||||||
# came from.
|
|
||||||
SWARM_CONTROLLER_OIDC_CLIENT_SECRET_FILE = "%d/queue-client.secret";
|
SWARM_CONTROLLER_OIDC_CLIENT_SECRET_FILE = "%d/queue-client.secret";
|
||||||
};
|
};
|
||||||
|
|
||||||
# Independent of `queueEnv` on purpose, and now for a simpler reason
|
# Independent of `queueEnv`/`queueLocal` on purpose — the queue
|
||||||
# than when this was written: the queue's own co-location guard
|
# coordinates' own co-location guard is narrower than it looks (it
|
||||||
# (`queueLocal`) is gone — its coordinates are options with assertions,
|
# silently drops the controller's identity on a host that splits
|
||||||
# so a split-host controller fails at eval instead of silently losing
|
# authelia/NATS from swarm-controller; a known gap tracked separately).
|
||||||
# its identity. Forge access still has nothing to do with where authelia
|
# Forge access has nothing to do with whether authelia+NATS happen to be
|
||||||
# and NATS run, only with whether `cfg.forgeTokenFile` resolves to a real
|
# local, only with whether `cfg.forgeTokenFile` resolves to a real file —
|
||||||
# file — which `forgeTokenFile`'s own default already handles
|
# which `forgeTokenFile`'s own default already handles (forge-local vs.
|
||||||
# (forge-local vs. operator-copied). Gating a second time here would
|
# operator-copied). Gating a second time here would just repeat that
|
||||||
# just repeat that option's own logic under a different name.
|
# option's own logic under a different name.
|
||||||
#
|
|
||||||
# ⚠️ The asymmetry is deliberate: the queue is REQUIRED for a controller
|
|
||||||
# and the forge is not, so absence is an error for one and a supported
|
|
||||||
# shape for the other.
|
|
||||||
forgeEnv = lib.optionalAttrs (cfg.forgeTokenFile != null) {
|
forgeEnv = lib.optionalAttrs (cfg.forgeTokenFile != null) {
|
||||||
SWARM_CONTROLLER_FORGE_URL = "https://${forgeCfg.domain}";
|
SWARM_CONTROLLER_FORGE_URL = "https://${forgeCfg.domain}";
|
||||||
# Same `%d` shape as the queue secret above — root reads the plaintext
|
# Same `%d` shape as the queue secret above — root reads the plaintext
|
||||||
|
|
@ -186,61 +182,6 @@ in
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
queue = {
|
|
||||||
natsUrl = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = "";
|
|
||||||
example = "nats://queue.example.com:4222";
|
|
||||||
description = ''
|
|
||||||
Where the controller reaches the swarm queue.
|
|
||||||
|
|
||||||
Empty means unset, which the assertion below refuses — a
|
|
||||||
controller with no queue is not a lighter controller.
|
|
||||||
|
|
||||||
`enableAllLocalDefaults` fills this in with loopback, because
|
|
||||||
that address is only correct when the queue is on this host:
|
|
||||||
its container shares the host netns. That derivation lives with
|
|
||||||
the mode rather than here, so this option describes itself
|
|
||||||
rather than a deployment shape.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
tokenEndpoint = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = lib.optionalString (autheliaCfg.url != null) "${autheliaCfg.url}/api/oidc/token";
|
|
||||||
defaultText = lib.literalExpression ''"''${swarm.authelia.url}/api/oidc/token"'';
|
|
||||||
description = ''
|
|
||||||
The OIDC token endpoint the controller mints its own access
|
|
||||||
token from.
|
|
||||||
|
|
||||||
Derived from `swarm.authelia.url`, which is the half of the
|
|
||||||
authelia module that exists on every host — so this is already
|
|
||||||
correct for a remote provider as long as that URL is set.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
clientSecretFile = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = "";
|
|
||||||
example = "/var/lib/secrets/swarm-controller-queue.secret";
|
|
||||||
description = ''
|
|
||||||
Path on **this** host holding the plaintext of the controller's
|
|
||||||
OAuth2 client secret. A path, never a value: the secret would
|
|
||||||
otherwise land in the world-readable nix store.
|
|
||||||
|
|
||||||
The controller cannot mint its own — minting happens inside
|
|
||||||
authelia's state directory during its first boot — so away from
|
|
||||||
that host the operator places the secret and names it here.
|
|
||||||
`enableAllLocalDefaults` points this at the minted file, which
|
|
||||||
is exactly the case where one exists locally.
|
|
||||||
|
|
||||||
Read by `LoadCredential`, so it needs to be readable by root at
|
|
||||||
unit start and nothing more; the daemon's own user never sees
|
|
||||||
the original path.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
links = lib.mkOption {
|
links = lib.mkOption {
|
||||||
type = lib.types.listOf (
|
type = lib.types.listOf (
|
||||||
lib.types.submodule {
|
lib.types.submodule {
|
||||||
|
|
@ -357,53 +298,6 @@ in
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
# A controller without a queue is not a lighter controller, it is a
|
|
||||||
# broken one: `/api/hives/status` reads the KV, so its whole aggregate
|
|
||||||
# half is off. The operator's rule is that the controller needs a queue
|
|
||||||
# but may not run on the same host as one, given it is configured
|
|
||||||
# properly — so the queue is required and the co-location is not.
|
|
||||||
#
|
|
||||||
# ⚠️ These fail at EVAL on purpose. The state they replace is worse than
|
|
||||||
# an error: with the coordinates absent the daemon started cleanly,
|
|
||||||
# served `/api/hives`, and silently never connected — nothing logged,
|
|
||||||
# because on an all-local deployment nothing was wrong.
|
|
||||||
assertions = [
|
|
||||||
{
|
|
||||||
assertion = cfg.queue.natsUrl != "";
|
|
||||||
message = ''
|
|
||||||
services.hyperhive.swarm.controller.queue.natsUrl is unset.
|
|
||||||
|
|
||||||
It defaults to loopback only when this host also runs the queue
|
|
||||||
(`services.hyperhive.swarm.nats.enable`). A controller on its
|
|
||||||
own host has to be told where the queue is.
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
{
|
|
||||||
assertion = cfg.queue.tokenEndpoint != "";
|
|
||||||
message = ''
|
|
||||||
services.hyperhive.swarm.controller.queue.tokenEndpoint is unset,
|
|
||||||
which means services.hyperhive.swarm.authelia.url is null.
|
|
||||||
|
|
||||||
The controller mints its own access token before it may connect
|
|
||||||
to the queue, so it needs to know where the swarm's identity
|
|
||||||
provider lives — set that URL, or set this endpoint directly.
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
{
|
|
||||||
assertion = cfg.queue.clientSecretFile != "";
|
|
||||||
message = ''
|
|
||||||
services.hyperhive.swarm.controller.queue.clientSecretFile is
|
|
||||||
unset.
|
|
||||||
|
|
||||||
It defaults to the file authelia's first-boot generator mints,
|
|
||||||
which only exists when authelia runs on this host. Elsewhere the
|
|
||||||
operator places the secret and names it here — the controller
|
|
||||||
cannot mint its own, because minting happens inside authelia's
|
|
||||||
state directory.
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
systemd.services.swarm-controller = {
|
systemd.services.swarm-controller = {
|
||||||
description = "hyperhive swarm-level controller daemon";
|
description = "hyperhive swarm-level controller daemon";
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
|
@ -412,16 +306,14 @@ in
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
ExecStart = "${cfg.package}/bin/swarm-controller";
|
ExecStart = "${cfg.package}/bin/swarm-controller";
|
||||||
|
|
||||||
# The two differ on purpose. The queue credential is
|
# Only the credentials that actually resolve on this host. An
|
||||||
# unconditional — the assertions above make its path a value that
|
# absent credential is not a failure for either: the daemon logs
|
||||||
# always exists by the time this renders, so there is no "queue is
|
# that no queue / no forge is configured and serves its HTTP
|
||||||
# off here" case left for a `mkIf` to express. The forge token
|
# surface regardless, which is the correct behaviour on a host
|
||||||
# stays optional: a controller with no forge access still serves
|
# that doesn't run one.
|
||||||
# its HTTP surface, and that IS a supported shape.
|
LoadCredential =
|
||||||
LoadCredential = [
|
lib.optional queueLocal "queue-client.secret:${autheliaCfg.hostClientSecretDir}/${queueClientId}.secret"
|
||||||
"queue-client.secret:${cfg.queue.clientSecretFile}"
|
++ lib.optional (cfg.forgeTokenFile != null) "forge-token:${cfg.forgeTokenFile}";
|
||||||
]
|
|
||||||
++ lib.optional (cfg.forgeTokenFile != null) "forge-token:${cfg.forgeTokenFile}";
|
|
||||||
User = "swarm-controller";
|
User = "swarm-controller";
|
||||||
Group = "swarm-controller";
|
Group = "swarm-controller";
|
||||||
Restart = "on-failure";
|
Restart = "on-failure";
|
||||||
|
|
@ -460,14 +352,10 @@ in
|
||||||
};
|
};
|
||||||
|
|
||||||
# Queue coordinates (`queueEnv`) and forge coordinates (`forgeEnv`)
|
# Queue coordinates (`queueEnv`) and forge coordinates (`forgeEnv`)
|
||||||
# merge in last. The daemon refuses a PARTIAL set of either rather
|
# merge in last, each present only when its own resolution actually
|
||||||
# than treating it as absent, which is why each is built as one
|
# succeeded on this host. Both refuse a PARTIAL set rather than
|
||||||
# attrset and never assigned individually.
|
# treating it as absent, which is why each is built as one attrset
|
||||||
#
|
# and never assigned individually.
|
||||||
# They differ in how absence is prevented: the queue's is checked by
|
|
||||||
# the assertions above, because a controller without a queue is
|
|
||||||
# broken rather than lighter; the forge's is genuinely optional and
|
|
||||||
# stays gated on `forgeTokenFile` resolving.
|
|
||||||
environment = {
|
environment = {
|
||||||
SWARM_CONTROLLER_SOCKET = cfg.socketPath;
|
SWARM_CONTROLLER_SOCKET = cfg.socketPath;
|
||||||
# The swarm's hive directory, JSON-encoded — the full directory
|
# The swarm's hive directory, JSON-encoded — the full directory
|
||||||
|
|
|
||||||
|
|
@ -47,16 +47,5 @@ in
|
||||||
config.services.hyperhive.swarm = {
|
config.services.hyperhive.swarm = {
|
||||||
matrix.enable = lib.mkDefault swarmCfg.enableRequiredServices;
|
matrix.enable = lib.mkDefault swarmCfg.enableRequiredServices;
|
||||||
authelia.enable = lib.mkDefault swarmCfg.enableRequiredServices;
|
authelia.enable = lib.mkDefault swarmCfg.enableRequiredServices;
|
||||||
# The queue. Added later than the two above and missed at the time —
|
|
||||||
# this file predates the `swarm-nats` container by nine days and had
|
|
||||||
# not been revisited since, so its absence was sequence rather than
|
|
||||||
# intent. It meets the rule in the option's own description exactly:
|
|
||||||
# once per swarm, and optional.
|
|
||||||
#
|
|
||||||
# The tell that it was an omission: `local-defaults.nix` already
|
|
||||||
# derives `nats.autoGenerateCallout` from the all-local mode, so that
|
|
||||||
# mode was minting the queue's callout nkeys and then never starting
|
|
||||||
# the queue they authenticate against.
|
|
||||||
nats.enable = lib.mkDefault swarmCfg.enableRequiredServices;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue