f1bcb8cd3a
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.
205 lines
7.2 KiB
Go
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
|
|
}
|