Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/nix/host-modules/swarm-attic.nix
atlas 1d3ab2fae5 run swarm-attic in its own network namespace
the gateway reaches atticd over a unix socket a relay in the container
forwards, and the host unit that creates the cache dials its public name.
2026-11-03 07:42:10 +01:00

640 lines
25 KiB
Nix

# The swarm's binary cache: an attic server in a `swarm-attic` container on the
# host that sets `deploy.attic.enable`, which `deploy.allSwarmServices` defaults
# on where the host has the cache's store identity. The swarm builder pushes
# green builds to it (docs/swarm/builder.md).
#
# Two host units run under the cache's own store leaf, the shape
# ./swarm-grafana.nix gives its admin password. One creates the key atticd signs
# tokens with, if the store holds none, and hands it to the container. The other
# creates the cache, the push and pull tokens, and the facts hives pull with
# (its address, name and public key), each only while absent
# (./lib/attic-init.nix). Neither needs a manual step.
#
# The container has a network namespace of its own on the hive bridge. atticd
# listens on that namespace's loopback only, because it cannot listen on a unix
# socket. A socket-activated relay in the container forwards a unix socket to it,
# and the gateway's nginx proxies to that socket. The host units reach the cache
# as every client does, at its published name through the gateway. Attic's own
# tokens are the authentication. Storage is local disk under the container's
# state.
#
# The two `swarm.attic` options every host reads (./swarm-attic-options.nix)
# default to this cache's names on every host, whether or not it runs here.
{
pkgs,
lib,
config,
...
}:
let
hyperhiveCfg = config.services.hyperhive;
cfg = hyperhiveCfg.deploy.attic;
atticCfg = hyperhiveCfg.swarm.attic;
baoCfg = hyperhiveCfg.swarm.bao;
baoDeploy = hyperhiveCfg.deploy.bao;
autheliaCfg = hyperhiveCfg.swarm.authelia;
networkCfg = hyperhiveCfg.network;
gatewayCfg = hyperhiveCfg.gateway;
machine = "swarm-attic";
unit = "swarm-attic";
# Where the container keeps its database and the cache's files.
stateName = "swarm-attic";
stateDir = "/var/lib/${stateName}";
# The host directory that carries the signing key into the container. The
# key's file is the only thing in it.
secretDir = "/run/swarm-attic-secrets";
envFile = "${secretDir}/server.env";
# Where the host units reach atticd: the gateway, at the published name.
endpoint = "https://${atticCfg.domain}";
# The unix socket the gateway's nginx connects to. The directory's mode is
# what keeps it private, so it holds this one socket.
socketDir = "/run/swarm-attic";
socketPath = "${socketDir}/atticd.sock";
socketGroup = "gateway-nginx";
# Only the number crosses into the container, which knows the group by name.
nginxGid = config.ids.gids.nginx;
relayUnit = "swarm-attic-relay";
configFile = (pkgs.formats.toml { }).generate "swarm-attic.toml" {
listen = "127.0.0.1:${toString cfg.port}";
# What clients are told to use for the cache's API.
api-endpoint = "https://${atticCfg.domain}/";
database.url = "sqlite://${stateDir}/server.db?mode=rwc";
storage = {
type = "local";
path = "${stateDir}/storage";
};
# attic's defaults, which its config requires spelled out.
chunking = {
nar-size-threshold = 65536;
min-size = 16384;
avg-size = 65536;
max-size = 262144;
};
garbage-collection = {
interval = "12 hours";
default-retention-period = cfg.retentionPeriod;
};
};
atticInit = import ./lib/attic-init.nix { inherit lib; } {
jwtPath = "secret/swarm/services/attic/jwt-secret";
pullPath = "secret/swarm/services/attic/pull-token";
pushPath = "secret/swarm/services/attic/push-token";
urlPath = "secret/swarm/services/attic/url";
cachePath = "secret/swarm/services/attic/cache";
publicKeyPath = "secret/swarm/services/attic/public-key";
url = endpoint;
cache = atticCfg.cache;
inherit
endpoint
configFile
envFile
machine
unit
;
};
atomicWriteSecret = import ./lib/atomic-write-secret.nix { };
refreshConsumer = import ./lib/refresh-consumer.nix { };
storeRetry = import ./lib/store-retry.nix { };
haveClientIdentity = baoDeploy.atticClientCertFile != null && baoDeploy.atticClientKeyFile != null;
# The container's journal forwarder pushes to its own receiver on the swarm
# collector, the route `serviceProducers.swarm-attic` in
# ./swarm-otel-service.nix declares.
forwarderClientId = atticCfg.otel.clientId;
forwarderEndpoint = "https://${hyperhiveCfg.swarm.otel.domain}/${machine}";
forwarderUnit = "swarm-journal-forwarder-oidc";
# Loopback ports of the forwarder's own metrics and of its OTLP/HTTP metrics
# receiver, which the cache-size exporter pushes to, in the container's own
# netns.
forwarderTelemetryPort = 8903;
forwarderOtlpPort = 8905;
# The exporter's unit and query. `V` is attic's stored value for a valid
# chunk (`ChunkState::Valid`) and for a valid NAR (`NarState::Valid`).
# `file_size` is the compressed size on disk, and each chunk is one row
# however many NARs reference it.
sizeUnit = "swarm-attic-cache-size";
databaseUri = "file:${stateDir}/server.db?mode=ro";
forwarderCredential = id: "/run/credentials/${forwarderUnit}.service/${id}";
# The cache's store identity, handed into the container as system credentials
# for the forwarder's fetch unit. The host units keep reading the same files.
forwarderStoreFiles = lib.filterAttrs (_: source: source != null) {
attic-bao-cert = baoDeploy.atticClientCertFile;
attic-bao-key = baoDeploy.atticClientKeyFile;
attic-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;
rootfsCheck = import ./lib/container-rootfs-check.nix { inherit config; };
# The forwarder reaches the swarm collector and authelia by name over TLS; in
# self-signed mode their leaves chain to the hive CA.
caTrust = import ./lib/hive-ca-trust.nix {
inherit lib gatewayCfg;
tlsCfg = hyperhiveCfg.deploy.hive-controller.tls;
};
# Both host units log in as the cache's principal.
storeEnvironment = {
BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}";
BAO_CLIENT_CERT = baoDeploy.atticClientCertFile;
BAO_CLIENT_KEY = baoDeploy.atticClientKeyFile;
}
// lib.optionalAttrs (baoDeploy.serverCaFile != null) {
BAO_CACERT = baoDeploy.serverCaFile;
};
in
{
# The tokens unit dials the gateway's name, whose leaf the system store lacks
# when the gateway serves a self-signed one. `hostUnit`: it reads the host's
# CA path and waits on `hive-tls-ca.service`. `enable`: `imports` is
# unconditional, so without it a host that runs no cache gets a bundle unit.
imports = [
(caTrust.trustBundle {
inherit pkgs;
name = "swarm-bao-attic-tokens";
consumers = [ "swarm-bao-attic-tokens" ];
hostUnit = true;
inherit (cfg) enable;
})
];
options.services.hyperhive.deploy.attic = {
package = lib.mkOption {
type = lib.types.package;
default = pkgs.attic-server;
defaultText = lib.literalExpression "pkgs.attic-server";
description = "The attic server package: `atticd`, and `atticadm` for the tokens.";
};
clientPackage = lib.mkOption {
type = lib.types.package;
default = pkgs.attic-client;
defaultText = lib.literalExpression "pkgs.attic-client";
description = "The attic client package, which creates the cache.";
};
port = lib.mkOption {
type = lib.types.port;
default = 8910;
description = ''
Port atticd listens on, on the loopback of the container's own network
namespace. The container's relay forwards the gateway's unix socket to
it; nothing else dials it.
'';
};
retentionPeriod = lib.mkOption {
type = lib.types.str;
default = "14 days";
example = "30 days";
description = ''
How long a store path stays in the cache after it was added or last
fetched. A garbage collection every twelve hours removes what is older;
swarm-controller rebuilds a closure the cache no longer holds and
the builder pushes it again.
'';
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = haveClientIdentity;
message = ''
services.hyperhive.deploy.attic.enable requires the cache's
swarm-secret-store client identity: set both
services.hyperhive.deploy.bao.atticClientCertFile
services.hyperhive.deploy.bao.atticClientKeyFile
The cache creates its signing key and tokens in the store 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 attic.pem and attic-key.pem from the store
host's /var/lib/swarm-bao-pki/ and name them in those two options.
'';
}
{
assertion = atticCfg.domain != null && atticCfg.cache != null;
message = ''
services.hyperhive.deploy.attic.enable needs
services.hyperhive.swarm.attic.domain and
services.hyperhive.swarm.attic.cache: it serves the cache at that name
and creates it by that name. They default when unset, so one of them
is set to null.
'';
}
{
assertion = lib.all oneWord (lib.attrValues forwarderStoreFiles);
message = ''
The cache's store identity paths must not contain whitespace:
nspawn's --load-credential receives them through nixos-container's
unquoted extra flags.
'';
}
{
assertion =
(hyperhiveCfg.swarm.otel.serviceProducers.${machine}.clientId or null) == forwarderClientId;
message = ''
services.hyperhive.swarm.otel.serviceProducers has no entry
"${machine}" with clientId "${forwarderClientId}", so the swarm
collector has no receiver for the cache's journal forwarder.
Declare it in swarm-otel-service.nix.
'';
}
];
# Not an assertion: a cache served under a name of the operator's own is a
# working setup, and an assertion would fail it.
warnings = lib.optional (atticCfg.domain != atticCfg.ownDomain) ''
services.hyperhive.deploy.attic.enable is on while
services.hyperhive.swarm.attic.domain is "${toString atticCfg.domain}", not
the swarm cache's own name "${atticCfg.ownDomain}". This host runs the
swarm's cache and its gateway serves that name, so pushes to an external
cache there fail the certificate check.
For an external cache, set
services.hyperhive.deploy.attic.enable = lib.mkForce false; where the
shared services are hosted ("To use an external cache, override the two
defaults and don't set `deploy.attic.enable` anywhere", docs/swarm/builder.md).
For the swarm's own cache, drop the override of
services.hyperhive.swarm.attic.domain.
'';
# nspawn fails to start the container when a credential source is
# missing, so where this host mints the leaf the container waits for it. The
# CA ordering keeps the trust bundle's bind source there first.
systemd.services."container@${machine}" = lib.mkMerge [
caTrust.containerOrdering
(rootfsCheck.warnOnly machine)
{
after = [ "swarm-bao-pki.service" ];
wants = [ "swarm-bao-pki.service" ];
}
];
services.hyperhive.gateway.localNames = [ atticCfg.domain ];
# The init units' journal reaches the log store through the hive collector.
# A host that also runs the swarm collector ships its whole journal already
# (./swarm-otel.nix), so naming them there would send each line twice.
services.hyperhive.otel.journalUnits = lib.optionals (!hyperhiveCfg.deploy.swarm-otel.enable) [
"swarm-bao-attic-secret.service"
"swarm-bao-attic-tokens.service"
];
# This host serves the vhost, and the host's own resolver finds the name.
services.hyperhive.gateway.enable = lib.mkDefault true;
services.hyperhive.gateway.dns.enable = lib.mkDefault true;
# The hive bridge carries the container's DHCP lease and its resolver.
services.hyperhive.network.enable = lib.mkDefault true;
# `forceSSL`: the tokens ride in a header. No `auth_request`: attic checks
# its own tokens, and a nix client cannot follow a login redirect. A store
# path is uploaded as one request that can run for minutes, so the body is
# neither size-limited nor buffered.
services.hyperhive.gateway.vhosts."${atticCfg.domain}" = {
forceSSL = true;
locations."/" = {
proxyPass = "http://unix:${socketPath}";
extraConfig = ''
client_max_body_size 0;
proxy_request_buffering off;
proxy_read_timeout 1h;
proxy_send_timeout 1h;
'';
};
};
# The key's directory is the container's read-only bind source, so it has
# to exist before the container starts even when the store does not answer.
# The delivery unit creates it too, which covers a switch. The socket
# directory is the gateway's way in: its group is nginx's.
systemd.tmpfiles.rules = [
"d ${secretDir} 0700 root root -"
"d ${socketDir} 0770 root ${toString nginxGid} - -"
];
# The signing key, created in the store if it holds none and written where
# the container reads it. Before the container and wanted by it: a store
# that comes up later fails this run, atticd restarts until the file is
# there, and the retry that lands it restarts atticd.
systemd.services.swarm-bao-attic-secret = {
description = "create the swarm cache's signing key in the secret store if absent and hand it to the container";
# Ordered after the store where it runs here:
# ./glue-bao-readers-store-order.nix and ./glue-bao-readers-policy-order.nix.
before = [ "container@${machine}.service" ];
wantedBy = [
"multi-user.target"
"container@${machine}.service"
];
path = [
baoDeploy.package
pkgs.coreutils
pkgs.openssl
pkgs.systemd
];
inherit (storeRetry) startLimitBurst startLimitIntervalSec;
serviceConfig = storeRetry.serviceConfig // {
Type = "oneshot";
RemainAfterExit = true;
SyslogIdentifier = "swarm-bao-attic-secret";
# Generating a 4096-bit key takes seconds.
TimeoutStartSec = 120;
};
environment = storeEnvironment;
script = ''
set -euo pipefail
${atomicWriteSecret}
${refreshConsumer}
install -d -m 0700 ${lib.escapeShellArg secretDir}
${atticInit.prelude}
${atticInit.ensureJwt}
'';
};
# The cache, its two tokens and its public facts, once atticd answers. The
# push token is the path the swarm builder reads; the pull token and the
# facts are for the hives, which the controller copies them to.
systemd.services.swarm-bao-attic-tokens = {
description = "create the swarm cache, and its tokens and public facts in the secret store, if absent";
# `nginx.service`: the unit dials the cache through the gateway, whose
# resolver answers the name with the bridge address (`localNames` above).
after = [
"container@${machine}.service"
"swarm-bao-attic-secret.service"
"nginx.service"
];
wants = [ "container@${machine}.service" ];
wantedBy = [ "multi-user.target" ];
path = [
baoDeploy.package
cfg.package
cfg.clientPackage
pkgs.coreutils
pkgs.curl
pkgs.gnused
];
inherit (storeRetry) startLimitBurst startLimitIntervalSec;
serviceConfig = storeRetry.serviceConfig // {
Type = "oneshot";
RemainAfterExit = true;
SyslogIdentifier = "swarm-bao-attic-tokens";
# Up to a minute waiting for atticd.
TimeoutStartSec = 180;
};
environment = storeEnvironment;
script = ''
set -euo pipefail
${atticInit.prelude}
${atticInit.provision}
'';
};
containers.${machine} = {
autoStart = true;
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}"
) forwarderStoreFiles;
privateNetwork = true;
hostBridge = networkCfg.bridgeName;
bindMounts = {
${secretDir} = {
hostPath = secretDir;
isReadOnly = true;
};
# A bind mount, not a path both sides know: a container's `/run` is an
# nspawn tmpfs the host cannot see into. Connecting to a socket is a
# write to it.
${socketDir} = {
hostPath = socketDir;
isReadOnly = false;
};
}
// caTrust.bindMount;
config =
{ ... }:
{
imports = [
../container-modules/swarm-container.nix
../container-modules/swarm-journal-forwarder.nix
(import ../container-modules/swarm-container-resolver.nix {
inherit (networkCfg) bridgeIp;
# The forwarder and its secret fetch dial the swarm collector,
# authelia and the store by name.
dnsConsumers = [
"opentelemetry-collector.service"
"${forwarderUnit}.service"
];
})
# Go: `SSL_CERT_FILE` replaces its trust store, for the swarm
# collector's and authelia's names.
(caTrust.trustBundle {
inherit pkgs;
name = machine;
consumers = [ "opentelemetry-collector" ];
})
];
services.hyperhive.swarmContainer.privateNetwork = true;
# The bridge address comes from the gateway's DHCP. `swarm-bridge-dns`
# owns resolv.conf, so dhcpcd must not write it over that unit.
networking.interfaces.eth0.useDHCP = true;
networking.dhcpcd.extraConfig = "nohook resolv.conf";
# The socket's group is nginx's, so the gateway can connect to it.
users.groups.${socketGroup}.gid = nginxGid;
systemd.sockets.${relayUnit} = {
description = "unix socket the gateway reaches atticd through";
wantedBy = [ "sockets.target" ];
listenStreams = [ socketPath ];
socketConfig = {
SocketMode = "0660";
SocketUser = "root";
SocketGroup = socketGroup;
};
};
systemd.services.${relayUnit} = {
description = "relay the gateway's connections to atticd";
requires = [ "${relayUnit}.socket" ];
after = [ "${relayUnit}.socket" ];
serviceConfig = {
ExecStart = "${pkgs.systemd}/lib/systemd/systemd-socket-proxyd 127.0.0.1:${toString cfg.port}";
DynamicUser = true;
PrivateTmp = true;
PrivateDevices = true;
NoNewPrivileges = true;
RestrictAddressFamilies = [
"AF_INET"
"AF_UNIX"
];
};
};
# Authenticated as the forwarder's own OIDC client, with the cache's
# store identity: one identity for the whole container. It is a
# system credential, so the fetch unit loads it.
services.hyperhive.swarmJournalForwarder = {
enable = true;
serviceName = "attic";
endpoint = forwarderEndpoint;
clientId = forwarderClientId;
tokenUrl = "${toString autheliaCfg.url}/api/oidc/token";
telemetryPort = forwarderTelemetryPort;
otlpMetricsPort = forwarderOtlpPort;
store = {
package = baoDeploy.package;
addr = "https://${baoCfg.domain}:${toString baoCfg.port}";
secretPath = "secret/swarm/services/${forwarderClientId}/oidc/client";
clientCertFile = forwarderCredential "attic-bao-cert";
clientKeyFile = forwarderCredential "attic-bao-key";
caFile =
if forwarderStoreFiles ? attic-bao-server-ca then
forwarderCredential "attic-bao-server-ca"
else
null;
};
};
systemd.services.${forwarderUnit}.serviceConfig.LoadCredential = lib.attrNames forwarderStoreFiles;
# Reads atticd's database and pushes the cache's size to the
# forwarder. It runs as atticd's dynamic user, shared by name, because
# the state directory is mode 0700. With atticd stopped the name gets
# another uid and the read fails. `-readonly` and `mode=ro` leave
# the database to atticd, and `.timeout` waits out its writes.
systemd.services.${sizeUnit} = {
description = "push the swarm cache's size, read from atticd's database, to the swarm collector";
wants = [ "${unit}.service" ];
after = [
"${unit}.service"
"opentelemetry-collector.service"
];
path = [
pkgs.coreutils
pkgs.curl
pkgs.sqlite
];
serviceConfig = {
Type = "oneshot";
User = unit;
DynamicUser = true;
StateDirectory = stateName;
SyslogIdentifier = sizeUnit;
UMask = "0077";
CapabilityBoundingSet = [ "" ];
NoNewPrivileges = true;
PrivateDevices = true;
PrivateTmp = true;
ProtectSystem = "strict";
ProtectHome = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
"AF_UNIX"
];
RestrictNamespaces = true;
RestrictRealtime = true;
LockPersonality = true;
SystemCallArchitectures = "native";
};
script = ''
set -euo pipefail
query() {
sqlite3 -readonly -cmd '.timeout 5000' ${lib.escapeShellArg databaseUri} "$1"
}
bytes="$(query "SELECT COALESCE(SUM(file_size), 0) FROM chunk WHERE state = 'V'")"
nars="$(query "SELECT COUNT(*) FROM nar WHERE state = 'V'")"
case "$bytes$nars" in
"" | *[!0-9]*)
echo "the database answered a non-number: bytes='$bytes' nars='$nars'" >&2
exit 1
;;
esac
now="$(date +%s%N)"
gauge() {
printf '{"name":"%s","gauge":{"dataPoints":[{"asInt":"%s","timeUnixNano":"%s"}]}}' "$1" "$2" "$now"
}
printf '{"resourceMetrics":[{"resource":{"attributes":[{"key":"service.name","value":{"stringValue":"%s"}}]},"scopeMetrics":[{"metrics":[%s,%s]}]}]}' \
${lib.escapeShellArg machine} \
"$(gauge hyperhive.attic.cache_bytes "$bytes")" \
"$(gauge hyperhive.attic.cache_nars "$nars")" |
curl --fail --silent --show-error --max-time 20 \
-H 'Content-Type: application/json' --data-binary @- \
http://127.0.0.1:${toString forwarderOtlpPort}/v1/metrics
'';
};
systemd.timers.${sizeUnit} = {
wantedBy = [ "timers.target" ];
timerConfig = {
OnBootSec = "2min";
OnUnitActiveSec = "5min";
};
};
systemd.services.${unit} = {
description = "attic binary cache server";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
ExecStart = "${cfg.package}/bin/atticd -f ${configFile} --mode monolithic";
# The signing key, in an environment file the host unit writes.
EnvironmentFile = envFile;
StateDirectory = stateName;
DynamicUser = true;
Restart = "on-failure";
RestartSec = 10;
SyslogIdentifier = unit;
UMask = "0077";
CapabilityBoundingSet = [ "" ];
NoNewPrivileges = true;
PrivateDevices = true;
PrivateTmp = true;
ProtectSystem = "strict";
ProtectHome = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
"AF_UNIX"
];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
LockPersonality = true;
SystemCallArchitectures = "native";
};
};
};
};
};
}