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

103 lines
6.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)