Merge pull request 'The mesh knows which host runs a machine, and says who is behind another' (#151) from fix/087-the-mesh-knows-which-host-runs-a-machine into main
This commit was merged in pull request #151.
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