backupmgr/backupmgr/configuration.py

311 lines
11 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import os
import sys
import os.path
import json
import errno
import time
import datetime
import collections
import itertools
import socket
import argparse
import dateutil.parser, dateutil.tz
from . import package_logger
from . import error
from . import backend_types
from . import backup
from .backup import (MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY,
SUNDAY, WEEKLY, MONTHLY)
LOCAL_TZ = dateutil.tz.tzlocal()
if sys.platform.startswith("darwin"):
DEFAULT_STATEFILE = "/var/db/backupmgr.state"
else:
DEFAULT_STATEFILE = "/var/lib/backupmgr/state"
CONFIG_LOCATION = "/etc/backupmgr.conf"
def module_logger():
return package_logger().getChild("configuration")
def prefix_match(s1, s2, required_length):
return s1[:required_length] == s2[:required_length]
def validate_timespec(spec):
if isinstance(spec, str):
if spec.lower() == "weekly":
return [WEEKLY]
if spec.lower() == "monthly":
return [MONTHLY]
if spec.lower() == "daily":
return [MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY]
else:
spec = [spec]
if not isinstance(spec, list) or any((not isinstance(x, str) for x in spec)):
raise InvalidConfigError("Invalid timespec {}".format(spec))
new_spec = set()
for item in (item.lower() for item in spec):
newitem = None
if prefix_match(item, "monday", 2):
newitem = MONDAY
elif prefix_match(item, "tuesday", 2):
newitem = TUESDAY
elif prefix_match(item, "wednesday", 2):
newitem = WEDNESDAY
elif prefix_match(item, "thursday", 2):
newitem = THURSDAY
elif prefix_match(item, "friday", 2):
newitem = FRIDAY
elif prefix_match(item, "saturday", 2):
newitem = SATURDAY
elif prefix_match(item, "sunday", 2):
newitem = SUNDAY
new_spec.add(newitem)
return new_spec
def validate_paths(paths):
if (not isinstance(paths, dict)
or any((not isinstance(x, str) for x in paths.keys()))
or any((not isinstance(x, str) for x in paths.values()))):
raise InvalidConfigError("paths should be a string -> string dictionary")
names = set()
for path, name in paths.items():
if not os.path.isabs(path):
raise InvalidConfigError("{} was not an absolute path".format(path))
if not name or '/' in name or '\\' in name or name == '..':
raise InvalidConfigError("Invalid name: \"{}\"".format(name))
if name in names:
raise InvalidConfigError("Name collision: \"{}\"".format(name))
names.add(name)
return paths
def parse_simple_date(datestr):
try:
timestamp = float(datestr)
except ValueError:
date = dateutil.parser.parse(datestr)
if date.tzinfo is None:
# If there is no tz info in this date, assume the user meant to use
# local time
date = date.replace(tzinfo=dateutil.tz.tzlocal())
else:
date = datetime.datetime.fromtimestamp(timestamp)
date = date.replace(tzinfo=LOCAL_TZ)
return date
BackupPruningConfiguration = collections.namedtuple(
"BackupPruningConfiguration", ["backup_name", "daily_count", "weekly_count", "monthly_count"])
class PruningConfiguration(object):
def __init__(self, backup_pruning_configs):
self.__map = {}
for config in backup_pruning_configs:
self.__map[config.backup_name] = config
def get_backup_pruning_config(self, name):
inf = float("inf")
return self.__map.get(name, BackupPruningConfiguration(name, inf, inf, inf))
class NoConfigError(error.Error):
def __init__(self):
super(NoConfigError, self).__init__("No config exists.")
class InvalidConfigError(error.Error):
def __init__(self, msg):
super(InvalidConfigError, self).__init__("Invalid config: {}".format(msg))
class Config(object):
@property
def logger(self):
return module_logger().getChild("Config")
def parse_args(self):
parser = argparse.ArgumentParser(prog=self.prog)
parser.add_argument("-q", "--quiet", action="store_true",
help="Be quiet on logging to stdout/stderr")
parser.set_defaults(verb=None)
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")
parser_prune = subparsers.add_parser("prune")
parser_prune.set_defaults(verb="prune")
return parser.parse_args(self.argv)
def default_state(self):
return {}
def load_state(self):
try:
with open(self.statefile_path) as f:
state = json.load(f)
except Exception as e:
self.logger.warn(e)
self.logger.warn("Could not read state. Assuming default state.")
state = self.default_state()
return state
def save_state(self, state):
with open(self.statefile_path, 'w') as f:
json.dump(state, f)
def save_state_given_new_backups(self, backups):
new_state = self.configured_backups.state_after_backups(backups)
self.save_state(new_state)
def all_configured_backups(self):
return self.configured_backups.all_backups()
def configured_backup_set(self):
return self.configured_backups
def all_configured_backends(self):
return self.configured_backends.values()
def _parse_pruning_behavior(self, pruning_info):
if not isinstance(pruning_info, dict):
raise InvalidConfigError("Pruning info must be a dictionary")
parsed_configs = []
for name, config in pruning_info.items():
if name not in [x.name for x in self.all_configured_backups()]:
msg = "Attempt to define backup configuration for unknown backup {}".format(name)
raise InvalidConfigError(msg)
inf = float("inf")
daily = config.get("daily", inf)
weekly = config.get("weekly", inf)
monthly = config.get("montly", inf)
backup_config = BackupPruningConfiguration(name, daily, weekly, monthly)
parsed_configs.append(backup_config)
self.pruning_configuration = PruningConfiguration(parsed_configs)
def __init__(self, argv, prog):
self.argv = argv
self.prog = prog
ns = self.parse_args()
self.config_options = ns
self.configfile = CONFIG_LOCATION
try:
with open(self.configfile) as f:
try:
config_dict = json.load(f)
except Exception as e:
raise InvalidConfigError(str(e))
except IOError as e:
if e.errno == errno.ENOENT:
nce = NoConfigError()
nce.__cause__ = e
raise nce
else:
raise
self.notification_address = config_dict.get("notification_address", "root")
self.statefile_path = config_dict.get("statefile", DEFAULT_STATEFILE)
def parse_backend_type(backend_dict):
if not isinstance(backend_dict, dict):
raise InvalidConfigError("Expected a dictionary describing the backend")
typestr = backend_dict.pop("type", None)
BackendType = backend_types.backend_type(typestr)
if BackendType is None:
raise InvalidConfigError("missing or invalid type for backend: {}".format(typestr))
return BackendType(backend_dict)
if not isinstance(config_dict.get("backends", None), list):
raise InvalidConfigError("Expected a list of backends")
self.configured_backends = {
backend.name: backend for backend in (parse_backend_type(backend_dict) for backend_dict in config_dict["backends"])
}
def parse_backup(backup_dict):
if not isinstance(backup_dict, dict):
raise InvalidConfigError("Expected a dictionary describing the backup")
name = backup_dict.get("name", None)
paths = validate_paths(backup_dict.get("paths", None))
backup_name = backup_dict.get("backup_name", None)
timespec = validate_timespec(backup_dict.get("timespec", None))
backends = backup_dict.get("backends", None)
if name is None:
raise InvalidConfigError("Backups must have names")
if not isinstance(backends, list) or any([not isinstance(x, str) for x in backends]):
raise InvalidConfigError("Expected a list of strings for backends")
def find_backend(name):
backend = self.configured_backends.get(name, None)
if backend is None:
raise InvalidConfigError("Could not find backend {}".format(name))
return backend
backends = [find_backend(backend_name) for backend_name in backends]
return backup.Backup(name, paths, backup_name, timespec, backends)
if not isinstance(config_dict.get("backups", None), list):
raise InvalidConfigError("Expected a list of backups")
configured_backups = [
parse_backup(backup_dict) for backup_dict in config_dict["backups"]
]
for name, count in collections.Counter([configured_backup.name for configured_backup in configured_backups]).items():
if count > 1:
raise InvalidConfigError("Duplicate backup \"{}\"".format(name))
try:
state_mtime = os.stat(self.statefile_path).st_mtime
except OSError as e:
if e.errno == errno.ENOENT:
state_mtime = 0
else:
raise
state = self.load_state()
self.configured_backups = backup.BackupSet(
state, configured_backups, os.stat(self.configfile).st_mtime,
state_mtime, datetime.datetime.now().replace(tzinfo=LOCAL_TZ))
self._parse_pruning_behavior(config_dict.get("pruning", {}))