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.
279 lines
8.3 KiB
Go
279 lines
8.3 KiB
Go
package jobrun
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.unkin.net/unkin/repospawner/internal/config"
|
|
"git.unkin.net/unkin/repospawner/internal/jobs"
|
|
)
|
|
|
|
func quietLogger() *slog.Logger {
|
|
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
}
|
|
|
|
// forge is a stub Gitea plus Vault, since the jobs mint a token before every
|
|
// forge call.
|
|
type forge struct {
|
|
branches atomic.Int32
|
|
files atomic.Int32
|
|
lastFile string
|
|
prCalls atomic.Int32
|
|
pollCalls atomic.Int32
|
|
merged atomic.Bool
|
|
closed atomic.Bool
|
|
}
|
|
|
|
func (f *forge) handler(t *testing.T) http.HandlerFunc {
|
|
t.Helper()
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case strings.HasSuffix(r.URL.Path, "/login"):
|
|
_, _ = w.Write([]byte(`{"auth":{"client_token":"s.vault"}}`))
|
|
case strings.HasPrefix(r.URL.Path, "/v1/gitea/creds/"):
|
|
_, _ = w.Write([]byte(`{"data":{"username":"u","token":"gitea-tok"}}`))
|
|
case strings.HasSuffix(r.URL.Path, "/branches"):
|
|
f.branches.Add(1)
|
|
_, _ = w.Write([]byte(`{"name":"repospawner/widget"}`))
|
|
case strings.Contains(r.URL.Path, "/contents/"):
|
|
f.files.Add(1)
|
|
var body map[string]string
|
|
raw, _ := io.ReadAll(r.Body)
|
|
_ = json.Unmarshal(raw, &body)
|
|
decoded, err := base64.StdEncoding.DecodeString(body["content"])
|
|
if err != nil {
|
|
t.Errorf("content is not base64: %v", err)
|
|
}
|
|
f.lastFile = string(decoded)
|
|
_, _ = w.Write([]byte(`{}`))
|
|
case strings.HasSuffix(r.URL.Path, "/pulls"):
|
|
f.prCalls.Add(1)
|
|
_, _ = w.Write([]byte(`{"number":42,"html_url":"https://git.unkin.net/unkin/terraform-git/pulls/42","state":"open"}`))
|
|
case strings.Contains(r.URL.Path, "/pulls/"):
|
|
f.pollCalls.Add(1)
|
|
switch {
|
|
case f.merged.Load():
|
|
_, _ = w.Write([]byte(`{"number":42,"state":"closed","merged":true}`))
|
|
case f.closed.Load():
|
|
_, _ = w.Write([]byte(`{"number":42,"state":"closed","merged":false}`))
|
|
default:
|
|
_, _ = w.Write([]byte(`{"number":42,"state":"open","merged":false}`))
|
|
}
|
|
case strings.HasPrefix(r.URL.Path, "/api/v1/repos/"):
|
|
_, _ = w.Write([]byte(`{"id":91,"full_name":"unkin/widget"}`))
|
|
default:
|
|
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}
|
|
}
|
|
}
|
|
|
|
func testCfg(t *testing.T, base string) *config.Config {
|
|
t.Helper()
|
|
tokenPath := filepath.Join(t.TempDir(), "token")
|
|
if err := os.WriteFile(tokenPath, []byte("sa-jwt"), 0o600); err != nil {
|
|
t.Fatalf("write sa token: %v", err)
|
|
}
|
|
return &config.Config{
|
|
GiteaURL: base,
|
|
TFGitRepo: "unkin/terraform-git",
|
|
VaultAddr: base,
|
|
VaultK8sMount: "k8s/au/syd1",
|
|
VaultK8sRole: "repospawner",
|
|
VaultSATokenPath: tokenPath,
|
|
GiteaCredsPath: "gitea/creds/repospawner",
|
|
WoodpeckerServer: base,
|
|
}
|
|
}
|
|
|
|
func TestPRWritesConfigAndOpensPullRequest(t *testing.T) {
|
|
f := &forge{}
|
|
srv := httptest.NewServer(f.handler(t))
|
|
defer srv.Close()
|
|
|
|
res, err := PR(context.Background(), quietLogger(), testCfg(t, srv.URL), PROptions{
|
|
RequestID: "abc123",
|
|
Name: "widget",
|
|
Description: "does widgets",
|
|
StatusChecks: []string{"ci/woodpecker/pr/test"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("PR: %v", err)
|
|
}
|
|
if res.PRNumber != 42 || !strings.HasSuffix(res.PRURL, "/42") {
|
|
t.Errorf("result = %+v", res)
|
|
}
|
|
if f.branches.Load() != 1 || f.files.Load() != 1 || f.prCalls.Load() != 1 {
|
|
t.Errorf("calls: branches=%d files=%d pulls=%d", f.branches.Load(), f.files.Load(), f.prCalls.Load())
|
|
}
|
|
if !strings.Contains(f.lastFile, `description: "does widgets"`) ||
|
|
!strings.Contains(f.lastFile, `- "ci/woodpecker/pr/test"`) {
|
|
t.Errorf("committed file:\n%s", f.lastFile)
|
|
}
|
|
}
|
|
|
|
func TestPRRejectsAnInvalidRequestBeforeTouchingTheForge(t *testing.T) {
|
|
f := &forge{}
|
|
srv := httptest.NewServer(f.handler(t))
|
|
defer srv.Close()
|
|
|
|
res, err := PR(context.Background(), quietLogger(), testCfg(t, srv.URL), PROptions{
|
|
RequestID: "abc123", Name: "Widget", Description: "d", StatusChecks: []string{"x"},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected a validation error")
|
|
}
|
|
if res.Error == "" {
|
|
t.Error("the result must carry the reason for the termination message")
|
|
}
|
|
if f.branches.Load() != 0 {
|
|
t.Error("an invalid request must not branch terraform-git")
|
|
}
|
|
}
|
|
|
|
func TestWatchReportsMerge(t *testing.T) {
|
|
f := &forge{}
|
|
f.merged.Store(true)
|
|
srv := httptest.NewServer(f.handler(t))
|
|
defer srv.Close()
|
|
|
|
res, err := Watch(context.Background(), quietLogger(), testCfg(t, srv.URL), WatchOptions{
|
|
Repo: "unkin/terraform-git", Number: 42, Interval: time.Millisecond,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Watch: %v", err)
|
|
}
|
|
if !res.Merged || res.Closed {
|
|
t.Errorf("result = %+v", res)
|
|
}
|
|
}
|
|
|
|
func TestWatchReportsClose(t *testing.T) {
|
|
f := &forge{}
|
|
f.closed.Store(true)
|
|
srv := httptest.NewServer(f.handler(t))
|
|
defer srv.Close()
|
|
|
|
res, err := Watch(context.Background(), quietLogger(), testCfg(t, srv.URL), WatchOptions{
|
|
Repo: "unkin/terraform-git", Number: 42, Interval: time.Millisecond,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Watch: %v", err)
|
|
}
|
|
if !res.Closed || res.Merged {
|
|
t.Errorf("result = %+v", res)
|
|
}
|
|
}
|
|
|
|
func TestWatchKeepsPollingWhileOpen(t *testing.T) {
|
|
f := &forge{}
|
|
srv := httptest.NewServer(f.handler(t))
|
|
defer srv.Close()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Millisecond)
|
|
defer cancel()
|
|
res, err := Watch(ctx, quietLogger(), testCfg(t, srv.URL), WatchOptions{
|
|
Repo: "unkin/terraform-git", Number: 42, Interval: time.Millisecond,
|
|
})
|
|
if err == nil {
|
|
t.Fatal("an open pull request must not resolve the watch")
|
|
}
|
|
if res.Error == "" {
|
|
t.Error("the result must explain why the watch ended")
|
|
}
|
|
if f.pollCalls.Load() < 2 {
|
|
t.Errorf("polls = %d, want repeated polling", f.pollCalls.Load())
|
|
}
|
|
}
|
|
|
|
func TestWoodpeckerEnable(t *testing.T) {
|
|
var enabled atomic.Bool
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case strings.HasSuffix(r.URL.Path, "/login"):
|
|
_, _ = w.Write([]byte(`{"auth":{"client_token":"s.vault"}}`))
|
|
case strings.HasPrefix(r.URL.Path, "/v1/gitea/creds/"):
|
|
_, _ = w.Write([]byte(`{"data":{"username":"u","token":"gitea-tok"}}`))
|
|
case r.URL.Path == "/api/v1/repos/unkin/widget":
|
|
_, _ = w.Write([]byte(`{"id":91,"full_name":"unkin/widget"}`))
|
|
case r.URL.Path == "/api/repos":
|
|
if r.URL.Query().Get("forge_remote_id") != "91" {
|
|
t.Errorf("forge_remote_id = %q", r.URL.Query().Get("forge_remote_id"))
|
|
}
|
|
enabled.Store(true)
|
|
_, _ = w.Write([]byte(`{"id":5,"full_name":"unkin/widget","active":true}`))
|
|
case strings.HasPrefix(r.URL.Path, "/api/repos/lookup/"):
|
|
_, _ = w.Write([]byte(`{"id":5,"full_name":"unkin/widget","active":` + boolStr(enabled.Load()) + `}`))
|
|
default:
|
|
t.Errorf("unexpected request %s", r.URL.Path)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
cfg := testCfg(t, srv.URL)
|
|
cfg.WoodpeckerTokenFile = filepath.Join(t.TempDir(), "wp")
|
|
if err := os.WriteFile(cfg.WoodpeckerTokenFile, []byte("wp-token\n"), 0o600); err != nil {
|
|
t.Fatalf("write token: %v", err)
|
|
}
|
|
|
|
res, err := WoodpeckerEnable(context.Background(), quietLogger(), cfg, "widget")
|
|
if err != nil {
|
|
t.Fatalf("WoodpeckerEnable: %v", err)
|
|
}
|
|
if !res.Enabled || res.RepoID != 91 {
|
|
t.Errorf("result = %+v", res)
|
|
}
|
|
}
|
|
|
|
func TestWoodpeckerEnableWithoutToken(t *testing.T) {
|
|
cfg := testCfg(t, "https://unused.invalid")
|
|
cfg.WoodpeckerTokenFile = filepath.Join(t.TempDir(), "absent")
|
|
res, err := WoodpeckerEnable(context.Background(), quietLogger(), cfg, "widget")
|
|
if err == nil {
|
|
t.Fatal("expected an error with no token file")
|
|
}
|
|
if res.Error == "" || res.Enabled {
|
|
t.Errorf("result = %+v", res)
|
|
}
|
|
}
|
|
|
|
func TestReportWritesTerminationMessage(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "termination-log")
|
|
Report(quietLogger(), path, jobs.PRResult{PRNumber: 42, PRURL: "https://example/42"})
|
|
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read: %v", err)
|
|
}
|
|
var got jobs.PRResult
|
|
if !jobs.DecodeResult(raw, &got) {
|
|
t.Fatalf("termination message %q did not decode", raw)
|
|
}
|
|
if got.PRNumber != 42 || got.PRURL != "https://example/42" {
|
|
t.Errorf("result = %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestReportSurvivesAnUnwritablePath(t *testing.T) {
|
|
// The kubelet path is absent outside a pod; the job must still exit cleanly.
|
|
Report(quietLogger(), filepath.Join(t.TempDir(), "missing-dir", "log"), jobs.WatchResult{Merged: true})
|
|
}
|
|
|
|
func boolStr(b bool) string {
|
|
if b {
|
|
return "true"
|
|
}
|
|
return "false"
|
|
}
|