add restore verb

This commit is contained in:
Conor Hughes 2014-10-25 09:08:11 -07:00
parent c75034ff37
commit c647995056
5 changed files with 172 additions and 17 deletions

View File

@ -1,4 +1,5 @@
#!/usr/bin/env python2.7
# -*- coding: utf-8 -*-
import logging
import os
@ -6,12 +7,19 @@ import sys
import traceback
import datetime
import dateutil
import dateutil.tz
from . import configuration
from . import package_logger
from . import error
from . import backend_types
from . import logging_handlers
from . import archive_specifiers
def pretty_archive(archive):
local_time = archive.datetime.astimezone(dateutil.tz.tzlocal())
human_time = local_time.strftime("%Y-%m-%d %H:%M:%S")
return "{} ({})".format(human_time, archive.timestamp)
class Application(object):
@property
@ -50,9 +58,23 @@ class Application(object):
def get_all_backups(self):
return self.config.all_configured_backups()
def get_backup_by_name(self, name):
for backup in self.get_all_backups():
if backup.name == name:
return backup
else:
raise error.Error("Couldn't find backup with name {}".format(name))
def get_all_backends(self):
return self.config.all_configured_backends()
def get_backend_by_name(self, name):
for backend in self.get_all_backends():
if backend.name == name:
return backend
else:
raise error.Error("Couldn't find backend with name {}".format(name))
def note_successful_backups(self, backups):
self.config.save_state_given_new_backups(backups)
@ -62,8 +84,7 @@ class Application(object):
def within_timespec(self, archive):
before = self.config.config_options.before
after = self.config.config_options.after
archive_date = datetime.datetime.utcfromtimestamp(archive.time)
archive_date = archive_date.replace(tzinfo=dateutil.tz.tzutc())
archive_date = archive.datetime
if before is not None and archive_date >= before:
return False
if after is not None and archive_date <= after:
@ -83,16 +104,14 @@ class Application(object):
self.note_successful_backups(backup_successes)
self.logger.info("Successfully completed {}/{} backups.".format(len(backup_successes), len(backups)))
def list_backups(self):
def list_archives(self):
for backup in self.get_all_backups():
sys.stdout.write("{}:\n".format(backup.name))
for backend, archives in backup.get_all_archives():
archives = (archive for archive in archives if self.within_timespec(archive))
sys.stdout.write("\t{}:\n".format(backend.name))
for archive in sorted(archives, cmp=lambda x,y: cmp(x.time, y.time)):
time = datetime.datetime.fromtimestamp(archive.time)
human_time = time.strftime("%Y-%m-%d %H:%M:%S")
sys.stdout.write("\t\t{} ({})\n".format(human_time, archive.time))
for archive in sorted(archives, cmp=lambda x,y: cmp(x.datetime, y.datetime)):
sys.stdout.write("\t\t{}\n".format(pretty_archive(archive)))
def list_configured_backups(self):
for backup in self.get_all_backups():
@ -104,21 +123,50 @@ class Application(object):
for backend in self.get_all_backends():
sys.stdout.write("{}\n".format(backend))
def restore_backup(self):
backup_name = self.config.config_options.backup
backup = self.get_backup_by_name(backup_name)
backend_name = self.config.config_options.backend
backend = self.get_backend_by_name(backend_name)
if backend not in backup.backends:
raise error.Error(
"backend {} not configured for backup {}".format(backend_name,
backup_name))
spec_str = self.config.config_options.archive_spec
spec = archive_specifiers.ArchiveSpecifier(spec_str)
matches = []
for _, archives in backup.get_all_archives(backends=[backend]):
for i, archive in enumerate(sorted(archives, cmp=lambda x,y: cmp(x.datetime, y.datetime))):
if spec.evaluate(archive, i):
matches.append(archive)
if len(matches) > 1:
msg = "Spec {} matched more than one archive!".format(spec_str)
for match in matches:
msg += "\n\t{}".format(pretty_archive(match))
raise error.Error(msg)
if len(matches) == 0:
raise error.Error("Spec {} matched no archives!".format(spec_str))
archive = matches[0]
return archive.restore(self.config.config_options.destination)
def unknown_verb(self):
raise Exception("Unknown verb")
def run(self):
verbs = {
"backup": self.perform_backups,
"list": self.list_backups,
"list": self.list_archives,
"list-configured-backups": self.list_configured_backups,
"list-backends": self.list_backends
"list-backends": self.list_backends,
"restore": self.restore_backup
}
try:
self.bootstrap()
self.load_config()
verbs.get(self.config.config_options.verb, self.unknown_verb)()
sys.exit(0)
ok = verbs.get(self.config.config_options.verb, self.unknown_verb)()
sys.exit(0 if ok or ok is None else 1)
except error.Error as e:
self.logger.fatal(e.message)
sys.exit(1)

View File

@ -0,0 +1,59 @@
#!/usr/bin/env python2.7
# -*- coding: utf-8 -*-
CONCRETE_SPECIFIERS = set()
class ArchiveSpecifierMeta(type):
def __init__(cls, *args, **kwargs):
super(ArchiveSpecifierMeta, cls).__init__(*args, **kwargs)
if cls.concrete:
CONCRETE_SPECIFIERS.add(cls)
def __call__(cls, specifier_str, *args, **kwargs):
if cls in CONCRETE_SPECIFIERS:
return super(ArchiveSpecifierMeta, cls).__call__(specifier_str, *args, **kwargs)
for specifier_type in CONCRETE_SPECIFIERS:
if specifier_type.acceptable_specifier(specifier_str):
return specifier_type(specifier_str)
raise ValueError("No specifier matched {}".format(specifier_str))
@property
def concrete(self):
return not self.__dict__.get("ABSTRACT", False)
class ArchiveSpecifier(object):
__metaclass__ = ArchiveSpecifierMeta
ABSTRACT = True
class OrdinalArchiveSpecifier(ArchiveSpecifier):
@classmethod
def acceptable_specifier(cls, specifier_str):
try:
value = int(specifier_str)
except ValueError:
return False
return value < 1000000000
def __init__(self, specifier_str):
self.ordinal = int(specifier_str)
def evaluate(self, archive, ordinal):
return ordinal == self.ordinal
class TimestampArchiveSpecifier(ArchiveSpecifier):
@classmethod
def acceptable_specifier(cls, specifier_str):
try:
value = float(specifier_str)
except ValueError:
return False
has_dot = "." in specifier_str
return value > 1000000000 or has_dot
def __init__(self, specifier_str):
self.timestamp = float(specifier_str)
def evaluate(self, archive, ordinal):
return archive.timestamp == self.timestamp

View File

@ -2,6 +2,8 @@
import pkgutil
import inspect
import datetime
import dateutil.tz
from . import error
@ -39,5 +41,12 @@ class BackupBackend(object):
def __str__(self):
return "{}: {}".format(self.__class__.__name__, self.name)
class Archive(object):
@property
def datetime(self):
dt = datetime.datetime.fromtimestamp(self.timestamp)
dt = dt.replace(tzinfo=dateutil.tz.tzlocal())
return dt
def load_backend_types():
from . import backup_backends

View File

@ -6,6 +6,8 @@ import shutil
import errno
import subprocess
import hashlib
import datetime
import dateutil.tz
import time
import re
@ -19,10 +21,28 @@ def backup_instance_regex(identifier, name):
r"^(?P<identifier>{})-(?P<timestamp>\d+(.\d+)?)-(?P<name>{})$"
.format(re.escape(identifier), re.escape(name)))
class TarsnapArchive(object):
def __init__(self, backend, time):
class TarsnapArchive(backend_types.Archive):
@property
def logger(self):
return package_logger().getChild("tarsnap_archive")
def __init__(self, backend, timestamp, fullname):
self.fullname = fullname
self.backend = backend
self.time = time
self.timestamp = timestamp
def restore(self, destination):
argv = [TARSNAP_PATH, "-C", destination, "-x", "-f", self.fullname]
proc = subprocess.Popen(argv, stderr=subprocess.STDOUT, stdout=subprocess.PIPE)
proc_logger = self.logger.getChild("tarsnap_output")
for line in proc.stdout:
proc_logger.info(line.strip())
code = proc.wait()
if code != 0:
self.logger.error("Tarsnap invocation failed with exit code {}".format(code))
return False
return True
class TarsnapBackend(backend_types.BackupBackend):
NAMES = {"tarsnap"}
@ -98,7 +118,7 @@ class TarsnapBackend(backend_types.BackupBackend):
m = regex.match(line)
if m:
ts = float(m.groupdict()["timestamp"])
results.append(TarsnapArchive(self, ts))
results.append(TarsnapArchive(self, ts, m.group()))
if proc.wait() != 0:
self.logger.error("Tarsnap invocation failed with exit code {}".format(proc.returncode))

View File

@ -139,10 +139,17 @@ class ConfiguredBackup(object):
success = success and backend.perform(self.paths, self.name)
return success
def get_all_archives(self):
def get_all_archives(self, backends=None):
if backends is None:
backends = self.backends
for backend in backends:
if backend not in self.backends:
raise Exception("Passed a backend we don't own!?")
pairs = []
for backend in self.backends:
for backend in backends:
pairs.append(
[backend, backend.existing_archives_for_name(self.name)])
@ -216,18 +223,30 @@ class Config(object):
parser.add_argument("-q", "--quiet", action="store_true",
help="Be quiet on logging to stdout/stderr")
subparsers = parser.add_subparsers()
parser_backup = subparsers.add_parser("backup")
parser_backup.set_defaults(verb="backup")
parser_list = subparsers.add_parser("list")
parser_list.set_defaults(verb="list")
parser_list.add_argument("--before", dest="before", default=None,
type=parse_simple_date)
parser_list.add_argument("--after", dest="after", default=None,
type=parse_simple_date)
parser_restore = subparsers.add_parser("restore")
parser_restore.set_defaults(verb="restore")
parser_restore.add_argument("backup", metavar="BACKUPNAME", type=str)
parser_restore.add_argument("backend", metavar="BACKENDNAME", type=str)
parser_restore.add_argument("archive_spec", metavar="SPEC", type=str)
parser_restore.add_argument("destination", metavar="DEST", type=str)
parser_list_backups = subparsers.add_parser("list-configured-backups")
parser_list_backups.set_defaults(verb="list-configured-backups")
parser_list_backends = subparsers.add_parser("list-backends")
parser_list_backends.set_defaults(verb="list-backends")
return parser.parse_args(self.argv)
def default_state(self):