Compare commits

...
19 Commits
Author SHA1 Message Date
Dan Helfman 9c06874073 #1: Add support for "borg check --last N" to Borg backend. 2015-07-27 21:47:52 -07:00
Dan Helfman f5e0e10143 #6: Fixing example config file to use valid keep_within value. 2015-07-27 19:06:39 -07:00
Dan Helfman 952a691f60 Linking to both Attic and Borg check docs from sample config. 2015-07-26 22:02:43 -07:00
Dan Helfman f94181480c Removing some annoying Pelican metadata from docs. 2015-07-26 21:29:14 -07:00
Dan Helfman c27b4a3497 Added tag 0.1.2 for changeset 83067f995dd3 2015-07-26 21:06:06 -07:00
Dan Helfman 58d33503a1 As a convenience to new users, allow a missing default excludes file. 2015-07-26 21:06:03 -07:00
Dan Helfman 38322a3f6f Linking to both Attic and Borg prune docs from sample config. 2015-07-26 20:57:31 -07:00
Dan Helfman 52ab7cb881 New issue tracker, linked from documentation. 2015-07-21 21:29:40 -07:00
Dan Helfman 17ac63aae6 Added tag 0.1.1 for changeset 7b6c87dca7ea 2015-07-18 23:49:06 -07:00
Dan Helfman 1f1c8fdaba Bumping version. 2015-07-18 23:48:58 -07:00
Dan Helfman ce6196a5c6 Added tag 0.1.1 for changeset ac5dfa01e9d1 2015-07-18 18:44:14 -07:00
Dan Helfman 6b0aa13856 Adding borgmatic cron example. 2015-07-18 18:44:11 -07:00
Dan Helfman d25db4cd0d Added tag 0.1.0 for changeset 38d72677343f 2015-07-18 18:39:33 -07:00
Dan Helfman 7097ed67a6 New "borgmatic" command to support Borg backup software, a fork of Attic. 2015-07-18 18:35:29 -07:00
Dan Helfman 52d5240fa0 Added tag 0.0.7 for changeset cf4c7065f071 2015-07-17 21:58:58 -07:00
Dan Helfman 5bf3a4875c Flag for multiple levels of verbosity: some, and lots. 2015-07-17 21:58:50 -07:00
Dan Helfman d9125451f5 Improved mocking of Python builtins in unit tests. 2015-06-14 11:00:46 -07:00
Dan Helfman c3613e0637 Adding some explanitory text about consistency checks to README example. 2015-05-10 22:06:48 -07:00
Dan Helfman c8f1af635f Added tag 0.0.6 for changeset 7ea93ca83f42 2015-05-10 22:00:34 -07:00
20 changed files with 574 additions and 280 deletions
+7
View File
@@ -8,3 +8,10 @@ aa8a807f4ba28f0652764ed14713ffea2fd6922d 0.0.5
569aef47a9b25c55b13753f94706f5d330219995 0.0.5
569aef47a9b25c55b13753f94706f5d330219995 0.0.5
a03495a8e8b471da63b5e2ae79d3ff9065839c2a 0.0.5
7ea93ca83f426ec0a608a68580c72c0775b81f86 0.0.6
cf4c7065f0711deda1cba878398bc05390e2c3f9 0.0.7
38d72677343f0a5d6845f4ac50d6778397083d45 0.1.0
ac5dfa01e9d14d09845f5e94c2c679e21c5eb2f9 0.1.1
ac5dfa01e9d14d09845f5e94c2c679e21c5eb2f9 0.1.1
7b6c87dca7ea312b2257ac1b46857b3f8c56b39c 0.1.1
83067f995dd391e38544a7722dc3b254b59c5521 0.1.2
+22
View File
@@ -1,3 +1,25 @@
0.1.3
* #1: Add support for "borg check --last N" to Borg backend.
0.1.2
* As a convenience to new users, allow a missing default excludes file.
* New issue tracker, linked from documentation.
0.1.1
* Adding borgmatic cron example, and updating documentation to refer to it.
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.
* Improved mocking of Python builtins in unit tests.
0.0.6
* New configuration section for customizing which Attic consistency checks run, if any.
+38 -20
View File
@@ -1,15 +1,14 @@
title: Atticmatic
date:
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 +16,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]
@@ -27,6 +26,7 @@ Here's an example config file:
keep_monthly: 6
[consistency]
# Consistency checks to run, or "disabled" to prevent checks.
checks: repository archives
Additionally, exclude patterns can be specified in a separate excludes config
@@ -40,14 +40,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.
@@ -56,13 +56,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/borgmatic.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
@@ -72,14 +78,23 @@ 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.
By default, the backup will proceed silently except in the case of errors. But
if you'd like to to get additional information about the progress of the
backup as it proceeds, use the verbose option instead:
backup as it proceeds, use the verbosity option:
atticmattic --verbose
atticmattic --verbosity 1
Or, for even more progress spew:
atticmattic --verbosity 2
If you'd like to see the available command-line arguments, view the help:
@@ -118,6 +133,9 @@ This should make the client keep the connection alive while validating
backups.
## Feedback
## Issues and feedback
Questions? Comments? Got a patch? Contact <mailto:witten@torsion.org>.
Got an issue or an idea for a feature enhancement? Check out the [atticmatic
issue tracker](https://tree.taiga.io/project/witten-atticmatic/issues).
Other questions or comments? Contact <mailto:witten@torsion.org>.
-136
View File
@@ -1,136 +0,0 @@
from datetime import datetime
import os
import platform
import subprocess
def create_archive(excludes_filename, verbose, source_directories, repository):
'''
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.
'''
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 _make_prune_flags(retention_config):
'''
Given a retention config dict mapping from option name to value, tranform it into an iterable of
command-line name-value flag pairs.
For example, given a retention config of:
{'keep_weekly': 4, 'keep_monthly': 6}
This will be returned as an iterable of:
(
('--keep-weekly', '4'),
('--keep-monthly', '6'),
)
'''
return (
('--' + option_name.replace('_', '-'), str(retention_config[option_name]))
for option_name, value in retention_config.items()
)
def prune_archives(verbose, repository, retention_config):
'''
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.
'''
command = (
'attic', 'prune',
repository,
) + tuple(
element
for pair in _make_prune_flags(retention_config)
for element in pair
) + (('--verbose',) if verbose else ())
subprocess.check_call(command)
DEFAULT_CHECKS = ('repository', 'archives')
def _parse_checks(consistency_config):
'''
Given a consistency config with a space-separated "checks" option, transform it to a tuple of
named checks to run.
For example, given a retention config of:
{'checks': 'repository archives'}
This will be returned as:
('repository', 'archives')
If no "checks" option is present, return the DEFAULT_CHECKS. If the checks value is the string
"disabled", return an empty tuple, meaning that no checks should be run.
'''
checks = consistency_config.get('checks', '').strip()
if not checks:
return DEFAULT_CHECKS
return tuple(
check for check in consistency_config['checks'].split(' ')
if check.lower() not in ('disabled', '')
)
def _make_check_flags(checks):
'''
Given a parsed sequence of checks, transform it into tuple of command-line flags.
For example, given parsed checks of:
('repository',)
This will be returned as:
('--repository-only',)
'''
if checks == DEFAULT_CHECKS:
return ()
return tuple(
'--{}-only'.format(check) for check in checks
)
def check_archives(verbose, repository, consistency_config):
'''
Given a verbosity flag, a local or remote repository path, and a consistency config dict, check
the contained attic archives for consistency.
If there are no consistency checks to run, skip running them.
'''
checks = _parse_checks(consistency_config)
if not checks:
return
command = (
'attic', 'check',
repository,
) + _make_check_flags(checks) + (('--verbose',) if verbose else ())
# Attic's check command spews to stdout even without the verbose flag. Suppress it.
stdout = None if verbose else open(os.devnull, 'w')
subprocess.check_call(command, stdout=stdout)
View File
+12
View File
@@ -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'
CONFIG_FORMAT = shared.CONFIG_FORMAT
create_archive = partial(shared.create_archive, command=COMMAND)
prune_archives = partial(shared.prune_archives, command=COMMAND)
check_archives = partial(shared.check_archives, command=COMMAND)
+24
View File
@@ -0,0 +1,24 @@
from functools import partial
from atticmatic.config import Section_format, option
from atticmatic.backends import shared
# An atticmatic backend that supports Borg for actually handling backups.
COMMAND = 'borg'
CONFIG_FORMAT = (
shared.CONFIG_FORMAT[0], # location
shared.CONFIG_FORMAT[1], # retention
Section_format(
'consistency',
(
option('checks', required=False),
option('check_last', required=False),
),
)
)
create_archive = partial(shared.create_archive, command=COMMAND)
prune_archives = partial(shared.prune_archives, command=COMMAND)
check_archives = partial(shared.check_archives, command=COMMAND)
+191
View File
@@ -0,0 +1,191 @@
from datetime import datetime
import os
import platform
import subprocess
from atticmatic.config import Section_format, option
from atticmatic.verbosity import VERBOSITY_SOME, VERBOSITY_LOTS
# 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.
CONFIG_FORMAT = (
Section_format(
'location',
(
option('source_directories'),
option('repository'),
),
),
Section_format(
'retention',
(
option('keep_within', required=False),
option('keep_hourly', int, required=False),
option('keep_daily', int, required=False),
option('keep_weekly', int, required=False),
option('keep_monthly', int, required=False),
option('keep_yearly', int, required=False),
option('prefix', required=False),
),
),
Section_format(
'consistency',
(
option('checks', required=False),
),
)
)
def create_archive(excludes_filename, verbosity, source_directories, repository, command):
'''
Given an excludes filename (or None), 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(' '))
exclude_flags = ('--exclude-from', excludes_filename) if excludes_filename else ()
verbosity_flags = {
VERBOSITY_SOME: ('--stats',),
VERBOSITY_LOTS: ('--verbose', '--stats'),
}.get(verbosity, ())
full_command = (
command, 'create',
'{repo}::{hostname}-{timestamp}'.format(
repo=repository,
hostname=platform.node(),
timestamp=datetime.now().isoformat(),
),
) + sources + exclude_flags + verbosity_flags
subprocess.check_call(full_command)
def _make_prune_flags(retention_config):
'''
Given a retention config dict mapping from option name to value, tranform it into an iterable of
command-line name-value flag pairs.
For example, given a retention config of:
{'keep_weekly': 4, 'keep_monthly': 6}
This will be returned as an iterable of:
(
('--keep-weekly', '4'),
('--keep-monthly', '6'),
)
'''
return (
('--' + option_name.replace('_', '-'), str(retention_config[option_name]))
for option_name, value in retention_config.items()
)
def prune_archives(verbosity, repository, retention_config, command):
'''
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, ())
full_command = (
command, 'prune',
repository,
) + tuple(
element
for pair in _make_prune_flags(retention_config)
for element in pair
) + verbosity_flags
subprocess.check_call(full_command)
DEFAULT_CHECKS = ('repository', 'archives')
def _parse_checks(consistency_config):
'''
Given a consistency config with a space-separated "checks" option, transform it to a tuple of
named checks to run.
For example, given a retention config of:
{'checks': 'repository archives'}
This will be returned as:
('repository', 'archives')
If no "checks" option is present, return the DEFAULT_CHECKS. If the checks value is the string
"disabled", return an empty tuple, meaning that no checks should be run.
'''
checks = consistency_config.get('checks', '').strip()
if not checks:
return DEFAULT_CHECKS
return tuple(
check for check in consistency_config['checks'].split(' ')
if check.lower() not in ('disabled', '')
)
def _make_check_flags(checks, check_last=None):
'''
Given a parsed sequence of checks, transform it into tuple of command-line flags.
For example, given parsed checks of:
('repository',)
This will be returned as:
('--repository-only',)
Additionally, if a check_last value is given, a "--last" flag will be added. Note that only
Borg supports this flag.
'''
last_flag = ('--last', check_last) if check_last else ()
if checks == DEFAULT_CHECKS:
return last_flag
return tuple(
'--{}-only'.format(check) for check in checks
) + last_flag
def check_archives(verbosity, repository, consistency_config, command):
'''
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.
'''
checks = _parse_checks(consistency_config)
check_last = consistency_config.get('check_last', None)
if not checks:
return
verbosity_flags = {
VERBOSITY_SOME: ('--verbose',),
VERBOSITY_LOTS: ('--verbose',),
}.get(verbosity, ())
full_command = (
command, 'check',
repository,
) + _make_check_flags(checks, check_last) + verbosity_flags
# 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(full_command, stdout=stdout)
+36 -15
View File
@@ -1,51 +1,72 @@
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.
'''
config_filename_default = DEFAULT_CONFIG_FILENAME_PATTERN.format(command_name)
excludes_filename_default = DEFAULT_EXCLUDES_FILENAME_PATTERN.format(command_name)
parser = ArgumentParser()
parser.add_argument(
'-c', '--config',
dest='config_filename',
default=DEFAULT_CONFIG_FILENAME,
default=config_filename_default,
help='Configuration filename',
)
parser.add_argument(
'--excludes',
dest='excludes_filename',
default=DEFAULT_EXCLUDES_FILENAME,
default=excludes_filename_default if os.path.exists(excludes_filename_default) else None,
help='Excludes filename',
)
parser.add_argument(
'-v', '--verbose',
action='store_true',
help='Display verbose progress information',
'-v', '--verbosity',
type=int,
help='Display verbose progress (1 for some, 2 for lots)',
)
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:])
config = parse_configuration(args.config_filename)
command_name = os.path.basename(sys.argv[0])
args = parse_arguments(command_name, *sys.argv[1:])
backend = load_backend(command_name)
config = parse_configuration(args.config_filename, backend.CONFIG_FORMAT)
repository = config.location['repository']
create_archive(args.excludes_filename, args.verbose, **config.location)
prune_archives(args.verbose, repository, config.retention)
check_archives(args.verbose, 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)
+10 -40
View File
@@ -20,35 +20,6 @@ def option(name, value_type=str, required=True):
return Config_option(name, value_type, required)
CONFIG_FORMAT = (
Section_format(
'location',
(
option('source_directories'),
option('repository'),
),
),
Section_format(
'retention',
(
option('keep_within', required=False),
option('keep_hourly', int, required=False),
option('keep_daily', int, required=False),
option('keep_weekly', int, required=False),
option('keep_monthly', int, required=False),
option('keep_yearly', int, required=False),
option('prefix', required=False),
),
),
Section_format(
'consistency',
(
option('checks', required=False),
),
)
)
def validate_configuration_format(parser, config_format):
'''
Given an open ConfigParser and an expected config file format, validate that the parsed
@@ -110,11 +81,6 @@ def validate_configuration_format(parser, config_format):
)
# Describes a parsed configuration, where each attribute is the name of a configuration file section
# and each value is a dict of that section's parsed options.
Parsed_config = namedtuple('Config', (section_format.name for section_format in CONFIG_FORMAT))
def parse_section_options(parser, section_format):
'''
Given an open ConfigParser and an expected section format, return the option values from that
@@ -135,21 +101,25 @@ def parse_section_options(parser, section_format):
)
def parse_configuration(config_filename):
def parse_configuration(config_filename, config_format):
'''
Given a config filename of the expected format, return the parsed configuration as Parsed_config
data structure.
Given a config filename and an expected config file format, return the parsed configuration
as a namedtuple with one attribute for each parsed section.
Raise IOError if the file cannot be read, or ValueError if the format is not as expected.
'''
parser = ConfigParser()
parser.readfp(open(config_filename))
parser.read(config_filename)
validate_configuration_format(parser, CONFIG_FORMAT)
validate_configuration_format(parser, config_format)
# Describes a parsed configuration, where each attribute is the name of a configuration file
# section and each value is a dict of that section's parsed options.
Parsed_config = namedtuple('Parsed_config', (section_format.name for section_format in config_format))
return Parsed_config(
*(
parse_section_options(parser, section_format)
for section_format in CONFIG_FORMAT
for section_format in config_format
)
)
+11
View File
@@ -0,0 +1,11 @@
from flexmock import flexmock
import sys
def builtins_mock():
try:
# Python 2
return flexmock(sys.modules['__builtin__'])
except KeyError:
# Python 3
return flexmock(sys.modules['builtins'])
+45 -13
View File
@@ -1,40 +1,72 @@
import os
import sys
from flexmock import flexmock
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
assert parser.verbose == False
def test_parse_arguments_with_no_arguments_uses_defaults():
flexmock(os.path).should_receive('exists').and_return(True)
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')
flexmock(os.path).should_receive('exists').and_return(True)
parser = module.parse_arguments(COMMAND_NAME, '--config', 'myconfig', '--excludes', 'myexcludes')
assert parser.config_filename == 'myconfig'
assert parser.excludes_filename == 'myexcludes'
assert parser.verbose == False
assert parser.verbosity == None
def test_parse_arguments_with_verbose_flag_overrides_default():
parser = module.parse_arguments('--verbose')
def test_parse_arguments_with_missing_default_excludes_file_sets_filename_to_none():
flexmock(os.path).should_receive('exists').and_return(False)
assert parser.config_filename == module.DEFAULT_CONFIG_FILENAME
assert parser.excludes_filename == module.DEFAULT_EXCLUDES_FILENAME
assert parser.verbose == True
parser = module.parse_arguments(COMMAND_NAME)
assert parser.config_filename == module.DEFAULT_CONFIG_FILENAME_PATTERN.format(COMMAND_NAME)
assert parser.excludes_filename == None
assert parser.verbosity == None
def test_parse_arguments_with_missing_overridden_excludes_file_retains_filename():
flexmock(os.path).should_receive('exists').and_return(False)
parser = module.parse_arguments(COMMAND_NAME, '--excludes', 'myexcludes')
assert parser.config_filename == module.DEFAULT_CONFIG_FILENAME_PATTERN.format(COMMAND_NAME)
assert parser.excludes_filename == 'myexcludes'
assert parser.verbosity == None
def test_parse_arguments_with_verbosity_flag_overrides_default():
flexmock(os.path).should_receive('exists').and_return(True)
parser = module.parse_arguments(COMMAND_NAME, '--verbosity', '1')
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
def test_parse_arguments_with_invalid_arguments_exits():
flexmock(os.path).should_receive('exists').and_return(True)
original_stderr = sys.stderr
sys.stderr = sys.stdout
try:
with assert_raises(SystemExit):
module.parse_arguments('--posix-me-harder')
module.parse_arguments(COMMAND_NAME, '--posix-me-harder')
finally:
sys.stderr = original_stderr
@@ -2,7 +2,9 @@ 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
def insert_subprocess_mock(check_call_command, **kwargs):
@@ -18,7 +20,7 @@ def insert_subprocess_never():
def insert_platform_mock():
flexmock(module).platform = flexmock().should_receive('node').and_return('host').mock
flexmock(module.platform).should_receive('node').and_return('host')
def insert_datetime_mock():
@@ -27,36 +29,63 @@ def insert_datetime_mock():
).mock
CREATE_COMMAND_WITHOUT_EXCLUDES = ('attic', 'create', 'repo::host-now', 'foo', 'bar')
CREATE_COMMAND = CREATE_COMMAND_WITHOUT_EXCLUDES + ('--exclude-from', 'excludes')
def test_create_archive_should_call_attic_with_parameters():
insert_subprocess_mock(
('attic', 'create', '--exclude-from', 'excludes', 'repo::host-now', 'foo', 'bar'),
)
insert_subprocess_mock(CREATE_COMMAND)
insert_platform_mock()
insert_datetime_mock()
module.create_archive(
excludes_filename='excludes',
verbose=False,
verbosity=None,
source_directories='foo bar',
repository='repo',
command='attic',
)
def test_create_archive_with_verbose_should_call_attic_with_verbose_parameters():
insert_subprocess_mock(
(
'attic', 'create', '--exclude-from', 'excludes', 'repo::host-now', 'foo', 'bar',
'--verbose', '--stats',
),
def test_create_archive_with_none_excludes_filename_should_call_attic_without_excludes():
insert_subprocess_mock(CREATE_COMMAND_WITHOUT_EXCLUDES)
insert_platform_mock()
insert_datetime_mock()
module.create_archive(
excludes_filename=None,
verbosity=None,
source_directories='foo bar',
repository='repo',
command='attic',
)
def test_create_archive_with_verbosity_some_should_call_attic_with_stats_parameter():
insert_subprocess_mock(CREATE_COMMAND + ('--stats',))
insert_platform_mock()
insert_datetime_mock()
module.create_archive(
excludes_filename='excludes',
verbose=True,
verbosity=VERBOSITY_SOME,
source_directories='foo bar',
repository='repo',
command='attic',
)
def test_create_archive_with_verbosity_lots_should_call_attic_with_verbose_parameter():
insert_subprocess_mock(CREATE_COMMAND + ('--verbose', '--stats'))
insert_platform_mock()
insert_datetime_mock()
module.create_archive(
excludes_filename='excludes',
verbosity=VERBOSITY_LOTS,
source_directories='foo bar',
repository='repo',
command='attic',
)
@@ -81,41 +110,53 @@ def test_make_prune_flags_should_return_flags_from_config():
assert tuple(result) == BASE_PRUNE_FLAGS
PRUNE_COMMAND = (
'attic', 'prune', 'repo', '--keep-daily', '1', '--keep-weekly', '2', '--keep-monthly', '3',
)
def test_prune_archives_should_call_attic_with_parameters():
retention_config = flexmock()
flexmock(module).should_receive('_make_prune_flags').with_args(retention_config).and_return(
BASE_PRUNE_FLAGS,
)
insert_subprocess_mock(
(
'attic', 'prune', 'repo', '--keep-daily', '1', '--keep-weekly', '2', '--keep-monthly',
'3',
),
)
insert_subprocess_mock(PRUNE_COMMAND)
module.prune_archives(
verbose=False,
verbosity=None,
repository='repo',
retention_config=retention_config,
command='attic',
)
def test_prune_archives_with_verbose_should_call_attic_with_verbose_parameters():
def test_prune_archives_with_verbosity_some_should_call_attic_with_stats_parameter():
retention_config = flexmock()
flexmock(module).should_receive('_make_prune_flags').with_args(retention_config).and_return(
BASE_PRUNE_FLAGS,
)
insert_subprocess_mock(
(
'attic', 'prune', 'repo', '--keep-daily', '1', '--keep-weekly', '2', '--keep-monthly',
'3', '--verbose',
),
)
insert_subprocess_mock(PRUNE_COMMAND + ('--stats',))
module.prune_archives(
repository='repo',
verbose=True,
verbosity=VERBOSITY_SOME,
retention_config=retention_config,
command='attic',
)
def test_prune_archives_with_verbosity_lots_should_call_attic_with_verbose_parameter():
retention_config = flexmock()
flexmock(module).should_receive('_make_prune_flags').with_args(retention_config).and_return(
BASE_PRUNE_FLAGS,
)
insert_subprocess_mock(PRUNE_COMMAND + ('--verbose', '--stats',))
module.prune_archives(
repository='repo',
verbosity=VERBOSITY_LOTS,
retention_config=retention_config,
command='attic',
)
@@ -155,10 +196,24 @@ def test_make_check_flags_with_default_checks_returns_no_flags():
assert flags == ()
def test_make_check_flags_with_checks_and_last_returns_flags_including_last():
flags = module._make_check_flags(('foo', 'bar'), check_last=3)
assert flags == ('--foo-only', '--bar-only', '--last', 3)
def test_make_check_flags_with_last_returns_last_flag():
flags = module._make_check_flags(module.DEFAULT_CHECKS, check_last=3)
assert flags == ('--last', 3)
def test_check_archives_should_call_attic_with_parameters():
consistency_config = flexmock()
flexmock(module).should_receive('_parse_checks').and_return(flexmock())
flexmock(module).should_receive('_make_check_flags').and_return(())
checks = flexmock()
check_last = flexmock()
consistency_config = flexmock().should_receive('get').and_return(check_last).mock
flexmock(module).should_receive('_parse_checks').and_return(checks)
flexmock(module).should_receive('_make_check_flags').with_args(checks, check_last).and_return(())
stdout = flexmock()
insert_subprocess_mock(
('attic', 'check', 'repo'),
@@ -166,18 +221,19 @@ def test_check_archives_should_call_attic_with_parameters():
)
insert_platform_mock()
insert_datetime_mock()
flexmock(module).open = lambda filename, mode: stdout
flexmock(module).os = flexmock().should_receive('devnull').mock
builtins_mock().should_receive('open').and_return(stdout)
flexmock(module.os).should_receive('devnull')
module.check_archives(
verbose=False,
verbosity=None,
repository='repo',
consistency_config=consistency_config,
command='attic',
)
def test_check_archives_with_verbose_should_call_attic_with_verbose_parameters():
consistency_config = flexmock()
def test_check_archives_with_verbosity_some_should_call_attic_with_verbose_parameter():
consistency_config = flexmock().should_receive('get').and_return(None).mock
flexmock(module).should_receive('_parse_checks').and_return(flexmock())
flexmock(module).should_receive('_make_check_flags').and_return(())
insert_subprocess_mock(
@@ -188,21 +244,40 @@ def test_check_archives_with_verbose_should_call_attic_with_verbose_parameters()
insert_datetime_mock()
module.check_archives(
verbose=True,
verbosity=VERBOSITY_SOME,
repository='repo',
consistency_config=consistency_config,
command='attic',
)
def test_check_archives_with_verbosity_lots_should_call_attic_with_verbose_parameter():
consistency_config = flexmock().should_receive('get').and_return(None).mock
flexmock(module).should_receive('_parse_checks').and_return(flexmock())
flexmock(module).should_receive('_make_check_flags').and_return(())
insert_subprocess_mock(
('attic', 'check', 'repo', '--verbose'),
stdout=None,
)
insert_platform_mock()
insert_datetime_mock()
module.check_archives(
verbosity=VERBOSITY_LOTS,
repository='repo',
consistency_config=consistency_config,
command='attic',
)
def test_check_archives_without_any_checks_should_bail():
consistency_config = flexmock()
consistency_config = flexmock().should_receive('get').and_return(None).mock
flexmock(module).should_receive('_parse_checks').and_return(())
insert_subprocess_never()
module.check_archives(
verbose=False,
verbosity=None,
repository='repo',
consistency_config=consistency_config,
command='attic',
)
+33
View File
@@ -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
+8 -8
View File
@@ -197,26 +197,26 @@ def test_parse_section_options_for_missing_section_should_return_empty_dict():
def insert_mock_parser():
parser = flexmock()
parser.should_receive('readfp')
flexmock(module).open = lambda filename: None
flexmock(module).ConfigParser = parser
parser.should_receive('read')
module.ConfigParser = lambda: parser
return parser
def test_parse_configuration_should_return_section_configs():
parser = insert_mock_parser()
config_format = (flexmock(name='items'), flexmock(name='things'))
mock_module = flexmock(module)
mock_module.should_receive('validate_configuration_format').with_args(
parser, module.CONFIG_FORMAT,
parser, config_format,
).once()
mock_section_configs = (flexmock(),) * len(module.CONFIG_FORMAT)
mock_section_configs = (flexmock(), flexmock())
for section_format, section_config in zip(module.CONFIG_FORMAT, mock_section_configs):
for section_format, section_config in zip(config_format, mock_section_configs):
mock_module.should_receive('parse_section_options').with_args(
parser, section_format,
).and_return(section_config).once()
parsed_config = module.parse_configuration('filename')
parsed_config = module.parse_configuration('filename', config_format)
assert parsed_config == module.Parsed_config(*mock_section_configs)
assert parsed_config == type(parsed_config)(*mock_section_configs)
+2
View File
@@ -0,0 +1,2 @@
VERBOSITY_SOME = 1
VERBOSITY_LOTS = 2
+3
View File
@@ -0,0 +1,3 @@
# You can drop this file into /etc/cron.d/ to run borgmatic nightly.
0 3 * * * root PATH=$PATH:/usr/local/bin /usr/local/bin/borgmatic
+8 -4
View File
@@ -2,13 +2,14 @@
# 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]
# Retention policy for how many backups to keep in each category. See
# https://attic-backup.org/usage.html#attic-prune for details.
#keep_within: 3h
# https://attic-backup.org/usage.html#attic-prune or
# https://borgbackup.github.io/borgbackup/usage.html#borg-prune for details.
#keep_within: 3H
#keep_hourly: 24
keep_daily: 7
keep_weekly: 4
@@ -19,5 +20,8 @@ keep_yearly: 1
[consistency]
# Space-separated list of consistency checks to run: "repository", "archives",
# or both. Defaults to both. Set to "disabled" to disable all consistency
# checks. See https://attic-backup.org/usage.html#attic-check for details.
# checks. See https://attic-backup.org/usage.html#attic-check or
# https://borgbackup.github.io/borgbackup/usage.html#borg-check for details.
checks: repository archives
# For Borg only, you can restrict the number of checked archives to the last n.
#check_last: 3
+8 -3
View File
@@ -2,12 +2,17 @@ from setuptools import setup, find_packages
setup(
name='atticmatic',
version='0.0.6',
description='A wrapper script for Attic backup software that creates and prunes backups',
version='0.1.1',
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',