catalogue: carry and validate a container schedule (ADR 0053)
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
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user