run attic as a swarm service with deploy.attic.enable
This commit is contained in:
parent
f59ed01353
commit
458a046b8d
11 changed files with 1242 additions and 66 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
165
nix/host-modules/lib/attic-init.nix
Normal file
165
nix/host-modules/lib/attic-init.nix
Normal 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
|
||||
'';
|
||||
}
|
||||
|
|
@ -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.
|
||||
'';
|
||||
};
|
||||
|
|
|
|||
332
nix/host-modules/swarm-attic.nix
Normal file
332
nix/host-modules/swarm-attic.nix
Normal 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";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue