diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 044db3e..21fcc8d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -134,6 +134,38 @@ jobs: - name: Test against the long-lived database run: make test-db + # AWS-boundary tests against Ministack's RDS/Aurora control plane. + # Ministack is MIT-licensed and tokenless, so this tier runs on every + # code PR — including forks. It is a signal job, deliberately NOT in + # all-green's required set: no production code makes AWS API calls yet, + # so an emulator or infrastructure failure here should not block a + # merge. Promote it to the required set when the first AWS-facing + # feature (Secrets Manager DSN resolution) lands. The Ministack + # container mounts the runner's Docker socket to start the sibling + # PostgreSQL container backing the provisioned cluster; that is safe + # only on an ephemeral GitHub-hosted runner — the guard step enforces + # it instead of trusting the runs-on label to never change. + aws-boundary: + needs: changes + if: needs.changes.outputs.code == 'true' + runs-on: ubuntu-latest + steps: + - name: Refuse non-GitHub-hosted runners + env: + RUNNER_ENV: ${{ runner.environment }} + run: | + if [ "$RUNNER_ENV" != "github-hosted" ]; then + echo "aws-boundary mounts the Docker socket; it must only run on ephemeral GitHub-hosted runners (got: $RUNNER_ENV)" + exit 1 + fi + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 + with: + go-version-file: go.mod + - run: make test-aws-boundary + # Single required status for branch protection ("all-green" is the # context to require). Succeeds when nothing failed — including # docs-only PRs where the heavy jobs were skipped. diff --git a/AGENTS.md b/AGENTS.md index 7aab437..7be9842 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,7 @@ make test # full suite; integration tests need Docker make test-unit # SKIP_INTEGRATION=1, no Docker make test-db # suite against the compose DB (make db-up first); PG_DSN make test-supported-postgres # full suite on every major 14 -> 18 +make test-aws-boundary # AWS-boundary tier (Ministack RDS/Aurora); needs Docker make lint # golangci-lint ``` diff --git a/Makefile b/Makefile index f8a23fc..39063e9 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ PG_DATABASE ?= pgsprite # Localhost-only test credentials, parameterized above — not a real secret. PG_DSN_LOCAL = postgres://$(PG_USER):$(PG_PASSWORD)@localhost:$(PG_PORT)/$(PG_DATABASE)?sslmode=disable# sadscan:disable np.postgres.1 -.PHONY: build test test-unit test-db test-supported-postgres lint setup db-up db-down clean +.PHONY: build test test-unit test-db test-supported-postgres test-aws-boundary lint setup db-up db-down clean build: $(GO) build -o bin/pg-sprite ./cmd/pg-sprite @@ -37,6 +37,12 @@ test-supported-postgres: PG_VERSION=$$v $(GO) test -race -count=1 ./... || exit 1; \ done +# AWS-boundary tests against Ministack's RDS/Aurora control plane +# (docs/testing.md). Needs Docker only — Ministack is MIT-licensed and +# tokenless. PG_VERSION selects the major of the provisioned database. +test-aws-boundary: + $(GO) test -tags ministack -race -count=1 -run 'AuroraControlPlane' -v ./internal/testutil/ + lint: golangci-lint run diff --git a/SAFETY.md b/SAFETY.md index f957b68..8cb3556 100644 --- a/SAFETY.md +++ b/SAFETY.md @@ -62,6 +62,10 @@ The short version — the full rules live in [docs/tcb-model.md](docs/tcb-model. `pgx/v5`, stdlib. The future decode path will add `pglogrepl`. Adding one requires a recorded decision (see the rubric in [docs/tcb-model.md](docs/tcb-model.md) — copy small things, take pinned dependencies only for load-bearing expertise). + Recorded decision: the AWS SDK (`aws-sdk-go-v2`) is a test-harness-only dependency, confined + behind the `ministack` build tag in `internal/testutil` — it never appears in the core, in + `cmd/pg-sprite`, or in any ordinary build; a plain `go build ./...` / `go test ./...` never + compiles it. pg-sprite **never imports `block/spirit` as a module**: we port ideas with citations, not code. - **Priorities when trade-offs are hard:** Correctness → Readability → Ease of use → diff --git a/docs/testing.md b/docs/testing.md index 38f5465..7a7fa96 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -137,6 +137,7 @@ capability no peer suite has. | `make test` | Full suite; integration tests start disposable PostgreSQL containers (testcontainers). `PG_VERSION` selects the major (default 16). | | `make test-supported-postgres` | Full suite against every supported major, 14 → 18 — the local mirror of the CI matrix. | | `make db-up` / `make test-db` / `make db-down` | Long-lived compose database on localhost; the suite connects to it via `PG_DSN` instead of starting per-test containers. Fastest loop for repeated integration runs, and the path CI's version matrix uses — per-test containers oversubscribe a small CI runner and get killed mid-test. | +| `make test-aws-boundary` | AWS-boundary tests against Ministack's RDS/Aurora control plane. Needs Docker only; see the tier table below. | The harness is [internal/testutil](../internal/testutil/postgres.go): `StartPostgres` returns a connection URL (container, or `PG_DSN` when set) @@ -148,16 +149,84 @@ generated CA for verify-full tests. The harness has its own tests proving the version selected by `PG_VERSION` is the version actually running, and that throwaway schemas are isolated. -## Version matrix vs real Aurora +## Aurora-shaped environments: three tiers, each proving what it can CI runs the matrix against **vanilla PostgreSQL 14 → 18 images** — the floor promised in [postgresql-version-support.md](postgresql-version-support.md) is enforced by CI, not just documented. Vanilla PostgreSQL is *not* Aurora: -storage internals, replication, and failover behavior differ, and some -Aurora-specific behavior (e.g. `rds.logical_replication`, failover slot -loss) cannot be exercised in public CI. Validation against real Aurora -engine versions is a separate, environment-specific gate that lives outside -this repository's CI. +storage internals, replication, and failover behavior differ. The suite is +explicit about which environment proves which claim, and never lets a +cheaper tier stand in for a claim it cannot prove: + +| Tier | Environment | Proves | Cannot prove | +| --- | --- | --- | --- | +| Data plane | Real PostgreSQL containers, majors 14 → 18 (the CI matrix) | All core SQL/DDL/catalog behavior | Aurora-only semantics | +| AWS boundary | [Ministack](https://github.com/ministackorg/ministack) (`ProvisionAuroraPostgres` in [internal/testutil](../internal/testutil/ministack.go)) | The RDS/Aurora **control plane**: cluster + instance provisioning through the real RDS API, endpoint discovery, connecting to the discovered endpoint through `pkg/dbconn` | Aurora data-plane behavior — the database behind the endpoint is real vanilla PostgreSQL in a sibling container, not Aurora. The production RDS TLS path (`pkg/dbconn`'s `IsRDSHost` detection and verify-full against the embedded Amazon CA bundle) — no emulator can present a chain that bundle trusts; that path is proven by `pkg/dbconn`'s TLS unit and integration tests instead | +| Real Aurora | Environment-specific gate outside public CI | Aurora-only semantics: `rds.logical_replication`, failover slot loss, storage-level replication, fast DDL | — | + +The AWS-boundary tier is **never** a substitute for the data-plane tier: +core logic keeps its no-mocked-DB rule and runs against real PostgreSQL. +Ministack earns its keep only at the seam where the engine talks to AWS — +today the provisioning/discovery flow, and as those features land, Secrets +Manager DSN resolution and reader/writer endpoint selection. To be honest +about what that means right now: **no production pg-sprite code makes an +AWS API call yet**, so today's test proves the harness itself — that the +real RDS API shapes provision a cluster, that discovery returns the +endpoint the test then connects to, and that the emulator serves the +requested PostgreSQL major. It pins the seam in place for the features +that will sit on it; it does not yet exercise shipped code the data-plane +tier misses. One platform caveat: the discovered endpoint is the sibling +database's container-internal address, which CI's Linux host routes to +directly; on macOS, where Docker runs in a VM, the test detects the +unreachable address and falls back to the sibling's host-published port — +so "connects to the discovered endpoint" is proven by CI, not by a macOS +laptop. + +Ministack is MIT-licensed and needs no auth token, so the tier runs +anywhere Docker runs — locally and on every CI PR, forks included. Both +tiers take the target major from the same `PG_VERSION` (`PGVersion()` in +`internal/testutil`), and the AWS-boundary test asserts the provisioned +server's major matches — an invariant, not a convention: the two tiers +cannot silently drift onto different majors, so version-specific behavior +has nowhere to hide. The Ministack container mounts the host Docker socket +to start the sibling database container; run it only on Docker hosts you +own (the CI job refuses to run on anything but an ephemeral GitHub-hosted +runner). `MINISTACK_IMAGE` overrides the pinned image for upgrades or +mirrors. + +### How much of the suite runs on Ministack + +Deliberately almost none: exactly one end-to-end test +([ministack_integration_test.go](../internal/testutil/ministack_integration_test.go)) +covering provision → instance `available` → endpoint discovery → +`dbconn` connect → PG-major assertion → DDL smoke. Everything else — all +parser, planner, executor, and connection behavior — runs on the +data-plane tier against real PostgreSQL. That split is policy, not +accident: Ministack exists only for the seam where the engine talks to +AWS APIs, and its share grows only when AWS-facing features land, never +by moving core-logic tests onto it. Planned growth, in dependency order: + +- reader/writer topology tests — writer-endpoint targeting with a reader + present, endpoint re-discovery after a global-cluster failover — once + the engine has endpoint-selection logic to test. Which endpoint a schema + change targets is a *safety* property, not a performance one: DDL against + a reader endpoint fails in confusing ways, and against the wrong cluster + member is worse — so when endpoint selection lands it must be visible in + the plan report, not just inside `dbconn`; +- Secrets Manager DSN resolution, when that feature lands. + +The harness and its test are behind the `ministack` build tag: a plain +`go test ./...` (and therefore `make test`) never compiles them, so the +default suite needs no Docker-socket mount and the AWS SDK stays out of +ordinary builds. `make test-aws-boundary` is the only way in. In CI the +tier runs as the `aws-boundary` job — a **signal, not a merge gate**: it +is deliberately outside `all-green`'s required set while no production +code makes AWS API calls, because an emulator or infrastructure failure +should not block a merge the tier can say nothing about. It gets promoted +to the required set when the first AWS-facing feature (Secrets Manager +DSN resolution) lands — the day a failure means something an author can +fix. It is also intentionally **not** part of the pre-push hook, which +stays unit-only so pushes remain fast. ## Current coverage (Phases 1 and 2.1–2.4) @@ -170,6 +239,7 @@ this repository's CI. | Verify-full TLS against a live TLS-only server | [pkg/dbconn/tls_integration_test.go](../pkg/dbconn/tls_integration_test.go) | | Targeted blocker termination | [pkg/dbconn/dbconn_integration_test.go](../pkg/dbconn/dbconn_integration_test.go) | | Test harness self-checks | [internal/testutil](../internal/testutil/postgres_test.go) | +| RDS control-plane provisioning → endpoint discovery → `dbconn` connect (Ministack) | [internal/testutil/ministack_integration_test.go](../internal/testutil/ministack_integration_test.go) | | Parse boundary, typed operations, and advisory rewrites | [pkg/statement](../pkg/statement/statement_test.go), [operation tests](../pkg/statement/ops_test.go) | | Native / copy-and-swap / refuse classification and safer SQL | [pkg/planner](../pkg/planner/planner_test.go) | | Backend routing and copy-and-swap unavailable disposition | [pkg/router](../pkg/router/router_test.go) | diff --git a/go.mod b/go.mod index c9120f1..1a106ff 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,12 @@ go 1.26 require ( github.com/alecthomas/kong v1.15.0 + github.com/aws/aws-sdk-go-v2 v1.43.4 + github.com/aws/aws-sdk-go-v2/credentials v1.17.5 + github.com/aws/aws-sdk-go-v2/service/rds v1.124.1 github.com/jackc/pgx/v5 v5.10.0 + github.com/moby/moby/api v1.54.2 + github.com/moby/moby/client v0.4.0 github.com/pganalyze/pg_query_go/v6 v6.2.2 github.com/stretchr/testify v1.11.1 github.com/testcontainers/testcontainers-go v0.43.0 @@ -16,6 +21,11 @@ require ( dario.cat/mergo v1.0.2 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35 // indirect + github.com/aws/smithy-go v1.27.6 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect @@ -31,18 +41,16 @@ require ( github.com/felixge/httpsnoop v1.0.4 // 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/lufia/plan9stats v0.0.0-20240226150601-1dcf7310316a // 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.2 // indirect - github.com/moby/moby/client v0.4.0 // indirect github.com/moby/patternmatcher v0.6.1 // indirect github.com/moby/sys/sequential v0.6.0 // indirect github.com/moby/sys/user v0.4.0 // indirect diff --git a/go.sum b/go.sum index 34d264f..0d9e5a2 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,22 @@ github.com/alecthomas/kong v1.15.0 h1:BVJstKbpO73zKpmIu+m/aLRrNmWwxXPIGTNin9VmLV github.com/alecthomas/kong v1.15.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/aws/aws-sdk-go-v2 v1.43.4 h1:b9FTvbRwy+JCsfp2Wp6wV/KbOx3Aj7nkoFb2cRX0IhE= +github.com/aws/aws-sdk-go-v2 v1.43.4/go.mod h1:70vwSy16txshwG+g55WkpgPKDIByzHI8ccBsOteo3bQ= +github.com/aws/aws-sdk-go-v2/credentials v1.17.5 h1:yn3zSvIKC2NZIs40cY3kckcy9Zma96PrRR07N54PCvY= +github.com/aws/aws-sdk-go-v2/credentials v1.17.5/go.mod h1:8JcKPAGZVnDWuR5lusAwmrSDtZnDIAnpQWaDC9RFt2g= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35 h1:kzVuGlatQtYinwBJEEyLAbggepCoavosiaHHX9+fD+c= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35/go.mod h1:0yLx0yEI+SfqeJMPvOtIEFoZbiQYXMGszBueiutQyaI= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35 h1:WK6CjihTuLisCjSKKbildJ79sGZZgbBz3iNa7VsKIhU= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35/go.mod h1:KYleN57luLoe97R7vTnx8PMcVrr9gAcRECtOjl91DNg= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15 h1:JJLBQxwY+AFwuPAi5ivGc1ChnTdUt4cXMv7e76m2c/Y= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15/go.mod h1:lQknBIe78MVL0cQOQDlag8KGflMbMEVFx9mB6O8ENvk= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35 h1:BBEElKh4a+rKshvjrfpajTe9CbpZvrbb4Jkg2PB7RzA= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35/go.mod h1:zaZk983w//8beSruBVec/mr4CmDwgZitW/qzGhAAX0g= +github.com/aws/aws-sdk-go-v2/service/rds v1.124.1 h1:tEeu5kuP2MLQ7drmlN4qYiBKVoUftyBiS6dSFN67pYc= +github.com/aws/aws-sdk-go-v2/service/rds v1.124.1/go.mod h1:qciN0v66sYiwRf+YRkus1mQR0XldavqGIQEzTxc2vb0= +github.com/aws/smithy-go v1.27.6 h1:0zjT8jgK3jbrTT7JJ3EE6JsMhX8JTrZ+f1sEndYDXrA= +github.com/aws/smithy-go v1.27.6/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -46,9 +62,10 @@ 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.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 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= @@ -71,8 +88,8 @@ 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-20240226150601-1dcf7310316a h1:3Bm7EwfUQUvhNeKIkUct/gl9eod1TcXuj8stxvi/GoI= +github.com/lufia/plan9stats v0.0.0-20240226150601-1dcf7310316a/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k= 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= @@ -155,13 +172,13 @@ golang.org/x/sync v0.20.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.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/testutil/ministack.go b/internal/testutil/ministack.go new file mode 100644 index 0000000..6ec66d2 --- /dev/null +++ b/internal/testutil/ministack.go @@ -0,0 +1,313 @@ +//go:build ministack + +// The ministack build tag confines this harness — and the AWS SDK it pulls +// in — to explicit opt-in via `make test-aws-boundary`. A plain `go test +// ./...` never compiles it, so the default suite needs no Docker-socket +// mount and the AWS SDK stays out of every ordinary build. + +package testutil + +import ( + "context" + "crypto/sha1" // not cryptographic: reproduces Ministack's container-name derivation + "encoding/hex" + "fmt" + "net" + "os" + "strconv" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/rds" + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/api/types/network" + "github.com/moby/moby/client" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +// The Ministack tier proves the AWS boundary — the RDS/Aurora control +// plane (cluster and instance provisioning, endpoint discovery) — not +// Aurora data-plane behavior: the database Ministack hands back is real +// vanilla PostgreSQL running in a sibling container. Core DDL logic stays +// on the real-PostgreSQL harness in postgres.go; Aurora-only semantics +// need a real cluster (see docs/testing.md). +const ( + // ministackGatewayPort is Ministack's edge port for all AWS APIs. + ministackGatewayPort = 4566 + // auroraProvisionDeadline bounds how long a provisioned cluster may take + // to become available. The first provision pulls the postgres image for + // the sibling database container, which dominates this budget. + auroraProvisionDeadline = 5 * time.Minute + auroraProvisionPoll = 2 * time.Second + // rdsStatusAvailable is the RDS API status of a usable instance. + rdsStatusAvailable = "available" + // endpointDialTimeout bounds the reachability probe of the discovered + // endpoint. The instance is already available — Ministack marks it so + // only after an authenticated probe query succeeds — so a reachable + // address accepts immediately and a timeout means a routing gap, not a + // database that is still starting. + endpointDialTimeout = 3 * time.Second + // siblingDBPort is the PostgreSQL port inside the sibling database + // container, which Ministack also publishes on the Docker host. + siblingDBPort = "5432/tcp" + // dockerSocket is mounted into the Ministack container so it can start + // the sibling PostgreSQL container that backs the provisioned cluster. + // This grants the emulator access to the host Docker daemon — fine for + // a test harness, but the reason this tier must never run against a + // shared Docker host it does not own. + dockerSocket = "/var/run/docker.sock" + // fixtureUser, fixturePassword, and fixtureDatabase are emulator-only + // test fixtures, never real credentials: Ministack hands them to the + // sibling database container it creates for the cluster. + fixtureUser = "pgsprite" + fixturePassword = "test-password-do-not-use" + fixtureDatabase = "pgsprite" + // awsAccountID and awsRegion identify the emulator's default account. + // Ministack scopes the sibling container's name by + // sha1(account:region), so these also feed siblingHostAddr. + awsAccountID = "000000000000" + awsRegion = "us-east-1" +) + +// ministackImage returns the Ministack image to run, pinned for +// reproducibility; MINISTACK_IMAGE overrides it for upgrades or registry +// mirrors. The "full" edition ships native database drivers, so the +// emulator marks an instance available only after an authenticated probe +// query succeeds — the slim edition would fall back to a TCP check that +// can pass before PostgreSQL accepts logins. +func ministackImage() string { + if img := os.Getenv("MINISTACK_IMAGE"); img != "" { + return img + } + return "ministackorg/ministack:1.4.13-full" +} + +// auroraEngineVersion returns a real aurora-postgresql engine version for +// the requested major. Real RDS requires a full version string ("16.6"), +// not a bare major — Ministack is lenient, but this tier exists to +// rehearse calls the way the real control plane requires, so the request +// is constructed as AWS would accept it. The exact minor is immaterial: +// Ministack derives the sibling database image from the major, and the +// test asserts the running server's major independently. +func auroraEngineVersion(major int) string { + versions := map[int]string{ + 14: "14.15", + 15: "15.10", + 16: "16.6", + 17: "17.4", + 18: "18.3", + } + if v, ok := versions[major]; ok { + return v + } + // A major newer than this map: fall back to ".1" so the call + // still carries a full version string. + return fmt.Sprintf("%d.1", major) +} + +// ProvisionAuroraPostgres starts a Ministack container, provisions an +// aurora-postgresql cluster and instance through the real RDS control-plane +// API, waits until the instance is available, and returns a connection URL +// for the cluster's database. The PostgreSQL major follows PG_VERSION: the +// cluster's database is a real postgres container of that major. +func ProvisionAuroraPostgres(t *testing.T) string { + t.Helper() + if os.Getenv("SKIP_INTEGRATION") != "" { + t.Skip("SKIP_INTEGRATION set; skipping test that needs Docker") + } + + ctx := t.Context() + // No MINISTACK_RDS_PUBLIC_ENDPOINT: in public-endpoint mode a + // containerized Ministack probes instance readiness at its own + // loopback, where the host-published sibling port does not exist, so + // the instance never becomes available. In the default mode the + // sibling database joins Ministack's Docker network, readiness probes + // its container IP, and the discovered endpoint is that + // container-internal address. + ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + Started: true, + ContainerRequest: testcontainers.ContainerRequest{ + Image: ministackImage(), + ExposedPorts: []string{fmt.Sprintf("%d/tcp", ministackGatewayPort)}, + HostConfigModifier: func(hc *container.HostConfig) { + hc.Binds = append(hc.Binds, dockerSocket+":"+dockerSocket) + }, + WaitingFor: wait.ForHTTP("/_ministack/health"). + WithPort(fmt.Sprintf("%d/tcp", ministackGatewayPort)), + }, + }) + require.NoError(t, err, "start Ministack container") + t.Cleanup(func() { + if err := testcontainers.TerminateContainer(ctr); err != nil { + t.Logf("terminate Ministack container: %v", err) + } + }) + + clnt := rdsClient(t, ctr) + major, err := strconv.Atoi(PGVersion()) + require.NoError(t, err, "PG_VERSION must be a PostgreSQL major number") + + const clusterID = "pgsprite-test" + _, err = clnt.CreateDBCluster(ctx, &rds.CreateDBClusterInput{ + DBClusterIdentifier: aws.String(clusterID), + Engine: aws.String("aurora-postgresql"), + EngineVersion: aws.String(auroraEngineVersion(major)), + DatabaseName: aws.String(fixtureDatabase), + MasterUsername: aws.String(fixtureUser), + MasterUserPassword: aws.String(fixturePassword), + }) + require.NoError(t, err, "create aurora-postgresql cluster") + // The database backing the cluster is a sibling Docker container, not a + // child of the Ministack container — terminating Ministack alone would + // leak it. Deleting the cluster through the API reaps it. Cleanups run + // last-in-first-out, so the instance delete registered below runs + // before this — matching the RDS rule that a cluster cannot be deleted + // while it still has instances. + t.Cleanup(func() { + cleanupCtx := context.WithoutCancel(t.Context()) + if _, err := clnt.DeleteDBCluster(cleanupCtx, &rds.DeleteDBClusterInput{ + DBClusterIdentifier: aws.String(clusterID), + SkipFinalSnapshot: aws.Bool(true), + }); err != nil { + t.Logf("delete cluster %s: %v", clusterID, err) + } + }) + + instanceID := clusterID + "-1" + _, err = clnt.CreateDBInstance(ctx, &rds.CreateDBInstanceInput{ + DBInstanceIdentifier: aws.String(instanceID), + DBClusterIdentifier: aws.String(clusterID), + Engine: aws.String("aurora-postgresql"), + DBInstanceClass: aws.String("db.t3.medium"), + }) + require.NoError(t, err, "create aurora-postgresql instance") + t.Cleanup(func() { + cleanupCtx := context.WithoutCancel(t.Context()) + if _, err := clnt.DeleteDBInstance(cleanupCtx, &rds.DeleteDBInstanceInput{ + DBInstanceIdentifier: aws.String(instanceID), + SkipFinalSnapshot: aws.Bool(true), + }); err != nil { + t.Logf("delete instance %s: %v", instanceID, err) + } + }) + + require.Eventuallyf(t, func() bool { + out, err := clnt.DescribeDBInstances(ctx, &rds.DescribeDBInstancesInput{ + DBInstanceIdentifier: aws.String(instanceID), + }) + if err != nil || len(out.DBInstances) == 0 { + return false + } + return aws.ToString(out.DBInstances[0].DBInstanceStatus) == rdsStatusAvailable + }, auroraProvisionDeadline, auroraProvisionPoll, + "instance %s did not become %s within the provision deadline", instanceID, rdsStatusAvailable) + + // Read the endpoint back from the control plane rather than trusting the + // request: the discovery flow is the behavior under test, and the + // address it returns is the address the test connects to. The one + // exception is a host that cannot route to container IPs (macOS, where + // Docker runs in a VM) — there the connection falls back to the + // sibling's host-published port, and only the reachability of the + // discovered address goes unproven locally; CI runs on Linux, where the + // discovered endpoint is used directly. + clusters, err := clnt.DescribeDBClusters(ctx, &rds.DescribeDBClustersInput{ + DBClusterIdentifier: aws.String(clusterID), + }) + require.NoError(t, err, "describe cluster after provisioning") + require.Len(t, clusters.DBClusters, 1, "provisioned cluster must be discoverable") + endpoint := aws.ToString(clusters.DBClusters[0].Endpoint) + port := aws.ToInt32(clusters.DBClusters[0].Port) + require.NotEmpty(t, endpoint, "cluster endpoint address must be discoverable") + require.NotZero(t, port, "cluster endpoint port must be discoverable") + + addr := net.JoinHostPort(endpoint, strconv.Itoa(int(port))) + if !tcpReachable(t, addr) { + addr = siblingHostAddr(t, ctr, clusterID) + } + + // sslmode=disable: the sibling database container runs plain PostgreSQL + // without TLS, and the endpoint is not an *.rds.amazonaws.com hostname, + // so the production TLS path is out of scope for this tier (it is + // proven by pkg/dbconn's TLS integration tests). + return fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=disable", + fixtureUser, fixturePassword, addr, fixtureDatabase) +} + +// tcpReachable reports whether addr accepts a TCP connection within +// endpointDialTimeout. +func tcpReachable(t *testing.T, addr string) bool { + t.Helper() + dialer := net.Dialer{Timeout: endpointDialTimeout} + conn, err := dialer.DialContext(t.Context(), "tcp", addr) + if err != nil { + return false + } + if err := conn.Close(); err != nil { + t.Logf("close reachability probe to %s: %v", addr, err) + } + return true +} + +// siblingHostAddr resolves the host-published address of the sibling +// database container backing the cluster. Ministack publishes the +// sibling's PostgreSQL port on the Docker host, so a host that cannot +// route to container IPs connects through that mapping. The container +// name — "ministack-rds--cluster-" +// — is an emulator implementation detail this fallback accepts coupling +// to; it is exercised only on hosts where the discovered endpoint is +// unreachable. +func siblingHostAddr(t *testing.T, ctr testcontainers.Container, clusterID string) string { + t.Helper() + ctx := t.Context() + docker, err := testcontainers.NewDockerClientWithOpts(ctx) + require.NoError(t, err, "create Docker client") + defer func() { + if err := docker.Close(); err != nil { + t.Logf("close Docker client: %v", err) + } + }() + + scope := sha1.Sum([]byte(awsAccountID + ":" + awsRegion)) + name := fmt.Sprintf("ministack-rds-%s-cluster-%s", hex.EncodeToString(scope[:])[:12], clusterID) + inspect, err := docker.ContainerInspect(ctx, name, client.ContainerInspectOptions{}) + require.NoErrorf(t, err, "inspect sibling database container %s", name) + + dbPort, err := network.ParsePort(siblingDBPort) + require.NoError(t, err, "parse sibling database port") + bindings := inspect.Container.NetworkSettings.Ports[dbPort] + require.NotEmptyf(t, bindings, "sibling container %s must publish %s on the host", name, siblingDBPort) + + host, err := ctr.Host(ctx) + require.NoError(t, err, "resolve Docker host address") + return net.JoinHostPort(host, bindings[0].HostPort) +} + +// rdsClient returns an RDS API client pointed at the container's gateway +// with the emulator's conventional static test credentials. +func rdsClient(t *testing.T, ctr testcontainers.Container) *rds.Client { + t.Helper() + ctx := t.Context() + host, err := ctr.Host(ctx) + require.NoError(t, err, "resolve container host") + gateway, err := ctr.MappedPort(ctx, fmt.Sprintf("%d/tcp", ministackGatewayPort)) + require.NoError(t, err, "resolve mapped gateway port") + endpoint := fmt.Sprintf("http://%s:%d", host, gateway.Num()) + + // The config is constructed directly, not via config.LoadDefaultConfig: + // the default loader reads ~/.aws/config, AWS_PROFILE, and the EC2 + // instance-metadata endpoint, so a developer's real AWS environment + // would leak into a test that must stay hermetic — and it drags the + // whole credential-discovery chain into go.mod for a client that only + // ever uses static fixture credentials against the emulator. + cfg := aws.Config{ + Region: awsRegion, + Credentials: credentials.NewStaticCredentialsProvider("test", "test", ""), + } + return rds.NewFromConfig(cfg, func(o *rds.Options) { + o.BaseEndpoint = aws.String(endpoint) + }) +} diff --git a/internal/testutil/ministack_integration_test.go b/internal/testutil/ministack_integration_test.go new file mode 100644 index 0000000..57df284 --- /dev/null +++ b/internal/testutil/ministack_integration_test.go @@ -0,0 +1,53 @@ +//go:build ministack + +package testutil_test + +import ( + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/block/pg-sprite/internal/testutil" + "github.com/block/pg-sprite/pkg/dbconn" +) + +// TestAuroraControlPlaneProvisionAndConnect proves the AWS-boundary flow +// end to end: an aurora-postgresql cluster provisioned through the real RDS +// control-plane API is discoverable, its endpoint accepts connections +// through pkg/dbconn (bounded session defaults included), the server runs +// the requested PostgreSQL major, and DDL executes. +func TestAuroraControlPlaneProvisionAndConnect(t *testing.T) { + url := testutil.ProvisionAuroraPostgres(t) + + pool, err := dbconn.NewPool(t.Context(), dbconn.Config{ + URL: url, + LockTimeout: 300 * time.Millisecond, + }) + require.NoError(t, err, "connect to provisioned cluster endpoint via dbconn") + t.Cleanup(pool.Close) + + // The server behind the endpoint runs the major the harness requested; + // a silent fallback inside the emulator would invalidate the tier. + requestedMajor, err := strconv.Atoi(testutil.PGVersion()) + require.NoError(t, err, "PG_VERSION must be a PostgreSQL major number") + var versionNum int + require.NoError(t, pool.QueryRow(t.Context(), "SELECT current_setting('server_version_num')::int").Scan(&versionNum)) + assert.Equal(t, requestedMajor, versionNum/10000, "server major must match the requested major") + + // The dbconn bounded session settings apply on this endpoint like any + // other PostgreSQL target. + var lockTimeout string + require.NoError(t, pool.QueryRow(t.Context(), "SHOW lock_timeout").Scan(&lockTimeout)) + assert.Equal(t, "300ms", lockTimeout) + + // DDL smoke: the provisioned database is writable and introspectable. + schema := testutil.NewSchema(t, pool) + _, err = pool.Exec(t.Context(), "CREATE TABLE "+schema+".t (id bigint PRIMARY KEY)") + require.NoError(t, err, "create table on provisioned cluster") + var oid *uint32 + require.NoError(t, pool.QueryRow(t.Context(), "SELECT to_regclass($1)::oid", schema+".t").Scan(&oid)) + assert.NotNil(t, oid, "created table must be visible in the catalog") +}