Files
mesh-controller/internal/catalogue/schedule_test.go
jschoubben 78b8b6e256 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
2026-09-06 14:08:51 +02:00

90 lines
3.5 KiB
Go

package catalogue
import (
"strings"
"testing"
)
// A scheduled container is the recurring twin of run-once (novox/hq ADR 0053). The control plane's
// part is small and exact: carry the field to the host unchanged, and refuse a malformed schedule —
// or the contradictory pair run-once + schedule — near its author rather than on the machine. These
// tests defend that.
func TestAScheduledContainerRendersWithTheCronField(t *testing.T) {
// The resolver carries `schedule` into the rendered host declaration untouched, exactly as it
// carries run-once — a container's keys are copied through, so the host receives the field as
// written.
digest := "@sha256:" + strings.Repeat("a", 64)
r := Resolution{Node: "laptop", Modules: []Manifest{{
Module: "kometa",
Resources: []map[string]any{
{"id": "sync", "type": "container", "name": "sync",
"image": "registry.example/runtime" + digest, "schedule": "0 3 * * *"},
},
}}}
out, err := r.Declaration(Rendering{})
if err != nil {
t.Fatal(err)
}
sync := indexOfID(out, "kometa.sync")
if sync == -1 {
t.Fatal("the scheduled container was lost in rendering")
}
if got, _ := out[sync]["schedule"].(string); got != "0 3 * * *" {
t.Errorf("schedule did not reach the host declaration: %+v", out[sync])
}
}
func TestAMalformedScheduleIsRefused(t *testing.T) {
digest := "@sha256:" + strings.Repeat("a", 64)
bad := []byte(`{"module":"m","resources":[
{"id":"sync","type":"container","name":"sync","image":"registry.example/x` + digest + `","schedule":"every night"}
]}`)
if _, err := ParseManifest(bad); err == nil {
t.Error("a malformed schedule was accepted")
} else if !strings.Contains(err.Error(), "cron") {
t.Errorf("refused for the wrong reason: %v", err)
}
// A schedule out of range is refused for the same reason, near its author.
outOfRange := []byte(`{"module":"m","resources":[
{"id":"sync","type":"container","name":"sync","image":"registry.example/x` + digest + `","schedule":"0 25 * * *"}
]}`)
if _, err := ParseManifest(outOfRange); err == nil {
t.Error("a schedule with an hour out of range was accepted")
}
good := []byte(`{"module":"m","resources":[
{"id":"sync","type":"container","name":"sync","image":"registry.example/x` + digest + `","schedule":"*/15 * * * *"}
]}`)
if _, err := ParseManifest(good); err != nil {
t.Errorf("a valid scheduled container was refused: %v", err)
}
}
func TestAScheduleThatIsNotAStringIsRefused(t *testing.T) {
digest := "@sha256:" + strings.Repeat("a", 64)
bad := []byte(`{"module":"m","resources":[
{"id":"sync","type":"container","name":"sync","image":"registry.example/x` + digest + `","schedule":true}
]}`)
if _, err := ParseManifest(bad); err == nil {
t.Error("a schedule that is not a string was accepted")
} else if !strings.Contains(err.Error(), "schedule") {
t.Errorf("refused for the wrong reason: %v", err)
}
}
func TestAContainerCannotBeBothRunOnceAndScheduled(t *testing.T) {
// A container runs once and gates, or on a cadence, or stays up — never two (novox/hq ADR 0053).
digest := "@sha256:" + strings.Repeat("a", 64)
bad := []byte(`{"module":"m","resources":[
{"id":"sync","type":"container","name":"sync","image":"registry.example/x` + digest + `","run-once":true,"schedule":"0 3 * * *"}
]}`)
if _, err := ParseManifest(bad); err == nil {
t.Error("a container that was both run-once and scheduled was accepted")
} else if !strings.Contains(err.Error(), "both run-once and schedule") {
t.Errorf("refused for the wrong reason: %v", err)
}
}