diff --git a/docs/patroni_configuration.rst b/docs/patroni_configuration.rst index 7232ad46..36411211 100644 --- a/docs/patroni_configuration.rst +++ b/docs/patroni_configuration.rst @@ -238,7 +238,7 @@ Validate Patroni configuration .. code:: text - patroni --validate-config [configfile] + patroni --validate-config [configfile] [--ignore-listen-port | -i] Description """"""""""" @@ -250,3 +250,6 @@ Parameters ``configfile`` Full path to the configuration file to check. If not given or file does not exist, will try to read from the ``PATRONI_CONFIG_VARIABLE`` environment variable or, if not set, from the :ref:`Patroni environment variables `. + +``--ignore-listen-port | -i`` + Optional flag to ignore bind failures for ``listen`` ports that are already in use when validating the ``configfile``. diff --git a/patroni/__main__.py b/patroni/__main__.py index 04d53c9e..169a39cd 100644 --- a/patroni/__main__.py +++ b/patroni/__main__.py @@ -241,6 +241,8 @@ def process_arguments() -> Namespace: * ``--validate-config`` -- used to validate the Patroni configuration file * ``--generate-config`` -- used to generate Patroni configuration from a running PostgreSQL instance * ``--generate-sample-config`` -- used to generate a sample Patroni configuration + * ``--ignore-listen-port`` | ``-i`` -- used to ignore ``listen`` ports already in use. + Can be used only with ``--validate-config`` .. note:: If running with ``--generate-config``, ``--generate-sample-config`` or ``--validate-flag`` will exit @@ -259,6 +261,9 @@ def process_arguments() -> Namespace: help='Generate a Patroni yaml configuration file for a running instance') parser.add_argument('--dsn', help='Optional DSN string of the instance to be used as a source \ for config generation. Superuser connection is required.') + parser.add_argument('--ignore-listen-port', '-i', action='store_true', + help='Ignore `listen` ports already in use.\ + Can only be used with --validate-config') args = parser.parse_args() if args.generate_sample_config: @@ -269,7 +274,9 @@ def process_arguments() -> Namespace: sys.exit(0) elif args.validate_config: from patroni.config import Config, ConfigParseError - from patroni.validator import schema + from patroni.validator import populate_validate_params, schema + + populate_validate_params(ignore_listen_port=args.ignore_listen_port) try: Config(args.configfile, validator=schema) diff --git a/patroni/validator.py b/patroni/validator.py index 5df8b1cd..04588421 100644 --- a/patroni/validator.py +++ b/patroni/validator.py @@ -17,6 +17,17 @@ 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: Union[str, Dict[str, Any], Any]) -> bool: """Checks if log field is valid. @@ -137,7 +148,8 @@ def validate_host_port(host_port: str, listen: bool = False, multiple_hosts: boo s = socket.socket(proto[0][0], socket.SOCK_STREAM) try: if s.connect_ex((host, port)) == 0: - if listen: + # 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)) diff --git a/tests/test_validator.py b/tests/test_validator.py index 217a4b5c..82f9fa17 100644 --- a/tests/test_validator.py +++ b/tests/test_validator.py @@ -8,7 +8,7 @@ from io import StringIO from unittest.mock import Mock, mock_open, patch from patroni.dcs import dcs_modules -from patroni.validator import Directory, schema, Schema +from patroni.validator import Directory, populate_validate_params, schema, Schema available_dcs = [m.split(".")[-1] for m in dcs_modules()] config = { @@ -400,3 +400,38 @@ class TestValidator(unittest.TestCase): errors = schema(c) output = "\n".join(errors) self.assertEqual(['postgresql.bin_dir', 'raft.bind_addr', 'raft.self_addr'], parse_output(output)) + + @patch('socket.socket.connect_ex', Mock(return_value=0)) + def test_bound_port_checks_without_ignore(self, mock_out, mock_err): + # When ignore_listen_port is False (default case), an error should be raised if the ports are already bound. + c = copy.deepcopy(config) + c['restapi']['listen'] = "127.0.0.1:8000" + c['postgresql']['listen'] = "127.0.0.1:9000" + c['raft']['self_addr'] = "127.0.0.2:9200" + + populate_validate_params(ignore_listen_port=False) + + errors = schema(c) + output = "\n".join(errors) + + self.assertEqual(['postgresql.bin_dir', 'postgresql.listen', + 'raft.bind_addr', 'restapi.listen'], + parse_output(output)) + + @patch('socket.socket.connect_ex', Mock(return_value=0)) + def test_bound_port_checks_with_ignore(self, mock_out, mock_err): + c = copy.deepcopy(config) + c['restapi']['listen'] = "127.0.0.1:8000" + c['postgresql']['listen'] = "127.0.0.1:9000" + c['raft']['self_addr'] = "127.0.0.2:9200" + c['raft']['bind_addr'] = "127.0.0.1:9300" + + # Case: When ignore_listen_port is True, error should NOT be raised + # even if the ports are already bound. + populate_validate_params(ignore_listen_port=True) + + errors = schema(c) + output = "\n".join(errors) + + self.assertEqual(['postgresql.bin_dir'], + parse_output(output))