Files
mesh-controller/internal/link/heard_test.go
T
jschoubben b5df244096 The mesh says what filters a converged machine: filters kept per node, shown by node show, named by status, and previewed with their fates (hq ADR 0168)
A host reports every table and chain that refuses traffic with its owner,
and a converged machine's found firewall's state. The controller keeps both
on the node's record (migration 0054), shows them on node show, names every
converged machine something other than the mesh filters in status — text
and JSON, and such a machine is not well — and the converge preview lists
what filters the machine with the fate of each: retired with the front end,
left as the runtime's, left as a ban, or left in force and not the mesh's.
What was invisible for eleven hours (issues 144, 145) is said by name.
2026-10-02 12:00:12 +02:00

263 lines
9.7 KiB
Go

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)
}
}
// novox/hq ADR 0100: what an adopted node holds, the firewall it found and what is reachable on it
// are kept from the report that carries them, and a bare word that the node is there wipes none.
func TestWhatAnAdoptedNodeHoldsIsKeptAndAnAliveWordDoesNotWipeIt(t *testing.T) {
inv, _, _ := heardFrom(t, link.Report{
Node: "anchor", Applied: []string{"hello-web.served"}, Firewall: "ufw",
Held: []link.Held{{ID: "hello-web.page", Module: "hello-web", Kind: "file",
Target: "/var/lib/hello-web/index.html", Changed: "rewritten", Kept: "/var/lib/mesh/kept/x"}},
Reachable: []link.Reach{{Protocol: "tcp", Address: "0.0.0.0", Port: 8080, By: "hello-web",
Published: true, ContainerPort: 80}},
})
ctx := context.Background()
check := func(when string) {
t.Helper()
got, err := inv.AdoptionOf(ctx, "anchor")
if err != nil {
t.Fatal(err)
}
if got.Firewall != "ufw" || len(got.Held) != 1 || got.Held[0].Changed != "rewritten" ||
len(got.Reachable) != 1 || got.Reachable[0].ContainerPort != 80 || got.At.IsZero() {
t.Fatalf("%s: what the node said is not what was kept: %+v", when, got)
}
}
check("after the report")
if _, err := (link.Enrolment{Inventory: inv}).Heard(ctx, link.Report{Node: "anchor"}); err != nil {
t.Fatal(err)
}
check("after an alive word")
// A reconcile report carrying only adoption is recorded, though it applied nothing.
if _, err := (link.Enrolment{Inventory: inv}).Heard(ctx, link.Report{Node: "anchor",
Firewall: "ufw"}); err != nil {
t.Fatal(err)
}
got, err := inv.AdoptionOf(ctx, "anchor")
if err != nil {
t.Fatal(err)
}
if len(got.Held) != 0 || got.Firewall != "ufw" {
t.Fatalf("a report from an adopted node holding nothing did not empty held: %+v", got)
}
}
// What filters a machine, and the state of its found firewall, are kept from every report that
// carries them and never cleared by one that does not (novox/hq ADR 0168).
func TestWhatFiltersAMachineIsKeptFromItsReport(t *testing.T) {
inv, _, _ := heardFrom(t, link.Report{
Node: "home-server", Applied: []string{"a"},
Filters: []link.Filter{
{Where: "table inet mesh, chain forward", Owner: "mesh", Refuses: "policy drop"},
{Where: "chain HAL-MESH-ONLY (iptables-legacy)", Owner: "other", Refuses: "-j DROP"},
},
FoundFirewall: &link.FoundFirewall{Kind: "ufw", Active: false, RetiredBy: "found-inactive"},
})
ctx := context.Background()
f, err := inv.FilteringOf(ctx, "home-server")
if err != nil {
t.Fatal(err)
}
if len(f.Filters) != 2 || f.Filters[1].Owner != inventory.FilterOther || f.Alone() {
t.Fatalf("recorded %+v", f)
}
if f.FoundFirewall == nil || f.FoundFirewall.RetiredBy != "found-inactive" || f.FoundFirewall.Active {
t.Fatalf("the found firewall's state: %+v", f.FoundFirewall)
}
if len(f.Others()) != 1 || f.Others()[0].Where != "chain HAL-MESH-ONLY (iptables-legacy)" {
t.Fatalf("others: %+v", f.Others())
}
// A bare word that the node is there clears nothing.
if _, err := (link.Enrolment{Inventory: inv}).Heard(ctx, link.Report{Node: "home-server"}); err != nil {
t.Fatal(err)
}
if again, _ := inv.FilteringOf(ctx, "home-server"); len(again.Filters) != 2 {
t.Fatalf("a bare report cleared what filters the machine: %+v", again)
}
// The next full report replaces it: the chain removed by hand is gone from the record.
if _, err := (link.Enrolment{Inventory: inv}).Heard(ctx, link.Report{Node: "home-server", Applied: []string{"a"},
Filters: []link.Filter{{Where: "table inet mesh, chain forward", Owner: "mesh", Refuses: "policy drop"}}}); err != nil {
t.Fatal(err)
}
if again, _ := inv.FilteringOf(ctx, "home-server"); len(again.Filters) != 1 || !again.Alone() {
t.Fatalf("the next report did not replace what filters the machine: %+v", again)
}
}