diff --git a/.gitignore b/.gitignore index c4be61019c5532dbfcd3666da29931d50c4a1da6..7206da09056b4ff6434406dd39a8dec6010273e7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ bin/ lib/ share/ local/ +config.toml diff --git a/config.example.toml b/config.example.toml index 4d15a1237fb2a449e67d7f31bd83d4f016ee2660..d57f31d94f5f6eb5337f62a57ccab3a26cb27586 100644 --- a/config.example.toml +++ b/config.example.toml @@ -2,6 +2,7 @@ [[volume]] volume_group = "exports" name = "pub" + path = ".snapshots" [[volume.keep]] number = 24 diff --git a/lvmsnapshot.py b/lvmsnapshot.py index 74d3dc941ae8a42bd4d2cb73ebb4609cf65a58e5..87765064a0d4de16fe1ae95121c300d8277eccd3 100644 --- a/lvmsnapshot.py +++ b/lvmsnapshot.py @@ -31,17 +31,56 @@ import json from contextlib import contextmanager from collections import OrderedDict -SNAPSHOT_BASE_DIR = "/snapshots" -TIMESTAMP_FORMAT = "%Y-%m-%d-%H-%M" -PERIOD_KEYS = OrderedDict([ - ("y", 365 * 86400), - ("m", 31 * 86400), - ("d", 86400), - ("H", 3600), - ("M", 60), -]) +class Config: + snapshot_base_dir = "/snapshots" + timestamp_format = "%Y-%m-%d-%H-%M" + period_keys = OrderedDict([ + ("y", 365 * 86400), + ("m", 31 * 86400), + ("d", 86400), + ("H", 3600), + ("M", 60), + ]) + min_interval = None + volumes = {} + periods = {} + + @staticmethod + def load(): + import toml + config_path = "config.toml" + global_config_path = "/etc/lvm-snapshot.toml" + ENV_VAR = "LVM_SNAPSHOT_CONFIG" + if not os.path.isfile(config_path) and os.path.isfile(global_config_path): + config_path = global_config_path + if ENV_VAR in os.environ: + config_path = os.environ[ENV_VAR] + with open(config_path, "r") as config_file: + return toml.load(config_file) + + @staticmethod + def parse(config): + periods = {} + min_interval = None + for volume_conf in config["volume"]: + name = volume_conf["name"] + volume_group = volume_conf["volume_group"] + path = volume_conf.get("path") + volume = Volume(volume_group, name, path) + Config.volumes[volume.get_full_name()] = volume + periods[volume] = [] + for raw_period in volume_conf["keep"]: + interval = Period.parse_interval(raw_period["interval"]) + if min_interval is None or interval < min_interval: + min_interval = interval + number = int(raw_period["number"]) + periods[volume].append( + Period(target_number=number, interval=interval)) + Config.min_interval = min_interval + Config.periods = periods def run_process(command, check=True): + logging.debug("running command {}".format(command)) result = sp.run(command, check=check, stdout=sp.PIPE, stderr=sp.PIPE) if result.stdout: logging.info(result.stdout.decode("utf-8").strip()) @@ -64,12 +103,20 @@ def freeze_xfs(mountpoint, freeze): run_process(command, check=True) class Volume: - def __init__(self, volume_group, name): + def __init__(self, volume_group, name, snapshot_dir=None): self.volume_group = volume_group self.name = name + if snapshot_dir is None: + if self.get_full_name() in Config.volumes: + snapshot_dir = Config.volumes[self.get_full_name()].snapshot_dir + else: + snapshot_dir = os.path.join(Config.snapshot_base_dir, name) + if not os.path.isabs(snapshot_dir): + snapshot_dir = os.path.join(self.get_mountpoint(), snapshot_dir) + self.snapshot_dir = snapshot_dir def __repr__(self): - return self.get_full_name() + return self.get_full_name() def __eq__(self, other): return hash(self) == hash(other) @@ -85,6 +132,7 @@ class Volume: def get_mountpoint(self): mapper_device = self.get_mapper_device() + logging.debug("searching volume mountpoint for {}".format(mapper_device)) command = [ "/bin/findmnt", "--source", mapper_device, @@ -110,7 +158,7 @@ class Snapshot: return self.get_name() def get_timestamp_str(self): - return self.timestamp.strftime(TIMESTAMP_FORMAT) + return self.timestamp.strftime(Config.timestamp_format) def get_name(self): return "{}-snapshot-{}".format( @@ -120,7 +168,7 @@ class Snapshot: @staticmethod def parse_name(name): parent_name, _, timestamp_str = name.split("-", 2) - timestamp = datetime.strptime(timestamp_str, TIMESTAMP_FORMAT) + timestamp = datetime.strptime(timestamp_str, Config.timestamp_format) return parent_name, timestamp def get_full_volume(self): @@ -128,12 +176,30 @@ class Snapshot: def get_mountpoint(self): return os.path.join( - SNAPSHOT_BASE_DIR, self.parent_volume.name, self.get_timestamp_str() + self.parent_volume.snapshot_dir, self.get_timestamp_str() ) def create_mountpoint(self): + logging.debug("creating mountpoint {}".format(self.get_mountpoint())) os.makedirs(self.get_mountpoint(), mode=0o755, exist_ok=True) + def find_mountpoint(self): + logging.debug("searching snapshot mountpoint for {}".format(self.get_name())) + command = [ + "/bin/findmnt", + "--source", self.get_mapper_device(), + "--json", + ] + try: + result = sp.check_output(command).decode("utf-8") + data = json.loads(result) + filesystems = data["filesystems"] + if len(filesystems) < 1: + return None + return filesystems[0]["target"] + except sp.CalledProcessError: + return None + def get_mapper_device(self): return "/dev/mapper/{}-{}".format( self.parent_volume.volume_group, @@ -149,6 +215,7 @@ class Snapshot: self.get_mapper_device()) def create(self): + logging.debug("creating snapshot {}".format(self.get_name())) parent_mountpoint = self.parent_volume.get_mountpoint() #with xfs_freeze(parent_mountpoint): # not necessary, lvm does this create_command = [ @@ -162,7 +229,9 @@ class Snapshot: self.mount() def mount(self): + logging.debug("mounting snapshot {}".format(self.get_name())) if self.check_mount(): + logging.debug("already mounted") return activate_command = [ "/sbin/lvchange", @@ -193,6 +262,7 @@ class Snapshot: return False def remove(self): + logging.debug("removing snapshot {}".format(self.get_name())) self.unmount() remove_command = [ "/sbin/lvremove", @@ -201,12 +271,17 @@ class Snapshot: run_process(remove_command, check=True) def unmount(self): - if self.check_mount(): - unmount_command = [ - "/bin/umount", - self.get_mountpoint() - ] - run_process(unmount_command, check=True) + logging.debug("unmounting snapshot {}".format(self.get_name())) + mountpoint = self.find_mountpoint() + if mountpoint is not None: + if self.check_mount(): + unmount_command = [ + "/bin/umount", + mountpoint + ] + run_process(unmount_command, check=True) + if os.path.isdir(mountpoint): + os.rmdir(mountpoint) deactivate_command = [ "/sbin/lvchange", "--activate", "n", @@ -215,7 +290,6 @@ class Snapshot: ] run_process(deactivate_command, check=True) self.active = False - os.rmdir(self.get_mountpoint()) @staticmethod def list_snapshots(): @@ -272,17 +346,14 @@ class Period: return datetime.now() - self.target_number * self.interval @staticmethod - def build_regex(period_keys=None): - if period_keys is None: - period_keys = PERIOD_KEYS - parts = [r"(?:(?P<{0}>\d+){0})?".format(key) for key in period_keys] + def build_regex(): + parts = [r"(?:(?P<{0}>\d+){0})?".format(key) for key in Config.period_keys] return "".join(parts) @staticmethod - def parse_interval(text, period_keys=None): - if period_keys is None: - period_keys = PERIOD_KEYS - regex = Period.build_regex(period_keys) + def parse_interval(text): + period_keys = Config.period_keys + regex = Period.build_regex() match = re.fullmatch(regex, text) if match is None: raise Exception("Invalid interval config: '{}', " @@ -294,36 +365,6 @@ class Period: seconds += period_keys[key] * int(groups[key]) return timedelta(seconds=seconds) -def load_config(): - import sys - import toml - config_path = "config.toml" - global_config_path = "/etc/lvm-snapshot.toml" - ENV_VAR = "LVM_SNAPSHOT_CONFIG" - if not os.path.isfile(config_path) and os.path.isfile(global_config_path): - config_path = global_config_path - if ENV_VAR in os.environ: - config_path = os.environ[ENV_VAR] - with open(config_path, "r") as config_file: - return toml.load(config_file) - -def parse_config(config): - periods = {} - min_interval = None - for volume_conf in config["volume"]: - name = volume_conf["name"] - volume_group = volume_conf["volume_group"] - volume = Volume(volume_group, name) - periods[volume] = [] - for raw_period in volume_conf["keep"]: - interval = Period.parse_interval(raw_period["interval"]) - if min_interval is None or interval < min_interval: - min_interval = interval - number = int(raw_period["number"]) - periods[volume].append( - Period(target_number=number, interval=interval)) - return periods, min_interval - def mark_snapshots(snapshots, periods, min_interval): all_snapshots = set(snapshots) marked_snapshots = set() @@ -342,29 +383,28 @@ def mark_snapshots(snapshots, periods, min_interval): unmarked_snapshots = all_snapshots - marked_snapshots return unmarked_snapshots -def list_snapshots(**kwargs): +def list_snapshots(): snapshots = Snapshot.list_snapshots() for volume in snapshots: print("volume: {}".format(str(volume))) for snapshot in snapshots[volume]: print(" {}".format(str(snapshot))) -def mount_snapshots(**kwargs): +def mount_snapshots(): snapshots = Snapshot.list_snapshots() for volume in snapshots: for snapshot in snapshots[volume]: snapshot.mount() -def unmount_snapshots(**kwargs): +def unmount_snapshots(): snapshots = Snapshot.list_snapshots() for volume in snapshots: for snapshot in snapshots[volume]: snapshot.unmount() def update_snapshots(): - config = load_config() snapshots = Snapshot.list_snapshots() - periods, min_interval = parse_config(config) + periods, min_interval = Config.periods, Config.min_interval for volume in set(periods.keys()) - set(snapshots.keys()): logging.warn("Warning: Volume {} is configured but does not exist or has no snapshots.".format(volume)) for volume in set(snapshots.keys()) - set(periods.keys()): @@ -372,8 +412,10 @@ def update_snapshots(): snapshots.pop(volume) for volume in snapshots: unmarked_snapshots = mark_snapshots(snapshots[volume], periods[volume], min_interval) + logging.info("removing snapshots {}".format(unmarked_snapshots)) for snapshot in unmarked_snapshots: snapshot.remove() + logging.info("creating new snapshot") new_snapshot = Snapshot(volume, datetime.now()) new_snapshot.create() @@ -386,14 +428,20 @@ operations = { def main(): import argparse - parser = argparse.ArgumentParser() - parser.add_argument("command", help="list|update|mount|unmount") - parser.add_argument("--verbose", action="store_true", help="do not redirect the command output to /dev/null") + parser = argparse.ArgumentParser(description="Manage snapshots of thin LVM volumes") + parser.add_argument("command", choices=["list", "update", "mount", "unmount"], default="list") + parser.add_argument("--verbose", "-v", action="count", help="do not redirect the command output to /dev/null") args = parser.parse_args() - loglevel = logging.ERROR - if args.verbose: - loglevel = logging.INFO + loglevels = { + 0: logging.ERROR, + 1: logging.WARNING, + 2: logging.INFO, + 3: logging.DEBUG + } + loglevel = loglevels[min(3, max(0, args.verbose))] logging.basicConfig(level=loglevel) + Config.parse(Config.load()) + operations[args.command]() if __name__ == "__main__":