Files
repospawner/internal/repospec/repospec.go
T
unkin-agent 8786636f7c
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Address review findings on the initial service
Four issues from the review of the initial repospawner service, none of
which change the shape of a request or the file terraform-git receives.

- Encode status checks as one --check flag per context on the
  server-to-job hop, so a separator inside a context can no longer turn
  one context into several; ban commas (and cap lengths) in Validate as
  well, since a real context never holds one.
- Fail a merged request that has waited five minutes for a Woodpecker
  token that vanished after acceptance, surfacing "woodpecker token
  unavailable" through the API, instead of warning in the log forever
  from enabling-ci. Advance now leaves a terminal request alone so the
  failure sticks.
- Hold a per-name lock from the duplicate checks through the store write,
  so two concurrent submissions of one name cannot both be accepted.
- Cap the description at 500 characters and the status checks at 20
  contexts of 100 characters each, and mirror the first two caps in the
  form.
2026-08-30 14:53:27 +10:00

157 lines
5.1 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
const (
// maxDescriptionLen bounds the description, which becomes a YAML scalar and
// a Job annotation.
maxDescriptionLen = 500
// maxCheckLen bounds a single status check context.
maxCheckLen = 100
// maxChecks bounds how many contexts one branch protection rule carries.
maxChecks = 20
)
// 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"
}
switch {
case r.Description == "":
errs["description"] = "required"
case len(r.Description) > maxDescriptionLen:
errs["description"] = fmt.Sprintf("must be at most %d characters", maxDescriptionLen)
}
switch {
case len(r.StatusChecks) == 0:
errs["status_checks"] = "at least one status check context is required"
case len(r.StatusChecks) > maxChecks:
errs["status_checks"] = fmt.Sprintf("at most %d status check contexts are allowed", maxChecks)
default:
for _, c := range r.StatusChecks {
// A comma would split into two contexts on the server-to-job hop, and
// a real context never contains one.
if strings.ContainsAny(c, "\n\",") {
errs["status_checks"] = "must not contain quotes, commas or newlines"
break
}
if len(c) > maxCheckLen {
errs["status_checks"] = fmt.Sprintf("each context must be at most %d characters", maxCheckLen)
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 + `"`
}