...
 
Commits (43)
......@@ -25,8 +25,10 @@ import glob
import json
import os
import subprocess
import sys
import tarfile
REPOSITORY = '/var/lib/freedombox/backups'
REPOSITORY = '/var/lib/freedombox/borgbackup'
def parse_arguments():
......@@ -41,7 +43,8 @@ def parse_arguments():
create = subparsers.add_parser('create', help='Create archive')
create.add_argument('--name', help='Archive name', required=True)
create.add_argument('--path', help='Path to archive', required=True)
create.add_argument('--paths', help='Paths to include in archive',
nargs='+')
delete = subparsers.add_parser('delete', help='Delete archive')
delete.add_argument('--name', help='Archive name', required=True)
......@@ -61,6 +64,12 @@ def parse_arguments():
list_exports.add_argument('--location', required=True,
help='location to check')
get_export_apps = subparsers.add_parser(
'get-export-apps',
help='Get list of apps included in exported archive file')
get_export_apps.add_argument(
'--filename', help='Tarball file name', required=True)
restore = subparsers.add_parser(
'restore', help='Restore files from an exported archive')
restore.add_argument('--filename', help='Tarball file name', required=True)
......@@ -74,6 +83,10 @@ def subcommand_setup(_):
try:
subprocess.run(['borg', 'info', REPOSITORY], check=True)
except:
path = os.path.dirname(REPOSITORY)
if not os.path.exists(path):
os.makedirs(path)
subprocess.run(['borg', 'init', '--encryption', 'none', REPOSITORY])
......@@ -89,10 +102,13 @@ def subcommand_list(_):
def subcommand_create(arguments):
"""Create archive."""
paths = filter(os.path.exists, arguments.paths)
subprocess.run([
'borg', 'create', '--json', REPOSITORY + '::' + arguments.name,
arguments.path
], check=True)
'borg',
'create',
'--json',
REPOSITORY + '::' + arguments.name,
] + list(paths), check=True)
def subcommand_delete(arguments):
......@@ -140,14 +156,45 @@ def subcommand_list_exports(arguments):
print(json.dumps(exports))
def subcommand_get_export_apps(arguments):
"""Get list of apps included in exported archive file."""
manifest = None
with tarfile.open(arguments.filename) as t:
filenames = t.getnames()
for name in filenames:
if 'var/lib/plinth/backups-manifests/' in name \
and name.endswith('.json'):
manifest_data = t.extractfile(name).read()
manifest = json.loads(manifest_data)
break
if manifest:
for app in manifest:
print(app['name'])
def subcommand_restore(arguments):
"""Restore files from an exported archive."""
prev_dir = os.getcwd()
try:
os.chdir('/')
subprocess.run(['tar', 'xf', arguments.filename], check=True)
finally:
os.chdir(prev_dir)
locations_data = ''.join(sys.stdin)
locations = json.loads(locations_data)
found_file = False
with tarfile.open(arguments.filename) as t:
for member in t.getmembers():
path = '/' + member.name
if path in locations['files']:
t.extract(member, '/')
found_file = True
else:
for d in locations['directories']:
if path.startswith(d):
t.extract(member, '/')
found_file = True
break
if not found_file:
print('No matching files or directories found in archive:', locations)
sys.exit(1)
def main():
......
#
# 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/>.
#
@apps @dynamicdns @backups
Feature: Dynamic DNS Client
Update public IP to a GnuDIP server.
Background:
Given I'm a logged in user
And the dynamicdns application is installed
Scenario: Backup and restore configuration
Given dynamicdns is configured
When I create a backup of the dynamicdns app data
And I change the dynamicdns configuration
And I export the dynamicdns app data backup
And I restore the dynamicdns app data backup
Then dynamicdns should have the original configuration
......@@ -15,7 +15,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
@apps @ejabberd
@apps @ejabberd @backups
Feature: Ejabberd Chat Server
Run ejabberd chat server.
......@@ -42,3 +42,12 @@ Scenario: Disable message archive management
Given the ejabberd application is enabled
When I disable message archive management
Then the ejabberd service should be running
Scenario: Backup and restore ejabberd
Given the ejabberd application is enabled
And I have added a contact to my roster
When I create a backup of the ejabberd app data
And I delete the contact from my roster
And I export the ejabberd app data backup
And I restore the ejabberd app data backup
Then I should have a contact on my roster
......@@ -15,7 +15,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
@apps @ikiwiki
@apps @ikiwiki @backups
Feature: ikiwiki Wiki and Blog
Manage wikis and blogs.
......@@ -32,4 +32,12 @@ Scenario: Disable wiki application
Given the wiki application is enabled
When I disable the wiki application
Then the wiki site should not be available
\ No newline at end of file
Scenario: Backup and restore wiki
Given the wiki application is enabled
When there is an ikiwiki wiki
And I create a backup of the ikiwiki app data
And I delete the ikiwiki wiki
And I export the ikiwiki app data backup
And I restore the ikiwiki app data backup
Then the ikiwiki wiki should be restored
......@@ -15,7 +15,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
@apps @mediawiki
@apps @mediawiki @backups
Feature: MediaWiki Wiki Engine
Manage wikis, multimedia and more.
......@@ -82,3 +82,11 @@ Scenario: Upload SVG image
Given the mediawiki application is enabled
When I upload an image named FreedomBox-logo-grayscale.svg to mediawiki with credentials admin and whatever123
Then there should be FreedomBox-logo-grayscale.svg image
Scenario: Backup and restore mediawiki
Given the mediawiki application is enabled
When I create a backup of the mediawiki app data
And I delete the mediawiki main page
And I export the mediawiki app data backup
And I restore the mediawiki app data backup
Then the mediawiki main page should be restored
......@@ -15,7 +15,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
@apps @sip
@apps @sip @backups
Feature: Repro SIP Server
Make audio and video calls.
......@@ -32,3 +32,12 @@ Scenario: Disable repro application
Given the repro application is enabled
When I disable the repro application
Then the repro service should not be running
Scenario: Backup and restore repro
Given the repro application is enabled
And repro has been configured
When I create a backup of the repro app data
And I delete the repro configuration
And I export the repro app data backup
And I restore the repro app data backup
Then the repro configuration should be restored
......@@ -199,3 +199,33 @@ def set_mediawiki_admin_password(browser):
@when(parsers.parse('I disable message archive management'))
def set_mediawiki_admin_password(browser):
application.disable_ejabberd_message_archive_management(browser)
@when('there is an ikiwiki wiki')
def ikiwiki_create_wiki_if_needed(browser):
application.ikiwiki_create_wiki_if_needed(browser)
@when('I delete the ikiwiki wiki')
def ikiwiki_delete_wiki(browser):
application.ikiwiki_delete_wiki(browser)
@then('the ikiwiki wiki should be restored')
def ikiwiki_should_exist(browser):
assert application.ikiwiki_wiki_exists(browser)
@given('I have added a contact to my roster')
def ejabberd_add_contact(browser):
application.ejabberd_add_contact(browser)
@when('I delete the contact from my roster')
def ejabberd_delete_contact(browser):
application.ejabberd_delete_contact(browser)
@then('I should have a contact on my roster')
def ejabberd_should_have_contact(browser):
assert application.ejabberd_has_contact(browser)
......@@ -15,7 +15,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from pytest_bdd import parsers, then, when
from pytest_bdd import given, parsers, then, when
from support import site
......@@ -86,3 +86,28 @@ def mediawiki_does_not_allow__account_creation_anonymous_reads_edits(browser):
'with credentials {username:w} and {password:w}'))
def login_to_mediawiki_with_credentials(browser, username, password):
site.login_to_mediawiki_with_credentials(browser, username, password)
@when('I delete the mediawiki main page')
def mediawiki_delete_main_page(browser):
site.mediawiki_delete_main_page(browser)
@then('the mediawiki main page should be restored')
def mediawiki_verify_text(browser):
assert site.mediawiki_has_main_page(browser)
@given('repro has been configured')
def repro_configure(browser):
site.repro_configure(browser)
@when('I delete the repro configuration')
def repro_delete_config(browser):
site.repro_delete_config(browser)
@then('the repro configuration should be restored')
def repro_is_configured(browser):
assert site.repro_is_configured(browser)
......@@ -100,3 +100,33 @@ def verify_snapshot_count(browser, count):
@then(parsers.parse('the default app should be {app_name:w}'))
def default_app_should_be(browser, app_name):
assert system.check_home_page_redirect(browser, app_name)
@given('dynamicdns is configured')
def dynamicdns_configure(browser):
system.dynamicdns_configure(browser)
@when('I change the dynamicdns configuration')
def dynamicdns_change_config(browser):
system.dynamicdns_change_config(browser)
@then('dynamicdns should have the original configuration')
def dynamicdns_has_original_config(browser):
assert system.dynamicdns_has_original_config(browser)
@when(parsers.parse('I create a backup of the {app_name:w} app data'))
def backup_create(browser, app_name):
system.backup_create(browser, app_name)
@when(parsers.parse('I export the {app_name:w} app data backup'))
def backup_export(browser, app_name):
system.backup_export(browser, app_name)
@when(parsers.parse('I restore the {app_name:w} app data backup'))
def backup_restore(browser, app_name):
system.backup_restore(browser, app_name)
......@@ -19,7 +19,7 @@ from time import sleep
import splinter
from support import config, interface
from support import config, interface, site
from support.interface import submit
from support.service import eventually, wait_for_page_update
......@@ -267,3 +267,50 @@ def disable_ejabberd_message_archive_management(browser):
interface.nav_to_module(browser, 'ejabberd')
_change_status(browser, 'ejabberd', 'disabled',
checkbox_id='id_MAM_enabled')
def ejabberd_add_contact(browser):
"""Add a contact to Ejabberd user's roster."""
site.jsxc_add_contact(browser)
def ejabberd_delete_contact(browser):
"""Delete the contact from Ejabberd user's roster."""
site.jsxc_delete_contact(browser)
def ejabberd_has_contact(browser):
"""Check whether the contact is in Ejabberd user's roster."""
return site.jsxc_has_contact(browser)
def ikiwiki_create_wiki_if_needed(browser):
"""Create wiki if it does not exist."""
interface.nav_to_module(browser, 'ikiwiki')
browser.find_link_by_href('/plinth/apps/ikiwiki/manage/').first.click()
wiki = browser.find_link_by_href('/ikiwiki/wiki')
if not wiki:
browser.find_link_by_href('/plinth/apps/ikiwiki/create/').first.click()
browser.find_by_id('id_ikiwiki-name').fill('wiki')
browser.find_by_id('id_ikiwiki-admin_name').fill(
config['DEFAULT']['username'])
browser.find_by_id('id_ikiwiki-admin_password').fill(
config['DEFAULT']['password'])
submit(browser)
def ikiwiki_delete_wiki(browser):
"""Delete wiki."""
interface.nav_to_module(browser, 'ikiwiki')
browser.find_link_by_href('/plinth/apps/ikiwiki/manage/').first.click()
browser.find_link_by_href(
'/plinth/apps/ikiwiki/wiki/delete/').first.click()
submit(browser)
def ikiwiki_wiki_exists(browser):
"""Check whether the wiki exists."""
interface.nav_to_module(browser, 'ikiwiki')
browser.find_link_by_href('/plinth/apps/ikiwiki/manage/').first.click()
wiki = browser.find_link_by_href('/ikiwiki/wiki')
return bool(wiki)
......@@ -20,9 +20,9 @@ from support import config
from .service import wait_for_page_update
sys_modules = [
'avahi', 'cockpit', 'config', 'datetime', 'diagnostics', 'firewall',
'letsencrypt', 'monkeysphere', 'names', 'networks', 'power', 'snapshot',
'upgrades', 'users'
'avahi', 'backups', 'cockpit', 'config', 'datetime', 'diagnostics',
'dynamicdns', 'firewall', 'letsencrypt', 'monkeysphere', 'names',
'networks', 'power', 'snapshot', 'upgrades', 'users'
]
default_url = config['DEFAULT']['url']
......
......@@ -21,9 +21,10 @@ from time import sleep
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.keys import Keys
from support import config, interface
from support import application, config, interface, system
from support.service import eventually, wait_for_page_update
# unlisted sites just use '/' + site_name as url
site_url = {
'wiki': '/ikiwiki',
......@@ -123,3 +124,92 @@ def get_uploaded_image_in_mediawiki(browser, image):
browser.visit(config['DEFAULT']['url'] + '/mediawiki/Special:ListFiles')
elements = browser.find_link_by_partial_href(image)
return elements[0].value
def mediawiki_delete_main_page(browser):
"""Delete the mediawiki main page."""
_login_to_mediawiki(browser, 'admin', 'whatever123')
browser.visit(
'{}/mediawiki/index.php?title=Main_Page&action=delete'.format(
interface.default_url))
with wait_for_page_update(browser):
browser.find_by_id('wpConfirmB').first.click()
def mediawiki_has_main_page(browser):
"""Check if mediawiki main page exists."""
return eventually(_mediawiki_has_main_page, [browser])
def _mediawiki_has_main_page(browser):
"""Check if mediawiki main page exists."""
browser.visit('{}/mediawiki/Main_Page'.format(interface.default_url))
content = browser.find_by_id('mw-content-text').first
return 'This page has been deleted.' not in content.text
def repro_configure(browser):
"""Configure repro."""
browser.visit(
'{}/repro/domains.html?domainUri=freedombox.local&domainTlsPort='
'&action=Add'.format(interface.default_url))
def repro_delete_config(browser):
"""Delete the repro config."""
browser.visit('{}/repro/domains.html?domainUri=&domainTlsPort='
'&action=Remove&remove.freedombox.local=on'.format(
interface.default_url))
def repro_is_configured(browser):
"""Check whether repro is configured."""
return eventually(_repro_is_configured, [browser])
def _repro_is_configured(browser):
"""Check whether repro is configured."""
browser.visit('{}/repro/domains.html'.format(interface.default_url))
remove = browser.find_by_name('remove.freedombox.local')
return bool(remove)
def jsxc_login(browser):
"""Login to JSXC."""
access_url(browser, 'jsxc')
browser.find_by_id('jsxc-username').fill(config['DEFAULT']['username'])
browser.find_by_id('jsxc-password').fill(config['DEFAULT']['password'])
browser.find_by_id('jsxc-submit').click()
relogin = browser.find_by_text('relogin')
if relogin:
relogin.first.click()
browser.find_by_id('jsxc_username').fill(config['DEFAULT']['username'])
browser.find_by_id('jsxc_password').fill(config['DEFAULT']['password'])
browser.find_by_text('Connect').first.click()
def jsxc_add_contact(browser):
"""Add a contact to JSXC user's roster."""
system.set_domain_name(browser, 'localhost')
application.install(browser, 'jsxc')
jsxc_login(browser)
new = browser.find_by_text('new contact')
if new: # roster is empty
new.first.click()
browser.find_by_id('jsxc_username').fill('alice@localhost')
browser.find_by_text('Add').first.click()
def jsxc_delete_contact(browser):
"""Delete the contact from JSXC user's roster."""
jsxc_login(browser)
browser.find_by_css('div.jsxc_more').first.click()
browser.find_by_text('delete contact').first.click()
browser.find_by_text('Remove').first.click()
def jsxc_has_contact(browser):
"""Check whether the contact is in JSXC user's roster."""
jsxc_login(browser)
contact = browser.find_by_text('alice@localhost')
return bool(contact)
......@@ -15,9 +15,9 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from support import config
from support import application, config
from .interface import nav_to_module, submit
from .interface import default_url, nav_to_module, submit
config_page_title_language_map = {
'da': 'Generel Konfiguration',
......@@ -99,3 +99,88 @@ def check_home_page_redirect(browser, app_name):
browser.visit(config['DEFAULT']['url'])
return browser.find_by_xpath(
"//a[contains(@href, '/plinth/') and @title='FreedomBox']")
def dynamicdns_configure(browser):
nav_to_module(browser, 'dynamicdns')
browser.find_link_by_href(
'/plinth/sys/dynamicdns/configure/').first.click()
browser.find_by_id('id_enabled').check()
browser.find_by_id('id_service_type').select('GnuDIP')
browser.find_by_id('id_dynamicdns_server').fill('example.com')
browser.find_by_id('id_dynamicdns_domain').fill('freedombox.example.com')
browser.find_by_id('id_dynamicdns_user').fill('tester')
browser.find_by_id('id_dynamicdns_secret').fill('testingtesting')
browser.find_by_id('id_dynamicdns_ipurl').fill(
'http://myip.datasystems24.de')
submit(browser)
def dynamicdns_has_original_config(browser):
nav_to_module(browser, 'dynamicdns')
browser.find_link_by_href(
'/plinth/sys/dynamicdns/configure/').first.click()
enabled = browser.find_by_id('id_enabled').value
service_type = browser.find_by_id('id_service_type').value
server = browser.find_by_id('id_dynamicdns_server').value
domain = browser.find_by_id('id_dynamicdns_domain').value
user = browser.find_by_id('id_dynamicdns_user').value
ipurl = browser.find_by_id('id_dynamicdns_ipurl').value
if enabled and service_type == 'GnuDIP' and server == 'example.com' \
and domain == 'freedombox.example.com' and user == 'tester' \
and ipurl == 'http://myip.datasystems24.de':
return True
else:
return False
def dynamicdns_change_config(browser):
nav_to_module(browser, 'dynamicdns')
browser.find_link_by_href(
'/plinth/sys/dynamicdns/configure/').first.click()
browser.find_by_id('id_enabled').check()
browser.find_by_id('id_service_type').select('GnuDIP')
browser.find_by_id('id_dynamicdns_server').fill('2.example.com')
browser.find_by_id('id_dynamicdns_domain').fill('freedombox2.example.com')
browser.find_by_id('id_dynamicdns_user').fill('tester2')
browser.find_by_id('id_dynamicdns_secret').fill('testingtesting2')
browser.find_by_id('id_dynamicdns_ipurl').fill(
'http://myip2.datasystems24.de')
submit(browser)
def backup_create(browser, app_name):
browser.visit(default_url)
application.install(browser, 'backups')
delete = browser.find_link_by_href(
'/plinth/sys/backups/delete/_functional_test_' + app_name + '/')
if delete:
delete.first.click()
submit(browser)
browser.find_link_by_href('/plinth/sys/backups/create/').first.click()
browser.find_by_id('id_backups-name').fill('_functional_test_' + app_name)
for app in browser.find_by_css('input[type=checkbox]'):
app.uncheck()
browser.find_by_value(app_name).first.check()
submit(browser)
def backup_export(browser, app_name):
browser.visit(default_url)
nav_to_module(browser, 'backups')
browser.find_link_by_href(
'/plinth/sys/backups/export/_functional_test_'
+ app_name + '/').first.click()
browser.find_by_id('id_backups-disk_0').first.check()
submit(browser)
def backup_restore(browser, app_name):
browser.visit(default_url)
nav_to_module(browser, 'backups')
browser.find_link_by_href(
'/plinth/sys/backups/restore/Root%2520Filesystem/_functional_test_'
+ app_name + '.tar.gz/').first.click()
submit(browser)
......@@ -18,24 +18,33 @@
FreedomBox app to manage backup archives.
"""
import errno
import json
import os
from django.utils.text import get_valid_filename
from django.utils.translation import ugettext_lazy as _
from plinth import actions
from plinth.menu import main_menu
from plinth.modules import udiskie
from .backups import backup_apps, restore_apps
version = 1
managed_packages = ['borgbackup']
name = _('Backups')
description = [_('Backups allows creating and managing backup archives.'), ]
description = [
_('Backups allows creating and managing backup archives.'),
]
service = None
MANIFESTS_FOLDER = '/var/lib/plinth/backups-manifests/'
def init():
"""Intialize the module."""
......@@ -67,9 +76,29 @@ def get_archive(name):
return None
def create_archive(name, path):
actions.superuser_run('backups',
['create', '--name', name, '--path', path])
def _backup_handler(packet):
"""Performs backup operation on packet."""
if not os.path.exists(MANIFESTS_FOLDER):
os.makedirs(MANIFESTS_FOLDER)
manifest_path = os.path.join(MANIFESTS_FOLDER,
get_valid_filename(packet.label) + '.json')
manifests = [{
'name': manifest[0],
'version': manifest[1].version,
'backup': manifest[2]
} for manifest in packet.manifests]
with open(manifest_path, 'w') as manifest_file:
json.dump(manifests, manifest_file)
paths = packet.directories + packet.files
paths.append(manifest_path)
actions.superuser_run(
'backups', ['create', '--name', packet.label, '--paths'] + paths)
def create_archive(name, app_names):
backup_apps(_backup_handler, app_names, name)
def delete_archive(name):
......@@ -79,7 +108,9 @@ def delete_archive(name):
def export_archive(name, location):
if location[-1] != '/':
location += '/'
filename = location + 'FreedomBox-backups/' + name + '.tar.gz'
filename = location + 'FreedomBox-backups/' + get_valid_filename(
name) + '.tar.gz'
actions.superuser_run('backups',
['export', '--name', name, '--filename', filename])
......@@ -103,21 +134,41 @@ def get_export_files():
export_files = {}
for location in locations:
output = actions.superuser_run(
'backups', ['list-exports', '--location', location[0]])
'backups', ['list-exports', '--location', location[0]])
export_files[location[1]] = json.loads(output)
return export_files
def restore_exported(label, name):
"""Restore files from exported backup archive."""
def find_exported_archive(disk_label, archive_name):
"""Return the full path for the exported archive file."""
locations = get_export_locations()
for location in locations:
if location[1] == label:
filename = location[0]
if filename[-1] != '/':
filename += '/'
filename += 'FreedomBox-backups/' + name
actions.superuser_run(
'backups', ['restore', '--filename', filename])
break
if location[1] == disk_label:
return os.path.join(location[0], 'FreedomBox-backups',
archive_name)
raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT),
archive_name)
def get_export_apps(filename):
"""Get list of apps included in exported archive file."""
output = actions.superuser_run('backups',
['get-export-apps', '--filename', filename])
return output.splitlines()
def _restore_handler(packet):
"""Perform restore operation on packet."""
locations = {'directories': packet.directories, 'files': packet.files}
locations_data = json.dumps(locations)
actions.superuser_run('backups', ['restore', '--filename', packet.label],
input=locations_data.encode())
def restore_exported(label, archive_name, apps=None):
"""Restore files from exported backup archive."""
filename = find_exported_archive(label, archive_name)
restore_apps(_restore_handler, app_names=apps, create_subvolume=False,
backup_file=filename)
......@@ -30,10 +30,40 @@ import collections
from plinth import actions, action_utils, module_loader
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)
class Packet:
"""Information passed to a handlers for backup/restore operations."""
def __init__(self, operation, scope, root, manifests=None):
def __init__(self, operation, scope, root, manifests=None, label=None):
"""Initialize the packet.
operation is either 'backup' or 'restore.
......@@ -50,6 +80,7 @@ class Packet:
self.scope = scope
self.root = root
self.manifests = manifests
self.label = label
self.directories = []
self.files = []
......@@ -58,11 +89,14 @@ class Packet:
def _process_manifests(self):
"""Look at manifests and fill up the list of directories/files."""
# XXX:
pass
for manifest in self.manifests:
backup = manifest[2]
for section in ['config', 'data', 'secrets']:
self.directories += backup[section]['directories']
self.files += backup[section]['files']
def backup_full(backup_handler):
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.')
......@@ -70,7 +104,7 @@ def backup_full(backup_handler):
snapshot = _take_snapshot()
backup_root = snapshot['mount_path']
packet = Packet('backup', 'full', backup_root)
packet = Packet('backup', 'full', backup_root, label)
_run_operation(backup_handler, packet)
_delete_snapshot(snapshot)
......@@ -89,10 +123,10 @@ def restore_full(restore_handler):
_switch_to_subvolume(subvolume)
def backup_apps(backup_handler, app_names=None):
def backup_apps(backup_handler, app_names=None, label=None):
"""Backup data belonging to a set of applications."""
if not app_names:
apps = _list_of_all_apps_for_backup()
apps = get_all_apps_for_backup()
else:
apps = _get_apps_in_order(app_names)
......@@ -108,7 +142,7 @@ def backup_apps(backup_handler, app_names=None):
backup_root = '/'
snapshotted = False
packet = Packet('backup', 'apps', backup_root, manifests)
packet = Packet('backup', 'apps', backup_root, manifests, label)
_run_operation(backup_handler, packet)
if snapshotted:
......@@ -118,10 +152,11 @@ def backup_apps(backup_handler, app_names=None):
_lockdown_apps(apps, lockdown=False)
def restore_apps(restore_handler, app_names=None, create_subvolume=True):
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 = _list_of_all_apps_for_backup()
apps = get_all_apps_for_backup()
else:
apps = _get_apps_in_order(app_names)
......@@ -137,7 +172,7 @@ def restore_apps(restore_handler, app_names=None, create_subvolume=True):
restore_root = '/'
subvolume = False
packet = Packet('restore', 'apps', restore_root, manifests)
packet = Packet('restore', 'apps', restore_root, manifests, backup_file)
_run_operation(restore_handler, packet)
if subvolume:
......@@ -147,10 +182,10 @@ def restore_apps(restore_handler, app_names=None, create_subvolume=True):
_lockdown_apps(apps, lockdown=False)
def _list_of_all_apps_for_backup():
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.values():
for module_name, module in module_loader.loaded_modules.items():
# Not installed
if module.setup_helper.get_state() == 'needs-setup':
continue
......@@ -167,7 +202,7 @@ def _list_of_all_apps_for_backup():
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.values():
for module_name, module in module_loader.loaded_modules.items():
if module_name in app_names:
apps.append((module_name, module))
......@@ -261,10 +296,11 @@ def _shutdown_services(manifests):
state[service] = {'app_name': app_name, 'app': app}
for service in state:
state['was_running'] = action_utils.service_is_running('service')
state[service]['was_running'] = action_utils.service_is_running(
service)
for service in reversed(state):
if service['was_running']:
if state[service]['was_running']:
actions.superuser_run('service', ['stop', service])
return state
......@@ -276,7 +312,7 @@ def _restore_services(original_state):
Maintain exact order of services so dependencies are satisfied.
"""
for service in original_state:
if service['was_running']:
if original_state[service]['was_running']:
actions.superuser_run('service', ['start', service])
......
......@@ -22,6 +22,7 @@ from django import forms
from django.core import validators
from django.utils.translation import ugettext_lazy as _
from . import backups as backups_api
from . import get_export_locations
......@@ -32,9 +33,18 @@ class CreateArchiveForm(forms.Form):
validators.RegexValidator(r'^[^/]+$', _('Invalid archive name'))
])
path = forms.CharField(label=_('Path'), strip=True, help_text=_(
'Disk path to a folder on this server that will be archived into '
'backup repository.'))
selected_apps = forms.MultipleChoiceField(
label=_('Included apps'),
help_text=_('Apps to include in the backup'),
widget=forms.CheckboxSelectMultiple)
def __init__(self, *args, **kwargs):
"""Initialize the form with selectable apps."""
super().__init__(*args, **kwargs)
apps = backups_api.get_all_apps_for_backup()
self.fields['selected_apps'].choices = [
(app[0], app[1].name) for app in apps]
self.fields['selected_apps'].initial = [app[0] for app in apps]
class ExportArchiveForm(forms.Form):
......@@ -47,3 +57,18 @@ class ExportArchiveForm(forms.Form):
"""Initialize the form with disk choices."""
super().__init__(*args, **kwargs)
self.fields['disk'].choices = get_export_locations()
class RestoreForm(forms.Form):
selected_apps = forms.MultipleChoiceField(
label=_('Restore apps'),
help_text=_('Apps data to restore from the backup'),
widget=forms.CheckboxSelectMultiple)
def __init__(self, *args, **kwargs):
"""Initialize the form with selectable apps."""
apps = kwargs.pop('apps')
super().__init__(*args, **kwargs)
self.fields['selected_apps'].choices = [
(app[0], app[1].name) for app in apps]
self.fields['selected_apps'].initial = [app[0] for app in apps]
......@@ -39,13 +39,29 @@
<p>{{ paragraph|safe }}</p>
{% endfor %}
<p>
<a title="{% trans 'Create archive' %}"
role="button" class="btn btn-primary"
href="{% url 'backups:create' %}">
{% trans 'Create archive' %}
</a>
</p>
{% if available_apps %}
<p>
<a title="{% trans 'New backup' %}"
role="button" class="btn btn-primary"
href="{% url 'backups:create' %}">
{% trans 'New backup' %}
</a>
</p>
{% else %}
<p>
<a title="{% trans 'New backup' %}"
role="button" class="btn btn-primary disabled"
href="{% url 'backups:create' %}">
{% trans 'New backup' %}
</a>
</p>
<p>
{% blocktrans trimmed %}
No apps that support backup are currently installed. Backup can be
created after an app supporting backups is installed.
{% endblocktrans %}
</p>
{% endif %}
<h3>{% trans 'Backup archives' %}</h3>
{% if not archives %}
......@@ -56,7 +72,6 @@
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Time" %}</th>
<th></th>
</tr>
</thead>
......@@ -65,7 +80,6 @@
{% for archive in archives %}
<tr id="archive-{{ archive.name }}" class="archive">
<td class="archive-name">{{ archive.name }}</td>
<td class="archive-time">{{ archive.time }}</td>
<td class="archive-operations">
<a class="archive-export btn btn-sm btn-default"
href="{% url 'backups:export' archive.name %}">
......
......@@ -47,6 +47,8 @@
<form class="form" method="post">
{% csrf_token %}
{{ form|bootstrap }}
<input type="submit" class="btn btn-danger"
value="{% blocktrans trimmed %}
Restore data from {{ name }}
......
#
# 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/>.
#
"""
Tests for backups module.
"""
import collections
import unittest
from unittest.mock import call, patch, MagicMock
from plinth.module_loader import load_modules
from ..backups import validate, Packet, backup_apps, restore_apps, \
get_all_apps_for_backup, _get_apps_in_order, _get_manifests, \
_lockdown_apps, _shutdown_services, _restore_services
def _get_test_manifest(name):
return validate({
'config': {
'directories': ['/etc/' + name + '/config.d/'],
'files': ['/etc/' + name + '/config'],
},
'data': {
'directories': ['/var/lib/' + name + '/data.d/'],
'files': ['/var/lib/' + name + '/data'],
},
'secrets': {
'directories': ['/etc/' + name + '/secrets.d/'],
'files': ['/etc/' + name + '/secrets'],
},
'services': [name]
})
class TestBackups(unittest.TestCase):
"""Test cases for backups module."""
def test_packet_process_manifests(self):
"""Test that directories/files are collected from manifests."""
manifests = [
('a', None, _get_test_manifest('a')),
('b', None, _get_test_manifest('b')),
]
packet = Packet('backup', 'apps', '/', manifests)
for manifest in manifests:
backup = manifest[2]
for section in ['config', 'data', 'secrets']:
for directory in backup[section]['directories']:
assert directory in packet.directories
for file_path in backup[section]['files']:
assert file_path in packet.files
def test_backup_apps(self):
"""Test that backup_handler is called."""
backup_handler = MagicMock()
backup_apps(backup_handler)
backup_handler.assert_called_once()
def test_restore_apps(self):
"""Test that restore_handler is called."""
restore_handler = MagicMock()
restore_apps(restore_handler)
restore_handler.assert_called_once()
def test_get_all_apps_for_backups(self):
"""Test that apps supporting backup are included in returned list."""
load_modules()
apps = get_all_apps_for_backup()
assert isinstance(apps, list)
# apps may be empty, if no apps supporting backup are installed.
def test__get_apps_in_order(self):
"""Test that apps are listed in correct dependency order."""
load_modules()
app_names = ['config', 'names']
apps = _get_apps_in_order(app_names)
ordered_app_names = [app[0] for app in apps]
names_index = ordered_app_names.index('names')
config_index = ordered_app_names.index('config')
assert names_index < config_index
def test__get_manifests(self):
"""Test that manifests are collected from the apps."""
a = MagicMock(backup=_get_test_manifest('a'))
b = MagicMock(backup=_get_test_manifest('b'))
apps = [
('a', a),
('b', b),
]
manifests = _get_manifests(apps)
assert ('a', a, a.backup) in manifests
assert ('b', b, b.backup) in manifests
def test__lockdown_apps(self):
"""Test that locked flag is set for each app."""
a = MagicMock(locked=False)
b = MagicMock(locked=None)
apps = [
('a', a),
('b', b),
]
_lockdown_apps(apps, True)
assert a.locked is True
assert b.locked is True
@patch('plinth.action_utils.service_is_running')
@patch('plinth.actions.superuser_run')
def test__shutdown_services(self, run, is_running):
"""Test that services are stopped in correct order."""
manifests = [
('a', None, _get_test_manifest('a')),
('b', None, _get_test_manifest('b')),
]
is_running.return_value = True
state = _shutdown_services(manifests)
assert 'a' in state
assert 'b' in state
is_running.assert_any_call('a')
is_running.assert_any_call('b')
calls = [
call('service', ['stop', 'b']),
call('service', ['stop', 'a'])
]
run.assert_has_calls(calls)
@patch('plinth.actions.superuser_run')
def test__restore_services(self, run):
"""Test that services are restored in correct order."""
original_state = collections.OrderedDict()
original_state['a'] = {
'app_name': 'a', 'app': None, 'was_running': True}
original_state['b'] = {
'app_name': 'b', 'app': None, 'was_running': False}
_restore_services(original_state)
run.assert_called_once_with('service', ['start', 'a'])
......@@ -18,6 +18,9 @@
Views for the backups app.
"""
from datetime import datetime
from urllib.parse import unquote
from django.contrib import messages
from django.contrib.messages.views import SuccessMessageMixin
from django.http import Http404
......@@ -25,11 +28,11 @@ from django.shortcuts import redirect
from django.urls import reverse_lazy
from django.utils.translation import ugettext as _
from django.views.generic import FormView, TemplateView
from urllib.parse import unquote
from plinth.modules import backups
from .forms import CreateArchiveForm, ExportArchiveForm
from . import backups as backups_api
from .forms import CreateArchiveForm, ExportArchiveForm, RestoreForm
class IndexView(TemplateView):
......@@ -44,6 +47,8 @@ class IndexView(TemplateView):
context['info'] = backups.get_info()
context['archives'] = backups.list_archives()
context['exports'] = backups.get_export_files()
apps = backups_api.get_all_apps_for_backup()
context['available_apps'] = [app[0] for app in apps]
return context
......@@ -58,13 +63,20 @@ class CreateArchiveView(SuccessMessageMixin, FormView):
def get_context_data(self, **kwargs):
"""Return additional context for rendering the template."""
context = super().get_context_data(**kwargs)
context['title'] = _('Create Archive')
context['title'] = _('New Backup')
return context
def get_initial(self):
"""Return the initial data to use for forms on this view."""
initial = super().get_initial()
initial['name'] = 'FreedomBox_backup_' + datetime.now().strftime(
'%Y-%m-%d:%H:%M')
return initial
def form_valid(self, form):
"""Create the archive on valid form submission."""
backups.create_archive(form.cleaned_data['name'],
form.cleaned_data['path'])
form.cleaned_data['selected_apps'])
return super().form_valid(form)
......@@ -109,14 +121,34 @@ class ExportArchiveView(SuccessMessageMixin, FormView):
def form_valid(self, form):
"""Create the archive on valid form submission."""
backups.export_archive(self.kwargs['name'],
form.cleaned_data['disk'])
backups.export_archive(self.kwargs['name'], form.cleaned_data['disk'])
return super().form_valid(form)
class RestoreView(SuccessMessageMixin, TemplateView):
class RestoreView(SuccessMessageMixin, FormView):
"""View to restore files from an exported archive."""
form_class = RestoreForm
prefix = 'backups'
template_name = 'backups_restore.html'
success_url = reverse_lazy('backups:index')
success_message = _('Restored files from backup.')
def _get_included_apps(self):
"""Save some data used to instantiate the form."""
label = unquote(self.kwargs['label'])
name = unquote(self.kwargs['name'])
filename = backups.find_exported_archive(label, name)
return backups.get_export_apps(filename)
def get_form_kwargs(self):
"""Pass additional keyword args for instantiating the form."""
kwargs = super().get_form_kwargs()
included_apps = self._get_included_apps()
installed_apps = backups_api.get_all_apps_for_backup()
kwargs['apps'] = [
app for app in installed_apps if app[0] in included_apps
]
return kwargs
def get_context_data(self, **kwargs):
"""Return additional context for rendering the template."""
......@@ -126,8 +158,9 @@ class RestoreView(SuccessMessageMixin, TemplateView):
context['name'] = self.kwargs['name']
return context
def post(self, request, label, name):
def form_valid(self, form):
"""Restore files from the archive on valid form submission."""
backups.restore_exported(label, name)
messages.success(request, _('Restored data from backup.'))
return redirect(reverse_lazy('backups:index'))
backups.restore_exported(
unquote(self.kwargs['label']), self.kwargs['name'],
form.cleaned_data['selected_apps'])
return super().form_valid(form)
......@@ -15,7 +15,9 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
backup = {
from plinth.modules.backups.backups import validate as validate_backup
backup = validate_backup({
'config': {
'directories': ['/etc/ez-ipupdate/'],
'files': [],
......@@ -29,4 +31,4 @@ backup = {
'files': [],
},
'services': []
}
})
......@@ -17,6 +17,7 @@
from django.utils.translation import ugettext_lazy as _
from plinth.modules.backups.backups import validate as validate_backup
from plinth.clients import store_url, validate
from plinth.modules.jsxc import manifest as jsxc_manifest
......@@ -121,7 +122,7 @@ _clients.extend(jsxc_manifest.clients)
clients = _clients
backup = {
backup = validate_backup({
'config': {
'directories': [],
'files': ['/etc/ejabberd/ejabberd.yml'],
......@@ -135,4 +136,4 @@ backup = {
'files': ['/etc/ejabberd/ejabberd.pem'],
},
'services': ['ejabberd']
}
})
......@@ -17,6 +17,7 @@
from django.utils.translation import ugettext_lazy as _
from plinth.modules.backups.backups import validate as validate_backup
from plinth.clients import validate
clients = validate([{
......@@ -27,13 +28,13 @@ clients = validate([{
}]
}])
backup = {
backup = validate_backup({
'config': {
'directories': [],
'files': [],
},
'data': {
'directories': ['/var/lib/ikiwiki/'],
'directories': ['/var/lib/ikiwiki/', '/var/www/ikiwiki/'],
'files': [],
},
'secrets': {
......@@ -41,4 +42,4 @@ backup = {
'files': [],
},
'services': []
}
})
......@@ -18,6 +18,7 @@
from django.utils.translation import ugettext_lazy as _
from plinth import cfg
from plinth.modules.backups.backups import validate as validate_backup
from plinth.clients import validate