From f1bcb8cd3aa0fd7d6f1eb69a892ef1a34a59ebc2 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sun, 30 Aug 2026 14:33:31 +1000 Subject: [PATCH 1/2] Add the initial repospawner service 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. --- .gitignore | 2 + .pre-commit-config.yaml | 27 ++ .woodpecker/build.yaml | 23 ++ .woodpecker/docker.yaml | 29 ++ .woodpecker/pre-commit.yaml | 49 +++ .woodpecker/test.yaml | 18 + Dockerfile | 22 + Makefile | 69 ++++ README.md | 205 +++++++++- cmd/repospawner/main.go | 193 +++++++++ go.mod | 49 +++ go.sum | 156 ++++++++ internal/auth/auth.go | 55 +++ internal/auth/auth_test.go | 71 ++++ internal/config/config.go | 147 +++++++ internal/config/config_test.go | 89 +++++ internal/gitea/gitea.go | 207 ++++++++++ internal/gitea/gitea_test.go | 184 +++++++++ internal/jobrun/jobrun.go | 204 ++++++++++ internal/jobrun/jobrun_test.go | 278 +++++++++++++ internal/jobs/jobs.go | 287 ++++++++++++++ internal/jobs/jobs_test.go | 222 +++++++++++ internal/jobs/state.go | 142 +++++++ internal/jobs/state_test.go | 164 ++++++++ internal/repospec/repospec.go | 133 +++++++ internal/repospec/repospec_test.go | 146 +++++++ internal/server/cluster.go | 80 ++++ internal/server/reconcile.go | 140 +++++++ internal/server/server.go | 222 +++++++++++ internal/server/server_test.go | 529 +++++++++++++++++++++++++ internal/store/store.go | 167 ++++++++ internal/store/store_test.go | 111 ++++++ internal/vaultauth/vaultauth.go | 180 +++++++++ internal/vaultauth/vaultauth_test.go | 165 ++++++++ internal/woodpecker/woodpecker.go | 102 +++++ internal/woodpecker/woodpecker_test.go | 74 ++++ ui/embed.go | 19 + ui/embed_test.go | 34 ++ ui/static/app.css | 126 ++++++ ui/static/app.js | 187 +++++++++ ui/static/index.html | 82 ++++ 41 files changed, 5388 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 .woodpecker/build.yaml create mode 100644 .woodpecker/docker.yaml create mode 100644 .woodpecker/pre-commit.yaml create mode 100644 .woodpecker/test.yaml create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 cmd/repospawner/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/auth/auth.go create mode 100644 internal/auth/auth_test.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/gitea/gitea.go create mode 100644 internal/gitea/gitea_test.go create mode 100644 internal/jobrun/jobrun.go create mode 100644 internal/jobrun/jobrun_test.go create mode 100644 internal/jobs/jobs.go create mode 100644 internal/jobs/jobs_test.go create mode 100644 internal/jobs/state.go create mode 100644 internal/jobs/state_test.go create mode 100644 internal/repospec/repospec.go create mode 100644 internal/repospec/repospec_test.go create mode 100644 internal/server/cluster.go create mode 100644 internal/server/reconcile.go create mode 100644 internal/server/server.go create mode 100644 internal/server/server_test.go create mode 100644 internal/store/store.go create mode 100644 internal/store/store_test.go create mode 100644 internal/vaultauth/vaultauth.go create mode 100644 internal/vaultauth/vaultauth_test.go create mode 100644 internal/woodpecker/woodpecker.go create mode 100644 internal/woodpecker/woodpecker_test.go create mode 100644 ui/embed.go create mode 100644 ui/embed_test.go create mode 100644 ui/static/app.css create mode 100644 ui/static/app.js create mode 100644 ui/static/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7759987 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/dist/ +*.out diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..d6d3b5a --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,27 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + - id: check-merge-conflict + + - repo: https://github.com/dnephin/pre-commit-golang + rev: v0.5.1 + hooks: + - id: go-fmt + - id: go-mod-tidy + + # repospawner has no root-level Go files (all under cmd/, internal/, ui/), so + # the dnephin go-vet hook (which runs `go vet` at the repo root) fails with + # "no Go files". Vet the whole module instead. + - repo: local + hooks: + - id: go-vet + name: go vet + entry: go vet ./... + language: system + types: [go] + pass_filenames: false diff --git a/.woodpecker/build.yaml b/.woodpecker/build.yaml new file mode 100644 index 0000000..d6d15df --- /dev/null +++ b/.woodpecker/build.yaml @@ -0,0 +1,23 @@ +when: + - event: pull_request + +steps: + - name: docker-build + image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/plugin-docker-buildx:latest + settings: + repo: artifactapi.k8s.syd1.au.unkin.net/docker-internal/repospawner + dockerfile: Dockerfile + dry_run: true + buildkit_config: | + [registry."artifactapi.k8s.syd1.au.unkin.net"] + ca = ["/etc/docker/certs.d/artifactapi.k8s.syd1.au.unkin.net/ca.crt"] + backend_options: + kubernetes: + serviceAccountName: repospawner-ci + resources: + requests: + memory: 1Gi + cpu: 1 + limits: + memory: 4Gi + cpu: 2 diff --git a/.woodpecker/docker.yaml b/.woodpecker/docker.yaml new file mode 100644 index 0000000..fbc87cf --- /dev/null +++ b/.woodpecker/docker.yaml @@ -0,0 +1,29 @@ +when: + - event: tag + ref: refs/tags/v* + +steps: + - name: docker + image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/plugin-docker-buildx:latest + settings: + registry: artifactapi.k8s.syd1.au.unkin.net + repo: artifactapi.k8s.syd1.au.unkin.net/docker-internal/repospawner + dockerfile: Dockerfile + build_args: + VERSION: ${CI_COMMIT_TAG} + buildkit_config: | + [registry."artifactapi.k8s.syd1.au.unkin.net"] + ca = ["/etc/docker/certs.d/artifactapi.k8s.syd1.au.unkin.net/ca.crt"] + tags: + - ${CI_COMMIT_TAG} + - latest + backend_options: + kubernetes: + serviceAccountName: repospawner-ci + resources: + requests: + memory: 1Gi + cpu: 1 + limits: + memory: 4Gi + cpu: 2 diff --git a/.woodpecker/pre-commit.yaml b/.woodpecker/pre-commit.yaml new file mode 100644 index 0000000..a079125 --- /dev/null +++ b/.woodpecker/pre-commit.yaml @@ -0,0 +1,49 @@ +when: + - event: pull_request + +steps: + - name: pre-commit + image: golang:1.25 + commands: + - test -z "$(gofmt -l .)" + - go vet ./... + backend_options: + kubernetes: + serviceAccountName: repospawner-ci + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 + + - name: lint + image: golangci/golangci-lint:latest + commands: + - golangci-lint run ./... + backend_options: + kubernetes: + serviceAccountName: repospawner-ci + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 + + - name: hooks + image: git.unkin.net/unkin/almalinux9-gobuilder:20260606 + commands: + - uvx pre-commit run --all-files + backend_options: + kubernetes: + serviceAccountName: repospawner-ci + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 diff --git a/.woodpecker/test.yaml b/.woodpecker/test.yaml new file mode 100644 index 0000000..5760aa1 --- /dev/null +++ b/.woodpecker/test.yaml @@ -0,0 +1,18 @@ +when: + - event: pull_request + +steps: + - name: test + image: golang:1.25 + commands: + - go test -race -count=1 ./... + backend_options: + kubernetes: + serviceAccountName: repospawner-ci + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8b83337 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.25-alpine AS builder + +WORKDIR /build + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +ARG VERSION=dev +RUN CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${VERSION}" -o repospawner ./cmd/repospawner + +# distroless static ships ca-certificates and runs as an unprivileged user. The +# jobs reach the forge, Vault and Woodpecker over HTTP only, so no git binary +# and no shell are needed at runtime. +FROM gcr.io/distroless/static-debian12:nonroot + +COPY --from=builder /build/repospawner /usr/local/bin/repospawner + +EXPOSE 8080 + +ENTRYPOINT ["repospawner"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..311d507 --- /dev/null +++ b/Makefile @@ -0,0 +1,69 @@ +DIST := dist +VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +GOFLAGS := -ldflags="-s -w -X main.version=$(VERSION)" +OS ?= $(shell go env GOOS) +ARCH ?= $(shell go env GOARCH) + +REGISTRY := artifactapi.k8s.syd1.au.unkin.net/docker-internal + +# Shipped binaries; each has its own main package under cmd/. +BINARIES := repospawner + +.PHONY: all build test vet fmt lint clean images patch minor major _tag pre-commit + +all: build + +# Mirror the .woodpecker/pre-commit.yaml checks locally. +pre-commit: + test -z "$$(gofmt -l .)" + go vet ./... + golangci-lint run ./... + uvx pre-commit run --all-files + +build: + @for b in $(BINARIES); do \ + echo "building $$b"; \ + CGO_ENABLED=0 GOOS=$(OS) GOARCH=$(ARCH) go build $(GOFLAGS) -o $(DIST)/$$b ./cmd/$$b || exit 1; \ + done + +test: + go test -race -count=1 ./... + +vet: + go vet ./... + +fmt: + gofmt -w . + +lint: + golangci-lint run ./... + +clean: + rm -rf $(DIST) + +# Local convenience: build the container image. +images: + docker build --build-arg VERSION=$(VERSION) -t $(REGISTRY)/repospawner:$(VERSION) . + +# Bump helpers — read the latest semver tag and create the next one. CI builds +# and pushes the image on the resulting v* tag. +_LATEST := $(shell git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | head -1) +_BASE := $(if $(_LATEST),$(_LATEST),v0.0.0) +_MAJ := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f1) +_MIN := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f2) +_PAT := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f3) + +patch: + @NEW=v$(_MAJ).$(_MIN).$(shell expr $(_PAT) + 1); \ + git tag $$NEW && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW + +minor: + @NEW=v$(_MAJ).$(shell expr $(_MIN) + 1).0; \ + git tag $$NEW && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW + +major: + @NEW=v$(shell expr $(_MAJ) + 1).0.0; \ + git tag $$NEW && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW + +_tag: + git push origin $(TAG) diff --git a/README.md b/README.md index 458b2f9..e4d06ae 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,206 @@ # repospawner -API service that opens terraform-git PRs for new repo requests via kube Jobs; oauth2-proxy'd status UI \ No newline at end of file +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/.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/.yaml + | server |<-----------------| (Gitea) | 404 = free, 200 = 409 + +-------------+ +----------------+ + | 202 {id, status_url} + | + | creates Job repospawner-pr- + v + +-------------------------------------------------+ + | job pr branch repospawner/ | + | write config/.../.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- + v + +-------------------------------------------------+ + | job watch poll the PR every 10s (7d deadline) | + | -> {"merged":true} / {"closed":true}| + +-------------------------------------------------+ + | merged -> state = merged + | + | creates Job repospawner-woodpecker- (only if woodpecker:true) + v + +-------------------------------------------------+ + | job woodpecker-enable | + | GET gitea repo id -> POST /api/repos?forge_ | + | remote_id= -> verify -> {"enabled":true} | + +-------------------------------------------------+ + | + v + state = ready +``` + +Every Job is labelled `repospawner.unkin.net/request=` 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: + +```console +$ 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: + +```console +$ 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): + +```console +$ 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 or by an in-flight request); `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. + +### Generated config + +Only the description and the status check contexts come from the request; +everything else is fixed estate policy: + +```yaml +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 + +```console +$ 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`. diff --git a/cmd/repospawner/main.go b/cmd/repospawner/main.go new file mode 100644 index 0000000..b82ba30 --- /dev/null +++ b/cmd/repospawner/main.go @@ -0,0 +1,193 @@ +// Command repospawner turns JSON new-repo requests into terraform-git pull +// requests. The same binary is both the API server and the Jobs it launches: +// "repospawner" serves, "repospawner job ..." performs one unit of work. +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "git.unkin.net/unkin/repospawner/internal/config" + "git.unkin.net/unkin/repospawner/internal/gitea" + "git.unkin.net/unkin/repospawner/internal/jobrun" + "git.unkin.net/unkin/repospawner/internal/server" + "git.unkin.net/unkin/repospawner/internal/store" + "git.unkin.net/unkin/repospawner/internal/vaultauth" + "git.unkin.net/unkin/repospawner/ui" +) + +var version = "dev" + +const usage = `repospawner - open terraform-git pull requests for new repositories + +usage: + repospawner [serve] run the API and UI + repospawner job pr --request ID --name NAME \ + --description TEXT --checks A,B open the terraform-git PR + repospawner job watch --repo OWNER/NAME --pr N follow that PR to its end + repospawner job woodpecker-enable --name NAME activate the repo in CI + repospawner version print the version +` + +func main() { + log := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + args := os.Args[1:] + + if len(args) > 0 && (args[0] == "-h" || args[0] == "--help" || args[0] == "help") { + fmt.Print(usage) + return + } + if len(args) > 0 && args[0] == "version" { + fmt.Println(version) + return + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + cfg, err := config.Load() + if err != nil { + log.Error("config", "err", err) + os.Exit(1) + } + + var runErr error + switch { + case len(args) == 0 || args[0] == "serve": + runErr = serve(ctx, log, cfg) + case args[0] == "job": + runErr = runJob(ctx, log, cfg, args[1:]) + default: + fmt.Fprint(os.Stderr, usage) + os.Exit(2) + } + if runErr != nil { + log.Error("command failed", "command", strings.Join(args, " "), "err", runErr) + os.Exit(1) + } +} + +func serve(ctx context.Context, log *slog.Logger, cfg *config.Config) error { + if cfg.Image == "" { + return errors.New("REPOSPAWNER_IMAGE must name this deployment's own image; the Jobs run it") + } + cluster, err := server.NewKubeCluster(cfg.Namespace) + if err != nil { + return fmt.Errorf("kubernetes client: %w", err) + } + vault := vaultauth.New(cfg.VaultAddr, cfg.VaultK8sMount, cfg.VaultK8sRole, cfg.VaultSATokenPath) + forge := gitea.New(cfg.GiteaURL, vaultauth.NewTokenSource(vault, cfg.GiteaCredsPath).Token) + + srv := server.New(cfg, store.New(), forge, cluster, ui.Assets(), log) + go srv.Run(ctx) + + httpSrv := &http.Server{ + Addr: cfg.Listen, + Handler: srv.Handler(), + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 120 * time.Second, + } + + errCh := make(chan error, 1) + go func() { + log.Info("repospawner listening", + "addr", cfg.Listen, "version", version, + "namespace", cfg.Namespace, "tfgitRepo", cfg.TFGitRepo, + "allowedGroups", cfg.AllowedGroups) + if err := httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- err + } + }() + + select { + case err := <-errCh: + return err + case <-ctx.Done(): + } + log.Info("shutting down") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + return httpSrv.Shutdown(shutdownCtx) +} + +func runJob(ctx context.Context, log *slog.Logger, cfg *config.Config, args []string) error { + if len(args) == 0 { + fmt.Fprint(os.Stderr, usage) + return errors.New("job needs a subcommand") + } + switch args[0] { + case "pr": + return runPRJob(ctx, log, cfg, args[1:]) + case "watch": + return runWatchJob(ctx, log, cfg, args[1:]) + case "woodpecker-enable": + return runWoodpeckerJob(ctx, log, cfg, args[1:]) + default: + fmt.Fprint(os.Stderr, usage) + return fmt.Errorf("unknown job %q", args[0]) + } +} + +func runPRJob(ctx context.Context, log *slog.Logger, cfg *config.Config, args []string) error { + fs := flag.NewFlagSet("job pr", flag.ContinueOnError) + requestID := fs.String("request", "", "request id this job serves") + name := fs.String("name", "", "repository name") + description := fs.String("description", "", "repository description") + checks := fs.String("checks", "", "comma-separated required status check contexts") + if err := fs.Parse(args); err != nil { + return err + } + res, err := jobrun.PR(ctx, log, cfg, jobrun.PROptions{ + RequestID: *requestID, + Name: *name, + Description: *description, + StatusChecks: strings.Split(*checks, ","), + }) + jobrun.Report(log, jobrun.ReportPath(), res) + return err +} + +func runWatchJob(ctx context.Context, log *slog.Logger, cfg *config.Config, args []string) error { + fs := flag.NewFlagSet("job watch", flag.ContinueOnError) + repo := fs.String("repo", cfg.TFGitRepo, "owner/name of the repository holding the pull request") + number := fs.Int("pr", 0, "pull request number") + interval := fs.Duration("interval", 10*time.Second, "poll interval") + if err := fs.Parse(args); err != nil { + return err + } + if *number <= 0 { + return errors.New("--pr must be a positive pull request number") + } + res, err := jobrun.Watch(ctx, log, cfg, jobrun.WatchOptions{ + Repo: *repo, + Number: *number, + Interval: *interval, + }) + jobrun.Report(log, jobrun.ReportPath(), res) + return err +} + +func runWoodpeckerJob(ctx context.Context, log *slog.Logger, cfg *config.Config, args []string) error { + fs := flag.NewFlagSet("job woodpecker-enable", flag.ContinueOnError) + name := fs.String("name", "", "repository name to activate") + if err := fs.Parse(args); err != nil { + return err + } + if strings.TrimSpace(*name) == "" { + return errors.New("--name is required") + } + res, err := jobrun.WoodpeckerEnable(ctx, log, cfg, *name) + jobrun.Report(log, jobrun.ReportPath(), res) + return err +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b3f0cbc --- /dev/null +++ b/go.mod @@ -0,0 +1,49 @@ +module git.unkin.net/unkin/repospawner + +go 1.25 + +require ( + k8s.io/api v0.34.1 + k8s.io/apimachinery v0.34.1 + k8s.io/client-go v0.34.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.38.0 // indirect + golang.org/x/oauth2 v0.27.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/term v0.30.0 // indirect + golang.org/x/text v0.23.0 // indirect + golang.org/x/time v0.9.0 // indirect + google.golang.org/protobuf v1.36.5 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect + k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect + sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..70f4e5d --- /dev/null +++ b/go.sum @@ -0,0 +1,156 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= +github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= +github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= +github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= +golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= +golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= +golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= +gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM= +k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk= +k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4= +k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY= +k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..f6b038d --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,55 @@ +// Package auth enforces Authentik group membership from the oauth2-proxy +// identity header. oauth2-proxy already gates the route, but repospawner opens +// pull requests against the estate's source of truth, so it re-checks the group +// server-side rather than trusting the front door alone. +package auth + +import ( + "net/http" + + "git.unkin.net/unkin/repospawner/internal/config" +) + +// Middleware rejects requests whose group header carries none of the allowed +// groups. header is the request header to read; allowed must be non-empty. +type Middleware struct { + header string + allowed map[string]bool +} + +// New builds a Middleware. An empty allowed set denies everything, which is the +// correct fail-closed behaviour if config validation is ever bypassed. +func New(header string, allowed []string) *Middleware { + m := &Middleware{header: header, allowed: make(map[string]bool, len(allowed))} + for _, g := range allowed { + m.allowed[g] = true + } + return m +} + +// Permit reports whether the request carries an allowed group. +func (m *Middleware) Permit(r *http.Request) bool { + if len(m.allowed) == 0 { + return false + } + for _, v := range r.Header.Values(m.header) { + for _, g := range config.ParseGroups(v) { + if m.allowed[g] { + return true + } + } + } + return false +} + +// Wrap gates next behind Permit, answering 403 with a plain body that never +// echoes the submitted groups back to the caller. +func (m *Middleware) Wrap(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !m.Permit(r) { + http.Error(w, "forbidden: missing required group", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..e4ef870 --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,71 @@ +package auth + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestPermit(t *testing.T) { + m := New("X-Forwarded-Groups", []string{"akP-repospawner-user", "akR-platform"}) + + cases := []struct { + name string + values []string + want bool + }{ + {name: "no header", want: false}, + {name: "unrelated group", values: []string{"akP-mediamark-user"}, want: false}, + {name: "exact group", values: []string{"akP-repospawner-user"}, want: true}, + {name: "comma list", values: []string{"a,akR-platform,b"}, want: true}, + {name: "repeated header", values: []string{"nope", "akR-platform"}, want: true}, + {name: "empty value", values: []string{""}, want: false}, + {name: "prefix only", values: []string{"akP-repospawner"}, want: false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/api/requests", nil) + for _, v := range tc.values { + r.Header.Add("X-Forwarded-Groups", v) + } + if got := m.Permit(r); got != tc.want { + t.Errorf("Permit = %v, want %v", got, tc.want) + } + }) + } +} + +func TestEmptyAllowListDeniesEverything(t *testing.T) { + m := New("X-Forwarded-Groups", nil) + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Header.Set("X-Forwarded-Groups", "anything") + if m.Permit(r) { + t.Error("an empty allow-list must deny") + } +} + +func TestWrap(t *testing.T) { + m := New("X-Forwarded-Groups", []string{"ok"}) + var reached bool + h := m.Wrap(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + reached = true + w.WriteHeader(http.StatusNoContent) + })) + + denied := httptest.NewRecorder() + h.ServeHTTP(denied, httptest.NewRequest(http.MethodGet, "/", nil)) + if denied.Code != http.StatusForbidden { + t.Errorf("status = %d, want 403", denied.Code) + } + if reached { + t.Error("the wrapped handler ran for a denied request") + } + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("X-Forwarded-Groups", "ok") + allowed := httptest.NewRecorder() + h.ServeHTTP(allowed, req) + if allowed.Code != http.StatusNoContent || !reached { + t.Errorf("status = %d reached = %v", allowed.Code, reached) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..e4ce322 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,147 @@ +// Package config loads repospawner runtime configuration from the environment. +package config + +import ( + "fmt" + "os" + "strings" +) + +// Config is the fully-resolved repospawner configuration. The same struct is +// loaded by the server and by the job subcommands, which run from the same +// image with the same environment. +type Config struct { + // Listen is the HTTP listen address, e.g. ":8080". + Listen string + // Namespace is where request Jobs are created and looked up. + Namespace string + // Image is repospawner's own image reference; Jobs run it with a + // different argv, so the deployment must pass its own tag through. + Image string + // JobServiceAccount is the service account the request Jobs run as; it + // carries the projected vault-audience token. + JobServiceAccount string + // GiteaURL is the forge base URL, e.g. https://git.unkin.net. + GiteaURL string + // TFGitRepo is the "owner/name" of the terraform-git repository whose + // config tree owns repository definitions. + TFGitRepo string + // VaultAddr, VaultK8sMount and VaultK8sRole drive the native kubernetes + // auth login used to mint short-lived Gitea tokens. + VaultAddr string + VaultK8sMount string + VaultK8sRole string + // VaultSATokenPath is the projected service account token with the + // "vault" audience. + VaultSATokenPath string + // GiteaCredsPath is the vault path of the dynamic Gitea credential. + GiteaCredsPath string + // WoodpeckerServer is the CI server base URL. + WoodpeckerServer string + // WoodpeckerTokenFile holds the Woodpecker API token. Absent means + // Woodpecker enablement is unavailable, not fatal. + WoodpeckerTokenFile string + // WoodpeckerSecret is the Secret the enablement Job mounts to obtain + // WoodpeckerTokenFile; the server reads the same file to decide whether + // enablement is offered at all. + WoodpeckerSecret string + // GroupsHeader is the oauth2-proxy header carrying Authentik group names. + GroupsHeader string + // AllowedGroups gates every page load and API call. Never empty. + AllowedGroups []string +} + +// Load resolves configuration from the environment, failing closed on an empty +// allow-list (an empty list would authorize nobody or, worse, be read as +// "anyone" by a future refactor). +func Load() (*Config, error) { + c := &Config{ + Listen: envOr("REPOSPAWNER_LISTEN", ":8080"), + Namespace: envOr("REPOSPAWNER_NAMESPACE", "repospawner"), + Image: envOr("REPOSPAWNER_IMAGE", ""), + JobServiceAccount: envOr("REPOSPAWNER_JOB_SERVICE_ACCOUNT", "repospawner"), + GiteaURL: envOr("GITEA_URL", "https://git.unkin.net"), + TFGitRepo: envOr("REPOSPAWNER_TFGIT_REPO", "unkin/terraform-git"), + VaultAddr: envOr("VAULT_ADDR", "https://vault.service.consul:8200"), + VaultK8sMount: envOr("REPOSPAWNER_VAULT_K8S_MOUNT", "k8s/au/syd1"), + VaultK8sRole: envOr("REPOSPAWNER_VAULT_K8S_ROLE", "repospawner"), + VaultSATokenPath: envOr("REPOSPAWNER_VAULT_SA_TOKEN_PATH", "/var/run/secrets/vault/token"), + GiteaCredsPath: envOr("REPOSPAWNER_GITEA_CREDS_PATH", "gitea/creds/repospawner"), + WoodpeckerServer: envOr("WOODPECKER_SERVER", "https://ci.k8s.syd1.au.unkin.net"), + WoodpeckerTokenFile: envOr("REPOSPAWNER_WOODPECKER_TOKEN_FILE", "/etc/repospawner/woodpecker/token"), + WoodpeckerSecret: envOr("REPOSPAWNER_WOODPECKER_SECRET", "repospawner-woodpecker"), + GroupsHeader: envOr("REPOSPAWNER_GROUPS_HEADER", "X-Forwarded-Groups"), + AllowedGroups: ParseGroups(envOr("REPOSPAWNER_ALLOWED_GROUPS", "akP-repospawner-user")), + } + + if len(c.AllowedGroups) == 0 { + return nil, fmt.Errorf("REPOSPAWNER_ALLOWED_GROUPS must name at least one group") + } + if strings.TrimSpace(c.GroupsHeader) == "" { + return nil, fmt.Errorf("REPOSPAWNER_GROUPS_HEADER must not be empty") + } + if strings.TrimSpace(c.Namespace) == "" { + return nil, fmt.Errorf("REPOSPAWNER_NAMESPACE must not be empty") + } + if strings.TrimSpace(c.JobServiceAccount) == "" { + return nil, fmt.Errorf("REPOSPAWNER_JOB_SERVICE_ACCOUNT must not be empty") + } + if strings.TrimSpace(c.VaultK8sMount) == "" || strings.TrimSpace(c.VaultK8sRole) == "" { + return nil, fmt.Errorf("REPOSPAWNER_VAULT_K8S_MOUNT and REPOSPAWNER_VAULT_K8S_ROLE must not be empty") + } + if strings.TrimSpace(c.GiteaCredsPath) == "" { + return nil, fmt.Errorf("REPOSPAWNER_GITEA_CREDS_PATH must not be empty") + } + if err := requireHTTP("GITEA_URL", c.GiteaURL); err != nil { + return nil, err + } + if err := requireHTTP("VAULT_ADDR", c.VaultAddr); err != nil { + return nil, err + } + if err := requireHTTP("WOODPECKER_SERVER", c.WoodpeckerServer); err != nil { + return nil, err + } + if _, _, err := SplitRepo(c.TFGitRepo); err != nil { + return nil, fmt.Errorf("REPOSPAWNER_TFGIT_REPO: %w", err) + } + return c, nil +} + +// SplitRepo splits an "owner/name" reference. +func SplitRepo(s string) (owner, name string, err error) { + owner, name, ok := strings.Cut(strings.TrimSpace(s), "/") + if !ok || owner == "" || name == "" || strings.Contains(name, "/") { + return "", "", fmt.Errorf("%q is not owner/name", s) + } + return owner, name, nil +} + +// ParseGroups splits a group list tolerating both comma and whitespace +// separation, dropping empties. oauth2-proxy emits comma-separated groups but +// deployments hand-write the allow-list. +func ParseGroups(s string) []string { + fields := strings.FieldsFunc(s, func(r rune) bool { + return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r' || r == ';' + }) + out := make([]string, 0, len(fields)) + for _, f := range fields { + if f = strings.TrimSpace(f); f != "" { + out = append(out, f) + } + } + return out +} + +func requireHTTP(name, val string) error { + if !strings.HasPrefix(val, "http://") && !strings.HasPrefix(val, "https://") { + return fmt.Errorf("%s %q must be an http(s) URL", name, val) + } + return nil +} + +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..b6ab4b6 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,89 @@ +package config + +import ( + "testing" +) + +func TestLoadDefaults(t *testing.T) { + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + for _, tc := range []struct{ name, got, want string }{ + {"Listen", cfg.Listen, ":8080"}, + {"Namespace", cfg.Namespace, "repospawner"}, + {"JobServiceAccount", cfg.JobServiceAccount, "repospawner"}, + {"GiteaURL", cfg.GiteaURL, "https://git.unkin.net"}, + {"TFGitRepo", cfg.TFGitRepo, "unkin/terraform-git"}, + {"VaultAddr", cfg.VaultAddr, "https://vault.service.consul:8200"}, + {"VaultK8sMount", cfg.VaultK8sMount, "k8s/au/syd1"}, + {"VaultK8sRole", cfg.VaultK8sRole, "repospawner"}, + {"GiteaCredsPath", cfg.GiteaCredsPath, "gitea/creds/repospawner"}, + {"WoodpeckerServer", cfg.WoodpeckerServer, "https://ci.k8s.syd1.au.unkin.net"}, + {"WoodpeckerTokenFile", cfg.WoodpeckerTokenFile, "/etc/repospawner/woodpecker/token"}, + {"GroupsHeader", cfg.GroupsHeader, "X-Forwarded-Groups"}, + } { + if tc.got != tc.want { + t.Errorf("%s = %q, want %q", tc.name, tc.got, tc.want) + } + } + if len(cfg.AllowedGroups) != 1 { + t.Errorf("AllowedGroups = %v", cfg.AllowedGroups) + } +} + +func TestLoadFailsClosed(t *testing.T) { + cases := []struct { + name string + env map[string]string + }{ + {name: "empty group list", env: map[string]string{"REPOSPAWNER_ALLOWED_GROUPS": " , ,"}}, + {name: "blank groups header", env: map[string]string{"REPOSPAWNER_GROUPS_HEADER": " "}}, + {name: "non-http gitea url", env: map[string]string{"GITEA_URL": "git.unkin.net"}}, + {name: "non-http vault addr", env: map[string]string{"VAULT_ADDR": "vault.service.consul:8200"}}, + {name: "non-http woodpecker", env: map[string]string{"WOODPECKER_SERVER": "ci.unkin.net"}}, + {name: "bad tfgit repo", env: map[string]string{"REPOSPAWNER_TFGIT_REPO": "terraform-git"}}, + {name: "nested tfgit repo", env: map[string]string{"REPOSPAWNER_TFGIT_REPO": "a/b/c"}}, + {name: "blank namespace", env: map[string]string{"REPOSPAWNER_NAMESPACE": " "}}, + {name: "blank vault role", env: map[string]string{"REPOSPAWNER_VAULT_K8S_ROLE": " "}}, + {name: "blank creds path", env: map[string]string{"REPOSPAWNER_GITEA_CREDS_PATH": " "}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + for k, v := range tc.env { + t.Setenv(k, v) + } + if _, err := Load(); err == nil { + t.Fatalf("Load() with %v should have failed", tc.env) + } + }) + } +} + +func TestSplitRepo(t *testing.T) { + owner, name, err := SplitRepo(" unkin/terraform-git ") + if err != nil || owner != "unkin" || name != "terraform-git" { + t.Fatalf("SplitRepo = %q %q %v", owner, name, err) + } + for _, bad := range []string{"", "unkin", "/name", "owner/", "a/b/c"} { + if _, _, err := SplitRepo(bad); err == nil { + t.Errorf("SplitRepo(%q) should have failed", bad) + } + } +} + +func TestParseGroups(t *testing.T) { + got := ParseGroups("a, b;c\nd e") + want := []string{"a", "b", "c", "d", "e"} + if len(got) != len(want) { + t.Fatalf("ParseGroups = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("ParseGroups = %v, want %v", got, want) + } + } + if len(ParseGroups(" ,, ")) != 0 { + t.Errorf("ParseGroups of separators only should be empty") + } +} diff --git a/internal/gitea/gitea.go b/internal/gitea/gitea.go new file mode 100644 index 0000000..5089da5 --- /dev/null +++ b/internal/gitea/gitea.go @@ -0,0 +1,207 @@ +// Package gitea is the small slice of the Gitea API repospawner needs: does a +// repository config file already exist, open a pull request, and follow that +// pull request to its merge. +package gitea + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// TokenFunc supplies a Gitea token. force asks for a freshly minted one, which +// the client requests exactly once per call after a 401. +type TokenFunc func(ctx context.Context, force bool) (string, error) + +// Client talks to one Gitea instance. +type Client struct { + BaseURL string + Token TokenFunc + HTTP *http.Client +} + +// New builds a Client with a bounded HTTP client. +func New(baseURL string, token TokenFunc) *Client { + return &Client{ + BaseURL: strings.TrimSuffix(baseURL, "/"), + Token: token, + HTTP: &http.Client{Timeout: 30 * time.Second}, + } +} + +// Repo is the subset of a repository record repospawner uses. ID is the forge +// remote id Woodpecker enablement needs. +type Repo struct { + ID int64 `json:"id"` + FullName string `json:"full_name"` +} + +// PullRequest is the subset of a pull request record repospawner uses. +type PullRequest struct { + Number int `json:"number"` + HTMLURL string `json:"html_url"` + State string `json:"state"` + Merged bool `json:"merged"` +} + +// Repo fetches a repository by "owner/name". +func (c *Client) Repo(ctx context.Context, repo string) (Repo, error) { + var out Repo + err := c.do(ctx, http.MethodGet, "/api/v1/repos/"+repo, nil, &out) + return out, err +} + +// FileExists reports whether path exists on ref in repo. A 404 is the answer +// "no", not an error; anything else is an error, so a broken forge can never be +// mistaken for a free name. +func (c *Client) FileExists(ctx context.Context, repo, path, ref string) (bool, error) { + ep := "/api/v1/repos/" + repo + "/contents/" + escapePath(path) + if ref != "" { + ep += "?ref=" + url.QueryEscape(ref) + } + err := c.do(ctx, http.MethodGet, ep, nil, nil) + var se *StatusError + if errors.As(err, &se) && se.Status == http.StatusNotFound { + return false, nil + } + if err != nil { + return false, err + } + return true, nil +} + +// CreateBranch branches newBranch off oldRef. An existing branch is not an +// error: a retried Job must be able to finish what its predecessor started. +func (c *Client) CreateBranch(ctx context.Context, repo, newBranch, oldRef string) error { + payload := map[string]string{"new_branch_name": newBranch, "old_ref_name": oldRef} + err := c.do(ctx, http.MethodPost, "/api/v1/repos/"+repo+"/branches", payload, nil) + var se *StatusError + if errors.As(err, &se) && se.Status == http.StatusConflict { + return nil + } + return err +} + +// CreateFile writes a new file on branch. content is raw bytes; Gitea wants it +// base64-encoded. +func (c *Client) CreateFile(ctx context.Context, repo, path, branch, message string, content []byte) error { + payload := map[string]string{ + "branch": branch, + "content": base64.StdEncoding.EncodeToString(content), + "message": message, + } + return c.do(ctx, http.MethodPost, "/api/v1/repos/"+repo+"/contents/"+escapePath(path), payload, nil) +} + +// CreatePullRequest opens a pull request from head into base. +func (c *Client) CreatePullRequest(ctx context.Context, repo, head, base, title, body string) (PullRequest, error) { + payload := map[string]string{"head": head, "base": base, "title": title, "body": body} + var out PullRequest + err := c.do(ctx, http.MethodPost, "/api/v1/repos/"+repo+"/pulls", payload, &out) + return out, err +} + +// PullRequest fetches one pull request by index. +func (c *Client) PullRequest(ctx context.Context, repo string, number int) (PullRequest, error) { + var out PullRequest + err := c.do(ctx, http.MethodGet, "/api/v1/repos/"+repo+"/pulls/"+strconv.Itoa(number), nil, &out) + return out, err +} + +// StatusError carries a non-2xx response. +type StatusError struct { + Status int + Method string + Path string + Body string +} + +func (e *StatusError) Error() string { + msg := fmt.Sprintf("gitea %s %s: status %d", e.Method, e.Path, e.Status) + if e.Body != "" { + msg += ": " + e.Body + } + return msg +} + +// do issues one API call, retrying exactly once with a freshly minted token +// when the forge answers 401 — dynamic Gitea credentials expire after about an +// hour and the watch job outlives that. +func (c *Client) do(ctx context.Context, method, path string, in, out any) error { + var body []byte + if in != nil { + var err error + if body, err = json.Marshal(in); err != nil { + return err + } + } + resp, err := c.attempt(ctx, method, path, body, false) + if err != nil { + return err + } + if resp.StatusCode == http.StatusUnauthorized { + _ = resp.Body.Close() + if resp, err = c.attempt(ctx, method, path, body, true); err != nil { + return err + } + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < 200 || resp.StatusCode > 299 { + snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return &StatusError{Status: resp.StatusCode, Method: method, Path: path, Body: strings.TrimSpace(string(snippet))} + } + if out == nil { + _, _ = io.Copy(io.Discard, resp.Body) + return nil + } + return json.NewDecoder(resp.Body).Decode(out) +} + +func (c *Client) attempt(ctx context.Context, method, path string, body []byte, force bool) (*http.Response, error) { + var reader io.Reader + if body != nil { + reader = bytes.NewReader(body) + } + req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, reader) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if c.Token != nil { + token, err := c.Token(ctx, force) + if err != nil { + return nil, err + } + if token != "" { + req.Header.Set("Authorization", "token "+token) + } + } + client := c.HTTP + if client == nil { + client = http.DefaultClient + } + return client.Do(req) +} + +// escapePath percent-escapes each path segment so a config path survives the +// contents API without collapsing its separators. +func escapePath(p string) string { + parts := strings.Split(strings.TrimPrefix(p, "/"), "/") + for i, s := range parts { + parts[i] = url.PathEscape(s) + } + return strings.Join(parts, "/") +} diff --git a/internal/gitea/gitea_test.go b/internal/gitea/gitea_test.go new file mode 100644 index 0000000..66a3d5a --- /dev/null +++ b/internal/gitea/gitea_test.go @@ -0,0 +1,184 @@ +package gitea + +import ( + "context" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" +) + +func staticToken(tok string) TokenFunc { + return func(context.Context, bool) (string, error) { return tok, nil } +} + +func TestFileExists(t *testing.T) { + cases := []struct { + name string + status int + want bool + errors bool + }{ + {name: "present", status: http.StatusOK, want: true}, + {name: "absent", status: http.StatusNotFound, want: false}, + {name: "forge broken", status: http.StatusInternalServerError, errors: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var gotPath, gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath, gotAuth = r.URL.Path, r.Header.Get("Authorization") + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(`{}`)) + })) + defer srv.Close() + + c := New(srv.URL, staticToken("tok")) + got, err := c.FileExists(context.Background(), "unkin/terraform-git", + "config/git.unkin.net/unkin/repository/widget.yaml", "main") + if tc.errors { + if err == nil { + t.Fatal("expected an error for a broken forge, got nil") + } + return + } + if err != nil { + t.Fatalf("FileExists: %v", err) + } + if got != tc.want { + t.Errorf("FileExists = %v, want %v", got, tc.want) + } + wantPath := "/api/v1/repos/unkin/terraform-git/contents/config/git.unkin.net/unkin/repository/widget.yaml" + if gotPath != wantPath { + t.Errorf("path = %q, want %q", gotPath, wantPath) + } + if gotAuth != "token tok" { + t.Errorf("Authorization = %q", gotAuth) + } + }) + } +} + +func TestCreatePullRequest(t *testing.T) { + var body map[string]string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/api/v1/repos/unkin/terraform-git/pulls" { + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + raw, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(raw, &body) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"number":42,"html_url":"https://git.unkin.net/unkin/terraform-git/pulls/42","state":"open"}`)) + })) + defer srv.Close() + + pr, err := New(srv.URL, staticToken("tok")).CreatePullRequest( + context.Background(), "unkin/terraform-git", "repospawner/widget", "main", "Add widget", "why") + if err != nil { + t.Fatalf("CreatePullRequest: %v", err) + } + if pr.Number != 42 || !strings.HasSuffix(pr.HTMLURL, "/42") { + t.Errorf("pr = %+v", pr) + } + if body["head"] != "repospawner/widget" || body["base"] != "main" || body["title"] != "Add widget" { + t.Errorf("payload = %v", body) + } +} + +func TestCreateFileBase64EncodesContent(t *testing.T) { + var body map[string]string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(raw, &body) + _, _ = w.Write([]byte(`{}`)) + })) + defer srv.Close() + + err := New(srv.URL, staticToken("tok")).CreateFile(context.Background(), + "unkin/terraform-git", "config/a/b.yaml", "repospawner/widget", "Add widget", []byte("description: x\n")) + if err != nil { + t.Fatalf("CreateFile: %v", err) + } + decoded, err := base64.StdEncoding.DecodeString(body["content"]) + if err != nil { + t.Fatalf("content is not base64: %v", err) + } + if string(decoded) != "description: x\n" { + t.Errorf("content = %q", decoded) + } + if body["branch"] != "repospawner/widget" { + t.Errorf("branch = %q", body["branch"]) + } +} + +func TestCreateBranchToleratesExisting(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"message":"branch already exists"}`)) + })) + defer srv.Close() + + if err := New(srv.URL, staticToken("tok")).CreateBranch(context.Background(), + "unkin/terraform-git", "repospawner/widget", "main"); err != nil { + t.Fatalf("CreateBranch on an existing branch should succeed, got %v", err) + } +} + +func TestReMintsTokenOn401(t *testing.T) { + var mints atomic.Int32 + token := func(_ context.Context, force bool) (string, error) { + if force { + mints.Add(1) + return "fresh", nil + } + return "stale", nil + } + + var seen []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + seen = append(seen, auth) + if auth == "token stale" { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message":"token expired"}`)) + return + } + _, _ = w.Write([]byte(`{"number":7,"html_url":"https://example/7","state":"open","merged":true}`)) + })) + defer srv.Close() + + pr, err := New(srv.URL, token).PullRequest(context.Background(), "unkin/terraform-git", 7) + if err != nil { + t.Fatalf("PullRequest: %v", err) + } + if !pr.Merged || pr.Number != 7 { + t.Errorf("pr = %+v", pr) + } + if mints.Load() != 1 { + t.Errorf("forced mints = %d, want 1", mints.Load()) + } + if len(seen) != 2 || seen[0] != "token stale" || seen[1] != "token fresh" { + t.Errorf("authorization headers = %v", seen) + } +} + +func TestStatusErrorCarriesBody(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message":"branch already has a pull request"}`)) + })) + defer srv.Close() + + _, err := New(srv.URL, staticToken("tok")).CreatePullRequest( + context.Background(), "unkin/terraform-git", "h", "main", "t", "b") + if err == nil { + t.Fatal("expected an error") + } + if !strings.Contains(err.Error(), "422") || !strings.Contains(err.Error(), "already has a pull request") { + t.Errorf("error = %v", err) + } +} diff --git a/internal/jobrun/jobrun.go b/internal/jobrun/jobrun.go new file mode 100644 index 0000000..59f4cd9 --- /dev/null +++ b/internal/jobrun/jobrun.go @@ -0,0 +1,204 @@ +// Package jobrun implements the three job subcommands. Each one runs to +// completion in its own pod, reports a JSON result on the pod's termination +// message and exits; the server reads that message back rather than parsing +// logs. +package jobrun + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "strings" + "time" + + "git.unkin.net/unkin/repospawner/internal/config" + "git.unkin.net/unkin/repospawner/internal/gitea" + "git.unkin.net/unkin/repospawner/internal/jobs" + "git.unkin.net/unkin/repospawner/internal/repospec" + "git.unkin.net/unkin/repospawner/internal/vaultauth" + "git.unkin.net/unkin/repospawner/internal/woodpecker" +) + +// terminationMessagePath is where the kubelet reads a pod's result from. +const terminationMessagePath = "/dev/termination-log" + +// Report writes result as the pod's termination message. A failure to write is +// logged and swallowed: the job's exit code still carries the outcome. +func Report(log *slog.Logger, path string, result any) { + b, err := json.Marshal(result) + if err != nil { + log.Error("encode termination message", "err", err) + return + } + if err := os.WriteFile(path, b, 0o644); err != nil { + log.Warn("write termination message", "path", path, "err", err) + } +} + +// ReportPath is the default termination message path. +func ReportPath() string { return terminationMessagePath } + +// giteaClient builds a forge client whose token is minted from Vault on demand +// and re-minted when the forge answers 401. +func giteaClient(cfg *config.Config) *gitea.Client { + vault := vaultauth.New(cfg.VaultAddr, cfg.VaultK8sMount, cfg.VaultK8sRole, cfg.VaultSATokenPath) + src := vaultauth.NewTokenSource(vault, cfg.GiteaCredsPath) + return gitea.New(cfg.GiteaURL, src.Token) +} + +// PROptions are the pr subcommand's inputs. +type PROptions struct { + RequestID string + Name string + Description string + StatusChecks []string +} + +// PR writes the repository config to a new terraform-git branch and opens the +// pull request. The file is written through the Gitea contents API rather than +// a git clone: the runtime image is distroless and carries no git binary, and +// the API commit is a single atomic call with no working copy to clean up. +func PR(ctx context.Context, log *slog.Logger, cfg *config.Config, opts PROptions) (jobs.PRResult, error) { + spec := repospec.Request{ + Name: opts.Name, + Description: opts.Description, + StatusChecks: opts.StatusChecks, + }.Normalize() + if err := spec.Validate(); err != nil { + return jobs.PRResult{Error: err.Error()}, err + } + + client := giteaClient(cfg) + branch := spec.BranchName() + path := spec.ConfigPath() + + if err := client.CreateBranch(ctx, cfg.TFGitRepo, branch, "main"); err != nil { + return jobs.PRResult{Error: "create branch: " + err.Error()}, err + } + log.Info("branched terraform-git", "repo", cfg.TFGitRepo, "branch", branch) + + message := "Add the " + spec.Name + " repository" + if err := client.CreateFile(ctx, cfg.TFGitRepo, path, branch, message, []byte(spec.RenderYAML())); err != nil { + return jobs.PRResult{Error: "write config: " + err.Error()}, err + } + log.Info("wrote repository config", "path", path) + + body := prBody(spec, opts.RequestID) + pr, err := client.CreatePullRequest(ctx, cfg.TFGitRepo, branch, "main", message, body) + if err != nil { + return jobs.PRResult{Error: "open pull request: " + err.Error()}, err + } + log.Info("opened pull request", "number", pr.Number, "url", pr.HTMLURL) + return jobs.PRResult{PRNumber: pr.Number, PRURL: pr.HTMLURL}, nil +} + +func prBody(spec repospec.Request, requestID string) string { + var b strings.Builder + b.WriteString("Requested through repospawner (request `" + requestID + "`).\n\n") + b.WriteString("- Adds `" + spec.ConfigPath() + "`\n") + b.WriteString("- Public repository, `main` default branch, squash merge, branch deleted after merge\n") + b.WriteString("- Protects `main`: Owners merge, benvin approves, required checks:\n") + for _, c := range spec.StatusChecks { + b.WriteString(" - `" + c + "`\n") + } + return b.String() +} + +// WatchOptions are the watch subcommand's inputs. +type WatchOptions struct { + Repo string + Number int + Interval time.Duration + // Deadline bounds the poll; zero means until the context is cancelled. + Deadline time.Duration +} + +// Watch follows a pull request until it merges or closes. Gitea credentials +// expire after about an hour and this job outlives that, so the client re-mints +// on the forge's first 401. +func Watch(ctx context.Context, log *slog.Logger, cfg *config.Config, opts WatchOptions) (jobs.WatchResult, error) { + if opts.Interval <= 0 { + opts.Interval = 10 * time.Second + } + if opts.Deadline > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, opts.Deadline) + defer cancel() + } + client := giteaClient(cfg) + + t := time.NewTicker(opts.Interval) + defer t.Stop() + for { + pr, err := client.PullRequest(ctx, opts.Repo, opts.Number) + if err != nil { + // A transient forge error must not end the watch; the deadline is + // the only thing that ends it early. + log.Warn("poll pull request", "repo", opts.Repo, "pr", opts.Number, "err", err) + } else { + switch { + case pr.Merged: + log.Info("pull request merged", "repo", opts.Repo, "pr", opts.Number) + return jobs.WatchResult{Merged: true}, nil + case pr.State == "closed": + log.Info("pull request closed without merging", "repo", opts.Repo, "pr", opts.Number) + return jobs.WatchResult{Closed: true}, nil + } + } + select { + case <-ctx.Done(): + return jobs.WatchResult{Error: "watch deadline reached before the pull request resolved"}, ctx.Err() + case <-t.C: + } + } +} + +// WoodpeckerEnable activates the newly created repository in Woodpecker. +func WoodpeckerEnable(ctx context.Context, log *slog.Logger, cfg *config.Config, name string) (jobs.WoodpeckerResult, error) { + token, err := readToken(cfg.WoodpeckerTokenFile) + if err != nil { + return jobs.WoodpeckerResult{Error: err.Error()}, err + } + owner, _, err := config.SplitRepo(cfg.TFGitRepo) + if err != nil { + return jobs.WoodpeckerResult{Error: err.Error()}, err + } + fullName := owner + "/" + name + + repo, err := giteaClient(cfg).Repo(ctx, fullName) + if err != nil { + return jobs.WoodpeckerResult{Error: "look up forge repository: " + err.Error()}, err + } + + wp := woodpecker.New(cfg.WoodpeckerServer, token) + if _, err := wp.Enable(ctx, repo.ID); err != nil { + return jobs.WoodpeckerResult{RepoID: repo.ID, Error: "enable in woodpecker: " + err.Error()}, err + } + active, err := wp.Lookup(ctx, fullName) + if err != nil { + return jobs.WoodpeckerResult{RepoID: repo.ID, Error: "verify woodpecker activation: " + err.Error()}, err + } + if !active.Active { + err := fmt.Errorf("woodpecker reports %s inactive after enabling it", fullName) + return jobs.WoodpeckerResult{RepoID: repo.ID, Error: err.Error()}, err + } + log.Info("repository enabled in woodpecker", "repo", fullName, "forgeRemoteID", repo.ID) + return jobs.WoodpeckerResult{Enabled: true, RepoID: repo.ID}, nil +} + +func readToken(path string) (string, error) { + if path == "" { + return "", fmt.Errorf("no woodpecker token file is configured") + } + b, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read woodpecker token: %w", err) + } + token := strings.TrimSpace(string(b)) + if token == "" { + return "", fmt.Errorf("woodpecker token file %s is empty", path) + } + return token, nil +} diff --git a/internal/jobrun/jobrun_test.go b/internal/jobrun/jobrun_test.go new file mode 100644 index 0000000..58d196d --- /dev/null +++ b/internal/jobrun/jobrun_test.go @@ -0,0 +1,278 @@ +package jobrun + +import ( + "context" + "encoding/base64" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "git.unkin.net/unkin/repospawner/internal/config" + "git.unkin.net/unkin/repospawner/internal/jobs" +) + +func quietLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +// forge is a stub Gitea plus Vault, since the jobs mint a token before every +// forge call. +type forge struct { + branches atomic.Int32 + files atomic.Int32 + lastFile string + prCalls atomic.Int32 + pollCalls atomic.Int32 + merged atomic.Bool + closed atomic.Bool +} + +func (f *forge) handler(t *testing.T) http.HandlerFunc { + t.Helper() + return func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/login"): + _, _ = w.Write([]byte(`{"auth":{"client_token":"s.vault"}}`)) + case strings.HasPrefix(r.URL.Path, "/v1/gitea/creds/"): + _, _ = w.Write([]byte(`{"data":{"username":"u","token":"gitea-tok"}}`)) + case strings.HasSuffix(r.URL.Path, "/branches"): + f.branches.Add(1) + _, _ = w.Write([]byte(`{"name":"repospawner/widget"}`)) + case strings.Contains(r.URL.Path, "/contents/"): + f.files.Add(1) + var body map[string]string + raw, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(raw, &body) + decoded, err := base64.StdEncoding.DecodeString(body["content"]) + if err != nil { + t.Errorf("content is not base64: %v", err) + } + f.lastFile = string(decoded) + _, _ = w.Write([]byte(`{}`)) + case strings.HasSuffix(r.URL.Path, "/pulls"): + f.prCalls.Add(1) + _, _ = w.Write([]byte(`{"number":42,"html_url":"https://git.unkin.net/unkin/terraform-git/pulls/42","state":"open"}`)) + case strings.Contains(r.URL.Path, "/pulls/"): + f.pollCalls.Add(1) + switch { + case f.merged.Load(): + _, _ = w.Write([]byte(`{"number":42,"state":"closed","merged":true}`)) + case f.closed.Load(): + _, _ = w.Write([]byte(`{"number":42,"state":"closed","merged":false}`)) + default: + _, _ = w.Write([]byte(`{"number":42,"state":"open","merged":false}`)) + } + case strings.HasPrefix(r.URL.Path, "/api/v1/repos/"): + _, _ = w.Write([]byte(`{"id":91,"full_name":"unkin/widget"}`)) + default: + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + } +} + +func testCfg(t *testing.T, base string) *config.Config { + t.Helper() + tokenPath := filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(tokenPath, []byte("sa-jwt"), 0o600); err != nil { + t.Fatalf("write sa token: %v", err) + } + return &config.Config{ + GiteaURL: base, + TFGitRepo: "unkin/terraform-git", + VaultAddr: base, + VaultK8sMount: "k8s/au/syd1", + VaultK8sRole: "repospawner", + VaultSATokenPath: tokenPath, + GiteaCredsPath: "gitea/creds/repospawner", + WoodpeckerServer: base, + } +} + +func TestPRWritesConfigAndOpensPullRequest(t *testing.T) { + f := &forge{} + srv := httptest.NewServer(f.handler(t)) + defer srv.Close() + + res, err := PR(context.Background(), quietLogger(), testCfg(t, srv.URL), PROptions{ + RequestID: "abc123", + Name: "widget", + Description: "does widgets", + StatusChecks: []string{"ci/woodpecker/pr/test"}, + }) + if err != nil { + t.Fatalf("PR: %v", err) + } + if res.PRNumber != 42 || !strings.HasSuffix(res.PRURL, "/42") { + t.Errorf("result = %+v", res) + } + if f.branches.Load() != 1 || f.files.Load() != 1 || f.prCalls.Load() != 1 { + t.Errorf("calls: branches=%d files=%d pulls=%d", f.branches.Load(), f.files.Load(), f.prCalls.Load()) + } + if !strings.Contains(f.lastFile, `description: "does widgets"`) || + !strings.Contains(f.lastFile, `- "ci/woodpecker/pr/test"`) { + t.Errorf("committed file:\n%s", f.lastFile) + } +} + +func TestPRRejectsAnInvalidRequestBeforeTouchingTheForge(t *testing.T) { + f := &forge{} + srv := httptest.NewServer(f.handler(t)) + defer srv.Close() + + res, err := PR(context.Background(), quietLogger(), testCfg(t, srv.URL), PROptions{ + RequestID: "abc123", Name: "Widget", Description: "d", StatusChecks: []string{"x"}, + }) + if err == nil { + t.Fatal("expected a validation error") + } + if res.Error == "" { + t.Error("the result must carry the reason for the termination message") + } + if f.branches.Load() != 0 { + t.Error("an invalid request must not branch terraform-git") + } +} + +func TestWatchReportsMerge(t *testing.T) { + f := &forge{} + f.merged.Store(true) + srv := httptest.NewServer(f.handler(t)) + defer srv.Close() + + res, err := Watch(context.Background(), quietLogger(), testCfg(t, srv.URL), WatchOptions{ + Repo: "unkin/terraform-git", Number: 42, Interval: time.Millisecond, + }) + if err != nil { + t.Fatalf("Watch: %v", err) + } + if !res.Merged || res.Closed { + t.Errorf("result = %+v", res) + } +} + +func TestWatchReportsClose(t *testing.T) { + f := &forge{} + f.closed.Store(true) + srv := httptest.NewServer(f.handler(t)) + defer srv.Close() + + res, err := Watch(context.Background(), quietLogger(), testCfg(t, srv.URL), WatchOptions{ + Repo: "unkin/terraform-git", Number: 42, Interval: time.Millisecond, + }) + if err != nil { + t.Fatalf("Watch: %v", err) + } + if !res.Closed || res.Merged { + t.Errorf("result = %+v", res) + } +} + +func TestWatchKeepsPollingWhileOpen(t *testing.T) { + f := &forge{} + srv := httptest.NewServer(f.handler(t)) + defer srv.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Millisecond) + defer cancel() + res, err := Watch(ctx, quietLogger(), testCfg(t, srv.URL), WatchOptions{ + Repo: "unkin/terraform-git", Number: 42, Interval: time.Millisecond, + }) + if err == nil { + t.Fatal("an open pull request must not resolve the watch") + } + if res.Error == "" { + t.Error("the result must explain why the watch ended") + } + if f.pollCalls.Load() < 2 { + t.Errorf("polls = %d, want repeated polling", f.pollCalls.Load()) + } +} + +func TestWoodpeckerEnable(t *testing.T) { + var enabled atomic.Bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/login"): + _, _ = w.Write([]byte(`{"auth":{"client_token":"s.vault"}}`)) + case strings.HasPrefix(r.URL.Path, "/v1/gitea/creds/"): + _, _ = w.Write([]byte(`{"data":{"username":"u","token":"gitea-tok"}}`)) + case r.URL.Path == "/api/v1/repos/unkin/widget": + _, _ = w.Write([]byte(`{"id":91,"full_name":"unkin/widget"}`)) + case r.URL.Path == "/api/repos": + if r.URL.Query().Get("forge_remote_id") != "91" { + t.Errorf("forge_remote_id = %q", r.URL.Query().Get("forge_remote_id")) + } + enabled.Store(true) + _, _ = w.Write([]byte(`{"id":5,"full_name":"unkin/widget","active":true}`)) + case strings.HasPrefix(r.URL.Path, "/api/repos/lookup/"): + _, _ = w.Write([]byte(`{"id":5,"full_name":"unkin/widget","active":` + boolStr(enabled.Load()) + `}`)) + default: + t.Errorf("unexpected request %s", r.URL.Path) + } + })) + defer srv.Close() + + cfg := testCfg(t, srv.URL) + cfg.WoodpeckerTokenFile = filepath.Join(t.TempDir(), "wp") + if err := os.WriteFile(cfg.WoodpeckerTokenFile, []byte("wp-token\n"), 0o600); err != nil { + t.Fatalf("write token: %v", err) + } + + res, err := WoodpeckerEnable(context.Background(), quietLogger(), cfg, "widget") + if err != nil { + t.Fatalf("WoodpeckerEnable: %v", err) + } + if !res.Enabled || res.RepoID != 91 { + t.Errorf("result = %+v", res) + } +} + +func TestWoodpeckerEnableWithoutToken(t *testing.T) { + cfg := testCfg(t, "https://unused.invalid") + cfg.WoodpeckerTokenFile = filepath.Join(t.TempDir(), "absent") + res, err := WoodpeckerEnable(context.Background(), quietLogger(), cfg, "widget") + if err == nil { + t.Fatal("expected an error with no token file") + } + if res.Error == "" || res.Enabled { + t.Errorf("result = %+v", res) + } +} + +func TestReportWritesTerminationMessage(t *testing.T) { + path := filepath.Join(t.TempDir(), "termination-log") + Report(quietLogger(), path, jobs.PRResult{PRNumber: 42, PRURL: "https://example/42"}) + + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read: %v", err) + } + var got jobs.PRResult + if !jobs.DecodeResult(raw, &got) { + t.Fatalf("termination message %q did not decode", raw) + } + if got.PRNumber != 42 || got.PRURL != "https://example/42" { + t.Errorf("result = %+v", got) + } +} + +func TestReportSurvivesAnUnwritablePath(t *testing.T) { + // The kubelet path is absent outside a pod; the job must still exit cleanly. + Report(quietLogger(), filepath.Join(t.TempDir(), "missing-dir", "log"), jobs.WatchResult{Merged: true}) +} + +func boolStr(b bool) string { + if b { + return "true" + } + return "false" +} diff --git a/internal/jobs/jobs.go b/internal/jobs/jobs.go new file mode 100644 index 0000000..8e05bc2 --- /dev/null +++ b/internal/jobs/jobs.go @@ -0,0 +1,287 @@ +// Package jobs builds the Kubernetes Jobs that carry out a request and reads +// their results back. Every job runs repospawner's own image with a different +// argv, so a job never needs a second image to keep in step. +package jobs + +import ( + "encoding/json" + "path" + "strconv" + "strings" + "time" + + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "git.unkin.net/unkin/repospawner/internal/config" + "git.unkin.net/unkin/repospawner/internal/store" +) + +// Type is the kind of work a Job performs. +type Type string + +const ( + // TypePR opens the terraform-git pull request. + TypePR Type = "pr" + // TypeWatch follows that pull request to merge or close. + TypeWatch Type = "watch" + // TypeWoodpecker enables the new repository in Woodpecker. + TypeWoodpecker Type = "woodpecker" +) + +// Label and annotation keys. The request label is the join key for both Jobs +// and their Pods; the annotations carry enough of the request to rebuild the +// store after a restart. +const ( + LabelRequest = "repospawner.unkin.net/request" + LabelType = "repospawner.unkin.net/type" + LabelApp = "app.kubernetes.io/name" + AppName = "repospawner" + AnnoName = "repospawner.unkin.net/name" + AnnoDescription = "repospawner.unkin.net/description" + AnnoWoodpecker = "repospawner.unkin.net/woodpecker" + AnnoStatusChecks = "repospawner.unkin.net/status-checks" + AnnoCreated = "repospawner.unkin.net/created" + AnnoPullRequestNo = "repospawner.unkin.net/pr-number" + AnnoPullRequest = "repospawner.unkin.net/pr-url" +) + +const ( + ttlSecondsAfterFinished = int32(3600) + backoffLimit = int32(2) + // watchDeadline is generous: a terraform-git PR waits on a human. + watchDeadline = int64(7 * 24 * 60 * 60) + shortDeadline = int64(15 * 60) +) + +// PRResult is written by the pr job to its termination message. +type PRResult struct { + PRNumber int `json:"pr_number"` + PRURL string `json:"pr_url"` + Error string `json:"error,omitempty"` +} + +// WatchResult is written by the watch job to its termination message. +type WatchResult struct { + Merged bool `json:"merged"` + Closed bool `json:"closed"` + Error string `json:"error,omitempty"` +} + +// WoodpeckerResult is written by the woodpecker job to its termination message. +type WoodpeckerResult struct { + Enabled bool `json:"enabled"` + RepoID int64 `json:"repo_id,omitempty"` + Error string `json:"error,omitempty"` +} + +// Name is the Job object name for a request and type. +func Name(t Type, id string) string { return "repospawner-" + string(t) + "-" + id } + +// PR builds the Job that opens the terraform-git pull request. +func PR(cfg *config.Config, r store.Request) *batchv1.Job { + args := []string{ + "job", "pr", + "--request", r.ID, + "--name", r.Name, + "--description", r.Description, + "--checks", strings.Join(r.StatusChecks, ","), + } + return base(cfg, r, TypePR, args, shortDeadline) +} + +// Watch builds the Job that follows the pull request to its conclusion. +func Watch(cfg *config.Config, r store.Request) *batchv1.Job { + args := []string{"job", "watch", "--repo", cfg.TFGitRepo, "--pr", strconv.Itoa(r.PRNumber)} + j := base(cfg, r, TypeWatch, args, watchDeadline) + j.Annotations[AnnoPullRequestNo] = strconv.Itoa(r.PRNumber) + j.Annotations[AnnoPullRequest] = r.PRURL + return j +} + +// Woodpecker builds the Job that activates the merged repository in CI. +func Woodpecker(cfg *config.Config, r store.Request) *batchv1.Job { + j := base(cfg, r, TypeWoodpecker, []string{"job", "woodpecker-enable", "--name", r.Name}, shortDeadline) + j.Annotations[AnnoPullRequestNo] = strconv.Itoa(r.PRNumber) + j.Annotations[AnnoPullRequest] = r.PRURL + mountWoodpeckerToken(cfg, &j.Spec.Template.Spec) + return j +} + +func base(cfg *config.Config, r store.Request, t Type, args []string, deadline int64) *batchv1.Job { + labels := map[string]string{ + LabelApp: AppName, + LabelRequest: r.ID, + LabelType: string(t), + } + annotations := map[string]string{ + AnnoName: r.Name, + AnnoDescription: r.Description, + AnnoWoodpecker: strconv.FormatBool(r.Woodpecker), + AnnoStatusChecks: strings.Join(r.StatusChecks, "\n"), + AnnoCreated: r.Created.UTC().Format(time.RFC3339), + } + vaultDir := path.Dir(cfg.VaultSATokenPath) + + return &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: Name(t, r.ID), + Namespace: cfg.Namespace, + Labels: labels, + Annotations: annotations, + }, + Spec: batchv1.JobSpec{ + BackoffLimit: ptr(backoffLimit), + TTLSecondsAfterFinished: ptr(ttlSecondsAfterFinished), + ActiveDeadlineSeconds: ptr(deadline), + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: labels, Annotations: annotations}, + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyNever, + ServiceAccountName: cfg.JobServiceAccount, + Containers: []corev1.Container{{ + Name: string(t), + Image: cfg.Image, + Args: args, + Env: env(cfg), + TerminationMessagePath: corev1.TerminationMessagePathDefault, + TerminationMessagePolicy: corev1.TerminationMessageFallbackToLogsOnError, + VolumeMounts: []corev1.VolumeMount{{ + Name: "vault-token", + MountPath: vaultDir, + ReadOnly: true, + }}, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse("64Mi"), + corev1.ResourceCPU: resource.MustParse("50m"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse("256Mi"), + corev1.ResourceCPU: resource.MustParse("500m"), + }, + }, + }}, + Volumes: []corev1.Volume{{ + Name: "vault-token", + VolumeSource: corev1.VolumeSource{ + Projected: &corev1.ProjectedVolumeSource{ + Sources: []corev1.VolumeProjection{{ + ServiceAccountToken: &corev1.ServiceAccountTokenProjection{ + Path: path.Base(cfg.VaultSATokenPath), + Audience: "vault", + ExpirationSeconds: ptr(int64(600)), + }, + }}, + }, + }, + }}, + }, + }, + }, + } +} + +// env passes the server's resolved configuration down to the job so both sides +// read the same forge, vault mount and credential path. +func env(cfg *config.Config) []corev1.EnvVar { + return []corev1.EnvVar{ + {Name: "GITEA_URL", Value: cfg.GiteaURL}, + {Name: "VAULT_ADDR", Value: cfg.VaultAddr}, + {Name: "REPOSPAWNER_TFGIT_REPO", Value: cfg.TFGitRepo}, + {Name: "REPOSPAWNER_VAULT_K8S_MOUNT", Value: cfg.VaultK8sMount}, + {Name: "REPOSPAWNER_VAULT_K8S_ROLE", Value: cfg.VaultK8sRole}, + {Name: "REPOSPAWNER_VAULT_SA_TOKEN_PATH", Value: cfg.VaultSATokenPath}, + {Name: "REPOSPAWNER_GITEA_CREDS_PATH", Value: cfg.GiteaCredsPath}, + {Name: "WOODPECKER_SERVER", Value: cfg.WoodpeckerServer}, + {Name: "REPOSPAWNER_WOODPECKER_TOKEN_FILE", Value: cfg.WoodpeckerTokenFile}, + {Name: "REPOSPAWNER_ALLOWED_GROUPS", Value: strings.Join(cfg.AllowedGroups, ",")}, + } +} + +func mountWoodpeckerToken(cfg *config.Config, spec *corev1.PodSpec) { + dir := path.Dir(cfg.WoodpeckerTokenFile) + spec.Volumes = append(spec.Volumes, corev1.Volume{ + Name: "woodpecker-token", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: cfg.WoodpeckerSecret, + Items: []corev1.KeyToPath{{Key: "token", Path: path.Base(cfg.WoodpeckerTokenFile)}}, + }, + }, + }) + spec.Containers[0].VolumeMounts = append(spec.Containers[0].VolumeMounts, corev1.VolumeMount{ + Name: "woodpecker-token", + MountPath: dir, + ReadOnly: true, + }) +} + +// View is a Job reduced to what the reconciler reasons about, so the state +// machine stays testable without a cluster. +type View struct { + Type Type + Request string + Succeeded bool + Failed bool + Annotations map[string]string + // Result is the pod's termination message, if the pod has terminated. + Result []byte +} + +// ViewOf reduces a Job (and its pod's termination message) to a View. +func ViewOf(j batchv1.Job, result []byte) View { + return View{ + Type: Type(j.Labels[LabelType]), + Request: j.Labels[LabelRequest], + Succeeded: j.Status.Succeeded > 0, + Failed: j.Status.Failed >= backoffLimit+1 || failedCondition(j), + Annotations: j.Annotations, + Result: result, + } +} + +func failedCondition(j batchv1.Job) bool { + for _, c := range j.Status.Conditions { + if c.Type == batchv1.JobFailed && c.Status == corev1.ConditionTrue { + return true + } + } + return false +} + +// RequestFrom rebuilds the request fields an annotated Job carries. It is how +// the server recovers its state after a restart. +func RequestFrom(v View) store.Request { + r := store.Request{ + ID: v.Request, + Name: v.Annotations[AnnoName], + Description: v.Annotations[AnnoDescription], + Woodpecker: v.Annotations[AnnoWoodpecker] == "true", + } + if s := v.Annotations[AnnoStatusChecks]; s != "" { + r.StatusChecks = strings.Split(s, "\n") + } + if t, err := time.Parse(time.RFC3339, v.Annotations[AnnoCreated]); err == nil { + r.Created = t + } + if n, err := strconv.Atoi(v.Annotations[AnnoPullRequestNo]); err == nil { + r.PRNumber = n + } + r.PRURL = v.Annotations[AnnoPullRequest] + return r +} + +// DecodeResult parses a termination message into out, tolerating the empty +// message a pod that never wrote one leaves behind. +func DecodeResult(raw []byte, out any) bool { + raw = []byte(strings.TrimSpace(string(raw))) + if len(raw) == 0 { + return false + } + return json.Unmarshal(raw, out) == nil +} + +func ptr[T any](v T) *T { return &v } diff --git a/internal/jobs/jobs_test.go b/internal/jobs/jobs_test.go new file mode 100644 index 0000000..f716df3 --- /dev/null +++ b/internal/jobs/jobs_test.go @@ -0,0 +1,222 @@ +package jobs + +import ( + "strings" + "testing" + "time" + + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "git.unkin.net/unkin/repospawner/internal/config" + "git.unkin.net/unkin/repospawner/internal/store" +) + +func testConfig() *config.Config { + return &config.Config{ + Namespace: "repospawner", + Image: "artifactapi.k8s.syd1.au.unkin.net/docker-internal/repospawner:v1.2.3", + JobServiceAccount: "repospawner", + GiteaURL: "https://git.unkin.net", + TFGitRepo: "unkin/terraform-git", + VaultAddr: "https://vault.service.consul:8200", + VaultK8sMount: "k8s/au/syd1", + VaultK8sRole: "repospawner", + VaultSATokenPath: "/var/run/secrets/vault/token", + GiteaCredsPath: "gitea/creds/repospawner", + WoodpeckerServer: "https://ci.k8s.syd1.au.unkin.net", + WoodpeckerTokenFile: "/etc/repospawner/woodpecker/token", + WoodpeckerSecret: "repospawner-woodpecker", + AllowedGroups: []string{"akP-repospawner-user"}, + } +} + +func testRequest() store.Request { + return store.Request{ + ID: "abc123", + Name: "widget", + Description: "does widgets", + Woodpecker: true, + StatusChecks: []string{"ci/woodpecker/pr/build", "ci/woodpecker/pr/test"}, + PRNumber: 42, + PRURL: "https://git.unkin.net/unkin/terraform-git/pulls/42", + Created: time.Date(2026, 8, 30, 1, 2, 3, 0, time.UTC), + } +} + +func TestPRJobSpec(t *testing.T) { + cfg, req := testConfig(), testRequest() + j := PR(cfg, req) + + if j.Name != "repospawner-pr-abc123" || j.Namespace != "repospawner" { + t.Errorf("object meta = %s/%s", j.Namespace, j.Name) + } + if j.Labels[LabelRequest] != "abc123" || j.Labels[LabelType] != string(TypePR) || j.Labels[LabelApp] != AppName { + t.Errorf("labels = %v", j.Labels) + } + pod := j.Spec.Template.Spec + if pod.ServiceAccountName != "repospawner" { + t.Errorf("serviceAccountName = %q", pod.ServiceAccountName) + } + if len(pod.Containers) != 1 || pod.Containers[0].Image != cfg.Image { + t.Fatalf("containers = %+v", pod.Containers) + } + args := strings.Join(pod.Containers[0].Args, " ") + want := "job pr --request abc123 --name widget --description does widgets " + + "--checks ci/woodpecker/pr/build,ci/woodpecker/pr/test" + if args != want { + t.Errorf("args = %q, want %q", args, want) + } + if pod.RestartPolicy != corev1.RestartPolicyNever { + t.Errorf("restartPolicy = %q", pod.RestartPolicy) + } + if j.Spec.TTLSecondsAfterFinished == nil || *j.Spec.TTLSecondsAfterFinished != 3600 { + t.Errorf("ttlSecondsAfterFinished = %v", j.Spec.TTLSecondsAfterFinished) + } + if j.Spec.BackoffLimit == nil || *j.Spec.BackoffLimit != 2 { + t.Errorf("backoffLimit = %v", j.Spec.BackoffLimit) + } + + // The vault-audience projected token is what makes the native login work. + if len(pod.Volumes) != 1 || pod.Volumes[0].Projected == nil { + t.Fatalf("volumes = %+v", pod.Volumes) + } + sat := pod.Volumes[0].Projected.Sources[0].ServiceAccountToken + if sat == nil || sat.Audience != "vault" || sat.Path != "token" { + t.Errorf("projected token = %+v", sat) + } + if pod.Containers[0].VolumeMounts[0].MountPath != "/var/run/secrets/vault" { + t.Errorf("mountPath = %q", pod.Containers[0].VolumeMounts[0].MountPath) + } + + env := map[string]string{} + for _, e := range pod.Containers[0].Env { + env[e.Name] = e.Value + } + for k, want := range map[string]string{ + "GITEA_URL": "https://git.unkin.net", + "VAULT_ADDR": "https://vault.service.consul:8200", + "REPOSPAWNER_VAULT_K8S_MOUNT": "k8s/au/syd1", + "REPOSPAWNER_GITEA_CREDS_PATH": "gitea/creds/repospawner", + "REPOSPAWNER_TFGIT_REPO": "unkin/terraform-git", + } { + if env[k] != want { + t.Errorf("env %s = %q, want %q", k, env[k], want) + } + } +} + +func TestWatchJobSpec(t *testing.T) { + j := Watch(testConfig(), testRequest()) + if j.Name != "repospawner-watch-abc123" { + t.Errorf("name = %q", j.Name) + } + args := strings.Join(j.Spec.Template.Spec.Containers[0].Args, " ") + if args != "job watch --repo unkin/terraform-git --pr 42" { + t.Errorf("args = %q", args) + } + if j.Annotations[AnnoPullRequestNo] != "42" || j.Annotations[AnnoPullRequest] == "" { + t.Errorf("annotations = %v", j.Annotations) + } + // A terraform-git PR waits on a human, so the deadline is days not minutes. + if j.Spec.ActiveDeadlineSeconds == nil || *j.Spec.ActiveDeadlineSeconds != 7*24*60*60 { + t.Errorf("activeDeadlineSeconds = %v", j.Spec.ActiveDeadlineSeconds) + } +} + +func TestWoodpeckerJobMountsTokenSecret(t *testing.T) { + cfg := testConfig() + j := Woodpecker(cfg, testRequest()) + if strings.Join(j.Spec.Template.Spec.Containers[0].Args, " ") != "job woodpecker-enable --name widget" { + t.Errorf("args = %v", j.Spec.Template.Spec.Containers[0].Args) + } + var secret *corev1.SecretVolumeSource + for _, v := range j.Spec.Template.Spec.Volumes { + if v.Name == "woodpecker-token" { + secret = v.Secret + } + } + if secret == nil || secret.SecretName != "repospawner-woodpecker" { + t.Fatalf("woodpecker volume = %+v", secret) + } + if secret.Items[0].Key != "token" || secret.Items[0].Path != "token" { + t.Errorf("secret items = %+v", secret.Items) + } + var mounted bool + for _, m := range j.Spec.Template.Spec.Containers[0].VolumeMounts { + if m.Name == "woodpecker-token" && m.MountPath == "/etc/repospawner/woodpecker" { + mounted = true + } + } + if !mounted { + t.Errorf("volumeMounts = %+v", j.Spec.Template.Spec.Containers[0].VolumeMounts) + } +} + +func TestDecodeResult(t *testing.T) { + var res PRResult + if !DecodeResult([]byte(`{"pr_number":7,"pr_url":"https://example/7"}`), &res) { + t.Fatal("DecodeResult returned false for a valid message") + } + if res.PRNumber != 7 || res.PRURL != "https://example/7" { + t.Errorf("res = %+v", res) + } + if DecodeResult(nil, &res) { + t.Error("an empty termination message must not decode") + } + if DecodeResult([]byte(" \n"), &res) { + t.Error("a whitespace termination message must not decode") + } + if DecodeResult([]byte("panic: boom"), &res) { + t.Error("a non-JSON termination message must not decode") + } +} + +func TestViewOfAndRequestFrom(t *testing.T) { + j := batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{LabelRequest: "abc123", LabelType: string(TypeWatch)}, + Annotations: map[string]string{ + AnnoName: "widget", + AnnoDescription: "does widgets", + AnnoWoodpecker: "true", + AnnoStatusChecks: "a\nb", + AnnoCreated: "2026-08-30T01:02:03Z", + AnnoPullRequestNo: "42", + AnnoPullRequest: "https://git.unkin.net/unkin/terraform-git/pulls/42", + }, + }, + Status: batchv1.JobStatus{Succeeded: 1}, + } + v := ViewOf(j, []byte(`{"merged":true}`)) + if v.Type != TypeWatch || v.Request != "abc123" || !v.Succeeded || v.Failed { + t.Fatalf("view = %+v", v) + } + + r := RequestFrom(v) + if r.Name != "widget" || r.Description != "does widgets" || !r.Woodpecker { + t.Errorf("request = %+v", r) + } + if len(r.StatusChecks) != 2 || r.StatusChecks[0] != "a" { + t.Errorf("statusChecks = %v", r.StatusChecks) + } + if r.PRNumber != 42 || r.PRURL == "" { + t.Errorf("pull request = %d %q", r.PRNumber, r.PRURL) + } + if !r.Created.Equal(time.Date(2026, 8, 30, 1, 2, 3, 0, time.UTC)) { + t.Errorf("created = %v", r.Created) + } +} + +func TestViewOfFailedCondition(t *testing.T) { + j := batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{LabelRequest: "x", LabelType: string(TypePR)}}, + Status: batchv1.JobStatus{Conditions: []batchv1.JobCondition{ + {Type: batchv1.JobFailed, Status: corev1.ConditionTrue}, + }}, + } + if v := ViewOf(j, nil); !v.Failed { + t.Errorf("view = %+v, want Failed", v) + } +} diff --git a/internal/jobs/state.go b/internal/jobs/state.go new file mode 100644 index 0000000..057f407 --- /dev/null +++ b/internal/jobs/state.go @@ -0,0 +1,142 @@ +package jobs + +import ( + "git.unkin.net/unkin/repospawner/internal/store" +) + +// Action is the next Job the reconciler should create for a request. +type Action string + +const ( + // ActionNone means the request is either waiting on a running Job or done. + ActionNone Action = "" + // ActionCreateWatch means the pull request exists and wants following. + ActionCreateWatch Action = "watch" + // ActionCreateWoodpecker means the merge landed and CI wants enabling. + ActionCreateWoodpecker Action = "woodpecker" +) + +// Advance folds the observed Jobs for one request into its next state and the +// Job that should be created next. It is deliberately pure: the reconciler +// supplies the observations and performs the action. +func Advance(r store.Request, views map[Type]View) (store.Request, Action) { + if pr, ok := views[TypePR]; ok { + r = applyPR(r, pr) + } + watch, watching := views[TypeWatch] + if watching { + r = applyWatch(r, watch) + } + if r.State == store.StateFailed || r.State == store.StateClosed { + return r, ActionNone + } + + // The pull request is open but nothing is following it yet. + if r.PRNumber > 0 && !watching && r.State != store.StateMerged && + r.State != store.StateEnablingCI && r.State != store.StateReady { + r.State = store.StatePROpen + return r, ActionCreateWatch + } + + if r.State != store.StateMerged && r.State != store.StateEnablingCI && r.State != store.StateReady { + return r, ActionNone + } + if !r.Woodpecker { + r.State = store.StateReady + return r, ActionNone + } + wp, ok := views[TypeWoodpecker] + if !ok { + if r.State == store.StateReady { + // The enablement Job already succeeded and its TTL expired. + return r, ActionNone + } + r.State = store.StateEnablingCI + return r, ActionCreateWoodpecker + } + return applyWoodpecker(r, wp), ActionNone +} + +func applyPR(r store.Request, v View) store.Request { + switch { + case v.Succeeded: + var res PRResult + if !DecodeResult(v.Result, &res) || res.PRURL == "" { + r.State = store.StateFailed + r.Error = "pull request job finished without reporting a pull request" + return r + } + r.PRNumber, r.PRURL = res.PRNumber, res.PRURL + if r.State == store.StateOpeningPR || r.State == "" { + r.State = store.StatePROpen + } + case v.Failed: + r.State = store.StateFailed + r.Error = resultError(v.Result, "pull request job failed") + default: + if r.State == "" { + r.State = store.StateOpeningPR + } + } + return r +} + +func applyWatch(r store.Request, v View) store.Request { + switch { + case v.Succeeded: + var res WatchResult + if !DecodeResult(v.Result, &res) { + r.State = store.StateFailed + r.Error = "watch job finished without reporting an outcome" + return r + } + switch { + case res.Merged: + if r.State != store.StateEnablingCI && r.State != store.StateReady { + r.State = store.StateMerged + } + case res.Closed: + r.State = store.StateClosed + default: + r.State = store.StateFailed + r.Error = resultError(v.Result, "watch job reported neither merge nor close") + } + case v.Failed: + r.State = store.StateFailed + r.Error = resultError(v.Result, "watch job failed") + default: + r.State = store.StatePROpen + } + return r +} + +func applyWoodpecker(r store.Request, v View) store.Request { + switch { + case v.Succeeded: + var res WoodpeckerResult + if !DecodeResult(v.Result, &res) || !res.Enabled { + r.State = store.StateFailed + r.Error = resultError(v.Result, "woodpecker enablement job reported no activation") + return r + } + r.CIEnabled = true + r.State = store.StateReady + case v.Failed: + r.State = store.StateFailed + r.Error = resultError(v.Result, "woodpecker enablement job failed") + default: + r.State = store.StateEnablingCI + } + return r +} + +// resultError prefers the job's own error string over the generic fallback. +func resultError(raw []byte, fallback string) string { + var res struct { + Error string `json:"error"` + } + if DecodeResult(raw, &res) && res.Error != "" { + return res.Error + } + return fallback +} diff --git a/internal/jobs/state_test.go b/internal/jobs/state_test.go new file mode 100644 index 0000000..d038386 --- /dev/null +++ b/internal/jobs/state_test.go @@ -0,0 +1,164 @@ +package jobs + +import ( + "strings" + "testing" + + "git.unkin.net/unkin/repospawner/internal/store" +) + +func view(t Type, succeeded, failed bool, result string) View { + return View{Type: t, Request: "abc123", Succeeded: succeeded, Failed: failed, Result: []byte(result)} +} + +func TestAdvance(t *testing.T) { + base := store.Request{ID: "abc123", Name: "widget", State: store.StateOpeningPR} + withCI := base + withCI.Woodpecker = true + prDone := `{"pr_number":42,"pr_url":"https://git.unkin.net/unkin/terraform-git/pulls/42"}` + + cases := []struct { + name string + in store.Request + views map[Type]View + wantState store.State + wantAction Action + wantPR int + wantErrSub string + }{ + { + name: "pr job still running", + in: base, + views: map[Type]View{TypePR: view(TypePR, false, false, "")}, + wantState: store.StateOpeningPR, + wantAction: ActionNone, + }, + { + name: "pr opened starts the watch", + in: base, + views: map[Type]View{TypePR: view(TypePR, true, false, prDone)}, + wantState: store.StatePROpen, + wantAction: ActionCreateWatch, + wantPR: 42, + }, + { + name: "pr job failed", + in: base, + views: map[Type]View{TypePR: view(TypePR, false, true, `{"error":"create branch: status 403"}`)}, + wantState: store.StateFailed, + wantAction: ActionNone, + wantErrSub: "status 403", + }, + { + name: "pr job succeeded but said nothing", + in: base, + views: map[Type]View{TypePR: view(TypePR, true, false, "")}, + wantState: store.StateFailed, + wantAction: ActionNone, + wantErrSub: "without reporting", + }, + { + name: "watch running keeps the pr open", + in: base, + views: map[Type]View{ + TypePR: view(TypePR, true, false, prDone), + TypeWatch: view(TypeWatch, false, false, ""), + }, + wantState: store.StatePROpen, + wantAction: ActionNone, + wantPR: 42, + }, + { + name: "merge without woodpecker is ready", + in: base, + views: map[Type]View{ + TypePR: view(TypePR, true, false, prDone), + TypeWatch: view(TypeWatch, true, false, `{"merged":true}`), + }, + wantState: store.StateReady, + wantAction: ActionNone, + wantPR: 42, + }, + { + name: "merge with woodpecker enables ci", + in: withCI, + views: map[Type]View{ + TypePR: view(TypePR, true, false, prDone), + TypeWatch: view(TypeWatch, true, false, `{"merged":true}`), + }, + wantState: store.StateEnablingCI, + wantAction: ActionCreateWoodpecker, + wantPR: 42, + }, + { + name: "closed pull request stops everything", + in: withCI, + views: map[Type]View{ + TypePR: view(TypePR, true, false, prDone), + TypeWatch: view(TypeWatch, true, false, `{"closed":true}`), + }, + wantState: store.StateClosed, + wantAction: ActionNone, + }, + { + name: "woodpecker enablement completes the request", + in: withCI, + views: map[Type]View{ + TypeWatch: view(TypeWatch, true, false, `{"merged":true}`), + TypeWoodpecker: view(TypeWoodpecker, true, false, `{"enabled":true,"repo_id":9}`), + }, + wantState: store.StateReady, + wantAction: ActionNone, + }, + { + name: "woodpecker enablement failure surfaces its error", + in: withCI, + views: map[Type]View{ + TypeWatch: view(TypeWatch, true, false, `{"merged":true}`), + TypeWoodpecker: view(TypeWoodpecker, false, true, `{"error":"enable in woodpecker: status 401"}`), + }, + wantState: store.StateFailed, + wantAction: ActionNone, + wantErrSub: "status 401", + }, + { + name: "watch job failure fails the request", + in: base, + views: map[Type]View{TypeWatch: view(TypeWatch, false, true, "")}, + wantState: store.StateFailed, + wantAction: ActionNone, + wantErrSub: "watch job failed", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, action := Advance(tc.in, tc.views) + if got.State != tc.wantState { + t.Errorf("state = %q, want %q", got.State, tc.wantState) + } + if action != tc.wantAction { + t.Errorf("action = %q, want %q", action, tc.wantAction) + } + if tc.wantPR != 0 && got.PRNumber != tc.wantPR { + t.Errorf("prNumber = %d, want %d", got.PRNumber, tc.wantPR) + } + if tc.wantErrSub != "" && !strings.Contains(got.Error, tc.wantErrSub) { + t.Errorf("error = %q, want it to mention %q", got.Error, tc.wantErrSub) + } + if tc.wantState == store.StateReady && tc.in.Woodpecker && !got.CIEnabled { + t.Errorf("ciEnabled = false for a completed woodpecker request") + } + }) + } +} + +// TestAdvanceIsStableAfterTTL covers the window where finished Jobs have been +// garbage collected: a terminal request must not be re-driven. +func TestAdvanceIsStableAfterTTL(t *testing.T) { + done := store.Request{ID: "abc123", Name: "widget", Woodpecker: true, State: store.StateReady, CIEnabled: true, PRNumber: 42} + got, action := Advance(done, map[Type]View{}) + if got.State != store.StateReady || action != ActionNone { + t.Errorf("state = %q action = %q, want ready/none", got.State, action) + } +} diff --git a/internal/repospec/repospec.go b/internal/repospec/repospec.go new file mode 100644 index 0000000..f1c3777 --- /dev/null +++ b/internal/repospec/repospec.go @@ -0,0 +1,133 @@ +// Package repospec validates a new-repo request and renders the terraform-git +// repository config file it becomes. +package repospec + +import ( + "fmt" + "regexp" + "sort" + "strings" +) + +// ConfigDir is the terraform-git tree that owns repository definitions. +const ConfigDir = "config/git.unkin.net/unkin/repository" + +// nameRE is the DNS-label-ish shape a repository name must take: it becomes a +// branch name, a container image name and a k8s object name downstream. +var nameRE = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`) + +// maxNameLen keeps generated Job names (prefix + name + suffix) inside the 63 +// character limit k8s applies to object names. +const maxNameLen = 40 + +// Request is a submitted new-repo request. +type Request struct { + Name string `json:"name"` + Description string `json:"description"` + Woodpecker bool `json:"woodpecker"` + StatusChecks []string `json:"status_checks"` +} + +// FieldErrors maps a request field to why it was rejected. +type FieldErrors map[string]string + +func (f FieldErrors) Error() string { + keys := make([]string, 0, len(f)) + for k := range f { + keys = append(keys, k) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, k+": "+f[k]) + } + return strings.Join(parts, "; ") +} + +// Normalize trims incidental whitespace and drops blank status check lines. The +// UI submits the check list as a textarea, so blank lines are routine. +func (r Request) Normalize() Request { + r.Name = strings.TrimSpace(r.Name) + r.Description = strings.TrimSpace(r.Description) + checks := make([]string, 0, len(r.StatusChecks)) + seen := map[string]bool{} + for _, c := range r.StatusChecks { + c = strings.TrimSpace(c) + if c == "" || seen[c] { + continue + } + seen[c] = true + checks = append(checks, c) + } + r.StatusChecks = checks + return r +} + +// Validate checks a normalized request, returning nil or a FieldErrors naming +// every problem at once so the form can show them together. +func (r Request) Validate() error { + errs := FieldErrors{} + switch { + case r.Name == "": + errs["name"] = "required" + case len(r.Name) > maxNameLen: + errs["name"] = fmt.Sprintf("must be at most %d characters", maxNameLen) + case !nameRE.MatchString(r.Name): + errs["name"] = "must be lowercase letters, digits and dashes, starting and ending alphanumeric" + } + if r.Description == "" { + errs["description"] = "required" + } + if len(r.StatusChecks) == 0 { + errs["status_checks"] = "at least one status check context is required" + } + for _, c := range r.StatusChecks { + if strings.ContainsAny(c, "\n\"") { + errs["status_checks"] = "must not contain quotes or newlines" + break + } + } + if len(errs) == 0 { + return nil + } + return errs +} + +// ConfigPath is the repository config file a request writes. +func (r Request) ConfigPath() string { return ConfigDir + "/" + r.Name + ".yaml" } + +// BranchName is the terraform-git branch the PR job pushes. +func (r Request) BranchName() string { return "repospawner/" + r.Name } + +// RenderYAML produces the terraform-git repository config. Everything except +// the description and the status check contexts is fixed estate policy: public, +// main-default, squash-merged, branch-deleted, and a protected main only the +// Owners team can merge into with benvin as the approver. +func (r Request) RenderYAML() string { + var b strings.Builder + fmt.Fprintf(&b, "description: %s\n", quote(r.Description)) + b.WriteString("private: false\n") + b.WriteString("default_branch: \"main\"\n") + b.WriteString("default_delete_branch_after_merge: true\n") + b.WriteString("default_merge_style: \"squash\"\n") + b.WriteString("branch_protection:\n") + b.WriteString(" - rule_name: \"main\"\n") + b.WriteString(" merge_whitelist_teams:\n") + b.WriteString(" - \"Owners\"\n") + b.WriteString(" enable_push: false\n") + b.WriteString(" status_check_contexts:\n") + for _, c := range r.StatusChecks { + fmt.Fprintf(&b, " - %s\n", quote(c)) + } + b.WriteString(" approval_whitelist_users:\n") + b.WriteString(" - \"benvin\"\n") + return b.String() +} + +// quote emits a double-quoted YAML scalar, escaping the two characters that +// can break out of one. +func quote(s string) string { + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, `"`, `\"`) + return `"` + s + `"` +} diff --git a/internal/repospec/repospec_test.go b/internal/repospec/repospec_test.go new file mode 100644 index 0000000..a3c951b --- /dev/null +++ b/internal/repospec/repospec_test.go @@ -0,0 +1,146 @@ +package repospec + +import ( + "errors" + "strings" + "testing" +) + +func TestValidate(t *testing.T) { + base := Request{Name: "widget", Description: "does widgets", StatusChecks: []string{"ci/woodpecker/pr/test"}} + + cases := []struct { + name string + req Request + fields []string + wantErr bool + }{ + {name: "valid", req: base}, + {name: "valid with digits and dashes", req: with(base, func(r *Request) { r.Name = "arr-proxy2" })}, + {name: "missing name", req: with(base, func(r *Request) { r.Name = "" }), fields: []string{"name"}, wantErr: true}, + {name: "uppercase name", req: with(base, func(r *Request) { r.Name = "Widget" }), fields: []string{"name"}, wantErr: true}, + {name: "underscore name", req: with(base, func(r *Request) { r.Name = "wid_get" }), fields: []string{"name"}, wantErr: true}, + {name: "leading dash", req: with(base, func(r *Request) { r.Name = "-widget" }), fields: []string{"name"}, wantErr: true}, + {name: "trailing dash", req: with(base, func(r *Request) { r.Name = "widget-" }), fields: []string{"name"}, wantErr: true}, + {name: "path traversal", req: with(base, func(r *Request) { r.Name = "../etc/passwd" }), fields: []string{"name"}, wantErr: true}, + {name: "over long", req: with(base, func(r *Request) { r.Name = strings.Repeat("a", maxNameLen+1) }), fields: []string{"name"}, wantErr: true}, + {name: "missing description", req: with(base, func(r *Request) { r.Description = "" }), fields: []string{"description"}, wantErr: true}, + {name: "no checks", req: with(base, func(r *Request) { r.StatusChecks = nil }), fields: []string{"status_checks"}, wantErr: true}, + { + name: "every field bad at once", + req: Request{}, + fields: []string{"name", "description", "status_checks"}, + wantErr: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := tc.req.Validate() + if tc.wantErr == (err == nil) { + t.Fatalf("Validate() = %v, wantErr %v", err, tc.wantErr) + } + if err == nil { + return + } + var fe FieldErrors + if !errors.As(err, &fe) { + t.Fatalf("error %v is not FieldErrors", err) + } + if len(fe) != len(tc.fields) { + t.Fatalf("fields = %v, want exactly %v", fe, tc.fields) + } + for _, f := range tc.fields { + if _, ok := fe[f]; !ok { + t.Errorf("missing field error for %q; got %v", f, fe) + } + } + }) + } +} + +func TestNormalize(t *testing.T) { + r := Request{ + Name: " widget \n", + Description: " does widgets ", + StatusChecks: []string{" a ", "", "b", "a", " "}, + }.Normalize() + + if r.Name != "widget" { + t.Errorf("Name = %q", r.Name) + } + if r.Description != "does widgets" { + t.Errorf("Description = %q", r.Description) + } + want := []string{"a", "b"} + if len(r.StatusChecks) != len(want) { + t.Fatalf("StatusChecks = %v, want %v", r.StatusChecks, want) + } + for i := range want { + if r.StatusChecks[i] != want[i] { + t.Fatalf("StatusChecks = %v, want %v", r.StatusChecks, want) + } + } +} + +func TestPathsAndBranch(t *testing.T) { + r := Request{Name: "widget"} + if got, want := r.ConfigPath(), "config/git.unkin.net/unkin/repository/widget.yaml"; got != want { + t.Errorf("ConfigPath() = %q, want %q", got, want) + } + if got, want := r.BranchName(), "repospawner/widget"; got != want { + t.Errorf("BranchName() = %q, want %q", got, want) + } +} + +// golden pins the file terraform-git receives; a drift here changes estate +// policy for every repository repospawner creates. +const golden = `description: "Keyboard-centric 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" +` + +func TestRenderYAMLGolden(t *testing.T) { + got := Request{ + Name: "widget", + Description: "Keyboard-centric widget service", + StatusChecks: []string{ + "ci/woodpecker/pr/build", + "ci/woodpecker/pr/test", + "ci/woodpecker/pr/pre-commit", + }, + }.RenderYAML() + if got != golden { + t.Errorf("RenderYAML() mismatch\n got:\n%s\nwant:\n%s", got, golden) + } +} + +func TestRenderYAMLQuotesDescription(t *testing.T) { + got := Request{ + Name: "widget", + Description: `a "quoted" \ description`, + StatusChecks: []string{"x"}, + }.RenderYAML() + want := `description: "a \"quoted\" \\ description"` + if !strings.Contains(got, want) { + t.Errorf("RenderYAML() did not escape the description; got first line %q", strings.SplitN(got, "\n", 2)[0]) + } +} + +func with(r Request, f func(*Request)) Request { + f(&r) + return r +} diff --git a/internal/server/cluster.go b/internal/server/cluster.go new file mode 100644 index 0000000..7bc6462 --- /dev/null +++ b/internal/server/cluster.go @@ -0,0 +1,80 @@ +package server + +import ( + "context" + + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + + "git.unkin.net/unkin/repospawner/internal/jobs" +) + +// Cluster is the slice of the Kubernetes API the server needs. Keeping it an +// interface lets the reconciler and handlers be tested without a cluster. +type Cluster interface { + // CreateJob creates a Job, reporting AlreadyExists as nil so a repeated + // reconcile is harmless. + CreateJob(ctx context.Context, job *batchv1.Job) error + // ListJobs returns every repospawner-owned Job in the namespace. + ListJobs(ctx context.Context) ([]batchv1.Job, error) + // ListPods returns every repospawner-owned Job pod in the namespace. + ListPods(ctx context.Context) ([]corev1.Pod, error) + // Ping reports whether the API server is reachable. + Ping(ctx context.Context) error +} + +// KubeCluster is the in-cluster Cluster implementation. +type KubeCluster struct { + client kubernetes.Interface + namespace string +} + +// NewKubeCluster builds a Cluster from the pod's in-cluster credentials. +func NewKubeCluster(namespace string) (*KubeCluster, error) { + cfg, err := rest.InClusterConfig() + if err != nil { + return nil, err + } + client, err := kubernetes.NewForConfig(cfg) + if err != nil { + return nil, err + } + return &KubeCluster{client: client, namespace: namespace}, nil +} + +// ownedSelector matches everything repospawner creates. +const ownedSelector = jobs.LabelApp + "=" + jobs.AppName + "," + jobs.LabelRequest + +func (k *KubeCluster) CreateJob(ctx context.Context, job *batchv1.Job) error { + _, err := k.client.BatchV1().Jobs(k.namespace).Create(ctx, job, metav1.CreateOptions{}) + if apierrors.IsAlreadyExists(err) { + return nil + } + return err +} + +func (k *KubeCluster) ListJobs(ctx context.Context) ([]batchv1.Job, error) { + list, err := k.client.BatchV1().Jobs(k.namespace).List(ctx, metav1.ListOptions{LabelSelector: ownedSelector}) + if err != nil { + return nil, err + } + return list.Items, nil +} + +func (k *KubeCluster) ListPods(ctx context.Context) ([]corev1.Pod, error) { + list, err := k.client.CoreV1().Pods(k.namespace).List(ctx, metav1.ListOptions{LabelSelector: ownedSelector}) + if err != nil { + return nil, err + } + return list.Items, nil +} + +func (k *KubeCluster) Ping(ctx context.Context) error { + limit := int64(1) + _, err := k.client.BatchV1().Jobs(k.namespace).List(ctx, metav1.ListOptions{Limit: limit}) + return err +} diff --git a/internal/server/reconcile.go b/internal/server/reconcile.go new file mode 100644 index 0000000..4f94f82 --- /dev/null +++ b/internal/server/reconcile.go @@ -0,0 +1,140 @@ +package server + +import ( + "context" + "time" + + corev1 "k8s.io/api/core/v1" + + "git.unkin.net/unkin/repospawner/internal/jobs" + "git.unkin.net/unkin/repospawner/internal/store" +) + +// reconcileInterval is how often the server folds Job state into the store. The +// UI polls on the same cadence, so a change surfaces within two ticks. +const reconcileInterval = 10 * time.Second + +// Run reconciles until ctx is cancelled, starting with an immediate pass so a +// restarted server rebuilds its state before serving its first request. +func (s *Server) Run(ctx context.Context) { + if err := s.Reconcile(ctx); err != nil { + s.log.Error("initial reconcile failed", "err", err) + } + t := time.NewTicker(reconcileInterval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + if err := s.Reconcile(ctx); err != nil { + s.log.Error("reconcile failed", "err", err) + } + } + } +} + +// Reconcile reads every repospawner Job, advances each request's state and +// creates whatever Job comes next. It is also the startup recovery path: a +// request the store has never seen is rebuilt from its Job annotations. +func (s *Server) Reconcile(ctx context.Context) error { + jobList, err := s.cluster.ListJobs(ctx) + if err != nil { + return err + } + podList, err := s.cluster.ListPods(ctx) + if err != nil { + return err + } + results := terminationMessages(podList) + + byRequest := map[string]map[jobs.Type]jobs.View{} + for _, j := range jobList { + id := j.Labels[jobs.LabelRequest] + t := jobs.Type(j.Labels[jobs.LabelType]) + if id == "" || t == "" { + continue + } + v := jobs.ViewOf(j, results[resultKey{id, t}]) + if byRequest[id] == nil { + byRequest[id] = map[jobs.Type]jobs.View{} + } + byRequest[id][t] = v + } + + for id, views := range byRequest { + current, ok := s.store.Get(id) + if !ok { + current = rebuild(views) + } + next, action := jobs.Advance(current, views) + s.store.Put(next) + if err := s.act(ctx, next, action); err != nil { + s.log.Error("create follow-up job", "request", id, "action", string(action), "err", err) + } + } + return nil +} + +// rebuild reconstructs a request the store lost, preferring the newest job's +// annotations because those carry the pull request coordinates. +func rebuild(views map[jobs.Type]jobs.View) store.Request { + for _, t := range []jobs.Type{jobs.TypeWoodpecker, jobs.TypeWatch, jobs.TypePR} { + if v, ok := views[t]; ok { + return jobs.RequestFrom(v) + } + } + return store.Request{} +} + +func (s *Server) act(ctx context.Context, r store.Request, action jobs.Action) error { + switch action { + case jobs.ActionCreateWatch: + s.log.Info("following terraform-git pull request", "request", r.ID, "pr", r.PRNumber) + return s.cluster.CreateJob(ctx, jobs.Watch(s.cfg, r)) + case jobs.ActionCreateWoodpecker: + if !s.woodpeckerAvailable() { + s.log.Warn("woodpecker enablement requested but no token is mounted", "request", r.ID) + return nil + } + s.log.Info("enabling repository in woodpecker", "request", r.ID, "name", r.Name) + return s.cluster.CreateJob(ctx, jobs.Woodpecker(s.cfg, r)) + case jobs.ActionNone: + return nil + default: + return nil + } +} + +type resultKey struct { + request string + jobType jobs.Type +} + +// terminationMessages collects each job pod's termination message, preferring a +// terminated container over one that is merely waiting to restart. +func terminationMessages(pods []corev1.Pod) map[resultKey][]byte { + out := map[resultKey][]byte{} + for _, p := range pods { + id := p.Labels[jobs.LabelRequest] + t := jobs.Type(p.Labels[jobs.LabelType]) + if id == "" || t == "" { + continue + } + for _, cs := range p.Status.ContainerStatuses { + term := cs.State.Terminated + if term == nil && cs.LastTerminationState.Terminated != nil { + term = cs.LastTerminationState.Terminated + } + if term == nil || term.Message == "" { + continue + } + key := resultKey{id, t} + // A retried pod leaves several messages; the successful one wins. + if _, seen := out[key]; !seen || term.ExitCode == 0 { + out[key] = []byte(term.Message) + } + } + } + return out +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..42ae529 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,222 @@ +// Package server wires repospawner's HTTP surface: the request API, the health +// probes and the embedded UI, plus the reconcile loop that drives requests +// forward. +package server + +import ( + "context" + "encoding/json" + "errors" + "io" + "io/fs" + "log/slog" + "net/http" + "os" + "strings" + "time" + + "git.unkin.net/unkin/repospawner/internal/auth" + "git.unkin.net/unkin/repospawner/internal/config" + "git.unkin.net/unkin/repospawner/internal/gitea" + "git.unkin.net/unkin/repospawner/internal/jobs" + "git.unkin.net/unkin/repospawner/internal/repospec" + "git.unkin.net/unkin/repospawner/internal/store" +) + +// maxBodyBytes caps a submitted request body; the payload is four small fields. +const maxBodyBytes = 64 << 10 + +// Server holds the resolved dependencies of the app. +type Server struct { + cfg *config.Config + store *store.Store + forge *gitea.Client + cluster Cluster + gate *auth.Middleware + assets fs.FS + log *slog.Logger +} + +// New constructs a Server. +func New(cfg *config.Config, st *store.Store, forge *gitea.Client, cluster Cluster, assets fs.FS, log *slog.Logger) *Server { + if log == nil { + log = slog.Default() + } + return &Server{ + cfg: cfg, + store: st, + forge: forge, + cluster: cluster, + gate: auth.New(cfg.GroupsHeader, cfg.AllowedGroups), + assets: assets, + log: log, + } +} + +// Handler returns the root handler. Health probes are ungated (kubelet sends no +// identity header); everything else — API and UI alike — sits behind the group +// gate, so an unauthorized user cannot even load the page shell. +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /livez", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("ok")) + }) + mux.HandleFunc("GET /readyz", s.readyz) + + gated := http.NewServeMux() + gated.HandleFunc("POST /api/requests", s.handleCreate) + gated.HandleFunc("GET /api/requests", s.handleList) + gated.HandleFunc("GET /api/requests/{id}", s.handleGet) + gated.HandleFunc("GET /api/capabilities", s.handleCapabilities) + gated.HandleFunc("/", s.handleUI) + + mux.Handle("/", s.gate.Wrap(gated)) + return secureHeaders(mux) +} + +// cspPolicy locks the page to same-origin code. The UI carries no inline script +// or style, so no unsafe-inline escape hatch is needed; data: is in img-src +// solely for the inline SVG favicon. +const cspPolicy = "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'" + +// secureHeaders stamps the browser-facing hardening headers onto every +// response — API, UI and probes alike — before the handler writes. +func secureHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("Content-Security-Policy", cspPolicy) + h.Set("X-Content-Type-Options", "nosniff") + h.Set("Referrer-Policy", "no-referrer") + next.ServeHTTP(w, r) + }) +} + +func (s *Server) readyz(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + if err := s.cluster.Ping(ctx); err != nil { + s.log.Warn("readyz: kubernetes api unreachable", "err", err) + http.Error(w, "kubernetes api unreachable", http.StatusServiceUnavailable) + return + } + _, _ = w.Write([]byte("ok")) +} + +// woodpeckerAvailable reports whether a Woodpecker API token is mounted. It is +// read per call so a later secret mount needs no restart. +func (s *Server) woodpeckerAvailable() bool { + if s.cfg.WoodpeckerTokenFile == "" { + return false + } + b, err := os.ReadFile(s.cfg.WoodpeckerTokenFile) + return err == nil && strings.TrimSpace(string(b)) != "" +} + +func (s *Server) handleCapabilities(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "woodpecker": s.woodpeckerAvailable(), + "tfgit_repo": s.cfg.TFGitRepo, + }) +} + +func (s *Server) handleCreate(w http.ResponseWriter, r *http.Request) { + var spec repospec.Request + dec := json.NewDecoder(io.LimitReader(r.Body, maxBodyBytes)) + dec.DisallowUnknownFields() + if err := dec.Decode(&spec); err != nil { + writeErr(w, http.StatusBadRequest, "request body is not the expected JSON object") + return + } + spec = spec.Normalize() + if err := spec.Validate(); err != nil { + var fe repospec.FieldErrors + if errors.As(err, &fe) { + writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid request", "fields": fe}) + return + } + writeErr(w, http.StatusBadRequest, "invalid request") + return + } + if spec.Woodpecker && !s.woodpeckerAvailable() { + writeErr(w, http.StatusServiceUnavailable, + "woodpecker enablement is unavailable: no woodpecker API token is mounted; resubmit with woodpecker disabled") + return + } + if s.store.HasActiveName(spec.Name) { + writeErr(w, http.StatusConflict, "a request for that repository name is already in flight") + return + } + + exists, err := s.forge.FileExists(r.Context(), s.cfg.TFGitRepo, spec.ConfigPath(), "main") + if err != nil { + s.log.Error("terraform-git name check failed", "name", spec.Name, "err", err) + writeErr(w, http.StatusBadGateway, "cannot check the repository name against terraform-git") + return + } + if exists { + writeErr(w, http.StatusConflict, "that repository is already defined in terraform-git") + return + } + + req := store.NewRequest(store.NewID(), spec, s.store.Now()) + if err := s.cluster.CreateJob(r.Context(), jobs.PR(s.cfg, req)); err != nil { + s.log.Error("create pull request job", "request", req.ID, "err", err) + writeErr(w, http.StatusInternalServerError, "cannot start the pull request job") + return + } + s.store.Put(req) + s.log.Info("accepted repo request", "request", req.ID, "name", req.Name, "woodpecker", req.Woodpecker) + + w.Header().Set("Location", "/api/requests/"+req.ID) + writeJSON(w, http.StatusAccepted, map[string]any{ + "id": req.ID, + "status_url": "/api/requests/" + req.ID, + "state": req.State, + }) +} + +func (s *Server) handleList(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{"requests": s.store.List()}) +} + +func (s *Server) handleGet(w http.ResponseWriter, r *http.Request) { + req, ok := s.store.Get(r.PathValue("id")) + if !ok { + writeErr(w, http.StatusNotFound, "no such request") + return + } + writeJSON(w, http.StatusOK, req) +} + +// handleUI serves the embedded assets, falling back to index.html so a reload +// on any path lands on the app. +func (s *Server) handleUI(w http.ResponseWriter, r *http.Request) { + p := strings.TrimPrefix(r.URL.Path, "/") + if strings.HasPrefix(p, "api/") { + writeErr(w, http.StatusNotFound, "not found") + return + } + if p != "" { + if st, err := fs.Stat(s.assets, p); err == nil && !st.IsDir() { + http.FileServerFS(s.assets).ServeHTTP(w, r) + return + } + } + b, err := fs.ReadFile(s.assets, "index.html") + if err != nil { + http.Error(w, "index missing", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(b) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func writeErr(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..2b83c23 --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,529 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "io/fs" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "testing/fstest" + "time" + + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "git.unkin.net/unkin/repospawner/internal/config" + "git.unkin.net/unkin/repospawner/internal/gitea" + "git.unkin.net/unkin/repospawner/internal/jobs" + "git.unkin.net/unkin/repospawner/internal/store" +) + +// fakeCluster records what the server would have created. +type fakeCluster struct { + mu sync.Mutex + created []*batchv1.Job + jobs []batchv1.Job + pods []corev1.Pod + createErr error + pingErr error +} + +func (f *fakeCluster) CreateJob(_ context.Context, job *batchv1.Job) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.createErr != nil { + return f.createErr + } + f.created = append(f.created, job) + f.jobs = append(f.jobs, *job) + return nil +} + +func (f *fakeCluster) ListJobs(context.Context) ([]batchv1.Job, error) { + f.mu.Lock() + defer f.mu.Unlock() + return append([]batchv1.Job(nil), f.jobs...), nil +} + +func (f *fakeCluster) ListPods(context.Context) ([]corev1.Pod, error) { + f.mu.Lock() + defer f.mu.Unlock() + return append([]corev1.Pod(nil), f.pods...), nil +} + +func (f *fakeCluster) Ping(context.Context) error { return f.pingErr } + +func (f *fakeCluster) createdNames() []string { + f.mu.Lock() + defer f.mu.Unlock() + out := make([]string, 0, len(f.created)) + for _, j := range f.created { + out = append(out, j.Name) + } + return out +} + +func testAssets() fs.FS { + return fstest.MapFS{ + "index.html": &fstest.MapFile{Data: []byte("repospawner")}, + "app.css": &fstest.MapFile{Data: []byte("body{}")}, + } +} + +func testCfg(t *testing.T) *config.Config { + t.Helper() + return &config.Config{ + Listen: ":0", + Namespace: "repospawner", + Image: "repospawner:test", + JobServiceAccount: "repospawner", + GiteaURL: "https://git.unkin.net", + TFGitRepo: "unkin/terraform-git", + VaultAddr: "https://vault.invalid", + VaultK8sMount: "k8s/au/syd1", + VaultK8sRole: "repospawner", + VaultSATokenPath: "/var/run/secrets/vault/token", + GiteaCredsPath: "gitea/creds/repospawner", + WoodpeckerServer: "https://ci.invalid", + WoodpeckerTokenFile: filepath.Join(t.TempDir(), "absent"), + WoodpeckerSecret: "repospawner-woodpecker", + GroupsHeader: "X-Forwarded-Groups", + AllowedGroups: []string{"akP-repospawner-user"}, + } +} + +// newTestServer wires a Server against a stub forge whose contents endpoint +// answers exists for every name in taken. +func newTestServer(t *testing.T, cfg *config.Config, cluster Cluster, taken ...string) (*Server, *store.Store) { + t.Helper() + forgeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + for _, name := range taken { + if strings.HasSuffix(r.URL.Path, "/"+name+".yaml") { + _, _ = w.Write([]byte(`{"name":"` + name + `.yaml"}`)) + return + } + } + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"object does not exist"}`)) + })) + t.Cleanup(forgeSrv.Close) + + forge := gitea.New(forgeSrv.URL, func(context.Context, bool) (string, error) { return "tok", nil }) + st := store.New() + log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + return New(cfg, st, forge, cluster, testAssets(), log), st +} + +func post(t *testing.T, h http.Handler, body string, groups string) *httptest.ResponseRecorder { + t.Helper() + r := httptest.NewRequest(http.MethodPost, "/api/requests", strings.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + if groups != "" { + r.Header.Set("X-Forwarded-Groups", groups) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w +} + +const allowed = "akP-repospawner-user" + +func TestCreateAcceptsAndLaunchesPRJob(t *testing.T) { + cluster := &fakeCluster{} + srv, st := newTestServer(t, testCfg(t), cluster) + h := srv.Handler() + + w := post(t, h, `{"name":"widget","description":"does widgets","woodpecker":false,"status_checks":["ci/woodpecker/pr/test"]}`, allowed) + if w.Code != http.StatusAccepted { + t.Fatalf("status = %d body = %s", w.Code, w.Body.String()) + } + var res struct { + ID string `json:"id"` + StatusURL string `json:"status_url"` + State string `json:"state"` + } + if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil { + t.Fatalf("decode: %v", err) + } + if res.ID == "" || res.StatusURL != "/api/requests/"+res.ID || res.State != string(store.StateOpeningPR) { + t.Fatalf("response = %+v", res) + } + if got := w.Header().Get("Location"); got != res.StatusURL { + t.Errorf("Location = %q, want %q", got, res.StatusURL) + } + if names := cluster.createdNames(); len(names) != 1 || names[0] != "repospawner-pr-"+res.ID { + t.Errorf("created jobs = %v", names) + } + if _, ok := st.Get(res.ID); !ok { + t.Error("the accepted request was not recorded") + } + + // The status URL the caller was handed must resolve. + getReq := httptest.NewRequest(http.MethodGet, res.StatusURL, nil) + getReq.Header.Set("X-Forwarded-Groups", allowed) + getRec := httptest.NewRecorder() + h.ServeHTTP(getRec, getReq) + if getRec.Code != http.StatusOK { + t.Fatalf("GET %s = %d", res.StatusURL, getRec.Code) + } + var stored store.Request + if err := json.Unmarshal(getRec.Body.Bytes(), &stored); err != nil { + t.Fatalf("decode: %v", err) + } + if stored.Name != "widget" || stored.State != store.StateOpeningPR || stored.PRURL != "" { + t.Errorf("stored = %+v (pr_url is empty until the job reports it)", stored) + } +} + +func TestCreateValidationErrors(t *testing.T) { + srv, _ := newTestServer(t, testCfg(t), &fakeCluster{}) + h := srv.Handler() + + cases := []struct { + name string + body string + status int + fields []string + }{ + {name: "not json", body: `nope`, status: http.StatusBadRequest}, + {name: "unknown field", body: `{"name":"a","description":"d","status_checks":["x"],"private":true}`, status: http.StatusBadRequest}, + { + name: "missing everything", + body: `{}`, + status: http.StatusBadRequest, + fields: []string{"name", "description", "status_checks"}, + }, + { + name: "bad name", + body: `{"name":"Widget","description":"d","status_checks":["x"]}`, + status: http.StatusBadRequest, + fields: []string{"name"}, + }, + { + name: "blank status checks", + body: `{"name":"widget","description":"d","status_checks":[" ",""]}`, + status: http.StatusBadRequest, + fields: []string{"status_checks"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w := post(t, h, tc.body, allowed) + if w.Code != tc.status { + t.Fatalf("status = %d body = %s", w.Code, w.Body.String()) + } + if len(tc.fields) == 0 { + return + } + var res struct { + Fields map[string]string `json:"fields"` + } + if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil { + t.Fatalf("decode: %v", err) + } + if len(res.Fields) != len(tc.fields) { + t.Fatalf("fields = %v, want %v", res.Fields, tc.fields) + } + for _, f := range tc.fields { + if _, ok := res.Fields[f]; !ok { + t.Errorf("missing field error %q in %v", f, res.Fields) + } + } + }) + } +} + +func TestCreateRejectsNameAlreadyInTerraformGit(t *testing.T) { + cluster := &fakeCluster{} + srv, _ := newTestServer(t, testCfg(t), cluster, "widget") + w := post(t, srv.Handler(), `{"name":"widget","description":"d","status_checks":["x"]}`, allowed) + if w.Code != http.StatusConflict { + t.Fatalf("status = %d body = %s", w.Code, w.Body.String()) + } + if len(cluster.createdNames()) != 0 { + t.Error("a rejected request must not launch a job") + } +} + +func TestCreateRejectsDuplicateInFlightName(t *testing.T) { + srv, _ := newTestServer(t, testCfg(t), &fakeCluster{}) + h := srv.Handler() + body := `{"name":"widget","description":"d","status_checks":["x"]}` + if w := post(t, h, body, allowed); w.Code != http.StatusAccepted { + t.Fatalf("first request status = %d", w.Code) + } + if w := post(t, h, body, allowed); w.Code != http.StatusConflict { + t.Fatalf("second request status = %d, want 409", w.Code) + } +} + +func TestCreateRejectsWoodpeckerWithoutToken(t *testing.T) { + srv, _ := newTestServer(t, testCfg(t), &fakeCluster{}) + w := post(t, srv.Handler(), `{"name":"widget","description":"d","woodpecker":true,"status_checks":["x"]}`, allowed) + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d body = %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "woodpecker") { + t.Errorf("body = %s", w.Body.String()) + } +} + +func TestCreateAcceptsWoodpeckerWhenTokenMounted(t *testing.T) { + cfg := testCfg(t) + cfg.WoodpeckerTokenFile = filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(cfg.WoodpeckerTokenFile, []byte("wp-token\n"), 0o600); err != nil { + t.Fatalf("write token: %v", err) + } + srv, _ := newTestServer(t, cfg, &fakeCluster{}) + w := post(t, srv.Handler(), `{"name":"widget","description":"d","woodpecker":true,"status_checks":["x"]}`, allowed) + if w.Code != http.StatusAccepted { + t.Fatalf("status = %d body = %s", w.Code, w.Body.String()) + } +} + +func TestCreateSurfacesJobFailure(t *testing.T) { + cluster := &fakeCluster{createErr: errors.New("forbidden")} + srv, st := newTestServer(t, testCfg(t), cluster) + w := post(t, srv.Handler(), `{"name":"widget","description":"d","status_checks":["x"]}`, allowed) + if w.Code != http.StatusInternalServerError { + t.Fatalf("status = %d", w.Code) + } + if len(st.List()) != 0 { + t.Error("a request whose job could not be created must not be recorded") + } +} + +func TestGroupGateAndProbes(t *testing.T) { + cluster := &fakeCluster{} + srv, _ := newTestServer(t, testCfg(t), cluster) + h := srv.Handler() + + for _, path := range []string{"/", "/api/requests"} { + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) + if w.Code != http.StatusForbidden { + t.Errorf("GET %s without a group = %d, want 403", path, w.Code) + } + } + if w := post(t, h, `{"name":"widget","description":"d","status_checks":["x"]}`, ""); w.Code != http.StatusForbidden { + t.Errorf("POST without a group = %d, want 403", w.Code) + } + + // Probes are ungated: the kubelet sends no identity header. + for _, path := range []string{"/livez", "/readyz"} { + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) + if w.Code != http.StatusOK { + t.Errorf("GET %s = %d, want 200", path, w.Code) + } + } + + cluster.pingErr = errors.New("connection refused") + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/readyz", nil)) + if w.Code != http.StatusServiceUnavailable { + t.Errorf("readyz with an unreachable API = %d, want 503", w.Code) + } +} + +func TestSecureHeadersAndUI(t *testing.T) { + srv, _ := newTestServer(t, testCfg(t), &fakeCluster{}) + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Header.Set("X-Forwarded-Groups", allowed) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, r) + + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "repospawner") { + t.Fatalf("status = %d body = %q", w.Code, w.Body.String()) + } + if got := w.Header().Get("Content-Security-Policy"); !strings.Contains(got, "default-src 'self'") { + t.Errorf("CSP = %q", got) + } + if w.Header().Get("X-Content-Type-Options") != "nosniff" { + t.Errorf("X-Content-Type-Options = %q", w.Header().Get("X-Content-Type-Options")) + } + + // An unknown /api path must 404 rather than fall through to index.html. + r2 := httptest.NewRequest(http.MethodGet, "/api/nope", nil) + r2.Header.Set("X-Forwarded-Groups", allowed) + w2 := httptest.NewRecorder() + srv.Handler().ServeHTTP(w2, r2) + if w2.Code != http.StatusNotFound { + t.Errorf("GET /api/nope = %d, want 404", w2.Code) + } +} + +func TestListNewestFirst(t *testing.T) { + srv, st := newTestServer(t, testCfg(t), &fakeCluster{}) + base := time.Date(2026, 8, 30, 0, 0, 0, 0, time.UTC) + now := base + st.SetClock(func() time.Time { return now }) + for _, name := range []string{"one", "two"} { + if w := post(t, srv.Handler(), `{"name":"`+name+`","description":"d","status_checks":["x"]}`, allowed); w.Code != http.StatusAccepted { + t.Fatalf("post %s = %d", name, w.Code) + } + now = now.Add(time.Minute) + } + r := httptest.NewRequest(http.MethodGet, "/api/requests", nil) + r.Header.Set("X-Forwarded-Groups", allowed) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, r) + + var res struct { + Requests []store.Request `json:"requests"` + } + if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil { + t.Fatalf("decode: %v", err) + } + if len(res.Requests) != 2 || res.Requests[0].Name != "two" { + t.Errorf("requests = %+v, want newest first", res.Requests) + } +} + +func TestGetUnknownRequest(t *testing.T) { + srv, _ := newTestServer(t, testCfg(t), &fakeCluster{}) + r := httptest.NewRequest(http.MethodGet, "/api/requests/nope", nil) + r.Header.Set("X-Forwarded-Groups", allowed) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, r) + if w.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", w.Code) + } +} + +func TestCapabilitiesReportsWoodpecker(t *testing.T) { + cfg := testCfg(t) + srv, _ := newTestServer(t, cfg, &fakeCluster{}) + read := func() bool { + r := httptest.NewRequest(http.MethodGet, "/api/capabilities", nil) + r.Header.Set("X-Forwarded-Groups", allowed) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, r) + var res struct { + Woodpecker bool `json:"woodpecker"` + } + if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil { + t.Fatalf("decode: %v", err) + } + return res.Woodpecker + } + if read() { + t.Error("woodpecker must be unavailable with no token file") + } + if err := os.WriteFile(cfg.WoodpeckerTokenFile, []byte("wp\n"), 0o600); err != nil { + t.Fatalf("write token: %v", err) + } + if !read() { + t.Error("woodpecker must become available once the token is mounted") + } +} + +// jobFor builds a finished Job with its pod, as the reconciler would observe it. +func jobFor(id string, t jobs.Type, anno map[string]string, succeeded bool, message string) (batchv1.Job, corev1.Pod) { + labels := map[string]string{jobs.LabelApp: jobs.AppName, jobs.LabelRequest: id, jobs.LabelType: string(t)} + job := batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{Name: jobs.Name(t, id), Labels: labels, Annotations: anno}, + } + if succeeded { + job.Status.Succeeded = 1 + } + pod := corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: jobs.Name(t, id) + "-xyz", Labels: labels}, + Status: corev1.PodStatus{ContainerStatuses: []corev1.ContainerStatus{{ + State: corev1.ContainerState{Terminated: &corev1.ContainerStateTerminated{Message: message}}, + }}}, + } + return job, pod +} + +func TestReconcileRebuildsStateFromJobs(t *testing.T) { + anno := map[string]string{ + jobs.AnnoName: "widget", + jobs.AnnoDescription: "does widgets", + jobs.AnnoWoodpecker: "false", + jobs.AnnoStatusChecks: "ci/woodpecker/pr/test", + jobs.AnnoCreated: "2026-08-30T01:02:03Z", + } + prJob, prPod := jobFor("abc123", jobs.TypePR, anno, + true, `{"pr_number":42,"pr_url":"https://git.unkin.net/unkin/terraform-git/pulls/42"}`) + cluster := &fakeCluster{jobs: []batchv1.Job{prJob}, pods: []corev1.Pod{prPod}} + + // A fresh store, as after a restart: everything comes from the cluster. + srv, st := newTestServer(t, testCfg(t), cluster) + if err := srv.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + + got, ok := st.Get("abc123") + if !ok { + t.Fatal("the request was not rebuilt from its job") + } + if got.Name != "widget" || got.Description != "does widgets" { + t.Errorf("rebuilt request = %+v", got) + } + if got.State != store.StatePROpen || got.PRNumber != 42 || got.PRURL == "" { + t.Errorf("state = %q pr = %d %q", got.State, got.PRNumber, got.PRURL) + } + if names := cluster.createdNames(); len(names) != 1 || names[0] != "repospawner-watch-abc123" { + t.Fatalf("created jobs = %v, want the watch job", names) + } + + // A second pass must not create the watch job again. + if err := srv.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + if names := cluster.createdNames(); len(names) != 1 { + t.Errorf("created jobs after a second reconcile = %v", names) + } +} + +func TestReconcileSkipsWoodpeckerWithoutToken(t *testing.T) { + anno := map[string]string{ + jobs.AnnoName: "widget", + jobs.AnnoWoodpecker: "true", + jobs.AnnoCreated: "2026-08-30T01:02:03Z", + jobs.AnnoPullRequest: "https://git.unkin.net/unkin/terraform-git/pulls/42", + jobs.AnnoPullRequestNo: "42", + } + watchJob, watchPod := jobFor("abc123", jobs.TypeWatch, anno, true, `{"merged":true}`) + cluster := &fakeCluster{jobs: []batchv1.Job{watchJob}, pods: []corev1.Pod{watchPod}} + srv, st := newTestServer(t, testCfg(t), cluster) + + if err := srv.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + got, _ := st.Get("abc123") + if got.State != store.StateEnablingCI { + t.Errorf("state = %q, want enabling-ci", got.State) + } + if len(cluster.createdNames()) != 0 { + t.Errorf("no woodpecker job may be created without a token: %v", cluster.createdNames()) + } +} + +func TestTerminationMessagesPrefersSuccessfulAttempt(t *testing.T) { + labels := map[string]string{jobs.LabelRequest: "abc123", jobs.LabelType: string(jobs.TypePR)} + pods := []corev1.Pod{{ + ObjectMeta: metav1.ObjectMeta{Labels: labels}, + Status: corev1.PodStatus{ContainerStatuses: []corev1.ContainerStatus{{ + LastTerminationState: corev1.ContainerState{ + Terminated: &corev1.ContainerStateTerminated{ExitCode: 1, Message: `{"error":"first try"}`}, + }, + State: corev1.ContainerState{ + Terminated: &corev1.ContainerStateTerminated{ExitCode: 0, Message: `{"pr_number":7}`}, + }, + }}}, + }} + got := terminationMessages(pods) + if string(got[resultKey{"abc123", jobs.TypePR}]) != `{"pr_number":7}` { + t.Errorf("message = %q", got[resultKey{"abc123", jobs.TypePR}]) + } +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..e23b3f3 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,167 @@ +// Package store holds the in-flight repo requests. State lives in memory and is +// rebuilt from the request Jobs on startup, so the deployment must stay at one +// replica with a Recreate strategy. +package store + +import ( + "crypto/rand" + "encoding/hex" + "reflect" + "sort" + "sync" + "time" + + "git.unkin.net/unkin/repospawner/internal/repospec" +) + +// State is where a request has reached. +type State string + +const ( + // StateOpeningPR means the PR job is running. + StateOpeningPR State = "opening-pr" + // StatePROpen means the PR exists and the watch job is following it. + StatePROpen State = "pr-open" + // StateMerged means the terraform-git PR merged. + StateMerged State = "merged" + // StateEnablingCI means the Woodpecker enablement job is running. + StateEnablingCI State = "enabling-ci" + // StateReady means everything the request asked for is done. + StateReady State = "ready" + // StateClosed means the PR was closed without merging. + StateClosed State = "closed" + // StateFailed means a job failed; Error carries why. + StateFailed State = "failed" +) + +// Terminal reports whether a state will not change again. +func (s State) Terminal() bool { + return s == StateReady || s == StateClosed || s == StateFailed +} + +// Request is one tracked new-repo request. +type Request struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Woodpecker bool `json:"woodpecker"` + StatusChecks []string `json:"status_checks"` + State State `json:"state"` + PRNumber int `json:"pr_number,omitempty"` + PRURL string `json:"pr_url,omitempty"` + CIEnabled bool `json:"ci_enabled"` + Error string `json:"error,omitempty"` + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` +} + +// Store is a concurrency-safe map of requests keyed by id. +type Store struct { + mu sync.RWMutex + byID map[string]Request + now func() time.Time +} + +// New builds an empty Store. +func New() *Store { + return &Store{byID: map[string]Request{}, now: time.Now} +} + +// SetClock replaces the timestamp source; tests use it for stable output. +func (s *Store) SetClock(now func() time.Time) { + s.mu.Lock() + defer s.mu.Unlock() + s.now = now +} + +// NewID returns a short random request id, unique enough to name a Job. +func NewID() string { + var b [6]byte + if _, err := rand.Read(b[:]); err != nil { + // crypto/rand failing is unrecoverable; a timestamp id keeps the + // caller's request identifiable rather than crashing the server. + return hex.EncodeToString([]byte(time.Now().UTC().Format("150405"))) + } + return hex.EncodeToString(b[:]) +} + +// NewRequest builds an unstored request in StateOpeningPR. The caller stores it +// only once the PR Job actually exists, so a failed create leaves no orphan. +func NewRequest(id string, spec repospec.Request, now time.Time) Request { + return Request{ + ID: id, + Name: spec.Name, + Description: spec.Description, + Woodpecker: spec.Woodpecker, + StatusChecks: spec.StatusChecks, + State: StateOpeningPR, + Created: now, + Updated: now, + } +} + +// Now returns the store's clock, so callers stamp requests consistently. +func (s *Store) Now() time.Time { + s.mu.RLock() + defer s.mu.RUnlock() + return s.now() +} + +// Put stores r verbatim, stamping Updated when anything actually changed. +func (s *Store) Put(r Request) { + s.mu.Lock() + defer s.mu.Unlock() + if prev, ok := s.byID[r.ID]; ok { + r.Created = prev.Created + if equalIgnoringUpdated(prev, r) { + return + } + } else if r.Created.IsZero() { + r.Created = s.now() + } + r.Updated = s.now() + s.byID[r.ID] = r +} + +// Get returns a request by id. +func (s *Store) Get(id string) (Request, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + r, ok := s.byID[id] + return r, ok +} + +// List returns every request, most recently created first. +func (s *Store) List() []Request { + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]Request, 0, len(s.byID)) + for _, r := range s.byID { + out = append(out, r) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Created.Equal(out[j].Created) { + return out[i].ID > out[j].ID + } + return out[i].Created.After(out[j].Created) + }) + return out +} + +// HasActiveName reports whether a non-terminal request already claims name. +// Two in-flight requests for the same name would race on the same branch. +func (s *Store) HasActiveName(name string) bool { + s.mu.RLock() + defer s.mu.RUnlock() + for _, r := range s.byID { + if r.Name == name && !r.State.Terminal() { + return true + } + } + return false +} + +func equalIgnoringUpdated(a, b Request) bool { + a.Updated, b.Updated = time.Time{}, time.Time{} + return reflect.DeepEqual(a, b) +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..fbe5ddd --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,111 @@ +package store + +import ( + "sync" + "testing" + "time" + + "git.unkin.net/unkin/repospawner/internal/repospec" +) + +func fixedClock(base time.Time) (*Store, func(time.Duration)) { + s := New() + now := base + s.SetClock(func() time.Time { return now }) + return s, func(d time.Duration) { now = now.Add(d) } +} + +func TestListNewestFirst(t *testing.T) { + s, advance := fixedClock(time.Date(2026, 8, 30, 0, 0, 0, 0, time.UTC)) + for _, name := range []string{"first", "second", "third"} { + s.Put(NewRequest(name, repospec.Request{Name: name}, s.Now())) + advance(time.Minute) + } + got := s.List() + if len(got) != 3 { + t.Fatalf("List returned %d requests", len(got)) + } + for i, want := range []string{"third", "second", "first"} { + if got[i].Name != want { + t.Errorf("List()[%d] = %q, want %q", i, got[i].Name, want) + } + } +} + +func TestPutOnlyStampsUpdatedOnChange(t *testing.T) { + s, advance := fixedClock(time.Date(2026, 8, 30, 0, 0, 0, 0, time.UTC)) + r := NewRequest("id", repospec.Request{Name: "widget", StatusChecks: []string{"a"}}, s.Now()) + s.Put(r) + stored, _ := s.Get("id") + + advance(time.Hour) + s.Put(r) + again, _ := s.Get("id") + if !again.Updated.Equal(stored.Updated) { + t.Errorf("Updated moved on an unchanged Put: %v -> %v", stored.Updated, again.Updated) + } + + r.State = StatePROpen + s.Put(r) + changed, _ := s.Get("id") + if !changed.Updated.After(stored.Updated) { + t.Errorf("Updated did not move on a real change: %v", changed.Updated) + } + if !changed.Created.Equal(stored.Created) { + t.Errorf("Created must not move: %v -> %v", stored.Created, changed.Created) + } +} + +func TestHasActiveName(t *testing.T) { + s := New() + r := NewRequest("id", repospec.Request{Name: "widget"}, time.Now()) + s.Put(r) + if !s.HasActiveName("widget") { + t.Error("an in-flight request must claim its name") + } + if s.HasActiveName("other") { + t.Error("an unrelated name must be free") + } + + for _, state := range []State{StateReady, StateClosed, StateFailed} { + r.State = state + s.Put(r) + if s.HasActiveName("widget") { + t.Errorf("state %q is terminal and must release the name", state) + } + } +} + +func TestNewIDIsUnique(t *testing.T) { + seen := map[string]bool{} + for i := 0; i < 1000; i++ { + id := NewID() + if id == "" { + t.Fatal("NewID returned an empty id") + } + if seen[id] { + t.Fatalf("NewID repeated %q", id) + } + seen[id] = true + } +} + +func TestConcurrentAccess(t *testing.T) { + s := New() + var wg sync.WaitGroup + for i := 0; i < 32; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + id := NewID() + s.Put(NewRequest(id, repospec.Request{Name: "widget"}, time.Now())) + s.Get(id) + s.List() + s.HasActiveName("widget") + }(i) + } + wg.Wait() + if len(s.List()) != 32 { + t.Errorf("List returned %d requests, want 32", len(s.List())) + } +} diff --git a/internal/vaultauth/vaultauth.go b/internal/vaultauth/vaultauth.go new file mode 100644 index 0000000..ed09f30 --- /dev/null +++ b/internal/vaultauth/vaultauth.go @@ -0,0 +1,180 @@ +// Package vaultauth mints short-lived Gitea credentials from Vault using the +// pod's projected kubernetes service account token. +// +// repospawner deliberately talks to Vault natively rather than shelling out to +// agentpr: the AppRole path agentpr uses is CIDR-bound to hosts outside the +// cluster, so an in-cluster login must go through the kubernetes auth mount. +package vaultauth + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "sync" + "time" +) + +// Creds is a dynamic Gitea credential. Token is secret and must never be +// logged; String deliberately redacts it. +type Creds struct { + Username string + Token string +} + +func (c Creds) String() string { return "vaultauth.Creds{Username:" + c.Username + ", Token:REDACTED}" } + +// Client reads credentials from Vault. It holds no long-lived state: every +// Creds call performs a fresh login, so nothing outlives the operation that +// needed it. +type Client struct { + Addr string + Mount string + Role string + TokenPath string + HTTP *http.Client +} + +// New builds a Client with a bounded HTTP client. +func New(addr, mount, role, tokenPath string) *Client { + return &Client{ + Addr: strings.TrimSuffix(addr, "/"), + Mount: strings.Trim(mount, "/"), + Role: role, + TokenPath: tokenPath, + HTTP: &http.Client{Timeout: 20 * time.Second}, + } +} + +// Login exchanges the projected service account token for a Vault token. +func (c *Client) Login(ctx context.Context) (string, error) { + jwt, err := os.ReadFile(c.TokenPath) + if err != nil { + return "", fmt.Errorf("read service account token: %w", err) + } + body, err := json.Marshal(map[string]string{ + "role": c.Role, + "jwt": strings.TrimSpace(string(jwt)), + }) + if err != nil { + return "", err + } + var out struct { + Auth struct { + ClientToken string `json:"client_token"` + } `json:"auth"` + } + url := c.Addr + "/v1/auth/" + c.Mount + "/login" + if err := c.do(ctx, http.MethodPost, url, "", bytes.NewReader(body), &out); err != nil { + return "", fmt.Errorf("vault kubernetes login: %w", err) + } + if out.Auth.ClientToken == "" { + return "", fmt.Errorf("vault kubernetes login: empty client token") + } + return out.Auth.ClientToken, nil +} + +// Creds logs in and reads a dynamic Gitea credential from path. +func (c *Client) Creds(ctx context.Context, path string) (Creds, error) { + token, err := c.Login(ctx) + if err != nil { + return Creds{}, err + } + var out struct { + Data struct { + Username string `json:"username"` + Token string `json:"token"` + } `json:"data"` + } + url := c.Addr + "/v1/" + strings.TrimPrefix(path, "/") + if err := c.do(ctx, http.MethodGet, url, token, nil, &out); err != nil { + return Creds{}, fmt.Errorf("read gitea credential: %w", err) + } + if out.Data.Token == "" { + return Creds{}, fmt.Errorf("read gitea credential: response carried no token") + } + return Creds{Username: out.Data.Username, Token: out.Data.Token}, nil +} + +// do issues a request and decodes a JSON body, mapping any non-2xx to an error +// that names the status but never echoes a token back. +func (c *Client) do(ctx context.Context, method, url, token string, body io.Reader, out any) error { + req, err := http.NewRequestWithContext(ctx, method, url, body) + if err != nil { + return err + } + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if token != "" { + req.Header.Set("X-Vault-Token", token) + } + resp, err := c.client().Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return fmt.Errorf("vault %s %s: status %d", method, redactPath(url), resp.StatusCode) + } + return json.NewDecoder(resp.Body).Decode(out) +} + +func (c *Client) client() *http.Client { + if c.HTTP != nil { + return c.HTTP + } + return http.DefaultClient +} + +// redactPath keeps the path for debugging but drops any query string, which is +// where a stray wrapped token would otherwise appear. +func redactPath(url string) string { + if i := strings.IndexByte(url, '?'); i >= 0 { + return url[:i] + } + return url +} + +// TokenSource hands out Gitea tokens, caching the last mint until a caller asks +// for a fresh one. Gitea tokens from the dynamic engine live about an hour, so +// long-running watch jobs re-mint on the first 401 rather than on a timer. +type TokenSource struct { + client *Client + path string + + mu sync.Mutex + cached Creds +} + +// NewTokenSource builds a TokenSource over c reading path. +func NewTokenSource(c *Client, path string) *TokenSource { + return &TokenSource{client: c, path: path} +} + +// Token returns a Gitea token. When force is true the cached value is discarded +// and a new credential is minted. +func (t *TokenSource) Token(ctx context.Context, force bool) (string, error) { + t.mu.Lock() + defer t.mu.Unlock() + if !force && t.cached.Token != "" { + return t.cached.Token, nil + } + creds, err := t.client.Creds(ctx, t.path) + if err != nil { + return "", err + } + t.cached = creds + return creds.Token, nil +} + +// StaticTokenSource is a TokenFunc over a fixed token, for tests and for the +// server's read-only lookups when a credential has already been minted. +func StaticTokenSource(token string) func(context.Context, bool) (string, error) { + return func(context.Context, bool) (string, error) { return token, nil } +} diff --git a/internal/vaultauth/vaultauth_test.go b/internal/vaultauth/vaultauth_test.go new file mode 100644 index 0000000..a327bd9 --- /dev/null +++ b/internal/vaultauth/vaultauth_test.go @@ -0,0 +1,165 @@ +package vaultauth + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" +) + +func writeSAToken(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write token: %v", err) + } + return path +} + +func TestLogin(t *testing.T) { + var gotPath string + var gotBody map[string]string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + raw, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(raw, &gotBody) + _, _ = w.Write([]byte(`{"auth":{"client_token":"s.vaulttoken"}}`)) + })) + defer srv.Close() + + c := New(srv.URL, "k8s/au/syd1", "repospawner", writeSAToken(t, " jwt-value\n")) + tok, err := c.Login(context.Background()) + if err != nil { + t.Fatalf("Login: %v", err) + } + if tok != "s.vaulttoken" { + t.Errorf("token = %q", tok) + } + if gotPath != "/v1/auth/k8s/au/syd1/login" { + t.Errorf("path = %q", gotPath) + } + if gotBody["role"] != "repospawner" || gotBody["jwt"] != "jwt-value" { + t.Errorf("body = %v (the projected token must be trimmed)", gotBody) + } +} + +func TestLoginRejectsEmptyToken(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"auth":{}}`)) + })) + defer srv.Close() + + c := New(srv.URL, "k8s/au/syd1", "repospawner", writeSAToken(t, "jwt")) + if _, err := c.Login(context.Background()); err == nil { + t.Fatal("expected an error when vault returns no client token") + } +} + +func TestLoginSurfacesStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer srv.Close() + + c := New(srv.URL, "k8s/au/syd1", "repospawner", writeSAToken(t, "jwt")) + _, err := c.Login(context.Background()) + if err == nil || !strings.Contains(err.Error(), "403") { + t.Fatalf("error = %v, want one naming status 403", err) + } +} + +func TestCredsUsesVaultToken(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/login"): + _, _ = w.Write([]byte(`{"auth":{"client_token":"s.vaulttoken"}}`)) + case r.URL.Path == "/v1/gitea/creds/repospawner": + if got := r.Header.Get("X-Vault-Token"); got != "s.vaulttoken" { + t.Errorf("X-Vault-Token = %q", got) + } + _, _ = w.Write([]byte(`{"data":{"username":"repospawner-abc","token":"gitea-token"}}`)) + default: + t.Errorf("unexpected path %s", r.URL.Path) + } + })) + defer srv.Close() + + c := New(srv.URL, "k8s/au/syd1", "repospawner", writeSAToken(t, "jwt")) + creds, err := c.Creds(context.Background(), "gitea/creds/repospawner") + if err != nil { + t.Fatalf("Creds: %v", err) + } + if creds.Token != "gitea-token" || creds.Username != "repospawner-abc" { + t.Errorf("creds = %+v", creds) + } + if strings.Contains(creds.String(), "gitea-token") { + t.Errorf("Creds.String() leaked the token: %s", creds.String()) + } +} + +func TestCredsRejectsEmptyToken(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/login") { + _, _ = w.Write([]byte(`{"auth":{"client_token":"s.t"}}`)) + return + } + _, _ = w.Write([]byte(`{"data":{"username":"u"}}`)) + })) + defer srv.Close() + + c := New(srv.URL, "k8s/au/syd1", "repospawner", writeSAToken(t, "jwt")) + if _, err := c.Creds(context.Background(), "gitea/creds/repospawner"); err == nil { + t.Fatal("expected an error when the credential carries no token") + } +} + +func TestTokenSourceCachesUntilForced(t *testing.T) { + var reads atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/login") { + _, _ = w.Write([]byte(`{"auth":{"client_token":"s.t"}}`)) + return + } + n := reads.Add(1) + _, _ = w.Write([]byte(`{"data":{"username":"u","token":"tok` + string(rune('0'+n)) + `"}}`)) + })) + defer srv.Close() + + src := NewTokenSource(New(srv.URL, "k8s/au/syd1", "repospawner", writeSAToken(t, "jwt")), "gitea/creds/repospawner") + ctx := context.Background() + + first, err := src.Token(ctx, false) + if err != nil { + t.Fatalf("Token: %v", err) + } + cached, err := src.Token(ctx, false) + if err != nil { + t.Fatalf("Token: %v", err) + } + if cached != first { + t.Errorf("cached token %q != first %q", cached, first) + } + forced, err := src.Token(ctx, true) + if err != nil { + t.Fatalf("Token: %v", err) + } + if forced == first { + t.Errorf("forced mint returned the cached token %q", forced) + } + if reads.Load() != 2 { + t.Errorf("credential reads = %d, want 2", reads.Load()) + } +} + +func TestLoginMissingTokenFile(t *testing.T) { + c := New("https://vault.invalid", "k8s/au/syd1", "repospawner", filepath.Join(t.TempDir(), "absent")) + if _, err := c.Login(context.Background()); err == nil { + t.Fatal("expected an error when the projected token is missing") + } +} diff --git a/internal/woodpecker/woodpecker.go b/internal/woodpecker/woodpecker.go new file mode 100644 index 0000000..164f36c --- /dev/null +++ b/internal/woodpecker/woodpecker.go @@ -0,0 +1,102 @@ +// Package woodpecker activates a repository in Woodpecker CI by its forge +// remote id. +package woodpecker + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" +) + +// Client talks to one Woodpecker server. +type Client struct { + BaseURL string + Token string + HTTP *http.Client +} + +// New builds a Client with a bounded HTTP client. +func New(baseURL, token string) *Client { + return &Client{ + BaseURL: strings.TrimSuffix(baseURL, "/"), + Token: token, + HTTP: &http.Client{Timeout: 30 * time.Second}, + } +} + +// Repo is the subset of a Woodpecker repository record that matters here. +type Repo struct { + ID int64 `json:"id"` + FullName string `json:"full_name"` + Active bool `json:"active"` +} + +// Enable activates the repository identified by its forge remote id. An +// already-active repository answers 409, which counts as success. +func (c *Client) Enable(ctx context.Context, forgeRemoteID int64) (Repo, error) { + var out Repo + path := "/api/repos?forge_remote_id=" + strconv.FormatInt(forgeRemoteID, 10) + err := c.do(ctx, http.MethodPost, path, &out) + var se *StatusError + if errors.As(err, &se) && se.Status == http.StatusConflict { + return out, nil + } + return out, err +} + +// Lookup fetches a repository by "owner/name", used to confirm activation. +func (c *Client) Lookup(ctx context.Context, fullName string) (Repo, error) { + var out Repo + err := c.do(ctx, http.MethodGet, "/api/repos/lookup/"+fullName, &out) + return out, err +} + +// StatusError carries a non-2xx response. +type StatusError struct { + Status int + Path string + Body string +} + +func (e *StatusError) Error() string { + msg := fmt.Sprintf("woodpecker %s: status %d", e.Path, e.Status) + if e.Body != "" { + msg += ": " + e.Body + } + return msg +} + +func (c *Client) do(ctx context.Context, method, path string, out any) error { + req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, nil) + if err != nil { + return err + } + req.Header.Set("Accept", "application/json") + if c.Token != "" { + req.Header.Set("Authorization", "Bearer "+c.Token) + } + client := c.HTTP + if client == nil { + client = http.DefaultClient + } + resp, err := client.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode < 200 || resp.StatusCode > 299 { + snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return &StatusError{Status: resp.StatusCode, Path: path, Body: strings.TrimSpace(string(snippet))} + } + if out == nil { + _, _ = io.Copy(io.Discard, resp.Body) + return nil + } + return json.NewDecoder(resp.Body).Decode(out) +} diff --git a/internal/woodpecker/woodpecker_test.go b/internal/woodpecker/woodpecker_test.go new file mode 100644 index 0000000..d098dda --- /dev/null +++ b/internal/woodpecker/woodpecker_test.go @@ -0,0 +1,74 @@ +package woodpecker + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestEnable(t *testing.T) { + var gotQuery, gotAuth, gotMethod string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotQuery, gotAuth, gotMethod = r.URL.RawQuery, r.Header.Get("Authorization"), r.Method + _, _ = w.Write([]byte(`{"id":5,"full_name":"unkin/widget","active":true}`)) + })) + defer srv.Close() + + repo, err := New(srv.URL, "wp-token").Enable(context.Background(), 91) + if err != nil { + t.Fatalf("Enable: %v", err) + } + if repo.ID != 5 || !repo.Active { + t.Errorf("repo = %+v", repo) + } + if gotMethod != http.MethodPost || gotQuery != "forge_remote_id=91" { + t.Errorf("%s ?%s", gotMethod, gotQuery) + } + if gotAuth != "Bearer wp-token" { + t.Errorf("Authorization = %q", gotAuth) + } +} + +func TestEnableTreatsConflictAsSuccess(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"message":"repository is already active"}`)) + })) + defer srv.Close() + + if _, err := New(srv.URL, "wp-token").Enable(context.Background(), 91); err != nil { + t.Fatalf("an already-active repository must not be an error, got %v", err) + } +} + +func TestEnableSurfacesUnauthorized(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + + _, err := New(srv.URL, "bad").Enable(context.Background(), 91) + if err == nil || !strings.Contains(err.Error(), "401") { + t.Fatalf("error = %v, want one naming status 401", err) + } +} + +func TestLookup(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/repos/lookup/unkin/widget" { + t.Errorf("path = %q", r.URL.Path) + } + _, _ = w.Write([]byte(`{"id":5,"full_name":"unkin/widget","active":true}`)) + })) + defer srv.Close() + + repo, err := New(srv.URL, "wp-token").Lookup(context.Background(), "unkin/widget") + if err != nil { + t.Fatalf("Lookup: %v", err) + } + if !repo.Active || repo.FullName != "unkin/widget" { + t.Errorf("repo = %+v", repo) + } +} diff --git a/ui/embed.go b/ui/embed.go new file mode 100644 index 0000000..da7badb --- /dev/null +++ b/ui/embed.go @@ -0,0 +1,19 @@ +// Package ui embeds the static repospawner assets. +package ui + +import ( + "embed" + "io/fs" +) + +//go:embed static +var embedded embed.FS + +// Assets returns the static asset filesystem rooted at the asset directory. +func Assets() fs.FS { + sub, err := fs.Sub(embedded, "static") + if err != nil { + panic(err) + } + return sub +} diff --git a/ui/embed_test.go b/ui/embed_test.go new file mode 100644 index 0000000..4aa423f --- /dev/null +++ b/ui/embed_test.go @@ -0,0 +1,34 @@ +package ui + +import ( + "io/fs" + "strings" + "testing" +) + +// TestAssetsShipEverythingThePageNeeds guards the embed: a missing asset would +// only surface as a broken page at runtime, since the CSP forbids CDNs. +func TestAssetsShipEverythingThePageNeeds(t *testing.T) { + assets := Assets() + for _, name := range []string{"index.html", "app.css", "app.js"} { + if _, err := fs.Stat(assets, name); err != nil { + t.Errorf("asset %s missing: %v", name, err) + } + } +} + +func TestIndexReferencesLocalAssetsOnly(t *testing.T) { + b, err := fs.ReadFile(Assets(), "index.html") + if err != nil { + t.Fatalf("read index.html: %v", err) + } + html := string(b) + if strings.Contains(html, "//cdn.") || strings.Contains(html, "https://") { + t.Error("index.html references an external origin; the CSP blocks those") + } + for _, want := range []string{`href="app.css"`, `src="app.js"`, `id="new-form"`, `id="rows"`} { + if !strings.Contains(html, want) { + t.Errorf("index.html is missing %s", want) + } + } +} diff --git a/ui/static/app.css b/ui/static/app.css new file mode 100644 index 0000000..e64867e --- /dev/null +++ b/ui/static/app.css @@ -0,0 +1,126 @@ +/* repospawner UI — Bootstrap-3-shaped layout with material elevation, in a + neutral slate palette. Fully self-contained: no CDNs, no webfonts. */ + +:root { + --bg: #eef1f5; + --surface: #ffffff; + --ink: #24292f; + --muted: #6b7280; + --line: #d7dde5; + --slate: #33475b; + --slate-dk: #24344a; + --accent: #2b6cb0; + --accent-dk: #22548a; + --ok: #2f7a4d; + --warn: #8a6116; + --danger: #a33226; + --shadow: 0 1px 3px rgba(16,24,40,.12), 0 6px 18px rgba(16,24,40,.08); + --radius: 6px; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + color: var(--ink); + background: var(--bg); + min-height: 100vh; +} + +.container { max-width: 960px; margin: 0 auto; padding: 0 16px; } + +/* ---- navbar ---- */ +.navbar { + background: linear-gradient(180deg, var(--slate) 0%, var(--slate-dk) 100%); + box-shadow: var(--shadow); +} +.navbar-inner { display: flex; align-items: baseline; gap: 12px; padding: 14px 16px; flex-wrap: wrap; } +.brand { color: #fff; font-size: 1.4rem; font-weight: 700; letter-spacing: .3px; } +.brand-tag { color: #c8d2de; font-size: .92rem; } + +/* ---- panels ---- */ +.panel { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: var(--shadow); + margin: 24px 0; +} +.panel-heading { + display: flex; align-items: baseline; justify-content: space-between; gap: 12px; + padding: 12px 20px; + border-bottom: 1px solid var(--line); + background: #f7f9fb; + border-radius: var(--radius) var(--radius) 0 0; +} +.panel-heading h2 { margin: 0; font-size: 1.1rem; } +.panel-body { padding: 20px; } + +/* ---- forms ---- */ +.form-group { margin-bottom: 18px; } +label { display: block; font-weight: 600; margin-bottom: 6px; } +label.check { font-weight: 400; display: inline-flex; align-items: center; gap: 8px; } +.form-control { + width: 100%; padding: 9px 12px; + border: 1px solid var(--line); border-radius: var(--radius); + background: #fff; font-size: 1rem; color: var(--ink); + font-family: inherit; +} +textarea.form-control { font-family: "SF Mono", Menlo, Consolas, monospace; font-size: .9rem; } +.form-control:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(43,108,176,.18); } +.help { margin: 6px 0 0; font-size: .85rem; color: var(--muted); } +.field-error { margin: 6px 0 0; font-size: .85rem; color: var(--danger); font-weight: 600; } + +/* ---- buttons ---- */ +.btn { + display: inline-block; border: none; border-radius: var(--radius); + padding: 10px 18px; font-size: 1rem; font-weight: 600; cursor: pointer; + box-shadow: 0 1px 2px rgba(16,24,40,.2); + transition: background .12s ease, transform .08s ease; + text-decoration: none; +} +.btn:active { transform: translateY(1px); } +.btn-primary { background: var(--accent); color: #fff; } +.btn-primary:hover { background: var(--accent-dk); } +.btn:disabled { opacity: .55; cursor: not-allowed; } + +/* ---- table ---- */ +.table-wrap { overflow-x: auto; } +.table { width: 100%; border-collapse: collapse; font-size: .95rem; } +.table th, .table td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--line); vertical-align: top; } +.table th { font-size: .78rem; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); } +.table tr:last-child td { border-bottom: none; } +.name { font-weight: 700; } +.desc { color: var(--muted); font-size: .85rem; } + +/* ---- badges ---- */ +.badge { + display: inline-block; font-size: .74rem; font-weight: 700; + padding: 3px 9px; border-radius: 10px; white-space: nowrap; + background: #e6eaf0; color: var(--slate-dk); +} +.badge-run { background: #dbeafe; color: #1e40af; } +.badge-ok { background: #d9f0e2; color: var(--ok); } +.badge-warn { background: #fbf0d3; color: var(--warn); } +.badge-err { background: #f8dcd8; color: var(--danger); } + +a { color: var(--accent); } +.muted { color: var(--muted); font-size: .85rem; } +.hidden { display: none !important; } +code { font-family: "SF Mono", Menlo, Consolas, monospace; font-size: .9em; } + +/* ---- toast ---- */ +.toast { + position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); + background: var(--slate-dk); color: #fff; + padding: 12px 20px; border-radius: var(--radius); box-shadow: var(--shadow); + border-left: 4px solid var(--accent); z-index: 50; max-width: 90vw; +} +.toast.err { border-left-color: var(--danger); } + +@media (max-width: 600px) { + .container { padding: 0 10px; } + .panel-body { padding: 14px; } + .table th, .table td { padding: 8px; } +} diff --git a/ui/static/app.js b/ui/static/app.js new file mode 100644 index 0000000..0c4d653 --- /dev/null +++ b/ui/static/app.js @@ -0,0 +1,187 @@ +/* repospawner UI — request table plus the submission form. No build step, no + CDNs; everything the page needs ships in the binary. */ +(function () { + "use strict"; + + var REFRESH_MS = 10000; + var FIELDS = ["name", "description", "status_checks"]; + var BADGES = { + "opening-pr": "badge-run", + "pr-open": "badge-run", + "merged": "badge-run", + "enabling-ci": "badge-run", + "ready": "badge-ok", + "closed": "badge-warn", + "failed": "badge-err" + }; + + var el = function (id) { return document.getElementById(id); }; + var toastTimer = null; + + function toast(msg, isErr) { + var t = el("toast"); + t.textContent = msg; + t.className = "toast" + (isErr ? " err" : ""); + if (toastTimer) { clearTimeout(toastTimer); } + toastTimer = setTimeout(function () { t.className = "toast hidden"; }, 6000); + } + + function clearFieldErrors() { + FIELDS.forEach(function (f) { + var e = el("e-" + f); + e.textContent = ""; + e.className = "field-error hidden"; + }); + } + + function showFieldErrors(fields) { + Object.keys(fields || {}).forEach(function (f) { + var e = el("e-" + f); + if (!e) { return; } + e.textContent = fields[f]; + e.className = "field-error"; + }); + } + + function age(iso) { + var secs = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000); + if (secs < 60) { return Math.floor(secs) + "s"; } + if (secs < 3600) { return Math.floor(secs / 60) + "m"; } + if (secs < 86400) { return Math.floor(secs / 3600) + "h"; } + return Math.floor(secs / 86400) + "d"; + } + + function cell(row, text, cls) { + var td = document.createElement("td"); + if (cls) { td.className = cls; } + if (text !== undefined && text !== null) { td.textContent = text; } + row.appendChild(td); + return td; + } + + function render(requests) { + var body = el("rows"); + body.textContent = ""; + el("empty").className = requests.length ? "muted hidden" : "muted"; + + requests.forEach(function (r) { + var tr = document.createElement("tr"); + + var nameCell = cell(tr, null); + var name = document.createElement("div"); + name.className = "name"; + name.textContent = r.name; + nameCell.appendChild(name); + if (r.description) { + var desc = document.createElement("div"); + desc.className = "desc"; + desc.textContent = r.description; + nameCell.appendChild(desc); + } + + var stateCell = cell(tr, null); + var badge = document.createElement("span"); + badge.className = "badge " + (BADGES[r.state] || ""); + badge.textContent = r.state; + stateCell.appendChild(badge); + if (r.error) { + var err = document.createElement("div"); + err.className = "desc"; + err.textContent = r.error; + stateCell.appendChild(err); + } + + var prCell = cell(tr, null); + if (r.pr_url) { + var a = document.createElement("a"); + a.href = r.pr_url; + a.target = "_blank"; + a.rel = "noreferrer noopener"; + a.textContent = "#" + r.pr_number; + prCell.appendChild(a); + } else { + prCell.textContent = "—"; + prCell.className = "muted"; + } + + if (!r.woodpecker) { + cell(tr, "not requested", "muted"); + } else { + cell(tr, r.ci_enabled ? "enabled" : "pending", r.ci_enabled ? "" : "muted"); + } + + cell(tr, age(r.created), "muted"); + body.appendChild(tr); + }); + el("refreshed").textContent = "updated " + new Date().toLocaleTimeString(); + } + + function refresh() { + return fetch("/api/requests", { headers: { Accept: "application/json" } }) + .then(function (resp) { + if (!resp.ok) { throw new Error("list failed: " + resp.status); } + return resp.json(); + }) + .then(function (data) { render(data.requests || []); }) + .catch(function (err) { toast(err.message, true); }); + } + + function capabilities() { + return fetch("/api/capabilities", { headers: { Accept: "application/json" } }) + .then(function (resp) { return resp.ok ? resp.json() : { woodpecker: false }; }) + .then(function (caps) { + if (!caps.woodpecker) { + el("f-woodpecker").checked = false; + el("f-woodpecker").disabled = true; + el("wp-unavailable").className = "help"; + } + }) + .catch(function () { /* the checkbox stays enabled; the API still refuses */ }); + } + + function submit(ev) { + ev.preventDefault(); + clearFieldErrors(); + var btn = el("submit"); + btn.disabled = true; + + var payload = { + name: el("f-name").value.trim(), + description: el("f-description").value.trim(), + woodpecker: el("f-woodpecker").checked, + status_checks: el("f-checks").value.split("\n").map(function (s) { return s.trim(); }) + .filter(function (s) { return s.length > 0; }) + }; + + fetch("/api/requests", { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify(payload) + }) + .then(function (resp) { + return resp.json().catch(function () { return {}; }).then(function (data) { + return { ok: resp.ok, status: resp.status, data: data }; + }); + }) + .then(function (res) { + if (res.ok) { + toast("Request " + res.data.id + " accepted; the pull request URL appears here shortly."); + el("f-name").value = ""; + el("f-description").value = ""; + return refresh(); + } + showFieldErrors(res.data.fields); + toast(res.data.error || ("request rejected: " + res.status), true); + return null; + }) + .catch(function (err) { toast(err.message, true); }) + .then(function () { btn.disabled = false; }); + } + + document.addEventListener("DOMContentLoaded", function () { + el("new-form").addEventListener("submit", submit); + capabilities(); + refresh(); + setInterval(refresh, REFRESH_MS); + }); +})(); diff --git a/ui/static/index.html b/ui/static/index.html new file mode 100644 index 0000000..66e68d7 --- /dev/null +++ b/ui/static/index.html @@ -0,0 +1,82 @@ + + + + + + repospawner + + + + + + +
+
+

New repository

+
+
+
+ + +

Lowercase letters, digits and dashes.

+ +
+ +
+ + + +
+ +
+ + +

One context per line; these gate merges into main.

+ +
+ +
+ + +
+ + +
+
+
+ +
+
+

Requests

+ +
+
+
+ + + + + + + +
NameStatePull requestWoodpeckerAge
+
+ +
+
+
+ + + + + From 8786636f7c45654bdd9bba48020ad1b8ebceada0 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sun, 30 Aug 2026 14:53:27 +1000 Subject: [PATCH 2/2] 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. --- README.md | 13 ++- cmd/repospawner/main.go | 35 ++++++-- cmd/repospawner/main_test.go | 56 +++++++++++++ internal/jobs/jobs.go | 6 +- internal/jobs/jobs_test.go | 2 +- internal/jobs/state.go | 5 ++ internal/repospec/repospec.go | 37 +++++++-- internal/repospec/repospec_test.go | 48 +++++++++++ internal/server/reconcile.go | 40 ++++++++- internal/server/server.go | 67 ++++++++++++++-- internal/server/server_test.go | 125 +++++++++++++++++++++++++++-- ui/static/index.html | 4 +- 12 files changed, 404 insertions(+), 34 deletions(-) create mode 100644 cmd/repospawner/main_test.go diff --git a/README.md b/README.md index e4d06ae..3285480 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ $ 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 or by an in-flight request); `503` if `woodpecker:true` but no Woodpecker token is mounted. | +| `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. | @@ -116,7 +116,16 @@ $ curl -sS https://repospawner.k8s.syd1.au.unkin.net/api/requests `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. +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 diff --git a/cmd/repospawner/main.go b/cmd/repospawner/main.go index b82ba30..945180c 100644 --- a/cmd/repospawner/main.go +++ b/cmd/repospawner/main.go @@ -32,7 +32,7 @@ const usage = `repospawner - open terraform-git pull requests for new repositori usage: repospawner [serve] run the API and UI repospawner job pr --request ID --name NAME \ - --description TEXT --checks A,B open the terraform-git PR + --description TEXT --check A [--check B] open the terraform-git PR repospawner job watch --repo OWNER/NAME --pr N follow that PR to its end repospawner job woodpecker-enable --name NAME activate the repo in CI repospawner version print the version @@ -139,21 +139,42 @@ func runJob(ctx context.Context, log *slog.Logger, cfg *config.Config, args []st } } -func runPRJob(ctx context.Context, log *slog.Logger, cfg *config.Config, args []string) error { +// stringList collects a flag given once per value, so a value may hold any +// character without a separator changing what was meant. +type stringList []string + +func (l *stringList) String() string { return strings.Join(*l, " ") } + +func (l *stringList) Set(v string) error { + *l = append(*l, v) + return nil +} + +// parsePRArgs turns the argv the server built into the pr job's inputs. +func parsePRArgs(args []string) (jobrun.PROptions, error) { fs := flag.NewFlagSet("job pr", flag.ContinueOnError) requestID := fs.String("request", "", "request id this job serves") name := fs.String("name", "", "repository name") description := fs.String("description", "", "repository description") - checks := fs.String("checks", "", "comma-separated required status check contexts") + var checks stringList + fs.Var(&checks, "check", "a required status check context; repeat once per context") if err := fs.Parse(args); err != nil { - return err + return jobrun.PROptions{}, err } - res, err := jobrun.PR(ctx, log, cfg, jobrun.PROptions{ + return jobrun.PROptions{ RequestID: *requestID, Name: *name, Description: *description, - StatusChecks: strings.Split(*checks, ","), - }) + StatusChecks: checks, + }, nil +} + +func runPRJob(ctx context.Context, log *slog.Logger, cfg *config.Config, args []string) error { + opts, err := parsePRArgs(args) + if err != nil { + return err + } + res, err := jobrun.PR(ctx, log, cfg, opts) jobrun.Report(log, jobrun.ReportPath(), res) return err } diff --git a/cmd/repospawner/main_test.go b/cmd/repospawner/main_test.go new file mode 100644 index 0000000..8dab827 --- /dev/null +++ b/cmd/repospawner/main_test.go @@ -0,0 +1,56 @@ +package main + +import ( + "slices" + "testing" + + "git.unkin.net/unkin/repospawner/internal/config" + "git.unkin.net/unkin/repospawner/internal/jobs" + "git.unkin.net/unkin/repospawner/internal/store" +) + +// TestPRArgsRoundTrip pins the server-to-job hop: whatever the server put in a +// request must come back out of the Job's argv unchanged, one context per +// context, whatever characters a context happens to hold. +func TestPRArgsRoundTrip(t *testing.T) { + cases := [][]string{ + {"ci/woodpecker/pr/build"}, + {"ci/woodpecker/pr/build", "ci/woodpecker/pr/test", "ci/woodpecker/pr/pre-commit"}, + {"ci/build, with a comma", "plain"}, + {"has spaces and --dashes"}, + } + cfg := &config.Config{Namespace: "repospawner", Image: "repospawner:test", VaultSATokenPath: "/var/run/secrets/vault/token"} + + for _, checks := range cases { + req := store.Request{ + ID: "abc123", + Name: "widget", + Description: "does widgets, comprehensively", + StatusChecks: checks, + } + argv := jobs.PR(cfg, req).Spec.Template.Spec.Containers[0].Args + if len(argv) < 2 || argv[0] != "job" || argv[1] != "pr" { + t.Fatalf("argv = %q", argv) + } + opts, err := parsePRArgs(argv[2:]) + if err != nil { + t.Fatalf("parsePRArgs(%q): %v", argv, err) + } + if opts.RequestID != req.ID || opts.Name != req.Name || opts.Description != req.Description { + t.Errorf("opts = %+v, want the request's fields %+v", opts, req) + } + if !slices.Equal(opts.StatusChecks, checks) { + t.Errorf("StatusChecks = %q, want %q", opts.StatusChecks, checks) + } + } +} + +func TestParsePRArgsWithoutChecks(t *testing.T) { + opts, err := parsePRArgs([]string{"--request", "abc", "--name", "widget", "--description", "d"}) + if err != nil { + t.Fatalf("parsePRArgs: %v", err) + } + if len(opts.StatusChecks) != 0 { + t.Errorf("StatusChecks = %q, want none", opts.StatusChecks) + } +} diff --git a/internal/jobs/jobs.go b/internal/jobs/jobs.go index 8e05bc2..3af6bd4 100644 --- a/internal/jobs/jobs.go +++ b/internal/jobs/jobs.go @@ -87,7 +87,11 @@ func PR(cfg *config.Config, r store.Request) *batchv1.Job { "--request", r.ID, "--name", r.Name, "--description", r.Description, - "--checks", strings.Join(r.StatusChecks, ","), + } + // One flag per context: a separator inside a check would otherwise turn one + // context into several on the way back out. + for _, c := range r.StatusChecks { + args = append(args, "--check", c) } return base(cfg, r, TypePR, args, shortDeadline) } diff --git a/internal/jobs/jobs_test.go b/internal/jobs/jobs_test.go index f716df3..10f5379 100644 --- a/internal/jobs/jobs_test.go +++ b/internal/jobs/jobs_test.go @@ -64,7 +64,7 @@ func TestPRJobSpec(t *testing.T) { } args := strings.Join(pod.Containers[0].Args, " ") want := "job pr --request abc123 --name widget --description does widgets " + - "--checks ci/woodpecker/pr/build,ci/woodpecker/pr/test" + "--check ci/woodpecker/pr/build --check ci/woodpecker/pr/test" if args != want { t.Errorf("args = %q, want %q", args, want) } diff --git a/internal/jobs/state.go b/internal/jobs/state.go index 057f407..a8bcb5d 100644 --- a/internal/jobs/state.go +++ b/internal/jobs/state.go @@ -20,6 +20,11 @@ const ( // Job that should be created next. It is deliberately pure: the reconciler // supplies the observations and performs the action. func Advance(r store.Request, views map[Type]View) (store.Request, Action) { + // A request that already ended badly stays ended: later observations of the + // jobs that got it there must not walk it back out of a terminal state. + if r.State == store.StateFailed || r.State == store.StateClosed { + return r, ActionNone + } if pr, ok := views[TypePR]; ok { r = applyPR(r, pr) } diff --git a/internal/repospec/repospec.go b/internal/repospec/repospec.go index f1c3777..b98a052 100644 --- a/internal/repospec/repospec.go +++ b/internal/repospec/repospec.go @@ -20,6 +20,16 @@ var nameRE = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`) // character limit k8s applies to object names. const maxNameLen = 40 +const ( + // maxDescriptionLen bounds the description, which becomes a YAML scalar and + // a Job annotation. + maxDescriptionLen = 500 + // maxCheckLen bounds a single status check context. + maxCheckLen = 100 + // maxChecks bounds how many contexts one branch protection rule carries. + maxChecks = 20 +) + // Request is a submitted new-repo request. type Request struct { Name string `json:"name"` @@ -75,16 +85,29 @@ func (r Request) Validate() error { case !nameRE.MatchString(r.Name): errs["name"] = "must be lowercase letters, digits and dashes, starting and ending alphanumeric" } - if r.Description == "" { + switch { + case r.Description == "": errs["description"] = "required" + case len(r.Description) > maxDescriptionLen: + errs["description"] = fmt.Sprintf("must be at most %d characters", maxDescriptionLen) } - if len(r.StatusChecks) == 0 { + switch { + case len(r.StatusChecks) == 0: errs["status_checks"] = "at least one status check context is required" - } - for _, c := range r.StatusChecks { - if strings.ContainsAny(c, "\n\"") { - errs["status_checks"] = "must not contain quotes or newlines" - break + case len(r.StatusChecks) > maxChecks: + errs["status_checks"] = fmt.Sprintf("at most %d status check contexts are allowed", maxChecks) + default: + for _, c := range r.StatusChecks { + // A comma would split into two contexts on the server-to-job hop, and + // a real context never contains one. + if strings.ContainsAny(c, "\n\",") { + errs["status_checks"] = "must not contain quotes, commas or newlines" + break + } + if len(c) > maxCheckLen { + errs["status_checks"] = fmt.Sprintf("each context must be at most %d characters", maxCheckLen) + break + } } } if len(errs) == 0 { diff --git a/internal/repospec/repospec_test.go b/internal/repospec/repospec_test.go index a3c951b..3dfd174 100644 --- a/internal/repospec/repospec_test.go +++ b/internal/repospec/repospec_test.go @@ -2,6 +2,7 @@ package repospec import ( "errors" + "strconv" "strings" "testing" ) @@ -26,6 +27,45 @@ func TestValidate(t *testing.T) { {name: "over long", req: with(base, func(r *Request) { r.Name = strings.Repeat("a", maxNameLen+1) }), fields: []string{"name"}, wantErr: true}, {name: "missing description", req: with(base, func(r *Request) { r.Description = "" }), fields: []string{"description"}, wantErr: true}, {name: "no checks", req: with(base, func(r *Request) { r.StatusChecks = nil }), fields: []string{"status_checks"}, wantErr: true}, + { + name: "over long description", + req: with(base, func(r *Request) { r.Description = strings.Repeat("d", maxDescriptionLen+1) }), + fields: []string{"description"}, + wantErr: true, + }, + { + name: "description at the cap", + req: with(base, func(r *Request) { r.Description = strings.Repeat("d", maxDescriptionLen) }), + }, + { + name: "check containing a comma", + req: with(base, func(r *Request) { r.StatusChecks = []string{"ci/woodpecker/pr/test,ci/woodpecker/pr/build"} }), + fields: []string{"status_checks"}, + wantErr: true, + }, + { + name: "check containing a quote", + req: with(base, func(r *Request) { r.StatusChecks = []string{`ci/"test"`} }), + fields: []string{"status_checks"}, + wantErr: true, + }, + { + name: "over long check", + req: with(base, func(r *Request) { r.StatusChecks = []string{strings.Repeat("c", maxCheckLen+1)} }), + fields: []string{"status_checks"}, + wantErr: true, + }, + { + name: "check at the cap", + req: with(base, func(r *Request) { r.StatusChecks = []string{strings.Repeat("c", maxCheckLen)} }), + }, + { + name: "too many checks", + req: with(base, func(r *Request) { r.StatusChecks = manyChecks(maxChecks + 1) }), + fields: []string{"status_checks"}, + wantErr: true, + }, + {name: "checks at the cap", req: with(base, func(r *Request) { r.StatusChecks = manyChecks(maxChecks) })}, { name: "every field bad at once", req: Request{}, @@ -144,3 +184,11 @@ func with(r Request, f func(*Request)) Request { f(&r) return r } + +func manyChecks(n int) []string { + out := make([]string, 0, n) + for i := range n { + out = append(out, "ci/woodpecker/pr/check"+strconv.Itoa(i)) + } + return out +} diff --git a/internal/server/reconcile.go b/internal/server/reconcile.go index 4f94f82..a6c27fe 100644 --- a/internal/server/reconcile.go +++ b/internal/server/reconcile.go @@ -14,6 +14,16 @@ import ( // UI polls on the same cadence, so a change surfaces within two ticks. const reconcileInterval = 10 * time.Second +// maxWoodpeckerTokenWaits bounds how many reconcile passes a merged request +// waits for a Woodpecker token that vanished after the request was accepted. +// Past it the request fails with the reason, rather than sitting in +// enabling-ci forever with the trouble visible only in the server's logs. +const maxWoodpeckerTokenWaits = 30 + +// woodpeckerTokenUnavailable is the error a request carries when the token +// never came back. +const woodpeckerTokenUnavailable = "woodpecker token unavailable" + // Run reconciles until ctx is cancelled, starting with an immediate pass so a // restarted server rebuilds its state before serving its first request. func (s *Server) Run(ctx context.Context) { @@ -94,9 +104,10 @@ func (s *Server) act(ctx context.Context, r store.Request, action jobs.Action) e return s.cluster.CreateJob(ctx, jobs.Watch(s.cfg, r)) case jobs.ActionCreateWoodpecker: if !s.woodpeckerAvailable() { - s.log.Warn("woodpecker enablement requested but no token is mounted", "request", r.ID) + s.awaitWoodpeckerToken(r) return nil } + s.forgetWoodpeckerWait(r.ID) s.log.Info("enabling repository in woodpecker", "request", r.ID, "name", r.Name) return s.cluster.CreateJob(ctx, jobs.Woodpecker(s.cfg, r)) case jobs.ActionNone: @@ -106,6 +117,33 @@ func (s *Server) act(ctx context.Context, r store.Request, action jobs.Action) e } } +// awaitWoodpeckerToken counts a pass spent waiting for a token that was there +// when the request was accepted, and fails the request once the wait is over. +func (s *Server) awaitWoodpeckerToken(r store.Request) { + s.waitMu.Lock() + s.woodpeckerWaits[r.ID]++ + waits := s.woodpeckerWaits[r.ID] + s.waitMu.Unlock() + + if waits < maxWoodpeckerTokenWaits { + s.log.Warn("woodpecker enablement requested but no token is mounted", + "request", r.ID, "name", r.Name, "waits", waits) + return + } + s.log.Error("failing request: woodpecker token never became available", + "request", r.ID, "name", r.Name, "waits", waits) + r.State = store.StateFailed + r.Error = woodpeckerTokenUnavailable + s.store.Put(r) + s.forgetWoodpeckerWait(r.ID) +} + +func (s *Server) forgetWoodpeckerWait(id string) { + s.waitMu.Lock() + defer s.waitMu.Unlock() + delete(s.woodpeckerWaits, id) +} + type resultKey struct { request string jobType jobs.Type diff --git a/internal/server/server.go b/internal/server/server.go index 42ae529..3d0c09c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -13,6 +13,7 @@ import ( "net/http" "os" "strings" + "sync" "time" "git.unkin.net/unkin/repospawner/internal/auth" @@ -35,6 +36,15 @@ type Server struct { gate *auth.Middleware assets fs.FS log *slog.Logger + + // names serialises the name claim so two concurrent submissions of one name + // cannot both pass the duplicate checks. + names keyedMutex + + waitMu sync.Mutex + // woodpeckerWaits counts, per request id, the reconcile passes spent + // waiting for a Woodpecker token that vanished after acceptance. + woodpeckerWaits map[string]int } // New constructs a Server. @@ -43,13 +53,51 @@ func New(cfg *config.Config, st *store.Store, forge *gitea.Client, cluster Clust log = slog.Default() } return &Server{ - cfg: cfg, - store: st, - forge: forge, - cluster: cluster, - gate: auth.New(cfg.GroupsHeader, cfg.AllowedGroups), - assets: assets, - log: log, + cfg: cfg, + store: st, + forge: forge, + cluster: cluster, + gate: auth.New(cfg.GroupsHeader, cfg.AllowedGroups), + assets: assets, + log: log, + woodpeckerWaits: map[string]int{}, + } +} + +// keyedMutex serialises work per key and forgets a key once nothing holds it. +type keyedMutex struct { + mu sync.Mutex + held map[string]*keyedEntry +} + +type keyedEntry struct { + mu sync.Mutex + refs int +} + +// lock blocks until key is free and returns the function that releases it. +func (k *keyedMutex) lock(key string) func() { + k.mu.Lock() + if k.held == nil { + k.held = map[string]*keyedEntry{} + } + e, ok := k.held[key] + if !ok { + e = &keyedEntry{} + k.held[key] = e + } + e.refs++ + k.mu.Unlock() + + e.mu.Lock() + return func() { + e.mu.Unlock() + k.mu.Lock() + defer k.mu.Unlock() + e.refs-- + if e.refs == 0 { + delete(k.held, key) + } } } @@ -142,6 +190,11 @@ func (s *Server) handleCreate(w http.ResponseWriter, r *http.Request) { "woodpecker enablement is unavailable: no woodpecker API token is mounted; resubmit with woodpecker disabled") return } + // Everything from here to the store write claims the name; holding it per + // name keeps two concurrent submissions from both finding it free. + unlock := s.names.lock(spec.Name) + defer unlock() + if s.store.HasActiveName(spec.Name) { writeErr(w, http.StatusConflict, "a request for that repository name is already in flight") return diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 2b83c23..2607ae2 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -485,7 +485,9 @@ func TestReconcileRebuildsStateFromJobs(t *testing.T) { } } -func TestReconcileSkipsWoodpeckerWithoutToken(t *testing.T) { +// mergedWoodpeckerCluster holds a request whose terraform-git PR merged and +// which asked for Woodpecker enablement. +func mergedWoodpeckerCluster() *fakeCluster { anno := map[string]string{ jobs.AnnoName: "widget", jobs.AnnoWoodpecker: "true", @@ -494,18 +496,129 @@ func TestReconcileSkipsWoodpeckerWithoutToken(t *testing.T) { jobs.AnnoPullRequestNo: "42", } watchJob, watchPod := jobFor("abc123", jobs.TypeWatch, anno, true, `{"merged":true}`) - cluster := &fakeCluster{jobs: []batchv1.Job{watchJob}, pods: []corev1.Pod{watchPod}} + return &fakeCluster{jobs: []batchv1.Job{watchJob}, pods: []corev1.Pod{watchPod}} +} + +func TestReconcileWaitsForWoodpeckerToken(t *testing.T) { + cluster := mergedWoodpeckerCluster() srv, st := newTestServer(t, testCfg(t), cluster) + // Short of the bound the request keeps waiting; the token may still arrive. + for i := range maxWoodpeckerTokenWaits - 1 { + if err := srv.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile %d: %v", i, err) + } + got, _ := st.Get("abc123") + if got.State != store.StateEnablingCI { + t.Fatalf("state after %d passes = %q, want enabling-ci", i+1, got.State) + } + } + if len(cluster.createdNames()) != 0 { + t.Errorf("no woodpecker job may be created without a token: %v", cluster.createdNames()) + } +} + +func TestReconcileFailsRequestWhenWoodpeckerTokenNeverArrives(t *testing.T) { + cluster := mergedWoodpeckerCluster() + srv, st := newTestServer(t, testCfg(t), cluster) + + for i := range maxWoodpeckerTokenWaits { + if err := srv.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile %d: %v", i, err) + } + } + got, _ := st.Get("abc123") + if got.State != store.StateFailed { + t.Fatalf("state = %q, want failed", got.State) + } + if got.Error != woodpeckerTokenUnavailable { + t.Errorf("error = %q, want %q", got.Error, woodpeckerTokenUnavailable) + } + if len(cluster.createdNames()) != 0 { + t.Errorf("no woodpecker job may be created without a token: %v", cluster.createdNames()) + } + + // The failure sticks across later passes, and the API surfaces the reason. + if err := srv.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + r := httptest.NewRequest(http.MethodGet, "/api/requests/abc123", nil) + r.Header.Set("X-Forwarded-Groups", allowed) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, r) + if w.Code != http.StatusOK { + t.Fatalf("GET = %d", w.Code) + } + var served store.Request + if err := json.Unmarshal(w.Body.Bytes(), &served); err != nil { + t.Fatalf("decode: %v", err) + } + if served.State != store.StateFailed || served.Error != woodpeckerTokenUnavailable { + t.Errorf("served = %+v, want a failed request naming the missing token", served) + } +} + +func TestReconcileEnablesWoodpeckerOnceTheTokenReturns(t *testing.T) { + cfg := testCfg(t) + cluster := mergedWoodpeckerCluster() + srv, st := newTestServer(t, cfg, cluster) + if err := srv.Reconcile(context.Background()); err != nil { t.Fatalf("Reconcile: %v", err) } - got, _ := st.Get("abc123") - if got.State != store.StateEnablingCI { + if err := os.WriteFile(cfg.WoodpeckerTokenFile, []byte("wp\n"), 0o600); err != nil { + t.Fatalf("write token: %v", err) + } + if err := srv.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + if names := cluster.createdNames(); len(names) != 1 || names[0] != "repospawner-woodpecker-abc123" { + t.Fatalf("created jobs = %v, want the woodpecker job", names) + } + if got, _ := st.Get("abc123"); got.State != store.StateEnablingCI { t.Errorf("state = %q, want enabling-ci", got.State) } - if len(cluster.createdNames()) != 0 { - t.Errorf("no woodpecker job may be created without a token: %v", cluster.createdNames()) +} + +func TestCreateSerialisesConcurrentSubmissionsOfOneName(t *testing.T) { + srv, st := newTestServer(t, testCfg(t), &fakeCluster{}) + h := srv.Handler() + body := `{"name":"widget","description":"d","status_checks":["x"]}` + + const submissions = 8 + codes := make([]int, submissions) + start := make(chan struct{}) + var wg sync.WaitGroup + for i := range submissions { + wg.Add(1) + go func() { + defer wg.Done() + <-start + r := httptest.NewRequest(http.MethodPost, "/api/requests", strings.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + r.Header.Set("X-Forwarded-Groups", allowed) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + codes[i] = w.Code + }() + } + close(start) + wg.Wait() + + accepted, conflicted := 0, 0 + for _, c := range codes { + switch c { + case http.StatusAccepted: + accepted++ + case http.StatusConflict: + conflicted++ + } + } + if accepted != 1 || conflicted != submissions-1 { + t.Fatalf("codes = %v, want exactly one 202 and %d 409s", codes, submissions-1) + } + if got := len(st.List()); got != 1 { + t.Errorf("stored requests = %d, want 1", got) } } diff --git a/ui/static/index.html b/ui/static/index.html index 66e68d7..ae9f51b 100644 --- a/ui/static/index.html +++ b/ui/static/index.html @@ -23,7 +23,7 @@
+ spellcheck="false" maxlength="40" placeholder="my-service">

Lowercase letters, digits and dashes.

@@ -31,7 +31,7 @@
+ maxlength="500" placeholder="What the repository is for">