From d0babf2041567c7e5739430a988a6c4e6781bd90 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 15 Jul 2026 13:45:29 +0300 Subject: [PATCH] =?UTF-8?q?docs:=20=D0=BE=D0=BF=D0=B8=D1=81=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20offline=20CI=20(=D0=B4=D0=B2=D0=B0=20runner'=D0=B0,=20?= =?UTF-8?q?=D1=87=D1=82=D0=BE=20=D1=82=D0=B5=D1=81=D1=82=D0=B8=D1=80=D1=83?= =?UTF-8?q?=D0=B5=D1=82=D1=81=D1=8F,=20=D0=BD=D1=8E=D0=B0=D0=BD=D1=81?= =?UTF-8?q?=D1=8B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 4 +- README.md | 47 ++++++++++++-- docs/CI.md | 187 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 232 insertions(+), 6 deletions(-) create mode 100644 docs/CI.md diff --git a/CLAUDE.md b/CLAUDE.md index 91d972e..e4e2bcf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,4 +61,6 @@ TX LO: канал `altvoltage1` (output); RX LO: `altvoltage0` (output). Сборка: `make` · Деплой: `make deploy` (scp -O! sftp на плате нет) Бенчмарк: `make bench && scripts/deploy.sh bench && ssh root@ '/tmp/ofdm_bench 1.92'` E2E-тест: `scripts/test_file_link.sh` -Пароль root на Pluto: `analog`. rootfs в RAM — /tmp очищается при ребуте. \ No newline at end of file +Пароль root на Pluto: `analog`. rootfs в RAM — /tmp очищается при ребуте. +Offline CI на push (2 self-hosted runner'а, host+armhf, НЕ эмуляция ARM +— кросс-компиляция): `.gitea/workflows/`, разбор нюансов в docs/CI.md. \ No newline at end of file diff --git a/README.md b/README.md index 2518138..8dc7e3f 100644 --- a/README.md +++ b/README.md @@ -54,20 +54,28 @@ PHY: OFDM (liquid-dsp) + RS(255,223) FEC. Весь сигнальный трак pluto-link/ ├── README.md # этот файл ├── CLAUDE.md # краткие правила для Claude Code -├── makefile # цели: all, tx, rx, bench, deploy, clean +├── makefile # цели: all, tx, rx, bench, gftest, deploy, clean ├── toolchain.env # CROSS=arm-linux-gnueabihf-, SYSROOT=$HOME/xarm +├── .gitea/workflows/ # offline CI (см. §4.5, docs/CI.md) +│ ├── 01-smoke.yml # runner жив, что есть в образе +│ ├── 02-guardrails.yml # grep-страж жёстких правил CLAUDE.md +│ ├── 03-gftest.yml # юнит-тест group-FEC (774 400 кейсов) +│ └── 04-cross-build.yml # приёмка ARM-тулчейна + сборка tx/rx/bench ├── src/ │ ├── common.h # параметры OFDM/RS, дефолты, прототипы │ ├── common.c # pluto_init (local:), pluto_configure │ ├── transmitter.c # stdin → OFDM → AD9361 TX │ ├── receiver.c # AD9361 RX → OFDM sync → stdout +│ ├── group_fec.c # GF(256) 16+2 erasure FEC + offline self-test │ └── ofdm_bench.c # бенчмарк CPU (не удалять!) ├── scripts/ │ ├── build_deps.sh # кросс-сборка FFTW + liquid-dsp → $HOME/xarm │ ├── deploy.sh # scp -O бинарей в /tmp обеих плат -│ └── test_file_link.sh # e2e: файл → эфир → md5-сверка +│ ├── test_file_link.sh # e2e: файл → эфир → md5-сверка +│ └── ci_guardrails.sh # логика 02-guardrails.yml, гоняется и локально └── docs/ ├── BENCHMARK.md # методика и результаты замеров CPU + ├── CI.md # два runner'а, что тестируется, нюансы └── NOTES.md # FDD, регуляторика, известные грабли ``` @@ -121,11 +129,16 @@ liquid молча падает на встроенный FFT (бюджет CPU arm-linux-gnueabihf-gcc -O3 -mcpu=cortex-a9 -mfpu=neon -mfloat-abi=hard \ -ffast-math -static src/receiver.c src/common.c -o receiver \ -I$HOME/xarm/include -L$HOME/xarm/lib \ - -lad9361 -liio -lliquid -lfec -lfftw3f -lm + -Wl,--start-group -lad9361 -liio -lliquid -lfec -lfftw3f -lm -lpthread -Wl,--end-group ``` -Порядок библиотек критичен при статической линковке: зависимый идёт раньше -(`-lad9361` перед `-liio`, `-lliquid` перед `-lfec`). +Библиотеки завёрнуты в `-Wl,--start-group ... -Wl,--end-group`: у +`libad9361.a`/`libiio.a` есть циклическая зависимость символов друг на друга, +и однопроходный порядок слева направо её не всегда разрешает — компоновщику +нужно несколько проходов по кругу. Баг был не виден на бумаге и всплыл +только на реальной кросс-сборке в CI (`04-cross-build.yml`, см. §4.5); без +`--start-group`/`--end-group` `make all` изредка падает на undefined +reference в зависимости от версии тулчейна. ### 4.4 Деплой @@ -137,6 +150,30 @@ scp -O transmitter receiver root@:/tmp/ # Pluto B rootfs Pluto живёт в RAM: `/tmp` очищается при ребуте — это нормально, `deploy.sh` заливает заново. +### 4.5 CI / автотесты (offline Gitea Actions) + +На каждый push гоняются 4 workflow на self-hosted-runner'ах сервера, без +доступа в интернет и без `actions/checkout` (Marketplace офлайн недоступен). +Два независимых runner'а: `host` (Alpine, только grep/bash-проверки) и +`armhf` (нативный x86-64 Debian-контейнер с ARM-кросс-тулчейном — **не** +эмуляция ARM, обычная кросс-компиляция). + +| Workflow | Runner | Проверяет | +|---|---|---| +| `01-smoke` | `host` | runner жив, какие инструменты есть в образе | +| `02-guardrails` | `host` | grep-страж жёстких правил CLAUDE.md (§5): rate, `header_len=12`, `local:`, static-флаги | +| `03-gftest` | `armhf` | `make gftest` — 774 400 кейсов GF(256) group-FEC | +| `04-cross-build` | `armhf` | `make check-env && make all` — реальная кросс-сборка tx/rx/bench | + +Локально без runner'а: `scripts/ci_guardrails.sh`, `make gftest`, `make +check-env && make all`. Полное описание архитектуры двух runner'ов, +почему это не эмуляция ARM, и разбор реальных ловушек (musl vs glibc, +group-linking из §4.3, параллелизм между runner'ами) — **`docs/CI.md`**. + +Зелёные 01–04 подтверждают компилируемость и статические инварианты. Они +**не** подтверждают передачу данных по эфиру — это по-прежнему ручной +чек-лист: `scripts/test_file_link.sh`, деплой, бенчмарк на плате (§8.2). + ## 5. Правила кода (конституция) 1. Язык — **C**. Python/GNU Radio в тракте данных запрещены. diff --git a/docs/CI.md b/docs/CI.md new file mode 100644 index 0000000..123c5cb --- /dev/null +++ b/docs/CI.md @@ -0,0 +1,187 @@ +# CI.md — офлайн-автотесты на self-hosted Gitea Actions + +Сервер Gitea изолирован (без постоянного интернета), поэтому здесь нет +`actions/checkout`, нет Marketplace-экшенов и нет ни одного `uses:` — только +`run:`. Прежде чем править `.gitea/workflows/*`, прочитай это целиком: почти +все неожиданные падения объясняются структурой раздела «Нюансы окружения» +ниже, а не багом в тесте. + +## Два runner'а — зачем и как разделены + +Сигнальный тракт pluto-link целиком живёт на ARM (Cortex-A9 внутри Pluto), а +сам Gitea-сервер — x86_64. Проверить можно только то, что не требует +Pluto-железа: (1) чистую логику на libc, (2) факт компиляции под armhf, +(3) статические grep-правила. Для этого заведено два независимых runner'а с +разными метками — они физически разные Docker-контейнеры на одном сервере: + +| Runner | Контейнер | База образа | Метка | Что гоняет | +|---|---|---|---|---| +| основной | `gitea_runner` | Alpine `act_runner_tools:0.2.13` | `host` | 01-smoke, 02-guardrails | +| cross-builder | `gitea_runner_armhf` | Debian bookworm, **нативный x86-64** | `armhf` | 03-gftest, 04-cross-build | + +У каждого `capacity: 1` в `config.yaml` — внутри одного runner'а job'ы идут +строго последовательно (см. «Нюансы» ниже про параллелизм между runner'ами). + +Оба используют host-executor: шаги `run:` выполняются прямо в окружении +процесса runner (то есть внутри контейнера), без docker-in-docker. Отсюда то +же правило, что и в примерах для других проектов на этом сервере: **что +лежит в образе runner'а — то и доступно job'у; что стоит на Debian-хосте +сервера — не видно job'у вообще.** + +## Что проверяет каждый workflow + +### `.gitea/workflows/01-smoke.yml` — информационный +Печатает `/etc/os-release` и `uname -a` (докажет, что это Alpine job-окружения, +а не Debian-хост сервера), какие инструменты есть (`git`/`bash`/`cc`/`make`/ +`arm-linux-gnueabihf-gcc`) и есть ли `$XARM/lib/*.a`. **Никогда не падает** — +только помечает `[ЕСТЬ]`/`[НЕТ]`. Зелёный означает буквально одно: «runner +жив и взял задачу», не более. `timeout-minutes: 5`. + +### `.gitea/workflows/02-guardrails.yml` — статический страж +Клонирует репозиторий (без `actions/checkout`, см. ниже) и гоняет +`scripts/ci_guardrails.sh` — 17 grep/bash-ассертов на жёсткие правила +CLAUDE.md: sample rate `1920000` и никогда `3840000`, `header_len=12` на TX +**и** RX (техдолг #1), только `local:` в тракте, статические/ARM-флаги в +makefile, отсутствие тяжёлых зависимостей, отсутствие Python в `src/`. Скрипт +самодостаточен и гоняется точно так же локально: +```sh +scripts/ci_guardrails.sh +``` +Тулчейн не нужен — работает на базовом Alpine-образе. `timeout-minutes: 5`. + +### `.gitea/workflows/03-gftest.yml` — юнит-тест group-FEC +`make gftest` (см. `makefile`): нативная (не кросс-!) сборка +`src/group_fec.c` с `-DGF_SELFTEST` через хостовый `cc` cross-builder'а и +запуск самопроверки GF(256) — 774 400 кейсов восстановления группового FEC +16+2, честный pass/fail по коду возврата. Модуль тянет только `/ +//`, поэтому компилируется любым gcc. Идёт на +`runs-on: armhf`, потому что там гарантированно есть нативный `cc` +(`build-essential`) — это не про ARM, просто удобно совместить с +cross-builder'ом. `timeout-minutes: 5`. + +### `.gitea/workflows/04-cross-build.yml` — приёмочный тест ARM-тулчейна +`make check-env && make all` — реальная кросс-сборка `transmitter`, +`receiver`, `ofdm_bench` под armhf с `-Wall -Wextra -static`. Ловит любую +компиляционную регрессию в боевом коде до деплоя на плату. `timeout-minutes: 15`. + +## Ключевой архитектурный момент: это НЕ эмуляция ARM + +Первая попытка поднять второй runner использовала `platform: linux/arm64` / +`linux/arm/v7` в compose — и падала с `exec /sbin/tini: exec format error`: +Docker пытался выполнить ARM-бинарь на x86-ядре сервера без +qemu/binfmt-регистрации. + +Это была неверная модель. **Кросс-компиляция не требует ARM-машины.** +`arm-linux-gnueabihf-gcc` — обычный **x86-бинарь**, который на входе берёт +`.c` и на выходе даёт ARM-машинный код; выполняется на полной скорости +x86-ядра, без единого такта эмуляции. Это ровно то же самое, что уже +десятилетиями делает Raspberry Pi-хост в `scripts/build_deps.sh` — только +хост там aarch64, а не x86_64, что для кросс-компиляции роли не играет. + +Рабочее решение: `gitea_runner_armhf` — **нативный x86-64** контейнер +(`FROM debian:bookworm`, без `platform:` в compose), в который *вложен* +ARM-кросс-тулчейн как обычный пакет (`gcc-arm-linux-gnueabihf`). Сам +`act_runner` в этом контейнере тоже нативный `linux-amd64` — ARM в цепочке +нет нигде, кроме результата компиляции. + +## Нюансы окружения (то, что реально ломалось) + +**musl vs glibc — «файл есть» ≠ «работает».** На Alpine (`host`-runner) +glibc-бинарь может лежать на диске и даже отвечать на `command -v`, но +падать с обманчивым `not found` при запуске — потому что нет `ld-linux`. +Поэтому все приёмочные шаги здесь **компилируют и запускают** пробную +программу, а не проверяют `command -v`. В `04-cross-build.yml` это доведено +до предела: после сборки пробника парсится байт `e_machine` прямо из +ELF-заголовка (`od -An -tx1 -j18 -N2`, ждём `2800` = `EM_ARM` в +little-endian) — «собралось без ошибки» тоже не значит «собралось под ту +архитектуру». Тот же разбор повторяется для всех трёх собранных бинарников в +`04`, а не только для пробника. + +**`$XARM` перенесён с RPi, не пересобирается в CI.** Пять статических +armhf-библиотек (`libliquid.a`, `libfec.a`, `libfftw3f.a`, `libiio.a`, +`libad9361.a`) — результат `scripts/build_deps.sh` (~15–20 мин, нужен +интернет и sudo). Пересобирать их в образе cross-builder'а на каждый билд +образа избыточно: они `.a`-файлы с ARM-машинным кодом, архитектура и ОС +хоста, на котором их скопировали, роли не играет. Поэтому в +`runner-image/Dockerfile` (лежит на сервере, `/opt/gitea-runner-armhf/`, вне +этого git-репозитория) готовый `~/xarm` с RPi просто кладётся `COPY xarm +/root/xarm` — путь совпадает с дефолтным `XARM ?= $(HOME)/xarm` из +`makefile`, поэтому `make check-env`/`make all` в CI работают без +дополнительных переменных окружения. + +**Циклические зависимости статических либ потребовали group-linking.** +Прямой прогон `make all` на реальном cross-builder'е (не на бумаге) вскрыл, +что однопроходного порядка `-lad9361 -liio -lliquid -lfec -lfftw3f` иногда +недостаточно — компоновщик требует повторных проходов по кругу +`ad9361↔iio`. Исправлено в `makefile` через `-Wl,--start-group ... -Wl,--end-group` +вокруг всего списка библиотек (коммит `738fd4a`): линкер пересматривает +группу, пока не разрешит все символы, вместо одного прохода слева направо. +Это именно то, ради чего 04-cross-build существует — баг был невидим на +бумаге и всплыл только на реальной кросс-сборке в CI. + +**Параллелизм: между runner'ами есть, внутри — нет.** `capacity: 1` у +каждого runner'а сериализует job'ы **в пределах одного контейнера**, но +`host` и `armhf` — независимые процессы с раздельной очередью, поэтому +01/02 (на `host`) и 03/04 (на `armhf`) в реальности выполняются +параллельно друг другу; наблюдалось по чередованию task-id в логах push'а. +Внутри `armhf` 03 и 04 всегда строго последовательны (04 ждёт освобождения +runner'а после 03). + +**Клон репозитория — без `actions/checkout`.** Marketplace-экшены +недоступны офлайн. Вместо этого каждый job сам клонирует себя через +токен job'а: +```yaml +- name: Получить код репозитория + env: + REPO_TOKEN: ${{ github.token }} + run: | + rm -rf repo + git clone --quiet "http://x-access-token:${REPO_TOKEN}@localhost:3000/${GITHUB_REPOSITORY}.git" repo + git -C repo checkout --quiet "$GITHUB_SHA" +``` +`--quiet` обязателен: без него git печатает URL с токеном в progress-вывод, +который попадает в открытые логи Actions. + +**Уборка рабочего каталога — `if: always()`.** Оба runner'а — общие +контейнеры для всех job'ов; мусор от упавшего шага иначе копится в ФС +контейнера до пересоздания. + +**Токен регистрации в логах — разовая гигиена.** `GITEA_RUNNER_REGISTRATION_TOKEN` +из `docker-compose.runner-armhf.yml` виден в истории команд/логах настройки. +Он однократный (используется только при первой регистрации — `data/.runner` +уже существует), но по итогам стоит сбросить в Gitea (Site Administration → +Actions → Runners → сброс токена регистрации), чтобы старое значение не +годилось для привязки постороннего runner'а к инстансу. + +## Как гонять локально, без runner'а + +```sh +scripts/ci_guardrails.sh # 02 — только bash/grep, где угодно +make gftest # 03 — нужен любой hostовый cc +make check-env && make all # 04 — нужен arm-linux-gnueabihf-gcc + $XARM + # (например, прямо на RPi-хосте) +``` + +## Что сознательно не автоматизировано + +- `scripts/test_file_link.sh` — e2e-передача файла через радио с md5-сверкой: + нужны две живые Pluto-платы по SSH/RF. +- `scripts/deploy.sh`, `make deploy` — заливка бинарников на платы. +- On-board `ofdm_bench`, PER-свипы, соук-тесты — числа с x86 бессмысленны: + бюджет CPU в CLAUDE.md посчитан для Cortex-A9 @ 667 МГц, а не для сервера. + Правило #6 в CLAUDE.md: при изменении PHY-параметров замерять на плате и + обновлять `docs/BENCHMARK.md` в том же коммите — CI это не проверяет. + +Зелёные 01–04 подтверждают компилируемость, статические инварианты и +group-FEC математику. Они **не** подтверждают, что радиолиния действительно +передаёт данные по эфиру — это по-прежнему ручной чек-лист выше. + +## Файлы + +- `.gitea/workflows/{01-smoke,02-guardrails,03-gftest,04-cross-build}.yml` +- `scripts/ci_guardrails.sh` — логика 02, гоняется и локально +- `runner-image/Dockerfile`, `docker-compose.runner-armhf.yml`, + `entrypoint.sh` cross-builder'а — **вне этого репозитория**, на сервере в + `/opt/gitea-runner-armhf/`. Не версионируются здесь; если понадобится + восстановить cross-builder с нуля, конфигурация существует только на + сервере.