#!/usr/bin/env python3 """Actual public QEMU CLI, guest software, twelve ports and removal lifecycle.""" import argparse import io import json import os import pty import selectors import termios from pathlib import Path import subprocess import sys import tarfile 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 parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--cli', type=Path, required=True) parser.add_argument('--qemu-runner', type=Path) args = parser.parse_args() work = Path(tempfile.mkdtemp(prefix='emu-test.', dir=project / 'out')) builder = Path((project / 'out/workstation-images-current.txt').read_text().strip()) assert json.loads((builder / 'acceptance.json').read_text())['status'] == 'passed' software = builder / 'software.img' shared = builder / 'shared.img' log = (work / 'commands.log').open('w') cli = str(args.cli.resolve()) session = work / 'normal' start_options = ['--qemu-runner', str(args.qemu_runner.resolve())] if args.qemu_runner else [] inputs = [software, shared, project / 'out/fds-system-cli.img', project / 'out/fds-initramfs.img', project / 'out/kernel/boot/kernel_2712.img', Path(cli)] (work / 'inputs.sha256').write_text(''.join(f'{digest(path)} {path}\n' for path in inputs)) def invoke(*command, ok=True): result = subprocess.run([cli, '--session', str(session), *map(str, command)], capture_output=True, text=True, timeout=240) log.write(repr(command) + '\n' + result.stdout + result.stderr) log.flush() assert (result.returncode == 0) == ok, (command, result.returncode, result.stdout, result.stderr) return result.stdout def guest(*command, ok=True): return invoke('guest', '--', *command, ok=ok) def query(*command): return json.loads(guest('fds', '--json', *command)) def wait_bay(number, expected, timeout=60): deadline = time.monotonic() + timeout while True: state = query('bay', number)['bays'][0] if state['state'] == expected: return state assert state['state'] != 'error' or expected == 'error', state assert time.monotonic() < deadline, state def mounts(): return guest('cat', '/proc/self/mountinfo') def clean(number): text = mounts() assert f'/run/fds/software/{number:02}/' not in text assert '/run/fds/apps/demo.workstation' not in text # A workstation-created disposable DATA fixture, never a host block device. data_root = work / 'data-root' (data_root / 'FDS').mkdir(parents=True) (data_root / 'FDS/CARTRIDGE.TOML').write_text('format=1\n[cartridge]\nid="demo.data"\nname="Emulator DATA"\nclass="data"\nversion="1"\n[media]\nwritable=true\n') filesystem = work / 'data.ext4' with filesystem.open('xb') as stream: stream.truncate(32 * 1024 * 1024) subprocess.run([str(project / 'tools/in-image-tools'), 'mke2fs', '-q', '-t', 'ext4', '-b', '4096', '-L', 'FDS_DATA', '-E', 'lazy_itable_init=0,lazy_journal_init=0,root_owner=1000:1000', '-d', str(data_root), str(filesystem)], check=True) data = work / 'data.img' gpt(data, [('FDS_DATA', LINUX_FILESYSTEM, filesystem)]) original_data = digest(data) original_software = digest(software) try: report = json.loads(invoke('start', *start_options)) assert report['qemu']['running'] assert guest('id', '-u').strip() == '1000' master, slave = pty.openpty() original_termios = termios.tcgetattr(slave) console = subprocess.Popen([cli, '--session', str(session), 'console'], stdin=slave, stdout=slave, stderr=slave) selector = selectors.DefaultSelector(); selector.register(master, selectors.EVENT_READ) def console_until(marker): output = bytearray(); deadline = time.monotonic() + 20 while marker not in output: assert time.monotonic() < deadline, output for _, _ in selector.select(max(0, deadline-time.monotonic())): chunk = os.read(master, 8192); assert chunk output.extend(chunk) return output try: console_until(b'FDS> ') os.write(master, b'id -u\n') assert b'1000' in console_until(b'\r\n1000\r\n') os.write(master, b'\x1d') assert console.wait(timeout=10) == 0 assert termios.tcgetattr(slave) == original_termios assert json.loads(invoke('status'))['qemu']['running'] finally: if console.poll() is None: console.kill(); console.wait() selector.close(); os.close(master); os.close(slave) assert all(b['state'] == 'empty' for b in query('bays')['bays']) invoke('insert', 13, software, ok=False) invoke('insert', 1, '/dev/null', ok=False) assert guest('printf', '%s', 'literal $(id); and spaces') == 'literal $(id); and spaces' assert guest('printf', '%s', 'Kernel panic / FDS_STAGE0_ERROR: is just guest data') == 'Kernel panic / FDS_STAGE0_ERROR: is just guest data' guest('false', ok=False) for number in range(1, 13): invoke('insert', number, shared if number == 6 else software) state = wait_bay(number, 'mounted_read_only') assert state['devices'][0]['serial'] == f'FDS-{number:02}' assert state['devices'][0]['topology'].endswith('/' + str(number)) catalogue = state['software']['software'] assert [s['partition'] for s in catalogue] == ([2, 2] if number == 6 else [2, 3]) invoke('insert', number, software, ok=False) if number in [1, 6, 12]: guest('fds', 'run', number, '--', 'demo.hello:hello') guest('fds', 'run', number, '--', 'demo.report:report') guest('touch', f'/run/fds/software/{number:02}/cache-demo.hello/unexpected', ok=False) guest('fds', 'run', number, '--', '../escape', ok=False) guest('fds', 'run', number, '--', 'demo.hello:missing', ok=False) if number == 6: guest('fds', 'run', number, '--', 'demo.report:report', 'hold') assert query('bay', number)['bays'][0]['consumers'] > 0 invoke('unplug', number) else: invoke('eject', number) state = wait_bay(number, 'empty') assert state['consumers'] == 0 clean(number) remaining = json.loads(invoke('status')) assert remaining['state']['cartridges'] == {} assert len(remaining['block_nodes']) == 2, remaining['block_nodes'] output = guest('cat', '/run/log/cartridged/current') assert 'FDS cartridge AArch64 hello' in output assert 'FDS cartridge script report' in output assert 'uid=1000(fds)' in output (work / 'program-output.log').write_text(output) invoke('insert', 3, builder / 'archive-corrupt.img') wait_bay(3, 'mounted_read_only') failure = guest('fds', 'run', 3, '--', 'demo.hello:hello', ok=False) assert 'digest' in failure.lower() or 'sha' in failure.lower(), failure assert '/run/fds/software/03/cache-' not in mounts() invoke('eject', 3) wait_bay(3, 'empty') invoke('insert', 4, builder / 'catalogue-mapping.img') wait_bay(4, 'error') invoke('eject', 4, ok=False) invoke('unplug', 4) wait_bay(4, 'empty') clean(4) inserted = json.loads(invoke('insert', 2, data)) overlay = Path(inserted['inserted']['overlay']) assert overlay.is_file() and overlay.suffix == '.qcow2' wait_bay(2, 'mounted_read_write') guest('sh', '-c', 'printf "overlay survived safe eject\\n" >/data/workstation.txt') assert guest('cat', '/data/workstation.txt').strip() == 'overlay survived safe eject' invoke('eject', 2) wait_bay(2, 'empty') assert digest(data) == original_data assert overlay.is_file() assert len(json.loads(invoke('status'))['block_nodes']) == 2 # Keep a verified software cache mounted across native shutdown. invoke('insert', 12, software) wait_bay(12, 'mounted_read_only') guest('fds', 'run', 12, '--', 'demo.report:report', 'hold') invoke('stop') assert 'FDS_SHUTDOWN_FINAL' in (session / 'console.log').read_text() finally: subprocess.run([cli, '--session', str(session), 'stop', '--force'], capture_output=True, timeout=40) # Isolated admin console fixture for service interruption and recovery. The # preceding normal-image tests used UID 1000; production login is unchanged. archive = work / 'admin.tar' with tarfile.open(project / 'out/rootfs-cli.tar') as source, tarfile.open(archive, 'w', format=tarfile.PAX_FORMAT) as target: name = 'usr/libexec/fds/console-session' assert source.getmember(name) for member in source: if member.name == name: continue target.addfile(member, source.extractfile(member) if member.isfile() else None) member = tarfile.TarInfo(name) payload = b"#!/bin/bash\n# Isolated workstation lifecycle test only.\nexec env HOME=/root PS1='FDS> ' bash --noprofile --norc\n" member.size = len(payload); member.mode = 0o755 target.addfile(member, io.BytesIO(payload)) admin = work / 'admin-image'; admin.mkdir() with (work / 'admin-build.log').open('w') as build_log: subprocess.run([str(project / 'image/build-system-cartridge'), '--rootfs', str(archive), '--output-directory', str(admin)], check=True, stdout=build_log, stderr=subprocess.STDOUT) session = work / 'admin' try: invoke('start', '--system', admin / 'system.img', *start_options) assert guest('id', '-u').strip() == '0' invoke('insert', 1, software) wait_bay(1, 'mounted_read_only') guest('fds', 'run', 1, '--', 'demo.report:report', 'hold') assert '/run/fds/software/01/cache-demo.report' in mounts() guest('s6-rc', '-l', '/run/s6-rc', '-d', 'change', 'cartridged') guest('s6-rc', '-l', '/run/s6-rc', '-u', 'change', 'cartridged') wait_bay(1, 'mounted_read_only') assert query('bay', 1)['bays'][0]['consumers'] == 0 assert '/run/fds/software/01/cache-' not in mounts() guest('fds', 'run', 1, '--', 'demo.hello:hello') # Extra payload aliases must prevent SAFE; cache is genuinely read-only. guest('mkdir', '/run/extra-payload') guest('mount', '--bind', '/run/fds/software/01/payload02', '/run/extra-payload') invoke('eject', 1, ok=False) guest('umount', '/run/extra-payload') invoke('eject', 1) wait_bay(1, 'empty') clean(1) guest('fds-burn', 'create', 'program', '/tmp', '/tmp/program.img', ok=False) invoke('stop') finally: subprocess.run([cli, '--session', str(session), 'stop', '--force'], capture_output=True, timeout=40) assert digest(software) == original_software and digest(data) == original_data record = dict(status='passed', ordinary_guest_uid=1000, public_emulator_cli=True, all_twelve_usb_bays=True, interactive_console_detach_and_terminal_restore=True, both_payload_partitions_executed=True, shared_partition_executed=True, readonly_cache=True, safe_eject=True, forced_removal_stops_consumer=True, corrupt_archive_and_catalogue_rejected=True, data_overlay_preserves_source=True, restart_cleans_cache_and_consumers=True, extra_mount_blocks_safe=True, native_shutdown_with_active_software=True, physical_pi='not tested') (work / 'acceptance.json').write_text(json.dumps(record, indent=2) + '\n') (project / 'out/workstation-emulator-current.txt').write_text(str(work) + '\n') print('PASS: public emulator, guest software and lifecycle acceptance:', work) print('SKIP: physical Pi, USB power-cycle behavior and timing require hardware')