Files
patroni/patroni/validator.py
T

1197 lines
53 KiB
Python

#!/usr/bin/env python3
"""Patroni configuration validation helpers.
This module contains facilities for validating configuration of Patroni processes.
:var schema: configuration schema of the daemon launched by ``patroni`` command.
"""
import os
import shutil
import socket
from typing import Any, cast, Dict, Iterator, List, Optional as OptionalType, Tuple, TYPE_CHECKING, Union
from .collections import CaseInsensitiveSet, EMPTY_DICT
from .dcs import dcs_modules
from .exceptions import ConfigParseError
from .log import type_logformat
from .utils import data_directory_is_empty, get_major_version, parse_int, split_host_port
# Additional parameters to fine-tune validation process
_validation_params: Dict[str, Any] = {}
def populate_validate_params(ignore_listen_port: bool = False) -> None:
"""Populate parameters used to fine-tune the validation of the Patroni config.
:param ignore_listen_port: ignore the bind failures for the ports marked as `listen`.
"""
_validation_params['ignore_listen_port'] = ignore_listen_port
def validate_log_field(field: Any) -> bool:
"""Checks if log field is valid.
:param field: A log field to be validated.
:returns: ``True`` if the field is either a string or a dictionary with exactly one key
that has string value, ``False`` otherwise.
"""
if isinstance(field, str):
return True
elif isinstance(field, dict):
field = cast(Dict[str, Any], field)
return len(field) == 1 and isinstance(next(iter(field.values())), str)
return False
def validate_log_format(logformat: type_logformat) -> bool:
"""Checks if log format is valid.
:param logformat: A log format to be validated.
:returns: ``True`` if the log format is either a string or a list of valid log fields.
:raises:
:exc:`~patroni.exceptions.ConfigParseError`:
* If the logformat is not a string or a list; or
* If the logformat is an empty list; or
* If the log format is a list and it with values that don't pass validation using
:func:`validate_log_field`.
"""
if isinstance(logformat, str):
return True
elif isinstance(logformat, list):
logformat = cast(List[Any], logformat)
if len(logformat) == 0:
raise ConfigParseError('should contain at least one item')
if not all(map(validate_log_field, logformat)):
raise ConfigParseError('each item should be a string or a dictionary with string values')
return True
else:
raise ConfigParseError('Should be a string or a list')
def data_directory_empty(data_dir: str) -> bool:
"""Check if PostgreSQL data directory is empty.
:param data_dir: path to the PostgreSQL data directory to be checked.
:returns: ``True`` if the data directory is empty.
"""
if os.path.isfile(os.path.join(data_dir, "global", "pg_control")):
return False
return data_directory_is_empty(data_dir)
def validate_connect_address(address: str) -> bool:
"""Check if options related to connection address were properly configured.
:param address: address to be validated in the format ``host:ip``.
:returns: ``True`` if the address is valid.
:raises:
:class:`~patroni.exceptions.ConfigParseError`:
* If the address is not in the expected format; or
* If the host is set to not allowed values (``127.0.0.1``, ``0.0.0.0``, ``*``, ``::1``, or ``localhost``).
"""
try:
host, _ = split_host_port(address, 1)
except (AttributeError, TypeError, ValueError):
raise ConfigParseError("contains a wrong value")
if host in ["127.0.0.1", "0.0.0.0", "*", "::1", "localhost"]:
raise ConfigParseError('must not contain "127.0.0.1", "0.0.0.0", "*", "::1", "localhost"')
return True
def validate_host_port(host_port: str, listen: bool = False, multiple_hosts: bool = False) -> bool:
"""Check if host(s) and port are valid and available for usage.
:param host_port: the host(s) and port to be validated. It can be in either of these formats:
* ``host:ip``, if *multiple_hosts* is ``False``; or
* ``host_1,host_2,...,host_n:port``, if *multiple_hosts* is ``True``.
:param listen: if the address is expected to be available for binding. ``False`` means it expects to connect to that
address, and ``True`` that it expects to bind to that address.
:param multiple_hosts: if *host_port* can contain multiple hosts.
:returns: ``True`` if the host(s) and port are valid.
:raises:
:class:`~patroni.exceptions.ConfigParseError`:
* If the *host_port* is not in the expected format; or
* If ``*`` was specified along with more hosts in *host_port*; or
* If we are expecting to bind to an address that is already in use; or
* If we are not able to connect to an address that we are expecting to do so; or
* If :class:`~socket.gaierror` is thrown by socket module when attempting to connect to the given
address(es).
"""
try:
hosts, port = split_host_port(host_port, 1)
except (ValueError, TypeError):
raise ConfigParseError("contains a wrong value")
else:
if multiple_hosts:
hosts = hosts.split(",")
else:
hosts = [hosts]
# If host is set to "*" get all hostnames and/or IP addresses that the host would be able to listen to
if "*" in hosts:
if len(hosts) != 1:
raise ConfigParseError("expecting '*' alone")
# Filter out unexpected results when python is compiled with --disable-ipv6 and running on IPv6 system.
hosts = [a[4][0] for a in socket.getaddrinfo(None, port, 0, socket.SOCK_STREAM, 0, socket.AI_PASSIVE)
if isinstance(a[4][0], str)]
for host in hosts:
# Check if "socket.IF_INET" or "socket.IF_INET6" is being used and instantiate a socket with the identified
# protocol
proto = socket.getaddrinfo(host, None, 0, socket.SOCK_STREAM, 0, socket.AI_PASSIVE)
s = socket.socket(proto[0][0], socket.SOCK_STREAM)
try:
if s.connect_ex((host, port)) == 0:
# Do not raise an exception if ignore_listen_port is set to True.
if listen and not _validation_params.get('ignore_listen_port', False):
raise ConfigParseError("Port {} is already in use.".format(port))
elif not listen:
raise ConfigParseError("{} is not reachable".format(host_port))
except socket.gaierror as e:
raise ConfigParseError(e)
finally:
s.close()
return True
def validate_host_port_list(value: List[str]) -> bool:
"""Validate a list of host(s) and port items.
Call :func:`validate_host_port` with each item in *value*.
:param value: list of host(s) and port items to be validated.
:returns: ``True`` if all items are valid.
"""
assert all([validate_host_port(v) for v in value]), "didn't pass the validation"
return True
def comma_separated_host_port(string: str) -> bool:
"""Validate a list of host and port items.
Call :func:`validate_host_port_list` with a list represented by the CSV *string*.
:param string: comma-separated list of host and port items.
:returns: ``True`` if all items in the CSV string are valid.
"""
return validate_host_port_list([s.strip() for s in string.split(",")])
def validate_host_port_listen(host_port: str) -> bool:
"""Check if host and port are valid and available for binding.
Call :func:`validate_host_port` with *listen* set to ``True``.
:param host_port: the host and port to be validated. Must be in the format
``host:ip``.
:returns: ``True`` if the host and port are valid and available for binding.
"""
return validate_host_port(host_port, listen=True)
def validate_host_port_listen_multiple_hosts(host_port: str) -> bool:
"""Check if host(s) and port are valid and available for binding.
Call :func:`validate_host_port` with both *listen* and *multiple_hosts* set to ``True``.
:param host_port: the host(s) and port to be validated. It can be in either of these formats
* ``host:ip``; or
* ``host_1,host_2,...,host_n:port``
:returns: ``True`` if the host(s) and port are valid and available for binding.
"""
return validate_host_port(host_port, listen=True, multiple_hosts=True)
def is_ipv4_address(ip: str) -> bool:
"""Check if *ip* is a valid IPv4 address.
:param ip: the IP to be checked.
:returns: ``True`` if the IP is an IPv4 address.
:raises:
:class:`~patroni.exceptions.ConfigParseError`: if *ip* is not a valid IPv4 address.
"""
try:
socket.inet_aton(ip)
except Exception:
raise ConfigParseError("Is not a valid ipv4 address")
return True
def is_ipv6_address(ip: str) -> bool:
"""Check if *ip* is a valid IPv6 address.
:param ip: the IP to be checked.
:returns: ``True`` if the IP is an IPv6 address.
:raises:
:class:`~patroni.exceptions.ConfigParseError`: if *ip* is not a valid IPv6 address.
"""
try:
socket.inet_pton(socket.AF_INET6, ip)
except Exception:
raise ConfigParseError("Is not a valid ipv6 address")
return True
def get_bin_name(bin_name: str) -> str:
"""Get the value of ``postgresql.bin_name[*bin_name*]`` configuration option.
:param bin_name: a key to be retrieved from ``postgresql.bin_name`` configuration.
:returns: value of ``postgresql.bin_name[*bin_name*]``, if present, otherwise *bin_name*.
"""
data = cast(Dict[Any, Any], schema.data)
return (data.get('postgresql', {}).get('bin_name', {}) or EMPTY_DICT).get(bin_name, bin_name)
def validate_data_dir(data_dir: str) -> bool:
"""Validate the value of ``postgresql.data_dir`` configuration option.
It requires that ``postgresql.data_dir`` is set and match one of following conditions:
* Point to a path that does not exist yet; or
* Point to an empty directory; or
* Point to a non-empty directory that seems to contain a valid PostgreSQL data directory.
:param data_dir: the value of ``postgresql.data_dir`` configuration option.
:returns: ``True`` if the PostgreSQL data directory is valid.
:raises:
:class:`~patroni.exceptions.ConfigParseError`:
* If no *data_dir* was given; or
* If *data_dir* is a file and not a directory; or
* If *data_dir* is a non-empty directory and:
* ``PG_VERSION`` file is not available in the directory
* ``pg_wal``/``pg_xlog`` is not available in the directory
* ``PG_VERSION`` content does not match the major version reported by ``postgres --version``
"""
if not data_dir:
raise ConfigParseError("is an empty string")
elif os.path.exists(data_dir) and not os.path.isdir(data_dir):
raise ConfigParseError("is not a directory")
elif not data_directory_empty(data_dir):
if not os.path.exists(os.path.join(data_dir, "PG_VERSION")):
raise ConfigParseError("doesn't look like a valid data directory")
else:
with open(os.path.join(data_dir, "PG_VERSION"), "r") as version:
pgversion = version.read().strip()
waldir = ("pg_wal" if float(pgversion) >= 10 else "pg_xlog")
if not os.path.isdir(os.path.join(data_dir, waldir)):
raise ConfigParseError("data dir for the cluster is not empty, but doesn't contain"
" \"{}\" directory".format(waldir))
data = cast(Dict[Any, Any], schema.data)
bin_dir = data.get("postgresql", {}).get("bin_dir", None)
major_version = get_major_version(bin_dir, get_bin_name('postgres'))
if pgversion != major_version:
raise ConfigParseError("data_dir directory postgresql version ({}) doesn't match with "
"'postgres --version' output ({})".format(pgversion, major_version))
return True
def validate_binary_name(bin_name: str) -> bool:
"""Validate the value of ``postgresql.binary_name[*bin_name*]`` configuration option.
If ``postgresql.bin_dir`` is set and the value of the *bin_name* meets these conditions:
* The path join of ``postgresql.bin_dir`` plus the *bin_name* value exists; and
* The path join as above is executable
If ``postgresql.bin_dir`` is not set, then validate that the value of *bin_name* meets this
condition:
* Is found in the system PATH using ``which``
:param bin_name: the value of the ``postgresql.bin_name[*bin_name*]``
:returns: ``True`` if the conditions are true
:raises:
:class:`~patroni.exceptions.ConfigParseError` if:
* *bin_name* is not set; or
* the path join of the ``postgresql.bin_dir`` plus *bin_name* does not exist; or
* the path join as above is not executable; or
* the *bin_name* cannot be found in the system PATH
"""
if not bin_name:
raise ConfigParseError("is an empty string")
data = cast(Dict[Any, Any], schema.data)
bin_dir = data.get('postgresql', {}).get('bin_dir', None)
if not shutil.which(bin_name, path=bin_dir):
raise ConfigParseError(f"does not contain '{bin_name}' in '{bin_dir or '$PATH'}'")
return True
class Result(object):
"""Represent the result of a given validation that was performed.
:ivar status: If the validation succeeded.
:ivar path: YAML tree path of the configuration option.
:ivar data: value of the configuration option.
:ivar level: error level, in case of error.
:ivar error: error message if the validation failed, otherwise ``None``.
"""
def __init__(self, status: bool, error: OptionalType[str] = "didn't pass validation", level: int = 0,
path: str = "", data: Any = "") -> None:
"""Create a :class:`Result` object based on the given arguments.
.. note::
``error`` attribute is only set if *status* is failed.
:param status: if the validation succeeded.
:param error: error message related to the validation that was performed, if the validation failed.
:param level: error level, in case of error.
:param path: YAML tree path of the configuration option.
:param data: value of the configuration option.
"""
self.status = status
self.path = path
self.data = data
self.level = level
self._error = error
if not self.status:
self.error = error
else:
self.error = None
def __repr__(self) -> str:
"""Show configuration path and value. If the validation failed, also show the error message."""
return str(self.path) + (" " + str(self.data) + " " + str(self._error) if self.error else "")
class Case(object):
"""Map how a list of available configuration options should be validated.
.. note::
It should be used together with an :class:`Or` object. The :class:`Or` object will define the list of possible
configuration options in a given context, and the :class:`Case` object will dictate how to validate each of
them, if they are set.
"""
def __init__(self, schema: Dict[str, Any]) -> None:
"""Create a :class:`Case` object.
:param schema: the schema for validating a set of attributes that may be available in the configuration.
Each key is the configuration that is available in a given scope and that should be validated,
and the related value is the validation function or expected type.
:Example:
.. code-block:: python
Case({
"host": validate_host_port,
"url": str,
})
That will check that ``host`` configuration, if given, is valid based on :func:`validate_host_port`, and will
also check that ``url`` configuration, if given, is a ``str`` instance.
"""
self._schema = schema
class Or(object):
"""Represent the list of options that are available.
It can represent either a list of configuration options that are available in a given scope, or a list of
validation functions and/or expected types for a given configuration option.
"""
def __init__(self, *args: Any) -> None:
"""Create an :class:`Or` object.
:param `*args`: any arguments that the caller wants to be stored in this :class:`Or` object.
:Example:
.. code-block:: python
Or("host", "hosts"): Case({
"host": validate_host_port,
"hosts": Or(comma_separated_host_port, [validate_host_port]),
})
The outer :class:`Or` is used to define that ``host`` and ``hosts`` are possible options in this scope.
The inner :class`Or` in the ``hosts`` key value is used to define that ``hosts`` option is valid if either
of :func:`comma_separated_host_port` or :func:`validate_host_port` succeed to validate it.
"""
self.args = args
class AtMostOne(object):
"""Mark that at most one option from a :class:`Case` can be supplied.
Represents a list of possible configuration options in a given scope, where at most one can actually
be provided.
.. note::
It should be used together with a :class:`Case` object.
"""
def __init__(self, *args: str) -> None:
"""Create a :class`AtMostOne` object.
:param `*args`: any arguments that the caller wants to be stored in this :class:`Or` object.
:Example:
.. code-block:: python
AtMostOne("nofailover", "failover_priority"): Case({
"nofailover": bool,
"failover_priority": IntValidator(min=0, raise_assert=True),
})
The :class`AtMostOne` object is used to define that at most one of ``nofailover`` and
``failover_priority`` can be provided.
"""
self.args = args
class Optional(object):
"""Mark a configuration option as optional.
:ivar name: name of the configuration option.
:ivar default: value to set if the configuration option is not explicitly provided
"""
def __init__(self, name: str, default: OptionalType[Any] = None) -> None:
"""Create an :class:`Optional` object.
:param name: name of the configuration option.
:param default: value to set if the configuration option is not explicitly provided
"""
self.name = name
self.default = default
class Directory(object):
"""Check if a directory contains the expected files.
The attributes of objects of this class are used by their :func:`validate` method.
:param contains: list of paths that should exist relative to a given directory.
:param contains_executable: list of executable files that should exist directly under a given directory.
"""
def __init__(self, contains: OptionalType[List[str]] = None,
contains_executable: OptionalType[List[str]] = None) -> None:
"""Create a :class:`Directory` object.
:param contains: list of paths that should exist relative to a given directory.
:param contains_executable: list of executable files that should exist directly under a given directory.
"""
self.contains = contains
self.contains_executable = contains_executable
def _check_executables(self, path: OptionalType[str] = None) -> Iterator[Result]:
"""Check that all executables from contains_executable list exist within the given directory or within ``PATH``.
:param path: optional path to the base directory against which executables will be validated.
If not provided, check within ``PATH``.
:yields: objects with the error message containing the name of the executable, if any check fails.
"""
for program in self.contains_executable or []:
if not shutil.which(program, path=path):
yield Result(False, f"does not contain '{program}' in '{(path or '$PATH')}'")
def validate(self, name: str) -> Iterator[Result]:
"""Check if the expected paths and executables can be found under *name* directory.
:param name: path to the base directory against which paths and executables will be validated.
Check against ``PATH`` if name is not provided.
:yields: objects with the error message related to the failure, if any check fails.
"""
if not name:
yield from self._check_executables()
elif not os.path.exists(name):
yield Result(False, "Directory '{}' does not exist.".format(name))
elif not os.path.isdir(name):
yield Result(False, "'{}' is not a directory.".format(name))
else:
if self.contains:
for path in self.contains:
if not os.path.exists(os.path.join(name, path)):
yield Result(False, "'{}' does not contain '{}'".format(name, path))
yield from self._check_executables(path=name)
class BinDirectory(Directory):
"""Check if a Postgres binary directory contains the expected files.
It is a subclass of :class:`Directory` with an extended capability: translating ``BINARIES`` according to configured
``postgresql.bin_name``, if any.
:cvar BINARIES: list of executable files that should exist directly under a given Postgres binary directory.
"""
# ``pg_rewind`` is not in the list because its usage by Patroni is optional. Also, it is not available by default on
# Postgres 9.3 and 9.4, versions which Patroni supports.
BINARIES = ["pg_ctl", "initdb", "pg_controldata", "pg_basebackup", "postgres", "pg_isready"]
def validate(self, name: str) -> Iterator[Result]:
"""Check if the expected executables can be found under *name* binary directory.
:param name: path to the base directory against which executables will be validated. Check against PATH if
*name* is not provided.
:yields: objects with the error message related to the failure, if any check fails.
"""
self.contains_executable: List[str] = [get_bin_name(binary) for binary in self.BINARIES]
yield from super().validate(name)
class Schema(object):
"""Define a configuration schema.
It contains all the configuration options that are available in each scope, including the validation(s) that should
be performed against each one of them. The validations will be performed whenever the :class:`Schema` object is
called, or its :func:`validate` method is called.
:ivar validator: validator of the configuration schema. Can be any of these:
* :class:`str`: defines that a string value is required; or
* :class:`type`: any subclass of :class:`type`, defines that a value of the given type is required; or
* ``callable``: any callable object, defines that validation will follow the code defined in the callable
object. If the callable object contains an ``expected_type`` attribute, then it will check if the
configuration value is of the expected type before calling the code of the callable object; or
* :class:`list`: list representing one or more values in the configuration; or
* :class:`dict`: dictionary representing the YAML configuration tree.
"""
def __init__(self, validator: Union[Dict[Any, Any], List[Any], Any]) -> None:
"""Create a :class:`Schema` object.
.. note::
This class is expected to be initially instantiated with a :class:`dict` based *validator* argument. The
idea is that dict represents the full YAML tree of configuration options. The :func:`validate` method will
then walk recursively through the configuration tree, creating new instances of :class:`Schema` with the
new "base path", to validate the structure and the leaf values of the tree. The recursion stops on leaf
nodes, when it performs checks of the actual setting values.
:param validator: validator of the configuration schema. Can be any of these:
* :class:`str`: defines that a string value is required; or
* :class:`type`: any subclass of :class:`type`, defines that a value of the given type is required; or
* ``callable``: Any callable object, defines that validation will follow the code defined in the callable
object. If the callable object contains an ``expected_type`` attribute, then it will check if the
configuration value is of the expected type before calling the code of the callable object; or
* :class:`list`: list representing it expects to contain one or more values in the configuration; or
* :class:`dict`: dictionary representing the YAML configuration tree.
The first 3 items in the above list are here referenced as "base validators", which cause the recursion
to stop.
If *validator* is a :class:`dict`, then you should follow these rules:
* For the keys it can be either:
* A :class:`str` instance. It will be the name of the configuration option; or
* An :class:`Optional` instance. The ``name`` attribute of that object will be the name of the
configuration option, and that class makes this configuration option as optional to the
user, allowing it to not be specified in the YAML; or
* An :class:`Or` instance. The ``args`` attribute of that object will contain a tuple of
configuration option names. At least one of them should be specified by the user in the YAML;
* For the values it can be either:
* A new :class:`dict` instance. It will represent a new level in the YAML configuration tree; or
* A :class:`Case` instance. This is required if the key of this value is an :class:`Or` instance,
and the :class:`Case` instance is used to map each of the ``args`` in :class:`Or` to their
corresponding base validator in :class:`Case`; or
* An :class:`Or` instance with one or more base validators; or
* A :class:`list` instance with a single item which is the base validator; or
* A base validator.
:Example:
.. code-block:: python
Schema({
"application_name": str,
"bind": {
"host": validate_host,
"port": int,
},
"aliases": [str],
Optional("data_directory"): "/var/lib/myapp",
Or("log_to_file", "log_to_db"): Case({
"log_to_file": bool,
"log_to_db": bool,
}),
"version": Or(int, float),
})
This sample schema defines that your YAML configuration follows these rules:
* It must contain an ``application_name`` entry which value should be a :class:`str` instance;
* It must contain a ``bind.host`` entry which value should be valid as per function ``validate_host``;
* It must contain a ``bind.port`` entry which value should be an :class:`int` instance;
* It must contain a ``aliases`` entry which value should be a :class:`list` of :class:`str` instances;
* It may optionally contain a ``data_directory`` entry, with a value which should be a string;
* It must contain at least one of ``log_to_file`` or ``log_to_db``, with a value which should be a
:class:`bool` instance;
* It must contain a ``version`` entry which value should be either an :class:`int` or a :class:`float`
instance.
"""
self.validator = validator
def __call__(self, data: Any) -> List[str]:
"""Perform validation of data using the rules defined in this schema.
:param data: configuration to be validated against ``validator``.
:returns: list of errors identified while validating the *data*, if any.
"""
errors: List[str] = []
for i in self.validate(data):
if not i.status:
errors.append(str(i))
return errors
def validate(self, data: Any) -> Iterator[Result]:
"""Perform all validations from the schema against the given configuration.
It first checks that *data* argument type is compliant with the type of ``validator`` attribute.
Additionally:
* If ``validator`` attribute is a callable object, calls it to validate *data* argument. Before doing so, if
`validator` contains an ``expected_type`` attribute, check if *data* argument is compliant with that
expected type.
* If ``validator`` attribute is an iterable object (:class:`dict`, :class:`list`, :class:`Directory` or
:class:`Or`), then it iterates over it to validate each of the corresponding entries in *data* argument.
:param data: configuration to be validated against ``validator``.
:yields: objects with the error message related to the failure, if any check fails.
"""
self.data = data
# New `Schema` objects can be created while validating a given `Schema`, depending on its structure. The first
# 3 IF statements deal with the situation where we already reached a leaf node in the `Schema` structure, then
# we are dealing with an actual value validation. The remaining logic in this method is used to iterate through
# iterable objects in the structure, until we eventually reach a leaf node to validate its value.
if isinstance(self.validator, str):
yield Result(isinstance(self.data, str), "is not a string", level=1, data=self.data)
elif isinstance(self.validator, type):
yield Result(isinstance(self.data, self.validator),
"is not {}".format(_get_type_name(self.validator)), level=1, data=self.data)
elif callable(self.validator):
if hasattr(self.validator, "expected_type"):
expected_type = getattr(self.validator, 'expected_type')
if not isinstance(data, expected_type):
yield Result(False, "is not {}".format(_get_type_name(expected_type)), level=1, data=self.data)
return
try:
self.validator(data)
yield Result(True, data=self.data)
except Exception as e:
yield Result(False, "didn't pass validation: {}".format(e), data=self.data)
elif isinstance(self.validator, dict):
if not isinstance(self.data, dict):
yield Result(isinstance(self.data, dict), "is not a dictionary", level=1, data=self.data)
else:
yield from self.iter_dict()
elif isinstance(self.validator, list):
if not isinstance(self.data, list):
yield Result(isinstance(self.data, list), "is not a list", level=1, data=self.data)
else:
yield from self.iter_list()
elif isinstance(self.validator, Or):
yield from self.iter_or()
elif isinstance(self.validator, Directory) and isinstance(self.data, str):
yield from self.validator.validate(self.data)
def iter_list(self) -> Iterator[Result]:
"""Iterate over a ``data`` object and perform validations using the first element of the ``validator``.
:yields: objects with the error message related to the failure, if any check fails.
"""
data = cast(List[Any], self.data)
if len(data) == 0:
yield Result(False, "is an empty list", data=data)
validators = cast(List[Any], self.validator)
if len(validators):
for key, value in enumerate(data):
# Although the value in the configuration (`data`) is expected to contain 1 or more entries, only
# the first validator defined in `validator` property list will be used. It is only defined as a
# `list` in `validator` so this logic can understand that the value in `data` attribute should be a
# `list`. For example: "pg_hba": [str] in `validator` attribute defines that "pg_hba" in `data`
# attribute should contain a list with one or more `str` entries.
for v in Schema(validators[0]).validate(value):
yield Result(v.status, v.error,
path=(str(key) + ("." + v.path if v.path else "")), level=v.level, data=value)
def iter_dict(self) -> Iterator[Result]:
"""Iterate over a :class:`dict` based ``validator`` to validate the corresponding entries in ``data``.
:yields: objects with the error message related to the failure, if any check fails.
"""
# One key in `validator` attribute (`key` variable) can be mapped to one or more keys in `data` attribute (`d`
# variable), depending on the `key` type.
data = cast(Dict[Any, Any], self.data)
validators = cast(Dict[Any, Any], self.validator)
for key in validators.keys():
if isinstance(key, AtMostOne) and len(list(self._data_key(key))) > 1:
yield Result(False, f"Multiple of {key.args} provided")
continue
for d in self._data_key(key):
if d not in data and not isinstance(key, Optional):
yield Result(False, "is not defined.", path=d)
elif d not in data and isinstance(key, Optional) and key.default is None:
continue
else:
if d not in data and isinstance(key, Optional):
data[d] = key.default
validator = validators[key]
if isinstance(key, (Or, AtMostOne)) and isinstance(validators[key], Case):
validator = validators[key]._schema[d]
# In this loop we may be calling a new `Schema` either over an intermediate node in the tree, or
# over a leaf node. In the latter case the recursive calls in the given path will finish.
for v in Schema(validator).validate(data[d]):
yield Result(v.status, v.error,
path=(d + ("." + v.path if v.path else "")), level=v.level, data=v.data)
def iter_or(self) -> Iterator[Result]:
"""Perform all validations defined in an :class:`Or` object for a given configuration option.
This method can be only called against leaf nodes in the configuration tree. :class:`Or` objects defined in the
``validator`` keys will be handled by :func:`iter_dict` method.
:yields: objects with the error message related to the failure, if any check fails.
"""
if TYPE_CHECKING: # pragma: no cover
assert isinstance(self.validator, Or)
results: List[Result] = []
for a in self.validator.args:
r: List[Result] = []
# Each of the `Or` validators can throw 0 to many `Result` instances.
for v in Schema(a).validate(self.data):
r.append(v)
if any([x.status for x in r]) and not all([x.status for x in r]):
results += [x for x in r if not x.status]
else:
results += r
# None of the `Or` validators succeeded to validate `data`, so we report the issues back.
if not any([x.status for x in results]):
max_level = 3
for v in sorted(results, key=lambda x: x.level):
if v.level > max_level:
break
max_level = v.level
yield Result(v.status, v.error, path=v.path, level=v.level, data=v.data)
def _data_key(self, key: Union[str, Optional, Or, AtMostOne]) -> Iterator[str]:
"""Map a key from the ``validator`` dictionary to the corresponding key(s) in the ``data`` dictionary.
:param key: key from the ``validator`` attribute.
:yields: keys that should be used to access corresponding value in the ``data`` attribute.
"""
data = cast(Dict[Any, Any], self.data)
# If the key was defined as an `Optional` object in `validator` attribute, then its name is the key to access
# the `data` dictionary.
if isinstance(key, Optional):
yield key.name
# If the key was defined as a `str` object in `validator` attribute, then it is already the final key
# to access the `data` dictionary.
elif isinstance(key, str):
yield key
# If the key was defined as an `Or` object in `validator` attribute, then each of its values are
# the keys to access the `data` dictionary.
elif isinstance(key, Or):
# At least one of the `Or` entries should be available in the `data` dictionary. If we find at least
# one of them in `data`, then we return all found entries so the caller method can validate them all.
if any([item in data for item in key.args]):
for item in key.args:
if item in data:
yield item
# If none of the `Or` entries is available in the `data` dictionary, then we return all entries so the
# caller method will issue errors that they are all absent.
else:
for item in key.args:
yield item
# If the key was defined as a `AtMostOne` object in `validator` attribute, then each of its values
# are the keys to access the `data` dictionary.
elif isinstance(key, AtMostOne): # pyright: ignore [reportUnnecessaryIsInstance]
# Yield back all of the entries from the `data` dictionary, each will be validated and then counted
# to inform us if we've provided too many
for item in key.args:
if item in data:
yield item
def _get_type_name(python_type: Any) -> str:
"""Get a user-friendly name for a given Python type.
:param python_type: Python type which user friendly name should be taken.
:returns: User friendly name of the given Python type.
"""
types: Dict[Any, str] = {str: 'a string', int: 'an integer', float: 'a number',
bool: 'a boolean', list: 'an array', dict: 'a dictionary'}
return types.get(python_type, getattr(python_type, __name__, "unknown type"))
def assert_(condition: bool, message: str = "Wrong value") -> None:
"""Assert that a given condition is ``True``.
If the assertion fails, then throw a message.
:param condition: result of a condition to be asserted.
:param message: message to be thrown if the condition is ``False``.
"""
assert condition, message
class IntValidator(object):
"""Validate an integer setting.
:ivar min: minimum allowed value for the setting, if any.
:ivar max: maximum allowed value for the setting, if any.
:ivar base_unit: the base unit to convert the value to before checking if it's within *min* and *max* range.
:ivar expected_type: the expected Python type.
:ivar raise_assert: if an ``assert`` test should be performed regarding expected type and valid range.
"""
def __init__(self, min: OptionalType[int] = None, max: OptionalType[int] = None,
base_unit: OptionalType[str] = None, expected_type: Any = None, raise_assert: bool = False) -> None:
"""Create an :class:`IntValidator` object with the given rules.
:param min: minimum allowed value for the setting, if any.
:param max: maximum allowed value for the setting, if any.
:param base_unit: the base unit to convert the value to before checking if it's within *min* and *max* range.
:param expected_type: the expected Python type.
:param raise_assert: if an ``assert`` test should be performed regarding expected type and valid range.
"""
self.min = min
self.max = max
self.base_unit = base_unit
if expected_type:
self.expected_type = expected_type
self.raise_assert = raise_assert
def __call__(self, value: Any) -> bool:
"""Check if *value* is a valid integer and within the expected range.
.. note::
If ``raise_assert`` is ``True`` and *value* is not valid, then an :class:`AssertionError` will be triggered.
:param value: value to be checked against the rules defined for this :class:`IntValidator` instance.
:returns: ``True`` if *value* is valid and within the expected range.
"""
value = parse_int(value, self.base_unit)
ret = isinstance(value, int)\
and (self.min is None or value >= self.min)\
and (self.max is None or value <= self.max)
if self.raise_assert:
assert_(ret)
return ret
class EnumValidator(object):
"""Validate enum setting
:ivar allowed_values: a ``set`` or ``CaseInsensitiveSet`` object with allowed enum values.
:ivar raise_assert: if an ``assert`` call should be performed regarding expected type and valid range.
"""
def __init__(self, allowed_values: Tuple[str, ...],
case_sensitive: bool = False, raise_assert: bool = False) -> None:
"""Create an :class:`EnumValidator` object with given allowed values.
:param allowed_values: a tuple with allowed enum values
:param case_sensitive: set to ``True`` to do case sensitive comparisons
:param raise_assert: if an ``assert`` call should be performed regarding expected values.
"""
self.allowed_values = set(allowed_values) if case_sensitive else CaseInsensitiveSet(allowed_values)
self.raise_assert = raise_assert
def __call__(self, value: Any) -> bool:
"""Check if provided *value* could be found within *allowed_values*.
.. note::
If ``raise_assert`` is ``True`` and *value* is not valid, then an ``AssertionError`` will be triggered.
:param value: value to be checked.
:returns: ``True`` if *value* could be found within *allowed_values*.
"""
ret = isinstance(value, str) and value in self.allowed_values
if self.raise_assert:
assert_(ret)
return ret
def validate_watchdog_mode(value: Any) -> None:
"""Validate ``watchdog.mode`` configuration option.
:param value: value of ``watchdog.mode`` to be validated.
"""
assert_(isinstance(value, (str, bool)), "expected type is not a string")
assert_(value in (False, "off", "automatic", "required"))
userattributes = {"username": "", Optional("password"): ""}
available_dcs = [m.split(".")[-1] for m in dcs_modules()]
setattr(validate_host_port_list, 'expected_type', list)
setattr(comma_separated_host_port, 'expected_type', str)
setattr(validate_connect_address, 'expected_type', str)
setattr(validate_host_port_listen, 'expected_type', str)
setattr(validate_host_port_listen_multiple_hosts, 'expected_type', str)
setattr(validate_data_dir, 'expected_type', str)
setattr(validate_binary_name, 'expected_type', str)
validate_etcd = {
Or("host", "hosts", "srv", "srv_suffix", "url", "proxy"): Case({
"host": validate_host_port,
"hosts": Or(comma_separated_host_port, [validate_host_port]),
"srv": str,
"srv_suffix": str,
"url": str,
"proxy": str
}),
Optional("protocol"): str,
Optional("username"): str,
Optional("password"): str,
Optional("cacert"): str,
Optional("cert"): str,
Optional("key"): str
}
schema = Schema({
"name": str,
"scope": str,
Optional("log"): {
Optional("type"): EnumValidator(('plain', 'json'), case_sensitive=True, raise_assert=True),
Optional("level"): EnumValidator(('DEBUG', 'INFO', 'WARN', 'WARNING', 'ERROR', 'FATAL', 'CRITICAL'),
case_sensitive=True, raise_assert=True),
Optional("traceback_level"): EnumValidator(('DEBUG', 'ERROR'), raise_assert=True),
Optional("format"): validate_log_format,
Optional("dateformat"): str,
Optional("static_fields"): dict,
Optional("max_queue_size"): int,
Optional("dir"): str,
Optional("file_num"): int,
Optional("file_size"): int,
Optional("mode"): IntValidator(min=0, max=511, expected_type=int, raise_assert=True),
Optional("loggers"): dict,
Optional("deduplicate_heartbeat_logs"): bool
},
Optional("ctl"): {
Optional("insecure"): bool,
Optional("cacert"): str,
Optional("certfile"): str,
Optional("keyfile"): str,
Optional("keyfile_password"): str
},
"restapi": {
"listen": validate_host_port_listen,
"connect_address": validate_connect_address,
Optional("authentication"): {
"username": str,
"password": str
},
Optional("certfile"): str,
Optional("keyfile"): str,
Optional("keyfile_password"): str,
Optional("cafile"): str,
Optional("ciphers"): str,
Optional("verify_client"): EnumValidator(("none", "optional", "required"),
case_sensitive=True, raise_assert=True),
Optional("allowlist"): [str],
Optional("allowlist_include_members"): bool,
Optional("http_extra_headers"): dict,
Optional("https_extra_headers"): dict,
Optional("request_queue_size"): IntValidator(min=0, max=4096, expected_type=int, raise_assert=True)
},
Optional("bootstrap"): {
"dcs": {
Optional("ttl"): IntValidator(min=20, raise_assert=True),
Optional("loop_wait"): IntValidator(min=1, raise_assert=True),
Optional("retry_timeout"): IntValidator(min=3, raise_assert=True),
Optional("maximum_lag_on_failover"): IntValidator(min=0, raise_assert=True),
Optional("maximum_lag_on_syncnode"): IntValidator(min=-1, raise_assert=True),
Optional('member_slots_ttl'): IntValidator(min=0, base_unit='s', raise_assert=True),
Optional("postgresql"): {
Optional("parameters"): {
Optional("max_connections"): IntValidator(1, 262143, raise_assert=True),
Optional("max_locks_per_transaction"): IntValidator(10, 2147483647, raise_assert=True),
Optional("max_prepared_transactions"): IntValidator(0, 262143, raise_assert=True),
Optional("max_replication_slots"): IntValidator(0, 262143, raise_assert=True),
Optional("max_wal_senders"): IntValidator(0, 262143, raise_assert=True),
Optional("max_worker_processes"): IntValidator(0, 262143, raise_assert=True),
},
Optional("use_pg_rewind"): bool,
Optional("pg_hba"): [str],
Optional("pg_ident"): [str],
Optional("pg_ctl_timeout"): IntValidator(min=0, raise_assert=True),
Optional("use_slots"): bool,
},
Optional("primary_start_timeout"): IntValidator(min=0, raise_assert=True),
Optional("primary_stop_timeout"): IntValidator(min=0, raise_assert=True),
Optional("standby_cluster"): {
Or("host", "port", "restore_command"): Case({
"host": str,
"port": IntValidator(max=65535, expected_type=int, raise_assert=True),
"restore_command": str
}),
Optional("primary_slot_name"): str,
Optional("create_replica_methods"): [str],
Optional("archive_cleanup_command"): str,
Optional("recovery_min_apply_delay"): str
},
Optional("synchronous_mode"): bool,
Optional("synchronous_mode_strict"): bool,
Optional("synchronous_node_count"): IntValidator(min=1, raise_assert=True),
},
Optional("initdb"): [Or(str, dict)],
Optional("method"): str
},
Or(*available_dcs): Case({
"consul": {
Or("host", "url"): Case({
"host": validate_host_port,
"url": str
}),
Optional("port"): IntValidator(max=65535, expected_type=int, raise_assert=True),
Optional("scheme"): str,
Optional("token"): str,
Optional("verify"): bool,
Optional("cacert"): str,
Optional("cert"): str,
Optional("key"): str,
Optional("dc"): str,
Optional("checks"): [str],
Optional("register_service"): bool,
Optional("service_tags"): [str],
Optional("service_check_interval"): str,
Optional("service_check_tls_server_name"): str,
Optional("consistency"): EnumValidator(('default', 'consistent', 'stale'),
case_sensitive=True, raise_assert=True)
},
"etcd": validate_etcd,
"etcd3": validate_etcd,
"exhibitor": {
"hosts": [str],
"port": IntValidator(max=65535, expected_type=int, raise_assert=True),
Optional("poll_interval"): IntValidator(min=1, expected_type=int, raise_assert=True),
},
"raft": {
"self_addr": validate_connect_address,
Optional("bind_addr"): validate_host_port_listen,
"partner_addrs": validate_host_port_list,
Optional("data_dir"): str,
Optional("password"): str
},
"zookeeper": {
"hosts": Or(comma_separated_host_port, [validate_host_port]),
Optional("use_ssl"): bool,
Optional("cacert"): str,
Optional("cert"): str,
Optional("key"): str,
Optional("key_password"): str,
Optional("verify"): bool,
Optional("set_acls"): dict,
Optional("auth_data"): dict,
},
"kubernetes": {
"labels": {},
Optional("bypass_api_service"): bool,
Optional("namespace"): str,
Optional("scope_label"): str,
Optional("role_label"): str,
Optional("leader_label_value"): str,
Optional("follower_label_value"): str,
Optional("standby_leader_label_value"): str,
Optional("tmp_role_label"): str,
Optional("use_endpoints"): bool,
Optional("pod_ip"): Or(is_ipv4_address, is_ipv6_address),
Optional("ports"): [{"name": str, "port": IntValidator(max=65535, expected_type=int, raise_assert=True)}],
Optional("cacert"): str,
Optional("retriable_http_codes"): Or(int, [int]),
Optional("bootstrap_labels"): dict,
},
}),
Optional("citus"): {
"database": str,
"group": IntValidator(min=0, expected_type=int, raise_assert=True),
},
"postgresql": {
"listen": validate_host_port_listen_multiple_hosts,
"connect_address": validate_connect_address,
Optional("proxy_address"): validate_connect_address,
"authentication": {
"replication": userattributes,
"superuser": userattributes,
Optional("rewind"): userattributes
},
"data_dir": validate_data_dir,
Optional("bin_name"): {
Optional("pg_ctl"): validate_binary_name,
Optional("initdb"): validate_binary_name,
Optional("pg_controldata"): validate_binary_name,
Optional("pg_basebackup"): validate_binary_name,
Optional("postgres"): validate_binary_name,
Optional("pg_isready"): validate_binary_name,
Optional("pg_rewind"): validate_binary_name,
},
Optional("bin_dir", ""): BinDirectory(),
Optional("parameters"): {
Optional("unix_socket_directories"): str
},
Optional("pg_hba"): [str],
Optional("pg_ident"): [str],
Optional("pg_ctl_timeout"): IntValidator(min=0, raise_assert=True),
Optional("use_pg_rewind"): bool
},
Optional("watchdog"): {
Optional("mode"): validate_watchdog_mode,
Optional("device"): str,
Optional("safety_margin"): IntValidator(min=-1, expected_type=int, raise_assert=True),
},
Optional("tags"): {
AtMostOne("nofailover", "failover_priority"): Case({
"nofailover": bool,
"failover_priority": IntValidator(min=0, expected_type=int, raise_assert=True),
}),
Optional("clonefrom"): bool,
Optional("noloadbalance"): bool,
Optional("replicatefrom"): str,
AtMostOne("nosync", "sync_priority"): Case({
"nosync": bool,
"sync_priority": IntValidator(min=0, expected_type=int, raise_assert=True),
}),
Optional("nostream"): bool
}
})