Skip to content

Commit

Permalink
Add support for remote barman config-update execution (#89)
Browse files Browse the repository at this point in the history
So far we had only operations related with Barman servers in the API:

* `RecoveryOperation`: to perform a `barman recover` of a Barman server
* `ConfigSwitchOperation`: to perform a `barman config-switch` to a Barman
  server

However, we needed to add an operation to the API which is performed at the
Barman instance level (global), not to a specific Barman server: the
`ConfigUpdateOperation`.

With that in mind this PR changes the classes below so we will be able to
create Barman instance operations:

* `OperationServer`: turn `server_name` argument optional. When it is `None`,
  that is considered an instance operation. Thus, the `job` and `output` files
  will be written under the Barman home in that case instead of under a Barman
  server directory
* `Operation`: turn `server_name` argument optional, so it creates
  `OperationServer` accordingly

We also added a couple new endpoints to the API, so we can handle instance
operations:

* `/operations/<operation_id>`: used to get the status of an instance
  operation -- similar to what we have for server operations through
  `/servers/<server_name>/operations/<operation_id>`;
* `/operations`: used to get a list of or instance operations, or to create a
  new instance operation -- similar to what we have for server operations
  through `/servers/<server_name>/operations`.

The `BarmanConfigUpdate` operation has been created and uses the above API
endpoints.

Unit tests changed accordingly, so we check both server and instance
operations, including the introduced operation.

References: BAR-126.
  • Loading branch information
barthisrael authored Jan 18, 2024
1 parent 896957b commit 510a2d7
Show file tree
Hide file tree
Showing 8 changed files with 686 additions and 64 deletions.
13 changes: 12 additions & 1 deletion pg_backup_api/pg_backup_api/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
import sys

from pg_backup_api.run import (serve, status, recovery_operation,
config_switch_operation)
config_switch_operation,
config_update_operation)


def main() -> None:
Expand Down Expand Up @@ -82,6 +83,16 @@ def main() -> None:
help="ID of the operation in the 'pg-backup-api'.")
p_ops.set_defaults(func=config_switch_operation)

p_ops = subparsers.add_parser(
"config-update",
description="Perform a 'barman config-update' through the "
"'pg-backup-api'. Can only be run if a config-update "
"operation has been previously registered."
)
p_ops.add_argument("--operation-id", required=True,
help="ID of the operation in the 'pg-backup-api'.")
p_ops.set_defaults(func=config_update_operation)

args = p.parse_args()
if hasattr(args, "func") is False:
p.print_help()
Expand Down
152 changes: 139 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 @@ -19,7 +19,7 @@
"""Define the Flask endpoints of the pg-backup-api REST API server."""
import json
import subprocess
from typing import Any, Dict, Tuple, Union, TYPE_CHECKING
from typing import Any, Dict, Optional, Tuple, Union, TYPE_CHECKING

from flask import abort, jsonify, request

Expand All @@ -37,6 +37,7 @@
DEFAULT_OP_TYPE,
RecoveryOperation,
ConfigSwitchOperation,
ConfigUpdateOperation,
MalformedContent)

if TYPE_CHECKING: # pragma: no cover
Expand Down Expand Up @@ -112,16 +113,13 @@ def resource_not_found(error: Any) -> Tuple['Response', int]:
return jsonify(error=str(error)), 404


@app.route("/servers/<server_name>/operations/<operation_id>")
def servers_operation_id_get(server_name: str, operation_id: str) \
def _operation_id_get(server_name: Optional[str], operation_id: str) \
-> 'Response':
"""
``GET`` request to ``/servers/*server_name*/operations/*operation_id*``.
Get status of an operation with ID *operation_id*.
Get status of an operation with ID *operation_id* for Barman server named
*server_name*.
:param server_name: name of the Barman server related to the operation.
:param server_name: name of the Barman server related to the operation, if
it's a server operation, ``None`` if it's an instance operation.
:param operation_id: ID of the operation previously created through
pg-backup-api.
:return: if *server_name* and *operation_id* are valid, return a JSON
Expand All @@ -146,6 +144,37 @@ def servers_operation_id_get(server_name: str, operation_id: str) \
abort(404, description="Resource not found")


@app.route("/servers/<server_name>/operations/<operation_id>")
def servers_operation_id_get(server_name: str, operation_id: str) \
-> 'Response':
"""
``GET`` request to ``/servers/*server_name*/operations/*operation_id*``.
Get status of an operation with ID *operation_id* for Barman server named
*server_name*.
:param server_name: name of the Barman server related to the operation.
:param operation_id: ID of the operation previously created through
pg-backup-api.
:return: see :func:`_operation_id_get` for details.
"""
return _operation_id_get(server_name, operation_id)


@app.route("/operations/<operation_id>")
def instance_operation_id_get(operation_id: str) -> 'Response':
"""
``GET`` request to ``/operations/*operation_id*``.
Get status of an operation with ID *operation_id* for the Barman instance.
:param operation_id: ID of the operation previously created through
pg-backup-api.
:return: see :func:`_operation_id_get` for details.
"""
return _operation_id_get(None, operation_id)


def servers_operations_post(server_name: str,
request: 'Request') -> Dict[str, str]:
"""
Expand Down Expand Up @@ -235,6 +264,26 @@ def servers_operations_post(server_name: str,
return {"operation_id": operation.id}


def _operations_get(server_name: Optional[str]) \
-> Union[Tuple['Response', int], 'Response']:
"""
Get a list of operations for a Barman server or instance.
:param server_name: name of the Barman server to fetch operations from, or
``None`` for instance operations.
:return: a JSON response with ``operations`` key containing a list of
operations for a Barman server or instance. Each item in the list
contains the operation ID and the operation type.
"""
try:
operation = OperationServer(server_name)
available_operations = {"operations": operation.get_operations_list()}
return jsonify(available_operations)
except OperationServerConfigError as e:
abort(404, description=str(e))


@app.route("/servers/<server_name>/operations", methods=("GET", "POST"))
def server_operation(server_name: str) \
-> Union[Tuple['Response', int], 'Response']:
Expand Down Expand Up @@ -262,9 +311,86 @@ def server_operation(server_name: str) \
if request.method == "POST":
return jsonify(servers_operations_post(server_name, request)), 202

return _operations_get(server_name)


def instance_operations_post(request: 'Request') -> Dict[str, str]:
"""
Handle ``POST`` request to ``/operations``.
:param request: the flask request that has been received by the routing
function.
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:
* ``config_update``:
* ``changes``: an array of dictionaries to be used in
``barman config-update``
:return: if the JSON body informed through the ``POST`` request is valid,
return a JSON response containing a key ``operation_id`` with the ID of
the operation that has been created.
Otherwise, if any issue is identified, return a response with either of
the following statuses and the relevant error message:
* ``400``: if any required option is missing in the JSON request body.
* ``404``: if any value in the JSON request body is invalid.
"""
request_body = request.get_json()

if not request_body:
msg_400 = "Minimum barman options not met for instance operation"
abort(400, description=msg_400)

operation = None
cmd = None
op_type = OperationType(request_body.get("type"))

if op_type == OperationType.CONFIG_UPDATE:
operation = ConfigUpdateOperation(None)
cmd = "pg-backup-api config-update"

if TYPE_CHECKING: # pragma: no cover
assert isinstance(operation, Operation)
assert isinstance(cmd, str)

try:
operation = OperationServer(server_name)
available_operations = {"operations": operation.get_operations_list()}
return jsonify(available_operations)
except OperationServerConfigError as e:
abort(404, description=str(e))
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}


@app.route("/operations", methods=("GET", "POST"))
def instance_operation() -> Union[Tuple['Response', int], 'Response']:
"""
Handle ``GET``/``POST`` request to ``/operations``.
Get a list of operations for the Barman instance, if a ``GET`` request, or
create a new operation for the instance, if a ``POST`` request.
:return: the returned response varies:
* If a successful ``GET`` request, then return a JSON response with
``operations`` key containing a list of operations for the Barman
instance. Each item in the list contain the operation ID and the
operation type;
* If a successful ``POST`` request, then return a JSON response with
HTTP status ``202`` containing an ``operation_id`` key with the ID
of the operation that has been created for the Barman instance;
* If any issue is faced when processing the request, return an HTTP
``400`` or ``404`` response with the relevant error message.
"""
if request.method == "POST":
return jsonify(instance_operations_post(request)), 202

return _operations_get(None)
21 changes: 20 additions & 1 deletion pg_backup_api/pg_backup_api/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@

from pg_backup_api.utils import create_app, load_barman_config
from pg_backup_api.server_operation import (RecoveryOperation,
ConfigSwitchOperation)
ConfigSwitchOperation,
ConfigUpdateOperation)


if TYPE_CHECKING: # pragma: no cover
Expand Down Expand Up @@ -153,3 +154,21 @@ def config_switch_operation(args: 'argparse.Namespace') -> Tuple[None, bool]:
"""
return _run_operation(ConfigSwitchOperation(args.server_name,
args.operation_id))


def config_update_operation(args: 'argparse.Namespace') -> Tuple[None, bool]:
"""
Perform a ``barman config-update`` through the pg-backup-api.
.. note::
See :func:`_run_operation` for more details.
:param args: command-line arguments for ``pg-backup-api config-update``
command. Contains the operation ID to be run.
:return: a tuple consisting of two items:
* ``None`` -- output of :meth:`ConfigUpdateOperation.write_output_file`
* ``True`` if ``barman config-update`` was successful, ``False``
otherwise.
"""
return _run_operation(ConfigUpdateOperation(None, args.operation_id))
Loading

0 comments on commit 510a2d7

Please sign in to comment.