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.
134 lines
4.2 KiB
Go
134 lines
4.2 KiB
Go
// 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 + `"`
|
|
}
|