backups: Implement backup/restore of key/value settings

- Implemented within the backup component. Scope for implementing database
backup/restore in similar way.

- Add new 'settings' key in the backup manifest to allow keys to backed up and
restored.

- Implement by dumping/loading settings from DB into the file.

Tests:

- Unit tests.

- Backup/restore tests for dynamicdns workss.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
Sunil Mohan Adapa 2022-02-05 17:29:42 -08:00 committed by James Valleroy
parent a9c6e96a95
commit 59c5e58549
No known key found for this signature in database
GPG Key ID: 77C0C75E7B650808
8 changed files with 188 additions and 6 deletions

View File

@ -8,6 +8,8 @@ Wrapper to handle backups using borg-backups.
import argparse import argparse
import json import json
import os import os
import pathlib
import re
import subprocess import subprocess
import sys import sys
import tarfile import tarfile
@ -16,6 +18,7 @@ from plinth.modules.backups import MANIFESTS_FOLDER
from plinth.utils import Version from plinth.utils import Version
TIMEOUT = 30 TIMEOUT = 30
BACKUPS_DATA_PATH = pathlib.Path('/var/lib/plinth/backups-data/')
def parse_arguments(): def parse_arguments():
@ -78,6 +81,16 @@ def parse_arguments():
restore_exported_archive.add_argument('--path', help='Tarball file path', restore_exported_archive.add_argument('--path', help='Tarball file path',
required=True) required=True)
dump_settings = subparsers.add_parser('dump-settings',
help='Dump JSON settings to a file')
dump_settings.add_argument('--app-id',
help='ID of the app to dump settings for')
load_settings = subparsers.add_parser(
'load-settings', help='Load JSON settings from a file')
load_settings.add_argument('--app-id',
help='ID of the app to load settings for')
subparsers.required = True subparsers.required = True
return parser.parse_args() return parser.parse_args()
@ -267,6 +280,32 @@ def subcommand_restore_exported_archive(arguments):
break break
def _assert_app_id(app_id):
"""Check that app ID is correct."""
if not re.fullmatch(r'[a-z0-9_]+', app_id):
raise Exception('Invalid App ID')
def subcommand_dump_settings(arguments):
"""Dump an app's settings to a JSON file."""
app_id = arguments.app_id
_assert_app_id(app_id)
BACKUPS_DATA_PATH.mkdir(exist_ok=True)
settings_path = BACKUPS_DATA_PATH / f'{app_id}-settings.json'
settings_path.write_text(arguments.stdin)
def subcommand_load_settings(arguments):
"""Load an app's settings from a JSON file."""
app_id = arguments.app_id
_assert_app_id(app_id)
settings_path = BACKUPS_DATA_PATH / f'{app_id}-settings.json'
try:
print(settings_path.read_text())
except FileNotFoundError:
print('{}')
def _read_encryption_passphrase(arguments): def _read_encryption_passphrase(arguments):
"""Read encryption passphrase from stdin.""" """Read encryption passphrase from stdin."""
if arguments.stdin: if arguments.stdin:

View File

@ -3,7 +3,10 @@
App component for other apps to use backup/restore functionality. App component for other apps to use backup/restore functionality.
""" """
from plinth import app import copy
import json
from plinth import actions, app
def _validate_directories_and_files(section): def _validate_directories_and_files(section):
@ -45,29 +48,52 @@ def _validate_service(service):
assert service['kind'] in ('config', 'site', 'module') assert service['kind'] in ('config', 'site', 'module')
def _validate_settings(settings):
"""Validate settings stored by an in kvstore."""
if not settings:
return
assert isinstance(settings, list)
for setting in settings:
assert isinstance(setting, str)
class BackupRestore(app.FollowerComponent): class BackupRestore(app.FollowerComponent):
"""Component to backup/restore an app.""" """Component to backup/restore an app."""
def __init__(self, component_id, config=None, data=None, secrets=None, def __init__(self, component_id, config=None, data=None, secrets=None,
services=None): services=None, settings=None):
"""Initialize the backup/restore component.""" """Initialize the backup/restore component."""
super().__init__(component_id) super().__init__(component_id)
_validate_directories_and_files(config) _validate_directories_and_files(config)
self.config = config or {} self.config = config or {}
_validate_directories_and_files(data) _validate_directories_and_files(data)
self.data = data or {} self._data = data or {}
_validate_directories_and_files(secrets) _validate_directories_and_files(secrets)
self.secrets = secrets or {} self.secrets = secrets or {}
_validate_services(services) _validate_services(services)
self.services = services or [] self.services = services or []
_validate_settings(settings)
self.settings = settings or []
self.has_data = bool(config) or bool(data) or bool(secrets) self.has_data = (bool(config) or bool(data) or bool(secrets)
or bool(settings))
def __eq__(self, other): def __eq__(self, other):
"""Check if this component is same as another.""" """Check if this component is same as another."""
return self.component_id == other.component_id return self.component_id == other.component_id
@property
def data(self):
"""Add additional files to data files list."""
data = copy.deepcopy(self._data)
settings_file = self._get_settings_file()
if settings_file:
data.setdefault('files', []).append(settings_file)
return data
@property @property
def manifest(self): def manifest(self):
"""Return the backup details as a dictionary.""" """Return the backup details as a dictionary."""
@ -84,10 +110,14 @@ class BackupRestore(app.FollowerComponent):
if self.services: if self.services:
manifest['services'] = self.services manifest['services'] = self.services
if self.settings:
manifest['settings'] = self.settings
return manifest return manifest
def backup_pre(self, packet): def backup_pre(self, packet):
"""Perform any special operations before backup.""" """Perform any special operations before backup."""
self._settings_backup_pre()
def backup_post(self, packet): def backup_post(self, packet):
"""Perform any special operations after backup.""" """Perform any special operations after backup."""
@ -97,3 +127,43 @@ class BackupRestore(app.FollowerComponent):
def restore_post(self, packet): def restore_post(self, packet):
"""Perform any special operations after restore.""" """Perform any special operations after restore."""
self._settings_restore_post()
def _get_settings_file(self):
"""Return the settings file path to list of files to backup."""
if not self.settings or not self.app_id:
return None
data_path = '/var/lib/plinth/backups-data/'
return data_path + f'{self.app_id}-settings.json'
def _settings_backup_pre(self):
"""Read keys from kvstore and store them in a file to backup."""
if not self.settings:
return
from plinth import kvstore
data = {}
for key in self.settings:
try:
data[key] = kvstore.get(key)
except Exception:
pass
input_ = json.dumps(data).encode()
actions.superuser_run('backups',
['dump-settings', '--app-id', self.app_id],
input=input_)
def _settings_restore_post(self):
"""Read from a file and restore keys to kvstore."""
if not self.settings:
return
output = actions.superuser_run(
'backups', ['load-settings', '--app-id', self.app_id])
data = json.loads(output)
from plinth import kvstore
for key, value in data.items():
kvstore.set(key, value)

View File

@ -3,8 +3,13 @@
Test the App components provides by backups app. Test the App components provides by backups app.
""" """
import json
from unittest.mock import call, patch
import pytest import pytest
from plinth import kvstore
from .. import components from .. import components
from ..components import BackupRestore from ..components import BackupRestore
@ -16,8 +21,9 @@ def fixture_backup_restore():
"""Fixture to create a domain type after clearing all existing ones.""" """Fixture to create a domain type after clearing all existing ones."""
value = {'files': ['a', 'b'], 'directories': ['a', 'b']} value = {'files': ['a', 'b'], 'directories': ['a', 'b']}
services = ['service-1', {'type': 'system', 'name': 'service-2'}] services = ['service-1', {'type': 'system', 'name': 'service-2'}]
settings = ['setting-1', 'setting-2']
return BackupRestore('test-backup-restore', config=value, data=value, return BackupRestore('test-backup-restore', config=value, data=value,
secrets=value, services=services) secrets=value, services=services, settings=settings)
@pytest.mark.parametrize('section', [ @pytest.mark.parametrize('section', [
@ -159,6 +165,7 @@ def test_backup_restore_init_default_arguments():
assert component.data == {} assert component.data == {}
assert component.secrets == {} assert component.secrets == {}
assert component.services == [] assert component.services == []
assert component.settings == []
assert not component.has_data assert not component.has_data
@ -185,6 +192,22 @@ def test_backup_restore_init_services():
assert not component.has_data assert not component.has_data
def test_backup_restore_init_settings():
"""Test initialization of the backup restore object."""
with pytest.raises(AssertionError):
BackupRestore('test-backup-restore', settings='invalid-value')
settings = ['setting1', 'setting2']
component = BackupRestore('test-backup-restore', settings=settings)
assert component.settings == settings
assert component.has_data
assert component.data == {}
component.app_id = 'testapp'
settings_file = '/var/lib/plinth/backups-data/testapp-settings.json'
assert component.data == {'files': [settings_file]}
def test_backup_restore_equal(backup_restore): def test_backup_restore_equal(backup_restore):
"""Test equality operator on the backup restore object.""" """Test equality operator on the backup restore object."""
assert backup_restore == BackupRestore('test-backup-restore') assert backup_restore == BackupRestore('test-backup-restore')
@ -199,6 +222,7 @@ def test_backup_restore_manifest(backup_restore):
assert manifest['data'] == backup_restore.data assert manifest['data'] == backup_restore.data
assert manifest['secrets'] == backup_restore.secrets assert manifest['secrets'] == backup_restore.secrets
assert manifest['services'] == backup_restore.services assert manifest['services'] == backup_restore.services
assert manifest['settings'] == backup_restore.settings
assert BackupRestore('test-backup-restore').manifest == {} assert BackupRestore('test-backup-restore').manifest == {}
@ -206,7 +230,47 @@ def test_backup_restore_manifest(backup_restore):
def test_backup_restore_hooks(backup_restore): def test_backup_restore_hooks(backup_restore):
"""Test running hooks on backup restore object.""" """Test running hooks on backup restore object."""
packet = None packet = None
backup_restore.backup_pre(packet)
backup_restore.backup_post(packet) backup_restore.backup_post(packet)
backup_restore.restore_pre(packet) backup_restore.restore_pre(packet)
@pytest.mark.django_db
@patch('plinth.actions.superuser_run')
def test_backup_restore_backup_pre(run, backup_restore):
"""Test running backup-pre hook."""
packet = None
kvstore.set('setting-1', 'value-1')
backup_restore.app_id = 'testapp'
component = BackupRestore('test-backup-restore')
component.backup_pre(packet)
run.assert_has_calls([])
backup_restore.backup_pre(packet)
input_ = {'setting-1': 'value-1'}
run.assert_has_calls([
call('backups', ['dump-settings', '--app-id', 'testapp'],
input=json.dumps(input_).encode())
])
@pytest.mark.django_db
@patch('plinth.actions.superuser_run')
def test_backup_restore_restore_post(run, backup_restore):
"""Test running restore-post hook."""
packet = None
backup_restore.app_id = 'testapp'
component = BackupRestore('test-backup-restore')
component.restore_post(packet)
run.assert_has_calls([])
output = {'setting-1': 'value-1'}
run.return_value = json.dumps(output)
backup_restore.restore_post(packet) backup_restore.restore_post(packet)
run.assert_has_calls(
[call('backups', ['load-settings', '--app-id', 'testapp'])])
assert kvstore.get('setting-1') == 'value-1'
with pytest.raises(Exception):
kvstore.get('setting-2')

View File

@ -153,6 +153,7 @@ class GitwebBackupRestore(BackupRestore):
def restore_post(self, packet): def restore_post(self, packet):
"""Update access after restoration of backups.""" """Update access after restoration of backups."""
super().restore_post(packet)
app.update_service_access() app.update_service_access()

View File

@ -107,10 +107,12 @@ class SambaBackupRestore(BackupRestore):
def backup_pre(self, packet): def backup_pre(self, packet):
"""Save registry share configuration.""" """Save registry share configuration."""
super().backup_pre(packet)
actions.superuser_run('samba', ['dump-shares']) actions.superuser_run('samba', ['dump-shares'])
def restore_post(self, packet): def restore_post(self, packet):
"""Restore configuration.""" """Restore configuration."""
super().restore_post(packet)
actions.superuser_run('samba', ['setup']) actions.superuser_run('samba', ['setup'])
actions.superuser_run('samba', ['restore-shares']) actions.superuser_run('samba', ['restore-shares'])

View File

@ -112,10 +112,12 @@ class TTRSSBackupRestore(BackupRestore):
def backup_pre(self, packet): def backup_pre(self, packet):
"""Save database contents.""" """Save database contents."""
super().backup_pre(packet)
actions.superuser_run('ttrss', ['dump-database']) actions.superuser_run('ttrss', ['dump-database'])
def restore_post(self, packet): def restore_post(self, packet):
"""Restore database contents.""" """Restore database contents."""
super().restore_post(packet)
actions.superuser_run('ttrss', ['restore-database']) actions.superuser_run('ttrss', ['restore-database'])

View File

@ -113,10 +113,12 @@ class WordPressBackupRestore(BackupRestore):
def backup_pre(self, packet): def backup_pre(self, packet):
"""Save database contents.""" """Save database contents."""
super().backup_pre(packet)
actions.superuser_run('wordpress', ['dump-database']) actions.superuser_run('wordpress', ['dump-database'])
def restore_post(self, packet): def restore_post(self, packet):
"""Restore database contents.""" """Restore database contents."""
super().restore_post(packet)
actions.superuser_run('wordpress', ['restore-database']) actions.superuser_run('wordpress', ['restore-database'])

View File

@ -126,8 +126,10 @@ class ZophBackupRestore(BackupRestore):
def backup_pre(self, packet): def backup_pre(self, packet):
"""Save database contents.""" """Save database contents."""
super().backup_pre(packet)
actions.superuser_run('zoph', ['dump-database']) actions.superuser_run('zoph', ['dump-database'])
def restore_post(self, packet): def restore_post(self, packet):
"""Restore database contents.""" """Restore database contents."""
super().restore_post(packet)
actions.superuser_run('zoph', ['restore-database']) actions.superuser_run('zoph', ['restore-database'])