Files
Verstack Local f73167de85 Add downloadable VMs and improve catalog processing
Package Apple Silicon VM exports and cached runtime switching with local cabinet controls and conagent provisioning. Preserve experimental SPIKE 2 emulation.

Improve preparation concurrency, Ghidra checkpoints, signature importing, sound indexing and client-side spectrograms. Include regression tests and validation notes.
2026-09-14 20:03:59 -05:00

145 lines
7.2 KiB
Python

#!/usr/bin/env python3
"""Materialize any extracted SPIKE runtime using verified archive files."""
import concurrent.futures
import json
import os
from pathlib import Path, PurePosixPath
import urllib.parse
import urllib.request
import subprocess
import uuid
import threading
import runtimes
def manifest(api, snapshot):
with urllib.request.urlopen(f'{api}/api/snapshots/{snapshot}', timeout=120) as response:
return json.load(response)
def file_url(api, snapshot, path):
return f'{api}/api/file/{snapshot}?' + urllib.parse.urlencode({'path': path})
def header(api, snapshot, path):
request = urllib.request.Request(file_url(api, snapshot, path), headers={'Range':'bytes=0-63'})
with urllib.request.urlopen(request, timeout=30) as response:
return response.read(64)
def discover(api, source, game_path=None):
files = {e['path']: e for e in source['entries'] if e['kind'] == 'file'}
candidates = [game_path] if game_path else [p for p in files if (p == 'game' or p.endswith('/game')) and '/spike_menu/' not in p and '/etc/' not in p]
games = []
for path in candidates:
if path not in files: raise ValueError('Selected game is absent from snapshot')
try: arch = runtimes.architecture(header(api, source['id'], path))
except ValueError: continue
games.append((path, arch))
if len(games) != 1: raise ValueError('Choose --game-path: expected exactly one ARM game executable')
game, arch = games[0]
return game, arch, discover_system(api, source, arch)
def discover_system(api, source, arch):
files = {e['path']: e for e in source['entries'] if e['kind'] == 'file'}
loaders = []
for path in files:
name = PurePosixPath(path).name
if '/lib/' in path and name.startswith('ld-') and '.so' in name:
try:
if runtimes.architecture(header(api, source['id'], path)) == arch:
loaders.append(str(PurePosixPath(path).parent.parent))
except ValueError: pass
roots = set(loaders)
system = next(iter(roots)) if len(roots) == 1 else None
return system
def materialize(api, destination, snapshot, system_snapshot=None, game_path=None):
uuid.UUID(snapshot)
source = manifest(api, snapshot)
game, arch, system = discover(api, source, game_path)
system_source = source
if system_snapshot:
system_source = manifest(api, system_snapshot)
system = discover_system(api, system_source, arch)
if not system: raise ValueError('Full SD system libraries are missing; supply --system-snapshot from a compatible SD extraction')
root = Path(destination) / snapshot
root.mkdir(parents=True, exist_ok=True)
game_root = str(PurePosixPath(game).parent)
entries = {}
for selected, prefix in ((system_source, system), (source, game_root)):
for entry in selected['entries']:
path = runtimes.safe_path(entry['path'])
if prefix != '.' and not str(path).startswith(prefix + '/'): continue
if entry['kind'] == 'directory': continue
if entry['kind'] != 'file': raise ValueError('Runtime symlinks must be reconstructed from verified files')
entries[entry['path']] = (selected['id'], entry)
# SPIKE 2 system menu/boot files can reside outside the game's directory.
targets = {'game':'game'}
files = {e['path'] for e in source['entries'] if e['kind']=='file'}
for name, suffix in (('spike-menu','spike3/spike_menu/game'),('boot-display','spike3/bin/boot_display')):
if game_root + '/' + suffix in files: targets[name] = suffix
system_files = {e['path'] for e in system_source['entries'] if e['kind']=='file'}
if arch == 'armhf':
for name, suffix in (('spike-menu','usr/local/spike/spike_menu/game'),('boot-display','usr/local/bin/boot_display')):
if system + '/' + suffix in system_files: targets[name] = 'system/' + suffix
# Shared system files and unchanged game assets need no new archive read.
# Verify the existing local file before hard-linking it into another runtime.
reusable = {}
for runtime in runtimes.inventory(destination):
for sid in {runtime.get('snapshot'), runtime.get('system_snapshot', runtime.get('snapshot'))}:
if not sid: continue
prior = manifest(api, sid)
for entry in prior['entries']:
if entry['kind'] == 'file':
local = Path(runtime['directory']) / entry['path']
if local.is_file(): reusable.setdefault(entry['artifact'], local)
link_lock = threading.Lock()
verified = set()
def copy(item):
sid, entry = item
target = root / entry['path']; target.parent.mkdir(parents=True, exist_ok=True)
expected = entry['artifact'].removeprefix('blake3:')
def digest(path): return subprocess.check_output(['b3sum', str(path)], text=True).split()[0]
if target.is_file() and target.stat().st_size == entry['size'] and digest(target) == expected: return
prior = reusable.get(entry['artifact'])
if prior and prior != target:
with link_lock:
if entry['artifact'] not in verified:
if prior.stat().st_size != entry['size'] or digest(prior) != expected:
raise ValueError('Existing runtime file failed artifact verification')
verified.add(entry['artifact'])
target.unlink(missing_ok=True)
os.link(prior, target)
return
temporary = target.with_name(target.name + '.download')
try:
with urllib.request.urlopen(file_url(api,sid,entry['path']),timeout=180) as response, temporary.open('wb') as output:
for block in iter(lambda: response.read(1024*1024), b''): output.write(block)
if temporary.stat().st_size != entry['size'] or digest(temporary) != expected:
raise ValueError('Artifact verification failed: ' + entry['path'])
temporary.chmod(entry['mode'] & 0o777);os.replace(temporary,target)
finally: temporary.unlink(missing_ok=True)
with concurrent.futures.ThreadPoolExecutor(max_workers=6) as pool:
for number,_ in enumerate(pool.map(copy,entries.values()),1):
if number % 100 == 0: print(f'Verified {number}/{len(entries)} files for {runtimes.label(source["release"])}',flush=True)
record = dict(schema=2,snapshot=snapshot,release=source['release'],architecture=arch,
game_root=game_root,system_root=system,system_snapshot=system_source['id'],targets=targets,files=len(entries))
temporary=root/'runtime.json.next';temporary.write_text(json.dumps(record));temporary.replace(root/'runtime.json')
print('Runtime materialization complete: '+runtimes.label(source['release']),flush=True)
return record
if __name__ == '__main__':
import argparse
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--api',default='http://127.0.0.1:8080')
parser.add_argument('--destination',default='/runtime')
parser.add_argument('--snapshot',required=True)
parser.add_argument('--system-snapshot')
parser.add_argument('--game-path')
args=parser.parse_args()
materialize(args.api,args.destination,args.snapshot,args.system_snapshot,args.game_path)