a3339a30b5
Addresses PR review + acceptance findings: - Add leader-election RBAC: namespaced Role (coordination leases + events) and RoleBinding to the operator ServiceAccount, so controllers actually start under --leader-elect instead of looping on "leases forbidden". Verified in a kind cluster: lease acquired, both controllers start workers. - Align Go versions: bump Dockerfile.operator to golang:1.26-alpine and CI images to golang:1.26 to match go.mod (go 1.26.5); set GOTOOLCHAIN=local so the image build stays hermetic (no toolchain download). - PVC controller: add an annotation predicate so only PVCs carrying (or transitioning off of) backups.unkin.net/schedule enqueue, eliminating reconcile churn from unannotated PVCs while keeping the teardown path. - Cron validator: accept alphabetic month/day-of-week names (MON, JAN, MON-FRI) that k8up and CNPG's robfig/cron accept, still rejecting unknown names; add unit tests for both mapping paths. - Deploy ordering: extract the Namespace into its own manifest and add a config/kustomization.yaml so `kubectl apply -k config` creates the namespace first; document it in the README. - Note the credential-less push precedent (jellyfin-ha) in docker.yaml.
135 lines
3.9 KiB
Go
135 lines
3.9 KiB
Go
package controller
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// cnpgShortcuts expands cron nickname shortcuts to the 6-field (seconds-first)
|
|
// cron expressions CloudNativePG's robfig/cron parser expects.
|
|
var cnpgShortcuts = map[string]string{
|
|
"@hourly": "0 0 * * * *",
|
|
"@daily": "0 0 0 * * *",
|
|
"@midnight": "0 0 0 * * *",
|
|
"@weekly": "0 0 0 * * 0",
|
|
"@monthly": "0 0 0 1 * *",
|
|
"@yearly": "0 0 0 1 1 *",
|
|
"@annually": "0 0 0 1 1 *",
|
|
}
|
|
|
|
// k8upShortcuts is the set of cron nicknames k8up accepts natively (passed
|
|
// through unchanged).
|
|
var k8upShortcuts = map[string]bool{
|
|
"@hourly": true, "@daily": true, "@midnight": true, "@weekly": true,
|
|
"@monthly": true, "@yearly": true, "@annually": true,
|
|
}
|
|
|
|
// ScheduleForK8up validates a schedule annotation for k8up, which accepts cron
|
|
// nickname shortcuts natively and otherwise expects a 5-field cron expression.
|
|
// A valid value is returned unchanged.
|
|
func ScheduleForK8up(in string) (string, error) {
|
|
s := strings.TrimSpace(in)
|
|
if s == "" {
|
|
return "", fmt.Errorf("empty schedule")
|
|
}
|
|
if strings.HasPrefix(s, "@") {
|
|
if k8upShortcuts[s] {
|
|
return s, nil
|
|
}
|
|
return "", fmt.Errorf("unsupported schedule shortcut %q", s)
|
|
}
|
|
if err := validateCronFields(s, 5); err != nil {
|
|
return "", err
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// ScheduleForCNPG normalises a schedule annotation into the 6-field
|
|
// (seconds-first) cron format a CloudNativePG ScheduledBackup requires:
|
|
// shortcuts expand to explicit expressions, a 5-field cron gains a leading "0"
|
|
// seconds field, and a 6-field cron is accepted as-is.
|
|
func ScheduleForCNPG(in string) (string, error) {
|
|
s := strings.TrimSpace(in)
|
|
if s == "" {
|
|
return "", fmt.Errorf("empty schedule")
|
|
}
|
|
if strings.HasPrefix(s, "@") {
|
|
if v, ok := cnpgShortcuts[s]; ok {
|
|
return v, nil
|
|
}
|
|
return "", fmt.Errorf("unsupported schedule shortcut %q", s)
|
|
}
|
|
fields := strings.Fields(s)
|
|
switch len(fields) {
|
|
case 5:
|
|
if err := validateCronFields(s, 5); err != nil {
|
|
return "", err
|
|
}
|
|
return "0 " + strings.Join(fields, " "), nil
|
|
case 6:
|
|
if err := validateCronFields(s, 6); err != nil {
|
|
return "", err
|
|
}
|
|
return strings.Join(fields, " "), nil
|
|
default:
|
|
return "", fmt.Errorf("cron %q must have 5 or 6 fields, got %d", s, len(fields))
|
|
}
|
|
}
|
|
|
|
// cronNames are the alphabetic month and day-of-week names robfig/cron (used by
|
|
// k8up and CloudNativePG) accepts, case-insensitively, e.g. "MON" or "JAN".
|
|
var cronNames = map[string]bool{
|
|
"JAN": true, "FEB": true, "MAR": true, "APR": true, "MAY": true, "JUN": true,
|
|
"JUL": true, "AUG": true, "SEP": true, "OCT": true, "NOV": true, "DEC": true,
|
|
"SUN": true, "MON": true, "TUE": true, "WED": true, "THU": true, "FRI": true, "SAT": true,
|
|
}
|
|
|
|
func validateCronFields(s string, want int) error {
|
|
fields := strings.Fields(s)
|
|
if len(fields) != want {
|
|
return fmt.Errorf("cron %q must have %d fields, got %d", s, want, len(fields))
|
|
}
|
|
for _, f := range fields {
|
|
if f == "" {
|
|
return fmt.Errorf("cron %q has an empty field", s)
|
|
}
|
|
if err := validateCronField(f); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateCronField accepts cron symbols plus known alphabetic month/day names
|
|
// (e.g. MON-FRI, JAN); an unknown alphabetic run like "foo" is rejected.
|
|
func validateCronField(f string) error {
|
|
runes := []rune(f)
|
|
for i := 0; i < len(runes); {
|
|
if isLetter(runes[i]) {
|
|
j := i
|
|
for j < len(runes) && isLetter(runes[j]) {
|
|
j++
|
|
}
|
|
name := strings.ToUpper(string(runes[i:j]))
|
|
if !cronNames[name] {
|
|
return fmt.Errorf("cron field %q contains unknown name %q", f, string(runes[i:j]))
|
|
}
|
|
i = j
|
|
continue
|
|
}
|
|
if !isCronRune(runes[i]) {
|
|
return fmt.Errorf("cron field %q contains invalid character %q", f, string(runes[i]))
|
|
}
|
|
i++
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isCronRune(r rune) bool {
|
|
return (r >= '0' && r <= '9') || r == '*' || r == '/' || r == ',' || r == '-' || r == '?'
|
|
}
|
|
|
|
func isLetter(r rune) bool {
|
|
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
|
|
}
|