fetch the agent's backend env file from the store, seeded from the existing file
the agent's own root unit reads swarm/agents/<agent>/backend-env into /run and creates it from backendEnvironmentFile with -cas=0 when the store holds nothing. the shared agent policy gains create (not update) on that one leaf. the harness and the subagent daemon load the old file first and the fetched one after it.
This commit is contained in:
parent
5486fc0333
commit
75e9e205e3
1 changed files with 933 additions and 31 deletions
205
nix/agent-modules/backend-env.nix
Normal file
205
nix/agent-modules/backend-env.nix
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
# This agent's backend environment (`ANTHROPIC_API_KEY`, `ANTHROPIC_BASE_URL`,
|
||||
# an ACP provider key), fetched from the swarm secret store by the agent
|
||||
# itself into the file the harness and the subagent daemon load as
|
||||
# `EnvironmentFile=`.
|
||||
#
|
||||
# The store holds the file's text at `swarm/agents/<agent>/backend-env`, field
|
||||
# `env` (`swarm_secret_client::backend_env`). Each run does one of three things:
|
||||
#
|
||||
# - the store has a value: it is written to `/run/hive-agent-backend-env/env`;
|
||||
# - the store has none and `backendEnvironmentFile` is a non-empty file: the
|
||||
# file's exact bytes are created at the leaf with `-cas=0`, then read back
|
||||
# and written the same way. A run that loses the race to another writer, or
|
||||
# whose create the store's policy refuses, uses whatever the read-back finds;
|
||||
# - neither: nothing is written, which is an agent with no backend file.
|
||||
#
|
||||
# It never rotates a value and never touches `backendEnvironmentFile`. That
|
||||
# file stays an `EnvironmentFile=` of its own, listed before the fetched one:
|
||||
# systemd lets a later file override an earlier one, so the store's value wins
|
||||
# when there is one, and the file carries the agent when the store is down.
|
||||
#
|
||||
# The unit runs as root because the file is root-owned `0600` where an operator
|
||||
# placed it. Root can still read the agent's `0400` store identity.
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
let
|
||||
agentCfg = config.services.hyperhive.agent;
|
||||
cfg = agentCfg.bao;
|
||||
|
||||
agentName = agentCfg.user.name;
|
||||
|
||||
# The server CA id ./bao.nix loads.
|
||||
serverCaCredential = "hive-agent-bao-server-ca";
|
||||
|
||||
unitName = "hive-agent-backend-env";
|
||||
|
||||
# The nix half of `swarm_secret_client::backend_env::env_path` plus
|
||||
# `path::MOUNT`.
|
||||
leaf = "secret/swarm/agents/${agentName}/backend-env";
|
||||
|
||||
runtimeDir = unitName;
|
||||
envFile = "/run/${runtimeDir}/env";
|
||||
stagedFile = "/run/${runtimeDir}/env.new";
|
||||
errFile = "/run/${runtimeDir}/bao.err";
|
||||
|
||||
# An OAuth claude agent has no backend credentials to fetch.
|
||||
fetches = cfg.addr != null && (agentCfg.useApiKey || agentCfg.runtime == "acp");
|
||||
|
||||
earlier = agentCfg.backendEnvironmentFile;
|
||||
|
||||
storeRetry = import ../host-modules/lib/store-retry.nix { };
|
||||
in
|
||||
{
|
||||
options.services.hyperhive.agent.backendEnvironment.environmentFiles = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
readOnly = true;
|
||||
default = lib.optional (earlier != null) "-${earlier}" ++ lib.optional fetches "-${envFile}";
|
||||
description = ''
|
||||
The `EnvironmentFile=` entries that carry this agent's backend
|
||||
environment, in load order: `backendEnvironmentFile` when set, then the
|
||||
file `${unitName}.service` keeps when the agent has a store and uses an
|
||||
API key. Read-only: the harness and the subagent daemon take it as it is.
|
||||
'';
|
||||
};
|
||||
|
||||
config = lib.mkIf fetches {
|
||||
systemd.services.${unitName} = {
|
||||
description = "fetch this agent's backend environment from the secret store";
|
||||
after = [
|
||||
"network.target"
|
||||
"hive-agent-bao-identity.service"
|
||||
];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
path = [
|
||||
pkgs.openbao
|
||||
pkgs.coreutils
|
||||
pkgs.diffutils
|
||||
pkgs.gnugrep
|
||||
];
|
||||
# ../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.
|
||||
# `RuntimeDirectoryPreserve` keeps the file between runs and after the
|
||||
# last one: the consumers read it at their own start.
|
||||
RemainAfterExit = false;
|
||||
TimeoutStartSec = 60;
|
||||
RuntimeDirectory = runtimeDir;
|
||||
RuntimeDirectoryMode = "0700";
|
||||
RuntimeDirectoryPreserve = "yes";
|
||||
# `0600`, the mode of the file an operator placed.
|
||||
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 backend environment." >&2
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -s "$CREDENTIALS_DIRECTORY/${serverCaCredential}" ]; then
|
||||
export BAO_CACERT="$CREDENTIALS_DIRECTORY/${serverCaCredential}"
|
||||
fi
|
||||
|
||||
err=${lib.escapeShellArg errFile}
|
||||
staged=${lib.escapeShellArg stagedFile}
|
||||
live=${lib.escapeShellArg envFile}
|
||||
earlier=${lib.escapeShellArg (if earlier == null then "" else earlier)}
|
||||
trap 'rm -f "$err" "$staged"' EXIT
|
||||
|
||||
# Bounded, so a store that drops packets cannot hold the unit past its
|
||||
# start timeout.
|
||||
store() { timeout 15 bao "$@"; }
|
||||
|
||||
if ! BAO_TOKEN="$(store 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
|
||||
|
||||
# Sets READ to have, missing or unusable, and leaves the value in
|
||||
# $staged. `kv get` exits 2 with "No value found at <path>" for a leaf
|
||||
# nobody wrote, and 2 again for a refused read, so the message tells
|
||||
# them apart. The value goes from the store's answer into a file and
|
||||
# is never in a variable or an argument.
|
||||
read_leaf() {
|
||||
rm -f "$staged"
|
||||
if store kv get -field=env ${lib.escapeShellArg leaf} >"$staged" 2>"$err"; then
|
||||
if [ -s "$staged" ]; then READ=have; else READ=unusable; fi
|
||||
elif grep -q '^No value found at ' "$err"; then
|
||||
READ=missing
|
||||
elif grep -q '^Field "env" not present' "$err"; then
|
||||
READ=unusable
|
||||
else
|
||||
echo "could not read ${leaf}:" >&2
|
||||
cat "$err" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
read_leaf
|
||||
if [ "$READ" = missing ]; then
|
||||
if [ -n "$earlier" ] && [ -s "$earlier" ]; then
|
||||
echo "${leaf} holds nothing; creating it from $earlier."
|
||||
if ! store kv put -cas=0 ${lib.escapeShellArg leaf} env=@"$earlier" >/dev/null 2>"$err"; then
|
||||
echo "could not create ${leaf}; reading it back in case another run did:" >&2
|
||||
cat "$err" >&2
|
||||
fi
|
||||
read_leaf
|
||||
else
|
||||
echo "${leaf} holds nothing and there is no file to create it from."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
if [ "$READ" != have ]; then
|
||||
echo "${leaf} holds no usable env; the file in place, if any, is used." >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if cmp -s "$staged" "$live"; then
|
||||
echo "this agent's backend environment at ${leaf} is unchanged."
|
||||
exit 0
|
||||
fi
|
||||
mv -f "$staged" "$live"
|
||||
echo "fetched this agent's backend environment from ${leaf}."
|
||||
'';
|
||||
};
|
||||
|
||||
# The same cadence as ./github-token.nix.
|
||||
systemd.timers.${unitName} = {
|
||||
description = "re-fetch this agent's backend environment from the secret store";
|
||||
wantedBy = [ "timers.target" ];
|
||||
timerConfig = {
|
||||
OnUnitInactiveSec = "2min";
|
||||
RandomizedDelaySec = "20s";
|
||||
};
|
||||
};
|
||||
|
||||
# `wants`, not `requires`: a store outage must not stop the harness, whose
|
||||
# `backendEnvironmentFile` still loads. `after` waits for one attempt.
|
||||
systemd.services.hive-agent = {
|
||||
wants = [ "${unitName}.service" ];
|
||||
after = [ "${unitName}.service" ];
|
||||
};
|
||||
systemd.services.hive-subagent-daemon = {
|
||||
wants = [ "${unitName}.service" ];
|
||||
after = [ "${unitName}.service" ];
|
||||
};
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue