Files
IoT/HISTORY/2026-10-11-session-log.md

22 KiB
Raw Permalink Blame History

Журнал сессии — 2026-10-11 (IoT: внешнее код-ревью и фиксы)

Запись по требованию «документировать всё, включая собственные ошибки» (приказ 2026-09-24). Предыдущие: HISTORY/2026-10-07-session-log.md, HISTORY/2026-10-10-session-log.md. Основание: внешнее ревью Claude Sonnet по промпту ../CLAUDE-REVIEW-PROMPT.md (40 пунктов на два сервиса; SQS-часть — в SQS-service/HISTORY/2026-10-11-session-log.md).

1. Задача

Разобрать находки ревью по монолиту iot-service и исправить их.

2. Что исправлено

🔴 Критичные

  • 🔴8/🔴9 — poison-строка в flushAll (internal/storage/iotpg/iot_telemetry_store.go:166,177). При постоянной (не retryable) ошибке flushAll возвращал ошибку → consumer (consumer.go:159) не удалял батч из SQS → бесконечный redelivery всего батча. Теперь ошибку ставит только retryable-ветка; постоянная ошибка означает «валидные строки уже вставлены построчно, битые отброшены» → Flush возвращает nil, батч подтверждается. Коммит 1fe543f.

🟡 Важные

Пункт Файл Суть Коммит
🟡13 bridge/sender.go Ретраил бесконечно любые ошибки SQS; permanent (4xx/размер/нет очереди) занимали воркеры → классификация isPermanentSQSErr (smithy APIError) 657850d
🟡14 bridge/handler.go MessageID = uuid на каждый колбэк → повторная QoS1-доставка не дедуплицировалась. Теперь SHA256(ns|device|topic|payload) 657850d
🟡15 bridge/bridge.go После 3 неудач подписки ждали следующего реконнекта (которого не будет при живом соединении) → бесконечный ретрай подписки 657850d
(new) bridge/handler.go Defense-in-depth: валидация формата ns/deviceID из топика (барьер — EMQX authz) 657850d
🟡17 consumer/consumer.go receiveCount проверялся только для битого envelope → применяем maxReceiveCount ко всем ошибкам 5114320
🟡18 cmd/iot-service/main.go run() выходил до завершения server.Shutdown → deferred Close срабатывали до дренажа in-flight. Добавлен shutdownDone 5114320
🟡19 api/handler/devices.go Ошибка GetByName после успешного INSERT давала 500 (пароль терялся, повтор → 409) → отдаём 201 с in-memory объектом 5114320
🟡11 iotpg/iot_telemetry_store.go getTenantDB/QueryTelemetry не проверяли safeNamespaceRe (a_b ↔ a-b → одна БД) → валидация на всех путях f41a9fe

Осознанно НЕ менялось — РЕШЕНИЯ (2026-10-11)

  • 🟡16 (QoS1 ack): решение — оставить async-ack + метрика дропов. Синхронный ack (подтверждать только после отправки в SQS) блокировал бы MQTT-колбэк при недоступности SQS — это уже чинили ранее (потеря ВСЕХ сообщений). Throughput важнее; дроп при переполнении фиксируется счётчиком dropped.
  • 🟡12 (AUTH_TEST_MODE): решение — оставить как есть. Уже fail-closed: при отсутствии JWT_HMAC_SECRET и AUTH_TEST_MODE=false middleware отказывает в обслуживании (500), а test-mode срабатывает только без секрета.

3. Прочее (перед ревью, в этой же сессии)

  • 193509c — консоль: namespace = sub (убрали sless-{SHA256}, расходившийся с middleware) и удалили несуществующий POST /v1/namespaces/{ns}/ensure.

4. Верификация

  • go build ./..., go vet ./..., go test -race ./... — зелёные.
  • Push: 54fe1f5..f41a9fe в origin/master.

5. Деплой на Nubes (Terraform) — образ EMQX, 2026-10-11

terraform apply упал на nubes_http.iot_emqx через ~70 с (стадия validation, 22.0 с): платформа требует РОВНО ОДИН EXPOSE в образе, иначе «Не указан applicationUsePort…». У emqx/emqx:5.5.1 — 7 EXPOSE (18083/1883/4370/5369/8083/8084/8883). Параметра applicationUsePort у провайдера nubes 3.1.0 нет (проверено: generated/prod/resources_yaml/98_http.yaml

  • generated/prod/docs-rendered/http.md + исходники провайдера — совпадений нет), поэтому лечится только пересборкой образа.

Что сделано (образ nail/iot-emqx:v0.2.5):

  1. emqx/Dockerfile — multi-stage: FROM emqx/emqx:5.5.1 AS base → FROM scratch → COPY --from=base / /. EXPOSE/ENV/VOLUME наследуемого образа НЕ переносятся в scratch, поэтому остаётся единственный EXPOSE 8083. ENV LANG/LC_ALL/PATH объявлены заново. USER emqx + WORKDIR /opt/emqx
    • штатные ENTRYPOINT/CMD базы.
    • ⛔ Попытка FROM debian:bookworm-slim + COPY --from=base / / НЕ работает: cannot copy to non-directory: /bin — в bookworm /bin симлинк (usrmerge), в emqx-образе (Debian 11) /bin — реальный каталог.
  2. emqx/emqx.conf — удалён salt_rounds = 10 из password_hash_algorithm: схема 5.5.1 его НЕ принимает (unknown_fields, unknown => "salt_rounds" → call_hocon_failed, контейнер не стартует). Роль поля нулевая: EMQX только сверяет bcrypt-хеш из БД, cost берётся из самого хеша.

Верификация образа (локально):

  • docker inspect … --format '{{json .Config.ExposedPorts}}' → {"8083/tcp":{}};
  • запуск контейнера → Listener tcp:default on 0.0.0.0:1883 started, Listener ws:default on 0.0.0.0:8083 started, EMQX 5.5.1 is running now!, статус running. Ошибки econnrefused к 127.0.0.1:5432 в тесте ожидаемы (PG рядом нет).
  • Реестр Gitea: теги latest, v0.2.4, v0.2.5.
  • terraform.tfvars / terraform.tfvars.example: iot_emqx_version = "v0.2.5".

⚠️ Блокер деплоя: инстанс iot-emqx (3bdaef8e-e880-4419-870a-bd0158826751) остался в состоянии not created после неудачного apply и держит имя. По документации платформы такие инстансы через API не удаляются — удалять вручную в ЛК (deck). terraform state list пуст (в state ничего не записалось).

6. Второй terraform apply — падение iot-service и РАЗБОР через кластер

Инстанс-блокер был удалён в ЛК владельцем (3bdaef8e… → 404). Повторный apply: EMQX создался и работает, nubes_http.iot_service упал через ~10 мин: Приложение не запустилось. Производится полный откат установки. Ошибка: jlib.k8s [correctReplicaActive] | Под(ы) не работают: 'containerk8s' (Deployment), затем таймаут клиента 600 с. Namespace iot-service откатан и удалён.

Разбор (kubeconfig ~/.kube/config → кластер iot-naeel, контекст tazetdinovn@gmail.com@iot-naeel):

  1. Причина падения — PG, а не образ. Эфемерный диагностический под (kubectl run iot-diag-bad, env — как в tfvars, удалён после проверки):
    level=ERROR msg="devices store" err="store: init schema: pq: must be owner of table iot_devices (42501)"
    exit=1  reason=Error
    
    Приложение падает на CREATE UNIQUE INDEX IF NOT EXISTS (internal/service/store/open.go:64
    • SQL на :29): таблица уже существует и принадлежит ДРУГОЙ роли.
  2. Владелец. В БД iotdb инстанса 509145c3… таблицы iot_devices, iot_devices_pkey, idx_iot_devices_ns_device, tenant_credentials принадлежали роли iot_service (создана 2026-08-16, пароль утерян — нет ни в vault платформы, ни в IoT/pg.key). Роль iotservice из tfvars — не владелец и без прав: has_table_privilege('iotservice','iot_devices','SELECT')=false. Данные: iot_devices 26 строк (в т.ч. __bridge/iot-bridge), tenant_credentials 31.
  3. Вторая ошибка (сработала бы следующей). MQTT_HOST из local.emqx_internal_host = iot-emqx.<uid>.svc.cluster.local — NXDOMAIN. Service в namespace платформы называется containerk8s: containerk8s.<uid>.svc.cluster.local → 10.98.42.138 ✅ (проверено getent hosts изнутри пода EMQX; тот же формат — HISTORY/2026-08-16-session-log.md:824).

Исправление (2026-10-11)

  • PG (через psql в поде postgresqlk8s-0, роль postgres): BEGIN; ALTER TABLE public.iot_devices OWNER TO iotservice; ALTER TABLE public.tenant_credentials OWNER TO iotservice; ALTER INDEX public.iot_devices_pkey OWNER TO iotservice; ALTER INDEX public.idx_iot_devices_ns_device OWNER TO iotservice; COMMIT; Пароли не менялись, БД не создавались, env EMQX не менялся.
  • deploy/terraform/resources.tf — emqx_internal_host → containerk8s.<instanceUid> (было resource_name).

Перепроверка после правки (обязательна)

  • Владельцы в PG: все 4 объекта → iotservice ✅
  • DDL приложения под iotservice по TCP (sslmode=require), ровно тот же SQL: CREATE TABLE … / CREATE UNIQUE INDEX IF NOT EXISTS … → exit=0, без 42501 ✅
  • Данные целы: iot_devices 26 строк, tenant_credentials 31 ✅
  • Диагностический под iot-diag-ok с MQTT_HOST=containerk8s.<uid>: devices store ready, bridge device ensured, telemetry store ready, bridge: MQTT connected, bridge: subscribed filter=+/telemetry/+; /health=200, /ready=200 {"status":"ready"} ✅
  • terraform validate Success, terraform plan = 1 to add, 0 to change, 0 to destroy (EMQX не меняется — его env правки не требует); terraform console local.emqx_internal_host = containerk8s.0d09ee82-c8f9-458f-88a3-89571e5676ed.svc.cluster.local ✅
  • Диагностические поды удалены, в ns EMQX остались только containerk8s и vmagent.

Правка дефолтов конфига (2026-10-11, после разбора)

  • internal/service/config/defaults.go + Dockerfile: дефолт SQS_ENDPOINT был http://containerk8s.f1ffb134-…:4100 (устаревший namespace — shared-sqs давно на ВМ 213) → заменён на https://sqs.sinefo.ru.
  • DefaultMQTTHost был "emqx" (не резолвится: EMQX в другом namespace) → пусто, MQTT_HOST стал обязательным: config.Load добавляет его в список missing (MQTT_HOST (или MQTT_BROKER_URL)), а не строит кривой ws://:8083/mqtt.
  • В нашем деплое оба значения всё равно задаются Terraform'ом (resources.tf) — правка убирает ловушку для деплоя без tfvars. go build/vet/test зелёные.

7. Деплой IoT на Nubes — ✅ ГОТОВО (2026-10-11, третий apply)

terraform apply прошёл успешно. Итог:

  • EMQX (nubes_http.iot_emqx): инстанс 0d09ee82-c8f9-458f-88a3-89571e5676ed, домен https://exqx-iot.containerk8s.dev.nubes.ru, под containerk8s 1/1 Running.
  • iot-service (nubes_http.iot_service): инстанс 6d78ebc3-bb90-4af9-8d2f-61186e04ee19, домен https://iot-api.containerk8s.dev.nubes.ru, под containerk8s 1/1 Running, 0 рестартов.

Проверено после apply:

  • логи старта: devices store ready → bridge device ensured → telemetry store ready → iot-service listening :9090 → bridge: MQTT connected → bridge: subscribed filter=+/telemetry/+ → consumer/bridge: SQS queue resolved queue=iot-telemetry dlq=iot-telemetry-dlq;
  • публично: /health=200, /ready=200; в логе GET /=200, GET /static/favicon.svg=200 (консоль).
  • terraform state list = nubes_http.iot_emqx + nubes_http.iot_service; outputs: emqx_url, iot_service_url, iot_mqtt_broker_url.

Осталось (не блокеры): end-to-end телеметрия (устройство → MQTT → SQS → per-tenant PG), домен EMQX wss://exqx-iot/mqtt для внешних устройств, Terraform на PROD.

8. E2E-тест телеметрии — ✅ ПРОЙДЕН (2026-10-11)

Проверен полный путь снаружи кластера: HTTP API → MQTT (wss) → EMQX → bridge → SQS → consumer → per-tenant PostgreSQL → HTTP API.

  1. Устройство через API (/tmp/e2e_api.py, PyJWT HS256 sub=<ns>): POST /v1/namespaces/e2e-nubes/iot/devices {"name":"dev1","device_id":"d-01"} → 201, выданы mqtt_username=e2e-nubes_d-01, mqtt_password=347425f2…753e, telemetry_topic=e2e-nubes/telemetry/d-01. ⚠ GET …/devices/dev1 пароль НЕ возвращает (как и задокументировано ранее).
  2. Публикация через внешний MQTT wss://exqx-iot.containerk8s.dev.nubes.ru/mqtt (/tmp/e2e_mqtt.py; MQTT-over-WS написан вручную на stdlib — локально нет paho/websockets): HTTP/1.1 101 Switching Protocols → CONNACK rc=0 (auth прошёл: bcrypt-хеш сверен EMQX напрямую с iot_devices) → PUBLISH e2e-nubes/telemetry/d-01 {"temp":21.5,…}. ACL: разрешён только свой топик — публикация в него не отклонена.
  3. Доставка: лог iot-service — iotpg: created tenant DB namespace=e2e-nubes db=tenant_e2e_nubes; в iotdb появилась строка e2e-nubes/dev1 (d-01).
  4. Чтение: GET /v1/namespaces/e2e-nubes/iot/telemetry?device_id=d-01 → 200 {"count":1,"items":[{"id":1,"device_id":"d-01","payload":{"ts":1791737028, "src":"e2e-manual","temp":21.5}}]}; в PG tenant_e2e_nubes.iot_telemetry — 1 строка, payload совпадает побайтово, message_id=30cf2047a060… (SHA256-дедуп).

Следствия: внешний MQTT-домен EMQX годится для устройств; SQS-креды, PG-DSN, ACL и дедуп работают. Тестовые артефакты: устройство e2e-nubes/dev1, БД tenant_e2e_nubes.

Осталось (не блокеры): Terraform на PROD; чистка тестовых артефактов e2e (по команде).

9. Проверки безопасности/корректности развёрнутого IoT — PASS=12 FAIL=0

Скрипт /tmp/e2e_neg.py (тест-стенд, namespace e2e-nubes, устройство dev1):

# Проверка Результат
1 MQTT auth: верные креды CONNACK rc=0 ✅
2 MQTT auth: неверный пароль rc=4 (bad user/password) ✅
3 MQTT auth: неизвестный пользователь rc=5 (not authorized) ✅
4 API без токена 401 ✅
5 API: sub ≠ namespace в пути 403 ✅
6 API: подделанная подпись JWT 401 ✅
7 MQTT ACL: публикация в чужой топик other-ns/telemetry/x соединение разорвано (deny_action=disconnect) ✅
8 Дедуп: два одинаковых payload iot_telemetry 1→2 (+1, не +2), message_id SHA256 ✅
9 PATCH enabled=false 200 ✅
10 MQTT auth выключенного устройства rc=5 ✅
11 PATCH enabled=true 200 ✅
12 MQTT auth после включения rc=0 ✅

Вывод: auth (bcrypt из PG), ACL (own-topic only, disconnect на нарушение), tenant-изоляция API (namespace == sub), проверка подписи JWT и дедуп повторов — работают на развёрнутом стенде.

10. Расширенный прогон — PASS=49 FAIL=0 KNOWN=1 (2026-10-11)

Скрипт /tmp/e2e_suite.py (~50 проверок на развёрнутом стенде).

Что покрыто: CRUD устройств (201/409/400/404/204) · лимиты идентификаторов (name/device_id 64 → 201, 65 → 400; namespace 56 → 201, 57 → 400) · пагинация (limit/offset, потолок 1000) · JWT (без токена 401, sub≠namespace 403, чужой ключ 401, истёкший 401, не-HS256 401) · MQTT (SUBACK на свой топик, round-trip, QoS1 → PUBACK, PINGRESP) · ACL-отказы с разрывом соединения (чужое устройство, wildcard, чужой namespace, 11 уровней топика при max_topic_levels=10, retained при retain_available=false, subscribe на +/telemetry/+ и чужой ns) · payload (не-JSON → строка, 200 КБ → доставлен, 20 сообщений подряд → 20/20) · служебное (/health, /healthz, /ready, /console, /iot-admin, / = 200; POST /health = 405; /iot-admin/stats без токена 401).

Найдено/подтверждено (требует решения):

  1. 🟡 Дедуп — по СОДЕРЖИМОМУ. message_id = SHA256(ns|device_id|topic|payload) (bridge/handler.go:73), поэтому два одинаковых payload подряд дают +1 строку (проверено: 49→50). Для реальных устройств это значит: если payload не содержит timestamp/счётчика, легитимные повторные одинаковые показания будут отброшены. Надо предупредить в документации для устройств (или включать ts в payload).
  2. 🟡 Валидация namespace только в POST. validateIdentifier(ns, "namespace", 56) вызывается только в CreateIoTDevice (devices.go:168); GET/DELETE/PATCH принимают любой ns → 200 (проверено: GET /v1/namespaces/bad_ns/iot/devices → 200). Хранилище такой ns отсекает (safeNamespaceRe), поэтому чтение телеметрии отдаёт 500 вместо 400. Утечки нет (изоляция по sub), но реакция несогласованная — вынести проверку в общий middleware/хелпер.
  3. ⚪ Dockerfile (комментарий) обещает /metrics на порту API — маршрута нет (404).
  4. ⚪ Чтение телеметрии ?limit=200 возвращает payload'ы целиком (в тесте — 200 КБ на запись); при массовом опросе API ощутимо тормозит (наблюдалось на 200m CPU).

Методическая заметка: при повторных прогонах тесты «доставки» надо делать с уникальным payload, иначе срабатывает дедуп и тест ложно падает.

11. Правки по итогам §10 (2026-10-11)

  1. Валидация namespace во всех эндпоинтах. Добавлены хелперы namespaceFromPath / requireNamespace (handler.go); ListIoTDevices, GetIoTDevice, DeleteIoTDevice, UpdateIoTDevice, ListIoTTelemetry теперь отвечают 400 на невалидный {namespace} (раньше отдавали 200, а телеметрия падала в хранилище с 500). CreateIoTDevice оставлен с прежним порядком (JSON → валидация), текст ошибки не изменился. Регрессионные тесты: internal/service/api/handler/namespace_test.go (8 невалидных ns × 5 обработчиков; пустой ns → default). go build/vet/test -race ./... — зелёные.
  2. Документ для интеграторов: doc/device-integration.md — регистрация устройства, MQTT (wss, username/topic), ACL-ограничения, ⚠️ требование ts/seq в payload (дедуп по содержимому), лимиты (1 МБ пакет, 250 КБ envelope), QoS1, чтение телеметрии, реальная задержка «MQTT → API» до ~20 с (long-poll консьюмера).

Собрано и опубликовано: образ iot-service:v0.1.14 (latest, v0.1.13, v0.1.14 в Gitea). Проверено живым подом iot-verify (удалён): /health → 200 v0.1.14; bad_ns для списка/телеметрии/карточки → 400 {"error":"namespace must match [a-zA-Z0-9][a-zA-Z0-9-]* …"}; валидный e2e-nubes → 200. terraform plan = 0 to add, 1 to change (registry_path v0.1.13 → v0.1.14). Применение — за владельцем.