Skip to content

test tooling: pgaftest binary, .pgaf spec suite, CI modernisation - #1132

Closed
dimitri wants to merge 69 commits into
mainfrom
pgaftest-infra-combined
Closed

test tooling: pgaftest binary, .pgaf spec suite, CI modernisation#1132
dimitri wants to merge 69 commits into
mainfrom
pgaftest-infra-combined

Conversation

@dimitri

@dimitri dimitri commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Replaces the Python/pytest test suite with a purpose-built test binary (pgaftest) and a declarative spec format (.pgaf). Ports all existing tests to the new format, wires up a full CI matrix for PG 14–17, and removes the unmaintained Azure integration.


New: pgaftest binary (src/bin/pgaftest/)

A self-contained test runner that reads .pgaf spec files and drives Docker Compose clusters:

  • pgaftest run <spec.pgaf> — headless CI mode: compose up → setup{} → steps in order → teardown{} → TAP output + exit code
  • pgaftest run --schedule <file> — run all specs listed in a schedule file
  • pgaftest setup <spec.pgaf> [--tmux] — interactive mode: brings the cluster up and ready, then drops into a shell (or tmux session with log/watch panes)
  • pgaftest step <name>, pgaftest down — run individual steps or tear down against a live stack

The .pgaf format covers: cluster topology (cluster{} with monitor, per-node options), setup{}/teardown{} blocks, named step{} blocks with exec, sql, expect, wait until, network disconnect/connect, compose stop/start, sleep, and promote.


Shared library (src/bin/common/)

~20 utility files moved out of src/bin/pg_autoctl/ into a shared static library (libpgaf_common.a). Both pg_autoctl and pgaftest link against it, eliminating source duplication.


Test suite (tests/tap/specs/, tests/tap/schedules/)

29 spec files, 301 steps total, covering the full test matrix:

Schedule Specs PG versions
quick basic_operation, basic_operation_listen_flag, config_get_set, skip_pg_hba 14–17
node create_standby_with_pgdata, maintenance_and_drop, auth, monitor_disabled, replace_monitor, extension_update, debian_clusters, tablespaces 14–17
ssl enable_ssl, ssl_self_signed, ssl_cert 14–17
multi-alternate multi_alternate 17
multi-misc multi_standbys, multi_maintenance, ensure, multi_ifdown 17
multi-async multi_async 17
citus-1 citus_cluster_name, citus_force_failover, citus_multi_standbys 17
citus-2 basic_citus_operation, nonha_citus_operation, citus_skip_pg_hba 17

Plus: installcheck (PG 14–17) and upgrade (PG 16).


CI (.github/workflows/run-pgaftest.yml)

New workflow: style check gates all jobs; builds one pgaf:run image per PG version plus a single pgaf:pgaftest image (built once from PG 17); test jobs download only the image they need. 3×4 + 5×1 = 17 test jobs + 4 installcheck + 1 upgrade.

.github/workflows/run-tests.yml (existing Python suite): style check now also gates build_images and all downstream jobs.


New pg_autoctl sub-commands

  • pg_autoctl inspect — read-only diagnostics (FSM state, monitor queries, networking) safe to run on live nodes
  • pg_autoctl node — declarative node lifecycle via pg_autoctl_node.ini files: run, apply, start, show, check
  • pg_autoctl manual — low-level FSM and service operations for manual recovery

Bug fixes in pg_autoctl

monitor.c — fixed 17 instances of dangling IntString pointer: intToString(x).strValue returns a pointer into a temporary that is destroyed at the end of the expression. The garbage bytes appeared in libpq parameter arrays and propagated into PostgreSQL error messages. Fixed by hoisting each IntString to the enclosing function scope.


Removed

  • Azure integrationazure.c, azure_config.c, cli_do_azure.c, cli_do_tmux_azure.c, azure.h, azure_config.h, Makefile.azure, docs/azure-tutorial.rst and all references. The unmaintained pg_autoctl do azure sub-tree is gone entirely.

Docs

  • docs/ref/pgaftest.rst — full pgaftest reference
  • docs/ref/pg_autoctl_inspect*.rst, pg_autoctl_node*.rst, pg_autoctl_manual*.rst — new sub-command reference pages
  • docs/tutorial/interactive_tutorial.pgaf, citus_tutorial.pgaf — spec files used by the tutorials
  • docs/operations.rst — Testing section added
  • docs/azure-tutorial.rst — deleted

dimitri added 21 commits July 6, 2026 23:35
…de.ini

Adds a new pg_autoctl node sub-command tree and a NodeSpec file format
designed as the recommended entry-point for container and Kubernetes
deployments.

## pg_autoctl_node.ini sections

  [node]         kind (postgres|monitor|coordinator|worker), name, hostname, port
  [postgresql]   pgdata
  [monitor]      pguri (empty for monitor nodes), no_monitor, node_id
  [formation]    name, group
  [settings]     candidate_priority, replication_quorum   ← mutable, applied live
  [options]      ssl, auth, pg_hba_lan                    ← create-time only
  [ssl]          ssl_ca_file, ssl_cert_file, ssl_key_file
  [launch]       mode=deferred: wait for pg_autoctl node start
  [formation N]  monitor only: additional named formations

## pg_autoctl node sub-commands

  run   <file>   Read ini, create node if absent, exec() into supervisor.
                 Sets PG_AUTOCTL_NODESPEC so the supervisor watches for
                 live [settings] changes via inotify (Linux) or mtime poll.
  apply <file>   Converge mutable settings on an already-running node.
  start [<file>] Clear launch=deferred so a waiting node run proceeds.
  show           Dump live config as pg_autoctl_node.ini on stdout.
  check <file>   Parse-only validate; print resolved fields.

## Supervisor file watcher (nodespec_watcher)

The supervisor initialises a NodeSpecWatcher when PG_AUTOCTL_NODESPEC is
set. Every tick it checks for file changes:
  - Linux:   drain inotify IN_CLOSE_WRITE / IN_MOVED_TO events
  - Others:  stat() every NODESPEC_WATCH_INTERVAL_SECS (10 s)
On change, re-parse [settings] and call nodespec_apply() to converge
mutable fields without restarting the node.

## Files

  src/bin/pg_autoctl/cli_node.c / cli_node.h
  src/bin/pg_autoctl/nodespec.c / nodespec.h
  src/bin/pg_autoctl/supervisor.c / supervisor.h  (watcher integration)
  docs/ref/pg_autoctl_node.rst
  docs/ref/pg_autoctl_node_run.rst
- pg_autoctl_node.rst: replace prose property list with a structured
  table (section/property/type/mutable/default/description), each
  property on its own row; fix 'disabled-monitor' label → actual ref

- nodespec_apply: add ssl apply block — when [options].ssl or any [ssl]
  cert path changes, call 'pg_autoctl enable ssl' with the appropriate
  flags (--ssl-self-signed, --no-ssl, or --ssl-mode + cert paths);
  update nodespec_apply comment to list ssl as mutable

- docs/index.rst: remove 'Container and Kubernetes' as a top-level
  toctree caption; the manual page is still reachable via Manual Pages

- docs/operations.rst: add 'Container and Kubernetes Deployments'
  section describing pg_autoctl node run, live reconfiguration, and
  the launch=deferred pattern; cross-refs to pg_autoctl_node for details

- docs/ref/configuration.rst: add 'Declarative Node Configuration'
  section covering pg_autoctl_node.ini sections and their relationship
  to pg_autoctl.cfg; cross-ref to pg_autoctl_node
When the [monitor] pguri changes in pg_autoctl_node.ini, nodespec_apply
now re-registers the node to the new monitor without stopping Postgres:

  pg_autoctl disable monitor --force --pgdata <dir>
  pg_autoctl enable monitor <new_uri> --pgdata <dir>

The disable step removes the node from the old monitor (--force allows
this even if the old monitor is temporarily unreachable). The enable step
registers the node to the new monitor and signals the running supervisor
to start using the new monitor_pguri for node_active calls.

Document the change in the property table and Live Reconfiguration section
of pg_autoctl_node.rst.
…ction

Follow the pg_autoctl_create pattern: each pg_autoctl node sub-command
now has its own manual page, all listed in pg_autoctl_node.rst's toctree:

  pg_autoctl node run    (existing)
  pg_autoctl node apply  (new)
  pg_autoctl node start  (new)
  pg_autoctl node show   (new)
  pg_autoctl node check  (new)

Remove the redundant 'Container and Kubernetes Deployments' section from
operations.rst. The full documentation for this feature lives in the
pg_autoctl_node manual page (ref/pg_autoctl_node.rst) and its sub-pages.
The list-table rendered poorly in the Sphinx HTML theme. Replace it with
the same definition-list style used throughout the other manual pages
(e.g. pg_autoctl_create_postgres Options section): each property is a
bare term followed by indented description paragraphs, grouped under
ini-section sub-headings.
…ioning

Re-add the 'Container and Kubernetes Deployments' section at the end of
operations.rst with a named anchor so it can be referenced from elsewhere.

Add a short note in the Provisioning section pointing to pg_autoctl node run
as the declarative alternative for container and Kubernetes deployments,
with a cross-reference down to the new section.
Add references to pg_autoctl node / pg_autoctl node run in all the places
identified by the documentation audit:

Reference pages (new See Also section):
- pg_autoctl_create_postgres.rst
- pg_autoctl_create_monitor.rst
- pg_autoctl_create_coordinator.rst
- pg_autoctl_create_worker.rst
- pg_autoctl_run.rst

Narrative docs (short note pointing to the declarative alternative):
- how-to.rst — after the pg_autoctl run step in Quick Start
- tutorial.rst — after the docker-compose.yml literalinclude
- citus-quickstart.rst — after the docker-compose-scale.yml literalinclude
- install.rst — after the systemd unit section
Replace the imperative pg_autoctl create postgres / pg_autoctl create
monitor commands with the declarative pg_autoctl node run approach:

- Add tutorial/ini/monitor.ini and tutorial/ini/postgres.ini — two small
  ini files that describe the monitor and every data node respectively.
  Data nodes share one ini file; hostname and name default to the
  container hostname set by Docker Compose.

- Rewrite tutorial/docker-compose.yml: all PG_AUTOCTL_* environment
  variables are gone; each service bind-mounts its ini file at
  /etc/pgaf/node.ini and runs 'pg_autoctl node run'. The x-node anchor
  is now clean — no env vars, single command.

- Update tutorial.rst to introduce the ini files before the compose file,
  explain that pg_autoctl node run handles both create and run, and show
  that live reconfiguration (candidate_priority change for node3) is done
  by editing the ini file rather than calling pg_autoctl set.

- Replace the stale pg_autoctl_do_tmux_compose_session reference in Next
  steps with a pointer to the pg_autoctl_node reference and the Container
  and Kubernetes Deployments section.
… paths

Show both approaches side by side:

- Direct command (pg_autoctl set candidate-priority): immediate, no restart
- Declarative ini file: explain that changing docker-compose.yml volumes
  requires 'docker compose up -d node3' to recreate the container, that
  pg_autoctl node run applies the ini diff on startup before exec'ing into
  the supervisor, and that once the dedicated ini file is mounted any
  subsequent edits to it are picked up live by the running supervisor.
Replace all pg_autoctl create coordinator/worker/monitor commands with
the declarative pg_autoctl node run approach:

New ini files:
  citus/ini/monitor.ini     — kind = monitor
  citus/ini/coordinator.ini — kind = coordinator, shared by coord0a/coord0b
  citus/ini/worker.ini      — kind = worker, no group (monitor auto-assigns)
  citus/ini/worker1.ini     — kind = worker, group = 1
  citus/ini/worker2.ini     — kind = worker, group = 2
  citus/ini/worker3.ini     — kind = worker, group = 3

Rewritten compose files:
  docker-compose-scale.yml  — three services (monitor/coord/worker), each
    bind-mounts its ini file; all PG_AUTOCTL_* env vars removed
  docker-compose.yml        — named services with per-group worker ini files;
    YAML anchors kept for coord and per-group worker templates

Updated citus-quickstart.rst:
  - Show all ini files with literalinclude captions before each compose file
  - Explain that worker.ini without [formation] group triggers monitor
    auto-assignment (right for --scale), while workerN.ini with group = N
    pins the pair to a specific shard group (right for named services)
  - Explain that pg_autoctl node run uses the container hostname as the
    node name when name is not set in the ini file
  - Replace stale pg_autoctl_do_tmux_compose_session reference in Next
    steps with pointer to pg_autoctl_node and container deployments guide
…pec suite

Combines what would have been two separate PRs:
- src/bin/common/: move ~20 utility sources (pgsql, pgsetup, ini_file, …) out
  of pg_autoctl/ into a shared static library linked by both binaries
- src/bin/pgaftest/: new test binary — reads .pgaf specs, runs headless CI
  (TAP output) or interactive setup with docker compose
- tests/tap/specs/: 35 .pgaf spec files covering the full test matrix
- tests/tap/schedule: ordered run list for CI
- tests/upgrade/: dual-binary upgrade test image and shim script
- .github/workflows/run-pgaftest.yml: CI workflow — builds pgaf:run image and
  pgaftest binary once, fans out one job per spec in parallel
- Dockerfile: add pgaftest build/install target, bison touch guard
- style: all new C files reformatted with citus/stylechecker:no-py Docker image
Split the single build job into:
- build-images: matrix over PG14/15/16/17, one pgaf:run-pgNN artifact each
- build-pgaftest: single job (PG17), binary is version-agnostic at runtime

The test matrix now crosses every spec × every PG version (19 specs × 4
versions = 76 parallel jobs), matching the run-tests.yml coverage scope.
installcheck likewise runs on all four versions.

This positions pgaftest as the replacement for run-tests.yml once all
scenarios are ported — run-tests.yml will be removed at that point.
Three panes, laid out evenly:
  top    — docker compose logs -f  (all services)
  middle — pg_autoctl watch        (on the monitor)
  bottom — bash inside the first non-deferred data node

Previously the session was created detached (-d) and the user had to
manually run 'tmux attach'. Now runner_setup attaches immediately after
creating the session so the terminal lands inside tmux.

The shell pane target is the first non-deferred data node in the spec's
formation block; falls back to the monitor when no data node is found.
…c --tmux)

The commandline library calls getopt, which on POSIX/macOS stops scanning
at the first non-option argument (the spec file path).  Any flags given
after the spec file — the natural invocation form for setup/run -- are
silently ignored, so --tmux and --work-dir have no effect.

Fix: after cli_setup extracts argv[0] as the spec path, reset getopt state
(optreset=1 on BSD, optind=1 everywhere) and call pgaftest_getopts again
on the remaining argv.  argv[0] serves as a dummy program name so getopt
starts scanning from index 1, picking up --tmux, --work-dir, etc.

Also applies to the tmux session creation:
- session is now attached immediately (tmux attach-session after new-session -d)
- third pane execs bash inside the first non-deferred data node
setup --tmux timing
  The setup{} block now runs inside the tmux bottom pane instead of
  before tmux is created.  The user immediately gets the three-pane
  session; the bottom pane shows setup progress and transitions to an
  interactive bash shell when done.

  - add runner_run_setup_only() — runs setup{} against an existing stack
  - add pgaftest _setup_ sub-command — internal entry-point for the pane
  - runner_setup() launches tmux first (after compose up + monitor ready)
    then the bottom pane calls _setup_ and exec's bash on success
  - set pg_autoctl_program to argv[0] in main() so runner_setup can
    reference the binary path in the tmux pane command

pgaftest down --work-dir
  When no spec file is found, cli_down now derives the project name from
  the workDir basename and runs:
    docker compose -p <name> -f <workDir>/docker-compose.yml down ...
  instead of a bare docker compose down that silently targets nothing.

docs
  - docs/ref/pgaftest.rst: full reference (sub-commands, DSL, env vars, TAP)
  - docs/operations.rst: new Testing section
  - docs/tutorial.rst: pgaftest alternative section with interactive_tutorial.pgaf
  - docs/citus-quickstart.rst: pgaftest alternative with citus_tutorial.pgaf
  - docs/index.rst: add ref/pgaftest to toctree
  - docs/tutorial/interactive_tutorial.pgaf: two-node HA interactive spec
  - docs/tutorial/citus_tutorial.pgaf: eight-node Citus interactive spec
Dockerfile: replace deprecated apt-key with gpg --dearmor
  Three stages (base/build, run, testrun) were using:
    curl ... | apt-key add -
  which triggers a deprecation warning in recent apt versions.
  Replaced with the modern approach:
    gpg --dearmor -o /usr/share/keyrings/pgdg-archive-keyring.gpg
  and added signed-by= to the sources list entry.

C: add missing prototype declarations
  - pgsql_alter_role_password() declared in common/pgsql.h was not
    visible when pg_autoctl/pgsql.h was included first; added to
    pg_autoctl/pgsql.h so the declaration is found regardless of
    include path ordering.
  - keeper_cli_pgsetup_hba_lan() in cli_do_misc.c was missing from
    cli_do_root.h; added alongside the other pgsetup helpers.
@dimitri dimitri self-assigned this Jul 6, 2026
@dimitri dimitri added enhancement New feature or request Developer productivity Enhancements to ability to ship quality code Packaging and CI Enhancements to our CI integration labels Jul 6, 2026
dimitri added 5 commits July 7, 2026 01:29
The 16 .c/.h files that were moved to src/bin/common/ were still present
in src/bin/pg_autoctl/ as copies.  pg_autoctl was compiling them locally
via the SRC = $(wildcard *.c) glob AND linking libpgaf_common.a, causing
duplicate symbol resolution (local .o silently winning over the archive).

All 32 files (16 .c + 16 .h pairs) are removed; pg_autoctl now uses only
the common/ versions via libpgaf_common.a and its include path.

The 6 files that had minor divergences were analysed:
- ini_file.c / ipaddr.c / pgsetup.c / signals.c: only brace-style
  differences from citus_indent; common/ version is correct.
- pgctl.c: common/ is a superset (adds --checkpoint=fast for
  pg_basebackup, fixes intToString dangling-pointer use).
- pgsql.c: common/ is a superset (adds pgsql_alter_role_password).
Both .c and .h pairs for pgctl and pgsql were checked; common/ contains
all declarations.
Replace strncpy/strncat with strlcpy/strlcat in hand-written pgaftest
source files (test_runner.c, cli_root.c, cli_indent.c, test_spec_parse.c),
and add IGNORE-BANNED to unavoidable memcpy calls.

Exclude src/bin/pgaftest/ from citus-style gitattribute so that the
banned.h.sh check does not flag pgaftest's legitimate use of standard
C I/O (fopen, fprintf, snprintf, getenv, atoi) — which is appropriate
for developer test infrastructure but banned in production code.

The Flex/Bison generated files (test_spec_scan.c, test_spec_parse.c)
are covered by the same exclusion.
v13.0.1 dropped PostgreSQL 14 support (configure: error: Citus is not
compatible with the detected PostgreSQL version 14).  v12.1.5 is what
main uses and supports PG14 through PG17.
Replace banned C stdlib calls with the project's safe wrappers:
- snprintf() → sformat()     (wraps pg_vsnprintf; from file_utils.h)
- fprintf()  → fformat()     (wraps pg_vfprintf; from file_utils.h)
- vsnprintf() → pg_vsnprintf()
- printf()   → fformat(stdout, ...)
- atoi()     → stringToInt() (from string_utils.h)
- strncat()  → strlcat()     (already done in earlier commit)
- strncpy()  → strlcpy()     (already done in earlier commit)

For calls with no safe-wrapper alternative (fopen, getenv, sscanf, memcpy)
that already follow the documented safe-use pattern, add /* IGNORE-BANNED */
on the calling line — matching the approach used in pg_autoctl/nodespec.c
and common/env_utils.c.

The Bison/Flex generated files (test_spec_parse.c, test_spec_scan.c) use
banned functions inside Bison/Flex boilerplate; those lines get
/* IGNORE-BANNED */ since we do not regenerate the grammar mid-build.

Add #include "file_utils.h" and #include "string_utils.h" to the files
that now call sformat/fformat/stringToInt.

Remove the src/bin/pgaftest/** -citus-style .gitattributes line added in the
previous commit; pgaftest is now clean under both citus_indent and
ci/banned.h.sh.
dimitri added 26 commits July 7, 2026 21:21
When auth=cert is configured, pg_autoctl writes HBA rules for standby
replication connections with the plain 'cert' method.  Cert auth
requires the client certificate CN to match the database username, but
both monitor and replication connections use the same client certificate
(CN=autoctl_node) while the replication role is pgautofailover_replicator.

Without an ident map the replication connection always fails:
  FATAL: certificate authentication failed for user "pgautofailover_replicator"

Fix keeper_update_group_hba() to:
  1. Use 'cert map=pgautofailover' for the replication HBA rule, matching
     the same pattern already used in cli_do_misc.c for LAN CIDR rules.
  2. Call pghba_ensure_ident_map_entry() to write
       pgautofailover autoctl_node pgautofailover_replicator
     to pg_ident.conf, so the cert CN maps to the replication role.

Verified with ssl_cert.pgaf: all 5 tests pass, formation completes in
~5 seconds.
When --auth md5 is used, upstream_has_replication_slot() needs to supply
the replication password to connect to the upstream node. Previously it
built the connection string without a password, causing authentication
failures when the upstream requires md5. After 5 consecutive failures
wait_until_primary_has_created_our_replication_slot() gives up, the
init process exits, the supervisor restarts it up to 5 times in 300s,
and the container dies -- all while the test waits for the formation to
reach primary/secondary, timing out after 300s.

Fix: set PGPASSWORD from upstream->password before calling pgsql_init(),
following the same save/restore pattern used in pg_basebackup().
Replace the bare-runner pattern (extract binary + apt-get install libpq5)
with a self-contained pgaf:pgaftest Docker image that already bundles
libpq5, docker-ce-cli, and docker-compose-plugin.

build-pgaftest now builds --target pgaftest and uploads the image as an
artifact.  All test/installcheck/upgrade jobs load that image and run
pgaftest via:

  docker run --rm \
    -v /var/run/docker.sock:/var/run/docker.sock \
    -v /tmp/pgaftest:/tmp/pgaftest \
    -v $(pwd):/work:ro \
    pgaf:pgaftest \
    pgaftest run ...

The /tmp/pgaftest bind-mount lets compose stacks started by pgaftest
(inside the container) access the generated INI and compose files via
the same host paths the Docker daemon sees — the standard DooD pattern
on Linux runners.

This eliminates the fragile dependency on Microsoft's apt repositories
that are pre-installed on GitHub's ubuntu-latest image and have been
causing sporadic apt-get update failures (NOSPLIT / auth errors) that
have nothing to do with our code.
pgaf:pgaftest runs as USER docker (UID 1000).  On GitHub's ubuntu-latest
runners /var/run/docker.sock is srw-rw---- root:docker where the docker
GID is ~999, not 1000.  The container's docker group GID doesn't match
the host GID so all docker compose calls fail immediately with permission
denied, causing every test job to exit in <30 seconds.

Fix: run the container as root and add the host docker socket GID via
--group-add so both the socket access and writes to /tmp/pgaftest
(bind-mounted from the runner) work without restriction.
GitHub Actions ubuntu-latest now uses rootless Docker.  Container root
(uid 0) maps to the rootless daemon uid on the host, which is different
from the runner uid that created /tmp/pgaftest (mode 755).  Writes from
inside the pgaf:pgaftest container therefore fail with EACCES when
pgaftest tries to create its per-spec work directories.

Making the bind-mount root world-writable (777) allows any mapped uid
to create sub-directories inside it, restoring write access regardless
of the uid remapping in effect.
assert stays primary while { stop postgres monitor } fails because
the stays-while logic checks the state via the monitor (LISTEN or SQL)
after each body command.  When the body stops the monitor's own postgres,
both the LISTEN connection and the SQL fallback become unavailable, so
the runner reports 'could not get state of node3 after command' and the
assertion fails incorrectly.

The monitor cannot assign state changes while its postgres is down, so
there is nothing to guard against during the outage.  Replace the
stays-while block with a plain stop + sleep + wait, matching the intent
of the original Python test: verify that a brief monitor outage does not
trigger a spurious failover.
…thcheck

multi_ifdown test_014: compose stop node3/node1 caused pg_autoctl perform
failover to block ~68s waiting for dead primaries, leaving only 22s of a 90s
timeout.  Switch to network disconnect (matches the Python test's ifdown()),
run the failover in background so the wait starts immediately, and increase
the timeout to 300s.  Update test_015 to use network connect instead of
compose start.

citus_skip_pg_hba: coord0a started simultaneously with the monitor but
auth=skip leaves the monitor's pg_hba.conf with only localhost rules, so
coord0a's registration attempts from another container all fail and its
healthcheck never passes.  Make coord0a launch deferred; move the monitor
HBA setup into the setup block (before starting coord0a); rename test_000
to reflect its new sole job of adding coord0a's own HBA rules.

compose_gen.c: a deferred node in the first slot (now coord0a) was
incorrectly chosen as the healthcheck anchor.  Its pg_autoctl status always
returns non-zero during sleep infinity, blocking all subsequent nodes via
service_healthy.  Skip deferred nodes when selecting firstNode so the
healthcheck lands on the first node that actually starts postgres.
The pg_rewind after this test requires a clean shutdown of node3 (primary)
and node1 so their WAL is preserved for the rewind-against-node2 step.
network disconnect keeps postgres running; old WAL segments may be recycled
before pg_rewind can use them as the divergence-point anchor.

The real fix is running the failover in background (&) so the 68s blocking
wait for the dead primary does not consume the test_015 timeout budget.
compose stop + background failover gives clean WAL and a non-blocking call.
…ip_pg_hba

multi_ifdown test_014: replace background perform-failover + wait-for-report_lsn
with blocking exec and wait for stable end state.  The report_lsn election
completes in under a second in local Docker; polling at 500ms granularity cannot
catch the intermediate state.  Also drop the now-unnecessary test_015_start_node3
compose start step (all nodes already online).

citus_skip_pg_hba test_005: remove the wait-for-wait_primary intermediate check.
perform failover --group 1 already blocks until the new primary is established,
so wait_primary is already gone by the time polling starts.

Root cause in both cases: the monitor FSM transitions from report_lsn (or
wait_primary) to the next state in under 1s, while the TAP runner polls via
docker compose exec at ~500ms intervals, making the intermediate state reliably
invisible under load.
ssl (all PG versions):
  On slow Azure CI runners the first data node's ssl_cert initialization
  (postgres init + SSL cert config + monitor TLS handshake) consistently
  exceeds the 360s Docker healthcheck window
  (start_period=120s + retries=120 × interval=2s = 360s).  For SSL
  clusters (verify-ca / verify-full), double start_period to 300s and
  increase retries to 150, giving a max window of 600s.

multi_async test_011 (PG17):
  The perform_promotion SQL in test_010 is non-blocking.  On fast CI
  runners the LSN election completes in under 1s, so by the time
  test_011 polls for 'node4 assigned-state = report_lsn' (180s timeout),
  node4 is already secondary and the transient state is long gone.
  Fix: disconnect node4 before the promotion so the election runs without
  it — same code path (candidate missing during LSN election), same
  observable outcome, no race window.
The ssl_cert test was permanently failing in CI (Azure runners) because
node1's 600s healthcheck window had to cover both the monitor's slow
SSL initialisation (cert copy + pg_autoctl SSL config + pghba setup)
AND node1's own postgres init.  On fast local machines the sum fits
comfortably, but on Azure it was exceeding 600s.

Fix: give the monitor its own healthcheck (same pg_autoctl status
probe; 300s start_period for SSL clusters, 60s otherwise) and make
node1 depend on service_healthy for the monitor.  The timing is now
serial:

  monitor fully initialised (≤600s)
    → node1 starts and initialises (≤600s)
      → node2 starts (depends_on node1: service_healthy)

Each stage has its full window, so slow CI runners no longer hit the
combined deadline.
Without this, pgaftest's setup timer starts as soon as the monitor is
connectable, but node1 may not have started yet (it now waits for the
monitor healthcheck to pass first).  For ssl_cert clusters the 300s
setup timeout was being consumed by node1+node2 initialisation from
scratch rather than just a status check.

With this change the start ordering is fully serialised:

  monitor healthy → node1 starts → node1 healthy → pgaftest starts setup
                                                  → node2 starts

The setup {} block's wait timeouts only need to cover the remaining
state transitions after the cluster skeleton is up.
…dump container logs on failure

Two fixes:

1. For verify-ca / verify-full clusters with cert auth, embed explicit
   SSL connection parameters in the monitor pguri written to each node's
   ini file:

     pguri = postgresql://autoctl_node@monitor/pg_auto_failover
             ?sslmode=verify-ca
             &sslrootcert=/etc/pgaf/ssl/ca.crt
             &sslcert=/var/lib/postgres/.postgresql/postgresql.crt
             &sslkey=/var/lib/postgres/.postgresql/postgresql.key

   The implicit form (sslmode=prefer with libpq auto-discovery of
   ~/.postgresql/) works on macOS Docker Desktop but fails in the CI
   Linux DinD environment: node1 retried the monitor connection for 607s
   and was declared unhealthy.  Making the params explicit removes the
   dependency on $HOME and libpq search paths.

2. On docker compose up failure, dump all container logs before
   returning false.  Every previous ssl_cert CI failure produced just
   'node1 is unhealthy' with no indication of what pg_autoctl was
   actually printing inside node1 for 600 seconds.  This makes the next
   failure self-diagnosing.
… compose-up failure

Debian Bullseye went EOL June 2026; apt repositories are no longer reliably
served, causing the base-stage apt-get install to fail with exit 100 in the
upgrade test's pgaf-next build.  Switch all three independent stages (base,
run, pgaftest) to debian:bookworm-slim and update the PGDG + Docker apt
source codenames accordingly.  Also remove python3-nose and mg which were
dropped from Bookworm.

test_runner: add container log dump in runner_run() when docker compose up
fails.  The existing dump in runner_compose_up() is not reached by the
schedule runner (which calls compose up inline in runner_run); this gap
meant ssl_cert failures showed no pg_autoctl output from node1.
pyroute2 was only used by the old Python test suite (tests/network.py).
pgaftest implements network disconnect/connect via 'docker network disconnect'
so Python is not needed.  On Bookworm, pip3 can no longer install into the
system Python without --break-system-packages anyway.
python3-nose was removed from Debian Bookworm apt repos.  Bookworm also
enforces PEP 668 which rejects bare pip3 installs.  Use
--break-system-packages to restore nosetests (needed by run-tests.yml's
NOSETESTS Makefile variable) and pyroute2 (imported by all old Python
tests via pgautofailover_utils -> tests/network.py).

Drop the <0.7.0 upper bound on pyroute2; the constraint was added as a
compatibility guard that no longer applies to the old test suite.
python3-nose is not available in Bookworm, and even when installed via
pip, nose crashes on Python 3.11 because collections.Callable was
removed in Python 3.10 (nose/suite.py still uses it).

Switch the Makefile test runner from nosetests to pytest and install
pytest via pip3 in the Dockerfile alongside nose (kept for nose.tools
imports like eq_ and raises which do not trigger the broken suite path).

The TEST_ARGUMENT format switches from nose's --where/--tests style to
pytest's path-based format (tests/ for all, tests/foo.py for named).
…ytest

pytest tests/ would descend into tests/tablespaces/ which uses a
different import layout (no __init__.py, imports as tests.tablespaces.*)
and fails with ModuleNotFoundError. Tablespaces tests have their own
make target; exclude them from the default pytest run by globbing only
top-level .py files.
…tness

Four independent fixes addressing the failures in the old Python test
suite (run-tests.yml) and one pgaftest improvement:

1. Makefile.citus: fix TEST_ARGUMENT for pytest

   The switch from nosetests to pytest (commit 1efe48b) updated the main
   Makefile but missed Makefile.citus.  The old nosetests-style
   '--where=tests --tests=...' arguments are unrecognized by pytest,
   causing all four citus test runs to fail with exit code 4.

   Use the same pattern as multi/single/monitor/ssl:
     TEST_ARGUMENT = $(TESTS_CITUS:%=tests/%.py)

2. tests/network.py: add LC_ALL=C LANG=C to network-namespace subprocesses

   pyroute2 >= 0.5.17 (installed in the Bookworm test image via
   'pip3 install pyroute2>=0.5.17') strictly decodes NSPopen output as
   UTF-8.  When pg_autoctl logs a PostgreSQL error whose message contains
   a raw non-UTF8 byte (e.g. the byte value embedded in 'invalid byte
   sequence for encoding'), the decode raises UnicodeDecodeError, failing
   test_multi_async.py::test_004_set_async on all four PG versions.

   Set LC_ALL=C and LANG=C in the subprocess env so PostgreSQL and
   pg_autoctl produce ASCII-only error messages inside network namespaces.

3. tests/tablespaces/conftest.py: add project root to sys.path

   The tablespaces tests import as 'from tests.tablespaces.xxx import ...'
   which requires the project root on sys.path.  nosetests satisfied this
   implicitly (tests/__init__.py makes it a package discovered from cwd);
   pytest does not.  A conftest.py in tests/tablespaces/ is the standard
   pytest hook for per-directory path setup.

4. citus_skip_pg_hba.pgaf: add pgsetup wait before every hba-lan call

   pg_autoctl node start on a deferred-launch node returns immediately
   while the container's PID 1 is still polling for launchDeferred=false,
   then execv()ing into pg_autoctl create.  Calling hba-lan immediately
   races with that execv and the subsequent supervisor + initdb startup.
   pg_autoctl inspect pgsetup wait blocks until postgres is accepting
   connections, ensuring pg_hba.conf exists before hba-lan tries to edit
   it.  Applied to coord0a (test_000), worker1a (test_002d), and
   worker1b (test_003).

5. pgaftest: mount spec directory at /etc/pgaf/specs in all containers

   Add specDir (dirname of the .pgaf spec file) as a read-only bind-mount
   at /etc/pgaf/specs in every service container.  This allows specs to
   reference static JSON or other helper files shipped alongside them in
   git, without resorting to inline shell printf hackery.

   Use this to clean up monitor_disabled.pgaf: the pg_autoctl manual fsm
   nodes set JSON payloads are now checked-in as
   monitor_disabled_nodes12.json / monitor_disabled_nodes123.json and
   referenced as /etc/pgaf/specs/monitor_disabled_nodes12.json.  Avoids
   command-line length issues and makes the intent readable.

Also fix run_cmd_capture() in test_runner.c to drain remaining output
after the 4096-byte buffer fills, preventing early pipe close and the
SIGPIPE -> SIGKILL (exit 137) it would send to the docker exec'd process.
…ce autocommit

- pgaftest CI: pass PGAFTEST_HOST_WORK_DIR to docker run so compose_gen
  translates the container-internal specDir (/work/tests/tap/specs) to the
  HOST-side path that the docker daemon can resolve for the /etc/pgaf/specs
  bind mount in node containers.  Fixes test_005_fsm_nodes_set (exit 256).

- multi_standbys test_005: add 'wait until node1 state is primary' after each
  'set formation number-sync-standbys N' so synchronous_standby_names is
  checked after the apply_settings → primary FSM cycle completes.

- multi_async test_004_set_async: add 'wait until node1 state is primary'
  after 'set formation number-sync-standbys 0' before setting per-node
  replication-quorum.  Without the wait, the primary enters apply_settings
  and the monitor rejects the third node's replication-quorum change.

- pgautofailover_utils run_sql_query: avoid 'with conn:' transaction context
  when autocommit=True; 'with conn:' wraps an implicit BEGIN that blocks
  DDL like CREATE TABLESPACE.  Use explicit commit/rollback instead.
…F8 output

monitor.c had two classes of dangling pointer bugs that caused garbage bytes
to appear as SQL parameter values, triggering Monitor ERROR messages with
non-UTF8 bytes and test UnicodeDecodeErrors:

1. IntString declared inside an if-block scope: paramValues[N] pointed into
   myGroupIdString.strValue, but that stack variable was destroyed when the
   if-block exited — before pgsql_execute_with_params was called. Fixed in
   monitor_get_nodes and monitor_print_nodes_as_json by moving the IntString
   declaration to function scope.

2. intToString(x).strValue inline temporaries: the returned IntString struct
   is destroyed at the semicolon, leaving paramValues[N] pointing to freed
   stack. Fixed across all 12 affected functions (monitor_node_active,
   monitor_register_node, monitor_set_node_candidate_priority,
   monitor_set_formation_number_sync_standbys, monitor_remove_by_hostname,
   monitor_perform_failover, monitor_create_formation,
   monitor_update_node_metadata, monitor_set_node_system_identifier,
   monitor_set_group_system_identifier, monitor_start_maintenance,
   monitor_stop_maintenance, monitor_find_node_by_nodeid) by naming the
   IntString variables at function scope.

Clang -Wdangling-assignment now reports zero warnings for monitor.c.

network.py: replace universal_newlines=True with encoding='utf-8',
errors='replace' in both NSPopen calls so that any residual non-UTF8 bytes
in pg_autoctl output are replaced rather than raising UnicodeDecodeError.
Split multi-1/multi-2 into three focused schedules:
- multi-alternate: multi_alternate alone (~6m)
- multi-async:     multi_async alone (~6m)
- multi-misc:      multi_standbys + multi_maintenance + ensure + multi_ifdown (~8m)

This brings the slow-schedule ceiling from 10.6m down to ~8m, with
quick and node (~7m each) now the practical floor.

Rebalance citus schedules:
- citus-1: citus_cluster_name + citus_force_failover + citus_multi_standbys (~4m)
- citus-2: basic_citus_operation + nonha_citus_operation + citus_skip_pg_hba (~6m)

Add style_checker job to run-pgaftest.yml (was only in run-tests.yml),
and gate both build-images and build-pgaftest on it passing. Also gate
build_images in run-tests.yml on style_checker.
The azure command stubs were already guarded by #if 0 after the
initial removal.  Delete them outright — dead code hidden behind
preprocessor guards is worse than no code at all.
…count

- Delete Makefile.azure and its include/COPY references — the three
  make targets (azcluster, az, azdrop) called pg_autoctl do azure
  which was deleted; the file was pure dead weight
- Drop pipenv from Dockerfile base apt-get block; nothing uses it
- Fix job-count formula comment: 3×4 + 5×1 = 17 (not 16)
- Remove tombstone comment from cli_demo.c file header
- Drop stale log_info pointing users to deleted pg_autoctl do demo run
@dimitri dimitri changed the title pgaftest: shared common/ library, .pgaf test suite, CI matrix, docs test tooling: pgaftest binary, .pgaf spec suite, CI modernisation Jul 9, 2026
@dimitri

dimitri commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

Extracted PRs — all merged

This WIP has been fully extracted into the following PRs, all of which are now merged:

PR Title
#1126 ci: replace ad-hoc linting job with citus/stylechecker:no-py style check
#1127 fix: three backportable bug fixes in pg_autoctl
#1128 ci: split build and test into separate jobs, upgrade actions to Node 24
#1129 remove: delete unmaintained Azure integration
#1130 refactor: add pg_autoctl inspect and manual sub-command groups
#1131 feat: pg_autoctl node — declarative node lifecycle from a single ini file
#1133 fix: hoist intToString() temporaries to avoid dangling strValue pointers
#1134 tests: migrate from nosetests to pytest
#1135 pg_autoctl: add commands and options required by the pgaftest runner
#1136 refactor: extract shared utility sources into src/bin/common/
#1137 feat: add pgaftest binary, tutorial specs, and CI workflow
#1138 tests: add pgaftest .pgaf spec suite and CI workflow

Closing in favour of the above.

@dimitri dimitri closed this Jul 10, 2026
@dimitri
dimitri deleted the pgaftest-infra-combined branch July 10, 2026 15:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Developer productivity Enhancements to ability to ship quality code enhancement New feature or request Packaging and CI Enhancements to our CI integration

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant