diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..672b11b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,37 @@ +# Git +.git +.gitignore + +# Documentation +docs/ +*.md +!README.md + +# Marathon logs +.marathon/ + +# Firmware build artifacts (use volume mount instead) +firmware/build/ +firmware/managed_components/ +firmware/.cache/ + +# IDE and editor files +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS files +.DS_Store +Thumbs.db + +# Test and coverage +*.test +*.out +coverage.txt + +# Temporary files +tmp/ +temp/ +*.tmp diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9af4e66 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Marathon logs +.marathon/logs/ + +# IDE and editor files +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS files +.DS_Store +Thumbs.db + +# Firmware build artifacts +firmware/build/ +firmware/managed_components/ +firmware/.cache/ + +# Test and coverage +*.test +*.out +coverage.txt + +# Temporary files +tmp/ +temp/ +*.tmp diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a2b055e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,44 @@ +# Spaxel Mothership Dockerfile +# Multi-stage build for minimal production image + +# Stage 1: Build the Go binary +FROM golang:1.23-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/ ./ + +# Build the binary +# CGO_ENABLED=0 because we use pure-Go SQLite (modernc.org/sqlite) +ARG VERSION=dev +RUN CGO_ENABLED=0 GOOS=linux go build \ + -ldflags="-s -w -X main.version=${VERSION}" \ + -o spaxel ./cmd/mothership + +# Stage 2: Minimal runtime image +FROM gcr.io/distroless/static-debian12:nonroot + +# Copy the binary +COPY --from=builder /app/spaxel /spaxel + +# Copy dashboard static files (served from filesystem at runtime) +COPY dashboard/ /dashboard/ + +# Create firmware directory (users should mount their own firmware volume) +# The container will serve firmware binaries for OTA from /firmware/ +VOLUME ["/data", "/firmware"] + +# Expose HTTP/WebSocket port +EXPOSE 8080 + +# Health check (distroless has wget) +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + ["wget", "-q", "-O-", "http://localhost:8080/healthz"] + +# Run as non-root (distroless default is UID 65532) +ENTRYPOINT ["/spaxel"] diff --git a/PROGRESS.md b/PROGRESS.md index 7c70dfc..7657aac 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -13,10 +13,63 @@ Goal: Bare-minimum loop from ESP32 to browser. Zero-config with passive radar an | BLE scanning | **Done** | Core 0 concurrent with WiFi | | Mothership WebSocket ingestion | **Done** | See iteration 1 below | | Dashboard skeleton | **Done** | See iteration 3 below | -| Docker packaging | Not started | | +| Docker packaging | **Done** | See iteration 4 below | ### Iteration Log +#### Iteration 4 — 2026-03-26 + +**Completed:** Docker packaging + +Implemented container deployment with: + +- **Dockerfile:** Multi-stage build for minimal production image + - Stage 1: `golang:1.23-bookworm` builder with module caching + - Stage 2: `distroless/static-debian12:nonroot` runtime (no shell, runs as UID 65532) + - CGO_ENABLED=0 build (pure-Go SQLite compatible) + - Dashboard static files copied to `/dashboard/` + - Firmware directory as volume mount point (`/firmware/`) + - Built-in healthcheck via wget + +- **docker-compose.yml:** Production-ready orchestration + - `network_mode: host` for mDNS multicast discovery (required for ESP32 nodes) + - Volume mounts: `spaxel-data` for persistence, `./firmware` for OTA binaries + - Environment variables: TZ, mDNS config, optional MQTT + - Resource limits: 512m RAM, 2 CPUs (scalable to 1g for 16+ nodes) + - 35s stop_grace_period for graceful shutdown + - Ulimits: 4096/8192 file descriptors for node connections + - Traefik labels (disabled by default, enable for TLS/proxy) + +- **Supporting files:** + - `VERSION` — 0.1.0 for image tagging + - `.dockerignore` — Excludes docs, build artifacts, IDE files + +**Key design decisions:** +- Host networking is REQUIRED for mDNS — Docker bridge blocks multicast 224.0.0.251 +- distroless image minimizes attack surface (no shell, no package manager) +- Firmware binaries not bundled — users mount their own `/firmware` volume +- Traefik labels included but disabled — enable for production TLS + +**Files created:** +``` +Dockerfile +docker-compose.yml +VERSION +.dockerignore +``` + +**Phase 1 Status:** COMPLETE + +All Phase 1 items implemented: +- ✅ ESP32 firmware skeleton +- ✅ Passive radar support +- ✅ BLE scanning +- ✅ Mothership WebSocket ingestion +- ✅ Dashboard skeleton +- ✅ Docker packaging + +**Next:** Phase 2 — Signal Processing (baseline, deltaRMS, Fresnel zones) + #### Iteration 3 — 2026-03-26 **Completed:** Dashboard skeleton with 3D visualization diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9ea4a1f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,87 @@ +# Spaxel Docker Compose +# Production deployment configuration +# +# IMPORTANT: network_mode: host is REQUIRED for mDNS to work. +# mDNS uses multicast address 224.0.0.251 (link-local), which Docker +# bridge networking blocks. With host networking, the container shares +# the host's network interfaces and mDNS multicasts reach the LAN. +# +# Side effect: 'ports' mapping is ignored in host mode — port 8080 is +# directly exposed on the host. + +services: + spaxel: + build: + context: . + dockerfile: Dockerfile + args: + VERSION: ${VERSION:-dev} + # Alternative: use pre-built image + # image: ghcr.io/spaxel/spaxel:latest + + # Host networking required for mDNS discovery by ESP32 nodes + network_mode: host + + # Alternative (if host mode is not desired): disable mDNS and require + # nodes to use cached ms_ip NVS key (manual IP entry during provisioning). + # Set SPAXEL_MDNS_ENABLED=false to skip the mDNS advertisement entirely. + + volumes: + - spaxel-data:/data # SQLite, baselines, floor plans, CSI buffer + - ./firmware:/firmware:ro # Firmware binaries for OTA (read-only) + + environment: + # Timezone - required for correct diurnal baseline hours + TZ: ${TZ:-UTC} + # mDNS service name (must match firmware ms_mdns NVS key) + SPAXEL_MDNS_NAME: ${SPAXEL_MDNS_NAME:-spaxel} + # Set to false if using bridge networking (nodes must use cached IP) + SPAXEL_MDNS_ENABLED: ${SPAXEL_MDNS_ENABLED:-true} + # Optional MQTT integration (uncomment to enable) + # SPAXEL_MQTT_BROKER: mqtt://homeassistant.local:1883 + # SPAXEL_MQTT_USERNAME: mosquitto + # SPAXEL_MQTT_PASSWORD: secret + # Debug logging (uncomment for troubleshooting) + # SPAXEL_LOG_LEVEL: debug + + restart: unless-stopped + + # Allow full 30s graceful shutdown + stop_grace_period: 35s + + ulimits: + nofile: + soft: 4096 # One fd per node connection + SQLite handles + hard: 8192 + + healthcheck: + test: ["CMD", "wget", "-q", "-O-", "http://localhost:8080/healthz"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s + + deploy: + resources: + limits: + memory: 512m # Increase to 1g for 16+ node fleets + cpus: "2.0" + reservations: + memory: 128m + cpus: "0.5" + + # Traefik labels for reverse proxy with TLS + # Uncomment and customize for your Traefik setup + labels: + - "traefik.enable=${TRAEFIK_ENABLE:-false}" + # - "traefik.http.routers.spaxel.rule=Host(`spaxel.example.com`)" + # - "traefik.http.routers.spaxel.entrypoints=websecure" + # - "traefik.http.routers.spaxel.tls.certresolver=letsencrypt" + # - "traefik.http.services.spaxel.loadbalancer.server.port=8080" + # # Extend Traefik timeout for long-lived WebSocket connections + # - "traefik.http.routers.spaxel.middlewares=spaxel-ws-timeout" + # - "traefik.http.middlewares.spaxel-ws-timeout.headers.respondingTimeouts.readTimeout=3600s" + +volumes: + spaxel-data: + driver: local