Taking a module off a node takes it off the machine
The half of the module system that was built and never proved. Unassigning i3 removed i3's file AND xorg's, because xorg was only there to satisfy i3 -- the node's own record agrees, and the resolution the mesh sends no longer mentions either. That works because a declaration removes what the mesh previously declared and nothing else, which is 04-ISSUES/010's fix carrying its weight here: the substrate the machine raised for itself is untouched by any of it. Tests for the storage layer, which had none. The ones worth naming: A module a machine is running cannot be forgotten -- not a fault, it means the mesh would lose the ability to describe what is on that machine. Removing a node DOES take its assignments, and the asymmetry is deliberate: a node that is gone cannot be running anything. A node that has never reported has NO capabilities rather than all of them. That refuses anything needing one, which is wrong but visible -- where assuming it can do everything would assign work it cannot do and find out on the machine. And a capability the node reported as ABSENT is not counted: reading the list without the verdict would let a module onto a machine that said no. `overlay push` is gone, replaced by `push`, which sends a node its network and its modules as one declaration. Two commands that overlap is how a mesh ends up half-configured by whichever was run. The old name answers with where to go, and answers before opening a database -- needing one would turn a redirect into a connection error.
This commit is contained in:
+11
-47
@@ -113,7 +113,6 @@ func usage() {
|
|||||||
declare <node> <file> send a node a signed declaration
|
declare <node> <file> send a node a signed declaration
|
||||||
overlay place <node> [flags] say where a node is and how it is reached
|
overlay place <node> [flags] say where a node is and how it is reached
|
||||||
overlay show the private network, as the mesh computes it
|
overlay show the private network, as the mesh computes it
|
||||||
overlay push send every node its part of the private network
|
|
||||||
module add <file> register a module from its manifest
|
module add <file> register a module from its manifest
|
||||||
module list what modules this mesh knows about
|
module list what modules this mesh knows about
|
||||||
module forget <name> remove one, unless a node is running it
|
module forget <name> remove one, unless a node is running it
|
||||||
@@ -497,7 +496,14 @@ func overlayCIDR() string {
|
|||||||
|
|
||||||
func overlayCommand(ctx context.Context, args []string) error {
|
func overlayCommand(ctx context.Context, args []string) error {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return errors.New("overlay place <node> [flags], overlay show, or overlay push")
|
return errors.New("overlay place <node> [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)
|
inv, err := openInventory(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -510,10 +516,9 @@ func overlayCommand(ctx context.Context, args []string) error {
|
|||||||
return overlayPlace(ctx, inv, args[1:])
|
return overlayPlace(ctx, inv, args[1:])
|
||||||
case "show":
|
case "show":
|
||||||
return overlayShow(ctx, inv)
|
return overlayShow(ctx, inv)
|
||||||
case "push":
|
|
||||||
return overlayPush(ctx, inv)
|
|
||||||
default:
|
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
|
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.
|
// 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
|
// 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
|
// "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
|
// 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 {
|
func heardFrom(n inventory.Node) string {
|
||||||
silent, ever := n.Silent()
|
silent, ever := n.Silent()
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user