4 Commits
125 changed files with 4748 additions and 12667 deletions
+173 -8
View File
@@ -1,10 +1,175 @@
# Правила # Правила работы агента в проекте IoT
> Подробные правила: [`.github/pravila.md`](pravila.md) ## ГЛАВНОЕ ПРАВИЛО
1. Не трогать рабочий код без явного указания. **НЕ "СОВЕРШЕНСТВОВАТЬ" РАБОЧИЙ КОД БЕЗ ЯВНОГО УКАЗАНИЯ.**
2. Файлы редактировать локально — `~/remote_dev/` = `~/terra/` на ВМ (sshfs), SCP не нужен.
3. Все команды — **только через SSH**, никогда локально: ---
```bash
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 'КОМАНДА' ## ЗАПРЕТ НА ВЫДУМКИ
```
**КАТЕГОРИЧЕСКИ ЗАПРЕЩАЕТСЯ придумывать, догадываться или предполагать:**
- значения параметров, которые не видны в коде или документации
- допустимые значения enum/ролей/типов — если не взяты из реального источника
- поведение API, провайдеров, библиотек — если не подтверждено кодом или документацией
- любые факты о системе, которые агент "знает" из общих соображений
**Если информации нет — спросить у пользователя. Не угадывать.**
Если код работает — не трогать. Никаких:
- рефакторингов "попутно"
- улучшений стиля
- добавления комментариев / docstring
- переименований переменных
- "пока уж заодно поправлю"
Делай только то, о чём явно попросили. Ничего лишнего.
---
## ⚠️ ВЫПОЛНЕНИЕ КОМАНД — ТОЛЬКО НА УДАЛЁННОЙ МАШИНЕ
- Все команды (git, go, docker, kubectl, make и т.д.) выполнять **ТОЛЬКО на удалённой машине** через SSH.
- **На локальной машине команды не запускать вообще.**
- Если для задачи необходимо выполнить что-то локально — **спросить явное разрешение у пользователя** перед запуском.
### Параметры удалённой машины
| Параметр | Значение |
|---|---|
| Хост | `5.172.178.213` |
| Пользователь | `naeel` |
| SSH-ключ | `~/.ssh/id_ed25519` |
| Рабочий каталог | `/home/naeel/terra/IoT` |
### Шаблон команды
```bash
ssh -i ~/.ssh/id_ed25519 \
-o StrictHostKeyChecking=no \
-o ConnectTimeout=10 \
naeel@5.172.178.213 \
'cd /home/naeel/terra/IoT && <КОМАНДА>'
```
### Примеры
```bash
# git статус
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 \
'cd /home/naeel/terra/IoT && git status'
# commit + push
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 \
'cd /home/naeel/terra/IoT && git add -A && git commit -m "..." && git push'
# сборка go
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 \
'cd /home/naeel/terra/IoT && go build ./...'
```
### Git / Gitea
- Remote: `https://gitea.services.ngcloud.ru/Nail/IoT.git` (HTTPS)
- SSH до Gitea **недоступен** с удалённой машины — использовать только HTTPS.
- Credentials сохранены на удалённой машине в `~/.git-credentials`.
---
## О проекте
IoT managed service — отдельная репа, вынесенная из sless.
Go модуль: `gitea.services.ngcloud.ru/Nail/IoT`
Репа: https://gitea.services.ngcloud.ru/Nail/IoT
### Компоненты (3 бинарника из одного образа):
1. **iot-operator** (`cmd/iot-operator/`) — controller-manager (IoTDevice CRD) + REST API на :9090
2. **mqtt-bridge** (`cmd/mqtt-bridge/`) — MQTT (EMQX) → Kafka bridge
3. **kafka-consumer** (`cmd/kafka-consumer/`) — Kafka → Postgres pipeline
### Стек:
- Go 1.25, controller-runtime v0.14, gorilla/mux
- CRD: `iot.kube5s.ru/v1alpha1` (IoTDevice)
- EMQX — MQTT брокер, Kafka — очередь телеметрии
- PostgreSQL — per-tenant databases для телеметрии
- Docker Hub: `naeel/iot-operator`
### Структура:
```
cmd/iot-operator/ — точка входа (controller + API сервер)
cmd/mqtt-bridge/ — MQTT→Kafka bridge
cmd/kafka-consumer/ — Kafka→Postgres
api/v1alpha1/ — CRD Go types (IoTDevice)
controllers/ — IoTDevice reconciler
internal/api/ — REST handlers, router, middleware, UI (go:embed)
internal/storage/ — iotpg (per-tenant Postgres)
config/crd/ — CRD YAML manifests
deployments/k8s/ — k8s deployment YAMLs
doc/ — документация
examples/ — примеры (Terraform, handler.py)
```
---
## Комментарии в коде
Комментарии — обязательны:
- В начале каждого файла при создании или правке — дата и время изменения
- На каждой функции/методе — краткое назначение
- На нетривиальной логике — **почему** сделано именно так (не "что делает", а "зачем")
Цель: любой агент в новом чате должен понять логику без дополнительных вопросов.
---
## Темп работы
Не спешить. Перед каждым шагом — убедиться что предыдущий понят и согласован.
---
## Документация
Всё важное фиксировать в `doc/`:
- `doc/architecture/` — архитектура, стек, схемы
- `doc/api/` — дизайн API
- `doc/decisions/` — принятые решения с обоснованием
- `doc/infrastructure/` — инфраструктура, кластер, сервисы
- `doc/errors/` — ошибки и как решили
- `doc/progress.md` — трекер задач
Обновлять после каждого значимого изменения.
---
## Именование
Имена должны быть **уникальными и осмысленными по всему проекту**:
- имена файлов
- имена функций/методов
- имена переменных/констант
- имена ресурсов (Terraform, Kubernetes и т.д.)
Цель: чтобы поиск по проекту находил нужные сущности без неоднозначности, а имя сразу отражало назначение.
Запрещены безликие и повторяющиеся имена вида `handler.py`, `handle`, `data`, `value`, `temp` без контекста.
---
## Лог мышления (обязательно)
Каждый агент в каждом чате **обязан** вести лог своих рассуждений:
- Папка: `doc/thinking/`
- Файл: `ГГГГ-ММ-ДД.md` (по дате сессии)
- В начале файла указать имя агента и модель
- Если файл на текущую дату уже существует — дописывать в конец, добавив разделитель `---` и имя агента
- Записывать **полный** ход мыслей: что анализирую, какие гипотезы, что нашёл, что отбросил, к чему пришёл, почему
- Записывать **до** начала действий (план) и **после** (результат)
Цель: пользователь должен видеть весь процесс рассуждений в читаемом виде.
---
## Git
Коммитить и пушить после каждого завершённого этапа.
+63 -20
View File
@@ -1,23 +1,35 @@
# Правила работы агента # Правила работы агента
## Файловая система ## ⛔⛔⛔ DOCKER — ОБЯЗАТЕЛЬНЫЙ ПОРЯДОК ПЕРЕД КАЖДЫМ BUILD
`~/remote_dev/` (локально) примонтирован через sshfs к `~/terra/` на ВМ — **одна ФС**. 1. УВЕЛИЧИТЬ ТЕГ в `deployments/k8s/iot-console.yaml` (vX.Y.Z → vX.Y.Z+1)
Файлы, сохранённые локально, мгновенно видны на ВМ. SCP не нужен. 2. rsync на ВМ
3. ПРОВЕРИТЬ что файлы на ВМ новые (grep ключевой строки)
4. docker build с НОВЫМ тегом
5. docker push с НОВЫМ тегом
6. kubectl apply (не rollout restart — apply подтягивает новый тег)
Монтирование может слетать. Признак: файлы рассинхронизированы. **НИКОГДА не делать `docker build` со старым тегом — под не перетянет образ (imagePullPolicy: IfNotPresent)**
```bash ## Файловая система (актуально)
# Размонтировать
fusermount -u ~/remote_dev
# Если завис: sudo umount -l /home/naeel/remote_dev
# Примонтировать 1. Все файлы редактируются локально: `~/IoT`
sshfs naeel@5.172.178.213:/home/naeel/terra ~/remote_dev \ 2. После любых изменений — обязательно rsync на ВМ:
-o cache=no -o no_readahead -o reconnect \ rsync -az \
-o ServerAliveInterval=15 -o ServerAliveCountMax=3 \ -e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \
-o IdentityFile=~/.ssh/naeel_vm_id_ed25519 ~/IoT/ \
``` naeel@5.172.178.213:~/terra/IoT/
3. Git (add/commit/push) выполнять ЛОКАЛЬНО в ~/IoT
4. Docker, kubectl и другие инфраструктурные команды — только через SSH на ВМ
5. Перед запуском любой команды на ВМ обязательно убедиться, что синхронизация (rsync) выполнена
6. SCP, sshfs, remote_dev и маунты больше НЕ используются
7. Только rsync для синхронизации
Пример:
1. Редактируешь локально
2. rsync на ВМ
3. Выполняешь команды через SSH на ВМ
## SSH ## SSH
@@ -37,7 +49,11 @@ ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=
## Git ## Git
Коммитить и пушить через SSH после каждого завершённого этапа. ⛔⛔⛔ АБСОЛЮТНОЕ ПРАВИЛО:
- Git — ТОЛЬКО ЛОКАЛЬНО в `~/IoT`. НИКОГДА через SSH на VM.
- Разрешены ТОЛЬКО две операции: `git commit` и `git push`.
- ЗАПРЕЩЕНО: git pull, git fetch, git rebase, git merge, git reset, git stash, git checkout — что угодно кроме commit и push.
- Если push отклонён — СТОП, доложить пользователю. Не лезть в pull/merge/rebase самостоятельно.
Версионирование тегами: `vMAJOR.MINOR.PATCH` Версионирование тегами: `vMAJOR.MINOR.PATCH`
- Patch — любое изменение кода - Patch — любое изменение кода
@@ -48,9 +64,36 @@ ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=
git tag vX.Y.Z && git push origin vX.Y.Z git tag vX.Y.Z && git push origin vX.Y.Z
``` ```
## Поведение агента ## ⛔ ТЕРМИНАЛЬНЫЙ БУФЕР — НИКОГДА НЕ ЧИТАТЬ СТАРЫЙ
- Не трогать рабочий код без явного указания **АБСОЛЮТНОЕ ПРАВИЛО:**
- Не делать ничего сверх того, о чём явно попросили - get_terminal_output из старых сессий — МУСОР. Там старые прогоны.
- Деструктивные операции (`kubectl delete`, `rm -rf`, `terraform destroy` и др.) — только после явного подтверждения с указанием конкретных объектов - Всегда запускать новую команду через SSH и читать её вывод напрямую.
- Отвечать кратко, без вступлений, извинений, благодарностей и прочей воды - НИКОГДА не читать буфер терминала из предыдущей сессии как актуальные данные.
- Актуальный результат — только из команды, которая была запущена СЕЙЧАС.
## ⛔ ДОКУМЕНТАЦИЯ ТЕСТ-ПРОГОН�ОВ — В РЕАЛЬНОМ ВРЕМЕНИ
**Правила:**
1. Перед запуском `run_all.sh` — создать файл `test-results/YYYY-MM-DD_HH-MM.log` и записать в него метку времени и что запускается.
2. Запускать `run_all.sh 2>&1 | tee ~/terra/IoT/test-results/YYYY-MM-DD_HH-MM.log` — вывод пишется сразу в файл и отображается в терминале.
3. После завершения — rsync лога локально. Лог остаётся как документация.
4. Папка `test-results/` в репозитории — `.gitignore` не добавлять, логи коммитить.
**Формат запуска:**
```bash
LOG="test-results/$(date +%Y-%m-%d_%H-%M).log"
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no naeel@5.172.178.213 \
"bash ~/terra/IoT/scripts/run_all.sh 2>&1 | tee ~/terra/IoT/${LOG}"
rsync -az -e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no" \
naeel@5.172.178.213:~/terra/IoT/test-results/ ~/IoT/test-results/
```
**Никогда не разбираться с результатами по памяти / буферу / чату. Только лог.**
## ⛔ РУЧНЫЕ ПАТЧИ — ЗАПРЕЩЕНЫ
- НИКОГДА не применять ручные патчи (`kubectl patch`, `kubectl apply` отдельных полей, `python -c` замены в yaml и т.д.) без явного указания.
- Все изменения — только через код (Helm chart, YAML, Go-код) + сборка + деплой.
- Ручной патч слетает при следующем helm upgrade/redeploy → регрессия.
- Исключение: только если пользователь явно написал "примени ручной патч".
+4 -36
View File
@@ -1,37 +1,13 @@
# Бинарники (только в корне проекта, не директории cmd/) # Бинарники
iot-operator
mqtt-bridge
kafka-consumer
bin/ bin/
ws-probe/ws-probe
/iot-service
# Go # Go
*.exe *.exe
*.test *.test
*.out *.out
*.prof
*.cover
*.key
# Python — кэш и генерируемые файлы
__pycache__/
*.py[cod]
*$py.class
.pytest_cache/
.mypy_cache/
.ruff_cache/
.tox/
.venv/
venv/
htmlcov/
.coverage
.coverage.*
*.egg-info/
build/
dist/
.eggs/
# Логи
*.log
logs/
# IDE # IDE
.idea/ .idea/
@@ -43,14 +19,6 @@ logs/
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Прочие генерируемые/временные
*.tmp
*.temp
*.bak
*.orig
*.swp
.DS_Store
/HISTORY/2026-08-16-session-log.md /HISTORY/2026-08-16-session-log.md
/deploy/terraform/terraform.tfvars.example /deploy/terraform/terraform.tfvars.example
/deployments/k8s/iot-postgres.yaml /deployments/k8s/iot-postgres.yaml
+24 -23
View File
@@ -1,38 +1,39 @@
# Dockerfile — монолит iot-service (один бинарник, один контейнер). # Создано: 2026-04-12
# Образ: gitea.services.ngcloud.ru/nail/iot-service (Gitea registry платформы). # Dockerfile для IoT managed service.
# # Multi-stage build: 3 бинарника (iot-operator, mqtt-bridge, kafka-consumer).
# Env-дефолты (не секреты) вшиты сюда — deck-UI задаёт только секреты. # Образ: naeel/iot-operator (Docker Hub).
FROM golang:1.25 AS builder FROM golang:1.25 AS builder
WORKDIR /workspace WORKDIR /workspace
# Кэширование зависимостей
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
# Исходный код
COPY api/ api/
COPY controllers/ controllers/
COPY cmd/ cmd/ COPY cmd/ cmd/
COPY internal/ internal/ COPY internal/ internal/
ARG VERSION=dev # iot-operator — controller-manager + REST API
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a \ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o iot-operator ./cmd/iot-operator/
-ldflags "-X main.version=${VERSION}" -o iot-service ./cmd/iot-service/
# mqtt-bridge — MQTT (EMQX) → Kafka
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o mqtt-bridge ./cmd/mqtt-bridge/
# kafka-consumer — Kafka → Postgres
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o kafka-consumer ./cmd/kafka-consumer/
# --- Runtime ---
FROM gcr.io/distroless/static:nonroot FROM gcr.io/distroless/static:nonroot
WORKDIR / WORKDIR /
COPY --from=builder /workspace/iot-service .
# Все три бинарника в одном образе.
# Какой запускать — определяется command в k8s Deployment.
COPY --from=builder /workspace/iot-operator .
COPY --from=builder /workspace/mqtt-bridge .
COPY --from=builder /workspace/kafka-consumer .
USER 65532:65532 USER 65532:65532
EXPOSE 9090 ENTRYPOINT ["/iot-operator"]
ENV API_PORT=9090 \
SQS_ENDPOINT=https://sqs.sinefo.ru \
SQS_QUEUE_NAME=iot-telemetry \
SQS_REGION=us-east-1 \
SQS_LONG_POLL_SECONDS=20 \
SQS_VISIBILITY_TIMEOUT=120 \
MQTT_CLIENT_ID=iot-bridge \
MQTT_PORT=8083 \
MQTT_WS_PATH=/mqtt \
AUTH_TEST_MODE=false \
LOG_LEVEL=info
ENTRYPOINT ["/iot-service"]
-168
View File
@@ -1,168 +0,0 @@
# Журнал сессии — 2026-10-07 (IoT: P0-доработки до production-ready)
**Рабочая папка:** /home/naeel/nubes/SQS_und_IoT/IoT
**Ветка:** feature/replace-kafka-with-sqs
> Основание: `doc/thinking/remediation-plan-2026-10-05.md` (на ветке master) —
> план по двум анализам готовности. Работа только по явной команде; после каждой
> правки — коммит.
---
## Сделано
### P0-1/P0-2 — tenant-граница + обязательная подпись JWT (`5e1cd28`)
- `internal/service/api/middleware/auth.go`:
- `validateJWT` теперь возвращает `sub`;
- middleware сверяет `sub` с `{namespace}` из URL (`mux.Vars`) → 403 при
несовпадении (закрыт горизонтальный доступ к чужим namespace);
- вне `AUTH_TEST_MODE` подпись HS256 ОБЯЗАТЕЛЬНА: без `JWT_HMAC_SECRET`
middleware отказывает (fail-closed, 500), а не проверяет «структурно».
- `internal/service/config/config.go`: `JWT_HMAC_SECRET` добавлен в список
обязательных (когда `AUTH_TEST_MODE=false`) — отказ на старте.
### P1-4 — убраны неаутентифицированные `/internal/mqtt/*` (`cd47258`)
- `internal/service/api/router.go`: удалены маршруты `/internal/mqtt/auth|acl`.
EMQX ходит в PostgreSQL напрямую (`backend=postgresql`), HTTP-fallback был
не нужен и висел на публичном порту без auth (перебор паролей/ACL).
- Примечание: те же эндпоинты остаются в ЛЕГАСИ `internal/api/` (не исполняется).
### P0-3 (часть 2) — пароль устройства не отдаётся повторно (`f480be3`)
- `internal/service/api/handler/devices.go`: `GetIoTDevice` больше не возвращает
`mqtt_password` (передаём `""` в `deviceToResponse`). Пароль — только при создании.
- Часть 1 (хэш паролей в БД + перевод EMQX на `password_hash_algorithm`) НЕ
сделана: требует правки `emqx/emqx.conf` и миграции существующих строк.
### P0-7 — подтверждение из SQS только после записи в БД (`c84cbae`)
- Проблема: `InsertTelemetry` лишь буферизует и возвращает nil до фактической
записи; consumer удалял сообщение сразу после `processTelemetry == nil` →
краш/flush-ошибка в окне = безвозвратная потеря телеметрии.
- `internal/storage/iotpg/iot_telemetry_store.go`: добавлен `Flush(ctx)`.
- `internal/service/consumer/consumer.go`: копим receipt handles, вызываем
`store.Flush(ctx)`, и только при успехе — `DeleteMessage`. При ошибке flush
сообщения НЕ удаляются (вернутся после visibility timeout).
### P1-9 — `/ready` отражает состояние подсистем
- `internal/service/api/readiness.go` — тип `Readiness` (atomic-флаги bridge/consumer).
- `internal/service/api/router.go` — `NewRouter` принимает `*Readiness`; добавлен
`/ready` (200 если обе подсистемы в строю, иначе 503). `/health` остался
liveness-пробой.
- `cmd/iot-service/main.go` — bridge/consumer поднимают/опускают свои флаги;
если горутина завершилась (ошибка/падение), `/ready` вернёт 503.
### P0-8 — идемпотентность телеметрии (dedup по message_id)
- `internal/service/bridge/handler.go`: в `TelemetryEnvelope` добавлен `MessageID`
(UUID на каждое MQTT-сообщение).
- `internal/service/consumer/processor.go`: `MessageID` передаётся в `InsertTelemetry`.
- `internal/storage/iotpg/iot_telemetry_store.go`: в таблицу добавлен столбец
`message_id` + UNIQUE INDEX `(device_id, message_id)`; батч-INSERT теперь
`ON CONFLICT DO NOTHING`. Пустой message_id → NULL (NULL в unique-индексе не
конфликтует → без ложного дедупа). При at-least-once повторе из SQS дубликат
не вставляется.
- Легаси `cmd/sqs-consumer`: вызов обновлён (`""`), файл помечен к удалению (P2-14).
### Ревью-фикс P0-7/P0-8 (найдено при перепроверке кода)
- ❗ Была реальная потеря данных: `flushAll` и `InsertTelemetry` (при заполнении
батча) вынимали строки из буфера и **подменяли буфер ДО записи**; при ошибке
`flushTenant` строки исчезали. Хуже — фоновый flusher мог «съесть» строки,
которые consumer ждал в `Flush`, тогда `Flush` вернул бы nil (буфер пуст) и
consumer **подтвердил бы** SQS-сообщение с незаписанными данными.
- Фикс: при ошибке `flushTenant` строки ВОЗВРАЩАЮТСЯ в буфер (в обоих путях) —
данные не теряются, повтор выполняет flusher/Flush. `ctx` проброшен в
`flushAll`/`flushTenant` (раньше `Flush(ctx)` игнорировал ctx).
- Consumer: receipt handle хранится значением (`string`), а не указателем SDK.
- Компромисс: при долгом сбое PG буфер растёт (backpressure) — осознанно, вместо
тихой потери телеметрии.
## Осталось (не сделано)
- P0-3 часть 1 (хэш паролей + EMQX), P1-11/P1-12 (тесты/CI), P2-14
(вычистить легаси `internal/api/`, `cmd/sqs-consumer`).
- Платформенные проблемы (wss ~150с, MSS/MTU) — вне кода.
---
## Архитекторский разбор (Дипсик Про, по итогам перепроверки)
Перечитана цепочка `bridge → SQS → consumer → iotpg` целиком. Найдены 5 проблем
(часть — мой же недочёт при кодинге P0-7/P0-8):
1. 🔴 **Залипание на «ядовитом» батче.** После моего фикса «возвращаем строки в
буфер при ошибке» один payload, падающий не по `ON CONFLICT`, зацикливает
flush этого namespace навсегда (flusher каждые 200мс). Так «тихая потеря»
превратилась в «вечное залипание». ← моя ошибка: фикс без различения типов ошибок.
2. 🔴 **Битый envelope теряется безвозвратно.** `processTelemetry` на битый JSON
возвращает `nil` → consumer подтверждает (DeleteMessage) → мусор исчезает без
следа. Если мусор шлёт bridge по багу — данные теряются навсегда, без DLQ.
3. 🟠 **Нарушение порядка телеметрии.** `ts` ставится `now()` в БД при INSERT, а
не в момент приёма (`ReceivedAt` из envelope не идёт в батч). При батчинге и
нескольких потоках порядок для device_id не гарантирован.
4. 🟠 **Дублирование / нет дедупа для `message_id=""`.** `ON CONFLICT` по
`(device_id, message_id)` не ловит NULL: legacy `cmd/sqs-consumer` передаёт
`""` → дубли вставляются. Плюс гонка InsertTelemetry(full) ↔ flusher на одном
namespace = два параллельных INSERT.
5. 🟡 **Close() может потерять последний батч** при ошибке shutdown-flush
(`_ = flushAll(...)` — ошибка игнорируется).
### Решение (как принято)
- п.1 + п.3 (битые/ядовитые → DLQ) — делаю я (Дипсик Про), это дизайн-развилки.
- п.2 + п.4 (ReceivedAt; message_id NOT NULL) — передаются Флашу, с критериями.
- Битое сообщение — **в DLQ** (стандартный паттерн: valid→process, invalid→DLQ).
- Ядовитый SQL-батч — различать retryable (сеть/PG лежит → ретрай в буфер) от
permanent (нарушение constraint → не вешать namespace, выброс в лог/DLQ).
### Статус
Реализация п.1 (разделение ошибок flush) и п.3 (битые → DLQ) — см. ниже.
### Реализовано (п.3) — битые envelope → DLQ (`42f0045`)
- `processor.go`: `processTelemetry` возвращает sentinel `errInvalidEnvelope`
при битом JSON (вместо молчаливого nil, из-за которого мусор подтверждался и
исчезал навсегда).
- `consumer.go`: при `errInvalidEnvelope` тело кладётся в DLQ
(`SQS_DLQ_NAME`, дефолт `iot-telemetry-dlq`) и основное сообщение подтверждается;
без DLQ — только лог (НЕ подтверждаем). Реальные ошибки PG — по-прежнему ретрай.
- `config.go`/`defaults.go`: добавлен `SQSDLQName` + `DefaultSQSDLQName`.
### Реализовано (п.1) — разделение retryable/permanent ошибок flush (`f1a7266`)
- `iot_telemetry_store.go`: `isRetryableFlushErr` — `'08'` (connection) и
57P01/02/03 (shutdown/crash) retryable (возврат в буфер); `'22'` (data exception)
и `'23'` (integrity violation) permanent (батч выбрасывается с логом, НЕ в буфер).
- Закрыто замеченное залипание: один «ядовитый» payload больше не зацикливает
flush namespace навсегда.
### Реализовано (п.2) — `ts` = время приёма (`a386d22`)
- `iot_telemetry_store.go`: `telemetryInsert` получил `ts`; `flushTenant` пишет
`ts` явно из `ReceivedAt` (серверное время приёма, ставит bridge), а не `now()`
БД. Zero → `now()`. Схема не менялась (существующая колонка `ts`).
- Callers обновлены: `consumer/processor.go` (парсит `ReceivedAt`), legacy
`cmd/sqs-consumer`.
### ОТКРЫТО (п.4) — `message_id NOT NULL` / дедуп legacy
- Проблема: `UNIQUE(device_id, message_id)` не ловит NULL → legacy
`cmd/sqs-consumer` (передаёт `""`) не дедуплицируется; возможны дубли.
- Развилка, где я не уверен (ОСТАНОВКА, без догадок):
1. Сделать `message_id NOT NULL`: нужна миграция (backfill `legacy-'||id` +
`SET NOT NULL`). Безопасна, но legacy-консьюмер тогда должен передавать
SQS `MessageId` (иначе его строки не пройдут NOT NULL и будут «дропнуты»
как permanent). Legacy и так идёт под удаление (P2-14).
2. Оставить nullable, задокументировать отсутствие дедупа для legacy и
удалить legacy (P2-14). Минимальный риск, но формально «не NOT NULL».
- Нужно решение владельца.
### Решено (п.4) — удалён legacy `cmd/sqs-consumer`
- Проверено: `Dockerfile`/`Makefile` собирают ТОЛЬКО `./cmd/iot-service/` →
`cmd/sqs-consumer` не попадает в образ/деплой (пустой `message_id` до прода
не доходил). Логика поглощена `internal/service/consumer/`.
- Удалён (`git rm -r cmd/sqs-consumer`); сборка/vet зелёные. `message_id`
оставлен nullable — единственный пишущий путь (bridge) всегда ставит UUID.
- LEGACY.md обновлён: `cmd/sqs-consumer` перенесён в «удалено».
- Примечание-противоречие: LEGACY.md до этого называл `cmd/sqs-consumer` «НЕ
легаси, переиспользуется»; при сверке с Dockerfile/Makefile подтверждено, что
standalone-бинарник не используется → удаление корректно.
### Исправление неточности (моей)
- В коммите п.3 и в этой HISTORY я написал «без DLQ — НЕ подтверждаем». По коду
НЕ так: даже без DLQ битый envelope ПОДТВЕРЖДАЕТСЯ (удаляется с логом) — и это
правильно (иначе он зациклился бы). Формулировка была неверной, исправлено.
## Проверки
- `go build ./...` и `go vet ./...` — зелёные на каждом шаге.
-102
View File
@@ -1,102 +0,0 @@
# Журнал сессии — 2026-10-10 (IoT: чистка легаси → ветка master → ревью → фиксы)
**Рабочая папка:** /home/naeel/nubes/SQS_und_IoT/IoT
**Ветка:** `master` (переименована из `feature/replace-kafka-with-sqs`)
**Состояние на конец сессии:** 28 коммитов сверх `origin/master`, **не запушено** (пуш — только по команде).
---
## 1. Чистка легаси
Цель владельца: «старое ГАВНО не всплывало».
- 🗑 Удалён код-кластер старого IoT (не импортировался монолитом, не собирался):
`cmd/iot-operator/`, `cmd/mqtt-bridge/`, `controllers/`, `api/v1alpha1/`,
`internal/api/` (старый REST + UI-embeds admin/console). Коммит `f152f54`.
- `go mod tidy` убрал k8s-зависимости (`k8s.io/*`, `controller-runtime`) — −57 строк `go.mod`, −614 `go.sum`.
- 🗑 `bin/` со старыми бинарниками (operator/kafka-consumer/mqtt-bridge).
- `iot-admin.html` (живой `/iot-admin`): секции Kafka/K8s Pods → «Очередь shared-SQS» + «Рантайм»; удалён CSS `.pod-*/.badge-*/.dot-*/.lag-bar*`. Коммит `42f0d6a`.
- `.gitignore`: убраны сталые пути бинарников.
- `LEGACY.md` обновлён (помечено `УДАЛЁН 2026-10-10`).
Осталось на диске вне git: `iot-console.html` со **старыми доменами** `sless.kube5s.ru` / `iot.kube5s.ru` (см. §6, требует решения владельца — какой домен).
## 2. Ветка → master, коммит/пуш
- SQS-service: `main` → `master`; IoT: `feature/replace-kafka-with-sqs` → `master` (старая `master` перезаписана, её 3 коммита влиты merge'ом, конфликт только в `.gitignore`).
- Пуш в Gitea (`gitea.services.ngcloud.ru/Nail/{SQS-service,IoT}`): `origin/master` создан/обновлён. Force не использовался.
## 3. Правки кода по ревью Opus
### Блокеры Terraform (Этап 2)
- `632e551` MQTT-пароль устройства отдаётся один раз в 201 + `phase` пишется в БД (Create).
- `a6ef9f9` `created_at` → RFC3339 + перечитывание после Create.
- `7e3828f` `telemetry_topic` + нормализация `metadata` (без drift `{}`/null).
- `7ea4923` валидация `namespace/name/device_id` (`^[a-zA-Z0-9][a-zA-Z0-9-]*$`, запрет `_ + # /`).
### Потери данных (Этап 1)
- `046db68` 🔴1 сериализация flush (`flushMu`) — Flush дожидается in-flight записи.
- `e0cd1ca` 🔴2 построчный fallback (ядовитая строка не роняет весь батч).
- `006dbfa` 🔴2 семантическая валидация envelope (пустые поля → DLQ).
- `01d21f6` 🔴3 слив буфера bridge при shutdown + безопасное закрытие канала.
- `241c98a` 🔴4 ретраи SQS до shutdown + таймаут на попытку.
- `69ae2ff` 🔴6 не рвать автореконнект (ретрай подписки, без `Disconnect`).
- `530ee7b` 🔴7 ретрай резолва DLQ + не терять битые при недоступной DLQ.
- `47d9a04` 🔴5 ретрай GetQueueUrl на старте + выход ≠0 при сбое подсистемы.
## 4. PRO-ревью (моё) — находки и фиксы
Разобрал работу предыдущей модели, нашёл 3 🔴 + 🟡/🟢:
- `98e6def` **#1** `os.Exit` обходил `defer iotStore.Close()` → финальный flush терялся. Вынес логику в `run() int`, `main = os.Exit(run())`.
- `6e4a844` **#2** «не подтверждать битое при недоступной DLQ» = ядовитое зацикливается. Добавлено чтение `ApproximateReceiveCount` (после `maxReceiveCount=5` — дроп).
- `c1733c5` **#3** `/ready` = «горутина запущена», а не «подписан». Bridge сам управляет флагом через `onSubscribed`.
### Этап 4 (изоляция/JWT)
- `ea569da` JWT: обязательный `exp`, 401 вместо 403, guard тест-режима, JSON-ошибки.
- `2bea1de` `replaceDSNDatabase` (устранено смешение тенантов: ошибка для key=value DSN, корректный разбор `sslrootcert=/path`) + guard namespace (`safeNamespaceRe`, `ErrInvalidNamespace`).
### Этап 3 (жизненный цикл)
- `75c30e5` readiness consumer по SQS (`onReady`), ограничение `wg.Wait` (`waitWithTimeout`, 15с), идемпотентный `Close` (`sync.Once`).
### Этап 5 (мелочи)
- `f37f216` удалён мёртвый `mqtt_auth.go` + constant-time админ-токен.
- `fa98020` `decodeJSON` (MaxBytesReader 64КиБ + DisallowUnknownFields) + таймауты сервера.
- `d7cb850` `DeleteMessageBatch` (до 10 за вызов).
- `1510efe` пагинация: tiebreaker `ts DESC, id DESC` + потолок `limit` + точный лог.
- `11bb38e` `MaxReconnectInterval` 30с + Debug для health-проб/телеметрии.
### Terraform / безопасность
- `eacc740` PATCH обновляет `metadata`.
- `0a51d70` 409 различает конфликт `name` vs `device_id` (`ErrDeviceIDExists`).
- `c1adb26` **P0-3** bcrypt-хэш паролей устройств и бриджа + EMQX `name=bcrypt, salt_rounds=10` (ключ `salt_rounds` подтверждён по исходникам emqx/emqx; Go `DefaultCost=10`).
### Тесты / доки
- `46744ac` юнит-тесты: `validateIdentifier` (handler), `replaceDSNDatabase` + `safeNamespaceRe` (iotpg).
- `1600339` деплой-дока: пароль отдаётся один раз, в БД bcrypt.
## 5. Повторное PRO-ревью — открытые находки
- 🟠 **ReceivedAt — точность до секунды.** `bridge/handler.go` пишет `time.RFC3339`, `consumer/processor.go` парсит `time.RFC3339`; субсекунды теряются. Очередь standard (не FIFO) → два показания в одну секунду могут встать не в том порядке; tiebreaker `id DESC` не чинит (id = порядок SQS-доставки). **Фикс: RFC3339Nano на обеих сторонах.**
- 🟡 Пустое тело (`msg.Body == ""`) подтверждается без DLQ — для консистентности тоже в DLQ.
- 🟡 `processBackoff`-sleep внутри цикла батча задерживает подтверждение соседей → redelivery (спасают дедуп + фоновый flusher). Пре-существующий дизайн.
- ⚠️ Валидация `namespace`/`exp` зависят от формата `sub`/токена платформы — не проверено.
- ⚠️ `flushMu` — глобальная сериализация (осознанный компромисс 🔴1 vs throughput).
- ⚠️ Гонки не прогнаны под `go test -race`.
## 6. Осталось (не сделано)
**Хвосты доделаны 2026-10-11:** `18cdea7` RFC3339Nano; `250bbdf` пустое тело → DLQ;
`49422ef` домены консоли (apiBase → origin, mqttBase → exqx…); `d195629` race-тест dispatcher.
Осталось:
- Проверить формат `sub` платформы (валидация namespace + JWT `exp`) — **проверка**, не код.
- Тесты на `flushAll`/`Close` (требуют мока PG/SQS; `go test -race` прогнан, dispatcher покрыт).
- Terraform-провайдер (следующая большая задача после IoT).
- SQS-хвосты (`CreateQueue`→`QueueNameExists`, `ListQueues`, JWT по `sub`, лимит по байтам).
## Правила (напомню себе)
- Коммитить после каждой правки; пуш — только по команде.
- Документировать всё, включая ошибки (HISTORY/ + doc/thinking/).
- Не задавать вопросов, ответ на которые есть в коде/доках.
- «Рекомендация без замера — не рекомендация».
-288
View File
@@ -1,288 +0,0 @@
# Журнал сессии — 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`). Применение — за владельцем.
-57
View File
@@ -1,57 +0,0 @@
# ⛔⛔⛔ ЛЕГАСИ — СТАРЫЙ IoT (k8s-деплой)
С 2026-08-16 всё, что относится к СТАРОМУ IoT (деплой в собственном k8s,
оператор, CRD), — **ЛЕГАСИ**. **НЕ ПРИНИМАТЬ ВО ВНИМАНИЕ** при принятии решений.
Не использовать, не ссылаться, не переносить в новую архитектуру без отдельной
команды.
**Актуальное:**
- `HISTORY/2026-08-16-session-log.md` — состояние, диагностика, план миграции
- `doc/sqs-integration.md` — актуальная интеграция с shared-sqs
## Помечено ЛЕГАСИ и перенесено в `legacy/` (2026-08-16)
- `legacy/deployments/` — все k8s-манифесты старого деплоя
- `legacy/config/` — CRD IoTDevice
- `legacy/doc/` — все старые документы (deployment, architecture, api, decisions,
thinking, progress, testing и др.)
- `legacy/examples/` — старые примеры (RabbitMQ/serverless)
- `legacy/2026-08-13-migration-to-managed.md` — СТАРЫЙ план миграции (Node.js) — ОТМЕНЁН
- `legacy/Dockerfile.old` — собирал 3 старых бинарника
- `legacy/Makefile.old` — старые k8s-цели
- ⛔ `cmd/iot-operator/`, `controllers/`, `api/v1alpha1/` — оператор (CRD-контроллер):
**УДАЛЁН 2026-10-10** — не импортировался активным монолитом, не собирался.
- ⛔ `internal/api/` — старый REST API + UI-embeds (admin/console): **УДАЛЁН 2026-10-10**
(активный API — `internal/service/api/`).
- ⛔ `cmd/mqtt-bridge/` — старый standalone bridge: **УДАЛЁН 2026-10-10** (заменён
`internal/service/bridge/`).
- k8s-зависимости (`k8s.io/*`, `controller-runtime`) — **УДАЛЕНЫ из go.mod 2026-10-10**
(`go mod tidy` после удаления оператора/CRD).
## Аудит вмешательства старого IoT (2026-08-16, проверено)
- **Код:** k8s-зависимости есть ТОЛЬКО в `internal/api/handler/` (handler.go,
iot_device_handler.go, iot_admin_stats_handler.go — controller-runtime,
corev1, CRD-типы). Всё остальное чисто: router, middleware, UI-embeds,
`internal/storage/iotpg/`, `cmd/mqtt-bridge/`, `cmd/sqs-consumer/`.
- **Правило для монолита:** новый код НЕ импортирует `controllers/`,
`api/v1alpha1/`, k8s-клиент. Хендлеры устройств переписываются на
PG-хранилище (`iot_devices`); после этого k8s-депы удаляются из go.mod.
- **Данные:** старый shared-sqs (qu.kube5s.ru) удалён полностью — старых
очередей нет. В managed PG остались старые тестовые tenant-DB (изолированы,
на новый код не влияют; очистка — только по отдельной команде).
Старые k8s Secrets не читаются (новый код без k8s-клиента).
- **Артефакты:** `bin/` в git не отслеживается; CI (.github/) содержит только
правила — старых пайплайнов нет.
## НЕ легаси (актуально)
- `HISTORY/2026-08-16-session-log.md`
- `doc/sqs-integration.md`
- `ws-probe/`
- `cmd/iot-service/`, `internal/service/`, `internal/storage/iotpg/` — активный
монолит iot-service
- ⛔ `cmd/sqs-consumer/` — **УДАЛЁН 2026-10-07**: standalone-консьюмер, который
НЕ собирался Dockerfile/Makefile (те билдят только `./cmd/iot-service/`); его
логика поглощена `internal/service/consumer/`. Был источником пустого
`message_id` (обход дедупа).
+30 -19
View File
@@ -1,29 +1,40 @@
# Makefile — монолит iot-service (образ gitea.services.ngcloud.ru/nail/iot-service). # Создано: 2026-04-12
# Registry: Gitea (gitea.services.ngcloud.ru) — managed-платформа тянет образы отсюда. # Makefile для IoT managed service.
# Старые k8s-цели — в legacy/Makefile.old.
REGISTRY ?= gitea.services.ngcloud.ru/nail IMG ?= naeel/iot-operator:latest
VERSION ?= v0.1.13
IMAGE ?= $(REGISTRY)/iot-service
LDFLAGS = -X main.version=$(VERSION)
.PHONY: build vet test tidy docker-build docker-push .PHONY: build build-all docker-build docker-push test tidy
build: # Собрать все бинарники
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/iot-service ./cmd/iot-service/ build-all: build-operator build-bridge build-consumer
vet: build-operator:
go vet ./... CGO_ENABLED=0 go build -o bin/iot-operator ./cmd/iot-operator/
test: build-bridge:
go test ./... CGO_ENABLED=0 go build -o bin/mqtt-bridge ./cmd/mqtt-bridge/
tidy: build-consumer:
go mod tidy CGO_ENABLED=0 go build -o bin/kafka-consumer ./cmd/kafka-consumer/
# Docker
docker-build: docker-build:
docker build --build-arg VERSION=$(VERSION) -t $(IMAGE):$(VERSION) -t $(IMAGE):latest . docker build -t $(IMG) .
docker-push: docker-push:
docker push $(IMAGE):$(VERSION) docker push $(IMG)
docker push $(IMAGE):latest
# Тесты и go mod
test:
go test ./...
tidy:
go mod tidy
# Применить CRD в кластер
install-crd:
kubectl apply -f config/crd/bases/
# Деплой всех компонентов
deploy:
kubectl apply -f deployments/k8s/
+71
View File
@@ -0,0 +1,71 @@
# IoT Managed Service
## Назначение
IoT Managed Service — это облачный сервис для управления IoT-устройствами, сбора и хранения телеметрии, а также интеграции с внешними системами через MQTT, Kafka и REST API.
Сервис автоматизирует регистрацию устройств, выдачу credentials, сбор телеметрии и предоставляет REST-интерфейс для управления и мониторинга.
---
## Архитектура
- **IoT Device** — физическое устройство, подключающееся по MQTT
- **EMQX** — брокер MQTT, делегирует auth/ACL в iot-operator
- **iot-mqtt-bridge** — подписка на все топики, проксирует в Kafka
- **Kafka** — очередь событий телеметрии
- **iot-kafka-consumer** — сохраняет телеметрию в per-tenant Postgres
- **IoT Postgres** — база данных для хранения телеметрии
- **REST API** — управление устройствами, получение телеметрии
---
## Основные компоненты
| Компонент | Назначение |
|-------------------|-----------------------------------|
| iot-operator | REST API, контроллер CRD |
| iot-mqtt-bridge | MQTT → Kafka |
| iot-kafka-consumer| Kafka → Postgres |
| iot-postgres | Хранение телеметрии |
| EMQX | MQTT брокер |
---
## Основные команды и эндпоинты
### Публичные
- `GET /console` — IoT Console (SPA)
- `GET /iot-admin` — страница администратора
### JWT (namespace)
- `POST /v1/namespaces/{ns}/iot/devices` — создать устройство
- `GET /v1/namespaces/{ns}/iot/devices` — список устройств
- `GET /v1/namespaces/{ns}/iot/devices/{name}` — получить устройство + пароль
- `DELETE /v1/namespaces/{ns}/iot/devices/{name}` — удалить устройство
- `PATCH /v1/namespaces/{ns}/iot/devices/{name}` — обновить (enabled)
- `GET /v1/namespaces/{ns}/iot/telemetry` — получить телеметрию
### Внутренние (без JWT)
- `POST /internal/mqtt/auth` — MQTT auth для EMQX
- `POST /internal/mqtt/acl` — MQTT ACL для EMQX
### Админ
- `GET /iot-admin/stats` — статистика (PG, Kafka, pods)
---
## Быстрый старт
1. Развернуть EMQX, iot-mqtt-bridge, event-dispatcher, iot-kafka-consumer, iot-postgres (см. deployments/k8s/)
2. Зарегистрировать устройство через REST API или CRD
3. Подключить устройство к MQTT (логин/пароль выдаются автоматически)
4. Телеметрия автоматически попадёт в Postgres и доступна через REST API
---
## Документация
- doc/architecture/overview.md — архитектура
- doc/api/endpoints.md — список эндпоинтов
- doc/deployment.md — деплой и инженерные детали
- examples/README.md — пример E2E цепочки
+99
View File
@@ -0,0 +1,99 @@
// Создано: 2026-04-04
// Описание CRD IoTDevice — регистрация IoT-устройства в платформе sless.
// Пользователь создаёт IoTDevice, контроллер автоматически генерирует MQTT-credentials
// и сохраняет их в k8s Secret в том же namespace.
// При удалении IoTDevice — Secret удаляется каскадно через OwnerReference.
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// IoTDeviceSpec — желаемое состояние IoT-устройства.
type IoTDeviceSpec struct {
// DeviceID — уникальный идентификатор устройства внутри namespace.
// Используется как часть MQTT username и имени Secret.
// Разрешены только строчные буквы, цифры и дефис — для совместимости с k8s именами.
// +kubebuilder:validation:Required
// +kubebuilder:validation:Pattern=`^[a-z0-9][a-z0-9-]*[a-z0-9]$`
// +kubebuilder:validation:MaxLength=48
DeviceID string `json:"deviceId"`
// Metadata — произвольные метаданные устройства (модель, локация и т.д.).
// Хранятся только в CRD, не влияют на логику контроллера.
// +optional
Metadata map[string]string `json:"metadata,omitempty"`
// Enabled — активно ли устройство (может подключаться к MQTT).
// Если false — контроллер устанавливает phase=Disabled, EMQX auth отклоняет подключение.
// Secret с credentials НЕ удаляется — при re-enable пароль остаётся прежним.
// +kubebuilder:default=true
Enabled bool `json:"enabled"`
}
// IoTDevicePhase — текущая фаза жизненного цикла устройства.
type IoTDevicePhase string
const (
// IoTDevicePhaseActive — устройство активно, credentials выданы, может подключаться к MQTT.
IoTDevicePhaseActive IoTDevicePhase = "Active"
// IoTDevicePhaseDisabled — устройство отключено (spec.enabled=false). EMQX отклоняет подключения.
IoTDevicePhaseDisabled IoTDevicePhase = "Disabled"
// IoTDevicePhasePending — устройство создано, контроллер ещё не успел сгенерировать credentials.
IoTDevicePhasePending IoTDevicePhase = "Pending"
// IoTDevicePhaseError — ошибка при создании credentials (см. status.message).
IoTDevicePhaseError IoTDevicePhase = "Error"
)
// IoTDeviceStatus — наблюдаемое состояние IoT-устройства (заполняет контроллер).
type IoTDeviceStatus struct {
// Phase — текущее состояние: Active, Disabled, Pending, Error.
Phase IoTDevicePhase `json:"phase,omitempty"`
// MQTTUsername — имя пользователя для подключения к MQTT-брокеру.
// Формат: {namespace}_{deviceId} — глобально уникален в рамках EMQX.
MQTTUsername string `json:"mqttUsername,omitempty"`
// SecretName — имя k8s Secret в том же namespace, содержащего mqtt-username и mqtt-password.
SecretName string `json:"secretName,omitempty"`
// TopicPrefix — MQTT topic prefix, на который разрешена публикация.
// Формат: {namespace}/ — устройство не может публиковать в чужие namespace.
TopicPrefix string `json:"topicPrefix,omitempty"`
// LastConnected — время последнего MQTT-подключения устройства.
// Заполняется MQTT auth-сервисом при каждом успешном CONNECT.
// +optional
LastConnected *metav1.Time `json:"lastConnected,omitempty"`
// Message — человекочитаемое сообщение о текущем статусе или ошибке.
Message string `json:"message,omitempty"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:name="DeviceID",type=string,JSONPath=`.spec.deviceId`
// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase`
// +kubebuilder:printcolumn:name="Enabled",type=boolean,JSONPath=`.spec.enabled`
// +kubebuilder:printcolumn:name="MQTTUser",type=string,JSONPath=`.status.mqttUsername`
// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
// IoTDevice — ресурс для регистрации IoT-устройства в платформе.
// Контроллер автоматически создаёт k8s Secret с MQTT-credentials.
type IoTDevice struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec IoTDeviceSpec `json:"spec,omitempty"`
Status IoTDeviceStatus `json:"status,omitempty"`
}
// +kubebuilder:object:root=true
// IoTDeviceList — список IoTDevice объектов.
type IoTDeviceList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []IoTDevice `json:"items"`
}
+30
View File
@@ -0,0 +1,30 @@
// Создано: 2026-04-04
// groupversion_info.go — регистрация API group iot.kube5s.ru/v1alpha1.
// ВАЖНО: группа намеренно отдельная от sless.kube5s.ru — при выносе IoT в
// отдельную репу CRD не будет конфликтовать с sless CRD.
// Package v1alpha1 содержит API Schema definitions для группы iot.kube5s.ru.
// +kubebuilder:object:generate=true
// +groupName=iot.kube5s.ru
package v1alpha1
import (
"k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/controller-runtime/pkg/scheme"
)
var (
// GroupVersion — API group/version для IoT ресурсов.
// Отдельный от sless.kube5s.ru — для изоляции и будущего разделения репозиториев.
GroupVersion = schema.GroupVersion{Group: "iot.kube5s.ru", Version: "v1alpha1"}
// SchemeBuilder регистрирует Go-типы в GroupVersionKind scheme.
SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion}
// AddToScheme добавляет типы этой API группы в scheme manager'а.
AddToScheme = SchemeBuilder.AddToScheme
)
func init() {
SchemeBuilder.Register(&IoTDevice{}, &IoTDeviceList{})
}
+109
View File
@@ -0,0 +1,109 @@
//go:build !ignore_autogenerated
// Code generated by controller-gen. DO NOT EDIT.
package v1alpha1
import (
runtime "k8s.io/apimachinery/pkg/runtime"
)
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *IoTDevice) DeepCopyInto(out *IoTDevice) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
in.Spec.DeepCopyInto(&out.Spec)
in.Status.DeepCopyInto(&out.Status)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IoTDevice.
func (in *IoTDevice) DeepCopy() *IoTDevice {
if in == nil {
return nil
}
out := new(IoTDevice)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *IoTDevice) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *IoTDeviceList) DeepCopyInto(out *IoTDeviceList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]IoTDevice, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IoTDeviceList.
func (in *IoTDeviceList) DeepCopy() *IoTDeviceList {
if in == nil {
return nil
}
out := new(IoTDeviceList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *IoTDeviceList) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *IoTDeviceSpec) DeepCopyInto(out *IoTDeviceSpec) {
*out = *in
if in.Metadata != nil {
in, out := &in.Metadata, &out.Metadata
*out = make(map[string]string, len(*in))
for key, val := range *in {
(*out)[key] = val
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IoTDeviceSpec.
func (in *IoTDeviceSpec) DeepCopy() *IoTDeviceSpec {
if in == nil {
return nil
}
out := new(IoTDeviceSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *IoTDeviceStatus) DeepCopyInto(out *IoTDeviceStatus) {
*out = *in
if in.LastConnected != nil {
in, out := &in.LastConnected, &out.LastConnected
*out = (*in).DeepCopy()
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IoTDeviceStatus.
func (in *IoTDeviceStatus) DeepCopy() *IoTDeviceStatus {
if in == nil {
return nil
}
out := new(IoTDeviceStatus)
in.DeepCopyInto(out)
return out
}
-208
View File
@@ -1,208 +0,0 @@
// iot-service — монолит IoT на платформе Nubes (контейнер «Простой HTTP»).
//
// Три роли в одном процессе:
// - REST API + MQTT auth/acl (:9090, устройства в PostgreSQL);
// - bridge — MQTT-подписка EMQX → shared-SQS;
// - consumer — shared-SQS → per-tenant PostgreSQL.
//
// Наследие старого IoT (k8s, оператор, CRD) НЕ используется.
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"sync"
"sync/atomic"
"syscall"
"time"
"gitea.services.ngcloud.ru/Nail/IoT/internal/service/api"
"gitea.services.ngcloud.ru/Nail/IoT/internal/service/api/handler"
"gitea.services.ngcloud.ru/Nail/IoT/internal/service/bridge"
"gitea.services.ngcloud.ru/Nail/IoT/internal/service/config"
"gitea.services.ngcloud.ru/Nail/IoT/internal/service/consumer"
"gitea.services.ngcloud.ru/Nail/IoT/internal/service/sqsclient"
"gitea.services.ngcloud.ru/Nail/IoT/internal/service/store"
"gitea.services.ngcloud.ru/Nail/IoT/internal/storage/iotpg"
)
// shutdownGrace — максимум ожидания остановки подсистем при завершении. Если
// что-то зависло — не блокируем выход (ревью 2026-10-10, «ограничить wg.Wait»).
const shutdownGrace = 15 * time.Second
// waitWithTimeout ждёт wg не дольше d; false = не дождались.
func waitWithTimeout(wg *sync.WaitGroup, d time.Duration) bool {
done := make(chan struct{})
go func() { wg.Wait(); close(done) }()
select {
case <-done:
return true
case <-time.After(d):
return false
}
}
// version — подставляется через ldflags при сборке.
var version = "dev"
// main — тонкая обёртка: os.Exit обходит defer-ы, поэтому вся логика в run(),
// которая возвращает код выхода и выполняет свои defer-ы (devices/iotStore
// Close, финальный flush) ДО выхода (ревью 🔴5: «os.Exit обходит defer-ы»).
func main() {
os.Exit(run())
}
func run() int {
cfg, err := config.Load()
if err != nil {
slog.Error("config", "err", err)
return 1
}
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: cfg.LogLevel}))
slog.SetDefault(log)
log.Info("iot-service starting", "version", version, "port", cfg.APIPort)
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer cancel()
// PostgreSQL — устройства и телеметрия.
storeCtx, storeCancel := context.WithTimeout(ctx, 15*time.Second)
devices, err := store.Open(storeCtx, cfg.IOTPGDSN)
storeCancel()
if err != nil {
log.Error("devices store", "err", err)
return 1
}
defer devices.Close()
log.Info("devices store ready")
// Служебная строка бриджа в iot_devices — для PG-auth EMQX (хвост 3).
ensureCtx, ensureCancel := context.WithTimeout(ctx, 10*time.Second)
err = devices.EnsureBridgeDevice(ensureCtx, cfg.MQTTUsername, cfg.MQTTPassword)
ensureCancel()
if err != nil {
log.Error("ensure bridge device", "err", err)
return 1
}
log.Info("bridge device ensured", "username", cfg.MQTTUsername)
iotStore, err := iotpg.New(cfg.IOTPGDSN, log)
if err != nil {
log.Error("iotpg store", "err", err)
return 1
}
defer iotStore.Close()
log.Info("telemetry store ready")
sqsClient := sqsclient.New(cfg)
h := &handler.Handler{
Devices: devices,
IoTPG: iotStore,
SQS: sqsClient,
Cfg: cfg,
Log: log,
Version: version,
StartedAt: time.Now(),
}
// HTTP API — в основном потоке.
// Readiness: /ready отдаёт 503, пока bridge/consumer не подняты.
ready := &api.Readiness{}
router := api.NewRouter(h, log, cfg.AuthTestMode, cfg.JwtHMACSecret, version, ready)
server := &http.Server{
Addr: ":" + cfg.APIPort,
Handler: router,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 15 * time.Second,
// WriteTimeout с запасом: admin-stats/telemetry отвечают через SQS/PG
// (внутренние таймауты до 15с). IdleTimeout — для keep-alive платформы.
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
// bridge + consumer — в фоне.
var wg sync.WaitGroup
wg.Add(2)
// shutdownDone — закрывается после завершения server.Shutdown (дренаж
// in-flight HTTP-запросов). run() ждёт его перед выходом, чтобы deferred
// Close (iotStore/devices) не сработали, пока запросы ещё дренируются.
shutdownDone := make(chan struct{})
// fatal — подсистема (bridge/consumer) завершилась с ПОСТОЯННОЙ ошибкой.
// Тогда после штатной остановки выходим с кодом ≠ 0, чтобы платформа
// перезапустила контейнер (ревью 🔴5: раньше процесс оставался жив, а
// /health отдавал 200).
var fatal atomic.Bool
go func() {
defer wg.Done()
// ready.SetBridge управляется самим bridge: true только после успешной
// подписки (находка #3). Иначе «соединён, но не подписан» давал /ready=200
// и молча терял телеметрию.
defer ready.SetBridge(false)
if err := bridge.Run(ctx, cfg, sqsClient, log, ready.SetBridge); err != nil && !errors.Is(err, context.Canceled) {
log.Error("bridge stopped", "err", err)
fatal.Store(true)
cancel() // штатный shutdown, затем выход ≠ 0
}
}()
go func() {
defer wg.Done()
// ready.SetConsumer управляется consumer'ом: true после успешного
// ReceiveMessage, false при ошибке SQS (находка «readiness»).
defer ready.SetConsumer(false)
if err := consumer.Run(ctx, cfg, sqsClient, iotStore, log, ready.SetConsumer); err != nil && !errors.Is(err, context.Canceled) {
log.Error("consumer stopped", "err", err)
fatal.Store(true)
cancel()
}
}()
// Сервер гаснет ПОСЛЕДНИМ: сначала останавливаются bridge+consumer
// (ctx → disconnect MQTT → flush SQS), потом HTTP. Иначе платформа
// может убить контейнер по liveness, пока bridge/consumer ещё работают
// (фикс MEDIUM из ревью 2026-08-16).
go func() {
<-ctx.Done()
// Ждём остановки подсистем, но не бесконечно (зависшую не ждём).
if !waitWithTimeout(&wg, shutdownGrace) {
log.Warn("iot-service: подсистемы не завершились за отведённое время", "grace", shutdownGrace)
}
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
_ = server.Shutdown(shutdownCtx)
close(shutdownDone)
}()
log.Info("iot-service listening", "addr", server.Addr)
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Error("http server", "err", err)
cancel() // останавливаем bridge/consumer
waitWithTimeout(&wg, shutdownGrace)
<-shutdownDone
return 1
}
// Ждём завершения Shutdown (дренаж in-flight HTTP) до deferred Close.
<-shutdownDone
if !waitWithTimeout(&wg, shutdownGrace) {
log.Warn("iot-service: подсистемы не завершились за отведённое время", "grace", shutdownGrace)
}
if fatal.Load() {
// Не os.Exit: возвращаем код, чтобы отработали defer-ы (iotStore.Close →
// финальный flush последнего батча). os.Exit их бы обошёл.
log.Error("iot-service: подсистема завершилась с ошибкой — выход ≠ 0")
return 1
}
log.Info("iot-service stopped")
return 0
}
@@ -1,6 +1,3 @@
# ⛔⛔⛔ ЛЕГАСИ (2026-08-16) — СТАРЫЙ IoT (CRD/k8s-деплой). НЕ ПРИНИМАТЬ ВО ВНИМАНИЕ.
# Актуальное: HISTORY/2026-08-16-session-log.md
--- ---
apiVersion: apiextensions.k8s.io/v1 apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition kind: CustomResourceDefinition
+175
View File
@@ -0,0 +1,175 @@
// Создано: 2026-04-04
// iotdevice_controller.go — контроллер IoTDevice CRD.
// Логика Reconcile:
// 1. Добавить finalizer при первом создании
// 2. При удалении (DeletionTimestamp != nil) — убрать finalizer.
// Secret удалится автоматически по OwnerReference (cascade GC).
// 3. Если spec.enabled=false — установить phase=Disabled, не создавать Secret.
// Если Secret уже существует — НЕ удалять (при re-enable пароль сохранится).
// 4. Если Secret не существует — сгенерировать пароль, создать Secret с OwnerRef.
// 5. Обновить status: phase, mqttUsername, secretName, topicPrefix.
package controllers
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/log"
iotv1alpha1 "gitea.services.ngcloud.ru/Nail/IoT/api/v1alpha1"
)
// iotDeviceFinalizer — имя finalizer для IoTDevice.
// Гарантирует что контроллер обработает удаление до того как k8s удалит объект.
const iotDeviceFinalizer = "iot.kube5s.ru/device-cleanup"
// IoTDeviceReconciler управляет жизненным циклом IoTDevice CRD.
type IoTDeviceReconciler struct {
client.Client
Scheme *runtime.Scheme
}
// +kubebuilder:rbac:groups=iot.kube5s.ru,resources=iotdevices,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=iot.kube5s.ru,resources=iotdevices/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=iot.kube5s.ru,resources=iotdevices/finalizers,verbs=update
// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;delete
// Reconcile — главный цикл управления IoTDevice.
func (r *IoTDeviceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
// Читаем IoTDevice из k8s
device := &iotv1alpha1.IoTDevice{}
if err := r.Get(ctx, req.NamespacedName, device); err != nil {
if errors.IsNotFound(err) {
// Объект удалён — финализация уже завершена
return ctrl.Result{}, nil
}
return ctrl.Result{}, fmt.Errorf("get iotdevice: %w", err)
}
// Если объект помечен к удалению — убираем finalizer.
// Secret удалится каскадно через OwnerReference — явно удалять не нужно.
if !device.DeletionTimestamp.IsZero() {
if controllerutil.ContainsFinalizer(device, iotDeviceFinalizer) {
controllerutil.RemoveFinalizer(device, iotDeviceFinalizer)
if err := r.Update(ctx, device); err != nil {
return ctrl.Result{}, fmt.Errorf("remove finalizer: %w", err)
}
logger.Info("removed finalizer from IoTDevice", "device", req.Name)
}
return ctrl.Result{}, nil
}
// Добавляем finalizer при первом создании объекта
if !controllerutil.ContainsFinalizer(device, iotDeviceFinalizer) {
controllerutil.AddFinalizer(device, iotDeviceFinalizer)
if err := r.Update(ctx, device); err != nil {
return ctrl.Result{}, fmt.Errorf("add finalizer: %w", err)
}
// Requeue — обязательно, чтобы продолжить reconcile с обновлённым объектом
return ctrl.Result{Requeue: true}, nil
}
// Если устройство отключено — обновляем статус, не создаём Secret
if !device.Spec.Enabled {
return ctrl.Result{}, r.setStatus(ctx, device, iotv1alpha1.IoTDevicePhaseDisabled, "Device is disabled")
}
// Имя Secret и MQTT username по формату из плана
secretName := "iot-" + device.Spec.DeviceID
mqttUsername := fmt.Sprintf("%s_%s", device.Namespace, device.Spec.DeviceID)
// Проверяем наличие Secret — если нет, создаём с новыми credentials
existingSecret := &corev1.Secret{}
err := r.Get(ctx, client.ObjectKey{Namespace: device.Namespace, Name: secretName}, existingSecret)
if errors.IsNotFound(err) {
// Генерируем криптографически стойкий пароль: 32 байта = 64 hex символа
password, genErr := generateMQTTPassword()
if genErr != nil {
setErr := r.setStatus(ctx, device, iotv1alpha1.IoTDevicePhaseError, fmt.Sprintf("generate password: %v", genErr))
return ctrl.Result{}, setErr
}
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: secretName,
Namespace: device.Namespace,
},
Data: map[string][]byte{
"mqtt-username": []byte(mqttUsername),
"mqtt-password": []byte(password),
},
}
// OwnerReference — Secret принадлежит IoTDevice.
// При удалении IoTDevice k8s каскадно удалит Secret.
if ownerErr := controllerutil.SetControllerReference(device, secret, r.Scheme); ownerErr != nil {
return ctrl.Result{}, fmt.Errorf("set owner reference: %w", ownerErr)
}
if createErr := r.Create(ctx, secret); createErr != nil {
setErr := r.setStatus(ctx, device, iotv1alpha1.IoTDevicePhaseError, fmt.Sprintf("create secret: %v", createErr))
return ctrl.Result{}, setErr
}
logger.Info("created MQTT credentials Secret", "secret", secretName, "device", device.Spec.DeviceID)
} else if err != nil {
// Неожиданная ошибка при чтении Secret
return ctrl.Result{}, fmt.Errorf("get secret: %w", err)
}
// else — Secret уже существует, ничего не делаем (пароль не меняем)
// Обновляем status — отражаем актуальное состояние устройства
device.Status.Phase = iotv1alpha1.IoTDevicePhaseActive
device.Status.MQTTUsername = mqttUsername
device.Status.SecretName = secretName
device.Status.TopicPrefix = device.Namespace + "/"
device.Status.Message = ""
if statusErr := r.Status().Update(ctx, device); statusErr != nil {
return ctrl.Result{}, fmt.Errorf("update status: %w", statusErr)
}
return ctrl.Result{}, nil
}
// setStatus — вспомогательная функция для обновления status.Phase и status.Message.
// Используется для промежуточных состояний (Disabled, Error).
func (r *IoTDeviceReconciler) setStatus(ctx context.Context, device *iotv1alpha1.IoTDevice, phase iotv1alpha1.IoTDevicePhase, message string) error {
device.Status.Phase = phase
device.Status.Message = message
if err := r.Status().Update(ctx, device); err != nil {
return fmt.Errorf("update status to %s: %w", phase, err)
}
return nil
}
// generateMQTTPassword генерирует криптографически стойкий пароль для MQTT.
// 32 байта случайных данных → hex строка 64 символа = 256 бит энтропии.
func generateMQTTPassword() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("rand.Read: %w", err)
}
return hex.EncodeToString(buf), nil
}
// SetupWithManager регистрирует контроллер в manager'е.
// Watches только IoTDevice — secrets обновляются через reconcile loop при необходимости.
func (r *IoTDeviceReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&iotv1alpha1.IoTDevice{}).
Owns(&corev1.Secret{}).
Complete(r)
}
-7
View File
@@ -1,7 +0,0 @@
# Terraform state и секреты — НЕ коммитить (.terraform.lock.hcl — коммитим)
.terraform/
*.tfstate
*.tfstate.*
terraform.tfvars
*.tfvars
crash.log
-13
View File
@@ -1,13 +0,0 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "tf-registry.containerk8s.services.ngcloud.ru/nubes-test/nubes" {
version = "3.1.0"
constraints = "3.1.0"
hashes = [
"h1:grJn9gwqsPZu5THrt3ZfmOYstRK2N/Fe3Czmf5fwy5k=",
"zh:530e741fae503f959ce5d7e26a4488cbeee7f2294786edae1af6e807afe1d1f8",
"zh:65fda474cc7abc24a0053638088abcc188ff69ded2c56a0a1d3d4fab2bbaf355",
"zh:bf039029d7fb67bfeb24b693ca791993255e21be062a1f69b913ae651bcaf67f",
]
}
-52
View File
@@ -1,52 +0,0 @@
# IoT на Nubes через Terraform
Разворачивает IoT-стек двумя HTTP-контейнерами провайдера `nubes-test` (ресурс `nubes_http`):
- `nubes_http.iot_emqx` — EMQX 5.5.1 (PG-auth) → `gitea.services.ngcloud.ru/nail/iot-emqx:v0.2.4`
- `nubes_http.iot_service` — монолит `iot-service` → `.../iot-service:v0.1.13` (bridge + consumer + api)
`iot-service` создаётся **после** EMQX (`depends_on`), подключается к shared-SQS (`sqs.sinefo.ru`),
managed PostgreSQL (инстанс `foriot`) и EMQX по внутреннему адресу.
> Источник провайдера: `tf-registry.containerk8s.services.ngcloud.ru/nubes-test/nubes` (TEST-стенд),
> версия `3.1.0`. Референсный пример — `~/TF/tf_provider/TEST_STAND/Maria_CRUD`.
## Предусловия (готово на 2026-10-11)
- Образы в Gitea: `nail/iot-emqx:v0.2.4`, `nail/iot-service:v0.1.13`.
- PG (инстанс `foriot`): user `iotservice` (ddl_user → CREATEDB+CREATEROLE) + БД `iotdb`.
- shared-SQS (тенант `demo-service`): очереди `iot-telemetry` + `iot-telemetry-dlq`.
## Запуск
```bash
cd deploy/terraform
cp terraform.tfvars.example terraform.tfvars # заполнить секреты
terraform init
terraform plan
terraform apply # ← запускает ВЛАДЕЛЕЦ (не агент)
```
## Проверка
```bash
terraform output iot_service_url # → https://<iot_domain>/health
terraform output iot_mqtt_broker_url # → wss://<emqx_domain>/mqtt
curl -s https://<iot_domain>/health # {"status":"ok",...}
curl -s https://<emqx_domain>/mqtt # 400 (норма: Cowboy без subprotocol)
```
В логах `iot-service` (ссылка в ЛК): `bridge: MQTT connected` → `bridge: subscribed` →
`consumer: SQS queue resolved`.
## Нюансы
- **`MQTT_HOST`**: по умолчанию вычисляется как `<resource_name>.<instanceUid>.svc.cluster.local`.
Если при первом apply адрес окажется другим — задать `mqtt_host` в `terraform.tfvars` (взять из ЛК).
`ws://` внутри кластера (порт `8083`, путь `/mqtt`).
- **`realm`**: ресурсная платформа K8s; должна существовать в ЛК, иначе
«Ресурсной платформы '<realm>' нету в Личном кабинете». Сверить у PG-инстанса.
- **Имя PG-пользователя**: платформенный regex `^[A-Za-z0-9]+$` — подчёркивание нельзя (`iotservice`, не `iot_service`).
- **`destroy` усыпляет** (`suspend_on_destroy=true`), не удаляет: удаление усыплённого — вручную в ЛК через 14 дней.
- **`reconcile`/`redeploy`** из Terraform недоступны (только ЛК).
- Секреты: `terraform.tfvars` и `*.tfstate` в `.gitignore` — НЕ коммитить.
-21
View File
@@ -1,21 +0,0 @@
# main.tf — IoT на платформе Nubes через Terraform (провайдер nubes-test).
#
# Разворачивает два HTTP-контейнера: EMQX (первым) и iot-service (вторым).
# Образы — из Gitea container registry (gitea.services.ngcloud.ru/nail).
#
# Стенд: TEST. Для DEV/PROD менять source провайдера (namespace) + api_endpoint + токен.
# См. TEST_STAND/Maria_CRUD — референсный пример (nubes_http, nubes_mariadb).
terraform {
required_providers {
nubes = {
source = "tf-registry.containerk8s.services.ngcloud.ru/nubes-test/nubes"
version = "3.1.0"
}
}
}
provider "nubes" {
api_token = var.api_token
api_endpoint = var.api_endpoint
}
-16
View File
@@ -1,16 +0,0 @@
# outputs.tf — адреса развёрнутых контейнеров.
output "emqx_url" {
value = try(nubes_http.iot_emqx.state_out.connectionUrl, "")
description = "Публичный адрес EMQX (wss MQTT)"
}
output "iot_service_url" {
value = try(nubes_http.iot_service.state_out.connectionUrl, "")
description = "Публичный адрес iot-service (REST/health)"
}
output "iot_mqtt_broker_url" {
value = "wss://${var.emqx_domain}/mqtt"
description = "MQTT broker URL для клиентов/консоли"
}
-87
View File
@@ -1,87 +0,0 @@
# resources.tf — два HTTP-контейнера: EMQX (первым) и iot-service (вторым).
# Образы: gitea.services.ngcloud.ru/nail/{iot-emqx,iot-service}.
# Провайдер nubes_http: docs-rendered/http.md.
#
# Порядок важен: iot-service подключается к EMQX (MQTT) → depends_on.
locals {
# Внутренний адрес EMQX-контейнера (для bridge внутри кластера).
# ⚠️ Service в namespace платформы ВСЕГДА называется `containerk8s`, НЕ
# resource_name. Проверено в кластере iot-naeel (2026-10-11):
# containerk8s.<instanceUid>.svc.cluster.local → 10.98.42.138 ✅
# iot-emqx.<instanceUid>.svc.cluster.local → NXDOMAIN ❌
# (тот же формат зафиксирован в HISTORY/2026-08-16-session-log.md:824).
# Если окажется иным — переопредели var.mqtt_host.
emqx_internal_host = "containerk8s.${nubes_http.iot_emqx.state_out.instanceUid}.svc.cluster.local"
mqtt_host = var.mqtt_host != "" ? var.mqtt_host : local.emqx_internal_host
}
# ── EMQX (создаётся ПЕРВЫМ) ──
resource "nubes_http" "iot_emqx" {
resource_name = "iot-emqx"
resource_realm = var.realm
domain = var.emqx_domain
registry_path = "${var.registry}/iot-emqx:${var.iot_emqx_version}"
resource_cpu = 1000
resource_memory = 1024
resource_instances = 1
# EMQX auth/ACL — напрямую в PostgreSQL (таблица iot_devices).
# ⚠ НЕ задавать ...__URL (HTTP-auth старой схемы — уронит под).
json_env = jsonencode({
EMQX_AUTHENTICATION__1__SERVER = "${var.pg_host}:${var.pg_port}"
EMQX_AUTHENTICATION__1__DATABASE = var.pg_db
EMQX_AUTHENTICATION__1__USERNAME = var.pg_user
EMQX_AUTHENTICATION__1__PASSWORD = var.pg_password
EMQX_AUTHORIZATION__SOURCES__1__SERVER = "${var.pg_host}:${var.pg_port}"
EMQX_AUTHORIZATION__SOURCES__1__DATABASE = var.pg_db
EMQX_AUTHORIZATION__SOURCES__1__USERNAME = var.pg_user
EMQX_AUTHORIZATION__SOURCES__1__PASSWORD = var.pg_password
EMQX_LOG__CONSOLE_HANDLER__LEVEL = "warning"
})
adopt_existing_on_create = true
}
# ── iot-service (создаётся ВТОРЫМ, после EMQX) ──
resource "nubes_http" "iot_service" {
resource_name = "iot-service"
resource_realm = var.realm
domain = var.iot_domain
registry_path = "${var.registry}/iot-service:${var.iot_service_version}"
resource_cpu = 200
resource_memory = 512
resource_instances = 1
json_env = jsonencode({
IOT_PG_DSN = "postgresql://${var.pg_user}:${var.pg_password}@${var.pg_host}:${var.pg_port}/${var.pg_db}?sslmode=require"
SQS_ENDPOINT = var.sqs_endpoint
SQS_ACCESS_KEY = var.sqs_access_key
SQS_SECRET_KEY = var.sqs_secret_key
SQS_QUEUE_NAME = var.sqs_queue_name
SQS_DLQ_NAME = var.sqs_dlq_name
SQS_REGION = "us-east-1"
MQTT_HOST = local.mqtt_host
MQTT_PORT = "8083"
MQTT_WS_PATH = "/mqtt"
MQTT_USERNAME = var.mqtt_username
MQTT_PASSWORD = var.mqtt_password
JWT_HMAC_SECRET = var.jwt_hmac_secret
ADMIN_STATS_TOKEN = var.admin_stats_token
AUTH_TEST_MODE = "false"
LOG_LEVEL = "info"
API_PORT = "9090"
})
# iot-service стартует только после создания EMQX
depends_on = [nubes_http.iot_emqx]
adopt_existing_on_create = true
}
-132
View File
@@ -1,132 +0,0 @@
# variables.tf — входные переменные стека IoT.
# Значения — в terraform.tfvars (не коммитится, см. .gitignore).
variable "api_token" {
type = string
sensitive = true
description = "Токен Nubes API (test.token)"
}
variable "api_endpoint" {
type = string
default = "https://lk-api-gateway-test.ngcloud.ru/api/v1/svc"
description = "Endpoint Nubes API (TEST). Менять вместе с source провайдера"
}
variable "realm" {
type = string
description = "Ресурсная платформа K8s (должна существовать в ЛК; у PG Инстанса foriot — iot-naeel)"
}
# ── Домены (короткое имя → платформа создаёт DNS в зоне realm; FQDN → проверка A-записи) ──
variable "emqx_domain" {
type = string
default = "exqx-iot"
description = "Домен EMQX (wss MQTT)"
}
variable "iot_domain" {
type = string
default = "iot-api"
description = "Домен iot-service (REST)"
}
# ── Реестр ──
variable "registry" {
type = string
default = "gitea.services.ngcloud.ru/nail"
description = "Gitea container registry (namespace nail)"
}
variable "iot_service_version" {
type = string
default = "v0.1.13"
}
variable "iot_emqx_version" {
type = string
default = "v0.2.4"
}
# ── PostgreSQL (managed, инстанс foriot 509145c3) ──
variable "pg_host" {
type = string
default = "postgresqlk8s-master.509145c3-ba71-4687-a084-d9dcbc88d3d2.svc.cluster.local"
}
variable "pg_port" {
type = string
default = "5432"
}
variable "pg_db" {
type = string
default = "iotdb"
}
variable "pg_user" {
type = string
default = "iotservice"
description = "PG-пользователь (платформенный regex ^[A-Za-z0-9]+$ — подчёркивание нельзя)"
}
variable "pg_password" {
type = string
sensitive = true
}
# ── shared-SQS (тенант demo-service) ──
variable "sqs_endpoint" {
type = string
default = "https://sqs.sinefo.ru"
description = "Публичный endpoint shared-sqs (на ВМ 213, не в кластере)"
}
variable "sqs_access_key" {
type = string
sensitive = true
}
variable "sqs_secret_key" {
type = string
sensitive = true
}
variable "sqs_queue_name" {
type = string
default = "iot-telemetry"
}
variable "sqs_dlq_name" {
type = string
default = "iot-telemetry-dlq"
}
# ── MQTT (bridge iot-service → EMQX) ──
variable "mqtt_username" {
type = string
default = "iot-bridge"
}
variable "mqtt_password" {
type = string
sensitive = true
}
variable "mqtt_host" {
type = string
default = ""
description = "Внутренний адрес EMQX. Пусто → вычисляется из state_out EMQX (проверить в ЛК при первом apply)"
}
# ── JWT API ──
variable "jwt_hmac_secret" {
type = string
sensitive = true
}
variable "admin_stats_token" {
type = string
sensitive = true
default = ""
}
@@ -1,6 +1,3 @@
# ⛔⛔⛔ ЛЕГАСИ (2026-08-16) — СТАРЫЙ IoT (k8s-деплой, Kafka). НЕ ПРИНИМАТЬ ВО ВНИМАНИЕ.
# Актуальное: HISTORY/2026-08-16-session-log.md
# Создано: 2026-04-06 # Создано: 2026-04-06
# Deployment iot-kafka-consumer — читает IoT телеметрию из Kafka → пишет в IoT Postgres. # Deployment iot-kafka-consumer — читает IoT телеметрию из Kafka → пишет в IoT Postgres.
# #
+71
View File
@@ -0,0 +1,71 @@
# Создано: 2026-04-04
# Изменено: 2026-04-06 (MQTT→Kafka: убран RABBITMQ_URL, добавлен KAFKA_BROKERS, v0.1.67)
# Deployment iot-mqtt-bridge — MQTT→Kafka мост для IoT.
#
# Получает MQTT сообщения от EMQX (подписка на "+/telemetry/+")
# и публикует в Kafka топик "iot.telemetry" (ключ = namespace).
#
# Credentials для MQTT подключения берутся из Secret iot-bridge-credentials.
# Этот Secret нужно создать вручную ДО деплоя:
#
# # 1. Создать IoTDevice для bridge через API:
# curl -X POST .../v1/namespaces/sless-bridge/iot/devices \
# -d '{"name":"bridge","device_id":"bridge","enabled":true}'
#
# # 2. Получить credentials:
# MQTT_USERNAME=$(kubectl get secret iot-bridge -n sless-bridge -o jsonpath='{.data.mqtt-username}' | base64 -d)
# MQTT_PASSWORD=$(kubectl get secret iot-bridge -n sless-bridge -o jsonpath='{.data.mqtt-password}' | base64 -d)
#
# # 3. Создать Secret для bridge Deployment (один раз):
# kubectl create secret generic iot-bridge-credentials -n sless \
# --from-literal=MQTT_USERNAME="$MQTT_USERNAME" \
# --from-literal=MQTT_PASSWORD="$MQTT_PASSWORD"
#
# Применение: kubectl apply -f deployments/k8s/iot-mqtt-bridge.yaml
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: iot-mqtt-bridge
namespace: sless
labels:
app: iot-mqtt-bridge
spec:
replicas: 1
selector:
matchLabels:
app: iot-mqtt-bridge
template:
metadata:
labels:
app: iot-mqtt-bridge
spec:
containers:
- name: mqtt-bridge
# Тот же образ что и оператор — оба бинаря в одном слое (manager + iot-mqtt-bridge).
# При смене версии оператора — менять тег и здесь.
image: pearlharbor.registryk8s.services.ngcloud.ru/naeel/sless-operator:v0.1.69
imagePullPolicy: Always
command: ["/iot-mqtt-bridge"]
env:
- name: MQTT_BROKER_URL
value: "tcp://emqx.sless.svc:1883"
- name: KAFKA_BROKERS
value: "kafka.sless.svc.cluster.local:9092"
envFrom:
- secretRef:
name: iot-bridge-credentials
# IOT_PG_DSN — сохранение телеметрии в Postgres (опционально)
- secretRef:
name: iot-postgres-secret
optional: true
resources:
requests:
memory: "32Mi"
cpu: "25m"
limits:
memory: "64Mi"
cpu: "100m"
imagePullSecrets:
- name: sless-registry-auth
@@ -1,6 +1,3 @@
# ⛔⛔⛔ ЛЕГАСИ (2026-08-16) — СТАРЫЙ IoT (k8s-деплой, Kafka). НЕ ПРИНИМАТЬ ВО ВНИМАНИЕ.
# Актуальное: HISTORY/2026-08-16-session-log.md
# Изменено: 2026-04-06 (добавлен postStart hook для предсоздания топика iot.telemetry) # Изменено: 2026-04-06 (добавлен postStart hook для предсоздания топика iot.telemetry)
# kafka.yaml — минимальный деплой Apache Kafka в KRaft mode (без Zookeeper). # kafka.yaml — минимальный деплой Apache Kafka в KRaft mode (без Zookeeper).
# Образ: apache/kafka (официальный, бесплатный). # Образ: apache/kafka (официальный, бесплатный).
@@ -1,5 +1,3 @@
> ⛔⛔⛔ ЛЕГАСИ (2026-08-16) — СТАРЫЙ IoT (k8s-деплой). НЕ ПРИНИМАТЬ ВО ВНИМАНИЕ. Актуальное: HISTORY/2026-08-16-session-log.md
# IoT API Endpoints # IoT API Endpoints
## Публичные (без auth) ## Публичные (без auth)
@@ -32,25 +30,3 @@
| Метод | Путь | Описание | | Метод | Путь | Описание |
|-------|------|----------| |-------|------|----------|
| GET | `/iot-admin/stats` | JSON статистика (PG, Kafka lag, pods) | | GET | `/iot-admin/stats` | JSON статистика (PG, Kafka lag, pods) |
---
## Обновление 2026-04-12: admin stats → SQS
> Kafka lag заменён на SQS queue stats. Endpoint тот же, формат ответа изменён.
### `/iot-admin/stats` — актуальный формат ответа
```json
{
"sqs": {
"queue_url": "http://us-east-1.goaws.com:4100/t-96afe7e9f781f6ca/iot-telemetry",
"approximate_messages": "0",
"approximate_messages_not_visible": "0"
},
"sqs_consumer_pods": [...],
"pg_tenants": [...]
}
```
Вместо `kafka_lag` теперь `sqs.approximate_messages` — количество сообщений в очереди, ожидающих обработки.
+66
View File
@@ -0,0 +1,66 @@
# IoT Managed Service — Архитектура
## Общая схема
```
IoT Device (MQTT CONNECT)
│ username="{ns}_{deviceId}", password=hex
▼
EMQX 5.5.1 (sless/emqx)
│ POST /internal/mqtt/auth → iot-operator:9090
│ POST /internal/mqtt/acl → iot-operator:9090
▼
│ MQTT PUBLISH → topic: "{ns}/telemetry/{deviceId}"
▼
iot-mqtt-bridge (cmd/mqtt-bridge)
│ подписка на "+/telemetry/+"
│ → Kafka topic "iot.telemetry"
▼
Kafka
▼
iot-kafka-consumer (cmd/kafka-consumer)
│ consumer group "iot-pg-consumer"
│ → per-tenant Postgres DB
▼
IoT Postgres (iot-postgres.sless.svc)
│ DB: tenant_{namespace}
│ Table: iot_telemetry
▼
REST API (iot-operator:9090)
│ GET /v1/namespaces/{ns}/iot/telemetry
▼
Пользователь (Terraform / UI Console)
```
## Компоненты
| Компонент | Бинарник | Порт | Назначение |
|-----------|----------|------|------------|
| iot-operator | cmd/iot-operator | :9090 (API), :8081 (health) | Controller-manager + REST API |
| mqtt-bridge | cmd/mqtt-bridge | — | MQTT→Kafka bridge |
| kafka-consumer | cmd/kafka-consumer | — | Kafka→Postgres pipeline |
## CRD
- **IoTDevice** (`iot.kube5s.ru/v1alpha1`)
- Создание: пользователь через API / Terraform
- Controller генерирует MQTT credentials → k8s Secret
- Secret удаляется каскадно через OwnerReference
## Хранение
- **IoT Postgres** — отдельный от sless PG
- Management DB: `iot_platform` (таблица `tenant_credentials`)
- Per-tenant DB: `tenant_{namespace}` (таблица `iot_telemetry`)
## Аутентификация
- REST API (/v1/): Bearer JWT (middleware.Auth)
- MQTT Auth (/internal/): вызывается EMQX, без JWT
- Admin (/iot-admin/stats): ADMIN_STATS_TOKEN
## Docker образ
- `naeel/iot-operator` (Docker Hub)
- Все 3 бинарника в одном образе
- `command: ["/iot-operator"]` или `["/mqtt-bridge"]` или `["/kafka-consumer"]`
@@ -1,5 +1,3 @@
> ⛔⛔⛔ ЛЕГАСИ (2026-08-16) — СТАРЫЙ IoT (k8s-деплой). НЕ ПРИНИМАТЬ ВО ВНИМАНИЕ. Актуальное: HISTORY/2026-08-16-session-log.md
# Решение: Вынос IoT в отдельную репу # Решение: Вынос IoT в отдельную репу
**Дата:** 2026-04-12 **Дата:** 2026-04-12
-167
View File
@@ -1,167 +0,0 @@
# Деплой IoT на платформе Nubes без kubectl (чужой кластер)
**Версии на 2026-08-16:** `gitea.services.ngcloud.ru/nail/iot-emqx:v0.2.4`, `gitea.services.ngcloud.ru/nail/iot-service:v0.1.2`.
Образы — в **Gitea container registry** (`gitea.services.ngcloud.ru`), не на Docker Hub
(managed-платформа тянет образы из Gitea; логин — `docker login gitea.services.ngcloud.ru`).
Каждый образ имеет РОВНО один EXPOSE
(EMQX — 8083, iot-service — 9090). Поле порта в deck отсутствует — порт
берётся из EXPOSE образа.
Процедура не требует kubectl: вся конфигурация задаётся env в deck-UI при
создании контейнеров. EMQX авторизует устройства напрямую в PostgreSQL
(таблица `iot_devices`) — адрес монолита ему НЕ нужен (в этом суть PG-auth,
см. HISTORY секцию 27).
---
## 0. Предусловия
1. **PostgreSQL managed** (инстанс платформы):
- роль `iot_service` (LOGIN) с правами **CREATEROLE + CREATEDB**
(нужны iotpg для per-tenant БД);
- база `iotdb` (OWNER iot_service);
- **SSL обязателен** (pg_hba платформы: без шифрования соединение
отклоняется) — в DSN `sslmode=require`, в EMQX `ssl.enable=true`.
2. **shared-SQS**: тенант с креды (AccessKey/SecretKey) и очередь
`iot-telemetry`. Внутренний endpoint тенанта вшит дефолтом в образ
iot-service (см. config/defaults.go) — для чужого кластера уточнить
внутренний адрес shared-sqs и задать через `SQS_ENDPOINT`.
3. **Имена доменов** выбрать заранее (задаются в deck при создании
контейнера), например: `iot.containerk8s.dev.nubes.ru` (API) и
`exqx.containerk8s.dev.nubes.ru` (MQTT wss).
## 1. PostgreSQL (разово)
Подключаться к master инстанса (`postgresqlk8s-master.<instance-uid>.
svc.cluster.local:5432`) из консоли/порт-форварда:
```sql
CREATE ROLE iot_service LOGIN PASSWORD '<сгенерировать>';
ALTER ROLE iot_service CREATEROLE CREATEDB;
-- БД создаётся с owner iot_service (ddl_user не может SET ROLE напрямую):
GRANT iot_service TO <текущая роль>;
CREATE DATABASE iotdb OWNER iot_service;
REVOKE iot_service FROM <текущая роль>;
```
Таблицы (`iot_devices`, per-tenant БД телеметрии) создаются кодом
автоматически при старте iot-service.
## 2. EMQX (создаётся ПЕРВЫМ)
Тип «Простой HTTP контейнер», образ `naeel/iot-emqx:v0.2.4`
(или `latest` — но см. раздел 5 про обновления). CPU **≥ 1000m**
(на квоте 500m загрузка 5–10 мин, dashboard падает с таймаутами).
Env (ВСЕ при создании — после создания env не меняются):
| Переменная | Значение |
|---|---|
| `EMQX_AUTHENTICATION__1__SERVER` | `postgresqlk8s-master.<uid>.svc.cluster.local:5432` |
| `EMQX_AUTHENTICATION__1__DATABASE` | `iotdb` |
| `EMQX_AUTHENTICATION__1__USERNAME` | `iot_service` |
| `EMQX_AUTHENTICATION__1__PASSWORD` | пароль роли |
| `EMQX_AUTHORIZATION__SOURCES__1__SERVER` | как AUTHENTICATION |
| `EMQX_AUTHORIZATION__SOURCES__1__DATABASE` | `iotdb` |
| `EMQX_AUTHORIZATION__SOURCES__1__USERNAME` | `iot_service` |
| `EMQX_AUTHORIZATION__SOURCES__1__PASSWORD` | пароль роли |
| `EMQX_LOG__CONSOLE_HANDLER__LEVEL` | `warning` |
⚠ НЕ задавать `EMQX_AUTHENTICATION__1__URL` / `EMQX_AUTHORIZATION__SOURCES__1__URL`
(это HTTP-auth старой схемы — уронит под с `unknown_fields "url"`).
⚠ `${VAR}` в конфиге не интерпретируется; только официальные env-переопределения.
Загрузка на CPU ≥1000m ~2 мин. Проверка: `curl https://<emqx-домен>/mqtt`
должен вернуть `400` (Cowboy без subprotocol — норма); wss-рука через
клиент с subprotocol `mqtt`.
После создания получить из консоли deck **UUID namespace EMQX** — он нужен
на шаге 3.
## 3. iot-service (создаётся ВТОРЫМ)
Образ `naeel/iot-service:v0.1.2`, CPU ≥ 500m, память ≥ 256Mi.
Env:
| Переменная | Значение | Обязательна |
|---|---|---|
| `IOT_PG_DSN` | `postgresql://iot_service:<пароль>@postgresqlk8s-master.<uid>.svc.cluster.local:5432/iotdb?sslmode=require` | да |
| `SQS_ACCESS_KEY` | AccessKey тенанта shared-sqs | да |
| `SQS_SECRET_KEY` | SecretKey тенанта shared-sqs | да |
| `MQTT_USERNAME` | `iot-bridge` | да |
| `MQTT_PASSWORD` | пароль бриджа (тот же, что в строке `__bridge` в iot_devices) | да |
| `MQTT_HOST` | `containerk8s.<uuid-emqx>.svc.cluster.local` | да (иначе дефолт `emqx` не резолвится) |
| `ADMIN_STATS_TOKEN` | токен для `/iot-admin/stats` | нет |
| `AUTH_TEST_MODE` | `false` | нет (дефолт false) |
Остальные дефолты вшиты в образ (API_PORT=9090, SQS_ENDPOINT, SQS_QUEUE_NAME,
SQS_REGION, long-poll 20с, visibility 30с, MQTT_PORT=8083, MQTT_WS_PATH=/mqtt,
MQTT_CLIENT_ID=iot-bridge, LOG_LEVEL=info).
Строку бриджа (`namespace='__bridge'`, `device_id=iot-bridge`,
`mqtt_password=MQTT_PASSWORD`) монолит создаёт сам при старте
(EnsureBridgeDevice) — вручную ничего не создавать.
Проверка: `GET https://<iot-домен>/health` → `{"status":"ok","version":"..."}`.
Бридж в логах: `bridge: MQTT connected` + `bridge: subscribed`.
## 4. Устройства (регистрация)
Устройство создаётся через API (JWT Bearer; структурная проверка sub+exp,
подпись на этом уровне не проверяется — периметр обеспечивает платформа):
```bash
# токен для API (sub+exp):
TOKEN=$(python3 -c "import base64,json,time;h=base64.urlsafe_b64encode(b'{\"alg\":\"none\",\"typ\":\"JWT\"}').rstrip(b'=').decode();p=base64.urlsafe_b64encode(json.dumps({'sub':'admin','exp':int(time.time())+86400}).encode()).rstrip(b'=').decode();print(h+'.'+p+'.sig')")
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"dev1","device_id":"dev-001"}' \
https://<iot-домен>/v1/namespaces/<ns>/iot/devices
```
Ответ содержит `mqtt_username` = `<ns>_<device_id>` и `mqtt_password` — пароль
отдаётся ТОЛЬКО здесь, один раз (в БД хранится bcrypt-хэш, см. P0-3). GET пароль
НЕ возвращает — сохраните его сразу.
Устройство подключается:
- URL: `wss://<emqx-домен>/mqtt`, подпротокол `mqtt`;
- username `<ns>_<device_id>`, password из ответа API;
- публикация: топик `<ns>/telemetry/<device_id>`;
- подписка разрешена только на свой топик (ACL).
Негативные auth-тесты (должны быть отказ): неверный пароль → CONNACK rc=4;
неизвестный username → rc=5. Проверять через on_connect rc (rc из
`paho.connect()` всегда 0 даже при отказе!).
Телеметрия: `GET /v1/namespaces/<ns>/iot/telemetry?limit=100`
(тоже JWT Bearer).
## 5. Обновления образов
Платформа кэширует `latest` зеркалом, а путь к образу фиксируется при
создании контейнера. Без kubectl обновление = **пересоздание контейнера в
deck с конкретным тегом** (`naeel/iot-emqx:v0.2.4`, `naeel/iot-service:v0.1.2`).
В своём кластере (есть kubectl): `kubectl -n <ns> set image
deployment/containerk8s app=<image>:<tag>` — зеркало подтянет уникальный тег.
## 6. Диагностика (кратко)
- EMQX не поднялся: `kubectl logs` (если доступен) или `curl` домена —
503 = под лежит; schema-ошибки видны в логах запуска
(`failed_to_check_schema`, `unknown_fields`).
- Устройство не авторизуется: включить `EMQX_LOG__CONSOLE_HANDLER__LEVEL=debug`,
смотреть `authenticator_result`/`authentication_result` и CONNACK ReasonCode.
- Бридж отваливается: в логах монолита `bridge: ...`; после рестарта EMQX
бридж переподписывается автоматически (фикс resubscribe в OnConnectHandler,
HISTORY секция 26).
- Телеметрия не доходит: цепочка устройство → EMQX → бридж → SQS
`iot-telemetry` → consumer → PG; сверять счётчики по HISTORY секции 26.4.
## 7. Известные ограничения платформы
- Edge-шлюз платформы рвёт wss ~150с (тикет Nubes подготовлен) — внешние
устройства должны переподключаться; внутри кластера (бридж) проблем нет.
- Внешний путь к SQS — таймауты ~31–33с (~5.5% запросов) и MSS-проблема;
монолит использует внутренний endpoint shared-sqs.
- env фиксируются при создании контейнера; ошиблись — пересоздать.
EOF
@@ -1,5 +1,3 @@
> ⛔⛔⛔ ЛЕГАСИ (2026-08-16) — СТАРЫЙ IoT (k8s-деплой). НЕ ПРИНИМАТЬ ВО ВНИМАНИЕ. Актуальное: HISTORY/2026-08-16-session-log.md
# IoT MVP — Инженерная документация деплоя # IoT MVP — Инженерная документация деплоя
> Создано: 2026-04-04 > Создано: 2026-04-04
@@ -306,101 +304,3 @@ node {
|--------|------|-----------| |--------|------|-----------|
| v0.1.50 | 2026-04-04 | IoT controller + IoT API + iot-mqtt-bridge бинарь | | v0.1.50 | 2026-04-04 | IoT controller + IoT API + iot-mqtt-bridge бинарь |
| v0.1.49 | ранее | До IoT | | v0.1.49 | ранее | До IoT |
---
## Актуальный деплой (2026-04-12)
> Кластер: iot-naeel (новый). Kafka удалён. Postgres — managed. SQS вместо Kafka.
### Кластер iot-naeel
- API: `https://185.247.187.149:6443`
- Ingress IP: `185.247.187.151`
- DNS: `iot.kube5s.ru → 185.247.187.147`
- kubeconfig: `~/.kube/config` на ВМ (токен с TTL 24ч, обновляется через auth.k8s.ngcloud.ru)
### Namespace sless — содержимое
| Ресурс | Имя | Описание |
|--------|-----|----------|
| Deployment | emqx | MQTT-брокер EMQX 5.5.1 |
| Deployment | iot-operator | Controller-manager + REST API v0.2.0 |
| Deployment | iot-mqtt-bridge | MQTT → shared-SQS bridge v0.2.0 |
| Deployment | iot-sqs-consumer | shared-SQS → Postgres consumer v0.2.0 |
| Service | emqx | :1883 (MQTT), :8083 (WS), :18083 (dashboard) |
| Service | emqx-ws | :8083 (для Ingress) |
| Service | iot-operator | :9090 (REST API) |
| Ingress | emqx-mqtt-websocket | iot.kube5s.ru → /mqtt (WS), /console (UI) |
| Secret | iot-sqs-credentials | SQS_ENDPOINT, SQS_ACCESS_KEY, SQS_SECRET_KEY |
| Secret | iot-postgres-secret | POSTGRES_USER, POSTGRES_PASSWORD, IOT_PG_DSN |
| Secret | iot-bridge-credentials | MQTT_USERNAME, MQTT_PASSWORD |
| CRD | iotdevices.iot.kube5s.ru | IoTDevice v1alpha1 |
| ServiceAccount | iot-operator | + ClusterRole + ClusterRoleBinding |
### Процедура деплоя (актуальная)
```bash
# 0. SSH на ВМ (все команды оттуда)
ssh -i ~/.ssh/id_ed25519 naeel@5.172.178.213
# 1. Сборка и push образа
cd /home/naeel/terra/IoT
docker build -t naeel/iot-operator:v0.2.0 -t naeel/iot-operator:latest .
docker push naeel/iot-operator:v0.2.0
docker push naeel/iot-operator:latest
# 2. CRD (один раз)
kubectl apply -f config/crd/bases/iot.kube5s.ru_iotdevices.yaml
# 3. Postgres Secret (содержит DSN managed PG)
kubectl apply -f deployments/k8s/iot-postgres.yaml
# 4. EMQX
kubectl apply -f deployments/k8s/emqx.yaml
kubectl apply -f deployments/k8s/emqx-ws-ingress.yaml
# 5. Operator
kubectl apply -f deployments/k8s/iot-operator.yaml
# 6. SQS credentials (уже создан через kubectl create secret)
# kubectl get secret iot-sqs-credentials -n sless
# 7. MQTT bridge credentials (уже создан)
# kubectl get secret iot-bridge-credentials -n sless
# 8. Bridge + Consumer
kubectl apply -f deployments/k8s/iot-mqtt-bridge.yaml
kubectl apply -f deployments/k8s/iot-sqs-consumer.yaml
# 9. Проверка
kubectl get pods -n sless
kubectl logs -n sless deployment/iot-operator --tail=10
kubectl logs -n sless deployment/iot-mqtt-bridge --tail=10
kubectl logs -n sless deployment/iot-sqs-consumer --tail=10
```
### Managed PostgreSQL
- Namespace: `dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5`
- Pod: `postgresqlk8s-0`
- Host: `postgresqlk8s-master.dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5.svc.cluster.local`
- User: `super`, DB: `sqsdb`, PG 17
- Тот же инстанс что использует shared-SQS для billing
- Credentials: см. `/home/naeel/terra/SQS-service/secrets/iot_pg.md`
### shared-SQS (очередь вместо Kafka)
- Namespace: `shared-sqs`
- Endpoint: `https://qu.kube5s.ru`
- Tenant ID: `t-96afe7e9f781f6ca` (iot-service)
- Queue: `iot-telemetry`
- Access Key: хранится в Secret `iot-sqs-credentials` namespace `sless`
- Admin token: хранится в Secret `shared-sqs-admin` namespace `shared-sqs`
### Версии образов (актуальные)
| Версия | Дата | Registry | Изменения |
|--------|------|----------|-----------|
| v0.2.0 | 2026-04-12 | Docker Hub naeel/iot-operator | Kafka→SQS, managed PG, новый кластер |
| v0.1.50 | 2026-04-04 | pearlharbor (Harbor) | IoT controller + API + mqtt-bridge (Kafka) |
-88
View File
@@ -1,88 +0,0 @@
# Подключение устройства к IoT (Nubes, тест-стенд)
Проверено end-to-end 2026-10-11 на развёрнутом стенде
(`https://iot-api.containerk8s.dev.nubes.ru`, `wss://exqx-iot.containerk8s.dev.nubes.ru/mqtt`).
## 1. Регистрация устройства (HTTP API, JWT HS256)
JWT подписывается `JWT_HMAC_SECRET` (HS256), claim `sub` = ваш namespace,
`exp` обязателен; `namespace` в пути **обязан совпадать** с `sub`, иначе `403`.
```bash
curl -X POST "$API/v1/namespaces/$NS/iot/devices" \
-H "Authorization: Bearer $JWT" -H 'Content-Type: application/json' \
-d '{"name":"dev1","device_id":"d-01"}'
```
Ответ `201` содержит **единственный раз** выдаваемый пароль:
```json
{"name":"dev1","namespace":"e2e-nubes","device_id":"d-01","enabled":true,
"mqtt_username":"e2e-nubes_d-01","mqtt_password":"<hex>",
"telemetry_topic":"e2e-nubes/telemetry/d-01"}
```
`GET …/devices/{name}` пароль **не** возвращает. Ограничения идентификаторов:
`^[a-zA-Z0-9][a-zA-Z0-9-]*$`, namespace ≤ 56, name/device_id ≤ 64 символов
(иначе `400`). Дубликаты name/device_id → `409`.
## 2. Подключение по MQTT
- URL: `wss://exqx-iot.containerk8s.dev.nubes.ru/mqtt` (WebSocket, subprotocol `mqtt`);
внутри кластера — `ws://containerk8s.<uuid-инстанса EMQX>.svc.cluster.local:8083/mqtt`.
- username = `namespace_device_id`, пароль — из ответа на создание.
- Публикация: топик `namespace/telemetry/device_id` (`telemetry_topic`).
ACL: устройству разрешён **только свой** топик. Публикация/подписка куда-либо ещё
(чужой namespace, другое устройство, wildcard `+`/`#`) → `deny_action=disconnect`,
соединение рвётся. Также запрещены: retained-публикации
(`retain_available=false`), топики глубже 10 уровней, subscribe на
`+/telemetry/+` (это привилегия бриджа).
Коды CONNACK: `0` — успех, `4` — неверный username/пароль, `5` — не авторизован
(в т.ч. устройство выключено через `PATCH {"enabled": false}`).
## 3. Требование к payload — ВАЖНО
Дедупликация выполняется по **содержимому сообщения**:
`message_id = SHA256(namespace | device_id | topic | payload)`
(`internal/service/bridge/handler.go`). Поэтому:
- **два побайтово одинаковых payload подряд считаются дубликатом и сохраняются
один раз** (это защита от повторной доставки SQS at-least-once);
- ⇒ в payload обязателен **timestamp или счётчик**, иначе легитимные повторные
одинаковые показания (например, `{"t":21.0}` каждую минуту) будут отброшены.
Пример корректного payload:
```json
{"temp": 21.5, "ts": 1791737028, "seq": 4211}
```
Payload валидный JSON сохраняется как JSON; не-JSON (текст, бинарь) оборачивается
в строку. Лимиты: MQTT-пакет ≤ 1 МБ (`max_packet_size`), envelope для SQS ≤ 250 КБ
(`maxSQSBodyBytes`) — сообщения больше отбрасываются мостом.
QoS: `QoS0` — «выстрелил и забыл»; для гарантии доставки используйте `QoS1`
(дождитесь PUBACK перед закрытием соединения).
## 4. Чтение телеметрии
```bash
curl "$API/v1/namespaces/$NS/iot/telemetry?device_id=d-01&limit=50&offset=0" \
-H "Authorization: Bearer $JWT"
```
`limit` — дефолт 50, потолок 1000. Ответ:
`{"count":N,"items":[{"id":…,"device_id":…,"ts":…,"payload":…}]}`.
⚠️ Чтение отдаёт payload целиком, поэтому `limit=1000` при крупных payload'ах
дорого для клиента и сервиса — для больших объёмов берите меньше и
используйте `offset`.
## 5. Наблюдаемое поведение (e2e, 2026-10-11)
Задержка «MQTT → видно в API» недетерминирована и доходит до ~20 с: потребитель
SQS работает long-poll окном (`SQS_LONG_POLL_SECONDS=20`), батч пишется в PG и
только потом удаляется из очереди. Для тестов/интеграции — опрашивать с
таймаутом ≥ 30 с, а не фиксированной паузой 1–5 с.
@@ -1,5 +1,3 @@
> ⛔⛔⛔ ЛЕГАСИ (2026-08-16) — СТАРЫЙ IoT (k8s-деплой). НЕ ПРИНИМАТЬ ВО ВНИМАНИЕ. Актуальное: HISTORY/2026-08-16-session-log.md
# IoT MVP — План реализации для Sonnet # IoT MVP — План реализации для Sonnet
> **Автор плана**: GitHub Copilot (Claude Opus 4.6) > **Автор плана**: GitHub Copilot (Claude Opus 4.6)
-100
View File
@@ -1,100 +0,0 @@
# IoT на Nubes — чек-лист деплоя (актуально на 2026-10-11)
> Детальная процедура — `deployment-nubes-production.md`. Здесь — конкретные
> шаги текущего стенда. Секреты НЕ хранятся в репо (`*.key` в `.gitignore`);
> значения `<>` берутся из `pg.key`/deck/генератора.
## 0. Готово
- **Образы в Gitea** (`gitea.services.ngcloud.ru/nail`):
- `iot-service:v0.1.13` (+ latest)
- `iot-emqx:v0.2.5` (+ latest), `iot-service:v0.1.13` (+ latest)
⚠️ **Образ обязан иметь РОВНО ОДИН `EXPOSE`** — платформа Nubes отказывает в создании
контейнера с «Не указан applicationUsePort…», если EXPOSE несколько. У `emqx/emqx:5.5.1`
их 7 → образ собирается через `FROM scratch` + `COPY --from=base / /` (EXPOSE не
наследуется), остаётся только `8083`. Параметра `applicationUsePort` у провайдера nubes
3.1.0 нет — обойти на стороне Terraform нельзя, только пересборкой образа.
- **shared-SQS** тенант `demo-service` (`SSAK-b8fb2b3000c178b865091686`, регион `us-east-1`):
- очередь `iot-telemetry` — создана;
- DLQ `iot-telemetry-dlq` — создана.
- публичный endpoint: `https://sqs.sinefo.ru`.
- **PostgreSQL** managed: хост `postgresqlk8s-master.509145c3-ba71-4687-a084-d9dcbc88d3d2.svc.cluster.local:5432` (из `pg.key`).
✅ **создано через Nubes API (test):** пользователь `iotservice` (role `ddl_user` → CREATEDB+CREATEROLE), БД `iotdb` (owner `iotservice`). Пароль `iotservice` — в vault инстанса.
## 1. PostgreSQL — ✅ ГОТОВО
Пользователь `iotservice` (role `ddl_user`, права CREATEDB+CREATEROLE) и БД `iotdb` (owner `iotservice`)
созданы через Nubes API (test): операции `create_user` (svcOperationId 241) и `create_database` (245)
на инстансе `509145c3-…`. ⚠ Гранулярность: имя PG-пользователя подчиняется платформенному regex
`^[A-Za-z0-9]+$` → `iot_service` НЕЛЬЗЯ (только `iotservice`).
Пароль: GET `/instances/509145c3-…/vault/users` (Nubes API, test) → поле `iotservice.password`.
Подключение:
```
IOT_PG_DSN = postgresql://iotservice:<PG_PASS>@postgresqlk8s-master.509145c3-ba71-4687-a084-d9dcbc88d3d2.svc.cluster.local:5432/iotdb?sslmode=require
```
Таблицы (`iot_devices`, per-tenant БД телеметрии) создаются кодом при старте.
## 2. EMQX-контейнер (создаётся ПЕРВЫМ, CPU ≥ 1000m)
Образ: `gitea.services.ngcloud.ru/nail/iot-emqx:v0.2.5`, порт из EXPOSE = `8083`.
Env (все — при создании, потом не меняются):
| Переменная | Значение |
|---|---|
| `EMQX_AUTHENTICATION__1__SERVER` | `postgresqlk8s-master.509145c3-ba71-4687-a084-d9dcbc88d3d2.svc.cluster.local:5432` |
| `EMQX_AUTHENTICATION__1__DATABASE` | `iotdb` |
| `EMQX_AUTHENTICATION__1__USERNAME` | `iotservice` |
| `EMQX_AUTHENTICATION__1__PASSWORD` | `<PG_PASS>` |
| `EMQX_AUTHORIZATION__SOURCES__1__SERVER` | как AUTHENTICATION |
| `EMQX_AUTHORIZATION__SOURCES__1__DATABASE` | `iotdb` |
| `EMQX_AUTHORIZATION__SOURCES__1__USERNAME` | `iotservice` |
| `EMQX_AUTHORIZATION__SOURCES__1__PASSWORD` | `<PG_PASS>` |
| `EMQX_LOG__CONSOLE_HANDLER__LEVEL` | `warning` |
⚠ Не задавать `...__URL` (HTTP-auth старой схемы — уронит под). SSL обязателен.
После создания — записать **UUID namespace EMQX** (нужен в `MQTT_HOST`).
⚠️ Внутренний Service в namespace платформы называется **`containerk8s`**, НЕ `iot-emqx`
(проверено 2026-10-11):
- `containerk8s.<instanceUid>.svc.cluster.local` → резолвится ✅
- `iot-emqx.<instanceUid>.svc.cluster.local` → NXDOMAIN ❌
⚠️ Роль-владелец таблиц: в БД `iotdb` таблицы `iot_devices`/`tenant_credentials` были созданы
ролью `iot_service` (деплой 2026-08-16). Приложению нужен ВЛАДЕЛЕЦ (иначе
`store: init schema: pq: must be owner of table iot_devices (42501)` → под падает, платформа
откатывает установку). Владение передано роли `iotservice` 2026-10-11:
`ALTER TABLE public.iot_devices OWNER TO iotservice;` + `tenant_credentials` + индексы.
## 3. iot-service-контейнер (создаётся ВТОРЫМ)
Образ: `gitea.services.ngcloud.ru/nail/iot-service:v0.1.13`, порт из EXPOSE = `9090`.
Env (обязательные — секреты; необязательные имеют дефолты в образе):
| Переменная | Значение |
|---|---|
| `IOT_PG_DSN` | `postgresql://iotservice:<PG_PASS>@postgresqlk8s-master.509145c3-ba71-4687-a084-d9dcbc88d3d2.svc.cluster.local:5432/iotdb?sslmode=require` |
| `SQS_ACCESS_KEY` | `SSAK-b8fb2b3000c178b865091686` |
| `SQS_SECRET_KEY` | `<из Admin API / лога сервиса>` |
| `MQTT_USERNAME` | `iot-bridge` (device_id строки бриджа) |
| `MQTT_PASSWORD` | `<сгенерировать>` |
| `JWT_HMAC_SECRET` | `<сгенерировать>` |
| `SQS_ENDPOINT` | `https://sqs.sinefo.ru` (не дефолт: shared-sqs НЕ в кластере, а на ВМ 213) |
| `SQS_QUEUE_NAME` | `iot-telemetry` (дефолт) |
| `SQS_DLQ_NAME` | `iot-telemetry-dlq` (дефолт) |
| `MQTT_HOST` | `containerk8s.<UUID namespace EMQX из шага 2>.svc.cluster.local` (см. предупреждение в шаге 2) |
| `MQTT_PORT` | `8083` (дефолт) |
| `AUTH_TEST_MODE` | `false` |
При старте монолит: подключается к PG, создаёт схему, `EnsureBridgeDevice(MQTT_USERNAME,
MQTT_PASSWORD)` → строка `__bridge/iot-bridge` в `iot_devices` для EMQX-auth.
## 4. Проверка
- `/health` и `/ready` (порт 9090) → 200.
- В логе: `bridge: MQTT connected`, `bridge: subscribed`, `consumer: SQS queue resolved`.
- EMQX: `curl https://<emqx-домен>/mqtt` → 400 (норма). wss-рука — клиент с subprotocol `mqtt`.
+37
View File
@@ -0,0 +1,37 @@
# IoT Managed Service — Трекер прогресса
## 2026-04-12: Перенос из sless в отдельную репу
### Выполнено
- [x] Создана репа https://gitea.services.ngcloud.ru/Nail/IoT
- [x] Go модуль: `gitea.services.ngcloud.ru/Nail/IoT`
- [x] Перенесены все IoT-компоненты из sless:
- `iot/api/v1alpha1/` → `api/v1alpha1/` (CRD types: IoTDevice)
- `iot/controllers/` → `controllers/` (IoTDevice reconciler)
- `iot/cmd/mqtt-bridge/` → `cmd/mqtt-bridge/` (MQTT→Kafka)
- `iot/cmd/kafka-consumer/` → `cmd/kafka-consumer/` (Kafka→Postgres)
- `internal/storage/iotpg/` → `internal/storage/iotpg/` (per-tenant PG)
- `internal/api/handler/iot_*.go` → `internal/api/handler/` (REST handlers)
- `internal/api/ui/iot-*.html` → `internal/api/ui/` (embedded HTML)
- `internal/api/middleware/` → `internal/api/middleware/` (auth, logging)
- `deployments/k8s/iot-*.yaml` + `kafka.yaml` → `deployments/k8s/`
- `config/crd/bases/` → `config/crd/bases/`
- `doc/iot/`, `examples/IOT/` → `doc/`, `examples/`
- [x] Созданы новые файлы:
- `cmd/iot-operator/main.go` — точка входа (controller-manager + REST API)
- `internal/api/handler/handler.go` — упрощённый Handler (без S3/PG от sless)
- `internal/api/router.go` — только IoT маршруты
- `Dockerfile` — multi-stage build, 3 бинарника, distroless
- `Makefile` — build/docker/deploy команды
- `.gitignore`
- `.github/copilot-instructions.md`
- [x] Все import paths заменены: `sless/iot/...` → `Nail/IoT/...`
- [x] Все 3 бинарника собираются без ошибок (go build ./...)
- [x] Запушено в Gitea
### Следующие шаги
- [ ] Docker build + push на Docker Hub (`naeel/iot-operator`)
- [ ] Обновить k8s manifests для нового образа
- [ ] Деплой в кластер
- [ ] E2E тест: создание устройства → MQTT → Kafka → Postgres → API
- [ ] Убрать IoT-код из sless (опционально, после подтверждения что всё работает)
-70
View File
@@ -1,70 +0,0 @@
# Интеграция IoT ↔ shared-sqs: состояние и рекомендации
> Составлено 2026-08-16 на основе работ по сервису shared-sqs (SQS-совместимая
> очередь на платформе Nubes). IoT будет использовать shared-sqs так же, как
> сам shared-sqs использует managed-сервисы платформы.
## 1. Что такое shared-sqs (кратко)
- SQS-совместимая очередь (AWS API), Go, multi-tenant, форк GoAWS.
- Версия на проде: **v0.1.35**.
- Внешний endpoint: `https://sqs.containerk8s.dev.nubes.ru` (регион `us-east-1`).
- Внутренний endpoint (для подов в том же кластере Nubes, realm iot-naeel):
`http://containerk8s.f1ffb134-7d16-45bd-8bef-69f6ec8ab33c.svc.cluster.local:4100`
- Реализовано: CreateQueue, GetQueueUrl, ListQueues, SendMessage(+Batch),
ReceiveMessage, DeleteMessage(+Batch), ChangeMessageVisibility(+Batch),
PurgeQueue, DeleteQueue, Get/SetQueueAttributes, TagQueue/Untag/List,
FIFO (порядок + dedup 5 мин), Dead Letter Queues (RedrivePolicy).
- Лимиты: 50 очередей на тенанта, сообщение до 256 КБ, Receive 1–10 сообщений.
## 2. Что сделано и проверено (состояние на 16.08)
- Все тесты зелёные: api_test 22/22, sdk_test 15/15, FIFO/DLQ e2e PASS.
- План проверок выполнен полностью (таймауты сервера, рестарты, long-poll,
CLI в нагрузке, память 2ч, граница лимита 50).
- Нагрузка: 168 591 операция без единой сервисной ошибки.
- Сравнение с Yandex YMQ: shared-sqs быстрее (p50 6–8мс из внутренней сети,
89–90мс из интернета против 60–62мс / 138–150мс у YMQ).
## 3. Известные проблемы ПЛАТФОРМЫ (не сервиса)
1. **Таймауты каждые 31–33с на внешнем пути** (интернет → шлюз Nubes): ~5.5%
запросов не получают ответ 30с. Доказано серверным tcpdump: в момент сбоя
запрос не доходит до пода; внутри кластера потерь нет (port-forward —
24977 раундов, 0 сбоев). Тикет в поддержку Nubes подготовлен.
2. **MSS=1448** на шлюзе при MTU пода 1400/underlay 1450: тела >~1400 байт
могут виснуть ~51с (PMTUD сломан).
## 4. Рекомендации по интеграции IoT → shared-sqs
1. **Использовать AWS SDK** (aws-sdk-go или boto3) с переопределением
endpoint'а. Не писать свой HTTP-клиент.
2. **Ходить по внутренней сети, если IoT на Nubes в том же кластере:**
внутренний URL из п.1 полностью обходит проблемный внешний шлюз —
таймаутов 31–33с и MTU-проблем там нет. Это главная рекомендация.
3. **Клиентские ретраи обязательны:** retries ≥ 3, read_timeout ≥ 30с
(для внешнего пути). При внутреннем пути достаточно дефолтов.
4. **Размер сообщений:** до ~1300 байт — безопасно всегда; большие тела —
только после фикса MSS шлюзом, либо бить на части.
5. **Receive:** VisibilityTimeout ставьте с запасом на обработку (30с+);
`ChangeMessageVisibility(0)` — мгновенный возврат в очередь; long-poll
(WaitTimeSeconds) поддерживается.
6. **FIFO:** обязателен `MessageGroupId`; dedup-окно 5 минут; порядок
гарантирован внутри группы.
7. **DLQ:** настраивается RedrivePolicy (`deadLetterTargetArn`,
`maxReceiveCount`); ARN DLQ должен быть tenant-scoped (как в тестах).
8. **Лимит:** 50 очередей на тенанта — держать список очередей под контролем.
9. **Мониторинг:** `/health` отдаёт `{"status":"ok","version":...}`;
метрики платформы — Grafana (как для остальных managed-сервисов).
## 5. Аутентификация
- Креды тенанта детерминированы: tenantID `t-`+sha256(email)[:8],
AccessKey `SSAK-`+sha256(email)[:12],
SecretKey sha256(email+":shared-sqs:secret-key:v1").
- IoT получит свой тенант/креды через консоль Nubes (как другие сервисы).
## 6. Что осталось до полного прода
- Отправить тикет Nubes (таймауты шлюза + MSS) — единственный блокер для
интернет-клиентов. Для внутренней интеграции IoT блокеров нет.
+59
View File
@@ -0,0 +1,59 @@
# 2026-04-12 — GitHub Copilot (Claude Opus 4.6)
## Задача
Пользователь просит:
1. Просмотреть всё в репе IoT
2. Оценить замену Kafka и RabbitMQ на собственный SQS-service (из ~/terra/SQS-service)
3. Контекст: проект переехал из ~/terra/sless в отдельную репу ~/remote_dev/IoT
## План
1. Изучить текущую структуру IoT репы — что есть, как используется Kafka
2. Посмотреть SQS-service — что он из себя представляет
3. Посмотреть старую репу sless — что оттуда переехало, что осталось
4. Дать краткую оценку замены Kafka/RabbitMQ → SQS
## Ход работы
### Анализ: что нашёл
**IoT — текущее использование Kafka:**
- `cmd/mqtt-bridge/main.go` — MQTT → Kafka writer (топик `iot.telemetry`, библиотека `segmentio/kafka-go`)
- `cmd/kafka-consumer/main.go` — Kafka reader → Postgres (consumer group `iot-pg-consumer`)
- `internal/api/handler/iot_admin_stats_handler.go` — admin UI: показывает Kafka consumer lag
- `cmd/iot-operator/main.go` — передаёт KAFKA_BROKERS в handler
- `deployments/k8s/kafka.yaml` — деплоймент Kafka в кластере
- В документации (deployment.md, iot-mvp-plan.md) упоминается RabbitMQ как изначальный план, но в коде **RabbitMQ нет** — в реальности уже Kafka
**SQS-service (shared-sqs):**
- AWS SQS-совместимый сервис на Go (модуль `shared-sqs`)
- HTTP API с SQS-протоколом: CreateQueue, SendMessage, ReceiveMessage, DeleteMessage и т.д.
- Multi-tenant: TenantStore с auth
- Persistence: Redis (write-through) + in-memory
- Billing через PostgreSQL
- Порт 4100, gorilla/mux
- **Не Kafka-совместимый** — это pull-based очередь (SQS-like), не streaming platform
**RabbitMQ в IoT:** только в документах (plan). В коде не используется.
**shared-SQS (из Gitea README):**
- AWS SQS-совместимый managed сервис — 17 операций SQS
- Уже задеплоен: endpoint `https://qu.kube5s.ru`, Web UI `https://qu.kube5s.ru/ui/`
- Multi-tenant с auth (JWT nubes → auto-create tenant → Access Key / Secret Key)
- Можно использовать через стандартные AWS SDK (Go, Python boto3, Node.js @aws-sdk/client-sqs)
- Persistence: Redis + in-memory
- Billing: PostgreSQL
- Benchmark: конкурентоспособен с Yandex MQ
### Выводы по замене Kafka → shared-SQS
**Что реально нужно сделать в IoT:**
1. `mqtt-bridge`: заменить `kafka.Writer` → AWS SDK SQS `SendMessage` (endpoint = qu.kube5s.ru или внутрикластерный)
2. `kafka-consumer` → переименовать в `sqs-consumer`: заменить `kafka.Reader` → SQS `ReceiveMessage` + `DeleteMessage` polling loop
3. `admin stats handler`: вместо Kafka lag → SQS `GetQueueAttributes` (ApproximateNumberOfMessages)
4. Удалить `deployments/k8s/kafka.yaml`
5. Убрать `segmentio/kafka-go` из go.mod, добавить `aws-sdk-go-v2`
6. Обновить env vars: KAFKA_BROKERS → SQS_ENDPOINT + SQS credentials
7. Обновить документацию
**Плюс:** AWS SDK for Go — стандартная библиотека, код станет проще. shared-SQS уже живой.
**Минус:** polling latency (ReceiveMessage WaitTimeSeconds до 20s) vs Kafka push. Для IoT телеметрии — приемлемо.
-230
View File
@@ -1,230 +0,0 @@
# IoT ↔ SQS: сводка всех находок и мыслей (handoff для нового чата/репы)
> Дата: 2026-10-05.
> Назначение: полностью самодостаточный документ — чтобы, открыв его в НОВОЙ
> репе или новом чате, можно было продолжить без чтения истории.
> Вся информация ниже взята из реального кода/доков репозитория IoT
> (`/home/naeel/nubes/IoT`), а НЕ из памяти и НЕ из чужих заметок.
---
## 0. КЛЮЧЕВОЙ РАЗВОРОТ: что в какой репе
**Есть ДВА разных проекта, в ДВУХ разных репах:**
| Проект | Репа | Что делает | Состояние |
|---|---|---|---|
| **shared-sqs** | отдельная репа (SQS-service) | SQS-совместимая очередь (AWS API, Go, форк GoAWS), multi-tenant | **ГОТОВ, работает на проде v0.1.35** (все тесты зелёные, нагрузка без ошибок) |
| **IoT** | эта репа (`/home/naeel/nubes/IoT`) | телеметрия устройств: MQTT → SQS → PostgreSQL → REST | **MVP, не production-ready** (есть подтверждённые блокеры) |
**Зависимость:** IoT **потребляет** shared-sqs как внешний сервис (очередь
телеметрии `iot-telemetry`). shared-sqs от IoT **не зависит**.
**Вывод о порядке (отвечая на вопрос «что сначала — SQS или IoT»):**
- shared-sqs фактически **уже готов и работает** раньше, чем IoT дотянет до
production. Его «доделывать» не надо для старта работ по IoT.
- Порядок работ по сути такой: **SQS уже есть (работает) → теперь доработки
IoT**, который на него опирается.
- Если планируется создавать оба «с нуля» — логический порядок архитектурно:
сначала SQS, потом IoT (т.к. IoT от него зависит).
**ВНИМАНИЕ про ветки IoT-репы (важно, легко запутаться):**
- Актуальный код IoT (монолит `iot-service`, `internal/service/`,
`internal/storage/iotpg/`) лежит на ветке **`feature/replace-kafka-with-sqs`**.
Имя ветки — ПЕРЕЖИТОК старой миграции Kafka→SQS (апрель 2026), к текущей
задаче отношения не имеет. Не путать имя с сутью: это текущая ветка IoT.
- Ветка **`master`** — СТАРАЯ, без монолита (там легаси: `internal/api/`, CRD,
k8s-манифесты, `deployments/`, `examples/`, `config/crd/`). НЕ актуальна.
- На `master` ошибочно закоммичены 3 файла документации (см. раздел 5).
---
## 1. Что такое текущий IoT (архитектура)
**Монолит `iot-service`** — один Go-бинарник с тремя ролями в одном процессе:
1. **REST API** + MQTT auth/acl на порту `:9090` (устройства хранятся в PostgreSQL);
2. **bridge** — MQTT-подписка на EMQX → отправка в shared-SQS;
3. **consumer** — чтение из shared-SQS (long-poll) → запись в per-tenant PostgreSQL.
**Конвейер телеметрии:**
```
устройство → wss → EMQX (authn+authz напрямую в PG) → бридж → shared-SQS
→ consumer → per-tenant PostgreSQL → REST API (/v1/namespaces/{ns}/iot/telemetry)
```
**Стек:**
- Go 1.25, gorilla/mux, paho.mqtt.golang, aws-sdk-go-v2 (SQS), lib/pq.
- EMQX 5.5.1 (образ `naeel/iot-emqx`), shared-SQS (SQS-совместимая очередь),
PostgreSQL managed (per-tenant БД).
- Модуль: `gitea.services.ngcloud.ru/Nail/IoT`.
**Ключевые файлы актуального кода (на ветке feature):**
- `cmd/iot-service/main.go` — точка входа: config → PG → SQS → API + горутины bridge/consumer.
- `internal/service/config/config.go`, `defaults.go` — env-конфигурация.
- `internal/service/api/router.go`, `middleware/auth.go` — маршруты + JWT.
- `internal/service/api/handler/*.go` — devices, mqtt_auth, telemetry, admin, sqsstats.
- `internal/service/store/*.go` — устройства в PG (`iot_devices`).
- `internal/storage/iotpg/iot_telemetry_store.go` — запись телеметрии (batch).
- `internal/service/bridge/*.go` — bridge → SQS.
- `internal/service/consumer/*.go` — SQS → PG.
- `internal/service/sqsclient/sqsclient.go` — SQS-клиент (endpoint override).
- `emqx/emqx.conf` — конфиг EMQX (auth/ACL — напрямую в PostgreSQL).
---
## 2. Как IoT использует shared-sqs (факты)
- Внутренний endpoint shared-sqs (тот же кластер Nubes, realm `iot-naeel`):
`http://containerk8s.f1ffb134-7d16-45bd-8bef-69f6ec8ab33c.svc.cluster.local:4100`
(вшит дефолтом в `config/defaults.go`).
- Очередь: `iot-telemetry`, регион `us-east-1`.
- Тенант IoT: `t-fec713a719ad0b33`; креды детерминированы из email:
tenantID `t-`+sha256(email)[:8], AccessKey `SSAK-`+sha256(email)[:12],
SecretKey sha256(email+":shared-sqs:secret-key:v1").
- Лимиты shared-sqs: 50 очередей на тенанта, сообщение до 256 КБ, Receive 1–10.
- В бридже: неблокирующий SQS-диспетчер (канал 10000 + 4 worker'а, ретраи 1/2/4с),
дроп при переполнении буфера и при oversize >250 КБ.
- В consumer: long-poll 20с, VisibilityTimeout 120с (в коде), backoff при
сбое PG (1с→60с), DeleteMessage после обработки.
---
## 3. ПОДТВЕРЖДЁННЫЕ БЛОКЕРЫ IoT (для доработок)
> Источник: два независимых анализа + ревью Opus + моя перепроверка построчно.
> Все — по КОДУ, с точными ссылками. Сгруппировано по приоритету.
### P0 — компрометация/потеря данных или доступ к чужим данным
**P0-1. Tenant-изоляция не работает.**
- `internal/service/api/middleware/auth.go` (`validateJWT`, ~стр. 62, 120):
проверяет только наличие `sub` и (необязательно) `exp`, а `sub` НИКАК не
сверяется с `{namespace}` из URL.
- Обработчики берут namespace из пути (`handler/devices.go:91`,
`handler/telemetry.go:19`) без сверки с токеном.
- Итог: **любой валидный токен читает/пишет ЛЮБОЙ namespace** — горизонтальный
доступ к чужим устройствам и телеметрии.
**P0-2. Проверка подписи JWT необязательна.**
- `auth.go` (~стр. 70): при пустом `JWT_HMAC_SECRET` подпись НЕ проверяется —
только структура токена. Токен подделывается вручную (`{"sub":"x","exp":...}`).
- `config/config.go` (~стр. 118): дефолт секрета пустой, секрет не в списке
required env.
**P0-3. Пароли устройств в открытом виде.**
- `store/open.go:21`: `mqtt_password TEXT` — хранится открытым текстом.
- `handler/devices.go:189`: пароль отдаётся повторно через `GET /devices/{name}`.
- `emqx/emqx.conf:46`: `password_hash_algorithm name=plain`.
- Компрометация БД = все пароли устройств + возможность выдать их повторно.
**P0-7. Потеря телеметрии: SQS-удаление до фактической записи.**
- `consumer/consumer.go:78`: удаляет сообщение из SQS после
`processTelemetry`==nil.
- `iotpg/iot_telemetry_store.go:356-376`: `InsertTelemetry` при неполном
батче кладёт строку В ПАМЯТЬ и возвращает nil (запись ещё не сделана).
- `iotpg/iot_telemetry_store.go:129-135` (`flushAll`): подменяет буфер на
пустой ДО записи в PG, при ошибке — только логирует.
- Итог: краш между «кладём в буфер» и «flush» → сообщение уже удалено из SQS,
в PG не попало → **безвозвратная потеря**.
**P0-8. Нет идемпотентности (дубли).**
- `iot_telemetry` таблица: `id BIGSERIAL`, без ключа дедупликации.
- `bridge/handler.go:19`: envelope без message-id.
- At-least-once SQS → **молчаливые дубли** при повторе сообщения.
### P1 — важные риски эксплуатации
**P1-4. Лишние HTTP-auth/ACL-эндпоинты.**
- `router.go:50-51`: `/internal/mqtt/auth` и `/internal/mqtt/acl` висят на
публичном порту 9090 БЕЗ аутентификации.
- Но EMQX ходит по PG-auth напрямую (`emqx/emqx.conf`, `backend=postgresql`),
эти эндпоинты — НЕиспользуемый fallback. Рекомендация: **удалить**, а не защищать.
- Риск: кто достучался до порта — перебирает пароли устройств и получает ACL.
**P1-5. `AUTH_TEST_MODE=true` на проде.**
- Был включён для диагностики DevOps (HISTORY §38); любой Bearer-токен проходит.
- Нужно выставить `false` + задать `JWT_HMAC_SECRET`.
**P1-9. `/health` не отражает состояние.**
- `router.go:72`: `healthHandler` всегда `{"status":"ok"}`, не проверяет
bridge/consumer/PG/SQS.
- `main.go`: ошибки `bridge.Run`/`consumer.Run` только логируются; горутина
завершается, а liveness остаётся зелёным.
**P1-10. Дропы в бридже без метрик.**
- `bridge/sender.go:73`: дроп при переполнении RAM-очереди (10000).
- `bridge/sender.go:112`: дроп после исчерпания ретраев.
### P2 — обвязка/гигиена
**P2-6. TLS.** `sslmode=require` (без verify-full) к PG; EMQX `verify=verify_none`
(`emqx.conf:40`).
**P2-11/P2-12. Нет тестов и CI.** `*_test.go` отсутствует; `.github/workflows`
нет (в `.github/` только правила).
**P2-13. `/metrics` заявлен, но не зарегистрирован.**
- `config/defaults.go:6`: в комментарии упомянут `/metrics`, но роут в
`router.go` НЕ создан. prometheus — только indirect dep. Rate-limiting нет.
**P2-14. Легаси не вычищено.**
- Остались `cmd/iot-operator`, `controllers/`, `internal/api/` (старый k8s-код),
`cmd/mqtt-bridge`, `cmd/sqs-consumer`; k8s/controller-runtime в `go.mod`
(~12 строк); бинарники в `bin/`, `ws-probe/ws-probe`.
**P2-15. Дрейф доков.**
- `doc/deployment-nubes-production.md` — образы `v0.1.2`; Makefile — `v0.1.13`.
- `loadtests/README.md` — visibility «30с» (в коде 120с), описывает уже
исправленную блокировку MQTT-обработчика.
**P2-16. `emqx.conf`: `cookie = "iot-emqx-cookie-mvp"` захардкожен.**
### ПЛАТФОРМА (вне нашего кода, но блокирует прод)
1. **wss-обрывы ~150с** на внешнем шлюзе Nubes (детерминированно, доказано с
3 независимых точек: станция, Vultr; внутри кластера и с ВМ — чисто).
Тикет Nubes подготовлен (`doc/thinking/nubes-devops-report-2026-08-16.md`).
2. **MSS/MTU-баг** — плавающий обрыв ответов >~15–64 КБ (баг нестабилен по
маршруту/времени). Обход в коде: offset-пагинация API, внутренний endpoint SQS.
---
## 4. Что уже ХОРОШО (не трогать без нужды)
- Конвейер end-to-end работает и проверен на проде (HISTORY: baseline 2390/2390
без потерь/дублей, ACL-нарушения отсекаются, reconnect-storm чистый).
- Уже исправлены (по итогам ревью 2026-08-16): асинхронный SQS-диспатчер,
VisibilityTimeout 120с, backoff, graceful shutdown, batch-INSERT) — НЕ
регрессировать это.
- EMQX auth/ACL — напрямую в PostgreSQL, без зависимости от монолита.
- MQTT-reconnect с повторной подпиской (OnConnectHandler).
- Документация: деплой без kubectl, интеграция с SQS, 10 нагрузочных сценариев.
---
## 5. Что уже записано в этой репе (файлы документации)
- `doc/thinking/readiness-assessment-2026-10-05.md` — два анализа (5/10 и
60–65%) + моё мнение.
- `doc/thinking/remediation-plan-2026-10-05.md` — план доработок (4 трека, P0/P1/P2).
- `doc/thinking/opus-review-prompt.md` — промпт для ревью (allowlist файлов).
- **ВНИМАНИЕ:** эти 3 файла лежат в ветке `master` (коммит `a4e8fd5`, запушен),
а НЕ на актуальной `feature/replace-kafka-with-sqs`, где живёт код.
`doc/sqs-integration.md`, `doc/deployment-nubes-production.md`, `HISTORY/` —
на feature-ветке.
**Backup-ветки (сохранение состояний):**
- `backup/2026-08-17-feature-sqs` (старая точка).
- `backup/2026-10-05-feature-sqs` (сделана 2026-10-05, на `12db7d3`).
---
## 6. Рекомендуемый порядок работ (по плану)
1. P0-1 + P0-2 — tenant-граница (связать `sub` с namespace) + обязательная подпись JWT.
2. P0-7 + P0-8 — доставка без потерь (ack только после успешного flush) + dedup (message-id).
3. P0-5 — выключить `AUTH_TEST_MODE`.
4. P0-3 + P1-4 — пароли (не отдавать повторно/хешировать) + удалить `/internal/mqtt/*`.
5. P1-9 + P1-10 — честный `/health` + метрики дропов.
6. P1-11 + P1-12 — Go-тесты + CI.
7. P2 (гигиена: легаси, доки, cookie, TLS).
@@ -1,143 +0,0 @@
# Отчёт для DevOps Nubes — сетевые проблемы платформы
**Кластер:** iot-naeel
**Дата:** 2026-08-16
**Статус:** предварительный, все факты подтверждены тестами и дампами.
Готовы предоставить скрипты воспроизведения и провести совместные тесты.
---
## Часть 1. Что не работает (наблюдения и доказательства)
### 1.1 Внешние WebSocket-соединения закрываются ~через 150 секунд
**Симптом.** WebSocket-соединения из внешних сетей (wss, TLS) закрываются
в районе 150 секунд после установления. Для клиентов это выглядит как
разрыв без видимой причины (close 1006/1005) с необходимостью
переподключения.
**Доказательства.**
- 5 из 5 внешних станций (разные сети) — разрыв ~150 с, стабильно.
- Чистый VPS (Vultr) — 1 из 1, тот же разрыв ~150 с.
- Внутри кластера (под → сервис) — 600 с без единого разрыва.
- Машина за тем же шлюзом, но вне платформы — 300 с чисто.
- Контрольные wss-соединения к внешним ресурсам (nginx, echo-серверы)
с тех же станций — чисто.
**Вывод.** Обрывает именно edge-шлюз платформы (или балансировщик перед
ним), по таймеру. Похоже на фиксированный лимит времени жизни
соединения (idle/lifetime timeout) на проксирующем слое.
**Влияние.** Любой долгоживущий протокол поверх WebSocket (например,
MQTT) вынужден переподключаться каждые ~150 с. Наши клиенты это
переживают (автопереподключение), но это лишний трафик и окна потерь.
### 1.2 Зависание внешних соединений ~на 51 секунду при объёмном трафике (MTU)
**Симптом.** Из внешней сети операции с телом/ответом крупнее ~1,4 КБ
периодически «зависают» ровно на ~51 секунду, после чего либо
завершаются, либо обрываются. Внутри кластера те же операции проходят
мгновенно.
**Доказательства (три независимых контура).**
- Контур A (HTTP-сервис за платформой): POST снаружи — пауза ~51 с на
любом объёме данных выше порога; изнутри — мгновенно. При
внешнемTrafficPolicy: Cluster большие файлы (>5 МБ) дают TCP-stall
ровно на 51 с.
- Контур B (очередь с AWS-совместимым API): тела >~1,4 КБ виснут ~51 с
(классическая экспонента TCP-ретрансмитов).
- Контур C (REST API): HTTP-ответы крупнее ~15 КБ приходят частично и
виснут (в тесте получено 15 672 байта — далее стоп); маленькие ответы
(до ~15 КБ) проходят стабильно.
**Диагностика (наша гипотеза, подтверждённая измерениями).**
- Underlay MTU = 1450.
- Оверлей (Cilium Geneve) добавляет ~50 байт инкапсуляции.
- MTU контейнеров остаётся дефолтным 1500.
- Кросс-нодовый пакет полного размера = 1500 + 50 = 1550 > 1450 →
фрагментация невозможна (DF) → пакет дропается → ядро ждёт
ретрансмиссии по экспоненте → суммарная пауза ~51 с.
- На внешнем пути наблюдается MSS 1448 при MTU 1400 — т.е. ICMP
«fragmentation needed» до клиентов, вероятно, не доходит (PMTUD
сломан на каком-то из хопов).
**Влияние.** Любой объёмный запрос/ответ снаружи рискует зависнуть на
~51 с; клиенты с таймаутами 30 с получают «таймаут», хотя сервис
ответил мгновенно.
### 1.3 Периодические таймауты внешних запросов на малых объёмах (~5,5%)
**Симптом.** Даже запросы размером ~512 байт снаружи в ~5,5% случаев не
получают ответ 30+ секунд; характерный паттерн — зависание на 31–33 с.
**Доказательства.**
- Внутри кластера: 24 977 запросов подряд — 0 сбоев (порт-форвард).
- Снаружи: периодические зависания подтверждены tcpdump-ом (запрос
уходит, ответ не возвращается в течение 30+ с).
- Маловероятно, что это наша прикладная логика: те же операции изнутри
кластера стабильны.
**Гипотеза.** Связь с пунктом 1.2 (потеря крупных сегментов при
переговорах TLS/подтверждениях) или лимиты conntrack/сессий на
балансировщике.
---
## Часть 2. Как это исправить (наши предложения)
### 2.1 К пункту 1.1 (wss ~150 с)
1. Найти слой, который закрывает соединения: проверить настройки
idle/lifetime timeout на edge-шлюзе и балансировщике перед кластером
(nginx: `proxy_read_timeout`, `proxy_send_timeout`; LB: idle timeout).
2. Для wss-трафика выставить таймауты ≥ 600 с (или «без таймаута»).
3. Включить WebSocket ping/pong на прокси-слое (keepalive-фреймы каждые
30–60 с), чтобы соединение не считалось простаивающим.
4. После изменения — прогнать наш контрольный тест (wss-клиент держит
соединение 10 минут; критерий — 0 разрывов).
### 2.2 К пункту 1.2 (MTU / 51 с)
Варианты, в порядке предпочтения:
1. **Включить MSS-clamping на внешнем пути** (на шлюзе/на узлах):
`iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN \
-j TCPMSS --clamp-mss-to-pmtu` (или фиксированный MSS под MTU
внешнего интерфейса). Это дешёвое и надёжное решение: клиент не будет
слать сегменты крупнее, чем проходит путь.
2. **Привести MTU контейнеров к underlay**: MTU подов 1450 − 50
(инкапсуляция) = 1400. Тогда кросс-нодовые пакеты перестанут
превышать underlay, и дропы исчезнут в принципе.
3. **Проверить, что ICMP «fragmentation needed» не режется** на всём
внешнем пути (firewall/LB) — восстановление PMTUD решит проблему
для больших ответов.
4. Отключить запрет фрагментации на edge для исходящих сегментов (менее
желательно).
Критерий приёмки: POST/GET с телами 1 КБ…1 МБ снаружи проходят без
пауз >2 с (наш тест: 100 запросов разных размеров, 0 зависаний).
### 2.3 К пункту 1.3 (таймауты малых тел)
1. Сначала применить 2.2 — вероятно, это тот же корень.
2. Если проблема останется: проверить лимиты conntrack на шлюзе
(`nf_conntrack_max`, `table-full` в логах) и пул/лимиты соединений
балансировщика.
3. Критерий: 10 000 запросов по 512 байт снаружи — 0 таймаутов.
---
## Что мы уже сделали со своей стороны (обходы)
- Клиенты переподключаются автоматически (переживают пункт 1.1).
- Внутренние взаимодействия ходят по внутренним DNS, минуя внешний
шлюз (таймауты 1.3 нас не блокируют).
- Чтение больших списков разбито на страницы небольшого размера
(обход пункта 1.2).
## Что нужно от вас
1. Подтверждение/опровержение по каждому пункту.
2. Если нужны воспроизведения — дайте окно и среду, прогоним тесты
совместно (скрипты готовы).
3. Оценка сроков по пунктам 2.1–2.3.
-154
View File
@@ -1,154 +0,0 @@
# Ревью iot-service (bridge → SQS → consumer → iotpg)
> Ответ Opus на промпт «код-ревью IoT». Дословно, без правок.
Код не запускал и тесты не писал. Поведение paho проверил по исходникам v1.5.1 в module cache. `emqx/emqx.conf` вне вашего списка, но я цитирую его строки, где от них зависит вывод.
## 🔴 Критично
1. **Подтверждение SQS до фактической записи (гонка с фоновым flusher).**
- `iot_telemetry_store.go:127-134`: `flushAll` забирает буфер целиком, а фоновый flusher (`:117`) идёт параллельно и игнорирует ошибку.
- `Flush` (`:164`), вызванный из `consumer.go:116`, видит пустой буфер и сразу возвращает `nil`. Тогда `consumer.go:120` удаляет сообщения, пока INSERT ещё в полёте или уже провалился.
- Если провал постоянный (`:149-154`), строки выброшены, а сообщения уже подтверждены. Это потеря данных.
- Тот же эффект в `InsertTelemetry` (`:345-354`). При полном батче и постоянной ошибке теряются до 99 строк от сообщений, которые уже лежат в `pending` и будут подтверждены.
- Нужна сериализация flush и ожидание in-flight записи, а `Flush` должен возвращать ошибку и фонового прохода.
2. **Ядовитое сообщение (poison pill) валит весь батч, и его никуда не отправляют.**
- `:144-154` и `:345-354` классифицируют ошибку на уровне батча, а не строки. Одна плохая строка выбрасывает до 100 валидных строк того же tenant или вечно блокирует их.
- Триггеры, которые проходят проверки:
- envelope без `payload` (`processor.go:39`) даёт NULL и ошибку 23502;
- `\u0000` в JSON-строке: `json.Valid` в `handler.go:47` пропускает, JSONB отвергает (22P05);
- пустые `namespace` и `device_id`: `processor.go:23-27` проверяет только синтаксис JSON, а `EnsureTenantDB` (`:234`) создаёт БД `tenant_`.
- В `consumer.go:116-119` сообщение не подтверждается и не уходит в DLQ. Счётчик `ApproximateReceiveCount` не читается, поэтому оно крутится вечно, если на очереди нет RedrivePolicy.
- Нужны построчный fallback при постоянной ошибке, семантическая валидация envelope как `errInvalidEnvelope`, и отправка в DLQ после N приёмов.
3. **Остаток буфера bridge при shutdown не доставляется.**
- `bridge.go:40-41` стартует воркеры с `ctx` от сигнала, а `bridge.go:54-55` вызывает `closeAndWait` уже после его отмены.
- `sender.go:124` и `:132` получают `context canceled` на первой попытке, и все накопленные сообщения логируются как «dropping».
- Комментарий в `main.go:123-126` про «flush SQS» не соответствует поведению.
- Дополнительно после `close(d.ch)` (`sender.go:87`) поздний колбэк paho паникует на `send on closed channel` (`:72`). Disconnect с quiesce 2 с не гарантирует остановку обработчиков.
4. **Короткая недоступность SQS ведёт к потере данных.**
- `sender.go:31` и `:119-145`: 3 попытки с паузами 1 и 2 с, затем сообщение дропается, хотя буфер на 10 000 почти пуст.
- У `SendMessage` (`:124`) нет таймаута на попытку. Зависшее соединение блокирует все 4 воркера, канал переполняется и `enqueue` дропает (`:70-83`).
- MQTT QoS1 подтверждается брокеру сразу после `enqueue` (`handler.go:52`), то есть до записи в SQS. Сквозной гарантии at-least-once нет.
- Нужны ретраи до дедлайна или shutdown и таймаут на попытку. Опционально отложенный `msg.Ack()` после успешной отправки.
5. **Пайплайн необратимо умирает после временной ошибки при старте, а `/health` остаётся 200.**
- `bridge.go:27-29` и `consumer.go:29-32`: ошибка `GetQueueUrl` фатальна без ретрая.
- `bridge.go:105-111`: таймаут подключения 30 с при `ConnectRetry=true` возвращает ошибку, но paho продолжает подключаться в фоне. Это утечка клиента и повод для паники на закрытом канале из п.3.
- `main.go:109-120` только логирует «stopped». Процесс не завершается и не перезапускается, а `/health` в `router.go:69` всегда отдаёт 200.
- Нужны ретрай старта с backoff или завершение процесса с кодом ≠ 0.
6. **Bridge «глохнет» после неудачной подписки.**
- `bridge.go:92` и `:97` вызывают `c.Disconnect(100)` «для реконнекта». В paho это явное отключение (`client.go:460`), автореконнект после него не срабатывает.
- Подписки нет, а `/ready` остаётся true.
7. **DLQ и подтверждение битых envelope (`consumer.go:33-39`, `:81-94`).**
- Любая ошибка `ResolveQueueURL` для DLQ, даже временная, навсегда превращает `dlqURL` в `""`, и битые сообщения подтверждаются и теряются (`:88-92`).
- Сбой `sendToDLQ` (`:85-87`) только логируется, затем следует подтверждение. Это потеря.
8. **MQTT-пароль устройства нигде не возвращается.**
- `devices.go:152` отдаёт `deviceToResponse(device, "")`, хотя комментарии (`:37-39`, `:78`) обещают отдать пароль один раз при создании. GET тоже отдаёт `""`.
- Сгенерированный пароль не получить, устройства не смогут подключиться. Это блокер и для Terraform.
## 🟡 Важно
- **JWT (`middleware/auth.go`).**
- `:140` при отсутствии `exp` токен вечный. Нужно требовать `exp`, желательно проверять `nbf`, `iss` и `aud`.
- `:57` отдаёт 403 на невалидный и просроченный токен. Должно быть 401, иначе клиент не отличит протухший токен от чужого namespace.
- `:38-42` и `config.go:121`: `AUTH_TEST_MODE` обходит проверку namespace и работает даже при заданном секрете. Лучше запретить при заданном `JWT_HMAC_SECRET` или громко логировать.
- `:92` в тестовом режиме JWT-подобные токены с пустым секретом принимаются без подписи.
- `:61` жёсткое `sub == namespace`. Проверьте, что платформа выдаёт именно такие токены, иначе Terraform получит 403.
- **Изоляция tenant и валидация входа.**
- `iot_telemetry_store.go:618-620`: `a-b` и `a_b` дают одну БД `tenant_a_b`. Длинные namespace (>56 символов) усекаются PG до 63 байт и тоже сливаются.
- `devices.go:106-113`: нет проверки `name`, `device_id` и namespace.
- `device_id` вида `+`, `#`, `/` превращает ACL-топик (`emqx.conf:75`) в wildcard.
- `_` в namespace делает username `ns_device` неоднозначным (`emqx.conf:46`, `LIMIT 1` без `ORDER BY`). Станет 🔴, если namespace допускают `_`.
- Префикс `__bridge` не зарезервирован.
- **Consumer: давление и дубли.**
- `consumer.go:99-105`: паузы 1…60 с внутри цикла по батчу при visibility 120 с (`config.go:116`) дают redelivery ещё не обработанных сообщений. Спасает только дедуп.
- `:116-119`: после ошибки flush нет backoff, цикл крутится «горячо» и вычерпывает очередь в память. Во время простоя PG буфер растёт и накапливает дубликаты строк (`store:147`, `:348`). Он не ограничен.
- `processor.go:30-32`: ошибки `EnsureTenantDB` не классифицируются, постоянные ошибки крутятся бесконечно.
- **`ts` и порядок.**
- `handler.go:57` пишет `received_at` с точностью до секунды.
- `iot_telemetry_store.go:410` и `:415`: `ORDER BY ts DESC` без tiebreaker плюс OFFSET даёт нестабильную пагинацию. Нужны RFC3339Nano и `id DESC`.
- **Дедуп.**
- `handler.go:58`: `message_id` создаётся на каждый приём из MQTT, поэтому дубль QoS1 (DUP после реконнекта) получает новый uuid и не дедуплицируется. Дедуп защищает только от повторной доставки SQS и ретраев отправки.
- `store:190-192`: пустой `message_id` превращается в NULL, дедупа нет. Лучше отвергать такие envelope или брать `MessageId` из SQS как запасной ключ.
- **Readiness.**
- `main.go:107` и `:116` выставляют `ready=true` до старта, а не после подключения MQTT и подписки.
- Потеря соединения MQTT (`bridge.go:81`), PG и SQS на `/ready` не влияют.
- **Shutdown.**
- `main.go:129` не ограничивает `wg.Wait` по времени, а платформа убьёт процесс раньше.
- `main.go:138`: `os.Exit(1)` обходит defer-ы, то есть Close и flush.
- `iot_telemetry_store.go:546`: `Close` делает одну попытку flush, строки после retryable-ошибки теряются при выходе. `Close` не идемпотентен.
- **MQTT.**
- `bridge.go:75-77`: `MaxReconnectInterval` по умолчанию 10 минут. Нужно ~30 с.
- Фиксированный `ClientID` при rolling-деплое с перекрытием даёт takeover и флаппинг двух инстансов.
- **`replaceDSNDatabase` (`:596-613`).**
- Работает только с URL-DSN. Для key=value DSN молча подставляет management-БД, и телеметрия всех tenant смешивается.
- `LastIndex("/")` ломается на `sslrootcert=/path` в query (типично для RDS).
- Пулы tenant (`:559-591`) не вытесняются, по 2 idle-соединения на tenant, и это упрётся в `max_connections`.
- **AWS-совместимость (`sqsclient.go:19-27`).**
- Только статические ключи: нет session token, IRSA и default chain.
- `SQS_ENDPOINT` нельзя оставить пустым (`config.go:109`), без override реальный AWS не используется.
- Нет таймаутов HTTP и настроек ретрайера.
- `SQS_LONG_POLL_SECONDS` не ограничен 20 (`consumer.go:50`).
- Envelope >250 КБ дропается без DLQ (`sender.go:101-110`).
- **`store/devices.go:19-21`: INSERT не пишет `phase`.**
- Устройство, созданное с `enabled=false`, лежит в БД как `Active`.
- При этом ответ POST считает `Disabled` (`devices.go:127-129`), и GET потом даёт другое значение.
- **P0-3 (коммент).**
- Хеширование пароля затронет `EnsureBridgeDevice` (`bridge.go:20-26`, перезапись при каждом старте) и `password_hash_algorithm` в `emqx.conf:42-46`. Менять нужно согласованно.
- `tenant_credentials.pg_password` (`:285`) тоже хранится открыто и нигде не используется для подключения.
## 🟢 Мелочи
- `handler/mqtt_auth.go` не подключён в `router.go`, то есть это мёртвый код. Если его подключить, он небезопасен: доверяет клиентскому `clientid` (`:123`) и разрешает subscribe на `#` (`:144`). Лучше удалить.
- `admin.go:23`: токен сравнивается не constant-time.
- Нет `MaxBytesReader` и `DisallowUnknownFields` (`devices.go:102`, `:233`).
- `main.go:95-99`: нет `WriteTimeout` и `IdleTimeout`.
- `processor.go:43` пишет «telemetry saved», хотя данные только буферизованы. Это Info на каждое сообщение.
- `logging.go:27` логирует пробы здоровья.
- `consumer.go:75-79`: пустое тело подтверждается без DLQ.
- `consumer.go:120`: сообщения удаляются по одному. Есть `DeleteMessageBatch`.
- При ошибке flush DLQ-сообщения отправляются повторно, и в DLQ появляются дубли.
- `defaults.go:6` обещает `/metrics`, маршрута нет. Нет счётчиков дропов (`sender.go:45`), глубины буфера и DLQ.
- Мёртвый код: `ns=="default"` в `devices.go`, nil-проверки `IoTPG`, `NewFromEnv`.
- `iot_telemetry_store.go:279-291`: сбой между `CREATE DATABASE` и записью credentials делает tenant невидимым в `GetAdminStats` (`:460`).
## Что стабилизировать в REST для Terraform
- **Пароль.** Вернуть `mqtt_password` один раз в 201 на POST, иначе ресурс не создать (п.8). Добавить ротацию (отдельный endpoint) или пароль, задаваемый клиентом. Решите заранее, что sensitive и что считается drift.
- **Идентичность ответов POST, GET и PATCH.**
- Сейчас при создании `created_at` пуст, потому что устройство не перечитывается из БД (`devices.go:152`).
- `created_at` в формате `2006-01-02 15:04:05 UTC` (`:73`) нужно привести к RFC3339, как `last_connected`.
- `phase` должен совпадать (см. 🟡 про INSERT).
- Пустой `metadata` (`{}`) не должен пропадать: из-за `omitempty` (`:50`) возникает drift `{}` против null.
- **Поля.**
- Детерминированные, `ForceNew`: `namespace`, `name`, `device_id`. Вычисляемые, стабильные: `mqtt_username` (`ns_device`) и `phase`.
- Нестабильное, только computed: `last_connected`.
- `topic_prefix` (`:66`) даёт `ns/`, а реальный топик `ns/telemetry/{device_id}`. Нужен явный `telemetry_topic`.
- **Изменяемость.** PATCH меняет только `enabled`, `metadata` обновить нельзя. Либо добавить update метаданных, либо зафиксировать `metadata` как `ForceNew`.
- **Контракт ошибок.**
- Токен: 401 против 403 (п. JWT выше).
- Ошибки middleware (`auth.go:27`, `:57`) идут как `text/plain`, а не JSON.
- 409 не различает конфликт `name` и `device_id`.
- Валидация входа возвращает 400 с понятным кодом и полем.
- Нужны единые `error`-структуры и идемпотентный DELETE (404 = «уже удалено»).
- **Валидация.** Регулярки для `name` и `device_id`, максимальная длина, запрет `+`, `#`, `/` и `_` в namespace.
- **Import.** GET по `{namespace}/{name}` уже есть, нужен стабильный ID.
- **List и telemetry.** Ограничения `limit` и `offset` молча игнорируются при ошибке парсинга (`devices.go:163-164`). Нужны явный потолок, `total` или cursor и tiebreaker сортировки.
- **Delete.** Телеметрия при удалении устройства остаётся, это нужно зафиксировать в документации API.
-74
View File
@@ -1,74 +0,0 @@
# Промпт для ревью (Opus) — IoT готовность к production
Ты ревьюишь Go-проект IoT. Цель — выявить блокеры, мешающие назвать сервис
production-ready. Дай ответ МАКСИМАЛЬНО сжато, без воды: списком, короткими
строками, с точными ссылками на файл и строку. Никаких пояснений-простыней.
**Запрещено:**
- Не читай память, историю сессий, заметки, предыдущие анализы, `HISTORY/`,
`LEGACY.md`, ничего в `legacy/`. Только код и документация из списка ниже.
- Не читай старые k8s-манифесты, CRD, оператор — это легаси, не актуально.
- Не предлагай рефакторинг ради рефакторинга — только то, что блокирует прод.
**Компоненты продукта (актуально):** монолит `iot-service` (API + MQTT-bridge +
SQS-consumer в одном бинарнике) + EMQX + shared-SQS + managed PostgreSQL.
Конвейер: устройство → wss → EMQX → бридж → SQS → consumer → PG → REST API.
**Смотри ТОЛЬКО эти файлы (актуальный код):**
Конфигурация и точка входа:
- `cmd/iot-service/main.go`
- `internal/service/config/config.go`
- `internal/service/config/defaults.go`
HTTP API:
- `internal/service/api/router.go`
- `internal/service/api/middleware/auth.go`
- `internal/service/api/middleware/logging.go`
- `internal/service/api/handler/handler.go`
- `internal/service/api/handler/devices.go`
- `internal/service/api/handler/mqtt_auth.go`
- `internal/service/api/handler/telemetry.go`
- `internal/service/api/handler/admin.go`
- `internal/service/api/handler/sqsstats.go`
Хранение:
- `internal/service/store/open.go`
- `internal/service/store/device.go`
- `internal/service/store/devices.go`
- `internal/service/store/bridge.go`
- `internal/storage/iotpg/iot_telemetry_store.go`
Бридж и consumer:
- `internal/service/bridge/bridge.go`
- `internal/service/bridge/handler.go`
- `internal/service/bridge/sender.go`
- `internal/service/consumer/consumer.go`
- `internal/service/consumer/processor.go`
- `internal/service/sqsclient/sqsclient.go`
Инфраструктура:
- `Dockerfile`
- `Makefile`
- `go.mod`
- `emqx/emqx.conf`
- `loadtests/README.md` (только для понимания критериев, не весь код)
**Вопросы (ответь строго по пунктам, каждому — вердикт + файл:строка):**
1. Tenant-изоляция: как `sub` из JWT связывается с `{namespace}` в URL?
Есть ли путь доступа к чужому namespace валидным токеном?
2. Гарантии доставки: есть ли окно потери сообщения между буфером записи,
ответом об успехе и удалением из SQS? Где именно?
3. Идемпотентность: защищён ли вход от дублей при at-least-once SQS?
4. `/health`: отражает ли реальную работоспособность bridge/consumer или
всегда `ok`?
5. Auth JWT: валидация sub/exp/подписи — что при пустом `JWT_HMAC_SECRET`?
6. Пароли устройств: как хранятся, как отдаются через API.
7. `/internal/mqtt/auth` и `/internal/mqtt/acl`: нужны ли ещё, защищены ли?
8. Ошибки записи в PG: что происходит с сообщением при сбое flush-батча?
9. Есть ли тесты (`*_test.go`), CI, rate-limiting, метрики, TLS-верификация PG?
**Формат ответа:** по каждому пункту — 1 строка вердикт (OK / ПРОБЛЕМА) +
`файл:строка` + при проблеме — 1 строка суть. В конце — список
«блокеры для прода» (только критические, без ранжирования по вкусу).
@@ -1,140 +0,0 @@
# Оценка готовности IoT к production (2026-10-05)
> Три взгляда на один репозиторий: анализ A, анализ B и моё мнение.
> Все три — только чтением кода и документации, без сборки и запуска тестов
> (правила репо: команды — только через SSH на ВМ; сборку/прогоны не выполняли).
---
## 1. Анализ A (первый) — итог 5/10
**Вердикт:** цельный MVP, но по коду не считать production-ready для
надёжной multi-tenant эксплуатации.
### Блокеры
- **Не проверяется принадлежность namespace пользователю.** Middleware
проверяет JWT, но не передаёт `sub` обработчикам для сравнения с namespace
из URL. CRUD и выдача пароля устройства берут namespace из запроса.
Валидный токен не ограничивает пользователя его tenant-ом:
`auth.go:20`, `router.go:54`, `devices.go:179`. `exp` фактически необязателен
(`auth.go:120`).
- **Путь потери уже принятых сообщений.** Flusher заменяет буфер на пустой
ДО записи в PG, при ошибке только логирует. Consumer при этом удаляет
сообщения из SQS после добавления в буфер: `iot_telemetry_store.go:129`,
`:135`, `consumer.go:81`.
- **Health зелёный при остановившемся pipeline.** Ошибки запуска bridge/consumer
только логируются; горутина завершается, а `/health` всегда отдаёт `ok`:
`main.go:106`, `router.go:74`.
- **Доставка допускает потери и дубли.** Переполненная RAM-очередь bridge
сбрасывает сообщения; исчерпание SQS-ретраев заканчивается drop. При
at-least-once нет идемпотентного ключа против дублей: `sender.go:70`, `:112`.
### Что хорошо
Компоненты сведены в понятный единый сервис; есть MQTT-reconnect с повторной
подпиской, SQS-ретраи, graceful shutdown, разделение хранения устройств и
телеметрии, широкий набор нагрузочных сценариев.
### Чего не хватает до уверенного релиза
- Автоматических Go-тестов (`*_test.go` не найдено) и CI.
- Согласованности доков с кодом: гайд деплоя — `iot-service:v0.1.2`, Makefile —
`v0.1.13`; loadtest README описывает уже исправленную блокировку
MQTT-обработчика и visibility 30с, код задаёт 120с:
`deployment-nubes-production.md:3`, `Makefile:4`, `README.md:85`, `config.go:114`.
---
## 2. Анализ B (второй) — итог ~60–65%
**Вердикт:** рабочий MVP / технический прототип, не продакшн-готовый сервис.
### Что хорошо
- Конвейер устройство → EMQX → мост → SQS → consumer → PG (per-tenant)
работает end-to-end; подтверждено HISTORY и нагрузочными прогонами.
- Прогоны: baseline 2390/2390 без потерь/дублей; ACL-нарушения отсекаются;
reconnect-storm прошёл чисто.
- Код небольшой (~2,4 тыс. строк в монолите), аккуратный: graceful shutdown,
асинхронный SQS-диспатчер с ретраями, batch-insert, backoff, constant-time
сравнение паролей.
- Документация подробная: деплой без kubectl, интеграция с SQS, лог ревью и
фиксов, 10 нагрузочных сценариев.
- Auth и ACL в EMQX работают напрямую через PostgreSQL, без зависимости от монолита.
### Что мешает назвать готовым
1. **Нет ни одного Go-теста и нет CI.** Только Python-нагрузочные скрипты;
в `.github/` одни правила.
2. **Изоляция тенантов слабая.** JWT проверяет только `sub` и `exp`; namespace
из URL не сверяется с токеном. Без `JWT_HMAC_SECRET` подпись не проверяется.
Любой «валидный» токен читает устройства и пароли любого namespace.
3. **Пароли устройств в БД открытым текстом** и отдаются через GET. EMQX —
`password_hash_algorithm = plain`.
4. **`AUTH_TEST_MODE=true`** (любой Bearer) был включён на проде для диагностики.
HISTORY §38 — «вернуть false»; закрытия не видно.
5. **`/internal/mqtt/auth` и `/internal/mqtt/acl`** открыты на публичном порту
без авторизации; дублируют PG-auth в EMQX и уже не нужны. ACL выдаёт права
моста любому клиенту с `clientid`, совпадающим с мостовым.
6. **Нерешённая платформенная проблема:** плавающий обрыв ответов >~15–64 КБ и
WebSocket-обрывы ~150с; стабильного воспроизведения нет, разбор с DevOps
идёт. В коде обходится пагинацией.
7. **Отложено:** whitelist тенантских БД (любой namespace создаёт базу), DLQ,
soak 24ч.
8. **Легаси не вычищено:** `cmd/iot-operator`, `controllers/`, `internal/api/`,
`cmd/mqtt-bridge`, `cmd/sqs-consumer`; в `go.mod` 12 строк k8s/controller-runtime;
бинарники в `bin/` и `ws-probe/ws-probe`. `LEGACY.md` обещает убрать после
переписывания хендлеров, а оно сделано.
9. **Дрейф конфигурации:** `cookie = "iot-emqx-cookie-mvp"` захардкожен в
`emqx.conf`; параметры EMQX задаются только через env в deck-UI.
10. **Нет TLS-верификации** к PG (`verify_none`). Нет rate limiting и метрик
(только `/health` и admin-статистика).
### Минимум для продакшна
- привязать namespace к JWT, сделать `JWT_HMAC_SECRET` обязательным;
- убедиться, что `AUTH_TEST_MODE` выключен;
- хешировать пароли устройств или не отдавать повторно;
- удалить `/internal/mqtt/*` и легаси;
- добавить тесты и CI;
- закрыть вопрос обрывов на платформе.
---
## 3. Моё мнение (перепроверено по коду)
Оба анализа компетентны и в главном совпадают. Расхождение 5/10 vs 60–65% —
не в фактах, а в весе, который они дают одним и тем же находкам. Спорные места
я перепроверил сам — оба правы по существу.
### Подтверждается кодом (не предположение)
1. **Изоляция тенантов слабая** — верно. `middleware/auth.go` проверяет JWT
только на `sub`+`exp`; `sub` не сверяется с `{namespace}` (claims не
передаются в обработчики, `router.go` не извлекает `sub`). `exp` необязателен
(`if claims.Exp > 0`), без `JWT_HMAC_SECRET` подпись не проверяется.
2. **Путь потери принятых сообщений** — верно. `processTelemetry` → `InsertTelemetry`
кладёт в in-memory буфер и возвращает nil, после чего consumer безусловно
удаляет сообщение из SQS. `flushAll` подменяет буфер на пустой ДО записи в PG
и при ошибке только логирует. Данные могут молча пропасть.
3. **`/health` зелёный при упавшем pipeline** — верно. `main.go` ошибки
bridge/consumer только логирует; горутина завершается, `healthHandler` всегда
отдаёт `ok`.
4. **Нет Go-тестов и CI** — верно. `*_test.go` отсутствует, `.github/` — правила.
5. **`AUTH_TEST_MODE` и пароли plaintext** — верно и задокументировано
(`password_hash_algorithm=plain`, `AUTH_TEST_MODE=true` для DevOps, §38).
6. **Дрейф доков** — верно: гайд `v0.1.2` vs Makefile `v0.1.13`; loadtest README
30с vs код 120с. Анализ A зафиксировал точнее.
### Где анализ B точнее/дополняет
- Явно оговорил, что сборку/тесты не запускал (ограничение правил — SSH).
- Глубже разобрал открытые `/internal/mqtt/*`, TLS `verify_none`, захардкоженный
`cookie`, невычищенное легаси и k8s-зависимости в `go.mod`.
### Где я бы поправил обоих
- **Проценты условны.** Оба дают один вывод «MVP/прототип, не production-ready»;
цифры — риторика, а не измерение.
- **Приоритет блокеров:** главные технические — потеря сообщений и tenant-граница.
Plaintext-пароли и unauthed `/internal/mqtt/*` — важные, но второго эшелона
(периметр платформы + EMQX уже на PG-auth напрямую снижают остроту).
### Итог
**Рабочий MVP, пригодный для демо/пилота, но не production-ready.** Главные
блокеры: слабая tenant-изоляция, гарантированная потеря сообщений при сбое
flush, «слепой» health, отсутствие Go-тестов/CI и не закрытая платформенная
проблема wss ~150с / MTU. Моя оценка — между A и B, ближе к
«функционально готов, эксплуатационно — нет».
-154
View File
@@ -1,154 +0,0 @@
# План доработок IoT — до production-ready (2026-10-05)
> Основа: два независимых анализа + ревью Опуса (построчные ссылки) + моя
> перепроверка по коду. Сгруппировано в 4 трека, внутри — по приоритету.
> Приоритеты: P0 — потеря/компрометация данных или чужие данные; P1 — важные
> риски эксплуатации; P2 — обвязка/гигиена; ПЛАТФОРМА — вне нашего кода.
---
## Трек 1. Безопасность / tenant-изоляция
### P0-1. Связать JWT `sub` с `{namespace}`
- Сейчас: `validateJWT` проверяет только наличие `sub`/`exp`/подписи, `sub`
никак не сверяется с `{namespace}` из URL (`middleware/auth.go:62`); обработчики
берут ns из пути (`handler/devices.go:91`, `handler/telemetry.go:19`).
- Что: извлекать `sub` из claims, передавать в контекст запроса, в middleware
(`/v1`) сверять с `{namespace}` → 403 при несовпадении.
- Файлы: `middleware/auth.go`, `router.go`, `handler/*.go`.
### P0-2. Сделать проверку подписи обязательной
- Сейчас: при пустом `JWT_HMAC_SECRET` подпись не проверяется — токен
подделывается (`auth.go:70`); дефолт секрета пустой, не в required
(`config.go:118`).
- Что: при `AUTH_TEST_MODE=false` требовать `JWT_HMAC_SECRET` (иначе отказ на
старте); `exp` сделать обязательным.
- Файлы: `config.go`, `auth.go`.
### P0-3. Не отдавать пароль устройства повторно + хешировать
- Сейчас: пароль в БД открытым текстом (`store/open.go:21` mqtt_password TEXT),
отдаётся через `GET /devices/{name}` (`handler/devices.go:189`); EMQX
`password_hash_algorithm name=plain` (`emqx.conf:46`).
- Что: пароль отдавать единожды при создании; в БД хранить hash; EMQX перевести
на алгоритм хэша. Миграция существующих строк.
- Файлы: `store/open.go`, `handler/devices.go`, `emqx/emqx.conf`.
### P1-4. Удалить HTTP-auth/ACL `/internal/mqtt/auth|acl`
- Сейчас: EMQX ходит по PG-auth напрямую (`emqx.conf`, `backend=postgresql`).
HTTP-эндпоинты — неиспользуемый fallback, висят на публичном порту 9090 без
аутентификации (`router.go:50-51`) → перебор паролей устройств.
- Что: удалить роуты и обработчики `MQTTAuth`/`MQTTAcl` (и зависимый код).
- Файлы: `router.go`, `handler/mqtt_auth.go`.
### P1-5. Включить `AUTH_TEST_MODE=false` на проде
- Сейчас: включён для диагностики DevOps (HISTORY §38); любой Bearer проходит.
- Что: выставить false + валидный `JWT_HMAC_SECRET`, убедиться в деплое.
### P2-6. TLS верификация PG и EMQX
- Сейчас: `sslmode=require` (без verify-full) и EMQX `verify=verify_none`
(`emqx.conf:40`) — шифрование без проверки сертификата.
- Что: `sslmode=verify-full` при наличии CA; убрать `verify_none` где возможно.
---
## Трек 2. Надёжность доставки телеметрии
### P0-7. Устранить окно потери сообщения (SQS-удаление до записи в PG)
- Сейчас: `consumer.go:78` удаляет из SQS после `processTelemetry`==nil, но
`InsertTelemetry` при неполном батче лишь кладёт в память и возвращает nil
(`iot_telemetry_store.go:356-376`); flush-ошибка неполного батча только
логируется (`iot_telemetry_store.go:133`). Краш/сбой flush = безвозвратная потеря.
- Что: либо синхронная запись (убрать «возврат nil до записи»), либо явный
acknowledge только после подтверждения flush + при ошибке возвращать сообщение
в очередь (видимость + не удалять).
- Файлы: `consumer/processor.go`, `iotpg/iot_telemetry_store.go`.
### P0-8. Идемпотентность входа (dedup)
- Сейчас: `iot_telemetry` id BIGSERIAL без ключа дедупликации; envelope без
message-id (`bridge/handler.go:19`). At-least-once SQS → молчаливые дубли.
- Что: добавить message-id в envelope, UNIQUE-индекс/ON CONFLICT DO NOTHING по
(namespace, message_id) в таблице телеметрии.
- Файлы: `bridge/handler.go`, `iotpg/iot_telemetry_store.go`, схема БД.
### P1-9. `/health` отражает реальное состояние
- Сейчас: `healthHandler` всегда `{"status":"ok"}` (`router.go:72`), не отражает
bridge/consumer/PG/SQS; liveness зелёный при упавшем consumer (`main.go:106`).
- Что: ready-проверки подсистем; `main().Run` bridge/consumer — не глотать
ошибку, а помечать неготовность; `/healthz` (liveness) отделить от `/ready`
(readiness).
- Файлы: `router.go`, `main.go`.
### P1-10. Bridge: поведение при переполнении и ретраях — осознанно
- Сейчас: дроп при переполнении RAM-очереди (`sender.go:73`) и после 3 ретраев
(`sender.go:112`) — потери без метрик.
- Что: метрики дропов; при исчерпании ретраев — не молчаливый дроп, а
лог+счётчик (или при желании DLQ в shared-sqs).
- Файлы: `bridge/sender.go`.
---
## Трек 3. Тесты, CI, метрики
### P1-11. Go-тесты
- Сейчас: `*_test.go` нет ни одного.
- Что: unit-тесты критичных мест — `validateJWT` (tenant-привязка),
`InsertTelemetry`/`flushAll` (потеря), `processor` (дубли), `devices` (CRUD +
пароль), `mqtt_auth` (constant-time). Приоритет — на путях из P0-1..P0-8.
- Файлы: `*_test.go` рядом с пакетами.
### P1-12. CI
- Сейчас: `.github/workflows` нет; в `.github/` только правила.
- Что: workflow: `go vet` + `go test ./...` + `gofmt` check; сборка образа.
- Файлы: `.github/workflows/ci.yml`.
### P2-13. `/metrics` + rate-limiting
- Сейчас: `/metrics` заявлен в комментарии (`config/defaults.go:6`), но роут не
зарегистрирован в `router.go`; prometheus — только indirect dep. Rate-limit нет.
- Что: зарегистрировать `/metrics` (если решено), добавить rate-limiting на `/v1`.
- Файлы: `router.go`, `middleware/`.
---
## Трек 4. Гигиена репозитория и документов
### P2-14. Вычистить легаси
- Сейчас: `cmd/iot-operator`, `controllers/`, `internal/api/`, `cmd/mqtt-bridge`,
`cmd/sqs-consumer`; k8s/controller-runtime в `go.mod` (12 строк); бинарники в
`bin/`, `ws-probe/ws-probe`. `LEGACY.md` обещает убрать после переписывания
хендлеров — оно сделано.
- Что: удалить легаси-код (после P1-4), `go mod tidy`, убрать бинарники из дерева.
- Файлы: `go.mod`, дерево.
### P2-15. Согласовать доки с кодом (дрейф)
- Сейчас: `doc/deployment-nubes-production.md` — образы `v0.1.2`; Makefile —
`v0.1.13`; `loadtests/README.md` — visibility 30с (в коде 120с) и старая
блокировка MQTT-обработчика (уже исправлена).
- Что: привести версии и параметры в доках к фактическим.
- Файлы: `doc/deployment-nubes-production.md`, `loadtests/README.md`.
### P2-16. Emqx cookie из env
- Сейчас: `cookie = "iot-emqx-cookie-mvp"` захардкожен (`emqx.conf`).
- Что: задавать cookie через env/деплой.
- Файлы: `emqx/emqx.conf`.
---
## ПЛАТФОРМА (вне нашего кода, но блокирует прод)
1. **wss обрывы ~150с** на внешнем шлюзе (детерминированно, доказано с 3 точек).
Тикет Nubes; до фикса — устройства должны авто-переподключаться (MQTT
keepalive ≤60с).
2. **MSS/MTU-баг** — обрыв ответов >~15–64 КБ. Обход уже есть (offset-пагинация,
внутренний SQS endpoint); нужен фикс на стороне Nubes.
---
## Рекомендуемый порядок выполнения
1. P0-1, P0-2 (tenant-граница + подпись) — закрывают горизонтальный доступ.
2. P0-7, P0-8 (доставка + dedup) — закрывают потерю/дубли.
3. P0-5 (выключить AUTH_TEST_MODE) — быстро, на проде.
4. P0-3, P1-4, P1-5 (пароли + лишние эндпоинты).
5. P1-9, P1-10 (health + метрики дропов).
6. P1-11, P1-12 (тесты + CI).
7. P2-13..P2-16 (гигиена).
-28
View File
@@ -1,28 +0,0 @@
# EMQX для IoT на платформе Nubes.
# Образ: gitea.services.ngcloud.ru/nail/iot-emqx (Gitea registry платформы).
#
# ⚠️ ВАЖНО: платформа Nubes требует РОВНО ОДИН EXPOSE у образа (иначе create падает
# с «Не указан applicationUsePort, а expose в Dockerfile содержит несколько портов»).
# База emqx/emqx:5.5.1 имеет 7 EXPOSE (18083/1883/4370/5369/8083/8084/8883), а EXPOSE
# не удаляется из наследуемого образа. Поэтому копируем rootfs базового образа в чистый
# (EXPOSE НЕ переносится) и выставляем единственный порт 8083.
#
# БАЗА — официальный emqx/emqx:5.5.1. Добавляем только наш emqx.conf (auth/ACL, listeners, логи).
FROM emqx/emqx:5.5.1 AS base
FROM scratch
COPY --from=base / /
ENV LANG=C.UTF-8
ENV LC_ALL=C.UTF-8
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
COPY emqx.conf /opt/emqx/etc/emqx.conf
EXPOSE 8083
USER emqx
WORKDIR /opt/emqx
ENTRYPOINT ["/usr/bin/docker-entrypoint.sh"]
CMD ["/opt/emqx/bin/emqx", "foreground"]
-15
View File
@@ -1,15 +0,0 @@
# EMQX-контейнер для IoT (образ gitea.services.ngcloud.ru/nail/iot-emqx).
# Registry: Gitea (gitea.services.ngcloud.ru) — managed-платформа тянет образы отсюда.
REGISTRY ?= gitea.services.ngcloud.ru/nail
VERSION ?= v0.2.4
IMAGE ?= $(REGISTRY)/iot-emqx
.PHONY: docker-build docker-push
docker-build:
docker build -t $(IMAGE):$(VERSION) -t $(IMAGE):latest .
docker-push:
docker push $(IMAGE):$(VERSION)
docker push $(IMAGE):latest
-123
View File
@@ -1,123 +0,0 @@
## EMQX 5.x — конфиг для контейнера IoT на платформе Nubes.
## Вшит в образ naeel/iot-emqx. Auth/ACL — напрямую в PostgreSQL (iot_devices),
## без зависимости от адреса монолита: EMQX можно создать в deck ДО iot-service.
##
## ⚠ ${VAR}-интерполяция в этом конфиге НЕ работает (проверено на 5.5.1:
## значения попадают в конфиг буквально). Параметры PG задаются ТОЛЬКО
## официальными env-переопределениями (deck-UI или kubectl set env):
## EMQX_AUTHENTICATION__1__SERVER — host:port master (внутренний svc)
## EMQX_AUTHENTICATION__1__DATABASE — имя БД (iotdb)
## EMQX_AUTHENTICATION__1__USERNAME — роль (iot_service)
## EMQX_AUTHENTICATION__1__PASSWORD — пароль роли
## EMQX_AUTHORIZATION__SOURCES__1__SERVER / DATABASE / USERNAME / PASSWORD
## SSL обязателен (pg_hba платформы): ssl = {enable=true, verify=verify_none}.
## ⚠ В authz-postgresql НЕ поддерживаются плейсхолдеры ${action}/${topic} —
## action/topic возвращаются КОЛОНКАМИ результата запроса (проверено на 5.5.1:
## ${action} подставлялся как литерал "{action}" и ACL-запрос падал).
node {
name = "emqx@127.0.0.1"
cookie = "iot-emqx-cookie-mvp"
data_dir = "/opt/emqx/data"
}
## Auth — PostgreSQL: устройства в iot_devices.
## username устройства = namespace || '_' || device_id; бридж: namespace='__bridge',
## username = device_id (строка создаётся монолитом EnsureBridgeDevice при старте).
authentication = [
{
mechanism = password_based
backend = postgresql
enable = true
server = "127.0.0.1:5432"
database = "iotdb"
username = "iot_service"
password = "placeholder"
auto_reconnect = true
pool_size = 8
ssl {
enable = true
verify = verify_none
}
password_hash_algorithm {
name = bcrypt
## ⚠ salt_rounds НЕ поддерживается схемой 5.5.1 (проверено на образе
## emqx/emqx:5.5.1: «unknown_fields, unknown => "salt_rounds"» и
## call_hocon_failed). Оставлять его нельзя — контейнер не стартует.
## Роль поля нулевая: EMQX только СВЕРЯЕТ хеш из БД, а cost берётся из
## самого bcrypt-хеша, не из конфига.
}
query = "SELECT mqtt_password AS password_hash FROM iot_devices WHERE enabled = true AND (namespace || '_' || device_id = ${username} OR (namespace = '__bridge' AND ${username} = device_id)) LIMIT 1"
}
]
## ACL — PostgreSQL: устройства pub/sub только в свой топик ns/telemetry/device_id,
## бридж — subscribe на "+/telemetry/+".
## no_match = deny: нет строки в iot_devices или устройство выключено — запрет.
authorization {
no_match = deny
deny_action = disconnect
cache {
enable = true
max_size = 32
ttl = 1m
}
sources = [
{
type = postgresql
enable = true
server = "127.0.0.1:5432"
database = "iotdb"
username = "iot_service"
password = "placeholder"
auto_reconnect = true
pool_size = 8
ssl {
enable = true
verify = verify_none
}
query = "SELECT 'allow' AS permission, 'subscribe' AS action, '+/telemetry/+' AS topic FROM iot_devices WHERE enabled = true AND namespace = '__bridge' AND ${username} = device_id UNION ALL SELECT 'allow' AS permission, a.action, namespace || '/telemetry/' || device_id AS topic FROM iot_devices, (VALUES ('publish'), ('subscribe')) AS a(action) WHERE enabled = true AND namespace || '_' || device_id = ${username}"
}
]
}
mqtt {
max_packet_size = 1MB
max_topic_levels = 10
retain_available = false
}
## Логи: console (stdout) — видны через kubectl logs платформы.
## Файловый handler отключён (у USER emqx нет прав на /var/log/emqx при deb).
log {
console_handler {
enable = true
level = warning
}
file_handlers.default {
enable = false
}
}
## Listeners: WebSocket — единственный порт контейнера платформы (8083).
## TCP 1883 остаётся для диагностики (не экспонируется платформой).
listeners.tcp.default {
bind = "0.0.0.0:1883"
max_connections = 1024
}
listeners.ws.default {
bind = "0.0.0.0:8083"
websocket {
mqtt_path = "/mqtt"
}
max_connections = 512
}
## Dashboard ОТКЛЮЧЁН (enable=false): на CPU-квоте 500m swagger-генерация падает
## с таймаутами и засоряет логи. Диагностика — через порт 1883/ctl.
dashboard {
listeners.http {
enable = false
}
}
@@ -1,5 +1,3 @@
> ⛔⛔⛔ ЛЕГАСИ (2026-08-16) — СТАРЫЙ IoT (RabbitMQ/serverless). НЕ ПРИНИМАТЬ ВО ВНИМАНИЕ. Актуальное: HISTORY/2026-08-16-session-log.md
# IoT MVP — E2E Demo # IoT MVP — E2E Demo
## Что делает этот пример ## Что делает этот пример
@@ -1,4 +1,3 @@
# ⛔⛔⛔ ЛЕГАСИ (2026-08-16) — СТАРЫЙ IoT (RabbitMQ/serverless). НЕ ПРИНИМАТЬ ВО ВНИМАНИЕ. Актуальное: HISTORY/2026-08-16-session-log.md
""" """
Создано: 2026-04-04 Создано: 2026-04-04
handler.py — обработчик IoT-телеметрии для демонстрации IoT MVP. handler.py — обработчик IoT-телеметрии для демонстрации IoT MVP.
@@ -1,5 +1,3 @@
# ⛔⛔⛔ ЛЕГАСИ (2026-08-16) — СТАРЫЙ IoT (RabbitMQ/serverless). НЕ ПРИНИМАТЬ ВО ВНИМАНИЕ. Актуальное: HISTORY/2026-08-16-session-log.md
# Создано: 2026-04-04 # Создано: 2026-04-04
# E2E Demo: IoT Device → MQTT → RabbitMQ → Serverless Function # E2E Demo: IoT Device → MQTT → RabbitMQ → Serverless Function
# #
+60 -7
View File
@@ -3,21 +3,74 @@ module gitea.services.ngcloud.ru/Nail/IoT
go 1.25 go 1.25
require ( require (
github.com/aws/aws-sdk-go-v2 v1.41.5
github.com/aws/aws-sdk-go-v2/credentials v1.19.14
github.com/aws/aws-sdk-go-v2/service/sqs v1.42.25
github.com/eclipse/paho.mqtt.golang v1.5.1 github.com/eclipse/paho.mqtt.golang v1.5.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/mux v1.8.1 github.com/gorilla/mux v1.8.1
github.com/lib/pq v1.11.2 github.com/lib/pq v1.11.2
golang.org/x/crypto v0.42.0 github.com/segmentio/kafka-go v0.4.50
k8s.io/api v0.26.0
k8s.io/apimachinery v0.26.0
k8s.io/client-go v0.26.0
sigs.k8s.io/controller-runtime v0.14.1
) )
require ( require (
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/aws/smithy-go v1.24.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/emicklei/go-restful/v3 v3.9.0 // indirect
github.com/evanphx/json-patch/v5 v5.6.0 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/go-logr/logr v1.2.3 // indirect
github.com/go-logr/zapr v1.2.3 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/jsonreference v0.20.0 // indirect
github.com/go-openapi/swag v0.19.14 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/google/gnostic v0.5.7-v3refs // indirect
github.com/google/go-cmp v0.5.9 // indirect
github.com/google/gofuzz v1.1.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect github.com/gorilla/websocket v1.5.3 // indirect
github.com/imdario/mergo v0.3.6 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.15.9 // indirect
github.com/mailru/easyjson v0.7.6 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pierrec/lz4/v4 v4.1.15 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus/client_golang v1.14.0 // indirect
github.com/prometheus/client_model v0.3.0 // indirect
github.com/prometheus/common v0.37.0 // indirect
github.com/prometheus/procfs v0.8.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
go.uber.org/atomic v1.7.0 // indirect
go.uber.org/multierr v1.6.0 // indirect
go.uber.org/zap v1.24.0 // indirect
golang.org/x/net v0.44.0 // indirect golang.org/x/net v0.44.0 // indirect
golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b // indirect
golang.org/x/sync v0.17.0 // indirect golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/term v0.35.0 // indirect
golang.org/x/text v0.29.0 // indirect
golang.org/x/time v0.3.0 // indirect
gomodules.xyz/jsonpatch/v2 v2.2.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.28.1 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/apiextensions-apiserver v0.26.0 // indirect
k8s.io/component-base v0.26.0 // indirect
k8s.io/klog/v2 v2.80.1 // indirect
k8s.io/kube-openapi v0.0.0-20221012153701-172d655c2280 // indirect
k8s.io/utils v0.0.0-20221128185143-99ec85e7a448 // indirect
sigs.k8s.io/json v0.0.0-20220713155537-f223a00ba0e2 // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.2.3 // indirect
sigs.k8s.io/yaml v1.3.0 // indirect
) )
+626 -14
View File
@@ -1,28 +1,640 @@
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w= cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4= cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c= cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A= cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps= cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
github.com/aws/aws-sdk-go-v2/service/sqs v1.42.25 h1:8Bv3TQ1Cob6HLlpUbAnWxeHhAkYScJO9RIHh2WPXaxw= cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
github.com/aws/aws-sdk-go-v2/service/sqs v1.42.25/go.mod h1:eDstEbM0OEnBUnNQxIA7j74Jy61cCU1S4EMlCtdMwzs= cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE= github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE=
github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU= github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
github.com/emicklei/go-restful/v3 v3.9.0 h1:XwGDlfxEnQZzuopoqxwSEllNcCOM9DhhFyhFIIGKwxE=
github.com/emicklei/go-restful/v3 v3.9.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ=
github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH5pOlLGNtQ5lPWQu84=
github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
github.com/evanphx/json-patch/v5 v5.6.0 h1:b91NhWfaz02IuVxO9faSllyAtNXHMPkC5J8sJCLunww=
github.com/evanphx/json-patch/v5 v5.6.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2VvlbKOFpnXhI9Bw4=
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0=
github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/zapr v1.2.3 h1:a9vnzlIBPQBBkeaR9IuMUfmVOrQlkoC4YfPoFkX3T7A=
github.com/go-logr/zapr v1.2.3/go.mod h1:eIauM6P8qSvTw5o2ez6UEAfGjQKrxQTl5EoK+Qa2oG4=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonreference v0.20.0 h1:MYlu0sBgChmCfJxxUKZ8g1cPWFOB37YSZqewK7OKeyA=
github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo=
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.14 h1:gm3vOOXfiuw5i9p5N9xJvfjvuofpyvLA9Wr6QfK5Fng=
github.com/go-openapi/swag v0.19.14/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/gnostic v0.5.7-v3refs h1:FhTMOKj2VhjpouxvWJAV1TL304uMlb9zcDqkl6cEI54=
github.com/google/gnostic v0.5.7-v3refs/go.mod h1:73MKFl6jIHelAJNaBGFzt3SPtZULs9dYrGFt8OiIsHQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.1.0 h1:Hsa8mG0dQ46ij8Sl2AYJDUv1oA9/d6Vk+3LG99Oe02g=
github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28=
github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.15.9 h1:wKRjX6JRtDdrE9qwa4b/Cip7ACOshUI4smpCQanqjSY=
github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs= github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs=
github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/matttproud/golang_protobuf_extensions v1.0.2 h1:hAHbPm5IJGijwng3PWk09JkG9WeqChjprR5s9bBZ+OM=
github.com/matttproud/golang_protobuf_extensions v1.0.2/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/onsi/ginkgo/v2 v2.6.0 h1:9t9b9vRUbFq3C4qKFCGkVuq/fIHji802N1nrtkh1mNc=
github.com/onsi/ginkgo/v2 v2.6.0/go.mod h1:63DOGlLAH8+REH8jUGdL3YpCpu7JODesutUjdENfUAc=
github.com/onsi/gomega v1.24.1 h1:KORJXNNTzJXzu4ScJWssJfJMnJ+2QJqhoQSRwNlze9E=
github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM=
github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0=
github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY=
github.com/prometheus/client_golang v1.14.0 h1:nJdhIvne2eSX/XRAFV9PcvFFRbrjbcTUj0VP62TMhnw=
github.com/prometheus/client_golang v1.14.0/go.mod h1:8vpkKitgIVNcqrRBWh1C4TIUQgYNtG/XQE4E/Zae36Y=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4=
github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w=
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc=
github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls=
github.com/prometheus/common v0.37.0 h1:ccBbHCgIiT9uSoFY0vX8H3zsNR5eLt17/RQLUvn8pXE=
github.com/prometheus/common v0.37.0/go.mod h1:phzohg0JFMnBEFGxTDbfu3QyL5GI8gTQJFhYO5B3mfA=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5mo=
github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/segmentio/kafka-go v0.4.50 h1:mcyC3tT5WeyWzrFbd6O374t+hmcu1NKt2Pu1L3QaXmc=
github.com/segmentio/kafka-go v0.4.50/go.mod h1:Y1gn60kzLEEaW28YshXyk2+VCUKbJ3Qr6DrnT3i4+9E=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI=
go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60=
go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b h1:clP8eMhB30EHdc0bd2Twtq6kgU7yl5ub2cQLSdrv1Dg=
golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gomodules.xyz/jsonpatch/v2 v2.2.0 h1:4pT439QV83L+G9FkcCriY6EkpcK6r6bK+A5FBUMI7qY=
gomodules.xyz/jsonpatch/v2 v2.2.0/go.mod h1:WXp+iVDkoLQqPudfQ9GBlwB2eZ5DKOnjQZCYdOS8GPY=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201019141844-1ed22bb0c154/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
k8s.io/api v0.26.0 h1:IpPlZnxBpV1xl7TGk/X6lFtpgjgntCg8PJ+qrPHAC7I=
k8s.io/api v0.26.0/go.mod h1:k6HDTaIFC8yn1i6pSClSqIwLABIcLV9l5Q4EcngKnQg=
k8s.io/apiextensions-apiserver v0.26.0 h1:Gy93Xo1eg2ZIkNX/8vy5xviVSxwQulsnUdQ00nEdpDo=
k8s.io/apiextensions-apiserver v0.26.0/go.mod h1:7ez0LTiyW5nq3vADtK6C3kMESxadD51Bh6uz3JOlqWQ=
k8s.io/apimachinery v0.26.0 h1:1feANjElT7MvPqp0JT6F3Ss6TWDwmcjLypwoPpEf7zg=
k8s.io/apimachinery v0.26.0/go.mod h1:tnPmbONNJ7ByJNz9+n9kMjNP8ON+1qoAIIC70lztu74=
k8s.io/client-go v0.26.0 h1:lT1D3OfO+wIi9UFolCrifbjUUgu7CpLca0AD8ghRLI8=
k8s.io/client-go v0.26.0/go.mod h1:I2Sh57A79EQsDmn7F7ASpmru1cceh3ocVT9KlX2jEZg=
k8s.io/component-base v0.26.0 h1:0IkChOCohtDHttmKuz+EP3j3+qKmV55rM9gIFTXA7Vs=
k8s.io/component-base v0.26.0/go.mod h1:lqHwlfV1/haa14F/Z5Zizk5QmzaVf23nQzCwVOQpfC8=
k8s.io/klog/v2 v2.80.1 h1:atnLQ121W371wYYFawwYx1aEY2eUfs4l3J72wtgAwV4=
k8s.io/klog/v2 v2.80.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0=
k8s.io/kube-openapi v0.0.0-20221012153701-172d655c2280 h1:+70TFaan3hfJzs+7VK2o+OGxg8HsuBr/5f6tVAjDu6E=
k8s.io/kube-openapi v0.0.0-20221012153701-172d655c2280/go.mod h1:+Axhij7bCpeqhklhUTe3xmOn6bWxolyZEeyaFpjGtl4=
k8s.io/utils v0.0.0-20221128185143-99ec85e7a448 h1:KTgPnR10d5zhztWptI952TNtt/4u5h3IzDXkdIMuo2Y=
k8s.io/utils v0.0.0-20221128185143-99ec85e7a448/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
sigs.k8s.io/controller-runtime v0.14.1 h1:vThDes9pzg0Y+UbCPY3Wj34CGIYPgdmspPm2GIpxpzM=
sigs.k8s.io/controller-runtime v0.14.1/go.mod h1:GaRkrY8a7UZF0kqFFbUKG7n9ICiTY5T55P1RiE3UZlU=
sigs.k8s.io/json v0.0.0-20220713155537-f223a00ba0e2 h1:iXTIw73aPyC+oRdyqqvVJuloN1p0AC/kzH07hu3NE+k=
sigs.k8s.io/json v0.0.0-20220713155537-f223a00ba0e2/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0=
sigs.k8s.io/structured-merge-diff/v4 v4.2.3 h1:PRbqxJClWWYMNV1dhaG4NsibJbArud9kFxnAMREiWFE=
sigs.k8s.io/structured-merge-diff/v4 v4.2.3/go.mod h1:qjx8mGObPmV2aSZepjQjbmb2ihdVs8cGKBraizNC69E=
sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=
sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
+27
View File
@@ -0,0 +1,27 @@
// Создано: 2026-04-06
// admin_embed.go — встраивает HTML страницы администратора IoT в бинарник через go:embed.
//
// Страница /iot-admin доступна без JWT — данные не содержит.
// Все данные загружаются через /iot-admin/stats (защищён ADMIN_STATS_TOKEN).
// Почему go:embed: единый деплой, нет отдельных pod-ов, нет nginx drift.
package api
import (
_ "embed"
"net/http"
)
// iotAdminHTML — бинарное содержимое страницы администратора IoT, встроенное при сборке.
//
//go:embed ui/iot-admin.html
var iotAdminHTML []byte
// ServeIoTAdmin обрабатывает GET /iot-admin — отдаёт HTML страницу администратора.
// Auth не нужен для HTML — сама страница ничего не содержит, только UI оболочка.
func ServeIoTAdmin(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache, must-revalidate")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(iotAdminHTML)
}
+32
View File
@@ -0,0 +1,32 @@
// Создано: 2026-04-04
// console_embed.go — встраивает HTML-файл IoT консоли в бинарник оператора через go:embed.
//
// Файл ui/iot-console.html встраивается при компиляции и раздаётся по GET /console.
// Путь /console доступен без JWT — это публичная статическая страница.
// Авторизация в UI происходит через Bearer-токен который пользователь вводит сам.
//
// Почему go:embed а не отдельный nginx: нет лишних pod'ов, единый деплой, нет drift.
// Почему /console без auth: HTML файл не содержит секретов, токен вводит пользователь.
package api
import (
_ "embed"
"net/http"
)
// iotConsoleHTML — бинарное содержимое IoT консоли, встроенное при сборке.
// При изменении HTML-файла достаточно пересобрать оператор.
//
//go:embed ui/iot-console.html
var iotConsoleHTML []byte
// ServeIoTConsole обрабатывает GET /console — отдаёт HTML SPA без JWT-проверки.
// Браузер кэширует HTML; API-запросы из JS защищены Bearer-токеном.
func ServeIoTConsole(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
// Не кэшировать агрессивно — консоль обновляется вместе с оператором
w.Header().Set("Cache-Control", "no-cache, must-revalidate")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(iotConsoleHTML)
}
+61
View File
@@ -0,0 +1,61 @@
// Создано: 2026-04-12
// Handler — контейнер зависимостей для IoT REST handlers.
// Упрощённая версия из sless — только IoT-специфичные поля.
//
// Бизнес-логика:
// - iot_device_handler.go — CRUD устройств + MQTT auth
// - iot_telemetry_handler.go — чтение телеметрии
// - iot_admin_stats_handler.go — админ-статистика
package handler
import (
"encoding/json"
"log/slog"
"net/http"
"github.com/gorilla/mux"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"gitea.services.ngcloud.ru/Nail/IoT/internal/storage/iotpg"
)
// defaultNamespace — fallback namespace для dev/тестов.
const defaultNamespace = "default"
// Handler содержит зависимости для всех IoT REST-обработчиков.
type Handler struct {
K8s client.Client
Scheme *runtime.Scheme
// IoTPG — хранилище IoT телеметрии (per-tenant Postgres). nil если IOT_PG_DSN не задан.
IoTPG *iotpg.IoTPostgresStore
// KafkaBrokers — адреса Kafka брокеров для чтения consumer lag на admin странице.
KafkaBrokers string
Log *slog.Logger
}
// writeJSON отправляет JSON-ответ с указанным статусом.
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
// errResp возвращает структуру ошибки для JSON.
func errResp(msg string) map[string]string {
return map[string]string{"error": msg}
}
// pathVar читает переменную пути из gorilla/mux.
func pathVar(r *http.Request, key string) string {
return mux.Vars(r)[key]
}
// namespace читает {namespace} из пути URL.
func namespace(r *http.Request) string {
if ns := mux.Vars(r)["namespace"]; ns != "" {
return ns
}
return defaultNamespace
}
@@ -0,0 +1,214 @@
// Создано: 2026-04-06
// iot_admin_stats_handler.go — handler для страницы администратора IoT.
//
// Endpoints:
// GET /iot-admin/stats — JSON с агрегированной статистикой (защищён ADMIN_STATS_TOKEN)
//
// Источники данных:
// - PostgreSQL (IoTPG): counts per tenant, last 1h/24h, latest rows
// - Kafka: consumer lag (latest offset - committed offset для group iot-pg-consumer)
// - K8s: статус подов iot-mqtt-bridge и iot-kafka-consumer
//
// Авторизация: Bearer из env ADMIN_STATS_TOKEN.
// Если ADMIN_STATS_TOKEN не задан — endpoint возвращает 503.
package handler
import (
"context"
"fmt"
"net/http"
"os"
"strings"
"time"
kafka "github.com/segmentio/kafka-go"
corev1 "k8s.io/api/core/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
)
// iotAdminPodStatus — краткая информация о k8s pod для страницы администратора.
type iotAdminPodStatus struct {
Name string `json:"name"`
Phase string `json:"phase"`
Ready bool `json:"ready"`
Restarts int32 `json:"restarts"`
Age string `json:"age"`
}
// iotAdminKafkaStats — информация о Kafka топике и consumer lag.
type iotAdminKafkaStats struct {
LatestOffset int64 `json:"latest_offset"`
CommittedOffset int64 `json:"committed_offset"`
ConsumerLag int64 `json:"consumer_lag"`
Error string `json:"error,omitempty"`
}
// AdminStats обрабатывает GET /iot-admin/stats.
// Проверяет Bearer-токен из ADMIN_STATS_TOKEN, затем собирает и возвращает статистику.
func (h *Handler) AdminStats(w http.ResponseWriter, r *http.Request) {
adminToken := os.Getenv("ADMIN_STATS_TOKEN")
if adminToken == "" {
writeJSON(w, http.StatusServiceUnavailable, errResp("admin stats not configured: ADMIN_STATS_TOKEN not set"))
return
}
authHeader := r.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, "Bearer ") || strings.TrimPrefix(authHeader, "Bearer ") != adminToken {
writeJSON(w, http.StatusUnauthorized, errResp("unauthorized"))
return
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
result := map[string]any{
"collected_at": time.Now().UTC(),
}
// PostgreSQL: статистика по всем tenant
if h.IoTPG != nil {
pgStats, err := h.IoTPG.GetAdminStats(ctx)
if err != nil {
result["postgres"] = map[string]any{"reachable": false, "error": err.Error()}
} else {
result["postgres"] = pgStats
}
} else {
result["postgres"] = map[string]any{"reachable": false, "error": "IoTPG not configured"}
}
// Kafka: consumer lag для топика iot.telemetry / группы iot-pg-consumer
result["kafka"] = h.collectIotKafkaLag(ctx)
// K8s: статус подов bridge и consumer
result["pods"] = h.collectIotPodStatuses(ctx)
writeJSON(w, http.StatusOK, result)
}
// collectIotKafkaLag получает latest offset топика и committed offset consumer group,
// вычисляет lag = latest - committed.
// Topic: "iot.telemetry", Consumer Group: "iot-pg-consumer".
func (h *Handler) collectIotKafkaLag(ctx context.Context) iotAdminKafkaStats {
if h.KafkaBrokers == "" {
return iotAdminKafkaStats{Error: "KAFKA_BROKERS not configured"}
}
brokers := strings.Split(h.KafkaBrokers, ",")
brokerAddr := kafka.TCP(brokers...)
kc := &kafka.Client{
Addr: brokerAddr,
Timeout: 5 * time.Second,
}
const topic = "iot.telemetry"
const group = "iot-pg-consumer"
// Получаем latest offset (конец лога — сколько всего сообщений прошло)
offsetsResp, err := kc.ListOffsets(ctx, &kafka.ListOffsetsRequest{
Addr: brokerAddr,
Topics: map[string][]kafka.OffsetRequest{
topic: {kafka.LastOffsetOf(0)},
},
})
if err != nil {
return iotAdminKafkaStats{Error: fmt.Sprintf("list offsets: %v", err)}
}
var latestOffset int64
if partitions, ok := offsetsResp.Topics[topic]; ok && len(partitions) > 0 {
if partitions[0].Error == nil {
latestOffset = partitions[0].LastOffset
}
}
// Получаем committed offset consumer group (что consumer уже обработал)
fetchResp, err := kc.OffsetFetch(ctx, &kafka.OffsetFetchRequest{
Addr: brokerAddr,
GroupID: group,
Topics: map[string][]int{topic: {0}},
})
if err != nil {
return iotAdminKafkaStats{
LatestOffset: latestOffset,
Error: fmt.Sprintf("offset fetch: %v", err),
}
}
var committedOffset int64
if partitions, ok := fetchResp.Topics[topic]; ok && len(partitions) > 0 {
if partitions[0].Error == nil {
committedOffset = partitions[0].CommittedOffset
}
}
lag := latestOffset - committedOffset
if lag < 0 {
lag = 0
}
return iotAdminKafkaStats{
LatestOffset: latestOffset,
CommittedOffset: committedOffset,
ConsumerLag: lag,
}
}
// collectIotPodStatuses собирает статус k8s pods для bridge и consumer по label app={name}.
func (h *Handler) collectIotPodStatuses(ctx context.Context) map[string]any {
result := map[string]any{}
for _, appLabel := range []string{"iot-mqtt-bridge", "iot-kafka-consumer"} {
podList := &corev1.PodList{}
if err := h.K8s.List(ctx, podList,
client.InNamespace("sless"),
client.MatchingLabels{"app": appLabel},
); err != nil {
result[appLabel] = map[string]any{"error": err.Error()}
continue
}
if len(podList.Items) == 0 {
result[appLabel] = map[string]any{"status": "not found"}
continue
}
pod := podList.Items[0]
var restarts int32
for _, cs := range pod.Status.ContainerStatuses {
restarts += cs.RestartCount
}
ready := false
for _, cond := range pod.Status.Conditions {
if cond.Type == corev1.PodReady && cond.Status == corev1.ConditionTrue {
ready = true
}
}
result[appLabel] = iotAdminPodStatus{
Name: pod.Name,
Phase: string(pod.Status.Phase),
Ready: ready,
Restarts: restarts,
Age: iotFormatAge(pod.CreationTimestamp.Time),
}
}
return result
}
// iotFormatAge возвращает человекочитаемый возраст (s/m/h/d) pod-а.
func iotFormatAge(created time.Time) string {
d := time.Since(created)
switch {
case d < time.Minute:
return fmt.Sprintf("%ds", int(d.Seconds()))
case d < time.Hour:
return fmt.Sprintf("%dm", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%dh", int(d.Hours()))
default:
return fmt.Sprintf("%dd", int(d.Hours()/24))
}
}
+454
View File
@@ -0,0 +1,454 @@
// Создано: 2026-04-04
// iot_device_handler.go — HTTP handlers для IoT-устройств (CRUD) и MQTT auth.
//
// Endpoints:
// POST /internal/mqtt/auth → MQTTAuth (без JWT, для EMQX)
// POST /v1/namespaces/{ns}/iot/devices → CreateIoTDevice
// GET /v1/namespaces/{ns}/iot/devices → ListIoTDevices
// GET /v1/namespaces/{ns}/iot/devices/{name} → GetIoTDevice (включает credentials)
// DELETE /v1/namespaces/{ns}/iot/devices/{name} → DeleteIoTDevice
// PATCH /v1/namespaces/{ns}/iot/devices/{name} → UpdateIoTDevice
//
// MQTTAuth вызывается EMQX при каждом MQTT CONNECT:
// - всегда возвращает HTTP 200 (non-200 = EMQX игнорирует backend)
// - {"result": "allow"|"deny"} в теле
//
// GetIoTDevice — единственный endpoint возвращающий mqtt-password.
// ListIoTDevices — без паролей (security by design).
package handler
import (
"crypto/subtle"
"encoding/json"
"net/http"
"strings"
"time"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
iotv1alpha1 "gitea.services.ngcloud.ru/Nail/IoT/api/v1alpha1"
)
// ——————————————————————————————————————————
// Типы запросов / ответов
// ——————————————————————————————————————————
// iotDeviceCreateRequest — тело POST при создании IoTDevice.
type iotDeviceCreateRequest struct {
// Name — имя k8s объекта IoTDevice (должно быть уникальным в namespace)
Name string `json:"name"`
// DeviceID — идентификатор устройства, используется в MQTT username и имени Secret
DeviceID string `json:"device_id"`
// Enabled — активно ли устройство с момента создания
Enabled *bool `json:"enabled"`
// Metadata — произвольные метаданные (модель, локация и т.д.)
Metadata map[string]string `json:"metadata,omitempty"`
}
// iotDeviceUpdateRequest — тело PATCH при обновлении IoTDevice.
type iotDeviceUpdateRequest struct {
// Enabled — включить/отключить устройство
Enabled *bool `json:"enabled"`
}
// iotDeviceResponse — ответ при чтении одного IoTDevice.
// MQTTPassword заполняется только из GetIoTDevice (чтение из Secret).
type iotDeviceResponse struct {
Name string `json:"name"`
Namespace string `json:"namespace"`
DeviceID string `json:"device_id"`
Enabled bool `json:"enabled"`
Phase iotv1alpha1.IoTDevicePhase `json:"phase"`
MQTTUsername string `json:"mqtt_username,omitempty"`
MQTTPassword string `json:"mqtt_password,omitempty"` // только в GET /devices/{name}
SecretName string `json:"secret_name,omitempty"`
TopicPrefix string `json:"topic_prefix,omitempty"`
LastConnected string `json:"last_connected,omitempty"`
Message string `json:"message,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
}
// mqttAuthRequest — тело запроса от EMQX при MQTT CONNECT.
// EMQX 5.x посылает JSON: username, password, clientid, peerhost.
type mqttAuthRequest struct {
Username string `json:"username"`
Password string `json:"password"`
ClientID string `json:"clientid"`
PeerHost string `json:"peerhost"`
}
// mqttAuthResponse — ответ для EMQX. Всегда HTTP 200.
// result = "allow" | "deny"
// ACL — список правил pub/sub, изолирует топики по устройству.
type mqttAuthResponse struct {
Result string `json:"result"`
ACL []aclRule `json:"acl,omitempty"`
}
// aclRule — одно правило ACL для EMQX HTTP auth plugin.
// permission: "allow" | "deny"
// action: "publish" | "subscribe" | "all"
// topic: точный топик или wildcard (#, +)
type aclRule struct {
Permission string `json:"permission"`
Action string `json:"action"`
Topic string `json:"topic"`
}
// ——————————————————————————————————————————
// Вспомогательные функции
// ——————————————————————————————————————————
// deviceToResponse конвертирует IoTDevice CRD в ответ API.
// password передаётся отдельно — берётся из Secret только в GetIoTDevice.
func deviceToResponse(d *iotv1alpha1.IoTDevice, password string) iotDeviceResponse {
resp := iotDeviceResponse{
Name: d.Name,
Namespace: d.Namespace,
DeviceID: d.Spec.DeviceID,
Enabled: d.Spec.Enabled,
Phase: d.Status.Phase,
MQTTUsername: d.Status.MQTTUsername,
MQTTPassword: password,
SecretName: d.Status.SecretName,
TopicPrefix: d.Status.TopicPrefix,
Message: d.Status.Message,
Metadata: d.Spec.Metadata,
}
if d.Status.LastConnected != nil && !d.Status.LastConnected.IsZero() {
resp.LastConnected = d.Status.LastConnected.UTC().Format(time.RFC3339)
}
if !d.CreationTimestamp.IsZero() {
resp.CreatedAt = d.CreationTimestamp.UTC().Format("2006-01-02 15:04:05 UTC")
}
return resp
}
// ——————————————————————————————————————————
// MQTT Auth — Этап 2
// ——————————————————————————————————————————
// MQTTAuth — POST /internal/mqtt/auth
// Вызывается EMQX при каждом MQTT CONNECT.
// НЕ защищён JWT middleware — доступен только из кластера (путь /internal/).
//
// Логика аутентификации:
// 1. Распарсить username → namespace + deviceId
// 2. Получить Secret iot-{deviceId} в namespace
// 3. Constant-time сравнение пароля (защита от timing attacks)
// 4. Проверить что IoTDevice существует и enabled=true
// 5. Обновить status.lastConnected в IoTDevice
func (h *Handler) MQTTAuth(w http.ResponseWriter, r *http.Request) {
var req mqttAuthRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
// Плохой JSON от EMQX — deny, но не 400 (EMQX игнорирует non-200)
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
return
}
// Парсим username: "{namespace}_{deviceId}"
// Namespace содержит только [a-z0-9-], первый "_" — разделитель.
idx := strings.Index(req.Username, "_")
if idx < 0 {
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
return
}
ns := req.Username[:idx]
deviceID := req.Username[idx+1:]
if ns == "" || deviceID == "" {
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
return
}
// Читаем Secret с MQTT credentials
secretName := "iot-" + deviceID
secret := &corev1.Secret{}
if err := h.K8s.Get(r.Context(), client.ObjectKey{Namespace: ns, Name: secretName}, secret); err != nil {
// Secret не найден или ошибка k8s — deny
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
return
}
// Constant-time сравнение пароля — защита от timing attacks
storedPassword := secret.Data["mqtt-password"]
if subtle.ConstantTimeCompare(storedPassword, []byte(req.Password)) != 1 {
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
return
}
// Проверяем что IoTDevice активно
device := &iotv1alpha1.IoTDevice{}
if err := h.K8s.Get(r.Context(), client.ObjectKey{Namespace: ns, Name: deviceID}, device); err != nil {
// IoTDevice не найден (или удалён) — deny
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
return
}
if !device.Spec.Enabled {
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
return
}
// Обновляем lastConnected в статусе устройства (best-effort, ошибка не критична)
now := metav1.NewTime(time.Now().UTC())
device.Status.LastConnected = &now
if err := h.K8s.Status().Update(r.Context(), device); err != nil {
h.Log.Warn("mqtt auth: failed to update lastConnected", "device", deviceID, "err", err)
// Продолжаем — это некритично, устройство всё равно авторизовано
}
// Формируем ACL правила для этого подключения.
// Топик устройства: "{namespace}/telemetry/{deviceId}"
// Это то что строит эмулятор: topicPrefix + "telemetry/" + device_id
// topicPrefix = "{ns}/" → итого "{ns}/telemetry/{deviceId}"
deviceTopic := ns + "/telemetry/" + deviceID
var aclRules []aclRule
if req.ClientID == "sless-iot-bridge" {
// Bridge подписывается на "+/telemetry/+" (все тенанты) — разрешаем
// Bridge НЕ публикует через MQTT — только читает
aclRules = []aclRule{
{Permission: "allow", Action: "subscribe", Topic: "+/telemetry/+"},
{Permission: "deny", Action: "all", Topic: "#"},
}
} else {
// Обычное IoT устройство: только свой топик
aclRules = []aclRule{
{Permission: "allow", Action: "publish", Topic: deviceTopic},
{Permission: "allow", Action: "subscribe", Topic: deviceTopic},
{Permission: "deny", Action: "all", Topic: "#"},
}
}
writeJSON(w, http.StatusOK, mqttAuthResponse{
Result: "allow",
ACL: aclRules,
})
}
// ——————————————————————————————————————————
// IoT Device CRUD — Этап 4
// ——————————————————————————————————————————
// CreateIoTDevice — POST /v1/namespaces/{namespace}/iot/devices
// Создаёт IoTDevice CRD. Контроллер асинхронно сгенерирует MQTT credentials.
// credentials доступны через GET /devices/{name} после reconcile (phase=Active).
func (h *Handler) CreateIoTDevice(w http.ResponseWriter, r *http.Request) {
ns := namespace(r)
var req iotDeviceCreateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, errResp("invalid JSON: "+err.Error()))
return
}
if req.Name == "" {
writeJSON(w, http.StatusBadRequest, errResp("name is required"))
return
}
if req.DeviceID == "" {
writeJSON(w, http.StatusBadRequest, errResp("device_id is required"))
return
}
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}
device := &iotv1alpha1.IoTDevice{
ObjectMeta: metav1.ObjectMeta{
Name: req.Name,
Namespace: ns,
},
Spec: iotv1alpha1.IoTDeviceSpec{
DeviceID: req.DeviceID,
Enabled: enabled,
Metadata: req.Metadata,
},
}
if err := h.K8s.Create(r.Context(), device); err != nil {
if errors.IsAlreadyExists(err) {
writeJSON(w, http.StatusConflict, errResp("iot device already exists"))
return
}
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
return
}
writeJSON(w, http.StatusCreated, deviceToResponse(device, ""))
}
// ListIoTDevices — GET /v1/namespaces/{namespace}/iot/devices
// Возвращает список устройств БЕЗ паролей (security by design).
func (h *Handler) ListIoTDevices(w http.ResponseWriter, r *http.Request) {
ns := namespace(r)
list := &iotv1alpha1.IoTDeviceList{}
if err := h.K8s.List(r.Context(), list, client.InNamespace(ns)); err != nil {
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
return
}
result := make([]iotDeviceResponse, 0, len(list.Items))
for i := range list.Items {
result = append(result, deviceToResponse(&list.Items[i], ""))
}
writeJSON(w, http.StatusOK, result)
}
// GetIoTDevice — GET /v1/namespaces/{namespace}/iot/devices/{name}
// Возвращает устройство включая mqtt_password из Secret.
// mqtt_password нужен пользователю для конфигурации физического устройства.
func (h *Handler) GetIoTDevice(w http.ResponseWriter, r *http.Request) {
ns := namespace(r)
name := pathVar(r, "name")
device := &iotv1alpha1.IoTDevice{}
if err := h.K8s.Get(r.Context(), client.ObjectKey{Namespace: ns, Name: name}, device); err != nil {
if errors.IsNotFound(err) {
writeJSON(w, http.StatusNotFound, errResp("iot device not found"))
return
}
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
return
}
// Читаем пароль из Secret — если ещё не создан (phase=Pending), password будет пустым
password := ""
if device.Status.SecretName != "" {
secret := &corev1.Secret{}
err := h.K8s.Get(r.Context(), client.ObjectKey{Namespace: ns, Name: device.Status.SecretName}, secret)
if err == nil {
password = string(secret.Data["mqtt-password"])
}
// Если Secret не найден — просто передаём пустой пароль (устройство ещё provisioning)
}
writeJSON(w, http.StatusOK, deviceToResponse(device, password))
}
// DeleteIoTDevice — DELETE /v1/namespaces/{namespace}/iot/devices/{name}
// Удаляет IoTDevice CRD. Контроллер через finalizer удалит Secret каскадно.
func (h *Handler) DeleteIoTDevice(w http.ResponseWriter, r *http.Request) {
ns := namespace(r)
name := pathVar(r, "name")
device := &iotv1alpha1.IoTDevice{}
if err := h.K8s.Get(r.Context(), client.ObjectKey{Namespace: ns, Name: name}, device); err != nil {
if errors.IsNotFound(err) {
writeJSON(w, http.StatusNotFound, errResp("iot device not found"))
return
}
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
return
}
if err := h.K8s.Delete(r.Context(), device); err != nil {
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
return
}
w.WriteHeader(http.StatusNoContent)
}
// UpdateIoTDevice — PATCH /v1/namespaces/{namespace}/iot/devices/{name}
// Позволяет включить/отключить устройство (spec.enabled).
// Контроллер увидит изменение и обновит status.phase.
func (h *Handler) UpdateIoTDevice(w http.ResponseWriter, r *http.Request) {
ns := namespace(r)
name := pathVar(r, "name")
var req iotDeviceUpdateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, errResp("invalid JSON: "+err.Error()))
return
}
if req.Enabled == nil {
writeJSON(w, http.StatusBadRequest, errResp("enabled field is required"))
return
}
device := &iotv1alpha1.IoTDevice{}
if err := h.K8s.Get(r.Context(), client.ObjectKey{Namespace: ns, Name: name}, device); err != nil {
if errors.IsNotFound(err) {
writeJSON(w, http.StatusNotFound, errResp("iot device not found"))
return
}
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
return
}
device.Spec.Enabled = *req.Enabled
if err := h.K8s.Update(r.Context(), device); err != nil {
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
return
}
writeJSON(w, http.StatusOK, deviceToResponse(device, ""))
}
// ——————————————————————————————————————————
// MQTT ACL — авторизация pub/sub
// ——————————————————————————————————————————
// mqttAclRequest — тело запроса от EMQX при каждом publish/subscribe.
type mqttAclRequest struct {
Username string `json:"username"`
ClientID string `json:"clientid"`
Action string `json:"action"` // "publish" | "subscribe"
Topic string `json:"topic"`
}
// MQTTAcl — POST /internal/mqtt/acl
// Вызывается EMQX для каждого pub/sub действия.
// НЕ защищён JWT — доступен только из кластера.
//
// Логика разрешений:
// 1. Bridge clientid "sless-iot-bridge" — subscribe на любой топик (нужен для "+/telemetry/+")
// 2. IoT Device (username "{ns}_{deviceId}") — publish/subscribe на "{ns}/telemetry/{deviceId}"
func (h *Handler) MQTTAcl(w http.ResponseWriter, r *http.Request) {
var req mqttAclRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
return
}
// Специальный случай: mqtt-bridge подписывается на "+/telemetry/+" (все тенанты).
// Публикация bridge НЕ разрешена — только чтение.
if req.ClientID == "sless-iot-bridge" {
if req.Action == "subscribe" {
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "allow"})
} else {
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
}
return
}
// Парсим username → namespace + deviceId (формат: "{ns}_{deviceId}")
// strings.Index находит ПЕРВЫЙ '_' — namespace содержит только дефисы
idx := strings.Index(req.Username, "_")
if idx < 0 {
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
return
}
ns := req.Username[:idx]
deviceID := req.Username[idx+1:]
if ns == "" || deviceID == "" {
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
return
}
// Разрешённый топик: "{ns}/telemetry/{deviceId}"
// Это то что эмулятор строит как: topicPrefix + "telemetry/" + device_id
// topicPrefix = "{ns}/" → итого "{ns}/telemetry/{deviceId}"
allowedTopic := ns + "/telemetry/" + deviceID
if req.Topic == allowedTopic {
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "allow"})
return
}
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
}
@@ -0,0 +1,55 @@
// Создано: 2026-04-05
// iot_telemetry_handler.go — REST handler для чтения IoT телеметрии.
//
// Endpoint:
// GET /v1/namespaces/{namespace}/iot/telemetry?device={id}&limit={n}
//
// Авторизация: Bearer JWT → namespace validation (как все /v1/ маршруты).
// Данные берутся из per-tenant Postgres DB через IoTPostgresStore.
// Если IoTPG не инициализирован (IOT_PG_DSN не задан) — возвращает 503.
package handler
import (
"net/http"
"strconv"
"github.com/gorilla/mux"
"gitea.services.ngcloud.ru/Nail/IoT/internal/storage/iotpg"
)
// ListIoTTelemetry обрабатывает GET /v1/namespaces/{namespace}/iot/telemetry.
// Параметры: device (опционально), limit (default 50, max 1000).
func (h *Handler) ListIoTTelemetry(w http.ResponseWriter, r *http.Request) {
if h.IoTPG == nil {
writeJSON(w, http.StatusServiceUnavailable, errResp("IoT telemetry storage not configured"))
return
}
ns := mux.Vars(r)["namespace"]
deviceID := r.URL.Query().Get("device")
limit := 50
if ls := r.URL.Query().Get("limit"); ls != "" {
if n, err := strconv.Atoi(ls); err == nil && n > 0 {
limit = n
}
}
rows, err := h.IoTPG.QueryTelemetry(r.Context(), ns, deviceID, limit)
if err != nil {
h.Log.Error("query IoT telemetry", "namespace", ns, "device", deviceID, "err", err)
writeJSON(w, http.StatusInternalServerError, errResp("failed to query telemetry"))
return
}
// Возвращаем пустой массив вместо null — удобнее для JS
if rows == nil {
rows = []iotpg.TelemetryRow{}
}
writeJSON(w, http.StatusOK, map[string]any{
"items": rows,
"count": len(rows),
})
}
+165
View File
@@ -0,0 +1,165 @@
// Изменено: 2026-04-05
// Auth middleware — проверяет Bearer JWT-токен из заголовка Authorization.
//
// Архитектура аутентификации:
// - В v1 токен — это JWT облака (nubes), выданный при логине.
// - Оператор НЕ проверяет подпись JWT (публичный ключ nubes недоступен внутри кластера).
// - Проверяется только структура JWT и claim "sub" (не пустой) и "exp" (не истёк).
// - Полная проверка подлинности токена происходит в провайдере terraform через PingNubesAPI.
// - Такой подход называют "trusted perimeter": оператор доступен только внутри кластера,
// внешний доступ — через Ingress, где токен уже проверен на уровне API-шлюза.
//
// TODO v2: получать публичный ключ из nubes JWKS endpoint и проверять подпись RS256.
//
// ──────────────────────────────────────────────────────────────────────────────
// ТЕСТОВЫЙ РЕЖИМ (authTestMode = true):
// Принимается ЛЮБАЯ строка без пробелов — не обязательно JWT.
// Это позволяет тестировать UI/API без реального токена nubes.
// Строка используется как идентификатор пользователя (аналог JWT.sub),
// namespace выводится из неё так же: SHA256 → первые 16 байт hex → "sless-{hex}".
//
// ⚠️ ПЕРЕД ВЫХОДОМ В ПРОД: установить authTestMode = false.
// Для возврата к строгой JWT-валидации: одна строка ниже.
// ──────────────────────────────────────────────────────────────────────────────
package middleware
import (
"encoding/base64"
"encoding/json"
"log/slog"
"net/http"
"strings"
"time"
)
// authTestMode — ТЕСТОВЫЙ РЕЖИМ аутентификации.
//
// true → принимается любая строка без пробелов (не обязательно JWT).
//
// Используется при разработке UI когда реальный токен nubes не нужен.
//
// false → строгая проверка JWT (структура + sub + exp).
//
// Необходимо установить перед деплоем в прод.
//
// Чтобы вернуться к JWT: изменить на false.
const authTestMode = true
// Auth возвращает middleware которое требует заголовок:
//
// Authorization: Bearer <token>
//
// В тестовом режиме (authTestMode=true): принимает любую строку без пробелов.
// В боевом режиме (authTestMode=false): требует валидный JWT (sub + exp).
func Auth(log *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
header := r.Header.Get("Authorization")
if header == "" {
log.Warn("auth: missing authorization header", "remote", r.RemoteAddr, "path", r.URL.Path)
http.Error(w, `{"error":"authorization required"}`, http.StatusUnauthorized)
return
}
parts := strings.SplitN(header, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") {
log.Warn("auth: invalid authorization format", "remote", r.RemoteAddr)
http.Error(w, `{"error":"invalid authorization format, use Bearer <token>"}`, http.StatusUnauthorized)
return
}
token := parts[1]
// ── ТЕСТОВЫЙ РЕЖИМ ───────────────────────────────────────────────────
// Если authTestMode=true и токен — просто строка без пробелов (не JWT),
// пропускаем JWT-валидацию. Строка обрабатывается как произвольный sub.
// Чтобы вернуть строгую проверку: установить authTestMode = false.
if authTestMode && isPlainToken(token) {
log.Info("auth: test mode — plain token accepted", "remote", r.RemoteAddr, "path", r.URL.Path)
next.ServeHTTP(w, r)
return
}
// ── БОЕВОЙ РЕЖИМ / JWT ───────────────────────────────────────────────
if err := validateJWT(token); err != nil {
log.Warn("auth: invalid token", "remote", r.RemoteAddr, "path", r.URL.Path, "reason", err.Error())
http.Error(w, `{"error":"invalid token"}`, http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
// isPlainToken возвращает true если токен — непустая строка без пробелов и НЕ является JWT.
// JWT определяется по наличию ровно двух точек (xxx.yyy.zzz).
// Логика: если строка выглядит как JWT — проверять через validateJWT (даже в testMode).
func isPlainToken(token string) bool {
if token == "" || strings.ContainsAny(token, " \t\n\r") {
return false
}
// Если три части через точку — скорее всего JWT, проверять нормально
parts := strings.Split(token, ".")
return len(parts) != 3
}
// validateJWT проверяет структуру JWT и claim "sub" и "exp".
// Подпись НЕ проверяется — см. комментарий к файлу.
func validateJWT(token string) error {
jwtParts := strings.Split(token, ".")
if len(jwtParts) != 3 {
return &jwtError{"not a JWT: expected 3 parts"}
}
payload := jwtParts[1]
// JWT использует base64url без padding
switch len(payload) % 4 {
case 2:
payload += "=="
case 3:
payload += "="
}
decoded, err := base64.URLEncoding.DecodeString(payload)
if err != nil {
decoded, err = base64.StdEncoding.DecodeString(payload)
if err != nil {
return &jwtError{"cannot decode JWT payload"}
}
}
var claims struct {
Sub string `json:"sub"`
Exp int64 `json:"exp"`
}
if err := json.Unmarshal(decoded, &claims); err != nil {
return &jwtError{"cannot parse JWT claims"}
}
if claims.Sub == "" {
return &jwtError{"missing sub claim"}
}
if claims.Exp > 0 && claims.Exp < time.Now().Unix() {
return &jwtError{"token expired"}
}
return nil
}
type jwtError struct{ msg string }
func (e *jwtError) Error() string { return e.msg }
// verifySignature — точка вставки для проверки подписи JWT (v2).
//
// Текущее состояние (v1): подпись НЕ проверяется.
// Причина: публичный ключ nubes недоступен внутри кластера без JWKS endpoint.
// Безопасность обеспечивается "trusted perimeter" — оператор доступен только изнутри кластера.
//
// Когда nubes предоставит JWKS endpoint, реализация:
//
// func verifySignature(token string) error {
// // 1. Получить JWKS: GET {NUBES_JWKS_URL}/.well-known/jwks.json
// // 2. Найти ключ по "kid" из JWT header
// // 3. Проверить подпись RS256/ES256
// // Пример: github.com/lestrrat-go/jwx/v2/jwk + jwt.Parse
// return nil
// }
//
// После реализации добавить вызов в validateJWT после проверки структуры:
//
// if err := verifySignature(token); err != nil {
// return &jwtError{"signature verification failed: " + err.Error()}
// }
+38
View File
@@ -0,0 +1,38 @@
// Изменено: 2026-03-07
// Logging middleware — логирует каждый HTTP запрос через slog.
// Записывает метод, путь, статус и длительность.
package middleware
import (
"log/slog"
"net/http"
"time"
)
// responseWriter оборачивает http.ResponseWriter чтобы захватить статус ответа.
type responseWriter struct {
http.ResponseWriter
status int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.status = code
rw.ResponseWriter.WriteHeader(code)
}
// Logging возвращает middleware которое логирует все запросы через slog.
func Logging(log *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rw := &responseWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rw, r)
log.Info("http",
"method", r.Method,
"path", r.URL.Path,
"status", rw.status,
"duration_ms", time.Since(start).Milliseconds(),
"remote", r.RemoteAddr,
)
})
}
+67
View File
@@ -0,0 +1,67 @@
// Создано: 2026-04-12
// router.go — регистрация IoT REST-маршрутов.
// Перенесено из sless — только IoT-специфичные маршруты.
// CORS для https://iot.kube5s.ru, JWT auth через middleware.Auth.
package api
import (
"log/slog"
"net/http"
"github.com/gorilla/mux"
"gitea.services.ngcloud.ru/Nail/IoT/internal/api/handler"
"gitea.services.ngcloud.ru/Nail/IoT/internal/api/middleware"
)
// corsMiddleware добавляет CORS-заголовки для IoT Консоли на https://iot.kube5s.ru.
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "https://iot.kube5s.ru")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
w.Header().Set("Access-Control-Max-Age", "600")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
// NewRouter собирает gorilla/mux роутер со всеми IoT маршрутами.
func NewRouter(h *handler.Handler, log *slog.Logger) http.Handler {
r := mux.NewRouter()
// IoT Консоль — публичный HTML
r.HandleFunc("/console", ServeIoTConsole).Methods(http.MethodGet)
// IoT Admin — страница администратора
r.HandleFunc("/iot-admin", ServeIoTAdmin).Methods(http.MethodGet)
r.HandleFunc("/iot-admin/stats", h.AdminStats).Methods(http.MethodGet)
// /v1 — API маршруты, защищены JWT
v1 := r.PathPrefix("/v1").Subrouter()
// IoT Devices CRUD
v1.HandleFunc("/namespaces/{namespace}/iot/devices", h.ListIoTDevices).Methods(http.MethodGet)
v1.HandleFunc("/namespaces/{namespace}/iot/devices", h.CreateIoTDevice).Methods(http.MethodPost)
v1.HandleFunc("/namespaces/{namespace}/iot/devices/{name}", h.GetIoTDevice).Methods(http.MethodGet)
v1.HandleFunc("/namespaces/{namespace}/iot/devices/{name}", h.DeleteIoTDevice).Methods(http.MethodDelete)
v1.HandleFunc("/namespaces/{namespace}/iot/devices/{name}", h.UpdateIoTDevice).Methods(http.MethodPatch)
// IoT Telemetry
v1.HandleFunc("/namespaces/{namespace}/iot/telemetry", h.ListIoTTelemetry).Methods(http.MethodGet)
// MQTT Auth — без JWT, вызывается EMQX из кластера
r.HandleFunc("/internal/mqtt/auth", h.MQTTAuth).Methods(http.MethodPost)
r.HandleFunc("/internal/mqtt/acl", h.MQTTAcl).Methods(http.MethodPost)
// JWT auth middleware для /v1/
v1.Use(func(next http.Handler) http.Handler {
return middleware.Auth(log, next)
})
return corsMiddleware(middleware.Logging(log, r))
}
+547
View File
@@ -0,0 +1,547 @@
<!DOCTYPE html>
<!-- Создано: 2026-04-06
iot-admin.html — страница администратора IoT pipeline.
Показывает: PostgreSQL stats per tenant, Kafka consumer lag, K8s pod statuses.
Auth: ADMIN_STATS_TOKEN вводится вручную и хранится в sessionStorage.
Раздаётся по GET /iot-admin (go:embed в бинарнике оператора).
НЕ для конечных пользователей — только для администратора платформы. -->
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nubes IoT Admin</title>
<link rel="icon" href="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/favicon.png">
<style>
/* Nubes brand palette — те же цвета что в iot-console.html */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: #001120; color: #e2ecf6; min-height: 100vh;
}
/* Navbar */
.navbar {
background: #001C34; border-bottom: 1px solid #0b2d50;
padding: 0 28px; height: 58px;
display: flex; align-items: center; gap: 14px;
}
.navbar-logo { display: flex; align-items: center; gap: 10px; text-decoration: none; }
.navbar-logo img { height: 18px; filter: brightness(0) invert(1); }
.navbar-logo-sep { width: 1px; height: 18px; background: #1a4a73; margin: 0 4px; }
.navbar-title { font-size: 15px; font-weight: 600; color: #e2ecf6; }
.navbar-badge {
background: #2d1a00; border: 1px solid #7a3a00; color: #f0a030;
font-size: 10px; font-weight: 700; padding: 2px 7px; border-radius: 4px;
letter-spacing: 0.5px; text-transform: uppercase;
}
.navbar-spacer { flex: 1; }
.navbar-refresh {
background: #0f3a60; border: 1px solid #1a5a8a; color: #7fc8f8;
padding: 6px 14px; border-radius: 6px; font-size: 13px; cursor: pointer;
transition: background 0.15s;
}
.navbar-refresh:hover { background: #1a5080; }
.navbar-refresh:disabled { opacity: 0.4; cursor: not-allowed; }
/* Layout */
.container { max-width: 1280px; margin: 0 auto; padding: 28px 24px; }
/* Auth box */
.auth-box {
background: #001929; border: 1px solid #0b2d50; border-radius: 12px;
padding: 40px; max-width: 480px; margin: 80px auto;
display: flex; flex-direction: column; gap: 16px;
}
.auth-box h2 { font-size: 20px; font-weight: 600; color: #7fc8f8; }
.auth-box p { font-size: 13px; color: #6b8eaa; }
.auth-input {
background: #001120; border: 1px solid #1a4a73; color: #e2ecf6;
padding: 10px 14px; border-radius: 8px; font-size: 14px; font-family: monospace;
width: 100%; outline: none;
}
.auth-input:focus { border-color: #1a7fd4; }
.auth-btn {
background: #1a7fd4; border: none; color: #fff;
padding: 10px 20px; border-radius: 8px; font-size: 14px; cursor: pointer;
font-weight: 600; transition: background 0.15s;
}
.auth-btn:hover { background: #1a6ab8; }
.auth-error { color: #f87171; font-size: 13px; }
/* Section header */
.section-header {
display: flex; align-items: center; gap: 10px;
margin-bottom: 16px; padding-bottom: 10px;
border-bottom: 1px solid #0b2d50;
}
.section-icon { width: 20px; height: 20px; opacity: 0.7; }
.section-title { font-size: 16px; font-weight: 600; color: #a0c4e8; }
.section { margin-bottom: 32px; }
/* Cards grid */
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
/* Stat card */
.card {
background: #001929; border: 1px solid #0b2d50; border-radius: 10px;
padding: 20px;
}
.card-title { font-size: 12px; color: #6b8eaa; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; }
.card-value { font-size: 28px; font-weight: 700; color: #e2ecf6; }
.card-sub { font-size: 12px; color: #6b8eaa; margin-top: 4px; }
.card-accent { color: #1a7fd4; }
.card-warn { color: #f59e0b; }
.card-ok { color: #34d399; }
.card-err { color: #f87171; }
/* Pod status card */
.pod-card {
background: #001929; border: 1px solid #0b2d50; border-radius: 10px;
padding: 20px; display: flex; flex-direction: column; gap: 8px;
}
.pod-name { font-size: 13px; font-weight: 600; color: #7fc8f8; font-family: monospace; }
.pod-row { display: flex; justify-content: space-between; font-size: 12px; }
.pod-label { color: #6b8eaa; }
.pod-val { color: #e2ecf6; }
.badge {
display: inline-block; padding: 2px 8px; border-radius: 4px;
font-size: 11px; font-weight: 700;
}
.badge-ok { background: #052e16; color: #34d399; border: 1px solid #064e3b; }
.badge-warn { background: #2d1c00; color: #f59e0b; border: 1px solid #4d3000; }
.badge-err { background: #300; color: #f87171; border: 1px solid #500; }
/* Tenant table */
.tenant-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.tenant-table th {
text-align: left; padding: 8px 12px; color: #6b8eaa;
border-bottom: 1px solid #0b2d50; font-weight: 600; font-size: 11px;
text-transform: uppercase; letter-spacing: 0.3px;
}
.tenant-table td { padding: 10px 12px; border-bottom: 1px solid #071a28; vertical-align: top; }
.tenant-table tr:last-child td { border-bottom: none; }
.tenant-table tr:hover td { background: rgba(26,127,212,0.05); }
.ns-tag {
font-family: monospace; font-size: 12px; color: #7fc8f8;
background: #0b2d50; padding: 2px 6px; border-radius: 4px;
}
.num-big { font-size: 16px; font-weight: 600; color: #e2ecf6; }
.num-small { font-size: 12px; color: #6b8eaa; }
/* Latest msgs mini list */
.latest-list { display: flex; flex-direction: column; gap: 4px; }
.latest-item {
background: #001120; border: 1px solid #0b2d50; border-radius: 6px;
padding: 6px 10px; font-size: 11px;
}
.latest-dev { color: #7fc8f8; font-weight: 600; }
.latest-ts { color: #6b8eaa; margin-left: 6px; }
.latest-payload { color: #a0c4e8; margin-top: 2px; word-break: break-all; font-family: monospace; }
/* Last updated */
.last-updated { font-size: 12px; color: #2d5070; text-align: center; margin-top: 16px; }
/* Status dot */
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
.dot-ok { background: #34d399; }
.dot-warn { background: #f59e0b; }
.dot-err { background: #f87171; }
/* Kafka lag bar */
.lag-bar-wrap { background: #001120; border-radius: 4px; height: 6px; margin-top: 8px; overflow: hidden; }
.lag-bar { height: 100%; border-radius: 4px; transition: width 0.5s; min-width: 2px; }
.lag-bar-ok { background: #34d399; }
.lag-bar-warn { background: #f59e0b; }
/* Spinner */
.spinner {
border: 3px solid #0b2d50; border-top-color: #1a7fd4;
border-radius: 50%; width: 32px; height: 32px;
animation: spin 0.8s linear infinite;
margin: 60px auto;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Error banner */
.error-banner {
background: #1a0000; border: 1px solid #5a0000; color: #f87171;
padding: 12px 16px; border-radius: 8px; font-size: 13px; margin-bottom: 20px;
}
/* Auto-refresh indicator */
.refresh-timer {
font-size: 11px; color: #2d5070; display: flex; align-items: center; gap: 6px;
}
.refresh-progress {
width: 60px; height: 2px; background: #0b2d50; border-radius: 2px; overflow: hidden;
}
.refresh-bar {
height: 100%; background: #1a7fd4; border-radius: 2px;
transition: width 1s linear;
}
</style>
</head>
<body>
<!-- Navbar -->
<nav class="navbar">
<a class="navbar-logo" href="#" aria-label="Nubes">
<img src="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/logo.svg" alt="Nubes">
</a>
<div class="navbar-logo-sep"></div>
<span class="navbar-title">IoT Admin</span>
<span class="navbar-badge">Admin Only</span>
<div class="navbar-spacer"></div>
<div class="refresh-timer" id="refreshTimer" style="display:none">
<span id="refreshCountdown">30</span>s
<div class="refresh-progress"><div class="refresh-bar" id="refreshBar" style="width:100%"></div></div>
</div>
<button class="navbar-refresh" id="btnRefresh" onclick="loadStats()" disabled>Обновить</button>
</nav>
<!-- Main content -->
<div class="container">
<!-- Auth box (показывается до ввода токена) -->
<div class="auth-box" id="authBox">
<h2>Доступ для администратора</h2>
<p>Введите ADMIN_STATS_TOKEN для просмотра статистики IoT pipeline.</p>
<input class="auth-input" id="tokenInput" type="password"
placeholder="Bearer token..." autocomplete="off"
onkeydown="if(event.key==='Enter') doAuth()">
<button class="auth-btn" onclick="doAuth()">Войти</button>
<div class="auth-error" id="authError" style="display:none"></div>
</div>
<!-- Контент (показывается после авторизации) -->
<div id="mainContent" style="display:none">
<div class="error-banner" id="errorBanner" style="display:none"></div>
<!-- Spinner при загрузке -->
<div class="spinner" id="spinner"></div>
<!-- Данные -->
<div id="dataContent" style="display:none">
<!-- Kafka -->
<div class="section">
<div class="section-header">
<svg class="section-icon" viewBox="0 0 24 24" fill="none" stroke="#7fc8f8" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
</svg>
<span class="section-title">Kafka</span>
</div>
<div class="cards" id="kafkaCards"></div>
</div>
<!-- K8s Pods -->
<div class="section">
<div class="section-header">
<svg class="section-icon" viewBox="0 0 24 24" fill="none" stroke="#7fc8f8" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>
</svg>
<span class="section-title">Pods</span>
</div>
<div class="cards" id="podCards"></div>
</div>
<!-- PostgreSQL per tenant -->
<div class="section">
<div class="section-header">
<svg class="section-icon" viewBox="0 0 24 24" fill="none" stroke="#7fc8f8" stroke-width="2">
<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14c0 1.66 4.03 3 9 3s9-1.34 9-3V5"/>
<path d="M3 12c0 1.66 4.03 3 9 3s9-1.34 9-3"/>
</svg>
<span class="section-title">PostgreSQL — Telemetry</span>
</div>
<div class="cards" style="margin-bottom:16px" id="pgSummaryCards"></div>
<div style="background:#001929;border:1px solid #0b2d50;border-radius:10px;overflow:auto">
<table class="tenant-table" id="tenantTable">
<thead>
<tr>
<th>Namespace</th>
<th>Total</th>
<th>Last 1h</th>
<th>Last 24h</th>
<th>Последние сообщения</th>
</tr>
</thead>
<tbody id="tenantTableBody"></tbody>
</table>
</div>
</div>
<div class="last-updated" id="lastUpdated"></div>
</div>
</div>
</div>
<script>
// ── State ──────────────────────────────────────────────────────────────────
const API_BASE = window.location.origin;
let adminToken = sessionStorage.getItem('iot_admin_token') || '';
let refreshInterval = null;
let refreshCountdown = 30;
// ── Auth ───────────────────────────────────────────────────────────────────
function doAuth() {
const input = document.getElementById('tokenInput').value.trim();
if (!input) return;
adminToken = input;
sessionStorage.setItem('iot_admin_token', adminToken);
document.getElementById('authBox').style.display = 'none';
document.getElementById('mainContent').style.display = 'block';
loadStats();
}
function showAuthError(msg) {
const el = document.getElementById('authError');
el.textContent = msg;
el.style.display = 'block';
// Сбрасываем токен — он не подошёл
adminToken = '';
sessionStorage.removeItem('iot_admin_token');
document.getElementById('authBox').style.display = 'block';
document.getElementById('mainContent').style.display = 'none';
if (refreshInterval) { clearInterval(refreshInterval); refreshInterval = null; }
document.getElementById('refreshTimer').style.display = 'none';
}
// Если токен уже в sessionStorage — пропускаем auth box
if (adminToken) {
document.getElementById('authBox').style.display = 'none';
document.getElementById('mainContent').style.display = 'block';
document.getElementById('spinner').style.display = 'block';
}
// ── Load stats ─────────────────────────────────────────────────────────────
async function loadStats() {
if (!adminToken) return;
document.getElementById('btnRefresh').disabled = true;
document.getElementById('spinner').style.display = 'block';
document.getElementById('dataContent').style.display = 'none';
document.getElementById('errorBanner').style.display = 'none';
resetRefreshTimer();
try {
const resp = await fetch(`${API_BASE}/iot-admin/stats`, {
headers: { 'Authorization': `Bearer ${adminToken}` }
});
if (resp.status === 401 || resp.status === 503) {
const body = await resp.json().catch(() => ({}));
showAuthError(body.error || 'Ошибка авторизации');
document.getElementById('spinner').style.display = 'none';
return;
}
if (!resp.ok) {
throw new Error(`HTTP ${resp.status}`);
}
const data = await resp.json();
renderAll(data);
document.getElementById('spinner').style.display = 'none';
document.getElementById('dataContent').style.display = 'block';
document.getElementById('refreshTimer').style.display = 'flex';
document.getElementById('lastUpdated').textContent =
'Обновлено: ' + new Date(data.collected_at).toLocaleTimeString('ru-RU');
setupAutoRefresh();
} catch (e) {
document.getElementById('spinner').style.display = 'none';
showBanner('Ошибка загрузки данных: ' + e.message);
document.getElementById('dataContent').style.display = 'block';
} finally {
document.getElementById('btnRefresh').disabled = false;
}
}
function showBanner(msg) {
const el = document.getElementById('errorBanner');
el.textContent = msg;
el.style.display = 'block';
}
// ── Auto-refresh ───────────────────────────────────────────────────────────
function setupAutoRefresh() {
if (refreshInterval) return; // уже запущен
refreshInterval = setInterval(() => {
refreshCountdown--;
document.getElementById('refreshCountdown').textContent = refreshCountdown;
const pct = (refreshCountdown / 30) * 100;
document.getElementById('refreshBar').style.width = pct + '%';
if (refreshCountdown <= 0) {
clearInterval(refreshInterval);
refreshInterval = null;
loadStats();
}
}, 1000);
}
function resetRefreshTimer() {
if (refreshInterval) { clearInterval(refreshInterval); refreshInterval = null; }
refreshCountdown = 30;
document.getElementById('refreshCountdown').textContent = '30';
document.getElementById('refreshBar').style.width = '100%';
}
// ── Render ─────────────────────────────────────────────────────────────────
function renderAll(data) {
renderKafka(data.kafka || {});
renderPods(data.pods || {});
renderPostgres(data.postgres || {});
}
// Kafka section
function renderKafka(kafka) {
const el = document.getElementById('kafkaCards');
if (kafka.error) {
el.innerHTML = `<div class="card"><div class="card-title">Ошибка</div>
<div class="card-value card-err" style="font-size:14px">${esc(kafka.error)}</div></div>`;
return;
}
const lag = kafka.consumer_lag || 0;
const latest = kafka.latest_offset || 0;
const committed = kafka.committed_offset || 0;
const lagClass = lag === 0 ? 'card-ok' : lag < 100 ? 'card-warn' : 'card-err';
const barClass = lag === 0 ? 'lag-bar-ok' : 'lag-bar-warn';
const barWidth = latest > 0 ? Math.max(2, Math.round((committed / latest) * 100)) : 100;
el.innerHTML = `
<div class="card">
<div class="card-title">Consumer Lag</div>
<div class="card-value ${lagClass}">${lag}</div>
<div class="card-sub">iot-pg-consumer / iot.telemetry</div>
<div class="lag-bar-wrap"><div class="lag-bar ${barClass}" style="width:${barWidth}%"></div></div>
</div>
<div class="card">
<div class="card-title">Latest Offset (всего прошло)</div>
<div class="card-value card-accent">${latest.toLocaleString()}</div>
<div class="card-sub">Kafka log end offset</div>
</div>
<div class="card">
<div class="card-title">Committed Offset</div>
<div class="card-value">${committed.toLocaleString()}</div>
<div class="card-sub">Consumer обработал</div>
</div>`;
}
// Pods section
function renderPods(pods) {
const el = document.getElementById('podCards');
el.innerHTML = '';
const labels = {
'iot-mqtt-bridge': 'MQTT Bridge',
'iot-kafka-consumer': 'Kafka Consumer'
};
for (const [key, pod] of Object.entries(pods)) {
const title = labels[key] || key;
if (pod.error || pod.status === 'not found') {
el.innerHTML += `
<div class="pod-card">
<div class="pod-name">${esc(title)}</div>
<div style="color:#f87171;font-size:12px">${esc(pod.error || 'Pod not found')}</div>
</div>`;
continue;
}
const readyBadge = pod.ready
? '<span class="badge badge-ok">Ready</span>'
: '<span class="badge badge-warn">Not Ready</span>';
const restartColor = pod.restarts > 5 ? 'card-err' : pod.restarts > 0 ? 'card-warn' : 'card-ok';
el.innerHTML += `
<div class="pod-card">
<div class="pod-name">
<span class="dot dot-${pod.ready ? 'ok' : 'warn'}"></span>${esc(title)}
</div>
<div class="pod-row"><span class="pod-label">Pod</span><span class="pod-val" style="font-family:monospace;font-size:11px">${esc(pod.name)}</span></div>
<div class="pod-row"><span class="pod-label">Phase</span><span class="pod-val">${esc(pod.phase)} ${readyBadge}</span></div>
<div class="pod-row"><span class="pod-label">Restarts</span><span class="pod-val ${restartColor}">${pod.restarts}</span></div>
<div class="pod-row"><span class="pod-label">Age</span><span class="pod-val">${esc(pod.age)}</span></div>
</div>`;
}
}
// Postgres section
function renderPostgres(pg) {
const summaryEl = document.getElementById('pgSummaryCards');
const tbodyEl = document.getElementById('tenantTableBody');
if (!pg.reachable) {
summaryEl.innerHTML = `<div class="card"><div class="card-title">Ошибка</div>
<div class="card-value card-err" style="font-size:14px">${esc(pg.error || 'Unreachable')}</div></div>`;
tbodyEl.innerHTML = '';
return;
}
const tenants = pg.tenants || [];
const totalAll = pg.total_all || 0;
summaryEl.innerHTML = `
<div class="card">
<div class="card-title">Всего записей</div>
<div class="card-value card-accent">${totalAll.toLocaleString()}</div>
<div class="card-sub">Все tenant, iot_telemetry</div>
</div>
<div class="card">
<div class="card-title">Tenant-ов</div>
<div class="card-value">${tenants.length}</div>
<div class="card-sub">Активных namespace</div>
</div>`;
tbodyEl.innerHTML = tenants.map(t => {
if (t.error) {
return `<tr><td><span class="ns-tag">${esc(t.namespace)}</span></td>
<td colspan="4" style="color:#f87171">${esc(t.error)}</td></tr>`;
}
const latest = (t.latest || []).slice(0, 3);
const latestHtml = latest.length === 0
? '<span style="color:#2d5070">нет данных</span>'
: `<div class="latest-list">${latest.map(row => `
<div class="latest-item">
<span class="latest-dev">${esc(row.device_id)}</span>
<span class="latest-ts">${formatTs(row.ts)}</span>
<div class="latest-payload">${esc(truncate(JSON.stringify(row.payload), 80))}</div>
</div>`).join('')}</div>`;
return `<tr>
<td><span class="ns-tag">${esc(t.namespace)}</span><br>
<span style="font-size:11px;color:#2d5070">${esc(t.db_name)}</span></td>
<td><span class="num-big">${(t.total||0).toLocaleString()}</span></td>
<td><span class="num-small">${(t.last_1h||0).toLocaleString()}</span></td>
<td><span class="num-small">${(t.last_24h||0).toLocaleString()}</span></td>
<td>${latestHtml}</td>
</tr>`;
}).join('');
}
// ── Utils ──────────────────────────────────────────────────────────────────
function esc(str) {
if (str == null) return '';
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function truncate(str, len) {
if (!str) return '';
return str.length > len ? str.slice(0, len) + '…' : str;
}
function formatTs(ts) {
if (!ts) return '';
try {
const d = new Date(ts);
return d.toLocaleTimeString('ru-RU', {hour:'2-digit', minute:'2-digit', second:'2-digit'});
} catch { return ts; }
}
// ── Init ───────────────────────────────────────────────────────────────────
if (adminToken) {
loadStats();
}
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
-20
View File
@@ -1,20 +0,0 @@
// admin_embed.go — встраивает HTML страницы администратора в бинарник.
package api
import (
_ "embed"
"net/http"
)
// iotAdminHTML — страница /iot-admin, встроена при сборке.
//
//go:embed ui/iot-admin.html
var iotAdminHTML []byte
// ServeIoTAdmin отдаёт HTML страницу администратора.
func ServeIoTAdmin(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache, must-revalidate")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(iotAdminHTML)
}
-20
View File
@@ -1,20 +0,0 @@
// console_embed.go — встраивает HTML IoT консоли в бинарник.
package api
import (
_ "embed"
"net/http"
)
// iotConsoleHTML — консоль /console, встроена при сборке.
//
//go:embed ui/iot-console.html
var iotConsoleHTML []byte
// ServeIoTConsole отдаёт HTML консоли.
func ServeIoTConsole(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache, must-revalidate")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(iotConsoleHTML)
}
-62
View File
@@ -1,62 +0,0 @@
// admin.go — статистика для страницы администратора.
//
// Endpoint: GET /iot-admin/stats (Bearer ADMIN_STATS_TOKEN).
// Источники: PostgreSQL (iotpg), shared-SQS (атрибуты очереди), рантайм процесса.
// k8s-статусы подов убраны — компоненты теперь в одном процессе.
package handler
import (
"context"
"crypto/subtle"
"net/http"
"strings"
"time"
)
// AdminStats — GET /iot-admin/stats.
func (h *Handler) AdminStats(w http.ResponseWriter, r *http.Request) {
if h.Cfg.AdminStatsToken == "" {
writeJSON(w, http.StatusServiceUnavailable, errResp("admin stats not configured: ADMIN_STATS_TOKEN not set"))
return
}
// Constant-time сравнение токена: обычное != давало утечку по времени (ревью 2026-10-10).
authHeader := r.Header.Get("Authorization")
token := strings.TrimPrefix(authHeader, "Bearer ")
if !strings.HasPrefix(authHeader, "Bearer ") ||
subtle.ConstantTimeCompare([]byte(token), []byte(h.Cfg.AdminStatsToken)) != 1 {
writeJSON(w, http.StatusUnauthorized, errResp("unauthorized"))
return
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
result := map[string]any{
"collected_at": time.Now().UTC(),
}
// PostgreSQL: статистика по всем tenant (как в старом коде).
if h.IoTPG != nil {
pgStats, err := h.IoTPG.GetAdminStats(ctx)
if err != nil {
result["postgres"] = map[string]any{"reachable": false, "error": err.Error()}
} else {
result["postgres"] = pgStats
}
} else {
result["postgres"] = map[string]any{"reachable": false, "error": "IoTPG not configured"}
}
// SQS: approximate counts очереди телеметрии.
result["sqs"] = h.collectSQSStats(ctx)
// Рантайм процесса (вместо статусов k8s-подов).
result["runtime"] = map[string]any{
"version": h.Version,
"uptime_sec": int64(time.Since(h.StartedAt).Seconds()),
"started_at": h.StartedAt.UTC().Format(time.RFC3339),
}
writeJSON(w, http.StatusOK, result)
}
-382
View File
@@ -1,382 +0,0 @@
// devices.go — CRUD устройств на PostgreSQL.
//
// Endpoints:
//
// POST /v1/namespaces/{ns}/iot/devices — создать (отдаёт mqtt_password ОДИН раз)
// GET /v1/namespaces/{ns}/iot/devices — список (без паролей)
// GET /v1/namespaces/{ns}/iot/devices/{name} — одно (без пароля)
// DELETE /v1/namespaces/{ns}/iot/devices/{name} — удалить
// PATCH /v1/namespaces/{ns}/iot/devices/{name} — enabled
package handler
import (
"crypto/rand"
"encoding/hex"
"errors"
"net/http"
"regexp"
"strconv"
"time"
"golang.org/x/crypto/bcrypt"
"gitea.services.ngcloud.ru/Nail/IoT/internal/service/store"
)
// iotDeviceCreateRequest — тело POST.
type iotDeviceCreateRequest struct {
Name string `json:"name"`
DeviceID string `json:"device_id"`
Enabled *bool `json:"enabled"`
Metadata map[string]string `json:"metadata,omitempty"`
}
// iotDeviceUpdateRequest — тело PATCH. Хотя бы одно поле обязательно:
// enabled и/или metadata (metadata раньше нельзя было обновить — ревью 2026-10-10).
type iotDeviceUpdateRequest struct {
Enabled *bool `json:"enabled,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}
// iotDeviceResponse — ответ API. MQTTPassword заполняется ТОЛЬКО при создании
// устройства (отдаётся один раз) и НИКОГДА при чтении — чтобы пароль не утекал
// через GET и не хранился в логах/истории.
type iotDeviceResponse struct {
Name string `json:"name"`
Namespace string `json:"namespace"`
DeviceID string `json:"device_id"`
Enabled bool `json:"enabled"`
Phase string `json:"phase"`
MQTTUsername string `json:"mqtt_username,omitempty"`
MQTTPassword string `json:"mqtt_password,omitempty"`
TopicPrefix string `json:"topic_prefix,omitempty"`
// TelemetryTopic — полный MQTT-топик телеметрии устройства «ns/telemetry/device».
// Явное поле (в отличие от topic_prefix = «ns/»): Terraform должен знать
// точный топик для публикации/подписки (ревью 2026-10-10).
TelemetryTopic string `json:"telemetry_topic"`
LastConnected string `json:"last_connected,omitempty"`
// Metadata — без omitempty: всегда присутствует в ответе. Пустой → «{}»,
// иначе Terraform видел бы drift «{}» против null (ревью 2026-10-10).
Metadata map[string]string `json:"metadata"`
CreatedAt string `json:"created_at,omitempty"`
}
// deviceToResponse конвертирует устройство из БД в ответ API.
// password отдаётся только если непустая — только на создании, при чтении
// передаётся пустая строка (пароль повторно не показывается).
func deviceToResponse(d *store.Device, password string) iotDeviceResponse {
// Пустой metadata нормализуем в «{}» (а не null/omit): иначе Terraform видел
// бы drift между «{}» и отсутствующим полем. nil-карту не мутируем — подменяем
// только для сериализации ответа.
metadata := d.Metadata
if metadata == nil {
metadata = map[string]string{}
}
resp := iotDeviceResponse{
Name: d.Name,
Namespace: d.Namespace,
DeviceID: d.DeviceID,
Enabled: d.Enabled,
Phase: d.Phase,
MQTTUsername: d.Namespace + "_" + d.DeviceID,
MQTTPassword: password,
TopicPrefix: d.Namespace + "/",
TelemetryTopic: d.Namespace + "/telemetry/" + d.DeviceID,
Metadata: metadata,
}
if d.LastConnected != nil && !d.LastConnected.IsZero() {
resp.LastConnected = d.LastConnected.UTC().Format(time.RFC3339)
}
if !d.CreatedAt.IsZero() {
// RFC3339 — как last_connected: единый формат для Terraform (drift между
// POST/GET из-за формата «2006-01-02 15:04:05 UTC» устранён).
resp.CreatedAt = d.CreatedAt.UTC().Format(time.RFC3339)
}
return resp
}
// generateMQTTPassword — 32 случайных байта в hex (как старый контроллер).
// crypto/rand ошибку даёт только при сбое системного PRNG — 3 попытки
// (ревью 2026-08-16: не возвращать пустой пароль).
func generateMQTTPassword() (string, error) {
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err == nil {
return hex.EncodeToString(buf), nil
} else {
lastErr = err
}
}
return "", lastErr
}
// hashPassword — bcrypt-хэш пароля устройства. EMQX (postgres-auth) проверяет
// bcrypt (см. emqx/emqx.conf password_hash_algorithm). В БД хранится ТОЛЬКО хэш;
// открытый пароль отдаётся один раз при создании устройства.
func hashPassword(plain string) (string, error) {
h, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(h), nil
}
// identRe — допустимый charset идентификаторов (namespace/name/device_id):
// только [a-zA-Z0-9] и дефисы, не с дефиса. Исключает _, +, #, /, пробелы, юникод.
// Причины запретов (ревью 2026-10-10):
// - _ — username = «namespace_device_id»; _ делает его неоднозначным
// (auth-запрос EMQX: namespace || '_' || device_id, LIMIT 1 без ORDER BY
// → может сматчить чужое устройство);
// - +/# — wildcard MQTT: в ACL-топике даст подписку/публикацию чужих топиков;
// - / — ломает топик «ns/telemetry/device» (лишний уровень).
var identRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9-]*$`)
// validateIdentifier возвращает текст ошибки (или "") для идентификатора value.
// what — имя поля для сообщения, maxLen — максимальная длина (namespace ≤ 56:
// имя БД tenant_<ns> усекается PG до 63 байт).
func validateIdentifier(value, what string, maxLen int) string {
if value == "" {
return what + " is required"
}
if len(value) > maxLen {
return what + " is too long (max " + strconv.Itoa(maxLen) + " chars)"
}
if !identRe.MatchString(value) {
return what + " must match [a-zA-Z0-9][a-zA-Z0-9-]* (no _, +, #, /, spaces)"
}
return ""
}
// CreateIoTDevice — POST /v1/namespaces/{ns}/iot/devices.
func (h *Handler) CreateIoTDevice(w http.ResponseWriter, r *http.Request) {
// Порядок важен: сначала разбор тела (400 «invalid JSON»), потом валидация
// идентификаторов — так и было до вынесения общей проверки.
ns := namespaceFromPath(r)
var req iotDeviceCreateRequest
if err := decodeJSON(w, r, &req); err != nil {
writeJSON(w, http.StatusBadRequest, errResp("invalid JSON: "+err.Error()))
return
}
// Валидация идентификаторов до записи в БД: единый charset и лимиты для
// namespace (из URL), name и device_id. Критично для MQTT (username/топик)
// и PG (имя БД tenant_<ns>): плохие символы ломают auth/ACL или дают чужой
// доступ (см. identRe). Ошибка 400 с понятным текстом — для Terraform.
if msg := validateIdentifier(ns, "namespace", 56); msg != "" {
writeJSON(w, http.StatusBadRequest, errResp(msg))
return
}
if msg := validateIdentifier(req.Name, "name", 64); msg != "" {
writeJSON(w, http.StatusBadRequest, errResp(msg))
return
}
if msg := validateIdentifier(req.DeviceID, "device_id", 64); msg != "" {
writeJSON(w, http.StatusBadRequest, errResp(msg))
return
}
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}
password, err := generateMQTTPassword()
if err != nil {
h.Log.Error("generate device password", "err", err)
writeJSON(w, http.StatusInternalServerError, errResp("failed to generate credentials"))
return
}
// В БД храним bcrypt-хэш (EMQX проверяет bcrypt — emqx/emqx.conf). Открытый
// пароль отдаём ОДИН раз в ответе 201 (иначе его негде взять).
hash, err := hashPassword(password)
if err != nil {
h.Log.Error("hash device password", "err", err)
writeJSON(w, http.StatusInternalServerError, errResp("failed to generate credentials"))
return
}
phase := "Active"
if !enabled {
phase = "Disabled"
}
device := &store.Device{
Namespace: ns,
Name: req.Name,
DeviceID: req.DeviceID,
Enabled: enabled,
MQTTPassword: hash,
Metadata: req.Metadata,
Phase: phase,
}
if err := h.Devices.Create(r.Context(), device); err != nil {
if errors.Is(err, store.ErrDeviceIDExists) {
writeJSON(w, http.StatusConflict, errResp("device_id already exists in this namespace"))
return
}
if errors.Is(err, store.ErrAlreadyExists) {
writeJSON(w, http.StatusConflict, errResp("iot device already exists"))
return
}
h.Log.Error("create device", "namespace", ns, "name", req.Name, "err", err)
writeJSON(w, http.StatusInternalServerError, errResp("failed to create device"))
return
}
// Перечитываем устройство из БД: после INSERT нужны БД-поля created_at
// (и phase из БД), которых нет в in-memory структуре. Иначе POST отдал бы
// пустой created_at → drift между POST и GET (ревью 2026-10-10, «Что
// стабилизировать в REST для Terraform»).
created, err := h.Devices.GetByName(r.Context(), ns, req.Name)
if err != nil {
// Устройство УЖЕ создано (INSERT прошёл): не отвечаем 500 — иначе пароль
// (отдаётся один раз) потерялся бы, а повтор дал бы 409. Отдаём созданный
// объект из памяти (created_at будет нулевым, уточнится следующим GET).
h.Log.Error("reload device after create (возвращаю in-memory без created_at)", "namespace", ns, "name", req.Name, "err", err)
writeJSON(w, http.StatusCreated, deviceToResponse(device, password))
return
}
// Пароль отдаётся ТОЛЬКО здесь, один раз в ответе 201. Дальше (GET/PATCH/
// LIST) он не возвращается — иначе пароль утекал бы через чтение и в
// историю/логи. Это блокер для Terraform: без пароля устройство нельзя
// подключить к MQTT, значит ресурс не создать. Именно поэтому пароль
// пробрасывается в deviceToResponse только в этой точке (GetByName его
// не отдаёт — см. P0-3 ч.2).
writeJSON(w, http.StatusCreated, deviceToResponse(created, password))
}
// ListIoTDevices — GET /v1/namespaces/{ns}/iot/devices (без паролей).
// Пагинация: ?limit=N&offset=M. limit — дефолт 100, потолок 1000 (защита от
// безлимитного запроса при ошибке парсинга).
func (h *Handler) ListIoTDevices(w http.ResponseWriter, r *http.Request) {
ns, ok := requireNamespace(w, r)
if !ok {
return
}
// limit: дефолт 100, потолок 1000 — иначе ошибка парсинга/омиссия = безлимит
// (ревью 2026-10-10).
limit := 100
if v, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && v > 0 {
limit = v
}
if limit > 1000 {
limit = 1000
}
offset := 0
if v, err := strconv.Atoi(r.URL.Query().Get("offset")); err == nil && v > 0 {
offset = v
}
devices, err := h.Devices.List(r.Context(), ns, limit, offset)
if err != nil {
h.Log.Error("list devices", "namespace", ns, "err", err)
writeJSON(w, http.StatusInternalServerError, errResp("failed to list devices"))
return
}
result := make([]iotDeviceResponse, 0, len(devices))
for i := range devices {
result = append(result, deviceToResponse(&devices[i], ""))
}
writeJSON(w, http.StatusOK, result)
}
// GetIoTDevice — GET /v1/namespaces/{ns}/iot/devices/{name} (с паролем).
func (h *Handler) GetIoTDevice(w http.ResponseWriter, r *http.Request) {
ns, ok := requireNamespace(w, r)
if !ok {
return
}
name := pathVar(r, "name")
device, err := h.Devices.GetByName(r.Context(), ns, name)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeJSON(w, http.StatusNotFound, errResp("iot device not found"))
return
}
h.Log.Error("get device", "namespace", ns, "name", name, "err", err)
writeJSON(w, http.StatusInternalServerError, errResp("failed to get device"))
return
}
writeJSON(w, http.StatusOK, deviceToResponse(device, ""))
}
// DeleteIoTDevice — DELETE /v1/namespaces/{ns}/iot/devices/{name}.
func (h *Handler) DeleteIoTDevice(w http.ResponseWriter, r *http.Request) {
ns, ok := requireNamespace(w, r)
if !ok {
return
}
name := pathVar(r, "name")
err := h.Devices.Delete(r.Context(), ns, name)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeJSON(w, http.StatusNotFound, errResp("iot device not found"))
return
}
h.Log.Error("delete device", "namespace", ns, "name", name, "err", err)
writeJSON(w, http.StatusInternalServerError, errResp("failed to delete device"))
return
}
w.WriteHeader(http.StatusNoContent)
}
// UpdateIoTDevice — PATCH /v1/namespaces/{ns}/iot/devices/{name} (enabled).
func (h *Handler) UpdateIoTDevice(w http.ResponseWriter, r *http.Request) {
ns, ok := requireNamespace(w, r)
if !ok {
return
}
name := pathVar(r, "name")
var req iotDeviceUpdateRequest
if err := decodeJSON(w, r, &req); err != nil {
writeJSON(w, http.StatusBadRequest, errResp("invalid JSON: "+err.Error()))
return
}
if req.Enabled == nil && req.Metadata == nil {
writeJSON(w, http.StatusBadRequest, errResp("enabled или metadata обязательны"))
return
}
// Обновляем только переданные поля (enabled и/или metadata).
if req.Enabled != nil {
if err := h.Devices.UpdateEnabled(r.Context(), ns, name, *req.Enabled); err != nil {
if errors.Is(err, store.ErrNotFound) {
writeJSON(w, http.StatusNotFound, errResp("iot device not found"))
return
}
h.Log.Error("update device enabled", "namespace", ns, "name", name, "err", err)
writeJSON(w, http.StatusInternalServerError, errResp("failed to update device"))
return
}
}
if req.Metadata != nil {
if err := h.Devices.UpdateMetadata(r.Context(), ns, name, req.Metadata); err != nil {
if errors.Is(err, store.ErrNotFound) {
writeJSON(w, http.StatusNotFound, errResp("iot device not found"))
return
}
h.Log.Error("update device metadata", "namespace", ns, "name", name, "err", err)
writeJSON(w, http.StatusInternalServerError, errResp("failed to update device"))
return
}
}
device, err := h.Devices.GetByName(r.Context(), ns, name)
if err != nil {
writeJSON(w, http.StatusInternalServerError, errResp("failed to reload device"))
return
}
writeJSON(w, http.StatusOK, deviceToResponse(device, ""))
}
@@ -1,42 +0,0 @@
// devices_test.go — тесты валидации идентификаторов (validateIdentifier).
//
// Покрывают charset/лимиты, критичные для MQTT (username/topic) и PG (имя БД):
// запрет _, +, #, /, ведущего дефиса, пробелов и превышения длины.
package handler
import (
"strings"
"testing"
)
func TestValidateIdentifier(t *testing.T) {
cases := []struct {
name string
value string
maxLen int
wantOK bool
}{
{"простое имя", "boiler", 64, true},
{"дефис внутри", "boiler-1", 64, true},
{"одна буква", "a", 64, true},
{"цифры", "dev001", 64, true},
{"подчёркивание запрещено", "a_b", 64, false},
{"плюс запрещён (wildcard MQTT)", "a+b", 64, false},
{"решётка запрещена (wildcard MQTT)", "a#b", 64, false},
{"слэш запрещён (уровень топика)", "a/b", 64, false},
{"ведущий дефис", "-abc", 64, false},
{"пробел", "abc def", 64, false},
{"пусто", "", 64, false},
{"юникод", "имя", 64, false},
{"ровно максимум", strings.Repeat("a", 56), 56, true},
{"больше максимума", strings.Repeat("a", 57), 56, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
gotOK := validateIdentifier(c.value, "x", c.maxLen) == ""
if gotOK != c.wantOK {
t.Errorf("validateIdentifier(%q, max=%d) ok=%v, want %v", c.value, c.maxLen, gotOK, c.wantOK)
}
})
}
}
-92
View File
@@ -1,92 +0,0 @@
// Package handler — HTTP-обработчики IoT REST API (новый монолит).
//
// Отличие от старого кода: устройства — в PostgreSQL (таблица iot_devices),
// без k8s-клиента и CRD. Телеметрия — через iotpg, как раньше.
package handler
import (
"encoding/json"
"log/slog"
"net/http"
"time"
"github.com/aws/aws-sdk-go-v2/service/sqs"
"github.com/gorilla/mux"
"gitea.services.ngcloud.ru/Nail/IoT/internal/service/config"
"gitea.services.ngcloud.ru/Nail/IoT/internal/service/store"
"gitea.services.ngcloud.ru/Nail/IoT/internal/storage/iotpg"
)
// Handler — зависимости всех HTTP-обработчиков.
type Handler struct {
Devices *store.DeviceStore
IoTPG *iotpg.IoTPostgresStore
SQS *sqs.Client
Cfg *config.Config
Log *slog.Logger
// Version — версия бинарника (ldflags), StartedAt — для админ-статистики.
Version string
StartedAt time.Time
}
// writeJSON отправляет JSON-ответ с указанным статусом.
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
// maxRequestBodyBytes — максимум размера тела запроса (устройства/метаданные
// небольшие; 64 КиБ с запасом).
const maxRequestBodyBytes = 64 * 1024
// decodeJSON читает JSON-тело с лимитом размера и запретом неизвестных полей
// (ревью 2026-10-10). MaxBytesReader защищает от раздутых тел;
// DisallowUnknownFields ловит опечатки в полях — критично для Terraform (лучше
// 400 с понятной ошибкой, чем молча проигнорированное поле).
func decodeJSON(w http.ResponseWriter, r *http.Request, v any) error {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodyBytes)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
return dec.Decode(v)
}
// errResp возвращает структуру ошибки для JSON.
func errResp(msg string) map[string]string {
return map[string]string{"error": msg}
}
// pathVar читает переменную пути из gorilla/mux.
func pathVar(r *http.Request, key string) string {
return mux.Vars(r)[key]
}
// defaultNamespace — namespace, когда в пути он не задан.
const defaultNamespace = "default"
// namespaceFromPath возвращает {namespace} из URL (пусто → "default").
func namespaceFromPath(r *http.Request) string {
ns := pathVar(r, "namespace")
if ns == "" {
ns = defaultNamespace
}
return ns
}
// requireNamespace — namespace из URL + его валидация. При невалидном значении
// отвечает 400 и возвращает ok=false (вызывающий обязан выйти).
//
// Зачем единая точка: раньше проверка была ТОЛЬКО в CreateIoTDevice, а
// GET/DELETE/PATCH/telemetry пропускали любой namespace дальше (тест 2026-10-11:
// `GET /v1/namespaces/bad_ns/iot/devices` → 200, а чтение телеметрии с таким ns
// падало уже в хранилище с 500). Валидируем на входе у всех — единообразный 400.
func requireNamespace(w http.ResponseWriter, r *http.Request) (string, bool) {
ns := namespaceFromPath(r)
if msg := validateIdentifier(ns, "namespace", 56); msg != "" {
writeJSON(w, http.StatusBadRequest, errResp(msg))
return "", false
}
return ns, true
}
@@ -1,78 +0,0 @@
// namespace_test.go — проверяет, что ВСЕ эндпоинты с {namespace} валидируют его
// одинаково (400), а не пропускают «мусор» до хранилища.
//
// Найдено e2e-прогоном 2026-10-11: валидация была только в CreateIoTDevice,
// поэтому GET-список с `bad_ns` отдавал 200, а чтение телеметрии падало уже в
// хранилище (safeNamespaceRe) с 500. Здесь — регрессия на все обработчики.
package handler
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gorilla/mux"
"gitea.services.ngcloud.ru/Nail/IoT/internal/storage/iotpg"
)
// badNamespaces — значения, которые ломают MQTT-топик/username или имя БД
// tenant_<ns>: подчёркивание (коллизия с дефисом), wildcard, слэш, пробел,
// ведущий дефис, юникод, пустой.
var badNamespaces = []string{"bad_ns", "a+b", "a#b", "a/b", "-abc", "abc def", "имя", ""}
func newReq(t *testing.T, ns string) *http.Request {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/v1/namespaces/x/iot/devices", nil)
return mux.SetURLVars(req, map[string]string{"namespace": ns, "name": "dev1"})
}
func TestHandlersRejectInvalidNamespace(t *testing.T) {
// Пустой IoTPG-стаб: до хранилища дело дойти не должно (валидация раньше),
// поэтому nil-зависимости Devices/IoTPG не используются.
h := &Handler{IoTPG: &iotpg.IoTPostgresStore{}}
handlers := map[string]func(http.ResponseWriter, *http.Request){
"ListIoTDevices": h.ListIoTDevices,
"GetIoTDevice": h.GetIoTDevice,
"DeleteIoTDevice": h.DeleteIoTDevice,
"UpdateIoTDevice": h.UpdateIoTDevice,
"ListIoTTelemetry": h.ListIoTTelemetry,
}
for name, fn := range handlers {
for _, ns := range badNamespaces {
if ns == "" {
continue // пустой namespace = "default" (валидный) — проверяется отдельно
}
t.Run(name+"/"+ns, func(t *testing.T) {
rec := httptest.NewRecorder()
fn(rec, newReq(t, ns))
if rec.Code != http.StatusBadRequest {
t.Fatalf("код %d, ожидался 400 (body=%s)", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "namespace") {
t.Errorf("в теле нет упоминания namespace: %s", rec.Body.String())
}
})
}
}
}
func TestEmptyNamespaceMeansDefault(t *testing.T) {
// Пустой {namespace} в пути → "default" (валидное значение), поэтому
// проверка должна пройти дальше валидации (здесь упадёт на nil Devices —
// это ожидаемо, ловим панику и убеждаемся, что это НЕ 400).
defer func() {
if r := recover(); r == nil {
t.Log("обработчик дошёл до зависимостей без паники")
}
}()
rec := httptest.NewRecorder()
h := &Handler{}
h.ListIoTDevices(rec, newReq(t, ""))
if rec.Code == http.StatusBadRequest {
t.Fatalf("пустой namespace не должен давать 400 (body=%s)", rec.Body.String())
}
}
-52
View File
@@ -1,52 +0,0 @@
// sqsstats.go — сбор статистики очереди shared-SQS для админ-страницы.
package handler
import (
"context"
"fmt"
"strconv"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/sqs"
sqstypes "github.com/aws/aws-sdk-go-v2/service/sqs/types"
)
// iotAdminSQSStats — информация об очереди для мониторинга.
type iotAdminSQSStats struct {
ApproximateMessages int64 `json:"approximate_messages"`
ApproximateMessagesNotVisible int64 `json:"approximate_messages_not_visible"`
Error string `json:"error,omitempty"`
}
// collectSQSStats получает ApproximateNumberOfMessages из очереди телеметрии.
func (h *Handler) collectSQSStats(ctx context.Context) iotAdminSQSStats {
if h.SQS == nil {
return iotAdminSQSStats{Error: "SQS client not configured"}
}
queueUrlOut, err := h.SQS.GetQueueUrl(ctx, &sqs.GetQueueUrlInput{
QueueName: aws.String(h.Cfg.SQSQueueName),
})
if err != nil {
return iotAdminSQSStats{Error: fmt.Sprintf("GetQueueUrl: %v", err)}
}
attrsOut, err := h.SQS.GetQueueAttributes(ctx, &sqs.GetQueueAttributesInput{
QueueUrl: queueUrlOut.QueueUrl,
AttributeNames: []sqstypes.QueueAttributeName{
sqstypes.QueueAttributeNameApproximateNumberOfMessages,
sqstypes.QueueAttributeNameApproximateNumberOfMessagesNotVisible,
},
})
if err != nil {
return iotAdminSQSStats{Error: fmt.Sprintf("GetQueueAttributes: %v", err)}
}
approxMsg, _ := strconv.ParseInt(attrsOut.Attributes["ApproximateNumberOfMessages"], 10, 64)
approxNotVisible, _ := strconv.ParseInt(attrsOut.Attributes["ApproximateNumberOfMessagesNotVisible"], 10, 64)
return iotAdminSQSStats{
ApproximateMessages: approxMsg,
ApproximateMessagesNotVisible: approxNotVisible,
}
}
-52
View File
@@ -1,52 +0,0 @@
// telemetry.go — REST-чтение телеметрии из per-tenant PostgreSQL.
//
// Endpoint: GET /v1/namespaces/{ns}/iot/telemetry?device_id={id}&limit={n}
package handler
import (
"net/http"
"strconv"
"gitea.services.ngcloud.ru/Nail/IoT/internal/storage/iotpg"
)
// ListIoTTelemetry — GET /v1/namespaces/{ns}/iot/telemetry.
func (h *Handler) ListIoTTelemetry(w http.ResponseWriter, r *http.Request) {
if h.IoTPG == nil {
writeJSON(w, http.StatusServiceUnavailable, errResp("IoT telemetry storage not configured"))
return
}
ns, ok := requireNamespace(w, r)
if !ok {
return
}
deviceID := r.URL.Query().Get("device_id")
limit := 50
if ls := r.URL.Query().Get("limit"); ls != "" {
if n, err := strconv.Atoi(ls); err == nil && n > 0 {
limit = n
}
}
offset := 0
if os := r.URL.Query().Get("offset"); os != "" {
if n, err := strconv.Atoi(os); err == nil && n >= 0 {
offset = n
}
}
rows, err := h.IoTPG.QueryTelemetry(r.Context(), ns, deviceID, limit, offset)
if err != nil {
h.Log.Error("query IoT telemetry", "namespace", ns, "device", deviceID, "err", err)
writeJSON(w, http.StatusInternalServerError, errResp("failed to query telemetry"))
return
}
if rows == nil {
rows = []iotpg.TelemetryRow{}
}
writeJSON(w, http.StatusOK, map[string]any{
"items": rows,
"count": len(rows),
})
}
-40
View File
@@ -1,40 +0,0 @@
// landing_embed.go — встраивает лендинг-инструкцию и статику (лого, favicon).
package api
import (
"embed"
"io/fs"
"net/http"
"strings"
)
// landingHTML — лендинг "/" с инструкцией (дизайн shared-sqs).
//
//go:embed ui/landing.html
var landingHTML []byte
// staticFS — логотип и favicon (дизайн Nubes, как на shared-sqs).
//
//go:embed ui/static
var staticFS embed.FS
// versionPlaceholder — заменяется на версию при отдаче страницы.
const versionPlaceholder = "{{VERSION}}"
// ServeLanding отдаёт лендинг "/" с подставленной версией.
func ServeLanding(w http.ResponseWriter, r *http.Request, version string) {
body := strings.ReplaceAll(string(landingHTML), versionPlaceholder, version)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache, must-revalidate")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(body))
}
// StaticHandler раздаёт /static/* (logo.svg, favicon.svg) из embed.
func StaticHandler() http.Handler {
sub, err := fs.Sub(staticFS, "ui/static")
if err != nil {
panic(err)
}
return http.FileServer(http.FS(sub))
}
-163
View File
@@ -1,163 +0,0 @@
// Package middleware — HTTP-промежуточные слои API.
package middleware
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"log/slog"
"net/http"
"strings"
"github.com/gorilla/mux"
)
// Auth — проверка Bearer-токена.
//
// authTestMode (env AUTH_TEST_MODE, дефолт false):
// - true — принимается любая строка без пробелов (для локальных тестов);
// - false — проверка JWT: sub + exp; если задан hmacSecret — обязательна
// HS256-подпись (HMAC-SHA256, env JWT_HMAC_SECRET). Без секрета —
// структурная проверка (периметр обеспечивает платформа).
//
// Тест-режим игнорируется при заданном hmacSecret (иначе обходил бы подпись).
func Auth(authTestMode bool, hmacSecret string, log *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
header := r.Header.Get("Authorization")
if header == "" {
log.Warn("auth: missing authorization header", "remote", r.RemoteAddr, "path", r.URL.Path)
authErr(w, http.StatusUnauthorized, "authorization required")
return
}
parts := strings.SplitN(header, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") {
log.Warn("auth: invalid authorization format", "remote", r.RemoteAddr)
authErr(w, http.StatusUnauthorized, "invalid authorization format, use Bearer <token>")
return
}
token := parts[1]
// Тест-режим только без секрета: при заданном JWT_HMAC_SECRET он обходил
// бы проверку подписи (ревью 2026-10-10).
if authTestMode && hmacSecret == "" && isPlainToken(token) {
log.Info("auth: test mode — plain token accepted", "remote", r.RemoteAddr, "path", r.URL.Path)
next.ServeHTTP(w, r)
return
}
// Fail-closed: вне тестового режима подпись ОБЯЗАТЕЛЬНА. Без секрета
// токен можно подделать — отказываем в обслуживании, а не проверяем «структурно».
if !authTestMode && hmacSecret == "" {
log.Error("auth: JWT_HMAC_SECRET не задан при AUTH_TEST_MODE=false — отказ (fail-closed)")
authErr(w, http.StatusInternalServerError, "auth misconfigured")
return
}
sub, err := validateJWT(token, hmacSecret)
if err != nil {
// 401, а не 403: невалидный/просроченный токен — не аутентифицирован
// (клиент/Terraform должны отличать «протух» от «чужой namespace»).
log.Warn("auth: invalid token", "remote", r.RemoteAddr, "path", r.URL.Path, "reason", err.Error())
authErr(w, http.StatusUnauthorized, "invalid token")
return
}
// Tenant-граница: `sub` из токена ОБЯЗАН совпадать с {namespace} из URL.
// Иначе любой валидный токен читал/писал бы любой namespace (горизонтальный доступ).
if ns := mux.Vars(r)["namespace"]; ns != "" && ns != sub {
log.Warn("auth: namespace mismatch — forbidden", "remote", r.RemoteAddr, "sub", sub, "namespace", ns, "path", r.URL.Path)
authErr(w, http.StatusForbidden, "forbidden")
return
}
next.ServeHTTP(w, r)
})
}
// authErr — единый JSON-ответ об ошибке аутентификации (middleware раньше отдавал
// text/plain через http.Error, а хендлеры — JSON; ревью 2026-10-10 — единый контракт).
func authErr(w http.ResponseWriter, code int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_, _ = w.Write([]byte(`{"error":"` + msg + `"}`))
}
// isPlainToken — непустая строка без пробелов, не похожая на JWT (три части через точку).
func isPlainToken(token string) bool {
if token == "" || strings.ContainsAny(token, " \t\n\r") {
return false
}
return len(strings.Split(token, ".")) != 3
}
// jwtError — структурная ошибка JWT.
type jwtError struct{ msg string }
func (e *jwtError) Error() string { return e.msg }
// validateJWT проверяет структуру JWT (sub + exp) и, если задан secret,
// HS256-подпись. Возвращает `sub` (идентичность пользователя) для сверки с namespace.
func validateJWT(token, secret string) (string, error) {
jwtParts := strings.Split(token, ".")
if len(jwtParts) != 3 {
return "", &jwtError{"not a JWT: expected 3 parts"}
}
if secret != "" {
var header struct {
Alg string `json:"alg"`
}
headerBytes, err := base64.RawURLEncoding.DecodeString(jwtParts[0])
if err != nil {
headerBytes, err = base64.StdEncoding.DecodeString(jwtParts[0])
}
if err != nil || jsonUnmarshal(headerBytes, &header) != nil || header.Alg != "HS256" {
return "", &jwtError{"JWT must be HS256 when JWT_HMAC_SECRET is set"}
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(jwtParts[0] + "." + jwtParts[1]))
expected := mac.Sum(nil)
sig, err := base64.RawURLEncoding.DecodeString(jwtParts[2])
if err != nil {
return "", &jwtError{"cannot decode JWT signature"}
}
if !hmac.Equal(expected, sig) {
return "", &jwtError{"JWT signature mismatch"}
}
}
payload := jwtParts[1]
switch len(payload) % 4 {
case 2:
payload += "=="
case 3:
payload += "="
}
decoded, err := base64.URLEncoding.DecodeString(payload)
if err != nil {
decoded, err = base64.StdEncoding.DecodeString(payload)
}
if err != nil {
return "", &jwtError{"cannot decode JWT payload"}
}
var claims struct {
Sub string `json:"sub"`
Exp float64 `json:"exp"`
}
if err := jsonUnmarshal(decoded, &claims); err != nil {
return "", &jwtError{"cannot parse JWT payload"}
}
if claims.Sub == "" {
return "", &jwtError{"JWT missing sub claim"}
}
// exp ОБЯЗАТЕЛЕН: без него токен вечный. Раньше проверка шла только при
// claims.Exp > 0, т.е. отсутствие exp = бессрочный токен (ревью 2026-10-10).
if claims.Exp == 0 {
return "", &jwtError{"JWT missing exp claim"}
}
if now := float64(timeNow().Unix()); claims.Exp < now {
return "", &jwtError{"JWT expired"}
}
return claims.Sub, nil
}
@@ -1,15 +0,0 @@
package middleware
import (
"encoding/json"
"time"
)
// jsonUnmarshal и timeNow вынесены для простоты тестирования.
func jsonUnmarshal(data []byte, v any) error {
return json.Unmarshal(data, v)
}
func timeNow() time.Time {
return time.Now()
}
@@ -1,42 +0,0 @@
// Package middleware — HTTP-промежуточные слои API.
package middleware
import (
"log/slog"
"net/http"
"time"
)
// responseWriter перехватывает статус ответа для логирования.
type responseWriter struct {
http.ResponseWriter
status int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.status = code
rw.ResponseWriter.WriteHeader(code)
}
// Logging логирует каждый HTTP-запрос: метод, путь, статус, длительность.
func Logging(log *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rw := &responseWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rw, r)
// Пробы здоровья логируем на Debug: платформа опрашивает их часто, а
// Info-строка на каждую засоряет лог (ревью 2026-10-10).
level := slog.LevelInfo
switch r.URL.Path {
case "/health", "/healthz", "/ready":
level = slog.LevelDebug
}
log.Log(r.Context(), level, "http",
"method", r.Method,
"path", r.URL.Path,
"status", rw.status,
"duration_ms", time.Since(start).Milliseconds(),
"remote", r.RemoteAddr,
)
})
}
-22
View File
@@ -1,22 +0,0 @@
// readiness.go — состояние подсистем для readiness-пробы /ready.
package api
import "sync/atomic"
// Readiness отслеживает, работают ли фоновые подсистемы (bridge, consumer).
// /health — liveness (процесс жив), /ready — readiness (подсистемы в строю).
type Readiness struct {
bridgeUp atomic.Bool
consumerUp atomic.Bool
}
// SetBridge — отметить состояние bridge.
func (r *Readiness) SetBridge(up bool) { r.bridgeUp.Store(up) }
// SetConsumer — отметить состояние consumer.
func (r *Readiness) SetConsumer(up bool) { r.consumerUp.Store(up) }
// Ready — true, когда обе подсистемы работают.
func (r *Readiness) Ready() bool {
return r.bridgeUp.Load() && r.consumerUp.Load()
}
-90
View File
@@ -1,90 +0,0 @@
// Package api — HTTP-сервер монолита: маршруты, CORS, health.
package api
import (
"log/slog"
"net/http"
"github.com/gorilla/mux"
"gitea.services.ngcloud.ru/Nail/IoT/internal/service/api/handler"
"gitea.services.ngcloud.ru/Nail/IoT/internal/service/api/middleware"
)
// corsMiddleware добавляет CORS-заголовки (консоль и API на одном домене платформы).
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
// NewRouter собирает все маршруты сервиса.
func NewRouter(h *handler.Handler, log *slog.Logger, authTestMode bool, jwtHMACSecret string, version string, ready *Readiness) http.Handler {
r := mux.NewRouter()
// Health — для платформенных проверок контейнера (liveness).
r.HandleFunc("/health", healthHandler(version)).Methods(http.MethodGet)
r.HandleFunc("/healthz", healthHandler(version)).Methods(http.MethodGet)
// Ready — readiness: 503, если фоновые подсистемы (bridge/consumer) не в строю.
r.HandleFunc("/ready", readyHandler(ready)).Methods(http.MethodGet)
// Лендинг "/" — инструкция (дизайн Nubes, как у shared-sqs) + статика.
r.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
ServeLanding(w, req, version)
}).Methods(http.MethodGet)
r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", StaticHandler()))
// UI — публичные страницы (без данных).
r.HandleFunc("/console", ServeIoTConsole).Methods(http.MethodGet)
r.HandleFunc("/iot-admin", ServeIoTAdmin).Methods(http.MethodGet)
// Админ-статистика — свой Bearer-токен.
r.HandleFunc("/iot-admin/stats", h.AdminStats).Methods(http.MethodGet)
// /v1 — под JWT.
v1 := r.PathPrefix("/v1").Subrouter()
v1.HandleFunc("/namespaces/{namespace}/iot/devices", h.ListIoTDevices).Methods(http.MethodGet)
v1.HandleFunc("/namespaces/{namespace}/iot/devices", h.CreateIoTDevice).Methods(http.MethodPost)
v1.HandleFunc("/namespaces/{namespace}/iot/devices/{name}", h.GetIoTDevice).Methods(http.MethodGet)
v1.HandleFunc("/namespaces/{namespace}/iot/devices/{name}", h.DeleteIoTDevice).Methods(http.MethodDelete)
v1.HandleFunc("/namespaces/{namespace}/iot/devices/{name}", h.UpdateIoTDevice).Methods(http.MethodPatch)
v1.HandleFunc("/namespaces/{namespace}/iot/telemetry", h.ListIoTTelemetry).Methods(http.MethodGet)
v1.Use(func(next http.Handler) http.Handler {
return middleware.Auth(authTestMode, jwtHMACSecret, log, next)
})
return corsMiddleware(middleware.Logging(log, r))
}
// healthHandler отдаёт статус сервиса и версию (для платформы).
func healthHandler(version string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok","version":"` + version + `"}`))
}
}
// readyHandler отдаёт 200 если все подсистемы в строю, иначе 503.
// Отделяет readiness от liveness: процесс может быть жив, но pipeline стоять.
func readyHandler(ready *Readiness) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if ready != nil && !ready.Ready() {
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(`{"status":"not ready"}`))
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ready"}`))
}
}
-736
View File
@@ -1,736 +0,0 @@
<!DOCTYPE html>
<!-- Создано: 2026-04-06
iot-admin.html — страница администратора IoT pipeline.
Показывает: PostgreSQL stats per tenant, очередь shared-SQS, рантайм процесса.
Auth: ADMIN_STATS_TOKEN вводится вручную и хранится в sessionStorage.
Раздаётся по GET /iot-admin (go:embed в бинарнике iot-service).
НЕ для конечных пользователей — только для администратора платформы. -->
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nubes IoT Admin</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="icon" href="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/favicon.png">
<style>
/* Nubes brand palette — те же цвета что в iot-console.html */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: #001120;
color: #e2ecf6;
min-height: 100vh;
}
/* Navbar */
.navbar {
background: #001C34;
border-bottom: 1px solid #0b2d50;
padding: 0 28px;
height: 58px;
display: flex;
align-items: center;
gap: 14px;
}
.navbar-logo {
display: flex;
align-items: center;
gap: 10px;
text-decoration: none;
}
.navbar-logo img {
height: 18px;
filter: brightness(0) invert(1);
}
.navbar-logo-sep {
width: 1px;
height: 18px;
background: #1a4a73;
margin: 0 4px;
}
.navbar-title {
font-size: 15px;
font-weight: 600;
color: #e2ecf6;
}
.navbar-badge {
background: #2d1a00;
border: 1px solid #7a3a00;
color: #f0a030;
font-size: 10px;
font-weight: 700;
padding: 2px 7px;
border-radius: 4px;
letter-spacing: 0.5px;
text-transform: uppercase;
}
.navbar-spacer {
flex: 1;
}
.navbar-refresh {
background: #0f3a60;
border: 1px solid #1a5a8a;
color: #7fc8f8;
padding: 6px 14px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
transition: background 0.15s;
}
.navbar-refresh:hover {
background: #1a5080;
}
.navbar-refresh:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Layout */
.container {
max-width: 1280px;
margin: 0 auto;
padding: 28px 24px;
}
/* Auth box */
.auth-box {
background: #001929;
border: 1px solid #0b2d50;
border-radius: 12px;
padding: 40px;
max-width: 480px;
margin: 80px auto;
display: flex;
flex-direction: column;
gap: 16px;
}
.auth-box h2 {
font-size: 20px;
font-weight: 600;
color: #7fc8f8;
}
.auth-box p {
font-size: 13px;
color: #6b8eaa;
}
.auth-input {
background: #001120;
border: 1px solid #1a4a73;
color: #e2ecf6;
padding: 10px 14px;
border-radius: 8px;
font-size: 14px;
font-family: monospace;
width: 100%;
outline: none;
}
.auth-input:focus {
border-color: #1a7fd4;
}
.auth-btn {
background: #1a7fd4;
border: none;
color: #fff;
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
font-weight: 600;
transition: background 0.15s;
}
.auth-btn:hover {
background: #1a6ab8;
}
.auth-error {
color: #f87171;
font-size: 13px;
}
/* Section header */
.section-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
padding-bottom: 10px;
border-bottom: 1px solid #0b2d50;
}
.section-icon {
width: 20px;
height: 20px;
opacity: 0.7;
}
.section-title {
font-size: 16px;
font-weight: 600;
color: #a0c4e8;
}
.section {
margin-bottom: 32px;
}
/* Cards grid */
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
/* Stat card */
.card {
background: #001929;
border: 1px solid #0b2d50;
border-radius: 10px;
padding: 20px;
}
.card-title {
font-size: 12px;
color: #6b8eaa;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
}
.card-value {
font-size: 28px;
font-weight: 700;
color: #e2ecf6;
}
.card-sub {
font-size: 12px;
color: #6b8eaa;
margin-top: 4px;
}
.card-accent {
color: #1a7fd4;
}
.card-warn {
color: #f59e0b;
}
.card-ok {
color: #34d399;
}
.card-err {
color: #f87171;
}
/* Tenant table */
.tenant-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.tenant-table th {
text-align: left;
padding: 8px 12px;
color: #6b8eaa;
border-bottom: 1px solid #0b2d50;
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.tenant-table td {
padding: 10px 12px;
border-bottom: 1px solid #071a28;
vertical-align: top;
}
.tenant-table tr:last-child td {
border-bottom: none;
}
.tenant-table tr:hover td {
background: rgba(26, 127, 212, 0.05);
}
.ns-tag {
font-family: monospace;
font-size: 12px;
color: #7fc8f8;
background: #0b2d50;
padding: 2px 6px;
border-radius: 4px;
}
.num-big {
font-size: 16px;
font-weight: 600;
color: #e2ecf6;
}
.num-small {
font-size: 12px;
color: #6b8eaa;
}
/* Latest msgs mini list */
.latest-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.latest-item {
background: #001120;
border: 1px solid #0b2d50;
border-radius: 6px;
padding: 6px 10px;
font-size: 11px;
}
.latest-dev {
color: #7fc8f8;
font-weight: 600;
}
.latest-ts {
color: #6b8eaa;
margin-left: 6px;
}
.latest-payload {
color: #a0c4e8;
margin-top: 2px;
word-break: break-all;
font-family: monospace;
}
/* Last updated */
.last-updated {
font-size: 12px;
color: #2d5070;
text-align: center;
margin-top: 16px;
}
/* Spinner */
.spinner {
border: 3px solid #0b2d50;
border-top-color: #1a7fd4;
border-radius: 50%;
width: 32px;
height: 32px;
animation: spin 0.8s linear infinite;
margin: 60px auto;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Error banner */
.error-banner {
background: #1a0000;
border: 1px solid #5a0000;
color: #f87171;
padding: 12px 16px;
border-radius: 8px;
font-size: 13px;
margin-bottom: 20px;
}
/* Auto-refresh indicator */
.refresh-timer {
font-size: 11px;
color: #2d5070;
display: flex;
align-items: center;
gap: 6px;
}
.refresh-progress {
width: 60px;
height: 2px;
background: #0b2d50;
border-radius: 2px;
overflow: hidden;
}
.refresh-bar {
height: 100%;
background: #1a7fd4;
border-radius: 2px;
transition: width 1s linear;
}
</style>
</head>
<body>
<!-- Navbar -->
<nav class="navbar">
<a class="navbar-logo" href="#" aria-label="Nubes">
<img src="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/logo.svg" alt="Nubes">
</a>
<div class="navbar-logo-sep"></div>
<span class="navbar-title">IoT Admin</span>
<span class="navbar-badge">Admin Only</span>
<div class="navbar-spacer"></div>
<div class="refresh-timer" id="refreshTimer" style="display:none">
<span id="refreshCountdown">30</span>s
<div class="refresh-progress">
<div class="refresh-bar" id="refreshBar" style="width:100%"></div>
</div>
</div>
<button class="navbar-refresh" id="btnRefresh" onclick="loadStats()" disabled>Обновить</button>
</nav>
<!-- Main content -->
<div class="container">
<!-- Auth box (показывается до ввода токена) -->
<div class="auth-box" id="authBox">
<h2>Доступ для администратора</h2>
<p>Введите ADMIN_STATS_TOKEN для просмотра статистики IoT pipeline.</p>
<input class="auth-input" id="tokenInput" type="password" placeholder="Bearer token..." autocomplete="off"
onkeydown="if(event.key==='Enter') doAuth()">
<button class="auth-btn" onclick="doAuth()">Войти</button>
<div class="auth-error" id="authError" style="display:none"></div>
</div>
<!-- Контент (показывается после авторизации) -->
<div id="mainContent" style="display:none">
<div class="error-banner" id="errorBanner" style="display:none"></div>
<!-- Spinner при загрузке -->
<div class="spinner" id="spinner"></div>
<!-- Данные -->
<div id="dataContent" style="display:none">
<!-- shared-SQS: очередь телеметрии -->
<div class="section">
<div class="section-header">
<svg class="section-icon" viewBox="0 0 24 24" fill="none" stroke="#7fc8f8" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
<span class="section-title">Очередь shared-SQS</span>
</div>
<div class="cards" id="sqsCards"></div>
</div>
<!-- Рантайм процесса -->
<div class="section">
<div class="section-header">
<svg class="section-icon" viewBox="0 0 24 24" fill="none" stroke="#7fc8f8" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2" />
<path d="M8 21h8M12 17v4" />
</svg>
<span class="section-title">Рантайм</span>
</div>
<div class="cards" id="runtimeCards"></div>
</div>
<!-- PostgreSQL per tenant -->
<div class="section">
<div class="section-header">
<svg class="section-icon" viewBox="0 0 24 24" fill="none" stroke="#7fc8f8" stroke-width="2">
<ellipse cx="12" cy="5" rx="9" ry="3" />
<path d="M3 5v14c0 1.66 4.03 3 9 3s9-1.34 9-3V5" />
<path d="M3 12c0 1.66 4.03 3 9 3s9-1.34 9-3" />
</svg>
<span class="section-title">PostgreSQL — Telemetry</span>
</div>
<div class="cards" style="margin-bottom:16px" id="pgSummaryCards"></div>
<div style="background:#001929;border:1px solid #0b2d50;border-radius:10px;overflow:auto">
<table class="tenant-table" id="tenantTable">
<thead>
<tr>
<th>Namespace</th>
<th>Total</th>
<th>Last 1h</th>
<th>Last 24h</th>
<th>Последние сообщения</th>
</tr>
</thead>
<tbody id="tenantTableBody"></tbody>
</table>
</div>
</div>
<div class="last-updated" id="lastUpdated"></div>
</div>
</div>
</div>
<script>
// ── State ──────────────────────────────────────────────────────────────────
const API_BASE = window.location.origin;
let adminToken = sessionStorage.getItem('iot_admin_token') || '';
let refreshInterval = null;
let refreshCountdown = 30;
// ── Auth ───────────────────────────────────────────────────────────────────
function doAuth() {
const input = document.getElementById('tokenInput').value.trim();
if (!input) return;
adminToken = input;
sessionStorage.setItem('iot_admin_token', adminToken);
document.getElementById('authBox').style.display = 'none';
document.getElementById('mainContent').style.display = 'block';
loadStats();
}
function showAuthError(msg) {
const el = document.getElementById('authError');
el.textContent = msg;
el.style.display = 'block';
// Сбрасываем токен — он не подошёл
adminToken = '';
sessionStorage.removeItem('iot_admin_token');
document.getElementById('authBox').style.display = 'block';
document.getElementById('mainContent').style.display = 'none';
if (refreshInterval) { clearInterval(refreshInterval); refreshInterval = null; }
document.getElementById('refreshTimer').style.display = 'none';
}
// Если токен уже в sessionStorage — пропускаем auth box
if (adminToken) {
document.getElementById('authBox').style.display = 'none';
document.getElementById('mainContent').style.display = 'block';
document.getElementById('spinner').style.display = 'block';
}
// ── Load stats ─────────────────────────────────────────────────────────────
async function loadStats() {
if (!adminToken) return;
document.getElementById('btnRefresh').disabled = true;
document.getElementById('spinner').style.display = 'block';
document.getElementById('dataContent').style.display = 'none';
document.getElementById('errorBanner').style.display = 'none';
resetRefreshTimer();
try {
const resp = await fetch(`${API_BASE}/iot-admin/stats`, {
headers: { 'Authorization': `Bearer ${adminToken}` }
});
if (resp.status === 401 || resp.status === 503) {
const body = await resp.json().catch(() => ({}));
showAuthError(body.error || 'Ошибка авторизации');
document.getElementById('spinner').style.display = 'none';
return;
}
if (!resp.ok) {
throw new Error(`HTTP ${resp.status}`);
}
const data = await resp.json();
renderAll(data);
document.getElementById('spinner').style.display = 'none';
document.getElementById('dataContent').style.display = 'block';
document.getElementById('refreshTimer').style.display = 'flex';
document.getElementById('lastUpdated').textContent =
'Обновлено: ' + new Date(data.collected_at).toLocaleTimeString('ru-RU');
setupAutoRefresh();
} catch (e) {
document.getElementById('spinner').style.display = 'none';
showBanner('Ошибка загрузки данных: ' + e.message);
document.getElementById('dataContent').style.display = 'block';
} finally {
document.getElementById('btnRefresh').disabled = false;
}
}
function showBanner(msg) {
const el = document.getElementById('errorBanner');
el.textContent = msg;
el.style.display = 'block';
}
// ── Auto-refresh ───────────────────────────────────────────────────────────
function setupAutoRefresh() {
if (refreshInterval) return; // уже запущен
refreshInterval = setInterval(() => {
refreshCountdown--;
document.getElementById('refreshCountdown').textContent = refreshCountdown;
const pct = (refreshCountdown / 30) * 100;
document.getElementById('refreshBar').style.width = pct + '%';
if (refreshCountdown <= 0) {
clearInterval(refreshInterval);
refreshInterval = null;
loadStats();
}
}, 1000);
}
function resetRefreshTimer() {
if (refreshInterval) { clearInterval(refreshInterval); refreshInterval = null; }
refreshCountdown = 30;
document.getElementById('refreshCountdown').textContent = '30';
document.getElementById('refreshBar').style.width = '100%';
}
// ── Render ─────────────────────────────────────────────────────────────────
function renderAll(data) {
renderSQS(data.sqs || {});
renderRuntime(data.runtime || {});
renderPostgres(data.postgres || {});
}
// Очередь shared-SQS
function renderSQS(sqs) {
const el = document.getElementById('sqsCards');
if (sqs.error) {
el.innerHTML = `<div class="card"><div class="card-title">Ошибка</div>
<div class="card-value card-err" style="font-size:14px">${esc(sqs.error)}</div></div>`;
return;
}
const pending = sqs.approximate_messages || 0;
const inFlight = sqs.approximate_messages_not_visible || 0;
const pendingClass = pending === 0 ? 'card-ok' : pending < 100 ? 'card-warn' : 'card-err';
el.innerHTML = `
<div class="card">
<div class="card-title">Ожидают обработки</div>
<div class="card-value ${pendingClass}">${pending.toLocaleString()}</div>
<div class="card-sub">ApproximateNumberOfMessages</div>
</div>
<div class="card">
<div class="card-title">В обработке</div>
<div class="card-value card-accent">${inFlight.toLocaleString()}</div>
<div class="card-sub">ApproximateNumberOfMessagesNotVisible</div>
</div>`;
}
// Рантайм процесса
function renderRuntime(rt) {
const el = document.getElementById('runtimeCards');
const uptime = rt.uptime_sec || 0;
const h = Math.floor(uptime / 3600);
const m = Math.floor((uptime % 3600) / 60);
const s = uptime % 60;
const uptimeStr = h > 0 ? `${h}ч ${m}м` : m > 0 ? `${m}м ${s}с` : `${s}с`;
el.innerHTML = `
<div class="card">
<div class="card-title">Версия</div>
<div class="card-value" style="font-size:20px">${esc(rt.version || '—')}</div>
<div class="card-sub">iot-service</div>
</div>
<div class="card">
<div class="card-title">Uptime</div>
<div class="card-value card-accent">${uptimeStr}</div>
<div class="card-sub">с ${esc(rt.started_at || '—')}</div>
</div>`;
}
// Postgres section
function renderPostgres(pg) {
const summaryEl = document.getElementById('pgSummaryCards');
const tbodyEl = document.getElementById('tenantTableBody');
if (!pg.reachable) {
summaryEl.innerHTML = `<div class="card"><div class="card-title">Ошибка</div>
<div class="card-value card-err" style="font-size:14px">${esc(pg.error || 'Unreachable')}</div></div>`;
tbodyEl.innerHTML = '';
return;
}
const tenants = pg.tenants || [];
const totalAll = pg.total_all || 0;
summaryEl.innerHTML = `
<div class="card">
<div class="card-title">Всего записей</div>
<div class="card-value card-accent">${totalAll.toLocaleString()}</div>
<div class="card-sub">Все tenant, iot_telemetry</div>
</div>
<div class="card">
<div class="card-title">Tenant-ов</div>
<div class="card-value">${tenants.length}</div>
<div class="card-sub">Активных namespace</div>
</div>`;
tbodyEl.innerHTML = tenants.map(t => {
if (t.error) {
return `<tr><td><span class="ns-tag">${esc(t.namespace)}</span></td>
<td colspan="4" style="color:#f87171">${esc(t.error)}</td></tr>`;
}
const latest = (t.latest || []).slice(0, 3);
const latestHtml = latest.length === 0
? '<span style="color:#2d5070">нет данных</span>'
: `<div class="latest-list">${latest.map(row => `
<div class="latest-item">
<span class="latest-dev">${esc(row.device_id)}</span>
<span class="latest-ts">${formatTs(row.ts)}</span>
<div class="latest-payload">${esc(truncate(JSON.stringify(row.payload), 80))}</div>
</div>`).join('')}</div>`;
return `<tr>
<td><span class="ns-tag">${esc(t.namespace)}</span><br>
<span style="font-size:11px;color:#2d5070">${esc(t.db_name)}</span></td>
<td><span class="num-big">${(t.total || 0).toLocaleString()}</span></td>
<td><span class="num-small">${(t.last_1h || 0).toLocaleString()}</span></td>
<td><span class="num-small">${(t.last_24h || 0).toLocaleString()}</span></td>
<td>${latestHtml}</td>
</tr>`;
}).join('');
}
// ── Utils ──────────────────────────────────────────────────────────────────
function esc(str) {
if (str == null) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function truncate(str, len) {
if (!str) return '';
return str.length > len ? str.slice(0, len) + '…' : str;
}
function formatTs(ts) {
if (!ts) return '';
try {
const d = new Date(ts);
return d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
} catch { return ts; }
}
// ── Init ───────────────────────────────────────────────────────────────────
if (adminToken) {
loadStats();
}
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
-362
View File
@@ -1,362 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>IoT — приём телеметрии устройств (Nubes)</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<style>
:root {
--brand-primary: #2563eb;
--brand-primary-dark: #1d4ed8;
--brand-gray: #d1d5db;
--brand-grey-light: #f3f4f6;
--text-primary: #1a1a1a;
--text-muted: #6b7280;
--success: #16a34a;
--warning: #d97706;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, "Segoe UI", Roboto, sans-serif;
font-size: 14px;
color: var(--text-primary);
background: #fafafa;
line-height: 1.5;
}
.topbar {
height: 56px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 16px;
background: #fff;
border-bottom: 1px solid var(--brand-gray);
position: sticky;
top: 0;
}
.topbar .brand { font-weight: 600; color: var(--text-primary); }
.topbar .muted { color: var(--text-muted); }
.topbar .spacer { flex: 1; }
.chip {
font-family: ui-monospace, monospace;
font-size: 12px;
color: var(--brand-primary);
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 999px;
padding: 2px 10px;
}
.container { max-width: 880px; margin: 0 auto; padding: 24px 16px 48px; }
.hero { display: flex; align-items: center; gap: 16px; margin-bottom: 24px; }
.hero h1 { font-size: 24px; margin: 0; letter-spacing: -0.3px; }
.hero .sub { color: var(--text-muted); margin: 4px 0 0; }
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: var(--warning);
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 999px;
padding: 2px 10px;
margin-left: 8px;
}
.card {
background: #fff;
border: 1px solid var(--brand-gray);
border-radius: 12px;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
margin-bottom: 20px;
overflow: hidden;
}
.card-header {
background: var(--brand-grey-light);
padding: 12px;
font-weight: 600;
font-size: 14px;
}
.card-body { padding: 12px; }
.card-body > p:first-child { margin-top: 0; }
.card-body > p:last-child { margin-bottom: 0; }
.kv { display: grid; grid-template-columns: 200px 1fr; gap: 8px 16px; }
.kv .k { color: var(--text-muted); }
.kv .v { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 13px; word-break: break-all; }
a { color: var(--brand-primary); text-decoration: none; }
a:hover { color: var(--brand-primary-dark); text-decoration: underline; }
pre {
background: var(--brand-grey-light);
border: 1px solid var(--brand-gray);
border-radius: 8px;
padding: 12px;
overflow-x: auto;
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 12.5px;
line-height: 1.6;
margin: 8px 0 0;
}
pre code { background: none; border: none; padding: 0; }
.status-ok { color: var(--success); font-weight: 600; }
.footer { color: var(--text-muted); font-size: 12px; text-align: center; margin-top: 32px; }
.step { display: flex; gap: 14px; margin-bottom: 18px; }
.step:last-child { margin-bottom: 0; }
.step-num {
flex: 0 0 30px;
height: 30px;
border-radius: 999px;
background: var(--brand-primary);
color: #fff;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
}
.step-body { flex: 1; }
.step-title { font-weight: 600; font-size: 15px; margin-bottom: 2px; }
.step-body p { color: #374151; margin: 4px 0; }
.btn-hero {
display: inline-flex;
align-items: center;
gap: 8px;
background: var(--brand-primary);
color: #fff !important;
text-decoration: none !important;
font-size: 15px;
font-weight: 600;
padding: 10px 20px;
border-radius: 8px;
margin-top: 8px;
}
.btn-hero:hover { background: var(--brand-primary-dark); }
.hint-box {
font-size: 14px;
color: #374151;
background: var(--brand-grey-light);
border: 1px solid var(--brand-gray);
border-radius: 8px;
padding: 10px 14px;
margin-top: 10px;
line-height: 1.7;
}
.hint-box code { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 13px; }
/* Схема пайплайна */
.flow {
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: stretch;
margin: 10px 0 4px;
}
.flow-node {
border: 1px solid var(--brand-gray);
border-radius: 10px;
background: #fff;
padding: 10px 12px;
min-width: 110px;
text-align: center;
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
}
.flow-node .fn-title { font-weight: 600; font-size: 13.5px; }
.flow-node .fn-sub {
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 11.5px;
color: var(--text-muted);
margin-top: 3px;
word-break: break-all;
}
.flow-node.acc { border-color: #bfdbfe; background: #eff6ff; }
.flow-node.acc .fn-title { color: var(--brand-primary); }
.flow-arrow {
align-self: center;
color: var(--text-muted);
font-size: 16px;
padding: 0 2px;
}
.flow-caption { color: var(--text-muted); font-size: 12px; margin-top: 6px; }
.pipeline { width: 100%; max-width: 640px; display: block; margin: 12px auto 4px; }
</style>
</head>
<body>
<div class="topbar">
<img src="/static/logo.svg" height="18" alt="nubes">
<span class="brand">Nubes</span>
<span class="muted">· IoT</span>
<span class="spacer"></span>
<span class="chip">{{VERSION}}</span>
</div>
<div class="container">
<div class="hero">
<img src="/static/logo.svg" height="28" alt="nubes">
<div>
<h1>IoT <span class="badge">тестирование</span></h1>
<p class="sub">Это база телеметрии. Устройства шлют сюда показания по MQTT —
показания сохраняются и доступны через API и консоль. Ниже — как отправить
первое показание за 2 минуты.</p>
</div>
</div>
<div class="card">
<div class="card-header">Как это работает</div>
<div class="card-body">
<p>Устройство (датчик, бойлер, счётчик) отправляет показания по протоколу
MQTT. Дальше показания проходят по цепочке и попадают в базу данных:</p>
<img class="pipeline" src="/static/pipeline.svg" alt="Схема пайплайна IoT">
<div class="flow-caption">
Между брокером и базой работают бридж и потребитель (части iot-service):
бридж кладёт показания в очередь, потребитель пишет их в базу.
</div>
<p style="color:#374151">
Очередь <code>iot-telemetry</code> — внутренний буфер сервиса: она
защищает показания от потерь при всплесках и сбоях базы. Пользователю
креды очереди НЕ нужны — сервис ходит в неё сам. Если требуется
посмотреть сообщения вручную — консоль очереди:
<a href="https://sqs.containerk8s.dev.nubes.ru/ui">sqs.containerk8s.dev.nubes.ru/ui</a>
(вход по API-токену Nubes; описание — на
<a href="https://sqs.containerk8s.dev.nubes.ru/">sqs.containerk8s.dev.nubes.ru</a>).
</p>
<p style="color:#374151">
У каждого устройства — свои логин и пароль, поэтому чужое устройство не
сможет писать в ваш топик. Данные разных проектов (namespace) лежат в
разных базах и не смешиваются.
</p>
<p><b>Три понятия:</b></p>
<p style="color:#374151">
<b>namespace</b> — имя вашего проекта/группы устройств (например
<code>myhome</code>);<br>
<b>device_id</b> — идентификатор конкретного устройства (например
<code>boiler-1</code>);<br>
<b>топик</b> — адрес, в который устройство отправляет показания:
<code>namespace/telemetry/device_id</code>.
</p>
</div>
</div>
<div class="card">
<div class="card-header">Быстрый старт — 4 шага</div>
<div class="card-body">
<div class="step">
<div class="step-num">1</div>
<div class="step-body">
<div class="step-title">Получить токен доступа к API</div>
<p>Все команды API требуют заголовок <code>Authorization: Bearer
&lt;токен&gt;</code>. Для тестов подойдёт самодельный токен: сервис
проверяет только два поля (кто и до какого времени), подпись не
проверяется. Одна команда — токен готов:</p>
<div class="hint-box">
<code>TOKEN=$(python3 -c "import base64,json,time;h=base64.urlsafe_b64encode(b'{\"alg\":\"none\",\"typ\":\"JWT\"}').rstrip(b'=').decode();p=base64.urlsafe_b64encode(json.dumps({'sub':'admin','exp':int(time.time())+86400}).encode()).rstrip(b'=').decode();print(h+'.'+p+'.sig')")</code>
</div>
</div>
</div>
<div class="step">
<div class="step-num">2</div>
<div class="step-body">
<div class="step-title">Зарегистрировать устройство</div>
<p>Одна команда — и устройство <code>boiler-1</code> появляется в
проекте <code>myhome</code>. В ответе будет логин для MQTT
(<code>myhome_boiler-1</code>). Пароль в ответе НЕ показывается —
его отдаёт второй запрос.</p>
<div class="hint-box">
<code>curl -X POST -H "Authorization: Bearer $TOKEN" \<br>
-H "Content-Type: application/json" \<br>
-d '{"name":"boiler","device_id":"boiler-1"}' \<br>
https://iot.containerk8s.dev.nubes.ru/v1/namespaces/myhome/iot/devices</code><br>
<br><code># пароль — во втором запросе (поле mqtt_password):</code><br>
<code>curl -H "Authorization: Bearer $TOKEN" \<br>
https://iot.containerk8s.dev.nubes.ru/v1/namespaces/myhome/iot/devices/boiler</code>
</div>
</div>
</div>
<div class="step">
<div class="step-num">3</div>
<div class="step-body">
<div class="step-title">Отправить показание</div>
<p>Подключение к MQTT — по адресу
<code>wss://exqx.containerk8s.dev.nubes.ru/mqtt</code>. Логин —
<code>myhome_boiler-1</code>, пароль — из шага 2. Показание
отправляется в топик <code>myhome/telemetry/boiler-1</code>.</p>
<div class="hint-box">
<code>import paho.mqtt.client as mqtt, json</code><br>
<code>c = mqtt.Client(transport="websockets")</code><br>
<code>c.ws_set_options(path="/mqtt")</code><br>
<code>c.username_pw_set("myhome_boiler-1", "&lt;пароль из шага 2&gt;")</code><br>
<code>c.connect("exqx.containerk8s.dev.nubes.ru", 443)</code><br>
<code>c.publish("myhome/telemetry/boiler-1", json.dumps({"temp": 58.3}))</code>
</div>
</div>
</div>
<div class="step">
<div class="step-num">4</div>
<div class="step-body">
<div class="step-title">Увидеть результат</div>
<p>Показание доходит в базу за ~1 секунду. Посмотреть — запросом или
в консоли:</p>
<div class="hint-box">
<code>curl -H "Authorization: Bearer $TOKEN" \<br>
"https://iot.containerk8s.dev.nubes.ru/v1/namespaces/myhome/iot/telemetry?limit=50"</code>
</div>
<a class="btn-hero" href="/console">Открыть консоль →</a>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">Подключение</div>
<div class="card-body kv">
<div class="k">MQTT endpoint</div>
<div class="v">wss://exqx.containerk8s.dev.nubes.ru/mqtt</div>
<div class="k">Протокол</div>
<div class="v">MQTT 3.1.1 / 5.0 over WebSocket (subprotocol <code>mqtt</code>), TLS</div>
<div class="k">REST API</div>
<div class="v">https://iot.containerk8s.dev.nubes.ru</div>
<div class="k">Аутентификация MQTT</div>
<div class="v">username <code>{namespace}_{device_id}</code> + пароль из API</div>
<div class="k">Аутентификация REST</div>
<div class="v">Bearer JWT (<code>sub</code>, <code>exp</code>)</div>
<div class="k">Топик устройства</div>
<div class="v">{namespace}/telemetry/{device_id}</div>
<div class="k">Консоль</div>
<div class="v"><a href="/console">/console</a> — устройства, эмулятор, телеметрия</div>
<div class="k">Админ-статистика</div>
<div class="v"><a href="/iot-admin">/iot-admin</a> (токен ADMIN_STATS_TOKEN)</div>
<div class="k">Health</div>
<div class="v"><a href="/health">GET /health</a> <span class="status-ok">● ok</span></div>
</div>
</div>
<div class="card">
<div class="card-header">MQTT endpoint (exqx)</div>
<div class="card-body">
<p style="color:#374151">
<code>exqx.containerk8s.dev.nubes.ru</code> — это MQTT-брокер, а не
веб-страница. Точка подключения — только <code>/mqtt</code> через
WebSocket. Если открыть этот адрес в браузере, вернётся
<code>400</code> — так и должно быть: браузер не использует
WebSocket-подпротокол <code>mqtt</code>. Подключаться нужно MQTT-
клиентом (paho-mqtt, mqtt.js и т.п.), как в шаге 3.
</p>
<p style="color:#374151">
Ограничения: сообщение — до ~250 КБ; платформа может разрывать
внешние WebSocket-соединения примерно раз в 150 секунд — клиент
должен уметь переподключаться (в paho это делается автоматически).
</p>
</div>
</div>
<div class="footer">iot-service · realm iot-naeel · версия {{VERSION}} · <a href="/console">консоль</a> · <a href="/iot-admin">админ</a> · <a href="/health">health</a></div>
</div>
</body>
</html>
@@ -1,55 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="57"
height="57"
xml:space="preserve"
overflow="hidden"
version="1.1"
id="svg8"
sodipodi:docname="U_v3.svg"
inkscape:version="1.3.2 (091e20e, 2023-11-25, custom)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview8"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="16.226667"
inkscape:cx="27.146672"
inkscape:cy="28.317584"
inkscape:window-width="2560"
inkscape:window-height="1494"
inkscape:window-x="-11"
inkscape:window-y="-11"
inkscape:window-maximized="1"
inkscape:current-layer="svg8" /><defs
id="defs2"><clipPath
id="clip0"><rect
x="652"
y="420"
width="64"
height="75"
id="rect1" /></clipPath><clipPath
id="clip1"><rect
x="652"
y="420"
width="64"
height="70"
id="rect2" /></clipPath></defs><g
clip-path="url(#clip0)"
transform="matrix(1.0135748,0,0,1.0135748,-664.97034,-440.30567)"
id="g8"><g
clip-path="url(#clip1)"
id="g7"><g
id="g6"><path
d="m 114.028,43.1492 v 7.6592 c 0,3.7843 -3.08,6.8632 -6.866,6.8632 L 85.3367,57.5419 c -3.7853,0 -6.8655,-3.0805 -6.8655,-6.8643 v -2.5279 l 0.0555,0.009 V 15.8148 l -10.3823,2.0127 0.0045,3.8772 -0.0045,0.0015 v 28.971 c 0,9.481 7.7132,17.1923 17.1867,17.1923 l 21.8359,0.1313 c 9.474,0 17.187,-7.7117 17.187,-17.1928 v -2.6541 l 0.027,0.0045 V 15.8145 l -10.382,2.0126 0.028,25.3214 z"
fill="#001c34"
fill-rule="evenodd"
transform="matrix(1,0,0,1.01337,587.92,420.059)"
id="path6" /></g></g></g></svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

-25
View File
@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Creator: CorelDRAW -->
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="82.3711mm" height="18.2443mm" version="1.1" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd"
viewBox="0 0 8221.93 1821.07"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:xodm="http://www.corel.com/coreldraw/odm/2003">
<defs>
<style type="text/css">
<![CDATA[
.fil0 {fill:#001C34}
]]>
</style>
</defs>
<g id="Слой_x0020_1">
<metadata id="CorelCorpID_0Corel-Layer"/>
<g id="_2283355517888">
<path class="fil0" d="M477.49 479.69l-413.51 0 -63.98 276.48 178.44 0 1.17 1028.71 0 26.75 0.03 0 276.39 0 0 -871.72c0,-101.28 82.43,-183.69 183.75,-183.69l589.25 3.44c101.34,0 183.76,82.46 183.76,183.74l0 871.72 276.44 0 0 -871.72c0,-253.77 -206.46,-460.15 -460.05,-460.15l-589.52 -3.53 -162.17 0.45 0 -0.48z"/>
<path class="fil0" d="M6664.65 1813.37l1181.04 0c212.14,0 376.24,-175.01 376.24,-387.08 0,-212.13 -172.55,-384.68 -384.69,-384.68l-653.66 0c-60.22,0 -109.16,-48.94 -109.16,-109.18l0 -66.18c0,-60.19 48.94,-109.14 109.16,-109.14l695.76 0 63.74 -275.49 -759.5 0c-212.13,0 -384.66,172.53 -384.66,384.63l0 66.18c0,212.13 172.53,384.67 384.66,384.67l653.66 0c60.2,0 109.2,49 109.2,109.19 0,60.13 -49,116.1 -109.2,116.1l-1118.9 0 -53.68 270.98z"/>
<path class="fil0" d="M6200.79 483.5l-723.21 0c-215.88,0 -391.47,175.6 -391.47,391.51l0 485.5c0,248.38 202.05,450.4 450.4,450.4l989.38 0 62.13 -268.51 -1051.51 0c-96.41,0 -174.95,-85.37 -174.95,-181.88l-1.5 -39.46 923.62 0 308.51 -1.84 0 -237.95 0 -206.26c0,-215.91 -175.59,-391.51 -391.41,-391.51zm115.96 560.28l-955.19 0 0 -168.77c0,-63.96 52.07,-116.05 116.02,-116.05l723.21 0c63.91,0 115.96,52.08 115.96,116.05l0 168.77z"/>
<path class="fil0" d="M4683.49 1194.24l0 168.28c0,100.92 -81.7,178.37 -182.67,178.37l-589.87 1.23c-93.18,0 -170.28,-69.96 -181.63,-160.09l0 -458.13c11.36,-90.1 88.46,-160.07 181.63,-160.07l589.44 3.5c100.97,0 183.1,82.18 183.1,183.1l0 30.42 0 213.4zm-1230.2 -345.53l-0.89 -795.03 276.91 -53.68 0 526.07c55.74,-24.16 117.03,-37.74 181.5,-37.74l589.71 3.5c252.69,0 458.42,205.72 458.42,458.59l0 30.42 0 213.4 0 168.28c0,252.86 -205.73,458.54 -458.42,458.54l-589.71 -3.5c-252.7,0 -458.41,-205.67 -458.41,-458.56l0 -171.61 0 -240.48 0.89 -98.2z"/>
<path class="fil0" d="M3041.25 1150.84l0 204.28c0,100.93 -82.15,183.05 -183.11,183.05l-582.11 -3.46c-100.96,0 -183.11,-82.16 -183.11,-183.08l0 -67.42 1.48 0.24 0 -862.65 -276.91 53.68 0.12 103.41 -0.12 0.04 0 772.69c0,252.87 205.72,458.54 458.39,458.54l582.4 3.5c252.67,0 458.4,-205.68 458.4,-458.55l0 -70.79 0.71 0.12 0 -862.65 -276.91 53.68 0.76 675.35z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

@@ -1,69 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 1060" width="100%" role="img" aria-label="Схема пайплайна IoT">
<defs>
<style>
text { font-family: system-ui, "Segoe UI", Roboto, sans-serif; }
.mono { font-family: ui-monospace, Menlo, Consolas, monospace; }
</style>
<marker id="arr" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto">
<path d="M0,0 L8,3 L0,6 Z" fill="#6b7280"/>
</marker>
</defs>
<!-- Подложка «ПЛАТФОРМА» -->
<text x="104" y="222" font-size="12" fill="#6b7280">ПЛАТФОРМА</text>
<rect x="100" y="232" width="440" height="568" rx="16" fill="none"
stroke="#bfdbfe" stroke-width="1.5" stroke-dasharray="6 4"/>
<!-- Переход 1: Устройство → EMQX -->
<line x1="320" y1="158" x2="320" y2="242" stroke="#6b7280" stroke-width="2" marker-end="url(#arr)"/>
<rect x="175" y="187" width="130" height="26" rx="8" fill="#ffffff" stroke="#d1d5db"/>
<text x="240" y="205" font-size="14" fill="#1a1a1a" text-anchor="middle">MQTT over wss</text>
<text x="345" y="196" font-size="13" class="mono" fill="#6b7280">{namespace}/</text>
<text x="345" y="216" font-size="13" class="mono" fill="#6b7280">telemetry/{device_id}</text>
<!-- Переход 2: EMQX → Очередь -->
<line x1="320" y1="368" x2="320" y2="452" stroke="#6b7280" stroke-width="2" marker-end="url(#arr)"/>
<rect x="200" y="397" width="80" height="26" rx="8" fill="#ffffff" stroke="#d1d5db"/>
<text x="240" y="415" font-size="14" fill="#1a1a1a" text-anchor="middle">бридж</text>
<text x="345" y="416" font-size="13" fill="#6b7280">кладёт показания</text>
<!-- Переход 3: Очередь → PostgreSQL -->
<line x1="320" y1="578" x2="320" y2="662" stroke="#6b7280" stroke-width="2" marker-end="url(#arr)"/>
<rect x="175" y="607" width="130" height="26" rx="8" fill="#ffffff" stroke="#d1d5db"/>
<text x="240" y="625" font-size="14" fill="#1a1a1a" text-anchor="middle">потребитель</text>
<text x="345" y="626" font-size="13" fill="#6b7280">пишет в базу</text>
<!-- Переход 4: PostgreSQL → API / Консоль -->
<line x1="320" y1="788" x2="320" y2="872" stroke="#6b7280" stroke-width="2" marker-end="url(#arr)"/>
<rect x="205" y="817" width="70" height="26" rx="8" fill="#ffffff" stroke="#d1d5db"/>
<text x="240" y="835" font-size="14" fill="#1a1a1a" text-anchor="middle">REST</text>
<text x="345" y="836" font-size="13" fill="#6b7280">чтение истории</text>
<!-- Блок 1: Устройство -->
<rect x="110" y="40" width="420" height="110" rx="12" fill="#ffffff" stroke="#d1d5db"/>
<text x="320" y="102" font-size="20" font-weight="600" fill="#1a1a1a" text-anchor="middle">Устройство</text>
<text x="320" y="128" font-size="14" class="mono" fill="#6b7280" text-anchor="middle">датчик · MQTT-клиент</text>
<!-- Блок 2: EMQX -->
<rect x="110" y="250" width="420" height="110" rx="12" fill="#eff6ff" stroke="#bfdbfe"/>
<text x="320" y="312" font-size="20" font-weight="600" fill="#2563eb" text-anchor="middle">EMQX</text>
<text x="320" y="338" font-size="14" class="mono" fill="#6b7280" text-anchor="middle">брокер · wss /mqtt</text>
<!-- Блок 3: Очередь -->
<rect x="110" y="460" width="420" height="110" rx="12" fill="#eff6ff" stroke="#bfdbfe"/>
<text x="320" y="522" font-size="20" font-weight="600" fill="#2563eb" text-anchor="middle">Очередь</text>
<text x="320" y="548" font-size="14" class="mono" fill="#6b7280" text-anchor="middle">iot-telemetry</text>
<!-- Блок 4: PostgreSQL -->
<rect x="110" y="670" width="420" height="110" rx="12" fill="#eff6ff" stroke="#bfdbfe"/>
<text x="320" y="732" font-size="20" font-weight="600" fill="#2563eb" text-anchor="middle">PostgreSQL</text>
<text x="320" y="758" font-size="14" class="mono" fill="#6b7280" text-anchor="middle">база на namespace</text>
<!-- Блок 5: API / Консоль -->
<rect x="110" y="880" width="420" height="110" rx="12" fill="#ffffff" stroke="#d1d5db"/>
<text x="320" y="942" font-size="20" font-weight="600" fill="#1a1a1a" text-anchor="middle">API / Консоль</text>
<text x="320" y="968" font-size="14" class="mono" fill="#6b7280" text-anchor="middle">чтение истории</text>
<!-- Подвал -->
<text x="320" y="1038" font-size="13" fill="#6b7280" text-anchor="middle">бридж и потребитель — части сервиса iot-service</text>
</svg>

Before

Width:  |  Height:  |  Size: 4.5 KiB

-145
View File
@@ -1,145 +0,0 @@
// Package bridge — MQTT-подписка на телеметрию устройств → shared-SQS.
//
// Роль старого mqtt-bridge, но внутри монолита: подключается к EMQX как
// MQTT-клиент, подписывается на "+/telemetry/+" и шлёт envelope в SQS.
package bridge
import (
"context"
"log/slog"
"time"
"github.com/aws/aws-sdk-go-v2/service/sqs"
mqtt "github.com/eclipse/paho.mqtt.golang"
"gitea.services.ngcloud.ru/Nail/IoT/internal/service/config"
"gitea.services.ngcloud.ru/Nail/IoT/internal/service/sqsclient"
)
// telemetryTopicFilter — все namespace, все устройства.
const telemetryTopicFilter = "+/telemetry/+"
// subscribeRetryInterval — пауза между попытками подписки при сбое.
// Подписка обязательна (иначе bridge «глохнет»: соединение есть, а топик не слушается).
const subscribeRetryInterval = 2 * time.Second
// setSubscribed — nil-safe вызов колбэка состояния подписки.
func setSubscribed(fn func(bool), v bool) {
if fn != nil {
fn(v)
}
}
// Run — бесконечный цикл bridge. onSubscribed — колбэк состояния подписки
// (true после успешного SUBSCRIBE, false при потере соединения/сбое подписки):
// /ready должен отражать реальную подписку, а не факт запуска горутины
// (находка #3: «соединён, но не подписан» молча терял телеметрию при /ready=200).
func Run(ctx context.Context, cfg *config.Config, sqsClient *sqs.Client, log *slog.Logger, onSubscribed func(bool)) error {
log.Info("bridge: starting", "broker", cfg.MQTTBrokerURL, "client_id", cfg.MQTTClientID)
// URL очереди получаем один раз при старте (как старый consumer).
queueURL, err := sqsclient.ResolveQueueURLWithRetry(ctx, sqsClient, cfg.SQSQueueName)
if err != nil {
return err
}
log.Info("bridge: SQS queue resolved", "queue", cfg.SQSQueueName)
// Обработчик сообщений передаём в connectMQTT: подписка выполняется в
// OnConnectHandler, т.е. повторяется при каждом (ре)подключении.
// Иначе после потери сессии EMQX бридж оставался бы без подписки
// (прецедент 2026-08-16: реконнект без resubscribe → телеметрия терялась).
//
// Отправка в SQS — через диспатчер (канал + worker-пул): колбэк MQTT
// не блокируется сетью (фикс CRITICAL из ревью 2026-08-16).
dispatcher := newSQSDispatcher(sqsClient, queueURL, log)
dispatcher.start()
handler := newMessageHandler(dispatcher, log)
client, err := connectMQTT(ctx, cfg, log, handler, onSubscribed)
if err != nil {
dispatcher.closeAndWait()
return err
}
<-ctx.Done()
log.Info("bridge: shutting down")
// Порядок: сначала отключить MQTT (источник), затем слить остаток в SQS.
client.Disconnect(2000)
dispatcher.closeAndWait()
return nil
}
// errSubscribeTimeout — не дождались подтверждения подписки.
var errSubscribeTimeout = errBridge("MQTT subscribe timeout")
// errBridge — ошибки bridge.
type errBridge string
func (e errBridge) Error() string { return string(e) }
// connectMQTT устанавливает подключение к EMQX с автореконнектом.
// handler передаётся в OnConnectHandler: подписка повторяется при каждом подключении.
func connectMQTT(ctx context.Context, cfg *config.Config, log *slog.Logger, handler mqtt.MessageHandler, onSubscribed func(bool)) (mqtt.Client, error) {
opts := mqtt.NewClientOptions()
opts.AddBroker(cfg.MQTTBrokerURL)
opts.SetClientID(cfg.MQTTClientID)
opts.SetUsername(cfg.MQTTUsername)
opts.SetPassword(cfg.MQTTPassword)
opts.SetAutoReconnect(true)
opts.SetConnectRetry(true)
opts.SetConnectRetryInterval(5 * time.Second)
// MaxReconnectInterval по умолчанию ~10 минут — слишком долго для потоковой
// телеметрии; 30с (ревью 2026-10-10).
opts.SetMaxReconnectInterval(30 * time.Second)
opts.SetKeepAlive(30 * time.Second)
opts.SetCleanSession(false)
opts.SetConnectionLostHandler(func(_ mqtt.Client, err error) {
log.Warn("bridge: MQTT connection lost, reconnecting...", "err", err)
setSubscribed(onSubscribed, false)
})
opts.SetReconnectingHandler(func(_ mqtt.Client, _ *mqtt.ClientOptions) {
log.Info("bridge: MQTT reconnecting...")
})
opts.SetOnConnectHandler(func(c mqtt.Client) {
log.Info("bridge: MQTT connected")
// Подписка обязательна. Ретраим пока не удастся (или не оборвётся ctx/
// соединение). НЕ вызываем c.Disconnect(): в paho это ЯВНОЕ отключение,
// после которого автореконнект НЕ срабатывает (ревью 2026-10-10, 🔴6).
// Раньше было 3 попытки и ожидание следующего реконнекта — но при живом
// соединении OnConnectHandler больше не вызовется, и bridge оставался бы
// без подписки навсегда (ревью 2026-10-11).
for {
if ctx.Err() != nil || !c.IsConnected() {
return
}
token := c.Subscribe(telemetryTopicFilter, 1, handler)
if token.WaitTimeout(10*time.Second) && token.Error() == nil {
log.Info("bridge: subscribed", "filter", telemetryTopicFilter)
setSubscribed(onSubscribed, true)
return
}
log.Error("bridge: subscribe failed, retrying", "filter", telemetryTopicFilter, "err", token.Error())
select {
case <-ctx.Done():
return
case <-time.After(subscribeRetryInterval):
}
}
})
client := mqtt.NewClient(opts)
token := client.Connect()
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(30 * time.Second):
return nil, errBridge("bridge: MQTT connect timeout")
case <-token.Done():
}
if token.Error() != nil {
return nil, token.Error()
}
return client, nil
}
-83
View File
@@ -1,83 +0,0 @@
// handler.go — обработчик MQTT-сообщений: envelope → канал диспатчера SQS.
//
// Колбэк НЕ делает сетевых вызовов: только парсинг топика и неблокирующий
// enqueue. Отправка в SQS — worker-пул sqsDispatcher (см. sender.go).
package bridge
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"log/slog"
"regexp"
"strings"
"time"
mqtt "github.com/eclipse/paho.mqtt.golang"
)
// TelemetryEnvelope — сообщение, перекладываемое в SQS (разбирается consumer).
type TelemetryEnvelope struct {
Namespace string `json:"namespace"`
DeviceID string `json:"device_id"`
Topic string `json:"topic"`
Payload json.RawMessage `json:"payload"`
ReceivedAt string `json:"received_at"`
// MessageID — уникальный id сообщения. Нужен для идемпотентности: SQS
// at-least-once может доставить дубликат — consumer дедуплицирует по нему.
MessageID string `json:"message_id"`
}
// newMessageHandler возвращает обработчик MQTT-сообщений:
// валидация топика "{namespace}/telemetry/{deviceId}" → enqueue в диспатчер.
func newMessageHandler(dispatcher *sqsDispatcher, log *slog.Logger) mqtt.MessageHandler {
return func(_ mqtt.Client, msg mqtt.Message) {
topic := msg.Topic()
payload := msg.Payload()
// Топик: "{namespace}/telemetry/{deviceId}". Пустые части — мусор.
parts := strings.SplitN(topic, "/", 3)
if len(parts) != 3 || parts[0] == "" || parts[2] == "" || parts[1] != "telemetry" {
log.Warn("bridge: unexpected topic format, skipping", "topic", topic)
return
}
ns := parts[0]
deviceID := parts[2]
// Defense-in-depth: EMQX ACL — основной барьер, но при его ошибке/отключении
// бридж принял бы произвольный namespace. Дополнительно проверяем формат.
if !identRe.MatchString(ns) || !identRe.MatchString(deviceID) {
log.Warn("bridge: invalid namespace/device in topic, skipping", "topic", topic)
return
}
// Не-JSON payload оборачиваем в строку.
rawPayload := json.RawMessage(payload)
if !json.Valid(payload) {
quoted, _ := json.Marshal(string(payload))
rawPayload = json.RawMessage(quoted)
}
dispatcher.enqueue(TelemetryEnvelope{
Namespace: ns,
DeviceID: deviceID,
Topic: topic,
Payload: rawPayload,
ReceivedAt: time.Now().UTC().Format(time.RFC3339Nano),
MessageID: messageIDFor(ns, deviceID, topic, payload),
})
}
}
// identRe — безопасный идентификатор (namespace/device_id): буквы/цифры/дефис,
// не с дефиса, ≤64 символов. Defense-in-depth поверх EMQX ACL.
var identRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9-]{0,63}$`)
// messageIDFor — детерминированный id сообщения для дедупа.
// Повторная QoS1-доставка того же (device, topic, payload) даёт тот же id,
// поэтому ON CONFLICT (device_id, message_id) в БД дедуплицирует дубликаты
// (раньше uuid на каждый колбэк — дедуп на повторе не срабатывал).
func messageIDFor(ns, deviceID, topic string, payload []byte) string {
h := sha256.Sum256([]byte(ns + "|" + deviceID + "|" + topic + "|" + string(payload)))
return hex.EncodeToString(h[:])
}
-216
View File
@@ -1,216 +0,0 @@
// sender.go — асинхронная отправка envelope в SQS из бриджа.
//
// Мотивация (Sonnet-ревью 2026-08-16, находка CRITICAL): SendMessage был
// синхронным внутри MQTT-колбэка paho. При недоступности SQS (таймаут ~30с)
// блокировался приём ВСЕХ MQTT-сообщений → потери телеметрии.
//
// Теперь: колбэк кладёт envelope в буферизованный канал НЕблокирующе
// (переполнение — дроп со счётчиком), worker-пул отправляет в SQS с
// ограниченными ретраями и backoff.
package bridge
import (
"context"
"encoding/json"
"errors"
"log/slog"
"sync"
"sync/atomic"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/sqs"
"github.com/aws/smithy-go"
)
// Константы диспатчера.
const (
// dispatcherQueueSize — буфер envelope перед SQS (~100 msg/s * 100с буфера).
dispatcherQueueSize = 10000
// dispatcherWorkers — горутин, отправляющих в SQS.
dispatcherWorkers = 4
// sendAttemptTimeout — таймаут ОДНОЙ попытки SendMessage. Без него зависшее
// соединение (SQS висит, но не отвечает) блокировало бы воркера навсегда и
// забило бы весь пул (ревью 🔴4).
sendAttemptTimeout = 10 * time.Second
// sendMaxBackoff — потолок паузы между попытками (экспоненциальный backoff).
sendMaxBackoff = 8 * time.Second
// bridgeFlushTimeout — сколько ждать слива остатка буфера в SQS при shutdown.
// Если SQS лежит и ретраи не завершаются, по этому таймауту ctx отменяется,
// воркеры обрывают отправку (остаток логируется как дропнутый).
bridgeFlushTimeout = 10 * time.Second
// maxSQSBodyBytes — потолок размера envelope для SQS.
// SQS (и shared-sqs) режут сообщения >256KB — запас на оверхед.
maxSQSBodyBytes = 250 * 1024
)
// sqsDispatcher — канал + worker-пул для отправки в SQS.
type sqsDispatcher struct {
ch chan TelemetryEnvelope
client *sqs.Client
queueURL string
log *slog.Logger
wg sync.WaitGroup
dropped atomic.Int64
// mu защищает закрытие канала: не даёт enqueue писать в уже закрытый ch
// (паника «send on closed channel» от позднего колбэка paho — ревью 🔴3).
mu sync.Mutex
closed bool
// ctx для отправки НЕ связан с сигнальным ctx bridge: иначе при shutdown
// воркеры сразу вернули бы «context canceled» и дропнули остаток буфера
// (ревью 🔴3). Отменяется в closeAndWait по bridgeFlushTimeout, если воркеры
// не успели слить остаток.
ctx context.Context
cancel context.CancelFunc
}
// newSQSDispatcher создаёт диспатчер. start() запускает worker-пул.
func newSQSDispatcher(client *sqs.Client, queueURL string, log *slog.Logger) *sqsDispatcher {
// ctx независимый от сигнала bridge: см. поле ctx. Отменяется только в
// closeAndWait (по таймауту), чтобы при shutdown воркеры успели слить буфер.
ctx, cancel := context.WithCancel(context.Background())
return &sqsDispatcher{
ch: make(chan TelemetryEnvelope, dispatcherQueueSize),
client: client,
queueURL: queueURL,
log: log,
ctx: ctx,
cancel: cancel,
}
}
// start запускает worker-пул. Воркеры используют d.ctx (независимый от сигнала).
func (d *sqsDispatcher) start() {
for i := 0; i < dispatcherWorkers; i++ {
d.wg.Add(1)
go func(wid int) {
defer d.wg.Done()
d.worker(wid)
}(i)
}
}
// enqueue кладёт envelope в канал НЕблокирующе. false = буфер полон (дроп) или
// канал уже закрыт (shutdown). Под mu, чтобы не писать в закрытый ch.
func (d *sqsDispatcher) enqueue(e TelemetryEnvelope) bool {
d.mu.Lock()
defer d.mu.Unlock()
if d.closed {
return false
}
select {
case d.ch <- e:
return true
default:
n := d.dropped.Add(1)
if n%100 == 1 {
d.log.Error("bridge: SQS dispatch queue full, dropping",
"namespace", e.Namespace, "device", e.DeviceID,
"dropped_total", n)
}
return false
}
}
// closeAndWait закрывает канал и ждёт, пока воркеры сольют остаток буфера в SQS.
// Закрытие — под mu (см. поле mu): после него поздний колбэк paho не упадёт на
// «send on closed channel». Флаш ограничен bridgeFlushTimeout: если SQS лежит,
// отменяем d.ctx, чтобы sendWithRetry оборвался и wg.Wait() вернулся.
func (d *sqsDispatcher) closeAndWait() {
d.mu.Lock()
d.closed = true
close(d.ch)
d.mu.Unlock()
t := time.AfterFunc(bridgeFlushTimeout, d.cancel)
d.wg.Wait()
t.Stop()
d.log.Info("bridge: SQS dispatcher stopped", "dropped_total", d.dropped.Load())
}
// worker — цикл отправки из канала с ретраями. Использует d.ctx (не сигнальный),
// чтобы при shutdown остаток буфера успел сойти в SQS.
func (d *sqsDispatcher) worker(wid int) {
for e := range d.ch {
body, err := json.Marshal(e)
if err != nil {
d.log.Error("bridge: marshal envelope", "worker", wid,
"namespace", e.Namespace, "err", err)
continue
}
if len(body) > maxSQSBodyBytes {
// Лимит SQS 256KB — такие сообщения не пройдут в принципе
// (проверено 2026-08-16: InvalidParameterValue message size
// exceeds the limit). Дроп с явным логом.
d.log.Error("bridge: payload too large for SQS, dropping",
"worker", wid, "namespace", e.Namespace,
"device", e.DeviceID, "bytes", len(body),
"limit_bytes", maxSQSBodyBytes)
continue
}
if err := d.sendWithRetry(d.ctx, string(body)); err != nil {
d.log.Error("bridge: SQS send failed after retries, dropping",
"worker", wid, "namespace", e.Namespace,
"device", e.DeviceID, "err", err)
}
}
}
// sendWithRetry — SendMessage с ретраями до остановки (ctx) и таймаутом на
// попытку. НЕ дропаем после фиксированного числа попыток (было 3 ≈ 7с): буфер
// на dispatcherQueueSize рассчитан на поглощение временной недоступности SQS,
// поэтому ретраим до ctx.Done() (shutdown) или успеха — иначе потеря была бы
// безвозвратной (ревью 🔴4).
func (d *sqsDispatcher) sendWithRetry(ctx context.Context, body string) error {
backoff := time.Second
for {
attemptCtx, cancel := context.WithTimeout(ctx, sendAttemptTimeout)
_, err := d.client.SendMessage(attemptCtx, &sqs.SendMessageInput{
QueueUrl: aws.String(d.queueURL),
MessageBody: aws.String(body),
})
cancel()
if err == nil {
return nil
}
// Permanent ошибки (неверный параметр/размер/нет очереди/доступ) — ретрай
// бессмыслен: вернуть сразу, иначе воркер залип бы на poison-сообщении и
// заполнил буфер (дроп остальных).
if isPermanentSQSErr(err) {
return err
}
if ctx.Err() != nil {
// shutdown: ctx отменён, ретраить бессмысленно.
return err
}
select {
case <-ctx.Done():
return err
case <-time.After(backoff):
}
if backoff < sendMaxBackoff {
backoff *= 2
}
}
}
// isPermanentSQSErr — true для ошибок SQS, которые не пройдут ни при каком
// ретрае (валидация, размер, отсутствие очереди, доступ). Остальное (throttling,
// 5xx, сеть) — retryable.
func isPermanentSQSErr(err error) bool {
var apiErr smithy.APIError
if errors.As(err, &apiErr) {
switch apiErr.ErrorCode() {
case "InvalidParameterValue", "InvalidMessageContents", "MessageTooBig",
"QueueDoesNotExist", "InvalidAddress", "InvalidSecurity",
"AccessDenied", "InvalidBatchEntryId":
return true
}
}
return false
}
-45
View File
@@ -1,45 +0,0 @@
// sender_test.go — тест безопасности enqueue ↔ closeAndWait (фикс 🔴3).
//
// Раньше поздний колбэк paho мог упасть «send on closed channel» при закрытии
// канала во время enqueue. Мьютекс + closed-флаг должны это исключать.
package bridge
import (
"io"
"log/slog"
"sync"
"testing"
)
func TestDispatcherEnqueueCloseNoPanic(t *testing.T) {
log := slog.New(slog.NewTextHandler(io.Discard, nil))
// client nil — enqueue/closeAndWait его не трогают (только worker'ы, которых не запускаем).
d := newSQSDispatcher(nil, "http://queue", log)
// Параллельный enqueue, пока основной поток закрывает канал.
var wg sync.WaitGroup
stop := make(chan struct{})
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
d.enqueue(TelemetryEnvelope{Namespace: "ns", DeviceID: "d"})
}
}
}()
}
d.closeAndWait()
close(stop)
wg.Wait()
// После закрытия enqueue обязан вернуть false (канал закрыт), а не паниковать.
if d.enqueue(TelemetryEnvelope{Namespace: "ns", DeviceID: "d"}) {
t.Error("enqueue после close должен вернуть false")
}
}
-171
View File
@@ -1,171 +0,0 @@
// Package config — конфигурация монолита iot-service из env-переменных.
//
// Принцип: несекретные значения имеют дефолты (продублированы ENV в Dockerfile),
// секреты — обязательны и задаются в deck-UI при создании контейнера.
// Env-переменные после деплоя не меняются — поэтому дефолты вшиты в образ.
package config
import (
"fmt"
"log/slog"
"os"
"strconv"
"strings"
)
// Config — вся конфигурация iot-service.
type Config struct {
// HTTP API
APIPort string
// PostgreSQL (managed, общий для устройств и телеметрии)
IOTPGDSN string
// shared-SQS
SQSEndpoint string
SQSAccessKey string
SQSSecretKey string
SQSQueueName string
SQSDLQName string
SQSRegion string
// SQS-потребитель
SQSLongPollSeconds int
SQSVisibilityTimeout int
// MQTT (EMQX)
MQTTBrokerURL string
MQTTUsername string
MQTTPassword string
MQTTClientID string
// Админ-статистика
AdminStatsToken string
// Безопасность
AuthTestMode bool
// JwtHMACSecret — если задан, JWT проверяется по HS256 (HMAC-SHA256).
// Пусто — структурная проверка (как раньше, периметр = платформа).
JwtHMACSecret string
// Логирование
LogLevel slog.Level
}
// getEnv возвращает значение env или дефолт.
func getEnv(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
// getEnvInt возвращает целое значение env или дефолт (неположительное — дефолт).
func getEnvInt(key string, def int) int {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
return n
}
}
return def
}
// getEnvBool разбирает булево значение env.
func getEnvBool(key string, def bool) bool {
v := os.Getenv(key)
if v == "" {
return def
}
switch strings.ToLower(v) {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
default:
return def
}
}
// parseLogLevel разбирает уровень логирования.
func parseLogLevel(v string, def slog.Level) slog.Level {
switch strings.ToLower(v) {
case "debug":
return slog.LevelDebug
case "warn", "warning":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return def
}
}
// Load читает конфигурацию из env, подставляет дефолты
// и проверяет обязательные (секретные) переменные.
func Load() (*Config, error) {
cfg := &Config{
APIPort: getEnv("API_PORT", DefaultAPIPort),
IOTPGDSN: os.Getenv("IOT_PG_DSN"),
SQSEndpoint: getEnv("SQS_ENDPOINT", DefaultSQSEndpoint),
SQSAccessKey: os.Getenv("SQS_ACCESS_KEY"),
SQSSecretKey: os.Getenv("SQS_SECRET_KEY"),
SQSQueueName: getEnv("SQS_QUEUE_NAME", DefaultSQSQueueName),
SQSDLQName: getEnv("SQS_DLQ_NAME", DefaultSQSDLQName),
SQSRegion: getEnv("SQS_REGION", DefaultSQSRegion),
SQSLongPollSeconds: getEnvInt("SQS_LONG_POLL_SECONDS", 20),
SQSVisibilityTimeout: getEnvInt("SQS_VISIBILITY_TIMEOUT", 120),
MQTTUsername: os.Getenv("MQTT_USERNAME"),
MQTTPassword: os.Getenv("MQTT_PASSWORD"),
MQTTClientID: getEnv("MQTT_CLIENT_ID", DefaultMQTTClientID),
AdminStatsToken: os.Getenv("ADMIN_STATS_TOKEN"),
AuthTestMode: getEnvBool("AUTH_TEST_MODE", false),
JwtHMACSecret: os.Getenv("JWT_HMAC_SECRET"),
LogLevel: parseLogLevel(os.Getenv("LOG_LEVEL"), slog.LevelInfo),
}
// MQTT broker URL: либо полный MQTT_BROKER_URL, либо из HOST/PORT/WS_PATH.
// MQTT_HOST обязателен: EMQX в другом namespace платформы, дефолта нет
// (см. DefaultMQTTHost).
cfg.MQTTBrokerURL = os.Getenv("MQTT_BROKER_URL")
if cfg.MQTTBrokerURL == "" {
host := getEnv("MQTT_HOST", DefaultMQTTHost)
port := getEnv("MQTT_PORT", DefaultMQTTPort)
path := getEnv("MQTT_WS_PATH", DefaultMQTTWSPath)
if host != "" {
cfg.MQTTBrokerURL = "ws://" + host + ":" + port + path
}
} else if !strings.HasPrefix(cfg.MQTTBrokerURL, "ws://") &&
!strings.HasPrefix(cfg.MQTTBrokerURL, "wss://") {
return nil, fmt.Errorf("MQTT_BROKER_URL must start with ws:// or wss://, got %q", cfg.MQTTBrokerURL)
}
var missing []string
if cfg.IOTPGDSN == "" {
missing = append(missing, "IOT_PG_DSN")
}
if cfg.MQTTBrokerURL == "" {
missing = append(missing, "MQTT_HOST (или MQTT_BROKER_URL)")
}
if cfg.SQSAccessKey == "" {
missing = append(missing, "SQS_ACCESS_KEY")
}
if cfg.SQSSecretKey == "" {
missing = append(missing, "SQS_SECRET_KEY")
}
if cfg.MQTTUsername == "" {
missing = append(missing, "MQTT_USERNAME")
}
if cfg.MQTTPassword == "" {
missing = append(missing, "MQTT_PASSWORD")
}
// Вне тестового режима JWT_HMAC_SECRET ОБЯЗАТЕЛЕН: без него подпись не
// проверяется и токен можно подделать (fail-closed на старте).
if !cfg.AuthTestMode && cfg.JwtHMACSecret == "" {
missing = append(missing, "JWT_HMAC_SECRET (или включите AUTH_TEST_MODE для тестов)")
}
if len(missing) > 0 {
return nil, fmt.Errorf("required env vars not set: %s", strings.Join(missing, ", "))
}
return cfg, nil
}
-39
View File
@@ -1,39 +0,0 @@
package config
// Defaults — дефолтные значения env-переменных (не секреты).
// Продублированы в Dockerfile ENV.
const (
// DefaultAPIPort — единственный порт контейнера: API + /health + /metrics.
DefaultAPIPort = "9090"
// DefaultSQSEndpoint — публичный endpoint shared-sqs (ВМ 213), внешний доступ
// обязателен: shared-sqs живёт НЕ в кластере Nubes. Переопределяется SQS_ENDPOINT.
DefaultSQSEndpoint = "https://sqs.sinefo.ru"
// DefaultSQSQueueName — очередь телеметрии IoT.
DefaultSQSQueueName = "iot-telemetry"
// DefaultSQSDLQName — DLQ для «мёртвых» сообщений: битые envelope, которые
// нельзя разобрать (повторная обработка бессмысленна). Валидные данные
// идут в основную очередь; неразборчивые — сюда, а не отбрасываются.
DefaultSQSDLQName = "iot-telemetry-dlq"
// DefaultSQSRegion — регион для self-hosted SQS (не важен, оставлен для SDK).
DefaultSQSRegion = "us-east-1"
// DefaultMQTTHost — пусто: вменяемого дефолта нет, EMQX живёт в ДРУГОМ
// namespace платформы и доступен только по FQDN
// containerk8s.<instanceUid EMQX>.svc.cluster.local. Поэтому MQTT_HOST обязателен
// (задаётся Terraform'ом из state EMQX), иначе config.Load вернёт ошибку.
DefaultMQTTHost = ""
// DefaultMQTTPort — WebSocket-листенер EMQX (wss внутри платформы).
DefaultMQTTPort = "8083"
// DefaultMQTTWSPath — путь MQTT-over-WebSocket на EMQX.
DefaultMQTTWSPath = "/mqtt"
// DefaultMQTTClientID — clientID bridge-подключения к EMQX.
// Используется и в ACL-логике MQTT-auth.
DefaultMQTTClientID = "iot-bridge"
)
-244
View File
@@ -1,244 +0,0 @@
// Package consumer — чтение shared-SQS → per-tenant PostgreSQL.
//
// Роль старого sqs-consumer внутри монолита: long-poll цикл, обработка
// envelope телеметрии, DeleteMessage после успешной записи (at-least-once).
package consumer
import (
"context"
"errors"
"log/slog"
"strconv"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/sqs"
sqstypes "github.com/aws/aws-sdk-go-v2/service/sqs/types"
"gitea.services.ngcloud.ru/Nail/IoT/internal/service/config"
"gitea.services.ngcloud.ru/Nail/IoT/internal/service/sqsclient"
"gitea.services.ngcloud.ru/Nail/IoT/internal/storage/iotpg"
)
// errorBackoff — пауза между попытками при ошибках SQS.
const errorBackoff = 5 * time.Second
// maxProcessBackoff — потолок backoff после ошибок обработки (PG).
const maxProcessBackoff = 60 * time.Second
// maxReceiveCount — после стольких приёмов «ядовитое» сообщение дропается, если
// DLQ недоступна: иначе оно крутилось бы на основной очереди бесконечно
// (находка #2 — без чтения ApproximateReceiveCount). При доступной DLQ битое
// уходит туда на первом же приёме.
const maxReceiveCount = 5
// Run — бесконечный long-poll цикл потребителя. onReady — колбэк готовности:
// true после успешного ReceiveMessage, false при ошибке SQS (отражается в /ready).
func Run(ctx context.Context, cfg *config.Config, sqsClient *sqs.Client, store *iotpg.IoTPostgresStore, log *slog.Logger, onReady func(bool)) error {
queueURL, err := sqsclient.ResolveQueueURLWithRetry(ctx, sqsClient, cfg.SQSQueueName)
if err != nil {
return err
}
// DLQ резолвим с ретраями: временная ошибка ResolveQueueURL НЕ должна навсегда
// отключить DLQ — иначе битые сообщения подтверждались бы и терялись без следа
// (ревью 2026-10-10, 🔴7). При окончательном провале dlqURL остаётся "" —
// битые сообщения тогда НЕ подтверждаются (см. обработку errInvalidEnvelope).
dlqURL := ""
for attempt := 1; attempt <= 3; attempt++ {
u, err := sqsclient.ResolveQueueURL(ctx, sqsClient, cfg.SQSDLQName)
if err == nil {
dlqURL = u
break
}
log.Warn("consumer: DLQ resolve failed", "dlq", cfg.SQSDLQName, "attempt", attempt, "err", err)
if !sleepCtx(ctx, errorBackoff) {
break
}
}
if dlqURL == "" {
log.Warn("consumer: DLQ недоступна — битые сообщения НЕ будут подтверждаться (останутся в очереди)", "dlq", cfg.SQSDLQName)
}
log.Info("consumer: SQS queue resolved", "queue", cfg.SQSQueueName, "dlq", cfg.SQSDLQName)
for {
if ctx.Err() != nil {
break
}
resp, err := sqsClient.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{
QueueUrl: aws.String(queueURL),
MaxNumberOfMessages: 10,
WaitTimeSeconds: int32(cfg.SQSLongPollSeconds),
VisibilityTimeout: int32(cfg.SQSVisibilityTimeout),
// Просим ApproximateReceiveCount — по нему ограничиваем повторы
// «ядовитого» сообщения (см. maxReceiveCount, находка #2).
MessageSystemAttributeNames: []sqstypes.MessageSystemAttributeName{
sqstypes.MessageSystemAttributeNameApproximateReceiveCount,
},
})
if err != nil {
if ctx.Err() != nil {
break
}
log.Error("consumer: ReceiveMessage failed", "err", err)
setReady(onReady, false)
if !sleepCtx(ctx, errorBackoff) {
break
}
continue
}
setReady(onReady, true)
processBackoff := time.Second
// Копим подтверждения, но НЕ удаляем из SQS сразу: InsertTelemetry лишь
// буферизует и возвращает nil до фактической записи. Удаляем только после
// успешного Flush — иначе краш/flush-ошибка = потеря подтверждённых
// сообщений (P0-7).
type pendingAck struct {
handle string
id string
}
var pending []pendingAck
for _, msg := range resp.Messages {
// nil/пустое тело не подтверждаем молча: прогоняем через processTelemetry,
// который вернёт errInvalidEnvelope → DLQ (ревью 2026-10-10: пустое тело
// подтверждалось без DLQ).
body := ""
if msg.Body != nil {
body = *msg.Body
}
if err := processTelemetry(ctx, body, store, log); err != nil {
// Битый envelope → DLQ (а не бесконечный ретрай и не потеря):
// кладём тело в DLQ, затем подтверждаем основное сообщение.
if errors.Is(err, errInvalidEnvelope) {
// Битый envelope → DLQ. Подтверждаем только если DLQ реально
// приняла сообщение (иначе битый теряется — 🔴7). Если DLQ
// недоступна/сбой — ограничиваем число повторов, чтобы не
// зациклиться навсегда (находка #2): после maxReceiveCount
// дропаем битое с громким логом.
if dlqURL != "" {
if derr := sendToDLQ(ctx, sqsClient, dlqURL, body, log); derr == nil {
pending = append(pending, pendingAck{aws.ToString(msg.ReceiptHandle), aws.ToString(msg.MessageId)})
continue
} else {
log.Error("consumer: send to DLQ failed", "err", derr, "message_id", aws.ToString(msg.MessageId))
}
}
recv := receiveCount(msg)
if recv >= maxReceiveCount {
log.Error("consumer: invalid envelope dropped after maxReceiveCount (DLQ недоступна)",
"message_id", aws.ToString(msg.MessageId), "receive_count", recv, "max", maxReceiveCount)
pending = append(pending, pendingAck{aws.ToString(msg.ReceiptHandle), aws.ToString(msg.MessageId)})
} else {
log.Warn("consumer: invalid envelope deferred (DLQ недоступна, будет повтор)",
"message_id", aws.ToString(msg.MessageId), "receive_count", recv)
}
continue
}
log.Error("consumer: process telemetry", "err", err, "message_id", aws.ToString(msg.MessageId))
// Ошибка обработки (не envelope, напр. PG). Применяем тот же лимит
// повторов, что и для битых envelope — иначе постоянная ошибка крутила
// бы сообщение бесконечно (visibility timeout → повтор → снова ошибка).
recv := receiveCount(msg)
if recv >= maxReceiveCount {
log.Error("consumer: dropped after maxReceiveCount",
"message_id", aws.ToString(msg.MessageId), "receive_count", recv, "err", err)
if dlqURL != "" {
if derr := sendToDLQ(ctx, sqsClient, dlqURL, body, log); derr == nil {
pending = append(pending, pendingAck{aws.ToString(msg.ReceiptHandle), aws.ToString(msg.MessageId)})
}
} else {
pending = append(pending, pendingAck{aws.ToString(msg.ReceiptHandle), aws.ToString(msg.MessageId)})
}
continue
}
// Сообщение НЕ подтверждаем — вернётся после visibility timeout.
// Backoff с jitter — не долбить упавший PG в цикле
// (фикс HIGH из ревью 2026-08-16: лавина ретраев).
if !sleepCtx(ctx, processBackoff) {
break
}
processBackoff *= 2
if processBackoff > maxProcessBackoff {
processBackoff = maxProcessBackoff
}
continue
}
processBackoff = time.Second
pending = append(pending, pendingAck{aws.ToString(msg.ReceiptHandle), aws.ToString(msg.MessageId)})
}
if len(pending) == 0 {
continue
}
// Сначала гарантируем запись в БД, только потом удаляем из SQS.
if err := store.Flush(ctx); err != nil {
log.Error("consumer: flush failed — сообщения НЕ удалены, вернутся после visibility timeout", "err", err)
continue
}
// DeleteMessageBatch: до 10 записей за вызов (было по одному DeleteMessage
// на сообщение — лишние round-trip, ревью 2026-10-10).
for i := 0; i < len(pending); i += 10 {
end := i + 10
if end > len(pending) {
end = len(pending)
}
chunk := pending[i:end]
entries := make([]sqstypes.DeleteMessageBatchRequestEntry, 0, len(chunk))
for j := range chunk {
entries = append(entries, sqstypes.DeleteMessageBatchRequestEntry{
Id: aws.String(strconv.Itoa(j)),
ReceiptHandle: aws.String(chunk[j].handle),
})
}
out, err := sqsClient.DeleteMessageBatch(ctx, &sqs.DeleteMessageBatchInput{
QueueUrl: aws.String(queueURL),
Entries: entries,
})
if err != nil {
log.Error("consumer: DeleteMessageBatch failed", "err", err, "count", len(chunk))
continue
}
for _, f := range out.Failed {
log.Error("consumer: DeleteMessageBatch entry failed",
"id", aws.ToString(f.Id), "code", aws.ToString(f.Code), "msg", aws.ToString(f.Message))
}
}
}
log.Info("consumer: shutting down")
return nil
}
// sendToDLQ — кладёт битое неразборчивое сообщение в DLQ для диагностики.
func sendToDLQ(ctx context.Context, client *sqs.Client, dlqURL, body string, log *slog.Logger) error {
_, err := client.SendMessage(ctx, &sqs.SendMessageInput{
QueueUrl: aws.String(dlqURL),
MessageBody: aws.String(body),
})
return err
}
// receiveCount — ApproximateReceiveCount сообщения (число приёмов из очереди).
// 0, если атрибут не запрошен/не задан.
func receiveCount(msg sqstypes.Message) int {
n, _ := strconv.Atoi(msg.Attributes[string(sqstypes.MessageSystemAttributeNameApproximateReceiveCount)])
return n
}
// setReady — nil-safe вызов колбэка готовности.
func setReady(fn func(bool), v bool) {
if fn != nil {
fn(v)
}
}
// sleepCtx — ожидание с прерыванием по ctx. false = ctx завершён.
func sleepCtx(ctx context.Context, d time.Duration) bool {
select {
case <-ctx.Done():
return false
case <-time.After(d):
return true
}
}
-68
View File
@@ -1,68 +0,0 @@
// processor.go — разбор envelope телеметрии и запись в per-tenant PostgreSQL.
package consumer
import (
"context"
"encoding/json"
"errors"
"log/slog"
"time"
"gitea.services.ngcloud.ru/Nail/IoT/internal/service/bridge"
"gitea.services.ngcloud.ru/Nail/IoT/internal/storage/iotpg"
)
// errInvalidEnvelope — битый/неразборчивый envelope: повторная обработка
// бессмысленна, сообщение следует отправить в DLQ, а не ретраить бесконечно.
var errInvalidEnvelope = errors.New("invalid telemetry envelope")
// processTelemetry десериализует envelope и пишет в tenant DB.
// Битый JSON — возвращает errInvalidEnvelope (consumer шлёт в DLQ),
// а НЕ молчаливый nil: иначе мусор подтверждается и теряется безвозвратно.
func processTelemetry(ctx context.Context, body string, store *iotpg.IoTPostgresStore, log *slog.Logger) error {
var envelope bridge.TelemetryEnvelope
if err := json.Unmarshal([]byte(body), &envelope); err != nil {
log.Warn("consumer: invalid envelope, routing to DLQ", "err", err)
return errInvalidEnvelope
}
// Семантическая валидация envelope ДО записи в БД: пустые namespace/device_id
// или пустой payload — битое сообщение, ретраем не исправится. Пустой
// namespace → EnsureTenantDB создал бы БД «tenant_», пустой payload → NULL
// (23502) или битый JSONB. Такое шлём в DLQ (errInvalidEnvelope), а не
// бесконечно ретраим (ревью 2026-10-10, 🔴2).
if envelope.Namespace == "" || envelope.DeviceID == "" || len(envelope.Payload) == 0 {
log.Warn("consumer: invalid envelope (empty namespace/device_id/payload), routing to DLQ",
"namespace", envelope.Namespace, "device", envelope.DeviceID)
return errInvalidEnvelope
}
// EnsureTenantDB идемпотентен, кэшируется после первого вызова.
if err := store.EnsureTenantDB(ctx, envelope.Namespace); err != nil {
if errors.Is(err, iotpg.ErrInvalidNamespace) {
// Небезопасный namespace — постоянная ошибка → в DLQ (не ретраить).
log.Warn("consumer: invalid namespace, routing to DLQ", "namespace", envelope.Namespace)
return errInvalidEnvelope
}
return err
}
// Время приёма сохраняем в ts (порядок телеметрии). RFC3339Nano — субсекундная
// точность (очередь non-FIFO: несколько показаний в секунду не должны терять
// порядок). Parse RFC3339Nano принимает и старые RFC3339 (доля секунды опциональна).
// Битый/пустой received_at → zero, стор подставит now().
receivedAt, perr := time.Parse(time.RFC3339Nano, envelope.ReceivedAt)
if perr != nil {
receivedAt = time.Time{}
}
if err := store.InsertTelemetry(ctx, envelope.Namespace, envelope.DeviceID, envelope.MessageID, receivedAt, envelope.Payload); err != nil {
return err
}
// Debug: данные ещё только буферизованы (не записаны), Info на каждое
// сообщение засорял бы лог (ревью 2026-10-10).
log.Debug("consumer: telemetry buffered",
"namespace", envelope.Namespace,
"device", envelope.DeviceID,
)
return nil
}
-65
View File
@@ -1,65 +0,0 @@
// Package sqsclient — общий фабричный клиент shared-SQS для монолита.
//
// Используют: bridge (SendMessage), consumer (ReceiveMessage/DeleteMessage),
// admin-stats (GetQueueUrl/GetQueueAttributes).
package sqsclient
import (
"context"
"fmt"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/sqs"
"gitea.services.ngcloud.ru/Nail/IoT/internal/service/config"
)
// New создаёт SQS-клиент с переопределённым endpoint (self-hosted shared-sqs).
func New(cfg *config.Config) *sqs.Client {
return sqs.New(sqs.Options{
Region: cfg.SQSRegion,
Credentials: credentials.NewStaticCredentialsProvider(
cfg.SQSAccessKey, cfg.SQSSecretKey, "",
),
BaseEndpoint: aws.String(cfg.SQSEndpoint),
})
}
// ResolveQueueURL получает URL очереди по имени (один вызов при старте).
func ResolveQueueURL(ctx context.Context, client *sqs.Client, queueName string) (string, error) {
out, err := client.GetQueueUrl(ctx, &sqs.GetQueueUrlInput{
QueueName: aws.String(queueName),
})
if err != nil {
return "", fmt.Errorf("sqs: GetQueueUrl %q: %w", queueName, err)
}
return aws.ToString(out.QueueUrl), nil
}
// ResolveQueueURLWithRetry — ResolveQueueURL с ретраями на старте. Временная
// ошибка GetQueueUrl (SQS ещё поднимается, сетевой сбой) не должна фатально
// ронять bridge/consumer без ретрая (ревью 2026-10-10, 🔴5). После исчерпания
// попыток возвращает ошибку — вызывающий должен завершить процесс ≠ 0 (платформа
// перезапустит контейнер).
func ResolveQueueURLWithRetry(ctx context.Context, client *sqs.Client, queueName string) (string, error) {
const attempts = 10
const backoff = 2 * time.Second
var lastErr error
for attempt := 1; attempt <= attempts; attempt++ {
url, err := ResolveQueueURL(ctx, client, queueName)
if err == nil {
return url, nil
}
lastErr = err
if attempt < attempts {
select {
case <-ctx.Done():
return "", lastErr
case <-time.After(backoff):
}
}
}
return "", lastErr
}
-40
View File
@@ -1,40 +0,0 @@
package store
import (
"context"
"fmt"
"golang.org/x/crypto/bcrypt"
)
// bridgeNamespace — служебный namespace строки бриджа в iot_devices.
// EMQX (postgres auth/ACL) узнаёт бридж по условию
// namespace = '__bridge' AND username = device_id.
const bridgeNamespace = "__bridge"
// EnsureBridgeDevice создаёт (или обновляет) служебную строку бриджа в
// iot_devices. EMQX авторизует MQTT-подключение бриджа по этой строке,
// поэтому device_id = MQTT username (iot-bridge), пароль = MQTT_PASSWORD.
//
// Нужно для PG-auth EMQX (хвост 3): EMQX зависит только от PostgreSQL,
// монолит при старте гарантирует наличие строки бриджа.
func (s *DeviceStore) EnsureBridgeDevice(ctx context.Context, username, password string) error {
// Пароль бриджа храним bcrypt-хэшем (EMQX проверяет bcrypt), как и пароли
// устройств.
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("store: hash bridge password: %w", err)
}
_, err = s.db.ExecContext(ctx, `
INSERT INTO iot_devices (namespace, name, device_id, enabled, mqtt_password, metadata, phase)
VALUES ($1, $2, $3, true, $4, '{}'::jsonb, 'Active')
ON CONFLICT (namespace, name) DO UPDATE
SET device_id = EXCLUDED.device_id,
enabled = true,
mqtt_password = EXCLUDED.mqtt_password`,
bridgeNamespace, "iot-bridge", username, string(hash))
if err != nil {
return fmt.Errorf("store: ensure bridge device: %w", err)
}
return nil
}
-33
View File
@@ -1,33 +0,0 @@
// Package store — хранилище устройств IoT в PostgreSQL.
//
// Таблица iot_devices заменяет CRD IoTDevice и k8s Secrets старого IoT.
// Устройства: namespace, name, device_id, enabled, mqtt_password, metadata.
package store
import (
"errors"
"time"
)
// ErrNotFound — устройство не найдено.
var ErrNotFound = errors.New("device not found")
// ErrAlreadyExists — конфликт уникальности (namespace, name) или (namespace, device_id).
var ErrAlreadyExists = errors.New("device already exists")
// ErrDeviceIDExists — конфликт именно по (namespace, device_id) (отличается от
// конфликта по name для понятной ошибки API/Terraform, ревью 2026-10-10).
var ErrDeviceIDExists = errors.New("device_id already exists")
// Device — устройство IoT из таблицы iot_devices.
type Device struct {
Namespace string
Name string
DeviceID string
Enabled bool
MQTTPassword string
Metadata map[string]string
Phase string
LastConnected *time.Time
CreatedAt time.Time
}
-215
View File
@@ -1,215 +0,0 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/lib/pq"
)
// Create вставляет новое устройство. Конфликт (namespace, name) или
// (namespace, device_id) возвращает ErrAlreadyExists.
func (s *DeviceStore) Create(ctx context.Context, d *Device) error {
metadata, err := json.Marshal(d.Metadata)
if err != nil {
return fmt.Errorf("store: marshal metadata: %w", err)
}
// phase пишем ЯВНО через phaseOf(enabled): колонка имеет DEFAULT 'Active',
// поэтому без явной записи устройство с enabled=false лежало бы в БД как
// 'Active', а POST-ответ считал бы 'Disabled' → GET отдал бы другое значение
// (ревью 2026-10-10, 🟡 store). Согласовано с UpdateEnabled (тот же phaseOf).
_, err = s.db.ExecContext(ctx, `
INSERT INTO iot_devices (namespace, name, device_id, enabled, mqtt_password, metadata, phase)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
d.Namespace, d.Name, d.DeviceID, d.Enabled, d.MQTTPassword, metadata, phaseOf(d.Enabled),
)
if err != nil {
if isUniqueViolation(err) {
// Различаем конфликт name и device_id по имени constraint (ревью
// 2026-10-10): PK — iot_devices_pkey (namespace,name), уникальный
// индекс — idx_iot_devices_ns_device (namespace,device_id).
var pqErr *pq.Error
if errors.As(err, &pqErr) && strings.Contains(pqErr.Constraint, "ns_device") {
return ErrDeviceIDExists
}
return ErrAlreadyExists
}
return fmt.Errorf("store: insert device: %w", err)
}
return nil
}
// List возвращает устройства namespace (без паролей — API их не отдаёт).
// limit <= 0 — без ограничения; offset — смещение (пагинация API).
func (s *DeviceStore) List(ctx context.Context, namespace string, limit, offset int) ([]Device, error) {
query := `SELECT namespace, name, device_id, enabled, mqtt_password, metadata, phase,
last_connected, created_at
FROM iot_devices WHERE namespace = $1 ORDER BY name`
args := []any{namespace}
if limit > 0 {
query += fmt.Sprintf(" LIMIT $%d", len(args)+1)
args = append(args, limit)
}
if offset > 0 {
query += fmt.Sprintf(" OFFSET $%d", len(args)+1)
args = append(args, offset)
}
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("store: list devices: %w", err)
}
defer rows.Close()
return scanDevices(rows)
}
// GetByName возвращает устройство по (namespace, name).
func (s *DeviceStore) GetByName(ctx context.Context, namespace, name string) (*Device, error) {
return s.get(ctx, `SELECT namespace, name, device_id, enabled, mqtt_password, metadata, phase,
last_connected, created_at
FROM iot_devices WHERE namespace = $1 AND name = $2`, namespace, name)
}
// GetByDeviceID возвращает устройство по (namespace, device_id) — для MQTT auth.
func (s *DeviceStore) GetByDeviceID(ctx context.Context, namespace, deviceID string) (*Device, error) {
return s.get(ctx, `SELECT namespace, name, device_id, enabled, mqtt_password, metadata, phase,
last_connected, created_at
FROM iot_devices WHERE namespace = $1 AND device_id = $2`, namespace, deviceID)
}
// get — общая выборка одного устройства.
func (s *DeviceStore) get(ctx context.Context, query string, args ...any) (*Device, error) {
d, err := scanDevice(s.db.QueryRowContext(ctx, query, args...))
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("store: get device: %w", err)
}
return d, nil
}
// UpdateEnabled включает/выключает устройство.
func (s *DeviceStore) UpdateEnabled(ctx context.Context, namespace, name string, enabled bool) error {
res, err := s.db.ExecContext(ctx,
`UPDATE iot_devices SET enabled = $3, phase = $4
WHERE namespace = $1 AND name = $2`,
namespace, name, enabled, phaseOf(enabled))
if err != nil {
return fmt.Errorf("store: update device: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// UpdateMetadata обновляет metadata устройства.
func (s *DeviceStore) UpdateMetadata(ctx context.Context, namespace, name string, metadata map[string]string) error {
b, err := json.Marshal(metadata)
if err != nil {
return fmt.Errorf("store: marshal metadata: %w", err)
}
res, err := s.db.ExecContext(ctx,
`UPDATE iot_devices SET metadata = $3 WHERE namespace = $1 AND name = $2`,
namespace, name, b)
if err != nil {
return fmt.Errorf("store: update metadata: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// Delete удаляет устройство.
func (s *DeviceStore) Delete(ctx context.Context, namespace, name string) error {
res, err := s.db.ExecContext(ctx,
`DELETE FROM iot_devices WHERE namespace = $1 AND name = $2`, namespace, name)
if err != nil {
return fmt.Errorf("store: delete device: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// TouchLastConnected обновляет last_connected (best-effort при MQTT auth).
func (s *DeviceStore) TouchLastConnected(ctx context.Context, namespace, deviceID string) {
_, _ = s.db.ExecContext(ctx, `
UPDATE iot_devices SET last_connected = now()
WHERE namespace = $1 AND device_id = $2`, namespace, deviceID)
}
// Count возвращает количество устройств в namespace (для админ-статистики).
func (s *DeviceStore) Count(ctx context.Context, namespace string) (int64, error) {
var n int64
err := s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM iot_devices WHERE namespace = $1`, namespace).Scan(&n)
if err != nil {
return 0, fmt.Errorf("store: count devices: %w", err)
}
return n, nil
}
// scanDevices читает все строки результата.
func scanDevices(rows *sql.Rows) ([]Device, error) {
var out []Device
for rows.Next() {
d, err := scanDevice(rows)
if err != nil {
return nil, err
}
out = append(out, *d)
}
return out, rows.Err()
}
// scanDevice читает одну строку устройства.
type rowScanner interface {
Scan(dest ...any) error
}
func scanDevice(r rowScanner) (*Device, error) {
var (
d Device
metadata []byte
lastConn sql.NullTime
)
err := r.Scan(&d.Namespace, &d.Name, &d.DeviceID, &d.Enabled, &d.MQTTPassword,
&metadata, &d.Phase, &lastConn, &d.CreatedAt)
if err != nil {
return nil, err
}
if len(metadata) > 0 {
_ = json.Unmarshal(metadata, &d.Metadata)
}
if lastConn.Valid {
t := lastConn.Time
d.LastConnected = &t
}
return &d, nil
}
// phaseOf — статус устройства в зависимости от enabled.
func phaseOf(enabled bool) string {
if enabled {
return "Active"
}
return "Disabled"
}
// isUniqueViolation определяет нарушение уникальности PostgreSQL.
func isUniqueViolation(err error) bool {
return err != nil && err.Error() != "" && hasSQLState(err, "23505")
}
func hasSQLState(err error, state string) bool {
type stater interface{ SQLState() string }
var e stater
return errors.As(err, &e) && e.SQLState() == state
}
-72
View File
@@ -1,72 +0,0 @@
package store
import (
"context"
"database/sql"
"fmt"
"os"
"strconv"
"time"
)
// schemaSQL — схема таблицы устройств.
// PRIMARY KEY (namespace, name) — как в REST-маршруте /devices/{name}.
// Уникальность device_id в рамках namespace — для MQTT username.
const schemaSQL = `
CREATE TABLE IF NOT EXISTS iot_devices (
namespace TEXT NOT NULL,
name TEXT NOT NULL,
device_id TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT true,
mqtt_password TEXT NOT NULL,
metadata JSONB,
phase TEXT NOT NULL DEFAULT 'Active',
last_connected TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (namespace, name)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_iot_devices_ns_device
ON iot_devices (namespace, device_id);
`
// DeviceStore — доступ к таблице iot_devices (собственный пул соединений).
type DeviceStore struct {
db *sql.DB
}
// Open подключается к PostgreSQL по DSN и инициализирует схему.
func Open(ctx context.Context, dsn string) (*DeviceStore, error) {
db, err := sql.Open("postgres", dsn)
if err != nil {
return nil, fmt.Errorf("store: open DB: %w", err)
}
// Пул устройств: дефолт 20 (ревью 2026-08-16: 5 мало для API+MQTT auth),
// переопределяется IOT_PG_MAX_CONNS.
maxConns := 20
if v := os.Getenv("IOT_PG_MAX_CONNS"); v != "" {
if n, convErr := strconv.Atoi(v); convErr == nil && n > 0 {
maxConns = n
}
}
db.SetMaxOpenConns(maxConns)
db.SetMaxIdleConns(2)
db.SetConnMaxLifetime(5 * time.Minute)
pingCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if err := db.PingContext(pingCtx); err != nil {
db.Close()
return nil, fmt.Errorf("store: ping DB: %w", err)
}
if _, err := db.ExecContext(ctx, schemaSQL); err != nil {
db.Close()
return nil, fmt.Errorf("store: init schema: %w", err)
}
return &DeviceStore{db: db}, nil
}
// Close закрывает пул соединений.
func (s *DeviceStore) Close() error {
return s.db.Close()
}
-91
View File
@@ -1,91 +0,0 @@
// dsn_test.go — тесты replaceDSNDatabase и safeNamespaceRe.
//
// Критичны для изоляции тенантов: неверная замена БД = телеметрия всех тенантов
// уходит в одну БД; небезопасный namespace = коллизия имени БД/username.
package iotpg
import (
"strings"
"testing"
)
func TestReplaceDSNDatabase(t *testing.T) {
cases := []struct {
name string
dsn string
newDB string
want string
wantErr bool
}{
{
name: "обычный URL-DSN",
dsn: "postgresql://u:p@h:5432/iot_platform?sslmode=require",
newDB: "tenant_abc",
want: "postgresql://u:p@h:5432/tenant_abc?sslmode=require",
},
{
name: "sslrootcert со слэшем в query не ломает замену",
dsn: "postgresql://u:p@h:5432/iot_platform?sslmode=require&sslrootcert=/etc/ssl/ca.pem",
newDB: "tenant_abc",
want: "postgresql://u:p@h:5432/tenant_abc?sslmode=require&sslrootcert=/etc/ssl/ca.pem",
},
{
name: "DSN без query",
dsn: "postgresql://u:p@h:5432/iot_platform",
newDB: "tenant_x",
want: "postgresql://u:p@h:5432/tenant_x",
},
{
name: "key=value DSN — ошибка (иначе смешение тенантов)",
dsn: "host=h user=u dbname=x sslmode=disable",
wantErr: true,
},
{
name: "URL без имени БД — ошибка",
dsn: "postgresql://u:p@h:5432",
wantErr: true,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := replaceDSNDatabase(c.dsn, c.newDB)
if c.wantErr {
if err == nil {
t.Fatalf("ожидалась ошибка, получено %q", got)
}
return
}
if err != nil {
t.Fatalf("неожиданная ошибка: %v", err)
}
if got != c.want {
t.Errorf("got %q, want %q", got, c.want)
}
})
}
}
func TestSafeNamespaceRe(t *testing.T) {
cases := []struct {
ns string
want bool
}{
{"myhome", true},
{"sless-abc123", true},
{"a", true},
{"tenant", true},
{"a_b", false}, // _
{"a+b", false}, // +
{"a#b", false}, // #
{"a/b", false}, // /
{"", false}, // пусто
{"-x", false}, // ведущий дефис
{strings.Repeat("a", 56), true},
{strings.Repeat("a", 57), false},
}
for _, c := range cases {
if got := safeNamespaceRe.MatchString(c.ns); got != c.want {
t.Errorf("safeNamespaceRe(%q) = %v, want %v", c.ns, got, c.want)
}
}
}
+61 -370
View File
@@ -24,7 +24,6 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"os" "os"
"regexp"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -38,48 +37,9 @@ type IoTPostgresStore struct {
adminDB *sql.DB adminDB *sql.DB
adminDSN string adminDSN string
tenants sync.Map tenants sync.Map
tenantMu sync.Map // namespace → *sync.Mutex (дедупликация открытия)
ensured sync.Map // namespace → struct{} (EnsureTenantDB уже выполнен)
log *slog.Logger log *slog.Logger
// батчинг вставок (фикс MEDIUM ревью 2026-08-16: 1000 msg/s = 1000 INSERT)
mu sync.Mutex
// flushMu сериализует фактическую запись (grab буфера + INSERT) между
// фоновым flusher'ом, Flush() из consumer и синхронной записью полного
// батча в InsertTelemetry. Без него Flush видел бы пустой буфер и возвращал
// nil, пока INSERT ещё в полёте → подтверждение SQS до записи (потеря).
flushMu sync.Mutex
batches map[string][]telemetryInsert
stopFlush chan struct{}
flushWG sync.WaitGroup
// closeOnce — Close идемпотентен: повторный вызов не паникует на close(stopFlush)
// (ревью 2026-10-10).
closeOnce sync.Once
} }
// telemetryInsert — строка для батч-вставки.
type telemetryInsert struct {
deviceID string
messageID string
ts time.Time
payload []byte
}
// telemetryBatchSize — размер батча перед синхронным flush.
const telemetryBatchSize = 100
// telemetryFlushInterval — период фонового flush неполных батчей.
const telemetryFlushInterval = 200 * time.Millisecond
// ErrInvalidNamespace — namespace небезопасен для имени БД tenant_<ns>/username.
var ErrInvalidNamespace = errors.New("invalid namespace")
// safeNamespaceRe — безопасный namespace: [a-zA-Z0-9-], не с дефиса, ≤56 символов
// (имя БД tenant_<ns> ≤ 63 байт PG). Совпадает с валидацией API (identRe).
// Defense-in-depth: основной барьер — валидация при создании устройства.
var safeNamespaceRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9-]{0,55}$`)
// TelemetryRow — одна запись телеметрии из таблицы iot_telemetry. // TelemetryRow — одна запись телеметрии из таблицы iot_telemetry.
type TelemetryRow struct { type TelemetryRow struct {
ID int64 `json:"id"` ID int64 `json:"id"`
@@ -100,164 +60,19 @@ func New(adminDSN string, log *slog.Logger) (*IoTPostgresStore, error) {
db.Close() db.Close()
return nil, fmt.Errorf("iotpg: ping admin DB: %w", err) return nil, fmt.Errorf("iotpg: ping admin DB: %w", err)
} }
db.SetMaxOpenConns(10) db.SetMaxOpenConns(5)
db.SetMaxIdleConns(2) db.SetMaxIdleConns(2)
db.SetConnMaxLifetime(5 * time.Minute) db.SetConnMaxLifetime(5 * time.Minute)
store := &IoTPostgresStore{ store := &IoTPostgresStore{adminDB: db, adminDSN: adminDSN, log: log}
adminDB: db,
adminDSN: adminDSN,
batches: make(map[string][]telemetryInsert),
stopFlush: make(chan struct{}),
log: log,
}
if err := store.initManagementSchema(ctx); err != nil { if err := store.initManagementSchema(ctx); err != nil {
db.Close() db.Close()
return nil, fmt.Errorf("iotpg: init management schema: %w", err) return nil, fmt.Errorf("iotpg: init management schema: %w", err)
} }
store.startFlusher()
log.Info("iotpg: connected to IoT Postgres management DB") log.Info("iotpg: connected to IoT Postgres management DB")
return store, nil return store, nil
} }
// startFlusher — фоновая горутина периодического flush неполных батчей.
func (s *IoTPostgresStore) startFlusher() {
s.flushWG.Add(1)
go func() {
defer s.flushWG.Done()
t := time.NewTicker(telemetryFlushInterval)
defer t.Stop()
for {
select {
case <-s.stopFlush:
return
case <-t.C:
s.flushAll(context.Background())
}
}
}()
}
// flushAll — flush всех накопленных батчей под переданным ctx.
// ВАЖНО: при ошибке записи тенанта строки НЕ теряются — возвращаются обратно
// в буфер и будут повторены. Иначе фоновый flusher мог бы молча потерять
// телеметрию, а consumer, увидев пустой буфер в Flush, подтвердил бы SQS-сообщение.
func (s *IoTPostgresStore) flushAll(ctx context.Context) error {
// flushMu: см. поле. Сериализует запись целиком, чтобы Flush() из consumer
// дождался in-flight INSERT фонового flusher'а и вернул РЕАЛЬНУЮ ошибку
// (или nil только если всё записано), а не «буфер пуст, пока идёт запись».
s.flushMu.Lock()
defer s.flushMu.Unlock()
s.mu.Lock()
if len(s.batches) == 0 {
s.mu.Unlock()
return nil
}
batches := s.batches
s.batches = make(map[string][]telemetryInsert)
s.mu.Unlock()
var firstErr error
for ns, rows := range batches {
if err := s.flushTenant(ctx, ns, rows); err != nil {
s.log.Error("iotpg: flush batch", "namespace", ns, "rows", len(rows), "err", err)
if isRetryableFlushErr(err) {
if firstErr == nil {
firstErr = err
}
// Сеть/PG временно недоступен — возвращаем строки в буфер, повторим.
s.mu.Lock()
s.batches[ns] = append(s.batches[ns], rows...)
s.mu.Unlock()
} else {
// Постоянная ошибка: валидные строки уже вставлены построчным
// fallback'ом (см. flushTenant), битые отброшены с логом. Ошибку
// НЕ возвращаем и строки в буфер НЕ возвращаем — данные обработаны,
// иначе consumer увидел бы ошибку и бесконечно гонял poison-батч
// через visibility timeout (ревью 🔴).
s.log.Error("iotpg: permanent flush error (валидные строки — построчно, битые отброшены)", "namespace", ns, "rows", len(rows), "err", err)
}
}
}
return firstErr
}
// Flush — принудительно синхронно пишет все накопленные батчи телеметрии в БД и
// возвращает первую ошибку. Нужен consumer'у, чтобы подтверждать (удалять из SQS)
// ТОЛЬКО фактически записанные данные: без этого краш/flush-ошибка после
// InsertTelemetry (которая лишь буферизует и возвращает nil) = потеря сообщений.
func (s *IoTPostgresStore) Flush(ctx context.Context) error {
return s.flushAll(ctx)
}
// flushTenant — INSERT батча строк одного тенанта (multi-VALUES).
func (s *IoTPostgresStore) flushTenant(ctx context.Context, ns string, rows []telemetryInsert) error {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
tenantDB, err := s.getTenantDB(ctx, ns)
if err != nil {
return err
}
// INSERT INTO iot_telemetry (device_id, payload, message_id, ts) VALUES ($1,$2,$3,$4),...
// ts — ВРЕМЯ ПРИЁМА (bridge ReceivedAt), а не момент INSERT: иначе батчинг и
// параллельные записи ломают порядок телеметрии (ORDER BY ts DESC).
// Zero → now() (fallback для legacy-сообщений без received_at).
// ON CONFLICT DO NOTHING — дедуп по (device_id, message_id) (P0-8).
var sb strings.Builder
sb.WriteString("INSERT INTO iot_telemetry (device_id, payload, message_id, ts) VALUES ")
args := make([]any, 0, len(rows)*4)
for i, r := range rows {
if i > 0 {
sb.WriteString(",")
}
sb.WriteString(fmt.Sprintf("($%d,$%d,$%d,$%d)", i*4+1, i*4+2, i*4+3, i*4+4))
var mid any
if r.messageID != "" {
mid = r.messageID
}
ts := r.ts
if ts.IsZero() {
ts = time.Now().UTC()
}
args = append(args, r.deviceID, r.payload, mid, ts)
}
sb.WriteString(" ON CONFLICT DO NOTHING")
_, err = tenantDB.ExecContext(ctx, sb.String(), args...)
if err != nil && !isRetryableFlushErr(err) {
// Построчный fallback: одна «ядовитая» строка (NULL/пустой payload,
// \u0000 в JSONB, битый JSON) роняет весь multi-VALUES батч (до
// telemetryBatchSize валидных строк). Вставляем по одной — валидные
// сохраняются, битая логируется, namespace не блокируется (ревью 🔴2).
s.flushRowsIndividually(ctx, tenantDB, ns, rows)
}
return err
}
// flushRowsIndividually — построчная вставка при ПОСТОЯННОЙ ошибке батча.
// Вызывается только из flushTenant, когда ошибка НЕ retryable (т.е. это данные,
// а не сеть/PG). Каждая строка вставляется отдельным INSERT ... ON CONFLICT DO
// NOTHING: валидные сохраняются, битая — логируется и пропускается (не роняет
// соседние и не блокирует namespace вечными ретраями).
func (s *IoTPostgresStore) flushRowsIndividually(ctx context.Context, tenantDB *sql.DB, ns string, rows []telemetryInsert) {
for _, r := range rows {
var mid any
if r.messageID != "" {
mid = r.messageID
}
ts := r.ts
if ts.IsZero() {
ts = time.Now().UTC()
}
_, err := tenantDB.ExecContext(ctx,
`INSERT INTO iot_telemetry (device_id, payload, message_id, ts) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING`,
r.deviceID, r.payload, mid, ts)
if err != nil {
s.log.Error("iotpg: poison row dropped", "namespace", ns, "device", r.deviceID, "err", err)
}
}
}
// NewFromEnv создаёт store из env var IOT_PG_DSN. // NewFromEnv создаёт store из env var IOT_PG_DSN.
// Возвращает (nil, nil) если переменная не задана — IoT Postgres опционален. // Возвращает (nil, nil) если переменная не задана — IoT Postgres опционален.
func NewFromEnv(log *slog.Logger) (*IoTPostgresStore, error) { func NewFromEnv(log *slog.Logger) (*IoTPostgresStore, error) {
@@ -282,16 +97,9 @@ created_at TIMESTAMPTZ DEFAULT now()
} }
// EnsureTenantDB создаёт DATABASE, USER и таблицу iot_telemetry для namespace. // EnsureTenantDB создаёт DATABASE, USER и таблицу iot_telemetry для namespace.
// Идемпотентен — повторный вызов безопасен (кэш ensured пропускает проверки). // Идемпотентен — повторный вызов безопасен.
// Вызывается mqtt-bridge при первом сообщении от нового tenant.
func (s *IoTPostgresStore) EnsureTenantDB(ctx context.Context, namespace string) error { func (s *IoTPostgresStore) EnsureTenantDB(ctx context.Context, namespace string) error {
// Guard (defense-in-depth): небезопасный namespace → постоянная ошибка.
if !safeNamespaceRe.MatchString(namespace) {
return fmt.Errorf("%w: %q", ErrInvalidNamespace, namespace)
}
if _, ok := s.ensured.Load(namespace); ok {
return nil
}
dbName := tenantDBName(namespace) dbName := tenantDBName(namespace)
userName := dbName userName := dbName
@@ -306,39 +114,21 @@ func (s *IoTPostgresStore) EnsureTenantDB(ctx context.Context, namespace string)
if !exists { if !exists {
password := uuid.New().String() password := uuid.New().String()
// Роль создаём только если её нет (DO-блоки НЕ принимают параметры — // CREATE USER через DO block — pg не поддерживает CREATE USER IF NOT EXISTS
// прецедент 2026-08-16: "got 2 parameters but the statement requires 0"). _, err = s.adminDB.ExecContext(ctx, fmt.Sprintf(
var roleExists bool `DO $$ BEGIN
err = s.adminDB.QueryRowContext(ctx, IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '%s') THEN
`SELECT EXISTS(SELECT 1 FROM pg_roles WHERE rolname = $1)`, userName, CREATE USER %s WITH PASSWORD '%s';
).Scan(&roleExists) END IF;
END $$`, userName, userName, password,
))
if err != nil { if err != nil {
return fmt.Errorf("iotpg: check role %s: %w", userName, err) return fmt.Errorf("iotpg: create user %s: %w", userName, err)
}
if !roleExists {
_, err = s.adminDB.ExecContext(ctx,
`CREATE USER `+pq.QuoteIdentifier(userName)+` WITH PASSWORD `+pq.QuoteLiteral(password),
)
if err != nil {
var pqErr *pq.Error
if errors.As(err, &pqErr) && pqErr.Code == "42710" { // duplicate_object
// гонка: роль создал параллельный вызов — ок
} else {
return fmt.Errorf("iotpg: create user %s: %w", userName, err)
}
}
}
// PG15+: GRANT role TO current_user перед CREATE DATABASE ... OWNER
if _, err = s.adminDB.ExecContext(ctx,
`GRANT `+pq.QuoteIdentifier(userName)+` TO CURRENT_USER`,
); err != nil {
return fmt.Errorf("iotpg: grant role %s: %w", userName, err)
} }
// CREATE DATABASE нельзя в транзакции // CREATE DATABASE нельзя в транзакции
if _, err = s.adminDB.ExecContext(ctx, if _, err = s.adminDB.ExecContext(ctx,
`CREATE DATABASE `+pq.QuoteIdentifier(dbName)+` OWNER `+pq.QuoteIdentifier(userName), fmt.Sprintf(`CREATE DATABASE %s OWNER %s`, dbName, userName),
); err != nil { ); err != nil {
return fmt.Errorf("iotpg: create database %s: %w", dbName, err) return fmt.Errorf("iotpg: create database %s: %w", dbName, err)
} }
@@ -360,108 +150,40 @@ func (s *IoTPostgresStore) EnsureTenantDB(ctx context.Context, namespace string)
} }
_, err = tenantDB.ExecContext(ctx, ` _, err = tenantDB.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS iot_telemetry ( CREATE TABLE IF NOT EXISTS iot_telemetry (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
device_id TEXT NOT NULL, device_id TEXT NOT NULL,
message_id TEXT, ts TIMESTAMPTZ NOT NULL DEFAULT now(),
ts TIMESTAMPTZ NOT NULL DEFAULT now(), payload JSONB NOT NULL
payload JSONB NOT NULL
); );
CREATE INDEX IF NOT EXISTS idx_iot_telemetry_device_ts CREATE INDEX IF NOT EXISTS idx_iot_telemetry_device_ts
ON iot_telemetry (device_id, ts DESC); ON iot_telemetry (device_id, ts DESC);
-- Миграция существующих таблиц + уникальный ключ дедупликации (P0-8).
ALTER TABLE iot_telemetry ADD COLUMN IF NOT EXISTS message_id TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS uniq_iot_telemetry_device_msg
ON iot_telemetry (device_id, message_id);
`) `)
return err
}
// InsertTelemetry записывает строку телеметрии в tenant DB.
func (s *IoTPostgresStore) InsertTelemetry(ctx context.Context, namespace, deviceID string, payload json.RawMessage) error {
tenantDB, err := s.getTenantDB(ctx, namespace)
if err != nil { if err != nil {
return err return fmt.Errorf("iotpg: get tenant DB for insert: %w", err)
} }
s.ensured.Store(namespace, struct{}{}) _, err = tenantDB.ExecContext(ctx,
return nil `INSERT INTO iot_telemetry (device_id, payload) VALUES ($1, $2)`,
} deviceID, []byte(payload),
)
// InsertTelemetry ставит строку в батч-буфер тенанта. return err
// При накоплении telemetryBatchSize строк батч пишется синхронно (ошибка
// возвращается вызывающему); неполные батчи дописывает фоновый flusher.
// messageID — уникальный id сообщения для дедупликации (может быть пустым →
// тогда строка не участвует в дедупе).
// receivedAt — время приёма сообщения (для колонки ts); zero → стор подставит now().
func (s *IoTPostgresStore) InsertTelemetry(ctx context.Context, namespace, deviceID, messageID string, receivedAt time.Time, payload json.RawMessage) error {
s.mu.Lock()
s.batches[namespace] = append(s.batches[namespace], telemetryInsert{
deviceID: deviceID,
messageID: messageID,
ts: receivedAt,
payload: append([]byte(nil), payload...),
})
full := len(s.batches[namespace]) >= telemetryBatchSize
var rows []telemetryInsert
if full {
rows = s.batches[namespace]
delete(s.batches, namespace)
}
s.mu.Unlock()
if full {
// Синхронная запись полного батча — под тем же flushMu, что и фоновый
// flusher и Flush: чтобы не писать параллельно (см. поле flushMu).
s.flushMu.Lock()
err := s.flushTenant(ctx, namespace, rows)
if err != nil {
if isRetryableFlushErr(err) {
// Не теряем строки: возвращаем в буфер (сеть/PG временно лежит).
s.mu.Lock()
s.batches[namespace] = append(s.batches[namespace], rows...)
s.mu.Unlock()
} else {
s.log.Error("iotpg: permanent error (валидные строки — построчно, битые отброшены)", "namespace", namespace, "rows", len(rows), "err", err)
}
}
s.flushMu.Unlock()
if err != nil {
return err
}
}
return nil
}
// isRetryableFlushErr — классифицирует ошибку INSERT: retryable (сеть, падение
// PG, таймаут) или permanent (неверные данные/нарушение constraint). Permanent
// НЕ должно возвращаться в буфер — иначе один «ядовитый» payload зациклит flush
// namespace навсегда.
func isRetryableFlushErr(err error) bool {
var pqErr *pq.Error
if errors.As(err, &pqErr) {
switch {
case strings.HasPrefix(string(pqErr.Code), "08"): // connection exception
return true
case pqErr.Code == "57P01" || pqErr.Code == "57P02" || pqErr.Code == "57P03": // admin shutdown / crash / cannot connect now
return true
case strings.HasPrefix(string(pqErr.Code), "22"): // data exception (invalid data)
return false
case strings.HasPrefix(string(pqErr.Code), "23"): // integrity constraint violation
return false
}
}
// Не-pq ошибка (например, context.DeadlineExceeded, net timeout) — считаем
// retryable: это среда, а не данные.
return true
} }
// QueryTelemetry читает телеметрию из tenant DB (ts DESC). // QueryTelemetry читает телеметрию из tenant DB (ts DESC).
// deviceID — фильтр (пустая строка = все устройства). limit — max записей // deviceID — фильтр (пустая строка = все устройства). limit — max записей (50..1000).
// (50..1000), offset — смещение для пагинации. // Если tenant DB не существует (данных ещё нет) — возвращает пустой срез без ошибки.
// Если tenant DB не существует (данных ещё нет) — пустой срез без ошибки. func (s *IoTPostgresStore) QueryTelemetry(ctx context.Context, namespace, deviceID string, limit int) ([]TelemetryRow, error) {
func (s *IoTPostgresStore) QueryTelemetry(ctx context.Context, namespace, deviceID string, limit, offset int) ([]TelemetryRow, error) {
if limit <= 0 { if limit <= 0 {
limit = 50 limit = 50
} }
if limit > 1000 { if limit > 1000 {
limit = 1000 limit = 1000
} }
if offset < 0 {
offset = 0
}
tenantDB, err := s.getTenantDB(ctx, namespace) tenantDB, err := s.getTenantDB(ctx, namespace)
if err != nil { if err != nil {
// Если DB не существует — тенант ещё не отправлял данные, это нормально // Если DB не существует — тенант ещё не отправлял данные, это нормально
@@ -475,14 +197,14 @@ func (s *IoTPostgresStore) QueryTelemetry(ctx context.Context, namespace, device
if deviceID != "" { if deviceID != "" {
rows, err = tenantDB.QueryContext(ctx, rows, err = tenantDB.QueryContext(ctx,
`SELECT id, device_id, ts, payload FROM iot_telemetry `SELECT id, device_id, ts, payload FROM iot_telemetry
WHERE device_id = $1 ORDER BY ts DESC, id DESC LIMIT $2 OFFSET $3`, WHERE device_id = $1 ORDER BY ts DESC LIMIT $2`,
deviceID, limit, offset, deviceID, limit,
) )
} else { } else {
rows, err = tenantDB.QueryContext(ctx, rows, err = tenantDB.QueryContext(ctx,
`SELECT id, device_id, ts, payload FROM iot_telemetry `SELECT id, device_id, ts, payload FROM iot_telemetry
ORDER BY ts DESC, id DESC LIMIT $1 OFFSET $2`, ORDER BY ts DESC LIMIT $1`,
limit, offset, limit,
) )
} }
if err != nil { if err != nil {
@@ -579,6 +301,7 @@ FROM iot_telemetry`).Scan(&stats.Total, &stats.Last1h, &stats.Last24h)
latestRows, err := tenantDB.QueryContext(ctx, latestRows, err := tenantDB.QueryContext(ctx,
`SELECT id, device_id, ts, payload FROM iot_telemetry ORDER BY ts DESC LIMIT 5`) `SELECT id, device_id, ts, payload FROM iot_telemetry ORDER BY ts DESC LIMIT 5`)
if err == nil { if err == nil {
defer latestRows.Close()
for latestRows.Next() { for latestRows.Next() {
var r TelemetryRow var r TelemetryRow
var rawPayload []byte var rawPayload []byte
@@ -587,7 +310,6 @@ FROM iot_telemetry`).Scan(&stats.Total, &stats.Last1h, &stats.Last24h)
stats.Latest = append(stats.Latest, r) stats.Latest = append(stats.Latest, r)
} }
} }
latestRows.Close() // закрываем сразу, не defer в цикле
} }
result.Tenants = append(result.Tenants, stats) result.Tenants = append(result.Tenants, stats)
@@ -607,50 +329,23 @@ func isDBNotExistErr(err error) bool {
return false return false
} }
// Close останавливает flusher, дописывает остаток и закрывает все подключения. // Close закрывает все подключения (admin + tenant кэш).
// Идемпотентен (closeOnce): повторный вызов не паникует на close(stopFlush).
func (s *IoTPostgresStore) Close() error { func (s *IoTPostgresStore) Close() error {
var err error s.tenants.Range(func(_, value any) bool {
s.closeOnce.Do(func() { if db, ok := value.(*sql.DB); ok {
close(s.stopFlush) db.Close()
s.flushWG.Wait() }
_ = s.flushAll(context.Background()) return true
s.tenants.Range(func(_, value any) bool {
if db, ok := value.(*sql.DB); ok {
db.Close()
}
return true
})
err = s.adminDB.Close()
}) })
return err return s.adminDB.Close()
} }
// getTenantDB возвращает *sql.DB для tenant DB из кэша или открывает новый. // getTenantDB возвращает *sql.DB для tenant DB из кэша или открывает новый.
// Открытие дедуплицируется per-namespace мьютексом (гонка Load→LoadOrStore
// из ревью 2026-08-16).
func (s *IoTPostgresStore) getTenantDB(ctx context.Context, namespace string) (*sql.DB, error) { func (s *IoTPostgresStore) getTenantDB(ctx context.Context, namespace string) (*sql.DB, error) {
// Валидация namespace на ВСЕХ путях (чтение/запись) — defense-in-depth.
// Основной барьер — валидация при создании устройства, но JWT с sub,
// прошедшим namespace==sub в middleware, не должен читать чужую БД через
// подмену дефисов (a_b ↔ a-b дают одно имя tenant_a_b) — см. tenantDBName.
if !safeNamespaceRe.MatchString(namespace) {
return nil, fmt.Errorf("%w: %q", ErrInvalidNamespace, namespace)
}
if cached, ok := s.tenants.Load(namespace); ok { if cached, ok := s.tenants.Load(namespace); ok {
return cached.(*sql.DB), nil return cached.(*sql.DB), nil
} }
m, _ := s.tenantMu.LoadOrStore(namespace, &sync.Mutex{}) dsn := replaceDSNDatabase(s.adminDSN, tenantDBName(namespace))
mu := m.(*sync.Mutex)
mu.Lock()
defer mu.Unlock()
if cached, ok := s.tenants.Load(namespace); ok {
return cached.(*sql.DB), nil
}
dsn, err := replaceDSNDatabase(s.adminDSN, tenantDBName(namespace))
if err != nil {
return nil, err
}
db, err := sql.Open("postgres", dsn) db, err := sql.Open("postgres", dsn)
if err != nil { if err != nil {
return nil, fmt.Errorf("iotpg: open tenant DB %s: %w", tenantDBName(namespace), err) return nil, fmt.Errorf("iotpg: open tenant DB %s: %w", tenantDBName(namespace), err)
@@ -673,30 +368,26 @@ func (s *IoTPostgresStore) getTenantDB(ctx context.Context, namespace string) (*
return db, nil return db, nil
} }
// replaceDSNDatabase заменяет имя базы данных в URL-DSN. // replaceDSNDatabase заменяет имя базы данных в DSN.
// Вход: "postgresql://user:pass@host:5432/iot_platform?sslmode=require" // Вход: "postgresql://user:pass@host:5432/iot_platform?sslmode=disable"
// Выход: "postgresql://user:pass@host:5432/tenant_abc?sslmode=require" // Выход: "postgresql://user:pass@host:5432/tenant_abc?sslmode=disable"
// func replaceDSNDatabase(dsn, newDBName string) string {
// Возвращает ОШИБКУ для не-URL (key=value) DSN: иначе функция молча вернула бы
// исходный DSN, и телеметрия ВСЕХ тенантов ушла бы в management-БД — смешение
// тенантов (ревью 2026-10-10). Query отделяется ПЕРВЫМ '?', поэтому '/' внутри
// query (напр. sslrootcert=/path) не ломает поиск имени БД.
func replaceDSNDatabase(dsn, newDBName string) (string, error) {
schemeEnd := strings.Index(dsn, "://") schemeEnd := strings.Index(dsn, "://")
if schemeEnd < 0 { if schemeEnd < 0 {
return "", fmt.Errorf("iotpg: replaceDSNDatabase: неподдерживаемый DSN (нет «://»); нужен URL вида postgresql://user:pass@host:port/db") return dsn
} }
rest := dsn[schemeEnd+3:] hostPart := dsn[schemeEnd+3:]
query := "" slashIdx := strings.LastIndex(hostPart, "/")
if q := strings.Index(rest, "?"); q >= 0 { if slashIdx < 0 {
query = rest[q:] return dsn
rest = rest[:q]
} }
slash := strings.LastIndex(rest, "/") afterSlash := hostPart[slashIdx+1:]
if slash < 0 { suffix := ""
return "", fmt.Errorf("iotpg: replaceDSNDatabase: в DSN нет имени базы данных") if qIdx := strings.Index(afterSlash, "?"); qIdx >= 0 {
suffix = afterSlash[qIdx:]
} }
return dsn[:schemeEnd+3] + rest[:slash+1] + newDBName + query, nil prefix := dsn[:schemeEnd+3+slashIdx+1]
return prefix + newDBName + suffix
} }
// tenantDBName возвращает имя Postgres DATABASE для namespace. // tenantDBName возвращает имя Postgres DATABASE для namespace.
-224
View File
@@ -1,224 +0,0 @@
> ⛔⛔⛔ ЛЕГАСИ (2026-08-16) — СТАРЫЙ план миграции (Node.js). ОТМЕНЁН. НЕ ПРИНИМАТЬ ВО ВНИМАНИЕ. Актуальное: HISTORY/2026-08-16-session-log.md
# Миграция IoT из Kubernetes → Managed Services (Nubes)
## Сессия 2026-08-13
---
## 1. Что изучили — текущая архитектура
### Стек
- **Язык:** Go 1.25
- **Паттерн:** Kubernetes Operator (controller-runtime)
- **Модуль:** `gitea.services.ngcloud.ru/Nail/IoT`
- **Docker Hub:** `naeel/iot-operator:v0.2.6`
### Три бинарника в одном образе
| Бинарник | Роль |
|---|---|
| `cmd/iot-operator` | Controller-manager (CRD IoTDevice) + REST API :9090 |
| `cmd/mqtt-bridge` | MQTT (EMQX) → shared-SQS (AWS SDK v2) |
| `cmd/sqs-consumer` | shared-SQS → per-tenant Postgres |
### Поток данных (текущий)
```
IoT Device (MQTT CONNECT, username="{ns}_{deviceId}")
↓
EMQX 5.5.1 (HTTP auth/acl → iot-operator:9090/internal/mqtt/auth)
↓ MQTT PUBLISH "{ns}/telemetry/{deviceId}"
iot-mqtt-bridge (подписка "+/telemetry/+")
↓ AWS SDK SQS SendMessage
shared-SQS (namespace shared-sqs, очередь "iot-telemetry")
↓ long polling (WaitTimeSeconds=20)
iot-sqs-consumer (at-least-once, DeleteMessage после успешной записи)
↓
Managed PostgreSQL 17 (per-tenant DB: tenant_{namespace})
↓
REST API (iot-operator:9090) → Пользователь (UI/Terraform)
```
### Ключевые компоненты k8s
- **CRD IoTDevice** (`iot.kube5s.ru/v1alpha1`) — регистрация устройств
- **k8s Secret** `iot-{deviceId}` — хранит MQTT пароль (64 hex, crypto/rand)
- **OwnerReference** — каскадное удаление Secret при удалении IoTDevice
- **RBAC** — ClusterRole для чтения/записи IoTDevice + Secrets
- **Namespace = ID тенанта** — изоляция устройств и данных
### Мультитенантность
- MQTT topic изоляция: `{ns}/telemetry/{deviceId}` — ACL на уровне EMQX
- Per-tenant Postgres DB: `tenant_{namespace}` (дефисы → подчёркивания)
- MQTT username: `{namespace}_{deviceId}` — глобально уникален
- `sync.Map` кэш `*sql.DB` per tenant — lazy init
### Наблюдения / потенциальные проблемы
1. **`authTestMode = true`** в `middleware/auth.go` — тестовый режим включён в проде, JWT подпись не проверяется
2. **`ADMIN_STATS_TOKEN = "iot-admin-2026"`** захардкожен в YAML манифесте вместо Secret
3. Устаревшие файлы: `deployments/k8s/kafka.yaml`, `iot-kafka-consumer.yaml` (Kafka удалена, манифесты остались)
4. Bridge clientID `"sless-iot-bridge"` захардкожен и в bridge коде и в ACL логике
5. `examples/main.tf` и `handler.py` описывают устаревший поток (RabbitMQ/serverless)
---
## 2. Почему хотим уйти из k8s
- Цель: использовать только **managed services** облака Nubes
- Nubes предоставляет: **Managed Node.js, Flask, Lucee, PostgreSQL**
- Никаких VPS, никакого k8s — только managed-платформа
- SQS тоже **наш собственный сервис** (не сторонний облачный), работает сейчас в k8s, тоже надо вынести
---
## 3. Принятые решения
### Node.js — выбранный стек
| Задача | Node.js |
|---|---|
| REST API | Express/Fastify |
| MQTT-брокер | `aedes` (embedded, over WebSocket) |
| MQTT-клиент для publish | `mqtt` npm |
| SQS клиент | `@aws-sdk/client-sqs` |
| Postgres | `pg` npm |
**Почему не Flask:** сложнее держать persistent MQTT и SQS polling в фоне
**Почему не Lucee:** не подходит для long-running background workers
**Почему Node.js — монолит:** bridge и consumer нельзя масштабировать независимо (один MQTT-клиент = одна подписка), смысла разделять нет
### EMQX → aedes (embedded в Node.js)
- Managed Node.js открывает только HTTP/HTTPS порты
- MQTT over WebSocket = HTTP upgrade → работает на любой managed-платформе
- Устройства подключаются через `wss://` (уже сейчас так, через ingress emqx-ws-ingress.yaml)
- **aedes** — полноценный MQTT-брокер на Node.js, встраивается в Express HTTP-сервер
- Auth/ACL становится обычной функцией внутри того же процесса (быстрее, проще)
### Мультитенантность сохраняется полностью
- `namespace` — просто строка в таблице `iot_devices` вместо k8s namespace
- Per-tenant Postgres DB остаётся (чистый SQL, без k8s)
- MQTT topic изоляция остаётся (`{ns}/telemetry/{deviceId}`)
- ACL по топику остаётся — просто функция вместо HTTP endpoint
### k8s CRD/Secret → таблица в Postgres
```sql
CREATE TABLE iot_devices (
namespace TEXT NOT NULL,
name TEXT NOT NULL,
device_id TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT true,
mqtt_password TEXT NOT NULL, -- было в k8s Secret
metadata JSONB,
phase TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (namespace, device_id)
);
```
---
## 4. Итоговая архитектура на Nubes Managed
```
IoT Device (wss://iot.example.ru/mqtt)
↓
Managed Node.js — IoT сервис
├── aedes MQTT-брокер (over WebSocket, порт :3000/mqtt)
│ auth/acl → функция → таблица iot_devices в PG
│ on publish → SQS SendMessage
├── SQS consumer (long polling, фоновый setInterval/async loop)
│ → per-tenant Postgres (tenant_{namespace})
└── REST API (Express)
GET/POST /v1/namespaces/{ns}/iot/devices
GET /v1/namespaces/{ns}/iot/telemetry
POST /internal/mqtt/auth (совместимость, опционально)
GET /console (embedded HTML)
GET /iot-admin/stats
↓
Managed Node.js — shared-SQS сервис ← другие сервисы тоже
↓
Managed PostgreSQL — IoT данные
```
### Два managed Node.js сервиса
| Сервис | Назначение |
|---|---|
| **shared-SQS** | AWS SQS-совместимая очередь, multi-tenant, HTTP API |
| **iot-service** | aedes MQTT + REST API + SQS consumer + PG |
---
## 5. Порядок миграции
### ⚠️ Сначала shared-SQS, потом IoT
**Причина:** IoT зависит от SQS. SQS независим — мигрирует первым.
```
Шаг 1: Вынести shared-SQS из k8s → Managed Node.js на Nubes
Шаг 2: Переписать IoT сервис на Node.js (aedes + REST + consumer)
Шаг 3: Деплой IoT на Managed Node.js на Nubes
Шаг 4: Отключить k8s деплой IoT
```
---
## 6. Детали для нового чата — shared-SQS миграция
### Что сейчас
- SQS сервис живёт в `namespace: shared-sqs` в кластере `iot-naeel`
- Endpoint: `https://qu.kube5s.ru`
- Multi-tenant: tenant `iot-service` (id: `t-96afe7e9f781f6ca`), очередь `iot-telemetry`
- IoT использует: `SQS_ENDPOINT=https://qu.kube5s.ru`, `SQS_ACCESS_KEY`, `SQS_SECRET_KEY`
- Протокол: AWS SQS-совместимый (SendMessage, ReceiveMessage, DeleteMessage, GetQueueUrl, GetQueueAttributes)
### Что нужно от нового SQS сервиса
- AWS SQS-совместимый HTTP API (те же методы что сейчас)
- Multi-tenant (разные access key / secret key для разных тенантов)
- Очереди создаются по имени (`GetQueueUrl` + `CreateQueue`)
- Long polling: `ReceiveMessage` с `WaitTimeSeconds` до 20
- Хранение сообщений: in-memory или Postgres/Redis
### Клиенты SQS в IoT коде
1. **mqtt-bridge** (`cmd/mqtt-bridge/main.go`): `SendMessage` при каждом MQTT сообщении
2. **sqs-consumer** (`cmd/sqs-consumer/main.go`): `ReceiveMessage` (polling) + `DeleteMessage`
3. **iot-admin-stats** (`internal/api/handler/iot_admin_stats_handler.go`): `GetQueueAttributes` для мониторинга
### Env vars для IoT → SQS
```
SQS_ENDPOINT=https://qu.kube5s.ru (поменяется на новый managed URL)
SQS_ACCESS_KEY=...
SQS_SECRET_KEY=...
SQS_QUEUE_NAME=iot-telemetry (default)
SQS_REGION=us-east-1 (default, не важен для self-hosted)
```
---
## 7. Что переписывается в IoT (Node.js)
### Соответствие Go → Node.js
| Go файл | Node.js файл |
|---|---|
| `cmd/iot-operator/main.go` | `src/index.js` (точка входа) |
| `internal/api/router.go` | `src/routes.js` |
| `internal/api/handler/iot_device_handler.go` | `src/handlers/devices.js` |
| `internal/api/handler/iot_telemetry_handler.go` | `src/handlers/telemetry.js` |
| `internal/api/handler/iot_admin_stats_handler.go` | `src/handlers/admin.js` |
| `internal/api/middleware/auth.go` | `src/middleware/auth.js` |
| `internal/storage/iotpg/iot_telemetry_store.go` | `src/storage/pg.js` |
| `cmd/mqtt-bridge/main.go` | встроен в `src/mqtt.js` (aedes) |
| `cmd/sqs-consumer/main.go` | встроен в `src/sqsWorker.js` |
| `controllers/iotdevice_controller.go` | **не нужен** (заменён CRUD в БД) |
| `api/v1alpha1/device_types.go` | **не нужен** (таблица iot_devices) |
### npm зависимости
```json
{
"dependencies": {
"express": "^4",
"aedes": "^0.51",
"websocket-stream": "^5",
"@aws-sdk/client-sqs": "^3",
"pg": "^8",
"uuid": "^9"
}
}
```
-43
View File
@@ -1,43 +0,0 @@
# ⛔⛔⛔ ЛЕГАСИ (2026-08-16) — СТАРЫЙ IoT (3 бинарника, образ naeel/iot-operator). НЕ ИСПОЛЬЗОВАТЬ.
# Новый монолит получит собственный Dockerfile. Актуальное: HISTORY/2026-08-16-session-log.md
# Создано: 2026-04-12
# Изменено: 2026-04-12 (kafka-consumer → sqs-consumer)
# Dockerfile для IoT managed service.
# Multi-stage build: 3 бинарника (iot-operator, mqtt-bridge, sqs-consumer).
# Образ: naeel/iot-operator (Docker Hub).
FROM golang:1.25 AS builder
WORKDIR /workspace
# Кэширование зависимостей
COPY go.mod go.sum ./
RUN go mod download
# Исходный код
COPY api/ api/
COPY controllers/ controllers/
COPY cmd/ cmd/
COPY internal/ internal/
# iot-operator — controller-manager + REST API
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o iot-operator ./cmd/iot-operator/
# mqtt-bridge — MQTT (EMQX) → shared-SQS
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o mqtt-bridge ./cmd/mqtt-bridge/
# sqs-consumer — shared-SQS → Postgres
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o sqs-consumer ./cmd/sqs-consumer/
# --- Runtime ---
FROM gcr.io/distroless/static:nonroot
WORKDIR /
# Все три бинарника в одном образе.
# Какой запускать — определяется command в k8s Deployment.
COPY --from=builder /workspace/iot-operator .
COPY --from=builder /workspace/mqtt-bridge .
COPY --from=builder /workspace/sqs-consumer .
USER 65532:65532
ENTRYPOINT ["/iot-operator"]

Some files were not shown because too many files have changed in this diff Show More