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
90 lines
3.5 KiB
Go
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)
|
|
}
|
|
}
|