Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/nix/agent-modules/gitlab-accounts.nix

284 lines
11 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 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";
};
};
};
}