A node says it is there every minute and describes what it applied rarely, and both went through Heard, which wrote every one down as a report. So a bare alive replaced the node's last real apply with an empty one -- clearing the declaration digest `current` is measured against, the carried ports a push assigns around, and the clean-or-failed outcome. A node that had just caught up read as behind within the minute, and never converged. Whether it converged in time was a race the node's own apply set: the link's one loop applies a declaration to completion before it can send the pending heartbeat, so a fast apply (catalogue-small) leaves the digest standing the ~60s until the next beat -- long enough for the lab to see `current` -- while a heavy wave whose apply outran the first beat (mongodb + unifi + marrytts) had the alive fire milliseconds after the report and never showed `current` at all, timing out settle even at 1200s. Heard now returns after moving last_seen for a report that carries no account of what the machine did -- nothing applied, nothing refused, nothing failed, which is exactly a bare alive. A real report always carries one. This is what the commit that began hearing alives said it did and did not: "a bare word that a node is there moves last_seen and touches nothing else." Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
178 lines
6.0 KiB
Go
178 lines
6.0 KiB
Go
package link_test
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/novox/mesh-control/internal/inventory"
|
|
"github.com/novox/mesh-control/internal/link"
|
|
)
|
|
|
|
// Turning what a node said into what the mesh keeps.
|
|
//
|
|
// This is where "refused" and "failed" become different things. They are different situations
|
|
// with different remedies — one is fixed in what was sent and the other on the machine — and the
|
|
// mapping deciding which is which had no test at all.
|
|
|
|
func heardFrom(t *testing.T, report link.Report) (*inventory.Inventory, inventory.Doing, bool) {
|
|
t.Helper()
|
|
inv := inventory.ForTest(t)
|
|
ctx := context.Background()
|
|
if _, err := inv.AddNode(ctx, report.Node); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := (link.Enrolment{Inventory: inv}).Heard(ctx, report); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
doing, said, err := inv.DoingOf(ctx, report.Node)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return inv, doing, said
|
|
}
|
|
|
|
func TestARefusalIsKeptAsARefusalWithTheNodesOwnWords(t *testing.T) {
|
|
_, doing, said := heardFrom(t, link.Report{
|
|
Node: "workstation", Refused: `resource "conf": a file needs a path`,
|
|
})
|
|
if !said {
|
|
t.Fatal("a refusal was not recorded")
|
|
}
|
|
if doing.Outcome != inventory.OutcomeRefused {
|
|
t.Fatalf("a refusal was recorded as %q", doing.Outcome)
|
|
}
|
|
if !strings.Contains(doing.Refused, "needs a path") {
|
|
// The host says exactly what it could not accept. Anything this end wrote instead would
|
|
// be a second, worse explanation of the same thing.
|
|
t.Fatalf("the node's own words were not kept: %q", doing.Refused)
|
|
}
|
|
}
|
|
|
|
func TestSomeOfItFailingIsNotARefusal(t *testing.T) {
|
|
// Refused means the machine is exactly as it was. Failed means it is in a state nobody
|
|
// declared. Reporting one as the other sends somebody to the wrong place.
|
|
_, doing, _ := heardFrom(t, link.Report{
|
|
Node: "workstation",
|
|
Applied: []string{"a", "b", "c"},
|
|
Failed: map[string]string{"svc": "unit not found", "pkg": "no such package"},
|
|
})
|
|
if doing.Outcome != inventory.OutcomeFailed {
|
|
t.Fatalf("a partial failure was recorded as %q", doing.Outcome)
|
|
}
|
|
if doing.Applied != 3 {
|
|
t.Fatalf("what did apply was not kept: %d", doing.Applied)
|
|
}
|
|
if len(doing.Failed) != 2 {
|
|
t.Fatalf("got %+v", doing.Failed)
|
|
}
|
|
// Ordered, so two readings of one failure are the same reading.
|
|
if doing.Failed[0].ID != "pkg" || doing.Failed[1].ID != "svc" {
|
|
t.Fatalf("failures came back unordered: %+v", doing.Failed)
|
|
}
|
|
}
|
|
|
|
func TestACleanApplyIsRecordedAsOne(t *testing.T) {
|
|
inv, doing, _ := heardFrom(t, link.Report{Node: "workstation", Applied: []string{"a", "b"}})
|
|
if doing.Outcome != inventory.OutcomeApplied || doing.Applied != 2 {
|
|
t.Fatalf("got %+v", doing)
|
|
}
|
|
wrong, err := inv.NotDoingWhatTheyWereTold(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(wrong) != 0 {
|
|
t.Fatalf("a clean apply is listed as wrong: %+v", wrong)
|
|
}
|
|
}
|
|
|
|
func TestABareAliveDoesNotWipeTheDeclarationThatSaysANodeIsCurrent(t *testing.T) {
|
|
// A node reports it is alive every minute and describes what it applied rarely. If a bare
|
|
// alive were written down as a report it would replace the last real apply with an empty one
|
|
// — clearing the declaration digest current is measured against — so a node that had just
|
|
// caught up would read as behind within the minute, and never converge. The lab saw exactly
|
|
// this: a heavy wave whose apply outran the first heartbeat never reached `current`.
|
|
inv := inventory.ForTest(t)
|
|
ctx := context.Background()
|
|
node, err := inv.AddNode(ctx, "anchor")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The mesh sent this node a declaration, and the node applied it and named which by digest.
|
|
const digest = "d640d1b6a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f6071829304152"
|
|
if err := inv.RecordSent(ctx, node.ID, digest); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := (link.Enrolment{Inventory: inv}).Heard(ctx, link.Report{
|
|
Node: "anchor", Applied: []string{"a", "b"}, Declared: digest, Carried: []int{5432},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
currentOf := func(name string) bool {
|
|
reports, err := inv.LastReports(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, r := range reports {
|
|
if r.Node == name {
|
|
return r.Current
|
|
}
|
|
}
|
|
t.Fatalf("no report for %s", name)
|
|
return false
|
|
}
|
|
if !currentOf("anchor") {
|
|
t.Fatal("a node that applied exactly what it was sent does not read as current")
|
|
}
|
|
|
|
// Now the node says only that it is there, as it does every minute.
|
|
if err := (link.Enrolment{Inventory: inv}).Heard(ctx, link.Report{Node: "anchor"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !currentOf("anchor") {
|
|
t.Fatal("a bare alive wiped the declaration digest, so a current node now reads as behind")
|
|
}
|
|
// And the last real account of what it did and holds is untouched.
|
|
doing, said, err := inv.DoingOf(ctx, "anchor")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !said || doing.Outcome != inventory.OutcomeApplied || doing.Applied != 2 {
|
|
t.Fatalf("a bare alive overwrote the last real report: said=%v %+v", said, doing)
|
|
}
|
|
owned, _, err := inv.Owned(ctx, node.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(owned) != 2 {
|
|
t.Fatalf("a bare alive replaced the account of what the machine holds: %v", owned)
|
|
}
|
|
}
|
|
|
|
func TestAFailureDoesNotBecomeTheAccountOfWhatTheMachineHolds(t *testing.T) {
|
|
// A partial list is not an account of what the machine holds. Recording one as though it
|
|
// were would tell a rebuilding node to remove what it still has — which is the fault that
|
|
// destroyed a substrate once (novox/hq 04-ISSUES/010).
|
|
inv := inventory.ForTest(t)
|
|
ctx := context.Background()
|
|
node, err := inv.AddNode(ctx, "workstation")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := inv.RecordOwned(ctx, node.ID, []string{"one", "two", "three"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := (link.Enrolment{Inventory: inv}).Heard(ctx, link.Report{
|
|
Node: "workstation", Applied: []string{"one"}, Failed: map[string]string{"two": "no"},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
owned, _, err := inv.Owned(ctx, node.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(owned) != 3 {
|
|
t.Fatalf("a partial report replaced the account of what the machine holds: %v", owned)
|
|
}
|
|
}
|