Files
unkin-agent 8786636f7c
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Address review findings on the initial service
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.
2026-08-30 14:53:27 +10:00

9.2 KiB

repospawner

repospawner turns a JSON "I want a new repository" request into a terraform-git pull request, follows that pull request to its merge, and optionally activates the new repository in Woodpecker. New repositories in the estate are created by Terraform from config/git.unkin.net/unkin/repository/<name>.yaml; repospawner writes that file and opens the PR so the review gate stays exactly where it is.

It is one Go binary carrying its own UI. The server runs as a Deployment and launches the work as Kubernetes Jobs running the same image with a different argv, so there is never a second image to keep in step.

Flow

  browser / curl
        |
        |  POST /api/requests  {name, description, woodpecker, status_checks}
        v
  +-------------+   name in use?   +----------------+
  | repospawner |----------------->| terraform-git  |  GET contents/<name>.yaml
  |   server    |<-----------------|     (Gitea)    |  404 = free, 200 = 409
  +-------------+                  +----------------+
        |  202 {id, status_url}
        |
        |  creates Job  repospawner-pr-<id>
        v
  +-------------------------------------------------+
  | job pr      branch repospawner/<name>           |
  |             write config/.../<name>.yaml        |
  |             open the pull request               |
  |             -> /dev/termination-log             |
  |                {"pr_number":N,"pr_url":"..."}   |
  +-------------------------------------------------+
        |  server reads the termination message; state = pr-open
        |
        |  creates Job  repospawner-watch-<id>
        v
  +-------------------------------------------------+
  | job watch   poll the PR every 10s (7d deadline) |
  |             -> {"merged":true} / {"closed":true}|
  +-------------------------------------------------+
        |  merged -> state = merged
        |
        |  creates Job  repospawner-woodpecker-<id>   (only if woodpecker:true)
        v
  +-------------------------------------------------+
  | job woodpecker-enable                           |
  |   GET gitea repo id -> POST /api/repos?forge_   |
  |   remote_id=<id> -> verify -> {"enabled":true}  |
  +-------------------------------------------------+
        |
        v
     state = ready

Every Job is labelled repospawner.unkin.net/request=<id> and repospawner.unkin.net/type=pr|watch|woodpecker, carries the request payload in annotations, sets ttlSecondsAfterFinished: 3600 and backoffLimit: 2.

Request state lives in memory and is reconstructed on startup from those Job labels and annotations, so the Deployment must run one replica with the Recreate strategy. A request whose Jobs have all aged out of the cluster is gone from the list; the terraform-git PR it produced is not.

API

Every route below the health probes is gated on the oauth2-proxy group header (REPOSPAWNER_GROUPS_HEADER / REPOSPAWNER_ALLOWED_GROUPS) as well as by the front door.

Submit a request:

$ curl -sS -X POST https://repospawner.k8s.syd1.au.unkin.net/api/requests \
    -H 'Content-Type: application/json' \
    -d '{
          "name": "widget",
          "description": "Widget service",
          "woodpecker": true,
          "status_checks": [
            "ci/woodpecker/pr/build",
            "ci/woodpecker/pr/test",
            "ci/woodpecker/pr/pre-commit"
          ]
        }'
{"id":"9f2c1ab4d0e7","state":"opening-pr","status_url":"/api/requests/9f2c1ab4d0e7"}

The response is 202 Accepted: opening the pull request needs a Vault-minted Gitea token and two forge round-trips, which happen in the Job. Poll the status URL for the pull request URL:

$ curl -sS https://repospawner.k8s.syd1.au.unkin.net/api/requests/9f2c1ab4d0e7
{"id":"9f2c1ab4d0e7","name":"widget","state":"pr-open","pr_number":312,
 "pr_url":"https://git.unkin.net/unkin/terraform-git/pulls/312", ...}

List every request, newest first (this is what the UI table renders):

$ curl -sS https://repospawner.k8s.syd1.au.unkin.net/api/requests
Route Meaning
POST /api/requests Submit a request. 202 with {id, status_url, state}; 400 with {error, fields} on validation failure; 409 if the name is taken (in terraform-git, by an in-flight request, or by a request submitted concurrently); 503 if woodpecker:true but no Woodpecker token is mounted.
GET /api/requests Every request, most recent first.
GET /api/requests/{id} One request: state, pr_url, error.
GET /api/capabilities Whether Woodpecker enablement is available.
GET /livez Always ok while the process is up.
GET /readyz ok when the Kubernetes API is reachable.

States

opening-pr -> pr-open -> merged -> (enabling-ci ->) ready, with closed (PR closed unmerged) and failed (a Job failed; error says why) as the other terminal states. A request stuck in enabling-ci because the Woodpecker token was unmounted after it was accepted fails with woodpecker token unavailable after five minutes of waiting, rather than waiting forever.

Limits

name is at most 40 characters of [a-z0-9-], description at most 500, and a request carries at most 20 status check contexts of at most 100 characters each. A context may not contain a quote, a newline or a comma.

Generated config

Only the description and the status check contexts come from the request; everything else is fixed estate policy:

description: "Widget service"
private: false
default_branch: "main"
default_delete_branch_after_merge: true
default_merge_style: "squash"
branch_protection:
  - rule_name: "main"
    merge_whitelist_teams:
      - "Owners"
    enable_push: false
    status_check_contexts:
      - "ci/woodpecker/pr/build"
      - "ci/woodpecker/pr/test"
      - "ci/woodpecker/pr/pre-commit"
    approval_whitelist_users:
      - "benvin"

Configuration

Variable Default Meaning
REPOSPAWNER_LISTEN :8080 HTTP listen address.
REPOSPAWNER_NAMESPACE repospawner Namespace the Jobs are created in.
REPOSPAWNER_IMAGE (required) This deployment's own image reference; the Jobs run it.
REPOSPAWNER_JOB_SERVICE_ACCOUNT repospawner Service account the Jobs run as.
GITEA_URL https://git.unkin.net Forge base URL.
REPOSPAWNER_TFGIT_REPO unkin/terraform-git Repository owning the repo config tree.
VAULT_ADDR https://vault.service.consul:8200 Vault address.
REPOSPAWNER_VAULT_K8S_MOUNT k8s/au/syd1 Kubernetes auth mount.
REPOSPAWNER_VAULT_K8S_ROLE repospawner Kubernetes auth role.
REPOSPAWNER_VAULT_SA_TOKEN_PATH /var/run/secrets/vault/token Projected SA token with audience vault.
REPOSPAWNER_GITEA_CREDS_PATH gitea/creds/repospawner Vault path of the dynamic Gitea credential.
WOODPECKER_SERVER https://ci.k8s.syd1.au.unkin.net Woodpecker API base URL.
REPOSPAWNER_WOODPECKER_TOKEN_FILE /etc/repospawner/woodpecker/token Mounted Woodpecker API token. Absent means enablement is unavailable and woodpecker:true is refused with 503.
REPOSPAWNER_WOODPECKER_SECRET repospawner-woodpecker Secret the enablement Job mounts to obtain that file.
REPOSPAWNER_GROUPS_HEADER X-Forwarded-Groups oauth2-proxy group header.
REPOSPAWNER_ALLOWED_GROUPS akP-repospawner-user Allow-list; an empty list is a startup error.

Credentials

repospawner logs into Vault natively with the pod's projected service account token (audience vault) and reads a short-lived Gitea credential from gitea/creds/repospawner. It does not shell out to agentpr: that path authenticates with an AppRole whose CIDR binding excludes in-cluster addresses. Tokens are minted per operation, never logged, and re-minted when the forge answers 401 — the watch Job routinely outlives a one-hour credential.

Deploy prerequisites

Provided by the argocd-apps deployment, not by this repository:

  • ServiceAccount repospawner in namespace repospawner, bound to a Role granting jobs create/get/list/watch/delete, pods get/list/watch and pods/log get.
  • A projected serviceAccountToken volume with audience: vault mounted at /var/run/secrets/vault, on the Deployment. The Jobs declare their own.
  • Vault kubernetes auth role repospawner bound to that service account, with a policy allowing read on gitea/creds/repospawner. (Already applied.)
  • Secret repospawner-woodpecker with key token, seeded from kv/kubernetes/namespace/repospawner/default/woodpecker (key token) via a VaultStaticSecret. Optional: without it, Woodpecker enablement is refused rather than the service failing to start.
  • One replica, Recreate strategy — request state is rebuilt from Jobs.
  • ServiceAccount repospawner-ci for the Woodpecker pipelines.

Development

$ make build       # dist/repospawner
$ make test        # go test -race
$ make pre-commit  # gofmt, go vet, golangci-lint, pre-commit hooks

repospawner --help lists the subcommands; repospawner with no arguments serves the API and UI.

Releases are cut with make patch|minor|major, which tags and pushes; the tag pipeline builds and pushes artifactapi.k8s.syd1.au.unkin.net/docker-internal/repospawner.