diff --git a/barman/cli.py b/barman/cli.py index cde3c0089..67064f881 100644 --- a/barman/cli.py +++ b/barman/cli.py @@ -1205,9 +1205,10 @@ def diagnose(args=None): """ # Get every server (both inactive and temporarily disabled) servers = get_server_list(on_error_stop=False, suppress_error=True) + models = get_models_list() # errors list with duplicate paths between servers errors_list = barman.__config__.servers_msg_list - barman.diagnose.exec_diagnose(servers, errors_list, args.show_config_source) + barman.diagnose.exec_diagnose(servers, models, errors_list, args.show_config_source) output.close_and_exit() @@ -1810,6 +1811,50 @@ def check_wal_archive(args): output.close_and_exit() +@command( + [ + argument( + "server_name", + completer=server_completer, + help="specifies the name of the server which configuration should " + "be override by the model", + ), + argument( + "model_name", + help="specifies the name of the model which configuration should " + "override the server configuration. Not used when called with " + "the '--reset' flag", + nargs="?", + ), + argument( + "--reset", + help="indicates that we should unapply the currently active model " + "for the server", + action="store_true", + ), + ] +) +def config_switch(args): + """ + Change the active configuration for a server by applying a named model on + top of it, or by resetting the active model. + """ + if args.model_name is None and not args.reset: + output.error("Either a model name or '--reset' flag need to be given") + return + + server = get_server(args, skip_inactive=False) + + if server is not None: + if args.reset: + server.config.reset_model() + else: + model = get_model(args) + + if model is not None: + server.config.apply_model(model, True) + + def pretty_args(args): """ Prettify the given argparse namespace to be human readable @@ -2103,6 +2148,111 @@ def manage_server_command( return True +def get_models_list(args=None): + """Get the model list from the configuration. + + If the *args* parameter is ``None`` returns all defined servers. + + :param args: an :class:`argparse.Namespace` containing a list + ``model_name`` parameter. + + :return: a :class:`dict` -- each key is a model name, and its value the + corresponding :class:`ModelConfig` instance. + """ + model_dict = {} + + # This function must to be called with in a multiple-model context + assert not args or isinstance(args.model_name, list) + + # Generate the list of models (required for global errors) + available_models = barman.__config__.model_names() + + # Handle special *args* is ``None`` case + if not args: + model_names = available_models + else: + # Put models in a set, so multiple occurrences are counted only once + model_names = set(args.model_name) + + # Loop through all the requested models + for model_name in model_names: + model = barman.__config__.get_model(model_name) + if model is None: + # Unknown model + model_dict[model_name] = None + else: + model_dict[model_name] = model + + return model_dict + + +def manage_model_command(model, name=None): + """ + Standard and consistent method for managing model errors within a model + command execution. + + :param model: :class:`ModelConfig` to be checked for errors. + :param name: name of the model. + + :return: ``True`` if the command has to be executed with this model. + """ + + # Unknown model (skip it) + if not model: + output.error("Unknown model '%s'" % name) + return False + + # All ok, execute the command + return True + + +def get_model(args, on_error_stop=True): + """ + Get a single model retrieving its configuration (wraps :func:`get_models_list`). + + .. warning:: + This function modifies the *args* parameter. + + :param args: an :class:`argparse.Namespace` containing a single + ``model_name`` parameter. + :param on_error_stop: stop if an error is found. + + :return: a :class:`ModelConfig` or ``None`` if the required model is + unknown and *on_error_stop* is ``False``. + """ + # This function must to be called with in a single-model context + name = args.model_name + assert isinstance(name, str) + + # Builds a list from a single given name + args.model_name = [name] + + # Retrieve the requested model + models = get_models_list(args) + + # The requested model has been excluded from :func:`get_models_list`` result + if len(models) == 0: + output.close_and_exit() + # The following return statement will never be reached + # but it is here for clarity + return None + + # retrieve the model object + model = models[name] + + # Apply standard validation control and skips + # the model if invalid, displaying standard + # error messages. If on_error_stop (default) exits + if not manage_model_command(model, name) and on_error_stop: + output.close_and_exit() + # The following return statement will never be reached + # but it is here for clarity + return None + + # Returns the filtered model + return model + + def parse_backup_id(server, args): """ Parses backup IDs including special words such as latest, oldest, etc. diff --git a/barman/config.py b/barman/config.py index 89ad738b0..59b2df249 100644 --- a/barman/config.py +++ b/barman/config.py @@ -21,6 +21,7 @@ Barman configuration, such as parsing configuration file. """ +from copy import deepcopy import collections import datetime import inspect @@ -419,7 +420,64 @@ def parse_create_slot(value): ) -class ServerConfig(object): +class BaseConfig(object): + """ + Contains basic methods for handling configuration of Servers and Models. + + You are expected to inherit from this class and define at least the + :cvar:`PARSERS` dictionary with a mapping of parsers for each suported + configuration option. + """ + + PARSERS = {} + + def invoke_parser(self, key, source, value, new_value): + """ + Function used for parsing configuration values. + If needed, it uses special parsers from the PARSERS map, + and handles parsing exceptions. + + Uses two values (value and new_value) to manage + configuration hierarchy (server config overwrites global config). + + :param str key: the name of the configuration option + :param str source: the section that contains the configuration option + :param value: the old value of the option if present. + :param str new_value: the new value that needs to be parsed + :return: the parsed value of a configuration option + """ + # If the new value is None, returns the old value + if new_value is None: + return value + # If we have a parser for the current key, use it to obtain the + # actual value. If an exception is thrown, print a warning and + # ignore the value. + # noinspection PyBroadException + if key in self.PARSERS: + parser = self.PARSERS[key] + try: + # If the parser is a subclass of the CsvOption class + # we need a different invocation, which passes not only + # the value to the parser, but also the key name + # and the section that contains the configuration + if inspect.isclass(parser) and issubclass(parser, CsvOption): + value = parser(new_value, key, source) + else: + value = parser(new_value) + except Exception as e: + output.warning( + "Ignoring invalid configuration value '%s' for key %s in %s: %s", + new_value, + key, + source, + e, + ) + else: + value = new_value + return value + + +class ServerConfig(BaseConfig): """ This class represents the configuration for a specific Server instance. """ @@ -447,6 +505,7 @@ class ServerConfig(object): "basebackup_retry_times", "basebackups_directory", "check_timeout", + "cluster", "compression", "conninfo", "custom_compression_filter", @@ -539,6 +598,7 @@ class ServerConfig(object): "check_timeout", "compression", "configuration_files_directory", + "create_slot", "custom_compression_filter", "custom_decompression_filter", "custom_compression_magic", @@ -578,7 +638,6 @@ class ServerConfig(object): "primary_ssh_command", "recovery_options", "recovery_staging_path", - "create_slot", "retention_policy", "retention_policy_mode", "reuse_backup", @@ -605,6 +664,7 @@ class ServerConfig(object): "basebackup_retry_times": "0", "basebackups_directory": "%(backup_directory)s/base", "check_timeout": "30", + "cluster": "%(name)s", "disabled": "false", "errors_directory": "%(backup_directory)s/errors", "forward_config_path": "false", @@ -670,51 +730,6 @@ class ServerConfig(object): "slot_name": parse_slot_name, } - def invoke_parser(self, key, source, value, new_value): - """ - Function used for parsing configuration values. - If needed, it uses special parsers from the PARSERS map, - and handles parsing exceptions. - - Uses two values (value and new_value) to manage - configuration hierarchy (server config overwrites global config). - - :param str key: the name of the configuration option - :param str source: the section that contains the configuration option - :param value: the old value of the option if present. - :param str new_value: the new value that needs to be parsed - :return: the parsed value of a configuration option - """ - # If the new value is None, returns the old value - if new_value is None: - return value - # If we have a parser for the current key, use it to obtain the - # actual value. If an exception is thrown, print a warning and - # ignore the value. - # noinspection PyBroadException - if key in self.PARSERS: - parser = self.PARSERS[key] - try: - # If the parser is a subclass of the CsvOption class - # we need a different invocation, which passes not only - # the value to the parser, but also the key name - # and the section that contains the configuration - if inspect.isclass(parser) and issubclass(parser, CsvOption): - value = parser(new_value, key, source) - else: - value = parser(new_value) - except Exception as e: - output.warning( - "Ignoring invalid configuration value '%s' for key %s in %s: %s", - new_value, - key, - source, - e, - ) - else: - value = new_value - return value - def __init__(self, config, name): self.msg_list = [] self.config = config @@ -750,6 +765,78 @@ def __init__(self, config, name): if value is not None and value == "" or value == "None": value = None setattr(self, key, value) + self._active_model_file = os.path.join( + self.backup_directory, ".active-model.auto" + ) + self.active_model = None + + def apply_model(self, model, from_cli=False): + """Apply config from a model named *name*. + + :param model: the model to be applied. + :param from_cli: ``True`` if this function has been called by the user + through a command, e.g. ``barman-config-switch``. ``False`` if it + has been called internally by Barman. ``INFO`` messages are written + in the first case, ``DEBUG`` messages in the second case. + """ + writer_func = getattr(output, "info" if from_cli else "debug") + + if self.cluster != model.cluster: + output.error( + "Model '%s' has 'cluster=%s', which is not compatible with " + "'cluster=%s' from server '%s'" + % ( + model.name, + model.cluster, + self.cluster, + self.name, + ) + ) + + return + + # No need to apply the same model twice + if self.active_model is not None and model.name == self.active_model.name: + writer_func( + "Model '%s' is already active for server '%s', " + "skipping..." % (model.name, self.name) + ) + + return + + writer_func("Applying model '%s' to server '%s'" % (model.name, self.name)) + + for option, value in model.get_override_options(): + old_value = getattr(self, option) + + if old_value != value: + writer_func( + "Changing value of option '%s' for server '%s' " + "from '%s' to '%s' through the model '%s'" + % (option, self.name, old_value, value, model.name) + ) + + setattr(self, option, value) + + if from_cli: + # If the request came from the CLI, like from 'barman config-switch' + # then we need to persist the change to disk. On the other hand, if + # Barman is calling this method on its own, that's because it previously + # already read the active model from that file, so there is no need + # to persist it again to disk + with open(self._active_model_file, "w") as f: + f.write(model.name) + + self.active_model = model + + def reset_model(self): + """Reset the active model for this server, if any.""" + output.info("Resetting the active model for the server %s" % (self.name)) + + if os.path.isfile(self._active_model_file): + os.remove(self._active_model_file) + + self.active_model = None def to_json(self, with_source=False): """ @@ -769,14 +856,34 @@ def to_json(self, with_source=False): the option has been configured by the user, otherwise ``None``. """ json_dict = dict(vars(self)) - # remove the reference to main Config object - del json_dict["config"] + + # remove references that should not go inside the + # `servers -> SERVER -> config` key in the barman diagnose output + # ideally we should change this later so we only consider configuration + # options, as things like `msg_list` are going to the `config` key, + # i.e. we might be interested in considering only `ServerConfig.KEYS` + # here instead of `vars(self)` + for key in ["config", "_active_model_file", "active_model"]: + del json_dict[key] + + # options that are override by the model + override_options = set() + + if self.active_model: + override_options = { + option for option, _ in self.active_model.get_override_options() + } if with_source: for option, value in json_dict.items(): + name = self.name + + if option in override_options: + name = self.active_model.name + json_dict[option] = { "value": value, - "source": self.config.get_config_source(self.name, option), + "source": self.config.get_config_source(name, option), } return json_dict @@ -815,6 +922,125 @@ def update_msg_list_and_disable_server(self, msg_list): self.disabled = True +class ModelConfig(BaseConfig): + """ + This class represents the configuration for a specific model of a server. + + :cvar KEYS: list of configuration options that are allowed in a model. + :cvar REQUIRED_KEYS: list of configuration options that must always be set + when defining a configuration model. + :cvar PARSERS: mapping of parsers for the configuration options, if they + need special handling. + """ + + # Keys from ServerConfig which are not allowed in a configuration model. + # They are mostly related with paths or hooks, which are not expected to + # be changed at all with a model. + _KEYS_BLACKLIST = { + # Path related options + "backup_directory", + "basebackups_directory", + "errors_directory", + "incoming_wals_directory", + "streaming_wals_directory", + "wals_directory", + # Hook related options + "post_archive_retry_script", + "post_archive_script", + "post_backup_retry_script", + "post_backup_script", + "post_delete_script", + "post_delete_retry_script", + "post_recovery_retry_script", + "post_recovery_script", + "post_wal_delete_script", + "post_wal_delete_retry_script", + "pre_archive_retry_script", + "pre_archive_script", + "pre_backup_retry_script", + "pre_backup_script", + "pre_delete_script", + "pre_delete_retry_script", + "pre_recovery_retry_script", + "pre_recovery_script", + "pre_wal_delete_script", + "pre_wal_delete_retry_script", + } + + KEYS = list((set(ServerConfig.KEYS) | {"model"}) - _KEYS_BLACKLIST) + + REQUIRED_KEYS = [ + "cluster", + "model", + ] + + PARSERS = deepcopy(ServerConfig.PARSERS) + PARSERS.update({"model": parse_boolean}) + for key in _KEYS_BLACKLIST: + PARSERS.pop(key, None) + + def __init__(self, config, name): + self.config = config + self.name = name + config.validate_model_config(self.name) + for key in ModelConfig.KEYS: + value = None + # Get the setting from the [name] section of config file + # A literal None value is converted to an empty string + new_value = config.get(name, key, self.__dict__, none_value="") + source = "[%s] section" % name + value = self.invoke_parser(key, source, value, new_value) + # An empty string is a None value + if value is not None and value == "" or value == "None": + value = None + setattr(self, key, value) + + def get_override_options(self): + """ + Get a list of options which values in the server should be override. + + :yield: tuples os option name and value which should override the value + specified in the server with the value specified in the model. + """ + for option in set(self.KEYS) - set(self.REQUIRED_KEYS): + value = getattr(self, option) + + if value is not None: + yield option, value + + def to_json(self, with_source=False): + """ + Return an equivalent dictionary that can be encoded in json + + :param with_source: if we should include the source file that provides + the effective value for each configuration option. + + :return: a dictionary. The structure depends on *with_source* argument: + + * If ``False``: key is the option name, value is its value; + * If ``True``: key is the option name, value is a dict with a + couple keys: + + * ``value``: the value of the option; + * ``source``: the file which provides the effective value, if + the option has been configured by the user, otherwise ``None``. + """ + json_dict = {} + + for option in self.KEYS: + value = getattr(self, option) + + if with_source: + value = { + "value": value, + "source": self.config.get_config_source(self.name, option), + } + + json_dict[option] = value + + return json_dict + + class ConfigMapping(ConfigParser): """Wrapper for :class:`ConfigParser`. @@ -968,6 +1194,7 @@ def __init__(self, filename=None): ) self.config_file = filename self._servers = None + self._models = None self.servers_msg_list = [] self._parse_global_config() @@ -1085,9 +1312,30 @@ def load_configuration_files_directory(self): # Add an info that a file has been discarded _logger.warn("Discarding configuration file: %s (not a file)", filename) - def _populate_servers(self): + def _is_model(self, name): """ - Populate server list from configuration file + Check if section *name* is a model. + + :param name: name of the config section. + + :return: ``True`` if section *name* is a model, ``False`` otherwise. + + :raises: + :exc:`ValueError`: re-raised if thrown by :func:`parse_boolean`. + """ + try: + value = self._config.get(name, "model") + except NoOptionError: + return False + + try: + return parse_boolean(value) + except ValueError as exc: + raise exc + + def _populate_servers_and_models(self): + """ + Populate server list and model list from configuration file Also check for paths errors in configuration. If two or more paths overlap in @@ -1097,9 +1345,10 @@ def _populate_servers(self): """ # Populate servers - if self._servers is not None: + if self._servers is not None and self._models is not None: return self._servers = {} + self._models = {} # Cycle all the available configurations sections for section in self._config.sections(): if section == "barman": @@ -1113,12 +1362,19 @@ def _populate_servers(self): ) _logger.fatal(msg) raise SystemExit("FATAL: %s" % msg) - # Create a ServerConfig object - self._servers[section] = ServerConfig(self, section) + if self._is_model(section): + # Create a ModelConfig object + self._models[section] = ModelConfig(self, section) + else: + # Create a ServerConfig object + self._servers[section] = ServerConfig(self, section) # Check for conflicting paths in Barman configuration self._check_conflicting_paths() + # Apply models if the hidden files say so + self._apply_models() + def _check_conflicting_paths(self): """ Look for conflicting paths intra-server and inter-server @@ -1130,11 +1386,7 @@ def _check_conflicting_paths(self): self.servers_msg_list = [] # Cycle all the available configurations sections - for section in sorted(self._config.sections()): - if section == "barman": - # skip global settings - continue - + for section in sorted(self.server_names()): # Paths map section_conf = self._servers[section] config_paths = { @@ -1215,14 +1467,53 @@ def _check_conflicting_paths(self): ) ) + def _apply_models(self): + """ + For each Barman server, check for a pre-existing active model. + + If a hidden file with a pre-existing active model file exists, apply + that on top of the server configuration. + """ + for server in self.servers(): + active_model = None + + try: + with open(server._active_model_file, "r") as f: + active_model = f.read().strip() + except FileNotFoundError: + # If a file does not exist, even if the server has models + # defined, none of them has ever been applied + continue + + if active_model.strip() == "": + # Try to protect itself from a bogus file + continue + + model = self.get_model(active_model) + + if model is None: + # The model used to exist, but it's no longer avaialble for + # some reason + server.update_msg_list_and_disable_server( + [ + "Model '%s' is set as the active model for the server " + "'%s' but the model does not exist." + % (active_model, server.name) + ] + ) + + continue + + server.apply_model(model) + def server_names(self): """This method returns a list of server names""" - self._populate_servers() + self._populate_servers_and_models() return self._servers.keys() def servers(self): """This method returns a list of server parameters""" - self._populate_servers() + self._populate_servers_and_models() return self._servers.values() def get_server(self, name): @@ -1231,9 +1522,36 @@ def get_server(self, name): :param str name: the server name """ - self._populate_servers() + self._populate_servers_and_models() return self._servers.get(name, None) + def model_names(self): + """Get a list of model names. + + :return: a :class:`list` of configured model names. + """ + self._populate_servers_and_models() + return self._models.keys() + + def models(self): + """Get a list of models. + + :return: a :class:`list` of configured :class:`ModelConfig` objects. + """ + self._populate_servers_and_models() + return self._models.values() + + def get_model(self, name): + """Get the configuration of the specified model. + + :param name: the model name. + + :return: a :class:`ModelConfig` if the model exists, otherwise + ``None``. + """ + self._populate_servers_and_models() + return self._models.get(name, None) + def validate_global_config(self): """ Validate global configuration parameters @@ -1267,6 +1585,20 @@ def validate_server_config(self, server): # server section of the configuration file self._validate_with_keys(self._config.items(server), ServerConfig.KEYS, server) + def validate_model_config(self, model): + """ + Validate configuration parameters for a specified model. + + :param model: the model name. + """ + # Check for the existence of unexpected parameters in the + # model section of the configuration file + self._validate_with_keys(self._config.items(model), ModelConfig.KEYS, model) + # Check for keys that are missing, but which are required + self._detect_missing_keys( + self._config.items(model), ModelConfig.REQUIRED_KEYS, model + ) + @staticmethod def _detect_missing_keys(config_items, required_keys, section): """ diff --git a/barman/diagnose.py b/barman/diagnose.py index 338a444b7..ff3d4fd59 100644 --- a/barman/diagnose.py +++ b/barman/diagnose.py @@ -34,7 +34,7 @@ _logger = logging.getLogger(__name__) -def exec_diagnose(servers, errors_list, show_config_source): +def exec_diagnose(servers, models, errors_list, show_config_source): """ Diagnostic command: gathers information from backup server and from all the configured servers. @@ -42,12 +42,13 @@ def exec_diagnose(servers, errors_list, show_config_source): Gathered information should be used for support and problems detection :param dict(str,barman.server.Server) servers: list of configured servers + :param models: list of configured models. :param list errors_list: list of global errors :param show_config_source: if we should include the configuration file that provides the effective value for each configuration option. """ # global section. info about barman server - diagnosis = {"global": {}, "servers": {}} + diagnosis = {"global": {}, "servers": {}, "models": {}} # barman global config diagnosis["global"]["config"] = dict( barman.__config__.global_config_to_json(show_config_source) @@ -72,6 +73,13 @@ def exec_diagnose(servers, errors_list, show_config_source): # server configuration diagnosis["servers"][name] = {} diagnosis["servers"][name]["config"] = server.config.to_json(show_config_source) + # server model + active_model = ( + server.config.active_model.name + if server.config.active_model is not None + else None + ) + diagnosis["servers"][name]["active_model"] = active_model # server system info if server.config.ssh_command: try: @@ -105,6 +113,15 @@ def exec_diagnose(servers, errors_list, show_config_source): } # Release any PostgreSQL resource server.close() + # per model section + for name in sorted(models): + model = models[name] + if model is None: + output.error("Unknown model '%s'" % name) + continue + # model configuration + diagnosis["models"][name] = {} + diagnosis["models"][name]["config"] = model.to_json(show_config_source) output.info( json.dumps(diagnosis, cls=BarmanEncoderV2, indent=4, sort_keys=True), log=False ) diff --git a/tests/test_cli.py b/tests/test_cli.py index dd7c79635..26421951a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -34,8 +34,11 @@ check_wal_archive, command, generate_manifest, + get_model, + get_models_list, get_server, get_server_list, + manage_model_command, manage_server_command, OrderedHelpFormatter, parse_backup_id, @@ -44,6 +47,7 @@ replication_status, keep, show_servers, + config_switch, ) from barman.exceptions import WalArchiveContentError from barman.infofile import BackupInfo @@ -325,6 +329,86 @@ def test_get_server_list_global_error_continue(self, monkeypatch): assert global_error_list assert len(global_error_list) == 6 + def test_get_model(self, monkeypatch): + """ + Test the get_model method, providing a basic configuration + + :param monkeypatch monkeypatch: pytest patcher + """ + # Mock the args from argparse + args = Mock() + args.model_name = "main:model" + monkeypatch.setattr( + barman, + "__config__", + build_config_from_dicts( + with_model=True, + ), + ) + model_main = get_model(args) + # Expect the model to exists + assert model_main + # Expect the name to be the right one + assert model_main.name == "main:model" + + @pytest.mark.parametrize("model", [None, MagicMock()]) + @patch("barman.cli.output") + def test_manage_model_command(self, mock_output, model): + """Test :func:`manage_model_command`. + + Ensure it returns the expected result and log the expected message. + """ + expected = model is not None + + assert manage_model_command(model, "SOME_MODEL") == expected + + if model is None: + mock_output.error.assert_called_once_with( + "Unknown model '%s'" % "SOME_MODEL" + ) + + def test_get_models_list_invalid_args(self): + """Test :func:`get_models_list`. + + Ensure an :exc:`AssertionError` is thrown when calling with invalid args. + """ + mock_args = Mock(model_name="SOME_MODEL") + + with pytest.raises(AssertionError): + get_models_list(mock_args) + + def test_get_models_list_none_args(self, monkeypatch): + """Test :func:`get_models_list`. + + Ensure the call brings all models when ``args`` is ``None``. + """ + monkeypatch.setattr( + barman, "__config__", build_config_from_dicts(with_model=True) + ) + # we only have the ``main:model`` model by default + model_list = get_models_list() + assert len(model_list) == 1 + assert list(model_list.keys())[0] == "main:model" + assert isinstance(model_list["main:model"], barman.config.ModelConfig) + + def test_get_models_list_valid_args(self, monkeypatch): + """Test :func:`get_models_list`. + + Ensure it brings a list with the requested models if ``args`` is given. + """ + monkeypatch.setattr( + barman, "__config__", build_config_from_dicts(with_model=True) + ) + + mock_args = Mock(model_name=["main:model", "SOME_MODEL"]) + # we only have the ``main:model`` model by default, so ``SOME_MODEL`` + # should be ``None`` + model_list = get_models_list(mock_args) + assert len(model_list) == 2 + assert sorted(list(model_list.keys())) == ["SOME_MODEL", "main:model"] + assert isinstance(model_list["main:model"], barman.config.ModelConfig) + assert model_list["SOME_MODEL"] is None + @pytest.fixture def mock_backup_info(self): backup_info = Mock() @@ -1464,3 +1548,96 @@ def test_show_servers_json( json_output = json.loads(out) assert [self.test_server_name] == list(json_output.keys()) assert json_output[self.test_server_name]["description"] == expected_description + + +class TestConfigSwitchCli: + """Test ``barman config-switch`` outcomes.""" + + @pytest.fixture + def mock_args(self): + return MagicMock( + server_name="SOME_SERVER", model_name="SOME_MODEL", reset=False + ) + + @patch("barman.cli.output") + def test_config_switch_invalid_args(self, mock_output, mock_args): + """Test :func:`config_switch`. + + It should error out if neither ``--reset`` nor ``model_name`` are given. + """ + mock_args.model_name = None + + config_switch(mock_args) + + mock_output.error.assert_called_once_with( + "Either a model name or '--reset' flag need to be given" + ) + + @patch("barman.cli.get_server") + def test_config_switch_no_server(self, mock_get_server, mock_args): + """Test :func:`config_switch`. + + It should do nothing if :func:`get_server` returns nothing. + """ + mock_get_server.return_value = None + + config_switch(mock_args) + + mock_get_server.assert_called_once_with(mock_args, skip_inactive=False) + + @patch("barman.cli.get_model") + @patch("barman.cli.get_server") + def test_config_switch_model_apply_model_no_model( + self, mock_get_server, mock_get_model, mock_args + ): + """Test :func:`config_switch`. + + It should call :meth:`barman.config.ServerConfig.apply_model` when + a server and a model are given. + """ + mock_apply_model = mock_get_server.return_value.config.apply_model + mock_reset_model = mock_get_server.return_value.config.reset_model + mock_get_model.return_value = None + + config_switch(mock_args) + + mock_get_server.assert_called_once_with(mock_args, skip_inactive=False) + mock_apply_model.assert_not_called() + mock_reset_model.assert_not_called() + + @patch("barman.cli.get_model") + @patch("barman.cli.get_server") + def test_config_switch_model_apply_model_ok( + self, mock_get_server, mock_get_model, mock_args + ): + """Test :func:`config_switch`. + + It should call :meth:`barman.config.ServerConfig.apply_model` when + a server and a model are given. + """ + mock_apply_model = mock_get_server.return_value.config.apply_model + mock_reset_model = mock_get_server.return_value.config.reset_model + + config_switch(mock_args) + + mock_get_server.assert_called_once_with(mock_args, skip_inactive=False) + mock_apply_model.assert_called_once_with(mock_get_model.return_value, True) + mock_reset_model.assert_not_called() + + @patch("barman.cli.get_server") + def test_config_switch_model_reset_model(self, mock_get_server, mock_args): + """Test :func:`config_switch`. + + It should call :meth:`barman.config.ServerConfig.reset_model` when + a server and ``--reset`` flag are given. + """ + mock_args.model_name = None + mock_args.reset = True + mock_apply_model = mock_get_server.return_value.config.apply_model + mock_reset_model = mock_get_server.return_value.config.reset_model + + config_switch(mock_args) + + mock_get_server.assert_called_once_with(mock_args, skip_inactive=False) + mock_apply_model.assert_not_called() + mock_reset_model.assert_called_once_with() diff --git a/tests/test_config.py b/tests/test_config.py index ae7c0f839..cc74bad7a 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -20,12 +20,15 @@ import mock import pytest -from mock import MagicMock, Mock, mock_open, patch +from mock import MagicMock, Mock, call, mock_open, patch from barman.config import ( BackupOptions, + BaseConfig, ConfigMapping, Config, + CsvOption, + ModelConfig, RecoveryOptions, parse_backup_compression, parse_backup_compression_format, @@ -366,6 +369,7 @@ def test_config(self): # create the expected dictionary expected = testing_helpers.build_config_dictionary( { + "_active_model_file": "/some/barman/home/web/.active-model.auto", "config": web.config, "autogenerate_manifest": False, "backup_directory": "/some/barman/home/web", @@ -375,6 +379,7 @@ def test_config(self): "backup_compression_level": None, "backup_compression_location": None, "backup_compression_workers": None, + "cluster": "web", "compression": None, "conninfo": "host=web01 user=postgres port=5432", "description": "Web applications database", @@ -577,7 +582,7 @@ def test_server_conflict_paths(self): ) assert main.__dict__ == expected - def test_populate_servers(self): + def test_populate_servers_and_models(self): """ Test for the presence of conflicting paths in configuration between all the servers @@ -595,14 +600,14 @@ def test_populate_servers(self): # attribute servers_msg_list is empty before _populate_server() assert len(c.servers_msg_list) == 0 - c._populate_servers() + c._populate_servers_and_models() - # after _populate_servers() if there is a global paths error + # after _populate_servers_and_models() if there is a global paths error # servers_msg_list is created in configuration assert c.servers_msg_list assert len(c.servers_msg_list) == 6 - def test_populate_servers_following_symlink(self, tmpdir): + def test_populate_servers_and_models_following_symlink(self, tmpdir): """ Test for the presence of conflicting paths in configuration between all the servers @@ -621,7 +626,7 @@ def test_populate_servers_following_symlink(self, tmpdir): }, ) - c._populate_servers() + c._populate_servers_and_models() # If there is one or more path errors are present, # the msg_list of the 'main' server is populated during @@ -805,6 +810,188 @@ def test_get_config_source(self): c.get_config_source("section", "option") mock.assert_called_once_with("section", "option") + def test__is_model_missing_model(self): + """Test :meth:`Config._is_model`. + + Ensure ``False`` is returned if there is no ``model`` option configured. + """ + fp = StringIO( + """ + [barman] + barman_home = /some/barman/home + barman_user = barman + log_file = %(barman_home)s/log/barman.log + + [SOME_MODEL] + """ + ) + c = Config(fp) + assert c._is_model("SOME_MODEL") is False + + def test__is_model_not_model(self): + """Test :meth:`Config._is_model`. + + Ensure ``False`` is returned if there ``model = false`` is found. + """ + fp = StringIO( + """ + [barman] + barman_home = /some/barman/home + barman_user = barman + log_file = %(barman_home)s/log/barman.log + + [SOME_MODEL] + model = false + """ + ) + c = Config(fp) + assert c._is_model("SOME_MODEL") is False + + def test__is_model_ok(self): + """Test :meth:`Config._is_model`. + + Ensure ``True`` is returned if there ``model = true`` is found. + """ + fp = StringIO( + """ + [barman] + barman_home = /some/barman/home + barman_user = barman + log_file = %(barman_home)s/log/barman.log + + [SOME_MODEL] + model = true + """ + ) + c = Config(fp) + assert c._is_model("SOME_MODEL") is True + + @patch("barman.config.parse_boolean") + def test__is_model_exception(self, mock_parse_boolean): + """Test :meth:`Config._is_model`. + + Ensure an exception is face if the parser function faces an exception. + """ + fp = StringIO( + """ + [barman] + barman_home = /some/barman/home + barman_user = barman + log_file = %(barman_home)s/log/barman.log + + [SOME_MODEL] + model = true + """ + ) + c = Config(fp) + mock_parse_boolean.side_effect = ValueError("SOME_ERROR") + + with pytest.raises(ValueError) as exc: + c._is_model("SOME_MODEL") + + assert str(exc.value) == "SOME_ERROR" + + def test__apply_models_file_not_found(self): + """Test :meth:`Config._apply_models`. + + Ensure it ignores active model files which do not exist. + """ + fp = StringIO(MINIMAL_CONFIG) + c = Config(fp) + + mock = mock_open() + mock.side_effect = FileNotFoundError("FILE DOES NOT EXIST") + + with patch.object(c, "servers") as mock_servers, patch("builtins.open", mock): + mock_server = MagicMock() + mock_servers.return_value = [mock_server] + + c._apply_models() + + mock_server.apply_model.assert_not_called() + mock_server.update_msg_list_and_disable_server.assert_not_called() + mock.assert_called_once_with(mock_server._active_model_file, "r") + + def test__apply_models_file_with_bogus_content(self): + """Test :meth:`Config._apply_models`. + + Ensure it ignores active model file with bogus content. + """ + fp = StringIO(MINIMAL_CONFIG) + c = Config(fp) + + mock = mock_open(read_data=" ") + + with patch.object(c, "servers") as mock_servers, patch("builtins.open", mock): + mock_server = MagicMock() + mock_servers.return_value = [mock_server] + + c._apply_models() + + mock_server.apply_model.assert_not_called() + mock_server.update_msg_list_and_disable_server.assert_not_called() + mock.assert_called_once_with(mock_server._active_model_file, "r") + handle = mock() + handle.read.assert_called_once_with() + + def test__apply_models_model_does_not_exist(self): + """Test :meth:`Config._apply_models`. + + Ensure errors are pointed out in case an invalid model is found in the + active model file. + """ + fp = StringIO(MINIMAL_CONFIG) + c = Config(fp) + + mock = mock_open(read_data="SOME_OTHER_MODEL") + + with patch.object(c, "servers") as mock_servers, patch( + "builtins.open", mock + ), patch.object(c, "get_model", Mock(return_value=None)): + mock_server = MagicMock() + mock_servers.return_value = [mock_server] + + c._apply_models() + + mock_server.apply_model.assert_not_called() + mock_server.update_msg_list_and_disable_server.assert_called_once_with( + [ + "Model '%s' is set as the active model for the server " + "'%s' but the model does not exist." + % ("SOME_OTHER_MODEL", mock_server.name) + ] + ) + mock.assert_called_once_with(mock_server._active_model_file, "r") + + @pytest.fixture + def mock_model(self): + mock = MagicMock() + mock.name = "SOME_OTHER_MODEL" + mock.cluster = "main" + return mock + + def test__apply_models_model_ok(self, mock_model): + """Test :meth:`Config._apply_models`. + + Ensure everything goes smoothly if the model and the file exists. + """ + fp = StringIO(MINIMAL_CONFIG) + c = Config(fp) + + mock = mock_open(read_data="SOME_OTHER_MODEL") + + with patch.object(c, "servers") as mock_servers, patch( + "builtins.open", mock + ), patch.object(c, "get_model", Mock(return_value=mock_model)): + mock_server = MagicMock() + mock_servers.return_value = [mock_server] + + c._apply_models() + + mock_server.apply_model.assert_called_once_with(mock_model) + mock_server.update_msg_list_and_disable_server.assert_not_called() + mock.assert_called_once_with(mock_server._active_model_file, "r") + class TestServerConfig(object): def test_update_msg_list_and_disable_server(self): @@ -894,7 +1081,8 @@ def test_to_json(self): "last_backup_minimum_size": 1048576, } expected = testing_helpers.build_config_dictionary(expected_override) - del expected["config"] + for key in ["config", "_active_model_file", "active_model"]: + del expected[key] assert main.to_json(False) == expected # Check `to_json(with_source=True)` works as expected @@ -917,6 +1105,395 @@ def test_to_json(self): assert main.to_json(True) == expected + @pytest.fixture + def server_config(self): + c = testing_helpers.build_config_from_dicts( + main_conf={"cluster": "SOME_CLUSTER"}, + ) + main = c.get_server("main") + return main + + @pytest.fixture + def model_config(self): + mock_config = MagicMock() + mock_config.get.return_value = None + mock_config.get_config_source.return_value = "SOME_SOURCE" + model = ModelConfig(mock_config, "SOME_MODEL") + model.cluster = "SOME_CLUSTER" + model.model = True + return model + + @patch("barman.config.output") + def test_apply_model_cluster_mismatch( + self, mock_output, server_config, model_config + ): + """Test :meth:`ServerConfig.apply_model`. + + Ensure it logs an error message and does nothing else when the cluster + config doesn't match between the model and the server. + """ + model_config.cluster = "SOME_OTHER_CLUSTER" + + mock = mock_open() + + with patch("builtins.open", mock): + server_config.apply_model(model_config) + + expected = ( + "Model '%s' has 'cluster=%s', which is not compatible with " + "'cluster=%s' from server '%s'" + % ( + model_config.name, + model_config.cluster, + server_config.cluster, + server_config.name, + ) + ) + mock_output.error.assert_called_once_with(expected) + mock.assert_not_called() + + @pytest.mark.parametrize("from_cli", [False, True]) + @patch("barman.config.output") + def test_apply_model_already_active( + self, mock_output, from_cli, server_config, model_config + ): + """Test :meth:`ServerConfig.apply_model`. + + Ensure it only logs a message and does nothing else if the given model + is already active. + """ + server_config.active_model = model_config + + mock = mock_open() + + with patch("builtins.open", mock): + server_config.apply_model(model_config, from_cli) + + expected = "Model '%s' is already active for server '%s', " "skipping..." % ( + "SOME_MODEL", + server_config.name, + ) + + if from_cli: + mock_output.debug.assert_not_called() + mock_output.info.assert_called_once_with(expected) + else: + mock_output.debug.assert_called_once_with(expected) + mock_output.info.assert_not_called() + + mock.assert_not_called() + + @pytest.fixture + def mock_model(self): + mock = MagicMock( + conninfo="VALUE_1", streaming_conninfo="VALUE_2", cluster="SOME_CLUSTER" + ) + mock.name = "SOME_MODEL" + mock.get_override_options.return_value = [ + ("conninfo", "VALUE_1"), + ("streaming_conninfo", "VALUE_2"), + ] + return mock + + @pytest.mark.parametrize("from_cli", [False, True]) + @patch("barman.config.output") + def test_apply_model_ok(self, mock_output, from_cli, server_config, mock_model): + """Test :meth:`ServerConfig.apply_model`. + + Ensure the new options are applied, and that attributes and file are + set/written as expected. + """ + mock = mock_open() + server_config.conninfo = "VALUE_1" + + with patch("builtins.open", mock): + server_config.apply_model(mock_model, from_cli) + + mock_model.get_override_options.assert_called_once_with() + + calls = [ + call(f"Applying model '{mock_model.name}' to server 'main'"), + call( + "Changing value of option 'streaming_conninfo' for server " + f"'{server_config.name}' from 'host=pg01.nowhere user=postgres port=5432' " + f"to '{mock_model.streaming_conninfo}' through the model " + f"'{mock_model.name}'" + ), + ] + + if from_cli: + mock_output.debug.assert_not_called() + mock_output.info.assert_has_calls(calls) + mock.assert_called_once_with( + "/some/barman/home/main/.active-model.auto", "w" + ) + handle = mock() + handle.write.assert_called_once_with("SOME_MODEL") + else: + mock_output.debug.assert_has_calls(calls) + mock_output.info.assert_not_called() + mock.assert_not_called() + + @pytest.mark.parametrize("file_exists", [False, True]) + @pytest.mark.parametrize("active_model", [None, mock_model]) + @patch("barman.config.output") + def test_reset_model(self, mock_output, file_exists, active_model, server_config): + """Test :meth:`ServerConfig.reset_model`. + + Ensure the active file is removed, if it exists, and that the control + attribute is set to ``None``. + """ + server_config.active_model = active_model + + with patch("os.path.isfile") as mock_is_file, patch("os.remove") as mock_remove: + mock_is_file.return_value = file_exists + + server_config.reset_model() + + mock_output.info.assert_called_once_with( + "Resetting the active model for the server %s" % (server_config.name) + ) + mock_is_file.assert_called_once_with( + "/some/barman/home/main/.active-model.auto" + ) + + if file_exists: + mock_remove.assert_called_once_with( + "/some/barman/home/main/.active-model.auto" + ) + else: + mock_remove.assert_not_called() + + +class TestModelConfig: + """Test :class:`ModelConfig`.""" + + @pytest.fixture + def model_config(self): + mock_config = MagicMock() + mock_config.get.return_value = None + mock_config.get_config_source.return_value = "SOME_SOURCE" + return ModelConfig(mock_config, "SOME_MODEL") + + @pytest.mark.parametrize("model", [None, "SOME_MODEL"]) + @pytest.mark.parametrize("cluster", [None, "SOME_CLUSTER"]) + @pytest.mark.parametrize("conninfo", [None, "SOME_CONNINFO"]) + @pytest.mark.parametrize("primary_conninfo", [None, "SOME_PRIMARY_CONNINFO"]) + @pytest.mark.parametrize("streaming_conninfo", [None, "SOME_STREAMING_CONNINFO"]) + def test_get_override_options( + self, + model, + cluster, + conninfo, + primary_conninfo, + streaming_conninfo, + model_config, + ): + """Test :meth:`ModelConfig.get_override_options`. + + Ensure the expected values are yielded by the method depending on the + attributes values. + """ + model_config.model = model + model_config.cluster = cluster + model_config.conninfo = conninfo + model_config.primary_conninfo = primary_conninfo + model_config.streaming_conninfo = streaming_conninfo + + expected = [] + + if conninfo is not None: + expected.append(("conninfo", conninfo)) + + if primary_conninfo is not None: + expected.append(("primary_conninfo", primary_conninfo)) + + if streaming_conninfo is not None: + expected.append(("streaming_conninfo", streaming_conninfo)) + + assert sorted(list(model_config.get_override_options())) == sorted(expected) + + def test_to_json(self, model_config): + """Test :meth:`ModelConfig.to_json`. + + Ensure it returns the expected result when we don't care about the + config source. + """ + model_config.model = True + model_config.cluster = "SOME_CLUSTER" + model_config.conninfo = "SOME_CONNINFO" + model_config.primary_conninfo = "SOME_PRIMARY_CONNINFO" + model_config.streaming_conninfo = "SOME_STREAMING_CONNINFO" + + expected = { + "active": None, + "archiver": None, + "archiver_batch_size": None, + "autogenerate_manifest": None, + "aws_profile": None, + "aws_region": None, + "azure_credential": None, + "azure_resource_group": None, + "azure_subscription_id": None, + "backup_compression": None, + "backup_compression_format": None, + "backup_compression_level": None, + "backup_compression_location": None, + "backup_compression_workers": None, + "backup_method": None, + "backup_options": None, + "bandwidth_limit": None, + "basebackup_retry_sleep": None, + "basebackup_retry_times": None, + "check_timeout": None, + "cluster": "SOME_CLUSTER", + "compression": None, + "conninfo": "SOME_CONNINFO", + "create_slot": None, + "custom_compression_filter": None, + "custom_compression_magic": None, + "custom_decompression_filter": None, + "description": None, + "disabled": None, + "forward_config_path": None, + "gcp_project": None, + "gcp_zone": None, + "immediate_checkpoint": None, + "last_backup_maximum_age": None, + "last_backup_minimum_size": None, + "last_wal_maximum_age": None, + "max_incoming_wals_queue": None, + "minimum_redundancy": None, + "model": True, + "network_compression": None, + "parallel_jobs": None, + "parallel_jobs_start_batch_period": None, + "parallel_jobs_start_batch_size": None, + "path_prefix": None, + "primary_checkpoint_timeout": None, + "primary_conninfo": "SOME_PRIMARY_CONNINFO", + "primary_ssh_command": None, + "recovery_options": None, + "recovery_staging_path": None, + "retention_policy": None, + "retention_policy_mode": None, + "reuse_backup": None, + "slot_name": None, + "snapshot_disks": None, + "snapshot_gcp_project": None, + "snapshot_instance": None, + "snapshot_provider": None, + "snapshot_zone": None, + "ssh_command": None, + "streaming_archiver": None, + "streaming_archiver_batch_size": None, + "streaming_archiver_name": None, + "streaming_backup_name": None, + "streaming_conninfo": "SOME_STREAMING_CONNINFO", + "tablespace_bandwidth_limit": None, + "wal_retention_policy": None, + } + assert model_config.to_json() == expected + + model_config.config.get_config_source.assert_not_called() + + def test_to_json_with_config_source(self, model_config): + """Test :meth:`ModelConfig.to_json`. + + Ensure it returns the expected result when we do care about the config + source. + """ + model_config.model = True + model_config.cluster = "SOME_CLUSTER" + model_config.conninfo = "SOME_CONNINFO" + model_config.primary_conninfo = "SOME_PRIMARY_CONNINFO" + model_config.streaming_conninfo = "SOME_STREAMING_CONNINFO" + + expected = { + "active": {"source": "SOME_SOURCE", "value": None}, + "archiver": {"source": "SOME_SOURCE", "value": None}, + "archiver_batch_size": {"source": "SOME_SOURCE", "value": None}, + "autogenerate_manifest": {"source": "SOME_SOURCE", "value": None}, + "aws_profile": {"source": "SOME_SOURCE", "value": None}, + "aws_region": {"source": "SOME_SOURCE", "value": None}, + "azure_credential": {"source": "SOME_SOURCE", "value": None}, + "azure_resource_group": {"source": "SOME_SOURCE", "value": None}, + "azure_subscription_id": {"source": "SOME_SOURCE", "value": None}, + "backup_compression": {"source": "SOME_SOURCE", "value": None}, + "backup_compression_format": {"source": "SOME_SOURCE", "value": None}, + "backup_compression_level": {"source": "SOME_SOURCE", "value": None}, + "backup_compression_location": {"source": "SOME_SOURCE", "value": None}, + "backup_compression_workers": {"source": "SOME_SOURCE", "value": None}, + "backup_method": {"source": "SOME_SOURCE", "value": None}, + "backup_options": {"source": "SOME_SOURCE", "value": None}, + "bandwidth_limit": {"source": "SOME_SOURCE", "value": None}, + "basebackup_retry_sleep": {"source": "SOME_SOURCE", "value": None}, + "basebackup_retry_times": {"source": "SOME_SOURCE", "value": None}, + "check_timeout": {"source": "SOME_SOURCE", "value": None}, + "cluster": {"source": "SOME_SOURCE", "value": "SOME_CLUSTER"}, + "compression": {"source": "SOME_SOURCE", "value": None}, + "conninfo": {"source": "SOME_SOURCE", "value": "SOME_CONNINFO"}, + "create_slot": {"source": "SOME_SOURCE", "value": None}, + "custom_compression_filter": {"source": "SOME_SOURCE", "value": None}, + "custom_compression_magic": {"source": "SOME_SOURCE", "value": None}, + "custom_decompression_filter": {"source": "SOME_SOURCE", "value": None}, + "description": {"source": "SOME_SOURCE", "value": None}, + "disabled": {"source": "SOME_SOURCE", "value": None}, + "forward_config_path": {"source": "SOME_SOURCE", "value": None}, + "gcp_project": {"source": "SOME_SOURCE", "value": None}, + "gcp_zone": {"source": "SOME_SOURCE", "value": None}, + "immediate_checkpoint": {"source": "SOME_SOURCE", "value": None}, + "last_backup_maximum_age": {"source": "SOME_SOURCE", "value": None}, + "last_backup_minimum_size": {"source": "SOME_SOURCE", "value": None}, + "last_wal_maximum_age": {"source": "SOME_SOURCE", "value": None}, + "max_incoming_wals_queue": {"source": "SOME_SOURCE", "value": None}, + "minimum_redundancy": {"source": "SOME_SOURCE", "value": None}, + "model": {"source": "SOME_SOURCE", "value": True}, + "network_compression": {"source": "SOME_SOURCE", "value": None}, + "parallel_jobs": {"source": "SOME_SOURCE", "value": None}, + "parallel_jobs_start_batch_period": { + "source": "SOME_SOURCE", + "value": None, + }, + "parallel_jobs_start_batch_size": {"source": "SOME_SOURCE", "value": None}, + "path_prefix": {"source": "SOME_SOURCE", "value": None}, + "primary_checkpoint_timeout": {"source": "SOME_SOURCE", "value": None}, + "primary_conninfo": { + "source": "SOME_SOURCE", + "value": "SOME_PRIMARY_CONNINFO", + }, + "primary_ssh_command": {"source": "SOME_SOURCE", "value": None}, + "recovery_options": {"source": "SOME_SOURCE", "value": None}, + "recovery_staging_path": {"source": "SOME_SOURCE", "value": None}, + "retention_policy": {"source": "SOME_SOURCE", "value": None}, + "retention_policy_mode": {"source": "SOME_SOURCE", "value": None}, + "reuse_backup": {"source": "SOME_SOURCE", "value": None}, + "slot_name": {"source": "SOME_SOURCE", "value": None}, + "snapshot_disks": {"source": "SOME_SOURCE", "value": None}, + "snapshot_gcp_project": {"source": "SOME_SOURCE", "value": None}, + "snapshot_instance": {"source": "SOME_SOURCE", "value": None}, + "snapshot_provider": {"source": "SOME_SOURCE", "value": None}, + "snapshot_zone": {"source": "SOME_SOURCE", "value": None}, + "ssh_command": {"source": "SOME_SOURCE", "value": None}, + "streaming_archiver": {"source": "SOME_SOURCE", "value": None}, + "streaming_archiver_batch_size": {"source": "SOME_SOURCE", "value": None}, + "streaming_archiver_name": {"source": "SOME_SOURCE", "value": None}, + "streaming_backup_name": {"source": "SOME_SOURCE", "value": None}, + "streaming_conninfo": { + "source": "SOME_SOURCE", + "value": "SOME_STREAMING_CONNINFO", + }, + "tablespace_bandwidth_limit": {"source": "SOME_SOURCE", "value": None}, + "wal_retention_policy": {"source": "SOME_SOURCE", "value": None}, + } + assert model_config.to_json(True) == expected + + assert model_config.config.get_config_source.call_count == len(expected) + model_config.config.get_config_source.assert_has_calls( + [call("SOME_MODEL", key) for key in expected], + any_order=True, + ) + # noinspection PyMethodMayBeStatic class TestCsvParsing(object): @@ -1212,3 +1789,109 @@ def test_invalid_option_output(self, out_mock): "test_server_option", "main", ) + + +class TestBaseConfig: + """Test :class:`BaseConfig` functionalities.""" + + def test_invoke_parser_no_new_value(self): + """Test :meth:`BaseConfig.invoke_parser`. + + Ensure old_value is returned when value is ``None``. + """ + bc = BaseConfig() + old_value = Mock() + + result = bc.invoke_parser("SOME_KEY", "SOME_SOURCE", old_value, None) + assert result == old_value + + def test_invoke_parser_csv_option_parser_ok(self): + """Test :meth:`BaseConfig.invoke_parser`. + + Ensure :meth:`CsvOption.parse` is called as expected when the parser is + an instance of the class. + """ + bc = BaseConfig() + + with patch.dict(bc.PARSERS, {"SOME_KEY": CsvOption}), patch.object( + CsvOption, "parse" + ) as mock: + result = bc.invoke_parser( + "SOME_KEY", "SOME_SOURCE", "SOME_VALUE", "SOME_NEW_VALUE" + ) + assert isinstance(result, CsvOption) + + mock.assert_called_once_with("SOME_NEW_VALUE", "SOME_KEY", "SOME_SOURCE") + + @patch("barman.config.output") + def test_invoke_parser_csv_option_parser_exception(self, mock_output): + """Test :meth:`BaseConfig.invoke_parser`. + + When using a :class:`CsvOption`, ensure a warning is logged if an + exception is faced by the parser, in which case the old value is + returned. + """ + bc = BaseConfig() + + with patch.dict(bc.PARSERS, {"SOME_KEY": CsvOption}), patch.object( + CsvOption, "parse" + ) as mock: + mock.side_effect = ValueError("SOME_ERROR") + + result = bc.invoke_parser( + "SOME_KEY", "SOME_SOURCE", "SOME_VALUE", "SOME_NEW_VALUE" + ) + assert result == "SOME_VALUE" + + mock.assert_called_once_with("SOME_NEW_VALUE", "SOME_KEY", "SOME_SOURCE") + mock_output.warning.assert_called_once_with( + "Ignoring invalid configuration value '%s' for key %s in %s: %s", + "SOME_NEW_VALUE", + "SOME_KEY", + "SOME_SOURCE", + mock.side_effect, + ) + + def test_invoke_parser_func_parser_ok(self): + """Test :meth:`BaseConfig.invoke_parser`. + + Ensure a parser function is called as expected and returns the expected + result when invoking the parser. + """ + bc = BaseConfig() + mock_parser = MagicMock() + + with patch.dict(bc.PARSERS, {"SOME_KEY": mock_parser}): + result = bc.invoke_parser( + "SOME_KEY", "SOME_SOURCE", "SOME_VALUE", "SOME_NEW_VALUE" + ) + assert result == mock_parser.return_value + + mock_parser.assert_called_once_with("SOME_NEW_VALUE") + + @patch("barman.config.output") + def test_invoke_parser_func_parser_exception(self, mock_output): + """Test :meth:`BaseConfig.invoke_parser`. + + When using a parse function, ensure a warning is logged if an exception + is faced by the parser, in which case the old value is returned. + """ + bc = BaseConfig() + mock_parser = MagicMock() + + with patch.dict(bc.PARSERS, {"SOME_KEY": mock_parser}): + mock_parser.side_effect = ValueError("SOME_ERROR") + + result = bc.invoke_parser( + "SOME_KEY", "SOME_SOURCE", "SOME_VALUE", "SOME_NEW_VALUE" + ) + assert result == "SOME_VALUE" + + mock_parser.assert_called_once_with("SOME_NEW_VALUE") + mock_output.warning.assert_called_once_with( + "Ignoring invalid configuration value '%s' for key %s in %s: %s", + "SOME_NEW_VALUE", + "SOME_KEY", + "SOME_SOURCE", + mock_parser.side_effect, + ) diff --git a/tests/test_diagnose.py b/tests/test_diagnose.py index d6d61fda0..40f4510b5 100644 --- a/tests/test_diagnose.py +++ b/tests/test_diagnose.py @@ -53,6 +53,20 @@ def setup_method(self, method): }, ) + self.test_config_with_models = build_config_from_dicts( + global_conf=None, + main_conf={ + "backup_directory": "/some/barman/home/main", + "archiver": "on", + }, + with_model=True, + model_conf={ + "model": "true", + "cluster": "main", + "conninfo": "SOME_CONNINFO", + }, + ) + @patch("barman.cli.output.close_and_exit") @patch("barman.diagnose.output.info") def test_diagnose_json(self, info_mock_output, close_exit_mock, monkeypatch): @@ -106,3 +120,26 @@ def test_diagnose_rerun(self, info_mock_output, close_exit_mock, monkeypatch): # Assert that the JSON output syntax is correct json.loads(json_output2) + + @patch("barman.cli.output.close_and_exit") + @patch("barman.diagnose.output.info") + def test_diagnose_json_with_models( + self, info_mock_output, close_exit_mock, monkeypatch + ): + monkeypatch.setattr(barman, "__config__", self.test_config_with_models) + mock_args = Mock(show_config_source=False) + diagnose(mock_args) + info_mock_output.assert_called_once() + json_output = info_mock_output.call_args[0][0] + + # Assert that the JSON output syntax is correct + json.loads(json_output) + + mock_args = Mock(show_config_source=True) + info_mock_output.reset_mock() + diagnose(mock_args) + info_mock_output.assert_called_once() + json_output = info_mock_output.call_args[0][0] + + # Assert that the JSON output syntax is correct + json.loads(json_output) diff --git a/tests/testing_helpers.py b/tests/testing_helpers.py index 851c265db..ade949740 100644 --- a/tests/testing_helpers.py +++ b/tests/testing_helpers.py @@ -162,7 +162,12 @@ def mock_backup_ext_info( def build_config_from_dicts( - global_conf=None, main_conf=None, test_conf=None, config_name=None + global_conf=None, + main_conf=None, + test_conf=None, + config_name=None, + with_model=False, + model_conf=None, ): """ Utility method, generate a barman.config.Config object @@ -175,6 +180,9 @@ def build_config_from_dicts( :param dict[str,str|None]|None main_conf: using this dictionary it is possible to override/add new values to the [main] section :return barman.config.Config: a barman configuration object + :param bool with_model: if we should include a ``main:model`` model section + :param dict[str,str|None]|None model_conf: using this dictionary + it is possible to override/add new values to the [main:model] section """ # base barman section base_barman = { @@ -195,6 +203,11 @@ def build_config_from_dicts( "ssh_command": 'ssh -c "arcfour" -p 22 postgres@pg02.nowhere', "conninfo": "host=pg02.nowhere user=postgres port=5433", } + # main:model section + base_main_model = { + "cluster": "main", + "model": "true", + } # update map values of the two sections if global_conf is not None: base_barman.update(global_conf) @@ -202,6 +215,8 @@ def build_config_from_dicts( base_main.update(main_conf) if test_conf is not None: base_test.update(test_conf) + if model_conf is not None: + base_main_model.update(model_conf) # writing the StringIO obj with the barman and main sections config_file = StringIO() @@ -217,6 +232,11 @@ def build_config_from_dicts( for key in base_test.keys(): config_file.write("%s = %s\n" % (key, base_main[key])) + if with_model: + config_file.write("[main:model]\n") + for key in base_main_model.keys(): + config_file.write("%s = %s\n" % (key, base_main_model[key])) + config_file.seek(0) config = Config(config_file) config.config_file = config_name or "build_config_from_dicts" @@ -236,7 +256,9 @@ def build_config_dictionary(config_keys=None): """ # Basic dictionary base_config = { + "_active_model_file": "/some/barman/home/main/.active-model.auto", "active": True, + "active_model": None, "archiver": True, "archiver_batch_size": 0, "autogenerate_manifest": False, @@ -246,6 +268,7 @@ def build_config_dictionary(config_keys=None): "azure_resource_group": None, "azure_subscription_id": None, "config": None, + "cluster": "main", "backup_compression": None, "backup_compression_format": None, "backup_compression_level": None,