354 lines
17 KiB
Python
354 lines
17 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import zipfile
|
|
|
|
import pytest
|
|
|
|
from app.ota.policy import check_upgrade, export_bundle, package_format, read_policy
|
|
from app.ota.versioning import SoftwareVersion as V
|
|
from app.ota.package import build_package, inspect_package, OtaPackageError
|
|
from scripts import ota_components as c
|
|
|
|
|
|
@pytest.mark.parametrize('current,target', [
|
|
('1.0.0', '1.0.3'), ('1.0.3', '1.1.0'), ('1.0.6', '1.1.0'),
|
|
('1.1.0', '1.1.3'), ('1.1.0', '1.2.0'), ('1.1.8', '1.2.0'), ('1.2.0', '1.2.9'),
|
|
])
|
|
def test_allowed_upgrade_paths(current, target):
|
|
check_upgrade(V.parse(current), V.parse(target))
|
|
|
|
|
|
@pytest.mark.parametrize('current,target,required', [
|
|
('1.0.6', '1.1.1', '1.1.0'), ('1.0.3', '1.2.0', '1.1.0'),
|
|
('1.0.0', '1.2.5', '1.1.0'), ('1.1.3', '1.2.1', '1.2.0'),
|
|
])
|
|
def test_cannot_skip_software_install(current, target, required):
|
|
with pytest.raises(ValueError, match=required):
|
|
check_upgrade(V.parse(current), V.parse(target))
|
|
|
|
|
|
def source_and_wheels(root, version):
|
|
source = root / 'source'
|
|
source.mkdir()
|
|
(source / 'VERSION').write_text(version, encoding='utf-8')
|
|
wheels = root / 'wheels'
|
|
wheels.mkdir()
|
|
(wheels / 'SHA256SUMS').write_text('test', encoding='utf-8')
|
|
return source, wheels
|
|
|
|
|
|
def test_bridge_v1_and_patch_v2_with_identical_manifest_shape(tmp_path):
|
|
for version in ('1.1.0', '1.1.3'):
|
|
root = tmp_path / version
|
|
root.mkdir()
|
|
source, wheels = source_and_wheels(root, version)
|
|
package = root / 'release.ota'
|
|
build_package(source, wheels, package, version=V.parse(version), release_notes='test')
|
|
with zipfile.ZipFile(package) as z:
|
|
manifest = json.loads(z.read('manifest.json'))
|
|
assert manifest['format_version'] == package_format(V.parse(version))
|
|
if version == '1.1.3':
|
|
with pytest.raises(OtaPackageError, match='1.1.0'):
|
|
inspect_package(package, current_version=V.parse('1.0.6'))
|
|
inspect_package(package, current_version=V.parse('1.1.0'))
|
|
|
|
|
|
def test_patch_rejects_system_binary_payload(tmp_path):
|
|
source, wheels = source_and_wheels(tmp_path, '1.1.1')
|
|
with pytest.raises(OtaPackageError, match='普通补丁'):
|
|
build_package(source, wheels, tmp_path/'bad.ota', version=V.parse('1.1.1'),
|
|
release_notes='test', system_dependencies=wheels)
|
|
|
|
|
|
def test_dependency_change_requires_checkpoint(tmp_path):
|
|
source = tmp_path / 'source'
|
|
source.mkdir()
|
|
binary = tmp_path / 'deps/frp/v/frpc'
|
|
binary.parent.mkdir(parents=True)
|
|
binary.write_bytes(b'original')
|
|
policy = {'schema_version': 1, 'checkpoints': [{'version': '1.1.0', 'components': {
|
|
'frpc': {'version': 'v', 'bundle': 'frp/v', 'sha256': hashlib.sha256(b'original').hexdigest()}}}]}
|
|
(source/'UPGRADE_POLICY.json').write_text(json.dumps(policy), encoding='utf-8')
|
|
assert export_bundle(source, tmp_path/'deps', V.parse('1.0.6'), V.parse('1.1.0')) == binary.parent
|
|
assert export_bundle(source, tmp_path/'deps', V.parse('1.1.0'), V.parse('1.1.3')) is None
|
|
binary.write_bytes(b'replaced')
|
|
with pytest.raises(ValueError, match='依赖发生变化'):
|
|
export_bundle(source, tmp_path/'deps', V.parse('1.1.0'), V.parse('1.1.3'))
|
|
policy['checkpoints'][0]['version'] = '1.2.0'
|
|
(source/'UPGRADE_POLICY.json').write_text(json.dumps(policy), encoding='utf-8')
|
|
with pytest.raises(ValueError, match='consecutive'):
|
|
read_policy(source)
|
|
|
|
|
|
@pytest.fixture
|
|
def host(tmp_path, monkeypatch):
|
|
for name, relative in [('DATA', 'var/data'), ('TARGET', 'opt/current'), ('RELEASES', 'opt/releases'),
|
|
('RUNTIME', 'run'), ('HELPER', 'opt/recover.py'), ('RECOVERY_UNIT', 'etc/recovery.service'),
|
|
('WORK_ROOT', 'opt/work'), ('ACCOUNT_POLICY', 'etc/account'),
|
|
('RUNTIME_GUARD', 'run/systemd/service.d/guard.conf')]:
|
|
monkeypatch.setattr(c, name, tmp_path / relative)
|
|
files = {name: tmp_path / 'etc' / name for name in c.FILES}
|
|
monkeypatch.setattr(c, 'FILES', files)
|
|
c.DATA.mkdir(parents=True)
|
|
c.RELEASES.mkdir(parents=True)
|
|
for name, path in files.items():
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(('old-'+name).encode())
|
|
commands = []
|
|
def run(args, **kwargs):
|
|
commands.append(args)
|
|
return subprocess.CompletedProcess(args, 0, b'', b'')
|
|
monkeypatch.setattr(c, 'run', run)
|
|
monkeypatch.setattr(c, 'sync_directory', lambda path: None)
|
|
monkeypatch.setattr(c, 'apply_metadata', lambda *args: None)
|
|
return commands
|
|
|
|
|
|
def make_journal():
|
|
journal = c.DATA / c.JOURNAL
|
|
journal.mkdir(parents=True)
|
|
record = {'job_id': 'job123', 'version': '1.1.0', 'old_version': '1.0.6',
|
|
'accepted_at': '2026-09-07T00:00:00Z', 'previous_target': None,
|
|
'permissions': {}, 'active': True, 'enabled': True,
|
|
'files': {name: {'mode': 0o644, 'uid': 0, 'gid': 0} for name in c.FILES}}
|
|
for name, path in c.FILES.items():
|
|
(journal/name).write_bytes(path.read_bytes())
|
|
(journal/'state.json').write_text(json.dumps(record), encoding='utf-8')
|
|
return journal, record
|
|
|
|
|
|
def test_failed_install_restores_system_bytes_and_running_state(host):
|
|
journal, record = make_journal()
|
|
for name in ('frpc', 'frpc-unit', 'frpc-dropin'):
|
|
c.FILES[name].write_bytes(b'new')
|
|
c.restore_components(journal, record)
|
|
for name in ('frpc', 'frpc-unit', 'frpc-dropin'):
|
|
assert c.FILES[name].read_bytes() == ('old-'+name).encode()
|
|
assert ['systemctl', 'enable', c.FRP] in host
|
|
assert ['systemctl', 'start', c.FRP] in host
|
|
|
|
|
|
def test_mobile_rollback_restores_files_masks_and_running_state(host):
|
|
journal, record = make_journal()
|
|
record['mobile_services'] = {
|
|
'aw859-bluetooth.service': {'enabled_state': 'masked', 'active': False},
|
|
'bluetooth.service': {'enabled_state': 'enabled', 'active': True},
|
|
}
|
|
record['files']['bluetooth-original'] = None
|
|
for name in c.MOBILE_FILES:
|
|
c.FILES[name].write_bytes(b'changed-by-mobile-install')
|
|
c.restore_components(journal, record)
|
|
assert not c.FILES['bluetooth-original'].exists()
|
|
for name in set(c.MOBILE_FILES) - {'bluetooth-original'}:
|
|
assert c.FILES[name].read_bytes() == ('old-' + name).encode()
|
|
assert ['systemctl', 'mask', 'aw859-bluetooth.service'] in host
|
|
assert ['systemctl', 'enable', 'bluetooth.service'] in host
|
|
assert ['systemctl', 'start', 'bluetooth.service'] in host
|
|
assert ['systemctl', 'start', 'aw859-bluetooth.service'] not in host
|
|
|
|
|
|
def test_legacy_component_journal_does_not_touch_bluetooth(host):
|
|
journal, record = make_journal()
|
|
c.restore_components(journal, record)
|
|
assert not any(unit in args for args in host for unit in c.MOBILE_SERVICES)
|
|
|
|
|
|
def test_power_loss_between_data_renames_recovers_original(host):
|
|
journal, record = make_journal()
|
|
(c.DATA/'user-content').write_bytes(b'unchanged')
|
|
backup = c.DATA.with_name('matrix-screen-controller.rollback.job123')
|
|
c.DATA.rename(backup)
|
|
assert c.locate_journal() == backup / c.JOURNAL
|
|
recovered = c.recover_application(backup/c.JOURNAL, record)
|
|
assert recovered == c.DATA/c.JOURNAL
|
|
assert (c.DATA/'user-content').read_bytes() == b'unchanged'
|
|
assert not backup.exists()
|
|
|
|
|
|
def test_legacy_failure_restores_ota_unit_too(host):
|
|
journal, record = make_journal()
|
|
c.FILES['ota-unit'].write_bytes(b'new-worker-unit')
|
|
c.recover_application(journal, record)
|
|
assert c.FILES['ota-unit'].read_bytes() == b'old-ota-unit'
|
|
|
|
|
|
def test_success_cleanup_keeps_installed_component_and_user_data(host):
|
|
journal, record = make_journal()
|
|
c.TARGET.mkdir(parents=True)
|
|
(c.TARGET/'VERSION').write_text('1.1.0', encoding='utf-8')
|
|
result = {'last_result': {'status': 'success', 'target_version': '1.1.0', 'installed_at': '2026-09-07T01:00:00Z'}}
|
|
(c.DATA/'ota/state.json').write_text(json.dumps(result), encoding='utf-8')
|
|
(c.DATA/'user-content').write_bytes(b'keep')
|
|
c.FILES['frpc'].write_bytes(b'new-verified')
|
|
c.finish()
|
|
assert c.FILES['frpc'].read_bytes() == b'new-verified'
|
|
assert (c.DATA/'user-content').read_bytes() == b'keep'
|
|
assert not journal.exists()
|
|
assert ['systemctl', 'stop', c.FRP] not in host
|
|
|
|
|
|
def test_old_success_result_cannot_commit_new_transaction(host):
|
|
journal, record = make_journal()
|
|
c.TARGET.mkdir(parents=True)
|
|
(c.TARGET/'VERSION').write_text('1.1.0', encoding='utf-8')
|
|
(c.DATA/'ota/state.json').write_text(json.dumps({'last_result': {
|
|
'status': 'success', 'target_version': '1.1.0', 'installed_at': '2026-09-01T00:00:00Z'}}), encoding='utf-8')
|
|
assert not c.committed(record)
|
|
|
|
|
|
def test_normal_migration_does_not_touch_components(host):
|
|
c.begin(c.TARGET, c.DATA)
|
|
assert host == []
|
|
|
|
|
|
def test_mobile_only_patch_enters_durable_component_transaction(host, monkeypatch):
|
|
from types import SimpleNamespace
|
|
source = c.RELEASES / '1.1.2-mobilepatch'
|
|
source.mkdir()
|
|
(source / 'VERSION').write_text('1.1.2', encoding='utf-8')
|
|
(source / 'scripts').mkdir()
|
|
(source / 'scripts/install_mobile_bluetooth.sh').write_text('# test fixture', encoding='utf-8')
|
|
candidate = c.DATA.with_name('matrix-screen-controller.ota.mobilepatch')
|
|
candidate.mkdir()
|
|
c.RUNTIME.mkdir(parents=True, exist_ok=True)
|
|
(c.RUNTIME / 'ota-request.json').write_text(json.dumps({
|
|
'job_id': 'mobilepatch', 'current_version': '1.1.1', 'target_version': '1.1.2'
|
|
}), encoding='utf-8')
|
|
monkeypatch.setattr(c.os, 'geteuid', lambda: 0, raising=False)
|
|
monkeypatch.setattr(c.os, 'uname', lambda: SimpleNamespace(machine='aarch64'), raising=False)
|
|
monkeypatch.setattr(c, 'check_installed', lambda *args: None)
|
|
monkeypatch.setattr(c, 'protect_runtime', lambda: None)
|
|
monkeypatch.setattr(c, 'mirror_permissions', lambda *args: None)
|
|
# Host test verifies transaction ordering; POSIX durability is covered by
|
|
# real Linux lifecycle checks, not Windows read-only descriptor fsync.
|
|
monkeypatch.setattr(c.os, 'fsync', lambda *args: None)
|
|
c.begin(source, candidate)
|
|
record = json.loads((c.DATA / c.JOURNAL / 'state.json').read_text(encoding='utf-8'))
|
|
assert set(record['mobile_services']) == set(c.MOBILE_SERVICES)
|
|
assert (candidate / c.JOURNAL / 'state.json').is_file()
|
|
assert ['/bin/sh', str(source / 'scripts/install_mobile_bluetooth.sh')] in host
|
|
assert not any('install_frpc_system.sh' in str(arg) for args in host for arg in args)
|
|
|
|
|
|
def release_project(tmp_path, monkeypatch):
|
|
from scripts import export_release as exporter
|
|
source = tmp_path / 'software'
|
|
source.mkdir()
|
|
(source / 'VERSION').write_text('1.0.6\n', encoding='utf-8')
|
|
(source / 'FEATURE_UPDATED_AT').write_text('2026-09-07T20:00+08:00\n', encoding='utf-8')
|
|
deps = tmp_path / '发布更新相关/其他依赖'
|
|
binary = deps / 'frp/v/frpc'
|
|
binary.parent.mkdir(parents=True)
|
|
binary.write_bytes(b'frpc')
|
|
digest = hashlib.sha256(b'frpc').hexdigest()
|
|
(binary.parent/'SHA256SUMS').write_text(f'{digest} frpc\n', encoding='utf-8')
|
|
wheels = deps / 'aarch64-py311'
|
|
wheels.mkdir()
|
|
(wheels/'SHA256SUMS').write_text('fixture', encoding='utf-8')
|
|
policy = {'schema_version': 1, 'checkpoints': [{'version': '1.1.0', 'components': {
|
|
'frpc': {'version': 'v', 'bundle': 'frp/v', 'sha256': digest}}}]}
|
|
(source/'UPGRADE_POLICY.json').write_text(json.dumps(policy), encoding='utf-8')
|
|
(tmp_path/'发布记录.json').write_text('{"schema_version":1,"releases":[]}', encoding='utf-8')
|
|
monkeypatch.setattr(exporter, 'SOURCE_ROOT', source)
|
|
return exporter, source
|
|
|
|
|
|
def test_export_checkpoint_then_patch_without_repeating_binary(tmp_path, monkeypatch):
|
|
import sys
|
|
import tarfile
|
|
exporter, source = release_project(tmp_path, monkeypatch)
|
|
monkeypatch.setattr(sys, 'argv', ['export', 'ota', '--version', '1.1.0', '--notes', 'test'])
|
|
assert exporter.main() == 0
|
|
assert (source/'VERSION').read_text(encoding='utf-8').strip() == '1.1.0'
|
|
monkeypatch.setattr(sys, 'argv', ['export', 'ota', '--notes', 'patch'])
|
|
assert exporter.main() == 0
|
|
artifact = next((tmp_path/'发布更新相关/OTA数据包/1.1.1').glob('*.ota'))
|
|
with zipfile.ZipFile(artifact) as z, z.open('payload.tar.gz') as f, tarfile.open(fileobj=f, mode='r|gz') as t:
|
|
assert not any('system-dependencies' in item.name for item in t)
|
|
records = json.loads((tmp_path/'发布记录.json').read_text(encoding='utf-8'))['releases']
|
|
assert [r['package_kind'] for r in records] == ['software-install', 'application']
|
|
assert all(r['artifact_path'].startswith('发布更新相关/OTA数据包/') for r in records)
|
|
assert all((tmp_path / r['artifact_path']).is_file() for r in records)
|
|
assert (source/'FEATURE_UPDATED_AT').read_text(encoding='utf-8') == '2026-09-07T20:00+08:00\n'
|
|
|
|
|
|
def test_failed_export_does_not_consume_version(tmp_path, monkeypatch):
|
|
import sys
|
|
exporter, source = release_project(tmp_path, monkeypatch)
|
|
original = (tmp_path/'发布记录.json').read_bytes()
|
|
original_version = (source/'VERSION').read_bytes()
|
|
monkeypatch.setattr(sys, 'argv', ['export', 'ota', '--version', '1.1.0', '--notes', 'test'])
|
|
def fail(*args):
|
|
raise OSError('injected history write failure')
|
|
monkeypatch.setattr(exporter, '_atomic_json', fail)
|
|
with pytest.raises(OSError, match='injected'):
|
|
exporter.main()
|
|
assert (source/'VERSION').read_bytes() == original_version
|
|
assert (tmp_path/'发布记录.json').read_bytes() == original
|
|
assert not list((tmp_path/'发布更新相关/OTA数据包').iterdir())
|
|
assert not (tmp_path/'.release-export.lock').exists()
|
|
|
|
|
|
def test_recovery_error_keeps_durable_journal(host, monkeypatch):
|
|
journal, record = make_journal()
|
|
def fail(*args):
|
|
raise OSError('injected restore failure')
|
|
monkeypatch.setattr(c, 'restore_components', fail)
|
|
with pytest.raises(OSError, match='injected'):
|
|
c.finish()
|
|
assert (journal/'state.json').is_file()
|
|
|
|
|
|
def test_provisioned_account_does_not_need_sudo_group(host, monkeypatch):
|
|
import sys
|
|
from types import SimpleNamespace
|
|
c.FILES['frpc-dropin'].unlink()
|
|
c.ACCOUNT_POLICY.write_text('maintainer ALL=(ALL:ALL) ALL\n', encoding='utf-8')
|
|
monkeypatch.setitem(sys.modules, 'pwd', SimpleNamespace(getpwnam=lambda name: SimpleNamespace(pw_uid=1001)))
|
|
monkeypatch.setitem(sys.modules, 'grp', SimpleNamespace())
|
|
assert c.account() == 'maintainer'
|
|
c.ACCOUNT_POLICY.write_text('invalid', encoding='utf-8')
|
|
with pytest.raises(RuntimeError, match='账户配置无效'):
|
|
c.account()
|
|
|
|
|
|
@pytest.mark.parametrize('fail_commit', [False, True])
|
|
def test_same_version_repair_promotes_exact_candidate_and_preserves_old_artifact(tmp_path, monkeypatch, fail_commit):
|
|
import sys
|
|
from scripts.repair_ota_release import promote
|
|
exporter, source = release_project(tmp_path, monkeypatch)
|
|
monkeypatch.setattr(sys, 'argv', ['export', 'ota', '--version', '1.1.0', '--notes', 'original'])
|
|
exporter.main()
|
|
artifact=tmp_path/'发布更新相关/OTA数据包/1.1.0/matrix-screen-controller-1.1.0.ota'
|
|
old=artifact.read_bytes();old_history=(tmp_path/'发布记录.json').read_bytes()
|
|
(source/'fix.py').write_text('FIXED = True\n',encoding='utf-8')
|
|
candidate=tmp_path/'candidate.ota'
|
|
build_package(source,tmp_path/'发布更新相关/其他依赖/aarch64-py311',candidate,
|
|
version=V.parse('1.1.0'),release_notes='repair',
|
|
system_dependencies=tmp_path/'发布更新相关/其他依赖/frp/v')
|
|
report=tmp_path/'validation.json'
|
|
report.write_text(json.dumps({'package_sha256':hashlib.sha256(candidate.read_bytes()).hexdigest(),
|
|
'installed_version':'1.1.0','status':'success','runtime_lifecycle_passed':True,
|
|
'user_data_preserved':True,'frp_state_preserved':True,'transaction_cleanup_passed':True}),encoding='utf-8')
|
|
if fail_commit:
|
|
def fail(*args): raise OSError('injected publication failure')
|
|
monkeypatch.setattr(exporter,'_atomic_json',fail)
|
|
with pytest.raises(OSError,match='publication'):
|
|
promote(source,candidate,report,'repair')
|
|
assert artifact.read_bytes()==old
|
|
assert (tmp_path/'发布记录.json').read_bytes()==old_history
|
|
else:
|
|
promote(source,candidate,report,'repair')
|
|
assert artifact.read_bytes()==candidate.read_bytes()
|
|
history=json.loads((tmp_path/'发布记录.json').read_text(encoding='utf-8'))
|
|
assert len(history['releases'])==1
|
|
assert len(history['releases'][0]['repairs'])==1
|
|
assert (source/'VERSION').read_text(encoding='utf-8').strip()=='1.1.0'
|
|
archived=list((tmp_path/'各种归档').glob('*/原安装包/*.ota'))
|
|
assert len(archived)==1 and archived[0].read_bytes()==old
|