405 lines
16 KiB
Nix
405 lines
16 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. The same fixtures
|
|
# cover ../agent-modules/egress-proxy.nix, the agent's proxy credential and the
|
|
# environment built from 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;
|
|
# A second `EnvironmentFile=` on the harness, which the proxy file must join.
|
|
services.hyperhive.agent.backendEnvironmentFile = "/var/lib/t/backend.env";
|
|
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;
|
|
proxyUnit = machine: machine.systemd.services.hive-agent-proxy-credential;
|
|
accountsUnit = machine: machine.systemd.services.hive-agent-forge-accounts;
|
|
tokenFile = machine: machine.services.hyperhive.agent.forge.tokenFile;
|
|
|
|
proxyEnvEntry = "-/run/hive-agent-proxy-credential/proxy.env";
|
|
# The units that make outbound calls and so load the proxy file. This fixture
|
|
# has an icon, a forge and GitHub on, and no matrix accounts.
|
|
proxyConsumers = [
|
|
"hive-agent"
|
|
"hive-bash-daemon"
|
|
"hive-subagent-daemon"
|
|
"hive-mcp-http"
|
|
"hive-forge-notify"
|
|
"hive-github-notify"
|
|
"forge-avatar-sync"
|
|
];
|
|
in
|
|
let
|
|
cases = [
|
|
{
|
|
# ../agent-modules/hive-ca.nix. Presence arm first (the unit, the
|
|
# timer, the trust variables, and every consumer ordered after the
|
|
# unit and requiring it), then the absence arm for an agent with no
|
|
# store, which has no root to fetch.
|
|
name = "an agent with a store address fetches the swarm root and its consumers wait for it";
|
|
ok =
|
|
let
|
|
svc = agentForgeBao.systemd.services;
|
|
unit = "hive-agent-trust-bundle.service";
|
|
bundle = "/run/hive-agent-trust-bundle/bundle.pem";
|
|
consumers = [
|
|
"hive-agent"
|
|
"hive-bash-daemon"
|
|
"hive-subagent-daemon"
|
|
"hive-mcp-http"
|
|
"hive-forge-notify"
|
|
"forge-avatar-sync"
|
|
];
|
|
in
|
|
svc ? hive-agent-trust-bundle
|
|
&& agentForgeBao.systemd.timers ? hive-agent-trust-bundle
|
|
&& lib.hasInfix "/v1/pki/ca/pem" svc.hive-agent-trust-bundle.script
|
|
&& agentForgeBao.systemd.globalEnvironment.SSL_CERT_FILE == bundle
|
|
&&
|
|
agentForgeBao.systemd.globalEnvironment.NODE_EXTRA_CA_CERTS
|
|
== "/run/hive-agent-trust-bundle/root.pem"
|
|
&& lib.all (c: lib.elem unit svc.${c}.after && lib.elem unit svc.${c}.requires) consumers
|
|
&& !(agentForgeNoBao.systemd.services ? hive-agent-trust-bundle)
|
|
&& !(agentForgeNoBao.systemd.globalEnvironment ? SSL_CERT_FILE)
|
|
&& !(lib.elem unit (agentForgeNoBao.systemd.services.hive-agent.after or [ ]));
|
|
}
|
|
{
|
|
# ../agent-modules/hive-ca.nix. The fetch retries inside the start job
|
|
# and the job's timeout outlasts the retry budget, so a consumer that
|
|
# `Requires=` the unit waits instead of failing with `dependency`.
|
|
name = "the swarm root fetch retries inside its start job and the timeout outlasts the retries";
|
|
ok =
|
|
let
|
|
unit = agentForgeBao.systemd.services.hive-agent-trust-bundle;
|
|
budget = 840;
|
|
in
|
|
lib.hasInfix "retry_in_job ${toString budget} fetch_root" unit.script
|
|
&& unit.serviceConfig.TimeoutStartSec > budget;
|
|
}
|
|
{
|
|
# 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 keeps current. Every `BAO_*` value is an address or
|
|
# a 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
|
|
e.BAO_ADDR == baoAddr
|
|
&& e.BAO_CLIENT_CERT == "/run/hive-agent-bao-identity/current/cert"
|
|
&& e.BAO_CLIENT_KEY == "/run/hive-agent-bao-identity/current/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";
|
|
}
|
|
{
|
|
# 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"
|
|
&& u.environment.BAO_CLIENT_CERT == "/run/hive-agent-bao-identity/current/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";
|
|
}
|
|
{
|
|
# ../agent-modules/egress-proxy.nix. Presence arm: the unit and its timer
|
|
# exist, and the agent is told nothing until a credential is fetched.
|
|
name = "an agent with a store address fetches its egress-proxy credential, and one without does not";
|
|
ok =
|
|
agentForgeBao.systemd.services ? hive-agent-proxy-credential
|
|
&& agentForgeBao.systemd.timers ? hive-agent-proxy-credential
|
|
&& !(agentForgeNoBao.systemd.services ? hive-agent-proxy-credential)
|
|
&& !(agentForgeNoBao.systemd.timers ? hive-agent-proxy-credential);
|
|
}
|
|
{
|
|
# The nix half of `swarm_secret_client::proxy::agent_credential_path`
|
|
# plus the mount.
|
|
name = "the proxy fetch reads the agent's own proxy-credential path, by redirect, and never echoes it";
|
|
ok =
|
|
let
|
|
name = agentForgeBao.services.hyperhive.agent.user.name;
|
|
s = (proxyUnit agentForgeBao).script;
|
|
in
|
|
lib.hasInfix "secret/swarm/agents/${name}/proxy-credential" s
|
|
&& lib.hasInfix "-field=value" s
|
|
&& lib.hasInfix "> ${lib.escapeShellArg "/run/hive-agent-proxy-credential/secret.new"}" s
|
|
&& !(lib.hasInfix "echo \"$(bao" s)
|
|
&& !(lib.hasInfix "echo $(bao" s)
|
|
&& !(lib.hasInfix "echo \"$secret" s)
|
|
&& !(lib.hasInfix "printf '%s\\n' \"$secret" s);
|
|
}
|
|
{
|
|
name = "the proxy fetch runs as the agent with its own store identity and keeps its file between runs";
|
|
ok =
|
|
let
|
|
u = proxyUnit agentForgeBao;
|
|
c = u.serviceConfig;
|
|
in
|
|
c.User == agentForgeBao.services.hyperhive.agent.user.name
|
|
&& c.UMask == "0177"
|
|
&& !c.RemainAfterExit
|
|
&& c.RuntimeDirectory == "hive-agent-proxy-credential"
|
|
&& c.RuntimeDirectoryMode == "0700"
|
|
&& c.RuntimeDirectoryPreserve == "yes"
|
|
&& u.environment.BAO_ADDR == baoAddr
|
|
&& u.environment.BAO_CLIENT_CERT == "/run/hive-agent-bao-identity/current/cert";
|
|
}
|
|
{
|
|
# Presence: every outbound unit loads the file, and the harness keeps the
|
|
# entry it already had. Absence: an agent with no store loads none.
|
|
name = "every outbound unit loads the proxy file optionally, beside its other environment files";
|
|
ok =
|
|
let
|
|
svc = agentForgeBao.systemd.services;
|
|
files = c: svc.${c}.serviceConfig.EnvironmentFile or [ ];
|
|
in
|
|
lib.all (
|
|
c:
|
|
lib.elem proxyEnvEntry (files c)
|
|
&& lib.elem "hive-agent-proxy-credential.service" svc.${c}.after
|
|
&& lib.elem "hive-agent-proxy-credential.service" svc.${c}.wants
|
|
&& !(lib.elem "hive-agent-proxy-credential.service" (svc.${c}.requires or [ ]))
|
|
) proxyConsumers
|
|
&& lib.elem "-/var/lib/t/backend.env" (files "hive-agent")
|
|
&& !(lib.any
|
|
(
|
|
c:
|
|
lib.elem proxyEnvEntry (agentForgeNoBao.systemd.services.${c}.serviceConfig.EnvironmentFile or [ ])
|
|
)
|
|
[
|
|
"hive-agent"
|
|
"hive-bash-daemon"
|
|
"hive-forge-notify"
|
|
]
|
|
);
|
|
}
|
|
{
|
|
# The credential is in a file only the fetch writes. Nothing static (a
|
|
# unit environment, the global one, the login shells) carries a proxy
|
|
# variable, so an agent without a fetched credential goes direct.
|
|
name = "no proxy variable is set where the credential is not";
|
|
ok =
|
|
let
|
|
names = [
|
|
"HTTPS_PROXY"
|
|
"HTTP_PROXY"
|
|
"https_proxy"
|
|
"http_proxy"
|
|
"NO_PROXY"
|
|
"no_proxy"
|
|
"NODE_USE_ENV_PROXY"
|
|
];
|
|
anywhere =
|
|
m: n:
|
|
m.systemd.globalEnvironment ? ${n}
|
|
|| m.environment.variables ? ${n}
|
|
|| lib.any (s: s.environment ? ${n}) (lib.attrValues m.systemd.services);
|
|
in
|
|
!(lib.any (anywhere agentForgeBao) names) && !(lib.any (anywhere agentForgeNoBao) names);
|
|
}
|
|
{
|
|
# The hive's facts are the only source of the proxy address and of the
|
|
# hosts that skip it, and they are read before the store is asked: a hive
|
|
# without them gets no login and no file. The loopback names stay local.
|
|
name = "the proxy file takes the proxy and the direct hosts from the hive's facts, before it asks the store";
|
|
ok =
|
|
let
|
|
s = (proxyUnit agentForgeBao).script;
|
|
at = needle: lib.stringLength (lib.head (lib.splitString needle s));
|
|
in
|
|
lib.hasInfix "HIVE_SQUID_PROXY) proxy=$value" s
|
|
&& lib.hasInfix "HIVE_EGRESS_NO_PROXY) direct=$value" s
|
|
&& lib.hasInfix "no_proxy=localhost,127.0.0.1,::1,$direct" s
|
|
&& lib.hasInfix "/etc/hyperhive-hive-facts" s
|
|
&& at "HIVE_SQUID_PROXY)" < at "bao login"
|
|
&& !(lib.hasInfix "hyperhive-bridge-dns" s);
|
|
}
|
|
{
|
|
name = "the proxy file names every variable the clients read, and the credential only in the URL's userinfo";
|
|
ok =
|
|
let
|
|
s = (proxyUnit agentForgeBao).script;
|
|
name = agentForgeBao.services.hyperhive.agent.user.name;
|
|
in
|
|
lib.all (v: lib.hasInfix "printf '${v}=%s\\n'" s) [
|
|
"HTTPS_PROXY"
|
|
"HTTP_PROXY"
|
|
"https_proxy"
|
|
"http_proxy"
|
|
"NO_PROXY"
|
|
"no_proxy"
|
|
]
|
|
&& lib.hasInfix "NODE_USE_ENV_PROXY=1" s
|
|
&& lib.hasInfix "url=\"http://${name}:$secret@$proxy\"" s;
|
|
}
|
|
];
|
|
in
|
|
runGroup "agent-forge-bao" cases
|