Two different things were both written `requires`. A shell, a display
server and a private network have to be on the machine that needs them.
A database does not — it runs somewhere and is reached over the network.
Both were answered the same way, so requiring a database installed
PostgreSQL on every machine that ran a web application.
What a module provides now carries a scope, the same idea claims already
use, written short in the ordinary case:
"provides": ["shell"]
"provides": [{"name": "database", "scope": "mesh"}]
A mesh-scoped requirement is answered by finding the node already running
it — never by installing it here. Choosing a machine to put a database on
is a decision with consequences, and nothing resolving a web application
should make it silently. With nothing anywhere it refuses and says which
module to assign; with two it refuses and says how to choose.
Choosing is `pin <node> <provision> <from>`, kept per node because that
is the granularity the choice has. A pin at a machine that does not
provide it refuses rather than falling back — a fallback would quietly
move somebody's data. One provider does not overrule a pin either.
Resolving a node now needs to know what the others offer, and working
that out needs them resolved, so it is two passes: the first answers only
what each node offers, the second answers everything. Nothing is ever
declared from the first.
A node's plan says what it takes from elsewhere. It is the only part of a
set that stops working when a different machine goes away, and nothing
else in that output would have said so. It is also where a credential
will hang once there is a mechanism for handing one back.
One test found passing for the wrong reason: it read pins through a join
on the provider, which hides a dangling row whether or not it was cleaned
up. It counts rows now, and bites when the cascade is removed.
431 lines
14 KiB
Go
431 lines
14 KiB
Go
package inventory
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
|
|
"github.com/novox/mesh-control/internal/catalogue"
|
|
)
|
|
|
|
func manifest(name string, provides, requires []string) catalogue.Manifest {
|
|
return catalogue.Manifest{Module: name, Provides: catalogue.Offers(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: catalogue.Offers("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, Source{}); 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), Source{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := inv.RegisterModule(t.Context(), manifest("thing", []string{"a-thing"}, nil), Source{}); 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), Source{}); 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), Source{}); 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), Source{}); 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")
|
|
}
|
|
}
|
|
|
|
func TestAModuleWithNoSourceIsNeverBehind(t *testing.T) {
|
|
// It was handed over directly, which is how a one-off arrives and how every module got here
|
|
// before provenance existed. Saying "out of date" about it would be inventing a comparison
|
|
// against nothing.
|
|
inv := fresh(t)
|
|
if err := inv.RegisterModule(t.Context(), manifest("thing", nil, nil), Source{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
from, err := inv.SourceOf(t.Context(), "thing")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !from.Current() {
|
|
t.Error("a module with no source was reported as behind")
|
|
}
|
|
behind, err := inv.Behind(t.Context())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(behind) != 0 {
|
|
t.Errorf("a module with no source is in the behind list: %v", behind)
|
|
}
|
|
}
|
|
|
|
func TestASourceThatMovedMakesTheModuleBehind(t *testing.T) {
|
|
inv := fresh(t)
|
|
if err := inv.RegisterModule(t.Context(), manifest("thing", nil, nil),
|
|
Source{Repository: "novox/thing", Ref: "main", BuiltFrom: "aaaa1111"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
from, err := inv.SourceOf(t.Context(), "thing")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !from.Current() {
|
|
t.Fatal("a module built from the only commit its source has is behind")
|
|
}
|
|
|
|
if err := inv.SourceMoved(t.Context(), "thing", "bbbb2222"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
from, err = inv.SourceOf(t.Context(), "thing")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if from.Current() {
|
|
t.Error("the source moved and the module still reports as current")
|
|
}
|
|
}
|
|
|
|
func TestBeingBehindNamesTheMachinesRunningTheOldOne(t *testing.T) {
|
|
// The question somebody actually has. A module being out of date is a fact about the
|
|
// catalogue; machines running last week's version is the thing with consequences.
|
|
inv := fresh(t)
|
|
for _, n := range []string{"laptop", "workstation"} {
|
|
if _, err := inv.AddNode(t.Context(), n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := inv.RegisterModule(t.Context(), manifest("thing", nil, nil),
|
|
Source{Repository: "novox/thing", BuiltFrom: "aaaa1111"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, n := range []string{"laptop", "workstation"} {
|
|
if err := inv.Assign(t.Context(), n, "thing"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := inv.SourceMoved(t.Context(), "thing", "bbbb2222"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
behind, err := inv.Behind(t.Context())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(behind["thing"]) != 2 {
|
|
t.Errorf("running on %v; both machines have the old one", behind["thing"])
|
|
}
|
|
}
|
|
|
|
func TestRebuildingCatchesUp(t *testing.T) {
|
|
inv := fresh(t)
|
|
if err := inv.RegisterModule(t.Context(), manifest("thing", nil, nil),
|
|
Source{Repository: "novox/thing", BuiltFrom: "aaaa1111"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := inv.SourceMoved(t.Context(), "thing", "bbbb2222"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := inv.RegisterModule(t.Context(), manifest("thing", []string{"a-thing"}, nil),
|
|
Source{Repository: "novox/thing", BuiltFrom: "bbbb2222"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
from, err := inv.SourceOf(t.Context(), "thing")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !from.Current() {
|
|
t.Errorf("built from the commit the source has and still behind: %+v", from)
|
|
}
|
|
}
|
|
|
|
func TestHandingOverAManifestDoesNotEraseWhereItComesFrom(t *testing.T) {
|
|
// Fixing something in a hurry is legitimate. Silently forgetting where the module normally
|
|
// comes from is not: it is the only thing that would say, afterwards, that a machine is
|
|
// running something nobody can rebuild.
|
|
inv := fresh(t)
|
|
if err := inv.RegisterModule(t.Context(), manifest("thing", nil, nil),
|
|
Source{Repository: "novox/thing", Ref: "main", BuiltFrom: "aaaa1111"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := inv.RegisterModule(t.Context(), manifest("thing", []string{"patched"}, nil),
|
|
Source{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
from, err := inv.SourceOf(t.Context(), "thing")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if from.Repository != "novox/thing" {
|
|
t.Errorf("handing over a manifest erased the source: %+v", from)
|
|
}
|
|
}
|
|
|
|
func TestASourceNobodyHasCheckedIsNotBehind(t *testing.T) {
|
|
// A module built from a commit, where nothing has yet told the mesh whether that source has
|
|
// moved. It is not behind — nobody has looked. Reporting it as behind would put every module
|
|
// on the list the moment provenance was recorded, which makes the list say nothing.
|
|
inv := fresh(t)
|
|
if err := inv.RegisterModule(t.Context(), manifest("thing", nil, nil),
|
|
Source{Repository: "novox/thing", BuiltFrom: "aaaa1111"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
from, err := inv.SourceOf(t.Context(), "thing")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Registering sets the head to what was built, so the two agree until something says
|
|
// otherwise. Either way it must not read as behind.
|
|
if !from.Current() {
|
|
t.Errorf("a source nobody has checked reports as behind: %+v", from)
|
|
}
|
|
|
|
// And with the head genuinely unknown, which is what a module registered before provenance
|
|
// existed looks like after somebody adds a source to it.
|
|
if (Source{Repository: "novox/thing", BuiltFrom: "aaaa1111"}).Current() == false {
|
|
t.Error("a module with no known head reports as behind")
|
|
}
|
|
}
|
|
|
|
func TestAPinSurvivesAndCanBeChanged(t *testing.T) {
|
|
inv := fresh(t)
|
|
ctx := context.Background()
|
|
for _, n := range []string{"user", "first", "second"} {
|
|
if _, err := inv.AddNode(ctx, n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := inv.PinProvision(ctx, "user", "database", "first"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Changing the answer replaces it rather than adding a second, or a machine would be told to
|
|
// use two databases and nothing would say which.
|
|
if err := inv.PinProvision(ctx, "user", "database", "second"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pins, err := inv.PinsFor(ctx, "user")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(pins) != 1 || pins["database"] != "second" {
|
|
t.Fatalf("got %v", pins)
|
|
}
|
|
if err := inv.UnpinProvision(ctx, "user", "database"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if pins, _ := inv.PinsFor(ctx, "user"); len(pins) != 0 {
|
|
t.Fatalf("the choice outlived being removed: %v", pins)
|
|
}
|
|
// Removing something that was never said is a mistake worth reporting, not a silent success.
|
|
if err := inv.UnpinProvision(ctx, "user", "database"); err == nil {
|
|
t.Fatal("unpinning something nobody pinned reported success")
|
|
}
|
|
}
|
|
|
|
func TestAPinGoesWhenTheProviderLeavesTheMesh(t *testing.T) {
|
|
// Otherwise a machine is pointed at something that no longer exists and reported as
|
|
// configured, which is the failure mode this whole project keeps refusing.
|
|
inv := fresh(t)
|
|
ctx := context.Background()
|
|
for _, n := range []string{"consumer", "provider"} {
|
|
if _, err := inv.AddNode(ctx, n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := inv.PinProvision(ctx, "consumer", "database", "provider"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := inv.store.Pool().Exec(ctx, `delete from node where name = 'provider'`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Counted in the table, not read through PinsFor. PinsFor joins on the provider, so a pin
|
|
// left behind by a departed node is invisible through it whether or not it was cleaned up —
|
|
// which made the first version of this test pass with the cascade removed.
|
|
rows, err := inv.pinRows(ctx, "consumer")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rows != 0 {
|
|
t.Fatalf("a choice outlived the machine it named: %d row(s) left", rows)
|
|
}
|
|
}
|