Add read-only laptop doctor and trusted image recovery entrypoints
This commit is contained in:
@@ -1,11 +1,11 @@
|
|||||||
# ASUS TUF Dash F15 FX517ZC
|
# 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 |
|
| Component | Hardware | Driver / requirement |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| CPU | i7-12650H, 10 cores / 16 threads | KVM Intel, VT-x |
|
| 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 |
|
| 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 |
|
| Discrete GPU | RTX 3050 Mobile 10de:25a2 | Initial shell uses Intel; test NVIDIA separately |
|
||||||
| Wi-Fi | Intel CNVi 8086:51f0 | iwlwifi/iwlmvm, linux-firmware |
|
| 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 |
|
| Thunderbolt | Intel 8086:463e | thunderbolt |
|
||||||
| Laptop controls | ASUS | asus_wmi, asus_nb_wmi |
|
| Laptop controls | ASUS | asus_wmi, asus_nb_wmi |
|
||||||
|
|
||||||
UEFI boot, Secure Boot currently disabled. Include vmd, nvme, xhci_pci,
|
Include vmd, nvme, xhci_pci, thunderbolt, usb_storage and sd_mod in boot coverage.
|
||||||
thunderbolt, usb_storage, sd_mod in boot coverage. Target physical refresh144Hz;
|
Target physical refresh is 144 Hz; actual guest frame delivery requires measurement.
|
||||||
actual guest frame delivery must be measured. About290GiB build storage free at
|
|
||||||
inventory. Initial RAM headroom about10GiB with existing sessions running.
|
## 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).
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ def parser() -> argparse.ArgumentParser:
|
|||||||
help='Distribution preferences for newly created profiles')
|
help='Distribution preferences for newly created profiles')
|
||||||
commands = cli.add_subparsers(dest='command')
|
commands = cli.add_subparsers(dest='command')
|
||||||
commands.add_parser('menu', help='Open the terminal VM menu')
|
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 = commands.add_parser('update', help='Signed runtime updates and rollback')
|
||||||
update.add_argument('update_args', nargs=argparse.REMAINDER)
|
update.add_argument('update_args', nargs=argparse.REMAINDER)
|
||||||
download = commands.add_parser('download', help='Download and validate a direct ISO URL')
|
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()
|
cli = parser()
|
||||||
args = cli.parse_args(argv)
|
args = cli.parse_args(argv)
|
||||||
try:
|
try:
|
||||||
|
if args.command == 'doctor':
|
||||||
|
from .doctor import doctor_main
|
||||||
|
return doctor_main(['--json'] if args.json else [])
|
||||||
if args.command == 'update':
|
if args.command == 'update':
|
||||||
checkout = Path(__file__).resolve().parents[1]
|
checkout = Path(__file__).resolve().parents[1]
|
||||||
if checkout == Path('/usr/lib/hydra'):
|
if checkout == Path('/usr/lib/hydra'):
|
||||||
|
|||||||
+186
@@ -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
@@ -269,8 +269,10 @@ def brand_os_release(root):
|
|||||||
def configure_rootfs(root, source):
|
def configure_rootfs(root, source):
|
||||||
root, source = Path(root), Path(source)
|
root, source = Path(root), Path(source)
|
||||||
# Refuse incomplete integrations before changing the exported copy.
|
# Refuse incomplete integrations before changing the exported copy.
|
||||||
for name in ('bin/hydra', 'bin/hydra-install', 'hydra/session.py', 'hydra/installer.py'):
|
for name in ('bin/hydra', 'bin/hydra-install', 'hydra/session.py', 'hydra/installer.py',
|
||||||
if not (source / name).is_file():
|
'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}')
|
raise ImageError(f'Required committed runtime/installer asset is missing: {name}')
|
||||||
version = kernel_version(root)
|
version = kernel_version(root)
|
||||||
kernel = kernel_image(root)
|
kernel = kernel_image(root)
|
||||||
@@ -287,11 +289,28 @@ def configure_rootfs(root, source):
|
|||||||
for module in sorted((source / 'hydra').glob('*.py')):
|
for module in sorted((source / 'hydra').glob('*.py')):
|
||||||
shutil.copyfile(module, root_path(root, 'usr/lib/hydra/hydra/' + module.name))
|
shutil.copyfile(module, root_path(root, 'usr/lib/hydra/hydra/' + module.name))
|
||||||
# Session helpers derive this checkout-compatible launcher from __file__.
|
# 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)
|
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,
|
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)
|
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-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'
|
write(root, 'usr/bin/hydra-network', '#!/usr/bin/python3\nimport sys\nsys.path.insert(0, "/usr/lib/hydra")\n'
|
||||||
|
|||||||
@@ -21,6 +21,29 @@ class CLITests(unittest.TestCase):
|
|||||||
def run_cli(self, *args):
|
def run_cli(self, *args):
|
||||||
return subprocess.run(self.base + list(args), capture_output=True, text=True, timeout=5)
|
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):
|
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')
|
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')
|
added = self.run_cli('add', 'first', '--width', '1600', '--network', 'user', '--refresh-millihz', '0')
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -35,6 +35,66 @@ class ImageTests(unittest.TestCase):
|
|||||||
self.file('boot/vmlinuz-' + version, 'kernel')
|
self.file('boot/vmlinuz-' + version, 'kernel')
|
||||||
return version
|
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):
|
def test_kernel_from_payload_not_running_host(self):
|
||||||
self.kernel()
|
self.kernel()
|
||||||
self.assertEqual(image.kernel_version(self.root), '6.12.58-gentoo-dist')
|
self.assertEqual(image.kernel_version(self.root), '6.12.58-gentoo-dist')
|
||||||
|
|||||||
Reference in New Issue
Block a user