VulpesOS/tundra/tools/build-system-image.py
OverSu b38dea0597 Publish Vulpes Preview 15 sources for desktop, Android and Tundra
Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
2026-09-30 23:13:03 +02:00

129 lines
6.5 KiB
Python

#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Assemble a private Sargo system image, including its Android HAL reference."""
import argparse
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
ROOT = Path(__file__).resolve().parents[1]
PROJECT = ROOT.parent
ANDROID_SHA = '132d0e18c6a6a111293447e940267f35aa2b14fa00a872ef160b20dc2014d886'
def digest(path):
with path.open('rb') as stream:
return hashlib.file_digest(stream, 'sha256').hexdigest()
def module(name, path):
spec = importlib.util.spec_from_file_location(name, path)
result = importlib.util.module_from_spec(spec)
spec.loader.exec_module(result)
return result
def pack(root, image):
if not os.environ.get('FAKEROOTKEY'):
raise RuntimeError('Image ownership must be prepared under fakeroot')
for directory, dirs, files in os.walk(root):
for path in [Path(directory), *[Path(directory)/n for n in dirs+files]]:
os.lchown(path, 0, 0)
# The trial session refreshes its local Gecko host files before starting.
for base in (root/'opt/vulpes', root/'home/droidian'):
for directory, dirs, files in os.walk(base):
for path in [Path(directory), *[Path(directory)/n for n in dirs+files]]:
os.lchown(path, 32011, 32011)
features = 'none,has_journal,ext_attr,dir_index,filetype,extent,sparse_super,large_file,uninit_bg,dir_nlink,extra_isize'
subprocess.run(['mke2fs', '-q', '-t', 'ext4', '-F', '-b', '4096', '-I', '256',
'-O', features, '-L', 'TUNDRA_SYSTEM', '-d', str(root), str(image)], check=True)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', type=Path, required=True)
parser.add_argument('--reference', type=Path, default=ROOT/'downloads/sargo-installed/runtime-reference-20260925')
parser.add_argument('--product', type=Path, default=ROOT/'out/droidian/product')
parser.add_argument('--pack', nargs=2, type=Path, help=argparse.SUPPRESS)
args = parser.parse_args()
if args.pack:
pack(*args.pack)
return
if os.geteuid() == 0:
parser.error('Build as a regular user')
android = ROOT/'downloads/sargo-installed/android-rootfs.img'
if digest(android) != ANDROID_SHA:
raise ValueError('Android HAL reference changed')
lock = json.loads((args.product/'engine-lock.json').read_text())
current = json.loads((PROJECT/'engine-lock.json').read_text())
if lock.get('architecture') != 'aarch64' or lock['version'] != current['version']:
raise ValueError('Prepare the matching ARM64 engine first')
output = args.output.resolve()
output.mkdir(parents=True, exist_ok=False)
root = output/'root'
staging = module('stage', ROOT/'tools/stage-runtime.py').stage(args.reference, root)
product = root/'opt/vulpes'
# Read UI/services from the common checkout, and only the engine/EGL adapter
# from the checked ARM64 product. Never copy profiles or phone configuration.
for name in ('assets', 'host', 'services', 'adapters', 'overrides', 'gaia/shared', 'gaia/apps/system'):
shutil.copytree(PROJECT/name, product/name, symlinks=True,
ignore=shutil.ignore_patterns('__pycache__', 'test', 'tests'))
for name in ('tools', 'tests', 'logs', 'profiles'):
(product/name).mkdir(exist_ok=True)
for name in ('serve-gaia.py', 'project.py', 'run-host.py'):
shutil.copy2(PROJECT/'tools'/name, product/'tools'/name)
shutil.copy2(PROJECT/'tests/ProbeChild.sys.mjs', product/'tests/ProbeChild.sys.mjs')
shutil.copy2(PROJECT/'release.json', product/'release.json')
shutil.copy2(args.product/'engine-lock.json', product/'engine-lock.json')
shutil.copytree(args.product/'engines', product/'engines', symlinks=True)
shutil.copytree(ROOT/'droidian', product/'trial', ignore=shutil.ignore_patterns('__pycache__'))
for name in ('libEGL_vulpes_hybris.so', 'manifest.json'):
shutil.copy2(args.product/'trial/graphics'/name, product/'trial/graphics'/name)
for name in ('home/droidian', 'etc/dbus-1', 'reference'):
(root/name).mkdir(parents=True, exist_ok=True)
shutil.copy2(android, root/'reference/android-rootfs.img')
module('native', ROOT/'runtime/prepare-native.py').prepare(root)
shutil.copy2(ROOT/'droidian/initialize-runtime.sh', root/'etc/tundra/initialize-runtime.sh')
# Caches are architecture dependent; generate them with the target binaries.
(root/'etc/systemd/system/tundra-initialize.service').write_text('''[Unit]
Description=Initialize Tundra runtime caches
Before=tundra-hal.service tundra-display.service
[Service]
Type=oneshot
ExecStart=/bin/sh /etc/tundra/initialize-runtime.sh
RemainAfterExit=yes
''')
for service in ('tundra-hal', 'tundra-display'):
folder = root/f'etc/systemd/system/{service}.service.d'
folder.mkdir(exist_ok=True)
(folder/'initialize.conf').write_text('[Unit]\nRequires=tundra-initialize.service\nAfter=tundra-initialize.service\n')
image = output/'tundra-sargo-system.ext4'
with image.open('xb') as stream:
stream.truncate(3*1024**3)
subprocess.run(['fakeroot', '--', sys.executable, str(Path(__file__).resolve()),
'--output', str(output), '--pack', str(root), str(image)], check=True)
check = subprocess.run(['e2fsck', '-f', '-n', str(image)], capture_output=True, text=True)
(output/'filesystem-check.txt').write_text(check.stdout+check.stderr)
if check.returncode:
raise RuntimeError('System image failed filesystem verification')
manifest = {'schema': 1, 'device': 'sargo', 'image': image.name,
'imageSha256': digest(image), 'bytes': image.stat().st_size,
'androidSha256': ANDROID_SHA, 'engine': lock,
'release': json.loads((product/'release.json').read_text()),
'staging': staging, 'filesystemCheckPassed': True,
'flashReady': False, 'hardwareBootTested': False,
'scope': 'Self-contained userspace; vendor/firmware partitions still required; RAM boot candidate',
'sourceHashes': {str(p.relative_to(PROJECT)): digest(p) for p in
[Path(__file__).resolve(), ROOT/'tools/stage-runtime.py',
*sorted((ROOT/'runtime').glob('*.py'))]}}
(output/'system.json').write_text(json.dumps(manifest, indent=2)+'\n')
print(image, manifest['imageSha256'], flush=True)
if __name__ == '__main__':
main()