Watch
0
0
Fork
You've already forked hyperhive
0

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:
atlas 2026-11-10 07:49:49 +01:00
commit 306aae0378
2 changed files with 2214 additions and 5 deletions

View 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"
''

View 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"
'';
}