From 2c556d33f54584379189440d215381befc6d8067 Mon Sep 17 00:00:00 2001 From: Kiran Muddukrishna Date: Thu, 6 Aug 2026 14:53:20 +1000 Subject: [PATCH] testutil: AWS-boundary test tier via Ministack RDS/Aurora Ministack (MIT, tokenless) replaces the LocalStack plan: the tier now runs on every code PR as a real merge gate, and the provisioned database follows PG_VERSION across the full 14-18 range. The full image is required so instance readiness is an authenticated probe, and the connection uses a pinned host-published port because the emulator's container-internal endpoint address is not routable from macOS hosts. --- .github/workflows/ci.yml | 20 +- AGENTS.md | 1 + Makefile | 8 +- docs/testing.md | 58 ++++- go.mod | 20 +- go.sum | 39 +++- internal/testutil/ministack.go | 220 ++++++++++++++++++ .../testutil/ministack_integration_test.go | 51 ++++ 8 files changed, 401 insertions(+), 16 deletions(-) create mode 100644 internal/testutil/ministack.go create mode 100644 internal/testutil/ministack_integration_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d21e296..e65a2e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -100,12 +100,30 @@ jobs: go-version-file: go.mod - run: make test + # 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 — as a real merge gate. The Ministack + # container mounts the runner's Docker socket to start the sibling + # PostgreSQL container backing the provisioned cluster; that is safe on + # an ephemeral GitHub-hosted runner but is why this job must stay on + # ubuntu-latest, never a shared self-hosted runner. + aws-boundary: + needs: changes + if: needs.changes.outputs.code == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + 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. all-green: if: always() - needs: [changes, lint, build, test] + needs: [changes, lint, build, test, aws-boundary] runs-on: ubuntu-latest steps: - name: Check job results diff --git a/AGENTS.md b/AGENTS.md index 5682218..f0062be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,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..c04049e 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 -race -count=1 -run 'AuroraControlPlane' -v ./internal/testutil/ + lint: golangci-lint run diff --git a/docs/testing.md b/docs/testing.md index 6b64480..cf129e8 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -123,6 +123,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. | +| `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) @@ -134,16 +135,60 @@ 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, and it does not serve TLS | +| 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 RDS IAM-auth token connections +(`dbconn.Config.BeforeConnect`). + +Ministack is MIT-licensed and needs no auth token, so the tier runs +anywhere Docker runs — locally and on every CI PR, forks included. The +provisioned database is a real `postgres` container whose major follows +`PG_VERSION`, so the tier covers the same 14 → 18 range as the matrix. +The Ministack container mounts the host Docker socket to start that +sibling container; run it only on Docker hosts you own (CI uses ephemeral +GitHub-hosted runners). `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; +- Secrets Manager DSN resolution, when that feature lands; +- RDS IAM-auth token connections (`dbconn.Config.BeforeConnect`), when + that feature lands. + +The tier runs in CI as the `aws-boundary` merge-gate job and inside +`make test` when Docker is available. It is intentionally **not** part +of the pre-push hook, which stays unit-only so pushes remain fast; CI is +the authoritative gate. ## Current coverage (Phases 1 and 2.1–2.4) @@ -156,6 +201,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..bb4e9fb 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/config v1.27.5 + 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/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,16 @@ 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/feature/ec2/imds v1.15.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/internal/ini v1.8.0 // 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/aws-sdk-go-v2/service/sso v1.20.1 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.28.2 // 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,17 +46,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 diff --git a/go.sum b/go.sum index 34d264f..bad0daa 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,34 @@ 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/config v1.27.5 h1:brBPsyRFQn97M1ZhQ9tLXkO7Zytiar0NS06FGmEJBdg= +github.com/aws/aws-sdk-go-v2/config v1.27.5/go.mod h1:I53uvsfddRRTG5YcC4n5Z3aOD1BU8hYCoIG7iEJG4wM= +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/feature/ec2/imds v1.15.2 h1:AK0J8iYBFeUk2Ax7O8YpLtFsfhdOByh2QIkHmigpRYk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.15.2/go.mod h1:iRlGzMix0SExQEviAyptRWRGdYNo3+ufW/lCzvKVTUc= +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/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY= +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/aws-sdk-go-v2/service/sso v1.20.1 h1:utEGkfdQ4L6YW/ietH7111ZYglLJvS+sLriHJ1NBJEQ= +github.com/aws/aws-sdk-go-v2/service/sso v1.20.1/go.mod h1:RsYqzYr2F2oPDdpy+PdhephuZxTfjHQe7SOBcZGoAU8= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.1 h1:9/GylMS45hGGFCcMrUZDVayQE1jYSIN6da9jo7RAYIw= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.1/go.mod h1:YjAPFn4kGFqKC54VsHs5fn5B6d+PCY2tziEa3U/GB5Y= +github.com/aws/aws-sdk-go-v2/service/sts v1.28.2 h1:0YjXuWdYHvsm0HnT4vO8XpwG1D+i2roxSCBoN6deJ7M= +github.com/aws/aws-sdk-go-v2/service/sts v1.28.2/go.mod h1:jI+FWmYkSMn+4APWmZiZTgt0oM0TrvymD51FMqCnWgA= +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 +74,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 +100,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 +184,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..439a77b --- /dev/null +++ b/internal/testutil/ministack.go @@ -0,0 +1,220 @@ +package testutil + +import ( + "context" + "fmt" + "net" + "os" + "strconv" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "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/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" + // 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" +) + +// 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" +} + +// 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() + // The sibling database container publishes its port on the Docker host + // starting at RDS_BASE_PORT. Pinning that base to a port this process + // picked keeps the database reachable at a known localhost address on + // every platform — container IPs are not routable from the host on + // macOS, so the endpoint address the API returns cannot be used + // directly. + dbPort := freePort(t) + ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + Started: true, + ContainerRequest: testcontainers.ContainerRequest{ + Image: ministackImage(), + ExposedPorts: []string{fmt.Sprintf("%d/tcp", ministackGatewayPort)}, + Env: map[string]string{ + "RDS_BASE_PORT": strconv.Itoa(dbPort), + }, + 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) + } + }) + + client := 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 = client.CreateDBCluster(ctx, &rds.CreateDBClusterInput{ + DBClusterIdentifier: aws.String(clusterID), + Engine: aws.String("aurora-postgresql"), + EngineVersion: aws.String(strconv.Itoa(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 := client.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 = client.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 := client.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 := client.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. + clusters, err := client.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") + require.NotEmpty(t, aws.ToString(clusters.DBClusters[0].Endpoint), + "cluster endpoint address must be discoverable") + require.NotZero(t, aws.ToInt32(clusters.DBClusters[0].Port), + "cluster endpoint port must be discoverable") + + // The discovered endpoint address is container-internal; connect via the + // pinned host-published port instead (see dbPort above). + // + // 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@localhost:%d/%s?sslmode=disable", + fixtureUser, fixturePassword, dbPort, fixtureDatabase) +} + +// freePort reserves an ephemeral TCP port and returns it for reuse. The +// port is released before returning, so a collision is possible but +// unlikely within a test's lifetime. +func freePort(t *testing.T) int { + t.Helper() + var lc net.ListenConfig + l, err := lc.Listen(t.Context(), "tcp", "127.0.0.1:0") + require.NoError(t, err, "reserve a free TCP port") + port := l.Addr().(*net.TCPAddr).Port + require.NoError(t, l.Close(), "release the reserved port") + return port +} + +// 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()) + + cfg, err := config.LoadDefaultConfig(ctx, + config.WithRegion("us-east-1"), + config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")), + ) + require.NoError(t, err, "load AWS SDK config") + 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..12f2c17 --- /dev/null +++ b/internal/testutil/ministack_integration_test.go @@ -0,0 +1,51 @@ +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") +}