package link_test import ( "context" "strings" "testing" "github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/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 foundation 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) } }