From 60095c48d29daad4851dd1cd82b53119b8bb3493 Mon Sep 17 00:00:00 2001 From: Fredrik Eriksson Date: Tue, 23 May 2017 20:59:40 +0200 Subject: [PATCH] * fixed weeding * added weed_enable option and default it to unset to prevent removing snapshots of unmanaged file systems * added locking when sending and weeding snapshots * fixed syslog logging format --- bin/zsnapper | 101 ++++++++++++++++++++++++++++++------------- zsnaplib/__init__.py | 66 ++++++++++++++-------------- zsnapper.ini-sample | 62 ++++++++++++++++++-------- 3 files changed, 149 insertions(+), 80 deletions(-) diff --git a/bin/zsnapper b/bin/zsnapper index 81dc76d..f4fb0c4 100644 --- a/bin/zsnapper +++ b/bin/zsnapper @@ -28,6 +28,7 @@ RET_CODES = { DEFAULT_CONFIG = { 'snapshot_interval': None, 'custom_keep_interval': None, + 'weed_enable': False, 'keep_yearly': 0, 'keep_monthly': 0, 'keep_weekly': 0, @@ -87,29 +88,12 @@ def get_config_for_fs(fs, config): return fs_config -def main(): - config = configparser.SafeConfigParser() - config.read('/etc/zsnapper.ini') - sudo = False - ret = RET_CODES['SUCCESS'] + +def do_snapshots(fslist, snapshots, config, sudo): + failed_snapshots = set() + now = datetime.datetime.now() log = logging.getLogger(LOGGER) - if os.getuid() != 0: - sudo = True - try: - sudo = config.get('settings', 'sudo') - except (configparser.NoOptionError, configparser.NoSectionError): - pass - - fslist = sorted(zsnaplib.get_filesystems(sudo)) - snapshots = zsnaplib.get_snapshots(sudo) - now = datetime.datetime.now() - - # if we fail to create or send a snapshot we do not want to remove - # the existing snapshots... - failed_snapshots = set() - - # First iteration: create snapshots for fs in fslist: conf = get_config_for_fs(fs, config) if not conf['snapshot_interval']: @@ -126,15 +110,14 @@ def main(): log.info('{} snapshot created'.format(fs)) except zsnaplib.ZFSSnapshotError as e: log.warning(e) - ret = RET_CODES['ERROR'] failed_snapshots.add(fs) - - # reload all snapshots so we get our new snapshots here - snapshots = zsnaplib.get_snapshots(sudo) + return failed_snapshots - # Second iteration: Send snapshots +def send_snapshots(fslist, snapshots, config, sudo): + failed_snapshots = set() remote_hosts = {} remote_targets = {} + log = logging.getLogger(LOGGER) for fs in fslist: conf = get_config_for_fs(fs, config) remote_snapshots = None @@ -194,7 +177,6 @@ def main(): except zsnaplib.ZFSSnapshotError as e: failed_snapshots.add(fs) log.warning(e) - ret = RET_CODES['ERROR'] continue remote_snapshots[remote_fs] = [base_snap] @@ -206,8 +188,7 @@ def main(): break if not last_remote: failed_snapshots.add(fs) - log.warning('No common snapshot local and remote, you need to create a new base copy!') - ret = RET_CODES['ERROR'] + log.warning('{}: No common snapshot local and remote, you need to create a new base copy!'.format(fs)) continue last_local = snapshots[fs][0] if last_remote == last_local: @@ -229,8 +210,10 @@ def main(): log.info('{} successfully sent to remote'.format(fs)) except zsnaplib.ZFSSnapshotError as e: log.warning(e) + return failed_snapshots - # Third iteration: weed old snapshots +def weed_snapshots(fslist, snapshots, config, sudo, failed_snapshots): + log = logging.getLogger(LOGGER) for fs in fslist: conf = get_config_for_fs(fs, config) if fs in failed_snapshots: @@ -238,6 +221,8 @@ def main(): continue if fs not in snapshots: continue + if not conf['weed_enable']: + continue kwargs = {k: int(v) for k, v in conf.items() if k in [ 'keep_custom', @@ -261,7 +246,60 @@ def main(): **kwargs) +def main(): + config = configparser.SafeConfigParser() + config.read('/etc/zsnapper.ini') + sudo = False + ret = RET_CODES['SUCCESS'] + log = logging.getLogger(LOGGER) + if os.getuid() != 0: + sudo = True + + fslist = sorted(zsnaplib.get_filesystems(sudo)) + snapshots = zsnaplib.get_snapshots(sudo) + + failed_snapshots = do_snapshots(fslist, snapshots, config, sudo) + if failed_snapshots: + ret = RET_CODES['ERROR'] + + lockfile = '/tmp/zsnapper.pid' + # This loop should run at most twice + while True: + try: + lockfd = os.open(lockfile, os.O_CREAT|os.O_EXCL|os.O_WRONLY, mode=0o640) + os.write(lockfd, "{}".format(os.getpid()).encode('utf-8')) + os.close(lockfd) + break + except OSError: + pass + + # lock file exists, check if the pid seems valid + with open(lockfile, 'r') as f: + pid = f.read() + try: + pid = int(pid) + os.kill(pid, 0) + # If we got here the lock is owned by an existing pid + log.info('Previous run is not completed yet, will not send or weed snapshots') + return ret + except OSError: + # pid is not running, forcing unlock + os.remove(lockfile) + except ValueError: + log.error('lockfile {} exists but does not seem to contain a pid. Will not continue'.format(lockfile)) + return RET_CODES['FAILED'] + + + # reload all snapshots so we get our new snapshots here + snapshots = zsnaplib.get_snapshots(sudo) + failed_send = send_snapshots(fslist, snapshots, config, sudo) + if failed_send: + ret = RET_CODES['ERROR'] + + failed_snapshots.update(failed_send) + weed_snapshots(fslist, snapshots, config, sudo, failed_snapshots) + os.remove(lockfile) if __name__ == '__main__': log = logging.getLogger(LOGGER) @@ -269,7 +307,10 @@ if __name__ == '__main__': handler = logging.StreamHandler() handler.setLevel(logging.WARNING) log.addHandler(handler) + handler = logging.handlers.SysLogHandler(address='/dev/log') + formatter = logging.Formatter(fmt='zsnapper %(message)s') + handler.setFormatter(formatter) handler.setLevel(logging.INFO) log.addHandler(handler) sys.exit(main()) diff --git a/zsnaplib/__init__.py b/zsnaplib/__init__.py index 5efc006..f13a2fc 100644 --- a/zsnaplib/__init__.py +++ b/zsnaplib/__init__.py @@ -212,69 +212,69 @@ def weed_snapshots( keep['custom'].append(date) if keep_yearly: - saved['year'] = saved['year'][-keep_yearly:] + keep['year'] = keep['year'][-keep_yearly:] else: - saved['year'] = [] + keep['year'] = [] if keep_monthly: - saved['month'] = saved['month'][-keep_monthly:] + keep['month'] = keep['month'][-keep_monthly:] else: - saved['month'] = [] + keep['month'] = [] if keep_weekly: - saved['week'] = saved['week'][-keep_weekly:] + keep['week'] = keep['week'][-keep_weekly:] else: - saved['week'] = [] + keep['week'] = [] if keep_daily: - saved['day'] = saved['day'][-keep_daily:] + keep['day'] = keep['day'][-keep_daily:] else: - saved['day'] = [] + keep['day'] = [] if keep_hourly: - saved['hour'] = saved['hour'][-keep_hourly:] + keep['hour'] = keep['hour'][-keep_hourly:] else: - saved['hour'] = [] + keep['hour'] = [] if keep_30min: - saved['min30'] = saved['min30'][-keep_30min:] + keep['min30'] = keep['min30'][-keep_30min:] else: - saved['min30'] = [] + keep['min30'] = [] if keep_15min: - saved['min15'] = saved['min15'][-keep_15min:] + keep['min15'] = keep['min15'][-keep_15min:] else: - saved['min15'] = [] + keep['min15'] = [] if keep_5min: - saved['min5'] = saved['min5'][-keep_5min:] + keep['min5'] = keep['min5'][-keep_5min:] else: - saved['min5'] = [] + keep['min5'] = [] if keep_1min: - saved['min1'] = saved['min1'][-keep_1min:] + keep['min1'] = keep['min1'][-keep_1min:] else: - saved['min1'] = [] + keep['min1'] = [] if keep_custom: - saved['custom'] = saved['custom'][-keep_custom:] + keep['custom'] = keep['custom'][-keep_custom:] else: - saved['custom'] = [] + keep['custom'] = [] - all_saved = [] - all_saved.extend(saved['year']) - all_saved.extend(saved['month']) - all_saved.extend(saved['week']) - all_saved.extend(saved['day']) - all_saved.extend(saved['hour']) - all_saved.extend(saved['min30']) - all_saved.extend(saved['min15']) - all_saved.extend(saved['min5']) - all_saved.extend(saved['min1']) - all_saved.extend(saved['custom']) - all_saved = set(all_saved) + all_keep = [] + all_keep.extend(keep['year']) + all_keep.extend(keep['month']) + all_keep.extend(keep['week']) + all_keep.extend(keep['day']) + all_keep.extend(keep['hour']) + all_keep.extend(keep['min30']) + all_keep.extend(keep['min15']) + all_keep.extend(keep['min5']) + all_keep.extend(keep['min1']) + all_keep.extend(keep['custom']) + all_keep = set(all_keep) - to_remove = [date for date in dates if date not in all_saved] + to_remove = [date for date in dates if date not in all_keep] for date in to_remove: try: log.info('{}: removing snapshot from {}'.format(fs, date)) diff --git a/zsnapper.ini-sample b/zsnapper.ini-sample index a29e038..68858e5 100644 --- a/zsnapper.ini-sample +++ b/zsnapper.ini-sample @@ -1,33 +1,60 @@ -[settings] -# NOTE: -# --stdin is used to pass password to sudo, this does not work the first time -# a user uses sudo, so make sure to run a sudo command manually first +# zsnapper sample configuration + +# Each section is the name of a ZFS file system +# All settings are applied recursively to all file system descendants # -# or preferably, leave this commented out and use NOPASSWD for sudo... -;sudo= - - [tank] -# frequency of snapshots +# Frequency of snapshots +# Set to empty value to disable snapshoting +# +# Interval units are 'd', 'h' and 'm' for days, hours and minutes. +# they can also be combined if you for example wants a snapshot taken every 1 and a half days: +;snapshot_interval=1d 12h snapshot_interval=1h # Remote replication -# possible other value is 'latest' to only sync the last available snapshot +# possible other value is 'latest' to only sync the latest snapshot +# Set to empty value to not send the snapshots to remote remote_enable=all +# The remote_zfs_cmd option is the command to use to execute zfs on target machine. +# remote_test_cmd, if set, is executed before trying to send any snapshot to remote. +# If remote_test_cmd returns a non-zero status the remote is considered to be unavailable +# and no snapshots are sent. (A warning is written in the log though) +# # NOTE: -# The command arguments must not contain whitespace characters, since -# split() is used to create an array to subprocess.Popen() +# The command arguments must not contain whitespace characters, due to implementation details. +# +# Variables can be used in remote_zfs_cmd and remote_test_cmd. Any setting +# available in the section can be used as a variable remote_zfs_cmd=/usr/bin/ssh ${remote_user}@${remote_host} /usr/bin/sudo /sbin/zfs remote_test_cmd=/usr/bin/ssh ${remote_user}@${remote_host} echo "success" +# The remote_host option is optional but recommended if you send snapshots to a remote host. +remote_host=my.backup.server.tld +# remote_user is not a actually a zsnapper option; but it's used as a variable in the remote commands. remote_user=backup -remote_host=hem.winterbird.org -remote_zfs_target=tank/backup/asuna/tank + +# remote_zfs_target is the file system on the remote client that should receive zfs sends +# for this file system. +# NOTE: +# Just like any other option this is inherited by file system descendants, +# but if a child has the same remote_zfs_target as the parent, the child +# will instead use this to figure out where the parent is and be sent to +# it position relative to the parent. +# For example: The local file system tank/ROOT will be sent to tank/backup/client/ROOT. +remote_zfs_target=tank/backup/client + # These can be set to use custom arguments to zfs send and zfs receive -; remote_send_flags=-D -p -remote_send_flags= +remote_send_flags=-D -p remote_recv_flags= +# snapshot weeding +# set weed_enable to an empty value to disable snapshot weeding. +# NOTE: +# If weeding is enabled but no keep_