Add the initial repospawner service
repospawner turns JSON new-repo requests into terraform-git pull requests via kubernetes Jobs, follows those PRs to merge and optionally activates the repository in Woodpecker.
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user