The recurring twin of run-once, one modifier over: a container marked schedule: "<cron>" is run to completion on its cadence, not started as a service and not run once as a gate. The gating rule is deliberately reversed. Installing a schedule records it as present state and reports the node current at once (applySchedule) -- it never runs the container and does not gate what follows. A Scheduler, held for the life of the daemon and re-established from each applied declaration (the declaration is the source of truth, ADR 0018), fires the container off an injected clock. A run that exits non-zero is logged and never fails the apply or flips the node's state, because it happens outside the apply and the store entirely. Runs never stack: a run still going when the next is due is skipped, not started as a second copy. No new host shape and no new action -- schedule is a string on the container the host already has, and the host process runs the container itself rather than installing a system timer (the rejected option 1). A minimal five-field cron (declaration/cron.go) validates on arrival and computes the next due minute; time is injected so the scheduler is tested without the wall clock. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
54 lines
2.0 KiB
Go
54 lines
2.0 KiB
Go
package declaration
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// The host refuses a schedule it does not fully understand, on arrival, for the same reason the
|
|
// control plane refuses it near its author (novox/hq ADR 0053): a declaration half-understood is
|
|
// refused whole rather than half-applied.
|
|
|
|
func schedulePinned() string { return "registry.example/runtime@sha256:" + strings.Repeat("a", 64) }
|
|
|
|
func TestAScheduledContainerCarriesTheCronField(t *testing.T) {
|
|
d, err := Parse([]byte(`{"declaration":1,"resources":[
|
|
{"id":"sync","type":"container","name":"sync","image":"` + schedulePinned() + `","schedule":"0 3 * * *"}
|
|
]}`))
|
|
if err != nil {
|
|
t.Fatalf("a valid scheduled container was refused: %v", err)
|
|
}
|
|
c, ok := d.Resources[0].(*Container)
|
|
if !ok {
|
|
t.Fatalf("the scheduled resource is not a container: %T", d.Resources[0])
|
|
}
|
|
if c.Schedule != "0 3 * * *" {
|
|
t.Errorf("the schedule did not survive parsing: %q", c.Schedule)
|
|
}
|
|
}
|
|
|
|
func TestAMalformedScheduleIsRefused(t *testing.T) {
|
|
_, err := Parse([]byte(`{"declaration":1,"resources":[
|
|
{"id":"sync","type":"container","name":"sync","image":"` + schedulePinned() + `","schedule":"every night"}
|
|
]}`))
|
|
if err == nil {
|
|
t.Fatal("a container with a malformed schedule was accepted")
|
|
}
|
|
if !strings.Contains(err.Error(), "cron") && !strings.Contains(err.Error(), "field") {
|
|
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).
|
|
_, err := Parse([]byte(`{"declaration":1,"resources":[
|
|
{"id":"sync","type":"container","name":"sync","image":"` + schedulePinned() + `","run-once":true,"schedule":"0 3 * * *"}
|
|
]}`))
|
|
if err == nil {
|
|
t.Fatal("a container that was both run-once and scheduled was accepted")
|
|
}
|
|
if !strings.Contains(err.Error(), "run-once") && !strings.Contains(err.Error(), "scheduled") {
|
|
t.Errorf("refused for the wrong reason: %v", err)
|
|
}
|
|
}
|