"""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)