Стенд¶
Реальная станция с SDR, подключённая к живой сети, — единственный способ проверить клиент по-настоящему. Ни ruff, ни pytest, ни чтение кода не ловят класс отказов, ради которого стенд и нужен: граф, умерший до первого отсчёта, выглядит снаружи как досрочно закончившееся наблюдение, без единой ошибки в логе.
tools/stand.sh — набор команд для работы со стендом. Определение агента,
который ими пользуется, — в .claude/agents/stand.md.
Настройка¶
Ничего специфичного для конкретного стенда в репозитории нет. Адрес, каталоги
и учётные данные задаются переменными окружения и ~/.ssh/config:
Переменная |
Смысл |
По умолчанию |
|---|---|---|
|
назначение ssh, alias из |
|
|
каталог тестовой станции на стенде |
|
|
каталог рабочей станции, гасимой на время работы |
пусто — не гасить |
|
тег образа тестовой станции |
пусто — общий с рабочей |
|
версия, которую собранный образ сообщает порталу |
|
|
параллельных заданий сборки |
|
|
готовая база для |
пусто — полная сборка |
|
локальный каталог собранных артефактов |
|
|
предел ожидания конца прохода, секунды |
|
|
добор после прохода на постобработку и выгрузку, секунды |
|
Соединение мультиплексируется через ControlMaster. При парольной
аутентификации мастер-соединение открывается один раз, вручную:
ssh -o ControlMaster=auto -o ControlPath=~/.ssh/cm-stand -o ControlPersist=10m "$STAND_SSH" true
Дальше stand.sh ходит по готовому сокету и пароля не спрашивает.
Тестовой станции нужен свой .env в $STAND_DIR. Две переменные в нём обязаны
отличаться от станционных умолчаний, иначе собирать будет нечего:
OBSERVATION__REMOVE_OBSERVATION_DATA=False # каталог едет в complete/, а не удаляется
OBSERVATION__REMOVE_WATERFALL_RAW_FILES=False # сырой .dat выживает для вердикта
Взаимоисключение станций¶
Если на стенде уже стоит рабочая станция, одновременно с тестовой она работать
не может: container_name — глобальное имя, а не имя в пределах проекта, да и
SDR физически один. deploy гасит рабочую и поднимает тестовую, deploy --restore возвращает как было. Возвращать обязательно: пока поднята тестовая,
рабочая не принимает ничего.
Цикл¶
tools/stand.sh contract # дифф аргументов клиент ↔ диспетчер, ничего не останавливая
tools/stand.sh deploy # выкатить src/, погасив рабочую станцию
tools/stand.sh next-pass 5 # ближайшие проходы из расписания портала
tools/stand.sh watch <id> # дождаться конца прохода и постобработки
tools/stand.sh collect <id> # забрать артефакты в $STAND_OUT/<id>/
tools/stand.sh verdict <id> # проверки по таблице
tools/stand.sh deploy --restore # вернуть рабочую станцию
deploy монтирует src/ поверх /src в контейнере и образ не пересобирает:
правки Dockerfile, scripts/, C++ и всего, что приезжает из соседних
репозиториев, так не доедут. Побочный эффект — src/core перекрывает
сгенерированный в образе _version.py, и станция сообщает порталу версию из
исходников, а не из тега образа.
Приёмка релиза: deploy --no-src¶
Для проверки опубликованного образа перед раскатом на парк монтировать
исходники нельзя — из-за того самого побочного эффекта станция сообщит порталу
заглушку 0.0.0, и разделить наблюдения по версиям будет нечем.
export STAND_IMAGE=sonikspace/soniks-client:2.3.0
tools/stand.sh deploy --no-src
Флаг убирает и rsync src/, и монтирование: контейнер работает ровно тем
кодом, что уедет в парк, и рапортует порталу версию из тега. STAND_IMAGE
обязателен — без него проверять нечего, скрипт откажется.
Так же проверяется HEALTHCHECK образа: репозиторный docker-compose.yml на
стенд не едет, у станции свой, поэтому здоровье считает именно то, что записано
в Dockerfile.
docker inspect --format '{{json .State.Health}}' soniks-client-test
docker exec soniks-client-test wget -qO- http://127.0.0.1:8080/healthz
Пересборка образа¶
Диспетчер флоуграфов, gr-satnogs и скрипты лежат внутри образа, а не в src/.
Правка в них доезжает до стенда только пересборкой:
export STAND_IMAGE=sonikspace/soniks-client:stand-test
tools/stand.sh build # rsync дерева в $STAND_DIR/build/ + docker build
tools/stand.sh deploy # override пиннит STAND_IMAGE
tools/stand.sh contract # ради чего всё и затевалось
STAND_IMAGE здесь не украшение. Оба compose пиннят latest-addons, и обе
станции хоста — тестовая и рабочая — тянут один и тот же тег. Пересборка под
этим именем молча подменила бы образ рабочей станции при deploy --restore.
Отдельный тег разводит их: deploy вписывает image: в переопределение
только когда переменная задана, без неё поведение прежнее.
Сборка идёт из исходников (gr-satnogs, .deb флоуграфов) и на одноплатнике
занимает десятки минут. STAND_BUILD_JOBS по умолчанию 2, а не 4 как в
Dockerfile: умолчание рассчитано на раннер CI, а на стенде памяти около
гигабайта на ядро, и четыре g++ на шаблонах gr-satnogs уводят сборку в OOM.
Версию в образ build передаёт так же, как CI, — аргументом сборки
CLIENT_VERSION. Без него уехала бы заглушка 0.0.0 из Dockerfile, станция
сообщила бы её порталу, и наблюдения стенда стало бы нечем отличить от чужих.
Умолчание — git describe --tags --always плюс суффикс -stand:
2.3.0-stand на самом теге, 2.3.0-1-ge1fe2fa-stand коммитом позже.
Суффикс не косметика. Собранный здесь образ — не тот артефакт, что уедет в парк: одна платформа, свой кэш, в registry не публикуется. Назовись он ровно тегом, его наблюдения смешались бы с наблюдениями настоящего релиза в одной выборке, и сравнение версий ниже показало бы не то, что нужно.
Корневой .env в build не попадает — исключён явно. Образу он и не нужен:
станция получает окружение из .env в $STAND_DIR при up.
Предупреждение
Собранный на стенде образ — один arm64 локально, в registry он не уезжает. Для
парка нужен релизный тег: мультиарх latest-addons публикует только джоба
docker из .gitlab-ci.yml, и только по тегу.
docker-compose.yml на стенд тоже не едет: он там свой, от станции, и правками
репозитория не обновляется. Поэтому ключи, которые прогон обязан проверить,
продублированы в генерируемом docker-compose.override.yml — сейчас это
PATHS__BASE и stop_grace_period. Меняете их в корневом docker-compose.yml —
меняйте и в cmd_deploy, иначе стенд проверит не то, что уедет на станции.
next-pass спрашивает расписание анонимно: JobView на портале не задаёт
permission_classes, токен нужен только чтобы обновился last_seen. Запрос
исполняется со стенда — сеть до портала там заведомо есть.
Дифф контракта¶
contract (и deploy в конце своей работы) печатает сравнение аргументов,
которые построил бы Flowgraph, с опциями, которые понимает
flowgraph_dispatcher. Стоит секунды, а ловит весь класс отказов «диспетчер не
знает аргумент»: parse_args() выходит с кодом 2 до запуска графа, Popen
при этом успешен, и клиент через секунду штатно закрывает наблюдение.
Аргументы со значением None графу не уезжают, но в диффе печатаются тоже —
станция, задавшая такой параметр в своём .env, получит ровно тот же тихий
отказ. Строки с пометкой «латентный» — заряженные мины, а не шум.
contract ничего не останавливает и тестовой станции не требует, поэтому
гонять его можно прямо на работающей станции — и стоит после каждого обновления
образа, где могли разъехаться версии клиента и флоуграфов.
Таблица вердикта¶
Источник — Дорожная карта: надёжность сети, раздел «Стенд». Три строки проверяются не так прямо, как звучат:
Проверка |
Порог |
Чем является на самом деле |
|---|---|---|
код возврата диспетчера |
0 |
косвенная: клиент код возврата нигде не логирует. Проверяется отсутствие |
|
отсутствует |
прямая. |
|
есть, размер > 0 |
прямая |
водопад |
не меньше |
роадмап говорит «строк», код сравнивает отсчёты ( |
|
есть, не |
прямая, читается из наблюдения на портале |
файлы |
хотя бы один |
WARN, не FAIL: «известность передатчика» из клиента не определяется |
|
пусто |
прямая |
Вердикт — данные, а не приговор. FAIL на реальном проходе может означать и
дефект клиента, и низкое прохождение, и молчавший спутник. Прежде чем объявлять
находку, посмотрите snr_db и водопад и проверьте, повторяется ли отказ на
соседнем проходе того же спутника.
Сравнение версий на портале¶
Вердикт судит один проход. Отличить дефект версии от геометрии пролёта одним
проходом нельзя: шестиградусный пролёт не даёт кадров и на исправном клиенте.
Нужна выборка — её собирает tools/portal_stats.py:
python3 tools/portal_stats.py --station 2 \
--start 2026-08-26T00:00:00Z --end 2026-08-27T00:00:00Z
Только stdlib, анонимно, без базы и без браузера. Скрипт идёт по курсорной
пагинации /api/observations/, разбирает client_metadata (строка JSON внутри
JSON, величины отформатированы вместе с единицами: "15.8 dB") и группирует
наблюдения по client_version — по версии на момент приёма, а не по текущей
версии станции. Поэтому старые и новые наблюдения одной станции сравнимы
напрямую, одной антенной и с одного места.
Столбец — — наблюдения, у которых версии нет вовсе. Это не чужой клиент, а
отказы: у них не бывает измеренного SNR. Их доля и есть главная величина
приёмки. Если новая версия её увеличила, раскатывать нельзя, как бы ни выглядели
остальные строки.
Метрики берутся из полей наблюдения, а не из status портала: тот производный
от «пришёл ли хоть один кадр» и искажён дефектом rate_observation
(Дорожная карта: надёжность сети). Сравнивать имеет смысл только сопоставимые
выборки — состав спутников и высоты пролётов между периодами обязаны совпадать,
иначе разница расписания выдаст себя за разницу версий.