228 lines
11 KiB
Python
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')
|