diff --git a/cmd/mesh-control/acts.go b/cmd/mesh-control/acts.go index 0586ceb..7911024 100644 --- a/cmd/mesh-control/acts.go +++ b/cmd/mesh-control/acts.go @@ -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() } diff --git a/cmd/mesh-control/acts_test.go b/cmd/mesh-control/acts_test.go new file mode 100644 index 0000000..7cbc642 --- /dev/null +++ b/cmd/mesh-control/acts_test.go @@ -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 +} diff --git a/cmd/mesh-control/board.go b/cmd/mesh-control/board.go index fc11d2a..49702ac 100644 --- a/cmd/mesh-control/board.go +++ b/cmd/mesh-control/board.go @@ -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.

{{else}}

{{.Machines}} machine{{if ne .Machines 1}}s{{end}}

+{{if .Unresolved}} +

Can everything be worked out?

+ +

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.

+{{end}} +{{if .Network}} +

The private network could not be computed: {{.Network}}

+{{end}} +

Is anything broken?

{{if .Broken}}