Add read-only laptop doctor and trusted image recovery entrypoints

This commit is contained in:
Codex
2026-10-13 21:52:38 +03:00
parent 87003e92c6
commit 00aa0229cc
7 changed files with 471 additions and 11 deletions
+52 -6
View File
@@ -1,11 +1,11 @@
# ASUS TUF Dash F15 FX517ZC
Read-only inventory 2026-10-13; no serial numbers or machine identifiers.
Initial read-only inventory on 2026-10-13; no serials or machine identifiers.
| Component | Hardware | Driver / requirement |
| --- | --- | --- |
| CPU | i7-12650H, 10 cores / 16 threads | KVM Intel, VT-x |
| RAM | 32 GB nominal | Four build jobs initially; avoid disrupting existing VMs |
| RAM | 32 GB nominal | Preserve memory headroom for existing VMs |
| Display | Intel Alder Lake-P UHD 8086:46a3 | i915, internal eDP, intel_backlight |
| Discrete GPU | RTX 3050 Mobile 10de:25a2 | Initial shell uses Intel; test NVIDIA separately |
| Wi-Fi | Intel CNVi 8086:51f0 | iwlwifi/iwlmvm, linux-firmware |
@@ -16,7 +16,53 @@ Read-only inventory 2026-10-13; no serial numbers or machine identifiers.
| Thunderbolt | Intel 8086:463e | thunderbolt |
| Laptop controls | ASUS | asus_wmi, asus_nb_wmi |
UEFI boot, Secure Boot currently disabled. Include vmd, nvme, xhci_pci,
thunderbolt, usb_storage, sd_mod in boot coverage. Target physical refresh144Hz;
actual guest frame delivery must be measured. About290GiB build storage free at
inventory. Initial RAM headroom about10GiB with existing sessions running.
Include vmd, nvme, xhci_pci, thunderbolt, usb_storage and sd_mod in boot coverage.
Target physical refresh is 144 Hz; actual guest frame delivery requires measurement.
## Read-only doctor and live functional checks
Run `hydra doctor` from the Hydra live session, or `hydra doctor --json` to save
structured observations. The command reads procfs/sysfs and checks service status;
it does not connect networks, load modules, change brightness, play sound, mount
storage, suspend, or start services. Root is not required. Missing tools, absent
hardware, access failures and service timeouts are reported without aborting.
Reports omit storage serials, PCI addresses, interface names, MAC/IP addresses,
SSID, usernames, hostname and machine IDs. CPU facts include architecture, logical
processor count and virtualization flags. Device facts include counts, capacities,
allowlisted driver names and connector resolution modes. Unknown driver names are
reported as `other`. Presence and status do not establish physical functionality.
Reads are limited to 64 KiB per file, directory scans to 64 entries, connector
modes to 32 lines, and seven service queries to one second each. Counts may be
partial on systems exceeding these limits. Service output is discarded; exit
status alone determines active/inactive/unavailable/unknown. User audio service
queries refer to the user running the command. A root invocation can therefore
observe a different user audio manager than the live desktop user.
The following source-checkout smoke was observed on the running NixOS host on
2026-10-13. It involved no changes to host settings or hardware. It is separate
from Hydra ISO validation and does not certify the new image's laptop drivers.
| Check | Doctor detects | Source-checkout host observation | Live functional check still needed |
| --- | --- | --- | --- |
| CPU / RAM | Architecture, logical CPUs, VM flags, memory totals | x86_64, 16 logical CPUs, `vmx`, 31,715 MiB RAM | Boot the Hydra image and compare |
| Firmware | UEFI presence, Secure Boot variable | UEFI present, Secure Boot disabled | Confirm live boot from USB |
| KVM | Device access and loaded KVM modules | `/dev/kvm` accessible, `kvm` and `kvm_intel` present | Launch a disposable VM from Hydra |
| Storage | Whole-device count/capacity; no serials or identities | Visibility observed; no target selected | Identify any future installation target separately |
| Graphics | PCI driver bindings and connector resolution modes | `i915`, `nvidia`; connected 1920×1080 mode | Select 144 Hz and verify actual refresh rate |
| Input / brightness | Event devices, keyboard capabilities, backlight presence | 23 event devices, three keyboard-capable devices, one backlight | Test keyboard, touchpad and both brightness keys |
| Wi-Fi / Ethernet | Device counts, wireless presence, driver bindings | One wireless device; `iwlwifi` and `e1000e` | Connect Wi-Fi and test connectivity; test Ethernet if used |
| Audio | Card presence and user audio service status | Three cards; PipeWire, WirePlumber and PipeWire Pulse active | Test speakers, headphones and microphone recording |
| Desktop / guest networking | Hydra and desktop service status | NetworkManager active; Hydra services absent on NixOS | Observe required services in the Hydra live session |
| Suspend / USB | No functional probe | Not exercised | Suspend/resume, then retest graphics/network/audio; attach USB devices |
Run the manual checks before deciding the image is ready for daily laptop use.
Read-only diagnostics and disposable VM acceptance cannot replace these checks.
Keep any report you share within its default structured output: unrelated logs,
network commands and screenshots can contain private identifiers.
Installation remains an explicit operator action; this document selects no disk
or partition. Signed runtime rollback restores Hydra runtime files only. Offline
Btrfs root/owned-EFI recovery has separate requirements and acceptance evidence
in [system-recovery.md](../system-recovery.md).
+5
View File
@@ -21,6 +21,8 @@ def parser() -> argparse.ArgumentParser:
help='Distribution preferences for newly created profiles')
commands = cli.add_subparsers(dest='command')
commands.add_parser('menu', help='Open the terminal VM menu')
doctor = commands.add_parser('doctor', help='Read-only laptop hardware observations')
doctor.add_argument('--json', action='store_true', help='Print structured observations')
update = commands.add_parser('update', help='Signed runtime updates and rollback')
update.add_argument('update_args', nargs=argparse.REMAINDER)
download = commands.add_parser('download', help='Download and validate a direct ISO URL')
@@ -95,6 +97,9 @@ def main(argv: list[str] | None = None) -> int:
cli = parser()
args = cli.parse_args(argv)
try:
if args.command == 'doctor':
from .doctor import doctor_main
return doctor_main(['--json'] if args.json else [])
if args.command == 'update':
checkout = Path(__file__).resolve().parents[1]
if checkout == Path('/usr/lib/hydra'):
+186
View File
@@ -0,0 +1,186 @@
"""Bounded, read-only hardware observations; presence is not a functional test."""
import argparse
import itertools
import json
import os
from pathlib import Path
import platform
import re
import shutil
import subprocess
READ_LIMIT = 65536
DEVICE_LIMIT = 64
TOOLS = ('systemctl', 'nmcli', 'ip', 'iw', 'rfkill', 'wpctl', 'pactl',
'aplay', 'brightnessctl', 'wlr-randr', 'qemu-system-x86_64', 'btrfs',
'openssl', 'efibootmgr', 'lsblk', 'lspci')
DRIVERS = frozenset(('i915', 'xe', 'nouveau', 'nvidia', 'amdgpu', 'radeon',
'virtio_gpu', 'bochs', 'simpledrm', 'iwlwifi', 'ath9k',
'ath10k_pci', 'ath11k_pci', 'mt7921e', 'mt7922', 'rtw89_pci',
'r8169', 'e1000e', 'igc', 'virtio_net', 'snd_hda_intel'))
MODULES = ('kvm', 'kvm_intel', 'kvm_amd', 'i915', 'xe', 'nouveau', 'nvidia',
'amdgpu', 'iwlwifi', 'mt7921e', 'snd_hda_intel', 'snd_sof_pci_intel_tgl')
MANUAL_CHECKS = [
'Wi-Fi connection: connect to your access point and test connectivity.',
'Brightness keys: check both keys and the visible panel brightness.',
'144 Hz: select the panel mode and verify the actual refresh rate.',
'Audio: play sound through speakers/headphones and record microphone input.',
'Suspend/resume: test wake-up, graphics, network and audio after suspend.',
'USB: attach your devices and verify input/storage without erasing data.',
]
def _read(path, limit=READ_LIMIT):
try:
with path.open('rb') as stream:
return stream.read(limit).decode('ascii', errors='replace')
except OSError:
return ''
def _entries(path):
try:
# Do not materialize an unbounded directory or emit its identifying names.
with os.scandir(path) as entries:
return [Path(entry.path) for entry in itertools.islice(entries, DEVICE_LIMIT)]
except OSError:
return []
def _number(path, *, base=10):
text = _read(path, 128).strip()
try:
value = int(text, base)
return value if 0 <= value <= 2**64 - 1 else None
except ValueError:
return None
def _driver(path):
try:
name = (path / 'driver').resolve(strict=True).name
return name if name in DRIVERS else 'other'
except OSError:
return 'unknown'
def _services(find_tool, run):
systemctl = find_tool('systemctl')
result = {}
for unit, user in (('NetworkManager', False), ('greetd', False),
('hydra-network', False), ('hydra-dnsmasq', False),
('pipewire', True), ('wireplumber', True), ('pipewire-pulse', True)):
key = unit + (':user' if user else ':system')
if not systemctl:
result[key] = 'unavailable'
continue
command = [systemctl] + (['--user'] if user else []) + ['is-active', '--quiet', unit + '.service']
try:
status = run(command, timeout=1, stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False).returncode
result[key] = {0: 'active', 3: 'inactive', 4: 'unavailable'}.get(status, 'unknown')
except subprocess.TimeoutExpired:
result[key] = 'timeout'
except OSError:
result[key] = 'unavailable'
return result
def doctor_report(*, proc_root=Path('/proc'), sys_root=Path('/sys'), dev_root=Path('/dev'),
find_tool=shutil.which, run=subprocess.run):
"""Return JSON-safe facts without identifiers or hardware-changing commands.
Each file read is bounded to 64 KiB, each device class to 64 entries and
the seven read-only service queries to one second each. Missing access
produces unknown/absent facts, never a claim of functional success.
"""
proc_root, sys_root, dev_root = Path(proc_root), Path(sys_root), Path(dev_root)
cpu = _read(proc_root / 'cpuinfo')
flags = set(re.findall(r'\b(?:vmx|svm)\b', cpu))
memory = {}
meminfo = _read(proc_root / 'meminfo')
for field, key in (('MemTotal', 'total_mib'), ('MemAvailable', 'available_mib')):
match = re.search(r'^' + field + r':\s+([0-9]{1,16})\s+kB$', meminfo, re.M)
memory[key] = int(match[1]) // 1024 if match else None
efi = sys_root / 'firmware/efi'
secure_boot = None
variable = efi / 'efivars/SecureBoot-8be4df61-93ca-11d2-aa0d-00e098032b8c'
try:
with variable.open('rb') as stream:
value = stream.read(6)
if len(value) == 5 and value[4] in (0, 1):
secure_boot = bool(value[4])
except OSError:
pass
storage = []
for path in _entries(sys_root / 'class/block'):
if (path / 'partition').exists() or re.match(r'^(loop|ram|zram)', path.name):
continue
sectors = _number(path / 'size')
storage.append({'size_bytes': sectors * 512 if sectors is not None else None,
'removable': {0: False, 1: True}.get(_number(path / 'removable'))})
graphics_drivers = set()
for path in _entries(sys_root / 'bus/pci/devices'):
pci_class = _number(path / 'class', base=16)
if pci_class is not None and pci_class >> 16 == 3:
graphics_drivers.add(_driver(path))
connectors = []
for path in _entries(sys_root / 'class/drm'):
if not (path / 'status').exists():
continue
status = _read(path / 'status', 64).strip()
modes = [line for line in _read(path / 'modes').splitlines()[:32]
if re.fullmatch(r'[0-9]{1,5}x[0-9]{1,5}', line)]
connectors.append({'status': status if status in ('connected', 'disconnected', 'unknown') else 'unknown',
'modes': modes})
network = _entries(sys_root / 'class/net')
inputs = [p for p in _entries(sys_root / 'class/input') if re.fullmatch(r'event[0-9]+', p.name)]
keyboards = 0
for path in inputs:
value = _read(path / 'device/capabilities/key', 256).strip().replace(' ', '')
if re.fullmatch(r'[0-9a-fA-F]{1,256}', value):
bits = int(value, 16)
keyboards += bool(bits & (1 << 30) and bits & (1 << 44))
return {
'format': 1,
'scope': 'Read-only observations; physical functionality requires manual tests.',
'cpu': {'architecture': platform.machine(), 'logical_processors': len(re.findall(r'^processor\s*:', cpu, re.M)) or None,
'virtualization_flags': sorted(flags)},
'memory': memory,
'firmware': {'uefi': efi.is_dir(), 'secure_boot': secure_boot},
'kvm': {'device_present': (dev_root / 'kvm').exists(),
'accessible': os.access(dev_root / 'kvm', os.R_OK | os.W_OK),
'modules': [name for name in ('kvm', 'kvm_intel', 'kvm_amd') if (sys_root / 'module' / name).is_dir()]},
'storage': {'devices': storage},
'graphics': {'drivers': sorted(graphics_drivers), 'connectors': connectors,
'refresh_rate_tested': False},
'input': {'event_devices': len(inputs), 'keyboards': keyboards,
'backlight_devices': len(_entries(sys_root / 'class/backlight'))},
'network': {'devices': len(network), 'wireless_devices': sum((p / 'wireless').is_dir() for p in network),
'drivers': sorted({_driver(p / 'device') for p in network if (p / 'device').exists()})},
'audio': {'cards': sum(bool(re.fullmatch(r'card[0-9]+', p.name)) for p in _entries(sys_root / 'class/sound'))},
'modules': [name for name in MODULES if (sys_root / 'module' / name).is_dir()],
'tools': {name: bool(find_tool(name)) for name in TOOLS},
'services': _services(find_tool, run),
'manual_checks': list(MANUAL_CHECKS),
}
def doctor_main(argv=None):
parser = argparse.ArgumentParser(prog='hydra doctor', description=__doc__)
parser.add_argument('--json', action='store_true', help='Print structured observations')
options = parser.parse_args(argv)
report = doctor_report()
if options.json:
print(json.dumps(report, indent=2, sort_keys=True))
else:
print('Hydra laptop doctor — read-only observations')
print(report['scope'])
for key, value in report.items():
if key not in ('format', 'scope', 'manual_checks'):
print(f'{key}: {json.dumps(value, sort_keys=True)}')
print('\nManual checks:')
for check in report['manual_checks']:
print('- ' + check)
return 0
+24 -5
View File
@@ -269,8 +269,10 @@ def brand_os_release(root):
def configure_rootfs(root, source):
root, source = Path(root), Path(source)
# Refuse incomplete integrations before changing the exported copy.
for name in ('bin/hydra', 'bin/hydra-install', 'hydra/session.py', 'hydra/installer.py'):
if not (source / name).is_file():
for name in ('bin/hydra', 'bin/hydra-install', 'hydra/session.py', 'hydra/installer.py',
'bin/hydra-update', 'bin/hydra-update-recover', 'hydra/update_engine.py',
'bin/hydra-system', 'hydra/system_recovery.py', 'image/rootfs/etc/hydra/update.pub'):
if (source / name).is_symlink() or not (source / name).is_file():
raise ImageError(f'Required committed runtime/installer asset is missing: {name}')
version = kernel_version(root)
kernel = kernel_image(root)
@@ -287,11 +289,28 @@ def configure_rootfs(root, source):
for module in sorted((source / 'hydra').glob('*.py')):
shutil.copyfile(module, root_path(root, 'usr/lib/hydra/hydra/' + module.name))
# Session helpers derive this checkout-compatible launcher from __file__.
for name in ('hydra', 'hydra-install'):
for name in ('hydra', 'hydra-install', 'hydra-system'):
write(root, 'usr/lib/hydra/bin/' + name, (source / 'bin' / name).read_text(), 0o755)
for name, module in (('hydra', 'cli'), ('hydra-install', 'installer')):
# The updater bootstrap and engine are independently installed trusted files.
# They must retain their isolated runpy launchers, outside runtime imports.
for destination, asset, mode in (
('usr/lib/hydra-update/engine.py', 'hydra/update_engine.py', 0o644),
('usr/bin/hydra-update', 'bin/hydra-update', 0o755),
('usr/bin/hydra-update-recover', 'bin/hydra-update-recover', 0o755),
('etc/hydra/update.pub', 'image/rootfs/etc/hydra/update.pub', 0o644),
):
remove(root, destination)
path = write(root, destination, (source / asset).read_text(), mode)
if os.geteuid() == 0: # Assembly runs as root; source fixtures remain unprivileged.
os.chown(path, 0, 0)
engine_directory = root_path(root, 'usr/lib/hydra-update')
engine_directory.chmod(0o755)
if os.geteuid() == 0:
os.chown(engine_directory, 0, 0)
for name, module in (('hydra', 'cli'), ('hydra-install', 'installer'),
('hydra-system', 'system_recovery')):
write(root, 'usr/bin/' + name,
'#!/usr/bin/python3\nimport sys\nsys.path.insert(0, "/usr/lib/hydra")\n'
'#!/usr/bin/python3 -I\nimport sys\nsys.path.insert(0, "/usr/lib/hydra")\n'
f'from hydra.{module} import main\nraise SystemExit(main())\n', 0o755)
write(root, 'usr/bin/hydra-session', '#!/bin/sh\nexec /usr/bin/hydra session "$@"\n', 0o755)
write(root, 'usr/bin/hydra-network', '#!/usr/bin/python3\nimport sys\nsys.path.insert(0, "/usr/lib/hydra")\n'
+23
View File
@@ -21,6 +21,29 @@ class CLITests(unittest.TestCase):
def run_cli(self, *args):
return subprocess.run(self.base + list(args), capture_output=True, text=True, timeout=5)
def test_doctor_json_and_text_do_not_create_runtime_state(self):
from unittest.mock import patch
from hydra import cli
import contextlib
import io
with patch('hydra.doctor.doctor_report', return_value={'format': 1, 'scope': 'observations',
'manual_checks': ['Wi-Fi connection', 'suspend']}):
output = io.StringIO()
with contextlib.redirect_stdout(output):
try:
status = cli.main(self.base[2:] + ['doctor', '--json'])
except SystemExit as error:
status = error.code
self.assertEqual(status, 0)
self.assertEqual(json.loads(output.getvalue())['format'], 1)
output = io.StringIO()
with contextlib.redirect_stdout(output):
self.assertEqual(cli.main(self.base[2:] + ['doctor']), 0)
self.assertIn('Manual checks', output.getvalue())
self.assertFalse((self.root / 'state').exists())
self.assertFalse((self.root / 'config').exists())
self.assertFalse((self.root / 'run').exists())
def test_defaults_affect_new_profiles_only_and_explicit_options_win(self):
self.defaults.write_text('network="bridge"\nbridge="hydrabr0"\nwidth=1920\nheight=1080\nrefresh_millihz=144000\n')
added = self.run_cli('add', 'first', '--width', '1600', '--network', 'user', '--refresh-millihz', '0')
+121
View File
@@ -0,0 +1,121 @@
import contextlib
import io
import json
import subprocess
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
try:
from hydra import doctor
except ImportError:
doctor = None
class DoctorTests(unittest.TestCase):
def setUp(self):
self.assertIsNotNone(doctor, 'read-only doctor module is missing')
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
for name in ('proc', 'sys', 'dev'):
(self.root / name).mkdir()
def file(self, name, value=''):
path = self.root / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(value)
return path
def report(self, **kwargs):
return doctor.doctor_report(proc_root=self.root / 'proc', sys_root=self.root / 'sys',
dev_root=self.root / 'dev', find_tool=lambda _: None, **kwargs)
def test_absent_hardware_is_reported_without_false_functional_claims(self):
result = self.report()
self.assertEqual(result['firmware']['uefi'], False)
self.assertIsNone(result['firmware']['secure_boot'])
self.assertEqual(result['storage']['devices'], [])
self.assertFalse(result['kvm']['device_present'])
self.assertTrue(all(not value for value in result['tools'].values()))
self.assertIn('Wi-Fi connection', ' '.join(result['manual_checks']))
self.assertIn('144 Hz', ' '.join(result['manual_checks']))
json.dumps(result)
def test_fake_hardware_reports_presence_and_driver_without_identifiers(self):
self.file('proc/cpuinfo', 'processor : 0\nflags : vmx sse\nSerial : PRIVATE_SERIAL\n')
self.file('proc/meminfo', 'MemTotal: 8388608 kB\nMemAvailable: 4194304 kB\n')
secure = self.file('sys/firmware/efi/efivars/SecureBoot-8be4df61-93ca-11d2-aa0d-00e098032b8c')
secure.write_bytes(b'\x07\0\0\0\x01')
self.file('dev/kvm')
self.file('sys/module/kvm_intel/refcnt', '0')
self.file('sys/class/block/nvme0n1/size', '2097152')
self.file('sys/class/block/nvme0n1/device/serial', 'PRIVATE_SERIAL')
self.file('sys/class/block/nvme0n1p1/partition', '1')
self.file('sys/class/drm/card0-eDP-1/status', 'connected')
self.file('sys/class/drm/card0-eDP-1/modes', '1920x1080\nPRIVATE_USER\n')
self.file('sys/bus/pci/devices/0000:00:02.0/class', '0x030000')
driver = self.root / 'sys/bus/pci/drivers/i915'
driver.mkdir(parents=True)
(self.root / 'sys/bus/pci/devices/0000:00:02.0/driver').symlink_to(driver)
self.file('sys/class/net/wlan0/wireless/present')
self.file('sys/class/net/wlan0/address', 'aa:bb:cc:dd:ee:ff')
self.file('sys/class/net/wlan0/ssid', 'PRIVATE_SSID')
self.file('sys/class/sound/card0/id', 'PRIVATE_USER')
self.file('sys/class/input/event0/device/name', 'PRIVATE_USER')
self.file('sys/class/input/event0/device/capabilities/key', hex((1 << 30) | (1 << 44))[2:])
result = self.report()
self.assertEqual(result['cpu']['virtualization_flags'], ['vmx'])
self.assertEqual(result['memory']['total_mib'], 8192)
self.assertEqual(result['firmware']['secure_boot'], True)
self.assertEqual(result['storage']['devices'][0]['size_bytes'], 1073741824)
self.assertEqual(result['graphics']['drivers'], ['i915'])
self.assertEqual(result['network']['wireless_devices'], 1)
self.assertEqual(result['audio']['cards'], 1)
self.assertEqual(result['input']['keyboards'], 1)
encoded = json.dumps(result)
for secret in ('PRIVATE_SERIAL', 'PRIVATE_USER', 'PRIVATE_SSID', 'aa:bb:cc:dd:ee:ff'):
self.assertNotIn(secret, encoded)
def test_probes_timeout_independently_and_discard_untrusted_output(self):
calls = []
def run(command, **kwargs):
calls.append((command, kwargs))
if len(calls) == 1:
raise subprocess.TimeoutExpired(command, kwargs['timeout'], output='PRIVATE_SSID')
return subprocess.CompletedProcess(command, 5, 'PRIVATE_USER 192.0.2.1 aa:bb:cc:dd:ee:ff', '')
result = doctor.doctor_report(proc_root=self.root / 'proc', sys_root=self.root / 'sys',
dev_root=self.root / 'dev', find_tool=lambda name: '/usr/bin/' + name,
run=run)
self.assertIn('timeout', result['services'].values())
self.assertIn('unknown', result['services'].values())
self.assertNotIn('PRIVATE', json.dumps(result))
self.assertLessEqual(len(calls), 8)
for argv, options in calls:
self.assertEqual(argv[0], '/usr/bin/systemctl')
self.assertIn('is-active', argv)
self.assertLessEqual(options['timeout'], 1)
self.assertFalse(options.get('shell', False))
def test_reads_are_bounded_and_invalid_numbers_are_unknown(self):
self.file('proc/meminfo', 'MemTotal: invalid kB\n' + 'secret' * 100000)
self.file('sys/class/block/sda/size', '-1')
self.file('sys/class/drm/card0-HDMI-A-1/status', 'private text')
result = self.report()
self.assertIsNone(result['memory']['total_mib'])
self.assertIsNone(result['storage']['devices'][0]['size_bytes'])
self.assertEqual(result['graphics']['connectors'][0]['status'], 'unknown')
self.assertLess(len(json.dumps(result)), 10000)
def test_text_and_json_outputs_share_report_and_manual_limits(self):
with patch.object(doctor, 'doctor_report', return_value=self.report()):
output = io.StringIO()
with contextlib.redirect_stdout(output):
self.assertEqual(doctor.doctor_main(['--json']), 0)
self.assertEqual(json.loads(output.getvalue())['firmware']['uefi'], False)
output = io.StringIO()
with contextlib.redirect_stdout(output):
self.assertEqual(doctor.doctor_main([]), 0)
self.assertIn('Manual checks', output.getvalue())
self.assertIn('suspend', output.getvalue())
+60
View File
@@ -35,6 +35,66 @@ class ImageTests(unittest.TestCase):
self.file('boot/vmlinuz-' + version, 'kernel')
return version
def test_installs_independent_updater_and_base_recovery_assets(self):
self.kernel()
source = Path(__file__).resolve().parents[1]
image.configure_rootfs(self.root, source)
for relative, asset, mode in (
('usr/lib/hydra-update/engine.py', 'hydra/update_engine.py', 0o644),
('usr/bin/hydra-update', 'bin/hydra-update', 0o755),
('usr/bin/hydra-update-recover', 'bin/hydra-update-recover', 0o755),
('usr/lib/hydra/bin/hydra-system', 'bin/hydra-system', 0o755),
('etc/hydra/update.pub', 'image/rootfs/etc/hydra/update.pub', 0o644),
):
with self.subTest(relative=relative):
path = self.root / relative
self.assertTrue(path.is_file(), relative)
self.assertEqual(path.read_bytes(), (source / asset).read_bytes())
self.assertEqual(path.stat().st_mode & 0o777, mode)
wrapper = self.root / 'usr/bin/hydra-system'
self.assertTrue(wrapper.is_file(), 'base recovery launcher is missing')
self.assertEqual(wrapper.stat().st_mode & 0o777, 0o755)
self.assertEqual(wrapper.read_text(),
'#!/usr/bin/python3 -I\nimport sys\nsys.path.insert(0, "/usr/lib/hydra")\n'
'from hydra.system_recovery import main\nraise SystemExit(main())\n')
self.assertEqual((self.root / 'usr/lib/hydra/hydra/doctor.py').read_bytes(),
(source / 'hydra/doctor.py').read_bytes())
def test_root_assembly_sets_trusted_asset_ownership(self):
from unittest.mock import patch
self.kernel()
with patch('hydra.image.os.geteuid', return_value=0), patch('hydra.image.os.chown') as chown:
image.configure_rootfs(self.root, Path(__file__).resolve().parents[1])
for relative in ('usr/lib/hydra-update/engine.py', 'usr/lib/hydra-update',
'usr/bin/hydra-update', 'usr/bin/hydra-update-recover', 'etc/hydra/update.pub'):
chown.assert_any_call(self.root / relative, 0, 0)
def test_missing_trusted_asset_refuses_configuration_before_mutation(self):
import shutil
source = Path(__file__).resolve().parents[1]
fixture = Path(self.tmp.name) / 'source'
fixture.mkdir()
for directory in ('bin', 'hydra', 'image'):
shutil.copytree(source / directory, fixture / directory,
ignore=shutil.ignore_patterns('__pycache__'))
for asset in ('bin/hydra-update', 'bin/hydra-update-recover', 'hydra/update_engine.py',
'bin/hydra-system', 'hydra/system_recovery.py', 'image/rootfs/etc/hydra/update.pub'):
with self.subTest(asset=asset):
self.root = Path(self.tmp.name) / ('root-' + asset.replace('/', '-'))
self.root.mkdir()
self.kernel('6.18.54-gentoo-dist-bin')
original = self.file('etc/machine-id', 'preserved-builder-id')
missing = fixture / asset
saved = missing.read_bytes()
missing.unlink()
try:
with self.assertRaisesRegex(image.ImageError, 'missing'):
image.configure_rootfs(self.root, fixture)
self.assertEqual(original.read_text(), 'preserved-builder-id')
self.assertFalse((self.root / 'usr').exists())
finally:
missing.write_bytes(saved)
def test_kernel_from_payload_not_running_host(self):
self.kernel()
self.assertEqual(image.kernel_version(self.root), '6.12.58-gentoo-dist')