Files
mesh-controller/internal/store/migrate_test.go
T
jschoubben 306c4ca13b The control plane, as far as identity
Tier 2 exists now. It holds one context of seven, inventory, and does one
thing with it: brings its schema up to date. That is step 3 of the substrate
bootstrap -- the step the first node cannot get past.

Verified against a real PostgreSQL, with the built binary: applied 0001-nodes,
reported 'already up to date' on the second run, and the node table is there
with the index and the unique constraint the migration asks for.

Written in Go, and the image is FROM scratch holding one file. Confirmed by
unpacking it. That is the whole argument of ADR 0024: the bundle pins this
image by digest and runs it where nothing can check it, so everything in it is
something a person has to audit before trusting a first node.

Exclusive store ownership is built as a rule about credentials rather than
about intentions. There is no mesh-wide connection setting and no way to ask
for one -- a context reads MESH_STORE_<ITS OWN NAME> and holds nothing else, so
reaching another context's store needs a new variable, which is visible in the
declaration that runs it.

The migration runner is mostly refusals: an edited migration that already ran,
a migration numbered below one that has run, duplicate numbers, misnamed files,
empty files. All stop rather than warn, because at the moment any of them is
true nobody knows what the database holds.

It stops before identity, deliberately. What a node presents to prove who it is
has not been decided anywhere, and a migration is the most expensive place in
this system to guess.

Two tests did not defend what they claimed, and both are fixed rather than
removed. One asked only whether Open returned an error, which it did either way
-- a bad context name and a missing credential both fail, so deleting the name
check changed nothing. The other claimed to prove the migration runs in a
transaction, but PostgreSQL already wraps a multi-statement query in one of its
own, so it passed with the transaction taken out. What the transaction actually
buys is that the schema change and the row recording it commit together, and
there is now a test for that which fails when they are split.
2026-08-29 02:44:09 +02:00

207 lines
7.8 KiB
Go

package store
import (
"strings"
"testing"
"testing/fstest"
)
// The refusals in this file are the ones that decide whether a schema can be trusted months
// later. Each has a wrong answer that looks helpful — skip it, apply it anyway, warn and carry on
// — and each of those turns "this database disagrees with these files" into a silence.
func load(t *testing.T, files fstest.MapFS) ([]Migration, error) {
t.Helper()
return LoadMigrations(files, "migrations")
}
func file(body string) *fstest.MapFile { return &fstest.MapFile{Data: []byte(body)} }
func TestMigrationsAreReadInNumericOrder(t *testing.T) {
// Read from a directory, which has no order of its own. Alphabetical happens to agree with
// numeric while the numbers are the same width, which is exactly why this is asserted rather
// than assumed.
got, err := load(t, fstest.MapFS{
"migrations/0010-tenth.sql": file("select 10"),
"migrations/0002-second.sql": file("select 2"),
"migrations/0001-first.sql": file("select 1"),
})
if err != nil {
t.Fatal(err)
}
var order []int
for _, m := range got {
order = append(order, m.Number)
}
if len(order) != 3 || order[0] != 1 || order[1] != 2 || order[2] != 10 {
t.Errorf("migrations came back in order %v; they run in the order they are returned", order)
}
}
func TestAMisnamedFileIsAnErrorRatherThanSkipped(t *testing.T) {
// The tempting behaviour is to ignore anything that does not match, so that a README can sit
// in the directory. The cost is that a migration named `001-thing.sql` or `0002_thing.sql` is
// then ignored in silence, and the schema simply lacks it — surfacing later as a missing
// column, a long way from the file that was misnamed.
_, err := load(t, fstest.MapFS{
"migrations/0001-first.sql": file("select 1"),
"migrations/0002_second.sql": file("select 2"),
})
if err == nil {
t.Fatal("a misnamed migration was skipped silently; it would never run and nothing would say so")
}
}
func TestTwoMigrationsWithOneNumberAreRefused(t *testing.T) {
// Order is the entire guarantee. Two files with one number have none, and whichever the
// filesystem returned first would win.
_, err := load(t, fstest.MapFS{
"migrations/0001-first.sql": file("select 1"),
"migrations/0001-also-first.sql": file("select 2"),
})
if err == nil {
t.Fatal("two migrations numbered 0001 were accepted")
}
}
func TestAnEmptyMigrationIsRefused(t *testing.T) {
// An empty migration records that something happened and changes nothing — the one state that
// cannot be told apart from a mistake, and it is recorded as done for ever.
_, err := load(t, fstest.MapFS{"migrations/0001-nothing.sql": file(" \n\t\n")})
if err == nil {
t.Fatal("an empty migration was accepted")
}
}
func TestAChangedMigrationIsRefused(t *testing.T) {
// The one that matters most. The database holds what the old file said; the repository holds
// the new one; nothing anywhere holds the difference. Applying it again would be wrong and
// skipping it silently leaves the two permanently out of step.
migrations := []Migration{{Number: 1, Name: "nodes", Checksum: "aaaa"}}
applied := []Applied{{Number: 1, Name: "nodes", Checksum: "bbbb"}}
_, err := Pending(migrations, applied)
if err == nil {
t.Fatal("a migration whose file changed after it ran was accepted")
}
if !strings.Contains(err.Error(), "0001-nodes") {
t.Errorf("the refusal does not name the migration: %v", err)
}
}
func TestAnUnchangedMigrationIsNotPending(t *testing.T) {
// The other half, and the one that makes the run idempotent. Without it every start would
// re-apply the whole schema.
migrations := []Migration{{Number: 1, Name: "nodes", Checksum: "aaaa"}}
applied := []Applied{{Number: 1, Name: "nodes", Checksum: "aaaa"}}
pending, err := Pending(migrations, applied)
if err != nil {
t.Fatal(err)
}
if len(pending) != 0 {
t.Errorf("an already-applied migration came back as pending; every start would re-run it")
}
}
func TestAMigrationArrivingBelowTheHighWaterMarkIsRefused(t *testing.T) {
// Two branches take the same next number; they merge in whatever order they landed. The file
// is fine and applying it now would run the schema in an order nobody tested — which is the
// same class of fault as applying them out of order deliberately, arriving by accident.
migrations := []Migration{
{Number: 1, Name: "nodes", Checksum: "aaaa"},
{Number: 2, Name: "late", Checksum: "cccc"},
{Number: 3, Name: "third", Checksum: "bbbb"},
}
applied := []Applied{
{Number: 1, Name: "nodes", Checksum: "aaaa"},
{Number: 3, Name: "third", Checksum: "bbbb"},
}
_, err := Pending(migrations, applied)
if err == nil {
t.Fatal("a migration numbered below one that already ran was applied out of order")
}
if !strings.Contains(err.Error(), "0002-late") {
t.Errorf("the refusal does not name the migration: %v", err)
}
}
func TestEveryDisagreementIsReportedNotOnlyTheFirst(t *testing.T) {
// A person looking at this is deciding what to do about a schema. Being told one problem,
// fixing it, and being told the next is how a single decision becomes four.
migrations := []Migration{
{Number: 1, Name: "one", Checksum: "aaaa"},
{Number: 2, Name: "two", Checksum: "cccc"},
}
applied := []Applied{
{Number: 1, Name: "one", Checksum: "changed"},
{Number: 3, Name: "three", Checksum: "dddd"},
}
_, err := Pending(migrations, applied)
if err == nil {
t.Fatal("expected refusals")
}
if !strings.Contains(err.Error(), "0001-one") || !strings.Contains(err.Error(), "0002-two") {
t.Errorf("only some problems were reported: %v", err)
}
}
func TestAContextNameThatCannotBeADatabaseIsRefused(t *testing.T) {
// The name becomes an environment variable and a database name. One that is valid in one and
// not the other fails at bootstrap, on a machine with no mesh on it and nobody watching.
//
// Asserted on *which* refusal fired, not merely that one did. Open has a second reason to
// fail a line later — no credential — and an unusable name would have produced an error
// either way, so a test asking only "was there an error" passes with this check deleted.
// It was written that way first and confirmed to defend nothing.
for _, name := range []string{"", "Inventory", "my-context", "9lives", "drop table"} {
_, err := Open(t.Context(), name)
if err == nil {
t.Errorf("%q was accepted as a context name", name)
continue
}
if !strings.Contains(err.Error(), "not a usable context name") {
t.Errorf("%q was refused for the wrong reason: %v", name, err)
}
}
}
func TestAContextWithoutItsCredentialIsRefused(t *testing.T) {
// And the message has to say that reusing another context's connection is not the remedy,
// because it is the obvious one and it is how ADR 0008 gets quietly undone.
t.Setenv(Variable("inventory"), "")
_, err := Open(t.Context(), "inventory")
if err == nil {
t.Fatal("a context with no credential opened a store")
}
if !strings.Contains(err.Error(), Variable("inventory")) {
t.Errorf("the refusal does not name the variable that is missing: %v", err)
}
}
func TestAMalformedConnectionStringIsNotQuotedBack(t *testing.T) {
// The value carries a password. An error message that quotes what it could not parse puts it
// into a log, on the one machine where the bootstrap output is being watched by a person.
secret := "hunter2-this-must-not-appear"
t.Setenv(Variable("inventory"), "postgres://user:"+secret+"@host:notaport/db")
_, err := Open(t.Context(), "inventory")
if err == nil {
t.Fatal("a malformed connection string was accepted")
}
if strings.Contains(err.Error(), secret) {
t.Errorf("the password appeared in the error: %v", err)
}
}
func TestTheVariableAndDatabaseFollowTheContextName(t *testing.T) {
if got := Variable("inventory"); got != "MESH_STORE_INVENTORY" {
t.Errorf("Variable(inventory) = %q", got)
}
if got := Database("inventory"); got != "inventory" {
t.Errorf("Database(inventory) = %q", got)
}
}