Skip to content
Snippets Groups Projects
Commit beec5311 authored by Administrator's avatar Administrator
Browse files

Better config options

parent 8c78f977
No related branches found
No related tags found
No related merge requests found
...@@ -2,3 +2,4 @@ bin/ ...@@ -2,3 +2,4 @@ bin/
lib/ lib/
share/ share/
local/ local/
config.toml
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
[[volume]] [[volume]]
volume_group = "exports" volume_group = "exports"
name = "pub" name = "pub"
path = ".snapshots"
[[volume.keep]] [[volume.keep]]
number = 24 number = 24
......
...@@ -31,17 +31,56 @@ import json ...@@ -31,17 +31,56 @@ import json
from contextlib import contextmanager from contextlib import contextmanager
from collections import OrderedDict from collections import OrderedDict
SNAPSHOT_BASE_DIR = "/snapshots" class Config:
TIMESTAMP_FORMAT = "%Y-%m-%d-%H-%M" snapshot_base_dir = "/snapshots"
PERIOD_KEYS = OrderedDict([ timestamp_format = "%Y-%m-%d-%H-%M"
period_keys = OrderedDict([
("y", 365 * 86400), ("y", 365 * 86400),
("m", 31 * 86400), ("m", 31 * 86400),
("d", 86400), ("d", 86400),
("H", 3600), ("H", 3600),
("M", 60), ("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): def run_process(command, check=True):
logging.debug("running command {}".format(command))
result = sp.run(command, check=check, stdout=sp.PIPE, stderr=sp.PIPE) result = sp.run(command, check=check, stdout=sp.PIPE, stderr=sp.PIPE)
if result.stdout: if result.stdout:
logging.info(result.stdout.decode("utf-8").strip()) logging.info(result.stdout.decode("utf-8").strip())
...@@ -64,9 +103,17 @@ def freeze_xfs(mountpoint, freeze): ...@@ -64,9 +103,17 @@ def freeze_xfs(mountpoint, freeze):
run_process(command, check=True) run_process(command, check=True)
class Volume: class Volume:
def __init__(self, volume_group, name): def __init__(self, volume_group, name, snapshot_dir=None):
self.volume_group = volume_group self.volume_group = volume_group
self.name = name 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): def __repr__(self):
return self.get_full_name() return self.get_full_name()
...@@ -85,6 +132,7 @@ class Volume: ...@@ -85,6 +132,7 @@ class Volume:
def get_mountpoint(self): def get_mountpoint(self):
mapper_device = self.get_mapper_device() mapper_device = self.get_mapper_device()
logging.debug("searching volume mountpoint for {}".format(mapper_device))
command = [ command = [
"/bin/findmnt", "/bin/findmnt",
"--source", mapper_device, "--source", mapper_device,
...@@ -110,7 +158,7 @@ class Snapshot: ...@@ -110,7 +158,7 @@ class Snapshot:
return self.get_name() return self.get_name()
def get_timestamp_str(self): def get_timestamp_str(self):
return self.timestamp.strftime(TIMESTAMP_FORMAT) return self.timestamp.strftime(Config.timestamp_format)
def get_name(self): def get_name(self):
return "{}-snapshot-{}".format( return "{}-snapshot-{}".format(
...@@ -120,7 +168,7 @@ class Snapshot: ...@@ -120,7 +168,7 @@ class Snapshot:
@staticmethod @staticmethod
def parse_name(name): def parse_name(name):
parent_name, _, timestamp_str = name.split("-", 2) 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 return parent_name, timestamp
def get_full_volume(self): def get_full_volume(self):
...@@ -128,12 +176,30 @@ class Snapshot: ...@@ -128,12 +176,30 @@ class Snapshot:
def get_mountpoint(self): def get_mountpoint(self):
return os.path.join( 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): def create_mountpoint(self):
logging.debug("creating mountpoint {}".format(self.get_mountpoint()))
os.makedirs(self.get_mountpoint(), mode=0o755, exist_ok=True) 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): def get_mapper_device(self):
return "/dev/mapper/{}-{}".format( return "/dev/mapper/{}-{}".format(
self.parent_volume.volume_group, self.parent_volume.volume_group,
...@@ -149,6 +215,7 @@ class Snapshot: ...@@ -149,6 +215,7 @@ class Snapshot:
self.get_mapper_device()) self.get_mapper_device())
def create(self): def create(self):
logging.debug("creating snapshot {}".format(self.get_name()))
parent_mountpoint = self.parent_volume.get_mountpoint() parent_mountpoint = self.parent_volume.get_mountpoint()
#with xfs_freeze(parent_mountpoint): # not necessary, lvm does this #with xfs_freeze(parent_mountpoint): # not necessary, lvm does this
create_command = [ create_command = [
...@@ -162,7 +229,9 @@ class Snapshot: ...@@ -162,7 +229,9 @@ class Snapshot:
self.mount() self.mount()
def mount(self): def mount(self):
logging.debug("mounting snapshot {}".format(self.get_name()))
if self.check_mount(): if self.check_mount():
logging.debug("already mounted")
return return
activate_command = [ activate_command = [
"/sbin/lvchange", "/sbin/lvchange",
...@@ -193,6 +262,7 @@ class Snapshot: ...@@ -193,6 +262,7 @@ class Snapshot:
return False return False
def remove(self): def remove(self):
logging.debug("removing snapshot {}".format(self.get_name()))
self.unmount() self.unmount()
remove_command = [ remove_command = [
"/sbin/lvremove", "/sbin/lvremove",
...@@ -201,12 +271,17 @@ class Snapshot: ...@@ -201,12 +271,17 @@ class Snapshot:
run_process(remove_command, check=True) run_process(remove_command, check=True)
def unmount(self): def unmount(self):
logging.debug("unmounting snapshot {}".format(self.get_name()))
mountpoint = self.find_mountpoint()
if mountpoint is not None:
if self.check_mount(): if self.check_mount():
unmount_command = [ unmount_command = [
"/bin/umount", "/bin/umount",
self.get_mountpoint() mountpoint
] ]
run_process(unmount_command, check=True) run_process(unmount_command, check=True)
if os.path.isdir(mountpoint):
os.rmdir(mountpoint)
deactivate_command = [ deactivate_command = [
"/sbin/lvchange", "/sbin/lvchange",
"--activate", "n", "--activate", "n",
...@@ -215,7 +290,6 @@ class Snapshot: ...@@ -215,7 +290,6 @@ class Snapshot:
] ]
run_process(deactivate_command, check=True) run_process(deactivate_command, check=True)
self.active = False self.active = False
os.rmdir(self.get_mountpoint())
@staticmethod @staticmethod
def list_snapshots(): def list_snapshots():
...@@ -272,17 +346,14 @@ class Period: ...@@ -272,17 +346,14 @@ class Period:
return datetime.now() - self.target_number * self.interval return datetime.now() - self.target_number * self.interval
@staticmethod @staticmethod
def build_regex(period_keys=None): def build_regex():
if period_keys is None: parts = [r"(?:(?P<{0}>\d+){0})?".format(key) for key in Config.period_keys]
period_keys = PERIOD_KEYS
parts = [r"(?:(?P<{0}>\d+){0})?".format(key) for key in period_keys]
return "".join(parts) return "".join(parts)
@staticmethod @staticmethod
def parse_interval(text, period_keys=None): def parse_interval(text):
if period_keys is None: period_keys = Config.period_keys
period_keys = PERIOD_KEYS regex = Period.build_regex()
regex = Period.build_regex(period_keys)
match = re.fullmatch(regex, text) match = re.fullmatch(regex, text)
if match is None: if match is None:
raise Exception("Invalid interval config: '{}', " raise Exception("Invalid interval config: '{}', "
...@@ -294,36 +365,6 @@ class Period: ...@@ -294,36 +365,6 @@ class Period:
seconds += period_keys[key] * int(groups[key]) seconds += period_keys[key] * int(groups[key])
return timedelta(seconds=seconds) 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): def mark_snapshots(snapshots, periods, min_interval):
all_snapshots = set(snapshots) all_snapshots = set(snapshots)
marked_snapshots = set() marked_snapshots = set()
...@@ -342,29 +383,28 @@ def mark_snapshots(snapshots, periods, min_interval): ...@@ -342,29 +383,28 @@ def mark_snapshots(snapshots, periods, min_interval):
unmarked_snapshots = all_snapshots - marked_snapshots unmarked_snapshots = all_snapshots - marked_snapshots
return unmarked_snapshots return unmarked_snapshots
def list_snapshots(**kwargs): def list_snapshots():
snapshots = Snapshot.list_snapshots() snapshots = Snapshot.list_snapshots()
for volume in snapshots: for volume in snapshots:
print("volume: {}".format(str(volume))) print("volume: {}".format(str(volume)))
for snapshot in snapshots[volume]: for snapshot in snapshots[volume]:
print(" {}".format(str(snapshot))) print(" {}".format(str(snapshot)))
def mount_snapshots(**kwargs): def mount_snapshots():
snapshots = Snapshot.list_snapshots() snapshots = Snapshot.list_snapshots()
for volume in snapshots: for volume in snapshots:
for snapshot in snapshots[volume]: for snapshot in snapshots[volume]:
snapshot.mount() snapshot.mount()
def unmount_snapshots(**kwargs): def unmount_snapshots():
snapshots = Snapshot.list_snapshots() snapshots = Snapshot.list_snapshots()
for volume in snapshots: for volume in snapshots:
for snapshot in snapshots[volume]: for snapshot in snapshots[volume]:
snapshot.unmount() snapshot.unmount()
def update_snapshots(): def update_snapshots():
config = load_config()
snapshots = Snapshot.list_snapshots() 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()): 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)) 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()): for volume in set(snapshots.keys()) - set(periods.keys()):
...@@ -372,8 +412,10 @@ def update_snapshots(): ...@@ -372,8 +412,10 @@ def update_snapshots():
snapshots.pop(volume) snapshots.pop(volume)
for volume in snapshots: for volume in snapshots:
unmarked_snapshots = mark_snapshots(snapshots[volume], periods[volume], min_interval) unmarked_snapshots = mark_snapshots(snapshots[volume], periods[volume], min_interval)
logging.info("removing snapshots {}".format(unmarked_snapshots))
for snapshot in unmarked_snapshots: for snapshot in unmarked_snapshots:
snapshot.remove() snapshot.remove()
logging.info("creating new snapshot")
new_snapshot = Snapshot(volume, datetime.now()) new_snapshot = Snapshot(volume, datetime.now())
new_snapshot.create() new_snapshot.create()
...@@ -386,14 +428,20 @@ operations = { ...@@ -386,14 +428,20 @@ operations = {
def main(): def main():
import argparse import argparse
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser(description="Manage snapshots of thin LVM volumes")
parser.add_argument("command", help="list|update|mount|unmount") parser.add_argument("command", choices=["list", "update", "mount", "unmount"], default="list")
parser.add_argument("--verbose", action="store_true", help="do not redirect the command output to /dev/null") parser.add_argument("--verbose", "-v", action="count", help="do not redirect the command output to /dev/null")
args = parser.parse_args() args = parser.parse_args()
loglevel = logging.ERROR loglevels = {
if args.verbose: 0: logging.ERROR,
loglevel = logging.INFO 1: logging.WARNING,
2: logging.INFO,
3: logging.DEBUG
}
loglevel = loglevels[min(3, max(0, args.verbose))]
logging.basicConfig(level=loglevel) logging.basicConfig(level=loglevel)
Config.parse(Config.load())
operations[args.command]() operations[args.command]()
if __name__ == "__main__": if __name__ == "__main__":
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment