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