summaryrefslogtreecommitdiff
path: root/modules/zfssnap/zfssnap
diff options
context:
space:
mode:
authorGregor Kleen <gkleen@yggdrasil.li>2022-11-01 21:05:33 +0100
committerGregor Kleen <gkleen@yggdrasil.li>2022-11-01 21:05:33 +0100
commita73133a7c629c76a7a328f0e8d2bb693c46ef45d (patch)
tree11112e70dd44a3a1eb7fd1702c0b80baa578b386 /modules/zfssnap/zfssnap
parent4188923d715bb4cfc3542eb05781fd45df85522e (diff)
downloadnixos-a73133a7c629c76a7a328f0e8d2bb693c46ef45d.tar
nixos-a73133a7c629c76a7a328f0e8d2bb693c46ef45d.tar.gz
nixos-a73133a7c629c76a7a328f0e8d2bb693c46ef45d.tar.bz2
nixos-a73133a7c629c76a7a328f0e8d2bb693c46ef45d.tar.xz
nixos-a73133a7c629c76a7a328f0e8d2bb693c46ef45d.zip
fix backups
Diffstat (limited to 'modules/zfssnap/zfssnap')
-rw-r--r--modules/zfssnap/zfssnap/setup.py10
-rw-r--r--modules/zfssnap/zfssnap/zfssnap/__main__.py435
2 files changed, 445 insertions, 0 deletions
diff --git a/modules/zfssnap/zfssnap/setup.py b/modules/zfssnap/zfssnap/setup.py
new file mode 100644
index 00000000..6c58757d
--- /dev/null
+++ b/modules/zfssnap/zfssnap/setup.py
@@ -0,0 +1,10 @@
1from setuptools import setup
2
3setup(name='zfssnap',
4 packages=['zfssnap'],
5 entry_points={
6 'console_scripts': [
7 'zfssnap=zfssnap.__main__:main',
8 ],
9 }
10)
diff --git a/modules/zfssnap/zfssnap/zfssnap/__main__.py b/modules/zfssnap/zfssnap/zfssnap/__main__.py
new file mode 100644
index 00000000..a0eade78
--- /dev/null
+++ b/modules/zfssnap/zfssnap/zfssnap/__main__.py
@@ -0,0 +1,435 @@
1#!@python@/bin/python
2
3import csv
4import subprocess
5import io
6from distutils.util import strtobool
7from datetime import datetime, timezone, timedelta
8from dateutil.tz import gettz, tzutc
9import pytimeparse
10import argparse
11import re
12
13import sys
14
15import logging
16
17import shlex
18
19from collections import defaultdict, OrderedDict, deque, namedtuple
20
21import configparser
22from xdg import BaseDirectory
23
24from functools import cache
25
26from math import floor
27
28import asyncio
29
30from dataclasses import dataclass
31
32
33TIME_PATTERNS = OrderedDict([
34 ("secondly", lambda t: t.strftime('%Y-%m-%d %H:%M:%S')),
35 ("minutely", lambda t: t.strftime('%Y-%m-%d %H:%M')),
36 ("5m", lambda t: (t.strftime('%Y-%m-%d %H'), floor(t.minute / 5) * 5)),
37 ("15m", lambda t: (t.strftime('%Y-%m-%d %H'), floor(t.minute / 15) * 15)),
38 ("hourly", lambda t: t.strftime('%Y-%m-%d %H')),
39 ("4h", lambda t: (t.strftime('%Y-%m-%d'), floor(t.hour / 4) * 4)),
40 ("12h", lambda t: (t.strftime('%Y-%m-%d'), floor(t.hour / 12) * 12)),
41 ("daily", lambda t: t.strftime('%Y-%m-%d')),
42 ("halfweekly", lambda t: (t.strftime('%G-%V'), floor(int(t.strftime('%u')) / 4) * 4)),
43 ("weekly", lambda t: t.strftime('%G-%V')),
44 ("monthly", lambda t: t.strftime('%Y-%m')),
45 ("yearly", lambda t: t.strftime('%Y')),
46])
47
48PROP_DO_AUTO_SNAPSHOT = 'li.yggdrasil:auto-snapshot'
49PROP_IS_AUTO_SNAPSHOT = 'li.yggdrasil:is-auto-snapshot'
50
51@dataclass(eq=True, order=True, frozen=True)
52class Snap:
53 name: str
54 creation: datetime
55
56@dataclass(eq=True, order=True, frozen=True)
57class KeptBecause:
58 rule: str
59 ix: int
60 base: str
61 period: str
62
63
64@cache
65def _now():
66 return datetime.now(timezone.utc)
67
68def _snap_name(item, time=_now()):
69 suffix = re.sub(r'\+00:00$', r'Z', time.isoformat(timespec='seconds'))
70 return f'{item}@{suffix}'
71
72def _log_cmd(*args):
73 fmt_args = ' '.join(map(shlex.quote, args))
74 logger.debug('Running command: %s', fmt_args)
75
76def _get_items():
77 items = {}
78
79 args = ['zfs', 'get', '-H', '-p', '-o', 'name,value', '-t', 'filesystem,volume', PROP_DO_AUTO_SNAPSHOT]
80 _log_cmd(*args)
81 with subprocess.Popen(args, stdout=subprocess.PIPE) as proc:
82 text_stdout = io.TextIOWrapper(proc.stdout)
83 reader = csv.DictReader(text_stdout, fieldnames=['name', 'value'], delimiter='\t', quoting=csv.QUOTE_NONE)
84 Row = namedtuple('Row', reader.fieldnames)
85 for row in [Row(**data) for data in reader]:
86 if not row.value or row.value == '-':
87 continue
88
89 items[row.name] = bool(strtobool(row.value))
90
91 return items
92
93def _get_snaps(only_auto=True):
94 snapshots = defaultdict(list)
95 args = ['zfs', 'list', '-H', '-p', '-t', 'snapshot', '-o', f'name,{PROP_IS_AUTO_SNAPSHOT},creation']
96 _log_cmd(*args)
97 with subprocess.Popen(args, stdout=subprocess.PIPE) as proc:
98 text_stdout = io.TextIOWrapper(proc.stdout)
99 reader = csv.DictReader(text_stdout, fieldnames=['name', 'is_auto_snapshot', 'timestamp'], delimiter='\t', quoting=csv.QUOTE_NONE)
100 Row = namedtuple('Row', reader.fieldnames)
101 for row in [Row(**data) for data in reader]:
102 if only_auto and not bool(strtobool(row.is_auto_snapshot)):
103 continue
104
105 base_name, _, _ = row.name.rpartition('@')
106 creation = datetime.fromtimestamp(int(row.timestamp), timezone.utc)
107 snapshots[base_name].append(Snap(name=row.name, creation=creation))
108
109 return snapshots
110
111def prune(config, dry_run, keep_newest, do_exec, exec_newest):
112 do_exec = do_exec and 'EXEC' in config
113 prune_timezone = config.gettimezone('KEEP', 'timezone', fallback=tzutc())
114 logger.debug('prune timezone: %s', prune_timezone)
115
116 items = _get_snaps()
117
118 kept_count = defaultdict(lambda: defaultdict(lambda: 0))
119 kept_because = OrderedDict()
120 def keep_because(base, snap, rule, period=None):
121 nonlocal kept_count, kept_because
122 kept_count[rule][base] += 1
123 if snap not in kept_because:
124 kept_because[snap] = deque()
125 kept_because[snap].append(KeptBecause(rule=rule, ix=kept_count[rule][base], base=base, period=period))
126
127 exec_candidates = set()
128 if do_exec:
129 exec_timezone = config.gettimezone('EXEC', 'timezone', fallback=prune_timezone)
130 logger.debug('exec timezone: %s', exec_timezone)
131
132 for rule, pattern in TIME_PATTERNS.items():
133 desired_count = config.getint('EXEC', rule, fallback=0)
134
135 for base, snaps in items.items():
136 periods = OrderedDict()
137
138 for snap in sorted(snaps, key=lambda snap: snap.creation, reverse=exec_newest):
139 period = pattern(snap.creation.astimezone(exec_timezone))
140 if period not in periods:
141 periods[period] = deque()
142 periods[period].append(snap)
143
144 to_exec = desired_count
145 ordered_periods = periods.items()
146 for period, period_snaps in ordered_periods:
147 if to_exec == 0:
148 break
149
150 for snap in period_snaps:
151 exec_candidates.add(snap)
152 logger.debug('‘%s’ is exec candidate', snap.name)
153 to_exec -= 1
154 break
155
156 if to_exec > 0:
157 logger.debug('Missing %d to fulfill exec %s=%d for ‘%s’', to_exec, rule, desired_count, base)
158
159 check_cmd = config.get('EXEC', 'check', fallback=None)
160 if check_cmd:
161 logger.debug('exec_candidates=%s', exec_candidates)
162 already_execed = set()
163 for snap in exec_candidates:
164 args = []
165 args += shlex.split(check_cmd)
166 args += [snap.name]
167 _log_cmd(*args)
168 check_res = subprocess.run(args)
169 if check_res.returncode == 0:
170 already_execed.add(snap)
171 logger.debug('‘%s’ already execed', snap.name)
172 elif check_res.returncode == 124:
173 already_execed.add(snap)
174 logger.warn('‘%s’ ignored', snap.name)
175 pass
176 elif check_res.returncode == 125:
177 already_execed.add(snap)
178 logger.info('‘%s’ ignored but specified for keeping, doing so...', snap.name)
179 base_name, _, _ = snap.name.rpartition('@')
180 keep_because(base_name, snap.name, 'exec-ignored')
181 elif check_res.returncode == 126:
182 logger.debug('‘%s’ to exec', snap.name)
183 else:
184 check_res.check_returncode()
185 exec_candidates -= already_execed
186
187 exec_cmd = config.get('EXEC', 'cmd', fallback=None)
188 exec_count = config.getint('EXEC', 'count', fallback=1)
189 if exec_cmd:
190 execed = set()
191 for snap in sorted(exec_candidates, key=lambda snap: snap.creation):
192 if exec_count > 0 and len(execed) >= exec_count:
193 logger.debug('exec_count of %d reached', exec_count)
194 break
195
196 args = []
197 args += shlex.split(exec_cmd)
198 args += [snap.name]
199 _log_cmd(*args)
200 p = subprocess.run(args)
201 if p.returncode == 125:
202 logger.warn('got dry-run returncode for ‘%s’, keeping...', snap.name)
203 base_name, _, _ = snap.name.rpartition('@')
204 keep_because(base_name, snap.name, 'exec-dryrun')
205 pass
206 else:
207 p.check_returncode()
208 execed.add(snap)
209
210 exec_candidates -= execed
211
212 for candidate in exec_candidates:
213 base_name, _, _ = candidate.name.rpartition('@')
214 keep_because(base_name, candidate.name, 'exec-candidate')
215
216 within = config.gettimedelta('KEEP', 'within')
217 if within > timedelta(seconds=0):
218 for base, snaps in items.items():
219 time_ref = max(snaps, key=lambda snap: snap.creation, default=None)
220 if not time_ref:
221 logger.warn('Nothing to keep for ‘%s’', base)
222 continue
223
224 logger.info('Using ‘%s’ as time reference for ‘%s’', time_ref.name, base)
225 within_cutoff = time_ref.creation - within
226
227 for snap in snaps:
228 if snap.creation >= within_cutoff:
229 keep_because(base, snap.name, 'within')
230 else:
231 logger.warn('Skipping rule ‘within’ since retention period is zero')
232
233 for rule, pattern in TIME_PATTERNS.items():
234 desired_count = config.getint('KEEP', rule, fallback=0)
235
236 for base, snaps in items.items():
237 periods = OrderedDict()
238
239 for snap in sorted(snaps, key=lambda snap: snap.creation, reverse=keep_newest):
240 period = pattern(snap.creation.astimezone(prune_timezone))
241 if period not in periods:
242 periods[period] = deque()
243 periods[period].append(snap)
244
245 to_keep = desired_count
246 ordered_periods = periods.items() if keep_newest else reversed(periods.items())
247 for period, period_snaps in ordered_periods:
248 if to_keep == 0:
249 break
250
251 for snap in period_snaps:
252 keep_because(base, snap.name, rule, period=period)
253 to_keep -= 1
254 break
255
256 if to_keep > 0:
257 logger.debug('Missing %d to fulfill prune %s=%d for ‘%s’', to_keep, rule, desired_count, base)
258
259 for snap, reasons in kept_because.items():
260 logger.info('Keeping ‘%s’ because: %s', snap, ', '.join(map(str, reasons)))
261 all_snaps = {snap.name for _, snaps in items.items() for snap in snaps}
262 to_destroy = all_snaps - {*kept_because}
263 if not to_destroy:
264 logger.info('Nothing to prune')
265
266 for snap in sorted(to_destroy):
267 args = ['zfs', 'destroy']
268 if dry_run:
269 args += ['-n']
270 args += [snap]
271 _log_cmd(*args)
272 subprocess.run(args, check=True)
273 if dry_run:
274 logger.info('Would have pruned ‘%s’', snap)
275 else:
276 logger.info('Pruned ‘%s’', snap)
277
278def rename(snapshots, destroy=False, set_is_auto=False):
279 args = ['zfs', 'get', '-H', '-p', '-o', 'name,value', 'creation', *snapshots]
280 _log_cmd(*args)
281 renamed_to = set()
282 with subprocess.Popen(args, stdout=subprocess.PIPE) as proc:
283 text_stdout = io.TextIOWrapper(proc.stdout)
284 reader = csv.DictReader(text_stdout, fieldnames=['name', 'timestamp'], delimiter='\t', quoting=csv.QUOTE_NONE)
285 Row = namedtuple('Row', reader.fieldnames)
286 for row in [Row(**data) for data in reader]:
287 creation = datetime.fromtimestamp(int(row.timestamp), timezone.utc)
288 base_name, _, _ = row.name.rpartition('@')
289 new_name = _snap_name(base_name, time=creation)
290 if new_name == row.name:
291 logger.debug('Not renaming ‘%s’ since name is already correct', row.name)
292 continue
293
294 if new_name in renamed_to:
295 if destroy:
296 logger.warning('Destroying ‘%s’ since ‘%s’ was already renamed to', row.name, new_name)
297 args = ['zfs', 'destroy', row.name]
298 _log_cmd(*args)
299 subprocess.run(args, check=True)
300 else:
301 logger.info('Skipping ‘%s’ since ‘%s’ was already renamed to', row.name, new_name)
302
303 continue
304
305 logger.info('Renaming ‘%s’ to ‘%s’', row.name, new_name)
306 args = ['zfs', 'rename', row.name, new_name]
307 _log_cmd(*args)
308 subprocess.run(args, check=True)
309 renamed_to.add(new_name)
310
311 if set_is_auto:
312 logger.info('Setting is-auto-snapshot on ‘%s’', new_name)
313 args = ['zfs', 'set', f'{PROP_IS_AUTO_SNAPSHOT}=true', new_name]
314 _log_cmd(*args)
315 subprocess.run(args, check=True)
316
317def autosnap():
318 items = _get_items()
319
320 all_snap_names = set()
321 async def do_snapshot(*snap_items, recursive=False):
322 nonlocal items, all_snap_names
323 snap_names = {_snap_name(item) for item in snap_items if items[item]}
324 if recursive:
325 for snap_item in snap_items:
326 all_snap_names |= {_snap_name(item) for item in items if item.startswith(snap_item)}
327 else:
328 all_snap_names |= snap_names
329
330 args = ['zfs', 'snapshot', '-o', f'{PROP_IS_AUTO_SNAPSHOT}=true']
331 if recursive:
332 args += ['-r']
333 args += snap_names
334
335 _log_cmd(*args)
336 subprocess.run(args, check=True)
337
338 pool_items = defaultdict(set)
339 for item in items:
340 pool, _, _ = item.partition('/')
341 pool_items[pool].add(item)
342
343 tasks = []
344 for snap_items in pool_items.values():
345 tasks.append(do_snapshot(*snap_items))
346 if not tasks:
347 logger.warning('No snapshots to create')
348 else:
349 async def run_tasks():
350 await asyncio.gather(*tasks)
351 asyncio.run(run_tasks())
352 for snap in all_snap_names:
353 logger.info('Created ‘%s’', snap)
354 if all_snap_names:
355 rename(snapshots=all_snap_names)
356
357def main():
358 global logger
359 logger = logging.getLogger(__name__)
360 console_handler = logging.StreamHandler()
361 console_handler.setFormatter( logging.Formatter('[%(levelname)s](%(name)s): %(message)s') )
362 if sys.stderr.isatty():
363 console_handler.setFormatter( logging.Formatter('%(asctime)s [%(levelname)s](%(name)s): %(message)s') )
364 logger.addHandler(console_handler)
365
366 # log uncaught exceptions
367 def log_exceptions(type, value, tb):
368 global logger
369
370 logger.error(value)
371 sys.__excepthook__(type, value, tb) # calls default excepthook
372
373 sys.excepthook = log_exceptions
374
375 parser = argparse.ArgumentParser(prog='zfssnap')
376 parser.add_argument('--verbose', '-v', dest='log_level', action='append_const', const=-1)
377 parser.add_argument('--quiet', '-q', dest='log_level', action='append_const', const=1)
378 subparsers = parser.add_subparsers()
379 parser.set_defaults(cmd=autosnap)
380 rename_parser = subparsers.add_parser('rename')
381 rename_parser.add_argument('snapshots', nargs='+')
382 rename_parser.add_argument('--destroy', action='store_true', default=False)
383 rename_parser.add_argument('--set-is-auto', action='store_true', default=False)
384 rename_parser.set_defaults(cmd=rename)
385 prune_parser = subparsers.add_parser('prune')
386 prune_parser.add_argument('--config', '-c', dest='config_files', nargs='*', default=list())
387 prune_parser.add_argument('--dry-run', '-n', action='store_true', default=False)
388 prune_parser.add_argument('--keep-newest', action='store_true', default=False)
389 prune_parser.add_argument('--exec-newest', action='store_true', default=False)
390 prune_parser.add_argument('--no-exec', dest='do_exec', action='store_false', default=True)
391 prune_parser.set_defaults(cmd=prune)
392 args = parser.parse_args()
393
394
395 LOG_LEVELS = [logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR, logging.CRITICAL]
396 DEFAULT_LOG_LEVEL = logging.ERROR
397 log_level = LOG_LEVELS.index(DEFAULT_LOG_LEVEL)
398
399 for adjustment in args.log_level or ():
400 log_level = min(len(LOG_LEVELS) - 1, max(log_level + adjustment, 0))
401 logger.setLevel(LOG_LEVELS[log_level])
402
403 cmdArgs = {}
404 for copy in {'snapshots', 'dry_run', 'destroy', 'keep_newest', 'exec_newest', 'set_is_auto', 'do_exec'}:
405 if copy in vars(args):
406 cmdArgs[copy] = vars(args)[copy]
407 if 'config_files' in vars(args):
408 def convert_timedelta(secs_str):
409 secs=pytimeparse.parse(secs_str)
410 if secs is None:
411 raise ValueError('Could not parse timedelta expression ‘%s’', secs_str)
412 return timedelta(seconds=secs)
413 config = configparser.ConfigParser(converters={
414 'timedelta': convert_timedelta,
415 'timezone': gettz
416 })
417 search_files = args.config_files if args.config_files else [*BaseDirectory.load_config_paths('zfssnap.ini')]
418 read_files = config.read(search_files)
419
420 def format_config_files(files):
421 if not files:
422 return 'no files'
423 return ', '.join(map(lambda file: f'‘{file}’', files))
424
425 if not read_files:
426 raise Exception('Found no config files. Tried: %s', format_config_files(search_files))
427
428 logger.debug('Read following config files: %s', format_config_files(read_files))
429
430 cmdArgs['config'] = config
431
432 args.cmd(**cmdArgs)
433
434if __name__ == '__main__':
435 sys.exit(main())