199 lines
11 KiB
Python
199 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Exercise the packaged machine settings service against disposable virtual NVMe."""
|
|
import json
|
|
from pathlib import Path
|
|
import shlex
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
|
|
project = Path(__file__).resolve().parents[2]
|
|
sys.path.insert(0, str(project / 'tools'))
|
|
from image_formats import digest, gpt, LINUX_FILESYSTEM
|
|
from vm_test import VM
|
|
|
|
work = Path(tempfile.mkdtemp(prefix='m12-internal.', dir=project / 'out'))
|
|
runner = str(project / 'tools/in-image-tools')
|
|
controller = 'qemu-xhci,id=xhci,addr=05.0'
|
|
placeholder = work / 'placeholder.bin'
|
|
placeholder.write_bytes(bytes(4096))
|
|
empty = work / 'empty.img'
|
|
gpt(empty, [('FDS_TEST', LINUX_FILESYSTEM, placeholder)])
|
|
|
|
for name in ['build-a', 'build-b']:
|
|
output = work / name
|
|
output.mkdir()
|
|
with (work / (name + '.log')).open('wb') as log:
|
|
subprocess.run([str(project / 'image/build-internal'), '--output-directory', str(output)], check=True, stdout=log, stderr=subprocess.STDOUT)
|
|
image = work / 'build-a/internal.img'
|
|
layout = json.loads((work / 'build-a/layout.json').read_text())
|
|
assert digest(image) == digest(work / 'build-b/internal.img'), 'Internal image construction is not reproducible'
|
|
assert [p['name'] for p in layout['partitions']] == ['FDS_BOOT', 'FDS_RECOVERY', 'FDS_INTERNAL']
|
|
assert [p['size'] * 512 for p in layout['partitions']] == [512 * 1024**2, 1024**3, 256 * 1024**2]
|
|
assert not ((project / 'out/rootfs-cli.tar').resolve().parent / 'root/var/cache/ldconfig/aux-cache').exists()
|
|
print('PASS: two complete internal GPT builds are byte-identical, with independent FAT/EROFS/ext4/GPT checks', flush=True)
|
|
|
|
# Every VM boots the packaged recovery EROFS. No injected runtime or bay-map
|
|
# replacement is used; installation exercises the public persistent-settings API.
|
|
def extra(path, node='internal', readonly=False):
|
|
return ['-drive', f'file={path},if=none,id={node},format=raw' + (',readonly=on' if readonly else ''),
|
|
'-device', f'nvme,drive={node},serial=FDS-{node.upper()}']
|
|
|
|
def wait(operation, condition, timeout=60):
|
|
deadline = time.monotonic() + timeout
|
|
while True:
|
|
result = operation()
|
|
if condition(result): return result
|
|
assert time.monotonic() < deadline, result
|
|
|
|
def query(vm, args):
|
|
return json.loads(vm.capture('fds --json ' + shlex.join(args)))
|
|
|
|
def enter(vm):
|
|
vm.expect(rb'FDS_SYSTEM MEDIA NOT PRESENT')
|
|
vm.send('recovery')
|
|
vm.expect(rb'RECOVERY# ')
|
|
assert vm.capture('id -u') == '0'
|
|
assert vm.capture('stat -c %u:%g:%a /') == '0:0:755'
|
|
wait(lambda: vm.capture('test -S /run/fds/control.sock && echo ready || echo pending'), lambda text: text == 'ready')
|
|
return query(vm, ['machine', 'status'])
|
|
|
|
def finish(vm):
|
|
vm.send('fds poweroff')
|
|
vm.expect(rb'reboot: Power down')
|
|
assert vm.child.wait(timeout=20) == 0
|
|
|
|
def unchanged_payloads(path):
|
|
import hashlib
|
|
with path.open('rb') as source:
|
|
for part in layout['partitions'][:2]:
|
|
source.seek(part['start'] * 512)
|
|
checksum = hashlib.sha256()
|
|
remaining = part['payload_bytes']
|
|
while remaining:
|
|
chunk = source.read(min(remaining, 1024 * 1024))
|
|
assert chunk
|
|
remaining -= len(chunk)
|
|
checksum.update(chunk)
|
|
assert checksum.hexdigest() == part['sha256']
|
|
|
|
def copy_image(name):
|
|
destination = work / (name + '.img')
|
|
subprocess.run(['cp', '--reflink=auto', '--sparse=always', str(image), str(destination)], check=True)
|
|
return destination
|
|
|
|
machine = copy_image('machine')
|
|
original = digest(machine)
|
|
with VM(work, 'install-settings', empty, extra=[*extra(machine), '-device', controller, '-device', 'usb-kbd,bus=xhci.0,port=4']) as vm:
|
|
status = enter(vm)
|
|
assert status['source'] == 'internal_nvme' and status['name'] == 'FP-85', status
|
|
assert digest(machine) == original, 'Loading settings changed the disk'
|
|
vm.capture('test -z "$(findmnt -nr -o TARGET | grep /run/fds/machine/internal)"')
|
|
vm.capture('s6-setuidgid fds fds machine store denied /etc/hostname', ok=False)
|
|
vm.capture('s6-setuidgid fds fds machine --load', ok=False)
|
|
devices = wait(lambda: query(vm, ['topology'])['unmapped'], lambda rows: any('03' in row['interfaces'] for row in rows))
|
|
keyboard = next(row for row in devices if '03' in row['interfaces'])
|
|
hub = keyboard['topology'].rsplit('/', 1)[0]
|
|
config = f'[front]\nhub={json.dumps(hub)}\n[front.ports]\n4=4\n'
|
|
catalog = f'[[device]]\nname="LAB KEYBOARD"\nvendor={json.dumps(keyboard["vendor"])}\nproduct={json.dumps(keyboard["product"])}\n'
|
|
vm.capture('fds machine export /tmp/new-machine')
|
|
vm.capture('fds machine export /tmp/new-machine', ok=False)
|
|
for file, data in [('bays.toml', config), ('hardware-catalog.toml', catalog), ('machine.toml', 'format=1\nname="FP-85 LAB"\n')]:
|
|
vm.capture('printf %s ' + shlex.quote(data) + ' >/tmp/new-machine/' + file)
|
|
vm.capture('fds machine validate /tmp/new-machine && fds machine install /tmp/new-machine')
|
|
assert query(vm, ['machine', 'status'])['name'] == 'FP-85'
|
|
vm.capture('s6-rc -l /run/s6-rc -d change cartridged && s6-rc -l /run/s6-rc -u change cartridged')
|
|
assert query(vm, ['topology'])['unmapped'], 'Machine settings changed underneath the current boot'
|
|
vm.capture('fds --json boot-profile >/tmp/boot.json && fds machine store acceptance-boot.json /tmp/boot.json && fds machine fetch acceptance-boot.json /tmp/retrieved.json && cmp /tmp/boot.json /tmp/retrieved.json')
|
|
vm.capture('fds machine store acceptance-boot.json /tmp/boot.json', ok=False)
|
|
vm.capture('fds machine store ../escape /tmp/boot.json', ok=False)
|
|
vm.capture('fds machine fetch acceptance-boot.json /tmp/retrieved.json', ok=False)
|
|
vm.capture('truncate -s 16777217 /tmp/oversize && fds machine store oversize /tmp/oversize', ok=False)
|
|
vm.capture('test -z "$(findmnt -nr -o TARGET | grep /run/fds/machine/internal)"')
|
|
finish(vm)
|
|
unchanged_payloads(machine)
|
|
print('PASS: settings load without writes, root-only atomic installation, fixed boot snapshot, bounded diagnostics and exact retrieval; boot/recovery partitions remain unchanged', flush=True)
|
|
|
|
with VM(work, 'persistent-settings', empty, extra=[*extra(machine), '-device', controller, '-device', 'usb-kbd,bus=xhci.0,port=4']) as vm:
|
|
status = enter(vm)
|
|
assert status['source'] == 'internal_nvme' and status['name'] == 'FP-85 LAB', status
|
|
bay = wait(lambda: query(vm, ['bay', '4'])['bays'][0], lambda row: row['state'] == 'hardware')
|
|
assert bay['name'] == 'LAB KEYBOARD', bay
|
|
vm.capture('fds machine fetch acceptance-boot.json /tmp/saved-boot.json')
|
|
saved = json.loads(vm.capture('cat /tmp/saved-boot.json'))
|
|
assert saved['boot_id'] != query(vm, ['boot-profile'])['boot_id']
|
|
(work / 'persistent-status.json').write_text(json.dumps(status, indent=2) + '\n')
|
|
finish(vm)
|
|
print('PASS: independent reboot loads saved bay/catalog/name settings and retrieves the previous boot record', flush=True)
|
|
|
|
# Independent filesystem inspection after real guest writes.
|
|
part = layout['partitions'][2]
|
|
settings = work / 'after-guest.ext4'
|
|
with machine.open('rb') as source, settings.open('wb') as output:
|
|
source.seek(part['start'] * 512)
|
|
remaining = part['payload_bytes']
|
|
while remaining:
|
|
chunk = source.read(min(remaining, 1024 * 1024))
|
|
assert chunk
|
|
output.write(chunk)
|
|
remaining -= len(chunk)
|
|
with (work / 'after-guest-fsck.log').open('wb') as log:
|
|
subprocess.run([runner, 'e2fsck', '-f', '-n', str(settings)], check=True, stdout=log, stderr=subprocess.STDOUT)
|
|
previous = subprocess.check_output([runner, 'debugfs', '-R', 'cat /config/previous.json', str(settings)], stderr=subprocess.DEVNULL)
|
|
assert json.loads(previous)['name'] == 'FP-85'
|
|
|
|
# Both recovery and settings are present on USB, but only recovery may be selected
|
|
# by its GPT label. USB cannot supply persistent machine settings.
|
|
with VM(work, 'usb-lookalike', image, system_usb=True) as vm:
|
|
status = enter(vm)
|
|
assert status['source'] == 'image_defaults' and 'No complete internal NVMe' in status['error'], status
|
|
finish(vm)
|
|
|
|
# Boot the ordinary CLI SYSTEM with two eligible NVMes already present. Stage0
|
|
# has one SYSTEM to choose; the machine settings loader must reject ambiguity.
|
|
with VM(work, 'ambiguous-internal', project / 'out/fds-system-cli.img', extra=[*extra(image, 'first', True), *extra(image, 'second', True)]) as vm:
|
|
vm.expect(rb'FDS> ')
|
|
assert vm.capture('id -u') == '1000'
|
|
assert vm.capture('stat -c %u:%g:%a /') == '0:0:755'
|
|
wait(lambda: vm.capture('test -S /run/fds/control.sock && echo ready || echo pending'), lambda text: text == 'ready')
|
|
status = query(vm, ['machine', 'status'])
|
|
assert status['source'] == 'image_defaults' and 'Multiple internal NVMe' in status['error'], status
|
|
finish(vm)
|
|
print('PASS: USB settings spoof and two eligible NVMes fall back with explicit diagnostics', flush=True)
|
|
|
|
# Corrupt configuration and an unclean ext4 are distinct failure cases. Build
|
|
# fixtures by changing only the settings partition, retaining the packaged OS.
|
|
def altered(name, commands):
|
|
fs = work / (name + '.ext4')
|
|
shutil.copyfile(work / 'build-a/internal.ext4', fs)
|
|
script = work / (name + '.debugfs')
|
|
script.write_text('\n'.join(commands) + '\n')
|
|
subprocess.run([runner, 'debugfs', '-w', '-f', str(script), str(fs)], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
disk = copy_image(name)
|
|
with disk.open('r+b') as output, fs.open('rb') as source:
|
|
output.seek(part['start'] * 512)
|
|
shutil.copyfileobj(source, output)
|
|
return disk
|
|
bad_config = work / 'bad.json'
|
|
bad_config.write_text('{"format":1,"command":"sh"}\n')
|
|
fixtures = [('invalid-settings', altered('invalid-settings', ['rm /config/machine.json', f'write {bad_config} /config/machine.json', 'set_inode_field /config/machine.json uid 0', 'set_inode_field /config/machine.json gid 0']), 'Invalid machine settings'),
|
|
('writable-settings', altered('writable-settings', ['set_inode_field /config/machine.json mode 0100666']), 'root-owned regular'),
|
|
('unclean-settings', altered('unclean-settings', ['set_super_value state 0']), 'unclean'),
|
|
('symlink-settings', altered('symlink-settings', ['rm /config/machine.json', 'symlink /config/machine.json /etc/passwd']), '(os error 40)')]
|
|
for name, disk, expected in fixtures:
|
|
before = digest(disk)
|
|
with VM(work, name, empty, extra=extra(disk)) as vm:
|
|
status = enter(vm)
|
|
assert status['source'] == 'image_defaults' and expected in status['error'], status
|
|
vm.capture('test -z "$(findmnt -nr -o TARGET | grep /run/fds/machine/internal)"')
|
|
assert digest(disk) == before, 'Failure path wrote internal storage'
|
|
finish(vm)
|
|
print('PASS: invalid JSON, writable/symlink settings and unclean ext4 preserve bytes and leave the recovery console usable', flush=True)
|
|
link = project / 'out/m12-internal-latest.next'
|
|
link.symlink_to(work.name)
|
|
link.replace(project / 'out/m12-internal-latest')
|
|
print(f'PASS: internal storage software acceptance: {work}', flush=True)
|
|
print('SKIP: Pi EEPROM/NVMe boot, real bay calibration, power-loss and flash durability')
|