backups: Handle errors when adding disk repository

- Simplify repository initialization using inheritance.

- Share the code for saving repository for disk and remote repositories. Error
  messages are properly handled for disk repositories too.

- Move logic to create remote SSH directory to SSH repository class instead of
  views.

- Create a new error for handling borg repository already existing while
  initialization.

- Get information of repository after initializing so that password errors are
  caught early.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
Sunil Mohan Adapa 2019-08-29 13:14:01 -07:00 committed by James Valleroy
parent 063587f7ac
commit 0465ce0efd
No known key found for this signature in database
GPG Key ID: 77C0C75E7B650808
3 changed files with 144 additions and 139 deletions

View File

@ -20,14 +20,15 @@ from plinth.errors import PlinthError
class BorgError(PlinthError): class BorgError(PlinthError):
"""Generic borg errors""" """Generic borg errors"""
pass
class BorgRepositoryDoesNotExistError(BorgError): class BorgRepositoryDoesNotExistError(BorgError):
"""Borg access to a repository works but the repository does not exist""" """Borg access to a repository works but the repository does not exist"""
pass
class SshfsError(PlinthError): class SshfsError(PlinthError):
"""Generic sshfs errors""" """Generic sshfs errors"""
pass
class BorgRepositoryExists(BorgError):
"""A repository at target location already exists during initialization."""

View File

@ -19,6 +19,7 @@ Remote and local Borg backup repositories
""" """
import abc import abc
import contextlib
import io import io
import json import json
import logging import logging
@ -26,54 +27,67 @@ import os
import re import re
from uuid import uuid1 from uuid import uuid1
import paramiko
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from plinth import actions, cfg from plinth import actions, cfg
from plinth.errors import ActionError from plinth.errors import ActionError
from plinth.utils import format_lazy from plinth.utils import format_lazy
from . import (_backup_handler, api, get_known_hosts_path, from . import (_backup_handler, api, errors, get_known_hosts_path,
restore_archive_handler, split_path, store) restore_archive_handler, split_path, store)
from .errors import BorgError, BorgRepositoryDoesNotExistError, SshfsError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
SUPPORTED_BORG_ENCRYPTION = ['none', 'repokey']
# known errors that come up when remotely accessing a borg repository # known errors that come up when remotely accessing a borg repository
# 'errors' are error strings to look for in the stacktrace. # 'errors' are error strings to look for in the stacktrace.
KNOWN_ERRORS = [{ KNOWN_ERRORS = [
{
'errors': ['subprocess.TimeoutExpired'], 'errors': ['subprocess.TimeoutExpired'],
'message': 'message':
_('Connection refused - make sure you provided correct ' _('Connection refused - make sure you provided correct '
'credentials and the server is running.'), 'credentials and the server is running.'),
'raise_as': 'raise_as':
BorgError, errors.BorgError,
}, },
{ {
'errors': ['Connection refused'], 'errors': ['Connection refused'],
'message': _('Connection refused'), 'message': _('Connection refused'),
'raise_as': BorgError, 'raise_as': errors.BorgError,
}, },
{ {
'errors': [ 'errors': [
'not a valid repository', 'does not exist', 'not a valid repository', 'does not exist', 'FileNotFoundError'
'FileNotFoundError'
], ],
'message': 'message':
_('Repository not found'), _('Repository not found'),
'raise_as': 'raise_as':
BorgRepositoryDoesNotExistError, errors.BorgRepositoryDoesNotExistError,
}, },
{ {
'errors': [('passphrase supplied in .* is incorrect')], 'errors': ['passphrase supplied in .* is incorrect'],
'message': _('Incorrect encryption passphrase'), 'message': _('Incorrect encryption passphrase'),
'raise_as': BorgError, 'raise_as': errors.BorgError,
}, },
{ {
'errors': [('Connection reset by peer')], 'errors': ['Connection reset by peer'],
'message': _('SSH access denied'), 'message': _('SSH access denied'),
'raise_as': SshfsError, 'raise_as': errors.SshfsError,
}] },
{
'errors': ['There is already something at'],
'message':
_('Repository path is neither empty nor '
'is an existing backups repository.'),
'raise_as':
errors.BorgError,
},
{
'errors': ['A repository already exists at'],
'message': None,
'raise_as': errors.BorgRepositoryExists,
},
]
class BaseBorgRepository(abc.ABC): class BaseBorgRepository(abc.ABC):
@ -147,7 +161,7 @@ class BaseBorgRepository(abc.ABC):
repository['mounted'] = self.is_mounted repository['mounted'] = self.is_mounted
if repository['mounted']: if repository['mounted']:
repository['archives'] = self.list_archives() repository['archives'] = self.list_archives()
except (BorgError, ActionError) as err: except (errors.BorgError, ActionError) as err:
repository['error'] = str(err) repository['error'] = str(err)
return repository return repository
@ -174,13 +188,20 @@ class BaseBorgRepository(abc.ABC):
archive_path = self._get_archive_path(archive_name) archive_path = self._get_archive_path(archive_name)
self.run(['delete-archive', '--path', archive_path]) self.run(['delete-archive', '--path', archive_path])
def initialize(self, encryption): def initialize(self):
"""Initialize / create a borg repository.""" """Initialize / create a borg repository."""
if encryption not in SUPPORTED_BORG_ENCRYPTION: encryption = 'none'
raise ValueError('Unsupported encryption: %s' % encryption) if 'encryption_passphrase' in self.credentials and \
self.credentials['encryption_passphrase']:
encryption = 'repokey'
try:
self.run( self.run(
['init', '--path', self.borg_path, '--encryption', encryption]) ['init', '--path', self.borg_path, '--encryption', encryption])
except errors.BorgRepositoryExists:
pass
self.get_info() # If password is incorrect raise an error early.
@staticmethod @staticmethod
def _get_encryption_data(credentials): def _get_encryption_data(credentials):
@ -380,6 +401,12 @@ class SshBorgRepository(BaseBorgRepository):
['is-mounted', '--mountpoint', self._mountpoint]) ['is-mounted', '--mountpoint', self._mountpoint])
return json.loads(output) return json.loads(output)
def initialize(self):
"""Initialize the repository after mounting the target directory."""
self._ensure_remote_directory()
self.mount()
super().initialize()
def mount(self): def mount(self):
"""Mount the remote path locally using sshfs.""" """Mount the remote path locally using sshfs."""
if self.is_mounted: if self.is_mounted:
@ -429,6 +456,41 @@ class SshBorgRepository(BaseBorgRepository):
return (arguments, kwargs) return (arguments, kwargs)
def _ensure_remote_directory(self):
"""Create remote SSH directory if it does not exist."""
username, hostname, dir_path = split_path(self.path)
if dir_path == '':
dir_path = '.'
if dir_path[0] == '~':
dir_path = '.' + dir_path[1:]
password = self.credentials['ssh_password']
# Ensure remote directory exists, check contents
# TODO Test with IPv6 connection
with _ssh_connection(hostname, username, password) as ssh_client:
with ssh_client.open_sftp() as sftp_client:
try:
sftp_client.listdir(dir_path)
except FileNotFoundError:
logger.info('Directory %s does not exist, creating.',
dir_path)
sftp_client.mkdir(dir_path)
@contextlib.contextmanager
def _ssh_connection(hostname, username, password):
"""Context manager to create and close an SSH connection."""
ssh_client = paramiko.SSHClient()
ssh_client.load_host_keys(str(get_known_hosts_path()))
try:
ssh_client.connect(hostname, username=username, password=password)
yield ssh_client
finally:
ssh_client.close()
def get_repositories(): def get_repositories():
"""Get all repositories of a given storage type.""" """Get all repositories of a given storage type."""

View File

@ -21,7 +21,6 @@ Views for the backups app.
import logging import logging
import os import os
import tempfile import tempfile
from contextlib import contextmanager
from datetime import datetime from datetime import datetime
from urllib.parse import unquote from urllib.parse import unquote
@ -39,9 +38,8 @@ from plinth.errors import PlinthError
from plinth.modules import backups, storage from plinth.modules import backups, storage
from . import (SESSION_PATH_VARIABLE, api, forms, get_known_hosts_path, from . import (SESSION_PATH_VARIABLE, api, forms, get_known_hosts_path,
is_ssh_hostkey_verified, split_path) is_ssh_hostkey_verified)
from .decorators import delete_tmp_backup_file from .decorators import delete_tmp_backup_file
from .errors import BorgRepositoryDoesNotExistError
from .repository import (BorgRepository, SshBorgRepository, get_instance, from .repository import (BorgRepository, SshBorgRepository, get_instance,
get_repositories) get_repositories)
@ -265,14 +263,11 @@ class AddRepositoryView(SuccessMessageMixin, FormView):
credentials = {'encryption_passphrase': encryption_passphrase} credentials = {'encryption_passphrase': encryption_passphrase}
repository = BorgRepository(path, credentials) repository = BorgRepository(path, credentials)
try: if _save_repository(self.request, repository):
repository.get_info()
except BorgRepositoryDoesNotExistError:
repository.initialize(encryption)
repository.save(store_credentials=True, verified=True)
return super().form_valid(form) return super().form_valid(form)
return redirect(reverse_lazy('backups:add-repository'))
class AddRemoteRepositoryView(SuccessMessageMixin, FormView): class AddRemoteRepositoryView(SuccessMessageMixin, FormView):
"""View to create a new remote backup repository.""" """View to create a new remote backup repository."""
@ -346,35 +341,32 @@ class VerifySshHostkeyView(SuccessMessageMixin, FormView):
def get(self, *args, **kwargs): def get(self, *args, **kwargs):
"""Skip this view if host is already verified.""" """Skip this view if host is already verified."""
if is_ssh_hostkey_verified(self._get_repository().hostname): if not is_ssh_hostkey_verified(self._get_repository().hostname):
messages.success(self.request, _('SSH host already verified.'))
return self._add_remote_repository()
return super().get(*args, **kwargs) return super().get(*args, **kwargs)
messages.success(self.request, _('SSH host already verified.'))
if _save_repository(self.request, self._get_repository()):
return redirect(reverse_lazy('backups:index'))
return redirect(reverse_lazy('backups:add-remote-repository'))
def form_valid(self, form): def form_valid(self, form):
"""Create and store the repository.""" """Create and store the repository."""
ssh_public_key = form.cleaned_data['ssh_public_key'] ssh_public_key = form.cleaned_data['ssh_public_key']
self._add_ssh_hostkey(ssh_public_key) self._add_ssh_hostkey(ssh_public_key)
messages.success(self.request, _('SSH host verified.')) messages.success(self.request, _('SSH host verified.'))
return self._add_remote_repository() if _save_repository(self.request, self._get_repository()):
def _add_remote_repository(self):
"""On successful verification of host, add repository."""
repository = self._get_repository()
encryption = 'none'
if 'encryption_passphrase' in repository.credentials and \
repository.credentials['encryption_passphrase']:
encryption = 'repokey'
try:
dir_contents = _list_remote_directory(repository.path,
repository.credentials)
repository.mount()
repository = _create_remote_repository(repository, encryption,
dir_contents)
repository.save(verified=True)
return redirect(reverse_lazy('backups:index')) return redirect(reverse_lazy('backups:index'))
return redirect(reverse_lazy('backups:add-remote-repository'))
def _save_repository(request, repository):
"""Initialize and save a repository. Convert errors to messages."""
try:
repository.initialize()
repository.save(verified=True)
return True
except paramiko.BadHostKeyException: except paramiko.BadHostKeyException:
message = _('SSH host public key could not be verified.') message = _('SSH host public key could not be verified.')
except paramiko.AuthenticationException: except paramiko.AuthenticationException:
@ -382,70 +374,20 @@ class VerifySshHostkeyView(SuccessMessageMixin, FormView):
except paramiko.SSHException as exception: except paramiko.SSHException as exception:
message = _('Error establishing connection to server: {}').format( message = _('Error establishing connection to server: {}').format(
str(exception)) str(exception))
except BorgRepositoryDoesNotExistError:
message = _('Repository path is neither empty nor '
'is an existing backups repository.')
except Exception as exception: except Exception as exception:
message = str(exception) message = str(exception)
logger.exception('Error adding repository: %s', exception) logger.exception('Error adding repository: %s', exception)
messages.error(self.request, message) messages.error(request, message)
messages.error(self.request, _('Repository removed.'))
# Remove the repository so that the user can have another go at # Remove the repository so that the user can have another go at
# creating it. # creating it.
try:
repository.remove() repository.remove()
return redirect(reverse_lazy('backups:add-remote-repository')) messages.error(request, _('Repository removed.'))
except KeyError:
pass
return False
def _list_remote_directory(path, credentials):
"""List a SSH remote directory. Create if it does not exist. """
username, hostname, dir_path = split_path(path)
if dir_path == '':
dir_path = '.'
if dir_path[0] == '~':
dir_path = '.' + dir_path[1:]
password = credentials['ssh_password']
# Ensure remote directory exists, check contents
dir_contents = None
# TODO Test with IPv6 connection
with _ssh_connection(hostname, username, password) as ssh_client:
with ssh_client.open_sftp() as sftp_client:
try:
dir_contents = sftp_client.listdir(dir_path)
except FileNotFoundError:
logger.info('Directory %s does not exist, creating.', dir_path)
sftp_client.mkdir(dir_path)
return dir_contents
def _create_remote_repository(repository, encryption, dir_contents):
"""Create a Borg repository on remote server if necessary."""
try:
repository.get_info()
except BorgRepositoryDoesNotExistError:
if dir_contents:
raise
repository.initialize(encryption)
return repository
@contextmanager
def _ssh_connection(hostname, username, password):
"""Context manager to create and close an SSH connection."""
ssh_client = paramiko.SSHClient()
ssh_client.load_host_keys(str(get_known_hosts_path()))
try:
ssh_client.connect(hostname, username=username, password=password)
yield ssh_client
finally:
ssh_client.close()
class RemoveRepositoryView(SuccessMessageMixin, TemplateView): class RemoveRepositoryView(SuccessMessageMixin, TemplateView):