From 1c32af6a22d68e3324fc57504ce91bd7bc51e73d Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 22 Sep 2026 17:14:05 +0200 Subject: [PATCH] Record whether a node is adopted and which modules were taken on it (hq ADR 0100) --- cmd/mesh-controller/adoption.go | 44 ++++++ cmd/mesh-controller/board.go | 9 +- cmd/mesh-controller/main.go | 3 +- cmd/mesh-controller/nodes.go | 97 +++++++++--- cmd/mesh-controller/nodes_test.go | 50 ++++++ cmd/mesh-controller/readable.go | 4 +- cmd/mesh-controller/status.go | 7 + internal/inventory/adoption.go | 149 ++++++++++++++++++ internal/inventory/adoption_test.go | 134 ++++++++++++++++ .../0029-a-node-is-adopted-or-converged.sql | 21 +++ internal/inventory/nodes.go | 66 +++++--- internal/token/token.go | 5 + internal/token/token_test.go | 16 ++ 13 files changed, 559 insertions(+), 46 deletions(-) create mode 100644 cmd/mesh-controller/adoption.go create mode 100644 internal/inventory/adoption.go create mode 100644 internal/inventory/adoption_test.go create mode 100644 internal/inventory/migrations/0029-a-node-is-adopted-or-converged.sql diff --git a/cmd/mesh-controller/adoption.go b/cmd/mesh-controller/adoption.go new file mode 100644 index 0000000..e898dbf --- /dev/null +++ b/cmd/mesh-controller/adoption.go @@ -0,0 +1,44 @@ +package main + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/novox/mesh-controller/internal/inventory" +) + +// A node is adopted or converged (novox/hq ADR 0100), and it is said to be adopted wherever the +// mesh reports a node's state: node list, node show, status and the board. + +// showMode is the node show lines about a node's mode and what was taken on it. +func showMode(ctx context.Context, inv *inventory.Inventory, node inventory.Node) error { + if !node.Adopted { + fmt.Printf(" mode converged\n") + return nil + } + fmt.Printf(" mode adopted since %s\n", + node.AdoptedSince.Local().Format(time.DateTime)) + taken, err := inv.Taken(ctx, node.Name) + if err != nil { + return err + } + if len(taken) == 0 { + fmt.Printf(" taken nothing yet\n") + } else { + fmt.Printf(" taken %s\n", strings.Join(taken, ", ")) + } + return nil +} + +// adoptedNodes are the names of every adopted node, in the order given. +func adoptedNodes(nodes []inventory.Node) []string { + var out []string + for _, n := range nodes { + if n.Adopted { + out = append(out, n.Name) + } + } + return out +} diff --git a/cmd/mesh-controller/board.go b/cmd/mesh-controller/board.go index 49702ac..3557b8d 100644 --- a/cmd/mesh-controller/board.go +++ b/cmd/mesh-controller/board.go @@ -137,6 +137,9 @@ type view struct { Unresolved []blockedMachine // Network is why the private network could not be computed, when it could not. Network string + // Adopted is every node still adopted (novox/hq ADR 0100). Not broken: nothing forces the + // flip, so a node left adopted is shown rather than read as converged. + Adopted []string At string } @@ -181,7 +184,7 @@ type staleModule struct { func viewOf(asked answers) view { out := view{Machines: len(asked.nodes), At: time.Now().Format("15:04:05"), - Network: asked.network} + Network: asked.network, Adopted: adoptedNodes(asked.nodes)} var blocked []string for name := range asked.refused { blocked = append(blocked, name) @@ -311,6 +314,10 @@ new, switched off, or unreachable.

Never told is not out of date: nobody has asked that machine to be anything yet. Both are sent by push --behind.

{{else}}

Every machine is running what the mesh would send it.

{{end}} +{{if .Adopted}} +

Which machines are adopted?

+ +{{end}} {{end}} diff --git a/cmd/mesh-controller/main.go b/cmd/mesh-controller/main.go index f03cd59..00cc99b 100644 --- a/cmd/mesh-controller/main.go +++ b/cmd/mesh-controller/main.go @@ -126,7 +126,7 @@ func usage() { fmt.Fprint(os.Stderr, `mesh-controller — the control plane migrate bring each context's schema up to date - node add create a node record + node add [--adopted] create a node record; --adopted: the machine is in use node list the nodes this mesh knows about node show what one machine reported it can do, and why node public-domain the domain it composes its routed names under @@ -134,6 +134,7 @@ func usage() { node public-domain --clear ...it faces the outside no longer token issue --node a one-time right to join, for an existing record token issue --new create the record and issue for it + token issue ... --adopted ...for a machine in use, which joins adopted identity show this control plane's signing key broker show where the broker is, and what to expect there serve consume what nodes say, and answer diff --git a/cmd/mesh-controller/nodes.go b/cmd/mesh-controller/nodes.go index e6dcfe0..1ca2257 100644 --- a/cmd/mesh-controller/nodes.go +++ b/cmd/mesh-controller/nodes.go @@ -38,15 +38,7 @@ func nodeCommand(ctx context.Context, args []string) error { } return showNode(ctx, inv, args[1]) case "add": - if len(args) != 2 { - return errors.New("node add ") - } - node, err := inv.AddNode(ctx, args[1]) - if err != nil { - return err - } - fmt.Printf("added %s (%s)\n", node.Name, node.ID) - return nil + return addNode(ctx, inv, args[1:]) case "list": nodes, err := inv.Nodes(ctx) @@ -61,7 +53,7 @@ func nodeCommand(ctx context.Context, args []string) error { return nil } for _, n := range nodes { - fmt.Printf("%-20s %-14s %s\n", n.Name, heardFrom(n), n.ID) + fmt.Printf("%-20s %-14s %-9s %s\n", n.Name, heardFrom(n), modeOf(n), n.ID) } return nil @@ -80,6 +72,39 @@ func nodeCommand(ctx context.Context, args []string) error { } } +// addNode creates a node record, adopted when the operator says so (novox/hq ADR 0100). +func addNode(ctx context.Context, inv *inventory.Inventory, args []string) error { + set := flag.NewFlagSet("node add", flag.ContinueOnError) + adopted := set.Bool("adopted", false, + "the machine is in use: keep what is found on it until each module is taken") + positionals, err := parseAround(set, args) + if err != nil { + return err + } + if len(positionals) != 1 { + return errors.New("node add [--adopted]") + } + node, err := inv.AddNodeAs(ctx, positionals[0], *adopted) + if err != nil { + return err + } + fmt.Printf("added %s (%s)", node.Name, node.ID) + if node.Adopted { + fmt.Print(", adopted") + } + fmt.Println() + return nil +} + +// modeOf is a node's mode as a word (novox/hq ADR 0100): an adopted node is said to be adopted +// wherever the mesh reports a node's state. +func modeOf(n inventory.Node) string { + if n.Adopted { + return "adopted" + } + return "converged" +} + // publicDomainUsage is the one description of the three forms, so a refusal and the help agree. const publicDomainUsage = "node public-domain — what it is now; " + " to set it; --clear to take it away" @@ -153,6 +178,8 @@ func tokenCommand(ctx context.Context, args []string) error { existing := set.String("node", "", "issue for a node record that already exists") fresh := set.String("new", "", "create the node record, then issue for it") validFor := set.Duration("for", time.Hour, "how long the token may be used") + adopted := set.Bool("adopted", false, + "the machine joining is in use: it is adopted, and keeps what is found on it") if err := set.Parse(args[1:]); err != nil { return err } @@ -171,16 +198,7 @@ func tokenCommand(ctx context.Context, args []string) error { defer open.Close() inv := open.inventory - name := *existing - if *fresh != "" { - node, err := inv.AddNode(ctx, *fresh) - if err != nil { - return err - } - name = node.Name - } - - issued, err := inv.IssueToken(ctx, name, *validFor) + issued, err := issueFor(ctx, inv, *existing, *fresh, *adopted, *validFor) if err != nil { return err } @@ -211,7 +229,8 @@ func tokenCommand(ctx context.Context, args []string) error { return err } - made := token.Token{Node: issued.Node.Name, Signer: key.Public, Secret: issued.Secret} + made := token.Token{Node: issued.Node.Name, Signer: key.Public, Secret: issued.Secret, + Adopted: issued.Node.Adopted} // Absent is a state, not a failure: a control plane can hold records and a key before it has // a broker. What it cannot do is issue a token anybody could use, and Missing() says so. @@ -228,8 +247,12 @@ func tokenCommand(ctx context.Context, args []string) error { return err } - fmt.Printf("token for %s, usable once, until %s\n\n %s\n\n", - issued.Node.Name, issued.Expires.Format(time.RFC3339), encoded) + joins := "" + if made.Adopted { + joins = ", joining adopted" + } + fmt.Printf("token for %s%s, usable once, until %s\n\n %s\n\n", + issued.Node.Name, joins, issued.Expires.Format(time.RFC3339), encoded) fmt.Println("This is the only time it is shown. What is stored is a hash of the secret.") if missing := made.Missing(); len(missing) > 0 { @@ -243,6 +266,31 @@ func tokenCommand(ctx context.Context, args []string) error { return nil } +// issueFor is the inventory's half of issuing a token: the record, made when it is new, adopted +// when the operator says so, and the one-time secret for it. The node in what it returns carries +// its mode, which is what the token says. +func issueFor(ctx context.Context, inv *inventory.Inventory, existing, fresh string, adopted bool, + validFor time.Duration) (inventory.Issued, error) { + name := existing + if fresh != "" { + node, err := inv.AddNodeAs(ctx, fresh, adopted) + if err != nil { + return inventory.Issued{}, err + } + name = node.Name + } + // Saying adopted makes the node adopted. Not saying it leaves the node as it is: re-issuing a + // token for an adopted node does not converge it — converging is its own act, previewed + // (novox/hq ADR 0100). + if adopted { + if err := inv.SetAdopted(ctx, name, true); err != nil { + return inventory.Issued{}, err + } + } + + return inv.IssueToken(ctx, name, validFor) +} + func identityCommand(ctx context.Context, args []string) error { if len(args) == 0 || args[0] != "show" { return errors.New("identity show") @@ -334,6 +382,9 @@ 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)) + if err := showMode(ctx, inv, node); err != nil { + return err + } // The domain its routed names are composed under, when it has one (novox/hq ADR 0066). Shown // only when set: a machine that serves nothing to the outside has no domain, and saying so of diff --git a/cmd/mesh-controller/nodes_test.go b/cmd/mesh-controller/nodes_test.go index a27d3ba..c9f55a7 100644 --- a/cmd/mesh-controller/nodes_test.go +++ b/cmd/mesh-controller/nodes_test.go @@ -3,6 +3,7 @@ package main import ( "strings" "testing" + "time" ) // **A read-shaped invocation is never a destructive write.** @@ -97,3 +98,52 @@ func TestAskingAboutAMachineTheMeshHasNeverHeardOfIsRefused(t *testing.T) { t.Fatal("a name the mesh does not know was answered as if it were a machine") } } + +// novox/hq ADR 0100: the operator says a node is adopted — `node add --adopted` for a record, and +// the token for a machine joining. +func TestANodeAddedAdoptedIsAdopted(t *testing.T) { + open := aMesh(t) + ctx := t.Context() + if err := addNode(ctx, open.inventory, []string{"joiner", "--adopted"}); err != nil { + t.Fatal(err) + } + n, err := open.inventory.NodeByName(ctx, "joiner") + if err != nil { + t.Fatal(err) + } + if !n.Adopted { + t.Fatal("node add --adopted made a converged node") + } + if err := addNode(ctx, open.inventory, []string{"plain"}); err != nil { + t.Fatal(err) + } + if n, _ := open.inventory.NodeByName(ctx, "plain"); n.Adopted { + t.Fatal("node add without --adopted made an adopted node") + } +} + +func TestATokenIssuedAdoptedSaysSoAndReissuingDoesNotConverge(t *testing.T) { + open := aMesh(t) + ctx := t.Context() + issued, err := issueFor(ctx, open.inventory, "", "joiner", true, time.Hour) + if err != nil { + t.Fatal(err) + } + if !issued.Node.Adopted { + t.Fatal("a token issued --adopted is for a node that is not adopted") + } + again, err := issueFor(ctx, open.inventory, "joiner", "", false, time.Hour) + if err != nil { + t.Fatal(err) + } + if !again.Node.Adopted { + t.Fatal("re-issuing without --adopted converged the node; converging is its own act") + } + existing, err := issueFor(ctx, open.inventory, "laptop", "", true, time.Hour) + if err != nil { + t.Fatal(err) + } + if !existing.Node.Adopted { + t.Fatal("--adopted on an existing record did not make it adopted") + } +} diff --git a/cmd/mesh-controller/readable.go b/cmd/mesh-controller/readable.go index 891bfcd..0bda5b5 100644 --- a/cmd/mesh-controller/readable.go +++ b/cmd/mesh-controller/readable.go @@ -54,6 +54,8 @@ type meshStatus struct { // Machines is how many the mesh knows about, so a reader can tell "none wrong" from // "none at all". Machines int `json:"machines"` + // Adopted is every node still adopted (novox/hq ADR 0100); absent when none is. + Adopted []string `json:"adopted,omitempty"` } type machineUnresolved struct { @@ -133,7 +135,7 @@ func statusAsJSON(asked answers) ([]byte, error) { out := meshStatus{Machines: len(nodes), Wrong: []machineDoing{}, Quiet: []machineQuiet{}, Behind: []moduleBehind{}, Waiting: []machineWaiting{}, Reported: []machineReported{}, Unresolved: []machineUnresolved{}, - Network: asked.network} + Network: asked.network, Adopted: adoptedNodes(nodes)} for name := range asked.refused { out.Unresolved = append(out.Unresolved, machineUnresolved{ Node: name, Problem: asked.refused[name]}) diff --git a/cmd/mesh-controller/status.go b/cmd/mesh-controller/status.go index cdc7bc7..e5bbae9 100644 --- a/cmd/mesh-controller/status.go +++ b/cmd/mesh-controller/status.go @@ -171,6 +171,13 @@ func statusCommand(ctx context.Context, args []string) error { fmt.Printf("\n `push --behind` sends them\n\n") } + if adopted := adoptedNodes(nodes); len(adopted) > 0 { + // Said, because nothing forces the flip: a node left adopted is visible here rather than + // read as converged (novox/hq ADR 0100). Not a fault, so it does not break "all well". + fmt.Printf("%d machine(s) adopted: %s\n", len(adopted), strings.Join(adopted, ", ")) + fmt.Printf("\n `converge ` previews the flip\n\n") + } + if len(wrong) == 0 && len(quiet) == 0 && len(behind) == 0 && len(asked.waiting) == 0 && len(asked.refused) == 0 && asked.network == "" { // Said plainly. "Nothing to report" and "nothing was checked" must never look the same, diff --git a/internal/inventory/adoption.go b/internal/inventory/adoption.go new file mode 100644 index 0000000..031bf52 --- /dev/null +++ b/internal/inventory/adoption.go @@ -0,0 +1,149 @@ +package inventory + +import ( + "context" + "errors" + "fmt" + "sort" +) + +// A node is adopted or converged (novox/hq ADR 0100). +// +// Adopted: what is found on the machine is kept until its module is taken, and the firewall found +// there stays in force. Converged: the machine is what the mesh declares, as every node was before +// adoption existed. The controller is authoritative, and every declaration it sends says which. + +// ErrNotAdopted is taking a module on a node that is converged. On a converged node every assigned +// module converges already; there is nothing to take. +var ErrNotAdopted = errors.New("the node is converged, so every module on it is taken already") + +// ErrNotAssigned is taking a module that is not on the node. Taking is the cutover of a module +// the node runs; one it does not run has nothing to cut over. +var ErrNotAssigned = errors.New("that module is not assigned to the node") + +// SetAdopted makes a node adopted or converged. Becoming adopted stamps when; converging stamps +// when too. Neither touches what was taken: what was taken stays taken when a node returns to +// adopted, and converging takes the rest by its own act. +func (i *Inventory) SetAdopted(ctx context.Context, name string, adopted bool) error { + node, err := i.NodeByName(ctx, name) + if err != nil { + return err + } + if node.Adopted == adopted { + return nil + } + if adopted { + _, err = i.store.Pool().Exec(ctx, + `update node set adopted = true, adopted_since = now() where id = $1`, node.ID) + return err + } + _, err = i.store.Pool().Exec(ctx, + `update node set adopted = false, adopted_since = null, converged_at = now() where id = $1`, + node.ID) + return err +} + +// Take records that a module has been taken on an adopted node: its cutover. From then on the +// module's resources converge on that node like any other, replacing what was found. +// +// Refused on a converged node and for a module not assigned there. Taking again is not an error; +// the first time it was taken is kept. +func (i *Inventory) Take(ctx context.Context, nodeName, module string) error { + node, err := i.NodeByName(ctx, nodeName) + if err != nil { + return err + } + if !node.Adopted { + return fmt.Errorf("%w: %s", ErrNotAdopted, nodeName) + } + return i.take(ctx, node, module) +} + +// take is Take without the adopted check, for converging, which takes every assigned module in +// the same act that makes the node converged. +func (i *Inventory) take(ctx context.Context, node Node, module string) error { + var assigned bool + if err := i.store.Pool().QueryRow(ctx, + `select exists (select 1 from assignment where node = $1 and module = $2)`, + node.ID, module).Scan(&assigned); err != nil { + return err + } + if !assigned { + return fmt.Errorf("%w: %s is not on %s; assign it first", ErrNotAssigned, module, node.Name) + } + _, err := i.store.Pool().Exec(ctx, + `insert into taken (node, module) values ($1, $2) on conflict do nothing`, node.ID, module) + return err +} + +// Converge makes an adopted node converged in one act: every module assigned there is taken, and +// the node is recorded converged. Returned is what this act took, in name order. +func (i *Inventory) Converge(ctx context.Context, nodeName string) ([]string, error) { + node, err := i.NodeByName(ctx, nodeName) + if err != nil { + return nil, err + } + if !node.Adopted { + return nil, fmt.Errorf("%s is converged already", nodeName) + } + tx, err := i.store.Pool().Begin(ctx) + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }() + rows, err := tx.Query(ctx, + `insert into taken (node, module) + select node, module from assignment where node = $1 + on conflict do nothing + returning module`, node.ID) + if err != nil { + return nil, err + } + var took []string + for rows.Next() { + var m string + if err := rows.Scan(&m); err != nil { + rows.Close() + return nil, err + } + took = append(took, m) + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + if _, err := tx.Exec(ctx, + `update node set adopted = false, adopted_since = null, converged_at = now() where id = $1`, + node.ID); err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + sort.Strings(took) + return took, nil +} + +// Taken is every module taken on a node, in name order — including one no longer assigned there: +// unassigning does not un-take. +func (i *Inventory) Taken(ctx context.Context, nodeName string) ([]string, error) { + node, err := i.NodeByName(ctx, nodeName) + if err != nil { + return nil, err + } + rows, err := i.store.Pool().Query(ctx, + `select module from taken where node = $1 order by module`, node.ID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []string + for rows.Next() { + var m string + if err := rows.Scan(&m); err != nil { + return nil, err + } + out = append(out, m) + } + return out, rows.Err() +} diff --git a/internal/inventory/adoption_test.go b/internal/inventory/adoption_test.go new file mode 100644 index 0000000..9e505a7 --- /dev/null +++ b/internal/inventory/adoption_test.go @@ -0,0 +1,134 @@ +package inventory + +import ( + "errors" + "reflect" + "testing" +) + +// novox/hq ADR 0100: a node is adopted or converged, and the controller records which. + +func TestANodeAddedWithoutSayingIsConverged(t *testing.T) { + inv := fresh(t) + if _, err := inv.AddNode(t.Context(), "anchor"); err != nil { + t.Fatal(err) + } + n, err := inv.NodeByName(t.Context(), "anchor") + if err != nil { + t.Fatal(err) + } + if n.Adopted || !n.AdoptedSince.IsZero() { + t.Fatalf("a node nobody said anything about reads as adopted: %+v", n) + } +} + +func TestAnAdoptedNodeRoundTripsThroughEveryReading(t *testing.T) { + inv := fresh(t) + made, err := inv.AddNodeAs(t.Context(), "anchor", true) + if err != nil { + t.Fatal(err) + } + if !made.Adopted || made.AdoptedSince.IsZero() { + t.Fatalf("added adopted, got %+v", made) + } + byName, err := inv.NodeByName(t.Context(), "anchor") + if err != nil { + t.Fatal(err) + } + all, err := inv.Nodes(t.Context()) + if err != nil { + t.Fatal(err) + } + if !byName.Adopted || len(all) != 1 || !all[0].Adopted { + t.Fatalf("adoption did not survive reading back: %+v %+v", byName, all) + } + + // And through a token: enrolment reads the node from the token it spends. + issued, err := inv.IssueToken(t.Context(), "anchor", 60e9) + if err != nil { + t.Fatal(err) + } + claimed, err := inv.Claim(t.Context(), issued.Secret, "a-key", false) + if err != nil { + t.Fatal(err) + } + if !claimed.Adopted { + t.Fatal("the node a token claims lost its mode") + } +} + +func TestTakingIsRefusedOnAConvergedNodeAndForAnUnassignedModule(t *testing.T) { + inv := fresh(t) + if err := inv.RegisterModule(t.Context(), manifest("hello-web", nil, nil), Source{}); err != nil { + t.Fatal(err) + } + if _, err := inv.AddNode(t.Context(), "converged"); err != nil { + t.Fatal(err) + } + if err := inv.Assign(t.Context(), "converged", "hello-web"); err != nil { + t.Fatal(err) + } + if err := inv.Take(t.Context(), "converged", "hello-web"); !errors.Is(err, ErrNotAdopted) { + t.Fatalf("taking on a converged node gave %v", err) + } + + if _, err := inv.AddNodeAs(t.Context(), "anchor", true); err != nil { + t.Fatal(err) + } + if err := inv.Take(t.Context(), "anchor", "hello-web"); !errors.Is(err, ErrNotAssigned) { + t.Fatalf("taking an unassigned module gave %v", err) + } +} + +func TestATakenModuleOutlivesItsAssignmentAndReturningToAdopted(t *testing.T) { + inv := fresh(t) + for _, m := range []string{"hello-web", "postgres"} { + if err := inv.RegisterModule(t.Context(), manifest(m, nil, nil), Source{}); err != nil { + t.Fatal(err) + } + } + if _, err := inv.AddNodeAs(t.Context(), "anchor", true); err != nil { + t.Fatal(err) + } + for _, m := range []string{"hello-web", "postgres"} { + if err := inv.Assign(t.Context(), "anchor", m); err != nil { + t.Fatal(err) + } + } + if err := inv.Take(t.Context(), "anchor", "postgres"); err != nil { + t.Fatal(err) + } + if err := inv.Take(t.Context(), "anchor", "postgres"); err != nil { + t.Fatalf("taking twice is not an error: %v", err) + } + if err := inv.Unassign(t.Context(), "anchor", "postgres"); err != nil { + t.Fatal(err) + } + taken, err := inv.Taken(t.Context(), "anchor") + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(taken, []string{"postgres"}) { + t.Fatalf("unassigning un-took it: %v", taken) + } + + took, err := inv.Converge(t.Context(), "anchor") + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(took, []string{"hello-web"}) { + t.Fatalf("converging took %v; it takes every assigned module not yet taken", took) + } + n, _ := inv.NodeByName(t.Context(), "anchor") + if n.Adopted { + t.Fatal("converged node still reads adopted") + } + + if err := inv.SetAdopted(t.Context(), "anchor", true); err != nil { + t.Fatal(err) + } + taken, _ = inv.Taken(t.Context(), "anchor") + if !reflect.DeepEqual(taken, []string{"hello-web", "postgres"}) { + t.Fatalf("returning to adopted lost what was taken: %v", taken) + } +} diff --git a/internal/inventory/migrations/0029-a-node-is-adopted-or-converged.sql b/internal/inventory/migrations/0029-a-node-is-adopted-or-converged.sql new file mode 100644 index 0000000..a5d2fb9 --- /dev/null +++ b/internal/inventory/migrations/0029-a-node-is-adopted-or-converged.sql @@ -0,0 +1,21 @@ +-- A node is adopted or converged, and the mesh records which (novox/hq ADR 0100). +-- +-- A machine already serving a predecessor's services is adopted: what is found on it is kept +-- until its module is taken, and the firewall found on it stays in force. It stays adopted until +-- the operator converges it. False by default, so every node that exists is converged, as it was. + +alter table node add column adopted boolean not null default false; +-- When it was last made adopted, and when it last converged. Both kept: a node returned to adopted +-- after converging is a different history from one that never converged. +alter table node add column adopted_since timestamptz; +alter table node add column converged_at timestamptz; + +-- The modules taken on a node: its cutover, the operator's act, done when the module's data has +-- moved. A row per node and module, and it outlives the assignment on purpose -- unassigning a +-- module does not un-take it, and what was taken stays taken when a node returns to adopted. +create table taken ( + node uuid not null references node(id) on delete cascade, + module text not null references module(name) on delete cascade, + taken_at timestamptz not null default now(), + primary key (node, module) +); diff --git a/internal/inventory/nodes.go b/internal/inventory/nodes.go index 2b822ce..fb736b1 100644 --- a/internal/inventory/nodes.go +++ b/internal/inventory/nodes.go @@ -49,6 +49,12 @@ type Node struct { // month's assignments, and until this existed those looked the same as a node that is // current (novox/hq 09-the-node-lifecycle). LastSeen time.Time + + // Adopted is whether this node is adopted rather than converged (novox/hq ADR 0100): what is + // found on it is kept until its module is taken, and the firewall found on it stays in force. + // AdoptedSince is when it last became so; zero for a converged node. + Adopted bool + AdoptedSince time.Time } // Silent is how long since this node was last heard from, and whether it ever was. @@ -74,28 +80,59 @@ var ErrNameTaken = errors.New("a node of that name already exists") // (novox/hq 09-the-node-lifecycle), so the record is what a token binds to and must exist before // there is anything to join. func (i *Inventory) AddNode(ctx context.Context, name string) (Node, error) { + return i.AddNodeAs(ctx, name, false) +} + +// AddNodeAs creates a node record, adopted or converged (novox/hq ADR 0100). The operator says +// which; a node added without saying is converged, as every node was before adoption existed. +func (i *Inventory) AddNodeAs(ctx context.Context, name string, adopted bool) (Node, error) { name = strings.TrimSpace(name) if name == "" { return Node{}, errors.New("a node needs a name: it is how a token is issued for it") } var n Node + var since *time.Time err := i.store.Pool().QueryRow(ctx, - `insert into node (name) values ($1) returning id, name, created`, - name).Scan(&n.ID, &n.Name, &n.Created) + `insert into node (name, adopted, adopted_since) + values ($1, $2, case when $2 then now() end) + returning id, name, created, adopted, adopted_since`, + name, adopted).Scan(&n.ID, &n.Name, &n.Created, &n.Adopted, &since) if err != nil { if strings.Contains(err.Error(), "node_name_key") { return Node{}, fmt.Errorf("%w: %s", ErrNameTaken, name) } return Node{}, err } + if since != nil { + n.AdoptedSince = *since + } + return n, nil +} + +// 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` + +func scanNode(row pgx.Row) (Node, error) { + var n Node + var seen, since *time.Time + if err := row.Scan(&n.ID, &n.Name, &n.Created, &seen, &n.Adopted, &since); err != nil { + return Node{}, err + } + if seen != nil { + n.LastSeen = *seen + } + if since != nil { + n.AdoptedSince = *since + } return n, nil } // Nodes are every node record, oldest first. func (i *Inventory) Nodes(ctx context.Context) ([]Node, error) { rows, err := i.store.Pool().Query(ctx, - `select id, name, created, last_seen from node order by created, name`) + `select `+nodeColumns+` from node order by created, name`) if err != nil { return nil, err } @@ -103,14 +140,10 @@ func (i *Inventory) Nodes(ctx context.Context) ([]Node, error) { var nodes []Node for rows.Next() { - var n Node - var seen *time.Time - if err := rows.Scan(&n.ID, &n.Name, &n.Created, &seen); err != nil { + n, err := scanNode(rows) + if err != nil { return nil, err } - if seen != nil { - n.LastSeen = *seen - } nodes = append(nodes, n) } return nodes, rows.Err() @@ -118,9 +151,8 @@ func (i *Inventory) Nodes(ctx context.Context) ([]Node, error) { // NodeByName finds one node record. func (i *Inventory) NodeByName(ctx context.Context, name string) (Node, error) { - var n Node - err := i.store.Pool().QueryRow(ctx, - `select id, name, created from node where name = $1`, name).Scan(&n.ID, &n.Name, &n.Created) + n, err := scanNode(i.store.Pool().QueryRow(ctx, + `select `+nodeColumns+` from node where name = $1`, name)) if errors.Is(err, pgx.ErrNoRows) { return Node{}, fmt.Errorf("%w: %s", ErrNoSuchNode, name) } @@ -248,10 +280,7 @@ func (i *Inventory) Claim(ctx context.Context, secret, by string, again bool) (N if err != nil { return Node{}, err } - var n Node - err = i.store.Pool().QueryRow(ctx, - `select id, name, created from node where id = $1`, id).Scan(&n.ID, &n.Name, &n.Created) - return n, err + return scanNode(i.store.Pool().QueryRow(ctx, `select `+nodeColumns+` from node where id = $1`, id)) } // Spend makes a claimed token used, only for the presenter holding the claim. The last write to the @@ -293,10 +322,7 @@ func (i *Inventory) Redeem(ctx context.Context, secret string) (Node, error) { return Node{}, err } - var n Node - err = i.store.Pool().QueryRow(ctx, - `select id, name, created from node where id = $1`, id).Scan(&n.ID, &n.Name, &n.Created) - return n, err + return scanNode(i.store.Pool().QueryRow(ctx, `select `+nodeColumns+` from node where id = $1`, id)) } // RecordProfile keeps the last thing a node said about what it can do. diff --git a/internal/token/token.go b/internal/token/token.go index ef99fbe..ee7ab49 100644 --- a/internal/token/token.go +++ b/internal/token/token.go @@ -50,6 +50,11 @@ type Token struct { // Secret is the one-time right to join. Useless once used, useless after it expires. Secret string `json:"secret"` + + // Adopted says the node joins adopted (novox/hq ADR 0100): the host checks, before enrolling, + // that it speaks the firewall found on the machine, because an adopted node keeps that firewall + // in force. Absent for a converged node, so a converged token is byte for byte what it was. + Adopted bool `json:"adopted,omitempty"` } // Missing names the parts that are not filled in. diff --git a/internal/token/token_test.go b/internal/token/token_test.go index a7860a1..ecf3f78 100644 --- a/internal/token/token_test.go +++ b/internal/token/token_test.go @@ -140,6 +140,22 @@ func TestTheWireFormatIsExactlyTheseFieldNames(t *testing.T) { if len(fields) != 6 { t.Errorf("the token has %d fields, expected 6: %v", len(fields), fields) } + + // An adopted node's token says so, under exactly this name, and a converged one does not + // carry it at all (novox/hq ADR 0100). + adopted := complete(t) + adopted.Adopted = true + raw, err = json.Marshal(adopted) + if err != nil { + t.Fatal(err) + } + fields = nil + if err := json.Unmarshal(raw, &fields); err != nil { + t.Fatal(err) + } + if fields["adopted"] != true || len(fields) != 7 { + t.Errorf("an adopted token does not carry \"adopted\": true: %v", fields) + } } func TestATokenWithNoNameIsRefused(t *testing.T) {