Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f082bd3bf9 | ||
|
|
b85c67b75a | ||
|
|
3c94871231 | ||
|
|
a9241c24dc |
@@ -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
@@ -1,23 +1,35 @@
|
||||
# Правила работы агента
|
||||
|
||||
## Файловая система
|
||||
## ⛔⛔⛔ DOCKER — ОБЯЗАТЕЛЬНЫЙ ПОРЯДОК ПЕРЕД КАЖДЫМ BUILD
|
||||
|
||||
`~/remote_dev/` (локально) примонтирован через sshfs к `~/terra/` на ВМ — **одна ФС**.
|
||||
Файлы, сохранённые локально, мгновенно видны на ВМ. SCP не нужен.
|
||||
1. УВЕЛИЧИТЬ ТЕГ в `deployments/k8s/iot-console.yaml` (vX.Y.Z → vX.Y.Z+1)
|
||||
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
|
||||
## Файловая система (актуально)
|
||||
|
||||
# Примонтировать
|
||||
sshfs naeel@5.172.178.213:/home/naeel/terra ~/remote_dev \
|
||||
-o cache=no -o no_readahead -o reconnect \
|
||||
-o ServerAliveInterval=15 -o ServerAliveCountMax=3 \
|
||||
-o IdentityFile=~/.ssh/naeel_vm_id_ed25519
|
||||
```
|
||||
1. Все файлы редактируются локально: `~/IoT`
|
||||
2. После любых изменений — обязательно rsync на ВМ:
|
||||
rsync -az \
|
||||
-e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \
|
||||
~/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
|
||||
|
||||
@@ -37,7 +49,11 @@ ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=
|
||||
|
||||
## 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`
|
||||
- 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
|
||||
```
|
||||
|
||||
## Поведение агента
|
||||
## ⛔ ТЕРМИНАЛЬНЫЙ БУФЕР — НИКОГДА НЕ ЧИТАТЬ СТАРЫЙ
|
||||
|
||||
- Не трогать рабочий код без явного указания
|
||||
- Не делать ничего сверх того, о чём явно попросили
|
||||
- Деструктивные операции (`kubectl delete`, `rm -rf`, `terraform destroy` и др.) — только после явного подтверждения с указанием конкретных объектов
|
||||
- Отвечать кратко, без вступлений, извинений, благодарностей и прочей воды
|
||||
**АБСОЛЮТНОЕ ПРАВИЛО:**
|
||||
- get_terminal_output из старых сессий — МУСОР. Там старые прогоны.
|
||||
- Всегда запускать новую команду через 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
@@ -1,37 +1,13 @@
|
||||
# Бинарники (только в корне проекта, не директории cmd/)
|
||||
# Бинарники
|
||||
iot-operator
|
||||
mqtt-bridge
|
||||
kafka-consumer
|
||||
bin/
|
||||
ws-probe/ws-probe
|
||||
/iot-service
|
||||
|
||||
# Go
|
||||
*.exe
|
||||
*.test
|
||||
*.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
|
||||
.idea/
|
||||
@@ -43,14 +19,6 @@ logs/
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Прочие генерируемые/временные
|
||||
*.tmp
|
||||
*.temp
|
||||
*.bak
|
||||
*.orig
|
||||
*.swp
|
||||
.DS_Store
|
||||
/HISTORY/2026-08-16-session-log.md
|
||||
/deploy/terraform/terraform.tfvars.example
|
||||
/deployments/k8s/iot-postgres.yaml
|
||||
|
||||
+24
-23
@@ -1,38 +1,39 @@
|
||||
# Dockerfile — монолит iot-service (один бинарник, один контейнер).
|
||||
# Образ: gitea.services.ngcloud.ru/nail/iot-service (Gitea registry платформы).
|
||||
#
|
||||
# Env-дефолты (не секреты) вшиты сюда — deck-UI задаёт только секреты.
|
||||
# Создано: 2026-04-12
|
||||
# Dockerfile для IoT managed service.
|
||||
# Multi-stage build: 3 бинарника (iot-operator, mqtt-bridge, kafka-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/
|
||||
|
||||
ARG VERSION=dev
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a \
|
||||
-ldflags "-X main.version=${VERSION}" -o iot-service ./cmd/iot-service/
|
||||
# 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) → 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
|
||||
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
|
||||
EXPOSE 9090
|
||||
|
||||
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"]
|
||||
ENTRYPOINT ["/iot-operator"]
|
||||
|
||||
@@ -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 ./...` — зелёные на каждом шаге.
|
||||
@@ -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/).
|
||||
- Не задавать вопросов, ответ на которые есть в коде/доках.
|
||||
- «Рекомендация без замера — не рекомендация».
|
||||
@@ -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`). Применение — за владельцем.
|
||||
@@ -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` (обход дедупа).
|
||||
@@ -1,29 +1,40 @@
|
||||
# Makefile — монолит iot-service (образ gitea.services.ngcloud.ru/nail/iot-service).
|
||||
# Registry: Gitea (gitea.services.ngcloud.ru) — managed-платформа тянет образы отсюда.
|
||||
# Старые k8s-цели — в legacy/Makefile.old.
|
||||
# Создано: 2026-04-12
|
||||
# Makefile для IoT managed service.
|
||||
|
||||
REGISTRY ?= gitea.services.ngcloud.ru/nail
|
||||
VERSION ?= v0.1.13
|
||||
IMAGE ?= $(REGISTRY)/iot-service
|
||||
LDFLAGS = -X main.version=$(VERSION)
|
||||
IMG ?= naeel/iot-operator:latest
|
||||
|
||||
.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:
|
||||
go vet ./...
|
||||
build-operator:
|
||||
CGO_ENABLED=0 go build -o bin/iot-operator ./cmd/iot-operator/
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
build-bridge:
|
||||
CGO_ENABLED=0 go build -o bin/mqtt-bridge ./cmd/mqtt-bridge/
|
||||
|
||||
tidy:
|
||||
go mod tidy
|
||||
build-consumer:
|
||||
CGO_ENABLED=0 go build -o bin/kafka-consumer ./cmd/kafka-consumer/
|
||||
|
||||
# Docker
|
||||
docker-build:
|
||||
docker build --build-arg VERSION=$(VERSION) -t $(IMAGE):$(VERSION) -t $(IMAGE):latest .
|
||||
docker build -t $(IMG) .
|
||||
|
||||
docker-push:
|
||||
docker push $(IMAGE):$(VERSION)
|
||||
docker push $(IMAGE):latest
|
||||
docker push $(IMG)
|
||||
|
||||
# Тесты и go mod
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
tidy:
|
||||
go mod tidy
|
||||
|
||||
# Применить CRD в кластер
|
||||
install-crd:
|
||||
kubectl apply -f config/crd/bases/
|
||||
|
||||
# Деплой всех компонентов
|
||||
deploy:
|
||||
kubectl apply -f deployments/k8s/
|
||||
|
||||
@@ -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 цепочки
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
-3
@@ -1,6 +1,3 @@
|
||||
# ⛔⛔⛔ ЛЕГАСИ (2026-08-16) — СТАРЫЙ IoT (CRD/k8s-деплой). НЕ ПРИНИМАТЬ ВО ВНИМАНИЕ.
|
||||
# Актуальное: HISTORY/2026-08-16-session-log.md
|
||||
|
||||
---
|
||||
apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
# Terraform state и секреты — НЕ коммитить (.terraform.lock.hcl — коммитим)
|
||||
.terraform/
|
||||
*.tfstate
|
||||
*.tfstate.*
|
||||
terraform.tfvars
|
||||
*.tfvars
|
||||
crash.log
|
||||
Generated
-13
@@ -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",
|
||||
]
|
||||
}
|
||||
@@ -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` — НЕ коммитить.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 для клиентов/консоли"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 = ""
|
||||
}
|
||||
-3
@@ -1,6 +1,3 @@
|
||||
# ⛔⛔⛔ ЛЕГАСИ (2026-08-16) — СТАРЫЙ IoT (k8s-деплой, Kafka). НЕ ПРИНИМАТЬ ВО ВНИМАНИЕ.
|
||||
# Актуальное: HISTORY/2026-08-16-session-log.md
|
||||
|
||||
# Создано: 2026-04-06
|
||||
# Deployment iot-kafka-consumer — читает IoT телеметрию из Kafka → пишет в IoT Postgres.
|
||||
#
|
||||
@@ -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)
|
||||
# kafka.yaml — минимальный деплой Apache Kafka в KRaft mode (без Zookeeper).
|
||||
# Образ: apache/kafka (официальный, бесплатный).
|
||||
@@ -1,5 +1,3 @@
|
||||
> ⛔⛔⛔ ЛЕГАСИ (2026-08-16) — СТАРЫЙ IoT (k8s-деплой). НЕ ПРИНИМАТЬ ВО ВНИМАНИЕ. Актуальное: HISTORY/2026-08-16-session-log.md
|
||||
|
||||
# IoT API Endpoints
|
||||
|
||||
## Публичные (без auth)
|
||||
@@ -32,25 +30,3 @@
|
||||
| Метод | Путь | Описание |
|
||||
|-------|------|----------|
|
||||
| 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` — количество сообщений в очереди, ожидающих обработки.
|
||||
@@ -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"]`
|
||||
-2
@@ -1,5 +1,3 @@
|
||||
> ⛔⛔⛔ ЛЕГАСИ (2026-08-16) — СТАРЫЙ IoT (k8s-деплой). НЕ ПРИНИМАТЬ ВО ВНИМАНИЕ. Актуальное: HISTORY/2026-08-16-session-log.md
|
||||
|
||||
# Решение: Вынос IoT в отдельную репу
|
||||
|
||||
**Дата:** 2026-04-12
|
||||
@@ -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 — Инженерная документация деплоя
|
||||
|
||||
> Создано: 2026-04-04
|
||||
@@ -306,101 +304,3 @@ node {
|
||||
|--------|------|-----------|
|
||||
| v0.1.50 | 2026-04-04 | IoT controller + IoT API + iot-mqtt-bridge бинарь |
|
||||
| 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) |
|
||||
@@ -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
|
||||
|
||||
> **Автор плана**: GitHub Copilot (Claude Opus 4.6)
|
||||
@@ -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`.
|
||||
@@ -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 (опционально, после подтверждения что всё работает)
|
||||
@@ -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 блокеров нет.
|
||||
@@ -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 телеметрии — приемлемо.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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, ближе к
|
||||
«функционально готов, эксплуатационно — нет».
|
||||
@@ -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 (гигиена).
|
||||
@@ -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"]
|
||||
@@ -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
@@ -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
|
||||
|
||||
## Что делает этот пример
|
||||
@@ -1,4 +1,3 @@
|
||||
# ⛔⛔⛔ ЛЕГАСИ (2026-08-16) — СТАРЫЙ IoT (RabbitMQ/serverless). НЕ ПРИНИМАТЬ ВО ВНИМАНИЕ. Актуальное: HISTORY/2026-08-16-session-log.md
|
||||
"""
|
||||
Создано: 2026-04-04
|
||||
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
|
||||
# E2E Demo: IoT Device → MQTT → RabbitMQ → Serverless Function
|
||||
#
|
||||
@@ -3,21 +3,74 @@ module gitea.services.ngcloud.ru/Nail/IoT
|
||||
go 1.25
|
||||
|
||||
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/google/uuid v1.6.0
|
||||
github.com/gorilla/mux v1.8.1
|
||||
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 (
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
|
||||
github.com/aws/smithy-go v1.24.2 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.1.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/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/oauth2 v0.0.0-20220223155221-ee480838109b // 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
|
||||
)
|
||||
|
||||
@@ -1,28 +1,640 @@
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
|
||||
github.com/aws/aws-sdk-go-v2/service/sqs v1.42.25 h1:8Bv3TQ1Cob6HLlpUbAnWxeHhAkYScJO9RIHh2WPXaxw=
|
||||
github.com/aws/aws-sdk-go-v2/service/sqs v1.42.25/go.mod h1:eDstEbM0OEnBUnNQxIA7j74Jy61cCU1S4EMlCtdMwzs=
|
||||
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
|
||||
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
|
||||
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
|
||||
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
|
||||
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
|
||||
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
|
||||
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
|
||||
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
|
||||
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
|
||||
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
|
||||
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/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/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/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
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/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/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
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/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/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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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()}
|
||||
// }
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
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
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"}`))
|
||||
}
|
||||
}
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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
@@ -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
|
||||
<токен></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", "<пароль из шага 2>")</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 |
@@ -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 |
@@ -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
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,6 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -38,48 +37,9 @@ type IoTPostgresStore struct {
|
||||
adminDB *sql.DB
|
||||
adminDSN string
|
||||
tenants sync.Map
|
||||
tenantMu sync.Map // namespace → *sync.Mutex (дедупликация открытия)
|
||||
ensured sync.Map // namespace → struct{} (EnsureTenantDB уже выполнен)
|
||||
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.
|
||||
type TelemetryRow struct {
|
||||
ID int64 `json:"id"`
|
||||
@@ -100,164 +60,19 @@ func New(adminDSN string, log *slog.Logger) (*IoTPostgresStore, error) {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("iotpg: ping admin DB: %w", err)
|
||||
}
|
||||
db.SetMaxOpenConns(10)
|
||||
db.SetMaxOpenConns(5)
|
||||
db.SetMaxIdleConns(2)
|
||||
db.SetConnMaxLifetime(5 * time.Minute)
|
||||
|
||||
store := &IoTPostgresStore{
|
||||
adminDB: db,
|
||||
adminDSN: adminDSN,
|
||||
batches: make(map[string][]telemetryInsert),
|
||||
stopFlush: make(chan struct{}),
|
||||
log: log,
|
||||
}
|
||||
store := &IoTPostgresStore{adminDB: db, adminDSN: adminDSN, log: log}
|
||||
if err := store.initManagementSchema(ctx); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("iotpg: init management schema: %w", err)
|
||||
}
|
||||
store.startFlusher()
|
||||
log.Info("iotpg: connected to IoT Postgres management DB")
|
||||
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.
|
||||
// Возвращает (nil, nil) если переменная не задана — IoT Postgres опционален.
|
||||
func NewFromEnv(log *slog.Logger) (*IoTPostgresStore, error) {
|
||||
@@ -282,16 +97,9 @@ created_at TIMESTAMPTZ DEFAULT now()
|
||||
}
|
||||
|
||||
// EnsureTenantDB создаёт DATABASE, USER и таблицу iot_telemetry для namespace.
|
||||
// Идемпотентен — повторный вызов безопасен (кэш ensured пропускает проверки).
|
||||
// Идемпотентен — повторный вызов безопасен.
|
||||
// Вызывается mqtt-bridge при первом сообщении от нового tenant.
|
||||
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)
|
||||
userName := dbName
|
||||
|
||||
@@ -306,39 +114,21 @@ func (s *IoTPostgresStore) EnsureTenantDB(ctx context.Context, namespace string)
|
||||
if !exists {
|
||||
password := uuid.New().String()
|
||||
|
||||
// Роль создаём только если её нет (DO-блоки НЕ принимают параметры —
|
||||
// прецедент 2026-08-16: "got 2 parameters but the statement requires 0").
|
||||
var roleExists bool
|
||||
err = s.adminDB.QueryRowContext(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM pg_roles WHERE rolname = $1)`, userName,
|
||||
).Scan(&roleExists)
|
||||
// CREATE USER через DO block — pg не поддерживает CREATE USER IF NOT EXISTS
|
||||
_, err = s.adminDB.ExecContext(ctx, fmt.Sprintf(
|
||||
`DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '%s') THEN
|
||||
CREATE USER %s WITH PASSWORD '%s';
|
||||
END IF;
|
||||
END $$`, userName, userName, password,
|
||||
))
|
||||
if err != nil {
|
||||
return fmt.Errorf("iotpg: check role %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)
|
||||
return fmt.Errorf("iotpg: create user %s: %w", userName, err)
|
||||
}
|
||||
|
||||
// CREATE DATABASE нельзя в транзакции
|
||||
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 {
|
||||
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, `
|
||||
CREATE TABLE IF NOT EXISTS iot_telemetry (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
device_id TEXT NOT NULL,
|
||||
message_id TEXT,
|
||||
ts TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
payload JSONB NOT NULL
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
device_id TEXT NOT NULL,
|
||||
ts TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
payload JSONB NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_iot_telemetry_device_ts
|
||||
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 {
|
||||
return err
|
||||
return fmt.Errorf("iotpg: get tenant DB for insert: %w", err)
|
||||
}
|
||||
s.ensured.Store(namespace, struct{}{})
|
||||
return nil
|
||||
}
|
||||
|
||||
// InsertTelemetry ставит строку в батч-буфер тенанта.
|
||||
// При накоплении 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
|
||||
_, err = tenantDB.ExecContext(ctx,
|
||||
`INSERT INTO iot_telemetry (device_id, payload) VALUES ($1, $2)`,
|
||||
deviceID, []byte(payload),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// QueryTelemetry читает телеметрию из tenant DB (ts DESC).
|
||||
// deviceID — фильтр (пустая строка = все устройства). limit — max записей
|
||||
// (50..1000), offset — смещение для пагинации.
|
||||
// Если tenant DB не существует (данных ещё нет) — пустой срез без ошибки.
|
||||
func (s *IoTPostgresStore) QueryTelemetry(ctx context.Context, namespace, deviceID string, limit, offset int) ([]TelemetryRow, error) {
|
||||
// deviceID — фильтр (пустая строка = все устройства). limit — max записей (50..1000).
|
||||
// Если tenant DB не существует (данных ещё нет) — возвращает пустой срез без ошибки.
|
||||
func (s *IoTPostgresStore) QueryTelemetry(ctx context.Context, namespace, deviceID string, limit int) ([]TelemetryRow, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
if limit > 1000 {
|
||||
limit = 1000
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
tenantDB, err := s.getTenantDB(ctx, namespace)
|
||||
if err != nil {
|
||||
// Если DB не существует — тенант ещё не отправлял данные, это нормально
|
||||
@@ -475,14 +197,14 @@ func (s *IoTPostgresStore) QueryTelemetry(ctx context.Context, namespace, device
|
||||
if deviceID != "" {
|
||||
rows, err = tenantDB.QueryContext(ctx,
|
||||
`SELECT id, device_id, ts, payload FROM iot_telemetry
|
||||
WHERE device_id = $1 ORDER BY ts DESC, id DESC LIMIT $2 OFFSET $3`,
|
||||
deviceID, limit, offset,
|
||||
WHERE device_id = $1 ORDER BY ts DESC LIMIT $2`,
|
||||
deviceID, limit,
|
||||
)
|
||||
} else {
|
||||
rows, err = tenantDB.QueryContext(ctx,
|
||||
`SELECT id, device_id, ts, payload FROM iot_telemetry
|
||||
ORDER BY ts DESC, id DESC LIMIT $1 OFFSET $2`,
|
||||
limit, offset,
|
||||
ORDER BY ts DESC LIMIT $1`,
|
||||
limit,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
@@ -579,6 +301,7 @@ FROM iot_telemetry`).Scan(&stats.Total, &stats.Last1h, &stats.Last24h)
|
||||
latestRows, err := tenantDB.QueryContext(ctx,
|
||||
`SELECT id, device_id, ts, payload FROM iot_telemetry ORDER BY ts DESC LIMIT 5`)
|
||||
if err == nil {
|
||||
defer latestRows.Close()
|
||||
for latestRows.Next() {
|
||||
var r TelemetryRow
|
||||
var rawPayload []byte
|
||||
@@ -587,7 +310,6 @@ FROM iot_telemetry`).Scan(&stats.Total, &stats.Last1h, &stats.Last24h)
|
||||
stats.Latest = append(stats.Latest, r)
|
||||
}
|
||||
}
|
||||
latestRows.Close() // закрываем сразу, не defer в цикле
|
||||
}
|
||||
|
||||
result.Tenants = append(result.Tenants, stats)
|
||||
@@ -607,50 +329,23 @@ func isDBNotExistErr(err error) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Close останавливает flusher, дописывает остаток и закрывает все подключения.
|
||||
// Идемпотентен (closeOnce): повторный вызов не паникует на close(stopFlush).
|
||||
// Close закрывает все подключения (admin + tenant кэш).
|
||||
func (s *IoTPostgresStore) Close() error {
|
||||
var err error
|
||||
s.closeOnce.Do(func() {
|
||||
close(s.stopFlush)
|
||||
s.flushWG.Wait()
|
||||
_ = s.flushAll(context.Background())
|
||||
s.tenants.Range(func(_, value any) bool {
|
||||
if db, ok := value.(*sql.DB); ok {
|
||||
db.Close()
|
||||
}
|
||||
return true
|
||||
})
|
||||
err = s.adminDB.Close()
|
||||
s.tenants.Range(func(_, value any) bool {
|
||||
if db, ok := value.(*sql.DB); ok {
|
||||
db.Close()
|
||||
}
|
||||
return true
|
||||
})
|
||||
return err
|
||||
return s.adminDB.Close()
|
||||
}
|
||||
|
||||
// getTenantDB возвращает *sql.DB для tenant DB из кэша или открывает новый.
|
||||
// Открытие дедуплицируется per-namespace мьютексом (гонка Load→LoadOrStore
|
||||
// из ревью 2026-08-16).
|
||||
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 {
|
||||
return cached.(*sql.DB), nil
|
||||
}
|
||||
m, _ := s.tenantMu.LoadOrStore(namespace, &sync.Mutex{})
|
||||
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
|
||||
}
|
||||
dsn := replaceDSNDatabase(s.adminDSN, tenantDBName(namespace))
|
||||
db, err := sql.Open("postgres", dsn)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// replaceDSNDatabase заменяет имя базы данных в URL-DSN.
|
||||
// Вход: "postgresql://user:pass@host:5432/iot_platform?sslmode=require"
|
||||
// Выход: "postgresql://user:pass@host:5432/tenant_abc?sslmode=require"
|
||||
//
|
||||
// Возвращает ОШИБКУ для не-URL (key=value) DSN: иначе функция молча вернула бы
|
||||
// исходный DSN, и телеметрия ВСЕХ тенантов ушла бы в management-БД — смешение
|
||||
// тенантов (ревью 2026-10-10). Query отделяется ПЕРВЫМ '?', поэтому '/' внутри
|
||||
// query (напр. sslrootcert=/path) не ломает поиск имени БД.
|
||||
func replaceDSNDatabase(dsn, newDBName string) (string, error) {
|
||||
// replaceDSNDatabase заменяет имя базы данных в DSN.
|
||||
// Вход: "postgresql://user:pass@host:5432/iot_platform?sslmode=disable"
|
||||
// Выход: "postgresql://user:pass@host:5432/tenant_abc?sslmode=disable"
|
||||
func replaceDSNDatabase(dsn, newDBName string) string {
|
||||
schemeEnd := strings.Index(dsn, "://")
|
||||
if schemeEnd < 0 {
|
||||
return "", fmt.Errorf("iotpg: replaceDSNDatabase: неподдерживаемый DSN (нет «://»); нужен URL вида postgresql://user:pass@host:port/db")
|
||||
return dsn
|
||||
}
|
||||
rest := dsn[schemeEnd+3:]
|
||||
query := ""
|
||||
if q := strings.Index(rest, "?"); q >= 0 {
|
||||
query = rest[q:]
|
||||
rest = rest[:q]
|
||||
hostPart := dsn[schemeEnd+3:]
|
||||
slashIdx := strings.LastIndex(hostPart, "/")
|
||||
if slashIdx < 0 {
|
||||
return dsn
|
||||
}
|
||||
slash := strings.LastIndex(rest, "/")
|
||||
if slash < 0 {
|
||||
return "", fmt.Errorf("iotpg: replaceDSNDatabase: в DSN нет имени базы данных")
|
||||
afterSlash := hostPart[slashIdx+1:]
|
||||
suffix := ""
|
||||
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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -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
Reference in New Issue
Block a user