diff --git a/.woodpecker/pre-commit.yaml b/.woodpecker/pre-commit.yaml index d57b508..9d77409 100644 --- a/.woodpecker/pre-commit.yaml +++ b/.woodpecker/pre-commit.yaml @@ -6,6 +6,9 @@ steps: image: git.unkin.net/unkin/almalinux9-gobuilder:20260606 commands: - uvx pre-commit run --all-files + environment: + # golib lives on Gitea; skip the public proxy/sum db. + GOPRIVATE: git.unkin.net backend_options: kubernetes: serviceAccountName: default diff --git a/.woodpecker/release.yaml b/.woodpecker/release.yaml index 8a57ca7..489aff1 100644 --- a/.woodpecker/release.yaml +++ b/.woodpecker/release.yaml @@ -8,6 +8,9 @@ steps: image: git.unkin.net/unkin/almalinux9-gobuilder:20260606 commands: - make build-cli VERSION=${CI_COMMIT_TAG} + environment: + # golib lives on Gitea; skip the public proxy/sum db. + GOPRIVATE: git.unkin.net backend_options: kubernetes: serviceAccountName: default diff --git a/.woodpecker/test.yaml b/.woodpecker/test.yaml index ed0afd8..52945bd 100644 --- a/.woodpecker/test.yaml +++ b/.woodpecker/test.yaml @@ -6,6 +6,9 @@ steps: image: golang:1.25 commands: - make lint + environment: + # golib lives on Gitea; skip the public proxy/sum db. + GOPRIVATE: git.unkin.net backend_options: kubernetes: serviceAccountName: default @@ -23,6 +26,9 @@ steps: # Container-backed DB tests self-skip when Docker is unavailable in CI; # they run in the docker-e2e path / locally. - make test-short + environment: + # golib lives on Gitea; skip the public proxy/sum db. + GOPRIVATE: git.unkin.net backend_options: kubernetes: serviceAccountName: default diff --git a/Dockerfile b/Dockerfile index e905eda..a6ae6d9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,10 @@ RUN apk add --no-cache git WORKDIR /build +# golib is fetched straight from Gitea (git is installed above for it); the +# public proxy and sum db have no view of git.unkin.net modules. +ENV GOPRIVATE=git.unkin.net + COPY go.mod go.sum ./ RUN go mod download diff --git a/Makefile b/Makefile index c9b65d4..2c44fd7 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,11 @@ DIST := dist OS ?= $(shell go env GOOS) ARCH ?= $(shell go env GOARCH) +# git.unkin.net modules (golib) are fetched straight from Gitea, never via the +# public proxy or sum db, which have no view of them. Exported here rather than +# written with `go env -w`, so a fresh checkout needs no machine-local setup. +export GOPRIVATE := git.unkin.net + GO_VERSION_REQUIRED := 1.23 GO_VERSION_ACTUAL := $(shell go version | sed 's/go version go\([0-9]*\.[0-9]*\).*/\1/') @@ -28,7 +33,7 @@ test: check-go # Fast suite: skips the database package's container-backed tests. test-short: check-go - go test -race -count=1 ./internal/config/... ./internal/enc/... ./internal/distro/... ./internal/server/... ./internal/cli/... ./pkg/... + go test -race -count=1 ./internal/config/... ./internal/enc/... ./internal/distro/... ./internal/server/... ./internal/cli/... ./migrations/... ./pkg/... lint: check-go go vet ./... diff --git a/README.md b/README.md index 9e92113..8ca7088 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,27 @@ The Terraform provider lives in a sibling repo: Foreign keys guarantee a node can only reference a role/status that exists, and a role/status in use cannot be deleted. +## Schema migrations + +The SQL lives in [`migrations/`](migrations), is embedded in the `encapi` +binary, and is applied at startup before the server listens — there is no +mirrored copy in the deployment to drift out of sync. + +The runner is [`golib/pg`](https://git.unkin.net/unkin/golib)'s +`pg.NewMigrated`: encapi owns the SQL, the shared library owns the mechanics. +Each start takes `pg_advisory_lock` on a key derived from the lock name +`encapi-migrations`, creates `schema_migrations` (`version`, `applied_at`) if +missing, and applies every embedded file whose filename is not yet recorded — in +lexical (version) order, each file's SQL and its tracking row in one transaction +— then unlocks. Replicas starting together queue on the lock and then find +nothing to do. + +A file the live database already satisfies but `schema_migrations` does not +record is re-run, which is how the pre-migration schema is adopted: migrations +are `IF NOT EXISTS`-guarded, so the re-run is a no-op that only lands the +tracking row. Add schema changes as a new numbered file; never edit an applied +one. + ## ENC output `encapi` renders two shapes from the same data: @@ -90,6 +111,22 @@ make test # full suite (Postgres via testcontainers) make test-short # skip container-backed DB tests ``` +### `GOPRIVATE` + +encapi depends on `git.unkin.net/unkin/golib`, which is served by Gitea and is +unknown to `proxy.golang.org` / `sum.golang.org`. Module resolution therefore +needs: + +``` +export GOPRIVATE=git.unkin.net +``` + +The `Makefile` exports it for every target, and the `Dockerfile` and the +woodpecker Go steps set it themselves, so `make build|test|lint` and CI work on +a clean checkout. Only bare `go` commands run outside `make` need it in your +shell — set it there (or in your shell profile) rather than with `go env -w`, +which is machine state this repo cannot carry. + ## Releases - **`encapi` server image** — tagging `vX.Y.Z` builds and pushes diff --git a/cmd/encapi/main.go b/cmd/encapi/main.go index a927eac..882ed03 100644 --- a/cmd/encapi/main.go +++ b/cmd/encapi/main.go @@ -27,7 +27,10 @@ func main() { os.Exit(1) } - db, err := database.New(cfg.DatabaseDSN()) + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + db, err := database.New(ctx, cfg.DatabaseDSN(), slog.Default()) if err != nil { slog.Error("connect database", "err", err) os.Exit(1) @@ -40,9 +43,6 @@ func main() { srv := server.New(db, distro.New(cfg.DistroAPIURL), cfg.WriteToken) - ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer stop() - if err := srv.ListenAndServe(ctx, cfg.ListenAddr); err != nil { slog.Error("server", "err", err) os.Exit(1) diff --git a/go.mod b/go.mod index a483d67..42b96be 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,11 @@ module git.unkin.net/unkin/encapi go 1.25.9 require ( + git.unkin.net/unkin/golib v0.1.0 github.com/go-chi/chi/v5 v5.3.0 github.com/jackc/pgx/v5 v5.10.0 - github.com/testcontainers/testcontainers-go v0.42.0 - github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 + github.com/testcontainers/testcontainers-go v0.44.0 + github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -23,26 +24,26 @@ require ( github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/distribution/reference v0.6.0 // indirect - github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-connections v0.7.0 // indirect github.com/docker/go-units v0.5.0 // indirect - github.com/ebitengine/purego v0.10.0 // indirect - github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/ebitengine/purego v0.10.1 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-ole/go-ole v1.2.6 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/klauspost/compress v1.18.5 // indirect - github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/klauspost/compress v1.18.6 // indirect + github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.2.0 // indirect - github.com/moby/moby/api v1.54.1 // indirect - github.com/moby/moby/client v0.4.0 // indirect + github.com/moby/moby/api v1.55.0 // indirect + github.com/moby/moby/client v0.5.0 // indirect github.com/moby/patternmatcher v0.6.1 // indirect - github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/sequential v0.7.0 // indirect github.com/moby/sys/user v0.4.0 // indirect github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect @@ -50,19 +51,19 @@ require ( github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect - github.com/shirou/gopsutil/v4 v4.26.3 // indirect + github.com/shirou/gopsutil/v4 v4.26.6 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/stretchr/testify v1.11.1 // indirect - github.com/tklauser/go-sysconf v0.3.16 // indirect - github.com/tklauser/numcpus v0.11.0 // indirect + github.com/tklauser/go-sysconf v0.4.0 // indirect + github.com/tklauser/numcpus v0.12.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect - go.opentelemetry.io/otel v1.41.0 // indirect - go.opentelemetry.io/otel/metric v1.41.0 // indirect - go.opentelemetry.io/otel/trace v1.41.0 // indirect - golang.org/x/crypto v0.48.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.34.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect ) diff --git a/go.sum b/go.sum index 24180da..9654fcd 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +git.unkin.net/unkin/golib v0.1.0 h1:OjKT5TO7PuXiYQ/1A+I4S2VZ/IsAxXY1reGWwasDMqE= +git.unkin.net/unkin/golib v0.1.0/go.mod h1:1e3PpMLEfa03Re/6tlk+I2XPWzBQMSpw2MB+Aw2lkX4= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= @@ -27,14 +29,14 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= -github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= +github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= -github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= -github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= +github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -42,9 +44,9 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -57,16 +59,16 @@ 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/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= -github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= -github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= @@ -75,14 +77,14 @@ github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3N github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= -github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4= -github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= -github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw= -github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g= +github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= +github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc= +github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s= github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= -github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= -github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8= +github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o= github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= @@ -99,8 +101,8 @@ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= -github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs= +github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -110,44 +112,44 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY= -github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= -github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 h1:GCbb1ndrF7OTDiIvxXyItaDab4qkzTFJ48LKFdM7EIo= -github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0/go.mod h1:IRPBaI8jXdrNfD0e4Zm7Fbcgaz5shKxOQv4axiL09xs= -github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= -github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= -github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= -github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI= +github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE= +github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 h1:8fdv/9y3JMxjQ+ULAcOG8RtgeNu5t9XF9LolSXDuTwM= +github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0/go.mod h1:CFr2LncGYokw+OKjXcr8ARCKG1SaC2UEnGxFBovE86g= +github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= +github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= +github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= +github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= -go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= -go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= -go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= -go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= -go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= -go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= -go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= -go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= -go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= -go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= -golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/config/config.go b/internal/config/config.go index adf4b4a..bb53117 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,6 +5,8 @@ import ( "fmt" "os" "strconv" + + "git.unkin.net/unkin/golib/pg" ) // Config is the fully-resolved server configuration. @@ -28,12 +30,11 @@ type Config struct { DistroAPIURL string } -// DatabaseDSN renders a libpq/pgx connection string. +// DatabaseDSN renders a libpq/pgx connection string. The fields come from this +// package rather than golib's pg.DSNFromEnv because encapi defaults DBUSER and +// DBNAME to "encapi", where DSNFromEnv treats both as required. func (c *Config) DatabaseDSN() string { - return fmt.Sprintf( - "postgres://%s:%s@%s:%d/%s?sslmode=%s", - c.DBUser, c.DBPass, c.DBHost, c.DBPort, c.DBName, c.DBSSL, - ) + return pg.DSN(c.DBHost, c.DBPort, c.DBUser, c.DBPass, c.DBName, c.DBSSL) } // Load reads configuration from the environment, applying defaults. diff --git a/internal/database/database_test.go b/internal/database/database_test.go index a1d3f56..fe694f5 100644 --- a/internal/database/database_test.go +++ b/internal/database/database_test.go @@ -10,7 +10,11 @@ import ( "git.unkin.net/unkin/encapi/pkg/models" ) -var testDB *DB +// testDB is the shared throwaway database; testDSN reaches the same server. +var ( + testDB *DB + testDSN string +) func TestMain(m *testing.M) { ctx := context.Background() @@ -19,12 +23,13 @@ func TestMain(m *testing.M) { // Docker unavailable: run so tests self-skip via requireDB. os.Exit(m.Run()) } - db, err := New(dsn) + db, err := New(ctx, dsn, nil) if err != nil { terminate() panic(err) } testDB = db + testDSN = dsn code := m.Run() db.Close() diff --git a/internal/database/migrate_test.go b/internal/database/migrate_test.go new file mode 100644 index 0000000..0cbed17 --- /dev/null +++ b/internal/database/migrate_test.go @@ -0,0 +1,33 @@ +package database + +import ( + "context" + "testing" +) + +// encapi's schema predates schema_migrations, so the first start after this +// change re-applies 0001_init against a database that already has the tables. +// The file is IF NOT EXISTS-guarded for exactly that: the run must succeed and +// leave only the tracking row behind. +func TestMigrateAdoptsAnExistingSchema(t *testing.T) { + requireDB(t) + ctx := context.Background() + + if _, err := testDB.Pool.Exec(ctx, `DROP TABLE schema_migrations`); err != nil { + t.Fatalf("drop schema_migrations: %v", err) + } + + db, err := New(ctx, testDSN, nil) + if err != nil { + t.Fatalf("migrate over an existing schema: %v", err) + } + defer db.Close() + + var n int + if err := db.Pool.QueryRow(ctx, `SELECT count(*) FROM schema_migrations`).Scan(&n); err != nil { + t.Fatalf("count schema_migrations: %v", err) + } + if n != 1 { + t.Fatalf("schema_migrations rows = %d, want 1", n) + } +} diff --git a/internal/database/postgres.go b/internal/database/postgres.go index d05b425..df00f99 100644 --- a/internal/database/postgres.go +++ b/internal/database/postgres.go @@ -6,9 +6,13 @@ package database import ( "context" - "fmt" + "log/slog" "github.com/jackc/pgx/v5/pgxpool" + + "git.unkin.net/unkin/golib/pg" + + "git.unkin.net/unkin/encapi/migrations" ) // DB wraps a pgx connection pool. @@ -16,56 +20,19 @@ type DB struct { Pool *pgxpool.Pool } -// New connects to Postgres, verifies the connection, and runs migrations. -func New(dsn string) (*DB, error) { - pool, err := pgxpool.New(context.Background(), dsn) +// New connects to Postgres, verifies the connection, and brings the schema up +// to date from the embedded migrations before returning, so the server never +// serves against a half-migrated database. log may be nil. +func New(ctx context.Context, dsn string, log *slog.Logger) (*DB, error) { + pool, err := pg.NewMigrated(ctx, dsn, migrations.FS, pg.MigrateOptions{ + LockName: migrations.LockName, + Logger: log, + }) if err != nil { - return nil, fmt.Errorf("connect to postgres: %w", err) + return nil, err } - if err := pool.Ping(context.Background()); err != nil { - pool.Close() - return nil, fmt.Errorf("ping postgres: %w", err) - } - - db := &DB{Pool: pool} - if err := db.migrate(); err != nil { - pool.Close() - return nil, fmt.Errorf("run migrations: %w", err) - } - return db, nil + return &DB{Pool: pool}, nil } // Close releases the pool. func (db *DB) Close() { db.Pool.Close() } - -func (db *DB) migrate() error { - _, err := db.Pool.Exec(context.Background(), ` - CREATE TABLE IF NOT EXISTS statuses ( - name TEXT PRIMARY KEY, - description TEXT NOT NULL DEFAULT '', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - - CREATE TABLE IF NOT EXISTS roles ( - name TEXT PRIMARY KEY, - description TEXT NOT NULL DEFAULT '', - default_params JSONB NOT NULL DEFAULT '{}', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - - CREATE TABLE IF NOT EXISTS nodes ( - certname TEXT PRIMARY KEY, - role TEXT NOT NULL REFERENCES roles(name) ON UPDATE CASCADE, - environment TEXT NOT NULL REFERENCES statuses(name) ON UPDATE CASCADE, - params JSONB NOT NULL DEFAULT '{}', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - `) - if err != nil { - return err - } - return nil -} diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql new file mode 100644 index 0000000..e4bf42f --- /dev/null +++ b/migrations/0001_init.sql @@ -0,0 +1,23 @@ +CREATE TABLE IF NOT EXISTS statuses ( + name TEXT PRIMARY KEY, + description TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS roles ( + name TEXT PRIMARY KEY, + description TEXT NOT NULL DEFAULT '', + default_params JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS nodes ( + certname TEXT PRIMARY KEY, + role TEXT NOT NULL REFERENCES roles(name) ON UPDATE CASCADE, + environment TEXT NOT NULL REFERENCES statuses(name) ON UPDATE CASCADE, + params JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/migrations/embed.go b/migrations/embed.go new file mode 100644 index 0000000..1637eea --- /dev/null +++ b/migrations/embed.go @@ -0,0 +1,16 @@ +// Package migrations embeds encapi's SQL schema so the server carries it in +// the binary and applies it at startup, with no separately deployed copy to +// drift out of sync. +package migrations + +import "embed" + +// FS holds every numbered migration; lexical filename order is version order. +// +//go:embed *.sql +var FS embed.FS + +// LockName names the cluster-wide advisory lock replicas contend for while +// migrating. golib derives the key as FNV-1a/64 of this name, so every replica +// migrating this database must agree on the string. +const LockName = "encapi-migrations" diff --git a/migrations/migrations_test.go b/migrations/migrations_test.go new file mode 100644 index 0000000..2aad52d --- /dev/null +++ b/migrations/migrations_test.go @@ -0,0 +1,99 @@ +package migrations + +import ( + "io/fs" + "os" + "strings" + "testing" + + "git.unkin.net/unkin/golib/pg" +) + +// readDir returns the .sql files on disk, keyed by name. +func readDir(t *testing.T) map[string]string { + t.Helper() + entries, err := os.ReadDir(".") + if err != nil { + t.Fatalf("read migrations dir: %v", err) + } + out := map[string]string{} + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") { + continue + } + b, err := os.ReadFile(e.Name()) + if err != nil { + t.Fatalf("read %s: %v", e.Name(), err) + } + out[e.Name()] = string(b) + } + if len(out) == 0 { + t.Fatal("no .sql files in migrations/") + } + return out +} + +// The embedded set is the schema the binary ships, so it must match the +// directory exactly — a file on disk but outside the embed pattern would never +// run in production while still passing a local review. +func TestEmbeddedMatchesDirectory(t *testing.T) { + onDisk := readDir(t) + + entries, err := fs.ReadDir(FS, ".") + if err != nil { + t.Fatalf("read embedded migrations: %v", err) + } + embedded := map[string]string{} + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") { + continue + } + b, err := FS.ReadFile(e.Name()) + if err != nil { + t.Fatalf("read embedded %s: %v", e.Name(), err) + } + embedded[e.Name()] = string(b) + } + + if len(embedded) != len(onDisk) { + t.Fatalf("embedded %d files, migrations/ has %d", len(embedded), len(onDisk)) + } + for name, want := range onDisk { + got, ok := embedded[name] + if !ok { + t.Errorf("migrations/%s is not embedded", name) + continue + } + if got != want { + t.Errorf("%s: embedded body differs from migrations/%s", name, name) + } + } +} + +// Migrations are re-run against a database whose schema predates +// schema_migrations, so every CREATE must tolerate the objects already +// existing. +func TestMigrationsAreIdempotent(t *testing.T) { + for name, body := range readDir(t) { + for _, line := range strings.Split(body, "\n") { + upper := strings.ToUpper(strings.TrimSpace(line)) + if !strings.HasPrefix(upper, "CREATE ") { + continue + } + if !strings.Contains(upper, "IF NOT EXISTS") && !strings.Contains(upper, "CREATE OR REPLACE") { + t.Errorf("%s: %q is not guarded with IF NOT EXISTS", name, strings.TrimSpace(line)) + } + } + } +} + +// golib derives the advisory lock key from LockName. Replicas only serialize +// against each other while they agree on the key, so renaming the lock during a +// rolling deploy would let two versions migrate at once. +func TestLockKeyIsStable(t *testing.T) { + // FNV-1a/64 of "encapi-migrations", reinterpreted as int64. + const deployedKey int64 = -7124093699699113719 + if got := pg.LockKey(LockName); got != deployedKey { + t.Fatalf("LockKey(%q) = %d, want %d", LockName, got, deployedKey) + } +}