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,207 @@
|
||||
// Package gitea is the small slice of the Gitea API repospawner needs: does a
|
||||
// repository config file already exist, open a pull request, and follow that
|
||||
// pull request to its merge.
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TokenFunc supplies a Gitea token. force asks for a freshly minted one, which
|
||||
// the client requests exactly once per call after a 401.
|
||||
type TokenFunc func(ctx context.Context, force bool) (string, error)
|
||||
|
||||
// Client talks to one Gitea instance.
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
Token TokenFunc
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
// New builds a Client with a bounded HTTP client.
|
||||
func New(baseURL string, token TokenFunc) *Client {
|
||||
return &Client{
|
||||
BaseURL: strings.TrimSuffix(baseURL, "/"),
|
||||
Token: token,
|
||||
HTTP: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Repo is the subset of a repository record repospawner uses. ID is the forge
|
||||
// remote id Woodpecker enablement needs.
|
||||
type Repo struct {
|
||||
ID int64 `json:"id"`
|
||||
FullName string `json:"full_name"`
|
||||
}
|
||||
|
||||
// PullRequest is the subset of a pull request record repospawner uses.
|
||||
type PullRequest struct {
|
||||
Number int `json:"number"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
State string `json:"state"`
|
||||
Merged bool `json:"merged"`
|
||||
}
|
||||
|
||||
// Repo fetches a repository by "owner/name".
|
||||
func (c *Client) Repo(ctx context.Context, repo string) (Repo, error) {
|
||||
var out Repo
|
||||
err := c.do(ctx, http.MethodGet, "/api/v1/repos/"+repo, nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// FileExists reports whether path exists on ref in repo. A 404 is the answer
|
||||
// "no", not an error; anything else is an error, so a broken forge can never be
|
||||
// mistaken for a free name.
|
||||
func (c *Client) FileExists(ctx context.Context, repo, path, ref string) (bool, error) {
|
||||
ep := "/api/v1/repos/" + repo + "/contents/" + escapePath(path)
|
||||
if ref != "" {
|
||||
ep += "?ref=" + url.QueryEscape(ref)
|
||||
}
|
||||
err := c.do(ctx, http.MethodGet, ep, nil, nil)
|
||||
var se *StatusError
|
||||
if errors.As(err, &se) && se.Status == http.StatusNotFound {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// CreateBranch branches newBranch off oldRef. An existing branch is not an
|
||||
// error: a retried Job must be able to finish what its predecessor started.
|
||||
func (c *Client) CreateBranch(ctx context.Context, repo, newBranch, oldRef string) error {
|
||||
payload := map[string]string{"new_branch_name": newBranch, "old_ref_name": oldRef}
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/repos/"+repo+"/branches", payload, nil)
|
||||
var se *StatusError
|
||||
if errors.As(err, &se) && se.Status == http.StatusConflict {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// CreateFile writes a new file on branch. content is raw bytes; Gitea wants it
|
||||
// base64-encoded.
|
||||
func (c *Client) CreateFile(ctx context.Context, repo, path, branch, message string, content []byte) error {
|
||||
payload := map[string]string{
|
||||
"branch": branch,
|
||||
"content": base64.StdEncoding.EncodeToString(content),
|
||||
"message": message,
|
||||
}
|
||||
return c.do(ctx, http.MethodPost, "/api/v1/repos/"+repo+"/contents/"+escapePath(path), payload, nil)
|
||||
}
|
||||
|
||||
// CreatePullRequest opens a pull request from head into base.
|
||||
func (c *Client) CreatePullRequest(ctx context.Context, repo, head, base, title, body string) (PullRequest, error) {
|
||||
payload := map[string]string{"head": head, "base": base, "title": title, "body": body}
|
||||
var out PullRequest
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/repos/"+repo+"/pulls", payload, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// PullRequest fetches one pull request by index.
|
||||
func (c *Client) PullRequest(ctx context.Context, repo string, number int) (PullRequest, error) {
|
||||
var out PullRequest
|
||||
err := c.do(ctx, http.MethodGet, "/api/v1/repos/"+repo+"/pulls/"+strconv.Itoa(number), nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// StatusError carries a non-2xx response.
|
||||
type StatusError struct {
|
||||
Status int
|
||||
Method string
|
||||
Path string
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *StatusError) Error() string {
|
||||
msg := fmt.Sprintf("gitea %s %s: status %d", e.Method, e.Path, e.Status)
|
||||
if e.Body != "" {
|
||||
msg += ": " + e.Body
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// do issues one API call, retrying exactly once with a freshly minted token
|
||||
// when the forge answers 401 — dynamic Gitea credentials expire after about an
|
||||
// hour and the watch job outlives that.
|
||||
func (c *Client) do(ctx context.Context, method, path string, in, out any) error {
|
||||
var body []byte
|
||||
if in != nil {
|
||||
var err error
|
||||
if body, err = json.Marshal(in); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
resp, err := c.attempt(ctx, method, path, body, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
_ = resp.Body.Close()
|
||||
if resp, err = c.attempt(ctx, method, path, body, true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return &StatusError{Status: resp.StatusCode, Method: method, Path: path, Body: strings.TrimSpace(string(snippet))}
|
||||
}
|
||||
if out == nil {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return nil
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
func (c *Client) attempt(ctx context.Context, method, path string, body []byte, force bool) (*http.Response, error) {
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
reader = bytes.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if c.Token != nil {
|
||||
token, err := c.Token(ctx, force)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "token "+token)
|
||||
}
|
||||
}
|
||||
client := c.HTTP
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
return client.Do(req)
|
||||
}
|
||||
|
||||
// escapePath percent-escapes each path segment so a config path survives the
|
||||
// contents API without collapsing its separators.
|
||||
func escapePath(p string) string {
|
||||
parts := strings.Split(strings.TrimPrefix(p, "/"), "/")
|
||||
for i, s := range parts {
|
||||
parts[i] = url.PathEscape(s)
|
||||
}
|
||||
return strings.Join(parts, "/")
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func staticToken(tok string) TokenFunc {
|
||||
return func(context.Context, bool) (string, error) { return tok, nil }
|
||||
}
|
||||
|
||||
func TestFileExists(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
status int
|
||||
want bool
|
||||
errors bool
|
||||
}{
|
||||
{name: "present", status: http.StatusOK, want: true},
|
||||
{name: "absent", status: http.StatusNotFound, want: false},
|
||||
{name: "forge broken", status: http.StatusInternalServerError, errors: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var gotPath, gotAuth string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath, gotAuth = r.URL.Path, r.Header.Get("Authorization")
|
||||
w.WriteHeader(tc.status)
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(srv.URL, staticToken("tok"))
|
||||
got, err := c.FileExists(context.Background(), "unkin/terraform-git",
|
||||
"config/git.unkin.net/unkin/repository/widget.yaml", "main")
|
||||
if tc.errors {
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for a broken forge, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("FileExists: %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("FileExists = %v, want %v", got, tc.want)
|
||||
}
|
||||
wantPath := "/api/v1/repos/unkin/terraform-git/contents/config/git.unkin.net/unkin/repository/widget.yaml"
|
||||
if gotPath != wantPath {
|
||||
t.Errorf("path = %q, want %q", gotPath, wantPath)
|
||||
}
|
||||
if gotAuth != "token tok" {
|
||||
t.Errorf("Authorization = %q", gotAuth)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePullRequest(t *testing.T) {
|
||||
var body map[string]string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/repos/unkin/terraform-git/pulls" {
|
||||
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(raw, &body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"number":42,"html_url":"https://git.unkin.net/unkin/terraform-git/pulls/42","state":"open"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
pr, err := New(srv.URL, staticToken("tok")).CreatePullRequest(
|
||||
context.Background(), "unkin/terraform-git", "repospawner/widget", "main", "Add widget", "why")
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePullRequest: %v", err)
|
||||
}
|
||||
if pr.Number != 42 || !strings.HasSuffix(pr.HTMLURL, "/42") {
|
||||
t.Errorf("pr = %+v", pr)
|
||||
}
|
||||
if body["head"] != "repospawner/widget" || body["base"] != "main" || body["title"] != "Add widget" {
|
||||
t.Errorf("payload = %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFileBase64EncodesContent(t *testing.T) {
|
||||
var body map[string]string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(raw, &body)
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := New(srv.URL, staticToken("tok")).CreateFile(context.Background(),
|
||||
"unkin/terraform-git", "config/a/b.yaml", "repospawner/widget", "Add widget", []byte("description: x\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateFile: %v", err)
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(body["content"])
|
||||
if err != nil {
|
||||
t.Fatalf("content is not base64: %v", err)
|
||||
}
|
||||
if string(decoded) != "description: x\n" {
|
||||
t.Errorf("content = %q", decoded)
|
||||
}
|
||||
if body["branch"] != "repospawner/widget" {
|
||||
t.Errorf("branch = %q", body["branch"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBranchToleratesExisting(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
_, _ = w.Write([]byte(`{"message":"branch already exists"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if err := New(srv.URL, staticToken("tok")).CreateBranch(context.Background(),
|
||||
"unkin/terraform-git", "repospawner/widget", "main"); err != nil {
|
||||
t.Fatalf("CreateBranch on an existing branch should succeed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReMintsTokenOn401(t *testing.T) {
|
||||
var mints atomic.Int32
|
||||
token := func(_ context.Context, force bool) (string, error) {
|
||||
if force {
|
||||
mints.Add(1)
|
||||
return "fresh", nil
|
||||
}
|
||||
return "stale", nil
|
||||
}
|
||||
|
||||
var seen []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
auth := r.Header.Get("Authorization")
|
||||
seen = append(seen, auth)
|
||||
if auth == "token stale" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"message":"token expired"}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"number":7,"html_url":"https://example/7","state":"open","merged":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
pr, err := New(srv.URL, token).PullRequest(context.Background(), "unkin/terraform-git", 7)
|
||||
if err != nil {
|
||||
t.Fatalf("PullRequest: %v", err)
|
||||
}
|
||||
if !pr.Merged || pr.Number != 7 {
|
||||
t.Errorf("pr = %+v", pr)
|
||||
}
|
||||
if mints.Load() != 1 {
|
||||
t.Errorf("forced mints = %d, want 1", mints.Load())
|
||||
}
|
||||
if len(seen) != 2 || seen[0] != "token stale" || seen[1] != "token fresh" {
|
||||
t.Errorf("authorization headers = %v", seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusErrorCarriesBody(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
_, _ = w.Write([]byte(`{"message":"branch already has a pull request"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := New(srv.URL, staticToken("tok")).CreatePullRequest(
|
||||
context.Background(), "unkin/terraform-git", "h", "main", "t", "b")
|
||||
if err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "422") || !strings.Contains(err.Error(), "already has a pull request") {
|
||||
t.Errorf("error = %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user