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/
lib/
share/
local/
config.toml
......@@ -2,6 +2,7 @@
[[volume]]
volume_group = "exports"
name = "pub"
path = ".snapshots"
[[volume.keep]]
number = 24
......
......@@ -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([
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,9 +103,17 @@ 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()
......@@ -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):
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",
self.get_mountpoint()
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__":
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment