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') }