Package workstation tools, reuse emulator sessions, and derive build versions

This commit is contained in:
2026-09-23 03:45:48 +08:00
parent 6bfcce8070
commit f5d9f96409
79 changed files with 878 additions and 132 deletions
+2 -1
View File
@@ -1,6 +1,7 @@
[package]
name = "fds-test-helpers"
version = "0.1.0"
version.workspace = true
build = "../../tools/version-build.rs"
edition = "2024"
license = "MIT"
publish = false
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
"""Install the actual Arch package into a disposable pacman root, without host writes."""
from pathlib import Path
import json
import os
import subprocess
import tempfile
project=Path(__file__).resolve().parents[2]
package=Path((project/'out/workstation/package-path.txt').read_text().strip())
work=Path(tempfile.mkdtemp(prefix='arch-package-test.',dir=project/'out'))
root=work/'root';root.mkdir()
(root/'var/lib/pacman').mkdir(parents=True)
(root/'var/cache/pacman/pkg').mkdir(parents=True)
config=work/'pacman.conf'
config.write_text('[options]\nArchitecture = auto\nSigLevel = Never\nDownloadUser = root\n')
wrapper=work/'bin';wrapper.mkdir()
# Run the actual installer. In this private namespace it is already uid 0;
# redirect only pacman's installation root and disable dependency resolution.
(wrapper/'pacman').write_text('#!/bin/bash\nexec /usr/bin/pacman --config '+str(config)+' --root '+str(root)+' --dbpath '+str(root/'var/lib/pacman')+' --cachedir '+str(root/'var/cache/pacman/pkg')+' --logfile '+str(work/'pacman.log')+' --nodeps --nodeps --noconfirm "$@"\n')
(wrapper/'pacman').chmod(0o755)
command=['bwrap','--unshare-user','--uid','0','--gid','0','--ro-bind','/','/','--bind',str(work),str(work),'--dev','/dev','--proc','/proc','--setenv','PATH',str(wrapper)+':'+os.environ['PATH'],str(project/'tools/install-workstation')]
result=subprocess.run(command,capture_output=True,text=True)
(work/'install.log').write_text(result.stdout+result.stderr)
assert result.returncode==0,(result.stdout,result.stderr)
version=subprocess.check_output([str(project/'tools/version')],text=True).strip()
for tool in ['fds-cartridge','fds-emulator']:
output=subprocess.check_output([str(root/'usr/bin'/tool),'--version'],text=True).strip()
assert output==f'{tool} {version}',output
subprocess.run([str(root/'usr/bin'/tool),'--help'],check=True,stdout=subprocess.DEVNULL)
assert (root/'usr/share/doc/fds-tools/docs/workstation.md').is_file()
assert (root/'usr/share/licenses/fds-tools/RUST-NOTICES.txt').stat().st_size>0
installed=subprocess.check_output(['pacman','--config',str(config),'--root',str(root),'--dbpath',str(root/'var/lib/pacman'),'-Q','fds-tools'],text=True).strip()
assert installed==f'fds-tools {version}-1',installed
(work/'acceptance.json').write_text(json.dumps(dict(status='passed',package=str(package),installed=installed,installer='tools/install-workstation',host_install=False),indent=2)+'\n')
(project/'out/arch-package-current.txt').write_text(str(work)+'\n')
print('PASS: real pacman installation and both installed commands:',work)
+2 -1
View File
@@ -30,7 +30,8 @@ expect_failure 'unsupported bootstrap option' tools/bootstrap-host --unknown
# Use a disposable minimal checkout to exercise pin and dirty-tree detection.
mkdir -p "$scratch/repo/tools" "$scratch/repo/vendor/void-packages"
cp tools/lib.sh tools/prepare-void tools/prepare-void-workspace "$scratch/repo/tools/"
cp tools/lib.sh tools/prepare-void tools/prepare-void-workspace tools/version tools/fds_version.py "$scratch/repo/tools/"
printf "1.0\n" >"$scratch/repo/FDS_VERSION"
fake="$scratch/repo/vendor/void-packages"
git -C "$fake" init -q
printf '# fixture\n' >"$fake/xbps-src"
+1
View File
@@ -11,6 +11,7 @@ import sys
import tempfile
project = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(project / 'tools'))
loader = importlib.machinery.SourceFileLoader('fds_frozen_fixture', str(project / 'tools/frozen-inputs'))
spec = importlib.util.spec_from_loader(loader.name, loader)
frozen = importlib.util.module_from_spec(spec)
+11 -2
View File
@@ -33,7 +33,9 @@ def invoke(prefix, arguments, ok=True):
def check_all(directory, key, ok=True):
for label, command in commands:
result = invoke(command, ['verify', directory, '--key', key], ok)
if ok: assert 'VERIFIED FDS/OS 0.1.0' in result
if ok:
expected=json.loads((directory/'manifest.json').read_text())['version']
assert f'VERIFIED FDS/OS {expected}' in result
key_prefix = work / 'test-key'
invoke([str(host)], ['keygen', key_prefix])
@@ -55,6 +57,13 @@ manifest = {'format': 1, 'version': '0.1.0', 'source_epoch': 1,
invoke([str(host)], ['sign', release, '--key', private])
check_all(release, public)
invoke([str(host)], ['sign', release, '--key', private], False)
# Keep the historical release fixture, and also sign the current Git-derived identity.
current = work / 'current-version'; current.mkdir()
shutil.copy2(release / 'image.img', current / 'image.img')
current_manifest = dict(manifest, version=subprocess.check_output([str(project/'tools/version')], text=True).strip())
(current / 'manifest.json').write_text(json.dumps(current_manifest))
invoke([str(host)], ['sign', current, '--key', private])
check_all(current, public)
# Independent standard Ed25519 verification/signing over the exact domain prefix
# and raw manifest bytes. Test-only DER files are private and never printed.
@@ -111,7 +120,7 @@ for name in ['traversal', 'duplicate', 'unknown', 'wrong-version', 'unbounded']:
if name == 'traversal': value['files'][0]['name'] = '../image.img'
elif name == 'duplicate': value['files'].append(copy.deepcopy(value['files'][0]))
elif name == 'unknown': value['execute'] = 'sh'
elif name == 'wrong-version': value['version'] = '999'
elif name == 'wrong-version': value['version'] = '../invalid'
(bad / 'manifest.json').write_text(json.dumps(value) + (' ' * (1024 * 1024) if name == 'unbounded' else ''))
(bad / 'manifest.sig').write_bytes((release / 'manifest.sig').read_bytes())
check_all(bad, public, False)
+4 -3
View File
@@ -6,6 +6,7 @@ import subprocess
import tempfile
project = Path(__file__).resolve().parents[2]
version = subprocess.check_output([str(project/'tools/version')], text=True).strip()
def run(binary, *args, ok=True):
result = subprocess.run([str(project/'tools/in-void'), 'qemu-aarch64',
@@ -22,11 +23,11 @@ with tempfile.TemporaryDirectory(prefix='m3-', dir=project/'out') as directory:
for helper in ['fds', 'fds-burn', 'fds-inspect', 'fds-eject', 'fds-power',
'fds-stage0', 'fds-boottrace', 'fds-cartridged', 'fds-profile', 'fds-release']:
assert f'Usage: {helper}' in run(helper, '--help')
assert '0.1.0' in run(helper, '--version')
assert version in run(helper, '--version')
assert 'activate windowmaker' in run('fds-profile', '--help')
run('fds-profile', 'activate', 'windowmaker', ok=False)
assert '0.1.0' in run('fds', '--version')
assert '0.1.0' in run('fds-stage0', '--version')
assert version in run('fds', '--version')
assert version in run('fds-stage0', '--version')
run('fds-stage0', ok=False) # Must refuse root-switch operations outside PID 1.
assert json.loads(run('fds', '--json', 'info'))['target'] == 'aarch64 static-musl'
fixture = project/'tests/fixtures/manifests/windowmaker.toml'
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""Check release, development, dirty, exported and cached Cargo version behavior."""
from pathlib import Path
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
project = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(project/'tools'))
from fds_version import version
class Versions(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory(prefix='fds-version-')
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.git('init', '-q')
self.git('config', 'user.name', 'FDS test')
self.git('config', 'user.email', 'test@example.invalid')
(self.root/'source').write_text('first\n')
self.commit()
def git(self, *args):
return subprocess.check_output(['git', '-C', str(self.root), *args], text=True).strip()
def commit(self):
self.git('add', '.')
self.git('commit', '-qm', 'fixture')
def test_git_versions(self):
revision=self.git('rev-parse','--short=12','HEAD')
self.assertEqual(version(self.root), f'0.dev.g{revision}')
self.git('tag','unrelated')
self.assertEqual(version(self.root), f'0.dev.g{revision}')
self.git('tag','-a','1.0','-m','Release 1.0')
self.assertEqual(version(self.root),'1.0')
(self.root/'source').write_text('changed\n')
self.assertEqual(version(self.root),'1.0.dirty')
self.commit()
self.assertEqual(version(self.root),f'1.0.r1.g{self.git("rev-parse","--short=12","HEAD")}')
self.git('tag','v1.1')
self.assertEqual(version(self.root),'1.1')
(self.root/'new').write_text('new\n')
self.assertEqual(version(self.root),'1.1')
self.git('add', 'new')
self.assertEqual(version(self.root),'1.1.dirty')
def test_export_is_independent_of_containing_git(self):
self.git('tag','1.0')
exported=self.root/'export';exported.mkdir()
(exported/'FDS_VERSION').write_text('0.dev.gabcdef123456\n')
self.assertEqual(version(exported),'0.dev.gabcdef123456')
(exported/'FDS_VERSION').write_text('../bad\n')
with self.assertRaises(ValueError):version(exported)
(exported/'FDS_VERSION').unlink()
with self.assertRaises(FileNotFoundError):version(exported)
def test_cargo_rebuilds_on_new_tags_and_edits(self):
(self.root/'tools').mkdir()
for name in ['version','fds_version.py','version-build.rs']:
shutil.copy2(project/'tools'/name,self.root/'tools'/name)
crate=self.root/'rust/probe';(crate/'src').mkdir(parents=True)
(self.root/'Cargo.toml').write_text('[workspace]\nmembers=["rust/probe"]\nresolver="2"\n')
(self.root/'.gitignore').write_text('/target/\n__pycache__/\n')
(crate/'Cargo.toml').write_text('[package]\nname="probe"\nversion="0.0.0"\nedition="2024"\nbuild="../../tools/version-build.rs"\n')
(crate/'src/main.rs').write_text('fn main() { println!("{}", env!("FDS_BUILD_VERSION")); }\n')
self.commit()
def run():
return subprocess.check_output(['cargo','run','--quiet','--offline','-p','probe'],cwd=self.root,text=True).strip()
self.assertEqual(run(),version(self.root))
self.commit() # Cargo generated its lockfile on the first run.
self.git('tag','1.0')
self.assertEqual(run(),'1.0')
(self.root/'source').write_text('edited\n')
self.assertEqual(run(),'1.0.dirty')
self.git('checkout','--','source')
self.assertEqual(run(),'1.0')
self.git('tag','-d','1.0')
self.assertEqual(run(),version(self.root))
# A frozen source tree builds with its saved identity without Git.
with tempfile.TemporaryDirectory(prefix='fds-export-') as directory:
exported=Path(directory)/'source'
shutil.copytree(self.root,exported,ignore=shutil.ignore_patterns('.git','target','__pycache__'))
(exported/'FDS_VERSION').write_text('1.0\n')
output=subprocess.check_output(['cargo','run','--quiet','--offline','-p','probe'],cwd=exported,text=True).strip()
self.assertEqual(output,'1.0')
if __name__=='__main__': unittest.main()
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""Verify the current version in actual GPT/EROFS bytes and execute its ARM commands."""
from pathlib import Path
import hashlib
import json
import shlex
import subprocess
import sys
import tarfile
import tempfile
import tomllib
project=Path(__file__).resolve().parents[2]
sys.path.insert(0,str(project/'tools'))
from fds_version import version
expected=version(project)
image=(project/'out/fds-system-cli.img').resolve(strict=True)
work=Path(tempfile.mkdtemp(prefix='version-image-test.',dir=project/'out'))
table=json.loads(subprocess.check_output(['sfdisk','--json',str(image)]))['partitiontable']
assert len(table['partitions'])==1 and table['partitions'][0]['name']=='FDS_SYSTEM'
offset=table['partitions'][0]['start']*table['sectorsize']
def extract(path,name):
target=work/name
subprocess.run([str(project/'tools/in-image-tools'),'fsck.erofs',f'--offset={offset}',f'--path=/{path}',f'--extract={target}',str(image)],check=True)
return target
identity=extract('usr/lib/os-release','os-release').read_text()
values=dict(line.split('=',1) for line in shlex.split(identity,comments=True))
assert values['VERSION_ID']==values['VERSION']==expected,values
assert values['PRETTY_NAME']==f'FDS/OS {expected}',values
assert extract('etc/issue','issue').read_text()==f'FDS/OS {expected}\n'
metadata=tomllib.loads(extract('FDS/CARTRIDGE.TOML','CARTRIDGE.TOML').read_text())
assert metadata['cartridge']['version']==expected,metadata
with tarfile.open(project/'out/rootfs-cli.tar') as archive:
assert archive.getmember('etc/os-release').linkname=='../usr/lib/os-release'
assert archive.extractfile('usr/lib/os-release').read().decode()==identity
commands={}
for name in ['fds','fds-control','fds-program','fds-boottrace','fds-cartridged','fds-profile','fds-burn','fds-release','fds-inspect','fds-eject','fds-power','dasungd']:
binary=extract(f'usr/bin/{name}',name)
output=subprocess.check_output([str(project/'tools/in-void'),'qemu-aarch64',str(binary),'--version'],text=True).strip()
assert output==f'{name} {expected}',output
commands[name]=output
with image.open('rb') as stream:
checksum=hashlib.file_digest(stream,'sha256').hexdigest()
record=dict(status='passed',version=expected,image=str(image),image_sha256=checksum,identity=values,cartridge_version=metadata['cartridge']['version'],arm_commands=commands,physical_hardware=False)
(work/'acceptance.json').write_text(json.dumps(record,indent=2)+'\n')
(project/'out/version-image-current.txt').write_text(str(work)+'\n')
print(f'PASS: {expected} in GPT/EROFS identity, cartridge metadata and all installed FDS commands: {work}')
+29 -2
View File
@@ -104,13 +104,17 @@ def interactive_program_checks():
os.write(master, b'\x03')
until(b'RETURN:130\r\n')
until(b'FDS> ')
os.write(master, b"b01:demo.report:shell -c 'printf \"STOP:%s\\n\" ready; exec tail -f /dev/null'\n")
os.write(master, b"b01:demo.report:shell -c 'printf \"STOP:%s\\n\" ready; read -r resumed; printf \"RESUMED:%s\\n\" \"$resumed\"; exec tail -f /dev/null'\n")
until(b'STOP:ready\r\n')
os.write(master, b'\x1a')
until(b'Stopped')
until(b'FDS> ')
os.write(master, b'fg\n')
until(b"exec tail -f /dev/null'\r\n")
# The fg command echo precedes terminal handoff and SIGCONT. Require
# actual input/output from the resumed foreground program before Ctrl-C.
os.write(master, b'continue\n')
until(b'RESUMED:continue\r\n')
os.write(master, b'\x03')
until(b'FDS> ')
os.write(master, b"test \"$(stty -g)\" = \"$(cat /tmp/program-tty-before)\" && printf 'RESTORE:%s\\n' passed\n")
@@ -156,6 +160,9 @@ original_software = digest(software)
try:
report = json.loads(invoke('start', *start_options))
assert report['qemu']['running']
initial_state = (session / 'session.json').read_bytes()
invoke('start', *start_options, ok=False)
assert (session / 'session.json').read_bytes() == initial_state
assert guest('id', '-u').strip() == '1000'
master, slave = pty.openpty()
original_termios = termios.tcgetattr(slave)
@@ -280,6 +287,24 @@ try:
guest('fds', 'run', 12, '--', 'demo.report:report', 'hold')
invoke('stop')
assert 'FDS_SHUTDOWN_FINAL' in (session / 'console.log').read_text()
retained_log = (session / 'console.log').read_bytes()
retained_data = {p: digest(p) for p in session.glob('data-*.qcow2')}
assert retained_data
previous_state = json.loads((session / 'session.json').read_text())
restarted = json.loads(invoke('start', *start_options))
assert restarted['qemu']['running'] and restarted['state']['cartridges'] == {}
assert restarted['state']['name'] != previous_state['name']
assert any(json.loads(p.read_text()) == previous_state for p in session.glob('previous-*.json'))
assert (session / 'console.log').read_bytes().startswith(retained_log)
assert all(digest(p) == value for p, value in retained_data.items())
assert guest('id', '-u').strip() == '1000'
assert all(b['state'] == 'empty' for b in query('bays')['bays'])
invoke('insert', 1, software); wait_bay(1, 'mounted_read_only')
assert 'Hello from' in guest('hello', 'restarted')
invoke('stop', '--force')
assert json.loads(invoke('start', *start_options))['qemu']['running']
assert guest('id', '-u').strip() == '1000'
invoke('stop')
finally:
subprocess.run([cli, '--session', str(session), 'stop', '--force'], capture_output=True, timeout=40)
@@ -340,7 +365,9 @@ record = dict(status='passed', ordinary_guest_uid=1000, public_emulator_cli=True
safe_eject=True, forced_removal_stops_consumer=True,
corrupt_program_and_catalogue_rejected=True, data_overlay_preserves_source=True,
restart_cleans_mounts_commands_and_consumers=True, extra_mount_blocks_safe=True,
native_shutdown_with_active_software=True, physical_pi='not tested')
native_shutdown_with_active_software=True, same_directory_restart_after_stop=True,
same_directory_restart_after_force=True, restart_retains_logs_overlays_and_mapping=True,
live_session_restart_rejected=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)