Files
fds-os/tests/integration/workstation-emulator.py
T
2026-09-21 22:29:23 +08:00

228 lines
11 KiB
Python

#!/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')