The mesh knows which host runs a machine, and says who is behind another #151

Merged
jschoubben merged 1 commits from fix/087-the-mesh-knows-which-host-runs-a-machine into main 2026-09-30 06:55:00 +00:00
7 changed files with 227 additions and 2 deletions
+98
View File
@@ -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)
}
}
+14
View File
@@ -436,6 +436,12 @@ func showNode(ctx context.Context, inv *inventory.Inventory, name string) error
} }
fmt.Printf("%s\n", node.Name) fmt.Printf("%s\n", node.Name)
fmt.Printf(" last heard from %s\n", heardFrom(node)) 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 { if err := showMode(ctx, inv, node); err != nil {
return err return err
} }
@@ -486,3 +492,11 @@ func showNode(ctx context.Context, inv *inventory.Inventory, name string) error
} }
return nil 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
}
+56
View File
@@ -171,6 +171,25 @@ func statusCommand(ctx context.Context, args []string) error {
fmt.Printf("\n `push --behind` sends them\n\n") 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 { if len(asked.untaken) > 0 {
// **Before the adopted line, and it breaks "all well".** An adopted machine is a state // **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 // 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 && 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 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
}
@@ -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;
+27 -2
View File
@@ -63,6 +63,11 @@ type Node struct {
// entry. What decides who a file under a home is owned by, and which account `ssh <node>` uses. // entry. What decides who a file under a home is owned by, and which account `ssh <node>` uses.
Account string Account string
AccountHome 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/<account> // Home is the account's home directory, derived when not stored: /root for root, /home/<account>
@@ -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 // nodeColumns and scanNode are the one reading of a node row, so every way of finding a node
// says whether it is adopted. // 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) { func scanNode(row pgx.Row) (Node, error) {
var n Node var n Node
var seen, since *time.Time var seen, since *time.Time
var host *string
if err := row.Scan(&n.ID, &n.Name, &n.Created, &seen, &n.Adopted, &since, 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 return Node{}, err
} }
if host != nil {
n.HostVersion = *host
}
if seen != nil { if seen != nil {
n.LastSeen = *seen n.LastSeen = *seen
} }
@@ -980,3 +990,18 @@ type Machine struct {
// being out of date and reads differently to whoever is looking. // being out of date and reads differently to whoever is looking.
Never bool 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
}
+8
View File
@@ -312,6 +312,14 @@ func (e Enrolment) Heard(ctx context.Context, report Report) (news bool, err err
return false, 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. // What it says about the tunnel it carried (novox/hq ADR 0105), whenever it says it.
if report.Tunnel != nil { if report.Tunnel != nil {
if err := e.Inventory.RecordCarriedTunnel(ctx, node.ID, inventory.Carried{ if err := e.Inventory.RecordCarriedTunnel(ctx, node.ID, inventory.Carried{
+9
View File
@@ -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 // 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. // load, and that is a machine filtering nothing while its unit reports success.
Outward []string `json:"outward,omitempty"` 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 // 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. // published container port. Only an adopted node reports it; it is what converging previews.
Reachable []Reach `json:"reachable,omitempty"` Reachable []Reach `json:"reachable,omitempty"`