8786636f7c
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.
157 lines
5.1 KiB
Go
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 + `"`
|
|
}
|