summaryrefslogtreecommitdiff
path: root/hosts/vidhar/borg
diff options
context:
space:
mode:
Diffstat (limited to 'hosts/vidhar/borg')
-rw-r--r--hosts/vidhar/borg/borgsnap/borgsnap/__main__.py355
-rw-r--r--hosts/vidhar/borg/default.nix6
2 files changed, 274 insertions, 87 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 @@
1import argparse 1import argparse
2import os, sys, signal 2import os, sys, signal, io
3from pyprctl import cap_permitted, cap_inheritable, cap_effective, cap_ambient, Cap 3from pyprctl import CapState, Cap, cap_ambient_raise, cap_ambient_is_set, set_keepcaps
4from pwd import getpwnam 4from pwd import getpwnam
5 5
6from datetime import datetime, timezone 6from datetime import datetime, timezone
7from dateutil.parser import isoparse 7from dateutil.parser import isoparse
8 8
9from xdg import xdg_runtime_dir
10import unshare 9import unshare
11from tempfile import TemporaryDirectory 10from tempfile import TemporaryDirectory
12 11
@@ -14,6 +13,9 @@ import logging
14 13
15import json 14import json
16import subprocess 15import subprocess
16import csv
17from collections import namedtuple
18from distutils.util import strtobool
17 19
18import pathlib 20import pathlib
19from pathlib import Path 21from pathlib import Path
@@ -22,21 +24,89 @@ from atomicwrites import atomic_write
22 24
23from traceback import format_exc 25from traceback import format_exc
24 26
27from multiprocessing import Process, Manager
28from contextlib import closing
29
30from enum import Enum, auto
31
32import select
33import time
34import math
35
36
37PROP_DO_BORGSNAP = 'li.yggdrasil:borgsnap'
38
39
40class 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
26borg_pwd = getpwnam('borg') 75borg_pwd = getpwnam('borg')
27 76
28def as_borg(caps=set(), cwd=None): 77def 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
42def _archive_name(snapshot, target, archive_prefix): 112def _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
52def check(*, snapshot, target, archive_prefix, cache_file): 122def 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
81def create(*, snapshot, target, archive_prefix, dry_run): 174def 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
146def sigterm(signum, frame): 307def 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'}:
diff --git a/hosts/vidhar/borg/default.nix b/hosts/vidhar/borg/default.nix
index 79c75c4d..7e3129f2 100644
--- a/hosts/vidhar/borg/default.nix
+++ b/hosts/vidhar/borg/default.nix
@@ -74,7 +74,7 @@ let
74 copy 74 copy
75 75
76 wrapProgram $out/bin/copy \ 76 wrapProgram $out/bin/copy \
77 --prefix PATH : ${makeBinPath (with pkgs; [util-linux borgbackup])}:${config.security.wrapperDir} 77 --prefix PATH : ${makeBinPath (with pkgs; [config.boot.zfs.package util-linux borgbackup])}:${config.security.wrapperDir}
78 ''; 78 '';
79 }); 79 });
80 80
@@ -88,7 +88,6 @@ let
88 atomicwrites 88 atomicwrites
89 pyprctl 89 pyprctl
90 python-unshare 90 python-unshare
91 xdg
92 python-dateutil 91 python-dateutil
93 ''; 92 '';
94 postInstall = '' 93 postInstall = ''
@@ -101,14 +100,13 @@ let
101 (self: super: { python-unshare = super.python-unshare.overrideAttrs (oldAttrs: { name = "python-unshare-0.2.1"; version = "0.2.1"; }); }) 100 (self: super: { python-unshare = super.python-unshare.overrideAttrs (oldAttrs: { name = "python-unshare-0.2.1"; version = "0.2.1"; }); })
102 ]; 101 ];
103 102
104 _.xdg.buildInputs.add = with pkgs."python3Packages"; [ poetry ];
105 _.tomli.buildInputs.add = with pkgs."python3Packages"; [ flit-core ]; 103 _.tomli.buildInputs.add = with pkgs."python3Packages"; [ flit-core ];
106 }; 104 };
107in { 105in {
108 config = { 106 config = {
109 services.zfssnap.config.exec = { 107 services.zfssnap.config.exec = {
110 check = "${borgsnap}/bin/borgsnap -vvv --target yggdrasil.borgbase:repo --archive-prefix yggdrasil.vidhar. check --cache-file /run/zfssnap-prune/archives-cache.json"; 108 check = "${borgsnap}/bin/borgsnap -vvv --target yggdrasil.borgbase:repo --archive-prefix yggdrasil.vidhar. check --cache-file /run/zfssnap-prune/archives-cache.json";
111 cmd = "${borgsnap}/bin/borgsnap -vvv --target yggdrasil.borgbase:repo --archive-prefix yggdrasil.vidhar. create --dry-run"; 109 cmd = "${borgsnap}/bin/borgsnap -vvv --target yggdrasil.borgbase:repo --archive-prefix yggdrasil.vidhar. create";
112 110
113 halfweekly = "8"; 111 halfweekly = "8";
114 monthly = "-1"; 112 monthly = "-1";