Source code for rsync_system_backup.destinations
# rsync-system-backup: Linux system backups powered by rsync.
#
# Author: Peter Odding <peter@peterodding.com>
# Last Change: August 2, 2019
# URL: https://github.com/xolox/python-rsync-system-backup
"""Parsing of rsync destination syntax (and then some)."""
# Standard library modules.
import logging
import os
import re
# External dependencies.
from humanfriendly import compact
from property_manager import (
PropertyManager,
mutable_property,
required_property,
set_property,
)
# Modules included in our package.
from rsync_system_backup.exceptions import (
InvalidDestinationError,
ParentDirectoryUnavailable,
)
RSYNCD_PORT = 873
"""
The default port of the `rsync daemon`_ (an integer).
.. _rsync daemon: https://manpages.debian.org/rsyncd.conf
"""
LOCAL_DESTINATION = re.compile('^(?P<directory>.+)$')
"""
A compiled regular expression pattern to parse local destinations,
used as a fall back because it matches any nonempty string.
"""
SSH_DESTINATION = re.compile('''
^ ( (?P<username> [^@]+ ) @ )? # optional username
(?P<hostname> [^:]+ ) : # mandatory host name
(?P<directory> .* ) # optional pathname
''', re.VERBOSE)
"""
A compiled regular expression pattern to parse remote destinations
of the form ``[USER@]HOST:DEST`` (using an SSH connection).
"""
SIMPLE_DAEMON_DESTINATION = re.compile('''
^ ( (?P<username> [^@]+ ) @ )? # optional username
(?P<hostname> [^:]+ ) :: # mandatory host name
(?P<module> [^/]+ ) # mandatory module name
( / (?P<directory> .* ) )? $ # optional pathname (without leading slash)
''', re.VERBOSE)
"""
A compiled regular expression pattern to parse remote destinations of the
form ``[USER@]HOST::MODULE[/DIRECTORY]`` (using an rsync daemon connection).
"""
ADVANCED_DAEMON_DESTINATION = re.compile(r'''
^ rsync:// # static prefix
( (?P<username>[^@]+) @ )? # optional username
(?P<hostname> [^:/]+ ) # mandatory host name
( : (?P<port_number> \d+ ) )? # optional port number
/ (?P<module> [^/]+ ) # mandatory module name
( / (?P<directory> .* ) )? $ # optional pathname (without leading slash)
''', re.VERBOSE)
"""
A compiled regular expression pattern to parse remote destinations of the form
``rsync://[USER@]HOST[:PORT]/MODULE[/DIRECTORY]`` (using an rsync daemon
connection).
"""
DESTINATION_PATTERNS = [
ADVANCED_DAEMON_DESTINATION,
SIMPLE_DAEMON_DESTINATION,
SSH_DESTINATION,
LOCAL_DESTINATION,
]
"""
A list of compiled regular expression patterns to match destination
expressions. The patterns are ordered by decreasing specificity.
"""
# Public identifiers that require documentation.
__all__ = (
'logger',
'RSYNCD_PORT',
'LOCAL_DESTINATION',
'SSH_DESTINATION',
'SIMPLE_DAEMON_DESTINATION',
'ADVANCED_DAEMON_DESTINATION',
'DESTINATION_PATTERNS',
'Destination',
)
# Initialize a logger for this module.
logger = logging.getLogger(__name__)
[docs]class Destination(PropertyManager):
"""
The :class:`Destination` class represents a location where backups are stored.
The :attr:`expression` property is a required property whose value is
parsed to populate the values of the :attr:`username`, :attr:`hostname`,
:attr:`port_number`, :attr:`module` and :attr:`directory` properties.
When you read the value of the :attr:`expression` property you get back a
computed value based on the values of the previously mentioned properties.
This makes it possible to manipulate the destination before passing it on
to rsync.
"""
@required_property
def expression(self):
"""
The destination in rsync's command line syntax (a string).
:raises: :exc:`.InvalidDestinationError` when you try to set
this property to a value that cannot be parsed.
"""
if not (self.hostname or self.directory):
# This is a bit tricky: Returning None here ensures that a
# TypeError will be raised when a Destination object is
# created without specifying a value for `expression'.
return None
value = 'rsync://' if self.module else ''
if self.hostname:
if self.username:
value += self.username + '@'
value += self.hostname
if self.module:
if self.port_number:
value += ':%s' % self.port_number
value += '/' + self.module
else:
value += ':'
if self.directory:
value += self.directory
return value
[docs] @expression.setter
def expression(self, value):
"""Automatically parse expression strings."""
for pattern in DESTINATION_PATTERNS:
match = pattern.match(value)
if match:
captures = match.groupdict()
non_empty = dict((n, c) for n, c in captures.items() if c)
self.set_properties(**non_empty)
break
else:
msg = "Failed to parse expression! (%s)"
raise InvalidDestinationError(msg % value)
[docs] @mutable_property
def directory(self):
"""The pathname of the directory where the backup should be written (a string)."""
return ''
[docs] @mutable_property
def hostname(self):
"""The host name or IP address of a remote system (a string)."""
return ''
[docs] @mutable_property
def module(self):
"""The name of a module exported by an `rsync daemon`_ (a string)."""
return ''
[docs] @mutable_property
def parent_directory(self):
"""
The pathname of the parent directory of the backup directory (a string).
:raises: :exc:`.ParentDirectoryUnavailable` when the parent directory
can't be determined because :attr:`directory` is empty or '/'.
"""
directory = os.path.dirname(self.directory.rstrip('/'))
if not directory:
raise ParentDirectoryUnavailable(compact("""
Failed to determine the parent directory of the destination
directory! This makes it impossible to create and rotate
snapshots for the destination {dest}.
""", dest=self.expression))
return directory
@mutable_property
def port_number(self):
"""
The port number of a remote `rsync daemon`_ (a number).
When :attr:`ssh_tunnel` is set the value of :attr:`port_number`
defaults to :attr:`executor.ssh.client.SecureTunnel.local_port`,
otherwise it defaults to :data:`RSYNCD_PORT`.
"""
return self.ssh_tunnel.local_port if self.ssh_tunnel is not None else RSYNCD_PORT
[docs] @port_number.setter
def port_number(self, value):
"""Automatically coerce port numbers to integers."""
set_property(self, 'port_number', int(value))
[docs] @mutable_property
def ssh_tunnel(self):
"""A :class:`~executor.ssh.client.SecureTunnel` object or :data:`None` (defaults to :data:`None`)."""
[docs] @mutable_property
def username(self):
"""The username for connecting to a remote system (a string)."""
return ''
[docs] def __enter__(self):
"""Automatically open :attr:`ssh_tunnel` when required."""
if self.ssh_tunnel:
self.ssh_tunnel.__enter__()
return self
[docs] def __exit__(self, exc_type=None, exc_value=None, traceback=None):
"""Automatically close :attr:`ssh_tunnel` when required"""
if self.ssh_tunnel:
self.ssh_tunnel.__exit__(exc_type, exc_value, traceback)