Provisions were named after roles: provides "database", requires "database". Nothing distinguished engines, so a module written against PostgreSQL could be matched to a provider of SQL Server, resolve as satisfied, deploy, and fail on its first query — with nothing connecting that error back to a match made elsewhere by something that believed it had done its job. The failure is in the direction that hides. Refusing on ambiguity exists precisely so this does not happen, and the generic name walked around it: with one provider of each name nothing is ambiguous, so nothing is asked. How it got in: every resolver test had exactly one provider per name, so no mismatch was expressible and none was caught. The fixtures agreed with the design — the same fault as the imagined test output in 04-ISSUES/005, at the level of a name. Refused rather than documented, because the old naming *was* the documented convention. Providing database/db/sql/sql-database is now a parse error naming what to write instead. The rule is about coupling, not specificity everywhere: route and resolver stay role-named, because a consumer genuinely cannot tell which proxy answered. novox/hq ADR 0027.
607 lines
20 KiB
Go
607 lines
20 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", "postgres-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", "postgres-database", "second"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pins, err := inv.PinsFor(ctx, "user")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(pins) != 1 || pins["postgres-database"] != "second" {
|
|
t.Fatalf("got %v", pins)
|
|
}
|
|
if err := inv.UnpinProvision(ctx, "user", "postgres-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", "postgres-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", "postgres-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")
|
|
}
|
|
}
|
|
|
|
// A capability's detail is half of what the machine said, and it was being thrown away.
|
|
//
|
|
// novox/hq ADR 0009: a capability's presence gates an assignment and its detail carries a value —
|
|
// `seat: card1-DP-1`. So *can this run here* and *what should it be configured as* are one fact
|
|
// read two ways, and keeping only the first read makes the second unanswerable.
|
|
func TestWhatAMachineSaidAboutItselfIsKeptWhole(t *testing.T) {
|
|
inv := ForTest(t)
|
|
ctx := t.Context()
|
|
node, err := inv.AddNode(ctx, "workstation")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := inv.RecordProfile(ctx, node.ID, map[string]any{"capabilities": []map[string]any{
|
|
{"name": "seat", "present": true, "detail": "card1-DP-1, card1-HDMI-A-1"},
|
|
{"name": "container-runtime", "present": false, "detail": "docker is not installed"},
|
|
}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
held, err := inv.Profile(ctx, "workstation")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(held) != 2 {
|
|
t.Fatalf("the machine reported two things and the mesh kept %d", len(held))
|
|
}
|
|
found := map[string]Capability{}
|
|
for _, c := range held {
|
|
found[c.Name] = c
|
|
}
|
|
if found["seat"].Detail != "card1-DP-1, card1-HDMI-A-1" {
|
|
t.Fatalf("the value the machine reported was discarded: %+v", found["seat"])
|
|
}
|
|
// And the absent one keeps its reason, which is the case a person most needs explaining:
|
|
// "this machine has no container runtime" is the answer and "docker is not installed" is why.
|
|
if found["container-runtime"].Present {
|
|
t.Fatal("something the machine said it does not have was recorded as present")
|
|
}
|
|
if found["container-runtime"].Detail != "docker is not installed" {
|
|
t.Fatalf("the reason a capability is absent was discarded: %+v", found["container-runtime"])
|
|
}
|
|
|
|
// The gating read is unchanged, and takes only what is present.
|
|
gates, err := inv.ProfileOf(ctx, "workstation")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !gates["seat"] || gates["container-runtime"] {
|
|
t.Fatalf("the gating read disagrees with what the machine said: %+v", gates)
|
|
}
|
|
}
|
|
|
|
// Never reported is not the same as reported nothing: one machine has not run the host, the other
|
|
// ran it and can do nothing, and the remedies are different.
|
|
func TestNeverReportedAndReportedNothingAreDifferentProfiles(t *testing.T) {
|
|
inv := ForTest(t)
|
|
ctx := t.Context()
|
|
silent, err := inv.AddNode(ctx, "silent")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
empty, err := inv.AddNode(ctx, "empty")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := inv.RecordProfile(ctx, empty.ID,
|
|
map[string]any{"capabilities": []map[string]any{}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = silent
|
|
|
|
never, err := inv.Profile(ctx, "silent")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if never != nil {
|
|
t.Fatalf("a machine that never reported looks like one that reported nothing: %+v", never)
|
|
}
|
|
nothing, err := inv.Profile(ctx, "empty")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if nothing == nil {
|
|
t.Fatal("a machine that reported nothing looks like one that never reported")
|
|
}
|
|
}
|