summaryrefslogtreecommitdiff
path: root/hosts/vidhar/borg/borgsnap
diff options
context:
space:
mode:
Diffstat (limited to 'hosts/vidhar/borg/borgsnap')
-rw-r--r--hosts/vidhar/borg/borgsnap/borgsnap/__main__.py202
-rw-r--r--hosts/vidhar/borg/borgsnap/setup.py10
2 files changed, 212 insertions, 0 deletions
diff --git a/hosts/vidhar/borg/borgsnap/borgsnap/__main__.py b/hosts/vidhar/borg/borgsnap/borgsnap/__main__.py
new file mode 100644
index 00000000..e93e6a60
--- /dev/null
+++ b/hosts/vidhar/borg/borgsnap/borgsnap/__main__.py
@@ -0,0 +1,202 @@
1import argparse
2import os, sys, signal
3from pyprctl import cap_permitted, cap_inheritable, cap_effective, cap_ambient, Cap
4from pwd import getpwnam
5
6from datetime import datetime, timezone
7from dateutil.parser import isoparse
8
9from xdg import xdg_runtime_dir
10import unshare
11from tempfile import TemporaryDirectory
12
13import logging
14
15import json
16import subprocess
17
18import pathlib
19from pathlib import Path
20
21from atomicwrites import atomic_write
22
23from traceback import format_exc
24
25
26borg_pwd = getpwnam('borg')
27
28def as_borg(caps=set(), cwd=None):
29 if caps:
30 cap_permitted.add(*caps)
31 cap_inheritable.add(*caps)
32 cap_effective.add(*caps)
33 cap_ambient.add(*caps)
34
35 os.setgid(borg_pwd.pw_gid)
36 os.setuid(borg_pwd.pw_uid)
37
38 if cwd is not None:
39 os.chdir(cwd)
40
41
42def _archive_name(snapshot, target, archive_prefix):
43 _, _, ts = snapshot.rpartition('@')
44 creation_time = isoparse(ts).astimezone(timezone.utc)
45 archive_name = _archive_basename(snapshot, archive_prefix)
46 return f'{target}::{archive_name}-{creation_time.strftime("%Y-%m-%dT%H:%M:%S")}'
47
48def _archive_basename(snapshot, archive_prefix):
49 base_name, _, _ = snapshot.rpartition('@')
50 return archive_prefix + base_name.replace('-', '--').replace('/', '-')
51
52def check(*, snapshot, target, archive_prefix, cache_file):
53 archives = None
54 if cache_file:
55 logger.debug('Trying cache...')
56 try:
57 with open(cache_file, mode='r', encoding='utf-8') as fp:
58 archives = set(json.load(fp))
59 logger.info('Loaded archive list from cache')
60 except FileNotFoundError:
61 pass
62
63 if not archives:
64 logger.info('Loading archive list from remote...')
65 with subprocess.Popen(['borg', 'list', '--info', '--lock-wait=600', '--json', target], stdout=subprocess.PIPE, preexec_fn=lambda: as_borg()) as proc:
66 archives = set([archive['barchive'] for archive in json.load(proc.stdout)['archives']])
67 if cache_file:
68 logger.debug('Saving archive list to cache...')
69 with atomic_write(cache_file, mode='w', encoding='utf-8', overwrite=True) as fp:
70 json.dump(list(archives), fp)
71
72 # logger.debug(f'archives: {archives}')
73 _, _, archive_name = _archive_name(snapshot, target, archive_prefix).partition('::')
74 if archive_name in archives:
75 logger.info(f'{archive_name} found')
76 return 0
77 else:
78 logger.info(f'{archive_name} not found')
79 return 126
80
81def create(*, snapshot, target, archive_prefix, dry_run):
82 basename = _archive_basename(snapshot, archive_prefix)
83
84 with TemporaryDirectory(prefix=f'borg-mount_{basename}_', dir=os.environ.get('RUNTIME_DIRECTORY')) as tmpdir:
85 child = os.fork()
86 if child == 0:
87 unshare.unshare(unshare.CLONE_NEWNS)
88 subprocess.run(['mount', '--make-rprivate', '/'], check=True)
89 chroot = pathlib.Path(tmpdir) / 'chroot'
90 upper = pathlib.Path(tmpdir) / 'upper'
91 work = pathlib.Path(tmpdir) / 'work'
92 for path in [chroot,upper,work]:
93 path.mkdir()
94 subprocess.run(['mount', '-t', 'overlay', 'overlay', '-o', f'lowerdir=/,upperdir={upper},workdir={work}', chroot], check=True)
95 bindMounts = ['nix', 'run', 'run/secrets.d', 'run/wrappers', 'proc', 'dev', 'sys', pathlib.Path(os.path.expanduser('~')).relative_to('/')]
96 if os.environ.get('BORG_BASE_DIR'):
97 bindMounts.append(pathlib.Path(os.environ['BORG_BASE_DIR']).relative_to('/'))
98 if 'SSH_AUTH_SOCK' in os.environ:
99 bindMounts.append(pathlib.Path(os.environ['SSH_AUTH_SOCK']).parent.relative_to('/'))
100 for bindMount in bindMounts:
101 (chroot / bindMount).mkdir(parents=True,exist_ok=True)
102 # print(*['mount', '--bind', pathlib.Path('/') / bindMount, chroot / bindMount], file=stderr)
103 subprocess.run(['mount', '--bind', pathlib.Path('/') / bindMount, chroot / bindMount], check=True)
104 os.chroot(chroot)
105 os.chdir('/')
106 dir = pathlib.Path('/borg')
107 dir.mkdir(parents=True,exist_ok=True,mode=0o0750)
108 os.chown(dir, borg_pwd.pw_uid, borg_pwd.pw_gid)
109 try:
110 subprocess.run(['mount', '-t', 'zfs', '-o', 'ro', snapshot, dir], check=True)
111 env = os.environ.copy()
112 create_args = ['borg',
113 'create',
114 '--lock-wait=600',
115 '--one-file-system',
116 '--compression=auto,zstd,10',
117 '--chunker-params=10,23,16,4095',
118 '--files-cache=ctime,size',
119 '--show-rc',
120 # '--remote-ratelimit=20480',
121 '--progress',
122 '--list',
123 '--filter=AMEi-x?',
124 '--stats' if not dry_run else '--dry-run'
125 ]
126 _, _, ts = snapshot.rpartition('@')
127 creation_time = isoparse(ts).astimezone(timezone.utc)
128 create_args += [f'--timestamp={creation_time.strftime("%Y-%m-%dT%H:%M:%S")}']
129 env['BORG_FILES_CACHE_SUFFIX'] = basename
130 create_args += [_archive_name(snapshot, target, archive_prefix), '.']
131 print({'create_args': create_args, 'cwd': dir, 'env': env}, file=sys.stderr)
132 subprocess.run(create_args, stdin=subprocess.DEVNULL, env=env, preexec_fn=lambda: as_borg(caps={CAP.DAC_READ_SEARCH}, cwd=dir), check=True)
133 # subprocess.run(create_args, stdin=subprocess.DEVNULL, env=env, preexec_fn=lambda: None, cwd=dir, check=True)
134 finally:
135 subprocess.run(['umount', dir], check=True)
136 os._exit(0)
137 else:
138 while True:
139 waitpid, waitret = os.wait()
140 if waitret != 0:
141 sys.exit(waitret)
142 if waitpid == child:
143 break
144 return 0
145
146def sigterm(signum, frame):
147 raise SystemExit(128 + signum)
148
149def main():
150 signal.signal(signal.SIGTERM, sigterm)
151
152 global logger
153 logger = logging.getLogger(__name__)
154 console_handler = logging.StreamHandler()
155 console_handler.setFormatter( logging.Formatter('[%(levelname)s](%(name)s): %(message)s') )
156 if sys.stderr.isatty():
157 console_handler.setFormatter( logging.Formatter('%(asctime)s [%(levelname)s](%(name)s): %(message)s') )
158 logger.addHandler(console_handler)
159
160 # log uncaught exceptions
161 def log_exceptions(type, value, tb):
162 global logger
163
164 logger.error(value)
165 sys.__excepthook__(type, value, tb) # calls default excepthook
166
167 sys.excepthook = log_exceptions
168
169 parser = argparse.ArgumentParser(prog='borgsnap')
170 parser.add_argument('--verbose', '-v', action='count', default=0)
171 parser.add_argument('--target', metavar='REPO', default='yggdrasil.borgbase:repo')
172 parser.add_argument('--archive-prefix', metavar='REPO', default='yggdrasil.vidhar.')
173 subparsers = parser.add_subparsers()
174 subparsers.required = True
175 parser.set_defaults(cmd=None)
176 check_parser = subparsers.add_parser('check')
177 check_parser.add_argument('--cache-file', type=lambda p: Path(p).absolute(), default=None)
178 check_parser.add_argument('snapshot')
179 check_parser.set_defaults(cmd=check)
180 create_parser = subparsers.add_parser('create')
181 create_parser.add_argument('--dry-run', '-n', action='store_true', default=False)
182 create_parser.add_argument('snapshot')
183 create_parser.set_defaults(cmd=create)
184 args = parser.parse_args()
185
186 if args.verbose <= 0:
187 logger.setLevel(logging.WARNING)
188 elif args.verbose <= 1:
189 logger.setLevel(logging.INFO)
190 else:
191 logger.setLevel(logging.DEBUG)
192
193 cmdArgs = {}
194 for copy in {'target', 'archive_prefix', 'snapshot', 'cache_file', 'dry_run'}:
195 if copy in vars(args):
196 cmdArgs[copy] = vars(args)[copy]
197
198 return args.cmd(**cmdArgs)
199
200
201if __name__ == '__main__':
202 sys.exit(main())
diff --git a/hosts/vidhar/borg/borgsnap/setup.py b/hosts/vidhar/borg/borgsnap/setup.py
new file mode 100644
index 00000000..76356bfc
--- /dev/null
+++ b/hosts/vidhar/borg/borgsnap/setup.py
@@ -0,0 +1,10 @@
1from setuptools import setup
2
3setup(name='borgsnap',
4 packages=['borgsnap'],
5 entry_points={
6 'console_scripts': [
7 'borgsnap=borgsnap.__main__:main',
8 ],
9 }
10)