mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group fix/315-a-failed-unit-is-a-condition delivered: every member is delivered
A module's failed unit now arrives among its resources and raises the module's own condition, named by the unit. The machine's own failed units, which no module places, are one warning for the machine listing them, cleared when none is listed. Nothing a send moved is among them, so the gate never holds a send on that finding. The statement's units are kept with the machine's health (migration 0080) and node show says them.
695 lines
26 KiB
Go
695 lines
26 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"net"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-controller/internal/broker"
|
|
"github.com/novox/mesh-controller/internal/catalogue"
|
|
"github.com/novox/mesh-controller/internal/conditions"
|
|
"github.com/novox/mesh-controller/internal/inventory"
|
|
"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":
|
|
return addNode(ctx, inv, args[1:])
|
|
|
|
case "list":
|
|
nodes, err := inv.Nodes(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// **The same list, for something other than a person** — the console's discovery reads it
|
|
// (novox/hq ADR 0195), and a reader that parses a printed column breaks when it is reworded.
|
|
if len(args) > 1 && args[1] == "--json" {
|
|
type listed struct {
|
|
Name string `json:"name"`
|
|
Heard string `json:"heard"`
|
|
Mode string `json:"mode"`
|
|
ID string `json:"id"`
|
|
}
|
|
out := make([]listed, 0, len(nodes))
|
|
for _, n := range nodes {
|
|
out = append(out, listed{Name: n.Name, Heard: heardFrom(n), Mode: modeOf(n), ID: n.ID})
|
|
}
|
|
return printJSON(out)
|
|
}
|
|
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 %-9s %s\n", n.Name, heardFrom(n), modeOf(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:])
|
|
|
|
case "networks":
|
|
// Removed by novox/hq ADR 0140, which superseded the record that added it. The filter no
|
|
// longer names any network: it constrains what arrives from outside the machine and says
|
|
// nothing about what did not, so there is no list to keep. Answered rather than met with
|
|
// "unknown command", because this was the documented way to stop a flip cutting a machine's
|
|
// containers off and somebody will reasonably still type it.
|
|
return errors.New("`node networks` is gone (novox/hq ADR 0140). The filter constrains what " +
|
|
"arrives from outside this machine and says nothing about traffic that did not, so no " +
|
|
"network is named anywhere and nothing needs to be said to keep a machine's own " +
|
|
"containers reaching outward. The machine reports which of its links face outside; see " +
|
|
"`node show <name>`")
|
|
|
|
case "account":
|
|
// The operator's login on this machine (novox/hq to-be 29): what a home-scoped file is
|
|
// owned by and which account `ssh <node>` uses. Reports with no argument; sets with one;
|
|
// an optional second argument is the home when it is not /home/<account>.
|
|
return nodeAccount(ctx, inv, args[1:])
|
|
|
|
default:
|
|
return fmt.Errorf("node has no %q; it has add, list, show, public-domain and account", args[0])
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
// nodeAccount reports or sets a node's operator account (novox/hq to-be 29). Read-shaped with no
|
|
// argument, like public-domain: `node account novox` answers, it does not change anything.
|
|
func nodeAccount(ctx context.Context, inv *inventory.Inventory, positionals []string) error {
|
|
if len(positionals) == 0 || len(positionals) > 3 {
|
|
return errors.New("node account <name> — what it is now; " +
|
|
"node account <name> <account> [home] — set it (home defaults to /home/<account>)")
|
|
}
|
|
node := positionals[0]
|
|
if len(positionals) == 1 {
|
|
who, err := inv.NodeByName(ctx, node)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if who.Account == "" {
|
|
fmt.Printf("%s has no operator account known\n", node)
|
|
fmt.Printf(" `node account %s <account>` sets it\n", node)
|
|
return nil
|
|
}
|
|
fmt.Printf("%s logs a person in as %s (home %s)\n", node, who.Account, who.Home())
|
|
return nil
|
|
}
|
|
home := ""
|
|
if len(positionals) == 3 {
|
|
home = positionals[2]
|
|
}
|
|
if err := inv.SetAccount(ctx, node, positionals[1], home); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("%s logs a person in as %s\n", node, positionals[1])
|
|
fmt.Printf(" run `push %s` once ssh-client is assigned, to send its operator config\n", node)
|
|
return nil
|
|
}
|
|
|
|
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")
|
|
adopted := set.Bool("adopted", false,
|
|
"the machine joining is in use: it is adopted, and keeps what is found on it")
|
|
tunnelKey := set.String("overlay-key", "",
|
|
"the public half of the tunnel key the machine made (`nox-mesh-host key`): it joins through the tunnel")
|
|
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
|
|
|
|
issued, err := issueFor(ctx, inv, *existing, *fresh, *adopted, *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.
|
|
|
|
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.
|
|
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
|
|
}
|
|
// **Through the tunnel** (novox/hq ADR 0169): the machine's key recorded, its address given, the
|
|
// hub sent it as a peer — all before the token is shown, so the tunnel answers the first time the
|
|
// machine knocks. The bus is then reached at its address on the private network.
|
|
if *tunnelKey != "" {
|
|
if made.Tunnel, made.Broker, err = throughTheTunnel(ctx, open, issued.Node, *tunnelKey, made.Broker); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
encoded, err := made.Encode()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
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 {
|
|
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
|
|
}
|
|
|
|
// throughTheTunnel makes a machine a peer of the hub for its token, and says what the token carries
|
|
// for it: its first tunnel, and the bus at its address on the private network (novox/hq ADR 0169).
|
|
//
|
|
// The hub is pushed here, before the token is shown. A token shown before the hub knew the key is a
|
|
// tunnel that does not answer, and a machine that cannot tell that from a bus that is down.
|
|
func throughTheTunnel(ctx context.Context, open *stores, node inventory.Node, key, busAt string) (
|
|
*token.Tunnel, string, error) {
|
|
inv := open.inventory
|
|
key = strings.TrimSpace(key)
|
|
if raw, err := base64.StdEncoding.DecodeString(key); err != nil || len(raw) != 32 {
|
|
return nil, "", fmt.Errorf("%q is not a tunnel public key: it is 32 bytes in base64, as "+
|
|
"`nox-mesh-host key` prints it", key)
|
|
}
|
|
// The bus on the private network is its holder's address at the bus's own port, so the port must
|
|
// be known before anything is recorded.
|
|
_, port, err := net.SplitHostPort(busAt)
|
|
if err != nil || port == "" {
|
|
return nil, "", fmt.Errorf("the bus's address %q has no port to reach it on", busAt)
|
|
}
|
|
places, err := inv.Overlays(ctx)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
var hub *inventory.Overlay
|
|
for i := range places {
|
|
if places[i].Hub {
|
|
hub = &places[i]
|
|
}
|
|
}
|
|
if hub == nil || hub.Key == "" || hub.Endpoint == "" || hub.Address == "" {
|
|
return nil, "", errors.New("this mesh has no hub with a key, an address and an endpoint to " +
|
|
"dial, so there is no tunnel to join through: place one (`overlay place <node> --hub " +
|
|
"--endpoint <host>:<port>`), or issue the token without --overlay-key")
|
|
}
|
|
if err := inv.RecordOverlayKey(ctx, node.ID, key); err != nil {
|
|
return nil, "", err
|
|
}
|
|
if err := inv.BindTokenToKey(ctx, node.ID, key); err != nil {
|
|
return nil, "", err
|
|
}
|
|
cidr, err := overlayRange(ctx, inv)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
address, err := inv.AssignAddress(ctx, node.ID, cidr)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
// The bus at its holder's address on the private network, reached through the hub like the rest
|
|
// of the range; the hub's own when no machine is recorded as holding it yet.
|
|
busAddress := hub.Address
|
|
if holders, err := seatHolders(ctx, inv); err != nil {
|
|
return nil, "", err
|
|
} else if h, held := holders[catalogue.BrokerSeat]; held {
|
|
for _, p := range places {
|
|
if p.Name == h.Node && p.Address != "" {
|
|
busAddress = p.Address
|
|
}
|
|
}
|
|
}
|
|
if err := sendTo(ctx, open, []string{hub.Name}); err != nil {
|
|
return nil, "", fmt.Errorf("%s was made a peer of the hub, and the hub could not be sent "+
|
|
"it, so the tunnel would not answer — the token is not shown; issue it again once %s "+
|
|
"can be pushed: %w", node.Name, hub.Name, err)
|
|
}
|
|
// An address, not a name — nothing resolves before the machine has joined (novox/hq ADR 0004).
|
|
return &token.Tunnel{
|
|
Key: key, Address: address + "/32", Range: cidr,
|
|
HubKey: hub.Key, HubEndpoint: hub.Endpoint,
|
|
}, net.JoinHostPort(busAddress, port), 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
|
|
}
|
|
// Not saying adopted 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). And saying it for a
|
|
// node already converged is refused rather than done quietly: returning a node to adopted is
|
|
// its own act too, which unloads the mesh's filter and enables the found firewall again.
|
|
if adopted && fresh == "" {
|
|
node, err := inv.NodeByName(ctx, name)
|
|
if err != nil {
|
|
return inventory.Issued{}, err
|
|
}
|
|
if !node.Adopted {
|
|
return inventory.Issued{}, fmt.Errorf("%s is converged, and a token does not change "+
|
|
"that: run `adopt %s` to return it to adopted, then issue the token without "+
|
|
"--adopted", name, name)
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
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(ctx context.Context, args []string) error {
|
|
if len(args) > 0 && args[0] == "certificate" {
|
|
return busCertificate(args[1:])
|
|
}
|
|
if len(args) > 0 && args[0] == "accounts" {
|
|
return busAccounts(ctx, args[1:])
|
|
}
|
|
if len(args) > 0 && args[0] == "consumer-reset" {
|
|
return consumerReset(ctx, args[1:])
|
|
}
|
|
if len(args) == 0 || args[0] != "show" {
|
|
return errors.New("broker show | broker certificate [--check] --into <directory> | broker accounts --into <file> | " +
|
|
"broker consumer-reset <stream> <consumer>")
|
|
}
|
|
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
|
|
}
|
|
|
|
// consumerReset re-makes one consumer on a stream that keeps history to start from now (novox/hq issue
|
|
// 248): the way out of a consumer replaying a week of announcements, said rather than done by hand. A
|
|
// person's act — what was pending is dropped — so it is a command, and nothing calls it on its own.
|
|
func consumerReset(ctx context.Context, args []string) error {
|
|
set := flag.NewFlagSet("broker consumer-reset", flag.ContinueOnError)
|
|
// A repair by hand, which says why (novox/hq to-be 45 §7).
|
|
why := addHandActFlags(set)
|
|
args, err := parseAround(set, args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(args) != 2 {
|
|
return errors.New("broker consumer-reset <stream> <consumer> --why <text>, e.g. broker consumer-reset EVENTS controller --why ...")
|
|
}
|
|
if err := why.require("broker consumer-reset"); err != nil {
|
|
return err
|
|
}
|
|
why.record(ctx, "broker consumer-reset", args)
|
|
address, err := broker.BusAddress()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
js, err := broker.Dial(address)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot reach the bus: %w", err)
|
|
}
|
|
defer js.Close()
|
|
before, after, err := js.ResetConsumer(args[0], args[1])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("consumer %s on %s re-made to deliver from now\n", args[1], args[0])
|
|
fmt.Printf(" before: delivers %s, delivered to %d, acknowledged to %d, %d pending, %d unacknowledged\n",
|
|
before.DeliverPolicy, before.Delivered, before.AckFloor, before.Pending, before.AckPending)
|
|
fmt.Printf(" after: delivers %s, %d pending; what was pending is dropped. A holder bound to it may need its "+
|
|
"process restarted to bind again\n", after.DeliverPolicy, after.Pending)
|
|
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))
|
|
// Which host runs it, as it reported (novox/hq 04-ISSUES/087). Said whenever known, because a
|
|
// host refuses a declaration carrying a field it does not understand and refuses it WHOLE — so
|
|
// which host a machine runs is what decides whether the mesh can send it anything new, and
|
|
// nothing could say it. "not reported" rather than blank: a machine that has not said is a
|
|
// different thing from one running nothing.
|
|
fmt.Printf(" host %s\n", orNotReported(node.HostVersion))
|
|
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
|
|
// 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)
|
|
}
|
|
|
|
// Every open condition about this machine (novox/hq to-be 45 §2) — a provider here failing a
|
|
// consumer, or a consumer here failed, among them (ADR 0224). Before the capabilities, because it
|
|
// is something not working now and they are a description. Unreadable is said, not passed over.
|
|
open, err := openConditions(ctx)
|
|
if err != nil {
|
|
fmt.Printf("\n the open conditions could NOT be read, so whether anything here is wrong is not known: %v\n", err)
|
|
}
|
|
var here []conditions.Condition
|
|
for _, c := range open {
|
|
if concerns(c, name) {
|
|
here = append(here, c)
|
|
}
|
|
}
|
|
if len(here) > 0 {
|
|
fmt.Printf("\n %d open condition(s) about this machine:\n", len(here))
|
|
for _, line := range conditionLines(here, time.Now()) {
|
|
fmt.Printf(" %s\n", line)
|
|
}
|
|
}
|
|
|
|
// What it runs for its modules, and how each is (novox/hq ADR 0240): "is it working" answered without
|
|
// a terminal on the machine.
|
|
if h, had, err := inv.HealthOf(ctx, name); err != nil {
|
|
fmt.Printf("\n what it says of what it runs could NOT be read: %v\n", err)
|
|
} else {
|
|
fmt.Println()
|
|
for _, line := range healthLines(h, had, time.Now()) {
|
|
fmt.Println(line)
|
|
}
|
|
for _, line := range networkLines(h, had, time.Now()) {
|
|
fmt.Println(line)
|
|
}
|
|
for _, line := range unitsLines(h, had, time.Now()) {
|
|
fmt.Println(line)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// orNotReported is a fact a machine states about itself, or the fact that it has not.
|
|
func orNotReported(s string) string {
|
|
if strings.TrimSpace(s) == "" {
|
|
return "not reported — this machine has not said since the mesh began keeping it"
|
|
}
|
|
return s
|
|
}
|
|
|
|
// printJSON prints a value as indented JSON, the shape every `--json` answers in.
|
|
func printJSON(v any) error {
|
|
body, err := json.MarshalIndent(v, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Println(string(body))
|
|
return nil
|
|
}
|