Skip to content

Commit

Permalink
Add a way of creating and executing "config switch" operations (#86)
Browse files Browse the repository at this point in the history
Through this PR we expose an operation in the REST API for performing `barman config-switch` command remotely. The following changes have been applied:

* Create a ``ConfigSwitchOperation`` class, which takes care of validating the arguments for a config switch operation, and also of running such operation;
* Modify `servers_operations_post` Flask end point so it is able to receive POST requests both for creating a recovery operation or a config switch operation;
* Create a new command ``pg-backup-api config-switch``, which is used by the API in order to run the config switch on Barman;
* Create a function ``config_switch_operation``, which is used by ``pg-backup-api config-switch`` command;
* Create a helper function ``_run_operation``, which is used both by ``recovery_operation`` and ``config_switch_operation`` functions. They both have a very similar logic, so that helper function executes the common code paths.

The new API endpoint is able to handle both an "apply model" or a "reset model" through `barman config-switch` command.

References: BAR-125.
  • Loading branch information
barthisrael authored Jan 8, 2024
1 parent 455f783 commit 896957b
Show file tree
Hide file tree
Showing 8 changed files with 557 additions and 72 deletions.
16 changes: 15 additions & 1 deletion pg_backup_api/pg_backup_api/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down
35 changes: 22 additions & 13 deletions pg_backup_api/pg_backup_api/logic/utility_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
OperationType,
DEFAULT_OP_TYPE,
RecoveryOperation,
ConfigSwitchOperation,
MalformedContent)

if TYPE_CHECKING: # pragma: no cover
Expand Down Expand Up @@ -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:
Expand All @@ -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.
Expand All @@ -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:
Expand All @@ -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}

Expand Down
62 changes: 50 additions & 12 deletions pg_backup_api/pg_backup_api/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand All @@ -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))
134 changes: 125 additions & 9 deletions pg_backup_api/pg_backup_api/server_operation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.
"""
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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."
)
Expand All @@ -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()
Expand Down
Loading

0 comments on commit 896957b

Please sign in to comment.