swarm-archive: back up every agent's state to an append-only restic repository per agent
swarm-archive serves restic repositories with rclone, append-only, one login and one repository per agent. swarm-controller writes each placed agent's login and restic password to the store, replaces them on a move and removes them when the agent is destroyed. Every hive backs its agents up hourly under its own store identity. The archive host prunes by retention.
This commit is contained in:
parent
409e7ee5a0
commit
306aae0378
13 changed files with 2214 additions and 5 deletions
|
|
@ -22,6 +22,7 @@
|
|||
./hive-matrix.nix
|
||||
./hive-network.nix
|
||||
./hive-priv.nix
|
||||
./hive-archive-backup.nix
|
||||
./hive-priv-cache.nix
|
||||
./hive-squid.nix
|
||||
./hive-squid-credential.nix
|
||||
|
|
@ -65,6 +66,7 @@
|
|||
./glue-swarm-otel-oidc-client.nix
|
||||
./glue-victorialogs-otel-oidc-client.nix
|
||||
./glue-victoriametrics-otel-oidc-client.nix
|
||||
./swarm-archive.nix
|
||||
./swarm-attic-options.nix
|
||||
./swarm-attic.nix
|
||||
./swarm-authelia-service.nix
|
||||
|
|
|
|||
|
|
@ -734,6 +734,25 @@ in
|
|||
'';
|
||||
};
|
||||
|
||||
archive.enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
example = true;
|
||||
description = ''
|
||||
Run the swarm's backup archive on this host: `rclone serve restic`
|
||||
behind the gateway at `archive.<swarm domain>`, append-only, with one
|
||||
login and one repository per agent, and a daily prune that applies the
|
||||
retention. Every hive's backup unit finds it by that name, so it runs
|
||||
on one host and the host does not matter. Run it on one host.
|
||||
|
||||
On by default where
|
||||
{option}`services.hyperhive.deploy.allSwarmServices` is set and this
|
||||
host has the archive's store identity (the pair
|
||||
{option}`services.hyperhive.deploy.bao.archiveClientCertFile` and
|
||||
`archiveClientKeyFile`, minted on the store's host).
|
||||
'';
|
||||
};
|
||||
|
||||
hive-controller.enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
|
|
|
|||
|
|
@ -90,6 +90,16 @@ let
|
|||
here = deployCfg.attic.enable;
|
||||
policies = [ "swarm-bao-attic-policy" ];
|
||||
};
|
||||
# ./swarm-archive.nix, under the archive's leaf, which reads the users and the
|
||||
# repositories' passwords only once that policy is written.
|
||||
swarm-archive-users = {
|
||||
here = deployCfg.archive.enable;
|
||||
policies = [ "swarm-bao-archive-policy" ];
|
||||
};
|
||||
swarm-archive-prune = {
|
||||
here = deployCfg.archive.enable;
|
||||
policies = [ "swarm-bao-archive-policy" ];
|
||||
};
|
||||
# ./hive-matrix.nix, under matrix-ctl's leaf.
|
||||
swarm-bao-matrix-oidc = {
|
||||
here = deployCfg.matrix.enable;
|
||||
|
|
|
|||
|
|
@ -69,6 +69,9 @@ let
|
|||
# ./swarm-attic.nix
|
||||
swarm-bao-attic-secret = deployCfg.attic.enable;
|
||||
swarm-bao-attic-tokens = deployCfg.attic.enable;
|
||||
# ./swarm-archive.nix
|
||||
swarm-archive-users = deployCfg.archive.enable;
|
||||
swarm-archive-prune = deployCfg.archive.enable;
|
||||
# ./swarm-otel.nix
|
||||
swarm-bao-otel-oidc =
|
||||
deployCfg.swarm-otel.enable
|
||||
|
|
|
|||
|
|
@ -126,6 +126,8 @@ in
|
|||
notifyRouterClientKeyFile = lib.mkDefault "${pkiDir}/notify-router-key.pem";
|
||||
atticClientCertFile = lib.mkDefault "${pkiDir}/attic.pem";
|
||||
atticClientKeyFile = lib.mkDefault "${pkiDir}/attic-key.pem";
|
||||
archiveClientCertFile = lib.mkDefault "${pkiDir}/archive.pem";
|
||||
archiveClientKeyFile = lib.mkDefault "${pkiDir}/archive-key.pem";
|
||||
forgePublisherClientCertFile = lib.mkDefault "${pkiDir}/forge-publisher.pem";
|
||||
forgePublisherClientKeyFile = lib.mkDefault "${pkiDir}/forge-publisher-key.pem";
|
||||
servicesIssuerClientCertFile = lib.mkDefault "${pkiDir}/services-issuer.pem";
|
||||
|
|
@ -337,6 +339,13 @@ in
|
|||
[ -s ${pkiDir}/attic.pem ] || ${signLeaf} ${pkiDir} attic \
|
||||
${lib.escapeShellArg deployCfg.bao.atticCommonName} "" clientAuth
|
||||
|
||||
# The backup archive's, which reads its users and the repositories'
|
||||
# passwords. Minted here for the matrix-ctl leaf's reason: the archive
|
||||
# is a swarm service, so on an archive host without the store this is
|
||||
# the file an operator copies.
|
||||
[ -s ${pkiDir}/archive.pem ] || ${signLeaf} ${pkiDir} archive \
|
||||
${lib.escapeShellArg deployCfg.bao.archiveCommonName} "" 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.
|
||||
|
|
|
|||
185
nix/host-modules/hive-archive-backup.nix
Normal file
185
nix/host-modules/hive-archive-backup.nix
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
# The hive's backup of its agents' state to the swarm archive
|
||||
# (./swarm-archive.nix): one restic repository per agent, at
|
||||
# `https://archive.<swarm domain>/<agent>/`.
|
||||
#
|
||||
# A root oneshot on a timer, not hive-c0re and not a hive-priv verb. Root,
|
||||
# because the agents' files belong to the agents (and, under user namespaces,
|
||||
# to remapped ids) and hive-c0re cannot read them; a unit of its own, because
|
||||
# no caller-supplied argument may reach root, and this one takes none.
|
||||
#
|
||||
# Each agent backs up under the credentials swarm-controller wrote for it at
|
||||
# `swarm/hives/<this hive>/archive/<agent>`
|
||||
# (../../swarm-controller/src/archive_access.rs), read with the hive's own
|
||||
# store identity: the agent's login to the archive, and its restic password,
|
||||
# which restic reads through `RESTIC_PASSWORD_COMMAND` and nothing writes to
|
||||
# disk. An agent the controller has not granted this hive is skipped; a hive
|
||||
# with agents and no grant at all fails, which is what a swarm-controller
|
||||
# without the archive looks like.
|
||||
#
|
||||
# A failed agent prints `archive backup failed: <agent>` to the journal,
|
||||
# which the swarm's `archive backup failed` alert reads, and fails the unit
|
||||
# after the others have been tried.
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
let
|
||||
hyperhiveCfg = config.services.hyperhive;
|
||||
hiveController = hyperhiveCfg.deploy.hive-controller;
|
||||
cfg = hiveController.archive;
|
||||
tls = hiveController.tls;
|
||||
baoCfg = hyperhiveCfg.swarm.bao;
|
||||
baoDeploy = hyperhiveCfg.deploy.bao;
|
||||
|
||||
enabled =
|
||||
cfg.enable
|
||||
&& hiveController.enable
|
||||
&& hiveController.hiveName != null
|
||||
&& tls.baoHiveIdentityCertFile != null
|
||||
&& tls.baoHiveIdentityKeyFile != null;
|
||||
|
||||
# The CA bundle restic trusts: the host's, then the swarm's root, which the
|
||||
# archive's certificate chains to when the gateway is self-signed. Written by
|
||||
# the unit at each run; `--cacert` replaces restic's own roots.
|
||||
runtimeDir = "/run/hive-archive-backup";
|
||||
caBundle = "${runtimeDir}/ca.pem";
|
||||
swarmCa = "${tls.stateDir}/swarm-services-root.pem";
|
||||
|
||||
storeContainer = lib.optional baoDeploy.enable "container@${baoCfg.machine}.service";
|
||||
|
||||
backup = import ./lib/archive-backup.nix { inherit lib; } {
|
||||
inherit (cfg) scope paths excludes;
|
||||
restic = cfg.package;
|
||||
hive = hiveController.hiveName;
|
||||
agentRoot = "/var/lib/hyperhive/agents";
|
||||
url = "https://${hyperhiveCfg.swarm.archive.ownDomain}";
|
||||
inherit caBundle;
|
||||
};
|
||||
in
|
||||
{
|
||||
options.services.hyperhive.deploy.hive-controller.archive = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = ''
|
||||
Back this hive's agents up to the swarm archive. On wherever the hive
|
||||
has its store identity; a swarm that runs no archive
|
||||
({option}`services.hyperhive.deploy.archive.enable` on one host) sees
|
||||
every run fail, so turn it off there.
|
||||
'';
|
||||
};
|
||||
|
||||
schedule = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "hourly";
|
||||
example = "*-*-* 00/6:00:00";
|
||||
description = ''
|
||||
systemd calendar expression for the backup. Each run sends only what
|
||||
changed since the last, so an hourly run on an idle agent is small.
|
||||
'';
|
||||
};
|
||||
|
||||
scope = lib.mkOption {
|
||||
type = lib.types.enum [
|
||||
"paths"
|
||||
"subvolume"
|
||||
];
|
||||
default = "paths";
|
||||
description = ''
|
||||
What of each agent is backed up. `paths`: the entries of
|
||||
{option}`services.hyperhive.deploy.hive-controller.archive.paths`
|
||||
inside the agent's directory. `subvolume`: the whole directory, which
|
||||
adds `work/` and everything an agent keeps there.
|
||||
'';
|
||||
};
|
||||
|
||||
paths = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [
|
||||
"state"
|
||||
"claude"
|
||||
"harness"
|
||||
];
|
||||
description = ''
|
||||
Entries of an agent's directory backed up under `scope = "paths"`: its
|
||||
state, its claude login and the harness. Caches and `work/` are left
|
||||
out unless listed. An entry the agent does not have is skipped.
|
||||
'';
|
||||
};
|
||||
|
||||
excludes = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ ];
|
||||
example = [ "*/target" ];
|
||||
description = "restic `--exclude` patterns, on top of `--exclude-caches`.";
|
||||
};
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = pkgs.restic;
|
||||
defaultText = lib.literalExpression "pkgs.restic";
|
||||
description = "The restic package.";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf enabled {
|
||||
services.hyperhive.otel.journalUnits = lib.optionals (!hyperhiveCfg.deploy.swarm-otel.enable) [
|
||||
"hive-archive-backup.service"
|
||||
];
|
||||
|
||||
systemd.services.hive-archive-backup = {
|
||||
description = "back up this hive's agents to the swarm archive";
|
||||
wants = [ "network-online.target" ];
|
||||
after = [ "network-online.target" ] ++ storeContainer;
|
||||
path = [
|
||||
baoDeploy.package
|
||||
pkgs.btrfs-progs
|
||||
pkgs.coreutils
|
||||
pkgs.gnugrep
|
||||
pkgs.jq
|
||||
];
|
||||
environment = {
|
||||
BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}";
|
||||
BAO_CLIENT_CERT = tls.baoHiveIdentityCertFile;
|
||||
BAO_CLIENT_KEY = tls.baoHiveIdentityKeyFile;
|
||||
RESTIC_CACHE_DIR = "/var/cache/hive-archive-backup";
|
||||
}
|
||||
// lib.optionalAttrs (baoDeploy.serverCaFile != null) {
|
||||
BAO_CACERT = baoDeploy.serverCaFile;
|
||||
};
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
UMask = "0077";
|
||||
SyslogIdentifier = "hive-archive-backup";
|
||||
RuntimeDirectory = "hive-archive-backup";
|
||||
CacheDirectory = "hive-archive-backup";
|
||||
# A first backup of a large agent sends all of it.
|
||||
TimeoutStartSec = "6h";
|
||||
Nice = 10;
|
||||
IOSchedulingClass = "idle";
|
||||
};
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
# A missing file is left out: the bundle is the host's roots alone
|
||||
# until the swarm's root is there.
|
||||
cat ${lib.escapeShellArg (toString config.security.pki.caBundle)} > ${caBundle}
|
||||
if [ -s ${lib.escapeShellArg swarmCa} ]; then
|
||||
cat ${lib.escapeShellArg swarmCa} >> ${caBundle}
|
||||
fi
|
||||
${backup}
|
||||
'';
|
||||
};
|
||||
|
||||
systemd.timers.hive-archive-backup = {
|
||||
description = "back up this hive's agents to the swarm archive";
|
||||
wantedBy = [ "timers.target" ];
|
||||
timerConfig = {
|
||||
OnCalendar = cfg.schedule;
|
||||
RandomizedDelaySec = "5min";
|
||||
Persistent = true;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
137
nix/host-modules/lib/archive-backup.nix
Normal file
137
nix/host-modules/lib/archive-backup.nix
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
# The hive's backup run: for each agent that has a state directory here and a
|
||||
# grant from swarm-controller, one `restic backup` into that agent's repository
|
||||
# on the archive. A pure function, NOT a NixOS module: ../hive-archive-backup.nix
|
||||
# wraps it in a unit, and the VM test (nix/vm-tests/swarm-archive.nix) runs the
|
||||
# same script against a stand-in `bao`.
|
||||
#
|
||||
# Needs `bao`, `jq`, `coreutils`, `gnugrep`, `btrfs-progs` on the unit's `path`
|
||||
# and the `BAO_*` client set. Every failure prints a line starting
|
||||
# `archive backup failed`, which the swarm's alert reads, and ends the run with
|
||||
# a non-zero status once the other agents have been tried.
|
||||
{ lib }:
|
||||
{
|
||||
restic,
|
||||
# The hive's name in the store: grants are read from
|
||||
# `secret/swarm/hives/<hive>/archive/`.
|
||||
hive,
|
||||
# The directory holding one directory per agent.
|
||||
agentRoot,
|
||||
# The repository server's base URL, no trailing slash.
|
||||
url,
|
||||
# "paths": `paths` under each agent's directory. "subvolume": the whole directory.
|
||||
scope,
|
||||
paths,
|
||||
excludes,
|
||||
# A CA file to trust instead of restic's default, or null.
|
||||
caBundle,
|
||||
}:
|
||||
let
|
||||
storeSeededSecret = import ./store-seeded-secret.nix { };
|
||||
|
||||
grants = hive: "secret/swarm/hives/${hive}/archive";
|
||||
in
|
||||
''
|
||||
set -euo pipefail
|
||||
${storeSeededSecret}
|
||||
|
||||
store_init
|
||||
store_login
|
||||
if [ "$STORE_UP" != 1 ]; then
|
||||
echo "archive backup failed: could not log in to the swarm secret store" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
${lib.optionalString (caBundle != null) ''
|
||||
export RESTIC_CACERT=${lib.escapeShellArg caBundle}
|
||||
''}
|
||||
root=${lib.escapeShellArg agentRoot}
|
||||
grants=${lib.escapeShellArg (grants hive)}
|
||||
|
||||
# One agent: a read-only snapshot of its subvolume when it is one, so the
|
||||
# databases are copied at one instant, else its directory in place.
|
||||
backup_agent() {
|
||||
local agent="$1" leaf="$grants/$1" rest base snap="" target targets=()
|
||||
rest="$(store_bao kv get -field=rest-password "$leaf")" || return 1
|
||||
export RESTIC_REPOSITORY="rest:${url}/$agent/"
|
||||
export RESTIC_REST_USERNAME="$agent"
|
||||
export RESTIC_REST_PASSWORD="$rest"
|
||||
export RESTIC_PASSWORD_COMMAND="bao kv get -field=restic-password $leaf"
|
||||
|
||||
if ! ${restic}/bin/restic cat config >/dev/null 2>&1; then
|
||||
${restic}/bin/restic init || return 1
|
||||
fi
|
||||
|
||||
base="$root/$agent"
|
||||
if btrfs subvolume show "$base" >/dev/null 2>&1; then
|
||||
snap="$root/.$agent.snapshot.archive"
|
||||
btrfs subvolume delete "$snap" >/dev/null 2>&1 || true
|
||||
btrfs subvolume snapshot -r "$base" "$snap" >/dev/null || return 1
|
||||
base="$snap"
|
||||
fi
|
||||
|
||||
${
|
||||
if scope == "subvolume" then
|
||||
''targets=("$base")''
|
||||
else
|
||||
''
|
||||
for target in ${lib.escapeShellArgs paths}; do
|
||||
[ -e "$base/$target" ] && targets+=("$base/$target")
|
||||
done''
|
||||
}
|
||||
local rc=0
|
||||
if [ "''${#targets[@]}" = 0 ]; then
|
||||
echo "$agent has none of its backup paths yet; nothing to back up."
|
||||
else
|
||||
${restic}/bin/restic backup --host "$agent" --tag archive --exclude-caches \
|
||||
${lib.concatMapStringsSep " " (e: "--exclude ${lib.escapeShellArg e}") excludes} \
|
||||
-- "''${targets[@]}" || rc=$?
|
||||
fi
|
||||
if [ -n "$snap" ]; then
|
||||
btrfs subvolume delete "$snap" >/dev/null || rc=1
|
||||
fi
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
# The agents the controller granted this hive; none yet is "No value found".
|
||||
rc=0
|
||||
granted="$(store_bao kv list -format=json "$grants" 2>"$STORE_ERR")" || rc=$?
|
||||
if [ "$rc" != 0 ]; then
|
||||
if grep -q 'No value found' "$STORE_ERR"; then
|
||||
granted='[]'
|
||||
else
|
||||
cat "$STORE_ERR" >&2
|
||||
echo "archive backup failed: could not list $grants" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
granted="$(printf '%s' "$granted" | jq -r '.[]')"
|
||||
|
||||
failed=0
|
||||
candidates=0
|
||||
backed_up=0
|
||||
for dir in "$root"/*/; do
|
||||
agent="$(basename -- "$dir")"
|
||||
case "$agent" in .*) continue ;; esac
|
||||
[[ "$agent" =~ ^[A-Za-z0-9_-]+$ ]] || continue
|
||||
candidates=$((candidates + 1))
|
||||
if ! grep -qxF -- "$agent" <<<"$granted"; then
|
||||
echo "$agent has no archive credentials on this hive; skipping."
|
||||
continue
|
||||
fi
|
||||
if (backup_agent "$agent"); then
|
||||
backed_up=$((backed_up + 1))
|
||||
echo "backed up $agent"
|
||||
else
|
||||
echo "archive backup failed: $agent" >&2
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
|
||||
# Agents here and not one of them granted: swarm-controller has not written
|
||||
# this hive's grants, which a controller without the archive never does.
|
||||
if [ "$candidates" -gt 0 ] && [ -z "$granted" ]; then
|
||||
echo "archive backup failed: swarm-controller has granted this hive no archive credentials ($candidates agents here)" >&2
|
||||
failed=1
|
||||
fi
|
||||
exit "$failed"
|
||||
''
|
||||
156
nix/host-modules/lib/archive-server.nix
Normal file
156
nix/host-modules/lib/archive-server.nix
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
# The archive server's moving parts: the rclone command that serves the
|
||||
# repositories, the script that renders its users file from the secret store,
|
||||
# and the script that prunes each repository. A pure function, NOT a NixOS
|
||||
# module: ../swarm-archive.nix wraps the pieces in units, and the VM test
|
||||
# (nix/vm-tests/swarm-archive.nix) runs the same scripts against a stand-in
|
||||
# `bao`.
|
||||
#
|
||||
# Both scripts use only `bao login -method=cert -token-only`,
|
||||
# `bao kv list -format=json` and `bao kv get -field=…`, and need `bao`, `jq`,
|
||||
# `coreutils`, `gnugrep` on the unit's `path` and the `BAO_*` client set.
|
||||
{ lib }:
|
||||
{
|
||||
rclone,
|
||||
restic,
|
||||
# Where the repositories live: `<reposDir>/<agent>/`. The only place that
|
||||
# names the backend; `rclone serve restic` takes any remote here.
|
||||
reposDir,
|
||||
# The address rclone listens on.
|
||||
addr,
|
||||
# The users file rclone checks logins against, its group and the user the
|
||||
# server runs as.
|
||||
authFile,
|
||||
authGroup,
|
||||
# `{ keepWithin, keepLast }`; a null `keepWithin` leaves that flag off.
|
||||
retention,
|
||||
}:
|
||||
let
|
||||
atomicWriteSecret = import ./atomic-write-secret.nix { };
|
||||
refreshConsumer = import ./refresh-consumer.nix { };
|
||||
storeSeededSecret = import ./store-seeded-secret.nix { };
|
||||
|
||||
usersPrefix = "secret/swarm/services/archive/users";
|
||||
resticPrefix = "secret/swarm/services/archive/restic";
|
||||
|
||||
keepFlags = lib.concatStringsSep " " (
|
||||
[ "--keep-last ${toString retention.keepLast}" ]
|
||||
++ lib.optional (
|
||||
retention.keepWithin != null
|
||||
) "--keep-within ${lib.escapeShellArg retention.keepWithin}"
|
||||
);
|
||||
in
|
||||
{
|
||||
inherit usersPrefix resticPrefix;
|
||||
|
||||
# Append-only: a client cannot delete or overwrite a snapshot, a pack or the
|
||||
# index, so a hive that is compromised cannot erase history. Private repos:
|
||||
# a login reaches `/<its own name>/` and nothing else.
|
||||
serverCommand = lib.escapeShellArgs [
|
||||
"${rclone}/bin/rclone"
|
||||
"serve"
|
||||
"restic"
|
||||
"--addr"
|
||||
addr
|
||||
"--append-only"
|
||||
"--private-repos"
|
||||
"--htpasswd"
|
||||
authFile
|
||||
reposDir
|
||||
];
|
||||
|
||||
# Renders the users file from `usersPrefix`, one leaf per agent holding that
|
||||
# agent's htpasswd line. The file is written only when the content changed,
|
||||
# and never from a partial read: any leaf that cannot be read leaves the file
|
||||
# as it is.
|
||||
usersScript = ''
|
||||
set -euo pipefail
|
||||
${storeSeededSecret}
|
||||
${atomicWriteSecret}
|
||||
${refreshConsumer}
|
||||
|
||||
file=${lib.escapeShellArg authFile}
|
||||
# The server will not start without the file; an empty one admits nobody.
|
||||
if [ ! -e "$file" ]; then
|
||||
atomic_write_secret 0640 root:${authGroup} "$file" ""
|
||||
fi
|
||||
|
||||
store_init
|
||||
store_login
|
||||
if [ "$STORE_UP" != 1 ]; then
|
||||
echo "could not log in to the swarm secret store; $file stays as it is." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A prefix that holds nothing is "No value found"; it means no agent has a
|
||||
# user yet, not a failure.
|
||||
rc=0
|
||||
names="$(store_bao kv list -format=json ${lib.escapeShellArg usersPrefix} 2>"$STORE_ERR")" || rc=$?
|
||||
if [ "$rc" != 0 ]; then
|
||||
if grep -q 'No value found' "$STORE_ERR"; then
|
||||
names='[]'
|
||||
else
|
||||
cat "$STORE_ERR" >&2
|
||||
echo "could not list ${usersPrefix}; $file stays as it is." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
content=""
|
||||
while IFS= read -r name; do
|
||||
[ -n "$name" ] || continue
|
||||
case "$name" in */) continue ;; esac
|
||||
if ! line="$(store_bao kv get -field=htpasswd ${lib.escapeShellArg usersPrefix}/"$name" 2>"$STORE_ERR")"; then
|
||||
cat "$STORE_ERR" >&2
|
||||
echo "could not read the user $name; $file stays as it is." >&2
|
||||
exit 1
|
||||
fi
|
||||
# The line must be this agent's own: a leaf cannot add another user.
|
||||
if [[ ! "$name" =~ ^[A-Za-z0-9_-]+$ ]] || [[ "$line" != "$name:{SHA}"* ]] \
|
||||
|| [[ ! "$line" =~ ^[A-Za-z0-9_-]+:\{SHA\}[A-Za-z0-9+/=]+$ ]]; then
|
||||
echo "the user leaf $name does not hold a {SHA} line for $name; leaving it out." >&2
|
||||
continue
|
||||
fi
|
||||
content+="$line"$'\n'
|
||||
done < <(printf '%s' "$names" | jq -r '.[]')
|
||||
content="''${content%$'\n'}"
|
||||
|
||||
if secret_differs "$file" "$content"; then
|
||||
atomic_write_secret 0640 root:${authGroup} "$file" "$content"
|
||||
echo "users file updated."
|
||||
fi
|
||||
'';
|
||||
|
||||
# `restic forget --prune` on every repository, with its password read from
|
||||
# the store at run time. The server refuses deletes from clients, so this is
|
||||
# the only place history is dropped.
|
||||
pruneScript = ''
|
||||
set -euo pipefail
|
||||
${storeSeededSecret}
|
||||
|
||||
store_init
|
||||
store_login
|
||||
if [ "$STORE_UP" != 1 ]; then
|
||||
echo "archive prune failed: could not log in to the swarm secret store" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
failed=0
|
||||
for repo in ${lib.escapeShellArg reposDir}/*/; do
|
||||
[ -e "$repo/config" ] || continue
|
||||
agent="$(basename -- "$repo")"
|
||||
if [[ ! "$agent" =~ ^[A-Za-z0-9_-]+$ ]]; then
|
||||
echo "archive prune failed: $agent is not an agent name" >&2
|
||||
failed=1
|
||||
continue
|
||||
fi
|
||||
export RESTIC_PASSWORD_COMMAND="bao kv get -field=password ${resticPrefix}/$agent"
|
||||
if ${restic}/bin/restic -r "$repo" --retry-lock 10m forget --group-by host ${keepFlags} --prune; then
|
||||
echo "pruned $agent"
|
||||
else
|
||||
echo "archive prune failed: $agent" >&2
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
exit "$failed"
|
||||
'';
|
||||
}
|
||||
348
nix/host-modules/swarm-archive.nix
Normal file
348
nix/host-modules/swarm-archive.nix
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
# The swarm's backup archive: `rclone serve restic` on the host that sets
|
||||
# `deploy.archive.enable`, behind the gateway at `archive.<swarm domain>`.
|
||||
# Every hive's backup unit (./hive-archive-backup.nix) writes one restic
|
||||
# repository per agent to it; nothing else uses it.
|
||||
#
|
||||
# - `--append-only`: a client cannot delete or overwrite a snapshot, a pack or
|
||||
# the index, so a compromised hive cannot erase history. Dropping history is
|
||||
# the prune unit's job, on this host, by retention.
|
||||
# - `--private-repos`: a login reaches `/<its own name>/` only. There is one
|
||||
# login per agent, created by swarm-controller (../../swarm-controller/src/archive_access.rs)
|
||||
# when it places the agent, replaced when it moves and removed when it is
|
||||
# destroyed. An agent's hive can write that agent's repository and no other.
|
||||
#
|
||||
# Everything secret lives in the swarm secret store and is read when needed:
|
||||
#
|
||||
# - `swarm-archive-users` (root, every two minutes) renders the htpasswd file
|
||||
# from the users leaves into `/run`, which is tmpfs. rclone re-reads the file
|
||||
# when it changes. The leaves hold password hashes only.
|
||||
# - `swarm-archive-prune` (daily) reads each agent's restic password through
|
||||
# `RESTIC_PASSWORD_COMMAND` for its `restic forget --prune`. This host can
|
||||
# therefore decrypt every backup, which is the cost of pruning where clients
|
||||
# cannot delete.
|
||||
#
|
||||
# Both units log in as the archive's own store principal (a leaf of the store's
|
||||
# CA, minted by ./glue-bao-tls.nix). Plain host units rather than a container:
|
||||
# no secret crosses a mount, and the backend is one string
|
||||
# (`reposDir`, in ./lib/archive-server.nix) that names where the repositories
|
||||
# are.
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
let
|
||||
hyperhiveCfg = config.services.hyperhive;
|
||||
cfg = hyperhiveCfg.deploy.archive;
|
||||
archiveCfg = hyperhiveCfg.swarm.archive;
|
||||
baoCfg = hyperhiveCfg.swarm.bao;
|
||||
baoDeploy = hyperhiveCfg.deploy.bao;
|
||||
|
||||
swarmDomain = hyperhiveCfg.swarm.domain;
|
||||
# Total on a null swarm domain, as the sibling options modules are: the
|
||||
# required-domain assertion in hive-network.nix is what an operator sees.
|
||||
domainBase = if swarmDomain == null then "invalid" else swarmDomain;
|
||||
|
||||
user = "swarm-archive";
|
||||
authDir = "/run/swarm-archive-auth";
|
||||
authFile = "${authDir}/htpasswd";
|
||||
reposDir = "${cfg.dataDir}/repos";
|
||||
|
||||
server = import ./lib/archive-server.nix { inherit lib; } {
|
||||
inherit (cfg) retention;
|
||||
inherit authFile reposDir;
|
||||
rclone = cfg.package;
|
||||
restic = cfg.resticPackage;
|
||||
addr = "127.0.0.1:${toString cfg.port}";
|
||||
authGroup = user;
|
||||
};
|
||||
|
||||
storeRetry = import ./lib/store-retry.nix { };
|
||||
|
||||
haveClientIdentity =
|
||||
baoDeploy.archiveClientCertFile != null && baoDeploy.archiveClientKeyFile != null;
|
||||
|
||||
storeEnvironment = {
|
||||
BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}";
|
||||
}
|
||||
// lib.optionalAttrs (baoDeploy.serverCaFile != null) {
|
||||
BAO_CACERT = baoDeploy.serverCaFile;
|
||||
};
|
||||
in
|
||||
{
|
||||
options.services.hyperhive.swarm.archive.ownDomain = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
readOnly = true;
|
||||
default = "archive.${domainBase}";
|
||||
defaultText = lib.literalExpression ''"archive.''${services.hyperhive.swarm.domain}"'';
|
||||
description = ''
|
||||
Read-only: the name the swarm's backup archive is served under and every
|
||||
hive's backup unit dials. 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 the archive.
|
||||
'';
|
||||
};
|
||||
|
||||
options.services.hyperhive.deploy.archive = {
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = pkgs.rclone;
|
||||
defaultText = lib.literalExpression "pkgs.rclone";
|
||||
description = "The rclone package: `rclone serve restic`.";
|
||||
};
|
||||
|
||||
resticPackage = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = pkgs.restic;
|
||||
defaultText = lib.literalExpression "pkgs.restic";
|
||||
description = "The restic package the prune unit runs.";
|
||||
};
|
||||
|
||||
port = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 8911;
|
||||
description = ''
|
||||
Port rclone listens on, on the host's loopback. The gateway's vhost
|
||||
proxies to it; every login is checked by rclone itself.
|
||||
'';
|
||||
};
|
||||
|
||||
dataDir = lib.mkOption {
|
||||
type = lib.types.path;
|
||||
default = "/var/lib/swarm-archive";
|
||||
description = ''
|
||||
Where the repositories live, under `repos/<agent>/`. Holds restic
|
||||
ciphertext: every backup of every agent, so size it for that.
|
||||
'';
|
||||
};
|
||||
|
||||
retention = {
|
||||
keepWithin = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = "3d";
|
||||
example = "14d";
|
||||
description = ''
|
||||
Every snapshot younger than this is kept (restic's `--keep-within`,
|
||||
written `1y2m3d4h`). It bounds how fast history can be evicted: a
|
||||
hive that appends many snapshots cannot push the last few days out.
|
||||
`null` leaves the flag off.
|
||||
'';
|
||||
};
|
||||
|
||||
keepLast = lib.mkOption {
|
||||
type = lib.types.ints.positive;
|
||||
default = 3;
|
||||
description = ''
|
||||
The newest this many snapshots of each agent are kept, however old
|
||||
(restic's `--keep-last`). Together with
|
||||
{option}`services.hyperhive.deploy.archive.retention.keepWithin` the
|
||||
prune keeps a snapshot either rule keeps.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
pruneSchedule = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "daily";
|
||||
example = "*-*-* 04:00:00";
|
||||
description = "systemd calendar expression for the prune: the retention rules are applied then.";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
assertions = [
|
||||
{
|
||||
assertion = haveClientIdentity;
|
||||
message = ''
|
||||
services.hyperhive.deploy.archive.enable requires the archive's
|
||||
swarm-secret-store client identity: set both
|
||||
|
||||
services.hyperhive.deploy.bao.archiveClientCertFile
|
||||
services.hyperhive.deploy.bao.archiveClientKeyFile
|
||||
|
||||
The archive reads its users and the repositories' passwords 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 archive.pem and archive-key.pem from the store
|
||||
host's /var/lib/swarm-bao-pki/ and name them in those two options.
|
||||
'';
|
||||
}
|
||||
];
|
||||
|
||||
users.users.${user} = {
|
||||
isSystemUser = true;
|
||||
group = user;
|
||||
description = "swarm backup archive";
|
||||
};
|
||||
users.groups.${user} = { };
|
||||
|
||||
# This host serves the vhost, and the host's own resolver finds the name.
|
||||
services.hyperhive.gateway.localNames = [ archiveCfg.ownDomain ];
|
||||
services.hyperhive.gateway.enable = lib.mkDefault true;
|
||||
services.hyperhive.gateway.dns.enable = lib.mkDefault true;
|
||||
|
||||
# `forceSSL`: the logins ride in a header. No `auth_request`: rclone checks
|
||||
# them, and restic cannot follow a login redirect. The vhost keeps the
|
||||
# gateway's default `public`: `public = false` needs the gateway's
|
||||
# `privateListenAddresses` set, and every request is authenticated either
|
||||
# way. A backup is one request that can run for minutes, so the body is
|
||||
# neither size-limited nor buffered.
|
||||
services.hyperhive.gateway.vhosts."${archiveCfg.ownDomain}" = {
|
||||
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;
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
services.hyperhive.otel.journalUnits = lib.optionals (!hyperhiveCfg.deploy.swarm-otel.enable) [
|
||||
"swarm-archive.service"
|
||||
"swarm-archive-users.service"
|
||||
"swarm-archive-prune.service"
|
||||
];
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"d ${cfg.dataDir} 0750 ${user} ${user} -"
|
||||
"d ${reposDir} 0750 ${user} ${user} -"
|
||||
"d ${authDir} 0750 root ${user} -"
|
||||
];
|
||||
|
||||
systemd.services.swarm-archive = {
|
||||
description = "swarm backup archive (rclone serve restic, append-only, one repository per agent)";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
wants = [ "swarm-archive-users.service" ];
|
||||
after = [
|
||||
"network.target"
|
||||
"swarm-archive-users.service"
|
||||
"systemd-tmpfiles-setup.service"
|
||||
];
|
||||
# rclone writes no config, and reads none.
|
||||
environment.RCLONE_CONFIG = "/dev/null";
|
||||
serviceConfig = {
|
||||
ExecStart = server.serverCommand;
|
||||
User = user;
|
||||
Group = user;
|
||||
Restart = "on-failure";
|
||||
RestartSec = 5;
|
||||
SyslogIdentifier = "swarm-archive";
|
||||
NoNewPrivileges = true;
|
||||
ProtectSystem = "strict";
|
||||
ProtectHome = true;
|
||||
PrivateTmp = true;
|
||||
PrivateDevices = true;
|
||||
ProtectKernelTunables = true;
|
||||
ProtectControlGroups = true;
|
||||
RestrictAddressFamilies = [
|
||||
"AF_INET"
|
||||
"AF_INET6"
|
||||
];
|
||||
ReadWritePaths = [ reposDir ];
|
||||
ReadOnlyPaths = [ authDir ];
|
||||
};
|
||||
};
|
||||
|
||||
# The users file, from the store. A failed run leaves the file as it is;
|
||||
# the timer and the retry bring a store that comes back up to date.
|
||||
systemd.services.swarm-archive-users = {
|
||||
description = "render the archive's users file from the swarm secret store";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
wants = [ "network-online.target" ];
|
||||
after = [
|
||||
"network-online.target"
|
||||
"systemd-tmpfiles-setup.service"
|
||||
];
|
||||
path = [
|
||||
baoDeploy.package
|
||||
pkgs.coreutils
|
||||
pkgs.gnugrep
|
||||
pkgs.jq
|
||||
pkgs.systemd
|
||||
];
|
||||
environment = storeEnvironment // {
|
||||
BAO_CLIENT_CERT = baoDeploy.archiveClientCertFile;
|
||||
BAO_CLIENT_KEY = baoDeploy.archiveClientKeyFile;
|
||||
};
|
||||
inherit (storeRetry) startLimitBurst startLimitIntervalSec;
|
||||
serviceConfig = storeRetry.serviceConfig // {
|
||||
Type = "oneshot";
|
||||
UMask = "0027";
|
||||
SyslogIdentifier = "swarm-archive-users";
|
||||
TimeoutStartSec = "5min";
|
||||
};
|
||||
script = server.usersScript;
|
||||
};
|
||||
|
||||
# A new grant reaches the server within the controller's pass (five
|
||||
# minutes) plus this interval.
|
||||
systemd.timers.swarm-archive-users = {
|
||||
description = "re-render the archive's users file";
|
||||
wantedBy = [ "timers.target" ];
|
||||
timerConfig = {
|
||||
OnUnitInactiveSec = "2min";
|
||||
RandomizedDelaySec = "20s";
|
||||
};
|
||||
};
|
||||
|
||||
systemd.services.swarm-archive-prune = {
|
||||
description = "apply the archive's retention to every agent's repository";
|
||||
after = [ "network-online.target" ];
|
||||
wants = [ "network-online.target" ];
|
||||
path = [
|
||||
baoDeploy.package
|
||||
cfg.resticPackage
|
||||
pkgs.coreutils
|
||||
pkgs.gnugrep
|
||||
pkgs.jq
|
||||
];
|
||||
# The unit runs as the repositories' owner, so the store identity comes
|
||||
# in as credentials, which only it can read.
|
||||
environment = {
|
||||
BAO_ADDR = storeEnvironment.BAO_ADDR;
|
||||
BAO_CLIENT_CERT = "%d/bao-cert";
|
||||
BAO_CLIENT_KEY = "%d/bao-key";
|
||||
RESTIC_CACHE_DIR = "%C/swarm-archive-prune";
|
||||
}
|
||||
// lib.optionalAttrs (baoDeploy.serverCaFile != null) {
|
||||
BAO_CACERT = "%d/bao-ca";
|
||||
};
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
User = user;
|
||||
Group = user;
|
||||
SyslogIdentifier = "swarm-archive-prune";
|
||||
CacheDirectory = "swarm-archive-prune";
|
||||
LoadCredential = [
|
||||
"bao-cert:${baoDeploy.archiveClientCertFile}"
|
||||
"bao-key:${baoDeploy.archiveClientKeyFile}"
|
||||
]
|
||||
++ lib.optional (baoDeploy.serverCaFile != null) "bao-ca:${baoDeploy.serverCaFile}";
|
||||
NoNewPrivileges = true;
|
||||
ProtectSystem = "strict";
|
||||
ProtectHome = true;
|
||||
PrivateTmp = true;
|
||||
PrivateDevices = true;
|
||||
ReadWritePaths = [ reposDir ];
|
||||
TimeoutStartSec = "6h";
|
||||
};
|
||||
script = server.pruneScript;
|
||||
};
|
||||
|
||||
systemd.timers.swarm-archive-prune = {
|
||||
description = "apply the archive's retention";
|
||||
wantedBy = [ "timers.target" ];
|
||||
timerConfig = {
|
||||
OnCalendar = cfg.pruneSchedule;
|
||||
RandomizedDelaySec = "30min";
|
||||
Persistent = true;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -518,7 +518,39 @@ let
|
|||
capabilities = ["create", "read", "update"]
|
||||
}
|
||||
'') atticHiveFactNames}
|
||||
path "${credentialMountPath}/data/${ciRunnerTokenLeaf}" {
|
||||
# The archive credentials of the agents each hive places
|
||||
# (swarm-controller's archive_access.rs): the hive's copy and the archive's
|
||||
# hash and restic password, listed to find what no placement justifies and
|
||||
# deleted when a move or a destroy revokes it.
|
||||
path "${credentialMountPath}/data/swarm/hives/+/archive/*" {
|
||||
capabilities = ["create", "read", "update"]
|
||||
}
|
||||
|
||||
path "${credentialMountPath}/metadata/swarm/hives/+/archive/" {
|
||||
capabilities = ["list"]
|
||||
}
|
||||
|
||||
path "${credentialMountPath}/metadata/swarm/hives/+/archive/+" {
|
||||
capabilities = ["delete"]
|
||||
}
|
||||
|
||||
path "${credentialMountPath}/data/swarm/services/archive/users/*" {
|
||||
capabilities = ["create", "read", "update"]
|
||||
}
|
||||
|
||||
path "${credentialMountPath}/data/swarm/services/archive/restic/*" {
|
||||
capabilities = ["create", "read"]
|
||||
}
|
||||
|
||||
path "${credentialMountPath}/metadata/swarm/services/archive/users/" {
|
||||
capabilities = ["list"]
|
||||
}
|
||||
|
||||
path "${credentialMountPath}/metadata/swarm/services/archive/users/+" {
|
||||
capabilities = ["delete"]
|
||||
}
|
||||
|
||||
path "${credentialMountPath}/data/${ciRunnerTokenLeaf}" {
|
||||
capabilities = ["create", "update"]
|
||||
}
|
||||
|
||||
|
|
@ -998,7 +1030,11 @@ let
|
|||
"public-key"
|
||||
"pull-token"
|
||||
]
|
||||
+ readStanza "${credentialMountPath}/data/swarm/hives/${obj.hiveName}/squid-selfcheck";
|
||||
+ readStanza "${credentialMountPath}/data/swarm/hives/${obj.hiveName}/squid-selfcheck"
|
||||
# The archive credentials swarm-controller writes for the agents this hive
|
||||
# places: the backup unit (./hive-archive-backup.nix) lists them and reads each.
|
||||
+ readStanza "${credentialMountPath}/data/swarm/hives/${obj.hiveName}/archive/*"
|
||||
+ listStanza "${credentialMountPath}/metadata/swarm/hives/${obj.hiveName}/archive/";
|
||||
|
||||
# The PKI mount agent client certificates are issued from. Its root is
|
||||
# generated inside the store, so the agent CA's key never exists outside it.
|
||||
|
|
@ -1103,6 +1139,12 @@ let
|
|||
}
|
||||
'';
|
||||
|
||||
listStanza = path: ''
|
||||
path "${path}" {
|
||||
capabilities = ["list"]
|
||||
}
|
||||
'';
|
||||
|
||||
# Singletons, so a list of one rather than a second shape: the granting unit
|
||||
# below takes a list either way and the units stay identical apart from
|
||||
# the objects they write.
|
||||
|
|
@ -1339,6 +1381,20 @@ let
|
|||
}
|
||||
];
|
||||
|
||||
# The backup archive: the users file is rendered from one leaf per agent, and
|
||||
# the prune reads each agent's restic password. Read and list only; the
|
||||
# controller writes both.
|
||||
archiveReaders = [
|
||||
{
|
||||
name = "swarm-archive";
|
||||
cn = baoDeploy.archiveCommonName;
|
||||
policyText =
|
||||
readStanza "${credentialMountPath}/data/swarm/services/archive/users/*"
|
||||
+ readStanza "${credentialMountPath}/data/swarm/services/archive/restic/*"
|
||||
+ listStanza "${credentialMountPath}/metadata/swarm/services/archive/users/";
|
||||
}
|
||||
];
|
||||
|
||||
# The CI run poller: its forge token and its own OIDC client secret, both
|
||||
# read once per pass by the host unit.
|
||||
ciMetricsReaders = [
|
||||
|
|
@ -2611,6 +2667,42 @@ in
|
|||
'';
|
||||
};
|
||||
|
||||
archiveCommonName = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "swarm-archive";
|
||||
description = ''
|
||||
Subject the store's `swarm-archive` cert-auth role accepts: the identity
|
||||
the backup archive's host units present. Its grant is read on
|
||||
`swarm/services/archive/users/*` and `restic/*`, and list on `users`.
|
||||
|
||||
⚠️ Reserved as a hive name by ./swarm.nix, like its siblings.
|
||||
'';
|
||||
};
|
||||
|
||||
archiveClientCertFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "/var/lib/swarm-bao-pki/archive.pem";
|
||||
description = ''
|
||||
Certificate the backup archive's host units present to the store. Its
|
||||
subject must be {option}`services.hyperhive.deploy.bao.archiveCommonName`.
|
||||
|
||||
./glue-bao-tls.nix points it at the leaf it mints on the store's host.
|
||||
On an archive host without the store, it is a file the operator copies
|
||||
from the store's host. A path, never a value.
|
||||
'';
|
||||
};
|
||||
|
||||
archiveClientKeyFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "/var/lib/swarm-bao-pki/archive-key.pem";
|
||||
description = ''
|
||||
Private key for {option}`services.hyperhive.deploy.bao.archiveClientCertFile`.
|
||||
A path, never a value.
|
||||
'';
|
||||
};
|
||||
|
||||
atticClientCertFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
|
|
@ -3847,6 +3939,8 @@ in
|
|||
|
||||
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-archive-policy = readerPolicyUnit "write the backup archive's user and password reader bao policy and cert-auth role" archiveReaders;
|
||||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -1684,6 +1684,29 @@ in
|
|||
for = "0s";
|
||||
summary = "swarm-controller failed to mint agent {{ $labels.F_AGENT }}'s store identity; the reason is the `F_ERROR` field of the swarm-controller log line";
|
||||
})
|
||||
# `hive-archive-backup` (nix/host-modules/hive-archive-backup.nix)
|
||||
# prints one `archive backup failed: <agent>` line per agent
|
||||
# it could not back up, and one for a hive swarm-controller
|
||||
# has granted nothing. The backup runs hourly, so `for`
|
||||
# rides out the minutes a newly placed agent waits for its
|
||||
# grant, and a second failed run keeps it firing.
|
||||
(logCountRule {
|
||||
uid = "hh-archive-backup-failed";
|
||||
title = "archive backup failed";
|
||||
windowSeconds = 7800;
|
||||
expr = ''_time:130m _SYSTEMD_UNIT:="hive-archive-backup.service" _msg:"archive backup failed" | stats by (_HOSTNAME) count() as failures'';
|
||||
for = "30m";
|
||||
summary = "{{ $labels._HOSTNAME }} failed to back up an agent to the archive; the unit's journal names the agent and the cause";
|
||||
})
|
||||
# `swarm-archive-prune` prints `archive prune failed: <agent>`.
|
||||
(logCountRule {
|
||||
uid = "hh-archive-prune-failed";
|
||||
title = "archive prune failed";
|
||||
windowSeconds = 90000;
|
||||
expr = ''_time:25h _SYSTEMD_UNIT:="swarm-archive-prune.service" _msg:"archive prune failed" | stats count() as failures'';
|
||||
for = "0s";
|
||||
summary = "the archive's daily prune failed for an agent's repository, so its retention is not applied; the unit's journal names the agent";
|
||||
})
|
||||
# Logged once per detected crash, by `emit_crash_transitions`
|
||||
# in hive-c0re's crash_watch.rs (10s poll interval). Replaces
|
||||
# the old per-hive dashboard `agents_crashing` banner — see
|
||||
|
|
|
|||
|
|
@ -139,6 +139,14 @@ in
|
|||
&& deployCfg.bao.atticClientKeyFile != null
|
||||
);
|
||||
|
||||
# The backup archive, on the same terms: its units log in to the store as the
|
||||
# archive's own principal.
|
||||
config.services.hyperhive.deploy.archive.enable = lib.mkDefault (
|
||||
deployCfg.allSwarmServices
|
||||
&& deployCfg.bao.archiveClientCertFile != null
|
||||
&& deployCfg.bao.archiveClientKeyFile != null
|
||||
);
|
||||
|
||||
# The claude login minter, defaulted on the same terms as the review
|
||||
# classifier.
|
||||
config.services.hyperhive.deploy.claude-login.enable = lib.mkDefault (
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ let
|
|||
deployCfg.bao.claudeLoginCommonName
|
||||
deployCfg.bao.notifyRouterCommonName
|
||||
deployCfg.bao.atticCommonName
|
||||
deployCfg.bao.archiveCommonName
|
||||
deployCfg.bao.forgePublisherCommonName
|
||||
]
|
||||
# The per-hive subject, spelled out per hive rather than as the prefix.
|
||||
|
|
@ -117,6 +118,8 @@ let
|
|||
# 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 backup archive, listed whether or not any host runs one.
|
||||
swarmCfg.archive.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