diff options
author | Gregor Kleen <gkleen@yggdrasil.li> | 2022-11-01 21:05:33 +0100 |
---|---|---|
committer | Gregor Kleen <gkleen@yggdrasil.li> | 2022-11-01 21:05:33 +0100 |
commit | a73133a7c629c76a7a328f0e8d2bb693c46ef45d (patch) | |
tree | 11112e70dd44a3a1eb7fd1702c0b80baa578b386 /hosts/vidhar/borg/borgsnap | |
parent | 4188923d715bb4cfc3542eb05781fd45df85522e (diff) | |
download | nixos-a73133a7c629c76a7a328f0e8d2bb693c46ef45d.tar nixos-a73133a7c629c76a7a328f0e8d2bb693c46ef45d.tar.gz nixos-a73133a7c629c76a7a328f0e8d2bb693c46ef45d.tar.bz2 nixos-a73133a7c629c76a7a328f0e8d2bb693c46ef45d.tar.xz nixos-a73133a7c629c76a7a328f0e8d2bb693c46ef45d.zip |
fix backups
Diffstat (limited to 'hosts/vidhar/borg/borgsnap')
-rw-r--r-- | hosts/vidhar/borg/borgsnap/borgsnap/__main__.py | 355 |
1 files changed, 272 insertions, 83 deletions
diff --git a/hosts/vidhar/borg/borgsnap/borgsnap/__main__.py b/hosts/vidhar/borg/borgsnap/borgsnap/__main__.py index e93e6a60..cd2f1ad2 100644 --- a/hosts/vidhar/borg/borgsnap/borgsnap/__main__.py +++ b/hosts/vidhar/borg/borgsnap/borgsnap/__main__.py | |||
@@ -1,12 +1,11 @@ | |||
1 | import argparse | 1 | import argparse |
2 | import os, sys, signal | 2 | import os, sys, signal, io |
3 | from pyprctl import cap_permitted, cap_inheritable, cap_effective, cap_ambient, Cap | 3 | from pyprctl import CapState, Cap, cap_ambient_raise, cap_ambient_is_set, set_keepcaps |
4 | from pwd import getpwnam | 4 | from pwd import getpwnam |
5 | 5 | ||
6 | from datetime import datetime, timezone | 6 | from datetime import datetime, timezone |
7 | from dateutil.parser import isoparse | 7 | from dateutil.parser import isoparse |
8 | 8 | ||
9 | from xdg import xdg_runtime_dir | ||
10 | import unshare | 9 | import unshare |
11 | from tempfile import TemporaryDirectory | 10 | from tempfile import TemporaryDirectory |
12 | 11 | ||
@@ -14,6 +13,9 @@ import logging | |||
14 | 13 | ||
15 | import json | 14 | import json |
16 | import subprocess | 15 | import subprocess |
16 | import csv | ||
17 | from collections import namedtuple | ||
18 | from distutils.util import strtobool | ||
17 | 19 | ||
18 | import pathlib | 20 | import pathlib |
19 | from pathlib import Path | 21 | from pathlib import Path |
@@ -22,21 +24,89 @@ from atomicwrites import atomic_write | |||
22 | 24 | ||
23 | from traceback import format_exc | 25 | from traceback import format_exc |
24 | 26 | ||
27 | from multiprocessing import Process, Manager | ||
28 | from contextlib import closing | ||
29 | |||
30 | from enum import Enum, auto | ||
31 | |||
32 | import select | ||
33 | import time | ||
34 | import math | ||
35 | |||
36 | |||
37 | PROP_DO_BORGSNAP = 'li.yggdrasil:borgsnap' | ||
38 | |||
39 | |||
40 | class DoValue(Enum): | ||
41 | BORGSNAP_DO = auto() | ||
42 | BORGSNAP_KEEP = auto() | ||
43 | BORGSNAP_DONT = auto() | ||
44 | |||
45 | @classmethod | ||
46 | def from_prop(cls, v: str): | ||
47 | if v.lower() == 'keep': | ||
48 | return cls.BORGSNAP_KEEP | ||
49 | |||
50 | return cls.BORGSNAP_DO if not v or bool(strtobool(v)) else cls.BORGSNAP_DONT | ||
51 | |||
52 | @classmethod | ||
53 | def merge(cls, v1, v2): | ||
54 | match (v1, v2): | ||
55 | case (cls.BORGSNAP_DONT, _): | ||
56 | return cls.BORGSNAP_DONT | ||
57 | case (_, cls.BORGSNAP_DONT): | ||
58 | return cls.BORGSNAP_DONT | ||
59 | case (cls.BORGSNAP_KEEP, _): | ||
60 | return cls.BORGSNAP_KEEP | ||
61 | case (_, cls.BORGSNAP_KEEP): | ||
62 | return cls.BORGSNAP_KEEP | ||
63 | case other: | ||
64 | return cls.BORGSNAP_DO | ||
65 | |||
66 | def returncode(self): | ||
67 | match self: | ||
68 | case self.__class__.BORGSNAP_DO: | ||
69 | return 126 | ||
70 | case self.__class__.BORGSNAP_KEEP: | ||
71 | return 125 | ||
72 | case self.__class__.BORGSNAP_DONT: | ||
73 | return 124 | ||
25 | 74 | ||
26 | borg_pwd = getpwnam('borg') | 75 | borg_pwd = getpwnam('borg') |
27 | 76 | ||
28 | def as_borg(caps=set(), cwd=None): | 77 | def as_borg(caps=set()): |
29 | if caps: | 78 | global logger |
30 | cap_permitted.add(*caps) | 79 | |
31 | cap_inheritable.add(*caps) | 80 | try: |
32 | cap_effective.add(*caps) | 81 | if caps: |
33 | cap_ambient.add(*caps) | 82 | c_state = CapState.get_current() |
83 | c_state.permitted.add(*caps) | ||
84 | c_state.set_current() | ||
85 | |||
86 | logger.debug("before setgid/setuid: cap_permitted=%s", CapState.get_current().permitted) | ||
87 | |||
88 | set_keepcaps(True) | ||
89 | |||
90 | os.setgid(borg_pwd.pw_gid) | ||
91 | os.setuid(borg_pwd.pw_uid) | ||
34 | 92 | ||
35 | os.setgid(borg_pwd.pw_gid) | 93 | if caps: |
36 | os.setuid(borg_pwd.pw_uid) | 94 | logger.debug("after setgid/setuid: cap_permitted=%s", CapState.get_current().permitted) |
37 | 95 | ||
38 | if cwd is not None: | 96 | c_state = CapState.get_current() |
39 | os.chdir(cwd) | 97 | c_state.permitted = caps.copy() |
98 | c_state.inheritable.add(*caps) | ||
99 | c_state.set_current() | ||
100 | |||
101 | logger.debug("cap_permitted=%s", CapState.get_current().permitted) | ||
102 | logger.debug("cap_inheritable=%s", CapState.get_current().inheritable) | ||
103 | |||
104 | for cap in caps: | ||
105 | cap_ambient_raise(cap) | ||
106 | logger.debug("cap_ambient[%s]=%s", cap, cap_ambient_is_set(cap)) | ||
107 | except Exception: | ||
108 | logger.error(format_exc()) | ||
109 | raise | ||
40 | 110 | ||
41 | 111 | ||
42 | def _archive_name(snapshot, target, archive_prefix): | 112 | def _archive_name(snapshot, target, archive_prefix): |
@@ -50,13 +120,15 @@ def _archive_basename(snapshot, archive_prefix): | |||
50 | return archive_prefix + base_name.replace('-', '--').replace('/', '-') | 120 | return archive_prefix + base_name.replace('-', '--').replace('/', '-') |
51 | 121 | ||
52 | def check(*, snapshot, target, archive_prefix, cache_file): | 122 | def check(*, snapshot, target, archive_prefix, cache_file): |
123 | global logger | ||
124 | |||
53 | archives = None | 125 | archives = None |
54 | if cache_file: | 126 | if cache_file: |
55 | logger.debug('Trying cache...') | 127 | logger.debug('Trying cache...') |
56 | try: | 128 | try: |
57 | with open(cache_file, mode='r', encoding='utf-8') as fp: | 129 | with open(cache_file, mode='r', encoding='utf-8') as fp: |
58 | archives = set(json.load(fp)) | 130 | archives = set(json.load(fp)) |
59 | logger.info('Loaded archive list from cache') | 131 | logger.debug('Loaded archive list from cache') |
60 | except FileNotFoundError: | 132 | except FileNotFoundError: |
61 | pass | 133 | pass |
62 | 134 | ||
@@ -72,76 +144,165 @@ def check(*, snapshot, target, archive_prefix, cache_file): | |||
72 | # logger.debug(f'archives: {archives}') | 144 | # logger.debug(f'archives: {archives}') |
73 | _, _, archive_name = _archive_name(snapshot, target, archive_prefix).partition('::') | 145 | _, _, archive_name = _archive_name(snapshot, target, archive_prefix).partition('::') |
74 | if archive_name in archives: | 146 | if archive_name in archives: |
75 | logger.info(f'{archive_name} found') | 147 | logger.info('‘%s’ found', archive_name) |
76 | return 0 | 148 | return 0 |
77 | else: | 149 | else: |
78 | logger.info(f'{archive_name} not found') | 150 | logger.info('‘%s’ not found', archive_name) |
79 | return 126 | 151 | |
152 | logger.debug('Checking %s for ‘%s’...', PROP_DO_BORGSNAP, snapshot) | ||
153 | intent = DoValue.BORGSNAP_DO | ||
154 | p = subprocess.run(['zfs', 'get', '-H', '-p', '-o', 'name,value', PROP_DO_BORGSNAP, snapshot], stdout=subprocess.PIPE, text=True, check=True) | ||
155 | reader = csv.DictReader(io.StringIO(p.stdout), fieldnames=['name', 'value'], delimiter='\t', quoting=csv.QUOTE_NONE) | ||
156 | Row = namedtuple('Row', reader.fieldnames) | ||
157 | for row in [Row(**data) for data in reader]: | ||
158 | if not row.value or row.value == '-': | ||
159 | continue | ||
160 | |||
161 | logger.debug('%s=%s (parsed as %s) for ‘%s’...', PROP_DO_BORGSNAP, row.value, DoValue.from_prop(row.value), row.name) | ||
162 | intent = DoValue.merge(intent, DoValue.from_prop(row.value)) | ||
163 | |||
164 | match intent: | ||
165 | case DoValue.BORGSNAP_DONT: | ||
166 | logger.warn('%s specifies to ignore, returning accordingly...', PROP_DO_BORGSNAP) | ||
167 | case DoValue.BORGSNAP_KEEP: | ||
168 | logger.info('%s specifies to ignore but keep, returning accordingly...', PROP_DO_BORGSNAP) | ||
169 | case other: | ||
170 | pass | ||
171 | |||
172 | return intent.returncode() | ||
80 | 173 | ||
81 | def create(*, snapshot, target, archive_prefix, dry_run): | 174 | def create(*, snapshot, target, archive_prefix, dry_run): |
175 | global logger | ||
176 | |||
82 | basename = _archive_basename(snapshot, archive_prefix) | 177 | basename = _archive_basename(snapshot, archive_prefix) |
83 | 178 | ||
84 | with TemporaryDirectory(prefix=f'borg-mount_{basename}_', dir=os.environ.get('RUNTIME_DIRECTORY')) as tmpdir: | 179 | def do_create(tmpdir_q): |
85 | child = os.fork() | 180 | global logger |
86 | if child == 0: | 181 | nonlocal basename, snapshot, target, archive_prefix, dry_run |
87 | unshare.unshare(unshare.CLONE_NEWNS) | 182 | |
88 | subprocess.run(['mount', '--make-rprivate', '/'], check=True) | 183 | tmpdir = tmpdir_q.get() |
89 | chroot = pathlib.Path(tmpdir) / 'chroot' | 184 | |
90 | upper = pathlib.Path(tmpdir) / 'upper' | 185 | unshare.unshare(unshare.CLONE_NEWNS) |
91 | work = pathlib.Path(tmpdir) / 'work' | 186 | subprocess.run(['mount', '--make-rprivate', '/'], check=True) |
92 | for path in [chroot,upper,work]: | 187 | chroot = pathlib.Path(tmpdir) / 'chroot' |
93 | path.mkdir() | 188 | upper = pathlib.Path(tmpdir) / 'upper' |
94 | subprocess.run(['mount', '-t', 'overlay', 'overlay', '-o', f'lowerdir=/,upperdir={upper},workdir={work}', chroot], check=True) | 189 | work = pathlib.Path(tmpdir) / 'work' |
95 | bindMounts = ['nix', 'run', 'run/secrets.d', 'run/wrappers', 'proc', 'dev', 'sys', pathlib.Path(os.path.expanduser('~')).relative_to('/')] | 190 | for path in [chroot,upper,work]: |
96 | if os.environ.get('BORG_BASE_DIR'): | 191 | path.mkdir() |
97 | bindMounts.append(pathlib.Path(os.environ['BORG_BASE_DIR']).relative_to('/')) | 192 | subprocess.run(['mount', '-t', 'overlay', 'overlay', '-o', f'lowerdir=/,upperdir={upper},workdir={work}', chroot], check=True) |
98 | if 'SSH_AUTH_SOCK' in os.environ: | 193 | bindMounts = ['nix', 'run', 'run/secrets.d', 'run/wrappers', 'proc', 'dev', 'sys', pathlib.Path(os.path.expanduser('~')).relative_to('/')] |
99 | bindMounts.append(pathlib.Path(os.environ['SSH_AUTH_SOCK']).parent.relative_to('/')) | 194 | if borg_base_dir := os.getenv('BORG_BASE_DIR'): |
100 | for bindMount in bindMounts: | 195 | bindMounts.append(pathlib.Path(borg_base_dir).relative_to('/')) |
101 | (chroot / bindMount).mkdir(parents=True,exist_ok=True) | 196 | if ssh_auth_sock := os.getenv('SSH_AUTH_SOCK'): |
102 | # print(*['mount', '--bind', pathlib.Path('/') / bindMount, chroot / bindMount], file=stderr) | 197 | bindMounts.append(pathlib.Path(ssh_auth_sock).parent.relative_to('/')) |
103 | subprocess.run(['mount', '--bind', pathlib.Path('/') / bindMount, chroot / bindMount], check=True) | 198 | for bindMount in bindMounts: |
104 | os.chroot(chroot) | 199 | (chroot / bindMount).mkdir(parents=True,exist_ok=True) |
105 | os.chdir('/') | 200 | subprocess.run(['mount', '--bind', pathlib.Path('/') / bindMount, chroot / bindMount], check=True) |
106 | dir = pathlib.Path('/borg') | 201 | |
107 | dir.mkdir(parents=True,exist_ok=True,mode=0o0750) | 202 | os.chroot(chroot) |
108 | os.chown(dir, borg_pwd.pw_uid, borg_pwd.pw_gid) | 203 | os.chdir('/') |
109 | try: | 204 | dir = pathlib.Path('/borg') |
205 | dir.mkdir(parents=True,exist_ok=True,mode=0o0750) | ||
206 | os.chown(dir, borg_pwd.pw_uid, borg_pwd.pw_gid) | ||
207 | |||
208 | base_name, _, _ = snapshot.rpartition('@') | ||
209 | type_val = subprocess.run(['zfs', 'get', '-H', '-p', '-o', 'value', 'type', base_name], stdout=subprocess.PIPE, text=True, check=True).stdout.strip() | ||
210 | match type_val: | ||
211 | case 'filesystem': | ||
110 | subprocess.run(['mount', '-t', 'zfs', '-o', 'ro', snapshot, dir], check=True) | 212 | subprocess.run(['mount', '-t', 'zfs', '-o', 'ro', snapshot, dir], check=True) |
111 | env = os.environ.copy() | 213 | case 'volume': |
112 | create_args = ['borg', | 214 | snapdev_val = subprocess.run(['zfs', 'get', '-H', '-p', '-o', 'value', 'snapdev', base_name], stdout=subprocess.PIPE, text=True, check=True).stdout.strip() |
113 | 'create', | 215 | try: |
114 | '--lock-wait=600', | 216 | if snapdev_val == 'hidden': |
115 | '--one-file-system', | 217 | subprocess.run(['zfs', 'set', 'snapdev=visible', base_name], check=True) |
116 | '--compression=auto,zstd,10', | 218 | subprocess.run(['mount', '-t', 'auto', '-o', 'ro', Path('/dev/zvol') / snapshot, dir], check=True) |
117 | '--chunker-params=10,23,16,4095', | 219 | finally: |
118 | '--files-cache=ctime,size', | 220 | if snapdev_val == 'hidden': |
119 | '--show-rc', | 221 | subprocess.run(['zfs', 'inherit', 'snapdev', base_name], check=True) |
120 | # '--remote-ratelimit=20480', | 222 | case other: |
121 | '--progress', | 223 | raise ValueError(f'‘{base_name}’ is of type ‘{type_val}’') |
122 | '--list', | 224 | |
123 | '--filter=AMEi-x?', | 225 | env = os.environ.copy() |
124 | '--stats' if not dry_run else '--dry-run' | 226 | create_args = ['borg', |
125 | ] | 227 | 'create', |
126 | _, _, ts = snapshot.rpartition('@') | 228 | '--lock-wait=600', |
127 | creation_time = isoparse(ts).astimezone(timezone.utc) | 229 | '--one-file-system', |
128 | create_args += [f'--timestamp={creation_time.strftime("%Y-%m-%dT%H:%M:%S")}'] | 230 | '--exclude-caches', |
129 | env['BORG_FILES_CACHE_SUFFIX'] = basename | 231 | '--keep-exclude-tags', |
130 | create_args += [_archive_name(snapshot, target, archive_prefix), '.'] | 232 | '--compression=auto,zstd,10', |
131 | print({'create_args': create_args, 'cwd': dir, 'env': env}, file=sys.stderr) | 233 | '--chunker-params=10,23,16,4095', |
132 | subprocess.run(create_args, stdin=subprocess.DEVNULL, env=env, preexec_fn=lambda: as_borg(caps={CAP.DAC_READ_SEARCH}, cwd=dir), check=True) | 234 | '--files-cache=ctime,size', |
133 | # subprocess.run(create_args, stdin=subprocess.DEVNULL, env=env, preexec_fn=lambda: None, cwd=dir, check=True) | 235 | '--show-rc', |
134 | finally: | 236 | # '--remote-ratelimit=20480', |
135 | subprocess.run(['umount', dir], check=True) | 237 | '--progress', |
136 | os._exit(0) | 238 | '--list', |
239 | '--filter=AMEi-x?', | ||
240 | '--stats' if not dry_run else '--dry-run', | ||
241 | ] | ||
242 | _, _, ts = snapshot.rpartition('@') | ||
243 | creation_time = isoparse(ts).astimezone(timezone.utc) | ||
244 | create_args += [f'--timestamp={creation_time.strftime("%Y-%m-%dT%H:%M:%S")}'] | ||
245 | env['BORG_FILES_CACHE_SUFFIX'] = basename | ||
246 | archive_name = _archive_name(snapshot, target, archive_prefix) | ||
247 | target_host, _, target_path = target.rpartition(':') | ||
248 | *parents_init, _ = list(Path(target_path).parents) | ||
249 | backup_patterns = [*(map(lambda p: Path('.backup') / f'{target_host}:{p}', [Path(target_path), *parents_init])), Path('.backup') / target_host, Path('.backup')] | ||
250 | for pattern_file in backup_patterns: | ||
251 | if (dir / pattern_file).is_file(): | ||
252 | logger.debug('Found backup patterns at ‘%s’', dir / pattern_file) | ||
253 | create_args += [f'--patterns-from={pattern_file}', archive_name] | ||
254 | break | ||
255 | elif (dir / pattern_file).exists(): | ||
256 | logger.warn('‘%s’ exists but is no file', dir / pattern_file) | ||
137 | else: | 257 | else: |
138 | while True: | 258 | logger.debug('No backup patterns exist, checked %s', list(map(lambda pattern_file: str(dir / pattern_file), backup_patterns))) |
139 | waitpid, waitret = os.wait() | 259 | create_args += [archive_name, '.'] |
140 | if waitret != 0: | 260 | logger.debug('%s', {'create_args': create_args, 'cwd': dir, 'env': env}) |
141 | sys.exit(waitret) | 261 | |
142 | if waitpid == child: | 262 | with subprocess.Popen(create_args, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, preexec_fn=lambda: as_borg(caps={Cap.DAC_READ_SEARCH}), cwd=dir, text=True) as proc: |
143 | break | 263 | proc_logger = logger.getChild('borg') |
144 | return 0 | 264 | |
265 | poll = select.poll() | ||
266 | poll.register(proc.stdout, select.POLLIN | select.POLLHUP) | ||
267 | poll.register(proc.stderr, select.POLLIN | select.POLLHUP) | ||
268 | pollc = 2 | ||
269 | events = poll.poll() | ||
270 | while pollc > 0 and len(events) > 0: | ||
271 | for rfd, event in events: | ||
272 | if event & select.POLLIN: | ||
273 | if rfd == proc.stdout.fileno(): | ||
274 | line = proc.stdout.readline() | ||
275 | if len(line) > 0: | ||
276 | proc_logger.info(line[:-1]) | ||
277 | if rfd == proc.stderr.fileno(): | ||
278 | line = proc.stderr.readline() | ||
279 | if len(line) > 0: | ||
280 | proc_logger.info(line[:-1]) | ||
281 | if event & select.POLLHUP: | ||
282 | poll.unregister(rfd) | ||
283 | pollc -= 1 | ||
284 | |||
285 | if pollc > 0: | ||
286 | events = poll.poll() | ||
287 | |||
288 | for handler in proc_logger.handlers: | ||
289 | handler.flush() | ||
290 | |||
291 | ret = proc.wait() | ||
292 | if ret != 0: | ||
293 | raise Exception(f'borg subprocess exited with returncode {ret}') | ||
294 | |||
295 | with Manager() as manager: | ||
296 | tmpdir_q = manager.Queue(1) | ||
297 | with closing(Process(target=do_create, args=(tmpdir_q,), name='do_create')) as p: | ||
298 | p.start() | ||
299 | |||
300 | with TemporaryDirectory(prefix=f'borg-mount_{basename}_', dir=os.getenv('RUNTIME_DIRECTORY')) as tmpdir: | ||
301 | tmpdir_q.put(tmpdir) | ||
302 | p.join() | ||
303 | if p.exitcode == 0 and dry_run: | ||
304 | return 125 | ||
305 | return p.exitcode | ||
145 | 306 | ||
146 | def sigterm(signum, frame): | 307 | def sigterm(signum, frame): |
147 | raise SystemExit(128 + signum) | 308 | raise SystemExit(128 + signum) |
@@ -155,6 +316,32 @@ def main(): | |||
155 | console_handler.setFormatter( logging.Formatter('[%(levelname)s](%(name)s): %(message)s') ) | 316 | console_handler.setFormatter( logging.Formatter('[%(levelname)s](%(name)s): %(message)s') ) |
156 | if sys.stderr.isatty(): | 317 | if sys.stderr.isatty(): |
157 | console_handler.setFormatter( logging.Formatter('%(asctime)s [%(levelname)s](%(name)s): %(message)s') ) | 318 | console_handler.setFormatter( logging.Formatter('%(asctime)s [%(levelname)s](%(name)s): %(message)s') ) |
319 | |||
320 | burst_max = 10000 | ||
321 | burst = burst_max | ||
322 | last_use = None | ||
323 | inv_rate = 1e6 | ||
324 | def consume_filter(record): | ||
325 | nonlocal burst, burst_max, inv_rate, last_use | ||
326 | |||
327 | delay = None | ||
328 | while True: | ||
329 | now = time.monotonic_ns() | ||
330 | burst = min(burst_max, burst + math.floor((now - last_use) / inv_rate)) if last_use else burst_max | ||
331 | last_use = now | ||
332 | |||
333 | if burst > 0: | ||
334 | burst -= 1 | ||
335 | if delay: | ||
336 | delay = now - delay | ||
337 | |||
338 | return True | ||
339 | |||
340 | if delay is None: | ||
341 | delay = now | ||
342 | time.sleep(inv_rate / 1e9) | ||
343 | console_handler.addFilter(consume_filter) | ||
344 | |||
158 | logger.addHandler(console_handler) | 345 | logger.addHandler(console_handler) |
159 | 346 | ||
160 | # log uncaught exceptions | 347 | # log uncaught exceptions |
@@ -167,7 +354,8 @@ def main(): | |||
167 | sys.excepthook = log_exceptions | 354 | sys.excepthook = log_exceptions |
168 | 355 | ||
169 | parser = argparse.ArgumentParser(prog='borgsnap') | 356 | parser = argparse.ArgumentParser(prog='borgsnap') |
170 | parser.add_argument('--verbose', '-v', action='count', default=0) | 357 | parser.add_argument('--verbose', '-v', dest='log_level', action='append_const', const=-1) |
358 | parser.add_argument('--quiet', '-q', dest='log_level', action='append_const', const=1) | ||
171 | parser.add_argument('--target', metavar='REPO', default='yggdrasil.borgbase:repo') | 359 | parser.add_argument('--target', metavar='REPO', default='yggdrasil.borgbase:repo') |
172 | parser.add_argument('--archive-prefix', metavar='REPO', default='yggdrasil.vidhar.') | 360 | parser.add_argument('--archive-prefix', metavar='REPO', default='yggdrasil.vidhar.') |
173 | subparsers = parser.add_subparsers() | 361 | subparsers = parser.add_subparsers() |
@@ -183,12 +371,13 @@ def main(): | |||
183 | create_parser.set_defaults(cmd=create) | 371 | create_parser.set_defaults(cmd=create) |
184 | args = parser.parse_args() | 372 | args = parser.parse_args() |
185 | 373 | ||
186 | if args.verbose <= 0: | 374 | LOG_LEVELS = [logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR, logging.CRITICAL] |
187 | logger.setLevel(logging.WARNING) | 375 | DEFAULT_LOG_LEVEL = logging.ERROR |
188 | elif args.verbose <= 1: | 376 | log_level = LOG_LEVELS.index(DEFAULT_LOG_LEVEL) |
189 | logger.setLevel(logging.INFO) | 377 | |
190 | else: | 378 | for adjustment in args.log_level or (): |
191 | logger.setLevel(logging.DEBUG) | 379 | log_level = min(len(LOG_LEVELS) - 1, max(log_level + adjustment, 0)) |
380 | logger.setLevel(LOG_LEVELS[log_level]) | ||
192 | 381 | ||
193 | cmdArgs = {} | 382 | cmdArgs = {} |
194 | for copy in {'target', 'archive_prefix', 'snapshot', 'cache_file', 'dry_run'}: | 383 | for copy in {'target', 'archive_prefix', 'snapshot', 'cache_file', 'dry_run'}: |