add external gitlab accounts: stored in bao, linked per agent, allowed in squid, usable with glab
This commit is contained in:
parent
3b8ab17240
commit
d6e983995d
20 changed files with 1322 additions and 51 deletions
|
|
@ -90,9 +90,10 @@ box it gets:
|
|||
[subagent](docs/tools/subagent.md)
|
||||
- **forge** — its own Forgejo account, the `hive-forge` CLI, git
|
||||
credentials, and notifications turned into todos. Optional linked
|
||||
accounts on other forges, and GitHub (`gh` + push) via
|
||||
accounts on other forges, GitLab (`glab` + push), and GitHub (`gh` + push) via
|
||||
`services.hyperhive.agent.github.enable`. →
|
||||
[forge](docs/integrations/forge.md) · [github](docs/integrations/github.md)
|
||||
[forge](docs/integrations/forge.md) · [gitlab](docs/integrations/gitlab.md) ·
|
||||
[github](docs/integrations/github.md)
|
||||
- **matrix** — its own account(s) on the swarm homeserver, as MCP tools. →
|
||||
[matrix](docs/tools/matrix.md)
|
||||
- **knowledge** — the swarm's shared `internal/knowledge` repo, read-only
|
||||
|
|
|
|||
|
|
@ -58,6 +58,8 @@ declarations.
|
|||
the PAT injected?** → [`integrations/github.md`](integrations/github.md)
|
||||
(operator content up top; the `gh`/git-push + notification-poller
|
||||
mechanics are in a collapsed "Implementation" section at the bottom).
|
||||
- **How to give an agent GitLab accounts (`glab` + `git push`, more than one
|
||||
instance)?** → [`integrations/gitlab.md`](integrations/gitlab.md).
|
||||
- **What's `/knowledge`? How does the swarm-wide knowledge repo sync,
|
||||
and how to contribute a document?** → [`integrations/knowledge.md`](integrations/knowledge.md).
|
||||
- **What does `hivectl` do? Provisioning, gateway users, container
|
||||
|
|
|
|||
88
docs/integrations/gitlab.md
Normal file
88
docs/integrations/gitlab.md
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# GitLab accounts
|
||||
|
||||
Give an agent managed accounts on one or more GitLab instances: a `glab` CLI
|
||||
and `git push` over HTTPS, both authenticated by operator-supplied access
|
||||
tokens, with no manual `glab auth login`.
|
||||
|
||||
An agent can hold any number of accounts, each under a label you choose. Two labels
|
||||
can name two instances, such as `gitlab.com` and a self-hosted one. Provisioning
|
||||
needs no per-agent nix declaration and no rebuild: the agent fetches its tokens
|
||||
from the swarm secret store itself.
|
||||
|
||||
## Linking an account
|
||||
|
||||
The swarm UI has no GitLab dialog yet, so link an account with the controller's
|
||||
API. This is the same route family as the other
|
||||
[linked accounts](../swarm/ui.md#linking-external-accounts):
|
||||
|
||||
```text
|
||||
PUT /api/hives/{hive}/agents/{agent}/gitlab-accounts/{label}
|
||||
{"url": "https://gitlab.example.org", "token": "<access token>"}
|
||||
```
|
||||
|
||||
- `label` is lowercase letters, digits and hyphens. It names the account in the
|
||||
store and in the account list.
|
||||
- `url` is the instance's base URL. The host must be a DNS name: an IP
|
||||
address, `localhost` and a single-label name get a 400, because the agent
|
||||
reaches the instance through its hive's egress proxy, which takes names
|
||||
only. A port and a subfolder (`https://example.org:8443/gitlab`) are part of
|
||||
the address. The path may hold letters, digits and `. _ ~ / -`; a URL with
|
||||
user information, a query, a fragment or any other character gets a 400, so
|
||||
every stored account is one the agent uses.
|
||||
- A plain `http://` URL works, and then the token crosses the network in
|
||||
cleartext. Use `https://` unless the instance sits on a trusted network.
|
||||
- `token` is a GitLab personal, group or project access token. Scope it
|
||||
`api` and `write_repository`: `glab` needs the first, and `git push` over
|
||||
HTTPS needs the second. Use a dedicated bot account and the narrowest token
|
||||
that does the agent's work, because the agent has passwordless sudo and can
|
||||
act within the token's scopes.
|
||||
|
||||
A label that holds an account already answers 409; delete it first
|
||||
(`DELETE .../gitlab-accounts/{label}`), then link again. No route hands a
|
||||
token back. `GET /api/hives/{hive}/agents/{agent}/linked-accounts` lists the
|
||||
account as kind `gitlab`, with its label and URL.
|
||||
|
||||
Linking also allows the instance's host in the agent's egress, as a
|
||||
`link:gitlab:<label>` entry on the swarm's allowed-domains list. swarm-controller
|
||||
derives the host from the URL it stored, so nothing the agent writes can widen
|
||||
its egress. Deleting the account removes the entry.
|
||||
|
||||
## What the agent gets
|
||||
|
||||
An agent with a store identity (`services.hyperhive.agent.bao.addr`) has:
|
||||
|
||||
- the `glab` command, which reads its configuration from `<state>/glab/`;
|
||||
- a git credential helper, `git-credential-hive-gitlab`, for each linked
|
||||
instance;
|
||||
- the `hive-agent-gitlab-accounts` unit and its timer.
|
||||
|
||||
The unit logs in to the store with the agent's own certificate, lists
|
||||
`swarm/agents/<agent>/gitlab/`, reads each account, and writes two files in
|
||||
`<state>/glab/` (mode `0600`) on boot and every two minutes, replacing a file
|
||||
only when its content changed:
|
||||
|
||||
- `config.yml`, `glab`'s own configuration with one `hosts` entry per instance,
|
||||
keyed by host and port, holding the token, the API protocol and the
|
||||
subfolder. It sets `git_protocol: https`. The `host` default is the first
|
||||
label's instance; inside a repository `glab` uses the host of its remote.
|
||||
- `gitconfig`, one `[credential "<scheme>://<host>"]` block per instance, which
|
||||
`/etc/gitconfig` includes. The helper answers only for a host that has an
|
||||
account, so it never touches GitHub or the swarm's forge.
|
||||
|
||||
The unit generates both files. A deleted account leaves them on the next run, and
|
||||
a hand edit doesn't survive. `glab` keeps one token per host: when two labels
|
||||
name the same host, the first label in alphabetical order wins. If the unit
|
||||
can't read one of the accounts, it replaces nothing and retries, so the files
|
||||
keep the last complete set.
|
||||
|
||||
The unit fails fast and restarts itself every 30 seconds for up to a day, as
|
||||
the other store fetches do, so a store that's down or sealed never holds the
|
||||
container's boot open.
|
||||
|
||||
## Rolling out
|
||||
|
||||
The endpoint, the store path and the agent unit are new, and each end ignores
|
||||
what it doesn't know. An old controller has no GitLab route, so no account gets
|
||||
linked. An old agent has no unit, so a stored account sits unused. A new agent
|
||||
under an old controller lists a store directory that doesn't exist and writes an
|
||||
empty `glab` configuration.
|
||||
|
|
@ -32,6 +32,7 @@ the rest of the cell says how.
|
|||
| `swarm/agents/<agent>/observability` | `swarm-controller`, when an operator sets, clears or creates an agent's permissions with `logs` or `metrics` in the old or new list, and when it destroys the agent; the leaf stays absent for an agent that never held either | the agent (read-only, its own prefix); `swarm-logs-auth` and `swarm-metrics-agent-auth`, which deny when it's absent or unreadable | ✅ the controller rewrites it with the permission, and every five minutes makes it equal the record's | ✅ the stores honour a changed grant for up to `deploy.victorialogs.authVerdictTtl` and `deploy.victoriametrics.agentAuthVerdictTtl` (60 s by default) |
|
||||
| `swarm/agents/<agent>/forge-token` | `swarm-controller`, at agent creation and every 5 minutes | the agent | ✅ when missing or no longer matching the forge | ✅ every 10 minutes |
|
||||
| `swarm/agents/<agent>/forge/<label>` | `swarm-controller`, when you link an external forge account in the swarm UI | the agent | ❌ your token; delete it and link the label again | ✅ every 2 minutes |
|
||||
| `swarm/agents/<agent>/gitlab/<label>` | `swarm-controller`, when you link a GitLab account through the API | the agent | ❌ your token; delete it and link the label again | ✅ every 2 minutes; a deleted account leaves the agent's `glab` configuration on the next run |
|
||||
| `swarm/agents/<agent>/github-token` | `swarm-controller`, when you link a GitHub account in the swarm UI | the agent | ❌ your token; delete it and link again | ✅ every 2 minutes |
|
||||
| `swarm/agents/<agent>/backend-env` | `hive-agent-backend-env`, in the agent's container, from the agent's `backendEnvironmentFile` when the leaf holds nothing (field `env`, the file's exact text); or `swarm-controller`, when you set the API-key environment in the swarm UI | the agent | ❌ nothing replaces it without you; a save in the swarm UI overwrites it, including text an agent wrote into an empty leaf; the agent's file stays as it was | ❌ the file in `/run` refreshes every 2 minutes, and the harness and the subagent daemon load it at their next start |
|
||||
| `swarm/agents/<agent>/claude-credentials` | `swarm-claude-login`, when you log the agent in from its card; one version per login | the agent, while it waits for a login | ❌ log in again | ✅ every 30 seconds while waiting |
|
||||
|
|
|
|||
|
|
@ -784,6 +784,7 @@ beneath the error.
|
|||
| --------- | ------------------------------------------- | ------------- | ------------------------ |
|
||||
| matrix | `swarm/agents/<agent>/matrix/<account>` | the account | its `homeserver`, if set |
|
||||
| forgejo | `swarm/agents/<agent>/forge/<label>` | the label | its `url` |
|
||||
| gitlab | `swarm/agents/<agent>/gitlab/<label>` | the label | its `url` |
|
||||
| github | `swarm/agents/<agent>/github-token`, if any | `github` | `github.com` |
|
||||
| `backend` | `swarm/agents/<agent>/backend-env`, if any | `backend-env` | none |
|
||||
|
||||
|
|
@ -807,6 +808,11 @@ a link dialog closes.
|
|||
label the store doesn't list stays untouched. swarm-controller answers 409
|
||||
to a link for a label that already holds an account, so replacing one
|
||||
takes a delete, then a new link.
|
||||
- **link a gitlab account** — `PUT /api/hives/{hive}/agents/{agent}/gitlab-accounts/{label}`
|
||||
with a GitLab base URL and an access token, stored at
|
||||
`swarm/agents/<agent>/gitlab/<label>`. The swarm UI has no dialog for it yet;
|
||||
the API is the way to link one. What the token needs and how the agent uses
|
||||
it: [GitLab accounts](../integrations/gitlab.md).
|
||||
- **link a github account** — `PUT /api/hives/{hive}/agents/{agent}/github-account`
|
||||
with a personal access token, stored at `swarm/agents/<agent>/github-token`.
|
||||
One token per agent. The agent's
|
||||
|
|
@ -834,6 +840,7 @@ request:
|
|||
| --------- | ------------------------------------------------------------------- |
|
||||
| matrix | `DELETE /api/hives/{hive}/agents/{agent}/matrix-accounts/{account}` |
|
||||
| forgejo | `DELETE /api/hives/{hive}/agents/{agent}/forge-accounts/{label}` |
|
||||
| gitlab | `DELETE /api/hives/{hive}/agents/{agent}/gitlab-accounts/{label}` |
|
||||
| github | `DELETE /api/hives/{hive}/agents/{agent}/github-account` |
|
||||
| `backend` | `DELETE /api/hives/{hive}/agents/{agent}/backend-env` |
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js";
|
|||
import { accountKindLabel } from "./account-kind.js";
|
||||
import "./LinkedAccounts.css";
|
||||
|
||||
type AccountKind = "matrix" | "forgejo" | "github" | "backend";
|
||||
type AccountKind = "matrix" | "forgejo" | "gitlab" | "github" | "backend";
|
||||
|
||||
// Mirrors `linked_accounts::LinkedAccount`.
|
||||
interface LinkedAccount {
|
||||
|
|
@ -36,6 +36,8 @@ function accountUrl(hive: string, agent: string, a: LinkedAccount): string {
|
|||
return `${base}/matrix-accounts/${encodeURIComponent(a.name)}`;
|
||||
case "forgejo":
|
||||
return `${base}/forge-accounts/${encodeURIComponent(a.name)}`;
|
||||
case "gitlab":
|
||||
return `${base}/gitlab-accounts/${encodeURIComponent(a.name)}`;
|
||||
case "github":
|
||||
return `${base}/github-account`;
|
||||
case "backend":
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ in
|
|||
./frontend.nix
|
||||
./github.nix
|
||||
./github-token.nix
|
||||
./gitlab-accounts.nix
|
||||
./hive-ca.nix
|
||||
./hive-facts.nix
|
||||
./logs.nix
|
||||
|
|
|
|||
284
nix/agent-modules/gitlab-accounts.nix
Normal file
284
nix/agent-modules/gitlab-accounts.nix
Normal 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";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -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"
|
||||
];
|
||||
|
|
|
|||
|
|
@ -402,8 +402,8 @@ let
|
|||
|
||||
# The two `list` paths end in `/`: bao checks a LIST against the directory
|
||||
# with a trailing slash, where `+` matches the empty last segment, so a
|
||||
# slashless list path loses that request to the `+/matrix/+` and `+/forge/+`
|
||||
# delete stanzas and the LIST is denied.
|
||||
# slashless list path loses that request to the `+/matrix/+`, `+/forge/+`
|
||||
# and `+/gitlab/+` delete stanzas and the LIST is denied.
|
||||
controllerPolicyText = ''
|
||||
path "auth/cert/certs/hive-*" {
|
||||
capabilities = ["create", "update", "read", "delete"]
|
||||
|
|
@ -472,6 +472,10 @@ let
|
|||
capabilities = ["list"]
|
||||
}
|
||||
|
||||
path "${credentialMountPath}/metadata/swarm/agents/+/gitlab/" {
|
||||
capabilities = ["list"]
|
||||
}
|
||||
|
||||
path "${credentialMountPath}/metadata/swarm/agents/+/matrix/+" {
|
||||
capabilities = ["delete"]
|
||||
}
|
||||
|
|
@ -480,6 +484,10 @@ let
|
|||
capabilities = ["delete"]
|
||||
}
|
||||
|
||||
path "${credentialMountPath}/metadata/swarm/agents/+/gitlab/+" {
|
||||
capabilities = ["delete"]
|
||||
}
|
||||
|
||||
path "${credentialMountPath}/metadata/swarm/agents/+/github-token" {
|
||||
capabilities = ["delete"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ let
|
|||
fetchUnit = machine: machine.systemd.services.hive-agent-forge-token;
|
||||
proxyUnit = machine: machine.systemd.services.hive-agent-proxy-credential;
|
||||
accountsUnit = machine: machine.systemd.services.hive-agent-forge-accounts;
|
||||
gitlabUnit = machine: machine.systemd.services.hive-agent-gitlab-accounts;
|
||||
bootWait = import ./boot-wait.nix { inherit lib; };
|
||||
tokenFile = machine: machine.services.hyperhive.agent.forge.tokenFile;
|
||||
|
||||
proxyEnvEntry = "-/run/hive-agent-proxy-credential/proxy.env";
|
||||
|
|
@ -269,6 +271,108 @@ let
|
|||
name = "the account fetch re-runs every two minutes";
|
||||
ok = agentForgeBao.systemd.timers.hive-agent-forge-accounts.timerConfig.OnUnitInactiveSec == "2min";
|
||||
}
|
||||
{
|
||||
# ../agent-modules/gitlab-accounts.nix. The switch is the store address,
|
||||
# as for the forge accounts; `glab` and the credential helper come with it.
|
||||
name = "an agent with a store address fetches its GitLab accounts and has glab, and one without has neither";
|
||||
ok =
|
||||
let
|
||||
named = n: machine: lib.any (p: (p.name or "") == n) machine.environment.systemPackages;
|
||||
in
|
||||
agentForgeBao.systemd.services ? hive-agent-gitlab-accounts
|
||||
&& agentForgeBao.systemd.timers ? hive-agent-gitlab-accounts
|
||||
&& named "glab" agentForgeBao
|
||||
&& named "git-credential-hive-gitlab" agentForgeBao
|
||||
&& !(agentForgeNoBao.systemd.services ? hive-agent-gitlab-accounts)
|
||||
&& !(agentForgeNoBao.systemd.timers ? hive-agent-gitlab-accounts)
|
||||
&& !(named "glab" agentForgeNoBao)
|
||||
&& !(named "git-credential-hive-gitlab" agentForgeNoBao);
|
||||
}
|
||||
{
|
||||
# The real `glab` binary is only reachable through the wrapper that
|
||||
# points it at the generated configuration, and the nix half of
|
||||
# `swarm_secret_client::gitlab::{accounts_dir,account_path}` reads a
|
||||
# directory of its own, never the forge accounts'.
|
||||
name = "the GitLab fetch lists the agent's own gitlab directory, and glab is only the wrapper";
|
||||
ok =
|
||||
let
|
||||
name = agentForgeBao.services.hyperhive.agent.user.name;
|
||||
s = (gitlabUnit agentForgeBao).script;
|
||||
in
|
||||
lib.hasInfix "bao kv list -format=json secret/swarm/agents/${name}/gitlab >" s
|
||||
&& lib.hasInfix "path=\"secret/swarm/agents/${name}/gitlab/$label\"" s
|
||||
&& !(lib.hasInfix "agents/${name}/forge" s)
|
||||
&& !(lib.elem pkgs.glab agentForgeBao.environment.systemPackages);
|
||||
}
|
||||
{
|
||||
# glab's own multi-host schema: `hosts.<host>.token` beside the global
|
||||
# keys, written to `<state>/glab/config.yml`; and the include that makes
|
||||
# git read the per-host credential blocks.
|
||||
name = "the GitLab fetch writes glab's hosts-keyed config and git's per-host credential blocks";
|
||||
ok =
|
||||
let
|
||||
name = agentForgeBao.services.hyperhive.agent.user.name;
|
||||
s = (gitlabUnit agentForgeBao).script;
|
||||
in
|
||||
# The pattern `swarm_secret_client::gitlab::URL_PATTERN` states, which
|
||||
# the controller applies before it stores an account.
|
||||
lib.hasInfix "test(\"^https?://[A-Za-z0-9.-]+(:[0-9]+)?(/[A-Za-z0-9._~/-]*)?$\")" s
|
||||
&& lib.hasInfix "token: .value, api_protocol: .scheme" s
|
||||
&& lib.hasInfix "hosts: (" s
|
||||
&& lib.hasInfix "git_protocol: \"https\"" s
|
||||
&& lib.hasInfix "/agents/${name}/state/glab/config.yml" s
|
||||
&& lib.hasInfix "/agents/${name}/state/glab/gitconfig" s
|
||||
&& lib.hasInfix "[credential \\\"\\(.scheme)://\\(.host)\\\"]" s
|
||||
&&
|
||||
lib.hasInfix "path = /agents/${name}/state/glab/gitconfig"
|
||||
agentForgeBao.environment.etc."gitconfig".text;
|
||||
}
|
||||
{
|
||||
# glab refuses a config that is not `0600`, and the agent owns its state
|
||||
# dir. The tokens only pass through the unit's own `0700` directory.
|
||||
name = "the GitLab fetch runs as the agent with its own store identity, and keeps tokens out of variables";
|
||||
ok =
|
||||
let
|
||||
u = gitlabUnit agentForgeBao;
|
||||
name = agentForgeBao.services.hyperhive.agent.user.name;
|
||||
in
|
||||
u.serviceConfig.User == name
|
||||
&& u.serviceConfig.UMask == "0077"
|
||||
&& u.serviceConfig.RuntimeDirectoryMode == "0700"
|
||||
&& u.environment.BAO_CLIENT_CERT == "/run/hive-agent-bao-identity/current/cert"
|
||||
&& !(lib.hasInfix "echo \"$(bao" u.script)
|
||||
&& !(lib.hasInfix "token=\"$(" u.script);
|
||||
}
|
||||
{
|
||||
# The boot-wait rule: the fetch states a finite budget below the 90 s
|
||||
# manager default, fails fast, and retries as a fresh start job, so it
|
||||
# never holds a boot open. The control: a limit at the stated budget
|
||||
# does flag it, so the empty answer below is not a walk that misses it.
|
||||
name = "the GitLab fetch states a short budget, retries with a backoff, and stays out of a boot wait over it";
|
||||
ok =
|
||||
let
|
||||
u = gitlabUnit agentForgeBao;
|
||||
reaching =
|
||||
limit:
|
||||
map (r: r.key) (
|
||||
bootWait.overBudget {
|
||||
guest = agentForgeBao;
|
||||
inherit limit;
|
||||
}
|
||||
);
|
||||
in
|
||||
u.serviceConfig.TimeoutStartSec == 60
|
||||
&& u.serviceConfig.Restart == "on-failure"
|
||||
&& u.serviceConfig.RestartSec == 30
|
||||
&& u.startLimitBurst == 2880
|
||||
&& !(lib.elem "hive-agent-gitlab-accounts.service" (reaching 61))
|
||||
&& lib.elem "hive-agent-gitlab-accounts.service" (reaching 60);
|
||||
}
|
||||
{
|
||||
name = "the GitLab fetch re-runs every two minutes";
|
||||
ok =
|
||||
agentForgeBao.systemd.timers.hive-agent-gitlab-accounts.timerConfig.OnUnitInactiveSec == "2min";
|
||||
}
|
||||
{
|
||||
# ../agent-modules/egress-proxy.nix. Presence arm: the unit and its timer
|
||||
# exist, and the agent is told nothing until a credential is fetched.
|
||||
|
|
|
|||
|
|
@ -2379,15 +2379,15 @@ let
|
|||
&& !(lib.hasInfix "secret/metadata/*" s);
|
||||
}
|
||||
{
|
||||
# `linked_accounts` names an agent's matrix accounts and forge labels by
|
||||
# listing their two directories. bao checks a LIST against the directory
|
||||
# `linked_accounts` names an agent's matrix accounts, forge labels and GitLab labels by
|
||||
# listing their three directories. bao checks a LIST against the directory
|
||||
# with a trailing slash, so each stanza names that slashed path and
|
||||
# reaches that one directory: not the agent's other keys, not `agents/`
|
||||
# itself, not anything below. Pinned as whole
|
||||
# stanzas, and as the only metadata stanzas under `agents/` beside the
|
||||
# queue revocation and the account deletes, so a widened path or an added
|
||||
# capability fails.
|
||||
name = "the controller may list each agent's matrix and forge accounts, and nothing else under agents";
|
||||
name = "the controller may list each agent's matrix, forge and gitlab accounts, and nothing else under agents";
|
||||
ok =
|
||||
let
|
||||
s = baoGrantHere.systemd.services.swarm-bao-controller-policy.script;
|
||||
|
|
@ -2395,11 +2395,12 @@ let
|
|||
in
|
||||
lib.hasInfix "path \"secret/metadata/swarm/agents/+/matrix/\" {\n capabilities = [\"list\"]\n}" s
|
||||
&& lib.hasInfix "path \"secret/metadata/swarm/agents/+/forge/\" {\n capabilities = [\"list\"]\n}" s
|
||||
&& stanzas == 7;
|
||||
&& lib.hasInfix "path \"secret/metadata/swarm/agents/+/gitlab/\" {\n capabilities = [\"list\"]\n}" s
|
||||
&& stanzas == 9;
|
||||
}
|
||||
{
|
||||
# `linked_accounts` deletes one matrix account, forge account, GitHub
|
||||
# token or backend environment through `delete_all_versions`, which
|
||||
# `linked_accounts` deletes one matrix account, forge account, GitLab
|
||||
# account, GitHub token or backend environment through `delete_all_versions`, which
|
||||
# addresses its metadata path.
|
||||
# A trailing `+` is one segment, so each stanza reaches the accounts
|
||||
# directly in that directory and nothing deeper. Pinned as whole stanzas,
|
||||
|
|
@ -2411,6 +2412,7 @@ let
|
|||
in
|
||||
lib.hasInfix "path \"secret/metadata/swarm/agents/+/matrix/+\" {\n capabilities = [\"delete\"]\n}" s
|
||||
&& lib.hasInfix "path \"secret/metadata/swarm/agents/+/forge/+\" {\n capabilities = [\"delete\"]\n}" s
|
||||
&& lib.hasInfix "path \"secret/metadata/swarm/agents/+/gitlab/+\" {\n capabilities = [\"delete\"]\n}" s
|
||||
&& lib.hasInfix "path \"secret/metadata/swarm/agents/+/github-token\" {\n capabilities = [\"delete\"]\n}" s
|
||||
&& lib.hasInfix "path \"secret/metadata/swarm/agents/+/backend-env\" {\n capabilities = [\"delete\"]\n}" s;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,10 +32,10 @@ The swarm's control plane. The swarm UI and `swarmctl` are its clients.
|
|||
- **Agent credentials** — at start and every five minutes it re-checks every
|
||||
agent's forge token and matrix account, and renews store certificates and
|
||||
queue secrets as they age.
|
||||
- **Linked external accounts** — an operator-supplied matrix, forge or
|
||||
- **Linked external accounts** — an operator-supplied matrix, forge, GitLab or
|
||||
GitHub account, or backend environment, for one agent, stored in the swarm secret store
|
||||
(`PUT /api/hives/{hive}/agents/{agent}/matrix-accounts/{account}`,
|
||||
`.../forge-accounts/{label}`, `.../github-account`, `.../backend-env`, whose save replaces what is stored); distinct from the agent's own swarm-minted
|
||||
`.../forge-accounts/{label}`, `.../gitlab-accounts/{label}`, `.../github-account`, `.../backend-env`, whose save replaces what is stored); distinct from the agent's own swarm-minted
|
||||
accounts above. `GET .../linked-accounts` lists them, plus the agent's own
|
||||
`main` matrix account, by kind, name and host, never with a credential.
|
||||
Each of the four also takes a `DELETE`; matrix's also takes
|
||||
|
|
|
|||
322
swarm-controller/src/gitlab_account.rs
Normal file
322
swarm-controller/src/gitlab_account.rs
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
//! An agent's accounts on GitLab instances: an operator hands us a base URL and
|
||||
//! a token, we put them in the swarm's secret store.
|
||||
//!
|
||||
//! The agent end is `nix/agent-modules/gitlab-accounts.nix`, which lists the
|
||||
//! agent's accounts and reads each under the agent's own certificate, and writes
|
||||
//! the `glab` configuration. No hive is in the path.
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use swarm_secret_client::gitlab;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use super::linked_accounts::link;
|
||||
use crate::backend_egress::host_of;
|
||||
use crate::routes::agents::swarm_hive;
|
||||
use crate::routes::{AppState, error_problem};
|
||||
|
||||
/// The account to store for one agent's GitLab instance.
|
||||
///
|
||||
/// No `Debug` derive: this carries a token.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct PutGitlabAccountRequest {
|
||||
/// The instance's base URL, `http://` or `https://`, with a DNS name as its
|
||||
/// host. A trailing slash is dropped.
|
||||
#[schema(example = "https://gitlab.example.org")]
|
||||
url: String,
|
||||
/// The access token, scoped `api` and `write_repository`. Never logged, and
|
||||
/// never returned by this route.
|
||||
token: String,
|
||||
}
|
||||
|
||||
/// `put_gitlab_account`'s success body.
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct PutGitlabAccountResponse {
|
||||
/// The base URL as stored.
|
||||
url: String,
|
||||
}
|
||||
|
||||
/// Store an agent's GitLab account, unless one is stored under the label
|
||||
/// already.
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/hives/{hive}/agents/{agent}/gitlab-accounts/{label}",
|
||||
params(
|
||||
("hive" = String, Path, description = "hive the agent runs on"),
|
||||
("agent" = String, Path, description = "agent the account belongs to"),
|
||||
("label" = String, Path, description = "the account's name"),
|
||||
),
|
||||
request_body = PutGitlabAccountRequest,
|
||||
responses(
|
||||
(status = 200, description = "stored", body = PutGitlabAccountResponse),
|
||||
(status = 400, description = "the agent or label is not an identifier, the URL is not http(s) with a DNS name as its host, the token is empty, or the hive is not in this swarm (problem+json)", body = String),
|
||||
(status = 409, description = "an account is stored under the label already; nothing was written (problem+json)", body = String),
|
||||
(status = 500, description = "the store could not be read or written (problem+json)", body = String),
|
||||
),
|
||||
tag = "agents"
|
||||
)]
|
||||
pub async fn put_gitlab_account(
|
||||
State(state): State<AppState>,
|
||||
axum::extract::Path((hive, agent, label)): axum::extract::Path<(String, String, String)>,
|
||||
Json(req): Json<PutGitlabAccountRequest>,
|
||||
) -> Result<Json<PutGitlabAccountResponse>, problem_details::ProblemDetails> {
|
||||
let hive = swarm_hive(&state, &hive).map_err(|(s, d)| error_problem(s, &d))?;
|
||||
let agent = hive_types::Ident::parse(&agent)
|
||||
.map_err(|reason| error_problem(StatusCode::BAD_REQUEST, reason))?
|
||||
.into_string();
|
||||
let label = hive_types::Ident::parse(&label)
|
||||
.map_err(|reason| error_problem(StatusCode::BAD_REQUEST, reason))?
|
||||
.into_string();
|
||||
let secret_path = gitlab::account_path(&agent, &label)
|
||||
.map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e.to_string()))?;
|
||||
let account = account(req).map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e))?;
|
||||
|
||||
let store = state.accounts.open().await.map_err(|e| {
|
||||
tracing::warn!(error = %e, "connecting to the swarm secret store failed");
|
||||
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
|
||||
})?;
|
||||
link(&store, &secret_path, &account).await.map_err(|e| {
|
||||
// The path names the agent and the label; the value is not in it.
|
||||
tracing::warn!(path = %secret_path, error = ?e, "linking the gitlab account failed");
|
||||
e.problem(&existing(&agent, &label))
|
||||
})?;
|
||||
crate::link_egress::added(
|
||||
&state,
|
||||
&agent,
|
||||
&crate::link_egress::Link::Gitlab {
|
||||
label: &label,
|
||||
url: &account.url,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
tracing::info!(%hive, %agent, %label, url = %account.url, "gitlab account stored");
|
||||
Ok(Json(PutGitlabAccountResponse { url: account.url }))
|
||||
}
|
||||
|
||||
/// The account a refused link names.
|
||||
fn existing(agent: &str, label: &str) -> String {
|
||||
format!("agent {agent} already has gitlab account {label:?}")
|
||||
}
|
||||
|
||||
/// The request as it is stored, or why it cannot be.
|
||||
///
|
||||
/// The URL must match [`gitlab::URL_PATTERN`], the rule the agent end filters by,
|
||||
/// so a stored account is never one the agent drops. The host must also be a
|
||||
/// name the egress allow list takes: the agent reaches the instance through its
|
||||
/// hive's proxy, so an address or `localhost` would be stored and never reachable.
|
||||
fn account(req: PutGitlabAccountRequest) -> Result<gitlab::Account, String> {
|
||||
let url = req.url.trim().trim_end_matches('/');
|
||||
if !(url.starts_with("http://") || url.starts_with("https://")) {
|
||||
return Err("url must start with http:// or https://".to_owned());
|
||||
}
|
||||
let authority = url
|
||||
.split_once("://")
|
||||
.and_then(|(_, rest)| rest.split(['/', '?', '#']).next())
|
||||
.unwrap_or_default();
|
||||
if authority.contains('@') {
|
||||
return Err("url must not carry user information; the token is its own field".to_owned());
|
||||
}
|
||||
if !gitlab::is_instance_url(url) {
|
||||
return Err(format!(
|
||||
"url must match {}: http(s), a host name, an optional port, and an optional path of \
|
||||
letters, digits and . _ ~ / - (no query, fragment, '@' or spaces)",
|
||||
gitlab::URL_PATTERN
|
||||
));
|
||||
}
|
||||
let host = host_of(url).ok_or("url has no host")?;
|
||||
swarm_wire::egress_allow::normalize_domain(host).map_err(|why| format!("url host: {why}"))?;
|
||||
if req.token.trim().is_empty() {
|
||||
return Err("token is required".to_owned());
|
||||
}
|
||||
Ok(gitlab::Account {
|
||||
value: req.token,
|
||||
url: url.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::super::linked_accounts::{Accounts, tests::FakeStore};
|
||||
use super::{PutGitlabAccountRequest, account};
|
||||
|
||||
fn request(url: &str, token: &str) -> PutGitlabAccountRequest {
|
||||
PutGitlabAccountRequest {
|
||||
url: url.to_owned(),
|
||||
token: token.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_url_loses_its_trailing_slash_and_the_token_is_kept() {
|
||||
let a = account(request("https://gitlab.example.org/ ", "t0k3n")).expect("valid");
|
||||
assert_eq!(a.url, "https://gitlab.example.org");
|
||||
assert_eq!(a.value, "t0k3n");
|
||||
// A port and a subfolder are part of the instance's address.
|
||||
let a = account(request("https://gitlab.example.org:8443/gl/", "t")).expect("valid");
|
||||
assert_eq!(a.url, "https://gitlab.example.org:8443/gl");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_url_that_is_not_http_is_refused() {
|
||||
assert!(account(request("gitlab.example.org", "t")).is_err());
|
||||
assert!(account(request("file:///etc/passwd", "t")).is_err());
|
||||
// The control: plain http with a name is accepted.
|
||||
assert!(account(request("http://gitlab.lan", "t")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_host_the_egress_list_would_refuse_is_refused() {
|
||||
for url in [
|
||||
"http://10.0.0.5",
|
||||
"https://10.0.0.5:8443",
|
||||
"http://[::1]",
|
||||
"https://localhost",
|
||||
"https://gitlab",
|
||||
"https://*.example.org",
|
||||
"https://",
|
||||
] {
|
||||
assert!(account(request(url, "t")).is_err(), "{url}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_information_in_the_url_is_refused() {
|
||||
assert!(account(request("https://oauth2:t0k3n@gitlab.example.org", "t")).is_err());
|
||||
// A path character the agent end drops is refused here too.
|
||||
for url in [
|
||||
"https://gitlab.example.org/@x",
|
||||
"https://gitlab.example.org/?x=1",
|
||||
"https://gitlab.example.org/#f",
|
||||
"https://gitlab.example.org/a b",
|
||||
] {
|
||||
assert!(account(request(url, "t")).is_err(), "{url}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_token_is_refused() {
|
||||
assert!(account(request("https://gitlab.example.org", " ")).is_err());
|
||||
}
|
||||
|
||||
/// Bare-minimum `AppState`, as `forge_account`'s tests build it.
|
||||
fn state(store: &Arc<FakeStore>) -> crate::routes::AppState {
|
||||
crate::routes::AppState {
|
||||
hives: std::sync::Arc::new(vec![crate::routes::hives::HiveEntry {
|
||||
name: "pr1ma".to_owned(),
|
||||
domain: "pr1ma.example".to_owned(),
|
||||
dashboard: "pr1ma.hives.example".to_owned(),
|
||||
}]),
|
||||
links: std::sync::Arc::new(Vec::new()),
|
||||
status: None,
|
||||
wanted: None,
|
||||
agent_status: None,
|
||||
agent_icons: None,
|
||||
agents_md: None,
|
||||
reported_inference: None,
|
||||
agent_login: None,
|
||||
jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
|
||||
hive_jobq::Graph::new(),
|
||||
hive_jobq::resources::ResourceTable::new(),
|
||||
))),
|
||||
webhook_secret: None,
|
||||
config_prs: std::sync::Arc::default(),
|
||||
swarm_name: None,
|
||||
auth: None,
|
||||
forge: std::sync::Arc::default(),
|
||||
create_gate: std::sync::Arc::default(),
|
||||
accounts: Accounts::Fake(Arc::clone(store)),
|
||||
try_outs: super::super::review_classifier::TryOuts::default(),
|
||||
reviews: std::sync::Arc::default(),
|
||||
runs: crate::review_classifier::runs::Runs::Live(None),
|
||||
builds: crate::builder::runs::Builds::Live(None),
|
||||
alerts: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn put(
|
||||
store: &Arc<FakeStore>,
|
||||
agent: &str,
|
||||
label: &str,
|
||||
url: &str,
|
||||
) -> Result<axum::Json<super::PutGitlabAccountResponse>, problem_details::ProblemDetails> {
|
||||
super::put_gitlab_account(
|
||||
axum::extract::State(state(store)),
|
||||
axum::extract::Path(("pr1ma".to_owned(), agent.to_owned(), label.to_owned())),
|
||||
axum::Json(request(url, "t0k3n")),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_plain_account_reaches_the_store_under_the_agents_gitlab_directory() {
|
||||
let store = Arc::default();
|
||||
let stored = put(&store, "atlas", "work", "https://gitlab.example.org")
|
||||
.await
|
||||
.expect("nothing is stored");
|
||||
assert_eq!(stored.url, "https://gitlab.example.org");
|
||||
assert_eq!(store.written(), ["swarm/agents/atlas/gitlab/work"]);
|
||||
}
|
||||
|
||||
/// Refused before the store, whatever the part: an address, a label or an
|
||||
/// agent that is not an identifier.
|
||||
#[tokio::test]
|
||||
async fn a_request_the_swarm_would_refuse_is_a_400_and_writes_nothing() {
|
||||
let store = Arc::default();
|
||||
for (agent, label, url) in [
|
||||
("atlas", "work", "http://10.0.0.5:8080"),
|
||||
("atlas", "work", "https://localhost"),
|
||||
("atlas", "work", "https://gitlab.example.org/@x"),
|
||||
("atlas", "work", "https://gitlab.example.org/?x=1"),
|
||||
("atlas", "My_Label", "https://gitlab.example.org"),
|
||||
("atlas", "../x", "https://gitlab.example.org"),
|
||||
("../x", "work", "https://gitlab.example.org"),
|
||||
] {
|
||||
let problem = put(&store, agent, label, url).await.expect_err("refused");
|
||||
assert_eq!(
|
||||
problem.status,
|
||||
Some(axum::http::StatusCode::BAD_REQUEST),
|
||||
"{agent} {label} {url}: {problem:?}"
|
||||
);
|
||||
}
|
||||
assert!(store.written().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn linking_a_label_twice_is_a_409_and_the_first_account_stays() {
|
||||
use super::super::linked_accounts::{AccountStore, link};
|
||||
use swarm_secret_client::gitlab;
|
||||
|
||||
let store = FakeStore::default();
|
||||
let path = gitlab::account_path("atlas", "work").expect("a valid path");
|
||||
let first = account(request("https://gitlab.example.org", "t0k3n-first")).expect("valid");
|
||||
let second = account(request("https://gitlab.lan", "t0k3n-second")).expect("valid");
|
||||
|
||||
link(&store, &path, &first)
|
||||
.await
|
||||
.expect("nothing is stored");
|
||||
let problem = link(&store, &path, &second)
|
||||
.await
|
||||
.expect_err("an account is stored")
|
||||
.problem(&super::existing("atlas", "work"));
|
||||
|
||||
assert_eq!(problem.status, Some(axum::http::StatusCode::CONFLICT));
|
||||
let detail = problem.detail.expect("a detail");
|
||||
assert!(
|
||||
detail.contains(r#"agent atlas already has gitlab account "work""#),
|
||||
"{detail}"
|
||||
);
|
||||
assert_eq!(store.written(), std::slice::from_ref(&path));
|
||||
let kept: gitlab::Account = store
|
||||
.read_optional(&path)
|
||||
.await
|
||||
.expect("store answers")
|
||||
.expect("still stored");
|
||||
assert_eq!(kept.url, "https://gitlab.example.org");
|
||||
assert_eq!(kept.value, "t0k3n-first");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,16 @@
|
|||
//! The hosts an agent's linked accounts reach, kept as derived entries of the
|
||||
//! egress allow list (`crate::egress_allow`), so a restricted agent with a
|
||||
//! linked GitHub, external matrix or external forge account reaches that
|
||||
//! service through its hive's proxy.
|
||||
//! linked GitHub, external matrix, external forge or GitLab account reaches
|
||||
//! that service through its hive's proxy.
|
||||
//!
|
||||
//! An entry is marked `derived` and carries the account as its `source`
|
||||
//! (`link:github`, `link:matrix:<account>`, `link:forge:<label>`), so the
|
||||
//! backend environment's entries (`crate::backend_egress`) and each account's
|
||||
//! are replaced and dropped independently. A hive renders it like any other.
|
||||
//! (`link:github`, `link:matrix:<account>`, `link:forge:<label>`,
|
||||
//! `link:gitlab:<label>`), so
|
||||
//! the backend environment's entries (`crate::backend_egress`) and each
|
||||
//! account's are replaced and dropped independently. A hive renders it like
|
||||
//! any other.
|
||||
//!
|
||||
//! The controller alone writes the three leaves the entries come from; the
|
||||
//! The controller alone writes the four leaves the entries come from; the
|
||||
//! agent's store policy grants it `create` on its own `backend-env` leaf and
|
||||
//! nothing else (`swarm-secret-client/src/policy.rs`), so what these leaves
|
||||
//! hold is the operator's choice, unlike the backend environment's.
|
||||
|
|
@ -55,6 +57,10 @@ pub(crate) enum Link<'a> {
|
|||
label: &'a str,
|
||||
url: &'a str,
|
||||
},
|
||||
Gitlab {
|
||||
label: &'a str,
|
||||
url: &'a str,
|
||||
},
|
||||
}
|
||||
|
||||
pub(crate) fn github_source() -> String {
|
||||
|
|
@ -69,12 +75,17 @@ pub(crate) fn forge_source(label: &str) -> String {
|
|||
format!("{LINK_SOURCE_PREFIX}forge:{label}")
|
||||
}
|
||||
|
||||
pub(crate) fn gitlab_source(label: &str) -> String {
|
||||
format!("{LINK_SOURCE_PREFIX}gitlab:{label}")
|
||||
}
|
||||
|
||||
impl Link<'_> {
|
||||
fn source(&self) -> String {
|
||||
match self {
|
||||
Self::Github => github_source(),
|
||||
Self::Matrix { account, .. } => matrix_source(account),
|
||||
Self::Forge { label, .. } => forge_source(label),
|
||||
Self::Gitlab { label, .. } => gitlab_source(label),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -87,7 +98,7 @@ impl Link<'_> {
|
|||
let hosts: Vec<Option<&str>> = match self {
|
||||
Self::Github => GITHUB_HOSTS.iter().map(|h| Some(*h)).collect(),
|
||||
Self::Matrix { homeserver, .. } => homeserver.iter().map(|u| host_of(u)).collect(),
|
||||
Self::Forge { url, .. } => vec![host_of(url)],
|
||||
Self::Forge { url, .. } | Self::Gitlab { url, .. } => vec![host_of(url)],
|
||||
};
|
||||
let mut entries = Vec::new();
|
||||
for host in hosts {
|
||||
|
|
@ -221,6 +232,13 @@ async fn wanted_entries(
|
|||
},
|
||||
None => continue,
|
||||
},
|
||||
AccountKind::Gitlab => match account.host.as_deref() {
|
||||
Some(url) => Link::Gitlab {
|
||||
label: &account.name,
|
||||
url,
|
||||
},
|
||||
None => continue,
|
||||
},
|
||||
AccountKind::Matrix | AccountKind::Backend => continue,
|
||||
};
|
||||
entries.extend(link.entries(agent, base));
|
||||
|
|
@ -370,6 +388,18 @@ mod tests {
|
|||
forge.entries("atlas", &base()),
|
||||
[entry("forge.example.org", "atlas", Some("link:forge:oss"))]
|
||||
);
|
||||
let gitlab = Link::Gitlab {
|
||||
label: "work",
|
||||
url: "https://GitLab.Example.org:8443/sub",
|
||||
};
|
||||
assert_eq!(
|
||||
gitlab.entries("atlas", &base()),
|
||||
[entry(
|
||||
"gitlab.example.org",
|
||||
"atlas",
|
||||
Some("link:gitlab:work")
|
||||
)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -385,6 +415,8 @@ mod tests {
|
|||
] {
|
||||
let forge = Link::Forge { label: "x", url };
|
||||
assert_eq!(hosts(&forge), Vec::<String>::new(), "{url:?}");
|
||||
let gitlab = Link::Gitlab { label: "x", url };
|
||||
assert_eq!(hosts(&gitlab), Vec::<String>::new(), "{url:?}");
|
||||
}
|
||||
// Controls: a public name gets one, and a matrix account with no
|
||||
// homeserver has none to derive.
|
||||
|
|
@ -393,6 +425,11 @@ mod tests {
|
|||
url: "https://forge.example.org",
|
||||
};
|
||||
assert_eq!(hosts(&forge), ["forge.example.org"]);
|
||||
let gitlab = Link::Gitlab {
|
||||
label: "x",
|
||||
url: "https://gitlab.example.org",
|
||||
};
|
||||
assert_eq!(hosts(&gitlab), ["gitlab.example.org"]);
|
||||
let matrix = Link::Matrix {
|
||||
account: "work",
|
||||
homeserver: None,
|
||||
|
|
@ -475,6 +512,22 @@ mod tests {
|
|||
Path(("pr1ma".to_owned(), parts.to_owned()))
|
||||
}
|
||||
|
||||
/// Link `atlas`'s GitLab account `work` through the route.
|
||||
async fn link_gitlab(state: &AppState) {
|
||||
let _ = crate::gitlab_account::put_gitlab_account(
|
||||
State(state.clone()),
|
||||
Path(("pr1ma".to_owned(), "atlas".to_owned(), "work".to_owned())),
|
||||
axum::Json(
|
||||
serde_json::from_value(
|
||||
json!({"url": "https://gitlab.example.org:8443/", "token": "t"}),
|
||||
)
|
||||
.expect("a body"),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("linked");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn linking_and_unlinking_through_the_routes_moves_the_entries() {
|
||||
let nats = TestNats::start();
|
||||
|
|
@ -501,6 +554,7 @@ mod tests {
|
|||
)
|
||||
.await
|
||||
.expect("linked");
|
||||
link_gitlab(&state).await;
|
||||
let _ = crate::matrix_account::put_matrix_account(
|
||||
State(state.clone()),
|
||||
Path(("pr1ma".to_owned(), "atlas".to_owned(), "work".to_owned())),
|
||||
|
|
@ -514,9 +568,10 @@ mod tests {
|
|||
.await
|
||||
.expect("linked");
|
||||
let forge = entry("forge.example.org", "atlas", Some("link:forge:oss"));
|
||||
let gitlab = entry("gitlab.example.org", "atlas", Some("link:gitlab:work"));
|
||||
let matrix = entry("matrix.example.org", "atlas", Some("link:matrix:work"));
|
||||
let mut all = github_entries("atlas");
|
||||
all.extend([forge.clone(), matrix.clone()]);
|
||||
all.extend([forge.clone(), gitlab.clone(), matrix.clone()]);
|
||||
assert_eq!(list_of(&client).await, sorted(all));
|
||||
|
||||
// The backend environment's own save and delete leave them alone.
|
||||
|
|
@ -535,14 +590,14 @@ mod tests {
|
|||
crate::linked_accounts::delete_backend_env(State(state.clone()), path("atlas"))
|
||||
.await
|
||||
.expect("deleted");
|
||||
assert_eq!(list_of(&client).await.len(), 2 + GITHUB_HOSTS.len());
|
||||
assert_eq!(list_of(&client).await.len(), 3 + GITHUB_HOSTS.len());
|
||||
|
||||
crate::linked_accounts::delete_github_account(State(state.clone()), path("atlas"))
|
||||
.await
|
||||
.expect("deleted");
|
||||
assert_eq!(
|
||||
list_of(&client).await,
|
||||
sorted(vec![forge.clone(), matrix.clone()])
|
||||
sorted(vec![forge.clone(), gitlab.clone(), matrix.clone()])
|
||||
);
|
||||
crate::linked_accounts::delete_forge_account(
|
||||
State(state.clone()),
|
||||
|
|
@ -550,6 +605,16 @@ mod tests {
|
|||
)
|
||||
.await
|
||||
.expect("deleted");
|
||||
assert_eq!(
|
||||
list_of(&client).await,
|
||||
sorted(vec![gitlab.clone(), matrix.clone()])
|
||||
);
|
||||
crate::linked_accounts::delete_gitlab_account(
|
||||
State(state.clone()),
|
||||
Path(("pr1ma".to_owned(), "atlas".to_owned(), "work".to_owned())),
|
||||
)
|
||||
.await
|
||||
.expect("deleted");
|
||||
assert_eq!(list_of(&client).await, [matrix]);
|
||||
crate::linked_accounts::delete_matrix_account(
|
||||
State(state.clone()),
|
||||
|
|
@ -571,6 +636,10 @@ mod tests {
|
|||
"swarm/agents/atlas/matrix/work",
|
||||
json!({"value": "t", "homeserver": "https://matrix.example.org"}),
|
||||
);
|
||||
let store = store.with(
|
||||
"swarm/agents/atlas/gitlab/work",
|
||||
json!({"value": "t", "url": "https://gitlab.example.org:8443"}),
|
||||
);
|
||||
// The agent's own account has a homeserver of its own and no entry.
|
||||
let store = store.with(
|
||||
"swarm/agents/atlas/matrix/main",
|
||||
|
|
@ -583,6 +652,7 @@ mod tests {
|
|||
// An orphan: iris has no leaf for it.
|
||||
entry("github.com", "iris", Some("link:github")),
|
||||
// An orphan on a live agent whose forge leaf is gone.
|
||||
entry("gitlab.example.org", "atlas", Some("link:gitlab:old")),
|
||||
entry("forge.example.org", "atlas", Some("link:forge:old")),
|
||||
entry("llm.example.com", "atlas", Some("backend-env")),
|
||||
entry("crates.io", "atlas", None),
|
||||
|
|
@ -596,6 +666,7 @@ mod tests {
|
|||
assert_eq!(reconcile(&client, &store, &live).await.expect("pass"), 0);
|
||||
let mut want = github_entries("atlas");
|
||||
want.extend([
|
||||
entry("gitlab.example.org", "atlas", Some("link:gitlab:work")),
|
||||
entry("matrix.example.org", "atlas", Some("link:matrix:work")),
|
||||
entry("llm.example.com", "atlas", Some("backend-env")),
|
||||
entry("crates.io", "atlas", None),
|
||||
|
|
@ -607,6 +678,38 @@ mod tests {
|
|||
assert_eq!(list_of(&client).await, sorted(want));
|
||||
}
|
||||
|
||||
/// The host of a GitLab entry is the controller-written leaf's `url`. The
|
||||
/// one object an agent can create in the store is its `backend-env` leaf,
|
||||
/// and a GitLab host named there yields no `link:gitlab` entry.
|
||||
#[tokio::test]
|
||||
async fn a_gitlab_entry_comes_from_the_controller_leaf_and_nothing_an_agent_writes() {
|
||||
let agent_written = FakeStore::default().with(
|
||||
"swarm/agents/atlas/backend-env",
|
||||
json!({"env": "GITLAB_HOST=https://evil.example.com\nANTHROPIC_BASE_URL=https://evil.example.com\n"}),
|
||||
);
|
||||
let got = wanted_entries(&agent_written, "atlas", &base())
|
||||
.await
|
||||
.expect("store answers");
|
||||
assert_eq!(got, []);
|
||||
|
||||
// The control: the controller's leaf yields the entry.
|
||||
let leaf = agent_written.with(
|
||||
"swarm/agents/atlas/gitlab/work",
|
||||
json!({"value": "t", "url": "https://gitlab.example.org:8443/sub"}),
|
||||
);
|
||||
let got = wanted_entries(&leaf, "atlas", &base())
|
||||
.await
|
||||
.expect("store answers");
|
||||
assert_eq!(
|
||||
got,
|
||||
[entry(
|
||||
"gitlab.example.org",
|
||||
"atlas",
|
||||
Some("link:gitlab:work")
|
||||
)]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_store_error_prunes_nothing_and_adds_nothing() {
|
||||
let nats = TestNats::start();
|
||||
|
|
|
|||
|
|
@ -1,28 +1,31 @@
|
|||
//! The accounts linked to one agent, as kind, name and host: what the swarm
|
||||
//! UI's agent panel lists, and deletes.
|
||||
//!
|
||||
//! [`link`] is the write the three link routes share: it refuses a path that
|
||||
//! [`link`] is the write the four link routes share: it refuses a path that
|
||||
//! already holds an account, so replacing one takes a delete first. The
|
||||
//! backend environment route is the exception: it overwrites.
|
||||
//!
|
||||
//! Read from the paths [`crate::matrix_account`], [`crate::forge_account`],
|
||||
//! [`crate::github_account`] and [`crate::backend_env`] write, plus the
|
||||
//! agent's own `main` matrix account, which `matrix_account::agent_token`
|
||||
//! mints into the same directory. Each entry is read for its host and nothing
|
||||
//! else leaves this module: [`LinkedAccount`] has no field a credential could
|
||||
//! land in. A backend environment is listed as set, with no host.
|
||||
//! [`crate::gitlab_account`], [`crate::github_account`] and
|
||||
//! [`crate::backend_env`] write, plus the agent's own `main` matrix account,
|
||||
//! which `matrix_account::agent_token` mints into the same directory. Each
|
||||
//! entry is read for its host and nothing else leaves this module:
|
||||
//! [`LinkedAccount`] has no field a credential could land in. A backend
|
||||
//! environment is listed as set, with no host.
|
||||
//!
|
||||
//! Naming the matrix accounts and forge labels takes `list` on each agent's
|
||||
//! `matrix` and `forge` metadata directories, and deleting an account takes
|
||||
//! `delete` on its metadata path. `controllerPolicyText` in
|
||||
//! `nix/host-modules/swarm-bao.nix` grants both on those paths alone.
|
||||
//! Naming the matrix accounts, forge labels and GitLab labels takes `list` on
|
||||
//! each agent's `matrix`, `forge` and `gitlab` metadata directories, and
|
||||
//! deleting an account takes `delete` on its metadata path.
|
||||
//! `controllerPolicyText` in `nix/host-modules/swarm-bao.nix` grants both on
|
||||
//! those paths alone.
|
||||
//!
|
||||
//! A delete removes the store entry only, and the agent is not told. Its
|
||||
//! GitHub and forge token files stay in place, since the units that write them
|
||||
//! never delete one; its matrix daemon drops the account when it next re-lists.
|
||||
//! A deleted backend environment leaves the agent's copy in `/run` until its
|
||||
//! next boot, and an agent with a non-empty `backendEnvironmentFile` creates
|
||||
//! the leaf again from that file.
|
||||
//! never delete one; its matrix daemon drops the account when it next re-lists,
|
||||
//! and its GitLab fetch drops the account from the `glab` configuration when it
|
||||
//! next runs. A deleted backend environment leaves the agent's copy in `/run`
|
||||
//! until its next boot, and an agent with a non-empty `backendEnvironmentFile`
|
||||
//! creates the leaf again from that file.
|
||||
|
||||
use std::future::Future;
|
||||
|
||||
|
|
@ -31,7 +34,7 @@ use axum::extract::State;
|
|||
use axum::http::StatusCode;
|
||||
use serde::de::{DeserializeOwned, IgnoredAny};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use swarm_secret_client::{Error, SecretStore, backend_env, forge, github, matrix};
|
||||
use swarm_secret_client::{Error, SecretStore, backend_env, forge, github, gitlab, matrix};
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
use crate::routes::agents::swarm_hive;
|
||||
|
|
@ -47,6 +50,7 @@ const GITHUB_HOST: &str = "github.com";
|
|||
pub enum AccountKind {
|
||||
Matrix,
|
||||
Forgejo,
|
||||
Gitlab,
|
||||
Github,
|
||||
Backend,
|
||||
}
|
||||
|
|
@ -55,10 +59,11 @@ pub enum AccountKind {
|
|||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, ToSchema)]
|
||||
pub struct LinkedAccount {
|
||||
pub(crate) kind: AccountKind,
|
||||
/// The matrix account name, the forge label, `github`, or `backend-env`.
|
||||
/// The matrix account name, the forge or GitLab label, `github`, or
|
||||
/// `backend-env`.
|
||||
#[schema(example = "main")]
|
||||
pub(crate) name: String,
|
||||
/// The matrix homeserver, the forge base URL, or `github.com`. `None` for a
|
||||
/// The matrix homeserver, the forge or GitLab base URL, or `github.com`. `None` for a
|
||||
/// matrix account stored without a homeserver.
|
||||
#[schema(example = "https://matrix.example.org")]
|
||||
pub(crate) host: Option<String>,
|
||||
|
|
@ -190,7 +195,7 @@ async fn objects(store: &impl AccountStore, dir: &str) -> Result<Vec<String>, Er
|
|||
Ok(keys.into_iter().filter(|k| !k.ends_with('/')).collect())
|
||||
}
|
||||
|
||||
/// Every account the store holds for `agent`: matrix, then forgejo, then
|
||||
/// Every account the store holds for `agent`: matrix, then forgejo, then gitlab, then
|
||||
/// github, then the backend environment.
|
||||
///
|
||||
/// A missing directory lists nothing, and a listed name whose object is gone
|
||||
|
|
@ -230,6 +235,18 @@ pub(crate) async fn linked_accounts(
|
|||
}
|
||||
}
|
||||
|
||||
for label in objects(store, &gitlab::accounts_dir(agent)?).await? {
|
||||
let path = gitlab::account_path(agent, &label)?;
|
||||
if let Some(a) = store.read_optional::<gitlab::Account>(&path).await? {
|
||||
out.push(LinkedAccount {
|
||||
kind: AccountKind::Gitlab,
|
||||
name: label,
|
||||
host: Some(a.url),
|
||||
reserved: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let path = github::account_path(agent)?;
|
||||
if store
|
||||
.read_optional::<github::Credential>(&path)
|
||||
|
|
@ -413,7 +430,7 @@ where
|
|||
remove(store, path).await
|
||||
}
|
||||
|
||||
/// Delete the forge account or GitHub token at `path`.
|
||||
/// Delete the forge account, GitLab account or GitHub token at `path`.
|
||||
async fn remove_plain<T: DeserializeOwned + Send>(
|
||||
store: &impl AccountStore,
|
||||
path: &str,
|
||||
|
|
@ -544,6 +561,53 @@ pub async fn delete_forge_account(
|
|||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// Delete an agent's GitLab account from the store, every version of it.
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/hives/{hive}/agents/{agent}/gitlab-accounts/{label}",
|
||||
params(
|
||||
("hive" = String, Path, description = "hive the agent runs on"),
|
||||
("agent" = String, Path, description = "agent the account belongs to"),
|
||||
("label" = String, Path, description = "the account's label"),
|
||||
),
|
||||
responses(
|
||||
(status = 204, description = "deleted"),
|
||||
(status = 400, description = "the agent or label is not an identifier, or the hive is not in this swarm (problem+json)", body = String),
|
||||
(status = 404, description = "no such account is stored (problem+json)", body = String),
|
||||
(status = 500, description = "the store could not be read or written (problem+json)", body = String),
|
||||
),
|
||||
tag = "agents"
|
||||
)]
|
||||
pub async fn delete_gitlab_account(
|
||||
State(state): State<AppState>,
|
||||
axum::extract::Path((hive, agent, label)): axum::extract::Path<(String, String, String)>,
|
||||
) -> Result<StatusCode, problem_details::ProblemDetails> {
|
||||
let hive = swarm_hive(&state, &hive).map_err(|(s, d)| error_problem(s, &d))?;
|
||||
let agent = hive_types::Ident::parse(&agent)
|
||||
.map_err(|reason| error_problem(StatusCode::BAD_REQUEST, reason))?
|
||||
.into_string();
|
||||
let label = hive_types::Ident::parse(&label)
|
||||
.map_err(|reason| error_problem(StatusCode::BAD_REQUEST, reason))?
|
||||
.into_string();
|
||||
let path = gitlab::account_path(&agent, &label)
|
||||
.map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e.to_string()))?;
|
||||
|
||||
let store = state.accounts.open().await.map_err(|e| {
|
||||
tracing::warn!(error = %e, "connecting to the swarm secret store failed");
|
||||
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
|
||||
})?;
|
||||
remove_plain::<gitlab::Account>(&store, &path)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::warn!(%hive, %agent, %label, error = ?e, "deleting the gitlab account failed");
|
||||
e.problem()
|
||||
})?;
|
||||
crate::link_egress::removed(&state, &agent, &crate::link_egress::gitlab_source(&label)).await?;
|
||||
|
||||
tracing::info!(%hive, %agent, %label, "gitlab account deleted");
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// Delete an agent's GitHub token from the store, every version of it.
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
|
|
@ -645,7 +709,7 @@ pub(crate) mod tests {
|
|||
AccountKind, AccountStore, LinkedAccount, Removal, linked_accounts, remove_matrix,
|
||||
remove_plain,
|
||||
};
|
||||
use swarm_secret_client::{forge, github};
|
||||
use swarm_secret_client::{forge, github, gitlab};
|
||||
|
||||
/// Objects by path. A list answers the next segment of every path under
|
||||
/// the directory, with a trailing `/` when it goes deeper, as the store
|
||||
|
|
@ -773,6 +837,10 @@ pub(crate) mod tests {
|
|||
"swarm/agents/atlas/forge/myforge",
|
||||
json!({"value": "t0k3n-forge", "url": "https://forge.example.org"}),
|
||||
)
|
||||
.with(
|
||||
"swarm/agents/atlas/gitlab/work",
|
||||
json!({"value": "t0k3n-gl", "url": "https://gitlab.example.org"}),
|
||||
)
|
||||
.with(
|
||||
"swarm/agents/atlas/github-token",
|
||||
json!({"value": "t0k3n-gh"}),
|
||||
|
|
@ -820,6 +888,12 @@ pub(crate) mod tests {
|
|||
Some("https://forge.example.org"),
|
||||
false
|
||||
),
|
||||
row(
|
||||
AccountKind::Gitlab,
|
||||
"work",
|
||||
Some("https://gitlab.example.org"),
|
||||
false
|
||||
),
|
||||
row(AccountKind::Github, "github", Some("github.com"), false),
|
||||
row(AccountKind::Backend, "backend-env", None, false),
|
||||
]
|
||||
|
|
@ -887,6 +961,9 @@ pub(crate) mod tests {
|
|||
remove_plain::<forge::Account>(&store, "swarm/agents/atlas/forge/myforge")
|
||||
.await
|
||||
.expect("stored");
|
||||
remove_plain::<gitlab::Account>(&store, "swarm/agents/atlas/gitlab/work")
|
||||
.await
|
||||
.expect("stored");
|
||||
remove_plain::<github::Credential>(&store, "swarm/agents/atlas/github-token")
|
||||
.await
|
||||
.expect("stored");
|
||||
|
|
@ -898,6 +975,7 @@ pub(crate) mod tests {
|
|||
[
|
||||
"swarm/agents/atlas/matrix/catgirl",
|
||||
"swarm/agents/atlas/forge/myforge",
|
||||
"swarm/agents/atlas/gitlab/work",
|
||||
"swarm/agents/atlas/github-token",
|
||||
"swarm/agents/atlas/backend-env",
|
||||
]
|
||||
|
|
@ -915,6 +993,7 @@ pub(crate) mod tests {
|
|||
)
|
||||
.await,
|
||||
remove_plain::<forge::Account>(&store, "swarm/agents/atlas/forge/myforge").await,
|
||||
remove_plain::<gitlab::Account>(&store, "swarm/agents/atlas/gitlab/work").await,
|
||||
remove_plain::<github::Credential>(&store, "swarm/agents/atlas/github-token").await,
|
||||
remove_plain::<IgnoredAny>(&store, "swarm/agents/atlas/backend-env").await,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ mod forge;
|
|||
mod forge_account;
|
||||
mod forge_events;
|
||||
mod github_account;
|
||||
mod gitlab_account;
|
||||
mod held_deploys;
|
||||
mod inference;
|
||||
mod issue_report;
|
||||
|
|
|
|||
|
|
@ -31,9 +31,9 @@ use crate::{
|
|||
agent_control, agent_custom, agent_icon, agent_input, agent_limits, agent_login,
|
||||
agent_permissions, agent_renewal, agent_state_stream, agent_status, agents_md, alerts,
|
||||
archetypes, auth, backend_env, builder, claude_login, config_pr, egress_allow, forge,
|
||||
forge_account, github_account, inference, issue_report, linked_accounts, matrix_account,
|
||||
message_stream, notify_flows, notify_router_api, pulls, queue_identity, review_classifier,
|
||||
schedules, status, subagent_term, term_stream, wanted, webhook,
|
||||
forge_account, github_account, gitlab_account, inference, issue_report, linked_accounts,
|
||||
matrix_account, message_stream, notify_flows, notify_router_api, pulls, queue_identity,
|
||||
review_classifier, schedules, status, subagent_term, term_stream, wanted, webhook,
|
||||
};
|
||||
|
||||
/// Where the daemon binds, overridable via `SWARM_CONTROLLER_SOCKET`.
|
||||
|
|
@ -587,6 +587,10 @@ fn linked_account_routes(router: OpenApiRouter<AppState>) -> OpenApiRouter<AppSt
|
|||
forge_account::put_forge_account,
|
||||
linked_accounts::delete_forge_account
|
||||
))
|
||||
.routes(routes!(
|
||||
gitlab_account::put_gitlab_account,
|
||||
linked_accounts::delete_gitlab_account
|
||||
))
|
||||
.routes(routes!(
|
||||
github_account::put_github_account,
|
||||
linked_accounts::delete_github_account
|
||||
|
|
|
|||
260
swarm-secret-client/src/gitlab.rs
Normal file
260
swarm-secret-client/src/gitlab.rs
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
//! The GitLab agreement: where an agent's accounts on GitLab instances live in
|
||||
//! the store, and what the objects at those paths hold.
|
||||
//!
|
||||
//! An operator hands `swarm-controller` a base URL and a token, the controller
|
||||
//! stores them at [`account_path`], and `nix/agent-modules/gitlab-accounts.nix`
|
||||
//! lists [`accounts_dir`] and reads each account under the agent's own
|
||||
//! certificate. The directory is named apart from [`crate::forge`]'s `forge/`,
|
||||
//! so a label of one kind is never read as the other.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
Error,
|
||||
path::{Kind, checked_segment, principal_prefix},
|
||||
};
|
||||
|
||||
/// The path holding `agent`'s account on the GitLab instance it calls `label`.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::PathSegment`] when either name contains anything but
|
||||
/// `[A-Za-z0-9_-]`, which is what keeps one agent's name from addressing
|
||||
/// another agent's secret.
|
||||
pub fn account_path(agent: &str, label: &str) -> Result<String, Error> {
|
||||
checked_segment("label", label)?;
|
||||
Ok(format!("{}/{label}", accounts_dir(agent)?))
|
||||
}
|
||||
|
||||
/// The directory every one of `agent`'s [`account_path`]s is under, in the
|
||||
/// form [`crate::SecretStore::list`] takes.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::PathSegment`] when `agent` contains anything but `[A-Za-z0-9_-]`.
|
||||
pub fn accounts_dir(agent: &str) -> Result<String, Error> {
|
||||
let prefix = principal_prefix(Kind::Agent, agent)?;
|
||||
Ok(format!("{prefix}/gitlab"))
|
||||
}
|
||||
|
||||
/// The shape of an instance URL the agent end accepts, as a regular
|
||||
/// expression: `gitlab-accounts.nix` filters every stored account through
|
||||
/// exactly this pattern (a `jq` `test`) and leaves out one that doesn't match.
|
||||
/// [`is_instance_url`] is the same rule in Rust, which `swarm-controller`
|
||||
/// applies before it stores an account, so a stored URL is always one the
|
||||
/// agent uses. A module-eval case pins the nix side to this literal.
|
||||
pub const URL_PATTERN: &str = "^https?://[A-Za-z0-9.-]+(:[0-9]+)?(/[A-Za-z0-9._~/-]*)?$";
|
||||
|
||||
/// Whether `url` matches [`URL_PATTERN`]: `http` or `https`, a host of letters,
|
||||
/// digits, dots and hyphens, an optional numeric port, and an optional path of
|
||||
/// letters, digits and `. _ ~ / -`. No user information, query or fragment.
|
||||
#[must_use]
|
||||
pub fn is_instance_url(url: &str) -> bool {
|
||||
let Some(rest) = url
|
||||
.strip_prefix("https://")
|
||||
.or_else(|| url.strip_prefix("http://"))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let (authority, path) = rest.find('/').map_or((rest, ""), |i| rest.split_at(i));
|
||||
let (host, port) = match authority.split_once(':') {
|
||||
Some((host, port)) => (host, Some(port)),
|
||||
None => (authority, None),
|
||||
};
|
||||
!host.is_empty()
|
||||
&& host
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-')
|
||||
&& port.is_none_or(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
|
||||
&& path
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b"._~/-".contains(&b))
|
||||
}
|
||||
|
||||
/// What an [`account_path`] holds.
|
||||
///
|
||||
/// No `Debug` derive: `value` is a live GitLab credential, and a derived
|
||||
/// `Debug` is one `{:?}` away from a log line.
|
||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Account {
|
||||
/// The access token. `gitlab-accounts.nix` reads this field by name.
|
||||
pub value: String,
|
||||
|
||||
/// The instance's base URL, without a trailing slash. Not secret.
|
||||
/// `gitlab-accounts.nix` reads this field by name too.
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Account {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Account")
|
||||
.field("value", &"<redacted>")
|
||||
.field("url", &self.url)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Whether `policy` has a stanza whose prefix `path` starts with.
|
||||
fn covers(policy: &str, path: &str) -> bool {
|
||||
policy.lines().any(|line| {
|
||||
line.strip_prefix("path \"")
|
||||
.and_then(|rest| rest.split_once("\" {"))
|
||||
.and_then(|(p, _)| p.strip_suffix('*'))
|
||||
.is_some_and(|prefix| path.starts_with(prefix))
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_account_and_its_directory_land_under_the_agent_prefix() {
|
||||
// Spelled out: `gitlab-accounts.nix` and `swarm-bao.nix` spell the
|
||||
// same strings.
|
||||
assert_eq!(
|
||||
account_path("atlas", "work").expect("both segments are legal"),
|
||||
"swarm/agents/atlas/gitlab/work"
|
||||
);
|
||||
assert_eq!(
|
||||
accounts_dir("atlas").expect("legal"),
|
||||
"swarm/agents/atlas/gitlab"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_gitlab_directory_is_not_the_forgejo_one() {
|
||||
let gitlab = accounts_dir("atlas").expect("legal");
|
||||
let forge = crate::forge::accounts_dir("atlas").expect("legal");
|
||||
assert_ne!(gitlab, forge);
|
||||
assert!(!gitlab.starts_with(&format!("{forge}/")), "{gitlab}");
|
||||
assert!(!forge.starts_with(&format!("{gitlab}/")), "{forge}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_that_could_escape_the_directory_is_refused() {
|
||||
for bad in ["../argus", "a/b", "a.b", ""] {
|
||||
assert!(account_path("atlas", bad).is_err(), "label {bad:?}");
|
||||
assert!(account_path(bad, "work").is_err(), "agent {bad:?}");
|
||||
assert!(accounts_dir(bad).is_err(), "agent {bad:?}");
|
||||
}
|
||||
// The control: the legal charset stays reachable.
|
||||
assert!(account_path("a-b_C9", "d-e0").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_agents_own_grants_list_its_accounts_and_read_each() {
|
||||
let policy = crate::policy::effective_agent("atlas");
|
||||
let dir = accounts_dir("atlas").expect("legal");
|
||||
assert!(covers(&policy, &format!("secret/metadata/{dir}/")));
|
||||
assert!(covers(
|
||||
&policy,
|
||||
&format!(
|
||||
"secret/data/{}",
|
||||
account_path("atlas", "work").expect("legal")
|
||||
)
|
||||
));
|
||||
// The control: another agent's accounts are outside both grants.
|
||||
let other = accounts_dir("argus").expect("legal");
|
||||
assert!(!covers(&policy, &format!("secret/metadata/{other}/")));
|
||||
assert!(!covers(
|
||||
&policy,
|
||||
&format!(
|
||||
"secret/data/{}",
|
||||
account_path("argus", "work").expect("legal")
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_agent_can_write_nothing_under_its_accounts() {
|
||||
// Egress is derived from these leaves, so an agent that could write
|
||||
// one would choose its own allowed hosts.
|
||||
let policy = crate::policy::effective_agent("atlas");
|
||||
let writes = |path: &str| {
|
||||
policy
|
||||
.split("path \"")
|
||||
.skip(1)
|
||||
.filter(|stanza| {
|
||||
stanza.split_once("\" {").is_some_and(|(p, _)| {
|
||||
p.strip_suffix('*')
|
||||
.map_or(p == path, |prefix| path.starts_with(prefix))
|
||||
})
|
||||
})
|
||||
.any(|stanza| {
|
||||
["create", "update", "delete"]
|
||||
.iter()
|
||||
.any(|cap| stanza.contains(&format!("\"{cap}\"")))
|
||||
})
|
||||
};
|
||||
let leaf = format!(
|
||||
"secret/data/{}",
|
||||
account_path("atlas", "work").expect("legal")
|
||||
);
|
||||
assert!(!writes(&leaf), "{policy}");
|
||||
// The control: the same scan finds the one write the agent has.
|
||||
let seed = format!(
|
||||
"secret/data/{}/{}",
|
||||
principal_prefix(Kind::Agent, "atlas").expect("legal"),
|
||||
crate::backend_env::LEAF
|
||||
);
|
||||
assert!(writes(&seed), "{policy}");
|
||||
}
|
||||
|
||||
/// One table for the rule: the same rows were fed to `jq`'s `test` with
|
||||
/// [`URL_PATTERN`] when this was written, and the module-eval case pins
|
||||
/// `gitlab-accounts.nix` to the same pattern.
|
||||
#[test]
|
||||
fn the_instance_url_rule_takes_exactly_the_documented_shapes() {
|
||||
for ok in [
|
||||
"https://gitlab.example.org",
|
||||
"http://gitlab.lan",
|
||||
"https://GitLab.Example.org:8443",
|
||||
"https://gitlab.example.org/gl",
|
||||
"https://gitlab.example.org/apps/gitlab_2/x~y.z-w",
|
||||
"https://10.0.0.5",
|
||||
] {
|
||||
assert!(is_instance_url(ok), "{ok}");
|
||||
}
|
||||
for bad in [
|
||||
"",
|
||||
"ftp://gitlab.example.org",
|
||||
"gitlab.example.org",
|
||||
"https://",
|
||||
"https://:8443",
|
||||
"https://gitlab.example.org:",
|
||||
"https://gitlab.example.org:80a",
|
||||
"https://user@gitlab.example.org",
|
||||
"https://gitlab.example.org/@x",
|
||||
"https://gitlab.example.org/?x=1",
|
||||
"https://gitlab.example.org?x=1",
|
||||
"https://gitlab.example.org/#frag",
|
||||
"https://gitlab.example.org/a b",
|
||||
"https://gitlab.example.org/a%20b",
|
||||
"https://gitlab.example.org/a:b",
|
||||
"https://gitlab_x.example.org",
|
||||
"https://[::1]",
|
||||
] {
|
||||
assert!(!is_instance_url(bad), "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_account_fields_match_what_the_nix_reader_asks_for() {
|
||||
let json = serde_json::to_string(&Account {
|
||||
value: "t".to_owned(),
|
||||
url: "https://gitlab.example.org".to_owned(),
|
||||
})
|
||||
.expect("two Strings serialise");
|
||||
assert_eq!(json, r#"{"value":"t","url":"https://gitlab.example.org"}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_never_prints_an_accounts_value() {
|
||||
let a = Account {
|
||||
value: "0123456789abcdef".to_owned(),
|
||||
url: "https://gitlab.example.org".to_owned(),
|
||||
};
|
||||
let shown = format!("{a:?}");
|
||||
assert!(!shown.contains("0123456789abcdef"), "{shown}");
|
||||
assert!(shown.contains("gitlab.example.org"), "{shown}");
|
||||
}
|
||||
}
|
||||
|
|
@ -42,6 +42,7 @@ pub mod claude_login;
|
|||
pub mod client;
|
||||
pub mod forge;
|
||||
pub mod github;
|
||||
pub mod gitlab;
|
||||
pub mod grafana;
|
||||
pub mod matrix;
|
||||
pub mod mtls;
|
||||
|
|
|
|||
Loading…
Reference in a new issue