284 lines
11 KiB
Nix
284 lines
11 KiB
Nix
# This agent's accounts on GitLab instances: fetched from the swarm secret store
|
||
# by the agent itself, into the `glab` configuration, and a `glab` and a git
|
||
# credential helper that use it.
|
||
#
|
||
# An operator links an account through `swarm-controller`, which stores it at
|
||
# `swarm/agents/<agent>/gitlab/<label>` (`swarm_secret_client::gitlab`). The
|
||
# agent's grant lists and reads its own subtree, so this unit lists
|
||
# `swarm/agents/<agent>/gitlab/`, reads each account, and writes
|
||
# `<state>/glab/config.yml`: one `hosts` entry per instance, which is `glab`'s
|
||
# own multi-host schema. It also writes `<state>/glab/gitconfig`, a
|
||
# `[credential "<scheme>://<host>"]` block per instance, which /etc/gitconfig
|
||
# includes.
|
||
#
|
||
# The files are generated: every run rebuilds them from the store, so an
|
||
# account the operator deleted is gone from them after the next run. A run in
|
||
# which any account could not be read replaces nothing. A file is replaced by
|
||
# rename, and only when its bytes changed.
|
||
#
|
||
# The wrapper and the helper read the files at invocation, so a fetched account
|
||
# takes effect with no rebuild.
|
||
{
|
||
pkgs,
|
||
lib,
|
||
config,
|
||
...
|
||
}:
|
||
let
|
||
cfg = config.services.hyperhive.agent.bao;
|
||
|
||
agentName = config.services.hyperhive.agent.user.name;
|
||
stateDir = "/agents/${agentName}/state";
|
||
glabDir = "${stateDir}/glab";
|
||
|
||
# The server CA id ./bao.nix loads.
|
||
serverCaCredential = "hive-agent-bao-server-ca";
|
||
|
||
unitName = "hive-agent-gitlab-accounts";
|
||
|
||
# The nix half of `swarm_secret_client::gitlab::accounts_dir` plus
|
||
# `path::MOUNT`.
|
||
accountsDir = "secret/swarm/agents/${agentName}/gitlab";
|
||
|
||
runtimeDir = unitName;
|
||
# The store's whole answer for one account, token included: kept in the
|
||
# unit's own `0700` directory, never in the state dir.
|
||
rawFile = "/run/${runtimeDir}/account.json";
|
||
listFile = "/run/${runtimeDir}/list.json";
|
||
errFile = "/run/${runtimeDir}/bao.err";
|
||
# One account's line, then every readable account's: both hold tokens.
|
||
oneFile = "/run/${runtimeDir}/one.jsonl";
|
||
accountsFile = "/run/${runtimeDir}/accounts.jsonl";
|
||
|
||
configured = cfg.addr != null;
|
||
|
||
storeRetry = import ../host-modules/lib/store-retry.nix { };
|
||
|
||
# `glab`'s schema is `hosts.<host[:port]>.{token,api_protocol,subfolder}`
|
||
# beside the global keys, and the file must be mode `0600`. JSON is YAML, so
|
||
# `jq` writes it directly. `glab` keeps one token per host: of several labels
|
||
# on one host the first wins, and the first label's host is the default one.
|
||
glabConfigProgram = ''
|
||
(.[0].host? // null) as $default
|
||
| group_by(.host)
|
||
| map(.[0]) as $hosts
|
||
| { git_protocol: "https", check_update: false, telemetry: false }
|
||
+ (if $hosts == [] then {} else { host: $default } end)
|
||
+ (if $hosts == [] then {} else
|
||
{ hosts: ($hosts | map({
|
||
key: .host,
|
||
value: ({ token: .value, api_protocol: .scheme }
|
||
+ (if .subfolder == "" then {} else { subfolder: .subfolder } end))
|
||
}) | from_entries) }
|
||
end)
|
||
'';
|
||
|
||
# One git credential block per instance, and nothing for a host with no
|
||
# account. `username = oauth2` is GitLab's placeholder for a token.
|
||
gitconfigProgram = ''
|
||
group_by(.host) | map(.[0])[]
|
||
| "[credential \"\(.scheme)://\(.host)\"]\n\thelper = ${gitCredHelper}/bin/git-credential-hive-gitlab\n\tusername = oauth2"
|
||
'';
|
||
|
||
# One line per account: the label, token and url, and the url's scheme,
|
||
# host[:port] and subfolder. A url that is not http(s) with a DNS-name host
|
||
# yields no line, so nothing else reaches the files written from it.
|
||
accountProgram = ''
|
||
.data.data
|
||
| { label: $label, value: (.value | strings), url: (.url | strings) }
|
||
| select(.url | test("^https?://[A-Za-z0-9.-]+(:[0-9]+)?(/[A-Za-z0-9._~/-]*)?$"))
|
||
| . + (.url | capture("^(?<scheme>https?)://(?<host>[A-Za-z0-9.-]+(:[0-9]+)?)(?<path>.*)$"))
|
||
| . + { subfolder: (.path | ltrimstr("/") | rtrimstr("/")) }
|
||
| del(.path)
|
||
'';
|
||
|
||
glabWrapper = pkgs.writeShellScriptBin "glab" ''
|
||
export GLAB_CONFIG_DIR=${lib.escapeShellArg glabDir}
|
||
exec ${pkgs.glab}/bin/glab "$@"
|
||
'';
|
||
|
||
gitCredHelper = pkgs.writeShellScriptBin "git-credential-hive-gitlab" ''
|
||
# git credential-helper protocol: only the `get` action needs an answer.
|
||
[ "''${1:-}" = "get" ] || exit 0
|
||
host=
|
||
while IFS='=' read -r key value; do
|
||
[ -n "$key" ] || break
|
||
if [ "$key" = host ]; then host=$value; fi
|
||
done
|
||
[ -n "$host" ] || exit 0
|
||
# glab prints nothing for a host it holds no token for.
|
||
token="$(GLAB_CONFIG_DIR=${lib.escapeShellArg glabDir} \
|
||
${pkgs.glab}/bin/glab config get token --host "$host" 2>/dev/null)" || exit 0
|
||
[ -n "$token" ] || exit 0
|
||
printf 'username=oauth2\n'
|
||
printf 'password=%s\n' "$token"
|
||
'';
|
||
in
|
||
{
|
||
config = lib.mkIf configured {
|
||
# No bare pkgs.glab here: the wrapper *is* `glab` and hardcodes the real
|
||
# binary path, so it can't be shadowed.
|
||
environment.systemPackages = [
|
||
glabWrapper
|
||
gitCredHelper
|
||
];
|
||
|
||
# Pulls in the per-instance credential blocks the fetch writes. A missing
|
||
# file is not an error to git. System /etc/gitconfig merges under the
|
||
# agent's ~/.gitconfig, and the lines of every module's text join.
|
||
environment.etc."gitconfig".text = ''
|
||
[include]
|
||
path = ${glabDir}/gitconfig
|
||
'';
|
||
|
||
systemd.services.${unitName} = {
|
||
description = "fetch this agent's GitLab accounts from the secret store";
|
||
after = [
|
||
"network.target"
|
||
"hive-agent-bao-identity.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.
|
||
RemainAfterExit = false;
|
||
# A failed fetch ends the start job within a minute and the unit
|
||
# restarts itself, so the unit never holds a boot open.
|
||
TimeoutStartSec = 60;
|
||
User = agentName;
|
||
Group = agentName;
|
||
RuntimeDirectory = runtimeDir;
|
||
RuntimeDirectoryMode = "0700";
|
||
# `glab` refuses a configuration that is not `0600`.
|
||
UMask = "0077";
|
||
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 cannot fetch its GitLab accounts." >&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}
|
||
list=${lib.escapeShellArg listFile}
|
||
one=${lib.escapeShellArg oneFile}
|
||
accounts=${lib.escapeShellArg accountsFile}
|
||
trap 'rm -f "$err" "$raw" "$list" "$one" "$accounts"' EXIT
|
||
|
||
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:" >&2
|
||
if [ -s "$err" ]; then cat "$err" >&2; fi
|
||
exit 1
|
||
fi
|
||
export BAO_TOKEN
|
||
|
||
# An empty directory is a 404, which `bao` answers with `{}` on stdout
|
||
# and nothing on stderr; a denial or an unreachable store prints
|
||
# nothing on stdout. Nothing linked is a list of no names, which
|
||
# empties the configuration.
|
||
if ! bao kv list -format=json ${lib.escapeShellArg accountsDir} >"$list" 2>"$err"; then
|
||
if [ ! -s "$err" ] && jq -e '. == {}' "$list" >/dev/null 2>&1; then
|
||
echo "no GitLab accounts are linked to this agent (nothing under ${accountsDir})."
|
||
echo '[]' >"$list"
|
||
else
|
||
echo "could not list ${accountsDir}:" >&2
|
||
if [ -s "$err" ]; then cat "$err" >&2; fi
|
||
exit 1
|
||
fi
|
||
fi
|
||
if ! jq -e 'arrays' "$list" >/dev/null; then
|
||
echo "listing ${accountsDir} returned no array of names." >&2
|
||
exit 1
|
||
fi
|
||
|
||
# Moves $1 over $2 and succeeds when the bytes differ; drops $1 otherwise.
|
||
replace() {
|
||
if cmp -s "$1" "$2"; then
|
||
rm -f "$1"
|
||
return 1
|
||
fi
|
||
mv -f "$1" "$2"
|
||
}
|
||
|
||
# ⚠️ A token goes from the store's answer into a file; it is never in a
|
||
# variable or an argument. An account that holds no usable url is
|
||
# logged and left out. One that cannot be read fails the run after the
|
||
# others, with nothing replaced, so the files keep the last full set.
|
||
: >"$accounts"
|
||
unreadable=
|
||
while IFS= read -r label; do
|
||
# A key ending in `/` is a directory below this one, not an account.
|
||
if [[ ! "$label" =~ ^[a-z0-9-]+$ ]]; then
|
||
echo "skipping listed key $(printf '%q' "$label"): not a label." >&2
|
||
continue
|
||
fi
|
||
path="${accountsDir}/$label"
|
||
if ! bao kv get -format=json "$path" >"$raw" 2>"$err"; then
|
||
echo "could not read $path:" >&2
|
||
if [ -s "$err" ]; then cat "$err" >&2; fi
|
||
unreadable=1
|
||
continue
|
||
fi
|
||
if ! jq -c --arg label "$label" ${lib.escapeShellArg accountProgram} "$raw" >"$one" 2>/dev/null \
|
||
|| [ ! -s "$one" ]; then
|
||
echo "$path holds no string value and an http(s) url with a host name; left out." >&2
|
||
continue
|
||
fi
|
||
cat "$one" >>"$accounts"
|
||
done < <(jq -r '.[]' "$list")
|
||
|
||
if [ -n "$unreadable" ]; then
|
||
echo "not every account could be read; the glab configuration is left as it is." >&2
|
||
exit 1
|
||
fi
|
||
|
||
mkdir -p ${lib.escapeShellArg glabDir}
|
||
staged_config=${lib.escapeShellArg glabDir}/.config.yml.new
|
||
staged_gitconfig=${lib.escapeShellArg glabDir}/.gitconfig.new
|
||
rm -f "$staged_config" "$staged_gitconfig"
|
||
jq -s ${lib.escapeShellArg glabConfigProgram} "$accounts" >"$staged_config"
|
||
jq -s -r ${lib.escapeShellArg gitconfigProgram} "$accounts" >"$staged_gitconfig"
|
||
|
||
changed=
|
||
replace "$staged_config" ${lib.escapeShellArg glabDir}/config.yml && changed=1
|
||
replace "$staged_gitconfig" ${lib.escapeShellArg glabDir}/gitconfig && changed=1
|
||
if [ -n "$changed" ]; then
|
||
echo "wrote the glab configuration from ${accountsDir}."
|
||
fi
|
||
'';
|
||
};
|
||
|
||
# The same cadence as ./forge-accounts.nix.
|
||
systemd.timers.${unitName} = {
|
||
description = "re-fetch this agent's GitLab accounts from the secret store";
|
||
wantedBy = [ "timers.target" ];
|
||
timerConfig = {
|
||
OnUnitInactiveSec = "2min";
|
||
RandomizedDelaySec = "20s";
|
||
};
|
||
};
|
||
};
|
||
}
|