140 lines
6.7 KiB
Python
140 lines
6.7 KiB
Python
from pathlib import Path
|
|
import importlib.util, json, os, shutil, subprocess, sys, tarfile, zipfile
|
|
|
|
ROOT = Path(__file__).resolve().parent
|
|
SEED = ROOT/'seed'
|
|
SEED.mkdir(exist_ok=True)
|
|
with zipfile.ZipFile(ROOT/'candidate.ota') as z:
|
|
with z.open('payload.tar.gz') as p, tarfile.open(fileobj=p, mode='r|gz') as t:
|
|
for m in t:
|
|
assert m.isfile() and not m.name.startswith('/') and '..' not in Path(m.name).parts
|
|
out=SEED/m.name;out.parent.mkdir(parents=True,exist_ok=True)
|
|
out.write_bytes(t.extractfile(m).read());out.chmod(m.mode)
|
|
sys.path.insert(0,str(SEED/'software'))
|
|
|
|
def module(name,path):
|
|
spec=importlib.util.spec_from_file_location(name,path)
|
|
m=importlib.util.module_from_spec(spec);sys.modules[name]=m;spec.loader.exec_module(m);return m
|
|
|
|
oldpackage=module('app.ota.legacy_package',ROOT/'baseline/package.py')
|
|
worker=module('legacy_worker',ROOT/'baseline/ota_worker.py')
|
|
worker.inspect_package=oldpackage.inspect_package
|
|
worker.extract_payload=oldpackage.extract_payload
|
|
from scripts import ota_components as c
|
|
|
|
def reset():
|
|
for p in [Path('/opt'),Path('/var/lib'),Path('/run'),Path('/usr/local/bin'),Path('/etc/systemd/system')]:
|
|
for entry in p.iterdir():
|
|
if entry.is_dir() and not entry.is_symlink(): shutil.rmtree(entry)
|
|
else: entry.unlink()
|
|
c.DATA.mkdir()
|
|
c.TARGET.mkdir()
|
|
(c.TARGET/'VERSION').write_text('1.0.3',encoding='utf-8')
|
|
c.RELEASES.mkdir()
|
|
(c.DATA/'keep.txt').write_bytes(b'user resource preserved')
|
|
for name in ('app-unit','ota-unit'):
|
|
c.FILES[name].write_bytes(('old '+name).encode())
|
|
|
|
if '--resume' not in sys.argv:
|
|
reset()
|
|
else:
|
|
assert '413 passed' in (ROOT/'validation.log').read_text(encoding='utf-8',errors='replace')
|
|
request={'schema_version':1,'job_id':'baseline103','target_version':'1.1.0','current_version':'1.0.3',
|
|
'package_path':'/opt/candidate.ota','status_path':str(c.RUNTIME/'ota-status.json'),
|
|
'accepted_at':'2026-09-07T00:00:00Z'}
|
|
c.RUNTIME.mkdir(parents=True,exist_ok=True)
|
|
(c.RUNTIME/'ota-request.json').write_text(json.dumps(request),encoding='utf-8')
|
|
shutil.copy2(ROOT/'candidate.ota',request['package_path'])
|
|
tx=worker.Transaction(request)
|
|
tx.start_visual=lambda: None
|
|
tx.stop_visual=lambda: None
|
|
actual_run=worker.run
|
|
def run(command,**kwargs):
|
|
if any('dedicated_host.py' in p for p in command):
|
|
print('Dedicated host policy: separately checked outside namespace',flush=True)
|
|
return
|
|
actual_run(command,**kwargs)
|
|
worker.run=run
|
|
if '--resume' in sys.argv:
|
|
actual_run([str(tx.release/'.venv/bin/python'),'-m','scripts.prepare_data_root','--data-root',str(tx.data_candidate),'--runtime-root',str(tx.work_root/'migration-runtime')],cwd=tx.release,env={**os.environ,'PYTHONPATH':str(tx.release)})
|
|
else:
|
|
tx.prepare()
|
|
assert c.FILES['frpc'].is_file()
|
|
assert (c.DATA/c.JOURNAL/'state.json').is_file()
|
|
tx.switch()
|
|
# Hardware/HTTP health is intentionally simulated in this isolated filesystem.
|
|
# The real worker ordering, venv, compile, pytest, migration and installation run.
|
|
tx.succeed()
|
|
c.finish()
|
|
assert (c.TARGET/'VERSION').read_text().strip()=='1.1.0'
|
|
assert (c.DATA/'keep.txt').read_bytes()==b'user resource preserved'
|
|
assert not (c.DATA/c.JOURNAL).exists()
|
|
c.check_installed(c.TARGET,'1.1.0')
|
|
print('PASS: real 1.0.3 worker -> 1.1.0, actual offline venv/tests/native compile/frpc; simulated service health',flush=True)
|
|
|
|
# Exercise idempotence and repair with the actual ARM64 shell installer.
|
|
bundle=SEED/'software/system-dependencies/frpc'
|
|
env=os.environ.copy();env['FRPC_BUNDLE']=str(bundle);env['FRPC_RUN_USER']=c.account()
|
|
installer=['/bin/sh',str(c.TARGET/'scripts/install_frpc_system.sh')]
|
|
state_path=Path('/run/fake-systemctl.json')
|
|
state_path.write_text(json.dumps({c.FRP:{'active':True,'enabled':True}}))
|
|
before=c.FILES['frpc'].stat().st_mtime_ns
|
|
subprocess.run(installer,env=env,check=True)
|
|
assert c.FILES['frpc'].stat().st_mtime_ns==before
|
|
c.FILES['frpc'].write_bytes(b'corrupt')
|
|
subprocess.run(installer,env=env,check=True)
|
|
c.check_installed(c.TARGET,'1.1.0')
|
|
c.FILES['frpc-dropin'].unlink()
|
|
subprocess.run(installer,env=env,check=True)
|
|
c.check_installed(c.TARGET,'1.1.0')
|
|
assert json.loads(state_path.read_text())[c.FRP]=={'active':True,'enabled':True}
|
|
print('PASS: existing binary skipped; damaged binary and missing drop-in repaired; enabled/active preserved',flush=True)
|
|
|
|
# Test independent rollback after the legacy worker switches program and data.
|
|
for stage in ('installed','switched','data-gap','service-failure','health-failure'):
|
|
reset()
|
|
source=c.RELEASES/'1.1.0-baseline103'
|
|
shutil.copytree(SEED/'software',source)
|
|
candidate=c.DATA.with_name('matrix-screen-controller.ota.baseline103')
|
|
shutil.copytree(c.DATA,candidate)
|
|
c.RUNTIME.mkdir(parents=True,exist_ok=True)
|
|
(c.RUNTIME/'ota-request.json').write_text(json.dumps(request),encoding='utf-8')
|
|
c.begin(source,candidate)
|
|
assert c.FILES['frpc'].exists()
|
|
if stage in ('service-failure','health-failure'):
|
|
failed_tx=worker.Transaction(request)
|
|
failed_tx.work_root.mkdir(parents=True)
|
|
failed_tx.start_visual=lambda: None
|
|
failed_tx.stop_visual=lambda: None
|
|
if stage=='service-failure':
|
|
state_path.write_text(json.dumps({'start_fail_once':c.SERVICE}))
|
|
try:
|
|
failed_tx.switch()
|
|
raise RuntimeError('injected health failure')
|
|
except Exception as error:
|
|
failed_tx.rollback(error)
|
|
if stage in ('switched','data-gap'):
|
|
c.TARGET.rename(c.RELEASES/'1.0.3-pre-ota-baseline103')
|
|
c.TARGET.symlink_to(source,target_is_directory=True)
|
|
c.DATA.rename(c.DATA.with_name('matrix-screen-controller.rollback.baseline103'))
|
|
if stage=='switched': candidate.rename(c.DATA)
|
|
c.finish(boot=True)
|
|
assert (c.TARGET/'VERSION').read_text().strip()=='1.0.3'
|
|
assert (c.DATA/'keep.txt').read_bytes()==b'user resource preserved'
|
|
assert not c.FILES['frpc'].exists()
|
|
assert not (c.DATA/c.JOURNAL).exists()
|
|
print('PASS: component/application interruption recovery '+stage,flush=True)
|
|
|
|
# v2 patch must fail in the real old parser, even without its own path policy.
|
|
from app.ota.package import build_package
|
|
from app.ota.versioning import SoftwareVersion
|
|
mini=ROOT/'patch-source';mini.mkdir(exist_ok=True)
|
|
(mini/'VERSION').write_text('1.1.1',encoding='utf-8')
|
|
patch=ROOT/'patch.ota';patch.unlink(missing_ok=True)
|
|
build_package(mini,SEED/'wheelhouse',patch,version=SoftwareVersion.parse('1.1.1'),release_notes='gate test')
|
|
try:
|
|
oldpackage.inspect_package(patch,current_version=SoftwareVersion.parse('1.0.3'))
|
|
except oldpackage.OtaPackageError:
|
|
print('PASS: real 1.0.3 parser rejects v2 patch before installation',flush=True)
|
|
else: raise AssertionError('legacy updater accepted patch')
|