velxio/docs/BOOT_IMAGES.md

154 lines
6.1 KiB
Markdown
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
# Boot-image provider
Some emulated boards (Raspberry Pi 3, future Pi 4 / Pi 5 if we add
them) need substantial boot files at runtime — kernel, device tree,
and a multi-GiB SD image. Shipping those inside the Docker image is
wasteful (image bloat, every rebuild re-pushes the layer) and
licensing-fragile (third-party OS forks). Pulling them at
container-build time would tie kernel updates to app rebuilds.
The boot-image provider solves this with **lazy, content-addressed,
cache-on-named-volume** materialisation. First user request for a
Pi 3 simulation triggers download + SHA256 verification +
decompression; every subsequent request is a cache hit.
## Architecture
```
backend/app/services/boot_images/
├── __init__.py public API + get_default_provider() singleton
├── manifest.py BootImageSpec / ImageSetSpec / BootImagesManifest
├── manifest.json versioned source of truth
├── integrity.py sha256_file, verify_sha256, decompress_zstd
├── downloader.py AssetDownloader Protocol + 2 implementations
├── provider.py BootImageProvider (the orchestrator)
└── errors.py typed exceptions
```
The key contract is the `AssetDownloader` Protocol:
```python
class AssetDownloader(Protocol):
async def fetch(self, asset_id: str, target_path: Path) -> None: ...
```
Two implementations ship in-box:
* **`LicenseGatedDownloader`** — production default. Fetches from
`${VELXIO_BINARY_BASE_URL}/{asset_id}?key=${VELXIO_LICENSE_KEY}`,
i.e. the same licence-module endpoint that already serves the
ESP32 / RISC-V QEMU libs.
* **`LocalDirectoryDownloader`** — self-hosters / tests. Reads
`${VELXIO_BOOT_IMAGES_LOCAL_DIR}/<asset_id>` (flat) or
`<asset_id>/manifest.json + binary` (matches the licence
module's storage layout, so the in-prod backend can point at
`/var/velxio-pro/binaries` directly).
`build_downloader_from_env()` picks one — local-dir wins if both
sets of env vars are present.
## On-disk layout
The provider caches everything under
`${VELXIO_BOOT_IMAGE_CACHE_DIR}` (default
`/var/cache/velxio/boot-images`). Each image set gets a subdirectory:
```
/var/cache/velxio/boot-images/
├── raspberry-pi-3/
│ ├── kernel8.img ← 9.3 MB raw
│ ├── bcm2710-rpi-3-b.dtb ← 34 KB raw
│ └── raspios-trixie-armhf.img ← 5.4 GB raw
└── (future image sets here)
```
In `docker-compose.yml` mount this as a named volume so it survives
`compose down/up`:
```yaml
volumes:
- boot-images:/var/cache/velxio/boot-images
volumes:
boot-images:
```
## Guarantees the provider gives you
1. **Atomic writes.** Files are downloaded to `<name>.tmp` inside the
cache directory, verified, and `rename()`-d into the final slot.
A killed container during download leaves the cache untouched.
2. **Content-addressed verification.** Every materialised file is
`sha256_file()`'d against the manifest. Mismatches raise
`IntegrityError` and DO NOT replace the existing cached file.
3. **Decompression is part of the verify ladder.** For compressed
assets the wire-format SHA256 is verified before decompression and
the decompressed SHA256 after, so a corrupt-on-wire or corrupt-
after-decompress both surface as `IntegrityError` with `name=
"<file> (compressed)"` vs `"<file> (decompressed)"`.
4. **Per-set concurrency control.** Two concurrent
`provider.get("raspberry-pi-3")` collapse into one download via a
per-set `asyncio.Lock`. Different sets materialise in parallel.
5. **Idempotent re-gets.** Cached files are re-validated by size +
SHA256 before being returned, so a corrupted cache (disk error,
manual `rm`) auto-repairs.
## Adding a new image set
1. Run the asset prep script
(`velxio-prod/scripts/prepare-pi3-images.sh`, adapt for the new
board) to produce the files + SHA256s.
2. Upload to the licence-module storage with `upload-binary.sh`.
3. Append the entry to `manifest.json`:
```json
"raspberry-pi-4": {
"description": "...",
"images": [
{
"name": "kernel8.img",
"asset_id": "kernel8-pi4",
"sha256": "...",
"size_bytes": ...
}
]
}
```
4. Register a lifespan pre-warm in whatever service module owns the
new board's QEMU integration (mirror
`qemu_manager.py:_prewarm_pi3_boot_images`).
5. Call `provider.get("raspberry-pi-4")` from the boot code; iterate
over the returned `{name: Path}` dict.
No edits to `provider.py` or `downloader.py` should be needed —
the abstraction is meant to absorb new boards purely through manifest
edits.
## When something goes wrong
| Symptom | Likely cause | Where to look |
|---|---|---|
| `NoDownloaderConfiguredError` at startup | neither env-var pair set | `build_downloader_from_env()` |
| `IntegrityError: …(compressed)` | the .zst on the wire is corrupt OR `compressed.sha256` in the manifest is wrong | re-verify the upload against `manifest.json` |
| `IntegrityError: …(decompressed)` | the .zst decompressed correctly but the underlying bytes drifted; usually means we bumped the source image but forgot to bump `sha256` | regenerate the manifest from `scripts/prepare-*` |
| `DownloadError: HTTP 401` | licence key invalid / suspended | check `${VELXIO_LICENSE_KEY}` + `pro` admin panel |
| First Pi 3 simulation request hangs ~60 s | the lifespan pre-warm failed silently and the user hit the cold-cache download path | grep backend log for `[boot-images]` |
| Files re-download on every container restart | named volume not mounted on `${VELXIO_BOOT_IMAGE_CACHE_DIR}` | check `docker-compose.yml` |
## Tests
`test/backend/unit/test_boot_images.py` covers 21 scenarios across
manifest parsing, integrity, both downloaders, the provider's
idempotent / concurrent / integrity / decompression / warmup paths.
Run with:
```bash
pytest test/backend/unit/test_boot_images.py -v
```
The provider tests use an in-process `FakeDownloader` so they don't
need network, httpx, or real zstd files past the `decompress_zstd`
unit test (which uses `zstandard.ZstdCompressor()` to round-trip a
small in-memory blob).