13 Commits

Author SHA1 Message Date
benvin 47118215b4 Merge pull request 'Make agentpr's Vault gitea creds path selectable' (#8) from benvin/agentpr-creds-path into main
ci/woodpecker/tag/release Pipeline was successful
Reviewed-on: #8
2026-08-30 09:36:22 +10:00
unkin-agent 26cd05e961 Make the Vault gitea creds path selectable
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
agentpr always read gitea/creds/unkin-agent from a bare const, so a service
like repospawner could not run it as its own Gitea identity.

- Replace the GiteaCredsPath const with a function: GITEA_CREDS_PATH when set,
  otherwise gitea/creds/<AGENT_LOGIN>. Unset env still resolves to
  gitea/creds/unkin-agent, so existing callers are unchanged.
- Thread the creds path through fetchGiteaToken/readGiteaCreds instead of
  reading a package-level const, and report it in the error messages.
- Make agentpr's help text login-agnostic and document both variables.
2026-08-30 00:50:35 +10:00
benvin 68805a8cde Merge pull request 'Add agentvault with a seed-outpost subcommand' (#7) from benvin/agentvault-seed-outpost into main
ci/woodpecker/tag/release Pipeline was successful
Reviewed-on: #7
2026-08-29 21:58:01 +10:00
unkin-agent 61bb464e32 Add agentvault with a seed-outpost subcommand
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Interactive agents are classifier-blocked from plumbing credentials through
a shell, so seeding an Authentik outpost token into Vault KV needs to happen
inside one binary invocation that never exposes the secret.

- Add cmd/agentvault, a fourth CLI sharing the agentpr Vault AppRole login
  (role_id only, VAULT_ADDR/AGENT_APPROLE_ROLE_ID defaults unchanged).
- Add `agentvault seed-outpost`: read the Authentik API token from
  kv/service/authentik/agent-api-token (field `token`, falling back to
  `api_token`), exact-match the outpost by name via the instances search,
  fetch its key from /api/v3/core/tokens/<identifier>/view_key/ and write it
  to --dest-path under --dest-key.
- Print only the outpost name, token identifier, dest path and new KV
  version; keep secret material out of results, errors and logs.
- Distinguish the failure stages (login, KV read denied, outpost missing,
  view_key, KV write denied) with ErrVaultDenied/ErrVaultNotFound/
  ErrOutpostNotFound sentinels and actionable messages.
- Add internal/agent vaultkv.go (AppRole-authenticated KV-v2 client) and
  authentik.go (outpost search + view_key) for reuse by future flows.
- Cover the happy path, idempotent re-run, field fallback and every failure
  mode with httptest servers, including a leak check on error strings.
- Wire agentvault into the Makefile, build-rpm.sh, nfpm contents, release
  cross-builds/assets, README and AGENTS.md.
2026-08-29 21:03:31 +10:00
benvin 60b08f1198 Merge pull request 'watchpr: fix watch mode never detecting changes' (#5) from benvin/watchpr-watchloop-fix into main
ci/woodpecker/tag/release Pipeline was successful
Reviewed-on: #5
2026-08-15 15:00:01 +10:00
unkin-agent fda3761ead watchpr: fix watch mode hanging when a merged PR's head commit is gone
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
FetchState fetched the PR (merged=true) but then failed the whole state
fetch when CommitStatus 404'd for a head commit that no longer existed
(the branch was deleted after a squash/rebase merge). The merge signal was
discarded, so the watch loop treated every post-merge poll as a transient
error and never exited -- the 37-minute hang seen in production.

- Add a typed APIError carrying the HTTP status so callers can detect a 404
  without parsing error strings.
- FetchState now tolerates a 404 from CommitStatus (commit gone => no CI
  status) and returns the authoritative merged/closed PR state.
- Regression tests: FetchState survives a 404 status; the full watch loop,
  driven through a real client, detects a merge whose head commit is gone
  (both fail/hang before the fix).
2026-08-15 13:23:25 +10:00
benvin 05cc0874d6 Merge pull request 'Add agentws worktree-management binary' (#4) from benvin/agentws into main
ci/woodpecker/tag/release Pipeline was successful
Reviewed-on: #4
2026-08-15 13:13:06 +10:00
unkin-agent 61e73ada51 agentws: fix golangci-lint findings
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
2026-08-15 12:54:22 +10:00
unkin-agent 6a82b88947 Add agentws worktree-management binary
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline failed
ci/woodpecker/pr/pre-commit Pipeline was successful
agentws manages per-branch git worktrees for the unkin-agent user: it clones
repos into the source root (~/src/prodenv/<repo>) so branches are visible in
Ben's main checkout, and creates isolated worktrees under the worktree root
(~/.cache/agentws/<repo>__<branch>).

- New internal/agent/git.go: small, testable git helpers shelling out to the
  git binary (clone/fetch/worktree add/remove/list/prune, branch + config ops,
  porcelain parsing, path sanitizing). No go-git dependency.
- New cmd/agentws: new / list / rm / clean / token / credential subcommands.
  Auth uses an ephemeral git credential helper (agentws credential get) so the
  ~1h Gitea token is never persisted in a remote URL or config; per-worktree
  config keeps the shared checkout's identity untouched.
- Wire agentws into Makefile, scripts/build-rpm.sh, packaging/nfpm.yaml (binary
  + bash/zsh/fish completions), .woodpecker/release.yaml (cross-compile + assets)
  and .gitignore.
- Tests: table tests for parsing/sanitizing/dir-naming, a real temp-git repo for
  the worktree lifecycle, and hermetic cmd tests (bad input + credential-helper
  host guard) that never touch the network.
- Document agentws in README.md and AGENTS.md.
2026-08-15 12:21:08 +10:00
benvin c6712063bc Merge pull request 'watchpr: detect merge/close in poll loop (was hanging after baseline)' (#3) from benvin/watchpr-poll-loop-fix into main
ci/woodpecker/tag/release Pipeline was successful
Reviewed-on: #3
2026-08-12 23:40:50 +10:00
unkin-agent f915b5ba3b watchpr: detect merge/close in poll loop (was hanging after baseline)
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
watchpr only reported meaningful changes as transitions from the poll
baseline. A PR already merged or closed when the watch started was
captured as the baseline and never produced a transition, so the loop
polled the dead PR forever (process alive, never exiting) -- the
single-PR --interval case observed in production.

Add a terminal-state check applied to the baseline snapshot: a PR that
is already merged or closed the moment watchpr starts is reported and
exits immediately, since it can never change again. Extract the
baseline+poll loop into agent.Watch behind a StateFetcher interface so
the loop, its open->merged/close detection, and its poll-error
resilience are unit-testable with a fake client.
2026-08-12 23:32:28 +10:00
benvin 7d9ae0fcc6 Merge pull request 'Fix non-zero exit on error and debounce transient mergeable=false' (#2) from benvin/followup-fixes into main
ci/woodpecker/tag/release Pipeline was successful
Reviewed-on: #2
2026-08-12 22:22:58 +10:00
unkin-agent 05594113a2 Fix non-zero exit on error and debounce transient mergeable=false
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
agentpr/watchpr already propagated command errors to a non-zero exit, but
that behaviour had no regression coverage and the root command was not
constructible outside main(). watchpr also fired a spurious conflict alert
because Gitea computes mergeability asynchronously and can briefly report
mergeable=false right after a push. The docs additionally printed the
AppRole role_id literal UUID.

- Extract newRootCmd() in both cmd/agentpr and cmd/watchpr so main() only
  runs Execute and exits non-zero on error; add tests asserting Execute
  returns an error for a bad PR ref / malformed --repo / no args.
- Debounce mergeability loss in MeaningfulChange: only alert when
  mergeable=false persists across two consecutive polls (both prev and cur
  false, still open); update the table test for one-poll-false (benign),
  false-persisting (alert), and recovered false->true (benign).
- Refer to AGENT_APPROLE_ROLE_ID by env var in README.md/AGENTS.md without
  printing the literal role_id; keep the code default and env override.
2026-08-12 22:17:10 +10:00
28 changed files with 2754 additions and 110 deletions
+2
View File
@@ -1,7 +1,9 @@
# built binaries (repo root only — not the cmd/ source dirs) # built binaries (repo root only — not the cmd/ source dirs)
/agentpr /agentpr
/watchpr /watchpr
/agentws
# cross-compiled release artifacts (e.g. agentpr-linux-amd64) # cross-compiled release artifacts (e.g. agentpr-linux-amd64)
/agentpr-* /agentpr-*
/watchpr-* /watchpr-*
/agentws-*
dist/ dist/
+3 -3
View File
@@ -17,7 +17,7 @@ steps:
memory: 2Gi memory: 2Gi
cpu: 2 cpu: 2
# Build both binaries into dist/ (consumed by the RPM step) plus the # Build every binary into dist/ (consumed by the RPM step) plus the
# cross-platform binaries attached to the Gitea release. Each tool is a # cross-platform binaries attached to the Gitea release. Each tool is a
# separate main package, so they are built individually per os/arch. # separate main package, so they are built individually per os/arch.
- name: build - name: build
@@ -28,7 +28,7 @@ steps:
# for the shell instead of substituting them (as pipeline vars) at parse # for the shell instead of substituting them (as pipeline vars) at parse
# time. ${CI_COMMIT_TAG} is a real Woodpecker var and stays single-$. # time. ${CI_COMMIT_TAG} is a real Woodpecker var and stays single-$.
- | - |
for entry in "agentpr:./cmd/agentpr" "watchpr:./cmd/watchpr"; do for entry in "agentpr:./cmd/agentpr" "watchpr:./cmd/watchpr" "agentws:./cmd/agentws" "agentvault:./cmd/agentvault"; do
name="$${entry%%:*}"; pkg="$${entry##*:}" name="$${entry%%:*}"; pkg="$${entry##*:}"
for osarch in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do for osarch in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do
os="$${osarch%/*}"; arch="$${osarch#*/}" os="$${osarch%/*}"; arch="$${osarch#*/}"
@@ -135,7 +135,7 @@ steps:
# root; the package step writes the RPM to dist/. Generate a checksums # root; the package step writes the RPM to dist/. Generate a checksums
# manifest over everything we attach so downloads can be verified. # manifest over everything we attach so downloads can be verified.
RPM=$$(ls dist/*.rpm 2>/dev/null | head -1) RPM=$$(ls dist/*.rpm 2>/dev/null | head -1)
ASSETS="agentpr-linux-amd64 agentpr-linux-arm64 agentpr-darwin-amd64 agentpr-darwin-arm64 watchpr-linux-amd64 watchpr-linux-arm64 watchpr-darwin-amd64 watchpr-darwin-arm64" ASSETS="agentpr-linux-amd64 agentpr-linux-arm64 agentpr-darwin-amd64 agentpr-darwin-arm64 watchpr-linux-amd64 watchpr-linux-arm64 watchpr-darwin-amd64 watchpr-darwin-arm64 agentws-linux-amd64 agentws-linux-arm64 agentws-darwin-amd64 agentws-darwin-arm64 agentvault-linux-amd64 agentvault-linux-arm64 agentvault-darwin-amd64 agentvault-darwin-arm64"
[ -n "$$RPM" ] && ASSETS="$$ASSETS $$RPM" [ -n "$$RPM" ] && ASSETS="$$ASSETS $$RPM"
sha256sum $$ASSETS > sha256sums.txt sha256sum $$ASSETS > sha256sums.txt
tea releases assets create "${CI_COMMIT_TAG}" $$ASSETS sha256sums.txt \ tea releases assets create "${CI_COMMIT_TAG}" $$ASSETS sha256sums.txt \
+70 -14
View File
@@ -2,9 +2,11 @@
## Project Overview ## Project Overview
This repo ships two Gitea-automation CLIs in one RPM (`agent-tools`). Both act This repo ships several Gitea-automation CLIs in one RPM (`agent-tools`). They
as the `unkin-agent` user by minting a scoped Gitea token from Vault, so act as an agent user (`unkin-agent` by default) by minting a scoped Gitea token
actions are attributed to the agent rather than to whoever runs the tool. from Vault, so actions are attributed to the agent rather than to whoever runs
the tool. Setting `AGENT_LOGIN` selects a different agent identity, so a service
like repospawner can run these tools as itself.
- **`agentpr`** — create pull requests and post PR comments as `unkin-agent` - **`agentpr`** — create pull requests and post PR comments as `unkin-agent`
(fixes the "tea posts as Ben" attribution problem). Subcommands: (fixes the "tea posts as Ben" attribution problem). Subcommands:
@@ -13,25 +15,36 @@ actions are attributed to the agent rather than to whoever runs the tool.
meaningfully: it merges/closes, gets a new non-agent comment, its CI fails, meaningfully: it merges/closes, gets a new non-agent comment, its CI fails,
or it loses mergeability. Benign transitions (CI pending→success, the agent's or it loses mergeability. Benign transitions (CI pending→success, the agent's
own comments) are ignored. own comments) are ignored.
- **`agentws`** — manage per-branch git worktrees for `unkin-agent`. Clones
repos into the source root (`~/src/prodenv/<repo>`), creates worktrees under
the worktree root (`~/.cache/agentws/<repo>__<branch>`), and authenticates
clone/fetch/push via an ephemeral credential helper. Subcommands: `new`,
`list`, `rm`, `clean`, `token`, `credential`.
Both tools are separate `main` packages under `cmd/` and share the All tools are separate `main` packages under `cmd/` and share the
`internal/agent` package (Vault AppRole login, Gitea REST client, PR-ref `internal/agent` package (Vault AppRole login, Gitea REST client, PR-ref
parsing, watch-state comparison). parsing, watch-state comparison, git worktree helpers).
## Structure ## Structure
``` ```
cmd/agentpr/main.go # agentpr CLI (pr create / pr comment / whoami) cmd/agentpr/main.go # agentpr CLI (pr create / pr comment / whoami)
cmd/watchpr/main.go # watchpr CLI (poll + meaningful-change exit) cmd/watchpr/main.go # watchpr CLI (poll + meaningful-change exit)
cmd/agentws/main.go # agentws CLI (new / list / rm / clean / token / credential)
cmd/agentvault/main.go # agentvault CLI (seed-outpost)
internal/agent/ # shared plumbing: internal/agent/ # shared plumbing:
token.go # env config + in-process Gitea-token cache token.go # env config + in-process Gitea-token cache
vault.go # AppRole login + read gitea/creds/unkin-agent vault.go # AppRole login + read the gitea creds path
gitea.go # Gitea REST client (PR create/get, comments, status, whoami) gitea.go # Gitea REST client (PR create/get, comments, status, whoami)
parse.go # owner/repo#N and owner/repo parsing parse.go # owner/repo#N and owner/repo parsing
watch.go # PRState snapshot + MeaningfulChange comparison watch.go # PRState snapshot + MeaningfulChange comparison
git.go # git worktree/clone/fetch helpers (os/exec, no go-git)
vaultkv.go # AppRole-authenticated Vault client + KV-v2 read/write
authentik.go # Authentik REST client (outpost search, token view_key)
seedoutpost.go # seed-outpost flow (Authentik token -> Vault KV)
go.mod # module git.unkin.net/unkin/agent-tools go.mod # module git.unkin.net/unkin/agent-tools
Makefile # build / test / lint / completions / rpm / version-bump Makefile # build / test / lint / completions / rpm / version-bump
packaging/nfpm.yaml # nfpm spec (envsubst-templated) for the RPM (both binaries) packaging/nfpm.yaml # nfpm spec (envsubst-templated) for the RPM (all binaries)
scripts/build-rpm.sh # generates completions + packages the RPM with nfpm scripts/build-rpm.sh # generates completions + packages the RPM with nfpm
.woodpecker/ # CI: build, test, pre-commit (PR) + release (tag) .woodpecker/ # CI: build, test, pre-commit (PR) + release (tag)
dist/ # build output: binaries, completions, RPM (not committed) dist/ # build output: binaries, completions, RPM (not committed)
@@ -42,25 +55,47 @@ own `-o` (a single `go build ./...` can't emit multiple mains to one file).
## Token acquisition (shared) ## Token acquisition (shared)
Both tools call `agent.GiteaToken()`, which (once per process): All tools call `agent.GiteaToken()`, which (once per process):
1. AppRole login: `POST $VAULT_ADDR/v1/auth/approle/login` with `role_id` only 1. AppRole login: `POST $VAULT_ADDR/v1/auth/approle/login` with `role_id` only
(no `secret_id`) → `client_token`. (no `secret_id`) → `client_token`.
2. `GET $VAULT_ADDR/v1/gitea/creds/unkin-agent` with `X-Vault-Token``.data.token`. 2. `GET $VAULT_ADDR/v1/<creds path>` with `X-Vault-Token``.data.token`, where
the creds path is `GITEA_CREDS_PATH` if set, else `gitea/creds/$AGENT_LOGIN`
(so unset env still reads `gitea/creds/unkin-agent`).
Config via env (all have defaults): Config via env (all have defaults):
| Variable | Default | Purpose | | Variable | Default | Purpose |
|---|---|---| |---|---|---|
| `VAULT_ADDR` | `https://vault.service.consul:8200` | Vault/OpenBao address | | `VAULT_ADDR` | `https://vault.service.consul:8200` | Vault/OpenBao address |
| `AGENT_APPROLE_ROLE_ID` | `ababbcd3-9c77-5c6a-be2d-287fce9214a6` | AppRole role_id | | `AGENT_APPROLE_ROLE_ID` | built-in default | AppRole role_id (overridable) |
| `GITEA_URL` | `https://git.unkin.net` | Gitea base URL | | `GITEA_URL` | `https://git.unkin.net` | Gitea base URL |
| `AGENT_LOGIN` | `unkin-agent` | login whose comments watchpr ignores | | `AGENT_LOGIN` | `unkin-agent` | agent identity: selects `gitea/creds/<login>`; login whose comments watchpr ignores; agentws git identity |
| `GITEA_CREDS_PATH` | `gitea/creds/$AGENT_LOGIN` | Vault path minting the Gitea token (wins over `AGENT_LOGIN`) |
| `AGENTWS_SRC_ROOT` | `~/src/prodenv` | agentws source-of-truth checkout root |
| `AGENTWS_ROOT` | `~/.cache/agentws` | agentws worktree root |
| `AGENTWS_OWNER` | `unkin` | Gitea org that owns agentws-managed repos |
| `AUTHENTIK_URL` | `https://identity.k8s.syd1.au.unkin.net` | Authentik base URL (`agentvault`) |
### agentws git auth (ephemeral credential helper)
Gitea tokens are ~1h ephemeral, so `agentws` never bakes one into a remote URL
or config. `agentws token` prints a fresh token; `agentws credential get`
implements the git credential protocol (reads the key=value request on stdin,
and for the configured Gitea host only emits `username=$AGENT_LOGIN` +
`password=<fresh token>`). `agentws new` wires this per worktree — it enables
`extensions.worktreeConfig` on the repo once, then writes `user.name`,
`user.email` and `credential.helper = !<agentws> credential` to the
**per-worktree** config so the shared checkout's identity/config is untouched.
Clone/fetch pass the same helper transiently via `-c credential.helper=...`.
Worktrees are created FROM `~/src/prodenv/<repo>` (`git worktree add`) so agent
branches are visible in Ben's main checkout; `rm`/`clean` fetch there afterwards
to keep the default branch current.
## Build ## Build
```bash ```bash
make build # -> dist/agentpr, dist/watchpr (CGO disabled, static) make build # -> dist/agentpr, dist/watchpr, dist/agentws, dist/agentvault (CGO disabled, static)
``` ```
Requires Go 1.21+. Dependency: `github.com/spf13/cobra` (CLI). Requires Go 1.21+. Dependency: `github.com/spf13/cobra` (CLI).
@@ -68,11 +103,12 @@ Requires Go 1.21+. Dependency: `github.com/spf13/cobra` (CLI).
## Packaging (RPM) ## Packaging (RPM)
```bash ```bash
make rpm # build both binaries + package into dist/*.rpm via nfpm make rpm # build all binaries + package into dist/*.rpm via nfpm
``` ```
`scripts/build-rpm.sh` generates bash/zsh/fish completions from the built `scripts/build-rpm.sh` generates bash/zsh/fish completions from the built
binaries and bundles them alongside `/usr/bin/agentpr` and `/usr/bin/watchpr`. binaries and bundles them alongside `/usr/bin/agentpr`, `/usr/bin/watchpr`,
`/usr/bin/agentws` and `/usr/bin/agentvault`.
On a `v*` tag the release pipeline builds the RPM and `PUT`s it to the On a `v*` tag the release pipeline builds the RPM and `PUT`s it to the
artifactapi `rpm-internal` repo, then cuts a Gitea release. artifactapi `rpm-internal` repo, then cuts a Gitea release.
@@ -94,10 +130,30 @@ alerting transitions), request-body construction, and the Vault+Gitea client
against `httptest` servers (fake AppRole login + gitea creds + PR create / against `httptest` servers (fake AppRole login + gitea creds + PR create /
comment / whoami / status). No live Vault/Gitea access is required for tests. comment / whoami / status). No live Vault/Gitea access is required for tests.
## agentvault seed-outpost
`agentvault seed-outpost --outpost <name> --dest-path <kv/path>` does the whole
flow in-process:
1. AppRole login (shared `approleLogin`), then KV-v2 read of
`kv/service/authentik/agent-api-token` (field `token`, falling back to
`api_token`).
2. `GET /api/v3/outposts/instances/?search=<name>` — Authentik's `search` is a
substring match, so the exact `name` is re-checked client-side.
3. `GET /api/v3/core/tokens/<token_identifier>/view_key/` for the key.
4. KV-v2 write to `--dest-path` under `--dest-key` (default `token`).
Only the outpost name, token identifier, dest path and new KV version are
printed. Errors are wrapped per stage (login / read denied / outpost missing /
view_key / write denied) via the `ErrVaultDenied`, `ErrVaultNotFound` and
`ErrOutpostNotFound` sentinels.
## Gotchas ## Gotchas
- `watchpr` exits 0 with no output changes on `--once` (just prints state). - `watchpr` exits 0 with no output changes on `--once` (just prints state).
- The token cache is process-wide (`sync.Once`); tests call the unexported - The token cache is process-wide (`sync.Once`); tests call the unexported
`fetchGiteaToken` to avoid it. `fetchGiteaToken` to avoid it.
- `agentvault` never puts a secret in an error string: Vault decode failures and
Authentik `view_key` responses are reported without their bodies.
- CI "combined status" comes from `/commits/{sha}/status`; an empty head SHA - CI "combined status" comes from `/commits/{sha}/status`; an empty head SHA
yields an empty state without an API call. yields an empty state without an API call.
+1 -1
View File
@@ -1,6 +1,6 @@
# All shipped binaries and the package path each is built from. Both tools live # All shipped binaries and the package path each is built from. Both tools live
# under cmd/; the module root ships no binary of its own. # under cmd/; the module root ships no binary of its own.
BINARIES := agentpr watchpr BINARIES := agentpr watchpr agentws agentvault
DIST := dist DIST := dist
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
GOFLAGS := -ldflags="-s -w -X main.version=$(VERSION)" GOFLAGS := -ldflags="-s -w -X main.version=$(VERSION)"
+106 -11
View File
@@ -1,33 +1,44 @@
# agent-tools # agent-tools
Two small Gitea-automation CLIs, shipped together in one RPM (`agent-tools`). Small Gitea-automation CLIs, shipped together in one RPM (`agent-tools`). They
Both act as the **`unkin-agent`** user by minting a scoped Gitea token from act as an agent user (**`unkin-agent`** by default) by minting a scoped Gitea
Vault, so automated PRs and comments are attributed to the agent — not to token from Vault, so automated PRs, comments and pushes are attributed to the
whoever happens to run the command. agent — not to whoever happens to run the command. Set `AGENT_LOGIN` to act as a
different agent identity.
- **`agentpr`** — create pull requests and post PR comments as `unkin-agent`. - **`agentpr`** — create pull requests and post PR comments as the agent user.
- **`watchpr`** — poll one or more PRs and exit when one changes in a way worth - **`watchpr`** — poll one or more PRs and exit when one changes in a way worth
acting on. acting on.
- **`agentws`** — manage per-branch git worktrees for `unkin-agent`, cloning
into Ben's source checkout and isolating agent work under the XDG cache.
- **`agentvault`** — run deterministic Vault flows in one invocation, so agents
never plumb secret material through a shell.
## How it gets a token ## How it gets a token
On first use each tool performs a Vault AppRole login (`role_id` only, no On first use each tool performs a Vault AppRole login (`role_id` only, no
`secret_id`), then reads `gitea/creds/unkin-agent` to obtain a short-lived Gitea `secret_id`), then reads `gitea/creds/$AGENT_LOGIN` — or `GITEA_CREDS_PATH` when
token, cached in-process for the run. set — to obtain a short-lived Gitea token, cached in-process for the run. With
neither variable set that is `gitea/creds/unkin-agent`, as before.
Everything is configured by environment variables, all with defaults: Everything is configured by environment variables, all with defaults:
| Variable | Default | Purpose | | Variable | Default | Purpose |
|---|---|---| |---|---|---|
| `VAULT_ADDR` | `https://vault.service.consul:8200` | Vault/OpenBao address | | `VAULT_ADDR` | `https://vault.service.consul:8200` | Vault/OpenBao address |
| `AGENT_APPROLE_ROLE_ID` | `ababbcd3-9c77-5c6a-be2d-287fce9214a6` | AppRole role_id | | `AGENT_APPROLE_ROLE_ID` | built-in default | AppRole role_id (overridable) |
| `GITEA_URL` | `https://git.unkin.net` | Gitea base URL | | `GITEA_URL` | `https://git.unkin.net` | Gitea base URL |
| `AGENT_LOGIN` | `unkin-agent` | login whose comments `watchpr` ignores | | `AGENT_LOGIN` | `unkin-agent` | agent identity: selects `gitea/creds/<login>`, and the login whose comments `watchpr` ignores |
| `GITEA_CREDS_PATH` | `gitea/creds/$AGENT_LOGIN` | Vault path minting the Gitea token (wins over `AGENT_LOGIN`) |
| `AGENTWS_SRC_ROOT` | `~/src/prodenv` | source-of-truth checkout root (`agentws`) |
| `AGENTWS_ROOT` | `~/.cache/agentws` | worktree root (`agentws`) |
| `AGENTWS_OWNER` | `unkin` | Gitea org that owns the repos (`agentws`) |
| `AUTHENTIK_URL` | `https://identity.k8s.syd1.au.unkin.net` | Authentik base URL (`agentvault`) |
## agentpr ## agentpr
```bash ```bash
# Verify identity (should print: unkin-agent) # Verify identity (prints the agent login, unkin-agent by default)
agentpr whoami agentpr whoami
# Open a PR # Open a PR
@@ -67,10 +78,94 @@ watchpr --once --json unkin/argocd-apps#42
On a meaningful change `watchpr` prints the reason and the PR's current state, On a meaningful change `watchpr` prints the reason and the PR's current state,
then exits 0. Use `--json` for machine-readable output. then exits 0. Use `--json` for machine-readable output.
## agentws
`agentws` gives an agent an isolated git worktree per branch without disturbing
Ben's shared checkouts. Repos are cloned into the **source root**
(`~/src/prodenv/<repo>`) so branches created here are visible in the main
checkout too; the worktrees themselves live under the **worktree root**
(`~/.cache/agentws/<repo>__<branch>`).
```bash
# Clone unkin/argocd-apps into ~/src/prodenv if missing, then add a worktree for
# a new branch off the remote default branch. Prints the worktree path.
agentws new argocd-apps --branch benvin/my-change
# Branch off a specific base instead of the remote default
agentws new argocd-apps --branch benvin/hotfix --from release-1.2
# List managed worktrees (repo, branch, path)
agentws list
# Remove a worktree (by path or branch); refreshes the source repo afterwards
agentws rm benvin/my-change
agentws rm ~/.cache/agentws/argocd-apps__benvin-my-change --delete-branch
# Remove every managed worktree and prune each source repo
agentws clean
# Print a fresh Gitea token for the agent login
agentws token
```
### Auth / credential-helper design
Gitea tokens minted from Vault are short-lived (~1h), so `agentws` never
persists one in a remote URL or in git config. Instead it wires itself as an
**ephemeral git credential helper**:
- `agentws token` prints a fresh token to stdout (handy for scripts).
- `agentws credential get` speaks the git credential protocol on stdin and, for
the configured Gitea host only, emits `username=$AGENT_LOGIN` +
`password=<fresh token>`.
`agentws new` sets this up per worktree without touching the shared checkout: it
enables `extensions.worktreeConfig` on the repo once, then writes
`user.name` / `user.email` and `credential.helper = !<agentws> credential` to
the **per-worktree** config. Clone/fetch use the same helper via a transient
`-c credential.helper=...`; the shared `origin` URL is left clean. On worktree
removal `agentws` fetches in `~/src/prodenv/<repo>` so its default branch stays
current.
## agentvault
Deterministic Vault flows, each a single self-contained invocation: the tool
reads and writes the secrets itself, and prints only identifiers.
### seed-outpost
Copy an Authentik outpost's token into Vault KV-v2. `agentvault` reads the
Authentik API token from `kv/service/authentik/agent-api-token`, resolves the
named outpost's `token_identifier`, fetches its key via
`/api/v3/core/tokens/<identifier>/view_key/` and writes it to the destination
KV path. The token value is never printed or logged.
```bash
agentvault seed-outpost \
--outpost k8s-outpost \
--dest-path kubernetes/namespace/authentik/default/outpost-token
```
```
outpost: k8s-outpost
token_identifier: ak-outpost-k8s-outpost
dest: kv/kubernetes/namespace/authentik/default/outpost-token
version: 3
```
Re-running is safe: it writes a new KV version. Flags: `--outpost` and
`--dest-path` are required; `--dest-key` (default `token`), `--kv-mount`
(default `kv`), `--token-path` (default `service/authentik/agent-api-token`) and
`--authentik-url` override the rest.
Errors name the failing stage: AppRole login, KV read denied (policy not
applied), outpost not found (terraform not applied), `view_key` failure, or KV
write denied.
## Build & package ## Build & package
```bash ```bash
make build # -> dist/agentpr, dist/watchpr make build # -> dist/agentpr, dist/watchpr, dist/agentws, dist/agentvault
make test # go test -race ./... make test # go test -race ./...
make rpm # build + package dist/agent-tools-<version>-1.x86_64.rpm make rpm # build + package dist/agent-tools-<version>-1.x86_64.rpm
``` ```
+19 -11
View File
@@ -1,7 +1,7 @@
// Command agentpr manages Gitea pull requests and comments as the unkin-agent // Command agentpr manages Gitea pull requests and comments as an agent user. It
// user. It obtains a scoped Gitea token from Vault (AppRole login, then reads // obtains a scoped Gitea token from Vault (AppRole login, then reads
// gitea/creds/unkin-agent) so actions are attributed to the agent rather than // gitea/creds/<AGENT_LOGIN>, or GITEA_CREDS_PATH when set) so actions are
// to whoever runs the tool. // attributed to that agent rather than to whoever runs the tool.
// //
// agentpr pr create --repo owner/repo --base main --head feature --title T --body B // agentpr pr create --repo owner/repo --base main --head feature --title T --body B
// agentpr pr comment --repo owner/repo --pr 12 --body "..." // agentpr pr comment --repo owner/repo --pr 12 --body "..."
@@ -20,20 +20,28 @@ import (
var version = "dev" var version = "dev"
func main() { func main() {
// cobra prints the error itself (SilenceErrors stays off); we only need to
// turn any command error into a non-zero exit.
if err := newRootCmd().Execute(); err != nil {
os.Exit(1)
}
}
// newRootCmd builds the agentpr command tree. It is separated from main so
// tests can invoke Execute and assert the exit behaviour without spawning a
// process.
func newRootCmd() *cobra.Command {
root := &cobra.Command{ root := &cobra.Command{
Use: "agentpr", Use: "agentpr",
Short: "Manage Gitea PRs and comments as the unkin-agent user.", Short: "Manage Gitea PRs and comments as an agent user.",
Long: "agentpr manages Gitea pull requests and comments as unkin-agent, using a\nGitea token minted from Vault (AppRole login + gitea/creds/unkin-agent).", Long: "agentpr manages Gitea pull requests and comments as an agent user, using a\nGitea token minted from Vault (AppRole login + gitea/creds/<AGENT_LOGIN>).\nSet AGENT_LOGIN to act as another agent identity, or GITEA_CREDS_PATH to name\nthe Vault creds path outright.",
Version: version, Version: version,
SilenceUsage: true, SilenceUsage: true,
} }
root.SetVersionTemplate("{{.Version}}\n") root.SetVersionTemplate("{{.Version}}\n")
root.AddCommand(newPRCmd(), newWhoamiCmd(), newVersionCmd()) root.AddCommand(newPRCmd(), newWhoamiCmd(), newVersionCmd())
return root
if err := root.Execute(); err != nil {
os.Exit(1)
}
} }
// client mints a Gitea token via Vault and returns a ready client. // client mints a Gitea token via Vault and returns a ready client.
@@ -138,7 +146,7 @@ func newPRCommentCmd() *cobra.Command {
func newWhoamiCmd() *cobra.Command { func newWhoamiCmd() *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "whoami", Use: "whoami",
Short: "Print the authenticated Gitea login (should be unkin-agent)", Short: "Print the authenticated Gitea login (the identity PRs are opened as)",
SilenceUsage: true, SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
c, err := client() c, err := client()
+18
View File
@@ -0,0 +1,18 @@
package main
import (
"io"
"testing"
)
// A malformed --repo must fail the command (so main exits non-zero). ParseRepo
// rejects it before any Vault/Gitea call, so this stays hermetic.
func TestExecuteBadRepoErrors(t *testing.T) {
cmd := newRootCmd()
cmd.SetArgs([]string{"pr", "create", "--repo", "not-a-repo", "--base", "main", "--head", "x", "--title", "t"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
if err := cmd.Execute(); err == nil {
t.Fatal("Execute() = nil, want error for a malformed --repo")
}
}
+83
View File
@@ -0,0 +1,83 @@
// Command agentvault runs deterministic Vault flows for agents in a single
// invocation, so credentials are never plumbed through a shell. It authenticates
// with the same Vault AppRole as agentpr (role_id only, no secret_id).
//
// agentvault seed-outpost --outpost <name> --dest-path <kv/path>
package main
import (
"fmt"
"os"
"git.unkin.net/unkin/agent-tools/internal/agent"
"github.com/spf13/cobra"
)
var version = "dev"
func main() {
if err := newRootCmd().Execute(); err != nil {
os.Exit(1)
}
}
// newRootCmd builds the agentvault command tree. Separated from main so tests
// can execute it against httptest servers.
func newRootCmd() *cobra.Command {
root := &cobra.Command{
Use: "agentvault",
Short: "Run deterministic Vault flows as the agent AppRole.",
Long: "agentvault performs self-contained Vault flows for agents: it logs in with the\nagent AppRole and moves secret material between systems without ever printing it.",
Version: version,
SilenceUsage: true,
}
root.SetVersionTemplate("{{.Version}}\n")
root.AddCommand(newSeedOutpostCmd(), newVersionCmd())
return root
}
func newSeedOutpostCmd() *cobra.Command {
opts := agent.SeedOutpostOptions{}
cmd := &cobra.Command{
Use: "seed-outpost",
Short: "Copy an Authentik outpost token into Vault KV",
Long: "Read the Authentik API token from Vault KV, resolve the named outpost's\n" +
"token_identifier, fetch its key and write it to a Vault KV path. Re-running\n" +
"writes a new KV version. The token value is never printed or logged.",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
opts.VaultAddr = agent.VaultAddr()
opts.RoleID = agent.RoleID()
res, err := agent.SeedOutpost(opts)
if err != nil {
return err
}
out := cmd.OutOrStdout()
_, _ = fmt.Fprintf(out, "outpost: %s\n", res.Outpost)
_, _ = fmt.Fprintf(out, "token_identifier: %s\n", res.TokenIdentifier)
_, _ = fmt.Fprintf(out, "dest: %s/%s\n", res.KVMount, res.DestPath)
_, _ = fmt.Fprintf(out, "version: %d\n", res.Version)
return nil
},
}
f := cmd.Flags()
f.StringVar(&opts.Outpost, "outpost", "", "Authentik outpost name (required)")
f.StringVar(&opts.DestPath, "dest-path", "", "KV-v2 path to write the token to, e.g. kubernetes/namespace/authentik/default/outpost-token (required)")
f.StringVar(&opts.DestKey, "dest-key", agent.DefaultDestKey, "Field to write the token under")
f.StringVar(&opts.KVMount, "kv-mount", agent.DefaultKVMount, "KV-v2 mount holding both the API token and the destination")
f.StringVar(&opts.TokenPath, "token-path", agent.DefaultOutpostTokenPath, "KV-v2 path of the Authentik API token")
f.StringVar(&opts.AuthentikURL, "authentik-url", agent.AuthentikURL(), "Authentik base URL")
_ = cmd.MarkFlagRequired("outpost")
_ = cmd.MarkFlagRequired("dest-path")
return cmd
}
func newVersionCmd() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Print the version",
Run: func(cmd *cobra.Command, args []string) { fmt.Println(version) },
SilenceUsage: true,
}
}
+96
View File
@@ -0,0 +1,96 @@
package main
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
const (
apiToken = "ak-api-token-secret"
outpostKey = "outpost-key-secret"
destPath = "kubernetes/namespace/authentik/default/outpost-token"
)
// fakeEstate serves the Vault (approle + KV-v2 read/write) and Authentik
// (outpost search + view_key) endpoints the seed flow needs.
func fakeEstate(t *testing.T) (vaultURL, authentikURL string) {
t.Helper()
vmux := http.NewServeMux()
vmux.HandleFunc("/v1/auth/approle/login", func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, `{"auth":{"client_token":"s.vaulttoken"}}`)
})
vmux.HandleFunc("/v1/kv/data/service/authentik/agent-api-token", func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, `{"data":{"data":{"token":"`+apiToken+`"}}}`)
})
vmux.HandleFunc("/v1/kv/data/"+destPath, func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, `{"data":{"version":7}}`)
})
vs := httptest.NewServer(vmux)
t.Cleanup(vs.Close)
amux := http.NewServeMux()
amux.HandleFunc("/api/v3/outposts/instances/", func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, `{"results":[{"pk":"1","name":"k8s-outpost","token_identifier":"ak-outpost-k8s"}]}`)
})
amux.HandleFunc("/api/v3/core/tokens/ak-outpost-k8s/view_key/", func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, `{"key":"`+outpostKey+`"}`)
})
as := httptest.NewServer(amux)
t.Cleanup(as.Close)
return vs.URL, as.URL
}
// The command prints identifiers and the KV version only — never a secret.
func TestSeedOutpostOutputHasNoSecrets(t *testing.T) {
vaultURL, authentikURL := fakeEstate(t)
t.Setenv("VAULT_ADDR", vaultURL)
t.Setenv("AGENT_APPROLE_ROLE_ID", "role-xyz")
var out bytes.Buffer
cmd := newRootCmd()
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{
"seed-outpost",
"--outpost", "k8s-outpost",
"--dest-path", destPath,
"--authentik-url", authentikURL,
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
got := out.String()
for _, want := range []string{"k8s-outpost", "ak-outpost-k8s", "kv/" + destPath, "version: 7"} {
if !strings.Contains(got, want) {
t.Errorf("output missing %q:\n%s", want, got)
}
}
for _, secret := range []string{apiToken, outpostKey} {
if strings.Contains(got, secret) {
t.Fatalf("output leaks a secret:\n%s", got)
}
}
}
func TestSeedOutpostRequiresFlags(t *testing.T) {
for name, args := range map[string][]string{
"no outpost": {"seed-outpost", "--dest-path", destPath},
"no dest-path": {"seed-outpost", "--outpost", "k8s-outpost"},
} {
t.Run(name, func(t *testing.T) {
cmd := newRootCmd()
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs(args)
if err := cmd.Execute(); err == nil {
t.Fatal("Execute() = nil, want a missing-required-flag error")
}
})
}
}
+458
View File
@@ -0,0 +1,458 @@
// Command agentws (agentic workspace) manages git worktrees for the unkin-agent
// user so agents can work on isolated branches without disturbing Ben's shared
// checkouts.
//
// Repositories are cloned into the source root (default ~/src/prodenv/<repo>) so
// branches created here are visible in the main checkout too. Worktrees live
// under the worktree root (default ~/.cache/agentws/<repo>__<branch>). Auth for
// clone/fetch/push comes from a short-lived Gitea token minted from Vault via
// agent.GiteaToken(); it is supplied through an ephemeral git credential helper
// (`agentws credential get`) rather than being persisted in any remote URL or
// config, since the tokens expire in about an hour.
//
// agentws new <repo> [--branch benvin/<name>] [--from <base-branch>]
// agentws list
// agentws rm <path-or-branch> [--delete-branch]
// agentws clean
// agentws token
// agentws credential get # git credential-helper protocol on stdin
package main
import (
"bufio"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"git.unkin.net/unkin/agent-tools/internal/agent"
"github.com/spf13/cobra"
)
var version = "dev"
func main() {
if err := newRootCmd().Execute(); err != nil {
os.Exit(1)
}
}
// newRootCmd builds the agentws command tree. Separated from main so tests can
// invoke Execute and assert behaviour without spawning a process.
func newRootCmd() *cobra.Command {
root := &cobra.Command{
Use: "agentws",
Short: "Manage git worktrees for the unkin-agent user.",
Long: "agentws manages per-branch git worktrees for unkin-agent. Repos are cloned into\nthe source root (~/src/prodenv) and worktrees live under the worktree root\n(~/.cache/agentws), authenticated by an ephemeral Vault-minted Gitea token.",
Version: version,
SilenceUsage: true,
}
root.SetVersionTemplate("{{.Version}}\n")
root.AddCommand(
newNewCmd(),
newListCmd(),
newRmCmd(),
newCleanCmd(),
newTokenCmd(),
newCredentialCmd(),
newVersionCmd(),
)
return root
}
// --- configuration (env-overridable) --------------------------------------
// srcRoot is where source-of-truth checkouts live (default ~/src/prodenv).
func srcRoot() (string, error) {
if v := os.Getenv("AGENTWS_SRC_ROOT"); v != "" {
return v, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, "src", "prodenv"), nil
}
// worktreeRoot is where managed worktrees live (default ~/.cache/agentws).
func worktreeRoot() (string, error) {
if v := os.Getenv("AGENTWS_ROOT"); v != "" {
return v, nil
}
cache, err := os.UserCacheDir()
if err != nil {
return "", err
}
return filepath.Join(cache, "agentws"), nil
}
// owner is the Gitea org that owns the repos (default unkin).
func owner() string {
if v := os.Getenv("AGENTWS_OWNER"); v != "" {
return v
}
return "unkin"
}
// cloneURL builds the (token-free) HTTPS clone URL for a repo.
func cloneURL(repo string) string {
return strings.TrimRight(agent.GiteaURL(), "/") + "/" + owner() + "/" + repo + ".git"
}
// credentialHelperArgs returns git global args that wire this binary as an
// ephemeral credential helper, so clone/fetch/push authenticate without
// persisting a token anywhere.
func credentialHelperArgs() []string {
exe, err := os.Executable()
if err != nil || exe == "" {
exe = "agentws"
}
return []string{"-c", "credential.helper=!" + exe + " credential"}
}
// --- new ------------------------------------------------------------------
func newNewCmd() *cobra.Command {
var branch, from string
cmd := &cobra.Command{
Use: "new <repo>",
Short: "Clone (if needed) and create a worktree for a branch",
Args: cobra.ExactArgs(1),
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
repo := strings.TrimSpace(args[0])
if repo == "" || strings.Contains(repo, "/") {
return fmt.Errorf("repo must be a bare repository name (owner comes from AGENTWS_OWNER, default %q)", owner())
}
if branch == "" {
return fmt.Errorf("--branch is required (e.g. benvin/<name>)")
}
sr, err := srcRoot()
if err != nil {
return err
}
wr, err := worktreeRoot()
if err != nil {
return err
}
srcDir := filepath.Join(sr, repo)
auth := credentialHelperArgs()
// a. Clone the source-of-truth checkout if missing.
if _, statErr := os.Stat(srcDir); statErr != nil {
if !os.IsNotExist(statErr) {
return statErr
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "cloning %s into %s\n", cloneURL(repo), srcDir)
if err := agent.GitClone(cloneURL(repo), srcDir, auth...); err != nil {
return err
}
}
// b. Refresh so the base branch is current.
if err := agent.GitFetch(srcDir, "origin", auth...); err != nil {
return err
}
// c. Base branch: --from or the remote default.
base := from
if base == "" {
base, err = agent.GitRemoteDefaultBranch(srcDir, "origin")
if err != nil {
return err
}
}
// d. Create the worktree FROM the source checkout so the branch is
// visible in the main checkout too.
wtPath := filepath.Join(wr, agent.WorktreeDirName(repo, branch))
if _, statErr := os.Stat(wtPath); statErr == nil {
return fmt.Errorf("worktree already exists at %s", wtPath)
}
if err := agent.GitWorktreeAdd(srcDir, wtPath, branch, "origin/"+base); err != nil {
return err
}
// e. Set the agent identity + auth WITHOUT polluting the shared
// checkout: per-worktree config only.
if err := agent.GitConfigSet(srcDir, false, "extensions.worktreeConfig", "true"); err != nil {
return err
}
if err := agent.GitConfigSet(wtPath, true, "user.name", agent.AgentLogin()); err != nil {
return err
}
if err := agent.GitConfigSet(wtPath, true, "user.email", agent.AgentLogin()+"@unkin.net"); err != nil {
return err
}
exe, _ := os.Executable()
if exe == "" {
exe = "agentws"
}
if err := agent.GitConfigSet(wtPath, true, "credential.helper", "!"+exe+" credential"); err != nil {
return err
}
// f. Report the worktree path and branch.
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s\n", wtPath)
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "branch %s (from origin/%s)\n", branch, base)
return nil
},
}
f := cmd.Flags()
f.StringVar(&branch, "branch", "", "Branch to check out/create (e.g. benvin/<name>) (required)")
f.StringVar(&from, "from", "", "Base branch to branch from (default: remote default branch)")
_ = cmd.MarkFlagRequired("branch")
return cmd
}
// --- list -----------------------------------------------------------------
func newListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List managed worktrees under the worktree root",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
managed, err := managedWorktrees()
if err != nil {
return err
}
out := cmd.OutOrStdout()
if len(managed) == 0 {
_, _ = fmt.Fprintln(out, "no managed worktrees")
return nil
}
for _, w := range managed {
_, _ = fmt.Fprintf(out, "%s\t%s\t%s\n", w.repo, w.branch, w.path)
}
return nil
},
}
}
// managedWt describes one worktree living under the worktree root.
type managedWt struct {
repo string
branch string
path string
srcDir string
}
// managedWorktrees scans the worktree root and resolves each entry's repo and
// branch from git so branch names are accurate (not the sanitized dir name).
func managedWorktrees() ([]managedWt, error) {
wr, err := worktreeRoot()
if err != nil {
return nil, err
}
entries, err := os.ReadDir(wr)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var out []managedWt
for _, e := range entries {
if !e.IsDir() {
continue
}
path := filepath.Join(wr, e.Name())
branch, err := agent.GitCurrentBranch(path)
if err != nil {
continue // not a git worktree; skip
}
srcDir, err := agent.SourceRepoDir(path)
if err != nil {
continue
}
out = append(out, managedWt{
repo: filepath.Base(srcDir),
branch: branch,
path: path,
srcDir: srcDir,
})
}
return out, nil
}
// --- rm -------------------------------------------------------------------
func newRmCmd() *cobra.Command {
var deleteBranch bool
cmd := &cobra.Command{
Use: "rm <path-or-branch>",
Short: "Remove a managed worktree and refresh its source repo",
Args: cobra.ExactArgs(1),
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
target := strings.TrimSpace(args[0])
wt, err := resolveWorktree(target)
if err != nil {
return err
}
return removeWorktree(cmd.OutOrStdout(), wt, deleteBranch)
},
}
cmd.Flags().BoolVar(&deleteBranch, "delete-branch", false, "Also delete the local branch after removing the worktree")
return cmd
}
// resolveWorktree finds a managed worktree by exact path or by branch name.
func resolveWorktree(target string) (managedWt, error) {
managed, err := managedWorktrees()
if err != nil {
return managedWt{}, err
}
abs, _ := filepath.Abs(target)
for _, w := range managed {
if w.path == target || w.path == abs || w.branch == target {
return w, nil
}
}
return managedWt{}, fmt.Errorf("no managed worktree matching %q (try `agentws list`)", target)
}
func removeWorktree(out io.Writer, wt managedWt, deleteBranch bool) error {
if err := agent.GitWorktreeRemove(wt.srcDir, wt.path, true); err != nil {
return err
}
_, _ = fmt.Fprintf(out, "removed worktree %s\n", wt.path)
if deleteBranch {
if err := agent.GitDeleteBranch(wt.srcDir, wt.branch, true); err != nil {
return err
}
_, _ = fmt.Fprintf(out, "deleted branch %s\n", wt.branch)
}
// Refresh the source repo's default branch, then prune.
if err := agent.GitFetch(wt.srcDir, "origin", credentialHelperArgs()...); err != nil {
return err
}
return agent.GitWorktreePrune(wt.srcDir)
}
// --- clean ----------------------------------------------------------------
func newCleanCmd() *cobra.Command {
return &cobra.Command{
Use: "clean",
Short: "Remove all managed worktrees and prune their source repos",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
managed, err := managedWorktrees()
if err != nil {
return err
}
out := cmd.OutOrStdout()
if len(managed) == 0 {
_, _ = fmt.Fprintln(out, "no managed worktrees")
return nil
}
for _, w := range managed {
if err := removeWorktree(out, w, false); err != nil {
return err
}
}
return nil
},
}
}
// --- token ----------------------------------------------------------------
func newTokenCmd() *cobra.Command {
return &cobra.Command{
Use: "token",
Short: "Print a fresh unkin-agent Gitea token",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
tok, err := agent.GiteaToken()
if err != nil {
return err
}
_, _ = fmt.Fprintln(cmd.OutOrStdout(), tok)
return nil
},
}
}
// --- credential (git credential-helper protocol) --------------------------
func newCredentialCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "credential <get|store|erase>",
Short: "git credential-helper: emit unkin-agent creds for git.unkin.net",
Args: cobra.ExactArgs(1),
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
switch args[0] {
case "get":
return credentialGet(cmd.InOrStdin(), cmd.OutOrStdout())
case "store", "erase":
// Nothing to persist/erase for an ephemeral helper; git ignores
// empty output and moves on.
return nil
default:
return fmt.Errorf("unknown credential action %q", args[0])
}
},
}
return cmd
}
// credentialGet implements the `get` half of the git credential protocol: read
// the key=value request on stdin and, for the configured Gitea host, emit a
// username/password pair (unkin-agent + a fresh Vault-minted token).
func credentialGet(stdin io.Reader, stdout io.Writer) error {
req := map[string]string{}
sc := bufio.NewScanner(stdin)
for sc.Scan() {
line := sc.Text()
if line == "" {
break
}
if k, v, ok := strings.Cut(line, "="); ok {
req[k] = v
}
}
if err := sc.Err(); err != nil {
return err
}
// Only answer for the configured Gitea host to avoid handing the token to
// any other remote git might ask about.
if host := req["host"]; host != "" && host != giteaHost() {
return nil
}
tok, err := agent.GiteaToken()
if err != nil {
return err
}
_, _ = fmt.Fprintf(stdout, "username=%s\n", agent.AgentLogin())
_, _ = fmt.Fprintf(stdout, "password=%s\n", tok)
return nil
}
// giteaHost returns the host portion of the configured Gitea URL.
func giteaHost() string {
u := agent.GiteaURL()
u = strings.TrimPrefix(u, "https://")
u = strings.TrimPrefix(u, "http://")
if i := strings.IndexByte(u, '/'); i >= 0 {
u = u[:i]
}
return u
}
// --- version --------------------------------------------------------------
func newVersionCmd() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Print the version",
Run: func(cmd *cobra.Command, args []string) { fmt.Println(version) },
SilenceUsage: true,
}
}
+63
View File
@@ -0,0 +1,63 @@
package main
import (
"bytes"
"io"
"strings"
"testing"
)
// `new` with a bad repo name (contains a slash) must fail before any network
// call, keeping the test hermetic.
func TestNewRejectsOwnerQualifiedRepo(t *testing.T) {
cmd := newRootCmd()
cmd.SetArgs([]string{"new", "unkin/argocd-apps", "--branch", "benvin/x"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
if err := cmd.Execute(); err == nil {
t.Fatal("Execute() = nil, want error for owner-qualified repo name")
}
}
// `new` without --branch must fail (cobra required-flag check) before any
// network call.
func TestNewRequiresBranch(t *testing.T) {
cmd := newRootCmd()
cmd.SetArgs([]string{"new", "argocd-apps"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
if err := cmd.Execute(); err == nil {
t.Fatal("Execute() = nil, want error when --branch is missing")
}
}
// An unknown credential action must fail.
func TestCredentialUnknownAction(t *testing.T) {
cmd := newRootCmd()
cmd.SetArgs([]string{"credential", "bogus"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
if err := cmd.Execute(); err == nil {
t.Fatal("Execute() = nil, want error for unknown credential action")
}
}
// credentialGet must stay silent (and never mint a token) when git asks about a
// host other than the configured Gitea host. This exercises the stdin parser
// without any network access.
func TestCredentialGetIgnoresOtherHost(t *testing.T) {
in := strings.NewReader("protocol=https\nhost=github.com\n\n")
var out bytes.Buffer
if err := credentialGet(in, &out); err != nil {
t.Fatalf("credentialGet: %v", err)
}
if out.Len() != 0 {
t.Errorf("expected no output for non-Gitea host, got %q", out.String())
}
}
func TestGiteaHost(t *testing.T) {
if h := giteaHost(); h != "git.unkin.net" {
t.Errorf("giteaHost() = %q, want git.unkin.net", h)
}
}
+25 -30
View File
@@ -23,6 +23,17 @@ import (
var version = "dev" var version = "dev"
func main() { func main() {
// cobra prints the error itself (SilenceErrors stays off); we only need to
// turn any command error into a non-zero exit.
if err := newRootCmd().Execute(); err != nil {
os.Exit(1)
}
}
// newRootCmd builds the watchpr command tree. It is separated from main so
// tests can invoke Execute and assert the exit behaviour without spawning a
// process.
func newRootCmd() *cobra.Command {
var interval time.Duration var interval time.Duration
var once, jsonMode bool var once, jsonMode bool
@@ -70,10 +81,7 @@ func main() {
Run: func(cmd *cobra.Command, args []string) { fmt.Println(version) }, Run: func(cmd *cobra.Command, args []string) { fmt.Println(version) },
SilenceUsage: true, SilenceUsage: true,
}) })
return root
if err := root.Execute(); err != nil {
os.Exit(1)
}
} }
func clientFor() (*agent.GiteaClient, error) { func clientFor() (*agent.GiteaClient, error) {
@@ -109,36 +117,23 @@ func runOnce(c *agent.GiteaClient, refs []agent.PRRef, jsonMode bool) error {
func runWatch(c *agent.GiteaClient, refs []agent.PRRef, interval time.Duration, jsonMode bool) error { func runWatch(c *agent.GiteaClient, refs []agent.PRRef, interval time.Duration, jsonMode bool) error {
login := agent.AgentLogin() login := agent.AgentLogin()
prev := make(map[string]agent.PRState, len(refs))
for _, ref := range refs {
st, err := agent.FetchState(c, ref, login)
if err != nil {
return err
}
prev[ref.String()] = st
}
if !jsonMode {
fmt.Fprintf(os.Stderr, "watching %d PR(s) every %s; baseline established\n", len(refs), interval)
}
ticker := time.NewTicker(interval) ticker := time.NewTicker(interval)
defer ticker.Stop() defer ticker.Stop()
for range ticker.C {
for _, ref := range refs { onBaseline := func() {
key := ref.String() if !jsonMode {
cur, err := agent.FetchState(c, ref, login) fmt.Fprintf(os.Stderr, "watching %d PR(s) every %s; baseline established\n", len(refs), interval)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: polling %s: %v\n", key, err)
continue
}
changed, reason := agent.MeaningfulChange(prev[key], cur)
if changed {
report(key, reason, cur, jsonMode)
return nil
}
prev[key] = cur
} }
} }
onError := func(ref agent.PRRef, err error) {
fmt.Fprintf(os.Stderr, "warning: polling %s: %v\n", ref.String(), err)
}
res, err := agent.Watch(c, refs, login, ticker.C, onBaseline, onError)
if err != nil {
return err
}
report(res.Ref.String(), res.Reason, res.State, jsonMode)
return nil return nil
} }
+30
View File
@@ -0,0 +1,30 @@
package main
import (
"io"
"testing"
)
// A bad PR reference must fail the command (so main exits non-zero) rather than
// return nil. Parsing rejects the ref before any Vault/Gitea call, so this stays
// hermetic.
func TestExecuteBadRefErrors(t *testing.T) {
cmd := newRootCmd()
cmd.SetArgs([]string{"--once", "not-a-ref"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
if err := cmd.Execute(); err == nil {
t.Fatal("Execute() = nil, want error for a bad PR reference")
}
}
// No arguments is also an error (nothing to watch).
func TestExecuteNoArgsErrors(t *testing.T) {
cmd := newRootCmd()
cmd.SetArgs(nil)
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
if err := cmd.Execute(); err == nil {
t.Fatal("Execute() = nil, want error when no PR references are given")
}
}
+93
View File
@@ -0,0 +1,93 @@
package agent
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
)
// ErrOutpostNotFound marks a search that returned no exactly-named outpost.
var ErrOutpostNotFound = errors.New("outpost not found")
// AuthentikClient talks to the Authentik REST API with a bearer API token. The
// internal CA is in the OS trust store, so the default transport suffices.
type AuthentikClient struct {
BaseURL string
Token string
HTTP *http.Client
}
// NewAuthentikClient builds a client for the given Authentik base URL.
func NewAuthentikClient(baseURL, token string) *AuthentikClient {
return &AuthentikClient{BaseURL: strings.TrimRight(baseURL, "/"), Token: token, HTTP: httpClient}
}
// Outpost is the subset of Authentik's outpost object we need.
type Outpost struct {
PK string `json:"pk"`
Name string `json:"name"`
TokenIdentifier string `json:"token_identifier"`
}
// get issues an authenticated GET and decodes into out. Error text never
// includes a successful response body, which may carry key material.
func (c *AuthentikClient) get(path string, out any) error {
req, err := http.NewRequest(http.MethodGet, c.BaseURL+path, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.Token)
req.Header.Set("Accept", "application/json")
resp, err := c.HTTP.Do(req)
if err != nil {
return fmt.Errorf("authentik GET %s: %w", path, err)
}
defer func() { _ = resp.Body.Close() }()
data, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("authentik GET %s: HTTP %d: %s", path, resp.StatusCode, strings.TrimSpace(string(data)))
}
if err := json.Unmarshal(data, out); err != nil {
return fmt.Errorf("authentik GET %s: decoding response: %w", path, err)
}
return nil
}
// FindOutpost searches outpost instances and returns the one whose name matches
// exactly (search is a substring match, so the exact name is re-checked here).
func (c *AuthentikClient) FindOutpost(name string) (Outpost, error) {
var out struct {
Results []Outpost `json:"results"`
}
path := "/api/v3/outposts/instances/?search=" + url.QueryEscape(name)
if err := c.get(path, &out); err != nil {
return Outpost{}, err
}
for _, o := range out.Results {
if o.Name == name {
return o, nil
}
}
return Outpost{}, fmt.Errorf("authentik outpost %q: %w (searched %d result(s))", name, ErrOutpostNotFound, len(out.Results))
}
// TokenKey returns the key behind a token identifier
// (GET /api/v3/core/tokens/<identifier>/view_key/).
func (c *AuthentikClient) TokenKey(identifier string) (string, error) {
var out struct {
Key string `json:"key"`
}
path := "/api/v3/core/tokens/" + url.PathEscape(identifier) + "/view_key/"
if err := c.get(path, &out); err != nil {
return "", err
}
if out.Key == "" {
return "", fmt.Errorf("authentik view_key for %q: response has no key field", identifier)
}
return out.Key, nil
}
+60 -6
View File
@@ -8,8 +8,9 @@ import (
"testing" "testing"
) )
// fakeVault serves the AppRole login and gitea creds endpoints. // fakeVault serves the AppRole login and the gitea creds secret at credsPath
func fakeVault(t *testing.T, wantRoleID, giteaToken string) *httptest.Server { // only, so a read of any other path 404s.
func fakeVault(t *testing.T, wantRoleID, credsPath, giteaToken string) *httptest.Server {
t.Helper() t.Helper()
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/v1/auth/approle/login", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/v1/auth/approle/login", func(w http.ResponseWriter, r *http.Request) {
@@ -26,7 +27,7 @@ func fakeVault(t *testing.T, wantRoleID, giteaToken string) *httptest.Server {
} }
_, _ = io.WriteString(w, `{"auth":{"client_token":"s.vaulttoken"}}`) _, _ = io.WriteString(w, `{"auth":{"client_token":"s.vaulttoken"}}`)
}) })
mux.HandleFunc("/v1/"+GiteaCredsPath, func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/v1/"+credsPath, func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Vault-Token"); got != "s.vaulttoken" { if got := r.Header.Get("X-Vault-Token"); got != "s.vaulttoken" {
t.Errorf("X-Vault-Token = %q, want s.vaulttoken", got) t.Errorf("X-Vault-Token = %q, want s.vaulttoken", got)
} }
@@ -36,10 +37,10 @@ func fakeVault(t *testing.T, wantRoleID, giteaToken string) *httptest.Server {
} }
func TestFetchGiteaToken(t *testing.T) { func TestFetchGiteaToken(t *testing.T) {
srv := fakeVault(t, "role-xyz", "gitea-abc") srv := fakeVault(t, "role-xyz", "gitea/creds/unkin-agent", "gitea-abc")
defer srv.Close() defer srv.Close()
tok, err := fetchGiteaToken(srv.URL, "role-xyz") tok, err := fetchGiteaToken(srv.URL, "role-xyz", "gitea/creds/unkin-agent")
if err != nil { if err != nil {
t.Fatalf("fetchGiteaToken: %v", err) t.Fatalf("fetchGiteaToken: %v", err)
} }
@@ -57,7 +58,7 @@ func TestFetchGiteaTokenLoginError(t *testing.T) {
srv := httptest.NewServer(mux) srv := httptest.NewServer(mux)
defer srv.Close() defer srv.Close()
if _, err := fetchGiteaToken(srv.URL, "role-xyz"); err == nil { if _, err := fetchGiteaToken(srv.URL, "role-xyz", "gitea/creds/unkin-agent"); err == nil {
t.Fatal("expected error on 403 login") t.Fatal("expected error on 403 login")
} }
} }
@@ -163,6 +164,59 @@ func TestFetchState(t *testing.T) {
} }
} }
// A merged PR whose branch was deleted leaves its head commit unreachable, so
// the commit-status endpoint 404s. FetchState must still return the (merged)
// state rather than failing, otherwise the watch loop never sees the merge.
func TestFetchStateToleratesMissingCommit(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/repos/unkin/repo/pulls/7", func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, `{"number":7,"state":"closed","merged":true,"mergeable":true,"title":"feat","html_url":"u","head":{"sha":"cafebabecafebabe"}}`)
})
mux.HandleFunc("/api/v1/repos/unkin/repo/commits/cafebabecafebabe/status", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = io.WriteString(w, `{"message":"not found"}`)
})
mux.HandleFunc("/api/v1/repos/unkin/repo/issues/7/comments", func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, `[]`)
})
srv := httptest.NewServer(mux)
defer srv.Close()
c := &GiteaClient{BaseURL: srv.URL, Token: "t", HTTP: srv.Client()}
ref := PRRef{Owner: "unkin", Repo: "repo", Number: 7}
st, err := FetchState(c, ref, "unkin-agent")
if err != nil {
t.Fatalf("FetchState must tolerate a 404 status for a gone commit: %v", err)
}
if !st.Merged || st.State != "closed" {
t.Errorf("merged/state = %t/%q, want true/closed", st.Merged, st.State)
}
if st.CIStatus != "" {
t.Errorf("CIStatus = %q, want empty (no status for a gone commit)", st.CIStatus)
}
}
// A non-404 error from the status endpoint is still fatal: only "commit gone" is
// tolerated, not, say, an auth or server failure.
func TestFetchStateFailsOnNon404StatusError(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/repos/unkin/repo/pulls/7", func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, `{"number":7,"state":"open","merged":false,"mergeable":true,"head":{"sha":"cafebabecafebabe"}}`)
})
mux.HandleFunc("/api/v1/repos/unkin/repo/commits/cafebabecafebabe/status", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = io.WriteString(w, `{"message":"boom"}`)
})
srv := httptest.NewServer(mux)
defer srv.Close()
c := &GiteaClient{BaseURL: srv.URL, Token: "t", HTTP: srv.Client()}
ref := PRRef{Owner: "unkin", Repo: "repo", Number: 7}
if _, err := FetchState(c, ref, "unkin-agent"); err == nil {
t.Fatal("FetchState should surface a 500 from the status endpoint")
}
}
func TestGiteaAPIError(t *testing.T) { func TestGiteaAPIError(t *testing.T) {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/api/v1/repos/unkin/repo/pulls", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/v1/repos/unkin/repo/pulls", func(w http.ResponseWriter, r *http.Request) {
+230
View File
@@ -0,0 +1,230 @@
package agent
import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
// ensureDir creates dir (and parents) if it does not already exist.
func ensureDir(dir string) error {
if dir == "" {
return nil
}
return os.MkdirAll(dir, 0o755)
}
// Worktree is one entry from `git worktree list --porcelain`.
type Worktree struct {
Path string
Head string
Branch string // short branch name ("" when detached or bare)
Bare bool
Detached bool
}
// runGit runs git with args, using dir as the working directory (empty = the
// process cwd). It returns trimmed stdout, or an error that includes stderr so
// failures like "branch already checked out" surface verbatim.
func runGit(dir string, args ...string) (string, error) {
cmd := exec.Command("git", args...)
if dir != "" {
cmd.Dir = dir
}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = strings.TrimSpace(stdout.String())
}
return "", fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, msg)
}
return strings.TrimSpace(stdout.String()), nil
}
// GitClone clones url into dir. Any globalArgs (e.g. "-c",
// "credential.helper=...") are passed before the clone subcommand so auth can be
// supplied without persisting it in the resulting checkout's config.
func GitClone(url, dir string, globalArgs ...string) error {
if err := ensureDir(filepath.Dir(dir)); err != nil {
return err
}
args := append(append([]string{}, globalArgs...), "clone", url, dir)
_, err := runGit(filepath.Dir(dir), args...)
return err
}
// GitFetch runs `git fetch <remote>` in repoDir. globalArgs are passed before
// the subcommand (used to inject an ephemeral credential helper).
func GitFetch(repoDir, remote string, globalArgs ...string) error {
args := append(append([]string{}, globalArgs...), "fetch", remote)
_, err := runGit(repoDir, args...)
return err
}
// GitRemoteDefaultBranch returns the short name of remote's default branch
// (e.g. "main") by resolving refs/remotes/<remote>/HEAD.
func GitRemoteDefaultBranch(repoDir, remote string) (string, error) {
out, err := runGit(repoDir, "rev-parse", "--abbrev-ref", remote+"/HEAD")
if err != nil {
return "", err
}
return strings.TrimPrefix(out, remote+"/"), nil
}
// GitBranchExists reports whether a local branch exists.
func GitBranchExists(repoDir, branch string) bool {
_, err := runGit(repoDir, "show-ref", "--verify", "--quiet", "refs/heads/"+branch)
return err == nil
}
// GitWorktreeAdd adds a worktree at path checked out to branch. When the branch
// already exists it is reused; otherwise it is created from startPoint.
func GitWorktreeAdd(repoDir, path, branch, startPoint string) error {
if err := ensureDir(filepath.Dir(path)); err != nil {
return err
}
var args []string
if GitBranchExists(repoDir, branch) {
args = []string{"worktree", "add", path, branch}
} else {
args = []string{"worktree", "add", path, "-b", branch, startPoint}
}
_, err := runGit(repoDir, args...)
return err
}
// GitWorktreeRemove removes the worktree at path (force skips the dirty check).
func GitWorktreeRemove(repoDir, path string, force bool) error {
args := []string{"worktree", "remove", path}
if force {
args = append(args, "--force")
}
_, err := runGit(repoDir, args...)
return err
}
// GitWorktreePrune prunes stale worktree administrative entries.
func GitWorktreePrune(repoDir string) error {
_, err := runGit(repoDir, "worktree", "prune")
return err
}
// GitWorktreeList returns the worktrees registered for repoDir.
func GitWorktreeList(repoDir string) ([]Worktree, error) {
out, err := runGit(repoDir, "worktree", "list", "--porcelain")
if err != nil {
return nil, err
}
return ParseWorktreeList(out), nil
}
// GitDeleteBranch deletes a local branch (force uses -D).
func GitDeleteBranch(repoDir, branch string, force bool) error {
flag := "-d"
if force {
flag = "-D"
}
_, err := runGit(repoDir, "branch", flag, branch)
return err
}
// GitConfigSet sets a config key in repoDir. When worktree is true the value is
// written to the per-worktree config (extensions.worktreeConfig must be enabled)
// so it does not touch the shared checkout's config.
func GitConfigSet(repoDir string, worktree bool, key, value string) error {
args := []string{"config"}
if worktree {
args = append(args, "--worktree")
}
args = append(args, key, value)
_, err := runGit(repoDir, args...)
return err
}
// GitCommonDir returns the absolute path to the shared .git directory for the
// checkout at dir (a worktree's common dir points back at its source repo).
func GitCommonDir(dir string) (string, error) {
out, err := runGit(dir, "rev-parse", "--path-format=absolute", "--git-common-dir")
if err != nil {
return "", err
}
return out, nil
}
// GitCurrentBranch returns the short branch name checked out at dir.
func GitCurrentBranch(dir string) (string, error) {
return runGit(dir, "rev-parse", "--abbrev-ref", "HEAD")
}
// SourceRepoDir maps a worktree checkout to its source repo directory by walking
// from the shared .git common dir up to the repo root.
func SourceRepoDir(worktreeDir string) (string, error) {
common, err := GitCommonDir(worktreeDir)
if err != nil {
return "", err
}
// common is ".../<repo>/.git"; the repo dir is its parent.
return filepath.Dir(common), nil
}
// ParseWorktreeList parses the output of `git worktree list --porcelain`.
func ParseWorktreeList(out string) []Worktree {
var wts []Worktree
var cur *Worktree
flush := func() {
if cur != nil {
wts = append(wts, *cur)
cur = nil
}
}
for _, line := range strings.Split(out, "\n") {
line = strings.TrimRight(line, "\r")
if line == "" {
flush()
continue
}
key, val, _ := strings.Cut(line, " ")
switch key {
case "worktree":
flush()
cur = &Worktree{Path: val}
case "HEAD":
if cur != nil {
cur.Head = val
}
case "branch":
if cur != nil {
cur.Branch = strings.TrimPrefix(val, "refs/heads/")
}
case "bare":
if cur != nil {
cur.Bare = true
}
case "detached":
if cur != nil {
cur.Detached = true
}
}
}
flush()
return wts
}
// SanitizeBranch turns a branch name into a filesystem-safe path segment by
// replacing separators that would otherwise create nested directories.
func SanitizeBranch(branch string) string {
r := strings.NewReplacer("/", "-", "\\", "-", ":", "-", " ", "-")
return r.Replace(strings.TrimSpace(branch))
}
// WorktreeDirName is the directory name (under the worktree root) for a repo's
// branch worktree: "<repo>__<sanitized-branch>".
func WorktreeDirName(repo, branch string) string {
return repo + "__" + SanitizeBranch(branch)
}
+196
View File
@@ -0,0 +1,196 @@
package agent
import (
"os"
"path/filepath"
"testing"
)
func TestSanitizeBranch(t *testing.T) {
tests := []struct {
in, want string
}{
{"benvin/agentws", "benvin-agentws"},
{"main", "main"},
{" feature/x ", "feature-x"},
{"a/b/c", "a-b-c"},
{"ns:thing", "ns-thing"},
{"with space", "with-space"},
}
for _, tt := range tests {
if got := SanitizeBranch(tt.in); got != tt.want {
t.Errorf("SanitizeBranch(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestWorktreeDirName(t *testing.T) {
if got := WorktreeDirName("argocd-apps", "benvin/foo"); got != "argocd-apps__benvin-foo" {
t.Errorf("WorktreeDirName = %q", got)
}
}
func TestParseWorktreeList(t *testing.T) {
out := `worktree /home/ben/src/prodenv/repo
HEAD 1111111111111111111111111111111111111111
branch refs/heads/main
worktree /home/ben/.cache/agentws/repo__benvin-foo
HEAD 2222222222222222222222222222222222222222
branch refs/heads/benvin/foo
worktree /home/ben/.cache/agentws/repo__detached
HEAD 3333333333333333333333333333333333333333
detached
`
wts := ParseWorktreeList(out)
if len(wts) != 3 {
t.Fatalf("got %d worktrees, want 3: %+v", len(wts), wts)
}
if wts[0].Branch != "main" || wts[0].Path != "/home/ben/src/prodenv/repo" {
t.Errorf("wt[0] = %+v", wts[0])
}
if wts[1].Branch != "benvin/foo" {
t.Errorf("wt[1].Branch = %q, want benvin/foo", wts[1].Branch)
}
if !wts[2].Detached || wts[2].Branch != "" {
t.Errorf("wt[2] = %+v, want detached with empty branch", wts[2])
}
}
// gitSeed sets a repo-local identity so commits work without global config.
func gitIdentity(t *testing.T, dir string) {
t.Helper()
if err := GitConfigSet(dir, false, "user.email", "test@example.com"); err != nil {
t.Fatalf("set user.email: %v", err)
}
if err := GitConfigSet(dir, false, "user.name", "Test"); err != nil {
t.Fatalf("set user.name: %v", err)
}
}
// newTempRepos builds a bare "origin" with one commit on main and clones it into
// srcDir (so refs/remotes/origin/HEAD is set), returning the source checkout.
func newTempRepos(t *testing.T) string {
t.Helper()
root := t.TempDir()
bare := filepath.Join(root, "origin.git")
if _, err := runGit(root, "init", "--bare", "-b", "main", bare); err != nil {
t.Fatalf("init bare: %v", err)
}
seed := filepath.Join(root, "seed")
if _, err := runGit(root, "init", "-b", "main", seed); err != nil {
t.Fatalf("init seed: %v", err)
}
gitIdentity(t, seed)
if err := os.WriteFile(filepath.Join(seed, "README.md"), []byte("hi\n"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := runGit(seed, "add", "."); err != nil {
t.Fatalf("add: %v", err)
}
if _, err := runGit(seed, "commit", "-m", "init"); err != nil {
t.Fatalf("commit: %v", err)
}
if _, err := runGit(seed, "remote", "add", "origin", bare); err != nil {
t.Fatalf("remote add: %v", err)
}
if _, err := runGit(seed, "push", "-u", "origin", "main"); err != nil {
t.Fatalf("push: %v", err)
}
srcDir := filepath.Join(root, "src")
if err := GitClone(bare, srcDir); err != nil {
t.Fatalf("clone: %v", err)
}
gitIdentity(t, srcDir)
return srcDir
}
func TestGitWorktreeLifecycle(t *testing.T) {
srcDir := newTempRepos(t)
def, err := GitRemoteDefaultBranch(srcDir, "origin")
if err != nil {
t.Fatalf("GitRemoteDefaultBranch: %v", err)
}
if def != "main" {
t.Errorf("default branch = %q, want main", def)
}
if err := GitFetch(srcDir, "origin"); err != nil {
t.Fatalf("GitFetch: %v", err)
}
wtPath := filepath.Join(t.TempDir(), "repo__benvin-x")
if GitBranchExists(srcDir, "benvin/x") {
t.Fatal("branch benvin/x should not exist yet")
}
if err := GitWorktreeAdd(srcDir, wtPath, "benvin/x", "origin/main"); err != nil {
t.Fatalf("GitWorktreeAdd: %v", err)
}
if !GitBranchExists(srcDir, "benvin/x") {
t.Error("branch benvin/x should exist after worktree add")
}
if br, err := GitCurrentBranch(wtPath); err != nil || br != "benvin/x" {
t.Errorf("GitCurrentBranch = %q, %v; want benvin/x", br, err)
}
src2, err := SourceRepoDir(wtPath)
if err != nil {
t.Fatalf("SourceRepoDir: %v", err)
}
if resolve(t, src2) != resolve(t, srcDir) {
t.Errorf("SourceRepoDir = %q, want %q", src2, srcDir)
}
wts, err := GitWorktreeList(srcDir)
if err != nil {
t.Fatalf("GitWorktreeList: %v", err)
}
found := false
for _, w := range wts {
if resolve(t, w.Path) == resolve(t, wtPath) && w.Branch == "benvin/x" {
found = true
}
}
if !found {
t.Errorf("worktree %s not found in list: %+v", wtPath, wts)
}
// Per-worktree config must not leak into the shared checkout.
if err := GitConfigSet(srcDir, false, "extensions.worktreeConfig", "true"); err != nil {
t.Fatalf("enable worktreeConfig: %v", err)
}
if err := GitConfigSet(wtPath, true, "user.name", "unkin-agent"); err != nil {
t.Fatalf("set worktree user.name: %v", err)
}
if name, _ := runGit(srcDir, "config", "user.name"); name == "unkin-agent" {
t.Error("shared checkout user.name was polluted by worktree config")
}
if err := GitWorktreeRemove(srcDir, wtPath, true); err != nil {
t.Fatalf("GitWorktreeRemove: %v", err)
}
if err := GitDeleteBranch(srcDir, "benvin/x", true); err != nil {
t.Fatalf("GitDeleteBranch: %v", err)
}
if GitBranchExists(srcDir, "benvin/x") {
t.Error("branch benvin/x should be gone after delete")
}
if err := GitWorktreePrune(srcDir); err != nil {
t.Fatalf("GitWorktreePrune: %v", err)
}
}
// resolve canonicalizes a path (temp dirs may live behind symlinks like /var).
func resolve(t *testing.T, p string) string {
t.Helper()
r, err := filepath.EvalSymlinks(p)
if err != nil {
return p
}
return r
}
+22 -1
View File
@@ -3,12 +3,33 @@ package agent
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"strings" "strings"
) )
// APIError is a non-2xx response from the Gitea API. It carries the status code
// so callers can react to specific failures (e.g. tolerate a 404 for a commit
// whose branch was deleted after a merge) instead of parsing error strings.
type APIError struct {
Method string
Path string
StatusCode int
Body string
}
func (e *APIError) Error() string {
return fmt.Sprintf("gitea %s %s: HTTP %d: %s", e.Method, e.Path, e.StatusCode, e.Body)
}
// isNotFound reports whether err is a Gitea 404.
func isNotFound(err error) bool {
var apiErr *APIError
return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound
}
// GiteaClient talks to the Gitea REST API as the agent user. // GiteaClient talks to the Gitea REST API as the agent user.
type GiteaClient struct { type GiteaClient struct {
BaseURL string BaseURL string
@@ -49,7 +70,7 @@ func (c *GiteaClient) do(method, path string, body any, out any) error {
defer func() { _ = resp.Body.Close() }() defer func() { _ = resp.Body.Close() }()
data, _ := io.ReadAll(resp.Body) data, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 { if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("gitea %s %s: HTTP %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(data))) return &APIError{Method: method, Path: path, StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(data))}
} }
if out != nil && len(data) > 0 { if out != nil && len(data) > 0 {
if err := json.Unmarshal(data, out); err != nil { if err := json.Unmarshal(data, out); err != nil {
+98
View File
@@ -0,0 +1,98 @@
package agent
import (
"errors"
"fmt"
)
const (
// DefaultOutpostTokenPath is the KV-v2 path holding the Authentik API token
// the agent uses to read outpost tokens.
DefaultOutpostTokenPath = "service/authentik/agent-api-token"
// DefaultDestKey is the KV field the outpost token is written to.
DefaultDestKey = "token"
)
// SeedOutpostOptions configures SeedOutpost. Every field is required; the CLI
// supplies the defaults.
type SeedOutpostOptions struct {
VaultAddr string
RoleID string
AuthentikURL string
Outpost string
KVMount string
TokenPath string
DestPath string
DestKey string
}
// SeedOutpostResult is the non-secret summary of a successful seed.
type SeedOutpostResult struct {
Outpost string
TokenIdentifier string
KVMount string
DestPath string
Version int
}
// SeedOutpost copies an Authentik outpost's token into Vault KV-v2. It reads an
// Authentik API token from Vault, resolves the outpost's token identifier,
// fetches the key and writes it to the destination path. The token value never
// leaves this function: results and errors carry only identifiers.
func SeedOutpost(o SeedOutpostOptions) (SeedOutpostResult, error) {
var res SeedOutpostResult
vc, err := NewVaultClient(o.VaultAddr, o.RoleID)
if err != nil {
return res, fmt.Errorf("vault approle login failed against %s (check VAULT_ADDR and AGENT_APPROLE_ROLE_ID): %w", o.VaultAddr, err)
}
secret, err := vc.ReadKV(o.KVMount, o.TokenPath)
if err != nil {
switch {
case errors.Is(err, ErrVaultDenied):
return res, fmt.Errorf("reading %s/%s denied: the agent AppRole policy does not grant read on this path (apply the terraform-vault policy change): %w", o.KVMount, o.TokenPath, err)
case errors.Is(err, ErrVaultNotFound):
return res, fmt.Errorf("secret %s/%s does not exist: seed the Authentik API token there first: %w", o.KVMount, o.TokenPath, err)
}
return res, fmt.Errorf("reading %s/%s: %w", o.KVMount, o.TokenPath, err)
}
apiToken := StringField(secret, "token", "api_token")
if apiToken == "" {
return res, fmt.Errorf("secret %s/%s has neither a 'token' nor an 'api_token' field", o.KVMount, o.TokenPath)
}
ac := NewAuthentikClient(o.AuthentikURL, apiToken)
outpost, err := ac.FindOutpost(o.Outpost)
if err != nil {
if errors.Is(err, ErrOutpostNotFound) {
return res, fmt.Errorf("no outpost named %q at %s: has the terraform-authentik outpost been applied?: %w", o.Outpost, o.AuthentikURL, err)
}
return res, fmt.Errorf("looking up outpost %q: %w", o.Outpost, err)
}
if outpost.TokenIdentifier == "" {
return res, fmt.Errorf("outpost %q has an empty token_identifier", outpost.Name)
}
key, err := ac.TokenKey(outpost.TokenIdentifier)
if err != nil {
return res, fmt.Errorf("fetching the key for token identifier %q (the API token needs view_key on it): %w", outpost.TokenIdentifier, err)
}
version, err := vc.WriteKV(o.KVMount, o.DestPath, map[string]string{o.DestKey: key})
if err != nil {
if errors.Is(err, ErrVaultDenied) {
return res, fmt.Errorf("writing %s/%s denied: the agent AppRole policy does not grant create/update on this path (apply the terraform-vault policy change): %w", o.KVMount, o.DestPath, err)
}
return res, fmt.Errorf("writing %s/%s: %w", o.KVMount, o.DestPath, err)
}
return SeedOutpostResult{
Outpost: outpost.Name,
TokenIdentifier: outpost.TokenIdentifier,
KVMount: o.KVMount,
DestPath: o.DestPath,
Version: version,
}, nil
}
+369
View File
@@ -0,0 +1,369 @@
package agent
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
)
const (
testAPIToken = "ak-api-token-secret"
testOutpostKey = "outpost-key-secret"
testTokenPath = "service/authentik/agent-api-token"
testDestPath = "kubernetes/namespace/authentik/default/outpost-token"
testOutpostName = "k8s-outpost"
testTokenIdent = "ak-outpost-k8s-outpost"
testVaultClientT = "s.vaulttoken"
)
// vaultStub is a KV-v2 stand-in whose per-path behaviour tests can override.
type vaultStub struct {
readStatus int
writeStatus int
tokenField string // field name the API token is stored under
writes []map[string]string
writeCount int
}
func newVaultStub() *vaultStub {
return &vaultStub{readStatus: http.StatusOK, writeStatus: http.StatusOK, tokenField: "token"}
}
func (v *vaultStub) server(t *testing.T) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/v1/auth/approle/login", func(w http.ResponseWriter, r *http.Request) {
var body map[string]string
_ = json.NewDecoder(r.Body).Decode(&body)
if _, ok := body["secret_id"]; ok {
t.Errorf("secret_id must not be sent")
}
_, _ = io.WriteString(w, `{"auth":{"client_token":"`+testVaultClientT+`"}}`)
})
mux.HandleFunc("/v1/kv/data/"+testTokenPath, func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Vault-Token"); got != testVaultClientT {
t.Errorf("X-Vault-Token = %q, want %q", got, testVaultClientT)
}
if v.readStatus != http.StatusOK {
w.WriteHeader(v.readStatus)
_, _ = io.WriteString(w, `{"errors":["permission denied"]}`)
return
}
_, _ = io.WriteString(w, `{"data":{"data":{"`+v.tokenField+`":"`+testAPIToken+`"},"metadata":{"version":1}}}`)
})
mux.HandleFunc("/v1/kv/data/"+testDestPath, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("write method = %s, want POST", r.Method)
}
if v.writeStatus != http.StatusOK {
w.WriteHeader(v.writeStatus)
_, _ = io.WriteString(w, `{"errors":["permission denied"]}`)
return
}
var body struct {
Data map[string]string `json:"data"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
v.writes = append(v.writes, body.Data)
v.writeCount++
_, _ = io.WriteString(w, `{"data":{"version":`+strconv.Itoa(v.writeCount+2)+`}}`)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
// authentikStub serves the outpost search and view_key endpoints.
type authentikStub struct {
results string // JSON array body for .results
viewKeyStatus int
viewKeyBody string
sawBearer string
sawSearchQuery string
}
func newAuthentikStub() *authentikStub {
return &authentikStub{
results: `{"pk":"1","name":"` + testOutpostName + `","token_identifier":"` + testTokenIdent + `"}`,
viewKeyStatus: http.StatusOK,
viewKeyBody: `{"key":"` + testOutpostKey + `"}`,
}
}
func (a *authentikStub) server(t *testing.T) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/api/v3/outposts/instances/", func(w http.ResponseWriter, r *http.Request) {
a.sawBearer = r.Header.Get("Authorization")
a.sawSearchQuery = r.URL.Query().Get("search")
_, _ = io.WriteString(w, `{"results":[`+a.results+`]}`)
})
mux.HandleFunc("/api/v3/core/tokens/"+testTokenIdent+"/view_key/", func(w http.ResponseWriter, r *http.Request) {
if a.viewKeyStatus != http.StatusOK {
w.WriteHeader(a.viewKeyStatus)
_, _ = io.WriteString(w, `{"detail":"boom"}`)
return
}
_, _ = io.WriteString(w, a.viewKeyBody)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
func opts(vaultURL, authentikURL string) SeedOutpostOptions {
return SeedOutpostOptions{
VaultAddr: vaultURL,
RoleID: "role-xyz",
AuthentikURL: authentikURL,
Outpost: testOutpostName,
KVMount: DefaultKVMount,
TokenPath: testTokenPath,
DestPath: testDestPath,
DestKey: DefaultDestKey,
}
}
func TestSeedOutpostHappyPath(t *testing.T) {
v := newVaultStub()
a := newAuthentikStub()
vs, as := v.server(t), a.server(t)
res, err := SeedOutpost(opts(vs.URL, as.URL))
if err != nil {
t.Fatalf("SeedOutpost: %v", err)
}
if res.Outpost != testOutpostName {
t.Errorf("Outpost = %q, want %q", res.Outpost, testOutpostName)
}
if res.TokenIdentifier != testTokenIdent {
t.Errorf("TokenIdentifier = %q, want %q", res.TokenIdentifier, testTokenIdent)
}
if res.DestPath != testDestPath || res.KVMount != DefaultKVMount {
t.Errorf("dest = %s/%s, want kv/%s", res.KVMount, res.DestPath, testDestPath)
}
if res.Version != 3 {
t.Errorf("Version = %d, want 3", res.Version)
}
if a.sawBearer != "Bearer "+testAPIToken {
t.Errorf("Authorization = %q, want the API token as a bearer", a.sawBearer)
}
if a.sawSearchQuery != testOutpostName {
t.Errorf("search = %q, want %q", a.sawSearchQuery, testOutpostName)
}
if len(v.writes) != 1 || v.writes[0][DefaultDestKey] != testOutpostKey {
t.Fatalf("written data = %v, want {%s: outpost key}", v.writes, DefaultDestKey)
}
}
// Re-running writes a new KV version rather than failing.
func TestSeedOutpostIdempotentNewVersion(t *testing.T) {
v := newVaultStub()
a := newAuthentikStub()
vs, as := v.server(t), a.server(t)
first, err := SeedOutpost(opts(vs.URL, as.URL))
if err != nil {
t.Fatalf("first SeedOutpost: %v", err)
}
second, err := SeedOutpost(opts(vs.URL, as.URL))
if err != nil {
t.Fatalf("second SeedOutpost: %v", err)
}
if second.Version != first.Version+1 {
t.Errorf("versions = %d then %d, want consecutive", first.Version, second.Version)
}
}
// The API token may be stored under api_token instead of token.
func TestSeedOutpostAPITokenFallbackField(t *testing.T) {
v := newVaultStub()
v.tokenField = "api_token"
a := newAuthentikStub()
vs, as := v.server(t), a.server(t)
if _, err := SeedOutpost(opts(vs.URL, as.URL)); err != nil {
t.Fatalf("SeedOutpost with api_token field: %v", err)
}
if a.sawBearer != "Bearer "+testAPIToken {
t.Errorf("Authorization = %q, want the api_token value", a.sawBearer)
}
}
func TestSeedOutpostCustomDestKey(t *testing.T) {
v := newVaultStub()
a := newAuthentikStub()
vs, as := v.server(t), a.server(t)
o := opts(vs.URL, as.URL)
o.DestKey = "outpost-token"
if _, err := SeedOutpost(o); err != nil {
t.Fatalf("SeedOutpost: %v", err)
}
if len(v.writes) != 1 || v.writes[0]["outpost-token"] != testOutpostKey {
t.Errorf("written data = %v, want the key under outpost-token", v.writes)
}
}
func TestSeedOutpostLoginFailure(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/v1/auth/approle/login", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = io.WriteString(w, `{"errors":["invalid role ID"]}`)
})
vs := httptest.NewServer(mux)
defer vs.Close()
a := newAuthentikStub()
_, err := SeedOutpost(opts(vs.URL, a.server(t).URL))
if err == nil {
t.Fatal("SeedOutpost() = nil, want an approle login error")
}
if !strings.Contains(err.Error(), "approle login failed") {
t.Errorf("error = %v, want it to name the approle login", err)
}
}
func TestSeedOutpostKVReadDenied(t *testing.T) {
v := newVaultStub()
v.readStatus = http.StatusForbidden
a := newAuthentikStub()
vs, as := v.server(t), a.server(t)
_, err := SeedOutpost(opts(vs.URL, as.URL))
if err == nil {
t.Fatal("SeedOutpost() = nil, want a KV read error")
}
msg := err.Error()
if !strings.Contains(msg, testTokenPath) || !strings.Contains(msg, "policy") {
t.Errorf("error = %v, want it to name the token path and point at the policy", err)
}
if strings.Contains(msg, "does not exist") {
t.Errorf("error = %v, denied must not be reported as missing", err)
}
}
func TestSeedOutpostKVReadNotFound(t *testing.T) {
v := newVaultStub()
v.readStatus = http.StatusNotFound
a := newAuthentikStub()
vs, as := v.server(t), a.server(t)
_, err := SeedOutpost(opts(vs.URL, as.URL))
if err == nil {
t.Fatal("SeedOutpost() = nil, want a missing-secret error")
}
if !strings.Contains(err.Error(), "does not exist") {
t.Errorf("error = %v, want it to say the secret does not exist", err)
}
}
func TestSeedOutpostMissingTokenField(t *testing.T) {
v := newVaultStub()
v.tokenField = "password"
a := newAuthentikStub()
vs, as := v.server(t), a.server(t)
_, err := SeedOutpost(opts(vs.URL, as.URL))
if err == nil {
t.Fatal("SeedOutpost() = nil, want an error for a secret with no token field")
}
if !strings.Contains(err.Error(), "api_token") {
t.Errorf("error = %v, want it to name the accepted fields", err)
}
}
// A substring hit that is not the exact name must not be accepted.
func TestSeedOutpostNotFound(t *testing.T) {
v := newVaultStub()
a := newAuthentikStub()
a.results = `{"pk":"1","name":"` + testOutpostName + `-staging","token_identifier":"other"}`
vs, as := v.server(t), a.server(t)
_, err := SeedOutpost(opts(vs.URL, as.URL))
if err == nil {
t.Fatal("SeedOutpost() = nil, want an outpost-not-found error")
}
msg := err.Error()
if !strings.Contains(msg, "no outpost named") || !strings.Contains(msg, "terraform") {
t.Errorf("error = %v, want it to report the missing outpost and mention terraform", err)
}
if len(v.writes) != 0 {
t.Errorf("wrote %v, want no KV write when the outpost is missing", v.writes)
}
}
func TestSeedOutpostViewKeyFailure(t *testing.T) {
v := newVaultStub()
a := newAuthentikStub()
a.viewKeyStatus = http.StatusForbidden
vs, as := v.server(t), a.server(t)
_, err := SeedOutpost(opts(vs.URL, as.URL))
if err == nil {
t.Fatal("SeedOutpost() = nil, want a view_key error")
}
if !strings.Contains(err.Error(), testTokenIdent) {
t.Errorf("error = %v, want it to name the token identifier", err)
}
if len(v.writes) != 0 {
t.Errorf("wrote %v, want no KV write when view_key fails", v.writes)
}
}
func TestSeedOutpostViewKeyEmpty(t *testing.T) {
v := newVaultStub()
a := newAuthentikStub()
a.viewKeyBody = `{}`
vs, as := v.server(t), a.server(t)
if _, err := SeedOutpost(opts(vs.URL, as.URL)); err == nil {
t.Fatal("SeedOutpost() = nil, want an error when view_key returns no key")
}
}
func TestSeedOutpostKVWriteDenied(t *testing.T) {
v := newVaultStub()
v.writeStatus = http.StatusForbidden
a := newAuthentikStub()
vs, as := v.server(t), a.server(t)
_, err := SeedOutpost(opts(vs.URL, as.URL))
if err == nil {
t.Fatal("SeedOutpost() = nil, want a KV write error")
}
msg := err.Error()
if !strings.Contains(msg, testDestPath) || !strings.Contains(msg, "create/update") {
t.Errorf("error = %v, want it to name the dest path and the missing capability", err)
}
}
// No failure path may leak the API token or the outpost key into the error.
func TestSeedOutpostErrorsNeverLeakSecrets(t *testing.T) {
cases := map[string]func(*vaultStub, *authentikStub){
"read denied": func(v *vaultStub, a *authentikStub) { v.readStatus = http.StatusForbidden },
"write denied": func(v *vaultStub, a *authentikStub) { v.writeStatus = http.StatusForbidden },
"view_key fail": func(v *vaultStub, a *authentikStub) { a.viewKeyStatus = http.StatusInternalServerError },
"outpost gone": func(v *vaultStub, a *authentikStub) { a.results = "" },
}
for name, mutate := range cases {
t.Run(name, func(t *testing.T) {
v, a := newVaultStub(), newAuthentikStub()
mutate(v, a)
vs, as := v.server(t), a.server(t)
_, err := SeedOutpost(opts(vs.URL, as.URL))
if err == nil {
t.Fatal("SeedOutpost() = nil, want an error")
}
for _, secret := range []string{testAPIToken, testOutpostKey} {
if strings.Contains(err.Error(), secret) {
t.Errorf("error %q leaks a secret", err)
}
}
})
}
}
+43 -17
View File
@@ -1,12 +1,13 @@
// Package agent holds the plumbing shared by the agent-tools CLIs (agentpr and // Package agent holds the plumbing shared by the agent-tools CLIs (agentpr,
// watchpr): obtaining a Gitea token via Vault AppRole, talking to the Gitea // watchpr, agentws and agentvault): the Vault AppRole login and its KV-v2
// API, parsing PR references, and deciding when a watched PR changed // client, talking to the Gitea and Authentik APIs, parsing PR references, and
// meaningfully. Both tools acquire their Gitea token the same way, so that // deciding when a watched PR changed meaningfully. Every tool authenticates to
// logic lives here once. // Vault the same way, so that logic lives here once.
package agent package agent
import ( import (
"os" "os"
"strings"
"sync" "sync"
) )
@@ -16,13 +17,18 @@ const (
// DefaultRoleID is the agent AppRole role_id used when AGENT_APPROLE_ROLE_ID // DefaultRoleID is the agent AppRole role_id used when AGENT_APPROLE_ROLE_ID
// is unset. Login uses role_id only (no secret_id). // is unset. Login uses role_id only (no secret_id).
DefaultRoleID = "ababbcd3-9c77-5c6a-be2d-287fce9214a6" DefaultRoleID = "ababbcd3-9c77-5c6a-be2d-287fce9214a6"
// GiteaCredsPath is the Vault path that mints a scoped Gitea token. // GiteaCredsPrefix is the Vault gitea secrets-engine creds prefix; the agent
GiteaCredsPath = "gitea/creds/unkin-agent" // login is appended to it to form the path that mints a scoped Gitea token.
GiteaCredsPrefix = "gitea/creds/"
// DefaultGiteaURL is the Gitea base URL used when GITEA_URL is unset. // DefaultGiteaURL is the Gitea base URL used when GITEA_URL is unset.
DefaultGiteaURL = "https://git.unkin.net" DefaultGiteaURL = "https://git.unkin.net"
// DefaultAgentLogin is the Gitea login of the agent whose own comments are // DefaultAgentLogin is the Gitea login the tools act as: it selects the Vault
// ignored by watchpr. Overridable via AGENT_LOGIN. // creds path, sets the agentws git identity and is the login whose own
// comments watchpr ignores. Overridable via AGENT_LOGIN.
DefaultAgentLogin = "unkin-agent" DefaultAgentLogin = "unkin-agent"
// DefaultAuthentikURL is the Authentik base URL used when AUTHENTIK_URL is
// unset. identity.unkin.net has no DNS record; the k8s name is the real one.
DefaultAuthentikURL = "https://identity.k8s.syd1.au.unkin.net"
) )
// VaultAddr returns the configured Vault address (env VAULT_ADDR or the default). // VaultAddr returns the configured Vault address (env VAULT_ADDR or the default).
@@ -50,8 +56,8 @@ func GiteaURL() string {
return DefaultGiteaURL return DefaultGiteaURL
} }
// AgentLogin returns the login whose comments watchpr ignores (env AGENT_LOGIN // AgentLogin returns the Gitea login the tools act as (env AGENT_LOGIN or the
// or the default). // default).
func AgentLogin() string { func AgentLogin() string {
if v := os.Getenv("AGENT_LOGIN"); v != "" { if v := os.Getenv("AGENT_LOGIN"); v != "" {
return v return v
@@ -59,6 +65,26 @@ func AgentLogin() string {
return DefaultAgentLogin return DefaultAgentLogin
} }
// GiteaCredsPath returns the Vault path that mints a scoped Gitea token:
// GITEA_CREDS_PATH when set, otherwise gitea/creds/<AgentLogin>. So a service
// running as its own identity only has to set AGENT_LOGIN.
func GiteaCredsPath() string {
// Trimmed because callers join this onto ".../v1/".
if v := strings.Trim(strings.TrimSpace(os.Getenv("GITEA_CREDS_PATH")), "/"); v != "" {
return v
}
return GiteaCredsPrefix + AgentLogin()
}
// AuthentikURL returns the configured Authentik base URL (env AUTHENTIK_URL or
// the default).
func AuthentikURL() string {
if v := os.Getenv("AUTHENTIK_URL"); v != "" {
return v
}
return DefaultAuthentikURL
}
var ( var (
tokenOnce sync.Once tokenOnce sync.Once
tokenValue string tokenValue string
@@ -69,18 +95,18 @@ var (
// and caching it in-process for the lifetime of the command. // and caching it in-process for the lifetime of the command.
func GiteaToken() (string, error) { func GiteaToken() (string, error) {
tokenOnce.Do(func() { tokenOnce.Do(func() {
tokenValue, tokenErr = fetchGiteaToken(VaultAddr(), RoleID()) tokenValue, tokenErr = fetchGiteaToken(VaultAddr(), RoleID(), GiteaCredsPath())
}) })
return tokenValue, tokenErr return tokenValue, tokenErr
} }
// fetchGiteaToken performs the AppRole login and reads the Gitea creds. It is // fetchGiteaToken performs the AppRole login and reads the Gitea creds at
// separated from GiteaToken so tests can exercise it directly against an // credsPath. It is separated from GiteaToken so tests can exercise it directly
// httptest server without touching the process-wide cache. // against an httptest server without touching the process-wide cache.
func fetchGiteaToken(vaultAddr, roleID string) (string, error) { func fetchGiteaToken(vaultAddr, roleID, credsPath string) (string, error) {
clientToken, err := approleLogin(vaultAddr, roleID) clientToken, err := approleLogin(vaultAddr, roleID)
if err != nil { if err != nil {
return "", err return "", err
} }
return readGiteaCreds(vaultAddr, clientToken) return readGiteaCreds(vaultAddr, clientToken, credsPath)
} }
+155
View File
@@ -0,0 +1,155 @@
package agent
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
)
func TestGiteaCredsPath(t *testing.T) {
tests := []struct {
name string
agentLogin string
credsPath string
want string
}{
{"no env keeps the historical path", "", "", "gitea/creds/unkin-agent"},
{"derived from AGENT_LOGIN", "repospawner", "", "gitea/creds/repospawner"},
{"GITEA_CREDS_PATH beats AGENT_LOGIN", "repospawner", "gitea/creds/someone-else", "gitea/creds/someone-else"},
{"GITEA_CREDS_PATH beats the default", "", "other-gitea/creds/bot", "other-gitea/creds/bot"},
{"override is trimmed for joining onto /v1/", "", " /gitea/creds/bot/ ", "gitea/creds/bot"},
{"blank override falls back to the login", "repospawner", " ", "gitea/creds/repospawner"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("AGENT_LOGIN", tt.agentLogin)
t.Setenv("GITEA_CREDS_PATH", tt.credsPath)
if got := GiteaCredsPath(); got != tt.want {
t.Errorf("GiteaCredsPath() = %q, want %q", got, tt.want)
}
})
}
}
// recordingVault serves the AppRole login plus any creds path under /v1/,
// recording which one was read so tests can assert the selected path.
type recordingVault struct {
mu sync.Mutex
credsPath string
}
func (v *recordingVault) path() string {
v.mu.Lock()
defer v.mu.Unlock()
return v.credsPath
}
// fakeEstate serves both Vault (/v1/...) and Gitea (/api/v1/...) from one
// server, so a test can drive the whole token-then-API flow.
func fakeEstate(t *testing.T, giteaToken, login string) (*httptest.Server, *recordingVault) {
t.Helper()
rec := &recordingVault{}
mux := http.NewServeMux()
mux.HandleFunc("/v1/auth/approle/login", func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, `{"auth":{"client_token":"s.vaulttoken"}}`)
})
mux.HandleFunc("/v1/", func(w http.ResponseWriter, r *http.Request) {
rec.mu.Lock()
rec.credsPath = strings.TrimPrefix(r.URL.Path, "/v1/")
rec.mu.Unlock()
_, _ = io.WriteString(w, `{"data":{"token":"`+giteaToken+`"}}`)
})
mux.HandleFunc("/api/v1/user", func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "token "+giteaToken {
t.Errorf("whoami auth header = %q, want token %s", got, giteaToken)
}
_, _ = io.WriteString(w, `{"login":"`+login+`","id":7}`)
})
mux.HandleFunc("/api/v1/repos/unkin/repo/pulls", func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "token "+giteaToken {
t.Errorf("create PR auth header = %q, want token %s", got, giteaToken)
}
var body CreatePROptions
_ = json.NewDecoder(r.Body).Decode(&body)
_, _ = io.WriteString(w, `{"number":12,"state":"open","html_url":"`+r.Host+`/pulls/12"}`)
})
return httptest.NewServer(mux), rec
}
// A service that sets AGENT_LOGIN must read its own creds path and act as its
// own Gitea identity for both whoami and PR creation.
func TestWhoamiAndPRUseSelectedCredsPath(t *testing.T) {
srv, rec := fakeEstate(t, "gitea-repospawner", "repospawner")
defer srv.Close()
t.Setenv("AGENT_LOGIN", "repospawner")
t.Setenv("GITEA_CREDS_PATH", "")
t.Setenv("VAULT_ADDR", srv.URL)
t.Setenv("GITEA_URL", srv.URL)
tok, err := fetchGiteaToken(VaultAddr(), RoleID(), GiteaCredsPath())
if err != nil {
t.Fatalf("fetchGiteaToken: %v", err)
}
if got := rec.path(); got != "gitea/creds/repospawner" {
t.Errorf("vault read path = %q, want gitea/creds/repospawner", got)
}
if tok != "gitea-repospawner" {
t.Fatalf("token = %q, want gitea-repospawner", tok)
}
c := NewGiteaClient(tok)
u, err := c.Whoami()
if err != nil {
t.Fatalf("Whoami: %v", err)
}
if u.Login != "repospawner" {
t.Errorf("whoami login = %q, want repospawner", u.Login)
}
pr, err := c.CreatePR("unkin/repo", CreatePROptions{Base: "main", Head: "feature", Title: "T"})
if err != nil {
t.Fatalf("CreatePR: %v", err)
}
if pr.Number != 12 {
t.Errorf("PR number = %d, want 12", pr.Number)
}
}
// GITEA_CREDS_PATH must win even when AGENT_LOGIN names a different identity.
func TestCredsPathOverrideBeatsAgentLogin(t *testing.T) {
srv, rec := fakeEstate(t, "gitea-override", "someone-else")
defer srv.Close()
t.Setenv("AGENT_LOGIN", "repospawner")
t.Setenv("GITEA_CREDS_PATH", "gitea/creds/someone-else")
t.Setenv("VAULT_ADDR", srv.URL)
if _, err := fetchGiteaToken(VaultAddr(), RoleID(), GiteaCredsPath()); err != nil {
t.Fatalf("fetchGiteaToken: %v", err)
}
if got := rec.path(); got != "gitea/creds/someone-else" {
t.Errorf("vault read path = %q, want gitea/creds/someone-else", got)
}
}
// With no env set the tools must still read the exact path they always did.
func TestCredsPathDefaultIsBackwardCompatible(t *testing.T) {
srv, rec := fakeEstate(t, "gitea-abc", "unkin-agent")
defer srv.Close()
t.Setenv("AGENT_LOGIN", "")
t.Setenv("GITEA_CREDS_PATH", "")
t.Setenv("VAULT_ADDR", srv.URL)
if _, err := fetchGiteaToken(VaultAddr(), RoleID(), GiteaCredsPath()); err != nil {
t.Fatalf("fetchGiteaToken: %v", err)
}
if got := rec.path(); got != "gitea/creds/unkin-agent" {
t.Errorf("vault read path = %q, want gitea/creds/unkin-agent", got)
}
}
+8 -7
View File
@@ -49,9 +49,10 @@ func approleLogin(vaultAddr, roleID string) (string, error) {
return out.Auth.ClientToken, nil return out.Auth.ClientToken, nil
} }
// readGiteaCreds reads the Gitea creds secret and returns the token field. // readGiteaCreds reads the Gitea creds secret at credsPath and returns the
func readGiteaCreds(vaultAddr, clientToken string) (string, error) { // token field.
url := strings.TrimRight(vaultAddr, "/") + "/v1/" + GiteaCredsPath func readGiteaCreds(vaultAddr, clientToken, credsPath string) (string, error) {
url := strings.TrimRight(vaultAddr, "/") + "/v1/" + credsPath
req, err := http.NewRequest(http.MethodGet, url, nil) req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil { if err != nil {
return "", err return "", err
@@ -60,12 +61,12 @@ func readGiteaCreds(vaultAddr, clientToken string) (string, error) {
resp, err := httpClient.Do(req) resp, err := httpClient.Do(req)
if err != nil { if err != nil {
return "", fmt.Errorf("vault read %s: %w", GiteaCredsPath, err) return "", fmt.Errorf("vault read %s: %w", credsPath, err)
} }
defer func() { _ = resp.Body.Close() }() defer func() { _ = resp.Body.Close() }()
data, _ := io.ReadAll(resp.Body) data, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("vault read %s: HTTP %d: %s", GiteaCredsPath, resp.StatusCode, strings.TrimSpace(string(data))) return "", fmt.Errorf("vault read %s: HTTP %d: %s", credsPath, resp.StatusCode, strings.TrimSpace(string(data)))
} }
var out struct { var out struct {
@@ -74,10 +75,10 @@ func readGiteaCreds(vaultAddr, clientToken string) (string, error) {
} `json:"data"` } `json:"data"`
} }
if err := json.Unmarshal(data, &out); err != nil { if err := json.Unmarshal(data, &out); err != nil {
return "", fmt.Errorf("vault read %s: decoding response: %w", GiteaCredsPath, err) return "", fmt.Errorf("vault read %s: decoding response: %w", credsPath, err)
} }
if out.Data.Token == "" { if out.Data.Token == "" {
return "", fmt.Errorf("vault read %s: no token field in secret", GiteaCredsPath) return "", fmt.Errorf("vault read %s: no token field in secret", credsPath)
} }
return out.Data.Token, nil return out.Data.Token, nil
} }
+134
View File
@@ -0,0 +1,134 @@
package agent
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
)
const (
// DefaultKVMount is the KV-v2 mount holding agent-facing secrets.
DefaultKVMount = "kv"
)
var (
// ErrVaultDenied marks a 403 from Vault (the AppRole policy lacks the capability).
ErrVaultDenied = errors.New("permission denied")
// ErrVaultNotFound marks a 404 from Vault (mount or secret does not exist).
ErrVaultNotFound = errors.New("not found")
)
// VaultClient issues authenticated requests against Vault/OpenBao using a token
// obtained from the agent AppRole.
type VaultClient struct {
Addr string
Token string
HTTP *http.Client
}
// NewVaultClient performs the AppRole login (role_id only, no secret_id) and
// returns a client bound to the resulting client_token.
func NewVaultClient(addr, roleID string) (*VaultClient, error) {
token, err := approleLogin(addr, roleID)
if err != nil {
return nil, err
}
return &VaultClient{Addr: addr, Token: token, HTTP: httpClient}, nil
}
func (c *VaultClient) do(method, path string, body any, out any) error {
var reader io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return err
}
reader = bytes.NewReader(b)
}
url := strings.TrimRight(c.Addr, "/") + path
req, err := http.NewRequest(method, url, reader)
if err != nil {
return err
}
req.Header.Set("X-Vault-Token", c.Token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.HTTP.Do(req)
if err != nil {
return fmt.Errorf("vault %s %s: %w", method, path, err)
}
defer func() { _ = resp.Body.Close() }()
data, _ := io.ReadAll(resp.Body)
switch {
case resp.StatusCode == http.StatusForbidden:
return fmt.Errorf("vault %s %s: %w", method, path, ErrVaultDenied)
case resp.StatusCode == http.StatusNotFound:
return fmt.Errorf("vault %s %s: %w", method, path, ErrVaultNotFound)
case resp.StatusCode < 200 || resp.StatusCode >= 300:
return fmt.Errorf("vault %s %s: HTTP %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(data)))
}
if out != nil && len(data) > 0 {
// Response bodies here carry secret material, so decode failures never
// echo the body.
if err := json.Unmarshal(data, out); err != nil {
return fmt.Errorf("vault %s %s: decoding response: %w", method, path, err)
}
}
return nil
}
// kvDataPath builds the KV-v2 data path for a mount and secret path.
func kvDataPath(mount, path string) string {
return "/v1/" + strings.Trim(mount, "/") + "/data/" + strings.Trim(path, "/")
}
// ReadKV returns the data map of a KV-v2 secret.
func (c *VaultClient) ReadKV(mount, path string) (map[string]any, error) {
var out struct {
Data struct {
Data map[string]any `json:"data"`
} `json:"data"`
}
if err := c.do(http.MethodGet, kvDataPath(mount, path), nil, &out); err != nil {
return nil, err
}
if out.Data.Data == nil {
return nil, fmt.Errorf("vault read %s/%s: secret has no data", mount, path)
}
return out.Data.Data, nil
}
// WriteKV writes a KV-v2 secret and returns the version it created.
func (c *VaultClient) WriteKV(mount, path string, data map[string]string) (int, error) {
var out struct {
Data struct {
Version int `json:"version"`
} `json:"data"`
}
body := map[string]any{"data": data}
if err := c.do(http.MethodPost, kvDataPath(mount, path), body, &out); err != nil {
return 0, err
}
if out.Data.Version == 0 {
return 0, fmt.Errorf("vault write %s/%s: no version in response", mount, path)
}
return out.Data.Version, nil
}
// StringField returns the first non-empty string value among the given keys.
func StringField(data map[string]any, keys ...string) string {
for _, k := range keys {
if s, ok := data[k].(string); ok && s != "" {
return s
}
}
return ""
}
+84 -3
View File
@@ -1,5 +1,7 @@
package agent package agent
import "time"
// PRState is a point-in-time snapshot of the PR attributes watchpr tracks. // PRState is a point-in-time snapshot of the PR attributes watchpr tracks.
type PRState struct { type PRState struct {
Ref PRRef `json:"ref"` Ref PRRef `json:"ref"`
@@ -20,8 +22,11 @@ func FetchState(c *GiteaClient, ref PRRef, agentLogin string) (PRState, error) {
if err != nil { if err != nil {
return PRState{}, err return PRState{}, err
} }
// A 404 here means the head commit is gone (branch deleted after a squash/
// rebase merge); the PR object is still authoritative, so treat CI as absent
// rather than discarding the merge signal and hanging the watch loop.
ci, err := c.CommitStatus(ref.RepoPath(), pr.Head.Sha) ci, err := c.CommitStatus(ref.RepoPath(), pr.Head.Sha)
if err != nil { if err != nil && !isNotFound(err) {
return PRState{}, err return PRState{}, err
} }
comments, err := c.ListComments(ref.RepoPath(), ref.Number) comments, err := c.ListComments(ref.RepoPath(), ref.Number)
@@ -41,6 +46,78 @@ func FetchState(c *GiteaClient, ref PRRef, agentLogin string) (PRState, error) {
}, nil }, nil
} }
// StateFetcher fetches the current PRState for a ref. *GiteaClient satisfies it
// via its FetchState method; tests inject fakes.
type StateFetcher interface {
FetchState(ref PRRef, agentLogin string) (PRState, error)
}
// FetchState makes *GiteaClient a StateFetcher.
func (c *GiteaClient) FetchState(ref PRRef, agentLogin string) (PRState, error) {
return FetchState(c, ref, agentLogin)
}
// WatchResult is the change that ended a watch.
type WatchResult struct {
Ref PRRef
Reason string
State PRState
}
// terminalState reports whether a PR has reached a final state from which no
// further meaningful change is possible, with a human-readable reason. Unlike a
// transition (see MeaningfulChange) this holds for a single snapshot, so it also
// catches a PR that is already merged/closed the moment watchpr starts.
func terminalState(st PRState) (bool, string) {
if st.Merged {
return true, "PR merged"
}
if st.State == "closed" {
return true, "PR closed without merging"
}
return false, ""
}
// Watch establishes a baseline for each ref, then polls on every tick until a
// tracked PR changes meaningfully, returning the first such change. A PR that is
// already terminal (merged/closed) at baseline is reported immediately rather
// than polled forever. Poll errors are handed to onError and never stop the
// loop; only a baseline fetch error aborts. onBaseline, if set, fires once after
// all baselines are captured and before the first tick.
func Watch(f StateFetcher, refs []PRRef, agentLogin string, ticks <-chan time.Time, onBaseline func(), onError func(PRRef, error)) (WatchResult, error) {
prev := make(map[string]PRState, len(refs))
for _, ref := range refs {
st, err := f.FetchState(ref, agentLogin)
if err != nil {
return WatchResult{}, err
}
if terminal, reason := terminalState(st); terminal {
return WatchResult{Ref: ref, Reason: reason, State: st}, nil
}
prev[ref.String()] = st
}
if onBaseline != nil {
onBaseline()
}
for range ticks {
for _, ref := range refs {
key := ref.String()
cur, err := f.FetchState(ref, agentLogin)
if err != nil {
if onError != nil {
onError(ref, err)
}
continue
}
if changed, reason := MeaningfulChange(prev[key], cur); changed {
return WatchResult{Ref: ref, Reason: reason, State: cur}, nil
}
prev[key] = cur
}
}
return WatchResult{}, nil
}
// countNonAgentComments counts comments authored by anyone other than agentLogin. // countNonAgentComments counts comments authored by anyone other than agentLogin.
func countNonAgentComments(comments []Comment, agentLogin string) int { func countNonAgentComments(comments []Comment, agentLogin string) int {
n := 0 n := 0
@@ -67,7 +144,7 @@ func isFailedCI(state string) bool {
// - the PR closed without merging // - the PR closed without merging
// - a new comment from someone other than the agent // - a new comment from someone other than the agent
// - CI transitioned into failure/error // - CI transitioned into failure/error
// - the PR lost mergeability (a conflict appeared) // - the PR lost mergeability (a conflict appeared) for two consecutive polls
func MeaningfulChange(prev, cur PRState) (bool, string) { func MeaningfulChange(prev, cur PRState) (bool, string) {
if !prev.Merged && cur.Merged { if !prev.Merged && cur.Merged {
return true, "PR merged" return true, "PR merged"
@@ -82,7 +159,11 @@ func MeaningfulChange(prev, cur PRState) (bool, string) {
if isFailedCI(cur.CIStatus) && !isFailedCI(prev.CIStatus) { if isFailedCI(cur.CIStatus) && !isFailedCI(prev.CIStatus) {
return true, "CI failed (" + cur.CIStatus + ")" return true, "CI failed (" + cur.CIStatus + ")"
} }
if prev.Mergeable && !cur.Mergeable && cur.State == "open" { // Gitea computes mergeability asynchronously, so a PR can briefly report
// mergeable=false right after a push. Require the loss to persist across two
// consecutive polls (both prev and cur false, still open) before treating it
// as a real conflict; a single false poll is debounced.
if !prev.Mergeable && !cur.Mergeable && cur.State == "open" {
return true, "PR lost mergeability (conflict)" return true, "PR lost mergeability (conflict)"
} }
return false, "" return false, ""
+248 -2
View File
@@ -1,6 +1,14 @@
package agent package agent
import "testing" import (
"errors"
"fmt"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
)
func base() PRState { func base() PRState {
return PRState{ return PRState{
@@ -17,6 +25,7 @@ func base() PRState {
func TestMeaningfulChange(t *testing.T) { func TestMeaningfulChange(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
mutatePrev func(s *PRState)
mutate func(s *PRState) mutate func(s *PRState)
wantChange bool wantChange bool
}{ }{
@@ -56,10 +65,27 @@ func TestMeaningfulChange(t *testing.T) {
wantChange: true, wantChange: true,
}, },
{ {
name: "lost mergeability alerts", // A single mergeable=false poll is debounced: Gitea often reports
// this transiently right after a push.
name: "mergeable true to false for one poll is benign",
mutate: func(s *PRState) { s.Mergeable = false },
wantChange: false,
},
{
// mergeable=false persisting into a second consecutive poll is a
// real conflict and alerts.
name: "mergeable false persisting a second poll alerts",
mutatePrev: func(s *PRState) { s.Mergeable = false },
mutate: func(s *PRState) { s.Mergeable = false }, mutate: func(s *PRState) { s.Mergeable = false },
wantChange: true, wantChange: true,
}, },
{
// mergeable recovered (false then true) must not alert.
name: "mergeable recovered false to true is benign",
mutatePrev: func(s *PRState) { s.Mergeable = false },
mutate: func(s *PRState) {},
wantChange: false,
},
{ {
name: "new head sha alone is benign", name: "new head sha alone is benign",
mutate: func(s *PRState) { s.HeadSHA = "def456" }, mutate: func(s *PRState) { s.HeadSHA = "def456" },
@@ -69,6 +95,9 @@ func TestMeaningfulChange(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
prev := base() prev := base()
if tt.mutatePrev != nil {
tt.mutatePrev(&prev)
}
cur := base() cur := base()
tt.mutate(&cur) tt.mutate(&cur)
got, reason := MeaningfulChange(prev, cur) got, reason := MeaningfulChange(prev, cur)
@@ -103,6 +132,223 @@ func TestMeaningfulChangeStaysFailed(t *testing.T) {
} }
} }
// fakeFetcher returns a scripted sequence of (state, error) results per call,
// so tests can drive Watch across baseline and successive polls.
type fakeFetcher struct {
states []PRState
errs []error
calls int
}
func (f *fakeFetcher) FetchState(ref PRRef, agentLogin string) (PRState, error) {
i := f.calls
if i >= len(f.states) {
i = len(f.states) - 1
}
f.calls++
var err error
if f.calls-1 < len(f.errs) {
err = f.errs[f.calls-1]
}
return f.states[i], err
}
func TestTerminalState(t *testing.T) {
open := base()
open.State = "open"
if term, _ := terminalState(open); term {
t.Errorf("open PR should not be terminal")
}
merged := base()
merged.State = "closed"
merged.Merged = true
if term, reason := terminalState(merged); !term || reason != "PR merged" {
t.Errorf("merged PR: got (%v, %q), want (true, %q)", term, reason, "PR merged")
}
closed := base()
closed.State = "closed"
if term, reason := terminalState(closed); !term || reason != "PR closed without merging" {
t.Errorf("closed PR: got (%v, %q), want (true, %q)", term, reason, "PR closed without merging")
}
}
// The production hang: a PR that is already merged when watchpr starts must be
// reported at baseline and exit, without ever consuming a tick. Before the fix,
// Watch only reported transitions, so a terminal baseline was polled forever.
func TestWatchExitsWhenAlreadyMergedAtBaseline(t *testing.T) {
merged := base()
merged.State = "closed"
merged.Merged = true
f := &fakeFetcher{states: []PRState{merged}}
ticks := make(chan time.Time) // never fires; a hang would block here
res, err := Watch(f, []PRRef{merged.Ref}, "unkin-agent", ticks, nil, nil)
if err != nil {
t.Fatalf("Watch: %v", err)
}
if res.Reason != "PR merged" {
t.Errorf("reason = %q, want %q", res.Reason, "PR merged")
}
if f.calls != 1 {
t.Errorf("fetch calls = %d, want 1 (baseline only)", f.calls)
}
}
// A PR already closed-without-merge at baseline must also exit immediately.
func TestWatchExitsWhenAlreadyClosedAtBaseline(t *testing.T) {
closed := base()
closed.State = "closed"
f := &fakeFetcher{states: []PRState{closed}}
ticks := make(chan time.Time)
res, err := Watch(f, []PRRef{closed.Ref}, "unkin-agent", ticks, nil, nil)
if err != nil {
t.Fatalf("Watch: %v", err)
}
if res.Reason != "PR closed without merging" {
t.Errorf("reason = %q, want %q", res.Reason, "PR closed without merging")
}
}
// An open→merged transition observed during polling must be detected and end
// the watch.
func TestWatchDetectsMergeAfterBaseline(t *testing.T) {
open := base()
merged := base()
merged.State = "closed"
merged.Merged = true
f := &fakeFetcher{states: []PRState{open, merged}} // baseline open, then merged
baselineFired := false
ticks := make(chan time.Time, 1)
ticks <- time.Now()
res, err := Watch(f, []PRRef{open.Ref}, "unkin-agent",
ticks, func() { baselineFired = true }, nil)
if err != nil {
t.Fatalf("Watch: %v", err)
}
if !baselineFired {
t.Errorf("onBaseline should fire for an open baseline")
}
if res.Reason != "PR merged" {
t.Errorf("reason = %q, want %q", res.Reason, "PR merged")
}
}
// A transient poll error must be reported and the loop must keep polling; a
// merge on the following tick still ends the watch.
func TestWatchContinuesPastPollError(t *testing.T) {
open := base()
merged := base()
merged.State = "closed"
merged.Merged = true
// baseline ok, first poll errors, second poll sees the merge.
f := &fakeFetcher{
states: []PRState{open, open, merged},
errs: []error{nil, errors.New("HTTP 502"), nil},
}
var gotErr error
ticks := make(chan time.Time, 2)
ticks <- time.Now()
ticks <- time.Now()
res, err := Watch(f, []PRRef{open.Ref}, "unkin-agent", ticks, nil,
func(_ PRRef, e error) { gotErr = e })
if err != nil {
t.Fatalf("Watch: %v", err)
}
if gotErr == nil {
t.Errorf("onError should have received the transient poll error")
}
if res.Reason != "PR merged" {
t.Errorf("reason = %q, want %q (loop must survive the error)", res.Reason, "PR merged")
}
}
// A baseline fetch error aborts the watch (nothing to establish a baseline
// from), unlike a mid-loop poll error.
func TestWatchBaselineErrorAborts(t *testing.T) {
f := &fakeFetcher{states: []PRState{base()}, errs: []error{errors.New("HTTP 500")}}
ticks := make(chan time.Time)
if _, err := Watch(f, []PRRef{base().Ref}, "unkin-agent", ticks, nil, nil); err == nil {
t.Fatal("Watch should return the baseline fetch error")
}
}
// The production hang, end to end: a watched PR stays open across several polls,
// then is squash-merged and its branch deleted, so the commit-status endpoint
// 404s. Driven through a real *GiteaClient, the watch loop must still detect the
// merge on the poll it happens. Before the fix, FetchState returned an error on
// that poll (the 404 masked the merge), so the loop reported only poll errors
// and never exited -- exactly the 37-minute hang seen in production.
func TestWatchDetectsMergeWhenCommitGone(t *testing.T) {
const sha = "cafebabecafebabe"
var polls atomic.Int32 // number of PR fetches so far
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/repos/unkin/repo/pulls/7", func(w http.ResponseWriter, r *http.Request) {
n := polls.Add(1)
if n >= 4 { // baseline + two unchanged polls, then merged
_, _ = fmt.Fprintf(w, `{"number":7,"state":"closed","merged":true,"mergeable":true,"head":{"sha":%q}}`, sha)
return
}
_, _ = fmt.Fprintf(w, `{"number":7,"state":"open","merged":false,"mergeable":true,"head":{"sha":%q}}`, sha)
})
mux.HandleFunc("/api/v1/repos/unkin/repo/commits/"+sha+"/status", func(w http.ResponseWriter, r *http.Request) {
if polls.Load() >= 4 { // branch deleted post-merge: commit is gone
w.WriteHeader(http.StatusNotFound)
_, _ = fmt.Fprint(w, `{"message":"not found"}`)
return
}
_, _ = fmt.Fprint(w, `{"state":"success"}`)
})
mux.HandleFunc("/api/v1/repos/unkin/repo/issues/7/comments", func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprint(w, `[]`)
})
srv := httptest.NewServer(mux)
defer srv.Close()
c := &GiteaClient{BaseURL: srv.URL, Token: "t", HTTP: srv.Client()}
ref := PRRef{Owner: "unkin", Repo: "repo", Number: 7}
// A real ticker so the loop advances on its own; a hang (the bug) is caught
// by the timeout below instead of blocking the suite.
tk := time.NewTicker(5 * time.Millisecond)
defer tk.Stop()
var pollErr atomic.Pointer[error]
type outcome struct {
res WatchResult
err error
}
done := make(chan outcome, 1)
go func() {
res, err := Watch(c, []PRRef{ref}, "unkin-agent", tk.C, nil,
func(_ PRRef, e error) { pollErr.Store(&e) })
done <- outcome{res, err}
}()
select {
case o := <-done:
if o.err != nil {
t.Fatalf("Watch: %v", o.err)
}
if o.res.Reason != "PR merged" {
t.Errorf("reason = %q, want %q", o.res.Reason, "PR merged")
}
if p := pollErr.Load(); p != nil {
t.Errorf("no poll error expected once a 404 status is tolerated, got: %v", *p)
}
case <-time.After(3 * time.Second):
var got error
if p := pollErr.Load(); p != nil {
got = *p
}
t.Fatalf("Watch hung: a merge with a gone head commit was never detected (last poll error: %v)", got)
}
}
func TestCountNonAgentComments(t *testing.T) { func TestCountNonAgentComments(t *testing.T) {
comments := []Comment{ comments := []Comment{
{User: User{Login: "unkin-agent"}}, {User: User{Login: "unkin-agent"}},
+36
View File
@@ -36,6 +36,18 @@ contents:
mode: 0755 mode: 0755
owner: root owner: root
group: root group: root
- src: dist/agentws
dst: /usr/bin/agentws
file_info:
mode: 0755
owner: root
group: root
- src: dist/agentvault
dst: /usr/bin/agentvault
file_info:
mode: 0755
owner: root
group: root
# Shell completions (generated by scripts/build-rpm.sh before packaging). # Shell completions (generated by scripts/build-rpm.sh before packaging).
- src: dist/completions/agentpr.bash - src: dist/completions/agentpr.bash
@@ -62,3 +74,27 @@ contents:
dst: /usr/share/fish/vendor_completions.d/watchpr.fish dst: /usr/share/fish/vendor_completions.d/watchpr.fish
file_info: file_info:
mode: 0644 mode: 0644
- src: dist/completions/agentws.bash
dst: /usr/share/bash-completion/completions/agentws
file_info:
mode: 0644
- src: dist/completions/_agentws
dst: /usr/share/zsh/site-functions/_agentws
file_info:
mode: 0644
- src: dist/completions/agentws.fish
dst: /usr/share/fish/vendor_completions.d/agentws.fish
file_info:
mode: 0644
- src: dist/completions/agentvault.bash
dst: /usr/share/bash-completion/completions/agentvault
file_info:
mode: 0644
- src: dist/completions/_agentvault
dst: /usr/share/zsh/site-functions/_agentvault
file_info:
mode: 0644
- src: dist/completions/agentvault.fish
dst: /usr/share/fish/vendor_completions.d/agentvault.fish
file_info:
mode: 0644
+4 -4
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# #
# Package the (already built) agentpr and watchpr binaries into an RPM with # Package the (already built) agentpr, watchpr, agentws and agentvault binaries into an RPM
# nfpm, bundling generated bash/zsh/fish shell completions. # with nfpm, bundling generated bash/zsh/fish shell completions.
# Usage: scripts/build-rpm.sh [version] (version defaults to $CI_COMMIT_TAG) # Usage: scripts/build-rpm.sh [version] (version defaults to $CI_COMMIT_TAG)
# #
set -euo pipefail set -euo pipefail
@@ -12,7 +12,7 @@ cd "${ROOT_DIR}"
VERSION="${1:-${CI_COMMIT_TAG:-0.0.0-dev}}" VERSION="${1:-${CI_COMMIT_TAG:-0.0.0-dev}}"
VERSION="${VERSION#v}" # strip a leading v VERSION="${VERSION#v}" # strip a leading v
PACKAGE="agent-tools" PACKAGE="agent-tools"
BINARIES=(agentpr watchpr) BINARIES=(agentpr watchpr agentws agentvault)
DIST="dist" DIST="dist"
for b in "${BINARIES[@]}"; do for b in "${BINARIES[@]}"; do
@@ -37,7 +37,7 @@ export PACKAGE_VERSION="${VERSION}"
export PACKAGE_RELEASE="1" export PACKAGE_RELEASE="1"
export PACKAGE_ARCH="amd64" export PACKAGE_ARCH="amd64"
export PACKAGE_PLATFORM="linux" export PACKAGE_PLATFORM="linux"
export PACKAGE_DESCRIPTION="CLI tools for Gitea automation as the unkin-agent user: agentpr (create PRs/comments) and watchpr (poll PRs and alert on meaningful change)" export PACKAGE_DESCRIPTION="CLI tools for automation as the unkin-agent user: agentpr (create PRs/comments), watchpr (poll PRs and alert on meaningful change), agentws (manage per-branch git worktrees) and agentvault (deterministic Vault flows)"
export PACKAGE_MAINTAINER="Ben Vincent <ben@unkin.net>" export PACKAGE_MAINTAINER="Ben Vincent <ben@unkin.net>"
export PACKAGE_HOMEPAGE="https://git.unkin.net/unkin/agent-tools" export PACKAGE_HOMEPAGE="https://git.unkin.net/unkin/agent-tools"
export PACKAGE_LICENSE="MIT" export PACKAGE_LICENSE="MIT"