# 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 shares the host's network namespace, as ./swarm-victorialogs.nix # does, and atticd listens on loopback only: the gateway's vhost is the way in, # and 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 shared loopback, not the gateway. endpoint = "http://127.0.0.1:${toString cfg.port}"; 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 = "https://${atticCfg.domain}"; 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"; # In the host netns: distinct from every other collector's there. forwarderTelemetryPort = 8903; 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; # 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 { 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 = '' Loopback port atticd listens on, which the gateway proxies to. ⚠️ Every swarm container shares the host's network namespace, so this is a swarm-wide claim: two modules picking the same number collide at runtime with no bind error. No repo-wide check catches 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; a build the cache no longer holds is rebuilt on the next miss. ''; }; }; 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. ''; } ]; # 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 { 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; # `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://127.0.0.1:${toString cfg.port}"; 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. systemd.tmpfiles.rules = [ "d ${secretDir} 0700 root root -" ]; # 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"; after = [ "container@${machine}.service" "swarm-bao-attic-secret.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 = false; bindMounts = { ${secretDir} = { hostPath = secretDir; isReadOnly = true; }; } // 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 = false; # 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; 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; 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"; }; }; }; }; }; }