A new swarm service, swarm-notify-router, in its own container on the host
that sets deploy.notify-router.enable (defaulted on by allSwarmServices where
the host has the router's store leaf, like the claude login minter).
- swarm-controller registers instance-wide hooks for `issues` +
`issue_assign` and `pull_request` + `pull_request_assign`, updates the
event list of a hook already registered with fewer (leaving its active flag as it is), and keeps each
verified body unchanged in the SWARM_EVENTS stream on
$SWARM.events.forge.<X-Forgejo-Event-Type>, deduped by X-Forgejo-Delivery.
A delivery the stream did not keep answers 503.
- swarm-notify-router drains its durable consumer on that stream, runs its
flows (source -> match -> todo sink, stored as nodes plus edges and checked
to be a tree) and queues each op on $SWARM.router.todo.<agent> in the
work-queue stream ROUTER_TODOS. Built-in flows: assigned, unassigned (same
key, rewritten) and closed (prefix-clears every holder's router todos). A
per-issue snapshot in its notify-router-state bucket holds the last
assignees and the todo holders; only agents in the agent-status bucket get
ops, and an assignee that was not one is left out of the snapshot so the
next assign event retries it. After the first event that must be retried,
the rest of the batch is nakked so redelivery keeps stream order.
- hive-agent creates its own durable consumer todo-<agent> and applies each
op to its todo store as subsystem `router`, then acks; a refused op stops
the rest of its batch the same way. The store's (subsystem, key) dedup
makes a redelivery harmless. New Todos::clear_prefix.
- swarm-nats-auth: a --notify-router-client arm; the controller may publish
$SWARM.events.forge.*; each agent gets three {agent} templates (extended
consumer create whose filter the server checks, MSG.NEXT, ACK).
- Credentials: a notify-router store leaf minted by glue-bao-tls, a
swarm-notify-router bao policy (read on its own queue client secret), an
authelia machine client, the secret publisher entry, a journal forwarder
route. No manual step.
hive-forge-notify is unchanged, so agents also still get forge's own
notifications.
Refs #5571
235 lines
9.5 KiB
Nix
235 lines
9.5 KiB
Nix
# The swarm notification router, in a `swarm-notify-router` container on the
|
|
# host that sets `deploy.notify-router.enable`, which `deploy.allSwarmServices`
|
|
# defaults on where the host has the router's store identity. It reads the
|
|
# forge events swarm-controller keeps from its webhooks and queues todo ops on
|
|
# each agent's subject of `ROUTER_TODOS`, which the agent's harness applies.
|
|
# docs/swarm/notify-router.md has the design.
|
|
#
|
|
# Its own network namespace on the bridge, as ./swarm-claude-login.nix has: it
|
|
# reaches the swarm services by name and nothing on the host's loopback. It
|
|
# talks only to the queue, the store and authelia, so it can run on any host.
|
|
#
|
|
# The container holds one credential, its own store leaf, which nspawn hands in
|
|
# as a system credential with no copy on disk. The daemon reads its queue
|
|
# client secret with it.
|
|
{
|
|
pkgs,
|
|
lib,
|
|
config,
|
|
...
|
|
}:
|
|
let
|
|
hyperhiveCfg = config.services.hyperhive;
|
|
|
|
cfg = hyperhiveCfg.deploy.notify-router;
|
|
baoCfg = hyperhiveCfg.swarm.bao;
|
|
baoDeploy = hyperhiveCfg.deploy.bao;
|
|
natsCfg = hyperhiveCfg.swarm.nats;
|
|
autheliaCfg = hyperhiveCfg.swarm.authelia;
|
|
gatewayCfg = hyperhiveCfg.gateway;
|
|
networkCfg = hyperhiveCfg.network;
|
|
tlsCfg = hyperhiveCfg.deploy.hive-controller.tls;
|
|
|
|
machine = "swarm-notify-router";
|
|
unit = "swarm-notify-router";
|
|
clientId = hyperhiveCfg.swarm.notify-router.clientId;
|
|
|
|
# The container's journal forwarder pushes to its own receiver on the swarm
|
|
# collector, the route `serviceProducers.swarm-notify-router` in
|
|
# ./swarm-otel-service.nix declares, as ./swarm-claude-login.nix does.
|
|
swarmOtelCfg = hyperhiveCfg.swarm.otel;
|
|
gatewayPortSuffix = lib.optionalString (
|
|
gatewayCfg.httpsPort != 443
|
|
) ":${toString gatewayCfg.httpsPort}";
|
|
forwarderEndpoint = "https://${swarmOtelCfg.domain}${gatewayPortSuffix}/${machine}";
|
|
forwarderUnit = "swarm-journal-forwarder-oidc";
|
|
# Loopback in the container's own netns, and distinct from the other service
|
|
# containers' forwarders all the same.
|
|
forwarderTelemetryPort = 8902;
|
|
|
|
# The daemon reaches authelia, the store and the queue by name over TLS, and
|
|
# in self-signed mode their leaves chain to the hive CA.
|
|
caTrust = import ./lib/hive-ca-trust.nix { inherit lib tlsCfg gatewayCfg; };
|
|
caBundleModule = caTrust.trustBundle {
|
|
inherit pkgs;
|
|
name = machine;
|
|
consumers = [
|
|
unit
|
|
# Go: `SSL_CERT_FILE` replaces its trust store, for the swarm
|
|
# collector's and authelia's names.
|
|
"opentelemetry-collector"
|
|
];
|
|
};
|
|
|
|
haveClientIdentity =
|
|
baoDeploy.notifyRouterClientCertFile != null && baoDeploy.notifyRouterClientKeyFile != null;
|
|
|
|
# Credential id → host source, as ./swarm-claude-login.nix hands its leaf in.
|
|
storeFiles = lib.filterAttrs (_: source: source != null) {
|
|
notify-router-bao-cert = baoDeploy.notifyRouterClientCertFile;
|
|
notify-router-bao-key = baoDeploy.notifyRouterClientKeyFile;
|
|
notify-router-bao-server-ca = baoDeploy.serverCaFile;
|
|
};
|
|
# nixos-container expands `extraFlags` unquoted, so a source path must be
|
|
# one word.
|
|
oneWord = path: builtins.match ".*[[:space:]].*" path == null;
|
|
credential = id: "/run/credentials/${unit}.service/${id}";
|
|
forwarderCredential = id: "/run/credentials/${forwarderUnit}.service/${id}";
|
|
|
|
privateNetwork = true;
|
|
in
|
|
{
|
|
options.services.hyperhive.swarm.notify-router.clientId = lib.mkOption {
|
|
type = lib.types.str;
|
|
readOnly = true;
|
|
default = "swarm-notify-router";
|
|
description = ''
|
|
The OAuth2 client id the notification router presents to the swarm
|
|
queue. Read-only: the host running authelia registers it, the queue's
|
|
auth-callout responder grants it the router's subjects, and the secret
|
|
publisher carries its secret to the store, all from this one value.
|
|
'';
|
|
};
|
|
|
|
options.services.hyperhive.deploy.notify-router.package = lib.mkOption {
|
|
type = lib.types.package;
|
|
defaultText = lib.literalExpression "hyperhive.packages.\${system}.swarm-notify-router";
|
|
description = "The notification router daemon package.";
|
|
};
|
|
|
|
config = lib.mkIf cfg.enable {
|
|
assertions = [
|
|
{
|
|
assertion = haveClientIdentity;
|
|
message = ''
|
|
services.hyperhive.deploy.notify-router.enable requires the router's
|
|
swarm-secret-store client identity: set both
|
|
|
|
services.hyperhive.deploy.bao.notifyRouterClientCertFile
|
|
services.hyperhive.deploy.bao.notifyRouterClientKeyFile
|
|
|
|
The router reads its queue client secret with it.
|
|
|
|
Both default to the pair glue-bao-tls.nix mints when
|
|
services.hyperhive.deploy.bao.enable is true (the host that runs the
|
|
store). Otherwise copy notify-router.pem and notify-router-key.pem
|
|
from the store host's /var/lib/swarm-bao-pki/ and name them in those
|
|
two options.
|
|
'';
|
|
}
|
|
{
|
|
assertion = lib.all oneWord (lib.attrValues storeFiles);
|
|
message = ''
|
|
The notification router's store identity paths must not contain
|
|
whitespace: nspawn's --load-credential receives them through
|
|
nixos-container's unquoted extra flags.
|
|
'';
|
|
}
|
|
];
|
|
|
|
# The container hangs off the bridge, takes its address from the gateway's
|
|
# DHCP and resolves through its dnsmasq.
|
|
services.hyperhive.network.enable = lib.mkDefault true;
|
|
services.hyperhive.gateway.enable = lib.mkDefault true;
|
|
services.hyperhive.gateway.dns.enable = lib.mkDefault true;
|
|
|
|
# nspawn fails to start the container when a credential source is
|
|
# missing, so where this host mints the leaf the container waits for it.
|
|
systemd.services."container@${machine}" = lib.mkMerge [
|
|
caTrust.containerOrdering
|
|
{ after = [ "swarm-bao-pki.service" ]; }
|
|
];
|
|
|
|
containers.${machine} = {
|
|
autoStart = true;
|
|
# The daemon keeps its state in the queue: its consumer's position and
|
|
# its per-issue snapshots.
|
|
ephemeral = false;
|
|
# No `--link-journal=host`: the journal stays in the guest, where the
|
|
# container's own forwarder reads it (../container-modules/swarm-journal-forwarder.nix).
|
|
extraFlags = lib.mapAttrsToList (id: source: "--load-credential=${id}:${source}") storeFiles;
|
|
inherit privateNetwork;
|
|
hostBridge = networkCfg.bridgeName;
|
|
bindMounts = caTrust.bindMount;
|
|
|
|
config =
|
|
{ lib, ... }:
|
|
{
|
|
imports = [
|
|
../container-modules/swarm-container.nix
|
|
../container-modules/swarm-journal-forwarder.nix
|
|
caBundleModule
|
|
];
|
|
|
|
services.hyperhive.swarmContainer = {
|
|
inherit privateNetwork;
|
|
writesOwnResolvConf = false;
|
|
};
|
|
networking.nameservers = [ networkCfg.bridgeIp ];
|
|
networking.interfaces.eth0.useDHCP = true;
|
|
|
|
users.users.${unit} = {
|
|
isSystemUser = true;
|
|
group = unit;
|
|
};
|
|
users.groups.${unit} = { };
|
|
|
|
# Authenticated as the daemon's own OIDC client, whose secret its
|
|
# store identity already reads: one identity for the whole container.
|
|
services.hyperhive.swarmJournalForwarder = {
|
|
enable = true;
|
|
serviceName = "notify-router";
|
|
endpoint = forwarderEndpoint;
|
|
inherit clientId;
|
|
tokenUrl = "${autheliaCfg.url}/api/oidc/token";
|
|
telemetryPort = forwarderTelemetryPort;
|
|
store = {
|
|
package = baoDeploy.package;
|
|
addr = "https://${baoCfg.domain}:${toString baoCfg.port}";
|
|
secretPath = "secret/swarm/services/${clientId}/oidc/client";
|
|
clientCertFile = forwarderCredential "notify-router-bao-cert";
|
|
clientKeyFile = forwarderCredential "notify-router-bao-key";
|
|
caFile =
|
|
if storeFiles ? notify-router-bao-server-ca then
|
|
forwarderCredential "notify-router-bao-server-ca"
|
|
else
|
|
null;
|
|
};
|
|
};
|
|
systemd.services.${forwarderUnit}.serviceConfig.LoadCredential = lib.attrNames storeFiles;
|
|
|
|
systemd.services.${unit} = {
|
|
description = "route forge events to agents' todo lists";
|
|
wantedBy = [ "multi-user.target" ];
|
|
wants = [ "network-online.target" ];
|
|
after = [ "network-online.target" ];
|
|
environment = {
|
|
BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}";
|
|
BAO_CLIENT_CERT = credential "notify-router-bao-cert";
|
|
BAO_CLIENT_KEY = credential "notify-router-bao-key";
|
|
SWARM_NOTIFY_ROUTER_NATS_URL = "tls://${natsCfg.domain}:${toString natsCfg.port}";
|
|
SWARM_NOTIFY_ROUTER_OIDC_TOKEN_ENDPOINT = "${autheliaCfg.url}/api/oidc/token";
|
|
SWARM_NOTIFY_ROUTER_OIDC_CLIENT_ID = clientId;
|
|
}
|
|
// lib.optionalAttrs (storeFiles ? notify-router-bao-server-ca) {
|
|
BAO_CACERT = credential "notify-router-bao-server-ca";
|
|
}
|
|
// lib.optionalAttrs caTrust.useSelfSigned {
|
|
SWARM_NOTIFY_ROUTER_OIDC_CA_FILE = caTrust.bundlePathFor machine;
|
|
};
|
|
serviceConfig = {
|
|
ExecStart = "${cfg.package}/bin/swarm-notify-router";
|
|
User = unit;
|
|
Group = unit;
|
|
UMask = "0077";
|
|
# Bare ids: the system credentials nspawn handed in.
|
|
LoadCredential = lib.attrNames storeFiles;
|
|
Restart = "on-failure";
|
|
RestartSec = "10s";
|
|
SyslogIdentifier = unit;
|
|
};
|
|
};
|
|
};
|
|
};
|
|
};
|
|
}
|