Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76c11d5476 | ||
|
|
f0cc6df9a6 | ||
|
|
d5a177e23c | ||
|
|
907aaad100 | ||
|
|
4cba163ace | ||
|
|
d4f7c5abc8 | ||
|
|
150146edba | ||
|
|
6caab6b729 |
@@ -1,175 +1,10 @@
|
|||||||
# Правила работы агента в проекте 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
|
|
||||||
|
|
||||||
Коммитить и пушить после каждого завершённого этапа.
|
|
||||||
|
|||||||
+20
-63
@@ -1,35 +1,23 @@
|
|||||||
# Правила работы агента
|
# Правила работы агента
|
||||||
|
|
||||||
## ⛔⛔⛔ DOCKER — ОБЯЗАТЕЛЬНЫЙ ПОРЯДОК ПЕРЕД КАЖДЫМ BUILD
|
## Файловая система
|
||||||
|
|
||||||
1. УВЕЛИЧИТЬ ТЕГ в `deployments/k8s/iot-console.yaml` (vX.Y.Z → vX.Y.Z+1)
|
`~/remote_dev/` (локально) примонтирован через sshfs к `~/terra/` на ВМ — **одна ФС**.
|
||||||
2. rsync на ВМ
|
Файлы, сохранённые локально, мгновенно видны на ВМ. SCP не нужен.
|
||||||
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`
|
# Примонтировать
|
||||||
2. После любых изменений — обязательно rsync на ВМ:
|
sshfs naeel@5.172.178.213:/home/naeel/terra ~/remote_dev \
|
||||||
rsync -az \
|
-o cache=no -o no_readahead -o reconnect \
|
||||||
-e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \
|
-o ServerAliveInterval=15 -o ServerAliveCountMax=3 \
|
||||||
~/IoT/ \
|
-o IdentityFile=~/.ssh/naeel_vm_id_ed25519
|
||||||
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
|
||||||
|
|
||||||
@@ -49,11 +37,7 @@ 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 — любое изменение кода
|
||||||
@@ -64,36 +48,9 @@ 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 из старых сессий — МУСОР. Там старые прогоны.
|
- Не делать ничего сверх того, о чём явно попросили
|
||||||
- Всегда запускать новую команду через SSH и читать её вывод напрямую.
|
- Деструктивные операции (`kubectl delete`, `rm -rf`, `terraform destroy` и др.) — только после явного подтверждения с указанием конкретных объектов
|
||||||
- НИКОГДА не читать буфер терминала из предыдущей сессии как актуальные данные.
|
- Отвечать кратко, без вступлений, извинений, благодарностей и прочей воды
|
||||||
- Актуальный результат — только из команды, которая была запущена СЕЙЧАС.
|
|
||||||
|
|
||||||
## ⛔ ДОКУМЕНТАЦИЯ ТЕСТ-ПРОГОН�ОВ — В РЕАЛЬНОМ ВРЕМЕНИ
|
|
||||||
|
|
||||||
**Правила:**
|
|
||||||
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 → регрессия.
|
|
||||||
- Исключение: только если пользователь явно написал "примени ручной патч".
|
|
||||||
|
|||||||
+5
-4
@@ -1,7 +1,8 @@
|
|||||||
# Бинарники
|
# Бинарники (только в корне проекта, не директории cmd/)
|
||||||
iot-operator
|
/iot-operator
|
||||||
mqtt-bridge
|
/mqtt-bridge
|
||||||
kafka-consumer
|
/kafka-consumer
|
||||||
|
/sqs-consumer
|
||||||
bin/
|
bin/
|
||||||
|
|
||||||
# Go
|
# Go
|
||||||
|
|||||||
+6
-5
@@ -1,6 +1,7 @@
|
|||||||
# Создано: 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, kafka-consumer).
|
# Multi-stage build: 3 бинарника (iot-operator, mqtt-bridge, sqs-consumer).
|
||||||
# Образ: naeel/iot-operator (Docker Hub).
|
# Образ: naeel/iot-operator (Docker Hub).
|
||||||
|
|
||||||
FROM golang:1.25 AS builder
|
FROM golang:1.25 AS builder
|
||||||
@@ -19,11 +20,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) → Kafka
|
# mqtt-bridge — MQTT (EMQX) → shared-SQS
|
||||||
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/
|
||||||
|
|
||||||
# kafka-consumer — Kafka → Postgres
|
# sqs-consumer — shared-SQS → Postgres
|
||||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o kafka-consumer ./cmd/kafka-consumer/
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o sqs-consumer ./cmd/sqs-consumer/
|
||||||
|
|
||||||
# --- Runtime ---
|
# --- Runtime ---
|
||||||
FROM gcr.io/distroless/static:nonroot
|
FROM gcr.io/distroless/static:nonroot
|
||||||
@@ -33,7 +34,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/kafka-consumer .
|
COPY --from=builder /workspace/sqs-consumer .
|
||||||
|
|
||||||
USER 65532:65532
|
USER 65532:65532
|
||||||
ENTRYPOINT ["/iot-operator"]
|
ENTRYPOINT ["/iot-operator"]
|
||||||
|
|||||||
@@ -15,16 +15,7 @@ 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/kafka-consumer ./cmd/kafka-consumer/
|
CGO_ENABLED=0 go build -o bin/sqs-consumer ./cmd/sqs-consumer/
|
||||||
|
|
||||||
# Docker
|
|
||||||
docker-build:
|
|
||||||
docker build -t $(IMG) .
|
|
||||||
|
|
||||||
docker-push:
|
|
||||||
docker push $(IMG)
|
|
||||||
|
|
||||||
# Тесты и go mod
|
|
||||||
test:
|
test:
|
||||||
go test ./...
|
go test ./...
|
||||||
|
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
# IoT Managed Service
|
|
||||||
|
|
||||||
## Назначение
|
|
||||||
|
|
||||||
IoT Managed Service — это облачный сервис для управления IoT-устройствами, сбора и хранения телеметрии, а также интеграции с внешними системами через MQTT, Kafka и REST API.
|
|
||||||
|
|
||||||
Сервис автоматизирует регистрацию устройств, выдачу credentials, сбор телеметрии и предоставляет REST-интерфейс для управления и мониторинга.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Архитектура
|
|
||||||
|
|
||||||
- **IoT Device** — физическое устройство, подключающееся по MQTT
|
|
||||||
- **EMQX** — брокер MQTT, делегирует auth/ACL в iot-operator
|
|
||||||
- **iot-mqtt-bridge** — подписка на все топики, проксирует в Kafka
|
|
||||||
- **Kafka** — очередь событий телеметрии
|
|
||||||
- **iot-kafka-consumer** — сохраняет телеметрию в per-tenant Postgres
|
|
||||||
- **IoT Postgres** — база данных для хранения телеметрии
|
|
||||||
- **REST API** — управление устройствами, получение телеметрии
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Основные компоненты
|
|
||||||
|
|
||||||
| Компонент | Назначение |
|
|
||||||
|-------------------|-----------------------------------|
|
|
||||||
| iot-operator | REST API, контроллер CRD |
|
|
||||||
| iot-mqtt-bridge | MQTT → Kafka |
|
|
||||||
| iot-kafka-consumer| Kafka → Postgres |
|
|
||||||
| iot-postgres | Хранение телеметрии |
|
|
||||||
| EMQX | MQTT брокер |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Основные команды и эндпоинты
|
|
||||||
|
|
||||||
### Публичные
|
|
||||||
- `GET /console` — IoT Console (SPA)
|
|
||||||
- `GET /iot-admin` — страница администратора
|
|
||||||
|
|
||||||
### JWT (namespace)
|
|
||||||
- `POST /v1/namespaces/{ns}/iot/devices` — создать устройство
|
|
||||||
- `GET /v1/namespaces/{ns}/iot/devices` — список устройств
|
|
||||||
- `GET /v1/namespaces/{ns}/iot/devices/{name}` — получить устройство + пароль
|
|
||||||
- `DELETE /v1/namespaces/{ns}/iot/devices/{name}` — удалить устройство
|
|
||||||
- `PATCH /v1/namespaces/{ns}/iot/devices/{name}` — обновить (enabled)
|
|
||||||
- `GET /v1/namespaces/{ns}/iot/telemetry` — получить телеметрию
|
|
||||||
|
|
||||||
### Внутренние (без JWT)
|
|
||||||
- `POST /internal/mqtt/auth` — MQTT auth для EMQX
|
|
||||||
- `POST /internal/mqtt/acl` — MQTT ACL для EMQX
|
|
||||||
|
|
||||||
### Админ
|
|
||||||
- `GET /iot-admin/stats` — статистика (PG, Kafka, pods)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Быстрый старт
|
|
||||||
|
|
||||||
1. Развернуть EMQX, iot-mqtt-bridge, event-dispatcher, iot-kafka-consumer, iot-postgres (см. deployments/k8s/)
|
|
||||||
2. Зарегистрировать устройство через REST API или CRD
|
|
||||||
3. Подключить устройство к MQTT (логин/пароль выдаются автоматически)
|
|
||||||
4. Телеметрия автоматически попадёт в Postgres и доступна через REST API
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Документация
|
|
||||||
- doc/architecture/overview.md — архитектура
|
|
||||||
- doc/api/endpoints.md — список эндпоинтов
|
|
||||||
- doc/deployment.md — деплой и инженерные детали
|
|
||||||
- examples/README.md — пример E2E цепочки
|
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
// Создано: 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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
// Создано: 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
// Создано: 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Создано: 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
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
# Создано: 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,25 +1,18 @@
|
|||||||
# Создано: 2026-04-04
|
# Создано: 2026-04-04
|
||||||
# Изменено: 2026-04-06 (MQTT→Kafka: убран RABBITMQ_URL, добавлен KAFKA_BROKERS, v0.1.67)
|
# Изменено: 2026-04-12 (замена Kafka → shared-SQS)
|
||||||
# Deployment iot-mqtt-bridge — MQTT→Kafka мост для IoT.
|
# Deployment iot-mqtt-bridge — MQTT→SQS мост для IoT.
|
||||||
#
|
#
|
||||||
# Получает MQTT сообщения от EMQX (подписка на "+/telemetry/+")
|
# Получает MQTT сообщения от EMQX (подписка на "+/telemetry/+")
|
||||||
# и публикует в Kafka топик "iot.telemetry" (ключ = namespace).
|
# и отправляет в shared-SQS очередь "iot-telemetry" через AWS SDK.
|
||||||
#
|
#
|
||||||
# Credentials для MQTT подключения берутся из Secret iot-bridge-credentials.
|
# Credentials для MQTT подключения берутся из Secret iot-bridge-credentials.
|
||||||
# Этот Secret нужно создать вручную ДО деплоя:
|
# Credentials для SQS берутся из Secret iot-sqs-credentials.
|
||||||
#
|
#
|
||||||
# # 1. Создать IoTDevice для bridge через API:
|
# Создание SQS Secret (один раз):
|
||||||
# curl -X POST .../v1/namespaces/sless-bridge/iot/devices \
|
# kubectl create secret generic iot-sqs-credentials -n sless \
|
||||||
# -d '{"name":"bridge","device_id":"bridge","enabled":true}'
|
# --from-literal=SQS_ENDPOINT="https://qu.kube5s.ru" \
|
||||||
#
|
# --from-literal=SQS_ACCESS_KEY="<access_key>" \
|
||||||
# # 2. Получить credentials:
|
# --from-literal=SQS_SECRET_KEY="<secret_key>"
|
||||||
# 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
|
||||||
|
|
||||||
@@ -45,21 +38,22 @@ spec:
|
|||||||
- name: mqtt-bridge
|
- name: mqtt-bridge
|
||||||
# Тот же образ что и оператор — оба бинаря в одном слое (manager + iot-mqtt-bridge).
|
# Тот же образ что и оператор — оба бинаря в одном слое (manager + iot-mqtt-bridge).
|
||||||
# При смене версии оператора — менять тег и здесь.
|
# При смене версии оператора — менять тег и здесь.
|
||||||
image: pearlharbor.registryk8s.services.ngcloud.ru/naeel/sless-operator:v0.1.69
|
image: naeel/iot-operator:v0.2.6
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
command: ["/iot-mqtt-bridge"]
|
command: ["/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: KAFKA_BROKERS
|
- name: SQS_QUEUE_NAME
|
||||||
value: "kafka.sless.svc.cluster.local:9092"
|
value: "iot-telemetry"
|
||||||
|
- name: SQS_REGION
|
||||||
|
value: "us-east-1"
|
||||||
envFrom:
|
envFrom:
|
||||||
- secretRef:
|
- secretRef:
|
||||||
name: iot-bridge-credentials
|
name: iot-bridge-credentials
|
||||||
# IOT_PG_DSN — сохранение телеметрии в Postgres (опционально)
|
# SQS_ENDPOINT, SQS_ACCESS_KEY, SQS_SECRET_KEY
|
||||||
- secretRef:
|
- secretRef:
|
||||||
name: iot-postgres-secret
|
name: iot-sqs-credentials
|
||||||
optional: true
|
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
memory: "32Mi"
|
memory: "32Mi"
|
||||||
@@ -67,5 +61,3 @@ spec:
|
|||||||
limits:
|
limits:
|
||||||
memory: "64Mi"
|
memory: "64Mi"
|
||||||
cpu: "100m"
|
cpu: "100m"
|
||||||
imagePullSecrets:
|
|
||||||
- name: sless-registry-auth
|
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
# Создано: 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,11 +1,16 @@
|
|||||||
# Создано: 2026-04-05
|
# Создано: 2026-04-05
|
||||||
# Postgres для IoT телеметрии — отдельный от sless postgres (тот для invocations логов).
|
# Изменено: 2026-04-12 (заменён self-hosted на managed Postgres через оператор)
|
||||||
# Deployment (не StatefulSet) — для dev/demo. В prod заменить на managed Postgres.
|
|
||||||
#
|
#
|
||||||
# Суперюзер iot_admin используется оператором для:
|
# Managed PostgreSQL 17 — тот же инстанс что использует shared-SQS.
|
||||||
|
# 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
|
||||||
@@ -13,54 +18,9 @@ metadata:
|
|||||||
name: iot-postgres-secret
|
name: iot-postgres-secret
|
||||||
namespace: sless
|
namespace: sless
|
||||||
stringData:
|
stringData:
|
||||||
# Суперпользователь — для управления tenant databases
|
# Суперпользователь managed Postgres
|
||||||
POSTGRES_USER: "iot_admin"
|
POSTGRES_USER: "super"
|
||||||
POSTGRES_PASSWORD: "iot-pg-super-2026"
|
POSTGRES_PASSWORD: "BQUF5ruECa1ZFlq4wYt3gPJUEmtBMkA9QNK4MM5Sd8al4ArMDlmT16DIKHYBPyif"
|
||||||
POSTGRES_DB: "iot_platform"
|
POSTGRES_DB: "sqsdb"
|
||||||
# DSN для оператора и mqtt-bridge (superuser к management DB)
|
# DSN для iot-operator и sqs-consumer (superuser к managed DB)
|
||||||
IOT_PG_DSN: "postgresql://iot_admin:iot-pg-super-2026@iot-postgres.sless.svc:5432/iot_platform?sslmode=disable"
|
IOT_PG_DSN: "postgresql://super:BQUF5ruECa1ZFlq4wYt3gPJUEmtBMkA9QNK4MM5Sd8al4ArMDlmT16DIKHYBPyif@postgresqlk8s-master.dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5.svc.cluster.local:5432/sqsdb?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
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Создано: 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"
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
# 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,3 +30,25 @@
|
|||||||
| Метод | Путь | Описание |
|
| Метод | Путь | Описание |
|
||||||
|-------|------|----------|
|
|-------|------|----------|
|
||||||
| 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` — количество сообщений в очереди, ожидающих обработки.
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
# 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,3 +64,171 @@ 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`
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Решение: замена 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`. Если есть внутрикластерный сервис — обновим.
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
# 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,3 +304,101 @@ 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,3 +35,105 @@
|
|||||||
- [ ] Деплой в кластер
|
- [ ] Деплой в кластер
|
||||||
- [ ] 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`
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
# 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,3 +57,183 @@
|
|||||||
|
|
||||||
**Плюс:** 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,11 +3,13 @@ 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
|
||||||
@@ -15,6 +17,9 @@ 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
|
||||||
@@ -36,13 +41,11 @@ 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,6 +38,18 @@ 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=
|
||||||
@@ -188,8 +200,6 @@ 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=
|
||||||
@@ -225,8 +235,6 @@ 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=
|
||||||
@@ -260,8 +268,6 @@ 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=
|
||||||
@@ -278,12 +284,6 @@ 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,36 +26,38 @@ 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
|
||||||
// KafkaBrokers — адреса Kafka брокеров для чтения consumer lag на admin странице.
|
Log *slog.Logger
|
||||||
KafkaBrokers string
|
// BridgeUsername â username внÑÑÑеннего MQTT bridge (env MQTT_BRIDGE_USERNAME)
|
||||||
Log *slog.Logger
|
BridgeUsername string
|
||||||
|
// 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,4 +1,5 @@
|
|||||||
// Создано: 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:
|
||||||
@@ -6,8 +7,8 @@
|
|||||||
//
|
//
|
||||||
// Источники данных:
|
// Источники данных:
|
||||||
// - PostgreSQL (IoTPG): counts per tenant, last 1h/24h, latest rows
|
// - PostgreSQL (IoTPG): counts per tenant, last 1h/24h, latest rows
|
||||||
// - Kafka: consumer lag (latest offset - committed offset для group iot-pg-consumer)
|
// - SQS: approximate message count (ApproximateNumberOfMessages)
|
||||||
// - K8s: статус подов iot-mqtt-bridge и iot-kafka-consumer
|
// - K8s: статус подов iot-mqtt-bridge и iot-sqs-consumer
|
||||||
//
|
//
|
||||||
// Авторизация: Bearer из env ADMIN_STATS_TOKEN.
|
// Авторизация: Bearer из env ADMIN_STATS_TOKEN.
|
||||||
// Если ADMIN_STATS_TOKEN не задан — endpoint возвращает 503.
|
// Если ADMIN_STATS_TOKEN не задан — endpoint возвращает 503.
|
||||||
@@ -19,10 +20,14 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
kafka "github.com/segmentio/kafka-go"
|
"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"
|
||||||
|
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"
|
||||||
)
|
)
|
||||||
@@ -36,12 +41,11 @@ type iotAdminPodStatus struct {
|
|||||||
Age string `json:"age"`
|
Age string `json:"age"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// iotAdminKafkaStats — информация о Kafka топике и consumer lag.
|
// iotAdminSQSStats — информация о SQS очереди для мониторинга.
|
||||||
type iotAdminKafkaStats struct {
|
type iotAdminSQSStats struct {
|
||||||
LatestOffset int64 `json:"latest_offset"`
|
ApproximateMessages int64 `json:"approximate_messages"`
|
||||||
CommittedOffset int64 `json:"committed_offset"`
|
ApproximateMessagesNotVisible int64 `json:"approximate_messages_not_visible"`
|
||||||
ConsumerLag int64 `json:"consumer_lag"`
|
Error string `json:"error,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminStats обрабатывает GET /iot-admin/stats.
|
// AdminStats обрабатывает GET /iot-admin/stats.
|
||||||
@@ -78,8 +82,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"}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kafka: consumer lag для топика iot.telemetry / группы iot-pg-consumer
|
// SQS: approximate message count для очереди iot-telemetry
|
||||||
result["kafka"] = h.collectIotKafkaLag(ctx)
|
result["sqs"] = h.collectIotSQSStats(ctx)
|
||||||
|
|
||||||
// K8s: статус подов bridge и consumer
|
// K8s: статус подов bridge и consumer
|
||||||
result["pods"] = h.collectIotPodStatuses(ctx)
|
result["pods"] = h.collectIotPodStatuses(ctx)
|
||||||
@@ -87,72 +91,59 @@ func (h *Handler) AdminStats(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, result)
|
writeJSON(w, http.StatusOK, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// collectIotKafkaLag получает latest offset топика и committed offset consumer group,
|
// collectIotSQSStats получает ApproximateNumberOfMessages из SQS очереди.
|
||||||
// вычисляет lag = latest - committed.
|
// Использует SQS_ENDPOINT, SQS_ACCESS_KEY, SQS_SECRET_KEY, SQS_QUEUE_NAME из env.
|
||||||
// Topic: "iot.telemetry", Consumer Group: "iot-pg-consumer".
|
func (h *Handler) collectIotSQSStats(ctx context.Context) iotAdminSQSStats {
|
||||||
func (h *Handler) collectIotKafkaLag(ctx context.Context) iotAdminKafkaStats {
|
endpoint := os.Getenv("SQS_ENDPOINT")
|
||||||
if h.KafkaBrokers == "" {
|
accessKey := os.Getenv("SQS_ACCESS_KEY")
|
||||||
return iotAdminKafkaStats{Error: "KAFKA_BROKERS not configured"}
|
secretKey := os.Getenv("SQS_SECRET_KEY")
|
||||||
|
queueName := os.Getenv("SQS_QUEUE_NAME")
|
||||||
|
if queueName == "" {
|
||||||
|
queueName = "iot-telemetry"
|
||||||
|
}
|
||||||
|
region := os.Getenv("SQS_REGION")
|
||||||
|
if region == "" {
|
||||||
|
region = "us-east-1"
|
||||||
}
|
}
|
||||||
|
|
||||||
brokers := strings.Split(h.KafkaBrokers, ",")
|
if endpoint == "" || accessKey == "" || secretKey == "" {
|
||||||
brokerAddr := kafka.TCP(brokers...)
|
return iotAdminSQSStats{Error: "SQS credentials not configured (SQS_ENDPOINT, SQS_ACCESS_KEY, SQS_SECRET_KEY)"}
|
||||||
|
|
||||||
kc := &kafka.Client{
|
|
||||||
Addr: brokerAddr,
|
|
||||||
Timeout: 5 * time.Second,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const topic = "iot.telemetry"
|
sqsClient := sqs.New(sqs.Options{
|
||||||
const group = "iot-pg-consumer"
|
Region: region,
|
||||||
|
Credentials: credentials.NewStaticCredentialsProvider(
|
||||||
|
accessKey, secretKey, "",
|
||||||
|
),
|
||||||
|
BaseEndpoint: aws.String(endpoint),
|
||||||
|
})
|
||||||
|
|
||||||
// Получаем latest offset (конец лога — сколько всего сообщений прошло)
|
// Получаем URL очереди
|
||||||
offsetsResp, err := kc.ListOffsets(ctx, &kafka.ListOffsetsRequest{
|
queueUrlOut, err := sqsClient.GetQueueUrl(ctx, &sqs.GetQueueUrlInput{
|
||||||
Addr: brokerAddr,
|
QueueName: aws.String(queueName),
|
||||||
Topics: map[string][]kafka.OffsetRequest{
|
})
|
||||||
topic: {kafka.LastOffsetOf(0)},
|
if err != nil {
|
||||||
|
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 iotAdminKafkaStats{Error: fmt.Sprintf("list offsets: %v", err)}
|
return iotAdminSQSStats{Error: fmt.Sprintf("GetQueueAttributes: %v", err)}
|
||||||
}
|
}
|
||||||
|
|
||||||
var latestOffset int64
|
approxMsg, _ := strconv.ParseInt(attrsOut.Attributes["ApproximateNumberOfMessages"], 10, 64)
|
||||||
if partitions, ok := offsetsResp.Topics[topic]; ok && len(partitions) > 0 {
|
approxNotVisible, _ := strconv.ParseInt(attrsOut.Attributes["ApproximateNumberOfMessagesNotVisible"], 10, 64)
|
||||||
if partitions[0].Error == nil {
|
|
||||||
latestOffset = partitions[0].LastOffset
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Получаем committed offset consumer group (что consumer уже обработал)
|
return iotAdminSQSStats{
|
||||||
fetchResp, err := kc.OffsetFetch(ctx, &kafka.OffsetFetchRequest{
|
ApproximateMessages: approxMsg,
|
||||||
Addr: brokerAddr,
|
ApproximateMessagesNotVisible: approxNotVisible,
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +151,7 @@ func (h *Handler) collectIotKafkaLag(ctx context.Context) iotAdminKafkaStats {
|
|||||||
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-kafka-consumer"} {
|
for _, appLabel := range []string{"iot-mqtt-bridge", "iot-sqs-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,6 +151,18 @@ 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, "_")
|
||||||
@@ -181,10 +193,21 @@ func (h *Handler) MQTTAuth(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверяем что IoTDevice активно
|
// Ищем IoTDevice по Spec.DeviceID (имя K8s объекта может отличаться от deviceID)
|
||||||
device := &iotv1alpha1.IoTDevice{}
|
deviceList := &iotv1alpha1.IoTDeviceList{}
|
||||||
if err := h.K8s.Get(r.Context(), client.ObjectKey{Namespace: ns, Name: deviceID}, device); err != nil {
|
if err := h.K8s.List(r.Context(), deviceList, client.InNamespace(ns)); err != nil {
|
||||||
// IoTDevice не найден (или удалён) — deny
|
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "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
|
||||||
}
|
}
|
||||||
@@ -427,6 +450,9 @@ 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")
|
deviceID := r.URL.Query().Get("device_id")
|
||||||
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,6 +126,13 @@ 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