Участие в разработке

Проект открытый, под лицензией AGPL-3.0. Основной репозиторий — GitLab, merge request’ы отправляйте туда.

Обязательное: подпись коммитов

CI проверяет каждый коммит на наличие трейлера Signed-off-by: и падает, если его нет:

git commit -s -m "Краткое описание изменения"

Забыли — поправьте историю до отправки: git rebase --signoff <база>.

Соглашения кода

  • Линтерruff:

    ruff check .
    

    Настройки в pyproject.toml: строка до 88 символов, двойные кавычки, явный список правил в select. Версия ruff запинена и продублирована в .gitlab-ci.yml (GITLAB_CI_RUFF_VERSION) — обновлять в двух местах сразу. Пин и явный select не формальность: ruff расширяет набор правил по умолчанию от релиза к релизу, и незакреплённая версия ломала CI без единой правки кода.

  • Язык. Сообщения логов, комментарии и докстринги — русские. Логирование только с ленивым форматированием:

    logger.info("Наблюдение начато: %s", observation_id)   # так
    logger.info(f"Наблюдение начато: {observation_id}")    # не так
    
  • Импорты. src/ — это два корня, core и soniks_client. Импорты абсолютные от них; префикс src. не пишем никогда — в контейнере эти пакеты лежат прямо в /src.

  • Ошибки логируем и гасим. Сломанный проход не должен ронять планировщик. Исключения из core/exceptions.py нужны для выбора судьбы файлов, а не для всплытия наверх — см. Загрузку данных.

  • Докстринги — стиль Google (napoleon), на русском. Модульный докстринг обязателен: он попадает в Справочник API.

  • Shell-скрипты проверяются shellcheck --severity=warning, Dockerfilehadolint. Обе проверки есть в CI отдельными джобами.

Тесты

uv run --python 3.11 --extra dev python -m pytest tests -q

pytest и ruff объявлены в [project.optional-dependencies].dev и в виртуальном окружении проекта не стоят — отсюда --extra dev.

Важно

Джоба 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 тянет его по цепочке импортов. Задавать что-либо в командной строке больше не нужно.

Один тест:

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 внутри контейнера — прогон потокового графа без ожидания прохода.

Предупреждение

Корневой .env — это конфигурация реальной станции мейнтейнера с рабочим токеном. Не коммитьте его изменения. Шаблон для правок — client/.env.

Документация

Документация лежит в docs/, собирается Sphinx из Markdown (MyST).

python -m pip install . -r docs/requirements.txt
cd docs && sphinx-build -b html . _build/html

Сам проект ставится вместе с зависимостями сборки не для красоты: без pydantic, numpy, matplotlib, apscheduler и skyfield autodoc не может импортировать ни один модуль, и раздел api/ собирается пустым — при этом сборка завершается успешно, только с предупреждениями.

Важно

Собирать только из docs/. Из корня репозитория pydantic подхватит локальный .env, и сборка упадёт на его пустых значениях. conf.py подставляет безопасное окружение сам, но .env в текущей директории имеет приоритет.

Что стоит знать при правке:

  • Исходники — Markdown. Это сознательный выбор: те же файлы служат контекстом для ИИ-ассистентов, работающих с репозиторием.

  • Раздел Справочник API генерируется 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

тег или основная ветка

Проверки безопасности

Предупреждение

Джоба 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.

./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 в Дорожная карта: надёжность сети). Канал — это digest, не тег: latest-addons обновляет парк без точки отката, агент тегов не принимает. Устройство агента — Агент обновлений.

Сборка идёт нативно на машине нужной архитектуры: в registry лежат однопланформенные образы arm64 — парк на Raspberry Pi 4. Собранный на x86 образ станции не подойдёт.

Публикация документации

Задание pages собирает HTML в артефакт public/, который отдаётся GitLab Pages. На sonik.space/docs/client он попадает проксированием на стороне портала:

location /docs/client/ {
    proxy_pass https://soniks-client-new-ea69a1.gitlab.io/;
    proxy_set_header Host soniks-client-new-ea69a1.gitlab.io;
}

Ссылки внутри Sphinx относительные, поэтому раздача из подпути работает без дополнительной настройки.