Files
mesh-sdk/go/catalogue/cron.go
T

93 lines
3.1 KiB
Go

package catalogue
// A five-field cron validator, enough to refuse a malformed schedule near its author
// (novox/hq ADR 0053).
//
// **Validation only, and written rather than pulled in.** The control plane's part in a scheduled
// step is small and exact: carry the field to the host unchanged, and refuse a `schedule` that is
// not a well-formed cron here rather than on the machine — the same near-versus-far discipline the
// manifest keeps for an action a module may not declare and a run-once that is not a boolean. What
// *when to run it again* means is the host's to evaluate; the control plane only checks the string
// is one it could. A cron library would be a dependency carried for evaluation nobody does here.
//
// The ordinary five fields — minute, hour, day-of-month, month, day-of-week — with `*`, lists
// (`,`), ranges (`-`) and steps (`/`). No names (jan, mon): a numeric cron is what the host
// evaluates, and refusing a name here is refusing it near whoever wrote it.
import (
"fmt"
"strconv"
"strings"
)
// validateCron reports why a string is not a five-field cron expression, or nil if it is one.
func validateCron(expr string) error {
fields := strings.Fields(expr)
if len(fields) != 5 {
return fmt.Errorf(
"a schedule is a five-field cron expression (minute hour day-of-month month "+
"day-of-week), and this has %d field(s)", len(fields))
}
bounds := []struct {
name string
min, max int
}{
{"minute", 0, 59},
{"hour", 0, 23},
{"day-of-month", 1, 31},
{"month", 1, 12},
{"day-of-week", 0, 7}, // 0 and 7 are both Sunday, the convention every cron keeps
}
for i, b := range bounds {
if err := validateCronField(fields[i], b.min, b.max); err != nil {
return fmt.Errorf("%s field: %w", b.name, err)
}
}
return nil
}
// validateCronField checks one field's elements are within range and well-formed.
func validateCronField(spec string, min, max int) error {
if spec == "" {
return fmt.Errorf("is empty")
}
for _, part := range strings.Split(spec, ",") {
if part == "" {
return fmt.Errorf("%q has an empty element between commas", spec)
}
rangePart := part
if slash := strings.IndexByte(part, '/'); slash >= 0 {
rangePart = part[:slash]
n, err := strconv.Atoi(part[slash+1:])
if err != nil || n < 1 {
return fmt.Errorf("step in %q is not a positive number", part)
}
}
switch {
case rangePart == "*":
// Any value; nothing to bound.
case strings.IndexByte(rangePart, '-') >= 0:
dash := strings.IndexByte(rangePart, '-')
lo, errLo := strconv.Atoi(rangePart[:dash])
hi, errHi := strconv.Atoi(rangePart[dash+1:])
if errLo != nil || errHi != nil {
return fmt.Errorf("range %q is not two numbers", rangePart)
}
if lo < min || hi > max || lo > hi {
return fmt.Errorf("range %q is outside the allowed %d-%d", part, min, max)
}
default:
v, err := strconv.Atoi(rangePart)
if err != nil {
return fmt.Errorf("%q is not a number", rangePart)
}
if v < min || v > max {
return fmt.Errorf("%q is outside the allowed range %d-%d", part, min, max)
}
}
}
return nil
}