One name per thing, per the HQ glossary: the module/container/image/binary/repo becomes mesh-controller, the seat the-controller, and the store+broker pair the foundation (embedded base bundles, default template and example lock renamed with their go:embed directives). No behaviour change — a pure vocabulary rename. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
384 lines
13 KiB
Go
384 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-controller/internal/broker"
|
|
"github.com/novox/mesh-controller/internal/inventory"
|
|
"github.com/novox/mesh-controller/internal/link"
|
|
"github.com/novox/mesh-controller/internal/token"
|
|
)
|
|
|
|
// what a machine is, and what it is allowed to be told.
|
|
//
|
|
// Split out of main.go, which had reached 2,769 lines because appending was always the
|
|
// cheapest next step. That is how novox/hq ADR 0001 records `hal/sdk` reaching 34,636:
|
|
// nothing in it was wrong, and no one edit was the one that should have been a new file.
|
|
|
|
func nodeCommand(ctx context.Context, args []string) error {
|
|
if len(args) == 0 {
|
|
return errors.New("node add <name>, node list, node show <name>, or " + publicDomainUsage)
|
|
}
|
|
open, err := openStores(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer open.Close()
|
|
inv := open.inventory
|
|
|
|
switch args[0] {
|
|
case "show":
|
|
if len(args) != 2 {
|
|
return errors.New("node show <name>")
|
|
}
|
|
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
|
|
|
|
case "list":
|
|
nodes, err := inv.Nodes(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(nodes) == 0 {
|
|
// Said rather than printed as nothing: an empty list and a failed read must never
|
|
// look the same, and this command answering "none" is only honest because getting
|
|
// here means the store answered.
|
|
fmt.Println("this mesh has no node records yet")
|
|
return nil
|
|
}
|
|
for _, n := range nodes {
|
|
fmt.Printf("%-20s %-14s %s\n", n.Name, heardFrom(n), n.ID)
|
|
}
|
|
return nil
|
|
|
|
case "public-domain":
|
|
// The domain this node composes its routed names under (novox/hq ADR 0066).
|
|
//
|
|
// **The form with no argument reports; clearing is asked for by name.** It used to clear —
|
|
// so `node public-domain anchor`, which reads like a question and is what anybody types to
|
|
// find out what the answer is, silently took every routed name the node had. A read-shaped
|
|
// invocation must never be a destructive write: there is no output that makes up for it,
|
|
// because the damage is already done by the time it prints.
|
|
return publicDomain(ctx, inv, args[1:])
|
|
|
|
default:
|
|
return fmt.Errorf("node has no %q; it has add, list, show and public-domain", args[0])
|
|
}
|
|
}
|
|
|
|
// 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"
|
|
|
|
// publicDomain reads, sets or clears the domain a node composes its routed names under.
|
|
//
|
|
// Three forms, and the destructive one is the only one that has to be asked for. Clearing is a
|
|
// real thing to want — a machine that stops facing the outside composes no names, and
|
|
// lab-versus-production is this one setting (novox/hq ADR 0066) — so it keeps a way to say it.
|
|
// What it does not keep is being the thing that happens when nothing was said at all.
|
|
func publicDomain(ctx context.Context, inv *inventory.Inventory, args []string) error {
|
|
set := flag.NewFlagSet("node public-domain", flag.ContinueOnError)
|
|
clear := set.Bool("clear", false, "take the domain away; it composes no routed names after")
|
|
positionals, err := parseAround(set, args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(positionals) == 0 || len(positionals) > 2 {
|
|
return errors.New(publicDomainUsage)
|
|
}
|
|
node := positionals[0]
|
|
|
|
switch {
|
|
case *clear && len(positionals) == 2:
|
|
// Both, which cannot be meant. Refused rather than one of them silently winning.
|
|
return fmt.Errorf("give %s a domain or --clear, not both: %q and --clear say opposite "+
|
|
"things and the mesh will not choose between them", node, positionals[1])
|
|
|
|
case *clear:
|
|
if err := inv.SetPublicDomain(ctx, node, ""); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("%s has no public domain, so it composes no routed names\n", node)
|
|
fmt.Printf(" run `push %s` to take them off it\n", node)
|
|
return nil
|
|
|
|
case len(positionals) == 2:
|
|
if err := inv.SetPublicDomain(ctx, node, positionals[1]); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("%s composes its routed names under %s\n", node, positionals[1])
|
|
fmt.Printf(" run `push %s` to send it\n", node)
|
|
return nil
|
|
|
|
default:
|
|
// Asked, so answered. NodeByName first, so a name the mesh has never heard of is a refusal
|
|
// rather than "it has no public domain", which is true of that name and says nothing.
|
|
if _, err := inv.NodeByName(ctx, node); err != nil {
|
|
return err
|
|
}
|
|
domain, err := inv.PublicDomainOf(ctx, node)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if domain == "" {
|
|
fmt.Printf("%s has no public domain, so it composes no routed names\n", node)
|
|
fmt.Printf(" `node public-domain %s <domain>` gives it one\n", node)
|
|
return nil
|
|
}
|
|
fmt.Printf("%s composes its routed names under %s\n", node, domain)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func tokenCommand(ctx context.Context, args []string) error {
|
|
if len(args) == 0 || args[0] != "issue" {
|
|
return errors.New("token issue --node <name>, or token issue --new <name>")
|
|
}
|
|
|
|
set := flag.NewFlagSet("token issue", flag.ContinueOnError)
|
|
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")
|
|
if err := set.Parse(args[1:]); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Exactly one, because the difference is what the token binds to. A command that guessed
|
|
// would sometimes create a second record for a machine that already has one.
|
|
if (*existing == "") == (*fresh == "") {
|
|
return errors.New("give exactly one of --node <name> or --new <name>: the first is a " +
|
|
"machine the mesh already has a record for, the second is one it has never seen")
|
|
}
|
|
|
|
open, err := openStores(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
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)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Assembled from two contexts by the process that holds both grants. Neither reads the
|
|
// other's store (novox/hq ADR 0008) — each is asked for its own part.
|
|
ident, err := openIdentity(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer ident.Close()
|
|
key, err := ident.Establish(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// The account is created before the token is handed over, which is what removes the
|
|
// chicken-and-egg entirely: the mesh runs the broker, so a joining node's credentials can
|
|
// exist before it does. The one-time secret IS the password, so a node's first connection is
|
|
// already authenticated and enrolment is what happens over it.
|
|
if management, err := broker.ManagementFromEnvironment(); err == nil {
|
|
if err := management.CreateNodeAccount(ctx, issued.Node.Name, issued.Secret); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("broker account %s created, scoped to %s and the %s exchange\n\n",
|
|
issued.Node.Name, link.QueueFor(issued.Node.Name), link.Exchange)
|
|
} else if !errors.Is(err, broker.ErrNotConfigured) {
|
|
return err
|
|
}
|
|
|
|
made := token.Token{Node: issued.Node.Name, Signer: key.Public, Secret: issued.Secret}
|
|
|
|
// 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.
|
|
known, err := broker.FromEnvironment()
|
|
switch {
|
|
case err == nil:
|
|
made.Broker, made.Fingerprint = known.Address, known.Fingerprint
|
|
case errors.Is(err, broker.ErrNotConfigured):
|
|
default:
|
|
return err
|
|
}
|
|
encoded, err := made.Encode()
|
|
if err != nil {
|
|
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)
|
|
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 {
|
|
fmt.Printf("\nINCOMPLETE — this token cannot be used to join anything yet. Missing:\n")
|
|
for _, m := range missing {
|
|
fmt.Printf(" - %s\n", m)
|
|
}
|
|
fmt.Printf("\nSet %s and %s once the broker is raised.\n",
|
|
broker.AddressVar, broker.CertificateVar)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func identityCommand(ctx context.Context, args []string) error {
|
|
if len(args) == 0 || args[0] != "show" {
|
|
return errors.New("identity show")
|
|
}
|
|
ident, err := openIdentity(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer ident.Close()
|
|
|
|
// Establish rather than read: a control plane asked for its identity before it has one should
|
|
// get one, not an error. Generating it is idempotent, so this is safe to run at any time.
|
|
key, err := ident.Establish(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("signing key %s\n", key.ID)
|
|
fmt.Printf("fingerprint %s\n", key.Fingerprint())
|
|
fmt.Printf("created %s\n", key.Created.Format(time.RFC3339))
|
|
fmt.Printf("\nThe public half of this travels in every enrolment token. A node believes a\n" +
|
|
"declaration because it carries a signature this key made (novox/hq ADR 0004).\n")
|
|
return nil
|
|
}
|
|
|
|
func brokerCommand(args []string) error {
|
|
if len(args) == 0 || args[0] != "show" {
|
|
return errors.New("broker show")
|
|
}
|
|
known, err := broker.FromEnvironment()
|
|
if errors.Is(err, broker.ErrNotConfigured) {
|
|
fmt.Printf("no broker configured. Set %s and %s.\n\n"+
|
|
"Until then tokens carry the signing key and the one-time secret, and say what they\n"+
|
|
"are missing. They cannot be used to join.\n",
|
|
broker.AddressVar, broker.CertificateVar)
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("address %s\n", known.Address)
|
|
fmt.Printf("fingerprint %s\n", known.Fingerprint)
|
|
fmt.Print("\nThe fingerprint is computed from the certificate on disk, never configured. A\n" +
|
|
"node checks it before sending anything (novox/hq ADR 0004).\n")
|
|
return nil
|
|
}
|
|
|
|
// heardFrom says when a node was last heard from, in a form somebody can act on.
|
|
//
|
|
// "never" and "an hour ago" are different answers and are kept different. A node that has never
|
|
// spoken did not finish joining; a node last heard from an hour ago is running an hour-old
|
|
// picture of the mesh.
|
|
func heardFrom(n inventory.Node) string {
|
|
silent, ever := n.Silent()
|
|
switch {
|
|
case !ever:
|
|
return "never spoken"
|
|
case silent > SilentFor:
|
|
return "out of touch " + roughly(silent)
|
|
default:
|
|
return "here"
|
|
}
|
|
}
|
|
|
|
// roughly is a duration a person reads rather than parses.
|
|
func roughly(d time.Duration) string {
|
|
switch {
|
|
case d < time.Hour:
|
|
return fmt.Sprintf("%dm", int(d.Minutes()))
|
|
case d < 48*time.Hour:
|
|
return fmt.Sprintf("%dh", int(d.Hours()))
|
|
default:
|
|
return fmt.Sprintf("%dd", int(d.Hours()/24))
|
|
}
|
|
}
|
|
|
|
// showNode says what one machine reported about itself, in its own words.
|
|
//
|
|
// **A capability is detected and never assumed** (novox/hq ADR 0009), so the only account of what
|
|
// a machine can do is the one it gave — and its detail is half of that account. The mesh was
|
|
// keeping the yes and discarding the reason, which makes *this machine has no seat* an answer with
|
|
// nowhere to go: a person told a machine lacks something wants to know what the detector saw.
|
|
//
|
|
// It is also where "what should it be configured as" is read. The same line that gates an
|
|
// assignment carries `card1-DP-1`, and a person composing settings for that machine needs it.
|
|
func showNode(ctx context.Context, inv *inventory.Inventory, name string) error {
|
|
node, err := inv.NodeByName(ctx, name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("%s\n", node.Name)
|
|
fmt.Printf(" last heard from %s\n", heardFrom(node))
|
|
|
|
// 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
|
|
// every internal node would be noise.
|
|
domain, err := inv.PublicDomainOf(ctx, name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if domain != "" {
|
|
fmt.Printf(" public domain %s\n", domain)
|
|
}
|
|
|
|
held, err := inv.Profile(ctx, name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if held == nil {
|
|
// Never reported is not the same as reported nothing, and the remedy differs: one is a
|
|
// machine that has not run the host yet, the other is a machine that ran it and can do
|
|
// nothing.
|
|
fmt.Printf("\n this machine has never said what it can do, so everything requiring a\n" +
|
|
" capability is refused here — run the host on it\n")
|
|
return nil
|
|
}
|
|
if len(held) == 0 {
|
|
fmt.Printf("\n it reported no capabilities at all\n")
|
|
return nil
|
|
}
|
|
|
|
fmt.Printf("\n what it can do, as it reported:\n")
|
|
for _, c := range held {
|
|
mark := "no "
|
|
if c.Present {
|
|
mark = "yes"
|
|
}
|
|
fmt.Printf(" %s %-20s %s\n", mark, c.Name, c.Detail)
|
|
}
|
|
|
|
assigned, err := inv.Assigned(ctx, name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(assigned) > 0 {
|
|
fmt.Printf("\n assigned: %s\n", strings.Join(assigned, ", "))
|
|
}
|
|
return nil
|
|
}
|