# 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//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"; }; }; }; }