Merge branch 'fix/settings-assign-and-cli' into feat/adr-0056-compose-and-propagate
This commit is contained in:
@@ -3,6 +3,8 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/novox/mesh-control/internal/catalogue"
|
||||
)
|
||||
@@ -17,11 +19,25 @@ import (
|
||||
// Each returns what happened as text a person can read and a caller can pass on. Neither surface
|
||||
// composes its own explanation, because two explanations of one refusal drift.
|
||||
|
||||
// assign puts a module on a node, and says at once whether the whole set still resolves.
|
||||
// assign puts a module on a node, and says at once what in the mesh no longer works out.
|
||||
//
|
||||
// The assignment is kept even when it does not: it is what a person meant, and the refusal is
|
||||
// about the set rather than about this one. That is a decision, so it lives here rather than in
|
||||
// whichever surface asked.
|
||||
// The assignment is kept even when the node does not resolve: it is what a person meant, and
|
||||
// assignment is not an ordering. A consumer assigned before its provider does not resolve for as
|
||||
// long as it takes to assign the provider, and refusing the first half of a pair would make the
|
||||
// order somebody types two commands in part of the mesh's rules.
|
||||
//
|
||||
// **What is checked is the mesh, not the one machine** — because that is what the assignment
|
||||
// changes. novox/hq 04-ISSUES/017 names the shape: an action can succeed into a state its own
|
||||
// verify rejects, and it happens when the action's test is not the test the verify uses. Here the
|
||||
// action tested one node and the verify is resolution over all of them, so an assignment could be
|
||||
// reported as fine while it took a provision away from every other machine — a module offering a
|
||||
// mesh-scoped provision stops offering it the moment its own node stops resolving, and every
|
||||
// consumer elsewhere is then told *nothing in this mesh provides it*, with a remedy that names a
|
||||
// module already assigned. That was found on a four-node raise and read as a version bump breaking
|
||||
// provider recognition; it was neither the version nor the provider.
|
||||
//
|
||||
// It costs a resolution per machine. Assignment is a person typing a command, and being told which
|
||||
// machines this just blocked is worth more than the milliseconds.
|
||||
func assign(ctx context.Context, open *stores, node, module string) (string, error) {
|
||||
if err := open.inventory.Assign(ctx, node, module); err != nil {
|
||||
return "", err
|
||||
@@ -29,8 +45,9 @@ func assign(ctx context.Context, open *stores, node, module string) (string, err
|
||||
said := fmt.Sprintf("%s is assigned %s", node, module)
|
||||
plan, _, err := planFor(ctx, open, node)
|
||||
if err != nil {
|
||||
// Kept, and still refused. Both halves are the answer.
|
||||
return said, err
|
||||
// Kept, and still refused. Both halves are the answer, and the rest of the mesh is still
|
||||
// worth reporting: this machine's refusal is rarely the only consequence.
|
||||
return said + blockedElsewhere(ctx, open, node), err
|
||||
}
|
||||
// Kept, and cannot be hosted here. Said at once rather than discovered at push: a module whose
|
||||
// capability the machine lacks is on the wrong machine, and the assignment records what a person
|
||||
@@ -43,15 +60,66 @@ func assign(ctx context.Context, open *stores, node, module string) (string, err
|
||||
said += "\n but " + catalogue.WrongMachine(u.Module, c, node)
|
||||
}
|
||||
}
|
||||
return said + fmt.Sprintf("\n run `push %s` to send it", node), nil
|
||||
return said + fmt.Sprintf("\n run `push %s` to send it", node) +
|
||||
blockedElsewhere(ctx, open, node), nil
|
||||
}
|
||||
|
||||
// unassign takes a module off a node. What it leaves behind is the host's business: a directory
|
||||
// holding anything the mesh did not put there is kept (novox/hq ADR 0030).
|
||||
//
|
||||
// It reports the rest of the mesh for the same reason assign does, and more sharply: taking a
|
||||
// module off one machine is the ordinary way to stop providing something to another, and nothing
|
||||
// about the command's own output would ever have said so.
|
||||
func unassign(ctx context.Context, open *stores, node, module string) (string, error) {
|
||||
if err := open.inventory.Unassign(ctx, node, module); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%s no longer runs %s — run `push %s` to make it so",
|
||||
node, module, node), nil
|
||||
node, module, node) + blockedElsewhere(ctx, open, node), nil
|
||||
}
|
||||
|
||||
// blockedElsewhere is every OTHER machine that cannot be worked out as things now stand.
|
||||
//
|
||||
// **The state, not the cause.** Saying "this assignment broke laptop" would mean resolving the
|
||||
// whole mesh twice and would still be a guess about which of several changes did it; saying
|
||||
// "laptop cannot be worked out, and here is what it says" is true, is what somebody has to fix,
|
||||
// and cannot mislead. The machine that was just changed is left out because its own refusal is
|
||||
// already the answer beside this one.
|
||||
//
|
||||
// Nothing here can fail the act it reports on. A mesh that cannot be read is worth saying and is
|
||||
// not a reason to claim the assignment did not happen — it did.
|
||||
func blockedElsewhere(ctx context.Context, open *stores, except string) string {
|
||||
nodes, err := open.inventory.Nodes(ctx)
|
||||
if err != nil {
|
||||
return "\n\nThe rest of the mesh could not be checked: " + err.Error()
|
||||
}
|
||||
blocked := map[string]string{}
|
||||
for _, n := range nodes {
|
||||
if n.Name == except {
|
||||
continue
|
||||
}
|
||||
if _, _, err := planFor(ctx, open, n.Name); err != nil {
|
||||
blocked[n.Name] = err.Error()
|
||||
}
|
||||
}
|
||||
if len(blocked) == 0 {
|
||||
return ""
|
||||
}
|
||||
names := make([]string, 0, len(blocked))
|
||||
for name := range blocked {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
var out strings.Builder
|
||||
fmt.Fprintf(&out, "\n\nAND %d other machine(s) cannot be worked out as things stand, so "+
|
||||
"nothing will be sent to them:\n", len(names))
|
||||
for _, name := range names {
|
||||
fmt.Fprintf(&out, " %s\n", name)
|
||||
for _, line := range strings.Split(strings.TrimRight(blocked[name], "\n"), "\n") {
|
||||
fmt.Fprintf(&out, " %s\n", strings.TrimSpace(line))
|
||||
}
|
||||
}
|
||||
out.WriteString("\nThis may or may not be what just changed — it is what is true now.")
|
||||
return out.String()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-control/internal/catalogue"
|
||||
)
|
||||
|
||||
// What an assignment says about the machines it was not about.
|
||||
|
||||
// **An assignment that blocks another machine says so.**
|
||||
//
|
||||
// The shape found on a four-node raise: a module offering a mesh-scoped provision stops offering it
|
||||
// the moment its own node stops resolving, so an assignment to the provider's machine silently took
|
||||
// a provision away from every consumer elsewhere. Those consumers were then told *nothing in this
|
||||
// mesh provides it*, naming as the remedy a module that was already assigned — which read, on the
|
||||
// way back, as a version bump breaking provider recognition. It was neither the version nor the
|
||||
// provider: it was one machine's set of assignments, and nothing said so.
|
||||
//
|
||||
// novox/hq 04-ISSUES/017 names the general shape — an action succeeding into a state its own verify
|
||||
// rejects, because the action's test is not the test the verify uses. `assign` tested one node; the
|
||||
// verify is resolution over all of them.
|
||||
func TestAnAssignmentThatBlocksAnotherMachineSaysWhichAndWhy(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
|
||||
// A provider on the hub and a consumer on the other machine, both resolving.
|
||||
register(t, open, catalogue.Manifest{Module: "step-ca", Version: "1",
|
||||
Provides: []catalogue.Offer{{Name: "acme-ca", Scope: catalogue.ScopeMesh}},
|
||||
Serves: map[string]map[string]any{"acme-ca": {"path": "/acme/directory"}}})
|
||||
register(t, open, catalogue.Manifest{Module: "route-proxy", Version: "1",
|
||||
Requires: []string{"acme-ca"}})
|
||||
if _, err := assign(ctx, open, "anchor", "step-ca"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
said, err := assign(ctx, open, "laptop", "route-proxy")
|
||||
if err != nil {
|
||||
t.Fatalf("a mesh that should resolve did not: %v\n%s", err, said)
|
||||
}
|
||||
if strings.Contains(said, "cannot be worked out") {
|
||||
t.Fatalf("a well mesh was reported as blocked:\n%s", said)
|
||||
}
|
||||
|
||||
// Now break the hub's own set, with nothing but the command a person has.
|
||||
one, two := rivals()
|
||||
register(t, open, one)
|
||||
register(t, open, two)
|
||||
if _, err := assign(ctx, open, "anchor", "rival-one"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
said, err = assign(ctx, open, "anchor", "rival-two")
|
||||
if err == nil {
|
||||
t.Fatal("an assignment that makes its own node incoherent was not reported at all")
|
||||
}
|
||||
|
||||
// The node's own refusal is the error, as it always was. What is new is that the machines this
|
||||
// just took a provision away from are named in the same breath.
|
||||
if !strings.Contains(said, "laptop") {
|
||||
t.Fatalf("the machine this blocked is not named:\n%s\n\n%v", said, err)
|
||||
}
|
||||
if !strings.Contains(said, "acme-ca") {
|
||||
t.Fatalf("what laptop is now missing is not said:\n%s", said)
|
||||
}
|
||||
if !strings.Contains(said, "cannot be worked out as things stand") {
|
||||
t.Fatalf("the report does not say what state the mesh is in:\n%s", said)
|
||||
}
|
||||
// And the assignment is kept: it is what a person meant, and assignment is not an ordering.
|
||||
assigned, err := open.inventory.Assigned(ctx, "anchor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !contains(assigned, "rival-two") {
|
||||
t.Fatalf("the assignment was not kept: %v", assigned)
|
||||
}
|
||||
}
|
||||
|
||||
// A consumer assigned before its provider is refused for itself and kept, because assignment is not
|
||||
// an ordering — refusing the first half of a pair would make the order somebody types two commands
|
||||
// in part of the mesh's rules.
|
||||
func TestAConsumerAssignedBeforeItsProviderIsStillAssigned(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
register(t, open, catalogue.Manifest{Module: "route-proxy", Version: "1",
|
||||
Requires: []string{"acme-ca"}})
|
||||
|
||||
said, err := assign(ctx, open, "laptop", "route-proxy")
|
||||
if err == nil {
|
||||
t.Fatalf("a consumer with nothing to consume resolved:\n%s", said)
|
||||
}
|
||||
assigned, err := open.inventory.Assigned(ctx, "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !contains(assigned, "route-proxy") {
|
||||
t.Fatalf("assignment became an ordering: %v", assigned)
|
||||
}
|
||||
}
|
||||
|
||||
// Unassigning is the ordinary way to stop providing something to another machine, and it reports
|
||||
// the same way for the same reason.
|
||||
func TestUnassigningAProviderNamesWhoIsNowBlocked(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
register(t, open, catalogue.Manifest{Module: "step-ca", Version: "1",
|
||||
Provides: []catalogue.Offer{{Name: "acme-ca", Scope: catalogue.ScopeMesh}},
|
||||
Serves: map[string]map[string]any{"acme-ca": {"path": "/acme/directory"}}})
|
||||
register(t, open, catalogue.Manifest{Module: "route-proxy", Version: "1",
|
||||
Requires: []string{"acme-ca"}})
|
||||
if _, err := assign(ctx, open, "anchor", "step-ca"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := assign(ctx, open, "laptop", "route-proxy"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
said, err := unassign(ctx, open, "anchor", "step-ca")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(said, "laptop") || !strings.Contains(said, "acme-ca") {
|
||||
t.Fatalf("taking the provider away said nothing about who was consuming it:\n%s", said)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(all []string, one string) bool {
|
||||
for _, s := range all {
|
||||
if s == one {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -94,8 +96,7 @@ func board() http.Handler {
|
||||
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
body, err := statusAsJSON(asked.wrong, asked.nodes, asked.quiet, asked.behind, asked.sources,
|
||||
asked.waiting, asked.reported)
|
||||
body, err := statusAsJSON(asked)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
@@ -129,7 +130,21 @@ type view struct {
|
||||
Quiet []quietMachine
|
||||
Behind []staleModule
|
||||
Waiting []waitingMachine
|
||||
At string
|
||||
// Unresolved is every machine that cannot be worked out at all. Shown above everything else,
|
||||
// because a machine here is in none of the other lists: nothing was computed for it, so it is
|
||||
// not broken, not quiet and not behind — and a page without this said "all well" about a mesh
|
||||
// where nothing could be sent anywhere.
|
||||
Unresolved []blockedMachine
|
||||
// Network is why the private network could not be computed, when it could not.
|
||||
Network string
|
||||
At string
|
||||
}
|
||||
|
||||
type blockedMachine struct {
|
||||
Node string
|
||||
// Said is the mesh's own words, a line at a time. Every unmet requirement, not the first:
|
||||
// a machine is usually blocked by more than one and fixing one of them changes nothing.
|
||||
Said []string
|
||||
}
|
||||
|
||||
type waitingMachine struct {
|
||||
@@ -165,7 +180,20 @@ type staleModule struct {
|
||||
}
|
||||
|
||||
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}
|
||||
var blocked []string
|
||||
for name := range asked.refused {
|
||||
blocked = append(blocked, name)
|
||||
}
|
||||
sort.Strings(blocked)
|
||||
for _, name := range blocked {
|
||||
one := blockedMachine{Node: name}
|
||||
for _, line := range strings.Split(strings.TrimRight(asked.refused[name], "\n"), "\n") {
|
||||
one.Said = append(one.Said, strings.TrimSpace(line))
|
||||
}
|
||||
out.Unresolved = append(out.Unresolved, one)
|
||||
}
|
||||
for _, d := range asked.wrong {
|
||||
one := brokenMachine{Node: d.Node, Outcome: d.Outcome,
|
||||
When: d.At.Local().Format("2006-01-02 15:04")}
|
||||
@@ -223,6 +251,22 @@ find out.</p>
|
||||
{{else}}
|
||||
<h1>{{.Machines}} machine{{if ne .Machines 1}}s{{end}}</h1>
|
||||
|
||||
{{if .Unresolved}}
|
||||
<h2>Can everything be worked out?</h2>
|
||||
<ul>
|
||||
{{range .Unresolved}}
|
||||
<li><span class="outcome failed">blocked</span> <strong>{{.Node}}</strong>
|
||||
{{range .Said}}<div class="said">{{.}}</div>{{end}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
<p class="quiet">Nothing can be sent to a machine here, and it appears in none of the lists below:
|
||||
nothing was computed for it, so there is nothing it can be behind.</p>
|
||||
{{end}}
|
||||
{{if .Network}}
|
||||
<p class="failed">The private network could not be computed: {{.Network}}</p>
|
||||
{{end}}
|
||||
|
||||
<h2>Is anything broken?</h2>
|
||||
{{if .Broken}}
|
||||
<ul>
|
||||
|
||||
@@ -442,4 +442,13 @@ type answers struct {
|
||||
// pair that answers "has it caught up", which waiting alone cannot (the sent digest is
|
||||
// recorded at send, not at apply).
|
||||
reported []inventory.Reported
|
||||
// refused is why a machine cannot be worked out at all, by name. A different thing from every
|
||||
// other answer here: those are about a machine that was told something, and this is about one
|
||||
// that cannot be told anything — it never reaches waiting, because nothing was computed for it
|
||||
// to compare against, so without this a wholly blocked mesh reads as a well one.
|
||||
refused map[string]string
|
||||
// network is why the private network could not be computed, when it could not. Almost always
|
||||
// a consequence of the refusals above: a node that does not resolve is not on the network, and
|
||||
// a mesh whose hub is that node has no hub.
|
||||
network string
|
||||
}
|
||||
|
||||
@@ -123,6 +123,9 @@ func usage() {
|
||||
node add <name> create a node record
|
||||
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
|
||||
node public-domain <name> <d> ...set it to d
|
||||
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
|
||||
identity show this control plane's signing key
|
||||
@@ -134,7 +137,8 @@ func usage() {
|
||||
module add <file> register a module from its manifest
|
||||
module list what modules this mesh knows about
|
||||
module moved <name> <commit> the source has a newer commit than the mesh built
|
||||
module forget <name> remove one, unless a node is running it
|
||||
module forget <name> remove one, unless a node runs it or the mesh holds things for it
|
||||
module forget <name> --and-what-it-holds ...and discard its settings, secrets and ports too
|
||||
module issue <name> --node <m> a broker account for a module, scoped to its emits and consumes
|
||||
status [--json] what is wrong, what is quiet, and what is out of date
|
||||
board [--listen ADDR] the same three questions, as a page that holds nothing
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-control/internal/catalogue"
|
||||
"github.com/novox/mesh-control/internal/inventory"
|
||||
"github.com/novox/mesh-control/internal/licences"
|
||||
"github.com/novox/mesh-control/internal/overlay"
|
||||
)
|
||||
|
||||
// A mesh a command can be run against.
|
||||
//
|
||||
// The commands here were tested through the pieces they call and never through themselves, so
|
||||
// three faults that only exist where the pieces meet — an assignment reported as fine while it
|
||||
// blocked other machines, a read-shaped invocation that wrote, a JSON interface that stopped
|
||||
// emitting JSON — were invisible to every test in this package. This raises the real stores and
|
||||
// calls the real functions.
|
||||
|
||||
// aMesh is two placed, capable machines on a private network, with nothing assigned but the
|
||||
// network itself.
|
||||
//
|
||||
// `anchor` is the hub. That is not decoration: a mesh whose hub cannot be resolved has no private
|
||||
// network at all, which is how one machine's problem reaches every other.
|
||||
func aMesh(t *testing.T) *stores {
|
||||
t.Helper()
|
||||
inventory.ForTest(t) // raises the store, migrates it, and points the environment at it
|
||||
licences.ForTest(t) // planning reaches this one too, by name and never by connection
|
||||
|
||||
open, err := openStores(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(open.Close)
|
||||
for _, m := range provided {
|
||||
if err := open.inventory.Provide(t.Context(), m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
for i, name := range []string{"anchor", "laptop"} {
|
||||
record, err := open.inventory.AddNode(t.Context(), name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := open.inventory.SetPlace(t.Context(), name, name+".example:51820", "here",
|
||||
name == "anchor", fmt.Sprintf("10.77.0.%d", i+1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reported, err := json.Marshal(map[string]any{"capabilities": []map[string]any{
|
||||
{"name": "container-runtime", "present": true},
|
||||
{"name": "wireguard", "present": true},
|
||||
{"name": "systemd", "present": true},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var profile map[string]any
|
||||
if err := json.Unmarshal(reported, &profile); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := open.inventory.RecordProfile(t.Context(), record.ID, profile); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := open.inventory.RecordSealingKey(t.Context(), record.ID, aPublicKey(t)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := open.inventory.RecordOverlayKey(t.Context(), record.ID, aPublicKey(t)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := open.inventory.Assign(t.Context(), name, overlay.Name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return open
|
||||
}
|
||||
|
||||
// aPublicKey is a key a machine could have reported. Its private half is thrown away: nothing here
|
||||
// opens anything, it only needs the mesh to believe a machine has a key.
|
||||
func aPublicKey(t *testing.T) string {
|
||||
t.Helper()
|
||||
k, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(k.PublicKey().Bytes())
|
||||
}
|
||||
|
||||
// register puts a manifest in the catalogue.
|
||||
func register(t *testing.T, open *stores, m catalogue.Manifest) {
|
||||
t.Helper()
|
||||
if err := open.inventory.RegisterModule(t.Context(), m, inventory.Source{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// rivals are two modules that cannot share a machine, which is the shortest way to make a node's
|
||||
// own set of assignments incoherent using nothing but commands a person has.
|
||||
func rivals() (catalogue.Manifest, catalogue.Manifest) {
|
||||
claim := []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}
|
||||
return catalogue.Manifest{Module: "rival-one", Version: "1", Claims: claim},
|
||||
catalogue.Manifest{Module: "rival-two", Version: "1", Claims: claim}
|
||||
}
|
||||
@@ -188,13 +188,37 @@ func moduleCommand(ctx context.Context, args []string) error {
|
||||
return nil
|
||||
|
||||
case "forget":
|
||||
if len(args) != 2 {
|
||||
return errors.New("module forget <name>")
|
||||
}
|
||||
if err := inv.ForgetModule(ctx, args[1]); err != nil {
|
||||
// **What goes with it is said before it goes** (novox/hq 04-ISSUES/017). The settings, the
|
||||
// module's own secrets and the ports the mesh chose all cascade off the module row, so
|
||||
// `forget` used to destroy them and report "forgotten" — an action succeeding into a state
|
||||
// its own verify would reject, and a sealed secret is not recoverable afterwards.
|
||||
set := flag.NewFlagSet("module forget", flag.ContinueOnError)
|
||||
andHeld := set.Bool("and-what-it-holds", false,
|
||||
"discard its settings, its own secrets and its ports along with it")
|
||||
positionals, err := parseAround(set, args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%s forgotten\n", args[1])
|
||||
if len(positionals) != 1 {
|
||||
return errors.New("module forget <name> [--and-what-it-holds]")
|
||||
}
|
||||
if !*andHeld {
|
||||
if err := inv.ForgetModule(ctx, positionals[0]); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%s forgotten\n", positionals[0])
|
||||
return nil
|
||||
}
|
||||
held, err := inv.DiscardModule(ctx, positionals[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%s forgotten\n", positionals[0])
|
||||
for _, line := range held.Lines() {
|
||||
// Said after the fact as well as before it: this is the only record that these
|
||||
// existed, and the next person to ask why the module came back empty reads it here.
|
||||
fmt.Printf(" discarded%s\n", strings.TrimPrefix(line, " "))
|
||||
}
|
||||
return nil
|
||||
|
||||
case "issue":
|
||||
|
||||
@@ -59,7 +59,8 @@ func overlayCommand(ctx context.Context, args []string) error {
|
||||
|
||||
func overlayPlace(ctx context.Context, inv *inventory.Inventory, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("overlay place <node> [--endpoint host:port] [--site name] [--hub]")
|
||||
return errors.New(
|
||||
"overlay place <node> [--endpoint host:port] [--site name] [--hub], or --nothing")
|
||||
}
|
||||
node := args[0]
|
||||
|
||||
@@ -67,10 +68,31 @@ func overlayPlace(ctx context.Context, inv *inventory.Inventory, args []string)
|
||||
endpoint := set.String("endpoint", "", "where this node can be dialled, or empty for nowhere")
|
||||
site := set.String("site", "", "where this machine physically is, or empty if it roams")
|
||||
hub := set.Bool("hub", false, "this node is the hub every other routes through")
|
||||
nothing := set.Bool("nothing", false,
|
||||
"place it with nothing set: not dialable, no site, not the hub")
|
||||
if err := set.Parse(args[1:]); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// **All three are declared together, so saying nothing took all three away.** The sibling of
|
||||
// `node public-domain`: `overlay place anchor` reads like it places the node it names, and it
|
||||
// silently unset the endpoint every other machine dials, the site it is in, and the hub if it
|
||||
// was the hub — every path through it going with them, at the moment somebody was trying to
|
||||
// look at it.
|
||||
//
|
||||
// A placement with nothing set is a real thing to want — a machine that roams and opens every
|
||||
// path itself is exactly that — so it keeps a way to say so, by name.
|
||||
if set.NFlag() == 0 {
|
||||
return fmt.Errorf("overlay place %s was given nothing to place it with, and all three are "+
|
||||
"declared together — it would take away the endpoint other machines dial %s at, its "+
|
||||
"site, and the hub if it is the hub. Say --endpoint/--site/--hub, or --nothing if that "+
|
||||
"is what you meant", node, node)
|
||||
}
|
||||
if *nothing && (*endpoint != "" || *site != "" || *hub) {
|
||||
return fmt.Errorf("give %s a placement or --nothing, not both: they say opposite things "+
|
||||
"and the mesh will not choose between them", node)
|
||||
}
|
||||
|
||||
// Declared, all three. The address is evidence of reachability and is not the fact, and hub
|
||||
// election by address prefix fails silently (novox/hq ADR 0007).
|
||||
if err := inv.SetPlace(ctx, node, *endpoint, *site, *hub, ""); err != nil {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-control/internal/inventory"
|
||||
)
|
||||
|
||||
// placementOf is what the mesh holds about where one node is.
|
||||
func placementOf(t *testing.T, ctx context.Context, inv *inventory.Inventory, name string) inventory.Overlay {
|
||||
t.Helper()
|
||||
placed, err := inv.Overlays(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, one := range placed {
|
||||
if one.Name == name {
|
||||
return one
|
||||
}
|
||||
}
|
||||
t.Fatalf("%s is not placed at all", name)
|
||||
return inventory.Overlay{}
|
||||
}
|
||||
|
||||
// The sibling of `node public-domain`, and the worse one: a placement is three facts declared
|
||||
// together, so an invocation that said none of them took all three away — the endpoint every other
|
||||
// machine dials, the site, and the hub. A mesh whose hub was placed that way has no paths left.
|
||||
func TestPlacingANodeWithNothingSaidDoesNotUnplaceIt(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
if err := overlayPlace(ctx, open.inventory,
|
||||
[]string{"anchor", "--endpoint", "198.51.100.10:51820", "--site", "hosting", "--hub"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := overlayPlace(ctx, open.inventory, []string{"anchor"})
|
||||
if err == nil {
|
||||
t.Fatal("saying nothing unplaced the node instead of being refused")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--nothing") {
|
||||
t.Errorf("the refusal does not say how to mean it: %v", err)
|
||||
}
|
||||
|
||||
held := placementOf(t, ctx, open.inventory, "anchor")
|
||||
if held.Endpoint != "198.51.100.10:51820" || held.Site != "hosting" || !held.Hub {
|
||||
t.Fatalf("the placement was taken away by an invocation that was refused: %+v", held)
|
||||
}
|
||||
}
|
||||
|
||||
// Placing a machine with nothing set is a real thing to want — one that roams and opens every path
|
||||
// itself is exactly that — so it keeps a way to be said, by name.
|
||||
func TestPlacingANodeWithNothingIsAskedForByName(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
if err := overlayPlace(ctx, open.inventory,
|
||||
[]string{"anchor", "--endpoint", "198.51.100.10:51820", "--site", "hosting"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := overlayPlace(ctx, open.inventory, []string{"anchor", "--nothing"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
held := placementOf(t, ctx, open.inventory, "anchor")
|
||||
if held.Endpoint != "" || held.Site != "" || held.Hub {
|
||||
t.Fatalf("--nothing did not place it with nothing: %+v", held)
|
||||
}
|
||||
}
|
||||
|
||||
// Both at once cannot be meant, so neither silently wins.
|
||||
func TestAPlacementAndNothingTogetherIsRefused(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
if err := overlayPlace(ctx, open.inventory,
|
||||
[]string{"anchor", "--site", "hosting", "--nothing"}); err == nil {
|
||||
t.Fatal("a placement and --nothing together was accepted")
|
||||
}
|
||||
}
|
||||
+73
-21
@@ -22,7 +22,7 @@ import (
|
||||
|
||||
func nodeCommand(ctx context.Context, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("node add <name>, node list, node show <name>, or node public-domain <name> [domain]")
|
||||
return errors.New("node add <name>, node list, node show <name>, or " + publicDomainUsage)
|
||||
}
|
||||
open, err := openStores(ctx)
|
||||
if err != nil {
|
||||
@@ -66,32 +66,84 @@ func nodeCommand(ctx context.Context, args []string) error {
|
||||
return nil
|
||||
|
||||
case "public-domain":
|
||||
// The domain this node composes its routed names under (novox/hq ADR 0056). Given a domain,
|
||||
// it is set; given nothing, it is cleared — a node that stops facing the outside composes no
|
||||
// names. Lab-versus-production is this one setting and nothing else (see the ADR).
|
||||
if len(args) < 2 || len(args) > 3 {
|
||||
return errors.New(
|
||||
"node public-domain <name> [domain] — a domain sets it, nothing clears it")
|
||||
}
|
||||
domain := ""
|
||||
if len(args) == 3 {
|
||||
domain = args[2]
|
||||
}
|
||||
if err := inv.SetPublicDomain(ctx, args[1], domain); err != nil {
|
||||
return err
|
||||
}
|
||||
if domain == "" {
|
||||
fmt.Printf("%s has no public domain, so it composes no routed names\n", args[1])
|
||||
} else {
|
||||
fmt.Printf("%s composes its routed names under %s\n", args[1], domain)
|
||||
}
|
||||
return nil
|
||||
// The domain this node composes its routed names under (novox/hq ADR 0056).
|
||||
//
|
||||
// **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 0056) — 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>")
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// **A read-shaped invocation is never a destructive write.**
|
||||
//
|
||||
// `node public-domain anchor` used to clear the domain. It reads like a question — it is what
|
||||
// anybody types to find out what the answer is — and it silently took every routed name the node
|
||||
// had. There is no output that makes up for that: by the time it prints, the fact is gone.
|
||||
func TestAskingForANodesPublicDomainDoesNotTakeItAway(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
if err := open.inventory.SetPublicDomain(ctx, "anchor", "example.test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := publicDomain(ctx, open.inventory, []string{"anchor"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
domain, err := open.inventory.PublicDomainOf(ctx, "anchor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if domain != "example.test" {
|
||||
t.Fatalf("asking what the domain is took it away: %q", domain)
|
||||
}
|
||||
}
|
||||
|
||||
// Clearing is a real thing to want — a machine that stops facing the outside composes no names —
|
||||
// so it keeps a way to be said. What it stops being is what happens when nothing was said.
|
||||
func TestClearingANodesPublicDomainIsAskedForByName(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
if err := open.inventory.SetPublicDomain(ctx, "anchor", "example.test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := publicDomain(ctx, open.inventory, []string{"anchor", "--clear"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
domain, err := open.inventory.PublicDomainOf(ctx, "anchor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if domain != "" {
|
||||
t.Fatalf("--clear did not clear it: %q", domain)
|
||||
}
|
||||
}
|
||||
|
||||
// A domain sets it, as it always did.
|
||||
func TestGivingANodeAPublicDomainSetsIt(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
if err := publicDomain(ctx, open.inventory, []string{"anchor", "example.test"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
domain, err := open.inventory.PublicDomainOf(ctx, "anchor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if domain != "example.test" {
|
||||
t.Fatalf("got %q", domain)
|
||||
}
|
||||
}
|
||||
|
||||
// A domain and --clear say opposite things. Refused rather than one of them silently winning.
|
||||
func TestADomainAndClearTogetherIsRefused(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
if err := open.inventory.SetPublicDomain(ctx, "anchor", "example.test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := publicDomain(ctx, open.inventory, []string{"anchor", "other.test", "--clear"})
|
||||
if err == nil {
|
||||
t.Fatal("a domain and --clear together were accepted")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not both") {
|
||||
t.Fatalf("the refusal does not say why: %v", err)
|
||||
}
|
||||
// And neither half happened.
|
||||
domain, err := open.inventory.PublicDomainOf(ctx, "anchor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if domain != "example.test" {
|
||||
t.Fatalf("a refused command changed something: %q", domain)
|
||||
}
|
||||
}
|
||||
|
||||
// Asking about a name the mesh has never heard of is a refusal, not "it has no public domain" —
|
||||
// which is true of that name and says nothing.
|
||||
func TestAskingAboutAMachineTheMeshHasNeverHeardOfIsRefused(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
if err := publicDomain(t.Context(), open.inventory, []string{"nowhere"}); err == nil {
|
||||
t.Fatal("a name the mesh does not know was answered as if it were a machine")
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,8 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-control/internal/inventory"
|
||||
)
|
||||
|
||||
// The same answers, in a shape something other than a person can read.
|
||||
@@ -40,11 +39,30 @@ type meshStatus struct {
|
||||
// whose is older is still working — and Waiting cannot tell those apart, because the sent
|
||||
// digest is recorded at send, not at apply.
|
||||
Reported []machineReported `json:"reported"`
|
||||
// Unresolved is every machine that cannot be worked out at all, with what the mesh said when
|
||||
// it tried. **A machine here is in none of the lists above**: nothing was computed for it, so
|
||||
// there is nothing to compare it against and nothing it can be behind — which is why a
|
||||
// document without this field described a wholly blocked mesh as a well one.
|
||||
//
|
||||
// Per machine, and data. One node failing must never take the document away from a reader
|
||||
// asking about the others.
|
||||
Unresolved []machineUnresolved `json:"unresolved"`
|
||||
// Network is why the private network could not be computed, when it could not; absent when it
|
||||
// could. Almost always a consequence of Unresolved: a node that does not resolve is not on the
|
||||
// network, and a mesh whose hub is that node has no hub.
|
||||
Network string `json:"network,omitempty"`
|
||||
// Machines is how many the mesh knows about, so a reader can tell "none wrong" from
|
||||
// "none at all".
|
||||
Machines int `json:"machines"`
|
||||
}
|
||||
|
||||
type machineUnresolved struct {
|
||||
Node string `json:"node"`
|
||||
// Problem is the mesh's own words, whole — newlines and all. It lists every requirement that
|
||||
// could not be met, and a first line alone would name one of them and hide the rest.
|
||||
Problem string `json:"problem"`
|
||||
}
|
||||
|
||||
type machineDoing struct {
|
||||
Node string `json:"node"`
|
||||
// Outcome is refused or failed. Kept distinct all the way out: they are fixed in different
|
||||
@@ -92,14 +110,32 @@ type moduleBehind struct {
|
||||
On []string `json:"on"`
|
||||
}
|
||||
|
||||
// statusAsJSON answers the same three questions as the text form, from the same calls.
|
||||
func statusAsJSON(wrong []inventory.Doing, nodes []inventory.Node, quiet []inventory.Node,
|
||||
behind map[string][]string, sources map[string]inventory.Source,
|
||||
waiting []inventory.Machine, reported []inventory.Reported) ([]byte, error) {
|
||||
// statusAsJSON answers the same questions as the text form, from the same reading.
|
||||
//
|
||||
// **It takes the whole reading rather than a growing argument list**, which is what let a new
|
||||
// answer be added to the text form and forgotten here — the two are one function's output in two
|
||||
// shapes, and they must not be able to differ about what was asked.
|
||||
//
|
||||
// It never fails on account of the mesh. Every per-machine problem in here is a field, so one
|
||||
// machine that cannot be worked out cannot stop a caller reading about the others: a
|
||||
// machine-readable interface that stops being machine-readable exactly when something is wrong is
|
||||
// one nobody can build an alarm on.
|
||||
func statusAsJSON(asked answers) ([]byte, error) {
|
||||
wrong, nodes, quiet := asked.wrong, asked.nodes, asked.quiet
|
||||
behind, sources := asked.behind, asked.sources
|
||||
waiting, reported := asked.waiting, asked.reported
|
||||
|
||||
out := meshStatus{Machines: len(nodes), Wrong: []machineDoing{},
|
||||
Quiet: []machineQuiet{}, Behind: []moduleBehind{}, Waiting: []machineWaiting{},
|
||||
Reported: []machineReported{}}
|
||||
Reported: []machineReported{}, Unresolved: []machineUnresolved{},
|
||||
Network: asked.network}
|
||||
for name := range asked.refused {
|
||||
out.Unresolved = append(out.Unresolved, machineUnresolved{
|
||||
Node: name, Problem: asked.refused[name]})
|
||||
}
|
||||
sort.Slice(out.Unresolved, func(i, j int) bool {
|
||||
return out.Unresolved[i].Node < out.Unresolved[j].Node
|
||||
})
|
||||
for _, r := range reported {
|
||||
out.Reported = append(out.Reported, machineReported{
|
||||
Node: r.Node, Outcome: r.Outcome, At: r.At, Sent: r.Sent, Current: r.Current})
|
||||
|
||||
@@ -17,7 +17,8 @@ import (
|
||||
func statusOf(t *testing.T, wrong []inventory.Doing, nodes, quiet []inventory.Node,
|
||||
behind map[string][]string, sources map[string]inventory.Source) map[string]any {
|
||||
t.Helper()
|
||||
body, err := statusAsJSON(wrong, nodes, quiet, behind, sources, nil, nil)
|
||||
body, err := statusAsJSON(answers{
|
||||
wrong: wrong, nodes: nodes, quiet: quiet, behind: behind, sources: sources})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -123,10 +124,12 @@ func TestNoSecretIsInWhatABoardReads(t *testing.T) {
|
||||
// Everything here comes from the mesh's own records, which hold no readable secret — but a
|
||||
// shape a page is built against is exactly where one would eventually be added for
|
||||
// convenience, so this says it out loud.
|
||||
body, err := statusAsJSON(
|
||||
[]inventory.Doing{{Node: "a", Outcome: inventory.OutcomeRefused,
|
||||
body, err := statusAsJSON(answers{
|
||||
wrong: []inventory.Doing{{Node: "a", Outcome: inventory.OutcomeRefused,
|
||||
Refused: "resource \"x\": a file needs a path"}},
|
||||
[]inventory.Node{{Name: "a"}}, nil, nil, nil, nil, nil)
|
||||
nodes: []inventory.Node{{Name: "a"}},
|
||||
refused: map[string]string{"a": "nothing provides \"database\", wanted by web"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-control/internal/inventory"
|
||||
"github.com/novox/mesh-control/internal/overlay"
|
||||
)
|
||||
|
||||
// is anything broken, is anything not answering, is anything out of date.
|
||||
@@ -54,8 +55,7 @@ func statusCommand(ctx context.Context, args []string) error {
|
||||
behind, sources := asked.behind, asked.sources
|
||||
|
||||
if *asJSON {
|
||||
body, err := statusAsJSON(wrong, nodes, quiet, behind, sources, asked.waiting,
|
||||
asked.reported)
|
||||
body, err := statusAsJSON(asked)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -63,6 +63,30 @@ func statusCommand(ctx context.Context, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(asked.refused) > 0 {
|
||||
// First, above everything else. A machine that cannot be worked out is not running an old
|
||||
// declaration — it has no declaration, and nothing below this line is about it.
|
||||
var names []string
|
||||
for name := range asked.refused {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
fmt.Printf("%d machine(s) cannot be worked out at all, so nothing can be sent to them:\n\n",
|
||||
len(names))
|
||||
for _, name := range names {
|
||||
fmt.Printf(" %s\n", name)
|
||||
for _, line := range strings.Split(strings.TrimRight(asked.refused[name], "\n"), "\n") {
|
||||
fmt.Printf(" %s\n", strings.TrimSpace(line))
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
if asked.network != "" {
|
||||
fmt.Printf("the private network could not be computed:\n %s\n\n",
|
||||
strings.ReplaceAll(strings.TrimRight(asked.network, "\n"), "\n", "\n "))
|
||||
}
|
||||
|
||||
if len(wrong) > 0 {
|
||||
fmt.Printf("%d machine(s) are not doing what they were told:\n\n", len(wrong))
|
||||
for _, d := range wrong {
|
||||
@@ -139,7 +163,8 @@ func statusCommand(ctx context.Context, args []string) error {
|
||||
fmt.Printf("\n `push --behind` sends them\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 == "" {
|
||||
// Said plainly. "Nothing to report" and "nothing was checked" must never look the same,
|
||||
// and getting here means every question was asked and answered.
|
||||
fmt.Printf("%d machine(s), all doing what they were told, all heard from, running what "+
|
||||
@@ -197,12 +222,31 @@ func theThreeQuestions(ctx context.Context, open *stores) (answers, error) {
|
||||
if err != nil {
|
||||
return answers{}, err
|
||||
}
|
||||
// And why any machine cannot be worked out at all, which is neither of the first two questions
|
||||
// and is asked before them both in practice: a machine nothing can be computed for is not
|
||||
// broken, not quiet and not behind, and every other answer here would call it well.
|
||||
//
|
||||
// Read through whoResolves, which is what the private network is built from, so this and the
|
||||
// network agree about who could not be resolved rather than deciding it twice.
|
||||
_, out.refused, err = whoResolves(ctx, open, overlay.Addressing)
|
||||
if err != nil {
|
||||
return answers{}, err
|
||||
}
|
||||
|
||||
// And which machines are not running what the mesh would send them. The same question as a
|
||||
// module being behind its source, one level down: that one says the catalogue is out of date,
|
||||
// this one says a machine is — and only the second has anybody's change waiting in it.
|
||||
//
|
||||
// **One machine that cannot be resolved must not take the answer away from every other**
|
||||
// (novox/hq 04-ISSUES/017's sibling). This reaches the private network, and a mesh whose hub
|
||||
// is the blocked machine has no hub — which used to come back here as a refusal, so `status`
|
||||
// said nothing at all and `status --json` emitted prose to stderr and no JSON anywhere. The
|
||||
// reason is kept and reported as data; every question that does not depend on it is still
|
||||
// answered.
|
||||
would, err := wouldSend(ctx, open, out.nodes)
|
||||
if err != nil {
|
||||
return answers{}, err
|
||||
out.network = err.Error()
|
||||
would = map[string]string{}
|
||||
}
|
||||
out.waiting, err = inv.Waiting(ctx, would)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// **One machine's failure must never take the answer away from a reader asking about the others.**
|
||||
//
|
||||
// A blocked machine is not on the private network, and a mesh whose hub is that machine has no hub
|
||||
// — which came back through the reading as a refusal, so `status` printed nothing at all and
|
||||
// `status --json` emitted multi-line prose on stderr and not one byte of JSON. A machine-readable
|
||||
// interface that stops being machine-readable exactly when something is wrong is one nobody can
|
||||
// build an alarm on.
|
||||
func TestOneBlockedMachineDoesNotDestroyTheWholeDocument(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
|
||||
// Break the hub, using nothing but the command a person has.
|
||||
one, two := rivals()
|
||||
register(t, open, one)
|
||||
register(t, open, two)
|
||||
for _, m := range []string{"rival-one", "rival-two"} {
|
||||
if _, err := assign(ctx, open, "anchor", m); err != nil && m == "rival-one" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
asked, err := theThreeQuestions(ctx, open)
|
||||
if err != nil {
|
||||
t.Fatalf("one blocked machine made the whole mesh unreadable: %v", err)
|
||||
}
|
||||
body, err := statusAsJSON(asked)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
t.Fatalf("what a script would read is not JSON: %v\n%s", err, body)
|
||||
}
|
||||
|
||||
// The failure is in the document, as data, on the machine it belongs to.
|
||||
unresolved, _ := parsed["unresolved"].([]any)
|
||||
if len(unresolved) == 0 {
|
||||
t.Fatalf("a machine that cannot be worked out is absent from the document:\n%s", body)
|
||||
}
|
||||
var found bool
|
||||
for _, row := range unresolved {
|
||||
entry, _ := row.(map[string]any)
|
||||
if entry["node"] != "anchor" {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
problem, _ := entry["problem"].(string)
|
||||
if !strings.Contains(problem, "the-seat") {
|
||||
t.Errorf("the machine's problem is not its own words: %q", problem)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("the blocked machine is not the one named:\n%s", body)
|
||||
}
|
||||
// And the mesh is still counted, so a reader can tell "none blocked" from "none at all".
|
||||
if parsed["machines"] != float64(2) {
|
||||
t.Errorf("the rest of the document did not survive: %v", parsed["machines"])
|
||||
}
|
||||
}
|
||||
|
||||
// A well mesh carries the field as an empty list, not as nothing: a reader distinguishing "none
|
||||
// blocked" from "this field is missing" would have to handle both, and null is the one that gets
|
||||
// forgotten.
|
||||
func TestAWellMeshCarriesAnEmptyUnresolvedList(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
asked, err := theThreeQuestions(t.Context(), open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := statusAsJSON(asked)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, ok := parsed["unresolved"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("\"unresolved\" is %T, not a list:\n%s", parsed["unresolved"], body)
|
||||
}
|
||||
if len(list) != 0 {
|
||||
t.Fatalf("a well mesh reports blocked machines: %v", list)
|
||||
}
|
||||
if _, said := parsed["network"]; said {
|
||||
t.Errorf("a well mesh says the network could not be computed: %v", parsed["network"])
|
||||
}
|
||||
}
|
||||
|
||||
// And the page says it too, from the same reading. A board that renders "all well" over a mesh
|
||||
// where nothing can be sent anywhere is worse than a board that is down.
|
||||
func TestThePageSaysWhichMachinesCannotBeWorkedOut(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
one, two := rivals()
|
||||
register(t, open, one)
|
||||
register(t, open, two)
|
||||
for _, m := range []string{"rival-one", "rival-two"} {
|
||||
if _, err := assign(ctx, open, "anchor", m); err != nil && m == "rival-one" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
asked, err := theThreeQuestions(ctx, open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rendered := render(t, viewOf(asked))
|
||||
for _, want := range []string{"Can everything be worked out?", "anchor", "the-seat"} {
|
||||
if !strings.Contains(rendered, want) {
|
||||
t.Fatalf("the page does not say %q:\n%s", want, rendered)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user