Add restore command

This commit is contained in:
Alexander Wainwright
2025-06-24 14:33:20 +10:00
parent 8a7b05f48e
commit bff6be4560
3 changed files with 270 additions and 0 deletions

View File

@@ -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

81
src/locutus/restore.py Normal file
View File

@@ -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] <targetdir>', 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

176
test/test_restore.py Normal file
View File

@@ -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