mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-04-29 10:10:19 +00:00
Backups, remote repositories: implement init, info and some test
- added functionality to use remote repositories - added some tests Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
parent
4eeceaa695
commit
0e2489ec23
@ -17,7 +17,7 @@
|
|||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
"""
|
"""
|
||||||
Configuration helper for backups.
|
Wrapper to handle backups using borg-backups.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@ -41,6 +41,10 @@ def parse_arguments():
|
|||||||
'setup', help='Create repository if it does not already exist')
|
'setup', help='Create repository if it does not already exist')
|
||||||
info = subparsers.add_parser('info', help='Show repository information')
|
info = subparsers.add_parser('info', help='Show repository information')
|
||||||
info.add_argument('--repository', help='Repository path', required=True)
|
info.add_argument('--repository', help='Repository path', required=True)
|
||||||
|
init = subparsers.add_parser('init', help='Initialize a repository')
|
||||||
|
init.add_argument('--repository', help='Repository path', required=True)
|
||||||
|
init.add_argument('--encryption', help='Enryption of the repository',
|
||||||
|
required=True)
|
||||||
subparsers.add_parser('list', help='List repository contents')
|
subparsers.add_parser('list', help='List repository contents')
|
||||||
|
|
||||||
create = subparsers.add_parser('create', help='Create archive')
|
create = subparsers.add_parser('create', help='Create archive')
|
||||||
@ -51,10 +55,17 @@ def parse_arguments():
|
|||||||
delete = subparsers.add_parser('delete', help='Delete archive')
|
delete = subparsers.add_parser('delete', help='Delete archive')
|
||||||
delete.add_argument('--name', help='Archive name', required=True)
|
delete.add_argument('--name', help='Archive name', required=True)
|
||||||
|
|
||||||
export_help='Export archive contents as tar on stdout'
|
export_help = 'Export archive contents as tar on stdout'
|
||||||
export_tar = subparsers.add_parser('export-tar', help=export_help)
|
export_tar = subparsers.add_parser('export-tar', help=export_help)
|
||||||
export_tar.add_argument('--name', help='Archive name)',
|
export_tar.add_argument('--name', help='Archive name)',
|
||||||
required=True)
|
required=True)
|
||||||
|
# TODO: add parameters to missing commands (list, create, delete, export)
|
||||||
|
for cmd in [info, init]:
|
||||||
|
cmd.add_argument('--ssh-keyfile', help='Path of private ssh key',
|
||||||
|
default=None)
|
||||||
|
cmd.add_argument('--encryption-passphrase',
|
||||||
|
help='Encryption passphrase',
|
||||||
|
default=None)
|
||||||
|
|
||||||
get_exported_archive_apps = subparsers.add_parser(
|
get_exported_archive_apps = subparsers.add_parser(
|
||||||
'get-exported-archive-apps',
|
'get-exported-archive-apps',
|
||||||
@ -71,14 +82,14 @@ def parse_arguments():
|
|||||||
restore_exported_archive = subparsers.add_parser(
|
restore_exported_archive = subparsers.add_parser(
|
||||||
'restore-exported-archive',
|
'restore-exported-archive',
|
||||||
help='Restore files from an exported archive')
|
help='Restore files from an exported archive')
|
||||||
restore_exported_archive.add_argument('--path',
|
restore_exported_archive.add_argument('--path', help='Tarball file path',
|
||||||
help='Tarball file path', required=True)
|
required=True)
|
||||||
|
|
||||||
restore_archive = subparsers.add_parser(
|
restore_archive = subparsers.add_parser(
|
||||||
'restore-archive', help='Restore files from an archive')
|
'restore-archive', help='Restore files from an archive')
|
||||||
restore_archive.add_argument('--path', help='Archive path', required=True)
|
restore_archive.add_argument('--path', help='Archive path', required=True)
|
||||||
restore_archive.add_argument('--destination', help='Destination',
|
restore_archive.add_argument('--destination', help='Destination',
|
||||||
required=True)
|
required=True)
|
||||||
|
|
||||||
subparsers.required = True
|
subparsers.required = True
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
@ -93,34 +104,65 @@ def subcommand_setup(_):
|
|||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
os.makedirs(path)
|
os.makedirs(path)
|
||||||
|
|
||||||
subprocess.run(['borg', 'init', '--encryption', 'none', REPOSITORY])
|
run(['borg', 'init', '--encryption', 'none', REPOSITORY])
|
||||||
|
|
||||||
|
|
||||||
|
def init(repository, encryption, env=None):
|
||||||
|
"""Initialize a local or remote borg repository"""
|
||||||
|
# TODO: verify that the repository does not exist?
|
||||||
|
# TODO: does remote borg also create folders if they don't exist?
|
||||||
|
if encryption != 'none' and 'BORG_PASSPHRASE' not in env:
|
||||||
|
raise ValueError('No encryption passphrase provided')
|
||||||
|
cmd = ['borg', 'init', '--encryption', encryption, repository]
|
||||||
|
run(cmd, env=env)
|
||||||
|
|
||||||
|
|
||||||
|
def get_env(arguments):
|
||||||
|
"""Create encryption and ssh kwargs out of given arguments"""
|
||||||
|
env = dict(os.environ, BORG_RELOCATED_REPO_ACCESS_IS_OK='yes')
|
||||||
|
if arguments.encryption_passphrase:
|
||||||
|
env['BORG_PASSPHRASE'] = arguments.encryption_passphrase
|
||||||
|
if arguments.ssh_keyfile:
|
||||||
|
env['BORG_RSH'] = "ssh -i %s" % arguments.ssh_keyfile
|
||||||
|
else:
|
||||||
|
password = read_password()
|
||||||
|
if password:
|
||||||
|
env['SSHPASS'] = password
|
||||||
|
env['BORG_RSH'] = 'sshpass -e ssh -o StrictHostKeyChecking=no'
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def subcommand_init(arguments):
|
||||||
|
env = get_env(arguments)
|
||||||
|
init(arguments.repository, arguments.encryption, env=env)
|
||||||
|
|
||||||
|
|
||||||
def subcommand_info(arguments):
|
def subcommand_info(arguments):
|
||||||
"""Show repository information."""
|
"""Show repository information."""
|
||||||
run(['borg', 'info', '--json', arguments.repository])
|
env = get_env(arguments)
|
||||||
|
cmd = ['borg', 'info', '--json', arguments.repository]
|
||||||
|
run(cmd, env=env)
|
||||||
|
|
||||||
|
|
||||||
def subcommand_list(_):
|
def subcommand_list(_):
|
||||||
"""List repository contents."""
|
"""List repository contents."""
|
||||||
subprocess.run(['borg', 'list', '--json', REPOSITORY], check=True)
|
run(['borg', 'list', '--json', REPOSITORY])
|
||||||
|
|
||||||
|
|
||||||
def subcommand_create(arguments):
|
def subcommand_create(arguments):
|
||||||
"""Create archive."""
|
"""Create archive."""
|
||||||
paths = filter(os.path.exists, arguments.paths)
|
paths = filter(os.path.exists, arguments.paths)
|
||||||
subprocess.run([
|
run([
|
||||||
'borg',
|
'borg',
|
||||||
'create',
|
'create',
|
||||||
'--json',
|
'--json',
|
||||||
REPOSITORY + '::' + arguments.name,
|
REPOSITORY + '::' + arguments.name,
|
||||||
] + list(paths), check=True)
|
] + list(paths))
|
||||||
|
|
||||||
|
|
||||||
def subcommand_delete(arguments):
|
def subcommand_delete(arguments):
|
||||||
"""Delete archive."""
|
"""Delete archive."""
|
||||||
subprocess.run(['borg', 'delete', REPOSITORY + '::' + arguments.name],
|
run(['borg', 'delete', REPOSITORY + '::' + arguments.name])
|
||||||
check=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _extract(archive_path, destination, locations=None):
|
def _extract(archive_path, destination, locations=None):
|
||||||
@ -150,9 +192,7 @@ def _extract(archive_path, destination, locations=None):
|
|||||||
|
|
||||||
def subcommand_export_tar(arguments):
|
def subcommand_export_tar(arguments):
|
||||||
"""Export archive contents as tar stream on stdout."""
|
"""Export archive contents as tar stream on stdout."""
|
||||||
subprocess.run([
|
run(['borg', 'export-tar', REPOSITORY + '::' + arguments.name, '-'])
|
||||||
'borg', 'export-tar', REPOSITORY + '::' + arguments.name, '-'],
|
|
||||||
check=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _read_archive_file(archive, filepath):
|
def _read_archive_file(archive, filepath):
|
||||||
@ -165,7 +205,7 @@ def subcommand_get_archive_apps(arguments):
|
|||||||
"""Get list of apps included in archive."""
|
"""Get list of apps included in archive."""
|
||||||
manifest_folder = os.path.relpath(MANIFESTS_FOLDER, '/')
|
manifest_folder = os.path.relpath(MANIFESTS_FOLDER, '/')
|
||||||
borg_call = ['borg', 'list', arguments.path, manifest_folder,
|
borg_call = ['borg', 'list', arguments.path, manifest_folder,
|
||||||
'--format', '{path}{NEWLINE}']
|
'--format', '{path}{NEWLINE}']
|
||||||
try:
|
try:
|
||||||
manifest_path = subprocess.check_output(borg_call).decode().strip()
|
manifest_path = subprocess.check_output(borg_call).decode().strip()
|
||||||
except subprocess.CalledProcessError:
|
except subprocess.CalledProcessError:
|
||||||
@ -173,8 +213,7 @@ def subcommand_get_archive_apps(arguments):
|
|||||||
|
|
||||||
manifest = None
|
manifest = None
|
||||||
if manifest_path:
|
if manifest_path:
|
||||||
manifest_data = _read_archive_file(arguments.path,
|
manifest_data = _read_archive_file(arguments.path, manifest_path)
|
||||||
manifest_path)
|
|
||||||
manifest = json.loads(manifest_data)
|
manifest = json.loads(manifest_data)
|
||||||
if manifest:
|
if manifest:
|
||||||
for app in _get_apps_of_manifest(manifest):
|
for app in _get_apps_of_manifest(manifest):
|
||||||
@ -246,17 +285,12 @@ def read_password():
|
|||||||
return ''.join(sys.stdin)
|
return ''.join(sys.stdin)
|
||||||
|
|
||||||
|
|
||||||
def run(cmd):
|
def run(cmd, env=None):
|
||||||
"""Pass provided passwords on to borg"""
|
"""Wrap the command with ssh password or keyfile authentication"""
|
||||||
env = dict(os.environ, BORG_RELOCATED_REPO_ACCESS_IS_OK='yes')
|
# If the remote server asks for a password but no password is
|
||||||
password = read_password()
|
# provided, we get stuck at asking the password.
|
||||||
timeout = None
|
timeout = None
|
||||||
if password:
|
if 'BORG_RSH' in env and 'SSHPASS' not in env:
|
||||||
env['SSHPASS'] = password
|
|
||||||
env['BORG_RSH'] = 'sshpass -e ssh -o StrictHostKeyChecking=no'
|
|
||||||
else:
|
|
||||||
# When no password is given ssh might ask for a password and get stuck.
|
|
||||||
# Use timeout to abort early.
|
|
||||||
timeout = TIMEOUT
|
timeout = TIMEOUT
|
||||||
subprocess.run(cmd, check=True, env=env, timeout=timeout)
|
subprocess.run(cmd, check=True, env=env, timeout=timeout)
|
||||||
|
|
||||||
|
|||||||
@ -26,6 +26,8 @@ from django.utils.translation import ugettext_lazy as _
|
|||||||
|
|
||||||
from plinth import actions
|
from plinth import actions
|
||||||
from plinth.menu import main_menu
|
from plinth.menu import main_menu
|
||||||
|
from plinth.errors import ActionError
|
||||||
|
from .errors import BorgError, BorgRepositoryDoesNotExistError
|
||||||
|
|
||||||
from . import api
|
from . import api
|
||||||
|
|
||||||
@ -45,6 +47,23 @@ MANIFESTS_FOLDER = '/var/lib/plinth/backups-manifests/'
|
|||||||
REPOSITORY = '/var/lib/freedombox/borgbackup'
|
REPOSITORY = '/var/lib/freedombox/borgbackup'
|
||||||
# session variable name that stores when a backup file should be deleted
|
# session variable name that stores when a backup file should be deleted
|
||||||
SESSION_PATH_VARIABLE = 'fbx-backups-upload-path'
|
SESSION_PATH_VARIABLE = 'fbx-backups-upload-path'
|
||||||
|
# known errors that come up when remotely accessing a borg repository
|
||||||
|
# 'errors' are error strings to look for in the stacktrace.
|
||||||
|
KNOWN_ERRORS = [{
|
||||||
|
"errors": ["subprocess.TimeoutExpired"],
|
||||||
|
"message": _("Server not reachable - try providing a password."),
|
||||||
|
"raise_as": BorgError,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"errors": ["Connection refused"],
|
||||||
|
"message": _("Connection refused"),
|
||||||
|
"raise_as": BorgError,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"errors": ["not a valid repository", "does not exist"],
|
||||||
|
"message": _("Connection works - Repository does not exist"),
|
||||||
|
"raise_as": BorgRepositoryDoesNotExistError,
|
||||||
|
}]
|
||||||
|
|
||||||
|
|
||||||
def init():
|
def init():
|
||||||
@ -59,12 +78,17 @@ def setup(helper, old_version=None):
|
|||||||
helper.call('post', actions.superuser_run, 'backups', ['setup'])
|
helper.call('post', actions.superuser_run, 'backups', ['setup'])
|
||||||
|
|
||||||
|
|
||||||
def get_info(repository, password=None):
|
def get_info(repository, encryption_passphrase=None, ssh_password=None,
|
||||||
args = ['backups', ['info', '--repository', repository]]
|
ssh_keyfile=None):
|
||||||
|
args = ['info', '--repository', repository]
|
||||||
kwargs = {}
|
kwargs = {}
|
||||||
if password is not None:
|
if ssh_password is not None:
|
||||||
kwargs['input'] = password.encode()
|
kwargs['input'] = ssh_password.encode()
|
||||||
output = actions.superuser_run(*args, **kwargs)
|
if ssh_keyfile is not None:
|
||||||
|
args += ['--ssh-keyfile', ssh_keyfile]
|
||||||
|
if encryption_passphrase is not None:
|
||||||
|
args += ['--encryption-passphrase', encryption_passphrase]
|
||||||
|
output = actions.superuser_run('backups', args, **kwargs)
|
||||||
return json.loads(output)
|
return json.loads(output)
|
||||||
|
|
||||||
|
|
||||||
@ -81,6 +105,30 @@ def get_archive(name):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def test_connection(repository, encryption_passphrase=None, ssh_password=None,
|
||||||
|
ssh_keyfile=None):
|
||||||
|
"""
|
||||||
|
Test connecting to a local or remote borg repository.
|
||||||
|
Tries to detect (and throw) some known ssh or borg errors.
|
||||||
|
Returns 'borg info' information otherwise.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# TODO: instead of passing encryption_passphrase, ssh_password and
|
||||||
|
# ssh_keyfile around all the time, try using an 'options' dict.
|
||||||
|
message = get_info(repository,
|
||||||
|
encryption_passphrase=encryption_passphrase,
|
||||||
|
ssh_password=ssh_password, ssh_keyfile=ssh_keyfile)
|
||||||
|
return message
|
||||||
|
except ActionError as err:
|
||||||
|
caught_error = str(err)
|
||||||
|
for known_error in KNOWN_ERRORS:
|
||||||
|
for error in known_error["errors"]:
|
||||||
|
if error in caught_error:
|
||||||
|
raise known_error["raise_as"](known_error["message"])
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
def _backup_handler(packet):
|
def _backup_handler(packet):
|
||||||
"""Performs backup operation on packet."""
|
"""Performs backup operation on packet."""
|
||||||
if not os.path.exists(MANIFESTS_FOLDER):
|
if not os.path.exists(MANIFESTS_FOLDER):
|
||||||
@ -108,6 +156,24 @@ def create_archive(name, app_names):
|
|||||||
api.backup_apps(_backup_handler, app_names, name)
|
api.backup_apps(_backup_handler, app_names, name)
|
||||||
|
|
||||||
|
|
||||||
|
def create_repository(repository, encryption, encryption_passphrase=None,
|
||||||
|
ssh_keyfile=None, ssh_password=None):
|
||||||
|
cmd = ['init', '--repository', repository, '--encryption', encryption]
|
||||||
|
if ssh_keyfile:
|
||||||
|
cmd += ['--ssh-keyfile', ssh_keyfile]
|
||||||
|
if encryption_passphrase:
|
||||||
|
cmd += ['--encryption-passphrase', encryption_passphrase]
|
||||||
|
|
||||||
|
kwargs = {}
|
||||||
|
if ssh_password:
|
||||||
|
kwargs['input'] = ssh_password.encode()
|
||||||
|
|
||||||
|
output = actions.superuser_run('backups', cmd, **kwargs)
|
||||||
|
if output:
|
||||||
|
output = json.loads(output)
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
def delete_archive(name):
|
def delete_archive(name):
|
||||||
actions.superuser_run('backups', ['delete', '--name', name])
|
actions.superuser_run('backups', ['delete', '--name', name])
|
||||||
|
|
||||||
|
|||||||
28
plinth/modules/backups/errors.py
Normal file
28
plinth/modules/backups/errors.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
#
|
||||||
|
# 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/>.
|
||||||
|
#
|
||||||
|
|
||||||
|
from plinth.errors import PlinthError
|
||||||
|
|
||||||
|
|
||||||
|
class BorgError(PlinthError):
|
||||||
|
"""Generic class for borg errors that """
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class BorgRepositoryDoesNotExistError(BorgError):
|
||||||
|
"""Borg access to a repository works but the repository does not exist"""
|
||||||
|
pass
|
||||||
@ -88,13 +88,12 @@ class CreateRepositoryForm(forms.Form):
|
|||||||
repository = forms.CharField(
|
repository = forms.CharField(
|
||||||
label=_('SSH Repository Path'), strip=True,
|
label=_('SSH Repository Path'), strip=True,
|
||||||
help_text=_('Path of the new repository. Example: '
|
help_text=_('Path of the new repository. Example: '
|
||||||
'<i>user@host/path/to/repo/</i>'))
|
'<i>user@host:~/path/to/repo/</i>'))
|
||||||
ssh_password = forms.CharField(
|
ssh_password = forms.CharField(
|
||||||
label=_('SSH password'), strip=True,
|
label=_('SSH server password'), strip=True,
|
||||||
help_text=_('Password of the SSH Server.<br />'
|
help_text=_('Password of the SSH Server.<br />'
|
||||||
'If you have set up <a href="https://www.ssh.com/ssh/key/"'
|
'SSH key-based authentication is not yet possible.'),
|
||||||
'target="_blank">SSH key-based authentication</a> you can '
|
widget=forms.PasswordInput(),
|
||||||
'omit the password.'),
|
|
||||||
required=False)
|
required=False)
|
||||||
encryption = forms.ChoiceField(
|
encryption = forms.ChoiceField(
|
||||||
label=_('Encryption'),
|
label=_('Encryption'),
|
||||||
@ -104,33 +103,33 @@ class CreateRepositoryForm(forms.Form):
|
|||||||
'<b>You need this password to restore a backup!</b>')),
|
'<b>You need this password to restore a backup!</b>')),
|
||||||
choices=[('repokey', 'Key in Repository'), ('none', 'None')]
|
choices=[('repokey', 'Key in Repository'), ('none', 'None')]
|
||||||
)
|
)
|
||||||
passphrase = forms.CharField(
|
encryption_passphrase = forms.CharField(
|
||||||
label=_('Passphrase'),
|
label=_('Passphrase'),
|
||||||
help_text=_('Passphrase; Only needed when using encryption.'),
|
help_text=_('Passphrase; Only needed when using encryption.'),
|
||||||
widget=forms.PasswordInput(),
|
widget=forms.PasswordInput(),
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
confirm_passphrase = forms.CharField(
|
confirm_encryption_passphrase = forms.CharField(
|
||||||
label=_('Confirm Passphrase'),
|
label=_('Confirm Passphrase'),
|
||||||
help_text=_('Repeat the passphrase.'),
|
help_text=_('Repeat the passphrase.'),
|
||||||
widget=forms.PasswordInput(),
|
widget=forms.PasswordInput(),
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
store_passphrase = forms.BooleanField(
|
store_passwords = forms.BooleanField(
|
||||||
label=_('Store passphrase on FreedomBox'),
|
label=_('Store passwords on FreedomBox'),
|
||||||
help_text=format_lazy(
|
help_text=format_lazy(
|
||||||
_('Store the passphrase on your {box_name}.'
|
_('Store the passwords on your {box_name}.'
|
||||||
'<br />You need to store the passphrase if you want to run '
|
'<br />You need to store passwords if you want to run '
|
||||||
'recurrent backups.'), box_name=_(cfg.box_name)),
|
'recurrent backups.'), box_name=_(cfg.box_name)),
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
cleaned_data = super(CreateRepositoryForm, self).clean()
|
cleaned_data = super(CreateRepositoryForm, self).clean()
|
||||||
passphrase = cleaned_data.get("passphrase")
|
passphrase = cleaned_data.get("encryption_passphrase")
|
||||||
confirm_passphrase = cleaned_data.get("confirm_passphrase")
|
confirm_passphrase = cleaned_data.get("confirm_encryption_passphrase")
|
||||||
|
|
||||||
if passphrase != confirm_passphrase:
|
if passphrase != confirm_passphrase:
|
||||||
raise forms.ValidationError(
|
raise forms.ValidationError(
|
||||||
"passphrase and confirm_passphrase do not match"
|
"The entered encryption passphrases do not match"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -40,11 +40,12 @@ from django.utils.translation import ugettext_lazy
|
|||||||
from django.views.generic import View, FormView, TemplateView
|
from django.views.generic import View, FormView, TemplateView
|
||||||
|
|
||||||
from plinth import actions, kvstore
|
from plinth import actions, kvstore
|
||||||
from plinth.errors import PlinthError, ActionError
|
from plinth.errors import PlinthError
|
||||||
from plinth.modules import backups, storage
|
from plinth.modules import backups, storage
|
||||||
|
|
||||||
from . import api, forms, SESSION_PATH_VARIABLE, REPOSITORY
|
from . import api, forms, SESSION_PATH_VARIABLE, REPOSITORY
|
||||||
from .decorators import delete_tmp_backup_file
|
from .decorators import delete_tmp_backup_file
|
||||||
|
from .errors import BorgRepositoryDoesNotExistError
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -316,22 +317,41 @@ class CreateRepositoryView(SuccessMessageMixin, FormView):
|
|||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
"""Return additional context for rendering the template."""
|
"""Return additional context for rendering the template."""
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
context['title'] = _('Create new repository')
|
context['title'] = _('Create remote backup repository')
|
||||||
context['subsubmenu'] = subsubmenu
|
context['subsubmenu'] = subsubmenu
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
"""Restore files from the archive on valid form submission."""
|
"""Restore files from the archive on valid form submission."""
|
||||||
|
repository = form.cleaned_data['repository']
|
||||||
|
encryption = form.cleaned_data['encryption']
|
||||||
|
encryption_passphrase = form.cleaned_data['encryption_passphrase']
|
||||||
|
ssh_password = form.cleaned_data['ssh_password']
|
||||||
|
ssh_keyfile = form.cleaned_data['ssh_keyfile']
|
||||||
|
|
||||||
|
# TODO: create borg repository if it doesn't exist
|
||||||
|
import pdb; pdb.set_trace()
|
||||||
|
try:
|
||||||
|
backups.test_connection(repository, ssh_password)
|
||||||
|
except BorgRepositoryDoesNotExistError:
|
||||||
|
kwargs = {}
|
||||||
|
if encryption_passphrase:
|
||||||
|
kwargs['encryption_passphrase'] = encryption_passphrase
|
||||||
|
if ssh_keyfile:
|
||||||
|
kwargs['ssh_keyfile'] = ssh_keyfile
|
||||||
|
backups.create_repository(repository, encryption, **kwargs)
|
||||||
|
|
||||||
repositories = kvstore.get_default('backups_repositories', [])
|
repositories = kvstore.get_default('backups_repositories', [])
|
||||||
if repositories:
|
if repositories:
|
||||||
repositories = json.loads(repositories)
|
repositories = json.loads(repositories)
|
||||||
new_repo = {
|
new_repo = {
|
||||||
'uuid': str(uuid1()),
|
'uuid': str(uuid1()),
|
||||||
'repository': form.cleaned_data['repository'],
|
'repository': repository,
|
||||||
'encryption': form.cleaned_data['encryption'],
|
'encryption': encryption,
|
||||||
}
|
}
|
||||||
if form.cleaned_data['store_passphrase']:
|
if form.cleaned_data['store_passwords']:
|
||||||
new_repo['passphrase'] = form.cleaned_data['passphrase']
|
new_repo['encryption_passphrase'] = \
|
||||||
|
form.cleaned_data['encryption_passphrase']
|
||||||
repositories.append(new_repo)
|
repositories.append(new_repo)
|
||||||
kvstore.set('backups_repositories', json.dumps(repositories))
|
kvstore.set('backups_repositories', json.dumps(repositories))
|
||||||
return super().form_valid(form)
|
return super().form_valid(form)
|
||||||
@ -342,18 +362,12 @@ class TestRepositoryView(TemplateView):
|
|||||||
template_name = 'backups_test_repository.html'
|
template_name = 'backups_test_repository.html'
|
||||||
|
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
|
# TODO: add support for borg encryption
|
||||||
context = self.get_context_data()
|
context = self.get_context_data()
|
||||||
repository = request.POST['backups-repository']
|
repository = request.POST['backups-repository']
|
||||||
ssh_password = request.POST['backups-ssh_password']
|
ssh_password = request.POST['backups-ssh_password']
|
||||||
try:
|
(error, message) = backups.test_connection(repository, ssh_password)
|
||||||
info = backups.get_info(repository, password=ssh_password)
|
context["message"] = message
|
||||||
except ActionError as err:
|
context["error"] = error
|
||||||
if "subprocess.TimeoutExpired" in str(err):
|
|
||||||
msg = _("Server not reachable - try providing a password.")
|
|
||||||
context["error"] = msg
|
|
||||||
else:
|
|
||||||
context["error"] = str(err)
|
|
||||||
else:
|
|
||||||
context["message"] = info
|
|
||||||
|
|
||||||
return self.render_to_response(context)
|
return self.render_to_response(context)
|
||||||
|
|||||||
83
plinth/tests/test_backups.py
Normal file
83
plinth/tests/test_backups.py
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
#
|
||||||
|
# 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/>.
|
||||||
|
#
|
||||||
|
"""
|
||||||
|
Test the backups action script.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from plinth import cfg
|
||||||
|
from plinth.modules import backups
|
||||||
|
|
||||||
|
euid = os.geteuid()
|
||||||
|
|
||||||
|
|
||||||
|
class TestBackups(unittest.TestCase):
|
||||||
|
"""Test creating, reading and deleting a repository"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
"""Initial setup for all the classes."""
|
||||||
|
cls.action_directory = tempfile.TemporaryDirectory()
|
||||||
|
cls.backup_directory = tempfile.TemporaryDirectory()
|
||||||
|
cfg.actions_dir = cls.action_directory.name
|
||||||
|
actions_dir = os.path.join(os.path.dirname(__file__), '..', '..',
|
||||||
|
'actions')
|
||||||
|
shutil.copy(os.path.join(actions_dir, 'backups'), cfg.actions_dir)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
"""Cleanup after all the tests are completed."""
|
||||||
|
cls.action_directory.cleanup()
|
||||||
|
cls.backup_directory.cleanup()
|
||||||
|
|
||||||
|
@unittest.skipUnless(euid == 0, 'Needs to be root')
|
||||||
|
def test_nonexisting_repository(self):
|
||||||
|
nonexisting_dir = os.path.join(self.backup_directory.name,
|
||||||
|
'does_not_exist')
|
||||||
|
with self.assertRaises(backups.errors.BorgRepositoryDoesNotExistError):
|
||||||
|
backups.test_connection(nonexisting_dir)
|
||||||
|
|
||||||
|
@unittest.skipUnless(euid == 0, 'Needs to be root')
|
||||||
|
def test_empty_dir(self):
|
||||||
|
empty_dir = os.path.join(self.backup_directory.name, 'empty_dir')
|
||||||
|
os.mkdir(empty_dir)
|
||||||
|
with self.assertRaises(backups.errors.BorgRepositoryDoesNotExistError):
|
||||||
|
backups.test_connection(empty_dir)
|
||||||
|
|
||||||
|
@unittest.skipUnless(euid == 0, 'Needs to be root')
|
||||||
|
def test_create_unencrypted_repository(self):
|
||||||
|
repo_path = os.path.join(self.backup_directory.name, 'borgbackup')
|
||||||
|
backups.create_repository(repo_path, 'none')
|
||||||
|
info = backups.get_info(repo_path)
|
||||||
|
assert 'encryption' in info
|
||||||
|
|
||||||
|
@unittest.skipUnless(euid == 0, 'Needs to be root')
|
||||||
|
def test_create_encrypted_repository(self):
|
||||||
|
repo_path = os.path.join(self.backup_directory.name,
|
||||||
|
'borgbackup_encrypted')
|
||||||
|
passphrase = '12345'
|
||||||
|
# create_repository is supposed to create the folder automatically
|
||||||
|
# if it does not exist
|
||||||
|
backups.create_repository(repo_path, 'repokey',
|
||||||
|
encryption_passphrase=passphrase)
|
||||||
|
assert backups.get_info(repo_path, encryption_passphrase=passphrase)
|
||||||
|
assert backups.test_connection(repo_path,
|
||||||
|
encryption_passphrase=passphrase)
|
||||||
Loading…
x
Reference in New Issue
Block a user