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