"""Implement Patroni's REST API.

Exposes a REST API of patroni operations functions, such as status, performance and management to web clients.

Much of what can be achieved with the command line tool patronictl can be done via the API. Patroni CLI and daemon
utilises the API to perform these functions.
"""

import base64
import hmac
import json
import logging
import time
import traceback
import dateutil.parser
import datetime
import os
import socket
import sys

from http.server import BaseHTTPRequestHandler, HTTPServer
from ipaddress import ip_address, ip_network, IPv4Network, IPv6Network
from socketserver import ThreadingMixIn
from threading import Thread
from urllib.parse import urlparse, parse_qs

from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, TYPE_CHECKING, Union

from . import psycopg
from .__main__ import Patroni
from .dcs import Cluster
from .exceptions import PostgresConnectionException, PostgresException
from .postgresql.misc import postgres_version_to_int
from .utils import deep_compare, enable_keepalive, parse_bool, patch_config, Retry, \
    RetryFailedError, parse_int, split_host_port, tzutc, uri, cluster_as_json

logger = logging.getLogger(__name__)


def check_access(func: Callable[['RestApiHandler'], None]) -> Callable[..., None]:
    """Check the source ip, authorization header, or client certificates.

    .. note::
        The actual logic to check access is implemented through :func:`RestApiServer.check_access`.

    :param func: function to be decorated.

    :returns: a decorator that executes *func* only if :func:`RestApiServer.check_access` returns ``True``.

    :Example:

        @check_access
        def do_PUT_foo():
            pass
    """

    def wrapper(self: 'RestApiHandler', *args: Any, **kwargs: Any) -> None:
        if self.server.check_access(self):
            return func(self, *args, **kwargs)

    return wrapper


class RestApiHandler(BaseHTTPRequestHandler):
    """Define how to handle each of the requests that are made against the REST API server."""

    # Comment from pyi stub file. These unions can cause typing errors with IDEs, e.g. PyCharm
    #
    # Those are technically of types, respectively:
    # * _RequestType = Union[socket.socket, Tuple[bytes, socket.socket]]
    # * _AddressType = Tuple[str, int]
    # But there are some concerns that having unions here would cause
    # too much inconvenience to people using it (see
    # https://github.com/python/typeshed/pull/384#issuecomment-234649696)

    def __init__(self, request: Any,
                 client_address: Any,
                 server: Union['RestApiServer', HTTPServer]) -> None:
        """Create a :class:`RestApiHandler` instance.

        .. note::
            Currently not different from its superclass :func:`__init__`, and only used so ``pyright`` can understand
            the type of ``server`` attribute.

        :param request: client request to be processed.
        :param client_address: address of the client connection.
        :param server: HTTP server that received the request.
        """
        if TYPE_CHECKING:  # pragma: no cover
            assert isinstance(server, RestApiServer)
        super(RestApiHandler, self).__init__(request, client_address, server)
        self.server: 'RestApiServer' = server
        self.__start_time: float = 0.0
        self.path_query: Dict[str, List[str]] = {}

    def _write_status_code_only(self, status_code: int) -> None:
        """Write a response that is composed only of the HTTP status.

        The response is written with these values separated by space:
            * HTTP protocol version;
            * *status_code*;
            * description of *status_code*.

        .. note::
            This is usually useful for replying to requests from software like HAProxy.

        :param status_code: HTTP status code.

        :Example:

            * ``_write_status_code_only(200)`` would write a response like ``HTTP/1.0 200 OK``.
        """
        message = self.responses[status_code][0]
        self.wfile.write('{0} {1} {2}\r\n\r\n'.format(self.protocol_version, status_code, message).encode('utf-8'))
        self.log_request(status_code)

    def write_response(self, status_code: int, body: str, content_type: str = 'text/html',
                       headers: Optional[Dict[str, str]] = None) -> None:
        """Write an HTTP response.

        .. note::
            Besides ``Content-Type`` header, and the HTTP headers passed through *headers*, this function will also
            write the HTTP headers defined through ``restapi.http_extra_headers`` and ``restapi.https_extra_headers``
            from Patroni configuration.

        :param status_code: response HTTP status code.
        :param body: response body.
        :param content_type: value for ``Content-Type`` HTTP header.
        :param headers: dictionary of additional HTTP headers to set for the response. Each key is the header name, and
            the corresponding value is the value for the header in the response.
        """
        # TODO: try-catch ConnectionResetError: [Errno 104] Connection reset by peer and log it in DEBUG level
        self.send_response(status_code)
        headers = headers or {}
        if content_type:
            headers['Content-Type'] = content_type
        for name, value in headers.items():
            self.send_header(name, value)
        for name, value in (self.server.http_extra_headers or {}).items():
            self.send_header(name, value)
        self.end_headers()
        self.wfile.write(body.encode('utf-8'))

    def _write_json_response(self, status_code: int, response: Any) -> None:
        """Write an HTTP response with a JSON content type.

        Call :func:`write_response` with ``content_type`` as ``application/json``.

        :param status_code: response HTTP status code.
        :param response: value to be dumped as a JSON string and to be used as the response body.
        """
        self.write_response(status_code, json.dumps(response, default=str), content_type='application/json')

    def _write_status_response(self, status_code: int, response: Dict[str, Any]) -> None:
        """Write an HTTP response with Patroni/Postgres status in JSON format.

        Modifies *response* before sending it to the client. Defines the ``patroni`` key, which is a
        dictionary that contains the mandatory keys:

        * ``version``: Patroni version, e.g. ``3.0.2``;
        * ``scope``: value of ``scope`` setting from Patroni configuration.

        May also add the following optional keys, depending on the status of this Patroni/PostgreSQL node:

        * ``tags``: tags that were set through Patroni configuration merged with dynamically applied tags;
        * ``database_system_identifier``: ``Database system identifier`` from ``pg_controldata`` output;
        * ``pending_restart``: ``True`` if PostgreSQL is pending to be restarted;
        * ``scheduled_restart``: a dictionary with a single key ``schedule``, which is the timestamp for the scheduled
            restart;
        * ``watchdog_failed``: ``True`` if watchdog device is unhealthy;
        * ``logger_queue_size``: log queue length if it is longer than expected;
        * ``logger_records_lost``: number of log records that have been lost while the log queue was full.

        :param status_code: response HTTP status code.
        :param response: represents the status of the PostgreSQL node, and is used as a basis for the HTTP response.
            This dictionary is built through :func:`get_postgresql_status`.
        """
        patroni = self.server.patroni
        tags = patroni.ha.get_effective_tags()
        if tags:
            response['tags'] = tags
        if patroni.postgresql.sysid:
            response['database_system_identifier'] = patroni.postgresql.sysid
        if patroni.postgresql.pending_restart:
            response['pending_restart'] = True
        response['patroni'] = {'version': patroni.version, 'scope': patroni.postgresql.scope}
        if patroni.scheduled_restart:
            response['scheduled_restart'] = patroni.scheduled_restart.copy()
            del response['scheduled_restart']['postmaster_start_time']
            response['scheduled_restart']['schedule'] = (response['scheduled_restart']['schedule']).isoformat()
        if not patroni.ha.watchdog.is_healthy:
            response['watchdog_failed'] = True
        qsize = patroni.logger.queue_size
        if qsize > patroni.logger.NORMAL_LOG_QUEUE_SIZE:
            response['logger_queue_size'] = qsize
            lost = patroni.logger.records_lost
            if lost:
                response['logger_records_lost'] = lost
        self._write_json_response(status_code, response)

    def do_GET(self, write_status_code_only: bool = False) -> None:
        """Process all GET requests which can not be routed to other methods.

        Is used for handling all health-checks requests. E.g. "GET /(primary|replica|sync|async|etc...)".

        The (optional) query parameters and the HTTP response status depend on the requested path:
            * ``/``, ``primary``, or ``read-write``:
                * HTTP status ``200``: if a primary with the leader lock.
            * ``/standby-leader``:
                * HTTP status ``200``: if holds the leader lock in a standby cluster.
            * ``/leader``:
                * HTTP status ``200``: if holds the leader lock.
            * ``/replica``:
                * Query parameters:
                    * ``lag``: only accept replication lag up to ``lag``. Accepts either an :class:`int`, which
                        represents lag in bytes, or a :class:`str` representing lag in human-readable format (e.g.
                        ``10MB``).
                    * Any custom parameter: will attempt to match them against node tags.
                * HTTP status ``200``: if up and running as a standby and without ``noloadbalance`` tag.
            * ``/read-only``:
                * HTTP status ``200``: if up and running and without ``noloadbalance`` tag.
            * ``/synchronous`` or ``/sync``:
                * HTTP status ``200``: if up and running as a synchronous standby.
            * ``/read-only-sync``:
                * HTTP status ``200``: if up and running as a synchronous standby or primary.
            * ``/asynchronous``:
                * Query parameters:
                    * ``lag``: only accept replication lag up to ``lag``. Accepts either an :class:`int`, which
                        represents lag in bytes, or a :class:`str` representing lag in human-readable format (e.g.
                        ``10MB``).
                * HTTP status ``200``: if up and running as an asynchronous standby.
            * ``/health``:
                * HTTP status ``200``: if up and running.

        .. note::
            If not able to honor the query parameter, or not able to match the condition described for HTTP status
            ``200`` in each path above, then HTTP status will be ``503``.

        .. note::
            Independently of the requested path, if *write_status_code_only* is ``False``, then it always write an HTTP
            response through :func:`_write_status_response`, with the node status.

        :param write_status_code_only: indicates that instead of a normal HTTP response we should
                                       send only the HTTP Status Code and close the connection.
                                       Useful when health-checks are executed by HAProxy.
        """
        path = '/primary' if self.path == '/' else self.path
        response = self.get_postgresql_status()

        patroni = self.server.patroni
        cluster = patroni.dcs.cluster
        global_config = patroni.config.get_global_config(cluster)

        leader_optime = cluster and cluster.last_lsn or 0
        replayed_location = response.get('xlog', {}).get('replayed_location', 0)
        max_replica_lag = parse_int(self.path_query.get('lag', [sys.maxsize])[0], 'B')
        if max_replica_lag is None:
            max_replica_lag = sys.maxsize
        is_lagging = leader_optime and leader_optime > replayed_location + max_replica_lag

        replica_status_code = 200 if not patroni.noloadbalance and not is_lagging and \
            response.get('role') == 'replica' and response.get('state') == 'running' else 503

        if not cluster and response.get('pause'):
            leader_status_code = 200 if response.get('role') in ('master', 'primary', 'standby_leader') else 503
            primary_status_code = 200 if response.get('role') in ('master', 'primary') else 503
            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:
                primary_status_code = replica_status_code = 503
                standby_leader_status_code = 200 if response.get('role') in ('replica', 'standby_leader') else 503
            else:
                primary_status_code = 200
                standby_leader_status_code = 503
        else:
            leader_status_code = primary_status_code = standby_leader_status_code = 503

        status_code = 503

        ignore_tags = False
        if 'standby_leader' in path or 'standby-leader' in path:
            status_code = standby_leader_status_code
            ignore_tags = True
        elif 'leader' in path:
            status_code = leader_status_code
            ignore_tags = True
        elif 'master' in path or 'primary' in path or 'read-write' in path:
            status_code = primary_status_code
            ignore_tags = True
        elif 'replica' in path:
            status_code = replica_status_code
        elif 'read-only' in path and 'sync' not in path:
            status_code = 200 if 200 in (primary_status_code, standby_leader_status_code) else replica_status_code
        elif 'health' in path:
            status_code = 200 if response.get('state') == 'running' else 503
        elif cluster:  # dcs is available
            is_synchronous = response.get('sync_standby')
            if path in ('/sync', '/synchronous') and is_synchronous:
                status_code = replica_status_code
            elif path in ('/async', '/asynchronous') and not is_synchronous:
                status_code = replica_status_code
            elif path in ('/read-only-sync', '/read-only-synchronous'):
                if 200 in (primary_status_code, standby_leader_status_code):
                    status_code = 200
                elif is_synchronous:
                    status_code = replica_status_code

        # check for user defined tags in query params
        if not ignore_tags and status_code == 200:
            qs_tag_prefix = "tag_"
            for qs_key, qs_value in self.path_query.items():
                if not qs_key.startswith(qs_tag_prefix):
                    continue
                qs_key = qs_key[len(qs_tag_prefix):]
                qs_value = qs_value[0]
                instance_tag_value = patroni.tags.get(qs_key)
                # tag not registered for instance
                if instance_tag_value is None:
                    status_code = 503
                    break
                if not isinstance(instance_tag_value, str):
                    instance_tag_value = str(instance_tag_value).lower()
                if instance_tag_value != qs_value:
                    status_code = 503
                    break

        if write_status_code_only:  # when haproxy sends OPTIONS request it reads only status code and nothing more
            self._write_status_code_only(status_code)
        else:
            self._write_status_response(status_code, response)

    def do_OPTIONS(self) -> None:
        """Handle an ``OPTIONS`` request.

        Write a simple HTTP response that represents the current PostgreSQL status. Send only `200 OK` or
        `503 Service Unavailable` as a response and nothing more, particularly no headers.
        """
        self.do_GET(write_status_code_only=True)

    def do_HEAD(self) -> None:
        """Handle a ``HEAD`` request.

        Write a simple HTTP response that represents the current PostgreSQL status. Send only `200 OK` or
        `503 Service Unavailable` as a response and nothing more, particularly no headers.
        """
        self.do_GET(write_status_code_only=True)

    def do_GET_liveness(self) -> None:
        """Handle a ``GET`` request to ``/liveness`` path.

        Write a simple HTTP response with HTTP status:
            * ``200``:
                * If the cluster is in maintenance mode; or
                * If Patroni heartbeat loop is properly running;
            * ``503`` if Patroni heartbeat loop last run was more than ``ttl`` setting ago on the primary (or twice the
                value of ``ttl`` on a replica).
        """
        patroni: Patroni = self.server.patroni
        is_primary = patroni.postgresql.role in ('master', 'primary') and patroni.postgresql.is_running()
        # We can tolerate Patroni problems longer on the replica.
        # On the primary the liveness probe most likely will start failing only after the leader key expired.
        # It should not be a big problem because replicas will see that the primary is still alive via REST API call.
        liveness_threshold = patroni.dcs.ttl * (1 if is_primary else 2)

        # In maintenance mode (pause) we are fine if heartbeat loop stuck.
        status_code = 200 if patroni.ha.is_paused() or patroni.next_run + liveness_threshold > time.time() else 503
        self._write_status_code_only(status_code)

    def do_GET_readiness(self) -> None:
        """Handle a ``GET`` request to ``/readiness`` path.

        Write a simple HTTP response which HTTP status can be:
            * ``200``:
                * If this Patroni node holds the DCS leader lock; or
                * If this PostgreSQL instance is up and running;
            * ``503``: if none of the previous conditions apply.
        """
        patroni = self.server.patroni
        if patroni.ha.is_leader():
            status_code = 200
        elif patroni.postgresql.state == 'running':
            status_code = 200 if patroni.dcs.cluster else 503
        else:
            status_code = 503
        self._write_status_code_only(status_code)

    def do_GET_patroni(self) -> None:
        """Handle a ``GET`` request to ``/patroni`` path.

        Write an HTTP response through :func:`_write_status_response`, with HTTP status ``200`` and the status of
        Postgres.
        """
        response = self.get_postgresql_status(True)
        self._write_status_response(200, response)

    def do_GET_cluster(self) -> None:
        """Handle a ``GET`` request to ``/cluster`` path.

        Write an HTTP response with JSON content based on the output of :func:`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)
        self._write_json_response(200, cluster_as_json(cluster, global_config))

    def do_GET_history(self) -> None:
        """Handle a ``GET`` request to ``/history`` path.

        Write an HTTP response with a JSON content representing the history of events in the cluster, with HTTP status
        ``200``.

        The response contains a :class:`list` of failover/switchover events. Each item is a :class:`list` with the
        following items:
            * Timeline when the event occurred (class:`int`);
            * LSN at which the event occurred (class:`int`);
            * The reason for the event (class:`str`);
            * Timestamp when the new timeline was created (class:`str`);
            * Name of the involved Patroni node (class:`str`).
        """
        cluster = self.server.patroni.dcs.cluster or self.server.patroni.dcs.get_cluster()
        self._write_json_response(200, cluster.history and cluster.history.lines or [])

    def do_GET_config(self) -> None:
        """Handle a ``GET`` request to ``/config`` path.

        Write an HTTP response with a JSON content representing the Patroni configuration that is stored in the DCS,
        with HTTP status ``200``.

        If the cluster information is not available in the DCS, then it will respond with no body and HTTP status
        ``502`` instead.
        """
        cluster = self.server.patroni.dcs.cluster or self.server.patroni.dcs.get_cluster()
        if cluster.config:
            self._write_json_response(200, cluster.config.data)
        else:
            self.send_error(502)

    def do_GET_metrics(self) -> None:
        """Handle a ``GET`` request to ``/metrics`` path.

        Write an HTTP response with plain text content in the format used by Prometheus, with HTTP status ``200``.

        The response contains the following items:

        * ``patroni_version``: Patroni version without periods, e.g. ``030002`` for Patroni ``3.0.2``;
        * ``patroni_postgres_running``: ``1`` if PostgreSQL is running, else ``0``;
        * ``patroni_postmaster_start_time``: epoch timestamp since Postmaster was started;
        * ``patroni_master``: ``1`` if this node holds the leader lock, else ``0``;
        * ``patroni_primary``: same as ``patroni_master``;
        * ``patroni_xlog_location``: ``pg_wal_lsn_diff(pg_current_wal_lsn(), '0/0')`` if leader, else ``0``;
        * ``patroni_standby_leader``: ``1`` if standby leader node, else ``0``;
        * ``patroni_replica``: ``1`` if a replica, else ``0``;
        * ``patroni_sync_standby``: ``1`` if a sync replica, else ``0``;
        * ``patroni_xlog_received_location``: ``pg_wal_lsn_diff(pg_last_wal_receive_lsn(), '0/0')``;
        * ``patroni_xlog_replayed_location``: ``pg_wal_lsn_diff(pg_last_wal_replay_lsn(), '0/0)``;
        * ``patroni_xlog_replayed_timestamp``: ``pg_last_xact_replay_timestamp``;
        * ``patroni_xlog_paused``: ``pg_is_wal_replay_paused()``;
        * ``patroni_postgres_server_version``: Postgres version without periods, e.g. ``150002`` for Postgres ``15.2``;
        * ``patroni_cluster_unlocked``: ``1`` if no one holds the leader lock, else ``0``;
        * ``patroni_failsafe_mode_is_active``: ``1`` if ``failsafe_mode`` is currently active, else ``0``;
        * ``patroni_postgres_timeline``: PostgreSQL timeline based on current WAL file name;
        * ``patroni_dcs_last_seen``: epoch timestamp when DCS was last contacted successfully;
        * ``patroni_pending_restart``: ``1`` if this PostgreSQL node is pending a restart, else ``0``;
        * ``patroni_is_paused``: ``1`` if Patroni is in maintenance node, else ``0``.

        For PostgreSQL v9.6+ the response will also have the following:

        * ``patroni_postgres_streaming``: 1 if Postgres is streaming from another node, else ``0``;
        * ``patroni_postgres_in_archive_recovery``: ``1`` if Postgres isn't streaming and
                                                    there is ``restore_command`` available, else ``0``.
        """
        postgres = self.get_postgresql_status(True)
        patroni = self.server.patroni
        epoch = datetime.datetime(1970, 1, 1, tzinfo=tzutc)

        metrics: List[str] = []

        scope_label = '{{scope="{0}"}}'.format(patroni.postgresql.scope)
        metrics.append("# HELP patroni_version Patroni semver without periods.")
        metrics.append("# TYPE patroni_version gauge")
        padded_semver = ''.join([x.zfill(2) for x in patroni.version.split('.')])  # 2.0.2 => 020002
        metrics.append("patroni_version{0} {1}".format(scope_label, padded_semver))

        metrics.append("# HELP patroni_postgres_running Value is 1 if Postgres is running, 0 otherwise.")
        metrics.append("# TYPE patroni_postgres_running gauge")
        metrics.append("patroni_postgres_running{0} {1}".format(scope_label, int(postgres['state'] == 'running')))

        metrics.append("# HELP patroni_postmaster_start_time Epoch seconds since Postgres started.")
        metrics.append("# TYPE patroni_postmaster_start_time gauge")
        postmaster_start_time = postgres.get('postmaster_start_time')
        postmaster_start_time = (postmaster_start_time - epoch).total_seconds() if postmaster_start_time else 0
        metrics.append("patroni_postmaster_start_time{0} {1}".format(scope_label, postmaster_start_time))

        metrics.append("# HELP patroni_master Value is 1 if this node is the leader, 0 otherwise.")
        metrics.append("# TYPE patroni_master gauge")
        metrics.append("patroni_master{0} {1}".format(scope_label, int(postgres['role'] in ('master', 'primary'))))

        metrics.append("# HELP patroni_primary Value is 1 if this node is the leader, 0 otherwise.")
        metrics.append("# TYPE patroni_primary gauge")
        metrics.append("patroni_primary{0} {1}".format(scope_label, int(postgres['role'] in ('master', 'primary'))))

        metrics.append("# HELP patroni_xlog_location Current location of the Postgres"
                       " transaction log, 0 if this node is not the leader.")
        metrics.append("# TYPE patroni_xlog_location counter")
        metrics.append("patroni_xlog_location{0} {1}".format(scope_label, postgres.get('xlog', {}).get('location', 0)))

        metrics.append("# HELP patroni_standby_leader Value is 1 if this node is the standby_leader, 0 otherwise.")
        metrics.append("# TYPE patroni_standby_leader gauge")
        metrics.append("patroni_standby_leader{0} {1}".format(scope_label, int(postgres['role'] == 'standby_leader')))

        metrics.append("# HELP patroni_replica Value is 1 if this node is a replica, 0 otherwise.")
        metrics.append("# TYPE patroni_replica gauge")
        metrics.append("patroni_replica{0} {1}".format(scope_label, int(postgres['role'] == 'replica')))

        metrics.append("# HELP patroni_sync_standby Value is 1 if this node is a sync standby replica, 0 otherwise.")
        metrics.append("# TYPE patroni_sync_standby gauge")
        metrics.append("patroni_sync_standby{0} {1}".format(scope_label, int(postgres.get('sync_standby', False))))

        metrics.append("# HELP patroni_xlog_received_location Current location of the received"
                       " Postgres transaction log, 0 if this node is not a replica.")
        metrics.append("# TYPE patroni_xlog_received_location counter")
        metrics.append("patroni_xlog_received_location{0} {1}"
                       .format(scope_label, postgres.get('xlog', {}).get('received_location', 0)))

        metrics.append("# HELP patroni_xlog_replayed_location Current location of the replayed"
                       " Postgres transaction log, 0 if this node is not a replica.")
        metrics.append("# TYPE patroni_xlog_replayed_location counter")
        metrics.append("patroni_xlog_replayed_location{0} {1}"
                       .format(scope_label, postgres.get('xlog', {}).get('replayed_location', 0)))

        metrics.append("# HELP patroni_xlog_replayed_timestamp Current timestamp of the replayed"
                       " Postgres transaction log, 0 if null.")
        metrics.append("# TYPE patroni_xlog_replayed_timestamp gauge")
        replayed_timestamp = postgres.get('xlog', {}).get('replayed_timestamp')
        replayed_timestamp = (replayed_timestamp - epoch).total_seconds() if replayed_timestamp else 0
        metrics.append("patroni_xlog_replayed_timestamp{0} {1}".format(scope_label, replayed_timestamp))

        metrics.append("# HELP patroni_xlog_paused Value is 1 if the Postgres xlog is paused, 0 otherwise.")
        metrics.append("# TYPE patroni_xlog_paused gauge")
        metrics.append("patroni_xlog_paused{0} {1}"
                       .format(scope_label, int(postgres.get('xlog', {}).get('paused', False) is True)))

        if postgres.get('server_version', 0) >= 90600:
            metrics.append("# HELP patroni_postgres_streaming Value is 1 if Postgres is streaming, 0 otherwise.")
            metrics.append("# TYPE patroni_postgres_streaming gauge")
            metrics.append("patroni_postgres_streaming{0} {1}"
                           .format(scope_label, int(postgres.get('replication_state') == 'streaming')))

            metrics.append("# HELP patroni_postgres_in_archive_recovery Value is 1"
                           " if Postgres is replicating from archive, 0 otherwise.")
            metrics.append("# TYPE patroni_postgres_in_archive_recovery gauge")
            metrics.append("patroni_postgres_in_archive_recovery{0} {1}"
                           .format(scope_label, int(postgres.get('replication_state') == 'in archive recovery')))

        metrics.append("# HELP patroni_postgres_server_version Version of Postgres (if running), 0 otherwise.")
        metrics.append("# TYPE patroni_postgres_server_version gauge")
        metrics.append("patroni_postgres_server_version {0} {1}".format(scope_label, postgres.get('server_version', 0)))

        metrics.append("# HELP patroni_cluster_unlocked Value is 1 if the cluster is unlocked, 0 if locked.")
        metrics.append("# TYPE patroni_cluster_unlocked gauge")
        metrics.append("patroni_cluster_unlocked{0} {1}".format(scope_label, int(postgres.get('cluster_unlocked', 0))))

        metrics.append("# HELP patroni_failsafe_mode_is_active Value is 1 if failsafe mode is active, 0 if inactive.")
        metrics.append("# TYPE patroni_failsafe_mode_is_active gauge")
        metrics.append("patroni_failsafe_mode_is_active{0} {1}"
                       .format(scope_label, int(postgres.get('failsafe_mode_is_active', 0))))

        metrics.append("# HELP patroni_postgres_timeline Postgres timeline of this node (if running), 0 otherwise.")
        metrics.append("# TYPE patroni_postgres_timeline counter")
        metrics.append("patroni_postgres_timeline{0} {1}".format(scope_label, postgres.get('timeline', 0)))

        metrics.append("# HELP patroni_dcs_last_seen Epoch timestamp when DCS was last contacted successfully"
                       " by Patroni.")
        metrics.append("# TYPE patroni_dcs_last_seen gauge")
        metrics.append("patroni_dcs_last_seen{0} {1}".format(scope_label, postgres.get('dcs_last_seen', 0)))

        metrics.append("# HELP patroni_pending_restart Value is 1 if the node needs a restart, 0 otherwise.")
        metrics.append("# TYPE patroni_pending_restart gauge")
        metrics.append("patroni_pending_restart{0} {1}"
                       .format(scope_label, int(patroni.postgresql.pending_restart)))

        metrics.append("# HELP patroni_is_paused Value is 1 if auto failover is disabled, 0 otherwise.")
        metrics.append("# TYPE patroni_is_paused gauge")
        metrics.append("patroni_is_paused{0} {1}".format(scope_label, int(postgres.get('pause', 0))))

        self.write_response(200, '\n'.join(metrics) + '\n', content_type='text/plain')

    def _read_json_content(self, body_is_optional: bool = False) -> Optional[Dict[Any, Any]]:
        """Read JSON from HTTP request body.

        .. note::
            Retrieves the request body based on `content-length` HTTP header. The body is expected to be a JSON
            string with that length.

            If request body is expected but `content-length` HTTP header is absent, then write an HTTP response
            with HTTP status ``411``.

            If request body is expected but contains nothing, or if an exception is faced, then write an HTTP
            response with HTTP status ``400``.

        :param body_is_optional: if ``False`` then the request must contain a body. If ``True``, then the request may or
            may not contain a body.

        :returns: deserialized JSON string from request body, if present. If body is absent, but *body_is_optional* is
            ``True``, then return an empty dictionary. Returns ``None`` otherwise.
        """
        if 'content-length' not in self.headers:
            return self.send_error(411) if not body_is_optional else {}
        try:
            content_length = int(self.headers.get('content-length') or 0)
            if content_length == 0 and body_is_optional:
                return {}
            request: Union[Dict[str, Any], Any] = json.loads(self.rfile.read(content_length).decode('utf-8'))
            if isinstance(request, dict) and (request or body_is_optional):
                return request
        except Exception:
            logger.exception('Bad request')
        self.send_error(400)

    @check_access
    def do_PATCH_config(self) -> None:
        """Handle a ``PATCH`` request to ``/config`` path.

        Updates the Patroni configuration based on the JSON request body, then writes a response with the new
        configuration, with HTTP status ``200``.

        .. note::
            If the configuration has been previously wiped out from DCS, then write a response with
            HTTP status ``503``.

            If applying a configuration value fails, then write a response with HTTP status ``409``.
        """
        request = self._read_json_content()
        if request:
            cluster = self.server.patroni.dcs.get_cluster(True)
            if not (cluster.config and cluster.config.modify_version):
                return self.send_error(503)
            data = cluster.config.data.copy()
            if patch_config(data, request):
                value = json.dumps(data, separators=(',', ':'))
                if not self.server.patroni.dcs.set_config_value(value, cluster.config.version):
                    return self.send_error(409)
            self.server.patroni.ha.wakeup()
            self._write_json_response(200, data)

    @check_access
    def do_PUT_config(self) -> None:
        """Handle a ``PUT`` request to ``/config`` path.

        Overwrites the Patroni configuration based on the JSON request body, then writes a response with the new
        configuration, with HTTP status ``200``.

        .. note::
            If applying the new configuration fails, then write a response with HTTP status ``502``.
        """
        request = self._read_json_content()
        if request:
            cluster = self.server.patroni.dcs.get_cluster()
            if not (cluster.config and deep_compare(request, cluster.config.data)):
                value = json.dumps(request, separators=(',', ':'))
                if not self.server.patroni.dcs.set_config_value(value):
                    return self.send_error(502)
            self._write_json_response(200, request)

    @check_access
    def do_POST_reload(self) -> None:
        """Handle a ``POST`` request to ``/reload`` path.

        Schedules a reload to Patroni and writes a response with HTTP status `202`.
        """
        self.server.patroni.sighup_handler()
        self.write_response(202, 'reload scheduled')

    def do_GET_failsafe(self) -> None:
        """Handle a ``GET`` request to ``/failsafe`` path.

        Writes a response with a JSON string body containing all nodes that are known to Patroni at a given point
        in time, with HTTP status ``200``. The JSON contains a dictionary, each key is the name of the Patroni node,
        and the corresponding value is the URI to access `/patroni` path of its REST API.

        .. note::
            If ``failsafe_mode`` is not enabled, then write a response with HTTP status ``502``.
        """
        failsafe = self.server.patroni.dcs.failsafe
        if isinstance(failsafe, dict):
            self._write_json_response(200, failsafe)
        else:
            self.send_error(502)

    @check_access
    def do_POST_failsafe(self) -> None:
        """Handle a ``POST`` request to ``/failsafe`` path.

        Writes a response with HTTP status ``200`` if this node is a Standby, or with HTTP status ``500`` if this is
        the primary.

        .. note::
            If ``failsafe_mode`` is not enabled, then write a response with HTTP status ``502``.
        """
        if self.server.patroni.ha.is_failsafe_mode():
            request = self._read_json_content()
            if request:
                message = self.server.patroni.ha.update_failsafe(request) or 'Accepted'
                code = 200 if message == 'Accepted' else 500
                self.write_response(code, message)
        else:
            self.send_error(502)

    @check_access
    def do_POST_sigterm(self) -> None:
        """Handle a ``POST`` request to ``/sigterm`` path.

        Schedule a shutdown and write a response with HTTP status ``202``.

        .. note::
            Only for behave testing on Windows.
        """
        if os.name == 'nt' and os.getenv('BEHAVE_DEBUG'):
            self.server.patroni.api_sigterm()
        self.write_response(202, 'shutdown scheduled')

    @staticmethod
    def parse_schedule(schedule: str,
                       action: str) -> Tuple[Union[int, None], Union[str, None], Union[datetime.datetime, None]]:
        """Parse the given *schedule* and validate it.

        :param schedule: a string representing a timestamp, e.g. ``2023-04-14T20:27:00+00:00``.
        :param action: the action to be scheduled (``restart``, ``switchover``, or ``failover``).

        :returns: a tuple composed of 3 items
            * Suggested HTTP status code for a response:
                * ``None``: if no issue was faced while parsing, leaving it up to the caller to decide the status; or
                * ``400``: if no timezone information could be found in *schedule*; or
                * ``422``: if *schedule* is invalid -- in the past or not parsable.
            * An error message, if any error is faced, otherwise ``None``;
            * Parsed *schedule*, if able to parse, otherwise ``None``.
        """
        error = None
        scheduled_at = None
        try:
            scheduled_at = dateutil.parser.parse(schedule)
            if scheduled_at.tzinfo is None:
                error = 'Timezone information is mandatory for the scheduled {0}'.format(action)
                status_code = 400
            elif scheduled_at < datetime.datetime.now(tzutc):
                error = 'Cannot schedule {0} in the past'.format(action)
                status_code = 422
            else:
                status_code = None
        except (ValueError, TypeError):
            logger.exception('Invalid scheduled %s time: %s', action, schedule)
            error = 'Unable to parse scheduled timestamp. It should be in an unambiguous format, e.g. ISO 8601'
            status_code = 422
        return status_code, error, scheduled_at

    @check_access
    def do_POST_restart(self) -> None:
        """Handle a ``POST`` request to ``/restart`` path.

        Used to restart postgres (or schedule a restart), mainly by ``patronictl restart``.

        The request body should be a JSON dictionary, and it can contain the following keys:
            * ``schedule``: timestamp at which the restart should occur;
            * ``role``: restart only nodes which role is ``role``. Can be either:
                * ``primary`` (or ``master``); or
                * ``replica``.
            * ``postgres_version``: restart only nodes which PostgreSQL version is less than ``postgres_version``, e.g.
                ``15.2``;
            * ``timeout``: if restart takes longer than ``timeout`` return an error and fail over to a replica;
            * ``restart_pending``: if we should restart only when have ``pending restart`` flag;

        Response HTTP status codes:
            * ``200``: if successfully performed an immediate restart; or
            * ``202``: if successfully scheduled a restart for later; or
            * ``500``: if the cluster is in maintenance mode; or
            * ``400``: if
                * ``role`` value is invalid; or
                * ``postgres_version`` value is invalid; or
                * ``timeout`` is not a number, or lesser than ``0``; or
                * request contains an unknown key; or
                * exception is faced while performing an immediate restart.
            * ``409``: if another restart was already previously scheduled; or
            * ``503``: if any issue was found while performing an immediate restart; or
            * HTTP status returned by :func:`parse_schedule`, if any error was observed while parsing the schedule.

        .. note::
            If it's not able to parse the request body, then the request is silently discarded.
        """
        status_code = 500
        data = 'restart failed'
        request = self._read_json_content(body_is_optional=True)
        cluster = self.server.patroni.dcs.get_cluster()
        if request is None:
            # failed to parse the json
            return
        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:
            self.write_response(status_code, "Can't schedule restart in the paused state")
            return

        for k in request:
            if k == 'schedule':
                (_, data, request[k]) = self.parse_schedule(request[k], "restart")
                if _:
                    status_code = _
                    break
            elif k == 'role':
                if request[k] not in ('master', 'primary', 'replica'):
                    status_code = 400
                    data = "PostgreSQL role should be either primary or replica"
                    break
            elif k == 'postgres_version':
                try:
                    postgres_version_to_int(request[k])
                except PostgresException as e:
                    status_code = 400
                    data = e.value
                    break
            elif k == 'timeout':
                request[k] = parse_int(request[k], 's')
                if request[k] is None or request[k] <= 0:
                    status_code = 400
                    data = "Timeout should be a positive number of seconds"
                    break
            elif k != 'restart_pending':
                status_code = 400
                data = "Unknown filter for the scheduled restart: {0}".format(k)
                break
        else:
            if 'schedule' not in request:
                try:
                    status, data = self.server.patroni.ha.restart(request)
                    status_code = 200 if status else 503
                except Exception:
                    logger.exception('Exception during restart')
                    status_code = 400
            else:
                if self.server.patroni.ha.schedule_future_restart(request):
                    data = "Restart scheduled"
                    status_code = 202
                else:
                    data = "Another restart is already scheduled"
                    status_code = 409
        # pyright thinks ``data`` can be ``None`` because ``parse_schedule`` call may return ``None``. However, if
        # that's the case, ``data`` will be overwritten when the ``for`` loop ends
        if TYPE_CHECKING:  # pragma: no cover
            assert isinstance(data, str)
        self.write_response(status_code, data)

    @check_access
    def do_DELETE_restart(self) -> None:
        """Handle a ``DELETE`` request to ``/restart`` path.

        Used to remove a scheduled restart of PostgreSQL.

        Response HTTP status codes:
            * ``200``: if a scheduled restart was removed; or
            * ``404``: if no scheduled restart could be found.
        """
        if self.server.patroni.ha.delete_future_restart():
            data = "scheduled restart deleted"
            code = 200
        else:
            data = "no restarts are scheduled"
            code = 404
        self.write_response(code, data)

    @check_access
    def do_DELETE_switchover(self) -> None:
        """Handle a ``DELETE`` request to ``/switchover`` path.

        Used to remove a scheduled switchover in the cluster.

        It writes a response, and the HTTP status code can be:
            * ``200``: if a scheduled switchover was removed; or
            * ``404``: if no scheduled switchover could be found; or
            * ``409``: if not able to update the switchover info in the DCS.
        """
        failover = self.server.patroni.dcs.get_cluster().failover
        if failover and failover.scheduled_at:
            if not self.server.patroni.dcs.manual_failover('', '', version=failover.version):
                return self.send_error(409)
            else:
                data = "scheduled switchover deleted"
                code = 200
        else:
            data = "no switchover is scheduled"
            code = 404
        self.write_response(code, data)

    @check_access
    def do_POST_reinitialize(self) -> None:
        """Handle a ``POST`` request to ``/reinitialize`` path.

        The request body may contain a JSON dictionary with the following key:
            * ``force``: ``True`` if we want to cancel an already running task in order to reinit a replica.

        Response HTTP status codes:
            * ``200``: if the reinit operation has started; or
            * ``503``: if any error is returned by :func:`Ha.reinitialize`.
        """
        request = self._read_json_content(body_is_optional=True)

        if request:
            logger.debug('received reinitialize request: %s', request)

        force = isinstance(request, dict) and parse_bool(request.get('force')) or False

        data = self.server.patroni.ha.reinitialize(force)
        if data is None:
            status_code = 200
            data = 'reinitialize started'
        else:
            status_code = 503
        self.write_response(status_code, data)

    def poll_failover_result(self, leader: Optional[str], candidate: Optional[str], action: str) -> Tuple[int, str]:
        """Poll failover/switchover operation until it finishes or times out.

        :param leader: name of the current Patroni leader.
        :param candidate: name of the Patroni node to be promoted.
        :param action: the action that is ongoing (``switchover`` or ``failover``).

        :returns: a tuple composed of 2 items
            * Response HTTP status codes:
                * ``200``: if the operation succeeded; or
                * ``503``: if the operation failed or timed out.
            * A status message about the operation.
        """
        timeout = max(10, self.server.patroni.dcs.loop_wait)
        for _ in range(0, timeout * 2):
            time.sleep(1)
            try:
                cluster = self.server.patroni.dcs.get_cluster()
                if not cluster.is_unlocked() and cluster.leader and cluster.leader.name != leader:
                    if not candidate or candidate == cluster.leader.name:
                        return 200, 'Successfully {0}ed over to "{1}"'.format(action[:-4], cluster.leader.name)
                    else:
                        return 200, '{0}ed over to "{1}" instead of "{2}"'.format(action[:-4].title(),
                                                                                  cluster.leader.name, candidate)
                if not cluster.failover:
                    return 503, action.title() + ' failed'
            except Exception as e:
                logger.debug('Exception occurred during polling %s result: %s', action, e)
        return 503, action.title() + ' status unknown'

    def is_failover_possible(self, cluster: Cluster, leader: Optional[str], candidate: Optional[str],
                             action: str) -> Optional[str]:
        """Checks whether there are nodes that could take over after demoting the primary.

        :param cluster: the Patroni cluster.
        :param leader: name of the current Patroni leader.
        :param candidate: name of the Patroni node to be promoted.
        :param action: the action to be performed (``switchover`` or ``failover``).

        :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
        if leader and (not cluster.leader or cluster.leader.name != leader):
            return 'leader name does not match'
        if candidate:
            if action == 'switchover' and is_synchronous_mode and not cluster.sync.matches(candidate):
                return 'candidate name does not match with sync_standby'
            members = [m for m in cluster.members if m.name == candidate]
            if not members:
                return 'candidate does not exists'
        elif is_synchronous_mode:
            members = [m for m in cluster.members if cluster.sync.matches(m.name)]
            if not members:
                return action + ' is not possible: can not find sync_standby'
        else:
            members = [m for m in cluster.members if not cluster.leader or m.name != cluster.leader.name and m.api_url]
            if not members:
                return action + ' is not possible: cluster does not have members except leader'
        for st in self.server.patroni.ha.fetch_nodes_statuses(members):
            if st.failover_limitation() is None:
                return None
        return action + ' is not possible: no good candidates have been found'

    @check_access
    def do_POST_failover(self, action: str = 'failover') -> None:
        """Handle a ``POST`` request to ``/failover`` path.

        Handles manual failovers/switchovers, mainly from ``patronictl``.

        The request body should be a JSON dictionary, and it can contain the following keys:
            * ``leader``: name of the current leader in the cluster;
            * ``candidate``: name of the Patroni node to be promoted;
            * ``scheduled_at``: a string representing the timestamp when to execute the switchover/failover, e.g.
                ``2023-04-14T20:27:00+00:00``.

        Response HTTP status codes:
            * ``202``: if operation has been scheduled;
            * ``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.

        .. note::
            If unable to parse the request body, then the request is silently discarded.

        :param action: the action to be performed (``switchover`` or ``failover``).
        """
        request = self._read_json_content()
        (status_code, data) = (400, '')
        if not request:
            return

        leader = request.get('leader')
        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)

        logger.info("received %s request with leader=%s candidate=%s scheduled_at=%s",
                    action, leader, candidate, scheduled_at)

        if action == 'failover' and not candidate:
            data = 'Failover could be performed only to a specific candidate'
        elif action == 'switchover' and not leader:
            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:
                (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 not scheduled_at:
            data = self.is_failover_possible(cluster, leader, candidate, action)
            if data:
                status_code = 412

        if not data:
            if self.server.patroni.dcs.manual_failover(leader, candidate, scheduled_at=scheduled_at):
                self.server.patroni.ha.wakeup()
                if scheduled_at:
                    data = action.title() + ' scheduled'
                    status_code = 202
                else:
                    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)
                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
        # ``write_response`` calls.
        if TYPE_CHECKING:  # pragma: no cover
            assert isinstance(status_code, int)
        self.write_response(status_code, data)

    def do_POST_switchover(self) -> None:
        """Handle a ``POST`` request to ``/switchover`` path.

        Calls :func:`do_POST_failover` with ``switchover`` option.
        """
        self.do_POST_failover(action='switchover')

    @check_access
    def do_POST_citus(self) -> None:
        """Handle a ``POST`` request to ``/citus`` path.

        Call :func:`CitusHandler.handle_event` to handle the request, then write a response with HTTP status code
        ``200``.

        .. note::
            If unable to parse the request body, then the request is silently discarded.
        """
        request = self._read_json_content()
        if not request:
            return

        patroni = self.server.patroni
        if patroni.postgresql.citus_handler.is_coordinator() and patroni.ha.is_leader():
            cluster = patroni.dcs.get_cluster(True)
            patroni.postgresql.citus_handler.handle_event(cluster, request)
        self.write_response(200, 'OK')

    def parse_request(self) -> bool:
        """Override :func:`parse_request` method to enrich basic functionality of :class:`BaseHTTPRequestHandler`.

        Original class can only invoke :func:`do_GET`, :func:`do_POST`, :func:`do_PUT`, etc method implementations if
        they are defined.
        But we would like to have at least some simple routing mechanism, i.e.:
            * ``GET /uri1/part2`` request should invoke :func:`do_GET_uri1()`
            * ``POST /other`` should invoke :func:`do_POST_other()`

        If the :func:`do_<REQUEST_METHOD>_<first_part_url>` method does not exist we'll fall back to original behavior.

        :returns: ``True`` for success, ``False`` for failure; on failure, any relevant error response has already been
        sent back.
        """
        ret = BaseHTTPRequestHandler.parse_request(self)
        if ret:
            urlpath = urlparse(self.path)
            self.path = urlpath.path
            self.path_query = parse_qs(urlpath.query) or {}
            mname = self.path.lstrip('/').split('/')[0]
            mname = self.command + ('_' + mname if mname else '')
            if hasattr(self, 'do_' + mname):
                self.command = mname
        return ret

    def query(self, sql: str, *params: Any, **kwargs: Any) -> List[Tuple[Any, ...]]:
        """Execute *sql* query with *params*.

        :param sql: the SQL statement to be run.
        :param params: positional arguments to call :func:`RestApiServer.query` with.
        :param kwargs: can contain the key ``retry``. If the key is present its value should be a :class:`bool` which
            indicates whether the query should be retried upon failure or given up immediately.

        :returns: a list of rows that were fetched from the database.
        """
        if not kwargs.get('retry', False):
            return self.server.query(sql, *params)
        retry = Retry(delay=1, retry_exceptions=PostgresConnectionException)
        return retry(self.server.query, sql, *params)

    def get_postgresql_status(self, retry: bool = False) -> Dict[str, Any]:
        """Builds an object representing a status of "postgres".

        Some of the values are collected by executing a query and other are taken from the state stored in memory.

        :param retry: whether the query should be retried if failed or give up immediately
        :returns: a dict with the status of Postgres/Patroni. The keys are:
            * ``state``: Postgres state among ``stopping``, ``stopped``, ``stop failed``, ``crashed``, ``running``,
                ``starting``, ``start failed``, ``restarting``, ``restart failed``, ``initializing new cluster``,
                ``initdb failed``, ``running custom bootstrap script``, ``custom bootstrap failed``,
                ``creating replica``, or ``unknown``;
            * ``postmaster_start_time``: ``pg_postmaster_start_time()``;
            * ``role``: ``replica`` or ``master`` based on ``pg_is_in_recovery()`` output;
            * ``server_version``: Postgres version without periods, e.g. ``150002`` for Postgres ``15.2``;
            * ``xlog``: dictionary. Its structure depends on ``role``:
                * If ``master``:
                    * ``location``: ``pg_current_wal_lsn()``
                * If ``replica``:
                    * ``received_location``: ``pg_wal_lsn_diff(pg_last_wal_receive_lsn(), '0/0')``;
                    * ``replayed_location``: ``pg_wal_lsn_diff(pg_last_wal_replay_lsn(), '0/0)``;
                    * ``replayed_timestamp``: ``pg_last_xact_replay_timestamp``;
                    * ``paused``: ``pg_is_wal_replay_paused()``;
            * ``sync_standby``: ``True`` if replication mode is synchronous and this is a sync standby;
            * ``timeline``: PostgreSQL primary node timeline;
            * ``replication``: :class:`list` of :class:`dict` entries, one for each replication connection. Each entry
                contains the following keys:
                * ``application_name``: ``pg_stat_activity.application_name``;
                * ``client_addr``: ``pg_stat_activity.client_addr``;
                * ``state``: ``pg_stat_replication.state``;
                * ``sync_priority``: ``pg_stat_replication.sync_priority``;
                * ``sync_state``: ``pg_stat_replication.sync_state``;
                * ``usename``: ``pg_stat_activity.usename``.
            * ``pause``: ``True`` if cluster is in maintenance mode;
            * ``cluster_unlocked``: ``True`` if cluster has no node holding the leader lock;
            * ``failsafe_mode_is_active``: ``True`` if DCS failsafe mode is currently active;
            * ``dcs_last_seen``: epoch timestamp DCS was last reached by Patroni.
        """
        postgresql = self.server.patroni.postgresql
        cluster = self.server.patroni.dcs.cluster
        global_config = self.server.patroni.config.get_global_config(cluster)
        try:

            if postgresql.state not in ('running', 'restarting', 'starting'):
                raise RetryFailedError('')
            replication_state = ('(pg_catalog.pg_stat_get_wal_receiver()).status'
                                 if postgresql.major_version >= 90600 else 'NULL') + ", " +\
                ("pg_catalog.current_setting('restore_command')" if postgresql.major_version >= 120000 else "NULL")
            stmt = ("SELECT " + postgresql.POSTMASTER_START_TIME + ", " + postgresql.TL_LSN + ","
                    " pg_catalog.pg_last_xact_replay_timestamp(), " + replication_state + ","
                    " pg_catalog.array_to_json(pg_catalog.array_agg(pg_catalog.row_to_json(ri))) "
                    "FROM (SELECT (SELECT rolname FROM pg_catalog.pg_authid WHERE oid = usesysid) AS usename,"
                    " application_name, client_addr, w.state, sync_state, sync_priority"
                    " FROM pg_catalog.pg_stat_get_wal_senders() w, pg_catalog.pg_stat_get_activity(pid)) AS ri")

            row = self.query(stmt.format(postgresql.wal_name, postgresql.lsn_name), retry=retry)[0]

            result = {
                'state': postgresql.state,
                'postmaster_start_time': row[0],
                'role': 'replica' if row[1] == 0 else 'master',
                'server_version': postgresql.server_version,
                'xlog': ({
                    'received_location': row[4] or row[3],
                    'replayed_location': row[3],
                    'replayed_timestamp': row[6],
                    'paused': row[5]} if row[1] == 0 else {
                    'location': row[2]
                })
            }

            if result['role'] == 'replica' and global_config.is_standby_cluster:
                result['role'] = postgresql.role

            if result['role'] == 'replica' and global_config.is_synchronous_mode\
                    and cluster and cluster.sync.matches(postgresql.name):
                result['sync_standby'] = True

            if row[1] > 0:
                result['timeline'] = row[1]
            else:
                leader_timeline = None\
                    if not cluster or cluster.is_unlocked() or not cluster.leader else cluster.leader.timeline
                result['timeline'] = postgresql.replica_cached_timeline(leader_timeline)

            replication_state = postgresql.replication_state_from_parameters(row[1] > 0, row[7], row[8])
            if replication_state:
                result['replication_state'] = replication_state

            if row[9]:
                result['replication'] = row[9]

        except (psycopg.Error, RetryFailedError, PostgresConnectionException):
            state = postgresql.state
            if state == 'running':
                logger.exception('get_postgresql_status')
                state = 'unknown'
            result: Dict[str, Any] = {'state': state, 'role': postgresql.role}

        if global_config.is_paused:
            result['pause'] = True
        if not cluster or cluster.is_unlocked():
            result['cluster_unlocked'] = True
        if self.server.patroni.ha.failsafe_is_active():
            result['failsafe_mode_is_active'] = True
        result['dcs_last_seen'] = self.server.patroni.dcs.last_seen
        return result

    def handle_one_request(self) -> None:
        """Parse and dispatch a request to the appropriate ``do_*`` method.

        .. note::
            This is only used to keep track of latency when logging messages through :func:`log_message`.
        """
        self.__start_time = time.time()
        BaseHTTPRequestHandler.handle_one_request(self)

    def log_message(self, format: str, *args: Any) -> None:
        """Log a custom ``debug`` message.

        Additionally, to *format*, the log entry contains the client IP address and the current latency of the request.

        :param format: printf-style format string message to be logged.
        :param args: arguments to be applied as inputs to *format*.
        """
        latency = 1000.0 * (time.time() - self.__start_time)
        logger.debug("API thread: %s - - %s latency: %0.3f ms", self.client_address[0], format % args, latency)


class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
    """Patroni REST API server.

    An asynchronous thread-based HTTP server.
    """

    # On 3.7+ the `ThreadingMixIn` gathers all non-daemon worker threads in order to join on them at server close.
    daemon_threads = True  # Make worker threads "fire and forget" to prevent a memory leak.

    def __init__(self, patroni: Patroni, config: Dict[str, Any]) -> None:
        """Establish patroni configuration for the REST API daemon.

        Create a :class:`RestApiServer` instance.

        :param patroni: Patroni daemon process.
        :param config: ``restapi`` section of Patroni configuration.
        """
        self.connection_string: str
        self.__auth_key = None
        self.__allowlist_include_members: Optional[bool] = None
        self.__allowlist: Tuple[Union[IPv4Network, IPv6Network], ...] = ()
        self.http_extra_headers: Dict[str, str] = {}
        self.patroni = patroni
        self.__listen = None
        self.request_queue_size = int(config.get('request_queue_size', 5))
        self.__ssl_options: Dict[str, Any] = {}
        self.__ssl_serial_number = None
        self._received_new_cert = False
        self.reload_config(config)
        self.daemon = True

    def query(self, sql: str, *params: Any) -> List[Tuple[Any, ...]]:
        """Execute *sql* query with *params*.

        :param sql: the SQL statement to be run.
        :param params: positional arguments to be used as parameters for *sql*.

        :returns: a list of rows that were fetched from the database.
        :raises psycopg.Error: if had issues while executing *sql*.
        :raises PostgresConnectionException: if had issues while connecting to the database.
        """
        cursor = None
        try:
            with self.patroni.postgresql.connection().cursor() as cursor:
                cursor.execute(sql.encode('utf-8'), params)
                return [r for r in cursor]
        except psycopg.Error as e:
            if cursor and cursor.connection.closed == 0:
                raise e
            raise PostgresConnectionException('connection problems')

    @staticmethod
    def _set_fd_cloexec(fd: socket.socket) -> None:
        """Set ``FD_CLOEXEC`` for *fd*.

        It is used to avoid inheriting the REST API port when forking its process.

        .. note::
            Only takes effect on non-Windows environments.

        :param fd: socket file descriptor.
        """
        if os.name != 'nt':
            import fcntl
            flags = fcntl.fcntl(fd, fcntl.F_GETFD)
            fcntl.fcntl(fd, fcntl.F_SETFD, flags | fcntl.FD_CLOEXEC)

    def check_basic_auth_key(self, key: str) -> bool:
        """Check if *key* matches the password configured for the REST API.

        :param key: the password received through the Basic authorization header of an HTTP request.

        :returns: ``True`` if *key* matches the password configured for the REST API.
        """
        # pyright -- ``__auth_key`` was already checked through the caller method (:func:`check_auth_header`).
        if TYPE_CHECKING:  # pragma: no cover
            assert self.__auth_key is not None
        return hmac.compare_digest(self.__auth_key, key.encode('utf-8'))

    def check_auth_header(self, auth_header: Optional[str]) -> Optional[str]:
        """Validate HTTP Basic authorization header, if present.

        :param auth_header: value of ``Authorization`` HTTP header, if present, else ``None``.

        :returns: an error message if any issue is found, ``None`` otherwise.
        """
        if self.__auth_key:
            if auth_header is None:
                return 'no auth header received'
            if not auth_header.startswith('Basic ') or not self.check_basic_auth_key(auth_header[6:]):
                return 'not authenticated'

    @staticmethod
    def __resolve_ips(host: str, port: int) -> Iterator[Union[IPv4Network, IPv6Network]]:
        """Resolve *host* + *port* to one or more IP networks.

        :param host: hostname to be checked.
        :param port: port to be checked.

        :rtype: Iterator[Union[IPv4Network, IPv6Network]] of *host* + *port* resolved to IP networks.
        """
        try:
            for _, _, _, _, sa in socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM, socket.IPPROTO_TCP):
                yield ip_network(sa[0], False)
        except Exception as e:
            logger.error('Failed to resolve %s: %r', host, e)

    def __members_ips(self) -> Iterator[Union[IPv4Network, IPv6Network]]:
        """Resolve each Patroni node ``restapi.connect_address`` to IP networks.

        .. note::
            Only yields object if ``restapi.allowlist_include_members`` setting is enabled.

        :rtype: Iterator[Union[IPv4Network, IPv6Network]] of each node ``restapi.connect_address`` resolved to an IP
            network.
        """
        cluster = self.patroni.dcs.cluster
        if self.__allowlist_include_members and cluster:
            for cluster in [cluster] + list(cluster.workers.values()):
                for member in cluster.members:
                    if member.api_url:
                        try:
                            r = urlparse(member.api_url)
                            if r.hostname:
                                port = r.port or (443 if r.scheme == 'https' else 80)
                                for ip in self.__resolve_ips(r.hostname, port):
                                    yield ip
                        except Exception as e:
                            logger.debug('Failed to parse url %s: %r', member.api_url, e)

    def check_access(self, rh: RestApiHandler) -> Optional[bool]:
        """Ensure client has enough privileges to perform a given request.

        Write a response back to the client if any issue is observed, and the HTTP status may be:
            * ``401``: if ``Authorization`` header is missing or contain an invalid password;
            * ``403``: if:
                * ``restapi.allowlist`` was configured, but client IP is not in the allowed list; or
                * ``restapi.allowlist_include_members`` is enabled, but client IP is not in the members list; or
                * a client certificate is expected by the server, but is missing in the request.

        :param rh: the request which access should be checked.

        :returns: ``True`` if client access verification succeeded, otherwise ``None``.
        """
        if self.__allowlist or self.__allowlist_include_members:
            incoming_ip = ip_address(rh.client_address[0])
            if not any(incoming_ip in net for net in self.__allowlist + tuple(self.__members_ips())):
                return rh.write_response(403, 'Access is denied')

        if not hasattr(rh.request, 'getpeercert') or not rh.request.getpeercert():  # valid client cert isn't present
            if self.__protocol == 'https' and self.__ssl_options.get('verify_client') in ('required', 'optional'):
                return rh.write_response(403, 'client certificate required')

        reason = self.check_auth_header(rh.headers.get('Authorization'))
        if reason:
            headers = {'WWW-Authenticate': 'Basic realm="' + self.patroni.__class__.__name__ + '"'}
            return rh.write_response(401, reason, headers=headers)
        return True

    @staticmethod
    def __has_dual_stack() -> bool:
        """Check if the system has support for dual stack sockets.

        :returns: ``True`` if it has support for dual stack sockets.
        """
        if hasattr(socket, 'AF_INET6') and hasattr(socket, 'IPPROTO_IPV6') and hasattr(socket, 'IPV6_V6ONLY'):
            sock = None
            try:
                sock = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
                sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, False)
                return True
            except socket.error as e:
                logger.debug('Error when working with ipv6 socket: %s', e)
            finally:
                if sock:
                    sock.close()
        return False

    def __httpserver_init(self, host: str, port: int) -> None:
        """Start REST API HTTP server.

        .. note::
            If system has no support for dual stack sockets, then IPv4 is preferred over IPv6.

        :param host: host to bind REST API to.
        :param port: port to bind REST API to.
        """
        dual_stack = self.__has_dual_stack()
        hostname = host
        if hostname in ('', '*'):
            hostname = None

        info = socket.getaddrinfo(hostname, port, socket.AF_UNSPEC, socket.SOCK_STREAM, 0, socket.AI_PASSIVE)
        # in case dual stack is not supported we want IPv4 to be preferred over IPv6
        info.sort(key=lambda x: x[0] == socket.AF_INET, reverse=not dual_stack)

        self.address_family = info[0][0]
        try:
            HTTPServer.__init__(self, info[0][-1][:2], RestApiHandler)
        except socket.error:
            logger.error(
                "Couldn't start a service on '%s:%s', please check your `restapi.listen` configuration", hostname, port)
            raise

    def __initialize(self, listen: str, ssl_options: Dict[str, Any]) -> None:
        """Configure and start REST API HTTP server.

        .. note::
            This method can be called upon first initialization, and also when reloading Patroni. When reloading
            Patroni, it restarts the HTTP server thread.

        :param listen: IP and port to bind REST API to. It should be a string in the format ``host:port``, where
            ``host`` can be a hostname or IP address. It is the value of ``restapi.listen`` setting.
        :param ssl_options: dictionary that may contain the following keys, depending on what has been configured in
            ``restapi` section:
            * ``certfile``: path to PEM certificate. If given, will start in HTTPS mode;
            * ``keyfile``: path to key of ``certfile``;
            * ``keyfile_password``: password for decrypting ``keyfile``;
            * ``cafile``: path to CA file to validate client certificates;
            * ``ciphers``: permitted cipher suites;
            * ``verify_client``: value can be one among:
                * ``none``: do not check client certificates;
                * ``optional``: check client certificate only for unsafe REST API endpoints;
                * ``required``: check client certificate for all REST API endpoints.

        :raises ValueError: if any issue is faced while parsing *listen*.
        """
        try:
            host, port = split_host_port(listen, None)
        except Exception:
            raise ValueError('Invalid "restapi" config: expected <HOST>:<PORT> for "listen", but got "{0}"'
                             .format(listen))

        reloading_config = self.__listen is not None  # changing config in runtime
        if reloading_config:
            self.shutdown()
            # Rely on ThreadingMixIn.server_close() to have all requests terminate before we continue
            self.server_close()

        self.__listen = listen
        self.__ssl_options = ssl_options
        self._received_new_cert = False  # reset to False after reload_config()

        self.__httpserver_init(host, port)
        Thread.__init__(self, target=self.serve_forever)
        self._set_fd_cloexec(self.socket)

        # wrap socket with ssl if 'certfile' is defined in a config.yaml
        # Sometime it's also needed to pass reference to a 'keyfile'.
        self.__protocol = 'https' if ssl_options.get('certfile') else 'http'
        if self.__protocol == 'https':
            import ssl
            ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH, cafile=ssl_options.get('cafile'))
            if ssl_options.get('ciphers'):
                ctx.set_ciphers(ssl_options['ciphers'])
            ctx.load_cert_chain(certfile=ssl_options['certfile'], keyfile=ssl_options.get('keyfile'),
                                password=ssl_options.get('keyfile_password'))
            verify_client = ssl_options.get('verify_client')
            if verify_client:
                modes = {'none': ssl.CERT_NONE, 'optional': ssl.CERT_OPTIONAL, 'required': ssl.CERT_REQUIRED}
                if verify_client in modes:
                    ctx.verify_mode = modes[verify_client]
                else:
                    logger.error('Bad value in the "restapi.verify_client": %s', verify_client)
            self.__ssl_serial_number = self.get_certificate_serial_number()
            self.socket = ctx.wrap_socket(self.socket, server_side=True, do_handshake_on_connect=False)
        if reloading_config:
            self.start()

    def process_request_thread(self, request: Union[socket.socket, Tuple[bytes, socket.socket]],
                               client_address: Tuple[str, int]) -> None:
        """Process a request to the REST API.

        Wrapper for :func:`ThreadingMixIn.process_request_thread` that additionally:
            * Enable TCP keepalive
            * Perform SSL handshake (if an SSL socket).

        :param request: socket to handle the client request.
        :param client_address: tuple containing the client IP and port.
        """
        if isinstance(request, socket.socket):
            enable_keepalive(request, 10, 3)
        if hasattr(request, 'context'):  # SSLSocket
            from ssl import SSLSocket
            if isinstance(request, SSLSocket):  # pyright
                request.do_handshake()
        super(RestApiServer, self).process_request_thread(request, client_address)

    def shutdown_request(self, request: Union[socket.socket, Tuple[bytes, socket.socket]]) -> None:
        """Shut down a request to the REST API.

        Wrapper for :func:`HTTPServer.shutdown_request` that additionally:
            * Perform SSL shutdown handshake (if a SSL socket).

        :param request: socket to handle the client request.
        """
        if hasattr(request, 'context'):  # SSLSocket
            try:
                from ssl import SSLSocket
                if isinstance(request, SSLSocket):  # pyright
                    request.unwrap()
            except Exception as e:
                logger.debug('Failed to shutdown SSL connection: %r', e)
        super(RestApiServer, self).shutdown_request(request)

    def get_certificate_serial_number(self) -> Optional[str]:
        """Get serial number of the certificate used by the REST API.

        :returns: serial number of the certificate configured through ``restapi.certfile`` setting.
        """
        if self.__ssl_options.get('certfile'):
            import ssl
            try:
                crt: Dict[str, Any] = ssl._ssl._test_decode_cert(self.__ssl_options['certfile'])  # pyright: ignore
                if TYPE_CHECKING:  # pragma: no cover
                    assert isinstance(crt, dict)
                return crt.get('serialNumber')
            except ssl.SSLError as e:
                logger.error('Failed to get serial number from certificate %s: %r', self.__ssl_options['certfile'], e)

    def reload_local_certificate(self) -> Optional[bool]:
        """Reload the SSL certificate used by the REST API.

        :return: ``True`` if a different certificate has been configured through ``restapi.certfile` setting, ``None``
            otherwise.
        """
        if self.__protocol == 'https':
            on_disk_cert_serial_number = self.get_certificate_serial_number()
            if on_disk_cert_serial_number != self.__ssl_serial_number:
                self._received_new_cert = True
                self.__ssl_serial_number = on_disk_cert_serial_number
                return True

    def _build_allowlist(self, value: Optional[List[str]]) -> Iterator[Union[IPv4Network, IPv6Network]]:
        """Resolve each entry in *value* to an IP network object.

        :param value: list of IPs and/or networks contained in ``restapi.allowlist`` setting. Each item can be a host,
            an IP, or a network in CIDR format.

        :rtype: Iterator[Union[IPv4Network, IPv6Network]] of *host* + *port* resolved to IP networks.
        """
        if isinstance(value, list):
            for v in value:
                if '/' in v:  # netmask
                    try:
                        yield ip_network(v, False)
                    except Exception as e:
                        logger.error('Invalid value "%s" in the allowlist: %r', v, e)
                else:  # ip or hostname, try to resolve it
                    for ip in self.__resolve_ips(v, 8080):
                        yield ip

    def reload_config(self, config: Dict[str, Any]) -> None:
        """Reload REST API configuration.

        :param config: dictionary representing values under the ``restapi`` configuration section.
        :raises ValueError: if ``listen`` key is not present in *config*.
        """
        if 'listen' not in config:  # changing config in runtime
            raise ValueError('Can not find "restapi.listen" config')

        self.__allowlist = tuple(self._build_allowlist(config.get('allowlist')))
        self.__allowlist_include_members = config.get('allowlist_include_members')

        ssl_options = {n: config[n] for n in ('certfile', 'keyfile', 'keyfile_password',
                                              'cafile', 'ciphers') if n in config}

        self.http_extra_headers = config.get('http_extra_headers') or {}
        self.http_extra_headers.update((config.get('https_extra_headers') or {}) if ssl_options.get('certfile') else {})

        if isinstance(config.get('verify_client'), str):
            ssl_options['verify_client'] = config['verify_client'].lower()

        if self.__listen != config['listen'] or self.__ssl_options != ssl_options or self._received_new_cert:
            self.__initialize(config['listen'], ssl_options)

        self.__auth_key = base64.b64encode(config['auth'].encode('utf-8')) if 'auth' in config else None
        # pyright -- ``__listen`` is initially created as ``None``, but right after that it is replaced with a string
        # through :func:`__initialize`.
        if TYPE_CHECKING:  # pragma: no cover
            assert isinstance(self.__listen, str)
        self.connection_string = uri(self.__protocol, config.get('connect_address') or self.__listen, 'patroni')

    def handle_error(self, request: Union[socket.socket, Tuple[bytes, socket.socket]],
                     client_address: Tuple[str, int]) -> None:
        """Handle any exception that is thrown while handling a request to the REST API.

        Logs ``WARNING`` messages with the client information, and the stack trace of the faced exception.

        :param request: the request that faced an exception.
        :param client_address: a tuple composed of the IP and port of the client connection.
        """
        logger.warning('Exception happened during processing of request from %s:%s',
                       client_address[0], client_address[1])
        logger.warning(traceback.format_exc())
