|
|
|
@@ -324,3 +324,110 @@ func TestBusyIsReadAndHeldToItsShape(t *testing.T) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A replacement owed to a restart-on trigger is still owed after the wait: a trigger that is no file or
|
|
|
|
|
// directory (a service, an identity's or a library's change) is in no container's spec and counts as changed only
|
|
|
|
|
// in the pass it changed, so the container must not read unchanged at the next pass and drop from the report as if
|
|
|
|
|
// replaced (novox/hq ADR 0335).
|
|
|
|
|
func TestAReplacementOwedToARestartTriggerIsMadeOnceIdle(t *testing.T) {
|
|
|
|
|
now := time.Date(2026, 10, 13, 1, 0, 0, 0, time.UTC)
|
|
|
|
|
atClock(t, &now)
|
|
|
|
|
d := parse(t, `{"declaration":1,"resources":[
|
|
|
|
|
{"id":"build-agent.server","type":"container","name":"mesh-build-agent","image":"`+pinned+`",
|
|
|
|
|
"restart-on":["identity:build-agent"],"busy":{"command":"mesh-builder busy"}}
|
|
|
|
|
]}`)
|
|
|
|
|
r := d.Resources[0].(*declaration.Container)
|
|
|
|
|
in := inputs{declares: map[string]string{}}
|
|
|
|
|
m := newBusyMachine()
|
|
|
|
|
m.containers["mesh-build-agent"] = containerSpec(r, in)
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
|
|
|
|
|
// The trigger moved this pass, and the agent holds a build: left running.
|
|
|
|
|
m.answer(true, "building")
|
|
|
|
|
out, err := applyContainer(ctx, r, m.run, map[string]bool{"identity:build-agent": true}, in, store.Applied{})
|
|
|
|
|
if err != nil || out.Action != waitsBusy || out.busySince == nil {
|
|
|
|
|
t.Fatalf("a busy container was not left running for its trigger: %+v %v", out, err)
|
|
|
|
|
}
|
|
|
|
|
record := store.Applied{ID: r.ID, Target: r.Name, BusySince: out.busySince}
|
|
|
|
|
|
|
|
|
|
// The next pass: nothing triggers now, and the replacement is still owed.
|
|
|
|
|
now = now.Add(time.Minute)
|
|
|
|
|
m.answer(false, "idle")
|
|
|
|
|
mark := m.mark()
|
|
|
|
|
out, err = applyContainer(ctx, r, m.run, map[string]bool{}, in, record)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if got := m.since(mark); !slices.Equal(got, []string{"ask mesh-build-agent", "rm mesh-build-agent", "create mesh-build-agent"}) {
|
|
|
|
|
t.Fatalf("the replacement owed to the trigger was dropped: %v (%+v)", got, out)
|
|
|
|
|
}
|
|
|
|
|
if out.busy == nil || out.busy.State != BusyReplacedIdle || out.busySince != nil {
|
|
|
|
|
t.Fatalf("the replacement does not say it was made once idle: %+v", out)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A wait kept from a clock that later went back is never longer than the bound from now.
|
|
|
|
|
func TestAWaitFromAClockThatWentBackIsClampedToNow(t *testing.T) {
|
|
|
|
|
now := time.Date(2026, 10, 13, 1, 0, 0, 0, time.UTC)
|
|
|
|
|
atClock(t, &now)
|
|
|
|
|
m := newBusyMachine()
|
|
|
|
|
_, state := applyBusy(t, m, twoContainers(t, "1", "1"), store.State{})
|
|
|
|
|
m.answer(true, "building")
|
|
|
|
|
_, state = applyBusy(t, m, twoContainers(t, "2", "1"), state)
|
|
|
|
|
now = now.Add(-time.Hour) // the clock went back an hour
|
|
|
|
|
report, _ := applyBusy(t, m, twoContainers(t, "2", "1"), state)
|
|
|
|
|
b, _ := busyOf(report, "build-agent.server")
|
|
|
|
|
if b.State != BusyWaiting || b.Since.After(now) || !b.Until.Equal(now.Add(15*time.Minute)) {
|
|
|
|
|
t.Fatalf("a wait from a later clock is not clamped to now: %+v (now %s)", b, now)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// An ask that fails — no shell in the image, a timeout, a paused container — is not busy: replaced at once.
|
|
|
|
|
func TestAnAskThatFailsIsIdle(t *testing.T) {
|
|
|
|
|
m := newBusyMachine()
|
|
|
|
|
_, state := applyBusy(t, m, twoContainers(t, "1", "1"), store.State{})
|
|
|
|
|
failing := func(ctx context.Context, name string, args ...string) (string, error) {
|
|
|
|
|
if args[0] == "exec" {
|
|
|
|
|
m.mu.Lock()
|
|
|
|
|
m.calls = append(m.calls, "ask "+args[1])
|
|
|
|
|
m.mu.Unlock()
|
|
|
|
|
return "", errors.New("OCI runtime exec failed: exec: \"/bin/sh\": no such file or directory")
|
|
|
|
|
}
|
|
|
|
|
return m.run(ctx, name, args...)
|
|
|
|
|
}
|
|
|
|
|
mark := m.mark()
|
|
|
|
|
report, _, err := Apply(context.Background(), archHost(t), twoContainers(t, "2", "1"), state,
|
|
|
|
|
store.OriginDeclared, failing, nil, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if got := m.since(mark); !slices.Equal(got, []string{"ask mesh-build-agent", "rm mesh-build-agent", "create mesh-build-agent"}) {
|
|
|
|
|
t.Fatalf("an ask that failed held the container: %v", got)
|
|
|
|
|
}
|
|
|
|
|
if len(report.Busy) != 0 {
|
|
|
|
|
t.Errorf("an ask that failed is said as a wait: %+v", report.Busy)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A stopped container holds no work: it is replaced without being asked.
|
|
|
|
|
func TestAStoppedContainerIsNotAsked(t *testing.T) {
|
|
|
|
|
m := newBusyMachine()
|
|
|
|
|
_, state := applyBusy(t, m, twoContainers(t, "1", "1"), store.State{})
|
|
|
|
|
m.answer(true, "would say busy")
|
|
|
|
|
stopped := func(ctx context.Context, name string, args ...string) (string, error) {
|
|
|
|
|
if args[0] == "container" && args[len(args)-1] == "mesh-build-agent" {
|
|
|
|
|
out, err := m.run(ctx, name, args...)
|
|
|
|
|
return strings.Replace(out, "true\t", "false\t", 1), err
|
|
|
|
|
}
|
|
|
|
|
return m.run(ctx, name, args...)
|
|
|
|
|
}
|
|
|
|
|
mark := m.mark()
|
|
|
|
|
if _, _, err := Apply(context.Background(), archHost(t), twoContainers(t, "2", "1"), state,
|
|
|
|
|
store.OriginDeclared, stopped, nil, nil); err != nil && !strings.Contains(err.Error(), "not running") {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
got := m.since(mark)
|
|
|
|
|
if slices.Contains(got, "ask mesh-build-agent") || !slices.Contains(got, "rm mesh-build-agent") {
|
|
|
|
|
t.Fatalf("a stopped container was asked, or not replaced: %v", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|