velxio/backend/app/services/boot_images/integrity.py

65 lines
2.1 KiB
Python
Raw Normal View History

feat(sim): boot_images module + Pi 3 emulation restored Pi 3 simulation had been broken since at least April 2026 (51 fail-events / 24h per docs/PI3_EMULATION_BROKEN.md). Two distinct defects compounded: 1. qemu_manager.py hard-coded paths for kernel8.img, a device-tree blob (under a DOS 8.3 short name!), and a 5.4 GiB Raspberry Pi OS SD image — none of which shipped in the repo or were pulled at image build. 2. qemu-system-arm + qemu-utils were missing from the Docker image entirely, so even with the boot files in place QEMU couldn't launch. Add both to Dockerfile.standalone (~200 MB). The architecture fix is a new `app.services.boot_images` module: * Manifest-driven (boot_images/manifest.json, versioned in repo, declares SHA256 + size for each file, supports an optional `compressed.{encoding,sha256,size_bytes}` block for assets shipped as .zst). * `BootImageProvider` materialises files lazily, atomically (temp + rename), verifies SHA256 pre- AND post-decompression, caches under /var/cache/velxio/boot-images, serialises concurrent get() calls per image set via asyncio.Lock. * `AssetDownloader` Protocol with two impls: - `LicenseGatedDownloader` — same flow ESP32 / RISC-V QEMU libs use (VELXIO_BINARY_BASE_URL + VELXIO_LICENSE_KEY). - `LocalDirectoryDownloader` — for tests + in-prod use where the licence-module storage is already on the same filesystem (saves the loopback HTTP roundtrip on a 1.4 GiB blob). * `build_downloader_from_env()` picks one — local-dir wins if both sets of env vars are present, so the prod box short-circuits to direct disk reads automatically. * Lifespan hook in qemu_manager.py pre-warms the cache on container boot so first-time user requests don't pay the 30-60 s download + decompress latency. Adding a future board kind (Pi 4 / Pi 5) is now: upload assets via upload-binary.sh, append an entry to manifest.json, register a lifespan pre-warm in the new board's service module. Zero edits to provider.py / downloader.py. Manifest entries for raspberry-pi-3: kernel8.img 9 695 883 bytes (uncompressed) bcm2710-rpi-3-b.dtb 34 687 bytes (uncompressed) raspios-trixie-armhf.img 5 729 419 264 bytes raw / 1 488 002 803 bytes .zst on wire (zstd -19) source: 2026-04-21 build from raspberrypi.com Tests: 21 new unit tests covering manifest parsing, integrity helpers, both downloaders, and the provider's idempotent / concurrent / integrity / decompression / warmup paths. In-process FakeDownloader keeps the suite under 1 s and httpx-free. Docs: new docs/BOOT_IMAGES.md describes the architecture, on-disk layout, named-volume operation, and the procedure for adding a new image set.
2026-05-16 10:41:01 +07:00
"""Hashing + decompression helpers used by the boot-image provider.
Kept separate from ``provider.py`` so the unit tests can exercise the
verification paths without spinning up a real provider.
"""
from __future__ import annotations
import hashlib
from pathlib import Path
from .errors import DecompressionError, IntegrityError
_READ_CHUNK = 1024 * 1024 # 1 MiB; matches the download chunk in downloader.py.
def sha256_file(path: Path) -> str:
"""Compute the SHA256 of ``path`` in fixed-size chunks.
Used by the provider both pre-download (cache validity probe) and
post-download (integrity gate). 1 MiB chunks keep peak RSS bounded
even for the 3 GiB SD image.
"""
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(_READ_CHUNK), b""):
h.update(chunk)
return h.hexdigest()
def verify_sha256(path: Path, expected: str, *, label: str | None = None) -> None:
"""Raise :class:`IntegrityError` if ``path`` doesn't hash to ``expected``."""
actual = sha256_file(path)
if actual.lower() != expected.lower():
raise IntegrityError(
name=label or str(path),
expected=expected.lower(),
actual=actual.lower(),
)
def decompress_zstd(src: Path, dst: Path) -> None:
"""Stream-decompress ``src`` (zstd) into ``dst``.
Uses the ``zstandard`` package's ``copy_stream`` so a 3 GiB image
decompresses with bounded memory. The package is declared in
``backend/requirements.txt``.
"""
try:
import zstandard
except ImportError as exc: # pragma: no cover - environment defect
raise DecompressionError(
"the 'zstandard' package is required to decompress .zst assets"
) from exc
dctx = zstandard.ZstdDecompressor()
try:
with src.open("rb") as fin, dst.open("wb") as fout:
dctx.copy_stream(
fin, fout, read_size=_READ_CHUNK, write_size=_READ_CHUNK,
)
except zstandard.ZstdError as exc:
raise DecompressionError(f"zstd decode failed: {exc}") from exc