diff --git a/pg_backup_api/pg_backup_api/__main__.py b/pg_backup_api/pg_backup_api/__main__.py index 8a3a38c..b455df3 100644 --- a/pg_backup_api/pg_backup_api/__main__.py +++ b/pg_backup_api/pg_backup_api/__main__.py @@ -20,7 +20,8 @@ import argparse import sys -from pg_backup_api.run import serve, status, recovery_operation +from pg_backup_api.run import (serve, status, recovery_operation, + config_switch_operation) def main() -> None: @@ -68,6 +69,19 @@ def main() -> None: help="ID of the operation in the 'pg-backup-api'.") p_ops.set_defaults(func=recovery_operation) + p_ops = subparsers.add_parser( + "config-switch", + description="Perform a 'barman config switch' through the " + "'pg-backup-api'. Can only be run if a config switch " + "operation has been previously registered." + ) + p_ops.add_argument("--server-name", required=True, + help="Name of the Barman server which config should be " + "switched.") + p_ops.add_argument("--operation-id", required=True, + help="ID of the operation in the 'pg-backup-api'.") + p_ops.set_defaults(func=config_switch_operation) + args = p.parse_args() if hasattr(args, "func") is False: p.print_help() diff --git a/pg_backup_api/pg_backup_api/logic/utility_controller.py b/pg_backup_api/pg_backup_api/logic/utility_controller.py index dff02a1..afa27b3 100644 --- a/pg_backup_api/pg_backup_api/logic/utility_controller.py +++ b/pg_backup_api/pg_backup_api/logic/utility_controller.py @@ -36,6 +36,7 @@ OperationType, DEFAULT_OP_TYPE, RecoveryOperation, + ConfigSwitchOperation, MalformedContent) if TYPE_CHECKING: # pragma: no cover @@ -155,7 +156,7 @@ def servers_operations_post(server_name: str, :param request: the flask request that has been received by the routing function. - Should contain a JSON body with a key ``type``, which identified the + Should contain a JSON body with a key ``type``, which identifies the type of the operation. The rest of the content depends on the type of operation being requested: @@ -167,6 +168,11 @@ def servers_operations_post(server_name: str, * ``remote_ssh_command``: SSH command to connect to the target machine. + * ``config_switch``: + + * ``model_name``: the name of the model to be applied; or + * ``reset``: if you want to unapply a currently active model. + :return: if *server_name* and the JSON body informed through the ``POST`` request are valid, return a JSON response containing a key ``operation_id`` with the ID of the operation that has been created. @@ -191,6 +197,7 @@ def servers_operations_post(server_name: str, abort(404, description=msg_404) operation = None + cmd = None op_type = OperationType(request_body.get("type", DEFAULT_OP_TYPE.value)) if op_type == OperationType.RECOVERY: @@ -207,21 +214,23 @@ def servers_operations_post(server_name: str, abort(404, description=msg_404) operation = RecoveryOperation(server_name) - - try: - operation.write_job_file(request_body) - except MalformedContent: - msg_400 = "Make sure all options/arguments are met and try again" - abort(400, description=msg_400) - - cmd = ( - f"pg-backup-api recovery --server-name {server_name} " - f"--operation-id {operation.id}" - ) - subprocess.Popen(cmd.split()) + cmd = f"pg-backup-api recovery --server-name {server_name}" + elif op_type == OperationType.CONFIG_SWITCH: + operation = ConfigSwitchOperation(server_name) + cmd = f"pg-backup-api config-switch --server-name {server_name}" if TYPE_CHECKING: # pragma: no cover assert isinstance(operation, Operation) + assert isinstance(cmd, str) + + try: + operation.write_job_file(request_body) + except MalformedContent: + msg_400 = "Make sure all options/arguments are met and try again" + abort(400, description=msg_400) + + cmd += f" --operation-id {operation.id}" + subprocess.Popen(cmd.split()) return {"operation_id": operation.id} diff --git a/pg_backup_api/pg_backup_api/run.py b/pg_backup_api/pg_backup_api/run.py index bc540f3..ee88b8b 100644 --- a/pg_backup_api/pg_backup_api/run.py +++ b/pg_backup_api/pg_backup_api/run.py @@ -29,10 +29,12 @@ from barman import output from pg_backup_api.utils import create_app, load_barman_config -from pg_backup_api.server_operation import RecoveryOperation +from pg_backup_api.server_operation import (RecoveryOperation, + ConfigSwitchOperation) if TYPE_CHECKING: # pragma: no cover + from pg_backup_api.server_operation import Operation import argparse app = create_app() @@ -82,29 +84,27 @@ def status(args: 'argparse.Namespace') -> Tuple[str, bool]: return (message, True if message == "OK" else False) -def recovery_operation(args: 'argparse.Namespace') -> Tuple[None, bool]: +def _run_operation(operation: 'Operation') -> Tuple[None, bool]: """ - Perform a ``barman recover`` through the pg-backup-api. + Perform an operation through the pg-backup-api. .. note:: - Can only be run if a recover operation has been previously registered. + Can only be run if an operation has been previously registered. - In the end of execution creates an output file through - :meth:`pg_backup_api.server_operation.RecoveryOperation.write_output_file` - with the following content, to indicate the operation has finished: + In the end of execution creates an output file through *operation*'s + ``write_output_file`` method with the following content, to indicate the + operation has finished: * ``success``: if the operation succeeded or not; * ``end_time``: timestamp when the operation finished; * ``output``: ``stdout``/``stderr`` of the operation. - :param args: command-line arguments for ``pg-backup-api recovery`` command. - Contains the name of the Barman server related to the operation. + :param operation: a subclass of :class:`Operation` which should be run. :return: a tuple consisting of two items: - * ``None`` -- output of :meth:`RecoveryOperation.write_output_file`; - * ``True`` if ``barman recover`` was successful, ``False`` otherwise. + * ``None`` -- output of *operation*'s ``write_output_file`` method; + * ``True`` operation executed successfully, ``False`` otherwise. """ - operation = RecoveryOperation(args.server_name, args.operation_id) output, retcode = operation.run() success = not retcode end_time = operation.time_event_now() @@ -115,3 +115,41 @@ def recovery_operation(args: 'argparse.Namespace') -> Tuple[None, bool]: content["output"] = output return (operation.write_output_file(content), success) + + +def recovery_operation(args: 'argparse.Namespace') -> Tuple[None, bool]: + """ + Perform a ``barman recover`` through the pg-backup-api. + + .. note:: + See :func:`_run_operation` for more details. + + :param args: command-line arguments for ``pg-backup-api recovery`` command. + Contains the name of the Barman server related to the operation. + :return: a tuple consisting of two items: + + * ``None`` -- output of :meth:`RecoveryOperation.write_output_file`; + * ``True`` if ``barman recover`` was successful, ``False`` otherwise. + """ + return _run_operation(RecoveryOperation(args.server_name, + args.operation_id)) + + +def config_switch_operation(args: 'argparse.Namespace') -> Tuple[None, bool]: + """ + Perform a ``barman config switch`` through the pg-backup-api. + + .. note:: + See :func:`_run_operation` for more details. + + :param args: command-line arguments for ``pg-backup-api config-switch`` + command. Contains the name of the Barman server related to the + operation. + :return: a tuple consisting of two items: + + * ``None`` -- output of :meth:`ConfigSwitchOperation.write_output_file` + * ``True`` if ``barman config-switch`` was successful, ``False`` + otherwise. + """ + return _run_operation(ConfigSwitchOperation(args.server_name, + args.operation_id)) diff --git a/pg_backup_api/pg_backup_api/server_operation.py b/pg_backup_api/pg_backup_api/server_operation.py index 52cf028..443a077 100644 --- a/pg_backup_api/pg_backup_api/server_operation.py +++ b/pg_backup_api/pg_backup_api/server_operation.py @@ -19,8 +19,8 @@ """ Logic for performing operations through the pg-backup-api. -:var DEFAULT_OP_TYPE: default operation to be performed (``recovery``), if none -is specified. +:data DEFAULT_OP_TYPE: default operation to be performed (``recovery``), if +none is specified. """ from abc import abstractmethod import argparse @@ -48,6 +48,7 @@ class OperationType(Enum): """Describe operations that can be performed through pg-backup-api.""" RECOVERY = "recovery" + CONFIG_SWITCH = "config_switch" DEFAULT_OP_TYPE = OperationType.RECOVERY @@ -74,9 +75,9 @@ class OperationServer: :ivar name: name of the Barman server. :ivar config: Barman configuration of the Barman server. - :ivar jobs_basedir: directory where to save files of recovery operations - that have been created for this Barman server. - :ivar output_basedir: directory where to save files with output of recovery + :ivar jobs_basedir: directory where to save files of operations that have + been created for this Barman server. + :ivar output_basedir: directory where to save files with output of operations that have been finished for this Barman server -- both for failed and successful executions. """ @@ -641,6 +642,121 @@ def _run_logic(self) -> \ return self._run_subprocess(cmd) +class ConfigSwitchOperation(Operation): + """ + Contain information and logic to process a config switch operation. + + :cvar POSSIBLE_ARGUMENTS: possible arguments when creating a config switch + operation. + :cvar TYPE: enum type of this operation. + """ + + POSSIBLE_ARGUMENTS = ("model_name", "reset",) + TYPE = OperationType.CONFIG_SWITCH + + @classmethod + def _validate_job_content(cls, content: Dict[str, Any]) -> None: + """ + Validate the content of the job file before creating it. + + :param content: Python dictionary representing the JSON content of the + job file. + + :raises: + :exc:`MalformedContent`: if the set of options in *content* is not + compliant with the supported options and how to use them. + """ + # One of :attr:`POSSIBLE_ARGUMENTS` must be specified, but not both + if not any(arg in content for arg in cls.POSSIBLE_ARGUMENTS): + msg = ( + "One among the following arguments must be specified: " + f"{', '.join(sorted(cls.POSSIBLE_ARGUMENTS))}" + ) + raise MalformedContent(msg) + elif all(arg in content for arg in cls.POSSIBLE_ARGUMENTS): + msg = ( + "Only one among the following arguments should be specified: " + f"{', '.join(sorted(cls.POSSIBLE_ARGUMENTS))}" + ) + raise MalformedContent(msg) + + for key, type_ in [ + ("model_name", str,), + ("reset", bool,), + ]: + if key in content and not isinstance(content[key], type_): + msg = ( + f"`{key}` is expected to be a `{type_}`, but a " + f"`{type(content[key])}` was found instead: " + f"`{content[key]}`." + ) + raise MalformedContent(msg) + + if "reset" in content and content["reset"] is False: + msg = "Value of `reset` key, if present, can only be `True`" + raise MalformedContent(msg) + + def write_job_file(self, content: Dict[str, Any]) -> None: + """ + Write the job file with *content*. + + .. note:: + See :meth:`Operation.write_job_file` for more details. + + :param content: Python dictionary representing the JSON content of the + job file. Besides what is contained in *content*, this method adds + the following keys: + + * ``operation_type``: ``config_switch``; + * ``start_time``: current timestamp. + """ + content["operation_type"] = self.TYPE.value + content["start_time"] = self.time_event_now() + self._validate_job_content(content) + super().write_job_file(content) + + def _get_args(self) -> List[str]: + """ + Get arguments for running ``barman config-switch`` command. + + :return: list of arguments for ``barman config-switch`` command. + """ + job_content = self.read_job_file() + + model_name = job_content.get("model_name") + reset = job_content.get("reset") + + if TYPE_CHECKING: # pragma: no cover + assert model_name is None or isinstance(model_name, str) + assert reset is None or isinstance(reset, bool) + + ret = [self.server.name] + + if model_name: + ret.append(model_name) + elif reset: + ret.append("--reset") + + return ret + + def _run_logic(self) -> \ + Tuple[Union[str, bytearray, memoryview], Union[int, Any]]: + """ + Logic to be ran when executing the config switch operation. + + Run ``barman config-switch`` command with the configured arguments. + + Will be called when running :meth:`Operation.run`. + + :return: a tuple consisting of: + + * ``stdout``/``stderr`` of ``barman config-switch``; + * exit code of ``barman config-switch``. + """ + cmd = ["barman", "config-switch"] + self._get_args() + return self._run_subprocess(cmd) + + def main(callback: Callable[..., Any], *args: Tuple[Any, ...]) -> int: """ Execute *callback* with *args* and log its output as an ``INFO`` message. @@ -674,12 +790,12 @@ def main(callback: Callable[..., Any], *args: Tuple[Any, ...]) -> int: ) parser.add_argument( "--server-name", required=True, - help="Name of the Barman server related to the recovery " - "operation.", + help="Name of the Barman server related to the operation.", ) parser.add_argument( "--operation-type", choices=[op_type.value for op_type in OperationType], + default=OperationType.RECOVERY.value, help="Type of the operation. Optional for 'list-operations' command. " "Defaults to 'recovery' for 'get-operation' command." ) @@ -691,8 +807,8 @@ def main(callback: Callable[..., Any], *args: Tuple[Any, ...]) -> int: parser.add_argument( "command", choices=["list-operations", "get-operation"], - help="What we should do -- list recovery operations, or get info " - "about a specific operation.", + help="What we should do -- list operations, or get info about a " + "specific operation.", ) args = parser.parse_args() diff --git a/pg_backup_api/pg_backup_api/tests/test_main.py b/pg_backup_api/pg_backup_api/tests/test_main.py index 5d548ef..7394157 100644 --- a/pg_backup_api/pg_backup_api/tests/test_main.py +++ b/pg_backup_api/pg_backup_api/tests/test_main.py @@ -28,10 +28,10 @@ _HELP_OUTPUT = { "pg-backup-api --help": dedent("""\ - usage: pg-backup-api [-h] {serve,status,recovery} ... + usage: pg-backup-api [-h] {serve,status,recovery,config-switch} ... positional arguments: - {serve,status,recovery} + {serve,status,recovery,config-switch} optional arguments: -h, --help show this help message and exit @@ -73,6 +73,22 @@ Name of the Barman server to be recovered. --operation-id OPERATION_ID ID of the operation in the 'pg-backup-api'. +\ + """), # noqa: E501 + "pg-backup-api config-switch --help": dedent("""\ + usage: pg-backup-api config-switch [-h] --server-name SERVER_NAME + --operation-id OPERATION_ID + + Perform a 'barman config switch' through the 'pg-backup-api'. Can only be run + if a config switch operation has been previously registered. + + optional arguments: + -h, --help show this help message and exit + --server-name SERVER_NAME + Name of the Barman server which config should be + switched. + --operation-id OPERATION_ID + ID of the operation in the 'pg-backup-api'. \ """), # noqa: E501 } @@ -81,6 +97,7 @@ "pg-backup-api serve": "serve", "pg-backup-api status": "status", "pg-backup-api recovery --server-name SOME_SERVER --operation-id SOME_OP_ID": "recovery_operation", # noqa: E501 + "pg-backup-api config-switch --server-name SOME_SERVER --operation-id SOME_OP_ID": "config_switch_operation", # noqa: E501 } diff --git a/pg_backup_api/pg_backup_api/tests/test_run.py b/pg_backup_api/pg_backup_api/tests/test_run.py index 255cc35..f53b0cd 100644 --- a/pg_backup_api/pg_backup_api/tests/test_run.py +++ b/pg_backup_api/pg_backup_api/tests/test_run.py @@ -24,7 +24,8 @@ import pytest -from pg_backup_api.run import serve, status, recovery_operation +from pg_backup_api.run import (serve, status, recovery_operation, + config_switch_operation) @pytest.mark.parametrize("port", [7480, 7481]) @@ -121,3 +122,41 @@ def test_recovery_operation(mock_rec_op, server_name, operation_id, rc): ]) mock_write_output.assert_called_once_with(mock_read_job.return_value) + + +@pytest.mark.parametrize("server_name", ["SERVER_1", "SERVER_2"]) +@pytest.mark.parametrize("operation_id", ["OPERATION_1", "OPERATION_2"]) +@pytest.mark.parametrize("rc", [0, 1]) +@patch("pg_backup_api.run.ConfigSwitchOperation") +def test_config_switch_operation(mock_cs_op, server_name, operation_id, rc): + """Test :func:`config_switch_operation`. + + Ensure the operation is created and executed, and that the expected values + are returned depending on the return code. + """ + args = argparse.Namespace(server_name=server_name, + operation_id=operation_id) + + mock_cs_op.return_value.run.return_value = ("SOME_OUTPUT", rc) + mock_write_output = mock_cs_op.return_value.write_output_file + mock_time_event = mock_cs_op.return_value.time_event_now + mock_read_job = mock_cs_op.return_value.read_job_file + + assert config_switch_operation(args) == (mock_write_output.return_value, + rc == 0) + + mock_cs_op.assert_called_once_with(server_name, operation_id) + mock_cs_op.return_value.run.assert_called_once_with() + mock_time_event.assert_called_once_with() + mock_read_job.assert_called_once_with() + + # Make sure the expected content was added to `read_job_file` output before + # writing it to the output file. + assert len(mock_read_job.return_value.__setitem__.mock_calls) == 3 + mock_read_job.return_value.__setitem__.assert_has_calls([ + call('success', rc == 0), + call('end_time', mock_time_event.return_value), + call('output', "SOME_OUTPUT"), + ]) + + mock_write_output.assert_called_once_with(mock_read_job.return_value) diff --git a/pg_backup_api/pg_backup_api/tests/test_server_operation.py b/pg_backup_api/pg_backup_api/tests/test_server_operation.py index 6d55003..00a0e86 100644 --- a/pg_backup_api/pg_backup_api/tests/test_server_operation.py +++ b/pg_backup_api/pg_backup_api/tests/test_server_operation.py @@ -30,6 +30,7 @@ OperationNotExists, Operation, RecoveryOperation, + ConfigSwitchOperation, ) @@ -377,7 +378,7 @@ def test_read_job_file_ok(self, op_server): def test_read_output_file_file_does_not_exist(self, op_server): """Test :meth:`OperationServer._read_output_file`. - Ensure and exception is raised if the file does not exist. + Ensure an exception is raised if the file does not exist. """ id = "SOME_OP_ID" @@ -784,7 +785,7 @@ def test__validate_job_content_content_missing_keys(self, content, operation): """Test :meth:`RecoveryOperation._validate_job_content`. - Ensure and exception is raised if the content is missing keys. + Ensure an exception is raised if the content is missing keys. """ with pytest.raises(MalformedContent) as exc: operation._validate_job_content(content) @@ -869,3 +870,175 @@ def test__run_logic(self, mock_get_args, mock_run_subprocess, operation): mock_run_subprocess.assert_called_once_with( ["barman", "recover"] + arguments, ) + + +@patch("pg_backup_api.server_operation.OperationServer", MagicMock()) +class TestConfigSwitchOperation: + """Run tests for :class:`ConfigSwitchOperation`.""" + + @pytest.fixture + @patch("pg_backup_api.server_operation.OperationServer", MagicMock()) + def operation(self): + """Create a :class:`ConfigSwitchOperation` instance for testing. + + :return: a new instance of :class:`ConfigSwitchOperation` for testing. + """ + return ConfigSwitchOperation(_BARMAN_SERVER) + + def test__validate_job_content_content_missing_both_keys(self, operation): + """Test :meth:`ConfigSwitchOperation._validate_job_content`. + + Ensure an exception is raised if the content is missing both + ``model_name`` and ``reset`` keys. + """ + with pytest.raises(MalformedContent) as exc: + operation._validate_job_content({}) + + assert str(exc.value) == ( + "One among the following arguments must be specified: " + "model_name, reset" + ) + + def test__validate_job_content_content_contains_both_keys(self, operation): + """Test :meth:`ConfigSwitchOperation._validate_job_content`. + + Ensure an exception is raised if the content has both ``model_name`` + and ``reset`` keys. + """ + with pytest.raises(MalformedContent) as exc: + operation._validate_job_content({ + "model_name": "SOME_MODEL", + "reset": True, + }) + + assert str(exc.value) == ( + "Only one among the following arguments should be specified: " + "model_name, reset" + ) + + @pytest.mark.parametrize("model_name", [1, 1.0, True, None]) + def test__validate_job_content_invalid_model_name_type(self, model_name, + operation): + """Test :meth:`ConfigSwitchOperation._validate_job_content`. + + Ensure an exception is raised if ``model_name`` has a value of an + invalid type. + """ + with pytest.raises(MalformedContent) as exc: + operation._validate_job_content({"model_name": model_name}) + + assert str(exc.value) == ( + f"`model_name` is expected to be a `{str}`, but a " + f"`{type(model_name)}` was found instead: `{model_name}`." + ) + + @pytest.mark.parametrize("reset", [1, 1.0, "true", None]) + def test__validate_job_content_invalid_reset_type(self, reset, operation): + """Test :meth:`ConfigSwitchOperation._validate_job_content`. + + Ensure an exception is raised if ``reset`` has a value of an + invalid type. + """ + with pytest.raises(MalformedContent) as exc: + operation._validate_job_content({"reset": reset}) + + assert str(exc.value) == ( + f"`reset` is expected to be a `{bool}`, but a " + f"`{type(reset)}` was found instead: `{reset}`." + ) + + def test__validate_job_content_invalid_reset_value(self, operation): + """Test :meth:`ConfigSwitchOperation._validate_job_content`. + + Ensure an exception is raised if ``reset`` has an invalid value. + """ + with pytest.raises(MalformedContent) as exc: + operation._validate_job_content({"reset": False}) + + assert str(exc.value) == ( + "Value of `reset` key, if present, can only be `True`" + ) + + def test__validate_job_content_apply_model_ok(self, operation): + """Test :meth:`ConfigSwitchOperation._validate_job_content`. + + Ensure execution is fine if only a valid ``model_name`` is given. + """ + operation._validate_job_content({"model_name": "SOME_MODEL"}) + + def test__validate_job_content_reset_model_ok(self, operation): + """Test :meth:`ConfigSwitchOperation._validate_job_content`. + + Ensure execution is fine if only a valid ``reset`` is given. + """ + operation._validate_job_content({"reset": True}) + + @patch("pg_backup_api.server_operation.Operation.time_event_now") + @patch("pg_backup_api.server_operation.Operation.write_job_file") + def test_write_job_file(self, mock_write_job_file, mock_time_event_now, + operation): + """Test :meth:`ConfigSwitchOperation.write_job_file`. + + Ensure the underlying methods are called as expected. + """ + content = { + "SOME": "CONTENT", + } + extended_content = { + "SOME": "CONTENT", + "operation_type": OperationType.CONFIG_SWITCH.value, + "start_time": "SOME_TIMESTAMP", + } + + with patch.object(operation, "_validate_job_content") as mock: + mock_time_event_now.return_value = "SOME_TIMESTAMP" + + operation.write_job_file(content) + + mock_time_event_now.assert_called_once() + mock.assert_called_once_with(extended_content) + mock_write_job_file.assert_called_once_with(extended_content) + + def test__get_args_apply_model(self, operation): + """Test :meth:`ConfigSwitchOperation._get_args`. + + Ensure it returns the correct arguments for ``barman config-switch`` + when ``model_name`` is given. + """ + with patch.object(operation, "read_job_file") as mock: + mock.return_value = {"model_name": "SOME_MODEL"} + + expected = [operation.server.name, "SOME_MODEL"] + assert operation._get_args() == expected + + def test__get_args_reset_model(self, operation): + """Test :meth:`ConfigSwitchOperation._get_args`. + + Ensure it returns the correct arguments for ``barman config-switch`` + when ``reset`` is given. + """ + with patch.object(operation, "read_job_file") as mock: + mock.return_value = {"reset": True} + + expected = [operation.server.name, "--reset"] + assert operation._get_args() == expected + + @patch("pg_backup_api.server_operation.Operation._run_subprocess") + @patch("pg_backup_api.server_operation.ConfigSwitchOperation._get_args") + def test__run_logic(self, mock_get_args, mock_run_subprocess, operation): + """Test :meth:`ConfigSwitchOperation._run_logic`. + + Ensure the underlying calls occur as expected. + """ + arguments = ["SOME", "ARGUMENTS"] + output = ("SOME OUTPUT", 0) + + mock_get_args.return_value = arguments + mock_run_subprocess.return_value = output + + assert operation._run_logic() == output + + mock_get_args.assert_called_once() + mock_run_subprocess.assert_called_once_with( + ["barman", "config-switch"] + arguments, + ) diff --git a/pg_backup_api/pg_backup_api/tests/test_utility_controller.py b/pg_backup_api/pg_backup_api/tests/test_utility_controller.py index ce35357..f691250 100644 --- a/pg_backup_api/pg_backup_api/tests/test_utility_controller.py +++ b/pg_backup_api/pg_backup_api/tests/test_utility_controller.py @@ -310,16 +310,14 @@ def test_server_operation_post_empty_json(self, mock_popen, mock_rec_op, @patch("pg_backup_api.logic.utility_controller.Server") @patch("pg_backup_api.logic.utility_controller.RecoveryOperation") @patch("subprocess.Popen") - def test_server_operation_post_server_does_not_exist(self, mock_popen, - mock_rec_op, - mock_server, - mock_parse_id, - mock_op_type, - mock_get_server, - client): + def test_server_operation_post_server_rec_op_does_not_exist( + self, mock_popen, mock_rec_op, mock_server, mock_parse_id, + mock_op_type, mock_get_server, client + ): """Test ``/servers//operations`` endpoint. - Ensure ``POST`` request returns ``404`` if Barman server doesn't exist. + Ensure ``POST`` request returns ``404`` if Barman server doesn't exist + when requesting a recovery operation. """ path = "/servers/SOME_SERVER_NAME/operations" @@ -352,14 +350,17 @@ def test_server_operation_post_server_does_not_exist(self, mock_popen, @patch("pg_backup_api.logic.utility_controller.Server") @patch("pg_backup_api.logic.utility_controller.RecoveryOperation") @patch("subprocess.Popen") - def test_server_operation_post_backup_id_missing(self, mock_popen, - mock_rec_op, mock_server, - mock_parse_id, - mock_op_type, - mock_get_server, client): + def test_server_operation_post_rec_op_backup_id_missing(self, mock_popen, + mock_rec_op, + mock_server, + mock_parse_id, + mock_op_type, + mock_get_server, + client): """Test ``/servers//operations`` endpoint. - Ensure ``POST`` request returns ``400`` if ``backup_id`` is missing. + Ensure ``POST`` request returns ``400`` if ``backup_id`` is missing + when requesting a recovery operation. """ path = "/servers/SOME_SERVER_NAME/operations" json_data = { @@ -387,16 +388,14 @@ def test_server_operation_post_backup_id_missing(self, mock_popen, @patch("pg_backup_api.logic.utility_controller.Server") @patch("pg_backup_api.logic.utility_controller.RecoveryOperation") @patch("subprocess.Popen") - def test_server_operation_post_backup_does_not_exist(self, mock_popen, - mock_rec_op, - mock_server, - mock_parse_id, - mock_op_type, - mock_get_server, - client): + def test_server_operation_post_rec_op_backup_does_not_exist( + self, mock_popen, mock_rec_op, mock_server, mock_parse_id, + mock_op_type, mock_get_server, client + ): """Test ``/servers//operations`` endpoint. - Ensure ``POST`` request returns ``404`` if backup does not exist. + Ensure ``POST`` request returns ``404`` if backup does not exist when + requesting a recovery operation. """ path = "/servers/SOME_SERVER_NAME/operations" json_data = { @@ -431,13 +430,17 @@ def test_server_operation_post_backup_does_not_exist(self, mock_popen, @patch("pg_backup_api.logic.utility_controller.Server") @patch("pg_backup_api.logic.utility_controller.RecoveryOperation") @patch("subprocess.Popen") - def test_server_operation_post_missing_options(self, mock_popen, - mock_rec_op, mock_server, - mock_parse_id, mock_op_type, - mock_get_server, client): + def test_server_operation_post_rec_op_missing_options(self, mock_popen, + mock_rec_op, + mock_server, + mock_parse_id, + mock_op_type, + mock_get_server, + client): """Test ``/servers//operations`` endpoint. - Ensure ``POST`` request returns ``400`` if any option is missing. + Ensure ``POST`` request returns ``400`` if any option is missing when + requesting a recovery operation. """ path = "/servers/SOME_SERVER_NAME/operations" json_data = { @@ -466,6 +469,43 @@ def test_server_operation_post_missing_options(self, mock_popen, expected = b"Make sure all options/arguments are met and try again" assert expected in response.data + @patch("pg_backup_api.logic.utility_controller.OperationServer", Mock()) + @patch("pg_backup_api.logic.utility_controller.get_server_by_name") + @patch("pg_backup_api.logic.utility_controller.OperationType") + @patch("pg_backup_api.logic.utility_controller.ConfigSwitchOperation") + @patch("subprocess.Popen") + def test_server_operation_post_cs_op_missing_options(self, mock_popen, + mock_cs_op, + mock_op_type, + mock_get_server, + client): + """Test ``/servers//operations`` endpoint. + + Ensure ``POST`` request returns ``400`` if any option is missing when + requesting a config switch operation. + """ + path = "/servers/SOME_SERVER_NAME/operations" + json_data = { + "type": "config_switch", + } + + mock_op_type.return_value = mock_op_type.CONFIG_SWITCH + mock_cs_op.return_value.id = "SOME_OP_ID" + mock_write_job = mock_cs_op.return_value.write_job_file + mock_write_job.side_effect = MalformedContent("SOME_ERROR") + + response = client.post(path, json=json_data) + + mock_get_server.assert_called_once_with("SOME_SERVER_NAME") + mock_op_type.assert_called_once_with("config_switch") + mock_cs_op.assert_called_once_with("SOME_SERVER_NAME") + mock_write_job.assert_called_once_with(json_data) + mock_popen.assert_not_called() + + assert response.status_code == 400 + expected = b"Make sure all options/arguments are met and try again" + assert expected in response.data + @patch("pg_backup_api.logic.utility_controller.OperationServer", Mock()) @patch("pg_backup_api.logic.utility_controller.get_server_by_name") @patch("pg_backup_api.logic.utility_controller.OperationType") @@ -473,13 +513,14 @@ def test_server_operation_post_missing_options(self, mock_popen, @patch("pg_backup_api.logic.utility_controller.Server") @patch("pg_backup_api.logic.utility_controller.RecoveryOperation") @patch("subprocess.Popen") - def test_server_operation_post_ok(self, mock_popen, mock_rec_op, - mock_server, mock_parse_id, mock_op_type, - mock_get_server, client): + def test_server_operation_post_rec_op_ok(self, mock_popen, mock_rec_op, + mock_server, mock_parse_id, + mock_op_type, mock_get_server, + client): """Test ``/servers//operations`` endpoint. - Ensure ``POST`` request returns ``202`` if everything is ok, and ensure - the subprocess is started. + Ensure ``POST`` request returns ``202`` if everything is ok when + requesting a recovery operation, and ensure the subprocess is started. """ path = "/servers/SOME_SERVER_NAME/operations" json_data = { @@ -510,6 +551,44 @@ def test_server_operation_post_ok(self, mock_popen, mock_rec_op, assert response.status_code == 202 assert response.data == b'{"operation_id":"SOME_OP_ID"}\n' + @patch("pg_backup_api.logic.utility_controller.OperationServer", Mock()) + @patch("pg_backup_api.logic.utility_controller.get_server_by_name") + @patch("pg_backup_api.logic.utility_controller.OperationType") + @patch("pg_backup_api.logic.utility_controller.ConfigSwitchOperation") + @patch("subprocess.Popen") + def test_server_operation_post_cs_op_ok(self, mock_popen, mock_cs_op, + mock_op_type, mock_get_server, + client): + """Test ``/servers//operations`` endpoint. + + Ensure ``POST`` request returns ``202`` if everything is ok when + requesting a config switch operation, and ensure the subprocess is + started. + """ + path = "/servers/SOME_SERVER_NAME/operations" + json_data = { + "type": "config_switch", + } + + mock_op_type.return_value = mock_op_type.CONFIG_SWITCH + mock_cs_op.return_value.id = "SOME_OP_ID" + + response = client.post(path, json=json_data) + + mock_write_job = mock_cs_op.return_value.write_job_file + mock_get_server.assert_called_once_with("SOME_SERVER_NAME") + mock_op_type.assert_called_once_with("config_switch") + mock_cs_op.assert_called_once_with("SOME_SERVER_NAME") + mock_write_job.assert_called_once_with(json_data) + mock_popen.assert_called_once_with(["pg-backup-api", "config-switch", + "--server-name", + "SOME_SERVER_NAME", + "--operation-id", + "SOME_OP_ID"]) + + assert response.status_code == 202 + assert response.data == b'{"operation_id":"SOME_OP_ID"}\n' + @patch("pg_backup_api.logic.utility_controller.OperationServer", Mock()) @patch("pg_backup_api.logic.utility_controller.get_server_by_name") @patch("pg_backup_api.logic.utility_controller.OperationType")