mirror of
https://github.com/outbackdingo/patroni.git
synced 2026-08-25 14:53:37 +00:00
Besides that: 1. Introduce `setup.py isort` for quick check 2. Introduce GH actions to check imports
1417 lines
66 KiB
Python
1417 lines
66 KiB
Python
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, 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.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] = 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):
|
|
data_list: List[Any] = value
|
|
return [cls._wrap(None, v) for v in data_list]
|
|
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:
|
|
try:
|
|
return getattr(self._core_v1_api, func)(*args, **kwargs)
|
|
except k8s_client.rest.ApiException as e:
|
|
if e.status in self._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, Union[Any, Dict[str, Union[Any, 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', 'master')
|
|
self._follower_label_value = config.get('follower_label_value', 'replica')
|
|
self._standby_leader_label_value = config.get('standby_leader_label_value', 'master')
|
|
self._tmp_role_label = config.get('tmp_role_label')
|
|
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 re-use 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(*args: Any, **kwargs: Any) -> Any:
|
|
kwargs['_retry'] = retry
|
|
return retry(*args, **kwargs)
|
|
|
|
try:
|
|
return bool(self._patch_or_create(self.leader_path, annotations, resource_version, ips=ips, retry=_retry))
|
|
except k8s_client.rest.ApiException as e:
|
|
if e.status == 409:
|
|
logger.warning('Concurrent update of %s', self.leader_path)
|
|
else:
|
|
logger.exception('Permission denied' if e.status == 403 else '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.
|
|
|
|
# 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)
|
|
|
|
if not retry.ensure_deadline(0.5):
|
|
return False
|
|
|
|
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
|
|
|
|
return bool(_run_and_handle_exceptions(self._patch_or_create, self.leader_path, annotations,
|
|
kind_resource_version, ips=ips, retry=_retry))
|
|
|
|
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 = datetime.datetime.now(tzutc).isoformat()
|
|
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
|
|
|
|
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 = datetime.datetime.now(tzutc).isoformat()
|
|
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)
|
|
|
|
try:
|
|
ret = bool(self._patch_or_create(self.leader_path, annotations,
|
|
self._leader_resource_version, retry=self.retry, ips=self.__ips))
|
|
except k8s_client.rest.ApiException as e:
|
|
if e.status == 409 and self._leader_resource_version: # Conflict in resource_version
|
|
# Terminate watchers, it could be a sign that K8s API is in a failed state
|
|
self._kinds.kill_stream()
|
|
self._pods.kill_stream()
|
|
ret = False
|
|
except (RetryFailedError, K8sException) as e:
|
|
raise KubernetesError(e)
|
|
|
|
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'] == 'standby_leader' else self._leader_label_value
|
|
tmp_role = 'master'
|
|
elif data['state'] == 'running' and data['role'] not in ('master', 'primary'):
|
|
role = {'replica': self._follower_label_value}.get(data['role'], data['role'])
|
|
tmp_role = data['role']
|
|
else:
|
|
role = None
|
|
tmp_role = None
|
|
|
|
role_labels = {self._role_label: role}
|
|
if self._tmp_role_label:
|
|
role_labels[self._tmp_role_label] = tmp_role
|
|
|
|
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 role_labels.items())\
|
|
and deep_compare(data, member.data)
|
|
|
|
if not ret:
|
|
metadata = {'namespace': self._namespace, 'name': self._name, 'labels': role_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()
|