commit 6dff335c8bbebdbe9a9819c24a70e980a393f63e Author: Dan Helfman Date: Thu Oct 30 22:34:03 2014 -0700 Initial import. diff --git a/.hgignore b/.hgignore new file mode 100644 index 0000000..e91865a --- /dev/null +++ b/.hgignore @@ -0,0 +1,3 @@ +syntax: glob +*.pyc +*.egg-info diff --git a/README b/README new file mode 100644 index 0000000..5b61af7 --- /dev/null +++ b/README @@ -0,0 +1,51 @@ +Overview +-------- + +atticmatic is a simple Python wrapper script for the Attic backup software +that initiates a backup and prunes any old backups according to a retention +policy. 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. + +Read more about Attic at https://attic-backup.org/ + + +Setup +----- + +To get up and running with Attic, follow the Attic Quick Start guide at +https://attic-backup.org/quickstart.html to create an Attic repository on a +local or remote host. + +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. + +To install atticmatic, run the following from the directory containing this +README: + + python setup.py install + +Then copy the following configuration files: + + sudo cp sample/atticmatic.cron /etc/init.d/atticmatic + sudo cp sample/config sample/excludes /etc/atticmatic/ + +Lastly, modify those files with your desired configuration. + + +Usage +----- + +You can run atticmatic and start a backup simply by invoking it without +arguments: + + atticmatic + +To get additional information about the progress of the backup, use the +verbose option: + + atticmattic --verbose + +If you'd like to see the available command-line arguments, view the help: + + atticmattic --help diff --git a/atticmatic/__init__.py b/atticmatic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/atticmatic/attic.py b/atticmatic/attic.py new file mode 100644 index 0000000..2274900 --- /dev/null +++ b/atticmatic/attic.py @@ -0,0 +1,34 @@ +from datetime import datetime + +import platform +import subprocess + + +def create_archive(excludes_filename, verbose, source_directories, repository): + sources = tuple(source_directories.split(' ')) + + command = ( + 'attic', 'create', + '--exclude-from', excludes_filename, + '{repo}::{hostname}-{timestamp}'.format( + repo=repository, + hostname=platform.node(), + timestamp=datetime.now().isoformat(), + ), + ) + sources + ( + ('--verbose', '--stats') if verbose else () + ) + + subprocess.check_call(command) + + +def prune_archives(repository, verbose, keep_daily, keep_weekly, keep_monthly): + command = ( + 'attic', 'prune', + repository, + '--keep-daily', str(keep_daily), + '--keep-weekly', str(keep_weekly), + '--keep-monthly', str(keep_monthly), + ) + (('--verbose',) if verbose else ()) + + subprocess.check_call(command) diff --git a/atticmatic/command.py b/atticmatic/command.py new file mode 100644 index 0000000..ebfdda3 --- /dev/null +++ b/atticmatic/command.py @@ -0,0 +1,38 @@ +from __future__ import print_function +from argparse import ArgumentParser +from subprocess import CalledProcessError +import sys + +from atticmatic.attic import create_archive, prune_archives +from atticmatic.config import parse_configuration + + +def main(): + parser = ArgumentParser() + parser.add_argument( + '--config', + dest='config_filename', + default='/etc/atticmatic/config', + help='Configuration filename', + ) + parser.add_argument( + '--excludes', + dest='excludes_filename', + default='/etc/atticmatic/excludes', + help='Excludes filename', + ) + parser.add_argument( + '--verbose', + action='store_true', + help='Display verbose progress information', + ) + args = parser.parse_args() + + try: + location_config, retention_config = parse_configuration(args.config_filename) + + create_archive(args.excludes_filename, args.verbose, *location_config) + prune_archives(location_config.repository, args.verbose, *retention_config) + except (ValueError, CalledProcessError), error: + print(error, file=sys.stderr) + sys.exit(1) diff --git a/atticmatic/config.py b/atticmatic/config.py new file mode 100644 index 0000000..7283ab8 --- /dev/null +++ b/atticmatic/config.py @@ -0,0 +1,57 @@ +from collections import namedtuple +from ConfigParser import SafeConfigParser + + +CONFIG_SECTION_LOCATION = 'location' +CONFIG_SECTION_RETENTION = 'retention' + +CONFIG_FORMAT = { + CONFIG_SECTION_LOCATION: ('source_directories', 'repository'), + CONFIG_SECTION_RETENTION: ('keep_daily', 'keep_weekly', 'keep_monthly'), +} + +LocationConfig = namedtuple('LocationConfig', CONFIG_FORMAT[CONFIG_SECTION_LOCATION]) +RetentionConfig = namedtuple('RetentionConfig', CONFIG_FORMAT[CONFIG_SECTION_RETENTION]) + + +def parse_configuration(config_filename): + ''' + Given a config filename of the expected format, return the parse configuration as a tuple of + (LocationConfig, RetentionConfig). Raise if the format is not as expected. + ''' + parser = SafeConfigParser() + parser.read((config_filename,)) + section_names = parser.sections() + expected_section_names = CONFIG_FORMAT.keys() + + if set(section_names) != set(expected_section_names): + raise ValueError( + 'Expected config sections {} but found sections: {}'.format( + ', '.join(expected_section_names), + ', '.join(section_names) + ) + ) + + for section_name in section_names: + option_names = parser.options(section_name) + expected_option_names = CONFIG_FORMAT[section_name] + + if set(option_names) != set(expected_option_names): + raise ValueError( + 'Expected options {} in config section {} but found options: {}'.format( + ', '.join(expected_option_names), + section_name, + ', '.join(option_names) + ) + ) + + return ( + LocationConfig(*( + parser.get(CONFIG_SECTION_LOCATION, option_name) + for option_name in CONFIG_FORMAT[CONFIG_SECTION_LOCATION] + )), + RetentionConfig(*( + parser.getint(CONFIG_SECTION_RETENTION, option_name) + for option_name in CONFIG_FORMAT[CONFIG_SECTION_RETENTION] + )) + ) diff --git a/sample/atticmatic.cron b/sample/atticmatic.cron new file mode 100644 index 0000000..a38e382 --- /dev/null +++ b/sample/atticmatic.cron @@ -0,0 +1,3 @@ +# You can drop this file into /etc/cron.d/ to run atticmatic nightly. + +0 3 * * * root /usr/local/bin/atticmatic diff --git a/sample/config b/sample/config new file mode 100644 index 0000000..66f508d --- /dev/null +++ b/sample/config @@ -0,0 +1,12 @@ +[location] +# Space-separated list of source directories to backup. +source_directories: /home /etc + +# Path to local or remote Attic repository. +repository: user@backupserver:sourcehostname.attic + +# Retention policy for how many backups to keep in each category. +[retention] +keep_daily: 7 +keep_weekly: 4 +keep_monthly: 6 diff --git a/sample/excludes b/sample/excludes new file mode 100644 index 0000000..7e81c88 --- /dev/null +++ b/sample/excludes @@ -0,0 +1,3 @@ +*.pyc +/home/*/.cache +/etc/ssl diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..5ebfca3 --- /dev/null +++ b/setup.py @@ -0,0 +1,11 @@ +from setuptools import setup, find_packages + +setup( + name='atticmatic', + version='0.0.1', + description='A wrapper script for Attic backup software', + author='Dan Helfman', + author_email='witten@torsion.org', + packages=find_packages(), + entry_points={'console_scripts': ['atticmatic = atticmatic.command:main']}, +)