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

244 lines
9.4 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
"""Orchestrator: materialise + cache + verify boot files for QEMU boards.
The provider is the only object QEMU launch code needs to hold a
reference to. It owns the on-disk cache, the per-set asyncio locks
(so concurrent boot requests for the same board collapse into one
download), and the verify-then-rename ladder that gives us atomic
"a file at this path means it's correct".
"""
from __future__ import annotations
import asyncio
import logging
import tempfile
from pathlib import Path
from .downloader import AssetDownloader
from .errors import BootImageError
from .integrity import decompress_zstd, sha256_file, verify_sha256
from .manifest import BootImageSpec, BootImagesManifest, ImageSetSpec
logger = logging.getLogger(__name__)
class BootImageProvider:
"""Materialise and cache boot files on demand, idempotently.
Operational notes:
* First call for a set_id pays the download cost. Subsequent calls
do an mtime/size/SHA256 cache check and return immediately if
the files are still valid.
* Concurrent ``get()`` calls for the same set_id serialise on a
per-set ``asyncio.Lock``; the second caller observes a populated
cache and skips the download.
* Different set_ids materialise in parallel.
* On SHA256 mismatch the file is left in a temp dir (never reaches
``target_path``), so a corrupt download cannot poison the cache
for the next process.
"""
def __init__(
self,
*,
manifest: BootImagesManifest,
downloader: AssetDownloader,
cache_dir: Path,
):
self._manifest = manifest
self._downloader = downloader
self._cache_dir = cache_dir
self._cache_dir.mkdir(parents=True, exist_ok=True)
self._locks: dict[str, asyncio.Lock] = {}
self._locks_guard = asyncio.Lock()
# ── Public API ────────────────────────────────────────────────────────
@property
def cache_dir(self) -> Path:
return self._cache_dir
@property
def manifest(self) -> BootImagesManifest:
return self._manifest
async def get(self, set_id: str) -> dict[str, Path]:
"""Return ``{image_name: absolute_path}`` for ``set_id``.
Downloads + verifies + (when applicable) decompresses on first
call. Subsequent calls are cache hits. Concurrent callers
serialise on a per-set lock.
"""
spec = self._manifest.get(set_id)
lock = await self._lock_for(set_id)
async with lock:
return await self._materialise(spec)
async def warmup(self, set_id: str) -> None:
"""Best-effort prefetch. Logs warnings on failure but never
raises designed to be fire-and-forget from a lifespan hook
so a transient network blip doesn't break process startup.
"""
try:
await self.get(set_id)
logger.info("[boot-images] warmup complete for %r", set_id)
except BootImageError as exc:
logger.warning(
"[boot-images] warmup for %r failed: %s", set_id, exc,
)
async def warmup_all(self) -> None:
"""Concurrent warmup of every set declared in the manifest."""
await asyncio.gather(
*(self.warmup(s) for s in self._manifest.image_sets),
return_exceptions=False, # warmup() already swallows
)
def is_cached(self, set_id: str) -> bool:
"""Sync probe used by health/status endpoints.
fix(pi3): show kernel boot + autologin SD + sidecar cache invalidation User report: clicked Pi 3 board → nothing visible happens. Three defects, all on the same path: 1. The kernel cmdline carried over from the original pre-OSS-split code: `quiet init=/bin/sh`. Result: kernel boot messages suppressed, then dropped straight to bare /bin/sh with no PS1 so the user sees an empty serial. Removed both. The kernel cmdline is now just `console=ttyAMA0 root=/dev/mmcblk0p2 rootwait rw dwc_otg.lpm_enable=0`, which lets systemd start a real serial-getty@ttyAMA0.service. 2. Pi OS Trixie armhf since Bookworm ships without a default user (no more pi/raspberry). With cmdline #1 fixed, the user would land at a login prompt and be stuck. Fix: pre-bake a systemd drop-in at /etc/systemd/system/serial-getty@ttyAMA0.service.d/ autologin.conf that uses `agetty --autologin root` so the serial console drops to a root shell on first prompt. The browser canvas IS the authentication boundary; the SD image is mounted RO via a qcow2 overlay so per-session edits don't persist. Edit happens in velxio-prod/scripts/configure-pi3-autologin.sh (to follow in a separate commit). 3. Architectural: the original cache-hit probe was size-only. Today's SD image rebake produced a file with identical byte count but different SHA256 — the cache served stale content for every request even after a manifest bump. Fix: write a sidecar `<file>.sha256` after every successful materialise and trust it on subsequent probes. Manifest SHA bumps invalidate the cache regardless of size. Two regression tests guard this: - test_provider_sidecar_invalidates_on_sha_mismatch - test_provider_missing_sidecar_treats_file_as_invalid Manifest bumped to version "2026-04-21+autologin" for the SD image (kernel + DTB unchanged, still 2026-04-21).
2026-05-16 11:23:03 +07:00
Uses the same sidecar-SHA check ``_is_valid_cached`` does so
a manifest bump correctly reports "not cached yet" until the
next ``get()`` re-materialises the file.
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
"""
try:
spec = self._manifest.get(set_id)
except BootImageError:
return False
set_dir = self._cache_dir / set_id
fix(pi3): show kernel boot + autologin SD + sidecar cache invalidation User report: clicked Pi 3 board → nothing visible happens. Three defects, all on the same path: 1. The kernel cmdline carried over from the original pre-OSS-split code: `quiet init=/bin/sh`. Result: kernel boot messages suppressed, then dropped straight to bare /bin/sh with no PS1 so the user sees an empty serial. Removed both. The kernel cmdline is now just `console=ttyAMA0 root=/dev/mmcblk0p2 rootwait rw dwc_otg.lpm_enable=0`, which lets systemd start a real serial-getty@ttyAMA0.service. 2. Pi OS Trixie armhf since Bookworm ships without a default user (no more pi/raspberry). With cmdline #1 fixed, the user would land at a login prompt and be stuck. Fix: pre-bake a systemd drop-in at /etc/systemd/system/serial-getty@ttyAMA0.service.d/ autologin.conf that uses `agetty --autologin root` so the serial console drops to a root shell on first prompt. The browser canvas IS the authentication boundary; the SD image is mounted RO via a qcow2 overlay so per-session edits don't persist. Edit happens in velxio-prod/scripts/configure-pi3-autologin.sh (to follow in a separate commit). 3. Architectural: the original cache-hit probe was size-only. Today's SD image rebake produced a file with identical byte count but different SHA256 — the cache served stale content for every request even after a manifest bump. Fix: write a sidecar `<file>.sha256` after every successful materialise and trust it on subsequent probes. Manifest SHA bumps invalidate the cache regardless of size. Two regression tests guard this: - test_provider_sidecar_invalidates_on_sha_mismatch - test_provider_missing_sidecar_treats_file_as_invalid Manifest bumped to version "2026-04-21+autologin" for the SD image (kernel + DTB unchanged, still 2026-04-21).
2026-05-16 11:23:03 +07:00
return all(
self._is_valid_cached(set_dir / img.name, img) for img in spec.images
)
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
# ── Internals ─────────────────────────────────────────────────────────
async def _lock_for(self, set_id: str) -> asyncio.Lock:
async with self._locks_guard:
lock = self._locks.get(set_id)
if lock is None:
lock = asyncio.Lock()
self._locks[set_id] = lock
return lock
async def _materialise(self, spec: ImageSetSpec) -> dict[str, Path]:
set_dir = self._cache_dir / spec.id
set_dir.mkdir(parents=True, exist_ok=True)
out: dict[str, Path] = {}
for img in spec.images:
target = set_dir / img.name
if await asyncio.to_thread(self._is_valid_cached, target, img):
logger.debug("[boot-images] cache hit %s", target)
out[img.name] = target
continue
logger.info(
"[boot-images] fetching %s/%s (asset_id=%s%s)",
spec.id,
img.name,
img.asset_id,
f", version={img.version}" if img.version else "",
)
await self._fetch_and_verify(img, target)
out[img.name] = target
return out
@staticmethod
fix(pi3): show kernel boot + autologin SD + sidecar cache invalidation User report: clicked Pi 3 board → nothing visible happens. Three defects, all on the same path: 1. The kernel cmdline carried over from the original pre-OSS-split code: `quiet init=/bin/sh`. Result: kernel boot messages suppressed, then dropped straight to bare /bin/sh with no PS1 so the user sees an empty serial. Removed both. The kernel cmdline is now just `console=ttyAMA0 root=/dev/mmcblk0p2 rootwait rw dwc_otg.lpm_enable=0`, which lets systemd start a real serial-getty@ttyAMA0.service. 2. Pi OS Trixie armhf since Bookworm ships without a default user (no more pi/raspberry). With cmdline #1 fixed, the user would land at a login prompt and be stuck. Fix: pre-bake a systemd drop-in at /etc/systemd/system/serial-getty@ttyAMA0.service.d/ autologin.conf that uses `agetty --autologin root` so the serial console drops to a root shell on first prompt. The browser canvas IS the authentication boundary; the SD image is mounted RO via a qcow2 overlay so per-session edits don't persist. Edit happens in velxio-prod/scripts/configure-pi3-autologin.sh (to follow in a separate commit). 3. Architectural: the original cache-hit probe was size-only. Today's SD image rebake produced a file with identical byte count but different SHA256 — the cache served stale content for every request even after a manifest bump. Fix: write a sidecar `<file>.sha256` after every successful materialise and trust it on subsequent probes. Manifest SHA bumps invalidate the cache regardless of size. Two regression tests guard this: - test_provider_sidecar_invalidates_on_sha_mismatch - test_provider_missing_sidecar_treats_file_as_invalid Manifest bumped to version "2026-04-21+autologin" for the SD image (kernel + DTB unchanged, still 2026-04-21).
2026-05-16 11:23:03 +07:00
def _sidecar(target: Path) -> Path:
"""Sidecar file recording the SHA256 of the cached payload.
Written atomically (temp + rename) after a successful
download+verify, read on every cache-validity probe. Lets the
provider detect manifest SHA bumps without re-hashing
multi-GiB files on every container start.
"""
return target.parent / f"{target.name}.sha256"
@classmethod
def _is_valid_cached(cls, path: Path, spec: BootImageSpec) -> bool:
"""O(1) cache-hit probe — presence + size + sidecar SHA match.
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
We deliberately do NOT re-hash the file on every probe. The
5.4 GiB Pi 3 SD image takes ~30 s to SHA256, and that cost
would be paid on every container boot pre-warm AND every user
fix(pi3): show kernel boot + autologin SD + sidecar cache invalidation User report: clicked Pi 3 board → nothing visible happens. Three defects, all on the same path: 1. The kernel cmdline carried over from the original pre-OSS-split code: `quiet init=/bin/sh`. Result: kernel boot messages suppressed, then dropped straight to bare /bin/sh with no PS1 so the user sees an empty serial. Removed both. The kernel cmdline is now just `console=ttyAMA0 root=/dev/mmcblk0p2 rootwait rw dwc_otg.lpm_enable=0`, which lets systemd start a real serial-getty@ttyAMA0.service. 2. Pi OS Trixie armhf since Bookworm ships without a default user (no more pi/raspberry). With cmdline #1 fixed, the user would land at a login prompt and be stuck. Fix: pre-bake a systemd drop-in at /etc/systemd/system/serial-getty@ttyAMA0.service.d/ autologin.conf that uses `agetty --autologin root` so the serial console drops to a root shell on first prompt. The browser canvas IS the authentication boundary; the SD image is mounted RO via a qcow2 overlay so per-session edits don't persist. Edit happens in velxio-prod/scripts/configure-pi3-autologin.sh (to follow in a separate commit). 3. Architectural: the original cache-hit probe was size-only. Today's SD image rebake produced a file with identical byte count but different SHA256 — the cache served stale content for every request even after a manifest bump. Fix: write a sidecar `<file>.sha256` after every successful materialise and trust it on subsequent probes. Manifest SHA bumps invalidate the cache regardless of size. Two regression tests guard this: - test_provider_sidecar_invalidates_on_sha_mismatch - test_provider_missing_sidecar_treats_file_as_invalid Manifest bumped to version "2026-04-21+autologin" for the SD image (kernel + DTB unchanged, still 2026-04-21).
2026-05-16 11:23:03 +07:00
request that triggers ``provider.get()``.
Instead, after a successful materialise we write a sidecar
``<name>.sha256`` containing the expected hash and trust it on
subsequent probes. A manifest SHA bump invalidates the sidecar
even if the size is unchanged (e.g. an in-place SD image edit
that ends up the exact same byte count), forcing a re-fetch.
If the sidecar is missing (legacy cache from before this
change, or operator tampering) the file is treated as invalid
and re-fetched. Manual operators who want to inject a file can
write the sidecar themselves: ``sha256sum file | cut -d' ' -f1
> file.sha256``.
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
"""
if not path.is_file():
return False
fix(pi3): show kernel boot + autologin SD + sidecar cache invalidation User report: clicked Pi 3 board → nothing visible happens. Three defects, all on the same path: 1. The kernel cmdline carried over from the original pre-OSS-split code: `quiet init=/bin/sh`. Result: kernel boot messages suppressed, then dropped straight to bare /bin/sh with no PS1 so the user sees an empty serial. Removed both. The kernel cmdline is now just `console=ttyAMA0 root=/dev/mmcblk0p2 rootwait rw dwc_otg.lpm_enable=0`, which lets systemd start a real serial-getty@ttyAMA0.service. 2. Pi OS Trixie armhf since Bookworm ships without a default user (no more pi/raspberry). With cmdline #1 fixed, the user would land at a login prompt and be stuck. Fix: pre-bake a systemd drop-in at /etc/systemd/system/serial-getty@ttyAMA0.service.d/ autologin.conf that uses `agetty --autologin root` so the serial console drops to a root shell on first prompt. The browser canvas IS the authentication boundary; the SD image is mounted RO via a qcow2 overlay so per-session edits don't persist. Edit happens in velxio-prod/scripts/configure-pi3-autologin.sh (to follow in a separate commit). 3. Architectural: the original cache-hit probe was size-only. Today's SD image rebake produced a file with identical byte count but different SHA256 — the cache served stale content for every request even after a manifest bump. Fix: write a sidecar `<file>.sha256` after every successful materialise and trust it on subsequent probes. Manifest SHA bumps invalidate the cache regardless of size. Two regression tests guard this: - test_provider_sidecar_invalidates_on_sha_mismatch - test_provider_missing_sidecar_treats_file_as_invalid Manifest bumped to version "2026-04-21+autologin" for the SD image (kernel + DTB unchanged, still 2026-04-21).
2026-05-16 11:23:03 +07:00
if path.stat().st_size != spec.size_bytes:
return False
sidecar = cls._sidecar(path)
if not sidecar.is_file():
return False
try:
recorded = sidecar.read_text(encoding="ascii").strip().lower()
except OSError:
return False
return recorded == spec.sha256.lower()
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
async def _fetch_and_verify(
self, img: BootImageSpec, target: Path,
) -> None:
if img.compressed is None:
await self._downloader.fetch(img.asset_id, target)
await asyncio.to_thread(
verify_sha256, target, img.sha256, label=img.name,
)
fix(pi3): show kernel boot + autologin SD + sidecar cache invalidation User report: clicked Pi 3 board → nothing visible happens. Three defects, all on the same path: 1. The kernel cmdline carried over from the original pre-OSS-split code: `quiet init=/bin/sh`. Result: kernel boot messages suppressed, then dropped straight to bare /bin/sh with no PS1 so the user sees an empty serial. Removed both. The kernel cmdline is now just `console=ttyAMA0 root=/dev/mmcblk0p2 rootwait rw dwc_otg.lpm_enable=0`, which lets systemd start a real serial-getty@ttyAMA0.service. 2. Pi OS Trixie armhf since Bookworm ships without a default user (no more pi/raspberry). With cmdline #1 fixed, the user would land at a login prompt and be stuck. Fix: pre-bake a systemd drop-in at /etc/systemd/system/serial-getty@ttyAMA0.service.d/ autologin.conf that uses `agetty --autologin root` so the serial console drops to a root shell on first prompt. The browser canvas IS the authentication boundary; the SD image is mounted RO via a qcow2 overlay so per-session edits don't persist. Edit happens in velxio-prod/scripts/configure-pi3-autologin.sh (to follow in a separate commit). 3. Architectural: the original cache-hit probe was size-only. Today's SD image rebake produced a file with identical byte count but different SHA256 — the cache served stale content for every request even after a manifest bump. Fix: write a sidecar `<file>.sha256` after every successful materialise and trust it on subsequent probes. Manifest SHA bumps invalidate the cache regardless of size. Two regression tests guard this: - test_provider_sidecar_invalidates_on_sha_mismatch - test_provider_missing_sidecar_treats_file_as_invalid Manifest bumped to version "2026-04-21+autologin" for the SD image (kernel + DTB unchanged, still 2026-04-21).
2026-05-16 11:23:03 +07:00
else:
# Compressed path: download → verify wire-format sha →
# decompress → verify decompressed sha → atomic rename to
# final cache slot.
with tempfile.TemporaryDirectory(
dir=target.parent, prefix=".staging-",
) as staging:
staging_dir = Path(staging)
compressed_path = (
staging_dir / f"{img.name}.{img.compressed.encoding}"
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
)
fix(pi3): show kernel boot + autologin SD + sidecar cache invalidation User report: clicked Pi 3 board → nothing visible happens. Three defects, all on the same path: 1. The kernel cmdline carried over from the original pre-OSS-split code: `quiet init=/bin/sh`. Result: kernel boot messages suppressed, then dropped straight to bare /bin/sh with no PS1 so the user sees an empty serial. Removed both. The kernel cmdline is now just `console=ttyAMA0 root=/dev/mmcblk0p2 rootwait rw dwc_otg.lpm_enable=0`, which lets systemd start a real serial-getty@ttyAMA0.service. 2. Pi OS Trixie armhf since Bookworm ships without a default user (no more pi/raspberry). With cmdline #1 fixed, the user would land at a login prompt and be stuck. Fix: pre-bake a systemd drop-in at /etc/systemd/system/serial-getty@ttyAMA0.service.d/ autologin.conf that uses `agetty --autologin root` so the serial console drops to a root shell on first prompt. The browser canvas IS the authentication boundary; the SD image is mounted RO via a qcow2 overlay so per-session edits don't persist. Edit happens in velxio-prod/scripts/configure-pi3-autologin.sh (to follow in a separate commit). 3. Architectural: the original cache-hit probe was size-only. Today's SD image rebake produced a file with identical byte count but different SHA256 — the cache served stale content for every request even after a manifest bump. Fix: write a sidecar `<file>.sha256` after every successful materialise and trust it on subsequent probes. Manifest SHA bumps invalidate the cache regardless of size. Two regression tests guard this: - test_provider_sidecar_invalidates_on_sha_mismatch - test_provider_missing_sidecar_treats_file_as_invalid Manifest bumped to version "2026-04-21+autologin" for the SD image (kernel + DTB unchanged, still 2026-04-21).
2026-05-16 11:23:03 +07:00
await self._downloader.fetch(img.asset_id, compressed_path)
await asyncio.to_thread(
verify_sha256,
compressed_path,
img.compressed.sha256,
label=f"{img.name} (compressed)",
)
decoded = staging_dir / img.name
if img.compressed.encoding == "zstd":
await asyncio.to_thread(decompress_zstd, compressed_path, decoded)
else:
raise BootImageError(
f"unsupported compression {img.compressed.encoding!r}"
)
await asyncio.to_thread(
verify_sha256,
decoded,
img.sha256,
label=f"{img.name} (decompressed)",
)
await asyncio.to_thread(decoded.replace, target)
# Record the expected SHA next to the file so future cache
# probes can detect manifest bumps without re-hashing the
# whole file. Sidecar write is atomic (temp + rename) so a
# process crash mid-write can't leave a half-written hash.
await asyncio.to_thread(self._write_sidecar, target, img.sha256)
@classmethod
def _write_sidecar(cls, target: Path, sha256: str) -> None:
sidecar = cls._sidecar(target)
tmp = sidecar.with_suffix(sidecar.suffix + ".tmp")
tmp.write_text(sha256.lower() + "\n", encoding="ascii")
tmp.replace(sidecar)