diff --git a/.github/workflows/install_deps.py b/.github/workflows/install_deps.py index 29acb701..6480f66a 100644 --- a/.github/workflows/install_deps.py +++ b/.github/workflows/install_deps.py @@ -46,7 +46,7 @@ def install_packages(what): packages = packages.get(what, []) ver = versions.get(what) if float(ver) >= 15: - packages += ['postgresql-{0}-citus-11.2'.format(ver)] + packages += ['postgresql-{0}-citus-12.1'.format(ver)] subprocess.call(['sudo', 'apt-get', 'update', '-y']) return subprocess.call(['sudo', 'apt-get', 'install', '-y', 'postgresql-' + ver, 'expect-dev'] + packages) diff --git a/.github/workflows/mapping.py b/.github/workflows/mapping.py index 397a4cc7..f75efec4 100644 --- a/.github/workflows/mapping.py +++ b/.github/workflows/mapping.py @@ -1 +1 @@ -versions = {'etcd': '9.6', 'etcd3': '14', 'consul': '13', 'exhibitor': '12', 'raft': '11', 'kubernetes': '15'} +versions = {'etcd': '9.6', 'etcd3': '16', 'consul': '13', 'exhibitor': '12', 'raft': '11', 'kubernetes': '15'} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 77a4df5b..7a102379 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -24,8 +24,11 @@ jobs: - name: Run tests and flake8 run: python .github/workflows/run_tests.py + - name: Install Python packaging build frontend + run: python -m pip install build + - name: Build a binary wheel and a source tarball - run: python setup.py sdist bdist_wheel + run: python -m build - name: Publish distribution to Test PyPI if: github.event_name == 'push' diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index bc6a1be3..aafe6b56 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -5,6 +5,7 @@ on: push: branches: - master + - 'REL_[0-9]+_[0-9]+' env: CODACY_PROJECT_TOKEN: ${{ secrets.CODACY_PROJECT_TOKEN }} @@ -173,7 +174,7 @@ jobs: - uses: jakebailey/pyright-action@v1 with: - version: 1.1.320 + version: 1.1.338 docs: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index c902c6eb..07e227ee 100644 --- a/.gitignore +++ b/.gitignore @@ -27,7 +27,7 @@ lib64 pip-log.txt # Unit test / coverage reports -.coverage +.coverage* .tox nosetests.xml coverage.xml @@ -35,6 +35,7 @@ htmlcov junit.xml features/output* dummy +result.json # Translations *.mo diff --git a/Dockerfile b/Dockerfile index c5b927ee..b74cdf24 100644 --- a/Dockerfile +++ b/Dockerfile @@ -94,9 +94,9 @@ RUN set -ex \ /usr/share/locale/??_?? \ /usr/share/postgresql/*/man \ /usr/share/postgresql-common/pg_wrapper \ - /usr/share/vim/vim80/doc \ - /usr/share/vim/vim80/lang \ - /usr/share/vim/vim80/tutor \ + /usr/share/vim/vim*/doc \ + /usr/share/vim/vim*/lang \ + /usr/share/vim/vim*/tutor \ # /var/lib/dpkg/info/* \ && find /usr/bin -xtype l -delete \ && find /var/log -type f -exec truncate --size 0 {} \; \ @@ -143,6 +143,7 @@ ARG PGBIN=/usr/lib/postgresql/$PG_MAJOR/bin ENV LC_ALL=$LC_ALL LANG=$LANG EDITOR=/usr/bin/editor ENV PGDATA=$PGDATA PATH=$PATH:$PGBIN +ENV ETCDCTL_API=3 COPY patroni /patroni/ COPY extras/confd/conf.d/haproxy.toml /etc/confd/conf.d/ diff --git a/Dockerfile.citus b/Dockerfile.citus index 7e6ec18c..5f0164b4 100644 --- a/Dockerfile.citus +++ b/Dockerfile.citus @@ -113,9 +113,9 @@ RUN set -ex \ /usr/share/locale/??_?? \ /usr/share/postgresql/*/man \ /usr/share/postgresql-common/pg_wrapper \ - /usr/share/vim/vim80/doc \ - /usr/share/vim/vim80/lang \ - /usr/share/vim/vim80/tutor \ + /usr/share/vim/vim*/doc \ + /usr/share/vim/vim*/lang \ + /usr/share/vim/vim*/tutor \ # /var/lib/dpkg/info/* \ && find /usr/bin -xtype l -delete \ && find /var/log -type f -exec truncate --size 0 {} \; \ @@ -164,6 +164,7 @@ ARG PGBIN=/usr/lib/postgresql/$PG_MAJOR/bin ENV LC_ALL=$LC_ALL LANG=$LANG EDITOR=/usr/bin/editor ENV PGDATA=$PGDATA PATH=$PATH:$PGBIN +ENV ETCDCTL_API=3 COPY patroni /patroni/ COPY extras/confd/conf.d/haproxy.toml /etc/confd/conf.d/ diff --git a/README.rst b/README.rst index f8f187e4..adade6b4 100644 --- a/README.rst +++ b/README.rst @@ -12,7 +12,7 @@ Patroni is a template for high availability (HA) PostgreSQL solutions using Pyth We call Patroni a "template" because it is far from being a one-size-fits-all or plug-and-play replication system. It will have its own caveats. Use wisely. -Currently supported PostgreSQL versions: 9.3 to 15. +Currently supported PostgreSQL versions: 9.3 to 16. **Note to Citus users**: Starting from 3.0 Patroni nicely integrates with the `Citus `__ database extension to Postgres. Please check the `Citus support page `__ in the Patroni documentation for more info about how to use Patroni high availability together with a Citus distributed cluster. @@ -77,23 +77,8 @@ There are a few options available: sudo apt-get install python3-psycopg2 # install psycopg2 module on Debian/Ubuntu sudo yum install python3-psycopg2 # install psycopg2 on RedHat/Fedora/CentOS -2. Install psycopg2 from the binary package +2. Specify one of `psycopg`, `psycopg2`, or `psycopg2-binary` in the list of dependencies when installing Patroni with pip (see below). -:: - - pip install psycopg2-binary - -3. Install psycopg2 from source - -:: - - pip install psycopg2>=2.5.4 - -4. Use psycopg 3.0 instead of psycopg2 - -:: - - pip install psycopg[binary]>=3.0.0 **General installation for pip** @@ -119,12 +104,20 @@ raft `pysyncobj` module in order to use python Raft implementation as DCS aws `boto3` in order to use AWS callbacks +all + all of the above (except psycopg family) +psycopg3 + `psycopg[binary]>=3.0.0` module +psycopg2 + `psycopg2>=2.5.4` module +psycopg2-binary + `psycopg2-binary` module -For example, the command in order to install Patroni together with dependencies for Etcd as a DCS and AWS callbacks is: +For example, the command in order to install Patroni together with psycopg3, dependencies for Etcd as a DCS, and AWS callbacks is: :: - pip install patroni[etcd,aws] + pip install patroni[psycopg3,etcd3,aws] Note that external tools to call in the replica creation or custom bootstrap scripts (i.e. WAL-E) should be installed independently of Patroni. @@ -158,7 +151,7 @@ run: YAML Configuration ================== -Go `here `__ for comprehensive information about settings for etcd, consul, and ZooKeeper. And for an example, see `postgres0.yml `__. +Go `here `__ for comprehensive information about settings for etcd, consul, and ZooKeeper. And for an example, see `postgres0.yml `__. ========================= Environment Configuration diff --git a/docker-compose-citus.yml b/docker-compose-citus.yml index 7ff2a2c5..da71c50a 100644 --- a/docker-compose-citus.yml +++ b/docker-compose-citus.yml @@ -19,7 +19,6 @@ services: image: ${PATRONI_TEST_IMAGE:-patroni-citus} networks: [ demo ] environment: - ETCDCTL_API: 3 ETCD_LISTEN_PEER_URLS: http://0.0.0.0:2380 ETCD_LISTEN_CLIENT_URLS: http://0.0.0.0:2379 ETCD_INITIAL_CLUSTER: etcd1=http://etcd1:2380,etcd2=http://etcd2:2380,etcd3=http://etcd3:2380 @@ -28,19 +27,19 @@ services: ETCD_UNSUPPORTED_ARCH: arm64 container_name: demo-etcd1 hostname: etcd1 - command: etcd -name etcd1 -initial-advertise-peer-urls http://etcd1:2380 + command: etcd --name etcd1 --initial-advertise-peer-urls http://etcd1:2380 etcd2: <<: *etcd container_name: demo-etcd2 hostname: etcd2 - command: etcd -name etcd2 -initial-advertise-peer-urls http://etcd2:2380 + command: etcd --name etcd2 --initial-advertise-peer-urls http://etcd2:2380 etcd3: <<: *etcd container_name: demo-etcd3 hostname: etcd3 - command: etcd -name etcd3 -initial-advertise-peer-urls http://etcd3:2380 + command: etcd --name etcd3 --initial-advertise-peer-urls http://etcd3:2380 haproxy: image: ${PATRONI_TEST_IMAGE:-patroni-citus} @@ -53,7 +52,6 @@ services: - "5001:5001" # Load-balancing across workers primaries command: haproxy environment: &haproxy_env - ETCDCTL_API: 3 ETCDCTL_ENDPOINTS: http://etcd1:2379,http://etcd2:2379,http://etcd3:2379 PATRONI_ETCD3_HOSTS: "'etcd1:2379','etcd2:2379','etcd3:2379'" PATRONI_SCOPE: demo diff --git a/docker-compose.yml b/docker-compose.yml index 996c2c82..6b7d7a92 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,19 +25,19 @@ services: ETCD_UNSUPPORTED_ARCH: arm64 container_name: demo-etcd1 hostname: etcd1 - command: etcd -name etcd1 -initial-advertise-peer-urls http://etcd1:2380 + command: etcd --name etcd1 --initial-advertise-peer-urls http://etcd1:2380 etcd2: <<: *etcd container_name: demo-etcd2 hostname: etcd2 - command: etcd -name etcd2 -initial-advertise-peer-urls http://etcd2:2380 + command: etcd --name etcd2 --initial-advertise-peer-urls http://etcd2:2380 etcd3: <<: *etcd container_name: demo-etcd3 hostname: etcd3 - command: etcd -name etcd3 -initial-advertise-peer-urls http://etcd3:2380 + command: etcd --name etcd3 --initial-advertise-peer-urls http://etcd3:2380 haproxy: image: ${PATRONI_TEST_IMAGE:-patroni} diff --git a/docker/README.md b/docker/README.md index f2b30ab6..0b7f3d58 100644 --- a/docker/README.md +++ b/docker/README.md @@ -19,102 +19,97 @@ The haproxy listens on ports 5000 (connects to the primary) and 5001 (does load- Example session: - $ docker-compose up -d - Creating demo-haproxy ... - Creating demo-patroni2 ... - Creating demo-patroni1 ... - Creating demo-patroni3 ... - Creating demo-etcd2 ... - Creating demo-etcd1 ... - Creating demo-etcd3 ... - Creating demo-haproxy - Creating demo-patroni2 - Creating demo-patroni1 - Creating demo-patroni3 - Creating demo-etcd1 - Creating demo-etcd2 - Creating demo-etcd2 ... done + $ docker compose up -d + ✔ Network patroni_demo Created + ✔ Container demo-etcd1 Started + ✔ Container demo-haproxy Started + ✔ Container demo-patroni1 Started + ✔ Container demo-patroni2 Started + ✔ Container demo-patroni3 Started + ✔ Container demo-etcd2 Started + ✔ Container demo-etcd3 Started $ docker ps - CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES - 5b7a90b4cfbf patroni "/bin/sh /entrypoint…" 29 seconds ago Up 27 seconds demo-etcd2 - e30eea5222f2 patroni "/bin/sh /entrypoint…" 29 seconds ago Up 27 seconds demo-etcd1 - 83bcf3cb208f patroni "/bin/sh /entrypoint…" 29 seconds ago Up 27 seconds demo-etcd3 - 922532c56e7d patroni "/bin/sh /entrypoint…" 29 seconds ago Up 28 seconds demo-patroni3 - 14f875e445f3 patroni "/bin/sh /entrypoint…" 29 seconds ago Up 28 seconds demo-patroni2 - 110d1073b383 patroni "/bin/sh /entrypoint…" 29 seconds ago Up 28 seconds demo-patroni1 - 5af5e6e36028 patroni "/bin/sh /entrypoint…" 29 seconds ago Up 28 seconds 0.0.0.0:5000-5001->5000-5001/tcp demo-haproxy + CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES + a37bcec56726 patroni "/bin/sh /entrypoint…" 15 minutes ago Up 15 minutes demo-etcd3 + 034ab73868a8 patroni "/bin/sh /entrypoint…" 15 minutes ago Up 15 minutes demo-patroni2 + 03837736f710 patroni "/bin/sh /entrypoint…" 15 minutes ago Up 15 minutes demo-patroni3 + 22815c3d85b3 patroni "/bin/sh /entrypoint…" 15 minutes ago Up 15 minutes demo-etcd2 + 814b4304d132 patroni "/bin/sh /entrypoint…" 15 minutes ago Up 15 minutes 0.0.0.0:5000-5001->5000-5001/tcp, :::5000-5001->5000-5001/tcp demo-haproxy + 6375b0ba2d0a patroni "/bin/sh /entrypoint…" 15 minutes ago Up 15 minutes demo-patroni1 + aef8bf3ee91f patroni "/bin/sh /entrypoint…" 15 minutes ago Up 15 minutes demo-etcd1 $ docker logs demo-patroni1 - 2019-02-20 08:19:32,714 INFO: Failed to import patroni.dcs.consul - 2019-02-20 08:19:32,737 INFO: Selected new etcd server http://etcd3:2379 - 2019-02-20 08:19:35,140 INFO: Lock owner: None; I am patroni1 - 2019-02-20 08:19:35,174 INFO: trying to bootstrap a new cluster + 2023-11-21 09:04:33,547 INFO: Selected new etcd server http://172.29.0.3:2379 + 2023-11-21 09:04:33,605 INFO: Lock owner: None; I am patroni1 + 2023-11-21 09:04:33,693 INFO: trying to bootstrap a new cluster ... - 2019-02-20 08:19:39,310 INFO: postmaster pid=37 - 2019-02-20 08:19:39.314 UTC [37] LOG: listening on IPv4 address "0.0.0.0", port 5432 - 2019-02-20 08:19:39.321 UTC [37] LOG: listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432" - 2019-02-20 08:19:39.353 UTC [39] LOG: database system was shut down at 2019-02-20 08:19:36 UTC - 2019-02-20 08:19:39.354 UTC [40] FATAL: the database system is starting up - localhost:5432 - rejecting connections - 2019-02-20 08:19:39.369 UTC [37] LOG: database system is ready to accept connections + 2023-11-21 09:04:34.920 UTC [43] LOG: starting PostgreSQL 15.5 (Debian 15.5-1.pgdg120+1) on x86_64-pc-linux-gnu, compiled by gcc (Debian 12.2.0-14) 12.2.0, 64-bit + 2023-11-21 09:04:34.921 UTC [43] LOG: listening on IPv4 address "0.0.0.0", port 5432 + 2023-11-21 09:04:34,922 INFO: postmaster pid=43 + 2023-11-21 09:04:34.922 UTC [43] LOG: listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432" + 2023-11-21 09:04:34.925 UTC [47] LOG: database system was shut down at 2023-11-21 09:04:34 UTC + 2023-11-21 09:04:34.928 UTC [43] LOG: database system is ready to accept connections localhost:5432 - accepting connections - 2019-02-20 08:19:39,383 INFO: establishing a new patroni connection to the postgres cluster - 2019-02-20 08:19:39,408 INFO: running post_bootstrap - 2019-02-20 08:19:39,432 WARNING: Could not activate Linux watchdog device: "Can't open watchdog device: [Errno 2] No such file or directory: '/dev/watchdog'" - 2019-02-20 08:19:39,515 INFO: initialized a new cluster - 2019-02-20 08:19:49,424 INFO: Lock owner: patroni1; I am patroni1 - 2019-02-20 08:19:49,447 INFO: Lock owner: patroni1; I am patroni1 - 2019-02-20 08:19:49,480 INFO: no action. i am the leader with the lock - 2019-02-20 08:19:59,422 INFO: Lock owner: patroni1; I am patroni1 + localhost:5432 - accepting connections + 2023-11-21 09:04:34,938 INFO: establishing a new patroni heartbeat connection to postgres + 2023-11-21 09:04:34,992 INFO: running post_bootstrap + 2023-11-21 09:04:35,004 WARNING: User creation via "bootstrap.users" will be removed in v4.0.0 + 2023-11-21 09:04:35,009 WARNING: Could not activate Linux watchdog device: Can't open watchdog device: [Errno 2] No such file or directory: '/dev/watchdog' + 2023-11-21 09:04:35,189 INFO: initialized a new cluster + 2023-11-21 09:04:35,328 INFO: no action. I am (patroni1), the leader with the lock + 2023-11-21 09:04:43,824 INFO: establishing a new patroni restapi connection to postgres + 2023-11-21 09:04:45,322 INFO: no action. I am (patroni1), the leader with the lock + 2023-11-21 09:04:55,320 INFO: no action. I am (patroni1), the leader with the lock + ... $ docker exec -ti demo-patroni1 bash postgres@patroni1:~$ patronictl list - +---------+----------+------------+--------+---------+----+-----------+ - | Cluster | Member | Host | Role | State | TL | Lag in MB | - +---------+----------+------------+--------+---------+----+-----------+ - | demo | patroni1 | 172.22.0.3 | Leader | running | 1 | 0 | - | demo | patroni2 | 172.22.0.7 | | running | 1 | 0 | - | demo | patroni3 | 172.22.0.4 | | running | 1 | 0 | - +---------+----------+------------+--------+---------+----+-----------+ + + Cluster: demo (7303838734793224214) --------+----+-----------+ + | Member | Host | Role | State | TL | Lag in MB | + +----------+------------+---------+-----------+----+-----------+ + | patroni1 | 172.29.0.2 | Leader | running | 1 | | + | patroni2 | 172.29.0.6 | Replica | streaming | 1 | 0 | + | patroni3 | 172.29.0.5 | Replica | streaming | 1 | 0 | + +----------+------------+---------+-----------+----+-----------+ - postgres@patroni1:~$ etcdctl ls --recursive --sort -p /service/demo + postgres@patroni1:~$ etcdctl get --keys-only --prefix /service/demo /service/demo/config /service/demo/initialize /service/demo/leader - /service/demo/members/ /service/demo/members/patroni1 /service/demo/members/patroni2 /service/demo/members/patroni3 - /service/demo/optime/ - /service/demo/optime/leader + /service/demo/status postgres@patroni1:~$ etcdctl member list - 1bab629f01fa9065: name=etcd3 peerURLs=http://etcd3:2380 clientURLs=http://etcd3:2379 isLeader=false - 8ecb6af518d241cc: name=etcd2 peerURLs=http://etcd2:2380 clientURLs=http://etcd2:2379 isLeader=true - b2e169fcb8a34028: name=etcd1 peerURLs=http://etcd1:2380 clientURLs=http://etcd1:2379 isLeader=false + 2bf3e2ceda5d5960, started, etcd2, http://etcd2:2380, http://172.29.0.3:2379 + 55b3264e129c7005, started, etcd3, http://etcd3:2380, http://172.29.0.7:2379 + acce7233f8ec127e, started, etcd1, http://etcd1:2380, http://172.29.0.8:2379 + + postgres@patroni1:~$ exit $ docker exec -ti demo-haproxy bash postgres@haproxy:~$ psql -h localhost -p 5000 -U postgres -W Password: postgres - psql (11.2 (Ubuntu 11.2-1.pgdg18.04+1), server 10.7 (Debian 10.7-1.pgdg90+1)) + psql (15.5 (Debian 15.5-1.pgdg120+1)) Type "help" for help. - localhost/postgres=# select pg_is_in_recovery(); + postgres=# SELECT pg_is_in_recovery(); pg_is_in_recovery ─────────────────── f (1 row) - localhost/postgres=# \q + postgres=# \q - $postgres@haproxy:~ psql -h localhost -p 5001 -U postgres -W + postgres@haproxy:~$ psql -h localhost -p 5001 -U postgres -W Password: postgres - psql (11.2 (Ubuntu 11.2-1.pgdg18.04+1), server 10.7 (Debian 10.7-1.pgdg90+1)) + psql (15.5 (Debian 15.5-1.pgdg120+1)) Type "help" for help. - localhost/postgres=# select pg_is_in_recovery(); + postgres=# SELECT pg_is_in_recovery(); pg_is_in_recovery ─────────────────── t @@ -127,81 +122,86 @@ The haproxy listens on ports 5000 (connects to the coordinator primary) and 5001 Example session: - $ docker-compose -f docker-compose-citus.yml up -d - Creating demo-work2-1 ... done - Creating demo-work1-1 ... done - Creating demo-etcd2 ... done - Creating demo-etcd1 ... done - Creating demo-coord3 ... done - Creating demo-etcd3 ... done - Creating demo-coord1 ... done - Creating demo-haproxy ... done - Creating demo-work2-2 ... done - Creating demo-coord2 ... done - Creating demo-work1-2 ... done + $ docker compose -f docker-compose-citus.yml up -d + ✔ Network patroni_demo Created + ✔ Container demo-coord2 Started + ✔ Container demo-work2-2 Started + ✔ Container demo-etcd1 Started + ✔ Container demo-haproxy Started + ✔ Container demo-work1-1 Started + ✔ Container demo-work2-1 Started + ✔ Container demo-work1-2 Started + ✔ Container demo-coord1 Started + ✔ Container demo-etcd3 Started + ✔ Container demo-coord3 Started + ✔ Container demo-etcd2 Started + $ docker ps - CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES - 852d8885a612 patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 3 seconds demo-coord3 - cdd692f947ab patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 3 seconds demo-work1-2 - 9f4e340b36da patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 3 seconds demo-etcd3 - d69c129a960a patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 4 seconds demo-etcd1 - c5849689b8cd patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 4 seconds demo-coord1 - c9d72bd6217d patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 3 seconds demo-work2-1 - 24b1b43efa05 patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 4 seconds demo-coord2 - cb0cc2b4ca0a patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 3 seconds demo-work2-2 - 9796c6b8aad5 patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 5 seconds demo-work1-1 - 8baccd74dcae patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 4 seconds demo-etcd2 - 353ec62a0187 patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 4 seconds 0.0.0.0:5000-5001->5000-5001/tcp demo-haproxy + CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES + 79c95492fac9 patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-etcd3 + 77eb82d0f0c1 patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-work2-1 + 03dacd7267ef patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-etcd1 + db9206c66f85 patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-etcd2 + 9a0fef7b7dd4 patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-work1-2 + f06b031d99dc patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-work2-2 + f7c58545f314 patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-coord2 + 383f9e7e188a patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-work1-1 + f02e96dcc9d6 patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-coord3 + 6945834b7056 patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-coord1 + b96ca42f785d patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes 0.0.0.0:5000-5001->5000-5001/tcp, :::5000-5001->5000-5001/tcp demo-haproxy + $ docker logs demo-coord1 - 2023-01-05 15:09:31,295 INFO: Selected new etcd server http://172.27.0.4:2379 - 2023-01-05 15:09:31,388 INFO: Lock owner: None; I am coord1 - 2023-01-05 15:09:31,501 INFO: trying to bootstrap a new cluster + 2023-11-21 09:36:14,293 INFO: Selected new etcd server http://172.30.0.4:2379 + 2023-11-21 09:36:14,390 INFO: Lock owner: None; I am coord1 + 2023-11-21 09:36:14,478 INFO: trying to bootstrap a new cluster ... - 2023-01-05 15:09:45,096 INFO: postmaster pid=39 + 2023-11-21 09:36:16,475 INFO: postmaster pid=52 localhost:5432 - no response - 2023-01-05 15:09:45.137 UTC [39] LOG: starting PostgreSQL 15.1 (Debian 15.1-1.pgdg110+1) on x86_64-pc-linux-gnu, compiled by gcc (Debian 10.2.1-6) 10.2.1 20210110, 64-bit - 2023-01-05 15:09:45.137 UTC [39] LOG: listening on IPv4 address "0.0.0.0", port 5432 - 2023-01-05 15:09:45.152 UTC [39] LOG: listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432" - 2023-01-05 15:09:45.177 UTC [43] LOG: database system was shut down at 2023-01-05 15:09:32 UTC - 2023-01-05 15:09:45.193 UTC [39] LOG: database system is ready to accept connections + 2023-11-21 09:36:16.495 UTC [52] LOG: starting PostgreSQL 15.5 (Debian 15.5-1.pgdg120+1) on x86_64-pc-linux-gnu, compiled by gcc (Debian 12.2.0-14) 12.2.0, 64-bit + 2023-11-21 09:36:16.495 UTC [52] LOG: listening on IPv4 address "0.0.0.0", port 5432 + 2023-11-21 09:36:16.496 UTC [52] LOG: listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432" + 2023-11-21 09:36:16.498 UTC [56] LOG: database system was shut down at 2023-11-21 09:36:15 UTC + 2023-11-21 09:36:16.501 UTC [52] LOG: database system is ready to accept connections localhost:5432 - accepting connections localhost:5432 - accepting connections - 2023-01-05 15:09:46,139 INFO: establishing a new patroni connection to the postgres cluster - 2023-01-05 15:09:46,208 INFO: running post_bootstrap - 2023-01-05 15:09:47.209 UTC [55] LOG: starting maintenance daemon on database 16386 user 10 - 2023-01-05 15:09:47.209 UTC [55] CONTEXT: Citus maintenance daemon for database 16386 user 10 - 2023-01-05 15:09:47,215 WARNING: Could not activate Linux watchdog device: "Can't open watchdog device: [Errno 2] No such file or directory: '/dev/watchdog'" - 2023-01-05 15:09:47.446 UTC [41] LOG: checkpoint starting: immediate force wait - 2023-01-05 15:09:47,466 INFO: initialized a new cluster - 2023-01-05 15:09:47,594 DEBUG: query(SELECT nodeid, groupid, nodename, nodeport, noderole FROM pg_catalog.pg_dist_node WHERE noderole = 'primary', ()) - 2023-01-05 15:09:47,594 INFO: establishing a new patroni connection to the postgres cluster - 2023-01-05 15:09:47,467 INFO: Lock owner: coord1; I am coord1 - 2023-01-05 15:09:47,613 DEBUG: query(SELECT pg_catalog.citus_set_coordinator_host(%s, %s, 'primary', 'default'), ('172.27.0.6', 5432)) - 2023-01-05 15:09:47,924 INFO: no action. I am (coord1), the leader with the lock - 2023-01-05 15:09:51.282 UTC [41] LOG: checkpoint complete: wrote 1086 buffers (53.0%); 0 WAL file(s) added, 0 removed, 0 recycled; write=0.029 s, sync=3.746 s, total=3.837 s; sync files=280, longest=0.028 s, average=0.014 s; distance=8965 kB, estimate=8965 kB - 2023-01-05 15:09:51.283 UTC [41] LOG: checkpoint starting: immediate force wait - 2023-01-05 15:09:51.495 UTC [41] LOG: checkpoint complete: wrote 18 buffers (0.9%); 0 WAL file(s) added, 0 removed, 0 recycled; write=0.044 s, sync=0.091 s, total=0.212 s; sync files=15, longest=0.015 s, average=0.007 s; distance=67 kB, estimate=8076 kB - 2023-01-05 15:09:57,467 INFO: Lock owner: coord1; I am coord1 - 2023-01-05 15:09:57,569 INFO: Assigning synchronous standby status to ['coord3'] + 2023-11-21 09:36:17,509 INFO: establishing a new patroni heartbeat connection to postgres + 2023-11-21 09:36:17,569 INFO: running post_bootstrap + 2023-11-21 09:36:17,593 WARNING: User creation via "bootstrap.users" will be removed in v4.0.0 + 2023-11-21 09:36:17,783 INFO: establishing a new patroni restapi connection to postgres + 2023-11-21 09:36:17,969 WARNING: Could not activate Linux watchdog device: Can't open watchdog device: [Errno 2] No such file or directory: '/dev/watchdog' + 2023-11-21 09:36:17.969 UTC [70] LOG: starting maintenance daemon on database 16386 user 10 + 2023-11-21 09:36:17.969 UTC [70] CONTEXT: Citus maintenance daemon for database 16386 user 10 + 2023-11-21 09:36:18.159 UTC [54] LOG: checkpoint starting: immediate force wait + 2023-11-21 09:36:18,162 INFO: initialized a new cluster + 2023-11-21 09:36:18,164 INFO: Lock owner: coord1; I am coord1 + 2023-11-21 09:36:18,297 INFO: Enabled synchronous replication + 2023-11-21 09:36:18,298 DEBUG: Adding the new task: PgDistNode(nodeid=None,group=0,host=172.30.0.3,port=5432,event=after_promote) + 2023-11-21 09:36:18,298 DEBUG: Adding the new task: PgDistNode(nodeid=None,group=1,host=172.30.0.7,port=5432,event=after_promote) + 2023-11-21 09:36:18,298 DEBUG: Adding the new task: PgDistNode(nodeid=None,group=2,host=172.30.0.8,port=5432,event=after_promote) + 2023-11-21 09:36:18,299 DEBUG: query(SELECT nodeid, groupid, nodename, nodeport, noderole FROM pg_catalog.pg_dist_node WHERE noderole = 'primary', ()) + 2023-11-21 09:36:18,299 INFO: establishing a new patroni citus connection to postgres + 2023-11-21 09:36:18,323 DEBUG: query(SELECT pg_catalog.citus_add_node(%s, %s, %s, 'primary', 'default'), ('172.30.0.7', 5432, 1)) + 2023-11-21 09:36:18,361 INFO: no action. I am (coord1), the leader with the lock + 2023-11-21 09:36:18,393 DEBUG: query(SELECT pg_catalog.citus_add_node(%s, %s, %s, 'primary', 'default'), ('172.30.0.8', 5432, 2)) + 2023-11-21 09:36:28,164 INFO: Lock owner: coord1; I am coord1 + 2023-11-21 09:36:28,251 INFO: Assigning synchronous standby status to ['coord3'] server signaled - 2023-01-05 15:09:57.574 UTC [39] LOG: received SIGHUP, reloading configuration files - 2023-01-05 15:09:57.580 UTC [39] LOG: parameter "synchronous_standby_names" changed to "coord3" - 2023-01-05 15:09:59,637 INFO: Synchronous standby status assigned to ['coord3'] - 2023-01-05 15:09:59,638 DEBUG: query(SELECT pg_catalog.citus_add_node(%s, %s, %s, 'primary', 'default'), ('172.27.0.2', 5432, 1)) - 2023-01-05 15:09:59.690 UTC [67] LOG: standby "coord3" is now a synchronous standby with priority 1 - 2023-01-05 15:09:59.690 UTC [67] STATEMENT: START_REPLICATION SLOT "coord3" 0/3000000 TIMELINE 1 - 2023-01-05 15:09:59,694 INFO: no action. I am (coord1), the leader with the lock - 2023-01-05 15:09:59,704 DEBUG: query(SELECT pg_catalog.citus_add_node(%s, %s, %s, 'primary', 'default'), ('172.27.0.8', 5432, 2)) - 2023-01-05 15:10:07,625 INFO: no action. I am (coord1), the leader with the lock - 2023-01-05 15:10:17,579 INFO: no action. I am (coord1), the leader with the lock + 2023-11-21 09:36:28.435 UTC [52] LOG: received SIGHUP, reloading configuration files + 2023-11-21 09:36:28.436 UTC [52] LOG: parameter "synchronous_standby_names" changed to "coord3" + 2023-11-21 09:36:28.641 UTC [83] LOG: standby "coord3" is now a synchronous standby with priority 1 + 2023-11-21 09:36:28.641 UTC [83] STATEMENT: START_REPLICATION SLOT "coord3" 0/3000000 TIMELINE 1 + 2023-11-21 09:36:30,582 INFO: Synchronous standby status assigned to ['coord3'] + 2023-11-21 09:36:30,626 INFO: no action. I am (coord1), the leader with the lock + 2023-11-21 09:36:38,250 INFO: no action. I am (coord1), the leader with the lock + ... $ docker exec -ti demo-haproxy bash postgres@haproxy:~$ etcdctl member list - 1bab629f01fa9065, started, etcd3, http://etcd3:2380, http://172.27.0.10:2379 - 8ecb6af518d241cc, started, etcd2, http://etcd2:2380, http://172.27.0.4:2379 - b2e169fcb8a34028, started, etcd1, http://etcd1:2380, http://172.27.0.7:2379 + 2b28411e74c0c281, started, etcd3, http://etcd3:2380, http://172.30.0.4:2379 + 6c70137d27cfa6c1, started, etcd2, http://etcd2:2380, http://172.30.0.5:2379 + a28f9a70ebf21304, started, etcd1, http://etcd1:2380, http://172.30.0.6:2379 postgres@haproxy:~$ etcdctl get --keys-only --prefix /service/demo /service/demo/0/config @@ -229,7 +229,7 @@ Example session: postgres@haproxy:~$ psql -h localhost -p 5000 -U postgres -d citus Password for user postgres: postgres - psql (15.1 (Debian 15.1-1.pgdg110+1)) + psql (15.5 (Debian 15.5-1.pgdg120+1)) SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, compression: off) Type "help" for help. @@ -240,67 +240,67 @@ Example session: (1 row) citus=# table pg_dist_node; - nodeid | groupid | nodename | nodeport | noderack | hasmetadata | isactive | noderole | nodecluster | metadatasynced | shouldhaveshards + nodeid | groupid | nodename | nodeport | noderack | hasmetadata | isactive | noderole | nodecluster | metadatasynced | shouldhaveshards --------+---------+------------+----------+----------+-------------+----------+----------+-------------+----------------+------------------ - 1 | 0 | 172.27.0.6 | 5432 | default | t | t | primary | default | t | f - 2 | 1 | 172.27.0.2 | 5432 | default | t | t | primary | default | t | t - 3 | 2 | 172.27.0.8 | 5432 | default | t | t | primary | default | t | t + 1 | 0 | 172.30.0.3 | 5432 | default | t | t | primary | default | t | f + 2 | 1 | 172.30.0.7 | 5432 | default | t | t | primary | default | t | t + 3 | 2 | 172.30.0.8 | 5432 | default | t | t | primary | default | t | t (3 rows) citus=# \q postgres@haproxy:~$ patronictl list - + Citus cluster: demo ----------+--------------+---------+----+-----------+ - | Group | Member | Host | Role | State | TL | Lag in MB | - +-------+---------+-------------+--------------+---------+----+-----------+ - | 0 | coord1 | 172.27.0.6 | Leader | running | 1 | | - | 0 | coord2 | 172.27.0.5 | Replica | running | 1 | 0 | - | 0 | coord3 | 172.27.0.9 | Sync Standby | running | 1 | 0 | - | 1 | work1-1 | 172.27.0.2 | Leader | running | 1 | | - | 1 | work1-2 | 172.27.0.12 | Sync Standby | running | 1 | 0 | - | 2 | work2-1 | 172.27.0.11 | Sync Standby | running | 1 | 0 | - | 2 | work2-2 | 172.27.0.8 | Leader | running | 1 | | - +-------+---------+-------------+--------------+---------+----+-----------+ + + Citus cluster: demo ----------+--------------+-----------+----+-----------+ + | Group | Member | Host | Role | State | TL | Lag in MB | + +-------+---------+-------------+--------------+-----------+----+-----------+ + | 0 | coord1 | 172.30.0.3 | Leader | running | 1 | | + | 0 | coord2 | 172.30.0.12 | Replica | streaming | 1 | 0 | + | 0 | coord3 | 172.30.0.2 | Sync Standby | streaming | 1 | 0 | + | 1 | work1-1 | 172.30.0.7 | Leader | running | 1 | | + | 1 | work1-2 | 172.30.0.10 | Sync Standby | streaming | 1 | 0 | + | 2 | work2-1 | 172.30.0.8 | Leader | running | 1 | | + | 2 | work2-2 | 172.30.0.11 | Sync Standby | streaming | 1 | 0 | + +-------+---------+-------------+--------------+-----------+----+-----------+ + postgres@haproxy:~$ patronictl switchover --group 2 --force Current cluster topology - + Citus cluster: demo (group: 2, 7185185529556963355) +-----------+ - | Member | Host | Role | State | TL | Lag in MB | - +---------+-------------+--------------+---------+----+-----------+ - | work2-1 | 172.27.0.11 | Sync Standby | running | 1 | 0 | - | work2-2 | 172.27.0.8 | Leader | running | 1 | | - +---------+-------------+--------------+---------+----+-----------+ - 2023-01-05 15:29:29.54204 Successfully switched over to "work2-1" - + Citus cluster: demo (group: 2, 7185185529556963355) -------+ + + Citus cluster: demo (group: 2, 7303846899271086103) --+-----------+ + | Member | Host | Role | State | TL | Lag in MB | + +---------+-------------+--------------+-----------+----+-----------+ + | work2-1 | 172.30.0.8 | Leader | running | 1 | | + | work2-2 | 172.30.0.11 | Sync Standby | streaming | 1 | 0 | + +---------+-------------+--------------+-----------+----+-----------+ + 2023-11-21 09:44:15.83849 Successfully switched over to "work2-2" + + Citus cluster: demo (group: 2, 7303846899271086103) -------+ | Member | Host | Role | State | TL | Lag in MB | +---------+-------------+---------+---------+----+-----------+ - | work2-1 | 172.27.0.11 | Leader | running | 1 | | - | work2-2 | 172.27.0.8 | Replica | stopped | | unknown | + | work2-1 | 172.30.0.8 | Replica | stopped | | unknown | + | work2-2 | 172.30.0.11 | Leader | running | 1 | | +---------+-------------+---------+---------+----+-----------+ postgres@haproxy:~$ patronictl list - + Citus cluster: demo ----------+--------------+---------+----+-----------+ - | Group | Member | Host | Role | State | TL | Lag in MB | - +-------+---------+-------------+--------------+---------+----+-----------+ - | 0 | coord1 | 172.27.0.6 | Leader | running | 1 | | - | 0 | coord2 | 172.27.0.5 | Replica | running | 1 | 0 | - | 0 | coord3 | 172.27.0.9 | Sync Standby | running | 1 | 0 | - | 1 | work1-1 | 172.27.0.2 | Leader | running | 1 | | - | 1 | work1-2 | 172.27.0.12 | Sync Standby | running | 1 | 0 | - | 2 | work2-1 | 172.27.0.11 | Leader | running | 2 | | - | 2 | work2-2 | 172.27.0.8 | Sync Standby | running | 2 | 0 | - +-------+---------+-------------+--------------+---------+----+-----------+ + + Citus cluster: demo ----------+--------------+-----------+----+-----------+ + | Group | Member | Host | Role | State | TL | Lag in MB | + +-------+---------+-------------+--------------+-----------+----+-----------+ + | 0 | coord1 | 172.30.0.3 | Leader | running | 1 | | + | 0 | coord2 | 172.30.0.12 | Replica | streaming | 1 | 0 | + | 0 | coord3 | 172.30.0.2 | Sync Standby | streaming | 1 | 0 | + | 1 | work1-1 | 172.30.0.7 | Leader | running | 1 | | + | 1 | work1-2 | 172.30.0.10 | Sync Standby | streaming | 1 | 0 | + | 2 | work2-1 | 172.30.0.8 | Sync Standby | streaming | 2 | 0 | + | 2 | work2-2 | 172.30.0.11 | Leader | running | 2 | | + +-------+---------+-------------+--------------+-----------+----+-----------+ postgres@haproxy:~$ psql -h localhost -p 5000 -U postgres -d citus - Password for user postgres: postgres - psql (15.1 (Debian 15.1-1.pgdg110+1)) + psql (15.5 (Debian 15.5-1.pgdg120+1)) SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, compression: off) Type "help" for help. citus=# table pg_dist_node; - nodeid | groupid | nodename | nodeport | noderack | hasmetadata | isactive | noderole | nodecluster | metadatasynced | shouldhaveshards + nodeid | groupid | nodename | nodeport | noderack | hasmetadata | isactive | noderole | nodecluster | metadatasynced | shouldhaveshards --------+---------+-------------+----------+----------+-------------+----------+----------+-------------+----------------+------------------ - 1 | 0 | 172.27.0.6 | 5432 | default | t | t | primary | default | t | f - 3 | 2 | 172.27.0.11 | 5432 | default | t | t | primary | default | t | t - 2 | 1 | 172.27.0.2 | 5432 | default | t | t | primary | default | t | t + 1 | 0 | 172.30.0.3 | 5432 | default | t | t | primary | default | t | f + 3 | 2 | 172.30.0.11 | 5432 | default | t | t | primary | default | t | t + 2 | 1 | 172.30.0.7 | 5432 | default | t | t | primary | default | t | t (3 rows) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index fb30bee7..1e6e91b5 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -13,6 +13,8 @@ readonly PATRONI_NAMESPACE="${PATRONI_NAMESPACE%/}" DOCKER_IP=$(hostname --ip-address) readonly DOCKER_IP +export DUMB_INIT_SETSID=0 + case "$1" in haproxy) haproxy -f /etc/haproxy/haproxy.cfg -p /var/run/haproxy.pid -D @@ -72,4 +74,4 @@ export PATRONI_SUPERUSER_SSLKEY="${PATRONI_SUPERUSER_SSLKEY:-$PGSSLKEY}" export PATRONI_SUPERUSER_SSLCERT="${PATRONI_SUPERUSER_SSLCERT:-$PGSSLCERT}" export PATRONI_SUPERUSER_SSLROOTCERT="${PATRONI_SUPERUSER_SSLROOTCERT:-$PGSSLROOTCERT}" -exec python3 /patroni.py postgres0.yml +exec dumb-init python3 /patroni.py postgres0.yml diff --git a/docs/ENVIRONMENT.rst b/docs/ENVIRONMENT.rst index e3762aca..ad7c46b8 100644 --- a/docs/ENVIRONMENT.rst +++ b/docs/ENVIRONMENT.rst @@ -24,15 +24,6 @@ Log - **PATRONI\_LOG\_FILE\_SIZE**: Size of patroni.log file (in bytes) that triggers a log rolling. - **PATRONI\_LOG\_LOGGERS**: Redefine logging level per python module. Example ``PATRONI_LOG_LOGGERS="{patroni.postmaster: WARNING, urllib3: DEBUG}"`` -Bootstrap configuration ------------------------ -It is possible to create new database users right after the successful initialization of a new cluster. This process is defined by the following variables: - -- **PATRONI\_\_PASSWORD=''** -- **PATRONI\_\_OPTIONS='list,of,options'** - -Example: defining ``PATRONI_admin_PASSWORD=strongpasswd`` and ``PATRONI_admin_OPTIONS='createrole,createdb'`` will cause creation of the user **admin** with the password **strongpasswd** that is allowed to create other users and databases. - Citus ----- Enables integration Patroni with `Citus `__. If configured, Patroni will take care of registering Citus worker nodes on the coordinator. You can find more information about Citus support :ref:`here `. @@ -94,6 +85,7 @@ ZooKeeper - **PATRONI\_ZOOKEEPER\_KEY\_PASSWORD**: (optional) The client key password. - **PATRONI\_ZOOKEEPER\_VERIFY**: (optional) Whether to verify certificate or not. Defaults to ``true``. - **PATRONI\_ZOOKEEPER\_SET\_ACLS**: (optional) If set, configure Kazoo to apply a default ACL to each ZNode that it creates. ACLs will assume 'x509' schema and should be specified as a dictionary with the principal as the key and one or more permissions as a list in the value. Permissions may be one of ``CREATE``, ``READ``, ``WRITE``, ``DELETE`` or ``ADMIN``. For example, ``set_acls: {CN=principal1: [CREATE, READ], CN=principal2: [ALL]}``. +- **PATRONI\_ZOOKEEPER\_AUTH\_DATA**: (optional) Authentication credentials to use for the connection. Should be a dictionary in the form that `scheme` is the key and `credential` is the value. Defaults to empty dictionary. .. note:: It is required to install ``kazoo>=2.6.0`` to support SSL. @@ -209,10 +201,10 @@ REST API CTL --- - **PATRONICTL\_CONFIG\_FILE**: (optional) location of the configuration file. -- **PATRONI\_CTL\_USERNAME**: (optional) Basic-auth username for accessing protected REST API endpoints. If not provided patronictl will use the value provided for REST API "username" parameter. -- **PATRONI\_CTL\_PASSWORD**: (optional) Basic-auth password for accessing protected REST API endpoints. If not provided patronictl will use the value provided for REST API "password" parameter. +- **PATRONI\_CTL\_USERNAME**: (optional) Basic-auth username for accessing protected REST API endpoints. If not provided :ref:`patronictl` will use the value provided for REST API "username" parameter. +- **PATRONI\_CTL\_PASSWORD**: (optional) Basic-auth password for accessing protected REST API endpoints. If not provided :ref:`patronictl` will use the value provided for REST API "password" parameter. - **PATRONI\_CTL\_INSECURE**: (optional) Allow connections to REST API without verifying SSL certs. -- **PATRONI\_CTL\_CACERT**: (optional) Specifies the file with the CA_BUNDLE file or directory with certificates of trusted CAs to use while verifying REST API SSL certs. If not provided patronictl will use the value provided for REST API "cafile" parameter. +- **PATRONI\_CTL\_CACERT**: (optional) Specifies the file with the CA_BUNDLE file or directory with certificates of trusted CAs to use while verifying REST API SSL certs. If not provided :ref:`patronictl` will use the value provided for REST API "cafile" parameter. - **PATRONI\_CTL\_CERTFILE**: (optional) Specifies the file with the client certificate in the PEM format. - **PATRONI\_CTL\_KEYFILE**: (optional) Specifies the file with the client secret key in the PEM format. - **PATRONI\_CTL\_KEYFILE\_PASSWORD**: (optional) Specifies a password for decrypting the client keyfile. diff --git a/docs/README.rst b/docs/README.rst index daba743d..61c428e2 100644 --- a/docs/README.rst +++ b/docs/README.rst @@ -25,82 +25,7 @@ We report new releases information :ref:`here `. Technical Requirements/Installation ----------------------------------- -**Pre-requirements for Mac OS** - -To install requirements on a Mac, run the following: - -:: - - brew install postgresql etcd haproxy libyaml python - -.. _psycopg2_install_options: - -**Psycopg** - -Starting from `psycopg2-2.8 `__ the binary version of psycopg2 will no longer be installed by default. Installing it from the source code requires C compiler and postgres+python dev packages. -Since in the python world it is not possible to specify dependency as ``psycopg2 OR psycopg2-binary`` you will have to decide how to install it. - -There are a few options available: - -1. Use the package manager from your distro - -:: - - sudo apt-get install python3-psycopg2 # install psycopg2 module on Debian/Ubuntu - sudo yum install python3-psycopg2 # install psycopg2 on RedHat/Fedora/CentOS - -2. Install psycopg2 from the binary package - -:: - - pip install psycopg2-binary - -3. Install psycopg2 from source - -:: - - pip install psycopg2>=2.5.4 - -4. Use psycopg 3.0 instead of psycopg2 - -:: - - pip install psycopg[binary]>=3.0.0 - -**General installation for pip** - -Patroni can be installed with pip: - -:: - - pip install patroni[dependencies] - -where dependencies can be either empty, or consist of one or more of the following: - -etcd or etcd3 - `python-etcd` module in order to use Etcd as Distributed Configuration Store (DCS) -consul - `python-consul` module in order to use Consul as DCS -zookeeper - `kazoo` module in order to use Zookeeper as DCS -exhibitor - `kazoo` module in order to use Exhibitor as DCS (same dependencies as for Zookeeper) -kubernetes - `kubernetes` module in order to use Kubernetes as DCS in Patroni -raft - `pysyncobj` module in order to use python Raft implementation as DCS -aws - `boto3` in order to use AWS callbacks - -For example, the command in order to install Patroni together with dependencies for Etcd as a DCS and AWS callbacks is: - -:: - - pip install patroni[etcd,aws] - -Note that external tools to call in the replica creation or custom bootstrap scripts (i.e. WAL-E) should be installed -independently of Patroni. - +Go :ref:`here ` for guidance on installing and upgrading Patroni on various platforms. .. _running_configuring: diff --git a/docs/citus.rst b/docs/citus.rst index 030cf93e..62cbdd1b 100644 --- a/docs/citus.rst +++ b/docs/citus.rst @@ -38,14 +38,18 @@ After that you just need to start Patroni and it will handle the rest: 2. If ``max_prepared_transactions`` isn't explicitly set in the global :ref:`dynamic configuration ` Patroni will automatically set it to ``2*max_connections``. -3. The ``citus.database`` will be automatically created followed by ``CREATE EXTENSION citus``. -4. Current superuser :ref:`credentials ` will be added to the ``pg_dist_authinfo`` +3. The ``citus.local_hostname`` GUC value will be adjusted from ``localhost`` to the + value that Patroni is using in order to connect to the local PostgreSQL + instance. The value sometimes should be different from the ``localhost`` + because PostgreSQL might be not listening on it. +4. The ``citus.database`` will be automatically created followed by ``CREATE EXTENSION citus``. +5. Current superuser :ref:`credentials ` will be added to the ``pg_dist_authinfo`` table to allow cross-node communication. Don't forget to update them if later you decide to change superuser username/password/sslcert/sslkey! -5. The coordinator primary node will automatically discover worker primary +6. The coordinator primary node will automatically discover worker primary nodes and add them to the ``pg_dist_node`` table using the ``citus_add_node()`` function. -6. Patroni will also maintain ``pg_dist_node`` in case failover/switchover +7. Patroni will also maintain ``pg_dist_node`` in case failover/switchover on the coordinator or worker clusters occurs. patronictl @@ -57,7 +61,7 @@ clusters that are just logically groupped together using the PostgreSQL. Therefore in most cases it is not possible to manage them as a single entity. -It results in two major differences in ``patronictl`` behaviour when +It results in two major differences in :ref:`patronictl` behaviour when ``patroni.yaml`` has the ``citus`` section comparing with the usual: 1. The ``list`` and the ``topology`` by default output all members of the Citus @@ -65,12 +69,12 @@ It results in two major differences in ``patronictl`` behaviour when which Citus group they belong to. 2. For all ``patronictl`` commands the new option is introduced, named ``--group``. For some commands the default value for the group might be - taken from the ``patroni.yaml``. For example, ``patronictl pause`` will + taken from the ``patroni.yaml``. For example, :ref:`patronictl_pause` will enable the maintenance mode by default for the ``group`` that is set in the - ``citus`` section, but for example for ``patronictl switchover`` or - ``patronictl remove`` the group must be explicitly specified. + ``citus`` section, but for example for :ref:`patronictl_switchover` or + :ref:`patronictl_remove` the group must be explicitly specified. -An example of ``patronictl list`` output for the Citus cluster:: +An example of :ref:`patronictl_list` output for the Citus cluster:: postgres@coord1:~$ patronictl list demo + Citus cluster: demo ----------+--------------+---------+----+-----------+ @@ -115,7 +119,7 @@ the coordinator for the shards hosted on a worker node. The switchover then happens while the traffic is kept on the coordinator, and resumes as soon as a new primary worker node is ready to accept read-write queries. -An example of ``patronictl switchover`` on the worker cluster:: +An example of :ref:`patronictl_switchover` on the worker cluster:: postgres@coord1:~$ patronictl switchover demo + Citus cluster: demo ----------+--------------+---------+----+-----------+ @@ -343,7 +347,7 @@ Citus upgrades and PostgreSQL major upgrades First, please read about upgrading Citus version in the `documentation`__. There is one minor change in the process. When executing upgrade, you have to -use ``patronictl restart`` instead of ``systemctl restart`` to restart +use :ref:`patronictl_restart` instead of ``systemctl restart`` to restart PostgreSQL. __ https://docs.citusdata.com/en/latest/admin_guide/upgrading_citus.html diff --git a/docs/conf.py b/docs/conf.py index f94b9660..950fbc91 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -112,10 +112,10 @@ todo_include_todos = True # a list of builtin themes. # +html_theme = 'sphinx_rtd_theme' on_rtd = os.environ.get('READTHEDOCS', None) == 'True' if not on_rtd: # only import and set the theme if we're building docs locally import sphinx_rtd_theme - html_theme = 'sphinx_rtd_theme' html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] # Theme options are theme-specific and customize the look and feel of a theme diff --git a/docs/contributing_guidelines.rst b/docs/contributing_guidelines.rst index 48710868..48d2b77a 100644 --- a/docs/contributing_guidelines.rst +++ b/docs/contributing_guidelines.rst @@ -3,21 +3,29 @@ Contributing guidelines ======================= -Wanna contribute to Patroni? Yay - here is how! +.. _chatting: Chatting -------- -Just want to chat with other Patroni users? Looking for interactive troubleshooting help? Join us on channel `#patroni `__ in the `PostgreSQL Slack `__. +If you have a question, looking for an interactive troubleshooting help or want to chat with other Patroni users, join us on channel `#patroni `__ in the `PostgreSQL Slack `__. + +.. _reporting_bugs: + +Reporting bugs +-------------- + +Before reporting a bug please make sure to **reproduce it with the latest Patroni version**! +Also please double check if the issue already exists in our `Issues Tracker `__. Running tests ------------- Requirements for running behave tests: -1. PostgreSQL packages need to be installed. -2. PostgreSQL binaries must be available in your `PATH`. You may need to add them to the path with something like `PATH=/usr/lib/postgresql/11/bin:$PATH python -m behave`. -3. If you'd like to test with external DCSs (e.g., Etcd, Consul, and Zookeeper) you'll need the packages installed and respective services running and accepting unencrypted/unprotected connections on localhost and default port. In the case of Etcd or Consul, the behave test suite could start them up if binaries are available in the `PATH`. +#. PostgreSQL packages including `contrib `__ modules need to be installed. +#. PostgreSQL binaries must be available in your `PATH`. You may need to add them to the path with something like `PATH=/usr/lib/postgresql/11/bin:$PATH python -m behave`. +#. If you'd like to test with external DCSs (e.g., Etcd, Consul, and Zookeeper) you'll need the packages installed and respective services running and accepting unencrypted/unprotected connections on localhost and default port. In the case of Etcd or Consul, the behave test suite could start them up if binaries are available in the `PATH`. Install dependencies: @@ -39,6 +47,13 @@ After you have all dependencies installed, you can run the various test suites: # Run the pytest suite in tests/: python setup.py test + # Moreover, you may want to run tests in different scopes for debugging purposes, + # the -s option include print output during test execution. + # Tests in pytest typically follow the pattern: FILEPATH::CLASSNAME::TESTNAME. + pytest -s tests/test_api.py + pytest -s tests/test_api.py::TestRestApiHandler + pytest -s tests/test_api.py::TestRestApiHandler::test_do_GET + # Run the behave (https://behave.readthedocs.io/en/latest/) test suite in features/; # modify DCS as desired (raft has no dependencies so is the easiest to start with): DCS=raft python -m behave @@ -143,12 +158,12 @@ If you want to disable this facility set the env var `OPEN_CMD` to the `:` no-op Behave tests ^^^^^^^^^^^^ -Behave tests with `-m behave` will build docker images based on PG_MAJOR version 11 through 15 and then run all +Behave tests with `-m behave` will build docker images based on PG_MAJOR version 11 through 16 and then run all behave tests. This can take quite a long time to run so you might want to limit the scope to a select version of Postgres or to a specific feature set or steps. To specify the version of postgres include the full name of the dependent image build env that you want and then the -behave env name. For instance if you want Postgres 15 use: +behave env name. For instance if you want Postgres 14 use: .. code-block:: bash @@ -163,19 +178,12 @@ the watchdog behave feature test scenario with all versions of Postgres. Of course you can combine the two. -Reporting issues ----------------- - -If you have a question about patroni or have a problem using it, please read the :ref:`README ` before filing an issue. -Also double check with the current issues on our `Issues Tracker `__. - Contributing a pull request --------------------------- -1) Submit a comment to the relevant issue or create a new issue describing your proposed change. -2) Do a fork, develop and test your code changes. -3) Include documentation -4) Submit a pull request. +#. Fork the repository, develop and test your code changes. +#. Reflect changes in the user documentation. +#. Submit a pull request with a clear description of the changes objective. Link an existing issue if necessary. You'll get feedback about your pull request as soon as possible. diff --git a/docs/dcs_failsafe_mode.rst b/docs/dcs_failsafe_mode.rst index e4eb6061..e6ce363e 100644 --- a/docs/dcs_failsafe_mode.rst +++ b/docs/dcs_failsafe_mode.rst @@ -60,4 +60,4 @@ F.A.Q. - How to enable the Failsafe Mode? - Before enabling the ``failsafe_mode`` please make sure that Patroni version on all members is up-to-date. After that, you can use either the ``PATCH /config`` :ref:`REST API ` or ``patronictl edit-config -s failsafe_mode=true`` + Before enabling the ``failsafe_mode`` please make sure that Patroni version on all members is up-to-date. After that, you can use either the ``PATCH /config`` :ref:`REST API ` or :ref:`patronictl edit-config -s failsafe_mode=true ` diff --git a/docs/dynamic_configuration.rst b/docs/dynamic_configuration.rst index f5bb4ee5..f38a8b39 100644 --- a/docs/dynamic_configuration.rst +++ b/docs/dynamic_configuration.rst @@ -6,11 +6,20 @@ Dynamic Configuration Settings Dynamic configuration is stored in the DCS (Distributed Configuration Store) and applied on all cluster nodes. -In order to change the dynamic configuration you can use either ``patronictl edit-config`` tool or Patroni :ref:`REST API `. +In order to change the dynamic configuration you can use either :ref:`patronictl_edit_config` tool or Patroni :ref:`REST API `. + +- **loop\_wait**: the number of seconds the loop will sleep. Default value: 10, minimum possible value: 1 +- **ttl**: the TTL to acquire the leader lock (in seconds). Think of it as the length of time before initiation of the automatic failover process. Default value: 30, minimum possible value: 20 +- **retry\_timeout**: timeout for DCS and PostgreSQL operation retries (in seconds). DCS or network issues shorter than this will not cause Patroni to demote the leader. Default value: 10, minimum possible value: 3 + +.. warning:: + when changing values of **loop_wait**, **retry_timeout**, or **ttl** you have to follow the rule: + + .. code-block:: python + + loop_wait + 2 * retry_timeout <= ttl + -- **loop\_wait**: the number of seconds the loop will sleep. Default value: 10 -- **ttl**: the TTL to acquire the leader lock (in seconds). Think of it as the length of time before initiation of the automatic failover process. Default value: 30 -- **retry\_timeout**: timeout for DCS and PostgreSQL operation retries (in seconds). DCS or network issues shorter than this will not cause Patroni to demote the leader. Default value: 10 - **maximum\_lag\_on\_failover**: the maximum bytes a follower may lag to be able to participate in leader election. - **maximum\_lag\_on\_syncnode**: the maximum bytes a synchronous follower may lag before it is considered as an unhealthy candidate and swapped by healthy asynchronous follower. Patroni utilize the max replica lsn if there is more than one follower, otherwise it will use leader's current wal lsn. Default is -1, Patroni will not take action to swap synchronous unhealthy follower when the value is set to 0 or below. Please set the value high enough so Patroni won't swap synchrounous follower fequently during high transaction volume. - **max\_timelines\_history**: maximum number of timeline history items kept in DCS. Default value: 0. When set to 0, it keeps the full history in DCS. @@ -46,9 +55,9 @@ In order to change the dynamic configuration you can use either ``patronictl edi - **archive\_cleanup\_command**: cleanup command for standby leader - **recovery\_min\_apply\_delay**: how long to wait before actually apply WAL records on a standby leader -- **slots**: define permanent replication slots. These slots will be preserved during switchover/failover. Permanent slots that don't exist will be created by Patroni. The physical slots are maintained only in the current primary. The logical slots are copied from the primary to a standby with restart, and after that their position advanced every **loop_wait** seconds (if necessary). Copying logical slot files performed via ``libpq`` connection and using either rewind or superuser credentials (see **postgresql.authentication** section). There is always a chance that the logical slot position on the replica is a bit behind the former primary, therefore application should be prepared that some messages could be received the second time after the failover. The easiest way of doing so - tracking ``confirmed_flush_lsn``. Enabling permanent logical replication slots requires **postgresql.use_slots** to be set and will also automatically enable the ``hot_standby_feedback``. Since the failover of logical replication slots is unsafe on PostgreSQL 9.6 and older and PostgreSQL version 10 is missing some important functions, the feature only works with PostgreSQL 11+. +- **slots**: define permanent replication slots. These slots will be preserved during switchover/failover. Permanent slots that don't exist will be created by Patroni. With PostgreSQL 11 onwards permanent physical slots are created on all nodes and their position is advanced every **loop_wait** seconds. For PostgreSQL versions older than 11 permanent physical replication slots are maintained only on the current primary. The logical slots are copied from the primary to a standby with restart, and after that their position advanced every **loop_wait** seconds (if necessary). Copying logical slot files performed via ``libpq`` connection and using either rewind or superuser credentials (see **postgresql.authentication** section). There is always a chance that the logical slot position on the replica is a bit behind the former primary, therefore application should be prepared that some messages could be received the second time after the failover. The easiest way of doing so - tracking ``confirmed_flush_lsn``. Enabling permanent replication slots requires **postgresql.use_slots** to be set to ``true``. If there are permanent logical replication slots defined Patroni will automatically enable the ``hot_standby_feedback``. Since the failover of logical replication slots is unsafe on PostgreSQL 9.6 and older and PostgreSQL version 10 is missing some important functions, the feature only works with PostgreSQL 11+. - - **my\_slot\_name**: the name of the permanent replication slot. If the permanent slot name matches with the name of the current leader it will not be created. Please note that Patroni does not make checks for permanent slot names added to this configuration matching those that Patroni creates automatically for members. If those names are added, Patroni will ensure that any slots that were created are not removed even if the member becomes unresponsive, situation which would normally result in the slot's removal by Patroni. Although this can be useful in some situations, such as when importing existing members to a new Patroni cluster (see :ref:`Convert a Standalone to a Patroni Cluster ` for details), caution should be exercised by the operator that these clashes in names are not persisted in the DCS due to its effect on normal functioning of Patroni. + - **my\_slot\_name**: the name of the permanent replication slot. If the permanent slot name matches with the name of the current node it will not be created on this node. If you add a permanent physical replication slot which name matches the name of a Patroni member, Patroni will ensure that the slot that was created is not removed even if the corresponding member becomes unresponsive, situation which would normally result in the slot's removal by Patroni. Although this can be useful in some situations, such as when you want replication slots used by members to persist during temporary failures or when importing existing members to a new Patroni cluster (see :ref:`Convert a Standalone to a Patroni Cluster ` for details), caution should be exercised by the operator that these clashes in names are not persisted in the DCS, when the slot is no longer required, due to its effect on normal functioning of Patroni. - **type**: slot type. Could be ``physical`` or ``logical``. If the slot is logical, you have to additionally define ``database`` and ``plugin``. - **database**: the database name where logical slots should be created. @@ -80,4 +89,22 @@ Note: **slots** is a hashmap while **ignore_slots** is an array. For example: plugin: test_decoding - name: ignored_physical_slot_name type: physical - ... \ No newline at end of file + ... + +Note: if cluster topology is static (fixed number of nodes that never change their names) you can configure permanent physical replication slots with names corresponding to names of nodes to avoid recycling of WAL files while replica is temporary down: + +.. code:: YAML + + slots: + node_name1: + type: physical + node_name2: + type: physical + node_name3: + type: physical + ... + + +.. warning:: + Permanent replication slots are synchronized only from the ``primary``/``standby_leader`` to replica nodes. That means, applications are supposed to be using them only from the leader node. Using them on replica nodes will cause indefinite growth of ``pg_wal`` on all other nodes in the cluster. + An exception to that rule are permanent physical slots that match the Patroni member names, if you happen to configure any. Those will be synchronized among all nodes as they are used for replication among them. diff --git a/docs/existing_data.rst b/docs/existing_data.rst index cb07bfa9..2aa3ad26 100644 --- a/docs/existing_data.rst +++ b/docs/existing_data.rst @@ -36,18 +36,18 @@ You can find below an overview of steps for converting an existing Postgres clus #. If you are running Postgres through systemd, then disable the Postgres systemd unit. This is performed as Patroni manages starting and stopping the Postgres daemon. - #. Create a YAML configuration file for Patroni. + #. Create a YAML configuration file for Patroni. You can use :ref:`Patroni configuration generation and validation tooling ` for that. * **Note (specific for the primary node):** If you have replication slots being used for replication between cluster members, then it is recommended that you enable ``use_slots`` and configure the existing replication slots as permanent via the ``slots`` configuration item. Be aware that Patroni automatically creates replication slots for replication between members, and drops replication slots that it does not recognize, when ``use_slots`` is enabled. The idea of using permanent slots here is to allow your existing slots to persist while the migration to Patroni is in progress. See :ref:`YAML Configuration Settings ` for details. #. Start Patroni using the ``patroni`` systemd service unit. It automatically detects that Postgres is already running and starts monitoring the instance. -#. Hand over Postgres "start up procedure" to Patroni. In order to do that you need to restart the cluster members through ``patronictl restart cluster-name member-name`` command. For minimal downtime you might want to split this step into: +#. Hand over Postgres "start up procedure" to Patroni. In order to do that you need to restart the cluster members through :ref:`patronictl restart cluster-name member-name ` command. For minimal downtime you might want to split this step into: #. Immediate restart of the standby nodes. #. Scheduled restart of the primary node within a maintenance window. -#. If you configured permanent slots in step ``1.2.``, then you should remove them from ``slots`` configuration through ``patronictl edit-config cluster-name member-name`` command once the ``restart_lsn`` of the slots created by Patroni is able to catch up with the ``restart_lsn`` of the original slots for the corresponding members. By removing the slots from ``slots`` configuration you will allow Patroni to drop the original slots from your cluster once they are not needed anymore. You can find below an example query to check the ``restart_lsn`` of a couple slots, so you can compare them: +#. If you configured permanent slots in step ``1.2.``, then you should remove them from ``slots`` configuration through :ref:`patronictl edit-config cluster-name member-name ` command once the ``restart_lsn`` of the slots created by Patroni is able to catch up with the ``restart_lsn`` of the original slots for the corresponding members. By removing the slots from ``slots`` configuration you will allow Patroni to drop the original slots from your cluster once they are not needed anymore. You can find below an example query to check the ``restart_lsn`` of a couple slots, so you can compare them: .. code-block:: sql @@ -73,7 +73,7 @@ The only possible way to do a major upgrade currently is: #. Stop Patroni #. Upgrade PostgreSQL binaries and perform `pg_upgrade `_ on the primary node #. Update patroni.yml -#. Remove the initialize key from DCS or wipe complete cluster state from DCS. The second one could be achieved by running ``patronictl remove ``. It is necessary because pg_upgrade runs initdb which actually creates a new database with a new PostgreSQL system identifier. +#. Remove the initialize key from DCS or wipe complete cluster state from DCS. The second one could be achieved by running :ref:`patronictl remove cluster-name ` . It is necessary because pg_upgrade runs initdb which actually creates a new database with a new PostgreSQL system identifier. #. If you wiped the cluster state in the previous step, you may wish to copy patroni.dynamic.json from old data dir to the new one. It will help you to retain some PostgreSQL parameters you had set before. #. Start Patroni on the primary node. #. Upgrade PostgreSQL binaries, update patroni.yml and wipe the data_dir on standby nodes. diff --git a/docs/faq.rst b/docs/faq.rst new file mode 100644 index 00000000..c56bf0ce --- /dev/null +++ b/docs/faq.rst @@ -0,0 +1,329 @@ +.. _faq: + +FAQ +=== + +In this section you will find answers for the most frequently asked questions about Patroni. +Each sub-section attempts to focus on different kinds of questions. + +We hope that this helps you to clarify most of your questions. +If you still have further concerns or find yourself facing an unexpected issue, please refer to :ref:`chatting` and :ref:`reporting_bugs` for instructions on how to get help or report issues. + +Comparison with other HA solutions +---------------------------------- + +Why does Patroni require a separate cluster of DCS nodes while other solutions like ``repmgr`` do not? + There are different ways of implementing HA solutions, each of them with their pros and cons. + + Software like ``repmgr`` performs communication among the nodes to decide when actions should be taken. + + Patroni on the other hand relies on the state stored in the DCS. The DCS acts as a source of truth for Patroni to decide what it should do. + + While having a separate DCS cluster can make you bloat your architecture, this approach also makes it less likely for split-brain scenarios to happen in your Postgres cluster. + +What is the difference between Patroni and other HA solutions in regards to Postgres management? + Patroni does not just manage the high availability of the Postgres cluster but also manages Postgres itself. + + If Postgres nodes do not exist yet, it takes care of bootstrapping the primary and the standby nodes, and also manages Postgres configuration of the nodes. If the Postgres nodes already exist, Patroni will take over management of the cluster. + + Besides the above, Patroni also has self-healing capabilities. In other words, if a primary node fails, Patroni will not only fail over to a replica, but also attempt to rejoin the former primary as a replica of the new primary. Similarly, if a replica fails, Patroni will attempt to rejoin that replica. + + That is way we call Patroni as a "template for HA solutions". It goes further than just managing physical replication: it manages Postgres as a whole. + +DCS +--- + +Can I use the same ``etcd`` cluster to store data from two or more Patroni clusters? + Yes, you can! + + Information about a Patroni cluster is stored in the DCS under a path prefixed with the ``namespace`` and ``scope`` Patroni settings. + + As long as you do not have conflicting namespace and scope across different Patroni clusters, you should be able to use the same DCS cluster to store information from multiple Patroni clusters. + +What occurs if I attempt to use the same combination of ``namespace`` and ``scope`` for different Patroni clusters that point to the same DCS cluster? + The second Patroni cluster that attempts to use the same ``namespace`` and ``scope`` will not be able to manage Postgres because it will find information related with that same combination in the DCS, but with an incompatible Postgres system identifier. + The mismatch on the system identifier causes Patroni to abort the management of the second cluster, as it assumes that refers to a different cluster and that the user has misconfigured Patroni. + + Make sure to use different ``namespace`` / ``scope`` when dealing with different Patroni clusters that share the same DCS cluster. + +What occurs if I lose my DCS cluster? + The DCS is used to store basically status and the dynamic configuration of the Patroni cluster. + + They very first consequence is that all the Patroni clusters that rely on that DCS will go to read-only mode -- unless :ref:`dcs_failsafe_mode` is enabled. + +What should I do if I lose my DCS cluster? + There are three possible outcomes upon losing your DCS cluster: + + 1. The DCS cluster is fully recovered: this requires no action from the Patroni side. Once the DCS cluster is recovered, Patroni should be able to recover too; + 2. The DCS cluster is re-created in place, and the endpoints remain the same. No changes are required on the Patroni side; + 3. A new DCS cluster is created with different endpoints. You will need to update the DCS endpoints in the Patroni configuration of each Patroni node. + + If you face scenario ``2.`` or ``3.`` Patroni will take care of creating the status information again based on the current status of the cluster, and recreate the dynamic configuration on the DCS based on a backup file named ``patroni.dynamic.json`` which is stored inside the Postgres data directory of each member of the Patroni cluster. + +What occurs if I lose majority in my DCS cluster? + The DCS will become unresponsive, which will cause Patroni to demote the current read/write Postgres node. + + Remember: Patroni relies on the state of the DCS to take actions on the cluster. + + You can use the :ref:`dcs_failsafe_mode` to alleviate that situation. + +patronictl +---------- + +Do I need to run :ref:`patronictl` in the Patroni host? + No, you do not need to do that. + + Running :ref:`patronictl` in the Patroni host is handy if you have access to the Patroni host because you can use the very same configuration file from the ``patroni`` agent for the :ref:`patronictl` application. + + However, :ref:`patronictl` is basically a client and it can be executed from remote machines. You just need to provide it with enough configuration so it can reach the DCS and the REST API of the Patroni member(s). + +Why did the information from one of my Patroni members disappear from the output of :ref:`patronictl_list` command? + Information shown by :ref:`patronictl_list` is based on the contents of the DCS. + + If information about a member disappeared from the DCS it is very likely that the Patroni agent on that node is not running anymore, or it is not able to communicate with the DCS. + + As the member is not able to update the information, the information eventually expires from the DCS, and consequently the member is not shown anymore in the output of :ref:`patronictl_list`. + +Why is the information about one of my Patroni members not up-to-date in the output of :ref:`patronictl_list` command? + Information shown by :ref:`patronictl_list` is based on the contents of the DCS. + + By default, that information is updated by Patroni roughly every ``loop_wait`` seconds. + In other words, even if everything is normally functional you may still see a "delay" of up to ``loop_wait`` seconds in the information stored in the DCS. + + Be aware that that is not a rule, though. Some operations performed by Patroni cause it to immediately update the DCS information. + +Configuration +------------- + +What is the difference between dynamic configuration and local configuration? + Dynamic configuration (or global configuration) is the configuration stored in the DCS, and which is applied to all members of the Patroni cluster. + This is primarily where you should store your configuration. + + Settings that are specific to a node, or settings that you would like to overwrite the global configuration with, you should set only on the desired Patroni member as a local configuration. + That local configuration can be specified either through the configuration file or through environment variables. + + See more in :ref:`patroni_configuration`. + +What are the types of configuration in Patroni, and what is the precedence? + The types are: + + * Dynamic configuration: applied to all members; + * Local configuration: applied to the local member, overrides dynamic configuration; + * Environment configuration: applied to the local member, overrides both dynamic and local configuration. + + **Note:** some Postgres GUCs can only be set globally, i.e., through dynamic configuration. Besides that, there are GUCs which Patroni enforces a hard-coded value. + + See more in :ref:`patroni_configuration`. + +Is there any facility to help me create my Patroni configuration file? + Yes, there is. + + You can use ``patroni --generate-sample-config`` or ``patroni --generate-config`` commands to generate a sample Patroni configuration or a Patroni configuration based on an existing Postgres instance, respectively. + + Please refer to :ref:`generate_sample_config` and :ref:`generate_config` for more details. + +I changed my parameters under ``bootstrap.dcs`` configuration but Patroni is not applying the changes to the cluster members. What is wrong? + The values configured under ``bootstrap.dcs`` are only used when bootstrapping a fresh cluster. Those values will be written to the DCS during the bootstrap. + + After the bootstrap phase finishes, you will only be able to change the dynamic configuration through the DCS. + + Refer to the next question for more details. + +How can I change my dynamic configuration? + You need to change the configuration in the DCS. That is accomplished either through: + + * :ref:`patronictl_edit_config`; or + * A ``PATCH`` request to :ref:`config_endpoint`. + +How can I change my local configuration? + You need to change the configuration file of the corresponding Patroni member and signal the Patroni agent with ``SIHGUP``. You can do that using either of these approaches: + + * Send a ``POST`` request to the REST API :ref:`reload_endpoint`; or + * Run :ref:`patronictl_reload`; or + * Locally signal the Patroni process with ``SIGHUP``: + + * If you started Patroni through systemd, you can use the command ``systemctl reload PATRONI_UNIT.service``, ``PATRONI_UNIT`` being the name of the Patroni service; or + * If you started Patroni through other means, you will need to identify the ``patroni`` process and run ``kill -s HUP PID``, ``PID`` being the process ID of the ``patroni`` process. + + **Note:** there are cases where a reload through the :ref:`patronictl_reload` may not work: + + * Expired REST API certificates: you can mitigate that by using the ``-k`` option of the :ref:`patronictl`; + * Wrong credentials: for example when changing ``restapi`` or ``ctl`` credentials in the configuration file, and using that same configuration file for Patroni and :ref:`patronictl`. + +How can I change my environment configuration? + The environment configuration is only read by Patroni during startup. + + With that in mind, if you change the environment configuration you will need to restart the corresponding Patroni agent. + + Take care to not cause a failover in the cluster! You might be interested in checking :ref:`patronictl_pause`. + +What occurs if I change a Postgres GUC that requires a reload? + When you change the dynamic or the local configuration as explained in the previous questions, Patroni will take care of reloading the Postgres configuration for you. + +What occurs if I change a Postgres GUC that requires a restart? + Patroni will mark the affected members with a flag of ``pending restart``. + + It is up to you to determine when and how to restart the members. That can be accomplished either through: + + * :ref:`patronictl_restart`; or + * A ``POST`` request to :ref:`restart_endpoint`. + + **Note:** some Postgres GUCs require a special management in terms of the order for restarting the Postgres nodes. Refer to :ref:`shared_memory_gucs` for more details. + +What is the difference between ``etcd`` and ``etcd3`` in Patroni configuration? + ``etcd`` uses the API version 2 of ``etcd``, while ``etcd3`` uses the API version 3 of ``etcd``. + + Be aware that information stored by the API version 2 is not manageable by API version 3 and vice-versa. + + We recommend that you configure ``etcd3`` instead of ``etcd`` because: + + * API version 2 is disabled by default from Etcd v3.4 onward; + * API version 2 will be completely removed on Etcd v3.6. + +I have ``use_slots`` enabled in my Patroni configuration, but when a cluster member goes offline for some time, the replication slot used by that member is dropped on the upstream node. What can I do to avoid that issue? + You can configure a permanent physical replication slot for the members. + + Since Patroni ``3.2.0`` it is now possible to have member slots as permanent slots managed by Patroni. + + Patroni will create the permanent physical slots on all nodes, and make sure to not remove the slots, as well as to advance the slots' LSN on all nodes according to the LSN that has been consumed by the member. + + Later, if you decide to remove the corresponding member, it's **your responsability** to adjust the permanent slots configuration, otherwise Patroni will keep the slots around forever. + + **Note:** on Patroni older than ``3.2.0`` you could still have member slots configured as permanent physical slots, however they would be managed only on the current leader. That is, in case of failover/switchover these slots would be created on the new leader, but that wouldn't guarantee that it had all WAL segments for the absent node. + + **Note:** even with Patroni ``3.2.0`` there might be a small race condition. In the very beginning, when the slot is created on the replica it could be ahead of the same slot on the leader and in case if nobody is consuming the slot there is still a chance that some files could be missing after failover. With that in mind, it is recommended that you configure continuous archiving, which makes it possible to restore required WALs or perform PITR. + +What is the difference between ``loop_wait``, ``retry_timeout`` and ``ttl``? + Patroni performs what we call a HA cycle from time to time. On each HA cycle it takes care of performing a series of checks on the cluster to determine its healthiness, and depending on the status it may take actions, like failing over to a standby. + + ``loop_wait`` determines for how long, in seconds, Patroni should sleep before performing a new cycle of HA checks. + + ``retry_timeout`` sets the timeout for retry operations on the DCS and on Postgres. For example: if the DCS is unresponsive for more than ``retry_timeout`` seconds, Patroni might demote the primary node as a security action. + + ``ttl`` sets the lease time on the ``leader`` lock in the DCS. If the current leader of the cluster is not able to renew the lease during its HA cycles for longer than ``ttl``, then the lease will expire and that will trigger a ``leader race`` in the cluster. + + **Note:** when modifying these settings, please keep in mind that Patroni enforces the rule and minimal values described in :ref:`dynamic_configuration` section of the docs. + +Postgres management +------------------- + +Can I change Postgres GUCs directly in Postgres configuration? + You can, but you should avoid that. + + Postgres configuration is managed by Patroni, and attempts to edit the configuration files may end up being frustrated by Patroni as it may eventually overwrite them. + + There are a few options available to overcome the management performed by Patroni: + + * Change Postgres GUCs through ``$PGDATA/postgresql.base.conf``; or + * Define a ``postgresql.custom_conf`` which will be used instead of ``postgresql.base.conf`` so you can manage that externally; or + * Change GUCs using ``ALTER SYSTEM`` / ``ALTER DATABASE`` / ``ALTER USER``. + + You can find more information about that in the section :ref:`important_configuration_rules`. + + In any case we recommend that you manage all the Postgres configuration through Patroni. That will centralize the management and make it easier to debug Patroni when needed. + +Can I restart Postgres nodes directly? + No, you should **not** attempt to manage Postgres directly! + + Any attempt of bouncing the Postgres server without Patroni can lead your cluster to face failovers. + + If you need to manage the Postgres server, do that through the ways exposed by Patroni. + +Is Patroni able to take over management of an already existing Postgres cluster? + Yes, it can! + + Please refer to :ref:`existing_data` for detailed instructions. + +How does Patroni manage Postgres? + Patroni takes care of bringing Postgres up and down by running the Postgres binaries, like ``pg_ctl`` and ``postgres``. + + With that in mind you **MUST** disable any other sources that could manage the Postgres clusters, like the systemd units, e.g. ``postgresql.service``. Only Patroni should be able to start, stop and promote Postgres instances in the cluster. Not doing so may result in split-brain scenarios. For example: if the node running as a primary failed and the unit ``postgresql.service`` is enabled, it may bring Postgres back up and cause a split-brain. + +Concepts and requirements +------------------------- + +Which are the applications that make part of Patroni? + Patroni basically ships a couple applications: + + * ``patroni``: This is the Patroni agent, which takes care of managing a Postgres node; + * ``patronictl``: This is a command-line utility used to interact with a Patroni cluster (perform switchovers, restarts, changes in the configuration, etc.). Please find more information in :ref:`patronictl`. + +What is a ``standby cluster`` in Patroni? + It is a cluster that does not have any primary Postgres node running, i.e., there is no read/write member in the cluster. + + These kinds of clusters exist to replicate data from another cluster and are usually useful when you want to replicate data across data centers. + + There will be a leader in the cluster which will be a standby in charge of replicating changes from a remote Postgres node. + Then, there will be a set of standbys configured with cascading replication from such leader member. + + **Note:** the standby cluster doesn't know anything about the source cluster which it is replicating from -- it can even use ``restore_command`` instead of WAL streaming, and may use an absolutely independent DCS cluster. + + Refer to :ref:`standby_cluster` for more details. + +What is a ``leader`` in Patroni? + A ``leader`` in Patroni is like a coordinator of the cluster. + + In a regular Patroni cluster, the ``leader`` will be the read/write node. + + In a standby Patroni cluster, the ``leader`` (AKA ``standby leader``) will be in charge of replicating from a remote Postgres node, and cascading those changes to the other members of the standby cluster. + +Does Patroni require a minimum number of Postgres nodes in the cluster? + No, you can run Patroni with any number of Postgres nodes. + + Remember: Patroni is decoupled from the DCS. + +What does ``pause`` mean in Patroni? + Pause is an operation exposed by Patroni so the user can ask Patroni to step back in regards to Postgres management. + + That is mainly useful when you want to perform maintenance on the cluster, and would like to avoid that Patroni takes decisions related with HA, like failing over to a standby when you stop the primary. + + You can find more information about that in :ref:`pause`. + +Automatic failover +------------------ + +How does the automatic failover mechanism of Patroni work? + Patroni automatic failover is based on what we call ``leader race``. + + Patroni stores the cluster's status in the DCS, among them a ``leader`` lock which holds the name of the Patroni member which is the current ``leader`` of the cluster. + + That ``leader`` lock has a time-to-live associated with it. If the leader node fails to update the lease of the ``leader`` lock in time, the key will eventually expire from the DCS. + + When the ``leader`` lock expires, it triggers what Patroni calls a ``leader race``: all nodes start performing checks to determine if they are the best candidates for taking over the ``leader`` role. + Some of these checks include calls to the REST API of all other Patroni members. + + All Patroni members that find themselves as the best candidate for taking over the ``leader`` lock will attempt to do so. + The first Patroni member that is able to take the ``leader`` lock will promote itself to a read/write node (or ``standby leader``), and the others will be configured to follow it. + +Can I temporarily disable automatic failover in the Patroni cluster? + Yes, you can! + + You can achieve that by temporarily pausing the cluster. + This is typically useful for performing maintenance. + + When you want to resume the automatic failover of the cluster, you just need to unpause it. + + You can find more information about that in :ref:`pause`. + +Bootstrapping and standbys creation +----------------------------------- + +How does Patroni create a primary Postgres node? What about a standby Postgres node? + By default Patroni will use ``initdb`` to bootstrap a fresh cluster, and ``pg_basebackup`` to create standby nodes from a copy of the ``leader`` member. + + You can customize that behavior by writing your custom bootstrap methods, and your custom replica creation methods. + + Custom methods are usually useful when you want to restore backups created by backup tools like pgBackRest or Barman, for example. + + For detailed information please refer to :ref:`custom_bootstrap` and :ref:`custom_replica_creation`. + +Monitoring +---------- + +How can I monitor my Patroni cluster? + Patroni exposes a couple handy endpoints in its :ref:`rest_api`: + + * ``/metrics``: exposes monitoring metrics in a format that can be consumed by Prometheus; + * ``/patroni``: exposes the status of the cluster in a JSON format. The information shown here is very similar to what is shown by the ``/metrics`` endpoint. + + You can use those endpoints to implement monitoring checks. diff --git a/docs/index.rst b/docs/index.rst index c7428a95..84d02db1 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -10,7 +10,7 @@ Patroni is a template for high availability (HA) PostgreSQL solutions using Pyth We call Patroni a "template" because it is far from being a one-size-fits-all or plug-and-play replication system. It will have its own caveats. Use wisely. There are many ways to run high availability with PostgreSQL; for a list, see the `PostgreSQL Documentation `__. -Currently supported PostgreSQL versions: 9.3 to 15. +Currently supported PostgreSQL versions: 9.3 to 16. **Note to Citus users**: Starting from 3.0 Patroni nicely integrates with the `Citus `__ database extension to Postgres. Please check the :ref:`Citus support page ` in the Patroni documentation for more info about how to use Patroni high availability together with a Citus distributed cluster. @@ -22,8 +22,10 @@ Currently supported PostgreSQL versions: 9.3 to 15. :caption: Contents: README + installation patroni_configuration rest_api + patronictl replica_bootstrap replication_modes watchdog @@ -34,6 +36,7 @@ Currently supported PostgreSQL versions: 9.3 to 15. existing_data security ha_multi_dc + faq releases CONTRIBUTING diff --git a/docs/installation.rst b/docs/installation.rst new file mode 100644 index 00000000..3c9dddfa --- /dev/null +++ b/docs/installation.rst @@ -0,0 +1,196 @@ +.. _installation: + +Installation +============ + +Pre-requirements for Mac OS +--------------------------- + +To install requirements on a Mac, run the following: + +.. code-block:: shell + + brew install postgresql etcd haproxy libyaml python + +.. _psycopg2_install_options: + +Psycopg +------- + +Starting from `psycopg2-2.8`_ the binary version of psycopg2 will no longer be installed by default. Installing it from +the source code requires C compiler and postgres+python dev packages. Since in the python world it is not possible to +specify dependency as ``psycopg2 OR psycopg2-binary`` you will have to decide how to install it. + +There are a few options available: + +1. Use the package manager from your distro + +.. code-block:: shell + + sudo apt-get install python3-psycopg2 # install psycopg2 module on Debian/Ubuntu + sudo yum install python3-psycopg2 # install psycopg2 on RedHat/Fedora/CentOS + +2. Specify one of `psycopg`, `psycopg2`, or `psycopg2-binary` in the :ref:`list of dependencies ` when installing Patroni with pip. + + +.. _extras: + +General installation for pip +---------------------------- + +Patroni can be installed with pip: + +.. code-block:: shell + + pip install patroni[dependencies] + +where ``dependencies`` can be either empty, or consist of one or more of the following: + +etcd or etcd3 + `python-etcd` module in order to use Etcd as Distributed Configuration Store (DCS) +consul + `python-consul` module in order to use Consul as DCS +zookeeper + `kazoo` module in order to use Zookeeper as DCS +exhibitor + `kazoo` module in order to use Exhibitor as DCS (same dependencies as for Zookeeper) +kubernetes + `kubernetes` module in order to use Kubernetes as DCS in Patroni +raft + `pysyncobj` module in order to use python Raft implementation as DCS +aws + `boto3` in order to use AWS callbacks +all + all of the above (except psycopg family) +psycopg + `psycopg[binary]>=3.0.0` module +psycopg2 + `psycopg2>=2.5.4` module +psycopg2-binary + `psycopg2-binary` module + +For example, the command in order to install Patroni together with psycopg3, dependencies for Etcd as a DCS, and AWS callbacks is: + +.. code-block:: shell + + pip install patroni[psycopg3,etcd3,aws] + +Note that external tools to call in the replica creation or custom bootstrap scripts (i.e. WAL-E) should be installed +independently of Patroni. + +.. _package_installation: + +Package installation on Linux +----------------------------- + +Patroni packages may be available for your operating system, produced by the Postgres community for: + +* RHEL, RockyLinux, AlmaLinux; +* Debian and Ubuntu; +* SUSE Enterprise Linux. + +You can also find packages for direct dependencies of Patroni, like python modules that might not be available in +the official operating system repositories. + +For more information see the `PGDG repository`_ documentation. + +If you are on a RedHat Enterprise Linux derivative operating system you may also require packages from EPEL, see +`EPEL repository`_ documentation. + +Once you have installed the PGDG repository for your OS you can install patroni. + +.. note:: + + Patroni packages are not maintained by the Patroni developers, but rather by the Postgres community. If you + require support please first try connecting on `Postgres slack`_. + +Installing on Debian derivatives +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +With PGDG repo installed, see :ref:`above `, install Patroni via apt run: + +.. code-block:: shell + + apt-get install patroni + +Installing on RedHat derivatives +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +With PGDG repo installed, see :ref:`above `, install patroni with an etcd DCS via dnf on RHEL 9 +(and derivatives) run: + +.. code-block:: shell + + dnf install patroni patroni-etcd + +You can install etcd from PGDG if your RedHat derivative distribution does not provide packages. On the nodes that will +host the DCS run: + +.. code-block:: shell + + dnf install 'dnf-command(config-manager)' + dnf config-manager --enable pgdg-rhel9-extras + dnf install etcd + +You can replace the version of RHEL with `8` in the repo to make `pgdg-rhel8-extras` if needed. The repo name is still +`pgdg-rhelN-extras` on RockyLinux, AlmaLinux, Oracle Linux, etc... + +Installing on SUSE Enterprise Linux +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +You might need to enable the SUSE PackageHub repositories for some dependencies. see `SUSE PackageHub`_ documentation. + +For SLES 15 with PGDG repo installed, see :ref:`above `, you can install patroni using: + +.. code-block:: shell + + zypper install patroni patroni-etcd + +With the SUSE PackageHub repo enabled you can also install etcd: + +.. code-block:: shell + + SUSEConnect -p PackageHub/15.5/x86_64 + zypper install etcd + +Upgrading +--------- + +Upgrading patroni is a very simple process, just update the software installation and restart the Patroni daemon on +each node in the cluster. + +However, restarting the Patroni daemon will result in a Postgres database restart. In some situations this may cause +a failover of the primary node in your cluster, therefore it is recommended to put the cluster into maintenance mode +until the Patroni daemon restart has been completed. + +To put the cluster in maintenance mode, run the following command on one of the patroni nodes: + +.. code-block:: shell + + patronictl pause --wait + +Then on each node in the cluster, perform the package upgrade required for your OS: + +.. code-block:: shell + + apt-get update && apt-get install patroni patroni-etcd + +Restart the patroni daemon process on each node: + +.. code-block:: shell + + systemctl restart patroni + +Then finally resume monitoring of Postgres with patroni to take it out of maintenance mode: + +.. code-block:: shell + + patronictl resume --wait + +The cluster will now be full operational with the new version of Patroni. + +.. _psycopg2-2.8: http://initd.org/psycopg/articles/2019/04/04/psycopg-28-released/ +.. _PGDG repository: https://www.postgresql.org/download/linux/ +.. _EPEL repository: https://docs.fedoraproject.org/en-US/epel/ +.. _SUSE PackageHub: https://packagehub.suse.com/how-to-use/ +.. _Postgres slack: http://pgtreats.info/slack-invite diff --git a/docs/patroni_configuration.rst b/docs/patroni_configuration.rst index fafbbbdb..06d71426 100644 --- a/docs/patroni_configuration.rst +++ b/docs/patroni_configuration.rst @@ -15,7 +15,7 @@ There are 3 types of Patroni configuration: - Global :ref:`dynamic configuration `. These options are stored in the DCS (Distributed Configuration Store) and applied on all cluster nodes. - Dynamic configuration can be set at any time using ``patronictl edit-config`` tool or Patroni :ref:`REST API `. + Dynamic configuration can be set at any time using :ref:`patronictl_edit_config` tool or Patroni :ref:`REST API `. If the options changed are not part of the startup configuration, they are applied asynchronously (upon the next wake up cycle) to every node, which gets subsequently reloaded. If the node requires a restart to apply the configuration (for `PostgreSQL parameters `__ with context postmaster, if their values @@ -24,12 +24,13 @@ There are 3 types of Patroni configuration: - Local :ref:`configuration file ` (patroni.yml). These options are defined in the configuration file and take precedence over dynamic configuration. - ``patroni.yml`` can be changed and reloaded at runtime (without restart of Patroni) by sending SIGHUP to the Patroni process, performing ``POST /reload`` REST-API request or executing ``patronictl reload``. Local configuration can be either a single YAML file or a directory. When it is a directory, all YAML files in that directory are loaded one by one in sorted order. In case a key is defined in multiple files, the occurrence in the last file takes precedence. + ``patroni.yml`` can be changed and reloaded at runtime (without restart of Patroni) by sending SIGHUP to the Patroni process, performing ``POST /reload`` REST-API request or executing :ref:`patronictl_reload`. Local configuration can be either a single YAML file or a directory. When it is a directory, all YAML files in that directory are loaded one by one in sorted order. In case a key is defined in multiple files, the occurrence in the last file takes precedence. - :ref:`Environment configuration `. It is possible to set/override some of the "Local" configuration parameters with environment variables. Environment configuration is very useful when you are running in a dynamic environment and you don't know some of the parameters in advance (for example it's not possible to know your external IP address when you are running inside ``docker``). +.. _important_configuration_rules: Important rules --------------- @@ -70,15 +71,16 @@ There also are some parameters like **postgresql.listen**, **postgresql.data_dir When applying the local or dynamic configuration options, the following actions are taken: -- The node first checks if there is a `postgresql.base.conf` or if the ``custom_conf`` parameter is set. -- If the ``custom_conf`` parameter is set, it will take the file specified on it as a base configuration, ignoring `postgresql.base.conf` and `postgresql.conf`. -- If the ``custom_conf`` parameter is not set and `postgresql.base.conf` exists, it contains the renamed "original" configuration and it will be used as a base configuration. -- If there is no ``custom_conf``` nor `postgresql.base.conf`, the original `postgresql.conf`` is taken and renamed to postgresql.base.conf. -- The dynamic options (with the exceptions above) are dumped into the `postgresql.conf`` and an include is set in - postgresql.conf to the used base configuration (either `postgresql.base.conf` or what is on ``custom_conf``). Therefore, we would be able to apply new options without re-reading the configuration file to check if the include is present not. +- The node first checks if there is a `postgresql.base.conf` file or if the ``custom_conf`` parameter is set. +- If the ``custom_conf`` parameter is set, the file it specifies is used as the base configuration, ignoring `postgresql.base.conf` and `postgresql.conf`. +- If the ``custom_conf`` parameter is not set and `postgresql.base.conf` exists, it contains the renamed "original" configuration and is used as the base configuration. +- If there is no ``custom_conf`` nor `postgresql.base.conf`, the original `postgresql.conf` is renamed to `postgresql.base.conf` and used as the base configuration. +- The dynamic options (with the exceptions above) are dumped into the `postgresql.conf` and an include is set in + `postgresql.conf` to the base configuration (either `postgresql.base.conf` or the file at ``custom_conf``). + Therefore, we would be able to apply new options without re-reading the configuration file to check if the include is present or not. - Some parameters that are essential for Patroni to manage the cluster are overridden using the command line. -- If some of the options that require restart are changed (we should look at the context in pg_settings and at the actual - values of those options), a pending_restart flag of a given node is set. This flag is reset on any restart. +- If an option that requires restart is changed (we should look at the context in pg_settings and at the actual + values of those options), a pending_restart flag is set on that node. This flag is reset on any restart. The parameters would be applied in the following order (run-time are given the highest priority): @@ -89,6 +91,43 @@ The parameters would be applied in the following order (run-time are given the h This allows configuration for all the nodes (2), configuration for a specific node using ``ALTER SYSTEM`` (3) and ensures that parameters essential to the running of Patroni are enforced (4), as well as leaves room for configuration tools that manage `postgresql.conf` directly without involving Patroni (1). +.. _shared_memory_gucs: + +PostgreSQL parameters that touch shared memory +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +PostgreSQL has some parameters that determine the size of the shared memory used by them: + +- **max_connections** +- **max_prepared_transactions** +- **max_locks_per_transaction** +- **max_wal_senders** +- **max_worker_processes** + +Changing these parameters require a PostgreSQL restart to take effect, and their shared memory structures cannot be smaller on the standby nodes than on the primary node. + +As explained before, Patroni restrict changing their values through :ref:`dynamic configuration `, which usually consists of: + +1. Applying changes through :ref:`patronictl_edit_config` (or via REST API ``/config`` endpoint) +2. Restarting nodes through :ref:`patronictl_restart` (or via REST API ``/restart`` endpoint) + +**Note:** please keep in mind that you should perform a restart of the PostgreSQL nodes through :ref:`patronictl_restart` command, or via REST API ``/restart`` endpoint. An attempt to restart PostgreSQL by restarting the Patroni daemon, e.g. by executing ``systemctl restart patroni``, can cause a failover to occur in the cluster, if you are restarting the primary node. + +However, as those settings manage shared memory, some extra care should be taken when restarting the nodes: + +* If you want to **increase** the value of any of those settings: + + 1. Restart all standbys first + 2. Restart the primary after that + +* If you want to **decrease** the value of any of those settings: + + 1. Restart the primary first + 2. Restart all standbys after that + +**Note:** if you attempt to restart all nodes in one go after **decreasing** the value of any of those settings, Patroni will ignore the change and restart the standby with the original setting value, thus requiring that you restart the standbys again later. Patroni does that to prevent the standby to enter in an infinite crash loop, because PostgreSQL quits with a `FATAL` message if you attempt to set any of those parameters to a value lower than what is visible in ``pg_controldata`` on the Standby node. In other words, we can only decrease the setting on the standby once its ``pg_controldata`` is up-to-date with the primary in regards to these changes on the primary. + +More information about that can be found at `PostgreSQL Administrator's Overview `__. Patroni configuration parameters ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -106,3 +145,109 @@ Also the following Patroni configuration options **can be changed only dynamical Upon changing these options, Patroni will read the relevant section of the configuration stored in DCS and change its run-time values. Patroni nodes are dumping the state of the DCS options to disk upon for every change of the configuration into the file ``patroni.dynamic.json`` located in the Postgres data directory. Only the leader is allowed to restore these options from the on-disk dump if these are completely absent from the DCS or if they are invalid. + + +.. _validate_generate_config: + +Configuration generation and validation +--------------------------------------- + +Patroni provides command-line interfaces for a Patroni :ref:`local configuration ` generation and validation. Using the ``patroni`` executable you can: + +- Create a sample local Patroni configuration; +- Create a Patroni configuration file for the locally running PostgreSQL instance (e.g. as a preparation step for the :ref:`Patroni integration `); +- Validate a given Patroni configuration file. + +.. _generate_sample_config: + +Sample Patroni configuration +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code:: text + + patroni --generate-sample-config [configfile] + +Description +""""""""""" + +Generate a sample Patroni configuration file in ``yaml`` format. +Parameter values are defined using the :ref:`Environment configuration `, otherwise, if not set, the defaults used in Patroni or the ``#FIXME`` string for the values that should be later defined by the user. + +Some default values are defined based on the local setup: + + - **postgresql.listen**: the IP address returned by ``gethostname`` call for the current machine's hostname and the standard ``5432`` port. + - **postgresql.connect_address**: the IP address returned by ``gethostname`` call for the current machine's hostname and the standard ``5432`` port. + - **postgresql.authentication.rewind**: is only defined if the PostgreSQL version can be defined from the binary and the version is 11 or later. + - **restapi.listen**: IP address returned by ``gethostname`` call for the current machine's hostname and the standard ``8008`` port. + - **restapi.connect_address**: IP address returned by ``gethostname`` call for the current machine's hostname and the standard ``8008`` port. + +Parameters +"""""""""" + +``configfile`` - full path to the configuration file used to store the result. If not provided, the result is sent to ``stdout``. + +.. _generate_config: + +Patroni configuration for a running instance +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code:: text + + patroni --generate-config [--dsn DSN] [configfile] + +Description +""""""""""" + +Generate a Patroni configuration in ``yaml`` format for the locally running PostgreSQL instance. +Either the provided DSN (takes precedence) or PostgreSQL `environment variables `__ will be used for the PostgreSQL connection. If the password is not provided, it should be entered via prompt. + +All the non-internal GUCs defined in the source Postgres instance, independently if they were set through a configuration file, through the postmaster command-line, or through environment variables, will be used as the source for the following Patroni configuration parameters: + + - **scope**: ``cluster_name`` GUC value; + - **postgresql.listen**: ``listen_addresses`` and ``port`` GUC values; + - **postgresql.datadir**: ``data_directory`` GUC value; + - **postgresql.parameters**: ``archive_command``, ``restore_command``, ``archive_cleanup_command``, ``recovery_end_command``, ``ssl_passphrase_command``, ``hba_file``, ``ident_file``, ``config_file`` GUC values; + - **bootstrap.dcs**: all other gathered PostgreSQL GUCs. + +If ``scope``, ``postgresql.listen`` or ``postgresql.datadir`` is not set from the Postgres GUCs, the respective :ref:`Environment configuration ` value is used. + +Other rules applied for the values definition: + + - **name**: ``PATRONI_NAME`` environment variable value if set, otherwise the current machine's hostname. + - **postgresql.bin_dir**: path to the Postgres binaries gathered from the running instance. + - **postgresql.connect_address**: the IP address returned by ``gethostname`` call for the current machine's hostname and the port used for the instance connection or the ``port`` GUC value. + - **postgresql.authentication.superuser**: the configuration used for the instance connection; + - **postgresql.pg_hba**: the lines gathered from the source instance's ``hba_file``. + - **postgresql.pg_ident**: the lines gathered from the source instance's ``ident_file``. + - **restapi.listen**: IP address returned by ``gethostname`` call for the current machine's hostname and the standard ``8008`` port. + - **restapi.connect_address**: IP address returned by ``gethostname`` call for the current machine's hostname and the standard ``8008`` port. + +Other parameters defined using :ref:`Environment configuration ` are also included into the configuration. + +Parameters +"""""""""" + +``configfile`` + Full path to the configuration file used to store the result. If not provided, result is sent to ``stdout``. + +``dsn`` + Optional DSN string for the local PostgreSQL instance to get GUC values from. + + +Validate Patroni configuration +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code:: text + + patroni --validate-config [configfile] + +Description +""""""""""" + +Validate the given Patroni configuration and print the information about the failed checks. + +Parameters +"""""""""" + +``configfile`` + Full path to the configuration file to check. If not given or file does not exist, will try to read from the ``PATRONI_CONFIG_VARIABLE`` environment variable or, if not set, from the :ref:`Patroni environment variables `. diff --git a/docs/patronictl.rst b/docs/patronictl.rst new file mode 100644 index 00000000..3c043a66 --- /dev/null +++ b/docs/patronictl.rst @@ -0,0 +1,1975 @@ +.. _patronictl: + +patronictl +========== + +Patroni has a command-line interface named ``patronictl``, which is used basically to interact with Patroni's REST API and with the DCS. It is intended to make it easier to perform operations in the cluster, and can easily be used by humans or scripts. + +.. _patronictl_configuration: + +Configuration +------------- + +``patronictl`` uses 3 sections of the configuration: + +- **ctl**: how to authenticate against the Patroni REST API, and how to validate the server identity. Refer to :ref:`ctl settings ` for more details; +- **restapi**: how to authenticate against the Patroni REST API, and how to validate the server identity. Only used if ``ctl`` configuration is not enough. ``patronictl`` is mainly interested in ``restapi.authentication`` section (in case ``ctl.authentication`` is missing) and ``restapi.cafile`` setting (in case ``ctl.cacert`` is missing). Refer to :ref:`REST API settings ` for more details; +- DCS (e.g. **etcd**): how to contact and authenticate against the DCS used by Patroni. + +Those configuration options can come either from environment variables or from a configuration file. Look for the above sections in :ref:`Environment Configuration Settings ` or :ref:`YAML Configuration Settings ` to understand how you can set the options for them through environment variables or through a configuration file. + +If you opt for using environment variables, it's a straight forward approach. Patronictl will read the environment variables and use their values. + +If you opt for using a configuration file, you have different ways to inform ``patronictl`` about the file to be used. By default ``patronictl`` will attempt to load a configuration file named ``patronictl.yaml``, which is expected to be found under either of these paths, according to your system: + +- Mac OS X: ``~/Library/Application Support/patroni`` +- Mac OS X (POSIX): ``~/.patroni`` +- Unix: ``~/.config/patroni`` +- Unix (POSIX): ``~/.patroni`` +- Windows (roaming): ``C:\Users\\AppData\Roaming\patroni`` +- Windows (not roaming): ``C:\Users\\AppData\Local\patroni`` + +You can override that behavior either by: + +- Setting the environment variable ``PATRONICTL_CONFIG_FILE`` with the path to a custom configuration file; +- Using the ``-c`` / ``--config-file`` command-line argument of ``patronictl`` with the path to a custom configuration file. + +.. note:: + If you are running ``patronictl`` in the same host as ``patroni`` daemon is running, you may just use the same configuration file if it contains all the configuration sections required by ``patronictl``. + +.. _patronictl_usage: + +Usage +----- + +``patronictl`` exposes several handy operations. This section is intended to describe each of them. + +Before jumping into each of the sub-commands of ``patronictl``, be aware that ``patronictl`` itself has the following command-line arguments: + +``-c`` / ``--config-file`` + As explained before, used to provide a path to a configuration file for ``patronictl``. + +``-d`` / ``--dcs-url`` / ``--dcs`` + Provide a connection string to the DCS used by Patroni. + + This argument can be used either to override the DCS and ``namespace`` settings from the ``patronictl`` configuration, or to define it if it's missing in the configuration. + + The value should be in the format ``DCS://HOST:PORT/NAMESPACE``, e.g. ``etcd3://localhost:2379/service`` to connect to etcd v3 running on ``localhost`` with Patroni cluster stored under ``service`` namespace. Any part that is missing in the argument value will be replaced with the value present in the configuration or with its default. + +``-k`` / ``--insecure`` + Flag to bypass validation of REST API server SSL certificate. + +This is the synopsis for running a command from the ``patronictl``: + +.. code:: text + + patronictl [ { -c | --config-file } CONFIG_FILE ] + [ { -d | --dcs-url | --dcs } DCS_URL ] + [ { -k | --insecure } ] + SUBCOMMAND + +.. note:: + + This is the syntax for the synopsis: + + - Options between square brackets are optional; + - Options between curly brackets represent a "choose one of set" operation; + - Options with ``[, ... ]`` can be specified multiple times; + - Things written in uppercase represent a literal that should be given a value to. + + We will use this same syntax when describing ``patronictl`` sub-commands in the following sub-sections. + Also, when describing sub-commands in the following sub-sections, the commands' synposis should be seen as a replacement for the ``SUBCOMMAND`` in the above synopsis. + +In the following sub-sections you can find a description of each command implemented by ``patronictl``. For sake of example, we will use the configuration files present in the GitHub repository of Patroni (files ``postgres0.yml``, ``postgres1.yml`` and ``postgres2.yml``). + +.. _patronictl_dsn: + +patronictl dsn +^^^^^^^^^^^^^^ + +.. _patronictl_dsn_synopsis: + +Synopsis +"""""""" + +.. code:: text + + dsn + [ CLUSTER_NAME ] + [ { { -r | --role } { leader | primary | standby-leader | replica | standby | any } | { -m | --member } MEMBER_NAME } ] + [ --group CITUS_GROUP ] + +.. _patronictl_dsn_description: + +Description +""""""""""" + +``patronictl dsn`` gets the connection string for one member of the Patroni cluster. + +If multiple members match the parameters of this command, one of them will be chosen, prioritizing the primary node. + +.. _patronictl_dsn_parameters: + +Parameters +"""""""""" + +``CLUSTER_NAME`` + Name of the Patroni cluster. + + If not given, ``patronictl`` will attempt to fetch that from the ``scope`` configuration, if it exists. + +``-r`` / ``--role`` + Choose a member that has the given role. + + Role can be one of: + + - ``leader``: the leader of either a regular Patroni cluster or a standby Patroni cluster; or + - ``primary``: the leader of a regular Patroni cluster; or + - ``standby-leader``: the leader of a standby Patroni cluster; or + - ``replica``: a replica of a Patroni cluster; or + - ``standby``: same as ``replica``; or + - ``any``: any role. Same as omitting this parameter; or + +``-m`` / ``--member`` + Choose a member of the cluster with the given name. + + ``MEMBER_NAME`` is the name of the member. + +``--group`` + Choose a member that is part of the given Citus group. + + ``CITUS_GROUP`` is the ID of the Citus group. + +.. _patronictl_dsn_examples: + +Examples +"""""""" + +Get DSN of the primary node: + +.. code:: bash + + $ patronictl -c postgres0.yml dsn batman -r primary + host=127.0.0.1 port=5432 + +Get DSN of the node named ``postgresql1``: + +.. code:: bash + + $ patronictl -c postgres0.yml dsn batman --member postgresql1 + host=127.0.0.1 port=5433 + +.. _patronictl_edit_config: + +patronictl edit-config +^^^^^^^^^^^^^^^^^^^^^^ + +.. _patronictl_edit_config_synopsis: + +Synopsis +"""""""" + +.. code:: text + + edit-config + [ CLUSTER_NAME ] + [ --group CITUS_GROUP ] + [ { -q | --quiet } ] + [ { -s | --set } CONFIG="VALUE" [, ... ] ] + [ { -p | --pg } PG_CONFIG="PG_VALUE" [, ... ] ] + [ { --apply | --replace } CONFIG_FILE ] + [ --force ] + +.. _patronictl_edit_config_description: + +Description +""""""""""" + +``patronictl edit-config`` changes the dynamic configuration of the cluster and updates the DCS with that. + +.. note:: + When invoked through a TTY the command attempts to show a diff of the dynamic configuration through a pager. By default, it attempts to use either ``less`` or ``more``. If you want a different pager, set the ``PAGER`` environment variable with the desired one. + +.. _patronictl_edit_config_parameters: + +Parameters +"""""""""" + +``CLUSTER_NAME`` + Name of the Patroni cluster. + + If not given, ``patronictl`` will attempt to fetch that from the ``scope`` configuration, if it exists. + +``--group`` + Change dynamic configuration of the given Citus group. + + If not given, ``patronictl`` will attempt to fetch that from the ``citus.group`` configuration, if it exists. + + ``CITUS_GROUP`` is the ID of the Citus group. + +``-q`` / ``--quiet`` + Flag to skip showing the configuration diff. + +``-s`` / ``--set`` + Set a given dynamic configuration option with a given value. + + ``CONFIG`` is the name of the dynamic configuration path in the YAML tree, with levels joined by ``.`` . + + ``VALUE`` is the value for ``CONFIG``. If it is ``null``, then ``CONFIG`` will be removed from the dynamic configuration. + +``-p`` / ``--pg`` + Set a given dynamic Postgres configuration option with the given value. + + It is essentially a shorthand for ``--s`` / ``--set`` with ``CONFIG`` prepended with ``postgresql.parameters.``. + + ``PG_CONFIG`` is the name of the Postgres configuration to be set. + + ``PG_VALUE`` is the value for ``PG_CONFIG``. If it is ``nulll``, then ``PG_CONFIG`` will be removed from the dynamic configuration. + +``--apply`` + Apply dynamic configuration from the given file. + + It is similar to specifying multiple ``-s`` / ``--set`` options, one for each configuration from ``CONFIG_FILE``. + + ``CONFIG_FILE`` is the path to a file containing the dynamic configuration to be applied, in YAML format. Use ``-`` if you want to read from ``stdin``. + +``--replace`` + Replace the dynamic configuration in the DCS with the dynamic configuration specified in the given file. + + ``CONFIG_FILE`` is the path to a file containing the new dynamic configuration to take effect, in YAML format. Use ``-`` if you want to read from ``stdin``. + +``--force`` + Flag to skip confirmation prompts when changing the dynamic configuration. + + Useful for scripts. + +.. _patronictl_edit_config_examples: + +Examples +"""""""" + +Change ``max_connections`` Postgres GUC: + +.. code:: diff + + patronictl -c postgres0.yml edit-config batman --pg max_connections="150" --force + --- + +++ + @@ -1,6 +1,8 @@ + loop_wait: 10 + maximum_lag_on_failover: 1048576 + postgresql: + + parameters: + + max_connections: 150 + pg_hba: + - host replication replicator 127.0.0.1/32 md5 + - host all all 0.0.0.0/0 md5 + + Configuration changed + +Change ``loop_wait`` and ``ttl`` settings: + +.. code:: diff + + patronictl -c postgres0.yml edit-config batman --set loop_wait="15" --set ttl="45" --force + --- + +++ + @@ -1,4 +1,4 @@ + -loop_wait: 10 + +loop_wait: 15 + maximum_lag_on_failover: 1048576 + postgresql: + pg_hba: + @@ -6,4 +6,4 @@ + - host all all 0.0.0.0/0 md5 + use_pg_rewind: true + retry_timeout: 10 + -ttl: 30 + +ttl: 45 + + Configuration changed + +Remove ``maximum_lag_on_failover`` setting from dynamic configuration: + +.. code:: diff + + patronictl -c postgres0.yml edit-config batman --set maximum_lag_on_failover="null" --force + --- + +++ + @@ -1,5 +1,4 @@ + loop_wait: 10 + -maximum_lag_on_failover: 1048576 + postgresql: + pg_hba: + - host replication replicator 127.0.0.1/32 md5 + + Configuration changed + +.. _patronictl_failover: + +patronictl failover +^^^^^^^^^^^^^^^^^^^ + +.. _patronictl_failover_synopsis: + +Synopsis +"""""""" + +.. code:: text + + failover + [ CLUSTER_NAME ] + [ --group CITUS_GROUP ] + [ { --leader | --primary } LEADER_NAME ] + --candidate CANDIDATE_NAME + [ --force ] + +.. _patronictl_failover_description: + +Description +""""""""""" + +``patronictl failover`` performs a manual failover in the cluster. + +It is designed to be used when the cluster is not healthy, e.g.: + +- There is no leader; or +- There is no synchronous standby available in a synchronous cluster. + +It also allows to fail over to an asynchronous node if synchronous mode is enabled. + +.. note:: + Nothing prevents you from running ``patronictl failover`` in a healthy cluster. However, we recommend using ``patronictl switchover`` in those cases. + +.. warning:: + Triggering a failover can cause data loss depending on how up-to-date the promoted replica is in comparison to the primary. + +.. _patronictl_failover_parameters: + +Parameters +"""""""""" + +``CLUSTER_NAME`` + Name of the Patroni cluster. + + If not given, ``patronictl`` will attempt to fetch that from the ``scope`` configuration, if it exists. + +``--group`` + Perform a failover in the given Citus group. + + ``CITUS_GROUP`` is the ID of the Citus group. + +``--leader`` / ``--primary`` + Indicate who is the expected leader at failover time. + + If given, a switchover is performed instead of a failover. + + ``LEADER_NAME`` should match the name of the current leader in the cluster. + + .. warning:: + This argument is deprecated and will be removed in a future release. + +``--candidate`` + The node to be promoted on failover. + + ``CANDIDATE_NAME`` is the name of the node to be promoted. + +``--force`` + Flag to skip confirmation prompts when performing the failover. + + Useful for scripts. + +.. _patronictl_failover_examples: + +Examples +"""""""" + +Fail over to node ``postgresql2``: + +.. code:: bash + + $ patronictl -c postgres0.yml failover batman --candidate postgresql2 --force + Current cluster topology + + Cluster: batman (7277694203142172922) -+-----------+----+-----------+ + | Member | Host | Role | State | TL | Lag in MB | + +-------------+----------------+---------+-----------+----+-----------+ + | postgresql0 | 127.0.0.1:5432 | Leader | running | 3 | | + | postgresql1 | 127.0.0.1:5433 | Replica | streaming | 3 | 0 | + | postgresql2 | 127.0.0.1:5434 | Replica | streaming | 3 | 0 | + +-------------+----------------+---------+-----------+----+-----------+ + 2023-09-12 11:52:27.50978 Successfully failed over to "postgresql2" + + Cluster: batman (7277694203142172922) -+---------+----+-----------+ + | Member | Host | Role | State | TL | Lag in MB | + +-------------+----------------+---------+---------+----+-----------+ + | postgresql0 | 127.0.0.1:5432 | Replica | stopped | | unknown | + | postgresql1 | 127.0.0.1:5433 | Replica | running | 3 | 0 | + | postgresql2 | 127.0.0.1:5434 | Leader | running | 3 | | + +-------------+----------------+---------+---------+----+-----------+ + +.. _patronictl_flush: + +patronictl flush +^^^^^^^^^^^^^^^^ + +.. _patronictl_flush_synopsis: + +Synopsis +"""""""" + +.. code:: text + + flush + CLUSTER_NAME + [ MEMBER_NAME [, ... ] ] + { restart | switchover } + [ --group CITUS_GROUP ] + [ { -r | --role } { leader | primary | standby-leader | replica | standby | any } ] + [ --force ] + +.. _patronictl_flush_description: + +Description +""""""""""" + +``patronictl flush`` discards scheduled events, if any. + +.. _patronictl_flush_parameters: + +Parameters +"""""""""" + +``CLUSTER_NAME`` + Name of the Patroni cluster. + +``MEMBER_NAME`` + Discard scheduled events for the given Patroni member(s). + + Multiple members can be specified. If no members are specified, all of them are considered. + + .. note:: + Only used if discarding scheduled restart events. + +``restart`` + Discard scheduled restart events. + +``switchover`` + Discard scheduled switchover event. + +``--group`` + Discard scheduled events from the given Citus group. + + ``CITUS_GROUP`` is the ID of the Citus group. + +``-r`` / ``--role`` + Discard scheduled events for members that have the given role. + + Role can be one of: + + - ``leader``: the leader of either a regular Patroni cluster or a standby Patroni cluster; or + - ``primary``: the leader of a regular Patroni cluster; or + - ``standby-leader``: the leader of a standby Patroni cluster; or + - ``replica``: a replica of a Patroni cluster; or + - ``standby``: same as ``replica``; or + - ``any``: any role. Same as omitting this parameter. + + .. note:: + Only used if discarding scheduled restart events. + +``--force`` + Flag to skip confirmation prompts when performing the flush. + + Useful for scripts. + +.. _patronictl_flush_examples: + +Examples +"""""""" + +Discard a scheduled switchover event: + +.. code:: bash + + $ patronictl -c postgres0.yml flush batman switchover --force + Success: scheduled switchover deleted + +Discard scheduled restart of all standby nodes: + +.. code:: bash + + $ patronictl -c postgres0.yml flush batman restart -r replica --force + + Cluster: batman (7277694203142172922) -+-----------+----+-----------+---------------------------+ + | Member | Host | Role | State | TL | Lag in MB | Scheduled restart | + +-------------+----------------+---------+-----------+----+-----------+---------------------------+ + | postgresql0 | 127.0.0.1:5432 | Leader | running | 5 | | 2023-09-12T17:17:00+00:00 | + | postgresql1 | 127.0.0.1:5433 | Replica | streaming | 5 | 0 | 2023-09-12T17:17:00+00:00 | + | postgresql2 | 127.0.0.1:5434 | Replica | streaming | 5 | 0 | 2023-09-12T17:17:00+00:00 | + +-------------+----------------+---------+-----------+----+-----------+---------------------------+ + Success: flush scheduled restart for member postgresql1 + Success: flush scheduled restart for member postgresql2 + +Discard scheduled restart of nodes ``postgresql0`` and ``postgresql1``: + +.. code:: bash + + $ patronictl -c postgres0.yml flush batman postgresql0 postgresql1 restart --force + + Cluster: batman (7277694203142172922) -+-----------+----+-----------+---------------------------+ + | Member | Host | Role | State | TL | Lag in MB | Scheduled restart | + +-------------+----------------+---------+-----------+----+-----------+---------------------------+ + | postgresql0 | 127.0.0.1:5432 | Leader | running | 5 | | 2023-09-12T17:17:00+00:00 | + | postgresql1 | 127.0.0.1:5433 | Replica | streaming | 5 | 0 | 2023-09-12T17:17:00+00:00 | + | postgresql2 | 127.0.0.1:5434 | Replica | streaming | 5 | 0 | 2023-09-12T17:17:00+00:00 | + +-------------+----------------+---------+-----------+----+-----------+---------------------------+ + Success: flush scheduled restart for member postgresql0 + Success: flush scheduled restart for member postgresql1 + +.. _patronictl_history: + +patronictl history +^^^^^^^^^^^^^^^^^^ + +.. _patronictl_history_synopsis: + +Synopsis +"""""""" + +.. code:: text + + history + [ CLUSTER_NAME ] + [ --group CITUS_GROUP ] + [ { -f | --format } { pretty | tsv | json | yaml } ] + +.. _patronictl_history_description: + +Description +""""""""""" + +``patronictl history`` shows a history of failover and switchover events from the cluster, if any. + +The following information is included in the output: + +``TL`` + Postgres timeline at which the event occurred. + +``LSN`` + Postgres LSN at which the event occurred. + +``Reason`` + Reason fetched from the Postgres ``.history`` file. + +``Timestamp`` + Time when the event occurred. + +``New Leader`` + Patroni member that has been promoted during the event. + +.. _patronictl_history_parameters: + +Parameters +"""""""""" + +``CLUSTER_NAME`` + Name of the Patroni cluster. + + If not given, ``patronictl`` will attempt to fetch that from the ``scope`` configuration, if it exists. + +``--group`` + Show history of events from the given Citus group. + + ``CITUS_GROUP`` is the ID of the Citus group. + + If not given, ``patronictl`` will attempt to fetch that from the ``citus.group`` configuration, if it exists. + +``-f`` / ``--format`` + How to format the list of events in the output. + + Format can be one of: + + - ``pretty``: prints history as a pretty table; or + - ``tsv``: prints history as tabular information, with columns delimited by ``\t``; or + - ``json``: prints history in JSON format; or + - ``yaml``: prints history in YAML format. + + The default is ``pretty``. + +``--force`` + Flag to skip confirmation prompts when performing the flush. + + Useful for scripts. + +.. _patronictl_history_examples: + +Examples +"""""""" + +Show the history of events: + +.. code:: bash + + $ patronictl -c postgres0.yml history batman + +----+----------+------------------------------+----------------------------------+-------------+ + | TL | LSN | Reason | Timestamp | New Leader | + +----+----------+------------------------------+----------------------------------+-------------+ + | 1 | 24392648 | no recovery target specified | 2023-09-11T22:11:27.125527+00:00 | postgresql0 | + | 2 | 50331864 | no recovery target specified | 2023-09-12T11:34:03.148097+00:00 | postgresql0 | + | 3 | 83886704 | no recovery target specified | 2023-09-12T11:52:26.948134+00:00 | postgresql2 | + | 4 | 83887280 | no recovery target specified | 2023-09-12T11:53:09.620136+00:00 | postgresql0 | + +----+----------+------------------------------+----------------------------------+-------------+ + +Show the history of events in YAML format: + +.. code:: bash + + $ patronictl -c postgres0.yml history batman -f yaml + - LSN: 24392648 + New Leader: postgresql0 + Reason: no recovery target specified + TL: 1 + Timestamp: '2023-09-11T22:11:27.125527+00:00' + - LSN: 50331864 + New Leader: postgresql0 + Reason: no recovery target specified + TL: 2 + Timestamp: '2023-09-12T11:34:03.148097+00:00' + - LSN: 83886704 + New Leader: postgresql2 + Reason: no recovery target specified + TL: 3 + Timestamp: '2023-09-12T11:52:26.948134+00:00' + - LSN: 83887280 + New Leader: postgresql0 + Reason: no recovery target specified + TL: 4 + Timestamp: '2023-09-12T11:53:09.620136+00:00' + +.. _patronictl_list: + +patronictl list +^^^^^^^^^^^^^^^ + +.. _patronictl_list_synopsis: + +Synopsis +"""""""" + +.. code:: text + + list + [ CLUSTER_NAME [, ... ] ] + [ --group CITUS_GROUP ] + [ { -e | --extended } ] + [ { -t | --timestamp } ] + [ { -f | --format } { pretty | tsv | json | yaml } ] + [ { -W | { -w | --watch } TIME } ] + +.. _patronictl_list_description: + +Description +""""""""""" + +``patronictl list`` shows information about Patroni cluster and its members. + +The following information is included in the output: + +``Cluster`` + Name of the Patroni cluster. + +``Member`` + Name of the Patroni member. + +``Host`` + Host where the member is located. + +``Role`` + Current role of the member. + + Can be one among: + + * ``Leader``: the current leader of a regular Patroni cluster; or + * ``Standby Leader``: the current leader of a Patroni standby cluster; or + * ``Sync Standby``: a synchronous standby of a Patroni cluster with synchronous mode enabled; or + * ``Replica``: a regular standby of a Patroni cluster. + +``State`` + Current state of Postgres in the Patroni member. + + Some examples among the possible states: + + * ``running``: if Postgres is currently up and running; + * ``streaming``: if a replica and Postgres is currently streaming WALs from the primary node; + * ``in archive recovery``: if a replica and Postgres is currently fetching WALs from the archive; + * ``stopped``: if Postgres had been shut down; + * ``crashed``: if Postgres has crashed. + +``TL`` + Current Postgres timeline in the Patroni member. + +``Lag in MB`` + Amount worth of replication lag in megabytes between the Patroni member and its upstream. + +Besides that, the following information may be included in the output: + +``System identifier`` + Postgres system identifier. + + .. note:: + Shown in the table header. + + Only shown if output format is ``pretty``. + +``Group`` + Citus group ID. + + .. note:: + Shown in the table header. + + Only shown if a Citus cluster. + +``Pending restart`` + ``*`` indicates that the node needs a restart for some Postgres configuration to take effect. An empty value indicates the node does not require a restart. + + .. note:: + Shown as a member attribute. + + Shown if: + + - Printing in ``pretty`` or ``tsv`` format and with extended output enabled; or + - If node requires a restart. + +``Scheduled restart`` + Timestamp at which a restart has been scheduled for the Postgres instance managed by the Patroni member. An empty value indicates there is no scheduled restart for the member. + + .. note:: + Shown as a member attribute. + + Shown if: + + - Printing in ``pretty`` or ``tsv`` format and with extended output enabled; or + - If node has a scheduled restart. + +``Tags`` + Contains tags set for the Patroni member. An empty value indicates that either no tags have been configured, or that they have been configured with default values. + + .. note:: + Shown as a member attribute. + + Shown if: + + - Printing in ``pretty`` or ``tsv`` format and with extended output enabled; or + - If node has any custom tags, or any default tags with non-default values. + +``Scheduled switchover`` + Timestamp at which a switchover has been scheduled for the Patroni cluster, if any. + + .. note:: + Shown in the table footer. + + Only shown if there is a scheduled switchover, and output format is ``pretty``. + +``Maintenance mode`` + + If the cluster monitoring is currently paused. + + .. note:: + Shown in the table footer. + + Only shown if the cluster is paused, and output format is ``pretty``. + +.. _patronictl_list_parameters: + +Parameters +"""""""""" + +``CLUSTER_NAME`` + Name of the Patroni cluster. + + If not given, ``patronictl`` will attempt to fetch that from the ``scope`` configuration, if it exists. + +``--group`` + Show information about members from the given Citus group. + + ``CITUS_GROUP`` is the ID of the Citus group. + +``-e`` / ``--extended`` + Show extended information. + + Force showing ``Pending restart``, ``Scheduled restart`` and ``Tags`` attributes, even if their value is empty. + + .. note:: + Only applies to ``pretty`` and ``tsv`` output formats. + +``-t`` / ``--timestamp`` + Print timestamp before printing information about the cluster and its members. + +``-f`` / ``--format`` + How to format the list of events in the output. + + Format can be one of: + + - ``pretty``: prints history as a pretty table; or + - ``tsv``: prints history as tabular information, with columns delimited by ``\t``; or + - ``json``: prints history in JSON format; or + - ``yaml``: prints history in YAML format. + + The default is ``pretty``. + +``-W`` + Automatically refresh information every 2 seconds. + +``-w`` / ``--watch`` + Automatically refresh information at the specified interval. + + ``TIME`` is the interval between refreshes, in seconds. + +.. _patronictl_list_examples: + +Examples +"""""""" + +Show information about the cluster in pretty format: + +.. code:: bash + + $ patronictl -c postgres0.yml list batman + + Cluster: batman (7277694203142172922) -+-----------+----+-----------+ + | Member | Host | Role | State | TL | Lag in MB | + +-------------+----------------+---------+-----------+----+-----------+ + | postgresql0 | 127.0.0.1:5432 | Leader | running | 5 | | + | postgresql1 | 127.0.0.1:5433 | Replica | streaming | 5 | 0 | + | postgresql2 | 127.0.0.1:5434 | Replica | streaming | 5 | 0 | + +-------------+----------------+---------+-----------+----+-----------+ + +Show information about the cluster in pretty format with extended columns: + +.. code:: bash + + $ patronictl -c postgres0.yml list batman -e + + Cluster: batman (7277694203142172922) -+-----------+----+-----------+-----------------+-------------------+------+ + | Member | Host | Role | State | TL | Lag in MB | Pending restart | Scheduled restart | Tags | + +-------------+----------------+---------+-----------+----+-----------+-----------------+-------------------+------+ + | postgresql0 | 127.0.0.1:5432 | Leader | running | 5 | | | | | + | postgresql1 | 127.0.0.1:5433 | Replica | streaming | 5 | 0 | | | | + | postgresql2 | 127.0.0.1:5434 | Replica | streaming | 5 | 0 | | | | + +-------------+----------------+---------+-----------+----+-----------+-----------------+-------------------+------+ + +Show information about the cluster in YAML format, with timestamp of execution: + +.. code:: bash + + $ patronictl -c postgres0.yml list batman -f yaml -t + 2023-09-12 13:30:48 + - Cluster: batman + Host: 127.0.0.1:5432 + Member: postgresql0 + Role: Leader + State: running + TL: 5 + - Cluster: batman + Host: 127.0.0.1:5433 + Lag in MB: 0 + Member: postgresql1 + Role: Replica + State: streaming + TL: 5 + - Cluster: batman + Host: 127.0.0.1:5434 + Lag in MB: 0 + Member: postgresql2 + Role: Replica + State: streaming + TL: 5 + +.. _patronictl_pause: + +patronictl pause +^^^^^^^^^^^^^^^^ + +.. _patronictl_pause_synopsis: + +Synopsis +"""""""" + +.. code:: text + + pause + [ CLUSTER_NAME ] + [ --group CITUS_GROUP ] + [ --wait ] + +.. _patronictl_pause_description: + +Description +""""""""""" + +``patronictl pause`` temporarily puts the Patroni cluster in maintenance mode and disables automatic failover. + +.. _patronictl_pause_parameters: + +Parameters +"""""""""" + +``CLUSTER_NAME`` + Name of the Patroni cluster. + + If not given, ``patronictl`` will attempt to fetch that from the ``scope`` configuration, if it exists. + +``--group`` + Pause the given Citus group. + + ``CITUS_GROUP`` is the ID of the Citus group. + + If not given, ``patronictl`` will attempt to fetch that from the ``citus.group`` configuration, if it exists. + +``--wait`` + Wait until all Patroni members are paused before returning control to the caller. + +.. _patronictl_pause_examples: + +Examples +"""""""" + +Put the cluster in maintenance mode, and wait until all nodes have been paused: + +.. code:: bash + + $ patronictl -c postgres0.yml pause batman --wait + 'pause' request sent, waiting until it is recognized by all nodes + Success: cluster management is paused + +.. _patronictl_query: + +patronictl query +^^^^^^^^^^^^^^^^ + +.. _patronictl_query_synopsis: + +Synopsis +"""""""" + +.. code:: text + + query + [ CLUSTER_NAME ] + [ --group CITUS_GROUP ] + [ { { -r | --role } { leader | primary | standby-leader | replica | standby | any } | { -m | --member } MEMBER_NAME } ] + [ { -d | --dbname } DBNAME ] + [ { -U | --username } USERNAME ] + [ --password ] + [ --format { pretty | tsv | json | yaml } ] + [ { { -f | --file } FILE_NAME | { -c | --command } SQL_COMMAND } ] + [ --delimiter ] + [ { -W | { -w | --watch } TIME } ] + +.. _patronictl_query_description: + +Description +""""""""""" + +``patronictl query`` executes a SQL command or script against a member of the Patroni cluster. + +.. _patronictl_query_parameters: + +Parameters +"""""""""" + +``CLUSTER_NAME`` + Name of the Patroni cluster. + + If not given, ``patronictl`` will attempt to fetch that from the ``scope`` configuration, if it exists. + +``--group`` + Query the given Citus group. + + ``CITUS_GROUP`` is the ID of the Citus group. + +``-r`` / ``--role`` + Choose a member that has the given role. + + Role can be one of: + + - ``leader``: the leader of either a regular Patroni cluster or a standby Patroni cluster; or + - ``primary``: the leader of a regular Patroni cluster; or + - ``standby-leader``: the leader of a standby Patroni cluster; or + - ``replica``: a replica of a Patroni cluster; or + - ``standby``: same as ``replica``; or + - ``any``: any role. Same as omitting this parameter. + +``-m`` / ``--member`` + Choose a member that has the given name. + + ``MEMBER_NAME`` is the name of the member to be picked. + +``-d`` / ``--dbname`` + Database to connect and run the query. + + ``DBNAME`` is the name of the database. If not given, defaults to ``USERNAME``. + +``-U`` / ``--username`` + User to connect to the database. + + ``USERNAME`` name of the user. If not given, defaults to the operating system user running ``patronictl query``. + +``--password`` + Prompt for the password of the connecting user. + + As Patroni uses ``libpq``, alternatively you can create a ``~/.pgpass`` file or set the ``PGPASSWORD`` environment variable. + +``--format`` + How to format the output of the query. + + Format can be one of: + + - ``pretty``: prints query output as a pretty table; or + - ``tsv``: prints query output as tabular information, with columns delimited by ``\t``; or + - ``json``: prints query output in JSON format; or + - ``yaml``: prints query output in YAML format. + + The default is ``tsv``. + +``-f`` / ``--file`` + Use a file as source of commands to run queries. + + ``FILE_NAME`` is the path to the source file. + +``-c`` / ``--command`` + Run the given SQL command in the query. + + ``SQL_COMMAND`` is the SQL command to be executed. + +``--delimiter`` + The delimiter when printing information in ``tsv`` format, or ``\t`` if omitted. + +``-W`` + Automatically re-run the query every 2 seconds. + +``-w`` / ``--watch`` + Automatically re-run the query at the specified interval. + + ``TIME`` is the interval between re-runs, in seconds. + +.. _patronictl_query_examples: + +Examples +"""""""" + +Run a SQL command as ``postgres`` user, and ask for its password: + +.. code:: bash + + $ patronictl -c postgres0.yml query batman -U postgres --password -c "SELECT now()" + Password: + now + 2023-09-12 18:10:53.228084+00:00 + +Run a SQL command as ``postgres`` user, and take password from ``libpq`` environment variable: + +.. code:: bash + + $ PGPASSWORD=zalando patronictl -c postgres0.yml query batman -U postgres -c "SELECT now()" + now + 2023-09-12 18:11:37.639500+00:00 + +Run a SQL command and print in ``pretty`` format every 2 seconds: + +.. code:: bash + + $ patronictl -c postgres0.yml query batman -c "SELECT now()" --format pretty -W + +----------------------------------+ + | now | + +----------------------------------+ + | 2023-09-12 18:12:16.716235+00:00 | + +----------------------------------+ + +----------------------------------+ + | now | + +----------------------------------+ + | 2023-09-12 18:12:18.732645+00:00 | + +----------------------------------+ + +----------------------------------+ + | now | + +----------------------------------+ + | 2023-09-12 18:12:20.750573+00:00 | + +----------------------------------+ + +Run a SQL command on database ``test`` and print the output in YAML format: + +.. code:: bash + + $ patronictl -c postgres0.yml query batman -d test -c "SELECT now() AS column_1, 'test' AS column_2" --format yaml + - column_1: 2023-09-12 18:14:22.052060+00:00 + column_2: test + +Run a SQL command on member ``postgresql2``: + +.. code:: bash + + $ patronictl -c postgres0.yml query batman -m postgresql2 -c "SHOW port" + port + 5434 + +Run a SQL command on any of the standbys: + +.. code:: bash + + $ patronictl -c postgres0.yml query batman -r replica -c "SHOW port" + port + 5433 + +.. _patronictl_reinit: + +patronictl reinit +^^^^^^^^^^^^^^^^^ + +.. _patronictl_reinit_synopsis: + +Synopsis +"""""""" + +.. code:: text + + reinit + CLUSTER_NAME + [ MEMBER_NAME [, ... ] ] + [ --group CITUS_GROUP ] + [ --wait ] + [ --force ] + +.. _patronictl_reinit_description: + +Description +""""""""""" + +``patronictl reinit`` rebuilds a Postgres standby instance managed by a replica member of the Patroni cluster. + +.. _patronictl_reinit_parameters: + +Parameters +"""""""""" + +``CLUSTER_NAME`` + Name of the Patroni cluster. + +``MEMBER_NAME`` + Name of the replica member for which the Postgres instance will be rebuilt. + + Multiple replica members can be specified. If no members are specified, the command does nothing. + +``--group`` + Rebuild a replica member of the given Citus group. + + ``CITUS_GROUP`` is the ID of the Citus group. + +``--wait`` + Wait until the reinitialization of the Postgres standby node(s) is finished. + +``--force`` + Flag to skip confirmation prompts when rebuilding Postgres standby instances. + + Useful for scripts. + +.. _patronictl_reinit_examples: + +Examples +"""""""" + +Request a rebuild of all replica members of the Patroni cluster and immediately return control to the caller: + +.. code:: bash + + $ patronictl -c postgres0.yml reinit batman postgresql1 postgresql2 --force + + Cluster: batman (7277694203142172922) -+-----------+----+-----------+ + | Member | Host | Role | State | TL | Lag in MB | + +-------------+----------------+---------+-----------+----+-----------+ + | postgresql0 | 127.0.0.1:5432 | Leader | running | 5 | | + | postgresql1 | 127.0.0.1:5433 | Replica | streaming | 5 | 0 | + | postgresql2 | 127.0.0.1:5434 | Replica | streaming | 5 | 0 | + +-------------+----------------+---------+-----------+----+-----------+ + Success: reinitialize for member postgresql1 + Success: reinitialize for member postgresql2 + +Request a rebuild of ``postgresql2`` and wait for it to complete: + +.. code:: bash + + $ patronictl -c postgres0.yml reinit batman postgresql2 --wait --force + + Cluster: batman (7277694203142172922) -+-----------+----+-----------+ + | Member | Host | Role | State | TL | Lag in MB | + +-------------+----------------+---------+-----------+----+-----------+ + | postgresql0 | 127.0.0.1:5432 | Leader | running | 5 | | + | postgresql1 | 127.0.0.1:5433 | Replica | streaming | 5 | 0 | + | postgresql2 | 127.0.0.1:5434 | Replica | streaming | 5 | 0 | + +-------------+----------------+---------+-----------+----+-----------+ + Success: reinitialize for member postgresql2 + Waiting for reinitialize to complete on: postgresql2 + Reinitialize is completed on: postgresql2 + +.. _patronictl_reload: + +patronictl reload +^^^^^^^^^^^^^^^^^ + +.. _patronictl_reload_synopsis: + +Synopsis +"""""""" + +.. code:: text + + reload + CLUSTER_NAME + [ MEMBER_NAME [, ... ] ] + [ --group CITUS_GROUP ] + [ { -r | --role } { leader | primary | standby-leader | replica | standby | any } ] + [ --force ] + +.. _patronictl_reload_description: + +Description +""""""""""" + +``patronictl reload`` requests a reload of local configuration for one or more Patroni members. + +It also triggers ``pg_ctl reload`` on the managed Postgres instance, even if nothing has changed. + +.. _patronictl_reload_parameters: + +Parameters +"""""""""" + +``CLUSTER_NAME`` + Name of the Patroni cluster. + +``MEMBER_NAME`` + Request a reload of local configuration for the given Patroni member(s). + + Multiple members can be specified. If no members are specified, all of them are considered. + +``--group`` + Request a reload of members of the given Citus group. + + ``CITUS_GROUP`` is the ID of the Citus group. + +``-r`` / ``--role`` + Select members that have the given role. + + Role can be one of: + + - ``leader``: the leader of either a regular Patroni cluster or a standby Patroni cluster; or + - ``primary``: the leader of a regular Patroni cluster; or + - ``standby-leader``: the leader of a standby Patroni cluster; or + - ``replica``: a replica of a Patroni cluster; or + - ``standby``: same as ``replica``; or + - ``any``: any role. Same as omitting this parameter. + +``--force`` + Flag to skip confirmation prompts when requesting a reload of the local configuration. + + Useful for scripts. + +.. _patronictl_reload_examples: + +Examples +"""""""" + +Request a reload of the local configuration of all members of the Patroni cluster: + +.. code:: bash + + $ patronictl -c postgres0.yml reload batman --force + + Cluster: batman (7277694203142172922) -+-----------+----+-----------+ + | Member | Host | Role | State | TL | Lag in MB | + +-------------+----------------+---------+-----------+----+-----------+ + | postgresql0 | 127.0.0.1:5432 | Leader | running | 5 | | + | postgresql1 | 127.0.0.1:5433 | Replica | streaming | 5 | 0 | + | postgresql2 | 127.0.0.1:5434 | Replica | streaming | 5 | 0 | + +-------------+----------------+---------+-----------+----+-----------+ + Reload request received for member postgresql0 and will be processed within 10 seconds + Reload request received for member postgresql1 and will be processed within 10 seconds + Reload request received for member postgresql2 and will be processed within 10 seconds + +.. _patronictl_remove: + +patronictl remove +^^^^^^^^^^^^^^^^^ + +.. _patronictl_remove_synopsis: + +Synopsis +"""""""" + +.. code:: text + + remove + CLUSTER_NAME + [ --group CITUS_GROUP ] + [ { -f | --format } { pretty | tsv | json | yaml } ] + +.. _patronictl_remove_description: + +Description +""""""""""" + +``patronictl remove`` removes information of the cluster from the DCS. + +It is an interactive action. + +.. warning:: + This operation will destroy the information of the Patroni cluster from the DCS. + +.. _patronictl_remove_parameters: + +Parameters +"""""""""" + +``CLUSTER_NAME`` + Name of the Patroni cluster. + +``--group`` + Remove information about the Patroni cluster related with the given Citus group. + + ``CITUS_GROUP`` is the ID of the Citus group. + +``-f`` / ``--format`` + How to format the list of members in the output when prompting for confirmation. + + Format can be one of: + + - ``pretty``: prints members as a pretty table; or + - ``tsv``: prints members as tabular information, with columns delimited by ``\t``; or + - ``json``: prints members in JSON format; or + - ``yaml``: prints members in YAML format. + + The default is ``pretty``. + +.. _patronictl_remove_examples: + +Examples +"""""""" + +Remove information about Patroni cluster ``batman`` from the DCS: + +.. code:: bash + + $ patronictl -c postgres0.yml remove batman + + Cluster: batman (7277694203142172922) -+-----------+----+-----------+ + | Member | Host | Role | State | TL | Lag in MB | + +-------------+----------------+---------+-----------+----+-----------+ + | postgresql0 | 127.0.0.1:5432 | Leader | running | 5 | | + | postgresql1 | 127.0.0.1:5433 | Replica | streaming | 5 | 0 | + | postgresql2 | 127.0.0.1:5434 | Replica | streaming | 5 | 0 | + +-------------+----------------+---------+-----------+----+-----------+ + Please confirm the cluster name to remove: batman + You are about to remove all information in DCS for batman, please type: "Yes I am aware": Yes I am aware + This cluster currently is healthy. Please specify the leader name to continue: postgresql0 + +.. _patronictl_restart: + +patronictl restart +^^^^^^^^^^^^^^^^^^ + +.. _patronictl_restart_synopsis: + +Synopsis +"""""""" + +.. code:: text + + restart + CLUSTER_NAME + [ MEMBER_NAME [, ...] ] + [ --group CITUS_GROUP ] + [ { -r | --role } { leader | primary | standby-leader | replica | standby | any } ] + [ --any ] + [ --pg-version PG_VERSION ] + [ --pending ] + [ --timeout TIMEOUT ] + [ --scheduled TIMESTAMP ] + [ --force ] + +.. _patronictl_restart_description: + +Description +""""""""""" + +``patronictl restart`` requests a restart of the Postgres instance managed by a member of the Patroni cluster. + +The restart can be performed immediately or scheduled for later. + +.. _patronictl_restart_parameters: + +Parameters +"""""""""" + +``CLUSTER_NAME`` + Name of the Patroni cluster. + +``--group`` + Restart the Patroni cluster related with the given Citus group. + + ``CITUS_GROUP`` is the ID of the Citus group. + +``-r`` / ``--role`` + Choose members that have the given role. + + Role can be one of: + + - ``leader``: the leader of either a regular Patroni cluster or a standby Patroni cluster; or + - ``primary``: the leader of a regular Patroni cluster; or + - ``standby-leader``: the leader of a standby Patroni cluster; or + - ``replica``: a replica of a Patroni cluster; or + - ``standby``: same as ``replica``; or + - ``any``: any role. Same as omitting this parameter. + +``--any`` + Restart a single random node among the ones which match the given filters. + +``--pg-version`` + Select only members which version of the managed Postgres instance is older than the given version. + + ``PG_VERSION`` is the Postgres version to be compared. + +``--pending`` + Select only members which are flagged as ``Pending restart``. + +``timeout`` + Abort the restart if it takes more than the specified timeout, and fail over to a replica if the issue is on the primary. + + ``TIMEOUT`` is the amount of seconds to wait before aborting the restart. + +``--scheduled`` + Schedule a restart to occur at the given timestamp. + + ``TIMESTAMP`` is the timestamp when the restart should occur. Specify it in unambiguous format, preferrably with time zone. You can also use the literal ``now`` for the restart to be executed immediately. + +``--force`` + Flag to skip confirmation prompts when requesting the restart operations. + + Useful for scripts. + +.. _patronictl_restart_examples: + +Examples +"""""""" + +Restart all members of the cluster immediately: + +.. code:: bash + + $ patronictl -c postgres0.yml restart batman --force + + Cluster: batman (7277694203142172922) -+-----------+----+-----------+ + | Member | Host | Role | State | TL | Lag in MB | + +-------------+----------------+---------+-----------+----+-----------+ + | postgresql0 | 127.0.0.1:5432 | Leader | running | 6 | | + | postgresql1 | 127.0.0.1:5433 | Replica | streaming | 6 | 0 | + | postgresql2 | 127.0.0.1:5434 | Replica | streaming | 6 | 0 | + +-------------+----------------+---------+-----------+----+-----------+ + Success: restart on member postgresql0 + Success: restart on member postgresql1 + Success: restart on member postgresql2 + +Restart a random member of the cluster immediately: + +.. code:: bash + + $ patronictl -c postgres0.yml restart batman --any --force + + Cluster: batman (7277694203142172922) -+-----------+----+-----------+ + | Member | Host | Role | State | TL | Lag in MB | + +-------------+----------------+---------+-----------+----+-----------+ + | postgresql0 | 127.0.0.1:5432 | Leader | running | 6 | | + | postgresql1 | 127.0.0.1:5433 | Replica | streaming | 6 | 0 | + | postgresql2 | 127.0.0.1:5434 | Replica | streaming | 6 | 0 | + +-------------+----------------+---------+-----------+----+-----------+ + Success: restart on member postgresql1 + +Schedule a restart to occur at ``2023-09-13T18:00-03:00``: + +.. code:: bash + + $ patronictl -c postgres0.yml restart batman --scheduled 2023-09-13T18:00-03:00 --force + + Cluster: batman (7277694203142172922) -+-----------+----+-----------+ + | Member | Host | Role | State | TL | Lag in MB | + +-------------+----------------+---------+-----------+----+-----------+ + | postgresql0 | 127.0.0.1:5432 | Leader | running | 6 | | + | postgresql1 | 127.0.0.1:5433 | Replica | streaming | 6 | 0 | + | postgresql2 | 127.0.0.1:5434 | Replica | streaming | 6 | 0 | + +-------------+----------------+---------+-----------+----+-----------+ + Success: restart scheduled on member postgresql0 + Success: restart scheduled on member postgresql1 + Success: restart scheduled on member postgresql2 + +.. _patronictl_resume: + +patronictl resume +^^^^^^^^^^^^^^^^^ + +.. _patronictl_resume_synopsis: + +Synopsis +"""""""" + +.. code:: text + + resume + [ CLUSTER_NAME ] + [ --group CITUS_GROUP ] + [ --wait ] + +.. _patronictl_resume_description: + +Description +""""""""""" + +``patronictl resume`` takes the Patroni cluster out of maintenance mode and re-enables automatic failover. + +.. _patronictl_resume_parameters: + +Parameters +"""""""""" + +``CLUSTER_NAME`` + Name of the Patroni cluster. + + If not given, ``patronictl`` will attempt to fetch that from the ``scope`` configuration, if it exists. + +``--group`` + Resume the given Citus group. + + ``CITUS_GROUP`` is the ID of the Citus group. + + If not given, ``patronictl`` will attempt to fetch that from the ``citus.group`` configuration, if it exists. + +``--wait`` + Wait until all Patroni members are unpaused before returning control to the caller. + +.. _patronictl_resume_examples: + +Examples +"""""""" + +Put the cluster out of maintenance mode: + +.. code:: bash + + $ patronictl -c postgres0.yml resume batman --wait + 'resume' request sent, waiting until it is recognized by all nodes + Success: cluster management is resumed + +.. _patronictl_show_config: + +patronictl show-config +^^^^^^^^^^^^^^^^^^^^^^ + +.. _patronictl_show_config_synopsis: + +Synopsis +"""""""" + +.. code:: text + + show-config + [ CLUSTER_NAME ] + [ --group CITUS_GROUP ] + +.. _patronictl_show_config_description: + +Description +""""""""""" + +``patronictl show-config`` shows the dynamic configuration of the cluster that is stored in the DCS. + +.. _patronictl_show_config_parameters: + +Parameters +"""""""""" + +``CLUSTER_NAME`` + Name of the Patroni cluster. + + If not given, ``patronictl`` will attempt to fetch that from the ``scope`` configuration, if it exists. + +``--group`` + Show dynamic configuration of the given Citus group. + + ``CITUS_GROUP`` is the ID of the Citus group. + + If not given, ``patronictl`` will attempt to fetch that from the ``citus.group`` configuration, if it exists. + +.. _patronictl_show_config_examples: + +Examples +"""""""" + +Show dynamic configuration of cluster ``batman``: + +.. code:: bash + + $ patronictl -c postgres0.yml show-config batman + loop_wait: 10 + postgresql: + parameters: + max_connections: 250 + pg_hba: + - host replication replicator 127.0.0.1/32 md5 + - host all all 0.0.0.0/0 md5 + use_pg_rewind: true + retry_timeout: 10 + ttl: 30 + +.. _patronictl_switchover: + +patronictl switchover +^^^^^^^^^^^^^^^^^^^^^ + +.. _patronictl_switchover_synopsis: + +Synopsis +"""""""" + +.. code:: text + + switchover + [ CLUSTER_NAME ] + [ --group CITUS_GROUP ] + [ { --leader | --primary } LEADER_NAME ] + --candidate CANDIDATE_NAME + [ --force ] + +.. _patronictl_switchover_description: + +Description +""""""""""" + +``patronictl switchover`` performs a switchover in the cluster. + +It is designed to be used when the cluster is healthy, e.g.: + +- There is a leader; +- There are synchronous standbys available in a synchronous cluster. + +.. note:: + If your cluster is unhealthy you might be interested in ``patronictl failover`` instead. + +.. _patronictl_switchover_parameters: + +Parameters +"""""""""" + +``CLUSTER_NAME`` + Name of the Patroni cluster. + + If not given, ``patronictl`` will attempt to fetch that from the ``scope`` configuration, if it exists. + +``--group`` + Perform a switchover in the given Citus group. + + ``CITUS_GROUP`` is the ID of the Citus group. + +``--leader`` / ``--primary`` + Indicate who is the leader to be demoted at switchover time. + + ``LEADER_NAME`` should match the name of the current leader in the cluster. + +``--candidate`` + The node to be promoted on switchover, and take the primary role. + + ``CANDIDATE_NAME`` is the name of the node to be promoted. + +``--scheduled`` + Schedule a switchover to occur at the given timestamp. + + ``TIMESTAMP`` is the timestamp when the switchover should occur. Specify it in unambiguous format, preferrably with time zone. You can also use the literal ``now`` for the switchover to be executed immediately. + +``--force`` + Flag to skip confirmation prompts when performing the switchover. + + Useful for scripts. + +.. _patronictl_switchover_examples: + +Examples +"""""""" + +Switch over with node ``postgresql2``: + +.. code:: bash + + $ patronictl -c postgres0.yml switchover batman --leader postgresql0 --candidate postgresql2 --force + Current cluster topology + + Cluster: batman (7277694203142172922) -+-----------+----+-----------+ + | Member | Host | Role | State | TL | Lag in MB | + +-------------+----------------+---------+-----------+----+-----------+ + | postgresql0 | 127.0.0.1:5432 | Leader | running | 6 | | + | postgresql1 | 127.0.0.1:5433 | Replica | streaming | 6 | 0 | + | postgresql2 | 127.0.0.1:5434 | Replica | streaming | 6 | 0 | + +-------------+----------------+---------+-----------+----+-----------+ + 2023-09-13 14:15:23.07497 Successfully switched over to "postgresql2" + + Cluster: batman (7277694203142172922) -+---------+----+-----------+ + | Member | Host | Role | State | TL | Lag in MB | + +-------------+----------------+---------+---------+----+-----------+ + | postgresql0 | 127.0.0.1:5432 | Replica | stopped | | unknown | + | postgresql1 | 127.0.0.1:5433 | Replica | running | 6 | 0 | + | postgresql2 | 127.0.0.1:5434 | Leader | running | 6 | | + +-------------+----------------+---------+---------+----+-----------+ + +Schedule a switchover between ``postgresql0`` and ``postgresql2`` to occur at ``2023-09-13T18:00:00-03:00``: + +.. code:: bash + + $ patronictl -c postgres0.yml switchover batman --leader postgresql0 --candidate postgresql2 --scheduled 2023-09-13T18:00-03:00 --force + Current cluster topology + + Cluster: batman (7277694203142172922) -+-----------+----+-----------+ + | Member | Host | Role | State | TL | Lag in MB | + +-------------+----------------+---------+-----------+----+-----------+ + | postgresql0 | 127.0.0.1:5432 | Leader | running | 8 | | + | postgresql1 | 127.0.0.1:5433 | Replica | streaming | 8 | 0 | + | postgresql2 | 127.0.0.1:5434 | Replica | streaming | 8 | 0 | + +-------------+----------------+---------+-----------+----+-----------+ + 2023-09-13 14:18:11.20661 Switchover scheduled + + Cluster: batman (7277694203142172922) -+-----------+----+-----------+ + | Member | Host | Role | State | TL | Lag in MB | + +-------------+----------------+---------+-----------+----+-----------+ + | postgresql0 | 127.0.0.1:5432 | Leader | running | 8 | | + | postgresql1 | 127.0.0.1:5433 | Replica | streaming | 8 | 0 | + | postgresql2 | 127.0.0.1:5434 | Replica | streaming | 8 | 0 | + +-------------+----------------+---------+-----------+----+-----------+ + Switchover scheduled at: 2023-09-13T18:00:00-03:00 + from: postgresql0 + to: postgresql2 + +.. _patronictl_topology: + +patronictl topology +^^^^^^^^^^^^^^^^^^^ + +.. _patronictl_topology_synopsis: + +Synopsis +"""""""" + +.. code:: text + + topology + [ CLUSTER_NAME [, ... ] ] + [ --group CITUS_GROUP ] + [ { -W | { -w | --watch } TIME } ] + +.. _patronictl_topology_description: + +Description +""""""""""" + +``patronictl topology`` shows information about the Patroni cluster and its members with a tree view approach. + +The following information is included in the output: + +``Cluster`` + Name of the Patroni cluster. + + .. note:: + Shown in the table header. + +``System identifier`` + Postgres system identifier. + + .. note:: + Shown in the table header. + +``Member`` + Name of the Patroni member. + + .. note:: + Information in this column is shown as a tree view of members in terms of replication connections. + +``Host`` + Host where the member is located. + +``Role`` + Current role of the member. + + Can be one among: + + * ``Leader``: the current leader of a regular Patroni cluster; or + * ``Standby Leader``: the current leader of a Patroni standby cluster; or + * ``Sync Standby``: a synchronous standby of a Patroni cluster with synchronous mode enabled; or + * ``Replica``: a regular standby of a Patroni cluster. + +``State`` + Current state of Postgres in the Patroni member. + + Some examples among the possible states: + + * ``running``: if Postgres is currently up and running; + * ``streaming``: if a replica and Postgres is currently streaming WALs from the primary node; + * ``in archive recovery``: if a replica and Postgres is currently fetching WALs from the archive; + * ``stopped``: if Postgres had been shut down; + * ``crashed``: if Postgres has crashed. + +``TL`` + Current Postgres timeline in the Patroni member. + +``Lag in MB`` + Amount worth of replication lag in megabytes between the Patroni member and its upstream. + +Besides that, the following information may be included in the output: + +``Group`` + Citus group ID. + + .. note:: + Shown in the table header. + + Only shown if a Citus cluster. + +``Pending restart`` + ``*`` indicates the node needs a restart for some Postgres configuration to take effect. An empty value indicates the node does not require a restart. + + .. note:: + Shown as a member attribute. + + Shown if node requires a restart. + +``Scheduled restart`` + Timestamp at which a restart has been scheduled for the Postgres instance managed by the Patroni member. An empty value indicates there is no scheduled restart for the member. + + .. note:: + Shown as a member attribute. + + Shown if node has a scheduled restart. + +``Tags`` + Contains tags set for the Patroni member. An empty value indicates that either no tags have been configured, or that they have been configured with default values. + + .. note:: + Shown as a member attribute. + + Shown if node has any custom tags, or any default tags with non-default values. + +``Scheduled switchover`` + Timestamp at which a switchover has been scheduled for the Patroni cluster, if any. + + .. note:: + Shown in the table footer. + + Only shown if there is a scheduled switchover. + +``Maintenance mode`` + + If the cluster monitoring is currently paused. + + .. note:: + Shown in the table footer. + + Only shown if the cluster is paused. + +.. _patronictl_topology_parameters: + +Parameters +"""""""""" + +``CLUSTER_NAME`` + Name of the Patroni cluster. + + If not given, ``patronictl`` will attempt to fetch that from the ``scope`` configuration, if it exists. + +``--group`` + Show information about members from the given Citus group. + + ``CITUS_GROUP`` is the ID of the Citus group. + +``-W`` + Automatically refresh information every 2 seconds. + +``-w`` / ``--watch`` + Automatically refresh information at the specified interval. + + ``TIME`` is the interval between refreshes, in seconds. + +.. _patronictl_topology_examples: + +Examples +"""""""" + +Show topology of the cluster ``batman`` -- ``postgresql1`` and ``postgresql2`` are replicating from ``postgresql0``: + +.. code:: bash + + $ patronictl -c postgres0.yml topology batman + + Cluster: batman (7277694203142172922) ---+-----------+----+-----------+ + | Member | Host | Role | State | TL | Lag in MB | + +---------------+----------------+---------+-----------+----+-----------+ + | postgresql0 | 127.0.0.1:5432 | Leader | running | 8 | | + | + postgresql1 | 127.0.0.1:5433 | Replica | streaming | 8 | 0 | + | + postgresql2 | 127.0.0.1:5434 | Replica | streaming | 8 | 0 | + +---------------+----------------+---------+-----------+----+-----------+ + +.. _patronictl_version: + +patronictl version +^^^^^^^^^^^^^^^^^^ + +.. _patronictl_version_synopsis: + +Synopsis +"""""""" + +.. code:: text + + version + [ CLUSTER_NAME [, ... ] ] + [ MEMBER_NAME [, ... ] ] + [ --group CITUS_GROUP ] + +.. _patronictl_version_description: + +Description +""""""""""" + +``patronictl version`` gets the version of ``patronictl`` application. Besides that it may also include version information about Patroni clusters and their members. + +.. _patronictl_version_parameters: + +Parameters +"""""""""" + +``CLUSTER_NAME`` + Name of the Patroni cluster. + +``MEMBER_NAME`` + Name of the member of the Patroni cluster. + +``--group`` + Consider a Patroni cluster with the given Citus group. + + ``CITUS_GROUP`` is the ID of the Citus group. + +.. _patronictl_version_examples: + +Examples +"""""""" + +Get version of ``patronictl`` only: + +.. code:: bash + + $ patronictl -c postgres0.yml version + patronictl version 3.1.0 + +Get version of ``patronictl`` and of all members of cluster ``batman``: + +.. code:: bash + + $ patronictl -c postgres0.yml version batman + patronictl version 3.1.0 + + postgresql0: Patroni 3.1.0 PostgreSQL 15.2 + postgresql1: Patroni 3.1.0 PostgreSQL 15.2 + postgresql2: Patroni 3.1.0 PostgreSQL 15.2 + +Get version of ``patronictl`` and of members ``postgresql1`` and ``postgresql2`` of cluster ``batman``: + +.. code:: bash + + $ patronictl -c postgres0.yml version batman postgresql1 postgresql2 + patronictl version 3.1.0 + + postgresql1: Patroni 3.1.0 PostgreSQL 15.2 + postgresql2: Patroni 3.1.0 PostgreSQL 15.2 diff --git a/docs/pause.rst b/docs/pause.rst index 8eb7c14c..9a74a2e0 100644 --- a/docs/pause.rst +++ b/docs/pause.rst @@ -19,7 +19,7 @@ When Patroni runs in a paused mode, it does not change the state of PostgreSQL, - For the Postgres primary with the leader lock Patroni updates the lock. If the node with the leader lock stops being the primary (i.e. is demoted manually), Patroni will release the lock instead of promoting the node back. -- Manual unscheduled restart, reinitialize and manual failover are allowed. Manual failover is only allowed if the node to failover to is specified. In the paused mode, manual failover does not require a running primary node. +- Manual unscheduled restart, manual unscheduled failover/switchover and reinitialize are allowed. No scheduled action is allowed. Manual switchover is only allowed if the node to switch over to is specified. - If 'parallel' primaries are detected by Patroni, it emits a warning, but does not demote the primary without the leader lock. @@ -32,6 +32,6 @@ When Patroni runs in a paused mode, it does not change the state of PostgreSQL, User guide ---------- -``patronictl`` supports ``pause`` and ``resume`` commands. +``patronictl`` supports :ref:`pause ` and :ref:`resume ` commands. One can also issue a ``PATCH`` request to the ``{namespace}/{cluster}/config`` key with ``{"pause": true/false/null}`` diff --git a/docs/releases.rst b/docs/releases.rst index 7a05bf7e..c68977cd 100644 --- a/docs/releases.rst +++ b/docs/releases.rst @@ -3,6 +3,196 @@ Release notes ============= +Version 3.2.1 +------------- + +**Bugfixes** + +- Limit accepted values for ``--format`` argument in ``patronictl`` (Alexander Kukushkin) + + It used to accept any arbitrary string and produce no output if the value wasn't recognized. + +- Verify that replica nodes received checkpoint LSN on shutdown before releasing the leader key (Alexander Kukushkin) + + Previously in some cases, we were using LSN of the SWITCH record that is followed by CHECKPOINT (if archiving mode is enabled). As a result the former primary sometimes had to do ``pg_rewind``, but there would be no data loss involved. + +- Do a real HTTP request when performing node name uniqueness check (Alexander Kukushkin) + + When running Patroni in containers it is possible that the traffic is routed using ``docker-proxy``, which listens on the port and accepts incoming connections. It was causing false positives. + +- Fixed Citus support with Etcd v2 (Alexander Kukushkin) + + Patroni was failing to deploy a new Citus cluster with Etcd v2. + +- Fixed ``pg_rewind`` behavior with Postgres v16+ (Alexander Kukushkin) + + The error message format of ``pg_waldump`` changed in v16 which caused ``pg_rewind`` to be called by Patroni even when it was not necessary. + +- Fixed bug with custom bootstrap (Alexander Kukushkin) + + Patroni was falsely applying ``--command`` argument, which is a bootstrap command itself. + +- Fixed the issue with REST API health check endpoints (Sophia Ruan) + + There were chances that after Postgres restart it could return ``unknown`` state for Postgres because connections were not properly closed. + +- Cache ``postgres --describe-config`` output results (Waynerv) + + They are used to figure out which GUCs are available to validate PostgreSQL configuration and we don't expect this list to change while Patroni is running. + + +Version 3.2.0 +------------- + +**Deprecation notice** + +- The ``bootstrap.users`` support will be removed in version 4.0.0. If you need to create users after deploying a new cluster please use the ``bootstrap.post_bootstrap`` hook for that. + + +**Breaking changes** + +- Enforce ``loop_wait + 2*retry_timeout <= ttl`` rule and hard-code minimal possible values (Alexander Kukushkin) + + Minimal values: ``loop_wait=2``, ``retry_timeout=3``, ``ttl=20``. In case values are smaller or violate the rule they are adjusted and a warning is written to Patroni logs. + + +**New features** + +- Failover priority (Mark Pekala) + + With the help of ``tags.failover_priority`` it's now possible to make a node more preferred during the leader race. More details in the documentation (ref tags). + +- Implemented ``patroni --generate-config [--dsn DSN]`` and ``patroni --generate-sample-config`` (Polina Bungina) + + It allows to generate a config file for the running PostgreSQL cluster or a sample config file for the new Patroni cluster. + +- Use a dedicated connection to Postgres for Patroni REST API (Alexander Kukushkin) + + It helps to avoid blocking the main heartbeat loop if the system is under stress. + +- Enrich some endpoints with the ``name`` of the node (sskserk) + + For the monitoring endpoint ``name`` is added next to the ``scope`` and for metrics endpoint the ``name`` is added to tags. + +- Ensure strict failover/switchover difference (Polina Bungina) + + Be more precise in log messages and allow failing over to an asynchronous node in a healthy synchronous cluster. + +- Make permanent physical replication slots behave similarly to permanent logical slots (Alexander Kukushkin) + + Create permanent physical replication slots on all nodes that are allowed to become the leader and use ``pg_replication_slot_advance()`` function to advance ``restart_lsn`` for slots on standby nodes. + +- Add capability of specifying namespace through ``--dcs`` argument in ``patronictl`` (Israel Barth Rubio) + + It could be handy if ``patronictl`` is used without a configuration file. + +- Add support for additional parameters in custom bootstrap configuration (Israel Barth Rubio) + + Previously it was only possible to add custom arguments to the ``command`` and now one could list them as a mapping. + + +**Improvements** + +- Set ``citus.local_hostname`` GUC to the same value which is used by Patroni to connect to the Postgres (Alexander Kukushkin) + + There are cases when Citus wants to have a connection to the local Postgres. By default it uses ``localhost``, which is not always available. + + +**Bugfixes** + +- Ignore ``synchronous_mode`` setting in a standby cluster (Polina Bungina) + + Postgres doesn't support cascading synchronous replication and not ignoring ``synchronous_mode`` was breaking a switchover in a standby cluster. + +- Handle SIGCHLD for ``on_reload`` callback (Alexander Kukushkin) + + Not doing so results in a zombie process, which is reaped only when the next ``on_reload`` is executed. + +- Handle ``AuthOldRevision`` error when working with Etcd v3 (Alexander Kukushkin, Kenny Do) + + The error is raised if Etcd is configured to use JWT and when the user database in Etcd is updated. + + +Version 3.1.2 +------------- + +**Bugfixes** + +- Fixed bug with ``wal_keep_size`` checks (Alexander Kukushkin) + + The ``wal_keep_size`` is a GUC that normally has a unit and Patroni was failing to cast its value to ``int``. As a result the value of ``bootstrap.dcs`` was not written to the ``/config`` key afterwards. + +- Detect and resolve inconsistencies between ``/sync`` key and ``synchronous_standby_names`` (Alexander Kukushkin) + + Normally, Patroni updates ``/sync`` and ``synchronous_standby_names`` in a very specific order, but in case of a bug or when someone manually reset ``synchronous_standby_names``, Patroni was getting into an inconsistent state. As a result it was possible that the failover happens to an asynchronous node. + +- Read GUC's values when joining running Postgres (Alexander Kukushkin) + + When restarted in ``pause``, Patroni was discarding the ``synchronous_standby_names`` GUC from the ``postgresql.conf``. To solve it and avoid similar issues, Patroni will read GUC's value if it is joining an already running Postgres. + +- Silenced annoying warnings when checking for node uniqueness (Alexander Kukushkin) + + ``WARNING`` messages are produced by ``urllib3`` if Patroni is quickly restarted. + + +Version 3.1.1 +------------- + +**Bugfixes** + +- Reset failsafe state on promote (ChenChangAo) + + If switchover/failover happened shortly after failsafe mode had been activated, the newly promoted primary was demoting itself after failsafe becomes inactive. + +- Silence useless warnings in ``patronictl`` (Alexander Kukushkin) + + If ``patronictl`` uses the same patroni.yaml file as Patroni and can access ``PGDATA`` directory it might have been showing annoying warnings about incorrect values in the global configuration. + +- Explicitly enable synchronous mode for a corner case (Alexander Kukushkin) + + Synchronous mode effectively was never activated if there are no replicas streaming from the primary. + +- Fixed bug with ``0`` integer values validation (Israel Barth Rubio) + + In most cases, it didn't cause any issues, just warnings. + +- Don't return logical slots for standby cluster (Alexander Kukushkin) + + Patroni can't create logical replication slots in the standby cluster, thus they should be ignored if they are defined in the global configuration. + +- Avoid showing docstring in ``patronictl --help`` output (Israel Barth Rubio) + + The ``click`` module needs to get a special hint for that. + +- Fixed bug with ``kubernetes.standby_leader_label_value`` (Alexander Kukushkin) + + This feature effectively never worked. + +- Returned cluster system identifier to the ``patronictl list`` output (Polina Bungina) + + The problem was introduced while implementing the support for Citus, where we need to hide the identifier because it is different for coordinator and all workers. + +- Override ``write_leader_optime`` method in Kubernetes implementation (Alexander Kukushkin) + + The method is supposed to write shutdown LSN to the leader Endpoint/ConfigMap when there are no healthy replicas available to become the new primary. + +- Don't start stopped postgres in pause (Alexander Kukushkin) + + Due to a race condition, Patroni was falsely assuming that the standby should be restarted because some recovery parameters (``primary_conninfo`` or similar) were changed. + +- Fixed bug in ``patronictl query`` command (Israel Barth Rubio) + + It didn't work when only ``-m`` argument was provided or when none of ``-r`` or ``-m`` were provided. + +- Properly treat integer parameters that are used in the command line to start postgres (Polina Bungina) + + If values are supplied as strings and not casted to integer it was resulting in an incorrect calculation of ``max_prepared_transactions`` based on ``max_connections`` for Citus clusters. + +- Don't rely on ``pg_stat_wal_receiver`` when deciding on ``pg_rewind`` (Alexander Kukushkin) + + It could happen that ``received_tli`` reported by ``pg_stat_wal_recevier`` is ahead of the actual replayed timeline, while the timeline reported by ``DENTIFY_SYSTEM`` via replication connection is always correct. + + Version 3.1.0 ------------- diff --git a/docs/replica_bootstrap.rst b/docs/replica_bootstrap.rst index 42a7d3b0..a6e3db8e 100644 --- a/docs/replica_bootstrap.rst +++ b/docs/replica_bootstrap.rst @@ -43,19 +43,50 @@ in the configuration files, Patroni supplies two cluster-specific ones: Passing these two additional flags can be disabled by setting a special ``no_params`` parameter to ``True``. -If the bootstrap script returns 0, Patroni tries to configure and start the PostgreSQL instance produced by it. If any +If the bootstrap script returns ``0``, Patroni tries to configure and start the PostgreSQL instance produced by it. If any of the intermediate steps fail, or the script returns a non-zero value, Patroni assumes that the bootstrap has failed, cleans up after itself and releases the initialize lock to give another node the opportunity to bootstrap. If a ``recovery_conf`` block is defined in the same section as the custom bootstrap method, Patroni will generate a -``recovery.conf`` before starting the newly bootstrapped instance. Typically, such recovery.conf should contain at least -one of the ``recovery_target_*`` parameters, together with the ``recovery_target_timeline`` set to ``promote``. +``recovery.conf`` before starting the newly bootstrapped instance (or set the recovery settings on Postgres configuration if +running PostgreSQL >= 12). +Typically, such recovery configuration should contain at least one of the ``recovery_target_*`` parameters, together with the ``recovery_target_timeline`` set to ``promote``. -If ``keep_existing_recovery_conf`` is defined and set to ``True``, Patroni will not remove the existing ``recovery.conf`` file if it exists. -This is useful when bootstrapping from a backup with tools like pgBackRest that generate the appropriate ``recovery.conf`` for you. +If ``keep_existing_recovery_conf`` is defined and set to ``True``, Patroni will not remove the existing ``recovery.conf`` file if it exists (PostgreSQL <= 11). +Similarly, in that case Patroni will not remove the existing ``recovery.signal`` or ``standby.signal`` if either exists, nor will it override the configured recovery settings (PostgreSQL >= 12). +This is useful when bootstrapping from a backup with tools like pgBackRest that generate the appropriate recovery configuration for you. + +Besides that, any additional key/value pairs informed in the custom bootstrap method configuration will be passed as arguments to ``command`` in the format ``--name=value``. For example: + +.. code:: YAML + + bootstrap: + method: + : + command: + arg1: value1 + arg2: value2 + +Makes the configured ``command`` to be called additionally with ``--arg1=value1 --arg2=value2`` command-line arguments. .. note:: Bootstrap methods are neither chained, nor fallen-back to the default one in case the primary one fails +As an example, you are able to bootstrap a fresh Patroni cluster from a Barman backup with a configuration like this: + +.. code:: YAML + + bootstrap: + method: barman + barman: + keep_existing_recovery_conf: true + command: patroni_barman_recover + api-url: https://barman-host:7480 + barman-server: my_server + ssh-command: ssh postgres@patroni-host + +.. note:: + ``patroni_barman_recover`` requires that you have both Barman and ``pg-backup-api`` configured in the Barman host, so it can execute a remote ``barman recover`` through the backup API. + The above example uses a subset of the available parameters. You can get more information running ``patroni_barman_recover --help``. .. _custom_replica_creation: @@ -110,6 +141,25 @@ example: pgbackrest basebackup: max-rate: '100M' +example: Barman + +.. code:: YAML + + postgresql: + create_replica_methods: + - barman + - basebackup + barman: + command: patroni_barman_recover + api-url: https://barman-host:7480 + barman-server: my_server + ssh-command: ssh postgres@patroni-host + basebackup: + max-rate: '100M' + +.. note:: + ``patroni_barman_recover`` requires that you have both Barman and ``pg-backup-api`` configured in the Barman host, so it can execute a remote ``barman recover`` through the backup API. + The above example uses a subset of the available parameters. You can get more information running ``patroni_barman_recover --help``. The ``create_replica_methods`` defines available replica creation methods and the order of executing them. Patroni will stop on the first one that returns 0. Each method should define a separate section in the configuration file, listing the command @@ -191,7 +241,7 @@ There is no further relationship between the standby cluster and the primary cluster it replicates from, in particular, they must not share the same DCS scope if they use the same DCS. They do not know anything else from each other apart from replication information. Also, the standby cluster is not being -displayed in ``patronictl list`` or ``patronictl topology`` output on the +displayed in :ref:`patronictl_list` or :ref:`patronictl_topology` output on the primary cluster. For the sake of flexibility, you can specify methods of creating a replica and diff --git a/docs/rest_api.rst b/docs/rest_api.rst index e49f6ee0..a03c05d2 100644 --- a/docs/rest_api.rst +++ b/docs/rest_api.rst @@ -3,7 +3,7 @@ Patroni REST API ================ -Patroni has a rich REST API, which is used by Patroni itself during the leader race, by the ``patronictl`` tool in order to perform failovers/switchovers/reinitialize/restarts/reloads, by HAProxy or any other kind of load balancer to perform HTTP health checks, and of course could also be used for monitoring. Below you will find the list of Patroni REST API endpoints. +Patroni has a rich REST API, which is used by Patroni itself during the leader race, by the :ref:`patronictl` tool in order to perform failovers/switchovers/reinitialize/restarts/reloads, by HAProxy or any other kind of load balancer to perform HTTP health checks, and of course could also be used for monitoring. Below you will find the list of Patroni REST API endpoints. Health check endpoints ---------------------- @@ -426,6 +426,7 @@ Cluster status endpoints ] ] +.. _config_endpoint: Config endpoint --------------- @@ -560,41 +561,114 @@ The above call removes ``postgresql.parameters.max_connections`` from the dynami Switchover and failover endpoints --------------------------------- -``POST /switchover`` or ``POST /failover``. These endpoints are very similar to each other. There are a couple of minor differences though: +.. _switchover_api: -1. The failover endpoint allows to perform a manual failover when there are no healthy nodes, but at the same time it will not allow you to schedule a switchover. +Switchover +^^^^^^^^^^ -2. The switchover endpoint is the opposite. It works only when the cluster is healthy (there is a leader) and allows to schedule a switchover at a given time. +``/switchover`` endpoint only works when the cluster is healthy (there is a leader). It also allows to schedule a switchover at a given time. +When calling ``/switchover`` endpoint a candidate can be specified but is not required, in contrast to ``/failover`` endpoint. If a candidate is not provided, all the eligible nodes of the cluster will participate in the leader race after the leader stepped down. -In the JSON body of the ``POST`` request you must specify at least the ``leader`` or ``candidate`` fields and optionally the ``scheduled_at`` field if you want to schedule a switchover at a specific time. +In the JSON body of the ``POST`` request you must specify the ``leader`` field. The ``candidate`` and the ``scheduled_at`` fields are optional and can be used to schedule a switchover at a specific time. +Depending on the situation, requests might return different HTTP status codes and bodies. Status code **200** is returned when the switchover or failover successfully completed. If the switchover was successfully scheduled, Patroni will return HTTP status code **202**. In case something went wrong, the error status code (one of **400**, **412**, or **503**) will be returned with some details in the response body. -Example: perform a failover to the specific node: +``DELETE /switchover`` can be used to delete the currently scheduled switchover. + +**Example:** perform a switchover to any healthy standby .. code-block:: bash - $ curl -s http://localhost:8009/failover -XPOST -d '{"candidate":"postgresql1"}' - Successfully failed over to "postgresql1" + $ curl -s http://localhost:8008/switchover -XPOST -d '{"leader":"postgresql1"}' + Successfully switched over to "postgresql2" -Example: schedule a switchover from the leader to any other healthy replica in the cluster at a specific time: +**Example:** perform a switchover to a specific node .. code-block:: bash - $ curl -s http://localhost:8008/switchover -XPOST -d \ - '{"leader":"postgresql0","scheduled_at":"2019-09-24T12:00+00"}' - Switchover scheduled + $ curl -s http://localhost:8008/switchover -XPOST -d \ + '{"leader":"postgresql1","candidate":"postgresql2"}' + Successfully switched over to "postgresql2" -Depending on the situation the request might finish with a different HTTP status code and body. The status code **200** is returned when the switchover or failover successfully completed. If the switchover was successfully scheduled, Patroni will return HTTP status code **202**. In case something went wrong, the error status code (one of **400**, **412** or **503**) will be returned with some details in the response body. For more information please check the source code of ``patroni/api.py:do_POST_failover()`` method. +**Example:** schedule a switchover from the leader to any other healthy standby in the cluster at a specific time. -- ``DELETE /switchover``: delete the scheduled switchover +.. code-block:: bash -The ``POST /switchover`` and ``POST failover`` endpoints are used by ``patronictl switchover`` and ``patronictl failover``, respectively. -The ``DELETE /switchover`` is used by ``patronictl flush switchover``. + $ curl -s http://localhost:8008/switchover -XPOST -d \ + '{"leader":"postgresql0","scheduled_at":"2019-09-24T12:00+00"}' + Switchover scheduled +Failover +^^^^^^^^ + +``/failover`` endpoint can be used to perform a manual failover when there are no healthy nodes (e.g. to an asynchronous standby if all synchronous standbys are not healthy enough to promote). However there is no requirement for a cluster not to have leader - failover can also be run on a healthy cluster. + +In the JSON body of the ``POST`` request you must specify the ``candidate`` field. If the ``leader`` field is specified, a switchover is triggered instead. + +**Example:** + +.. code-block:: bash + + $ curl -s http://localhost:8008/failover -XPOST -d '{"candidate":"postgresql1"}' + Successfully failed over to "postgresql1" + +.. warning:: + :ref:`Be very careful ` when using this endpoint, as this can cause data loss in certain situations. In most cases, :ref:`the switchover endpoint ` satisfies the administrator's needs. + + +``POST /switchover`` and ``POST /failover`` endpoints are used by :ref:`patronictl_switchover` and :ref:`patronictl_failover`, respectively. + +``DELETE /switchover`` is used by :ref:`patronictl flush cluster-name switchover `. + +.. list-table:: Failover/Switchover comparison + :widths: 25 25 25 + :header-rows: 1 + + * - + - Failover + - Switchover + * - Requires leader specified + - no + - yes + * - Requires candidate specified + - yes + - no + * - Can be run in pause + - yes + - yes (only to a specific candidate) + * - Can be scheduled + - no + - yes (if not in pause) + +.. _failover_healthcheck: + +Healthy standby +^^^^^^^^^^^^^^^ + +There are a couple of checks that a member of a cluster should pass to be able to participate in the leader race during a switchover or to become a leader as a failover/switchover candidate: + +- be reachable via Patroni API; +- not have ``nofailover`` tag set to ``true``; +- have watchdog fully functional (if required by the configuration); +- in case of a switchover in a healthy cluster or an automatic failover, not exceed maximum replication lag (``maximum_lag_on_failover`` :ref:`configuration parameter `); +- in case of a switchover in a healthy cluster or an automatic failover, not have a timeline number smaller than the cluster timeline if ``check_timeline`` :ref:`configuration parameter ` is set to ``true``; +- in :ref:`synchronous mode `: + + - In case of a switchover (both with and without a candidate): be listed in the ``/sync`` key members; + - For a failover in both healthy and unhealthy clusters, this check is omitted. + +.. warning:: + In case of a manual failover in a cluster without a leader, a candidate will be allowed to promote even if: + - it is not in the ``/sync`` key members when synchronous mode is enabled; + - its lag exceeds the maximum replication lag allowed; + - it has the timeline number smaller than the last known cluster timeline. + +.. _restart_endpoint: + Restart endpoint ---------------- @@ -608,15 +682,16 @@ Restart endpoint - ``DELETE /restart``: delete the scheduled restart -``POST /restart`` and ``DELETE /restart`` endpoints are used by ``patronictl restart`` and ``patronictl flush restart`` respectively. +``POST /restart`` and ``DELETE /restart`` endpoints are used by :ref:`patronictl_restart` and :ref:`patronictl flush cluster-name restart ` respectively. +.. _reload_endpoint: Reload endpoint --------------- -The ``POST /reload`` call will order Patroni to re-read and apply the configuration file. This is the equivalent of sending the ``SIGHUP`` signal to the Patroni process. In case you changed some of the Postgres parameters which require a restart (like **shared_buffers**), you still have to explicitly do the restart of Postgres by either calling the ``POST /restart`` endpoint or with the help of ``patronictl restart``. +The ``POST /reload`` call will order Patroni to re-read and apply the configuration file. This is the equivalent of sending the ``SIGHUP`` signal to the Patroni process. In case you changed some of the Postgres parameters which require a restart (like **shared_buffers**), you still have to explicitly do the restart of Postgres by either calling the ``POST /restart`` endpoint or with the help of :ref:`patronictl_restart`. -The reload endpoint is used by ``patronictl reload``. +The reload endpoint is used by :ref:`patronictl_reload`. Reinitialize endpoint @@ -626,4 +701,4 @@ Reinitialize endpoint The call might fail if Patroni is in a loop trying to recover (restart) a failed Postgres. In order to overcome this problem one can specify ``{"force":true}`` in the request body. -The reinitialize endpoint is used by ``patronictl reinit``. +The reinitialize endpoint is used by :ref:`patronictl_reinit`. diff --git a/docs/security.rst b/docs/security.rst index cddefe0c..24af168e 100644 --- a/docs/security.rst +++ b/docs/security.rst @@ -9,7 +9,7 @@ A Patroni cluster has two interfaces to be protected from unauthorized access: t Protecting DCS ============== -Patroni and patronictl both store and retrieve data to/from the DCS. +Patroni and :ref:`patronictl` both store and retrieve data to/from the DCS. Despite DCS doesn't contain any sensitive information, it allows changing some of Patroni/Postgres configuration. Therefore the very first thing that should be protected is DCS itself. @@ -22,7 +22,7 @@ Protecting the REST API Protecting the REST API is a more complicated task. -The Patroni REST API is used by Patroni itself during the leader race, by the ``patronictl`` tool in order to perform failovers/switchovers/reinitialize/restarts/reloads, by HAProxy or any other kind of load balancer to perform HTTP health checks, and of course could also be used for monitoring. +The Patroni REST API is used by Patroni itself during the leader race, by the :ref:`patronictl` tool in order to perform failovers/switchovers/reinitialize/restarts/reloads, by HAProxy or any other kind of load balancer to perform HTTP health checks, and of course could also be used for monitoring. From the point of view of security, REST API contains safe (``GET`` requests, only retrieve information) and unsafe (``PUT``, ``POST``, ``PATCH`` and ``DELETE`` requests, change the state of nodes) endpoints. @@ -32,6 +32,6 @@ When TLS for the REST API is enabled and a PKI is established, mutual authentica The ``restapi`` section parameters enable TLS client authentication to the server. Depending on the value of the ``verify_client`` parameter, the API server requires a successful client certificate verification for both safe and unsafe API calls (``verify_client: required``), or only for unsafe API calls (``verify_client: optional``), or for no API calls (``verify_client: none``). -The ``ctl`` section parameters enable TLS server authentication to the client (the ``patronictl`` tool which uses the same config as patroni). Set ``insecure: true`` to disable the server certificate verification by the client. See :ref:`settings ` for a detailed description of the TLS client parameters. +The ``ctl`` section parameters enable TLS server authentication to the client (the :ref:`patronictl` tool which uses the same config as patroni). Set ``insecure: true`` to disable the server certificate verification by the client. See :ref:`settings ` for a detailed description of the TLS client parameters. Protecting the PostgreSQL database proper from unauthorized access is beyond the scope of this document and is covered in https://www.postgresql.org/docs/current/client-authentication.html diff --git a/docs/yaml_configuration.rst b/docs/yaml_configuration.rst index d9283192..a4a33134 100644 --- a/docs/yaml_configuration.rst +++ b/docs/yaml_configuration.rst @@ -34,7 +34,7 @@ Bootstrap configuration .. note:: Once Patroni has initialized the cluster for the first time and settings have been stored in the DCS, all future changes to the ``bootstrap.dcs`` section of the YAML configuration will not take any effect! If you want to change - them please use either ``patronictl edit-config`` or the Patroni :ref:`REST API `. + them please use either :ref:`patronictl_edit_config` or the Patroni :ref:`REST API `. - **bootstrap**: @@ -49,24 +49,8 @@ Bootstrap configuration - **- data-checksums**: Must be enabled when pg_rewind is needed on 9.3. - **- encoding: UTF8**: default encoding for new databases. - **- locale: UTF8**: default locale for new databases. - - **users**: Some additional users which need to be created after initializing new cluster, see :ref:`Bootstrap users configuration ` below. - **post\_bootstrap** or **post\_init**: An additional script that will be executed after initializing the cluster. The script receives a connection string URL (with the cluster superuser as a user name). The PGPASSFILE variable is set to the location of pgpass file. -.. _bootstrap_users_configuration: - -Bootstrap users configuration -============================= - -Users which need to be created after initializing the cluster: - -- **admin**: the name of user - - - **password**: (optional) password for the user - - **options**: list of options for CREATE USER statement - - - **- createrole** - - **- createdb** - .. _citus_settings: Citus @@ -149,6 +133,7 @@ ZooKeeper - **key_password**: (optional) The client key password. - **verify**: (optional) Whether to verify certificate or not. Defaults to ``true``. - **set_acls**: (optional) If set, configure Kazoo to apply a default ACL to each ZNode that it creates. ACLs will assume 'x509' schema and should be specified as a dictionary with the principal as the key and one or more permissions as a list in the value. Permissions may be one of ``CREATE``, ``READ``, ``WRITE``, ``DELETE`` or ``ADMIN``. For example, ``set_acls: {CN=principal1: [CREATE, READ], CN=principal2: [ALL]}``. +- **auth_data**: (optional) Authentication credentials to use for the connection. Should be a dictionary in the form that `scheme` is the key and `credential` is the value. Defaults to empty dictionary. .. note:: It is required to install ``kazoo>=2.6.0`` to support SSL. @@ -366,10 +351,10 @@ CTL - **authentication**: - - **username**: Basic-auth username for accessing protected REST API endpoints. If not provided patronictl will use the value provided for REST API "username" parameter. - - **password**: Basic-auth password for accessing protected REST API endpoints. If not provided patronictl will use the value provided for REST API "password" parameter. + - **username**: Basic-auth username for accessing protected REST API endpoints. If not provided :ref:`patronictl` will use the value provided for REST API "username" parameter. + - **password**: Basic-auth password for accessing protected REST API endpoints. If not provided :ref:`patronictl` will use the value provided for REST API "password" parameter. - **insecure**: Allow connections to REST API without verifying SSL certs. - - **cacert**: Specifies the file with the CA_BUNDLE file or directory with certificates of trusted CAs to use while verifying REST API SSL certs. If not provided patronictl will use the value provided for REST API "cafile" parameter. + - **cacert**: Specifies the file with the CA_BUNDLE file or directory with certificates of trusted CAs to use while verifying REST API SSL certs. If not provided :ref:`patronictl` will use the value provided for REST API "cafile" parameter. - **certfile**: Specifies the file with the client certificate in the PEM format. - **keyfile**: Specifies the file with the client secret key in the PEM format. - **keyfile\_password**: Specifies a password for decrypting the client keyfile. @@ -384,11 +369,15 @@ Watchdog Tags ---- -- **nofailover**: ``true`` or ``false``, controls whether this node is allowed to participate in the leader race and become a leader. Defaults to ``false`` - **clonefrom**: ``true`` or ``false``. If set to ``true`` other nodes might prefer to use this node for bootstrap (take ``pg_basebackup`` from). If there are several nodes with ``clonefrom`` tag set to ``true`` the node to bootstrap from will be chosen randomly. The default value is ``false``. - **noloadbalance**: ``true`` or ``false``. If set to ``true`` the node will return HTTP Status Code 503 for the ``GET /replica`` REST API health-check and therefore will be excluded from the load-balancing. Defaults to ``false``. - **replicatefrom**: The IP address/hostname of another replica. Used to support cascading replication. - **nosync**: ``true`` or ``false``. If set to ``true`` the node will never be selected as a synchronous replica. +- **nofailover**: ``true`` or ``false``, controls whether this node is allowed to participate in the leader race and become a leader. Defaults to ``false``, meaning this node _can_ participate in leader races. +- **failover_priority**: integer, controls the priority that this node should have during failover. Nodes with higher priority will be preferred over lower priority nodes if they received/replayed the same amount of WAL. However, nodes with higher values of receive/replay LSN are preferred regardless of their priority. If the ``failover_priority`` is 0 or negative - such node is not allowed to participate in the leader race and to become a leader (similar to ``nofailover: true``). + +.. warning:: + Provide only one of ``nofailover`` or ``failover_priority``. Providing ``nofailover: true`` is the same as ``failover_priority: 0``, and providing ``nofailover: false`` will give the node priority 1. In addition to these predefined tags, you can also add your own ones: @@ -397,4 +386,4 @@ In addition to these predefined tags, you can also add your own ones: - **key3**: ``1.4`` - **key4**: ``"RandomString"`` -Tags are visible in the :ref:`REST API ` and ``patronictl list`` You can also check for an instance health using these tags. If the tag isn't defined for an instance, or if the respective value doesn't match the querying value, it will return HTTP Status Code 503. +Tags are visible in the :ref:`REST API ` and :ref:`patronictl_list` You can also check for an instance health using these tags. If the tag isn't defined for an instance, or if the respective value doesn't match the querying value, it will return HTTP Status Code 503. diff --git a/features/backup_restore.py b/features/backup_restore.py index 5aa973c4..9b926b54 100755 --- a/features/backup_restore.py +++ b/features/backup_restore.py @@ -6,6 +6,7 @@ if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--datadir", required=True) parser.add_argument("--sourcedir", required=True) + parser.add_argument("--test-argument", required=True) args, _ = parser.parse_known_args() shutil.copytree(args.sourcedir, args.datadir) diff --git a/features/basic_replication.feature b/features/basic_replication.feature index b58520bd..552c2954 100644 --- a/features/basic_replication.feature +++ b/features/basic_replication.feature @@ -82,4 +82,4 @@ Feature: basic replication @reject-duplicate-name Scenario: check graceful rejection when two nodes have the same name Given I start duplicate postgres0 on port 8011 - Then there is a "Can't start; there is already a node named 'postgres0' running" CRITICAL in the dup-postgres0 patroni log + Then there is one of ["Can't start; there is already a node named 'postgres0' running"] CRITICAL in the dup-postgres0 patroni log after 5 seconds diff --git a/features/citus.feature b/features/citus.feature index b89ecb3f..16afc34d 100644 --- a/features/citus.feature +++ b/features/citus.feature @@ -75,6 +75,6 @@ Feature: citus And I receive a response output "+ttl: 20" Then postgres4 is registered in the postgres2 as the primary in group 2 after 5 seconds When I shut down postgres4 - Then There is a transaction in progress on postgres0 changing pg_dist_node + Then there is a transaction in progress on postgres0 changing pg_dist_node after 5 seconds When I run patronictl.py restart batman postgres2 --group 1 --force Then a transaction finishes in 20 seconds diff --git a/features/dcs_failsafe_mode.feature b/features/dcs_failsafe_mode.feature index 4a34b754..0489db39 100644 --- a/features/dcs_failsafe_mode.feature +++ b/features/dcs_failsafe_mode.feature @@ -4,14 +4,14 @@ Feature: dcs failsafe mode Scenario: check failsafe mode can be successfully enabled Given I start postgres0 And postgres0 is a leader after 10 seconds - And I sleep for 3 seconds - When I issue a PATCH request to http://127.0.0.1:8008/config with {"loop_wait": 2, "ttl": 20, "retry_timeout": 5, "failsafe_mode": true} + Then "config" key in DCS has ttl=30 after 10 seconds + When I issue a PATCH request to http://127.0.0.1:8008/config with {"loop_wait": 2, "ttl": 20, "retry_timeout": 3, "failsafe_mode": true} Then I receive a response code 200 And Response on GET http://127.0.0.1:8008/failsafe contains postgres0 after 10 seconds When I issue a GET request to http://127.0.0.1:8008/failsafe Then I receive a response code 200 And I receive a response postgres0 http://127.0.0.1:8008/patroni - When I issue a PATCH request to http://127.0.0.1:8008/config with {"postgresql": {"parameters": {"wal_level": "logical"}}} + When I issue a PATCH request to http://127.0.0.1:8008/config with {"postgresql": {"parameters": {"wal_level": "logical"}},"slots":{"dcs_slot_1": null,"postgres0":null}} Then I receive a response code 200 When I issue a PATCH request to http://127.0.0.1:8008/config with {"slots": {"dcs_slot_0": {"type": "logical", "database": "postgres", "plugin": "test_decoding"}}} Then I receive a response code 200 @@ -28,7 +28,6 @@ Feature: dcs failsafe mode When I do a backup of postgres0 And I shut down postgres0 When I start postgres1 in a cluster batman from backup with no_leader - And I sleep for 2 seconds Then postgres1 role is the replica after 12 seconds Scenario: check leader and replica are both in /failsafe key after leader is back @@ -45,41 +44,73 @@ Feature: dcs failsafe mode @dcs-failsafe @slot-advance Scenario: check leader and replica are functioning while DCS is down - Given logical slot dcs_slot_0 is in sync between postgres0 and postgres1 after 10 seconds + Given I get all changes from physical slot dcs_slot_1 on postgres0 + Then physical slot dcs_slot_1 is in sync between postgres0 and postgres1 after 10 seconds + And logical slot dcs_slot_0 is in sync between postgres0 and postgres1 after 10 seconds And DCS is down Then Response on GET http://127.0.0.1:8008/primary contains failsafe_mode_is_active after 12 seconds Then postgres0 role is the primary after 10 seconds And postgres1 role is the replica after 2 seconds And replication works from postgres0 to postgres1 after 10 seconds - And I get all changes from logical slot dcs_slot_0 on postgres0 - And logical slot dcs_slot_0 is in sync between postgres0 and postgres1 after 20 seconds + When I get all changes from logical slot dcs_slot_0 on postgres0 + And I get all changes from physical slot dcs_slot_1 on postgres0 + Then logical slot dcs_slot_0 is in sync between postgres0 and postgres1 after 20 seconds + And physical slot dcs_slot_1 is in sync between postgres0 and postgres1 after 10 seconds @dcs-failsafe Scenario: check primary is demoted when one replica is shut down and DCS is down Given DCS is down And I kill postgres1 And I kill postmaster on postgres1 - And I sleep for 2 seconds Then postgres0 role is the replica after 12 seconds @dcs-failsafe Scenario: check known replica is promoted when leader is down and DCS is up - Given I shut down postgres0 + Given I kill postgres0 + And I shut down postmaster on postgres0 And DCS is up When I start postgres1 Then "members/postgres1" key in DCS has state=running after 10 seconds And postgres1 role is the primary after 25 seconds @dcs-failsafe - Scenario: check three-node cluster is functioning while DCS is down + Scenario: scale to three-node cluster Given I start postgres0 And I start postgres2 Then "members/postgres2" key in DCS has state=running after 10 seconds And "members/postgres0" key in DCS has state=running after 20 seconds And Response on GET http://127.0.0.1:8008/failsafe contains postgres2 after 10 seconds And replication works from postgres1 to postgres0 after 10 seconds + And replication works from postgres1 to postgres2 after 10 seconds + + @dcs-failsafe + @slot-advance + Scenario: make sure permanent slots exist on replicas + Given I issue a PATCH request to http://127.0.0.1:8009/config with {"slots":{"dcs_slot_0":null,"dcs_slot_2":{"type":"logical","database":"postgres","plugin":"test_decoding"}}} + Then logical slot dcs_slot_2 is in sync between postgres1 and postgres0 after 20 seconds + And logical slot dcs_slot_2 is in sync between postgres1 and postgres2 after 20 seconds + When I get all changes from physical slot dcs_slot_1 on postgres1 + Then physical slot dcs_slot_1 is in sync between postgres1 and postgres0 after 10 seconds + And physical slot dcs_slot_1 is in sync between postgres1 and postgres2 after 10 seconds + And physical slot postgres0 is in sync between postgres1 and postgres2 after 10 seconds + + @dcs-failsafe + Scenario: check three-node cluster is functioning while DCS is down Given DCS is down - Then Response on GET http://127.0.0.1:8008/primary contains failsafe_mode_is_active after 12 seconds + Then Response on GET http://127.0.0.1:8009/primary contains failsafe_mode_is_active after 12 seconds Then postgres1 role is the primary after 10 seconds And postgres0 role is the replica after 2 seconds And postgres2 role is the replica after 2 seconds + + @dcs-failsafe + @slot-advance + Scenario: check that permanent slots are in sync between nodes while DCS is down + Given replication works from postgres1 to postgres0 after 10 seconds + And replication works from postgres1 to postgres2 after 10 seconds + When I get all changes from logical slot dcs_slot_2 on postgres1 + And I get all changes from physical slot dcs_slot_1 on postgres1 + Then logical slot dcs_slot_2 is in sync between postgres1 and postgres0 after 20 seconds + And logical slot dcs_slot_2 is in sync between postgres1 and postgres2 after 20 seconds + And physical slot dcs_slot_1 is in sync between postgres1 and postgres0 after 10 seconds + And physical slot dcs_slot_1 is in sync between postgres1 and postgres2 after 10 seconds + And physical slot postgres0 is in sync between postgres1 and postgres2 after 10 seconds diff --git a/features/environment.py b/features/environment.py index e3c21252..a0367657 100644 --- a/features/environment.py +++ b/features/environment.py @@ -162,9 +162,10 @@ class PatroniController(AbstractController): def stop(self, kill=False, timeout=15, postgres=False): if postgres: - return subprocess.call(['pg_ctl', '-D', self._data_dir, 'stop', '-mi', '-w']) + mode = 'i' if kill else 'f' + return subprocess.call(['pg_ctl', '-D', self._data_dir, 'stop', '-m' + mode, '-w']) super(PatroniController, self).stop(kill, timeout) - if isinstance(self._context.dcs_ctl, KubernetesController): + if isinstance(self._context.dcs_ctl, KubernetesController) and not kill: self._context.dcs_ctl.delete_pod(self._name[8:]) if self.watchdog: self.watchdog.stop() @@ -244,6 +245,10 @@ class PatroniController(AbstractController): self.recursive_update(config, custom_config) self.recursive_update(config, { + 'log': { + 'format': '%(asctime)s %(levelname)s [%(pathname)s:%(lineno)d - %(funcName)s]: %(message)s', + 'loggers': {'patroni.postgresql.callback_executor': 'DEBUG'} + }, 'bootstrap': { 'dcs': { 'loop_wait': 2, @@ -649,9 +654,10 @@ class KubernetesController(AbstractExternalDcsController): try: if group is not None: scope = '{0}-{1}'.format(scope, group) - ep = scope + {'leader': '', 'history': '-config', 'initialize': '-config'}.get(key, '-' + key) + rkey = 'leader' if key in ('status', 'failsafe') else key + ep = scope + {'leader': '', 'history': '-config', 'initialize': '-config'}.get(rkey, '-' + rkey) e = self._api.read_namespaced_endpoints(ep, self._namespace) - if key != 'sync': + if key not in ('sync', 'status', 'failsafe'): return e.metadata.annotations[key] else: return json.dumps(e.metadata.annotations) @@ -687,7 +693,7 @@ class ZooKeeperController(AbstractExternalDcsController): self._client = kazoo.client.KazooClient() def process_name(self): - return "zookeeper" + return "java .*zookeeper" def query(self, key, scope='batman', group=None): import kazoo.exceptions @@ -886,22 +892,28 @@ class PatroniPoolController(object): } self.start(to_name, custom_config=custom_config) + def backup_restore_config(self, params=None): + return { + 'command': (self.BACKUP_RESTORE_SCRIPT + + ' --sourcedir=' + os.path.join(self.patroni_path, 'data', 'basebackup')).replace('\\', '/'), + 'test-argument': 'test-value', # test config mapping approach on custom bootstrap/replica creation + **(params or {}), + } + def bootstrap_from_backup(self, name, cluster_name): custom_config = { 'scope': cluster_name, 'bootstrap': { 'method': 'backup_restore', - 'backup_restore': { - 'command': (self.BACKUP_RESTORE_SCRIPT + ' --sourcedir=' - + os.path.join(self.patroni_path, 'data', 'basebackup').replace('\\', '/')), + 'backup_restore': self.backup_restore_config({ 'recovery_conf': { 'recovery_target_action': 'promote', 'recovery_target_timeline': 'latest', 'restore_command': (self.ARCHIVE_RESTORE_SCRIPT + ' --mode restore ' + '--dirname {} --filename %f --pathname %p').format( os.path.join(self.patroni_path, 'data', 'wal_archive_clone').replace('\\', '/')) - } - } + }, + }) }, 'postgresql': { 'authentication': { @@ -922,11 +934,7 @@ class PatroniPoolController(object): .format(os.path.join(self.patroni_path, 'data', 'wal_archive').replace('\\', '/')) }, 'create_replica_methods': ['no_leader_bootstrap'], - 'no_leader_bootstrap': { - 'command': (self.BACKUP_RESTORE_SCRIPT + ' --sourcedir=' - + os.path.join(self.patroni_path, 'data', 'basebackup').replace('\\', '/')), - 'no_leader': '1' - } + 'no_leader_bootstrap': self.backup_restore_config({'no_leader': '1'}) } } self.start(name, custom_config=custom_config) diff --git a/features/ignored_slots.feature b/features/ignored_slots.feature index 08a6dda5..4e83570d 100644 --- a/features/ignored_slots.feature +++ b/features/ignored_slots.feature @@ -25,10 +25,10 @@ Feature: ignored slots # but Patroni can actually end up dropping them almost immediately, so it's helpful # to verify they exist before we begin testing whether they persist through failover # cycles. - Then postgres1 has a logical replication slot named unmanaged_slot_0 with the test_decoding plugin - And postgres1 has a logical replication slot named unmanaged_slot_1 with the test_decoding plugin - And postgres1 has a logical replication slot named unmanaged_slot_2 with the test_decoding plugin - And postgres1 has a logical replication slot named unmanaged_slot_3 with the test_decoding plugin + Then postgres1 has a logical replication slot named unmanaged_slot_0 with the test_decoding plugin after 2 seconds + And postgres1 has a logical replication slot named unmanaged_slot_1 with the test_decoding plugin after 2 seconds + And postgres1 has a logical replication slot named unmanaged_slot_2 with the test_decoding plugin after 2 seconds + And postgres1 has a logical replication slot named unmanaged_slot_3 with the test_decoding plugin after 2 seconds When I start postgres0 Then "members/postgres0" key in DCS has role=replica after 10 seconds @@ -46,16 +46,16 @@ Feature: ignored slots And "members/postgres1" key in DCS has role=replica after 10 seconds # give Patroni time to sync replication slots And I sleep for 2 seconds - And postgres1 has a logical replication slot named unmanaged_slot_0 with the test_decoding plugin - And postgres1 has a logical replication slot named unmanaged_slot_1 with the test_decoding plugin - And postgres1 has a logical replication slot named unmanaged_slot_2 with the test_decoding plugin - And postgres1 has a logical replication slot named unmanaged_slot_3 with the test_decoding plugin - And postgres1 does not have a logical replication slot named dummy_slot + And postgres1 has a logical replication slot named unmanaged_slot_0 with the test_decoding plugin after 2 seconds + And postgres1 has a logical replication slot named unmanaged_slot_1 with the test_decoding plugin after 2 seconds + And postgres1 has a logical replication slot named unmanaged_slot_2 with the test_decoding plugin after 2 seconds + And postgres1 has a logical replication slot named unmanaged_slot_3 with the test_decoding plugin after 2 seconds + And postgres1 does not have a replication slot named dummy_slot # 3. After a failover the server (now a primary) still has the slot. When I shut down postgres0 Then "members/postgres1" key in DCS has role=master after 10 seconds - And postgres1 has a logical replication slot named unmanaged_slot_0 with the test_decoding plugin - And postgres1 has a logical replication slot named unmanaged_slot_1 with the test_decoding plugin - And postgres1 has a logical replication slot named unmanaged_slot_2 with the test_decoding plugin - And postgres1 has a logical replication slot named unmanaged_slot_3 with the test_decoding plugin + And postgres1 has a logical replication slot named unmanaged_slot_0 with the test_decoding plugin after 2 seconds + And postgres1 has a logical replication slot named unmanaged_slot_1 with the test_decoding plugin after 2 seconds + And postgres1 has a logical replication slot named unmanaged_slot_2 with the test_decoding plugin after 2 seconds + And postgres1 has a logical replication slot named unmanaged_slot_3 with the test_decoding plugin after 2 seconds diff --git a/features/patroni_api.feature b/features/patroni_api.feature index 624d3271..ff06f3c9 100644 --- a/features/patroni_api.feature +++ b/features/patroni_api.feature @@ -68,6 +68,7 @@ Scenario: check API requests for the primary-replica pair in the pause mode When I kill postmaster on postgres1 And I issue a GET request to http://127.0.0.1:8009/replica Then I receive a response code 503 + And "members/postgres1" key in DCS has state=stopped after 10 seconds When I run patronictl.py restart batman postgres1 --force Then I receive a response returncode 0 Then replication works from postgres0 to postgres1 after 20 seconds @@ -76,7 +77,7 @@ Scenario: check API requests for the primary-replica pair in the pause mode Then I receive a response code 200 And I receive a response state running And I receive a response role replica - When I run patronictl.py reinit batman postgres1 --force + When I run patronictl.py reinit batman postgres1 --force --wait Then I receive a response returncode 0 And I receive a response output "Success: reinitialize for member postgres1" And postgres1 role is the secondary after 30 seconds diff --git a/features/permanent_slots.feature b/features/permanent_slots.feature new file mode 100644 index 00000000..2928e829 --- /dev/null +++ b/features/permanent_slots.feature @@ -0,0 +1,75 @@ +Feature: permanent slots + Scenario: check that physical permanent slots are created + Given I start postgres0 + Then postgres0 is a leader after 10 seconds + And there is a non empty initialize key in DCS after 15 seconds + When I issue a PATCH request to http://127.0.0.1:8008/config with {"slots":{"test_physical":0,"postgres0":0,"postgres1":0,"postgres3":0},"postgresql":{"parameters":{"wal_level":"logical"}}} + Then I receive a response code 200 + And Response on GET http://127.0.0.1:8008/config contains slots after 10 seconds + When I start postgres1 + And I start postgres2 + And I configure and start postgres3 with a tag replicatefrom postgres2 + Then postgres0 has a physical replication slot named test_physical after 10 seconds + And postgres0 has a physical replication slot named postgres1 after 10 seconds + And postgres0 has a physical replication slot named postgres2 after 10 seconds + And postgres2 has a physical replication slot named postgres3 after 10 seconds + + @slot-advance + Scenario: check that logical permanent slots are created + Given I run patronictl.py restart batman postgres0 --force + And I issue a PATCH request to http://127.0.0.1:8008/config with {"slots":{"test_logical":{"type":"logical","database":"postgres","plugin":"test_decoding"}}} + Then postgres0 has a logical replication slot named test_logical with the test_decoding plugin after 10 seconds + + @slot-advance + Scenario: check that permanent slots are created on replicas + Given postgres1 has a logical replication slot named test_logical with the test_decoding plugin after 10 seconds + Then Logical slot test_logical is in sync between postgres0 and postgres1 after 10 seconds + And Logical slot test_logical is in sync between postgres0 and postgres2 after 10 seconds + And Logical slot test_logical is in sync between postgres0 and postgres3 after 10 seconds + And postgres1 has a physical replication slot named test_physical after 2 seconds + And postgres2 has a physical replication slot named test_physical after 2 seconds + And postgres3 has a physical replication slot named test_physical after 2 seconds + + @slot-advance + Scenario: check permanent physical slots that match with member names + Given postgres0 has a physical replication slot named postgres3 after 2 seconds + And postgres1 has a physical replication slot named postgres0 after 2 seconds + And postgres1 has a physical replication slot named postgres3 after 2 seconds + And postgres2 has a physical replication slot named postgres0 after 2 seconds + And postgres2 has a physical replication slot named postgres3 after 2 seconds + And postgres2 has a physical replication slot named postgres1 after 2 seconds + And postgres1 does not have a replication slot named postgres2 + And postgres3 does not have a replication slot named postgres2 + + @slot-advance + Scenario: check that permanent slots are advanced on replicas + Given I add the table replicate_me to postgres0 + When I get all changes from logical slot test_logical on postgres0 + And I get all changes from physical slot test_physical on postgres0 + Then Logical slot test_logical is in sync between postgres0 and postgres1 after 10 seconds + And Physical slot test_physical is in sync between postgres0 and postgres1 after 10 seconds + And Logical slot test_logical is in sync between postgres0 and postgres2 after 10 seconds + And Physical slot test_physical is in sync between postgres0 and postgres2 after 10 seconds + And Logical slot test_logical is in sync between postgres0 and postgres3 after 10 seconds + And Physical slot test_physical is in sync between postgres0 and postgres3 after 10 seconds + And Physical slot postgres1 is in sync between postgres0 and postgres2 after 10 seconds + And Physical slot postgres3 is in sync between postgres2 and postgres0 after 20 seconds + And Physical slot postgres3 is in sync between postgres2 and postgres1 after 10 seconds + And postgres1 does not have a replication slot named postgres2 + And postgres3 does not have a replication slot named postgres2 + + @slot-advance + Scenario: check that only permanent slots are written to the /status key + Given "status" key in DCS has test_physical in slots + And "status" key in DCS has postgres0 in slots + And "status" key in DCS has postgres1 in slots + And "status" key in DCS does not have postgres2 in slots + And "status" key in DCS has postgres3 in slots + + Scenario: check permanent physical replication slot after failover + Given I shut down postgres3 + And I shut down postgres2 + And I shut down postgres0 + Then postgres1 has a physical replication slot named test_physical after 10 seconds + And postgres1 has a physical replication slot named postgres0 after 10 seconds + And postgres1 has a physical replication slot named postgres3 after 10 seconds diff --git a/features/priority_failover.feature b/features/priority_failover.feature new file mode 100644 index 00000000..b33dd045 --- /dev/null +++ b/features/priority_failover.feature @@ -0,0 +1,23 @@ +Feature: priority replication + We should check that we can give nodes priority during failover + + Scenario: check failover priority 0 prevents leaderships + Given I configure and start postgres0 with a tag failover_priority 1 + And I configure and start postgres1 with a tag failover_priority 0 + Then replication works from postgres0 to postgres1 after 20 seconds + When I shut down postgres0 + And I sleep for 5 seconds + Then postgres1 role is the secondary after 10 seconds + And there is one of ["following a different leader because I am not allowed to promote"] INFO in the postgres1 patroni log after 5 seconds + Given I start postgres0 + Then postgres0 role is the primary after 10 seconds + + Scenario: check higher failover priority is respected + Given I configure and start postgres2 with a tag failover_priority 1 + And I configure and start postgres3 with a tag failover_priority 2 + Then replication works from postgres0 to postgres2 after 20 seconds + And replication works from postgres0 to postgres3 after 20 seconds + When I shut down postgres0 + And I sleep for 5 seconds + Then postgres3 role is the primary after 10 seconds + And there is one of ["postgres3 has equally tolerable WAL position and priority 2, while this node has priority 1","Wal position of postgres3 is ahead of my wal position"] INFO in the postgres2 patroni log after 5 seconds diff --git a/features/recovery.feature b/features/recovery.feature index 809f7fb9..7839e26f 100644 --- a/features/recovery.feature +++ b/features/recovery.feature @@ -14,6 +14,8 @@ Feature: recovery Then I receive a response code 200 And I receive a response role master And I receive a response timeline 1 + And "members/postgres0" key in DCS has state=running after 12 seconds + And replication works from postgres0 to postgres1 after 15 seconds Scenario: check immediate failover when master_start_timeout=0 Given I issue a PATCH request to http://127.0.0.1:8008/config with {"master_start_timeout": 0} diff --git a/features/standby_cluster.feature b/features/standby_cluster.feature index 850c7970..97c27203 100644 --- a/features/standby_cluster.feature +++ b/features/standby_cluster.feature @@ -22,9 +22,6 @@ Feature: standby cluster Scenario: check permanent logical slots are synced to the replica Given I run patronictl.py restart batman postgres1 --force Then Logical slot test_logical is in sync between postgres0 and postgres1 after 10 seconds - When I add the table replicate_me to postgres1 - And I get all changes from logical slot test_logical on postgres1 - Then Logical slot test_logical is in sync between postgres0 and postgres1 after 10 seconds Scenario: Detach exiting node from the cluster When I shut down postgres1 @@ -54,17 +51,21 @@ Feature: standby cluster When I issue a GET request to http://127.0.0.1:8010/patroni Then I receive a response code 200 And I receive a response replication_state streaming - And postgres1 does not have a logical replication slot named test_logical + And postgres1 does not have a replication slot named test_logical + + Scenario: check switchover + Given I run patronictl.py switchover batman1 --force + Then Status code on GET http://127.0.0.1:8010/standby_leader is 200 after 10 seconds + And postgres1 is replicating from postgres2 after 32 seconds + And there is a postgres2_cb.log with "on_start replica batman1\non_role_change standby_leader batman1" in postgres2 data directory Scenario: check failover - When I kill postgres1 - And I kill postmaster on postgres1 - Then postgres2 is replicating from postgres0 after 32 seconds - When I issue a GET request to http://127.0.0.1:8010/primary + When I kill postgres2 + And I kill postmaster on postgres2 + Then postgres1 is replicating from postgres0 after 32 seconds + And Status code on GET http://127.0.0.1:8009/standby_leader is 200 after 10 seconds + When I issue a GET request to http://127.0.0.1:8009/primary Then I receive a response code 503 - And I sleep for 3 seconds - When I issue a GET request to http://127.0.0.1:8010/standby_leader - Then I receive a response code 200 And I receive a response role standby_leader - And replication works from postgres0 to postgres2 after 15 seconds - And there is a postgres2_cb.log with "on_start replica batman1\non_role_change standby_leader batman1" in postgres2 data directory + And replication works from postgres0 to postgres1 after 15 seconds + And there is a postgres1_cb.log with "on_role_change replica batman1\non_role_change standby_leader batman1" in postgres1 data directory diff --git a/features/steps/basic_replication.py b/features/steps/basic_replication.py index 6718cb01..d70c6d0e 100644 --- a/features/steps/basic_replication.py +++ b/features/steps/basic_replication.py @@ -1,3 +1,4 @@ +import json import patroni.psycopg as pg from behave import step, then @@ -35,11 +36,16 @@ def kill_patroni(context, name): return context.pctl.stop(name, kill=True) -@step('I kill postmaster on {name:w}') +@step('I shut down postmaster on {name:w}') def stop_postgres(context, name): return context.pctl.stop(name, postgres=True) +@step('I kill postmaster on {name:w}') +def kill_postgres(context, name): + return context.pctl.stop(name, kill=True, postgres=True) + + @step('I add the table {table_name:w} to {pg_name:w}') def add_table(context, table_name, pg_name): # parse the configuration file and get the port @@ -105,11 +111,18 @@ def replication_works(context, primary, replica, time_limit): context.execute_steps(u""" When I add the table test_{0} to {1} Then table test_{0} is present on {2} after {3} seconds - """.format(int(time()), primary, replica, time_limit)) + """.format(str(time()).replace('.', '_').replace(',', '_'), primary, replica, time_limit)) -@then('there is a "{message}" {level:w} in the {node} patroni log') -def check_patroni_log(context, message, level, node): - messsages_of_level = context.pctl.read_patroni_log(node, level) - assert any(message in line for line in messsages_of_level), \ - "There was no {0} {1} in the {2} patroni log".format(message, level, node) +@then('there is one of {message_list} {level:w} in the {node} patroni log after {timeout:d} seconds') +def check_patroni_log(context, message_list, level, node, timeout): + timeout *= context.timeout_multiplier + message_list = json.loads(message_list) + + for _ in range(int(timeout)): + messsages_of_level = context.pctl.read_patroni_log(node, level) + if any(any(message in line for line in messsages_of_level) for message in message_list): + break + time.sleep(1) + else: + assert False, f"There were none of {message_list} {level} in the {node} patroni log after {timeout} seconds" diff --git a/features/steps/cascading_replication.py b/features/steps/cascading_replication.py index 9783fbae..c6b43f31 100644 --- a/features/steps/cascading_replication.py +++ b/features/steps/cascading_replication.py @@ -28,7 +28,7 @@ def check_member(context, name, key, value, time_limit): while time.time() < max_time: try: response = json.loads(context.dcs_ctl.query(name)) - dcs_value = response.get(key) + dcs_value = str(response.get(key)) if dcs_value == value: return except Exception: diff --git a/features/steps/citus.py b/features/steps/citus.py index 644219c7..7277cccc 100644 --- a/features/steps/citus.py +++ b/features/steps/citus.py @@ -115,15 +115,21 @@ def count_rows(context, name): assert rows == context.insert_counter, "Distributed table doesn't have expected amount of rows" -@step("There is a transaction in progress on {name:w} changing pg_dist_node") -def check_transaction(context, name): - cur = context.pctl.query(name, "SELECT xact_start FROM pg_stat_activity WHERE pid <> pg_backend_pid()" - " AND state = 'idle in transaction' AND query ~ 'citus_update_node'") - assert cur.rowcount == 1, "There is no idle in transaction updating pg_dist_node" - context.xact_start = cur.fetchone()[0] +@step("there is a transaction in progress on {name:w} changing pg_dist_node after {time_limit:d} seconds") +def check_transaction(context, name, time_limit): + time_limit *= context.timeout_multiplier + max_time = time.time() + int(time_limit) + while time.time() < max_time: + cur = context.pctl.query(name, "SELECT xact_start FROM pg_stat_activity WHERE pid <> pg_backend_pid()" + " AND state = 'idle in transaction' AND query ~ 'citus_update_node'") + if cur.rowcount == 1: + context.xact_start = cur.fetchone()[0] + return + time.sleep(1) + assert False, f"There is no idle in transaction on {name} updating pg_dist_node after {time_limit} seconds" @step("a transaction finishes in {timeout:d} seconds") def check_transaction_timeout(context, timeout): - assert (datetime.now(tzutc) - context.xact_start).seconds > timeout, \ + assert (datetime.now(tzutc) - context.xact_start).seconds >= timeout, \ "a transaction finished earlier than in {0} seconds".format(timeout) diff --git a/features/steps/slots.py b/features/steps/slots.py index 8a742b1c..182aa87c 100644 --- a/features/steps/slots.py +++ b/features/steps/slots.py @@ -1,3 +1,4 @@ +import json import time from behave import step, then @@ -15,21 +16,30 @@ def create_logical_replication_slot(context, slot_name, pg_name, plugin): assert False, "Error creating slot {0} on {1} with plugin {2}".format(slot_name, pg_name, plugin) -@then('{pg_name:w} has a logical replication slot named {slot_name} with the {plugin:w} plugin') -def has_logical_replication_slot(context, pg_name, slot_name, plugin): - try: - row = context.pctl.query(pg_name, ("SELECT slot_type, plugin FROM pg_replication_slots" - " WHERE slot_name = '{0}'").format(slot_name)).fetchone() - assert row, "Couldn't find replication slot named {0}".format(slot_name) - assert row[0] == "logical", "Found replication slot named {0} but wasn't a logical slot".format(slot_name) - assert row[1] == plugin, ("Found replication slot named {0} but was using plugin " - "{1} rather than {2}").format(slot_name, row[1], plugin) - except pg.Error: - assert False, "Error looking for slot {0} on {1} with plugin {2}".format(slot_name, pg_name, plugin) +@step('{pg_name:w} has a logical replication slot named {slot_name}' + ' with the {plugin:w} plugin after {time_limit:d} seconds') +@then('{pg_name:w} has a logical replication slot named {slot_name}' + ' with the {plugin:w} plugin after {time_limit:d} seconds') +def has_logical_replication_slot(context, pg_name, slot_name, plugin, time_limit): + time_limit *= context.timeout_multiplier + max_time = time.time() + int(time_limit) + while time.time() < max_time: + try: + row = context.pctl.query(pg_name, ("SELECT slot_type, plugin FROM pg_replication_slots" + f" WHERE slot_name = '{slot_name}'")).fetchone() + if row: + assert row[0] == "logical", f"Replication slot {slot_name} isn't a logical but {row[0]}" + assert row[1] == plugin, f"Replication slot {slot_name} using plugin {row[1]} rather than {plugin}" + return + except Exception: + pass + time.sleep(1) + assert False, f"Error looking for slot {slot_name} on {pg_name} with plugin {plugin}" -@then('{pg_name:w} does not have a logical replication slot named {slot_name}') -def does_not_have_logical_replication_slot(context, pg_name, slot_name): +@step('{pg_name:w} does not have a replication slot named {slot_name:w}') +@then('{pg_name:w} does not have a replication slot named {slot_name:w}') +def does_not_have_replication_slot(context, pg_name, slot_name): try: row = context.pctl.query(pg_name, ("SELECT 1 FROM pg_replication_slots" " WHERE slot_name = '{0}'").format(slot_name)).fetchone() @@ -38,13 +48,14 @@ def does_not_have_logical_replication_slot(context, pg_name, slot_name): assert False, "Error looking for slot {0} on {1}".format(slot_name, pg_name) -@step('Logical slot {slot_name:w} is in sync between {pg_name1:w} and {pg_name2:w} after {time_limit:d} seconds') -def logical_slots_in_sync(context, slot_name, pg_name1, pg_name2, time_limit): +@step('{slot_type:w} slot {slot_name:w} is in sync between {pg_name1:w} and {pg_name2:w} after {time_limit:d} seconds') +def slots_in_sync(context, slot_type, slot_name, pg_name1, pg_name2, time_limit): time_limit *= context.timeout_multiplier max_time = time.time() + int(time_limit) + column = 'confirmed_flush_lsn' if slot_type.lower() == 'logical' else 'restart_lsn' + query = f"SELECT {column} FROM pg_replication_slots WHERE slot_name = '{slot_name}'" while time.time() < max_time: try: - query = "SELECT confirmed_flush_lsn FROM pg_replication_slots WHERE slot_name = '{0}'".format(slot_name) slot1 = context.pctl.query(pg_name1, query).fetchone() slot2 = context.pctl.query(pg_name2, query).fetchone() if slot1[0] == slot2[0]: @@ -52,9 +63,43 @@ def logical_slots_in_sync(context, slot_name, pg_name1, pg_name2, time_limit): except Exception: pass time.sleep(1) - assert False, "Logical slot {0} is not in sync between {1} and {2}".format(slot_name, pg_name1, pg_name2) + assert False, \ + f"{slot_type} slot {slot_name} is not in sync between {pg_name1} and {pg_name2} after {time_limit} seconds" @step('I get all changes from logical slot {slot_name:w} on {pg_name:w}') def logical_slot_get_changes(context, slot_name, pg_name): context.pctl.query(pg_name, "SELECT * FROM pg_logical_slot_get_changes('{0}', NULL, NULL)".format(slot_name)) + + +@step('I get all changes from physical slot {slot_name:w} on {pg_name:w}') +def physical_slot_get_changes(context, slot_name, pg_name): + context.pctl.query(pg_name, f"SELECT * FROM pg_replication_slot_advance('{slot_name}', pg_current_wal_lsn())") + + +@step('{pg_name:w} has a physical replication slot named {slot_name} after {time_limit:d} seconds') +def has_physical_replication_slot(context, pg_name, slot_name, time_limit): + time_limit *= context.timeout_multiplier + max_time = time.time() + int(time_limit) + query = f"SELECT * FROM pg_catalog.pg_replication_slots WHERE slot_type = 'physical' AND slot_name = '{slot_name}'" + while time.time() < max_time: + try: + row = context.pctl.query(pg_name, query).fetchone() + if row: + return + except Exception: + pass + time.sleep(1) + assert False, f"Physical slot {slot_name} doesn't exist after {time_limit} seconds" + + +@step('"{name}" key in DCS has {subkey:w} in {key:w}') +def dcs_key_contains(context, name, subkey, key): + response = json.loads(context.dcs_ctl.query(name)) + assert key in response and subkey in response[key], f"{name} key in DCS doesn't have {subkey} in {key}" + + +@step('"{name}" key in DCS does not have {subkey:w} in {key:w}') +def dcs_key_does_not_contain(context, name, subkey, key): + response = json.loads(context.dcs_ctl.query(name)) + assert key not in response or subkey not in response[key], f"{name} key in DCS has {subkey} in {key}" diff --git a/features/steps/standby_cluster.py b/features/steps/standby_cluster.py index 17d635b8..1bf3b3fb 100644 --- a/features/steps/standby_cluster.py +++ b/features/steps/standby_cluster.py @@ -15,9 +15,7 @@ def start_patroni(context, name, cluster_name): "scope": cluster_name, "postgresql": { "callbacks": callbacks(context, name), - "backup_restore": { - "command": (context.pctl.PYTHON + " features/backup_restore.py --sourcedir=" - + os.path.join(context.pctl.patroni_path, 'data', 'basebackup').replace('\\', '/'))} + "backup_restore": context.pctl.backup_restore_config() } }) @@ -34,6 +32,7 @@ def start_patroni_standby_cluster(context, name, cluster_name, name2): "ttl": 20, "loop_wait": 2, "retry_timeout": 5, + "synchronous_mode": True, # should be completely ignored "standby_cluster": { "host": "localhost", "port": port, diff --git a/kubernetes/Dockerfile b/kubernetes/Dockerfile index 2fa25f73..29a683bd 100644 --- a/kubernetes/Dockerfile +++ b/kubernetes/Dockerfile @@ -9,8 +9,8 @@ RUN export DEBIAN_FRONTEND=noninteractive \ | xargs apt-get install -y vim-tiny curl jq locales git python3-pip python3-wheel \ ## Make sure we have a en_US.UTF-8 locale available && localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8 \ - && pip3 install setuptools \ - && pip3 install 'git+https://github.com/zalando/patroni.git#egg=patroni[kubernetes]' \ + && pip3 install --break-system-packages setuptools \ + && pip3 install --break-system-packages 'git+https://github.com/zalando/patroni.git#egg=patroni[kubernetes]' \ && PGHOME=/home/postgres \ && mkdir -p $PGHOME \ && chown postgres $PGHOME \ diff --git a/kubernetes/Dockerfile.citus b/kubernetes/Dockerfile.citus index 1ae242bf..f9564521 100644 --- a/kubernetes/Dockerfile.citus +++ b/kubernetes/Dockerfile.citus @@ -10,12 +10,24 @@ RUN export DEBIAN_FRONTEND=noninteractive \ | xargs apt-get install -y busybox vim-tiny curl jq less locales git python3-pip python3-wheel lsb-release \ ## Make sure we have a en_US.UTF-8 locale available && localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8 \ - && echo "deb [signed-by=/etc/apt/trusted.gpg.d/citusdata_community.gpg] https://packagecloud.io/citusdata/community/debian/ $(lsb_release -cs) main" > /etc/apt/sources.list.d/citusdata_community.list \ - && curl -sL https://packagecloud.io/citusdata/community/gpgkey | gpg --dearmor > /etc/apt/trusted.gpg.d/citusdata_community.gpg \ - && apt-get update -y \ - && apt-get -y install postgresql-$PG_MAJOR-citus-11.3 \ - && pip3 install setuptools \ - && pip3 install 'git+https://github.com/zalando/patroni.git#egg=patroni[kubernetes]' \ + && if [ $(dpkg --print-architecture) = 'arm64' ]; then \ + apt-get install -y postgresql-server-dev-15 \ + gcc make autoconf \ + libc6-dev flex libcurl4-gnutls-dev \ + libicu-dev libkrb5-dev liblz4-dev \ + libpam0g-dev libreadline-dev libselinux1-dev\ + libssl-dev libxslt1-dev libzstd-dev uuid-dev \ + && git clone -b "main" https://github.com/citusdata/citus.git \ + && MAKEFLAGS="-j $(grep -c ^processor /proc/cpuinfo)" \ + && cd citus && ./configure && make install && cd ../ && rm -rf /citus; \ + else \ + echo "deb [signed-by=/etc/apt/trusted.gpg.d/citusdata_community.gpg] https://packagecloud.io/citusdata/community/debian/ $(lsb_release -cs) main" > /etc/apt/sources.list.d/citusdata_community.list \ + && curl -sL https://packagecloud.io/citusdata/community/gpgkey | gpg --dearmor > /etc/apt/trusted.gpg.d/citusdata_community.gpg \ + && apt-get update -y \ + && apt-get -y install postgresql-15-citus-12.0; \ + fi \ + && pip3 install --break-system-packages setuptools \ + && pip3 install --break-system-packages 'git+https://github.com/zalando/patroni.git#egg=patroni[kubernetes]' \ && PGHOME=/home/postgres \ && mkdir -p $PGHOME \ && chown postgres $PGHOME \ @@ -26,6 +38,9 @@ RUN export DEBIAN_FRONTEND=noninteractive \ && chmod 664 /etc/passwd \ # Clean up && apt-get remove -y git python3-pip python3-wheel \ + postgresql-server-dev-15 gcc make autoconf \ + libc6-dev flex libicu-dev libkrb5-dev liblz4-dev \ + libpam0g-dev libreadline-dev libselinux1-dev libssl-dev libxslt1-dev libzstd-dev uuid-dev \ && apt-get autoremove -y \ && apt-get clean -y \ && rm -rf /var/lib/apt/lists/* /root/.cache diff --git a/patroni/__init__.py b/patroni/__init__.py index 7f7035c2..7e67e299 100644 --- a/patroni/__init__.py +++ b/patroni/__init__.py @@ -3,23 +3,14 @@ :var PATRONI_ENV_PREFIX: prefix for Patroni related configuration environment variables. :var KUBERNETES_ENV_PREFIX: prefix for Kubernetes related configuration environment variables. :var MIN_PSYCOPG2: minimum version of :mod:`psycopg2` required by Patroni to work. +:var MIN_PSYCOPG3: minimum version of :mod:`psycopg` required by Patroni to work. """ - -import sys - -from typing import Any, Callable, Iterator, Tuple +from typing import Iterator, Tuple PATRONI_ENV_PREFIX = 'PATRONI_' KUBERNETES_ENV_PREFIX = 'KUBERNETES_' MIN_PSYCOPG2 = (2, 5, 4) - - -def fatal(string: str, *args: Any) -> None: - """Write a fatal message to stderr and exit with code ``1``. - - :param string: message to be written before exiting. - """ - sys.exit('FATAL: ' + string.format(*args)) +MIN_PSYCOPG3 = (3, 0, 0) def parse_version(version: str) -> Tuple[int, ...]: @@ -28,25 +19,25 @@ def parse_version(version: str) -> Tuple[int, ...]: .. note:: Designed for easy comparison of software versions in Python. - :param version: human-readable software version, e.g. ``2.5.4``. + :param version: human-readable software version, e.g. ``2.5.4.dev1 (dt dec pq3 ext lo64)``. :returns: tuple of *version* parts, each part as an integer. :Example: - >>> parse_version('2.5.4') + >>> parse_version('2.5.4.dev1 (dt dec pq3 ext lo64)') (2, 5, 4) """ def _parse_version(version: str) -> Iterator[int]: """Yield each part of a human-readable version string as an integer. - :param version: human-readable software version, e.g. ``2.5.4``. + :param version: human-readable software version, e.g. ``2.5.4.dev1``. :yields: each part of *version* as an integer. :Example: - >>> tuple(_parse_version('2.5.4')) + >>> tuple(_parse_version('2.5.4.dev1')) (2, 5, 4) """ for e in version.split('.'): @@ -55,40 +46,3 @@ def parse_version(version: str) -> Tuple[int, ...]: except ValueError: break return tuple(_parse_version(version.split(' ')[0])) - - -def check_psycopg(_min_psycopg2: Tuple[int, ...] = MIN_PSYCOPG2, - _parse_version: Callable[[str], Tuple[int, ...]] = parse_version) -> None: - """Ensure at least one among :mod:`psycopg2` or :mod:`psycopg` libraries are available in the environment. - - .. note:: - We pass ``MIN_PSYCOPG2`` and :func:`parse_version` as arguments to simplify usage of :func:`check_psycopg` from - the ``setup.py``. - - .. note:: - Patroni chooses :mod:`psycopg2` over :mod:`psycopg`, if possible. - - If nothing meeting the requirements is found, then exit with a fatal message. - - :param _min_psycopg2: minimum required version in case :mod:`psycopg2` is chosen. - :param _parse_version: function used to parse :mod:`psycopg2`/:mod:`psycopg` version into a comparable object. - """ - min_psycopg2_str = '.'.join(map(str, _min_psycopg2)) - - # try psycopg2 - try: - from psycopg2 import __version__ - if _parse_version(__version__) >= _min_psycopg2: - return - version_str = __version__.split(' ')[0] - except ImportError: - version_str = None - - # try psycopg3 - try: - from psycopg import __version__ - except ImportError: - error = 'Patroni requires psycopg2>={0}, psycopg2-binary, or psycopg>=3.0'.format(min_psycopg2_str) - if version_str is not None: - error += ', but only psycopg2=={0} is available'.format(version_str) - fatal(error) diff --git a/patroni/__main__.py b/patroni/__main__.py index 2b318a67..229ccfb9 100644 --- a/patroni/__main__.py +++ b/patroni/__main__.py @@ -10,8 +10,9 @@ import sys import time from argparse import Namespace -from typing import Any, Dict, Optional, TYPE_CHECKING +from typing import Any, Dict, List, Optional, TYPE_CHECKING +from patroni import MIN_PSYCOPG2, MIN_PSYCOPG3, parse_version from patroni.daemon import AbstractPatroniDaemon, abstract_main, get_base_arg_parser from patroni.tags import Tags @@ -115,11 +116,14 @@ class Patroni(AbstractPatroniDaemon, Tags): if not isinstance(member, Member): return try: + # Silence annoying WARNING: Retrying (...) messages when Patroni is quickly restarted. + # At this moment we don't have custom log levels configured and hence shouldn't lose anything useful. + self.logger.update_loggers({'urllib3.connectionpool': 'ERROR'}) _ = self.request(member, endpoint="/liveness", timeout=3) logger.fatal("Can't start; there is already a node named '%s' running", self.config['name']) sys.exit(1) except Exception: - return + self.logger.update_loggers({}) def _get_tags(self) -> Dict[str, Any]: """Get tags configured for this node, if any. @@ -281,6 +285,45 @@ def process_arguments() -> Namespace: return args +def check_psycopg() -> None: + """Ensure at least one among :mod:`psycopg2` or :mod:`psycopg` libraries are available in the environment. + + .. note:: + Patroni chooses :mod:`psycopg2` over :mod:`psycopg`, if possible. + + If nothing meeting the requirements is found, then exit with a fatal message. + """ + min_psycopg2_str = '.'.join(map(str, MIN_PSYCOPG2)) + min_psycopg3_str = '.'.join(map(str, MIN_PSYCOPG3)) + + available_versions: List[str] = [] + + # try psycopg2 + try: + from psycopg2 import __version__ + if parse_version(__version__) >= MIN_PSYCOPG2: + return + available_versions.append('psycopg2=={0}'.format(__version__.split(' ')[0])) + except ImportError: + logger.debug('psycopg2 module is not available') + + # try psycopg3 + try: + from psycopg import __version__ + if parse_version(__version__) >= MIN_PSYCOPG3: + return + available_versions.append('psycopg=={0}'.format(__version__.split(' ')[0])) + except ImportError: + logger.debug('psycopg module is not available') + + error = f'FATAL: Patroni requires psycopg2>={min_psycopg2_str}, psycopg2-binary, or psycopg>={min_psycopg3_str}' + if available_versions: + error += ', but only {0} {1} available'.format( + ' and '.join(available_versions), + 'is' if len(available_versions) == 1 else 'are') + sys.exit(error) + + def main() -> None: """Main entrypoint of :mod:`patroni.__main__`. @@ -292,12 +335,10 @@ def main() -> None: ``patroni`` daemon as another process. In that case relevant signals received by the main process and forwarded to ``patroni`` daemon process. """ - from patroni import check_psycopg + check_psycopg() args = process_arguments() - check_psycopg() - if os.getpid() != 1: return patroni_main(args.configfile) diff --git a/patroni/api.py b/patroni/api.py index c5320506..5761d359 100644 --- a/patroni/api.py +++ b/patroni/api.py @@ -26,7 +26,7 @@ from urllib.parse import urlparse, parse_qs from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, TYPE_CHECKING, Union -from . import psycopg +from . import global_config, psycopg from .__main__ import Patroni from .dcs import Cluster from .exceptions import PostgresConnectionException, PostgresException @@ -37,7 +37,7 @@ from .utils import deep_compare, enable_keepalive, parse_bool, patch_config, Ret logger = logging.getLogger(__name__) -def check_access(func: Callable[['RestApiHandler'], None]) -> Callable[..., None]: +def check_access(func: Callable[..., None]) -> Callable[..., None]: """Check the source ip, authorization header, or client certificates. .. note:: @@ -103,7 +103,7 @@ class RestApiHandler(BaseHTTPRequestHandler): if TYPE_CHECKING: # pragma: no cover assert isinstance(server, RestApiServer) super(RestApiHandler, self).__init__(request, client_address, server) - self.server: 'RestApiServer' = server + self.server: 'RestApiServer' = server # pyright: ignore [reportIncompatibleVariableOverride] self.__start_time: float = 0.0 self.path_query: Dict[str, List[str]] = {} @@ -290,7 +290,7 @@ class RestApiHandler(BaseHTTPRequestHandler): patroni = self.server.patroni cluster = patroni.dcs.cluster - global_config = patroni.config.get_global_config(cluster) + config = global_config.from_cluster(cluster) leader_optime = cluster and cluster.last_lsn or 0 replayed_location = response.get('xlog', {}).get('replayed_location', 0) @@ -308,7 +308,7 @@ class RestApiHandler(BaseHTTPRequestHandler): standby_leader_status_code = 200 if response.get('role') == 'standby_leader' else 503 elif patroni.ha.is_leader(): leader_status_code = 200 - if global_config.is_standby_cluster: + if config.is_standby_cluster: primary_status_code = replica_status_code = 503 standby_leader_status_code = 200 if response.get('role') in ('replica', 'standby_leader') else 503 else: @@ -451,10 +451,9 @@ class RestApiHandler(BaseHTTPRequestHandler): Write an HTTP response with JSON content based on the output of :func:`~patroni.utils.cluster_as_json`, with HTTP status ``200`` and the JSON representation of the cluster topology. """ - cluster = self.server.patroni.dcs.get_cluster(True) - global_config = self.server.patroni.config.get_global_config(cluster) + cluster = self.server.patroni.dcs.get_cluster() - response = cluster_as_json(cluster, global_config) + response = cluster_as_json(cluster) response['scope'] = self.server.patroni.postgresql.scope self._write_json_response(200, response) @@ -690,7 +689,7 @@ class RestApiHandler(BaseHTTPRequestHandler): """ request = self._read_json_content() if request: - cluster = self.server.patroni.dcs.get_cluster(True) + cluster = self.server.patroni.dcs.get_cluster() if not (cluster.config and cluster.config.modify_version): return self.send_error(503) data = cluster.config.data.copy() @@ -864,7 +863,7 @@ class RestApiHandler(BaseHTTPRequestHandler): if request: logger.debug("received restart request: {0}".format(request)) - if self.server.patroni.config.get_global_config(cluster).is_paused and 'schedule' in request: + if global_config.from_cluster(cluster).is_paused and 'schedule' in request: self.write_response(status_code, "Can't schedule restart in the paused state") return @@ -1033,7 +1032,7 @@ class RestApiHandler(BaseHTTPRequestHandler): :returns: a string with the error message or ``None`` if good nodes are found. """ - is_synchronous_mode = self.server.patroni.config.get_global_config(cluster).is_synchronous_mode + is_synchronous_mode = global_config.from_cluster(cluster).is_synchronous_mode if leader and (not cluster.leader or cluster.leader.name != leader): return 'leader name does not match' if candidate: @@ -1074,7 +1073,8 @@ class RestApiHandler(BaseHTTPRequestHandler): * ``412``: if operation is not possible; * ``503``: if unable to register the operation to the DCS; * HTTP status returned by :func:`parse_schedule`, if any error was observed while parsing the schedule; - * HTTP status returned by :func:`poll_failover_result` if the operation has been processed immediately. + * HTTP status returned by :func:`poll_failover_result` if the operation has been processed immediately; + * ``400``: if none of the above applies. .. note:: If unable to parse the request body, then the request is silently discarded. @@ -1090,7 +1090,7 @@ class RestApiHandler(BaseHTTPRequestHandler): candidate = request.get('candidate') or request.get('member') scheduled_at = request.get('scheduled_at') cluster = self.server.patroni.dcs.get_cluster() - global_config = self.server.patroni.config.get_global_config(cluster) + config = global_config.from_cluster(cluster) logger.info("received %s request with leader=%s candidate=%s scheduled_at=%s", action, leader, candidate, scheduled_at) @@ -1101,15 +1101,22 @@ class RestApiHandler(BaseHTTPRequestHandler): data = 'Switchover could be performed only from a specific leader' if not data and scheduled_at: - if not leader: - data = 'Scheduled {0} is possible only from a specific leader'.format(action) - if not data and global_config.is_paused: - data = "Can't schedule {0} in the paused state".format(action) - if not data: + if action == 'failover': + data = "Failover can't be scheduled" + elif config.is_paused: + data = "Can't schedule switchover in the paused state" + else: (status_code, data, scheduled_at) = self.parse_schedule(scheduled_at, action) - if not data and global_config.is_paused and not candidate: - data = action.title() + ' is possible only to a specific candidate in a paused state' + if not data and config.is_paused and not candidate: + data = 'Switchover is possible only to a specific candidate in a paused state' + + if action == 'failover' and leader: + logger.warning('received failover request with leader specifed - performing switchover instead') + action = 'switchover' + + if not data and leader == candidate: + data = 'Switchover target and source are the same' if not data and not scheduled_at: data = self.is_failover_possible(cluster, leader, candidate, action) @@ -1126,7 +1133,7 @@ class RestApiHandler(BaseHTTPRequestHandler): status_code, data = self.poll_failover_result(cluster.leader and cluster.leader.name, candidate, action) else: - data = 'failed to write {0} key into DCS'.format(action) + data = 'failed to write failover key into DCS' status_code = 503 # pyright thinks ``status_code`` can be ``None`` because ``parse_schedule`` call may return ``None``. However, # if that's the case, ``status_code`` will be overwritten somewhere between ``parse_schedule`` and @@ -1158,7 +1165,7 @@ class RestApiHandler(BaseHTTPRequestHandler): patroni = self.server.patroni if patroni.postgresql.citus_handler.is_coordinator() and patroni.ha.is_leader(): - cluster = patroni.dcs.get_cluster(True) + cluster = patroni.dcs.get_cluster() patroni.postgresql.citus_handler.handle_event(cluster, request) self.write_response(200, 'OK') @@ -1252,7 +1259,7 @@ class RestApiHandler(BaseHTTPRequestHandler): """ postgresql = self.server.patroni.postgresql cluster = self.server.patroni.dcs.cluster - global_config = self.server.patroni.config.get_global_config(cluster) + config = global_config.from_cluster(cluster) try: if postgresql.state not in ('running', 'restarting', 'starting'): @@ -1283,10 +1290,10 @@ class RestApiHandler(BaseHTTPRequestHandler): }) } - if result['role'] == 'replica' and global_config.is_standby_cluster: + if result['role'] == 'replica' and config.is_standby_cluster: result['role'] = postgresql.role - if result['role'] == 'replica' and global_config.is_synchronous_mode\ + if result['role'] == 'replica' and config.is_synchronous_mode\ and cluster and cluster.sync.matches(postgresql.name): result['sync_standby'] = True @@ -1311,7 +1318,7 @@ class RestApiHandler(BaseHTTPRequestHandler): state = 'unknown' result: Dict[str, Any] = {'state': state, 'role': postgresql.role} - if global_config.is_paused: + if config.is_paused: result['pause'] = True if not cluster or cluster.is_unlocked(): result['cluster_unlocked'] = True diff --git a/patroni/config.py b/patroni/config.py index 65991b0e..e523bc08 100644 --- a/patroni/config.py +++ b/patroni/config.py @@ -12,10 +12,11 @@ from typing import Any, Callable, Collection, Dict, List, Optional, Union, TYPE_ from . import PATRONI_ENV_PREFIX from .collections import CaseInsensitiveDict -from .dcs import ClusterConfig, Cluster +from .dcs import ClusterConfig from .exceptions import ConfigParseError from .file_perm import pg_perm from .postgresql.config import ConfigHandler +from .validator import IntValidator from .utils import deep_compare, parse_bool, parse_int, patch_config logger = logging.getLogger(__name__) @@ -53,154 +54,6 @@ def default_validator(conf: Dict[str, Any]) -> List[str]: return [] -class GlobalConfig(object): - """A class that wraps global configuration and provides convenient methods to access/check values. - - It is instantiated either by calling :func:`get_global_config` or :meth:`Config.get_global_config`, which picks - either a configuration from provided :class:`Cluster` object (the most up-to-date) or from the - local cache if :class:`ClusterConfig` is not initialized or doesn't have a valid config. - """ - - def __init__(self, config: Dict[str, Any]) -> None: - """Initialize :class:`GlobalConfig` object with given *config*. - - :param config: current configuration either from - :class:`ClusterConfig` or from :func:`Config.dynamic_configuration`. - """ - self.__config = config - - def get(self, name: str) -> Any: - """Gets global configuration value by *name*. - - :param name: parameter name. - - :returns: configuration value or ``None`` if it is missing. - """ - return self.__config.get(name) - - def check_mode(self, mode: str) -> bool: - """Checks whether the certain parameter is enabled. - - :param mode: parameter name, e.g. ``synchronous_mode``, ``failsafe_mode``, ``pause``, ``check_timeline``, and - so on. - - :returns: ``True`` if parameter *mode* is enabled in the global configuration. - """ - return bool(parse_bool(self.__config.get(mode))) - - @property - def is_paused(self) -> bool: - """``True`` if cluster is in maintenance mode.""" - return self.check_mode('pause') - - @property - def is_synchronous_mode(self) -> bool: - """``True`` if synchronous replication is requested.""" - return self.check_mode('synchronous_mode') - - @property - def is_synchronous_mode_strict(self) -> bool: - """``True`` if at least one synchronous node is required.""" - return self.check_mode('synchronous_mode_strict') - - def get_standby_cluster_config(self) -> Union[Dict[str, Any], Any]: - """Get ``standby_cluster`` configuration. - - :returns: a copy of ``standby_cluster`` configuration. - """ - return deepcopy(self.get('standby_cluster')) - - @property - def is_standby_cluster(self) -> bool: - """``True`` if global configuration has a valid ``standby_cluster`` section.""" - config = self.get_standby_cluster_config() - return isinstance(config, dict) and\ - bool(config.get('host') or config.get('port') or config.get('restore_command')) - - def get_int(self, name: str, default: int = 0) -> int: - """Gets current value of *name* from the global configuration and try to return it as :class:`int`. - - :param name: name of the parameter. - :param default: default value if *name* is not in the configuration or invalid. - - :returns: currently configured value of *name* from the global configuration or *default* if it is not set or - invalid. - """ - ret = parse_int(self.get(name)) - return default if ret is None else ret - - @property - def min_synchronous_nodes(self) -> int: - """The minimal number of synchronous nodes based on whether ``synchronous_mode_strict`` is enabled or not.""" - return 1 if self.is_synchronous_mode_strict else 0 - - @property - def synchronous_node_count(self) -> int: - """Currently configured value of ``synchronous_node_count`` from the global configuration. - - Assume ``1`` if it is not set or invalid. - """ - return max(self.get_int('synchronous_node_count', 1), self.min_synchronous_nodes) - - @property - def maximum_lag_on_failover(self) -> int: - """Currently configured value of ``maximum_lag_on_failover`` from the global configuration. - - Assume ``1048576`` if it is not set or invalid. - """ - return self.get_int('maximum_lag_on_failover', 1048576) - - @property - def maximum_lag_on_syncnode(self) -> int: - """Currently configured value of ``maximum_lag_on_syncnode`` from the global configuration. - - Assume ``-1`` if it is not set or invalid. - """ - return self.get_int('maximum_lag_on_syncnode', -1) - - @property - def primary_start_timeout(self) -> int: - """Currently configured value of ``primary_start_timeout`` from the global configuration. - - Assume ``300`` if it is not set or invalid. - - .. note:: - ``master_start_timeout`` is still supported to keep backward compatibility. - """ - default = 300 - return self.get_int('primary_start_timeout', default)\ - if 'primary_start_timeout' in self.__config else self.get_int('master_start_timeout', default) - - @property - def primary_stop_timeout(self) -> int: - """Currently configured value of ``primary_stop_timeout`` from the global configuration. - - Assume ``0`` if it is not set or invalid. - - .. note:: - ``master_stop_timeout`` is still supported to keep backward compatibility. - """ - default = 0 - return self.get_int('primary_stop_timeout', default)\ - if 'primary_stop_timeout' in self.__config else self.get_int('master_stop_timeout', default) - - -def get_global_config(cluster: Optional[Cluster], default: Optional[Dict[str, Any]] = None) -> GlobalConfig: - """Instantiates :class:`GlobalConfig` based on the input. - - :param cluster: the currently known cluster state from DCS. - :param default: default configuration, which will be used if there is no valid *cluster.config*. - - :returns: :class:`GlobalConfig` object. - """ - # Try to protect from the case when DCS was wiped out - if cluster and cluster.config and cluster.config.modify_version: - config = cluster.config.data - else: - config = default or {} - return GlobalConfig(deepcopy(config)) - - class Config(object): """Handle Patroni configuration. @@ -292,6 +145,7 @@ class Config(object): if validator: # patronictl uses validator=None and we don't want to load anything from local cache in this case self._load_cache() self._cache_needs_saving = False + self._validate_failover_tags() @property def config_file(self) -> Optional[str]: @@ -399,6 +253,66 @@ class Config(object): except Exception: logger.error('Can not remove temporary file %s', tmpfile) + def __get_and_maybe_adjust_int_value(self, config: Dict[str, Any], param: str, min_value: int) -> int: + """Get, validate and maybe adjust a *param* integer value from the *config* :class:`dict`. + + .. note: + If the value is smaller than provided *min_value* we update the *config*. + + This method may raise an exception if value isn't :class:`int` or cannot be casted to :class:`int`. + + :param config: :class:`dict` object with new global configuration. + :param param: name of the configuration parameter we want to read/validate/adjust. + :param min_value: the minimum possible value that a given *param* could have. + + :returns: an integer value which corresponds to a provided *param*. + """ + value = int(config.get(param, self.__DEFAULT_CONFIG[param])) + if value < min_value: + logger.warning("%s=%d can't be smaller than %d, adjusting...", param, value, min_value) + value = config[param] = min_value + return value + + def _validate_and_adjust_timeouts(self, config: Dict[str, Any]) -> None: + """Validate and adjust ``loop_wait``, ``retry_timeout``, and ``ttl`` values if necessary. + + Minimum values: + + * ``loop_wait``: 1 second; + * ``retry_timeout``: 3 seconds. + * ``ttl``: 20 seconds; + + Maximum values: + In case if values don't fulfill the following rule, ``retry_timeout`` and ``loop_wait`` + are reduced so that the rule is fulfilled: + + .. code-block:: python + + loop_wait + 2 * retry_timeout <= ttl + + .. note: + We prefer to reduce ``loop_wait`` and will reduce ``retry_timeout`` only if ``loop_wait`` + is already set to a minimal possible value. + + :param config: :class:`dict` object with new global configuration. + """ + + min_loop_wait = 1 + loop_wait = self. __get_and_maybe_adjust_int_value(config, 'loop_wait', min_loop_wait) + retry_timeout = self. __get_and_maybe_adjust_int_value(config, 'retry_timeout', 3) + ttl = self. __get_and_maybe_adjust_int_value(config, 'ttl', 20) + + if min_loop_wait + 2 * retry_timeout > ttl: + config['loop_wait'] = min_loop_wait + config['retry_timeout'] = (ttl - min_loop_wait) // 2 + logger.warning('Violated the rule "loop_wait + 2*retry_timeout <= ttl", where ttl=%d. ' + 'Adjusting loop_wait from %d to %d and retry_timeout from %d to %d', + ttl, loop_wait, min_loop_wait, retry_timeout, config['retry_timeout']) + elif loop_wait + 2 * retry_timeout > ttl: + config['loop_wait'] = ttl - 2 * retry_timeout + logger.warning('Violated the rule "loop_wait + 2*retry_timeout <= ttl", where ttl=%d and retry_timeout=%d.' + ' Adjusting loop_wait from %d to %d', ttl, retry_timeout, loop_wait, config['loop_wait']) + # configuration could be either ClusterConfig or dict def set_dynamic_configuration(self, configuration: Union[ClusterConfig, Dict[str, Any]]) -> bool: """Set dynamic configuration values with given *configuration*. @@ -416,6 +330,7 @@ class Config(object): if not deep_compare(self._dynamic_configuration, configuration): try: + self._validate_and_adjust_timeouts(configuration) self.__effective_configuration = self._build_effective_configuration(configuration, self._local_configuration) self._dynamic_configuration = configuration @@ -487,8 +402,10 @@ class Config(object): if name not in ConfigHandler.CMDLINE_OPTIONS: pg_params[name] = value elif not is_local: - if ConfigHandler.CMDLINE_OPTIONS[name][1](value): - pg_params[name] = value + validator = ConfigHandler.CMDLINE_OPTIONS[name][1] + if validator(value): + int_val = parse_int(value) if isinstance(validator, IntValidator) else None + pg_params[name] = int_val if isinstance(int_val, int) else value else: logger.warning("postgresql parameter %s=%s failed validation, defaulting to %s", name, value, ConfigHandler.CMDLINE_OPTIONS[name][0]) @@ -726,7 +643,7 @@ class Config(object): 'SERVICE_TAGS', 'NAMESPACE', 'CONTEXT', 'USE_ENDPOINTS', 'SCOPE_LABEL', 'ROLE_LABEL', 'POD_IP', 'PORTS', 'LABELS', 'BYPASS_API_SERVICE', 'RETRIABLE_HTTP_CODES', 'KEY_PASSWORD', 'USE_SSL', 'SET_ACLS', 'GROUP', 'DATABASE', 'LEADER_LABEL_VALUE', 'FOLLOWER_LABEL_VALUE', - 'STANDBY_LEADER_LABEL_VALUE', 'TMP_ROLE_LABEL') and name: + 'STANDBY_LEADER_LABEL_VALUE', 'TMP_ROLE_LABEL', 'AUTH_DATA') and name: value = os.environ.pop(param) if name == 'CITUS': if suffix == 'GROUP': @@ -737,7 +654,7 @@ class Config(object): value = value and parse_int(value) elif suffix in ('HOSTS', 'PORTS', 'CHECKS', 'SERVICE_TAGS', 'RETRIABLE_HTTP_CODES'): value = value and _parse_list(value) - elif suffix in ('LABELS', 'SET_ACLS'): + elif suffix in ('LABELS', 'SET_ACLS', 'AUTH_DATA'): value = _parse_dict(value) elif suffix in ('USE_PROXIES', 'REGISTER_SERVICE', 'USE_ENDPOINTS', 'BYPASS_API_SERVICE', 'VERIFY'): value = parse_bool(value) @@ -884,14 +801,23 @@ class Config(object): """ return deepcopy(self.__effective_configuration) - def get_global_config(self, cluster: Optional[Cluster]) -> GlobalConfig: - """Instantiate :class:`GlobalConfig` based on input. + def _validate_failover_tags(self) -> None: + """Check ``nofailover``/``failover_priority`` config and warn user if it's contradictory. - Use the configuration from provided *cluster* (the most up-to-date) or from the - local cache if *cluster.config* is not initialized or doesn't have a valid config. - - :param cluster: the currently known cluster state from DCS. - - :returns: :class:`GlobalConfig` object. + .. note:: + To preserve sanity (and backwards compatibility) the ``nofailover`` tag will still exist. A contradictory + configuration is one where ``nofailover`` is ``True`` but ``failover_priority > 0``, or where + ``nofailover`` is ``False``, but ``failover_priority <= 0``. Essentially, ``nofailover`` and + ``failover_priority`` are communicating different things. + This checks for this edge case (which is a misconfiguration on the part of the user) and warns them. + The behaviour is as if ``failover_priority`` were not provided (i.e ``nofailover`` is the + bedrock source of truth) """ - return get_global_config(cluster, self._dynamic_configuration) + tags = self.get('tags', {}) + nofailover_tag = tags.get('nofailover') + failover_priority_tag = parse_int(tags.get('failover_priority')) + if failover_priority_tag is not None \ + and (nofailover_tag is True and failover_priority_tag > 0 + or nofailover_tag is False and failover_priority_tag <= 0): + logger.warning('Conflicting configuration between nofailover: %s and failover_priority: %s. ' + 'Defaulting to nofailover: %s', nofailover_tag, failover_priority_tag, nofailover_tag) diff --git a/patroni/config_generator.py b/patroni/config_generator.py index 956e18d2..c2b133c6 100644 --- a/patroni/config_generator.py +++ b/patroni/config_generator.py @@ -9,7 +9,7 @@ import yaml from getpass import getuser, getpass from contextlib import contextmanager -from typing import Any, Dict, Iterator, List, Optional, Tuple, TYPE_CHECKING, Union +from typing import Any, Dict, Iterator, List, Optional, TextIO, Tuple, TYPE_CHECKING, Union if TYPE_CHECKING: # pragma: no cover from psycopg import Cursor from psycopg2 import cursor @@ -17,6 +17,7 @@ if TYPE_CHECKING: # pragma: no cover from . import psycopg from .config import Config from .exceptions import PatroniException +from .log import PatroniLogger from .postgresql.config import ConfigHandler, parse_dsn from .postgresql.misc import postgres_major_version_to_int from .utils import get_major_version, parse_bool, patch_config, read_stripped @@ -38,7 +39,7 @@ _AUTH_ALLOWED_PARAMETERS_MAPPING = { 'gssencmode': 'PGGSSENCMODE', 'channel_binding': 'PGCHANNELBINDING' } -_NO_VALUE_MSG = '#FIXME' +NO_VALUE_MSG = '#FIXME' def get_address() -> Tuple[str, str]: @@ -50,7 +51,7 @@ def get_address() -> Tuple[str, str]: :returns: tuple consisting of the hostname returned by :func:`~socket.gethostname` and the first element in the sorted list of the addresses returned by :func:`~socket.getaddrinfo`. Sorting guarantees it will prefer IPv4. - If an exception occured, hostname and ip values are equal to :data:`~patroni.config_generator._NO_VALUE_MSG`. + If an exception occured, hostname and ip values are equal to :data:`~patroni.config_generator.NO_VALUE_MSG`. """ hostname = None try: @@ -59,7 +60,7 @@ def get_address() -> Tuple[str, str]: key=lambda x: x[0])[0][4][0] except Exception as err: logging.warning('Failed to obtain address: %r', err) - return _NO_VALUE_MSG, _NO_VALUE_MSG + return NO_VALUE_MSG, NO_VALUE_MSG class AbstractConfigGenerator(abc.ABC): @@ -88,30 +89,42 @@ class AbstractConfigGenerator(abc.ABC): """Generate a template config for further extension (e.g. in the inherited classes). :returns: dictionary with the values gathered from Patroni env, hopefully defined hostname and ip address - (otherwise set to :data:`~patroni.config_generator._NO_VALUE_MSG`), and some sane defaults. + (otherwise set to :data:`~patroni.config_generator.NO_VALUE_MSG`), and some sane defaults. """ template_config: Dict[str, Any] = { - 'scope': _NO_VALUE_MSG, + 'scope': NO_VALUE_MSG, 'name': cls._HOSTNAME, + 'restapi': { + 'connect_address': cls._IP + ':8008', + 'listen': cls._IP + ':8008' + }, + 'log': { + 'level': PatroniLogger.DEFAULT_LEVEL, + 'traceback_level': PatroniLogger.DEFAULT_TRACEBACK_LEVEL, + 'format': PatroniLogger.DEFAULT_FORMAT, + 'max_queue_size': PatroniLogger.DEFAULT_MAX_QUEUE_SIZE + }, 'postgresql': { - 'data_dir': _NO_VALUE_MSG, - 'connect_address': _NO_VALUE_MSG + ':5432', - 'listen': _NO_VALUE_MSG + ':5432', + 'data_dir': NO_VALUE_MSG, + 'connect_address': cls._IP + ':5432', + 'listen': cls._IP + ':5432', 'bin_dir': '', 'authentication': { 'superuser': { 'username': 'postgres', - 'password': _NO_VALUE_MSG + 'password': NO_VALUE_MSG }, 'replication': { 'username': 'replicator', - 'password': _NO_VALUE_MSG + 'password': NO_VALUE_MSG } } }, - 'restapi': { - 'connect_address': cls._IP + ':8008', - 'listen': cls._IP + ':8008' + 'tags': { + 'failover_priority': 1, + 'noloadbalance': False, + 'clonefrom': True, + 'nosync': False, } } @@ -130,6 +143,72 @@ class AbstractConfigGenerator(abc.ABC): def generate(self) -> None: """Generate config and store in :attr:`~AbstractConfigGenerator.config`.""" + @staticmethod + def _format_block(block: Any, line_prefix: str = '') -> str: + """Format a single YAML block. + + .. note:: + Optionally the formatted block could be indented with the *line_prefix* + + :param block: the object that should be formatted to YAML. + :param line_prefix: is used for indentation. + + :returns: a formatted and indented *block*. + """ + return line_prefix + yaml.safe_dump(block, default_flow_style=False, line_break='\n', + allow_unicode=True, indent=2).strip().replace('\n', '\n' + line_prefix) + + def _format_config_section(self, section_name: str) -> Iterator[str]: + """Format and yield as single section of the current :attr:`~AbstractConfigGenerator.config`. + + .. note:: + If the section is a :class:`dict` object we put an empty line before it. + + :param section_name: a section name in the :attr:`~AbstractConfigGenerator.config`. + + :yields: a formatted section in case if it exists in the :attr:`~AbstractConfigGenerator.config`. + """ + if section_name in self.config: + if isinstance(self.config[section_name], dict): + yield '' + yield self._format_block({section_name: self.config[section_name]}) + + def _format_config(self) -> Iterator[str]: + """Format current :attr:`~AbstractConfigGenerator.config` and enrich it with some comments. + + :yields: formatted lines or blocks that represent a text output of the YAML document. + """ + for name in ('scope', 'namespace', 'name', 'log', 'restapi', 'ctl' 'citus', + 'consul', 'etcd', 'etcd3', 'exhibitor', 'kubernetes', 'raft', 'zookeeper'): + yield from self._format_config_section(name) + + if 'bootstrap' in self.config: + yield '\n# The bootstrap configuration. Works only when the cluster is not yet initialized.' + yield '# If the cluster is already initialized, all changes in the `bootstrap` section are ignored!' + yield 'bootstrap:' + if 'dcs' in self.config['bootstrap']: + yield ' # This section will be written into :///config after initializing' + yield ' # new cluster and all other cluster members will use it as a `global configuration`.' + yield ' # WARNING! If you want to change any of the parameters that were set up' + yield ' # via `bootstrap.dcs` section, please use `patronictl edit-config`!' + yield ' dcs:' + for name in ('loop_wait', 'retry_timeout', 'ttl'): + if name in self.config['bootstrap']['dcs']: + yield self._format_block({name: self.config['bootstrap']['dcs'].pop(name)}, ' ') + + for name, value in self.config['bootstrap']['dcs'].items(): + yield self._format_block({name: value}, ' ') + + for name in ('postgresql', 'watchdog', 'tags'): + yield from self._format_config_section(name) + + def _write_config_to_fd(self, fd: TextIO) -> None: + """Format and write current :attr:`~AbstractConfigGenerator.config` to provided file descriptor. + + :param fd: where to write the config file. Could be ``sys.stdout`` or the real file. + """ + fd.write('\n'.join(self._format_config())) + def write_config(self) -> None: """Write current :attr:`~AbstractConfigGenerator.config` to the output file if provided, to stdout otherwise.""" if self.output_file: @@ -137,9 +216,9 @@ class AbstractConfigGenerator(abc.ABC): if dir_path and not os.path.isdir(dir_path): os.makedirs(dir_path) with open(self.output_file, 'w', encoding='UTF-8') as output_file: - yaml.safe_dump(self.config, output_file, default_flow_style=False, allow_unicode=True) + self._write_config_to_fd(output_file) else: - yaml.safe_dump(self.config, sys.stdout, default_flow_style=False, allow_unicode=True) + self._write_config_to_fd(sys.stdout) class SampleConfigGenerator(AbstractConfigGenerator): @@ -182,10 +261,13 @@ class SampleConfigGenerator(AbstractConfigGenerator): self.config['bootstrap']['dcs']['postgresql']['parameters'][wal_keep_param] = \ ConfigHandler.CMDLINE_OPTIONS[wal_keep_param][0] + wal_level = 'hot_standby' if self.pg_major < 90600 else 'replica' + self.config['bootstrap']['dcs']['postgresql']['parameters']['wal_level'] = wal_level + self.config['bootstrap']['dcs']['postgresql']['use_pg_rewind'] = True if self.pg_major >= 110000: self.config['postgresql']['authentication'].setdefault( - 'rewind', {'username': 'rewind_user'}).setdefault('password', _NO_VALUE_MSG) + 'rewind', {'username': 'rewind_user'}).setdefault('password', NO_VALUE_MSG) class RunningClusterConfigGenerator(AbstractConfigGenerator): @@ -287,7 +369,7 @@ class RunningClusterConfigGenerator(AbstractConfigGenerator): :param cur: connection cursor to use. """ - cur.execute("SELECT name, current_setting(name) FROM pg_settings " + cur.execute("SELECT name, pg_catalog.current_setting(name) FROM pg_catalog.pg_settings " "WHERE context <> 'internal' " "AND source IN ('configuration file', 'command line', 'environment variable') " "AND category <> 'Write-Ahead Log / Recovery Target' " @@ -335,7 +417,7 @@ class RunningClusterConfigGenerator(AbstractConfigGenerator): getpass('Please enter the user password:') self.config['postgresql']['authentication'] = { 'superuser': su_params, - 'replication': {'username': _NO_VALUE_MSG, 'password': _NO_VALUE_MSG} + 'replication': {'username': NO_VALUE_MSG, 'password': NO_VALUE_MSG} } def _set_conf_files(self) -> None: @@ -411,41 +493,6 @@ class RunningClusterConfigGenerator(AbstractConfigGenerator): def generate_config(output_file: str, sample: bool, dsn: Optional[str]) -> None: """Generate Patroni configuration file. - Gather all the available non-internal GUC values having configuration file, postmaster command line or environment - variable as a source and store them in the appropriate part of Patroni configuration (``postgresql.parameters`` or - ``bootstrap.dcs.postgresql.parameters``). Either the provided DSN (takes precedence) or PG ENV vars will be used - for the connection. If password is not provided, it should be entered via prompt. - - The created configuration contains: - * ``scope``: ``cluster_name`` GUC value or ``PATRONI_SCOPE ENV`` variable value if available. - * ``name``: ``PATRONI_NAME`` ENV variable value if set, otherwise hostname. - - * ``bootstrap.dcs``: section with all the parameters (incl. the majority of PG GUCs) set to their default values - defined by Patroni and adjusted by the source instances's configuration values. - - * ``postgresql.parameters``: the source instance's ``archive_command``, ``restore_command``, - ``archive_cleanup_command``, ``recovery_end_command``, ``ssl_passphrase_command``, ``hba_file``, ``ident_file``, - ``config_file`` GUC values. - - * ``postgresql.bin_dir``: path to Postgres binaries gathered from the running instance or, if not available, - the value of ``PATRONI_POSTGRESQL_BIN_DIR`` ENV variable. Otherwise, an empty string. - - * ``postgresql.datadir``: the value gathered from the corresponding PG GUC. - * ``postgresql.listen``: source instance's ``listen_addresses`` and port GUC values. - * ``postgresql.connect_address``: if possible, generated from the connection params. - * ``postgresql.authentication``: - - * superuser and replication users defined (if possible, usernames are set from the respective Patroni ENV vars, - otherwise the default ``postgres`` and ``replicator`` values are used). - If not a sample config, either DSN or PG ENV vars are used to define superuser authentication parameters. - - * rewind user is defined only for sample config, if PG version can be defined and PG version is >=11 - (if possible, username is set from the respective Patroni ENV var). - - * ``bootstrap.dcs.postgresql.use_pg_rewind`` set to ``True`` for a sample config only. - * ``postgresql.pg_hba`` defaults or the lines gathered from the source instance's ``hba_file``. - * ``postgresql.pg_ident`` the lines gathered from the source instance's ``ident_file``. - :param output_file: Full path to the configuration file to be used. If not provided, result is sent to ``stdout``. :param sample: Optional flag. If set, no source instance will be used - generate config with some sane defaults. :param dsn: Optional DSN string for the local instance to get GUC values from. diff --git a/patroni/ctl.py b/patroni/ctl.py index 3f49130e..3e981b45 100644 --- a/patroni/ctl.py +++ b/patroni/ctl.py @@ -46,6 +46,8 @@ try: except ImportError: # pragma: no cover from cdiff import markup_to_pager, PatchStream # pyright: ignore [reportMissingModuleSource] +from . import global_config +from .config import Config from .dcs import get_dcs as _get_dcs, AbstractDCS, Cluster, Member from .exceptions import PatroniException from .postgresql.misc import postgres_version_to_int @@ -164,7 +166,7 @@ class PatronictlPrettyTable(PrettyTable): def parse_dcs(dcs: Optional[str]) -> Optional[Dict[str, Any]]: """Parse a DCS URL. - :param dcs: the DCS URL in the format ``DCS://HOST:PORT``. ``DCS`` can be one among: + :param dcs: the DCS URL in the format ``DCS://HOST:PORT/NAMESPACE``. ``DCS`` can be one among: * ``consul`` * ``etcd`` @@ -173,10 +175,12 @@ def parse_dcs(dcs: Optional[str]) -> Optional[Dict[str, Any]]: * ``zookeeper`` If ``DCS`` is not specified, assume ``etcd`` by default. If ``HOST`` is not specified, assume ``localhost`` by - default. If ``PORT`` is not specified, assume the default port of the given ``DCS``. + default. If ``PORT`` is not specified, assume the default port of the given ``DCS``. If ``NAMESPACE`` is not + specified, use whatever is in config. :returns: ``None`` if *dcs* is ``None``, otherwise a dictionary. The dictionary represents *dcs* as if it were - parsed from the Patroni configuration file. + parsed from the Patroni configuration file. Additionally, if a namespace is specified in *dcs*, return a + ``namespace`` key with the parsed value. :raises: :class:`PatroniCtlException`: if the DCS name in *dcs* is not valid. @@ -194,6 +198,9 @@ def parse_dcs(dcs: Optional[str]) -> Optional[Dict[str, Any]]: >>> parse_dcs('etcd3://random.com:2399') {'etcd3': {'host': 'random.com:2399'}} + + >>> parse_dcs('etcd3://random.com:2399/customnamespace') + {'etcd3': {'host': 'random.com:2399'}, 'namespace': '/customnamespace'} """ if dcs is None: return None @@ -210,23 +217,27 @@ def parse_dcs(dcs: Optional[str]) -> Optional[Dict[str, Any]]: raise PatroniCtlException('Unknown dcs scheme: {}'.format(scheme)) default = DCS_DEFAULTS[scheme] - return yaml.safe_load(default['template'].format(host=parsed.hostname or 'localhost', port=port or default['port'])) + ret = yaml.safe_load(default['template'].format(host=parsed.hostname or 'localhost', port=port or default['port'])) + + if parsed.path and parsed.path.strip() != '/': + ret['namespace'] = parsed.path.strip() + + return ret def load_config(path: str, dcs_url: Optional[str]) -> Dict[str, Any]: """Load configuration file from *path* and optionally override its DCS configuration with *dcs_url*. :param path: path to the configuration file. - :param dcs_url: the DCS URL in the format ``DCS://HOST:PORT``, e.g. ``etcd3://random.com:2399``. If given override - whatever DCS is set in the configuration file. + :param dcs_url: the DCS URL in the format ``DCS://HOST:PORT/NAMESPACE``, e.g. ``etcd3://random.com:2399/service``. + If given, override whatever DCS and ``namespace`` that are set in the configuration file. See :func:`parse_dcs` + for more information. :returns: a dictionary representing the configuration. :raises: :class:`PatroniCtlException`: if *path* does not exist or is not readable. """ - from patroni.config import Config - if not (os.path.exists(path) and os.access(path, os.R_OK)): if path != CONFIG_FILE_PATH: # bail if non-default config location specified but file not found / readable raise PatroniCtlException('Provided config file {0} not existing or no read rights.' @@ -245,14 +256,23 @@ def load_config(path: str, dcs_url: Optional[str]) -> Dict[str, Any]: return config -option_format = click.option('--format', '-f', 'fmt', help='Output format (pretty, tsv, json, yaml)', default='pretty') +def _get_configuration() -> Dict[str, Any]: + """Get configuration object. + + :returns: configuration object from the current context. + """ + return click.get_current_context().obj['__config'] + + +option_format = click.option('--format', '-f', 'fmt', help='Output format', default='pretty', + type=click.Choice(['pretty', 'tsv', 'json', 'yaml', 'yml'])) option_watchrefresh = click.option('-w', '--watch', type=float, help='Auto update the screen every X seconds') option_watch = click.option('-W', is_flag=True, help='Auto update the screen every 2 seconds') option_force = click.option('--force', is_flag=True, help='Do not ask for confirmation at any point') arg_cluster_name = click.argument('cluster_name', required=False, - default=lambda: click.get_current_context().obj.get('scope')) + default=lambda: _get_configuration().get('scope')) option_default_citus_group = click.option('--group', required=False, type=int, help='Citus group', - default=lambda: click.get_current_context().obj.get('citus', {}).get('group')) + default=lambda: _get_configuration().get('citus', {}).get('group')) option_citus_group = click.option('--group', required=False, type=int, help='Citus group') role_choice = click.Choice(['leader', 'primary', 'standby-leader', 'replica', 'standby', 'any', 'master']) @@ -290,15 +310,23 @@ def ctl(ctx: click.Context, config_file: str, dcs_url: Optional[str], insecure: level = os.environ.get(name, level) logging.basicConfig(format='%(asctime)s - %(levelname)s - %(message)s', level=level) logging.captureWarnings(True) # Capture eventual SSL warning - ctx.obj = load_config(config_file, dcs_url) + config = load_config(config_file, dcs_url) # backward compatibility for configuration file where ctl section is not defined - ctx.obj.setdefault('ctl', {})['insecure'] = ctx.obj.get('ctl', {}).get('insecure') or insecure + config.setdefault('ctl', {})['insecure'] = config.get('ctl', {}).get('insecure') or insecure + ctx.obj = {'__config': config} -def get_dcs(config: Dict[str, Any], scope: str, group: Optional[int]) -> AbstractDCS: +def is_citus_cluster() -> bool: + """Check if we are working with Citus cluster. + + :returns: ``True`` if configuration has ``citus`` section, otherwise ``False``. + """ + return bool(_get_configuration().get('citus')) + + +def get_dcs(scope: str, group: Optional[int]) -> AbstractDCS: """Get the DCS object. - :param config: Patroni configuration. :param scope: cluster name. :param group: if *group* is defined, use it to select which alternative Citus group this DCS refers to. If *group* is ``None`` and a Citus configuration exists, assume this is the coordinator. Coordinator has the group ``0``. @@ -309,13 +337,14 @@ def get_dcs(config: Dict[str, Any], scope: str, group: Optional[int]) -> Abstrac :raises: :class:`PatroniCtlException`: if not suitable DCS configuration could be found. """ + config = _get_configuration() config.update({'scope': scope, 'patronictl': True}) if group is not None: config['citus'] = {'group': group} config.setdefault('name', scope) try: dcs = _get_dcs(config) - if config.get('citus') and group is None: + if is_citus_cluster() and group is None: dcs.is_citus_coordinator = lambda: True return dcs except PatroniException as e: @@ -336,7 +365,7 @@ def request_patroni(member: Member, method: str = 'GET', ctx = click.get_current_context() # the current click context request_executor = ctx.obj.get('__request_patroni') if not request_executor: - request_executor = ctx.obj['__request_patroni'] = PatroniRequest(ctx.obj) + request_executor = ctx.obj['__request_patroni'] = PatroniRequest(_get_configuration()) return request_executor(member, method, endpoint, data) @@ -403,9 +432,9 @@ def print_output(columns: Optional[List[str]], rows: List[List[Any]], alignment: def watching(w: bool, watch: Optional[int], max_count: Optional[int] = None, clear: bool = True) -> Iterator[int]: - """Yield a value every ``x`` seconds. + """Yield a value every ``watch`` seconds. - Used to run a command with a watch-based aproach. + Used to run a command with a watch-based approach. :param w: if ``True`` and *watch* is ``None``, then *watch* assumes the value ``2``. :param watch: amount of seconds to wait before yielding another value. @@ -441,11 +470,9 @@ def watching(w: bool, watch: Optional[int], max_count: Optional[int] = None, cle yield 0 -def get_all_members(obj: Dict[str, Any], cluster: Cluster, - group: Optional[int], role: str = 'leader') -> Iterator[Member]: +def get_all_members(cluster: Cluster, group: Optional[int], role: str = 'leader') -> Iterator[Member]: """Get all cluster members that have the given *role*. - :param obj: the Patroni configuration. :param cluster: the Patroni cluster. :param group: filter which Citus group we should get members from. If ``None`` get from all groups. :param role: role to filter members. Can be one among: @@ -459,7 +486,7 @@ def get_all_members(obj: Dict[str, Any], cluster: Cluster, :yields: members that have the given *role*. """ clusters = {0: cluster} - if obj.get('citus') and group is None: + if is_citus_cluster() and group is None: clusters.update(cluster.workers) if role in ('leader', 'master', 'primary', 'standby-leader'): # In the DCS the members' role can be one among: ``primary``, ``master``, ``replica`` or ``standby_leader``. @@ -481,11 +508,10 @@ def get_all_members(obj: Dict[str, Any], cluster: Cluster, yield m -def get_any_member(obj: Dict[str, Any], cluster: Cluster, group: Optional[int], +def get_any_member(cluster: Cluster, group: Optional[int], role: Optional[str] = None, member: Optional[str] = None) -> Optional[Member]: """Get the first found cluster member that has the given *role*. - :param obj: the Patroni configuration. :param cluster: the Patroni cluster. :param group: filter which Citus group we should get members from. If ``None`` get from all groups. :param role: role to filter members. See :func:`get_all_members` for available options. @@ -503,7 +529,7 @@ def get_any_member(obj: Dict[str, Any], cluster: Cluster, group: Optional[int], elif role is None: role = 'leader' - for m in get_all_members(obj, cluster, group, role): + for m in get_all_members(cluster, group, role): if member is None or m.name == member: return m @@ -524,7 +550,7 @@ def get_all_members_leader_first(cluster: Cluster) -> Iterator[Member]: yield member -def get_cursor(obj: Dict[str, Any], cluster: Cluster, group: Optional[int], connect_parameters: Dict[str, Any], +def get_cursor(cluster: Cluster, group: Optional[int], connect_parameters: Dict[str, Any], role: Optional[str] = None, member_name: Optional[str] = None) -> Union['cursor', 'Cursor[Any]', None]: """Get a cursor object to execute queries against a member that has the given *role* or *member_name*. @@ -533,7 +559,6 @@ def get_cursor(obj: Dict[str, Any], cluster: Cluster, group: Optional[int], conn * ``fallback_application_name``: as ``Patroni ctl``; * ``connect_timeout``: as ``5``. - :param obj: the Patroni configuration. :param cluster: the Patroni cluster. :param group: filter which Citus group we should get members to create a cursor against. If ``None`` consider members from all groups. @@ -548,7 +573,7 @@ def get_cursor(obj: Dict[str, Any], cluster: Cluster, group: Optional[int], conn * A :class:`psycopg2.extensions.cursor` if using :mod:`psycopg2`; * ``None`` if not able to get a cursor that attendees *role* and *member_name*. """ - member = get_any_member(obj, cluster, group, role=role, member=member_name) + member = get_any_member(cluster, group, role=role, member=member_name) if member is None: return None @@ -562,9 +587,10 @@ def get_cursor(obj: Dict[str, Any], cluster: Cluster, group: Optional[int], conn from . import psycopg conn = psycopg.connect(**params) cursor = conn.cursor() - # If we want ``any`` node we are fine to return the cursor + # If we want ``any`` node we are fine to return the cursor. ``None`` is similar to ``any`` at this point, as it's + # been dealt with through :func:`get_any_member`. # If we want the Patroni leader node, :func:`get_any_member` already checks that for us - if role in ('any', 'leader'): + if role in (None, 'any', 'leader'): return cursor # If we want something other than ``any`` or ``leader``, then we do not rely only on the DCS information about @@ -582,7 +608,7 @@ def get_cursor(obj: Dict[str, Any], cluster: Cluster, group: Optional[int], conn return None -def get_members(obj: Dict[str, Any], cluster: Cluster, cluster_name: str, member_names: List[str], role: str, +def get_members(cluster: Cluster, cluster_name: str, member_names: List[str], role: str, force: bool, action: str, ask_confirmation: bool = True, group: Optional[int] = None) -> List[Member]: """Get the list of members based on the given filters. @@ -606,7 +632,6 @@ def get_members(obj: Dict[str, Any], cluster: Cluster, cluster_name: str, member ``ask_confirmation=False``, and later call :func:`confirm_members_action` manually in the caller method. That way the workflow won't look broken to the user that is interacting with ``patronictl``. - :param obj: Patroni configuration. :param cluster: Patroni cluster. :param cluster_name: name of the Patroni cluster. :param member_names: used to filter which members should take the *action* based on their names. Each item is the @@ -635,13 +660,13 @@ def get_members(obj: Dict[str, Any], cluster: Cluster, cluster_name: str, member * Cluster does not have members that match the given *member_names*; or * No member with given *role* is found among the specified *member_names*. """ - members = list(get_all_members(obj, cluster, group, role)) + members = list(get_all_members(cluster, group, role)) candidates = {m.name for m in members} if not force or role: if not member_names and not candidates: raise PatroniCtlException('{0} cluster doesn\'t have any members'.format(cluster_name)) - output_members(obj, cluster, cluster_name, group=group) + output_members(cluster, cluster_name, group=group) if member_names: member_names = list(set(member_names) & candidates) @@ -701,9 +726,7 @@ def confirm_members_action(members: List[Member], force: bool, action: str, @click.option('--member', '-m', help='Generate a dsn for this member', type=str) @arg_cluster_name @option_citus_group -@click.pass_obj -def dsn(obj: Dict[str, Any], cluster_name: str, group: Optional[int], - role: Optional[str], member: Optional[str]) -> None: +def dsn(cluster_name: str, group: Optional[int], role: Optional[str], member: Optional[str]) -> None: """Process ``dsn`` command of ``patronictl`` utility. Get DSN to connect to *member*. @@ -711,7 +734,6 @@ def dsn(obj: Dict[str, Any], cluster_name: str, group: Optional[int], .. note:: If no *role* nor *member* is given assume *role* as ``leader``. - :param obj: Patroni configuration. :param cluster_name: name of the Patroni cluster. :param group: filter which Citus group we should get members to get DSN from. Refer to the module note for more details. @@ -724,8 +746,8 @@ def dsn(obj: Dict[str, Any], cluster_name: str, group: Optional[int], * both *role* and *member* are provided; or * No member matches requested *member* or *role*. """ - cluster = get_dcs(obj, cluster_name, group).get_cluster() - m = get_any_member(obj, cluster, group, role=role, member=member) + cluster = get_dcs(cluster_name, group).get_cluster() + m = get_any_member(cluster, group, role=role, member=member) if m is None: raise PatroniCtlException('Can not find a suitable member') @@ -747,9 +769,7 @@ def dsn(obj: Dict[str, Any], cluster_name: str, group: Optional[int], @click.option('--delimiter', help='The column delimiter', default='\t') @click.option('--command', '-c', help='The SQL commands to execute') @click.option('-d', '--dbname', help='database name to connect to', type=str) -@click.pass_obj def query( - obj: Dict[str, Any], cluster_name: str, group: Optional[int], role: Optional[str], @@ -768,7 +788,6 @@ def query( Perform a Postgres query in a Patroni node. - :param obj: Patroni configuration. :param cluster_name: name of the Patroni cluster. :param group: filter which Citus group we should get members from to perform the query. Refer to the module note for more details. @@ -800,7 +819,7 @@ def query( raise PatroniCtlException('You need to specify either --command or --file') sql = command - connect_parameters = {} + connect_parameters: Dict[str, str] = {} if username: connect_parameters['username'] = username if password: @@ -808,24 +827,22 @@ def query( if dbname: connect_parameters['dbname'] = dbname - dcs = get_dcs(obj, cluster_name, group) + dcs = get_dcs(cluster_name, group) cluster = cursor = None for _ in watching(w, watch, clear=False): if cluster is None: cluster = dcs.get_cluster() -# cursor = get_cursor(obj, cluster, group, connect_parameters, role=role, member=member) - output, header = query_member(obj, cluster, group, cursor, member, role, sql, connect_parameters) + output, header = query_member(cluster, group, cursor, member, role, sql, connect_parameters) print_output(header, output, fmt=fmt, delimiter=delimiter) -def query_member(obj: Dict[str, Any], cluster: Cluster, group: Optional[int], - cursor: Union['cursor', 'Cursor[Any]', None], member: Optional[str], role: Optional[str], - command: str, connect_parameters: Dict[str, Any]) -> Tuple[List[List[Any]], Optional[List[Any]]]: +def query_member(cluster: Cluster, group: Optional[int], cursor: Union['cursor', 'Cursor[Any]', None], + member: Optional[str], role: Optional[str], command: str, + connect_parameters: Dict[str, Any]) -> Tuple[List[List[Any]], Optional[List[Any]]]: """Execute SQL *command* against a member. - :param obj: Patroni configuration. :param cluster: the Patroni cluster. :param group: filter which Citus group we should get members from to perform the query. Refer to the module note for more details. @@ -854,13 +871,15 @@ def query_member(obj: Dict[str, Any], cluster: Cluster, group: Optional[int], from . import psycopg try: if cursor is None: - cursor = get_cursor(obj, cluster, group, connect_parameters, role=role, member_name=member) + cursor = get_cursor(cluster, group, connect_parameters, role=role, member_name=member) if cursor is None: if member is not None: - message = 'No connection to member {0} is available'.format(member) + message = f'No connection to member {member} is available' + elif role is not None: + message = f'No connection to role {role} is available' else: - message = 'No connection to role={0} is available'.format(role) + message = 'No connection is available' logging.debug(message) return [[timestamp(0), message]], None @@ -879,13 +898,11 @@ def query_member(obj: Dict[str, Any], cluster: Cluster, group: Optional[int], @click.argument('cluster_name') @option_citus_group @option_format -@click.pass_obj -def remove(obj: Dict[str, Any], cluster_name: str, group: Optional[int], fmt: str) -> None: +def remove(cluster_name: str, group: Optional[int], fmt: str) -> None: """Process ``remove`` command of ``patronictl`` utility. Remove cluster *cluster_name* from the DCS. - :param obj: Patroni configuration. :param cluster_name: name of the cluster which information will be wiped out of the DCS. :param group: which Citus group should have its information wiped out of the DCS. Refer to the module note for more details. @@ -899,12 +916,12 @@ def remove(obj: Dict[str, Any], cluster_name: str, group: Optional[int], fmt: st * use did not type the correct leader name when requesting removal of a healthy cluster. """ - dcs = get_dcs(obj, cluster_name, group) + dcs = get_dcs(cluster_name, group) cluster = dcs.get_cluster() - if obj.get('citus') and group is None: + if is_citus_cluster() and group is None: raise PatroniCtlException('For Citus clusters the --group must me specified') - output_members(obj, cluster, cluster_name, fmt=fmt) + output_members(cluster, cluster_name, fmt=fmt) confirm = click.prompt('Please confirm the cluster name to remove', type=str) if confirm != cluster_name: @@ -989,32 +1006,28 @@ def parse_scheduled(scheduled: Optional[str]) -> Optional[datetime.datetime]: @option_citus_group @click.option('--role', '-r', help='Reload only members with this role', type=role_choice, default='any') @option_force -@click.pass_obj -def reload(obj: Dict[str, Any], cluster_name: str, member_names: List[str], - group: Optional[int], force: bool, role: str) -> None: +def reload(cluster_name: str, member_names: List[str], group: Optional[int], force: bool, role: str) -> None: """Process ``reload`` command of ``patronictl`` utility. Reload configuration of cluster members based on given filters. - :param obj: Patroni configuration. :param cluster_name: name of the Patroni cluster. :param member_names: name of the members which configuration should be reloaded. :param group: filter which Citus group we should reload members. Refer to the module note for more details. :param force: perform the reload without asking for confirmations. :param role: role to filter members. See :func:`get_all_members` for available options. """ - dcs = get_dcs(obj, cluster_name, group) + dcs = get_dcs(cluster_name, group) cluster = dcs.get_cluster() - members = get_members(obj, cluster, cluster_name, member_names, role, force, 'reload', group=group) + members = get_members(cluster, cluster_name, member_names, role, force, 'reload', group=group) for member in members: r = request_patroni(member, 'post', 'reload') if r.status == 200: click.echo('No changes to apply on member {0}'.format(member.name)) elif r.status == 202: - from patroni.config import get_global_config - config = get_global_config(cluster) + config = global_config.from_cluster(cluster) click.echo('Reload request received for member {0} and will be processed within {1} seconds'.format( member.name, config.get('loop_wait') or dcs.loop_wait) ) @@ -1037,15 +1050,13 @@ def reload(obj: Dict[str, Any], cluster_name: str, member_names: List[str], @click.option('--pending', help='Restart if pending', is_flag=True) @click.option('--timeout', help='Return error and fail over if necessary when restarting takes longer than this.') @option_force -@click.pass_obj -def restart(obj: Dict[str, Any], cluster_name: str, group: Optional[int], member_names: List[str], +def restart(cluster_name: str, group: Optional[int], member_names: List[str], force: bool, role: str, p_any: bool, scheduled: Optional[str], version: Optional[str], pending: bool, timeout: Optional[str]) -> None: """Process ``restart`` command of ``patronictl`` utility. Restart Postgres on cluster members based on given filters. - :param obj: Patroni configuration. :param cluster_name: name of the Patroni cluster. :param group: filter which Citus group we should restart members. Refer to the module note for more details. :param member_names: name of the members that should be restarted. @@ -1063,9 +1074,9 @@ def restart(obj: Dict[str, Any], cluster_name: str, group: Optional[int], member * *version* could not be parsed; or * a restart is attempted against a cluster that is in maintenance mode. """ - cluster = get_dcs(obj, cluster_name, group).get_cluster() + cluster = get_dcs(cluster_name, group).get_cluster() - members = get_members(obj, cluster, cluster_name, member_names, role, force, 'restart', False, group=group) + members = get_members(cluster, cluster_name, member_names, role, force, 'restart', False, group=group) if scheduled is None and not force: next_hour = (datetime.datetime.now() + datetime.timedelta(hours=1)).strftime('%Y-%m-%dT%H:%M') scheduled = click.prompt('When should the restart take place (e.g. ' + next_hour + ') ', @@ -1082,7 +1093,7 @@ def restart(obj: Dict[str, Any], cluster_name: str, group: Optional[int], member version = click.prompt('Restart if the PostgreSQL version is less than provided (e.g. 9.5.2) ', type=str, default='') - content = {} + content: Dict[str, Any] = {} if pending: content['restart_pending'] = True @@ -1095,8 +1106,7 @@ def restart(obj: Dict[str, Any], cluster_name: str, group: Optional[int], member content['postgres_version'] = version if scheduled_at: - from patroni.config import get_global_config - if get_global_config(cluster).is_paused: + if global_config.from_cluster(cluster).is_paused: raise PatroniCtlException("Can't schedule restart in the paused state") content['schedule'] = scheduled_at.isoformat() @@ -1128,9 +1138,7 @@ def restart(obj: Dict[str, Any], cluster_name: str, group: Optional[int], member @click.argument('member_names', nargs=-1) @option_force @click.option('--wait', help='Wait until reinitialization completes', is_flag=True) -@click.pass_obj -def reinit(obj: Dict[str, Any], cluster_name: str, group: Optional[int], - member_names: List[str], force: bool, wait: bool) -> None: +def reinit(cluster_name: str, group: Optional[int], member_names: List[str], force: bool, wait: bool) -> None: """Process ``reinit`` command of ``patronictl`` utility. Reinitialize cluster members based on given filters. @@ -1138,15 +1146,14 @@ def reinit(obj: Dict[str, Any], cluster_name: str, group: Optional[int], .. note:: Only reinitialize replica members, not a leader. - :param obj: Patroni configuration. :param cluster_name: name of the Patroni cluster. :param group: filter which Citus group we should reinit members. Refer to the module note for more details. :param member_names: name of the members that should be reinitialized. :param force: perform the restart without asking for confirmations. :param wait: wait for the operation to complete. """ - cluster = get_dcs(obj, cluster_name, group).get_cluster() - members = get_members(obj, cluster, cluster_name, member_names, 'replica', force, 'reinitialize', group=group) + cluster = get_dcs(cluster_name, group).get_cluster() + members = get_members(cluster, cluster_name, member_names, 'replica', force, 'reinitialize', group=group) wait_on_members: List[Member] = [] for member in members: @@ -1177,8 +1184,8 @@ def reinit(obj: Dict[str, Any], cluster_name: str, group: Optional[int], wait_on_members.remove(member) -def _do_failover_or_switchover(obj: Dict[str, Any], action: str, cluster_name: str, - group: Optional[int], leader: Optional[str], candidate: Optional[str], +def _do_failover_or_switchover(action: str, cluster_name: str, group: Optional[int], + leader: Optional[str], candidate: Optional[str], force: bool, scheduled: Optional[str] = None) -> None: """Perform a failover or a switchover operation in the cluster. @@ -1188,7 +1195,6 @@ def _do_failover_or_switchover(obj: Dict[str, Any], action: str, cluster_name: s .. note:: If not able to perform the operation through the REST API, write directly to the DCS as a fall back. - :param obj: Patroni configuration. :param action: action to be taken -- ``failover`` or ``switchover``. :param cluster_name: name of the Patroni cluster. :param group: filter Citus group within we should perform a failover or switchover. If ``None``, user will be @@ -1210,32 +1216,35 @@ def _do_failover_or_switchover(obj: Dict[str, Any], action: str, cluster_name: s * trying to schedule a switchover in a cluster that is in maintenance mode; or * user aborts the operation. """ - dcs = get_dcs(obj, cluster_name, group) + dcs = get_dcs(cluster_name, group) cluster = dcs.get_cluster() click.echo('Current cluster topology') - output_members(obj, cluster, cluster_name, group=group) + output_members(cluster, cluster_name, group=group) - if obj.get('citus') and group is None: + if is_citus_cluster() and group is None: if force: raise PatroniCtlException('For Citus clusters the --group must me specified') else: group = click.prompt('Citus group', type=int) - dcs = get_dcs(obj, cluster_name, group) + dcs = get_dcs(cluster_name, group) cluster = dcs.get_cluster() - if action == 'switchover' and (cluster.leader is None or not cluster.leader.name): - raise PatroniCtlException('This cluster has no leader') + config = global_config.from_cluster(cluster) - if leader is None: - if force or action == 'failover': - leader = cluster.leader and cluster.leader.name - else: - from patroni.config import get_global_config - prompt = 'Standby Leader' if get_global_config(cluster).is_standby_cluster else 'Primary' - leader = click.prompt(prompt, type=str, default=(cluster.leader and cluster.leader.member.name)) + # leader has to be be defined for switchover only + if action == 'switchover': + if cluster.leader is None or not cluster.leader.name: + raise PatroniCtlException('This cluster has no leader') - if leader is not None and cluster.leader and cluster.leader.member.name != leader: - raise PatroniCtlException('Member {0} is not the leader of cluster {1}'.format(leader, cluster_name)) + if leader is None: + if force: + leader = cluster.leader.name + else: + prompt = 'Standby Leader' if config.is_standby_cluster else 'Primary' + leader = click.prompt(prompt, type=str, default=(cluster.leader and cluster.leader.name)) + + if cluster.leader.name != leader: + raise PatroniCtlException(f'Member {leader} is not the leader of cluster {cluster_name}') # excluding members with nofailover tag candidate_names = [str(m.name) for m in cluster.members if m.name != leader and not m.nofailover] @@ -1255,7 +1264,16 @@ def _do_failover_or_switchover(obj: Dict[str, Any], action: str, cluster_name: s raise PatroniCtlException(action.title() + ' target and source are the same.') if candidate and candidate not in candidate_names: - raise PatroniCtlException('Member {0} does not exist in cluster {1}'.format(candidate, cluster_name)) + raise PatroniCtlException( + f'Member {candidate} does not exist in cluster {cluster_name} or is tagged as nofailover') + + if all((not force, + action == 'failover', + config.is_synchronous_mode, + not cluster.sync.is_empty, + not cluster.sync.matches(candidate, True))): + if click.confirm(f'Are you sure you want to failover to the asynchronous node {candidate}'): + raise PatroniCtlException('Aborting ' + action) scheduled_at_str = None scheduled_at = None @@ -1268,25 +1286,29 @@ def _do_failover_or_switchover(obj: Dict[str, Any], action: str, cluster_name: s scheduled_at = parse_scheduled(scheduled) if scheduled_at: - from patroni.config import get_global_config - if get_global_config(cluster).is_paused: + if config.is_paused: raise PatroniCtlException("Can't schedule switchover in the paused state") scheduled_at_str = scheduled_at.isoformat() - failover_value = {'leader': leader, 'candidate': candidate, 'scheduled_at': scheduled_at_str} + failover_value = {'candidate': candidate} + if action == 'switchover': + failover_value['leader'] = leader + if scheduled_at_str: + failover_value['scheduled_at'] = scheduled_at_str logging.debug(failover_value) # By now we have established that the leader exists and the candidate exists if not force: - demote_msg = ', demoting current leader ' + leader if leader else '' + demote_msg = f', demoting current leader {cluster.leader.name}' if cluster.leader else '' if scheduled_at_str: - if not click.confirm('Are you sure you want to schedule {0} of cluster {1} at {2}{3}?' - .format(action, cluster_name, scheduled_at_str, demote_msg)): + # only switchover can be scheduled + if not click.confirm(f'Are you sure you want to schedule switchover of cluster ' + f'{cluster_name} at {scheduled_at_str}{demote_msg}?'): + # action as a var to catch a regression in the tests raise PatroniCtlException('Aborting scheduled ' + action) else: - if not click.confirm('Are you sure you want to {0} cluster {1}{2}?' - .format(action, cluster_name, demote_msg)): + if not click.confirm(f'Are you sure you want to {action} cluster {cluster_name}{demote_msg}?'): raise PatroniCtlException('Aborting ' + action) r = None @@ -1314,7 +1336,7 @@ def _do_failover_or_switchover(obj: Dict[str, Any], action: str, cluster_name: s click.echo('{0} Could not {1} using Patroni api, falling back to DCS'.format(timestamp(), action)) dcs.manual_failover(leader, candidate, scheduled_at=scheduled_at) - output_members(obj, cluster, cluster_name, group=group) + output_members(cluster, cluster_name, group=group) @ctl.command('failover', help='Failover to a replica') @@ -1323,8 +1345,7 @@ def _do_failover_or_switchover(obj: Dict[str, Any], action: str, cluster_name: s @click.option('--leader', '--primary', '--master', 'leader', help='The name of the current leader', default=None) @click.option('--candidate', help='The name of the candidate', default=None) @option_force -@click.pass_obj -def failover(obj: Dict[str, Any], cluster_name: str, group: Optional[int], +def failover(cluster_name: str, group: Optional[int], leader: Optional[str], candidate: Optional[str], force: bool) -> None: """Process ``failover`` command of ``patronictl`` utility. @@ -1332,11 +1353,12 @@ def failover(obj: Dict[str, Any], cluster_name: str, group: Optional[int], .. note:: If *leader* is given perform a switchover instead of a failover. + This behavior is deprecated. ``--leader`` option support will be + removed in the next major release. .. seealso:: Refer to :func:`_do_failover_or_switchover` for details. - :param obj: Patroni configuration. :param cluster_name: name of the Patroni cluster. :param group: filter Citus group within we should perform a failover or switchover. If ``None``, user will be prompted for filling it -- unless *force* is ``True``, in which case an exception is raised by @@ -1345,8 +1367,13 @@ def failover(obj: Dict[str, Any], cluster_name: str, group: Optional[int], :param candidate: name of a standby member to be promoted. Nodes that are tagged with ``nofailover`` cannot be used. :param force: perform the failover or switchover without asking for confirmations. """ - action = 'switchover' if leader else 'failover' - _do_failover_or_switchover(obj, action, cluster_name, group, leader, candidate, force) + action = 'failover' + if leader: + action = 'switchover' + click.echo(click.style( + 'Supplying a leader name using this command is deprecated and will be removed in a future version of' + ' Patroni, change your scripts to use `switchover` instead.\nExecuting switchover!', fg='red')) + _do_failover_or_switchover(action, cluster_name, group, leader, candidate, force) @ctl.command('switchover', help='Switchover to a replica') @@ -1357,9 +1384,8 @@ def failover(obj: Dict[str, Any], cluster_name: str, group: Optional[int], @click.option('--scheduled', help='Timestamp of a scheduled switchover in unambiguous format (e.g. ISO 8601)', default=None) @option_force -@click.pass_obj -def switchover(obj: Dict[str, Any], cluster_name: str, group: Optional[int], - leader: Optional[str], candidate: Optional[str], force: bool, scheduled: Optional[str]) -> None: +def switchover(cluster_name: str, group: Optional[int], leader: Optional[str], + candidate: Optional[str], force: bool, scheduled: Optional[str]) -> None: """Process ``switchover`` command of ``patronictl`` utility. Perform a switchover operation in the cluster. @@ -1367,7 +1393,6 @@ def switchover(obj: Dict[str, Any], cluster_name: str, group: Optional[int], .. seealso:: Refer to :func:`_do_failover_or_switchover` for details. - :param obj: Patroni configuration. :param cluster_name: name of the Patroni cluster. :param group: filter Citus group within we should perform a switchover. If ``None``, user will be prompted for filling it -- unless *force* is ``True``, in which case an exception is raised by @@ -1377,7 +1402,7 @@ def switchover(obj: Dict[str, Any], cluster_name: str, group: Optional[int], :param force: perform the switchover without asking for confirmations. :param scheduled: timestamp when the switchover should be scheduled to occur. If ``now`` perform immediately. """ - _do_failover_or_switchover(obj, 'switchover', cluster_name, group, leader, candidate, force, scheduled) + _do_failover_or_switchover('switchover', cluster_name, group, leader, candidate, force, scheduled) def generate_topology(level: int, member: Dict[str, Any], @@ -1479,8 +1504,8 @@ def get_cluster_service_info(cluster: Dict[str, Any]) -> List[str]: return service_info -def output_members(obj: Dict[str, Any], cluster: Cluster, name: str, - extended: bool = False, fmt: str = 'pretty', group: Optional[int] = None) -> None: +def output_members(cluster: Cluster, name: str, extended: bool = False, + fmt: str = 'pretty', group: Optional[int] = None) -> None: """Print information about the Patroni cluster and its members. Information is printed to console through :func:`print_output`, and contains: @@ -1505,7 +1530,6 @@ def output_members(obj: Dict[str, Any], cluster: Cluster, name: str, The 3 extended columns are always included if *extended*, even if the member has no value for a given column. If not *extended*, these columns may still be shown if any of the members has any information for them. - :param obj: Patroni configuration. :param cluster: Patroni cluster. :param name: name of the Patroni cluster. :param extended: if extended information (pending restarts, scheduled restarts, node tags) should be printed, if @@ -1523,8 +1547,7 @@ def output_members(obj: Dict[str, Any], cluster: Cluster, name: str, clusters = {group or 0: cluster_as_json(cluster)} - is_citus_cluster = obj.get('citus') - if is_citus_cluster: + if is_citus_cluster(): columns.insert(1, 'Group') if group is None: clusters.update({g: cluster_as_json(c) for g, c in cluster.workers.items()}) @@ -1562,10 +1585,12 @@ def output_members(obj: Dict[str, Any], cluster: Cluster, name: str, rows.append([member.get(n.lower().replace(' ', '_'), '') for n in columns]) - title = 'Citus cluster' if is_citus_cluster else 'Cluster' - title_details = f' ({initialize})' - if is_citus_cluster: + if is_citus_cluster(): + title = 'Citus cluster' title_details = '' if group is None else f' (group: {group}, {initialize})' + else: + title = 'Cluster' + title_details = f' ({initialize})' title = f' {title}: {name}{title_details} ' print_output(columns, rows, {'Group': 'r', 'Lag in MB': 'r', 'TL': 'r'}, fmt, title) @@ -1576,7 +1601,7 @@ def output_members(obj: Dict[str, Any], cluster: Cluster, name: str, for g, c in sorted(clusters.items()): service_info = get_cluster_service_info(c) if service_info: - if is_citus_cluster and group is None: + if is_citus_cluster() and group is None: click.echo('Citus group: {0}'.format(g)) click.echo(' ' + '\n '.join(service_info)) @@ -1589,16 +1614,14 @@ def output_members(obj: Dict[str, Any], cluster: Cluster, name: str, @option_format @option_watch @option_watchrefresh -@click.pass_obj -def members(obj: Dict[str, Any], cluster_names: List[str], group: Optional[int], - fmt: str, watch: Optional[int], w: bool, extended: bool, ts: bool) -> None: +def members(cluster_names: List[str], group: Optional[int], fmt: str, + watch: Optional[int], w: bool, extended: bool, ts: bool) -> None: """Process ``list`` command of ``patronictl`` utility. Print information about the Patroni cluster through :func:`output_members`. - :param obj: Patroni configuration. :param cluster_names: name of clusters that should be printed. If ``None`` consider only the cluster present in - ``scope`` key of *obj*. + ``scope`` key of the configuration. :param group: filter which Citus group we should get members from. Refer to the module note for more details. :param fmt: the output table printing format. See :func:`print_output` for available options. :param watch: if given print output every *watch* seconds. @@ -1607,9 +1630,10 @@ def members(obj: Dict[str, Any], cluster_names: List[str], group: Optional[int], more details. :param ts: if timestamp should be included in the output. """ + config = _get_configuration() if not cluster_names: - if 'scope' in obj: - cluster_names = [obj['scope']] + if 'scope' in config: + cluster_names = [config['scope']] if not cluster_names: return logging.warning('Listing members: No cluster names were provided') @@ -1618,10 +1642,10 @@ def members(obj: Dict[str, Any], cluster_names: List[str], group: Optional[int], click.echo(timestamp(0)) for cluster_name in cluster_names: - dcs = get_dcs(obj, cluster_name, group) + dcs = get_dcs(cluster_name, group) cluster = dcs.get_cluster() - output_members(obj, cluster, cluster_name, extended, fmt, group) + output_members(cluster, cluster_name, extended, fmt, group) @ctl.command('topology', help='Prints ASCII topology for given cluster') @@ -1663,14 +1687,12 @@ def timestamp(precision: int = 6) -> str: @click.argument('target', type=click.Choice(['restart', 'switchover'])) @click.option('--role', '-r', help='Flush only members with this role', type=role_choice, default='any') @option_force -@click.pass_obj -def flush(obj: Dict[str, Any], cluster_name: str, group: Optional[int], +def flush(cluster_name: str, group: Optional[int], member_names: List[str], force: bool, role: str, target: str) -> None: """Process ``flush`` command of ``patronictl`` utility. Discard scheduled restart or switchover events. - :param obj: Patroni configuration. :param cluster_name: name of the Patroni cluster. :param group: filter which Citus group we should flush an event. Refer to the module note for more details. :param member_names: name of the members which events should be flushed. @@ -1678,11 +1700,11 @@ def flush(obj: Dict[str, Any], cluster_name: str, group: Optional[int], :param role: role to filter members. See :func:`get_all_members` for available options. :param target: the event that should be flushed -- ``restart`` or ``switchover``. """ - dcs = get_dcs(obj, cluster_name, group) + dcs = get_dcs(cluster_name, group) cluster = dcs.get_cluster() if target == 'restart': - for member in get_members(obj, cluster, cluster_name, member_names, role, force, 'flush', group=group): + for member in get_members(cluster, cluster_name, member_names, role, force, 'flush', group=group): if member.data.get('scheduled_restart'): r = request_patroni(member, 'delete', 'restart') check_response(r, member.name, 'flush scheduled restart') @@ -1718,8 +1740,7 @@ def wait_until_pause_is_applied(dcs: AbstractDCS, paused: bool, old_cluster: Clu :param old_cluster: original cluster information before pause or unpause has been requested. Used to report which nodes are still pending to have ``pause`` equal *paused* at a given point in time. """ - from patroni.config import get_global_config - config = get_global_config(old_cluster) + config = global_config.from_cluster(old_cluster) click.echo("'{0}' request sent, waiting until it is recognized by all nodes".format(paused and 'pause' or 'resume')) old = {m.name: m.version for m in old_cluster.members if m.api_url} @@ -1741,10 +1762,9 @@ def wait_until_pause_is_applied(dcs: AbstractDCS, paused: bool, old_cluster: Clu return click.echo('Success: cluster management is {0}'.format(paused and 'paused' or 'resumed')) -def toggle_pause(config: Dict[str, Any], cluster_name: str, group: Optional[int], paused: bool, wait: bool) -> None: +def toggle_pause(cluster_name: str, group: Optional[int], paused: bool, wait: bool) -> None: """Toggle the ``pause`` state in the cluster members. - :param config: Patroni configuration. :param cluster_name: name of the Patroni cluster. :param group: filter which Citus group we should toggle the pause state of. Refer to the module note for more details. @@ -1756,10 +1776,9 @@ def toggle_pause(config: Dict[str, Any], cluster_name: str, group: Optional[int] * ``pause`` state is already *paused*; or * cluster contains no accessible members. """ - from patroni.config import get_global_config - dcs = get_dcs(config, cluster_name, group) + dcs = get_dcs(cluster_name, group) cluster = dcs.get_cluster() - if get_global_config(cluster).is_paused == paused: + if global_config.from_cluster(cluster).is_paused == paused: raise PatroniCtlException('Cluster is {0} paused'.format(paused and 'already' or 'not')) for member in get_all_members_leader_first(cluster): @@ -1786,37 +1805,33 @@ def toggle_pause(config: Dict[str, Any], cluster_name: str, group: Optional[int] @ctl.command('pause', help='Disable auto failover') @arg_cluster_name @option_default_citus_group -@click.pass_obj @click.option('--wait', help='Wait until pause is applied on all nodes', is_flag=True) -def pause(obj: Dict[str, Any], cluster_name: str, group: Optional[int], wait: bool) -> None: +def pause(cluster_name: str, group: Optional[int], wait: bool) -> None: """Process ``pause`` command of ``patronictl`` utility. Put the cluster in maintenance mode. - :param obj: Patroni configuration. :param cluster_name: name of the Patroni cluster. :param group: filter which Citus group we should pause. Refer to the module note for more details. :param wait: ``True`` if it should block until the operation is finished or ``false`` for returning immediately. """ - return toggle_pause(obj, cluster_name, group, True, wait) + return toggle_pause(cluster_name, group, True, wait) @ctl.command('resume', help='Resume auto failover') @arg_cluster_name @option_default_citus_group @click.option('--wait', help='Wait until pause is cleared on all nodes', is_flag=True) -@click.pass_obj -def resume(obj: Dict[str, Any], cluster_name: str, group: Optional[int], wait: bool) -> None: +def resume(cluster_name: str, group: Optional[int], wait: bool) -> None: """Process ``unpause`` command of ``patronictl`` utility. Put the cluster out of maintenance mode. - :param obj: Patroni configuration. :param cluster_name: name of the Patroni cluster. :param group: filter which Citus group we should unpause. Refer to the module note for more details. :param wait: ``True`` if it should block until the operation is finished or ``false`` for returning immediately. """ - return toggle_pause(obj, cluster_name, group, False, wait) + return toggle_pause(cluster_name, group, False, wait) @contextmanager @@ -2048,15 +2063,12 @@ def invoke_editor(before_editing: str, cluster_name: str) -> Tuple[str, Dict[str @click.option('--replace', 'replace_filename', help='Apply configuration from file, replacing existing configuration.' ' Use - for stdin.') @option_force -@click.pass_obj -def edit_config(obj: Dict[str, Any], cluster_name: str, group: Optional[int], - force: bool, quiet: bool, kvpairs: List[str], pgkvpairs: List[str], - apply_filename: Optional[str], replace_filename: Optional[str]) -> None: +def edit_config(cluster_name: str, group: Optional[int], force: bool, quiet: bool, kvpairs: List[str], + pgkvpairs: List[str], apply_filename: Optional[str], replace_filename: Optional[str]) -> None: """Process ``edit-config`` command of ``patronictl`` utility. Update or replace Patroni configuration in the DCS. - :param obj: Patroni configuration. :param cluster_name: name of the Patroni cluster. :param group: filter which Citus group configuration we should edit. Refer to the module note for more details. :param force: if ``True`` apply config changes without asking for confirmations. @@ -2073,7 +2085,7 @@ def edit_config(obj: Dict[str, Any], cluster_name: str, group: Optional[int], * Configuration is absent from DCS; or * Detected a concurrent modification of the configuration in the DCS. """ - dcs = get_dcs(obj, cluster_name, group) + dcs = get_dcs(cluster_name, group) cluster = dcs.get_cluster() if not cluster.config: @@ -2111,7 +2123,7 @@ def edit_config(obj: Dict[str, Any], cluster_name: str, group: Optional[int], return if force or click.confirm('Apply these changes?'): - if not dcs.set_config_value(json.dumps(changed_data), cluster.config.version): + if not dcs.set_config_value(json.dumps(changed_data, separators=(',', ':')), cluster.config.version): raise PatroniCtlException("Config modification aborted due to concurrent changes") click.echo("Configuration changed") @@ -2119,17 +2131,15 @@ def edit_config(obj: Dict[str, Any], cluster_name: str, group: Optional[int], @ctl.command('show-config', help="Show cluster configuration") @arg_cluster_name @option_default_citus_group -@click.pass_obj -def show_config(obj: Dict[str, Any], cluster_name: str, group: Optional[int]) -> None: +def show_config(cluster_name: str, group: Optional[int]) -> None: """Process ``show-config`` command of ``patronictl`` utility. Show Patroni configuration stored in the DCS. - :param obj: Patroni configuration. :param cluster_name: name of the Patroni cluster. :param group: filter which Citus group configuration we should show. Refer to the module note for more details. """ - cluster = get_dcs(obj, cluster_name, group).get_cluster() + cluster = get_dcs(cluster_name, group).get_cluster() if cluster.config: click.echo(format_config_for_editing(cluster.config.data)) @@ -2138,8 +2148,7 @@ def show_config(obj: Dict[str, Any], cluster_name: str, group: Optional[int]) -> @click.argument('cluster_name', required=False) @click.argument('member_names', nargs=-1) @option_citus_group -@click.pass_obj -def version(obj: Dict[str, Any], cluster_name: str, group: Optional[int], member_names: List[str]) -> None: +def version(cluster_name: str, group: Optional[int], member_names: List[str]) -> None: """Process ``version`` command of ``patronictl`` utility. Show version of: @@ -2147,7 +2156,6 @@ def version(obj: Dict[str, Any], cluster_name: str, group: Optional[int], member * ``patroni`` on all members of the cluster; * ``PostgreSQL`` on all members of the cluster. - :param obj: Patroni configuration. :param cluster_name: name of the Patroni cluster. :param group: filter which Citus group we should get members from. Refer to the module note for more details. :param member_names: filter which members we should get version information from. @@ -2158,8 +2166,8 @@ def version(obj: Dict[str, Any], cluster_name: str, group: Optional[int], member return click.echo("") - cluster = get_dcs(obj, cluster_name, group).get_cluster() - for m in get_all_members(obj, cluster, group, 'any'): + cluster = get_dcs(cluster_name, group).get_cluster() + for m in get_all_members(cluster, group, 'any'): if m.api_url: if not member_names or m.name in member_names: try: @@ -2177,8 +2185,7 @@ def version(obj: Dict[str, Any], cluster_name: str, group: Optional[int], member @arg_cluster_name @option_default_citus_group @option_format -@click.pass_obj -def history(obj: Dict[str, Any], cluster_name: str, group: Optional[int], fmt: str) -> None: +def history(cluster_name: str, group: Optional[int], fmt: str) -> None: """Process ``history`` command of ``patronictl`` utility. Show the history of failover/switchover events in the cluster. @@ -2190,12 +2197,11 @@ def history(obj: Dict[str, Any], cluster_name: str, group: Optional[int], fmt: s * ``Timestamp``: timestamp when the event occurred; * ``New Leader``: the Postgres node that was promoted during the event. - :param obj: Patroni configuration. :param cluster_name: name of the Patroni cluster. :param group: filter which Citus group we should get events from. Refer to the module note for more details. :param fmt: the output table printing format. See :func:`print_output` for available options. """ - cluster = get_dcs(obj, cluster_name, group).get_cluster() + cluster = get_dcs(cluster_name, group).get_cluster() cluster_history = cluster.history.lines if cluster.history else [] history: List[List[Any]] = list(map(list, cluster_history)) table_header_row = ['TL', 'LSN', 'Reason', 'Timestamp', 'New Leader'] diff --git a/patroni/dcs/__init__.py b/patroni/dcs/__init__.py index 4cef65ea..28c3734f 100644 --- a/patroni/dcs/__init__.py +++ b/patroni/dcs/__init__.py @@ -1,33 +1,32 @@ """Abstract classes for Distributed Configuration Store.""" import abc import datetime -import importlib -import inspect import json import logging -import os -import pkgutil import re -import sys import time from collections import defaultdict from copy import deepcopy from random import randint from threading import Event, Lock -from types import ModuleType -from typing import Any, Callable, Collection, Dict, List, NamedTuple, Optional, Set, Tuple, Union, TYPE_CHECKING, \ - Type, Iterator +from typing import Any, Callable, Collection, Dict, Iterator, List, \ + NamedTuple, Optional, Tuple, Type, TYPE_CHECKING, Union from urllib.parse import urlparse, urlunparse, parse_qsl import dateutil.parser +from .. import global_config +from ..dynamic_loader import iter_classes, iter_modules from ..exceptions import PatroniFatalException from ..utils import deep_compare, uri from ..tags import Tags +from ..utils import parse_int if TYPE_CHECKING: # pragma: no cover from ..config import Config + from ..postgresql import Postgresql +SLOT_ADVANCE_AVAILABLE_VERSION = 110000 CITUS_COORDINATOR_GROUP_ID = 0 citus_group_re = re.compile('^(0|[1-9][0-9]*)$') slot_name_re = re.compile('^[a-z0-9_]{1,63}$') @@ -85,28 +84,9 @@ def parse_connection_string(value: str) -> Tuple[str, Union[str, None]]: def dcs_modules() -> List[str]: """Get names of DCS modules, depending on execution environment. - .. note:: - If being packaged with PyInstaller, modules aren't discoverable dynamically by scanning source directory because - :class:`importlib.machinery.FrozenImporter` doesn't implement :func:`iter_modules`. But it is still possible to - find all potential DCS modules by iterating through ``toc``, which contains list of all "frozen" resources. - :returns: list of known module names with absolute python module path namespace, e.g. ``patroni.dcs.etcd``. """ - dcs_dirname = os.path.dirname(__file__) - module_prefix = __package__ + '.' - - if getattr(sys, 'frozen', False): - toc: Set[str] = set() - # dcs_dirname may contain a dot, which causes pkgutil.iter_importers() - # to misinterpret the path as a package name. This can be avoided - # altogether by not passing a path at all, because PyInstaller's - # FrozenImporter is a singleton and registered as top-level finder. - for importer in pkgutil.iter_importers(): - if hasattr(importer, 'toc'): - toc |= getattr(importer, 'toc') - return [module for module in toc if module.startswith(module_prefix) and module.count('.') == 2] - - return [module_prefix + name for _, name, is_pkg in pkgutil.iter_modules([dcs_dirname]) if not is_pkg] + return iter_modules(__package__) def iter_dcs_classes( @@ -120,44 +100,16 @@ def iter_dcs_classes( :param config: configuration information with possible DCS names as keys. If given, only attempt to import DCS modules defined in the configuration. Else, if ``None``, attempt to import any supported DCS module. - :yields: a tuple containing the module ``name`` and the imported DCS class object. + :returns: an iterator of tuples, each containing the module ``name`` and the imported DCS class object. """ - for mod_name in dcs_modules(): - name = mod_name.rpartition('.')[2] - if config is None or name in config: - - try: - module = importlib.import_module(mod_name) - dcs_module = find_dcs_class_in_module(module) - if dcs_module: - yield name, dcs_module - - except ImportError: - logger.log(logging.DEBUG if config is not None else logging.INFO, - 'Failed to import %s', mod_name) - - -def find_dcs_class_in_module(module: ModuleType) -> Optional[Type['AbstractDCS']]: - """Try to find the implementation of :class:`AbstractDCS` interface in *module* matching the *module* name. - - :param module: Imported DCS module. - - :returns: class with a name matching the name of *module* that implements :class:`AbstractDCS` or ``None`` if not - found. - """ - module_name = module.__name__.rpartition('.')[2] - return next( - (obj for obj_name, obj in module.__dict__.items() - if (obj_name.lower() == module_name - and inspect.isclass(obj) and issubclass(obj, AbstractDCS))), - None) + return iter_classes(__package__, AbstractDCS, config) def get_dcs(config: Union['Config', Dict[str, Any]]) -> 'AbstractDCS': """Attempt to load a Distributed Configuration Store from known available implementations. .. note:: - Using the list of available DCS modules returned by :func:`iter_dcs_modules` attempt to dynamically import and + Using the list of available DCS classes returned by :func:`iter_classes` attempt to dynamically instantiate the class that implements a DCS using the abstract class :class:`AbstractDCS`. Basic top-level configuration parameters retrieved from *config* are propagated to the DCS specific config @@ -183,9 +135,9 @@ def get_dcs(config: Union['Config', Dict[str, Any]]) -> 'AbstractDCS': config[name].update(config['citus']) return dcs_class(config[name]) - raise PatroniFatalException( - f"Can not find suitable configuration of distributed configuration store\n" - f"Available implementations: {', '.join(sorted([n for n, _ in iter_dcs_classes()]))}") + available_implementations = ', '.join(sorted([n for n, _ in iter_dcs_classes()])) + raise PatroniFatalException("Can not find suitable configuration of distributed configuration store\n" + f"Available implementations: {available_implementations}") _Version = Union[int, str] @@ -350,6 +302,11 @@ class Member(Tags, NamedTuple('Member', logger.debug('Failed to parse Patroni version %s', version) return None + @property + def lsn(self) -> Optional[int]: + """Current LSN (receive/flush/replay).""" + return parse_int(self.data.get('xlog_location')) + class RemoteMember(Member): """Represents a remote member (typically a primary) for a standby cluster. @@ -583,24 +540,6 @@ class ClusterConfig(NamedTuple): modify_version = 0 return ClusterConfig(version, data, version if modify_version is None else modify_version) - @property - def permanent_slots(self) -> Dict[str, Any]: - """Dictionary of permanent slots information looked up from :attr:`~ClusterConfig.data`.""" - return (self.data.get('permanent_replication_slots') - or self.data.get('permanent_slots') - or self.data.get('slots') - or {}) - - @property - def ignore_slots_matchers(self) -> List[Dict[str, Any]]: - """The value for ``ignore_slots`` from :attr:`~ClusterConfig.data` if defined or an empty list.""" - return self.data.get('ignore_slots') or [] - - @property - def max_timelines_history(self) -> int: - """The value for ``max_timelines_history`` from :attr:`~ClusterConfig.data` if defined or ``0``.""" - return self.data.get('max_timelines_history', 0) - class SyncState(NamedTuple): """Immutable object (namedtuple) which represents last observed synchronous replication state. @@ -619,7 +558,7 @@ class SyncState(NamedTuple): """Factory method to parse *value* as synchronisation state information. :param version: optional *version* number for the object. - :param value: (optionally JSON serialised) sychronisation state information + :param value: (optionally JSON serialised) synchronisation state information :returns: constructed :class:`SyncState` object. @@ -778,16 +717,71 @@ class TimelineHistory(NamedTuple): return TimelineHistory(version, value, lines) +class Status(NamedTuple): + """Immutable object (namedtuple) which represents `/status` key. + + Consists of the following fields: + + :ivar last_lsn: :class:`int` object containing position of last known leader LSN. + :ivar slots: state of permanent replication slots on the primary in the format: ``{"slot_name": int}``. + """ + last_lsn: int + slots: Optional[Dict[str, int]] + + @staticmethod + def empty() -> 'Status': + """Construct an empty :class:`Status` instance. + + :returns: empty :class:`Status` object. + """ + return Status(0, None) + + @staticmethod + def from_node(value: Union[str, Dict[str, Any], None]) -> 'Status': + """Factory method to parse *value* as :class:`Status` object. + + :param value: JSON serialized string + + :returns: constructed :class:`Status` object. + """ + try: + if isinstance(value, str): + value = json.loads(value) + except Exception: + return Status.empty() + + if isinstance(value, int): # legacy + return Status(value, None) + + if not isinstance(value, dict): + return Status.empty() + + try: + last_lsn = int(value.get('optime', '')) + except Exception: + last_lsn = 0 + + slots: Union[str, Dict[str, int], None] = value.get('slots') + if isinstance(slots, str): + try: + slots = json.loads(slots) + except Exception: + slots = None + if not isinstance(slots, dict): + slots = None + + return Status(last_lsn, slots) + + class Cluster(NamedTuple('Cluster', [('initialize', Optional[str]), ('config', Optional[ClusterConfig]), ('leader', Optional[Leader]), - ('last_lsn', int), + ('status', Status), ('members', List[Member]), ('failover', Optional[Failover]), ('sync', SyncState), ('history', Optional[TimelineHistory]), - ('slots', Optional[Dict[str, int]]), ('failsafe', Optional[Dict[str, str]]), ('workers', Dict[int, 'Cluster'])])): """Immutable object (namedtuple) which represents PostgreSQL or Citus cluster. @@ -801,13 +795,11 @@ class Cluster(NamedTuple('Cluster', :ivar initialize: shows whether this cluster has initialization key stored in DC or not. :ivar config: global dynamic configuration, reference to `ClusterConfig` object. :ivar leader: :class:`Leader` object which represents current leader of the cluster. - :ivar last_lsn: :class:int object containing position of last known leader LSN. - This value is stored in the `/status` key or `/optime/leader` (legacy) key. + :ivar status: :class:`Status` object which represents the `/status` key. :ivar members: list of:class:` Member` objects, all PostgreSQL cluster members including leader :ivar failover: reference to :class:`Failover` object. :ivar sync: reference to :class:`SyncState` object, last observed synchronous replication state. :ivar history: reference to `TimelineHistory` object. - :ivar slots: state of permanent logical replication slots on the primary in the format: {"slot_name": int}. :ivar failsafe: failsafe topology. Node is allowed to become the leader only if its name is found in this list. :ivar workers: dictionary of workers of the Citus cluster, optional. Each key is an :class:`int` representing the group, and the corresponding value is a :class:`Cluster` instance. @@ -819,10 +811,20 @@ class Cluster(NamedTuple('Cluster', kwargs['workers'] = {} return super(Cluster, cls).__new__(cls, *args, **kwargs) + @property + def last_lsn(self) -> int: + """Last known leader LSN.""" + return self.status.last_lsn + + @property + def slots(self) -> Optional[Dict[str, int]]: + """State of permanent replication slots on the primary in the format: ``{"slot_name": int}``.""" + return self.status.slots + @staticmethod def empty() -> 'Cluster': """Produce an empty :class:`Cluster` instance.""" - return Cluster(None, None, None, 0, [], None, SyncState.empty(), None, None, None, {}) + return Cluster(None, None, None, Status.empty(), [], None, SyncState.empty(), None, None, {}) def is_empty(self): """Validate definition of all attributes of this :class:`Cluster` instance. @@ -845,7 +847,7 @@ class Cluster(NamedTuple('Cluster', >>> assert bool(cluster) is False - >>> cluster = Cluster(None, None, None, 0, [1, 2, 3], None, SyncState.empty(), None, None, None, {}) + >>> cluster = Cluster(None, None, None, Status(0, None), [1, 2, 3], None, SyncState.empty(), None, None, {}) >>> len(cluster) 1 @@ -901,59 +903,83 @@ class Cluster(NamedTuple('Cluster', candidates = [m for m in self.members if m.clonefrom and m.is_running and m.name not in exclude] return candidates[randint(0, len(candidates) - 1)] if candidates else self.leader + @staticmethod + def is_physical_slot(value: Union[Any, Dict[str, Any]]) -> bool: + """Check whether provided configuration is for permanent physical replication slot. + + :param value: configuration of the permanent replication slot. + + :returns: ``True`` if *value* is a physical replication slot, otherwise ``False``. + """ + return not value or isinstance(value, dict) and value.get('type', 'physical') == 'physical' + + @staticmethod + def is_logical_slot(value: Union[Any, Dict[str, Any]]) -> bool: + """Check whether provided configuration is for permanent logical replication slot. + + :param value: configuration of the permanent replication slot. + + :returns: ``True`` if *value* is a logical replication slot, otherwise ``False``. + """ + return isinstance(value, dict) \ + and value.get('type', 'logical') == 'logical' \ + and bool(value.get('database') and value.get('plugin')) + @property def __permanent_slots(self) -> Dict[str, Union[Dict[str, Any], Any]]: """Dictionary of permanent replication slots with their known LSN.""" - ret = deepcopy(self.config.permanent_slots if self.config else {}) - # If primary reported flush LSN for permanent slots we want to enrich our structure with it - for name, lsn in (self.slots or {}).items(): - if name in ret: - if not ret[name]: - ret[name] = {} - if isinstance(ret[name], dict): - ret[name]['lsn'] = lsn + ret: Dict[str, Union[Dict[str, Any], Any]] = global_config.permanent_slots + + members: Dict[str, int] = {slot_name_from_member_name(m.name): m.lsn or 0 for m in self.members} + slots: Dict[str, int] = {k: parse_int(v) or 0 for k, v in (self.slots or {}).items()} + for name, value in list(ret.items()): + if not value: + value = ret[name] = {} + if isinstance(value, dict): + # for permanent physical slots we want to get MAX LSN from the `Cluster.slots` and from the + # member with the matching name. It is necessary because we may have the replication slot on + # the primary that is streaming from the other standby node using the `replicatefrom` tag. + lsn = max(members.get(name, 0) if self.is_physical_slot(value) else 0, slots.get(name, 0)) + if lsn: + value['lsn'] = lsn + else: + # Don't let anyone set 'lsn' in the global configuration :) + value.pop('lsn', None) return ret @property def __permanent_physical_slots(self) -> Dict[str, Any]: """Dictionary of permanent ``physical`` replication slots.""" - return {name: value for name, value in self.__permanent_slots.items() - if not value or isinstance(value, dict) and value.get('type', 'physical') == 'physical'} + return {name: value for name, value in self.__permanent_slots.items() if self.is_physical_slot(value)} @property def __permanent_logical_slots(self) -> Dict[str, Any]: """Dictionary of permanent ``logical`` replication slots.""" - return {name: value for name, value in self.__permanent_slots.items() if isinstance(value, dict) - and value.get('type', 'logical') == 'logical' and value.get('database') and value.get('plugin')} + return {name: value for name, value in self.__permanent_slots.items() if self.is_logical_slot(value)} - @property - def use_slots(self) -> bool: - """``True`` if cluster is configured to use replication slots.""" - return bool(self.config and (self.config.data.get('postgresql') or {}).get('use_slots', True)) - - def get_replication_slots(self, my_name: str, role: str, nofailover: bool, major_version: int, *, - is_standby_cluster: bool = False, show_error: bool = False) -> Dict[str, Dict[str, Any]]: + def get_replication_slots(self, postgresql: 'Postgresql', member: Tags, *, + role: Optional[str] = None, show_error: bool = False) -> Dict[str, Dict[str, Any]]: """Lookup configured slot names in the DCS, report issues found and merge with permanent slots. Will log an error if: * Any logical slots are disabled, due to version compatibility, and *show_error* is ``True``. - :param my_name: name of this node. - :param role: role of this node. - :param nofailover: ``True`` if this node is tagged to not be a failover candidate. - :param major_version: postgresql major version. - :param is_standby_cluster: ``True`` if it is known that this is a standby cluster. We pass the value from - the outside because we want to protect from the ``/config`` key removal. + :param postgresql: reference to :class:`Postgresql` object. + :param member: reference to an object implementing :class:`Tags` interface. + :param role: role of the node, if not set will be taken from *postgresql*. :param show_error: if ``True`` report error if any disabled logical slots or conflicting slot names are found. :returns: final dictionary of slot names, after merging with permanent slots and performing sanity checks. """ - slots: Dict[str, Dict[str, str]] = self._get_members_slots(my_name, role) - permanent_slots: Dict[str, Any] = self._get_permanent_slots(is_standby_cluster, role, nofailover) + name = member.name if isinstance(member, Member) else postgresql.name + role = role or postgresql.role + + slots: Dict[str, Dict[str, str]] = self._get_members_slots(name, role) + permanent_slots: Dict[str, Any] = self._get_permanent_slots(postgresql, member, role) disabled_permanent_logical_slots: List[str] = self._merge_permanent_slots( - slots, permanent_slots, my_name, major_version) + slots, permanent_slots, name, postgresql.major_version) if disabled_permanent_logical_slots and show_error: logger.error("Permanent logical replication slots supported by Patroni only starting from PostgreSQL 11. " @@ -961,8 +987,7 @@ class Cluster(NamedTuple('Cluster', return slots - @staticmethod - def _merge_permanent_slots(slots: Dict[str, Dict[str, str]], permanent_slots: Dict[str, Any], my_name: str, + def _merge_permanent_slots(self, slots: Dict[str, Dict[str, str]], permanent_slots: Dict[str, Any], name: str, major_version: int) -> List[str]: """Merge replication *slots* for members with *permanent_slots*. @@ -972,7 +997,7 @@ class Cluster(NamedTuple('Cluster', Type is assumed to be ``physical`` if there are no attributes stored as the slot value. :param slots: Slot names with existing attributes if known. - :param my_name: name of this node. + :param name: name of this node. :param permanent_slots: dictionary containing slot name key and slot information values. :param major_version: postgresql major version. @@ -980,9 +1005,9 @@ class Cluster(NamedTuple('Cluster', """ disabled_permanent_logical_slots: List[str] = [] - for name, value in permanent_slots.items(): - if not slot_name_re.match(name): - logger.error("Invalid permanent replication slot name '%s'", name) + for slot_name, value in permanent_slots.items(): + if not slot_name_re.match(slot_name): + logger.error("Invalid permanent replication slot name '%s'", slot_name) logger.error("Slot name may only contain lower case letters, numbers, and the underscore chars") continue @@ -993,24 +1018,24 @@ class Cluster(NamedTuple('Cluster', if value['type'] == 'physical': # Don't try to create permanent physical replication slot for yourself - if name != slot_name_from_member_name(my_name): - slots[name] = value + if slot_name != slot_name_from_member_name(name): + slots[slot_name] = value continue - if value['type'] == 'logical' and value.get('database') and value.get('plugin'): - if major_version < 110000: - disabled_permanent_logical_slots.append(name) - elif name in slots: + if self.is_logical_slot(value): + if major_version < SLOT_ADVANCE_AVAILABLE_VERSION: + disabled_permanent_logical_slots.append(slot_name) + elif slot_name in slots: logger.error("Permanent logical replication slot {'%s': %s} is conflicting with" - " physical replication slot for cluster member", name, value) + " physical replication slot for cluster member", slot_name, value) else: - slots[name] = value + slots[slot_name] = value continue - logger.error("Bad value for slot '%s' in permanent_slots: %s", name, permanent_slots[name]) + logger.error("Bad value for slot '%s' in permanent_slots: %s", slot_name, permanent_slots[slot_name]) return disabled_permanent_logical_slots - def _get_permanent_slots(self, is_standby_cluster: bool, role: str, nofailover: bool) -> Dict[str, Any]: + def _get_permanent_slots(self, postgresql: 'Postgresql', tags: Tags, role: str) -> Dict[str, Any]: """Get configured permanent replication slots. .. note:: @@ -1022,22 +1047,23 @@ class Cluster(NamedTuple('Cluster', The returned dictionary for a non-standby cluster always contains permanent logical replication slots in order to show a warning if they are not supported by PostgreSQL before v11. - :param is_standby_cluster: ``True`` if it is known that this is a standby cluster. We pass the value from - the outside because we want to protect from the ``/config`` key removal. - :param role: role of this node -- ``primary``, ``standby_leader`` or ``replica``. - :param nofailover: ``True`` if this node is tagged to not be a failover candidate. + :param postgresql: reference to :class:`Postgresql` object. + :param tags: reference to an object implementing :class:`Tags` interface. + :param role: role of the node -- ``primary``, ``standby_leader`` or ``replica``. :returns: dictionary of permanent slot names mapped to attributes. """ - if not self.use_slots or nofailover: + if not global_config.use_slots or tags.nofailover: return {} - if is_standby_cluster: - return self.__permanent_physical_slots if role == 'standby_leader' else {} + if global_config.is_standby_cluster: + return self.__permanent_physical_slots \ + if postgresql.major_version >= SLOT_ADVANCE_AVAILABLE_VERSION or role == 'standby_leader' else {} - return self.__permanent_slots if role in ('master', 'primary') else self.__permanent_logical_slots + return self.__permanent_slots if postgresql.major_version >= SLOT_ADVANCE_AVAILABLE_VERSION\ + or role in ('master', 'primary') else self.__permanent_logical_slots - def _get_members_slots(self, my_name: str, role: str) -> Dict[str, Dict[str, str]]: + def _get_members_slots(self, name: str, role: str) -> Dict[str, Dict[str, str]]: """Get physical replication slots configuration for members that sourcing from this node. If the ``replicatefrom`` tag is set on the member - we should not create the replication slot for it on @@ -1049,25 +1075,25 @@ class Cluster(NamedTuple('Cluster', * Conflicting slot names between members are found - :param my_name: name of this node. + :param name: name of this node. :param role: role of this node, if this is a ``primary`` or ``standby_leader`` return list of members replicating from this node. If not then return a list of members replicating as cascaded replicas from this node. :returns: dictionary of physical replication slots that should exist on a given node. """ - if not self.use_slots: + if not global_config.use_slots: return {} # we always want to exclude the member with our name from the list - members = filter(lambda m: m.name != my_name, self.members) + members = filter(lambda m: m.name != name, self.members) if role in ('master', 'primary', 'standby_leader'): members = [m for m in members if m.replicatefrom is None - or m.replicatefrom == my_name or not self.has_member(m.replicatefrom)] + or m.replicatefrom == name or not self.has_member(m.replicatefrom)] else: # only manage slots for replicas that replicate from this one, except for the leader among them - members = [m for m in members if m.replicatefrom == my_name and m.name != self.leader_name] + members = [m for m in members if m.replicatefrom == name and m.name != self.leader_name] slots = {slot_name_from_member_name(m.name): {'type': 'physical'} for m in members} if len(slots) < len(members): @@ -1080,46 +1106,76 @@ class Cluster(NamedTuple('Cluster', for k, v in slot_conflicts.items() if len(v) > 1)) return slots - def has_permanent_logical_slots(self, my_name: str, nofailover: bool, major_version: int = 110000) -> bool: + def has_permanent_slots(self, postgresql: 'Postgresql', member: Tags) -> bool: + """Check if our node has permanent replication slots configured. + + :param postgresql: reference to :class:`Postgresql` object. + :param member: reference to an object implementing :class:`Tags` interface for + the node that we are checking permanent logical replication slots for. + + :returns: ``True`` if there are permanent replication slots configured, otherwise ``False``. + """ + role = 'replica' + members_slots: Dict[str, Dict[str, str]] = self._get_members_slots(postgresql.name, role) + permanent_slots: Dict[str, Any] = self._get_permanent_slots(postgresql, member, role) + slots = deepcopy(members_slots) + self._merge_permanent_slots(slots, permanent_slots, postgresql.name, postgresql.major_version) + return len(slots) > len(members_slots) or any(self.is_physical_slot(v) for v in permanent_slots.values()) + + def filter_permanent_slots(self, postgresql: 'Postgresql', slots: Dict[str, int]) -> Dict[str, int]: + """Filter out all non-permanent slots from provided *slots* dict. + + :param postgresql: reference to :class:`Postgresql` object. + :param slots: slot names with LSN values. + + :returns: a :class:`dict` object that contains only slots that are known to be permanent. + """ + if postgresql.major_version < SLOT_ADVANCE_AVAILABLE_VERSION: + return {} # for legacy PostgreSQL we don't support permanent slots on standby nodes + + permanent_slots: Dict[str, Any] = self._get_permanent_slots(postgresql, RemoteMember('', {}), 'replica') + members_slots = {slot_name_from_member_name(m.name) for m in self.members} + + return {name: value for name, value in slots.items() if name in permanent_slots + and (self.is_physical_slot(permanent_slots[name]) + or self.is_logical_slot(permanent_slots[name]) and name not in members_slots)} + + def _has_permanent_logical_slots(self, postgresql: 'Postgresql', member: Tags) -> bool: """Check if the given member node has permanent ``logical`` replication slots configured. - :param my_name: name of the member node to check. - :param nofailover: ``True`` if this node is tagged to not be a failover candidate. - :param major_version: the PostgreSQL major version number. + :param postgresql: reference to a :class:`Postgresql` object. + :param member: reference to an object implementing :class:`Tags` interface for + the node that we are checking permanent logical replication slots for. - :returns: ``False`` if PostgreSQL is < 11, ``True`` if any detected replications slots are ``logical``. + :returns: ``True`` if any detected replications slots are ``logical``, otherwise ``False``. """ - if major_version < 110000: - return False - slots = self.get_replication_slots(my_name, 'replica', nofailover, major_version).values() + slots = self.get_replication_slots(postgresql, member, role='replica').values() return any(v for v in slots if v.get("type") == "logical") - def should_enforce_hot_standby_feedback(self, my_name: str, nofailover: bool, major_version: int) -> bool: + def should_enforce_hot_standby_feedback(self, postgresql: 'Postgresql', member: Tags) -> bool: """Determine whether ``hot_standby_feedback`` should be enabled for the given member. The ``hot_standby_feedback`` must be enabled if the current replica has ``logical`` slots, or it is working as a cascading replica for the other node that has ``logical`` slots. - :param my_name: name of the member node to check. - :param nofailover: ``True`` if this node is tagged to not be a failover candidate. - :param major_version: PostgreSQL major version number. + :param postgresql: reference to a :class:`Postgresql` object. + :param member: reference to an object implementing :class:`Tags` interface for + the node that we are checking permanent logical replication slots for. - :returns: ``True`` if this node or any member replicating from this node has permanent logical slots. - ``False`` if PostgreSQL major version is < 11. + :returns: ``True`` if this node or any member replicating from this node has + permanent logical slots, otherwise ``False``. """ - if major_version < 110000: - return False - - if self.has_permanent_logical_slots(my_name, nofailover, major_version): + if self._has_permanent_logical_slots(postgresql, member): return True - if self.use_slots: - members = [m for m in self.members if m.replicatefrom == my_name and m.name != self.leader_name] - return any(self.should_enforce_hot_standby_feedback(m.name, m.nofailover, major_version) for m in members) + if global_config.use_slots: + name = member.name if isinstance(member, Member) else postgresql.name + members = [m for m in self.members if m.replicatefrom == name and m.name != self.leader_name] + return any(self.should_enforce_hot_standby_feedback(postgresql, m) for m in members) return False - def get_my_slot_name_on_primary(self, my_name: str, replicatefrom: Optional[str]) -> str: - """Canonical slot name for physical replication. + def get_slot_name_on_primary(self, name: str, tags: Tags) -> str: + """Get the name of physical replication slot for this node on the primary. .. note:: P <-- I <-- L @@ -1127,14 +1183,14 @@ class Cluster(NamedTuple('Cluster', In case of cascading replication we have to check not our physical slot, but slot of the replica that connects us to the primary. - :param my_name: the member node name that is replicating. - :param replicatefrom: the Intermediate member name that is configured to replicate for cascading replication. + :param name: name of the member node to check. + :param tags: reference to an object implementing :class:`Tags` interface. - :returns: The slot name that is in use for physical replication on this no`de. + :returns: the slot name on the primary that is in use for physical replication on this node. """ - m = self.get_member(replicatefrom, False) if replicatefrom else None - return self.get_my_slot_name_on_primary(m.name, m.replicatefrom) \ - if isinstance(m, Member) else slot_name_from_member_name(my_name) + replicatefrom = self.get_member(tags.replicatefrom, False) if tags.replicatefrom else None + return self.get_slot_name_on_primary(replicatefrom.name, replicatefrom) \ + if isinstance(replicatefrom, Member) else slot_name_from_member_name(name) @property def timeline(self) -> int: @@ -1147,19 +1203,20 @@ class Cluster(NamedTuple('Cluster', :Example: No history provided: - >>> Cluster(0, 0, 0, 0, 0, 0, 0, 0, 0, None, {}).timeline + >>> Cluster(0, 0, 0, Status.empty(), 0, 0, 0, 0, None, {}).timeline 0 Empty history assume timeline is ``1``: - >>> Cluster(0, 0, 0, 0, 0, 0, 0, TimelineHistory.from_node(1, '[]'), 0, None, {}).timeline + >>> Cluster(0, 0, 0, Status.empty(), 0, 0, 0, TimelineHistory.from_node(1, '[]'), None, {}).timeline 1 Invalid history format, a string of ``a``, returns ``0``: - >>> Cluster(0, 0, 0, 0, 0, 0, 0, TimelineHistory.from_node(1, '[["a"]]'), 0, None, {}).timeline + >>> Cluster(0, 0, 0, Status.empty(), 0, 0, 0, TimelineHistory.from_node(1, '[["a"]]'), None, {}).timeline 0 History as a list of strings: - >>> Cluster(0, 0, 0, 0, 0, 0, 0, TimelineHistory.from_node(1, '[["3", "2", "1"]]'), 0, None, {}).timeline + >>> history = TimelineHistory.from_node(1, '[["3", "2", "1"]]') + >>> Cluster(0, 0, 0, Status.empty(), 0, 0, 0, history, None, {}).timeline 4 """ if self.history: @@ -1421,7 +1478,7 @@ class AbstractDCS(abc.ABC): """ @abc.abstractmethod - def _citus_cluster_loader(self, path: Any) -> Union[Cluster, Dict[int, Cluster]]: + def _citus_cluster_loader(self, path: Any) -> Dict[int, Cluster]: """Load and build all Patroni clusters from a single Citus cluster. :param path: the path in DCS where to load Cluster(s) from. @@ -1448,9 +1505,6 @@ class AbstractDCS(abc.ABC): primary and exception raised, instance would be demoted. """ - def _bypass_caches(self) -> None: - """Used only in Zookeeper.""" - def __get_patroni_cluster(self, path: Optional[str] = None) -> Cluster: """Low level method to load a :class:`Cluster` object from DCS. @@ -1493,15 +1547,14 @@ class AbstractDCS(abc.ABC): dict. """ groups = self._load_cluster(self._base_path + '/', self._citus_cluster_loader) - if isinstance(groups, Cluster): # Zookeeper could return a cached version - cluster = groups - else: - cluster = groups.pop(CITUS_COORDINATOR_GROUP_ID, Cluster.empty()) - cluster.workers.update(groups) + if TYPE_CHECKING: # pragma: no cover + assert isinstance(groups, dict) + cluster = groups.pop(CITUS_COORDINATOR_GROUP_ID, Cluster.empty()) + cluster.workers.update(groups) return cluster - def get_cluster(self, force: bool = False) -> Cluster: - """Retrieve an appropriate cached or fresh view of DCS. + def get_cluster(self) -> Cluster: + """Retrieve a fresh view of DCS. .. note:: Stores copy of time, status and failsafe values for comparison in DCS update decisions. @@ -1509,12 +1562,8 @@ class AbstractDCS(abc.ABC): Returns either a Citus or Patroni implementation of :class:`Cluster` depending on availability. - :param force: a value of ``True`` will override Zookeeper caching features. - :returns: """ - if force: - self._bypass_caches() try: cluster = self._get_citus_cluster() if self.is_citus_coordinator() else self.__get_patroni_cluster() except Exception: diff --git a/patroni/dcs/consul.py b/patroni/dcs/consul.py index b2a6c478..27cab778 100644 --- a/patroni/dcs/consul.py +++ b/patroni/dcs/consul.py @@ -15,7 +15,7 @@ from urllib3.exceptions import HTTPError from urllib.parse import urlencode, urlparse, quote from typing import Any, Callable, Dict, List, Mapping, NamedTuple, Optional, Union, Tuple, TYPE_CHECKING -from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, SyncState, \ +from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, Status, SyncState, \ TimelineHistory, ReturnFalseException, catch_return_false_exception, citus_group_re from ..exceptions import DCSError from ..utils import deep_compare, parse_bool, Retry, RetryFailedError, split_host_port, uri, USER_AGENT @@ -383,23 +383,8 @@ class Consul(AbstractDCS): history = history and TimelineHistory.from_node(history['ModifyIndex'], history['Value']) # get last known leader lsn and slots - status = nodes.get(self._STATUS) - if status: - try: - status = json.loads(status['Value']) - last_lsn = status.get(self._OPTIME) - slots = status.get('slots') - except Exception: - slots = last_lsn = None - else: - last_lsn = nodes.get(self._LEADER_OPTIME) - last_lsn = last_lsn and last_lsn['Value'] - slots = None - - try: - last_lsn = int(last_lsn or '') - except Exception: - last_lsn = 0 + status = nodes.get(self._STATUS) or nodes.get(self._LEADER_OPTIME) + status = Status.from_node(status and status['Value']) # get list of members members = [self.member(n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1] @@ -428,7 +413,7 @@ class Consul(AbstractDCS): except Exception: failsafe = None - return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots, failsafe) + return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe) @property def _consistency(self) -> str: @@ -437,8 +422,8 @@ class Consul(AbstractDCS): def _cluster_loader(self, path: str) -> Cluster: _, results = self.retry(self._client.kv.get, path, recurse=True, consistency=self._consistency) if results is None: - raise NotFound - nodes = {} + return Cluster.empty() + nodes: Dict[str, Dict[str, Any]] = {} for node in results: node['Value'] = (node['Value'] or b'').decode('utf-8') nodes[node['Key'][len(path):]] = node @@ -460,8 +445,6 @@ class Consul(AbstractDCS): ) -> Union[Cluster, Dict[int, Cluster]]: try: return loader(path) - except NotFound: - return Cluster.empty() except Exception: logger.exception('get_cluster') raise ConsulError('Consul is not responding properly') diff --git a/patroni/dcs/etcd.py b/patroni/dcs/etcd.py index 335cf7a7..3be699a6 100644 --- a/patroni/dcs/etcd.py +++ b/patroni/dcs/etcd.py @@ -21,7 +21,7 @@ from urllib.parse import urlparse from urllib3 import Timeout from urllib3.exceptions import HTTPError, ReadTimeoutError, ProtocolError -from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, SyncState, \ +from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, Status, SyncState, \ TimelineHistory, ReturnFalseException, catch_return_false_exception, citus_group_re from ..exceptions import DCSError from ..request import get as requests_get @@ -677,23 +677,8 @@ class Etcd(AbstractEtcd): history = history and TimelineHistory.from_node(history.modifiedIndex, history.value) # get last know leader lsn and slots - status = nodes.get(self._STATUS) - if status: - try: - status = json.loads(status.value) - last_lsn = status.get(self._OPTIME) - slots = status.get('slots') - except Exception: - slots = last_lsn = None - else: - last_lsn = nodes.get(self._LEADER_OPTIME) - last_lsn = last_lsn and last_lsn.value - slots = None - - try: - last_lsn = int(last_lsn or '') - except Exception: - last_lsn = 0 + status = nodes.get(self._STATUS) or nodes.get(self._LEADER_OPTIME) + status = Status.from_node(status and status.value) # get list of members members = [self.member(n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1] @@ -722,16 +707,23 @@ class Etcd(AbstractEtcd): except Exception: failsafe = None - return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots, failsafe) + return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe) def _cluster_loader(self, path: str) -> Cluster: - result = self.retry(self._client.read, path, recursive=True, quorum=self._ctl) + try: + result = self.retry(self._client.read, path, recursive=True, quorum=self._ctl) + except etcd.EtcdKeyNotFound: + return Cluster.empty() nodes = {node.key[len(result.key):].lstrip('/'): node for node in result.leaves} return self._cluster_from_nodes(result.etcd_index, nodes) def _citus_cluster_loader(self, path: str) -> Dict[int, Cluster]: + try: + result = self.retry(self._client.read, path, recursive=True, quorum=self._ctl) + except etcd.EtcdKeyNotFound: + return {} + clusters: Dict[int, Dict[str, etcd.EtcdResult]] = defaultdict(dict) - result = self.retry(self._client.read, path, recursive=True, quorum=self._ctl) for node in result.leaves: key = node.key[len(result.key):].lstrip('/').split('/', 1) if len(key) == 2 and citus_group_re.match(key[0]): @@ -744,8 +736,6 @@ class Etcd(AbstractEtcd): cluster = None try: cluster = loader(path) - except etcd.EtcdKeyNotFound: - cluster = Cluster.empty() except Exception as e: self._handle_exception(e, 'get_cluster', raise_ex=EtcdError('Etcd is not responding properly')) self._has_failed = False diff --git a/patroni/dcs/etcd3.py b/patroni/dcs/etcd3.py index 5b1acac3..ea7e52f2 100644 --- a/patroni/dcs/etcd3.py +++ b/patroni/dcs/etcd3.py @@ -15,7 +15,7 @@ from urllib3.exceptions import ReadTimeoutError, ProtocolError from threading import Condition, Lock, Thread from typing import Any, Callable, Collection, Dict, Iterator, List, Optional, Tuple, Type, TYPE_CHECKING, Union -from . import ClusterConfig, Cluster, Failover, Leader, Member, SyncState, \ +from . import ClusterConfig, Cluster, Failover, Leader, Member, Status, SyncState, \ TimelineHistory, catch_return_false_exception, citus_group_re from .etcd import AbstractEtcdClientWithFailover, AbstractEtcd, catch_etcd_errors, DnsCachingResolver, Retry from ..exceptions import DCSError, PatroniException @@ -124,6 +124,10 @@ class AuthFailed(InvalidArgument): error = "etcdserver: authentication failed, invalid user ID or password" +class AuthOldRevision(InvalidArgument): + error = "etcdserver: revision of auth store is old" + + class PermissionDenied(Etcd3ClientError): code = GRPCCode.PermissionDenied error = "etcdserver: permission denied" @@ -193,6 +197,12 @@ def build_range_request(key: str, range_end: Union[bytes, str, None] = None) -> return fields +class ReAuthenticateMode(IntEnum): + NOT_REQUIRED = 0 + REQUIRED = 1 + WITHOUT_WATCHER_RESTART = 2 + + def _handle_auth_errors(func: Callable[..., Any]) -> Any: def wrapper(self: 'Etcd3Client', *args: Any, **kwargs: Any) -> Any: return self.handle_auth_errors(func, *args, **kwargs) @@ -204,8 +214,9 @@ class Etcd3Client(AbstractEtcdClientWithFailover): ERROR_CLS = Etcd3Error def __init__(self, config: Dict[str, Any], dns_resolver: DnsCachingResolver, cache_ttl: int = 300) -> None: + self._reauthenticate_reason = ReAuthenticateMode.NOT_REQUIRED self._token = None - self._cluster_version: Tuple[int] = tuple() + self._cluster_version: Tuple[int, ...] = tuple() super(Etcd3Client, self).__init__({**config, 'version_prefix': '/v3beta'}, dns_resolver, cache_ttl) try: @@ -282,7 +293,7 @@ class Etcd3Client(AbstractEtcdClientWithFailover): fields['retry'] = retry return self.api_execute(self.version_prefix + method, self._MPOST, fields) - def authenticate(self) -> bool: + def authenticate(self, *, restart_watcher: bool = True, retry: Optional[Retry] = None) -> bool: if self._use_proxies and not self._cluster_version: kwargs = self._prepare_common_parameters(1) self._ensure_version_prefix(self._base_uri, **kwargs) @@ -291,7 +302,7 @@ class Etcd3Client(AbstractEtcdClientWithFailover): logger.info('Trying to authenticate on Etcd...') old_token, self._token = self._token, None try: - response = self.call_rpc('/auth/authenticate', {'name': self.username, 'password': self.password}) + response = self.call_rpc('/auth/authenticate', {'name': self.username, 'password': self.password}, retry) except AuthNotEnabled: logger.info('Etcd authentication is not enabled') self._token = None @@ -302,48 +313,65 @@ class Etcd3Client(AbstractEtcdClientWithFailover): self._token = response.get('token') return old_token != self._token - def handle_auth_errors(self: 'Etcd3Client', func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any: - def retry(ex: Exception) -> Any: - if self.username and self.password: - self.authenticate() - return func(self, *args, **kwargs) - else: - logger.fatal('Username or password not set, authentication is not possible') - raise ex + def handle_auth_errors(self: 'Etcd3Client', func: Callable[..., Any], *args: Any, + retry: Optional[Retry] = None, **kwargs: Any) -> Any: + exc = None + while True: + if self._reauthenticate_reason: + if self.username and self.password: + self.authenticate( + restart_watcher=self._reauthenticate_reason != ReAuthenticateMode.WITHOUT_WATCHER_RESTART, + retry=retry) + self._reauthenticate_reason = ReAuthenticateMode.NOT_REQUIRED + if retry: + retry.ensure_deadline(0) + else: + msg = 'Username or password not set, authentication is not possible' + logger.fatal(msg) + raise exc or Etcd3Exception(msg) - try: - return func(self, *args, **kwargs) - except (UserEmpty, PermissionDenied) as e: # no token provided - # PermissionDenied is raised on 3.0 and 3.1 - if self._cluster_version < (3, 3) and (not isinstance(e, PermissionDenied) - or self._cluster_version < (3, 2)): - raise UnsupportedEtcdVersion('Authentication is required by Etcd cluster but not ' - 'supported on version lower than 3.3.0. Cluster version: ' - '{0}'.format('.'.join(map(str, self._cluster_version)))) - return retry(e) - except InvalidAuthToken as e: - logger.error('Invalid auth token: %s', self._token) - return retry(e) + try: + return func(self, *args, retry=retry, **kwargs) + except (UserEmpty, PermissionDenied) as e: # no token provided + # PermissionDenied is raised on 3.0 and 3.1 + if self._cluster_version < (3, 3) and (not isinstance(e, PermissionDenied) + or self._cluster_version < (3, 2)): + raise UnsupportedEtcdVersion('Authentication is required by Etcd cluster but not ' + 'supported on version lower than 3.3.0. Cluster version: ' + '{0}'.format('.'.join(map(str, self._cluster_version)))) + exc = e + except InvalidAuthToken as e: + logger.error('Invalid auth token: %s', self._token) + exc = e + except AuthOldRevision as e: + logger.error('Auth token is for old revision of auth store') + exc = e + self._reauthenticate_reason = ReAuthenticateMode.WITHOUT_WATCHER_RESTART \ + if isinstance(exc, AuthOldRevision) else ReAuthenticateMode.REQUIRED + if not retry: + raise exc + retry.ensure_deadline(0.5, exc) @_handle_auth_errors def range(self, key: str, range_end: Union[bytes, str, None] = None, serializable: bool = True, - retry: Optional[Retry] = None) -> Dict[str, Any]: + *, retry: Optional[Retry] = None) -> Dict[str, Any]: params = build_range_request(key, range_end) params['serializable'] = serializable # For better performance. We can tolerate stale reads return self.call_rpc('/kv/range', params, retry) - def prefix(self, key: str, serializable: bool = True, retry: Optional[Retry] = None) -> Dict[str, Any]: - return self.range(key, prefix_range_end(key), serializable, retry) + def prefix(self, key: str, serializable: bool = True, *, retry: Optional[Retry] = None) -> Dict[str, Any]: + return self.range(key, prefix_range_end(key), serializable, retry=retry) @_handle_auth_errors - def lease_grant(self, ttl: int, retry: Optional[Retry] = None) -> str: + def lease_grant(self, ttl: int, *, retry: Optional[Retry] = None) -> str: return self.call_rpc('/lease/grant', {'TTL': ttl}, retry)['ID'] - def lease_keepalive(self, ID: str, retry: Optional[Retry] = None) -> Optional[str]: + def lease_keepalive(self, ID: str, *, retry: Optional[Retry] = None) -> Optional[str]: return self.call_rpc('/lease/keepalive', {'ID': ID}, retry).get('result', {}).get('TTL') + @_handle_auth_errors def txn(self, compare: Dict[str, Any], success: Dict[str, Any], - failure: Optional[Dict[str, Any]] = None, retry: Optional[Retry] = None) -> Dict[str, Any]: + failure: Optional[Dict[str, Any]] = None, *, retry: Optional[Retry] = None) -> Dict[str, Any]: fields = {'compare': [compare], 'success': [success]} if failure: fields['failure'] = [failure] @@ -352,7 +380,7 @@ class Etcd3Client(AbstractEtcdClientWithFailover): @_handle_auth_errors def put(self, key: str, value: str, lease: Optional[str] = None, create_revision: Optional[str] = None, - mod_revision: Optional[str] = None, retry: Optional[Retry] = None) -> Dict[str, Any]: + mod_revision: Optional[str] = None, *, retry: Optional[Retry] = None) -> Dict[str, Any]: fields = {'key': base64_encode(key), 'value': base64_encode(value)} if lease: fields['lease'] = lease @@ -367,14 +395,14 @@ class Etcd3Client(AbstractEtcdClientWithFailover): @_handle_auth_errors def deleterange(self, key: str, range_end: Union[bytes, str, None] = None, - mod_revision: Optional[str] = None, retry: Optional[Retry] = None) -> Dict[str, Any]: + mod_revision: Optional[str] = None, *, retry: Optional[Retry] = None) -> Dict[str, Any]: fields = build_range_request(key, range_end) if mod_revision is None: return self.call_rpc('/kv/deleterange', fields, retry) compare = {'target': 'MOD', 'mod_revision': mod_revision, 'key': fields['key']} return self.txn(compare, {'request_delete_range': fields}, retry=retry) - def deleteprefix(self, key: str, retry: Optional[Retry] = None) -> Dict[str, Any]: + def deleteprefix(self, key: str, *, retry: Optional[Retry] = None) -> Dict[str, Any]: return self.deleterange(key, prefix_range_end(key), retry=retry) def watchrange(self, key: str, range_end: Union[bytes, str, None] = None, @@ -574,9 +602,9 @@ class PatroniEtcd3Client(Etcd3Client): super(PatroniEtcd3Client, self).set_base_uri(value) self._restart_watcher() - def authenticate(self) -> bool: - ret = super(PatroniEtcd3Client, self).authenticate() - if ret: + def authenticate(self, *, restart_watcher: bool = True, retry: Optional[Retry] = None) -> bool: + ret = super(PatroniEtcd3Client, self).authenticate(restart_watcher=restart_watcher, retry=retry) + if ret and restart_watcher: self._restart_watcher() return ret @@ -631,8 +659,8 @@ class PatroniEtcd3Client(Etcd3Client): return ret def txn(self, compare: Dict[str, Any], success: Dict[str, Any], - failure: Optional[Dict[str, Any]] = None, retry: Optional[Retry] = None) -> Dict[str, Any]: - ret = super(PatroniEtcd3Client, self).txn(compare, success, failure, retry) + failure: Optional[Dict[str, Any]] = None, *, retry: Optional[Retry] = None) -> Dict[str, Any]: + ret = super(PatroniEtcd3Client, self).txn(compare, success, failure, retry=retry) # Here we abuse the fact that the `failure` is only set in the call from update_leader(). # In all other cases the txn() call failure may be an indicator of a stale cache, # and therefore we want to restart watcher. @@ -676,12 +704,12 @@ class Etcd3(AbstractEtcd): if not force and self._lease and self._last_lease_refresh + self._loop_wait > time.time(): return False - if self._lease and not self._client.lease_keepalive(self._lease, retry): + if self._lease and not self._client.lease_keepalive(self._lease, retry=retry): self._lease = None ret = not self._lease if ret: - self._lease = self._client.lease_grant(self._ttl, retry) + self._lease = self._client.lease_grant(self._ttl, retry=retry) self._last_lease_refresh = time.time() return ret @@ -723,23 +751,8 @@ class Etcd3(AbstractEtcd): history = history and TimelineHistory.from_node(history['mod_revision'], history['value']) # get last know leader lsn and slots - status = nodes.get(self._STATUS) - if status: - try: - status = json.loads(status['value']) - last_lsn = status.get(self._OPTIME) - slots = status.get('slots') - except Exception: - slots = last_lsn = None - else: - last_lsn = nodes.get(self._LEADER_OPTIME) - last_lsn = last_lsn and last_lsn['value'] - slots = None - - try: - last_lsn = int(last_lsn or '') - except Exception: - last_lsn = 0 + status = nodes.get(self._STATUS) or nodes.get(self._LEADER_OPTIME) + status = Status.from_node(status and status['value']) # get list of members members = [self.member(n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1] @@ -770,7 +783,7 @@ class Etcd3(AbstractEtcd): except Exception: failsafe = None - return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots, failsafe) + return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe) def _cluster_loader(self, path: str) -> Cluster: nodes = {node['key'][len(path):]: node diff --git a/patroni/dcs/kubernetes.py b/patroni/dcs/kubernetes.py index a88f4b23..aee87bd3 100644 --- a/patroni/dcs/kubernetes.py +++ b/patroni/dcs/kubernetes.py @@ -19,7 +19,7 @@ from urllib3.exceptions import HTTPError from threading import Condition, Lock, Thread from typing import Any, Callable, Collection, Dict, List, Optional, Tuple, Type, Union, TYPE_CHECKING -from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, SyncState, \ +from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, Status, SyncState, \ TimelineHistory, CITUS_COORDINATOR_GROUP_ID, citus_group_re from ..exceptions import DCSError from ..utils import deep_compare, iter_response_objects, keepalive_socket_options, \ @@ -771,8 +771,7 @@ class Kubernetes(AbstractDCS): except k8s_config.ConfigException: k8s_config.load_kube_config(context=config.get('context', 'kind-kind')) - pod_ip = config.get('pod_ip') - self.__ips: List[str] = [] if self._ctl or not isinstance(pod_ip, str) else [pod_ip] + self.__ips: List[str] = [] if self._ctl else [config.get('pod_ip', '')] self.__ports: List[K8sObject] = [] ports: List[Dict[str, Any]] = config.get('ports', [{}]) for p in ports: @@ -836,7 +835,7 @@ class Kubernetes(AbstractDCS): self._api.configure_timeouts(self.loop_wait, self._retry.deadline, self.ttl) # retriable_http_codes supposed to be either int, list of integers or comma-separated string with integers. - retriable_http_codes = config.get('retriable_http_codes', []) + retriable_http_codes: Union[str, List[Union[str, int]]] = config.get('retriable_http_codes', []) if not isinstance(retriable_http_codes, list): retriable_http_codes = [c.strip() for c in str(retriable_http_codes).split(',')] @@ -888,18 +887,8 @@ class Kubernetes(AbstractDCS): self._leader_resource_version = metadata.resource_version if metadata else None annotations: Dict[str, str] = metadata and metadata.annotations or {} - # get last known leader lsn - try: - last_lsn = int(annotations.get(self._OPTIME, '')) - except Exception: - last_lsn = 0 - - # get permanent slots state (confirmed_flush_lsn) - slots = annotations.get('slots') - try: - slots = json.loads(annotations.get('slots', '')) - except Exception: - slots = None + # get last known leader lsn and slots + status = Status.from_node(annotations) # get failsafe topology try: @@ -945,7 +934,7 @@ class Kubernetes(AbstractDCS): metadata = sync and sync.metadata sync = SyncState.from_node(metadata and metadata.resource_version, metadata and metadata.annotations) - return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots, failsafe) + return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe) def _cluster_loader(self, path: Dict[str, Any]) -> Cluster: return self._cluster_from_nodes(path['group'], path['nodes'], path['pods'].values()) @@ -1069,6 +1058,27 @@ class Kubernetes(AbstractDCS): def _patch_or_create(self, name: str, annotations: Dict[str, Any], resource_version: Optional[str] = None, patch: bool = False, retry: Optional[Callable[..., Any]] = None, ips: Optional[List[str]] = None) -> K8sObject: + """Patch or create K8s object, Endpoint or ConfigMap. + + :param name: the name of the object. + :param annotations: mapping of annotations that we want to create/update. + :param resource_version: object should be updated only if the ``resource_version`` matches provided value. + :param patch: ``True`` if we know in advance that the object already exists and we should patch it. + :param retry: a callable that will take care of retries + :param ips: IP address that we want to put to the subsets of the endpoint. Could have following values: + + * ``None`` - when we don't need to touch subset; + * ``[]`` - to set subsets to the empty list, when :meth:`delete_leader` method is called; + + * ``['ip.add.re.ss']`` - when we want to make sure that the subsets of the leader endpoint + contains the IP address of the leader, that we get from the ``kubernetes.pod_ip``; + + * ``['']`` - when we want to make sure that the subsets of the leader endpoint contains the IP + address of the leader, but ``kubernetes.pod_ip`` configuration is missing. In this case we will + try to take the IP address of the Pod which name matches ``name`` from the config file. + + :returns: the new :class:`V1Endpoints` or :class:`V1ConfigMap` object, that was created or updated. + """ metadata = {'namespace': self._namespace, 'name': name, 'labels': self._labels, 'annotations': annotations} if patch or resource_version: if resource_version is not None: @@ -1081,9 +1091,10 @@ class Kubernetes(AbstractDCS): metadata['annotations'] = {k: v for k, v in annotations.items() if v is not None} metadata = k8s_client.V1ObjectMeta(**metadata) - if ips is not None and self._api.use_endpoints: + if self._api.use_endpoints: endpoints = {'metadata': metadata} - self._map_subsets(endpoints, ips) + if ips is not None: + self._map_subsets(endpoints, ips) body = k8s_client.V1Endpoints(**endpoints) else: body = k8s_client.V1ConfigMap(metadata=metadata) @@ -1232,11 +1243,10 @@ class Kubernetes(AbstractDCS): else: annotations['acquireTime'] = self._leader_observed_record.get('acquireTime') or now annotations['transitions'] = str(transitions) - ips: Optional[List[str]] = [] if self._api.use_endpoints else None try: ret = bool(self._patch_or_create(self.leader_path, annotations, - self._leader_resource_version, retry=self.retry, ips=ips)) + self._leader_resource_version, retry=self.retry, ips=self.__ips)) except k8s_client.rest.ApiException as e: if e.status == 409 and self._leader_resource_version: # Conflict in resource_version # Terminate watchers, it could be a sign that K8s API is in a failed state diff --git a/patroni/dcs/raft.py b/patroni/dcs/raft.py index 3f9337cb..98c48f44 100644 --- a/patroni/dcs/raft.py +++ b/patroni/dcs/raft.py @@ -12,7 +12,8 @@ from pysyncobj.transport import TCPTransport, CONNECTION_STATE from pysyncobj.utility import TcpUtility from typing import Any, Callable, Collection, Dict, List, Optional, Set, Union, TYPE_CHECKING -from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, SyncState, TimelineHistory, citus_group_re +from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, Status, SyncState, \ + TimelineHistory, citus_group_re from ..exceptions import DCSError from ..utils import validate_directory if TYPE_CHECKING: # pragma: no cover @@ -343,23 +344,8 @@ class Raft(AbstractDCS): history = history and TimelineHistory.from_node(history['index'], history['value']) # get last know leader lsn and slots - status = nodes.get(self._STATUS) - if status: - try: - status = json.loads(status['value']) - last_lsn = status.get(self._OPTIME) - slots = status.get('slots') - except Exception: - slots = last_lsn = None - else: - last_lsn = nodes.get(self._LEADER_OPTIME) - last_lsn = last_lsn and last_lsn['value'] - slots = None - - try: - last_lsn = int(last_lsn or '') - except Exception: - last_lsn = 0 + status = nodes.get(self._STATUS) or nodes.get(self._LEADER_OPTIME) + status = Status.from_node(status and status['value']) # get list of members members = [self.member(k, n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1] @@ -387,7 +373,7 @@ class Raft(AbstractDCS): except Exception: failsafe = None - return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots, failsafe) + return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe) def _cluster_loader(self, path: str) -> Cluster: response = self._sync_obj.get(path, recursive=True) diff --git a/patroni/dcs/zookeeper.py b/patroni/dcs/zookeeper.py index 29d159e6..3704b579 100644 --- a/patroni/dcs/zookeeper.py +++ b/patroni/dcs/zookeeper.py @@ -12,7 +12,8 @@ from kazoo.retry import RetryFailedError from kazoo.security import ACL, make_acl from typing import Any, Callable, Dict, List, Optional, Union, Tuple, TYPE_CHECKING -from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, SyncState, TimelineHistory, citus_group_re +from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, Status, SyncState, \ + TimelineHistory, citus_group_re from ..exceptions import DCSError from ..utils import deep_compare if TYPE_CHECKING: # pragma: no cover @@ -89,7 +90,7 @@ class ZooKeeper(AbstractDCS): def __init__(self, config: Dict[str, Any]) -> None: super(ZooKeeper, self).__init__(config) - hosts = config.get('hosts', []) + hosts: Union[str, List[str]] = config.get('hosts', []) if isinstance(hosts, list): hosts = ','.join(hosts) @@ -114,11 +115,9 @@ class ZooKeeper(AbstractDCS): self._client = PatroniKazooClient(hosts, handler=PatroniSequentialThreadingHandler(config['retry_timeout']), timeout=config['ttl'], connection_retry=KazooRetry(max_delay=1, max_tries=-1, sleep_func=time.sleep), command_retry=KazooRetry(max_delay=1, max_tries=-1, - deadline=config['retry_timeout'], sleep_func=time.sleep), **kwargs) - self._client.add_listener(self.session_listener) + deadline=config['retry_timeout'], sleep_func=time.sleep), + auth_data=list(config.get('auth_data', {}).items()), **kwargs) - self._fetch_cluster: bool = True - self._fetch_status: bool = True self.__last_member_data: Optional[Dict[str, Any]] = None self._orig_kazoo_connect = self._client._connection._connect @@ -141,18 +140,9 @@ class ZooKeeper(AbstractDCS): ret = self._orig_kazoo_connect(*args) return max(self.loop_wait - 2, 2) * 1000, ret[1] - def session_listener(self, state: str) -> None: - if state in [KazooState.SUSPENDED, KazooState.LOST]: - self.cluster_watcher(None) - - def status_watcher(self, event: Optional[WatchedEvent]) -> None: - self._fetch_status = True - self.event.set() - - def cluster_watcher(self, event: Optional[WatchedEvent]) -> None: - self._fetch_cluster = True - if not event or event.state != KazooState.CONNECTED or event.path.startswith(self.client_path('')): - self.status_watcher(event) + def _watcher(self, event: WatchedEvent) -> None: + if event.state != KazooState.CONNECTED or event.path.startswith(self.client_path('')): + self.event.set() def reload_config(self, config: Union['Config', Dict[str, Any]]) -> None: self.set_retry_timeout(config['retry_timeout']) @@ -200,138 +190,89 @@ class ZooKeeper(AbstractDCS): except NoNodeError: return None - def get_status(self, path: str, leader: Optional[Leader]) -> Tuple[int, Optional[Dict[str, int]]]: - watch = self.status_watcher if not leader or leader.name != self._name else None - - status = self.get_node(path + self._STATUS, watch) - if status: - try: - status = json.loads(status[0]) - last_lsn = status.get(self._OPTIME) - slots = status.get('slots') - except Exception: - slots = last_lsn = None - else: - last_lsn = self.get_node(path + self._LEADER_OPTIME, watch) - last_lsn = last_lsn and last_lsn[0] - slots = None - - try: - last_lsn = int(last_lsn or '') - except Exception: - last_lsn = 0 - - self._fetch_status = False - return last_lsn, slots + def get_status(self, path: str, leader: Optional[Leader]) -> Status: + status = self.get_node(path + self._STATUS) + if not status: + status = self.get_node(path + self._LEADER_OPTIME) + return Status.from_node(status and status[0]) @staticmethod def member(name: str, value: str, znode: ZnodeStat) -> Member: return Member.from_node(znode.version, name, znode.ephemeralOwner, value) - def get_children(self, key: str, watch: Optional[Callable[[WatchedEvent], None]] = None) -> List[str]: + def get_children(self, key: str) -> List[str]: try: - return self._client.get_children(key, watch) + return self._client.get_children(key) except NoNodeError: return [] def load_members(self, path: str) -> List[Member]: members: List[Member] = [] - for member in self.get_children(path + self._MEMBERS, self.cluster_watcher): + for member in self.get_children(path + self._MEMBERS): data = self.get_node(path + self._MEMBERS + member) if data is not None: members.append(self.member(member, *data)) return members def _cluster_loader(self, path: str) -> Cluster: - self._fetch_cluster = False - self.event.clear() - nodes = set(self.get_children(path, self.cluster_watcher)) - if not nodes: - self._fetch_cluster = True + nodes = set(self.get_children(path)) # get initialize flag initialize = (self.get_node(path + self._INITIALIZE) or [None])[0] if self._INITIALIZE in nodes else None # get global dynamic configuration - config = self.get_node(path + self._CONFIG, watch=self.cluster_watcher) if self._CONFIG in nodes else None + config = self.get_node(path + self._CONFIG, watch=self._watcher) if self._CONFIG in nodes else None config = config and ClusterConfig.from_node(config[1].version, config[0], config[1].mzxid) # get timeline history - history = self.get_node(path + self._HISTORY, watch=self.cluster_watcher) if self._HISTORY in nodes else None + history = self.get_node(path + self._HISTORY) if self._HISTORY in nodes else None history = history and TimelineHistory.from_node(history[1].mzxid, history[0]) # get synchronization state - sync = self.get_node(path + self._SYNC, watch=self.cluster_watcher) if self._SYNC in nodes else None + sync = self.get_node(path + self._SYNC) if self._SYNC in nodes else None sync = SyncState.from_node(sync and sync[1].version, sync and sync[0]) # get list of members members = self.load_members(path) if self._MEMBERS[:-1] in nodes else [] # get leader - leader = self.get_node(path + self._LEADER) if self._LEADER in nodes else None + leader = self.get_node(path + self._LEADER, watch=self._watcher) if self._LEADER in nodes else None if leader: member = Member(-1, leader[0], None, {}) member = ([m for m in members if m.name == leader[0]] or [member])[0] leader = Leader(leader[1].version, leader[1].ephemeralOwner, member) - self._fetch_cluster = member.version == -1 # get last known leader lsn and slots - last_lsn, slots = self.get_status(path, leader) + status = self.get_status(path, leader) # failover key - failover = self.get_node(path + self._FAILOVER, watch=self.cluster_watcher) if self._FAILOVER in nodes else None + failover = self.get_node(path + self._FAILOVER) if self._FAILOVER in nodes else None failover = failover and Failover.from_node(failover[1].version, failover[0]) # get failsafe topology - failsafe = self.get_node(path + self._FAILSAFE, watch=self.cluster_watcher) if self._FAILSAFE in nodes else None + failsafe = self.get_node(path + self._FAILSAFE) if self._FAILSAFE in nodes else None try: failsafe = json.loads(failsafe[0]) if failsafe else None except Exception: failsafe = None - return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots, failsafe) + return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe) def _citus_cluster_loader(self, path: str) -> Dict[int, Cluster]: - fetch_cluster = False ret: Dict[int, Cluster] = {} - for node in self.get_children(path, self.cluster_watcher): + for node in self.get_children(path): if citus_group_re.match(node): ret[int(node)] = self._cluster_loader(path + node + '/') - fetch_cluster = fetch_cluster or self._fetch_cluster - self._fetch_cluster = fetch_cluster return ret def _load_cluster( self, path: str, loader: Callable[[str], Union[Cluster, Dict[int, Cluster]]] ) -> Union[Cluster, Dict[int, Cluster]]: - cluster = self.cluster if path == self._base_path + '/' else None - if self._fetch_cluster or cluster is None: - try: - cluster = self._client.retry(loader, path) - except Exception: - logger.exception('get_cluster') - self.cluster_watcher(None) - raise ZooKeeperError('ZooKeeper in not responding properly') - # The /status ZNode was updated or doesn't exist - elif self._fetch_status and not self._fetch_cluster or not cluster.last_lsn \ - or cluster.has_permanent_logical_slots(self._name, False) and not cluster.slots: - # If current node is the leader just clear the event without fetching anything (we are updating the /status) - if cluster.leader and cluster.leader.name == self._name: - self.event.clear() - else: - try: - last_lsn, slots = self.get_status(self.client_path(''), cluster.leader) - self.event.clear() - new_cluster: List[Any] = list(cluster) - new_cluster[3] = last_lsn - new_cluster[8] = slots - cluster = Cluster(*new_cluster) - except Exception: - pass - return cluster - - def _bypass_caches(self) -> None: - self._fetch_cluster = True + try: + return self._client.retry(loader, path) + except Exception: + logger.exception('get_cluster') + raise ZooKeeperError('ZooKeeper in not responding properly') def _create(self, path: str, value: bytes, retry: bool = False, ephemeral: bool = False) -> bool: try: @@ -393,21 +334,17 @@ class ZooKeeper(AbstractDCS): cluster = self.cluster member = cluster and cluster.get_member(self._name, fallback_to_leader=False) member_data = self.__last_member_data or member and member.data - # We want to notify leader if some important fields in the member key changed by removing ZNode - if member and (self._client.client_id is not None and member.session != self._client.client_id[0] - or not (member_data and deep_compare(member_data.get('tags', {}), data.get('tags', {})) - and (member_data.get('state') == data.get('state') - or 'running' not in (member_data.get('state'), data.get('state'))) - and member_data.get('version') == data.get('version') - and member_data.get('checkpoint_after_promote') - == data.get('checkpoint_after_promote'))): - try: - self._client.delete_async(self.member_path).get(timeout=1) - except NoNodeError: - pass - except Exception: - return False - member = None + if member and member_data: + # We want delete the member ZNode if our session doesn't match with session id on our member key + if self._client.client_id is not None and member.session != self._client.client_id[0]: + logger.warning('Recreating the member ZNode due to ownership mismatch') + try: + self._client.delete_async(self.member_path).get(timeout=1) + except NoNodeError: + pass + except Exception: + return False + member = None encoded_data = json.dumps(data, separators=(',', ':')).encode('utf-8') if member and member_data: @@ -499,7 +436,10 @@ class ZooKeeper(AbstractDCS): return self.set_sync_state_value("{}", version) is not False def watch(self, leader_version: Optional[int], timeout: float) -> bool: - ret = super(ZooKeeper, self).watch(leader_version, timeout + 0.5) - if ret and not self._fetch_status: - self._fetch_cluster = True - return ret or self._fetch_cluster + if leader_version: + timeout += 0.5 + + try: + return super(ZooKeeper, self).watch(leader_version, timeout) + finally: + self.event.clear() diff --git a/patroni/dynamic_loader.py b/patroni/dynamic_loader.py new file mode 100644 index 00000000..6c207349 --- /dev/null +++ b/patroni/dynamic_loader.py @@ -0,0 +1,96 @@ +"""Helper functions to search for implementations of specific abstract interface in a package.""" +import importlib +import inspect +import logging +import os +import pkgutil +import sys +from types import ModuleType + +from typing import Any, Dict, Iterator, List, Optional, Set, Tuple, TYPE_CHECKING, Type, TypeVar, Union + +if TYPE_CHECKING: # pragma: no cover + from .config import Config + +logger = logging.getLogger(__name__) + + +def iter_modules(package: str) -> List[str]: + """Get names of modules from *package*, depending on execution environment. + + .. note:: + If being packaged with PyInstaller, modules aren't discoverable dynamically by scanning source directory because + :class:`importlib.machinery.FrozenImporter` doesn't implement :func:`iter_modules`. But it is still possible to + find all potential modules by iterating through ``toc``, which contains list of all "frozen" resources. + + :param package: a package name to search modules in, e.g. ``patroni.dcs``. + + :returns: list of known module names with absolute python module path namespace, e.g. ``patroni.dcs.etcd``. + """ + module_prefix = package + '.' + + if getattr(sys, 'frozen', False): + toc: Set[str] = set() + # dirname may contain a few dots, which causes pkgutil.iter_importers() + # to misinterpret the path as a package name. This can be avoided + # altogether by not passing a path at all, because PyInstaller's + # FrozenImporter is a singleton and registered as top-level finder. + for importer in pkgutil.iter_importers(): + if hasattr(importer, 'toc'): + toc |= getattr(importer, 'toc') + dots = module_prefix.count('.') # search for modules only on the same level + return [module for module in toc if module.startswith(module_prefix) and module.count('.') == dots] + + # here we are making an assumption that the package which is calling this function is already imported + pkg_file = sys.modules[package].__file__ + if TYPE_CHECKING: # pragma: no cover + assert isinstance(pkg_file, str) + return [name for _, name, is_pkg in pkgutil.iter_modules([os.path.dirname(pkg_file)], module_prefix) if not is_pkg] + + +ClassType = TypeVar("ClassType") + + +def find_class_in_module(module: ModuleType, cls_type: Type[ClassType]) -> Optional[Type[ClassType]]: + """Try to find the implementation of *cls_type* class interface in *module* matching the *module* name. + + :param module: imported module. + :param cls_type: a class type we are looking for. + + :returns: class with a name matching the name of *module* that implements *cls_type* or ``None`` if not found. + """ + module_name = module.__name__.rpartition('.')[2] + return next( + (obj for obj_name, obj in module.__dict__.items() + if (obj_name.lower() == module_name + and inspect.isclass(obj) and issubclass(obj, cls_type))), + None) + + +def iter_classes( + package: str, cls_type: Type[ClassType], + config: Optional[Union['Config', Dict[str, Any]]] = None +) -> Iterator[Tuple[str, Type[ClassType]]]: + """Attempt to import modules and find implementations of *cls_type* that are present in the given configuration. + + .. note:: + If a module successfully imports we can assume that all its requirements are installed. + + :param package: a package name to search modules in, e.g. ``patroni.dcs``. + :param cls_type: a class type we are looking for. + :param config: configuration information with possible module names as keys. If given, only attempt to import + modules defined in the configuration. Else, if ``None``, attempt to import any supported module. + + :yields: a tuple containing the module ``name`` and the imported class object. + """ + for mod_name in iter_modules(package): + name = mod_name.rpartition('.')[2] + if config is None or name in config: + try: + module = importlib.import_module(mod_name) + module_cls = find_class_in_module(module, cls_type) + if module_cls: + yield name, module_cls + except ImportError: + logger.log(logging.DEBUG if config is not None else logging.INFO, + 'Failed to import %s', mod_name) diff --git a/patroni/global_config.py b/patroni/global_config.py new file mode 100644 index 00000000..7731cf59 --- /dev/null +++ b/patroni/global_config.py @@ -0,0 +1,227 @@ +"""Implements *global_config* facilities. + +The :class:`GlobalConfig` object is instantiated on import and replaces +``patroni.global_config`` module in :data:`sys.modules`, what allows to use +its properties and methods like they were module variables and functions. +""" +import sys +import types + +from copy import deepcopy +from typing import Any, Dict, List, Optional, Union, TYPE_CHECKING + +from .utils import parse_bool, parse_int + +if TYPE_CHECKING: # pragma: no cover + from .dcs import Cluster + + +def __getattr__(mod: types.ModuleType, name: str) -> Any: + """This function exists just to make pyright happy. + + Without it pyright complains about access to unknown members of global_config module. + """ + return getattr(sys.modules[__name__], name) # pragma: no cover + + +class GlobalConfig(types.ModuleType): + """A class that wraps global configuration and provides convenient methods to access/check values.""" + + __file__ = __file__ # just to make unittest and pytest happy + + def __init__(self) -> None: + """Initialize :class:`GlobalConfig` object.""" + super().__init__(__name__) + self.__config = {} + + @staticmethod + def _cluster_has_valid_config(cluster: Optional['Cluster']) -> bool: + """Check if provided *cluster* object has a valid global configuration. + + :param cluster: the currently known cluster state from DCS. + + :returns: ``True`` if provided *cluster* object has a valid global configuration, otherwise ``False``. + """ + return bool(cluster and cluster.config and cluster.config.modify_version) + + def update(self, cluster: Optional['Cluster']) -> None: + """Update with the new global configuration from the :class:`Cluster` object view. + + .. note:: + Global configuration is updated only when configuration in the *cluster* view is valid. + + Update happens in-place and is executed only from the main heartbeat thread. + + :param cluster: the currently known cluster state from DCS. + """ + # Try to protect from the case when DCS was wiped out + if self._cluster_has_valid_config(cluster): + self.__config = cluster.config.data # pyright: ignore [reportOptionalMemberAccess] + + def from_cluster(self, cluster: Optional['Cluster']) -> 'GlobalConfig': + """Return :class:`GlobalConfig` instance from the provided :class:`Cluster` object view. + + .. note:: + If the provided *cluster* object doesn't have a valid global configuration we return + the last known valid state of the :class:`GlobalConfig` object. + + This method is used when we need to have the most up-to-date values in the global configuration, + but we don't want to update the global object. + + :param cluster: the currently known cluster state from DCS. + + :returns: :class:`GlobalConfig` object. + """ + if not self._cluster_has_valid_config(cluster): + return self + + ret = GlobalConfig() + ret.update(cluster) + return ret + + def get(self, name: str) -> Any: + """Gets global configuration value by *name*. + + :param name: parameter name. + + :returns: configuration value or ``None`` if it is missing. + """ + return self.__config.get(name) + + def check_mode(self, mode: str) -> bool: + """Checks whether the certain parameter is enabled. + + :param mode: parameter name, e.g. ``synchronous_mode``, ``failsafe_mode``, ``pause``, ``check_timeline``, and + so on. + + :returns: ``True`` if parameter *mode* is enabled in the global configuration. + """ + return bool(parse_bool(self.__config.get(mode))) + + @property + def is_paused(self) -> bool: + """``True`` if cluster is in maintenance mode.""" + return self.check_mode('pause') + + @property + def is_synchronous_mode(self) -> bool: + """``True`` if synchronous replication is requested and it is not a standby cluster config.""" + return self.check_mode('synchronous_mode') and not self.is_standby_cluster + + @property + def is_synchronous_mode_strict(self) -> bool: + """``True`` if at least one synchronous node is required.""" + return self.check_mode('synchronous_mode_strict') + + def get_standby_cluster_config(self) -> Union[Dict[str, Any], Any]: + """Get ``standby_cluster`` configuration. + + :returns: a copy of ``standby_cluster`` configuration. + """ + return deepcopy(self.get('standby_cluster')) + + @property + def is_standby_cluster(self) -> bool: + """``True`` if global configuration has a valid ``standby_cluster`` section.""" + config = self.get_standby_cluster_config() + return isinstance(config, dict) and\ + bool(config.get('host') or config.get('port') or config.get('restore_command')) + + def get_int(self, name: str, default: int = 0) -> int: + """Gets current value of *name* from the global configuration and try to return it as :class:`int`. + + :param name: name of the parameter. + :param default: default value if *name* is not in the configuration or invalid. + + :returns: currently configured value of *name* from the global configuration or *default* if it is not set or + invalid. + """ + ret = parse_int(self.get(name)) + return default if ret is None else ret + + @property + def min_synchronous_nodes(self) -> int: + """The minimum number of synchronous nodes based on whether ``synchronous_mode_strict`` is enabled or not.""" + return 1 if self.is_synchronous_mode_strict else 0 + + @property + def synchronous_node_count(self) -> int: + """Currently configured value of ``synchronous_node_count`` from the global configuration. + + Assume ``1`` if it is not set or invalid. + """ + return max(self.get_int('synchronous_node_count', 1), self.min_synchronous_nodes) + + @property + def maximum_lag_on_failover(self) -> int: + """Currently configured value of ``maximum_lag_on_failover`` from the global configuration. + + Assume ``1048576`` if it is not set or invalid. + """ + return self.get_int('maximum_lag_on_failover', 1048576) + + @property + def maximum_lag_on_syncnode(self) -> int: + """Currently configured value of ``maximum_lag_on_syncnode`` from the global configuration. + + Assume ``-1`` if it is not set or invalid. + """ + return self.get_int('maximum_lag_on_syncnode', -1) + + @property + def primary_start_timeout(self) -> int: + """Currently configured value of ``primary_start_timeout`` from the global configuration. + + Assume ``300`` if it is not set or invalid. + + .. note:: + ``master_start_timeout`` is still supported to keep backward compatibility. + """ + default = 300 + return self.get_int('primary_start_timeout', default)\ + if 'primary_start_timeout' in self.__config else self.get_int('master_start_timeout', default) + + @property + def primary_stop_timeout(self) -> int: + """Currently configured value of ``primary_stop_timeout`` from the global configuration. + + Assume ``0`` if it is not set or invalid. + + .. note:: + ``master_stop_timeout`` is still supported to keep backward compatibility. + """ + default = 0 + return self.get_int('primary_stop_timeout', default)\ + if 'primary_stop_timeout' in self.__config else self.get_int('master_stop_timeout', default) + + @property + def ignore_slots_matchers(self) -> List[Dict[str, Any]]: + """Currently configured value of ``ignore_slots`` from the global configuration. + + Assume an empty :class:`list` if not set. + """ + return self.get('ignore_slots') or [] + + @property + def max_timelines_history(self) -> int: + """Currently configured value of ``max_timelines_history`` from the global configuration. + + Assume ``0`` if not set or invalid. + """ + return self.get_int('max_timelines_history', 0) + + @property + def use_slots(self) -> bool: + """``True`` if cluster is configured to use replication slots.""" + return bool(parse_bool((self.get('postgresql') or {}).get('use_slots', True))) + + @property + def permanent_slots(self) -> Dict[str, Any]: + """Dictionary of permanent slots information from the global configuration.""" + return deepcopy(self.get('permanent_replication_slots') + or self.get('permanent_slots') + or self.get('slots') + or {}) + + +sys.modules[__name__] = GlobalConfig() diff --git a/patroni/ha.py b/patroni/ha.py index befa1ff9..ab1bc433 100644 --- a/patroni/ha.py +++ b/patroni/ha.py @@ -10,11 +10,11 @@ from multiprocessing.pool import ThreadPool from threading import RLock from typing import Any, Callable, Collection, Dict, List, NamedTuple, Optional, Union, Tuple, TYPE_CHECKING -from . import psycopg +from . import global_config, psycopg from .__main__ import Patroni from .async_executor import AsyncExecutor, CriticalTask from .collections import CaseInsensitiveSet -from .dcs import AbstractDCS, Cluster, Leader, Member, RemoteMember +from .dcs import AbstractDCS, Cluster, Leader, Member, RemoteMember, Status, slot_name_from_member_name from .exceptions import DCSError, PostgresConnectionException, PatroniFatalException from .postgresql.callback_executor import CallbackAction from .postgresql.misc import postgres_version_to_int @@ -123,7 +123,8 @@ class Failsafe(object): leader = self.leader if leader: # We rely on the strict order of fields in the namedtuple - cluster = Cluster(*cluster[0:2], leader, *cluster[3:8], leader.member.data['slots'], *cluster[9:]) + status = Status(cluster.status.last_lsn, leader.member.data['slots']) + cluster = Cluster(*cluster[0:2], leader, status, *cluster[4:]) return cluster def is_active(self) -> bool: @@ -155,7 +156,6 @@ class Ha(object): self._rewind = Rewind(self.state_handler) self.dcs = patroni.dcs self.cluster = Cluster.empty() - self.global_config = self.patroni.config.get_global_config(None) self.old_cluster = Cluster.empty() self._leader_expiry = 0 self._leader_expiry_lock = RLock() @@ -187,20 +187,20 @@ class Ha(object): def primary_stop_timeout(self) -> Union[int, None]: """:returns: "primary_stop_timeout" from the global configuration or `None` when not in synchronous mode.""" - ret = self.global_config.primary_stop_timeout + ret = global_config.primary_stop_timeout return ret if ret > 0 and self.is_synchronous_mode() else None def is_paused(self) -> bool: """:returns: `True` if in maintenance mode.""" - return self.global_config.is_paused + return global_config.is_paused def check_timeline(self) -> bool: """:returns: `True` if should check whether the timeline is latest during the leader race.""" - return self.global_config.check_mode('check_timeline') + return global_config.check_mode('check_timeline') def is_standby_cluster(self) -> bool: """:returns: `True` if global configuration has a valid "standby_cluster" section.""" - return self.global_config.is_standby_cluster + return global_config.is_standby_cluster def is_leader(self) -> bool: """:returns: `True` if the current node is the leader, based on expiration set when it last held the key.""" @@ -225,6 +225,17 @@ class Ha(object): """ return self.is_synchronous_mode() and not self.cluster.sync.is_empty + def _get_failover_action_name(self) -> str: + """Return the currently requested manual failover action name or the default ``failover``. + + :returns: :class:`str` representing the manually requested action (``manual failover`` if no leader + is specified in the ``/failover`` in DCS, ``switchover`` otherwise) or ``failover`` if + ``/failover`` is empty. + """ + if not self.cluster.failover: + return 'failover' + return 'switchover' if self.cluster.failover.leader else 'manual failover' + def load_cluster_from_dcs(self) -> None: cluster = self.dcs.get_cluster() @@ -260,12 +271,31 @@ class Ha(object): ret[self.state_handler.name] = self.patroni.api.connection_string return ret - def update_lock(self, write_leader_optime: bool = False) -> bool: + def update_lock(self, update_status: bool = False) -> bool: + """Update the leader lock in DCS. + + .. note:: + After successful update of the leader key the :meth:`AbstractDCS.update_leader` method could also + optionally update the ``/status`` and ``/failsafe`` keys. + + The ``/status`` key contains the last known LSN on the leader node and the last known state + of permanent replication slots including permanent physical replication slot for the leader. + + Last, but not least, this method calls a :meth:`Watchdog.keepalive` method after the leader key + was successfully updated. + + :param update_status: ``True`` if we also need to update the ``/status`` key in DCS, otherwise ``False``. + + :returns: ``True`` if the leader key was successfully updated and we can continue to run postgres + as a ``primary`` or as a ``standby_leader``, otherwise ``False``. + """ last_lsn = slots = None - if write_leader_optime: + if update_status: try: last_lsn = self.state_handler.last_operation() - slots = self.state_handler.slots() + slots = self.cluster.filter_permanent_slots( + self.state_handler, + {**self.state_handler.slots(), slot_name_from_member_name(self.state_handler.name): last_lsn}) except Exception: logger.exception('Exception when called state_handler.last_operation()') if TYPE_CHECKING: # pragma: no cover @@ -418,7 +448,7 @@ class Ha(object): return ret or 'trying to bootstrap {0}'.format(msg) # no leader, but configuration may allowed replica creation using backup tools - create_replica_methods = self.global_config.get_standby_cluster_config().get('create_replica_methods', []) \ + create_replica_methods = global_config.get_standby_cluster_config().get('create_replica_methods', []) \ if self.is_standby_cluster() else None can_bootstrap = self.state_handler.can_create_replica_without_replication_connection(create_replica_methods) concurrent_bootstrap = self.cluster.initialize == "" @@ -493,7 +523,7 @@ class Ha(object): :returns: action message, describing what was performed. """ if self.has_lock() and self.update_lock(): - timeout = self.global_config.primary_start_timeout + timeout = global_config.primary_start_timeout if timeout == 0: # We are requested to prefer failing over to restarting primary. But see first if there # is anyone to fail over to. @@ -572,7 +602,9 @@ class Ha(object): """ # The standby leader or when there is no standby leader we want to follow # the remote member, except when there is no standby leader in pause. - if self.is_standby_cluster() and (self.has_lock(False) or self.cluster.is_unlocked() and not self.is_paused()): + if self.is_standby_cluster() \ + and (cluster.leader and cluster.leader.name and cluster.leader.name == self.state_handler.name + or cluster.is_unlocked() and not self.is_paused()): node_to_follow = self.get_remote_member() # If replicatefrom tag is set, try to follow the node mentioned there, otherwise, follow the leader. elif self.patroni.replicatefrom and self.patroni.replicatefrom != self.state_handler.name: @@ -588,7 +620,7 @@ class Ha(object): for param in params: # It is highly unlikely to happen, but we want to protect from the case node_to_follow.data.pop(param, None) # when above-mentioned params came from outside. if self.is_standby_cluster(): - standby_config = self.global_config.get_standby_cluster_config() + standby_config = global_config.get_standby_cluster_config() node_to_follow.data.update({p: standby_config[p] for p in params if standby_config.get(p)}) return node_to_follow @@ -650,11 +682,11 @@ class Ha(object): def is_synchronous_mode(self) -> bool: """:returns: `True` if synchronous replication is requested.""" - return self.global_config.is_synchronous_mode + return global_config.is_synchronous_mode def is_failsafe_mode(self) -> bool: """:returns: `True` if failsafe_mode is enabled in global configuration.""" - return self.global_config.check_mode('failsafe_mode') + return global_config.check_mode('failsafe_mode') def process_sync_replication(self) -> None: """Process synchronous standby beahvior. @@ -679,6 +711,14 @@ class Ha(object): current = CaseInsensitiveSet(sync.members) picked, allow_promote = self.state_handler.sync_handler.current_state(self.cluster) + if picked == current and current != allow_promote: + logger.warning('Inconsistent state between synchronous_standby_names = %s and /sync = %s key ' + 'detected, updating synchronous replication key...', list(allow_promote), list(current)) + sync = self.dcs.write_sync_state(self.state_handler.name, allow_promote, version=sync.version) + if not sync: + return logger.warning("Updating sync state failed") + current = CaseInsensitiveSet(sync.members) + if picked != current: # update synchronous standby list in dcs temporarily to point to common nodes in current and picked sync_common = current & allow_promote @@ -690,7 +730,7 @@ class Ha(object): return logger.info('Synchronous replication key updated by someone else.') # When strict mode and no suitable replication connections put "*" to synchronous_standby_names - if self.global_config.is_synchronous_mode_strict and not picked: + if global_config.is_synchronous_mode_strict and not picked: picked = CaseInsensitiveSet('*') logger.warning("No standbys available!") @@ -760,13 +800,13 @@ class Ha(object): if cluster_history: self.dcs.set_history_value('[]') elif not cluster_history or cluster_history[-1][0] != primary_timeline - 1 or len(cluster_history[-1]) != 5: - cluster_history = {line[0]: line for line in cluster_history} + cluster_history_dict: Dict[int, List[Any]] = {line[0]: list(line) for line in cluster_history} history: List[List[Any]] = list(map(list, self.state_handler.get_history(primary_timeline))) if self.cluster.config: - history = history[-self.cluster.config.max_timelines_history:] + history = history[-global_config.max_timelines_history:] for line in history: # enrich current history with promotion timestamps stored in DCS - cluster_history_line = list(cluster_history.get(line[0], [])) + cluster_history_line = cluster_history_dict.get(line[0], []) if len(line) == 3 and len(cluster_history_line) >= 4 and cluster_history_line[1] == line[1]: line.append(cluster_history_line[3]) if len(cluster_history_line) == 5: @@ -821,7 +861,7 @@ class Ha(object): # promotion until next cycle. TODO: trigger immediate retry of run_cycle return 'Postponing promotion because synchronous replication state was updated by somebody else' self.state_handler.sync_handler.set_synchronous_standby_names( - CaseInsensitiveSet('*') if self.global_config.is_synchronous_mode_strict else CaseInsensitiveSet()) + CaseInsensitiveSet('*') if global_config.is_synchronous_mode_strict else CaseInsensitiveSet()) if self.state_handler.role not in ('master', 'promoted', 'primary'): # reset failsafe state when promote self._failsafe.set_is_active(0) @@ -878,6 +918,26 @@ class Ha(object): return False def check_failsafe_topology(self) -> bool: + """Check whether we could continue to run as a primary by calling all members from the failsafe topology. + + .. note:: + If the ``/failsafe`` key contains invalid data or if the ``name`` of our node is missing in + the ``/failsafe`` key, we immediately give up and return ``False``. + + We send the JSON document in the POST request with the following fields: + + * ``name`` - the name of our node; + * ``conn_url`` - connection URL to the postgres, which is reachable from other nodes; + * ``api_url`` - connection URL to Patroni REST API on this node reachable from other nodes; + * ``slots`` - a :class:`dict` with replication slots that exist on the leader node, including the primary + itself with the last known LSN, because there could be a permanent physical slot on standby nodes. + + Standby nodes are using information from the ``slots`` dict to advance position of permanent + replication slots while DCS is not accessible in order to avoid indefinite growth of ``pg_wal``. + + :returns: ``True`` if all members from the ``/failsafe`` topology agree that this node could continue to + run as a ``primary``, or ``False`` if some of standby nodes are not accessible or don't agree. + """ failsafe = self.dcs.failsafe if not isinstance(failsafe, dict) or self.state_handler.name not in failsafe: return False @@ -887,7 +947,10 @@ class Ha(object): 'api_url': self.patroni.api.connection_string, } try: - data['slots'] = self.state_handler.slots() + data['slots'] = { + **self.state_handler.slots(), + slot_name_from_member_name(self.state_handler.name): self.state_handler.last_operation() + } except Exception: logger.exception('Exception when called state_handler.slots()') members = [RemoteMember(name, {'api_url': url}) @@ -909,7 +972,7 @@ class Ha(object): :returns True when node is lagging """ lag = (self.cluster.last_lsn or 0) - wal_position - return lag > self.global_config.maximum_lag_on_failover + return lag > global_config.maximum_lag_on_failover def _is_healthiest_node(self, members: Collection[Member], check_replication_lag: bool = True) -> bool: """This method tries to determine whether I am healthy enough to became a new leader candidate or not.""" @@ -944,6 +1007,15 @@ class Ha(object): if not self.sync_mode_is_active() or not self.cluster.sync.leader_matches(st.member.name): return False logger.info('Ignoring the former leader being ahead of us') + if my_wal_position == st.wal_position and self.patroni.failover_priority < st.failover_priority: + # There's a higher priority non-lagging replica + logger.info( + '%s has equally tolerable WAL position and priority %s, while this node has priority %s', + st.member.name, + st.failover_priority, + self.patroni.failover_priority, + ) + return False return True def is_failover_possible(self, *, cluster_lsn: int = 0, exclude_failover_candidate: bool = False) -> bool: @@ -956,11 +1028,12 @@ class Ha(object): """ candidates = self.get_failover_candidates(exclude_failover_candidate) + action = self._get_failover_action_name() if self.is_synchronous_mode() and self.cluster.failover and self.cluster.failover.candidate and not candidates: - logger.warning('Failover candidate=%s does not match with sync_standbys=%s', - self.cluster.failover.candidate, self.cluster.sync.sync_standby) + logger.warning('%s candidate=%s does not match with sync_standbys=%s', + action.title(), self.cluster.failover.candidate, self.cluster.sync.sync_standby) elif not candidates: - logger.warning('manual failover: candidates list is empty') + logger.warning('%s: candidates list is empty', action) ret = False cluster_timeline = self.cluster.timeline @@ -987,15 +1060,18 @@ class Ha(object): failover = self.cluster.failover if TYPE_CHECKING: # pragma: no cover assert failover is not None - if failover.candidate: # manual failover to specific member - if failover.candidate == self.state_handler.name: # manual failover to me + + action = self._get_failover_action_name() + + if failover.candidate: # manual failover/switchover to specific member + if failover.candidate == self.state_handler.name: # manual failover/switchover to me return True elif self.is_paused(): # Remove failover key if the node to failover has terminated to avoid waiting for it indefinitely # In order to avoid attempts to delete this key from all nodes only the primary is allowed to do it. if not self.cluster.get_member(failover.candidate, fallback_to_leader=False)\ and self.state_handler.is_primary(): - logger.warning("manual failover: removing failover key because failover candidate is not running") + logger.warning("%s: removing failover key because failover candidate is not running", action) self.dcs.manual_failover('', '', version=failover.version) return None return False @@ -1011,17 +1087,17 @@ class Ha(object): st = self.fetch_node_status(member) not_allowed_reason = st.failover_limitation() if not_allowed_reason is None: # node is healthy - logger.info('manual failover: to %s, i am %s', st.member.name, self.state_handler.name) + logger.info('%s: to %s, i am %s', action, st.member.name, self.state_handler.name) return False - # we wanted to failover to specific member but it is not healthy - logger.warning('manual failover: member %s is %s', st.member.name, not_allowed_reason) + # we wanted to failover/switchover to specific member but it is not healthy + logger.warning('%s: member %s is %s', action, st.member.name, not_allowed_reason) - # at this point we should consider all members as a candidates for failover + # at this point we should consider all members as a candidates for failover/switchover # i.e. we assume that failover.candidate is None elif self.is_paused(): return False - # try to pick some other members to failover and check that they are healthy + # try to pick some other members for switchover and check that they are healthy if failover.leader: if self.state_handler.name == failover.leader: # I was the leader # exclude desired member which is unhealthy if it was specified @@ -1079,8 +1155,8 @@ class Ha(object): if self.cluster.failover: # When doing a switchover in synchronous mode only synchronous nodes and former leader are allowed to race - if self.sync_mode_is_active() and not self.cluster.sync.matches(self.state_handler.name, True) and \ - self.cluster.failover.leader: + if self.cluster.failover.leader and self.sync_mode_is_active() \ + and not self.cluster.sync.matches(self.state_handler.name, True): return False return self.manual_failover_process_no_leader() or False @@ -1149,15 +1225,16 @@ class Ha(object): status = {'released': False} - def on_shutdown(checkpoint_location: int) -> None: + def on_shutdown(checkpoint_location: int, prev_location: int) -> None: # Postmaster is still running, but pg_control already reports clean "shut down". # It could happen if Postgres is still archiving the backlog of WAL files. # If we know that there are replicas that received the shutdown checkpoint # location, we can remove the leader key and allow them to start leader race. + time.sleep(1) # give replicas some more time to catch up if self.is_failover_possible(cluster_lsn=checkpoint_location): self.state_handler.set_role('demoted') with self._async_executor: - self.release_leader_key_voluntarily(checkpoint_location) + self.release_leader_key_voluntarily(prev_location) status['released'] = True def before_shutdown() -> None: @@ -1244,28 +1321,35 @@ class Ha(object): :returns: action message if demote was initiated, None if no action was taken""" failover = self.cluster.failover + # if there is no failover key or + # I am holding the lock but am not primary = I am the standby leader, + # then do nothing if not failover or (self.is_paused() and not self.state_handler.is_primary()): return + action = self._get_failover_action_name() + bare_action = action.replace('manual ', '') + + # it is not the time for the scheduled switchover yet, do nothing if (failover.scheduled_at and not - self.should_run_scheduled_action("failover", failover.scheduled_at, lambda: + self.should_run_scheduled_action(bare_action, failover.scheduled_at, lambda: self.dcs.manual_failover('', '', version=failover.version))): return if not failover.leader or failover.leader == self.state_handler.name: if not failover.candidate or failover.candidate != self.state_handler.name: if not failover.candidate and self.is_paused(): - logger.warning('Failover is possible only to a specific candidate in a paused state') + logger.warning('%s is possible only to a specific candidate in a paused state', action.title()) elif self.is_failover_possible(): - ret = self._async_executor.try_run_async('manual failover: demote', self.demote, ('graceful',)) - return ret or 'manual failover: demoting myself' + ret = self._async_executor.try_run_async(f'{action}: demote', self.demote, ('graceful',)) + return ret or f'{action}: demoting myself' else: - logger.warning('manual failover: no healthy members found, failover is not possible') + logger.warning('%s: no healthy members found, %s is not possible', + action, bare_action) else: - logger.warning('manual failover: I am already the leader, no need to failover') + logger.warning('%s: I am already the leader, no need to %s', action, bare_action) else: - logger.warning('manual failover: leader name does not match: %s != %s', - failover.leader, self.state_handler.name) + logger.warning('%s: leader name does not match: %s != %s', action, failover.leader, self.state_handler.name) logger.info('Cleaning up failover key') self.dcs.manual_failover('', '', version=failover.version) @@ -1322,6 +1406,7 @@ class Ha(object): self._delete_leader() return 'removed leader lock because postgres is not running as primary' + # update lock to avoid split-brain if self.update_lock(True): msg = self.process_manual_failover_from_leader() if msg is not None: @@ -1454,7 +1539,7 @@ class Ha(object): # Now that restart is scheduled we can set timeout for startup, it will get reset # once async executor runs and main loop notices PostgreSQL as up. - timeout = restart_data.get('timeout', self.global_config.primary_start_timeout) + timeout = restart_data.get('timeout', global_config.primary_start_timeout) self.set_start_timeout(timeout) def before_shutdown() -> None: @@ -1518,7 +1603,7 @@ class Ha(object): """Figure out what to do with the task AsyncExecutor is performing.""" if self.has_lock() and self.update_lock(): if self._async_executor.scheduled_action == 'doing crash recovery in a single user mode': - time_left = self.global_config.primary_start_timeout - (time.time() - self._crash_recovery_started) + time_left = global_config.primary_start_timeout - (time.time() - self._crash_recovery_started) if time_left <= 0 and self.is_failover_possible(): logger.info("Demoting self because crash recovery is taking too long") self.state_handler.cancellable.cancel(True) @@ -1603,7 +1688,7 @@ class Ha(object): self.set_is_leader(True) if self.is_synchronous_mode(): self.state_handler.sync_handler.set_synchronous_standby_names( - CaseInsensitiveSet('*') if self.global_config.is_synchronous_mode_strict else CaseInsensitiveSet()) + CaseInsensitiveSet('*') if global_config.is_synchronous_mode_strict else CaseInsensitiveSet()) self.state_handler.call_nowait(CallbackAction.ON_START) self.load_cluster_from_dcs() @@ -1626,7 +1711,7 @@ class Ha(object): self.demote('immediate-nolock') return 'stopped PostgreSQL while starting up because leader key was lost' - timeout = self._start_timeout or self.global_config.primary_start_timeout + timeout = self._start_timeout or global_config.primary_start_timeout time_left = timeout - self.state_handler.time_in_state() if time_left <= 0: @@ -1659,8 +1744,8 @@ class Ha(object): try: try: self.load_cluster_from_dcs() - self.global_config = self.patroni.config.get_global_config(self.cluster) - self.state_handler.reset_cluster_info_state(self.cluster, self.patroni.nofailover, self.global_config) + global_config.update(self.cluster) + self.state_handler.reset_cluster_info_state(self.cluster, self.patroni) except Exception: self.state_handler.reset_cluster_info_state(None) raise @@ -1680,10 +1765,10 @@ class Ha(object): self.touch_member() # cluster has leader key but not initialize key - if not (self.cluster.is_unlocked() or self.sysid_valid(self.cluster.initialize)) and self.has_lock(): + if self.has_lock(False) and not self.sysid_valid(self.cluster.initialize): self.dcs.initialize(create_new=(self.cluster.initialize is None), sysid=self.state_handler.sysid) - if not (self.cluster.is_unlocked() or self.cluster.config and self.cluster.config.data) and self.has_lock(): + if self.has_lock(False) and not (self.cluster.config and self.cluster.config.data): self.dcs.set_config_value(json.dumps(self.patroni.config.dynamic_configuration, separators=(',', ':'))) self.cluster = self.dcs.get_cluster() @@ -1817,7 +1902,7 @@ class Ha(object): if not is_promoting and create_slots and self.cluster.leader: err = self._async_executor.try_run_async('copy_logical_slots', self.state_handler.slots_handler.copy_logical_slots, - args=(self.cluster, create_slots)) + args=(self.cluster, self.patroni, create_slots)) if not err: ret = 'Copying logical slots {0} from the primary'.format(create_slots) return ret @@ -1873,10 +1958,7 @@ class Ha(object): cluster = self._failsafe.update_cluster(self.cluster)\ if self.is_failsafe_mode() and not self.is_leader() else self.cluster if cluster: - slots = self.state_handler.slots_handler.sync_replication_slots(cluster, - self.patroni.nofailover, - self.patroni.replicatefrom, - self.is_paused()) + slots = self.state_handler.slots_handler.sync_replication_slots(cluster, self.patroni) # Don't copy replication slots if failsafe_mode is active return [] if self.failsafe_is_active() else slots @@ -1904,18 +1986,18 @@ class Ha(object): status = {'deleted': False} - def _on_shutdown(checkpoint_location: int) -> None: + def _on_shutdown(checkpoint_location: int, prev_location: int) -> None: if self.is_leader(): # Postmaster is still running, but pg_control already reports clean "shut down". # It could happen if Postgres is still archiving the backlog of WAL files. # If we know that there are replicas that received the shutdown checkpoint # location, we can remove the leader key and allow them to start leader race. - + time.sleep(1) # give replicas some more time to catch up if self.is_failover_possible(cluster_lsn=checkpoint_location): - self.dcs.delete_leader(self.cluster.leader, checkpoint_location) + self.dcs.delete_leader(self.cluster.leader, prev_location) status['deleted'] = True else: - self.dcs.write_leader_optime(checkpoint_location) + self.dcs.write_leader_optime(prev_location) def _before_shutdown() -> None: self.notify_citus_coordinator('before_demote') @@ -1960,7 +2042,7 @@ class Ha(object): config or cluster.config.data. """ data: Dict[str, Any] = {} - cluster_params = self.global_config.get_standby_cluster_config() + cluster_params = global_config.get_standby_cluster_config() if cluster_params: data.update({k: v for k, v in cluster_params.items() if k in RemoteMember.ALLOWED_KEYS}) @@ -1990,8 +2072,9 @@ class Ha(object): exclude = [self.state_handler.name] + ([failover.candidate] if failover and exclude_failover_candidate else []) def is_eligible(node: Member) -> bool: - # TODO: allow manual failover (=no leader specified) to async node - if self.sync_mode_is_active() and not self.cluster.sync.matches(node.name): + # in synchronous mode we allow failover (not switchover!) to async node + if self.sync_mode_is_active() and not self.cluster.sync.matches(node.name)\ + and not (failover and not failover.leader): return False # Don't spend time on "nofailover" nodes checking. # We also don't need nodes which we can't query with the api in the list. diff --git a/patroni/log.py b/patroni/log.py index 09d73883..6ac67a17 100644 --- a/patroni/log.py +++ b/patroni/log.py @@ -202,24 +202,37 @@ class PatroniLogger(Thread): self._proxy_handler = ProxyHandler(self) self._root_logger.addHandler(self._proxy_handler) - def update_loggers(self) -> None: - """Configure loggers' log level as defined in ``log.loggers`` section of Patroni configuration. + def update_loggers(self, config: Dict[str, Any]) -> None: + """Configure custom loggers' log levels. .. note:: It creates logger objects that are not defined yet in the log manager. + + :param config: :class:`dict` object with custom loggers configuration, is set either from: + + * ``log.loggers`` section of Patroni configuration; or + + * from the method that is trying to make sure that the node name + isn't duplicated (to silence annoying ``urllib3`` WARNING's). + + :Example: + + .. code-block:: python + + update_loggers({'urllib3.connectionpool': 'WARNING'}) """ - loggers = deepcopy((self._config or {}).get('loggers') or {}) + loggers = deepcopy(config) for name, logger in self._root_logger.manager.loggerDict.items(): # ``Placeholder`` is a node in the log manager for which no logger has been defined. We are interested only # in the ones that were defined if not isinstance(logger, logging.PlaceHolder): - # if this logger is present in ``log.loggers`` Patroni configuration, use the configured level, - # otherwise use ``logging.NOTSET``, which means it will inherit the level from any parent node up to - # the root for which log level is defined. + # if this logger is present in *config*, use the configured level, otherwise + # use ``logging.NOTSET``, which means it will inherit the level + # from any parent node up to the root for which log level is defined. level = loggers.pop(name, logging.NOTSET) logger.setLevel(level) - # define loggers that do not exist yet and set level as configured in ``log.loggers`` section of configuration. + # define loggers that do not exist yet and set level as configured in the *config* for name, level in loggers.items(): logger = self._root_logger.manager.getLogger(name) logger.setLevel(level) @@ -274,7 +287,7 @@ class PatroniLogger(Thread): self.log_handler = new_handler self._config = config.copy() - self.update_loggers() + self.update_loggers(config.get('loggers') or {}) def _close_old_handlers(self) -> None: """Close old log handlers. diff --git a/patroni/postgresql/__init__.py b/patroni/postgresql/__init__.py index a37e15e1..c87c29fa 100644 --- a/patroni/postgresql/__init__.py +++ b/patroni/postgresql/__init__.py @@ -24,17 +24,17 @@ from .misc import parse_history, parse_lsn, postgres_major_version_to_int from .postmaster import PostmasterProcess from .slots import SlotsHandler from .sync import SyncHandler -from .. import psycopg +from .. import global_config, psycopg from ..async_executor import CriticalTask from ..collections import CaseInsensitiveSet -from ..dcs import Cluster, Leader, Member +from ..dcs import Cluster, Leader, Member, SLOT_ADVANCE_AVAILABLE_VERSION from ..exceptions import PostgresConnectionException from ..utils import Retry, RetryFailedError, polling_loop, data_directory_is_empty, parse_int +from ..tags import Tags if TYPE_CHECKING: # pragma: no cover from psycopg import Connection as Connection3, Cursor from psycopg2 import connection as connection3, cursor - from ..config import GlobalConfig logger = logging.getLogger(__name__) @@ -73,7 +73,6 @@ class Postgresql(object): self.connection_string: str self.proxy_url: Optional[str] self._major_version = self.get_major_version() - self._global_config = None self._state_lock = Lock() self.set_state('stopped') @@ -112,24 +111,37 @@ class Postgresql(object): self._state_entry_timestamp = 0 self._cluster_info_state = {} - self._has_permanent_logical_slots = True + self._has_permanent_slots = True self._enforce_hot_standby_feedback = False self._cached_replica_timeline = None # Last known running process self._postmaster_proc = None - if self.is_running(): # we are "joining" already running postgres - self.set_state('running') + self._available_gucs = None + + if self.is_running(): + # If we found postmaster process we need to figure out whether postgres is accepting connections + self.set_state('starting') + self.check_startup_state_changed() + + if self.state == 'running': # we are "joining" already running postgres + # we know that PostgreSQL is accepting connections and can read some GUC's from pg_settings + self.config.load_current_server_parameters() + self.set_role('master' if self.is_primary() else 'replica') - # postpone writing postgresql.conf for 12+ because recovery parameters are not yet known - if self.major_version < 120000 or self.is_primary(): - self.config.write_postgresql_conf() + hba_saved = self.config.replace_pg_hba() ident_saved = self.config.replace_pg_ident() - if hba_saved or ident_saved: + + if self.major_version < 120000 or self.role in ('master', 'primary'): + # If PostgreSQL is running as a primary or we run PostgreSQL that is older than 12 we can + # call reload_config() once again (the first call happened in the ConfigHandler constructor), + # so that it can figure out if config files should be updated and pg_ctl reload executed. + self.config.reload_config(config, sighup=bool(hba_saved or ident_saved)) + elif hba_saved or ident_saved: self.reload() - elif self.role in ('master', 'primary'): + elif not self.is_running() and self.role in ('master', 'primary'): self.set_role('demoted') @property @@ -174,6 +186,11 @@ class Postgresql(object): """:returns: `True` if Postgres version supports more than one synchronous node.""" return self._major_version >= 90600 + @property + def can_advance_slots(self) -> bool: + """``True`` if :attr:``major_version`` is greater than 110000.""" + return self.major_version >= SLOT_ADVANCE_AVAILABLE_VERSION + @property def cluster_info_query(self) -> str: """Returns the monitoring query with a fixed number of fields. @@ -201,15 +218,16 @@ class Postgresql(object): "FROM pg_catalog.pg_stat_get_wal_senders() w," " pg_catalog.pg_stat_get_activity(w.pid)" " WHERE w.state = 'streaming') r)").format(self.wal_name, self.lsn_name) - if (not self.global_config or self.global_config.is_synchronous_mode) + if global_config.is_synchronous_mode and self.role in ('master', 'primary', 'promoted') else "'on', '', NULL") if self._major_version >= 90600: extra = ("pg_catalog.current_setting('restore_command')" if self._major_version >= 120000 else "NULL") +\ ", " + ("(SELECT pg_catalog.json_agg(s.*) FROM (SELECT slot_name, slot_type as type, datoid::bigint, " "plugin, catalog_xmin, pg_catalog.pg_wal_lsn_diff(confirmed_flush_lsn, '0/0')::bigint" - " AS confirmed_flush_lsn FROM pg_catalog.pg_get_replication_slots()) AS s)" - if self._has_permanent_logical_slots and self._major_version >= 110000 else "NULL") + extra + " AS confirmed_flush_lsn, pg_catalog.pg_wal_lsn_diff(restart_lsn, '0/0')::bigint" + " AS restart_lsn FROM pg_catalog.pg_get_replication_slots()) AS s)" + if self._has_permanent_slots and self.can_advance_slots else "NULL") + extra extra = (", CASE WHEN latest_end_lsn IS NULL THEN NULL ELSE received_tli END," " slot_name, conninfo, status, {0} FROM pg_catalog.pg_stat_get_wal_receiver()").format(extra) if self.role == 'standby_leader': @@ -224,7 +242,9 @@ class Postgresql(object): @property def available_gucs(self) -> CaseInsensitiveSet: """GUCs available in this Postgres server.""" - return self._get_gucs() + if not self._available_gucs: + self._available_gucs = self._get_gucs() + return self._available_gucs def _version_file_exists(self) -> bool: return not self.data_directory_empty() and os.path.isfile(self._version_file) @@ -409,43 +429,30 @@ class Postgresql(object): self.config.write_postgresql_conf() self.reload() - @property - def global_config(self) -> Optional['GlobalConfig']: - return self._global_config - - def reset_cluster_info_state(self, cluster: Union[Cluster, None], nofailover: bool = False, - global_config: Optional['GlobalConfig'] = None) -> None: + def reset_cluster_info_state(self, cluster: Optional[Cluster], tags: Optional[Tags] = None) -> None: """Reset monitoring query cache. - It happens in the beginning of heart-beat loop and on change of `synchronous_standby_names`. + .. note:: + It happens in the beginning of heart-beat loop and on change of `synchronous_standby_names`. :param cluster: currently known cluster state from DCS - :param nofailover: whether this node could become a new primary. - Important when there are logical permanent replication slots because "nofailover" - node could do cascading replication and should enable `hot_standby_feedback` - :param global_config: last known :class:`GlobalConfig` object + :param tags: reference to an object implementing :class:`Tags` interface. """ self._cluster_info_state = {} - if global_config: - self._global_config = global_config - - if not self._global_config: + if not tags: return - if self._global_config.is_standby_cluster: + if global_config.is_standby_cluster: # Standby cluster can't have logical replication slots, and we don't need to enforce hot_standby_feedback - self._has_permanent_logical_slots = False self.set_enforce_hot_standby_feedback(False) - elif cluster and cluster.config and cluster.config.modify_version: - self._has_permanent_logical_slots =\ - cluster.has_permanent_logical_slots(self.name, nofailover, self.major_version) + if cluster and cluster.config and cluster.config.modify_version: # We want to enable hot_standby_feedback if the replica is supposed # to have a logical slot or in case if it is the cascading replica. - self.set_enforce_hot_standby_feedback( - self._has_permanent_logical_slots - or cluster.should_enforce_hot_standby_feedback(self.name, nofailover, self.major_version)) + self.set_enforce_hot_standby_feedback(not global_config.is_standby_cluster and self.can_advance_slots + and cluster.should_enforce_hot_standby_feedback(self, tags)) + self._has_permanent_slots = cluster.has_permanent_slots(self, tags) def _cluster_info_state_get(self, name: str) -> Optional[Any]: if not self._cluster_info_state: @@ -456,7 +463,7 @@ class Postgresql(object): 'received_tli', 'slot_name', 'conninfo', 'receiver_state', 'restore_command', 'slots', 'synchronous_commit', 'synchronous_standby_names', 'pg_stat_replication'], result)) - if self._has_permanent_logical_slots: + if self._has_permanent_slots and self.can_advance_slots: cluster_info_state['slots'] =\ self.slots_handler.process_permanent_slots(cluster_info_state['slots']) self._cluster_info_state = cluster_info_state @@ -568,17 +575,20 @@ class Postgresql(object): r'lsn: ([0-9A-Fa-f]+/[0-9A-Fa-f]+), prev ([0-9A-Fa-f]+/[0-9A-Fa-f]+), ' r'.*?desc: (.+)', out.decode('utf-8')) if match: - return match.groups() + return match.group(1), match.group(2), match.group(3), match.group(4) return None, None, None, None - def latest_checkpoint_location(self) -> Optional[int]: - """Returns checkpoint location for the cleanly shut down primary. - But, if we know that the checkpoint was written to the new WAL - due to the archive_mode=on, we will return the LSN of prev wal record (SWITCH).""" + def _checkpoint_locations_from_controldata(self, data: Dict[str, str]) -> Optional[Tuple[int, int]]: + """Get shutdown checkpoint location. - data = self.controldata() + :param data: :class:`dict` object with values returned by `pg_controldata` tool. + + :returns: a tuple of checkpoint LSN for the cleanly shut down primary, and LSN of prev wal record (SWITCH) + if we know that the checkpoint was written to the new WAL file due to the archive_mode=on. + """ timeline = data.get("Latest checkpoint's TimeLineID") lsn = checkpoint_lsn = data.get('Latest checkpoint location') + prev_lsn = None if data.get('Database cluster state') == 'shut down' and lsn and timeline and checkpoint_lsn: try: checkpoint_lsn = parse_lsn(checkpoint_lsn) @@ -589,13 +599,26 @@ class Postgresql(object): _, lsn, _, desc = self.parse_wal_record(timeline, prev) prev = parse_lsn(prev) # If the cluster is shutdown with archive_mode=on, WAL is switched before writing the checkpoint. - # In this case we want to take the LSN of previous record (switch) as the last known WAL location. + # In this case we want to take the LSN of previous record (SWITCH) as the last known WAL location. if lsn and parse_lsn(lsn) == prev and str(desc).strip() in ('xlog switch', 'SWITCH'): - return prev + prev_lsn = prev except Exception as e: logger.error('Exception when parsing WAL pg_%sdump output: %r', self.wal_name, e) if isinstance(checkpoint_lsn, int): - return checkpoint_lsn + return checkpoint_lsn, (prev_lsn or checkpoint_lsn) + + def latest_checkpoint_location(self) -> Optional[int]: + """Get shutdown checkpoint location. + + .. note:: + In case if checkpoint was written to the new WAL file due to the archive_mode=on + we return LSN of the previous wal record (SWITCH). + + :returns: checkpoint LSN for the cleanly shut down primary. + """ + checkpoint_locations = self._checkpoint_locations_from_controldata(self.controldata()) + if checkpoint_locations: + return checkpoint_locations[1] def is_running(self) -> Optional[PostmasterProcess]: """Returns PostmasterProcess if one is running on the data directory or None. If most recently seen process @@ -781,7 +804,7 @@ class Postgresql(object): return 'not accessible or not healty' def stop(self, mode: str = 'fast', block_callbacks: bool = False, checkpoint: Optional[bool] = None, - on_safepoint: Optional[Callable[..., Any]] = None, on_shutdown: Optional[Callable[[int], Any]] = None, + on_safepoint: Optional[Callable[..., Any]] = None, on_shutdown: Optional[Callable[[int, int], Any]] = None, before_shutdown: Optional[Callable[..., Any]] = None, stop_timeout: Optional[int] = None) -> bool: """Stop PostgreSQL @@ -811,7 +834,7 @@ class Postgresql(object): return success def _do_stop(self, mode: str, block_callbacks: bool, checkpoint: bool, - on_safepoint: Optional[Callable[..., Any]], on_shutdown: Optional[Callable[..., Any]], + on_safepoint: Optional[Callable[..., Any]], on_shutdown: Optional[Callable[[int, int], Any]], before_shutdown: Optional[Callable[..., Any]], stop_timeout: Optional[int]) -> Tuple[bool, bool]: postmaster = self.is_running() if not postmaster: @@ -851,7 +874,9 @@ class Postgresql(object): while postmaster.is_running(): data = self.controldata() if data.get('Database cluster state', '') == 'shut down': - on_shutdown(self.latest_checkpoint_location()) + checkpoint_locations = self._checkpoint_locations_from_controldata(data) + if checkpoint_locations: + on_shutdown(*checkpoint_locations) break elif data.get('Database cluster state', '').startswith('shut down'): # shut down in recovery break @@ -1023,7 +1048,7 @@ class Postgresql(object): return None, None @contextmanager - def get_replication_connection_cursor(self, host: Optional[str] = None, port: int = 5432, + def get_replication_connection_cursor(self, host: Optional[str] = None, port: Union[int, str] = 5432, **kwargs: Any) -> Iterator[Union['cursor', 'Cursor[Any]']]: conn_kwargs = self.config.replication.copy() conn_kwargs.update(host=host, port=int(port) if port else None, user=conn_kwargs.pop('username'), diff --git a/patroni/postgresql/bootstrap.py b/patroni/postgresql/bootstrap.py index 26025e43..a544bd73 100644 --- a/patroni/postgresql/bootstrap.py +++ b/patroni/postgresql/bootstrap.py @@ -151,9 +151,46 @@ class Bootstrap(object): os.unlink(trigger_file) def _custom_bootstrap(self, config: Any) -> bool: + """Bootstrap a fresh Patroni cluster using a custom method provided by the user. + + :param config: configuration used for running a custom bootstrap method. It comes from the Patroni YAML file, + so it is expected to be a :class:`dict`. + + .. note:: + *config* must contain a ``command`` key, which value is the command or script to perform the custom + bootstrap procedure. The exit code of the ``command`` dictates if the bootstrap succeeded or failed. + + When calling ``command``, Patroni will pass the following arguments to the ``command`` call: + + * ``--scope``: contains the value of ``scope`` configuration; + * ``--data_dir``: contains the value of the ``postgresql.data_dir`` configuration. + + You can avoid that behavior by filling the optional key ``no_params`` with the value ``False`` in the + configuration file, which will instruct Patroni to not pass these parameters to the ``command`` call. + + Besides that, a couple more keys are supported in *config*, but optional: + + * ``keep_existing_recovery_conf``: if ``True``, instruct Patroni to not remove the existing + ``recovery.conf`` (PostgreSQL <= 11), to not discard recovery parameters from the configuration + (PostgreSQL >= 12), and to not remove the files ``recovery.signal`` or ``standby.signal`` + (PostgreSQL >= 12). This is specially useful when you are restoring backups through tools like + pgBackRest and Barman, in which case they generated the appropriate recovery settings for you; + * ``recovery_conf``: a section containing a map, where each key is the name of a recovery related + setting, and the value is the value of the corresponding setting. + + Any key/value other than the ones that were described above will be interpreted as additional arguments for + the ``command`` call. They will all be added to the call in the format ``--key=value``. + + :returns: ``True`` if the bootstrap was successful, i.e. the execution of the custom ``command`` from *config* + exited with code ``0``, ``False`` otherwise. + """ self._postgresql.set_state('running custom bootstrap script') params = [] if config.get('no_params') else ['--scope=' + self._postgresql.scope, '--datadir=' + self._postgresql.data_dir] + # Add custom parameters specified by the user + reserved_args = {'command', 'no_params', 'keep_existing_recovery_conf', 'recovery_conf', 'scope', 'datadir'} + params += [f"--{arg}={val}" for arg, val in config.items() if arg not in reserved_args] + try: logger.info('Running custom bootstrap script: %s', config['command']) if self._postgresql.cancellable.call(shlex.split(config['command']) + params) != 0: @@ -400,6 +437,9 @@ BEGIN END;$$""".format(f, quote_ident(rewind['username'], postgresql.connection())) postgresql.query(sql) + if config.get('users'): + logger.warning('User creation via "bootstrap.users" will be removed in v4.0.0') + for name, value in (config.get('users') or {}).items(): if all(name != a.get('username') for a in (superuser, replication, rewind)): self.create_or_update_role(name, value.get('password'), value.get('options', [])) diff --git a/patroni/postgresql/callback_executor.py b/patroni/postgresql/callback_executor.py index 3ae073fd..06b9f353 100644 --- a/patroni/postgresql/callback_executor.py +++ b/patroni/postgresql/callback_executor.py @@ -1,8 +1,9 @@ import logging +import sys from enum import Enum from threading import Condition, Thread -from typing import List +from typing import Any, Dict, List from .cancellable import CancellableExecutor, CancellableSubprocess @@ -30,7 +31,9 @@ class OnReloadExecutor(CancellableSubprocess): self.cancel(kill=True) self._kill_children() with self._lock: - self._start_process(cmd, close_fds=True) + started = self._start_process(cmd, close_fds=True) + if started and self._process is not None: + Thread(target=self._process.wait).start() class CallbackExecutor(CancellableExecutor, Thread): @@ -51,6 +54,8 @@ class CallbackExecutor(CancellableExecutor, Thread): If it couldn't be killed we wait until it finishes. :param cmd: command to be executed""" + kwargs: Dict[str, Any] = {'stacklevel': 3} if sys.version_info >= (3, 8) else {} + logger.debug('CallbackExecutor.call(%s)', cmd, **kwargs) if cmd[-3] == CallbackAction.ON_RELOAD: return self._on_reload_executor.call_nowait(cmd) diff --git a/patroni/postgresql/citus.py b/patroni/postgresql/citus.py index 020dced5..4b83aed2 100644 --- a/patroni/postgresql/citus.py +++ b/patroni/postgresql/citus.py @@ -387,8 +387,9 @@ class CitusHandler(Thread): def on_demote(self) -> None: with self._condition: - self._pg_dist_group.clear() - self._tasks[:] = [] + self._pg_dist_node.clear() + empty_tasks: List[PgDistNode] = [] + self._tasks[:] = empty_tasks self._in_flight = None def query(self, sql: str, *params: Any) -> List[Tuple[Any, ...]]: @@ -711,12 +712,15 @@ class CitusHandler(Thread): parameters['shared_preload_libraries'] = ','.join(['citus'] + shared_preload_libraries) # if not explicitly set Citus overrides max_prepared_transactions to max_connections*2 - if parameters.get('max_prepared_transactions') == 0: + if parameters['max_prepared_transactions'] == 0: parameters['max_prepared_transactions'] = parameters['max_connections'] * 2 # Resharding in Citus implemented using logical replication parameters['wal_level'] = 'logical' + # Sometimes Citus needs to connect to the local postgres. We will do it the same way as Patroni does. + parameters['citus.local_hostname'] = self._postgresql.connection_pool.conn_kwargs.get('host', 'localhost') + def ignore_replication_slot(self, slot: Dict[str, str]) -> bool: if isinstance(self._config, dict) and self._postgresql.is_primary() and\ slot['type'] == 'logical' and slot['database'] == self._config['database']: diff --git a/patroni/postgresql/config.py b/patroni/postgresql/config.py index 315bf8c7..271bbdfe 100644 --- a/patroni/postgresql/config.py +++ b/patroni/postgresql/config.py @@ -12,6 +12,7 @@ from types import TracebackType from typing import Any, Collection, Dict, Iterator, List, Optional, Union, Tuple, Type, TYPE_CHECKING from .validator import recovery_parameters, transform_postgresql_parameter_value, transform_recovery_parameter_value +from .. import global_config from ..collections import CaseInsensitiveDict, CaseInsensitiveSet from ..dcs import Leader, Member, RemoteMember, slot_name_from_member_name from ..exceptions import PatroniFatalException, PostgresConnectionException @@ -244,9 +245,10 @@ class ConfigWriter(object): self._fd.write(line) self._fd.write('\n') - def writelines(self, lines: List[str]) -> None: + def writelines(self, lines: List[Optional[str]]) -> None: for line in lines: - self.writeline(line) + if isinstance(line, str): + self.writeline(line) @staticmethod def escape(value: Any) -> str: # Escape (by doubling) any single quotes or backslashes in given string @@ -326,14 +328,22 @@ class ConfigHandler(object): .format(self._pgpass)) self._passfile = None self._passfile_mtime = None - self._synchronous_standby_names = None self._postmaster_ctime = None self._current_recovery_params: Optional[CaseInsensitiveDict] = None self._config = {} self._recovery_params = CaseInsensitiveDict() - self._server_parameters: CaseInsensitiveDict + self._server_parameters: CaseInsensitiveDict = CaseInsensitiveDict() self.reload_config(config) + def load_current_server_parameters(self) -> None: + """Read GUC's values from ``pg_settings`` when Patroni is joining the the postgres that is already running.""" + exclude = [name.lower() for name, value in self.CMDLINE_OPTIONS.items() if value[1] == _false_validator] \ + + [name.lower() for name in self._RECOVERY_PARAMETERS] + self._server_parameters = CaseInsensitiveDict({r[0]: r[1] for r in self._postgresql.query( + "SELECT name, pg_catalog.current_setting(name) FROM pg_catalog.pg_settings" + " WHERE (source IN ('command line', 'environment variable') OR sourcefile = %s)" + " AND pg_catalog.lower(name) != ALL(%s)", self._postgresql_conf, exclude)}) + def setup_server_parameters(self) -> None: self._server_parameters = self.get_server_parameters(self._config) self._adjust_recovery_parameters() @@ -586,7 +596,7 @@ class ConfigHandler(object): is_remote_member = isinstance(member, RemoteMember) primary_conninfo = self.primary_conninfo_params(member) if primary_conninfo: - use_slots = self.get('use_slots', True) and self._postgresql.major_version >= 90400 + use_slots = global_config.use_slots and self._postgresql.major_version >= 90400 if use_slots and not (is_remote_member and member.no_replication_slot): primary_slot_name = member.primary_slot_name if is_remote_member else self._postgresql.name recovery_params['primary_slot_name'] = slot_name_from_member_name(primary_slot_name) @@ -921,15 +931,16 @@ class ConfigHandler(object): parameters = config['parameters'].copy() listen_addresses, port = split_host_port(config['listen'], 5432) parameters.update(cluster_name=self._postgresql.scope, listen_addresses=listen_addresses, port=str(port)) - if not self._postgresql.global_config or self._postgresql.global_config.is_synchronous_mode: - if self._synchronous_standby_names is None: - if self._postgresql.global_config and self._postgresql.global_config.is_synchronous_mode_strict\ + if global_config.is_synchronous_mode: + synchronous_standby_names = self._server_parameters.get('synchronous_standby_names') + if synchronous_standby_names is None: + if global_config.is_synchronous_mode_strict\ and self._postgresql.role in ('master', 'primary', 'promoted'): parameters['synchronous_standby_names'] = '*' else: parameters.pop('synchronous_standby_names', None) else: - parameters['synchronous_standby_names'] = self._synchronous_standby_names + parameters['synchronous_standby_names'] = synchronous_standby_names # Handle hot_standby <-> replica rename if parameters.get('wal_level') == ('hot_standby' if self._postgresql.major_version >= 90600 else 'replica'): @@ -1026,17 +1037,14 @@ class ConfigHandler(object): # "notify" connection_pool about the "new" local connection address self._postgresql.connection_pool.conn_kwargs = local_conn_kwargs - def _get_pg_settings( - self, names: Collection[str] - ) -> Dict[str, Tuple[str, str, Optional[str], str, str, Optional[str]]]: + def _get_pg_settings(self, names: Collection[str]) -> Dict[Any, Tuple[Any, ...]]: return {r[0]: r for r in self._postgresql.query(('SELECT name, setting, unit, vartype, context, sourcefile' + ' FROM pg_catalog.pg_settings ' + ' WHERE pg_catalog.lower(name) = ANY(%s)'), [n.lower() for n in names])} @staticmethod - def _handle_wal_buffers(old_values: Dict[str, Tuple[str, str, Optional[str], str, str, Optional[str]]], - changes: CaseInsensitiveDict) -> None: + def _handle_wal_buffers(old_values: Dict[Any, Tuple[Any, ...]], changes: CaseInsensitiveDict) -> None: wal_block_size = parse_int(old_values['wal_block_size'][1]) or 8192 wal_segment_size = old_values['wal_segment_size'] wal_segment_unit = parse_int(wal_segment_size[2], 'B') or 8192 \ @@ -1153,12 +1161,11 @@ class ConfigHandler(object): def set_synchronous_standby_names(self, value: Optional[str]) -> Optional[bool]: """Updates synchronous_standby_names and reloads if necessary. :returns: True if value was updated.""" - if value != self._synchronous_standby_names: + if value != self._server_parameters.get('synchronous_standby_names'): if value is None: self._server_parameters.pop('synchronous_standby_names', None) else: self._server_parameters['synchronous_standby_names'] = value - self._synchronous_standby_names = value if self._postgresql.state == 'running': self.write_postgresql_conf() self._postgresql.reload() diff --git a/patroni/postgresql/connection.py b/patroni/postgresql/connection.py index 2a50dbb5..040dcf78 100644 --- a/patroni/postgresql/connection.py +++ b/patroni/postgresql/connection.py @@ -147,7 +147,8 @@ class ConnectionPool: def close(self) -> None: """Close all named connections from Patroni to PostgreSQL registered in the pool.""" with self._lock: - if any(conn.close(True) for conn in self._connections.values()): + closed_connections = [conn.close(True) for conn in self._connections.values()] + if any(closed_connections): logger.info("closed patroni connections to postgres") diff --git a/patroni/postgresql/rewind.py b/patroni/postgresql/rewind.py index 73bccb44..4a5283f7 100644 --- a/patroni/postgresql/rewind.py +++ b/patroni/postgresql/rewind.py @@ -101,12 +101,26 @@ class Rewind(object): return 'not accessible or not healty' def _get_checkpoint_end(self, timeline: int, lsn: int) -> int: - """The checkpoint record size in WAL depends on postgres major version and platform (memory alignment). - Hence, the only reliable way to figure out where it ends, read the record from file with the help of pg_waldump - and parse the output. We are trying to read two records, and expect that it will fail to read the second one: - `pg_waldump: fatal: error in WAL record at 0/182E220: invalid record length at 0/182E298: wanted 24, got 0` - The error message contains information about LSN of the next record, which is exactly where checkpoint ends.""" + """Get the end of checkpoint record from WAL. + .. note:: + The checkpoint record size in WAL depends on postgres major version and platform (memory alignment). + Hence, the only reliable way to figure out where it ends, is to read the record from file with the + help of ``pg_waldump`` and parse the output. + + We are trying to read two records, and expect that it will fail to read the second record with message: + + fatal: error in WAL record at 0/182E220: invalid record length at 0/182E298: wanted 24, got 0; or + + fatal: error in WAL record at 0/182E220: invalid record length at 0/182E298: expected at least 24, got 0 + + The error message contains information about LSN of the next record, which is exactly where checkpoint ends. + + :param timeline: the checkpoint *timeline* from ``pg_controldata``. + :param lsn: the checkpoint *location* as :class:`int` from ``pg_controldata``. + + :returns: the end of checkpoint record as :class:`int` or ``0`` if failed to parse ``pg_waldump`` output. + """ lsn8 = format_lsn(lsn, True) lsn_str = format_lsn(lsn) out, err = self._postgresql.waldump(timeline, lsn_str, 2) @@ -117,12 +131,17 @@ class Rewind(object): if len(out) == 1 and len(err) == 1 and ', lsn: {0}, prev '.format(lsn8) in out[0] and pattern in err[0]: i = err[0].find(pattern) + len(pattern) - j = err[0].find(": wanted ", i) - if j > -1: - try: - return parse_lsn(err[0][i:j]) - except Exception as e: - logger.error('Failed to parse lsn %s: %r', err[0][i:j], e) + # Message format depends on the major version: + # * expected at least -- starting from v16 + # * wanted -- before v16 + # We will simply check all possible combinations. + for pattern in (': expected at least ', ': wanted '): + j = err[0].find(pattern, i) + if j > -1: + try: + return parse_lsn(err[0][i:j]) + except Exception as e: + logger.error('Failed to parse lsn %s: %r', err[0][i:j], e) logger.error('Failed to parse pg_%sdump output', self._postgresql.wal_name) logger.error(' stdout=%s', '\n'.join(out)) logger.error(' stderr=%s', '\n'.join(err)) @@ -158,7 +177,7 @@ class Rewind(object): def _get_local_timeline_lsn(self) -> Tuple[Optional[bool], Optional[int], Optional[int]]: if self._postgresql.is_running(): # if postgres is running - get timeline from replication connection in_recovery = True - timeline = self._postgresql.received_timeline() or self._postgresql.get_replica_timeline() + timeline = self._postgresql.get_replica_timeline() lsn = self._postgresql.replayed_location() else: # otherwise analyze pg_controldata output in_recovery, timeline, lsn = self._get_local_timeline_lsn_from_controldata() diff --git a/patroni/postgresql/slots.py b/patroni/postgresql/slots.py index 7391f543..fb9448cd 100644 --- a/patroni/postgresql/slots.py +++ b/patroni/postgresql/slots.py @@ -13,9 +13,11 @@ from typing import Any, Dict, Iterator, List, Optional, Union, Tuple, TYPE_CHECK from .connection import get_connection_cursor from .misc import format_lsn, fsync_dir +from .. import global_config from ..dcs import Cluster, Leader from ..file_perm import pg_perm from ..psycopg import OperationalError +from ..tags import Tags if TYPE_CHECKING: # pragma: no cover from psycopg import Cursor @@ -231,15 +233,16 @@ class SlotsHandler: ret: Dict[str, int] = {} slots_dict: Dict[str, Dict[str, Any]] = {slot['slot_name']: slot for slot in slots or []} - if slots_dict: - for name, value in slots_dict.items(): - if name in self._replication_slots: - if compare_slots(value, self._replication_slots[name], 'datoid'): - if value['type'] == 'logical': - ret[name] = value['confirmed_flush_lsn'] - self._copy_items(value, self._replication_slots[name]) + for name, value in slots_dict.items(): + if name in self._replication_slots: + if compare_slots(value, self._replication_slots[name], 'datoid'): + if value['type'] == 'logical': + ret[name] = value['confirmed_flush_lsn'] + self._copy_items(value, self._replication_slots[name]) else: - self._schedule_load_slots = True + self._replication_slots[name]['restart_lsn'] = ret[name] = value['restart_lsn'] + else: + self._schedule_load_slots = True # It could happen that the slot was deleted in the background, we want to detect this case if any(name not in slots_dict for name in self._replication_slots.keys()): @@ -260,16 +263,19 @@ class SlotsHandler: """ if self._postgresql.major_version >= 90400 and self._schedule_load_slots: replication_slots: Dict[str, Dict[str, Any]] = {} - extra = ", catalog_xmin, pg_catalog.pg_wal_lsn_diff(confirmed_flush_lsn, '0/0')::bigint" \ + pg_wal_lsn_diff = f"pg_catalog.pg_{self._postgresql.wal_name}_{self._postgresql.lsn_name}_diff" + extra = f", catalog_xmin, {pg_wal_lsn_diff}(confirmed_flush_lsn, '0/0')::bigint" \ if self._postgresql.major_version >= 100000 else "" skip_temp_slots = ' WHERE NOT temporary' if self._postgresql.major_version >= 100000 else '' - for r in self._query('SELECT slot_name, slot_type, plugin, database, datoid' - f'{extra} FROM pg_catalog.pg_replication_slots{skip_temp_slots}'): + for r in self._query(f"SELECT slot_name, slot_type, {pg_wal_lsn_diff}(restart_lsn, '0/0')::bigint, plugin," + f" database, datoid{extra} FROM pg_catalog.pg_replication_slots{skip_temp_slots}"): value = {'type': r[1]} if r[1] == 'logical': - value.update(plugin=r[2], database=r[3], datoid=r[4]) + value.update(plugin=r[3], database=r[4], datoid=r[5]) if self._postgresql.major_version >= 100000: - value.update(catalog_xmin=r[5], confirmed_flush_lsn=r[6]) + value.update(catalog_xmin=r[6], confirmed_flush_lsn=r[7]) + else: + value['restart_lsn'] = r[2] replication_slots[r[0]] = value self._replication_slots = replication_slots self._schedule_load_slots = False @@ -289,7 +295,7 @@ class SlotsHandler: """ slot = self._replication_slots[name] if cluster.config: - for matcher in cluster.config.ignore_slots_matchers: + for matcher in global_config.ignore_slots_matchers: if ( (matcher.get("name") is None or matcher["name"] == name) and all(not matcher.get(a) or matcher[a] == slot.get(a) @@ -313,9 +319,9 @@ class SlotsHandler: ' true AS dropped FROM slots WHERE not active) ' 'SELECT active, COALESCE(dropped, false) FROM slots' ' FULL OUTER JOIN dropped ON true'), name) - return rows[0] if rows else (False, False) + return (rows[0][0], rows[0][1]) if rows else (False, False) - def _drop_incorrect_slots(self, cluster: Cluster, slots: Dict[str, Any], paused: bool) -> None: + def _drop_incorrect_slots(self, cluster: Cluster, slots: Dict[str, Any]) -> None: """Compare required slots and configured as permanent slots with those found, dropping extraneous ones. .. note:: @@ -326,11 +332,10 @@ class SlotsHandler: :param cluster: cluster state information object. :param slots: dictionary of desired slot names as keys with slot attributes as a dictionary value, if known. - :param paused: ``True`` if the patroni cluster is currently in a paused state. """ # drop old replication slots which are not presented in desired slots. for name in set(self._replication_slots) - set(slots): - if not paused and not self.ignore_replication_slot(cluster, name): + if not global_config.is_paused and not self.ignore_replication_slot(cluster, name): active, dropped = self.drop_replication_slot(name) if dropped: logger.info("Dropped unknown replication slot '%s'", name) @@ -353,7 +358,7 @@ class SlotsHandler: self._schedule_load_slots = True def _ensure_physical_slots(self, slots: Dict[str, Any]) -> None: - """Create any missing physical replication *slots*. + """Create or advance physical replication *slots*. Any failures are logged and do not interrupt creation of all *slots*. @@ -362,7 +367,9 @@ class SlotsHandler: """ immediately_reserve = ', true' if self._postgresql.major_version >= 90600 else '' for name, value in slots.items(): - if name not in self._replication_slots and value['type'] == 'physical': + if value['type'] != 'physical': + continue + if name not in self._replication_slots: try: self._query(f"SELECT pg_catalog.pg_create_physical_replication_slot(%s{immediately_reserve})" f" WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_replication_slots" @@ -371,6 +378,15 @@ class SlotsHandler: except Exception: logger.exception("Failed to create physical replication slot '%s'", name) self._schedule_load_slots = True + elif self._postgresql.can_advance_slots and self._replication_slots[name]['type'] == 'physical': + value['restart_lsn'] = self._replication_slots[name]['restart_lsn'] + lsn = value.get('lsn') + if lsn and lsn > value['restart_lsn']: # The slot has feedback in DCS and needs to be advanced + try: + lsn = format_lsn(lsn) + self._query("SELECT pg_catalog.pg_replication_slot_advance(%s, %s)", name, lsn) + except Exception as exc: + logger.error("Error while advancing replication slot %s to position '%s': %r", name, lsn, exc) @contextmanager def get_local_connection_cursor(self, **kwargs: Any) -> Iterator[Union['cursor', 'Cursor[Any]']]: @@ -460,12 +476,9 @@ class SlotsHandler: # If the logical already exists, copy some information about it into the original structure if name in self._replication_slots and compare_slots(value, self._replication_slots[name]): self._copy_items(self._replication_slots[name], value) - if 'lsn' in value: # The slot has feedback in DCS - try: # Skip slots that don't need to be advanced - if value['confirmed_flush_lsn'] < int(value['lsn']): - advance_slots[value['database']][name] = int(value['lsn']) - except Exception as e: - logger.error('Failed to parse "%s": %r', value['lsn'], e) + if 'lsn' in value and value['confirmed_flush_lsn'] < value['lsn']: # The slot has feedback in DCS + # Skip slots that don't need to be advanced + advance_slots[value['database']][name] = value['lsn'] elif name not in self._replication_slots and 'lsn' in value: # We want to copy only slots with feedback in a DCS create_slots.append(name) @@ -480,32 +493,28 @@ class SlotsHandler: self._schedule_load_slots = True return create_slots + copy_slots - def sync_replication_slots(self, cluster: Cluster, nofailover: bool, - replicatefrom: Optional[str] = None, paused: bool = False) -> List[str]: + def sync_replication_slots(self, cluster: Cluster, tags: Tags) -> List[str]: """During the HA loop read, check and alter replication slots found in the cluster. - Read physical and logical slots found on the primary, then compare to those configured in the DCS. + Read physical and logical slots from ``pg_replication_slots``, then compare to those configured in the DCS. Drop any slots that do not match those required by configuration and are not configured as permanent. - Create any missing physical slots. If we are the leader then logical slots too, otherwise if logical slots - are known and active create them on replica nodes. + Create any missing physical slots, or advance their position according to feedback stored in DCS. + If we are the primary then create logical slots, otherwise if logical slots are known and active create + them on replica nodes by copying slot files from the primary. :param cluster: object containing stateful information for the cluster. - :param nofailover: ``True`` if this node has been tagged to not be a failover candidate. - :param replicatefrom: the tag containing the node to replicate from. - :param paused: ``True`` if the cluster is in maintenance mode. + :param tags: reference to an object implementing :class:`Tags` interface. :returns: list of logical replication slots names that should be copied from the primary. """ ret = [] - if self._postgresql.major_version >= 90400 and self._postgresql.global_config and cluster.config: + if self._postgresql.major_version >= 90400 and cluster.config: try: self.load_replication_slots() - slots = cluster.get_replication_slots( - self._postgresql.name, self._postgresql.role, nofailover, self._postgresql.major_version, - is_standby_cluster=self._postgresql.global_config.is_standby_cluster, show_error=True) + slots = cluster.get_replication_slots(self._postgresql, tags, show_error=True) - self._drop_incorrect_slots(cluster, slots, paused) + self._drop_incorrect_slots(cluster, slots) self._ensure_physical_slots(slots) @@ -513,7 +522,7 @@ class SlotsHandler: self._logical_slots_processing_queue.clear() self._ensure_logical_slots_primary(slots) else: - self.check_logical_slots_readiness(cluster, replicatefrom) + self.check_logical_slots_readiness(cluster, tags) ret = self._ensure_logical_slots_replica(slots) self._replication_slots = slots @@ -539,7 +548,7 @@ class SlotsHandler: with get_connection_cursor(connect_timeout=3, options="-c statement_timeout=2000", **conn_kwargs) as cur: yield cur - def check_logical_slots_readiness(self, cluster: Cluster, replicatefrom: Optional[str]) -> bool: + def check_logical_slots_readiness(self, cluster: Cluster, tags: Tags) -> bool: """Determine whether all known logical slots are synchronised from the leader. 1) Retrieve the current ``catalog_xmin`` value for the physical slot from the cluster leader, and @@ -548,13 +557,13 @@ class SlotsHandler: 3) store logical slot ``catalog_xmin`` when the physical slot ``catalog_xmin`` becomes valid. :param cluster: object containing stateful information for the cluster. - :param replicatefrom: name of the member that should be used to replicate from. + :param tags: reference to an object implementing :class:`Tags` interface. :returns: ``False`` if any issue while checking logical slots readiness, ``True`` otherwise. """ catalog_xmin = None if self._logical_slots_processing_queue and cluster.leader: - slot_name = cluster.get_my_slot_name_on_primary(self._postgresql.name, replicatefrom) + slot_name = cluster.get_slot_name_on_primary(self._postgresql.name, tags) try: with self._get_leader_connection_cursor(cluster.leader) as cur: cur.execute("SELECT slot_name, catalog_xmin FROM pg_catalog.pg_get_replication_slots()" @@ -632,16 +641,17 @@ class SlotsHandler: if standby_logical_slot: logger.info('Logical slot %s is safe to be used after a failover', name) - def copy_logical_slots(self, cluster: Cluster, create_slots: List[str]) -> None: + def copy_logical_slots(self, cluster: Cluster, tags: Tags, create_slots: List[str]) -> None: """Create logical replication slots on standby nodes. :param cluster: object containing stateful information for the cluster. + :param tags: reference to an object implementing :class:`Tags` interface. :param create_slots: list of slot names to copy from the primary. """ leader = cluster.leader if not leader: return - slots = cluster.get_replication_slots(self._postgresql.name, 'replica', False, self._postgresql.major_version) + slots = cluster.get_replication_slots(self._postgresql, tags, role='replica') copy_slots: Dict[str, Dict[str, Any]] = {} with self._get_leader_connection_cursor(leader) as cur: try: diff --git a/patroni/postgresql/sync.py b/patroni/postgresql/sync.py index 9cff04e0..577422b5 100644 --- a/patroni/postgresql/sync.py +++ b/patroni/postgresql/sync.py @@ -5,6 +5,7 @@ import time from copy import deepcopy from typing import Collection, List, NamedTuple, Tuple, TYPE_CHECKING +from .. import global_config from ..collections import CaseInsensitiveDict, CaseInsensitiveSet from ..dcs import Cluster from ..psycopg import quote_ident as _quote_ident @@ -303,11 +304,8 @@ END;$$""") replica_list = _ReplicaList(self._postgresql, cluster) self._process_replica_readiness(cluster, replica_list) - if TYPE_CHECKING: # pragma: no cover - assert self._postgresql.global_config is not None - sync_node_count = self._postgresql.global_config.synchronous_node_count\ - if self._postgresql.supports_multiple_sync else 1 - sync_node_maxlag = self._postgresql.global_config.maximum_lag_on_syncnode + sync_node_count = global_config.synchronous_node_count if self._postgresql.supports_multiple_sync else 1 + sync_node_maxlag = global_config.maximum_lag_on_syncnode candidates = CaseInsensitiveSet() sync_nodes = CaseInsensitiveSet() diff --git a/patroni/scripts/barman_recover.py b/patroni/scripts/barman_recover.py new file mode 100644 index 00000000..1cffe34d --- /dev/null +++ b/patroni/scripts/barman_recover.py @@ -0,0 +1,468 @@ +#!/usr/bin/env python + +"""Restore a Barman backup to the local node through ``pg-backup-api``. + +This script can be used both as a custom bootstrap method, and as a custom +create replica method. Check the output of ``--help`` to understand the +parameters supported by the script. ``--datadir`` is a special parameter and it +is automatically filled by Patroni in both cases. + +It requires that you have previously configured a Barman server, and that you +have ``pg-backup-api`` configured and running in the same host as Barman. + +Refer to :class:`ExitCode` for possible exit codes of this script. +""" +from argparse import ArgumentParser +from enum import IntEnum +import json +import logging +import sys +import time +from typing import Any, Callable, Optional, Tuple, Type, Union +from urllib.parse import urljoin +from urllib3 import PoolManager +from urllib3.exceptions import MaxRetryError +from urllib3.response import HTTPResponse + + +class ExitCode(IntEnum): + """Possible exit codes of this script. + + :cvar RECOVERY_DONE: backup was successfully restored. + :cvar RECOVERY_FAILED: recovery of the backup faced an issue. + :cvar API_NOT_OK: ``pg-backup-api`` status is not ``OK``. + :cvar HTTP_REQUEST_ERROR: an error has occurred during a request to the + ``pg-backup-api``. + :cvar HTTP_RESPONSE_MALFORMED: ``pg-backup-api`` returned a bogus response. + """ + + RECOVERY_DONE = 0 + RECOVERY_FAILED = 1 + API_NOT_OK = 2 + HTTP_REQUEST_ERROR = 3 + HTTP_RESPONSE_MALFORMED = 4 + + +class RetriesExceeded(Exception): + """Maximum number of retries exceeded.""" + + +def retry(exceptions: Union[Type[Exception], Tuple[Type[Exception], ...]]) \ + -> Any: + """Retry an operation n times if expected *exceptions* are faced. + + .. note:: + Should be used as a decorator of a class' method as it expects the + first argument to be a class instance. + + The class which method is going to be decorated should contain a couple + attributes: + + * ``max_retries``: maximum retry attempts before failing; + * ``retry_wait``: how long to wait before retrying. + + :param exceptions: exceptions that could trigger a retry attempt. + + :raises: + :exc:`RetriesExceeded`: if the maximum number of attempts has been + exhausted. + """ + def decorator(func: Callable[..., Any]) -> Any: + def inner_func(instance: object, *args: Any, **kwargs: Any) -> Any: + times: int = getattr(instance, "max_retries") + retry_wait: int = getattr(instance, "retry_wait") + method_name = f"{instance.__class__.__name__}.{func.__name__}" + + attempt = 1 + + while attempt <= times: + try: + return func(instance, *args, **kwargs) + except exceptions as exc: + logging.warning("Attempt %d of %d on method %s failed " + "with %r.", + attempt, times, method_name, exc) + attempt += 1 + + time.sleep(retry_wait) + + raise RetriesExceeded("Maximum number of retries exceeded for " + f"method {method_name}.") + return inner_func + return decorator + + +class BarmanRecover: + """Facilities for performing a remote ``barman recover`` operation. + + You should instantiate this class, which will take care of configuring the + operation accordingly. When you want to start the operation, you should + call :meth:`restore_backup`. At any point of interaction with this class, + you may face a :func:`sys.exit` call. Refer to :class:`ExitCode` for a view + on the possible exit codes. + + :ivar api_url: base URL to reach the ``pg-backup-api``. + :ivar cert_file: certificate to authenticate against the + ``pg-backup-api``, if required. + :ivar key_file: certificate key to authenticate against the + ``pg-backup-api``, if required. + :ivar barman_server: name of the Barman server which backup is to be + restored. + :ivar backup_id: ID of the backup from the Barman server. + :ivar ssh_command: SSH command to connect from the Barman host to the + local host. + :ivar data_directory: path to the Postgres data directory where to + restore the backup at. + :ivar loop_wait: how long to wait before checking again the status of the + recovery process. Higher values are useful for backups that are + expected to take long to restore. + :ivar retry_wait: how long to wait before retrying a failed request to the + ``pg-backup-api``. + :ivar max_retries: maximum number of retries when ``pg-backup-api`` returns + malformed responses. + :ivar http: a HTTP pool manager for performing web requests. + """ + + def __init__(self, api_url: str, barman_server: str, backup_id: str, + ssh_command: str, data_directory: str, loop_wait: int, + retry_wait: int, max_retries: int, + cert_file: Optional[str] = None, + key_file: Optional[str] = None) -> None: + """Create a new instance of :class:`BarmanRecover`. + + Make sure the ``pg-backup-api`` is reachable and running fine. + + :param api_url: base URL to reach the ``pg-backup-api``. + :param barman_server: name of the Barman server which backup is to be + restored. + :param backup_id: ID of the backup from the Barman server. + :param ssh_command: SSH command to connect from the Barman host to the + local host. + :param data_directory: path to the Postgres data directory where to + restore the backup at. + :param loop_wait: how long to wait before checking again the status of + the recovery process. Higher values are useful for backups that are + expected to take long to restore. + :param retry_wait: how long to wait before retrying a failed request to + the ``pg-backup-api``. + :param max_retries: maximum number of retries when ``pg-backup-api`` + returns malformed responses. + :param cert_file: certificate to authenticate against the + ``pg-backup-api``, if required. + :param key_file: certificate key to authenticate against the + ``pg-backup-api``, if required. + """ + self.api_url = api_url + self.cert_file = cert_file + self.key_file = key_file + self.barman_server = barman_server + self.backup_id = backup_id + self.ssh_command = ssh_command + self.data_directory = data_directory + self.loop_wait = loop_wait + self.retry_wait = retry_wait + self.max_retries = max_retries + self.http = PoolManager(cert_file=cert_file, key_file=key_file) + self._ensure_api_ok() + + def _build_full_url(self, url_path: str) -> str: + """Build the full URL by concatenating *url_path* with the base URL. + + :param url_path: path to be accessed in the ``pg-backup-api``. + + :returns: the full URL after concatenating. + """ + return urljoin(self.api_url, url_path) + + @staticmethod + def _deserialize_response(response: HTTPResponse) -> Any: + """Retrieve body from *response* as a deserialized JSON object. + + :param response: response from which JSON body will be deserialized. + + :returns: the deserialized JSON body. + """ + return json.loads(response.data.decode("utf-8")) + + @staticmethod + def _serialize_request(body: Any) -> Any: + """Serialize a request body. + + :param body: content of the request body to be serialized. + + :returns: the serialized request body. + """ + return json.dumps(body).encode("utf-8") + + def _get_request(self, url_path: str) -> Any: + """Perform a ``GET`` request to *url_path*. + + .. note:: + If a :exc:`MaxRetryError` is faced while performing the request, + then exit with :attr:`ExitCode.HTTP_REQUEST_ERROR` + + :param url_path: URL to perform the ``GET`` request against. + + :returns: the deserialized response body. + """ + response = None + + try: + response = self.http.request("GET", self._build_full_url(url_path)) + except MaxRetryError as exc: + logging.critical("An error occurred while performing an HTTP GET " + "request: %r", exc) + sys.exit(ExitCode.HTTP_REQUEST_ERROR) + + return self._deserialize_response(response) + + def _post_request(self, url_path: str, body: Any) -> Any: + """Perform a ``POST`` request to *url_path* serializing *body* as JSON. + + .. note:: + If a :exc:`MaxRetryError` is faced while performing the request, + then exit with :attr:`ExitCode.HTTP_REQUEST_ERROR` + + :param url_path: URL to perform the ``POST`` request against. + :param body: the body to be serialized as JSON and sent in the request. + + :returns: the deserialized response body. + """ + body = self._serialize_request(body) + + response = None + + try: + response = self.http.request("POST", + self._build_full_url(url_path), + body=body, + headers={ + "Content-Type": "application/json" + }) + except MaxRetryError as exc: + logging.critical("An error occurred while performing an HTTP POST " + "request: %r", exc) + sys.exit(ExitCode.HTTP_REQUEST_ERROR) + + return self._deserialize_response(response) + + def _ensure_api_ok(self) -> None: + """Ensure ``pg-backup-api`` is reachable and ``OK``. + + .. note:: + If ``pg-backup-api`` status is not ``OK``, then exit with + :attr:`ExitCode.API_NOT_OK`. + """ + response = self._get_request("status") + + if response != "OK": + logging.critical("pg-backup-api is not working: %s", response) + sys.exit(ExitCode.API_NOT_OK) + + @retry(KeyError) + def _create_recovery_operation(self) -> str: + """Create a recovery operation on the ``pg-backup-api``. + + :returns: the ID of the recovery operation that has been created. + """ + response = self._post_request( + f"servers/{self.barman_server}/operations", + { + "type": "recovery", + "backup_id": self.backup_id, + "remote_ssh_command": self.ssh_command, + "destination_directory": self.data_directory, + }, + ) + + return response["operation_id"] + + @retry(KeyError) + def _get_recovery_operation_status(self, operation_id: str) -> str: + """Get status of the recovery operation *operation_id*. + + :param operation_id: ID of the recovery operation to be checked. + + :returns: the status of the recovery operation. + """ + response = self._get_request( + f"servers/{self.barman_server}/operations/{operation_id}", + ) + + return response["status"] + + def restore_backup(self) -> bool: + """Restore the configured Barman backup through ``pg-backup-api``. + + .. note:: + If recovery API request returns a malformed response, then exit with + :attr:`ExitCode.HTTP_RESPONSE_MALFORMED`. + + :returns: ``True`` if it was successfully recovered, ``False`` + otherwise. + """ + operation_id = None + + try: + operation_id = self._create_recovery_operation() + except RetriesExceeded: + logging.critical("Maximum number of retries exceeded, exiting.") + sys.exit(ExitCode.HTTP_RESPONSE_MALFORMED) + + logging.info("Created the recovery operation with ID %s", operation_id) + + status = None + + while True: + try: + status = self._get_recovery_operation_status(operation_id) + except RetriesExceeded: + logging.critical("Maximum number of retries exceeded, " + "exiting.") + sys.exit(ExitCode.HTTP_RESPONSE_MALFORMED) + + if status != "IN_PROGRESS": + break + + logging.info("Recovery operation %s is still in progress", + operation_id) + time.sleep(self.loop_wait) + + return status == "DONE" + + +def set_up_logging(log_file: Optional[str] = None) -> None: + """Set up logging to file, if *log_file* is given, otherwise to console. + + :param log_file: file where to log messages, if any. + """ + logging.basicConfig(filename=log_file, level=logging.INFO, + format="%(asctime)s %(levelname)s: %(message)s") + + +def main() -> None: + """Entry point of this script. + + Parse the command-line arguments and recover a Barman backup through + ``pg-backup-api`` to the local host. + """ + parser = ArgumentParser( + epilog=( + "Wrapper script for ``pg-backup-api``. Communicate with the API " + "running at ``--api-url`` to restore a ``--backup-id`` Barman " + "backup of the server ``--barman-server``." + ), + ) + parser.add_argument( + "--api-url", + type=str, + required=True, + help="URL to reach the ``pg-backup-api``, e.g. " + "``http://localhost:7480``", + dest="api_url", + ) + parser.add_argument( + "--cert-file", + type=str, + required=False, + help="Certificate to authenticate against the API, if required.", + dest="cert_file", + ) + parser.add_argument( + "--key-file", + type=str, + required=False, + help="Certificate key to authenticate against the API, if required.", + dest="key_file", + ) + parser.add_argument( + "--barman-server", + type=str, + required=True, + help="Name of the Barman server from which to restore the backup.", + dest="barman_server", + ) + parser.add_argument( + "--backup-id", + type=str, + required=False, + default="latest", + help="ID of the Barman backup to be restored. You can use any value " + "supported by ``barman recover`` command " + "(default: ``%(default)s``)", + dest="backup_id", + ) + parser.add_argument( + "--ssh-command", + type=str, + required=True, + help="Value to be passed as ``--remote-ssh-command`` to " + "``barman recover``.", + dest="ssh_command", + ) + parser.add_argument( + "--data-directory", + "--datadir", + type=str, + required=True, + help="Destination path where to restore the barman backup in the " + "local host.", + dest="data_directory", + ) + parser.add_argument( + "--log-file", + type=str, + required=False, + help="File where to log messages produced by this script, if any.", + dest="log_file", + ) + parser.add_argument( + "--loop-wait", + type=int, + required=False, + default=10, + help="How long to wait before checking again the status of the " + "recovery process, in seconds. Use higher values if your " + "recovery is expected to take long (default: ``%(default)s``)", + dest="loop_wait", + ) + parser.add_argument( + "--retry-wait", + type=int, + required=False, + default=2, + help="How long to wait before retrying a failed ``pg-backup-api`` " + "request (default: ``%(default)s``)", + dest="retry_wait", + ) + parser.add_argument( + "--max-retries", + type=int, + required=False, + default=5, + help="Maximum number of retries when receiving malformed responses " + "from the ``pg-backup-api`` (default: ``%(default)s``)", + dest="max_retries", + ) + args, _ = parser.parse_known_args() + + set_up_logging(args.log_file) + + barman_recover = BarmanRecover(args.api_url, args.barman_server, + args.backup_id, args.ssh_command, + args.data_directory, args.loop_wait, + args.retry_wait, args.max_retries, + args.cert_file, args.key_file) + + successful = barman_recover.restore_backup() + + if successful: + logging.info("Recovery operation finished successfully.") + sys.exit(ExitCode.RECOVERY_DONE) + else: + logging.critical("Recovery operation failed.") + sys.exit(ExitCode.RECOVERY_FAILED) + + +if __name__ == "__main__": + main() diff --git a/patroni/tags.py b/patroni/tags.py index 6b3a1984..998ff693 100644 --- a/patroni/tags.py +++ b/patroni/tags.py @@ -3,6 +3,8 @@ import abc from typing import Any, Dict, Optional +from patroni.utils import parse_int + class Tags(abc.ABC): """An abstract class that encapsulates all the ``tags`` logic. @@ -45,8 +47,29 @@ class Tags(abc.ABC): @property def nofailover(self) -> bool: - """``True`` if ``nofailover`` is ``True``, else ``False``.""" - return bool(self.tags.get('nofailover', False)) + """Common logic for obtaining the value of ``nofailover`` from ``tags`` if defined. + + If ``nofailover`` is not defined, this methods returns ``True`` if ``failover_priority`` is non-positive, + ``False`` otherwise. + """ + from_tags = self.tags.get('nofailover') + if from_tags is not None: + # Value of `nofailover` takes precedence over `failover_priority` + return bool(from_tags) + failover_priority = parse_int(self.tags.get('failover_priority')) + return failover_priority is not None and failover_priority <= 0 + + @property + def failover_priority(self) -> int: + """Common logic for obtaining the value of ``failover_priority`` from ``tags`` if defined. + + If ``nofailover`` is defined as ``True``, this will return ``0``. Otherwise, it will return the value of + ``failover_priority``, defaulting to ``1`` if it's not defined or invalid. + """ + from_tags = self.tags.get('nofailover') + failover_priority = parse_int(self.tags.get('failover_priority')) + failover_priority = 1 if failover_priority is None else failover_priority + return 0 if from_tags else failover_priority @property def noloadbalance(self) -> bool: diff --git a/patroni/utils.py b/patroni/utils.py index be468d2e..23f419e5 100644 --- a/patroni/utils.py +++ b/patroni/utils.py @@ -33,7 +33,6 @@ from .version import __version__ if TYPE_CHECKING: # pragma: no cover from .dcs import Cluster - from .config import GlobalConfig tzutc = tz.tzutc() @@ -759,12 +758,10 @@ def iter_response_objects(response: HTTPResponse) -> Iterator[Dict[str, Any]]: prev = chunk[idx:] -def cluster_as_json(cluster: 'Cluster', global_config: Optional['GlobalConfig'] = None) -> Dict[str, Any]: +def cluster_as_json(cluster: 'Cluster') -> Dict[str, Any]: """Get a JSON representation of *cluster*. :param cluster: the :class:`~patroni.dcs.Cluster` object to be parsed as JSON. - :param global_config: optional :class:`~patroni.config.GlobalConfig` object to check the cluster state. - if not provided will be instantiated from the `Cluster.config`. :returns: JSON representation of *cluster*. @@ -793,16 +790,16 @@ def cluster_as_json(cluster: 'Cluster', global_config: Optional['GlobalConfig'] * ``from``: name of the member to be demoted; * ``to``: name of the member to be promoted. """ - if not global_config: - from patroni.config import get_global_config - global_config = get_global_config(cluster) + from . import global_config + + config = global_config.from_cluster(cluster) leader_name = cluster.leader.name if cluster.leader else None cluster_lsn = cluster.last_lsn or 0 ret: Dict[str, Any] = {'members': []} for m in cluster.members: if m.name == leader_name: - role = 'standby_leader' if global_config.is_standby_cluster else 'leader' + role = 'standby_leader' if config.is_standby_cluster else 'leader' elif cluster.sync.matches(m.name): role = 'sync_standby' else: @@ -819,7 +816,7 @@ def cluster_as_json(cluster: 'Cluster', global_config: Optional['GlobalConfig'] member.update({n: m.data[n] for n in optional_attributes if n in m.data}) if m.name != leader_name: - lsn = m.data.get('xlog_location') + lsn = m.lsn if lsn is None: member['lag'] = 'unknown' elif cluster_lsn >= lsn: @@ -832,7 +829,7 @@ def cluster_as_json(cluster: 'Cluster', global_config: Optional['GlobalConfig'] # sort members by name for consistency cmp: Callable[[Dict[str, Any]], bool] = lambda m: m['name'] ret['members'].sort(key=cmp) - if global_config.is_paused: + if config.is_paused: ret['pause'] = True if cluster.failover and cluster.failover.scheduled_at: ret['scheduled_switchover'] = {'at': cluster.failover.scheduled_at.isoformat()} diff --git a/patroni/validator.py b/patroni/validator.py index bc308f71..d0b168be 100644 --- a/patroni/validator.py +++ b/patroni/validator.py @@ -9,7 +9,7 @@ import os import shutil import socket -from typing import Any, Dict, Union, Iterator, List, Optional as OptionalType, Tuple +from typing import Any, Dict, Union, Iterator, List, Optional as OptionalType, Tuple, TYPE_CHECKING from .collections import CaseInsensitiveSet @@ -200,6 +200,8 @@ def get_bin_name(bin_name: str) -> str: :returns: value of ``postgresql.bin_name[*bin_name*]``, if present, otherwise *bin_name*. """ + if TYPE_CHECKING: # pragma: no cover + assert isinstance(schema.data, dict) return (schema.data.get('postgresql', {}).get('bin_name', {}) or {}).get(bin_name, bin_name) @@ -239,6 +241,8 @@ def validate_data_dir(data_dir: str) -> bool: if not os.path.isdir(os.path.join(data_dir, waldir)): raise ConfigParseError("data dir for the cluster is not empty, but doesn't contain" " \"{}\" directory".format(waldir)) + if TYPE_CHECKING: # pragma: no cover + assert isinstance(schema.data, dict) bin_dir = schema.data.get("postgresql", {}).get("bin_dir", None) major_version = get_major_version(bin_dir, get_bin_name('postgres')) if pgversion != major_version: @@ -274,6 +278,8 @@ def validate_binary_name(bin_name: str) -> bool: """ if not bin_name: raise ConfigParseError("is an empty string") + if TYPE_CHECKING: # pragma: no cover + assert isinstance(schema.data, dict) bin_dir = schema.data.get('postgresql', {}).get('bin_dir', None) if not shutil.which(bin_name, path=bin_dir): raise ConfigParseError(f"does not contain '{bin_name}' in '{bin_dir or '$PATH'}'") @@ -379,6 +385,37 @@ class Or(object): self.args = args +class AtMostOne(object): + """Mark that at most one option from a :class:`Case` can be suplied. + + Represents a list of possible configuration options in a given scope, where at most one can actually + be provided. + + .. note:: + + It should be used together with a :class:`Case` object. + """ + + def __init__(self, *args: str) -> None: + """Create a :class`AtMostOne` object. + + :param `*args`: any arguments that the caller wants to be stored in this :class:`Or` object. + + :Example: + + .. code-block:: python + + AtMostOne("nofailover", "failover_priority"): Case({ + "nofailover": bool, + "failover_priority": IntValidator(min=0, raise_assert=True), + }) + + The :class`AtMostOne` object is used to define that at most one of ``nofailover`` and + ``failover_priority`` can be provided. + """ + self.args = args + + class Optional(object): """Mark a configuration option as optional. @@ -492,7 +529,7 @@ class Schema(object): * :class:`dict`: dictionary representing the YAML configuration tree. """ - def __init__(self, validator: Any) -> None: + def __init__(self, validator: Union[Dict[Any, Any], List[Any], Any]) -> None: """Create a :class:`Schema` object. .. note:: @@ -583,7 +620,7 @@ class Schema(object): errors.append(str(i)) return errors - def validate(self, data: Any) -> Iterator[Result]: + def validate(self, data: Union[Dict[Any, Any], Any]) -> Iterator[Result]: """Perform all validations from the schema against the given configuration. It first checks that *data* argument type is compliant with the type of ``validator`` attribute. @@ -607,11 +644,8 @@ class Schema(object): # iterable objects in the structure, until we eventually reach a leaf node to validate its value. if isinstance(self.validator, str): yield Result(isinstance(self.data, str), "is not a string", level=1, data=self.data) - elif issubclass(type(self.validator), type): - validator = self.validator - if self.validator == str: - validator = str - yield Result(isinstance(self.data, validator), + elif isinstance(self.validator, type): + yield Result(isinstance(self.data, self.validator), "is not {}".format(_get_type_name(self.validator)), level=1, data=self.data) elif callable(self.validator): if hasattr(self.validator, "expected_type"): @@ -658,7 +692,7 @@ class Schema(object): for v in Schema(self.validator[0]).validate(value): yield Result(v.status, v.error, path=(str(key) + ("." + v.path if v.path else "")), level=v.level, data=value) - elif isinstance(self.validator, Directory): + elif isinstance(self.validator, Directory) and isinstance(self.data, str): yield from self.validator.validate(self.data) elif isinstance(self.validator, Or): yield from self.iter_or() @@ -670,7 +704,13 @@ class Schema(object): """ # One key in `validator` attribute (`key` variable) can be mapped to one or more keys in `data` attribute (`d` # variable), depending on the `key` type. + if TYPE_CHECKING: # pragma: no cover + assert isinstance(self.validator, dict) + assert isinstance(self.data, dict) for key in self.validator.keys(): + if isinstance(key, AtMostOne) and len(list(self._data_key(key))) > 1: + yield Result(False, f"Multiple of {key.args} provided") + continue for d in self._data_key(key): if d not in self.data and not isinstance(key, Optional): yield Result(False, "is not defined.", path=d) @@ -680,7 +720,7 @@ class Schema(object): if d not in self.data and isinstance(key, Optional): self.data[d] = key.default validator = self.validator[key] - if isinstance(key, Or) and isinstance(self.validator[key], Case): + if isinstance(key, (Or, AtMostOne)) and isinstance(self.validator[key], Case): validator = self.validator[key]._schema[d] # In this loop we may be calling a new `Schema` either over an intermediate node in the tree, or # over a leaf node. In the latter case the recursive calls in the given path will finish. @@ -696,6 +736,8 @@ class Schema(object): :yields: objects with the error message related to the failure, if any check fails. """ + if TYPE_CHECKING: # pragma: no cover + assert isinstance(self.validator, Or) results: List[Result] = [] for a in self.validator.args: r: List[Result] = [] @@ -715,7 +757,7 @@ class Schema(object): max_level = v.level yield Result(v.status, v.error, path=v.path, level=v.level, data=v.data) - def _data_key(self, key: Union[str, Optional, Or]) -> Iterator[str]: + def _data_key(self, key: Union[str, Optional, Or, AtMostOne]) -> Iterator[str]: """Map a key from the ``validator`` dictionary to the corresponding key(s) in the ``data`` dictionary. :param key: key from the ``validator`` attribute. @@ -732,18 +774,26 @@ class Schema(object): yield key.name # If the key was defined as an `Or` object in `validator` attribute, then each of its values are the keys to # access the `data` dictionary. - elif isinstance(key, Or): + elif isinstance(key, Or) and isinstance(self.data, dict): # At least one of the `Or` entries should be available in the `data` dictionary. If we find at least one of # them in `data`, then we return all found entries so the caller method can validate them all. - if any([i in self.data for i in key.args]): - for i in key.args: - if i in self.data: - yield i + if any([item in self.data for item in key.args]): + for item in key.args: + if item in self.data: + yield item # If none of the `Or` entries is available in the `data` dictionary, then we return all entries so the # caller method will issue errors that they are all absent. else: - for i in key.args: - yield i + for item in key.args: + yield item + # If the key was defined as a `AtMostOne` object in `validator` attribute, then each of its values + # are the keys to access the `data` dictionary. + elif isinstance(key, AtMostOne) and isinstance(self.data, dict): + # Yield back all of the entries from the `data` dictionary, each will be validated and then counted + # to inform us if we've provided too many + for item in key.args: + if item in self.data: + yield item def _get_type_name(python_type: Any) -> str: @@ -772,27 +822,28 @@ def assert_(condition: bool, message: str = "Wrong value") -> None: class IntValidator(object): """Validate an integer setting. - :cvar expected_type: the expected Python type for an integer setting (:class:`int`). :ivar min: minimum allowed value for the setting, if any. :ivar max: maximum allowed value for the setting, if any. :ivar base_unit: the base unit to convert the value to before checking if it's within *min* and *max* range. + :ivar expected_type: the expected Python type. :ivar raise_assert: if an ``assert`` test should be performed regarding expected type and valid range. """ - expected_type = int - def __init__(self, min: OptionalType[int] = None, max: OptionalType[int] = None, - base_unit: OptionalType[str] = None, raise_assert: bool = False) -> None: + base_unit: OptionalType[str] = None, expected_type: Any = None, raise_assert: bool = False) -> None: """Create an :class:`IntValidator` object with the given rules. :param min: minimum allowed value for the setting, if any. :param max: maximum allowed value for the setting, if any. :param base_unit: the base unit to convert the value to before checking if it's within *min* and *max* range. + :param expected_type: the expected Python type. :param raise_assert: if an ``assert`` test should be performed regarding expected type and valid range. """ self.min = min self.max = max self.base_unit = base_unit + if expected_type: + self.expected_type = expected_type self.raise_assert = raise_assert def __call__(self, value: Any) -> bool: @@ -911,36 +962,36 @@ schema = Schema({ Optional("allowlist_include_members"): bool, Optional("http_extra_headers"): dict, Optional("https_extra_headers"): dict, - Optional("request_queue_size"): IntValidator(min=0, max=4096, raise_assert=True) + Optional("request_queue_size"): IntValidator(min=0, max=4096, expected_type=int, raise_assert=True) }, Optional("bootstrap"): { "dcs": { - Optional("ttl"): int, - Optional("loop_wait"): int, - Optional("retry_timeout"): int, - Optional("maximum_lag_on_failover"): int, - Optional("maximum_lag_on_syncnode"): int, + Optional("ttl"): IntValidator(min=20, raise_assert=True), + Optional("loop_wait"): IntValidator(min=1, raise_assert=True), + Optional("retry_timeout"): IntValidator(min=3, raise_assert=True), + Optional("maximum_lag_on_failover"): IntValidator(min=0, raise_assert=True), + Optional("maximum_lag_on_syncnode"): IntValidator(min=-1, raise_assert=True), Optional("postgresql"): { Optional("parameters"): { - Optional("max_connections"): int, - Optional("max_locks_per_transaction"): int, - Optional("max_prepared_transactions"): int, - Optional("max_replication_slots"): int, - Optional("max_wal_senders"): int, - Optional("max_worker_processes"): int + Optional("max_connections"): IntValidator(1, 262143, raise_assert=True), + Optional("max_locks_per_transaction"): IntValidator(10, 2147483647, raise_assert=True), + Optional("max_prepared_transactions"): IntValidator(0, 262143, raise_assert=True), + Optional("max_replication_slots"): IntValidator(0, 262143, raise_assert=True), + Optional("max_wal_senders"): IntValidator(0, 262143, raise_assert=True), + Optional("max_worker_processes"): IntValidator(0, 262143, raise_assert=True), }, Optional("use_pg_rewind"): bool, Optional("pg_hba"): [str], Optional("pg_ident"): [str], - Optional("pg_ctl_timeout"): int, + Optional("pg_ctl_timeout"): IntValidator(min=0, raise_assert=True), Optional("use_slots"): bool, }, - Optional("primary_start_timeout"): int, - Optional("primary_stop_timeout"): int, + Optional("primary_start_timeout"): IntValidator(min=0, raise_assert=True), + Optional("primary_stop_timeout"): IntValidator(min=0, raise_assert=True), Optional("standby_cluster"): { Or("host", "port", "restore_command"): Case({ "host": str, - "port": int, + "port": IntValidator(max=65535, expected_type=int, raise_assert=True), "restore_command": str }), Optional("primary_slot_name"): str, @@ -950,7 +1001,7 @@ schema = Schema({ }, Optional("synchronous_mode"): bool, Optional("synchronous_mode_strict"): bool, - Optional("synchronous_node_count"): int + Optional("synchronous_node_count"): IntValidator(min=1, raise_assert=True), }, Optional("initdb"): [Or(str, dict)], Optional("method"): str @@ -961,7 +1012,7 @@ schema = Schema({ "host": validate_host_port, "url": str }), - Optional("port"): int, + Optional("port"): IntValidator(max=65535, expected_type=int, raise_assert=True), Optional("scheme"): str, Optional("token"): str, Optional("verify"): bool, @@ -981,8 +1032,8 @@ schema = Schema({ "etcd3": validate_etcd, "exhibitor": { "hosts": [str], - "port": IntValidator(max=65535, raise_assert=True), - Optional("pool_interval"): int + "port": IntValidator(max=65535, expected_type=int, raise_assert=True), + Optional("poll_interval"): IntValidator(min=1, expected_type=int, raise_assert=True), }, "raft": { "self_addr": validate_connect_address, @@ -999,7 +1050,8 @@ schema = Schema({ Optional("key"): str, Optional("key_password"): str, Optional("verify"): bool, - Optional("set_acls"): dict + Optional("set_acls"): dict, + Optional("auth_data"): dict, }, "kubernetes": { "labels": {}, @@ -1013,14 +1065,14 @@ schema = Schema({ Optional("tmp_role_label"): str, Optional("use_endpoints"): bool, Optional("pod_ip"): Or(is_ipv4_address, is_ipv6_address), - Optional("ports"): [{"name": str, "port": int}], + Optional("ports"): [{"name": str, "port": IntValidator(max=65535, expected_type=int, raise_assert=True)}], Optional("cacert"): str, Optional("retriable_http_codes"): Or(int, [int]), }, }), Optional("citus"): { "database": str, - "group": int + "group": IntValidator(min=0, expected_type=int, raise_assert=True), }, "postgresql": { "listen": validate_host_port_listen_multiple_hosts, @@ -1047,16 +1099,19 @@ schema = Schema({ }, Optional("pg_hba"): [str], Optional("pg_ident"): [str], - Optional("pg_ctl_timeout"): int, + Optional("pg_ctl_timeout"): IntValidator(min=0, raise_assert=True), Optional("use_pg_rewind"): bool }, Optional("watchdog"): { Optional("mode"): validate_watchdog_mode, Optional("device"): str, - Optional("safety_margin"): int + Optional("safety_margin"): IntValidator(min=-1, expected_type=int, raise_assert=True), }, Optional("tags"): { - Optional("nofailover"): bool, + AtMostOne("nofailover", "failover_priority"): Case({ + "nofailover": bool, + "failover_priority": IntValidator(min=0, expected_type=int, raise_assert=True), + }), Optional("clonefrom"): bool, Optional("noloadbalance"): bool, Optional("replicatefrom"): str, diff --git a/patroni/version.py b/patroni/version.py index 87eff52e..96592c7d 100644 --- a/patroni/version.py +++ b/patroni/version.py @@ -2,4 +2,4 @@ :var __version__: the current Patroni version. """ -__version__ = '3.1.0' +__version__ = '3.2.1' diff --git a/postgres0.yml b/postgres0.yml index 0605c322..8a975156 100644 --- a/postgres0.yml +++ b/postgres0.yml @@ -43,9 +43,13 @@ etcd: # - 127.0.0.1:2223 # - 127.0.0.1:2224 +# The bootstrap configuration. Works only when the cluster is not yet initialized. +# If the cluster is already initialized, all changes in the `bootstrap` section are ignored! bootstrap: - # this section will be written into Etcd:///config after initializing new cluster - # and all other cluster members will use it as a `global configuration` + # This section will be written into Etcd:///config after initializing new cluster + # and all other cluster members will use it as a `global configuration`. + # WARNING! If you want to change any of the parameters that were set up + # via `bootstrap.dcs` section, please use `patronictl edit-config`! dcs: ttl: 30 loop_wait: 10 @@ -93,14 +97,6 @@ bootstrap: # Additional script to be launched after initial cluster creation (will be passed the connection URL as parameter) # post_init: /usr/local/bin/setup_cluster.sh - # Some additional users which needs to be created after initializing new cluster - users: - admin: - password: admin% - options: - - createrole - - createdb - postgresql: listen: 127.0.0.1:5432 connect_address: 127.0.0.1:5432 diff --git a/postgres1.yml b/postgres1.yml index 89dcca1a..6ca2aa64 100644 --- a/postgres1.yml +++ b/postgres1.yml @@ -43,9 +43,13 @@ etcd: # - 127.0.0.1:2222 # - 127.0.0.1:2224 +# The bootstrap configuration. Works only when the cluster is not yet initialized. +# If the cluster is already initialized, all changes in the `bootstrap` section are ignored! bootstrap: - # this section will be written into Etcd:///config after initializing new cluster - # and all other cluster members will use it as a `global configuration` + # This section will be written into Etcd:///config after initializing new cluster + # and all other cluster members will use it as a `global configuration`. + # WARNING! If you want to change any of the parameters that were set up + # via `bootstrap.dcs` section, please use `patronictl edit-config`! dcs: ttl: 30 loop_wait: 10 @@ -87,14 +91,6 @@ bootstrap: # Additional script to be launched after initial cluster creation (will be passed the connection URL as parameter) # post_init: /usr/local/bin/setup_cluster.sh - # Some additional users which needs to be created after initializing new cluster - users: - admin: - password: admin% - options: - - createrole - - createdb - postgresql: listen: 127.0.0.1:5433 connect_address: 127.0.0.1:5433 diff --git a/postgres2.yml b/postgres2.yml index 581fa719..ee61a023 100644 --- a/postgres2.yml +++ b/postgres2.yml @@ -43,9 +43,13 @@ etcd: # - 127.0.0.1:2222 # - 127.0.0.1:2223 +# The bootstrap configuration. Works only when the cluster is not yet initialized. +# If the cluster is already initialized, all changes in the `bootstrap` section are ignored! bootstrap: - # this section will be written into Etcd:///config after initializing new cluster - # and all other cluster members will use it as a `global configuration` + # This section will be written into Etcd:///config after initializing new cluster + # and all other cluster members will use it as a `global configuration`. + # WARNING! If you want to change any of the parameters that were set up + # via `bootstrap.dcs` section, please use `patronictl edit-config`! dcs: ttl: 30 loop_wait: 10 @@ -84,14 +88,6 @@ bootstrap: - encoding: UTF8 - data-checksums - # Some additional users which needs to be created after initializing new cluster - users: - admin: - password: admin% - options: - - createrole - - createdb - postgresql: listen: 127.0.0.1:5434 connect_address: 127.0.0.1:5434 diff --git a/setup.py b/setup.py index d61eab6e..5bd9c71f 100644 --- a/setup.py +++ b/setup.py @@ -26,7 +26,6 @@ KEYWORDS = 'etcd governor patroni postgresql postgres ha haproxy confd' +\ EXTRAS_REQUIRE = {'aws': ['boto3'], 'etcd': ['python-etcd'], 'etcd3': ['python-etcd'], 'consul': ['python-consul'], 'exhibitor': ['kazoo'], 'zookeeper': ['kazoo'], 'kubernetes': [], 'raft': ['pysyncobj', 'cryptography']} -COVERAGE_XML = True # Add here all kinds of additional classifiers as defined under # https://pypi.python.org/pypi?%3Aaction=list_classifiers @@ -55,7 +54,8 @@ CONSOLE_SCRIPTS = ['patroni = patroni.__main__:main', 'patronictl = patroni.ctl:ctl', 'patroni_raft_controller = patroni.raft_controller:main', "patroni_wale_restore = patroni.scripts.wale_restore:main", - "patroni_aws = patroni.scripts.aws:main"] + "patroni_aws = patroni.scripts.aws:main", + "patroni_barman_recover = patroni.scripts.barman_recover:main"] class _Command(Command): @@ -120,14 +120,21 @@ def read(fname): return fd.read() -def setup_package(version): +def get_versions(): + old_modules = sys.modules.copy() + try: + from patroni import MIN_PSYCOPG2, MIN_PSYCOPG3 + from patroni.version import __version__ + return __version__, MIN_PSYCOPG2, MIN_PSYCOPG3 + finally: + sys.modules.clear() + sys.modules.update(old_modules) + + +def main(): logging.basicConfig(format='%(message)s', level=os.getenv('LOGLEVEL', logging.WARNING)) - # Assemble additional setup commands - cmdclass = {'test': PyTest, 'flake8': Flake8} - install_requires = [] - for r in read('requirements.txt').split('\n'): r = r.strip() if r == '': @@ -139,15 +146,22 @@ def setup_package(version): deps[i] = r EXTRAS_REQUIRE[e] = deps extra = True - break - if extra: - break if not extra: install_requires.append(r) + # Just for convenience, if someone wants to install dependencies for all extras + EXTRAS_REQUIRE['all'] = list({e for extras in EXTRAS_REQUIRE.values() for e in extras}) + + patroni_version, min_psycopg2, min_psycopg3 = get_versions() + + # Make it possible to specify psycopg dependency as extra + for name, version in {'psycopg[binary]': min_psycopg3, 'psycopg2': min_psycopg2, 'psycopg2-binary': None}.items(): + EXTRAS_REQUIRE[name] = [name + ('>=' + '.'.join(map(str, version)) if version else '')] + EXTRAS_REQUIRE['psycopg3'] = EXTRAS_REQUIRE.pop('psycopg[binary]') + setup( name=NAME, - version=version, + version=patroni_version, url=URL, author=AUTHOR, author_email=AUTHOR_EMAIL, @@ -163,20 +177,10 @@ def setup_package(version): ]}, install_requires=install_requires, extras_require=EXTRAS_REQUIRE, - cmdclass=cmdclass, + cmdclass={'test': PyTest, 'flake8': Flake8}, entry_points={'console_scripts': CONSOLE_SCRIPTS}, ) if __name__ == '__main__': - old_modules = sys.modules.copy() - try: - from patroni import check_psycopg - from patroni.version import __version__ - finally: - sys.modules.clear() - sys.modules.update(old_modules) - - check_psycopg() - - setup_package(__version__) + main() diff --git a/tests/__init__.py b/tests/__init__.py index f70aafb1..aca90bee 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -104,18 +104,20 @@ class MockCursor(object): elif sql.startswith('SELECT slot_name, slot_type, datname, plugin, catalog_xmin'): self.results = [('ls', 'logical', 'a', 'b', 100, 500, b'123456')] elif sql.startswith('SELECT slot_name'): - self.results = [('blabla', 'physical'), ('foobar', 'physical'), ('ls', 'logical', 'b', 'a', 5, 100, 500)] + self.results = [('blabla', 'physical', 12345), + ('foobar', 'physical', 12345), + ('ls', 'logical', 499, 'b', 'a', 5, 100, 500)] elif sql.startswith('WITH slots AS (SELECT slot_name, active'): self.results = [(False, True)] if self.rowcount == 1 else [] elif sql.startswith('SELECT CASE WHEN pg_catalog.pg_is_in_recovery()'): self.results = [(1, 2, 1, 0, False, 1, 1, None, None, 'streaming', '', - [{"slot_name": "ls", "confirmed_flush_lsn": 12345}], + [{"slot_name": "ls", "confirmed_flush_lsn": 12345, "restart_lsn": 12344}], 'on', 'n1', None)] elif sql.startswith('SELECT pg_catalog.pg_is_in_recovery()'): self.results = [(False, 2)] elif sql.startswith('SELECT pg_catalog.pg_postmaster_start_time'): self.results = [(datetime.datetime.now(tzutc),)] - elif sql.startswith('SELECT name, current_setting(name) FROM pg_settings'): + elif sql.startswith('SELECT name, pg_catalog.current_setting(name) FROM pg_catalog.pg_settings'): self.results = [('data_directory', 'data'), ('hba_file', os.path.join('data', 'pg_hba.conf')), ('ident_file', os.path.join('data', 'pg_ident.conf')), @@ -135,6 +137,11 @@ class MockCursor(object): ('wal_block_size', '8192', None, 'integer', 'internal'), ('shared_buffers', '16384', '8kB', 'integer', 'postmaster'), ('wal_buffers', '-1', '8kB', 'integer', 'postmaster'), + ('max_connections', '100', None, 'integer', 'postmaster'), + ('max_prepared_transactions', '0', None, 'integer', 'postmaster'), + ('max_worker_processes', '8', None, 'integer', 'postmaster'), + ('max_locks_per_transaction', '64', None, 'integer', 'postmaster'), + ('max_wal_senders', '5', None, 'integer', 'postmaster'), ('search_path', 'public', None, 'string', 'user'), ('port', '5433', None, 'integer', 'postmaster'), ('listen_addresses', '*', None, 'string', 'postmaster'), @@ -238,7 +245,7 @@ class PostgresInit(unittest.TestCase): 'replication': {'username': '', 'password': 'rep-pass'}, 'rewind': {'username': 'rewind', 'password': 'test'}}, 'remove_data_directory_on_rewind_failure': True, - 'use_pg_rewind': True, 'pg_ctl_timeout': 'bla', + 'use_pg_rewind': True, 'pg_ctl_timeout': 'bla', 'use_unix_socket': True, 'parameters': self._PARAMETERS, 'recovery_conf': {'foo': 'bar'}, 'pg_hba': ['host all all 0.0.0.0/0 md5'], @@ -250,14 +257,15 @@ class PostgresInit(unittest.TestCase): class BaseTestPostgresql(PostgresInit): + @patch('time.sleep', Mock()) def setUp(self): super(BaseTestPostgresql, self).setUp() if not os.path.exists(self.p.data_dir): os.makedirs(self.p.data_dir) - self.leadermem = Member(0, 'leader', 28, { - 'state': 'running', 'conn_url': 'postgres://replicator:rep-pass@127.0.0.1:5435/postgres'}) + self.leadermem = Member(0, 'leader', 28, {'xlog_location': 100, 'state': 'running', + 'conn_url': 'postgres://replicator:rep-pass@127.0.0.1:5435/postgres'}) self.leader = Leader(-1, 28, self.leadermem) self.other = Member(0, 'test-1', 28, {'conn_url': 'postgres://replicator:rep-pass@127.0.0.1:5433/postgres', 'state': 'running', 'tags': {'replicatefrom': 'leader'}}) diff --git a/tests/test_api.py b/tests/test_api.py index fa9a6280..234a6824 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -8,8 +8,8 @@ from io import BytesIO as IO from mock import Mock, PropertyMock, patch from socketserver import ThreadingMixIn +from patroni import global_config from patroni.api import RestApiHandler, RestApiServer -from patroni.config import GlobalConfig from patroni.dcs import ClusterConfig, Member from patroni.exceptions import PostgresConnectionException from patroni.ha import _MemberStatus @@ -148,16 +148,9 @@ class MockLogger(object): records_lost = 1 -class MockConfig(object): - - def get_global_config(self, _): - return GlobalConfig({}) - - class MockPatroni(object): ha = MockHa() - config = MockConfig() postgresql = ha.state_handler dcs = Mock() logger = MockLogger() @@ -211,7 +204,7 @@ class TestRestApiHandler(unittest.TestCase): def test_do_GET(self): MockPatroni.dcs.cluster.last_lsn = 20 MockPatroni.dcs.cluster.sync.members = [MockPostgresql.name] - with patch.object(GlobalConfig, 'is_synchronous_mode', PropertyMock(return_value=True)): + with patch.object(global_config.__class__, 'is_synchronous_mode', PropertyMock(return_value=True)): MockRestApiServer(RestApiHandler, 'GET /replica') MockRestApiServer(RestApiHandler, 'GET /replica?lag=1M') MockRestApiServer(RestApiHandler, 'GET /replica?lag=10MB') @@ -234,7 +227,7 @@ class TestRestApiHandler(unittest.TestCase): with patch.object(MockHa, 'is_leader', Mock(return_value=True)): MockRestApiServer(RestApiHandler, 'GET /replica') MockRestApiServer(RestApiHandler, 'GET /read-only-sync') - with patch.object(GlobalConfig, 'is_standby_cluster', Mock(return_value=True)): + with patch.object(global_config.__class__, 'is_standby_cluster', Mock(return_value=True)): MockRestApiServer(RestApiHandler, 'GET /standby_leader') MockPatroni.dcs.cluster = None with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'role': 'primary'})): @@ -244,8 +237,8 @@ class TestRestApiHandler(unittest.TestCase): self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /primary')) with patch.object(RestApiServer, 'query', Mock(return_value=[('', 1, '', '', '', '', False, None, None, '')])): self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /patroni')) - with patch.object(GlobalConfig, 'is_standby_cluster', Mock(return_value=True)), \ - patch.object(GlobalConfig, 'is_paused', Mock(return_value=True)): + with patch.object(global_config.__class__, 'is_standby_cluster', Mock(return_value=True)), \ + patch.object(global_config.__class__, 'is_paused', Mock(return_value=True)): MockRestApiServer(RestApiHandler, 'GET /standby_leader') # test tags @@ -475,7 +468,7 @@ class TestRestApiHandler(unittest.TestCase): request = make_request(role='primary', postgres_version='9.5.2') MockRestApiServer(RestApiHandler, request) - with patch.object(GlobalConfig, 'is_paused', PropertyMock(return_value=True)): + with patch.object(global_config.__class__, 'is_paused', PropertyMock(return_value=True)): MockRestApiServer(RestApiHandler, make_request(schedule='2016-08-42 12:45TZ+1', role='primary')) # Valid timeout MockRestApiServer(RestApiHandler, make_request(timeout='60s')) @@ -516,86 +509,163 @@ class TestRestApiHandler(unittest.TestCase): post = 'POST /switchover HTTP/1.0' + self._authorization + '\nContent-Length: ' - MockRestApiServer(RestApiHandler, post + '7\n\n{"1":2}') + # Invalid content + with patch.object(RestApiHandler, 'write_response') as response_mock: + MockRestApiServer(RestApiHandler, post + '7\n\n{"1":2}') + response_mock.assert_called_with(400, 'Switchover could be performed only from a specific leader') + # Empty content request = post + '0\n\n' MockRestApiServer(RestApiHandler, request) - cluster.leader.name = 'postgresql1' - MockRestApiServer(RestApiHandler, request) + # [Switchover without a candidate] - request = post + '25\n\n{"leader": "postgresql1"}' - - with patch.object(GlobalConfig, 'is_paused', PropertyMock(return_value=True)): + # Cluster with only a leader + with patch.object(RestApiHandler, 'write_response') as response_mock: + cluster.leader.name = 'postgresql1' + request = post + '25\n\n{"leader": "postgresql1"}' MockRestApiServer(RestApiHandler, request) + response_mock.assert_called_with( + 412, 'switchover is not possible: cluster does not have members except leader') - for is_synchronous_mode in (True, False): - with patch.object(GlobalConfig, 'is_synchronous_mode', PropertyMock(return_value=is_synchronous_mode)): + # Switchover in pause mode + with patch.object(RestApiHandler, 'write_response') as response_mock, \ + patch.object(global_config.__class__, 'is_paused', PropertyMock(return_value=True)): + MockRestApiServer(RestApiHandler, request) + response_mock.assert_called_with( + 400, 'Switchover is possible only to a specific candidate in a paused state') + + # No healthy nodes to promote in both sync and async mode + for is_synchronous_mode, response in ( + (True, 'switchover is not possible: can not find sync_standby'), + (False, 'switchover is not possible: cluster does not have members except leader')): + with patch.object(global_config.__class__, 'is_synchronous_mode', + PropertyMock(return_value=is_synchronous_mode)), \ + patch.object(RestApiHandler, 'write_response') as response_mock: MockRestApiServer(RestApiHandler, request) + response_mock.assert_called_with(412, response) - cluster.leader.name = 'postgresql2' - request = post + '53\n\n{"leader": "postgresql1", "candidate": "postgresql2"}' - MockRestApiServer(RestApiHandler, request) + # [Switchover to the candidate specified] + # Candidate to promote is the same as the leader specified + with patch.object(RestApiHandler, 'write_response') as response_mock: + request = post + '53\n\n{"leader": "postgresql2", "candidate": "postgresql2"}' + MockRestApiServer(RestApiHandler, request) + response_mock.assert_called_with(400, 'Switchover target and source are the same') + + # Current leader is different from the one specified + with patch.object(RestApiHandler, 'write_response') as response_mock: + cluster.leader.name = 'postgresql2' + request = post + '53\n\n{"leader": "postgresql1", "candidate": "postgresql2"}' + MockRestApiServer(RestApiHandler, request) + response_mock.assert_called_with(412, 'leader name does not match') + + # Candidate to promote is not a member of the cluster cluster.leader.name = 'postgresql1' cluster.sync.matches.return_value = False - for is_synchronous_mode in (True, False): - with patch.object(GlobalConfig, 'is_synchronous_mode', PropertyMock(return_value=is_synchronous_mode)): + for is_synchronous_mode, response in ( + (True, 'candidate name does not match with sync_standby'), (False, 'candidate does not exists')): + with patch.object(global_config.__class__, 'is_synchronous_mode', + PropertyMock(return_value=is_synchronous_mode)), \ + patch.object(RestApiHandler, 'write_response') as response_mock: MockRestApiServer(RestApiHandler, request) + response_mock.assert_called_with(412, response) cluster.members = [Member(0, 'postgresql0', 30, {'api_url': 'http'}), Member(0, 'postgresql2', 30, {'api_url': 'http'})] - MockRestApiServer(RestApiHandler, request) - cluster.failover = None - MockRestApiServer(RestApiHandler, request) + # Failover key is empty in DCS + with patch.object(RestApiHandler, 'write_response') as response_mock: + cluster.failover = None + MockRestApiServer(RestApiHandler, request) + response_mock.assert_called_with(503, 'Switchover failed') - dcs.get_cluster.side_effect = [cluster] - MockRestApiServer(RestApiHandler, request) + # Result polling failed + with patch.object(RestApiHandler, 'write_response') as response_mock: + dcs.get_cluster.side_effect = [cluster] + MockRestApiServer(RestApiHandler, request) + response_mock.assert_called_with(503, 'Switchover status unknown') - cluster2 = cluster.copy() - cluster2.leader.name = 'postgresql0' - cluster2.is_unlocked.return_value = False - dcs.get_cluster.side_effect = [cluster, cluster2] - MockRestApiServer(RestApiHandler, request) + # Switchover to a node different from the candidate specified + with patch.object(RestApiHandler, 'write_response') as response_mock: + cluster2 = cluster.copy() + cluster2.leader.name = 'postgresql0' + cluster2.is_unlocked.return_value = False + dcs.get_cluster.side_effect = [cluster, cluster2] + MockRestApiServer(RestApiHandler, request) + response_mock.assert_called_with(200, 'Switched over to "postgresql0" instead of "postgresql2"') - cluster2.leader.name = 'postgresql2' - dcs.get_cluster.side_effect = [cluster, cluster2] - MockRestApiServer(RestApiHandler, request) + # Successful switchover to the candidate + with patch.object(RestApiHandler, 'write_response') as response_mock: + cluster2.leader.name = 'postgresql2' + dcs.get_cluster.side_effect = [cluster, cluster2] + MockRestApiServer(RestApiHandler, request) + response_mock.assert_called_with(200, 'Successfully switched over to "postgresql2"') + + with patch.object(RestApiHandler, 'write_response') as response_mock: + dcs.manual_failover.return_value = False + dcs.get_cluster.side_effect = None + MockRestApiServer(RestApiHandler, request) + response_mock.assert_called_with(503, 'failed to write failover key into DCS') - dcs.get_cluster.side_effect = None - dcs.manual_failover.return_value = False - MockRestApiServer(RestApiHandler, request) dcs.manual_failover.return_value = True - with patch.object(MockHa, 'fetch_nodes_statuses', Mock(return_value=[])): + # Candidate is not healthy to be promoted + with patch.object(MockHa, 'fetch_nodes_statuses', Mock(return_value=[])), \ + patch.object(RestApiHandler, 'write_response') as response_mock: MockRestApiServer(RestApiHandler, request) + response_mock.assert_called_with(412, 'switchover is not possible: no good candidates have been found') + + # [Scheduled switchover] # Valid future date - request = post + '103\n\n{"leader": "postgresql1", "member": "postgresql2",' +\ - ' "scheduled_at": "6016-02-15T18:13:30.568224+01:00"}' - MockRestApiServer(RestApiHandler, request) - with patch.object(GlobalConfig, 'is_paused', PropertyMock(return_value=True)), \ - patch.object(MockPatroni, 'dcs') as d: - d.manual_failover.return_value = False + with patch.object(RestApiHandler, 'write_response') as response_mock: + request = post + '103\n\n{"leader": "postgresql1", "member": "postgresql2",' + \ + ' "scheduled_at": "6016-02-15T18:13:30.568224+01:00"}' MockRestApiServer(RestApiHandler, request) + response_mock.assert_called_with(202, 'Switchover scheduled') - # Exception: No timezone specified - request = post + '97\n\n{"leader": "postgresql1", "member": "postgresql2",' +\ - ' "scheduled_at": "6016-02-15T18:13:30.568224"}' - MockRestApiServer(RestApiHandler, request) + # Schedule in paused mode + with patch.object(RestApiHandler, 'write_response') as response_mock, \ + patch.object(global_config.__class__, 'is_paused', PropertyMock(return_value=True)): + dcs.manual_failover.return_value = False + MockRestApiServer(RestApiHandler, request) + response_mock.assert_called_with(400, "Can't schedule switchover in the paused state") + + # No timezone specified + with patch.object(RestApiHandler, 'write_response') as response_mock: + request = post + '97\n\n{"leader": "postgresql1", "member": "postgresql2",' + \ + ' "scheduled_at": "6016-02-15T18:13:30.568224"}' + MockRestApiServer(RestApiHandler, request) + response_mock.assert_called_with(400, 'Timezone information is mandatory for the scheduled switchover') - # Exception: Scheduled in the past request = post + '103\n\n{"leader": "postgresql1", "member": "postgresql2", "scheduled_at": "' - MockRestApiServer(RestApiHandler, request + '1016-02-15T18:13:30.568224+01:00"}') + + # Scheduled in the past + with patch.object(RestApiHandler, 'write_response') as response_mock: + MockRestApiServer(RestApiHandler, request + '1016-02-15T18:13:30.568224+01:00"}') + response_mock.assert_called_with(422, 'Cannot schedule switchover in the past') # Invalid date - self.assertIsNotNone(MockRestApiServer(RestApiHandler, request + '2010-02-29T18:13:30.568224+01:00"}')) + with patch.object(RestApiHandler, 'write_response') as response_mock: + MockRestApiServer(RestApiHandler, request + '2010-02-29T18:13:30.568224+01:00"}') + response_mock.assert_called_with( + 422, 'Unable to parse scheduled timestamp. It should be in an unambiguous format, e.g. ISO 8601') def test_do_POST_failover(self): post = 'POST /failover HTTP/1.0' + self._authorization + '\nContent-Length: ' - MockRestApiServer(RestApiHandler, post + '14\n\n{"leader":"1"}') - MockRestApiServer(RestApiHandler, post + '37\n\n{"candidate":"2","scheduled_at": "1"}') + + with patch.object(RestApiHandler, 'write_response') as response_mock: + MockRestApiServer(RestApiHandler, post + '14\n\n{"leader":"1"}') + response_mock.assert_called_once_with(400, 'Failover could be performed only to a specific candidate') + + with patch.object(RestApiHandler, 'write_response') as response_mock: + MockRestApiServer(RestApiHandler, post + '37\n\n{"candidate":"2","scheduled_at": "1"}') + response_mock.assert_called_once_with(400, "Failover can't be scheduled") + + with patch.object(RestApiHandler, 'write_response') as response_mock: + MockRestApiServer(RestApiHandler, post + '30\n\n{"leader":"1","candidate":"2"}') + response_mock.assert_called_once_with(412, 'leader name does not match') @patch.object(MockHa, 'is_leader', Mock(return_value=True)) def test_do_POST_citus(self): diff --git a/tests/test_barman_recover.py b/tests/test_barman_recover.py new file mode 100644 index 00000000..c0efda83 --- /dev/null +++ b/tests/test_barman_recover.py @@ -0,0 +1,366 @@ +import logging +import mock +from mock import MagicMock, Mock, patch +import unittest +from urllib3.exceptions import MaxRetryError + +from patroni.scripts.barman_recover import BarmanRecover, ExitCode, RetriesExceeded, main, set_up_logging + + +API_URL = "http://localhost:7480" +BARMAN_SERVER = "my_server" +BACKUP_ID = "backup_id" +SSH_COMMAND = "ssh postgres@localhost" +DATA_DIRECTORY = "/path/to/pgdata" +LOOP_WAIT = 10 +RETRY_WAIT = 2 +MAX_RETRIES = 5 + + +class TestBarmanRecover(unittest.TestCase): + + @patch.object(BarmanRecover, "_ensure_api_ok", Mock()) + @patch("patroni.scripts.barman_recover.PoolManager", MagicMock()) + def setUp(self): + self.br = BarmanRecover(API_URL, BARMAN_SERVER, BACKUP_ID, SSH_COMMAND, DATA_DIRECTORY, LOOP_WAIT, RETRY_WAIT, + MAX_RETRIES) + # Reset the mock as the same instance is used across tests + self.br.http.request.reset_mock() + self.br.http.request.side_effect = None + + def test__build_full_url(self): + self.assertEqual(self.br._build_full_url("/some/path"), f"{API_URL}/some/path") + + @patch("json.loads") + def test__deserialize_response(self, mock_json_loads): + mock_response = MagicMock() + self.assertIsNotNone(self.br._deserialize_response(mock_response)) + mock_json_loads.assert_called_once_with(mock_response.data.decode("utf-8")) + + @patch("json.dumps") + def test__serialize_request(self, mock_json_dumps): + body = "some_body" + ret = self.br._serialize_request(body) + self.assertIsNotNone(ret) + mock_json_dumps.assert_called_once_with(body) + mock_json_dumps.return_value.encode.assert_called_once_with("utf-8") + + @patch.object(BarmanRecover, "_deserialize_response", Mock(return_value="test")) + @patch("logging.critical") + def test__get_request(self, mock_logging): + mock_request = self.br.http.request + + # with no error + self.assertEqual(self.br._get_request("/some/path"), "test") + mock_request.assert_called_once_with("GET", f"{API_URL}/some/path") + + # with MaxRetryError + http_error = MaxRetryError(self.br.http, f"{API_URL}/some/path") + mock_request.side_effect = http_error + + with self.assertRaises(SystemExit) as exc: + self.assertIsNone(self.br._get_request("/some/path")) + + mock_logging.assert_called_once_with("An error occurred while performing an HTTP GET request: %r", http_error) + self.assertEqual(exc.exception.code, ExitCode.HTTP_REQUEST_ERROR) + + # with Exception + mock_logging.reset_mock() + mock_request.side_effect = Exception("Some error.") + + with patch("sys.exit") as mock_sys: + with self.assertRaises(Exception): + self.assertIsNone(self.br._get_request("/some/path")) + + mock_logging.assert_not_called() + mock_sys.assert_not_called() + + @patch.object(BarmanRecover, "_deserialize_response", Mock(return_value="test")) + @patch("logging.critical") + @patch.object(BarmanRecover, "_serialize_request") + def test__post_request(self, mock_serialize, mock_logging): + mock_request = self.br.http.request + + # with no error + self.assertEqual(self.br._post_request("/some/path", "some body"), "test") + mock_serialize.assert_called_once_with("some body") + mock_request.assert_called_once_with("POST", f"{API_URL}/some/path", body=mock_serialize.return_value, + headers={"Content-Type": "application/json"}) + + # with HTTPError + http_error = MaxRetryError(self.br.http, f"{API_URL}/some/path") + mock_request.side_effect = http_error + + with self.assertRaises(SystemExit) as exc: + self.assertIsNone(self.br._post_request("/some/path", "some body")) + + mock_logging.assert_called_once_with("An error occurred while performing an HTTP POST request: %r", http_error) + self.assertEqual(exc.exception.code, ExitCode.HTTP_REQUEST_ERROR) + + # with Exception + mock_logging.reset_mock() + mock_request.side_effect = Exception("Some error.") + + with patch("sys.exit") as mock_sys: + with self.assertRaises(Exception): + self.br._post_request("/some/path", "some body") + + mock_logging.assert_not_called() + mock_sys.assert_not_called() + + @patch("logging.critical") + @patch.object(BarmanRecover, "_get_request") + def test__ensure_api_ok(self, mock_get_request, mock_logging): + # API ok + mock_get_request.return_value = "OK" + + with patch("sys.exit") as mock_sys: + self.assertIsNone(self.br._ensure_api_ok()) + mock_logging.assert_not_called() + mock_sys.assert_not_called() + + # API not ok + mock_get_request.return_value = "random" + + with self.assertRaises(SystemExit) as exc: + self.assertIsNone(self.br._ensure_api_ok()) + + mock_logging.assert_called_once_with("pg-backup-api is not working: %s", "random") + self.assertEqual(exc.exception.code, ExitCode.API_NOT_OK) + + @patch("logging.warning") + @patch("time.sleep") + @patch.object(BarmanRecover, "_post_request") + def test__create_recovery_operation(self, mock_post_request, mock_sleep, mock_logging): + # well formed response + mock_post_request.return_value = {"operation_id": "some_id"} + self.assertEqual(self.br._create_recovery_operation(), "some_id") + mock_sleep.assert_not_called() + mock_logging.assert_not_called() + mock_post_request.assert_called_once_with( + f"servers/{BARMAN_SERVER}/operations", + { + "type": "recovery", + "backup_id": BACKUP_ID, + "remote_ssh_command": SSH_COMMAND, + "destination_directory": DATA_DIRECTORY, + } + ) + + # malformed response + mock_post_request.return_value = {"operation_idd": "some_id"} + + with self.assertRaises(RetriesExceeded) as exc: + self.br._create_recovery_operation() + + self.assertEqual(str(exc.exception), + "Maximum number of retries exceeded for method BarmanRecover._create_recovery_operation.") + + self.assertEqual(mock_sleep.call_count, self.br.max_retries) + + mock_sleep.assert_has_calls([mock.call(self.br.retry_wait)] * self.br.max_retries) + + self.assertEqual(mock_logging.call_count, self.br.max_retries) + for i in range(mock_logging.call_count): + call_args = mock_logging.call_args_list[i][0] + self.assertEqual(len(call_args), 5) + self.assertEqual(call_args[0], "Attempt %d of %d on method %s failed with %r.") + self.assertEqual(call_args[1], i + 1) + self.assertEqual(call_args[2], self.br.max_retries) + self.assertEqual(call_args[3], "BarmanRecover._create_recovery_operation") + self.assertIsInstance(call_args[4], KeyError) + self.assertEqual(call_args[4].args, ('operation_id',)) + + @patch("logging.warning") + @patch("time.sleep") + @patch.object(BarmanRecover, "_get_request") + def test__get_recovery_operation_status(self, mock_get_request, mock_sleep, mock_logging): + # well formed response + mock_get_request.return_value = {"status": "some status"} + self.assertEqual(self.br._get_recovery_operation_status("some_id"), "some status") + mock_get_request.assert_called_once_with(f"servers/{BARMAN_SERVER}/operations/some_id") + mock_sleep.assert_not_called() + mock_logging.assert_not_called() + + # malformed response + mock_get_request.return_value = {"statuss": "some status"} + + with self.assertRaises(RetriesExceeded) as exc: + self.br._get_recovery_operation_status("some_id") + + self.assertEqual(str(exc.exception), + "Maximum number of retries exceeded for method BarmanRecover._get_recovery_operation_status.") + + self.assertEqual(mock_sleep.call_count, self.br.max_retries) + mock_sleep.assert_has_calls([mock.call(self.br.retry_wait)] * self.br.max_retries) + + self.assertEqual(mock_logging.call_count, self.br.max_retries) + for i in range(mock_logging.call_count): + call_args = mock_logging.call_args_list[i][0] + self.assertEqual(len(call_args), 5) + self.assertEqual(call_args[0], "Attempt %d of %d on method %s failed with %r.") + self.assertEqual(call_args[1], i + 1) + self.assertEqual(call_args[2], self.br.max_retries) + self.assertEqual(call_args[3], "BarmanRecover._get_recovery_operation_status") + self.assertIsInstance(call_args[4], KeyError) + self.assertEqual(call_args[4].args, ('status',)) + + @patch.object(BarmanRecover, "_get_recovery_operation_status") + @patch("time.sleep") + @patch("logging.info") + @patch("logging.critical") + @patch.object(BarmanRecover, "_create_recovery_operation") + def test_restore_backup(self, mock_create_op, mock_log_critical, mock_log_info, mock_sleep, mock_get_status): + # successful fast restore + mock_create_op.return_value = "some_id" + mock_get_status.return_value = "DONE" + + self.assertTrue(self.br.restore_backup()) + + mock_create_op.assert_called_once() + mock_get_status.assert_called_once_with("some_id") + mock_log_info.assert_called_once_with("Created the recovery operation with ID %s", "some_id") + mock_log_critical.assert_not_called() + mock_sleep.assert_not_called() + + # successful slow restore + mock_create_op.reset_mock() + mock_get_status.reset_mock() + mock_log_info.reset_mock() + mock_get_status.side_effect = ["IN_PROGRESS"] * 20 + ["DONE"] + + self.assertTrue(self.br.restore_backup()) + + mock_create_op.assert_called_once() + + self.assertEqual(mock_get_status.call_count, 21) + mock_get_status.assert_has_calls([mock.call("some_id")] * 21) + + self.assertEqual(mock_log_info.call_count, 21) + mock_log_info.assert_has_calls([mock.call("Created the recovery operation with ID %s", "some_id")] + + [mock.call("Recovery operation %s is still in progress", "some_id")] * 20) + + mock_log_critical.assert_not_called() + + self.assertEqual(mock_sleep.call_count, 20) + mock_sleep.assert_has_calls([mock.call(LOOP_WAIT)] * 20) + + # failed fast restore + mock_create_op.reset_mock() + mock_get_status.reset_mock() + mock_log_info.reset_mock() + mock_sleep.reset_mock() + mock_get_status.side_effect = None + mock_get_status.return_value = "FAILED" + + self.assertFalse(self.br.restore_backup()) + + mock_create_op.assert_called_once() + mock_get_status.assert_called_once_with("some_id") + mock_log_info.assert_called_once_with("Created the recovery operation with ID %s", "some_id") + mock_log_critical.assert_not_called() + mock_sleep.assert_not_called() + + # failed slow restore + mock_create_op.reset_mock() + mock_get_status.reset_mock() + mock_log_info.reset_mock() + mock_sleep.reset_mock() + mock_get_status.side_effect = ["IN_PROGRESS"] * 20 + ["FAILED"] + + self.assertFalse(self.br.restore_backup()) + + mock_create_op.assert_called_once() + + self.assertEqual(mock_get_status.call_count, 21) + mock_get_status.assert_has_calls([mock.call("some_id")] * 21) + + self.assertEqual(mock_log_info.call_count, 21) + mock_log_info.assert_has_calls([mock.call("Created the recovery operation with ID %s", "some_id")] + + [mock.call("Recovery operation %s is still in progress", "some_id")] * 20) + + mock_log_critical.assert_not_called() + + self.assertEqual(mock_sleep.call_count, 20) + mock_sleep.assert_has_calls([mock.call(LOOP_WAIT)] * 20) + + # create retries exceeded + mock_log_info.reset_mock() + mock_sleep.reset_mock() + mock_create_op.side_effect = RetriesExceeded + mock_get_status.side_effect = None + + with self.assertRaises(SystemExit) as exc: + self.assertIsNone(self.br.restore_backup()) + + self.assertEqual(exc.exception.code, ExitCode.HTTP_RESPONSE_MALFORMED) + mock_log_info.assert_not_called() + mock_log_critical.assert_called_once_with("Maximum number of retries exceeded, exiting.") + mock_sleep.assert_not_called() + + # get status retries exceeded + mock_create_op.reset_mock() + mock_create_op.side_effect = None + mock_log_critical.reset_mock() + mock_log_info.reset_mock() + mock_get_status.side_effect = RetriesExceeded + + with self.assertRaises(SystemExit) as exc: + self.assertIsNone(self.br.restore_backup()) + + self.assertEqual(exc.exception.code, ExitCode.HTTP_RESPONSE_MALFORMED) + mock_log_info.assert_called_once_with("Created the recovery operation with ID %s", "some_id") + mock_log_critical.assert_called_once_with("Maximum number of retries exceeded, exiting.") + mock_sleep.assert_not_called() + + +class TestMain(unittest.TestCase): + + @patch("logging.basicConfig") + def test_set_up_logging(self, mock_log_config): + log_file = "/path/to/some/file.log" + set_up_logging(log_file) + mock_log_config.assert_called_once_with(filename=log_file, level=logging.INFO, + format="%(asctime)s %(levelname)s: %(message)s") + + @patch("logging.critical") + @patch("logging.info") + @patch("patroni.scripts.barman_recover.set_up_logging") + @patch("patroni.scripts.barman_recover.BarmanRecover") + @patch("patroni.scripts.barman_recover.ArgumentParser") + def test_main(self, mock_arg_parse, mock_br, mock_set_up_log, mock_log_info, mock_log_critical): + # successful restore + args = MagicMock() + mock_arg_parse.return_value.parse_known_args.return_value = (args, None) + mock_br.return_value.restore_backup.return_value = True + + with self.assertRaises(SystemExit) as exc: + main() + + mock_arg_parse.assert_called_once() + mock_set_up_log.assert_called_once_with(args.log_file) + mock_br.assert_called_once_with(args.api_url, args.barman_server, args.backup_id, args.ssh_command, + args.data_directory, args.loop_wait, args.retry_wait, args.max_retries, + args.cert_file, args.key_file) + mock_log_info.assert_called_once_with("Recovery operation finished successfully.") + mock_log_critical.assert_not_called() + self.assertEqual(exc.exception.code, ExitCode.RECOVERY_DONE) + + # failed restore + mock_arg_parse.reset_mock() + mock_set_up_log.reset_mock() + mock_br.reset_mock() + mock_log_info.reset_mock() + mock_br.return_value.restore_backup.return_value = False + + with self.assertRaises(SystemExit) as exc: + main() + + mock_arg_parse.assert_called_once() + mock_set_up_log.assert_called_once_with(args.log_file) + mock_br.assert_called_once_with(args.api_url, args.barman_server, args.backup_id, args.ssh_command, + args.data_directory, args.loop_wait, args.retry_wait, args.max_retries, + args.cert_file, args.key_file) + mock_log_info.assert_not_called() + mock_log_critical.assert_called_once_with("Recovery operation failed.") + self.assertEqual(exc.exception.code, ExitCode.RECOVERY_FAILED) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index c922fcae..8724b03c 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -179,10 +179,17 @@ class TestBootstrap(BaseTestPostgresql): @patch.object(Postgresql, 'controldata', Mock(return_value={'Database cluster state': 'in production'})) def test_custom_bootstrap(self, mock_cancellable_subprocess_call): self.p.config._config.pop('pg_hba') - config = {'method': 'foo', 'foo': {'command': 'bar'}} + config = {'method': 'foo', 'foo': {'command': 'bar --arg1=val1'}} mock_cancellable_subprocess_call.return_value = 1 self.assertFalse(self.b.bootstrap(config)) + self.assertEqual(mock_cancellable_subprocess_call.call_args_list[0][0][0], + ['bar', '--arg1=val1', '--scope=batman', '--datadir=' + os.path.join('data', 'test0')]) + + mock_cancellable_subprocess_call.reset_mock() + config['foo']['no_params'] = 1 + self.assertFalse(self.b.bootstrap(config)) + self.assertEqual(mock_cancellable_subprocess_call.call_args_list[0][0][0], ['bar', '--arg1=val1']) mock_cancellable_subprocess_call.return_value = 0 with patch('multiprocessing.Process', Mock(side_effect=Exception("42"))), \ @@ -238,7 +245,8 @@ class TestBootstrap(BaseTestPostgresql): self.p.reload_config({'authentication': {'superuser': {'username': 'p', 'password': 'p'}, 'replication': {'username': 'r', 'password': 'r'}, 'rewind': {'username': 'rw', 'password': 'rw'}}, - 'listen': '*', 'retry_timeout': 10, 'parameters': {'wal_level': '', 'hba_file': 'foo'}}) + 'listen': '*', 'retry_timeout': 10, + 'parameters': {'wal_level': '', 'hba_file': 'foo', 'max_prepared_transactions': 10}}) with patch.object(Postgresql, 'major_version', PropertyMock(return_value=110000)), \ patch.object(Postgresql, 'restart', Mock()) as mock_restart: self.b.post_bootstrap({}, task) @@ -255,7 +263,7 @@ class TestBootstrap(BaseTestPostgresql): mock_cancellable_subprocess_call.assert_called() args, kwargs = mock_cancellable_subprocess_call.call_args self.assertTrue('PGPASSFILE' in kwargs['env']) - self.assertEqual(args[0], ['/bin/false', 'dbname=postgres host=127.0.0.2 port=5432']) + self.assertEqual(args[0], ['/bin/false', 'dbname=postgres host=/tmp port=5432']) mock_cancellable_subprocess_call.reset_mock() self.p.connection_pool._conn_kwargs.pop('host') diff --git a/tests/test_callback_executor.py b/tests/test_callback_executor.py index df2556b9..51c915d9 100644 --- a/tests/test_callback_executor.py +++ b/tests/test_callback_executor.py @@ -35,5 +35,6 @@ class TestCallbackExecutor(unittest.TestCase): ce._invoke_excepthook = Mock() self.assertIsNone(ce.call(callback)) + mock_popen.side_effect = [Mock()] self.assertIsNone(ce.call(['test.sh', 'on_reload', 'replica', 'foo'])) ce.join() diff --git a/tests/test_citus.py b/tests/test_citus.py index b6f42c0a..1eddded1 100644 --- a/tests/test_citus.py +++ b/tests/test_citus.py @@ -17,7 +17,6 @@ class TestCitus(BaseTestPostgresql): def setUp(self): super(TestCitus, self).setUp() self.c = self.p.citus_handler - self.p.connection_pool.conn_kwargs = {'host': 'localhost', 'dbname': 'postgres'} self.cluster = get_cluster_initialized_with_leader() self.cluster.workers[1] = self.cluster @@ -153,6 +152,7 @@ class TestCitus(BaseTestPostgresql): self.assertEqual(parameters['max_prepared_transactions'], 202) self.assertEqual(parameters['shared_preload_libraries'], 'citus,foo,bar') self.assertEqual(parameters['wal_level'], 'logical') + self.assertEqual(parameters['citus.local_hostname'], '/tmp') def test_bootstrap(self): self.c._config = None diff --git a/tests/test_config.py b/tests/test_config.py index cf798d00..7bf01f56 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -3,8 +3,13 @@ import sys import unittest import io +from copy import deepcopy from mock import MagicMock, Mock, patch -from patroni.config import Config, ConfigParseError + +from patroni import global_config +from patroni.config import ClusterConfig, Config, ConfigParseError + +from .test_ha import get_cluster_initialized_with_only_leader class TestConfig(unittest.TestCase): @@ -22,7 +27,7 @@ class TestConfig(unittest.TestCase): self.assertFalse(self.config.set_dynamic_configuration({'foo': 'bar'})) self.assertTrue(self.config.set_dynamic_configuration({'standby_cluster': {}, 'postgresql': { 'parameters': {'cluster_name': 1, 'hot_standby': 1, 'wal_keep_size': 1, - 'track_commit_timestamp': 1, 'wal_level': 1}}})) + 'track_commit_timestamp': 1, 'wal_level': 1, 'max_connections': '100'}}})) def test_reload_local_configuration(self): os.environ.update({ @@ -149,3 +154,104 @@ class TestConfig(unittest.TestCase): @patch('os.path.isdir', Mock(return_value=False)) def test_invalid_path(self): self.assertRaises(ConfigParseError, Config, 'postgres0') + + @patch.object(Config, 'get') + @patch('patroni.config.logger') + def test__validate_failover_tags(self, mock_logger, mock_get): + """Ensures that only one of `nofailover` or `failover_priority` can be provided""" + mock_logger.warning.reset_mock() + config = Config("postgres0.yml") + # Providing one of `nofailover` or `failover_priority` is fine + just_nofailover = {"nofailover": True} + mock_get.side_effect = [just_nofailover] * 2 + self.assertIsNone(config._validate_failover_tags()) + mock_logger.warning.assert_not_called() + just_failover_priority = {"failover_priority": 1} + mock_get.side_effect = [just_failover_priority] * 2 + self.assertIsNone(config._validate_failover_tags()) + mock_logger.warning.assert_not_called() + # Providing both `nofailover` and `failover_priority` is fine if consistent + consistent_false = {"nofailover": False, "failover_priority": 1} + mock_get.side_effect = [consistent_false] * 2 + self.assertIsNone(config._validate_failover_tags()) + mock_logger.warning.assert_not_called() + consistent_true = {"nofailover": True, "failover_priority": 0} + mock_get.side_effect = [consistent_true] * 2 + self.assertIsNone(config._validate_failover_tags()) + mock_logger.warning.assert_not_called() + # Providing both inconsistently should log a warning + inconsistent_false = {"nofailover": False, "failover_priority": 0} + mock_get.side_effect = [inconsistent_false] * 2 + self.assertIsNone(config._validate_failover_tags()) + mock_logger.warning.assert_called_once_with( + 'Conflicting configuration between nofailover: %s and failover_priority: %s.' + + ' Defaulting to nofailover: %s', + False, + 0, + False + ) + mock_logger.warning.reset_mock() + inconsistent_true = {"nofailover": True, "failover_priority": 1} + mock_get.side_effect = [inconsistent_true] * 2 + self.assertIsNone(config._validate_failover_tags()) + mock_logger.warning.assert_called_once_with( + 'Conflicting configuration between nofailover: %s and failover_priority: %s.' + + ' Defaulting to nofailover: %s', + True, + 1, + True + ) + + def test__process_postgresql_parameters(self): + expected_params = { + 'f.oo': 'bar', # not in ConfigHandler.CMDLINE_OPTIONS + 'max_connections': 100, # IntValidator + 'wal_keep_size': '128MB', # IntValidator + 'wal_level': 'hot_standby', # EnumValidator + } + input_params = deepcopy(expected_params) + + input_params['max_connections'] = '100' + self.assertEqual(self.config._process_postgresql_parameters(input_params), expected_params) + + expected_params['f.oo'] = input_params['f.oo'] = '100' + self.assertEqual(self.config._process_postgresql_parameters(input_params), expected_params) + + input_params['wal_level'] = 'cold_standby' + expected_params.pop('wal_level') + self.assertEqual(self.config._process_postgresql_parameters(input_params), expected_params) + + input_params['max_connections'] = 10 + expected_params.pop('max_connections') + self.assertEqual(self.config._process_postgresql_parameters(input_params), expected_params) + + def test__validate_and_adjust_timeouts(self): + with patch('patroni.config.logger.warning') as mock_logger: + self.config._validate_and_adjust_timeouts({'ttl': 15}) + self.assertEqual(mock_logger.call_args_list[0][0], + ("%s=%d can't be smaller than %d, adjusting...", 'ttl', 15, 20)) + with patch('patroni.config.logger.warning') as mock_logger: + self.config._validate_and_adjust_timeouts({'loop_wait': 0}) + self.assertEqual(mock_logger.call_args_list[0][0], + ("%s=%d can't be smaller than %d, adjusting...", 'loop_wait', 0, 1)) + with patch('patroni.config.logger.warning') as mock_logger: + self.config._validate_and_adjust_timeouts({'retry_timeout': 1}) + self.assertEqual(mock_logger.call_args_list[0][0], + ("%s=%d can't be smaller than %d, adjusting...", 'retry_timeout', 1, 3)) + with patch('patroni.config.logger.warning') as mock_logger: + self.config._validate_and_adjust_timeouts({'ttl': 20, 'loop_wait': 11, 'retry_timeout': 5}) + self.assertEqual(mock_logger.call_args_list[0][0], + ('Violated the rule "loop_wait + 2*retry_timeout <= ttl", where ttl=%d ' + 'and retry_timeout=%d. Adjusting loop_wait from %d to %d', 20, 5, 11, 10)) + with patch('patroni.config.logger.warning') as mock_logger: + self.config._validate_and_adjust_timeouts({'ttl': 20, 'loop_wait': 10, 'retry_timeout': 10}) + self.assertEqual(mock_logger.call_args_list[0][0], + ('Violated the rule "loop_wait + 2*retry_timeout <= ttl", where ttl=%d. Adjusting' + ' loop_wait from %d to %d and retry_timeout from %d to %d', 20, 10, 1, 10, 9)) + + def test_global_config_is_synchronous_mode(self): + # we should ignore synchronous_mode setting in a standby cluster + config = {'standby_cluster': {'host': 'some_host'}, 'synchronous_mode': True} + cluster = get_cluster_initialized_with_only_leader(cluster_config=ClusterConfig(1, config, 1)) + test_config = global_config.from_cluster(cluster) + self.assertFalse(test_config.is_synchronous_mode) diff --git a/tests/test_config_generator.py b/tests/test_config_generator.py index 49799c01..1b436bb0 100644 --- a/tests/test_config_generator.py +++ b/tests/test_config_generator.py @@ -1,36 +1,42 @@ import os import psutil -import socket import unittest +import yaml from . import MockConnect, MockCursor, MockConnectionInfo from copy import deepcopy -from mock import MagicMock, Mock, PropertyMock, mock_open, patch +from mock import MagicMock, Mock, PropertyMock, mock_open as _mock_open, patch from patroni.__main__ import main as _main from patroni.config import Config -from patroni.config_generator import AbstractConfigGenerator, get_address - +from patroni.config_generator import AbstractConfigGenerator, get_address, NO_VALUE_MSG +from patroni.log import PatroniLogger from patroni.utils import patch_config from . import psycopg_connect +HOSTNAME = 'test_hostname' +IP = '1.9.8.4' + + +def mock_open(*args, **kwargs): + ret = _mock_open(*args, **kwargs) + ret.return_value.__iter__ = lambda o: iter(o.readline, '') + if not kwargs.get('read_data'): + ret.return_value.readline = Mock(return_value=None) + return ret + @patch('patroni.psycopg.connect', psycopg_connect) -@patch('socket.getaddrinfo', Mock(return_value=[(0, 0, 0, 0, ('1.9.8.4', 1984))])) @patch('builtins.open', MagicMock()) @patch('subprocess.check_output', Mock(return_value=b"postgres (PostgreSQL) 16.2")) @patch('psutil.Process.exe', Mock(return_value='/bin/dir/from/running/postgres')) @patch('psutil.Process.__init__', Mock(return_value=None)) +@patch.object(AbstractConfigGenerator, '_HOSTNAME', HOSTNAME) +@patch.object(AbstractConfigGenerator, '_IP', IP) class TestGenerateConfig(unittest.TestCase): - no_value_msg = '#FIXME' - _HOSTNAME = socket.gethostname() - _IP = sorted(socket.getaddrinfo(_HOSTNAME, 0, socket.AF_UNSPEC, socket.SOCK_STREAM, 0), key=lambda x: x[0])[0][4][0] - def setUp(self): - self.maxDiff = None - os.environ['PATRONI_SCOPE'] = 'scope_from_env' os.environ['PATRONI_POSTGRESQL_BIN_DIR'] = '/bin/from/env' os.environ['PATRONI_SUPERUSER_USERNAME'] = 'su_user_from_env' @@ -54,14 +60,24 @@ class TestGenerateConfig(unittest.TestCase): self.config = { 'scope': self.environ['PATRONI_SCOPE'], - 'name': self._HOSTNAME, + 'name': HOSTNAME, + 'log': { + 'level': PatroniLogger.DEFAULT_LEVEL, + 'traceback_level': PatroniLogger.DEFAULT_TRACEBACK_LEVEL, + 'format': PatroniLogger.DEFAULT_FORMAT, + 'max_queue_size': PatroniLogger.DEFAULT_MAX_QUEUE_SIZE + }, + 'restapi': { + 'connect_address': self.environ['PATRONI_RESTAPI_CONNECT_ADDRESS'], + 'listen': self.environ['PATRONI_RESTAPI_LISTEN'] + }, 'bootstrap': { 'dcs': dynamic_config }, 'postgresql': { - 'connect_address': self.no_value_msg + ':5432', - 'data_dir': self.no_value_msg, - 'listen': self.no_value_msg + ':5432', + 'connect_address': IP + ':5432', + 'data_dir': NO_VALUE_MSG, + 'listen': IP + ':5432', 'pg_hba': ['host all all all md5', f'host replication {self.environ["PATRONI_REPLICATION_USERNAME"]} all md5'], 'authentication': {'superuser': {'username': self.environ['PATRONI_SUPERUSER_USERNAME'], @@ -72,10 +88,6 @@ class TestGenerateConfig(unittest.TestCase): 'bin_dir': self.environ['PATRONI_POSTGRESQL_BIN_DIR'], 'bin_name': {'postgres': self.environ['PATRONI_POSTGRESQL_BIN_POSTGRES']}, 'parameters': {'password_encryption': 'md5'} - }, - 'restapi': { - 'connect_address': self.environ['PATRONI_RESTAPI_CONNECT_ADDRESS'], - 'listen': self.environ['PATRONI_RESTAPI_LISTEN'] } } @@ -99,7 +111,7 @@ class TestGenerateConfig(unittest.TestCase): } }, 'postgresql': { - 'connect_address': f'{self._IP}:bar', + 'connect_address': f'{IP}:bar', 'listen': '6.6.6.6:1984', 'data_dir': 'data', 'bin_dir': '/bin/dir/from/running', @@ -118,11 +130,17 @@ class TestGenerateConfig(unittest.TestCase): 'sslmode': 'prefer' }, 'replication': { - 'username': self.no_value_msg, - 'password': self.no_value_msg + 'username': NO_VALUE_MSG, + 'password': NO_VALUE_MSG }, 'rewind': None }, + }, + 'tags': { + 'failover_priority': 1, + 'noloadbalance': False, + 'clonefrom': True, + 'nosync': False, } } patch_config(self.config, conf) @@ -143,22 +161,21 @@ class TestGenerateConfig(unittest.TestCase): ] @patch('os.makedirs') - @patch('yaml.safe_dump') - def test_generate_sample_config_pre_13_dir_creation(self, mock_config_dump, mock_makedir): + def test_generate_sample_config_pre_13_dir_creation(self, mock_makedir): with patch('sys.argv', ['patroni.py', '--generate-sample-config', '/foo/bar.yml']), \ patch('subprocess.check_output', Mock(return_value=b"postgres (PostgreSQL) 9.4.3")) as pg_bin_mock, \ + patch('builtins.open', _mock_open()) as mocked_file, \ self.assertRaises(SystemExit) as e: _main() + self.assertEqual(self.config, yaml.safe_load(mocked_file().write.call_args_list[0][0][0])) self.assertEqual(e.exception.code, 0) - self.assertEqual(self.config, mock_config_dump.call_args[0][0]) mock_makedir.assert_called_once() pg_bin_mock.assert_called_once_with([os.path.join(self.environ['PATRONI_POSTGRESQL_BIN_DIR'], self.environ['PATRONI_POSTGRESQL_BIN_POSTGRES']), '--version']) @patch('os.makedirs', Mock()) - @patch('yaml.safe_dump') - def test_generate_sample_config_16(self, mock_config_dump): + def test_generate_sample_config_16(self): conf = { 'bootstrap': { 'dcs': { @@ -179,21 +196,22 @@ class TestGenerateConfig(unittest.TestCase): 'authentication': { 'rewind': { 'username': self.environ['PATRONI_REWIND_USERNAME'], - 'password': self.no_value_msg} + 'password': NO_VALUE_MSG} }, } } patch_config(self.config, conf) with patch('sys.argv', ['patroni.py', '--generate-sample-config', '/foo/bar.yml']), \ + patch('builtins.open', _mock_open()) as mocked_file, \ self.assertRaises(SystemExit) as e: _main() + self.assertEqual(self.config, yaml.safe_load(mocked_file().write.call_args_list[0][0][0])) self.assertEqual(e.exception.code, 0) - self.assertEqual(self.config, mock_config_dump.call_args[0][0]) @patch('os.makedirs', Mock()) - @patch('yaml.safe_dump') - def test_generate_config_running_instance_16(self, mock_config_dump): + @patch('sys.stdout') + def test_generate_config_running_instance_16(self, mock_sys_stdout): self._set_running_instance_config_vals() with patch('builtins.open', Mock(side_effect=self._get_running_instance_open_res())), \ @@ -202,11 +220,11 @@ class TestGenerateConfig(unittest.TestCase): self.assertRaises(SystemExit) as e: _main() self.assertEqual(e.exception.code, 0) - self.assertEqual(self.config, mock_config_dump.call_args[0][0]) + self.assertEqual(self.config, yaml.safe_load(mock_sys_stdout.write.call_args_list[0][0][0])) @patch('os.makedirs', Mock()) - @patch('yaml.safe_dump') - def test_generate_config_running_instance_16_connect_from_env(self, mock_config_dump): + @patch('sys.stdout') + def test_generate_config_running_instance_16_connect_from_env(self, mock_sys_stdout): self._set_running_instance_config_vals() # su auth params and connect host from env os.environ['PGCHANNELBINDING'] = \ @@ -230,7 +248,7 @@ class TestGenerateConfig(unittest.TestCase): } }, 'postgresql': { - 'connect_address': f'{self._IP}:1984', + 'connect_address': f'{IP}:1984', 'authentication': { 'superuser': { 'username': self.environ['PGUSER'], @@ -249,7 +267,7 @@ class TestGenerateConfig(unittest.TestCase): self.assertRaises(SystemExit) as e: _main() self.assertEqual(e.exception.code, 0) - self.assertEqual(self.config, mock_config_dump.call_args[0][0]) + self.assertEqual(self.config, yaml.safe_load(mock_sys_stdout.write.call_args_list[0][0][0])) def test_generate_config_running_instance_errors(self): # 1. Wrong DSN format @@ -330,5 +348,5 @@ class TestGenerateConfig(unittest.TestCase): def test_get_address(self): with patch('socket.getaddrinfo', Mock(side_effect=Exception)), \ patch('logging.warning') as mock_warning: - self.assertEqual(get_address(), (self.no_value_msg, self.no_value_msg)) + self.assertEqual(get_address(), (NO_VALUE_MSG, NO_VALUE_MSG)) self.assertIn('Failed to obtain address: %r', mock_warning.call_args_list[0][0]) diff --git a/tests/test_ctl.py b/tests/test_ctl.py index b1468075..a174b03d 100644 --- a/tests/test_ctl.py +++ b/tests/test_ctl.py @@ -1,3 +1,4 @@ +import click import etcd import mock import os @@ -6,10 +7,11 @@ import unittest from click.testing import CliRunner from datetime import datetime, timedelta from mock import patch, Mock, PropertyMock +from patroni import global_config from patroni.ctl import ctl, load_config, output_members, get_dcs, parse_dcs, \ get_all_members, get_any_member, get_cursor, query_member, PatroniCtlException, apply_config_changes, \ format_config_for_editing, show_diff, invoke_editor, format_pg_version, CONFIG_FILE_PATH, PatronictlPrettyTable -from patroni.dcs.etcd import AbstractEtcdClientWithFailover, Cluster, Failover +from patroni.dcs import Cluster, Failover from patroni.psycopg import OperationalError from patroni.utils import tzutc from prettytable import PrettyTable, ALL @@ -21,19 +23,26 @@ from .test_ha import get_cluster_initialized_without_leader, get_cluster_initial get_cluster_initialized_with_only_leader, get_cluster_not_initialized_without_leader, get_cluster, Member -@patch('patroni.ctl.load_config', Mock(return_value={ - 'scope': 'alpha', 'restapi': {'listen': '::', 'certfile': 'a'}, 'ctl': {'certfile': 'a'}, - 'etcd': {'host': 'localhost:2379'}, 'citus': {'database': 'citus', 'group': 0}, - 'postgresql': {'data_dir': '.', 'pgpass': './pgpass', 'parameters': {}, 'retry_timeout': 5}})) +def get_default_config(*args): + return { + 'scope': 'alpha', + 'restapi': {'listen': '::', 'certfile': 'a'}, + 'ctl': {'certfile': 'a'}, + 'etcd': {'host': 'localhost:2379', 'retry_timeout': 10, 'ttl': 30}, + 'citus': {'database': 'citus', 'group': 0}, + 'postgresql': {'data_dir': '.', 'pgpass': './pgpass', 'parameters': {}, 'retry_timeout': 5} + } + + +@patch.object(PoolManager, 'request', Mock(return_value=MockResponse())) +@patch('patroni.ctl.load_config', get_default_config) +@patch('patroni.dcs.AbstractDCS.get_cluster', Mock(return_value=get_cluster_initialized_with_leader())) class TestCtl(unittest.TestCase): TEST_ROLES = ('master', 'primary', 'leader') @patch('socket.getaddrinfo', socket_getaddrinfo) - @patch.object(AbstractEtcdClientWithFailover, '_get_machines_list', Mock(return_value=['http://remotehost:2379'])) def setUp(self): self.runner = CliRunner() - self.e = get_dcs({'etcd': {'ttl': 30, 'host': 'ok:2379', 'retry_timeout': 10}, - 'citus': {'group': 0}}, 'foo', None) @patch('patroni.ctl.logging.debug') def test_load_config(self, mock_logger_debug): @@ -59,14 +68,31 @@ class TestCtl(unittest.TestCase): @patch('patroni.psycopg.connect', psycopg_connect) def test_get_cursor(self): - for role in self.TEST_ROLES: - self.assertIsNone(get_cursor({}, get_cluster_initialized_without_leader(), None, {}, role=role)) - self.assertIsNotNone(get_cursor({}, get_cluster_initialized_with_leader(), None, {}, role=role)) + with click.Context(click.Command('query')) as ctx: + ctx.obj = {'__config': {}} + for role in self.TEST_ROLES: + self.assertIsNone(get_cursor(get_cluster_initialized_without_leader(), None, {}, role=role)) + self.assertIsNotNone(get_cursor(get_cluster_initialized_with_leader(), None, {}, role=role)) - # MockCursor returns pg_is_in_recovery as false - self.assertIsNone(get_cursor({}, get_cluster_initialized_with_leader(), None, {}, role='replica')) + # MockCursor returns pg_is_in_recovery as false + self.assertIsNone(get_cursor(get_cluster_initialized_with_leader(), None, {}, role='replica')) - self.assertIsNotNone(get_cursor({}, get_cluster_initialized_with_leader(), None, {'dbname': 'foo'}, role='any')) + self.assertIsNotNone(get_cursor(get_cluster_initialized_with_leader(), None, {'dbname': 'foo'}, role='any')) + + # Mutually exclusive options + with self.assertRaises(PatroniCtlException) as e: + get_cursor(get_cluster_initialized_with_leader(), None, {'dbname': 'foo'}, member_name='other', + role='replica') + + self.assertEqual(str(e.exception), '--role and --member are mutually exclusive options') + + # Invalid member provided + self.assertIsNone(get_cursor(get_cluster_initialized_with_leader(), None, {'dbname': 'foo'}, + member_name='invalid')) + + # Valid member provided + self.assertIsNotNone(get_cursor(get_cluster_initialized_with_leader(), None, {'dbname': 'foo'}, + member_name='other')) def test_parse_dcs(self): assert parse_dcs(None) is None @@ -80,118 +106,149 @@ class TestCtl(unittest.TestCase): self.assertRaises(PatroniCtlException, parse_dcs, 'invalid://test') def test_output_members(self): - scheduled_at = datetime.now(tzutc) + timedelta(seconds=600) - cluster = get_cluster_initialized_with_leader(Failover(1, 'foo', 'bar', scheduled_at)) - del cluster.members[1].data['conn_url'] - for fmt in ('pretty', 'json', 'yaml', 'topology'): - self.assertIsNone(output_members({}, cluster, name='abc', fmt=fmt)) + with click.Context(click.Command('list')) as ctx: + ctx.obj = {'__config': {}} + scheduled_at = datetime.now(tzutc) + timedelta(seconds=600) + cluster = get_cluster_initialized_with_leader(Failover(1, 'foo', 'bar', scheduled_at)) + del cluster.members[1].data['conn_url'] + for fmt in ('pretty', 'json', 'yaml', 'topology'): + self.assertIsNone(output_members(cluster, name='abc', fmt=fmt)) - with patch('click.echo') as mock_echo: - self.assertIsNone(output_members({}, cluster, name='abc', fmt='tsv')) - self.assertEqual(mock_echo.call_args[0][0], 'abc\tother\t\tReplica\trunning\t\tunknown') + with patch('click.echo') as mock_echo: + self.assertIsNone(output_members(cluster, name='abc', fmt='tsv')) + self.assertEqual(mock_echo.call_args[0][0], 'abc\tother\t\tReplica\trunning\t\tunknown') - @patch('patroni.ctl.get_dcs') - @patch.object(PoolManager, 'request', Mock(return_value=MockResponse())) - def test_switchover(self, mock_get_dcs): - mock_get_dcs.return_value = self.e - mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader - mock_get_dcs.return_value.set_failover_value = Mock() + @patch('patroni.dcs.AbstractDCS.set_failover_value', Mock()) + def test_switchover(self): + # Confirm result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'], input='leader\nother\n\ny') - assert 'leader' in result.output + self.assertEqual(result.exit_code, 0) + # Abort + result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'], input='leader\nother\n\nN') + self.assertEqual(result.exit_code, 1) + + # Without a candidate with --force option + result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0', '--force']) + self.assertEqual(result.exit_code, 0) + + # Scheduled (confirm) result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'], input='leader\nother\n2300-01-01T12:23:00\ny') - assert result.exit_code == 0 + self.assertEqual(result.exit_code, 0) - with patch('patroni.config.GlobalConfig.is_paused', PropertyMock(return_value=True)): - result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0', - '--force', '--scheduled', '2015-01-01T12:00:00']) - assert result.exit_code == 1 - - # Aborting switchover, as we answer NO to the confirmation - result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'], input='leader\nother\n\nN') - assert result.exit_code == 1 - - # Aborting scheduled switchover, as we answer NO to the confirmation + # Scheduled (abort) result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0', '--scheduled', '2015-01-01T12:00:00+01:00'], input='leader\nother\n\nN') - assert result.exit_code == 1 + self.assertEqual(result.exit_code, 1) + + # Scheduled with --force option + result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0', + '--force', '--scheduled', '2015-01-01T12:00:00+01:00']) + self.assertEqual(result.exit_code, 0) + + # Scheduled in pause mode + with patch.object(global_config.__class__, 'is_paused', PropertyMock(return_value=True)): + result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0', + '--force', '--scheduled', '2015-01-01T12:00:00']) + self.assertEqual(result.exit_code, 1) + self.assertIn("Can't schedule switchover in the paused state", result.output) # Target and source are equal result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'], input='leader\nleader\n\ny') - assert result.exit_code == 1 + self.assertEqual(result.exit_code, 1) + self.assertIn('Switchover target and source are the same', result.output) - # Reality is not part of this cluster + # Candidate is not a member of the cluster result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'], input='leader\nReality\n\ny') - assert result.exit_code == 1 - - result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0', '--force']) - assert 'Member' in result.output - - result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0', - '--force', '--scheduled', '2015-01-01T12:00:00+01:00']) - assert result.exit_code == 0 + self.assertEqual(result.exit_code, 1) + self.assertIn('Member Reality does not exist in cluster dummy or is tagged as nofailover', result.output) # Invalid timestamp result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0', '--force', '--scheduled', 'invalid']) - assert result.exit_code != 0 + self.assertEqual(result.exit_code, 1) + self.assertIn('Unable to parse scheduled timestamp', result.output) # Invalid timestamp result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0', '--force', '--scheduled', '2115-02-30T12:00:00+01:00']) - assert result.exit_code != 0 + self.assertEqual(result.exit_code, 1) + self.assertIn('Unable to parse scheduled timestamp', result.output) # Specifying wrong leader result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'], input='dummy') - assert result.exit_code == 1 + self.assertEqual(result.exit_code, 1) + self.assertIn('Member dummy is not the leader of cluster dummy', result.output) - with patch.object(PoolManager, 'request', Mock(side_effect=Exception)): - # Non-responding patroni + # Errors while sending Patroni REST API request + with patch('patroni.ctl.request_patroni', Mock(side_effect=Exception)): result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'], input='leader\nother\n2300-01-01T12:23:00\ny') - assert 'falling back to DCS' in result.output + self.assertIn('falling back to DCS', result.output) - with patch.object(PoolManager, 'request') as mocked: - mocked.return_value.status = 500 + with patch('patroni.ctl.request_patroni') as mock_api_request: + mock_api_request.return_value.status = 500 result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'], input='leader\nother\n\ny') - assert 'Switchover failed' in result.output + self.assertIn('Switchover failed', result.output) - mocked.return_value.status = 501 - mocked.return_value.data = b'Server does not support this operation' + mock_api_request.return_value.status = 501 + mock_api_request.return_value.data = b'Server does not support this operation' result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'], input='leader\nother\n\ny') - assert 'Switchover failed' in result.output + self.assertIn('Switchover failed', result.output) # No members available - mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_only_leader - result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'], input='leader\nother\n\ny') - assert result.exit_code == 1 + with patch('patroni.dcs.AbstractDCS.get_cluster', + Mock(return_value=get_cluster_initialized_with_only_leader())): + result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'], input='leader\nother\n\ny') + self.assertEqual(result.exit_code, 1) + self.assertIn('No candidates found to switchover to', result.output) # No leader available - mock_get_dcs.return_value.get_cluster = get_cluster_initialized_without_leader - result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'], input='leader\nother\n\ny') - assert result.exit_code == 1 + with patch('patroni.dcs.AbstractDCS.get_cluster', Mock(return_value=get_cluster_initialized_without_leader())): + result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'], input='leader\nother\n\ny') + self.assertEqual(result.exit_code, 1) + self.assertIn('This cluster has no leader', result.output) - @patch('patroni.ctl.get_dcs') - @patch.object(PoolManager, 'request', Mock(return_value=MockResponse())) - def test_failover(self, mock_get_dcs): - mock_get_dcs.return_value = self.e - mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader - mock_get_dcs.return_value.set_failover_value = Mock() - result = self.runner.invoke(ctl, ['failover', 'dummy', '--force'], input='\n') - assert 'For Citus clusters the --group must me specified' in result.output + # Citus cluster, no group number specified + result = self.runner.invoke(ctl, ['switchover', 'dummy', '--force'], input='\n') + self.assertEqual(result.exit_code, 1) + self.assertIn('For Citus clusters the --group must me specified', result.output) + + @patch('patroni.dcs.AbstractDCS.set_failover_value', Mock()) + def test_failover(self): + # No candidate specified result = self.runner.invoke(ctl, ['failover', 'dummy'], input='0\n') - assert 'Failover could be performed only to a specific candidate' in result.output + self.assertIn('Failover could be performed only to a specific candidate', result.output) - @patch('patroni.dcs.dcs_modules', Mock(return_value=['patroni.dcs.dummy', 'patroni.dcs.etcd'])) + # Temp test to check a fallback to switchover if leader is specified + with patch('patroni.ctl._do_failover_or_switchover') as failover_func_mock: + result = self.runner.invoke(ctl, ['failover', '--leader', 'leader', 'dummy'], input='0\n') + self.assertIn('Supplying a leader name using this command is deprecated', result.output) + failover_func_mock.assert_called_once_with('switchover', 'dummy', None, 'leader', None, False) + + # Failover to an async member in sync mode (confirm) + cluster = get_cluster_initialized_with_leader(sync=('leader', 'other')) + cluster.members.append(Member(0, 'async', 28, {'api_url': 'http://127.0.0.1:8012/patroni'})) + cluster.config.data['synchronous_mode'] = True + with patch('patroni.dcs.AbstractDCS.get_cluster', Mock(return_value=cluster)): + result = self.runner.invoke(ctl, + ['failover', 'dummy', '--group', '0', '--candidate', 'async'], input='y\ny') + self.assertIn('Are you sure you want to failover to the asynchronous node async', result.output) + + # Failover to an async member in sync mode (abort) + result = self.runner.invoke(ctl, ['failover', 'dummy', '--group', '0', '--candidate', 'async'], input='N') + self.assertEqual(result.exit_code, 1) + + @patch('patroni.dynamic_loader.iter_modules', Mock(return_value=['patroni.dcs.dummy', 'patroni.dcs.etcd'])) def test_get_dcs(self): - self.assertRaises(PatroniCtlException, get_dcs, {'dummy': {}}, 'dummy', 0) + with click.Context(click.Command('list')) as ctx: + ctx.obj = {'__config': {'dummy': {}}} + self.assertRaises(PatroniCtlException, get_dcs, 'dummy', 0) @patch('patroni.psycopg.connect', psycopg_connect) @patch('patroni.ctl.query_member', Mock(return_value=([['mock column']], None))) - @patch('patroni.ctl.get_dcs') @patch.object(etcd.Client, 'read', etcd_read) - def test_query(self, mock_get_dcs): - mock_get_dcs.return_value = self.e + def test_query(self): # Mutually exclusive for role in self.TEST_ROLES: result = self.runner.invoke(ctl, ['query', 'alpha', '--member', 'abc', '--role', role]) @@ -224,25 +281,29 @@ class TestCtl(unittest.TestCase): def test_query_member(self): with patch('patroni.ctl.get_cursor', Mock(return_value=MockConnect().cursor())): for role in self.TEST_ROLES: - rows = query_member({}, None, None, None, None, role, 'SELECT pg_catalog.pg_is_in_recovery()', {}) + rows = query_member(None, None, None, None, role, 'SELECT pg_catalog.pg_is_in_recovery()', {}) self.assertTrue('False' in str(rows)) with patch.object(MockCursor, 'execute', Mock(side_effect=OperationalError('bla'))): - rows = query_member({}, None, None, None, None, 'replica', 'SELECT pg_catalog.pg_is_in_recovery()', {}) + rows = query_member(None, None, None, None, 'replica', 'SELECT pg_catalog.pg_is_in_recovery()', {}) with patch('patroni.ctl.get_cursor', Mock(return_value=None)): - rows = query_member({}, None, None, None, None, None, 'SELECT pg_catalog.pg_is_in_recovery()', {}) - self.assertTrue('No connection to' in str(rows)) + # No role nor member given -- generic message + rows = query_member(None, None, None, None, None, 'SELECT pg_catalog.pg_is_in_recovery()', {}) + self.assertTrue('No connection is available' in str(rows)) - rows = query_member({}, None, None, None, 'foo', 'replica', 'SELECT pg_catalog.pg_is_in_recovery()', {}) - self.assertTrue('No connection to' in str(rows)) + # Member given -- message pointing to member + rows = query_member(None, None, None, 'foo', None, 'SELECT pg_catalog.pg_is_in_recovery()', {}) + self.assertTrue('No connection to member foo' in str(rows)) + + # Role given -- message pointing to role + rows = query_member(None, None, None, None, 'replica', 'SELECT pg_catalog.pg_is_in_recovery()', {}) + self.assertTrue('No connection to role replica' in str(rows)) with patch('patroni.ctl.get_cursor', Mock(side_effect=OperationalError('bla'))): - rows = query_member({}, None, None, None, None, 'replica', 'SELECT pg_catalog.pg_is_in_recovery()', {}) + rows = query_member(None, None, None, None, 'replica', 'SELECT pg_catalog.pg_is_in_recovery()', {}) - @patch('patroni.ctl.get_dcs') - def test_dsn(self, mock_get_dcs): - mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader + def test_dsn(self): result = self.runner.invoke(ctl, ['dsn', 'alpha']) assert 'host=127.0.0.1 port=5435' in result.output @@ -255,11 +316,8 @@ class TestCtl(unittest.TestCase): result = self.runner.invoke(ctl, ['dsn', 'alpha', '--member', 'dummy']) assert result.exit_code == 1 - @patch.object(PoolManager, 'request') - @patch('patroni.ctl.get_dcs') - def test_reload(self, mock_get_dcs, mock_post): - mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader - + @patch('patroni.ctl.request_patroni') + def test_reload(self, mock_post): result = self.runner.invoke(ctl, ['reload', 'alpha'], input='y') assert 'Failed: reload for member' in result.output @@ -271,10 +329,8 @@ class TestCtl(unittest.TestCase): result = self.runner.invoke(ctl, ['reload', 'alpha'], input='y') assert 'Reload request received for member' in result.output - @patch.object(PoolManager, 'request') - @patch('patroni.ctl.get_dcs') - def test_restart_reinit(self, mock_get_dcs, mock_post): - mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader + @patch('patroni.ctl.request_patroni') + def test_restart_reinit(self, mock_post): mock_post.return_value.status = 503 result = self.runner.invoke(ctl, ['restart', 'alpha'], input='now\ny\n') assert 'Failed: restart for' in result.output @@ -314,7 +370,7 @@ class TestCtl(unittest.TestCase): result = self.runner.invoke(ctl, ['restart', 'alpha', 'other', '--force', '--scheduled', '2300-10-01T14:30']) assert 'Failed: flush scheduled restart' in result.output - with patch('patroni.config.GlobalConfig.is_paused', PropertyMock(return_value=True)): + with patch.object(global_config.__class__, 'is_paused', PropertyMock(return_value=True)): result = self.runner.invoke(ctl, ['restart', 'alpha', 'other', '--force', '--scheduled', '2300-10-01T14:30']) assert result.exit_code == 1 @@ -349,12 +405,10 @@ class TestCtl(unittest.TestCase): assert 'Failed: another restart is already' in result.output assert result.exit_code == 0 - @patch('patroni.ctl.get_dcs') - def test_remove(self, mock_get_dcs): - mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader + def test_remove(self): result = self.runner.invoke(ctl, ['remove', 'dummy'], input='\n') assert 'For Citus clusters the --group must me specified' in result.output - result = self.runner.invoke(ctl, ['-k', 'remove', 'alpha', '--group', '0'], input='alpha\nstandby') + result = self.runner.invoke(ctl, ['remove', 'alpha', '--group', '0'], input='alpha\nstandby') assert 'Please confirm' in result.output assert 'You are about to remove all' in result.output # Not typing an exact confirmation @@ -372,37 +426,36 @@ class TestCtl(unittest.TestCase): assert result.exit_code == 0 def test_ctl(self): - self.runner.invoke(ctl, ['list']) - result = self.runner.invoke(ctl, ['--help']) assert 'Usage:' in result.output def test_get_any_member(self): - for role in self.TEST_ROLES: - self.assertIsNone(get_any_member({}, get_cluster_initialized_without_leader(), None, role=role)) + with click.Context(click.Command('list')) as ctx: + ctx.obj = {'__config': {}} + for role in self.TEST_ROLES: + self.assertIsNone(get_any_member(get_cluster_initialized_without_leader(), None, role=role)) - m = get_any_member({}, get_cluster_initialized_with_leader(), None, role=role) - self.assertEqual(m.name, 'leader') + m = get_any_member(get_cluster_initialized_with_leader(), None, role=role) + self.assertEqual(m.name, 'leader') def test_get_all_members(self): - for role in self.TEST_ROLES: - self.assertEqual(list(get_all_members({}, get_cluster_initialized_without_leader(), None, role=role)), []) + with click.Context(click.Command('list')) as ctx: + ctx.obj = {'__config': {}} + for role in self.TEST_ROLES: + self.assertEqual(list(get_all_members(get_cluster_initialized_without_leader(), None, role=role)), []) - r = list(get_all_members({}, get_cluster_initialized_with_leader(), None, role=role)) + r = list(get_all_members(get_cluster_initialized_with_leader(), None, role=role)) + self.assertEqual(len(r), 1) + self.assertEqual(r[0].name, 'leader') + + r = list(get_all_members(get_cluster_initialized_with_leader(), None, role='replica')) self.assertEqual(len(r), 1) - self.assertEqual(r[0].name, 'leader') + self.assertEqual(r[0].name, 'other') - r = list(get_all_members({}, get_cluster_initialized_with_leader(), None, role='replica')) - self.assertEqual(len(r), 1) - self.assertEqual(r[0].name, 'other') - - self.assertEqual(len(list(get_all_members({}, get_cluster_initialized_without_leader(), - None, role='replica'))), 2) - - @patch('patroni.ctl.get_dcs') - def test_members(self, mock_get_dcs): - mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader + self.assertEqual(len(list(get_all_members(get_cluster_initialized_without_leader(), + None, role='replica'))), 2) + def test_members(self): result = self.runner.invoke(ctl, ['list']) assert '127.0.0.1' in result.output assert result.exit_code == 0 @@ -411,127 +464,100 @@ class TestCtl(unittest.TestCase): result = self.runner.invoke(ctl, ['list', '--group', '0']) assert 'Citus cluster: alpha (group: 0, 12345678901) -' in result.output - with patch('patroni.ctl.load_config', Mock(return_value={'scope': 'alpha'})): + config = get_default_config() + del config['citus'] + with patch('patroni.ctl.load_config', Mock(return_value=config)): result = self.runner.invoke(ctl, ['list']) assert 'Cluster: alpha (12345678901) -' in result.output with patch('patroni.ctl.load_config', Mock(return_value={})): self.runner.invoke(ctl, ['list']) - @patch('patroni.ctl.get_dcs') - def test_list_extended(self, mock_get_dcs): - mock_get_dcs.return_value = self.e - cluster = get_cluster_initialized_with_leader(sync=('leader', 'other')) - mock_get_dcs.return_value.get_cluster = Mock(return_value=cluster) - + def test_list_extended(self): result = self.runner.invoke(ctl, ['list', 'dummy', '--extended', '--timestamp']) assert '2100' in result.output assert 'Scheduled restart' in result.output - @patch('patroni.ctl.get_dcs') - def test_topology(self, mock_get_dcs): - mock_get_dcs.return_value = self.e + def test_topology(self): cluster = get_cluster_initialized_with_leader() - cascade_member = Member(0, 'cascade', 28, {'conn_url': 'postgres://replicator:rep-pass@127.0.0.1:5437/postgres', - 'api_url': 'http://127.0.0.1:8012/patroni', - 'state': 'running', - 'tags': {'replicatefrom': 'other'}, - }) - cascade_member_wrong_tags = Member(0, 'wrong_cascade', 28, - {'conn_url': 'postgres://replicator:rep-pass@127.0.0.1:5438/postgres', - 'api_url': 'http://127.0.0.1:8013/patroni', - 'state': 'running', - 'tags': {'replicatefrom': 'nonexistinghost'}, - }) - cluster.members.append(cascade_member) - cluster.members.append(cascade_member_wrong_tags) - mock_get_dcs.return_value.get_cluster = Mock(return_value=cluster) - result = self.runner.invoke(ctl, ['topology', 'dummy']) - assert '+\n| 0 | leader | 127.0.0.1:5435 | Leader |' in result.output - assert '|\n| 0 | + other | 127.0.0.1:5436 | Replica |' in result.output - assert '|\n| 0 | + cascade | 127.0.0.1:5437 | Replica |' in result.output - assert '|\n| 0 | + wrong_cascade | 127.0.0.1:5438 | Replica |' in result.output + cluster.members.append(Member(0, 'cascade', 28, + {'conn_url': 'postgres://replicator:rep-pass@127.0.0.1:5437/postgres', + 'api_url': 'http://127.0.0.1:8012/patroni', 'state': 'running', + 'tags': {'replicatefrom': 'other'}})) + cluster.members.append(Member(0, 'wrong_cascade', 28, + {'conn_url': 'postgres://replicator:rep-pass@127.0.0.1:5438/postgres', + 'api_url': 'http://127.0.0.1:8013/patroni', 'state': 'running', + 'tags': {'replicatefrom': 'nonexistinghost'}})) + with patch('patroni.dcs.AbstractDCS.get_cluster', Mock(return_value=cluster)): + result = self.runner.invoke(ctl, ['topology', 'dummy']) + assert '+\n| 0 | leader | 127.0.0.1:5435 | Leader |' in result.output + assert '|\n| 0 | + other | 127.0.0.1:5436 | Replica |' in result.output + assert '|\n| 0 | + cascade | 127.0.0.1:5437 | Replica |' in result.output + assert '|\n| 0 | + wrong_cascade | 127.0.0.1:5438 | Replica |' in result.output - cluster = get_cluster_initialized_without_leader() - mock_get_dcs.return_value.get_cluster = Mock(return_value=cluster) - result = self.runner.invoke(ctl, ['topology', 'dummy']) - assert '+\n| 0 | + leader | 127.0.0.1:5435 | Replica |' in result.output - assert '|\n| 0 | + other | 127.0.0.1:5436 | Replica |' in result.output - - @patch('patroni.ctl.get_dcs') - @patch.object(PoolManager, 'request', Mock(return_value=MockResponse())) - def test_flush_restart(self, mock_get_dcs): - mock_get_dcs.return_value = self.e - mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader + with patch('patroni.dcs.AbstractDCS.get_cluster', Mock(return_value=get_cluster_initialized_without_leader())): + result = self.runner.invoke(ctl, ['topology', 'dummy']) + assert '+\n| 0 | + leader | 127.0.0.1:5435 | Replica |' in result.output + assert '|\n| 0 | + other | 127.0.0.1:5436 | Replica |' in result.output + @patch('patroni.dcs.AbstractDCS.get_cluster', Mock(return_value=get_cluster_initialized_with_leader())) + def test_flush_restart(self): for role in self.TEST_ROLES: - result = self.runner.invoke(ctl, ['-k', 'flush', 'dummy', 'restart', '-r', role], input='y') + result = self.runner.invoke(ctl, ['flush', 'dummy', 'restart', '-r', role], input='y') assert 'No scheduled restart' in result.output result = self.runner.invoke(ctl, ['flush', 'dummy', 'restart', '--force']) assert 'Success: flush scheduled restart' in result.output - with patch.object(PoolManager, 'request', return_value=MockResponse(404)): + with patch('patroni.ctl.request_patroni', Mock(return_value=MockResponse(404))): result = self.runner.invoke(ctl, ['flush', 'dummy', 'restart', '--force']) assert 'Failed: flush scheduled restart' in result.output - @patch('patroni.ctl.get_dcs') - @patch.object(PoolManager, 'request', Mock(return_value=MockResponse())) - def test_flush_switchover(self, mock_get_dcs): - mock_get_dcs.return_value = self.e - - mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader - result = self.runner.invoke(ctl, ['flush', 'dummy', 'switchover']) - assert 'No pending scheduled switchover' in result.output + def test_flush_switchover(self): + with patch('patroni.dcs.AbstractDCS.get_cluster', Mock(return_value=get_cluster_initialized_with_leader())): + result = self.runner.invoke(ctl, ['flush', 'dummy', 'switchover']) + assert 'No pending scheduled switchover' in result.output scheduled_at = datetime.now(tzutc) + timedelta(seconds=600) - mock_get_dcs.return_value.get_cluster = Mock( - return_value=get_cluster_initialized_with_leader(Failover(1, 'a', 'b', scheduled_at))) - result = self.runner.invoke(ctl, ['flush', 'dummy', 'switchover']) - assert result.output.startswith('Success: ') + with patch('patroni.dcs.AbstractDCS.get_cluster', + Mock(return_value=get_cluster_initialized_with_leader(Failover(1, 'a', 'b', scheduled_at)))): + result = self.runner.invoke(ctl, ['-k', 'flush', 'dummy', 'switchover']) + assert result.output.startswith('Success: ') - mock_get_dcs.return_value.manual_failover = Mock() - with patch.object(PoolManager, 'request', side_effect=[MockResponse(409), Exception]): - result = self.runner.invoke(ctl, ['flush', 'dummy', 'switchover']) - assert 'Could not find any accessible member of cluster' in result.output + with patch('patroni.ctl.request_patroni', side_effect=[MockResponse(409), Exception]), \ + patch('patroni.dcs.AbstractDCS.manual_failover', Mock()): + result = self.runner.invoke(ctl, ['flush', 'dummy', 'switchover']) + assert 'Could not find any accessible member of cluster' in result.output - @patch.object(PoolManager, 'request') - @patch('patroni.ctl.get_dcs') @patch('patroni.ctl.polling_loop', Mock(return_value=[1])) - def test_pause_cluster(self, mock_get_dcs, mock_post): - mock_get_dcs.return_value = self.e - mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader + def test_pause_cluster(self): + with patch('patroni.ctl.request_patroni', Mock(return_value=MockResponse(500))): + result = self.runner.invoke(ctl, ['pause', 'dummy']) + assert 'Failed' in result.output - mock_post.return_value.status = 500 - result = self.runner.invoke(ctl, ['pause', 'dummy']) - assert 'Failed' in result.output - - mock_post.return_value.status = 200 - with patch('patroni.config.GlobalConfig.is_paused', PropertyMock(return_value=True)): + with patch.object(global_config.__class__, 'is_paused', PropertyMock(return_value=True)): result = self.runner.invoke(ctl, ['pause', 'dummy']) assert 'Cluster is already paused' in result.output result = self.runner.invoke(ctl, ['pause', 'dummy', '--wait']) assert "'pause' request sent" in result.output - mock_get_dcs.return_value.get_cluster = Mock(side_effect=[get_cluster_initialized_with_leader(), - get_cluster(None, None, [], None, None)]) - self.runner.invoke(ctl, ['pause', 'dummy', '--wait']) - member = Member(1, 'other', 28, {}) - mock_get_dcs.return_value.get_cluster = Mock(side_effect=[get_cluster_initialized_with_leader(), - get_cluster(None, None, [member], None, None)]) - self.runner.invoke(ctl, ['pause', 'dummy', '--wait']) - @patch.object(PoolManager, 'request') - @patch('patroni.ctl.get_dcs') - def test_resume_cluster(self, mock_get_dcs, mock_post): - mock_get_dcs.return_value = self.e - mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader + with patch('patroni.dcs.AbstractDCS.get_cluster', + Mock(side_effect=[get_cluster_initialized_with_leader(), get_cluster(None, None, [], None, None)])): + self.runner.invoke(ctl, ['pause', 'dummy', '--wait']) + with patch('patroni.dcs.AbstractDCS.get_cluster', + Mock(side_effect=[get_cluster_initialized_with_leader(), + get_cluster(None, None, [Member(1, 'other', 28, {})], None, None)])): + self.runner.invoke(ctl, ['pause', 'dummy', '--wait']) + @patch('patroni.ctl.request_patroni') + @patch('patroni.dcs.AbstractDCS.get_cluster', Mock(return_value=get_cluster_initialized_with_leader())) + def test_resume_cluster(self, mock_post): mock_post.return_value.status = 200 - with patch('patroni.config.GlobalConfig.is_paused', PropertyMock(return_value=False)): + with patch.object(global_config.__class__, 'is_paused', PropertyMock(return_value=False)): result = self.runner.invoke(ctl, ['resume', 'dummy']) assert 'Cluster is not paused' in result.output - with patch('patroni.config.GlobalConfig.is_paused', PropertyMock(return_value=True)): + with patch.object(global_config.__class__, 'is_paused', PropertyMock(return_value=True)): result = self.runner.invoke(ctl, ['resume', 'dummy']) assert 'Success' in result.output @@ -633,67 +659,53 @@ class TestCtl(unittest.TestCase): with patch('shutil.which', Mock(return_value=e)): self.assertRaises(PatroniCtlException, invoke_editor, 'foo: bar\n', 'test') - @patch('patroni.ctl.get_dcs') - def test_show_config(self, mock_get_dcs): - mock_get_dcs.return_value = self.e - mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader + def test_show_config(self): self.runner.invoke(ctl, ['show-config', 'dummy']) - @patch('patroni.ctl.get_dcs') @patch('subprocess.call', Mock(return_value=0)) - def test_edit_config(self, mock_get_dcs): - mock_get_dcs.return_value = self.e - mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader - mock_get_dcs.return_value.set_config_value = Mock(return_value=False) + def test_edit_config(self): os.environ['EDITOR'] = 'true' self.runner.invoke(ctl, ['edit-config', 'dummy']) self.runner.invoke(ctl, ['edit-config', 'dummy', '-s', 'foo=bar']) self.runner.invoke(ctl, ['edit-config', 'dummy', '--replace', 'postgres0.yml']) self.runner.invoke(ctl, ['edit-config', 'dummy', '--apply', '-'], input='foo: bar') self.runner.invoke(ctl, ['edit-config', 'dummy', '--force', '--apply', '-'], input='foo: bar') - mock_get_dcs.return_value.set_config_value.return_value = True - self.runner.invoke(ctl, ['edit-config', 'dummy', '--force', '--apply', '-'], input='foo: bar') - mock_get_dcs.return_value.get_cluster = Mock(return_value=Cluster.empty()) - result = self.runner.invoke(ctl, ['edit-config', 'dummy']) - assert result.exit_code == 1 - assert 'The config key does not exist in the cluster dummy' in result.output + with patch('patroni.dcs.etcd.Etcd.set_config_value', Mock(return_value=True)): + self.runner.invoke(ctl, ['edit-config', 'dummy', '--force', '--apply', '-'], input='foo: bar') + with patch('patroni.dcs.AbstractDCS.get_cluster', Mock(return_value=Cluster.empty())): + result = self.runner.invoke(ctl, ['edit-config', 'dummy']) + assert result.exit_code == 1 + assert 'The config key does not exist in the cluster dummy' in result.output - @patch('patroni.ctl.get_dcs') - def test_version(self, mock_get_dcs): - mock_get_dcs.return_value = self.e - mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader - with patch.object(PoolManager, 'request') as mocked: - result = self.runner.invoke(ctl, ['version']) - assert 'patronictl version' in result.output - mocked.return_value.data = b'{"patroni":{"version":"1.2.3"},"server_version": 100001}' - result = self.runner.invoke(ctl, ['version', 'dummy']) - assert '1.2.3' in result.output - with patch.object(PoolManager, 'request', Mock(side_effect=Exception)): - result = self.runner.invoke(ctl, ['version', 'dummy']) - assert 'failed to get version' in result.output + @patch('patroni.ctl.request_patroni') + def test_version(self, mock_request): + result = self.runner.invoke(ctl, ['version']) + assert 'patronictl version' in result.output + mock_request.return_value.data = b'{"patroni":{"version":"1.2.3"},"server_version": 100001}' + result = self.runner.invoke(ctl, ['version', 'dummy']) + assert '1.2.3' in result.output + mock_request.side_effect = Exception + result = self.runner.invoke(ctl, ['version', 'dummy']) + assert 'failed to get version' in result.output - @patch('patroni.ctl.get_dcs') - def test_history(self, mock_get_dcs): - mock_get_dcs.return_value.get_cluster = Mock() - mock_get_dcs.return_value.get_cluster.return_value.history.lines = [[1, 67176, 'no recovery target specified']] - result = self.runner.invoke(ctl, ['history']) - assert 'Reason' in result.output + def test_history(self): + with patch('patroni.dcs.AbstractDCS.get_cluster') as mock_get_cluster: + mock_get_cluster.return_value.history.lines = [[1, 67176, 'no recovery target specified']] + result = self.runner.invoke(ctl, ['history']) + assert 'Reason' in result.output def test_format_pg_version(self): self.assertEqual(format_pg_version(100001), '10.1') self.assertEqual(format_pg_version(90605), '9.6.5') - @patch('patroni.ctl.get_dcs') - def test_get_members(self, mock_get_dcs): - mock_get_dcs.return_value = self.e - mock_get_dcs.return_value.get_cluster = get_cluster_not_initialized_without_leader - result = self.runner.invoke(ctl, ['reinit', 'dummy']) - assert "cluster doesn\'t have any members" in result.output + def test_get_members(self): + with patch('patroni.dcs.AbstractDCS.get_cluster', + Mock(return_value=get_cluster_not_initialized_without_leader())): + result = self.runner.invoke(ctl, ['reinit', 'dummy']) + assert "cluster doesn\'t have any members" in result.output @patch('time.sleep', Mock()) - @patch('patroni.ctl.get_dcs') - def test_reinit_wait(self, mock_get_dcs): - mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader + def test_reinit_wait(self): with patch.object(PoolManager, 'request') as mocked: mocked.side_effect = [Mock(data=s, status=200) for s in [b"reinitialize", b'{"state":"creating replica"}', b'{"state":"running"}']] diff --git a/tests/test_etcd.py b/tests/test_etcd.py index 90402b5f..874aac5c 100644 --- a/tests/test_etcd.py +++ b/tests/test_etcd.py @@ -274,6 +274,8 @@ class TestEtcd(unittest.TestCase): cluster = self.etcd.get_cluster() self.assertIsInstance(cluster, Cluster) self.assertIsInstance(cluster.workers[1], Cluster) + self.etcd._base_path = '/service/nocluster' + self.assertTrue(self.etcd.get_cluster().is_empty()) def test_touch_member(self): self.assertFalse(self.etcd.touch_member('')) diff --git a/tests/test_etcd3.py b/tests/test_etcd3.py index 9aed7eb1..10ab1ea5 100644 --- a/tests/test_etcd3.py +++ b/tests/test_etcd3.py @@ -6,8 +6,8 @@ import urllib3 from mock import Mock, PropertyMock, patch from patroni.dcs.etcd import DnsCachingResolver from patroni.dcs.etcd3 import PatroniEtcd3Client, Cluster, Etcd3, Etcd3Client, \ - Etcd3Error, Etcd3ClientError, RetryFailedError, InvalidAuthToken, Unavailable, \ - Unknown, UnsupportedEtcdVersion, UserEmpty, AuthFailed, base64_encode + Etcd3Error, Etcd3ClientError, ReAuthenticateMode, RetryFailedError, InvalidAuthToken, Unavailable, \ + Unknown, UnsupportedEtcdVersion, UserEmpty, AuthFailed, AuthOldRevision, base64_encode from threading import Thread from . import SleepException, MockResponse @@ -161,9 +161,16 @@ class TestPatroniEtcd3Client(BaseTestEtcd3): mock_urlopen.return_value.content = '{"code":16,"error":"etcdserver: invalid auth token"}' self.assertRaises(InvalidAuthToken, self.client.deleteprefix, 'foo') with patch.object(PatroniEtcd3Client, 'authenticate', Mock(return_value=True)): - self.assertRaises(InvalidAuthToken, self.client.deleteprefix, 'foo') + retry = self.etcd3._retry.copy() + with patch('time.time', Mock(side_effect=[0, 10, 20, 30, 40])): + self.assertRaises(InvalidAuthToken, retry, self.client.deleteprefix, 'foo', retry=retry) self.client.username = None - self.assertRaises(InvalidAuthToken, self.client.deleteprefix, 'foo') + self.client._reauthenticate_reason = ReAuthenticateMode.NOT_REQUIRED + retry = self.etcd3._retry.copy() + self.assertRaises(InvalidAuthToken, retry, self.client.deleteprefix, 'foo', retry=retry) + mock_urlopen.return_value.content = '{"code":3,"error":"etcdserver: revision of auth store is old"}' + self.client._reauthenticate_reason = ReAuthenticateMode.NOT_REQUIRED + self.assertRaises(AuthOldRevision, retry, self.client.deleteprefix, 'foo', retry=retry) def test__handle_server_response(self): response = MockResponse() diff --git a/tests/test_ha.py b/tests/test_ha.py index b35ebc52..40063018 100644 --- a/tests/test_ha.py +++ b/tests/test_ha.py @@ -4,9 +4,10 @@ import os import sys from mock import Mock, MagicMock, PropertyMock, patch, mock_open +from patroni import global_config from patroni.collections import CaseInsensitiveSet from patroni.config import Config -from patroni.dcs import Cluster, ClusterConfig, Failover, Leader, Member, get_dcs, SyncState, TimelineHistory +from patroni.dcs import Cluster, ClusterConfig, Failover, Leader, Member, get_dcs, Status, SyncState, TimelineHistory from patroni.dcs.etcd import AbstractEtcdClientWithFailover from patroni.exceptions import DCSError, PostgresConnectionException, PatroniFatalException from patroni.ha import Ha, _MemberStatus @@ -39,7 +40,7 @@ def get_cluster(initialize, leader, members, failover, sync, cluster_config=None history = TimelineHistory(1, '[[1,67197376,"no recovery target specified","' + t + '","foo"]]', [(1, 67197376, 'no recovery target specified', t, 'foo')]) cluster_config = cluster_config or ClusterConfig(1, {'check_timeline': True}, 1) - return Cluster(initialize, cluster_config, leader, 10, members, failover, sync, history, None, failsafe) + return Cluster(initialize, cluster_config, leader, Status(10, None), members, failover, sync, history, failsafe) def get_cluster_not_initialized_without_leader(cluster_config=None): @@ -94,11 +95,12 @@ def get_cluster_initialized_with_leader_and_failsafe(): def get_node_status(reachable=True, in_recovery=True, dcs_last_seen=0, timeline=2, wal_position=10, nofailover=False, - watchdog_failed=False): + watchdog_failed=False, failover_priority=1): def fetch_node_status(e): tags = {} if nofailover: tags['nofailover'] = True + tags['failover_priority'] = failover_priority return _MemberStatus(e, reachable, in_recovery, wal_position, {'tags': tags, 'watchdog_failed': watchdog_failed, 'dcs_last_seen': dcs_last_seen, 'timeline': timeline}) @@ -153,6 +155,7 @@ zookeeper: 'postmaster_start_time': str(postmaster_start_time)} self.watchdog = Watchdog(self.config) self.request = lambda *args, **kwargs: requests_get(args[0].api_url, *args[1:], **kwargs) + self.failover_priority = 1 def run_async(self, func, args=()): @@ -167,6 +170,7 @@ def run_async(self, func, args=()): @patch.object(Postgresql, 'is_primary', Mock(return_value=True)) @patch.object(Postgresql, 'timeline_wal_position', Mock(return_value=(1, 10, 1))) @patch.object(Postgresql, '_cluster_info_state_get', Mock(return_value=10)) +@patch.object(Postgresql, 'slots', Mock(return_value={'l': 100})) @patch.object(Postgresql, 'data_directory_empty', Mock(return_value=False)) @patch.object(Postgresql, 'controldata', Mock(return_value={ 'Database system identifier': SYSID, @@ -214,6 +218,7 @@ class TestHa(PostgresInit): self.ha = Ha(MockPatroni(self.p, self.e)) self.ha.old_cluster = self.e.get_cluster() self.ha.cluster = get_cluster_initialized_without_leader() + global_config.update(self.ha.cluster) self.ha.load_cluster_from_dcs = Mock() def test_update_lock(self): @@ -248,8 +253,10 @@ class TestHa(PostgresInit): @patch('patroni.dcs.etcd.Etcd.initialize', return_value=True) def test_bootstrap_as_standby_leader(self, initialize): self.p.data_directory_empty = true + self.ha.cluster = get_cluster_not_initialized_without_leader( + cluster_config=ClusterConfig(1, {"standby_cluster": {"port": 5432}}, 1)) + global_config.update(self.ha.cluster) self.ha.cluster = get_cluster_not_initialized_without_leader(cluster_config=ClusterConfig(0, {}, 0)) - self.ha.patroni.config._dynamic_configuration = {"standby_cluster": {"port": 5432}} self.assertEqual(self.ha.run_cycle(), 'trying to bootstrap a new standby leader') def test_bootstrap_waiting_for_standby_leader(self): @@ -315,7 +322,7 @@ class TestHa(PostgresInit): self.ha.state_handler.cancellable._process = Mock() self.ha._crash_recovery_started -= 600 self.ha.cluster.config.data.update({'maximum_lag_on_failover': 10}) - self.ha.global_config = self.ha.patroni.config.get_global_config(self.ha.cluster) + global_config.update(self.ha.cluster) self.assertEqual(self.ha.run_cycle(), 'terminated crash recovery because of startup timeout') @patch.object(Rewind, 'ensure_clean_shutdown', Mock()) @@ -435,6 +442,7 @@ class TestHa(PostgresInit): def test_promote_without_watchdog(self): self.ha.has_lock = true + self.p.is_primary = true with patch.object(Watchdog, 'activate', Mock(return_value=False)): self.assertEqual(self.ha.run_cycle(), 'Demoting self because watchdog could not be activated') self.p.is_primary = false @@ -505,7 +513,7 @@ class TestHa(PostgresInit): def test_check_failsafe_topology(self): self.ha.load_cluster_from_dcs = Mock(side_effect=DCSError('Etcd is not responding properly')) self.ha.cluster = get_cluster_initialized_with_leader_and_failsafe() - self.ha.global_config = self.ha.patroni.config.get_global_config(self.ha.cluster) + global_config.update(self.ha.cluster) self.ha.dcs._last_failsafe = self.ha.cluster.failsafe self.assertEqual(self.ha.run_cycle(), 'demoting self because DCS is not accessible and I was a leader') self.ha.state_handler.name = self.ha.cluster.leader.name @@ -525,7 +533,7 @@ class TestHa(PostgresInit): def test_no_dcs_connection_primary_failsafe(self): self.ha.load_cluster_from_dcs = Mock(side_effect=DCSError('Etcd is not responding properly')) self.ha.cluster = get_cluster_initialized_with_leader_and_failsafe() - self.ha.global_config = self.ha.patroni.config.get_global_config(self.ha.cluster) + global_config.update(self.ha.cluster) self.ha.dcs._last_failsafe = self.ha.cluster.failsafe self.ha.state_handler.name = self.ha.cluster.leader.name self.assertEqual(self.ha.run_cycle(), @@ -542,7 +550,7 @@ class TestHa(PostgresInit): def test_no_dcs_connection_replica_failsafe(self): self.ha.load_cluster_from_dcs = Mock(side_effect=DCSError('Etcd is not responding properly')) self.ha.cluster = get_cluster_initialized_with_leader_and_failsafe() - self.ha.global_config = self.ha.patroni.config.get_global_config(self.ha.cluster) + global_config.update(self.ha.cluster) self.ha.update_failsafe({'name': 'leader', 'api_url': 'http://127.0.0.1:8008/patroni', 'conn_url': 'postgres://127.0.0.1:5432/postgres', 'slots': {'foo': 1000}}) self.p.is_primary = false @@ -614,6 +622,7 @@ class TestHa(PostgresInit): self.ha.cluster = get_cluster_not_initialized_without_leader() self.e.initialize = true self.ha.bootstrap() + self.p.is_primary = true with patch.object(Watchdog, 'activate', Mock(return_value=False)), \ patch('patroni.ha.logger.error') as mock_logger: self.assertEqual(self.ha.post_bootstrap(), 'running post_bootstrap') @@ -687,110 +696,289 @@ class TestHa(PostgresInit): @patch('patroni.postgresql.citus.CitusHandler.is_coordinator', Mock(return_value=False)) def test_manual_failover_from_leader(self): + self.ha.has_lock = true # I am the leader + + # to me + with patch('patroni.ha.logger.warning') as mock_warning: + self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, '', self.p.name, None)) + self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock') + mock_warning.assert_called_with('%s: I am already the leader, no need to %s', 'manual failover', 'failover') + + # to a non-existent candidate + with patch('patroni.ha.logger.warning') as mock_warning: + self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, '', 'blabla', None)) + self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock') + mock_warning.assert_called_with( + '%s: no healthy members found, %s is not possible', 'manual failover', 'failover') + + # to an existent candidate self.ha.fetch_node_status = get_node_status() - self.ha.has_lock = true - self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', '', None)) - self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock') - self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, '', self.p.name, None)) - self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock') - self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, '', 'blabla', None)) - self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock') - f = Failover(0, self.p.name, '', None) - self.ha.cluster = get_cluster_initialized_with_leader(f) + self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, '', 'b', None)) + self.ha.cluster.members.append(Member(0, 'b', 28, {'api_url': 'http://127.0.0.1:8011/patroni'})) self.assertEqual(self.ha.run_cycle(), 'manual failover: demoting myself') + + # to a candidate on an older timeline + with patch('patroni.ha.logger.info') as mock_info: + self.ha.fetch_node_status = get_node_status(timeline=1) + self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock') + self.assertEqual(mock_info.call_args_list[0][0], + ('Timeline %s of member %s is behind the cluster timeline %s', 1, 'b', 2)) + + # to a lagging candidate + with patch('patroni.ha.logger.info') as mock_info: + self.ha.fetch_node_status = get_node_status(wal_position=1) + self.ha.cluster.config.data.update({'maximum_lag_on_failover': 5}) + self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock') + self.assertEqual(mock_info.call_args_list[0][0], + ('Member %s exceeds maximum replication lag', 'b')) + self.ha.cluster.members.pop() + + @patch('patroni.postgresql.citus.CitusHandler.is_coordinator', Mock(return_value=False)) + def test_manual_switchover_from_leader(self): + self.ha.has_lock = true # I am the leader + + self.ha.fetch_node_status = get_node_status() + + # different leader specified in failover key, no candidate + with patch('patroni.ha.logger.warning') as mock_warning: + self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', '', None)) + self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock') + mock_warning.assert_called_with( + '%s: leader name does not match: %s != %s', 'switchover', 'blabla', 'postgresql0') + + # no candidate + self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, self.p.name, '', None)) + self.assertEqual(self.ha.run_cycle(), 'switchover: demoting myself') + self.ha._rewind.rewind_or_reinitialize_needed_and_possible = true - self.assertEqual(self.ha.run_cycle(), 'manual failover: demoting myself') - self.ha.fetch_node_status = get_node_status(nofailover=True) - self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock') - self.ha.fetch_node_status = get_node_status(watchdog_failed=True) - self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock') - self.ha.fetch_node_status = get_node_status(timeline=1) - self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock') - self.ha.fetch_node_status = get_node_status(wal_position=1) - self.ha.cluster.config.data.update({'maximum_lag_on_failover': 5}) - self.ha.global_config = self.ha.patroni.config.get_global_config(self.ha.cluster) - self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock') - # manual failover from the previous leader to us won't happen if we hold the nofailover flag - self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, None)) - self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock') + self.assertEqual(self.ha.run_cycle(), 'switchover: demoting myself') - # Failover scheduled time must include timezone - scheduled = datetime.datetime.now() - self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled)) - self.ha.run_cycle() + # other members with failover_limitation_s + with patch('patroni.ha.logger.info') as mock_info: + self.ha.fetch_node_status = get_node_status(nofailover=True) + self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock') + self.assertEqual(mock_info.call_args_list[0][0], ('Member %s is %s', 'leader', 'not allowed to promote')) + with patch('patroni.ha.logger.info') as mock_info: + self.ha.fetch_node_status = get_node_status(watchdog_failed=True) + self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock') + self.assertEqual(mock_info.call_args_list[0][0], ('Member %s is %s', 'leader', 'not watchdog capable')) + with patch('patroni.ha.logger.info') as mock_info: + self.ha.fetch_node_status = get_node_status(timeline=1) + self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock') + self.assertEqual(mock_info.call_args_list[0][0], + ('Timeline %s of member %s is behind the cluster timeline %s', 1, 'leader', 2)) + with patch('patroni.ha.logger.info') as mock_info: + self.ha.fetch_node_status = get_node_status(wal_position=1) + self.ha.cluster.config.data.update({'maximum_lag_on_failover': 5}) + global_config.update(self.ha.cluster) + self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock') + self.assertEqual(mock_info.call_args_list[0][0], ('Member %s exceeds maximum replication lag', 'leader')) + @patch('patroni.postgresql.citus.CitusHandler.is_coordinator', Mock(return_value=False)) + def test_scheduled_switchover_from_leader(self): + self.ha.has_lock = true # I am the leader + + self.ha.fetch_node_status = get_node_status() + + # switchover scheduled time must include timezone + with patch('patroni.ha.logger.warning') as mock_warning: + scheduled = datetime.datetime.now() + self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, self.p.name, 'blabla', scheduled)) + self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock') + self.assertIn('Incorrect value of scheduled_at: %s', mock_warning.call_args_list[0][0]) + + # scheduled now scheduled = datetime.datetime.utcnow().replace(tzinfo=tzutc) - self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled)) - self.assertEqual('no action. I am (postgresql0), the leader with the lock', self.ha.run_cycle()) + self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, self.p.name, 'b', scheduled)) + self.ha.cluster.members.append(Member(0, 'b', 28, {'api_url': 'http://127.0.0.1:8011/patroni'})) + self.assertEqual('switchover: demoting myself', self.ha.run_cycle()) - scheduled = scheduled + datetime.timedelta(seconds=30) - self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled)) - self.assertEqual('no action. I am (postgresql0), the leader with the lock', self.ha.run_cycle()) + # scheduled in the future + with patch('patroni.ha.logger.info') as mock_info: + scheduled = scheduled + datetime.timedelta(seconds=30) + self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, self.p.name, 'blabla', scheduled)) + self.assertEqual('no action. I am (postgresql0), the leader with the lock', self.ha.run_cycle()) + self.assertIn('Awaiting %s at %s (in %.0f seconds)', mock_info.call_args_list[0][0]) - scheduled = scheduled + datetime.timedelta(seconds=-600) - self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled)) - self.assertEqual('no action. I am (postgresql0), the leader with the lock', self.ha.run_cycle()) + # stale value + with patch('patroni.ha.logger.warning') as mock_warning: + scheduled = scheduled + datetime.timedelta(seconds=-600) + self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, self.p.name, 'b', scheduled)) + self.ha.cluster.members.append(Member(0, 'b', 28, {'api_url': 'http://127.0.0.1:8011/patroni'})) + self.assertEqual('no action. I am (postgresql0), the leader with the lock', self.ha.run_cycle()) + self.assertIn('Found a stale %s value, cleaning up: %s', mock_warning.call_args_list[0][0]) - scheduled = None - self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled)) - self.assertEqual('no action. I am (postgresql0), the leader with the lock', self.ha.run_cycle()) + def test_manual_switchover_from_leader_in_pause(self): + self.ha.has_lock = true # I am the leader + self.ha.is_paused = true + + # no candidate + self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, self.p.name, '', None)) + with patch('patroni.ha.logger.warning') as mock_warning: + self.assertEqual('PAUSE: no action. I am (postgresql0), the leader with the lock', self.ha.run_cycle()) + mock_warning.assert_called_with( + '%s is possible only to a specific candidate in a paused state', 'Switchover') def test_manual_failover_from_leader_in_pause(self): self.ha.has_lock = true + self.ha.fetch_node_status = get_node_status() self.ha.is_paused = true - scheduled = datetime.datetime.now() - self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled)) - self.assertEqual('PAUSE: no action. I am (postgresql0), the leader with the lock', self.ha.run_cycle()) - self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, self.p.name, '', None)) - self.assertEqual('PAUSE: no action. I am (postgresql0), the leader with the lock', self.ha.run_cycle()) + + # failover from me, candidate is healthy + self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, None, 'b', None)) + self.ha.cluster.members.append(Member(0, 'b', 28, {'api_url': 'http://127.0.0.1:8011/patroni'})) + self.assertEqual('PAUSE: manual failover: demoting myself', self.ha.run_cycle()) + self.ha.cluster.members.pop() def test_manual_failover_from_leader_in_synchronous_mode(self): - self.ha.has_lock = true self.ha.is_synchronous_mode = true self.ha.process_sync_replication = Mock() - self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, self.p.name, 'a', None), (self.p.name, None)) - self.assertEqual('no action. I am (postgresql0), the leader with the lock', self.ha.run_cycle()) - self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, self.p.name, 'a', None), (self.p.name, 'a')) - self.ha.is_failover_possible = true + self.ha.fetch_node_status = get_node_status() + + # I am the leader + self.p.is_primary = true + self.ha.has_lock = true + + # the candidate is not in sync members but we allow failover to an async candidate + self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, None, 'b', None), sync=(self.p.name, 'a')) + self.ha.cluster.members.append(Member(0, 'b', 28, {'api_url': 'http://127.0.0.1:8011/patroni'})) self.assertEqual('manual failover: demoting myself', self.ha.run_cycle()) + self.ha.cluster.members.pop() + + def test_manual_switchover_from_leader_in_synchronous_mode(self): + self.ha.is_synchronous_mode = true + self.ha.process_sync_replication = Mock() + + # I am the leader + self.p.is_primary = true + self.ha.has_lock = true + + # candidate specified is not in sync members + with patch('patroni.ha.logger.warning') as mock_warning: + self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, self.p.name, 'a', None), + sync=(self.p.name, 'blabla')) + self.assertEqual('no action. I am (postgresql0), the leader with the lock', self.ha.run_cycle()) + self.assertEqual(mock_warning.call_args_list[0][0], + ('%s candidate=%s does not match with sync_standbys=%s', 'Switchover', 'a', 'blabla')) + + # the candidate is in sync members and is healthy + self.ha.fetch_node_status = get_node_status(wal_position=305419896) + self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, self.p.name, 'a', None), + sync=(self.p.name, 'a')) + self.ha.cluster.members.append(Member(0, 'a', 28, {'api_url': 'http://127.0.0.1:8011/patroni'})) + self.assertEqual('switchover: demoting myself', self.ha.run_cycle()) + + # the candidate is in sync members but is not healthy + with patch('patroni.ha.logger.info') as mock_info: + self.ha.fetch_node_status = get_node_status(nofailover=true) + self.assertEqual('no action. I am (postgresql0), the leader with the lock', self.ha.run_cycle()) + self.assertEqual(mock_info.call_args_list[0][0], ('Member %s is %s', 'a', 'not allowed to promote')) def test_manual_failover_process_no_leader(self): self.p.is_primary = false - self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', self.p.name, None)) - self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', 'leader', None)) self.p.set_role('replica') - self.assertEqual(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock') - self.ha.fetch_node_status = get_node_status() # accessible, in_recovery - self.assertEqual(self.ha.run_cycle(), 'following a different leader because i am not the healthiest node') - self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, self.p.name, '', None)) - self.assertEqual(self.ha.run_cycle(), 'following a different leader because i am not the healthiest node') - self.ha.fetch_node_status = get_node_status(reachable=False) # inaccessible, in_recovery + + # failover to another member, fetch_node_status for candidate fails + with patch('patroni.ha.logger.warning') as mock_warning: + self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', 'leader', None)) + self.assertEqual(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock') + self.assertEqual(mock_warning.call_args_list[1][0], + ('%s: member %s is %s', 'manual failover', 'leader', 'not reachable')) + + # failover to another member, candidate is accessible, in_recovery self.p.set_role('replica') - self.assertEqual(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock') - # set failover flag to True for all members of the cluster + self.ha.fetch_node_status = get_node_status() + self.assertEqual(self.ha.run_cycle(), 'following a different leader because i am not the healthiest node') + + # set nofailover flag to True for all members of the cluster # this should elect the current member, as we are not going to call the API for it. self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', 'other', None)) - self.ha.fetch_node_status = get_node_status(nofailover=True) # accessible, in_recovery - self.p.set_role('replica') + self.ha.fetch_node_status = get_node_status(nofailover=True) self.assertEqual(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock') - # same as previous, but set the current member to nofailover. In no case it should be elected as a leader + + # failover to me but I am set to nofailover. In no case I should be elected as a leader + self.p.set_role('replica') + self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', 'postgresql0', None)) self.ha.patroni.nofailover = True self.assertEqual(self.ha.run_cycle(), 'following a different leader because I am not allowed to promote') + self.ha.patroni.nofailover = False + + # failover to another member that is on an older timeline (only failover_limitation() is checked) + with patch('patroni.ha.logger.info') as mock_info: + self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', 'b', None)) + self.ha.cluster.members.append(Member(0, 'b', 28, {'api_url': 'http://127.0.0.1:8011/patroni'})) + self.ha.fetch_node_status = get_node_status(timeline=1) + self.assertEqual(self.ha.run_cycle(), 'following a different leader because i am not the healthiest node') + mock_info.assert_called_with('%s: to %s, i am %s', 'manual failover', 'b', 'postgresql0') + + # failover to another member lagging behind the cluster_lsn (only failover_limitation() is checked) + with patch('patroni.ha.logger.info') as mock_info: + self.ha.cluster.config.data.update({'maximum_lag_on_failover': 5}) + self.ha.fetch_node_status = get_node_status(wal_position=1) + self.assertEqual(self.ha.run_cycle(), 'following a different leader because i am not the healthiest node') + mock_info.assert_called_with('%s: to %s, i am %s', 'manual failover', 'b', 'postgresql0') + + def test_manual_switchover_process_no_leader(self): + self.p.is_primary = false + self.p.set_role('replica') + + # I was the leader, other members are healthy + self.ha.fetch_node_status = get_node_status() + self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, self.p.name, '', None)) + self.assertEqual(self.ha.run_cycle(), 'following a different leader because i am not the healthiest node') + + # I was the leader, I am the only healthy member + with patch('patroni.ha.logger.info') as mock_info: + self.ha.fetch_node_status = get_node_status(reachable=False) # inaccessible, in_recovery + self.assertEqual(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock') + self.assertEqual(mock_info.call_args_list[0][0], ('Member %s is %s', 'leader', 'not reachable')) + self.assertEqual(mock_info.call_args_list[1][0], ('Member %s is %s', 'other', 'not reachable')) + def test_manual_failover_process_no_leader_in_synchronous_mode(self): self.ha.is_synchronous_mode = true self.p.is_primary = false + self.ha.fetch_node_status = get_node_status(nofailover=True) # other nodes are not healthy - # switchover to a specific node, which name doesn't match our name (postgresql0) + # manual failover when our name (postgresql0) isn't in the /sync key and the candidate node is not available + self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', 'other', None), + sync=('leader1', 'blabla')) + self.assertEqual(self.ha.run_cycle(), 'following a different leader because i am not the healthiest node') + + # manual failover when the candidate node isn't available but our name is in the /sync key + # while other sync node is nofailover + with patch('patroni.ha.logger.warning') as mock_warning: + self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', 'other', None), + sync=('leader1', 'postgresql0')) + self.p.sync_handler.current_state = Mock(return_value=(CaseInsensitiveSet(), CaseInsensitiveSet())) + self.ha.dcs.write_sync_state = Mock(return_value=SyncState.empty()) + self.assertEqual(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock') + self.assertEqual(mock_warning.call_args_list[0][0], + ('%s: member %s is %s', 'manual failover', 'other', 'not allowed to promote')) + + # manual failover to our node (postgresql0), + # which name is not in sync nodes list (some sync nodes are available) + self.p.set_role('replica') + self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', 'postgresql0', None), + sync=('leader1', 'other')) + self.p.sync_handler.current_state = Mock(return_value=(CaseInsensitiveSet(['leader1']), + CaseInsensitiveSet(['leader1']))) + self.assertEqual(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock') + + def test_manual_switchover_process_no_leader_in_synchronous_mode(self): + self.ha.is_synchronous_mode = true + self.p.is_primary = false + + # to a specific node, which name doesn't match our name (postgresql0) self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, 'leader', 'other', None)) self.assertEqual(self.ha.run_cycle(), 'following a different leader because i am not the healthiest node') - # switchover to our node (postgresql0), which name is not in sync nodes list + # to our node (postgresql0), which name is not in sync nodes list self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, 'leader', 'postgresql0', None), sync=('leader1', 'blabla')) self.assertEqual(self.ha.run_cycle(), 'following a different leader because i am not the healthiest node') - # switchover from a specific leader, but our name (postgresql0) is not in the sync nodes list + # without candidate, our name (postgresql0) is not in the sync nodes list self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, 'leader', '', None), sync=('leader', 'blabla')) self.assertEqual(self.ha.run_cycle(), 'following a different leader because i am not the healthiest node') @@ -800,45 +988,31 @@ class TestHa(PostgresInit): sync=('postgresql0')) self.ha.patroni.nofailover = True self.assertEqual(self.ha.run_cycle(), 'following a different leader because I am not allowed to promote') - self.ha.patroni.nofailover = False - - # manual failover when our name (postgresql0) isn't in the /sync key and the `other` node is not available - self.ha.fetch_node_status = get_node_status(nofailover=True) # accessible, in_recovery - self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', 'other', None), - sync=('leader1', 'blabla')) - self.assertEqual(self.ha.run_cycle(), 'following a different leader because i am not the healthiest node') - - # manual failover when the `other` node isn't available but our name is in the /sync key - self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', 'other', None), - sync=('leader1', 'postgresql0')) - self.p.sync_handler.current_state = Mock(return_value=(CaseInsensitiveSet(), CaseInsensitiveSet())) - self.ha.dcs.write_sync_state = Mock(return_value=SyncState.empty()) - self.assertEqual(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock') - - # manual failover to our node (postgresql0), - # which name is not in sync nodes list (the leader and all sync nodes are not available) - self.p.set_role('replica') - self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', 'postgresql0', None), - sync=('leader1', 'other')) - self.assertEqual(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock') - - # manual failover to our node (postgresql0), - # which name is not in sync nodes list (some sync nodes are available) - self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', 'postgresql0', None), - sync=('leader1', 'other')) - self.p.set_role('replica') - self.p.sync_handler.current_state = Mock(return_value=(CaseInsensitiveSet(['leader1']), - CaseInsensitiveSet(['leader1']))) - self.assertEqual(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock') def test_manual_failover_process_no_leader_in_pause(self): self.ha.is_paused = true + + # I am running as primary, cluster is unlocked, the candidate is allowed to promote + # but we are in pause self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', 'other', None)) self.assertEqual(self.ha.run_cycle(), 'PAUSE: continue to run as primary without lock') + + def test_manual_switchover_process_no_leader_in_pause(self): + self.ha.is_paused = true + + # I am running as primary, cluster is unlocked, no candidate specified self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, 'leader', '', None)) self.assertEqual(self.ha.run_cycle(), 'PAUSE: continue to run as primary without lock') - self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, 'leader', 'blabla', None)) - self.assertEqual('PAUSE: acquired session lock as a leader', self.ha.run_cycle()) + + # the candidate is not running + with patch('patroni.ha.logger.warning') as mock_warning: + self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, 'leader', 'blabla', None)) + self.assertEqual('PAUSE: acquired session lock as a leader', self.ha.run_cycle()) + self.assertEqual( + mock_warning.call_args_list[0][0], + ('%s: removing failover key because failover candidate is not running', 'switchover')) + + # switchover to me, I am not leader self.p.is_primary = false self.p.set_role('replica') self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, 'leader', self.p.name, None)) @@ -846,7 +1020,7 @@ class TestHa(PostgresInit): def test_is_healthiest_node(self): self.ha.is_failsafe_mode = true - self.p.is_primary = false + self.ha.state_handler.is_primary = false self.ha.patroni.nofailover = False self.ha.fetch_node_status = get_node_status() self.ha.dcs._last_failsafe = {'foo': ''} @@ -862,19 +1036,24 @@ class TestHa(PostgresInit): def test__is_healthiest_node(self): self.p.is_primary = false self.ha.cluster = get_cluster_initialized_without_leader(sync=('postgresql1', self.p.name)) - self.ha.global_config = self.ha.patroni.config.get_global_config(self.ha.cluster) + global_config.update(self.ha.cluster) self.assertTrue(self.ha._is_healthiest_node(self.ha.old_cluster.members)) self.ha.fetch_node_status = get_node_status() # accessible, in_recovery self.assertTrue(self.ha._is_healthiest_node(self.ha.old_cluster.members)) self.ha.fetch_node_status = get_node_status(in_recovery=False) # accessible, not in_recovery self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members)) + self.ha.fetch_node_status = get_node_status(failover_priority=2) # accessible, in_recovery, higher priority + self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members)) + # if there is a higher-priority node but it has a lower WAL position then this node should race + self.ha.fetch_node_status = get_node_status(failover_priority=6, wal_position=9) + self.assertTrue(self.ha._is_healthiest_node(self.ha.old_cluster.members)) self.ha.fetch_node_status = get_node_status(wal_position=11) # accessible, in_recovery, wal position ahead self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members)) # in synchronous_mode consider itself healthy if the former leader is accessible in read-only and ahead of us with patch.object(Ha, 'is_synchronous_mode', Mock(return_value=True)): self.assertTrue(self.ha._is_healthiest_node(self.ha.old_cluster.members)) self.ha.cluster.config.data.update({'maximum_lag_on_failover': 5}) - self.ha.global_config = self.ha.patroni.config.get_global_config(self.ha.cluster) + global_config.update(self.ha.cluster) with patch('patroni.postgresql.Postgresql.last_operation', return_value=1): self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members)) with patch('patroni.postgresql.Postgresql.replica_cached_timeline', return_value=None): @@ -883,7 +1062,9 @@ class TestHa(PostgresInit): self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members)) self.ha.patroni.nofailover = True self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members)) - self.ha.patroni.nofailover = False + self.ha.patroni.nofailover = None + self.ha.patroni.failover_priority = 0 + self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members)) def test_fetch_node_status(self): member = Member(0, 'test', 1, {'api_url': 'http://127.0.0.1:8011/patroni'}) @@ -1088,14 +1269,14 @@ class TestHa(PostgresInit): f = Failover(0, self.p.name, '', None) self.ha.cluster = get_cluster_initialized_with_leader(f) self.ha.fetch_node_status = get_node_status() # accessible, in_recovery - self.assertEqual(self.ha.run_cycle(), 'manual failover: demoting myself') + self.assertEqual(self.ha.run_cycle(), 'switchover: demoting myself') @patch('patroni.ha.Ha.demote') def test_failover_immediately_on_zero_primary_start_timeout(self, demote): self.p.is_running = false self.ha.cluster = get_cluster_initialized_with_leader(sync=(self.p.name, 'other')) self.ha.cluster.config.data.update({'synchronous_mode': True, 'primary_start_timeout': 0}) - self.ha.global_config = self.ha.patroni.config.get_global_config(self.ha.cluster) + global_config.update(self.ha.cluster) self.ha.has_lock = true self.ha.update_lock = true self.ha.fetch_node_status = get_node_status() # accessible, in_recovery @@ -1105,13 +1286,13 @@ class TestHa(PostgresInit): def test_primary_stop_timeout(self): self.assertEqual(self.ha.primary_stop_timeout(), None) self.ha.cluster.config.data.update({'primary_stop_timeout': 30}) - self.ha.global_config = self.ha.patroni.config.get_global_config(self.ha.cluster) + global_config.update(self.ha.cluster) with patch.object(Ha, 'is_synchronous_mode', Mock(return_value=True)): self.assertEqual(self.ha.primary_stop_timeout(), 30) with patch.object(Ha, 'is_synchronous_mode', Mock(return_value=False)): self.assertEqual(self.ha.primary_stop_timeout(), None) self.ha.cluster.config.data['primary_stop_timeout'] = None - self.ha.global_config = self.ha.patroni.config.get_global_config(self.ha.cluster) + global_config.update(self.ha.cluster) self.assertEqual(self.ha.primary_stop_timeout(), None) @patch('patroni.postgresql.Postgresql.follow') @@ -1203,8 +1384,9 @@ class TestHa(PostgresInit): # Test sync set to '*' when synchronous_mode_strict is enabled mock_set_sync.reset_mock() self.p.sync_handler.current_state = Mock(return_value=(CaseInsensitiveSet(), CaseInsensitiveSet())) - with patch('patroni.config.GlobalConfig.is_synchronous_mode_strict', PropertyMock(return_value=True)): - self.ha.run_cycle() + self.ha.cluster.config.data['synchronous_mode_strict'] = True + global_config.update(self.ha.cluster) + self.ha.run_cycle() mock_set_sync.assert_called_once_with(CaseInsensitiveSet('*')) def test_sync_replication_become_primary(self): @@ -1311,6 +1493,24 @@ class TestHa(PostgresInit): self.ha.run_cycle() self.assertEqual(mock_logger.call_args[0][0], 'Updating sync state failed') + @patch.object(Cluster, 'is_unlocked', Mock(return_value=False)) + def test_inconsistent_synchronous_state(self): + self.ha.is_synchronous_mode = true + self.ha.has_lock = true + self.p.name = 'leader' + self.ha.cluster = get_cluster_initialized_without_leader(sync=('leader', 'a')) + self.p.sync_handler.current_state = Mock(return_value=(CaseInsensitiveSet('a'), CaseInsensitiveSet())) + self.ha.dcs.write_sync_state = Mock(return_value=SyncState.empty()) + mock_set_sync = self.p.sync_handler.set_synchronous_standby_names = Mock() + with patch('patroni.ha.logger.warning') as mock_logger: + self.ha.run_cycle() + mock_set_sync.assert_called_once() + self.assertTrue(mock_logger.call_args_list[0][0][0].startswith('Inconsistent state between ')) + self.ha.dcs.write_sync_state = Mock(return_value=None) + with patch('patroni.ha.logger.warning') as mock_logger: + self.ha.run_cycle() + self.assertEqual(mock_logger.call_args[0][0], 'Updating sync state failed') + def test_effective_tags(self): self.ha._disable_sync = True self.assertEqual(self.ha.get_effective_tags(), {'foo': 'bar', 'nosync': True}) @@ -1319,7 +1519,6 @@ class TestHa(PostgresInit): @patch('patroni.postgresql.mtime', Mock(return_value=1588316884)) @patch('builtins.open', Mock(side_effect=Exception)) - @patch.object(Cluster, 'is_unlocked', Mock(return_value=False)) def test_restore_cluster_config(self): self.ha.cluster.config.data.clear() self.ha.has_lock = true @@ -1337,7 +1536,7 @@ class TestHa(PostgresInit): self.ha.is_leader = true def stop(*args, **kwargs): - kwargs['on_shutdown'](123) + kwargs['on_shutdown'](123, 120) self.p.stop = stop self.ha.shutdown() @@ -1397,6 +1596,7 @@ class TestHa(PostgresInit): @patch('patroni.psycopg.connect', psycopg_connect) def test_permanent_logical_slots_after_promote(self): + self.p._major_version = 110000 config = ClusterConfig(1, {'slots': {'l': {'database': 'postgres', 'plugin': 'test_decoding'}}}, 1) self.p.name = 'other' self.ha.cluster = get_cluster_initialized_without_leader(cluster_config=config) diff --git a/tests/test_kubernetes.py b/tests/test_kubernetes.py index 4f9f418c..b6db7fb3 100644 --- a/tests/test_kubernetes.py +++ b/tests/test_kubernetes.py @@ -63,7 +63,7 @@ def mock_list_namespaced_pod(*args, **kwargs): metadata = k8s_client.V1ObjectMeta(resource_version='1', labels={'f': 'b', Kubernetes._CITUS_LABEL: '1'}, name='p-0', annotations={'status': '{}'}, uid='964dfeae-e79b-4476-8a5a-1920b5c2a69d') - status = k8s_client.V1PodStatus(pod_ip='10.0.0.0') + status = k8s_client.V1PodStatus(pod_ip='10.0.0.1') spec = k8s_client.V1PodSpec(hostname='p-0', node_name='kind-control-plane', containers=[]) items = [k8s_client.V1Pod(metadata=metadata, status=status, spec=spec)] return k8s_client.V1PodList(items=items, kind='PodList') @@ -356,6 +356,20 @@ class TestKubernetesConfigMaps(BaseTestKubernetes): mock_warning.assert_called_once() +class TestKubernetesEndpointsNoPodIP(BaseTestKubernetes): + @patch.object(k8s_client.CoreV1Api, 'list_namespaced_endpoints', mock_list_namespaced_endpoints, create=True) + def setUp(self, config=None): + super(TestKubernetesEndpointsNoPodIP, self).setUp({'use_endpoints': True}) + + @patch.object(k8s_client.CoreV1Api, 'patch_namespaced_endpoints', create=True) + def test_update_leader(self, mock_patch_namespaced_endpoints): + leader = self.k.get_cluster().leader + self.assertIsNotNone(self.k.update_leader(leader, '123', failsafe={'foo': 'bar'})) + args = mock_patch_namespaced_endpoints.call_args[0] + self.assertEqual(args[2].subsets[0].addresses[0].target_ref.resource_version, '1') + self.assertEqual(args[2].subsets[0].addresses[0].ip, '10.0.0.1') + + class TestKubernetesEndpoints(BaseTestKubernetes): @patch.object(k8s_client.CoreV1Api, 'list_namespaced_endpoints', mock_list_namespaced_endpoints, create=True) @@ -368,6 +382,7 @@ class TestKubernetesEndpoints(BaseTestKubernetes): self.assertIsNotNone(self.k.update_leader(leader, '123', failsafe={'foo': 'bar'})) args = mock_patch_namespaced_endpoints.call_args[0] self.assertEqual(args[2].subsets[0].addresses[0].target_ref.resource_version, '10') + self.assertEqual(args[2].subsets[0].addresses[0].ip, '10.0.0.0') self.k._kinds._object_cache['test'].subsets[:] = [] self.assertIsNotNone(self.k.update_leader(leader, '123')) self.k._kinds._object_cache['test'].metadata.annotations['leader'] = 'p-1' diff --git a/tests/test_patroni.py b/tests/test_patroni.py index 0385731c..8e08d406 100644 --- a/tests/test_patroni.py +++ b/tests/test_patroni.py @@ -15,8 +15,7 @@ from patroni.dcs.etcd import AbstractEtcdClientWithFailover from patroni.exceptions import DCSError from patroni.postgresql import Postgresql from patroni.postgresql.config import ConfigHandler -from patroni import check_psycopg -from patroni.__main__ import Patroni, main as _main +from patroni.__main__ import check_psycopg, Patroni, main as _main from threading import Thread from . import psycopg_connect, SleepException @@ -25,10 +24,16 @@ from .test_postgresql import MockPostmaster def mock_import(*args, **kwargs): - if args[0] == 'psycopg': + ret = Mock() + ret.__version__ = '2.5.3.dev1 a b c' if args[0] == 'psycopg2' else '3.1.0' + return ret + + +def mock_import2(*args, **kwargs): + if args[0] == 'psycopg2': raise ImportError ret = Mock() - ret.__version__ = '2.5.3.dev1 a b c' + ret.__version__ = '0.1.2' return ret @@ -108,6 +113,7 @@ class TestPatroni(unittest.TestCase): @patch('os.getpid') @patch('multiprocessing.Process') @patch('patroni.__main__.patroni_main', Mock()) + @patch('sys.argv', ['patroni.py', 'postgres0.yml']) def test_patroni_main(self, mock_process, mock_getpid): mock_getpid.return_value = 2 _main() @@ -148,6 +154,7 @@ class TestPatroni(unittest.TestCase): self.p.api.start = Mock() self.p.logger.start = Mock() self.p.config._dynamic_configuration = {} + self.assertRaises(SleepException, self.p.run) with patch('patroni.dcs.Cluster.is_unlocked', Mock(return_value=True)): self.assertRaises(SleepException, self.p.run) with patch('patroni.config.Config.reload_local_configuration', Mock(return_value=False)): @@ -173,10 +180,42 @@ class TestPatroni(unittest.TestCase): self.assertTrue(self.p.noloadbalance) def test_nofailover(self): - self.p.tags['nofailover'] = True - self.assertTrue(self.p.nofailover) - self.p.tags['nofailover'] = None - self.assertFalse(self.p.nofailover) + for (nofailover, failover_priority, expected) in [ + # Without any tags, default is False + (None, None, False), + # Setting `nofailover: True` has precedence + (True, 0, True), + (True, 1, True), + # Similarly, setting `nofailover: False` has precedence + (False, 0, False), + (False, 1, False), + # Only when we have `nofailover: None` should we got based on priority + (None, 0, True), + (None, 1, False), + ]: + with self.subTest(nofailover=nofailover, failover_priority=failover_priority, expected=expected): + self.p.tags['nofailover'] = nofailover + self.p.tags['failover_priority'] = failover_priority + self.assertEqual(self.p.nofailover, expected) + + def test_failover_priority(self): + for (nofailover, failover_priority, expected) in [ + # Without any tags, default is 1 + (None, None, 1), + # Setting `nofailover: True` has precedence (value 0) + (True, 0, 0), + (True, 1, 0), + # Setting `nofailover: False` and `failover_priority: None` gives 1 + (False, None, 1), + # Normal function of failover_priority + (None, 0, 0), + (None, 1, 1), + (None, 2, 2), + ]: + with self.subTest(nofailover=nofailover, failover_priority=failover_priority, expected=expected): + self.p.tags['nofailover'] = nofailover + self.p.tags['failover_priority'] = failover_priority + self.assertEqual(self.p.failover_priority, expected) def test_replicatefrom(self): self.assertIsNone(self.p.replicatefrom) @@ -204,6 +243,8 @@ class TestPatroni(unittest.TestCase): with patch('builtins.__import__', Mock(side_effect=ImportError)): self.assertRaises(SystemExit, check_psycopg) with patch('builtins.__import__', mock_import): + self.assertIsNone(check_psycopg()) + with patch('builtins.__import__', mock_import2): self.assertRaises(SystemExit, check_psycopg) def test_ensure_unique_name(self): @@ -233,8 +274,8 @@ class TestPatroni(unittest.TestCase): ) with patch('patroni.dcs.AbstractDCS.get_cluster', Mock(return_value=bad_cluster)): # If the api of the running node cannot be reached, this implies unique name - with patch.object(self.p, 'request', Mock(side_effect=ConnectionError)): + with patch('urllib3.PoolManager.request', Mock(side_effect=ConnectionError)): self.assertIsNone(self.p.ensure_unique_name()) # Only if the api of the running node is reachable do we throw an error - with patch.object(self.p, 'request', Mock()): + with patch('urllib3.PoolManager.request', Mock()): self.assertRaises(SystemExit, self.p.ensure_unique_name) diff --git a/tests/test_postgresql.py b/tests/test_postgresql.py index 9ab3f52d..31454479 100644 --- a/tests/test_postgresql.py +++ b/tests/test_postgresql.py @@ -9,9 +9,9 @@ from mock import Mock, MagicMock, PropertyMock, patch, mock_open import patroni.psycopg as psycopg +from patroni import global_config from patroni.async_executor import CriticalTask from patroni.collections import CaseInsensitiveSet -from patroni.config import GlobalConfig from patroni.dcs import RemoteMember from patroni.exceptions import PostgresConnectionException, PatroniException from patroni.postgresql import Postgresql, STATE_REJECT, STATE_NO_RESPONSE @@ -237,7 +237,10 @@ class TestPostgresql(BaseTestPostgresql): @patch.object(Postgresql, 'latest_checkpoint_location', Mock(return_value='7')) def test__do_stop(self): mock_callback = Mock() - with patch.object(Postgresql, 'controldata', Mock(return_value={'Database cluster state': 'shut down'})): + with patch.object(Postgresql, 'controldata', + Mock(return_value={'Database cluster state': 'shut down', + "Latest checkpoint's TimeLineID": '1', + 'Latest checkpoint location': '1/1'})): self.assertTrue(self.p.stop(on_shutdown=mock_callback, stop_timeout=3)) mock_callback.assert_called() with patch.object(Postgresql, 'controldata', @@ -688,13 +691,13 @@ class TestPostgresql(BaseTestPostgresql): self.assertIsNone(self.p.wait_for_startup()) def test_get_server_parameters(self): - config = {'parameters': {'wal_level': 'hot_standby'}, 'listen': '0'} - self.p._global_config = GlobalConfig({'synchronous_mode': True}) - self.p.config.get_server_parameters(config) - self.p._global_config = GlobalConfig({'synchronous_mode': True, 'synchronous_mode_strict': True}) - self.p.config.get_server_parameters(config) - self.p.config.set_synchronous_standby_names('foo') - self.assertTrue(str(self.p.config.get_server_parameters(config)).startswith(' None: ... def __setitem__(self, key, val) -> None: ...