Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/nix/agent-modules/github-token.nix

178 lines
6.6 KiB
Nix
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# This agent's GitHub personal access token, fetched from the swarm secret
# store by the agent itself, into the file ./github.nix's readers use.
#
# An operator links the token in the swarm UI; `swarm-controller` stores it at
# `swarm/agents/<agent>/github-token` (`swarm_secret_client::github`). The
# agent's own read grant covers that path, so this unit reads it and writes
# `services.hyperhive.agent.github.tokenFile`, under `/run`, which the `gh`
# wrapper, the git credential helper and `hive-github-notify` read.
#
# `/run` is empty at every start, so the token is fetched anew each time and a
# token removed from the store is gone at the next start. A store that does not
# answer leaves the agent without a token until it does; the timer below asks
# again. Nothing reads the `github-token` file an earlier version kept in the
# state dir, and nothing deletes it.
#
# Within one run, a token already fetched stays when the store has none or
# cannot be read. The file is replaced by rename, and only when its bytes
# changed.
{
pkgs,
lib,
config,
...
}:
let
cfg = config.services.hyperhive.agent.bao;
agentName = config.services.hyperhive.agent.user.name;
# The server CA id ./bao.nix loads.
serverCaCredential = "hive-agent-bao-server-ca";
unitName = "hive-agent-github-token";
# The nix half of `swarm_secret_client::github::account_path` plus
# `path::MOUNT`.
tokenPath = "secret/swarm/agents/${agentName}/github-token";
runtimeDir = unitName;
tokenFile = "/run/${runtimeDir}/token";
# Beside the token, so the rename that replaces it stays in one directory.
stagedFile = "/run/${runtimeDir}/token.new";
# The store's whole answer, token included: kept in the unit's own `0700`
# directory.
rawFile = "/run/${runtimeDir}/account.json";
errFile = "/run/${runtimeDir}/bao.err";
configured = cfg.addr != null && config.services.hyperhive.agent.github.enable;
storeRetry = import ../host-modules/lib/store-retry.nix { };
in
{
options.services.hyperhive.agent.github.tokenFile = lib.mkOption {
type = lib.types.str;
readOnly = true;
default = tokenFile;
description = ''
Path this agent's GitHub token is fetched to. Read-only: it is a fact
about where `${unitName}.service` writes, not a knob.
🩸 A PATH and never a value. The file is `0400` to the agent user and
lives on `/run`, so it is gone whenever the agent's container restarts.
The file exists only once an operator has linked a token and the agent
has a store identity to fetch it with. Until then `gh` and `git push`
run unauthenticated.
'';
};
config = lib.mkIf configured {
systemd.services.${unitName} = {
description = "fetch this agent's GitHub token from the secret store";
after = [
"network.target"
"hive-agent-bao-identity.service"
];
# Ordered first so the poller finds the token at start; it re-reads
# the file on every poll after that.
before = [ "hive-github-notify.service" ];
wantedBy = [ "multi-user.target" ];
path = [
pkgs.openbao
pkgs.coreutils
pkgs.diffutils
pkgs.jq
];
# ../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` is what keeps the directory, and the
# token in it, alive between runs instead.
RemainAfterExit = false;
TimeoutStartSec = 30;
User = agentName;
Group = agentName;
RuntimeDirectory = runtimeDir;
RuntimeDirectoryMode = "0700";
RuntimeDirectoryPreserve = "yes";
# Every file this script creates is `0400`, so only the redirect that
# creates a file can write to it: each is removed before it is
# written.
UMask = "0377";
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 runs without a GitHub token." >&2
exit 0
fi
done
if [ -s "$CREDENTIALS_DIRECTORY/${serverCaCredential}" ]; then
export BAO_CACERT="$CREDENTIALS_DIRECTORY/${serverCaCredential}"
fi
err=${lib.escapeShellArg errFile}
raw=${lib.escapeShellArg rawFile}
staged=${lib.escapeShellArg stagedFile}
token=${lib.escapeShellArg tokenFile}
trap 'rm -f "$err" "$raw" "$staged"' EXIT
rm -f "$err"
if ! BAO_TOKEN="$(bao login -method=cert -token-only 2>"$err")"; then
echo "the swarm secret store at $BAO_ADDR did not accept this agent's certificate login; this agent runs without a GitHub token until it does:" >&2
if [ -s "$err" ]; then cat "$err" >&2; fi
exit 1
fi
export BAO_TOKEN
# No token linked and a store that cannot answer look alike here, and
# either way a token fetched earlier in this run, if any, is kept.
rm -f "$err" "$raw"
if ! bao kv get -format=json ${lib.escapeShellArg tokenPath} >"$raw" 2>"$err"; then
echo "no GitHub token read from ${tokenPath}; this agent runs without one until the store has one:" >&2
if [ -s "$err" ]; then cat "$err" >&2; fi
exit 0
fi
# ⚠️ The token goes from the store's answer straight into a file; it is
# never in a variable or an argument. A malformed object exits 0:
# failing the unit would only restart it into the same answer.
rm -f "$staged"
if ! jq -er '.data.data.value | strings' "$raw" >"$staged"; then
echo "${tokenPath} holds no string value; this agent runs without a GitHub token." >&2
exit 0
fi
if cmp -s "$staged" "$token"; then
echo "this agent's GitHub token at ${tokenPath} is unchanged."
exit 0
fi
mv -f "$staged" "$token"
echo "fetched this agent's GitHub token from ${tokenPath}."
'';
};
# The same cadence as ./forge-accounts.nix.
systemd.timers.${unitName} = {
description = "re-fetch this agent's GitHub token from the secret store";
wantedBy = [ "timers.target" ];
timerConfig = {
OnUnitInactiveSec = "2min";
RandomizedDelaySec = "20s";
};
};
};
}