diff --git a/cmd/mesh-control/main.go b/cmd/mesh-control/main.go index 3a74939..d783f73 100644 --- a/cmd/mesh-control/main.go +++ b/cmd/mesh-control/main.go @@ -113,7 +113,6 @@ func usage() { declare send a node a signed declaration overlay place [flags] say where a node is and how it is reached overlay show the private network, as the mesh computes it - overlay push send every node its part of the private network module add register a module from its manifest module list what modules this mesh knows about module forget remove one, unless a node is running it @@ -497,7 +496,14 @@ func overlayCIDR() string { func overlayCommand(ctx context.Context, args []string) error { if len(args) == 0 { - return errors.New("overlay place [flags], overlay show, or overlay push") + return errors.New("overlay place [flags], or overlay show") + } + // Answered before anything is opened. A message about which command to use should not need a + // database to say so, and needing one turns a redirect into a connection error. + if args[0] == "push" { + return errors.New("`overlay push` is now `push`, which sends a node its network AND " + + "what its assignments resolve to — the two are computed from one picture of the " + + "mesh, and sending them separately would let them disagree") } inv, err := openInventory(ctx) if err != nil { @@ -510,10 +516,9 @@ func overlayCommand(ctx context.Context, args []string) error { return overlayPlace(ctx, inv, args[1:]) case "show": return overlayShow(ctx, inv) - case "push": - return overlayPush(ctx, inv) + default: - return fmt.Errorf("overlay has no %q; it has place, show and push", args[0]) + return fmt.Errorf("overlay has no %q; it has place and show", args[0]) } } @@ -611,47 +616,6 @@ func overlayShow(ctx context.Context, inv *inventory.Inventory) error { return nil } -func overlayPush(ctx context.Context, inv *inventory.Inventory) error { - nodes, computed, err := graph(ctx, inv) - if err != nil { - return err - } - - ident, err := openIdentity(ctx) - if err != nil { - return err - } - defer ident.Close() - - server, err := link.Connect(nil, nil) - if err != nil { - return err - } - defer server.Close() - - sent := 0 - for _, n := range nodes { - peers, ok := computed[n.Name] - if !ok { - // Skipped, and said. A node with no key or address is not on the network yet, and - // sending it an empty configuration would take down the one it may already have. - fmt.Printf("%s is not on the overlay yet — skipped\n", n.Name) - continue - } - declaration, err := overlay.Declaration(n, peers, nodes, "") - if err != nil { - return err - } - if err := link.Declare(ctx, server.Channel(), ident, n.Name, declaration, 15*time.Second); err != nil { - return err - } - fmt.Printf("sent %s its place on the overlay — %d peer(s)\n", n.Name, len(peers)) - sent++ - } - fmt.Printf("\n%d of %d node(s) told\n", sent, len(nodes)) - return nil -} - // SilentFor is how long a node may be quiet before the mesh says so. // // A node speaks every minute, so three of them missed is a gap rather than a slow one. The number @@ -662,7 +626,7 @@ const SilentFor = 3 * time.Minute // // "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 — and until this existed both looked exactly like a node that is current. +// picture of the mesh. func heardFrom(n inventory.Node) string { silent, ever := n.Silent() switch { diff --git a/internal/inventory/catalogue_test.go b/internal/inventory/catalogue_test.go new file mode 100644 index 0000000..783af16 --- /dev/null +++ b/internal/inventory/catalogue_test.go @@ -0,0 +1,212 @@ +package inventory + +import ( + "errors" + "testing" + + "github.com/novox/mesh-control/internal/catalogue" +) + +func manifest(name string, provides, requires []string) catalogue.Manifest { + return catalogue.Manifest{Module: name, Provides: provides, Requires: requires} +} + +func TestAModuleRoundTripsWholeAndUnshredded(t *testing.T) { + // The manifest is held as it was given. Every field of it is read together when a node is + // resolved, and a manifest that gains a field should not need a migration before it can be + // stored — the module system is the thing most likely to grow. + inv := fresh(t) + m := catalogue.Manifest{ + Module: "xorg", Provides: []string{"display-server"}, + Capabilities: []string{"seat"}, + Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}, + Resources: []map[string]any{{"id": "conf", "type": "file", "path": "/etc/X11/x.conf"}}, + } + if err := inv.RegisterModule(t.Context(), m); err != nil { + t.Fatal(err) + } + + shelf, err := inv.Catalogue(t.Context()) + if err != nil { + t.Fatal(err) + } + back, ok := shelf["xorg"] + if !ok { + t.Fatal("the module was not in the catalogue") + } + if len(back.Claims) != 1 || back.Claims[0].Name != "the-seat" { + t.Errorf("the claims did not survive: %+v", back.Claims) + } + if len(back.Resources) != 1 || back.Resources[0]["path"] != "/etc/X11/x.conf" { + t.Errorf("the resources did not survive: %+v", back.Resources) + } +} + +func TestRegisteringAgainReplacesTheManifest(t *testing.T) { + // A manifest changing is the ordinary case — a module gains a requirement, a claim, a + // resource. What matters is that the change is what the next resolution sees. + inv := fresh(t) + if err := inv.RegisterModule(t.Context(), manifest("thing", nil, nil)); err != nil { + t.Fatal(err) + } + if err := inv.RegisterModule(t.Context(), manifest("thing", []string{"a-thing"}, nil)); err != nil { + t.Fatal(err) + } + + shelf, err := inv.Catalogue(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(shelf) != 1 { + t.Fatalf("registering twice made %d modules", len(shelf)) + } + if len(shelf["thing"].Provides) != 1 { + t.Error("the second manifest did not replace the first") + } +} + +func TestAModuleAMachineIsRunningCannotBeForgotten(t *testing.T) { + // Not a fault. It means a machine is running that module now, and removing the record would + // leave the mesh unable to describe what is on it. + inv := fresh(t) + if _, err := inv.AddNode(t.Context(), "laptop"); err != nil { + t.Fatal(err) + } + if err := inv.RegisterModule(t.Context(), manifest("thing", nil, nil)); err != nil { + t.Fatal(err) + } + if err := inv.Assign(t.Context(), "laptop", "thing"); err != nil { + t.Fatal(err) + } + + err := inv.ForgetModule(t.Context(), "thing") + if !errors.Is(err, ErrStillAssigned) { + t.Fatalf("a module in use was forgotten: %v", err) + } + if err := inv.Unassign(t.Context(), "laptop", "thing"); err != nil { + t.Fatal(err) + } + if err := inv.ForgetModule(t.Context(), "thing"); err != nil { + t.Errorf("an unassigned module could not be forgotten: %v", err) + } +} + +func TestRemovingANodeTakesItsAssignments(t *testing.T) { + // The asymmetry with modules above, and it is deliberate: a node that is gone cannot be + // running anything, so its assignments are meaningless rather than dangerous. + inv := fresh(t) + node, err := inv.AddNode(t.Context(), "laptop") + if err != nil { + t.Fatal(err) + } + if err := inv.RegisterModule(t.Context(), manifest("thing", nil, nil)); err != nil { + t.Fatal(err) + } + if err := inv.Assign(t.Context(), "laptop", "thing"); err != nil { + t.Fatal(err) + } + if _, err := inv.store.Pool().Exec(t.Context(), `delete from node where id = $1`, node.ID); err != nil { + t.Fatal(err) + } + + var left int + if err := inv.store.Pool().QueryRow(t.Context(), + `select count(*) from assignment`).Scan(&left); err != nil { + t.Fatal(err) + } + if left != 0 { + t.Errorf("%d assignment(s) outlived the node they were on", left) + } + // And the module itself survives, because other nodes may be running it. + shelf, err := inv.Catalogue(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(shelf) != 1 { + t.Error("removing a node took a module with it") + } +} + +func TestAssigningAModuleTheMeshDoesNotKnowIsRefused(t *testing.T) { + // Said as "no module of that name" rather than as a foreign key. A person mistyping a module + // name should be told that, not shown a constraint. + inv := fresh(t) + if _, err := inv.AddNode(t.Context(), "laptop"); err != nil { + t.Fatal(err) + } + err := inv.Assign(t.Context(), "laptop", "not-a-module") + if !errors.Is(err, ErrNoSuchModule) { + t.Fatalf("assigning an unknown module gave %v", err) + } +} + +func TestAssigningTwiceIsNotAnError(t *testing.T) { + // It is a statement of what should be true, and it already is. + inv := fresh(t) + if _, err := inv.AddNode(t.Context(), "laptop"); err != nil { + t.Fatal(err) + } + if err := inv.RegisterModule(t.Context(), manifest("thing", nil, nil)); err != nil { + t.Fatal(err) + } + for i := 0; i < 3; i++ { + if err := inv.Assign(t.Context(), "laptop", "thing"); err != nil { + t.Fatalf("assigning again failed: %v", err) + } + } + assigned, err := inv.Assigned(t.Context(), "laptop") + if err != nil { + t.Fatal(err) + } + if len(assigned) != 1 { + t.Errorf("assigned three times and got %v", assigned) + } +} + +func TestANodeThatNeverReportedHasNoCapabilities(t *testing.T) { + // Not "everything". A node that has never spoken will refuse anything needing a capability, + // which is wrong but visible — where assuming it can do everything would assign work it + // cannot do and find out on the machine. + inv := fresh(t) + if _, err := inv.AddNode(t.Context(), "laptop"); err != nil { + t.Fatal(err) + } + caps, err := inv.ProfileOf(t.Context(), "laptop") + if err != nil { + t.Fatal(err) + } + if len(caps) != 0 { + t.Errorf("a node that never reported has capabilities: %v", caps) + } +} + +func TestOnlyPresentCapabilitiesCount(t *testing.T) { + // A profile lists what was looked for and whether it was found. A capability that was looked + // for and absent is the same as one nobody looked for, as far as what may run here goes — + // and reading the list without the verdict would let a module onto a machine that reported + // "no". + inv := fresh(t) + node, err := inv.AddNode(t.Context(), "laptop") + if err != nil { + t.Fatal(err) + } + if err := inv.RecordProfile(t.Context(), node.ID, map[string]any{ + "capabilities": []any{ + map[string]any{"name": "seat", "present": true}, + map[string]any{"name": "firewall", "present": false}, + }, + }); err != nil { + t.Fatal(err) + } + + caps, err := inv.ProfileOf(t.Context(), "laptop") + if err != nil { + t.Fatal(err) + } + if !caps["seat"] { + t.Error("a capability the node reported as present is missing") + } + if caps["firewall"] { + t.Error("a capability the node reported as ABSENT was counted as present") + } +}