Files
repospawner/internal/repospec/repospec.go
T
unkin-agent f1bcb8cd3a
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
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.
2026-08-30 14:33:31 +10:00

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 + `"`
}