mirror of https://github.com/digint/btrbk
btrbk_restore_raw.py: add script for restoring raw backups
parent
322ae2c78f
commit
82860f0f4c
|
@ -0,0 +1,217 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
import os
|
||||
import logging
|
||||
import subprocess
|
||||
import argparse
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TransformProcess:
|
||||
def run(self, bfile, options, **kw):
|
||||
return subprocess.Popen(self.get_cmd(bfile, options), **kw)
|
||||
|
||||
def get_cmd(self, bfile, options):
|
||||
raise NotImplementedError()
|
||||
|
||||
@classmethod
|
||||
def add_parser_options(cls, parser):
|
||||
pass
|
||||
|
||||
|
||||
class TransformOpensslDecrypt(TransformProcess):
|
||||
@staticmethod
|
||||
def get_cmd(bfile, options):
|
||||
return [
|
||||
'openssl', 'enc', '-d', '-' + bfile.info['cipher'], '-K',
|
||||
open(options.openssl_keyfile, 'r').read(), '-iv', bfile.info['iv']
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def add_parser_options(parser):
|
||||
parser.add_argument('--openssl-keyfile', help="path to private encryption key file")
|
||||
|
||||
|
||||
class TransformDecompress(TransformProcess):
|
||||
def __init__(self, program):
|
||||
self.p = program
|
||||
|
||||
def get_cmd(self, bfile, options):
|
||||
return [self.p, '-d']
|
||||
|
||||
|
||||
class TransformBtrfsReceive(TransformProcess):
|
||||
@classmethod
|
||||
def run(cls, bfile, options, **kw):
|
||||
return subprocess.Popen(cls.get_cmd(bfile, options), **kw)
|
||||
|
||||
@staticmethod
|
||||
def get_cmd(bfile, options):
|
||||
return ['btrfs', 'receive', options.restore_dir]
|
||||
|
||||
|
||||
TRANSFORMERS = (
|
||||
TransformOpensslDecrypt, TransformDecompress, TransformBtrfsReceive
|
||||
)
|
||||
|
||||
|
||||
class BtrfsPipeline:
|
||||
def __init__(self, bfile):
|
||||
self.bfile = bfile
|
||||
self.processors = []
|
||||
|
||||
def append(self, transformer):
|
||||
self.processors.append(transformer)
|
||||
|
||||
def run(self, options):
|
||||
processes = []
|
||||
with open(self.bfile.data_file, 'rb') as next_input:
|
||||
for transformer in self.processors:
|
||||
process = transformer.run(
|
||||
self.bfile, options,
|
||||
stdin=next_input, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE)
|
||||
next_input = process.stdout
|
||||
processes.append(process)
|
||||
btrfs_process = TransformBtrfsReceive.run(
|
||||
self.bfile, options, stdin=next_input,
|
||||
stderr=subprocess.PIPE, stdout=subprocess.DEVNULL)
|
||||
processes.append(btrfs_process)
|
||||
# warning: the code below is pretty ugly and hacky
|
||||
terminated = 0
|
||||
while terminated < len(processes):
|
||||
for p in processes:
|
||||
if p.returncode is not None:
|
||||
continue
|
||||
msg = None
|
||||
try:
|
||||
p.wait(timeout=1)
|
||||
except subprocess.TimeoutExpired as e:
|
||||
pass
|
||||
except Exception as e:
|
||||
msg = e
|
||||
else:
|
||||
msg = p.stderr.read().decode('utf-8').strip()
|
||||
finally:
|
||||
if p.returncode is not None:
|
||||
terminated += 1
|
||||
if p.returncode != 0:
|
||||
for p_other in processes:
|
||||
p_other.terminate()
|
||||
terminated += 1
|
||||
if msg:
|
||||
logger.error(f"error running {p.args}: {msg}")
|
||||
|
||||
def get_cmd(self, options):
|
||||
command_pipe = [['cat', self.bfile.data_file]]
|
||||
for transformer in self.processors:
|
||||
command_pipe.append(transformer.get_cmd(self.bfile, options))
|
||||
command_pipe.append(TransformBtrfsReceive.get_cmd(self.bfile, options))
|
||||
return ' | '.join(' '.join(x) for x in command_pipe)
|
||||
|
||||
|
||||
class BackupFile:
|
||||
def __init__(self, path):
|
||||
assert path.endswith('.info')
|
||||
self.info_file = path
|
||||
self.info = self._parse_info()
|
||||
self.uuid = self.info['RECEIVED_UUID']
|
||||
self.data_file = os.path.join(os.path.dirname(path), self.info['FILE'])
|
||||
self.parent = self.info.get('RECEIVED_PARENT_UUID')
|
||||
self.is_restored = False
|
||||
|
||||
def _parse_info(self):
|
||||
config = {}
|
||||
with open(self.info_file, 'r') as fh:
|
||||
# skip command option line
|
||||
for line in fh.readlines():
|
||||
if '=' not in line:
|
||||
continue
|
||||
key, val = line.strip().split('=', maxsplit=1)
|
||||
config[key] = val
|
||||
return config
|
||||
|
||||
def get_transformers(self):
|
||||
if 'encrypt' in self.info:
|
||||
if self.info['encrypt'] == 'gpg':
|
||||
raise NotImplementedError('gpg encryption')
|
||||
elif self.info['encrypt'] == 'openssl_enc':
|
||||
yield TransformOpensslDecrypt()
|
||||
else:
|
||||
raise Exception(f'unknown encryption type: "{self.info["encrypt"]}"')
|
||||
if 'compress' in self.info:
|
||||
yield TransformDecompress(self.info['compress'])
|
||||
|
||||
def restore_file(self, options):
|
||||
assert self.info.get('TYPE') == 'raw'
|
||||
assert not self.info.get('INCOMPLETE')
|
||||
pipeline = BtrfsPipeline(self)
|
||||
for transformer in self.get_transformers():
|
||||
pipeline.append(transformer)
|
||||
if options.dry_run:
|
||||
print(pipeline.get_cmd(options))
|
||||
else:
|
||||
logger.info(f"restoring backup {os.path.basename(self.data_file)}")
|
||||
pipeline.run(options)
|
||||
self.is_restored = True
|
||||
|
||||
|
||||
def restore_from_path(backup, options):
|
||||
path = os.path.dirname(backup)
|
||||
info_files = {}
|
||||
backup_file = BackupFile(backup + '.info')
|
||||
restored_files = set()
|
||||
for entry in os.scandir(path):
|
||||
if entry.is_file() and entry.name.endswith('.info'):
|
||||
info = BackupFile(entry.path)
|
||||
info_files[info.uuid] = info
|
||||
restored_files.update(restore_backup(backup_file, info_files, options))
|
||||
logger.info(f"finished; restored {len(restored_files)} backup files")
|
||||
|
||||
|
||||
def restore_backup(bfile, parents, options):
|
||||
if bfile.is_restored:
|
||||
return
|
||||
if bfile.parent:
|
||||
parent = parents.get(bfile.parent)
|
||||
if not parent:
|
||||
msg = (f"missing parent {bfile.parent} for"
|
||||
f"'{os.path.basename(bfile.info_file)}'")
|
||||
if options.ignore_missing:
|
||||
logger.warning(msg)
|
||||
else:
|
||||
raise Exception(msg)
|
||||
else:
|
||||
yield from restore_backup(parent, parents, options)
|
||||
bfile.restore_file(options)
|
||||
yield bfile.uuid
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="restore btrbk raw backup")
|
||||
parser.add_argument('backup', help="backup file to restore; for incremental"
|
||||
" backups the parent files must be in the same directory")
|
||||
parser.add_argument('restore_dir', help="target directory for restored subvolumes"
|
||||
" (path argument for \"btrfs receive\")")
|
||||
parser.add_argument('-n', '--dry-run', action='store_true',
|
||||
help="print commmands that would be executed")
|
||||
parser.add_argument('--ignore-missing', action='store_true',
|
||||
help="do not fail on missing parent snapshots")
|
||||
|
||||
for transformer in TRANSFORMERS:
|
||||
transformer.add_parser_options(parser)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.dry_run:
|
||||
logger.setLevel('ERROR')
|
||||
|
||||
restore_from_path(args.backup, args)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
logger.setLevel('INFO')
|
||||
logging.basicConfig(format='%(asctime)s %(levelname)s - %(message)s')
|
||||
main()
|
Loading…
Reference in New Issue