Files
matrix-screen-controller/核桃派软件源代码/tests/test_ota_checkpoints.py
T

450 lines
22 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')
(source / 'requirements.txt').write_text('example==1.0.0\n', encoding='utf-8')
(source / 'requirements-dev.txt').write_text('-r requirements.txt\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()
wheel = wheels / 'example-1.0.0-py3-none-any.whl'
wheel.write_bytes(b'fixture wheel')
(wheels/'SHA256SUMS').write_text(f'{hashlib.sha256(wheel.read_bytes()).hexdigest()} {wheel.name}\n', encoding='ascii')
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 validated_ota_report(tmp_path, candidate, source_version, target_version):
artifact = candidate / f'matrix-screen-controller-{target_version}.ota'
report = tmp_path / f'validated-{target_version}.json'
report.write_text(json.dumps({
'schema_version': 1, 'artifact_type': 'ota',
'package_sha256': hashlib.sha256(artifact.read_bytes()).hexdigest(),
'software_version': target_version, 'source_version': source_version,
'restored_version': source_version, 'status': 'success',
'validated_at': '2026-09-27T18:00:00+08:00',
'offline_install_passed': True, 'ota_health_passed': True,
'runtime_lifecycle_passed': True, 'user_data_preserved': True,
'frp_state_preserved': True, 'bluetooth_state_preserved': True,
'transaction_cleanup_passed': True, 'baseline_restored': True,
}), encoding='utf-8')
return report
def publish_fixture_ota(tmp_path, monkeypatch, exporter, current, target, notes):
import sys
candidate = tmp_path / f'candidate-{target}'
args = ['export', 'ota', '--notes', notes, '--candidate-output', str(candidate)]
if target.endswith('.0'):
args.extend(['--version', target])
monkeypatch.setattr(sys, 'argv', args)
assert exporter.main() == 0
report = validated_ota_report(tmp_path, candidate, current, target)
monkeypatch.setattr(sys, 'argv', ['export', 'ota', '--notes', notes,
'--validated-candidate', str(candidate),
'--validation-report', str(report)])
assert exporter.main() == 0
return candidate
@pytest.mark.parametrize('failure', ['missing', 'version', 'digest'])
def test_ota_candidate_rejects_incomplete_offline_wheels(tmp_path, monkeypatch, failure):
import sys
exporter, source = release_project(tmp_path, monkeypatch)
wheels = tmp_path / '发布更新相关/其他依赖/aarch64-py311'
if failure == 'missing':
(source / 'requirements-dev.txt').write_text('-r requirements.txt\nparamiko==4.0.0\n', encoding='utf-8')
expected = 'offline wheel missing'
elif failure == 'version':
(source / 'requirements.txt').write_text('example==2.0.0\n', encoding='utf-8')
expected = 'offline wheel version missing'
else:
(wheels / 'example-1.0.0-py3-none-any.whl').write_bytes(b'changed')
expected = 'digest differs'
candidate = tmp_path / 'candidate-1.1.0'
monkeypatch.setattr(sys, 'argv', ['export', 'ota', '--version', '1.1.0',
'--candidate-output', str(candidate), '--notes', 'test'])
with pytest.raises(ValueError, match=expected):
exporter.main()
assert not candidate.exists()
assert (source / 'VERSION').read_text(encoding='utf-8').strip() == '1.0.6'
assert json.loads((tmp_path / '发布记录.json').read_text(encoding='utf-8'))['releases'] == []
assert not (tmp_path / '.release-export.lock').exists()
@pytest.mark.parametrize('failure', ['report', 'source', 'artifact'])
def test_ota_candidate_promotion_rejects_changed_evidence(tmp_path, monkeypatch, failure):
import sys
from scripts.promote_ota_release import promote
exporter, source = release_project(tmp_path, monkeypatch)
candidate = tmp_path / 'candidate-1.1.0'
monkeypatch.setattr(sys, 'argv', ['export', 'ota', '--version', '1.1.0',
'--candidate-output', str(candidate), '--notes', 'test'])
assert exporter.main() == 0
assert (source / 'VERSION').read_text(encoding='utf-8').strip() == '1.0.6'
assert not (tmp_path / '发布更新相关/OTA数据包/1.1.0').exists()
report = validated_ota_report(tmp_path, candidate, '1.0.6', '1.1.0')
if failure == 'report':
document = json.loads(report.read_text(encoding='utf-8'))
document['package_sha256'] = '0' * 64
report.write_text(json.dumps(document), encoding='utf-8')
expected = 'matching successful OTA'
elif failure == 'source':
(source / 'changed.py').write_text('CHANGED = True\n', encoding='utf-8')
expected = 'software file list differs'
else:
artifact = candidate / 'matrix-screen-controller-1.1.0.ota'
artifact.write_bytes(artifact.read_bytes() + b'tampered')
expected = 'manifest differs'
with pytest.raises(ValueError, match=expected):
promote(source, candidate, report, 'test')
assert (source / 'VERSION').read_text(encoding='utf-8').strip() == '1.0.6'
assert not (tmp_path / '发布更新相关/OTA数据包/1.1.0').exists()
assert json.loads((tmp_path / '发布记录.json').read_text(encoding='utf-8'))['releases'] == []
assert not (tmp_path / '.release-export.lock').exists()
def test_export_checkpoint_then_patch_without_repeating_binary(tmp_path, monkeypatch):
import tarfile
exporter, source = release_project(tmp_path, monkeypatch)
publish_fixture_ota(tmp_path, monkeypatch, exporter, '1.0.6', '1.1.0', 'test')
assert (source/'VERSION').read_text(encoding='utf-8').strip() == '1.1.0'
publish_fixture_ota(tmp_path, monkeypatch, exporter, '1.1.0', '1.1.1', 'patch')
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()
candidate = tmp_path / 'candidate-1.1.0'
monkeypatch.setattr(sys, 'argv', ['export', 'ota', '--version', '1.1.0',
'--candidate-output', str(candidate), '--notes', 'test'])
assert exporter.main() == 0
report = validated_ota_report(tmp_path, candidate, '1.0.6', '1.1.0')
monkeypatch.setattr(sys, 'argv', ['export', 'ota', '--notes', 'test',
'--validated-candidate', str(candidate),
'--validation-report', str(report)])
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):
from scripts.repair_ota_release import promote
exporter, source = release_project(tmp_path, monkeypatch)
publish_fixture_ota(tmp_path, monkeypatch, exporter, '1.0.6', '1.1.0', 'original')
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