The mesh knows which host runs a machine, and says who is behind another
novox/hq 04-ISSUES/087. A host refuses a declaration carrying a field it does not know, and refuses it WHOLE — deliberately, because that keeps a half-understood declaration off a machine. It makes every new declaration field 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. The machine has reported its host version since ADR 0141. The controller's own copy of the report did not have the field, so it was unmarshalled into nothing and thrown away on arrival. It has it now, records it, and shows it in `node show` — "not reported" rather than blank, because a machine that has not said is not a machine running nothing. Status says which machines run an older host than another machine does, and which is newest. Deliberately disagreement rather than staleness: nothing delivers a host version yet (ADR 0141, accepted and not built), so the mesh holds no canonical current version and cannot honestly say a machine is behind THE host. What it can say is that the oldest host in the mesh is what the mesh may send. A machine that has reported nothing is left out rather than called behind. Versions compare as strings, which suits the timestamps and commits this mesh uses and is wrong for a scheme where "10" sorts before "9" — said in the code, at the place that would have to learn.
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
Reference in New Issue
Block a user