Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
561506ed91 | ||
|
|
bac2f93a25 |
@@ -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/` на ВМ — **одна ФС**.
|
1. УВЕЛИЧИТЬ ТЕГ в `deployments/k8s/iot-console.yaml` (vX.Y.Z → vX.Y.Z+1)
|
||||||
Файлы, сохранённые локально, мгновенно видны на ВМ. SCP не нужен.
|
2. rsync на ВМ
|
||||||
|
3. ПРОВЕРИТЬ что файлы на ВМ новые (grep ключевой строки)
|
||||||
|
4. docker build с НОВЫМ тегом
|
||||||
|
5. docker push с НОВЫМ тегом
|
||||||
|
6. kubectl apply (не rollout restart — apply подтягивает новый тег)
|
||||||
|
|
||||||
Монтирование может слетать. Признак: файлы рассинхронизированы.
|
**НИКОГДА не делать `docker build` со старым тегом — под не перетянет образ (imagePullPolicy: IfNotPresent)**
|
||||||
|
|
||||||
```bash
|
## Файловая система (актуально)
|
||||||
# Размонтировать
|
|
||||||
fusermount -u ~/remote_dev
|
|
||||||
# Если завис: sudo umount -l /home/naeel/remote_dev
|
|
||||||
|
|
||||||
# Примонтировать
|
1. Все файлы редактируются локально: `~/IoT`
|
||||||
sshfs naeel@5.172.178.213:/home/naeel/terra ~/remote_dev \
|
2. После любых изменений — обязательно rsync на ВМ:
|
||||||
-o cache=no -o no_readahead -o reconnect \
|
rsync -az \
|
||||||
-o ServerAliveInterval=15 -o ServerAliveCountMax=3 \
|
-e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \
|
||||||
-o IdentityFile=~/.ssh/naeel_vm_id_ed25519
|
~/IoT/ \
|
||||||
```
|
naeel@5.172.178.213:~/terra/IoT/
|
||||||
|
|
||||||
|
3. Git (add/commit/push) выполнять ЛОКАЛЬНО в ~/IoT
|
||||||
|
4. Docker, kubectl и другие инфраструктурные команды — только через SSH на ВМ
|
||||||
|
5. Перед запуском любой команды на ВМ обязательно убедиться, что синхронизация (rsync) выполнена
|
||||||
|
6. SCP, sshfs, remote_dev и маунты больше НЕ используются
|
||||||
|
7. Только rsync для синхронизации
|
||||||
|
|
||||||
|
Пример:
|
||||||
|
1. Редактируешь локально
|
||||||
|
2. rsync на ВМ
|
||||||
|
3. Выполняешь команды через SSH на ВМ
|
||||||
|
|
||||||
## SSH
|
## SSH
|
||||||
|
|
||||||
@@ -37,7 +49,11 @@ ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=
|
|||||||
|
|
||||||
## Git
|
## Git
|
||||||
|
|
||||||
Коммитить и пушить через SSH после каждого завершённого этапа.
|
⛔⛔⛔ АБСОЛЮТНОЕ ПРАВИЛО:
|
||||||
|
- Git — ТОЛЬКО ЛОКАЛЬНО в `~/IoT`. НИКОГДА через SSH на VM.
|
||||||
|
- Разрешены ТОЛЬКО две операции: `git commit` и `git push`.
|
||||||
|
- ЗАПРЕЩЕНО: git pull, git fetch, git rebase, git merge, git reset, git stash, git checkout — что угодно кроме commit и push.
|
||||||
|
- Если push отклонён — СТОП, доложить пользователю. Не лезть в pull/merge/rebase самостоятельно.
|
||||||
|
|
||||||
Версионирование тегами: `vMAJOR.MINOR.PATCH`
|
Версионирование тегами: `vMAJOR.MINOR.PATCH`
|
||||||
- Patch — любое изменение кода
|
- Patch — любое изменение кода
|
||||||
@@ -48,9 +64,36 @@ ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=
|
|||||||
git tag vX.Y.Z && git push origin vX.Y.Z
|
git tag vX.Y.Z && git push origin vX.Y.Z
|
||||||
```
|
```
|
||||||
|
|
||||||
## Поведение агента
|
## ⛔ ТЕРМИНАЛЬНЫЙ БУФЕР — НИКОГДА НЕ ЧИТАТЬ СТАРЫЙ
|
||||||
|
|
||||||
- Не трогать рабочий код без явного указания
|
**АБСОЛЮТНОЕ ПРАВИЛО:**
|
||||||
- Не делать ничего сверх того, о чём явно попросили
|
- get_terminal_output из старых сессий — МУСОР. Там старые прогоны.
|
||||||
- Деструктивные операции (`kubectl delete`, `rm -rf`, `terraform destroy` и др.) — только после явного подтверждения с указанием конкретных объектов
|
- Всегда запускать новую команду через SSH и читать её вывод напрямую.
|
||||||
- Отвечать кратко, без вступлений, извинений, благодарностей и прочей воды
|
- НИКОГДА не читать буфер терминала из предыдущей сессии как актуальные данные.
|
||||||
|
- Актуальный результат — только из команды, которая была запущена СЕЙЧАС.
|
||||||
|
|
||||||
|
## ⛔ ДОКУМЕНТАЦИЯ ТЕСТ-ПРОГОН�ОВ — В РЕАЛЬНОМ ВРЕМЕНИ
|
||||||
|
|
||||||
|
**Правила:**
|
||||||
|
1. Перед запуском `run_all.sh` — создать файл `test-results/YYYY-MM-DD_HH-MM.log` и записать в него метку времени и что запускается.
|
||||||
|
2. Запускать `run_all.sh 2>&1 | tee ~/terra/IoT/test-results/YYYY-MM-DD_HH-MM.log` — вывод пишется сразу в файл и отображается в терминале.
|
||||||
|
3. После завершения — rsync лога локально. Лог остаётся как документация.
|
||||||
|
4. Папка `test-results/` в репозитории — `.gitignore` не добавлять, логи коммитить.
|
||||||
|
|
||||||
|
**Формат запуска:**
|
||||||
|
```bash
|
||||||
|
LOG="test-results/$(date +%Y-%m-%d_%H-%M).log"
|
||||||
|
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no naeel@5.172.178.213 \
|
||||||
|
"bash ~/terra/IoT/scripts/run_all.sh 2>&1 | tee ~/terra/IoT/${LOG}"
|
||||||
|
rsync -az -e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no" \
|
||||||
|
naeel@5.172.178.213:~/terra/IoT/test-results/ ~/IoT/test-results/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Никогда не разбираться с результатами по памяти / буферу / чату. Только лог.**
|
||||||
|
|
||||||
|
## ⛔ РУЧНЫЕ ПАТЧИ — ЗАПРЕЩЕНЫ
|
||||||
|
|
||||||
|
- НИКОГДА не применять ручные патчи (`kubectl patch`, `kubectl apply` отдельных полей, `python -c` замены в yaml и т.д.) без явного указания.
|
||||||
|
- Все изменения — только через код (Helm chart, YAML, Go-код) + сборка + деплой.
|
||||||
|
- Ручной патч слетает при следующем helm upgrade/redeploy → регрессия.
|
||||||
|
- Исключение: только если пользователь явно написал "примени ручной патч".
|
||||||
|
|||||||
+4
-5
@@ -1,8 +1,7 @@
|
|||||||
# Бинарники (только в корне проекта, не директории cmd/)
|
# Бинарники
|
||||||
/iot-operator
|
iot-operator
|
||||||
/mqtt-bridge
|
mqtt-bridge
|
||||||
/kafka-consumer
|
kafka-consumer
|
||||||
/sqs-consumer
|
|
||||||
bin/
|
bin/
|
||||||
|
|
||||||
# Go
|
# Go
|
||||||
|
|||||||
+5
-6
@@ -1,7 +1,6 @@
|
|||||||
# Создано: 2026-04-12
|
# Создано: 2026-04-12
|
||||||
# Изменено: 2026-04-12 (kafka-consumer → sqs-consumer)
|
|
||||||
# Dockerfile для IoT managed service.
|
# Dockerfile для IoT managed service.
|
||||||
# Multi-stage build: 3 бинарника (iot-operator, mqtt-bridge, sqs-consumer).
|
# Multi-stage build: 3 бинарника (iot-operator, mqtt-bridge, kafka-consumer).
|
||||||
# Образ: naeel/iot-operator (Docker Hub).
|
# Образ: naeel/iot-operator (Docker Hub).
|
||||||
|
|
||||||
FROM golang:1.25 AS builder
|
FROM golang:1.25 AS builder
|
||||||
@@ -20,11 +19,11 @@ COPY internal/ internal/
|
|||||||
# iot-operator — controller-manager + REST API
|
# iot-operator — controller-manager + REST API
|
||||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o iot-operator ./cmd/iot-operator/
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o iot-operator ./cmd/iot-operator/
|
||||||
|
|
||||||
# mqtt-bridge — MQTT (EMQX) → shared-SQS
|
# mqtt-bridge — MQTT (EMQX) → Kafka
|
||||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o mqtt-bridge ./cmd/mqtt-bridge/
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o mqtt-bridge ./cmd/mqtt-bridge/
|
||||||
|
|
||||||
# sqs-consumer — shared-SQS → Postgres
|
# kafka-consumer — Kafka → Postgres
|
||||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o sqs-consumer ./cmd/sqs-consumer/
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o kafka-consumer ./cmd/kafka-consumer/
|
||||||
|
|
||||||
# --- Runtime ---
|
# --- Runtime ---
|
||||||
FROM gcr.io/distroless/static:nonroot
|
FROM gcr.io/distroless/static:nonroot
|
||||||
@@ -34,7 +33,7 @@ WORKDIR /
|
|||||||
# Какой запускать — определяется command в k8s Deployment.
|
# Какой запускать — определяется command в k8s Deployment.
|
||||||
COPY --from=builder /workspace/iot-operator .
|
COPY --from=builder /workspace/iot-operator .
|
||||||
COPY --from=builder /workspace/mqtt-bridge .
|
COPY --from=builder /workspace/mqtt-bridge .
|
||||||
COPY --from=builder /workspace/sqs-consumer .
|
COPY --from=builder /workspace/kafka-consumer .
|
||||||
|
|
||||||
USER 65532:65532
|
USER 65532:65532
|
||||||
ENTRYPOINT ["/iot-operator"]
|
ENTRYPOINT ["/iot-operator"]
|
||||||
|
|||||||
@@ -15,7 +15,16 @@ build-bridge:
|
|||||||
CGO_ENABLED=0 go build -o bin/mqtt-bridge ./cmd/mqtt-bridge/
|
CGO_ENABLED=0 go build -o bin/mqtt-bridge ./cmd/mqtt-bridge/
|
||||||
|
|
||||||
build-consumer:
|
build-consumer:
|
||||||
CGO_ENABLED=0 go build -o bin/sqs-consumer ./cmd/sqs-consumer/
|
CGO_ENABLED=0 go build -o bin/kafka-consumer ./cmd/kafka-consumer/
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
docker-build:
|
||||||
|
docker build -t $(IMG) .
|
||||||
|
|
||||||
|
docker-push:
|
||||||
|
docker push $(IMG)
|
||||||
|
|
||||||
|
# Тесты и go mod
|
||||||
test:
|
test:
|
||||||
go test ./...
|
go test ./...
|
||||||
|
|
||||||
|
|||||||
@@ -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 цепочки
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
// Создано: 2026-04-12
|
|
||||||
// main.go — точка входа IoT managed service.
|
|
||||||
// Запускает controller-manager (IoTDevice CRD) и REST API сервер (порт 9090) в одном процессе.
|
|
||||||
// Перенесено из sless/main.go — только IoT-специфичная часть.
|
|
||||||
//
|
|
||||||
// Компоненты:
|
|
||||||
// 1. Controller-Manager (controller-runtime) — reconcile IoTDevice CRD
|
|
||||||
// 2. REST API Server (gorilla/mux) — CRUD устройств, телеметрия, MQTT auth, admin
|
|
||||||
//
|
|
||||||
// Конфигурация через env vars:
|
|
||||||
// IOT_PG_DSN — DSN для IoT Postgres (опционально)
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"k8s.io/apimachinery/pkg/runtime"
|
|
||||||
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
|
|
||||||
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
|
|
||||||
ctrl "sigs.k8s.io/controller-runtime"
|
|
||||||
"sigs.k8s.io/controller-runtime/pkg/healthz"
|
|
||||||
"sigs.k8s.io/controller-runtime/pkg/log/zap"
|
|
||||||
|
|
||||||
iotv1alpha1 "gitea.services.ngcloud.ru/Nail/IoT/api/v1alpha1"
|
|
||||||
iotcontrollers "gitea.services.ngcloud.ru/Nail/IoT/controllers"
|
|
||||||
iotapi "gitea.services.ngcloud.ru/Nail/IoT/internal/api"
|
|
||||||
"gitea.services.ngcloud.ru/Nail/IoT/internal/api/handler"
|
|
||||||
"gitea.services.ngcloud.ru/Nail/IoT/internal/storage/iotpg"
|
|
||||||
)
|
|
||||||
|
|
||||||
var scheme = runtime.NewScheme()
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
|
|
||||||
// IoT API group iot.kube5s.ru/v1alpha1
|
|
||||||
utilruntime.Must(iotv1alpha1.AddToScheme(scheme))
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
|
||||||
ctrl.SetLogger(zap.New(zap.UseDevMode(true)))
|
|
||||||
|
|
||||||
log.Info("starting IoT managed service operator")
|
|
||||||
|
|
||||||
// Controller Manager — управляет reconcile loop для IoTDevice CRD
|
|
||||||
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
|
|
||||||
Scheme: scheme,
|
|
||||||
HealthProbeBindAddress: ":8081",
|
|
||||||
LeaderElection: false,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Error("unable to create controller manager", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Регистрируем IoTDevice controller
|
|
||||||
if err = (&iotcontrollers.IoTDeviceReconciler{
|
|
||||||
Client: mgr.GetClient(),
|
|
||||||
Scheme: mgr.GetScheme(),
|
|
||||||
}).SetupWithManager(mgr); err != nil {
|
|
||||||
log.Error("unable to create IoTDevice controller", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Health/ready пробы для k8s
|
|
||||||
if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
|
|
||||||
log.Error("unable to set up health check", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil {
|
|
||||||
log.Error("unable to set up ready check", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// IoT Postgres — опционален (если IOT_PG_DSN не задан — телеметрия отключена)
|
|
||||||
iotPGStore, err := iotpg.NewFromEnv(log)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("failed to init IoT Postgres", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
if iotPGStore != nil {
|
|
||||||
defer iotPGStore.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// REST API handler
|
|
||||||
h := &handler.Handler{
|
|
||||||
K8s: mgr.GetClient(),
|
|
||||||
Scheme: mgr.GetScheme(),
|
|
||||||
IoTPG: iotPGStore,
|
|
||||||
Log: log,
|
|
||||||
BridgeUsername: os.Getenv("MQTT_BRIDGE_USERNAME"),
|
|
||||||
BridgePassword: os.Getenv("MQTT_BRIDGE_PASSWORD"),
|
|
||||||
}
|
|
||||||
|
|
||||||
router := iotapi.NewRouter(h, log)
|
|
||||||
|
|
||||||
// HTTP API сервер на порту 9090
|
|
||||||
apiServer := &http.Server{
|
|
||||||
Addr: ":9090",
|
|
||||||
Handler: router,
|
|
||||||
ReadTimeout: 30 * time.Second,
|
|
||||||
WriteTimeout: 60 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Запускаем API сервер в горутине
|
|
||||||
go func() {
|
|
||||||
log.Info("starting REST API server", "addr", ":9090")
|
|
||||||
if err := apiServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
||||||
log.Error("API server failed", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Graceful shutdown
|
|
||||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// Запускаем controller-manager (блокирует до ctx.Done)
|
|
||||||
go func() {
|
|
||||||
log.Info("starting controller manager")
|
|
||||||
if err := mgr.Start(ctx); err != nil {
|
|
||||||
log.Error("controller manager failed", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
<-ctx.Done()
|
|
||||||
log.Info("shutting down IoT operator")
|
|
||||||
|
|
||||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
defer shutdownCancel()
|
|
||||||
if err := apiServer.Shutdown(shutdownCtx); err != nil {
|
|
||||||
log.Error("API server shutdown error", "err", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("IoT operator stopped")
|
|
||||||
}
|
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
// Создано: 2026-04-04
|
|
||||||
// Изменено: 2026-04-12 (замена Kafka → shared-SQS)
|
|
||||||
// mqtt-bridge/main.go — сервис-мост: MQTT (EMQX) → shared-SQS.
|
|
||||||
//
|
|
||||||
// Роль в архитектуре:
|
|
||||||
// IoT Device → MQTT PUBLISH → EMQX → [mqtt-bridge подписан на "+/telemetry/+"]
|
|
||||||
// → SQS очередь "iot-telemetry"
|
|
||||||
// → iot-sqs-consumer → Postgres (история телеметрии)
|
|
||||||
// → event-dispatcher → Serverless Functions (триггеры)
|
|
||||||
//
|
|
||||||
// Логика:
|
|
||||||
// 1. Подключиться к EMQX как MQTT клиент (credentials из env)
|
|
||||||
// 2. Подписаться на топик "+/telemetry/+" (any namespace / telemetry / any device)
|
|
||||||
// 3. При получении сообщения — отправить в SQS очередь "iot-telemetry"
|
|
||||||
// 4. Payload оборачивается в envelope с метаданными (namespace, device_id, ts)
|
|
||||||
//
|
|
||||||
// Конфигурация через env vars:
|
|
||||||
// MQTT_BROKER_URL — tcp://emqx.sless.svc:1883
|
|
||||||
// MQTT_USERNAME — username для подключения bridge к EMQX
|
|
||||||
// MQTT_PASSWORD — пароль bridge клиента
|
|
||||||
// SQS_ENDPOINT — https://qu.kube5s.ru (или внутрикластерный endpoint)
|
|
||||||
// SQS_ACCESS_KEY — Access Key для shared-SQS tenant
|
|
||||||
// SQS_SECRET_KEY — Secret Key для shared-SQS tenant
|
|
||||||
// SQS_QUEUE_NAME — имя очереди (default: iot-telemetry)
|
|
||||||
// SQS_REGION — регион (default: us-east-1)
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"strings"
|
|
||||||
"syscall"
|
|
||||||
"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"
|
|
||||||
mqtt "github.com/eclipse/paho.mqtt.golang"
|
|
||||||
)
|
|
||||||
|
|
||||||
// mqttBridgeConfig — конфигурация сервиса из env vars.
|
|
||||||
type mqttBridgeConfig struct {
|
|
||||||
MQTTBrokerURL string
|
|
||||||
MQTTUsername string
|
|
||||||
MQTTPassword string
|
|
||||||
SQSEndpoint string
|
|
||||||
SQSAccessKey string
|
|
||||||
SQSSecretKey string
|
|
||||||
SQSQueueName string
|
|
||||||
SQSRegion string
|
|
||||||
}
|
|
||||||
|
|
||||||
// iotTelemetryMessage — envelope сообщения публикуемого в SQS.
|
|
||||||
// Потребители (iot-sqs-consumer, event-dispatcher) читают этот формат.
|
|
||||||
type iotTelemetryMessage struct {
|
|
||||||
// Namespace — k8s namespace тенанта (из MQTT topic, первый сегмент)
|
|
||||||
Namespace string `json:"namespace"`
|
|
||||||
// DeviceID — идентификатор устройства (из MQTT topic, третий сегмент)
|
|
||||||
DeviceID string `json:"device_id"`
|
|
||||||
// Topic — оригинальный MQTT topic
|
|
||||||
Topic string `json:"topic"`
|
|
||||||
// Payload — данные от устройства (JSON as-is, или строка если не JSON)
|
|
||||||
Payload json.RawMessage `json:"payload"`
|
|
||||||
// ReceivedAt — время получения сообщения мостом (UTC, RFC3339)
|
|
||||||
ReceivedAt string `json:"received_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
|
||||||
|
|
||||||
cfg := loadBridgeConfig()
|
|
||||||
|
|
||||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
log.Info("starting iot-mqtt-bridge",
|
|
||||||
"mqtt_broker", cfg.MQTTBrokerURL,
|
|
||||||
"mqtt_username", cfg.MQTTUsername,
|
|
||||||
"sqs_endpoint", cfg.SQSEndpoint,
|
|
||||||
"sqs_queue", cfg.SQSQueueName,
|
|
||||||
)
|
|
||||||
|
|
||||||
// SQS клиент через AWS SDK Go v2. Endpoint переопределяем на shared-SQS.
|
|
||||||
sqsClient := sqs.New(sqs.Options{
|
|
||||||
Region: cfg.SQSRegion,
|
|
||||||
Credentials: credentials.NewStaticCredentialsProvider(
|
|
||||||
cfg.SQSAccessKey, cfg.SQSSecretKey, "",
|
|
||||||
),
|
|
||||||
BaseEndpoint: aws.String(cfg.SQSEndpoint),
|
|
||||||
})
|
|
||||||
|
|
||||||
// Получаем QueueUrl по имени — SQS API требует URL, а не имя очереди.
|
|
||||||
// Делаем это один раз при старте.
|
|
||||||
queueUrlOut, err := sqsClient.GetQueueUrl(ctx, &sqs.GetQueueUrlInput{
|
|
||||||
QueueName: aws.String(cfg.SQSQueueName),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Error("failed to get SQS queue URL — очередь должна существовать", "queue", cfg.SQSQueueName, "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
queueURL := *queueUrlOut.QueueUrl
|
|
||||||
log.Info("SQS queue resolved", "queue_url", queueURL)
|
|
||||||
|
|
||||||
// Создаём MQTT клиент
|
|
||||||
mqttClient, err := connectMQTT(cfg, log)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("failed to connect to MQTT broker", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
defer mqttClient.Disconnect(500)
|
|
||||||
|
|
||||||
// Функция-обработчик MQTT сообщений — отправляет envelope в SQS
|
|
||||||
messageHandler := buildMQTTMessageHandler(ctx, sqsClient, queueURL, log)
|
|
||||||
|
|
||||||
// Подписываемся на все telemetry топики всех namespace
|
|
||||||
// "+/telemetry/+" = {любой namespace}/telemetry/{любой deviceId}
|
|
||||||
const telemetryTopicFilter = "+/telemetry/+"
|
|
||||||
token := mqttClient.Subscribe(telemetryTopicFilter, 1, messageHandler)
|
|
||||||
token.Wait()
|
|
||||||
if token.Error() != nil {
|
|
||||||
log.Error("mqtt subscribe failed", "topic", telemetryTopicFilter, "err", token.Error())
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
log.Info("subscribed to MQTT topic", "filter", telemetryTopicFilter)
|
|
||||||
|
|
||||||
<-ctx.Done()
|
|
||||||
log.Info("shutting down iot-mqtt-bridge")
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadBridgeConfig читает конфигурацию из env vars.
|
|
||||||
func loadBridgeConfig() mqttBridgeConfig {
|
|
||||||
required := func(key string) string {
|
|
||||||
v := os.Getenv(key)
|
|
||||||
if v == "" {
|
|
||||||
slog.Error("required env var not set", "key", key)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
return mqttBridgeConfig{
|
|
||||||
MQTTBrokerURL: getEnvOrDefault("MQTT_BROKER_URL", "tcp://emqx.sless.svc:1883"),
|
|
||||||
MQTTUsername: required("MQTT_USERNAME"),
|
|
||||||
MQTTPassword: required("MQTT_PASSWORD"),
|
|
||||||
SQSEndpoint: required("SQS_ENDPOINT"),
|
|
||||||
SQSAccessKey: required("SQS_ACCESS_KEY"),
|
|
||||||
SQSSecretKey: required("SQS_SECRET_KEY"),
|
|
||||||
SQSQueueName: getEnvOrDefault("SQS_QUEUE_NAME", "iot-telemetry"),
|
|
||||||
SQSRegion: getEnvOrDefault("SQS_REGION", "us-east-1"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getEnvOrDefault(key, defaultVal string) string {
|
|
||||||
if v := os.Getenv(key); v != "" {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return defaultVal
|
|
||||||
}
|
|
||||||
|
|
||||||
// connectMQTT устанавливает подключение к EMQX брокеру.
|
|
||||||
func connectMQTT(cfg mqttBridgeConfig, log *slog.Logger) (mqtt.Client, error) {
|
|
||||||
opts := mqtt.NewClientOptions()
|
|
||||||
opts.AddBroker(cfg.MQTTBrokerURL)
|
|
||||||
opts.SetClientID("sless-iot-bridge")
|
|
||||||
opts.SetUsername(cfg.MQTTUsername)
|
|
||||||
opts.SetPassword(cfg.MQTTPassword)
|
|
||||||
opts.SetAutoReconnect(true)
|
|
||||||
opts.SetConnectRetry(true)
|
|
||||||
opts.SetConnectRetryInterval(5 * time.Second)
|
|
||||||
opts.SetKeepAlive(30 * time.Second)
|
|
||||||
opts.SetCleanSession(false)
|
|
||||||
|
|
||||||
opts.SetConnectionLostHandler(func(_ mqtt.Client, err error) {
|
|
||||||
log.Warn("MQTT connection lost, reconnecting...", "err", err)
|
|
||||||
})
|
|
||||||
opts.SetReconnectingHandler(func(_ mqtt.Client, _ *mqtt.ClientOptions) {
|
|
||||||
log.Info("MQTT reconnecting...")
|
|
||||||
})
|
|
||||||
opts.SetOnConnectHandler(func(_ mqtt.Client) {
|
|
||||||
log.Info("MQTT connected to broker")
|
|
||||||
})
|
|
||||||
|
|
||||||
client := mqtt.NewClient(opts)
|
|
||||||
token := client.Connect()
|
|
||||||
if !token.WaitTimeout(30 * time.Second) {
|
|
||||||
return nil, fmt.Errorf("MQTT connect timeout")
|
|
||||||
}
|
|
||||||
if token.Error() != nil {
|
|
||||||
return nil, fmt.Errorf("MQTT connect: %w", token.Error())
|
|
||||||
}
|
|
||||||
return client, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildMQTTMessageHandler возвращает обработчик MQTT сообщений.
|
|
||||||
// При получении — формирует envelope и отправляет в SQS очередь.
|
|
||||||
// MessageGroupId = namespace (для FIFO очередей — группировка по тенанту).
|
|
||||||
func buildMQTTMessageHandler(ctx context.Context, sqsClient *sqs.Client, queueURL string, 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 {
|
|
||||||
log.Warn("unexpected MQTT topic format, skipping", "topic", topic)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ns := parts[0]
|
|
||||||
deviceID := parts[2]
|
|
||||||
|
|
||||||
// Нормализуем payload: если не JSON — оборачиваем в строку
|
|
||||||
rawPayload := json.RawMessage(payload)
|
|
||||||
if !json.Valid(payload) {
|
|
||||||
quotedBytes, _ := json.Marshal(string(payload))
|
|
||||||
rawPayload = json.RawMessage(quotedBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
envelope := iotTelemetryMessage{
|
|
||||||
Namespace: ns,
|
|
||||||
DeviceID: deviceID,
|
|
||||||
Topic: topic,
|
|
||||||
Payload: rawPayload,
|
|
||||||
ReceivedAt: time.Now().UTC().Format(time.RFC3339),
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := json.Marshal(envelope)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("marshal telemetry message", "topic", topic, "err", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Отправляем в SQS. SendMessage — синхронный, но HTTP round-trip быстрый.
|
|
||||||
// При ошибке логируем и продолжаем — не блокируем MQTT callback надолго.
|
|
||||||
_, err = sqsClient.SendMessage(ctx, &sqs.SendMessageInput{
|
|
||||||
QueueUrl: aws.String(queueURL),
|
|
||||||
MessageBody: aws.String(string(body)),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Error("SQS SendMessage failed", "queue_url", queueURL, "err", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Info("forwarded IoT telemetry to SQS",
|
|
||||||
"mqtt_topic", topic,
|
|
||||||
"namespace", ns,
|
|
||||||
"device", deviceID,
|
|
||||||
"sqs_queue", queueURL,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
// Создано: 2026-04-12 (замена kafka-consumer → sqs-consumer)
|
|
||||||
// sqs-consumer/main.go — iot-sqs-consumer: читает IoT телеметрию из shared-SQS → пишет в Postgres.
|
|
||||||
//
|
|
||||||
// Роль в архитектуре:
|
|
||||||
// SQS очередь "iot-telemetry" → iot-sqs-consumer → IoT Postgres (per-tenant DB)
|
|
||||||
//
|
|
||||||
// Long polling (WaitTimeSeconds=20) — минимизирует пустые запросы к SQS.
|
|
||||||
// При временной недоступности Postgres — сообщения остаются в очереди (visibility timeout).
|
|
||||||
// DeleteMessage вызывается только после успешной записи в Postgres.
|
|
||||||
//
|
|
||||||
// Конфигурация через env vars:
|
|
||||||
// SQS_ENDPOINT — https://qu.kube5s.ru (или внутрикластерный endpoint)
|
|
||||||
// SQS_ACCESS_KEY — Access Key для shared-SQS tenant
|
|
||||||
// SQS_SECRET_KEY — Secret Key для shared-SQS tenant
|
|
||||||
// SQS_QUEUE_NAME — имя очереди (default: iot-telemetry)
|
|
||||||
// SQS_REGION — регион (default: us-east-1)
|
|
||||||
// IOT_PG_DSN — postgres://user:pass@host:5432/iotdb (master DSN для IoT Postgres)
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"log/slog"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"syscall"
|
|
||||||
"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/storage/iotpg"
|
|
||||||
)
|
|
||||||
|
|
||||||
// sqsConsumerConfig — конфигурация из env vars.
|
|
||||||
type sqsConsumerConfig struct {
|
|
||||||
SQSEndpoint string
|
|
||||||
SQSAccessKey string
|
|
||||||
SQSSecretKey string
|
|
||||||
SQSQueueName string
|
|
||||||
SQSRegion string
|
|
||||||
}
|
|
||||||
|
|
||||||
// iotTelemetryMessage — envelope из SQS (идентичен bridge).
|
|
||||||
type iotTelemetryMessage struct {
|
|
||||||
Namespace string `json:"namespace"`
|
|
||||||
DeviceID string `json:"device_id"`
|
|
||||||
Topic string `json:"topic"`
|
|
||||||
Payload json.RawMessage `json:"payload"`
|
|
||||||
ReceivedAt string `json:"received_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
|
||||||
|
|
||||||
cfg := loadConsumerConfig()
|
|
||||||
|
|
||||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
log.Info("starting iot-sqs-consumer",
|
|
||||||
"sqs_endpoint", cfg.SQSEndpoint,
|
|
||||||
"sqs_queue", cfg.SQSQueueName,
|
|
||||||
)
|
|
||||||
|
|
||||||
// IoT Postgres — обязательный компонент для этого сервиса
|
|
||||||
iotStore, err := iotpg.NewFromEnv(log)
|
|
||||||
if err != nil || iotStore == nil {
|
|
||||||
log.Error("failed to connect to IoT Postgres — IOT_PG_DSN required", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
defer iotStore.Close()
|
|
||||||
log.Info("connected to IoT Postgres")
|
|
||||||
|
|
||||||
// SQS клиент
|
|
||||||
sqsClient := sqs.New(sqs.Options{
|
|
||||||
Region: cfg.SQSRegion,
|
|
||||||
Credentials: credentials.NewStaticCredentialsProvider(
|
|
||||||
cfg.SQSAccessKey, cfg.SQSSecretKey, "",
|
|
||||||
),
|
|
||||||
BaseEndpoint: aws.String(cfg.SQSEndpoint),
|
|
||||||
})
|
|
||||||
|
|
||||||
// Получаем QueueUrl по имени один раз при старте
|
|
||||||
queueUrlOut, err := sqsClient.GetQueueUrl(ctx, &sqs.GetQueueUrlInput{
|
|
||||||
QueueName: aws.String(cfg.SQSQueueName),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Error("failed to get SQS queue URL — очередь должна существовать", "queue", cfg.SQSQueueName, "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
queueURL := *queueUrlOut.QueueUrl
|
|
||||||
log.Info("SQS queue resolved", "queue_url", queueURL)
|
|
||||||
|
|
||||||
log.Info("sqs consumer ready, starting polling loop...")
|
|
||||||
|
|
||||||
// Polling loop — long polling (WaitTimeSeconds=20) минимизирует пустые запросы.
|
|
||||||
// При ошибке SQS — backoff 5 сек и продолжаем.
|
|
||||||
for {
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := sqsClient.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{
|
|
||||||
QueueUrl: aws.String(queueURL),
|
|
||||||
MaxNumberOfMessages: 10,
|
|
||||||
WaitTimeSeconds: 20, // long polling — SQS держит соединение до 20 сек
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
break // штатное завершение
|
|
||||||
}
|
|
||||||
log.Error("SQS ReceiveMessage failed", "err", err)
|
|
||||||
// Backoff при ошибках SQS — не спамим запросами
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
case <-time.After(5 * time.Second):
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, sqsMsg := range resp.Messages {
|
|
||||||
if sqsMsg.Body == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := processSQSTelemetry(ctx, *sqsMsg.Body, iotStore, log); err != nil {
|
|
||||||
log.Error("process telemetry message", "err", err, "message_id", derefStr(sqsMsg.MessageId))
|
|
||||||
// НЕ удаляем сообщение — оно вернётся в очередь после visibility timeout
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Удаляем сообщение из SQS только после успешной записи в Postgres
|
|
||||||
_, err := sqsClient.DeleteMessage(ctx, &sqs.DeleteMessageInput{
|
|
||||||
QueueUrl: aws.String(queueURL),
|
|
||||||
ReceiptHandle: sqsMsg.ReceiptHandle,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Error("SQS DeleteMessage failed", "err", err, "message_id", derefStr(sqsMsg.MessageId))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Info("shutting down iot-sqs-consumer")
|
|
||||||
}
|
|
||||||
|
|
||||||
// processSQSTelemetry десериализует envelope из SQS и записывает в Postgres.
|
|
||||||
func processSQSTelemetry(ctx context.Context, body string, store *iotpg.IoTPostgresStore, log *slog.Logger) error {
|
|
||||||
var envelope iotTelemetryMessage
|
|
||||||
if err := json.Unmarshal([]byte(body), &envelope); err != nil {
|
|
||||||
// Битое сообщение — логируем и пропускаем (не блокируем очередь)
|
|
||||||
log.Warn("failed to unmarshal telemetry envelope, skipping", "err", err, "raw", body)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// EnsureTenantDB идемпотентен — кэшируется после первого вызова
|
|
||||||
if err := store.EnsureTenantDB(ctx, envelope.Namespace); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := store.InsertTelemetry(ctx, envelope.Namespace, envelope.DeviceID, envelope.Payload); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Info("telemetry saved to Postgres",
|
|
||||||
"namespace", envelope.Namespace,
|
|
||||||
"device", envelope.DeviceID,
|
|
||||||
)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadConsumerConfig читает конфигурацию из env vars.
|
|
||||||
func loadConsumerConfig() sqsConsumerConfig {
|
|
||||||
required := func(key string) string {
|
|
||||||
v := os.Getenv(key)
|
|
||||||
if v == "" {
|
|
||||||
slog.Error("required env var not set", "key", key)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
return sqsConsumerConfig{
|
|
||||||
SQSEndpoint: required("SQS_ENDPOINT"),
|
|
||||||
SQSAccessKey: required("SQS_ACCESS_KEY"),
|
|
||||||
SQSSecretKey: required("SQS_SECRET_KEY"),
|
|
||||||
SQSQueueName: getEnvOrDefault("SQS_QUEUE_NAME", "iot-telemetry"),
|
|
||||||
SQSRegion: getEnvOrDefault("SQS_REGION", "us-east-1"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getEnvOrDefault(key, defaultVal string) string {
|
|
||||||
if v := os.Getenv(key); v != "" {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return defaultVal
|
|
||||||
}
|
|
||||||
|
|
||||||
// derefStr — безопасная разыменовка строкового указателя.
|
|
||||||
func derefStr(s *string) string {
|
|
||||||
if s == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return *s
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
# Создано: 2026-04-12
|
|
||||||
# MQTT over WebSocket через Ingress с TLS termination.
|
|
||||||
# Причина: порт 1883 заблокирован NSX-T Edge firewall на уровне облака.
|
|
||||||
# Решение: EMQX WebSocket listener (8083) проксируется через nginx-ingress с TLS.
|
|
||||||
#
|
|
||||||
# IoT устройство подключается: wss://iot.kube5s.ru/mqtt
|
|
||||||
# IoT Консоль (UI): https://iot.kube5s.ru/console
|
|
||||||
#
|
|
||||||
# DNS A-запись: iot.kube5s.ru → 185.247.187.147
|
|
||||||
# TLS: cert-manager + letsencrypt-prod, secret=iot-kube5s-ru-tls
|
|
||||||
#
|
|
||||||
# Применение: kubectl apply -f deployments/k8s/emqx-ws-ingress.yaml
|
|
||||||
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: emqx-ws
|
|
||||||
namespace: sless
|
|
||||||
# Отдельный Service — WebSocket порт для Ingress
|
|
||||||
spec:
|
|
||||||
selector:
|
|
||||||
app: emqx
|
|
||||||
ports:
|
|
||||||
- name: mqtt-ws
|
|
||||||
port: 8083
|
|
||||||
targetPort: 8083
|
|
||||||
protocol: TCP
|
|
||||||
---
|
|
||||||
apiVersion: networking.k8s.io/v1
|
|
||||||
kind: Ingress
|
|
||||||
metadata:
|
|
||||||
name: emqx-mqtt-websocket
|
|
||||||
namespace: sless
|
|
||||||
annotations:
|
|
||||||
kubernetes.io/ingress.class: nginx
|
|
||||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
|
||||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
|
||||||
# WebSocket: nginx-ingress добавляет Upgrade/Connection при proxy-http-version=1.1
|
|
||||||
nginx.ingress.kubernetes.io/proxy-http-version: "1.1"
|
|
||||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
|
|
||||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
|
|
||||||
spec:
|
|
||||||
ingressClassName: nginx
|
|
||||||
tls:
|
|
||||||
- hosts:
|
|
||||||
- iot.kube5s.ru
|
|
||||||
secretName: iot-kube5s-ru-tls
|
|
||||||
rules:
|
|
||||||
- host: iot.kube5s.ru
|
|
||||||
http:
|
|
||||||
paths:
|
|
||||||
# MQTT over WebSocket — wss://iot.kube5s.ru/mqtt
|
|
||||||
- path: /mqtt
|
|
||||||
pathType: Exact
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: emqx-ws
|
|
||||||
port:
|
|
||||||
number: 8083
|
|
||||||
# IoT Консоль (UI) — https://iot.kube5s.ru/console
|
|
||||||
- path: /console
|
|
||||||
pathType: Exact
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: iot-operator
|
|
||||||
port:
|
|
||||||
number: 9090
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
# Создано: 2026-04-12
|
|
||||||
# EMQX MQTT-брокер для IoT-сервиса (namespace: sless).
|
|
||||||
#
|
|
||||||
# Архитектура:
|
|
||||||
# IoT Device → MQTT CONNECT → EMQX (HTTP auth → iot-operator:9090/internal/mqtt/auth)
|
|
||||||
# EMQX → MQTT PUBLISH → iot-mqtt-bridge (paho subscriber) → shared-SQS queue iot-telemetry
|
|
||||||
# SQS → iot-sqs-consumer → Postgres
|
|
||||||
#
|
|
||||||
# EMQX 5.x конфиг через emqx.conf (HOCON формат), монтируется как ConfigMap volume.
|
|
||||||
#
|
|
||||||
# Порты:
|
|
||||||
# 1883 — MQTT (plaintext)
|
|
||||||
# 8083 — MQTT over WebSocket
|
|
||||||
# 18083 — EMQX Dashboard (admin/public по умолчанию — менять в prod!)
|
|
||||||
#
|
|
||||||
# Применение: kubectl apply -f deployments/k8s/emqx.yaml
|
|
||||||
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: ConfigMap
|
|
||||||
metadata:
|
|
||||||
name: emqx-config
|
|
||||||
namespace: sless
|
|
||||||
data:
|
|
||||||
# emqx.conf — HOCON конфиг для EMQX 5.5.x
|
|
||||||
# Раздел authentication: HTTP Backend для проверки MQTT credentials IoT-устройств.
|
|
||||||
# iot-operator ищет Secret iot-{deviceId} и сравнивает пароль.
|
|
||||||
emqx.conf: |
|
|
||||||
## EMQX 5.x configuration (HOCON format)
|
|
||||||
## Создано: 2026-04-12
|
|
||||||
|
|
||||||
## node — без них EMQX 5.x падает при старте
|
|
||||||
## node.cookie — секрет кластерного Erlang-соединения, для single-node любая строка
|
|
||||||
## node.data_dir — директория данных (mnesia, конфиги)
|
|
||||||
node {
|
|
||||||
name = "emqx@127.0.0.1"
|
|
||||||
cookie = "iot-emqx-cookie-mvp"
|
|
||||||
data_dir = "/opt/emqx/data"
|
|
||||||
}
|
|
||||||
|
|
||||||
## HTTP Auth Backend для IoT-устройств
|
|
||||||
## EMQX посылает POST с {username, password, clientid} → iot-operator отвечает {"result":"allow"|"deny"}
|
|
||||||
authentication = [
|
|
||||||
{
|
|
||||||
mechanism = password_based
|
|
||||||
backend = http
|
|
||||||
enable = true
|
|
||||||
method = post
|
|
||||||
url = "http://iot-operator.sless.svc:9090/internal/mqtt/auth"
|
|
||||||
body {
|
|
||||||
username = "${username}"
|
|
||||||
password = "${password}"
|
|
||||||
clientid = "${clientid}"
|
|
||||||
}
|
|
||||||
headers {
|
|
||||||
"content-type" = "application/json"
|
|
||||||
}
|
|
||||||
connect_timeout = 5s
|
|
||||||
request_timeout = 5s
|
|
||||||
pool_size = 8
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
## Authorization (ACL) — HTTP backend для изоляции топиков по устройству.
|
|
||||||
## no_match = deny: если HTTP backend недоступен или не ответил — запрещаем.
|
|
||||||
## Endpoint /internal/mqtt/acl возвращает allow только для топиков {ns}/{deviceId}/#
|
|
||||||
authorization {
|
|
||||||
no_match = deny
|
|
||||||
deny_action = disconnect
|
|
||||||
cache {
|
|
||||||
enable = true
|
|
||||||
max_size = 32
|
|
||||||
ttl = 1m
|
|
||||||
}
|
|
||||||
sources = [
|
|
||||||
{
|
|
||||||
type = http
|
|
||||||
enable = true
|
|
||||||
method = post
|
|
||||||
url = "http://iot-operator.sless.svc:9090/internal/mqtt/acl"
|
|
||||||
body {
|
|
||||||
username = "${username}"
|
|
||||||
clientid = "${clientid}"
|
|
||||||
action = "${action}"
|
|
||||||
topic = "${topic}"
|
|
||||||
}
|
|
||||||
headers {
|
|
||||||
"content-type" = "application/json"
|
|
||||||
}
|
|
||||||
connect_timeout = 5s
|
|
||||||
request_timeout = 5s
|
|
||||||
pool_size = 8
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
## MQTT настройки
|
|
||||||
mqtt {
|
|
||||||
max_packet_size = 1MB
|
|
||||||
max_topic_levels = 10
|
|
||||||
retain_available = false
|
|
||||||
}
|
|
||||||
|
|
||||||
## Listeners — plaintext MQTT + WebSocket
|
|
||||||
listeners.tcp.default {
|
|
||||||
bind = "0.0.0.0:1883"
|
|
||||||
max_connections = 1024
|
|
||||||
}
|
|
||||||
|
|
||||||
listeners.ws.default {
|
|
||||||
bind = "0.0.0.0:8083"
|
|
||||||
max_connections = 512
|
|
||||||
}
|
|
||||||
|
|
||||||
## Dashboard
|
|
||||||
dashboard {
|
|
||||||
listeners.http {
|
|
||||||
bind = 18083
|
|
||||||
}
|
|
||||||
}
|
|
||||||
---
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: emqx
|
|
||||||
namespace: sless
|
|
||||||
labels:
|
|
||||||
app: emqx
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: emqx
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: emqx
|
|
||||||
spec:
|
|
||||||
containers:
|
|
||||||
- name: emqx
|
|
||||||
image: emqx/emqx:5.5.1
|
|
||||||
ports:
|
|
||||||
- name: mqtt
|
|
||||||
containerPort: 1883
|
|
||||||
- name: ws
|
|
||||||
containerPort: 8083
|
|
||||||
- name: dashboard
|
|
||||||
containerPort: 18083
|
|
||||||
volumeMounts:
|
|
||||||
- name: emqx-conf
|
|
||||||
mountPath: /opt/emqx/etc/emqx.conf
|
|
||||||
subPath: emqx.conf
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
memory: "256Mi"
|
|
||||||
cpu: "100m"
|
|
||||||
limits:
|
|
||||||
memory: "512Mi"
|
|
||||||
cpu: "500m"
|
|
||||||
readinessProbe:
|
|
||||||
tcpSocket:
|
|
||||||
port: 1883
|
|
||||||
initialDelaySeconds: 20
|
|
||||||
periodSeconds: 10
|
|
||||||
timeoutSeconds: 5
|
|
||||||
livenessProbe:
|
|
||||||
tcpSocket:
|
|
||||||
port: 1883
|
|
||||||
initialDelaySeconds: 40
|
|
||||||
periodSeconds: 20
|
|
||||||
volumes:
|
|
||||||
- name: emqx-conf
|
|
||||||
configMap:
|
|
||||||
name: emqx-config
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: emqx
|
|
||||||
namespace: sless
|
|
||||||
spec:
|
|
||||||
selector:
|
|
||||||
app: emqx
|
|
||||||
ports:
|
|
||||||
- name: mqtt
|
|
||||||
port: 1883
|
|
||||||
targetPort: 1883
|
|
||||||
- name: ws
|
|
||||||
port: 8083
|
|
||||||
targetPort: 8083
|
|
||||||
- name: dashboard
|
|
||||||
port: 18083
|
|
||||||
targetPort: 18083
|
|
||||||
@@ -1,18 +1,25 @@
|
|||||||
# Создано: 2026-04-04
|
# Создано: 2026-04-04
|
||||||
# Изменено: 2026-04-12 (замена Kafka → shared-SQS)
|
# Изменено: 2026-04-06 (MQTT→Kafka: убран RABBITMQ_URL, добавлен KAFKA_BROKERS, v0.1.67)
|
||||||
# Deployment iot-mqtt-bridge — MQTT→SQS мост для IoT.
|
# Deployment iot-mqtt-bridge — MQTT→Kafka мост для IoT.
|
||||||
#
|
#
|
||||||
# Получает MQTT сообщения от EMQX (подписка на "+/telemetry/+")
|
# Получает MQTT сообщения от EMQX (подписка на "+/telemetry/+")
|
||||||
# и отправляет в shared-SQS очередь "iot-telemetry" через AWS SDK.
|
# и публикует в Kafka топик "iot.telemetry" (ключ = namespace).
|
||||||
#
|
#
|
||||||
# Credentials для MQTT подключения берутся из Secret iot-bridge-credentials.
|
# Credentials для MQTT подключения берутся из Secret iot-bridge-credentials.
|
||||||
# Credentials для SQS берутся из Secret iot-sqs-credentials.
|
# Этот Secret нужно создать вручную ДО деплоя:
|
||||||
#
|
#
|
||||||
# Создание SQS Secret (один раз):
|
# # 1. Создать IoTDevice для bridge через API:
|
||||||
# kubectl create secret generic iot-sqs-credentials -n sless \
|
# curl -X POST .../v1/namespaces/sless-bridge/iot/devices \
|
||||||
# --from-literal=SQS_ENDPOINT="https://qu.kube5s.ru" \
|
# -d '{"name":"bridge","device_id":"bridge","enabled":true}'
|
||||||
# --from-literal=SQS_ACCESS_KEY="<access_key>" \
|
#
|
||||||
# --from-literal=SQS_SECRET_KEY="<secret_key>"
|
# # 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
|
# Применение: kubectl apply -f deployments/k8s/iot-mqtt-bridge.yaml
|
||||||
|
|
||||||
@@ -38,22 +45,21 @@ spec:
|
|||||||
- name: mqtt-bridge
|
- name: mqtt-bridge
|
||||||
# Тот же образ что и оператор — оба бинаря в одном слое (manager + iot-mqtt-bridge).
|
# Тот же образ что и оператор — оба бинаря в одном слое (manager + iot-mqtt-bridge).
|
||||||
# При смене версии оператора — менять тег и здесь.
|
# При смене версии оператора — менять тег и здесь.
|
||||||
image: naeel/iot-operator:v0.2.6
|
image: pearlharbor.registryk8s.services.ngcloud.ru/naeel/sless-operator:v0.1.69
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
command: ["/mqtt-bridge"]
|
command: ["/iot-mqtt-bridge"]
|
||||||
env:
|
env:
|
||||||
- name: MQTT_BROKER_URL
|
- name: MQTT_BROKER_URL
|
||||||
value: "tcp://emqx.sless.svc:1883"
|
value: "tcp://emqx.sless.svc:1883"
|
||||||
- name: SQS_QUEUE_NAME
|
- name: KAFKA_BROKERS
|
||||||
value: "iot-telemetry"
|
value: "kafka.sless.svc.cluster.local:9092"
|
||||||
- name: SQS_REGION
|
|
||||||
value: "us-east-1"
|
|
||||||
envFrom:
|
envFrom:
|
||||||
- secretRef:
|
- secretRef:
|
||||||
name: iot-bridge-credentials
|
name: iot-bridge-credentials
|
||||||
# SQS_ENDPOINT, SQS_ACCESS_KEY, SQS_SECRET_KEY
|
# IOT_PG_DSN — сохранение телеметрии в Postgres (опционально)
|
||||||
- secretRef:
|
- secretRef:
|
||||||
name: iot-sqs-credentials
|
name: iot-postgres-secret
|
||||||
|
optional: true
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
memory: "32Mi"
|
memory: "32Mi"
|
||||||
@@ -61,3 +67,5 @@ spec:
|
|||||||
limits:
|
limits:
|
||||||
memory: "64Mi"
|
memory: "64Mi"
|
||||||
cpu: "100m"
|
cpu: "100m"
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: sless-registry-auth
|
||||||
|
|||||||
@@ -1,152 +0,0 @@
|
|||||||
# Создано: 2026-04-12
|
|
||||||
# Deployment iot-operator — controller-manager (IoTDevice CRD) + REST API на :9090.
|
|
||||||
#
|
|
||||||
# Компоненты:
|
|
||||||
# - ServiceAccount + ClusterRole + ClusterRoleBinding (RBAC для CRD controller)
|
|
||||||
# - Deployment: naeel/iot-operator:v0.2.6
|
|
||||||
# - Service: ClusterIP :9090 (REST API, MQTT auth/acl, admin UI)
|
|
||||||
#
|
|
||||||
# iot-operator обслуживает:
|
|
||||||
# - IoTDevice CRD reconcilation (controller-runtime)
|
|
||||||
# - REST API: устройства, телеметрия, MQTT auth/acl
|
|
||||||
# - Admin UI: /iot-admin
|
|
||||||
# - Console UI: /console
|
|
||||||
#
|
|
||||||
# Секреты:
|
|
||||||
# iot-postgres-secret — IOT_PG_DSN для managed Postgres
|
|
||||||
# iot-sqs-credentials — SQS_ENDPOINT, SQS_ACCESS_KEY, SQS_SECRET_KEY
|
|
||||||
#
|
|
||||||
# Применение: kubectl apply -f deployments/k8s/iot-operator.yaml
|
|
||||||
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: ServiceAccount
|
|
||||||
metadata:
|
|
||||||
name: iot-operator
|
|
||||||
namespace: sless
|
|
||||||
---
|
|
||||||
# ClusterRole — права на IoTDevice CRD + Secrets (для MQTT auth)
|
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
|
||||||
kind: ClusterRole
|
|
||||||
metadata:
|
|
||||||
name: iot-operator-role
|
|
||||||
rules:
|
|
||||||
# IoTDevice CRD
|
|
||||||
- apiGroups: ["iot.kube5s.ru"]
|
|
||||||
resources: ["iotdevices", "iotdevices/status", "iotdevices/finalizers"]
|
|
||||||
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
|
|
||||||
# Secrets — для MQTT auth (чтение device credentials)
|
|
||||||
- apiGroups: [""]
|
|
||||||
resources: ["secrets"]
|
|
||||||
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
|
|
||||||
# Events — controller-runtime записывает events
|
|
||||||
- apiGroups: [""]
|
|
||||||
resources: ["events"]
|
|
||||||
verbs: ["create", "patch"]
|
|
||||||
# Namespaces — для per-tenant DB provisioning
|
|
||||||
- apiGroups: [""]
|
|
||||||
resources: ["namespaces"]
|
|
||||||
verbs: ["get", "list", "watch"]
|
|
||||||
# Leases — leader election (controller-runtime)
|
|
||||||
- apiGroups: ["coordination.k8s.io"]
|
|
||||||
resources: ["leases"]
|
|
||||||
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
|
|
||||||
---
|
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
|
||||||
kind: ClusterRoleBinding
|
|
||||||
metadata:
|
|
||||||
name: iot-operator-rolebinding
|
|
||||||
roleRef:
|
|
||||||
apiGroup: rbac.authorization.k8s.io
|
|
||||||
kind: ClusterRole
|
|
||||||
name: iot-operator-role
|
|
||||||
subjects:
|
|
||||||
- kind: ServiceAccount
|
|
||||||
name: iot-operator
|
|
||||||
namespace: sless
|
|
||||||
---
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: iot-operator
|
|
||||||
namespace: sless
|
|
||||||
labels:
|
|
||||||
app: iot-operator
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: iot-operator
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: iot-operator
|
|
||||||
spec:
|
|
||||||
serviceAccountName: iot-operator
|
|
||||||
containers:
|
|
||||||
- name: operator
|
|
||||||
image: naeel/iot-operator:v0.2.6
|
|
||||||
imagePullPolicy: Always
|
|
||||||
ports:
|
|
||||||
- name: api
|
|
||||||
containerPort: 9090
|
|
||||||
- name: metrics
|
|
||||||
containerPort: 8080
|
|
||||||
- name: health
|
|
||||||
containerPort: 8081
|
|
||||||
envFrom:
|
|
||||||
# IOT_PG_DSN — managed Postgres
|
|
||||||
- secretRef:
|
|
||||||
name: iot-postgres-secret
|
|
||||||
# SQS_ENDPOINT, SQS_ACCESS_KEY, SQS_SECRET_KEY — для admin stats
|
|
||||||
- secretRef:
|
|
||||||
name: iot-sqs-credentials
|
|
||||||
env:
|
|
||||||
- name: API_PORT
|
|
||||||
value: "9090"
|
|
||||||
# ADMIN_STATS_TOKEN — токен доступа к /iot-admin/stats
|
|
||||||
- name: ADMIN_STATS_TOKEN
|
|
||||||
value: "iot-admin-2026"
|
|
||||||
# Bridge MQTT credentials — для авторизации внутреннего mqtt-bridge
|
|
||||||
- name: MQTT_BRIDGE_USERNAME
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: iot-bridge-credentials
|
|
||||||
key: MQTT_USERNAME
|
|
||||||
- name: MQTT_BRIDGE_PASSWORD
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: iot-bridge-credentials
|
|
||||||
key: MQTT_PASSWORD
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /healthz
|
|
||||||
port: 8081
|
|
||||||
initialDelaySeconds: 5
|
|
||||||
periodSeconds: 10
|
|
||||||
livenessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /healthz
|
|
||||||
port: 8081
|
|
||||||
initialDelaySeconds: 15
|
|
||||||
periodSeconds: 20
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
memory: "64Mi"
|
|
||||||
cpu: "50m"
|
|
||||||
limits:
|
|
||||||
memory: "256Mi"
|
|
||||||
cpu: "500m"
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: iot-operator
|
|
||||||
namespace: sless
|
|
||||||
spec:
|
|
||||||
selector:
|
|
||||||
app: iot-operator
|
|
||||||
ports:
|
|
||||||
- name: api
|
|
||||||
port: 9090
|
|
||||||
targetPort: 9090
|
|
||||||
@@ -1,16 +1,11 @@
|
|||||||
# Создано: 2026-04-05
|
# Создано: 2026-04-05
|
||||||
# Изменено: 2026-04-12 (заменён self-hosted на managed Postgres через оператор)
|
# Postgres для IoT телеметрии — отдельный от sless postgres (тот для invocations логов).
|
||||||
|
# Deployment (не StatefulSet) — для dev/demo. В prod заменить на managed Postgres.
|
||||||
#
|
#
|
||||||
# Managed PostgreSQL 17 — тот же инстанс что использует shared-SQS.
|
# Суперюзер iot_admin используется оператором для:
|
||||||
# Namespace: dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5 (managed оператором)
|
|
||||||
# Host: postgresqlk8s-master.dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5.svc.cluster.local
|
|
||||||
#
|
|
||||||
# IoT оператор использует суперюзер для:
|
|
||||||
# - CREATE USER tenant_{ns} + CREATE DATABASE tenant_{ns}
|
# - CREATE USER tenant_{ns} + CREATE DATABASE tenant_{ns}
|
||||||
# - CREATE TABLE iot_telemetry в tenant DB
|
# - CREATE TABLE iot_telemetry в tenant DB
|
||||||
# Клиенты НЕ имеют прямого доступа — только через REST API платформы.
|
# Клиенты НЕ имеют прямого доступа — только через REST API платформы.
|
||||||
#
|
|
||||||
# Deployment и Service удалены — Postgres managed, только Secret с DSN.
|
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Secret
|
kind: Secret
|
||||||
@@ -18,9 +13,54 @@ metadata:
|
|||||||
name: iot-postgres-secret
|
name: iot-postgres-secret
|
||||||
namespace: sless
|
namespace: sless
|
||||||
stringData:
|
stringData:
|
||||||
# Суперпользователь managed Postgres
|
# Суперпользователь — для управления tenant databases
|
||||||
POSTGRES_USER: "super"
|
POSTGRES_USER: "iot_admin"
|
||||||
POSTGRES_PASSWORD: "BQUF5ruECa1ZFlq4wYt3gPJUEmtBMkA9QNK4MM5Sd8al4ArMDlmT16DIKHYBPyif"
|
POSTGRES_PASSWORD: "iot-pg-super-2026"
|
||||||
POSTGRES_DB: "sqsdb"
|
POSTGRES_DB: "iot_platform"
|
||||||
# DSN для iot-operator и sqs-consumer (superuser к managed DB)
|
# DSN для оператора и mqtt-bridge (superuser к management DB)
|
||||||
IOT_PG_DSN: "postgresql://super:BQUF5ruECa1ZFlq4wYt3gPJUEmtBMkA9QNK4MM5Sd8al4ArMDlmT16DIKHYBPyif@postgresqlk8s-master.dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5.svc.cluster.local:5432/sqsdb?sslmode=disable"
|
IOT_PG_DSN: "postgresql://iot_admin:iot-pg-super-2026@iot-postgres.sless.svc:5432/iot_platform?sslmode=disable"
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: iot-postgres
|
||||||
|
namespace: sless
|
||||||
|
labels:
|
||||||
|
app: iot-postgres
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: iot-postgres
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: iot-postgres
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: postgres
|
||||||
|
image: postgres:16-alpine
|
||||||
|
ports:
|
||||||
|
- containerPort: 5432
|
||||||
|
envFrom:
|
||||||
|
- secretRef:
|
||||||
|
name: iot-postgres-secret
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: "128Mi"
|
||||||
|
cpu: "100m"
|
||||||
|
limits:
|
||||||
|
memory: "512Mi"
|
||||||
|
cpu: "500m"
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: iot-postgres
|
||||||
|
namespace: sless
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: iot-postgres
|
||||||
|
ports:
|
||||||
|
- port: 5432
|
||||||
|
targetPort: 5432
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
# Создано: 2026-04-12
|
|
||||||
# Deployment iot-sqs-consumer — читает IoT телеметрию из shared-SQS → пишет в IoT Postgres.
|
|
||||||
#
|
|
||||||
# Long polling (WaitTimeSeconds=20) — минимизирует пустые запросы.
|
|
||||||
# Сообщение удаляется из SQS только после успешной записи в Postgres (at-least-once).
|
|
||||||
#
|
|
||||||
# Credentials для SQS берутся из Secret iot-sqs-credentials.
|
|
||||||
# IOT_PG_DSN берётся из Secret iot-postgres-secret.
|
|
||||||
#
|
|
||||||
# Создание SQS Secret (один раз, если ещё не создан):
|
|
||||||
# kubectl create secret generic iot-sqs-credentials -n sless \
|
|
||||||
# --from-literal=SQS_ENDPOINT="https://qu.kube5s.ru" \
|
|
||||||
# --from-literal=SQS_ACCESS_KEY="<access_key>" \
|
|
||||||
# --from-literal=SQS_SECRET_KEY="<secret_key>"
|
|
||||||
#
|
|
||||||
# Применение: kubectl apply -f deployments/k8s/iot-sqs-consumer.yaml
|
|
||||||
|
|
||||||
---
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: iot-sqs-consumer
|
|
||||||
namespace: sless
|
|
||||||
labels:
|
|
||||||
app: iot-sqs-consumer
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: iot-sqs-consumer
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: iot-sqs-consumer
|
|
||||||
spec:
|
|
||||||
containers:
|
|
||||||
- name: sqs-consumer
|
|
||||||
# Тот же образ что и оператор — все IoT бинари в одном образе.
|
|
||||||
image: naeel/iot-operator:v0.2.6
|
|
||||||
imagePullPolicy: Always
|
|
||||||
command: ["/sqs-consumer"]
|
|
||||||
env:
|
|
||||||
- name: SQS_QUEUE_NAME
|
|
||||||
value: "iot-telemetry"
|
|
||||||
- name: SQS_REGION
|
|
||||||
value: "us-east-1"
|
|
||||||
envFrom:
|
|
||||||
# SQS_ENDPOINT, SQS_ACCESS_KEY, SQS_SECRET_KEY
|
|
||||||
- secretRef:
|
|
||||||
name: iot-sqs-credentials
|
|
||||||
# IOT_PG_DSN — master DSN для IoT Postgres (per-tenant DB)
|
|
||||||
- secretRef:
|
|
||||||
name: iot-postgres-secret
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
memory: "32Mi"
|
|
||||||
cpu: "25m"
|
|
||||||
limits:
|
|
||||||
memory: "64Mi"
|
|
||||||
cpu: "100m"
|
|
||||||
@@ -1,410 +0,0 @@
|
|||||||
# IoT REST API — Полная документация (v0.2.3)
|
|
||||||
|
|
||||||
> Дата: 2026-04-12
|
|
||||||
> Базовый URL: https://iot.kube5s.ru (через Ingress) или http://iot-operator.sless.svc:9090 (из кластера)
|
|
||||||
> Аутентификация: Bearer JWT в заголовке Authorization (authTestMode=true: любой непустой токен)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Содержание
|
|
||||||
|
|
||||||
1. [IoT Devices CRUD](#iot-devices-crud)
|
|
||||||
2. [IoT Telemetry](#iot-telemetry)
|
|
||||||
3. [MQTT Auth (internal)](#mqtt-auth-internal)
|
|
||||||
4. [MQTT ACL (internal)](#mqtt-acl-internal)
|
|
||||||
5. [Admin Stats](#admin-stats)
|
|
||||||
6. [UI Pages](#ui-pages)
|
|
||||||
7. [Коды ошибок](#коды-ошибок)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## IoT Devices CRUD
|
|
||||||
|
|
||||||
### POST /v1/namespaces/{namespace}/iot/devices — Создание устройства
|
|
||||||
|
|
||||||
Создаёт IoTDevice CRD. Контроллер асинхронно генерирует MQTT credentials (Secret).
|
|
||||||
|
|
||||||
**Headers:**
|
|
||||||
```
|
|
||||||
Authorization: Bearer <token>
|
|
||||||
Content-Type: application/json
|
|
||||||
```
|
|
||||||
|
|
||||||
**Request body:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "sensor-01",
|
|
||||||
"device_id": "sensor-01",
|
|
||||||
"enabled": true,
|
|
||||||
"metadata": {
|
|
||||||
"model": "DHT22",
|
|
||||||
"location": "room-1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Поле | Тип | Обязательное | Описание |
|
|
||||||
|------|-----|-------------|----------|
|
|
||||||
| name | string | да | Имя k8s объекта IoTDevice (уникальное в namespace) |
|
|
||||||
| device_id | string | да | ID устройства, pattern: ^[a-z0-9][a-z0-9-]*[a-z0-9]$ |
|
|
||||||
| enabled | bool | нет | default: true |
|
|
||||||
| metadata | map[string]string | нет | Произвольные метаданные |
|
|
||||||
|
|
||||||
**Response 201 Created:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "sensor-01",
|
|
||||||
"namespace": "tenant-abc",
|
|
||||||
"device_id": "sensor-01",
|
|
||||||
"enabled": true,
|
|
||||||
"phase": "",
|
|
||||||
"metadata": {"model": "DHT22", "location": "room-1"},
|
|
||||||
"created_at": "2026-04-12 14:30:00 UTC"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response 409 Conflict:** `{"error": "iot device already exists"}`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### GET /v1/namespaces/{namespace}/iot/devices — Список устройств
|
|
||||||
|
|
||||||
Возвращает все IoTDevice в namespace. **Пароли НЕ включены** (security by design).
|
|
||||||
|
|
||||||
**Headers:**
|
|
||||||
```
|
|
||||||
Authorization: Bearer <token>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response 200 OK:**
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"name": "sensor-01",
|
|
||||||
"namespace": "tenant-abc",
|
|
||||||
"device_id": "sensor-01",
|
|
||||||
"enabled": true,
|
|
||||||
"phase": "Active",
|
|
||||||
"mqtt_username": "tenant-abc_sensor-01",
|
|
||||||
"secret_name": "iot-sensor-01",
|
|
||||||
"topic_prefix": "tenant-abc/",
|
|
||||||
"metadata": {"model": "DHT22"},
|
|
||||||
"created_at": "2026-04-12 14:30:00 UTC"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### GET /v1/namespaces/{namespace}/iot/devices/{name} — Получение устройства
|
|
||||||
|
|
||||||
Возвращает устройство **включая mqtt_password** из Secret.
|
|
||||||
Используется для конфигурации физического устройства.
|
|
||||||
|
|
||||||
**Headers:**
|
|
||||||
```
|
|
||||||
Authorization: Bearer <token>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response 200 OK:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "sensor-01",
|
|
||||||
"namespace": "tenant-abc",
|
|
||||||
"device_id": "sensor-01",
|
|
||||||
"enabled": true,
|
|
||||||
"phase": "Active",
|
|
||||||
"mqtt_username": "tenant-abc_sensor-01",
|
|
||||||
"mqtt_password": "a1b2c3d4...hex64chars",
|
|
||||||
"secret_name": "iot-sensor-01",
|
|
||||||
"topic_prefix": "tenant-abc/",
|
|
||||||
"last_connected": "2026-04-12T14:35:00Z",
|
|
||||||
"metadata": {"model": "DHT22"},
|
|
||||||
"created_at": "2026-04-12 14:30:00 UTC"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response 404:** `{"error": "iot device not found"}`
|
|
||||||
|
|
||||||
> Примечание: mqtt_password будет пустым если Secret ещё не создан (phase=Pending).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### PATCH /v1/namespaces/{namespace}/iot/devices/{name} — Обновление устройства
|
|
||||||
|
|
||||||
Включает/отключает устройство.
|
|
||||||
|
|
||||||
**Headers:**
|
|
||||||
```
|
|
||||||
Authorization: Bearer <token>
|
|
||||||
Content-Type: application/json
|
|
||||||
```
|
|
||||||
|
|
||||||
**Request body:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"enabled": false
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Поле | Тип | Обязательное | Описание |
|
|
||||||
|------|-----|-------------|----------|
|
|
||||||
| enabled | bool | да | true=Active, false=Disabled |
|
|
||||||
|
|
||||||
**Response 200 OK:** полный объект устройства (без пароля).
|
|
||||||
|
|
||||||
**Response 404:** `{"error": "iot device not found"}`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### DELETE /v1/namespaces/{namespace}/iot/devices/{name} — Удаление устройства
|
|
||||||
|
|
||||||
Удаляет IoTDevice CRD. Контроллер через finalizer удаляет Secret каскадно.
|
|
||||||
|
|
||||||
**Headers:**
|
|
||||||
```
|
|
||||||
Authorization: Bearer <token>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response 204 No Content** (пустое тело)
|
|
||||||
|
|
||||||
**Response 404:** `{"error": "iot device not found"}`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## IoT Telemetry
|
|
||||||
|
|
||||||
### GET /v1/namespaces/{namespace}/iot/telemetry — Чтение телеметрии
|
|
||||||
|
|
||||||
Возвращает записи телеметрии из per-tenant Postgres.
|
|
||||||
|
|
||||||
**Headers:**
|
|
||||||
```
|
|
||||||
Authorization: Bearer <token>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Query parameters:**
|
|
||||||
|
|
||||||
| Параметр | Тип | Default | Описание |
|
|
||||||
|----------|-----|---------|----------|
|
|
||||||
| device | string | (все) | Фильтр по device_id |
|
|
||||||
| limit | int | 50 | Макс. кол-во записей (max 1000) |
|
|
||||||
|
|
||||||
**Response 200 OK:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"device_id": "sensor-01",
|
|
||||||
"payload": {"temperature": 22.5, "humidity": 65},
|
|
||||||
"received_at": "2026-04-12T14:35:00Z",
|
|
||||||
"created_at": "2026-04-12T14:35:01Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response 503:** `{"error": "IoT telemetry storage not configured"}` (IOT_PG_DSN не задан)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## MQTT Auth (internal)
|
|
||||||
|
|
||||||
### POST /internal/mqtt/auth — Аутентификация MQTT клиента
|
|
||||||
|
|
||||||
Вызывается EMQX при каждом MQTT CONNECT. **Без JWT.** Доступен только из кластера.
|
|
||||||
|
|
||||||
**Request body (от EMQX):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"username": "tenant-abc_sensor-01",
|
|
||||||
"password": "a1b2c3d4...hex64chars",
|
|
||||||
"clientid": "mqtt-client-123",
|
|
||||||
"peerhost": "10.0.1.5"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Логика:**
|
|
||||||
1. Если username == BridgeUsername → проверить BridgePassword (constant-time) → allow/deny
|
|
||||||
2. Парсить username по первому "_" → namespace + deviceId
|
|
||||||
3. Найти Secret `iot-{deviceId}` в namespace
|
|
||||||
4. `crypto/subtle.ConstantTimeCompare(password, secret["mqtt-password"])`
|
|
||||||
5. Проверить IoTDevice существует и enabled=true
|
|
||||||
6. Обновить status.lastConnected (best-effort)
|
|
||||||
7. Вернуть ACL правила для клиента
|
|
||||||
|
|
||||||
**Response 200 (allow с ACL):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"result": "allow",
|
|
||||||
"acl": [
|
|
||||||
{"permission": "allow", "action": "publish", "topic": "tenant-abc/telemetry/sensor-01"},
|
|
||||||
{"permission": "allow", "action": "subscribe", "topic": "tenant-abc/telemetry/sensor-01"},
|
|
||||||
{"permission": "deny", "action": "all", "topic": "#"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response 200 (bridge allow):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"result": "allow"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response 200 (deny):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"result": "deny"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> Всегда HTTP 200. EMQX игнорирует non-200 ответы.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## MQTT ACL (internal)
|
|
||||||
|
|
||||||
### POST /internal/mqtt/acl — Авторизация pub/sub
|
|
||||||
|
|
||||||
Вызывается EMQX для каждого publish/subscribe. **Без JWT.**
|
|
||||||
|
|
||||||
**Request body:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"username": "tenant-abc_sensor-01",
|
|
||||||
"clientid": "mqtt-client-123",
|
|
||||||
"action": "publish",
|
|
||||||
"topic": "tenant-abc/telemetry/sensor-01"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Логика:**
|
|
||||||
- Bridge (clientid=sless-iot-bridge): только subscribe → allow. Publish → deny.
|
|
||||||
- Device: action на topic `{ns}/telemetry/{deviceId}` → allow. Всё остальное → deny.
|
|
||||||
|
|
||||||
**Response 200:** `{"result": "allow"}` или `{"result": "deny"}`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Admin Stats
|
|
||||||
|
|
||||||
### GET /iot-admin/stats — Статистика администратора
|
|
||||||
|
|
||||||
Защищён токеном ADMIN_STATS_TOKEN (env). Не проходит через JWT middleware.
|
|
||||||
|
|
||||||
**Headers:**
|
|
||||||
```
|
|
||||||
Authorization: Bearer <ADMIN_STATS_TOKEN>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response 200 OK:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"collected_at": "2026-04-12T14:40:00Z",
|
|
||||||
"postgres": {
|
|
||||||
"reachable": true,
|
|
||||||
"tenants": [
|
|
||||||
{
|
|
||||||
"namespace": "tenant-abc",
|
|
||||||
"total_count": 150,
|
|
||||||
"last_1h_count": 42,
|
|
||||||
"last_24h_count": 130,
|
|
||||||
"latest_rows": [...]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"sqs": {
|
|
||||||
"approximate_messages": 5,
|
|
||||||
"approximate_messages_not_visible": 2
|
|
||||||
},
|
|
||||||
"pods": {
|
|
||||||
"iot-mqtt-bridge": {
|
|
||||||
"name": "iot-mqtt-bridge-xxx",
|
|
||||||
"phase": "Running",
|
|
||||||
"ready": true,
|
|
||||||
"restarts": 0,
|
|
||||||
"age": "3h"
|
|
||||||
},
|
|
||||||
"iot-sqs-consumer": {
|
|
||||||
"name": "iot-sqs-consumer-yyy",
|
|
||||||
"phase": "Running",
|
|
||||||
"ready": true,
|
|
||||||
"restarts": 0,
|
|
||||||
"age": "3h"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response 401:** `{"error": "unauthorized"}`
|
|
||||||
**Response 503:** `{"error": "admin stats not configured: ADMIN_STATS_TOKEN not set"}`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## UI Pages
|
|
||||||
|
|
||||||
### GET /console — IoT Консоль
|
|
||||||
|
|
||||||
HTML-страница (go:embed) для управления устройствами и просмотра телеметрии.
|
|
||||||
Включает MQTT WebSocket клиент для реального времени.
|
|
||||||
|
|
||||||
### GET /iot-admin — IoT Admin Panel
|
|
||||||
|
|
||||||
HTML-страница (go:embed) администратора с графиками и мониторингом.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Коды ошибок
|
|
||||||
|
|
||||||
| Код | Значение | Когда |
|
|
||||||
|-----|---------|-------|
|
|
||||||
| 200 | OK | Успешные GET, PATCH, MQTT auth/acl |
|
|
||||||
| 201 | Created | Успешный POST (создание устройства) |
|
|
||||||
| 204 | No Content | Успешный DELETE |
|
|
||||||
| 400 | Bad Request | Невалидный JSON, отсутствуют обязательные поля |
|
|
||||||
| 401 | Unauthorized | Невалидный/отсутствующий Bearer token |
|
|
||||||
| 404 | Not Found | Устройство не найдено |
|
|
||||||
| 409 | Conflict | Устройство уже существует |
|
|
||||||
| 500 | Internal Server Error | Ошибка k8s API или БД |
|
|
||||||
| 503 | Service Unavailable | IoTPG не сконфигурирован или AdminToken не задан |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Curl примеры
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Создать устройство
|
|
||||||
curl -X POST https://iot.kube5s.ru/v1/namespaces/test-ns/iot/devices \
|
|
||||||
-H "Authorization: Bearer test-token" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"name":"sensor-01","device_id":"sensor-01","enabled":true}'
|
|
||||||
|
|
||||||
# Список устройств
|
|
||||||
curl https://iot.kube5s.ru/v1/namespaces/test-ns/iot/devices \
|
|
||||||
-H "Authorization: Bearer test-token"
|
|
||||||
|
|
||||||
# Получить устройство с паролем
|
|
||||||
curl https://iot.kube5s.ru/v1/namespaces/test-ns/iot/devices/sensor-01 \
|
|
||||||
-H "Authorization: Bearer test-token"
|
|
||||||
|
|
||||||
# Включить/отключить
|
|
||||||
curl -X PATCH https://iot.kube5s.ru/v1/namespaces/test-ns/iot/devices/sensor-01 \
|
|
||||||
-H "Authorization: Bearer test-token" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"enabled":false}'
|
|
||||||
|
|
||||||
# Удалить
|
|
||||||
curl -X DELETE https://iot.kube5s.ru/v1/namespaces/test-ns/iot/devices/sensor-01 \
|
|
||||||
-H "Authorization: Bearer test-token"
|
|
||||||
|
|
||||||
# Телеметрия (последние 100)
|
|
||||||
curl "https://iot.kube5s.ru/v1/namespaces/test-ns/iot/telemetry?limit=100" \
|
|
||||||
-H "Authorization: Bearer test-token"
|
|
||||||
|
|
||||||
# Телеметрия по устройству
|
|
||||||
curl "https://iot.kube5s.ru/v1/namespaces/test-ns/iot/telemetry?device=sensor-01&limit=50" \
|
|
||||||
-H "Authorization: Bearer test-token"
|
|
||||||
```
|
|
||||||
@@ -30,25 +30,3 @@
|
|||||||
| Метод | Путь | Описание |
|
| Метод | Путь | Описание |
|
||||||
|-------|------|----------|
|
|-------|------|----------|
|
||||||
| GET | `/iot-admin/stats` | JSON статистика (PG, Kafka lag, pods) |
|
| GET | `/iot-admin/stats` | JSON статистика (PG, Kafka lag, pods) |
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Обновление 2026-04-12: admin stats → SQS
|
|
||||||
|
|
||||||
> Kafka lag заменён на SQS queue stats. Endpoint тот же, формат ответа изменён.
|
|
||||||
|
|
||||||
### `/iot-admin/stats` — актуальный формат ответа
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"sqs": {
|
|
||||||
"queue_url": "http://us-east-1.goaws.com:4100/t-96afe7e9f781f6ca/iot-telemetry",
|
|
||||||
"approximate_messages": "0",
|
|
||||||
"approximate_messages_not_visible": "0"
|
|
||||||
},
|
|
||||||
"sqs_consumer_pods": [...],
|
|
||||||
"pg_tenants": [...]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Вместо `kafka_lag` теперь `sqs.approximate_messages` — количество сообщений в очереди, ожидающих обработки.
|
|
||||||
|
|||||||
@@ -1,256 +0,0 @@
|
|||||||
# IoT Managed Service — Актуальная архитектура (v0.2.3)
|
|
||||||
|
|
||||||
> Дата: 2026-04-12
|
|
||||||
> Образ: naeel/iot-operator:v0.2.3
|
|
||||||
> Кластер: iot-naeel, namespace: sless
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Путь данных (data flow)
|
|
||||||
|
|
||||||
```
|
|
||||||
IoT Device (MQTT CONNECT)
|
|
||||||
| username="{ns}_{deviceId}", password=hex(64)
|
|
||||||
v
|
|
||||||
EMQX 5.5.1 (Pod emqx, namespace sless)
|
|
||||||
| 1. POST /internal/mqtt/auth -> iot-operator:9090
|
|
||||||
| - Bridge auth: username=iot-bridge-internal -> allow
|
|
||||||
| - Device auth: username={ns}_{deviceId} -> Secret -> compare
|
|
||||||
| 2. POST /internal/mqtt/acl -> iot-operator:9090
|
|
||||||
| - Device: pub/sub только {ns}/telemetry/{deviceId}
|
|
||||||
| - Bridge: sub на +/telemetry/+ (wildcard)
|
|
||||||
v
|
|
||||||
| MQTT PUBLISH -> topic: "{ns}/telemetry/{deviceId}"
|
|
||||||
v
|
|
||||||
iot-mqtt-bridge (Pod, namespace sless)
|
|
||||||
| paho.mqtt.golang, подписка на "+/telemetry/+"
|
|
||||||
| Парсит topic -> namespace (segment 0), deviceId (segment 2)
|
|
||||||
| AWS SDK SQS SendMessage -> shared-SQS
|
|
||||||
v
|
|
||||||
shared-SQS (namespace shared-sqs)
|
|
||||||
| Endpoint: https://qu.kube5s.ru
|
|
||||||
| Tenant: iot-service (ID: t-96afe7e9f781f6ca)
|
|
||||||
| Queue: iot-telemetry
|
|
||||||
v
|
|
||||||
iot-sqs-consumer (Pod, namespace sless)
|
|
||||||
| ReceiveMessage (WaitTimeSeconds=20, long polling)
|
|
||||||
| Парсит envelope -> namespace, device_id, payload
|
|
||||||
| EnsureTenantDB(namespace) -> CREATE DATABASE tenant_{ns}
|
|
||||||
| INSERT INTO iot_telemetry
|
|
||||||
| DeleteMessage (at-least-once)
|
|
||||||
v
|
|
||||||
Managed PostgreSQL 17
|
|
||||||
| Host: postgresqlk8s-master.dc5db45d-....svc.cluster.local
|
|
||||||
| Per-tenant: DATABASE tenant_{namespace}
|
|
||||||
| Таблица: iot_telemetry (id, device_id, payload JSONB, received_at, created_at)
|
|
||||||
v
|
|
||||||
REST API (iot-operator:9090)
|
|
||||||
| GET /v1/namespaces/{ns}/iot/telemetry?device=X&limit=N
|
|
||||||
v
|
|
||||||
Пользователь
|
|
||||||
| IoT Console: https://iot.kube5s.ru/console
|
|
||||||
| WebSocket MQTT: wss://iot.kube5s.ru/mqtt
|
|
||||||
| Terraform: sless_iot_device resource
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Компоненты
|
|
||||||
|
|
||||||
### iot-operator (cmd/iot-operator)
|
|
||||||
|
|
||||||
Единый бинарник: controller-manager + REST API сервер.
|
|
||||||
|
|
||||||
| Функция | Описание |
|
|
||||||
|---------|----------|
|
|
||||||
| IoTDevice Controller | Reconcile: создание Secret с MQTT credentials, OwnerReference |
|
|
||||||
| REST API :9090 | CRUD устройств, телеметрия, MQTT auth/acl, admin stats, UI |
|
|
||||||
| Health :8081 | /healthz, /readyz для k8s probes |
|
|
||||||
|
|
||||||
### iot-mqtt-bridge (cmd/mqtt-bridge)
|
|
||||||
|
|
||||||
MQTT subscriber -> SQS producer. Stateless.
|
|
||||||
|
|
||||||
| Параметр | Значение |
|
|
||||||
|----------|----------|
|
|
||||||
| MQTT broker | tcp://emqx.sless.svc:1883 |
|
|
||||||
| MQTT username | iot-bridge-internal (Secret iot-bridge-credentials) |
|
|
||||||
| MQTT subscription | +/telemetry/+ |
|
|
||||||
| SQS endpoint | https://qu.kube5s.ru |
|
|
||||||
| SQS queue | iot-telemetry |
|
|
||||||
|
|
||||||
Bridge auth (v0.2.3): operator проверяет BridgeUsername/BridgePassword ДО парсинга namespace_deviceId.
|
|
||||||
|
|
||||||
### iot-sqs-consumer (cmd/sqs-consumer)
|
|
||||||
|
|
||||||
SQS consumer -> Postgres writer. Stateless.
|
|
||||||
|
|
||||||
| Параметр | Значение |
|
|
||||||
|----------|----------|
|
|
||||||
| SQS endpoint | https://qu.kube5s.ru |
|
|
||||||
| SQS queue | iot-telemetry |
|
|
||||||
| Long polling | WaitTimeSeconds=20 |
|
|
||||||
| Postgres | IOT_PG_DSN из Secret |
|
|
||||||
| Семантика | at-least-once (DeleteMessage после INSERT) |
|
|
||||||
|
|
||||||
### EMQX 5.5.1
|
|
||||||
|
|
||||||
MQTT-брокер с HTTP auth backend.
|
|
||||||
|
|
||||||
| Параметр | Значение |
|
|
||||||
|----------|----------|
|
|
||||||
| Образ | emqx/emqx:5.5.1 |
|
|
||||||
| Порты | 1883 (MQTT), 8083 (WebSocket), 18083 (Dashboard) |
|
|
||||||
| Auth | HTTP POST -> iot-operator:9090/internal/mqtt/auth |
|
|
||||||
| ACL | HTTP POST -> iot-operator:9090/internal/mqtt/acl |
|
|
||||||
| Конфиг | HOCON emqx.conf через ConfigMap |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## CRD: IoTDevice (iot.kube5s.ru/v1alpha1)
|
|
||||||
|
|
||||||
### Spec
|
|
||||||
|
|
||||||
| Поле | Тип | Обязательное | Описание |
|
|
||||||
|------|-----|-------------|----------|
|
|
||||||
| deviceId | string | да | Pattern: ^[a-z0-9][a-z0-9-]*[a-z0-9]$ |
|
|
||||||
| enabled | bool | нет | default: true |
|
|
||||||
| metadata | map[string]string | нет | Произвольные метаданные |
|
|
||||||
|
|
||||||
### Status
|
|
||||||
|
|
||||||
| Поле | Описание |
|
|
||||||
|------|----------|
|
|
||||||
| phase | Active / Disabled / Pending / Error |
|
|
||||||
| mqttUsername | {namespace}_{deviceId} |
|
|
||||||
| secretName | iot-{deviceId} |
|
|
||||||
| topicPrefix | {namespace}/ |
|
|
||||||
| message | Сообщение об ошибке |
|
|
||||||
|
|
||||||
### Reconcile logic
|
|
||||||
|
|
||||||
1. Добавить finalizer iot.kube5s.ru/device-cleanup
|
|
||||||
2. Если Secret iot-{deviceId} не существует:
|
|
||||||
- crypto/rand 32 bytes -> hex (64 символа) = пароль
|
|
||||||
- Создать Secret с OwnerReference -> каскадное удаление
|
|
||||||
- Keys: mqtt-username, mqtt-password, device-id
|
|
||||||
3. Status: phase=Active, mqttUsername={ns}_{deviceId}
|
|
||||||
4. Если enabled=false -> phase=Disabled (Secret НЕ удаляется)
|
|
||||||
5. DELETE: finalizer cleanup -> Secret удаляется каскадно
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Аутентификация
|
|
||||||
|
|
||||||
### REST API (/v1/)
|
|
||||||
|
|
||||||
- Middleware: Bearer JWT token
|
|
||||||
- authTestMode = true (текущий): любой непустой Bearer token принимается
|
|
||||||
- authTestMode = false (prod): JWT decode -> sub -> SHA256 -> namespace mapping
|
|
||||||
|
|
||||||
### MQTT Auth (/internal/mqtt/auth)
|
|
||||||
|
|
||||||
1. Если username == BridgeUsername -> проверить BridgePassword -> allow/deny
|
|
||||||
2. Иначе: парсить username по первому "_" -> namespace + deviceId
|
|
||||||
3. Найти Secret iot-{deviceId} в namespace
|
|
||||||
4. crypto/subtle.ConstantTimeCompare(password, secret.mqtt-password)
|
|
||||||
5. Всегда HTTP 200, body: {"result": "allow"} или {"result": "deny"}
|
|
||||||
|
|
||||||
### MQTT ACL (/internal/mqtt/acl)
|
|
||||||
|
|
||||||
- Bridge (clientid=sless-iot-bridge): allow subscribe +/telemetry/+
|
|
||||||
- Device: allow pub/sub только {namespace}/telemetry/{deviceId}
|
|
||||||
- Всё остальное: deny
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Хранение данных
|
|
||||||
|
|
||||||
### PostgreSQL (managed)
|
|
||||||
|
|
||||||
| Параметр | Значение |
|
|
||||||
|----------|----------|
|
|
||||||
| Версия | PG 17 |
|
|
||||||
| Host | postgresqlk8s-master.dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5.svc.cluster.local |
|
|
||||||
| User | super |
|
|
||||||
| Master DB | sqsdb |
|
|
||||||
|
|
||||||
Per-tenant изоляция: отдельная DATABASE tenant_{namespace}.
|
|
||||||
|
|
||||||
Таблица iot_telemetry:
|
|
||||||
- id SERIAL PRIMARY KEY
|
|
||||||
- device_id TEXT NOT NULL
|
|
||||||
- payload JSONB NOT NULL
|
|
||||||
- received_at TIMESTAMPTZ
|
|
||||||
- created_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
|
|
||||||
### shared-SQS
|
|
||||||
|
|
||||||
| Параметр | Значение |
|
|
||||||
|----------|----------|
|
|
||||||
| Endpoint | https://qu.kube5s.ru |
|
|
||||||
| Tenant | iot-service (t-96afe7e9f781f6ca) |
|
|
||||||
| Queue | iot-telemetry |
|
|
||||||
| Протокол | AWS SQS API compatible |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Docker образ
|
|
||||||
|
|
||||||
- Registry: Docker Hub naeel/iot-operator
|
|
||||||
- Базовый: gcr.io/distroless/static:nonroot
|
|
||||||
- Содержит 3 бинарника: /iot-operator, /mqtt-bridge, /sqs-consumer
|
|
||||||
- Выбор бинарника через command в Deployment YAML
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Сетевая схема
|
|
||||||
|
|
||||||
```
|
|
||||||
Internet
|
|
||||||
|
|
|
||||||
v
|
|
||||||
nginx-ingress (namespace ingress)
|
|
||||||
| iot.kube5s.ru/mqtt -> emqx-ws:8083 (WebSocket)
|
|
||||||
| iot.kube5s.ru/console -> iot-operator:9090 (UI)
|
|
||||||
| iot.kube5s.ru/iot-admin -> iot-operator:9090 (Admin UI)
|
|
||||||
v
|
|
||||||
namespace sless:
|
|
||||||
emqx:1883 <-> iot-mqtt-bridge (MQTT)
|
|
||||||
emqx:1883 <- IoT devices (MQTT)
|
|
||||||
iot-operator:9090 <- emqx (auth/acl HTTP)
|
|
||||||
iot-operator:9090 <- users (REST API)
|
|
||||||
iot-mqtt-bridge -> shared-sqs (HTTPS, SQS API)
|
|
||||||
iot-sqs-consumer <- shared-sqs (HTTPS, SQS API)
|
|
||||||
iot-sqs-consumer -> managed-postgres (TCP 5432)
|
|
||||||
iot-operator -> managed-postgres (TCP 5432, telemetry GET)
|
|
||||||
iot-operator -> k8s API (CRD watch, Secret CRUD)
|
|
||||||
|
|
||||||
namespace shared-sqs:
|
|
||||||
shared-sqs:9324 (SQS API)
|
|
||||||
Ingress: qu.kube5s.ru -> shared-sqs
|
|
||||||
|
|
||||||
namespace dc5db45d-...:
|
|
||||||
postgresqlk8s-0 (PG 17 managed)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Секреты (namespace sless)
|
|
||||||
|
|
||||||
| Secret | Ключи | Используется |
|
|
||||||
|--------|-------|-------------|
|
|
||||||
| iot-bridge-credentials | MQTT_USERNAME, MQTT_PASSWORD | iot-operator, iot-mqtt-bridge |
|
|
||||||
| iot-sqs-credentials | SQS_ENDPOINT, SQS_ACCESS_KEY, SQS_SECRET_KEY | iot-mqtt-bridge, iot-sqs-consumer |
|
|
||||||
| iot-postgres-secret | IOT_PG_DSN | iot-sqs-consumer |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Эволюция архитектуры
|
|
||||||
|
|
||||||
| Версия | Дата | Message bus | Postgres | Кластер |
|
|
||||||
|--------|------|------------|----------|---------|
|
|
||||||
| v0.1.50 | 2026-04-04 | RabbitMQ (AMQP) | emptyDir PVC | sless (общий) |
|
|
||||||
| v0.1.68 | 2026-04-06 | Kafka (segmentio/kafka-go) | emptyDir PVC | sless (общий) |
|
|
||||||
| v0.2.0 | 2026-04-12 | shared-SQS (AWS SDK) | Managed PG 17 | iot-naeel (новый) |
|
|
||||||
| v0.2.3 | 2026-04-12 | shared-SQS (AWS SDK) | Managed PG 17 | iot-naeel |
|
|
||||||
@@ -64,171 +64,3 @@ REST API (iot-operator:9090)
|
|||||||
- `naeel/iot-operator` (Docker Hub)
|
- `naeel/iot-operator` (Docker Hub)
|
||||||
- Все 3 бинарника в одном образе
|
- Все 3 бинарника в одном образе
|
||||||
- `command: ["/iot-operator"]` или `["/mqtt-bridge"]` или `["/kafka-consumer"]`
|
- `command: ["/iot-operator"]` или `["/mqtt-bridge"]` или `["/kafka-consumer"]`
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Актуальная архитектура (2026-04-12)
|
|
||||||
|
|
||||||
> Kafka заменён на shared-SQS. Postgres заменён на managed. Новый кластер iot-naeel.
|
|
||||||
|
|
||||||
### Текущая схема
|
|
||||||
|
|
||||||
```
|
|
||||||
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/+"
|
|
||||||
│ → AWS SDK SQS SendMessage → shared-SQS (https://qu.kube5s.ru)
|
|
||||||
│ очередь: "iot-telemetry" (tenant: iot-service, id: t-96afe7e9f781f6ca)
|
|
||||||
▼
|
|
||||||
shared-SQS (namespace shared-sqs)
|
|
||||||
│ AWS SQS-совместимый, multi-tenant
|
|
||||||
▼
|
|
||||||
iot-sqs-consumer (cmd/sqs-consumer)
|
|
||||||
│ ReceiveMessage (WaitTimeSeconds=20, long polling)
|
|
||||||
│ DeleteMessage после успешной записи (at-least-once)
|
|
||||||
│ → per-tenant Postgres DB
|
|
||||||
▼
|
|
||||||
Managed PostgreSQL 17 (namespace dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5)
|
|
||||||
│ Host: postgresqlk8s-master.dc5db45d-....svc.cluster.local
|
|
||||||
│ User: super, DB: sqsdb
|
|
||||||
│ Per-tenant DB: tenant_{namespace}, Table: iot_telemetry
|
|
||||||
▼
|
|
||||||
REST API (iot-operator:9090)
|
|
||||||
│ GET /v1/namespaces/{ns}/iot/telemetry
|
|
||||||
▼
|
|
||||||
Пользователь (Terraform / UI Console)
|
|
||||||
│ wss://iot.kube5s.ru/mqtt (WebSocket через Ingress)
|
|
||||||
│ https://iot.kube5s.ru/console (UI)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Компоненты (актуальные)
|
|
||||||
|
|
||||||
| Компонент | Бинарник | Порт | Назначение |
|
|
||||||
|-----------|----------|------|------------|
|
|
||||||
| iot-operator | cmd/iot-operator | :9090 (API), :8080 (metrics), :8081 (health) | Controller-manager + REST API |
|
|
||||||
| mqtt-bridge | cmd/mqtt-bridge | — | MQTT → shared-SQS bridge |
|
|
||||||
| sqs-consumer | cmd/sqs-consumer | — | shared-SQS → Postgres pipeline |
|
|
||||||
|
|
||||||
### Инфраструктура
|
|
||||||
|
|
||||||
| Сервис | Тип | Namespace | Описание |
|
|
||||||
|--------|-----|-----------|----------|
|
|
||||||
| EMQX 5.5.1 | В кластере | sless | MQTT-брокер, HTTP auth/acl → iot-operator |
|
|
||||||
| shared-SQS | В кластере | shared-sqs | AWS SQS-совместимая очередь, endpoint https://qu.kube5s.ru |
|
|
||||||
| PostgreSQL 17 | Managed (оператор) | dc5db45d-... | per-tenant DB, тот же инстанс что и SQS billing |
|
|
||||||
| cert-manager | В кластере | cert-manager | TLS сертификаты для iot.kube5s.ru |
|
|
||||||
| nginx-ingress | В кластере | ingress | WSS/HTTPS проксирование |
|
|
||||||
|
|
||||||
### Секреты в namespace sless
|
|
||||||
|
|
||||||
| Secret | Ключи | Источник |
|
|
||||||
|--------|-------|----------|
|
|
||||||
| iot-sqs-credentials | SQS_ENDPOINT, SQS_ACCESS_KEY, SQS_SECRET_KEY | shared-SQS tenant iot-service |
|
|
||||||
| iot-postgres-secret | POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB, IOT_PG_DSN | Managed PG |
|
|
||||||
| iot-bridge-credentials | MQTT_USERNAME, MQTT_PASSWORD | Для mqtt-bridge → EMQX |
|
|
||||||
|
|
||||||
### Docker образ (актуальный)
|
|
||||||
|
|
||||||
- `naeel/iot-operator:v0.2.0` (Docker Hub)
|
|
||||||
- Все 3 бинарника в одном образе
|
|
||||||
- `command: ["/iot-operator"]` или `["/mqtt-bridge"]` или `["/sqs-consumer"]`
|
|
||||||
- Базовый: `gcr.io/distroless/static:nonroot`
|
|
||||||
|
|
||||||
### Кластер
|
|
||||||
|
|
||||||
- Имя: `iot-naeel`
|
|
||||||
- API: `https://185.247.187.149:6443`
|
|
||||||
- Ingress IP: `185.247.187.151`
|
|
||||||
- DNS: `iot.kube5s.ru → 185.247.187.147`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Актуальная архитектура (2026-04-12)
|
|
||||||
|
|
||||||
> Kafka заменён на shared-SQS. Postgres заменён на managed. Новый кластер iot-naeel.
|
|
||||||
|
|
||||||
### Текущая схема
|
|
||||||
|
|
||||||
```
|
|
||||||
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/+"
|
|
||||||
│ → AWS SDK SQS SendMessage → shared-SQS (https://qu.kube5s.ru)
|
|
||||||
│ очередь: "iot-telemetry" (tenant: iot-service, id: t-96afe7e9f781f6ca)
|
|
||||||
▼
|
|
||||||
shared-SQS (namespace shared-sqs)
|
|
||||||
│ AWS SQS-совместимый, multi-tenant
|
|
||||||
▼
|
|
||||||
iot-sqs-consumer (cmd/sqs-consumer)
|
|
||||||
│ ReceiveMessage (WaitTimeSeconds=20, long polling)
|
|
||||||
│ DeleteMessage после успешной записи (at-least-once)
|
|
||||||
│ → per-tenant Postgres DB
|
|
||||||
▼
|
|
||||||
Managed PostgreSQL 17 (namespace dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5)
|
|
||||||
│ Host: postgresqlk8s-master.dc5db45d-....svc.cluster.local
|
|
||||||
│ User: super, DB: sqsdb
|
|
||||||
│ Per-tenant DB: tenant_{namespace}, Table: iot_telemetry
|
|
||||||
▼
|
|
||||||
REST API (iot-operator:9090)
|
|
||||||
│ GET /v1/namespaces/{ns}/iot/telemetry
|
|
||||||
▼
|
|
||||||
Пользователь (Terraform / UI Console)
|
|
||||||
│ wss://iot.kube5s.ru/mqtt (WebSocket через Ingress)
|
|
||||||
│ https://iot.kube5s.ru/console (UI)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Компоненты (актуальные)
|
|
||||||
|
|
||||||
| Компонент | Бинарник | Порт | Назначение |
|
|
||||||
|-----------|----------|------|------------|
|
|
||||||
| iot-operator | cmd/iot-operator | :9090 (API), :8080 (metrics), :8081 (health) | Controller-manager + REST API |
|
|
||||||
| mqtt-bridge | cmd/mqtt-bridge | — | MQTT → shared-SQS bridge |
|
|
||||||
| sqs-consumer | cmd/sqs-consumer | — | shared-SQS → Postgres pipeline |
|
|
||||||
|
|
||||||
### Инфраструктура
|
|
||||||
|
|
||||||
| Сервис | Тип | Namespace | Описание |
|
|
||||||
|--------|-----|-----------|----------|
|
|
||||||
| EMQX 5.5.1 | В кластере | sless | MQTT-брокер, HTTP auth/acl → iot-operator |
|
|
||||||
| shared-SQS | В кластере | shared-sqs | AWS SQS-совместимая очередь, endpoint https://qu.kube5s.ru |
|
|
||||||
| PostgreSQL 17 | Managed (оператор) | dc5db45d-... | per-tenant DB, тот же инстанс что и SQS billing |
|
|
||||||
| cert-manager | В кластере | cert-manager | TLS сертификаты для iot.kube5s.ru |
|
|
||||||
| nginx-ingress | В кластере | ingress | WSS/HTTPS проксирование |
|
|
||||||
|
|
||||||
### Секреты в namespace sless
|
|
||||||
|
|
||||||
| Secret | Ключи | Источник |
|
|
||||||
|--------|-------|----------|
|
|
||||||
| iot-sqs-credentials | SQS_ENDPOINT, SQS_ACCESS_KEY, SQS_SECRET_KEY | shared-SQS tenant iot-service |
|
|
||||||
| iot-postgres-secret | POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB, IOT_PG_DSN | Managed PG |
|
|
||||||
| iot-bridge-credentials | MQTT_USERNAME, MQTT_PASSWORD | Для mqtt-bridge → EMQX |
|
|
||||||
|
|
||||||
### Docker образ (актуальный)
|
|
||||||
|
|
||||||
- `naeel/iot-operator:v0.2.0` (Docker Hub)
|
|
||||||
- Все 3 бинарника в одном образе
|
|
||||||
- `command: ["/iot-operator"]` или `["/mqtt-bridge"]` или `["/sqs-consumer"]`
|
|
||||||
- Базовый: `gcr.io/distroless/static:nonroot`
|
|
||||||
|
|
||||||
### Кластер
|
|
||||||
|
|
||||||
- Имя: `iot-naeel`
|
|
||||||
- API: `https://185.247.187.149:6443`
|
|
||||||
- Ingress IP: `185.247.187.151`
|
|
||||||
- DNS: `iot.kube5s.ru → 185.247.187.147`
|
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
# Решение: замена Kafka на shared-SQS
|
|
||||||
|
|
||||||
**Дата:** 2026-04-12
|
|
||||||
**Статус:** в работе
|
|
||||||
**Ветка:** `feature/replace-kafka-with-sqs`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Контекст
|
|
||||||
|
|
||||||
IoT-сервис использует Kafka (библиотека `segmentio/kafka-go`) как промежуточную очередь между MQTT-bridge и Postgres consumer.
|
|
||||||
Kafka — тяжёлый компонент: требует отдельный деплоймент в кластере (`deployments/k8s/kafka.yaml`), ZooKeeper/KRaft, настройку топиков, партиций.
|
|
||||||
|
|
||||||
У нас есть собственный сервис **shared-SQS** — AWS SQS-совместимая очередь сообщений:
|
|
||||||
- Репа: https://gitea.services.ngcloud.ru/Nail/shared-SQS
|
|
||||||
- Endpoint: `https://qu.kube5s.ru`
|
|
||||||
- 17 SQS-операций, multi-tenant, Redis persistence, billing
|
|
||||||
- Работает через стандартные AWS SDK
|
|
||||||
|
|
||||||
RabbitMQ в коде IoT **не используется** (только в старых документах как план MVP).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Решение
|
|
||||||
|
|
||||||
Заменить Kafka → shared-SQS во всём IoT pipeline.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Что меняется
|
|
||||||
|
|
||||||
### 1. mqtt-bridge (`cmd/mqtt-bridge/main.go`)
|
|
||||||
- **Было:** `kafka.Writer` → `WriteMessages()` в топик `iot.telemetry`
|
|
||||||
- **Стало:** AWS SDK Go v2 → `sqs.SendMessage()` в очередь `iot-telemetry`
|
|
||||||
- Env: `KAFKA_BROKERS` → `SQS_ENDPOINT`, `SQS_ACCESS_KEY`, `SQS_SECRET_KEY`, `SQS_QUEUE_NAME`
|
|
||||||
|
|
||||||
### 2. kafka-consumer → sqs-consumer (`cmd/kafka-consumer/` → `cmd/sqs-consumer/`)
|
|
||||||
- **Было:** `kafka.NewReader` с consumer group, `FetchMessage()` + `CommitMessages()`
|
|
||||||
- **Стало:** polling loop: `sqs.ReceiveMessage(WaitTimeSeconds=20)` + `sqs.DeleteMessage()`
|
|
||||||
- Переименовать директорию и бинарник
|
|
||||||
|
|
||||||
### 3. admin stats handler (`internal/api/handler/iot_admin_stats_handler.go`)
|
|
||||||
- **Было:** Kafka lag (ListOffsets + OffsetFetch)
|
|
||||||
- **Стало:** `sqs.GetQueueAttributes(ApproximateNumberOfMessages, ApproximateNumberOfMessagesNotVisible)`
|
|
||||||
- Env: `KAFKA_BROKERS` → SQS credentials в handler
|
|
||||||
|
|
||||||
### 4. go.mod
|
|
||||||
- Убрать: `github.com/segmentio/kafka-go`
|
|
||||||
- Добавить: `github.com/aws/aws-sdk-go-v2`, `github.com/aws/aws-sdk-go-v2/service/sqs`
|
|
||||||
|
|
||||||
### 5. Deployments
|
|
||||||
- Удалить: `deployments/k8s/kafka.yaml`
|
|
||||||
- Обновить: `deployments/k8s/iot-mqtt-bridge.yaml` — новые env vars
|
|
||||||
- Обновить: `deployments/k8s/iot-kafka-consumer.yaml` → `iot-sqs-consumer.yaml`
|
|
||||||
|
|
||||||
### 6. Dockerfile / Makefile
|
|
||||||
- Переименовать бинарник `kafka-consumer` → `sqs-consumer`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Плюсы
|
|
||||||
|
|
||||||
- Убираем Kafka из кластера (экономия ресурсов)
|
|
||||||
- Используем свой managed сервис (единая инфраструктура)
|
|
||||||
- AWS SDK — стандартная библиотека, код проще
|
|
||||||
- Billing и мониторинг из коробки в shared-SQS
|
|
||||||
|
|
||||||
## Риски
|
|
||||||
|
|
||||||
- SQS — pull-based (polling latency до 20s long poll vs Kafka push). Для IoT телеметрии приемлемо.
|
|
||||||
- At-least-once delivery — нужно учитывать idempotency (уже есть в текущем коде: INSERT ON CONFLICT)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## SQS endpoint
|
|
||||||
|
|
||||||
Пока используем публичный `https://qu.kube5s.ru`. Если есть внутрикластерный сервис — обновим.
|
|
||||||
@@ -1,295 +0,0 @@
|
|||||||
# IoT Managed Service — Актуальный деплой (v0.2.3)
|
|
||||||
|
|
||||||
> Дата: 2026-04-12
|
|
||||||
> Образ: `naeel/iot-operator:v0.2.3`
|
|
||||||
> Ветка: `feature/replace-kafka-with-sqs`
|
|
||||||
> Кластер: `iot-naeel`
|
|
||||||
> Автор: GitHub Copilot (Claude Opus 4.6)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Обзор
|
|
||||||
|
|
||||||
Три бинарника из одного Docker-образа `naeel/iot-operator:v0.2.3`:
|
|
||||||
|
|
||||||
| Бинарник | Deployment | Назначение |
|
|
||||||
|----------|-----------|------------|
|
|
||||||
| `/iot-operator` | iot-operator | Controller-manager (IoTDevice CRD) + REST API :9090 |
|
|
||||||
| `/mqtt-bridge` | iot-mqtt-bridge | MQTT (EMQX) → shared-SQS bridge |
|
|
||||||
| `/sqs-consumer` | iot-sqs-consumer | shared-SQS → Postgres pipeline |
|
|
||||||
|
|
||||||
Плюс:
|
|
||||||
- EMQX 5.5.1 — MQTT-брокер (отдельный образ `emqx/emqx:5.5.1`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Кластер
|
|
||||||
|
|
||||||
| Параметр | Значение |
|
|
||||||
|----------|----------|
|
|
||||||
| Имя | `iot-naeel` |
|
|
||||||
| API | `https://185.247.187.149:6443` |
|
|
||||||
| Ingress IP | `185.247.187.151` |
|
|
||||||
| DNS | `iot.kube5s.ru → 185.247.187.147` |
|
|
||||||
| Namespace | `sless` |
|
|
||||||
| kubeconfig | `~/.kube/config` на ВМ (токен 24ч, обновлять через auth.k8s.ngcloud.ru) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Секреты (namespace sless)
|
|
||||||
|
|
||||||
Перед первым деплоем создать 3 секрета:
|
|
||||||
|
|
||||||
### 1. iot-bridge-credentials (MQTT bridge → EMQX)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl create secret generic iot-bridge-credentials -n sless \
|
|
||||||
--from-literal=MQTT_USERNAME="iot-bridge-internal" \
|
|
||||||
--from-literal=MQTT_PASSWORD="<пароль bridge>"
|
|
||||||
```
|
|
||||||
|
|
||||||
Используется:
|
|
||||||
- **iot-operator** — для проверки bridge auth в `/internal/mqtt/auth` (env: MQTT_BRIDGE_USERNAME, MQTT_BRIDGE_PASSWORD)
|
|
||||||
- **iot-mqtt-bridge** — для подключения к EMQX (envFrom: secretRef)
|
|
||||||
|
|
||||||
### 2. iot-sqs-credentials (shared-SQS)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl create secret generic iot-sqs-credentials -n sless \
|
|
||||||
--from-literal=SQS_ENDPOINT="https://qu.kube5s.ru" \
|
|
||||||
--from-literal=SQS_ACCESS_KEY="SSAK-a9964f2723bc6d347f48d153" \
|
|
||||||
--from-literal=SQS_SECRET_KEY="<secret_key>"
|
|
||||||
```
|
|
||||||
|
|
||||||
Используется:
|
|
||||||
- **iot-mqtt-bridge** — для SendMessage
|
|
||||||
- **iot-sqs-consumer** — для ReceiveMessage + DeleteMessage
|
|
||||||
|
|
||||||
### 3. iot-postgres-secret (Managed PostgreSQL)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl create secret generic iot-postgres-secret -n sless \
|
|
||||||
--from-literal=IOT_PG_DSN="postgres://super:<password>@postgresqlk8s-master.dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5.svc.cluster.local:5432/sqsdb?sslmode=disable"
|
|
||||||
```
|
|
||||||
|
|
||||||
Используется:
|
|
||||||
- **iot-sqs-consumer** — для записи телеметрии в per-tenant DB
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Процедура деплоя (пошагово)
|
|
||||||
|
|
||||||
### Все команды выполнять на ВМ через SSH
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ssh -i ~/.ssh/id_ed25519 naeel@5.172.178.213
|
|
||||||
cd /home/naeel/terra/IoT
|
|
||||||
```
|
|
||||||
|
|
||||||
### 0. Сборка образа (если код менялся)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker build --no-cache -t naeel/iot-operator:v0.2.3 .
|
|
||||||
docker push naeel/iot-operator:v0.2.3
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1. CRD (один раз, cluster-wide)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl apply -f config/crd/bases/iot.kube5s.ru_iotdevices.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. EMQX
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl apply -f deployments/k8s/emqx.yaml
|
|
||||||
kubectl apply -f deployments/k8s/emqx-ws-ingress.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Postgres secret (один раз)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl apply -f deployments/k8s/iot-postgres.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. SQS secret (один раз)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl get secret iot-sqs-credentials -n sless
|
|
||||||
# Если нет — создать (см. секцию Секреты выше)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Bridge credentials (один раз)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl get secret iot-bridge-credentials -n sless
|
|
||||||
# Если нет — создать (см. секцию Секреты выше)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. Operator
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl apply -f deployments/k8s/iot-operator.yaml
|
|
||||||
kubectl rollout status deployment/iot-operator -n sless
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7. MQTT Bridge
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl apply -f deployments/k8s/iot-mqtt-bridge.yaml
|
|
||||||
kubectl rollout status deployment/iot-mqtt-bridge -n sless
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8. SQS Consumer
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl apply -f deployments/k8s/iot-sqs-consumer.yaml
|
|
||||||
kubectl rollout status deployment/iot-sqs-consumer -n sless
|
|
||||||
```
|
|
||||||
|
|
||||||
### 9. Проверка
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl get pods -n sless
|
|
||||||
# Ожидаемый результат:
|
|
||||||
# emqx-xxx 1/1 Running
|
|
||||||
# iot-operator-xxx 1/1 Running
|
|
||||||
# iot-mqtt-bridge-xxx 1/1 Running
|
|
||||||
# iot-sqs-consumer-xxx 1/1 Running
|
|
||||||
|
|
||||||
kubectl logs -n sless deployment/iot-operator --tail=5
|
|
||||||
kubectl logs -n sless deployment/iot-mqtt-bridge --tail=5
|
|
||||||
kubectl logs -n sless deployment/iot-sqs-consumer --tail=5
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Обновление образа (rollout)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Собрать новый образ
|
|
||||||
docker build --no-cache -t naeel/iot-operator:v0.2.4 .
|
|
||||||
docker push naeel/iot-operator:v0.2.4
|
|
||||||
|
|
||||||
# 2. Обновить тег во ВСЕХ трёх YAML
|
|
||||||
sed -i 's/v0.2.3/v0.2.4/g' deployments/k8s/iot-operator.yaml \
|
|
||||||
deployments/k8s/iot-mqtt-bridge.yaml \
|
|
||||||
deployments/k8s/iot-sqs-consumer.yaml
|
|
||||||
|
|
||||||
# 3. Применить
|
|
||||||
kubectl apply -f deployments/k8s/iot-operator.yaml
|
|
||||||
kubectl apply -f deployments/k8s/iot-mqtt-bridge.yaml
|
|
||||||
kubectl apply -f deployments/k8s/iot-sqs-consumer.yaml
|
|
||||||
|
|
||||||
# 4. Проверить
|
|
||||||
kubectl get pods -n sless -w
|
|
||||||
```
|
|
||||||
|
|
||||||
**ВАЖНО:** Если образ с тем же тегом — Kubernetes не скачает новый (кэш на нодах). Всегда менять тег.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Компоненты: env vars
|
|
||||||
|
|
||||||
### iot-operator
|
|
||||||
|
|
||||||
| Переменная | Значение | Источник |
|
|
||||||
|-----------|----------|----------|
|
|
||||||
| MQTT_BRIDGE_USERNAME | iot-bridge-internal | Secret iot-bridge-credentials (key: MQTT_USERNAME) |
|
|
||||||
| MQTT_BRIDGE_PASSWORD | (пароль) | Secret iot-bridge-credentials (key: MQTT_PASSWORD) |
|
|
||||||
|
|
||||||
### iot-mqtt-bridge
|
|
||||||
|
|
||||||
| Переменная | Значение | Источник |
|
|
||||||
|-----------|----------|----------|
|
|
||||||
| MQTT_BROKER_URL | tcp://emqx.sless.svc:1883 | YAML env |
|
|
||||||
| SQS_QUEUE_NAME | iot-telemetry | YAML env |
|
|
||||||
| SQS_REGION | us-east-1 | YAML env |
|
|
||||||
| MQTT_USERNAME | iot-bridge-internal | Secret iot-bridge-credentials |
|
|
||||||
| MQTT_PASSWORD | (пароль) | Secret iot-bridge-credentials |
|
|
||||||
| SQS_ENDPOINT | https://qu.kube5s.ru | Secret iot-sqs-credentials |
|
|
||||||
| SQS_ACCESS_KEY | SSAK-... | Secret iot-sqs-credentials |
|
|
||||||
| SQS_SECRET_KEY | (secret) | Secret iot-sqs-credentials |
|
|
||||||
|
|
||||||
### iot-sqs-consumer
|
|
||||||
|
|
||||||
| Переменная | Значение | Источник |
|
|
||||||
|-----------|----------|----------|
|
|
||||||
| SQS_QUEUE_NAME | iot-telemetry | YAML env |
|
|
||||||
| SQS_REGION | us-east-1 | YAML env |
|
|
||||||
| SQS_ENDPOINT | https://qu.kube5s.ru | Secret iot-sqs-credentials |
|
|
||||||
| SQS_ACCESS_KEY | SSAK-... | Secret iot-sqs-credentials |
|
|
||||||
| SQS_SECRET_KEY | (secret) | Secret iot-sqs-credentials |
|
|
||||||
| IOT_PG_DSN | postgres://... | Secret iot-postgres-secret |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Managed PostgreSQL
|
|
||||||
|
|
||||||
| Параметр | Значение |
|
|
||||||
|---------|----------|
|
|
||||||
| Версия | PostgreSQL 17 |
|
|
||||||
| Host | postgresqlk8s-master.dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5.svc.cluster.local |
|
|
||||||
| Port | 5432 |
|
|
||||||
| User | super |
|
|
||||||
| DB | sqsdb |
|
|
||||||
| Тип | Managed (оператор в другом namespace) |
|
|
||||||
|
|
||||||
Per-tenant изоляция: для каждого namespace создаётся отдельная DATABASE `tenant_{namespace}` с таблицей `iot_telemetry`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## shared-SQS (очередь)
|
|
||||||
|
|
||||||
| Параметр | Значение |
|
|
||||||
|---------|----------|
|
|
||||||
| Endpoint | https://qu.kube5s.ru |
|
|
||||||
| Tenant | iot-service (ID: t-96afe7e9f781f6ca) |
|
|
||||||
| Очередь | iot-telemetry |
|
|
||||||
| Протокол | AWS SQS API (AWS SDK compatible) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Версии образов (история)
|
|
||||||
|
|
||||||
| Версия | Дата | Изменения |
|
|
||||||
|--------|------|-----------|
|
|
||||||
| v0.2.3 | 2026-04-12 | Bridge auth fix (BridgeUsername/BridgePassword), debug logs |
|
|
||||||
| v0.2.2 | 2026-04-12 | Bridge auth (первая попытка, проблема с табами) |
|
|
||||||
| v0.2.1 | 2026-04-12 | Bridge auth (проблема IsSuperuser field) |
|
|
||||||
| v0.2.0 | 2026-04-12 | Kafka->SQS, managed PG, новый кластер iot-naeel |
|
|
||||||
| v0.1.69 | 2026-04-06 | Kafka Async write fix (1000/1000 тест) |
|
|
||||||
| v0.1.68 | 2026-04-06 | Kafka pipeline (bridge->Kafka->consumer->Postgres) |
|
|
||||||
| v0.1.50 | 2026-04-04 | Первый IoT: controller + API + mqtt-bridge (RabbitMQ) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Известные ошибки и решения (v0.2.x)
|
|
||||||
|
|
||||||
### mqtt-bridge: MQTT connect timeout / CrashLoopBackOff
|
|
||||||
|
|
||||||
**Симптом:** bridge не подключается к EMQX, логи "MQTT connect timeout"
|
|
||||||
|
|
||||||
**Причина:** EMQX вызывает `/internal/mqtt/auth` при CONNECT. Bridge username `iot-bridge-internal` не содержит `_` -> MQTTAuth парсит namespace_deviceId -> ошибка -> deny -> EMQX возвращает not_authorized -> bridge retry 30с -> выглядит как timeout.
|
|
||||||
|
|
||||||
**Решение (v0.2.3):** В Handler добавлены поля BridgeUsername/BridgePassword. MQTTAuth проверяет bridge credentials ДО парсинга namespace_deviceId. Если username совпадает — allow.
|
|
||||||
|
|
||||||
### Docker image cache на k8s нодах
|
|
||||||
|
|
||||||
**Симптом:** после `docker push` новый образ не используется, pod стартует со старым кодом.
|
|
||||||
|
|
||||||
**Причина:** imagePullPolicy=Always работает, но если тег не изменился Kubernetes может использовать кэш ноды.
|
|
||||||
|
|
||||||
**Решение:** Всегда менять тег при пересборке (v0.2.1 -> v0.2.2 -> v0.2.3 и т.д.)
|
|
||||||
|
|
||||||
### EMQX: required_field node.cookie/node.data_dir
|
|
||||||
|
|
||||||
**Симптом:** EMQX CrashLoopBackOff при первом старте
|
|
||||||
|
|
||||||
**Причина:** EMQX 5.x требует явных node.cookie и node.data_dir
|
|
||||||
|
|
||||||
**Решение:** В emqx.conf (ConfigMap) обязательны:
|
|
||||||
```hocon
|
|
||||||
node { name = "emqx@127.0.0.1", cookie = "...", data_dir = "/opt/emqx/data" }
|
|
||||||
```
|
|
||||||
После изменения ConfigMap: `kubectl rollout restart deployment/emqx -n sless`
|
|
||||||
@@ -304,101 +304,3 @@ node {
|
|||||||
|--------|------|-----------|
|
|--------|------|-----------|
|
||||||
| v0.1.50 | 2026-04-04 | IoT controller + IoT API + iot-mqtt-bridge бинарь |
|
| v0.1.50 | 2026-04-04 | IoT controller + IoT API + iot-mqtt-bridge бинарь |
|
||||||
| v0.1.49 | ранее | До IoT |
|
| v0.1.49 | ранее | До IoT |
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Актуальный деплой (2026-04-12)
|
|
||||||
|
|
||||||
> Кластер: iot-naeel (новый). Kafka удалён. Postgres — managed. SQS вместо Kafka.
|
|
||||||
|
|
||||||
### Кластер iot-naeel
|
|
||||||
|
|
||||||
- API: `https://185.247.187.149:6443`
|
|
||||||
- Ingress IP: `185.247.187.151`
|
|
||||||
- DNS: `iot.kube5s.ru → 185.247.187.147`
|
|
||||||
- kubeconfig: `~/.kube/config` на ВМ (токен с TTL 24ч, обновляется через auth.k8s.ngcloud.ru)
|
|
||||||
|
|
||||||
### Namespace sless — содержимое
|
|
||||||
|
|
||||||
| Ресурс | Имя | Описание |
|
|
||||||
|--------|-----|----------|
|
|
||||||
| Deployment | emqx | MQTT-брокер EMQX 5.5.1 |
|
|
||||||
| Deployment | iot-operator | Controller-manager + REST API v0.2.0 |
|
|
||||||
| Deployment | iot-mqtt-bridge | MQTT → shared-SQS bridge v0.2.0 |
|
|
||||||
| Deployment | iot-sqs-consumer | shared-SQS → Postgres consumer v0.2.0 |
|
|
||||||
| Service | emqx | :1883 (MQTT), :8083 (WS), :18083 (dashboard) |
|
|
||||||
| Service | emqx-ws | :8083 (для Ingress) |
|
|
||||||
| Service | iot-operator | :9090 (REST API) |
|
|
||||||
| Ingress | emqx-mqtt-websocket | iot.kube5s.ru → /mqtt (WS), /console (UI) |
|
|
||||||
| Secret | iot-sqs-credentials | SQS_ENDPOINT, SQS_ACCESS_KEY, SQS_SECRET_KEY |
|
|
||||||
| Secret | iot-postgres-secret | POSTGRES_USER, POSTGRES_PASSWORD, IOT_PG_DSN |
|
|
||||||
| Secret | iot-bridge-credentials | MQTT_USERNAME, MQTT_PASSWORD |
|
|
||||||
| CRD | iotdevices.iot.kube5s.ru | IoTDevice v1alpha1 |
|
|
||||||
| ServiceAccount | iot-operator | + ClusterRole + ClusterRoleBinding |
|
|
||||||
|
|
||||||
### Процедура деплоя (актуальная)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 0. SSH на ВМ (все команды оттуда)
|
|
||||||
ssh -i ~/.ssh/id_ed25519 naeel@5.172.178.213
|
|
||||||
|
|
||||||
# 1. Сборка и push образа
|
|
||||||
cd /home/naeel/terra/IoT
|
|
||||||
docker build -t naeel/iot-operator:v0.2.0 -t naeel/iot-operator:latest .
|
|
||||||
docker push naeel/iot-operator:v0.2.0
|
|
||||||
docker push naeel/iot-operator:latest
|
|
||||||
|
|
||||||
# 2. CRD (один раз)
|
|
||||||
kubectl apply -f config/crd/bases/iot.kube5s.ru_iotdevices.yaml
|
|
||||||
|
|
||||||
# 3. Postgres Secret (содержит DSN managed PG)
|
|
||||||
kubectl apply -f deployments/k8s/iot-postgres.yaml
|
|
||||||
|
|
||||||
# 4. EMQX
|
|
||||||
kubectl apply -f deployments/k8s/emqx.yaml
|
|
||||||
kubectl apply -f deployments/k8s/emqx-ws-ingress.yaml
|
|
||||||
|
|
||||||
# 5. Operator
|
|
||||||
kubectl apply -f deployments/k8s/iot-operator.yaml
|
|
||||||
|
|
||||||
# 6. SQS credentials (уже создан через kubectl create secret)
|
|
||||||
# kubectl get secret iot-sqs-credentials -n sless
|
|
||||||
|
|
||||||
# 7. MQTT bridge credentials (уже создан)
|
|
||||||
# kubectl get secret iot-bridge-credentials -n sless
|
|
||||||
|
|
||||||
# 8. Bridge + Consumer
|
|
||||||
kubectl apply -f deployments/k8s/iot-mqtt-bridge.yaml
|
|
||||||
kubectl apply -f deployments/k8s/iot-sqs-consumer.yaml
|
|
||||||
|
|
||||||
# 9. Проверка
|
|
||||||
kubectl get pods -n sless
|
|
||||||
kubectl logs -n sless deployment/iot-operator --tail=10
|
|
||||||
kubectl logs -n sless deployment/iot-mqtt-bridge --tail=10
|
|
||||||
kubectl logs -n sless deployment/iot-sqs-consumer --tail=10
|
|
||||||
```
|
|
||||||
|
|
||||||
### Managed PostgreSQL
|
|
||||||
|
|
||||||
- Namespace: `dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5`
|
|
||||||
- Pod: `postgresqlk8s-0`
|
|
||||||
- Host: `postgresqlk8s-master.dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5.svc.cluster.local`
|
|
||||||
- User: `super`, DB: `sqsdb`, PG 17
|
|
||||||
- Тот же инстанс что использует shared-SQS для billing
|
|
||||||
- Credentials: см. `/home/naeel/terra/SQS-service/secrets/iot_pg.md`
|
|
||||||
|
|
||||||
### shared-SQS (очередь вместо Kafka)
|
|
||||||
|
|
||||||
- Namespace: `shared-sqs`
|
|
||||||
- Endpoint: `https://qu.kube5s.ru`
|
|
||||||
- Tenant ID: `t-96afe7e9f781f6ca` (iot-service)
|
|
||||||
- Queue: `iot-telemetry`
|
|
||||||
- Access Key: хранится в Secret `iot-sqs-credentials` namespace `sless`
|
|
||||||
- Admin token: хранится в Secret `shared-sqs-admin` namespace `shared-sqs`
|
|
||||||
|
|
||||||
### Версии образов (актуальные)
|
|
||||||
|
|
||||||
| Версия | Дата | Registry | Изменения |
|
|
||||||
|--------|------|----------|-----------|
|
|
||||||
| v0.2.0 | 2026-04-12 | Docker Hub naeel/iot-operator | Kafka→SQS, managed PG, новый кластер |
|
|
||||||
| v0.1.50 | 2026-04-04 | pearlharbor (Harbor) | IoT controller + API + mqtt-bridge (Kafka) |
|
|
||||||
|
|||||||
-102
@@ -35,105 +35,3 @@
|
|||||||
- [ ] Деплой в кластер
|
- [ ] Деплой в кластер
|
||||||
- [ ] E2E тест: создание устройства → MQTT → Kafka → Postgres → API
|
- [ ] E2E тест: создание устройства → MQTT → Kafka → Postgres → API
|
||||||
- [ ] Убрать IoT-код из sless (опционально, после подтверждения что всё работает)
|
- [ ] Убрать IoT-код из sless (опционально, после подтверждения что всё работает)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2026-04-12: Замена Kafka → shared-SQS + деплой в новый кластер
|
|
||||||
|
|
||||||
### Выполнено
|
|
||||||
- [x] Создана ветка `feature/replace-kafka-with-sqs`
|
|
||||||
- [x] Документировано решение: `doc/decisions/2026-04-12-replace-kafka-with-sqs.md`
|
|
||||||
- [x] **mqtt-bridge** (`cmd/mqtt-bridge/main.go`): Kafka Writer → AWS SDK SQS SendMessage
|
|
||||||
- [x] **sqs-consumer** (`cmd/sqs-consumer/main.go`): новый — заменяет kafka-consumer, SQS ReceiveMessage → Postgres
|
|
||||||
- [x] **kafka-consumer** (`cmd/kafka-consumer/`): удалён из кода
|
|
||||||
- [x] **admin stats** (`internal/api/handler/iot_admin_stats_handler.go`): Kafka lag → SQS GetQueueAttributes
|
|
||||||
- [x] **handler.go**: убрано поле KafkaBrokers
|
|
||||||
- [x] **iot-operator/main.go**: убрана передача KAFKA_BROKERS
|
|
||||||
- [x] go.mod: убран `segmentio/kafka-go`, добавлен `aws-sdk-go-v2` (v1.41.5 + sqs v1.42.25)
|
|
||||||
- [x] Dockerfile: kafka-consumer → sqs-consumer
|
|
||||||
- [x] Makefile: build-consumer → sqs-consumer
|
|
||||||
- [x] .gitignore: исправлен баг (паттерны без `/` игнорировали `cmd/` директории)
|
|
||||||
- [x] `go build ./...` проходит на ВМ
|
|
||||||
- [x] Коммит и пуш в ветку
|
|
||||||
|
|
||||||
### Деплой в кластер iot-naeel (новый кластер)
|
|
||||||
- [x] shared-SQS: создан tenant `iot-service` (t-96afe7e9f781f6ca), очередь `iot-telemetry`
|
|
||||||
- [x] Namespace `sless` создан
|
|
||||||
- [x] Secret `iot-sqs-credentials` создан
|
|
||||||
- [x] Secret `iot-bridge-credentials` создан
|
|
||||||
- [x] Postgres: переключён на managed PG17 (тот же что SQS billing)
|
|
||||||
- [x] Secret `iot-postgres-secret` создан (DSN → managed PG)
|
|
||||||
- [x] Docker: собран и запушен `naeel/iot-operator:v0.2.0` в Docker Hub
|
|
||||||
- [x] Deployment YAML: обновлены image на Docker Hub, убраны imagePullSecrets
|
|
||||||
- [x] EMQX: задеплоен (emqx.yaml + emqx-ws-ingress.yaml, адаптирован из sless)
|
|
||||||
- [x] iot-operator: задеплоен (новый iot-operator.yaml с RBAC)
|
|
||||||
- [x] CRD `iotdevices.iot.kube5s.ru` установлен
|
|
||||||
- [x] iot-mqtt-bridge: задеплоен, Running
|
|
||||||
- [x] iot-sqs-consumer: задеплоен, Running — подключился к PG и SQS
|
|
||||||
- [x] cert-manager: выпускает TLS для iot.kube5s.ru
|
|
||||||
- [x] Коммит и пуш
|
|
||||||
|
|
||||||
### Что работает
|
|
||||||
- Все 4 пода Running 1/1
|
|
||||||
- iot-operator: controller стартовал, MQTT auth/acl обрабатывает запросы от EMQX
|
|
||||||
- sqs-consumer: подключился к Postgres и SQS, polling loop запущен
|
|
||||||
- mqtt-bridge: подключён к EMQX и SQS, SQS queue resolved
|
|
||||||
- EMQX: MQTT :1883, WS :8083, dashboard :18083
|
|
||||||
|
|
||||||
### Следующие шаги
|
|
||||||
- [ ] E2E тест: создание устройства → MQTT → SQS → Postgres → API
|
|
||||||
- [ ] Нагрузочный тест
|
|
||||||
- [ ] Мерж ветки в master
|
|
||||||
- [ ] Убрать IoT-код из sless (опционально)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2026-04-12: Документация v0.2.3
|
|
||||||
|
|
||||||
### Выполнено
|
|
||||||
- [x] Аудит документации из репозитория sless (thinking 04-04/05/06/09, decisions, progress, api, examples)
|
|
||||||
- [x] Аудит документации в IoT репозитории (deployment.md, overview.md, endpoints.md, mvp-plan.md)
|
|
||||||
- [x] Анализ расхождений — определены устаревшие секции (Kafka, RabbitMQ)
|
|
||||||
- [x] Решение: старые документы НЕ удалять, новые создавать отдельно
|
|
||||||
- [x] Создан doc/deployment-v0.2.3.md (295 строк) — актуальная инструкция деплоя
|
|
||||||
- [x] Создан doc/architecture/current-v0.2.3.md (256 строк) — полная архитектура v0.2.3
|
|
||||||
- [x] Создан doc/api/endpoints-v0.2.3.md (~300 строк) — полная документация REST API
|
|
||||||
- [x] Создан doc/run-and-test.md (~180 строк) — руководство по запуску и E2E тесту
|
|
||||||
- [x] Обновлён doc/progress.md
|
|
||||||
- [x] Дописан doc/thinking/2026-04-12.md — лог мышления сессии
|
|
||||||
|
|
||||||
### Баг-фиксы v0.2.1—v0.2.3
|
|
||||||
- v0.2.1: mqtt-bridge — bridge auth fix (BridgeUsername/BridgePassword проверяется ДО парсинга username)
|
|
||||||
- v0.2.3: финальный образ со всеми фиксами
|
|
||||||
|
|
||||||
### Документация (сессия 2, вечер)
|
|
||||||
|
|
||||||
Проведён полный аудит IoT-документации из sless репы. Прочитано:
|
|
||||||
- sless/doc/thinking/2026-04-04.md (681 строк) — архитектура, первый деплой
|
|
||||||
- sless/doc/thinking/2026-04-05.md (178 строк) — телеметрия, MQTTX, UX
|
|
||||||
- sless/doc/thinking/2026-04-06.md (613 строк) — Kafka pipeline, 8 тестов
|
|
||||||
- sless/doc/thinking/2026-04-09.md (349 строк) — shared-sqs, TenantStore
|
|
||||||
- sless/doc/iot/deployment.md — инженерный документ деплоя (RabbitMQ)
|
|
||||||
- sless/doc/progress.md — IoT-записи
|
|
||||||
- sless/examples/IOT/ — E2E demo
|
|
||||||
|
|
||||||
Созданы НОВЫЕ актуальные файлы (старые НЕ тронуты — база знаний):
|
|
||||||
- [x] doc/deployment-v0.2.3.md — полная процедура деплоя (SQS, managed PG, bridge auth)
|
|
||||||
- [x] doc/architecture/current-v0.2.3.md — актуальная архитектура (без Kafka/RabbitMQ)
|
|
||||||
- [x] doc/api/endpoints-v0.2.3.md — все API эндпоинты с примерами запросов/ответов
|
|
||||||
- [x] doc/run-and-test.md — как запустить и протестировать E2E
|
|
||||||
|
|
||||||
## 2026-04-12 — v0.2.5: баг-фиксы и E2E тест
|
|
||||||
|
|
||||||
### Найдены и исправлены баги:
|
|
||||||
1. **MQTTAuth: Get по Name=deviceID** — заменено на List + фильтр по Spec.DeviceID
|
|
||||||
2. **PG15+ GRANT**: добавлен `GRANT role TO CURRENT_USER` перед `CREATE DATABASE ... OWNER`
|
|
||||||
|
|
||||||
### E2E тест пройден:
|
|
||||||
- Устройство создано через REST API (201)
|
|
||||||
- MQTT CONNECT + PUBLISH через EMQX — OK
|
|
||||||
- mqtt-bridge → SQS forwarding — OK
|
|
||||||
- sqs-consumer → Postgres (tenant DB created + telemetry saved) — OK
|
|
||||||
- REST API GET /telemetry — 2 записи с payload
|
|
||||||
|
|
||||||
### Docker Hub: `naeel/iot-operator:v0.2.5`
|
|
||||||
|
|||||||
@@ -1,249 +0,0 @@
|
|||||||
# IoT — Руководство по запуску и тестированию (v0.2.3)
|
|
||||||
|
|
||||||
> Дата: 2026-04-12
|
|
||||||
> Кластер: iot-naeel, namespace: sless
|
|
||||||
> Образ: naeel/iot-operator:v0.2.3
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Содержание
|
|
||||||
|
|
||||||
1. [Предварительные требования](#предварительные-требования)
|
|
||||||
2. [Сборка и публикация образа](#сборка-и-публикация-образа)
|
|
||||||
3. [Деплой в кластер](#деплой-в-кластер)
|
|
||||||
4. [E2E тест: полный путь данных](#e2e-тест-полный-путь-данных)
|
|
||||||
5. [Проверка компонентов](#проверка-компонентов)
|
|
||||||
6. [Отладка](#отладка)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Предварительные требования
|
|
||||||
|
|
||||||
- kubectl с kubeconfig для кластера iot-naeel (185.247.187.149:6443)
|
|
||||||
- Docker для сборки образа
|
|
||||||
- mosquitto-clients для MQTT тестов (apt install mosquitto-clients)
|
|
||||||
- curl для REST API
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Сборка и публикация образа
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/naeel/terra/IoT
|
|
||||||
|
|
||||||
# Сборка multi-binary Docker образа
|
|
||||||
docker build -t naeel/iot-operator:v0.2.3 .
|
|
||||||
|
|
||||||
# Push на Docker Hub
|
|
||||||
docker push naeel/iot-operator:v0.2.3
|
|
||||||
```
|
|
||||||
|
|
||||||
Dockerfile собирает 3 бинарника: /iot-operator, /mqtt-bridge, /sqs-consumer.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Деплой в кластер
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Применить CRD
|
|
||||||
kubectl apply -f config/crd/bases/iot.kube5s.ru_iotdevices.yaml
|
|
||||||
|
|
||||||
# Деплой всех компонентов
|
|
||||||
kubectl apply -f deployments/k8s/emqx.yaml
|
|
||||||
kubectl apply -f deployments/k8s/iot-operator.yaml
|
|
||||||
kubectl apply -f deployments/k8s/iot-mqtt-bridge.yaml
|
|
||||||
kubectl apply -f deployments/k8s/iot-sqs-consumer.yaml
|
|
||||||
kubectl apply -f deployments/k8s/iot-postgres.yaml
|
|
||||||
|
|
||||||
# Проверка
|
|
||||||
kubectl get pods -n sless
|
|
||||||
# Ожидаем: emqx, iot-operator, iot-mqtt-bridge, iot-sqs-consumer — все Running
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## E2E тест: полный путь данных
|
|
||||||
|
|
||||||
Полный тест проверяет цепочку: REST API → CRD → Secret → MQTT → SQS → Postgres → Telemetry API.
|
|
||||||
|
|
||||||
### Шаг 1: Создать устройство через API
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST https://iot.kube5s.ru/v1/namespaces/sless/iot/devices \
|
|
||||||
-H "Authorization: Bearer test-token" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"name":"e2e-test-01","device_id":"e2e-test-01","enabled":true}'
|
|
||||||
```
|
|
||||||
|
|
||||||
Ответ: 201 Created с данными устройства.
|
|
||||||
|
|
||||||
### Шаг 2: Получить MQTT credentials
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s https://iot.kube5s.ru/v1/namespaces/sless/iot/devices/e2e-test-01 \
|
|
||||||
-H "Authorization: Bearer test-token" \
|
|
||||||
| jq '{mqtt_username, mqtt_password, phase}'
|
|
||||||
```
|
|
||||||
|
|
||||||
Ожидаем: phase=Active, mqtt_username="sless_e2e-test-01", mqtt_password=hex(64).
|
|
||||||
|
|
||||||
> Если phase=Pending — подождать 1-2 секунды, контроллер ещё создаёт Secret.
|
|
||||||
|
|
||||||
### Шаг 3: Отправить телеметрию через MQTT
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Вставить реальный пароль из Шага 2
|
|
||||||
MQTT_USER="sless_e2e-test-01"
|
|
||||||
MQTT_PASS="<mqtt_password из шага 2>"
|
|
||||||
|
|
||||||
# Через WebSocket (через Ingress)
|
|
||||||
mosquitto_pub \
|
|
||||||
-h iot.kube5s.ru \
|
|
||||||
-p 443 \
|
|
||||||
--capath /etc/ssl/certs \
|
|
||||||
-u "$MQTT_USER" \
|
|
||||||
-P "$MQTT_PASS" \
|
|
||||||
-t "sless/telemetry/e2e-test-01" \
|
|
||||||
-m '{"temperature": 22.5, "humidity": 65}' \
|
|
||||||
--protocol-version mqttv5
|
|
||||||
|
|
||||||
# ИЛИ через kubectl port-forward (MQTT напрямую)
|
|
||||||
kubectl port-forward svc/emqx -n sless 1883:1883 &
|
|
||||||
mosquitto_pub \
|
|
||||||
-h localhost \
|
|
||||||
-p 1883 \
|
|
||||||
-u "$MQTT_USER" \
|
|
||||||
-P "$MQTT_PASS" \
|
|
||||||
-t "sless/telemetry/e2e-test-01" \
|
|
||||||
-m '{"temperature": 22.5, "humidity": 65}'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Шаг 4: Проверить что телеметрия дошла до Postgres
|
|
||||||
|
|
||||||
Подождать 5-10 секунд (SQS long polling + обработка).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s "https://iot.kube5s.ru/v1/namespaces/sless/iot/telemetry?device=e2e-test-01&limit=10" \
|
|
||||||
-H "Authorization: Bearer test-token" \
|
|
||||||
| jq '.'
|
|
||||||
```
|
|
||||||
|
|
||||||
Ожидаем: count >= 1, items содержит запись с payload {"temperature": 22.5, "humidity": 65}.
|
|
||||||
|
|
||||||
### Шаг 5: Очистка
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X DELETE https://iot.kube5s.ru/v1/namespaces/sless/iot/devices/e2e-test-01 \
|
|
||||||
-H "Authorization: Bearer test-token"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Проверка компонентов
|
|
||||||
|
|
||||||
### Поды
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl get pods -n sless -l 'app in (iot-operator,iot-mqtt-bridge,iot-sqs-consumer,emqx)'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Логи
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Operator (controller + API)
|
|
||||||
kubectl logs -n sless deployment/iot-operator --tail=50
|
|
||||||
|
|
||||||
# MQTT Bridge
|
|
||||||
kubectl logs -n sless deployment/iot-mqtt-bridge --tail=50
|
|
||||||
|
|
||||||
# SQS Consumer
|
|
||||||
kubectl logs -n sless deployment/iot-sqs-consumer --tail=50
|
|
||||||
|
|
||||||
# EMQX
|
|
||||||
kubectl logs -n sless deployment/emqx --tail=50
|
|
||||||
```
|
|
||||||
|
|
||||||
### CRD ресурсы
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Список IoTDevice
|
|
||||||
kubectl get iotdevices -n sless
|
|
||||||
|
|
||||||
# Детали
|
|
||||||
kubectl describe iotdevice e2e-test-01 -n sless
|
|
||||||
|
|
||||||
# Секреты
|
|
||||||
kubectl get secret -n sless -l app.kubernetes.io/managed-by=iot-operator
|
|
||||||
```
|
|
||||||
|
|
||||||
### EMQX Dashboard (отладка)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl port-forward svc/emqx -n sless 18083:18083
|
|
||||||
# Открыть http://localhost:18083
|
|
||||||
# Default: admin / public
|
|
||||||
```
|
|
||||||
|
|
||||||
### SQS очередь
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Через admin stats endpoint
|
|
||||||
curl -s https://iot.kube5s.ru/iot-admin/stats \
|
|
||||||
-H "Authorization: Bearer <ADMIN_STATS_TOKEN>" \
|
|
||||||
| jq '.sqs'
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Отладка
|
|
||||||
|
|
||||||
### Устройство не подключается по MQTT
|
|
||||||
|
|
||||||
1. Проверить phase: `kubectl get iotdevice {name} -n {ns} -o jsonpath='{.status.phase}'`
|
|
||||||
— Должно быть `Active`
|
|
||||||
2. Проверить Secret: `kubectl get secret iot-{deviceId} -n {ns}`
|
|
||||||
3. Проверить EMQX logs: `kubectl logs -n sless deployment/emqx | grep "auth"`
|
|
||||||
4. Ручная проверка auth:
|
|
||||||
```bash
|
|
||||||
kubectl port-forward svc/iot-operator -n sless 9090:9090
|
|
||||||
curl -X POST http://localhost:9090/internal/mqtt/auth \
|
|
||||||
-d '{"username":"sless_sensor-01","password":"...","clientid":"test"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Телеметрия не появляется в API
|
|
||||||
|
|
||||||
1. Bridge подключён? `kubectl logs -n sless deployment/iot-mqtt-bridge | tail -20`
|
|
||||||
2. SQS получает? `curl /iot-admin/stats` → sqs.approximate_messages
|
|
||||||
3. Consumer обрабатывает? `kubectl logs -n sless deployment/iot-sqs-consumer | tail -20`
|
|
||||||
4. Postgres доступен? Проверить логи consumer на ошибки подключения
|
|
||||||
|
|
||||||
### Bridge не подключается к MQTT
|
|
||||||
|
|
||||||
1. Проверить Secret iot-bridge-credentials:
|
|
||||||
`kubectl get secret iot-bridge-credentials -n sless -o jsonpath='{.data.MQTT_USERNAME}' | base64 -d`
|
|
||||||
2. Проверить env в Bridge pod:
|
|
||||||
`kubectl exec -n sless deployment/iot-mqtt-bridge -- env | grep MQTT`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## IoT Console (Web UI)
|
|
||||||
|
|
||||||
Открыть `https://iot.kube5s.ru/console` в браузере.
|
|
||||||
|
|
||||||
Функции:
|
|
||||||
- CRUD устройств через REST API
|
|
||||||
- Встроенный MQTT WebSocket клиент (wss://iot.kube5s.ru/mqtt)
|
|
||||||
- Real-time отображение входящей телеметрии
|
|
||||||
- Просмотр истории из Postgres
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## IoT Admin Panel
|
|
||||||
|
|
||||||
Открыть `https://iot.kube5s.ru/iot-admin` в браузере.
|
|
||||||
|
|
||||||
Функции:
|
|
||||||
- Статистика по PostgreSQL (количество записей по тенантам)
|
|
||||||
- SQS: approximate message count
|
|
||||||
- Статус подов bridge и consumer
|
|
||||||
- Автообновление каждые 30 секунд
|
|
||||||
@@ -57,183 +57,3 @@
|
|||||||
|
|
||||||
**Плюс:** AWS SDK for Go — стандартная библиотека, код станет проще. shared-SQS уже живой.
|
**Плюс:** AWS SDK for Go — стандартная библиотека, код станет проще. shared-SQS уже живой.
|
||||||
**Минус:** polling latency (ReceiveMessage WaitTimeSeconds до 20s) vs Kafka push. Для IoT телеметрии — приемлемо.
|
**Минус:** polling latency (ReceiveMessage WaitTimeSeconds до 20s) vs Kafka push. Для IoT телеметрии — приемлемо.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Продолжение — GitHub Copilot (Claude Opus 4.6)
|
|
||||||
|
|
||||||
### Реализация замены Kafka → SQS
|
|
||||||
|
|
||||||
**Что сделано:**
|
|
||||||
|
|
||||||
1. **mqtt-bridge** — полностью переписан:
|
|
||||||
- Убран `segmentio/kafka-go`, добавлен `aws-sdk-go-v2` (sqs, config, credentials)
|
|
||||||
- `kafka.Writer` → `sqs.Client.SendMessage`
|
|
||||||
- При старте: `GetQueueUrl` для резолва URL очереди "iot-telemetry"
|
|
||||||
- Env vars: `SQS_ENDPOINT`, `SQS_ACCESS_KEY`, `SQS_SECRET_KEY`, `SQS_QUEUE_NAME`, `SQS_REGION`
|
|
||||||
- Формат сообщения (MessageBody JSON) не изменился: `{namespace, device_id, topic, payload, received_at}`
|
|
||||||
|
|
||||||
2. **sqs-consumer** — создан с нуля (заменяет kafka-consumer):
|
|
||||||
- Long polling: `ReceiveMessage(WaitTimeSeconds=20)` — минимизирует запросы при пустой очереди
|
|
||||||
- At-least-once: `DeleteMessage` только после успешной записи в Postgres
|
|
||||||
- Использует `iotpg.IoTPostgresStore` — тот же механизм per-tenant DB что и kafka-consumer
|
|
||||||
|
|
||||||
3. **admin stats handler** — переписан:
|
|
||||||
- Вместо Kafka consumer lag → `GetQueueAttributes(ApproximateNumberOfMessages, ApproximateNumberOfMessagesNotVisible)`
|
|
||||||
- Pod labels для consumer: `iot-kafka-consumer` → `iot-sqs-consumer`
|
|
||||||
|
|
||||||
4. **Баг .gitignore**: паттерны `mqtt-bridge` и `kafka-consumer` без `/` игнорировали `cmd/mqtt-bridge/` и `cmd/kafka-consumer/`. Исправлено добавлением `/` префикса.
|
|
||||||
|
|
||||||
5. **Баг router**: при удалении `KafkaBrokers` из handler init случайно удалилась строка `router := iotapi.NewRouter(h, log)`. Восстановлена.
|
|
||||||
|
|
||||||
### Деплой в новый кластер iot-naeel
|
|
||||||
|
|
||||||
**Обнаружения при деплое:**
|
|
||||||
|
|
||||||
1. Кластер **полностью новый** — namespace `sless` не существовал, ничего не задеплоено.
|
|
||||||
|
|
||||||
2. **Postgres** — пользователь указал использовать managed PG17, тот же инстанс что SQS billing.
|
|
||||||
Credentials в `/home/naeel/terra/SQS-service/secrets/iot_pg.md`.
|
|
||||||
Самодеплоенный postgres:16-alpine из YAML заменён на DSN к managed PG.
|
|
||||||
|
|
||||||
3. **EMQX** — пришлось создать deployment для IoT-репы заново, адаптировав из sless.
|
|
||||||
Ключевое изменение: auth URL `sless-operator.sless.svc:9090` → `iot-operator.sless.svc:9090`.
|
|
||||||
|
|
||||||
4. **iot-operator deployment** — его не было в IoT-репе! Создан новый:
|
|
||||||
- ServiceAccount + ClusterRole (iotdevices CRD, secrets, events, namespaces, leases)
|
|
||||||
- ClusterRoleBinding
|
|
||||||
- Deployment + Service :9090
|
|
||||||
|
|
||||||
5. **kubectl токен** истекал за 24 часа — пользователь обновлял вручную.
|
|
||||||
|
|
||||||
6. **Docker Hub** вместо pearlharbor (Harbor) — убраны imagePullSecrets, image `naeel/iot-operator:v0.2.0`.
|
|
||||||
|
|
||||||
7. **shared-SQS tenant** создан через API:
|
|
||||||
- Tenant: `iot-service`, ID: `t-96afe7e9f781f6ca`
|
|
||||||
- Queue: `iot-telemetry`
|
|
||||||
- Admin token из Secret `shared-sqs-admin` в namespace `shared-sqs`
|
|
||||||
|
|
||||||
### Результат
|
|
||||||
|
|
||||||
Все 4 пода Running 1/1:
|
|
||||||
- `iot-operator` — controller работает, MQTT auth/acl обрабатывает запросы
|
|
||||||
- `emqx` — MQTT брокер, подключает IoT устройства
|
|
||||||
- `iot-mqtt-bridge` — подписан на EMQX, SQS queue resolved
|
|
||||||
- `iot-sqs-consumer` — подключён к PG и SQS, polling loop активен
|
|
||||||
|
|
||||||
TLS сертификат для `iot.kube5s.ru` выпускается cert-manager.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Сессия 2 — Агент: GitHub Copilot (Claude Opus 4.6)
|
|
||||||
|
|
||||||
### Контекст
|
|
||||||
Продолжение деплоя после замены Kafka→SQS. Новый кластер iot-naeel, namespace sless пустой.
|
|
||||||
|
|
||||||
### Что обнаружил
|
|
||||||
1. kubeconfig токен истёк (JWT TTL=24ч) — пользователь обновил вручную
|
|
||||||
2. Kafka отсутствует в кластере — уже нет, удалять нечего
|
|
||||||
3. Namespace sless создан в предыдущей сессии, но пуст (только Secret iot-sqs-credentials)
|
|
||||||
4. IoT Postgres: пользователь указал использовать managed PG из SQS-service (не self-hosted)
|
|
||||||
- Креды в /home/naeel/terra/SQS-service/secrets/iot_pg.md
|
|
||||||
- Host: postgresqlk8s-master.dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5.svc.cluster.local
|
|
||||||
- PG17, managed через оператор
|
|
||||||
5. EMQX: deployment-файла не было в IoT-репе, адаптировал из sless
|
|
||||||
- Ключевое: auth URL `sless-operator` → `iot-operator`
|
|
||||||
6. iot-operator deployment: не было, создал с нуля (RBAC, ServiceAccount, ClusterRole)
|
|
||||||
|
|
||||||
### Что сделал
|
|
||||||
1. Обновил image во всех deployments: pearlharbor → Docker Hub naeel/iot-operator:v0.2.0
|
|
||||||
2. Убрал imagePullSecrets (Docker Hub публичный)
|
|
||||||
3. Собрал Docker образ на ВМ, запушил в Docker Hub (v0.2.0 + latest)
|
|
||||||
4. Обновил iot-postgres.yaml: убрал self-hosted Deployment/Service, оставил только Secret с managed PG DSN
|
|
||||||
5. Создал emqx.yaml (EMQX 5.5.1 + ConfigMap + Service, auth→iot-operator)
|
|
||||||
6. Создал emqx-ws-ingress.yaml (ws Ingress + TLS cert-manager)
|
|
||||||
7. Создал iot-operator.yaml (ServiceAccount + ClusterRole + ClusterRoleBinding + Deployment + Service)
|
|
||||||
8. Установил CRD iotdevices.iot.kube5s.ru
|
|
||||||
9. Создал секреты: iot-postgres-secret, iot-bridge-credentials
|
|
||||||
10. Задеплоил всё: EMQX → operator → mqtt-bridge → sqs-consumer
|
|
||||||
11. Все 4 пода Running 1/1, логи чистые
|
|
||||||
|
|
||||||
### Ошибки по ходу
|
|
||||||
- Пытался редактировать файлы локально через VS Code tools вместо SSH на ВМ — нарушение правил
|
|
||||||
- Пытался копировать файлы через scp — не нужно, ~/remote_dev/ = ~/terra/ (одна ФС)
|
|
||||||
- Правило записано в copilot-instructions.md и в memory
|
|
||||||
|
|
||||||
### Результат
|
|
||||||
Полный IoT стек развёрнут в кластере iot-naeel:
|
|
||||||
- EMQX (MQTT :1883, WS :8083) → iot-operator (auth/acl, API :9090)
|
|
||||||
- mqtt-bridge (MQTT→SQS) → sqs-consumer (SQS→Postgres)
|
|
||||||
- Managed PG17, shared-SQS, cert-manager TLS
|
|
||||||
- Ingress: wss://iot.kube5s.ru/mqtt, https://iot.kube5s.ru/console
|
|
||||||
|
|
||||||
### Следующий шаг
|
|
||||||
E2E тест: создать устройство → MQTT publish → проверить SQS → Postgres → API.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Сессия: Документация v0.2.3 (продолжение)
|
|
||||||
|
|
||||||
Агент: GitHub Copilot (Claude Opus 4.6)
|
|
||||||
|
|
||||||
### Что сделано
|
|
||||||
|
|
||||||
1. **architecture/current-v0.2.3.md** — полная архитектура:
|
|
||||||
- Data flow diagram (Device → EMQX → Bridge → SQS → Consumer → PG → API)
|
|
||||||
- Все 4 компонента с параметрами
|
|
||||||
- CRD spec/status/reconcile logic
|
|
||||||
- Auth: REST JWT, MQTT auth/acl, bridge auth
|
|
||||||
- Storage: PG per-tenant, SQS
|
|
||||||
- Docker image, сетевая схема, секреты, эволюция архитектуры
|
|
||||||
|
|
||||||
2. **api/endpoints-v0.2.3.md** — документация API:
|
|
||||||
- Изучил router.go, handler.go, iot_device_handler.go, iot_telemetry_handler.go, iot_admin_stats_handler.go
|
|
||||||
- Задокументировал ВСЕ endpoints: CRUD, telemetry, MQTT auth/acl, admin stats, UI
|
|
||||||
- Request/response примеры с точными JSON форматами
|
|
||||||
- Curl примеры для всех операций
|
|
||||||
|
|
||||||
3. **run-and-test.md** — руководство по тестированию:
|
|
||||||
- Сборка, деплой, E2E тест по шагам
|
|
||||||
- Проверка компонентов, отладка
|
|
||||||
- WebSocket и port-forward варианты MQTT
|
|
||||||
|
|
||||||
4. **progress.md** — дополнена секция документации
|
|
||||||
|
|
||||||
### Проблемы
|
|
||||||
- Скрипт /tmp/write_arch.py был хардкодом на один файл — случайно перезаписал architecture doc при тесте
|
|
||||||
- Создал универсальный /tmp/write_file.py с аргументом пути — больше проблем нет
|
|
||||||
|
|
||||||
### Осталось
|
|
||||||
- git commit + push
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Агент: GitHub Copilot (Claude Opus 4.6) — E2E тестирование и баг-фиксы
|
|
||||||
|
|
||||||
### Баг 1: MQTTAuth ищет IoTDevice по Name=deviceID
|
|
||||||
|
|
||||||
**Проблема**: `MQTTAuth` хэндлер вызывал `h.K8s.Get(client.ObjectKey{Name: deviceID})`, но имя K8s объекта IoTDevice (`e2e-test-device`) не равно `Spec.DeviceID` (`e2e-test-01`). Результат — `deny`.
|
|
||||||
|
|
||||||
**Диагностика**:
|
|
||||||
1. Auth endpoint вернул `{"result":"deny"}` при прямом вызове curl
|
|
||||||
2. Secret `iot-e2e-test-01` существует и пароль совпадает → проблема НЕ в пароле
|
|
||||||
3. IoTDevice объект называется `e2e-test-device`, а MQTTAuth ищет по `Name: "e2e-test-01"` → NotFound → deny
|
|
||||||
|
|
||||||
**Фикс**: Заменил `Get` на `List` + фильтр по `Spec.DeviceID == deviceID`. Это O(n) по количеству устройств в namespace, но для MVP приемлемо. При необходимости можно добавить label-index.
|
|
||||||
|
|
||||||
### Баг 2: PG15+ требует GRANT перед CREATE DATABASE ... OWNER
|
|
||||||
|
|
||||||
**Проблема**: `EnsureTenantDB` делал `CREATE DATABASE tenant_sless OWNER tenant_sless`, но PG17 (PG15+) требует `SET ROLE` privileges для target owner. Ошибка: `pq: must be able to SET ROLE "tenant_sless" (42501)`.
|
|
||||||
|
|
||||||
**Фикс**: Добавил `GRANT {userName} TO CURRENT_USER` перед `CREATE DATABASE ... OWNER`.
|
|
||||||
|
|
||||||
### E2E тест v0.2.5 — полный пайплайн
|
|
||||||
|
|
||||||
1. `POST /v1/namespaces/sless/iot/devices` → 201, device created
|
|
||||||
2. `GET /devices/e2e-test-device` → phase=Active, credentials получены
|
|
||||||
3. `mosquitto_pub` → CONNACK(0), PUBLISH OK
|
|
||||||
4. mqtt-bridge → `forwarded IoT telemetry to SQS`
|
|
||||||
5. sqs-consumer → `created tenant DB` + `telemetry saved to Postgres`
|
|
||||||
6. `GET /v1/namespaces/sless/iot/telemetry?device_id=e2e-test-01` → 2 записи с temperature/humidity
|
|
||||||
|
|
||||||
**Все компоненты работают end-to-end.**
|
|
||||||
|
|||||||
@@ -3,13 +3,11 @@ module gitea.services.ngcloud.ru/Nail/IoT
|
|||||||
go 1.25
|
go 1.25
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/aws/aws-sdk-go-v2 v1.41.5
|
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.14
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/sqs v1.42.25
|
|
||||||
github.com/eclipse/paho.mqtt.golang v1.5.1
|
github.com/eclipse/paho.mqtt.golang v1.5.1
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/mux v1.8.1
|
github.com/gorilla/mux v1.8.1
|
||||||
github.com/lib/pq v1.11.2
|
github.com/lib/pq v1.11.2
|
||||||
|
github.com/segmentio/kafka-go v0.4.50
|
||||||
k8s.io/api v0.26.0
|
k8s.io/api v0.26.0
|
||||||
k8s.io/apimachinery v0.26.0
|
k8s.io/apimachinery v0.26.0
|
||||||
k8s.io/client-go v0.26.0
|
k8s.io/client-go v0.26.0
|
||||||
@@ -17,9 +15,6 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
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/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.1.2 // indirect
|
github.com/cespare/xxhash/v2 v2.1.2 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
@@ -41,11 +36,13 @@ require (
|
|||||||
github.com/imdario/mergo v0.3.6 // indirect
|
github.com/imdario/mergo v0.3.6 // indirect
|
||||||
github.com/josharian/intern v1.0.0 // indirect
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // 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/mailru/easyjson v0.7.6 // indirect
|
||||||
github.com/matttproud/golang_protobuf_extensions v1.0.2 // 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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // 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/pkg/errors v0.9.1 // indirect
|
||||||
github.com/prometheus/client_golang v1.14.0 // indirect
|
github.com/prometheus/client_golang v1.14.0 // indirect
|
||||||
github.com/prometheus/client_model v0.3.0 // indirect
|
github.com/prometheus/client_model v0.3.0 // indirect
|
||||||
|
|||||||
@@ -38,18 +38,6 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy
|
|||||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
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-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||||
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
|
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
|
||||||
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=
|
|
||||||
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
|
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
|
||||||
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
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 v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
|
||||||
@@ -200,6 +188,8 @@ github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7V
|
|||||||
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
|
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/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
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.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/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/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||||
@@ -235,6 +225,8 @@ 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/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 h1:KORJXNNTzJXzu4ScJWssJfJMnJ+2QJqhoQSRwNlze9E=
|
||||||
github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM=
|
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.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pkg/errors v0.8.1/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 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
@@ -268,6 +260,8 @@ github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1
|
|||||||
github.com/prometheus/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5mo=
|
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/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/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.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
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/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
|
||||||
@@ -284,6 +278,12 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
|||||||
github.com/stretchr/testify v1.7.0/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 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
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.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.1.27/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.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
|||||||
@@ -10,15 +10,15 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"k8s.io/apimachinery/pkg/runtime"
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
|
|
||||||
"gitea.services.ngcloud.ru/Nail/IoT/internal/storage/iotpg"
|
"gitea.services.ngcloud.ru/Nail/IoT/internal/storage/iotpg"
|
||||||
)
|
)
|
||||||
|
|
||||||
// defaultNamespace — fallback namespace для dev/тестов.
|
// defaultNamespace — fallback namespace для dev/тестов.
|
||||||
@@ -26,38 +26,36 @@ const defaultNamespace = "default"
|
|||||||
|
|
||||||
// Handler содержит зависимости для всех IoT REST-обработчиков.
|
// Handler содержит зависимости для всех IoT REST-обработчиков.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
K8s client.Client
|
K8s client.Client
|
||||||
Scheme *runtime.Scheme
|
Scheme *runtime.Scheme
|
||||||
// IoTPG — хранилище IoT телеметрии (per-tenant Postgres). nil если IOT_PG_DSN не задан.
|
// IoTPG — хранилище IoT телеметрии (per-tenant Postgres). nil если IOT_PG_DSN не задан.
|
||||||
IoTPG *iotpg.IoTPostgresStore
|
IoTPG *iotpg.IoTPostgresStore
|
||||||
Log *slog.Logger
|
// KafkaBrokers — адреса Kafka брокеров для чтения consumer lag на admin странице.
|
||||||
// BridgeUsername â username внÑÑÑеннего MQTT bridge (env MQTT_BRIDGE_USERNAME)
|
KafkaBrokers string
|
||||||
BridgeUsername string
|
Log *slog.Logger
|
||||||
// BridgePassword â паÑÐ¾Ð»Ñ bridge (env MQTT_BRIDGE_PASSWORD)
|
|
||||||
BridgePassword string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeJSON отправляет JSON-ответ с указанным статусом.
|
// writeJSON отправляет JSON-ответ с указанным статусом.
|
||||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
_ = json.NewEncoder(w).Encode(v)
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
// errResp возвращает структуру ошибки для JSON.
|
// errResp возвращает структуру ошибки для JSON.
|
||||||
func errResp(msg string) map[string]string {
|
func errResp(msg string) map[string]string {
|
||||||
return map[string]string{"error": msg}
|
return map[string]string{"error": msg}
|
||||||
}
|
}
|
||||||
|
|
||||||
// pathVar читает переменную пути из gorilla/mux.
|
// pathVar читает переменную пути из gorilla/mux.
|
||||||
func pathVar(r *http.Request, key string) string {
|
func pathVar(r *http.Request, key string) string {
|
||||||
return mux.Vars(r)[key]
|
return mux.Vars(r)[key]
|
||||||
}
|
}
|
||||||
|
|
||||||
// namespace читает {namespace} из пути URL.
|
// namespace читает {namespace} из пути URL.
|
||||||
func namespace(r *http.Request) string {
|
func namespace(r *http.Request) string {
|
||||||
if ns := mux.Vars(r)["namespace"]; ns != "" {
|
if ns := mux.Vars(r)["namespace"]; ns != "" {
|
||||||
return ns
|
return ns
|
||||||
}
|
}
|
||||||
return defaultNamespace
|
return defaultNamespace
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
// Создано: 2026-04-06
|
// Создано: 2026-04-06
|
||||||
// Изменено: 2026-04-12 (замена Kafka lag → SQS queue stats)
|
|
||||||
// iot_admin_stats_handler.go — handler для страницы администратора IoT.
|
// iot_admin_stats_handler.go — handler для страницы администратора IoT.
|
||||||
//
|
//
|
||||||
// Endpoints:
|
// Endpoints:
|
||||||
@@ -7,8 +6,8 @@
|
|||||||
//
|
//
|
||||||
// Источники данных:
|
// Источники данных:
|
||||||
// - PostgreSQL (IoTPG): counts per tenant, last 1h/24h, latest rows
|
// - PostgreSQL (IoTPG): counts per tenant, last 1h/24h, latest rows
|
||||||
// - SQS: approximate message count (ApproximateNumberOfMessages)
|
// - Kafka: consumer lag (latest offset - committed offset для group iot-pg-consumer)
|
||||||
// - K8s: статус подов iot-mqtt-bridge и iot-sqs-consumer
|
// - K8s: статус подов iot-mqtt-bridge и iot-kafka-consumer
|
||||||
//
|
//
|
||||||
// Авторизация: Bearer из env ADMIN_STATS_TOKEN.
|
// Авторизация: Bearer из env ADMIN_STATS_TOKEN.
|
||||||
// Если ADMIN_STATS_TOKEN не задан — endpoint возвращает 503.
|
// Если ADMIN_STATS_TOKEN не задан — endpoint возвращает 503.
|
||||||
@@ -20,14 +19,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/aws/aws-sdk-go-v2/aws"
|
kafka "github.com/segmentio/kafka-go"
|
||||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
|
||||||
"github.com/aws/aws-sdk-go-v2/service/sqs"
|
|
||||||
sqstypes "github.com/aws/aws-sdk-go-v2/service/sqs/types"
|
|
||||||
corev1 "k8s.io/api/core/v1"
|
corev1 "k8s.io/api/core/v1"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
)
|
)
|
||||||
@@ -41,11 +36,12 @@ type iotAdminPodStatus struct {
|
|||||||
Age string `json:"age"`
|
Age string `json:"age"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// iotAdminSQSStats — информация о SQS очереди для мониторинга.
|
// iotAdminKafkaStats — информация о Kafka топике и consumer lag.
|
||||||
type iotAdminSQSStats struct {
|
type iotAdminKafkaStats struct {
|
||||||
ApproximateMessages int64 `json:"approximate_messages"`
|
LatestOffset int64 `json:"latest_offset"`
|
||||||
ApproximateMessagesNotVisible int64 `json:"approximate_messages_not_visible"`
|
CommittedOffset int64 `json:"committed_offset"`
|
||||||
Error string `json:"error,omitempty"`
|
ConsumerLag int64 `json:"consumer_lag"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminStats обрабатывает GET /iot-admin/stats.
|
// AdminStats обрабатывает GET /iot-admin/stats.
|
||||||
@@ -82,8 +78,8 @@ func (h *Handler) AdminStats(w http.ResponseWriter, r *http.Request) {
|
|||||||
result["postgres"] = map[string]any{"reachable": false, "error": "IoTPG not configured"}
|
result["postgres"] = map[string]any{"reachable": false, "error": "IoTPG not configured"}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SQS: approximate message count для очереди iot-telemetry
|
// Kafka: consumer lag для топика iot.telemetry / группы iot-pg-consumer
|
||||||
result["sqs"] = h.collectIotSQSStats(ctx)
|
result["kafka"] = h.collectIotKafkaLag(ctx)
|
||||||
|
|
||||||
// K8s: статус подов bridge и consumer
|
// K8s: статус подов bridge и consumer
|
||||||
result["pods"] = h.collectIotPodStatuses(ctx)
|
result["pods"] = h.collectIotPodStatuses(ctx)
|
||||||
@@ -91,59 +87,72 @@ func (h *Handler) AdminStats(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, result)
|
writeJSON(w, http.StatusOK, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// collectIotSQSStats получает ApproximateNumberOfMessages из SQS очереди.
|
// collectIotKafkaLag получает latest offset топика и committed offset consumer group,
|
||||||
// Использует SQS_ENDPOINT, SQS_ACCESS_KEY, SQS_SECRET_KEY, SQS_QUEUE_NAME из env.
|
// вычисляет lag = latest - committed.
|
||||||
func (h *Handler) collectIotSQSStats(ctx context.Context) iotAdminSQSStats {
|
// Topic: "iot.telemetry", Consumer Group: "iot-pg-consumer".
|
||||||
endpoint := os.Getenv("SQS_ENDPOINT")
|
func (h *Handler) collectIotKafkaLag(ctx context.Context) iotAdminKafkaStats {
|
||||||
accessKey := os.Getenv("SQS_ACCESS_KEY")
|
if h.KafkaBrokers == "" {
|
||||||
secretKey := os.Getenv("SQS_SECRET_KEY")
|
return iotAdminKafkaStats{Error: "KAFKA_BROKERS not configured"}
|
||||||
queueName := os.Getenv("SQS_QUEUE_NAME")
|
|
||||||
if queueName == "" {
|
|
||||||
queueName = "iot-telemetry"
|
|
||||||
}
|
|
||||||
region := os.Getenv("SQS_REGION")
|
|
||||||
if region == "" {
|
|
||||||
region = "us-east-1"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if endpoint == "" || accessKey == "" || secretKey == "" {
|
brokers := strings.Split(h.KafkaBrokers, ",")
|
||||||
return iotAdminSQSStats{Error: "SQS credentials not configured (SQS_ENDPOINT, SQS_ACCESS_KEY, SQS_SECRET_KEY)"}
|
brokerAddr := kafka.TCP(brokers...)
|
||||||
|
|
||||||
|
kc := &kafka.Client{
|
||||||
|
Addr: brokerAddr,
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
sqsClient := sqs.New(sqs.Options{
|
const topic = "iot.telemetry"
|
||||||
Region: region,
|
const group = "iot-pg-consumer"
|
||||||
Credentials: credentials.NewStaticCredentialsProvider(
|
|
||||||
accessKey, secretKey, "",
|
|
||||||
),
|
|
||||||
BaseEndpoint: aws.String(endpoint),
|
|
||||||
})
|
|
||||||
|
|
||||||
// Получаем URL очереди
|
// Получаем latest offset (конец лога — сколько всего сообщений прошло)
|
||||||
queueUrlOut, err := sqsClient.GetQueueUrl(ctx, &sqs.GetQueueUrlInput{
|
offsetsResp, err := kc.ListOffsets(ctx, &kafka.ListOffsetsRequest{
|
||||||
QueueName: aws.String(queueName),
|
Addr: brokerAddr,
|
||||||
})
|
Topics: map[string][]kafka.OffsetRequest{
|
||||||
if err != nil {
|
topic: {kafka.LastOffsetOf(0)},
|
||||||
return iotAdminSQSStats{Error: fmt.Sprintf("GetQueueUrl: %v", err)}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Запрашиваем атрибуты очереди — approximate message counts
|
|
||||||
attrsOut, err := sqsClient.GetQueueAttributes(ctx, &sqs.GetQueueAttributesInput{
|
|
||||||
QueueUrl: queueUrlOut.QueueUrl,
|
|
||||||
AttributeNames: []sqstypes.QueueAttributeName{
|
|
||||||
sqstypes.QueueAttributeNameApproximateNumberOfMessages,
|
|
||||||
sqstypes.QueueAttributeNameApproximateNumberOfMessagesNotVisible,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return iotAdminSQSStats{Error: fmt.Sprintf("GetQueueAttributes: %v", err)}
|
return iotAdminKafkaStats{Error: fmt.Sprintf("list offsets: %v", err)}
|
||||||
}
|
}
|
||||||
|
|
||||||
approxMsg, _ := strconv.ParseInt(attrsOut.Attributes["ApproximateNumberOfMessages"], 10, 64)
|
var latestOffset int64
|
||||||
approxNotVisible, _ := strconv.ParseInt(attrsOut.Attributes["ApproximateNumberOfMessagesNotVisible"], 10, 64)
|
if partitions, ok := offsetsResp.Topics[topic]; ok && len(partitions) > 0 {
|
||||||
|
if partitions[0].Error == nil {
|
||||||
|
latestOffset = partitions[0].LastOffset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return iotAdminSQSStats{
|
// Получаем committed offset consumer group (что consumer уже обработал)
|
||||||
ApproximateMessages: approxMsg,
|
fetchResp, err := kc.OffsetFetch(ctx, &kafka.OffsetFetchRequest{
|
||||||
ApproximateMessagesNotVisible: approxNotVisible,
|
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +160,7 @@ func (h *Handler) collectIotSQSStats(ctx context.Context) iotAdminSQSStats {
|
|||||||
func (h *Handler) collectIotPodStatuses(ctx context.Context) map[string]any {
|
func (h *Handler) collectIotPodStatuses(ctx context.Context) map[string]any {
|
||||||
result := map[string]any{}
|
result := map[string]any{}
|
||||||
|
|
||||||
for _, appLabel := range []string{"iot-mqtt-bridge", "iot-sqs-consumer"} {
|
for _, appLabel := range []string{"iot-mqtt-bridge", "iot-kafka-consumer"} {
|
||||||
podList := &corev1.PodList{}
|
podList := &corev1.PodList{}
|
||||||
if err := h.K8s.List(ctx, podList,
|
if err := h.K8s.List(ctx, podList,
|
||||||
client.InNamespace("sless"),
|
client.InNamespace("sless"),
|
||||||
|
|||||||
@@ -151,18 +151,6 @@ func (h *Handler) MQTTAuth(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Bridge â внÑÑÑенний MQTT bridge клиенÑ. ÐÑовеÑÑем ÑикÑиÑованнÑе credentials
|
|
||||||
// до паÑÑинга namespace_deviceId.
|
|
||||||
if h.BridgeUsername != "" && req.Username == h.BridgeUsername {
|
|
||||||
if subtle.ConstantTimeCompare([]byte(h.BridgePassword), []byte(req.Password)) == 1 {
|
|
||||||
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "allow"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Парсим username: "{namespace}_{deviceId}"
|
// Парсим username: "{namespace}_{deviceId}"
|
||||||
// Namespace содержит только [a-z0-9-], первый "_" — разделитель.
|
// Namespace содержит только [a-z0-9-], первый "_" — разделитель.
|
||||||
idx := strings.Index(req.Username, "_")
|
idx := strings.Index(req.Username, "_")
|
||||||
@@ -193,21 +181,10 @@ func (h *Handler) MQTTAuth(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ищем IoTDevice по Spec.DeviceID (имя K8s объекта может отличаться от deviceID)
|
// Проверяем что IoTDevice активно
|
||||||
deviceList := &iotv1alpha1.IoTDeviceList{}
|
device := &iotv1alpha1.IoTDevice{}
|
||||||
if err := h.K8s.List(r.Context(), deviceList, client.InNamespace(ns)); err != nil {
|
if err := h.K8s.Get(r.Context(), client.ObjectKey{Namespace: ns, Name: deviceID}, device); err != nil {
|
||||||
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
|
// IoTDevice не найден (или удалён) — deny
|
||||||
return
|
|
||||||
}
|
|
||||||
var device *iotv1alpha1.IoTDevice
|
|
||||||
for i := range deviceList.Items {
|
|
||||||
if deviceList.Items[i].Spec.DeviceID == deviceID {
|
|
||||||
device = &deviceList.Items[i]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if device == nil {
|
|
||||||
// IoTDevice с таким deviceID не найден — deny
|
|
||||||
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
|
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -450,9 +427,6 @@ func (h *Handler) MQTTAcl(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Bridge â внÑÑÑенний MQTT bridge клиенÑ. ÐÑовеÑÑем ÑикÑиÑованнÑе credentials
|
|
||||||
// до паÑÑинга namespace_deviceId.
|
|
||||||
// Парсим username → namespace + deviceId (формат: "{ns}_{deviceId}")
|
// Парсим username → namespace + deviceId (формат: "{ns}_{deviceId}")
|
||||||
// strings.Index находит ПЕРВЫЙ '_' — namespace содержит только дефисы
|
// strings.Index находит ПЕРВЫЙ '_' — namespace содержит только дефисы
|
||||||
idx := strings.Index(req.Username, "_")
|
idx := strings.Index(req.Username, "_")
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func (h *Handler) ListIoTTelemetry(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ns := mux.Vars(r)["namespace"]
|
ns := mux.Vars(r)["namespace"]
|
||||||
deviceID := r.URL.Query().Get("device_id")
|
deviceID := r.URL.Query().Get("device")
|
||||||
limit := 50
|
limit := 50
|
||||||
if ls := r.URL.Query().Get("limit"); ls != "" {
|
if ls := r.URL.Query().Get("limit"); ls != "" {
|
||||||
if n, err := strconv.Atoi(ls); err == nil && n > 0 {
|
if n, err := strconv.Atoi(ls); err == nil && n > 0 {
|
||||||
|
|||||||
@@ -126,13 +126,6 @@ func (s *IoTPostgresStore) EnsureTenantDB(ctx context.Context, namespace string)
|
|||||||
return fmt.Errorf("iotpg: create user %s: %w", userName, err)
|
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,
|
|
||||||
fmt.Sprintf(`GRANT %s TO CURRENT_USER`, userName),
|
|
||||||
); err != nil {
|
|
||||||
return fmt.Errorf("iotpg: grant role %s: %w", userName, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CREATE DATABASE нельзя в транзакции
|
// CREATE DATABASE нельзя в транзакции
|
||||||
if _, err = s.adminDB.ExecContext(ctx,
|
if _, err = s.adminDB.ExecContext(ctx,
|
||||||
fmt.Sprintf(`CREATE DATABASE %s OWNER %s`, dbName, userName),
|
fmt.Sprintf(`CREATE DATABASE %s OWNER %s`, dbName, userName),
|
||||||
|
|||||||
Reference in New Issue
Block a user