diff --git a/NEWS b/NEWS index f46598a..f8dd0bf 100644 --- a/NEWS +++ b/NEWS @@ -1,3 +1,7 @@ +0.1.0 + + * New "borgmatic" command to support Borg backup software, a fork of Attic. + 0.0.7 * Flag for multiple levels of verbosity: some, and lots. diff --git a/README.md b/README.md index 67b4bc1..fe26676 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,13 @@ save_as: atticmatic/index.html ## Overview -atticmatic is a simple Python wrapper script for the [Attic backup -software](https://attic-backup.org/) that initiates a backup, prunes any old -backups according to a retention policy, and validates backups for -consistency. The script supports specifying your settings in a declarative -configuration file rather than having to put them all on the command-line, and -handles common errors. +atticmatic is a simple Python wrapper script for the +[Attic](https://attic-backup.org/) and +[Borg](https://borgbackup.github.io/borgbackup/) backup software that +initiates a backup, prunes any old backups according to a retention policy, +and validates backups for consistency. The script supports specifying your +settings in a declarative configuration file rather than having to put them +all on the command-line, and handles common errors. Here's an example config file: @@ -17,7 +18,7 @@ Here's an example config file: # Space-separated list of source directories to backup. source_directories: /home /etc - # Path to local or remote Attic repository. + # Path to local or remote backup repository. repository: user@backupserver:sourcehostname.attic [retention] @@ -41,14 +42,14 @@ available](https://torsion.org/hg/atticmatic). It's also mirrored on ## Setup -To get up and running with Attic, follow the [Attic Quick -Start](https://attic-backup.org/quickstart.html) guide to create an Attic +To get up and running, follow the [Attic Quick +Start](https://attic-backup.org/quickstart.html) or the [Borg Quick +Start](https://borgbackup.github.io/borgbackup/quickstart.html) to create a repository on a local or remote host. Note that if you plan to run atticmatic on a schedule with cron, and you encrypt your attic repository with a passphrase instead of a key file, you'll need to set the `ATTIC_PASSPHRASE` -environment variable. See [attic's repository encryption -documentation](https://attic-backup.org/quickstart.html#encrypted-repos) for -more info. +environment variable. See the repository encryption section of the Quick Start +for more info. If the repository is on a remote host, make sure that your local root user has key-based ssh access to the desired user account on the remote host. @@ -57,13 +58,19 @@ To install atticmatic, run the following command to download and install it: sudo pip install --upgrade hg+https://torsion.org/hg/atticmatic -Then copy the following configuration files: +If you are using Attic, copy the following configuration files: sudo cp sample/atticmatic.cron /etc/cron.d/atticmatic sudo mkdir /etc/atticmatic/ sudo cp sample/config sample/excludes /etc/atticmatic/ -Lastly, modify those files with your desired configuration. +If you are using Borg, copy the files like this instead: + + sudo cp sample/atticmatic.cron /etc/cron.d/borgmatic + sudo mkdir /etc/borgmatic/ + sudo cp sample/config sample/excludes /etc/borgmatic/ + +Lastly, modify the /etc files with your desired configuration. ## Usage @@ -73,6 +80,11 @@ arguments: atticmatic +Or, if you're using Borg, use this command instead to make use of the Borg +backend: + + borgmatic + This will also prune any old backups as per the configured retention policy, and check backups for consistency problems due to things like file damage. diff --git a/atticmatic/backends/__init__.py b/atticmatic/backends/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/atticmatic/backends/attic.py b/atticmatic/backends/attic.py new file mode 100644 index 0000000..a99c323 --- /dev/null +++ b/atticmatic/backends/attic.py @@ -0,0 +1,12 @@ +from functools import partial + +from atticmatic.backends import shared + +# An atticmatic backend that supports Attic for actually handling backups. + +COMMAND = 'attic' + + +create_archive = partial(shared.create_archive, command=COMMAND) +prune_archives = partial(shared.prune_archives, command=COMMAND) +check_archives = partial(shared.check_archives, command=COMMAND) diff --git a/atticmatic/backends/borg.py b/atticmatic/backends/borg.py new file mode 100644 index 0000000..96b5bff --- /dev/null +++ b/atticmatic/backends/borg.py @@ -0,0 +1,13 @@ +from functools import partial + +from atticmatic.backends import shared + +# An atticmatic backend that supports Borg for actually handling backups. + +COMMAND = 'borg' + + +create_archive = partial(shared.create_archive, command=COMMAND) +prune_archives = partial(shared.prune_archives, command=COMMAND) +check_archives = partial(shared.check_archives, command=COMMAND) + diff --git a/atticmatic/attic.py b/atticmatic/backends/shared.py similarity index 74% rename from atticmatic/attic.py rename to atticmatic/backends/shared.py index 5a0a4b7..eb167b5 100644 --- a/atticmatic/attic.py +++ b/atticmatic/backends/shared.py @@ -6,10 +6,16 @@ import subprocess from atticmatic.verbosity import VERBOSITY_SOME, VERBOSITY_LOTS -def create_archive(excludes_filename, verbosity, source_directories, repository): +# Common backend functionality shared by Attic and Borg. As the two backup +# commands diverge, these shared functions will likely need to be replaced +# with non-shared functions within atticmatic.backends.attic and +# atticmatic.backends.borg. + + +def create_archive(excludes_filename, verbosity, source_directories, repository, command): ''' - Given an excludes filename, a vebosity flag, a space-separated list of source directories, and - a local or remote repository path, create an attic archive. + Given an excludes filename, a vebosity flag, a space-separated list of source directories, a + local or remote repository path, and a command to run, create an attic archive. ''' sources = tuple(source_directories.split(' ')) verbosity_flags = { @@ -17,8 +23,8 @@ def create_archive(excludes_filename, verbosity, source_directories, repository) VERBOSITY_LOTS: ('--verbose', '--stats'), }.get(verbosity, ()) - command = ( - 'attic', 'create', + full_command = ( + command, 'create', '--exclude-from', excludes_filename, '{repo}::{hostname}-{timestamp}'.format( repo=repository, @@ -27,7 +33,7 @@ def create_archive(excludes_filename, verbosity, source_directories, repository) ), ) + sources + verbosity_flags - subprocess.check_call(command) + subprocess.check_call(full_command) def _make_prune_flags(retention_config): @@ -52,18 +58,19 @@ def _make_prune_flags(retention_config): ) -def prune_archives(verbosity, repository, retention_config): +def prune_archives(verbosity, repository, retention_config, command): ''' - Given a verbosity flag, a local or remote repository path, and a retention config dict, prune - attic archives according the the retention policy specified in that configuration. + Given a verbosity flag, a local or remote repository path, a retention config dict, and a + command to run, prune attic archives according the the retention policy specified in that + configuration. ''' verbosity_flags = { VERBOSITY_SOME: ('--stats',), VERBOSITY_LOTS: ('--verbose', '--stats'), }.get(verbosity, ()) - command = ( - 'attic', 'prune', + full_command = ( + command, 'prune', repository, ) + tuple( element @@ -71,7 +78,7 @@ def prune_archives(verbosity, repository, retention_config): for element in pair ) + verbosity_flags - subprocess.check_call(command) + subprocess.check_call(full_command) DEFAULT_CHECKS = ('repository', 'archives') @@ -123,10 +130,10 @@ def _make_check_flags(checks): ) -def check_archives(verbosity, repository, consistency_config): +def check_archives(verbosity, repository, consistency_config, command): ''' - Given a verbosity flag, a local or remote repository path, and a consistency config dict, check - the contained attic archives for consistency. + Given a verbosity flag, a local or remote repository path, a consistency config dict, and a + command to run, check the contained attic archives for consistency. If there are no consistency checks to run, skip running them. ''' @@ -139,12 +146,12 @@ def check_archives(verbosity, repository, consistency_config): VERBOSITY_LOTS: ('--verbose',), }.get(verbosity, ()) - command = ( - 'attic', 'check', + full_command = ( + command, 'check', repository, ) + _make_check_flags(checks) + verbosity_flags - # Attic's check command spews to stdout even without the verbose flag. Suppress it. + # The check command spews to stdout even without the verbose flag. Suppress it. stdout = None if verbosity_flags else open(os.devnull, 'w') - subprocess.check_call(command, stdout=stdout) + subprocess.check_call(full_command, stdout=stdout) diff --git a/atticmatic/command.py b/atticmatic/command.py index 37f42be..4578c1e 100644 --- a/atticmatic/command.py +++ b/atticmatic/command.py @@ -1,31 +1,34 @@ from __future__ import print_function from argparse import ArgumentParser +from importlib import import_module +import os from subprocess import CalledProcessError import sys -from atticmatic.attic import check_archives, create_archive, prune_archives from atticmatic.config import parse_configuration -DEFAULT_CONFIG_FILENAME = '/etc/atticmatic/config' -DEFAULT_EXCLUDES_FILENAME = '/etc/atticmatic/excludes' +DEFAULT_CONFIG_FILENAME_PATTERN = '/etc/{}/config' +DEFAULT_EXCLUDES_FILENAME_PATTERN = '/etc/{}/excludes' -def parse_arguments(*arguments): +def parse_arguments(command_name, *arguments): ''' - Parse the given command-line arguments and return them as an ArgumentParser instance. + Given the name of the command with which this script was invoked and command-line arguments, + parse the arguments and return them as an ArgumentParser instance. Use the command name to + determine the default configuration and excludes paths. ''' parser = ArgumentParser() parser.add_argument( '-c', '--config', dest='config_filename', - default=DEFAULT_CONFIG_FILENAME, + default=DEFAULT_CONFIG_FILENAME_PATTERN.format(command_name), help='Configuration filename', ) parser.add_argument( '--excludes', dest='excludes_filename', - default=DEFAULT_EXCLUDES_FILENAME, + default=DEFAULT_EXCLUDES_FILENAME_PATTERN.format(command_name), help='Excludes filename', ) parser.add_argument( @@ -37,15 +40,30 @@ def parse_arguments(*arguments): return parser.parse_args(arguments) +def load_backend(command_name): + ''' + Given the name of the command with which this script was invoked, return the corresponding + backend module responsible for actually dealing with backups. + ''' + backend_name = { + 'atticmatic': 'attic', + 'borgmatic': 'borg', + }.get(command_name, 'attic') + + return import_module('atticmatic.backends.{}'.format(backend_name)) + + def main(): try: - args = parse_arguments(*sys.argv[1:]) + command_name = os.path.basename(sys.argv[0]) + args = parse_arguments(command_name, *sys.argv[1:]) config = parse_configuration(args.config_filename) repository = config.location['repository'] + backend = load_backend(command_name) - create_archive(args.excludes_filename, args.verbosity, **config.location) - prune_archives(args.verbosity, repository, config.retention) - check_archives(args.verbosity, repository, config.consistency) + backend.create_archive(args.excludes_filename, args.verbosity, **config.location) + backend.prune_archives(args.verbosity, repository, config.retention) + backend.check_archives(args.verbosity, repository, config.consistency) except (ValueError, IOError, CalledProcessError) as error: print(error, file=sys.stderr) sys.exit(1) diff --git a/atticmatic/tests/integration/test_command.py b/atticmatic/tests/integration/test_command.py index 9b75871..fd72595 100644 --- a/atticmatic/tests/integration/test_command.py +++ b/atticmatic/tests/integration/test_command.py @@ -5,16 +5,19 @@ from nose.tools import assert_raises from atticmatic import command as module -def test_parse_arguments_with_no_arguments_uses_defaults(): - parser = module.parse_arguments() +COMMAND_NAME = 'foomatic' - assert parser.config_filename == module.DEFAULT_CONFIG_FILENAME - assert parser.excludes_filename == module.DEFAULT_EXCLUDES_FILENAME + +def test_parse_arguments_with_no_arguments_uses_defaults(): + parser = module.parse_arguments(COMMAND_NAME) + + assert parser.config_filename == module.DEFAULT_CONFIG_FILENAME_PATTERN.format(COMMAND_NAME) + assert parser.excludes_filename == module.DEFAULT_EXCLUDES_FILENAME_PATTERN.format(COMMAND_NAME) assert parser.verbosity == None def test_parse_arguments_with_filename_arguments_overrides_defaults(): - parser = module.parse_arguments('--config', 'myconfig', '--excludes', 'myexcludes') + parser = module.parse_arguments(COMMAND_NAME, '--config', 'myconfig', '--excludes', 'myexcludes') assert parser.config_filename == 'myconfig' assert parser.excludes_filename == 'myexcludes' @@ -22,10 +25,10 @@ def test_parse_arguments_with_filename_arguments_overrides_defaults(): def test_parse_arguments_with_verbosity_flag_overrides_default(): - parser = module.parse_arguments('--verbosity', '1') + parser = module.parse_arguments(COMMAND_NAME, '--verbosity', '1') - assert parser.config_filename == module.DEFAULT_CONFIG_FILENAME - assert parser.excludes_filename == module.DEFAULT_EXCLUDES_FILENAME + assert parser.config_filename == module.DEFAULT_CONFIG_FILENAME_PATTERN.format(COMMAND_NAME) + assert parser.excludes_filename == module.DEFAULT_EXCLUDES_FILENAME_PATTERN.format(COMMAND_NAME) assert parser.verbosity == 1 @@ -35,6 +38,6 @@ def test_parse_arguments_with_invalid_arguments_exits(): try: with assert_raises(SystemExit): - module.parse_arguments('--posix-me-harder') + module.parse_arguments(COMMAND_NAME, '--posix-me-harder') finally: sys.stderr = original_stderr diff --git a/atticmatic/tests/unit/backends/__init__.py b/atticmatic/tests/unit/backends/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/atticmatic/tests/unit/test_attic.py b/atticmatic/tests/unit/backends/test_shared.py similarity index 95% rename from atticmatic/tests/unit/test_attic.py rename to atticmatic/tests/unit/backends/test_shared.py index 13be67b..c779c36 100644 --- a/atticmatic/tests/unit/test_attic.py +++ b/atticmatic/tests/unit/backends/test_shared.py @@ -2,7 +2,7 @@ from collections import OrderedDict from flexmock import flexmock -from atticmatic import attic as module +from atticmatic.backends import shared as module from atticmatic.tests.builtins import builtins_mock from atticmatic.verbosity import VERBOSITY_SOME, VERBOSITY_LOTS @@ -42,6 +42,7 @@ def test_create_archive_should_call_attic_with_parameters(): verbosity=None, source_directories='foo bar', repository='repo', + command='attic', ) @@ -55,6 +56,7 @@ def test_create_archive_with_verbosity_some_should_call_attic_with_stats_paramet verbosity=VERBOSITY_SOME, source_directories='foo bar', repository='repo', + command='attic', ) @@ -68,6 +70,7 @@ def test_create_archive_with_verbosity_lots_should_call_attic_with_verbose_param verbosity=VERBOSITY_LOTS, source_directories='foo bar', repository='repo', + command='attic', ) @@ -108,6 +111,7 @@ def test_prune_archives_should_call_attic_with_parameters(): verbosity=None, repository='repo', retention_config=retention_config, + command='attic', ) @@ -122,6 +126,7 @@ def test_prune_archives_with_verbosity_some_should_call_attic_with_stats_paramet repository='repo', verbosity=VERBOSITY_SOME, retention_config=retention_config, + command='attic', ) @@ -136,6 +141,7 @@ def test_prune_archives_with_verbosity_lots_should_call_attic_with_verbose_param repository='repo', verbosity=VERBOSITY_LOTS, retention_config=retention_config, + command='attic', ) @@ -193,6 +199,7 @@ def test_check_archives_should_call_attic_with_parameters(): verbosity=None, repository='repo', consistency_config=consistency_config, + command='attic', ) @@ -211,6 +218,7 @@ def test_check_archives_with_verbosity_some_should_call_attic_with_verbose_param verbosity=VERBOSITY_SOME, repository='repo', consistency_config=consistency_config, + command='attic', ) @@ -229,6 +237,7 @@ def test_check_archives_with_verbosity_lots_should_call_attic_with_verbose_param verbosity=VERBOSITY_LOTS, repository='repo', consistency_config=consistency_config, + command='attic', ) @@ -241,4 +250,5 @@ def test_check_archives_without_any_checks_should_bail(): verbosity=None, repository='repo', consistency_config=consistency_config, + command='attic', ) diff --git a/atticmatic/tests/unit/test_command.py b/atticmatic/tests/unit/test_command.py new file mode 100644 index 0000000..6a3cdb1 --- /dev/null +++ b/atticmatic/tests/unit/test_command.py @@ -0,0 +1,33 @@ +from flexmock import flexmock + +from atticmatic import command as module + + +def test_load_backend_with_atticmatic_command_should_return_attic_backend(): + backend = flexmock() + ( + flexmock(module).should_receive('import_module').with_args('atticmatic.backends.attic') + .and_return(backend).once() + ) + + assert module.load_backend('atticmatic') == backend + + +def test_load_backend_with_unknown_command_should_return_attic_backend(): + backend = flexmock() + ( + flexmock(module).should_receive('import_module').with_args('atticmatic.backends.attic') + .and_return(backend).once() + ) + + assert module.load_backend('unknownmatic') == backend + + +def test_load_backend_with_borgmatic_command_should_return_borg_backend(): + backend = flexmock() + ( + flexmock(module).should_receive('import_module').with_args('atticmatic.backends.borg') + .and_return(backend).once() + ) + + assert module.load_backend('borgmatic') == backend diff --git a/sample/config b/sample/config index b13afbe..6ae08b4 100644 --- a/sample/config +++ b/sample/config @@ -2,7 +2,7 @@ # Space-separated list of source directories to backup. source_directories: /home /etc -# Path to local or remote Attic repository. +# Path to local or remote repository. repository: user@backupserver:sourcehostname.attic [retention] diff --git a/setup.py b/setup.py index 2a64c55..5736ccc 100644 --- a/setup.py +++ b/setup.py @@ -2,12 +2,17 @@ from setuptools import setup, find_packages setup( name='atticmatic', - version='0.0.7', - description='A wrapper script for Attic backup software that creates and prunes backups', + version='0.1.0', + description='A wrapper script for Attic/Borg backup software that creates and prunes backups', author='Dan Helfman', author_email='witten@torsion.org', packages=find_packages(), - entry_points={'console_scripts': ['atticmatic = atticmatic.command:main']}, + entry_points={ + 'console_scripts': [ + 'atticmatic = atticmatic.command:main', + 'borgmatic = atticmatic.command:main', + ] + }, tests_require=( 'flexmock', 'nose',