289 lines
22 KiB
Markdown
289 lines
22 KiB
Markdown
# Журнал сессии — 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`). Применение — за владельцем.
|