spaxel/Dockerfile
jedarden 5e588592f4 test: add firmware host tests for nvs/csi/serial_prov + wire gcc harness into CI
Adds the three firmware host-test modules required by the Testing Strategy as a
plain gcc harness under firmware/test/ — NOT idf.py --target linux. That path was
rejected (docs/notes/firmware-host-test-approach.md, bf-21t): firmware/main
cannot host-link because csi.c pulls in esp_wifi.h and provision.c pulls in
driver/uart.h, and the single `main` component REQUIRES esp_wifi/bt/driver,
which have no linux build — so even nvs_migration.c (hostable in isolation) is
unhostable as part of the component. The harness therefore tests dependency-free
logic extractions and binary-format/wire contracts instead of linking the
firmware source.

- test_nvs_migration.c: fresh-install init to v1, no-downgrade guard, forward
  migration loop dispatch (v→v+1 at index v−1), and the concrete v1→v2 step
  (rename ms_ip→mothership_ip, default ntp_server), driven against an in-memory
  NVS store. Mirrors nvs_migration.c decision-for-decision.
- test_csi_frame.c: 24-byte header field round-trip, explicit little-endian
  timestamp byte order, signed-RSSI (uint8_t) reinterpretation, I/Q payload
  copy, n_sub=0 header-only probe, and the ingestion-side validation rules
  (too-short / payload-mismatch / n_sub>128 / bad channel). Mirrors the
  websocket.c encoder contract (offset/byte for offset/byte).
- test_serial_prov.c: provisioning JSON parser + NVS-mapping mirror of
  provision.c (all four protocol branches + every field mapping), shipping a
  bounded recursive-descent JSON decoder as the fuzz target. The fuzz pass
  (4000 random byte streams, a tricky-input corpus, 500 deep-nesting cases)
  proves the parser never crashes and the protocol always answers a single
  well-formed {"ok":...} line on any UART input.
- Makefile: gcc build/run recipe that globs every test_*.c + test_runner.c.

CI wiring: the Dockerfile firmware-builder stage now runs `make -C test test`
before the expensive ESP-IDF build, so a logic/format-contract regression fails
the image build fast. .gitignore + .dockerignore exclude the regenerable
host_tests binary.

docs/plan/plan.md Testing Strategy updated from the idf.py description to the
gcc harness (matching the decision record).

28 tests, all passing. go test ./... and go vet ./... unchanged (firmware-only).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 13:21:26 -04:00

123 lines
4.9 KiB
Docker

# Spaxel Mothership Dockerfile
# Multi-stage build: ESP32 firmware (amd64 only) → Go binary → minimal runtime image
# Build arguments for multi-platform support
ARG TARGETPLATFORM=linux/amd64
ARG TARGETARCH=amd64
# Stage 1: Build ESP32-S3 firmware (amd64 only - ESP-IDF is x86_64)
FROM espressif/idf:v5.2 AS firmware-builder
ARG TARGETPLATFORM
# Create build directory
RUN mkdir -p /project/build
# Handle amd64-only firmware build: skip on arm64, build on amd64
RUN if [ "$TARGETPLATFORM" != "linux/amd64" ]; then \
echo "# Firmware not available on $TARGETPLATFORM (ESP-IDF is amd64-only)" > /project/build/spaxel-firmware-merged.bin && \
echo "Firmware build skipped - placeholder created"; \
fi
# Only copy firmware source and build on amd64 (placeholder already created on arm64)
RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
cd /project && \
echo "Building ESP32 firmware for $TARGETPLATFORM"; \
else \
exit 0; \
fi
# Bust Kaniko layer cache when flash size config changes (sdkconfig.defaults).
# Without this ARG, Kaniko can serve a cached firmware layer that was built with
# the old 16MB config even after sdkconfig.defaults is updated to 4MB.
ARG FIRMWARE_CACHE_BUST=2026-06-03
WORKDIR /project
COPY firmware/ ./
# Remove any stale generated sdkconfig so set-target regenerates it from
# sdkconfig.defaults (which specifies CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y).
RUN rm -f sdkconfig sdkconfig.old
# Firmware host-test gate: run the gcc host unit tests (nvs schema migration,
# CSI binary-frame serialization, serial_prov JSON parser fuzz) BEFORE the
# expensive ESP-IDF build so a logic/format-contract regression fails the image
# build fast. Pure gcc — no IDF toolchain needed; gcc + GNU make ship in this
# espressif/idf image. `make` propagates the suite's non-zero exit code on any
# assertion failure, failing the build. This is the gcc harness, NOT idf.py
# --target linux — see firmware/test/Makefile and the decision record
# docs/notes/firmware-host-test-approach.md (firmware/main cannot be host-linked).
RUN make -C test test
# Source export.sh to activate IDF toolchain (entrypoint is not called in build stages).
# set-target must be run explicitly before build even when CONFIG_IDF_TARGET is in sdkconfig.defaults.
# idf.py build produces build/spaxel-firmware.bin
SHELL ["/bin/bash", "-c"]
RUN . $IDF_PATH/export.sh && idf.py set-target esp32s3 && idf.py build && \
python -m esptool --chip esp32s3 merge_bin \
--flash_mode dio --flash_freq 80m --flash_size 4MB \
--output build/spaxel-firmware-merged.bin \
0x0 build/bootloader/bootloader.bin \
0x8000 build/partition_table/partition-table.bin \
0x10000 build/spaxel-firmware.bin
# Stage 2: Build the Go binary (cross-platform)
FROM golang:1.25-bookworm AS builder
WORKDIR /app
# Copy Go module files first for better caching
COPY mothership/go.mod mothership/go.sum ./
RUN go mod download
# Copy source code
COPY mothership/ ./
# Copy dashboard files into the mothership cmd/mothership directory for go:embed
# The go:embed directive in cmd/mothership/main.go references the local dashboard directory
COPY dashboard/ ./cmd/mothership/dashboard/
# Build the binary. CI builds amd64 only (ESP-IDF firmware is x86_64-only),
# so GOOS/GOARCH are pinned to linux/amd64.
# CGO_ENABLED=0 because we use pure-Go SQLite (modernc.org/sqlite)
# -tags=embed enables dashboard embedding via go:embed
ARG VERSION=dev
ARG TARGETPLATFORM
RUN CGO_ENABLED=0 \
GOOS=linux GOARCH=amd64 \
go build \
-ldflags="-s -w -X main.version=${VERSION}" \
-tags=embed \
-o spaxel ./cmd/mothership
# Also build the CSI simulator so the same image can run synthetic-node load
# against a deployed mothership (used by the in-cluster simulator workload).
RUN CGO_ENABLED=0 \
GOOS=linux GOARCH=amd64 \
go build \
-ldflags="-s -w" \
-o spaxel-sim ./cmd/sim
# Stage 3: Minimal runtime image - distroless nonroot
# Dashboard is embedded in the Go binary via go:embed, not copied as files
FROM gcr.io/distroless/static-debian12:nonroot
ARG TARGETARCH=amd64
# Copy the binary (dashboard is embedded via go:embed)
COPY --from=builder /app/spaxel /spaxel
# CSI simulator binary — invoked via an explicit command override in the
# simulator workload; the default ENTRYPOINT still runs the mothership.
COPY --from=builder /app/spaxel-sim /spaxel-sim
# Bake ESP32 firmware into the image so the mothership can seed it on first run.
# The mothership copies /firmware/*.bin → /data/firmware/ at startup if not present.
# Firmware is only included on amd64 builds (ESP-IDF is x86_64-only).
# For non-amd64 builds, the placeholder from firmware-builder stage is included.
COPY --from=firmware-builder /project/build/spaxel-firmware-merged.bin /firmware/spaxel-firmware.bin
VOLUME ["/data"]
# Expose HTTP/WebSocket port
EXPOSE 8080
# Run as non-root
ENTRYPOINT ["/spaxel"]