fix(storage): strengthen system-disk protection before formatting

This commit is contained in:
martino
2026-09-07 11:54:28 +02:00
parent b4e34d0902
commit fa4e8fd087
2 changed files with 374 additions and 28 deletions
+283
View File
@@ -0,0 +1,283 @@
"""Fixture-only safety tests: extract real functions, never source/run the script.
Only text utilities are on PATH; discovery is stubbed, mutations are unavailable.
Run: python3 -m unittest discover -s tests -p test_format_disk_system_disks.py -v
"""
from pathlib import Path
import re
import shlex
import shutil
import subprocess
import tempfile
import unittest
SCRIPT = Path(__file__).resolve().parents[1] / 'scripts/storage/format-disk.sh'
class SystemDisks(unittest.TestCase):
def run_case(self, root='/dev/mapper/pve-root', boot='/dev/sda1',
parents=None, candidate='/dev/sdb', fail='', block_mounts=True,
root_fs='/dev/mapper/pve-root', changed_root=False,
mount_rows=None, root_error=0, pool_rows='rpool\n\t/dev/sdb\t100\t20\t80\n',
pool_error=0, timeout_error=0, unresolved='', timeout_available=True):
parents = parents or {'/dev/mapper/pve-root': ('lvm', ['/dev/sdb1']),
'/dev/sdb1': ('part', ['/dev/sdb']),
'/dev/sdb': ('disk', []),
'/dev/sda1': ('part', ['/dev/sda']),
'/dev/sda': ('disk', [])}
text = SCRIPT.read_text()
names = ['_is_system_mount', '_get_zfs_root_pool', '_fmt_collect_cmd',
'_resolve_zfs_entry', '_build_pool_disks', '_disk_in_config_text',
'_get_running_vm_config_text', 'get_disk_info',
'build_disk_candidates', 'revalidate_selected_disk']
names += re.findall(r'^(_fmt_system_\w+)\(\) \{', text, re.M)
functions = []
for name in names:
match = re.search(r'^' + name + r'\(\) \{\n.*?^\}', text, re.M | re.S)
self.assertIsNotNone(match, name)
assert match is not None
functions.append(match[0])
q = shlex.quote
mounts = f'{root.removeprefix("/dev/").removeprefix("mapper/")} /\n{boot.removeprefix("/dev/")} /boot\n'
cases = ["'-no PKNAME /dev/pve-root') return 32 ;;", f"'-ln -o NAME,MOUNTPOINT') printf %s {q(mounts)} ;;",
f"'-lnpo PATH,MOUNTPOINT') printf '%s\\n' {q(root + ' /')} {q(boot + ' /boot')} ;;",
f"'-dn -e 7,11 -o PATH,RO,TYPE') printf '%s\\n' '{candidate} 0 disk' ;;",
"'-dn -o MODEL '* ) printf 'Fixture\\n' ;;",
"'-dn -o SIZE '* ) printf '100G\\n' ;;",
"'-ln -o NAME '* ) : ;;",
"'-ln -o NAME,MOUNTPOINT '* ) : ;;"]
def inverse(path):
kind, ps = parents[path]
return [f'{path} {kind}'] + [row for p in ps for row in inverse(p)]
for path, (kind, ps) in parents.items():
cases.append(f"{q('-snrpo PATH,TYPE ' + path)}) printf '%s\\n' " + ' '.join(q(row) for row in inverse(path)) + ' ;;')
cases.append(f"{q('-no PKNAME ' + path)}) printf '%s\\n' " + ' '.join(q(p.removeprefix('/dev/')) for p in ps) + ' ;;')
stubs = '''
translate() { printf '%s' "$*"; }
readlink() { printf '%s\\n' "${@: -1}"; }
swapon() { :; }
qm() { printf 'VMID NAME STATUS\\n'; }
pct() { printf 'VMID STATUS NAME\\n'; }
zpool() { [[ "$1" == list ]] || { printf 'MUTATION\\n' >> "$EVENTS"; return 99; }; }
umount() { printf 'MUTATION\\n' >> "$EVENTS"; return 99; }
'''
stubs += 'fixture_lsblk() {\n'
if changed_root:
stubs += '[[ "$PHASE" == menu && ( "$*" == "-lnpo PATH,MOUNTPOINT" || "$*" == "-ln -o NAME,MOUNTPOINT" ) ]] && return 0\n'
if fail == 'topology':
stubs += '[[ "$*" == *"' + root + '" ]] && return 32\n'
elif fail == 'empty-topology':
stubs += '[[ "$*" == "-snrpo PATH,TYPE ' + root + '" ]] && return 0\n'
if fail == 'mounts':
stubs += '[[ "$*" == "-lnpo PATH,MOUNTPOINT" || "$*" == "-ln -o NAME,MOUNTPOINT" ]] && return 32\n'
elif not block_mounts:
stubs += '[[ "$*" == "-lnpo PATH,MOUNTPOINT" || "$*" == "-ln -o NAME,MOUNTPOINT" ]] && return 0\n'
stubs += 'case "$*" in\n' + '\n'.join(cases) + '\n*) printf "UNEXPECTED lsblk %s\\n" "$*" >> "$EVENTS"; return 99 ;;\nesac\n}\n'
stubs += f"df() {{ printf '%s\\n' 'Filesystem blocks used available percent mounted' '{root_fs} 100 20 80 20% /'; }}\n"
if root_fs.startswith('rpool/'):
stubs += f'''zpool() {{
case "$*" in
'list -v -H rpool'|'list -v -H -P -L rpool') printf %s {q(pool_rows)}; return {pool_error} ;;
'list -H -o name') : ;;
*) printf 'UNEXPECTED zpool %s\\n' "$*" >> "$EVENTS"; return 99 ;;
esac
}}
readlink() {{ [[ "${{@: -1}}" == {q(unresolved)} ]] && return 1; printf '%s\\n' "${{@: -1}}"; }}
'''
if timeout_available:
stubs += f'''timeout() {{
[[ "$1" == --kill-after=2 && ( "$2" == 8s || "$2" == 5s ) ]] || {{ printf 'UNEXPECTED timeout\\n' >> "$EVENTS"; return 99; }}
shift 2
[[ "$*" != 'zpool list -H -o name' && {timeout_error} != 0 ]] && return {timeout_error}
"$@"
}}
'''
# Raw findmnt emits one row per mount, with whitespace hex-escaped.
rows = mount_rows if mount_rows is not None else (
f'{root if block_mounts else root_fs} / ' +
('zfs' if root_fs.startswith('rpool/') else 'ext4') +
(f'\n{boot} /boot ext4' if block_mounts else '') +
'\nproc /proc proc\nsysfs /sys sysfs\ntmpfs /run tmpfs')
stubs += f'''findmnt() {{
case "$*" in
'-krnv -o SOURCE,TARGET,FSTYPE')
[[ {q(fail)} == mounts ]] && return 32
if [[ {q(str(changed_root))} == True && "$PHASE" == menu ]]; then
printf '%s\\n' '/dev/sda1 / ext4'
else
printf '%s\\n' {q(rows)}
fi ;;
'-krnv -o SOURCE,FSTYPE -T /')
return_status={root_error}
(( return_status )) && return "$return_status"
printf '%s\\n' {q(root_fs + (' zfs' if root_fs.startswith('rpool/') else ' ext4'))} ;;
*) printf 'UNEXPECTED findmnt %s\\n' "$*" >> "$EVENTS"; return 99 ;;
esac
}}
'''
if mount_rows is not None:
# Simulate lsblk's single MOUNTPOINT hiding the system mount.
stubs += "lsblk() { [[ \"$*\" == '-lnpo PATH,MOUNTPOINT' ]] && { printf '%s\\n' '/dev/mapper/pve-root /mnt/backup'; return; }; fixture_lsblk \"$@\"; }\n"
else:
stubs += 'lsblk() { fixture_lsblk "$@"; }\n'
with tempfile.TemporaryDirectory(prefix='format-disk-test-') as td:
for cmd in ['awk', 'grep', 'sort', 'xargs', 'echo']:
executable = shutil.which(cmd)
assert executable, cmd
Path(td, cmd).symlink_to(executable)
events = Path(td, 'events')
source = '\n'.join(functions) + '\n' + stubs + f'''
declare -A DISK_RUNNING_VM_FLAG
SELECTED_DISK={q(candidate)}
PHASE=menu
build_disk_candidates
printf 'MENU=%s\\n' "${{DISK_OPTIONS[*]}}"
PHASE=revalidation
revalidate_selected_disk
printf 'STATUS=%s\\n' "$?"
printf 'DETAIL=%s\\n' "$REVALIDATE_ERROR_DETAIL"
'''
result = subprocess.run(['/bin/bash', '--noprofile', '--norc'], input=source,
text=True, capture_output=True, timeout=10,
env={'PATH': td, 'EVENTS': str(events), 'LC_ALL': 'C'})
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stderr, '')
self.assertFalse(events.exists(), events.read_text() if events.exists() else '')
return result.stdout
def test_zfs_membership_fails_closed(self):
for options in [dict(pool_error=1), dict(pool_error=124),
dict(timeout_error=124), dict(timeout_error=137),
dict(timeout_available=False), dict(pool_rows=''),
dict(pool_rows='rpool\nmirror-0\n'),
dict(pool_rows='rpool\n/dev/sdb\n123456789\n'),
dict(unresolved='/dev/sdb'),
dict(root='/dev/sdb', fail='topology'),
dict(root='/dev/sdb', fail='empty-topology')]:
with self.subTest(options=options):
result = self.run_case(block_mounts=False, root_fs='rpool/ROOT/pve-1',
candidate='/dev/sdc', **options)
self.assertIn('MENU=\n', result)
self.assertIn('STATUS=1\n', result)
def test_zfs_stacked_member_protects_all_ancestors(self):
result = self.run_case(block_mounts=False, root_fs='rpool/ROOT/pve-1',
pool_rows='rpool\n/dev/mapper/pve-root\t100\t20\n')
self.assertIn('MENU=\n', result)
self.assertIn('STATUS=1\n', result)
def test_zfs_class_headers_allow_unrelated_disk(self):
result = self.run_case(block_mounts=False, root_fs='rpool/ROOT/pve-1',
candidate='/dev/sdc',
pool_rows='rpool\nmirror-0\n/dev/sdb1\t100\t20\nlogs\ndedup\nspecial\ncache\nspare\n')
self.assertIn('MENU=/dev/sdc ', result)
self.assertIn('STATUS=0\n', result)
def test_root_discovery_fails_closed(self):
for error, source in [(32, '/dev/sdb1'), (124, '/dev/sdb1'), (0, ''), (0, 'overlay')]:
with self.subTest(error=error, source=source):
result = self.run_case(candidate='/dev/sdc', root_error=error, root_fs=source)
self.assertIn('MENU=\n', result)
self.assertIn('STATUS=1\n', result)
def test_multiple_mounts_do_not_hide_root(self):
result = self.run_case(mount_rows='/dev/mapper/pve-root / ext4\n/dev/mapper/pve-root /mnt/backup ext4')
self.assertIn('MENU=\n', result)
self.assertIn('STATUS=1\n', result)
def test_mount_scan_requires_root(self):
for rows in ['', 'proc /proc proc', '/dev/sda1 /boot ext4']:
with self.subTest(rows=rows):
result = self.run_case(mount_rows=rows, candidate='/dev/sdc')
self.assertIn('MENU=\n', result)
self.assertIn('STATUS=1\n', result)
def test_stacked_lvm_root_separate_boot(self):
result = self.run_case()
self.assertIn('MENU=\n', result)
self.assertIn('STATUS=1\n', result)
self.assertIn('system-critical mount', result)
def test_revalidation_rediscovers_mounts(self):
result = self.run_case(changed_root=True)
self.assertIn('MENU=/dev/sdb ', result)
self.assertIn('STATUS=1\n', result)
def test_topology_without_physical_disk_fails_closed(self):
result = self.run_case(parents={'/dev/mapper/pve-root': ('lvm', []),
'/dev/sda1': ('part', ['/dev/sda']),
'/dev/sda': ('disk', [])})
self.assertIn('MENU=\n', result)
self.assertIn('STATUS=1\n', result)
def test_direct_partition_root(self):
result = self.run_case(root='/dev/sdb1')
self.assertIn('MENU=\n', result)
self.assertIn('STATUS=1\n', result)
def test_whole_disk_root(self):
result = self.run_case(root='/dev/sdb')
self.assertIn('MENU=\n', result)
self.assertIn('STATUS=1\n', result)
def test_root_and_boot_same_disk(self):
result = self.run_case(boot='/dev/sdb1')
self.assertIn('MENU=\n', result)
self.assertIn('STATUS=1\n', result)
def test_multiple_physical_parents(self):
graph = {'/dev/mapper/pve-root': ('lvm', ['/dev/md0']),
'/dev/md0': ('raid1', ['/dev/sdb1', '/dev/sdc1']),
'/dev/sdb1': ('part', ['/dev/sdb']),
'/dev/sdc1': ('part', ['/dev/sdc']),
'/dev/sdb': ('disk', []), '/dev/sdc': ('disk', []),
'/dev/sda1': ('part', ['/dev/sda']), '/dev/sda': ('disk', [])}
for candidate in ['/dev/sdb', '/dev/sdc']:
with self.subTest(candidate=candidate):
result = self.run_case(parents=graph, candidate=candidate)
self.assertIn('MENU=\n', result)
self.assertIn('STATUS=1\n', result)
def test_unresolved_topology_fails_closed(self):
result = self.run_case(fail='topology', candidate='/dev/sdc')
self.assertIn('MENU=\n', result)
self.assertIn('STATUS=1\n', result)
self.assertIn('Unable to resolve system disk topology', result)
def test_empty_topology_fails_closed(self):
result = self.run_case(fail='empty-topology', candidate='/dev/sdc')
self.assertIn('MENU=\n', result)
self.assertIn('STATUS=1\n', result)
def test_failed_mount_discovery_fails_closed(self):
result = self.run_case(fail='mounts', candidate='/dev/sdc')
self.assertIn('MENU=\n', result)
self.assertIn('STATUS=1\n', result)
def test_unrelated_candidate_remains_available(self):
result = self.run_case(candidate='/dev/sdc')
self.assertIn('MENU=/dev/sdc ', result)
self.assertIn('STATUS=0\n', result)
def test_successful_no_block_mounts_is_not_failure(self):
result = self.run_case(block_mounts=False, candidate='/dev/sdc')
self.assertIn('MENU=/dev/sdc ', result)
self.assertIn('STATUS=0\n', result)
def test_zfs_root_without_lsblk_root_mount_remains_protected(self):
result = self.run_case(block_mounts=False, root_fs='rpool/ROOT/pve-1')
self.assertIn('MENU=\n', result)
self.assertIn('STATUS=1\n', result)
self.assertIn('system ZFS pool', result)
def test_zfs_root_allows_unrelated_disk(self):
result = self.run_case(block_mounts=False, root_fs='rpool/ROOT/pve-1',
candidate='/dev/sdc')
self.assertIn('MENU=/dev/sdc ', result)
self.assertIn('STATUS=0\n', result)
if __name__ == '__main__':
unittest.main()