A build is work, not state. Everything else the control plane sends a node is a declaration — this is what you should be — reconciled forever. A build happens once and is finished. Putting it in a declaration would mean rebuilding on every reconcile, or a declaration carrying "and I already did this", which is state about an event rather than about a machine. So it travels on its own queue and the answer comes back correlated. One queue, so several build machines share the work and each request is done exactly once — which a per-machine routing key would not give. mesh-builder is the program a build machine runs. Not the control plane, which must not run commands on a machine; not the host, which would then need a container runtime and git everywhere to do something almost no machine will ever do. It holds its own broker credential and nothing else. Three properties that are decisions: - a request is acknowledged only once the answer is away, so a builder that dies mid-build leaves the work for another machine rather than losing it with nobody ever hearing why - one build at a time. Five at once against one runtime finishes all five slower than it would have finished the first, and the queue is what shares work between machines - a failure is a RESULT. A build that fails silently is indistinguishable from a builder that is not running, and those want different responses And `module list` is a catalogue: what exists, at which version, built from which commit or handed over by hand or shipped with the control plane, whether it is behind its source, and which machines run it. All of that was recorded from the first build and none of it was shown, so "is this current?" could only be answered by reading the database. Proven against a real broker, registry and store: the mesh asked, a builder consumed, built, published, answered; the manifest was recorded with its commit; the source moved and the catalogue said "behind"; rebuilding caught it up with a new digest because the content changed.
520 lines
17 KiB
Go
520 lines
17 KiB
Go
package inventory
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"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)
|
|
}
|
|
}
|
|
|
|
func TestTheCatalogueSaysWhereEachModuleCameFromAndWhoRunsIt(t *testing.T) {
|
|
// The provenance was recorded from the first build and nothing showed it, which made "is this
|
|
// current?" a question you could only answer by reading the database.
|
|
inv := fresh(t)
|
|
ctx := context.Background()
|
|
for _, n := range []string{"workstation", "laptop"} {
|
|
if _, err := inv.AddNode(ctx, n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := inv.RegisterModule(ctx, manifest("shell", []string{"login-shell"}, nil),
|
|
Source{Repository: "https://forge.invalid/shell.git", BuiltFrom: "aaa", Head: "aaa"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := inv.RegisterModule(ctx, manifest("byhand", nil, nil), Source{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := inv.Provide(ctx, manifest("networking", nil, []string{"login-shell"})); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, n := range []string{"workstation", "laptop"} {
|
|
if err := inv.Assign(ctx, n, "shell"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
entries, err := inv.Catalogued(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
by := map[string]Entry{}
|
|
for _, e := range entries {
|
|
by[e.Manifest.Module] = e
|
|
}
|
|
if len(by) != 3 {
|
|
t.Fatalf("the catalogue has %d modules", len(by))
|
|
}
|
|
|
|
// Sorted, and both nodes, so a person reading it twice sees the same thing.
|
|
if got := strings.Join(by["shell"].On, ","); got != "laptop,workstation" {
|
|
t.Fatalf("shell runs on %q", got)
|
|
}
|
|
if by["shell"].Source.Repository != "https://forge.invalid/shell.git" {
|
|
t.Fatalf("shell came from %q", by["shell"].Source.Repository)
|
|
}
|
|
if by["shell"].Provided {
|
|
t.Fatal("a module built from a repository was reported as shipped with the control plane")
|
|
}
|
|
|
|
// A module nobody runs is in the catalogue: the catalogue is what EXISTS, and what runs is a
|
|
// different question the same row answers.
|
|
if len(by["byhand"].On) != 0 {
|
|
t.Fatalf("byhand runs on %v", by["byhand"].On)
|
|
}
|
|
// Handed over by hand is its own state. Nothing can rebuild it, and showing it as a
|
|
// repository would send somebody looking for one.
|
|
if by["byhand"].Source.Repository != "" || by["byhand"].Provided {
|
|
t.Fatalf("byhand: %+v", by["byhand"])
|
|
}
|
|
|
|
if !by["networking"].Provided {
|
|
t.Fatal("a module the control plane ships was not marked as such")
|
|
}
|
|
if by["networking"].Source.Repository != "" {
|
|
// It is not a repository, and showing it as one would have somebody go looking for it.
|
|
t.Fatalf("networking claims to come from %q", by["networking"].Source.Repository)
|
|
}
|
|
}
|
|
|
|
func TestACatalogueEntryKnowsWhetherItIsBehind(t *testing.T) {
|
|
inv := fresh(t)
|
|
ctx := context.Background()
|
|
if err := inv.RegisterModule(ctx, manifest("shell", nil, nil),
|
|
Source{Repository: "https://forge.invalid/shell.git", BuiltFrom: "aaa", Head: "aaa"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := inv.SourceMoved(ctx, "shell", "bbb"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
entries, err := inv.Catalogued(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if entries[0].Source.Current() {
|
|
t.Fatal("a module whose source moved reported itself current")
|
|
}
|
|
}
|