import atexit
import base64
import datetime
import functools
import json
import logging
import os
import random
import socket
import tempfile
import time

from collections import defaultdict
from copy import deepcopy
from http.client import HTTPException
from threading import Condition, Lock, Thread
from typing import Any, Callable, cast, Collection, Dict, List, Optional, Tuple, Type, TYPE_CHECKING, Union

import urllib3
import yaml

from urllib3.exceptions import HTTPError

from ..collections import EMPTY_DICT
from ..exceptions import DCSError
from ..postgresql.misc import PostgresqlRole, PostgresqlState
from ..postgresql.mpp import AbstractMPP
from ..utils import deep_compare, iter_response_objects, \
    keepalive_socket_options, Retry, RetryFailedError, tzutc, uri, USER_AGENT
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, Status, SyncState, TimelineHistory

if TYPE_CHECKING:  # pragma: no cover
    from ..config import Config

logger = logging.getLogger(__name__)

KUBE_CONFIG_DEFAULT_LOCATION = os.environ.get('KUBECONFIG', '~/.kube/config')
SERVICE_HOST_ENV_NAME = 'KUBERNETES_SERVICE_HOST'
SERVICE_PORT_ENV_NAME = 'KUBERNETES_SERVICE_PORT'
SERVICE_TOKEN_FILENAME = '/var/run/secrets/kubernetes.io/serviceaccount/token'
SERVICE_CERT_FILENAME = '/var/run/secrets/kubernetes.io/serviceaccount/ca.crt'
__temp_files: List[str] = []


class KubernetesError(DCSError):
    pass


def _cleanup_temp_files() -> None:
    global __temp_files
    for temp_file in __temp_files:
        try:
            os.remove(temp_file)
        except OSError:
            pass
    __temp_files = []


def _create_temp_file(content: bytes) -> str:
    if len(__temp_files) == 0:
        atexit.register(_cleanup_temp_files)

    fd, name = tempfile.mkstemp()
    os.write(fd, content)
    os.close(fd)
    __temp_files.append(name)
    return name


# this function does the same mapping of snake_case => camelCase for > 97% of cases as autogenerated swagger code
def to_camel_case(value: str) -> str:
    reserved = {'api', 'apiv3', 'cidr', 'cpu', 'csi', 'id', 'io', 'ip', 'ipc', 'pid', 'tls', 'uri', 'url', 'uuid'}
    words = value.split('_')
    return words[0] + ''.join(w.upper() if w in reserved else w.title() for w in words[1:])


class K8sConfig(object):

    class ConfigException(Exception):
        pass

    def __init__(self) -> None:
        self.pool_config: Dict[str, Any] = {'maxsize': 10, 'num_pools': 10}  # urllib3.PoolManager config
        self._token_expires_at = datetime.datetime.max
        self._headers: Dict[str, str] = {}
        self._make_headers()

    def _set_token(self, token: str) -> None:
        self._headers['authorization'] = 'Bearer ' + token

    def _make_headers(self, token: Optional[str] = None, **kwargs: Any) -> None:
        self._headers = urllib3.make_headers(user_agent=USER_AGENT, **kwargs)
        if token:
            self._set_token(token)

    def _read_token_file(self) -> str:
        if not os.path.isfile(SERVICE_TOKEN_FILENAME):
            raise self.ConfigException('Service token file does not exists.')
        with open(SERVICE_TOKEN_FILENAME) as f:
            token = f.read()
            if not token:
                raise self.ConfigException('Token file exists but empty.')
            self._token_expires_at = datetime.datetime.now() + self._token_refresh_interval
            return token

    def load_incluster_config(self, ca_certs: str = SERVICE_CERT_FILENAME,
                              token_refresh_interval: datetime.timedelta = datetime.timedelta(minutes=1)) -> None:
        if SERVICE_HOST_ENV_NAME not in os.environ or SERVICE_PORT_ENV_NAME not in os.environ:
            raise self.ConfigException('Service host/port is not set.')
        if not os.environ[SERVICE_HOST_ENV_NAME] or not os.environ[SERVICE_PORT_ENV_NAME]:
            raise self.ConfigException('Service host/port is set but empty.')

        if not os.path.isfile(ca_certs):
            raise self.ConfigException('Service certificate file does not exists.')
        with open(ca_certs) as f:
            if not f.read():
                raise self.ConfigException('Cert file exists but empty.')
        self.pool_config['ca_certs'] = ca_certs
        self._token_refresh_interval = token_refresh_interval
        token = self._read_token_file()
        self._make_headers(token=token)
        self._server = uri('https', (os.environ[SERVICE_HOST_ENV_NAME], os.environ[SERVICE_PORT_ENV_NAME]))

    @staticmethod
    def _get_by_name(config: Dict[str, List[Dict[str, Any]]], section: str, name: str) -> Optional[Dict[str, Any]]:
        for c in config[section + 's']:
            if c['name'] == name:
                return c[section]

    def _pool_config_from_file_or_data(self, config: Dict[str, str], file_key_name: str, pool_key_name: str) -> None:
        data_key_name = file_key_name + '-data'
        if data_key_name in config:
            self.pool_config[pool_key_name] = _create_temp_file(base64.b64decode(config[data_key_name]))
        elif file_key_name in config:
            self.pool_config[pool_key_name] = config[file_key_name]

    def load_kube_config(self, context: Optional[str] = None) -> None:
        with open(os.path.expanduser(KUBE_CONFIG_DEFAULT_LOCATION)) as f:
            config: Dict[str, Any] = yaml.safe_load(f)

        context = context or config['current-context']
        if TYPE_CHECKING:  # pragma: no cover
            assert isinstance(context, str)
        context_value = self._get_by_name(config, 'context', context)
        if TYPE_CHECKING:  # pragma: no cover
            assert isinstance(context_value, dict)
        cluster = self._get_by_name(config, 'cluster', context_value['cluster'])
        if TYPE_CHECKING:  # pragma: no cover
            assert isinstance(cluster, dict)
        user = self._get_by_name(config, 'user', context_value['user'])
        if TYPE_CHECKING:  # pragma: no cover
            assert isinstance(user, dict)

        self._server = cluster['server'].rstrip('/')
        if self._server.startswith('https'):
            self._pool_config_from_file_or_data(user, 'client-certificate', 'cert_file')
            self._pool_config_from_file_or_data(user, 'client-key', 'key_file')
            self._pool_config_from_file_or_data(cluster, 'certificate-authority', 'ca_certs')
            self.pool_config['cert_reqs'] = 'CERT_NONE' if cluster.get('insecure-skip-tls-verify') else 'CERT_REQUIRED'
        if user.get('token'):
            self._make_headers(token=user['token'])
        elif 'username' in user and 'password' in user:
            self._make_headers(basic_auth=':'.join((user['username'], user['password'])))

    @property
    def server(self) -> str:
        return self._server

    @property
    def headers(self) -> Dict[str, str]:
        if self._token_expires_at <= datetime.datetime.now():
            try:
                self._set_token(self._read_token_file())
            except Exception as e:
                logger.error('Failed to refresh service account token: %r', e)
        return self._headers.copy()


class K8sObject(object):

    def __init__(self, kwargs: Dict[str, Any]) -> None:
        self._dict = {k: self._wrap(k, v) for k, v in kwargs.items()}

    def get(self, name: str, default: Optional[Any] = None) -> Optional[Any]:
        return self._dict.get(name, default)

    def __getattr__(self, name: str) -> Any:
        return self.get(to_camel_case(name))

    @classmethod
    def _wrap(cls, parent: Optional[str], value: Any) -> Any:
        if isinstance(value, dict):
            data_dict: Dict[str, Any] = cast(Dict[str, Any], value)
            # we know that `annotations` and `labels` are dicts and therefore don't want to convert them into K8sObject
            return data_dict if parent in {'annotations', 'labels'} and \
                all(isinstance(v, str) for v in data_dict.values()) else cls(data_dict)
        elif isinstance(value, list):
            return [cls._wrap(None, v) for v in cast(List[Any], value)]
        else:
            return value

    def to_dict(self) -> Dict[str, Any]:
        return self._dict

    def __repr__(self) -> str:
        return json.dumps(self, indent=4, default=lambda o: o.to_dict())


class K8sException(Exception):
    pass


class K8sConnectionFailed(K8sException):
    pass


class K8sClient(object):

    class rest(object):

        class ApiException(Exception):
            def __init__(self, status: Optional[int] = None, reason: Optional[str] = None,
                         http_resp: Optional[urllib3.HTTPResponse] = None) -> None:
                self.status = http_resp.status if http_resp else status
                self.reason = http_resp.reason if http_resp else reason
                self.body = http_resp.data if http_resp else None
                self.headers = http_resp.headers if http_resp else None

            def __str__(self) -> str:
                error_message = "({0})\nReason: {1}\n".format(self.status, self.reason)
                if self.headers:
                    error_message += "HTTP response headers: {0}\n".format(self.headers)
                if self.body:
                    error_message += "HTTP response body: {0}\n".format(self.body)
                return error_message

    class ApiClient(object):

        _API_URL_PREFIX = '/api/v1/namespaces/'

        def __init__(self, bypass_api_service: Optional[bool] = False) -> None:
            self._bypass_api_service = bypass_api_service
            self.pool_manager = urllib3.PoolManager(**k8s_config.pool_config)
            self._base_uri = k8s_config.server
            self._api_servers_cache = [k8s_config.server]
            self._api_servers_cache_updated = 0
            self.set_api_servers_cache_ttl(10)
            self.set_read_timeout(10)
            try:
                self._load_api_servers_cache()
            except K8sException:
                pass

        def set_read_timeout(self, timeout: Union[int, float]) -> None:
            self._read_timeout = timeout

        def set_api_servers_cache_ttl(self, ttl: int) -> None:
            self._api_servers_cache_ttl = ttl - 0.5

        def set_base_uri(self, value: str) -> None:
            logger.info('Selected new K8s API server endpoint %s', value)
            # We will connect by IP of the K8s master node which is not listed as alternative name
            self.pool_manager.connection_pool_kw['assert_hostname'] = False
            self._base_uri = value

        @staticmethod
        def _handle_server_response(response: urllib3.HTTPResponse,
                                    _preload_content: bool) -> Union[urllib3.HTTPResponse, K8sObject]:
            if response.status not in range(200, 206):
                raise k8s_client.rest.ApiException(http_resp=response)
            return K8sObject(json.loads(response.data.decode('utf-8'))) if _preload_content else response

        @staticmethod
        def _make_headers(headers: Optional[Dict[str, str]]) -> Dict[str, str]:
            ret = k8s_config.headers
            ret.update(headers or {})
            return ret

        @property
        def api_servers_cache(self) -> List[str]:
            base_uri, cache = self._base_uri, self._api_servers_cache
            return ([base_uri] if base_uri in cache else []) + [machine for machine in cache if machine != base_uri]

        def _get_api_servers(self, api_servers_cache: List[str]) -> List[str]:
            _, per_node_timeout, per_node_retries = self._calculate_timeouts(len(api_servers_cache))
            headers = self._make_headers({})
            kwargs = {'preload_content': True, 'retries': per_node_retries,
                      'timeout': urllib3.Timeout(connect=max(1.0, per_node_timeout / 2.0), total=per_node_timeout)}
            path = self._API_URL_PREFIX + 'default/endpoints/kubernetes'
            for base_uri in api_servers_cache:
                try:
                    response = self.pool_manager.request('GET', base_uri + path, headers=headers, **kwargs)
                    endpoint = self._handle_server_response(response, True)
                    if TYPE_CHECKING:  # pragma: no cover
                        assert isinstance(endpoint, K8sObject)
                    for subset in endpoint.subsets:
                        for port in subset.ports:
                            if port.name == 'https' and port.protocol == 'TCP':
                                addresses = [uri('https', (a.ip, port.port)) for a in subset.addresses]
                                if addresses:
                                    random.shuffle(addresses)
                                    return addresses
                except Exception as e:
                    if isinstance(e, k8s_client.rest.ApiException) and e.status == 403:
                        raise
                    self.pool_manager.clear()
                    logger.error('Failed to get "kubernetes" endpoint from %s: %r', base_uri, e)
            raise K8sConnectionFailed('No more K8s API server nodes in the cluster')

        def _refresh_api_servers_cache(self, updating_cache: Optional[bool] = False) -> None:
            if self._bypass_api_service:
                try:
                    api_servers_cache = [k8s_config.server] if updating_cache else self.api_servers_cache
                    self._api_servers_cache = self._get_api_servers(api_servers_cache)
                    if updating_cache:
                        self.pool_manager.clear()
                except k8s_client.rest.ApiException:  # 403 Permission denied
                    logger.warning("Kubernetes RBAC doesn't allow GET access to the 'kubernetes' "
                                   "endpoint in the 'default' namespace. Disabling 'bypass_api_service'.")
                    self._bypass_api_service = False
                    self._api_servers_cache = [k8s_config.server]
                    if not updating_cache:
                        self.pool_manager.clear()
                except K8sConnectionFailed:
                    if updating_cache:
                        raise K8sException("Could not get the list of K8s API server nodes")
                    return
            else:
                self._api_servers_cache = [k8s_config.server]

            if self._base_uri not in self._api_servers_cache:
                self.set_base_uri(self._api_servers_cache[0])
            self._api_servers_cache_updated = time.time()

        def refresh_api_servers_cache(self) -> None:
            if self._bypass_api_service and time.time() - self._api_servers_cache_updated > self._api_servers_cache_ttl:
                self._refresh_api_servers_cache()

        def _load_api_servers_cache(self) -> None:
            self._update_api_servers_cache = True
            self._refresh_api_servers_cache(True)
            self._update_api_servers_cache = False

        def _calculate_timeouts(self, api_servers: int, timeout: Optional[float] = None) -> Tuple[int, float, int]:
            """Calculate a request timeout and number of retries per single K8s API server node.
            In case if the timeout per node is too small (less than one second) we will reduce the number of nodes.
            For the cluster with only one API server node we will try to do 1 retry.
            No retries for clusters with 2 or more API server nodes. We better rely on switching to a different node."""

            per_node_timeout = timeout = float(timeout or self._read_timeout)

            max_retries = 3 - min(api_servers, 2)
            per_node_retries = 1
            min_timeout = 1.0

            while api_servers > 0:
                per_node_timeout = float(timeout) / api_servers
                if per_node_timeout >= min_timeout:
                    # for small clusters we will try to do more than one try on every node
                    while per_node_retries < max_retries and per_node_timeout / (per_node_retries + 1) >= min_timeout:
                        per_node_retries += 1
                    per_node_timeout /= per_node_retries
                    break
                # if the timeout per one node is to small try to reduce number of nodes
                api_servers -= 1
                max_retries = 1

            return api_servers, per_node_timeout, per_node_retries - 1

        def _do_http_request(self, retry: Optional[Retry], api_servers_cache: List[str],
                             method: str, path: str, **kwargs: Any) -> urllib3.HTTPResponse:
            some_request_failed = False
            for i, base_uri in enumerate(api_servers_cache):
                if i > 0:
                    logger.info('Retrying on %s', base_uri)
                try:
                    response = self.pool_manager.request(method, base_uri + path, **kwargs)
                    if some_request_failed:
                        self.set_base_uri(base_uri)
                        self._refresh_api_servers_cache()
                    return response
                except (HTTPError, HTTPException, socket.error, socket.timeout) as e:
                    self.pool_manager.clear()
                    if not retry:
                        # switch to the next node if request failed and retry is not allowed
                        if i + 1 < len(api_servers_cache):
                            self.set_base_uri(api_servers_cache[i + 1])
                        raise K8sException('{0} {1} request failed'.format(method, path))
                    logger.error('Request to server %s failed: %r', base_uri, e)
                    some_request_failed = True

            raise K8sConnectionFailed('No more API server nodes in the cluster')

        def request(
                self, retry: Optional[Retry], method: str, path: str,
                timeout: Union[int, float, Tuple[Union[int, float], Union[int, float]], urllib3.Timeout, None] = None,
                **kwargs: Any) -> urllib3.HTTPResponse:
            if self._update_api_servers_cache:
                self._load_api_servers_cache()

            api_servers_cache = self.api_servers_cache
            api_servers = len(api_servers_cache)

            if timeout:
                if isinstance(timeout, (int, float)):
                    timeout = urllib3.Timeout(total=timeout)
                elif isinstance(timeout, tuple) and len(timeout) == 2:
                    timeout = urllib3.Timeout(connect=timeout[0], read=timeout[1])
                retries = 0
            else:
                _, timeout, retries = self._calculate_timeouts(api_servers)
                timeout = urllib3.Timeout(connect=max(1.0, timeout / 2.0), total=timeout)
            kwargs.update(retries=retries, timeout=timeout)

            while True:
                try:
                    return self._do_http_request(retry, api_servers_cache, method, path, **kwargs)
                except K8sConnectionFailed as ex:
                    try:
                        self._load_api_servers_cache()
                        api_servers_cache = self.api_servers_cache
                        api_servers = len(api_servers_cache)
                    except Exception as e:
                        logger.debug('Failed to update list of K8s master nodes: %r', e)

                    if TYPE_CHECKING:  # pragma: no cover
                        assert isinstance(retry, Retry)  # K8sConnectionFailed is raised only if retry is not None!
                    sleeptime = retry.sleeptime
                    remaining_time = (retry.stoptime or time.time()) - sleeptime - time.time()
                    nodes, timeout, retries = self._calculate_timeouts(api_servers, remaining_time)
                    if nodes == 0:
                        self._update_api_servers_cache = True
                        raise ex
                    retry.sleep_func(sleeptime)
                    retry.update_delay()
                    # We still have some time left. Partially reduce `api_servers_cache` and retry request
                    kwargs.update(timeout=urllib3.Timeout(connect=max(1.0, timeout / 2.0), total=timeout),
                                  retries=retries)
                    api_servers_cache = api_servers_cache[:nodes]

        def call_api(self, method: str, path: str, headers: Optional[Dict[str, str]] = None,
                     body: Optional[Any] = None, _retry: Optional[Retry] = None, _preload_content: bool = True,
                     _request_timeout: Optional[float] = None, **kwargs: Any) -> Union[urllib3.HTTPResponse, K8sObject]:
            headers = self._make_headers(headers)
            fields = {to_camel_case(k): v for k, v in kwargs.items()}  # resource_version => resourceVersion
            body = json.dumps(body, default=lambda o: o.to_dict()) if body is not None else None

            response = self.request(_retry, method, self._API_URL_PREFIX + path, headers=headers, fields=fields,
                                    body=body, preload_content=_preload_content, timeout=_request_timeout)

            return self._handle_server_response(response, _preload_content)

    class CoreV1Api(object):

        def __init__(self, api_client: Optional['K8sClient.ApiClient'] = None) -> None:
            self._api_client = api_client or k8s_client.ApiClient()

        def __getattr__(self, func: str) -> Callable[..., Any]:
            # `func` name pattern: (action)_namespaced_(kind)
            action, kind = func.split('_namespaced_')  # (read|list|create|patch|replace|delete|delete_collection)
            kind = kind.replace('_', '') + ('s' * int(kind[-1] != 's'))  # plural, single word

            def wrapper(*args: Any, **kwargs: Any) -> Union[urllib3.HTTPResponse, K8sObject]:
                method = {'read': 'GET', 'list': 'GET', 'create': 'POST',
                          'replace': 'PUT'}.get(action, action.split('_')[0]).upper()

                if action == 'create' or len(args) == 1:  # namespace is a first argument and name in not in arguments
                    path = '/'.join([args[0], kind])
                else:  # name, namespace followed by optional body
                    path = '/'.join([args[1], kind, args[0]])

                headers = {'Content-Type': 'application/strategic-merge-patch+json'} if action == 'patch' else {}

                if len(args) == 3:  # name, namespace, body
                    body = args[2]
                elif action == 'create':  # namespace, body
                    body = args[1]  # pyright: ignore [reportGeneralTypeIssues]
                elif action == 'delete':  # name, namespace
                    body = kwargs.pop('body', None)
                else:
                    body = None

                return self._api_client.call_api(method, path, headers, body, **kwargs)
            return wrapper

    class _K8sObjectTemplate(K8sObject):
        """The template for objects which we create locally, e.g. k8s_client.V1ObjectMeta & co"""
        def __init__(self, **kwargs: Any) -> None:
            self._dict = {to_camel_case(k): v for k, v in kwargs.items()}

    def __init__(self) -> None:
        self.__cls_cache: Dict[str, Type['K8sClient._K8sObjectTemplate']] = {}
        self.__cls_lock = Lock()

    def __getattr__(self, name: str) -> Type['K8sClient._K8sObjectTemplate']:
        with self.__cls_lock:
            if name not in self.__cls_cache:
                self.__cls_cache[name] = type(name, (self._K8sObjectTemplate,), {})
        return self.__cls_cache[name]


k8s_client = K8sClient()
k8s_config = K8sConfig()


class KubernetesRetriableException(k8s_client.rest.ApiException):

    def __init__(self, orig: K8sClient.rest.ApiException) -> None:
        super(KubernetesRetriableException, self).__init__(orig.status, orig.reason)
        self.body = orig.body
        self.headers = orig.headers

    @property
    def sleeptime(self) -> Optional[int]:
        try:
            return int((self.headers or EMPTY_DICT).get('retry-after', ''))
        except Exception:
            return None


class CoreV1ApiProxy(object):
    """Proxy class to work with k8s_client.CoreV1Api() object"""

    _DEFAULT_RETRIABLE_HTTP_CODES = frozenset([500, 503, 504])

    def __init__(self, use_endpoints: Optional[bool] = False, bypass_api_service: Optional[bool] = False) -> None:
        self._api_client = k8s_client.ApiClient(bypass_api_service)
        self._core_v1_api = k8s_client.CoreV1Api(self._api_client)
        self._use_endpoints = bool(use_endpoints)
        self._retriable_http_codes = set(self._DEFAULT_RETRIABLE_HTTP_CODES)

    def configure_timeouts(self, loop_wait: int, retry_timeout: Union[int, float], ttl: int) -> None:
        # Normally every loop_wait seconds we should have receive something from the socket.
        # If we didn't received anything after the loop_wait + retry_timeout it is a time
        # to start worrying (send keepalive messages). Finally, the connection should be
        # considered as dead if we received nothing from the socket after the ttl seconds.
        self._api_client.pool_manager.connection_pool_kw['socket_options'] = \
            list(keepalive_socket_options(ttl, int(loop_wait + retry_timeout)))
        self._api_client.set_read_timeout(retry_timeout)
        self._api_client.set_api_servers_cache_ttl(loop_wait)

    def configure_retriable_http_codes(self, retriable_http_codes: List[int]) -> None:
        self._retriable_http_codes = self._DEFAULT_RETRIABLE_HTTP_CODES | set(retriable_http_codes)

    def refresh_api_servers_cache(self) -> None:
        self._api_client.refresh_api_servers_cache()

    def __getattr__(self, func: str) -> Callable[..., Any]:
        """Intercepts calls to `CoreV1Api` methods.

        Handles two important cases:
        1. Depending on whether Patroni is configured to work with `ConfigMaps` or `Endpoints`
           it remaps "virtual" method names from `*_kind` to `*_endpoints` or `*_config_map`.
        2. It handles HTTP error codes and raises `KubernetesRetriableException`
           if the given error is supposed to be handled with retry."""

        if func.endswith('_kind'):
            func = func[:-4] + ('endpoints' if self._use_endpoints else 'config_map')

        def wrapper(*args: Any, **kwargs: Any) -> Any:
            retriable_http_codes = self._retriable_http_codes | set(kwargs.pop('_retriable_http_codes', None) or [])
            try:
                return getattr(self._core_v1_api, func)(*args, **kwargs)
            except k8s_client.rest.ApiException as e:
                if e.status in retriable_http_codes or e.headers and 'retry-after' in e.headers:
                    raise KubernetesRetriableException(e)
                raise
        return wrapper

    @property
    def use_endpoints(self) -> bool:
        return self._use_endpoints


def _run_and_handle_exceptions(method: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
    try:
        return method(*args, **kwargs)
    except k8s_client.rest.ApiException as e:
        if e.status == 403:
            logger.exception('Permission denied')
        elif e.status != 409:  # Object exists or conflict in resource_version
            logger.exception('Unexpected error from Kubernetes API')
        return False
    except (RetryFailedError, K8sException) as e:
        raise KubernetesError(e)


def catch_kubernetes_errors(func: Callable[..., Any]) -> Callable[..., Any]:
    def wrapper(self: 'Kubernetes', *args: Any, **kwargs: Any) -> Any:
        try:
            return _run_and_handle_exceptions(func, self, *args, **kwargs)
        except KubernetesError:
            return False
    return wrapper


class ObjectCache(Thread):

    def __init__(self, dcs: 'Kubernetes', func: Callable[..., Any], retry: Retry,
                 condition: Condition, name: Optional[str] = None) -> None:
        super(ObjectCache, self).__init__()
        self.daemon = True
        self._dcs = dcs
        self._func = func
        self._retry = retry
        self._condition = condition
        self._name = name  # name of this pod
        self._is_ready = False
        self._response: Union[urllib3.HTTPResponse, bool, None] = None  # needs to be accessible from the `kill_stream`
        self._response_lock = Lock()  # protect the `self._response` from concurrent access
        self._object_cache: Dict[str, K8sObject] = {}
        self._object_cache_lock = Lock()
        self._annotations_map = {self._dcs.leader_path: getattr(self._dcs, '_LEADER'),
                                 self._dcs.config_path: getattr(self._dcs, '_CONFIG')}  # pyright
        self.start()

    def _list(self) -> K8sObject:
        try:
            return self._func(_retry=self._retry.copy())
        except Exception:
            time.sleep(1)
            raise

    def _watch(self, resource_version: str) -> urllib3.HTTPResponse:
        return self._func(_request_timeout=(self._retry.deadline, urllib3.Timeout.DEFAULT_TIMEOUT),
                          _preload_content=False, watch=True, resource_version=resource_version)

    def set(self, name: str, value: K8sObject) -> Tuple[bool, Optional[K8sObject]]:
        with self._object_cache_lock:
            old_value = self._object_cache.get(name)
            ret = not old_value or int(old_value.metadata.resource_version) < int(value.metadata.resource_version)
            if ret:
                self._object_cache[name] = value
        return ret, old_value

    def delete(self, name: str, resource_version: str) -> Tuple[bool, Optional[K8sObject]]:
        with self._object_cache_lock:
            old_value = self._object_cache.get(name)
            ret = old_value and int(old_value.metadata.resource_version) < int(resource_version)
            if ret:
                del self._object_cache[name]
        return bool(not old_value or ret), old_value

    def copy(self) -> Dict[str, K8sObject]:
        with self._object_cache_lock:
            return self._object_cache.copy()

    def get(self, name: str) -> Optional[K8sObject]:
        with self._object_cache_lock:
            return self._object_cache.get(name)

    def _process_event(self, event: Dict[str, Any]) -> None:
        ev_type = event['type']
        obj = event['object']
        name = obj['metadata']['name']

        new_value = None
        if ev_type in ('ADDED', 'MODIFIED'):
            obj = K8sObject(obj)
            success, old_value = self.set(name, obj)
            if success:
                new_value = (obj.metadata.annotations or EMPTY_DICT).get(self._annotations_map.get(name, ''))
        elif ev_type == 'DELETED':
            success, old_value = self.delete(name, obj['metadata']['resourceVersion'])
        else:
            return logger.warning('Unexpected event type: %s', ev_type)

        if success and obj.get('kind') != 'Pod':
            if old_value:
                old_value = (old_value.metadata.annotations or EMPTY_DICT).get(self._annotations_map.get(name, ''))

            value_changed = old_value != new_value and \
                (name != self._dcs.config_path or old_value is not None and new_value is not None)

            if value_changed:
                logger.debug('%s changed from %s to %s', name, old_value, new_value)

            # Do not wake up HA loop if we run as leader and received leader object update event
            if value_changed or name == self._dcs.leader_path and self._name != new_value:
                self._dcs.event.set()

    @staticmethod
    def _finish_response(response: urllib3.HTTPResponse) -> None:
        try:
            response.close()
        finally:
            response.release_conn()

    def _do_watch(self, resource_version: str) -> None:
        with self._response_lock:
            self._response = None
        response = self._watch(resource_version)
        with self._response_lock:
            if self._response is None:
                self._response = response

        if not self._response:
            return self._finish_response(response)

        for event in iter_response_objects(response):
            if event['object'].get('code') == 410:
                break
            self._process_event(event)

    def _build_cache(self) -> None:
        objects = self._list()
        with self._object_cache_lock:
            self._object_cache = {item.metadata.name: item for item in objects.items}
        with self._condition:
            self._is_ready = True
            self._condition.notify()

        try:
            self._do_watch(objects.metadata.resource_version)
        finally:
            with self._condition:
                self._is_ready = False
            with self._response_lock:
                response, self._response = self._response, None
            if isinstance(response, urllib3.HTTPResponse):
                self._finish_response(response)

    def kill_stream(self) -> None:
        sock = None
        with self._response_lock:
            if isinstance(self._response, urllib3.HTTPResponse):
                try:
                    sock = self._response.connection.sock if self._response.connection else None
                except Exception:
                    sock = None
            else:
                self._response = False
        if sock:
            try:
                sock.shutdown(socket.SHUT_RDWR)
                sock.close()
            except Exception as e:
                logger.debug('Error on socket.shutdown: %r', e)

    def run(self) -> None:
        while True:
            try:
                self._build_cache()
            except Exception as e:
                logger.error('ObjectCache.run %r', e)

    def is_ready(self) -> bool:
        """Must be called only when holding the lock on `_condition`"""
        return self._is_ready


class Kubernetes(AbstractDCS):

    def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
        self._labels = deepcopy(config['labels'])
        self._labels[config.get('scope_label', 'cluster-name')] = config['scope']
        self._label_selector = ','.join('{0}={1}'.format(k, v) for k, v in self._labels.items())
        self._namespace = config.get('namespace') or 'default'
        self._role_label = config.get('role_label', 'role')
        self._leader_label_value = config.get('leader_label_value', 'primary')
        self._follower_label_value = config.get('follower_label_value', 'replica')
        self._standby_leader_label_value = config.get('standby_leader_label_value', 'primary')
        self._tmp_role_label = config.get('tmp_role_label')
        self._bootstrap_labels: Dict[str, str] = {str(k): str(v)
                                                  for k, v in (config.get('bootstrap_labels') or EMPTY_DICT).items()}
        self._ca_certs = os.environ.get('PATRONI_KUBERNETES_CACERT', config.get('cacert')) or SERVICE_CERT_FILENAME
        super(Kubernetes, self).__init__({**config, 'namespace': ''}, mpp)
        if self._mpp.is_enabled():
            self._labels[self._mpp.k8s_group_label] = str(self._mpp.group)

        self._retry = Retry(deadline=config['retry_timeout'], max_delay=1, max_tries=-1,
                            retry_exceptions=KubernetesRetriableException)
        self._ttl = int(config.get('ttl') or 30)
        try:
            k8s_config.load_incluster_config(ca_certs=self._ca_certs)
        except k8s_config.ConfigException:
            k8s_config.load_kube_config(context=config.get('context', 'kind-kind'))

        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:
            port: Dict[str, Any] = {'port': int(p.get('port', '5432'))}
            port.update({n: p[n] for n in ('name', 'protocol') if p.get(n)})
            self.__ports.append(k8s_client.V1EndpointPort(**port))

        bypass_api_service = not self._ctl and config.get('bypass_api_service')
        self._api = CoreV1ApiProxy(config.get('use_endpoints'), bypass_api_service)
        self._should_create_config_service = self._api.use_endpoints
        self.reload_config(config)
        # leader_observed_record, leader_resource_version, and leader_observed_time are used only for leader race!
        self._leader_observed_record: Dict[str, str] = {}
        self._leader_observed_time = None
        self._leader_resource_version = None
        self.__do_not_watch = False

        self._condition = Condition()

        pods_func = functools.partial(self._api.list_namespaced_pod, self._namespace,
                                      label_selector=self._label_selector)
        self._pods = ObjectCache(self, pods_func, self._retry, self._condition)

        kinds_func = functools.partial(self._api.list_namespaced_kind, self._namespace,
                                       label_selector=self._label_selector)
        self._kinds = ObjectCache(self, kinds_func, self._retry, self._condition, self._name)

    def retry(self, method: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
        retry = self._retry.copy()
        kwargs['_retry'] = retry
        return retry(method, *args, **kwargs)

    def client_path(self, path: str) -> str:
        return super(Kubernetes, self).client_path(path)[1:].replace('/', '-')

    @property
    def leader_path(self) -> str:
        return super(Kubernetes, self).leader_path[:-7 if self._api.use_endpoints else None]

    def set_ttl(self, ttl: int) -> Optional[bool]:
        ttl = int(ttl)
        self.__do_not_watch = self._ttl != ttl
        self._ttl = ttl
        return None

    @property
    def ttl(self) -> int:
        return self._ttl

    def set_retry_timeout(self, retry_timeout: int) -> None:
        self._retry.deadline = retry_timeout

    def reload_config(self, config: Union['Config', Dict[str, Any]]) -> None:
        """Handles dynamic config changes.

        Either cause by changes in the local configuration file + SIGHUP or by changes of dynamic configuration"""

        super(Kubernetes, self).reload_config(config)
        if TYPE_CHECKING:  # pragma: no cover
            assert self._retry.deadline is not None
        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: 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(',')]

        try:
            self._api.configure_retriable_http_codes([int(c) for c in retriable_http_codes])
        except Exception as e:
            logger.warning('Invalid value of retriable_http_codes = %s: %r', config['retriable_http_codes'], e)

    @staticmethod
    def member(pod: K8sObject) -> Member:
        annotations = pod.metadata.annotations or EMPTY_DICT
        member = Member.from_node(pod.metadata.resource_version, pod.metadata.name, None, annotations.get('status', ''))
        member.data['pod_labels'] = pod.metadata.labels
        return member

    def _wait_caches(self, stop_time: float) -> None:
        while not (self._pods.is_ready() and self._kinds.is_ready()):
            timeout = stop_time - time.time()
            if timeout <= 0:
                raise RetryFailedError('Exceeded retry deadline')
            self._condition.wait(timeout)

    def _cluster_from_nodes(self, group: str, nodes: Dict[str, K8sObject], pods: Collection[K8sObject]) -> Cluster:
        members = [self.member(pod) for pod in pods]
        path = self._base_path[1:] + '-'
        if group:
            path += group + '-'

        config = nodes.get(path + self._CONFIG)
        metadata = config and config.metadata
        annotations = metadata and metadata.annotations or {}

        # get initialize flag
        initialize = annotations.get(self._INITIALIZE)

        # get global dynamic configuration
        config = metadata and ClusterConfig.from_node(metadata.resource_version,
                                                      annotations.get(self._CONFIG) or '{}',
                                                      metadata.resource_version if self._CONFIG in annotations else 0)

        # get timeline history
        history = metadata and TimelineHistory.from_node(metadata.resource_version,
                                                         annotations.get(self._HISTORY) or '[]')

        leader_path = path[:-1] if self._api.use_endpoints else path + self._LEADER
        leader = nodes.get(leader_path)
        metadata = leader and leader.metadata
        if leader_path == self.leader_path:  # We want to memorize leader_resource_version only for our cluster
            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 and slots
        status = Status.from_node(annotations)

        # get failsafe topology
        try:
            failsafe = json.loads(annotations.get(self._FAILSAFE, ''))
        except Exception:
            failsafe = None

        # get leader
        leader_record: Dict[str, str] = {n: annotations[n] for n in (self._LEADER, 'acquireTime',
                                         'ttl', 'renewTime', 'transitions') if n in annotations}
        # We want to memorize leader_observed_record and update leader_observed_time only for our cluster
        if leader_path == self.leader_path and (leader_record or self._leader_observed_record)\
                and leader_record != self._leader_observed_record:
            self._leader_observed_record = leader_record
            self._leader_observed_time = time.time()

        leader = leader_record.get(self._LEADER)
        try:
            ttl = int(leader_record.get('ttl', self._ttl)) or self._ttl
        except (TypeError, ValueError):
            ttl = self._ttl

        # We want to check validity of the leader record only for our own cluster
        if leader_path == self.leader_path and\
                not (metadata and self._leader_observed_time and self._leader_observed_time + ttl >= time.time()):
            leader = None

        if metadata:
            member = Member(-1, leader or '', None, {})
            member = ([m for m in members if m.name == leader] or [member])[0]
            leader = Leader(metadata.resource_version, None, member)
        else:
            leader = None

        # failover key
        failover = nodes.get(path + self._FAILOVER)
        metadata = failover and failover.metadata
        failover = metadata and Failover.from_node(metadata.resource_version,
                                                   (metadata.annotations or EMPTY_DICT).copy())

        # get synchronization state
        sync = nodes.get(path + self._SYNC)
        metadata = sync and sync.metadata
        sync = SyncState.from_node(metadata and metadata.resource_version, metadata and metadata.annotations)

        return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)

    def _postgresql_cluster_loader(self, path: Dict[str, Any]) -> Cluster:
        """Load and build the :class:`Cluster` object from DCS, which represents a single PostgreSQL cluster.

        :param path: the path in DCS where to load :class:`Cluster` from.

        :returns: :class:`Cluster` instance.
        """
        return self._cluster_from_nodes(path['group'], path['nodes'], path['pods'].values())

    def _mpp_cluster_loader(self, path: Dict[str, Any]) -> Dict[int, Cluster]:
        """Load and build all PostgreSQL clusters from a single MPP cluster.

        :param path: the path in DCS where to load Cluster(s) from.

        :returns: all MPP groups as :class:`dict`, with group IDs as keys and :class:`Cluster` objects as values.
        """
        clusters: Dict[str, Dict[str, Dict[str, K8sObject]]] = defaultdict(lambda: defaultdict(dict))

        for name, pod in path['pods'].items():
            group = pod.metadata.labels.get(self._mpp.k8s_group_label)
            if group and self._mpp.group_re.match(group):
                clusters[group]['pods'][name] = pod

        for name, kind in path['nodes'].items():
            group = kind.metadata.labels.get(self._mpp.k8s_group_label)
            if group and self._mpp.group_re.match(group):
                clusters[group]['nodes'][name] = kind
        return {int(group): self._cluster_from_nodes(group, value['nodes'], value['pods'].values())
                for group, value in clusters.items()}

    def __load_cluster(
            self, group: Optional[str], loader: Callable[[Dict[str, Any]], Union[Cluster, Dict[int, Cluster]]]
    ) -> Union[Cluster, Dict[int, Cluster]]:
        if TYPE_CHECKING:  # pragma: no cover
            assert self._retry.deadline is not None
        stop_time = time.time() + self._retry.deadline
        self._api.refresh_api_servers_cache()
        try:
            with self._condition:
                self._wait_caches(stop_time)
                pods = {name: pod for name, pod in self._pods.copy().items()
                        if not group or pod.metadata.labels.get(self._mpp.k8s_group_label) == group}
                nodes = {name: kind for name, kind in self._kinds.copy().items()
                         if not group or kind.metadata.labels.get(self._mpp.k8s_group_label) == group}
            return loader({'group': group, 'pods': pods, 'nodes': nodes})
        except Exception:
            logger.exception('get_cluster')
            raise KubernetesError('Kubernetes API is not responding properly')

    def _load_cluster(
            self, path: str, loader: Callable[[Any], Union[Cluster, Dict[int, Cluster]]]
    ) -> Union[Cluster, Dict[int, Cluster]]:
        group = str(self._mpp.group) if self._mpp.is_enabled() and path == self.client_path('') else None
        return self.__load_cluster(group, loader)

    def get_mpp_coordinator(self) -> Optional[Cluster]:
        """Load the PostgreSQL cluster for the MPP Coordinator.

        .. note::
            This method is only executed on the worker nodes to find the coordinator.

        :returns: Select :class:`Cluster` instance associated with the MPP Coordinator group ID.
        """
        try:
            ret = self.__load_cluster(str(self._mpp.coordinator_group_id), self._postgresql_cluster_loader)
            if TYPE_CHECKING:  # pragma: no cover
                assert isinstance(ret, Cluster)
            return ret
        except Exception as e:
            logger.error('Failed to load %s coordinator cluster from Kubernetes: %r', self._mpp.type, e)

    @staticmethod
    def compare_ports(p1: K8sObject, p2: K8sObject) -> bool:
        return p1.name == p2.name and p1.port == p2.port and (p1.protocol or 'TCP') == (p2.protocol or 'TCP')

    @staticmethod
    def subsets_changed(last_observed_subsets: List[K8sObject], ip: str, ports: List[K8sObject]) -> bool:
        """
        >>> ip = '1.2.3.4'
        >>> a = [k8s_client.V1EndpointAddress(ip=ip)]
        >>> s = [k8s_client.V1EndpointSubset(addresses=a)]
        >>> Kubernetes.subsets_changed(s, '1.2.3.5', [])
        True
        >>> s = [k8s_client.V1EndpointSubset(addresses=a, ports=[k8s_client.V1EndpointPort(protocol='TCP', port=1)])]
        >>> Kubernetes.subsets_changed(s, '1.2.3.4', [k8s_client.V1EndpointPort(port=5432)])
        True
        >>> p1 = k8s_client.V1EndpointPort(name='port1', port=1)
        >>> p2 = k8s_client.V1EndpointPort(name='port2', port=2)
        >>> p3 = k8s_client.V1EndpointPort(name='port3', port=3)
        >>> s = [k8s_client.V1EndpointSubset(addresses=a, ports=[p1, p2])]
        >>> Kubernetes.subsets_changed(s, ip, [p2, p3])
        True
        >>> s2 = [k8s_client.V1EndpointSubset(addresses=a, ports=[p2, p1])]
        >>> Kubernetes.subsets_changed(s, ip, [p2, p1])
        False
        """

        if len(last_observed_subsets) != 1:
            return True
        if len(last_observed_subsets[0].addresses or []) != 1 or \
                last_observed_subsets[0].addresses[0].ip != ip or \
                len(last_observed_subsets[0].ports) != len(ports):
            return True
        if len(ports) == 1:
            return not Kubernetes.compare_ports(last_observed_subsets[0].ports[0], ports[0])
        observed_ports = {p.name: p for p in last_observed_subsets[0].ports}
        for p in ports:
            if p.name not in observed_ports or not Kubernetes.compare_ports(p, observed_ports.pop(p.name)):
                return True
        return False

    def __target_ref(self, leader_ip: str, latest_subsets: List[K8sObject], pod: K8sObject) -> K8sObject:
        # we want to reuse existing target_ref if possible
        empty_addresses: List[K8sObject] = []
        for subset in latest_subsets:
            for address in subset.addresses or empty_addresses:
                if address.ip == leader_ip and address.target_ref and address.target_ref.name == self._name:
                    return address.target_ref
        return k8s_client.V1ObjectReference(kind='Pod', uid=pod.metadata.uid, namespace=self._namespace,
                                            name=self._name, resource_version=pod.metadata.resource_version)

    def _map_subsets(self, endpoints: Dict[str, Any], ips: List[str]) -> None:
        leader = self._kinds.get(self.leader_path)
        empty_addresses: List[K8sObject] = []
        latest_subsets = leader and leader.subsets or empty_addresses
        if not ips:
            # We want to have subsets empty
            if latest_subsets:
                endpoints['subsets'] = []
            return

        pod = self._pods.get(self._name)
        leader_ip = ips[0] or pod and pod.status.pod_ip
        # don't touch subsets if our (leader) ip is unknown or subsets is valid
        if leader_ip and self.subsets_changed(latest_subsets, leader_ip, self.__ports):
            kwargs = {'hostname': pod.spec.hostname, 'node_name': pod.spec.node_name,
                      'target_ref': self.__target_ref(leader_ip, latest_subsets, pod)} if pod else {}
            address = k8s_client.V1EndpointAddress(ip=leader_ip, **kwargs)
            endpoints['subsets'] = [k8s_client.V1EndpointSubset(addresses=[address], ports=self.__ports)]

    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:
                metadata['resource_version'] = resource_version
            func = functools.partial(self._api.patch_namespaced_kind, name)
            metadata['annotations'] = annotations
        else:
            func = functools.partial(self._api.create_namespaced_kind)
            # skip annotations with null values
            metadata['annotations'] = {k: v for k, v in annotations.items() if v is not None}

        metadata = k8s_client.V1ObjectMeta(**metadata)
        if self._api.use_endpoints:
            endpoints = {'metadata': metadata}
            if ips is not None:
                self._map_subsets(endpoints, ips)
            body = k8s_client.V1Endpoints(**endpoints)
        else:
            body = k8s_client.V1ConfigMap(metadata=metadata)
        ret = retry(func, self._namespace, body) if retry else func(self._namespace, body)
        if ret:
            self._kinds.set(name, ret)
        return ret

    @catch_kubernetes_errors
    def patch_or_create(self, name: str, annotations: Dict[str, Any], resource_version: Optional[str] = None,
                        patch: bool = False, retry: bool = True, ips: Optional[List[str]] = None) -> K8sObject:
        try:
            return self._patch_or_create(name, annotations, resource_version, patch, self.retry if retry else None, ips)
        except k8s_client.rest.ApiException as e:
            if e.status == 409 and resource_version:  # Conflict in resource_version
                # Terminate watchers, it could be a sign that K8s API is in a failed state
                self._kinds.kill_stream()
                self._pods.kill_stream()
            raise e

    def patch_or_create_config(self, annotations: Dict[str, Any],
                               resource_version: Optional[str] = None, patch: bool = False, retry: bool = True) -> bool:
        # SCOPE-config endpoint requires corresponding service otherwise it might be "cleaned" by k8s master
        if self._api.use_endpoints and not patch and not resource_version:
            self._should_create_config_service = True
            self._create_config_service()
        return bool(self.patch_or_create(self.config_path, annotations, resource_version, patch, retry))

    def _create_config_service(self) -> None:
        metadata = k8s_client.V1ObjectMeta(namespace=self._namespace, name=self.config_path, labels=self._labels)
        body = k8s_client.V1Service(metadata=metadata, spec=k8s_client.V1ServiceSpec(cluster_ip='None'))
        try:
            if not self._api.create_namespaced_service(self._namespace, body):
                return
        except Exception as e:
            # 409 - service already exists, 403 - creation forbidden
            if not isinstance(e, k8s_client.rest.ApiException) or e.status not in (409, 403):
                return logger.exception('create_config_service failed')
        self._should_create_config_service = False

    def _write_leader_optime(self, last_lsn: str) -> bool:
        """Unused"""
        raise NotImplementedError  # pragma: no cover

    def _write_status(self, value: str) -> bool:
        """Unused"""
        raise NotImplementedError  # pragma: no cover

    def _write_failsafe(self, value: str) -> bool:
        """Unused"""
        raise NotImplementedError  # pragma: no cover

    def _update_leader(self, leader: Leader) -> bool:
        """Unused"""
        raise NotImplementedError  # pragma: no cover

    def write_leader_optime(self, last_lsn: int) -> None:
        """Write value for WAL LSN to ``optime`` annotation of the leader object.

        :param last_lsn: absolute WAL LSN in bytes.
        """
        self.patch_or_create(self.leader_path, {self._OPTIME: str(last_lsn)}, patch=True, retry=False)

    def _update_leader_with_retry(self, annotations: Dict[str, Any],
                                  resource_version: Optional[str], ips: List[str]) -> bool:
        retry = self._retry.copy()

        def _retry_403(*args: Any, **kwargs: Any) -> Any:
            kwargs.update(_retry=retry, _retriable_http_codes=frozenset([403]))
            return retry(*args, **kwargs)

        try:
            return bool(self._patch_or_create(self.leader_path, annotations, resource_version,
                                              ips=ips, retry=_retry_403))
        except k8s_client.rest.ApiException as e:
            if e.status == 409:
                logger.warning('Concurrent update of %s', self.leader_path)
            else:
                logger.exception('Unexpected error from Kubernetes API')
                return False
        except (RetryFailedError, K8sException) as e:
            raise KubernetesError(e)

        # if we are here, that means update failed with 409
        if not retry.ensure_deadline(1):
            return False  # No time for retry. Tell ha.py that we have to demote due to failed update.

        def _retry(*args: Any, **kwargs: Any) -> Any:
            kwargs['_retry'] = retry
            return retry(*args, **kwargs)

        # Try to get the latest version directly from K8s API instead of relying on async cache
        try:
            kind = _retry(self._api.read_namespaced_kind, self.leader_path, self._namespace)
        except (RetryFailedError, K8sException) as e:
            raise KubernetesError(e)
        except Exception as e:
            logger.error('Failed to get the leader object "%s": %r', self.leader_path, e)
            return False

        self._kinds.set(self.leader_path, kind)

        kind_annotations = kind and kind.metadata.annotations or EMPTY_DICT
        kind_resource_version = kind and kind.metadata.resource_version

        # There is different leader or resource_version in cache didn't change
        if kind and (kind_annotations.get(self._LEADER) != self._name or kind_resource_version == resource_version):
            return False

        # We can get 409 because we do at least one retry, and the first update might have succeeded,
        # therefore we will check if annotations on the read object match expectations.
        if all(kind_annotations.get(k) == v for k, v in annotations.items()):
            return True

        if not retry.ensure_deadline(0.5):
            return False

        return bool(_run_and_handle_exceptions(self._patch_or_create, self.leader_path, annotations,
                                               kind_resource_version, ips=ips, retry=_retry))

    @staticmethod
    def _isotime() -> str:
        return datetime.datetime.now(tzutc).isoformat()

    def update_leader(self, cluster: Cluster, last_lsn: Optional[int],
                      slots: Optional[Dict[str, int]] = None, failsafe: Optional[Dict[str, str]] = None) -> bool:
        kind = self._kinds.get(self.leader_path)
        kind_annotations = kind and kind.metadata.annotations or EMPTY_DICT

        if kind and kind_annotations.get(self._LEADER) != self._name:
            return False

        now = self._isotime()
        leader_observed_record = kind_annotations or self._leader_observed_record
        annotations = {self._LEADER: self._name, 'ttl': str(self._ttl), 'renewTime': now,
                       'acquireTime': leader_observed_record.get('acquireTime') or now,
                       'transitions': leader_observed_record.get('transitions') or '0'}
        if last_lsn:
            annotations[self._OPTIME] = str(last_lsn)
            annotations['slots'] = json.dumps(slots, separators=(',', ':')) if slots else None
            retain_slots = self._build_retain_slots(cluster, slots)
            annotations['retain_slots'] = json.dumps(retain_slots) if retain_slots else None

        if failsafe is not None:
            annotations[self._FAILSAFE] = json.dumps(failsafe, separators=(',', ':')) if failsafe else None

        resource_version = kind and kind.metadata.resource_version
        return self._update_leader_with_retry(annotations, resource_version, self.__ips)

    def attempt_to_acquire_leader(self) -> bool:
        now = self._isotime()
        annotations = {self._LEADER: self._name, 'ttl': str(self._ttl),
                       'renewTime': now, 'acquireTime': now, 'transitions': '0'}
        if self._leader_observed_record:
            try:
                transitions = int(self._leader_observed_record.get('transitions', ''))
            except (TypeError, ValueError):
                transitions = 0

            if self._leader_observed_record.get(self._LEADER) != self._name:
                transitions += 1
            else:
                annotations['acquireTime'] = self._leader_observed_record.get('acquireTime') or now
            annotations['transitions'] = str(transitions)

        resource_version = self._leader_resource_version
        if resource_version:
            kind = self._kinds.get(self.leader_path)
            # If leader object in cache was updated we should better use fresh resource_version
            if kind and kind.metadata.resource_version != resource_version:
                kind_annotations = kind and kind.metadata.annotations or EMPTY_DICT
                # But, only in case if leader annotations didn't change
                if all(kind_annotations.get(k) == self._leader_observed_record.get(k) for k in annotations.keys()):
                    resource_version = kind.metadata.resource_version

        retry = self._retry.copy()

        def _retry(*args: Any, **kwargs: Any) -> Any:
            kwargs['_retry'] = retry
            return retry(*args, **kwargs)

        handle_conflict = False
        try:
            ret = bool(self._patch_or_create(self.leader_path, annotations,
                                             resource_version, retry=_retry, ips=self.__ips))
        except k8s_client.rest.ApiException as e:
            if e.status == 409 and resource_version:  # Conflict in resource_version
                # Terminate watchers, it could be a sign that K8s API is in a failed state
                self._kinds.kill_stream()
                self._pods.kill_stream()
                handle_conflict = True
            ret = False
        except (RetryFailedError, K8sException) as e:
            raise KubernetesError(e)

        if handle_conflict and retry.ensure_deadline(1):
            # if we are here, that means update failed with 409
            # Try to get the latest version directly from K8s API instead of relying on async cache
            try:
                kind = _retry(self._api.read_namespaced_kind, self.leader_path, self._namespace)
            except (RetryFailedError, K8sException) as e:
                raise KubernetesError(e)
            except Exception as e:
                logger.error('Failed to get the leader object "%s": %r', self.leader_path, e)
            else:
                kind_annotations = kind and kind.metadata.annotations or EMPTY_DICT
                kind_resource_version = kind and kind.metadata.resource_version

                # We can get 409 because we do at least one retry, and the first update might have succeeded,
                # therefore we will check if annotations on the read object match expectations.
                if kind and kind_resource_version != resource_version and\
                        all(kind_annotations.get(k) == v for k, v in annotations.items()):
                    ret = True

        if not ret:
            logger.info('Could not take out TTL lock')
        return ret

    def take_leader(self) -> bool:
        return self.attempt_to_acquire_leader()

    def set_failover_value(self, value: str, version: Optional[str] = None) -> bool:
        """Unused"""
        raise NotImplementedError  # pragma: no cover

    def manual_failover(self, leader: Optional[str], candidate: Optional[str],
                        scheduled_at: Optional[datetime.datetime] = None, version: Optional[str] = None) -> bool:
        annotations = {'leader': leader or None, 'member': candidate or None,
                       'scheduled_at': scheduled_at and scheduled_at.isoformat()}
        patch = bool(self.cluster and isinstance(self.cluster.failover, Failover) and self.cluster.failover.version)
        return bool(self.patch_or_create(self.failover_path, annotations, version, bool(version or patch), False))

    @property
    def _config_resource_version(self) -> Optional[str]:
        config = self._kinds.get(self.config_path)
        return config and config.metadata.resource_version

    def set_config_value(self, value: str, version: Optional[str] = None) -> bool:
        return self.patch_or_create_config({self._CONFIG: value}, version, bool(self._config_resource_version), False)

    @catch_kubernetes_errors
    def touch_member(self, data: Dict[str, Any]) -> bool:
        cluster = self.cluster
        if cluster and cluster.leader and cluster.leader.name == self._name:
            role = self._standby_leader_label_value \
                if data['role'] == PostgresqlRole.STANDBY_LEADER else self._leader_label_value
            tmp_role = 'primary'
        elif data['state'] == PostgresqlState.RUNNING and data['role'] != PostgresqlRole.PRIMARY:
            role = {'replica': self._follower_label_value}.get(data['role'], data['role'])
            tmp_role = data['role']
        else:
            role = None
            tmp_role = None

        updated_labels = {self._role_label: role}
        if self._tmp_role_label:
            updated_labels[self._tmp_role_label] = tmp_role

        if self._bootstrap_labels:
            if data['state'] in (PostgresqlState.INITDB, PostgresqlState.CUSTOM_BOOTSTRAP,
                                 PostgresqlState.BOOTSTRAP_STARTING, PostgresqlState.CREATING_REPLICA):
                updated_labels.update(self._bootstrap_labels)
            else:
                updated_labels.update({k: None for k, _ in self._bootstrap_labels.items()})

        member = cluster and cluster.get_member(self._name, fallback_to_leader=False)
        pod_labels = member and member.data.pop('pod_labels', None)
        ret = member and pod_labels is not None\
            and all(pod_labels.get(k) == v for k, v in updated_labels.items())\
            and deep_compare(data, member.data)

        if not ret:
            metadata: Dict[str, Any] = {'namespace': self._namespace, 'name': self._name, 'labels': updated_labels,
                                        'annotations': {'status': json.dumps(data, separators=(',', ':'))}}
            body = k8s_client.V1Pod(metadata=k8s_client.V1ObjectMeta(**metadata))
            ret = self._api.patch_namespaced_pod(self._name, self._namespace, body)
            if ret:
                self._pods.set(self._name, ret)
        if self._should_create_config_service:
            self._create_config_service()
        return bool(ret)

    def initialize(self, create_new: bool = True, sysid: str = "") -> bool:
        cluster = self.cluster
        resource_version = str(cluster.config.version)\
            if cluster and cluster.config and cluster.config.version else None
        return self.patch_or_create_config({self._INITIALIZE: sysid}, resource_version)

    def _delete_leader(self, leader: Leader) -> bool:
        """Unused"""
        raise NotImplementedError  # pragma: no cover

    def delete_leader(self, leader: Optional[Leader], last_lsn: Optional[int] = None) -> bool:
        ret = False
        kind = self._kinds.get(self.leader_path)
        if kind and (kind.metadata.annotations or EMPTY_DICT).get(self._LEADER) == self._name:
            annotations: Dict[str, Optional[str]] = {self._LEADER: None}
            if last_lsn:
                annotations[self._OPTIME] = str(last_lsn)
            ret = self.patch_or_create(self.leader_path, annotations, kind.metadata.resource_version, True, False, [])
            self.reset_cluster()
        return ret

    def cancel_initialization(self) -> bool:
        return self.patch_or_create_config({self._INITIALIZE: None}, None, True)

    @catch_kubernetes_errors
    def delete_cluster(self) -> bool:
        return bool(self.retry(self._api.delete_collection_namespaced_kind,
                               self._namespace, label_selector=self._label_selector))

    def set_history_value(self, value: str) -> bool:
        return self.patch_or_create_config({self._HISTORY: value}, None, bool(self._config_resource_version), False)

    def set_sync_state_value(self, value: str, version: Optional[str] = None) -> bool:
        """Unused"""
        raise NotImplementedError  # pragma: no cover

    def write_sync_state(self, leader: Optional[str], sync_standby: Optional[Collection[str]],
                         quorum: Optional[int], version: Optional[str] = None) -> Optional[SyncState]:
        """Prepare and write annotations to $SCOPE-sync Endpoint or ConfigMap.

        :param leader: name of the leader node that manages /sync key
        :param sync_standby: collection of currently known synchronous standby node names
        :param quorum: if the node from sync_standby list is doing a leader race it should
                       see at least quorum other nodes from the sync_standby + leader list
        :param version: last known `resource_version` for conditional update of the object
        :returns: the new :class:`SyncState` object or None
        """
        sync_state = self.sync_state(leader, sync_standby, quorum)
        sync_state['quorum'] = str(sync_state['quorum']) if sync_state['quorum'] is not None else None
        ret = self.patch_or_create(self.sync_path, sync_state, version, False)
        if not isinstance(ret, bool):
            return SyncState.from_node(ret.metadata.resource_version, sync_state)

    def delete_sync_state(self, version: Optional[str] = None) -> bool:
        """Patch annotations of $SCOPE-sync Endpoint or ConfigMap with empty values.

        Effectively it removes "leader" and "sync_standby" annotations from the object.
        :param version: last known `resource_version` for conditional update of the object
        :returns: `True` if "delete" was successful
        """
        return self.write_sync_state(None, None, None, version=version) is not None

    def watch(self, leader_version: Optional[str], timeout: float) -> bool:
        if self.__do_not_watch:
            self.__do_not_watch = False
            return True

        # We want to give a bit more time to non-leader nodes to synchronize HA loops
        if leader_version:
            timeout += 0.5

        try:
            return super(Kubernetes, self).watch(None, timeout)
        finally:
            self.event.clear()
