Files
mesh-controller/internal/inventory/doing_test.go
T
jschoubben 5a28434ba8 "Behind" means not running what the mesh would send
It meant "failed or refused". So a machine that applied cleanly and whose
declaration has since changed was not behind — and novox/hq ADR 0010's
question, did my change go out?, was answerable exactly for the machines that
broke. For every machine that worked, the answer was silence whether the change
had gone out or not, which is the thing replacing a pipeline was supposed not
to cost.

The mesh now records a digest of what it last sent each machine. A digest
rather than the declaration: it can compute what a machine should be at any
moment, and keeping a copy would be a second account of it able to disagree
with the first. What cannot be recomputed is what was actually sent.

Recorded after the send, not before — a digest kept for something that failed
to send would make the machine look current for a declaration it never
received.

Never told stays separate from out of date. The remedy is the same push and the
situations are not alike: nobody has ever asked that machine to be anything.
And a machine the mesh could not work out is not reported as waiting, because
saying so would invent a comparison — that is `plan`'s answer to give.

`status` says it and `push --behind` sends it, or the flag would know something
the person reading the status does not.
2026-08-31 05:17:21 +02:00

248 lines
7.7 KiB
Go

package inventory
import (
"context"
"strings"
"testing"
)
// What each machine did with what it was last sent.
//
// Until this, a refusal or a failure moved last_seen and the reason went to a log line — so
// "which machine is not doing what it was told" had no answer the next morning, which is the
// question a mesh exists to answer.
func nodeNamed(t *testing.T, inv *Inventory, name string) string {
t.Helper()
n, err := inv.AddNode(context.Background(), name)
if err != nil {
t.Fatal(err)
}
return n.ID
}
func TestRefusedAndFailedAreDifferentSituations(t *testing.T) {
// Refused means the machine is exactly as it was, and what is wrong is in what was sent.
// Failed means it is in a state nobody declared, and what is wrong is on the machine. One
// word for both would make the report say less than the node did.
inv := fresh(t)
ctx := context.Background()
refuser := nodeNamed(t, inv, "refuser")
failer := nodeNamed(t, inv, "failer")
if err := inv.RecordDoing(ctx, refuser, Doing{
Outcome: OutcomeRefused, Refused: "resource \"x\": a file needs a path",
}); err != nil {
t.Fatal(err)
}
if err := inv.RecordDoing(ctx, failer, Doing{
Outcome: OutcomeFailed,
Failed: []FailedResource{{ID: "svc", Error: "unit not found"}},
Applied: 4,
}); err != nil {
t.Fatal(err)
}
wrong, err := inv.NotDoingWhatTheyWereTold(ctx)
if err != nil {
t.Fatal(err)
}
if len(wrong) != 2 {
t.Fatalf("got %d", len(wrong))
}
by := map[string]Doing{}
for _, d := range wrong {
by[d.Node] = d
}
if by["refuser"].Outcome != OutcomeRefused || !strings.Contains(by["refuser"].Refused, "needs a path") {
t.Fatalf("got %+v", by["refuser"])
}
if by["failer"].Outcome != OutcomeFailed || len(by["failer"].Failed) != 1 {
t.Fatalf("got %+v", by["failer"])
}
// And how much DID work, because "four of five" and "none of five" are different machines.
if by["failer"].Applied != 4 {
t.Fatalf("what did apply was not kept: %+v", by["failer"])
}
}
func TestAMachineDoingWhatItWasToldIsNotOnTheList(t *testing.T) {
inv := fresh(t)
ctx := context.Background()
id := nodeNamed(t, inv, "fine")
if err := inv.RecordDoing(ctx, id, Doing{Outcome: OutcomeApplied, Applied: 6}); err != nil {
t.Fatal(err)
}
wrong, err := inv.NotDoingWhatTheyWereTold(ctx)
if err != nil {
t.Fatal(err)
}
if len(wrong) != 0 {
t.Fatalf("a machine that did as it was told is listed as wrong: %+v", wrong)
}
// It is still answerable about, which is a different question.
d, said, err := inv.DoingOf(ctx, "fine")
if err != nil || !said {
t.Fatalf("said=%v err=%v", said, err)
}
if d.Wrong() || d.Applied != 6 {
t.Fatalf("got %+v", d)
}
}
func TestTheLastReportReplacesTheOneBefore(t *testing.T) {
// The question is the machine's CURRENT state. "This failed an hour ago and then succeeded"
// is not a machine anybody needs to look at, and a table of every report would bury the ones
// that matter under the ones that do not.
inv := fresh(t)
ctx := context.Background()
id := nodeNamed(t, inv, "recovered")
if err := inv.RecordDoing(ctx, id, Doing{
Outcome: OutcomeFailed, Failed: []FailedResource{{ID: "a", Error: "no"}},
}); err != nil {
t.Fatal(err)
}
if err := inv.RecordDoing(ctx, id, Doing{Outcome: OutcomeApplied, Applied: 3}); err != nil {
t.Fatal(err)
}
wrong, err := inv.NotDoingWhatTheyWereTold(ctx)
if err != nil {
t.Fatal(err)
}
if len(wrong) != 0 {
t.Fatalf("a machine that recovered is still listed as wrong: %+v", wrong)
}
d, _, _ := inv.DoingOf(ctx, "recovered")
if len(d.Failed) != 0 {
t.Fatalf("the old failure survived: %+v", d)
}
}
func TestAMachineThatHasSaidNothingIsNotWrong(t *testing.T) {
// It may be new, switched off, or unreachable. None of those is a machine that tried and
// could not, and treating silence as failure would have somebody debug a machine that has
// simply never been sent anything.
inv := fresh(t)
ctx := context.Background()
nodeNamed(t, inv, "silent")
wrong, err := inv.NotDoingWhatTheyWereTold(ctx)
if err != nil {
t.Fatal(err)
}
if len(wrong) != 0 {
t.Fatalf("silence was read as failure: %+v", wrong)
}
if _, said, err := inv.DoingOf(ctx, "silent"); err != nil || said {
t.Fatalf("a machine that never reported was reported about: said=%v err=%v", said, err)
}
}
func TestWhatANodeSaidGoesWhenTheNodeDoes(t *testing.T) {
inv := fresh(t)
ctx := context.Background()
id := nodeNamed(t, inv, "leaving")
if err := inv.RecordDoing(ctx, id, Doing{Outcome: OutcomeFailed}); err != nil {
t.Fatal(err)
}
if _, err := inv.store.Pool().Exec(ctx, `delete from node where name = 'leaving'`); err != nil {
t.Fatal(err)
}
var left int
if err := inv.store.Pool().QueryRow(ctx, `select count(*) from node_report`).Scan(&left); err != nil {
t.Fatal(err)
}
if left != 0 {
t.Fatalf("%d report(s) outlived the machine", left)
}
}
// "Behind" must mean not running what the mesh would send, not only "failed".
//
// novox/hq ADR 0010 names losing "did my change go out?" as the real risk of replacing a pipeline
// with a comparison. With behind meaning only failed-or-refused, that question was answerable
// exactly for the machines that broke — and for every machine that worked, the answer was silence
// whether the change had gone out or not.
func TestAMachineIsWaitingWhenWhatItWasSentIsNotWhatItShouldBe(t *testing.T) {
inv := ForTest(t)
ctx := t.Context()
anchor, err := inv.AddNode(ctx, "anchor")
if err != nil {
t.Fatal(err)
}
if _, err := inv.AddNode(ctx, "laptop"); err != nil {
t.Fatal(err)
}
// Never sent anything: waiting, and said differently. Nobody has ever asked it to be
// anything, which is not the same as it being out of date.
waiting, err := inv.Waiting(ctx, map[string]string{"anchor": "aaa", "laptop": "bbb"})
if err != nil {
t.Fatal(err)
}
if len(waiting) != 2 {
t.Fatalf("machines that were never sent anything are not waiting: %+v", waiting)
}
for _, m := range waiting {
if !m.Never {
t.Fatalf("%s was never sent anything and does not say so: %+v", m.Node, m)
}
}
// Sent what it should be: not waiting.
if err := inv.RecordSent(ctx, anchor.ID, "aaa"); err != nil {
t.Fatal(err)
}
waiting, err = inv.Waiting(ctx, map[string]string{"anchor": "aaa", "laptop": "bbb"})
if err != nil {
t.Fatal(err)
}
if len(waiting) != 1 || waiting[0].Node != "laptop" {
t.Fatalf("a machine sent exactly what it should be is still waiting: %+v", waiting)
}
// The declaration changes: waiting again, and no longer "never".
waiting, err = inv.Waiting(ctx, map[string]string{"anchor": "ccc", "laptop": "bbb"})
if err != nil {
t.Fatal(err)
}
var found bool
for _, m := range waiting {
if m.Node != "anchor" {
continue
}
found = true
if m.Never {
t.Fatal("a machine that has been sent something is reported as never told")
}
if m.Sent != "aaa" {
t.Fatalf("what it was last sent was lost: %+v", m)
}
}
if !found {
t.Fatal("a machine whose declaration changed since it was sent is not waiting")
}
}
// A machine nobody worked out is not reported as waiting: saying so would invent a comparison.
func TestAMachineWithNothingComputedForItIsNotWaiting(t *testing.T) {
inv := ForTest(t)
ctx := t.Context()
node, err := inv.AddNode(ctx, "unresolvable")
if err != nil {
t.Fatal(err)
}
// It has been sent something before, which is what makes this the case the guard is for: a
// machine with a digest and nothing computed for it would compare against the empty string
// and look out of date, when the truth is that nobody worked out what it should be.
if err := inv.RecordSent(ctx, node.ID, "what-it-got-last-time"); err != nil {
t.Fatal(err)
}
waiting, err := inv.Waiting(ctx, map[string]string{})
if err != nil {
t.Fatal(err)
}
if len(waiting) != 0 {
t.Fatalf("a machine the caller could not work out was reported as waiting: %+v", waiting)
}
}