Add the initial repospawner service
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful

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.
This commit is contained in:
2026-08-30 14:33:31 +10:00
parent c104212dda
commit f1bcb8cd3a
41 changed files with 5388 additions and 1 deletions
+2
View File
@@ -0,0 +1,2 @@
/dist/
*.out
+27
View File
@@ -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
+23
View File
@@ -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
+29
View File
@@ -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
+49
View File
@@ -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
+18
View File
@@ -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
+22
View File
@@ -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"]
+69
View File
@@ -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)
+204 -1
View File
@@ -1,3 +1,206 @@
# repospawner
API service that opens terraform-git PRs for new repo requests via kube Jobs; oauth2-proxy'd status UI
repospawner turns a JSON "I want a new repository" request into a terraform-git
pull request, follows that pull request to its merge, and optionally activates
the new repository in Woodpecker. New repositories in the estate are created by
Terraform from `config/git.unkin.net/unkin/repository/<name>.yaml`; repospawner
writes that file and opens the PR so the review gate stays exactly where it is.
It is one Go binary carrying its own UI. The server runs as a Deployment and
launches the work as Kubernetes Jobs running the *same image* with a different
argv, so there is never a second image to keep in step.
## Flow
```
browser / curl
|
| POST /api/requests {name, description, woodpecker, status_checks}
v
+-------------+ name in use? +----------------+
| repospawner |----------------->| terraform-git | GET contents/<name>.yaml
| server |<-----------------| (Gitea) | 404 = free, 200 = 409
+-------------+ +----------------+
| 202 {id, status_url}
|
| creates Job repospawner-pr-<id>
v
+-------------------------------------------------+
| job pr branch repospawner/<name> |
| write config/.../<name>.yaml |
| open the pull request |
| -> /dev/termination-log |
| {"pr_number":N,"pr_url":"..."} |
+-------------------------------------------------+
| server reads the termination message; state = pr-open
|
| creates Job repospawner-watch-<id>
v
+-------------------------------------------------+
| job watch poll the PR every 10s (7d deadline) |
| -> {"merged":true} / {"closed":true}|
+-------------------------------------------------+
| merged -> state = merged
|
| creates Job repospawner-woodpecker-<id> (only if woodpecker:true)
v
+-------------------------------------------------+
| job woodpecker-enable |
| GET gitea repo id -> POST /api/repos?forge_ |
| remote_id=<id> -> verify -> {"enabled":true} |
+-------------------------------------------------+
|
v
state = ready
```
Every Job is labelled `repospawner.unkin.net/request=<id>` and
`repospawner.unkin.net/type=pr|watch|woodpecker`, carries the request payload in
annotations, sets `ttlSecondsAfterFinished: 3600` and `backoffLimit: 2`.
Request state lives in memory and is **reconstructed on startup** from those Job
labels and annotations, so the Deployment must run **one replica with the
`Recreate` strategy**. A request whose Jobs have all aged out of the cluster is
gone from the list; the terraform-git PR it produced is not.
## API
Every route below the health probes is gated on the oauth2-proxy group header
(`REPOSPAWNER_GROUPS_HEADER` / `REPOSPAWNER_ALLOWED_GROUPS`) as well as by the
front door.
Submit a request:
```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`.
+193
View File
@@ -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
}
+49
View File
@@ -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
)
+156
View File
@@ -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=
+55
View File
@@ -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)
})
}
+71
View File
@@ -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)
}
}
+147
View File
@@ -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
}
+89
View File
@@ -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")
}
}
+207
View File
@@ -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, "/")
}
+184
View File
@@ -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)
}
}
+204
View File
@@ -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
}
+278
View File
@@ -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"
}
+287
View File
@@ -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 }
+222
View File
@@ -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)
}
}
+142
View File
@@ -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
}
+164
View File
@@ -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)
}
}
+133
View File
@@ -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 + `"`
}
+146
View File
@@ -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
}
+80
View File
@@ -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
}
+140
View File
@@ -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
}
+222
View File
@@ -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})
}
+529
View File
@@ -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("<html>repospawner</html>")},
"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}])
}
}
+167
View File
@@ -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)
}
+111
View File
@@ -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()))
}
}
+180
View File
@@ -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 }
}
+165
View File
@@ -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")
}
}
+102
View File
@@ -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)
}
+74
View File
@@ -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)
}
}
+19
View File
@@ -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
}
+34
View File
@@ -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)
}
}
}
+126
View File
@@ -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; }
}
+187
View File
@@ -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);
});
})();
+82
View File
@@ -0,0 +1,82 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>repospawner</title>
<link rel="stylesheet" href="app.css">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E%F0%9F%93%A6%3C/text%3E%3C/svg%3E">
</head>
<body>
<nav class="navbar">
<div class="container navbar-inner">
<span class="brand">repospawner</span>
<span class="brand-tag">new repositories, via terraform-git pull requests</span>
</div>
</nav>
<main class="container">
<section class="panel">
<div class="panel-heading"><h2>New repository</h2></div>
<div class="panel-body">
<form id="new-form" novalidate>
<div class="form-group">
<label for="f-name">Name</label>
<input class="form-control" id="f-name" name="name" autocomplete="off"
spellcheck="false" placeholder="my-service">
<p class="help">Lowercase letters, digits and dashes.</p>
<p class="field-error hidden" id="e-name"></p>
</div>
<div class="form-group">
<label for="f-description">Description</label>
<input class="form-control" id="f-description" name="description" autocomplete="off"
placeholder="What the repository is for">
<p class="field-error hidden" id="e-description"></p>
</div>
<div class="form-group">
<label for="f-checks">Required status checks</label>
<textarea class="form-control" id="f-checks" name="status_checks" rows="4"
spellcheck="false">ci/woodpecker/pr/build
ci/woodpecker/pr/test
ci/woodpecker/pr/pre-commit</textarea>
<p class="help">One context per line; these gate merges into <code>main</code>.</p>
<p class="field-error hidden" id="e-status_checks"></p>
</div>
<div class="form-group">
<label class="check"><input type="checkbox" id="f-woodpecker"> Enable in Woodpecker after the PR merges</label>
<p class="help hidden" id="wp-unavailable">No Woodpecker token is mounted, so enablement is unavailable.</p>
</div>
<button class="btn btn-primary" id="submit" type="submit">Request repository</button>
</form>
</div>
</section>
<section class="panel">
<div class="panel-heading">
<h2>Requests</h2>
<span class="muted" id="refreshed"></span>
</div>
<div class="panel-body">
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th>Name</th><th>State</th><th>Pull request</th><th>Woodpecker</th><th>Age</th>
</tr>
</thead>
<tbody id="rows"></tbody>
</table>
</div>
<p class="muted hidden" id="empty">No requests yet.</p>
</div>
</section>
</main>
<div id="toast" class="toast hidden"></div>
<script src="app.js"></script>
</body>
</html>