# ───────────────────────────────────────────────────────────────────────── # postgresql/cutover/test/pgha-dryrun.yaml — DISPOSABLE dry-run stack # # Validates the ADR-0001 cutover mechanics WITHOUT touching production: # - own overlay network (pgha-test_db-backend), own stack name # - throwaway "legacy" postgres:17 seeded with marker data, carrying the # in-network alias "postgresql" (mirrors prod CLONE_HOST) # - same etcd/patroni/haproxy topology, images, and env wiring as the # real staging file — including clone-from-live via pg_basebackup # - reuses the real Docker secrets (read-only mounts; harmless) # - disposable data dirs under /volume1/docker/PostgreSQL/dryrun/ # # Deploy: docker stack deploy -c pgha-test # Teardown: docker stack rm pgha-test && rm -rf /volume1/docker/PostgreSQL/dryrun # # NOTE: command: blocks use $$(...) not $(...) — Compose's own variable # interpolation parses $( as an attempted ${VAR} reference and fails with # "invalid interpolation format" / "you may need to escape any $ with # another $". $$ escapes to a literal $ for the shell at runtime. This # applies uniformly to EVERY literal $ character anywhere in a command: # block, including inside heredocs and nested quoting — Compose scans the # raw text before any shell/heredoc logic ever runs, so quoting context # doesn't exempt anything from this escaping requirement. # # NOTE: ETCD3_HOSTS not ETCD_HOSTS — confirmed against zalando/spilo # configure_spilo.py: PATRONI_DCS includes both "etcd" (legacy v2 API, # python-etcd client) and "etcd3" (v3 API, python-etcd3 client) as # distinct DCS backends selected by env var prefix. Our etcd containers # (v3.5.9) have the v2 API disabled by default, so ETCD_HOSTS causes # Patroni to hit /v2 endpoints that 404. ETCD3_HOSTS selects the correct # v3-API client. # # NOTE: "legacy" needs a pg_hba.conf rule permitting REPLICATION-type # connections (distinct from normal client connections), fixed via a # /docker-entrypoint-initdb.d/ hook script (official extension point). # "trust" is acceptable ONLY because this container is fully disposable. # See note "ADR-0001 Addendum — pg_hba.conf replication prerequisite # discovered in dry run" for the real production prerequisite this exposes. # # NOTE: patroni-0/patroni-1 override bootstrap.post_init via # SPILO_CONFIGURATION (Spilo's documented, supported mechanism for # overriding any generated Patroni config — configure_spilo.py deep-merges # user-supplied SPILO_CONFIGURATION on top of its own generated config). # This is needed because Spilo's own /scripts/post_init.sh hardcodes # "ALTER VIEW ... OWNER TO postgres" with no way to parameterize that role # name via env vars. Since our superuser is PGUSER_SUPERUSER=PGadmin (to # match production), there is no role literally named "postgres" in the # cloned data, so Spilo's unmodified script fails with # 'ERROR: role "postgres" does not exist'. The override points # bootstrap.post_init at our own wrapper script instead, which creates a # harmless, idempotent, NOLOGIN "postgres" role first, then execs Spilo's # real, UNMODIFIED post_init.sh with all original arguments passed through # — Zalando's script itself is never patched or forked. This affects real # production too (superuser there is also PGadmin, not postgres) and must # be carried into the real staging/production compose files as well. # ───────────────────────────────────────────────────────────────────────── version: "3.6" services: # Stand-in for the production single-instance postgres (clone source) legacy: image: public.ecr.aws/docker/library/postgres:17 hostname: db entrypoint: ["/bin/sh", "-c"] command: - | mkdir -p /docker-entrypoint-initdb.d cat > /docker-entrypoint-initdb.d/zz-enable-replication.sh <<'EOF' #!/bin/sh echo "host replication all all trust" >> "$$PGDATA/pg_hba.conf" EOF chmod +x /docker-entrypoint-initdb.d/zz-enable-replication.sh exec docker-entrypoint.sh postgres environment: POSTGRES_USER: PGadmin POSTGRES_PASSWORD_FILE: /run/secrets/postgresql_password secrets: - postgresql_password volumes: - /volume1/docker/PostgreSQL/dryrun/legacy:/var/lib/postgresql/data networks: db-backend: aliases: - postgresql - db deploy: placement: constraints: - node.hostname == docker-2 etcd-1: image: quay.io/coreos/etcd:v3.5.9 hostname: etcd-1 command: - etcd - --name=etcd-1 - --data-dir=/etcd-data - --initial-advertise-peer-urls=http://etcd-1:2380 - --listen-peer-urls=http://0.0.0.0:2380 - --listen-client-urls=http://0.0.0.0:2379 - --advertise-client-urls=http://etcd-1:2379 - --initial-cluster=etcd-1=http://etcd-1:2380,etcd-2=http://etcd-2:2380,etcd-3=http://etcd-3:2380 - --initial-cluster-state=new - --initial-cluster-token=pgha-test-etcd volumes: - /volume1/docker/PostgreSQL/dryrun/etcd-1:/etcd-data networks: - db-backend deploy: placement: constraints: - node.hostname == docker-1 etcd-2: image: quay.io/coreos/etcd:v3.5.9 hostname: etcd-2 command: - etcd - --name=etcd-2 - --data-dir=/etcd-data - --initial-advertise-peer-urls=http://etcd-2:2380 - --listen-peer-urls=http://0.0.0.0:2380 - --listen-client-urls=http://0.0.0.0:2379 - --advertise-client-urls=http://etcd-2:2379 - --initial-cluster=etcd-1=http://etcd-1:2380,etcd-2=http://etcd-2:2380,etcd-3=http://etcd-3:2380 - --initial-cluster-state=new - --initial-cluster-token=pgha-test-etcd volumes: - /volume1/docker/PostgreSQL/dryrun/etcd-2:/etcd-data networks: - db-backend deploy: placement: constraints: - node.hostname == docker-2 etcd-3: image: quay.io/coreos/etcd:v3.5.9 hostname: etcd-3 command: - etcd - --name=etcd-3 - --data-dir=/etcd-data - --initial-advertise-peer-urls=http://etcd-3:2380 - --listen-peer-urls=http://0.0.0.0:2380 - --listen-client-urls=http://0.0.0.0:2379 - --advertise-client-urls=http://etcd-3:2379 - --initial-cluster=etcd-1=http://etcd-1:2380,etcd-2=http://etcd-2:2380,etcd-3=http://etcd-3:2380 - --initial-cluster-state=new - --initial-cluster-token=pgha-test-etcd volumes: - /volume1/docker/PostgreSQL/dryrun/etcd-3:/etcd-data networks: - db-backend deploy: placement: constraints: - node.hostname == docker-3 patroni-0: image: ghcr.io/zalando/spilo-17:4.0-p3 hostname: patroni-0 command: - /bin/sh - -c - | export PGPASSWORD_SUPERUSER="$$(cat /run/secrets/postgresql_password)" export PGPASSWORD_STANDBY="$$(cat /run/secrets/postgresql_replication_password)" export PATRONI_RESTAPI_PASSWORD="$$(cat /run/secrets/postgresql_patroni_password)" export CLONE_PASSWORD="$$(cat /run/secrets/postgresql_password)" mkdir -p /scripts cat > /scripts/post_init_wrapper.sh <<'WRAP' #!/bin/bash set -e psql -d "$$2" -v ON_ERROR_STOP=1 -c 'DO $$do$$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = $$x$$postgres$$x$$) THEN CREATE ROLE postgres; END IF; END $$do$$;' exec /scripts/post_init.sh "$$@" WRAP chmod +x /scripts/post_init_wrapper.sh exec /bin/sh /launch.sh init environment: SCOPE: pgha-test PATRONI_NAME: patroni-0 ETCD3_HOSTS: '"etcd-1:2379","etcd-2:2379","etcd-3:2379"' PGUSER_SUPERUSER: PGadmin PGUSER_STANDBY: standby PATRONI_RESTAPI_USERNAME: patroni PGROOT: /home/postgres/pgdata/pgroot CLONE_METHOD: CLONE_WITH_BASEBACKUP CLONE_SCOPE: legacy-single CLONE_HOST: postgresql CLONE_PORT: "5432" CLONE_USER: PGadmin SPILO_CONFIGURATION: | bootstrap: post_init: /scripts/post_init_wrapper.sh "zalandos" secrets: - postgresql_password - postgresql_replication_password - postgresql_patroni_password volumes: - /volume1/docker/PostgreSQL/dryrun/patroni-0:/home/postgres/pgdata networks: - db-backend deploy: placement: constraints: - node.labels.pg-role == primary patroni-1: image: ghcr.io/zalando/spilo-17:4.0-p3 hostname: patroni-1 command: - /bin/sh - -c - | export PGPASSWORD_SUPERUSER="$$(cat /run/secrets/postgresql_password)" export PGPASSWORD_STANDBY="$$(cat /run/secrets/postgresql_replication_password)" export PATRONI_RESTAPI_PASSWORD="$$(cat /run/secrets/postgresql_patroni_password)" export CLONE_PASSWORD="$$(cat /run/secrets/postgresql_password)" mkdir -p /scripts cat > /scripts/post_init_wrapper.sh <<'WRAP' #!/bin/bash set -e psql -d "$$2" -v ON_ERROR_STOP=1 -c 'DO $$do$$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = $$x$$postgres$$x$$) THEN CREATE ROLE postgres; END IF; END $$do$$;' exec /scripts/post_init.sh "$$@" WRAP chmod +x /scripts/post_init_wrapper.sh exec /bin/sh /launch.sh init environment: SCOPE: pgha-test PATRONI_NAME: patroni-1 ETCD3_HOSTS: '"etcd-1:2379","etcd-2:2379","etcd-3:2379"' PGUSER_SUPERUSER: PGadmin PGUSER_STANDBY: standby PATRONI_RESTAPI_USERNAME: patroni PGROOT: /home/postgres/pgdata/pgroot CLONE_METHOD: CLONE_WITH_BASEBACKUP CLONE_SCOPE: legacy-single CLONE_HOST: postgresql CLONE_PORT: "5432" CLONE_USER: PGadmin SPILO_CONFIGURATION: | bootstrap: post_init: /scripts/post_init_wrapper.sh "zalandos" secrets: - postgresql_password - postgresql_replication_password - postgresql_patroni_password volumes: - /volume1/docker/PostgreSQL/dryrun/patroni-1:/home/postgres/pgdata networks: - db-backend deploy: placement: constraints: - node.labels.pg-role == replica haproxy: image: haproxy:2.9-alpine hostname: haproxy volumes: - /volume1/docker/compose-files/postgresql/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro networks: - db-backend deploy: mode: global networks: db-backend: attachable: true driver: overlay secrets: postgresql_password: external: true postgresql_replication_password: external: true postgresql_patroni_password: external: true