# Журнал сессии — 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..svc.cluster.local` — **NXDOMAIN**. Service в namespace платформы называется `containerk8s`: `containerk8s..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.` (было `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.`: `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=`): `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 и дедуп повторов — работают на развёрнутом стенде.