A container may declare schedule: "<cron>", the recurring twin of run-once. The resolver already carries a resource's keys through untouched, so schedule reaches the rendered host declaration on its own; what belongs here is refusing, near its author, what the host would otherwise refuse far away. The manifest parser refuses a schedule that is not a string, one that is not a well-formed five-field cron (cron.go: fields, ranges, *, comma, dash, slash), and the contradictory pair run-once + schedule -- a container runs once and gates, or on a cadence, or stays up, never two. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
93 lines
3.1 KiB
Go
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
|
|
}
|