Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/nix/module-eval/agent-forge-bao.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

224 lines
8.6 KiB
Nix

# `checks.module-eval-agent-forge-bao` — see ./lib.nix for the shared
# rationale (why this suite exists, naming convention, "evaluates
# not executes").
#
# The agent side of the swarm-minted forge token: ../agent-modules/forge-token.nix
# fetches it, and ../agent-modules/forge.nix's readers find it.
{
pkgs,
lib,
self,
nixosSystem,
}:
let
inherit
(import ./lib.nix {
inherit
pkgs
lib
self
nixosSystem
;
})
agentWith
runGroup
;
forgeUrl = "http://forge.t.local";
baoAddr = "https://bao.t.local:8200";
# A forge and a store: the fetch exists and every reader points at it.
agentForgeBao = agentWith {
services.hyperhive.agent.bao.addr = baoAddr;
services.hyperhive.agent.forge.url = forgeUrl;
services.hyperhive.agent.icon = pkgs.emptyFile;
};
# A forge and no store: the absence arm, and what makes the cases above able
# to fail.
agentForgeNoBao = agentWith {
services.hyperhive.agent.forge.url = forgeUrl;
services.hyperhive.agent.icon = pkgs.emptyFile;
};
fetchUnit = machine: machine.systemd.services.hive-agent-forge-token;
accountsUnit = machine: machine.systemd.services.hive-agent-forge-accounts;
tokenFile = machine: machine.services.hyperhive.agent.forge.tokenFile;
in
let
cases = [
{
# The whole switch is the store address, as for the queue credential.
name = "an agent with a store address fetches its forge token";
ok =
agentForgeBao.systemd.services ? hive-agent-forge-token
&& agentForgeBao.systemd.timers ? hive-agent-forge-token;
}
{
name = "an agent told no store address fetches no forge token";
ok =
!(agentForgeNoBao.systemd.services ? hive-agent-forge-token)
&& !(agentForgeNoBao.systemd.timers ? hive-agent-forge-token)
&& !(agentForgeNoBao.systemd.globalEnvironment ? HIVE_FORGE_TOKEN_FILE);
}
{
# The nix half of `swarm_secret_client::forge::agent_token_path` plus the
# mount. Spelled out: the Rust test pins `swarm/agents/atlas/forge-token`,
# and a drift between the two is a fetch that 404s forever and says
# "not minted yet".
name = "the fetch reads the agent's own forge-token path";
ok =
let
name = agentForgeBao.services.hyperhive.agent.user.name;
in
lib.hasInfix "secret/swarm/agents/${name}/forge-token" (fetchUnit agentForgeBao).script;
}
{
# One store identity per agent: the fetch presents the same certificate
# the identity check proves. Every `BAO_*` value is an address or a
# `%d/` path, never a value.
name = "the fetch presents the agent's own store identity, by path";
ok =
let
u = fetchUnit agentForgeBao;
e = u.environment;
in
builtins.elem "hive-agent-bao-cert" u.serviceConfig.LoadCredential
&& builtins.elem "hive-agent-bao-key" u.serviceConfig.LoadCredential
&& e.BAO_ADDR == baoAddr
&& e.BAO_CLIENT_CERT == "%d/hive-agent-bao-cert"
&& e.BAO_CLIENT_KEY == "%d/hive-agent-bao-key";
}
{
# The field is the secret. It goes to a file by redirect and is never
# echoed.
name = "the fetch writes the token by redirect and never echoes it";
ok =
let
s = (fetchUnit agentForgeBao).script;
in
lib.hasInfix "-field=value" s
&& lib.hasInfix "> ${lib.escapeShellArg "/run/hive-agent-forge-token/token.new"}" s
&& !(lib.hasInfix "echo \"$(bao" s)
&& !(lib.hasInfix "echo $(bao" s);
}
{
# A timer can only start an inactive unit, so the fetch must not stay
# active; the directory, and the token in it, has to outlive each run.
name = "the fetch can be re-run by its timer without losing the token";
ok =
let
c = (fetchUnit agentForgeBao).serviceConfig;
in
!c.RemainAfterExit
&& c.RuntimeDirectory == "hive-agent-forge-token"
&& c.RuntimeDirectoryPreserve == "yes"
&& c.UMask == "0377";
}
{
# Rename in only on a change: a reader never sees half a token, and the
# avatar watcher does not fire on every timer tick.
name = "the fetch swaps the token in by rename, only when it changed";
ok =
let
s = (fetchUnit agentForgeBao).script;
in
lib.hasInfix "cmp -s" s && lib.hasInfix "mv -f" s;
}
{
# Every unit and the bash-task runner find the token through this. A
# path, never the value.
name = "the fetched token's path is published to every unit";
ok =
agentForgeBao.systemd.globalEnvironment.HIVE_FORGE_TOKEN_FILE == tokenFile agentForgeBao
&& tokenFile agentForgeBao == "/run/hive-agent-forge-token/token";
}
{
# The avatar sync has to re-fire when the swarm's token lands, which is
# the file the fetch writes, not the state-dir file nothing writes any
# more.
name = "the avatar watcher follows the fetched token";
ok =
agentForgeBao.systemd.paths.forge-avatar-sync.pathConfig.PathChanged == tokenFile agentForgeBao
&& builtins.elem "hive-agent-forge-token.service" agentForgeBao.systemd.services.forge-avatar-sync.after;
}
{
# Both readers take the fetched token first and fall back to the state
# file, which is still the only copy for an agent with no store identity.
name = "the avatar sync reads the fetched token before the state file";
ok =
let
s = agentForgeBao.systemd.services.forge-avatar-sync.script;
name = agentForgeBao.services.hyperhive.agent.user.name;
fetched = lib.escapeShellArg (tokenFile agentForgeBao);
state = lib.escapeShellArg "/agents/${name}/state/forge-token";
in
lib.hasInfix "for f in ${fetched} ${state}; do" s;
}
{
# tea-login copied the token into ~/.config/tea/config.yml, which
# docs/swarm/credentials.md forbids for a store secret. Gone, with the
# package it configured.
name = "no tea-login unit and no tea package";
ok =
!(agentForgeBao.systemd.services ? tea-login)
&& !(agentForgeNoBao.systemd.services ? tea-login)
&& !(builtins.elem pkgs.tea agentForgeBao.environment.systemPackages);
}
{
name = "an agent with a store address fetches its external forge accounts, and one without does not";
ok =
agentForgeBao.systemd.services ? hive-agent-forge-accounts
&& agentForgeBao.systemd.timers ? hive-agent-forge-accounts
&& !(agentForgeNoBao.systemd.services ? hive-agent-forge-accounts)
&& !(agentForgeNoBao.systemd.timers ? hive-agent-forge-accounts);
}
{
# The nix half of `swarm_secret_client::forge::{accounts_dir,account_path}`,
# and the two file names `hive-forge -f` reads.
name = "the account fetch lists the agent's own accounts and reads each into hive-forge's files";
ok =
let
name = agentForgeBao.services.hyperhive.agent.user.name;
s = (accountsUnit agentForgeBao).script;
in
lib.hasInfix "bao kv list -format=json secret/swarm/agents/${name}/forge >" s
&& !(lib.hasInfix "/index/" s)
&& lib.hasInfix "path=\"secret/swarm/agents/${name}/forge/$label\"" s
&& lib.hasInfix "/agents/${name}/state" s
&& lib.hasInfix "/forge-$label-token" s
&& lib.hasInfix "/forge-$label.json" s
&& lib.hasInfix "{base_url: (.data.data.url | strings)}" s;
}
{
# The agent user owns its state dir (./user.nix), and the files keep
# the `0600` they have always had.
name = "the account fetch runs as the agent, with its own store identity";
ok =
let
u = accountsUnit agentForgeBao;
name = agentForgeBao.services.hyperhive.agent.user.name;
in
u.serviceConfig.User == name
&& u.serviceConfig.UMask == "0077"
&& builtins.elem "hive-agent-bao-cert" u.serviceConfig.LoadCredential
&& u.environment.BAO_CLIENT_CERT == "%d/hive-agent-bao-cert";
}
{
# Files a hive wrote keep working until the operator re-links them.
name = "the account fetch never deletes a state-dir file it did not stage";
ok =
let
s = (accountsUnit agentForgeBao).script;
in
!(lib.hasInfix "rm -f \"$token\"" s)
&& !(lib.hasInfix "rm -f \"$sidecar\"" s)
&& !(lib.hasInfix "forge-*" s);
}
{
name = "the account fetch re-runs every two minutes";
ok = agentForgeBao.systemd.timers.hive-agent-forge-accounts.timerConfig.OnUnitInactiveSec == "2min";
}
];
in
runGroup "agent-forge-bao" cases