Delivery is a comparison, not a pipeline: the control plane holds what source
exists and what has been built from it, and the difference is the work. Both
halves are written down now, so "is this current" is a question about two
columns rather than something you find out by building.
`status` answers "did my change go out?", which ADR 0010 names as the real risk
of replacing a pipeline with a comparison -- it is answerable today by opening
a pipeline, and something had to replace that.
zsh holds 4f2a9c1e, source has 9e3b7d2a
running on laptop
The machines are the point. A module being out of date is a fact about the
catalogue; which machines are running last week's version is the thing with
consequences.
Three things this had to get right.
A module with no source is never behind -- it was handed over directly, which
is how a one-off arrives, and saying "out of date" about it would be inventing
a comparison against nothing.
A source nobody has checked is not behind either. Reporting it as behind would
put every module on the list the moment provenance was recorded, which makes
the list say nothing. Fault injection found this: my first test passed with the
guard removed, because both halves were empty strings and compared equal. The
case that actually needed it -- a known commit and an unknown head -- was
untested.
And handing over a manifest by hand does not erase where the module normally
comes from. Fixing something in a hurry is legitimate; silently forgetting its
origin is not, because that record is the only thing that would say afterwards
that a machine is running something nobody can rebuild.
Also fixed the flag parsing, which stopped at the first positional argument and
silently ignored every flag after it -- so `module add thing.json --source x`
recorded no source at all and said it had succeeded. The host's own parser
documents this exact footgun and I wrote it again anyway.
367 lines
12 KiB
Go
367 lines
12 KiB
Go
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, 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")
|
|
}
|
|
}
|