name: Firmware QEMU Tests (ADR-061) on: push: paths: - 'firmware/**' - 'scripts/qemu-esp32s3-test.sh' - 'scripts/validate_qemu_output.py' - 'scripts/generate_nvs_matrix.py' - 'scripts/qemu_swarm.py' - 'scripts/swarm_health.py' - 'scripts/swarm_presets/**' - '.github/workflows/firmware-qemu.yml' pull_request: paths: - 'firmware/**' - 'scripts/qemu-esp32s3-test.sh' - 'scripts/validate_qemu_output.py' - 'scripts/generate_nvs_matrix.py' - 'scripts/qemu_swarm.py' - 'scripts/swarm_health.py' - 'scripts/swarm_presets/**' - '.github/workflows/firmware-qemu.yml' env: IDF_VERSION: "v5.4" QEMU_REPO: "https://github.com/espressif/qemu.git" QEMU_BRANCH: "esp-develop" jobs: build-qemu: name: Build Espressif QEMU runs-on: ubuntu-latest steps: - name: Cache QEMU build id: cache-qemu uses: actions/cache@v4 with: path: /opt/qemu-esp32 # Include date component so cache refreshes monthly when branch updates key: qemu-esp32s3-${{ env.QEMU_BRANCH }}-v5 restore-keys: | qemu-esp32s3-${{ env.QEMU_BRANCH }}- - name: Install QEMU build dependencies if: steps.cache-qemu.outputs.cache-hit != 'true' run: | sudo apt-get update sudo apt-get install -y \ git build-essential ninja-build pkg-config \ libglib2.0-dev libpixman-1-dev libslirp-dev \ libgcrypt20-dev \ python3 python3-venv - name: Clone and build Espressif QEMU if: steps.cache-qemu.outputs.cache-hit != 'true' run: | git clone --depth 1 -b "$QEMU_BRANCH" "$QEMU_REPO" /tmp/qemu-esp cd /tmp/qemu-esp mkdir build && cd build ../configure \ --target-list=xtensa-softmmu \ --prefix=/opt/qemu-esp32 \ --enable-slirp \ --disable-werror ninja -j$(nproc) ninja install - name: Verify QEMU binary run: | file_size() { stat -c%s "$1" 2>/dev/null || stat -f%z "$1" 2>/dev/null || wc -c < "$1"; } /opt/qemu-esp32/bin/qemu-system-xtensa --version echo "QEMU binary size: $(file_size /opt/qemu-esp32/bin/qemu-system-xtensa) bytes" - name: Upload QEMU artifact uses: actions/upload-artifact@v4 with: name: qemu-esp32 path: /opt/qemu-esp32/ retention-days: 7 qemu-test: name: QEMU Test (${{ matrix.nvs_config }}) needs: build-qemu runs-on: ubuntu-latest container: image: espressif/idf:v5.4 strategy: fail-fast: false matrix: nvs_config: - default - full-adr060 - edge-tier0 - edge-tier1 - tdm-3node - boundary-max - boundary-min steps: - uses: actions/checkout@v4 - name: Download QEMU artifact uses: actions/download-artifact@v4 with: name: qemu-esp32 path: /opt/qemu-esp32 - name: Make QEMU executable run: chmod +x /opt/qemu-esp32/bin/qemu-system-xtensa - name: Verify QEMU works run: /opt/qemu-esp32/bin/qemu-system-xtensa --version - name: Install Python dependencies run: | . $IDF_PATH/export.sh pip install esptool esp-idf-nvs-partition-gen - name: Set target ESP32-S3 working-directory: firmware/esp32-csi-node run: | . $IDF_PATH/export.sh idf.py set-target esp32s3 - name: Build firmware (mock CSI mode) working-directory: firmware/esp32-csi-node run: | . $IDF_PATH/export.sh idf.py \ -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.qemu" \ build - name: Generate NVS matrix run: | . $IDF_PATH/export.sh python3 scripts/generate_nvs_matrix.py \ --output-dir firmware/esp32-csi-node/build/nvs_matrix \ --only ${{ matrix.nvs_config }} - name: Create merged flash image working-directory: firmware/esp32-csi-node run: | . $IDF_PATH/export.sh # Determine merge_bin arguments OTA_ARGS="" if [ -f build/ota_data_initial.bin ]; then OTA_ARGS="0xf000 build/ota_data_initial.bin" fi python3 -m esptool --chip esp32s3 merge_bin \ -o build/qemu_flash.bin \ --flash_mode dio --flash_freq 80m --flash_size 8MB \ --fill-flash-size 8MB \ 0x0 build/bootloader/bootloader.bin \ 0x8000 build/partition_table/partition-table.bin \ $OTA_ARGS \ 0x20000 build/esp32-csi-node.bin file_size() { stat -c%s "$1" 2>/dev/null || stat -f%z "$1" 2>/dev/null || wc -c < "$1"; } echo "Flash image size: $(file_size build/qemu_flash.bin) bytes" - name: Inject NVS partition if: matrix.nvs_config != 'default' working-directory: firmware/esp32-csi-node run: | NVS_BIN="build/nvs_matrix/nvs_${{ matrix.nvs_config }}.bin" if [ -f "$NVS_BIN" ]; then file_size() { stat -c%s "$1" 2>/dev/null || stat -f%z "$1" 2>/dev/null || wc -c < "$1"; } echo "Injecting NVS: $NVS_BIN ($(file_size "$NVS_BIN") bytes)" dd if="$NVS_BIN" of=build/qemu_flash.bin \ bs=1 seek=$((0x9000)) conv=notrunc 2>/dev/null else echo "WARNING: NVS binary not found: $NVS_BIN" fi - name: Run QEMU smoke test env: QEMU_PATH: /opt/qemu-esp32/bin/qemu-system-xtensa QEMU_TIMEOUT: "90" run: | echo "Starting QEMU (timeout: ${QEMU_TIMEOUT}s)..." timeout "$QEMU_TIMEOUT" "$QEMU_PATH" \ -machine esp32s3 \ -nographic \ -drive file=firmware/esp32-csi-node/build/qemu_flash.bin,if=mtd,format=raw \ -serial mon:stdio \ -nic user,model=open_eth,net=10.0.2.0/24 \ -no-reboot \ 2>&1 | tee firmware/esp32-csi-node/build/qemu_output.log || true echo "QEMU finished. Log size: $(wc -l < firmware/esp32-csi-node/build/qemu_output.log) lines" - name: Validate QEMU output run: | python3 scripts/validate_qemu_output.py \ firmware/esp32-csi-node/build/qemu_output.log - name: Upload test logs if: always() uses: actions/upload-artifact@v4 with: name: qemu-logs-${{ matrix.nvs_config }} path: | firmware/esp32-csi-node/build/qemu_output.log firmware/esp32-csi-node/build/nvs_matrix/ retention-days: 14 fuzz-test: name: Fuzz Testing (ADR-061 Layer 6) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install clang run: | sudo apt-get update sudo apt-get install -y clang - name: Build fuzz targets working-directory: firmware/esp32-csi-node/test run: make all CC=clang - name: Run serialize fuzzer (60s) working-directory: firmware/esp32-csi-node/test run: make run_serialize FUZZ_DURATION=60 || echo "FUZZER_CRASH=serialize" >> "$GITHUB_ENV" - name: Run edge enqueue fuzzer (60s) working-directory: firmware/esp32-csi-node/test run: make run_edge FUZZ_DURATION=60 || echo "FUZZER_CRASH=edge" >> "$GITHUB_ENV" - name: Run NVS config fuzzer (60s) working-directory: firmware/esp32-csi-node/test run: make run_nvs FUZZ_DURATION=60 || echo "FUZZER_CRASH=nvs" >> "$GITHUB_ENV" - name: Check for crashes working-directory: firmware/esp32-csi-node/test run: | CRASHES=$(find . -type f \( -name "crash-*" -o -name "oom-*" -o -name "timeout-*" \) 2>/dev/null | wc -l) echo "Crash artifacts found: $CRASHES" if [ "$CRASHES" -gt 0 ] || [ -n "${FUZZER_CRASH:-}" ]; then echo "::error::Fuzzer found $CRASHES crash/oom/timeout artifacts. FUZZER_CRASH=${FUZZER_CRASH:-none}" ls -la crash-* oom-* timeout-* 2>/dev/null exit 1 fi - name: Upload fuzz artifacts if: failure() uses: actions/upload-artifact@v4 with: name: fuzz-crashes path: | firmware/esp32-csi-node/test/crash-* firmware/esp32-csi-node/test/oom-* firmware/esp32-csi-node/test/timeout-* retention-days: 30 nvs-matrix-validate: name: NVS Matrix Generation runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install NVS generator run: pip install esp-idf-nvs-partition-gen - name: Generate all 14 NVS configs run: | python3 scripts/generate_nvs_matrix.py \ --output-dir build/nvs_matrix - name: Verify all binaries generated run: | EXPECTED=14 ACTUAL=$(find build/nvs_matrix -type f -name "nvs_*.bin" 2>/dev/null | wc -l) echo "Generated $ACTUAL / $EXPECTED NVS binaries" ls -la build/nvs_matrix/ if [ "$ACTUAL" -lt "$EXPECTED" ]; then echo "::error::Only $ACTUAL of $EXPECTED NVS binaries generated" exit 1 fi - name: Verify binary sizes run: | file_size() { stat -c%s "$1" 2>/dev/null || stat -f%z "$1" 2>/dev/null || wc -c < "$1"; } for f in build/nvs_matrix/nvs_*.bin; do SIZE=$(file_size "$f") if [ "$SIZE" -ne 24576 ]; then echo "::error::$f has unexpected size $SIZE (expected 24576)" exit 1 fi echo " OK: $(basename $f) ($SIZE bytes)" done # --------------------------------------------------------------------------- # ADR-062: QEMU Swarm Configurator Test # # Runs a lightweight 3-node swarm (ci_matrix preset) under QEMU to validate # multi-node orchestration, TDM slot coordination, and swarm-level health # assertions. Uses the pre-built QEMU binary from the build-qemu job and the # firmware built by qemu-test. # # The CI runner is non-root, so TAP bridge networking is unavailable. # The orchestrator (qemu_swarm.py) detects this and falls back to SLIRP # user-mode networking, which is sufficient for the ci_matrix preset. # --------------------------------------------------------------------------- swarm-test: name: Swarm Test (ADR-062) needs: [build-qemu] runs-on: ubuntu-latest container: image: espressif/idf:v5.4 steps: - uses: actions/checkout@v4 - name: Download QEMU artifact uses: actions/download-artifact@v4 with: name: qemu-esp32 path: /opt/qemu-esp32 - name: Make QEMU executable run: chmod +x /opt/qemu-esp32/bin/qemu-system-xtensa - name: Install Python dependencies run: | . $IDF_PATH/export.sh pip install pyyaml esptool esp-idf-nvs-partition-gen - name: Build firmware for swarm working-directory: firmware/esp32-csi-node run: | . $IDF_PATH/export.sh idf.py set-target esp32s3 idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.qemu" build python3 -m esptool --chip esp32s3 merge_bin \ -o build/qemu_flash.bin \ --flash_mode dio --flash_freq 80m --flash_size 8MB \ --fill-flash-size 8MB \ 0x0 build/bootloader/bootloader.bin \ 0x8000 build/partition_table/partition-table.bin \ 0x20000 build/esp32-csi-node.bin - name: Run swarm smoke test run: | . $IDF_PATH/export.sh EXIT_CODE=0 python3 scripts/qemu_swarm.py --preset ci_matrix \ --qemu-path /opt/qemu-esp32/bin/qemu-system-xtensa \ --output-dir build/swarm-results || EXIT_CODE=$? # Exit 0=PASS, 1=WARN (acceptable in CI without real hardware) if [ "$EXIT_CODE" -gt 1 ]; then echo "Swarm test failed with exit code $EXIT_CODE" exit "$EXIT_CODE" fi timeout-minutes: 10 - name: Upload swarm results if: always() uses: actions/upload-artifact@v4 with: name: swarm-results path: | build/swarm-results/ retention-days: 14