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

repospawner turns JSON new-repo requests into terraform-git pull requests
via kubernetes Jobs, follows those PRs to merge and optionally activates
the repository in Woodpecker.
This commit is contained in:
2026-08-30 14:33:31 +10:00
parent c104212dda
commit f1bcb8cd3a
41 changed files with 5388 additions and 1 deletions
+193
View File
@@ -0,0 +1,193 @@
// Command repospawner turns JSON new-repo requests into terraform-git pull
// requests. The same binary is both the API server and the Jobs it launches:
// "repospawner" serves, "repospawner job ..." performs one unit of work.
package main
import (
"context"
"errors"
"flag"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"git.unkin.net/unkin/repospawner/internal/config"
"git.unkin.net/unkin/repospawner/internal/gitea"
"git.unkin.net/unkin/repospawner/internal/jobrun"
"git.unkin.net/unkin/repospawner/internal/server"
"git.unkin.net/unkin/repospawner/internal/store"
"git.unkin.net/unkin/repospawner/internal/vaultauth"
"git.unkin.net/unkin/repospawner/ui"
)
var version = "dev"
const usage = `repospawner - open terraform-git pull requests for new repositories
usage:
repospawner [serve] run the API and UI
repospawner job pr --request ID --name NAME \
--description TEXT --checks A,B open the terraform-git PR
repospawner job watch --repo OWNER/NAME --pr N follow that PR to its end
repospawner job woodpecker-enable --name NAME activate the repo in CI
repospawner version print the version
`
func main() {
log := slog.New(slog.NewJSONHandler(os.Stdout, nil))
args := os.Args[1:]
if len(args) > 0 && (args[0] == "-h" || args[0] == "--help" || args[0] == "help") {
fmt.Print(usage)
return
}
if len(args) > 0 && args[0] == "version" {
fmt.Println(version)
return
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
cfg, err := config.Load()
if err != nil {
log.Error("config", "err", err)
os.Exit(1)
}
var runErr error
switch {
case len(args) == 0 || args[0] == "serve":
runErr = serve(ctx, log, cfg)
case args[0] == "job":
runErr = runJob(ctx, log, cfg, args[1:])
default:
fmt.Fprint(os.Stderr, usage)
os.Exit(2)
}
if runErr != nil {
log.Error("command failed", "command", strings.Join(args, " "), "err", runErr)
os.Exit(1)
}
}
func serve(ctx context.Context, log *slog.Logger, cfg *config.Config) error {
if cfg.Image == "" {
return errors.New("REPOSPAWNER_IMAGE must name this deployment's own image; the Jobs run it")
}
cluster, err := server.NewKubeCluster(cfg.Namespace)
if err != nil {
return fmt.Errorf("kubernetes client: %w", err)
}
vault := vaultauth.New(cfg.VaultAddr, cfg.VaultK8sMount, cfg.VaultK8sRole, cfg.VaultSATokenPath)
forge := gitea.New(cfg.GiteaURL, vaultauth.NewTokenSource(vault, cfg.GiteaCredsPath).Token)
srv := server.New(cfg, store.New(), forge, cluster, ui.Assets(), log)
go srv.Run(ctx)
httpSrv := &http.Server{
Addr: cfg.Listen,
Handler: srv.Handler(),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
errCh := make(chan error, 1)
go func() {
log.Info("repospawner listening",
"addr", cfg.Listen, "version", version,
"namespace", cfg.Namespace, "tfgitRepo", cfg.TFGitRepo,
"allowedGroups", cfg.AllowedGroups)
if err := httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}()
select {
case err := <-errCh:
return err
case <-ctx.Done():
}
log.Info("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
return httpSrv.Shutdown(shutdownCtx)
}
func runJob(ctx context.Context, log *slog.Logger, cfg *config.Config, args []string) error {
if len(args) == 0 {
fmt.Fprint(os.Stderr, usage)
return errors.New("job needs a subcommand")
}
switch args[0] {
case "pr":
return runPRJob(ctx, log, cfg, args[1:])
case "watch":
return runWatchJob(ctx, log, cfg, args[1:])
case "woodpecker-enable":
return runWoodpeckerJob(ctx, log, cfg, args[1:])
default:
fmt.Fprint(os.Stderr, usage)
return fmt.Errorf("unknown job %q", args[0])
}
}
func runPRJob(ctx context.Context, log *slog.Logger, cfg *config.Config, args []string) error {
fs := flag.NewFlagSet("job pr", flag.ContinueOnError)
requestID := fs.String("request", "", "request id this job serves")
name := fs.String("name", "", "repository name")
description := fs.String("description", "", "repository description")
checks := fs.String("checks", "", "comma-separated required status check contexts")
if err := fs.Parse(args); err != nil {
return err
}
res, err := jobrun.PR(ctx, log, cfg, jobrun.PROptions{
RequestID: *requestID,
Name: *name,
Description: *description,
StatusChecks: strings.Split(*checks, ","),
})
jobrun.Report(log, jobrun.ReportPath(), res)
return err
}
func runWatchJob(ctx context.Context, log *slog.Logger, cfg *config.Config, args []string) error {
fs := flag.NewFlagSet("job watch", flag.ContinueOnError)
repo := fs.String("repo", cfg.TFGitRepo, "owner/name of the repository holding the pull request")
number := fs.Int("pr", 0, "pull request number")
interval := fs.Duration("interval", 10*time.Second, "poll interval")
if err := fs.Parse(args); err != nil {
return err
}
if *number <= 0 {
return errors.New("--pr must be a positive pull request number")
}
res, err := jobrun.Watch(ctx, log, cfg, jobrun.WatchOptions{
Repo: *repo,
Number: *number,
Interval: *interval,
})
jobrun.Report(log, jobrun.ReportPath(), res)
return err
}
func runWoodpeckerJob(ctx context.Context, log *slog.Logger, cfg *config.Config, args []string) error {
fs := flag.NewFlagSet("job woodpecker-enable", flag.ContinueOnError)
name := fs.String("name", "", "repository name to activate")
if err := fs.Parse(args); err != nil {
return err
}
if strings.TrimSpace(*name) == "" {
return errors.New("--name is required")
}
res, err := jobrun.WoodpeckerEnable(ctx, log, cfg, *name)
jobrun.Report(log, jobrun.ReportPath(), res)
return err
}