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

190 lines
6.8 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
"""Pluggable strategies for fetching opaque asset blobs onto disk.
Two implementations ship in the box:
* :class:`LicenseGatedDownloader` talks to the velxio.dev licence-
gated download endpoint (the same one ``Dockerfile.prod`` uses to
pull ``libqemu-xtensa.so`` at image-build time).
* :class:`LocalDirectoryDownloader` copies from a directory on the
host. Useful for tests, air-gapped self-hosting, and bootstrapping
new asset sets before they're uploaded to the licence endpoint.
The :class:`AssetDownloader` ``Protocol`` is intentionally tiny: one
``fetch()`` method that materialises a blob atomically. The provider
handles SHA256 verification and decompression on top.
"""
from __future__ import annotations
import logging
import os
import shutil
from pathlib import Path
from typing import Protocol
from .errors import DownloadError, NoDownloaderConfiguredError
logger = logging.getLogger(__name__)
class AssetDownloader(Protocol):
"""Strategy interface for materialising one opaque blob on disk.
Implementations MUST write to a sibling temp file and ``rename()``
atomically partial writes from an aborted fetch must not leave a
half-finished file at ``target_path``.
"""
async def fetch(self, asset_id: str, target_path: Path) -> None:
"""Stream the bytes for ``asset_id`` into ``target_path``.
Raises:
DownloadError: any failure (network, auth, server-side).
"""
...
class LicenseGatedDownloader:
"""Fetch from ``${VELXIO_BINARY_BASE_URL}/{asset_id}?key=$KEY``.
Mirrors the URL pattern the ``qemu-provider`` stage of
``Dockerfile.prod`` uses for ESP32 / RISC-V libs, so adding a new
board's assets is "upload to the licence module, list the asset_id
in manifest.json" with no plumbing per board.
"""
def __init__(
self,
base_url: str,
license_key: str,
*,
timeout_s: float = 600.0,
):
self._base_url = base_url.rstrip("/")
self._key = license_key
self._timeout_s = timeout_s
async def fetch(self, asset_id: str, target_path: Path) -> None:
# httpx is imported lazily so unit tests that mock the
# downloader don't need it in the test graph.
import httpx
url = f"{self._base_url}/{asset_id}"
tmp = target_path.with_suffix(target_path.suffix + ".tmp")
try:
async with httpx.AsyncClient(
timeout=httpx.Timeout(self._timeout_s, connect=10.0),
follow_redirects=True,
) as client:
async with client.stream(
"GET", url, params={"key": self._key},
) as response:
if response.status_code != 200:
body = (await response.aread())[:256].decode(
"utf-8", "replace",
)
raise DownloadError(
f"GET {url} → HTTP {response.status_code}: {body!r}"
)
with tmp.open("wb") as f:
async for chunk in response.aiter_bytes(1024 * 1024):
f.write(chunk)
tmp.replace(target_path)
except DownloadError:
tmp.unlink(missing_ok=True)
raise
except Exception as exc:
tmp.unlink(missing_ok=True)
raise DownloadError(
f"download failed for {asset_id}: {exc}"
) from exc
class LocalDirectoryDownloader:
"""Resolve assets from ``source_dir/<asset_id>``.
Two flavours of ``source_dir`` layout are recognised:
* Flat ``source_dir/<asset_id>`` is the binary itself.
* Manifest ``source_dir/<asset_id>/`` is a directory containing
``manifest.json`` + the real binary file (the same layout the
licence module's ``AssetStorage`` uses). The downloader reads
``binary_filename`` from the manifest.
The licence-module layout flavour is what lets the in-prod backend
point at ``/var/velxio-pro/binaries`` directly for boot-image
resolution (skipping the round-trip through the HTTP endpoint) if
that's ever desired for tests or co-located deployments.
"""
def __init__(self, source_dir: Path):
self._source_dir = source_dir
async def fetch(self, asset_id: str, target_path: Path) -> None:
src = self._resolve(asset_id)
if src is None:
raise DownloadError(
f"asset {asset_id!r} not present under {self._source_dir}"
)
tmp = target_path.with_suffix(target_path.suffix + ".tmp")
try:
shutil.copyfile(src, tmp)
tmp.replace(target_path)
except Exception as exc:
tmp.unlink(missing_ok=True)
raise DownloadError(
f"local copy failed for {asset_id}: {exc}"
) from exc
def _resolve(self, asset_id: str) -> Path | None:
flat = self._source_dir / asset_id
if flat.is_file():
return flat
manifest_dir = self._source_dir / asset_id
if manifest_dir.is_dir():
mf = manifest_dir / "manifest.json"
if mf.is_file():
import json
try:
data = json.loads(mf.read_text(encoding="utf-8"))
binary = manifest_dir / str(data["binary_filename"])
if binary.is_file():
return binary
except (OSError, json.JSONDecodeError, KeyError):
pass
return None
def build_downloader_from_env() -> AssetDownloader:
"""Choose a downloader using environment variables.
Resolution order first match wins:
1. ``VELXIO_BOOT_IMAGES_LOCAL_DIR`` :class:`LocalDirectoryDownloader`
(preferred for tests / air-gapped self-hosting).
2. ``VELXIO_BINARY_BASE_URL`` + ``VELXIO_LICENSE_KEY``
:class:`LicenseGatedDownloader` (production default).
Raises :class:`NoDownloaderConfiguredError` if neither path is
configured fail-fast at startup so deployments without the
correct env hit the error in CI rather than at first user request.
"""
local_dir = os.environ.get("VELXIO_BOOT_IMAGES_LOCAL_DIR", "").strip()
if local_dir:
return LocalDirectoryDownloader(Path(local_dir))
base_url = os.environ.get("VELXIO_BINARY_BASE_URL", "").strip()
license_key = os.environ.get("VELXIO_LICENSE_KEY", "").strip()
if base_url and license_key:
return LicenseGatedDownloader(base_url, license_key)
raise NoDownloaderConfiguredError(
"Boot-image downloader is not configured. Set "
"VELXIO_BINARY_BASE_URL + VELXIO_LICENSE_KEY for the velxio.dev "
"licence flow, or VELXIO_BOOT_IMAGES_LOCAL_DIR pointing at a "
"directory holding the asset files."
)