From f7e04e7b7f4bffb296f0a67263426bc4dab11910 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 10:22:53 +0000 Subject: [PATCH 01/16] spike(pg-compat): capture exact dbdeployer PostgreSQL deploy command + ports --- test/pg-compat/SPIKE-dbdeployer-pg.md | 354 ++++++++++++++++++++++++++ 1 file changed, 354 insertions(+) create mode 100644 test/pg-compat/SPIKE-dbdeployer-pg.md diff --git a/test/pg-compat/SPIKE-dbdeployer-pg.md b/test/pg-compat/SPIKE-dbdeployer-pg.md new file mode 100644 index 0000000000..86eea9aba0 --- /dev/null +++ b/test/pg-compat/SPIKE-dbdeployer-pg.md @@ -0,0 +1,354 @@ +# SPIKE: dbdeployer PostgreSQL deploy command (Task 1) + +Status: **CONFIRMED WORKING** — the pinned ProxySQL fork of dbdeployer (v2.2.1) can deploy a +1-primary + 2-replica PostgreSQL 17 streaming-replication sandbox. Task 2a (dbdeployer path) is +viable. This doc records the exact commands, ports, and — critically — several undocumented +gaps/limitations in this fork's PostgreSQL support that Task 2 must work around. + +All experiments were run inside a single throwaway `ubuntu:22.04` container +(`docker run -d --name sdd-sp2-spike --network=host ubuntu:22.04 sleep infinity`, removed at the +end via `docker rm -f sdd-sp2-spike`). Nothing was installed on the host. + +## 1. dbdeployer version + install method (verbatim, matches the MySQL GR reference Dockerfile) + +```bash +curl -fsSL "https://github.com/ProxySQL/dbdeployer/releases/download/v2.2.1/dbdeployer-2.2.1.linux_amd64.tar.gz" \ + | tar -xz -C /usr/local/bin/ +chmod +x /usr/local/bin/dbdeployer +``` + +`dbdeployer --version` → `dbdeployer version 2.2.1`. (No rename/symlink needed — the tarball's +member is already named `dbdeployer`, unlike the brief's assumption of a +`dbdeployer-2.2.1.linux_amd64` binary name.) + +`dbdeployer deploy --help` lists a **`postgresql` subcommand** ("deploys a PostgreSQL sandbox") +alongside `single`/`multiple`/`replication`/`proxysql`, and `deploy replication`/`deploy single` +both expose a `--provider string` flag ("Database provider (mysql, postgresql)", default +`mysql`). This is the confirmed PG support surface. + +## 2. PostgreSQL is NOT in `dbdeployer downloads list` — use PGDG `.deb`s instead + +This is the biggest divergence from the brief's assumed flow. **`dbdeployer downloads list` +(with `--OS=all`, or `--flavor=postgresql|pgsql|postgres`) returns zero PostgreSQL entries** — +the fork's built-in remote tarball index only carries MySQL/Percona/MariaDB tarballs. There is +no `dbdeployer downloads get-unpack ` to run. + +Instead, `dbdeployer deploy postgresql --help` states the actual prerequisite: + +``` +Requires PostgreSQL binaries to be extracted first: + dbdeployer unpack --provider=postgresql postgresql-16_*.deb postgresql-client-16_*.deb +``` + +i.e. dbdeployer expects **official PGDG `.deb` packages** (not `.tar.gz`), fed to +`dbdeployer unpack --provider=postgresql`, which extracts their file trees into +`$HOME/opt/postgresql//` (note: separate from `$HOME/opt/mysql`, and NOT affected by +`--sandbox-binary`). + +### Exact commands used (PG 17.10, the newest available for jammy/22.04 in PGDG at spike time) + +```bash +# Add the PGDG apt repo (ubuntu:22.04 = jammy) +apt-get install -y -qq gnupg lsb-release wget +install -d /usr/share/postgresql-common/pgdg +wget -q -O /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc \ + https://www.postgresql.org/media/keys/ACCC4CF8.asc +echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] \ + https://apt.postgresql.org/pub/repos/apt jammy-pgdg main" \ + > /etc/apt/sources.list.d/pgdg.list +apt-get update -qq + +# Fetch the .deb files themselves (do NOT `apt-get install` the PG packages system-wide; +# dbdeployer wants the raw .deb to extract itself) +mkdir -p /root/pgdebs && cd /root/pgdebs +apt-get install -y -qq --download-only --reinstall -o Dir::Cache::archives=/root/pgdebs \ + postgresql-17 postgresql-client-17 postgresql-common postgresql-client-common + +# Exact tarball(deb) names used: +# postgresql-17_17.10-1.pgdg22.04+1_amd64.deb +# postgresql-client-17_17.10-1.pgdg22.04+1_amd64.deb + +# Unpack via dbdeployer (as a NON-ROOT user — see §4) +cd /root/pgdebs +dbdeployer unpack --provider=postgresql \ + postgresql-17_17.10-1.pgdg22.04+1_amd64.deb \ + postgresql-client-17_17.10-1.pgdg22.04+1_amd64.deb +# => "PostgreSQL 17.10 unpacked to $HOME/opt/postgresql/17.10" +``` + +PG version deployed: **17.10** (PGDG's latest 17.x for jammy at spike time 2026-07-08; 17.8/17.9 +were also available — pin whichever exact point release Task 2 wants, all worked identically). + +## 3. Runtime shared libraries required (Debian PG binaries are not self-contained) + +Unlike the MySQL/Percona/MariaDB tarballs dbdeployer normally consumes (statically-ish bundled, +relocatable), **the PGDG `.deb` payload only contains PostgreSQL's own files** — it does NOT +bundle its shared-library dependencies. Running the unpacked binaries requires these installed +system-wide first (else `initdb`/`postgres` fail with `error while loading shared libraries`): + +```bash +apt-get install -y -qq /root/pgdebs/libpq5_*.deb /root/pgdebs/libicu70_*.deb \ + /root/pgdebs/libxml2_*.deb /root/pgdebs/libxslt1.1_*.deb /root/pgdebs/libllvm15_*.deb \ + /root/pgdebs/libedit2_*.deb /root/pgdebs/libbsd0_*.deb /root/pgdebs/libmd0_*.deb \ + /root/pgdebs/tzdata_*.deb +ldconfig +``` + +**Untested alternative worth trying in Task 2** (simpler, not verified in this spike): instead of +hand-picking these libs, just `apt-get install -y postgresql-17 postgresql-client-17` normally +(full system install, which pulls in all deps AND places files at the standard `/usr/share/postgresql/17` +path — see next point), then *separately* `apt-get download` a second copy of the same `.deb`s to +feed to `dbdeployer unpack`. This would likely avoid the manual lib list above and the symlink +workaround below. Flagging as a recommended experiment for Task 2, not a confirmed fact. + +### `pg_config --sharedir` is a hardcoded absolute path, not relocatable + +The Debian PG build reports (`pg_config --sharedir --bindir --pkglibdir`): +``` +/usr/share/postgresql/17 <- WRONG once relocated by dbdeployer unpack; hardcoded, not relative +$HOME/opt/postgresql/17.10/bin <- correct, tracks the actual unpack location +$HOME/opt/postgresql/17.10/lib <- correct +``` +`initdb` fails (`could not open directory "/usr/share/postgresql/17/timezonesets"`) because the +binary looks for its share files at the compiled-in absolute path, which doesn't exist once the +tree is copied to `$HOME/opt/postgresql/17.10`. **Workaround used and confirmed working:** + +```bash +mkdir -p /usr/share/postgresql +ln -sf "$HOME/opt/postgresql/17.10/share/postgresql/17" /usr/share/postgresql/17 +``` + +(System `/usr/share/zoneinfo` must also exist — i.e. the `tzdata` package must be installed; +it was in the runtime-lib list above.) + +## 4. initdb refuses to run as root — must run dbdeployer as a non-root user + +`dbdeployer deploy postgresql`/`deploy replication --provider=postgresql` shell out to `initdb`, +which **hard-refuses to run as root** (`initdb: error: cannot be run as root` — no override flag +exists; this is a PostgreSQL security feature, not a dbdeployer bug). This is a real architectural +difference from the MySQL GR reference image, whose entrypoint runs `dbdeployer` as root with no +issue. + +**Confirmed working setup:** create a dedicated non-root user, and run every `dbdeployer` command +for PG (`unpack`, `deploy postgresql`, `deploy replication --provider=postgresql`, and any +`sandboxes`/`delete`) via that user (its `$HOME` becomes dbdeployer's default `sandbox-home` / +`sandbox-binary` root for the postgresql provider): + +```bash +useradd -m -s /bin/bash pguser +# copy/own the downloaded .deb files, then, as pguser: +su - pguser -c 'dbdeployer unpack --provider=postgresql postgresql-17_17.10-1.pgdg22.04+1_amd64.deb postgresql-client-17_17.10-1.pgdg22.04+1_amd64.deb' +``` + +Task 2's entrypoint/Dockerfile should either run the whole container as this user (`USER pguser` ++ appropriate ownership of the build layers), or `su`/`gosu`/`setpriv` into it for the PG-specific +commands only, similar to how official `postgres` images drop privileges. + +## 5. The exact WORKING `dbdeployer deploy replication` command + +Run as `pguser` (see §4), after `dbdeployer defaults update reserved-ports '0'` (same +reserved-ports-clearing step as the MySQL GR image; **relevant for PG too since dbdeployer +reserves 5432 by default**): + +```bash +dbdeployer deploy replication 17.10 \ + --provider=postgresql \ + --topology=master-slave \ + --nodes=3 \ + --bind-address=0.0.0.0 \ + --base-port=5432 \ + -c listen_addresses=0.0.0.0 \ + -c max_connections=500 \ + -c shared_preload_libraries=pg_stat_statements +``` + +Output: +``` + Primary deployed in $HOME/sandboxes/postgresql_repl_16710/primary (port: 16710) + Replica 1 deployed in $HOME/sandboxes/postgresql_repl_16710/replica1 (port: 16711) + Replica 2 deployed in $HOME/sandboxes/postgresql_repl_16710/replica2 (port: 16712) +postgresql replication sandbox (1 primary + 2 replicas) deployed in $HOME/sandboxes/postgresql_repl_16710 +``` + +### IMPORTANT — silently-ignored flags (surprise #1) + +**`--base-port`, `--bind-address`, and every `-c` config override above are silently ignored by +the `--provider=postgresql` replication code path in this fork.** The command above "succeeds" +and reports the settings were requested, but: +- Ports are **not** 5432-based; they are auto-derived from the version number + (`17.10` → primary port **16710**, replicas **16711**/**16712** — pattern is + `15000 + major*100 + minor`, confirmed by observing the same 16710 base for a plain + `deploy postgresql 17.10` single-node deploy with no port flags at all). +- `listen_addresses` in the generated `postgresql.conf` stays `127.0.0.1` regardless of + `--bind-address=0.0.0.0`. +- None of the `-c` key=value pairs appear in `postgresql.conf` at all (verified by `tail`-ing the + file post-deploy — only dbdeployer's own fixed template lines are present: `port`, + `listen_addresses`, `unix_socket_directories`, `logging_collector`, `log_directory`, + `wal_level`, `max_wal_senders`, `hot_standby`). + +This is a real fork limitation (verified by testing flag placement both after and before the +`replication` subcommand — no difference), not a usage mistake. **Task 2 cannot rely on `-c` / +`--bind-address` / `--base-port` for the postgresql provider.** + +### Confirmed working workaround (surprise #1, mitigation) + +Stop all 3 nodes, append overrides directly to each `data/postgresql.conf`, and restart — this +DOES work and was verified end-to-end (config values took effect, replication stayed intact, +extension load succeeded): + +```bash +for n in primary replica1 replica2; do + "$SBASE/$n/stop" + cat >> "$SBASE/$n/data/postgresql.conf" <", user "postgres", database "postgres", no +encryption` — i.e. **listen_addresses alone is not sufficient for other containers on the Docker +network to reach these nodes; `pg_hba.conf` must also be widened.** Confirmed fix (append + reload, +no restart needed): + +```bash +for n in primary replica1 replica2; do + echo "host all all 0.0.0.0/0 trust" >> "$SBASE/$n/data/pg_hba.conf" + echo "host replication all 0.0.0.0/0 trust" >> "$SBASE/$n/data/pg_hba.conf" +done +"$SBASE/primary/use" -c "SELECT pg_reload_conf();" # or any node — reload is per-node +"$SBASE/replica1/use" -c "SELECT pg_reload_conf();" +"$SBASE/replica2/use" -c "SELECT pg_reload_conf();" +``` +Re-verified with a real (non-loopback) client connection after the reload: succeeded +(`SELECT 1` returned). `trust` auth is obviously permissive — Task 2 may want to scope the CIDR +to the actual Docker bridge subnet and/or switch to `scram-sha-256` with a real password for +anything beyond a fully-isolated test network (see §7 — there's currently no password set at +all). + +## 6. Sandbox directory layout & port map (PG 17.10, 1 primary + 2 replicas) + +``` +$HOME/sandboxes/postgresql_repl_16710/ +├── check_recovery # runs `SELECT pg_is_in_recovery();` against both replica ports +├── check_replication # runs `SELECT ... FROM pg_stat_replication;` against the primary +├── primary/ +│ ├── data/ # PGDATA (postgresql.conf, pg_hba.conf, base/, etc.) +│ ├── postgresql.log +│ ├── start / stop / restart / status / clear / use # `use` = psql wrapper script +├── replica1/ (same layout) +└── replica2/ (same layout) +``` + +| Node | Role | Port | `pg_is_in_recovery()` | +|----------|---------|-------|------------------------| +| primary | primary | 16710 | `f` | +| replica1 | replica | 16711 | `t` | +| replica2 | replica | 16712 | `t` | + +**Note:** `dbdeployer sandboxes` (the catalog command) did **not** reliably list this sandbox in +testing — it showed a stale/unrelated entry from an earlier failed attempt while the real, +running `postgresql_repl_16710` tree was absent from its output. **Task 2 should enumerate PG +sandboxes via `ls $HOME/sandboxes/` / the directory tree, not trust `dbdeployer sandboxes` for +the postgresql provider.** + +## 7. Superuser, replication user, and adding more users + +- Superuser: **`postgres`**, auth method **`trust`** (no password set) for local/loopback + connections by default. `--db-user`/`--db-password` (global deploy flags, default + `msandbox`/`msandbox`) are **also ignored** for `--provider=postgresql` — the role is always + `postgres` with no password, confirmed via `\du` (single role: `postgres`, Superuser/Create + role/Create DB/Replication/Bypass RLS). +- Replication: dbdeployer does **not** create a dedicated replication role. Each replica's + `postgresql.auto.conf` sets `primary_conninfo = 'user=postgres passfile=... host=127.0.0.1 + port=16710 ...'` — i.e. replication streams **as the `postgres` superuser itself** via + streaming replication (`standby.signal` present, `wal_level=replica`, `max_wal_senders=10`, + `hot_standby=on` baked into the primary's `postgresql.conf` by dbdeployer). No replication + slots are configured (physical replication via `primary_conninfo`, not slot-based). +- Additional users/passwords: not a dbdeployer feature for PG — just connect as `postgres` via + the `use` script and run plain SQL, e.g.: + ```bash + "$SBASE/primary/use" -c "CREATE ROLE app_user LOGIN PASSWORD 'app_pw';" + ``` + +## 8. `pg_stat_statements` availability (confirmed present, verified loadable) + +`postgresql-17_17.10-1.pgdg22.04+1_amd64.deb` ships `pg_stat_statements.so` at +`$HOME/opt/postgresql/17.10/lib/pg_stat_statements.so`, plus its control/SQL files under +`share/postgresql/17/extension/pg_stat_statements*`. Confirmed end-to-end: +- `-c shared_preload_libraries=pg_stat_statements` written directly into `postgresql.conf` (see + §5 workaround) → after restart, `SHOW shared_preload_libraries;` returned `pg_stat_statements`. +- `CREATE EXTENSION pg_stat_statements;` succeeded on the primary; `SELECT 1 FROM + pg_stat_statements LIMIT 1;` returned a row. + +## 9. Full verification transcript (final state) + +``` +-- primary (port 16710) +SHOW listen_addresses; -- 0.0.0.0 +SHOW max_connections; -- 500 +SHOW shared_preload_libraries; -- pg_stat_statements +SELECT pg_is_in_recovery(); -- f +CREATE EXTENSION pg_stat_statements; -- CREATE EXTENSION +SELECT 1 FROM pg_stat_statements LIMIT 1; -- 1 row + +-- check_replication (run on primary, port 16710) + client_addr | state | sent_lsn | write_lsn | flush_lsn | replay_lsn +-------------+-----------+-----------+-----------+-----------+------------ + 127.0.0.1 | streaming | 0/40513C8 | 0/40513C8 | 0/40513C8 | 0/40513C8 + 127.0.0.1 | streaming | 0/40513C8 | 0/40513C8 | 0/40513C8 | 0/40513C8 + +-- check_recovery +=== Replica port 16711 === pg_is_in_recovery = t +=== Replica port 16712 === pg_is_in_recovery = t + +-- remote (non-loopback) connectivity, post pg_hba.conf fix +psql -h -p 16710 -U postgres -c "SELECT 1 AS remote_ok;" -- 1 +``` + +## 10. Summary of surprises/limitations for Task 2 to design around + +1. No PG tarballs in `dbdeployer downloads list` — must pull PGDG `.deb`s via `apt` and feed them + to `dbdeployer unpack --provider=postgresql`. +2. Debian PG binaries need their shared-lib dependencies installed system-wide (`libpq5`, + `libicu70`, `libxml2`, `libxslt1.1`, `libllvm15`, `libedit2`, `libbsd0`, `libmd0`, `tzdata`), + and a `/usr/share/postgresql/` symlink to the unpacked share dir (hardcoded, non- + relocatable `sharedir`). +3. `initdb`/`postgres` refuse to run as root — the container must run PG-related dbdeployer + commands as a non-root user (unlike the MySQL GR image, which runs entirely as root). +4. `--base-port`, `--bind-address`, `-c my-cnf-options`, and `--db-user`/`--db-password` are all + silently ignored for `--provider=postgresql`; config must be injected by editing + `postgresql.conf`/`pg_hba.conf` directly and restarting/reloading. Ports are fixed at + `15000 + major*100 + minor` (16710/16711/16712 for 17.10) — do not fight this, standardize on + it. +5. `pg_hba.conf` defaults to loopback-only `trust` rules — must append a wider `host ... 0.0.0.0/0` + (or the actual Docker subnet) entry for cross-container reachability; `pg_reload_conf()` (no + restart) is sufficient. +6. `dbdeployer sandboxes` does not reliably enumerate PG sandboxes — use the sandbox directory + tree directly. +7. No dedicated replication role/slots — replicas stream as `postgres` itself with no password + (`trust`); no password is set for `postgres` by dbdeployer at all. + +None of the above break the dbdeployer path — every one has a confirmed, tested workaround above. +**Recommendation: proceed with Task 2a (dbdeployer), incorporating the post-deploy config-patch +step (§5) and the pg_hba widening step (§5) into the Dockerfile/entrypoint.** The native +`postgres:17` fallback (Task 2b) is not needed. From c4926e1eb4796f37087e2189c087392d052512eb Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 10:30:39 +0000 Subject: [PATCH 02/16] spike(pg-compat): fix doc gaps from review (deb ownership step, reserved-ports claim, final command block) --- test/pg-compat/SPIKE-dbdeployer-pg.md | 82 ++++++++++++++++++++------- 1 file changed, 63 insertions(+), 19 deletions(-) diff --git a/test/pg-compat/SPIKE-dbdeployer-pg.md b/test/pg-compat/SPIKE-dbdeployer-pg.md index 86eea9aba0..37b19d5879 100644 --- a/test/pg-compat/SPIKE-dbdeployer-pg.md +++ b/test/pg-compat/SPIKE-dbdeployer-pg.md @@ -68,12 +68,22 @@ apt-get install -y -qq --download-only --reinstall -o Dir::Cache::archives=/root # postgresql-17_17.10-1.pgdg22.04+1_amd64.deb # postgresql-client-17_17.10-1.pgdg22.04+1_amd64.deb -# Unpack via dbdeployer (as a NON-ROOT user — see §4) -cd /root/pgdebs -dbdeployer unpack --provider=postgresql \ +# IMPORTANT: the unpack must run as a NON-ROOT user (see §4), and /root is mode 700 — +# pguser cannot traverse into /root/pgdebs at all ("Permission denied"). Copy the debs +# to a pguser-owned location first. Exact commands used in this spike (run as root): +useradd -m -s /bin/bash pguser # the non-root user from §4 (create it first) +mkdir -p /home/pguser/pgdebs +cp /root/pgdebs/*.deb /home/pguser/pgdebs/ +chown -R pguser:pguser /home/pguser/pgdebs +# (Alternative: download straight to a world-readable dir, e.g. +# -o Dir::Cache::archives=/tmp/pgdebs above, and chown that — either way the .debs +# must end up readable at a path pguser can reach.) + +# Unpack via dbdeployer, as pguser (see §4) +su - pguser -c 'cd /home/pguser/pgdebs && dbdeployer unpack --provider=postgresql \ postgresql-17_17.10-1.pgdg22.04+1_amd64.deb \ - postgresql-client-17_17.10-1.pgdg22.04+1_amd64.deb -# => "PostgreSQL 17.10 unpacked to $HOME/opt/postgresql/17.10" + postgresql-client-17_17.10-1.pgdg22.04+1_amd64.deb' +# => "PostgreSQL 17.10 unpacked to /home/pguser/opt/postgresql/17.10" ``` PG version deployed: **17.10** (PGDG's latest 17.x for jammy at spike time 2026-07-08; 17.8/17.9 @@ -136,8 +146,13 @@ for PG (`unpack`, `deploy postgresql`, `deploy replication --provider=postgresql ```bash useradd -m -s /bin/bash pguser -# copy/own the downloaded .deb files, then, as pguser: -su - pguser -c 'dbdeployer unpack --provider=postgresql postgresql-17_17.10-1.pgdg22.04+1_amd64.deb postgresql-client-17_17.10-1.pgdg22.04+1_amd64.deb' +# Copy + chown the downloaded .deb files into pguser's home first — /root is mode 700, +# so pguser cannot read /root/pgdebs (exact commands in §2): +mkdir -p /home/pguser/pgdebs +cp /root/pgdebs/*.deb /home/pguser/pgdebs/ +chown -R pguser:pguser /home/pguser/pgdebs +# then, as pguser: +su - pguser -c 'cd /home/pguser/pgdebs && dbdeployer unpack --provider=postgresql postgresql-17_17.10-1.pgdg22.04+1_amd64.deb postgresql-client-17_17.10-1.pgdg22.04+1_amd64.deb' ``` Task 2's entrypoint/Dockerfile should either run the whole container as this user (`USER pguser` @@ -146,20 +161,45 @@ commands only, similar to how official `postgres` images drop privileges. ## 5. The exact WORKING `dbdeployer deploy replication` command -Run as `pguser` (see §4), after `dbdeployer defaults update reserved-ports '0'` (same -reserved-ports-clearing step as the MySQL GR image; **relevant for PG too since dbdeployer -reserves 5432 by default**): +### FINAL RECOMMENDED COMMAND (Task 2: copy this) + +Run as `pguser` (see §4). This is the minimal form with the no-op flags removed (see +"silently-ignored flags" below for why `--bind-address`/`--base-port`/`-c` are omitted — +they do nothing for the postgresql provider): + +```bash +dbdeployer deploy replication 17.10 \ + --provider=postgresql \ + --topology=master-slave \ + --nodes=3 +``` + +Config injection (`listen_addresses`, `shared_preload_libraries`, `max_connections`, +`pg_hba.conf`) happens **post-deploy** — see the two confirmed workaround blocks below; Task 2's +entrypoint must include both. + +On reserved-ports: the spike ran `dbdeployer defaults update reserved-ports '0'` before +deploying, carried over defensively from the MySQL GR recipe (dbdeployer's default reserved list +includes 5432). It was **NOT confirmed necessary for the postgresql provider** — PG ports are +auto-derived to 16710+ regardless of any port flags (see below), nowhere near the reserved list, +and a run without the step was not tested. Keep it or drop it in Task 2; do not treat it as a +proven requirement. + +### The command as actually run in the spike (annotated history) + +The spike's original invocation included the flags below; it succeeded, but the highlighted flags +were proven no-ops afterwards: ```bash dbdeployer deploy replication 17.10 \ --provider=postgresql \ --topology=master-slave \ --nodes=3 \ - --bind-address=0.0.0.0 \ - --base-port=5432 \ - -c listen_addresses=0.0.0.0 \ - -c max_connections=500 \ - -c shared_preload_libraries=pg_stat_statements + --bind-address=0.0.0.0 \ # IGNORED for postgresql provider + --base-port=5432 \ # IGNORED — ports land at 16710/16711/16712 + -c listen_addresses=0.0.0.0 \ # IGNORED + -c max_connections=500 \ # IGNORED + -c shared_preload_libraries=pg_stat_statements # IGNORED ``` Output: @@ -335,17 +375,21 @@ psql -h -p 16710 -U postgres -c "SELECT 1 AS remote_ok;" -- relocatable `sharedir`). 3. `initdb`/`postgres` refuse to run as root — the container must run PG-related dbdeployer commands as a non-root user (unlike the MySQL GR image, which runs entirely as root). -4. `--base-port`, `--bind-address`, `-c my-cnf-options`, and `--db-user`/`--db-password` are all +4. Because of (3), the downloaded `.deb`s must be readable by that non-root user before + `dbdeployer unpack` — `/root` is mode 700, so debs fetched under `/root/pgdebs` hit + "Permission denied". Copy + `chown` them into the user's home (exact commands in §2/§4), or + download them to a world-readable path in the first place. +5. `--base-port`, `--bind-address`, `-c my-cnf-options`, and `--db-user`/`--db-password` are all silently ignored for `--provider=postgresql`; config must be injected by editing `postgresql.conf`/`pg_hba.conf` directly and restarting/reloading. Ports are fixed at `15000 + major*100 + minor` (16710/16711/16712 for 17.10) — do not fight this, standardize on it. -5. `pg_hba.conf` defaults to loopback-only `trust` rules — must append a wider `host ... 0.0.0.0/0` +6. `pg_hba.conf` defaults to loopback-only `trust` rules — must append a wider `host ... 0.0.0.0/0` (or the actual Docker subnet) entry for cross-container reachability; `pg_reload_conf()` (no restart) is sufficient. -6. `dbdeployer sandboxes` does not reliably enumerate PG sandboxes — use the sandbox directory +7. `dbdeployer sandboxes` does not reliably enumerate PG sandboxes — use the sandbox directory tree directly. -7. No dedicated replication role/slots — replicas stream as `postgres` itself with no password +8. No dedicated replication role/slots — replicas stream as `postgres` itself with no password (`trust`); no password is set for `postgres` by dbdeployer at all. None of the above break the dbdeployer path — every one has a confirmed, tested workaround above. From c8cc4e667dca5f8c58b03aa932bcf197ee4f1605 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 10:46:24 +0000 Subject: [PATCH 03/16] infra(pg-compat): dbdeployer PG17 primary+2-replica infra with pg_stat_statements --- test/infra/infra-dbdeployer-pgsql17-repl/.env | 13 ++ .../bin/docker-pgsql-post.bash | 63 ++++++ .../bin/docker-proxy-post.bash | 12 ++ .../docker-compose-destroy.bash | 11 + .../docker-compose-init.bash | 153 ++++++++++++++ .../docker-compose.yml | 22 ++ .../docker/Dockerfile | 85 ++++++++ .../docker/build.sh | 8 + .../docker/entrypoint.sh | 196 ++++++++++++++++++ test/tap/groups/pg-compat/env.sh | 15 ++ test/tap/groups/pg-compat/infras.lst | 1 + 11 files changed, 579 insertions(+) create mode 100644 test/infra/infra-dbdeployer-pgsql17-repl/.env create mode 100755 test/infra/infra-dbdeployer-pgsql17-repl/bin/docker-pgsql-post.bash create mode 100755 test/infra/infra-dbdeployer-pgsql17-repl/bin/docker-proxy-post.bash create mode 100755 test/infra/infra-dbdeployer-pgsql17-repl/docker-compose-destroy.bash create mode 100755 test/infra/infra-dbdeployer-pgsql17-repl/docker-compose-init.bash create mode 100644 test/infra/infra-dbdeployer-pgsql17-repl/docker-compose.yml create mode 100644 test/infra/infra-dbdeployer-pgsql17-repl/docker/Dockerfile create mode 100755 test/infra/infra-dbdeployer-pgsql17-repl/docker/build.sh create mode 100755 test/infra/infra-dbdeployer-pgsql17-repl/docker/entrypoint.sh create mode 100644 test/tap/groups/pg-compat/env.sh create mode 100644 test/tap/groups/pg-compat/infras.lst diff --git a/test/infra/infra-dbdeployer-pgsql17-repl/.env b/test/infra/infra-dbdeployer-pgsql17-repl/.env new file mode 100644 index 0000000000..cfd8cc63c2 --- /dev/null +++ b/test/infra/infra-dbdeployer-pgsql17-repl/.env @@ -0,0 +1,13 @@ +PGSQL_VERSION=17.10 + +PREFIX=00 + +WHG=00 +RHG=01 + +# dbdeployer deploys all 3 PG nodes in ONE container; ports are auto-derived +# from the version (15000 + major*100 + minor) and fixed for 17.10. +PG_PRIMARY_HOST=dbdeployer1 +PG_PRIMARY_PORT=16710 +PG_REPLICA1_PORT=16711 +PG_REPLICA2_PORT=16712 diff --git a/test/infra/infra-dbdeployer-pgsql17-repl/bin/docker-pgsql-post.bash b/test/infra/infra-dbdeployer-pgsql17-repl/bin/docker-pgsql-post.bash new file mode 100755 index 0000000000..6fa1fe8c8c --- /dev/null +++ b/test/infra/infra-dbdeployer-pgsql17-repl/bin/docker-pgsql-post.bash @@ -0,0 +1,63 @@ +#!/bin/bash +# Host-side verification of the dbdeployer PostgreSQL replication backend. +# Provisioning (roles/databases/extension) happens inside the container's +# entrypoint; this script is VERIFICATION-ONLY. It asserts: +# - all 3 nodes are reachable and report the expected recovery state (f/t/t) +# - pg_stat_statements is queryable on every node +# - testuser can authenticate over TCP using its password +set -e +set -o pipefail +[ -f .env ] && . .env + +CONTAINER="${COMPOSE_PROJECT}-dbdeployer1-1" + +PRIMARY_PORT="${PG_PRIMARY_PORT:-16710}" +REPLICA1_PORT="${PG_REPLICA1_PORT:-16711}" +REPLICA2_PORT="${PG_REPLICA2_PORT:-16712}" + +# psql runs INSIDE the container (image bakes psql onto PATH); connect over +# loopback (trust) as postgres for the recovery/extension assertions. +pg() { + local port="$1"; shift + docker exec "${CONTAINER}" psql -h 127.0.0.1 -p "${port}" -U postgres -d postgres -tAc "$1" +} + +printf "[%s] PgSQL replication verification (Container: %s)\n" "$(date)" "${CONTAINER}" + +# 1. Reachability + recovery state (primary=f, replicas=t). +declare -A EXPECT=( ["${PRIMARY_PORT}"]="f" ["${REPLICA1_PORT}"]="t" ["${REPLICA2_PORT}"]="t" ) +for PORT in "${PRIMARY_PORT}" "${REPLICA1_PORT}" "${REPLICA2_PORT}"; do + echo -n " - node ${PORT}: waiting for connectivity..." + MAX_WAIT=60; COUNT=0 + while ! pg "${PORT}" "SELECT 1" >/dev/null 2>&1; do + if [ $COUNT -ge $MAX_WAIT ]; then echo " TIMEOUT"; exit 1; fi + echo -n "."; sleep 2; COUNT=$((COUNT + 2)) + done + REC=$(pg "${PORT}" "SELECT pg_is_in_recovery();") + if [ "${REC}" != "${EXPECT[$PORT]}" ]; then + echo " FAIL (pg_is_in_recovery=${REC}, expected ${EXPECT[$PORT]})" + exit 1 + fi + echo " OK (pg_is_in_recovery=${REC})" +done + +# 2. pg_stat_statements queryable on every node. +for PORT in "${PRIMARY_PORT}" "${REPLICA1_PORT}" "${REPLICA2_PORT}"; do + echo -n " - node ${PORT}: pg_stat_statements..." + if ! pg "${PORT}" "SELECT count(*) FROM pg_stat_statements;" >/dev/null 2>&1; then + echo " FAIL (pg_stat_statements not queryable)"; exit 1 + fi + echo " OK" +done + +# 3. testuser TCP password login against every node. +for PORT in "${PRIMARY_PORT}" "${REPLICA1_PORT}" "${REPLICA2_PORT}"; do + echo -n " - node ${PORT}: testuser TCP password login..." + if ! docker exec -e PGPASSWORD=testuser "${CONTAINER}" \ + psql -h 127.0.0.1 -p "${PORT}" -U testuser -d testuser -tAc "SELECT 1" >/dev/null 2>&1; then + echo " FAIL (testuser could not authenticate)"; exit 1 + fi + echo " OK" +done + +printf "[%s] PgSQL replication verification COMPLETE (f/t/t + pg_stat_statements + testuser OK)\n" "$(date)" diff --git a/test/infra/infra-dbdeployer-pgsql17-repl/bin/docker-proxy-post.bash b/test/infra/infra-dbdeployer-pgsql17-repl/bin/docker-proxy-post.bash new file mode 100755 index 0000000000..ffefe792ca --- /dev/null +++ b/test/infra/infra-dbdeployer-pgsql17-repl/bin/docker-proxy-post.bash @@ -0,0 +1,12 @@ +#!/bin/bash +# Placeholder: ProxySQL configuration for this PG replication backend is added +# in SP-2 Task 4 (pgsql_servers + pgsql_replication_hostgroups + monitor). Kept +# as a no-op so the control flow (docker-compose-init.bash / ensure-infras.bash) +# that unconditionally invokes ./bin/docker-proxy-post.bash succeeds today. +set -e +set -o pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +[ -f "${SCRIPT_DIR}/../.env" ] && . "${SCRIPT_DIR}/../.env" + +echo ">>> docker-proxy-post.bash (infra-dbdeployer-pgsql17-repl): no-op placeholder (ProxySQL config lands in SP-2 Task 4)." +exit 0 diff --git a/test/infra/infra-dbdeployer-pgsql17-repl/docker-compose-destroy.bash b/test/infra/infra-dbdeployer-pgsql17-repl/docker-compose-destroy.bash new file mode 100755 index 0000000000..cc903d8d53 --- /dev/null +++ b/test/infra/infra-dbdeployer-pgsql17-repl/docker-compose-destroy.bash @@ -0,0 +1,11 @@ +#!/bin/bash +set -e +set -o pipefail +pushd $(dirname $0) &>/dev/null +trap 'popd &>/dev/null' EXIT +set -a; . .env; set +a +export INFRA=${PWD##*/} +export COMPOSE_PROJECT="${INFRA}-${INFRA_ID}" + +echo "Destroying CI Infra Cluster '${INFRA}' (Project: ${COMPOSE_PROJECT})..." +docker compose -p "${COMPOSE_PROJECT}" down -v diff --git a/test/infra/infra-dbdeployer-pgsql17-repl/docker-compose-init.bash b/test/infra/infra-dbdeployer-pgsql17-repl/docker-compose-init.bash new file mode 100755 index 0000000000..9a0841c4f9 --- /dev/null +++ b/test/infra/infra-dbdeployer-pgsql17-repl/docker-compose-init.bash @@ -0,0 +1,153 @@ +#!/bin/bash +# RELIABLY CAPTURE INFRA_ID FROM ENVIRONMENT OR DIRECTORY NAME +if [ -z "${INFRA_ID}" ]; then + export INFRA_ID=$(basename $(dirname $(pwd)) | sed 's/infra-//; s/docker-//') +fi +# Final safety: if INFRA_ID is still empty or ".", use a default +if [ -z "${INFRA_ID}" ] || [ "${INFRA_ID}" = "." ]; then + export INFRA_ID="dev-$USER" +fi + +# Derive Workspace relative to script +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" +export WORKSPACE="${REPO_ROOT}" + +set -e +set -o pipefail + +# SUDO helper: empty if root +SUDO="" +if [ "$(id -u)" != "0" ]; then SUDO="sudo"; fi + +# relaunch self with timeout +[[ $(ps -o command= $(ps -o ppid= $$)) =~ timeout ]] || exec timeout -v -s 9 ${TIMEOUT:-600} "${BASH_SOURCE}" "$@" + +# make sure we have correct cwd +pushd $(dirname $0) &>/dev/null +trap 'popd &>/dev/null' EXIT + +# Load .env but ensure INFRA_ID is preserved +if [ ! -f .env ]; then echo "Error: .env not found"; exit 1; fi +SAVED_INFRA_ID="${INFRA_ID}" +set -a; . .env; set +a +export INFRA_ID="${SAVED_INFRA_ID}" + +# Docker Compose version helper - prefer plugin (v2) +COMPOSE_CMD="docker compose" +if ! $COMPOSE_CMD version &>/dev/null; then + COMPOSE_CMD="docker-compose" + if ! $COMPOSE_CMD version &>/dev/null; then + echo "ERROR: Neither 'docker compose' nor 'docker-compose' found!" + exit 1 + fi +fi + +if [ -z "${INFRA_ID}" ]; then echo "Error: INFRA_ID must be set"; exit 1; fi + +export ROOT_PASSWORD=$(echo -n "${INFRA_ID}" | sha256sum | head -c 10) +export INFRA=${PWD##*/} +export COMPOSE_PROJECT="${INFRA}-${INFRA_ID}" +export INFRA_LOGS_PATH=${INFRA_LOGS_PATH:-${WORKSPACE}/ci_infra_logs} + +echo "================================================================================" +echo "Initializing CI Infra '${INFRA}' (Project: ${COMPOSE_PROJECT}) ..." +echo "================================================================================" + +# 1. VERIFY NO EXISTING CONTAINERS ARE RUNNING FOR THIS PROJECT +if [ -n "$($COMPOSE_CMD -p "${COMPOSE_PROJECT}" ps -q 2>/dev/null)" ]; then + echo "ERROR: Containers for project ${COMPOSE_PROJECT} are already running." + echo "Please run teardown first." + exit 1 +fi + +# 2. Infrastructure-specific preparation (logs/data) +# We extract host paths that appear to be for logs or data. +echo "Scanning for volumes in docker-compose.yml..." +# CRITICAL: Exclude .crt and .key files from auto-mkdir logic to prevent "directory vs file" conflicts +MOUNTED_PATHS=$(grep -E '\$\{INFRA_LOGS_PATH\}|\./log/' docker-compose.yml | grep -vE "\.crt|\.key" | awk -F: '{print $1}' | sed 's/^[[:space:]-]*//' | sort -u || true) + +for RAW_PATH in ${MOUNTED_PATHS}; do + # Skip relative paths that point to config files (e.g. ./conf/...) + if [[ "${RAW_PATH}" == "./conf/"* ]]; then continue; fi + + # Expand variables like ${INFRA_LOGS_PATH} and ${COMPOSE_PROJECT} + eval "ACTUAL_PATH=${RAW_PATH}" + + # Safety: Refuse to proceed if ACTUAL_PATH is a directory and is not empty + if [ -d "${ACTUAL_PATH}" ] && [ "$(ls -A "${ACTUAL_PATH}" 2>/dev/null)" ]; then + echo "ERROR: Directory '${ACTUAL_PATH}' is not empty." + echo "Please run teardown/cleanup first." + exit 1 + fi + + echo "Preparing directory: ${ACTUAL_PATH}" + $SUDO mkdir -p "${ACTUAL_PATH}" + $SUDO chmod -R 777 "${ACTUAL_PATH}" + + # Aggressive postgres fix: UID 999 + if [[ "${ACTUAL_PATH}" == *pgsql* ]] || [[ "${ACTUAL_PATH}" == *pgdb* ]]; then + echo "Applying postgres ownership (999:999) to ${ACTUAL_PATH}" + $SUDO chown -R 999:999 "${ACTUAL_PATH}" + fi +done + +# 3. Create a temporary env file for docker-compose to ensure it sees our variables +ENV_FILE=".env.isolated.${INFRA_ID}" +cat < "${ENV_FILE}" +INFRA_ID=${INFRA_ID} +ROOT_PASSWORD=${ROOT_PASSWORD} +INFRA=${INFRA} +COMPOSE_PROJECT=${COMPOSE_PROJECT} +INFRA_LOGS_PATH=${INFRA_LOGS_PATH} +ENVEOF + +# 4. START CONTAINERS +if ! $COMPOSE_CMD --env-file .env --env-file "${ENV_FILE}" -p "${COMPOSE_PROJECT}" up -d; then + echo "ERROR: Docker Compose failed"; rm -f "${ENV_FILE}"; exit 1 +fi +rm -f "${ENV_FILE}" + +# 5. VERIFY ALL CONTAINERS STARTED SUCCESSFULLY +echo "Verifying container health..." +PROJECT_CONTAINERS=$($COMPOSE_CMD -p "${COMPOSE_PROJECT}" ps --format '{{.Name}}') +for C in ${PROJECT_CONTAINERS}; do + STATE=$(docker inspect -f '{{.State.Running}}' "${C}" 2>/dev/null || echo "false") + if [ "${STATE}" != "true" ]; then + echo -e "\nERROR: Container ${C} failed to start!" + echo ">>> Container Logs:" + docker logs "${C}" | tail -n 50 + exit 1 + fi +done + +if [ -f /.dockerenv ]; then + RUNNER_ID=$(hostname) + docker network connect "${INFRA_ID}_backend" "${RUNNER_ID}" || true +fi + +# 6. Wait for dbdeployer entrypoint to finish MySQL deployment +CONTAINER="${COMPOSE_PROJECT}-dbdeployer1-1" +echo -n "Waiting for dbdeployer to finish deployment..." +MAX_WAIT=120 +COUNT=0 +while ! docker exec "${CONTAINER}" test -f /tmp/dbdeployer_ready 2>/dev/null; do + if [ $COUNT -ge $MAX_WAIT ]; then + echo " TIMEOUT" + echo ">>> Container Logs:" + docker logs "${CONTAINER}" | tail -n 50 + exit 1 + fi + echo -n "." + sleep 2 + COUNT=$((COUNT + 2)) +done +echo " OK" + +# 7. Run post-scripts if they exist +[ -f ./bin/docker-pgsql-post.bash ] && ./bin/docker-pgsql-post.bash +[ -f ./bin/docker-proxy-post.bash ] && ./bin/docker-proxy-post.bash "$1" + +echo "================================================================================" +echo "Done." +echo "================================================================================" diff --git a/test/infra/infra-dbdeployer-pgsql17-repl/docker-compose.yml b/test/infra/infra-dbdeployer-pgsql17-repl/docker-compose.yml new file mode 100644 index 0000000000..c93ef81310 --- /dev/null +++ b/test/infra/infra-dbdeployer-pgsql17-repl/docker-compose.yml @@ -0,0 +1,22 @@ +services: + + dbdeployer1: + hostname: dbdeployer1.${INFRA} + image: proxysql/ci-infra:dbdeployer-pgsql17-repl + container_name: ${COMPOSE_PROJECT}-dbdeployer1-1 + environment: + - ROOT_PASSWORD=${ROOT_PASSWORD} + - INFRA=${INFRA} + networks: + backend: + aliases: + - dbdeployer1.${INFRA} + - dbdeployer1.infra-dbdeployer-pgsql17-repl + # env.sh publishes PGCOMPAT_*_HOST=dbdeployer1.${INFRA_ID}; expose that + # name so downstream SP-2 tasks (harness/ProxySQL) resolve the backend. + - dbdeployer1.${INFRA_ID} + +networks: + backend: + name: "${INFRA_ID}_backend" + external: true diff --git a/test/infra/infra-dbdeployer-pgsql17-repl/docker/Dockerfile b/test/infra/infra-dbdeployer-pgsql17-repl/docker/Dockerfile new file mode 100644 index 0000000000..d6dee385e6 --- /dev/null +++ b/test/infra/infra-dbdeployer-pgsql17-repl/docker/Dockerfile @@ -0,0 +1,85 @@ +FROM ubuntu:22.04 + +ARG DBDEPLOYER_VERSION=2.2.1 + +# PostgreSQL point release to bake in. Pinned so the dbdeployer-derived ports +# stay fixed at 16710/16711/16712 (ports = 15000 + major*100 + minor, per the +# Task 1 spike). If PGDG retires this exact point release, bump all three of +# PG_VERSION/PG_DEB_VERSION and the ports in .env / env.sh together. +ARG PG_MAJOR=17 +ARG PG_VERSION=17.10 +ARG PG_DEB_VERSION=17.10-1.pgdg22.04+1 + +ENV DEBIAN_FRONTEND=noninteractive + +# Base tooling: dbdeployer install (curl|tar), PGDG repo setup (gnupg/wget), +# and su/psql helpers. PostgreSQL runtime shared libraries are installed later +# from the downloaded PGDG .debs (the Debian PG binaries are NOT self-contained). +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + wget \ + gnupg \ + lsb-release \ + && rm -rf /var/lib/apt/lists/* + +# Install dbdeployer (ProxySQL fork release; tarball member is already named +# 'dbdeployer', no rename needed). +RUN curl -fsSL "https://github.com/ProxySQL/dbdeployer/releases/download/v${DBDEPLOYER_VERSION}/dbdeployer-${DBDEPLOYER_VERSION}.linux_amd64.tar.gz" \ + | tar -xz -C /usr/local/bin/ \ + && chmod +x /usr/local/bin/dbdeployer + +# Add the PGDG apt repo (ubuntu:22.04 = jammy). +RUN install -d /usr/share/postgresql-common/pgdg \ + && wget -q -O /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc \ + https://www.postgresql.org/media/keys/ACCC4CF8.asc \ + && echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt jammy-pgdg main" \ + > /etc/apt/sources.list.d/pgdg.list + +# Non-root user: initdb / postgres refuse to run as root, so every dbdeployer +# PG command (unpack at build time, deploy at runtime) runs as pguser. +RUN useradd -m -s /bin/bash pguser + +# Download the PGDG .debs (raw packages, NOT a system install of PG) plus all +# their dependency .debs into /root/pgdebs. dbdeployer unpack consumes the two +# server/client .debs directly; the dependency .debs supply the runtime shared +# libraries the Debian PG binaries need (libpq5, libicu70, libllvm15, tzdata, ...). +RUN mkdir -p /root/pgdebs \ + && apt-get update \ + && apt-get install -y -qq --download-only --reinstall -o Dir::Cache::archives=/root/pgdebs \ + postgresql-${PG_MAJOR}=${PG_DEB_VERSION} \ + postgresql-client-${PG_MAJOR}=${PG_DEB_VERSION} \ + postgresql-common postgresql-client-common \ + # Install every downloaded dependency .deb (the shared libs + apt housekeeping + # packages) system-wide, but NOT the two main postgresql server/client packages + # -- those must be unpacked by dbdeployer into pguser's home instead. + && DEP_DEBS=$(ls /root/pgdebs/*.deb | grep -vE "/(postgresql-${PG_MAJOR}|postgresql-client-${PG_MAJOR})_[0-9]") \ + && apt-get install -y -qq ${DEP_DEBS} \ + && ldconfig \ + && rm -rf /var/lib/apt/lists/* + +# Copy the two main .debs to a pguser-readable path (/root is mode 700) and +# unpack them via dbdeployer as pguser -> /home/pguser/opt/postgresql//. +RUN mkdir -p /home/pguser/pgdebs \ + && cp /root/pgdebs/postgresql-${PG_MAJOR}_*.deb /home/pguser/pgdebs/ \ + && cp /root/pgdebs/postgresql-client-${PG_MAJOR}_*.deb /home/pguser/pgdebs/ \ + && chown -R pguser:pguser /home/pguser/pgdebs \ + && su - pguser -c "cd /home/pguser/pgdebs && dbdeployer unpack --provider=postgresql postgresql-${PG_MAJOR}_*.deb postgresql-client-${PG_MAJOR}_*.deb" + +# The Debian PG build's compiled-in sharedir (/usr/share/postgresql/) is +# NOT relocatable; symlink it to the unpacked share tree so initdb finds its +# timezonesets / templates. +RUN mkdir -p /usr/share/postgresql \ + && ln -sf "/home/pguser/opt/postgresql/${PG_VERSION}/share/postgresql/${PG_MAJOR}" \ + "/usr/share/postgresql/${PG_MAJOR}" + +# Make the unpacked psql/pg_isready available on PATH for docker-exec health checks. +ENV PATH="/home/pguser/opt/postgresql/17.10/bin:${PATH}" + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# dbdeployer auto-derives PG ports for 17.10 as 16710 (primary), 16711/16712 (replicas). +EXPOSE 16710 16711 16712 + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/test/infra/infra-dbdeployer-pgsql17-repl/docker/build.sh b/test/infra/infra-dbdeployer-pgsql17-repl/docker/build.sh new file mode 100755 index 0000000000..3986f2cf13 --- /dev/null +++ b/test/infra/infra-dbdeployer-pgsql17-repl/docker/build.sh @@ -0,0 +1,8 @@ +#!/bin/bash +set -e +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +IMAGE_TAG="${1:-proxysql/ci-infra:dbdeployer-pgsql17-repl}" + +echo "Building Docker image: ${IMAGE_TAG}" +docker build --network=host -t "${IMAGE_TAG}" -f "${SCRIPT_DIR}/Dockerfile" "${SCRIPT_DIR}" +echo "Done: ${IMAGE_TAG}" diff --git a/test/infra/infra-dbdeployer-pgsql17-repl/docker/entrypoint.sh b/test/infra/infra-dbdeployer-pgsql17-repl/docker/entrypoint.sh new file mode 100755 index 0000000000..0fce4b7cf9 --- /dev/null +++ b/test/infra/infra-dbdeployer-pgsql17-repl/docker/entrypoint.sh @@ -0,0 +1,196 @@ +#!/bin/bash +set -e +set -o pipefail + +echo "========================================================================" +echo "dbdeployer entrypoint: deploying PostgreSQL 17 streaming replication" +echo " (1 primary + 2 replicas, master-slave topology)" +echo "========================================================================" + +# Passed in via docker-compose environment. +ROOT_PASSWORD="${ROOT_PASSWORD:-default_password}" +INFRA="${INFRA:-infra-dbdeployer-pgsql17-repl}" + +# --------------------------------------------------------------------------- +# 1. Detect the pre-baked PostgreSQL version (unpacked at image-build time). +# --------------------------------------------------------------------------- +PG_VERSION=$(ls /home/pguser/opt/postgresql/ 2>/dev/null | head -1) +if [ -z "${PG_VERSION}" ]; then + echo "ERROR: No unpacked PostgreSQL found in /home/pguser/opt/postgresql/" + exit 1 +fi +echo "Using PostgreSQL version: ${PG_VERSION}" + +# --------------------------------------------------------------------------- +# 2. Deploy the replication sandbox as pguser (initdb refuses to run as root). +# --base-port / --bind-address / -c are silently ignored by the postgresql +# provider (spike §5); ports are auto-derived to 16710/16711/16712 for 17.10. +# --------------------------------------------------------------------------- +su - pguser -c "dbdeployer deploy replication ${PG_VERSION} --provider=postgresql --topology=master-slave --nodes=3" + +# Locate the sandbox tree (dbdeployer sandboxes command is unreliable for PG; +# enumerate the directory instead -- spike §6). +SBASE=$(ls -d /home/pguser/sandboxes/postgresql_repl_* 2>/dev/null | head -1) +if [ -z "${SBASE}" ]; then + echo "ERROR: PostgreSQL replication sandbox directory not found" + exit 1 +fi +echo "Sandbox directory: ${SBASE}" + +# Derive the port map from the sandbox dir name (postgresql_repl_). +BASE_PORT="$(basename "${SBASE}")" +BASE_PORT="${BASE_PORT##*_}" +PRIMARY_PORT="${BASE_PORT}" +REPLICA1_PORT="$((BASE_PORT + 1))" +REPLICA2_PORT="$((BASE_PORT + 2))" +echo "Ports -> primary=${PRIMARY_PORT} replica1=${REPLICA1_PORT} replica2=${REPLICA2_PORT}" + +# --------------------------------------------------------------------------- +# 3. Post-deploy config injection (spike §5): the provider ignores -c flags, so +# append overrides directly to each node's postgresql.conf, widen pg_hba.conf +# for cross-container access, then restart each node. +# --------------------------------------------------------------------------- +echo "Injecting postgresql.conf / pg_hba.conf overrides on all 3 nodes..." +for n in primary replica1 replica2; do + echo " - stopping ${n}" + su - pguser -c "'${SBASE}/${n}/stop'" + + cat >> "${SBASE}/${n}/data/postgresql.conf" <= 10), so real password auth is exercised for app roles. + # Replication connections keep 'trust' (replicas stream as postgres with no + # password via primary_conninfo) so setting the postgres password below does + # not break streaming. + cat >> "${SBASE}/${n}/data/pg_hba.conf" </dev/null 2>&1; do + if [ $COUNT -ge $MAX_WAIT ]; then echo " TIMEOUT"; exit 1; fi + echo -n "."; sleep 1; COUNT=$((COUNT + 1)) + done + echo " OK" +done + +# --------------------------------------------------------------------------- +# 5. Wait for streaming replication to be established. +# primary: pg_is_in_recovery()=f AND 2 streaming walsenders +# replicas: pg_is_in_recovery()=t +# --------------------------------------------------------------------------- +echo -n "Waiting for replication to stream..." +MAX_WAIT=60; COUNT=0 +while true; do + PRIM_REC=$(psql -h 127.0.0.1 -p "${PRIMARY_PORT}" -U postgres -d postgres -tAc "SELECT pg_is_in_recovery();" 2>/dev/null || echo "err") + R1_REC=$(psql -h 127.0.0.1 -p "${REPLICA1_PORT}" -U postgres -d postgres -tAc "SELECT pg_is_in_recovery();" 2>/dev/null || echo "err") + R2_REC=$(psql -h 127.0.0.1 -p "${REPLICA2_PORT}" -U postgres -d postgres -tAc "SELECT pg_is_in_recovery();" 2>/dev/null || echo "err") + STREAMING=$(psql -h 127.0.0.1 -p "${PRIMARY_PORT}" -U postgres -d postgres -tAc \ + "SELECT count(*) FROM pg_stat_replication WHERE state='streaming';" 2>/dev/null || echo "0") + if [ "${PRIM_REC}" = "f" ] && [ "${R1_REC}" = "t" ] && [ "${R2_REC}" = "t" ] && [ "${STREAMING}" = "2" ]; then + echo " OK (primary=f, replicas=t, ${STREAMING} streaming)" + break + fi + if [ $COUNT -ge $MAX_WAIT ]; then + echo " TIMEOUT (primary=${PRIM_REC} r1=${R1_REC} r2=${R2_REC} streaming=${STREAMING})" + exit 1 + fi + echo -n "."; sleep 2; COUNT=$((COUNT + 2)) +done + +# --------------------------------------------------------------------------- +# 6. Provision roles / databases / extension on the PRIMARY only. +# Physical streaming replication propagates all of this byte-for-byte to the +# replicas (spike §8; brief: "creating on primary is sufficient"). +# --------------------------------------------------------------------------- +echo "Provisioning roles / databases / pg_stat_statements on the primary..." +${PSQL_PRIMARY} -v ON_ERROR_STOP=1 <= 0 FROM pg_stat_statements;" 2>/dev/null || echo "f") + R2_OK=$(psql -h 127.0.0.1 -p "${REPLICA2_PORT}" -U postgres -d postgres -tAc \ + "SELECT count(*) >= 0 FROM pg_stat_statements;" 2>/dev/null || echo "f") + if [ "${R1_OK}" = "t" ] && [ "${R2_OK}" = "t" ]; then echo " OK"; break; fi + if [ $COUNT -ge $MAX_WAIT ]; then + echo " TIMEOUT (replica1=${R1_OK} replica2=${R2_OK})"; exit 1 + fi + echo -n "."; sleep 2; COUNT=$((COUNT + 2)) +done + +# --------------------------------------------------------------------------- +# 8. Signal readiness (consumed by docker-compose-init.bash). +# --------------------------------------------------------------------------- +touch /tmp/dbdeployer_ready + +echo "========================================================================" +echo "dbdeployer PostgreSQL 17 replication is ready." +echo " primary : port ${PRIMARY_PORT} (pg_is_in_recovery = f)" +echo " replica1 : port ${REPLICA1_PORT} (pg_is_in_recovery = t)" +echo " replica2 : port ${REPLICA2_PORT} (pg_is_in_recovery = t)" +echo " roles : testuser/testuser (LOGIN CREATEDB), monitor/monitor," +echo " postgres/" +echo " pg_stat_statements: preloaded + created in postgres + testuser DBs" +echo "========================================================================" + +# Keep the container alive. +exec sleep infinity diff --git a/test/tap/groups/pg-compat/env.sh b/test/tap/groups/pg-compat/env.sh new file mode 100644 index 0000000000..fa28d100f4 --- /dev/null +++ b/test/tap/groups/pg-compat/env.sh @@ -0,0 +1,15 @@ +# pg-compat TAP group environment (SP-2 polyglot PG test foundation). +# +# The backend is the dbdeployer PG17 primary + 2-replica infra, which deploys +# all three nodes in ONE container (single hostname, three ports). Downstream +# SP-2 tasks (Toxiproxy, ProxySQL config, pytest harness) consume THESE vars. + +export INFRA_TYPE="infra-dbdeployer-pgsql17-repl" + +# Single container -> one host, three auto-derived ports (17.10 => 16710-16712). +export PGCOMPAT_PRIMARY_HOST="dbdeployer1.${INFRA_ID}" +export PGCOMPAT_PRIMARY_PORT="16710" +export PGCOMPAT_REPLICA1_HOST="dbdeployer1.${INFRA_ID}" +export PGCOMPAT_REPLICA1_PORT="16711" +export PGCOMPAT_REPLICA2_HOST="dbdeployer1.${INFRA_ID}" +export PGCOMPAT_REPLICA2_PORT="16712" diff --git a/test/tap/groups/pg-compat/infras.lst b/test/tap/groups/pg-compat/infras.lst new file mode 100644 index 0000000000..46ae11b621 --- /dev/null +++ b/test/tap/groups/pg-compat/infras.lst @@ -0,0 +1 @@ +infra-dbdeployer-pgsql17-repl From 218e552c0174ccabf02772fb03943dabbaddd06f Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 10:54:26 +0000 Subject: [PATCH 04/16] infra(pg-compat): parameterize Dockerfile PATH on PG_VERSION + review minors --- .../docker-compose-init.bash | 2 +- .../infra-dbdeployer-pgsql17-repl/docker/Dockerfile | 9 ++++++--- .../infra-dbdeployer-pgsql17-repl/docker/entrypoint.sh | 8 ++++++-- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/test/infra/infra-dbdeployer-pgsql17-repl/docker-compose-init.bash b/test/infra/infra-dbdeployer-pgsql17-repl/docker-compose-init.bash index 9a0841c4f9..84e75b544d 100755 --- a/test/infra/infra-dbdeployer-pgsql17-repl/docker-compose-init.bash +++ b/test/infra/infra-dbdeployer-pgsql17-repl/docker-compose-init.bash @@ -126,7 +126,7 @@ if [ -f /.dockerenv ]; then docker network connect "${INFRA_ID}_backend" "${RUNNER_ID}" || true fi -# 6. Wait for dbdeployer entrypoint to finish MySQL deployment +# 6. Wait for dbdeployer entrypoint to finish PostgreSQL deployment CONTAINER="${COMPOSE_PROJECT}-dbdeployer1-1" echo -n "Waiting for dbdeployer to finish deployment..." MAX_WAIT=120 diff --git a/test/infra/infra-dbdeployer-pgsql17-repl/docker/Dockerfile b/test/infra/infra-dbdeployer-pgsql17-repl/docker/Dockerfile index d6dee385e6..7438b52efa 100644 --- a/test/infra/infra-dbdeployer-pgsql17-repl/docker/Dockerfile +++ b/test/infra/infra-dbdeployer-pgsql17-repl/docker/Dockerfile @@ -5,7 +5,8 @@ ARG DBDEPLOYER_VERSION=2.2.1 # PostgreSQL point release to bake in. Pinned so the dbdeployer-derived ports # stay fixed at 16710/16711/16712 (ports = 15000 + major*100 + minor, per the # Task 1 spike). If PGDG retires this exact point release, bump all three of -# PG_VERSION/PG_DEB_VERSION and the ports in .env / env.sh together. +# PG_VERSION/PG_DEB_VERSION and the ports in .env / env.sh together (the ENV +# PATH below auto-follows PG_VERSION). ARG PG_MAJOR=17 ARG PG_VERSION=17.10 ARG PG_DEB_VERSION=17.10-1.pgdg22.04+1 @@ -73,8 +74,10 @@ RUN mkdir -p /usr/share/postgresql \ && ln -sf "/home/pguser/opt/postgresql/${PG_VERSION}/share/postgresql/${PG_MAJOR}" \ "/usr/share/postgresql/${PG_MAJOR}" -# Make the unpacked psql/pg_isready available on PATH for docker-exec health checks. -ENV PATH="/home/pguser/opt/postgresql/17.10/bin:${PATH}" +# Make the unpacked psql/pg_isready available on PATH for docker-exec health +# checks. ${PG_VERSION} (build ARG, in scope in this stage) is expanded at build +# time, so the baked ENV holds the concrete versioned path. +ENV PATH="/home/pguser/opt/postgresql/${PG_VERSION}/bin:${PATH}" COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/test/infra/infra-dbdeployer-pgsql17-repl/docker/entrypoint.sh b/test/infra/infra-dbdeployer-pgsql17-repl/docker/entrypoint.sh index 0fce4b7cf9..ffeb6c1d90 100755 --- a/test/infra/infra-dbdeployer-pgsql17-repl/docker/entrypoint.sh +++ b/test/infra/infra-dbdeployer-pgsql17-repl/docker/entrypoint.sh @@ -7,9 +7,10 @@ echo "dbdeployer entrypoint: deploying PostgreSQL 17 streaming replication" echo " (1 primary + 2 replicas, master-slave topology)" echo "========================================================================" -# Passed in via docker-compose environment. +# Passed in via docker-compose environment. (Unlike the MySQL GR reference, +# INFRA is not needed here: PG has no report_host equivalent and no per-infra +# role is provisioned.) ROOT_PASSWORD="${ROOT_PASSWORD:-default_password}" -INFRA="${INFRA:-infra-dbdeployer-pgsql17-repl}" # --------------------------------------------------------------------------- # 1. Detect the pre-baked PostgreSQL version (unpacked at image-build time). @@ -150,6 +151,9 @@ ${PSQL_PRIMARY} -v ON_ERROR_STOP=1 -c "SET client_min_messages='error';" \ -c "CREATE DATABASE testuser OWNER testuser;" psql -h 127.0.0.1 -p "${PRIMARY_PORT}" -U postgres -d testuser -v ON_ERROR_STOP=1 \ -c "SET client_min_messages='error';" -c "GRANT ALL ON SCHEMA public TO testuser;" +# Intentional second grant: the same grant against the 'postgres' database's +# public schema, since some tests use 'postgres' as their default DB (mirrors +# docker-pgsql16-single/bin/docker-pgsql-post.bash, which grants on both). ${PSQL_PRIMARY} -v ON_ERROR_STOP=1 \ -c "SET client_min_messages='error';" -c "GRANT ALL ON SCHEMA public TO testuser;" From 230fbea1cabcd240646d4332599842996d7a90e5 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 10:58:58 +0000 Subject: [PATCH 05/16] infra(pg-compat): Toxiproxy sidecar with per-backend passthrough proxies --- .../bin/toxiproxy-bootstrap.sh | 68 +++++++++++++++++++ .../docker-compose-init.bash | 12 ++++ .../docker-compose.yml | 13 ++++ test/tap/groups/pg-compat/env.sh | 10 +++ 4 files changed, 103 insertions(+) create mode 100755 test/infra/infra-dbdeployer-pgsql17-repl/bin/toxiproxy-bootstrap.sh diff --git a/test/infra/infra-dbdeployer-pgsql17-repl/bin/toxiproxy-bootstrap.sh b/test/infra/infra-dbdeployer-pgsql17-repl/bin/toxiproxy-bootstrap.sh new file mode 100755 index 0000000000..9c6a9df34a --- /dev/null +++ b/test/infra/infra-dbdeployer-pgsql17-repl/bin/toxiproxy-bootstrap.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Create one passthrough Toxiproxy proxy per PG backend (primary/replica1/ +# replica2). No toxics are added here -- SP-4's chaos suite adds those later. +# +# Idempotent: safe to re-run against an already-bootstrapped toxiproxy (each +# proxy is deleted first, 404-on-delete is tolerated, and a 409 on create is +# treated as "already exists" -> success). +# +# The toxiproxy:2.9.0 image ships no shell/curl, so the admin HTTP API (port +# 8474) is driven from a throwaway curl container attached to the same +# Docker network as toxiproxy and the backend. +set -euo pipefail + +: "${INFRA_ID:?INFRA_ID must be set}" + +NETWORK="${INFRA_ID}_backend" +TOXI_HOST="toxiproxy.${INFRA_ID}" +TOXI_ADMIN="${TOXI_HOST}:8474" +UPSTREAM_HOST="dbdeployer1.${INFRA_ID}" +CURL_IMAGE="curlimages/curl:8.10.1" + +curl_in_net() { + docker run --rm --network "${NETWORK}" "${CURL_IMAGE}" "$@" +} + +echo ">>> toxiproxy-bootstrap: waiting for Toxiproxy admin API at ${TOXI_ADMIN}..." +MAX_WAIT=60 +COUNT=0 +until curl_in_net -fsS -o /dev/null "http://${TOXI_ADMIN}/version"; do + if [ "${COUNT}" -ge "${MAX_WAIT}" ]; then + echo "ERROR: Toxiproxy admin API at ${TOXI_ADMIN} did not become reachable within ${MAX_WAIT}s." + exit 1 + fi + echo -n "." + sleep 2 + COUNT=$((COUNT + 2)) +done +echo " OK" + +mk() { # name listen_port upstream_port + local name="$1" port="$2" upstream_port="$3" + local body="{\"name\":\"${name}\",\"listen\":\"0.0.0.0:${port}\",\"upstream\":\"${UPSTREAM_HOST}:${upstream_port}\",\"enabled\":true}" + + echo -n " - proxy ${name} (0.0.0.0:${port} -> ${UPSTREAM_HOST}:${upstream_port})..." + + # Delete any pre-existing proxy of the same name; tolerate 404 (doesn't exist yet). + curl_in_net -sS -o /dev/null -XDELETE "http://${TOXI_ADMIN}/proxies/${name}" || true + + local http_code + http_code=$(curl_in_net -sS -o /tmp/toxi_create_resp -w '%{http_code}' \ + -XPOST "http://${TOXI_ADMIN}/proxies" -d "${body}" 2>/dev/null || echo "000") + + if [ "${http_code}" = "200" ] || [ "${http_code}" = "201" ] || [ "${http_code}" = "409" ]; then + echo " OK (${http_code})" + else + echo " FAIL (HTTP ${http_code})" + exit 1 + fi +} + +mk pg_primary 6001 16710 +mk pg_replica1 6002 16711 +mk pg_replica2 6003 16712 + +echo ">>> toxiproxy-bootstrap: verifying proxy list..." +curl_in_net -fsS "http://${TOXI_ADMIN}/proxies" +echo +echo ">>> toxiproxy-bootstrap: done." diff --git a/test/infra/infra-dbdeployer-pgsql17-repl/docker-compose-init.bash b/test/infra/infra-dbdeployer-pgsql17-repl/docker-compose-init.bash index 84e75b544d..b46888cc50 100755 --- a/test/infra/infra-dbdeployer-pgsql17-repl/docker-compose-init.bash +++ b/test/infra/infra-dbdeployer-pgsql17-repl/docker-compose-init.bash @@ -146,6 +146,18 @@ echo " OK" # 7. Run post-scripts if they exist [ -f ./bin/docker-pgsql-post.bash ] && ./bin/docker-pgsql-post.bash + +# 7b. Bootstrap the Toxiproxy passthrough proxies (one per backend node), now +# that the backend is up and verified. Must succeed -- a bootstrap failure +# fails init loudly, since later SP-2 tasks (ProxySQL config, chaos suite) +# depend on these proxies existing. +if [ -f ./bin/toxiproxy-bootstrap.sh ]; then + if ! INFRA_ID="${INFRA_ID}" ./bin/toxiproxy-bootstrap.sh; then + echo "ERROR: toxiproxy-bootstrap.sh failed." + exit 1 + fi +fi + [ -f ./bin/docker-proxy-post.bash ] && ./bin/docker-proxy-post.bash "$1" echo "================================================================================" diff --git a/test/infra/infra-dbdeployer-pgsql17-repl/docker-compose.yml b/test/infra/infra-dbdeployer-pgsql17-repl/docker-compose.yml index c93ef81310..d010f3e6fa 100644 --- a/test/infra/infra-dbdeployer-pgsql17-repl/docker-compose.yml +++ b/test/infra/infra-dbdeployer-pgsql17-repl/docker-compose.yml @@ -16,6 +16,19 @@ services: # name so downstream SP-2 tasks (harness/ProxySQL) resolve the backend. - dbdeployer1.${INFRA_ID} + toxiproxy: + hostname: toxiproxy.${INFRA} + image: ghcr.io/shopify/toxiproxy:2.9.0 + container_name: ${COMPOSE_PROJECT}-toxiproxy-1 + command: ["-host", "0.0.0.0"] + networks: + backend: + aliases: + - toxiproxy.${INFRA} + # env.sh publishes PGCOMPAT_TOXI_*_HOST=toxiproxy.${INFRA_ID}; expose + # that name so downstream SP-2 tasks (harness/ProxySQL) resolve it. + - toxiproxy.${INFRA_ID} + networks: backend: name: "${INFRA_ID}_backend" diff --git a/test/tap/groups/pg-compat/env.sh b/test/tap/groups/pg-compat/env.sh index fa28d100f4..621ef8626b 100644 --- a/test/tap/groups/pg-compat/env.sh +++ b/test/tap/groups/pg-compat/env.sh @@ -13,3 +13,13 @@ export PGCOMPAT_REPLICA1_HOST="dbdeployer1.${INFRA_ID}" export PGCOMPAT_REPLICA1_PORT="16711" export PGCOMPAT_REPLICA2_HOST="dbdeployer1.${INFRA_ID}" export PGCOMPAT_REPLICA2_PORT="16712" + +# Toxiproxy sidecar sits between ProxySQL and each PG backend node so later +# SP-2/SP-4 tasks can degrade backends individually (passthrough only today). +export PGCOMPAT_TOXI_ADMIN="toxiproxy.${INFRA_ID}:8474" +export PGCOMPAT_TOXI_PRIMARY_HOST="toxiproxy.${INFRA_ID}" +export PGCOMPAT_TOXI_PRIMARY_PORT="6001" +export PGCOMPAT_TOXI_REPLICA1_HOST="toxiproxy.${INFRA_ID}" +export PGCOMPAT_TOXI_REPLICA1_PORT="6002" +export PGCOMPAT_TOXI_REPLICA2_HOST="toxiproxy.${INFRA_ID}" +export PGCOMPAT_TOXI_REPLICA2_PORT="6003" From f738a401fb904cc13d1ae08c1772d6ce5a01fd04 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 11:11:10 +0000 Subject: [PATCH 06/16] config(pg-compat): automatic pgsql_replication_hostgroups via Toxiproxy Fill in infra-dbdeployer-pgsql17-repl/bin/docker-proxy-post.bash (was a no-op placeholder) and add its conf/proxysql/infra-config.sql: all three PG backends load into the writer hostgroup addressed through Toxiproxy (toxiproxy.${INFRA_ID}:6001/6002/6003), pgsql_replication_hostgroups drives automatic rw-split via check_type='read_only', and the monitor (monitor/monitor role, 1000ms read_only_interval) demotes replicas by polling pg_is_in_recovery() through the proxy. Uses ${INFRA_ID} rather than ${INFRA} for hostnames/comment-tagging: ${INFRA} is only reliably exported when this script runs via docker-compose-init.bash, not via ensure-infras.bash's already-running reconfigure path, which would otherwise template unresolvable "toxiproxy." hostnames. docker-compose.yml already provisions the toxiproxy.${INFRA_ID}/dbdeployer1.${INFRA_ID} aliases for this reason. Verified end-to-end on infra sdd-sp2: applied via test/infra/control/ensure-infras.bash, monitor demotes both replicas within a few seconds (runtime_pgsql_servers: 1 writer + 2 readers, pgsql_server_read_only_log clean), and frontend routing on port 6133 is correct (SELECT pg_is_in_recovery() -> reader, writes -> writer). --- .../bin/docker-proxy-post.bash | 35 +++++++-- .../conf/proxysql/infra-config.sql | 73 +++++++++++++++++++ 2 files changed, 102 insertions(+), 6 deletions(-) create mode 100644 test/infra/infra-dbdeployer-pgsql17-repl/conf/proxysql/infra-config.sql diff --git a/test/infra/infra-dbdeployer-pgsql17-repl/bin/docker-proxy-post.bash b/test/infra/infra-dbdeployer-pgsql17-repl/bin/docker-proxy-post.bash index ffefe792ca..17444ac579 100755 --- a/test/infra/infra-dbdeployer-pgsql17-repl/bin/docker-proxy-post.bash +++ b/test/infra/infra-dbdeployer-pgsql17-repl/bin/docker-proxy-post.bash @@ -1,12 +1,35 @@ #!/bin/bash -# Placeholder: ProxySQL configuration for this PG replication backend is added -# in SP-2 Task 4 (pgsql_servers + pgsql_replication_hostgroups + monitor). Kept -# as a no-op so the control flow (docker-compose-init.bash / ensure-infras.bash) -# that unconditionally invokes ./bin/docker-proxy-post.bash succeeds today. +# Configure ProxySQL for the infra-dbdeployer-pgsql17-repl backend (automatic +# rw-split via Toxiproxy + monitor-driven pg_is_in_recovery() demotion). +# +# Follows the infra-pgsql17-repl pattern: eval-expand the SQL template (so +# ${INFRA_ID}/${WHG}/${RHG}/${ROOT_PASSWORD} are substituted) and pipe it into +# the ProxySQL admin interface over the PG protocol (port 6132), NOT the MySQL +# admin protocol -- ProxySQL's pgsql_* admin tables are only writable there. set -e set -o pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" [ -f "${SCRIPT_DIR}/../.env" ] && . "${SCRIPT_DIR}/../.env" +PROXY_CONTAINER="proxysql.${INFRA_ID}" -echo ">>> docker-proxy-post.bash (infra-dbdeployer-pgsql17-repl): no-op placeholder (ProxySQL config lands in SP-2 Task 4)." -exit 0 +# ROOT_PASSWORD is normally exported by the caller (docker-compose-init.bash / +# start-proxysql-isolated.bash), but ensure-infras.bash's "already running" +# reconfigure path invokes this script directly without it. Re-derive it with +# the same deterministic formula so the 'postgres' pgsql_users row keeps +# matching the password the entrypoint actually set on the role. +ROOT_PASSWORD="${ROOT_PASSWORD:-$(echo -n "${INFRA_ID}" | sha256sum | head -c 10)}" + +echo ">>> Configuring ProxySQL (${PROXY_CONTAINER}) for PGSQL Replication (automatic rw-split via Toxiproxy): ${INFRA}" + +# Wait for ProxySQL admin (MySQL protocol, port 6032) to be reachable. +while ! docker exec "${PROXY_CONTAINER}" mysql -uadmin -padmin -h127.0.0.1 -P6032 -e 'SELECT 1' >/dev/null 2>&1; do + echo -n '.' + sleep 1 +done + +# Pre-process the SQL template. +SQL_TEMPLATE=$(cat ./conf/proxysql/infra-config.sql) +SQL_CONTENT=$(eval "echo \"${SQL_TEMPLATE}\"") + +# Apply configuration via docker exec using psql (ProxySQL Admin supports PG protocol on port 6132). +echo "${SQL_CONTENT}" | docker exec -i "${PROXY_CONTAINER}" env PGPASSWORD='admin' psql -h127.0.0.1 -p6132 -Uadmin -dadmin diff --git a/test/infra/infra-dbdeployer-pgsql17-repl/conf/proxysql/infra-config.sql b/test/infra/infra-dbdeployer-pgsql17-repl/conf/proxysql/infra-config.sql new file mode 100644 index 0000000000..215290c6bd --- /dev/null +++ b/test/infra/infra-dbdeployer-pgsql17-repl/conf/proxysql/infra-config.sql @@ -0,0 +1,73 @@ +-- ProxySQL PostgreSQL Server Configuration for infra-dbdeployer-pgsql17-repl. +-- +-- Unlike the static-split reference (infra-pgsql17-repl/conf/proxysql/infra-config.sql), +-- this is the AUTOMATIC rw-split path: all three backends start in the WRITER +-- hostgroup and the monitor demotes read-only replicas to the reader hostgroup +-- via pg_is_in_recovery() (pgsql_replication_hostgroups, check_type='read_only'). +-- +-- Backends are reached exclusively through Toxiproxy (bootstrapped by +-- ./bin/toxiproxy-bootstrap.sh): toxiproxy.${INFRA_ID} ports 6001/6002/6003 -> +-- dbdeployer1.${INFRA_ID}:16710/16711/16712 (primary/replica1/replica2). +-- +-- NOTE: this template is eval-expanded by ./bin/docker-proxy-post.bash, which +-- can run either from docker-compose-init.bash (INFRA exported = this infra +-- directory's basename) or from control/ensure-infras.bash's already-running +-- reconfigure path (which does NOT reliably export INFRA -- it is only set +-- from INFRA_TYPE, itself populated later by the group's env.sh). +-- INFRA_ID (the per-test-run instance id, e.g. sdd-sp2) IS reliably present +-- in every invocation path, and docker-compose.yml registers the +-- toxiproxy.${INFRA_ID} / dbdeployer1.${INFRA_ID} network aliases specifically +-- so downstream SP-2 tasks (harness/ProxySQL) resolve the backend -- so this +-- config addresses backends and tags comments via ${INFRA_ID}, not ${INFRA}. +-- +-- CAUTION for future edits: this whole file is run through a shell eval to +-- expand the template placeholders above, so any double quote character or +-- any dollar-sign token in a comment (not just the intended placeholders) +-- gets interpreted by that eval too. Keep comments free of both. +DELETE FROM pgsql_servers WHERE comment LIKE '%${INFRA_ID}%'; + +-- All three backends in the WRITER hostgroup, addressed via Toxiproxy. +INSERT INTO pgsql_servers (hostgroup_id, hostname, port, max_connections, comment) VALUES + (${WHG}, 'toxiproxy.${INFRA_ID}', 6001, 200, 'pg_primary ${INFRA_ID}'), + (${WHG}, 'toxiproxy.${INFRA_ID}', 6002, 200, 'pg_replica1 ${INFRA_ID}'), + (${WHG}, 'toxiproxy.${INFRA_ID}', 6003, 200, 'pg_replica2 ${INFRA_ID}'); + +-- Automatic writer/reader assignment via pg_is_in_recovery(). +DELETE FROM pgsql_replication_hostgroups WHERE writer_hostgroup=${WHG}; +INSERT INTO pgsql_replication_hostgroups (writer_hostgroup, reader_hostgroup, check_type, comment) + VALUES (${WHG}, ${RHG}, 'read_only', 'pg auto rw-split ${INFRA_ID}'); + +LOAD PGSQL SERVERS TO RUNTIME; -- loads replication_hostgroups too +SAVE PGSQL SERVERS TO DISK; + +DELETE FROM pgsql_users WHERE comment LIKE '%${INFRA_ID}%'; + +-- Superuser row, mirroring the reference infra (postgres/${ROOT_PASSWORD}, +-- the role the entrypoint gives a known password so ProxySQL can log in). +REPLACE INTO pgsql_users (username, password, active, default_hostgroup, comment) VALUES + ('postgres', '${ROOT_PASSWORD}', 1, ${WHG}, '${INFRA_ID}'); +-- Application user. +REPLACE INTO pgsql_users (username, password, active, default_hostgroup, comment) VALUES + ('testuser', 'testuser', 1, ${WHG}, '${INFRA_ID}'); + +LOAD PGSQL USERS TO RUNTIME; +SAVE PGSQL USERS TO DISK; + +-- Read/write split query rules (route SELECTs to the reader HG, SELECT ... FOR +-- UPDATE and everything else stays on the writer HG). +DELETE FROM pgsql_query_rules WHERE destination_hostgroup IN (${WHG}, ${RHG}); +INSERT INTO pgsql_query_rules (rule_id, active, match_digest, destination_hostgroup, apply) VALUES + (${WHG}01, 1, '^SELECT.*FOR UPDATE', ${WHG}, 1), + (${RHG}01, 1, '^SELECT', ${RHG}, 1); +LOAD PGSQL QUERY RULES TO RUNTIME; +SAVE PGSQL QUERY RULES TO DISK; + +-- Enable the monitor (drives the automatic split) against the 'monitor' role +-- provisioned by the entrypoint (monitor/monitor, LOGIN). Shorten the +-- read_only check interval to 1000ms (still within [100, 7*24*3600*1000], +-- see PgSQL_Thread.cpp VariablesPointers_int) so demotion happens fast in tests. +UPDATE global_variables SET variable_value='true' WHERE variable_name='pgsql-monitor_enabled'; +UPDATE global_variables SET variable_value='monitor' WHERE variable_name IN ('pgsql-monitor_username','pgsql-monitor_password'); +UPDATE global_variables SET variable_value='1000' WHERE variable_name='pgsql-monitor_read_only_interval'; +LOAD PGSQL VARIABLES TO RUNTIME; +SAVE PGSQL VARIABLES TO DISK; From 09336acc42729627d1d4a199be19303d9aedaa4b Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 11:16:00 +0000 Subject: [PATCH 07/16] config(pg-compat): fail proxy-post non-zero on SQL errors (ON_ERROR_STOP) Add -v ON_ERROR_STOP=1 to the psql invocation that applies infra-config.sql to the ProxySQL admin (PG protocol, port 6132). Without it psql prints SQL-level errors (bad token, constraint violation, duplicate rule_id) but continues and exits 0, so set -e never fires and the script's fail-non-zero contract was silently defeated -- connection failures were caught, SQL failures were not. Matches the precedent in this infra's docker/entrypoint.sh and infra-pgsql17-repl's init-replication.sh. Verified on sdd-sp2: a bogus statement piped with ON_ERROR_STOP=1 now aborts with exit 3 at the first error (without the flag the same input kept executing and exited 0), and a full ensure-infras.bash re-apply still exits 0 leaving runtime_pgsql_servers in the expected 1-writer/2-reader state. --- .../bin/docker-proxy-post.bash | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/test/infra/infra-dbdeployer-pgsql17-repl/bin/docker-proxy-post.bash b/test/infra/infra-dbdeployer-pgsql17-repl/bin/docker-proxy-post.bash index 17444ac579..29bb8578c1 100755 --- a/test/infra/infra-dbdeployer-pgsql17-repl/bin/docker-proxy-post.bash +++ b/test/infra/infra-dbdeployer-pgsql17-repl/bin/docker-proxy-post.bash @@ -31,5 +31,9 @@ done SQL_TEMPLATE=$(cat ./conf/proxysql/infra-config.sql) SQL_CONTENT=$(eval "echo \"${SQL_TEMPLATE}\"") -# Apply configuration via docker exec using psql (ProxySQL Admin supports PG protocol on port 6132). -echo "${SQL_CONTENT}" | docker exec -i "${PROXY_CONTAINER}" env PGPASSWORD='admin' psql -h127.0.0.1 -p6132 -Uadmin -dadmin +# Apply configuration via docker exec using psql (ProxySQL Admin supports PG +# protocol on port 6132). ON_ERROR_STOP=1 makes psql abort with a non-zero +# exit on the FIRST SQL-level error (bad token, constraint violation, ...); +# without it psql prints the error, keeps going, and exits 0 -- silently +# defeating set -e and this script's fail-non-zero contract. +echo "${SQL_CONTENT}" | docker exec -i "${PROXY_CONTAINER}" env PGPASSWORD='admin' psql -v ON_ERROR_STOP=1 -h127.0.0.1 -p6132 -Uadmin -dadmin From 080f7833a7e571cce695aa8252b1193c3ffec99d Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 11:28:41 +0000 Subject: [PATCH 08/16] test(pg-compat): pytest harness skeleton + admin config primitive + smoke --- test/pg-compat/Dockerfile | 7 +++ test/pg-compat/README.md | 82 ++++++++++++++++++++++++++++++ test/pg-compat/conftest.py | 24 +++++++++ test/pg-compat/harness/__init__.py | 0 test/pg-compat/harness/proxysql.py | 67 ++++++++++++++++++++++++ test/pg-compat/pytest.ini | 2 + test/pg-compat/requirements.txt | 4 ++ test/pg-compat/run-pg-compat.bash | 43 ++++++++++++++++ test/pg-compat/tests/test_smoke.py | 28 ++++++++++ 9 files changed, 257 insertions(+) create mode 100644 test/pg-compat/Dockerfile create mode 100644 test/pg-compat/README.md create mode 100644 test/pg-compat/conftest.py create mode 100644 test/pg-compat/harness/__init__.py create mode 100644 test/pg-compat/harness/proxysql.py create mode 100644 test/pg-compat/pytest.ini create mode 100644 test/pg-compat/requirements.txt create mode 100755 test/pg-compat/run-pg-compat.bash create mode 100644 test/pg-compat/tests/test_smoke.py diff --git a/test/pg-compat/Dockerfile b/test/pg-compat/Dockerfile new file mode 100644 index 0000000000..5441775525 --- /dev/null +++ b/test/pg-compat/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.11-slim +RUN apt-get update && apt-get install -y --no-install-recommends libpq5 curl && rm -rf /var/lib/apt/lists/* +WORKDIR /pg-compat +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +ENTRYPOINT ["pytest", "-q"] diff --git a/test/pg-compat/README.md b/test/pg-compat/README.md new file mode 100644 index 0000000000..0997ab9e1a --- /dev/null +++ b/test/pg-compat/README.md @@ -0,0 +1,82 @@ +# pg-compat + +Polyglot PostgreSQL-protocol compatibility suite for ProxySQL (SP-2). It +drives real PG client drivers (psycopg, asyncpg) against the ProxySQL PG +frontend and cross-checks/configures behavior via the ProxySQL admin +interface, running against the `infra-dbdeployer-pgsql17-repl` backend +(one primary + two replicas, automatic RW-split, fronted by Toxiproxy). + +This is a **discovery-phase, non-gating** suite (see the "Global +Constraints" / §2.1 framing in the plan below): its first job is to build a +failure inventory, not to be all-green. Known divergences are recorded as +`xfail` entries (added in a later task; see +`docs/superpowers/plans/2026-07-08-pgsql-sp2-polyglot-foundation.md`) rather +than by loosening assertions. + +## Running + +The suite runs **inside a container** joined to the infra's Docker network +(no ProxySQL/backend ports are published to the host — see Global +Constraints). Never start containers or networks by hand; always go +through `test/infra/control/*`. + +```bash +# 1. Bring up (or reuse) the infra + ProxySQL for this TAP group. +WORKSPACE=$(pwd) INFRA_ID= TAP_GROUP=pg-compat test/infra/control/ensure-infras.bash + +# 2. Build the pg-compat image and run pytest in a container on the infra network. +WORKSPACE=$(pwd) INFRA_ID= test/pg-compat/run-pg-compat.bash # full suite +WORKSPACE=$(pwd) INFRA_ID= test/pg-compat/run-pg-compat.bash tests/test_smoke.py # one file +``` + +Extra arguments after the script name are forwarded verbatim to `pytest` +(e.g. `-k`, a specific test file, `-v`). + +If ProxySQL was rebuilt, re-run +`test/infra/control/start-proxysql-isolated.bash` to pick up the new binary +(it only restarts the ProxySQL container, leaving backends up). + +## Env contract + +Populated by `test/tap/groups/pg-compat/env.sh` (sourced by +`run-pg-compat.bash`) plus two pairs set by `run-pg-compat.bash` itself: + +| Variable | Meaning | +|---|---| +| `PGCOMPAT_PRIMARY_HOST` / `_PORT` | dbdeployer PG primary (single container, per-node port) | +| `PGCOMPAT_REPLICA1_HOST` / `_PORT` | dbdeployer replica 1 | +| `PGCOMPAT_REPLICA2_HOST` / `_PORT` | dbdeployer replica 2 | +| `PGCOMPAT_TOXI_ADMIN` | Toxiproxy admin API (`host:port`) | +| `PGCOMPAT_TOXI_PRIMARY_HOST` / `_PORT` | Toxiproxy listener in front of the primary | +| `PGCOMPAT_TOXI_REPLICA1_HOST` / `_PORT` | Toxiproxy listener in front of replica 1 | +| `PGCOMPAT_TOXI_REPLICA2_HOST` / `_PORT` | Toxiproxy listener in front of replica 2 | +| `PGCOMPAT_PROXY_HOST` / `_PORT` | ProxySQL PG frontend (default `proxysql:6133`); user `testuser`/`testuser`, db `testuser` | +| `PGCOMPAT_ADMIN_HOST` / `_PORT` | ProxySQL admin over the PG protocol (default `proxysql:6132`) | + +There is intentionally **no** single `PGCOMPAT_BACKEND_PORT` — each node has +its own host/port pair because all three nodes are one dbdeployer +container. `run-pg-compat.bash` forwards every `PGCOMPAT_*` variable +currently in the environment into the container, so new variables added to +`env.sh` are picked up automatically. + +### Admin credentials + +The admin config primitive (`harness/proxysql.py::Admin`) connects as +`radmin`/`radmin`, not `admin`/`admin`. ProxySQL's PG-protocol admin +interface rejects the literal username `admin` from any non-loopback peer +("User 'admin' can only connect locally"); `radmin` carries the same admin +privileges without that restriction and is intended for exactly this kind +of remote/containerized use. See the docstring in `harness/proxysql.py` for +the empirical verification. + +## Layout + +- `harness/proxysql.py` — `Admin` class: `query`, `set_var`, `load_vars`, + `snapshot`, `restore` — the read/modify/LOAD/verify/restore cycle used by + every test that flips a ProxySQL runtime variable. +- `conftest.py` — `admin` (session-scoped `Admin`) and `proxy_conn` + (function-scoped psycopg connection to the ProxySQL PG frontend) + fixtures shared by all tests. +- `tests/` — the test suite (`test_smoke.py` today). +- `SPIKE-dbdeployer-pg.md` — prior spike notes on the dbdeployer PG infra; + left as-is. diff --git a/test/pg-compat/conftest.py b/test/pg-compat/conftest.py new file mode 100644 index 0000000000..511f6f5758 --- /dev/null +++ b/test/pg-compat/conftest.py @@ -0,0 +1,24 @@ +import os + +import psycopg +import pytest + +from harness.proxysql import Admin + + +@pytest.fixture(scope="session") +def admin(): + return Admin() + + +def _proxy_dsn(dbname="testuser"): + h = os.environ["PGCOMPAT_PROXY_HOST"] + p = os.environ["PGCOMPAT_PROXY_PORT"] + return f"host={h} port={p} user=testuser password=testuser dbname={dbname} sslmode=disable" + + +@pytest.fixture +def proxy_conn(): + conn = psycopg.connect(_proxy_dsn(), autocommit=True) + yield conn + conn.close() diff --git a/test/pg-compat/harness/__init__.py b/test/pg-compat/harness/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/pg-compat/harness/proxysql.py b/test/pg-compat/harness/proxysql.py new file mode 100644 index 0000000000..303cb310d1 --- /dev/null +++ b/test/pg-compat/harness/proxysql.py @@ -0,0 +1,67 @@ +"""Admin config primitive for the pg-compat suite. + +Talks to ProxySQL's admin interface over the PG wire protocol (port 6132) +via psycopg. Provides the read/SET/LOAD/snapshot/restore cycle that later +pg-compat tests use to flip a runtime variable, exercise behavior, and put +the variable back. + +Deviation from the brief: the brief's DSN used user/password `admin`/`admin`. +Empirically, ProxySQL's PG-protocol admin interface rejects the literal +username `admin` from any peer that is not 127.0.0.1/::1/localhost +(PgSQL_Session.cpp: "User '%s' can only connect locally" — the check is a +strcmp() against the literal string "admin", scoped to the ADMIN_HOSTGROUP +default hostgroup). Since this harness always connects from a separate +container over the docker network, the literal `admin` user can never log +in here. ProxySQL ships a second admin credential pair via +`admin-admin_credentials` (default `admin:admin;radmin:radmin`) where +`radmin` maps to the same ADMIN_HOSTGROUP/privileges but is NOT subject to +the localhost-only check (the strcmp only matches "admin"). So this harness +authenticates as radmin/radmin, verified against the running sdd-sp2 infra: + docker exec psql -h proxysql -p 6132 -U admin -d admin ... -> FATAL: User 'admin' can only connect locally + docker exec psql -h proxysql -p 6132 -U radmin -d admin ... -> works +""" +import os + +import psycopg + + +def _admin_dsn(): + host = os.environ["PGCOMPAT_ADMIN_HOST"] + port = os.environ["PGCOMPAT_ADMIN_PORT"] + # See module docstring: "admin" is restricted to loopback connections + # only; "radmin" carries the same admin privileges without that + # restriction, so it is what a remote (containerized) client must use. + return f"host={host} port={port} user=radmin password=radmin dbname=admin sslmode=disable" + + +class Admin: + def __init__(self): + self.conn = psycopg.connect(_admin_dsn(), autocommit=True) + + def query(self, sql): + with self.conn.cursor() as cur: + cur.execute(sql) + return cur.fetchall() if cur.description else None + + def set_var(self, name, value): + # Verified empirically against the running admin: both + # `SET name=1` and `SET name='1'` are accepted and applied + # identically for a numeric variable (pgsql-authentication_method). + # Quote only non-numeric strings, matching the brief. + self.query(f"SET {name}={value!r}" if isinstance(value, str) else f"SET {name}={value}") + + def load_vars(self): + self.query("LOAD PGSQL VARIABLES TO RUNTIME") + + def snapshot(self, var_names): + placeholders = ",".join(f"'{v}'" for v in var_names) + rows = self.query( + "SELECT variable_name, variable_value FROM global_variables " + f"WHERE variable_name IN ({placeholders})" + ) + return dict(rows) + + def restore(self, saved): + for name, value in saved.items(): + self.set_var(name, value) + self.load_vars() diff --git a/test/pg-compat/pytest.ini b/test/pg-compat/pytest.ini new file mode 100644 index 0000000000..5ee6477165 --- /dev/null +++ b/test/pg-compat/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +testpaths = tests diff --git a/test/pg-compat/requirements.txt b/test/pg-compat/requirements.txt new file mode 100644 index 0000000000..46c18ce4d1 --- /dev/null +++ b/test/pg-compat/requirements.txt @@ -0,0 +1,4 @@ +psycopg[binary]==3.2.* +asyncpg==0.30.* +pytest==8.* +tomli==2.* diff --git a/test/pg-compat/run-pg-compat.bash b/test/pg-compat/run-pg-compat.bash new file mode 100755 index 0000000000..55581412ca --- /dev/null +++ b/test/pg-compat/run-pg-compat.bash @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Build the pg-compat pytest image and run it joined to the infra's backend +# network, so it can reach ProxySQL and the PG backends purely by DNS alias +# (no host ports are published — see the plan's Global Constraints). +set -euo pipefail +: "${INFRA_ID:?}"; : "${WORKSPACE:?}" +NETWORK="${INFRA_ID}_backend" + +# Populates PGCOMPAT_{PRIMARY,REPLICA1,REPLICA2}_{HOST,PORT} and +# PGCOMPAT_TOXI_* (per-node vars; there is no single PGCOMPAT_BACKEND_PORT). +source "${WORKSPACE}/test/tap/groups/pg-compat/env.sh" + +# ProxySQL frontend (PG protocol) and admin-over-PG-protocol ports/host. +# The proxysql container is started with --hostname proxysql and +# --network-alias proxysql on this network (see start-proxysql-isolated.bash). +export PGCOMPAT_PROXY_HOST="${PGCOMPAT_PROXY_HOST:-proxysql}" +export PGCOMPAT_PROXY_PORT="${PGCOMPAT_PROXY_PORT:-6133}" +export PGCOMPAT_ADMIN_HOST="${PGCOMPAT_ADMIN_HOST:-proxysql}" +export PGCOMPAT_ADMIN_PORT="${PGCOMPAT_ADMIN_PORT:-6132}" + +# --network=host: this environment's default docker bridge network has no +# egress to the internet from build containers (buildkit's isolated build +# network cannot resolve/reach deb.debian.org or PyPI at all, verified by +# hand: DNS and raw-IP both fail on the default network, while the host +# itself has working internet access). --network=host runs apt-get/pip +# install steps using the host's network namespace so the image can build +# in environments like this one; the resulting image is unaffected by the +# network used at build time. +docker build --network=host -t proxysql-pg-compat:latest "${WORKSPACE}/test/pg-compat" + +# Forward every PGCOMPAT_* env var currently set (from env.sh plus the +# PROXY_/ADMIN_ overrides above) as -e flags, so new vars added to env.sh +# in the future are picked up automatically without editing this script. +ENV_ARGS=() +while IFS='=' read -r name _; do + [ -n "${name}" ] || continue + ENV_ARGS+=("-e" "${name}") +done < <(env | grep '^PGCOMPAT_') + +docker run --rm --network "${NETWORK}" \ + -e INFRA_ID \ + "${ENV_ARGS[@]}" \ + proxysql-pg-compat:latest "$@" diff --git a/test/pg-compat/tests/test_smoke.py b/test/pg-compat/tests/test_smoke.py new file mode 100644 index 0000000000..c2152e218e --- /dev/null +++ b/test/pg-compat/tests/test_smoke.py @@ -0,0 +1,28 @@ +"""Wiring smoke tests for the pg-compat harness. + +These prove: the pytest container reaches the ProxySQL PG frontend (6133) +and the ProxySQL admin over the PG protocol (6132) on the infra's backend +network, and the admin config primitive (snapshot/set/load/restore) works. +""" + + +def test_proxy_select_one(proxy_conn): + with proxy_conn.cursor() as cur: + cur.execute("SELECT 1") + assert cur.fetchone()[0] == 1 + + +def test_admin_reconfig_roundtrip(admin): + saved = admin.snapshot(["pgsql-authentication_method"]) + try: + admin.set_var("pgsql-authentication_method", 1) + admin.load_vars() + val = admin.query( + "SELECT variable_value FROM global_variables " + "WHERE variable_name='pgsql-authentication_method'" + ) + assert val[0][0] == "1" + finally: + # Must run even if the assertion above fails, so the suite never + # leaks state into a subsequent run. + admin.restore(saved) From b4b811f4c9f9acda9baf81e2c0a1f0f1d372c7ac Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 11:33:59 +0000 Subject: [PATCH 09/16] test(pg-compat): SQL-safe quoting in Admin.set_var/snapshot --- test/pg-compat/harness/proxysql.py | 42 +++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/test/pg-compat/harness/proxysql.py b/test/pg-compat/harness/proxysql.py index 303cb310d1..5812a228e7 100644 --- a/test/pg-compat/harness/proxysql.py +++ b/test/pg-compat/harness/proxysql.py @@ -34,6 +34,21 @@ def _admin_dsn(): return f"host={host} port={port} user=radmin password=radmin dbname=admin sslmode=disable" +def _sql_quote(value): + """SQL-quote a string literal for the ProxySQL admin parser. + + Escapes by doubling single quotes (verified live against the admin: + `SET pgsql-server_version='16.1''test'` stores `16.1'test` and reads + back correctly from global_variables). Backslashes are passed through + literally — the admin's SQLite-based parser uses standard-conforming + string literals and does not treat backslash as an escape character. + NUL cannot be represented in a SQL string literal, so it is rejected. + """ + if "\x00" in value: + raise ValueError("NUL byte not representable in a SQL string literal") + return "'" + value.replace("'", "''") + "'" + + class Admin: def __init__(self): self.conn = psycopg.connect(_admin_dsn(), autocommit=True) @@ -44,17 +59,32 @@ def query(self, sql): return cur.fetchall() if cur.description else None def set_var(self, name, value): - # Verified empirically against the running admin: both - # `SET name=1` and `SET name='1'` are accepted and applied - # identically for a numeric variable (pgsql-authentication_method). - # Quote only non-numeric strings, matching the brief. - self.query(f"SET {name}={value!r}" if isinstance(value, str) else f"SET {name}={value}") + # str -> SQL-quoted, single quotes doubled (see _sql_quote). + # Always quoting strings is safe even for numeric variables: + # verified live that SET name=1 and SET name='1' behave + # identically, so restore() (whose values from snapshot() + # are always str) round-trips every variable type. + # bool -> bare true/false (verified live: SET + # pgsql-connection_warming=true / =false are accepted and + # read back as "true"/"false"). Checked before the generic + # path because bool is a subclass of int. + # int/float -> bare, unquoted. + if isinstance(value, bool): + literal = "true" if value else "false" + elif isinstance(value, str): + literal = _sql_quote(value) + else: + literal = str(value) + self.query(f"SET {name}={literal}") def load_vars(self): self.query("LOAD PGSQL VARIABLES TO RUNTIME") def snapshot(self, var_names): - placeholders = ",".join(f"'{v}'" for v in var_names) + # Variable names are expected to be literals from trusted call + # sites, but quote them with the same doubling as values for + # consistency/safety. + placeholders = ",".join(_sql_quote(v) for v in var_names) rows = self.query( "SELECT variable_name, variable_value FROM global_variables " f"WHERE variable_name IN ({placeholders})" From 96316bfa349540f6752429e5edf056b3c86dc570 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 11:44:05 +0000 Subject: [PATCH 10/16] test(pg-compat): 6-target differential engine + cases + divergence self-check --- test/pg-compat/cases/001_scalars.sql | 2 + test/pg-compat/cases/002_bytea_json_array.sql | 2 + test/pg-compat/harness/diff.py | 129 ++++++++++++++++++ test/pg-compat/harness/proxysql.py | 11 +- test/pg-compat/harness/targets.py | 98 +++++++++++++ test/pg-compat/tests/test_differential.py | 48 +++++++ .../tests/test_differential_selfcheck.py | 63 +++++++++ 7 files changed, 352 insertions(+), 1 deletion(-) create mode 100644 test/pg-compat/cases/001_scalars.sql create mode 100644 test/pg-compat/cases/002_bytea_json_array.sql create mode 100644 test/pg-compat/harness/diff.py create mode 100644 test/pg-compat/harness/targets.py create mode 100644 test/pg-compat/tests/test_differential.py create mode 100644 test/pg-compat/tests/test_differential_selfcheck.py diff --git a/test/pg-compat/cases/001_scalars.sql b/test/pg-compat/cases/001_scalars.sql new file mode 100644 index 0000000000..66352dfe9a --- /dev/null +++ b/test/pg-compat/cases/001_scalars.sql @@ -0,0 +1,2 @@ +-- transactional: false +SELECT true, 2147483647::int4, 9223372036854775807::int8, 1.5::float8, 12345.6789::numeric, 'héllo'::text; diff --git a/test/pg-compat/cases/002_bytea_json_array.sql b/test/pg-compat/cases/002_bytea_json_array.sql new file mode 100644 index 0000000000..bf892fc306 --- /dev/null +++ b/test/pg-compat/cases/002_bytea_json_array.sql @@ -0,0 +1,2 @@ +-- transactional: false +SELECT '\xdeadbeef'::bytea, '{"a":1}'::jsonb, ARRAY[1,2,3]::int4[], '192.168.0.1'::inet, '00000000-0000-0000-0000-000000000001'::uuid; diff --git a/test/pg-compat/harness/diff.py b/test/pg-compat/harness/diff.py new file mode 100644 index 0000000000..721cd66452 --- /dev/null +++ b/test/pg-compat/harness/diff.py @@ -0,0 +1,129 @@ +"""The differential engine. + +Runs the same SQL statements against several targets (see harness.targets) +and asserts that every proxy target is byte-for-byte indistinguishable from +its FORMAT-MATCHED direct baseline: same status tag, same column names, same +type OIDs, same row values. ``compare`` returns ``(ok, detail_text)``. + +Format-matched: a ``*_binary`` proxy target is compared only against +``direct_binary`` and a ``*_text`` proxy target only against ``direct_text``. +This is deliberate -- psycopg decodes text and binary wire formats through +different code paths, so a proxy-vs-direct comparison is only apples-to-apples +within the same result format. + +Case metadata (parsed from ``-- key: value`` comment lines): + ``-- skip-targets: name1 name2`` targets to exclude + ``-- only-targets: name1 name2`` restrict to exactly these targets + ``-- transactional: false`` parsed for completeness; the shipped + pure-SELECT cases are stateless so it is + not acted on here. + +Native backend axis: for AVAILABLE native targets the backend mode is set via +the admin (``pgsql-use_native_backend_protocol``) before running. Unavailable +targets (the norm today -- PR #5882 unmerged) are simply not run; the test +layer surfaces them as skips. ``compare`` therefore gracefully handles absent +targets -- only the proxy targets actually present in ``results`` are checked. +""" +import re + +import psycopg + +from harness.targets import NATIVE_VAR, native_var_present + + +def _parse_meta(sql): + skip = set() + for m in re.findall(r"--\s*skip-targets:\s*(.+)", sql): + skip.update(m.split()) + only = set() + for m in re.findall(r"--\s*only-targets:\s*(.+)", sql): + only.update(m.split()) + return skip, only + + +def _statements(sql): + return [ + s.strip() + for s in sql.split(";") + if s.strip() and not s.strip().startswith("--") + ] + + +def _parse_case_file(case_file): + with open(case_file) as f: + sql = f.read() + skip, only = _parse_meta(sql) + return _statements(sql), skip, only + + +def _run_on(target, stmts, admin, native_present): + # Toggle the backend-protocol mode ONLY when the variable exists and the + # target participates in that axis (native_backend is not None). With the + # variable absent (today), native targets are unavailable and never reach + # here, and libpq/direct targets need no toggle (libpq is the only mode). + if native_present and target.native_backend is not None: + admin.set_var(NATIVE_VAR, bool(target.native_backend)) + admin.load_vars() + + out = [] + with psycopg.connect(target.dsn, autocommit=True) as conn: + for s in stmts: + with conn.cursor(binary=target.binary) as cur: + cur.execute(s) + cols = [(d.name, d.type_code) for d in (cur.description or [])] + rows = cur.fetchall() if cur.description else None + out.append((cur.statusmessage, cols, rows)) + return out + + +def _run(stmts, targets, admin, skip, only): + native_present = native_var_present(admin) if admin is not None else False + results = {} + for t in targets: + if not t.available: + continue + if t.name in skip: + continue + if only and t.name not in only: + continue + results[t.name] = _run_on(t, stmts, admin, native_present) + return results + + +def run_case(case_file, targets, admin=None): + """Run every statement in ``case_file`` against all available targets.""" + stmts, skip, only = _parse_case_file(case_file) + return _run(stmts, targets, admin, skip, only) + + +def run_case_sql(sql, targets, admin=None): + """Inline-SQL variant of ``run_case`` (a SQL string, not a file).""" + skip, only = _parse_meta(sql) + return _run(_statements(sql), targets, admin, skip, only) + + +def compare(results): + """Every proxy target must equal its format-matched direct baseline. + + Absent targets are handled gracefully: only proxy targets present in + ``results`` are compared, each against its direct baseline (which is always + available). Returns ``(ok, detail_text)``. + """ + def base(name): + return "direct_binary" if name.endswith("binary") else "direct_text" + + diffs = [] + for name in sorted(results): + if name.startswith("direct"): + continue + b_name = base(name) + b = results.get(b_name) + if b is None: + diffs.append(f"{name}: format-matched baseline {b_name} unavailable") + continue + res = results[name] + if res != b: + diffs.append( + f"{name} != {b_name}\n got: {res}\n base: {b}" + ) + return (not diffs, "\n".join(diffs)) diff --git a/test/pg-compat/harness/proxysql.py b/test/pg-compat/harness/proxysql.py index 5812a228e7..c26fabc141 100644 --- a/test/pg-compat/harness/proxysql.py +++ b/test/pg-compat/harness/proxysql.py @@ -51,7 +51,16 @@ def _sql_quote(value): class Admin: def __init__(self): - self.conn = psycopg.connect(_admin_dsn(), autocommit=True) + # prepare_threshold=None disables psycopg's automatic server-side + # prepared statements. ProxySQL's PG-protocol admin interface does NOT + # support the extended-query Parse/Bind path, so once psycopg silently + # promoted a repeated statement to a prepared one (default threshold = + # 5 executions) the admin returned "Feature not supported". Admin + # queries are cheap and infrequent, so plain simple-protocol execution + # is both correct and sufficient here. + self.conn = psycopg.connect( + _admin_dsn(), autocommit=True, prepare_threshold=None + ) def query(self, sql): with self.conn.cursor() as cur: diff --git a/test/pg-compat/harness/targets.py b/test/pg-compat/harness/targets.py new file mode 100644 index 0000000000..467fb84046 --- /dev/null +++ b/test/pg-compat/harness/targets.py @@ -0,0 +1,98 @@ +"""The 6 differential targets for the pg-compat engine. + +Each SQL case is run against every AVAILABLE target and ProxySQL must be +indistinguishable from a direct PostgreSQL backend (status tag, column +names, type OIDs, rows). The target matrix is a 3-way product: + + {proxy, direct} x {libpq-backend, native-backend} x {text, binary} + +collapsed to 6 because the native-backend axis does not apply to a direct +connection: + + proxy_libpq_text proxy_libpq_binary + proxy_native_text proxy_native_binary + direct_text direct_binary + +Native-backend axis (spec 2.2): the two ``proxy_native_*`` targets toggle +``pgsql-use_native_backend_protocol``. That variable does NOT exist in this +build (PR #5882 unmerged), so ``all_targets`` probes the admin once and marks +those two targets ``available=False`` with a reason. The differential test +reports them as pytest SKIPS (never silent omissions, never failures); when +#5882 merges the probe returns present and they light up with ZERO code +changes here. + +Env contract (see test/tap/groups/pg-compat/env.sh + run-pg-compat.bash): +proxy = ``PGCOMPAT_PROXY_HOST``/``PGCOMPAT_PROXY_PORT`` (testuser/testuser, +db testuser); direct primary = ``PGCOMPAT_PRIMARY_HOST``/ +``PGCOMPAT_PRIMARY_PORT`` (per-node vars -- there is NO PGCOMPAT_BACKEND_PORT). +""" +import os +from dataclasses import dataclass +from typing import Optional + +# psycopg is imported so callers can ``from harness import targets`` and reach +# the same driver the engine uses; diff.py performs the actual connections. +import psycopg # noqa: F401 + +NATIVE_VAR = "pgsql-use_native_backend_protocol" +NATIVE_ABSENT_REASON = f"{NATIVE_VAR} absent (PR #5882 not merged)" + + +def _dsn(host, port, dbname="testuser", user="testuser", pw="testuser"): + return ( + f"host={host} port={port} user={user} password={pw} " + f"dbname={dbname} sslmode=disable" + ) + + +def _proxy(): + return _dsn(os.environ["PGCOMPAT_PROXY_HOST"], os.environ["PGCOMPAT_PROXY_PORT"]) + + +def _direct(): + # Per-node env vars; there is deliberately no single PGCOMPAT_BACKEND_PORT + # in this infra (the dbdeployer container exposes three ports on one host). + return _dsn(os.environ["PGCOMPAT_PRIMARY_HOST"], os.environ["PGCOMPAT_PRIMARY_PORT"]) + + +@dataclass +class Target: + name: str + dsn: str + binary: bool + native_backend: Optional[bool] # True=native, False=libpq, None=direct (N/A) + available: bool = True + skip_reason: str = "" + + +def native_var_present(admin): + """True iff ProxySQL knows ``pgsql-use_native_backend_protocol``. + + Probes the admin once. Absent today (PR #5882 unmerged) -> the two native + targets are marked unavailable. + """ + rows = admin.query( + "SELECT count(*) FROM global_variables " + f"WHERE variable_name='{NATIVE_VAR}'" + ) + return int(rows[0][0]) > 0 + + +def all_targets(admin): + present = native_var_present(admin) + + def _native(name, binary): + return Target( + name, _proxy(), binary, True, + available=present, + skip_reason="" if present else NATIVE_ABSENT_REASON, + ) + + return [ + Target("proxy_libpq_text", _proxy(), False, False), + Target("proxy_libpq_binary", _proxy(), True, False), + _native("proxy_native_text", False), + _native("proxy_native_binary", True), + Target("direct_text", _direct(), False, None), + Target("direct_binary", _direct(), True, None), + ] diff --git a/test/pg-compat/tests/test_differential.py b/test/pg-compat/tests/test_differential.py new file mode 100644 index 0000000000..de53a67c13 --- /dev/null +++ b/test/pg-compat/tests/test_differential.py @@ -0,0 +1,48 @@ +"""Differential transparency: ProxySQL must be indistinguishable from direct PG. + +Each ``cases/*.sql`` file is run against every available target and compared +against its format-matched direct baseline (see harness.diff.compare). + +The two native-backend targets are unavailable while PR #5882 is unmerged. +``test_target_available`` surfaces them as explicit, reasoned pytest SKIPS +(visible in ``-v`` output) rather than silently omitting them -- and they +flip to PASS automatically once the backend variable exists, with no change +to this file. +""" +import glob +import os + +import pytest + +from harness import targets, diff + +HERE = os.path.dirname(__file__) +CASE_FILES = sorted(glob.glob(os.path.join(HERE, "..", "cases", "*.sql"))) +CASE_IDS = [os.path.basename(f) for f in CASE_FILES] + +# Stable names of the full 6-target matrix (import-time safe: no admin conn). +TARGET_NAMES = [ + "proxy_libpq_text", + "proxy_libpq_binary", + "proxy_native_text", + "proxy_native_binary", + "direct_text", + "direct_binary", +] + + +@pytest.mark.parametrize("case_file", CASE_FILES, ids=CASE_IDS) +def test_case_is_transparent(admin, case_file): + tgts = targets.all_targets(admin) + results = diff.run_case(case_file, tgts, admin) + ok, detail = diff.compare(results) + assert ok, f"{os.path.basename(case_file)} diverged:\n{detail}" + + +@pytest.mark.parametrize("target_name", TARGET_NAMES) +def test_target_available(admin, target_name): + """One item per target; unavailable ones are skipped WITH their reason.""" + by_name = {t.name: t for t in targets.all_targets(admin)} + t = by_name[target_name] + if not t.available: + pytest.skip(t.skip_reason) diff --git a/test/pg-compat/tests/test_differential_selfcheck.py b/test/pg-compat/tests/test_differential_selfcheck.py new file mode 100644 index 0000000000..39b5b43fd3 --- /dev/null +++ b/test/pg-compat/tests/test_differential_selfcheck.py @@ -0,0 +1,63 @@ +"""Self-check: prove the differential engine actually DETECTS divergence. + +A test oracle that can never fail is worthless. This installs a ProxySQL +query rule that rewrites the canary ``SELECT 1 AS canary`` into +``SELECT 2 AS canary`` ON THE PROXY PATH ONLY, so the proxy targets return a +row that differs from every direct backend. The engine MUST then report +``compare(...) -> not ok``. The rule is always removed in ``finally``. + +Why a rewrite (replace_pattern) and not the brief's literal rule, and not an +error_msg rule -- verified live against the sdd-sp2 admin: + + * The brief's ``INSERT ... (match_digest, replace_pattern, ...)`` violates a + ProxySQL CHECK constraint: + CASE WHEN replace_pattern IS NULL THEN 1 + WHEN replace_pattern IS NOT NULL AND match_pattern IS NOT NULL + THEN 1 ELSE 0 END + i.e. a ``replace_pattern`` REQUIRES a ``match_pattern``. So we match on + ``match_pattern`` (raw query text) instead of ``match_digest``. + + * The infra ships RW-split rules ``rule_id=1`` (``^SELECT.*FOR UPDATE``) and + ``rule_id=101`` (``^SELECT``), both ``apply=1``. Rules evaluate in + ascending ``rule_id``; the first ``apply=1`` match STOPS processing. So a + high rule_id (e.g. the brief's 990001) never runs for a SELECT -- the + reader rule at 101 fires first. The self-check rule must therefore sort + BEFORE 101; we use ``rule_id=90``. + + * ``replace_pattern`` is preferred over ``error_msg`` because the rewrite + keeps BOTH the proxy and direct executions succeeding, so ``compare`` + exercises its real status/column/OID/row comparison. An ``error_msg`` rule + would make the proxy raise, aborting the run before ``compare`` is reached. + +Live evidence (psql through the proxy) with rule 90 installed: + SELECT 1 AS canary -> 2 (rewritten) +and removed: + SELECT 1 AS canary -> 1 +""" +from harness import targets, diff + +# Must sort before the infra RW-split reader rule (rule_id 101, apply=1). +SELFCHECK_RULE_ID = 90 + + +def test_engine_detects_divergence(admin): + admin.query( + "INSERT INTO pgsql_query_rules " + "(rule_id,active,match_pattern,replace_pattern,re_modifiers,apply) " + f"VALUES ({SELFCHECK_RULE_ID},1,'SELECT 1 AS canary'," + "'SELECT 2 AS canary','CASELESS',1)" + ) + admin.query("LOAD PGSQL QUERY RULES TO RUNTIME") + try: + tgts = targets.all_targets(admin) + results = diff.run_case_sql("SELECT 1 AS canary", tgts, admin) + ok, detail = diff.compare(results) + assert not ok, ( + "differential engine FAILED to detect an injected rewrite " + f"divergence; results={results}" + ) + finally: + admin.query( + f"DELETE FROM pgsql_query_rules WHERE rule_id={SELFCHECK_RULE_ID}" + ) + admin.query("LOAD PGSQL QUERY RULES TO RUNTIME") From a3f91dd6aee750c6a6220ea581aaccfef708ce8a Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 11:56:31 +0000 Subject: [PATCH 11/16] fix(pg-compat): line-aware case parsing + empty-case guard + pipeline self-check (Task 6 review) --- test/pg-compat/harness/diff.py | 35 +++++++++-- test/pg-compat/harness/targets.py | 13 +++- .../tests/test_differential_selfcheck.py | 59 ++++++++++++++++--- 3 files changed, 93 insertions(+), 14 deletions(-) diff --git a/test/pg-compat/harness/diff.py b/test/pg-compat/harness/diff.py index 721cd66452..315b7f3a1b 100644 --- a/test/pg-compat/harness/diff.py +++ b/test/pg-compat/harness/diff.py @@ -42,16 +42,26 @@ def _parse_meta(sql): def _statements(sql): - return [ - s.strip() - for s in sql.split(";") - if s.strip() and not s.strip().startswith("--") - ] + # Strip comment lines PER-LINE before the ";"-split. The previous + # chunk-based filter (`split(";")` then drop chunks starting with "--") + # silently discarded an ENTIRE case whose first line is a metadata + # comment: with only one trailing ";" the whole file is a single chunk + # beginning with "--", so the comment AND the SQL were thrown away + # together and zero statements ran (a vacuous pass). Known limitations + # of this simple splitter: no ";" inside string literals, and no + # trailing "--" comments appended to statement lines. + body = "\n".join( + line for line in sql.splitlines() + if not line.strip().startswith("--") + ) + return [s.strip() for s in body.split(";") if s.strip()] def _parse_case_file(case_file): with open(case_file) as f: sql = f.read() + # Metadata regexes run on the ORIGINAL text (comment lines included); + # only statement extraction works on the comment-stripped body. skip, only = _parse_meta(sql) return _statements(sql), skip, only @@ -93,13 +103,26 @@ def _run(stmts, targets, admin, skip, only): def run_case(case_file, targets, admin=None): """Run every statement in ``case_file`` against all available targets.""" stmts, skip, only = _parse_case_file(case_file) + if not stmts: + # An empty case must be a LOUD error, never a vacuous pass: with no + # statements every target returns [] and compare() trivially succeeds. + raise ValueError( + f"{case_file}: no executable statements parsed — " + "check comment/semicolon handling" + ) return _run(stmts, targets, admin, skip, only) def run_case_sql(sql, targets, admin=None): """Inline-SQL variant of ``run_case`` (a SQL string, not a file).""" skip, only = _parse_meta(sql) - return _run(_statements(sql), targets, admin, skip, only) + stmts = _statements(sql) + if not stmts: + raise ValueError( + "inline case: no executable statements parsed — " + "check comment/semicolon handling" + ) + return _run(stmts, targets, admin, skip, only) def compare(results): diff --git a/test/pg-compat/harness/targets.py b/test/pg-compat/harness/targets.py index 467fb84046..e7d33a9943 100644 --- a/test/pg-compat/harness/targets.py +++ b/test/pg-compat/harness/targets.py @@ -39,9 +39,20 @@ def _dsn(host, port, dbname="testuser", user="testuser", pw="testuser"): + # client_encoding is pinned to UTF8 on EVERY target so proxy and direct + # runs are apples-to-apples. Without it the two sides get DIFFERENT + # defaults (verified live on the sdd-sp2 infra): the dbdeployer backend + # databases are SQL_ASCII, so a direct session defaults client_encoding + # to SQL_ASCII (psycopg then maps it to Python's 'ascii' codec and cannot + # even send non-ASCII SQL like 'héllo'), while a session THROUGH ProxySQL + # reports client_encoding=UTF8 (ProxySQL imposes it on its backend + # connections rather than inheriting the server default -- a session- + # default divergence flagged in the Task 6 report). Pinning the parameter + # standardizes the client side only; compare() still requires identical + # status/columns/OIDs/rows. return ( f"host={host} port={port} user={user} password={pw} " - f"dbname={dbname} sslmode=disable" + f"dbname={dbname} sslmode=disable client_encoding=UTF8" ) diff --git a/test/pg-compat/tests/test_differential_selfcheck.py b/test/pg-compat/tests/test_differential_selfcheck.py index 39b5b43fd3..dc70c9530f 100644 --- a/test/pg-compat/tests/test_differential_selfcheck.py +++ b/test/pg-compat/tests/test_differential_selfcheck.py @@ -34,21 +34,29 @@ and removed: SELECT 1 AS canary -> 1 """ +import glob +import os + from harness import targets, diff # Must sort before the infra RW-split reader rule (rule_id 101, apply=1). SELFCHECK_RULE_ID = 90 +CASES_DIR = os.path.join(os.path.dirname(__file__), "..", "cases") + def test_engine_detects_divergence(admin): - admin.query( - "INSERT INTO pgsql_query_rules " - "(rule_id,active,match_pattern,replace_pattern,re_modifiers,apply) " - f"VALUES ({SELFCHECK_RULE_ID},1,'SELECT 1 AS canary'," - "'SELECT 2 AS canary','CASELESS',1)" - ) - admin.query("LOAD PGSQL QUERY RULES TO RUNTIME") try: + # INSERT + LOAD inside the try so the finally ALWAYS deletes rule 90 + # and reloads, even if either setup statement fails partway (deleting + # a rule that was never inserted is harmless). + admin.query( + "INSERT INTO pgsql_query_rules " + "(rule_id,active,match_pattern,replace_pattern,re_modifiers,apply) " + f"VALUES ({SELFCHECK_RULE_ID},1,'SELECT 1 AS canary'," + "'SELECT 2 AS canary','CASELESS',1)" + ) + admin.query("LOAD PGSQL QUERY RULES TO RUNTIME") tgts = targets.all_targets(admin) results = diff.run_case_sql("SELECT 1 AS canary", tgts, admin) ok, detail = diff.compare(results) @@ -61,3 +69,40 @@ def test_engine_detects_divergence(admin): f"DELETE FROM pgsql_query_rules WHERE rule_id={SELFCHECK_RULE_ID}" ) admin.query("LOAD PGSQL QUERY RULES TO RUNTIME") + + +def test_file_pipeline_executes_real_statements(admin): + """Guard against vacuous passes on the FILE-based path. + + Review of the first Task 6 iteration found ``_statements()`` discarded an + entire case file whose first line was a metadata comment (one trailing + ``;`` -> one chunk starting with ``--``), so ``run_case`` executed ZERO + statements, every target returned ``[]``, and ``compare`` passed on + ``[] == []``. The inline-SQL divergence self-check above could not catch + that (no leading comment in its SQL). This test permanently pins the + file-parsing pipeline: every shipped case file must parse to at least one + statement, and running a real case file end-to-end must yield, for EVERY + available target, a non-empty result list whose statements each returned + at least one row (the shipped cases are pure single-row SELECTs). + """ + case_files = sorted(glob.glob(os.path.join(CASES_DIR, "*.sql"))) + assert case_files, f"no case files found under {CASES_DIR}" + + # Every shipped case must parse to >= 1 executable statement. + for cf in case_files: + stmts, _, _ = diff._parse_case_file(cf) + assert stmts, f"{os.path.basename(cf)} parsed to zero statements" + + # And a real file run must produce real, non-empty results per target. + tgts = targets.all_targets(admin) + results = diff.run_case(case_files[0], tgts, admin) + available = [t.name for t in tgts if t.available] + assert sorted(results) == sorted(available), ( + f"expected results for every available target {available}, " + f"got {sorted(results)}" + ) + for name, res in results.items(): + assert res, f"{name}: empty result list — no statement executed" + for status, cols, rows in res: + assert cols, f"{name}: statement returned no columns ({status})" + assert rows, f"{name}: statement returned no rows ({status})" From fb5ce59272142e0936a0c6ce36eab42ebdf37f4f Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 12:03:19 +0000 Subject: [PATCH 12/16] test(pg-compat): pg_stat_statements routing oracle + write-pin self-check --- test/pg-compat/harness/oracle.py | 91 +++++++++++++++++++++ test/pg-compat/tests/test_routing_oracle.py | 58 +++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 test/pg-compat/harness/oracle.py create mode 100644 test/pg-compat/tests/test_routing_oracle.py diff --git a/test/pg-compat/harness/oracle.py b/test/pg-compat/harness/oracle.py new file mode 100644 index 0000000000..42e466af24 --- /dev/null +++ b/test/pg-compat/harness/oracle.py @@ -0,0 +1,91 @@ +"""Routing oracle: prove WHERE a query landed by reading each backend node's +own ``pg_stat_statements`` view. + +Design (a) from the task brief's two options: connect to every backend as +the sandbox **superuser** (``postgres``), not as ``testuser``. Two reasons: + + * ``pg_stat_statements_reset()`` requires superuser (or an explicit + ``GRANT EXECUTE``); ``testuser`` has neither. Rather than add a second + no-reset baseline-delta design (option (b)), connecting as ``postgres`` + keeps the API a simple reset()/calls_for() pair, matching the brief. + * The superuser sees ALL sessions' statements on that node (not just its + own), which matters here because the workload runs through ProxySQL as + ``testuser`` while the oracle needs a clear, unfiltered view. + +The password is NOT a fixed/default credential: the infra entrypoint +(test/infra/infra-dbdeployer-pgsql17-repl/docker/entrypoint.sh) sets +``ALTER ROLE postgres WITH PASSWORD '${ROOT_PASSWORD}'`` where +``ROOT_PASSWORD=$(echo -n "${INFRA_ID}" | sha256sum | head -c 10)`` (see +docker-compose-init.bash). ``run-pg-compat.bash`` already forwards +``INFRA_ID`` into the pytest container (``-e INFRA_ID``), so the same +derivation is reproduced here in Python rather than requiring a new env +var or touching the infra entrypoint. + +Verified live against sdd-sp2: connecting as +``postgres`` / ``sha256("sdd-sp2")[:10]`` to each of the three per-node +DSNs (host+port from PGCOMPAT_{PRIMARY,REPLICA1,REPLICA2}_{HOST,PORT} -- +there is no single PGCOMPAT_BACKEND_PORT, see harness/targets.py) succeeds, +``pg_stat_statements_reset()`` runs, and a probe query +``SELECT 42 AS oracle_probe`` is normalized by pg_stat_statements to +``SELECT $1 AS oracle_probe`` (literal folded to a parameter placeholder, +as expected). A substring pattern like ``%oracle_probe%`` still matches the +normalized text since "oracle_probe" itself is not a literal that gets +folded, so callers do not need to spell out ``$1`` in their pattern. +""" +import hashlib +import os + +import psycopg + +_BACKENDS = { + "primary": ("PGCOMPAT_PRIMARY_HOST", "PGCOMPAT_PRIMARY_PORT"), + "replica1": ("PGCOMPAT_REPLICA1_HOST", "PGCOMPAT_REPLICA1_PORT"), + "replica2": ("PGCOMPAT_REPLICA2_HOST", "PGCOMPAT_REPLICA2_PORT"), +} + + +def _root_password(): + # Same derivation as docker-compose-init.bash: + # ROOT_PASSWORD=$(echo -n "${INFRA_ID}" | sha256sum | head -c 10) + infra_id = os.environ["INFRA_ID"] + return hashlib.sha256(infra_id.encode()).hexdigest()[:10] + + +def _dsn(host, port): + return ( + f"host={host} port={port} user=postgres password={_root_password()} " + f"dbname=testuser sslmode=disable client_encoding=UTF8" + ) + + +def _connect(name): + host_var, port_var = _BACKENDS[name] + return psycopg.connect( + _dsn(os.environ[host_var], os.environ[port_var]), + autocommit=True, + ) + + +def reset_all(): + """Reset pg_stat_statements counters on every backend node.""" + for name in _BACKENDS: + with _connect(name) as conn, conn.cursor() as cur: + cur.execute("SELECT pg_stat_statements_reset()") + + +def calls_for(pattern): + """Sum of ``calls`` per node for statements whose normalized query text + LIKE-matches ``pattern`` (e.g. ``"%oracle_probe%"``). + + Returns ``{"primary": int, "replica1": int, "replica2": int}``. + """ + out = {} + for name in _BACKENDS: + with _connect(name) as conn, conn.cursor() as cur: + cur.execute( + "SELECT COALESCE(SUM(calls), 0) FROM pg_stat_statements " + "WHERE query LIKE %s", + (pattern,), + ) + out[name] = int(cur.fetchone()[0]) + return out diff --git a/test/pg-compat/tests/test_routing_oracle.py b/test/pg-compat/tests/test_routing_oracle.py new file mode 100644 index 0000000000..498491e664 --- /dev/null +++ b/test/pg-compat/tests/test_routing_oracle.py @@ -0,0 +1,58 @@ +"""Routing oracle: prove *where* a query actually landed by reading each +backend node's own ``pg_stat_statements`` (harness/oracle.py), rather than +trusting ProxySQL's own reporting of what it routed. + +IMPORTANT subtlety (do not "fix" by routing verification reads through the +proxy): a verification query like ``SELECT count(*) FROM pg_stat_statements`` +issued THROUGH the proxy would itself match the ``^SELECT`` reader rule and +get routed to a replica, polluting the very counts being inspected. So +``oracle.calls_for()`` connects directly to each backend node (as the +sandbox superuser -- see harness/oracle.py docstring for why), never through +ProxySQL. +""" +from harness import oracle + +# Real (not TEMP) table: a TEMP table's DDL/DML still executes against +# whichever hostgroup the statement is routed to under the ^SELECT rule +# (write statements -> writer), but a TEMP table would never physically +# replicate to the readers even if a read against it were misrouted there -- +# which would silently launder a real routing bug. A REAL table makes a +# leaked read-on-replica or leaked write-on-replica actually observable via +# pg_stat_statements. Dropped at the end of the test for idempotency across +# repeated runs and across the differential suite (see run-pg-compat.bash). +WRITE_TABLE = "oracle_w" + + +def test_select_lands_on_a_reader(proxy_conn): + oracle.reset_all() + with proxy_conn.cursor() as cur: + for _ in range(20): + cur.execute("SELECT 42 AS oracle_probe") + cur.fetchone() + counts = oracle.calls_for("%oracle_probe%") + # read/write split: SELECTs must hit readers (replicas), never the + # primary/writer. + assert counts["primary"] == 0, f"SELECT hit the primary: {counts}" + assert counts["replica1"] + counts["replica2"] == 20, ( + f"reads not fully accounted for on the replicas: {counts}" + ) + + +def test_write_pins_to_primary(proxy_conn): + oracle.reset_all() + try: + with proxy_conn.cursor() as cur: + cur.execute(f"CREATE TABLE IF NOT EXISTS {WRITE_TABLE} (id int)") + cur.execute(f"INSERT INTO {WRITE_TABLE} VALUES (1)") + counts = oracle.calls_for(f"%{WRITE_TABLE}%") + assert counts["replica1"] == 0 and counts["replica2"] == 0, ( + f"write leaked to a replica: {counts}" + ) + assert counts["primary"] > 0, ( + f"write did not land on the primary at all: {counts}" + ) + finally: + # Always drop, even on assertion failure, so the table never leaks + # into the differential/selfcheck suites or a re-run of this test. + with proxy_conn.cursor() as cur: + cur.execute(f"DROP TABLE IF EXISTS {WRITE_TABLE}") From 5e8435426ea067e62d3399b4eda5b079a9c52efe Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 12:10:58 +0000 Subject: [PATCH 13/16] test(pg-compat): shared behavior set + Python (psycopg3) driver adapter Adds the driver-agnostic behavior set (connect, transactions, prepared, session_isolation) behind a small adapter interface, plus the psycopg3 reference adapter, so SP-3 can add Java/Go/Node adapters against the same behaviors with no changes to the behavior modules. Two adaptations to the original brief, folded in from SP-1 lessons: - session_isolation probes TimeZone instead of application_name, since ProxySQL hardcodes application_name in ignore_vars (lib/PgSQL_Variables.cpp) and never forwards it to the backend. - transactions wraps every verification read in an explicit begin()/commit() so it pins to the writer hostgroup, avoiding a replica read-after-write race under the RW-split (^SELECT -> reader). Verified empirically via the routing oracle: the verify-read lands 2/0/0 on primary/replica1/replica2. Also fixes a real psycopg3 API mismatch in the brief's prepared.py (SQL must use %s placeholders, not raw $1/$2, for cursor.execute(sql, params)). --- test/pg-compat/behaviors/__init__.py | 0 test/pg-compat/behaviors/connect.py | 11 ++++ test/pg-compat/behaviors/prepared.py | 33 ++++++++++ test/pg-compat/behaviors/session_isolation.py | 46 ++++++++++++++ test/pg-compat/behaviors/transactions.py | 56 +++++++++++++++++ test/pg-compat/drivers/__init__.py | 0 test/pg-compat/drivers/python/__init__.py | 0 test/pg-compat/drivers/python/adapter.py | 63 +++++++++++++++++++ test/pg-compat/tests/test_behaviors.py | 20 ++++++ 9 files changed, 229 insertions(+) create mode 100644 test/pg-compat/behaviors/__init__.py create mode 100644 test/pg-compat/behaviors/connect.py create mode 100644 test/pg-compat/behaviors/prepared.py create mode 100644 test/pg-compat/behaviors/session_isolation.py create mode 100644 test/pg-compat/behaviors/transactions.py create mode 100644 test/pg-compat/drivers/__init__.py create mode 100644 test/pg-compat/drivers/python/__init__.py create mode 100644 test/pg-compat/drivers/python/adapter.py create mode 100644 test/pg-compat/tests/test_behaviors.py diff --git a/test/pg-compat/behaviors/__init__.py b/test/pg-compat/behaviors/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/pg-compat/behaviors/connect.py b/test/pg-compat/behaviors/connect.py new file mode 100644 index 0000000000..1d10aea54b --- /dev/null +++ b/test/pg-compat/behaviors/connect.py @@ -0,0 +1,11 @@ +"""Driver-agnostic behavior: a fresh connection can run a trivial query. + +The simplest possible contract -- if this fails, nothing else in the +behavior set is meaningful for that driver/target. +""" + + +def run(Adapter): + a = Adapter() + assert a.exec_simple("SELECT 1")[0][0] == 1 + a.close() diff --git a/test/pg-compat/behaviors/prepared.py b/test/pg-compat/behaviors/prepared.py new file mode 100644 index 0000000000..2831d4a21a --- /dev/null +++ b/test/pg-compat/behaviors/prepared.py @@ -0,0 +1,33 @@ +"""Driver-agnostic behavior: a parameterized statement, reused many times, +keeps working across ProxySQL's connection multiplexing. + +psycopg3 auto-prepares a statement (turning it into a real extended-protocol +Parse/Bind/Execute sequence, not just a client-side text substitution) after +it has been executed more than ``prepare_threshold`` times (default 5) on +the same connection -- see psycopg's ``prepared.py``. Looping 50 times here +guarantees the driver crosses that threshold, so the back half of this loop +genuinely exercises real server-side prepared statements multiplexed by +ProxySQL, not merely simple-protocol round trips. + +Placeholder syntax note (a real adaptation, not a style choice): the brief +wrote the query with raw PostgreSQL positional placeholders (``$1``, ``$2``). +That is NOT what psycopg3's ``cursor.execute(sql, params)`` expects on the +client side -- psycopg (2 and 3 alike) uses ``%s`` placeholders in the SQL +text it is given and translates them to ``$1``/``$2``/... itself when it +builds the wire-protocol Bind message. Passing ``$1``/``$2`` literally +through ``execute()`` makes psycopg count zero ``%s`` placeholders in the +query while two params were supplied, raising +``psycopg.ProgrammingError: the query has 0 placeholders but 2 parameters +were passed`` (reproduced against this exact behavior before this fix). The +query text below therefore uses ``%s``; the actual wire protocol PostgreSQL +sees (and what ProxySQL multiplexes) still uses ``$1``/``$2`` -- that +translation is exactly what the driver is for. +""" + + +def run(Adapter): + a = Adapter() + for i in range(50): + r = a.exec_params("SELECT %s::int + %s::int", (i, 1)) + assert r[0][0] == i + 1 + a.close() diff --git a/test/pg-compat/behaviors/session_isolation.py b/test/pg-compat/behaviors/session_isolation.py new file mode 100644 index 0000000000..a64290b694 --- /dev/null +++ b/test/pg-compat/behaviors/session_isolation.py @@ -0,0 +1,46 @@ +"""Driver-agnostic behavior: session state set on one connection must not +leak to a different connection. + +Trap avoided here (folded in from SP-1's lessons, ahead of the original +brief which probed ``application_name``): ProxySQL explicitly lists +``application_name`` in ``ignore_vars`` (see ``lib/PgSQL_Variables.cpp``) -- +it is never forwarded to or tracked against the backend, which hardcodes +``application_name = 'proxysql'`` on its own server connections. That means +``SHOW application_name`` can NEVER reflect a client ``SET``, through +ProxySQL, and a probe built on it would fail for a reason that has nothing +to do with session isolation. This behavior instead probes ``TimeZone``, +which IS a tracked/forwarded/reset variable (``pgsql_tracked_variables[]`` +in ``include/proxysql_structs.h``) -- the same swap validated by SP-1's +pool-churn TAP test. + +Structure: connection A sets the distinctive value and asserts it, then +CLOSES before connection B ever opens. This makes backend-connection reuse +between A and B at least *possible* (A's backend connection is free by the +time B asks for one), so this behavior is testing the real cross-driver +contract: IF B happens to land on the same physical backend connection A +just released, ProxySQL must have reset/not-inherited A's session state. +Note for anyone tightening this later: a trivial pass is possible here if B +lands on a *different* backend than A (then there was never any shared +state to leak in the first place) -- this is an inherent limitation of a +black-box, connection-pool-driven probe run against a live multi-backend +pool where which physical backend serves which client session is not +observable/controllable from here. The deterministic, single-backend +variant (that forces A and B onto the very same backend and proves the +reset explicitly) lives in SP-1's TAP test; this behavior's job is only to +exercise the same contract identically across every driver adapter (Python +here, Java/Go/Node in SP-3). +""" + +DISTINCTIVE_TZ = "Antarctica/Troll" + + +def run(Adapter): + a = Adapter() + a.exec_simple(f"SET TimeZone = '{DISTINCTIVE_TZ}'") + assert a.exec_simple("SHOW TimeZone")[0][0] == DISTINCTIVE_TZ + a.close() + + b = Adapter() + val = b.exec_simple("SHOW TimeZone")[0][0] + b.close() + assert val != DISTINCTIVE_TZ, "session state leaked across connections" diff --git a/test/pg-compat/behaviors/transactions.py b/test/pg-compat/behaviors/transactions.py new file mode 100644 index 0000000000..ad70fac3f9 --- /dev/null +++ b/test/pg-compat/behaviors/transactions.py @@ -0,0 +1,56 @@ +"""Driver-agnostic behavior: BEGIN/COMMIT/ROLLBACK are honored end-to-end +through ProxySQL. + +Read-after-write through the RW-split (trap, see SP-1 lessons folded into +this SP-2 task): the infra routes a bare ``^SELECT`` to a READER hostgroup. +``INSERT ... COMMIT`` followed by a bare ``SELECT count(*)`` would send the +verification read to a replica, where the freshly committed row may not be +visible yet (streaming replication lag) -- a flake that has nothing to do +with transaction semantics. + +Fix applied here: every verification read runs inside its own explicit +``begin()``/``commit()`` pair. ``BEGIN`` does not match ``^SELECT``, so it +takes the default hostgroup (the writer); ProxySQL then keeps that +transaction pinned to the same backend connection until it commits, so the +SELECT inside it is also pinned to the writer -- the same connection that +just did the INSERT/COMMIT, so there is no cross-node replication lag to +race. This was verified empirically against the oracle (see the task +report): the verify-SELECT below carries a distinctive column alias +(``AS verify_read``) precisely so it can be isolated in +``pg_stat_statements`` and proven to land only on the primary, never a +replica. +""" + +TABLE = "behavior_tx_t" + + +def run(Adapter): + a = Adapter() + try: + a.exec_simple(f"DROP TABLE IF EXISTS {TABLE}") + a.exec_simple(f"CREATE TABLE {TABLE} (id int)") + + a.begin() + a.exec_simple(f"INSERT INTO {TABLE} VALUES (1)") + a.rollback() + + a.begin() + count = a.exec_simple(f"SELECT count(*) AS verify_read FROM {TABLE}")[0][0] + a.commit() + assert count == 0, "rollback did not discard the insert" + + a.begin() + a.exec_simple(f"INSERT INTO {TABLE} VALUES (2)") + a.commit() + + a.begin() + count = a.exec_simple(f"SELECT count(*) AS verify_read FROM {TABLE}")[0][0] + a.commit() + assert count == 1, "commit did not persist the insert" + finally: + # Leave no state behind, whether or not the assertions above passed, + # and use a table name distinct from other behaviors/tests + # (harness/oracle.py's own probe table is "oracle_w") so runs never + # collide. + a.exec_simple(f"DROP TABLE IF EXISTS {TABLE}") + a.close() diff --git a/test/pg-compat/drivers/__init__.py b/test/pg-compat/drivers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/pg-compat/drivers/python/__init__.py b/test/pg-compat/drivers/python/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/pg-compat/drivers/python/adapter.py b/test/pg-compat/drivers/python/adapter.py new file mode 100644 index 0000000000..28ca76a572 --- /dev/null +++ b/test/pg-compat/drivers/python/adapter.py @@ -0,0 +1,63 @@ +"""Reference driver adapter: psycopg3 against the ProxySQL PG frontend. + +This is the reuse mechanism SP-3 hooks Java/Go/Node adapters into: each +behavior in ``behaviors/`` is written once against a small adapter +interface (``connect`` via the constructor, ``exec_simple``, ``exec_params``, +``begin``/``commit``/``rollback``, ``close``) and every driver gets the same +behavior for free by implementing that interface. + +Named-prepared-statement methods (``prepare(name, sql)`` / +``exec_prepared(name, params)``) are deliberately NOT implemented here. +psycopg3 has no explicit named-prepared-statement API of its own -- it +auto-prepares a parameterized statement after it has been executed +``prepare_threshold`` (default 5) times on the same connection (see +psycopg's ``prepared.py``), which is exactly what ``behaviors/prepared.py`` +exercises through ``exec_params``. Add ``prepare``/``exec_prepared`` here +(and to the shared behaviors) only when a driver that needs explicit named +statements (e.g. a Node/Go client) is wired up in SP-3. + +Env contract: connects to ``PGCOMPAT_PROXY_HOST``/``PGCOMPAT_PROXY_PORT`` +(testuser/testuser, db ``testuser`` by default) -- the same ProxySQL PG +frontend used everywhere else in this harness (see conftest.py / +harness/targets.py). ``client_encoding=UTF8`` is pinned in the DSN for the +same reason targets.py pins it: the dbdeployer backend databases default to +SQL_ASCII, which psycopg maps to Python's restrictive 'ascii' codec. +""" +import os + +import psycopg + + +class PsycopgAdapter: + def __init__(self, dbname="testuser"): + h = os.environ["PGCOMPAT_PROXY_HOST"] + p = os.environ["PGCOMPAT_PROXY_PORT"] + self.conn = psycopg.connect( + f"host={h} port={p} user=testuser password=testuser dbname={dbname} " + f"sslmode=disable client_encoding=UTF8", + autocommit=True, + ) + + def exec_simple(self, sql): + with self.conn.cursor() as cur: + cur.execute(sql) + return cur.fetchall() if cur.description else None + + def exec_params(self, sql, params, binary=False): + with self.conn.cursor(binary=binary) as cur: + cur.execute(sql, params) + return cur.fetchall() if cur.description else None + + def begin(self): + self.conn.autocommit = False + + def commit(self): + self.conn.commit() + self.conn.autocommit = True + + def rollback(self): + self.conn.rollback() + self.conn.autocommit = True + + def close(self): + self.conn.close() diff --git a/test/pg-compat/tests/test_behaviors.py b/test/pg-compat/tests/test_behaviors.py new file mode 100644 index 0000000000..dac6c2d87e --- /dev/null +++ b/test/pg-compat/tests/test_behaviors.py @@ -0,0 +1,20 @@ +"""Shared, driver-agnostic behavior set run against the Python (psycopg3) +adapter. The reuse mechanism: each module under ``behaviors/`` defines a +single ``run(Adapter)`` that is written once against the small adapter +interface in ``drivers/python/adapter.py`` -- SP-3 adds Java/Go/Node +adapters and parametrizes this same behavior list over them, with zero +changes to the behavior modules themselves. +""" +import pytest + +from behaviors import connect, transactions, prepared, session_isolation +from drivers.python.adapter import PsycopgAdapter + +BEHAVIORS = [connect, transactions, prepared, session_isolation] + + +@pytest.mark.parametrize( + "behavior", BEHAVIORS, ids=[b.__name__.split(".")[-1] for b in BEHAVIORS] +) +def test_behavior_python(behavior): + behavior.run(PsycopgAdapter) From 1745a9d8337c46e631714583e75fb5f75d7bcd65 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 12:17:36 +0000 Subject: [PATCH 14/16] test(pg-compat): xfail catalogue with xpass reporting (discovery phase) --- test/pg-compat/README.md | 52 +++++++++++++++++++++++++--- test/pg-compat/conftest.py | 27 +++++++++++++++ test/pg-compat/harness/xfail.py | 42 +++++++++++++++++++++++ test/pg-compat/pytest.ini | 5 +++ test/pg-compat/xfail.toml | 60 +++++++++++++++++++++++++++++++++ 5 files changed, 181 insertions(+), 5 deletions(-) create mode 100644 test/pg-compat/harness/xfail.py create mode 100644 test/pg-compat/xfail.toml diff --git a/test/pg-compat/README.md b/test/pg-compat/README.md index 0997ab9e1a..5a81ea20fc 100644 --- a/test/pg-compat/README.md +++ b/test/pg-compat/README.md @@ -9,9 +9,8 @@ interface, running against the `infra-dbdeployer-pgsql17-repl` backend This is a **discovery-phase, non-gating** suite (see the "Global Constraints" / §2.1 framing in the plan below): its first job is to build a failure inventory, not to be all-green. Known divergences are recorded as -`xfail` entries (added in a later task; see -`docs/superpowers/plans/2026-07-08-pgsql-sp2-polyglot-foundation.md`) rather -than by loosening assertions. +`xfail` entries in `xfail.toml` (see "xfail / finding catalogue" below) +rather than by loosening assertions. ## Running @@ -69,14 +68,57 @@ privileges without that restriction and is intended for exactly this kind of remote/containerized use. See the docstring in `harness/proxysql.py` for the empirical verification. +## xfail / finding catalogue + +`xfail.toml` is the suite's living failure inventory (spec §2.1). It has two +independent sections — see the loader in `harness/xfail.py` and the header +comment in `xfail.toml` itself for the authoritative format: + +- **`[[xfail]]`** — a known divergence that currently makes one specific + test fail. Each entry maps a pytest `test_id` (`item.nodeid`, e.g. + `tests/test_differential.py::test_case_is_transparent[002_bytea_json_array.sql]`) + to `mode` (`libpq` | `native` | `both`), a human `reason`, and a tracking + `ref` (issue/PR). `conftest.py`'s `pytest_collection_modifyitems` hook + applies `pytest.mark.xfail(reason=..., strict=False)` to every listed + `test_id` at collection time. Because `strict=False`: + - the test still runs every time, so nothing is silently skipped; + - while the divergence persists, it reports `xfailed` — an *expected* + failure — instead of `failed`, so the suite stays green without + loosening any assertion; + - once the underlying bug is fixed, the *same* test starts passing and is + reported `xpassed` (visible with `-rxX`, on by default via `pytest.ini`'s + `addopts = -rxX`). An `xpassed` report is the signal to delete that + entry from `xfail.toml` — the fix landed. Do not "fix" an xpass by + flipping to `strict=True`; the point is that closing a divergence is + detected by the run itself, not by someone remembering to edit the toml. + - **To add an entry:** find (or intentionally reproduce) the failing + `test_id`, add a `[[xfail]]` block with `test_id`/`mode`/`reason`/`ref`, + re-run — it should now report `xfailed` rather than `failed`. +- **`[[finding]]`** — a real, verified ProxySQL behavioral difference + discovered while building this suite that does **not** currently fail any + test (typically because the harness neutralizes it, e.g. by pinning a + session parameter so a differential comparison stays apples-to-apples). + It has no `test_id` to attach an xfail marker to, so it's recorded here + instead purely so the finding isn't lost. Fields: `summary`, `mode`, + `detail`, `discovered_by`, `ref`. `harness/xfail.py::findings()` exposes + these for tooling; nothing in `conftest.py` acts on them automatically. + Today's one entry: ProxySQL imposes `client_encoding=UTF8` on backend + connections instead of inheriting the server default (discovered against + the SQL_ASCII dbdeployer backend in Task 6; see `xfail.toml` for detail). + ## Layout - `harness/proxysql.py` — `Admin` class: `query`, `set_var`, `load_vars`, `snapshot`, `restore` — the read/modify/LOAD/verify/restore cycle used by every test that flips a ProxySQL runtime variable. +- `harness/xfail.py` — `load()` / `findings()`: loaders for the two sections + of `xfail.toml` (see "xfail / finding catalogue" above). - `conftest.py` — `admin` (session-scoped `Admin`) and `proxy_conn` (function-scoped psycopg connection to the ProxySQL PG frontend) - fixtures shared by all tests. -- `tests/` — the test suite (`test_smoke.py` today). + fixtures shared by all tests, plus the `pytest_collection_modifyitems` + hook that applies xfail markers from `xfail.toml`. +- `tests/` — the test suite (`test_smoke.py`, `test_behaviors.py`, + `test_differential.py`, `test_differential_selfcheck.py`, + `test_routing_oracle.py` today). - `SPIKE-dbdeployer-pg.md` — prior spike notes on the dbdeployer PG infra; left as-is. diff --git a/test/pg-compat/conftest.py b/test/pg-compat/conftest.py index 511f6f5758..8f47c19755 100644 --- a/test/pg-compat/conftest.py +++ b/test/pg-compat/conftest.py @@ -3,6 +3,7 @@ import psycopg import pytest +from harness import xfail as _xfail from harness.proxysql import Admin @@ -22,3 +23,29 @@ def proxy_conn(): conn = psycopg.connect(_proxy_dsn(), autocommit=True) yield conn conn.close() + + +# --- xfail catalogue (discovery-phase reporting; spec sec 2.1) ------------- +# +# test/pg-compat/xfail.toml's [[xfail]] entries are keyed by pytest test_id +# (item.nodeid, e.g. "tests/test_differential.py::test_case_is_transparent +# [002_bytea_json_array.sql]"). Any test_id listed there gets an +# xfail(strict=False) marker applied at collection time: +# - while the entry's divergence persists, the test reports "xfailed" +# (an expected failure) instead of "failed" -- the suite stays green +# without loosening any assertion; +# - if/when the underlying bug is fixed, the SAME test starts passing and +# is reported "xpassed" (visible with `-rxX`) because strict=False -- +# that's the signal the entry should be removed from xfail.toml. +_XFAILS = {e["test_id"]: e for e in _xfail.load()} + + +def pytest_collection_modifyitems(config, items): + for item in items: + entry = _XFAILS.get(item.nodeid) + if entry: + item.add_marker( + pytest.mark.xfail( + reason=f'{entry["reason"]} ({entry["ref"]})', strict=False + ) + ) diff --git a/test/pg-compat/harness/xfail.py b/test/pg-compat/harness/xfail.py new file mode 100644 index 0000000000..44590ba34b --- /dev/null +++ b/test/pg-compat/harness/xfail.py @@ -0,0 +1,42 @@ +"""Loader for the pg-compat xfail / finding catalogue (test/pg-compat/xfail.toml). + +Two independent sections live in that one file (see its header comment and +README.md for the full policy): + + [[xfail]] -- known divergences that currently fail a specific test_id. + load() returns these; conftest.py turns each into an + xfail(strict=False) marker on the matching test. + [[finding]] -- verified ProxySQL behavioral differences that do NOT + currently fail any test (e.g. because the harness + neutralizes them), so there is no test_id to attach a + marker to. findings() returns these for completeness / + tooling; nothing in conftest.py consumes them today. +""" +import os + +import tomli + +_XFAIL_TOML = os.path.join(os.path.dirname(__file__), "..", "xfail.toml") + + +def _load_toml(): + if not os.path.exists(_XFAIL_TOML): + return {} + with open(_XFAIL_TOML, "rb") as f: + return tomli.load(f) + + +def load(): + """Return the list of [[xfail]] entries (each a dict with test_id/mode/reason/ref). + + Returns [] if xfail.toml is missing entirely. + """ + return _load_toml().get("xfail", []) + + +def findings(): + """Return the list of [[finding]] entries (each a dict with summary/mode/detail/discovered_by/ref). + + Returns [] if xfail.toml is missing entirely. + """ + return _load_toml().get("finding", []) diff --git a/test/pg-compat/pytest.ini b/test/pg-compat/pytest.ini index 5ee6477165..3182775ee3 100644 --- a/test/pg-compat/pytest.ini +++ b/test/pg-compat/pytest.ini @@ -1,2 +1,7 @@ [pytest] testpaths = tests +# -r xX: always surface xfailed/xpassed summary lines by default (discovery- +# phase reporting, spec sec 2.1) so an xpass -- a fix that closed a catalogued +# divergence in xfail.toml -- is never missed just because -rxX wasn't passed +# on the command line. Still overridable/augmentable via extra -r flags. +addopts = -rxX diff --git a/test/pg-compat/xfail.toml b/test/pg-compat/xfail.toml new file mode 100644 index 0000000000..4b8066c90f --- /dev/null +++ b/test/pg-compat/xfail.toml @@ -0,0 +1,60 @@ +# pg-compat xfail / finding catalogue +# +# This is a discovery-phase suite (spec sec 2.1): its job is to build a +# living FAILURE INVENTORY, not to stay all-green by construction. Two +# distinct kinds of entries live here: +# +# [[xfail]] -- a KNOWN DIVERGENCE that currently makes a specific test +# fail. Each entry maps one failing test_id to a reason + tracking ref, and +# conftest.py's pytest_collection_modifyitems() marks that test_id +# xfail(strict=False): +# - the test still runs every time; +# - while the divergence persists it reports "xfailed" (expected +# failure), not "failed" -- the suite stays green without anyone +# loosening an assertion; +# - once the underlying bug is fixed the SAME test starts passing and is +# reported as "xpassed" (visible with -rxX) BECAUSE strict=false. That +# is the signal to remove the entry -- the fix landed and the divergence +# is closed. Do NOT flip to strict=true as a "fix": the point of this +# catalogue is that a fix is detected automatically by the run, not by +# someone remembering to update the toml first. +# +# Format (see harness/xfail.py for the loader): +# +# [[xfail]] +# test_id = "tests/test_differential.py::test_case_is_transparent[002_bytea_json_array.sql]" +# mode = "native" # libpq | native | both -- which backend-protocol axis (spec 2.2) this applies to +# reason = "proxy_native_binary bytea OID mismatch on the young native backend path" +# ref = "PR #5882" +# +# There are currently NO active xfail entries: the suite is all-green +# (16 passed, 2 native-path skips pending PR #5882). The list below is +# intentionally empty. + +# [[finding]] -- a REAL, VERIFIED ProxySQL behavioral difference that was +# discovered while building this suite but that does NOT (today) fail any +# test -- typically because the harness neutralizes it (e.g. by pinning a +# session parameter so both sides of a differential comparison are +# apples-to-apples). It has no test_id to attach an xfail marker to, so it +# is recorded here instead, to make sure the finding isn't lost just +# because nothing is currently red. +# +# Fields: summary, mode (libpq | native | both), detail, discovered_by, ref. + +[[finding]] +summary = "ProxySQL imposes client_encoding=UTF8 on backend connections instead of inheriting the server default" +mode = "libpq" +detail = """ +Verified live on the sdd-sp2 infra against the SQL_ASCII dbdeployer backend: +a DIRECT session's client_encoding defaults to SQL_ASCII (the initdb +default), while a session THROUGH ProxySQL reports client_encoding=UTF8 +regardless of the backend's actual default (`SHOW client_encoding`: direct +-> SQL_ASCII, via proxy -> UTF8). This is a session-default / parameter- +transparency divergence, not a row-level query divergence, so it does not +fail the differential engine's compare(). The harness neutralizes it by +pinning client_encoding=UTF8 on EVERY target (proxy and direct alike) in +harness/targets.py::_dsn() so runs are apples-to-apples; that pin is why +this finding has no failing test_id to list under [[xfail]]. +""" +discovered_by = "task 6 differential engine (SQL_ASCII backend)" +ref = "SP-2 Task 6 report; candidate ProxySQL issue" From a95fa02f8a5f51777f4c960266a18030818579a9 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 12:32:44 +0000 Subject: [PATCH 15/16] ci(pg-compat): nightly + label-gated workflow (caller + staged reusable, non-gating) Wires test/pg-compat/ into CI: schedule (nightly) + pg-compat-labeled PRs + manual dispatch, building ProxySQL inline (CI-3p-* model) rather than chaining off CI-trigger/CI-builds. Non-gating during the discovery phase (spec sec 2.1): the run step uses `|| true` and publishes the junitxml as an artifact regardless of outcome. Follows the two-branch caller/reusable split: CI-pg-compat.yml (caller, v3.0) + gh-actions-reusable/ci-pg-compat.yml (reusable, staged here for merge to GH-Actions first, per doc/GH-Actions/README.md's merge-order requirement -- both files call this out at the top). Also fixes a real bug in run-pg-compat.bash traced while wiring this up: the pytest container runs with --rm and had no volume mount, so any --junitxml report written inside it was destroyed on exit before ever reaching the host. run-pg-compat.bash now bind-mounts a host directory (default ${WORKSPACE}/pg-compat-reports, override via PGCOMPAT_REPORT_DIR) to /pg-compat-reports in the container so reports survive; verified end-to-end with a local dry run (throwaway ci-dryrun infra) that the file lands on the host at the exact path the CI artifact step reads, including the root-owned-file chown needed before upload. --- .github/workflows/CI-pg-compat.yml | 47 +++++++ .../gh-actions-reusable/ci-pg-compat.yml | 124 ++++++++++++++++++ .gitignore | 4 + test/pg-compat/README.md | 33 +++++ test/pg-compat/run-pg-compat.bash | 16 +++ 5 files changed, 224 insertions(+) create mode 100644 .github/workflows/CI-pg-compat.yml create mode 100644 .github/workflows/gh-actions-reusable/ci-pg-compat.yml diff --git a/.github/workflows/CI-pg-compat.yml b/.github/workflows/CI-pg-compat.yml new file mode 100644 index 0000000000..64ead1b3cb --- /dev/null +++ b/.github/workflows/CI-pg-compat.yml @@ -0,0 +1,47 @@ +# PAIRED FILE -- caller half of the CI-pg-compat pair (lives on v3.0). The +# reusable half, .github/workflows/gh-actions-reusable/ci-pg-compat.yml, is +# staged in THIS repo for review but must be merged to the `GH-Actions` +# branch FIRST, at path `.github/workflows/ci-pg-compat.yml`, before (never +# after) this caller file merges to v3.0 -- see doc/GH-Actions/README.md +# "Merge order" (~line 816): `workflow_run`/`workflow_call` references are +# only resolved against files that already exist on the target branch, so a +# caller landing before its reusable exists on GH-Actions fails immediately +# with "Unable to resolve action". See doc/GH-Actions/README.md (~lines +# 42-170) for the full two-branch caller/reusable split rationale. +name: CI-pg-compat + +on: + schedule: + - cron: '0 3 * * *' + pull_request: + types: [opened, synchronize, reopened, labeled] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref_name }} + cancel-in-progress: true + +jobs: + pg-compat: + # Runs on: the nightly schedule, a manual dispatch, or a pull_request + # that carries the 'pg-compat' label (checked on every listed pull_request + # type, including 'labeled', so adding the label to an already-open PR + # triggers a run without needing a new commit). Unlike the TAP families, + # this does NOT chain off CI-trigger/CI-builds -- it builds ProxySQL + # inline in the reusable job, so it doesn't need CI-builds' cache to + # exist first (nightly/label runs have no guaranteed prior CI-builds run + # to restore from). + if: >- + github.event_name == 'schedule' || + github.event_name == 'workflow_dispatch' || + contains(github.event.pull_request.labels.*.name, 'pg-compat') + # write-all: reusable-workflow permissions are the intersection of + # caller + callee: the callee at ci-pg-compat.yml@GH-Actions also + # declares write-all (needed for actions/upload-artifact's write scope + # under the pull_request event, matching CI-3p-postgresql.yml's + # documented rationale). + permissions: write-all + uses: sysown/proxysql/.github/workflows/ci-pg-compat.yml@GH-Actions + secrets: inherit + with: + trigger: ${{ toJson(github) }} diff --git a/.github/workflows/gh-actions-reusable/ci-pg-compat.yml b/.github/workflows/gh-actions-reusable/ci-pg-compat.yml new file mode 100644 index 0000000000..0b0c8a4e49 --- /dev/null +++ b/.github/workflows/gh-actions-reusable/ci-pg-compat.yml @@ -0,0 +1,124 @@ +# STAGED FILE -- this is the reusable half of the CI-pg-compat pair. +# It is authored here (on v3.0, under gh-actions-reusable/) for review, but +# it does NOT run from here. It must be merged to the `GH-Actions` branch +# at path `.github/workflows/ci-pg-compat.yml` FIRST, before (or in the same +# merge window as, but not after) the caller `.github/workflows/CI-pg-compat.yml` +# lands on `v3.0` -- see doc/GH-Actions/README.md "Merge order" (~line 816): +# a caller referencing `ci-pg-compat.yml@GH-Actions` before that file exists +# on GH-Actions fails immediately with "Unable to resolve action". See the +# two-branch caller/reusable split explained in doc/GH-Actions/README.md +# (~lines 42-170): callers (`CI-*.yml`, uppercase) live on `v3.0`; reusables +# (`ci-*.yml`, lowercase) live on `GH-Actions`. +name: CI-pg-compat + +on: + workflow_dispatch: + workflow_call: + inputs: + trigger: + type: string + +# No env.SHA/trigger-JSON parsing here (unlike the workflow_run-triggered +# reusables, e.g. ci-legacy-g4.yml): those need it because their caller is +# invoked BY workflow_run, whose own github.sha is the default branch tip, +# not the real source commit -- the real sha only exists inside the passed +# `trigger` JSON. This caller triggers directly via pull_request/schedule/ +# workflow_dispatch, so github.sha here (a workflow_call callee inherits the +# caller's context) already IS the right commit; `inputs.trigger` is kept +# only for parity with the sibling callers' `with: trigger: ...` shape and +# isn't parsed for a sha. checkout below uses actions/checkout@v4's default +# ref (the triggering ref), so no untrusted github.event.* field is ever +# substituted into a `ref:`. + +jobs: + pg-compat: + runs-on: ubuntu-22.04 + # Generous budget: a from-scratch `PROXYSQL31=1 make debug` (deps -> lib + # -> src) on a 2-core GH-hosted runner is the dominant cost here (there + # is no build-cache restore in this job, unlike the CI-builds-fed TAP + # families -- this suite runs inline, like the CI-3p-* family, since its + # schedule/label triggers have no guaranteed prior CI-builds run to + # restore a cache from). + timeout-minutes: 120 + permissions: write-all + steps: + - name: Checkout + uses: actions/checkout@v4 + + # Inline build (CI-3p-* model, not the CI-trigger/CI-builds cache-chain + # model used by the TAP families): no ccache pattern exists elsewhere + # in this repo's workflows (checked both branches) to reuse, so this + # is a plain build for v1. PROXYSQL31=1 is required -- bare `make` + # would leave FFTO/TSDB symbols out and is not what any tier actually + # ships; debug is required because the isolated harness + # (start-proxysql-isolated.bash / ensure-infras.bash) issues + # debug-only admin commands. + - name: Build ProxySQL (debug, PROXYSQL31) + run: PROXYSQL31=1 make -j$(nproc) debug + + # Stand up the pg-compat infra: dbdeployer PG17 primary+2-replica + # backend, Toxiproxy sidecar, and the ProxySQL container built above + # (ensure-infras.bash starts ProxySQL itself via + # start-proxysql-isolated.bash if it isn't already running -- see + # test/infra/control/ensure-infras.bash step 2). Never manage Docker + # by hand here; this script is the only supported entry point. + - name: Stand up infra (backends + Toxiproxy + ProxySQL) + env: + INFRA_ID: ci-${{ github.run_id }} + WORKSPACE: ${{ github.workspace }} + TAP_GROUP: pg-compat + run: test/infra/control/ensure-infras.bash + + # Non-gating (discovery phase, spec sec 2.1): the suite's job right now + # is to build a failure inventory in xfail.toml, not to be all-green. + # `|| true` keeps this step (and therefore the job) from failing the + # workflow on real/uncatalogued divergences during discovery. Promote + # to gating by dropping `|| true` (and tightening xfail.toml) once the + # suite is green and stable -- see test/pg-compat/README.md. + # + # --junitxml path: run-pg-compat.bash's container runs with --rm, so a + # report written to the container's own filesystem (e.g. /tmp) would + # be destroyed on exit and never reach this runner -- traced and fixed + # in run-pg-compat.bash, which now bind-mounts a host directory + # (default "${WORKSPACE}/pg-compat-reports", override via + # PGCOMPAT_REPORT_DIR) to /pg-compat-reports inside the container. + # Writing the report there is what makes it visible to the upload + # step below. + - name: Run pg-compat suite (non-gating, discovery phase) + env: + INFRA_ID: ci-${{ github.run_id }} + WORKSPACE: ${{ github.workspace }} + run: test/pg-compat/run-pg-compat.bash --junitxml=/pg-compat-reports/pg-compat.xml -rxX || true + + # The pg-compat container's default user is root, so the bind-mounted + # report directory is root-owned on the host afterwards; chown it back + # to the runner user before upload-artifact (which runs as the + # non-root runner account) tries to read it. Same pattern already + # used for docker-written logs in ci-3p-postgresql.yml. + - name: Fix report ownership + if: always() + run: sudo chown -R "$(id -u):$(id -g)" "${{ github.workspace }}/pg-compat-reports" || true + + - name: Publish report + if: always() + uses: actions/upload-artifact@v4 + with: + name: pg-compat-report + path: ${{ github.workspace }}/pg-compat-reports/pg-compat.xml + if-no-files-found: warn + + # Teardown always runs, mirroring ci-legacy-g4.yml's cleanup step: + # stop the ProxySQL container first, then tear down the backend + + # Toxiproxy infra. destroy-infras.bash is test/infra/control's + # documented teardown entry point (paired with ensure-infras.bash). + - name: Cleanup + if: always() + env: + INFRA_ID: ci-${{ github.run_id }} + WORKSPACE: ${{ github.workspace }} + TAP_GROUP: pg-compat + run: | + set +e + docker logs "proxysql.${INFRA_ID}" 2>&1 | tail -50 || true + test/infra/control/stop-proxysql-isolated.bash + test/infra/control/destroy-infras.bash diff --git a/.gitignore b/.gitignore index 8084d0a006..5c0847984e 100644 --- a/.gitignore +++ b/.gitignore @@ -228,3 +228,7 @@ test-scripts/deps/ test/tap/tests/parsersql_digest_test test/tap/tests/setparser_parsersql_test deps/protobuf/protobuf-*/ + +# pg-compat report output (run-pg-compat.bash's default host bind-mount +# target; see test/pg-compat/README.md "Report output") +/pg-compat-reports/ diff --git a/test/pg-compat/README.md b/test/pg-compat/README.md index 5a81ea20fc..f2a0b7aaf2 100644 --- a/test/pg-compat/README.md +++ b/test/pg-compat/README.md @@ -35,6 +35,39 @@ If ProxySQL was rebuilt, re-run `test/infra/control/start-proxysql-isolated.bash` to pick up the new binary (it only restarts the ProxySQL container, leaving backends up). +### Report output (`--junitxml` and friends) + +`run-pg-compat.bash`'s container runs with `--rm`, so anything pytest writes +to its own filesystem is destroyed the moment the container exits. The +script bind-mounts a host directory to `/pg-compat-reports` inside the +container (default `${WORKSPACE}/pg-compat-reports`, override with +`PGCOMPAT_REPORT_DIR`) so report files survive. Write reports there, e.g.: + +```bash +WORKSPACE=$(pwd) INFRA_ID= test/pg-compat/run-pg-compat.bash \ + --junitxml=/pg-compat-reports/pg-compat.xml -rxX +# report lands at: ${WORKSPACE}/pg-compat-reports/pg-compat.xml +``` + +## CI + +The suite is wired into CI as `CI-pg-compat` (`.github/workflows/CI-pg-compat.yml` +caller on `v3.0` + `ci-pg-compat.yml` reusable on `GH-Actions`, per the +two-branch split in `doc/GH-Actions/README.md`). Unlike the TAP families it +does not chain off `CI-trigger`/`CI-builds`; it builds ProxySQL inline +(`PROXYSQL31=1 make debug`), like the `CI-3p-*` family, since its triggers +have no guaranteed prior `CI-builds` cache to restore from. + +- **Triggers:** nightly at 03:00 UTC (`schedule`), any `pull_request` that + carries the `pg-compat` label, and manual `workflow_dispatch`. +- **Status: non-gating.** Per the discovery-phase framing above, the run + step uses `|| true` so a real/uncatalogued divergence does not fail the + workflow. Promote to gating (drop `|| true`, tighten `xfail.toml`) once + the suite is green and stable. +- **Artifact:** the junitxml report is uploaded as `pg-compat-report` on + every run (`if: always()`), whether the underlying pytest run passed, + xfailed, or hit real failures. + ## Env contract Populated by `test/tap/groups/pg-compat/env.sh` (sourced by diff --git a/test/pg-compat/run-pg-compat.bash b/test/pg-compat/run-pg-compat.bash index 55581412ca..a0e4107717 100755 --- a/test/pg-compat/run-pg-compat.bash +++ b/test/pg-compat/run-pg-compat.bash @@ -37,7 +37,23 @@ while IFS='=' read -r name _; do ENV_ARGS+=("-e" "${name}") done < <(env | grep '^PGCOMPAT_') +# Report output bind mount. The container runs with --rm, so anything pytest +# writes to its own filesystem (e.g. a --junitxml file) is destroyed the +# moment the container exits -- it never reaches the host regardless of "$@". +# Any caller (CI included) that wants a report file back on the host MUST +# write it under /pg-compat-reports inside the container, e.g.: +# run-pg-compat.bash --junitxml=/pg-compat-reports/pg-compat.xml -rxX +# which lands at "${REPORT_DIR}/pg-compat.xml" on the host afterwards. +# Default REPORT_DIR is workspace-relative so CI's github.workspace-based +# artifact-upload path and a dev's ad-hoc invocation both work unmodified; +# override with PGCOMPAT_REPORT_DIR for a different host location. The +# container's default user is root, so files land root-owned on the host; +# CI chowns them back before uploading (see ci-pg-compat.yml). +REPORT_DIR="${PGCOMPAT_REPORT_DIR:-${WORKSPACE}/pg-compat-reports}" +mkdir -p "${REPORT_DIR}" + docker run --rm --network "${NETWORK}" \ -e INFRA_ID \ "${ENV_ARGS[@]}" \ + -v "${REPORT_DIR}:/pg-compat-reports" \ proxysql-pg-compat:latest "$@" From 2085044a5f21551c30f7504b3cae194f0a9eb3f9 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 8 Jul 2026 12:46:24 +0000 Subject: [PATCH 16/16] fix(pg-compat): native-var restore in differential engine + resource/consistency hardening (final review) - diff.py: snapshot/restore pgsql-use_native_backend_protocol around the target loop in _run() (shared by run_case/run_case_sql) so a native-mode toggle never leaks into later cases once PR #5882 lands the variable; a pure no-op today since the variable is absent. - conftest.py: pin client_encoding=UTF8 on the proxy DSN, matching targets.py and drivers/python/adapter.py. - behaviors/{connect,prepared,session_isolation}.py: wrap bodies in try/finally so connections close even on assert failure; make PsycopgAdapter.close() idempotent since session_isolation.py's finally may close an already-closed connection. - behaviors/transactions.py: fix stale comment pointing at a nonexistent harness/oracle.py; oracle_w lives in tests/test_routing_oracle.py. --- test/pg-compat/behaviors/connect.py | 6 ++- test/pg-compat/behaviors/prepared.py | 10 ++-- test/pg-compat/behaviors/session_isolation.py | 24 +++++++--- test/pg-compat/behaviors/transactions.py | 4 +- test/pg-compat/conftest.py | 9 +++- test/pg-compat/drivers/python/adapter.py | 9 +++- test/pg-compat/harness/diff.py | 46 +++++++++++++------ 7 files changed, 77 insertions(+), 31 deletions(-) diff --git a/test/pg-compat/behaviors/connect.py b/test/pg-compat/behaviors/connect.py index 1d10aea54b..4fb36709ef 100644 --- a/test/pg-compat/behaviors/connect.py +++ b/test/pg-compat/behaviors/connect.py @@ -7,5 +7,7 @@ def run(Adapter): a = Adapter() - assert a.exec_simple("SELECT 1")[0][0] == 1 - a.close() + try: + assert a.exec_simple("SELECT 1")[0][0] == 1 + finally: + a.close() diff --git a/test/pg-compat/behaviors/prepared.py b/test/pg-compat/behaviors/prepared.py index 2831d4a21a..43f7e5a0fe 100644 --- a/test/pg-compat/behaviors/prepared.py +++ b/test/pg-compat/behaviors/prepared.py @@ -27,7 +27,9 @@ def run(Adapter): a = Adapter() - for i in range(50): - r = a.exec_params("SELECT %s::int + %s::int", (i, 1)) - assert r[0][0] == i + 1 - a.close() + try: + for i in range(50): + r = a.exec_params("SELECT %s::int + %s::int", (i, 1)) + assert r[0][0] == i + 1 + finally: + a.close() diff --git a/test/pg-compat/behaviors/session_isolation.py b/test/pg-compat/behaviors/session_isolation.py index a64290b694..895cf459e8 100644 --- a/test/pg-compat/behaviors/session_isolation.py +++ b/test/pg-compat/behaviors/session_isolation.py @@ -36,11 +36,21 @@ def run(Adapter): a = Adapter() - a.exec_simple(f"SET TimeZone = '{DISTINCTIVE_TZ}'") - assert a.exec_simple("SHOW TimeZone")[0][0] == DISTINCTIVE_TZ - a.close() + b = None + try: + a.exec_simple(f"SET TimeZone = '{DISTINCTIVE_TZ}'") + assert a.exec_simple("SHOW TimeZone")[0][0] == DISTINCTIVE_TZ + # Close A before B opens (deliberate -- see module docstring): this + # keeps A's backend connection possibly free by the time B asks for + # one. The `finally` below closes A again as a resource-hygiene + # backstop on an assert failure above; the adapter's close() is + # idempotent so that repeat call is a safe no-op. + a.close() - b = Adapter() - val = b.exec_simple("SHOW TimeZone")[0][0] - b.close() - assert val != DISTINCTIVE_TZ, "session state leaked across connections" + b = Adapter() + val = b.exec_simple("SHOW TimeZone")[0][0] + assert val != DISTINCTIVE_TZ, "session state leaked across connections" + finally: + a.close() + if b is not None: + b.close() diff --git a/test/pg-compat/behaviors/transactions.py b/test/pg-compat/behaviors/transactions.py index ad70fac3f9..7525c38ac9 100644 --- a/test/pg-compat/behaviors/transactions.py +++ b/test/pg-compat/behaviors/transactions.py @@ -50,7 +50,7 @@ def run(Adapter): finally: # Leave no state behind, whether or not the assertions above passed, # and use a table name distinct from other behaviors/tests - # (harness/oracle.py's own probe table is "oracle_w") so runs never - # collide. + # (tests/test_routing_oracle.py's own probe table is "oracle_w") so + # runs never collide. a.exec_simple(f"DROP TABLE IF EXISTS {TABLE}") a.close() diff --git a/test/pg-compat/conftest.py b/test/pg-compat/conftest.py index 8f47c19755..dbc56ee680 100644 --- a/test/pg-compat/conftest.py +++ b/test/pg-compat/conftest.py @@ -15,7 +15,14 @@ def admin(): def _proxy_dsn(dbname="testuser"): h = os.environ["PGCOMPAT_PROXY_HOST"] p = os.environ["PGCOMPAT_PROXY_PORT"] - return f"host={h} port={p} user=testuser password=testuser dbname={dbname} sslmode=disable" + # client_encoding pinned to UTF8 for the same reason harness/targets.py + # and drivers/python/adapter.py pin it: the dbdeployer backend databases + # default to SQL_ASCII, which psycopg maps to Python's restrictive + # 'ascii' codec. + return ( + f"host={h} port={p} user=testuser password=testuser dbname={dbname} " + f"sslmode=disable client_encoding=UTF8" + ) @pytest.fixture diff --git a/test/pg-compat/drivers/python/adapter.py b/test/pg-compat/drivers/python/adapter.py index 28ca76a572..6e75d3d951 100644 --- a/test/pg-compat/drivers/python/adapter.py +++ b/test/pg-compat/drivers/python/adapter.py @@ -60,4 +60,11 @@ def rollback(self): self.conn.autocommit = True def close(self): - self.conn.close() + # Idempotent: behaviors/session_isolation.py closes its first + # connection explicitly before opening the second (deliberately, so + # the second connection can land on the same freed backend), then + # closes it again from a `finally` guarding the whole behavior body. + # Guard on psycopg's `closed` property so the repeat call is a safe + # no-op instead of erroring on an already-closed connection. + if not self.conn.closed: + self.conn.close() diff --git a/test/pg-compat/harness/diff.py b/test/pg-compat/harness/diff.py index 315b7f3a1b..132d5f742e 100644 --- a/test/pg-compat/harness/diff.py +++ b/test/pg-compat/harness/diff.py @@ -19,10 +19,13 @@ not acted on here. Native backend axis: for AVAILABLE native targets the backend mode is set via -the admin (``pgsql-use_native_backend_protocol``) before running. Unavailable -targets (the norm today -- PR #5882 unmerged) are simply not run; the test -layer surfaces them as skips. ``compare`` therefore gracefully handles absent -targets -- only the proxy targets actually present in ``results`` are checked. +the admin (``pgsql-use_native_backend_protocol``) before running, and the +GLOBAL variable's prior value is snapshotted/restored around the target loop +(see ``_run``) so one case's native-mode toggle never leaks into the next. +Unavailable targets (the norm today -- PR #5882 unmerged) are simply not run; +the test layer surfaces them as skips. ``compare`` therefore gracefully +handles absent targets -- only the proxy targets actually present in +``results`` are checked. """ import re @@ -88,16 +91,31 @@ def _run_on(target, stmts, admin, native_present): def _run(stmts, targets, admin, skip, only): native_present = native_var_present(admin) if admin is not None else False - results = {} - for t in targets: - if not t.available: - continue - if t.name in skip: - continue - if only and t.name not in only: - continue - results[t.name] = _run_on(t, stmts, admin, native_present) - return results + # Snapshot/restore NATIVE_VAR around the target loop. Dormant today (the + # variable is absent -- PR #5882 unmerged -- so native_present is False, + # snapshot() is never called, and this is a pure no-op: zero admin + # round-trips beyond the native_var_present() probe already required + # above). The day #5882 merges, native_present flips True and every + # _run_on() call that toggles a native target's backend-protocol mode + # (see _run_on) leaves the GLOBAL runtime variable at whatever it last + # set it to; without restoring it here that value would leak into every + # subsequent case run in the same process. Restoring in a ``finally`` + # guarantees the global is put back even if a target raises mid-loop. + saved = admin.snapshot([NATIVE_VAR]) if native_present else None + try: + results = {} + for t in targets: + if not t.available: + continue + if t.name in skip: + continue + if only and t.name not in only: + continue + results[t.name] = _run_on(t, stmts, admin, native_present) + return results + finally: + if saved is not None: + admin.restore(saved) def run_case(case_file, targets, admin=None):