"""Promote an explicitly authorized, device-validated OTA without rebuilding it.""" from __future__ import annotations from datetime import datetime, timezone import json import os from pathlib import Path import shutil import tempfile import uuid from app.ota.package import inspect_package, extract_payload, _source_file_allowed from app.ota.policy import read_policy, required_components, release_metadata, package_format from app.ota.versioning import read_software_version from app.ota.diagnostics import sha256_file def promote(source: Path, candidate: Path, validation: Path, notes: str) -> Path: from scripts.export_release import _history, _commit_repair project = source.parent lock = project / '.release-export.lock' fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY) os.close(fd) try: version = read_software_version(source) current = project / '发布更新相关' / 'OTA数据包' / str(version) name = f'matrix-screen-controller-{version}.ota' history_path = project / '发布记录.json' history = _history(history_path) matches = [(i, r) for i, r in enumerate(history['releases']) if r['version'] == str(version)] if len(matches) != 1 or matches[0][1]['artifact_type'] != 'ota': raise ValueError('current version must have exactly one OTA release record') index, previous = matches[0] original_sha = sha256_file(current / name) manifest = json.loads((current / 'manifest.json').read_text(encoding='utf-8')) if (previous['artifact_sha256'] != original_sha or manifest['artifact_sha256'] != original_sha or (current / (name + '.sha256')).read_text(encoding='ascii') != f'{original_sha} {name}\n'): raise ValueError('original OTA release metadata does not match its artifact') info = inspect_package(candidate) digest = sha256_file(candidate) report = json.loads(validation.read_text(encoding='utf-8')) if (info.target_version != version or report.get('package_sha256') != digest or report.get('installed_version') != str(version) or report.get('status') != 'success' or report.get('runtime_lifecycle_passed') is not True or report.get('user_data_preserved') is not True or report.get('frp_state_preserved') is not True or report.get('transaction_cleanup_passed') is not True): raise ValueError('candidate lacks matching successful real-device validation') if digest == original_sha: raise ValueError('repair candidate is identical to the current artifact') with tempfile.TemporaryDirectory(prefix='matrix-ota-repair-check-') as text: unpacked = Path(text) / 'unpacked' extract_payload(info, unpacked) software = unpacked / 'software' expected = {p.relative_to(source).as_posix() for p in source.rglob('*') if p.is_file() and _source_file_allowed(p, source)} actual = {p.relative_to(software).as_posix() for p in software.rglob('*') if p.is_file() and _source_file_allowed(p, software)} if actual != expected or any((software / n).read_bytes() != (source / n).read_bytes() for n in expected): raise ValueError('validated candidate differs from current source; validate a new candidate') for component, requirement in required_components(source, version).items(): if version.patch == 0 and sha256_file(software / 'system-dependencies' / component / component) != requirement['sha256']: raise ValueError('candidate system dependency differs from policy') stamp = datetime.now(timezone.utc).astimezone().isoformat(timespec='seconds') archive = project / '各种归档' / f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_OTA{version}修复前_{uuid.uuid4().hex[:6]}" archive.mkdir(parents=True) shutil.copytree(current, archive / '原安装包') shutil.copy2(history_path, archive / '发布记录_修复前.json') shutil.copy2(validation, archive / '实机验收.json') staged = current.parent / f'.{version}.{uuid.uuid4().hex}.repairing' staged.mkdir() try: shutil.copy2(candidate, staged / name) if sha256_file(staged / name) != digest: raise ValueError('candidate copy checksum mismatch') metadata = {**manifest, **release_metadata(version), 'format_version': package_format(version), 'created_at': info.created_at, 'notes': notes, 'artifact_bytes': candidate.stat().st_size, 'artifact_sha256': digest, 'repaired_at': stamp, 'replaces_sha256': original_sha} (staged / 'manifest.json').write_text(json.dumps(metadata, ensure_ascii=False, indent=2)+'\n', encoding='utf-8') (staged / (name+'.sha256')).write_text(f'{digest} {name}\n', encoding='ascii', newline='\n') (staged / 'README.md').write_text( f'# OTA {version} 软件安装包(修复版)\n\n{notes}\n\n' f'- SHA-256:`{digest}`\n- 修复时间:`{stamp}`\n' '- 通过系统设置上传 `.ota`,无需解压。1.0.x 必须先安装本节点,再安装后续补丁。\n' '- 已通过真实 systemd 停启测试及测试设备 OTA;正式包与实机验收包字节一致。\n' '- 已安装 frp 时保留配置和启停状态,完整组件跳过,缺失或损坏时离线修复。\n' '- 本包修复停止旧服务时运行目录被删除的问题;仍为 1.1.0,不是 1.1.1。\n' f'- 原失败包与旧记录:`{archive.relative_to(project).as_posix()}`。\n', encoding='utf-8') repair = {'at': stamp, 'reason': notes, 'previous_sha256': original_sha, 'archive_path': archive.relative_to(project).as_posix()} history['releases'][index] = {**previous, 'created_at': info.created_at, 'notes': notes, 'artifact_sha256': digest, 'component_checkpoints': read_policy(source)['checkpoints'], 'repairs': [*previous.get('repairs', []), repair]} leftover = _commit_repair(current, staged, history_path, history) if leftover: print(f'obsolete temporary backup requires cleanup: {leftover}') except BaseException: shutil.rmtree(staged, ignore_errors=True) raise return current finally: lock.unlink(missing_ok=True)