Skip to content

Commit 8b03d91

Browse files
committed
topology-controller: add BackupTopologyController
This controller provides automatic backup and restore of the topology hosts.
1 parent 21713d5 commit 8b03d91

File tree

2 files changed

+143
-2
lines changed

2 files changed

+143
-2
lines changed

pytest_mh/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
)
3333
from ._private.plugin import MultihostPlugin, mh_fixture, pytest_addoption, pytest_configure
3434
from ._private.topology import Topology, TopologyDomain
35-
from ._private.topology_controller import TopologyController
35+
from ._private.topology_controller import BackupTopologyController, TopologyController
3636

3737
__all__ = [
3838
"mh",
@@ -66,6 +66,7 @@
6666
"pytest_configure",
6767
"Topology",
6868
"TopologyController",
69+
"BackupTopologyController",
6970
"TopologyDomain",
7071
"TopologyMark",
7172
]

pytest_mh/_private/topology_controller.py

Lines changed: 141 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
from __future__ import annotations
22

3+
from functools import partial, wraps
34
from types import SimpleNamespace
45
from typing import Any, Callable, Generic
56

67
from .artifacts import MultihostArtifactsType, MultihostTopologyControllerArtifacts
78
from .logging import MultihostLogger
89
from .misc import OperationStatus, invoke_callback
9-
from .multihost import ConfigType, MultihostDomain, MultihostHost
10+
from .multihost import ConfigType, MultihostBackupHost, MultihostDomain, MultihostHost
1011
from .topology import Topology, TopologyDomain
1112

1213

@@ -349,3 +350,142 @@ def teardown(self, *args, **kwargs) -> None:
349350
Called after execution of each test.
350351
"""
351352
pass
353+
354+
355+
class BackupTopologyController(TopologyController[ConfigType]):
356+
"""
357+
Implements automatic backup and restore of all topology hosts that inherit
358+
from :class:`MultihostBackupHost`.
359+
360+
The backup of all hosts is taken in :meth:`topology_setup`. It is expected
361+
that this method is overridden by the user to setup the topology
362+
environment. In such case, it is possible to call
363+
``super().topology_setup(**kwargs)`` at the end of the overridden function
364+
or omit this call and store the backup in :attr:`backup_data` manually.
365+
366+
:meth:`teardown` restores the hosts to the backup taken in
367+
:meth:`topology_setup`. This is done after each test, so each test starts
368+
with clear topology environment.
369+
370+
When all tests for this topology are run, :meth:`topology_teardown` is
371+
called and the hosts are restored to the original state which backup was
372+
taken in :meth:`MultihostBackupHost.pytest_setup` so the environment is
373+
fresh for the next topology.
374+
375+
.. note::
376+
377+
It is possible to decorate methods, usually the custom implementation of
378+
:meth:`topology_setup` with :meth:`restore_vanilla_on_error`. This makes
379+
sure that the hosts are reverted to the original state if any of the
380+
setup calls fail.
381+
382+
.. code-block:: python
383+
384+
@BackupTopologyController.restore_vanilla_on_error
385+
def topology_setup(self, *kwargs) -> None:
386+
raise Exception("Hosts are automatically restored now.")
387+
"""
388+
389+
def __init__(self) -> None:
390+
super().__init__()
391+
392+
self.backup_data: dict[MultihostBackupHost, Any | None] = {}
393+
"""
394+
Backup data. Dictionary with host as a key and backup as a value.
395+
"""
396+
397+
def restore(self, hosts: dict[MultihostBackupHost, Any | None]) -> None:
398+
"""
399+
Restore given hosts to their given backup.
400+
401+
:param hosts: Dictionary (host, backup)
402+
:type hosts: dict[MultihostBackupHost, Any | None]
403+
:raises ExceptionGroup: If some hosts fail to restore.
404+
"""
405+
errors = []
406+
for host, backup_data in hosts.items():
407+
if not isinstance(host, MultihostBackupHost):
408+
continue
409+
410+
try:
411+
host.restore(backup_data)
412+
except Exception as e:
413+
errors.append(e)
414+
415+
if errors:
416+
raise ExceptionGroup("Some hosts failed to restore to original state", errors)
417+
418+
def restore_vanilla(self) -> None:
419+
"""
420+
Restore to the original host state that is stored in the host object.
421+
422+
This backup was taken when pytest started and we want to revert to this
423+
state when this topology is finished.
424+
"""
425+
restore_data: dict[MultihostBackupHost, Any | None] = {}
426+
427+
for host in self.hosts:
428+
if not isinstance(host, MultihostBackupHost):
429+
continue
430+
431+
restore_data[host] = host.backup_data
432+
433+
self.restore(restore_data)
434+
435+
def topology_setup(self, *args, **kwargs) -> None:
436+
"""
437+
Take backup of all topology hosts.
438+
"""
439+
super().topology_setup(**kwargs)
440+
441+
for host in self.hosts:
442+
if not isinstance(host, MultihostBackupHost):
443+
continue
444+
445+
self.backup_data[host] = host.backup()
446+
447+
def topology_teardown(self, *args, **kwargs) -> None:
448+
"""
449+
Remove all topology backups from the hosts and restore the hosts to the
450+
original state before this topology.
451+
"""
452+
try:
453+
for host, backup_data in self.backup_data.items():
454+
if not isinstance(host, MultihostBackupHost):
455+
continue
456+
457+
host.remove_backup(backup_data)
458+
except Exception:
459+
# This is not that important, we can just ignore
460+
pass
461+
462+
self.restore_vanilla()
463+
464+
def teardown(self, *args, **kwargs) -> None:
465+
"""
466+
Restore the host to the state created by this topology in
467+
:meth:`topology_setup` after each test is finished.
468+
"""
469+
self.restore(self.backup_data)
470+
471+
@staticmethod
472+
def restore_vanilla_on_error(method):
473+
"""
474+
Decorator. Restore all hosts to its original state if an exception
475+
occurs during method execution.
476+
477+
:param method: Method to decorate.
478+
:type method: Any setup or teardown callback.
479+
:return: Decorated method.
480+
:rtype: Callback
481+
"""
482+
483+
@wraps(method)
484+
def wrapper(self: BackupTopologyController, *args, **kwargs):
485+
try:
486+
return self._invoke_with_args(partial(method, self))
487+
except Exception:
488+
self.restore_vanilla()
489+
raise
490+
491+
return wrapper

0 commit comments

Comments
 (0)