# # This file is part of FreedomBox. # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as # published by the Free Software Foundation, either version 3 of the # License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . # """ API for performing backup and restore. Backups can be full disk backups or backup of individual applications. TODO: - Implement snapshots by calling to snapshots module. - Handles errors during backup and service start/stop. - Implement unit tests. """ from plinth import actions, action_utils, module_loader def validate(backup): """Validate the backup' information schema.""" assert isinstance(backup, dict) if 'config' in backup: assert isinstance(backup['config'], dict) _validate_directories_and_files(backup['config']) if 'data' in backup: assert isinstance(backup['data'], dict) _validate_directories_and_files(backup['data']) if 'secrets' in backup: assert isinstance(backup['secrets'], dict) _validate_directories_and_files(backup['secrets']) if 'services' in backup: assert isinstance(backup['services'], list) for service in backup['services']: assert isinstance(service, (str, dict)) if isinstance(service, dict): _validate_service(service) return backup def _validate_directories_and_files(section): """Validate directories and files keys in a section.""" 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_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 Packet: """Information passed to a handlers for backup/restore operations.""" def __init__(self, operation, scope, root, apps=None, label=None): """Initialize the packet. operation is either 'backup' or 'restore. scope is either 'full' for full backups/restores or 'apps' for application specific operations. manifests are used to build file/directory lists if scope is 'apps'. All paths populated are relative to the 'root' path. The root path itself must not be stored in the backup. """ self.operation = operation self.scope = scope self.root = root self.apps = apps self.label = label self.directories = [] self.files = [] if scope == 'apps': self._process_manifests() def _process_manifests(self): """Look at manifests and fill up the list of directories/files.""" for app in self.apps: for section in ['config', 'data', 'secrets']: self.directories += app.manifest.get(section, {}).get( 'directories', []) self.files += app.manifest.get(section, {}).get('files', []) def backup_full(backup_handler, label=None): """Backup the entire system.""" if not _is_snapshot_available(): raise Exception('Full backup is not supported without snapshots.') snapshot = _take_snapshot() backup_root = snapshot['mount_path'] packet = Packet('backup', 'full', backup_root, label) _run_operation(backup_handler, packet) _delete_snapshot(snapshot) def restore_full(restore_handler): """Restore the entire system.""" if not _is_snapshot_available(): raise Exception('Full restore is not supported without snapshots.') subvolume = _create_subvolume(empty=True) restore_root = subvolume['mount_path'] packet = Packet('restore', 'full', restore_root) _run_operation(restore_handler, packet) _switch_to_subvolume(subvolume) def backup_apps(backup_handler, app_names=None, label=None): """Backup data belonging to a set of applications.""" if not app_names: apps = get_all_apps_for_backup() else: apps = _get_apps_in_order(app_names) if _is_snapshot_available(): snapshot = _take_snapshot() backup_root = snapshot['mount_path'] snapshotted = True else: _lockdown_apps(apps, lockdown=True) original_state = _shutdown_services(apps) backup_root = '/' snapshotted = False packet = Packet('backup', 'apps', backup_root, apps, label) _run_operation(backup_handler, packet) if snapshotted: _delete_snapshot(snapshot) else: _restore_services(original_state) _lockdown_apps(apps, lockdown=False) def restore_apps(restore_handler, app_names=None, create_subvolume=True, backup_file=None): """Restore data belonging to a set of applications.""" if not app_names: apps = get_all_apps_for_backup() else: apps = _get_apps_in_order(app_names) if _is_snapshot_available() and create_subvolume: subvolume = _create_subvolume(empty=False) restore_root = subvolume['mount_path'] subvolume = True else: _lockdown_apps(apps, lockdown=True) original_state = _shutdown_services(apps) restore_root = '/' subvolume = False packet = Packet('restore', 'apps', restore_root, apps, backup_file) _run_operation(restore_handler, packet) if subvolume: _switch_to_subvolume(subvolume) else: _restore_services(original_state) _lockdown_apps(apps, lockdown=False) class BackupApp: """A application that can be backed up and its manifest.""" def __init__(self, name, app): """Initialize object and load manfiest.""" self.name = name self.app = app # Not installed if app.setup_helper.get_state() == 'needs-setup': raise TypeError # Has no backup related meta data try: self.manifest = app.backup except AttributeError: raise TypeError self.has_data = bool(app.backup) def __eq__(self, other_app): """Check if this app is same as another.""" return self.name == other_app.name and \ self.app == other_app.app and \ self.manifest == other_app.manifest and \ self.has_data == other_app.has_data def get_all_apps_for_backup(): """Return a list of all applications that can be backed up.""" apps = [] for module_name, module in module_loader.loaded_modules.items(): try: apps.append(BackupApp(module_name, module)) except TypeError: # Application not available for backup/restore pass return apps def _get_apps_in_order(app_names): """Return a list of app modules in order of dependency.""" apps = [] for module_name, module in module_loader.loaded_modules.items(): if module_name in app_names: apps.append(BackupApp(module_name, module)) return apps def _lockdown_apps(apps, lockdown): """Mark apps as in/out of lockdown mode and disable all user interaction. This is a flag in the app module. It will enforced by a middleware that will intercept all interaction and show a lockdown message. """ for app in apps: app.app.locked = lockdown # XXX: Lockdown the application UI by implementing a middleware def _is_snapshot_available(): """Return whether it is possible to take filesystem snapshots.""" pass def _take_snapshot(): """Take a snapshot of the entire filesystem. - Snapshot must be read-only. - Mount the snapshot and make it available for backup. Return information dictionary about snapshot including 'mount_path', the mount point of the snapshot and any other information necessary to delete the snapshot later. """ raise NotImplementedError def _create_subvolume(empty=True): """Create a new subvolume for restore files to. - If empty is true, create an empty subvolume. Otherwise, create a read-write snapshot of the current root. - Mount the subvolume read/write and make it available for restore. Return information dictionary about subvolume created including 'mount_path', the mount point of the subvolume and any other information necessary to switch to this subvolume later. """ raise NotImplementedError def _delete_snapshot(snapshot): """Delete a snapshot given information captured when snapshot was taken.""" raise NotImplementedError def _switch_to_subvolume(subvolume): """Make the provided subvolume the default subvolume to mount.""" raise NotImplementedError class ServiceHandler: """Abstraction to help with service shutdown/restart.""" @staticmethod def create(backup_app, service): service_type = 'system' if isinstance(service, dict): service_type = service['type'] service_map = { 'system': SystemServiceHandler, 'apache': ApacheServiceHandler, } assert service_type in service_map return service_map[service_type](backup_app, service) def __init__(self, backup_app, service): """Initialize the object.""" self.backup_app = backup_app self.service = service def stop(self): """Stop the service.""" raise NotImplementedError def restart(self): """Stop the service.""" raise NotImplementedError def __eq__(self, other_handler): """Compare that two handlers are the same.""" return self.backup_app == other_handler.backup_app and \ self.service == other_handler.service class SystemServiceHandler(ServiceHandler): """Handle starting and stoping of system services for backup.""" def __init__(self, backup_app, service): """Initialize the object.""" super().__init__(backup_app, service) self.was_running = None def stop(self): """Stop the service.""" self.was_running = action_utils.service_is_running(self.service) if self.was_running: actions.superuser_run('service', ['stop', self.service]) def restart(self): """Restart the service if it was earlier running.""" if self.was_running: actions.superuser_run('service', ['start', self.service]) class ApacheServiceHandler(ServiceHandler): """Handle starting and stoping of Apache services for backup.""" def __init__(self, backup_app, service): """Initialize the object.""" super().__init__(backup_app, service) self.was_enabled = None self.web_name = service['name'] self.kind = service['kind'] def stop(self): """Stop the service.""" self.was_enabled = action_utils.webserver_is_enabled( self.web_name, kind=self.kind) if self.was_enabled: actions.superuser_run( 'apache', ['disable', '--name', self.web_name, '--kind', self.kind]) def restart(self): """Restart the service if it was earlier running.""" if self.was_enabled: actions.superuser_run( 'apache', ['enable', '--name', self.web_name, '--kind', self.kind]) def _shutdown_services(apps): """Shutdown all services specified by manifests. - Services are shutdown in the reverse order of the apps listing. Return the current state of the services so they can be restored accurately. """ state = [] for app in apps: for service in app.manifest.get('services', []): state.append(ServiceHandler.create(app, service)) for service in reversed(state): service.stop() return state def _restore_services(original_state): """Re-run services to restore them to their initial state. Maintain exact order of services so dependencies are satisfied. """ for service_handler in original_state: service_handler.restart() def _run_operation(handler, packet): """Run handler and pre/post hooks for backup/restore operations.""" handler(packet)