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/mesh_for_test.go b/cmd/mesh-control/mesh_for_test.go new file mode 100644 index 0000000..43ff46f --- /dev/null +++ b/cmd/mesh-control/mesh_for_test.go @@ -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} +}