09-the-node-lifecycle asks for this in as many words -- *how long it has been disconnected is a fact the mesh must hold, and nothing holds it today. Without it, a node running last month's assignments looks exactly like one that is current.* Now it holds it. `node list` says "here", "out of touch 4m", or "never spoken", and the third is kept distinct from the second on purpose: a node that has never spoken did not finish joining, and a node last heard from a month ago is running a month-old picture of the mesh. Those need different responses from a person. A bare word that a node is there moves last_seen and touches nothing else. It is not an account of what the machine holds, and recording it as one would replace the recovery copy with an empty list every minute -- so a rebuilding node would then be told it owns nothing and remove whatever it found. There is a test for exactly that. Heard is silent in the log. A node saying it is there every minute would fill the log with the ordinary case, and a log where the ordinary case is loud is a log nobody reads. Verified in the lab across the threshold, both directions.
436 lines
13 KiB
Go
436 lines
13 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)
|
|
}
|
|
}
|
|
|
|
func TestNeverHeardFromIsNotTheSameAsLongAgo(t *testing.T) {
|
|
// The distinction the whole thing rests on. A node that has never spoken did not finish
|
|
// joining; a node last heard from a month ago is running a month-old picture of the mesh.
|
|
// Until this existed both looked exactly like a node that is current.
|
|
inv := fresh(t)
|
|
node, err := inv.AddNode(t.Context(), "laptop")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
nodes, err := inv.Nodes(t.Context())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ever := nodes[0].Silent(); ever {
|
|
t.Error("a node that has never spoken reports a time since it last did")
|
|
}
|
|
|
|
if err := inv.Seen(t.Context(), node.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
nodes, err = inv.Nodes(t.Context())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
silent, ever := nodes[0].Silent()
|
|
if !ever {
|
|
t.Fatal("a node that has spoken still reports never having done so")
|
|
}
|
|
if silent > time.Minute {
|
|
t.Errorf("a node heard from just now has been silent for %s", silent)
|
|
}
|
|
}
|
|
|
|
func TestBeingHeardFromDoesNotChangeWhatANodeOwns(t *testing.T) {
|
|
// A node saying it is there is not an account of what it holds. Treating one as the other
|
|
// would replace the recovery copy with an empty list every minute, and a rebuilding node
|
|
// would then be told it owns nothing.
|
|
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"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := inv.Seen(t.Context(), node.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
owned, _, err := inv.Owned(t.Context(), node.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(owned) != 2 {
|
|
t.Errorf("after a bare word that the node is here, the mesh believes it owns %v", owned)
|
|
}
|
|
}
|