agent trust bundle: retry the root fetch inside its start job so a dns blip at boot queues the harness instead of failing it
This commit is contained in:
parent
fec66af1c8
commit
abf21683b9
7 changed files with 158 additions and 5 deletions
|
|
@ -263,7 +263,7 @@ naming, because it's subtle and it bites at a distance:
|
||||||
|
|
||||||
Two consumers, both reading the root at runtime:
|
Two consumers, both reading the root at runtime:
|
||||||
|
|
||||||
- **Agents fetch it themselves.** `hive-agent-trust-bundle.service` (`nix/agent-modules/hive-ca.nix`) reads `<mount>/ca/pem` from the store with the agent's own store certificate, then writes `/run/hive-agent-trust-bundle/bundle.pem` (system CAs plus the root) and `root.pem` (the root alone). The agent's units point `SSL_CERT_FILE` at the first (Rust, Go and OpenSSL clients) and `NODE_EXTRA_CA_CERTS` at the second (Node and Claude Code), and order after the unit with `Requires=`. The meta flake names no CA file, and a timer re-reads the root hourly. A consumer reads it at start, so a rotated root reaches an agent unit at its next start.
|
- **Agents fetch it themselves.** `hive-agent-trust-bundle.service` (`nix/agent-modules/hive-ca.nix`) reads `<mount>/ca/pem` from the store with the agent's own store certificate, then writes `/run/hive-agent-trust-bundle/bundle.pem` (system CAs plus the root) and `root.pem` (the root alone). The agent's units point `SSL_CERT_FILE` at the first (Rust, Go and OpenSSL clients) and `NODE_EXTRA_CA_CERTS` at the second (Node and Claude Code), and order after the unit with `Requires=`. The fetch retries inside its start job for up to 14 minutes, with a wait that doubles from 2 to 60 seconds, so a store name that doesn't resolve yet delays those units instead of failing them; after that the job fails and they fail with it. The meta flake names no CA file, and a timer re-reads the root hourly. A consumer reads it at start, so a rotated root reaches an agent unit at its next start.
|
||||||
- **The Matrix container gets a bind mount**, via the shared
|
- **The Matrix container gets a bind mount**, via the shared
|
||||||
`lib/hive-ca-trust.nix` helper that `hive-forge` and `swarm-ci-runner`
|
`lib/hive-ca-trust.nix` helper that `hive-forge` and `swarm-ci-runner`
|
||||||
use: the bundle is bind-mounted read-only into the container, and a
|
use: the bundle is bind-mounted read-only into the container, and a
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,10 @@
|
||||||
#
|
#
|
||||||
# A process reads its trust when it starts, so a rotated root reaches the
|
# A process reads its trust when it starts, so a rotated root reaches the
|
||||||
# consumers below at their next start. They `Requires=` this unit, which keeps
|
# consumers below at their next start. They `Requires=` this unit, which keeps
|
||||||
# one from running with a store that lacks the root.
|
# one from running with a store that lacks the root. The fetch retries inside
|
||||||
|
# the start job (../host-modules/lib/retry-in-job.nix), so a consumer queues
|
||||||
|
# behind a store that does not answer yet rather than failing with
|
||||||
|
# `dependency`.
|
||||||
{
|
{
|
||||||
pkgs,
|
pkgs,
|
||||||
lib,
|
lib,
|
||||||
|
|
@ -40,6 +43,12 @@ let
|
||||||
configured = cfg.addr != null;
|
configured = cfg.addr != null;
|
||||||
|
|
||||||
storeRetry = import ../host-modules/lib/store-retry.nix { };
|
storeRetry = import ../host-modules/lib/store-retry.nix { };
|
||||||
|
retryInJob = import ../host-modules/lib/retry-in-job.nix { };
|
||||||
|
|
||||||
|
# How long the fetch retries inside one start job. The store's name does not
|
||||||
|
# resolve for minutes after a host switch, and the consumers below wait in
|
||||||
|
# the job queue for this long instead of failing with `dependency`.
|
||||||
|
fetchBudgetSec = 840;
|
||||||
|
|
||||||
# Units that dial a swarm service over TLS. The first group is defined
|
# Units that dial a swarm service over TLS. The first group is defined
|
||||||
# whenever the agent module is; the rest exist under the same condition as
|
# whenever the agent module is; the rest exist under the same condition as
|
||||||
|
|
@ -99,7 +108,7 @@ in
|
||||||
serviceConfig = storeRetry.serviceConfig // {
|
serviceConfig = storeRetry.serviceConfig // {
|
||||||
Type = "oneshot";
|
Type = "oneshot";
|
||||||
RemainAfterExit = false;
|
RemainAfterExit = false;
|
||||||
TimeoutStartSec = 60;
|
TimeoutStartSec = fetchBudgetSec + 60;
|
||||||
User = agentName;
|
User = agentName;
|
||||||
Group = agentName;
|
Group = agentName;
|
||||||
RuntimeDirectory = runtimeDir;
|
RuntimeDirectory = runtimeDir;
|
||||||
|
|
@ -121,6 +130,8 @@ in
|
||||||
script = ''
|
script = ''
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
${retryInJob}
|
||||||
|
|
||||||
for f in "$BAO_CLIENT_CERT" "$BAO_CLIENT_KEY"; do
|
for f in "$BAO_CLIENT_CERT" "$BAO_CLIENT_KEY"; do
|
||||||
if [ ! -s "$f" ]; then
|
if [ ! -s "$f" ]; then
|
||||||
echo "this agent has no store identity at $f, so it cannot fetch the swarm root certificate." >&2
|
echo "this agent has no store identity at $f, so it cannot fetch the swarm root certificate." >&2
|
||||||
|
|
@ -145,12 +156,15 @@ in
|
||||||
|
|
||||||
# The listener demands a client certificate and the endpoint takes no
|
# The listener demands a client certificate and the endpoint takes no
|
||||||
# token, so no login precedes this.
|
# token, so no login precedes this.
|
||||||
if ! curl --fail --silent --show-error \
|
fetch_root() {
|
||||||
|
curl --fail --silent --show-error \
|
||||||
--connect-timeout 10 --max-time 30 \
|
--connect-timeout 10 --max-time 30 \
|
||||||
''${cacert:+--cacert "$cacert"} \
|
''${cacert:+--cacert "$cacert"} \
|
||||||
--cert "$BAO_CLIENT_CERT" --key "$BAO_CLIENT_KEY" \
|
--cert "$BAO_CLIENT_CERT" --key "$BAO_CLIENT_KEY" \
|
||||||
--output "$raw" \
|
--output "$raw" \
|
||||||
${lib.escapeShellArg "${cfg.addr}/v1/${cfg.servicesPkiMount}/ca/pem"} 2>"$err"; then
|
${lib.escapeShellArg "${cfg.addr}/v1/${cfg.servicesPkiMount}/ca/pem"} 2>"$err"
|
||||||
|
}
|
||||||
|
if ! retry_in_job ${toString fetchBudgetSec} fetch_root; then
|
||||||
echo "could not read the store's ${cfg.servicesPkiMount} root at $BAO_ADDR." >&2
|
echo "could not read the store's ${cfg.servicesPkiMount} root at $BAO_ADDR." >&2
|
||||||
cat "$err" >&2
|
cat "$err" >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,10 @@ in
|
||||||
# Runs ./host-modules/lib/refresh-consumer.nix's restart decision under a
|
# Runs ./host-modules/lib/refresh-consumer.nix's restart decision under a
|
||||||
# comma-decimal locale, against a stub `systemctl`.
|
# comma-decimal locale, against a stub `systemctl`.
|
||||||
script-test-refresh-consumer = import ./script-tests/refresh-consumer.nix { inherit pkgs; };
|
script-test-refresh-consumer = import ./script-tests/refresh-consumer.nix { inherit pkgs; };
|
||||||
|
# Runs ./host-modules/lib/retry-in-job.nix's `retry_in_job`, which the agent
|
||||||
|
# trust-bundle fetch retries through inside its start job: success after two
|
||||||
|
# failures, the wait schedule, and a return of 1 once the budget is spent.
|
||||||
|
script-test-retry-in-job = import ./script-tests/retry-in-job.nix { inherit pkgs; };
|
||||||
# Runs ./host-modules/lib/attic-init.nix's two scripts, which create the swarm
|
# Runs ./host-modules/lib/attic-init.nix's two scripts, which create the swarm
|
||||||
# cache's key, cache and tokens, twice over against stub tools: the second
|
# cache's key, cache and tokens, twice over against stub tools: the second
|
||||||
# run must mint nothing.
|
# run must mint nothing.
|
||||||
|
|
|
||||||
38
nix/host-modules/lib/retry-in-job.nix
Normal file
38
nix/host-modules/lib/retry-in-job.nix
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
# Shared shell step for a oneshot that fetches over the network and that other
|
||||||
|
# units `Requires=`. `retry_in_job` repeats the fetch inside the unit's own
|
||||||
|
# start job, so a unit ordered `After=` it waits for the fetch to succeed. A
|
||||||
|
# unit that exits non-zero ends its job as `failed` and fails every `Requires=`
|
||||||
|
# dependent with `dependency`; a dependent whose start job failed is not
|
||||||
|
# started again when the retry succeeds, so ./store-retry.nix cannot cover
|
||||||
|
# this case.
|
||||||
|
#
|
||||||
|
# The retry stops at the budget and returns non-zero, so the unit still fails
|
||||||
|
# closed. Set the unit's `TimeoutStartSec` above the budget, or systemd kills
|
||||||
|
# the job before the helper gives up.
|
||||||
|
#
|
||||||
|
# Pure function — NOT a NixOS module. Call it from a unit's `script`:
|
||||||
|
#
|
||||||
|
# retryInJob = import ../host-modules/lib/retry-in-job.nix { };
|
||||||
|
# script = ''
|
||||||
|
# ${retryInJob}
|
||||||
|
# if ! retry_in_job 840 fetch; then exit 1; fi
|
||||||
|
# '';
|
||||||
|
{ }:
|
||||||
|
''
|
||||||
|
# retry_in_job <budget-seconds> <command…>: run the command until it
|
||||||
|
# succeeds, waiting 2s, doubling to at most 60s between attempts. Returns 1
|
||||||
|
# once another wait would pass the budget.
|
||||||
|
retry_in_job() {
|
||||||
|
local rc delay=2 deadline=$((SECONDS + $1))
|
||||||
|
shift
|
||||||
|
until "$@"; do
|
||||||
|
rc=$?
|
||||||
|
if [ $((SECONDS + delay)) -gt "$deadline" ]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo "the attempt failed with exit status $rc; retrying in $delay seconds." >&2
|
||||||
|
sleep "$delay"
|
||||||
|
delay=$((delay * 2 > 60 ? 60 : delay * 2))
|
||||||
|
done
|
||||||
|
}
|
||||||
|
''
|
||||||
|
|
@ -97,6 +97,19 @@ let
|
||||||
&& !(agentForgeNoBao.systemd.globalEnvironment ? SSL_CERT_FILE)
|
&& !(agentForgeNoBao.systemd.globalEnvironment ? SSL_CERT_FILE)
|
||||||
&& !(lib.elem unit (agentForgeNoBao.systemd.services.hive-agent.after or [ ]));
|
&& !(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.
|
# The whole switch is the store address, as for the queue credential.
|
||||||
name = "an agent with a store address fetches its forge token";
|
name = "an agent with a store address fetches its forge token";
|
||||||
|
|
|
||||||
18
nix/script-tests/retry-in-job.nix
Normal file
18
nix/script-tests/retry-in-job.nix
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# `checks.script-test-retry-in-job` — runs ../host-modules/lib/retry-in-job.nix's
|
||||||
|
# `retry_in_job` against a command that fails a set number of times. The cases
|
||||||
|
# are in ./retry-in-job.sh.
|
||||||
|
#
|
||||||
|
# What runs is the rendered helper, sourced as the trust-bundle unit's script
|
||||||
|
# includes it.
|
||||||
|
{
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
pkgs.runCommand "hyperhive-script-test-retry-in-job"
|
||||||
|
{
|
||||||
|
HELPER = pkgs.writeText "retry-in-job.sh" (import ../host-modules/lib/retry-in-job.nix { });
|
||||||
|
}
|
||||||
|
''
|
||||||
|
${pkgs.bash}/bin/bash ${./retry-in-job.sh}
|
||||||
|
touch "$out"
|
||||||
|
''
|
||||||
66
nix/script-tests/retry-in-job.sh
Normal file
66
nix/script-tests/retry-in-job.sh
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
# Cases for ./retry-in-job.nix. `sleep` is a function that advances `SECONDS`
|
||||||
|
# and records the wait, so a case takes no wall time and the budget is
|
||||||
|
# measured on the clock the helper reads.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
failures=0
|
||||||
|
count=0
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "FAILED: $case: $*" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
errlog=$(mktemp)
|
||||||
|
waits=
|
||||||
|
sleep() {
|
||||||
|
waits="$waits $1"
|
||||||
|
SECONDS=$((SECONDS + $1))
|
||||||
|
}
|
||||||
|
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
. "$HELPER"
|
||||||
|
|
||||||
|
attempts=0
|
||||||
|
# Fails the first $FAIL_FIRST calls, then succeeds.
|
||||||
|
flaky() {
|
||||||
|
attempts=$((attempts + 1))
|
||||||
|
[ "$attempts" -gt "$FAIL_FIRST" ] || return 6
|
||||||
|
}
|
||||||
|
|
||||||
|
# run <description> <fail-first> <budget>
|
||||||
|
run() {
|
||||||
|
case=$1
|
||||||
|
FAIL_FIRST=$2
|
||||||
|
attempts=0
|
||||||
|
waits=
|
||||||
|
SECONDS=0
|
||||||
|
count=$((count + 1))
|
||||||
|
rc=0
|
||||||
|
retry_in_job "$3" flaky 2>"$errlog" || rc=$?
|
||||||
|
}
|
||||||
|
|
||||||
|
run "a command that succeeds at once is not waited for" 0 100
|
||||||
|
[ "$rc" = 0 ] || fail "returned $rc"
|
||||||
|
[ "$attempts" = 1 ] || fail "ran $attempts times, wanted 1"
|
||||||
|
[ -z "$waits" ] || fail "waited:$waits"
|
||||||
|
|
||||||
|
run "fail, fail, then succeed returns success after waiting 2s and 4s" 2 100
|
||||||
|
[ "$rc" = 0 ] || fail "returned $rc"
|
||||||
|
[ "$attempts" = 3 ] || fail "ran $attempts times, wanted 3"
|
||||||
|
[ "$waits" = " 2 4" ] || fail "waited:$waits, wanted: 2 4"
|
||||||
|
grep -q "failed with exit status 6; retrying in 2 seconds" "$errlog" || fail "the cause of a failed attempt is not logged: $(<"$errlog")"
|
||||||
|
|
||||||
|
run "the wait doubles and stops growing at 60s" 8 1000
|
||||||
|
[ "$rc" = 0 ] || fail "returned $rc"
|
||||||
|
[ "$waits" = " 2 4 8 16 32 60 60 60" ] || fail "waited:$waits"
|
||||||
|
|
||||||
|
run "a command that always fails returns 1 once the budget is spent" 1000000 100
|
||||||
|
[ "$rc" = 1 ] || fail "returned $rc, wanted 1"
|
||||||
|
[ "$waits" = " 2 4 8 16 32" ] || fail "waited:$waits, wanted: 2 4 8 16 32 (the next 60s wait passes the budget)"
|
||||||
|
|
||||||
|
if [ "$failures" -gt 0 ]; then
|
||||||
|
echo "script-test-retry-in-job: $failures failed assertions over $count runs" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "script-test-retry-in-job: $count runs, all assertions hold"
|
||||||
Loading…
Reference in a new issue