diff --git a/zfs_smart_snapshot/main.py b/zfs_smart_snapshot/main.py index db5c27f..d5ec61b 100644 --- a/zfs_smart_snapshot/main.py +++ b/zfs_smart_snapshot/main.py @@ -1,8 +1,12 @@ from datetime import datetime, timezone from dateutil.relativedelta import relativedelta -from pprint import pprint import subprocess import sys +from pprint import pprint + +# TODO: Argument parsing +verbose = True +dry_run = False def keep_time(now, snapshots, convert, delta, count): @@ -11,9 +15,10 @@ def keep_time(now, snapshots, convert, delta, count): return keep start = convert(now) - delta*(count-1) for snapshot in snapshots: - if snapshot >= start: + snapdate = datetime.strptime(snapshot, 'zfs-smart-snap-%Y-%m-%d-%H%M').replace(tzinfo=timezone.utc) + if snapdate >= start: keep.add(snapshot) - start = convert(snapshot) + delta + start = convert(snapdate) + delta if start > now: break return keep @@ -49,7 +54,7 @@ def snapshots_to_keep(now, snapshots, yearly=0, monthly=0, daily=0, hourly=0, fr keep.update(keep_day(now, snapshots, daily)) keep.update(keep_hour(now, snapshots, hourly)) keep.update(keep_frequent(now, snapshots, frequently)) - return sorted(list(keep)) + return keep def parse_int(fields, name, fieldname): @@ -59,14 +64,13 @@ def parse_int(fields, name, fieldname): return int(fields[fieldname]) except ValueError: print(f"Warning: Could not parse value {fields[fieldname]} for fs/zvol {name} prop {fieldname}", - output=sys.stderr) + file=sys.stderr) return -1 def zfs_list(fieldnames, args): proc = subprocess.run(['/usr/sbin/zfs', 'list', '-H', '-o', ','.join(['name'] + fieldnames)] + args, capture_output=True, check=True, encoding='UTF-8') - pprint(proc.args) if not fieldnames: result = list() else: @@ -96,7 +100,6 @@ def read_snapshots(fs_to_snap): result = dict() for name in raw_list: fs, snap = name.split('@') - print(f'{fs}, {snap}') if snap.startswith('zfs-smart-snap'): result[fs] = result.get(fs, []) + [snap] return result @@ -113,25 +116,53 @@ def find_latest_snapshots(existing_snaps): def written_since_last(fs, snap): proc = subprocess.run(['/usr/sbin/zfs', 'list', '-Hp', '-o', f'written@{snap}', fs], check=True, capture_output=True, encoding="UTF-8") - return int(proc.stdout) > 0 + res = int(proc.stdout) + if verbose and res == 0: + print(f"{fs} not written since {snap}, skipping") + return res > 0 def main(): now = datetime.now(timezone.utc).replace(second=0, microsecond=0) + if verbose: + print(f"zfs_smart_snapshot running at {now.strftime('%Y-%m-%d-%H%M')}") props = read_snapshot_props() - pprint(props) fs_to_snap = [k for k, v in props.items() if v['snapshot']] if fs_to_snap: existing_snaps = read_snapshots(fs_to_snap) - pprint(existing_snaps) latest_snaps = find_latest_snapshots(existing_snaps) fs_to_really_snap = [fs for fs in fs_to_snap if fs not in latest_snaps or written_since_last(fs, latest_snaps[fs])] if fs_to_really_snap: + pools = {fs.split('/')[0] for fs in fs_to_really_snap} snapname = 'zfs-smart-snap-' + now.strftime('%Y-%m-%d-%H%M') - subprocess.run(['/usr/sbin/zfs', 'snapshot'] + [f'{fs}@{snapname}' for fs in fs_to_really_snap], check=True) + snaps_to_create = [f'{fs}@{snapname}' for fs in fs_to_really_snap] + if dry_run or verbose: + phrase = "Would" if dry_run else "Will" + print(f"{phrase} create snapshots " + ", ".join(snaps_to_create)) + if not dry_run: + for pool in pools: + subprocess.run(['/usr/sbin/zfs', 'snapshot'] + [snap for snap in snaps_to_create if snap.startswith(pool)], check=True) + for fs in fs_to_really_snap: + existing_snaps[fs] = existing_snaps.get(fs, []) + [snapname] - # TODO: Cleanup expired snapshots + to_delete = [] + for fs in (fs for fs in fs_to_snap if fs in existing_snaps): + to_keep = snapshots_to_keep(now, existing_snaps[fs], props[fs]['yearly'], props[fs]['monthly'], props[fs]['daily'], props[fs]['hourly'], props[fs]['frequently']) + # Always keep latest snapshot. If it would be deleted (e.g. due to a incoherent snapshot history configuration) this could confuse the + # 'written since last snapshot' logic and cause a cycle of creating/deleteing/creating the snapshot on an idle file system + if existing_snaps[fs][-1] not in to_keep: + print(f"Warning: Latest snap {fs}@{existing_snaps[fs][-1]} not preserved by retention policy, keeping anyways", file=sys.stderr) + to_keep.add(existing_snaps[fs][-1]) + to_delete += (f'{fs}@{snap}' for snap in set(existing_snaps[fs]).difference(to_keep)) + + if not dry_run: + if verbose and len(to_delete) > 0: + print("Will destroy snapshots " + ", ".join(sorted(to_delete))) + for delete_snap in to_delete: + subprocess.run(['/usr/sbin/zfs', 'destroy', delete_snap], check=True) + elif len(to_delete) > 0: + print("Would destroy snapshots " + ", ".join(sorted(to_delete))) if __name__ == "__main__":