Files
mesh-controller/internal/inventory/catalogue_test.go
T
jschoubben d4064122d6 Where the answer to a requirement is allowed to live
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.
2026-08-29 23:51:50 +02:00

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