#!/usr/bin/python # -*- encoding: utf-8 -*- """Make periodic hardlink snapshots. Looks for a directory structure like this under the current directory: .snapshot/ hourly.0/ hourly.1/ hourly.2/ daily.0/ daily.1/ weekly.0/ monthly.0/ monthly.1/ You can create that configuration with a bash command like mkdir -p .snapshot/{hourly.{0..2},daily.{0..1},weekly.0,monthly.{0..1}} When run, it rotates the snapshots appropriately without changing which ones exist. It uses the number of existing snapshots in each category to determine retention in that category. It uses the modification time of the latest snapshot in each category to determine whether the category needs to be rotated. The snapshots themselves consist only of hardlinks to the files in the current directory, so creating them is fast, but only effective if you update the files by making new versions of them. If you truncate the files and/or modify them in place then the snapshots will save nothing. """ import os, time, stat, sys categories = { 'hourly': 60*60, 'daily': 24*60*60, 'weekly': 7*24*60*60, 'monthly': 30*24*60*60, } def make_snapshot(where): os.mkdir(where, os.stat('.').st_mode & 0777) for name in os.listdir('.'): if os.path.isfile(name): # Exclude symlinks and subdirs os.link(name, '{}/{}'.format(where, name)) os.chmod(where, os.stat(where).st_mode & 0555) # make read-only def nuke(path): assert path.startswith('.snapshot/') if not os.path.exists(path): return os.chmod(path, 0700) for name in os.listdir(path): os.unlink('{}/{}'.format(path, name)) os.rmdir(path) def update_category(category): try: subdirs = os.listdir('.snapshot') except IOError: return existing = [subdir for subdir in subdirs if subdir.startswith(category + '.')] if not existing: return latest = '.snapshot/{}.0'.format(category) try: mtime = os.stat(latest).st_mtime except OSError: # daily.0 or whatever doesn't exist, so create it pass else: period = categories[category] now = time.time() if os.listdir(latest) and now - mtime < period: return print('making {} snapshot'.format(category)) # Save the oldest snapshot, hourly.7 or whatever, as hourly.tmp.7 # if hourly.6 exists oldest = max(int(subdir[subdir.rindex('.')+1:]) for subdir in existing) tmp = '.snapshot/{}.tmp.{}'.format(category, oldest) nuke(tmp) nontmp = '.snapshot/{}.{}'.format(category, oldest) next = '.snapshot/{}.{}'.format(category, oldest-1) # If next doesn't exist, we can't nuke the oldest snapshot in this # category without changing the highest number in the category. if oldest == 0 or (os.path.exists(nontmp) and os.path.exists(next)): os.rename(nontmp, tmp) # Rotate all the newer snapshots for i in range(oldest)[::-1]: newer = '.snapshot/{}.{}'.format(category, i) if os.path.exists(newer): os.rename(newer, '.snapshot/{}.{}'.format(category, i+1)) # Now it’s safe to remove hourly.tmp.7 or whatever; it won’t # change the highest number any more nuke(tmp) make_snapshot(latest) def update_snapshots(): for category in categories: update_category(category) def main(): if not os.path.isdir('.snapshot'): sys.stderr.write('no .snapshot dir here\n') return -1 update_snapshots() return 0 if __name__ == '__main__': sys.exit(main())