Files
repospawner/internal/jobrun/jobrun.go
T
unkin-agent f1bcb8cd3a
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
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.
2026-08-30 14:33:31 +10:00

205 lines
7.2 KiB
Go

// 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
}