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

289 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Журнал сессии — 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`). Применение — за владельцем.