A first machine that does not report within the bound now stops the module's rollout, naming it. The first machine is the first by name heard from lately; reports are judged by what the store says was last sent. A plan that ends says what it built and never sent, and a failed send's error is kept in the plan's note.
153 lines
6.9 KiB
Go
153 lines
6.9 KiB
Go
package main
|
|
|
|
import (
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-controller/internal/inventory"
|
|
)
|
|
|
|
// novox/hq issue 249, ADR 0218: a plan rolls a module out to one machine first and the rest only
|
|
// once that machine has reported it applied; a module whose policy says together goes everywhere at
|
|
// once, as before.
|
|
func TestAPlanSendsOneMachineFirstAndTheRestAfterItsReport(t *testing.T) {
|
|
running := []string{"novox", "ace", "g14"}
|
|
sentAt := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
|
|
now := sentAt.Add(5 * time.Minute)
|
|
bound := 30 * time.Minute
|
|
after := sentAt.Add(time.Minute)
|
|
next := func(s inventory.PlanModule, running []string, together bool, reports []inventory.Reported) rolloutStep {
|
|
return nextRollout(s, running, together, reports, now, bound)
|
|
}
|
|
|
|
// Together: every machine at once.
|
|
if step := next(inventory.PlanModule{}, running, true, nil); !reflect.DeepEqual(step.send, running) || step.first {
|
|
t.Fatalf("a together policy did not send every machine at once: %+v", step)
|
|
}
|
|
|
|
// Otherwise the first by name, alone, when none has reported lately.
|
|
step := next(inventory.PlanModule{}, running, false, nil)
|
|
if !step.first || !reflect.DeepEqual(step.send, []string{"ace"}) {
|
|
t.Fatalf("the first send was %+v, wanted ace alone", step)
|
|
}
|
|
|
|
state := inventory.PlanModule{First: []string{"ace"}, FirstAt: &sentAt}
|
|
report := func(outcome string, current bool) []inventory.Reported {
|
|
return []inventory.Reported{{Node: "ace", At: &after, Outcome: outcome, Current: current},
|
|
{Node: "g14", At: &after, Outcome: inventory.OutcomeApplied, Current: true}}
|
|
}
|
|
|
|
// No report yet, or one about an older declaration than it was last sent: wait.
|
|
for what, reports := range map[string][]inventory.Reported{
|
|
"no report": nil,
|
|
"a report about older": report(inventory.OutcomeApplied, false),
|
|
} {
|
|
step := next(state, running, false, reports)
|
|
if len(step.send) != 0 || step.waiting != "ace" || step.failed != "" {
|
|
t.Errorf("%s: %+v, wanted to wait for ace", what, step)
|
|
}
|
|
}
|
|
|
|
// Applied what it was last sent: the rest, and only the rest.
|
|
step = next(state, running, false, report(inventory.OutcomeApplied, true))
|
|
if step.first || !reflect.DeepEqual(step.send, []string{"novox", "g14"}) {
|
|
t.Fatalf("after ace applied it the plan sent %+v, wanted novox and g14", step)
|
|
}
|
|
|
|
// Failed or refused: stop, the rest untouched.
|
|
for _, outcome := range []string{inventory.OutcomeFailed, inventory.OutcomeRefused} {
|
|
step := next(state, running, false, report(outcome, true))
|
|
if len(step.send) != 0 || !strings.Contains(step.failed, "ace "+outcome) ||
|
|
!reflect.DeepEqual(step.rest, []string{"novox", "g14"}) {
|
|
t.Errorf("a first machine that %s it: %+v", outcome, step)
|
|
}
|
|
}
|
|
|
|
// The machine holding the bus went with the first send: the rest wait for it too, and it is
|
|
// not sent again.
|
|
both := inventory.PlanModule{First: []string{"novox", "ace"}, FirstAt: &sentAt}
|
|
half := report(inventory.OutcomeApplied, true)
|
|
if step := next(both, running, false, half); step.waiting != "novox" {
|
|
t.Fatalf("the plan did not wait for the bus's machine sent first: %+v", step)
|
|
}
|
|
all := append(half, inventory.Reported{Node: "novox", At: &after, Outcome: inventory.OutcomeApplied, Current: true})
|
|
if step := next(both, running, false, all); !reflect.DeepEqual(step.send, []string{"g14"}) {
|
|
t.Fatalf("after both applied it the plan sent %+v, wanted g14 alone", step)
|
|
}
|
|
|
|
// One machine, or none: nothing is waited for that cannot come.
|
|
if step := next(inventory.PlanModule{}, nil, false, nil); len(step.send) != 0 || step.first {
|
|
t.Fatalf("a module nothing runs was sent: %+v", step)
|
|
}
|
|
if step := next(state, []string{"ace"}, false, report(inventory.OutcomeApplied, true)); len(step.send) != 0 || step.waiting != "" {
|
|
t.Fatalf("a module on one machine waited for more: %+v", step)
|
|
}
|
|
}
|
|
|
|
// ADR 0218 §2: a first machine that does not report within the bound stops the rollout there,
|
|
// naming the machine and the bound; the rest are left alone.
|
|
func TestAFirstMachineThatDoesNotReportStopsTheRollout(t *testing.T) {
|
|
sentAt := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
|
|
state := inventory.PlanModule{First: []string{"ace"}, FirstAt: &sentAt}
|
|
step := nextRollout(state, []string{"ace", "g14"}, false, nil, sentAt.Add(31*time.Minute), 30*time.Minute)
|
|
if !strings.Contains(step.failed, "ace did not report it applied within 30m") ||
|
|
!reflect.DeepEqual(step.rest, []string{"g14"}) || len(step.send) != 0 {
|
|
t.Fatalf("a silent first machine: %+v", step)
|
|
}
|
|
}
|
|
|
|
// The first machine is the first by name among those heard from lately: a laptop that is away is
|
|
// not the one the rest wait on. When none has been heard from, the first by name.
|
|
func TestTheFirstMachineIsOneThatHasReportedLately(t *testing.T) {
|
|
now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
|
|
lately, long := now.Add(-time.Minute), now.Add(-3*time.Hour)
|
|
reports := []inventory.Reported{
|
|
{Node: "ace", At: &long}, {Node: "g14", At: &lately}, {Node: "novox", At: &lately},
|
|
}
|
|
step := nextRollout(inventory.PlanModule{}, []string{"novox", "ace", "g14"}, false, reports, now, 30*time.Minute)
|
|
if !step.first || !reflect.DeepEqual(step.send, []string{"g14"}) {
|
|
t.Fatalf("the first send was %+v, wanted g14, the first heard from lately", step)
|
|
}
|
|
}
|
|
|
|
// An announced move of a module an open plan is still rolling out is left to the plan: sending it
|
|
// here as well put the bundle on every machine at once (novox/hq issue 249).
|
|
func TestAnAnnouncedMoveIsLeftToThePlanRollingItOut(t *testing.T) {
|
|
sent := time.Now()
|
|
plans := []inventory.Plan{
|
|
{ID: "plan-done", State: inventory.PlanDone, Modules: map[string]*inventory.PlanModule{"agent": {}}},
|
|
{ID: "plan-1", State: inventory.PlanRolling, Modules: map[string]*inventory.PlanModule{
|
|
"agent": {State: "built", First: []string{"ace"}, FirstAt: &sent}, "gitea": {State: "built", SentAt: &sent}}},
|
|
}
|
|
if got := rolledOutByAPlan(plans, "agent"); got != "plan-1" {
|
|
t.Fatalf("a module the plan is rolling out was not left to it: %q", got)
|
|
}
|
|
for _, m := range []string{"gitea", "keycloak"} {
|
|
if got := rolledOutByAPlan(plans, m); got != "" {
|
|
t.Errorf("%s, which no plan will send, was left to %s", m, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A plan that ends without sending what it built says so, with the remedy; a module whose rollout
|
|
// stopped at its first machine is not among them.
|
|
func TestAnEndedPlanSaysWhatItBuiltAndNeverSent(t *testing.T) {
|
|
at := time.Now()
|
|
p := inventory.Plan{State: inventory.PlanFailed, Note: "closed by hand at tier 1",
|
|
Modules: map[string]*inventory.PlanModule{
|
|
"agent": {State: "built"},
|
|
"stopped": {State: "built", First: []string{"ace"}, FirstAt: &at},
|
|
"sent": {State: "built", SentAt: &at},
|
|
"notes": {State: "built"},
|
|
"later": {},
|
|
}}
|
|
rollsOut := func(m string) bool { return m != "notes" }
|
|
sayUnsent(&p, rollsOut)
|
|
sayUnsent(&p, rollsOut)
|
|
if p.Note != "closed by hand at tier 1; built and never sent: agent — `push --behind` sends them" {
|
|
t.Fatalf("the note reads %q", p.Note)
|
|
}
|
|
}
|