diff --git a/cmd/mesh-controller/hosts_behind_test.go b/cmd/mesh-controller/hosts_behind_test.go new file mode 100644 index 0000000..dd0fd50 --- /dev/null +++ b/cmd/mesh-controller/hosts_behind_test.go @@ -0,0 +1,98 @@ +package main + +import ( + "testing" + + "github.com/novox/mesh-controller/internal/inventory" +) + +// A host refuses a declaration carrying a field it does not know, and refuses it whole — so every new +// field is a flag day, and the mesh had no record of which host any machine ran (novox/hq +// 04-ISSUES/087). The order was kept by somebody remembering it. + +func TestTheMeshNamesEveryMachineOnAnOlderHostThanAnother(t *testing.T) { + older, newest := hostsBehind([]inventory.Node{ + {Name: "anchor", HostVersion: "2026-09-29-0918"}, + {Name: "laptop", HostVersion: "2026-09-29-0113"}, + {Name: "spare", HostVersion: "2026-09-29-0918"}, + }) + if newest != "2026-09-29-0918" { + t.Fatalf("the newest reported host is %q", newest) + } + if len(older) != 1 || older[0].Name != "laptop" { + t.Fatalf("the machines behind another are %v, wanted laptop alone", older) + } +} + +func TestAMachineThatHasNotSaidIsNotCalledBehind(t *testing.T) { + // It may be running anything. Guessing either way is worse than saying it has not said, which + // `node show` does per machine. + older, newest := hostsBehind([]inventory.Node{ + {Name: "anchor", HostVersion: "2026-09-29-0918"}, + {Name: "quiet"}, + }) + if newest != "2026-09-29-0918" { + t.Fatalf("the newest reported host is %q", newest) + } + for _, n := range older { + if n.Name == "quiet" { + t.Fatal("a machine that reported no host version was called behind") + } + } +} + +func TestAMeshWhereNothingReportedAHostStatesNoDisagreement(t *testing.T) { + // Every machine predating ADR 0141, or a mesh that has heard nothing since the column existed. + // Saying "0 machines behind" would be a claim the mesh cannot make. + older, newest := hostsBehind([]inventory.Node{{Name: "anchor"}, {Name: "laptop"}}) + if len(older) != 0 || newest != "" { + t.Fatalf("a mesh that has been told no host version reported %v / %q", older, newest) + } +} + +func TestMachinesAllOnOneHostAreNotBehind(t *testing.T) { + older, _ := hostsBehind([]inventory.Node{ + {Name: "anchor", HostVersion: "v2"}, + {Name: "laptop", HostVersion: "v2"}, + }) + if len(older) != 0 { + t.Fatalf("machines agreeing on their host were reported as behind: %v", older) + } +} + +func TestAReportedHostVersionIsKeptAndReadBack(t *testing.T) { + // The machine has sent this since ADR 0141 and the controller's own copy of the report did not + // have the field, so it was unmarshalled into nothing. End to end through the store, because the + // fault was a field that existed on one side of the wire only. + open := aMesh(t) + record, err := open.inventory.NodeByName(t.Context(), "anchor") + if err != nil { + t.Fatal(err) + } + if record.HostVersion != "" { + t.Fatalf("a machine that never reported one has host version %q", record.HostVersion) + } + if err := open.inventory.RecordHostVersion(t.Context(), record.ID, "2026-09-30-0214"); err != nil { + t.Fatal(err) + } + again, err := open.inventory.NodeByName(t.Context(), "anchor") + if err != nil { + t.Fatal(err) + } + if again.HostVersion != "2026-09-30-0214" { + t.Fatalf("the reported host version read back as %q", again.HostVersion) + } + // An empty report never clears what a machine last said: a bare word that the node is there + // says nothing about its host. + if err := open.inventory.RecordHostVersion(t.Context(), record.ID, " "); err != nil { + t.Fatal(err) + } + kept, err := open.inventory.NodeByName(t.Context(), "anchor") + if err != nil { + t.Fatal(err) + } + if kept.HostVersion != "2026-09-30-0214" { + t.Fatalf("a report carrying no host version cleared what the machine had said: %q", + kept.HostVersion) + } +} diff --git a/cmd/mesh-controller/nodes.go b/cmd/mesh-controller/nodes.go index 3eba5c2..fe96b48 100644 --- a/cmd/mesh-controller/nodes.go +++ b/cmd/mesh-controller/nodes.go @@ -436,6 +436,12 @@ func showNode(ctx context.Context, inv *inventory.Inventory, name string) error } fmt.Printf("%s\n", node.Name) fmt.Printf(" last heard from %s\n", heardFrom(node)) + // Which host runs it, as it reported (novox/hq 04-ISSUES/087). Said whenever known, because a + // host refuses a declaration carrying a field it does not understand and refuses it WHOLE — so + // which host a machine runs is what decides whether the mesh can send it anything new, and + // nothing could say it. "not reported" rather than blank: a machine that has not said is a + // different thing from one running nothing. + fmt.Printf(" host %s\n", orNotReported(node.HostVersion)) if err := showMode(ctx, inv, node); err != nil { return err } @@ -486,3 +492,11 @@ func showNode(ctx context.Context, inv *inventory.Inventory, name string) error } return nil } + +// orNotReported is a fact a machine states about itself, or the fact that it has not. +func orNotReported(s string) string { + if strings.TrimSpace(s) == "" { + return "not reported — this machine has not said since the mesh began keeping it" + } + return s +} diff --git a/cmd/mesh-controller/status.go b/cmd/mesh-controller/status.go index 5493205..22f3ae3 100644 --- a/cmd/mesh-controller/status.go +++ b/cmd/mesh-controller/status.go @@ -171,6 +171,25 @@ func statusCommand(ctx context.Context, args []string) error { fmt.Printf("\n `push --behind` sends them\n\n") } + if older, newest := hostsBehind(nodes); len(older) > 0 { + // **Before a declaration gains a field, every machine has to understand it** (novox/hq + // 04-ISSUES/087). A host refuses a declaration carrying a field it does not know, and refuses + // it whole, so every new field is a flag day: hosts first, then the controller. The mesh had + // no record of which host any machine ran, so that order was kept by somebody remembering it, + // and a machine that refused for this reason reported a failure with nothing saying why. + // + // Said as disagreement rather than as "out of date", because nothing delivers a host version + // yet (ADR 0141, not built) and so the mesh has no canonical current one. What it can say + // truthfully is that these machines do not all run the same host, and which is newest of the + // ones it has been told about. + fmt.Printf("%d machine(s) run an older host than another machine does:\n", len(older)) + for _, n := range older { + fmt.Printf(" %-12s %s\n", n.Name, orNotReported(n.HostVersion)) + } + fmt.Printf("\n the newest any machine reports is %s. A host refuses a declaration carrying a\n"+ + " field it does not know, whole — so a new field reaches these machines last\n\n", newest) + } + if len(asked.untaken) > 0 { // **Before the adopted line, and it breaks "all well".** An adopted machine is a state // somebody chose and can leave alone; a module assigned to one and never taken is work @@ -369,3 +388,40 @@ func (a answers) well() bool { return len(a.wrong) == 0 && len(a.quiet) == 0 && len(a.behind) == 0 && len(a.waiting) == 0 && len(a.refused) == 0 && a.network == "" && len(a.untaken) == 0 } + +// hostsBehind is every machine reporting an older host than the newest any machine reports, and that +// newest version. +// +// **Disagreement, not staleness.** Nothing delivers a host version yet +// ([ADR 0141](../../02-DECISIONS/0141-the-host-delivers-its-own-successor.md) is accepted and not +// built), so the mesh holds no canonical current version and cannot say a machine is behind THE host. +// It can say these machines are behind ANOTHER MACHINE's, which is the fact that matters before a +// declaration gains a field: the oldest host in the mesh is what the mesh may send +// (novox/hq 04-ISSUES/087). +// +// A machine that has not reported a version is left out rather than called behind. It may be running +// anything, and guessing in either direction is worse than saying it has not said — which `node show` +// does say, per machine. +// +// Versions are compared as strings, which is enough for the timestamps and commits this mesh uses and +// is wrong for a scheme where "10" sorts before "9". Saying so here rather than pretending: when a +// version becomes something ordered, this is the place that has to learn how. +func hostsBehind(nodes []inventory.Node) ([]inventory.Node, string) { + newest := "" + for _, n := range nodes { + if n.HostVersion > newest { + newest = n.HostVersion + } + } + if newest == "" { + return nil, "" // nothing has reported one; there is no disagreement to state + } + var older []inventory.Node + for _, n := range nodes { + if n.HostVersion != "" && n.HostVersion != newest { + older = append(older, n) + } + } + sort.Slice(older, func(i, j int) bool { return older[i].Name < older[j].Name }) + return older, newest +} diff --git a/internal/inventory/migrations/0045-a-machine-says-which-host-runs-it.sql b/internal/inventory/migrations/0045-a-machine-says-which-host-runs-it.sql new file mode 100644 index 0000000..4908ad3 --- /dev/null +++ b/internal/inventory/migrations/0045-a-machine-says-which-host-runs-it.sql @@ -0,0 +1,15 @@ +-- The version of the host running on a machine, as the machine reports it. +-- +-- novox/hq 04-ISSUES/087. A host parses a declaration strictly: a field it does not know makes it +-- refuse the whole declaration and apply nothing. That is deliberate — it keeps a half-understood +-- declaration off a machine — and it makes every new field in a declaration a flag day, hosts before +-- controller. The mesh had no record of which host a machine runs, so it could neither refuse to send +-- a declaration a machine cannot parse nor say which machines were behind. The order was kept by +-- somebody remembering it. +-- +-- The machine has been reporting this since ADR 0141 and the control plane discarded it: the field was +-- absent from the controller's own copy of the report, so it was unmarshalled into nothing. +-- +-- Null for a machine that has not reported since this column existed, which is not the same as a +-- machine running no host — so a reader is never told a version the mesh does not have. +alter table node add column host_version text; diff --git a/internal/inventory/nodes.go b/internal/inventory/nodes.go index 971927b..30c1423 100644 --- a/internal/inventory/nodes.go +++ b/internal/inventory/nodes.go @@ -63,6 +63,11 @@ type Node struct { // entry. What decides who a file under a home is owned by, and which account `ssh ` uses. Account string AccountHome string + + // HostVersion is the version of the host this machine reported running (novox/hq 04-ISSUES/087). + // Empty when it has not said since the mesh began keeping it — which is not the same as running + // no host, so nothing derives "behind" from an empty one. + HostVersion string } // Home is the account's home directory, derived when not stored: /root for root, /home/ @@ -136,15 +141,20 @@ func (i *Inventory) AddNodeAs(ctx context.Context, name string, adopted bool) (N // nodeColumns and scanNode are the one reading of a node row, so every way of finding a node // says whether it is adopted. -const nodeColumns = `id, name, created, last_seen, adopted, adopted_since, account, account_home` +const nodeColumns = `id, name, created, last_seen, adopted, adopted_since, account, account_home, + host_version` func scanNode(row pgx.Row) (Node, error) { var n Node var seen, since *time.Time + var host *string if err := row.Scan(&n.ID, &n.Name, &n.Created, &seen, &n.Adopted, &since, - &n.Account, &n.AccountHome); err != nil { + &n.Account, &n.AccountHome, &host); err != nil { return Node{}, err } + if host != nil { + n.HostVersion = *host + } if seen != nil { n.LastSeen = *seen } @@ -980,3 +990,18 @@ type Machine struct { // being out of date and reads differently to whoever is looking. Never bool } + +// RecordHostVersion keeps the version of the host a machine reported running (novox/hq 04-ISSUES/087). +// +// Never cleared by a report that carries none: a bare word that the node is there says nothing about +// its host, and a machine whose host predates ADR 0141 reports none at all. So an empty version means +// the mesh has not been told, and the caller does not write it. +func (i *Inventory) RecordHostVersion(ctx context.Context, id, version string) error { + version = strings.TrimSpace(version) + if version == "" { + return nil + } + _, err := i.store.Pool().Exec(ctx, + `update node set host_version = $2, last_seen = now() where id = $1`, id, version) + return err +} diff --git a/internal/link/enrolment.go b/internal/link/enrolment.go index 81e06fa..7661996 100644 --- a/internal/link/enrolment.go +++ b/internal/link/enrolment.go @@ -312,6 +312,14 @@ func (e Enrolment) Heard(ctx context.Context, report Report) (news bool, err err return false, err } } + // Which host produced this report (novox/hq 04-ISSUES/087), whenever it says. Recorded on every + // report that carries it and never cleared by one that does not — a bare word that the node is + // there says nothing about its host, and a machine whose host predates this reports none. + if report.Host != "" { + if err := e.Inventory.RecordHostVersion(ctx, node.ID, report.Host); err != nil { + return false, err + } + } // What it says about the tunnel it carried (novox/hq ADR 0105), whenever it says it. if report.Tunnel != nil { if err := e.Inventory.RecordCarriedTunnel(ctx, node.ID, inventory.Carried{ diff --git a/internal/link/protocol.go b/internal/link/protocol.go index 4d74275..cd34e2d 100644 --- a/internal/link/protocol.go +++ b/internal/link/protocol.go @@ -184,6 +184,15 @@ type Report struct { // leaves the one it has: a rule written around a link with no name is a rule set that does not // load, and that is a machine filtering nothing while its unit reports success. Outward []string `json:"outward,omitempty"` + + // Host is the version of the host that produced this report (novox/hq ADR 0141). + // + // **The machine has sent this since 0141 and this struct did not have it**, so it was + // unmarshalled into nothing and the mesh could not say which host any machine runs + // (novox/hq 04-ISSUES/087). A host refuses a declaration carrying a field it does not know, and + // refuses it whole — which is right, and makes every new field a flag day that the mesh could + // not see coming. + Host string `json:"host,omitempty"` // Reachable is what can be reached on the machine now: every listening socket and every // published container port. Only an adopted node reports it; it is what converging previews. Reachable []Reach `json:"reachable,omitempty"`