Package workstation tools, reuse emulator sessions, and derive build versions
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
@@ -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}')
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user