diff --git a/plinth/modules/backups/components.py b/plinth/modules/backups/components.py new file mode 100644 index 000000000..3fadf8e82 --- /dev/null +++ b/plinth/modules/backups/components.py @@ -0,0 +1,99 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +App component for other apps to use backup/restore functionality. +""" + +from plinth import app + + +def _validate_directories_and_files(section): + """Validate directories and files keys in a section.""" + if not section: + return + + assert isinstance(section, dict) + + if 'directories' in section: + assert isinstance(section['directories'], list) + for directory in section['directories']: + assert isinstance(directory, str) + + if 'files' in section: + assert isinstance(section['files'], list) + for file_path in section['files']: + assert isinstance(file_path, str) + + +def _validate_services(services): + """Validate services manifest provided as list.""" + if not services: + return + + assert isinstance(services, list) + for service in services: + assert isinstance(service, (str, dict)) + if isinstance(service, dict): + _validate_service(service) + + +def _validate_service(service): + """Validate a service manifest provided as a dictionary.""" + assert isinstance(service['name'], str) + assert isinstance(service['type'], str) + assert service['type'] in ('apache', 'uwsgi', 'system') + if service['type'] == 'apache': + assert service['kind'] in ('config', 'site', 'module') + + +class BackupRestore(app.FollowerComponent): + """Component to backup/restore an app.""" + + def __init__(self, component_id, config=None, data=None, secrets=None, + services=None): + """Initialize the backup/restore component.""" + super().__init__(component_id) + + _validate_directories_and_files(config) + self.config = config or {} + _validate_directories_and_files(data) + self.data = data or {} + _validate_directories_and_files(secrets) + self.secrets = secrets or {} + _validate_services(services) + self.services = services or [] + + self.has_data = bool(config) or bool(data) or bool(secrets) + + def __eq__(self, other): + """Check if this component is same as another.""" + return self.component_id == other.component_id + + @property + def manifest(self): + """Return the backup details as a dictionary.""" + manifest = {} + if self.config: + manifest['config'] = self.config + + if self.secrets: + manifest['secrets'] = self.secrets + + if self.data: + manifest['data'] = self.data + + if self.services: + manifest['services'] = self.services + + return manifest + + def backup_pre(self, packet): + """Perform any special operations before backup.""" + + def backup_post(self, packet): + """Perform any special operations after backup.""" + + def restore_pre(self, packet): + """Perform any special operations before restore.""" + + def restore_post(self, packet): + """Perform any special operations after restore.""" diff --git a/plinth/modules/backups/tests/test_components.py b/plinth/modules/backups/tests/test_components.py new file mode 100644 index 000000000..d1a0df774 --- /dev/null +++ b/plinth/modules/backups/tests/test_components.py @@ -0,0 +1,212 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +Test the App components provides by backups app. +""" + +import pytest + +from .. import components +from ..components import BackupRestore + +# pylint: disable=protected-access + + +@pytest.fixture(name='backup_restore') +def fixture_backup_restore(): + """Fixture to create a domain type after clearing all existing ones.""" + value = {'files': ['a', 'b'], 'directories': ['a', 'b']} + services = ['service-1', {'type': 'system', 'name': 'service-2'}] + return BackupRestore('test-backup-restore', config=value, data=value, + secrets=value, services=services) + + +@pytest.mark.parametrize('section', [ + None, + { + 'directories': ['a', 'b'] + }, + { + 'files': ['a', 'b'] + }, + { + 'directories': ['a'], + 'files': ['a'] + }, + { + 'extra': 'value' + }, +]) +def test_valid_directories_and_files(section): + """Test that valid values of files and directories.""" + components._validate_directories_and_files(section) + + +@pytest.mark.parametrize('section', [ + 'invalid', + 10, + ['invalid'], + { + 'files': None + }, + { + 'files': 10 + }, + { + 'files': {} + }, + { + 'files': [10], + }, + { + 'files': [None], + }, + { + 'files': [[]], + }, + { + 'directories': None + }, + { + 'directories': [10], + }, +]) +def test_invalid_directories_and_files(section): + """Test that invalid values of files and directories.""" + with pytest.raises(AssertionError): + components._validate_directories_and_files(section) + + +@pytest.mark.parametrize('services', [ + None, + [], + ['service'], + [{ + 'type': 'uwsgi', + 'name': 'service' + }], + [{ + 'type': 'system', + 'name': 'service' + }], + [{ + 'type': 'apache', + 'name': 'service', + 'kind': 'config' + }], + [{ + 'type': 'apache', + 'name': 'service', + 'kind': 'site' + }], + [{ + 'type': 'apache', + 'name': 'service', + 'kind': 'module' + }], +]) +def test_valid_services(services): + """Test that valid values of services.""" + components._validate_services(services) + + +@pytest.mark.parametrize('services', [ + 10, + 'invalid', + [10], + [[]], + [{}], + [{ + 'type': 'invalid', + 'name': 'service' + }], + [{ + 'type': 10, + 'name': 'service' + }], + [{ + 'type': 'system', + 'name': 10 + }], + [{ + 'type': 'system', + 'name': None + }], + [{ + 'type': 'system', + 'name': [] + }], + [{ + 'type': 'apache', + 'name': 'service' + }], + [{ + 'type': 'apache', + 'name': 'service', + 'kind': 'invalid-kind' + }], +]) +def test_invalid_services(services): + """Test that invalid values of services.""" + with pytest.raises((AssertionError, KeyError)): + components._validate_services(services) + + +def test_backup_restore_init_default_arguments(): + """Test initialization of the backup restore object.""" + component = BackupRestore('test-backup-restore') + assert component.component_id == 'test-backup-restore' + assert component.config == {} + assert component.data == {} + assert component.secrets == {} + assert component.services == [] + assert not component.has_data + + +@pytest.mark.parametrize('key', ['config', 'data', 'secrets']) +def test_backup_restore_init(key): + """Test initialization of the backup restore object.""" + with pytest.raises(AssertionError): + BackupRestore('test-backup-restore', **{key: 'invalid-value'}) + + value = {'files': ['a', 'b'], 'directories': ['a', 'b']} + component = BackupRestore('test-backup-restore', **{key: value}) + assert getattr(component, key) == value + assert component.has_data + + +def test_backup_restore_init_services(): + """Test initialization of the backup restore object.""" + with pytest.raises(AssertionError): + BackupRestore('test-backup-restore', services='invalid-value') + + services = ['service-1', {'type': 'system', 'name': 'service-2'}] + component = BackupRestore('test-backup-restore', services=services) + assert component.services == services + assert not component.has_data + + +def test_backup_restore_equal(backup_restore): + """Test equality operator on the backup restore object.""" + assert backup_restore == BackupRestore('test-backup-restore') + assert backup_restore != BackupRestore('test-different') + + +def test_backup_restore_manifest(backup_restore): + """Test manifest retrieval from backup restore object.""" + manifest = backup_restore.manifest + assert isinstance(manifest, dict) + assert manifest['config'] == backup_restore.config + assert manifest['data'] == backup_restore.data + assert manifest['secrets'] == backup_restore.secrets + assert manifest['services'] == backup_restore.services + + assert BackupRestore('test-backup-restore').manifest == {} + + +def test_backup_restore_hooks(backup_restore): + """Test running hooks on backup restore object.""" + packet = None + backup_restore.backup_pre(packet) + backup_restore.backup_post(packet) + backup_restore.restore_pre(packet) + backup_restore.restore_post(packet)