Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/nix/module-eval/agent-queue-bao.nix
atlas ccb5bd3b38 hive-agent: read the per-agent queue secret from bao in process
The harness now reads swarm/agents/<agent>/queue from the store itself,
under the agent's own store certificate, and holds it in memory only.
It reads once before the first connect and again on every reconnect
attempt (async-nats `ConnectOptions::with_auth_callback`), so an agent
whose secret was re-minted reconnects with the new value instead of
being refused until the container restarts.

hive-agent-queue-credential.service, the /run file it wrote, and
HIVE_AGENT_QUEUE_AGENT_SECRET_FILE are gone; queue-identity.nix now
hands hive-agent.service the store address, its certificate paths and
the agent name.

A failed or empty read before the first connect still falls back to
the hive's shared client. Each read is bounded by a 10s timeout, and
retries wait out the existing reconnect backoff (500ms doubling, capped
at 60s).

Closes #4783
2026-09-29 10:18:07 +02:00

235 lines
9.7 KiB
Nix

# `checks.module-eval-agent-queue-bao` — see ./lib.nix for the shared
# rationale (why this suite exists, naming convention, "evaluates
# not executes").
{
pkgs,
lib,
self,
nixosSystem,
}:
let
inherit
(import ./lib.nix {
inherit
pkgs
lib
self
nixosSystem
;
})
agent
agentWith
runGroup
agentHarness
;
# The agent side of the swarm queue. Both coordinates set is the only state
# in which the harness unit declares a credential at all, so the pair and
# the empty fixture beside it are the two arms worth having.
agentQueue = agent {
queue.natsUrl = "nats://10.42.0.1:4222";
queue.tokenEndpoint = "https://auth.t.local/api/oidc/token";
};
agentNoQueue = agent { };
# The agent side of the swarm secret store. The address is the whole switch —
# it is both what generates the login check and what that check points at —
# so it and the empty fixture beside it are the two arms worth having.
agentBao = agentWith { services.hyperhive.agent.bao.addr = "https://bao.t.local:8200"; };
agentNoBao = agentWith { };
# Both at once, for the property that only exists when both are configured:
# the store certificate never reaches a queue variable.
agentQueueBao = agentWith {
hyperhive.queue.natsUrl = "nats://10.42.0.1:4222";
hyperhive.queue.tokenEndpoint = "https://auth.t.local/api/oidc/token";
services.hyperhive.agent.bao.addr = "https://bao.t.local:8200";
};
agentBaoIdentity = machine: machine.systemd.services.hive-agent-bao-identity;
cases = [
{
# Both ids or neither: the secret authenticates nobody without the id it
# belongs to, and the harness refuses to treat one of the two as a queue.
name = "an agent with queue coordinates imports both halves of its credential";
ok =
let
c = (agentHarness agentQueue).serviceConfig.LoadCredential;
in
builtins.elem "hive-queue-agent-secret" c && builtins.elem "hive-queue-agent-client-id" c;
}
{
# `%d` and not a path under the state dir: the host file is `0600`
# root-owned, so the only copy this unprivileged unit can open is the
# one systemd puts in its own credentials directory.
name = "the harness reads its queue credential out of the credentials directory";
ok =
let
e = (agentHarness agentQueue).environment;
in
e.HIVE_AGENT_OIDC_CLIENT_SECRET_FILE == "%d/hive-queue-agent-secret"
&& e.HIVE_AGENT_OIDC_CLIENT_ID_FILE == "%d/hive-queue-agent-client-id";
}
{
# An agent built before its hive was handed the queue's address. It
# must declare nothing rather than name a credential that never
# arrives — and the harness then reports "no queue coordinates"
# instead of a half-set environment.
name = "an agent with no queue coordinates declares no credential";
ok =
let
u = agentHarness agentNoQueue;
in
!(u.serviceConfig ? LoadCredential)
&& !(u.environment ? HIVE_AGENT_OIDC_CLIENT_SECRET_FILE)
&& !(u.environment ? HIVE_AGENT_OIDC_CLIENT_ID_FILE);
}
{
# The three ids `hive_c0re::lifecycle::agent_identity` forwards under.
# Neither end can discover the other's spelling, and a mismatch is a
# credential that is simply not there — which this unit then reports as
# a hive that delivered nothing.
name = "an agent with the store enabled imports every half of its identity";
ok =
let
c = (agentBaoIdentity agentBao).serviceConfig.LoadCredential;
in
builtins.elem "hive-agent-bao-cert" c
&& builtins.elem "hive-agent-bao-key" c
&& builtins.elem "hive-agent-bao-server-ca" c;
}
{
# `%d` and not a path under the agent's state dir, for the reason the
# queue arm above gives: the host file is `0600` to the hive daemon, so
# the only copy this unprivileged unit can open is the one systemd puts
# in its own credentials directory. The address is the option's value
# rather than a literal that agrees with it today.
name = "the identity check presents its certificate out of the credentials directory";
ok =
let
e = (agentBaoIdentity agentBao).environment;
in
e.BAO_CLIENT_CERT == "%d/hive-agent-bao-cert"
&& e.BAO_CLIENT_KEY == "%d/hive-agent-bao-key"
&& e.BAO_ADDR == agentBao.services.hyperhive.agent.bao.addr;
}
{
# Same 403-not-a-miss reason as the hive-side readers: the path
# `swarm_secret_client::mtls::identity_path` builds is the one this
# agent's own policy stanza covers, and a path outside it is refused
# however correct it looks. Built from the agent's own name rather than
# from a literal, because the name is what makes it this agent's path
# and not some other agent's.
name = "the identity check reads the agent's own path";
ok =
let
m = agentBao;
name = m.services.hyperhive.agent.user.name;
in
lib.hasInfix "secret/swarm/agents/${name}/bao-mtls" (agentBaoIdentity m).script;
}
{
# The whole point of the unit, and the thing a quieter default would
# undo: every arm of the check ends the unit non-zero, so an agent that
# cannot authenticate as itself says so at boot instead of at whichever
# pull needed the store first.
name = "the identity check fails the unit rather than degrading";
ok =
let
u = agentBaoIdentity agentBao;
in
lib.hasInfix "exit 1" u.script
&& !(lib.hasInfix "exit 0" u.script)
&& u.serviceConfig.Restart == "on-failure";
}
{
# Nothing about the identity may be printed, and the read-back is where
# that could slip: `bao kv get` on this path answers with certificate
# material, and the object beside it is a private key. The check needs
# only whether the read succeeded.
#
# The path goes through `lib.escapeShellArg` here for the same reason the
# module passes it through one — that helper decides whether an argument
# needs quotes at all, and this one (only `[a-z0-9/-]`) comes back bare.
# Spelling the quotes in by hand asserts a rendering nixpkgs chooses
# rather than the redirect this property is about.
name = "the identity check discards what it reads back";
ok =
let
m = agentBao;
name = m.services.hyperhive.agent.user.name;
arg = lib.escapeShellArg "secret/swarm/agents/${name}/bao-mtls";
in
lib.hasInfix "bao kv get -field=cert ${arg} >/dev/null" (agentBaoIdentity m).script;
}
{
# The absence arm, and what makes the four above able to fail. An agent
# whose swarm never minted an identity has nothing to log in with, and a
# failed unit at every boot would be the loudest possible statement
# about a deployment that never asked for one.
name = "an agent told no store address runs no identity check";
ok = !(agentNoBao.systemd.services ? hive-agent-bao-identity);
}
{
# The harness reads its per-agent queue secret from the store itself,
# under the same store identity the check above proves, because there is
# one identity per agent.
name = "the harness presents the agent's own store identity";
ok =
let
u = agentHarness agentBao;
in
builtins.elem "hive-agent-bao-cert" u.serviceConfig.LoadCredential
&& builtins.elem "hive-agent-bao-key" u.serviceConfig.LoadCredential
&& u.environment.BAO_CLIENT_CERT == "%d/hive-agent-bao-cert"
&& u.environment.BAO_CLIENT_KEY == "%d/hive-agent-bao-key"
&& u.environment.BAO_ADDR == agentBao.services.hyperhive.agent.bao.addr;
}
{
# `swarm_secret_client` builds both the store path and the cert-auth role
# from this name, so it has to be the name the credential was minted
# under.
name = "the harness is told the agent name its queue secret is minted under";
ok =
(agentHarness agentBao).environment.HIVE_AGENT_NAME == agentBao.services.hyperhive.agent.user.name;
}
{
# The per-agent queue secret lives in the harness's memory only: no unit
# fetches it to a file, and the harness is handed no file to read it
# from.
name = "no unit writes the per-agent queue secret to disk";
ok =
!(agentBao.systemd.services ? hive-agent-queue-credential)
&& !((agentHarness agentBao).environment ? HIVE_AGENT_QUEUE_AGENT_SECRET_FILE);
}
{
# ⛔ The store certificate authenticates against the store and nothing
# else. With both the store and the queue configured, no queue variable
# may name one of its paths.
name = "the harness never points the queue at the store certificate";
ok =
let
e = (agentHarness agentQueueBao).environment;
queueVars = lib.filterAttrs (
n: _: lib.hasPrefix "HIVE_AGENT_OIDC_" n || n == "HIVE_AGENT_NATS_URL"
) e;
in
queueVars != { } && !(lib.any (lib.hasInfix "hive-agent-bao") (builtins.attrValues queueVars));
}
{
# The absence arm. An agent whose swarm gave it no store has nothing to
# log in with, so the harness is handed no store coordinates at all.
name = "an agent told no store address hands the harness no store identity";
ok =
let
u = agentHarness agentNoBao;
in
!(u.environment ? BAO_ADDR)
&& !(u.environment ? HIVE_AGENT_NAME)
&& !(builtins.elem "hive-agent-bao-cert" (u.serviceConfig.LoadCredential or [ ]));
}
];
in
runGroup "agent-queue-bao" cases