This commit is contained in:
1 parent
15a968a2f7
commit
b52921e171
38 files changed
+564
-2956
No files matched your search
+6
-13
@@ -7,9 +7,14 @@ from datetime import datetime, timezone
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
COMMIT = '962fb850324b79bbe5480e1c2087d98f83a60917'
|
||||
REPOSITORY = 'reyandme/kam_remake'
|
||||
VERSION = '0.4.0'
|
||||
CPUS = {'aarch64': ('arm64-v8a', 183, 2, 8), 'arm': ('armeabi-v7a', 40, 1, 4),
|
||||
'x86_64': ('x86_64', 62, 2, 8)}
|
||||
CURRENT_OUTPUT_DIR = ROOT / 'out/current'
|
||||
CURRENT_BUILD_METADATA = CURRENT_OUTPUT_DIR / 'build.json'
|
||||
CURRENT_PACKAGE_METADATA = CURRENT_OUTPUT_DIR / 'package.json'
|
||||
CURRENT_APK = CURRENT_OUTPUT_DIR / 'kam-mobile.apk'
|
||||
CURRENT_BUILD_SCHEMA = 'kam-android-build/2'
|
||||
CURRENT_PACKAGE_SCHEMA = 'kam-android-package/1'
|
||||
|
||||
class ProbeError(RuntimeError):
|
||||
pass
|
||||
@@ -66,15 +71,3 @@ def first_errors(log: str, limit: int = 12) -> list[str]:
|
||||
rows = [s.strip() for s in log.splitlines() if re.search(
|
||||
r'\b(fatal|error|undefined reference|cannot find|can.t find|not found)\b', s, re.I)]
|
||||
return rows[:limit]
|
||||
|
||||
def classify(log: str) -> str:
|
||||
"""Triage hint, never an automatic decision to rewrite a subsystem."""
|
||||
if re.search(r'cannot find.*(ppcross|ppca64|ppcarm)|compiler.*not found|SDK.*not found', log, re.I):
|
||||
return 'TOOLCHAIN'
|
||||
if re.search(r"can.t find unit (Windows|Forms|Controls|Graphics|LCL\w*|Dialogs|ShellAPI)", log, re.I):
|
||||
return 'PLATFORM_BOUNDARY'
|
||||
if re.search(r'undefined reference|cannot find -l|error while linking', log, re.I):
|
||||
return 'NATIVE_LINK_DEPENDENCY'
|
||||
if re.search(r"can.t find unit|cannot open include file", log, re.I):
|
||||
return 'SOURCE_OR_PACKAGE_DEPENDENCY'
|
||||
return 'SOURCE_OR_TOOLCHAIN_REVIEW'
|
||||
@@ -1,84 +0,0 @@
|
||||
"""Collect Task 1 decision-probe evidence. Does not choose architecture."""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
from pathlib import Path
|
||||
from common import ROOT, write_json
|
||||
|
||||
QUESTION_MAP={
|
||||
'T1-RP01-Q01':['oq_host_view','oq_host_callback'],
|
||||
'T1-RP01-Q02':['decision.schedule.callback_progress','decision.schedule.worker_progress','decision.schedule.lifecycle'],
|
||||
'T1-RP01-Q03':['decision.persistence.rename_replace','decision.persistence.append_reopen'],
|
||||
'T1-RP02-Q02':['decision.text.cwstring_unit','decision.text.cp1251_decode','decision.text.cp1251_encode'],
|
||||
'T1-Q01':['decision.fixture.castle_stream','decision.fixture.materialized_native_path','fixture_staging_driver'],
|
||||
}
|
||||
|
||||
def load(path:Path):
|
||||
return json.loads(path.read_text(encoding='utf-8'))
|
||||
|
||||
def main()->int:
|
||||
latest_path=ROOT/'reports/LATEST.json'
|
||||
if not latest_path.is_file():
|
||||
raise SystemExit('reports/LATEST.json not found; run build first')
|
||||
latest=load(latest_path);run_id=latest['run_id'];run_dir=ROOT/latest['directory'];build=load(run_dir/'result.json')
|
||||
devices=[]
|
||||
for result_path in sorted((ROOT/'reports/device').glob('*/device-result.json')):
|
||||
try:rec=load(result_path)
|
||||
except Exception:continue
|
||||
if rec.get('build_id')!=run_id and rec.get('package',{}).get('build_id')!=run_id:continue
|
||||
runtime_path=result_path.with_name('runtime.json')
|
||||
runtime=load(runtime_path) if runtime_path.is_file() else None
|
||||
devices.append({'directory':str(result_path.parent.relative_to(ROOT)),'result':rec,'runtime':runtime})
|
||||
out={'schema':'kam-task1-decision-evidence/2','build_id':run_id,'gate':build.get('gate'),
|
||||
'environment':{'toolchain':build.get('toolchain')},'questions':QUESTION_MAP,'profiles':{},'device_runs':[]}
|
||||
out['infrastructure_files_changed']=['tools/runner.py','tools/artifacts.py','tools/device.py','tools/decision_report.py',
|
||||
'config/profiles.json','code/GameViewMain.pas','code/ProbeReport.pas','CODEX_RUN_TASK1_DECISION_TESTS.md','tests/test_probe.py','tests/test_pipeline.py']
|
||||
for row in build.get('profiles',[]):
|
||||
if row.get('id') in ('platform','oq_host_view','oq_host_callback'):
|
||||
out['profiles'][row['id']]={'compile_link':row.get('compile_link'),'package':row.get('package'),'apk':row.get('apk'),'errors':row.get('errors',[])}
|
||||
for d in devices:
|
||||
rec=d['result'];runtime=d['runtime'];entry={'directory':d['directory'],'profile':rec.get('profile',rec.get('package',{}).get('profile')),
|
||||
'status':rec.get('status'),'errors':rec.get('errors',[]),'preflight':rec.get('preflight'),'infrastructure_blocker':rec.get('infrastructure_blocker'),'fixture_staging_driver':rec.get('fixture_staging_driver')}
|
||||
if runtime:
|
||||
entry['observations']=[x for x in runtime.get('tests',[]) if str(x.get('id','')).startswith('decision.')]
|
||||
entry['session']=runtime.get('session')
|
||||
out['device_runs'].append(entry)
|
||||
out['history']=[d for d in out['device_runs'] if d.get('status')!='AUTOMATIC_SMOKE_PASS']
|
||||
valid_runs=[d for d in out['device_runs'] if d.get('status')=='AUTOMATIC_SMOKE_PASS']
|
||||
observations={}
|
||||
for d in valid_runs:
|
||||
for item in d.get('observations',[]):observations[item.get('id')]=item
|
||||
if d.get('fixture_staging_driver') is not None:observations['fixture_staging_driver']=d['fixture_staging_driver']
|
||||
blocker=next((d.get('infrastructure_blocker',{}).get('reason') for d in reversed(out['device_runs']) if d.get('infrastructure_blocker')),None)
|
||||
out['decision_test_results']=[{'question_id':q,'test':test,'status':observations.get(test,{}).get('status','NOT_RUN'),
|
||||
'evidence':observations.get(test),'blocker':None if test in observations else blocker} for q,tests in QUESTION_MAP.items() for test in tests]
|
||||
json_path=ROOT/'reports/TASK1-DECISION-EVIDENCE.json';write_json(json_path,out)
|
||||
lines=['# Task 1 — decision-probe evidence','',f"Build ID: `{run_id}`",'',
|
||||
'Этот отчёт фиксирует наблюдения и не закрывает архитектурные вопросы Task 1.','',
|
||||
'## Environment','',f"- Toolchain receipt: `reports/runs/{run_id}/toolchain.json`",'- Docker command receipts are retained under `reports/runs/*/docker-command.json`.','',
|
||||
'## Infrastructure files changed','']
|
||||
lines += [f'- `{path}`' for path in out['infrastructure_files_changed']]
|
||||
lines += ['','## Build/package','', '| Profile | Compile/link | APK | Native library inside APK |','|---|---|---|---|']
|
||||
for p in ('platform','oq_host_view','oq_host_callback'):
|
||||
r=out['profiles'].get(p,{});apk=r.get('apk') or {};lines.append(f"| {p} | {r.get('compile_link',{}).get('status','NOT_RUN')} | {r.get('package',{}).get('status','NOT_RUN')} | `{apk.get('main_library_path','')}` |")
|
||||
lines+=['','## Device observations','']
|
||||
if not valid_runs:lines.append('Valid device runs for this build: NOT_RUN.')
|
||||
for d in valid_runs:
|
||||
lines.append(f"### {d.get('profile')} — {d.get('status')}")
|
||||
lines.append('')
|
||||
if d.get('fixture_staging_driver') is not None:lines.append('fixture_staging_driver: `'+json.dumps(d['fixture_staging_driver'],ensure_ascii=False)+'`')
|
||||
if d.get('preflight') is not None:lines.append('preflight: `'+json.dumps(d['preflight'],ensure_ascii=False)+'`')
|
||||
if d.get('infrastructure_blocker') is not None:lines.append('infrastructure_blocker: `'+json.dumps(d['infrastructure_blocker'],ensure_ascii=False)+'`')
|
||||
for obs in d.get('observations',[]):lines.append(f"- `{obs.get('id')}` — `{obs.get('status')}` — {obs.get('detail','')}")
|
||||
if d.get('errors'):
|
||||
for e in d['errors']:lines.append('- error: '+str(e))
|
||||
lines.append('')
|
||||
if out['history']:
|
||||
lines+=['## Historical blocked/failed attempts','']
|
||||
for d in out['history']:
|
||||
lines.append(f"- `{d.get('directory')}` / {d.get('profile')} — {d.get('status')}: {d.get('infrastructure_blocker') or d.get('errors')}")
|
||||
lines+=['## Decision-test results','', '| Question / test | Status | Blocker |','|---|---|---|']
|
||||
for item in out['decision_test_results']:lines.append(f"| {item['question_id']} / {item['test']} | {item['status']} | {item['blocker'] or ''} |")
|
||||
md_path=ROOT/'reports/TASK1-DECISION-EVIDENCE.md';md_path.write_text('\n'.join(lines)+'\n',encoding='utf-8')
|
||||
print(md_path);print(json_path);return 0
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
Executable → Regular
+70
-119
@@ -1,134 +1,85 @@
|
||||
"""Install and collect a selected Android decision probe with diagnostic evidence."""
|
||||
"""Install and launch the current Android application package."""
|
||||
from __future__ import annotations
|
||||
import argparse, json, re, shutil, subprocess, time
|
||||
import argparse, json, shutil, subprocess, xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
from common import ROOT, CPUS, ProbeError, safe_relative, sha256, stamp, write_json
|
||||
|
||||
DECISION_PROFILES={'oq_host_view','oq_host_callback'}
|
||||
from common import (ROOT, CPUS, ProbeError, sha256, CURRENT_BUILD_METADATA,
|
||||
CURRENT_PACKAGE_METADATA, CURRENT_APK, CURRENT_BUILD_SCHEMA,
|
||||
CURRENT_PACKAGE_SCHEMA)
|
||||
|
||||
class Adb:
|
||||
def __init__(self,serial:str|None): self.base=['adb']+(['-s',serial] if serial else [])
|
||||
def __init__(self, serial: str | None): self.base=['adb']+(['-s',serial] if serial else [])
|
||||
def run(self,*args:str,check:bool=True,timeout:int=120)->subprocess.CompletedProcess:
|
||||
p=subprocess.run(self.base+list(args),capture_output=True,timeout=timeout)
|
||||
if check and p.returncode: raise ProbeError('ADB command failed: '+' '.join(args)+'\n'+p.stderr.decode(errors='replace'))
|
||||
return p
|
||||
def text(self,*args:str,check:bool=True)->str: return self.run(*args,check=check).stdout.decode(errors='replace').strip()
|
||||
|
||||
def own_report_paths(text:str)->list[str]:
|
||||
paths=[]
|
||||
for line in text.splitlines():
|
||||
name=line.strip()[2:] if line.strip().startswith('./') else line.strip()
|
||||
try: safe_relative(name)
|
||||
except ProbeError: continue
|
||||
if name.endswith('kam-architecture-probe/runtime.json'): paths.append(name)
|
||||
return paths
|
||||
def fail(message: str) -> None: raise ProbeError(message)
|
||||
|
||||
def validate_runtime(data:dict,package:dict,require_frames:bool=True)->list[str]:
|
||||
errors=[]
|
||||
for key,value in (('schema','kam-android-runtime/1'),('build_id',package['build_id']),('profile',package['profile']),('cpu_expected',package['cpu'])):
|
||||
if data.get(key)!=value: errors.append('Wrong/stale runtime '+key)
|
||||
if data.get('automatic_smoke_status')!='PASS': errors.append('Automatic smoke did not pass')
|
||||
tests=data.get('tests')
|
||||
if not isinstance(tests,list) or not tests: return errors+['No runtime tests']
|
||||
by_id={x.get('id'):x for x in tests if isinstance(x,dict)}; profile=package['profile']; required=['android.startup']; observed=[]
|
||||
if profile not in DECISION_PROFILES:
|
||||
required += ['abi.pointer','abi.scalar_layout','abi.endianness','files.native_utf8','files.seek','files.case_sensitive','fpc.zstream','fpc.thread']
|
||||
observed += ['decision.persistence.rename_replace','decision.persistence.append_reopen']
|
||||
if profile=='oq_host_view': required += ['decision.host.view']
|
||||
elif profile=='oq_host_callback': required += ['decision.host.callback']
|
||||
if profile=='platform': required += ['decision.text.cwstring_unit']; observed += ['decision.text.cp1251_decode','decision.text.cp1251_encode']
|
||||
if require_frames: required += ['render.frame_callbacks','render.context']
|
||||
for name in required:
|
||||
if by_id.get(name,{}).get('status')!='PASS': errors.append('Required runtime check did not pass: '+name)
|
||||
for name in observed:
|
||||
if name not in by_id: errors.append('Decision observation missing: '+name)
|
||||
if any(t.get('status')=='FAIL' for t in tests if isinstance(t,dict)): errors.append('One or more runtime checks failed')
|
||||
if require_frames and data.get('session',{}).get('frames',0)<30: errors.append('Fewer than 30 frames')
|
||||
return errors
|
||||
def load_json(path: Path, missing: str) -> dict:
|
||||
try: value=json.loads(path.read_text())
|
||||
except FileNotFoundError: fail(missing)
|
||||
except (OSError, ValueError) as error: fail(f'Malformed {path.name}: {error}')
|
||||
if not isinstance(value,dict): fail(f'Malformed {path.name}')
|
||||
return value
|
||||
|
||||
def read_runtime(adb:Adb,pkg:str)->tuple[dict|None,str|None]:
|
||||
paths=own_report_paths(adb.text('shell','run-as',pkg,'find','.','-name','runtime.json',check=False))
|
||||
if len(paths)!=1: return None,None
|
||||
raw=adb.run('exec-out','run-as',pkg,'cat',paths[0],check=False).stdout
|
||||
try: return json.loads(raw),paths[0]
|
||||
except (ValueError,UnicodeDecodeError): return None,None
|
||||
|
||||
def wait_runtime(adb:Adb,pkg:str,predicate,timeout:int)->tuple[dict|None,str|None]:
|
||||
end=time.monotonic()+timeout
|
||||
while time.monotonic()<end:
|
||||
data,path=read_runtime(adb,pkg)
|
||||
if data is not None and predicate(data): return data,path
|
||||
time.sleep(1)
|
||||
return read_runtime(adb,pkg)
|
||||
|
||||
def save_command(out:Path,name:str,p:subprocess.CompletedProcess)->None:
|
||||
out.joinpath(name).write_text('exit='+str(p.returncode)+'\nstdout:\n'+p.stdout.decode(errors='replace')+'\nstderr:\n'+p.stderr.decode(errors='replace'),encoding='utf-8')
|
||||
|
||||
def main()->int:
|
||||
ap=argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument('--profile',default='platform');ap.add_argument('--serial');ap.add_argument('--index',type=Path,default=ROOT/'dist/LATEST.json')
|
||||
ap.add_argument('--seconds',type=int,default=30);ap.add_argument('--exercise-lifecycle',action='store_true');ap.add_argument('--stage-file',type=Path)
|
||||
a=ap.parse_args()
|
||||
if a.seconds<5: ap.error('--seconds must be at least 5')
|
||||
out=ROOT/'reports/device'/stamp(); n=1
|
||||
while out.exists(): out=ROOT/'reports/device'/(stamp()+'-'+str(n));n+=1
|
||||
out.mkdir(parents=True)
|
||||
record={'schema':'kam-android-device/2','status':'BLOCKED','errors':[],'preflight':{'status':'NOT_RUN'},'manual_visual_touch_audio':'NOT_VERIFIED','world_simulation':'NOT_RUN','command_environment':{'host_adb':shutil.which('adb'),'timeout_seconds':a.seconds}}
|
||||
adb=None
|
||||
def manifest_identity() -> dict:
|
||||
try:
|
||||
index=json.loads(a.index.read_text()); matches=[p for p in index.get('packages',[]) if p.get('profile')==a.profile]
|
||||
if len(matches)!=1: raise ProbeError('No unique successfully-built APK for '+a.profile)
|
||||
package=matches[0]; apk=ROOT/package['file']; pkg=package['package'];record.update(build_id=package['build_id'],profile=a.profile,package=package)
|
||||
if not apk.is_file() or sha256(apk)!=package['sha256']: raise ProbeError('APK missing/changed')
|
||||
if not re.fullmatch(r'org\.kammobile\.archprobe\.[a-z0-9]+',pkg): raise ProbeError('Refusing unrelated Android package')
|
||||
if not shutil.which('adb'): record['preflight']={'status':'BLOCKED','reason':'adb is not installed on host'};raise ProbeError(record['preflight']['reason'])
|
||||
listed=Adb(None).text('devices').splitlines()[1:]; devices=[line.split()[:2] for line in listed if line.strip()]
|
||||
record['preflight']={'adb':'PASS','devices':devices}
|
||||
if len(devices)!=1 or len(devices[0])!=2 or devices[0][1]!='device': record['preflight'].update(status='BLOCKED',reason='Require exactly one authorized device; found '+repr(devices));raise ProbeError(record['preflight']['reason'])
|
||||
if a.serial and a.serial!=devices[0][0]: record['preflight'].update(status='BLOCKED',reason='Requested serial is not the sole authorized device');raise ProbeError(record['preflight']['reason'])
|
||||
adb=Adb(devices[0][0]); record['device']={'serial':devices[0][0],'abi':adb.text('shell','getprop','ro.product.cpu.abilist'),'android':adb.text('shell','getprop','ro.build.version.release'),'sdk':adb.text('shell','getprop','ro.build.version.sdk'),'model':adb.text('shell','getprop','ro.product.model')}
|
||||
if CPUS[package['cpu']][0] not in record['device']['abi'].split(','): record['preflight'].update(status='BLOCKED',reason='Device ABI does not support arm64-v8a');raise ProbeError(record['preflight']['reason'])
|
||||
record['preflight']['status']='PASS'
|
||||
install=adb.run('install','-r',str(apk),check=False);save_command(out,'install.log',install);record['install']={'package_id':pkg,'initial_exit':install.returncode,'result':install.stdout.decode(errors='replace').strip()}
|
||||
root=ET.parse(ROOT/'app/CastleEngineManifest.xml').getroot(); version=root.find('version')
|
||||
if root.tag!='project' or version is None: fail('Malformed permanent manifest')
|
||||
return {'name':root.attrib['name'],'package':root.attrib['qualified_name'],
|
||||
'version':version.attrib['value'],'version_code':int(version.attrib['code'])}
|
||||
except (OSError, ET.ParseError, KeyError, ValueError) as error: fail(f'Malformed permanent manifest: {error}')
|
||||
|
||||
def current_package() -> tuple[dict, str]:
|
||||
if not CURRENT_BUILD_METADATA.is_file(): raise RuntimeError('Current build metadata is missing')
|
||||
build=load_json(CURRENT_BUILD_METADATA,'Current build metadata is missing')
|
||||
if set(build)!={'schema','build_id','status','cpu','abi','application','upstream_commit','resource_count','compile','package','package_metadata','error'} or build.get('schema')!=CURRENT_BUILD_SCHEMA: fail('Invalid current build schema')
|
||||
if build.get('status')!='PASS': raise RuntimeError('Current build is not PASS')
|
||||
app=build.get('application'); identity=manifest_identity()
|
||||
if not isinstance(app,dict) or any(app.get(key)!=value for key,value in identity.items()): fail('Build application identity mismatch')
|
||||
if build.get('package_metadata')!='out/current/package.json': fail('Build package metadata locator mismatch')
|
||||
package=load_json(CURRENT_PACKAGE_METADATA,'Current package metadata is missing')
|
||||
if set(package)!={'schema','build_id','application','cpu','abi','apk'} or package.get('schema')!=CURRENT_PACKAGE_SCHEMA: fail('Invalid current package schema')
|
||||
if package.get('build_id')!=build.get('build_id') or package.get('application')!=identity: fail('Package metadata identity mismatch')
|
||||
if package.get('cpu')!=build.get('cpu') or package.get('abi')!=build.get('abi'): fail('Package build mismatch')
|
||||
apk=package.get('apk')
|
||||
if not isinstance(apk,dict) or set(apk)!={'file','sha256','size','native_library_path_in_apk'} or apk.get('file')!='out/current/kam-mobile.apk': fail('Package APK locator mismatch')
|
||||
if package['cpu'] not in CPUS or package['abi']!=CPUS[package['cpu']][0]: fail('Invalid package ABI')
|
||||
if not CURRENT_APK.is_file() or apk.get('sha256')!=sha256(CURRENT_APK): fail('Current APK missing or changed')
|
||||
return package, identity['package']
|
||||
|
||||
def main() -> int:
|
||||
ap=argparse.ArgumentParser(description=__doc__); ap.add_argument('--serial'); a=ap.parse_args()
|
||||
try:
|
||||
package,pkg=current_package()
|
||||
except ProbeError as error:
|
||||
print('FAIL\n'+str(error)); return 1
|
||||
except RuntimeError as error:
|
||||
print('BLOCKED\n'+str(error)); return 2
|
||||
except (KeyError, TypeError) as error:
|
||||
print('FAIL\n'+str(error)); return 1
|
||||
if not shutil.which('adb'):
|
||||
print('BLOCKED\nadb is not installed on host'); return 2
|
||||
try:
|
||||
devices=[line.split()[:2] for line in Adb(None).text('devices').splitlines()[1:] if line.strip()]
|
||||
if len(devices)!=1 or len(devices[0])!=2 or devices[0][1]!='device': fail('Require exactly one authorized device')
|
||||
serial=devices[0][0]
|
||||
if a.serial and a.serial!=serial: fail('Requested serial is not the sole authorized device')
|
||||
adb=Adb(serial)
|
||||
if package['abi'] not in adb.text('shell','getprop','ro.product.cpu.abilist').split(','): fail('Device ABI does not support package ABI')
|
||||
except (ProbeError, OSError, subprocess.TimeoutExpired) as error:
|
||||
print('BLOCKED\n'+str(error)); return 2
|
||||
try:
|
||||
install=adb.run('install','-r',str(CURRENT_APK),check=False)
|
||||
if install.returncode and b'INSTALL_FAILED_UPDATE_INCOMPATIBLE' in install.stdout+install.stderr:
|
||||
uninstall=adb.run('uninstall',pkg,check=False);save_command(out,'uninstall.log',uninstall);record['install']['uninstall_for_ephemeral_key']={'exit':uninstall.returncode,'result':uninstall.stdout.decode(errors='replace').strip()}
|
||||
install=adb.run('install',str(apk),check=False);save_command(out,'install-retry.log',install);record['install'].update(retry_exit=install.returncode,retry_result=install.stdout.decode(errors='replace').strip())
|
||||
if install.returncode: raise ProbeError('APK install failed; see install.log')
|
||||
run_as=adb.run('shell','run-as',pkg,'id',check=False);save_command(out,'run-as.log',run_as);record['run_as']={'status':'PASS' if run_as.returncode==0 else 'BLOCKED','output':run_as.stdout.decode(errors='replace').strip(),'error':run_as.stderr.decode(errors='replace').strip()}
|
||||
if run_as.returncode: record['infrastructure_blocker']={'stage':'run_as_preflight','reason':'run-as unavailable for '+pkg};raise ProbeError(record['infrastructure_blocker']['reason'])
|
||||
if a.stage_file:
|
||||
if not a.stage_file.is_file(): raise ProbeError('Stage file not found: '+str(a.stage_file))
|
||||
rel='files/kam-architecture-probe/driver-staged-fixture.bin';adb.run('shell','run-as',pkg,'sh','-c','mkdir -p files/kam-architecture-probe')
|
||||
p=subprocess.run(adb.base+['exec-in','run-as',pkg,'sh','-c','cat > '+rel],input=a.stage_file.read_bytes(),stdout=subprocess.PIPE,stderr=subprocess.PIPE);save_command(out,'stage.log',p)
|
||||
size=adb.text('shell','run-as',pkg,'stat','-c','%s',rel,check=False);record['fixture_staging_driver']={'status':'PASS' if p.returncode==0 and size==str(a.stage_file.stat().st_size) else 'FAIL','source':str(a.stage_file),'bytes':a.stage_file.stat().st_size,'device_bytes':size}
|
||||
adb.run('shell','am','force-stop',pkg); listing=adb.text('shell','run-as',pkg,'find','.','-name','runtime.json',check=False)
|
||||
for name in own_report_paths(listing): adb.run('shell','run-as',pkg,'rm','--',name)
|
||||
adb.run('logcat','-c');record['launch_marker_epoch_ms']=int(time.time()*1000)
|
||||
component=adb.text('shell','cmd','package','resolve-activity','--brief','-a','android.intent.action.MAIN','-c','android.intent.category.LAUNCHER',pkg); candidates=[x.strip() for x in component.splitlines() if x.strip().startswith(pkg+'/')]
|
||||
if len(candidates)!=1 or not re.fullmatch(r'[\w.$/]+',candidates[0]): raise ProbeError('Cannot resolve probe launch Activity')
|
||||
launch=adb.run('shell','am','start','-W','-n',candidates[0],check=False);save_command(out,'launch.log',launch);record['launch']={'activity':candidates[0],'exit':launch.returncode,'result':launch.stdout.decode(errors='replace').strip()}
|
||||
if launch.returncode: raise ProbeError('Launch failed; see launch.log')
|
||||
data,path=wait_runtime(adb,pkg,lambda d:any(x.get('id')=='android.startup' and x.get('status')=='PASS' for x in d.get('tests',[])),a.seconds)
|
||||
if data is None: raise ProbeError('android.startup was not observed before timeout; see logcat.log and process.log')
|
||||
if a.exercise_lifecycle:
|
||||
adb.run('shell','input','keyevent','KEYCODE_HOME');time.sleep(1);adb.run('shell','am','start','-W','-n',candidates[0])
|
||||
expected=[]
|
||||
if a.exercise_lifecycle and a.profile=='platform': expected=['decision.schedule.callback_progress','decision.schedule.worker_progress','decision.schedule.lifecycle']
|
||||
if a.profile=='platform': expected += ['render.frame_callbacks','decision.text.cwstring_unit','decision.text.cp1251_decode','decision.text.cp1251_encode']
|
||||
if expected: data,path=wait_runtime(adb,pkg,lambda d: all(any(x.get('id')==name for x in d.get('tests',[])) for name in expected),a.seconds)
|
||||
if data is None: raise ProbeError('Runtime report disappeared; see logcat.log and process.log')
|
||||
out.joinpath('runtime.json').write_text(json.dumps(data,ensure_ascii=False,indent=2)+'\n',encoding='utf-8');record['runtime_path']=path
|
||||
record['errors']+=validate_runtime(data,package,require_frames=package['profile'] not in DECISION_PROFILES)
|
||||
if expected and not all(any(x.get('id')==name for x in data.get('tests',[])) for name in expected): record['errors'].append('Timed out waiting for observations: '+', '.join(expected))
|
||||
if a.stage_file and record.get('fixture_staging_driver',{}).get('status')!='PASS': record['errors'].append('Existing server fixture staging size mismatch')
|
||||
record['status']='FAIL' if record['errors'] else 'AUTOMATIC_SMOKE_PASS'
|
||||
except (ProbeError,OSError,ValueError,KeyError,subprocess.TimeoutExpired) as e:
|
||||
record['errors'].append(str(e))
|
||||
if record['preflight'].get('status')=='BLOCKED': record['infrastructure_blocker']={'stage':'device_preflight','reason':record['preflight'].get('reason',str(e))}
|
||||
finally:
|
||||
if adb:
|
||||
p=adb.run('shell','pidof',record.get('package',{}).get('package',''),check=False);save_command(out,'process.log',p);record['process_after_launch']=p.stdout.decode(errors='replace').strip()
|
||||
p=adb.run('logcat','-d','-v','threadtime',check=False);save_command(out,'logcat.log',p)
|
||||
write_json(out/'device-result.json',record);print(record['status']+'\n'+str(out));[print(x) for x in record['errors']]
|
||||
return 0 if record['status']=='AUTOMATIC_SMOKE_PASS' else 1 if record['status']=='FAIL' else 2
|
||||
adb.run('uninstall',pkg,check=False); install=adb.run('install',str(CURRENT_APK),check=False)
|
||||
if install.returncode: fail('APK install failed')
|
||||
candidates=[line.strip() for line in adb.text('shell','cmd','package','resolve-activity','--brief','-a','android.intent.action.MAIN','-c','android.intent.category.LAUNCHER',pkg).splitlines() if line.strip()]
|
||||
if len(candidates)!=1 or not candidates[0].startswith(pkg+'/'): fail('Cannot resolve exactly one application launcher activity')
|
||||
if adb.run('shell','am','start','-W','-n',candidates[0],check=False).returncode: fail('Launch failed')
|
||||
except (ProbeError, OSError, subprocess.TimeoutExpired) as error:
|
||||
print('FAIL\n'+str(error)); return 1
|
||||
print('PASS'); return 0
|
||||
|
||||
if __name__=='__main__': raise SystemExit(main())
|
||||
+73
-119
@@ -1,130 +1,84 @@
|
||||
"""Generate isolated Android CGE targets. No KaM stubs or rewritten game loaders."""
|
||||
from __future__ import annotations
|
||||
import json, os, shutil, urllib.parse, xml.etree.ElementTree as ET
|
||||
import hashlib, os, xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
from common import ROOT, COMMIT, VERSION, CPUS, ProbeError, safe_relative, sha256, write_json
|
||||
from source_resolution import build_source_resolution
|
||||
from common import ROOT, ProbeError
|
||||
|
||||
UPSTREAM_DEFINES=['DBG_SKIP_SECURE_AUTH'] # Existing upstream switch; private multiplayer authentication is out of scope.
|
||||
COMPILER_OPTIONS=['-B','-CX-','-XX-','-k--no-undefined','-k--no-gc-sections']
|
||||
|
||||
def imports_source(profile:dict)->tuple[str,list[str]]:
|
||||
units=list(profile['units'])
|
||||
points=profile['id'] in ('core_types','core_streams','integration')
|
||||
streams=profile['id'] in ('core_streams','integration')
|
||||
if points and 'KM_Points' not in units:units.append('KM_Points')
|
||||
head='unit ProbeImports;\n{$mode delphi}{$H+}\ninterface\nuses ProbeReport;\nprocedure RunUpstreamChecks(R:TProbeReport);\nimplementation\n'
|
||||
head+='uses SysUtils'+(' ,'+', '.join(units) if units else '')+';\n'
|
||||
head+='procedure RunUpstreamChecks(R:TProbeReport);\n'
|
||||
if points:
|
||||
head+='var P,Q:TKMPoint;\n'
|
||||
if streams:head+=' S:TKMemoryStreamBinary; I:Integer; U:UnicodeString;\n'
|
||||
head+='begin\n'
|
||||
if not units:
|
||||
head+=" R.Add('upstream.imports','NOT_RUN','Platform-only APK; no KaM modules imported');\n"
|
||||
else:
|
||||
head+=" R.Add('upstream.imports','PASS','Real imported units initialized: "+', '.join(units)+"; this does not execute a game world');\n"
|
||||
if points:
|
||||
head+=''' try
|
||||
P:=KMPoint(17,-3); Q:=KMPointRight(P);
|
||||
R.Check('core.points',(P.X=17) and (P.Y=-3) and (Q.X=18) and (Q.Y=-3),
|
||||
'Calls original KMPoint / KMPointRight, not a local replacement');
|
||||
R.Check('core.point_layout',(SizeOf(TKMPoint)=8) and (SizeOf(TKMPointF)=8),
|
||||
Format('TKMPoint=%d TKMPointF=%d',[SizeOf(TKMPoint),SizeOf(TKMPointF)]));
|
||||
except on E:Exception do R.Add('core.points','FAIL',E.ClassName+': '+E.Message);end;
|
||||
'''
|
||||
if streams:
|
||||
head+=''' try
|
||||
S:=TKMemoryStreamBinary.Create;
|
||||
try
|
||||
S.Write(Integer(123456)); S.Write(P); S.WriteW(UnicodeString('KaM Android'));
|
||||
S.Position:=0; S.Read(I); S.Read(Q); S.ReadW(U);
|
||||
R.Check('core.stream_roundtrip',(I=123456) and (Q.X=P.X) and (Q.Y=P.Y)
|
||||
and (U=UnicodeString('KaM Android')) and (S.Position=S.Size),
|
||||
'Calls original TKMemoryStreamBinary Read/Write/ReadW/WriteW');
|
||||
finally S.Free;end;
|
||||
except on E:Exception do R.Add('core.stream_roundtrip','FAIL',E.ClassName+': '+E.Message);end;
|
||||
'''
|
||||
head+='end;\nend.\n'
|
||||
return head,units
|
||||
_PROJECT_ATTRIBUTES={
|
||||
'name':'kam_mobile', 'caption':'KaM Remake Mobile', 'author':'KaM Mobile',
|
||||
'qualified_name':'org.kammobile.kamremake', 'game_units':'GameInitialize',
|
||||
'compiler':'fpc', 'screen_orientation':'landscape', 'fullscreen_immersive':'true',
|
||||
}
|
||||
|
||||
def asset_inventory(root:Path|None, includes:list[str]|None=None)->list[dict]:
|
||||
if root is None:return []
|
||||
root=root.resolve()
|
||||
if not root.is_dir():raise ProbeError('Asset directory does not exist: '+str(root))
|
||||
if includes:
|
||||
result=[]
|
||||
seen=set()
|
||||
for raw in includes:
|
||||
rel=safe_relative(raw.replace('\\','/'))
|
||||
if rel in seen:continue
|
||||
seen.add(rel);p=root/rel
|
||||
if p.is_symlink():raise ProbeError('Asset symlinks are not allowed: '+str(p))
|
||||
if not p.is_file():raise ProbeError('Requested asset path not found under asset root: '+rel)
|
||||
result.append({'path':rel,'size':p.stat().st_size,'sha256':sha256(p),
|
||||
'url':'castle-data:/game/'+urllib.parse.quote(rel,safe='/')})
|
||||
return result
|
||||
result=[]
|
||||
for p in sorted(root.rglob('*')):
|
||||
if p.is_symlink():raise ProbeError('Asset symlinks are not allowed: '+str(p))
|
||||
if p.is_dir():continue
|
||||
if not p.is_file():raise ProbeError('Not a regular asset: '+str(p))
|
||||
rel=safe_relative(p.relative_to(root).as_posix())
|
||||
if rel=='README.md' or '.git' in p.parts:continue
|
||||
result.append({'path':rel,'size':p.stat().st_size,'sha256':sha256(p),
|
||||
'url':'castle-data:/game/'+urllib.parse.quote(rel,safe='/')})
|
||||
return result
|
||||
def _one_child(parent: ET.Element, tag: str) -> ET.Element:
|
||||
children=parent.findall(tag)
|
||||
if len(children)!=1:raise ProbeError(f'Manifest requires exactly one <{tag}>')
|
||||
return children[0]
|
||||
|
||||
def make_target(project:Path,profile:dict,cpu:str,build_id:str,source:Path|None,
|
||||
validated:dict|None,assets_root:Path|None=None,assets:list[dict]|None=None)->dict:
|
||||
def _manifest_path(path: object, project: Path, kind: str) -> str:
|
||||
try:
|
||||
resolved=Path(path).resolve()
|
||||
if not resolved.is_dir():raise ProbeError(f'{kind} path missing: {resolved}')
|
||||
relative=os.path.relpath(resolved,project).replace(os.sep,'/')
|
||||
if not any(char.isspace() for char in relative):return relative
|
||||
alias=project/'.source-paths'/hashlib.sha256(str(resolved).encode()).hexdigest()
|
||||
alias.parent.mkdir(exist_ok=True)
|
||||
if alias.exists() or alias.is_symlink():
|
||||
if not alias.is_symlink() or alias.resolve()!=resolved:raise ProbeError(f'{kind} path alias collision: {alias}')
|
||||
else:alias.symlink_to(resolved,target_is_directory=True)
|
||||
return os.path.relpath(alias,project).replace(os.sep,'/')
|
||||
except ProbeError:
|
||||
raise
|
||||
except (OSError, TypeError, ValueError, RuntimeError) as error:
|
||||
raise ProbeError(f'Cannot represent {kind} path {path!r}: {error}') from error
|
||||
|
||||
def _permanent_manifest() -> tuple[ET.ElementTree, ET.Element]:
|
||||
path=ROOT/'app/CastleEngineManifest.xml'
|
||||
try:
|
||||
tree=ET.parse(path)
|
||||
except (OSError, ET.ParseError) as error:
|
||||
raise ProbeError(f'Cannot read permanent manifest: {error}') from error
|
||||
root=tree.getroot()
|
||||
if root.tag!='project' or root.attrib!=_PROJECT_ATTRIBUTES:
|
||||
raise ProbeError('Invalid permanent application identity')
|
||||
version=_one_child(root,'version')
|
||||
if version.get('value')!='0.4.0' or version.get('code')!='30':
|
||||
raise ProbeError('Invalid permanent application version')
|
||||
options=_one_child(root,'compiler_options')
|
||||
if [child.tag for child in options] != ['search_paths','defines','custom_options','library_paths']:
|
||||
raise ProbeError('Invalid permanent compiler policy order')
|
||||
search_paths,defines,custom_options,library_paths=(
|
||||
_one_child(options,tag) for tag in ('search_paths','defines','custom_options','library_paths'))
|
||||
if search_paths.findall('path') or library_paths.findall('path'):
|
||||
raise ProbeError('Permanent manifest path slots must be empty')
|
||||
if [item.text for item in defines.findall('define')]!=UPSTREAM_DEFINES:
|
||||
raise ProbeError('Invalid permanent compiler defines')
|
||||
if [item.text for item in custom_options.findall('option')]!=COMPILER_OPTIONS:
|
||||
raise ProbeError('Invalid permanent compiler options')
|
||||
return tree,root
|
||||
|
||||
def materialize_application_project(project: Path, source_resolution: dict) -> dict:
|
||||
"""Create one fresh Castle project from the permanent manifest and RP01 paths."""
|
||||
if project.exists():raise ProbeError('Target directory must be fresh: '+str(project))
|
||||
try:
|
||||
compiler_paths=tuple(source_resolution['compiler_source_paths'])
|
||||
library_paths=tuple(source_resolution['library_paths'])
|
||||
except (KeyError, TypeError) as error:
|
||||
raise ProbeError(f'Invalid source resolution: {error}') from error
|
||||
tree,root=_permanent_manifest()
|
||||
project.mkdir(parents=True)
|
||||
shutil.copytree(ROOT/'code',project/'code')
|
||||
text,units=imports_source(profile)
|
||||
(project/'code/ProbeImports.pas').write_text(text,encoding='utf-8')
|
||||
(project/'code/ProbeBuild.pas').write_text(
|
||||
'unit ProbeBuild;\n{$mode objfpc}{$H+}\ninterface\nconst\n'+
|
||||
f" ProbeBuildId='{build_id}';\n ProbeProfile='{profile['id']}';\n ProbeCpu='{cpu}';\n"+
|
||||
f" ProbeUpstreamCommit='{COMMIT}';\n ProbePointerBytes={CPUS[cpu][3]};\nimplementation\nend.\n")
|
||||
data=project/'data';data.mkdir()
|
||||
entries=assets or []
|
||||
if entries and assets_root is None:raise ProbeError('Missing asset root')
|
||||
for e in entries:
|
||||
dest=data/'game'/e['path'];dest.parent.mkdir(parents=True,exist_ok=True)
|
||||
shutil.copyfile(assets_root/e['path'],dest)
|
||||
if sha256(dest)!=e['sha256']:raise ProbeError('Asset changed while staging: '+e['path'])
|
||||
write_json(data/'assets-manifest.json',{'schema':'kam-probe-assets/1','files':entries})
|
||||
name='kam_probe_'+profile['id']
|
||||
root=ET.Element('project',name=name,caption='KaM Android: '+profile['id'],
|
||||
author='KaM Mobile',qualified_name='org.kammobile.archprobe.'+profile['id'].replace('_',''),
|
||||
game_units=profile.get('game_unit','GameInitialize'),compiler='fpc',screen_orientation='landscape',fullscreen_immersive='true')
|
||||
ET.SubElement(root,'version',value=VERSION,code='30')
|
||||
options=ET.SubElement(root,'compiler_options');paths=ET.SubElement(options,'search_paths')
|
||||
if units:
|
||||
if source is None or validated is None:raise ProbeError('KaM source resolution is required for '+profile['id'])
|
||||
resolution=build_source_resolution(validated,source,project/'code')
|
||||
compiler_source_paths=resolution['compiler_source_paths']
|
||||
library_paths=resolution['library_paths']
|
||||
else:
|
||||
compiler_source_paths=(project/'code',)
|
||||
library_paths=()
|
||||
for p in compiler_source_paths:
|
||||
if not p.is_dir():raise ProbeError('Source path missing: '+str(p))
|
||||
ET.SubElement(paths,'path',value=os.path.relpath(p,project).replace(os.sep,'/'))
|
||||
defines=ET.SubElement(options,'defines')
|
||||
for define in (UPSTREAM_DEFINES if units else []):ET.SubElement(defines,'define').text=define
|
||||
custom=ET.SubElement(options,'custom_options')
|
||||
for flag in COMPILER_OPTIONS:ET.SubElement(custom,'option').text=flag
|
||||
libraries=ET.SubElement(options,'library_paths')
|
||||
for p in library_paths:
|
||||
if not p.is_dir():raise ProbeError('Native library path missing: '+str(p))
|
||||
ET.SubElement(libraries,'path',value=os.path.relpath(p,project).replace(os.sep,'/'))
|
||||
# Android SDK versions deliberately inherited from the locked CGE image.
|
||||
ET.indent(root)
|
||||
ET.ElementTree(root).write(project/'CastleEngineManifest.xml',encoding='utf-8',xml_declaration=True)
|
||||
description={'profile':profile['id'],'name':name,'package':'org.kammobile.archprobe.'+profile['id'].replace('_',''),
|
||||
'cpu':cpu,'units':units,'game_unit':profile.get('game_unit','GameInitialize'),'source_paths':[str(p) for p in compiler_source_paths],
|
||||
'compiler_options':COMPILER_OPTIONS,'upstream_defines':UPSTREAM_DEFINES if units else [],
|
||||
'assets':entries,'shims':False}
|
||||
write_json(project/'target.json',description)
|
||||
return description
|
||||
data_root=project/'data';data_root.mkdir()
|
||||
options=_one_child(root,'compiler_options')
|
||||
for tag,paths,kind in (('search_paths',compiler_paths,'Source'),('library_paths',library_paths,'Native library')):
|
||||
slot=_one_child(options,tag)
|
||||
for child in list(slot):slot.remove(child)
|
||||
for path in paths:ET.SubElement(slot,'path',value=_manifest_path(path,project,kind))
|
||||
ET.indent(tree)
|
||||
tree.write(project/'CastleEngineManifest.xml',encoding='utf-8',xml_declaration=True)
|
||||
version=_one_child(root,'version')
|
||||
return {'project_dir':project,'data_root':data_root,'name':root.get('name'),'caption':root.get('caption'),
|
||||
'package':root.get('qualified_name'),'version':version.get('value'),'version_code':int(version.get('code')),
|
||||
'game_unit':root.get('game_units'),'compiler_source_paths':compiler_paths,'library_paths':library_paths}
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Load the permanent runtime-resource selection."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
from common import ROOT, ProbeError, safe_relative, sha256, write_json
|
||||
|
||||
|
||||
RUNTIME_RESOURCE_ROOT = ROOT / 'private-assets'
|
||||
RUNTIME_RESOURCE_MANIFEST = ROOT / 'config/runtime-resources.json'
|
||||
_SCHEMA = 'kam-runtime-resources/1'
|
||||
_KEYS = {'schema', 'entries'}
|
||||
_ENTRY_KEYS = {'kind', 'path'}
|
||||
|
||||
|
||||
def _load_runtime_resource_manifest() -> tuple[dict[str, str], ...]:
|
||||
"""Return the validated authored resource selection, without resolving it."""
|
||||
try:
|
||||
value = json.loads(RUNTIME_RESOURCE_MANIFEST.read_text(encoding='utf-8'))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as error:
|
||||
raise ProbeError(f'Cannot read runtime-resource manifest: {error}') from error
|
||||
if not isinstance(value, dict) or set(value) != _KEYS:
|
||||
raise ProbeError('Invalid runtime-resource manifest fields')
|
||||
if value['schema'] != _SCHEMA:
|
||||
raise ProbeError('Invalid runtime-resource manifest schema')
|
||||
if not isinstance(value['entries'], list) or not value['entries']:
|
||||
raise ProbeError('Runtime-resource entries must be a non-empty array')
|
||||
|
||||
seen_paths: set[str] = set()
|
||||
entries = []
|
||||
for entry in value['entries']:
|
||||
if not isinstance(entry, dict) or set(entry) != _ENTRY_KEYS:
|
||||
raise ProbeError('Invalid runtime-resource entry fields')
|
||||
kind, path = entry['kind'], entry['path']
|
||||
if not isinstance(kind, str) or kind not in {'file', 'directory'} or not isinstance(path, str) or not path:
|
||||
raise ProbeError('Invalid runtime-resource entry')
|
||||
safe_relative(path)
|
||||
if path in seen_paths:
|
||||
raise ProbeError(f'Duplicate runtime-resource path: {path!r}')
|
||||
seen_paths.add(path)
|
||||
entries.append({'kind': kind, 'path': path})
|
||||
return tuple(entries)
|
||||
|
||||
|
||||
def _resolve_runtime_resource_files(entries: tuple[dict[str, str], ...]) -> tuple[str, ...]:
|
||||
"""Expand parsed resource entries into exact, safe file identities."""
|
||||
root = RUNTIME_RESOURCE_ROOT
|
||||
if root.is_symlink() or not root.is_dir():
|
||||
raise ProbeError(f'Runtime-resource root is not a real directory: {root}')
|
||||
try:
|
||||
fixed_root = root.resolve(strict=True)
|
||||
except OSError as error:
|
||||
raise ProbeError(f'Cannot resolve runtime-resource root: {error}') from error
|
||||
|
||||
def checked(path):
|
||||
if path.is_symlink():
|
||||
raise ProbeError(f'Runtime-resource symlink is not allowed: {path}')
|
||||
try:
|
||||
resolved = path.resolve(strict=True)
|
||||
except OSError as error:
|
||||
raise ProbeError(f'Cannot resolve runtime resource: {path}') from error
|
||||
if not resolved.is_relative_to(fixed_root):
|
||||
raise ProbeError(f'Runtime resource escapes fixed root: {path}')
|
||||
return resolved
|
||||
|
||||
def exact(path: str):
|
||||
current = root
|
||||
for component in path.split('/'):
|
||||
if not current.is_dir():
|
||||
raise ProbeError(f'Runtime-resource path component is not a directory: {current}')
|
||||
try:
|
||||
with os.scandir(current) as scanned:
|
||||
child = next((Path(entry.path) for entry in scanned
|
||||
if entry.name == component), None)
|
||||
except OSError as error:
|
||||
raise ProbeError(f'Cannot inspect runtime-resource path: {current}') from error
|
||||
if child is None:
|
||||
raise ProbeError(f'Runtime-resource path not found: {path}')
|
||||
checked(child)
|
||||
current = child
|
||||
return current
|
||||
|
||||
selected: set[str] = set()
|
||||
for entry in entries:
|
||||
kind, authored = entry['kind'], entry['path']
|
||||
target = exact(authored)
|
||||
if kind == 'file':
|
||||
if target.name == '.gitkeep' or not target.is_file() or target.is_symlink():
|
||||
raise ProbeError(f'Runtime-resource file is not a regular selectable file: {authored}')
|
||||
selected.add(authored)
|
||||
continue
|
||||
if not target.is_dir() or target.is_symlink():
|
||||
raise ProbeError(f'Runtime-resource directory is not a real directory: {authored}')
|
||||
produced = False
|
||||
pending = [target]
|
||||
while pending:
|
||||
directory = pending.pop()
|
||||
try:
|
||||
children = list(os.scandir(directory))
|
||||
except OSError as error:
|
||||
raise ProbeError(f'Cannot inspect runtime-resource directory: {directory}') from error
|
||||
for child_entry in children:
|
||||
child = Path(child_entry.path)
|
||||
checked(child)
|
||||
if child_entry.is_symlink():
|
||||
raise ProbeError(f'Runtime-resource symlink is not allowed: {child}')
|
||||
if child_entry.is_dir(follow_symlinks=False):
|
||||
pending.append(child)
|
||||
elif child_entry.is_file(follow_symlinks=False):
|
||||
if child.name != '.gitkeep':
|
||||
produced = True
|
||||
selected.add(child.relative_to(root).as_posix())
|
||||
else:
|
||||
raise ProbeError(f'Runtime-resource node is not a regular file or directory: {child}')
|
||||
if not produced:
|
||||
raise ProbeError(f'Runtime-resource directory is empty: {authored}')
|
||||
|
||||
folded: dict[str, str] = {}
|
||||
for path in selected:
|
||||
prior = folded.setdefault(path.casefold(), path)
|
||||
if prior != path:
|
||||
raise ProbeError(f'Case-colliding runtime-resource paths: {prior!r}, {path!r}')
|
||||
return tuple(sorted(selected, key=lambda path: (path.casefold(), path)))
|
||||
|
||||
|
||||
def stage_runtime_resources(castle_data_root: Path) -> tuple[dict[str, object], ...]:
|
||||
"""Stage the selected runtime resources into a Castle data directory."""
|
||||
paths = _resolve_runtime_resource_files(_load_runtime_resource_manifest())
|
||||
records = []
|
||||
for path in paths:
|
||||
source = RUNTIME_RESOURCE_ROOT / path
|
||||
if source.is_symlink() or not source.is_file():
|
||||
raise ProbeError(f'Runtime-resource file disappeared or changed type: {path}')
|
||||
try:
|
||||
digest = sha256(source)
|
||||
records.append({'path': path, 'size': source.stat().st_size, 'sha256': digest,
|
||||
'url': 'castle-data:/game/' + urllib.parse.quote(path, safe='/')})
|
||||
except OSError as error:
|
||||
raise ProbeError(f'Cannot inventory runtime resource: {path}: {error}') from error
|
||||
|
||||
game = castle_data_root / 'game'
|
||||
manifest = castle_data_root / 'assets-manifest.json'
|
||||
try:
|
||||
if game.is_symlink() or game.is_file():
|
||||
game.unlink()
|
||||
elif game.exists():
|
||||
shutil.rmtree(game)
|
||||
if manifest.exists() or manifest.is_symlink():
|
||||
manifest.unlink()
|
||||
for record in records:
|
||||
path = record['path']
|
||||
source = RUNTIME_RESOURCE_ROOT / path
|
||||
if source.is_symlink() or not source.is_file():
|
||||
raise ProbeError(f'Runtime-resource file disappeared or changed type: {path}')
|
||||
destination = game / path
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(source, destination)
|
||||
if sha256(destination) != record['sha256']:
|
||||
raise ProbeError(f'Runtime-resource copy hash mismatch: {path}')
|
||||
write_json(manifest, {'schema': 'kam-runtime-assets/1', 'files': records})
|
||||
except ProbeError:
|
||||
shutil.rmtree(game, ignore_errors=True)
|
||||
try:
|
||||
manifest.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
except OSError as error:
|
||||
shutil.rmtree(game, ignore_errors=True)
|
||||
try:
|
||||
manifest.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
raise ProbeError(f'Cannot stage runtime resources: {error}') from error
|
||||
return tuple(records)
|
||||
+137
-231
@@ -1,25 +1,19 @@
|
||||
"""One-command Android-only compile, full native link, APK and evidence pipeline."""
|
||||
from __future__ import annotations
|
||||
import argparse, json, os, re, shutil, subprocess, sys, uuid, zipfile
|
||||
import argparse, json, os, re, shutil, subprocess, uuid, zipfile
|
||||
from pathlib import Path
|
||||
from common import ROOT, COMMIT, CPUS, ProbeError, stamp, sha256, write_json, run_command, first_errors, classify
|
||||
from common import (ROOT, COMMIT, CPUS, ProbeError, stamp, sha256, write_json, run_command,
|
||||
first_errors, CURRENT_OUTPUT_DIR, CURRENT_BUILD_METADATA,
|
||||
CURRENT_PACKAGE_METADATA, CURRENT_APK, CURRENT_BUILD_SCHEMA,
|
||||
CURRENT_PACKAGE_SCHEMA)
|
||||
from source_fetch import fetch, verify
|
||||
from source_audit import inventory
|
||||
from source_resolution import load_source_contract, validate_source_contract
|
||||
from generate import make_target, asset_inventory
|
||||
from source_resolution import load_source_contract, validate_source_contract, build_source_resolution
|
||||
from generate import materialize_application_project
|
||||
from artifacts import inspect_elf, inspect_apk
|
||||
from resource_staging import stage_runtime_resources
|
||||
|
||||
IMAGE_TAG='kambi/castle-engine-cloud-builds-tools:cge-unstable'
|
||||
PROFILES=json.loads((ROOT/'config/profiles.json').read_text())
|
||||
|
||||
def selected_profiles(text:str)->list[dict]:
|
||||
if text=='all':return PROFILES
|
||||
names=[s.strip() for s in text.split(',') if s.strip()]
|
||||
available={p['id'] for p in PROFILES}
|
||||
if not names or set(names)-available:raise ProbeError('Unknown/empty profiles: '+text)
|
||||
names=set(names)|{'platform'} # Always establish the same-toolchain platform baseline.
|
||||
return [p for p in PROFILES if p['id'] in names]
|
||||
|
||||
def cge_command(cpu:str,action:str)->list[str]:
|
||||
if cpu not in CPUS:raise ProbeError('Unsupported Android CPU')
|
||||
if action not in ('compile','package'):raise ProbeError('Not an Android build action')
|
||||
@@ -27,73 +21,6 @@ def cge_command(cpu:str,action:str)->list[str]:
|
||||
if action=='package':cmd+=['--assume-compiled','--package-format=android-apk']
|
||||
return cmd+[action]
|
||||
|
||||
def blocked_row(profile:dict,reason:str)->dict:
|
||||
return {'id':profile['id'],'required':profile['required'],'purpose':profile['purpose'],
|
||||
'compile_link':{'status':'BLOCKED','reason':reason},'package':{'status':'NOT_RUN'},
|
||||
'runtime':{'status':'NOT_RUN'},'errors':[]}
|
||||
|
||||
def gate(rows:list[dict])->str:
|
||||
by_id={r['id']:r for r in rows}
|
||||
required=[p for p in PROFILES if p['required']]
|
||||
if any(r['compile_link']['status']=='FAIL' or r['package']['status']=='FAIL' for r in rows if r['required']):
|
||||
return 'NOT_BUILDABLE_AS_IS'
|
||||
if any(r['compile_link']['status']=='BLOCKED' for r in rows if r['required']):return 'BLOCKED'
|
||||
if any(p['id'] not in by_id for p in required):return 'PARTIAL_SELECTION'
|
||||
for p in required:
|
||||
r=by_id[p['id']]
|
||||
if r['compile_link']['status']!='PASS':return 'BLOCKED'
|
||||
if p['package'] and r['package']['status']!='PASS':return 'BLOCKED'
|
||||
return 'ANDROID_BUILD_GATE_PASS'
|
||||
|
||||
def render_summary(result:dict,out:Path)->None:
|
||||
title='# KaM r16020 — Android architecture probe\n\n'
|
||||
title+=f"Build ID: `{result['run_id']}` · CPU: `{result['cpu']}` · Gate: **{result['gate']}**\n\n"
|
||||
title+='Нет desktop target. Успех означает сборку выбранных Android-корней, не игровую совместимость.\n\n'
|
||||
title+='| Проба | Compile + native link | APK | Ошибка / следующий шаг |\n|---|---|---|---|\n'
|
||||
for r in result['profiles']:
|
||||
error=(r.get('errors') or [r['compile_link'].get('reason','')])[0]
|
||||
error=error.replace('|','/').replace('\n',' ')[:260]
|
||||
title+=f"| {r['id']} | {r['compile_link']['status']} | {r['package']['status']} | {error} |\n"
|
||||
title+='\n## Что установлено\n\n'
|
||||
title+='`PASS` compile_link = FPC завершился успешно, найден свежий ELF нужного ABI, проверены корневые PPU. Линковка не отключена; требуются определённые символы. '
|
||||
title+='`PASS` APK = упаковка завершилась, проверены ZIP/DEX, правильный ABI, совпадение native library и DT_NEEDED. '
|
||||
title+='Подпись проверяется отдельно, если доступен apksigner. `runtime=NOT_RUN` до реального запуска на Android.\n\n'
|
||||
title+='`NOT_BUILDABLE_AS_IS` — найдено препятствие в текущем коде/окружении, а не доказательство, что core надо переписать. '
|
||||
title+='Ошибки legacy_render/legacy_audio диагностические и не входят в обязательный gate, но их транзитивное попадание в core всё равно видно.\n\n'
|
||||
title+='## Как принимать решение\n\n'
|
||||
title+='| Свидетельство | Разумное решение |\n|---|---|\n'
|
||||
title+='| Корень собирается с оригинальными исходниками | Кандидат на reuse; поведение ещё проверяется отдельно |\n'
|
||||
title+='| Активная зависимость Windows/LCL/desktop GL | Исследовать минимальное отделение платформенной части |\n'
|
||||
title+='| Отсутствует source unit / package | Проверить комплектность исходников, условные defines и пакет для Android |\n'
|
||||
title+='| Undefined reference / отсутствует native library | Сборка/подключение Android-библиотеки либо адаптер Castle |\n'
|
||||
title+='| Один и тот же блокер у многих модулей | Исправить общий корень и повторить пробы; не считать это множеством отдельных переписываний |\n'
|
||||
title+='| Срабатывает exception при запуске | Снять logcat; проверить unit initialization/lifecycle/доступ к данным |\n'
|
||||
title+='\nНи один автоматический результат здесь не выносит решение «написать подсистему с нуля».\n'
|
||||
(out/'SUMMARY.md').write_text(title,encoding='utf-8')
|
||||
|
||||
def finalize(result:dict,out:Path)->int:
|
||||
result['gate']=gate(result['profiles'])
|
||||
write_json(out/'result.json',result);render_summary(result,out)
|
||||
write_json(ROOT/'reports/LATEST.json',{'run_id':result['run_id'],'directory':str(out.relative_to(ROOT)),
|
||||
'gate':result['gate'],'summary':str((out/'SUMMARY.md').relative_to(ROOT))})
|
||||
shutil.copyfile(out/'SUMMARY.md',ROOT/'reports/LATEST-SUMMARY.md')
|
||||
# Evidence intentionally excludes APK/.so, user game assets, caches and source trees.
|
||||
with zipfile.ZipFile(out/'evidence.zip','w',zipfile.ZIP_DEFLATED) as z:
|
||||
for p in sorted(out.rglob('*')):
|
||||
if p.is_file() and p.suffix in ('.json','.log','.md','.txt','.xml','.pas'):
|
||||
z.write(p,p.relative_to(out))
|
||||
print('\n'+result['gate'])
|
||||
print('Report: '+str(out/'SUMMARY.md'))
|
||||
print('Evidence (no game assets): '+str(out/'evidence.zip'))
|
||||
return 0 if result['gate']=='ANDROID_BUILD_GATE_PASS' else 1 if result['gate']=='NOT_BUILDABLE_AS_IS' else 2
|
||||
|
||||
def new_result(cpu:str,profiles:list[dict],reason:str='Not started')->tuple[dict,Path]:
|
||||
rid=stamp()+'-'+uuid.uuid4().hex[:8]
|
||||
out=ROOT/'reports/runs'/rid;out.mkdir(parents=True)
|
||||
return ({'schema':'kam-android-build/1','run_id':rid,'cpu':cpu,'commit':COMMIT,
|
||||
'desktop_target':False,'shims':False,'runtime':'NOT_RUN',
|
||||
'profiles':[blocked_row(p,reason) for p in profiles]},out)
|
||||
|
||||
def tool_versions(out:Path)->dict:
|
||||
commands={'castle':['castle-engine','--version'],'fpc':['fpc','-iV'],'java':['java','-version'],
|
||||
'adb':['adb','version']}
|
||||
@@ -122,175 +49,154 @@ def signature_tool()->str|None:
|
||||
if os.environ.get(key):paths+=list(Path(os.environ[key]).glob('build-tools/*/apksigner'))
|
||||
return str(sorted(paths)[-1]) if paths else None
|
||||
|
||||
def build_local(a:argparse.Namespace,profiles:list[dict])->int:
|
||||
result,out=new_result(a.cpu,profiles)
|
||||
result['toolchain']=tool_versions(out)
|
||||
if not shutil.which('castle-engine'):
|
||||
for r in result['profiles']:r['compile_link']['reason']='castle-engine not installed; no Android compilation attempted'
|
||||
return finalize(result,out)
|
||||
source=ROOT/'vendor/kam_remake';audit=None;validated=None;assets=[]
|
||||
need_source=any(p['units'] for p in profiles)
|
||||
try:
|
||||
contract=load_source_contract()
|
||||
assets=asset_inventory(Path(a.assets),a.asset_path) if a.assets else []
|
||||
result['asset_count']=len(assets)
|
||||
if need_source:
|
||||
if (source/'SOURCE_RECEIPT.json').is_file():receipt=verify(source)
|
||||
elif a.no_fetch:raise ProbeError('Pinned source tree absent and --no-fetch requested')
|
||||
else:receipt=fetch(source)
|
||||
write_json(out/'source-receipt.json',receipt)
|
||||
audit=inventory(source);write_json(out/'source-inventory.json',audit)
|
||||
validated=validate_source_contract(contract,source,receipt,audit)
|
||||
except (ProbeError,OSError,ValueError,KeyError) as e:
|
||||
result['source_or_inputs_error']=str(e)
|
||||
return finalize(result,out)
|
||||
work=ROOT/'build'/result['run_id'];work.mkdir(parents=True)
|
||||
dist=ROOT/'dist'/result['run_id'];dist.mkdir(parents=True)
|
||||
package_index=[]
|
||||
for idx,profile in enumerate(profiles):
|
||||
row=result['profiles'][idx];ident=profile['id'];project=work/ident
|
||||
print(f"[{idx+1}/{len(profiles)}] Android {a.cpu}: {ident}",flush=True)
|
||||
try:
|
||||
if profile['units']:
|
||||
missing=[u for u in profile['units'] if u.casefold() not in audit['units']]
|
||||
if missing:raise ProbeError('Missing required original source units: '+', '.join(missing))
|
||||
# Resources only enter APKs when explicitly requested. The platform baseline remains independent.
|
||||
pack=profile['package'] or a.package_all
|
||||
use_assets=bool(a.assets) and pack and ident=='platform'
|
||||
description=make_target(project,profile,a.cpu,result['run_id'],source if profile['units'] else None,
|
||||
validated if profile['units'] else None,
|
||||
Path(a.assets) if use_assets else None,assets if use_assets else [])
|
||||
row['target']=description
|
||||
evidence=out/ident;evidence.mkdir()
|
||||
for file in ('CastleEngineManifest.xml','target.json'):
|
||||
shutil.copyfile(project/file,evidence/file)
|
||||
shutil.copyfile(project/'code/ProbeImports.pas',evidence/'ProbeImports.pas')
|
||||
row['compile_link']=run_command(cge_command(a.cpu,'compile'),project,evidence/'compile-link.log',a.timeout)
|
||||
text=(evidence/'compile-link.log').read_text(errors='replace')
|
||||
if row['compile_link']['status']!='PASS':
|
||||
row['errors']=first_errors(text) or [text[-1500:]];row['triage']=classify(text);continue
|
||||
name='lib'+description['name']+'.so'
|
||||
outputs=list(project.rglob('lib'+description['name']+'*.so'))
|
||||
if not outputs:raise ProbeError('Compiler exited 0 but no expected native library matching: '+name)
|
||||
hashes={sha256(p) for p in outputs}
|
||||
if len(hashes)!=1:raise ProbeError('Ambiguous different native outputs')
|
||||
library=outputs[0];name=library.name;elf=inspect_elf(library.read_bytes(),a.cpu)
|
||||
row['native_library']={'file':str(library.relative_to(ROOT)),'sha256':sha256(library),'elf':elf}
|
||||
ppu_files={p.stem.casefold():p for p in project.rglob('*.ppu')}
|
||||
evidence_units=[]
|
||||
for unit in description['units']:
|
||||
p=ppu_files.get(unit.casefold())
|
||||
if p is None or p.read_bytes()[:3]!=b'PPU':
|
||||
raise ProbeError('No fresh Pascal unit artifact found for '+unit)
|
||||
evidence_units.append({'unit':unit,'ppu':str(p.relative_to(ROOT)),'sha256':sha256(p),
|
||||
'source_candidates':audit['units'].get(unit.casefold(),[])})
|
||||
row['compiled_roots']=evidence_units
|
||||
if not pack:continue
|
||||
row['package']=run_command(cge_command(a.cpu,'package'),project,evidence/'package.log',a.timeout)
|
||||
if row['package']['status']!='PASS':
|
||||
row['errors']+=first_errors((evidence/'package.log').read_text(errors='replace'));continue
|
||||
candidates=list(project.rglob('*.apk'))
|
||||
if not candidates:raise ProbeError('Packaging exited 0 but no new APK')
|
||||
apk_hashes={sha256(p) for p in candidates}
|
||||
if len(apk_hashes)!=1:raise ProbeError('Multiple different APK outputs; refusing arbitrary selection')
|
||||
apk=candidates[0]
|
||||
apk_info=inspect_apk(apk,a.cpu,name,row['native_library']['sha256'],library.read_bytes())
|
||||
signer=signature_tool()
|
||||
if signer:
|
||||
sign=run_command([signer,'verify','--verbose',str(apk)],project,evidence/'signature.log',120)
|
||||
row['signature']=sign
|
||||
if sign['status']!='PASS':row['package']['status']='FAIL';continue
|
||||
else:row['signature']={'status':'NOT_RUN','reason':'apksigner not found'}
|
||||
final=dist/(description['name']+'-'+a.cpu+'-debug.apk');shutil.copy2(apk,final)
|
||||
row['apk']={'file':str(final.relative_to(ROOT)),**apk_info}
|
||||
package_index.append({'profile':ident,'package':description['package'],'file':str(final.relative_to(ROOT)),
|
||||
'sha256':sha256(final),'cpu':a.cpu,'build_id':result['run_id'],
|
||||
'native_library_path_in_apk':apk_info['main_library_path']})
|
||||
except (ProbeError,OSError,ValueError,KeyError,zipfile.BadZipFile) as e:
|
||||
row['errors'].append(str(e))
|
||||
# A supposedly successful compiler/packager without valid output is a real test failure.
|
||||
if row['package']['status']=='PASS':row['package']['status']='FAIL'
|
||||
elif row['compile_link']['status']=='PASS':row['compile_link']['status']='FAIL'
|
||||
else:row['compile_link']={'status':'BLOCKED','reason':str(e)}
|
||||
finally:
|
||||
write_json(out/'result-in-progress.json',result)
|
||||
if need_source:
|
||||
try:verify(source)
|
||||
except ProbeError as e:
|
||||
result['source_modified_during_build']=str(e)
|
||||
for r in result['profiles']:
|
||||
if r['id']!='platform':r['compile_link']['status']='FAIL';r['errors'].append(str(e))
|
||||
write_json(dist/'apks.json',{'build_id':result['run_id'],'packages':package_index})
|
||||
write_json(ROOT/'dist/LATEST.json',{'build_id':result['run_id'],'directory':str(dist.relative_to(ROOT)),
|
||||
'packages':package_index})
|
||||
return finalize(result,out)
|
||||
def _application(descriptor:dict|None)->dict|None:
|
||||
if descriptor is None:return None
|
||||
return {key:descriptor[key] for key in ('name','caption','package','version','version_code','game_unit')}
|
||||
|
||||
def docker_build(a:argparse.Namespace,profiles:list[dict])->int:
|
||||
result,out=new_result(a.cpu,profiles,'Docker bootstrap not completed')
|
||||
if not shutil.which('docker'):
|
||||
for r in result['profiles']:r['compile_link']['reason']='Docker not installed. Use --local only with a configured CGE/FPC Android toolchain.'
|
||||
return finalize(result,out)
|
||||
def _record(build_id:str, cpu:str, status:str, application:dict|None=None,
|
||||
resource_count:int|None=None, compile:dict|None=None, package:dict|None=None,
|
||||
package_metadata:str|None=None, error:str|None=None)->dict:
|
||||
return {'schema':CURRENT_BUILD_SCHEMA,'build_id':build_id,'status':status,'cpu':cpu,'abi':CPUS[cpu][0],
|
||||
'application':application,'upstream_commit':COMMIT,'resource_count':resource_count,
|
||||
'compile':compile or {'status':'NOT_RUN','exit_code':None},
|
||||
'package':package or {'status':'NOT_RUN','exit_code':None},
|
||||
'package_metadata':package_metadata,'error':error}
|
||||
|
||||
def _finish(record:dict)->int:
|
||||
if record['status']!='PASS':
|
||||
CURRENT_OUTPUT_DIR.mkdir(parents=True,exist_ok=True)
|
||||
for path in (CURRENT_PACKAGE_METADATA,CURRENT_APK):
|
||||
path.unlink(missing_ok=True)
|
||||
record['package_metadata']=None
|
||||
write_json(CURRENT_BUILD_METADATA,record)
|
||||
return {'PASS':0,'FAIL':1,'BLOCKED':2}[record['status']]
|
||||
|
||||
def _valid_docker_handoff(record:object,build_id:str,code:int)->bool:
|
||||
fields={'schema','build_id','status','cpu','abi','application','upstream_commit','resource_count','compile','package','package_metadata','error'}
|
||||
statuses={'PASS':0,'FAIL':1,'BLOCKED':2};stage_statuses=set(statuses)|{'NOT_RUN'}
|
||||
if not isinstance(record,dict) or set(record)!=fields or record.get('schema')!=CURRENT_BUILD_SCHEMA or record.get('build_id')!=build_id or statuses.get(record.get('status'))!=code:return False
|
||||
cpu=record.get('cpu')
|
||||
if cpu not in CPUS or record.get('abi')!=CPUS[cpu][0] or record.get('upstream_commit')!=COMMIT:return False
|
||||
if record['resource_count'] is not None and (type(record['resource_count']) is not int or record['resource_count']<0):return False
|
||||
app=record['application']
|
||||
if app is not None and (not isinstance(app,dict) or set(app)!={'name','caption','package','version','version_code','game_unit'} or any(not isinstance(app[key],str) or not app[key] for key in ('name','caption','package','version','game_unit')) or type(app['version_code']) is not int):return False
|
||||
for stage in ('compile','package'):
|
||||
value=record[stage]
|
||||
if not isinstance(value,dict) or set(value)!={'status','exit_code'} or value.get('status') not in stage_statuses:return False
|
||||
if (value['status']=='NOT_RUN') != (value['exit_code'] is None) or value['exit_code'] is not None and type(value['exit_code']) is not int:return False
|
||||
if record['status']=='PASS':
|
||||
return app is not None and record['resource_count'] is not None and record['compile']['status']==record['package']['status']=='PASS' and record['package_metadata']=='out/current/package.json' and record['error'] is None
|
||||
return record['package_metadata'] is None and isinstance(record['error'],str) and bool(record['error'])
|
||||
|
||||
def _error(log:Path)->str:
|
||||
text=log.read_text(errors='replace') if log.exists() else ''
|
||||
return (first_errors(text) or [text[-1500:] or 'Build command failed'])[0]
|
||||
|
||||
def _prepare(build_id:str, diagnostics:bool=True)->tuple[Path,Path]:
|
||||
work=ROOT/'build'/build_id; logs=work/'logs'
|
||||
logs.mkdir(parents=True,exist_ok=True)
|
||||
if diagnostics:tool_versions(logs)
|
||||
return work,logs
|
||||
|
||||
def build_local(a:argparse.Namespace,build_id:str)->int:
|
||||
work,logs=_prepare(build_id, not bool(os.environ.get('KAM_BUILD_ID')))
|
||||
app=None; resources=None; compile={'status':'NOT_RUN','exit_code':None}; package={'status':'NOT_RUN','exit_code':None}
|
||||
try:
|
||||
vendor_root=ROOT/'vendor/kam_remake'
|
||||
receipt=verify(vendor_root) if (vendor_root/'SOURCE_RECEIPT.json').is_file() else (fetch(vendor_root) if not a.no_fetch else (_ for _ in ()).throw(ProbeError('Pinned source tree absent and --no-fetch requested')))
|
||||
audited=inventory(vendor_root)
|
||||
validated=validate_source_contract(load_source_contract(),vendor_root,receipt,audited)
|
||||
resolution=build_source_resolution(validated,vendor_root,ROOT/'code')
|
||||
descriptor=materialize_application_project(work/'application',resolution);app=_application(descriptor)
|
||||
resources=stage_runtime_resources(descriptor['data_root'])
|
||||
except (ProbeError,OSError,ValueError,KeyError) as error:
|
||||
return _finish(_record(build_id,a.cpu,'BLOCKED',app,None,compile,package,error=str(error)))
|
||||
if not shutil.which('castle-engine'):
|
||||
return _finish(_record(build_id,a.cpu,'BLOCKED',app,len(resources),compile,package,error='castle-engine not installed'))
|
||||
project=descriptor['project_dir']; compile_result=run_command(cge_command(a.cpu,'compile'),project,logs/'compile.log',a.timeout)
|
||||
compile={'status':compile_result['status'],'exit_code':compile_result['exit_code']}
|
||||
if compile['status']!='PASS':
|
||||
return _finish(_record(build_id,a.cpu,compile['status'],app,len(resources),compile,package,error=_error(logs/'compile.log')))
|
||||
try:
|
||||
candidates=list(project.rglob(f"lib{descriptor['name']}_android_{a.cpu}.so"))
|
||||
if len(candidates)!=1:raise ProbeError('Compiler exited 0 but expected exactly one native library')
|
||||
library=candidates[0]; inspect_elf(library.read_bytes(),a.cpu)
|
||||
except (ProbeError,OSError,ValueError) as error:
|
||||
compile['status']='FAIL'
|
||||
return _finish(_record(build_id,a.cpu,'FAIL',app,len(resources),compile,package,error=str(error)))
|
||||
package_result=run_command(cge_command(a.cpu,'package'),project,logs/'package.log',a.timeout)
|
||||
package={'status':package_result['status'],'exit_code':package_result['exit_code']}
|
||||
if package['status']!='PASS':
|
||||
return _finish(_record(build_id,a.cpu,package['status'],app,len(resources),compile,package,error=_error(logs/'package.log')))
|
||||
try:
|
||||
candidates=list(project.rglob('*.apk'))
|
||||
if not candidates:raise ProbeError('Packaging exited 0 but no APK')
|
||||
if len({sha256(path) for path in candidates})!=1:raise ProbeError('Multiple different APK outputs')
|
||||
apk=candidates[0]; apk_info=inspect_apk(apk,a.cpu,library.name,sha256(library),library.read_bytes())
|
||||
signer=signature_tool()
|
||||
if signer:
|
||||
signed=run_command([signer,'verify','--verbose',str(apk)],project,logs/'signature.log',a.timeout)
|
||||
if signed['status']!='PASS':raise ProbeError(_error(logs/'signature.log'))
|
||||
except (ProbeError,OSError,ValueError,KeyError,zipfile.BadZipFile) as error:
|
||||
package['status']='FAIL'
|
||||
return _finish(_record(build_id,a.cpu,'FAIL',app,len(resources),compile,package,error=str(error)))
|
||||
try:
|
||||
CURRENT_OUTPUT_DIR.mkdir(parents=True,exist_ok=True)
|
||||
temporary=CURRENT_APK.with_name(CURRENT_APK.name+'.tmp')
|
||||
shutil.copyfile(apk,temporary);os.replace(temporary,CURRENT_APK)
|
||||
package_data={'schema':CURRENT_PACKAGE_SCHEMA,'build_id':build_id,
|
||||
'application':{key:app[key] for key in ('name','package','version','version_code')},
|
||||
'cpu':a.cpu,'abi':CPUS[a.cpu][0],
|
||||
'apk':{'file':'out/current/kam-mobile.apk','sha256':sha256(CURRENT_APK),'size':CURRENT_APK.stat().st_size,
|
||||
'native_library_path_in_apk':apk_info['main_library_path']}}
|
||||
write_json(CURRENT_PACKAGE_METADATA,package_data)
|
||||
return _finish(_record(build_id,a.cpu,'PASS',app,len(resources),compile,package,
|
||||
'out/current/package.json',None))
|
||||
except (OSError,ValueError,KeyError) as error:
|
||||
package['status']='FAIL'
|
||||
return _finish(_record(build_id,a.cpu,'FAIL',app,len(resources),compile,package,error=str(error)))
|
||||
|
||||
def docker_build(a:argparse.Namespace,build_id:str)->int:
|
||||
compile={'status':'NOT_RUN','exit_code':None};package={'status':'NOT_RUN','exit_code':None}
|
||||
try:
|
||||
work,logs=_prepare(build_id)
|
||||
if not shutil.which('docker'):raise ProbeError('Docker not installed')
|
||||
lock=ROOT/'config/toolchain.lock.json'
|
||||
if lock.is_file():
|
||||
image=json.loads(lock.read_text())['image']
|
||||
if not re.fullmatch(r'[\w./-]+@sha256:[a-f0-9]{64}',image):raise ProbeError('Invalid toolchain digest lock')
|
||||
p=subprocess.run(['docker','image','inspect',image],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
|
||||
if p.returncode:
|
||||
r=run_command(['docker','pull','--platform=linux/amd64',image],ROOT,out/'docker-pull.log',a.timeout)
|
||||
if r['status']!='PASS':raise ProbeError('Cannot obtain locked Docker image; see docker-pull.log')
|
||||
if subprocess.run(['docker','image','inspect',image],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL).returncode:
|
||||
if run_command(['docker','pull','--platform=linux/amd64',image],ROOT,logs/'docker-pull.log',a.timeout)['status']!='PASS':raise ProbeError('Cannot obtain locked Docker image')
|
||||
else:
|
||||
r=run_command(['docker','pull','--platform=linux/amd64',IMAGE_TAG],ROOT,out/'docker-pull.log',a.timeout)
|
||||
if r['status']!='PASS':raise ProbeError('Docker image pull failed; see docker-pull.log')
|
||||
raw=subprocess.check_output(['docker','image','inspect',IMAGE_TAG],text=True)
|
||||
info=json.loads(raw)[0];digests=info.get('RepoDigests',[])
|
||||
image=next((d for d in digests if 'castle-engine-cloud-builds-tools@sha256:' in d),None)
|
||||
if run_command(['docker','pull','--platform=linux/amd64',IMAGE_TAG],ROOT,logs/'docker-pull.log',a.timeout)['status']!='PASS':raise ProbeError('Docker image pull failed')
|
||||
info=json.loads(subprocess.check_output(['docker','image','inspect',IMAGE_TAG],text=True))[0]
|
||||
image=next((value for value in info.get('RepoDigests',[]) if 'castle-engine-cloud-builds-tools@sha256:' in value),None)
|
||||
if image is None:raise ProbeError('Image has no immutable RepoDigest')
|
||||
write_json(lock,{'image':image,'image_id':info['Id'],'resolved_from':IMAGE_TAG,'locked_at':stamp(),
|
||||
'note':'Subsequent runs use this digest, never silently advance the tag.'})
|
||||
cache=ROOT/'.cache/home';cache.mkdir(parents=True,exist_ok=True)
|
||||
cmd=['docker','run','--rm','--platform=linux/amd64','--user','0:0',
|
||||
'-e','HOME=/work/.cache/home','-v',str(ROOT)+':/work','-w','/work']
|
||||
write_json(lock,{'image':image,'image_id':info['Id'],'resolved_from':IMAGE_TAG,'locked_at':stamp(),'note':'Subsequent runs use this digest, never silently advance the tag.'})
|
||||
cmd=['docker','run','--rm','--platform=linux/amd64','--user','0:0','-e','HOME=/work/.cache/home',
|
||||
'-e','KAM_BUILD_ID='+build_id,'-v',str(ROOT)+':/work','-w','/work']
|
||||
if os.environ.get('GITHUB_TOKEN'):cmd+=['-e','GITHUB_TOKEN']
|
||||
if a.assets:
|
||||
resource=Path(a.assets).resolve()
|
||||
if not resource.is_dir():raise ProbeError('Asset directory not found: '+str(resource))
|
||||
cmd+=['-v',str(resource)+':/probe-assets:ro']
|
||||
cmd+=['--entrypoint','python3',image,'tools/runner.py','--local','--cpu',a.cpu,
|
||||
'--profiles',a.profiles,'--timeout',str(a.timeout)]
|
||||
cmd+=['--entrypoint','python3',image,'tools/runner.py','--local','--cpu',a.cpu,'--timeout',str(a.timeout)]
|
||||
if a.no_fetch:cmd+=['--no-fetch']
|
||||
if a.package_all:cmd+=['--package-all']
|
||||
if a.assets:cmd+=['--assets','/probe-assets']
|
||||
for rel in a.asset_path or []:cmd+=['--asset-path',rel]
|
||||
write_json(out/'docker-command.json',{'command':cmd,'image':image})
|
||||
# Stream the user's real build directly. No background processes/promises.
|
||||
latest=ROOT/'reports/LATEST.json'
|
||||
before=latest.read_bytes() if latest.exists() else None
|
||||
code=subprocess.call(cmd,cwd=ROOT)
|
||||
after=latest.read_bytes() if latest.exists() else None
|
||||
if code in (0,1,2) and after is not None and after!=before:
|
||||
fresh=json.loads(after)
|
||||
if (ROOT/fresh['directory']/'result.json').is_file():return code
|
||||
raise ProbeError('Container could not finish the report; Docker exit code '+str(code))
|
||||
except (ProbeError,OSError,ValueError,KeyError,subprocess.CalledProcessError) as e:
|
||||
for r in result['profiles']:r['compile_link']['reason']=str(e)
|
||||
return finalize(result,out)
|
||||
if code not in (0,1,2):raise ProbeError('Docker exit code '+str(code))
|
||||
record=json.loads(CURRENT_BUILD_METADATA.read_text())
|
||||
if not _valid_docker_handoff(record,build_id,code):raise ProbeError('Container current build metadata is missing, stale, or inconsistent')
|
||||
return code
|
||||
except (ProbeError,OSError,ValueError,KeyError,subprocess.CalledProcessError) as error:
|
||||
return _finish(_record(build_id,a.cpu,'BLOCKED',None,None,compile,package,error=str(error)))
|
||||
|
||||
def main()->int:
|
||||
ap=argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument('--local',action='store_true',help='Use installed toolchain; still compile Android only')
|
||||
ap.add_argument('--cpu',choices=CPUS,default='aarch64')
|
||||
ap.add_argument('--profiles',default='all',help='all or comma-separated IDs from config/profiles.json')
|
||||
ap.add_argument('--no-fetch',action='store_true',help='Require previously fetched/verified source tree')
|
||||
ap.add_argument('--assets',help='Optional private KaM resource root; no resources are downloaded')
|
||||
ap.add_argument('--asset-path',action='append',help='Relative path under --assets to stage. Repeat for a small selected fixture set.')
|
||||
ap.add_argument('--package-all',action='store_true',help='Package every successfully linked probe for device startup checks')
|
||||
ap.add_argument('--timeout',type=int,default=1800,help='Per-stage safety timeout in seconds')
|
||||
a=ap.parse_args()
|
||||
if a.asset_path and not a.assets:ap.error('--asset-path requires --assets')
|
||||
if a.timeout<1:ap.error('--timeout must be positive')
|
||||
try:profiles=selected_profiles(a.profiles)
|
||||
except ProbeError as e:ap.error(str(e))
|
||||
return build_local(a,profiles) if a.local else docker_build(a,profiles)
|
||||
inherited=os.environ.get('KAM_BUILD_ID')
|
||||
if inherited and not re.fullmatch(r'\d{8}T\d{6}Z-[0-9a-f]{8}',inherited):ap.error('Invalid KAM_BUILD_ID')
|
||||
build_id=inherited or stamp()+'-'+uuid.uuid4().hex[:8]
|
||||
return build_local(a,build_id) if a.local else docker_build(a,build_id)
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
Reference in new issue
Block a user