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
// 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>
+2 -1
View File
@@ -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
+74 -23
View File
@@ -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
+50
View File
@@ -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")
}
}
+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
// "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]})
+7
View File
@@ -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,