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") } }