Files
mesh-controller/internal/inventory/inventory_test.go
T
jschoubben f563ababa1 The mesh keeps a copy of what each node owns
novox/hq 09-the-node-lifecycle asks for this and it was missing: the host
reports what it owns and the mesh keeps the last report. A backup, never a
source -- nothing decides anything from it, and a node that disagrees with it
wins, because the node is the one that can see the machine.

Its point is the orphans. A node that loses its state file currently strands
whatever it applied: nothing on the machine knows those resources were the
mesh's doing, so nothing removes them. With this, a rebuilt node receives both
the declaration and the record of what it previously owned.

Never reported and reported nothing are kept apart, and that is the whole care
in it. A node that applied nothing holds nothing; a node that has never spoken
is unknown -- and handing back an empty list for the second would tell a
rebuilding node it owns nothing and have it remove whatever it found.

The age comes back with the answer rather than being left for the caller to go
and find. An answer about a machine is worth much less without one, and this
repository has already been bitten by a cache with no age on it.

A refusal or a partial failure moves last_seen and nothing else: neither is an
account of what the machine holds, and recording one as though it were would
tell a rebuilding node to remove what it still has.
2026-08-29 16:51:54 +02:00

377 lines
11 KiB
Go

package inventory
import (
"context"
"errors"
"fmt"
"os"
"strings"
"sync"
"testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/novox/mesh-control/internal/store"
)
// Against a real PostgreSQL, for the reason novox/hq ADR 0017 gives: what is being tested here is
// that the database enforces what this code relies on it enforcing — a unique name, a token that
// two racing redemptions cannot both spend, a cascade that leaves no token behind. A fake would
// assert that the fake enforces them.
func fresh(t *testing.T) *Inventory {
t.Helper()
admin := os.Getenv("MESH_TEST_POSTGRES")
if admin == "" {
t.Skip("no MESH_TEST_POSTGRES; run `make check` to raise one")
}
name := fmt.Sprintf("inv_%d_%s", time.Now().UnixNano()%1_000_000,
strings.ToLower(strings.NewReplacer("/", "", "-", "").Replace(t.Name())))
if len(name) > 60 {
name = name[:60]
}
conn, err := pgx.Connect(t.Context(), admin)
if err != nil {
t.Fatalf("cannot reach the test PostgreSQL: %v", err)
}
if _, err := conn.Exec(t.Context(), "create database "+name); err != nil {
t.Fatalf("cannot create %s: %v", name, err)
}
conn.Close(t.Context())
cut := strings.LastIndex(admin, "/")
t.Setenv(store.Variable(Name), admin[:cut]+"/"+name+"?sslmode=disable")
inv, err := Open(t.Context())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
inv.Close()
c, err := pgx.Connect(context.Background(), admin)
if err != nil {
return
}
defer c.Close(context.Background())
_, _ = c.Exec(context.Background(), "drop database if exists "+name+" with (force)")
})
if err := inv.Ready(t.Context(), 20*time.Second); err != nil {
t.Fatal(err)
}
migrations, err := Migrations()
if err != nil {
t.Fatal(err)
}
if _, err := inv.store.Migrate(t.Context(), migrations); err != nil {
t.Fatal(err)
}
return inv
}
func TestANodeRecordRoundTrips(t *testing.T) {
inv := fresh(t)
made, err := inv.AddNode(t.Context(), "workstation")
if err != nil {
t.Fatal(err)
}
found, err := inv.NodeByName(t.Context(), "workstation")
if err != nil {
t.Fatal(err)
}
if found.ID != made.ID {
t.Errorf("added %s and found %s", made.ID, found.ID)
}
}
func TestTwoNodesCannotShareAName(t *testing.T) {
// A name is how a token is issued for a node. Two records with one name makes that command
// ambiguous at the moment it grants access to the mesh.
inv := fresh(t)
if _, err := inv.AddNode(t.Context(), "workstation"); err != nil {
t.Fatal(err)
}
_, err := inv.AddNode(t.Context(), "workstation")
if !errors.Is(err, ErrNameTaken) {
t.Fatalf("a duplicate name gave %v; it must be a plain answer a person can act on", err)
}
}
func TestAnUnknownNodeIsNotAnEmptyRecord(t *testing.T) {
inv := fresh(t)
_, err := inv.NodeByName(t.Context(), "never-existed")
if !errors.Is(err, ErrNoSuchNode) {
t.Fatalf("expected ErrNoSuchNode, got %v", err)
}
}
func TestATokenIsRedeemableExactlyOnce(t *testing.T) {
// "Useless once used" (novox/hq ADR 0004). Without it a token that leaked after a successful
// join is a second machine's way in, and nothing would have noticed the first.
inv := fresh(t)
if _, err := inv.AddNode(t.Context(), "laptop"); err != nil {
t.Fatal(err)
}
issued, err := inv.IssueToken(t.Context(), "laptop", time.Hour)
if err != nil {
t.Fatal(err)
}
node, err := inv.Redeem(t.Context(), issued.Secret)
if err != nil {
t.Fatalf("a fresh token was refused: %v", err)
}
if node.Name != "laptop" {
t.Errorf("redeemed a token for %q", node.Name)
}
if _, err := inv.Redeem(t.Context(), issued.Secret); !errors.Is(err, ErrTokenRefused) {
t.Fatal("the same token was redeemed twice")
}
}
func TestAnExpiredTokenIsRefused(t *testing.T) {
// "Useless after it expires" — the other half, and the one nothing notices, because a token
// ages out with nobody watching. It has to be read from the row rather than from a status
// something would have had to write.
inv := fresh(t)
if _, err := inv.AddNode(t.Context(), "laptop"); err != nil {
t.Fatal(err)
}
issued, err := inv.IssueToken(t.Context(), "laptop", 40*time.Millisecond)
if err != nil {
t.Fatal(err)
}
time.Sleep(120 * time.Millisecond)
if _, err := inv.Redeem(t.Context(), issued.Secret); !errors.Is(err, ErrTokenRefused) {
t.Fatal("an expired token was accepted")
}
}
func TestATokenWithNoLifetimeIsRefused(t *testing.T) {
inv := fresh(t)
if _, err := inv.AddNode(t.Context(), "laptop"); err != nil {
t.Fatal(err)
}
if _, err := inv.IssueToken(t.Context(), "laptop", 0); err == nil {
t.Fatal("a token that never expires was issued")
}
}
func TestIssuingAgainInvalidatesTheOutstandingToken(t *testing.T) {
// Two live tokens for one node record are two machines able to join as the same node, with
// nothing downstream able to tell which was meant.
inv := fresh(t)
if _, err := inv.AddNode(t.Context(), "laptop"); err != nil {
t.Fatal(err)
}
first, err := inv.IssueToken(t.Context(), "laptop", time.Hour)
if err != nil {
t.Fatal(err)
}
second, err := inv.IssueToken(t.Context(), "laptop", time.Hour)
if err != nil {
t.Fatal(err)
}
if _, err := inv.Redeem(t.Context(), first.Secret); !errors.Is(err, ErrTokenRefused) {
t.Error("the first token still worked after a second was issued")
}
if _, err := inv.Redeem(t.Context(), second.Secret); err != nil {
t.Errorf("the newest token was refused: %v", err)
}
}
func TestTheSecretIsNotStored(t *testing.T) {
// A copy of this database must not be a set of working credentials.
inv := fresh(t)
if _, err := inv.AddNode(t.Context(), "laptop"); err != nil {
t.Fatal(err)
}
issued, err := inv.IssueToken(t.Context(), "laptop", time.Hour)
if err != nil {
t.Fatal(err)
}
var stored string
if err := inv.store.Pool().QueryRow(t.Context(),
`select secret from enrolment_token limit 1`).Scan(&stored); err != nil {
t.Fatal(err)
}
if stored == issued.Secret {
t.Fatal("the token secret is stored verbatim; this table would be a set of live credentials")
}
if strings.Contains(stored, issued.Secret) {
t.Fatal("the stored value contains the secret")
}
}
func TestTwoRedemptionsOfOneSecretCannotBothWin(t *testing.T) {
// The check and the spend are one statement for this reason. Reading first and writing second
// leaves a window where two machines both pass the check and both join as the same node.
inv := fresh(t)
if _, err := inv.AddNode(t.Context(), "laptop"); err != nil {
t.Fatal(err)
}
issued, err := inv.IssueToken(t.Context(), "laptop", time.Hour)
if err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
results := make([]error, 8)
for i := range results {
wg.Add(1)
go func(i int) {
defer wg.Done()
_, results[i] = inv.Redeem(context.Background(), issued.Secret)
}(i)
}
wg.Wait()
won := 0
for _, err := range results {
if err == nil {
won++
}
}
if won != 1 {
t.Errorf("%d of 8 concurrent redemptions succeeded; exactly one may", won)
}
}
func TestRemovingANodeTakesItsTokensWithIt(t *testing.T) {
// A token outliving the record it was issued for is a right to join as nobody.
inv := fresh(t)
node, err := inv.AddNode(t.Context(), "laptop")
if err != nil {
t.Fatal(err)
}
if _, err := inv.IssueToken(t.Context(), "laptop", time.Hour); 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 enrolment_token`).Scan(&left); err != nil {
t.Fatal(err)
}
if left != 0 {
t.Errorf("%d token(s) outlived the node record they were issued for", left)
}
}
func TestAnUnknownSecretIsRefusedTheSameWayAsAnExpiredOne(t *testing.T) {
// One error for every reason. Somebody guessing must not learn which of their guesses was a
// real token that had merely expired.
inv := fresh(t)
if _, err := inv.AddNode(t.Context(), "laptop"); err != nil {
t.Fatal(err)
}
expired, err := inv.IssueToken(t.Context(), "laptop", 30*time.Millisecond)
if err != nil {
t.Fatal(err)
}
time.Sleep(100 * time.Millisecond)
_, unknownErr := inv.Redeem(t.Context(), "not-a-token-at-all")
_, expiredErr := inv.Redeem(t.Context(), expired.Secret)
if unknownErr == nil || expiredErr == nil {
t.Fatal("one of them was accepted")
}
if unknownErr.Error() != expiredErr.Error() {
t.Errorf("the two are distinguishable:\n unknown: %v\n expired: %v", unknownErr, expiredErr)
}
}
func TestNeverReportedIsNotTheSameAsReportedNothing(t *testing.T) {
// The distinction that makes this safe to hand back. A node that applied nothing holds
// nothing; a node that has never spoken is unknown — and returning an empty list for the
// second would tell a rebuilding node it owns nothing, and have it remove whatever it found
// on the machine.
inv := fresh(t)
node, err := inv.AddNode(t.Context(), "laptop")
if err != nil {
t.Fatal(err)
}
owned, reported, err := inv.Owned(t.Context(), node.ID)
if err != nil {
t.Fatal(err)
}
if owned != nil {
t.Errorf("a node that never reported came back owning %v", owned)
}
if !reported.IsZero() {
t.Error("a node that never reported has a report time")
}
if err := inv.RecordOwned(t.Context(), node.ID, []string{}); err != nil {
t.Fatal(err)
}
owned, reported, err = inv.Owned(t.Context(), node.ID)
if err != nil {
t.Fatal(err)
}
if owned == nil {
t.Error("a node that reported holding nothing is indistinguishable from one that never spoke")
}
if reported.IsZero() {
t.Error("a report that happened has no time on it")
}
}
func TestWhatANodeOwnsIsReplacedNotAccumulated(t *testing.T) {
// The question this answers is what is on that machine now. A node that stopped owning
// something and had it remembered would be handed it back on a rebuild and put it there again.
inv := fresh(t)
node, err := inv.AddNode(t.Context(), "laptop")
if err != nil {
t.Fatal(err)
}
if err := inv.RecordOwned(t.Context(), node.ID, []string{"a", "b", "c"}); err != nil {
t.Fatal(err)
}
if err := inv.RecordOwned(t.Context(), node.ID, []string{"a"}); err != nil {
t.Fatal(err)
}
owned, _, err := inv.Owned(t.Context(), node.ID)
if err != nil {
t.Fatal(err)
}
if len(owned) != 1 || owned[0] != "a" {
t.Errorf("after reporting a, the mesh believes the node owns %v", owned)
}
}
func TestAnAnswerAboutAMachineCarriesItsAge(t *testing.T) {
// This repository has already been bitten by a cache with no age on it: a node running from
// one looked identical to a node running from the database. An answer about a machine is
// worth much less without knowing how old it is, so the age comes back with it.
inv := fresh(t)
node, err := inv.AddNode(t.Context(), "laptop")
if err != nil {
t.Fatal(err)
}
before := time.Now().Add(-time.Second)
if err := inv.RecordOwned(t.Context(), node.ID, []string{"a"}); err != nil {
t.Fatal(err)
}
_, reported, err := inv.Owned(t.Context(), node.ID)
if err != nil {
t.Fatal(err)
}
if reported.Before(before) {
t.Errorf("the report time is %s, which is before the report", reported)
}
}