Record whether a node is adopted and which modules were taken on it (hq ADR 0100)

This commit is contained in:
2026-09-22 17:14:05 +02:00
parent 0a39b7df82
commit 1c32af6a22
13 changed files with 559 additions and 46 deletions
+44
View File
@@ -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
}
+8 -1
View File
@@ -137,6 +137,9 @@ type view struct {
Unresolved []blockedMachine Unresolved []blockedMachine
// Network is why the private network could not be computed, when it could not. // Network is why the private network could not be computed, when it could not.
Network string 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 At string
} }
@@ -181,7 +184,7 @@ type staleModule struct {
func viewOf(asked answers) view { func viewOf(asked answers) view {
out := view{Machines: len(asked.nodes), At: time.Now().Format("15:04:05"), 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 var blocked []string
for name := range asked.refused { for name := range asked.refused {
blocked = append(blocked, name) blocked = append(blocked, name)
@@ -311,6 +314,10 @@ new, switched off, or unreachable.</p>
<p class="quiet">Never told is not out of date: nobody has asked that machine to be anything yet. <p class="quiet">Never told is not out of date: nobody has asked that machine to be anything yet.
Both are sent by <code>push --behind</code>.</p> Both are sent by <code>push --behind</code>.</p>
{{else}}<p class="quiet">Every machine is running what the mesh would send it.</p>{{end}} {{else}}<p class="quiet">Every machine is running what the mesh would send it.</p>{{end}}
{{if .Adopted}}
<h2>Which machines are adopted?</h2>
<ul>{{range .Adopted}}<li><strong>{{.}}</strong> <span class="quiet">adopted — what was found on it is kept until each module is taken</span></li>{{end}}</ul>
{{end}}
{{end}} {{end}}
<footer>Read at {{.At}}. This page holds nothing and changes nothing.</footer> <footer>Read at {{.At}}. This page holds nothing and changes nothing.</footer>
+2 -1
View File
@@ -126,7 +126,7 @@ func usage() {
fmt.Fprint(os.Stderr, `mesh-controller — the control plane fmt.Fprint(os.Stderr, `mesh-controller — the control plane
migrate bring each context's schema up to date migrate bring each context's schema up to date
node add <name> create a node record node add <name> [--adopted] create a node record; --adopted: the machine is in use
node list the nodes this mesh knows about node list the nodes this mesh knows about
node show <name> what one machine reported it can do, and why node show <name> what one machine reported it can do, and why
node public-domain <name> the domain it composes its routed names under node public-domain <name> the domain it composes its routed names under
@@ -134,6 +134,7 @@ func usage() {
node public-domain <name> --clear ...it faces the outside no longer node public-domain <name> --clear ...it faces the outside no longer
token issue --node <name> a one-time right to join, for an existing record token issue --node <name> a one-time right to join, for an existing record
token issue --new <name> create the record and issue for it token issue --new <name> 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 identity show this control plane's signing key
broker show where the broker is, and what to expect there broker show where the broker is, and what to expect there
serve consume what nodes say, and answer serve consume what nodes say, and answer
+74 -23
View File
@@ -38,15 +38,7 @@ func nodeCommand(ctx context.Context, args []string) error {
} }
return showNode(ctx, inv, args[1]) return showNode(ctx, inv, args[1])
case "add": case "add":
if len(args) != 2 { return addNode(ctx, inv, args[1:])
return errors.New("node add <name>")
}
node, err := inv.AddNode(ctx, args[1])
if err != nil {
return err
}
fmt.Printf("added %s (%s)\n", node.Name, node.ID)
return nil
case "list": case "list":
nodes, err := inv.Nodes(ctx) nodes, err := inv.Nodes(ctx)
@@ -61,7 +53,7 @@ func nodeCommand(ctx context.Context, args []string) error {
return nil return nil
} }
for _, n := range nodes { 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 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 <name> [--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. // publicDomainUsage is the one description of the three forms, so a refusal and the help agree.
const publicDomainUsage = "node public-domain <name> — what it is now; " + const publicDomainUsage = "node public-domain <name> — what it is now; " +
"<name> <domain> to set it; <name> --clear to take it away" "<name> <domain> to set it; <name> --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") existing := set.String("node", "", "issue for a node record that already exists")
fresh := set.String("new", "", "create the node record, then issue for it") 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") 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 { if err := set.Parse(args[1:]); err != nil {
return err return err
} }
@@ -171,16 +198,7 @@ func tokenCommand(ctx context.Context, args []string) error {
defer open.Close() defer open.Close()
inv := open.inventory inv := open.inventory
name := *existing issued, err := issueFor(ctx, inv, *existing, *fresh, *adopted, *validFor)
if *fresh != "" {
node, err := inv.AddNode(ctx, *fresh)
if err != nil {
return err
}
name = node.Name
}
issued, err := inv.IssueToken(ctx, name, *validFor)
if err != nil { if err != nil {
return err return err
} }
@@ -211,7 +229,8 @@ func tokenCommand(ctx context.Context, args []string) error {
return err 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 // 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. // 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 return err
} }
fmt.Printf("token for %s, usable once, until %s\n\n %s\n\n", joins := ""
issued.Node.Name, issued.Expires.Format(time.RFC3339), encoded) 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.") 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 { if missing := made.Missing(); len(missing) > 0 {
@@ -243,6 +266,31 @@ func tokenCommand(ctx context.Context, args []string) error {
return nil 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 { func identityCommand(ctx context.Context, args []string) error {
if len(args) == 0 || args[0] != "show" { if len(args) == 0 || args[0] != "show" {
return errors.New("identity 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("%s\n", node.Name)
fmt.Printf(" last heard from %s\n", heardFrom(node)) 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 // 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 // only when set: a machine that serves nothing to the outside has no domain, and saying so of
+50
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"strings" "strings"
"testing" "testing"
"time"
) )
// **A read-shaped invocation is never a destructive write.** // **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") 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")
}
}
+3 -1
View File
@@ -54,6 +54,8 @@ type meshStatus struct {
// Machines is how many the mesh knows about, so a reader can tell "none wrong" from // Machines is how many the mesh knows about, so a reader can tell "none wrong" from
// "none at all". // "none at all".
Machines int `json:"machines"` 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 { type machineUnresolved struct {
@@ -133,7 +135,7 @@ func statusAsJSON(asked answers) ([]byte, error) {
out := meshStatus{Machines: len(nodes), Wrong: []machineDoing{}, out := meshStatus{Machines: len(nodes), Wrong: []machineDoing{},
Quiet: []machineQuiet{}, Behind: []moduleBehind{}, Waiting: []machineWaiting{}, Quiet: []machineQuiet{}, Behind: []moduleBehind{}, Waiting: []machineWaiting{},
Reported: []machineReported{}, Unresolved: []machineUnresolved{}, Reported: []machineReported{}, Unresolved: []machineUnresolved{},
Network: asked.network} Network: asked.network, Adopted: adoptedNodes(nodes)}
for name := range asked.refused { for name := range asked.refused {
out.Unresolved = append(out.Unresolved, machineUnresolved{ out.Unresolved = append(out.Unresolved, machineUnresolved{
Node: name, Problem: asked.refused[name]}) Node: name, Problem: asked.refused[name]})
+7
View File
@@ -171,6 +171,13 @@ 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 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 <node>` previews the flip\n\n")
}
if len(wrong) == 0 && len(quiet) == 0 && len(behind) == 0 && len(asked.waiting) == 0 && if len(wrong) == 0 && len(quiet) == 0 && len(behind) == 0 && len(asked.waiting) == 0 &&
len(asked.refused) == 0 && asked.network == "" { len(asked.refused) == 0 && asked.network == "" {
// Said plainly. "Nothing to report" and "nothing was checked" must never look the same, // Said plainly. "Nothing to report" and "nothing was checked" must never look the same,
+149
View File
@@ -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()
}
+134
View File
@@ -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)
}
}
@@ -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)
);
+46 -20
View File
@@ -49,6 +49,12 @@ type Node struct {
// month's assignments, and until this existed those looked the same as a node that is // month's assignments, and until this existed those looked the same as a node that is
// current (novox/hq 09-the-node-lifecycle). // current (novox/hq 09-the-node-lifecycle).
LastSeen time.Time 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. // 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 // (novox/hq 09-the-node-lifecycle), so the record is what a token binds to and must exist before
// there is anything to join. // there is anything to join.
func (i *Inventory) AddNode(ctx context.Context, name string) (Node, error) { 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) name = strings.TrimSpace(name)
if name == "" { if name == "" {
return Node{}, errors.New("a node needs a name: it is how a token is issued for it") return Node{}, errors.New("a node needs a name: it is how a token is issued for it")
} }
var n Node var n Node
var since *time.Time
err := i.store.Pool().QueryRow(ctx, err := i.store.Pool().QueryRow(ctx,
`insert into node (name) values ($1) returning id, name, created`, `insert into node (name, adopted, adopted_since)
name).Scan(&n.ID, &n.Name, &n.Created) 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 err != nil {
if strings.Contains(err.Error(), "node_name_key") { if strings.Contains(err.Error(), "node_name_key") {
return Node{}, fmt.Errorf("%w: %s", ErrNameTaken, name) return Node{}, fmt.Errorf("%w: %s", ErrNameTaken, name)
} }
return Node{}, err 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 return n, nil
} }
// Nodes are every node record, oldest first. // Nodes are every node record, oldest first.
func (i *Inventory) Nodes(ctx context.Context) ([]Node, error) { func (i *Inventory) Nodes(ctx context.Context) ([]Node, error) {
rows, err := i.store.Pool().Query(ctx, 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 { if err != nil {
return nil, err return nil, err
} }
@@ -103,14 +140,10 @@ func (i *Inventory) Nodes(ctx context.Context) ([]Node, error) {
var nodes []Node var nodes []Node
for rows.Next() { for rows.Next() {
var n Node n, err := scanNode(rows)
var seen *time.Time if err != nil {
if err := rows.Scan(&n.ID, &n.Name, &n.Created, &seen); err != nil {
return nil, err return nil, err
} }
if seen != nil {
n.LastSeen = *seen
}
nodes = append(nodes, n) nodes = append(nodes, n)
} }
return nodes, rows.Err() return nodes, rows.Err()
@@ -118,9 +151,8 @@ func (i *Inventory) Nodes(ctx context.Context) ([]Node, error) {
// NodeByName finds one node record. // NodeByName finds one node record.
func (i *Inventory) NodeByName(ctx context.Context, name string) (Node, error) { func (i *Inventory) NodeByName(ctx context.Context, name string) (Node, error) {
var n Node n, err := scanNode(i.store.Pool().QueryRow(ctx,
err := i.store.Pool().QueryRow(ctx, `select `+nodeColumns+` from node where name = $1`, name))
`select id, name, created from node where name = $1`, name).Scan(&n.ID, &n.Name, &n.Created)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return Node{}, fmt.Errorf("%w: %s", ErrNoSuchNode, name) 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 { if err != nil {
return Node{}, err return Node{}, err
} }
var n Node return scanNode(i.store.Pool().QueryRow(ctx, `select `+nodeColumns+` from node where id = $1`, id))
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
} }
// Spend makes a claimed token used, only for the presenter holding the claim. The last write to the // 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 return Node{}, err
} }
var n Node return scanNode(i.store.Pool().QueryRow(ctx, `select `+nodeColumns+` from node where id = $1`, id))
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
} }
// RecordProfile keeps the last thing a node said about what it can do. // RecordProfile keeps the last thing a node said about what it can do.
+5
View File
@@ -50,6 +50,11 @@ type Token struct {
// Secret is the one-time right to join. Useless once used, useless after it expires. // Secret is the one-time right to join. Useless once used, useless after it expires.
Secret string `json:"secret"` 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. // Missing names the parts that are not filled in.
+16
View File
@@ -140,6 +140,22 @@ func TestTheWireFormatIsExactlyTheseFieldNames(t *testing.T) {
if len(fields) != 6 { if len(fields) != 6 {
t.Errorf("the token has %d fields, expected 6: %v", len(fields), fields) 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) { func TestATokenWithNoNameIsRefused(t *testing.T) {