Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/nix/agent-modules/forge-accounts.nix
atlas 2c7e586f47 forge: external forge accounts live in swarm bao; the agent fetches them itself
An operator now links an agent's external forge account (label, base URL,
token) in the swarm UI. swarm-controller stores it at
swarm/agents/<agent>/forge/<label>. There is no index: the store's
listing of the agent's forge/ directory is the set of accounts.

In the agent, hive-agent-forge-accounts (oneshot + 2-minute timer, as
the agent user, under its own store certificate) lists
swarm/agents/<agent>/forge/ with the `list` #4866 grants an agent on its
own metadata subtree, reads each account, and writes
<state>/forge-<label>-token and forge-<label>.json in the names and shape
hive-forge -f already reads. An empty listing (a 404, which `bao kv list
-format=json` answers with `{}` and an empty stderr) is zero accounts; a
denial or an unreachable store fails the unit. It never deletes: files
for labels not listed, including ones the hive wrote, stay as they are.

Removed: the dashboard FORGES tab (credentials.js/html section and its
CSS), hive-c0re's extra_forges.rs and its routes, priv_client's
extra-forge calls, and hive-priv's WriteAgentExtraForgeAccount /
DeleteAgentExtraForgeAccount with their helpers. The GITHUB tab and
WriteAgentGithubToken stay.

Also: persistence.md's matrix avatar note names the exit-75 restart on a
changed account listing, not the dashboard, as what brings a linked
account up.

Refs #4348
2026-10-01 18:05:33 +02:00

191 lines
7.2 KiB
Nix
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 same three ids ./bao.nix and ./forge-token.nix load.
certCredential = "hive-agent-bao-cert";
keyCredential = "hive-agent-bao-key";
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 = [
certCredential
keyCredential
serverCaCredential
];
};
environment = {
BAO_ADDR = cfg.addr;
BAO_CLIENT_CERT = "%d/${certCredential}";
BAO_CLIENT_KEY = "%d/${keyCredential}";
};
script = ''
set -euo pipefail
# No identity delivered: ./bao.nix's check reports that.
for id in ${lib.escapeShellArg certCredential} ${lib.escapeShellArg keyCredential}; do
if [ ! -s "$CREDENTIALS_DIRECTORY/$id" ]; 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";
};
};
};
}