103 lines
6.5 KiB
Python
103 lines
6.5 KiB
Python
"""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)
|