175 lines
8.1 KiB
Python
175 lines
8.1 KiB
Python
"""Promote an exact, real-card-validated image candidate without rebuilding it."""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import uuid
|
|
|
|
from app.ota.versioning import SoftwareVersion, read_software_version
|
|
from scripts.build_sd_image import build_bundle, sha256_file
|
|
from scripts.fat16_image import Fat16Image
|
|
from scripts.image_config import read_config_file
|
|
|
|
|
|
REPORT_FIELDS = {
|
|
"schema_version", "artifact_type", "image_sha256", "software_version", "status",
|
|
"firstboot_completed", "automatic_reboot_passed", "default_wifi_connected",
|
|
"ssh_password_login_passed", "sudo_password_passed", "root_login_rejected",
|
|
"networkmanager_passed", "controller_service_passed", "kernel_health_passed",
|
|
"h618_mapping_passed", "web_ui_passed", "plaintext_config_removed",
|
|
"machine_identity_regenerated", "second_reboot_passed", "validated_at",
|
|
}
|
|
PASS_FIELDS = REPORT_FIELDS - {
|
|
"schema_version", "artifact_type", "image_sha256", "software_version", "status", "validated_at"
|
|
}
|
|
|
|
|
|
def _validate_report(path: Path, digest: str, version: SoftwareVersion) -> dict:
|
|
report = json.loads(path.read_text(encoding="utf-8"))
|
|
if not isinstance(report, dict) or set(report) != REPORT_FIELDS:
|
|
raise ValueError("image validation report fields are invalid")
|
|
try:
|
|
validated_at = datetime.fromisoformat(report.get("validated_at", ""))
|
|
except (TypeError, ValueError) as exc:
|
|
raise ValueError("image validation timestamp is invalid") from exc
|
|
if (
|
|
report["schema_version"] != 1
|
|
or report["artifact_type"] != "image"
|
|
or report["image_sha256"] != digest
|
|
or report["software_version"] != str(version)
|
|
or report["status"] != "success"
|
|
or validated_at.tzinfo is None
|
|
or any(report[name] is not True for name in PASS_FIELDS)
|
|
):
|
|
raise ValueError("candidate lacks matching successful real-card validation")
|
|
return report
|
|
|
|
|
|
def promote(source: Path, candidate: Path, validation: Path, notes: str) -> Path:
|
|
from scripts.export_release import (
|
|
_atomic_bytes, _atomic_json, _copy_source, _history, _image_readme, next_patch,
|
|
)
|
|
|
|
source = Path(source).resolve()
|
|
project = source.parent
|
|
candidate = Path(candidate).resolve()
|
|
validation = Path(validation).resolve()
|
|
lock = project / ".release-export.lock"
|
|
descriptor = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
os.close(descriptor)
|
|
archive: Path | None = None
|
|
staged: Path | None = None
|
|
final: Path | None = None
|
|
published = False
|
|
history_path = project / "发布记录.json"
|
|
history_original = history_path.read_bytes()
|
|
version_original = (source / "VERSION").read_bytes()
|
|
try:
|
|
if not candidate.is_dir():
|
|
raise ValueError("validated image candidate directory is missing")
|
|
manifest = json.loads((candidate / "manifest.json").read_text(encoding="utf-8"))
|
|
version = SoftwareVersion.parse(manifest.get("software_version", ""))
|
|
current = read_software_version(source)
|
|
if version != next_patch(current):
|
|
raise ValueError("validated image candidate must be the next patch version")
|
|
artifact_name = f"matrix-screen-controller-{version}.img"
|
|
if {path.name for path in candidate.iterdir()} != {
|
|
"README.md", "manifest.json", artifact_name, f"{artifact_name}.sha256",
|
|
}:
|
|
raise ValueError("image candidate directory contents are not exact")
|
|
artifact = candidate / artifact_name
|
|
digest = sha256_file(artifact)
|
|
if (
|
|
manifest.get("artifact_type") != "image"
|
|
or manifest.get("artifact") != artifact_name
|
|
or manifest.get("image_sha256") != digest
|
|
or manifest.get("image_bytes") != artifact.stat().st_size
|
|
or manifest.get("notes") != notes.strip()
|
|
or (candidate / f"{artifact_name}.sha256").read_bytes()
|
|
!= f"{digest} {artifact_name}\n".encode("ascii")
|
|
):
|
|
raise ValueError("image candidate metadata does not match its artifact")
|
|
with Fat16Image(artifact) as image:
|
|
config_bytes = image.read_file("MSCCFG.BIN")
|
|
config, _generation, _slot = read_config_file(config_bytes)
|
|
if config["software_version"] != str(version):
|
|
raise ValueError("image candidate configuration version is invalid")
|
|
if manifest.get("config_sha256") != hashlib.sha256(config_bytes).hexdigest():
|
|
raise ValueError("image candidate configuration digest is invalid")
|
|
if (candidate / "README.md").read_text(encoding="utf-8") != _image_readme(version, manifest, notes, config):
|
|
raise ValueError("image candidate README does not match its embedded defaults")
|
|
_validate_report(validation, digest, version)
|
|
|
|
dependency_root = project / "发布更新相关" / "其他依赖"
|
|
kernel = dependency_root / "aarch64-kernel" / "6.1.31-matrix-axp313a1"
|
|
with tempfile.TemporaryDirectory(prefix="matrix-image-promotion-") as temporary_text:
|
|
temporary = Path(temporary_text)
|
|
staged_source = temporary / "核桃派软件源代码"
|
|
_copy_source(source, staged_source, version)
|
|
expected_bundle = temporary / "MSCBOOT.TGZ"
|
|
build_bundle(
|
|
staged_source,
|
|
dependency_root / "aarch64-py311",
|
|
dependency_root / "debian12-aarch64",
|
|
expected_bundle,
|
|
dependency_root / "frp" / "0.71.0" / "linux-arm64",
|
|
)
|
|
subprocess.run(
|
|
[
|
|
"bash", str(staged_source / "scripts" / "verify_image_bootstrap.sh"),
|
|
str(artifact), str(staged_source / "systemd" / "matrix-image-firstboot.service"),
|
|
str(staged_source / "systemd" / "10-walnutpi-screen-hardening.conf"),
|
|
str(kernel), str(expected_bundle),
|
|
],
|
|
check=True,
|
|
)
|
|
|
|
history = _history(history_path)
|
|
if any(record.get("version") == str(version) for record in history["releases"]):
|
|
raise ValueError("validated image version is already registered")
|
|
final = project / "发布更新相关" / "导出包" / str(version)
|
|
if final.exists():
|
|
raise ValueError("formal image release directory already exists")
|
|
stamp = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
|
|
archive = project / "各种归档" / f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_IMAGE{version}实卡验收_{uuid.uuid4().hex[:6]}"
|
|
archive.mkdir(parents=True)
|
|
shutil.copy2(validation, archive / "实机验收.json")
|
|
staged = final.parent / f".{version}.{uuid.uuid4().hex}.publishing"
|
|
shutil.copytree(candidate, staged)
|
|
if sha256_file(staged / artifact_name) != digest:
|
|
raise ValueError("candidate copy checksum mismatch")
|
|
history["releases"].append({
|
|
"version": str(version),
|
|
"artifact_type": "image",
|
|
"created_at": manifest["created_at"],
|
|
"notes": notes.strip(),
|
|
"artifact_path": f"发布更新相关/导出包/{version}/{artifact_name}",
|
|
"artifact_sha256": digest,
|
|
"validation_path": f"{archive.relative_to(project).as_posix()}/实机验收.json",
|
|
"validated_at": stamp,
|
|
})
|
|
os.replace(staged, final)
|
|
staged = None
|
|
published = True
|
|
_atomic_json(history_path, history)
|
|
_atomic_bytes(source / "VERSION", f"{version}\n".encode("utf-8"))
|
|
return final
|
|
except BaseException:
|
|
if staged is not None:
|
|
shutil.rmtree(staged, ignore_errors=True)
|
|
if published and final is not None:
|
|
shutil.rmtree(final, ignore_errors=True)
|
|
_atomic_bytes(history_path, history_original)
|
|
_atomic_bytes(source / "VERSION", version_original)
|
|
if archive is not None:
|
|
shutil.rmtree(archive, ignore_errors=True)
|
|
raise
|
|
finally:
|
|
lock.unlink(missing_ok=True)
|