backups.py 9.3 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
#
# 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 <http://www.gnu.org/licenses/>.
#
"""
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.
"""

import collections

from plinth import actions, action_utils, module_loader


33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62
def validate(backup):
    """Validate the backup' information schema."""
    assert isinstance(backup, dict)

    assert 'config' in backup
    assert isinstance(backup['config'], dict)
    _validate_directories_and_files(backup['config'])

    assert 'data' in backup
    assert isinstance(backup['data'], dict)
    _validate_directories_and_files(backup['data'])

    assert 'secrets' in backup
    assert isinstance(backup['secrets'], dict)
    _validate_directories_and_files(backup['secrets'])

    assert 'services' in backup
    assert isinstance(backup['services'], list)

    return backup


def _validate_directories_and_files(df):
    """Validate directories and files structure."""
    assert 'directories' in df
    assert isinstance(df['directories'], list)
    assert 'files' in df
    assert isinstance(df['files'], list)


63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90
class Packet:
    """Information passed to a handlers for backup/restore operations."""

    def __init__(self, operation, scope, root, manifests=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.manifests = manifests

        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."""
91 92 93 94 95
        for manifest in self.manifests:
            backup = manifest[2]
            for x in ['config', 'data', 'secrets']:
                self.directories += backup[x]['directories']
                self.files += backup[x]['files']
96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185


def backup_full(backup_handler):
    """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)
    _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):
    """Backup data belonging to a set of applications."""
    if not app_names:
        apps = _list_of_all_apps_for_backup()
    else:
        apps = _get_apps_in_order(app_names)

    manifests = _get_manifests(apps)

    if _is_snapshot_available():
        snapshot = _take_snapshot()
        backup_root = snapshot['mount_path']
        snapshotted = True
    else:
        _lockdown_apps(apps, lockdown=True)
        original_state = _shutdown_services(manifests)
        backup_root = '/'
        snapshotted = False

    packet = Packet('backup', 'apps', backup_root, manifests)
    _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):
    """Restore data belonging to a set of applications."""
    if not app_names:
        apps = _list_of_all_apps_for_backup()
    else:
        apps = _get_apps_in_order(app_names)

    manifests = _get_manifests(apps)

    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(manifests)
        restore_root = '/'
        subvolume = False

    packet = Packet('restore', 'apps', restore_root, manifests)
    _run_operation(restore_handler, packet)

    if subvolume:
        _switch_to_subvolume(subvolume)
    else:
        _restore_services(original_state)
        _lockdown_apps(apps, lockdown=False)


def _list_of_all_apps_for_backup():
    """Return a list of all applications that can be backed up."""
    apps = []
186
    for module_name, module in module_loader.loaded_modules.items():
187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202
        # Not installed
        if module.setup_helper.get_state() == 'needs-setup':
            continue

        # Has no backup related meta data
        if not hasattr(module, 'backup'):
            continue

        apps.append((module_name, module))

    return apps


def _get_apps_in_order(app_names):
    """Return a list of app modules in order of dependency."""
    apps = []
203
    for module_name, module in module_loader.loaded_modules.items():
204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318
        if module_name in app_names:
            apps.append((module_name, module))

    return apps


def _get_manifests(apps):
    """Return a dictionary of apps' backup manifest data.

    Maintain the application order in returned data.
    """
    manifests = []
    for app_name, app in apps:
        try:
            manifests.append((app_name, app, app.backup))
        except AttributeError:
            pass

    return manifests


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.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


def _shutdown_services(manifests):
    """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 = collections.OrderedDict()
    for app_name, app, manifest in manifests:
        for service in manifest.get('services', []):
            if service not in state:
                state[service] = {'app_name': app_name, 'app': app}

    for service in state:
        state['was_running'] = action_utils.service_is_running('service')

    for service in reversed(state):
        if service['was_running']:
            actions.superuser_run('service', ['stop', service])

    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 in original_state:
        if service['was_running']:
            actions.superuser_run('service', ['start', service])


def _run_operation(handler, packet):
    """Run handler and pre/post hooks for backup/restore operations."""
    handler(packet)