Last active
May 22, 2019 15:42
-
-
Save astoeckel/a6d3ea8cf38ecb52f0edb63634b92dda to your computer and use it in GitHub Desktop.
Populates a "snapshots" directory in each user's home directory with links at separate versions
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python | |
| ################################################################################ | |
| # zfs_link_snapshots.py # | |
| # Creates a "snapshots" directory in users home directories # | |
| # (c) 2019 Andreas Stöckel, licensed under the GPLv3 # | |
| ################################################################################ | |
| ################################################################################ | |
| # COMMAND LINE ARGUMENT PARSING # | |
| ################################################################################ | |
| # See https://stackoverflow.com/a/43357954 | |
| def str2bool(v): | |
| if v.lower() in ('yes', 'true', 't', 'y', '1'): | |
| return True | |
| elif v.lower() in ('no', 'false', 'f', 'n', '0'): | |
| return False | |
| else: | |
| raise argparse.ArgumentTypeError('Boolean value expected.') | |
| import argparse | |
| parser = argparse.ArgumentParser() | |
| parser.add_argument('volume', help='The ZFS volume name') | |
| parser.add_argument('--dry', help='Perform a dry run, only print actions', type=str2bool) | |
| args = parser.parse_args() | |
| volume = args.volume | |
| ################################################################################ | |
| # STEP 1: List snapshots # | |
| ################################################################################ | |
| def exec_process(args): | |
| import subprocess | |
| """ | |
| Executes a child process and exits if the child process fails | |
| """ | |
| # Open the child process with the given arguments | |
| child = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) | |
| # Fetch stderr and stdout | |
| stdout, stderr = child.communicate() | |
| # Raise an exception in case the return code is not zero | |
| if child.returncode != 0: | |
| raise Exception("Error while executing subprocess\n" + stderr) | |
| return str(stdout, "ascii") | |
| # Fetch all snapshots | |
| snapshots = [] | |
| for snapshot in exec_process(["zfs", "get", "-Hro", "name,value", "-p", "creation", volume]).split('\n'): | |
| snapshot = snapshot.split('\t') | |
| if len(snapshot) == 2: | |
| if snapshot[0].startswith(volume + "@"): | |
| snapshot_name = snapshot[0][len(volume + "@"):] | |
| snapshot_time = int(float(snapshot[1])) | |
| snapshots.append((snapshot_name, snapshot_time)) | |
| # Get the directory in which the volume is mounted | |
| mnt = exec_process(["zfs", "list", "-Ho", "mountpoint", volume]).strip() | |
| # List all subdirectories and check whether a "snapshots" and a "backup" | |
| # directory exists within that directory | |
| import os, datetime | |
| for path in os.listdir(mnt): | |
| home_dir = os.path.join(mnt, path) | |
| snap_dir = os.path.join(home_dir, "snapshots") | |
| back_dir = os.path.join(home_dir, "backup") | |
| if not (os.path.isdir(snap_dir) or os.path.isdir(back_dir)): | |
| continue | |
| # Remember the set of all existing symlinks | |
| links_old = set() | |
| for path in os.listdir(snap_dir): | |
| path = os.path.join(snap_dir, path) | |
| if os.path.islink(path): | |
| if args.dry: | |
| print("Found existing symlink \"{}\"".format(path)) | |
| links_old.add(path) | |
| # For each snapshot, generate a link that points at the backup directory | |
| # in the corresponding snapshot directory | |
| links_new = set() | |
| for snapshot_name, snapshot_time in snapshots: | |
| # Find the target ZFS snapshot directory | |
| zfs_snap_dir = os.path.join(mnt, ".zfs", "snapshot", snapshot_name) | |
| rel_back_dir = os.path.relpath(back_dir, mnt) | |
| zfs_snap_back_dir = os.path.join(zfs_snap_dir, rel_back_dir) | |
| if not os.path.isdir(zfs_snap_back_dir): | |
| if args.dry: | |
| print("Link target", zfs_snap_back_dir, "does not exist for snapshot", snapshot_name) | |
| continue | |
| # Construct the symlink name | |
| dt = datetime.datetime.fromtimestamp(snapshot_time) | |
| link = os.path.join(snap_dir, "snapshot_" + dt.strftime("%Y-%m-%d_%H%M")) | |
| if link in links_new: | |
| cnt = 1 | |
| while "{}_{}".format(link, cnt) in links_new: | |
| cnt = cnt + 1 | |
| link = "{}_{}".format(link, cnt) | |
| links_new.add(link) | |
| # If the target symlink file already exists, make sure it points at the | |
| # right target, otherwise remove it | |
| if link in links_old: | |
| links_old.remove(link) | |
| zfs_snap_back_dir_old = os.readlink(link) | |
| if zfs_snap_back_dir_old == zfs_snap_back_dir: | |
| if args.dry: | |
| print("Checked existing symlink \"{}\" with target \"{}\"".format(link, zfs_snap_back_dir)) | |
| continue | |
| print("Removing wrong link", link) | |
| if not args.dry: | |
| os.unlink(link) | |
| print("Creating new link", link, "->", zfs_snap_back_dir, "for", snapshot_name) | |
| if not args.dry: | |
| os.symlink(zfs_snap_back_dir, link) | |
| # Remove all old links that were not removed from the set above | |
| for link in links_old: | |
| print("Removing outdated link", link) | |
| if not args.dry: | |
| os.unlink(link) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment