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:
@@ -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