Watch
0
0
Fork
You've already forked hyperhive
0

add external gitlab accounts: stored in bao, linked per agent, allowed in squid, usable with glab

This commit is contained in:
atlas 2026-11-02 16:11:25 +01:00
commit d6e983995d
3 changed files with 1322 additions and 51 deletions

View file

@ -78,6 +78,7 @@ in
./frontend.nix
./github.nix
./github-token.nix
./gitlab-accounts.nix
./hive-ca.nix
./hive-facts.nix
./logs.nix

View file

@ -0,0 +1,284 @@
# 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";
};
};
};
}

View file

@ -52,9 +52,9 @@
# `/etc/hyperhive-bridge-dns` (containing the gateway IP) since
# isolation is always on; the oneshot reads it and rewrites
# resolv.conf on every boot. Ordered before the first DNS consumers
# (the forge-token, bao-identity, github-token and forge-accounts
# oneshots) and the network targets so name resolution works for
# the very first turn.
# (the forge-token, bao-identity, github-token, forge-accounts and
# gitlab-accounts oneshots) and the network targets so name resolution
# works for the very first turn.
systemd.services.hyperhive-isolated-dns = {
description = "point resolv.conf at the hive bridge resolver (isolated containers)";
wantedBy = [ "multi-user.target" ];
@ -67,14 +67,15 @@
# first turn errors — self-heals next turn, but better not to flap).
# `hive-matrix-daemon` likewise syncs over the network; the `before`
# is a harmless no-op when matrix is disabled (the unit is absent);
# so are the bao-identity / github-token / forge-accounts edges on
# agents that don't enable those oneshots.
# so are the bao-identity / github-token / forge-accounts /
# gitlab-accounts edges on agents that don't enable those oneshots.
before = [
"network-online.target"
"hive-agent-forge-token.service"
"hive-agent-bao-identity.service"
"hive-agent-github-token.service"
"hive-agent-forge-accounts.service"
"hive-agent-gitlab-accounts.service"
"hive-agent.service"
"hive-matrix-daemon.service"
];