diff options
Diffstat (limited to 'hosts/vidhar/borg/borgsnap')
| -rw-r--r-- | hosts/vidhar/borg/borgsnap/borgsnap/__main__.py | 202 | ||||
| -rw-r--r-- | hosts/vidhar/borg/borgsnap/setup.py | 10 |
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 @@ | |||
| 1 | import argparse | ||
| 2 | import os, sys, signal | ||
| 3 | from pyprctl import cap_permitted, cap_inheritable, cap_effective, cap_ambient, Cap | ||
| 4 | from pwd import getpwnam | ||
| 5 | |||
| 6 | from datetime import datetime, timezone | ||
| 7 | from dateutil.parser import isoparse | ||
| 8 | |||
| 9 | from xdg import xdg_runtime_dir | ||
| 10 | import unshare | ||
| 11 | from tempfile import TemporaryDirectory | ||
| 12 | |||
| 13 | import logging | ||
| 14 | |||
| 15 | import json | ||
| 16 | import subprocess | ||
| 17 | |||
| 18 | import pathlib | ||
| 19 | from pathlib import Path | ||
| 20 | |||
| 21 | from atomicwrites import atomic_write | ||
| 22 | |||
| 23 | from traceback import format_exc | ||
| 24 | |||
| 25 | |||
| 26 | borg_pwd = getpwnam('borg') | ||
| 27 | |||
| 28 | def 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 | |||
| 42 | def _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 | |||
| 48 | def _archive_basename(snapshot, archive_prefix): | ||
| 49 | base_name, _, _ = snapshot.rpartition('@') | ||
| 50 | return archive_prefix + base_name.replace('-', '--').replace('/', '-') | ||
| 51 | |||
| 52 | def 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 | |||
| 81 | def 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 | |||
| 146 | def sigterm(signum, frame): | ||
| 147 | raise SystemExit(128 + signum) | ||
| 148 | |||
| 149 | def 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 | |||
| 201 | if __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 @@ | |||
| 1 | from setuptools import setup | ||
| 2 | |||
| 3 | setup(name='borgsnap', | ||
| 4 | packages=['borgsnap'], | ||
| 5 | entry_points={ | ||
| 6 | 'console_scripts': [ | ||
| 7 | 'borgsnap=borgsnap.__main__:main', | ||
| 8 | ], | ||
| 9 | } | ||
| 10 | ) | ||
