# Участие в разработке Проект открытый, под лицензией AGPL-3.0. Основной репозиторий — [GitLab](https://gitlab.com/space-education-development/soniks/client/soniks-client), merge request'ы отправляйте туда. ## Обязательное: подпись коммитов CI проверяет **каждый** коммит на наличие трейлера `Signed-off-by:` и падает, если его нет: ```bash git commit -s -m "Краткое описание изменения" ``` Забыли — поправьте историю до отправки: `git rebase --signoff <база>`. ## Соглашения кода * **Линтер** — `ruff`: ```bash ruff check . ``` Настройки в `pyproject.toml`: строка до 88 символов, двойные кавычки, явный список правил в `select`. Версия ruff **запинена** и продублирована в `.gitlab-ci.yml` (`GITLAB_CI_RUFF_VERSION`) — обновлять в двух местах сразу. Пин и явный `select` не формальность: ruff расширяет набор правил по умолчанию от релиза к релизу, и незакреплённая версия ломала CI без единой правки кода. * **Язык.** Сообщения логов, комментарии и докстринги — **русские**. Логирование только с ленивым форматированием: ```python logger.info("Наблюдение начато: %s", observation_id) # так logger.info(f"Наблюдение начато: {observation_id}") # не так ``` * **Импорты.** `src/` — это два корня, `core` и `soniks_client`. Импорты абсолютные от них; префикс `src.` не пишем никогда — в контейнере эти пакеты лежат прямо в `/src`. * **Ошибки логируем и гасим.** Сломанный проход не должен ронять планировщик. Исключения из `core/exceptions.py` нужны для выбора судьбы файлов, а не для всплытия наверх — см. [Загрузку данных](upload.md). * **Докстринги** — стиль Google (napoleon), на русском. Модульный докстринг обязателен: он попадает в [Справочник API](../api/index.md). * **Shell-скрипты** проверяются `shellcheck --severity=warning`, **Dockerfile** — `hadolint`. Обе проверки есть в CI отдельными джобами. ## Тесты ```bash uv run --python 3.11 --extra dev python -m pytest tests -q ``` `pytest` и `ruff` объявлены в `[project.optional-dependencies].dev` и в виртуальном окружении проекта не стоят — отсюда `--extra dev`. ```{important} Джоба `pytest` в CI ставит зависимости командой `uv sync --locked`, то есть строго по `uv.lock`, а не перерешивает диапазоны из `pyproject.toml`. Отсюда правило: **правка зависимости требует `uv lock` в том же коммите**, иначе джоба падает с «lockfile needs to be updated». Это и есть цель — до пина CI краснел от чужих релизов без единой правки кода (так было со skyfield 1.55, см. `roadmap.md`). Версия самого `uv` запинена в `.gitlab-ci.yml` (`GITLAB_CI_UV_VERSION`): `uv.lock` имеет `revision = 3`, и старый `uv` его не прочитает. Джоба `pages` осознанно осталась на `pip`: её зависимости живут в `docs/requirements.txt`, которого в `uv.lock` нет. ``` Изоляция от вашего `.env` — в `tests/conftest.py`: он подменяет файл настроек через переменную `SONIKS_ENV_FILE`, выставляет обязательные `STATION__*` и записываемые пути, а также подменяет `Hamlib` на `MagicMock` — пакет собирается только внутри образа, а `soniks_client.jobs` тянет его по цепочке импортов. Задавать что-либо в командной строке больше не нужно. Один тест: ```bash uv run --python 3.11 --extra dev python -m pytest \ tests/antenna/tracking/test_strategies.py::TestFlipStrategy -q ``` Покрыты стратегии слежения и угловая математика, выбор пролёта из событий Skyfield, судьба файлов по итогу выгрузки — включая все три пути выгрузки (во время прохода, после него и повтор из `incomplete`), накопление кадров в пачку, разбор KISS и скрипты-декодеры, синхронизация расписания, разбор `.dat` водопада и границы его шкалы, выбор приёмника по частоте, имена файлов наблюдения, постобработка прохода (судьба сырого `.dat` и блок `signal` в метаданных), командная строка скриптов вокруг прохода и потокового графа вместе с их запуском и остановкой, сверка env-имён в `scripts/` с полями `Settings`, таблица соответствия режимов демодуляции, а также отсутствие побочных эффектов у импорта (`tests/test_import_side_effects.py` проверяет это отдельным процессом: импорт не создаёт директорий, `configure_runtime()` создаёт). Всё, что требует живого SDR или Hamlib, по-прежнему проверяется только в контейнере. ## Проверка изменений Без SDR полноценно проверить клиент нельзя. Реальные способы: 1. **`ruff check .`** — то же, что сделает CI. 2. **Тесты стратегий** — если правили угловую математику. 3. **Локальная сборка образа** — `./build.sh` (версии и теги правятся внутри). 4. **`docker compose up` на станции** — единственная настоящая проверка. 5. **`scripts/test-flowgraph.sh`** внутри контейнера — прогон потокового графа без ожидания прохода. ```{warning} Корневой `.env` — это конфигурация реальной станции мейнтейнера с рабочим токеном. Не коммитьте его изменения. Шаблон для правок — [`client/.env`](../station/configuration.md). ``` ## Документация Документация лежит в `docs/`, собирается Sphinx из Markdown (MyST). ```bash python -m pip install . -r docs/requirements.txt cd docs && sphinx-build -b html . _build/html ``` Сам проект ставится вместе с зависимостями сборки не для красоты: без `pydantic`, `numpy`, `matplotlib`, `apscheduler` и `skyfield` autodoc не может импортировать ни один модуль, и раздел `api/` собирается пустым — при этом сборка завершается успешно, только с предупреждениями. ```{important} Собирать **только из `docs/`**. Из корня репозитория pydantic подхватит локальный `.env`, и сборка упадёт на его пустых значениях. `conf.py` подставляет безопасное окружение сам, но `.env` в текущей директории имеет приоритет. ``` Что стоит знать при правке: * Исходники — Markdown. Это сознательный выбор: те же файлы служат контекстом для ИИ-ассистентов, работающих с репозиторием. * Раздел [Справочник API](../api/index.md) генерируется `autodoc` из докстрингов. Отдельно его редактировать не нужно — пишите докстринги. * `Hamlib` и `pymcp2221` при сборке документации мокаются (`autodoc_mock_imports` в `conf.py`) — их нет вне контейнера. * Добавили страницу — впишите её в `toctree` в `docs/index.md`, иначе Sphinx предупредит о документе вне оглавления. ## CI `.gitlab-ci.yml`, стадии `static`, `test`, `docs`, `docker`, `release`, `security`: | Задание | Когда | Что | |---|---|---| | `sign_off` | всегда | Проверка трейлера `Signed-off-by:` | | `lint_python` | всегда | `ruff check .` | | `lint_shell` | всегда | `shellcheck --severity=warning` по `scripts/` и `build.sh` | | `docker_lint` | всегда | `hadolint Dockerfile` | | `pytest` | всегда | `uv sync --locked --extra dev` + `pytest tests -q` | | `pages` | основная ветка, стадия `docs` | Сборка Sphinx, публикация документации | | `docker` | **только тег** | Сборка образа под arm64/amd64 с публикацией. Каналом поставки не является — см. предупреждение ниже | | `docker_build_check` | расписание — сам, ветки и MR — вручную | Сборка образа на одной платформе без публикации: проверка, что `Dockerfile` вообще собирается | | `release_notes`, `release` | тег | Формирование релиза | | `container_scanning` | тег | Сканирование опубликованного образа | | SAST, Secret Detection, Dependency Scanning | тег или основная ветка | Проверки безопасности | ```{warning} Джоба `docker` **не является каналом поставки** и ни разу им не была. Образы парка собираются вручную — см. «Выкатка образа» ниже. Сейчас джоба к тому же не доходит до сборки: на раннере проекта `runc` не может прикрыть `/proc/acpi` монтированием tmpfs и падает на старте контейнера сборки. Тег `2.3.0` есть, образа под ним в registry нет. Это незаконченная работа, а не сломавшийся механизм. ``` Мультиарх под QEMU идёт часами, поэтому джоба и привязана к тегу. `linux/arm/v7` снят — 32-битных станций нет. Просто **проверить, что образ собирается**, можно и без тега: `docker_build_check` собирает одну платформу без публикации. По расписанию она идёт сама, на ветках и MR — кнопкой и не блокируя. Джоба нужна потому, что имя `.deb` флоуграфов и разрешимость пиннутых тегов не видит ни один тест и ни один линтер: именно так переименование `.deb` два дня роняло сборку при зелёных пайплайнах. ## Версия клиента Версию задаёт **тег GitLab**, и больше нигде она не объявляется. CI подставляет `$CI_COMMIT_TAG` в `ARG CLIENT_VERSION`, тот перезаписывает `src/core/_version.py` в образе, и порталу уходит именно это значение. В репозитории `pyproject.toml`, `ARG CLIENT_VERSION` и `_version.py` содержат заглушку `0.0.0` — руками перед релизом править нечего, достаточно поставить тег. `tests/test_version_consistency.py` сторожит, чтобы настоящее число туда не вернулось. Рядом в `_version.py` образ получает коммиты gr-satnogs и флоуграфов: оба клонируются по тегу, а хеш пишется в файл при сборке и уезжает порталу в `client_metadata.radio`. Добавили в `_version.py` новое имя — добавьте его и в шаг `Dockerfile`, иначе в образе его не окажется; тот же тест это проверяет. Все джобы выполняются на раннере проекта: он адресуется тегом из переменной `GITLAB_CI_RUNNER_TAG` в блоке `default`. Шаренные раннеры исчерпали квоту compute-минут namespace и пайплайн на них не стартует. Там же задан `interruptible: true` — вытесненный новым пушем пайплайн MR прерывается; джобы по тегу помечены `interruptible: false` поимённо. ## Выкатка образа Образы парка собираются вручную и публикуются `docker buildx build --push`. Инструмент — `build.sh`, и он же единственный канал поставки. Образ расслоён на два: `soniks-base` — GNU Radio, gr-soniks, флоуграфы, gr-satellites, apt- и pip-зависимости, гигабайты, меняется редко; поверх него клиентский слой — код, satyaml и скрипты, десятки мегабайт, меняется часто. Стадии `base` и `client` живут в одном `Dockerfile`; какую базу брать, задаёт `ARG BASE_IMAGE`. ```bash ./build.sh # разработка: клиент поверх базы из BASE_IMAGE ./build.sh --base # пересобрать и базу (первый раз — обязательно) ./build.sh 2.3.0 # версия уезжает в образ и в client_metadata ./build.sh 2.3.0 --push # и публикуется тегами :2.3.0 и :latest-addons ./build.sh 2.3.0 --push --no-latest # только версионным тегом PLATFORMS=linux/arm64,linux/amd64 ./build.sh 2.3.0 --push # обе архитектуры ``` **Платформы.** Парк — в основном RPi (`linux/arm64`), но есть станции на ноутбуках с Ubuntu и Windows (`linux/amd64`, на Windows — Docker Desktop). Выкатка идёт в два этапа: сначала arm64 (умолчание `PLATFORMS`), затем обе архитектуры одним manifest list. Три условия, без которых мультиплатформенная сборка не пойдёт: * чужая для машины сборки архитектура собирается под QEMU — один раз `docker run --privileged --rm tonistiigi/binfmt --install arm64,amd64`. База под QEMU собирается часами, но пересобирается редко; клиентский слой лёгкий; * нужен builder с драйвером `docker-container` (`docker buildx create --use`): драйвер `docker` по умолчанию собирает только одну платформу; * несколько платформ — только с `--push`: manifest list в локальный docker не загружается, и `build.sh` откажет. По той же причине при `--base --push` база публикуется первой — клиентская сборка берёт её из registry. Что стоит знать до первой выкатки: * **версия — позиционный аргумент, и без неё публиковать нельзя.** Скрипт откажет: образ без версии рапортует порталу `0.0.0`, наблюдения перестают делиться по версиям клиента (`tools/portal_stats.py`), а откатывать парк становится не на что. Для разработки версия не нужна — сборка идёт с предупреждением; * **публикуются два тега сразу.** `latest-addons` тянут оба `docker-compose.yml` — им и обновляется парк; версионный тег существует ровно затем, чтобы было куда откатиться. `--no-latest` публикует только версионный: образ ложится в registry, парк его не замечает, а станцию на него переключают руками (`STAND_IMAGE`, `image:` в compose). Это форма выкатки для приёмки — сначала стенд, потом парк; * **база пиннится неизменяемым тегом** в переменной `BASE_IMAGE` внутри `build.sh`. Плавающий тег здесь означал бы, что две сборки дают разный образ, а какой именно — не записано нигде; ровно поэтому же пиннуты `GRSATNOGS_BRANCH` и `FLOWGRAPHS_BRANCH`; * **новая зависимость в `pyproject.toml` — это изменение базы.** Ставится она на базовой стадии, поэтому такой коммит требует `--base`, иначе в образе её не окажется; * `--push` публикует базу только если её пересобирали в этом же запуске. * **после `--push` скрипт печатает digest** в виде `sonik.space/sonikspace/soniks-client@sha256:…`. Это адрес для канала релизов на портале (`ReleaseChannel`, админка): staff ставит его на `canary`, и станции с агентом в режиме `managed` применяют образ сами, с health-gate и откатом; тот же digest на `stable` — когда канарейки отработали (решение 38 в [](../roadmap-network.md)). Канал — это digest, не тег: `latest-addons` обновляет парк без точки отката, агент тегов не принимает. Устройство агента — [](../station/agent.md). Сборка идёт нативно на машине нужной архитектуры: в registry лежат однопланформенные образы `arm64` — парк на Raspberry Pi 4. Собранный на x86 образ станции не подойдёт. ## Публикация документации Задание `pages` собирает HTML в артефакт `public/`, который отдаётся GitLab Pages. На `sonik.space/docs/client` он попадает проксированием на стороне портала: ```nginx location /docs/client/ { proxy_pass https://soniks-client-new-ea69a1.gitlab.io/; proxy_set_header Host soniks-client-new-ea69a1.gitlab.io; } ``` Ссылки внутри Sphinx относительные, поэтому раздача из подпути работает без дополнительной настройки.