Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a way of creating and executing "config switch" operations #86

Merged
merged 4 commits into from
Jan 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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