From 2c556d33f54584379189440d215381befc6d8067 Mon Sep 17 00:00:00 2001 From: Kiran Muddukrishna Date: Thu, 6 Aug 2026 14:53:20 +1000 Subject: [PATCH 1/2] 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") +} From e00752f8ee3e6a64cf093a6cd66e36159b607040 Mon Sep 17 00:00:00 2001 From: Kiran Muddukrishna Date: Tue, 11 Aug 2026 14:36:08 +1000 Subject: [PATCH 2/2] testutil: gate Ministack harness behind build tag, demote CI job to signal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No production code makes AWS API calls yet, so the aws-boundary tier cannot gate merges: it leaves all-green's required set until the first AWS-facing feature lands. The build tag keeps the AWS SDK and the Docker-socket mount out of ordinary builds and out of `make test`. The harness now connects to the endpoint discovery returns. Public- endpoint mode was dropped because a containerized Ministack then probes readiness at its own loopback and the instance never becomes available; default mode probes the sibling's container IP, which is also the discovered endpoint — routable on Linux (CI), with a host-published-port fallback for macOS. The AWS config is built directly (no default credential chain), engine versions are sent as the full strings real RDS requires, and the CI job refuses non-GitHub-hosted runners instead of trusting a comment. --- .github/workflows/ci.yml | 28 ++- Makefile | 2 +- SAFETY.md | 4 + docs/testing.md | 62 ++++-- go.mod | 8 +- go.sum | 12 -- internal/testutil/ministack.go | 183 +++++++++++++----- .../testutil/ministack_integration_test.go | 2 + 8 files changed, 210 insertions(+), 91 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59e2a90..21fcc8d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -136,18 +136,32 @@ jobs: # 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 + # 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 on - # an ephemeral GitHub-hosted runner but is why this job must stay on - # ubuntu-latest, never a shared self-hosted runner. + # 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: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - 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 @@ -157,7 +171,7 @@ jobs: # docs-only PRs where the heavy jobs were skipped. all-green: if: always() - needs: [changes, lint, build, test, aws-boundary] + needs: [changes, lint, build, test] runs-on: ubuntu-latest steps: - name: Check job results diff --git a/Makefile b/Makefile index c04049e..39063e9 100644 --- a/Makefile +++ b/Makefile @@ -41,7 +41,7 @@ test-supported-postgres: # (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/ + $(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 f81c0da..7a7fa96 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -161,24 +161,38 @@ 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 | +| 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 RDS IAM-auth token connections -(`dbconn.Config.BeforeConnect`). +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. 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. +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 @@ -194,15 +208,25 @@ 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. + 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) diff --git a/go.mod b/go.mod index bb4e9fb..1a106ff 100644 --- a/go.mod +++ b/go.mod @@ -5,11 +5,11 @@ 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/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 @@ -21,15 +21,10 @@ 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 @@ -56,7 +51,6 @@ require ( 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/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 bad0daa..0d9e5a2 100644 --- a/go.sum +++ b/go.sum @@ -14,30 +14,18 @@ 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= diff --git a/internal/testutil/ministack.go b/internal/testutil/ministack.go index 439a77b..6ec66d2 100644 --- a/internal/testutil/ministack.go +++ b/internal/testutil/ministack.go @@ -1,7 +1,16 @@ +//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" @@ -10,10 +19,11 @@ import ( "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/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" @@ -35,6 +45,15 @@ const ( 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 @@ -47,6 +66,11 @@ const ( 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 @@ -62,6 +86,29 @@ func ministackImage() string { 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 @@ -74,21 +121,18 @@ func ProvisionAuroraPostgres(t *testing.T) string { } 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) + // 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)}, - Env: map[string]string{ - "RDS_BASE_PORT": strconv.Itoa(dbPort), - }, HostConfigModifier: func(hc *container.HostConfig) { hc.Binds = append(hc.Binds, dockerSocket+":"+dockerSocket) }, @@ -103,15 +147,15 @@ func ProvisionAuroraPostgres(t *testing.T) string { } }) - client := rdsClient(t, ctr) + 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 = client.CreateDBCluster(ctx, &rds.CreateDBClusterInput{ + _, err = clnt.CreateDBCluster(ctx, &rds.CreateDBClusterInput{ DBClusterIdentifier: aws.String(clusterID), Engine: aws.String("aurora-postgresql"), - EngineVersion: aws.String(strconv.Itoa(major)), + EngineVersion: aws.String(auroraEngineVersion(major)), DatabaseName: aws.String(fixtureDatabase), MasterUsername: aws.String(fixtureUser), MasterUserPassword: aws.String(fixturePassword), @@ -125,7 +169,7 @@ func ProvisionAuroraPostgres(t *testing.T) string { // while it still has instances. t.Cleanup(func() { cleanupCtx := context.WithoutCancel(t.Context()) - if _, err := client.DeleteDBCluster(cleanupCtx, &rds.DeleteDBClusterInput{ + if _, err := clnt.DeleteDBCluster(cleanupCtx, &rds.DeleteDBClusterInput{ DBClusterIdentifier: aws.String(clusterID), SkipFinalSnapshot: aws.Bool(true), }); err != nil { @@ -134,7 +178,7 @@ func ProvisionAuroraPostgres(t *testing.T) string { }) instanceID := clusterID + "-1" - _, err = client.CreateDBInstance(ctx, &rds.CreateDBInstanceInput{ + _, err = clnt.CreateDBInstance(ctx, &rds.CreateDBInstanceInput{ DBInstanceIdentifier: aws.String(instanceID), DBClusterIdentifier: aws.String(clusterID), Engine: aws.String("aurora-postgresql"), @@ -143,7 +187,7 @@ func ProvisionAuroraPostgres(t *testing.T) string { require.NoError(t, err, "create aurora-postgresql instance") t.Cleanup(func() { cleanupCtx := context.WithoutCancel(t.Context()) - if _, err := client.DeleteDBInstance(cleanupCtx, &rds.DeleteDBInstanceInput{ + if _, err := clnt.DeleteDBInstance(cleanupCtx, &rds.DeleteDBInstanceInput{ DBInstanceIdentifier: aws.String(instanceID), SkipFinalSnapshot: aws.Bool(true), }); err != nil { @@ -152,7 +196,7 @@ func ProvisionAuroraPostgres(t *testing.T) string { }) require.Eventuallyf(t, func() bool { - out, err := client.DescribeDBInstances(ctx, &rds.DescribeDBInstancesInput{ + out, err := clnt.DescribeDBInstances(ctx, &rds.DescribeDBInstancesInput{ DBInstanceIdentifier: aws.String(instanceID), }) if err != nil || len(out.DBInstances) == 0 { @@ -163,39 +207,83 @@ func ProvisionAuroraPostgres(t *testing.T) string { "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{ + // 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") - 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). - // + 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@localhost:%d/%s?sslmode=disable", - fixtureUser, fixturePassword, dbPort, fixtureDatabase) + 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 } -// 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 { +// 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() - 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 + 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 @@ -209,11 +297,16 @@ func rdsClient(t *testing.T, ctr testcontainers.Container) *rds.Client { 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") + // 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 index 12f2c17..57df284 100644 --- a/internal/testutil/ministack_integration_test.go +++ b/internal/testutil/ministack_integration_test.go @@ -1,3 +1,5 @@ +//go:build ministack + package testutil_test import (