mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-11 11:01:07 +00:00
168 lines
7.6 KiB
Python
168 lines
7.6 KiB
Python
"""One entry point for runtime recipes, cache identities and verified job handoff."""
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path, PurePosixPath
|
|
import shutil
|
|
import subprocess
|
|
import tarfile
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
REGISTRY = 'tools/components/recipes.json'
|
|
TREES = ['app/src/main/assets/linuxfs', 'app/src/main/assets/directaudio',
|
|
'app/src/main/assets/droiddeck-esync', 'app/build/prepared-assets',
|
|
'app/build/component-records']
|
|
LIBRARIES = ['app/src/main/jniLibs/arm64-v8a/' + name for name in
|
|
('libproot.so', 'libproot-loader.so', 'libdirectaudiorelay.so')]
|
|
MANIFEST = 'app/build/prepared-components.json'
|
|
|
|
|
|
def digest(path):
|
|
with path.open('rb') as stream:
|
|
return hashlib.file_digest(stream, 'sha256').hexdigest()
|
|
|
|
|
|
def registry(root=ROOT):
|
|
return json.loads((root / REGISTRY).read_text())
|
|
|
|
|
|
def identity(name, root=ROOT):
|
|
spec = registry(root)[name]
|
|
paths = subprocess.check_output(['git', 'ls-files', '--cached', '--others', '--exclude-standard',
|
|
'--', *spec['inputs'], *spec.get('source_inputs', [])], cwd=root, text=True).splitlines()
|
|
values = {}
|
|
for name_ in sorted(set(paths)):
|
|
if '__pycache__' in name_ or name_.endswith(('.pyc', '.md')):
|
|
continue
|
|
content = (root / name_).read_bytes()
|
|
if b'\0' not in content:
|
|
content = content.replace(b'\r\n', b'\n')
|
|
values[name_] = hashlib.sha256(content).hexdigest()
|
|
policy = dict(spec=spec, sources=values, runner=os.environ.get('RUNNER_OS', 'Linux'),
|
|
architecture=os.environ.get('RUNNER_ARCH', 'X64'))
|
|
entrypoint = root / 'tools/components/prepare.py'
|
|
if entrypoint.is_file():
|
|
policy['entrypoint'] = hashlib.sha256(entrypoint.read_bytes().replace(b'\r\n', b'\n')).hexdigest()
|
|
installer = root / 'tools/ci/install-ubuntu-packages.sh'
|
|
if spec.get('packages') and installer.is_file():
|
|
policy['installer'] = hashlib.sha256(installer.read_bytes().replace(b'\r\n', b'\n')).hexdigest()
|
|
if spec.get('ndk'):
|
|
policy['ndk'] = os.environ.get('NDK_VERSION', '')
|
|
policy['ndk_policy'] = hashlib.sha256((root / 'tools/ci/toolchains.env').read_bytes().replace(b'\r\n', b'\n')).hexdigest()
|
|
return hashlib.sha256(json.dumps(policy, sort_keys=True).encode()).hexdigest()
|
|
|
|
|
|
def prepare(name, root=ROOT):
|
|
spec = registry(root)[name]
|
|
prepare_tools([name], root)
|
|
if 'command' in spec:
|
|
command = spec['command']
|
|
else:
|
|
script = (root / spec['script']).resolve()
|
|
if not script.is_relative_to((root / 'tools/components').resolve()):
|
|
raise ValueError('Recipe script escapes components directory')
|
|
command = ['bash', str(script)]
|
|
environment = dict(os.environ)
|
|
if spec.get('source'):
|
|
environment.update(COMPONENT_SOURCE_URL=spec['source']['url'],
|
|
COMPONENT_SOURCE_SHA256=spec['source']['sha256'])
|
|
subprocess.run(command, cwd=root, env=environment, check=True)
|
|
missing = [name_ for name_ in spec['outputs'] if not (root / name_).is_file()]
|
|
if missing:
|
|
raise ValueError(f'{name} did not prepare required outputs: {missing}')
|
|
record = dict(component=name, input_sha256=identity(name, root),
|
|
outputs={p: digest(root / p) for p in spec['outputs']})
|
|
destination = root / 'app/build/component-records' / (name + '.json')
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
destination.write_text(json.dumps(record, sort_keys=True, indent=2) + '\n')
|
|
|
|
|
|
def prepare_tools(names, root=ROOT):
|
|
packages = sorted({package for name in names for package in registry(root)[name].get('packages', [])})
|
|
if packages:
|
|
subprocess.run(['bash', 'tools/ci/install-ubuntu-packages.sh', *packages], cwd=root, check=True)
|
|
|
|
|
|
def files(root=ROOT):
|
|
result = []
|
|
for name in TREES:
|
|
folder = root / name
|
|
if not folder.is_dir() or folder.is_symlink():
|
|
raise ValueError('Missing prepared component tree: ' + name)
|
|
for path in folder.rglob('*'):
|
|
if path.is_symlink():
|
|
raise ValueError('Symlink in prepared components: ' + str(path))
|
|
if path.is_file():
|
|
result.append(path.relative_to(root).as_posix())
|
|
for name in LIBRARIES:
|
|
if not (root / name).is_file() or (root / name).is_symlink():
|
|
raise ValueError('Missing prepared component library: ' + name)
|
|
result.append(name)
|
|
return sorted(result)
|
|
|
|
|
|
def pack(destination, root=ROOT):
|
|
names = files(root)
|
|
tree = subprocess.check_output(['git', 'rev-parse', 'HEAD^{tree}'], cwd=root, text=True).strip()
|
|
data = dict(version=1, source_tree=tree, run=os.environ.get('GITHUB_RUN_ID', ''),
|
|
recipes={name: identity(name, root) for name in registry(root)},
|
|
files={name: digest(root / name) for name in names})
|
|
(root / MANIFEST).write_text(json.dumps(data, sort_keys=True, indent=2) + '\n')
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
with tarfile.open(destination, mode='w:gz', compresslevel=1) as archive:
|
|
for name in names + [MANIFEST]:
|
|
archive.add(root / name, arcname=name, recursive=False)
|
|
|
|
|
|
def unpack(archive_path, source_tree, root=ROOT):
|
|
total = 0
|
|
seen = set()
|
|
with tarfile.open(archive_path, mode='r:gz') as archive:
|
|
for entry in archive:
|
|
allowed = entry.name in LIBRARIES + [MANIFEST] or any(entry.name.startswith(p + '/') for p in TREES)
|
|
path = root / entry.name
|
|
total += entry.size
|
|
if (not allowed or '\\' in entry.name or '..' in PurePosixPath(entry.name).parts
|
|
or PurePosixPath(entry.name).as_posix() != entry.name
|
|
or not entry.isfile() or entry.name in seen or len(seen) >= 100000
|
|
or entry.size > 512 * 1024 * 1024
|
|
or total > 2 * 1024**3 or not path.resolve().is_relative_to(root.resolve())
|
|
or path.is_symlink()):
|
|
raise ValueError('Unsafe prepared component archive')
|
|
seen.add(entry.name)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with path.open('wb') as output:
|
|
shutil.copyfileobj(archive.extractfile(entry), output)
|
|
path.chmod(entry.mode & 0o777)
|
|
data = json.loads((root / MANIFEST).read_text())
|
|
actual = {name: digest(root / name) for name in files(root)}
|
|
recipes = {name: identity(name, root) for name in registry(root)}
|
|
if (data.get('version') != 1 or data['source_tree'] != source_tree or data['files'] != actual
|
|
or data['recipes'] != recipes):
|
|
raise ValueError('Prepared components do not match the pinned source tree and file hashes')
|
|
print('Prepared component source identity and all output hashes verified.')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument('recipe')
|
|
parser.add_argument('--archive', type=Path, default=ROOT / 'app/build/prepared-components.tar.gz')
|
|
parser.add_argument('--tree')
|
|
args = parser.parse_args()
|
|
if args.recipe == 'plan':
|
|
for name in registry():
|
|
value = identity(name)
|
|
print(f'{name}: {value}')
|
|
if os.environ.get('GITHUB_OUTPUT'):
|
|
with open(os.environ['GITHUB_OUTPUT'], 'a') as stream:
|
|
stream.write(f'{name}={value}\n')
|
|
elif args.recipe == 'tools':
|
|
prepare_tools(os.environ.get('COMPONENTS_TO_BUILD', '').split())
|
|
elif args.recipe == 'pack':
|
|
pack(args.archive)
|
|
elif args.recipe == 'unpack':
|
|
unpack(args.archive, args.tree)
|
|
else:
|
|
prepare(args.recipe)
|