From 28894fa5bdf1921083289705cadedf5e4c755720 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 22 Sep 2026 17:23:09 +0200 Subject: [PATCH] Keep what an adopted node reports holding, the firewall it found and what is reachable on it (hq ADR 0100) --- cmd/mesh-controller/adoption.go | 31 ++++++ internal/inventory/adoption.go | 99 ++++++++++++++++++- .../0030-what-an-adopted-node-reports.sql | 12 +++ internal/link/enrolment.go | 21 ++++ internal/link/heard_test.go | 43 ++++++++ internal/link/protocol.go | 42 +++++++- internal/link/protocol_test.go | 7 ++ 7 files changed, 253 insertions(+), 2 deletions(-) create mode 100644 internal/inventory/migrations/0030-what-an-adopted-node-reports.sql diff --git a/cmd/mesh-controller/adoption.go b/cmd/mesh-controller/adoption.go index e898dbf..eec29d4 100644 --- a/cmd/mesh-controller/adoption.go +++ b/cmd/mesh-controller/adoption.go @@ -29,9 +29,40 @@ func showMode(ctx context.Context, inv *inventory.Inventory, node inventory.Node } else { fmt.Printf(" taken %s\n", strings.Join(taken, ", ")) } + said, err := inv.AdoptionOf(ctx, node.Name) + if err != nil { + return err + } + if said.At.IsZero() { + fmt.Printf(" it has not yet said what it found\n") + return nil + } + fmt.Printf(" firewall found %s\n", orNone(said.Firewall)) + if len(said.Held) == 0 { + fmt.Printf(" holding nothing found\n") + } + for _, h := range said.Held { + // A held thing that changed is how a predecessor still writing is caught: said first. + line := fmt.Sprintf(" holds %-11s %s %s, for %s", h.Kind, h.Target, h.ID, h.Module) + if h.Changed != "" { + line += " — " + strings.ToUpper(h.Changed) + " by something other than the mesh" + } + fmt.Println(line) + if h.Kept != "" { + fmt.Printf(" %-17s original kept at %s\n", "", h.Kept) + } + } + fmt.Printf(" as of %s\n", said.At.Local().Format(time.DateTime)) return nil } +func orNone(s string) string { + if s == "" { + return "none reported" + } + return s +} + // adoptedNodes are the names of every adopted node, in the order given. func adoptedNodes(nodes []inventory.Node) []string { var out []string diff --git a/internal/inventory/adoption.go b/internal/inventory/adoption.go index 031bf52..feae77c 100644 --- a/internal/inventory/adoption.go +++ b/internal/inventory/adoption.go @@ -2,9 +2,13 @@ package inventory import ( "context" + "encoding/json" "errors" "fmt" "sort" + "time" + + "github.com/jackc/pgx/v5" ) // A node is adopted or converged (novox/hq ADR 0100). @@ -113,7 +117,9 @@ func (i *Inventory) Converge(ctx context.Context, nodeName string) ([]string, er return nil, err } if _, err := tx.Exec(ctx, - `update node set adopted = false, adopted_since = null, converged_at = now() where id = $1`, + `update node set adopted = false, adopted_since = null, converged_at = now(), + held = null, reachable = null + where id = $1`, node.ID); err != nil { return nil, err } @@ -147,3 +153,94 @@ func (i *Inventory) Taken(ctx context.Context, nodeName string) ([]string, error } return out, rows.Err() } + +// Held is one file or container an adopted node found and keeps as it was until its module is +// taken. The node's own account, kept as it said it. +type Held struct { + ID string `json:"id"` + Module string `json:"module"` + Kind string `json:"kind"` + Target string `json:"target"` + Since time.Time `json:"since"` + Changed string `json:"changed,omitempty"` + Kept string `json:"kept,omitempty"` +} + +// Reach is one thing reachable on an adopted node: a listening socket or a published port. +type Reach struct { + Protocol string `json:"protocol"` + Address string `json:"address"` + Port int `json:"port"` + By string `json:"by,omitempty"` + Published bool `json:"published,omitempty"` + ContainerPort int `json:"container-port,omitempty"` +} + +// Adoption is what an adopted node last said about adoption, and when. +type Adoption struct { + Held []Held + Firewall string + Reachable []Reach + // At is when it said so; zero when it never has. + At time.Time +} + +// RecordAdoption keeps what a node last reported about adoption, replacing what was there: the +// question is the machine as it is now. +func (i *Inventory) RecordAdoption(ctx context.Context, node string, held []Held, firewall string, + reachable []Reach) error { + heldRaw, err := json.Marshal(nonNil(held)) + if err != nil { + return err + } + reachRaw, err := json.Marshal(nonNil(reachable)) + if err != nil { + return err + } + _, err = i.store.Pool().Exec(ctx, + `update node set held = $2, firewall = nullif($3, ''), reachable = $4, + adoption_reported = now(), last_seen = now() + where id = $1`, node, heldRaw, firewall, reachRaw) + return err +} + +func nonNil[T any](s []T) []T { + if s == nil { + return []T{} + } + return s +} + +// AdoptionOf is what a node last reported about adoption. +func (i *Inventory) AdoptionOf(ctx context.Context, name string) (Adoption, error) { + var heldRaw, reachRaw []byte + var firewall *string + var at *time.Time + err := i.store.Pool().QueryRow(ctx, + `select held, firewall, reachable, adoption_reported from node where name = $1`, name). + Scan(&heldRaw, &firewall, &reachRaw, &at) + if errors.Is(err, pgx.ErrNoRows) { + return Adoption{}, fmt.Errorf("%w: %s", ErrNoSuchNode, name) + } + if err != nil { + return Adoption{}, err + } + var out Adoption + if firewall != nil { + out.Firewall = *firewall + } + if at != nil { + out.At = *at + } + if len(heldRaw) > 0 { + if err := json.Unmarshal(heldRaw, &out.Held); err != nil { + return Adoption{}, err + } + } + if len(reachRaw) > 0 { + if err := json.Unmarshal(reachRaw, &out.Reachable); err != nil { + return Adoption{}, err + } + } + return out, nil +} diff --git a/internal/inventory/migrations/0030-what-an-adopted-node-reports.sql b/internal/inventory/migrations/0030-what-an-adopted-node-reports.sql new file mode 100644 index 0000000..047d457 --- /dev/null +++ b/internal/inventory/migrations/0030-what-an-adopted-node-reports.sql @@ -0,0 +1,12 @@ +-- What an adopted node last reported about adoption (novox/hq ADR 0100): the files and containers +-- it found and holds until their module is taken, the firewall it found, and what is reachable on +-- the machine now — which converging it previews, so nothing closes without being named first. +-- +-- The last report, replaced, like what a node owns: the question is the machine as it is now. +-- Kept apart from node_report because a node reports it on its own schedule, when what it holds +-- changes, and not only after an apply. + +alter table node add column held jsonb; +alter table node add column firewall text; +alter table node add column reachable jsonb; +alter table node add column adoption_reported timestamptz; diff --git a/internal/link/enrolment.go b/internal/link/enrolment.go index f39bc52..d4d71aa 100644 --- a/internal/link/enrolment.go +++ b/internal/link/enrolment.go @@ -199,6 +199,27 @@ func (e Enrolment) Heard(ctx context.Context, report Report) (err error) { return err } + // What an adopted node holds, which firewall it found, and what is reachable on it (novox/hq + // ADR 0100). Recorded whenever a report carries any of it — a node reports these on its own + // schedule, when what it holds changes, not only after an apply — and never cleared by a + // report that carries none, which is every bare word that the node is there. An adopted node + // always names its firewall, so a report from one replaces all three, emptied held included. + if len(report.Held) > 0 || report.Firewall != "" || len(report.Reachable) > 0 { + held := make([]inventory.Held, 0, len(report.Held)) + for _, h := range report.Held { + held = append(held, inventory.Held{ID: h.ID, Module: h.Module, Kind: h.Kind, + Target: h.Target, Since: h.Since, Changed: h.Changed, Kept: h.Kept}) + } + reachable := make([]inventory.Reach, 0, len(report.Reachable)) + for _, r := range report.Reachable { + reachable = append(reachable, inventory.Reach{Protocol: r.Protocol, Address: r.Address, + Port: r.Port, By: r.By, Published: r.Published, ContainerPort: r.ContainerPort}) + } + if err := e.Inventory.RecordAdoption(ctx, node.ID, held, report.Firewall, reachable); err != nil { + return err + } + } + // A bare word that a node is there is not an account of what the machine did or holds: it // moves last_seen and touches nothing else. This arrives every minute (link.AliveEvery), // while a real report is rare, so recording it as one would overwrite the node's last real diff --git a/internal/link/heard_test.go b/internal/link/heard_test.go index 01fb10b..3a89b21 100644 --- a/internal/link/heard_test.go +++ b/internal/link/heard_test.go @@ -175,3 +175,46 @@ func TestAFailureDoesNotBecomeTheAccountOfWhatTheMachineHolds(t *testing.T) { 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) + } +} diff --git a/internal/link/protocol.go b/internal/link/protocol.go index ba6dd7c..0a7227d 100644 --- a/internal/link/protocol.go +++ b/internal/link/protocol.go @@ -6,7 +6,10 @@ // traffic between them, each receiving half of what it expects. That has happened here before. package link -import "encoding/base64" +import ( + "encoding/base64" + "time" +) // Exchange is where nodes publish everything they have to say. const Exchange = "mesh" @@ -116,6 +119,43 @@ type Report struct { // Declared is the digest of the declaration this report is about — the same bytes, hashed // the same way, as the `sent` digest the mesh recorded. Which declaration, not when. Declared string `json:"declared,omitempty"` + + // Held is what an adopted node found and is keeping as it was until its module is taken + // (novox/hq ADR 0100). Without it an adopted node reads as converged. + Held []Held `json:"held,omitempty"` + // Firewall is the firewall found on the machine — "ufw" or "none" — and empty on a node that + // was never asked, which is every converged one. + Firewall string `json:"firewall,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"` +} + +// Held is one file or container found on an adopted node and kept as it was. +type Held struct { + ID string `json:"id"` + Module string `json:"module"` + Kind string `json:"kind"` + Target string `json:"target"` + Since time.Time `json:"since"` + // Changed is what something other than the mesh did to it since — rewritten, stopped, + // replaced or gone — and empty while it is as found. + Changed string `json:"changed,omitempty"` + // Kept is where a file's original was kept. + Kept string `json:"kept,omitempty"` +} + +// Reach is one thing reachable on the machine: a listening socket, or a published container port. +type Reach struct { + Protocol string `json:"protocol"` + Address string `json:"address"` + Port int `json:"port"` + // By is what holds it — a process, or a container's name. + By string `json:"by,omitempty"` + // Published is a container port the runtime publishes, reached on the forwarded path; its + // container's own port is ContainerPort. + Published bool `json:"published,omitempty"` + ContainerPort int `json:"container-port,omitempty"` } // EnrolReply is what the mesh says back. diff --git a/internal/link/protocol_test.go b/internal/link/protocol_test.go index 6c0ff37..53ef4ce 100644 --- a/internal/link/protocol_test.go +++ b/internal/link/protocol_test.go @@ -16,6 +16,13 @@ func TestTheWireFormatIsExactlyTheseFieldNames(t *testing.T) { {Signed{Declaration: []byte("{}"), Signature: []byte("x")}, []string{"declaration", "signature"}}, {Report{Node: "n", Applied: []string{"a"}, Failed: map[string]string{"k": "v"}, Refused: "r"}, []string{"node", "applied", "failed", "refused"}}, + {Report{Node: "n", Held: []Held{{ID: "m.f"}}, Firewall: "ufw", Reachable: []Reach{{Port: 1}}}, + []string{"node", "held", "firewall", "reachable"}}, + {Held{ID: "m.f", Module: "m", Kind: "file", Target: "/f", Changed: "rewritten", Kept: "/k"}, + []string{"id", "module", "kind", "target", "since", "changed", "kept"}}, + {Reach{Protocol: "tcp", Address: "0.0.0.0", Port: 8080, By: "hello-web", Published: true, + ContainerPort: 80}, + []string{"protocol", "address", "port", "by", "published", "container-port"}}, {EnrolRequest{Node: "n", Secret: "s", PublicKey: []byte("k")}, []string{"node", "secret", "public_key"}}, } {