Watch
0
0
Fork
You've already forked hyperhive
0

run attic as a swarm service with deploy.attic.enable

This commit is contained in:
atlas 2026-10-27 12:23:28 +01:00
commit 458a046b8d
11 changed files with 1242 additions and 66 deletions

View file

@ -63,6 +63,7 @@
./glue-victorialogs-otel-oidc-client.nix
./glue-victoriametrics-otel-oidc-client.nix
./swarm-attic-options.nix
./swarm-attic.nix
./swarm-authelia-service.nix
./swarm-authelia.nix
./swarm-bao-service.nix

View file

@ -505,6 +505,28 @@ in
'';
};
attic.enable = lib.mkOption {
type = lib.types.bool;
default = false;
example = true;
description = ''
Run the swarm's binary cache in a `swarm-attic` container on this
host: an attic server behind the gateway, with its signing key, its
cache and its push and pull tokens created in the secret store on first
start. The swarm builder pushes green builds to it. Run it on one host.
On by default where
{option}`services.hyperhive.deploy.allSwarmServices` is set and this
host has the cache's store identity (the pair
{option}`services.hyperhive.deploy.bao.atticClientCertFile` and
`atticClientKeyFile`, minted on the store's host).
With it on, {option}`services.hyperhive.swarm.attic.domain` and `cache`
default to the cache's own name and cache; naming either yourself wins.
With it off, those two options name an external cache or stay unset.
'';
};
hive-controller.enable = lib.mkOption {
type = lib.types.bool;
default = false;

View file

@ -79,6 +79,16 @@ let
here = deployCfg.grafana.enable;
policies = [ "swarm-bao-grafana-oidc-policy" ];
};
# ./swarm-attic.nix, under the cache's leaf, which may create the signing
# key and the two tokens only once that policy is written.
swarm-bao-attic-secret = {
here = deployCfg.attic.enable;
policies = [ "swarm-bao-attic-policy" ];
};
swarm-bao-attic-tokens = {
here = deployCfg.attic.enable;
policies = [ "swarm-bao-attic-policy" ];
};
# ./hive-matrix.nix, under matrix-ctl's leaf.
swarm-bao-matrix-oidc = {
here = deployCfg.matrix.enable;

View file

@ -60,6 +60,9 @@ let
swarm-bao-nats-callout-xkey = deployCfg.nats.enable;
# ./swarm-ci-runner.nix
swarm-bao-ci-runner = deployCfg.ci-runner.enable;
# ./swarm-attic.nix
swarm-bao-attic-secret = deployCfg.attic.enable;
swarm-bao-attic-tokens = deployCfg.attic.enable;
# ./swarm-otel.nix
swarm-bao-otel-oidc =
deployCfg.swarm-otel.enable

View file

@ -124,6 +124,8 @@ in
claudeLoginClientKeyFile = lib.mkDefault "${pkiDir}/claude-login-key.pem";
notifyRouterClientCertFile = lib.mkDefault "${pkiDir}/notify-router.pem";
notifyRouterClientKeyFile = lib.mkDefault "${pkiDir}/notify-router-key.pem";
atticClientCertFile = lib.mkDefault "${pkiDir}/attic.pem";
atticClientKeyFile = lib.mkDefault "${pkiDir}/attic-key.pem";
forgePublisherClientCertFile = lib.mkDefault "${pkiDir}/forge-publisher.pem";
forgePublisherClientKeyFile = lib.mkDefault "${pkiDir}/forge-publisher-key.pem";
servicesIssuerClientCertFile = lib.mkDefault "${pkiDir}/services-issuer.pem";
@ -328,6 +330,13 @@ in
[ -s ${pkiDir}/notify-router.pem ] || ${signLeaf} ${pkiDir} notify-router \
${lib.escapeShellArg deployCfg.bao.notifyRouterCommonName} "" clientAuth
# The in-swarm cache's, which creates and reads its three secrets.
# Minted here for the matrix-ctl leaf's reason: the cache is a swarm
# service, so on a cache host without the store this is the file an
# operator copies.
[ -s ${pkiDir}/attic.pem ] || ${signLeaf} ${pkiDir} attic \
${lib.escapeShellArg deployCfg.bao.atticCommonName} "" clientAuth
# The forge's token publisher. Minted here for the matrix-ctl leaf's
# reason: the forge is a swarm singleton, so on a forge host without the
# store this is the file an operator copies.

View file

@ -0,0 +1,165 @@
# The shell for the in-swarm cache's first start: what its two host units run
# after they log in to the secret store under the cache's own certificate.
# Each secret is created only while the store holds none, so a second run reads
# what the first one stored and mints nothing.
#
# Pure function — NOT a NixOS module. Call it from a module's `let`:
#
# atticInit = import ./lib/attic-init.nix { inherit lib; } { … };
# script = ''
# ${atticInit.prelude}
# ${atticInit.ensureJwt}
# '';
#
# `prelude` expects `./atomic-write-secret.nix` and `./refresh-consumer.nix`
# above it, and `bao`, `coreutils` and `systemd` on the caller's `path`.
# `ensureJwt` also wants `openssl` there; `provision` wants `atticadm`, `attic`
# and `curl`.
{ lib }:
{
# Store paths in the form `bao kv` takes, mount first.
jwtPath,
pullPath,
pushPath,
# The cache's name on the server.
cache,
# Where the host reaches atticd, scheme included.
endpoint,
# The server's config file, which the host's `atticadm` reads as well.
configFile,
# The file atticd loads its signing key from, and the container that does.
envFile,
machine,
unit,
}:
let
q = lib.escapeShellArg;
in
{
prelude = ''
err="$(mktemp)"
work="$(mktemp -d)"
trap 'rm -rf "$err" "$work"' EXIT
if ! BAO_TOKEN="$(bao login -method=cert -token-only 2>"$err")"; then
echo "could not log in to swarm-bao with the cache's certificate; leaving the cache as it is." >&2
cat "$err" >&2
exit 1
fi
export BAO_TOKEN
# <store path> <mint function>: the stored value, created with the
# function's output first while the store holds none. `bao kv get` exits 2
# both for "nothing stored" and for a refusal, so a failed read always
# tries the create, which `-cas=0` refuses when the path exists, and the
# second read decides. The value reaches `bao` on stdin, never in an
# argument.
ensure_secret() {
local path="$1" mint="$2" value minted
if ! value="$(bao kv get -field=value "$path" 2>"$err")"; then
minted="$("$mint")" || return 1
if [ -z "$minted" ]; then
echo "minting $path produced nothing." >&2
return 1
fi
if ! printf '%s' "$minted" | bao kv put -cas=0 "$path" value=- >/dev/null 2>"$err"; then
echo "could not create $path; reading it back in case another run created it." >&2
cat "$err" >&2
fi
if ! value="$(bao kv get -field=value "$path" 2>"$err")"; then
echo "swarm-bao did not return $path." >&2
cat "$err" >&2
return 1
fi
fi
if [ -z "$value" ]; then
echo "swarm-bao returned an empty $path." >&2
return 1
fi
printf '%s' "$value"
}
'';
# The signing key atticd verifies tokens with, and the file it loads it from.
# The key is a base64 RSA PEM in PKCS#1 form, which is what `-traditional`
# asks for.
ensureJwt = ''
mint_jwt() {
openssl genrsa -traditional 4096 2>/dev/null | base64 -w0
}
jwt="$(ensure_secret ${q jwtPath} mint_jwt)" || exit 1
line="ATTIC_SERVER_TOKEN_RS256_SECRET_BASE64=$jwt"
if secret_differs ${q envFile} "$line"; then
atomic_write_secret 0400 "" ${q envFile} "$line"
fi
# atticd reads the key as it starts, and only then.
refresh_consumer ${q machine} ${q unit} ${q envFile}
'';
# The cache and the two tokens, once atticd answers. The tokens are signed
# here with the key the store holds; the cache is created through the API by a
# token that lives for five minutes and is never stored.
provision = ''
up=0
for _ in $(seq 1 30); do
if curl -s -o /dev/null --max-time 2 ${q endpoint}; then
up=1
break
fi
sleep 2
done
if [ "$up" != 1 ]; then
echo "atticd did not answer at ${endpoint}." >&2
exit 1
fi
if ! jwt="$(bao kv get -field=value ${q jwtPath} 2>"$err")" || [ -z "$jwt" ]; then
echo "swarm-bao did not return ${jwtPath}; the cache's tokens are not minted." >&2
cat "$err" >&2
exit 1
fi
export ATTIC_SERVER_TOKEN_RS256_SECRET_BASE64="$jwt"
# <subject> <validity> <grant…>
mint_token() {
local sub="$1" validity="$2"
shift 2
atticadm -f ${q configFile} make-token --sub "$sub" --validity "$validity" "$@"
}
mint_pull() { mint_token swarm-hives-pull 10y --pull ${q cache}; }
mint_push() { mint_token swarm-builder-push 10y --pull ${q cache} --push ${q cache}; }
pull="$(ensure_secret ${q pullPath} mint_pull)" || exit 1
ensure_secret ${q pushPath} mint_push >/dev/null || exit 1
# The client reads its token from a file, so the token is in no argument.
export XDG_CONFIG_HOME="$work"
use_token() {
install -d -m 0700 "$work/attic"
(umask 077 && printf '%s' "$1" >"$work/token")
printf 'default-server = "local"\n\n[servers.local]\nendpoint = "%s"\ntoken-file = "%s"\n' \
${q endpoint} "$work/token" >"$work/attic/config.toml"
}
# The pull token reads the cache's configuration when the cache exists, so
# a cache that is there costs no token. The server's database can be empty
# while the store still holds both tokens.
use_token "$pull"
if ! attic cache info ${q "local:${cache}"} >/dev/null 2>"$err"; then
admin="$(mint_token swarm-attic-init 5m --create-cache ${q cache} --pull ${q cache})" || exit 1
use_token "$admin"
if ! attic cache create ${q "local:${cache}"} >/dev/null 2>"$err"; then
echo "could not create the cache ${cache}; checking whether another run did." >&2
cat "$err" >&2
fi
use_token "$pull"
if ! attic cache info ${q "local:${cache}"} >/dev/null 2>"$err"; then
echo "the cache ${cache} is not there after creating it." >&2
cat "$err" >&2
exit 1
fi
fi
'';
}

View file

@ -1,9 +1,32 @@
# The binary cache the swarm pushes agent closures to, as every host sees it:
# which attic server, and which cache on it. The swarm runs no attic of its
# own yet; the instance is the operator's, outside the swarm.
{ lib, ... }:
# which attic server, and which cache on it. A host that sets
# `deploy.attic.enable` runs the swarm's own and fills both in (./swarm-attic.nix);
# otherwise the instance is the operator's, outside the swarm.
{ lib, config, ... }:
let
swarmDomain = config.services.hyperhive.swarm.domain;
# Total on a null swarm domain, as every sibling module is: the
# required-domain assertion in hive-network.nix is what an operator should
# see, not a coercion error from here.
domainBase = if swarmDomain == null then "invalid" else swarmDomain;
in
{
options.services.hyperhive.swarm.attic = {
ownDomain = lib.mkOption {
type = lib.types.str;
readOnly = true;
default = "attic.${domainBase}";
defaultText = lib.literalExpression ''"attic.''${services.hyperhive.swarm.domain}"'';
description = ''
Read-only: the name the swarm's own cache is served under, which
{option}`services.hyperhive.deploy.attic.enable` defaults
{option}`services.hyperhive.swarm.attic.domain` to. A sibling of the
swarm's other service names, so the swarm-services sub-CA can issue for it,
and in `swarm.serviceDomains` on every host whether or not one runs it.
'';
};
domain = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
@ -18,9 +41,14 @@
Null pushes nothing: the swarm builder does not push, and the closure
record says it is not cached.
The push token is not an option. The operator writes it to
{option}`services.hyperhive.deploy.attic.enable` defaults this to
`attic.<swarm.domain>` on the host that runs the swarm's own cache. Every
other host reads it from the same option, so name it there too.
The push token is not an option. It is at
`secret/swarm/services/attic/push-token` in the swarm secret store
(field `value`), and the builder reads it on every push.
(field `value`), and the builder reads it on every push. The swarm's own
cache creates it; for an external cache the operator writes it.
'';
};
@ -31,8 +59,9 @@
example = "grid";
description = ''
Name of the cache on {option}`services.hyperhive.swarm.attic.domain`
that the swarm builder pushes closures to. The push token must be
allowed to push to it. The name follows attic's rule: 1 to 50 letters,
that the swarm builder pushes closures to, `swarm` by default on the
host that runs the swarm's own cache. The push token must be allowed to
push to it. The name follows attic's rule: 1 to 50 letters,
digits, `-`, `_` or `+`, starting with a letter or digit.
'';
};

View file

@ -0,0 +1,332 @@
# 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 and the push and pull tokens, 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) get
# their values from here, at `mkDefault`: naming them yourself wins, and with
# the switch off nothing is set.
{
pkgs,
lib,
config,
...
}:
let
hyperhiveCfg = config.services.hyperhive;
cfg = hyperhiveCfg.deploy.attic;
atticCfg = hyperhiveCfg.swarm.attic;
baoCfg = hyperhiveCfg.swarm.bao;
baoDeploy = hyperhiveCfg.deploy.bao;
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";
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;
# 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 {
services.hyperhive.swarm.attic = {
domain = lib.mkDefault atticCfg.ownDomain;
cache = lib.mkDefault "swarm";
};
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.
'';
}
];
services.hyperhive.gateway.localNames = [ atticCfg.domain ];
# 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 and its two tokens, once atticd answers. The push token is the
# path the swarm builder reads; the pull token is for the hives.
systemd.services.swarm-bao-attic-tokens = {
description = "create the swarm cache, and its push and pull tokens 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
];
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;
privateNetwork = false;
bindMounts.${secretDir} = {
hostPath = secretDir;
isReadOnly = true;
};
config =
{ ... }:
{
imports = [ ../container-modules/swarm-container.nix ];
services.hyperhive.swarmContainer.privateNetwork = false;
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";
};
};
};
};
};
}

View file

@ -693,11 +693,16 @@ let
builderForgeTokenLeaf = "swarm/services/builder/forge/token";
# The token the swarm builder pushes closures to the binary cache with, the nix
# half of `swarm_secret_client::builder::attic_push_token_path`. The operator
# writes it by hand; the builder reads it on every push, and nothing else in
# the swarm reaches it.
# half of `swarm_secret_client::builder::attic_push_token_path`. The builder
# reads it on every push. The operator writes it for an external cache; the
# in-swarm cache's host units create it.
atticPushTokenLeaf = "swarm/services/attic/push-token";
# The in-swarm cache's other two secrets, which its host units create: the
# key atticd signs tokens with, and the read-only token for the hives' pulls.
atticJwtLeaf = "swarm/services/attic/jwt-secret";
atticPullTokenLeaf = "swarm/services/attic/pull-token";
# The CI run poller's forge token, the nix half of
# `swarm_secret_client::ci_metrics::forge_token_path`. The controller mints it
# and reads it back before deciding whether to re-mint; the poller reads it
@ -1166,6 +1171,28 @@ let
}
];
# The in-swarm cache's three secrets, create and read and nothing more: its
# host units create each one only while it is absent, so none needs `update`,
# and a rewrite of a signing key or a token an operator holds is refused.
atticWriters = [
{
name = "swarm-attic";
cn = baoDeploy.atticCommonName;
policyText =
lib.concatMapStrings
(leaf: ''
path "${credentialMountPath}/data/${leaf}" {
capabilities = ["create", "read"]
}
'')
[
atticJwtLeaf
atticPushTokenLeaf
atticPullTokenLeaf
];
}
];
# The CI run poller: its forge token and its own OIDC client secret, both
# read once per pass by the host unit.
ciMetricsReaders = [
@ -2409,6 +2436,43 @@ in
'';
};
atticCommonName = lib.mkOption {
type = lib.types.str;
default = "swarm-attic";
description = ''
Subject the store's `swarm-attic` cert-auth role accepts: the identity
the in-swarm cache's host units present. Its grant is create and read on
three paths, `swarm/services/attic/jwt-secret`, `push-token` and
`pull-token`, and nothing else.
⚠️ Reserved as a hive name by ./swarm.nix, like its siblings.
'';
};
atticClientCertFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/var/lib/swarm-bao-pki/attic.pem";
description = ''
Certificate the in-swarm cache's host units present to the store. Its
subject must be {option}`services.hyperhive.deploy.bao.atticCommonName`.
./glue-bao-tls.nix points it at the leaf it mints on the store's host.
On a cache host without the store, it is a file the operator copies
from the store's host. A path, never a value.
'';
};
atticClientKeyFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/var/lib/swarm-bao-pki/attic-key.pem";
description = ''
Private key for {option}`services.hyperhive.deploy.bao.atticClientCertFile`.
A path, never a value.
'';
};
claudeLoginClientCertFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
@ -3571,6 +3635,8 @@ in
systemd.services.swarm-bao-claude-login-policy = readerPolicyUnit "write the claude login minter's credentials-writer bao policy and cert-auth role" claudeLoginWriters;
systemd.services.swarm-bao-attic-policy = readerPolicyUnit "write the in-swarm cache's secret-creator bao policy and cert-auth role" atticWriters;
systemd.services.swarm-bao-notify-router-policy = readerPolicyUnit "write the notification router's client-secret-reader bao policy and cert-auth role" notifyRouterReaders;
systemd.services.swarm-bao-forge-publisher-policy = readerPolicyUnit "write the forge's controller-token-publisher bao policy and cert-auth role" forgePublisherWriters;

View file

@ -130,6 +130,15 @@ in
&& deployCfg.bao.ciMetricsClientKeyFile != null
);
# The binary cache, on the same terms: its units log in to the store as the
# cache's own principal. An operator running an external cache sets this to
# `false` and names `swarm.attic.{domain,cache}` itself.
config.services.hyperhive.deploy.attic.enable = lib.mkDefault (
deployCfg.allSwarmServices
&& deployCfg.bao.atticClientCertFile != null
&& deployCfg.bao.atticClientKeyFile != null
);
# The claude login minter, defaulted on the same terms as the review
# classifier.
config.services.hyperhive.deploy.claude-login.enable = lib.mkDefault (

View file

@ -62,6 +62,7 @@ let
deployCfg.bao.ciMetricsCommonName
deployCfg.bao.claudeLoginCommonName
deployCfg.bao.notifyRouterCommonName
deployCfg.bao.atticCommonName
deployCfg.bao.forgePublisherCommonName
]
# The per-hive subject, spelled out per hive rather than as the prefix.
@ -112,6 +113,10 @@ let
swarmCfg.victoriametrics.domain
swarmCfg.otel.domain
swarmCfg.victorialogs.domain
# The name the swarm's own cache is served under, listed whether or not any
# host runs one, as every name here is. `swarm.attic.domain` is not: it is
# null where there is no cache and may name an external one.
swarmCfg.attic.ownDomain
# The store's browser UI, not the store: `swarm.bao.domain` is mTLS and
# never gets a gateway certificate.
swarmCfg.bao.ui.domain