Add the initial repospawner service #1
@@ -0,0 +1,2 @@
|
||||
/dist/
|
||||
*.out
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -1,3 +1,215 @@
|
||||
# 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, by an in-flight request, or by a request submitted concurrently); `503` if `woodpecker:true` but no Woodpecker token is mounted. |
|
||||
| `GET /api/requests` | Every request, most recent first. |
|
||||
| `GET /api/requests/{id}` | One request: `state`, `pr_url`, `error`. |
|
||||
| `GET /api/capabilities` | Whether Woodpecker enablement is available. |
|
||||
| `GET /livez` | Always `ok` while the process is up. |
|
||||
| `GET /readyz` | `ok` when the Kubernetes API is reachable. |
|
||||
|
||||
### States
|
||||
|
||||
`opening-pr` -> `pr-open` -> `merged` -> (`enabling-ci` ->) `ready`, with
|
||||
`closed` (PR closed unmerged) and `failed` (a Job failed; `error` says why) as
|
||||
the other terminal states. A request stuck in `enabling-ci` because the
|
||||
Woodpecker token was unmounted after it was accepted fails with
|
||||
`woodpecker token unavailable` after five minutes of waiting, rather than
|
||||
waiting forever.
|
||||
|
||||
### Limits
|
||||
|
||||
`name` is at most 40 characters of `[a-z0-9-]`, `description` at most 500, and a
|
||||
request carries at most 20 status check contexts of at most 100 characters each.
|
||||
A context may not contain a quote, a newline or a comma.
|
||||
|
||||
### Generated config
|
||||
|
||||
Only the description and the status check contexts come from the request;
|
||||
everything else is fixed estate policy:
|
||||
|
||||
```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`.
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
// 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 --check A [--check B] open the terraform-git PR
|
||||
repospawner job watch --repo OWNER/NAME --pr N follow that PR to its end
|
||||
repospawner job woodpecker-enable --name NAME activate the repo in CI
|
||||
repospawner version print the version
|
||||
`
|
||||
|
||||
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])
|
||||
}
|
||||
}
|
||||
|
||||
// stringList collects a flag given once per value, so a value may hold any
|
||||
// character without a separator changing what was meant.
|
||||
type stringList []string
|
||||
|
||||
func (l *stringList) String() string { return strings.Join(*l, " ") }
|
||||
|
||||
func (l *stringList) Set(v string) error {
|
||||
*l = append(*l, v)
|
||||
return nil
|
||||
}
|
||||
|
||||
// parsePRArgs turns the argv the server built into the pr job's inputs.
|
||||
func parsePRArgs(args []string) (jobrun.PROptions, error) {
|
||||
fs := flag.NewFlagSet("job pr", flag.ContinueOnError)
|
||||
requestID := fs.String("request", "", "request id this job serves")
|
||||
name := fs.String("name", "", "repository name")
|
||||
description := fs.String("description", "", "repository description")
|
||||
var checks stringList
|
||||
fs.Var(&checks, "check", "a required status check context; repeat once per context")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return jobrun.PROptions{}, err
|
||||
}
|
||||
return jobrun.PROptions{
|
||||
RequestID: *requestID,
|
||||
Name: *name,
|
||||
Description: *description,
|
||||
StatusChecks: checks,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func runPRJob(ctx context.Context, log *slog.Logger, cfg *config.Config, args []string) error {
|
||||
opts, err := parsePRArgs(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := jobrun.PR(ctx, log, cfg, opts)
|
||||
jobrun.Report(log, jobrun.ReportPath(), res)
|
||||
return err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"git.unkin.net/unkin/repospawner/internal/config"
|
||||
"git.unkin.net/unkin/repospawner/internal/jobs"
|
||||
"git.unkin.net/unkin/repospawner/internal/store"
|
||||
)
|
||||
|
||||
// TestPRArgsRoundTrip pins the server-to-job hop: whatever the server put in a
|
||||
// request must come back out of the Job's argv unchanged, one context per
|
||||
// context, whatever characters a context happens to hold.
|
||||
func TestPRArgsRoundTrip(t *testing.T) {
|
||||
cases := [][]string{
|
||||
{"ci/woodpecker/pr/build"},
|
||||
{"ci/woodpecker/pr/build", "ci/woodpecker/pr/test", "ci/woodpecker/pr/pre-commit"},
|
||||
{"ci/build, with a comma", "plain"},
|
||||
{"has spaces and --dashes"},
|
||||
}
|
||||
cfg := &config.Config{Namespace: "repospawner", Image: "repospawner:test", VaultSATokenPath: "/var/run/secrets/vault/token"}
|
||||
|
||||
for _, checks := range cases {
|
||||
req := store.Request{
|
||||
ID: "abc123",
|
||||
Name: "widget",
|
||||
Description: "does widgets, comprehensively",
|
||||
StatusChecks: checks,
|
||||
}
|
||||
argv := jobs.PR(cfg, req).Spec.Template.Spec.Containers[0].Args
|
||||
if len(argv) < 2 || argv[0] != "job" || argv[1] != "pr" {
|
||||
t.Fatalf("argv = %q", argv)
|
||||
}
|
||||
opts, err := parsePRArgs(argv[2:])
|
||||
if err != nil {
|
||||
t.Fatalf("parsePRArgs(%q): %v", argv, err)
|
||||
}
|
||||
if opts.RequestID != req.ID || opts.Name != req.Name || opts.Description != req.Description {
|
||||
t.Errorf("opts = %+v, want the request's fields %+v", opts, req)
|
||||
}
|
||||
if !slices.Equal(opts.StatusChecks, checks) {
|
||||
t.Errorf("StatusChecks = %q, want %q", opts.StatusChecks, checks)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePRArgsWithoutChecks(t *testing.T) {
|
||||
opts, err := parsePRArgs([]string{"--request", "abc", "--name", "widget", "--description", "d"})
|
||||
if err != nil {
|
||||
t.Fatalf("parsePRArgs: %v", err)
|
||||
}
|
||||
if len(opts.StatusChecks) != 0 {
|
||||
t.Errorf("StatusChecks = %q, want none", opts.StatusChecks)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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, "/")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
// 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,
|
||||
}
|
||||
// One flag per context: a separator inside a check would otherwise turn one
|
||||
// context into several on the way back out.
|
||||
for _, c := range r.StatusChecks {
|
||||
args = append(args, "--check", c)
|
||||
}
|
||||
return base(cfg, r, TypePR, args, shortDeadline)
|
||||
}
|
||||
|
||||
// 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 }
|
||||
@@ -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 " +
|
||||
"--check ci/woodpecker/pr/build --check 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
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) {
|
||||
// A request that already ended badly stays ended: later observations of the
|
||||
// jobs that got it there must not walk it back out of a terminal state.
|
||||
if r.State == store.StateFailed || r.State == store.StateClosed {
|
||||
return r, ActionNone
|
||||
}
|
||||
if pr, ok := views[TypePR]; ok {
|
||||
r = applyPR(r, pr)
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// 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
|
||||
|
||||
const (
|
||||
// maxDescriptionLen bounds the description, which becomes a YAML scalar and
|
||||
// a Job annotation.
|
||||
maxDescriptionLen = 500
|
||||
// maxCheckLen bounds a single status check context.
|
||||
maxCheckLen = 100
|
||||
// maxChecks bounds how many contexts one branch protection rule carries.
|
||||
maxChecks = 20
|
||||
)
|
||||
|
||||
// Request is a submitted new-repo request.
|
||||
type Request struct {
|
||||
Name string `json:"name"`
|
||||
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"
|
||||
}
|
||||
switch {
|
||||
case r.Description == "":
|
||||
errs["description"] = "required"
|
||||
case len(r.Description) > maxDescriptionLen:
|
||||
errs["description"] = fmt.Sprintf("must be at most %d characters", maxDescriptionLen)
|
||||
}
|
||||
switch {
|
||||
case len(r.StatusChecks) == 0:
|
||||
errs["status_checks"] = "at least one status check context is required"
|
||||
case len(r.StatusChecks) > maxChecks:
|
||||
errs["status_checks"] = fmt.Sprintf("at most %d status check contexts are allowed", maxChecks)
|
||||
default:
|
||||
for _, c := range r.StatusChecks {
|
||||
// A comma would split into two contexts on the server-to-job hop, and
|
||||
// a real context never contains one.
|
||||
if strings.ContainsAny(c, "\n\",") {
|
||||
errs["status_checks"] = "must not contain quotes, commas or newlines"
|
||||
break
|
||||
}
|
||||
if len(c) > maxCheckLen {
|
||||
errs["status_checks"] = fmt.Sprintf("each context must be at most %d characters", maxCheckLen)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(errs) == 0 {
|
||||
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 + `"`
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package repospec
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"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: "over long description",
|
||||
req: with(base, func(r *Request) { r.Description = strings.Repeat("d", maxDescriptionLen+1) }),
|
||||
fields: []string{"description"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "description at the cap",
|
||||
req: with(base, func(r *Request) { r.Description = strings.Repeat("d", maxDescriptionLen) }),
|
||||
},
|
||||
{
|
||||
name: "check containing a comma",
|
||||
req: with(base, func(r *Request) { r.StatusChecks = []string{"ci/woodpecker/pr/test,ci/woodpecker/pr/build"} }),
|
||||
fields: []string{"status_checks"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "check containing a quote",
|
||||
req: with(base, func(r *Request) { r.StatusChecks = []string{`ci/"test"`} }),
|
||||
fields: []string{"status_checks"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "over long check",
|
||||
req: with(base, func(r *Request) { r.StatusChecks = []string{strings.Repeat("c", maxCheckLen+1)} }),
|
||||
fields: []string{"status_checks"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "check at the cap",
|
||||
req: with(base, func(r *Request) { r.StatusChecks = []string{strings.Repeat("c", maxCheckLen)} }),
|
||||
},
|
||||
{
|
||||
name: "too many checks",
|
||||
req: with(base, func(r *Request) { r.StatusChecks = manyChecks(maxChecks + 1) }),
|
||||
fields: []string{"status_checks"},
|
||||
wantErr: true,
|
||||
},
|
||||
{name: "checks at the cap", req: with(base, func(r *Request) { r.StatusChecks = manyChecks(maxChecks) })},
|
||||
{
|
||||
name: "every field bad at once",
|
||||
req: Request{},
|
||||
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
|
||||
}
|
||||
|
||||
func manyChecks(n int) []string {
|
||||
out := make([]string, 0, n)
|
||||
for i := range n {
|
||||
out = append(out, "ci/woodpecker/pr/check"+strconv.Itoa(i))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
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
|
||||
|
||||
// maxWoodpeckerTokenWaits bounds how many reconcile passes a merged request
|
||||
// waits for a Woodpecker token that vanished after the request was accepted.
|
||||
// Past it the request fails with the reason, rather than sitting in
|
||||
// enabling-ci forever with the trouble visible only in the server's logs.
|
||||
const maxWoodpeckerTokenWaits = 30
|
||||
|
||||
// woodpeckerTokenUnavailable is the error a request carries when the token
|
||||
// never came back.
|
||||
const woodpeckerTokenUnavailable = "woodpecker token unavailable"
|
||||
|
||||
// Run reconciles until ctx is cancelled, starting with an immediate pass so a
|
||||
// restarted server rebuilds its state before serving its first request.
|
||||
func (s *Server) Run(ctx context.Context) {
|
||||
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.awaitWoodpeckerToken(r)
|
||||
return nil
|
||||
}
|
||||
s.forgetWoodpeckerWait(r.ID)
|
||||
s.log.Info("enabling repository in woodpecker", "request", r.ID, "name", r.Name)
|
||||
return s.cluster.CreateJob(ctx, jobs.Woodpecker(s.cfg, r))
|
||||
case jobs.ActionNone:
|
||||
return nil
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// awaitWoodpeckerToken counts a pass spent waiting for a token that was there
|
||||
// when the request was accepted, and fails the request once the wait is over.
|
||||
func (s *Server) awaitWoodpeckerToken(r store.Request) {
|
||||
s.waitMu.Lock()
|
||||
s.woodpeckerWaits[r.ID]++
|
||||
waits := s.woodpeckerWaits[r.ID]
|
||||
s.waitMu.Unlock()
|
||||
|
||||
if waits < maxWoodpeckerTokenWaits {
|
||||
s.log.Warn("woodpecker enablement requested but no token is mounted",
|
||||
"request", r.ID, "name", r.Name, "waits", waits)
|
||||
return
|
||||
}
|
||||
s.log.Error("failing request: woodpecker token never became available",
|
||||
"request", r.ID, "name", r.Name, "waits", waits)
|
||||
r.State = store.StateFailed
|
||||
r.Error = woodpeckerTokenUnavailable
|
||||
s.store.Put(r)
|
||||
s.forgetWoodpeckerWait(r.ID)
|
||||
}
|
||||
|
||||
func (s *Server) forgetWoodpeckerWait(id string) {
|
||||
s.waitMu.Lock()
|
||||
defer s.waitMu.Unlock()
|
||||
delete(s.woodpeckerWaits, id)
|
||||
}
|
||||
|
||||
type resultKey struct {
|
||||
request string
|
||||
jobType jobs.Type
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
// 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"
|
||||
"sync"
|
||||
"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
|
||||
|
||||
// names serialises the name claim so two concurrent submissions of one name
|
||||
// cannot both pass the duplicate checks.
|
||||
names keyedMutex
|
||||
|
||||
waitMu sync.Mutex
|
||||
// woodpeckerWaits counts, per request id, the reconcile passes spent
|
||||
// waiting for a Woodpecker token that vanished after acceptance.
|
||||
woodpeckerWaits map[string]int
|
||||
}
|
||||
|
||||
// New constructs a Server.
|
||||
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,
|
||||
woodpeckerWaits: map[string]int{},
|
||||
}
|
||||
}
|
||||
|
||||
// keyedMutex serialises work per key and forgets a key once nothing holds it.
|
||||
type keyedMutex struct {
|
||||
mu sync.Mutex
|
||||
held map[string]*keyedEntry
|
||||
}
|
||||
|
||||
type keyedEntry struct {
|
||||
mu sync.Mutex
|
||||
refs int
|
||||
}
|
||||
|
||||
// lock blocks until key is free and returns the function that releases it.
|
||||
func (k *keyedMutex) lock(key string) func() {
|
||||
k.mu.Lock()
|
||||
if k.held == nil {
|
||||
k.held = map[string]*keyedEntry{}
|
||||
}
|
||||
e, ok := k.held[key]
|
||||
if !ok {
|
||||
e = &keyedEntry{}
|
||||
k.held[key] = e
|
||||
}
|
||||
e.refs++
|
||||
k.mu.Unlock()
|
||||
|
||||
e.mu.Lock()
|
||||
return func() {
|
||||
e.mu.Unlock()
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
e.refs--
|
||||
if e.refs == 0 {
|
||||
delete(k.held, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
// Everything from here to the store write claims the name; holding it per
|
||||
// name keeps two concurrent submissions from both finding it free.
|
||||
unlock := s.names.lock(spec.Name)
|
||||
defer unlock()
|
||||
|
||||
if s.store.HasActiveName(spec.Name) {
|
||||
writeErr(w, http.StatusConflict, "a request for that repository name is already in flight")
|
||||
return
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// mergedWoodpeckerCluster holds a request whose terraform-git PR merged and
|
||||
// which asked for Woodpecker enablement.
|
||||
func mergedWoodpeckerCluster() *fakeCluster {
|
||||
anno := map[string]string{
|
||||
jobs.AnnoName: "widget",
|
||||
jobs.AnnoWoodpecker: "true",
|
||||
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}`)
|
||||
return &fakeCluster{jobs: []batchv1.Job{watchJob}, pods: []corev1.Pod{watchPod}}
|
||||
}
|
||||
|
||||
func TestReconcileWaitsForWoodpeckerToken(t *testing.T) {
|
||||
cluster := mergedWoodpeckerCluster()
|
||||
srv, st := newTestServer(t, testCfg(t), cluster)
|
||||
|
||||
// Short of the bound the request keeps waiting; the token may still arrive.
|
||||
for i := range maxWoodpeckerTokenWaits - 1 {
|
||||
if err := srv.Reconcile(context.Background()); err != nil {
|
||||
t.Fatalf("Reconcile %d: %v", i, err)
|
||||
}
|
||||
got, _ := st.Get("abc123")
|
||||
if got.State != store.StateEnablingCI {
|
||||
t.Fatalf("state after %d passes = %q, want enabling-ci", i+1, got.State)
|
||||
}
|
||||
}
|
||||
if len(cluster.createdNames()) != 0 {
|
||||
t.Errorf("no woodpecker job may be created without a token: %v", cluster.createdNames())
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileFailsRequestWhenWoodpeckerTokenNeverArrives(t *testing.T) {
|
||||
cluster := mergedWoodpeckerCluster()
|
||||
srv, st := newTestServer(t, testCfg(t), cluster)
|
||||
|
||||
for i := range maxWoodpeckerTokenWaits {
|
||||
if err := srv.Reconcile(context.Background()); err != nil {
|
||||
t.Fatalf("Reconcile %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
got, _ := st.Get("abc123")
|
||||
if got.State != store.StateFailed {
|
||||
t.Fatalf("state = %q, want failed", got.State)
|
||||
}
|
||||
if got.Error != woodpeckerTokenUnavailable {
|
||||
t.Errorf("error = %q, want %q", got.Error, woodpeckerTokenUnavailable)
|
||||
}
|
||||
if len(cluster.createdNames()) != 0 {
|
||||
t.Errorf("no woodpecker job may be created without a token: %v", cluster.createdNames())
|
||||
}
|
||||
|
||||
// The failure sticks across later passes, and the API surfaces the reason.
|
||||
if err := srv.Reconcile(context.Background()); err != nil {
|
||||
t.Fatalf("Reconcile: %v", err)
|
||||
}
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/requests/abc123", nil)
|
||||
r.Header.Set("X-Forwarded-Groups", allowed)
|
||||
w := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET = %d", w.Code)
|
||||
}
|
||||
var served store.Request
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &served); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if served.State != store.StateFailed || served.Error != woodpeckerTokenUnavailable {
|
||||
t.Errorf("served = %+v, want a failed request naming the missing token", served)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileEnablesWoodpeckerOnceTheTokenReturns(t *testing.T) {
|
||||
cfg := testCfg(t)
|
||||
cluster := mergedWoodpeckerCluster()
|
||||
srv, st := newTestServer(t, cfg, cluster)
|
||||
|
||||
if err := srv.Reconcile(context.Background()); err != nil {
|
||||
t.Fatalf("Reconcile: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(cfg.WoodpeckerTokenFile, []byte("wp\n"), 0o600); err != nil {
|
||||
t.Fatalf("write token: %v", err)
|
||||
}
|
||||
if err := srv.Reconcile(context.Background()); err != nil {
|
||||
t.Fatalf("Reconcile: %v", err)
|
||||
}
|
||||
if names := cluster.createdNames(); len(names) != 1 || names[0] != "repospawner-woodpecker-abc123" {
|
||||
t.Fatalf("created jobs = %v, want the woodpecker job", names)
|
||||
}
|
||||
if got, _ := st.Get("abc123"); got.State != store.StateEnablingCI {
|
||||
t.Errorf("state = %q, want enabling-ci", got.State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateSerialisesConcurrentSubmissionsOfOneName(t *testing.T) {
|
||||
srv, st := newTestServer(t, testCfg(t), &fakeCluster{})
|
||||
h := srv.Handler()
|
||||
body := `{"name":"widget","description":"d","status_checks":["x"]}`
|
||||
|
||||
const submissions = 8
|
||||
codes := make([]int, submissions)
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
for i := range submissions {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
r := httptest.NewRequest(http.MethodPost, "/api/requests", strings.NewReader(body))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
r.Header.Set("X-Forwarded-Groups", allowed)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
codes[i] = w.Code
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
accepted, conflicted := 0, 0
|
||||
for _, c := range codes {
|
||||
switch c {
|
||||
case http.StatusAccepted:
|
||||
accepted++
|
||||
case http.StatusConflict:
|
||||
conflicted++
|
||||
}
|
||||
}
|
||||
if accepted != 1 || conflicted != submissions-1 {
|
||||
t.Fatalf("codes = %v, want exactly one 202 and %d 409s", codes, submissions-1)
|
||||
}
|
||||
if got := len(st.List()); got != 1 {
|
||||
t.Errorf("stored requests = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
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}])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
})();
|
||||
@@ -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" maxlength="40" 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"
|
||||
maxlength="500" 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>
|
||||
Reference in New Issue
Block a user