diff --git a/src/locutus/main.py b/src/locutus/main.py index 3619ec7..f340bc4 100644 --- a/src/locutus/main.py +++ b/src/locutus/main.py @@ -64,6 +64,15 @@ def parse_args() -> tuple[argparse.Namespace, argparse.ArgumentParser]: umount_parser = subparsers.add_parser('umount', help='Unmount a mountpoint') umount_parser.add_argument('mountpoint', help='Mountpoint to unmount') + # restore + restore_parser = subparsers.add_parser( + 'restore', help='Restore files from a backup archive' + ) + restore_parser.add_argument( + 'archive', help='Archive index (int) or name (str)' + ) + restore_parser.add_argument('targetdir', help='Directory to restore into') + return parser.parse_args(), parser @@ -91,6 +100,10 @@ def main() -> int: from .umount import run_umount return run_umount(args) + case 'restore': + from .restore import run_restore + + return run_restore(args) case _: parser.print_help() return 1 diff --git a/src/locutus/restore.py b/src/locutus/restore.py new file mode 100644 index 0000000..9505f06 --- /dev/null +++ b/src/locutus/restore.py @@ -0,0 +1,81 @@ +import argparse +import os +import subprocess +import sys + +from locutus.config import LocutusConfig + + +def run_restore(args: argparse.Namespace) -> int: + """Restore (extract) a backup archive into the specified directory. + + Args: + args: argparse.Namespace with --config, --profile, 'archive' (index or + name), 'targetdir' (directory). + + Returns: + 0 on success, 1 on error + """ + if not hasattr(args, 'archive') or not hasattr(args, 'targetdir'): + print( + 'Usage: locutus restore [index|name] ', file=sys.stderr + ) + return 1 + + cfg = LocutusConfig(args.config, args.profile) + repo = cfg.get_repo() + if not repo: + print('No BORG_REPO configured.', file=sys.stderr) + return 1 + env = {**os.environ, **cfg.env} + + # Determine archive name (by index or direct) + target = args.archive + archive_name = None + try: + index = int(target) + cmd = ['borg', 'list', '--short', repo] + result = subprocess.run( + cmd, env=env, capture_output=True, text=True, check=True + ) + archives = result.stdout.strip().splitlines()[::-1] + if not archives: + print('No archives found.', file=sys.stderr) + return 1 + if not (0 <= index < len(archives)): + print( + f'Archive index out of range (0..{len(archives) - 1}).', + file=sys.stderr, + ) + return 1 + archive_name = archives[index] + except ValueError: + archive_name = target + except Exception as e: + print( + f'borg restore failed during archive lookup: {e}', file=sys.stderr + ) + return 1 + + # Check target dir + targetdir = args.targetdir + if not os.path.isdir(targetdir): + print( + f"Target directory '{targetdir}' does not exist or is not a " + 'directory.', + file=sys.stderr, + ) + return 1 + + # Run borg extract + cmd = ['borg', 'extract', f'{repo}::{archive_name}'] + try: + subprocess.run(cmd, env=env, check=True, cwd=targetdir) + print(f"Archive '{archive_name}' restored to {targetdir}") + return 0 + except subprocess.CalledProcessError as e: + print(f'borg extract failed: {e}', file=sys.stderr) + return 1 + except Exception as e: + print(f'borg extract failed (unexpected): {e}', file=sys.stderr) + return 1 diff --git a/test/test_restore.py b/test/test_restore.py new file mode 100644 index 0000000..b14ea1d --- /dev/null +++ b/test/test_restore.py @@ -0,0 +1,176 @@ +import argparse +from unittest import mock +import subprocess +import pytest + +from locutus.restore import run_restore + + +def make_args(config, profile, archive, targetdir): + args = argparse.Namespace() + args.config = config + args.profile = profile + args.archive = archive + args.targetdir = targetdir + return args + + +@mock.patch('locutus.restore.LocutusConfig') +@mock.patch('os.path.isdir', return_value=True) +@mock.patch('subprocess.run') +def test_restore_by_index_success( + mock_run, mock_isdir, mock_config, tmp_path, capsys +): + mock_config.return_value.get_repo.return_value = '/repo' + mock_config.return_value.env = {} + archives = ['archA', 'archB', 'archC'] + mock_run.side_effect = [ + mock.Mock(stdout='\n'.join(archives) + '\n', returncode=0), # borg list + mock.Mock(returncode=0), # borg extract + ] + + args = make_args('dummy.toml', 'dummy.rc', '1', str(tmp_path)) + rc = run_restore(args) + assert rc == 0 + out = capsys.readouterr().out + assert 'restored to' in out + assert 'archB' in out + extract_call = mock_run.call_args_list[1][0][0] + assert extract_call == ['borg', 'extract', '/repo::archB'] + + +@mock.patch('locutus.restore.LocutusConfig') +@mock.patch('os.path.isdir', return_value=True) +@mock.patch('subprocess.run') +def test_restore_by_name_success( + mock_run, mock_isdir, mock_config, tmp_path, capsys +): + mock_config.return_value.get_repo.return_value = '/repo' + mock_config.return_value.env = {} + mock_run.return_value.returncode = 0 + + args = make_args('dummy.toml', 'dummy.rc', 'named-archive', str(tmp_path)) + rc = run_restore(args) + assert rc == 0 + out = capsys.readouterr().out + assert 'restored to' in out + assert 'named-archive' in out + call_args = mock_run.call_args[0][0] + assert call_args == ['borg', 'extract', '/repo::named-archive'] + + +@mock.patch('locutus.restore.LocutusConfig') +@mock.patch('os.path.isdir', return_value=True) +@mock.patch('subprocess.run') +def test_restore_by_index_out_of_range( + mock_run, mock_isdir, mock_config, tmp_path, capsys +): + mock_config.return_value.get_repo.return_value = '/repo' + mock_config.return_value.env = {} + archives = ['a', 'b'] + mock_run.return_value.stdout = '\n'.join(archives) + '\n' + mock_run.return_value.returncode = 0 + + args = make_args('dummy.toml', 'dummy.rc', '5', str(tmp_path)) + rc = run_restore(args) + assert rc == 1 + err = capsys.readouterr().err + assert 'Archive index out of range' in err + + +@mock.patch('locutus.restore.LocutusConfig') +@mock.patch('os.path.isdir', return_value=True) +@mock.patch('subprocess.run') +def test_restore_by_index_no_archives( + mock_run, mock_isdir, mock_config, tmp_path, capsys +): + mock_config.return_value.get_repo.return_value = '/repo' + mock_config.return_value.env = {} + mock_run.return_value.stdout = '' + mock_run.return_value.returncode = 0 + + args = make_args('dummy.toml', 'dummy.rc', '0', str(tmp_path)) + rc = run_restore(args) + assert rc == 1 + err = capsys.readouterr().err + assert 'No archives found' in err + + +@mock.patch('locutus.restore.LocutusConfig') +@mock.patch('os.path.isdir', return_value=False) +def test_restore_invalid_targetdir(mock_isdir, mock_config, capsys): + mock_config.return_value.get_repo.return_value = '/repo' + mock_config.return_value.env = {} + + args = make_args('dummy.toml', 'dummy.rc', 'archive', '/notadir') + rc = run_restore(args) + assert rc == 1 + err = capsys.readouterr().err + assert 'does not exist or is not a directory' in err + + +@mock.patch('locutus.restore.LocutusConfig') +def test_restore_no_repo(mock_config, tmp_path, capsys): + mock_config.return_value.get_repo.return_value = None + mock_config.return_value.env = {} + args = make_args('dummy.toml', 'dummy.rc', '0', str(tmp_path)) + rc = run_restore(args) + assert rc == 1 + err = capsys.readouterr().err + assert 'No BORG_REPO configured' in err + + +@mock.patch('locutus.restore.LocutusConfig') +@mock.patch('os.path.isdir', return_value=True) +@mock.patch( + 'subprocess.run', + side_effect=subprocess.CalledProcessError(1, ['borg', 'extract']), +) +def test_restore_extract_calledprocesserror( + mock_run, mock_isdir, mock_config, tmp_path, capsys +): + mock_config.return_value.get_repo.return_value = '/repo' + mock_config.return_value.env = {} + + args = make_args('dummy.toml', 'dummy.rc', 'named-archive', str(tmp_path)) + rc = run_restore(args) + assert rc == 1 + err = capsys.readouterr().err + assert 'borg extract failed' in err + + +@mock.patch('locutus.restore.LocutusConfig') +@mock.patch('os.path.isdir', return_value=True) +@mock.patch('subprocess.run', side_effect=Exception('fail!')) +def test_restore_extract_generic_exception( + mock_run, mock_isdir, mock_config, tmp_path, capsys +): + mock_config.return_value.get_repo.return_value = '/repo' + mock_config.return_value.env = {} + + args = make_args('dummy.toml', 'dummy.rc', 'named-archive', str(tmp_path)) + rc = run_restore(args) + assert rc == 1 + err = capsys.readouterr().err + assert 'borg extract failed (unexpected): fail!' in err + + +def test_restore_usage_message(capsys): + args = argparse.Namespace() + rc = run_restore(args) + assert rc == 1 + err = capsys.readouterr().err + assert 'Usage:' in err + + +@mock.patch("locutus.restore.LocutusConfig") +@mock.patch("os.path.isdir", return_value=True) +@mock.patch("subprocess.run", side_effect=Exception("index explode")) +def test_restore_index_lookup_generic_exception(mock_run, mock_isdir, mock_config, tmp_path, capsys): + mock_config.return_value.get_repo.return_value = "/repo" + mock_config.return_value.env = {} + args = make_args("dummy.toml", "dummy.rc", "0", str(tmp_path)) + rc = run_restore(args) + assert rc == 1 + err = capsys.readouterr().err + assert "borg restore failed during archive lookup: index explode" in err