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,133 @@
|
||||
// Package repospec validates a new-repo request and renders the terraform-git
|
||||
// repository config file it becomes.
|
||||
package repospec
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ConfigDir is the terraform-git tree that owns repository definitions.
|
||||
const ConfigDir = "config/git.unkin.net/unkin/repository"
|
||||
|
||||
// nameRE is the DNS-label-ish shape a repository name must take: it becomes a
|
||||
// branch name, a container image name and a k8s object name downstream.
|
||||
var nameRE = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`)
|
||||
|
||||
// maxNameLen keeps generated Job names (prefix + name + suffix) inside the 63
|
||||
// character limit k8s applies to object names.
|
||||
const maxNameLen = 40
|
||||
|
||||
// Request is a submitted new-repo request.
|
||||
type Request struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Woodpecker bool `json:"woodpecker"`
|
||||
StatusChecks []string `json:"status_checks"`
|
||||
}
|
||||
|
||||
// FieldErrors maps a request field to why it was rejected.
|
||||
type FieldErrors map[string]string
|
||||
|
||||
func (f FieldErrors) Error() string {
|
||||
keys := make([]string, 0, len(f))
|
||||
for k := range f {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
parts = append(parts, k+": "+f[k])
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// Normalize trims incidental whitespace and drops blank status check lines. The
|
||||
// UI submits the check list as a textarea, so blank lines are routine.
|
||||
func (r Request) Normalize() Request {
|
||||
r.Name = strings.TrimSpace(r.Name)
|
||||
r.Description = strings.TrimSpace(r.Description)
|
||||
checks := make([]string, 0, len(r.StatusChecks))
|
||||
seen := map[string]bool{}
|
||||
for _, c := range r.StatusChecks {
|
||||
c = strings.TrimSpace(c)
|
||||
if c == "" || seen[c] {
|
||||
continue
|
||||
}
|
||||
seen[c] = true
|
||||
checks = append(checks, c)
|
||||
}
|
||||
r.StatusChecks = checks
|
||||
return r
|
||||
}
|
||||
|
||||
// Validate checks a normalized request, returning nil or a FieldErrors naming
|
||||
// every problem at once so the form can show them together.
|
||||
func (r Request) Validate() error {
|
||||
errs := FieldErrors{}
|
||||
switch {
|
||||
case r.Name == "":
|
||||
errs["name"] = "required"
|
||||
case len(r.Name) > maxNameLen:
|
||||
errs["name"] = fmt.Sprintf("must be at most %d characters", maxNameLen)
|
||||
case !nameRE.MatchString(r.Name):
|
||||
errs["name"] = "must be lowercase letters, digits and dashes, starting and ending alphanumeric"
|
||||
}
|
||||
if r.Description == "" {
|
||||
errs["description"] = "required"
|
||||
}
|
||||
if len(r.StatusChecks) == 0 {
|
||||
errs["status_checks"] = "at least one status check context is required"
|
||||
}
|
||||
for _, c := range r.StatusChecks {
|
||||
if strings.ContainsAny(c, "\n\"") {
|
||||
errs["status_checks"] = "must not contain quotes or newlines"
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(errs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
// ConfigPath is the repository config file a request writes.
|
||||
func (r Request) ConfigPath() string { return ConfigDir + "/" + r.Name + ".yaml" }
|
||||
|
||||
// BranchName is the terraform-git branch the PR job pushes.
|
||||
func (r Request) BranchName() string { return "repospawner/" + r.Name }
|
||||
|
||||
// RenderYAML produces the terraform-git repository config. Everything except
|
||||
// the description and the status check contexts is fixed estate policy: public,
|
||||
// main-default, squash-merged, branch-deleted, and a protected main only the
|
||||
// Owners team can merge into with benvin as the approver.
|
||||
func (r Request) RenderYAML() string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "description: %s\n", quote(r.Description))
|
||||
b.WriteString("private: false\n")
|
||||
b.WriteString("default_branch: \"main\"\n")
|
||||
b.WriteString("default_delete_branch_after_merge: true\n")
|
||||
b.WriteString("default_merge_style: \"squash\"\n")
|
||||
b.WriteString("branch_protection:\n")
|
||||
b.WriteString(" - rule_name: \"main\"\n")
|
||||
b.WriteString(" merge_whitelist_teams:\n")
|
||||
b.WriteString(" - \"Owners\"\n")
|
||||
b.WriteString(" enable_push: false\n")
|
||||
b.WriteString(" status_check_contexts:\n")
|
||||
for _, c := range r.StatusChecks {
|
||||
fmt.Fprintf(&b, " - %s\n", quote(c))
|
||||
}
|
||||
b.WriteString(" approval_whitelist_users:\n")
|
||||
b.WriteString(" - \"benvin\"\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// quote emits a double-quoted YAML scalar, escaping the two characters that
|
||||
// can break out of one.
|
||||
func quote(s string) string {
|
||||
s = strings.ReplaceAll(s, `\`, `\\`)
|
||||
s = strings.ReplaceAll(s, `"`, `\"`)
|
||||
return `"` + s + `"`
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package repospec
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
base := Request{Name: "widget", Description: "does widgets", StatusChecks: []string{"ci/woodpecker/pr/test"}}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
req Request
|
||||
fields []string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "valid", req: base},
|
||||
{name: "valid with digits and dashes", req: with(base, func(r *Request) { r.Name = "arr-proxy2" })},
|
||||
{name: "missing name", req: with(base, func(r *Request) { r.Name = "" }), fields: []string{"name"}, wantErr: true},
|
||||
{name: "uppercase name", req: with(base, func(r *Request) { r.Name = "Widget" }), fields: []string{"name"}, wantErr: true},
|
||||
{name: "underscore name", req: with(base, func(r *Request) { r.Name = "wid_get" }), fields: []string{"name"}, wantErr: true},
|
||||
{name: "leading dash", req: with(base, func(r *Request) { r.Name = "-widget" }), fields: []string{"name"}, wantErr: true},
|
||||
{name: "trailing dash", req: with(base, func(r *Request) { r.Name = "widget-" }), fields: []string{"name"}, wantErr: true},
|
||||
{name: "path traversal", req: with(base, func(r *Request) { r.Name = "../etc/passwd" }), fields: []string{"name"}, wantErr: true},
|
||||
{name: "over long", req: with(base, func(r *Request) { r.Name = strings.Repeat("a", maxNameLen+1) }), fields: []string{"name"}, wantErr: true},
|
||||
{name: "missing description", req: with(base, func(r *Request) { r.Description = "" }), fields: []string{"description"}, wantErr: true},
|
||||
{name: "no checks", req: with(base, func(r *Request) { r.StatusChecks = nil }), fields: []string{"status_checks"}, wantErr: true},
|
||||
{
|
||||
name: "every field bad at once",
|
||||
req: Request{},
|
||||
fields: []string{"name", "description", "status_checks"},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tc.req.Validate()
|
||||
if tc.wantErr == (err == nil) {
|
||||
t.Fatalf("Validate() = %v, wantErr %v", err, tc.wantErr)
|
||||
}
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
var fe FieldErrors
|
||||
if !errors.As(err, &fe) {
|
||||
t.Fatalf("error %v is not FieldErrors", err)
|
||||
}
|
||||
if len(fe) != len(tc.fields) {
|
||||
t.Fatalf("fields = %v, want exactly %v", fe, tc.fields)
|
||||
}
|
||||
for _, f := range tc.fields {
|
||||
if _, ok := fe[f]; !ok {
|
||||
t.Errorf("missing field error for %q; got %v", f, fe)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalize(t *testing.T) {
|
||||
r := Request{
|
||||
Name: " widget \n",
|
||||
Description: " does widgets ",
|
||||
StatusChecks: []string{" a ", "", "b", "a", " "},
|
||||
}.Normalize()
|
||||
|
||||
if r.Name != "widget" {
|
||||
t.Errorf("Name = %q", r.Name)
|
||||
}
|
||||
if r.Description != "does widgets" {
|
||||
t.Errorf("Description = %q", r.Description)
|
||||
}
|
||||
want := []string{"a", "b"}
|
||||
if len(r.StatusChecks) != len(want) {
|
||||
t.Fatalf("StatusChecks = %v, want %v", r.StatusChecks, want)
|
||||
}
|
||||
for i := range want {
|
||||
if r.StatusChecks[i] != want[i] {
|
||||
t.Fatalf("StatusChecks = %v, want %v", r.StatusChecks, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathsAndBranch(t *testing.T) {
|
||||
r := Request{Name: "widget"}
|
||||
if got, want := r.ConfigPath(), "config/git.unkin.net/unkin/repository/widget.yaml"; got != want {
|
||||
t.Errorf("ConfigPath() = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := r.BranchName(), "repospawner/widget"; got != want {
|
||||
t.Errorf("BranchName() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// golden pins the file terraform-git receives; a drift here changes estate
|
||||
// policy for every repository repospawner creates.
|
||||
const golden = `description: "Keyboard-centric widget service"
|
||||
private: false
|
||||
default_branch: "main"
|
||||
default_delete_branch_after_merge: true
|
||||
default_merge_style: "squash"
|
||||
branch_protection:
|
||||
- rule_name: "main"
|
||||
merge_whitelist_teams:
|
||||
- "Owners"
|
||||
enable_push: false
|
||||
status_check_contexts:
|
||||
- "ci/woodpecker/pr/build"
|
||||
- "ci/woodpecker/pr/test"
|
||||
- "ci/woodpecker/pr/pre-commit"
|
||||
approval_whitelist_users:
|
||||
- "benvin"
|
||||
`
|
||||
|
||||
func TestRenderYAMLGolden(t *testing.T) {
|
||||
got := Request{
|
||||
Name: "widget",
|
||||
Description: "Keyboard-centric widget service",
|
||||
StatusChecks: []string{
|
||||
"ci/woodpecker/pr/build",
|
||||
"ci/woodpecker/pr/test",
|
||||
"ci/woodpecker/pr/pre-commit",
|
||||
},
|
||||
}.RenderYAML()
|
||||
if got != golden {
|
||||
t.Errorf("RenderYAML() mismatch\n got:\n%s\nwant:\n%s", got, golden)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderYAMLQuotesDescription(t *testing.T) {
|
||||
got := Request{
|
||||
Name: "widget",
|
||||
Description: `a "quoted" \ description`,
|
||||
StatusChecks: []string{"x"},
|
||||
}.RenderYAML()
|
||||
want := `description: "a \"quoted\" \\ description"`
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("RenderYAML() did not escape the description; got first line %q", strings.SplitN(got, "\n", 2)[0])
|
||||
}
|
||||
}
|
||||
|
||||
func with(r Request, f func(*Request)) Request {
|
||||
f(&r)
|
||||
return r
|
||||
}
|
||||
Reference in New Issue
Block a user