diff --git a/hydra/system_recovery.py b/hydra/system_recovery.py index 6fc6c1c..d002dcd 100644 --- a/hydra/system_recovery.py +++ b/hydra/system_recovery.py @@ -150,8 +150,12 @@ def sync_tree(root): class Btrfs: def run(self, argv): - return subprocess.run(argv, check=True, text=True, stdout=subprocess.PIPE, - stderr=subprocess.PIPE, env=dict(os.environ, LC_ALL='C')).stdout + try: + return subprocess.run(argv, check=True, text=True, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, env=dict(os.environ, LC_ALL='C')).stdout + except subprocess.CalledProcessError as exc: + detail = (exc.stderr or '').strip()[:2000] + raise RecoveryError(f'{argv[0]} failed ({exc.returncode}): {detail}') from exc def info(self, path): output = self.run(['btrfs', 'subvolume', 'show', str(path)]) fields = dict(line.strip().split(':', 1) for line in output.splitlines() if ':' in line) @@ -476,7 +480,7 @@ def mounted(backend, device, filesystem, writable=False): options = ['rw' if writable else 'ro', 'nosuid', 'nodev', 'noexec'] if filesystem == 'btrfs': options.append('subvolid=5') - if not writable: options.append('nologreplay') + if not writable: options.append('rescue=nologreplay') backend.run(['mount', '-t', filesystem, '-o', ','.join(options), device, str(directory)]) did_mount = True if filesystem == 'btrfs': diff --git a/tests/test_system_recovery.py b/tests/test_system_recovery.py index 950761b..b512ee6 100644 --- a/tests/test_system_recovery.py +++ b/tests/test_system_recovery.py @@ -252,6 +252,14 @@ s.restore(sys.argv[3]) self.assertEqual((self.top / '@root/kernel').read_text(), 'old kernel') class NativeBoundaryTests(unittest.TestCase): + def test_failed_native_command_includes_bounded_stderr(self): + backend = r.Btrfs() + error = subprocess.CalledProcessError(32, ['mount'], stderr='specific mount failure\n' + 'x' * 5000) + with patch.object(r.subprocess, 'run', side_effect=error): + with self.assertRaisesRegex(r.RecoveryError, 'specific mount failure') as caught: + backend.run(['mount', '-t', 'btrfs', '/dev/example', '/run/example']) + self.assertLess(len(str(caught.exception)), 2300) + def test_btrfs_show_parser_and_readonly_property(self): backend = r.Btrfs() sample = '''@snapshots/example/root @@ -292,7 +300,7 @@ class NativeBoundaryTests(unittest.TestCase): with self.assertRaises(r.RecoveryError): with r.mounted(backend, '/dev/fake', 'btrfs'): pass self.assertEqual([argv[0] for argv in calls], ['mount', 'btrfs', 'umount']) - self.assertIn('ro,nosuid,nodev,noexec,subvolid=5,nologreplay', calls[0]) + self.assertIn('ro,nosuid,nodev,noexec,subvolid=5,rescue=nologreplay', calls[0]) def test_cli_requires_matching_confirmation_before_probe(self): with patch.object(r, 'DeviceProbe') as probe, patch.object(sys, 'stderr', new_callable=io.StringIO): self.assertEqual(r.main(['restore', '--root-uuid', ROOT_UUID, '--checkpoint', ROOT_PART, '--execute']), 1)