Add the initial repospawner service #1

Merged
benvin merged 2 commits from benvin/initial-service into main 2026-08-30 15:06:44 +10:00
Member

Why

New repositories in the estate come from terraform-git's
config/git.unkin.net/unkin/repository/<name>.yaml tree, so every new repo
means hand-writing that YAML, opening a PR, waiting for the merge, then
remembering to activate the repo in Woodpecker. repospawner does that from a
JSON request (or a small web form) without moving the review gate: the merge
approval stays exactly where it is today.

What this adds

  • Go single binary: API server plus an embedded, self-contained UI (no CDNs,
    secureHeaders CSP, oauth2-proxy group gate re-checked server-side).
  • POST /api/requests validates the request (DNS-ish name, description,
    non-empty status checks), checks the name against terraform-git's contents
    API, and launches a Kubernetes Job. GET /api/requests[/{id}] feeds the UI
    table; /livez and /readyz (k8s API reachable) for the probes.
  • Three Job subcommands on the same image, mediamover style:
    job pr (branch, write the YAML, open the PR), job watch (poll the PR to
    merge or close, 7d deadline), job woodpecker-enable (forge repo id ->
    POST /api/repos?forge_remote_id=, then verify). Each reports a JSON result
    on the pod termination message.
  • A reconcile loop folds Job/pod status into request state every 10s and
    creates the next Job. It doubles as startup recovery: state is rebuilt from
    Job labels and annotations, so the deployment is one replica, Recreate.
  • internal/vaultauth: native Vault kubernetes login with the projected
    audience: vault SA token, then a short-lived Gitea credential read per use.
  • CI mirroring mediamark: test, pre-commit (gofmt/vet + golangci-lint +
    uvx pre-commit), build (dry-run image), docker (push on v*), all on
    serviceAccountName: repospawner-ci.
  • README: env table, curl examples, ASCII job-flow diagram, RBAC/deploy
    prerequisites.

Notes on two spec points

"Return a pull request URL on success." Opening the PR needs a Vault-minted
token and several forge round-trips, so it happens in the Job, not in the
request handler. POST therefore answers 202 with {id, status_url}, and the
PR URL appears on GET /api/requests/{id} (and in the UI table) as soon as the
PR Job reports it. Same information, without holding the HTTP request open on a
forge call that can take seconds.

No agentpr in-cluster. agentpr authenticates to Vault with an AppRole
whose CIDR binding excludes pod addresses, so the PR Job would fail there.
repospawner logs in through the kubernetes auth mount with its projected SA
token instead, mints per operation, never logs a token, and re-mints on a 401
(the watch Job outlives the ~1h credential).

Deviation

The PR Job writes the config through Gitea's branch + contents API rather than
cloning and pushing with git. The runtime image is distroless static, which has
no git binary or shell; the API path is a single atomic commit with no working
copy to manage, and an existing branch is tolerated so a retried Job can finish
what its predecessor started.

Checks

gofmt, go vet, golangci-lint run (0 issues), uvx pre-commit run --all-files and go test -race all clean locally; 11 packages with tests
covering validation, YAML golden output, the Gitea/Vault/Woodpecker clients over
httptest (including the 401 re-mint), Job spec construction, termination
message parsing, the state machine, state reconstruction from Job lists, the
group middleware and the handler contracts.

CI needs the repospawner-ci ServiceAccount, which is in flight in argocd-apps.

## Why New repositories in the estate come from `terraform-git`'s `config/git.unkin.net/unkin/repository/<name>.yaml` tree, so every new repo means hand-writing that YAML, opening a PR, waiting for the merge, then remembering to activate the repo in Woodpecker. repospawner does that from a JSON request (or a small web form) without moving the review gate: the merge approval stays exactly where it is today. ## What this adds - Go single binary: API server plus an embedded, self-contained UI (no CDNs, `secureHeaders` CSP, oauth2-proxy group gate re-checked server-side). - `POST /api/requests` validates the request (DNS-ish name, description, non-empty status checks), checks the name against terraform-git's contents API, and launches a Kubernetes Job. `GET /api/requests[/{id}]` feeds the UI table; `/livez` and `/readyz` (k8s API reachable) for the probes. - Three Job subcommands on the same image, `mediamover` style: `job pr` (branch, write the YAML, open the PR), `job watch` (poll the PR to merge or close, 7d deadline), `job woodpecker-enable` (forge repo id -> `POST /api/repos?forge_remote_id=`, then verify). Each reports a JSON result on the pod termination message. - A reconcile loop folds Job/pod status into request state every 10s and creates the next Job. It doubles as startup recovery: state is rebuilt from Job labels and annotations, so the deployment is one replica, `Recreate`. - `internal/vaultauth`: native Vault kubernetes login with the projected `audience: vault` SA token, then a short-lived Gitea credential read per use. - CI mirroring mediamark: `test`, `pre-commit` (gofmt/vet + golangci-lint + `uvx pre-commit`), `build` (dry-run image), `docker` (push on `v*`), all on `serviceAccountName: repospawner-ci`. - README: env table, curl examples, ASCII job-flow diagram, RBAC/deploy prerequisites. ## Notes on two spec points **"Return a pull request URL on success."** Opening the PR needs a Vault-minted token and several forge round-trips, so it happens in the Job, not in the request handler. `POST` therefore answers `202` with `{id, status_url}`, and the PR URL appears on `GET /api/requests/{id}` (and in the UI table) as soon as the PR Job reports it. Same information, without holding the HTTP request open on a forge call that can take seconds. **No `agentpr` in-cluster.** `agentpr` authenticates to Vault with an AppRole whose CIDR binding excludes pod addresses, so the PR Job would fail there. repospawner logs in through the kubernetes auth mount with its projected SA token instead, mints per operation, never logs a token, and re-mints on a `401` (the watch Job outlives the ~1h credential). ## Deviation The PR Job writes the config through Gitea's branch + contents API rather than cloning and pushing with git. The runtime image is distroless static, which has no git binary or shell; the API path is a single atomic commit with no working copy to manage, and an existing branch is tolerated so a retried Job can finish what its predecessor started. ## Checks `gofmt`, `go vet`, `golangci-lint run` (0 issues), `uvx pre-commit run --all-files` and `go test -race` all clean locally; 11 packages with tests covering validation, YAML golden output, the Gitea/Vault/Woodpecker clients over `httptest` (including the 401 re-mint), Job spec construction, termination message parsing, the state machine, state reconstruction from Job lists, the group middleware and the handler contracts. CI needs the `repospawner-ci` ServiceAccount, which is in flight in argocd-apps.
unkin-agent added 1 commit 2026-08-30 14:34:13 +10:00
Add the initial repospawner service
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
f1bcb8cd3a
repospawner turns JSON new-repo requests into terraform-git pull requests
via kubernetes Jobs, follows those PRs to merge and optionally activates
the repository in Woodpecker.
unkin-agent added 1 commit 2026-08-30 14:53:29 +10:00
Address review findings on the initial service
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
8786636f7c
Four issues from the review of the initial repospawner service, none of
which change the shape of a request or the file terraform-git receives.

- Encode status checks as one --check flag per context on the
  server-to-job hop, so a separator inside a context can no longer turn
  one context into several; ban commas (and cap lengths) in Validate as
  well, since a real context never holds one.
- Fail a merged request that has waited five minutes for a Woodpecker
  token that vanished after acceptance, surfacing "woodpecker token
  unavailable" through the API, instead of warning in the log forever
  from enabling-ci. Advance now leaves a terminal request alone so the
  failure sticks.
- Hold a per-name lock from the duplicate checks through the store write,
  so two concurrent submissions of one name cannot both be accepted.
- Cap the description at 500 characters and the status checks at 20
  contexts of 100 characters each, and mirror the first two caps in the
  form.
Author
Member

Review findings addressed in 8786636:

  • status-check round-trip: the pr Job now takes one --check flag per context instead of a comma-joined --checks, so nothing can split a context in two; Validate() also rejects commas outright. Round-trip test (jobs.PR argv -> parsePRArgs) covers separator-bearing contexts.
  • stuck enabling-ci: a merged request whose Woodpecker token vanished after acceptance now fails with woodpecker token unavailable after 30 reconcile passes (~5min) and the reason is served on the request; Advance leaves a terminal request alone so it sticks. Old skip test split into wait / fail / recover-when-token-returns.
  • name-claim TOCTOU: a per-name mutex is held from HasActiveName through store.Put; 8 concurrent POSTs for one name now yield exactly one 202 (verified failing without the lock).
  • size caps: description <= 500 chars, <= 20 checks of <= 100 chars each, with tests either side of each cap; form inputs carry matching maxlength.

gofmt/vet/golangci-lint clean, go test -race ./... green, pre-commit passes.

Review findings addressed in 8786636: - **status-check round-trip**: the pr Job now takes one `--check` flag per context instead of a comma-joined `--checks`, so nothing can split a context in two; `Validate()` also rejects commas outright. Round-trip test (`jobs.PR` argv -> `parsePRArgs`) covers separator-bearing contexts. - **stuck enabling-ci**: a merged request whose Woodpecker token vanished after acceptance now fails with `woodpecker token unavailable` after 30 reconcile passes (~5min) and the reason is served on the request; `Advance` leaves a terminal request alone so it sticks. Old skip test split into wait / fail / recover-when-token-returns. - **name-claim TOCTOU**: a per-name mutex is held from `HasActiveName` through `store.Put`; 8 concurrent POSTs for one name now yield exactly one 202 (verified failing without the lock). - **size caps**: description <= 500 chars, <= 20 checks of <= 100 chars each, with tests either side of each cap; form inputs carry matching `maxlength`. gofmt/vet/golangci-lint clean, `go test -race ./...` green, pre-commit passes.
benvin merged commit 1b566022ac into main 2026-08-30 15:06:44 +10:00
Sign in to join this conversation.