Files
mesh-catalog/modules/pacman/cmd/pacman-tools/rounds_test.go
T

306 lines
11 KiB
Go

package main
// The week's round (novox/hq ADR 0508 §5, §6; acceptance 1841/AC6 and 1841/AC7), for three made-up nodes over
// states in memory.
import (
"encoding/json"
"strings"
"testing"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// roundBed is three keepers over one bed's states, each with the order node-a, node-b, node-c.
type roundBed struct {
*keeperBed
nodes map[string]*Upgrades
}
func newRoundBed(t *testing.T, order ...string) *roundBed {
k := newKeeperBed(t)
k.now = time.Date(2026, 10, 24, 3, 0, 0, 0, time.UTC) // a Saturday
b := &roundBed{keeperBed: k, nodes: map[string]*Upgrades{}}
var updates UpdatesValue
k.updates.value("node-a", &updates)
for _, n := range []string{"node-a", "node-b", "node-c"} {
u := k.keeper(k.u.Machine)
u.Node, u.Config.UpgradeOrder, u.Config.RoundStartMinutes = n, order, 120
if _, err := k.installed.Put(n, nodeAPackages); err != nil {
t.Fatal(err)
}
if _, err := k.updates.Put(n, updates); err != nil {
t.Fatal(err)
}
b.nodes[n] = u
}
return b
}
func (b *roundBed) step(t *testing.T, node string) time.Time {
t.Helper()
next, err := b.nodes[node].RoundStep()
if err != nil {
t.Fatalf("%s: %v", node, err)
}
return next
}
// end ends node's running transaction as unitState says and follows it.
func (b *roundBed) end(t *testing.T, node, unitState string) {
t.Helper()
b.unitState = unitState
if err := b.nodes[node].Follow(); err != nil {
t.Fatal(err)
}
b.now = b.now.Add(5 * time.Minute)
}
func (b *roundBed) runsOf(node string) []Run {
keys, _ := b.runs.Keys()
var out []Run
for _, k := range keys {
if strings.HasPrefix(k, node+".") {
var r Run
b.runs.value(k, &r)
out = append(out, r)
}
}
return out
}
func (b *roundBed) state(t *testing.T) map[string]NodeInRound {
t.Helper()
v, err := readRound(b.rounds, b.runs, "2026-W43")
if err != nil || v == nil {
t.Fatalf("round: %v", err)
}
out := map[string]NodeInRound{}
for _, s := range RoundState(v, b.now, 120) {
out[s.Node] = s
}
return out
}
func TestTheRoundOpensSaturdayAtFourAndThePassedCheckGivesTheNextItsTurn(t *testing.T) {
if o := RoundOpening(time.Date(2026, 10, 22, 15, 0, 0, 0, time.UTC)); !o.Equal(time.Date(2026, 10, 24, 4, 0, 0, 0, time.UTC)) {
t.Fatalf("the opening of the week of Thursday 22 October: %s", o)
}
b := newRoundBed(t, "node-a", "node-b", "node-c")
if next := b.step(t, "node-a"); !next.Equal(time.Date(2026, 10, 24, 4, 0, 0, 0, time.UTC)) {
t.Errorf("before 04:00 the first node waits for 04:00: %s", next)
}
if keys, _ := b.rounds.Keys(); len(keys) != 0 {
t.Fatalf("a round before 04:00: %v", keys)
}
b.step(t, "node-b")
if keys, _ := b.rounds.Keys(); len(keys) != 0 {
t.Fatalf("only the first of the order opens the round: %v", keys)
}
b.now = time.Date(2026, 10, 24, 4, 0, 0, 0, time.UTC)
if next := b.step(t, "node-a"); !next.Equal(time.Date(2026, 10, 31, 4, 0, 0, 0, time.UTC)) {
t.Errorf("after opening, next Saturday: %s", next)
}
var round Round
if !b.rounds.value("2026-W43", &round) || round.OpenedBy != "node-a" || strings.Join(round.Order, " ") != "node-a node-b node-c" {
t.Fatalf("the round: %+v", round)
}
if r := b.runsOf("node-a"); len(r) != 1 || r[0].Round != "2026-W43" || r[0].From != FromRound || lastStep(r[0]) != StepStarted {
t.Fatalf("the first node starts its run in the round: %+v", r)
}
b.now = b.now.Add(time.Minute)
b.step(t, "node-a")
if w := b.rounds.writesTo("2026-W43"); len(w) != 2 || len(b.runsOf("node-a")) != 1 {
t.Errorf("the round is created once and the run started once: %v", w)
}
b.step(t, "node-b")
if len(b.runsOf("node-b")) != 0 || b.state(t)["node-b"].State != InRoundWaiting {
t.Fatalf("node-b waits while node-a runs: %+v", b.state(t)["node-b"])
}
b.end(t, "node-a", "LoadState=not-found\n")
if s := b.state(t)["node-a"]; s.State != InRoundPassed {
t.Fatalf("node-a: %+v", s)
}
b.step(t, "node-c")
if len(b.runsOf("node-c")) != 0 {
t.Fatal("node-c started before node-b")
}
b.step(t, "node-b")
if r := b.runsOf("node-b"); len(r) != 1 || r[0].Round != "2026-W43" {
t.Fatalf("node-b's turn after node-a's check passed: %+v", r)
}
// node-b's transaction fails: the round stops at node-b, and node-c waits.
b.end(t, "node-b", "LoadState=loaded\nActiveState=failed\nResult=exit-code\nExecMainStatus=1\n")
b.step(t, "node-c")
st := b.state(t)
if len(b.runsOf("node-c")) != 0 || st["node-b"].State != InRoundFailed || st["node-c"].State != InRoundWaiting || st["node-c"].WaitsFor != "node-b" {
t.Errorf("a failed check stops the round: %+v %+v", st["node-b"], st["node-c"])
}
}
func TestNoOrderNoRound(t *testing.T) {
b := newRoundBed(t)
b.now = time.Date(2026, 10, 24, 5, 0, 0, 0, time.UTC)
if next := b.step(t, "node-a"); !next.IsZero() {
t.Errorf("no wake without an order: %s", next)
}
if keys, _ := b.rounds.Keys(); len(keys) != 0 || len(b.runsOf("node-a")) != 0 {
t.Errorf("a round without an order: %v", keys)
}
}
func TestANodeOffAtItsTurnRunsAtItsNextStartAndAStalledTurnIsSaid(t *testing.T) {
b := newRoundBed(t, "node-a", "node-b", "node-c")
b.now = time.Date(2026, 10, 24, 4, 0, 0, 0, time.UTC)
b.step(t, "node-a")
b.end(t, "node-a", "LoadState=not-found\n")
passed := b.now.Add(-5 * time.Minute)
b.now = passed.Add(121 * time.Minute) // node-b is off
st := b.state(t)
if st["node-b"].State != InRoundStalled || st["node-b"].Since == nil || !st["node-b"].Since.Equal(passed) {
t.Errorf("node-b's turn came at %s and it has not started: %+v", passed, st["node-b"])
}
if st["node-c"].WaitsFor != "node-b" || st["node-c"].State != InRoundWaiting {
t.Errorf("node-c names the node it waits for: %+v", st["node-c"])
}
b.step(t, "node-b") // node-b starts again
if len(b.runsOf("node-b")) != 1 {
t.Error("a node off at its turn runs at its next start")
}
}
// warrant is the operator channel's record of an ask answered with option.
func warrant(a asks.Ask, option string, level asks.Level, at time.Time) json.RawMessage {
w := asks.Warrant{Ask: a.ID, Asker: Module, Outcome: asks.OutcomeChosen, Option: option, Level: level,
By: &asks.Person{Who: asks.Operator, Kind: "made-up-channel", Identity: "1", Verified: "user id"}, At: at, AskDigest: a.Digest()}
raw, _ := json.Marshal(map[string]any{"state": "chosen", "warrant": w})
return raw
}
func TestOnlyTheOperatorsApprovePassesANodeAndAPassNeedsAReason(t *testing.T) {
b := newRoundBed(t, "node-a", "node-b", "node-c")
b.now = time.Date(2026, 10, 24, 4, 0, 0, 0, time.UTC)
b.step(t, "node-a")
b.end(t, "node-a", "LoadState=not-found\n")
b.step(t, "node-b")
b.end(t, "node-b", "LoadState=loaded\nActiveState=failed\nResult=exit-code\nExecMainStatus=1\n")
var published []asks.Ask
var subjects []string
saved := seatPublish
seatPublish = func(subject string, body any, id string) error {
subjects, published = append(subjects, subject), append(published, body.(asks.Ask))
return nil
}
defer func() { seatPublish = saved }()
if _, err := AskPass(b.rounds, b.runs, b.asks, "", "node-b", " ", b.now); err == nil || len(published) != 0 {
t.Fatalf("a pass without a reason: %v", err)
}
out, err := AskPass(b.rounds, b.runs, b.asks, "", "node-b", "its disk is being replaced", b.now)
if err != nil {
t.Fatal(err)
}
if len(published) != 1 || subjects[0] != "mesh.seat.operator-channel.accept.ask.pacman" || out["waiting"] != "node-c" {
t.Fatalf("the operator is asked on their channel: %v %v", subjects, out)
}
opAsk := published[0]
if err := opAsk.Check(b.now); err != nil || !strings.Contains(opAsk.Explanation, "its disk is being replaced") {
t.Errorf("the ask: %v %+v", err, opAsk)
}
keys, _ := b.asks.Keys()
if len(keys) != 1 || keys[0] != NodeKey("node-c", opAsk.ID) {
t.Fatalf("the ask for node-c's keeper: %v", keys)
}
var a Ask
b.asks.value(keys[0], &a)
raw, _ := json.Marshal(a)
if err := b.nodes["node-c"].TakeAsk(stdio.StateChange{State: StateAsks, Key: keys[0], Op: "put", Value: raw}); err != nil {
t.Fatal(err)
}
if st := b.state(t)["node-b"]; st.Pass == nil || st.Pass.Reason != "its disk is being replaced" || st.Pass.Answer != "" {
t.Fatalf("the pass waits in the round: %+v", st.Pass)
}
record := map[string]json.RawMessage{}
b.nodes["node-c"].AskRecord = func(id string) (*stdio.StateEntry, error) {
if v, ok := record[id]; ok {
return &stdio.StateEntry{Key: Module + "." + id, Value: v}, nil
}
return nil, nil
}
if next := b.step(t, "node-c"); len(b.runsOf("node-c")) != 0 || !next.Equal(b.now.Add(passCheckEvery)) {
t.Fatalf("no answer yet: node-c waits and looks again in a minute (%s)", next)
}
// An answer to another ask, or to this one as it was not shown, passes nothing.
other := opAsk
other.Explanation = "something else"
record[opAsk.ID] = warrant(other, "pass", asks.Approve, b.now)
if _, err := b.nodes["node-c"].RoundStep(); err == nil || len(b.runsOf("node-c")) != 0 {
t.Fatalf("a warrant for another ask: %v", err)
}
record[opAsk.ID] = warrant(opAsk, "pass", asks.Approve, b.now)
b.step(t, "node-c")
if st := b.state(t)["node-b"]; st.State != InRoundPassed || st.Pass.Answer != PassApproved {
t.Errorf("the operator's Approve passes node-b: %+v", st)
}
if r := b.runsOf("node-c"); len(r) != 1 || r[0].Round != "2026-W43" {
t.Errorf("node-c's turn after the pass: %+v", r)
}
}
func TestAPassTheOperatorKeepsWaitingPassesNothing(t *testing.T) {
b := newRoundBed(t, "node-a", "node-b")
b.now = time.Date(2026, 10, 24, 4, 0, 0, 0, time.UTC)
b.step(t, "node-a") // node-a runs and never ends
saved := seatPublish
var opAsk asks.Ask
seatPublish = func(_ string, body any, _ string) error { opAsk = body.(asks.Ask); return nil }
defer func() { seatPublish = saved }()
if _, err := AskPass(b.rounds, b.runs, b.asks, "2026-W43", "node-a", "it hangs", b.now); err != nil {
t.Fatal(err)
}
keys, _ := b.asks.Keys()
var a Ask
b.asks.value(keys[0], &a)
raw, _ := json.Marshal(a)
if err := b.nodes["node-b"].TakeAsk(stdio.StateChange{State: StateAsks, Key: keys[0], Op: "put", Value: raw}); err != nil {
t.Fatal(err)
}
b.nodes["node-b"].AskRecord = func(string) (*stdio.StateEntry, error) {
return &stdio.StateEntry{Value: warrant(opAsk, "keep", asks.Acknowledge, b.now)}, nil
}
b.step(t, "node-b")
if st := b.state(t); st["node-a"].Pass.Answer != PassDeclined || len(b.runsOf("node-b")) != 0 {
t.Errorf("kept waiting: %+v", st["node-a"])
}
if _, err := AskPass(b.rounds, b.runs, b.asks, "2026-W43", "node-b", "x", b.now); err == nil {
t.Error("the last of the order has no node waiting on it")
}
if _, err := AskPass(b.rounds, b.runs, b.asks, "2026-W43", "node-z", "x", b.now); err == nil {
t.Error("a node not in the order")
}
}
func TestPacmanRoundReadsTheRounds(t *testing.T) {
b := newRoundBed(t, "node-a", "node-b")
b.now = time.Date(2026, 10, 24, 4, 0, 0, 0, time.UTC)
b.step(t, "node-a")
out, err := ListRounds(b.rounds, b.runs, "", b.now, 120, 4)
if err != nil {
t.Fatal(err)
}
listed := out["rounds"].([]map[string]any)
nodes := listed[0]["nodes"].([]NodeInRound)
if len(listed) != 1 || listed[0]["round"] != "2026-W43" || nodes[0].State != InRoundRunning || nodes[1].WaitsFor != "node-a" {
t.Errorf("%+v", listed)
}
if _, err := ListRounds(b.rounds, b.runs, "2026-W40", b.now, 120, 4); err == nil {
t.Error("a round that is not there")
}
}