swarm-controller re-issues an agent's store leaf at half its validity, but the running container kept presenting the copy its hive delivered at start, so a re-issued leaf reached the agent only on its next restart. hive-agent-bao-identity now seeds a live copy of the pair in its own runtime directory from the delivered credentials, and re-runs every 10 minutes: it logs in with the live leaf, reads its own swarm/agents/<agent>/bao-mtls (a read render_agent already grants), and when the leaf changed, logs in with the new pair and swaps it in only if the store accepts it. Every unit in the container that logs in to the store presents the live pair: hive-agent (queue-identity.nix), hive-matrix-daemon, hive-subagent-daemon, hive-agent-forge-token, hive-agent-forge-accounts and hive-agent-github-token. They keep loading the server CA as a credential. The long-running daemons are ordered after the refresher so the live pair exists when they start. It renews only while the held leaf is still valid: the login that reads the new leaf is made with the current one. Refs #5017
185 lines
7 KiB
Nix
185 lines
7 KiB
Nix
# This agent's accounts on external forges, fetched from the swarm secret store
|
||
# by the agent itself, into the files `hive-forge -f <label>` reads.
|
||
#
|
||
# An operator links an account in the swarm UI; `swarm-controller` stores it at
|
||
# `swarm/agents/<agent>/forge/<label>` (`swarm_secret_client::forge`). The
|
||
# agent's grant lists and reads its own subtree, so this unit lists
|
||
# `swarm/agents/<agent>/forge/`, reads each account, and writes
|
||
# `<state>/forge-<label>-token` and `<state>/forge-<label>.json`
|
||
# (`{"base_url":…}`), the two files `hive-forge`'s `resolve_credentials` reads.
|
||
#
|
||
# It never deletes. A `forge-<label>` pair for a label not listed is left
|
||
# as it is, whoever wrote it, and so is the pair of a label whose read fails.
|
||
# A file is replaced by rename, and only when its bytes changed.
|
||
{
|
||
pkgs,
|
||
lib,
|
||
config,
|
||
...
|
||
}:
|
||
let
|
||
cfg = config.services.hyperhive.agent.bao;
|
||
|
||
agentName = config.services.hyperhive.agent.user.name;
|
||
stateDir = "/agents/${agentName}/state";
|
||
|
||
# The server CA id ./bao.nix loads.
|
||
serverCaCredential = "hive-agent-bao-server-ca";
|
||
|
||
unitName = "hive-agent-forge-accounts";
|
||
|
||
# The nix half of `swarm_secret_client::forge::accounts_dir` plus
|
||
# `path::MOUNT`.
|
||
accountsDir = "secret/swarm/agents/${agentName}/forge";
|
||
|
||
runtimeDir = unitName;
|
||
# The store's whole answer for one account, token included: kept in the
|
||
# unit's own `0700` directory, never in the state dir.
|
||
rawFile = "/run/${runtimeDir}/account.json";
|
||
listFile = "/run/${runtimeDir}/list.json";
|
||
errFile = "/run/${runtimeDir}/bao.err";
|
||
|
||
configured = cfg.addr != null;
|
||
|
||
storeRetry = import ../host-modules/lib/store-retry.nix { };
|
||
in
|
||
{
|
||
config = lib.mkIf configured {
|
||
systemd.services.${unitName} = {
|
||
description = "fetch this agent's external forge accounts from the secret store";
|
||
after = [
|
||
"network.target"
|
||
"hive-agent-bao-identity.service"
|
||
];
|
||
wantedBy = [ "multi-user.target" ];
|
||
path = [
|
||
pkgs.openbao
|
||
pkgs.coreutils
|
||
pkgs.diffutils
|
||
pkgs.jq
|
||
];
|
||
# ../host-modules/lib/store-retry.nix.
|
||
inherit (storeRetry) startLimitBurst startLimitIntervalSec;
|
||
serviceConfig = storeRetry.serviceConfig // {
|
||
Type = "oneshot";
|
||
# Not `RemainAfterExit`, so the timer below can start it again.
|
||
RemainAfterExit = false;
|
||
TimeoutStartSec = 60;
|
||
User = agentName;
|
||
Group = agentName;
|
||
RuntimeDirectory = runtimeDir;
|
||
RuntimeDirectoryMode = "0700";
|
||
# `0600`, the mode the files in the state dir have always had.
|
||
UMask = "0077";
|
||
LoadCredential = [ serverCaCredential ];
|
||
};
|
||
environment = {
|
||
BAO_ADDR = cfg.addr;
|
||
BAO_CLIENT_CERT = cfg.clientCertFile;
|
||
BAO_CLIENT_KEY = cfg.clientKeyFile;
|
||
};
|
||
script = ''
|
||
set -euo pipefail
|
||
|
||
# No identity delivered: ./bao.nix's check reports that.
|
||
for f in "$BAO_CLIENT_CERT" "$BAO_CLIENT_KEY"; do
|
||
if [ ! -s "$f" ]; then
|
||
echo "this agent has no store identity, so it cannot fetch its external forge accounts." >&2
|
||
exit 0
|
||
fi
|
||
done
|
||
|
||
if [ -s "$CREDENTIALS_DIRECTORY/${serverCaCredential}" ]; then
|
||
export BAO_CACERT="$CREDENTIALS_DIRECTORY/${serverCaCredential}"
|
||
fi
|
||
|
||
err=${lib.escapeShellArg errFile}
|
||
raw=${lib.escapeShellArg rawFile}
|
||
list=${lib.escapeShellArg listFile}
|
||
trap 'rm -f "$err" "$raw" "$list"' EXIT
|
||
|
||
if ! BAO_TOKEN="$(bao login -method=cert -token-only 2>"$err")"; then
|
||
echo "the swarm secret store at $BAO_ADDR did not accept this agent's certificate login:" >&2
|
||
if [ -s "$err" ]; then cat "$err" >&2; fi
|
||
exit 1
|
||
fi
|
||
export BAO_TOKEN
|
||
|
||
# An empty directory is a 404, which `bao` answers with `{}` on stdout
|
||
# and nothing on stderr; a denial or an unreachable store prints
|
||
# nothing on stdout.
|
||
if ! bao kv list -format=json ${lib.escapeShellArg accountsDir} >"$list" 2>"$err"; then
|
||
if [ ! -s "$err" ] && jq -e '. == {}' "$list" >/dev/null 2>&1; then
|
||
echo "no external forge accounts are linked to this agent (nothing under ${accountsDir})."
|
||
exit 0
|
||
fi
|
||
echo "could not list ${accountsDir}:" >&2
|
||
if [ -s "$err" ]; then cat "$err" >&2; fi
|
||
exit 1
|
||
fi
|
||
if ! jq -e 'arrays' "$list" >/dev/null; then
|
||
echo "listing ${accountsDir} returned no array of names." >&2
|
||
exit 1
|
||
fi
|
||
|
||
# Moves $1 over $2 and succeeds when the bytes differ; drops $1 otherwise.
|
||
replace() {
|
||
if cmp -s "$1" "$2"; then
|
||
rm -f "$1"
|
||
return 1
|
||
fi
|
||
mv -f "$1" "$2"
|
||
}
|
||
|
||
# One account that cannot be read is logged and skipped. It does not
|
||
# stop the others, and failing the unit would only restart it into the
|
||
# same answer.
|
||
while IFS= read -r label; do
|
||
# The label becomes a file name, and `hive-forge -f` names only these.
|
||
# A key ending in `/` is a directory below this one, not an account.
|
||
if [[ ! "$label" =~ ^[a-z0-9-]+$ ]]; then
|
||
echo "skipping listed key $(printf '%q' "$label"): not a label hive-forge -f can name." >&2
|
||
continue
|
||
fi
|
||
path="${accountsDir}/$label"
|
||
if ! bao kv get -format=json "$path" >"$raw" 2>"$err"; then
|
||
echo "could not read $path; forge-$label files left as they are:" >&2
|
||
if [ -s "$err" ]; then cat "$err" >&2; fi
|
||
continue
|
||
fi
|
||
|
||
# ⚠️ The token goes from the store's answer straight into a file;
|
||
# it is never in a variable or an argument.
|
||
token=${lib.escapeShellArg stateDir}/forge-$label-token
|
||
sidecar=${lib.escapeShellArg stateDir}/forge-$label.json
|
||
staged_token=${lib.escapeShellArg stateDir}/.forge-$label-token.new
|
||
staged_sidecar=${lib.escapeShellArg stateDir}/.forge-$label.json.new
|
||
rm -f "$staged_token" "$staged_sidecar"
|
||
if ! jq -er '.data.data.value | strings' "$raw" >"$staged_token" \
|
||
|| ! jq -cje '{base_url: (.data.data.url | strings)}' "$raw" >"$staged_sidecar"; then
|
||
echo "$path holds no string value and url; forge-$label files left as they are." >&2
|
||
rm -f "$staged_token" "$staged_sidecar"
|
||
continue
|
||
fi
|
||
|
||
changed=
|
||
replace "$staged_token" "$token" && changed=1
|
||
replace "$staged_sidecar" "$sidecar" && changed=1
|
||
if [ -n "$changed" ]; then
|
||
echo "fetched external forge account $label from $path."
|
||
fi
|
||
done < <(jq -r '.[]' "$list")
|
||
'';
|
||
};
|
||
|
||
# The same cadence as hive-matrix-daemon's re-listing of its own accounts.
|
||
systemd.timers.${unitName} = {
|
||
description = "re-fetch this agent's external forge accounts from the secret store";
|
||
wantedBy = [ "timers.target" ];
|
||
timerConfig = {
|
||
OnUnitInactiveSec = "2min";
|
||
RandomizedDelaySec = "20s";
|
||
};
|
||
};
|
||
};
|
||
}
|