summaryrefslogtreecommitdiff
path: root/modules
diff options
context:
space:
mode:
authorGregor Kleen <gkleen@yggdrasil.li>2022-02-19 15:05:25 +0100
committerGregor Kleen <gkleen@yggdrasil.li>2022-02-19 15:05:25 +0100
commit6f82f965a742d2013d9bab578a4d83fa37526902 (patch)
tree33809fa53d7b8c71d2ebd0f88f90e23c738f3616 /modules
parente3ae43ac32b6d267dcb6acfa1dd5256136b4656b (diff)
downloadnixos-6f82f965a742d2013d9bab578a4d83fa37526902.tar
nixos-6f82f965a742d2013d9bab578a4d83fa37526902.tar.gz
nixos-6f82f965a742d2013d9bab578a4d83fa37526902.tar.bz2
nixos-6f82f965a742d2013d9bab578a4d83fa37526902.tar.xz
nixos-6f82f965a742d2013d9bab578a4d83fa37526902.zip
vidhar: zfssnap...
Diffstat (limited to 'modules')
-rw-r--r--modules/zfssnap/default.nix95
-rw-r--r--modules/zfssnap/zfssnap.py290
2 files changed, 385 insertions, 0 deletions
diff --git a/modules/zfssnap/default.nix b/modules/zfssnap/default.nix
new file mode 100644
index 00000000..a0590c9f
--- /dev/null
+++ b/modules/zfssnap/default.nix
@@ -0,0 +1,95 @@
1{ config, pkgs, lib, ... }:
2
3with lib;
4
5let
6 zfssnap = pkgs.stdenv.mkDerivation rec {
7 name = "zfssnap";
8 src = ./zfssnap.py;
9
10 phases = [ "buildPhase" "checkPhase" "installPhase" ];
11
12 buildInputs = with pkgs; [makeWrapper];
13
14 python = pkgs.python39.withPackages (ps: with ps; [pyxdg pytimeparse dateutil systemd]);
15
16 buildPhase = ''
17 substitute $src zfssnap \
18 --subst-var-by python ${escapeShellArg python}
19 '';
20
21 doCheck = true;
22 checkPhase = ''
23 ${python}/bin/python -m py_compile zfssnap
24 '';
25
26 installPhase = ''
27 install -m 0755 -D -t $out/bin \
28 zfssnap
29
30 wrapProgram $out/bin/zfssnap \
31 --prefix PATH : ${makeBinPath [config.boot.zfs.package]}
32 '';
33 };
34
35 cfg = config.services.zfssnap;
36in {
37 options = {
38 services.zfssnap = {
39 enable = mkEnableOption "zfssnap service";
40
41 config = mkOption {
42 type = with types; attrsOf (attrsOf str);
43 default = {
44 keep = {
45 within = "5m";
46 "5m" = "24";
47 hourly = "24";
48 daily = "31";
49 monthly = "24";
50 yearly = "-1";
51 };
52 };
53 };
54
55 snapInterval = mkOption {
56 type = types.str;
57 default = "*-*-* *:00/5:00 Europe/Berlin";
58 };
59 };
60 };
61
62 config = mkIf cfg.enable {
63 systemd.services."zfssnap" = {
64 description = "Create automatic ZFS snapshots";
65 after = [ "zfs-import.target" ];
66 wants = [ "zfssnap-prune.service" ];
67 before = [ "zfssnap-prune.service" ];
68 serviceConfig = {
69 Type = "oneshot";
70 ExecStart = "${zfssnap}/bin/zfssnap --no-stderr -vv";
71 };
72 };
73 systemd.services."zfssnap-prune" = {
74 description = "Prune automatic ZFS snapshots";
75 after = [ "zfs-import.target" "zfssnap.service" ];
76 serviceConfig = {
77 Type = "oneshot";
78 ExecStart = let
79 mkSectionName = name: strings.escape [ "[" "]" ] (strings.toUpper name);
80 zfssnapConfig = generators.toINI { inherit mkSectionName; } cfg.config;
81 in "${zfssnap}/bin/zfssnap --no-stderr -vv prune --config=${zfssnapConfig}";
82 };
83 };
84
85 systemd.timers."zfssnap" = {
86 wantedBy = ["timers.target"];
87 timerConfig = {
88 OnCalendar = cfg.snapInterval;
89 Persistent = true;
90 };
91 };
92
93 environment.systemPackages = [zfssnap];
94 };
95}
diff --git a/modules/zfssnap/zfssnap.py b/modules/zfssnap/zfssnap.py
new file mode 100644
index 00000000..86690127
--- /dev/null
+++ b/modules/zfssnap/zfssnap.py
@@ -0,0 +1,290 @@
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, tzlocal
9import pytimeparse
10import argparse
11import re
12
13import sys
14
15import logging
16
17import shlex
18
19from collections import defaultdict, OrderedDict
20
21import configparser
22from xdg import BaseDirectory
23
24from functools import cache
25
26from math import floor
27
28from systemd import journal
29
30
31@cache
32def _now():
33 return datetime.now(timezone.utc)
34
35def _snap_name(item, time=_now()):
36 suffix = re.sub(r'\+00:00$', r'Z', time.isoformat())
37 return f'{item}@auto_{suffix}'
38
39def _log_cmd(*args):
40 fmt_args = ' '.join(map(shlex.quote, args))
41 logger.debug(f'Running command: {fmt_args}')
42
43def _get_items():
44 items = {}
45
46 args = ['zfs', 'get', '-H', '-p', '-o', 'name,value', '-t', 'filesystem,volume', '-s', 'local,default,inherited,temporary,received', 'li.yggdrasil:auto-snapshot']
47 _log_cmd(*args)
48 with subprocess.Popen(args, stdout=subprocess.PIPE) as proc:
49 text_stdout = io.TextIOWrapper(proc.stdout)
50 reader = csv.reader(text_stdout, delimiter='\t', quoting=csv.QUOTE_NONE)
51 for row in reader:
52 name = row[0]
53 setting = bool(strtobool(row[1]))
54 items[name] = setting
55
56 return items
57
58def prune(config, dry_run):
59
60 items = defaultdict(list)
61
62 args = ['zfs', 'get', '-H', '-p', '-o', 'name,value', '-t', 'snapshot', 'creation']
63 _log_cmd(*args)
64 with subprocess.Popen(args, stdout=subprocess.PIPE) as proc:
65 text_stdout = io.TextIOWrapper(proc.stdout)
66 reader = csv.reader(text_stdout, delimiter='\t', quoting=csv.QUOTE_NONE)
67 for row in reader:
68 name = row[0]
69 timestamp = int(row[1])
70 creation = datetime.fromtimestamp(timestamp, timezone.utc)
71 base_name, _, _ = name.rpartition('@')
72 expected_name = _snap_name(base_name, time=creation)
73 if expected_name != name:
74 # logger.debug(f'Skipping ‘{name}’ since it does not conform to naming scheme')
75 continue
76 items[base_name].append({'name': name, 'creation': creation})
77
78 keep = set()
79 kept_count = defaultdict(lambda: defaultdict(lambda: 0))
80 def keep_because(base, snap, rule, period=None):
81 nonlocal kept_count
82 if snap not in keep:
83 kept_count[rule][base] += 1
84 logger.info(f'Keeping ‘{snap}’ because of rule ‘{rule}’ (#{kept_count[rule][base]} for ‘{base}’, period={period})')
85 keep.add(snap)
86
87 within = config.gettimedelta('KEEP', 'within')
88 within_cutoff = _now() - within
89
90 for base, snap in [(base, snap) for base, snaps in items.items() for snap in snaps]:
91 if snap['creation'] >= within_cutoff:
92 keep_because(base, snap['name'], 'within')
93
94 prune_timezone = config.gettimezone('KEEP', 'timezone', fallback=tzlocal)
95
96 PRUNING_PATTERNS = OrderedDict([
97 ("secondly", lambda t: t.strftime('%Y-%m-%d %H:%M:%S')),
98 ("minutely", lambda t: t.strftime('%Y-%m-%d %H:%M')),
99 ("5m", lambda t: (t.strftime('%Y-%m-%d %H'), floor(t.minute / 5) * 5)),
100 ("hourly", lambda t: t.strftime('%Y-%m-%d %H')),
101 ("daily", lambda t: t.strftime('%Y-%m-%d')),
102 ("weekly", lambda t: t.strftime('%G-%V')),
103 ("monthly", lambda t: t.strftime('%Y-%m')),
104 ("yearly", lambda t: t.strftime('%Y')),
105 ])
106
107 for rule, pattern in PRUNING_PATTERNS.items():
108 desired_count = config.getint('KEEP', rule, fallback=0)
109
110 for base, snaps in items.items():
111 last_period = None
112 to_keep = desired_count
113
114 if to_keep == 0:
115 continue
116
117 for snap in sorted(snaps, key=lambda snap: snap['creation'], reverse=True):
118 if to_keep == 0:
119 break
120
121 period = pattern(snap['creation'])
122 if period != last_period:
123 last_period = period
124 keep_because(base, snap['name'], rule, period=period)
125 to_keep -= 1
126
127 if to_keep > 0:
128 logger.debug(f'Missing {to_keep} to fulfill {rule}={desired_count} for ‘{base}’')
129
130 all_snaps = {snap['name'] for _, snaps in items.items() for snap in snaps}
131 to_delete = all_snaps - keep
132 if to_delete:
133 logger.info(f'Will prune: %s', ', '.join(map(lambda snap: f'‘{snap}’', to_delete)))
134 else:
135 logger.info('Nothing to prune')
136
137 for snap in to_delete:
138 args = ['zfs', 'destroy']
139 if dry_run:
140 args += ['-n']
141 args += [snap]
142 _log_cmd(*args)
143 subprocess.run(args, check=True)
144
145def rename(snapshots):
146 args = ['zfs', 'get', '-H', '-p', '-o', 'name,value', 'creation', *snapshots]
147 _log_cmd(*args)
148 with subprocess.Popen(args, stdout=subprocess.PIPE) as proc:
149 text_stdout = io.TextIOWrapper(proc.stdout)
150 reader = csv.reader(text_stdout, delimiter='\t', quoting=csv.QUOTE_NONE)
151 for row in reader:
152 name = row[0]
153 timestamp = int(row[1])
154 creation = datetime.fromtimestamp(timestamp, timezone.utc)
155 base_name, _, _ = name.rpartition('@')
156 new_name = _snap_name(base_name, time=creation)
157 if new_name == name:
158 logger.debug(f'Not renaming ‘{name}’ since name is already correct')
159 continue
160 logger.info(f'Renaming ‘{name}’ to ‘{new_name}’')
161
162 args = ['zfs', 'rename', name, new_name]
163 _log_cmd(*args)
164 subprocess.run(args, check=True)
165
166def autosnap():
167 items = _get_items()
168
169 recursive, single = set(), set()
170
171 for item_name, is_included in items.items():
172 if not is_included:
173 continue
174
175 children = {sub_name for sub_name in items if sub_name.startswith(f'{item_name}/')}
176 is_recursive = all([items[sub_name] for sub_name in children])
177 if is_recursive and children:
178 recursive.add(item_name)
179 else:
180 single.add(item_name)
181
182 for item_name in recursive | single:
183 is_covered = any([item_name.startswith(f'{super_name}/') for super_name in recursive])
184 if is_covered:
185 try:
186 recursive.remove(item_name)
187 except KeyError:
188 pass
189 try:
190 single.remove(item_name)
191 except KeyError:
192 pass
193
194 def do_snapshot(*snap_items, recursive=False):
195 nonlocal items
196 snap_names = {_snap_name(item) for item in snap_items}
197 all_snap_names = None
198 if recursive:
199 all_snap_names = set()
200 for snap_item in snap_items:
201 all_snap_names |= {_snap_name(item) for item in items if item.startswith(snap_item)}
202 else:
203 all_snap_names = snap_names
204
205 args = ['zfs', 'snapshot']
206 if recursive:
207 args += ['-r']
208 args += snap_names
209
210 _log_cmd(*args)
211 subprocess.run(args, check=True)
212 rename(snapshots=all_snap_names)
213
214 do_snapshot(*single)
215 do_snapshot(*recursive, recursive=True)
216
217def main():
218 global logger
219 logger = logging.getLogger(__name__)
220 systemd_handler = journal.JournalHandler()
221
222 # log uncaught exceptions
223 def log_exceptions(type, value, tb):
224 global logger
225
226 logger.error(value)
227 sys.__excepthook__(type, value, tb) # calls default excepthook
228
229 sys.excepthook = log_exceptions
230
231 parser = argparse.ArgumentParser(prog='zfssnap')
232 parser.add_argument('--verbose', '-v', action='count', default=0)
233 parser.add_argument('--no-stderr', dest='stderr', action='store_false', default=True)
234 subparsers = parser.add_subparsers()
235 parser.set_defaults(cmd=autosnap)
236 rename_parser = subparsers.add_parser('rename')
237 rename_parser.add_argument('snapshots', nargs='+')
238 rename_parser.set_defaults(cmd=rename)
239 prune_parser = subparsers.add_parser('prune')
240 prune_parser.add_argument('--config', '-c', dest='config_files', type=argparse.FileType('r'), nargs='*', default=list())
241 prune_parser.add_argument('--dry-run', '-n', action='store_true', default=False)
242 prune_parser.set_defaults(cmd=prune)
243 args = parser.parse_args()
244
245 if args.stderr:
246 console_handler = logging.StreamHandler()
247 console_handler.setFormatter( logging.Formatter('[%(levelname)s](%(name)s): %(message)s') )
248 if sys.stderr.isatty():
249 console_handler.setFormatter( logging.Formatter('%(asctime)s [%(levelname)s](%(name)s): %(message)s') )
250 logger.addHandler(console_handler)
251
252 if args.verbose <= 0:
253 logger.setLevel(logging.WARNING)
254 elif args.verbose <= 1:
255 logger.setLevel(logging.INFO)
256 else:
257 logger.setLevel(logging.DEBUG)
258
259 cmdArgs = {}
260 for copy in {'snapshots', 'dry_run'}:
261 if copy in vars(args):
262 cmdArgs[copy] = vars(args)[copy]
263 if 'config_files' in vars(args):
264 def convert_timedelta(secs_str):
265 secs=pytimeparse.parse(secs_str)
266 if secs is None:
267 raise ValueError(f'Could not parse timedelta expression ‘{secs_str}’')
268 return timedelta(seconds=secs)
269 config = configparser.ConfigParser(converters={
270 'timedelta': convert_timedelta,
271 'timezone': gettz
272 })
273 search_files = args.config_files if args.config_files else [*BaseDirectory.load_config_paths('zfssnap.ini')]
274 read_files = config.read(search_files)
275
276 def format_config_files(files):
277 if not files:
278 return 'no files'
279 return ', '.join(map(lambda file: f'‘{file}’', files))
280
281 if not read_files:
282 raise Exception(f'Found no config files. Tried: {format_config_files(search_files)}')
283
284 logger.debug(f'Read following config files: {format_config_files(read_files)}')
285
286 cmdArgs['config'] = config
287
288 args.cmd(**cmdArgs)
289
290sys.exit(main())