Adoption mode: a node in use is adopted before it is converged (hq ADR 0100–0103) #44
@@ -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
|
||||
}
|
||||
@@ -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.</p>
|
||||
<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>
|
||||
{{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}}
|
||||
|
||||
<footer>Read at {{.At}}. This page holds nothing and changes nothing.</footer>
|
||||
|
||||
@@ -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 <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 show <name> what one machine reported it can do, and why
|
||||
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
|
||||
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 ... --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
|
||||
|
||||
@@ -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 <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
|
||||
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 <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.
|
||||
const publicDomainUsage = "node public-domain <name> — what it is now; " +
|
||||
"<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")
|
||||
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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]})
|
||||
|
||||
@@ -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 <node>` 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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user