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.
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
/build/
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
# The control plane's image.
|
||||||
|
#
|
||||||
|
# novox/hq ADR 0024: this image is pinned by digest in the bundle the host carries, fetched on a
|
||||||
|
# machine where no mesh exists yet, and run before there is anything to check it against. So it
|
||||||
|
# holds the program and nothing else — no shell, no package manager, no libc, nothing with a CVE
|
||||||
|
# feed of its own. What a person has to audit before trusting a first node is one binary.
|
||||||
|
#
|
||||||
|
# There are no CA certificates in here on purpose. Nothing it does today makes an outbound TLS
|
||||||
|
# connection to a public name: it reaches PostgreSQL on the machine it was raised on, and the
|
||||||
|
# broker is verified against a fingerprint pinned in a token rather than against a public root
|
||||||
|
# (novox/hq ADR 0004). Adding them "just in case" would put a trust store in the one image whose
|
||||||
|
# whole argument is that it contains nothing to reason about.
|
||||||
|
|
||||||
|
FROM golang:1.25-alpine AS build
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Dependencies first, so a change to the source does not refetch them.
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
ARG VERSION=development
|
||||||
|
RUN CGO_ENABLED=0 go build -trimpath \
|
||||||
|
-ldflags "-s -w -X main.version=${VERSION}" \
|
||||||
|
-o /mesh-control ./cmd/mesh-control
|
||||||
|
|
||||||
|
FROM scratch
|
||||||
|
COPY --from=build /mesh-control /mesh-control
|
||||||
|
|
||||||
|
# Numeric because there is no /etc/passwd to look a name up in. Nothing here needs to be root:
|
||||||
|
# it opens outbound connections and writes nothing to its own filesystem.
|
||||||
|
USER 65534:65534
|
||||||
|
|
||||||
|
ENTRYPOINT ["/mesh-control"]
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# novox/hq ADR 0024 — the control plane, in Go.
|
||||||
|
#
|
||||||
|
# The image the bundle pins holds the program and nothing else, so the build is static and the
|
||||||
|
# container is built FROM scratch. That is not a size optimisation: this image is fetched by
|
||||||
|
# digest and run on a machine where no mesh exists to check anything, and everything in it is
|
||||||
|
# something a person would have to audit.
|
||||||
|
|
||||||
|
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo development)
|
||||||
|
LDFLAGS := -s -w -X main.version=$(VERSION)
|
||||||
|
|
||||||
|
# Where `make check` raises PostgreSQL. A high port and a throwaway container: nothing here
|
||||||
|
# touches a database anybody else is using. Override PG_PORT if this one is taken -- the first
|
||||||
|
# port chosen was already serving something that had been up for six days.
|
||||||
|
PG_PORT ?= 55532
|
||||||
|
PG_CONTAINER ?= mesh-control-check
|
||||||
|
PG_IMAGE ?= postgres:17-alpine
|
||||||
|
export MESH_TEST_POSTGRES ?= postgres://postgres:check@127.0.0.1:$(PG_PORT)/postgres?sslmode=disable
|
||||||
|
|
||||||
|
.PHONY: build image check test vet fmt postgres postgres-stop clean
|
||||||
|
|
||||||
|
build:
|
||||||
|
CGO_ENABLED=0 go build -trimpath -ldflags '$(LDFLAGS)' -o build/mesh-control ./cmd/mesh-control
|
||||||
|
|
||||||
|
IMAGE ?= mesh-control:$(VERSION)
|
||||||
|
|
||||||
|
image:
|
||||||
|
docker build --build-arg VERSION=$(VERSION) -t $(IMAGE) .
|
||||||
|
@echo
|
||||||
|
@docker image inspect $(IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
||||||
|
|
||||||
|
# The whole gate. Raises a database, runs everything against it, and takes it down again --
|
||||||
|
# including when the tests fail, which is why the teardown is not conditional.
|
||||||
|
check: fmt vet postgres
|
||||||
|
@go test ./... ; status=$$? ; $(MAKE) postgres-stop ; exit $$status
|
||||||
|
|
||||||
|
# Without a database the live tests skip rather than fail, so this is the honest subset and not
|
||||||
|
# the gate.
|
||||||
|
test:
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
vet:
|
||||||
|
go vet ./...
|
||||||
|
|
||||||
|
fmt:
|
||||||
|
@unformatted=$$(gofmt -l . 2>/dev/null) ; \
|
||||||
|
if [ -n "$$unformatted" ] ; then echo "not gofmt'd:" ; echo "$$unformatted" ; exit 1 ; fi
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
@docker rm -f $(PG_CONTAINER) >/dev/null 2>&1 || true
|
||||||
|
@docker run -d --name $(PG_CONTAINER) -e POSTGRES_PASSWORD=check \
|
||||||
|
-p 127.0.0.1:$(PG_PORT):5432 $(PG_IMAGE) >/dev/null
|
||||||
|
@printf 'waiting for postgres'
|
||||||
|
@for i in $$(seq 1 60) ; do \
|
||||||
|
if docker exec $(PG_CONTAINER) pg_isready -U postgres >/dev/null 2>&1 ; then \
|
||||||
|
echo ' — ready' ; exit 0 ; fi ; \
|
||||||
|
printf '.' ; sleep 1 ; \
|
||||||
|
done ; \
|
||||||
|
echo ' — never came up' ; docker logs $(PG_CONTAINER) | tail -20 ; exit 1
|
||||||
|
|
||||||
|
postgres-stop:
|
||||||
|
@docker rm -f $(PG_CONTAINER) >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf build/
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# mesh-control
|
||||||
|
|
||||||
|
**Tier 2 of Novox Mesh — the control plane.** Everything that needs to know about more than one
|
||||||
|
node.
|
||||||
|
|
||||||
|
That is the whole test, and it draws the line the host cannot: the host applies and does not
|
||||||
|
decide, *because deciding needs knowledge one machine does not have*. Which nodes should run the
|
||||||
|
store, which peers belong in an overlay, whether a node has been unreachable for a week — nobody
|
||||||
|
on a single machine can answer any of them.
|
||||||
|
|
||||||
|
The reasoning lives in [novox/hq](https://git.novox.be/novox/hq); this repository carries no
|
||||||
|
argument that is not settled there.
|
||||||
|
|
||||||
|
## What it is not
|
||||||
|
|
||||||
|
- **Not the thing that changes machines.** It decides; the host applies. It never reaches into a
|
||||||
|
node except through the host, over the link, in a bounded vocabulary.
|
||||||
|
- **Not a database.** There is no mesh database. Each context owns its own store and nothing
|
||||||
|
outside a context touches it — including nodes, which hold no credential to any of them.
|
||||||
|
- **Not privileged.** It has no more access to a machine than a declaration can express.
|
||||||
|
|
||||||
|
## What exists today
|
||||||
|
|
||||||
|
**One context of seven, and one of the things it will do.**
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| `inventory` | the node records — **the schema exists** |
|
||||||
|
| `config`, `connectivity`, `provisioning`, `delivery`, `observability`, `identity` | not built |
|
||||||
|
| the interface every surface speaks to | not built; its shape is not decided |
|
||||||
|
|
||||||
|
```
|
||||||
|
mesh-control migrate bring each context's schema up to date
|
||||||
|
mesh-control version what this binary is
|
||||||
|
```
|
||||||
|
|
||||||
|
`migrate` is **step 3 of the substrate bootstrap** — the step the first node cannot get past, run
|
||||||
|
against a database raised moments earlier from the bundle the host carries.
|
||||||
|
|
||||||
|
### Where this stops, and why there
|
||||||
|
|
||||||
|
At **identity**. A node's own identity is the next thing needed and its cryptographic form is not
|
||||||
|
decided anywhere: whether a node holds a keypair whose public half the mesh keeps, or something
|
||||||
|
else. Modelling it would have meant guessing, in a migration — which is the most expensive place
|
||||||
|
in this system to guess, because a schema that ran is finished and the only way back is another
|
||||||
|
migration.
|
||||||
|
|
||||||
|
So the node table holds what a node record *is* — a name, when it was made, what the machine last
|
||||||
|
reported about itself, when it was last heard from — and stops before what a node *presents*.
|
||||||
|
|
||||||
|
## Reaching a store
|
||||||
|
|
||||||
|
**A context is granted only what it exclusively owns.** No shared writes, no read-only role on
|
||||||
|
another context's store, and no connection string that reaches more than one.
|
||||||
|
|
||||||
|
That is a rule about credentials, so it is built as one. There is no mesh-wide connection setting
|
||||||
|
and no way to ask for one:
|
||||||
|
|
||||||
|
```
|
||||||
|
MESH_STORE_INVENTORY=postgres://…/inventory
|
||||||
|
```
|
||||||
|
|
||||||
|
A process granted `inventory` holds that variable and no other. Reaching another context's store
|
||||||
|
is not a matter of restraint — it has no address for it and no credential to present. And it is
|
||||||
|
how the rule is *checked*: what a context can reach is visible in the declaration that runs it,
|
||||||
|
as the list of variables it was given.
|
||||||
|
|
||||||
|
Each context's database is named after the context. There is deliberately no database named for
|
||||||
|
the mesh as a whole.
|
||||||
|
|
||||||
|
## Migrations
|
||||||
|
|
||||||
|
Numbered, embedded in the binary, applied in order, each in a transaction with the row recording
|
||||||
|
it. The applying is four lines; the rest is refusals, and the refusals are the point:
|
||||||
|
|
||||||
|
| it stops when | because |
|
||||||
|
|---|---|
|
||||||
|
| a migration that ran has since been edited | the database holds the old version, the repository holds the new one, and nothing holds the difference |
|
||||||
|
| a migration is numbered below one that already ran | usually two branches taking the same next number — applying it now runs the schema in an order nobody tested |
|
||||||
|
| two migrations share a number | order is the entire guarantee, and two files with one number have none |
|
||||||
|
| a file in the directory is not a valid migration name | a misnamed migration would otherwise never run and nothing would say so |
|
||||||
|
| a migration is empty | it records that something happened and changes nothing, which cannot be told from a mistake |
|
||||||
|
|
||||||
|
All of them stop rather than warn. At the moment any of them is true, nobody knows what the
|
||||||
|
database contains, and there is no correct guess about a schema.
|
||||||
|
|
||||||
|
Running it again does nothing. Two copies running at once take an advisory lock, so a restart
|
||||||
|
during a slow migration does not become two runners racing.
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
```
|
||||||
|
make build the binary
|
||||||
|
make image the container image
|
||||||
|
make check gofmt, vet, and every test against a real PostgreSQL
|
||||||
|
```
|
||||||
|
|
||||||
|
`make check` raises a throwaway PostgreSQL in a container and takes it down afterwards, including
|
||||||
|
when the tests fail. Without one the tests that need a database **skip and say so** rather than
|
||||||
|
passing quietly — `make test` is the honest subset, not the gate.
|
||||||
|
|
||||||
|
Tests run against a real database rather than a fake because what is being tested *is* the
|
||||||
|
database's behaviour: that DDL is transactional, that an advisory lock serialises, that a
|
||||||
|
checksum mismatch is caught against a record PostgreSQL actually kept. A fake would assert that
|
||||||
|
the fake behaves as expected.
|
||||||
|
|
||||||
|
Every test here has been confirmed to fail when the behaviour it defends is removed. Two did not,
|
||||||
|
when first written, and both are now commented with what they were missing.
|
||||||
|
|
||||||
|
## The image
|
||||||
|
|
||||||
|
`FROM scratch`, holding one statically linked binary and nothing else — no shell, no package
|
||||||
|
manager, no libc, no CA certificates.
|
||||||
|
|
||||||
|
Not a size optimisation. The bundle a host carries pins this image by digest, and it is fetched
|
||||||
|
and run on a machine where no mesh exists to check anything and a person is expected to have read
|
||||||
|
the bundle and believed it. Everything in the image is something that person would have to audit.
|
||||||
|
|
||||||
|
## Where the reasoning lives
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| what the control plane is | novox/hq ADR 0006 |
|
||||||
|
| what it takes to run one, and why Go | novox/hq ADR 0024 |
|
||||||
|
| a context owns its store, exclusively | novox/hq ADR 0008 |
|
||||||
|
| schema changes are numbered migrations | novox/hq ADR 0013 |
|
||||||
|
| a test defends a decision | novox/hq ADR 0017 |
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
// Command mesh-control is the control plane: everything that needs to know about more than one
|
||||||
|
// node (novox/hq ADR 0006).
|
||||||
|
//
|
||||||
|
// It runs as one process holding several contexts, each owning its own store. Today it holds one,
|
||||||
|
// `inventory`, and does one thing with it — brings its schema up to date, which is step 3 of the
|
||||||
|
// bootstrap in novox/hq 07-the-substrate and the step the first node cannot get past without.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-control/internal/inventory"
|
||||||
|
"github.com/novox/mesh-control/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// version is stamped at link time. Unset in a development build, and it says so rather than
|
||||||
|
// claiming a number.
|
||||||
|
var version = "development build"
|
||||||
|
|
||||||
|
// held is a context this process was granted, and the schema it carries.
|
||||||
|
//
|
||||||
|
// novox/hq ADR 0006 names seven. One is built. The list is short because the others do not exist
|
||||||
|
// yet, not because they are optional.
|
||||||
|
var held = []struct {
|
||||||
|
name string
|
||||||
|
migrations func() ([]store.Migration, error)
|
||||||
|
}{
|
||||||
|
{inventory.Name, inventory.Migrations},
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := run(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "mesh-control: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() error {
|
||||||
|
args := os.Args[1:]
|
||||||
|
if len(args) == 0 {
|
||||||
|
usage()
|
||||||
|
return fmt.Errorf("no command given")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
switch args[0] {
|
||||||
|
case "migrate":
|
||||||
|
return migrate(ctx)
|
||||||
|
case "version":
|
||||||
|
fmt.Println(version)
|
||||||
|
return nil
|
||||||
|
case "help", "-h", "--help":
|
||||||
|
usage()
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
usage()
|
||||||
|
return fmt.Errorf("%q is not a command", args[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func usage() {
|
||||||
|
fmt.Fprint(os.Stderr, `mesh-control — the control plane
|
||||||
|
|
||||||
|
migrate bring each context's schema up to date
|
||||||
|
version what this binary is
|
||||||
|
|
||||||
|
Each context reaches its own store through its own credential (novox/hq ADR 0008), named
|
||||||
|
`+store.Variable("<context>")+`. This process holds:
|
||||||
|
|
||||||
|
`)
|
||||||
|
for _, c := range held {
|
||||||
|
fmt.Fprintf(os.Stderr, " %-12s database %-12s from %s\n",
|
||||||
|
c.name, store.Database(c.name), store.Variable(c.name))
|
||||||
|
}
|
||||||
|
fmt.Fprintln(os.Stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrate brings every held context's schema up to date.
|
||||||
|
//
|
||||||
|
// Reported per context and per migration, because this runs during a bootstrap on a machine with
|
||||||
|
// nothing else on it — the output is the only account of what happened, and "migrated" is not one.
|
||||||
|
func migrate(ctx context.Context) error {
|
||||||
|
for _, c := range held {
|
||||||
|
migrations, err := c.migrations()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s, err := store.Open(ctx, c.name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
// The bootstrap raises PostgreSQL moments before this runs, and a container that is
|
||||||
|
// running is not a database that will answer — a distinction this project has already
|
||||||
|
// paid for once, when a crash-looping database reported itself as up between restarts.
|
||||||
|
if err := s.Ready(ctx, 60*time.Second); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
done, err := s.Migrate(ctx, migrations)
|
||||||
|
for _, m := range done {
|
||||||
|
fmt.Printf("%s: applied %04d-%s\n", c.name, m.Number, m.Name)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(done) == 0 {
|
||||||
|
applied, err := s.AppliedMigrations(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("%s: already up to date — %d migration(s)\n", c.name, len(applied))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
module github.com/novox/mesh-control
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
|
golang.org/x/text v0.29.0 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||||
|
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// Package inventory is the context that knows which machines are in the mesh.
|
||||||
|
//
|
||||||
|
// novox/hq ADR 0006 names seven contexts; this is the first built, because everything else needs
|
||||||
|
// to be able to say which node it is talking about.
|
||||||
|
//
|
||||||
|
// It owns its store exclusively (novox/hq ADR 0008) — a database called `inventory`, reached with
|
||||||
|
// a credential no other context holds.
|
||||||
|
package inventory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-control/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Name is what this context is called: its database, and the environment variable holding the
|
||||||
|
// credential for it, are both derived from this.
|
||||||
|
const Name = "inventory"
|
||||||
|
|
||||||
|
//go:embed migrations/*.sql
|
||||||
|
var files embed.FS
|
||||||
|
|
||||||
|
// Migrations are the schema changes this context carries, in order.
|
||||||
|
//
|
||||||
|
// Embedded, so the binary and its schema are one artifact: an image cannot run this code against
|
||||||
|
// a directory of migrations from a different version of it.
|
||||||
|
func Migrations() ([]store.Migration, error) {
|
||||||
|
return store.LoadMigrations(files, "migrations")
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
-- The node records: which machines this mesh knows about.
|
||||||
|
--
|
||||||
|
-- novox/hq ADR 0006 — inventory holds nodes, modules, assignments and versions, and this is the
|
||||||
|
-- first of the four. The others arrive with delivery, which is not built; a table nothing writes
|
||||||
|
-- to is a guess about a shape, and guesses about shapes are what migrations make expensive.
|
||||||
|
|
||||||
|
create table node (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
|
||||||
|
-- What a person calls this machine. Unique because it is how a node is named when a token is
|
||||||
|
-- issued for it (`token issue --node workstation`), and a name that matched two records would
|
||||||
|
-- make that command ambiguous at exactly the moment it grants access to the mesh.
|
||||||
|
name text not null unique,
|
||||||
|
|
||||||
|
created timestamptz not null default now(),
|
||||||
|
|
||||||
|
-- The last profile the node reported: what it can run, which is the input to deciding what it
|
||||||
|
-- should run (novox/hq 09-the-node-lifecycle, step 4).
|
||||||
|
--
|
||||||
|
-- Held opaquely, as the document the node sent. The host owns that shape and the control
|
||||||
|
-- plane's job here is to keep the last one faithfully, not to have an opinion about it — so a
|
||||||
|
-- host that learns to report something new does not need this schema to change first.
|
||||||
|
profile jsonb,
|
||||||
|
|
||||||
|
-- When this node was last heard from.
|
||||||
|
--
|
||||||
|
-- There is no `state` column, and its absence is deliberate. The lifecycle has four states,
|
||||||
|
-- but two of them — unmanaged and hosted — are situations of a *machine* that the mesh has
|
||||||
|
-- not been told about, so they cannot be rows here. The remaining pair, enrolled and
|
||||||
|
-- disconnected, are described in novox/hq ADR 0004 as the same node in two situations rather
|
||||||
|
-- than two kinds of thing, and the difference between them is how long it has been since this
|
||||||
|
-- column moved.
|
||||||
|
--
|
||||||
|
-- Stored as a state it would have to be written by something noticing a node had gone quiet —
|
||||||
|
-- and nothing notices silence. It would be correct while nodes were talking and wrong exactly
|
||||||
|
-- when it mattered.
|
||||||
|
last_seen timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
-- "Has this node been unreachable for a week" is the control plane's question by definition
|
||||||
|
-- (novox/hq ADR 0006 — nobody else is watching), so the column it is asked of is indexed.
|
||||||
|
create index node_last_seen on node (last_seen nulls first);
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// These run against a real PostgreSQL. Not a fake, and for the reason novox/hq ADR 0017 gives
|
||||||
|
// where the host tests the real filesystem: what is being tested here *is* the database's
|
||||||
|
// behaviour — that DDL is transactional, that an advisory lock serialises, that a checksum
|
||||||
|
// mismatch is caught against a record the database actually kept. A fake would assert that the
|
||||||
|
// fake behaves as expected.
|
||||||
|
//
|
||||||
|
// `make check` raises one. Without it these skip, and say so rather than passing.
|
||||||
|
|
||||||
|
func admin(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
dsn := os.Getenv("MESH_TEST_POSTGRES")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("no MESH_TEST_POSTGRES; run `make check` to raise one")
|
||||||
|
}
|
||||||
|
return dsn
|
||||||
|
}
|
||||||
|
|
||||||
|
// freshStore gives a test its own empty database.
|
||||||
|
//
|
||||||
|
// Its own, rather than a shared one cleaned between tests: these tests are about what a migration
|
||||||
|
// runner does to a schema, and a leftover table from a previous test is indistinguishable from
|
||||||
|
// the bug this whole package exists to catch.
|
||||||
|
func freshStore(t *testing.T) *Store {
|
||||||
|
t.Helper()
|
||||||
|
dsn := admin(t)
|
||||||
|
name := fmt.Sprintf("test_%s_%d", strings.ToLower(strings.NewReplacer(
|
||||||
|
"/", "_", "-", "_").Replace(t.Name())), time.Now().UnixNano()%1_000_000)
|
||||||
|
if len(name) > 60 {
|
||||||
|
name = name[:60]
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := pgx.Connect(t.Context(), dsn)
|
||||||
|
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())
|
||||||
|
|
||||||
|
t.Setenv(Variable("testing"), replaceDatabase(dsn, name))
|
||||||
|
s, err := Open(t.Context(), "testing")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
s.Close()
|
||||||
|
c, err := pgx.Connect(context.Background(), dsn)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer c.Close(context.Background())
|
||||||
|
_, _ = c.Exec(context.Background(), "drop database if exists "+name+" with (force)")
|
||||||
|
})
|
||||||
|
if err := s.Ready(t.Context(), 20*time.Second); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceDatabase(dsn, name string) string {
|
||||||
|
cut := strings.LastIndex(dsn, "/")
|
||||||
|
rest := ""
|
||||||
|
if q := strings.Index(dsn[cut:], "?"); q >= 0 {
|
||||||
|
rest = dsn[cut+q:]
|
||||||
|
}
|
||||||
|
return dsn[:cut] + "/" + name + rest
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheSchemaIsAppliedAndRecorded(t *testing.T) {
|
||||||
|
s := freshStore(t)
|
||||||
|
migrations := []Migration{{Number: 1, Name: "people", SQL: "create table person (id int)", Checksum: "a"}}
|
||||||
|
|
||||||
|
done, err := s.Migrate(t.Context(), migrations)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(done) != 1 {
|
||||||
|
t.Fatalf("applied %d migrations, expected 1", len(done))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read back from the system rather than trusting the return value (novox/hq ADR 0018).
|
||||||
|
var exists bool
|
||||||
|
if err := s.Pool().QueryRow(t.Context(),
|
||||||
|
`select exists (select 1 from information_schema.tables where table_name = 'person')`,
|
||||||
|
).Scan(&exists); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
t.Error("Migrate reported success and the table is not there")
|
||||||
|
}
|
||||||
|
|
||||||
|
applied, err := s.AppliedMigrations(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(applied) != 1 || applied[0].Checksum != "a" {
|
||||||
|
t.Errorf("the record says %+v", applied)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunningTwiceChangesNothing(t *testing.T) {
|
||||||
|
// The bootstrap runs this, and so does every restart of the control plane. A second run that
|
||||||
|
// re-applied the schema would fail on the first `create table`, so a control plane would come
|
||||||
|
// up exactly once.
|
||||||
|
s := freshStore(t)
|
||||||
|
migrations := []Migration{{Number: 1, Name: "people", SQL: "create table person (id int)", Checksum: "a"}}
|
||||||
|
|
||||||
|
if _, err := s.Migrate(t.Context(), migrations); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
done, err := s.Migrate(t.Context(), migrations)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the second run failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(done) != 0 {
|
||||||
|
t.Errorf("the second run applied %d migrations", len(done))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAFailedMigrationLeavesNothingBehind(t *testing.T) {
|
||||||
|
// Note what this does and does not defend. PostgreSQL wraps a multi-statement simple query in
|
||||||
|
// an implicit transaction of its own, so this passes with this package's transaction removed
|
||||||
|
// — it was checked, and it did. What it defends is the database and driver behaviour relied
|
||||||
|
// on: a driver sending each statement separately would break it, and nothing else would say
|
||||||
|
// so. The property that belongs to this code is the next test.
|
||||||
|
s := freshStore(t)
|
||||||
|
migrations := []Migration{{
|
||||||
|
Number: 1, Name: "half", Checksum: "a",
|
||||||
|
SQL: `create table kept (id int);
|
||||||
|
create table broken (id int) this is not sql;`,
|
||||||
|
}}
|
||||||
|
|
||||||
|
if _, err := s.Migrate(t.Context(), migrations); err == nil {
|
||||||
|
t.Fatal("a migration with a syntax error reported success")
|
||||||
|
}
|
||||||
|
|
||||||
|
var tables int
|
||||||
|
if err := s.Pool().QueryRow(t.Context(),
|
||||||
|
`select count(*) from information_schema.tables where table_name in ('kept','broken')`,
|
||||||
|
).Scan(&tables); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if tables != 0 {
|
||||||
|
t.Errorf("%d table(s) survived a failed migration; it must be all or nothing", tables)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestASchemaChangeAndItsRecordCommitTogether(t *testing.T) {
|
||||||
|
// This is what the explicit transaction is for, and all it is for.
|
||||||
|
//
|
||||||
|
// Split the change from the row saying it happened, and a schema moves with nothing recording
|
||||||
|
// it — so the next run finds the migration outstanding and applies it to a database that
|
||||||
|
// already has it. The failure surfaces as a broken migration rather than as a lost record.
|
||||||
|
//
|
||||||
|
// The real case is the process dying between the two, which a test cannot arrange. Standing
|
||||||
|
// in for it: a migration that makes its own record impossible to write.
|
||||||
|
s := freshStore(t)
|
||||||
|
if _, err := s.AppliedMigrations(t.Context()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
migrations := []Migration{{
|
||||||
|
Number: 1, Name: "hostile", Checksum: "a",
|
||||||
|
SQL: "create table kept (id int); drop table migration;",
|
||||||
|
}}
|
||||||
|
if _, err := s.Migrate(t.Context(), migrations); err == nil {
|
||||||
|
t.Fatal("a migration whose record could not be written reported success")
|
||||||
|
}
|
||||||
|
|
||||||
|
var kept, ledger bool
|
||||||
|
if err := s.Pool().QueryRow(t.Context(),
|
||||||
|
`select exists (select 1 from information_schema.tables where table_name = 'kept'),
|
||||||
|
exists (select 1 from information_schema.tables where table_name = 'migration')`,
|
||||||
|
).Scan(&kept, &ledger); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if kept {
|
||||||
|
t.Error("the schema change survived although nothing recorded it; the next run would " +
|
||||||
|
"apply it again, to a database that already has it")
|
||||||
|
}
|
||||||
|
if !ledger {
|
||||||
|
t.Error("the migration record did not come back with the rollback")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAChangedMigrationIsRefusedAgainstARealRecord(t *testing.T) {
|
||||||
|
// The same refusal as the unit test, against a record PostgreSQL actually kept — which is
|
||||||
|
// what the guard protects, and the unit test can only model.
|
||||||
|
s := freshStore(t)
|
||||||
|
first := []Migration{{Number: 1, Name: "people", SQL: "create table person (id int)", Checksum: "a"}}
|
||||||
|
if _, err := s.Migrate(t.Context(), first); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
edited := []Migration{{Number: 1, Name: "people", SQL: "create table person (id bigint)", Checksum: "b"}}
|
||||||
|
if _, err := s.Migrate(t.Context(), edited); err == nil {
|
||||||
|
t.Fatal("a migration edited after it ran was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTwoRunnersDoNotRaceEachOther(t *testing.T) {
|
||||||
|
// A restart during a slow migration produces exactly this: two copies of the control plane
|
||||||
|
// migrating one database. Without the advisory lock both read an empty record, both decide
|
||||||
|
// everything is outstanding, and the second fails on `create table` — which looks like a
|
||||||
|
// broken migration rather than a race.
|
||||||
|
s := freshStore(t)
|
||||||
|
migrations := []Migration{{
|
||||||
|
Number: 1, Name: "slow", Checksum: "a",
|
||||||
|
SQL: "create table slow (id int); select pg_sleep(0.4);",
|
||||||
|
}}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
results := make([]error, 2)
|
||||||
|
counts := make([]int, 2)
|
||||||
|
for i := range results {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
done, err := s.Migrate(context.Background(), migrations)
|
||||||
|
results[i], counts[i] = err, len(done)
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
for i, err := range results {
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("runner %d failed: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if counts[0]+counts[1] != 1 {
|
||||||
|
t.Errorf("the migration was applied %d times between two runners; exactly one should have "+
|
||||||
|
"done the work and the other should have found nothing to do", counts[0]+counts[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadedMigrationsApplyToARealDatabase(t *testing.T) {
|
||||||
|
// A migration that parses and does not run is the failure this catches. The unit tests read
|
||||||
|
// files and check names; nothing there executes SQL.
|
||||||
|
s := freshStore(t)
|
||||||
|
migrations, err := LoadMigrations(fstest.MapFS{
|
||||||
|
"migrations/0001-first.sql": {Data: []byte("create table a (id int);")},
|
||||||
|
"migrations/0002-second.sql": {Data: []byte("alter table a add column b text;")},
|
||||||
|
}, "migrations")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
done, err := s.Migrate(t.Context(), migrations)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(done) != 2 || done[0].Number != 1 || done[1].Number != 2 {
|
||||||
|
t.Fatalf("applied %+v", done)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadyRefusesADatabaseThatWillNotAnswer(t *testing.T) {
|
||||||
|
// Ready is what stands between the bootstrap and a control plane that starts against a
|
||||||
|
// database still coming up. It has to give up rather than block for ever, and it has to fail
|
||||||
|
// when nothing is there.
|
||||||
|
admin(t)
|
||||||
|
t.Setenv(Variable("testing"), "postgres://nobody@127.0.0.1:1/nothing")
|
||||||
|
s, err := Open(t.Context(), "testing")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
if err := s.Ready(t.Context(), 1*time.Second); err == nil {
|
||||||
|
t.Fatal("Ready returned success against a port with nothing on it")
|
||||||
|
}
|
||||||
|
if elapsed := time.Since(start); elapsed > 10*time.Second {
|
||||||
|
t.Errorf("Ready took %s to give up on a 1s budget", elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"path"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// novox/hq ADR 0013: every schema change is a numbered migration. This applies them, and most of
|
||||||
|
// what follows is refusals rather than application — the applying is four lines.
|
||||||
|
//
|
||||||
|
// The refusals are the point. A migration runner that only moves forward is easy; what makes a
|
||||||
|
// schema trustworthy months later is that it will not run when the record and the files disagree,
|
||||||
|
// because at that moment nobody knows what the database actually contains and the honest thing is
|
||||||
|
// to stop.
|
||||||
|
|
||||||
|
// migrationFile is `0001-name.sql` — the number, then what it does.
|
||||||
|
var migrationFile = regexp.MustCompile(`^(\d{4})-([a-z0-9-]+)\.sql$`)
|
||||||
|
|
||||||
|
// Migration is one numbered change, read from the files the binary carries.
|
||||||
|
type Migration struct {
|
||||||
|
Number int
|
||||||
|
Name string
|
||||||
|
SQL string
|
||||||
|
Checksum string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applied is one row of the record — what ran, and what it looked like when it did.
|
||||||
|
type Applied struct {
|
||||||
|
Number int
|
||||||
|
Name string
|
||||||
|
Checksum string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ledger is where the record lives, inside the context's own database.
|
||||||
|
//
|
||||||
|
// In the same database as the schema it describes, deliberately: a record kept anywhere else can
|
||||||
|
// be restored separately from the thing it describes, and then it is not a record of anything.
|
||||||
|
const ledger = `
|
||||||
|
create table if not exists migration (
|
||||||
|
number integer primary key,
|
||||||
|
name text not null,
|
||||||
|
checksum text not null,
|
||||||
|
applied timestamptz not null default now()
|
||||||
|
)`
|
||||||
|
|
||||||
|
// lockKey is the advisory lock every migration run takes.
|
||||||
|
//
|
||||||
|
// One control plane runs (novox/hq ADR 0006), so a race needs two copies of it — which is exactly
|
||||||
|
// what a restart during a slow migration produces, and is not rare enough to leave to chance. An
|
||||||
|
// arbitrary constant; it only has to be the same in every copy of this binary.
|
||||||
|
const lockKey int64 = 6_845_121_074
|
||||||
|
|
||||||
|
// LoadMigrations reads the migrations a context carries.
|
||||||
|
//
|
||||||
|
// From an embedded filesystem rather than from disk. The binary and its schema travel as one
|
||||||
|
// thing, so a container image cannot be running one version of the code against a directory of
|
||||||
|
// migrations from another.
|
||||||
|
func LoadMigrations(files fs.FS, dir string) ([]Migration, error) {
|
||||||
|
entries, err := fs.ReadDir(files, dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot read the migrations in %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var migrations []Migration
|
||||||
|
seen := map[int]string{}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
match := migrationFile.FindStringSubmatch(entry.Name())
|
||||||
|
if match == nil {
|
||||||
|
// Not skipped quietly. A file that was meant to be a migration and is misnamed would
|
||||||
|
// otherwise be ignored in silence, and the schema would simply lack it — the failure
|
||||||
|
// arriving later as a missing column, a long way from its cause.
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"%s is not a migration filename; it must be NNNN-what-it-does.sql, and a file "+
|
||||||
|
"in this directory that is not a migration cannot be told apart from one "+
|
||||||
|
"that was misnamed", path.Join(dir, entry.Name()))
|
||||||
|
}
|
||||||
|
number, _ := strconv.Atoi(match[1])
|
||||||
|
if other, clash := seen[number]; clash {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"two migrations are numbered %04d: %s and %s. Order is the whole guarantee, and "+
|
||||||
|
"two files with one number have none", number, other, entry.Name())
|
||||||
|
}
|
||||||
|
seen[number] = entry.Name()
|
||||||
|
|
||||||
|
body, err := fs.ReadFile(files, path.Join(dir, entry.Name()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(string(body)) == "" {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"%s is empty. An empty migration records that something happened and changes "+
|
||||||
|
"nothing, which is the one state that cannot be told from a mistake",
|
||||||
|
entry.Name())
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(body)
|
||||||
|
migrations = append(migrations, Migration{
|
||||||
|
Number: number,
|
||||||
|
Name: match[2],
|
||||||
|
SQL: string(body),
|
||||||
|
Checksum: hex.EncodeToString(sum[:]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(migrations, func(i, j int) bool { return migrations[i].Number < migrations[j].Number })
|
||||||
|
return migrations, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppliedMigrations reads the record of what has run.
|
||||||
|
func (s *Store) AppliedMigrations(ctx context.Context) ([]Applied, error) {
|
||||||
|
if _, err := s.pool.Exec(ctx, ledger); err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot create the migration record in %s: %w", s.context, err)
|
||||||
|
}
|
||||||
|
rows, err := s.pool.Query(ctx,
|
||||||
|
`select number, name, checksum from migration order by number`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var applied []Applied
|
||||||
|
for rows.Next() {
|
||||||
|
var a Applied
|
||||||
|
if err := rows.Scan(&a.Number, &a.Name, &a.Checksum); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
applied = append(applied, a)
|
||||||
|
}
|
||||||
|
return applied, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending is what has not run, having first established that what has run still matches.
|
||||||
|
//
|
||||||
|
// Two refusals, and both describe a database whose contents are no longer known:
|
||||||
|
//
|
||||||
|
// - a migration that ran and whose file has since changed. The database holds the old version
|
||||||
|
// and the repository holds the new one, and nothing anywhere holds the difference.
|
||||||
|
// - a migration numbered below one that already ran, which has not run itself. Almost always
|
||||||
|
// two branches picking the same next number, merged in the order they happened to land. The
|
||||||
|
// file is fine; applying it now would run the schema in an order nobody tested.
|
||||||
|
//
|
||||||
|
// Both are stops rather than warnings. There is no correct guess about a schema.
|
||||||
|
func Pending(migrations []Migration, applied []Applied) ([]Migration, error) {
|
||||||
|
record := map[int]Applied{}
|
||||||
|
highest := 0
|
||||||
|
for _, a := range applied {
|
||||||
|
record[a.Number] = a
|
||||||
|
if a.Number > highest {
|
||||||
|
highest = a.Number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var pending []Migration
|
||||||
|
var problems []string
|
||||||
|
for _, m := range migrations {
|
||||||
|
ran, wasApplied := record[m.Number]
|
||||||
|
if wasApplied {
|
||||||
|
if ran.Checksum != m.Checksum {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"migration %04d-%s ran against this database, and the file has changed since. "+
|
||||||
|
"The database holds what the old file said and nothing holds the "+
|
||||||
|
"difference. A migration that has run is finished — write a new one",
|
||||||
|
m.Number, m.Name))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m.Number < highest {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"migration %04d-%s has never run, but %04d has. This is usually two branches "+
|
||||||
|
"taking the same next number. Applying it now would run this schema in an "+
|
||||||
|
"order that was never tested — renumber it above %04d",
|
||||||
|
m.Number, m.Name, highest, highest))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pending = append(pending, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(problems) > 0 {
|
||||||
|
return nil, errors.New("this database and these migrations disagree:\n - " +
|
||||||
|
strings.Join(problems, "\n - "))
|
||||||
|
}
|
||||||
|
return pending, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate applies everything outstanding, in order, and reports what it did.
|
||||||
|
//
|
||||||
|
// Each migration runs in its own transaction together with the row recording it, so the two
|
||||||
|
// cannot come apart: PostgreSQL runs DDL transactionally, so a migration that fails half way
|
||||||
|
// leaves neither the change nor a claim that the change was made.
|
||||||
|
func (s *Store) Migrate(ctx context.Context, migrations []Migration) ([]Migration, error) {
|
||||||
|
conn, err := s.pool.Acquire(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer conn.Release()
|
||||||
|
|
||||||
|
// Held on one connection for the whole run, and released when it goes back to the pool. A
|
||||||
|
// second copy of this process waits here rather than interleaving with the first.
|
||||||
|
if _, err := conn.Exec(ctx, `select pg_advisory_lock($1)`, lockKey); err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot take the migration lock on %s: %w", s.context, err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_, _ = conn.Exec(context.WithoutCancel(ctx), `select pg_advisory_unlock($1)`, lockKey)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Read *after* the lock. Reading before it would mean deciding what is outstanding from a
|
||||||
|
// picture taken before another process was known to be finished with it.
|
||||||
|
applied, err := s.AppliedMigrations(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pending, err := Pending(migrations, applied)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var done []Migration
|
||||||
|
for _, m := range pending {
|
||||||
|
err := func() error {
|
||||||
|
tx, err := conn.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }()
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, m.SQL); err != nil {
|
||||||
|
return fmt.Errorf("migration %04d-%s failed, and nothing it did was kept: %w",
|
||||||
|
m.Number, m.Name, err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx,
|
||||||
|
`insert into migration (number, name, checksum) values ($1, $2, $3)`,
|
||||||
|
m.Number, m.Name, m.Checksum); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}()
|
||||||
|
if err != nil {
|
||||||
|
// What succeeded stays applied and stays recorded, which is why they are reported
|
||||||
|
// alongside the failure: a person deciding what to do next needs to know the schema
|
||||||
|
// moved, not only that the run did not finish.
|
||||||
|
return done, err
|
||||||
|
}
|
||||||
|
done = append(done, m)
|
||||||
|
}
|
||||||
|
return done, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
// Package store is how a context reaches the database it exclusively owns.
|
||||||
|
//
|
||||||
|
// novox/hq ADR 0008: a context is granted only what it exclusively owns — no shared writes, no
|
||||||
|
// read-only role on another context's store. That is a rule about credentials, so this package
|
||||||
|
// makes it a rule about credentials rather than a rule about intentions.
|
||||||
|
//
|
||||||
|
// There is no mesh-wide connection string and no way to ask for one. A store is opened by naming
|
||||||
|
// a context, and the settings for that context come from an environment variable named after it.
|
||||||
|
// A control plane process that has been granted `inventory` holds MESH_STORE_INVENTORY and
|
||||||
|
// nothing else, so reaching another context's store is not a matter of restraint — the process
|
||||||
|
// has no address for it and no credential to present.
|
||||||
|
//
|
||||||
|
// Which is also how the rule is *checked*: what a context can reach is visible in the
|
||||||
|
// declaration that runs it, as the list of variables it was given.
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// contextName is what a context may be called.
|
||||||
|
//
|
||||||
|
// Constrained because the name becomes part of an environment variable and part of a database
|
||||||
|
// name, and a name that is valid in one and not the other is a fault discovered at bootstrap on
|
||||||
|
// a machine with no mesh on it.
|
||||||
|
var contextName = regexp.MustCompile(`^[a-z][a-z0-9]*$`)
|
||||||
|
|
||||||
|
// Store is one context's database.
|
||||||
|
type Store struct {
|
||||||
|
context string
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Variable is the environment variable holding a context's connection settings.
|
||||||
|
//
|
||||||
|
// Exported because the bootstrap has to set it and the declaration has to name it, and both
|
||||||
|
// should read it from here rather than spell it out again.
|
||||||
|
func Variable(context string) string {
|
||||||
|
return "MESH_STORE_" + strings.ToUpper(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Database is what a context's database is called.
|
||||||
|
//
|
||||||
|
// Named after the context, so that a person looking at a PostgreSQL server can see which
|
||||||
|
// contexts exist without a map. There is deliberately no database named for the mesh as a whole:
|
||||||
|
// novox/hq ADR 0006 records that *the mesh database* names a thing that will not exist.
|
||||||
|
func Database(context string) string { return context }
|
||||||
|
|
||||||
|
// Open connects to the database a context owns.
|
||||||
|
//
|
||||||
|
// The settings are read from the environment rather than passed in, which is not indirection for
|
||||||
|
// its own sake: it means no caller anywhere can hand a context a connection to something else.
|
||||||
|
func Open(ctx context.Context, name string) (*Store, error) {
|
||||||
|
if !contextName.MatchString(name) {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"%q is not a usable context name: it becomes an environment variable and a database "+
|
||||||
|
"name, so it must be lower-case letters and digits, starting with a letter", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
dsn := os.Getenv(Variable(name))
|
||||||
|
if strings.TrimSpace(dsn) == "" {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"this process has no %s, so it was not granted the %s store. A context reaches only "+
|
||||||
|
"the store it exclusively owns (novox/hq ADR 0008), so this is either the wrong "+
|
||||||
|
"context or a missing grant — it is never something to work around by reusing "+
|
||||||
|
"another context's connection", Variable(name), name)
|
||||||
|
}
|
||||||
|
|
||||||
|
config, err := pgxpool.ParseConfig(dsn)
|
||||||
|
if err != nil {
|
||||||
|
// Deliberately not wrapping the driver's error verbatim into a message that gets logged:
|
||||||
|
// a malformed DSN often *is* the password, and the value is the one thing here that must
|
||||||
|
// not be quoted back.
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"the connection settings in %s could not be read; the value is not quoted here "+
|
||||||
|
"because it carries a password", Variable(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
pool, err := pgxpool.NewWithConfig(ctx, config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot open the %s store: %w", name, err)
|
||||||
|
}
|
||||||
|
return &Store{context: name, pool: pool}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context is which context this store belongs to.
|
||||||
|
func (s *Store) Context() string { return s.context }
|
||||||
|
|
||||||
|
// Pool is the connection pool, for the context that owns it.
|
||||||
|
func (s *Store) Pool() *pgxpool.Pool { return s.pool }
|
||||||
|
|
||||||
|
// Close releases the connections.
|
||||||
|
func (s *Store) Close() {
|
||||||
|
if s.pool != nil {
|
||||||
|
s.pool.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ready waits until the database answers, or gives up.
|
||||||
|
//
|
||||||
|
// A read-back rather than a connect: pgxpool connects lazily, so a Store that was opened without
|
||||||
|
// error proves only that a string parsed. The bootstrap raises PostgreSQL and the control plane
|
||||||
|
// moments later, and "the container is running" is not "the database will answer" — that
|
||||||
|
// distinction has already cost a debugging session on this project once.
|
||||||
|
func (s *Store) Ready(ctx context.Context, within time.Duration) error {
|
||||||
|
deadline := time.Now().Add(within)
|
||||||
|
var last error
|
||||||
|
for {
|
||||||
|
err := s.pool.Ping(ctx)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
last = err
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return errors.Join(ctx.Err(), last)
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"the %s store did not answer within %s: %w", s.context, within, last)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return errors.Join(ctx.Err(), last)
|
||||||
|
case <-time.After(250 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user