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)
|
||||
|
||||
**НЕ "СОВЕРШЕНСТВОВАТЬ" РАБОЧИЙ КОД БЕЗ ЯВНОГО УКАЗАНИЯ.**
|
||||
|
||||
---
|
||||
|
||||
## ЗАПРЕТ НА ВЫДУМКИ
|
||||
|
||||
**КАТЕГОРИЧЕСКИ ЗАПРЕЩАЕТСЯ придумывать, догадываться или предполагать:**
|
||||
- значения параметров, которые не видны в коде или документации
|
||||
- допустимые значения 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
|
||||
|
||||
Коммитить и пушить после каждого завершённого этапа.
|
||||
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 'КОМАНДА'
|
||||
```
|
||||
|
||||
+20
-63
@@ -1,35 +1,23 @@
|
||||
# Правила работы агента
|
||||
|
||||
## ⛔⛔⛔ DOCKER — ОБЯЗАТЕЛЬНЫЙ ПОРЯДОК ПЕРЕД КАЖДЫМ BUILD
|
||||
## Файловая система
|
||||
|
||||
1. УВЕЛИЧИТЬ ТЕГ в `deployments/k8s/iot-console.yaml` (vX.Y.Z → vX.Y.Z+1)
|
||||
2. rsync на ВМ
|
||||
3. ПРОВЕРИТЬ что файлы на ВМ новые (grep ключевой строки)
|
||||
4. docker build с НОВЫМ тегом
|
||||
5. docker push с НОВЫМ тегом
|
||||
6. kubectl apply (не rollout restart — apply подтягивает новый тег)
|
||||
`~/remote_dev/` (локально) примонтирован через sshfs к `~/terra/` на ВМ — **одна ФС**.
|
||||
Файлы, сохранённые локально, мгновенно видны на ВМ. SCP не нужен.
|
||||
|
||||
**НИКОГДА не делать `docker build` со старым тегом — под не перетянет образ (imagePullPolicy: IfNotPresent)**
|
||||
Монтирование может слетать. Признак: файлы рассинхронизированы.
|
||||
|
||||
## Файловая система (актуально)
|
||||
```bash
|
||||
# Размонтировать
|
||||
fusermount -u ~/remote_dev
|
||||
# Если завис: sudo umount -l /home/naeel/remote_dev
|
||||
|
||||
1. Все файлы редактируются локально: `~/IoT`
|
||||
2. После любых изменений — обязательно rsync на ВМ:
|
||||
rsync -az \
|
||||
-e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \
|
||||
~/IoT/ \
|
||||
naeel@5.172.178.213:~/terra/IoT/
|
||||
|
||||
3. Git (add/commit/push) выполнять ЛОКАЛЬНО в ~/IoT
|
||||
4. Docker, kubectl и другие инфраструктурные команды — только через SSH на ВМ
|
||||
5. Перед запуском любой команды на ВМ обязательно убедиться, что синхронизация (rsync) выполнена
|
||||
6. SCP, sshfs, remote_dev и маунты больше НЕ используются
|
||||
7. Только rsync для синхронизации
|
||||
|
||||
Пример:
|
||||
1. Редактируешь локально
|
||||
2. rsync на ВМ
|
||||
3. Выполняешь команды через SSH на ВМ
|
||||
# Примонтировать
|
||||
sshfs naeel@5.172.178.213:/home/naeel/terra ~/remote_dev \
|
||||
-o cache=no -o no_readahead -o reconnect \
|
||||
-o ServerAliveInterval=15 -o ServerAliveCountMax=3 \
|
||||
-o IdentityFile=~/.ssh/naeel_vm_id_ed25519
|
||||
```
|
||||
|
||||
## SSH
|
||||
|
||||
@@ -49,11 +37,7 @@ ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=
|
||||
|
||||
## Git
|
||||
|
||||
⛔⛔⛔ АБСОЛЮТНОЕ ПРАВИЛО:
|
||||
- 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 самостоятельно.
|
||||
Коммитить и пушить через SSH после каждого завершённого этапа.
|
||||
|
||||
Версионирование тегами: `vMAJOR.MINOR.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
|
||||
```
|
||||
|
||||
## ⛔ ТЕРМИНАЛЬНЫЙ БУФЕР — НИКОГДА НЕ ЧИТАТЬ СТАРЫЙ
|
||||
## Поведение агента
|
||||
|
||||
**АБСОЛЮТНОЕ ПРАВИЛО:**
|
||||
- get_terminal_output из старых сессий — МУСОР. Там старые прогоны.
|
||||
- Всегда запускать новую команду через SSH и читать её вывод напрямую.
|
||||
- НИКОГДА не читать буфер терминала из предыдущей сессии как актуальные данные.
|
||||
- Актуальный результат — только из команды, которая была запущена СЕЙЧАС.
|
||||
|
||||
## ⛔ ДОКУМЕНТАЦИЯ ТЕСТ-ПРОГОН�ОВ — В РЕАЛЬНОМ ВРЕМЕНИ
|
||||
|
||||
**Правила:**
|
||||
1. Перед запуском `run_all.sh` — создать файл `test-results/YYYY-MM-DD_HH-MM.log` и записать в него метку времени и что запускается.
|
||||
2. Запускать `run_all.sh 2>&1 | tee ~/terra/IoT/test-results/YYYY-MM-DD_HH-MM.log` — вывод пишется сразу в файл и отображается в терминале.
|
||||
3. После завершения — rsync лога локально. Лог остаётся как документация.
|
||||
4. Папка `test-results/` в репозитории — `.gitignore` не добавлять, логи коммитить.
|
||||
|
||||
**Формат запуска:**
|
||||
```bash
|
||||
LOG="test-results/$(date +%Y-%m-%d_%H-%M).log"
|
||||
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no naeel@5.172.178.213 \
|
||||
"bash ~/terra/IoT/scripts/run_all.sh 2>&1 | tee ~/terra/IoT/${LOG}"
|
||||
rsync -az -e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no" \
|
||||
naeel@5.172.178.213:~/terra/IoT/test-results/ ~/IoT/test-results/
|
||||
```
|
||||
|
||||
**Никогда не разбираться с результатами по памяти / буферу / чату. Только лог.**
|
||||
|
||||
## ⛔ РУЧНЫЕ ПАТЧИ — ЗАПРЕЩЕНЫ
|
||||
|
||||
- НИКОГДА не применять ручные патчи (`kubectl patch`, `kubectl apply` отдельных полей, `python -c` замены в yaml и т.д.) без явного указания.
|
||||
- Все изменения — только через код (Helm chart, YAML, Go-код) + сборка + деплой.
|
||||
- Ручной патч слетает при следующем helm upgrade/redeploy → регрессия.
|
||||
- Исключение: только если пользователь явно написал "примени ручной патч".
|
||||
- Не трогать рабочий код без явного указания
|
||||
- Не делать ничего сверх того, о чём явно попросили
|
||||
- Деструктивные операции (`kubectl delete`, `rm -rf`, `terraform destroy` и др.) — только после явного подтверждения с указанием конкретных объектов
|
||||
- Отвечать кратко, без вступлений, извинений, благодарностей и прочей воды
|
||||
|
||||
+5
-4
@@ -1,7 +1,8 @@
|
||||
# Бинарники
|
||||
iot-operator
|
||||
mqtt-bridge
|
||||
kafka-consumer
|
||||
# Бинарники (только в корне проекта, не директории cmd/)
|
||||
/iot-operator
|
||||
/mqtt-bridge
|
||||
/kafka-consumer
|
||||
/sqs-consumer
|
||||
bin/
|
||||
|
||||
# Go
|
||||
|
||||
+6
-5
@@ -1,6 +1,7 @@
|
||||
# Создано: 2026-04-12
|
||||
# Изменено: 2026-04-12 (kafka-consumer → sqs-consumer)
|
||||
# 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).
|
||||
|
||||
FROM golang:1.25 AS builder
|
||||
@@ -19,11 +20,11 @@ COPY internal/ internal/
|
||||
# iot-operator — controller-manager + REST API
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o iot-operator ./cmd/iot-operator/
|
||||
|
||||
# mqtt-bridge — MQTT (EMQX) → Kafka
|
||||
# mqtt-bridge — MQTT (EMQX) → shared-SQS
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o mqtt-bridge ./cmd/mqtt-bridge/
|
||||
|
||||
# kafka-consumer — Kafka → Postgres
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o kafka-consumer ./cmd/kafka-consumer/
|
||||
# sqs-consumer — shared-SQS → Postgres
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o sqs-consumer ./cmd/sqs-consumer/
|
||||
|
||||
# --- Runtime ---
|
||||
FROM gcr.io/distroless/static:nonroot
|
||||
@@ -33,7 +34,7 @@ WORKDIR /
|
||||
# Какой запускать — определяется command в k8s Deployment.
|
||||
COPY --from=builder /workspace/iot-operator .
|
||||
COPY --from=builder /workspace/mqtt-bridge .
|
||||
COPY --from=builder /workspace/kafka-consumer .
|
||||
COPY --from=builder /workspace/sqs-consumer .
|
||||
|
||||
USER 65532:65532
|
||||
ENTRYPOINT ["/iot-operator"]
|
||||
|
||||
@@ -15,16 +15,7 @@ build-bridge:
|
||||
CGO_ENABLED=0 go build -o bin/mqtt-bridge ./cmd/mqtt-bridge/
|
||||
|
||||
build-consumer:
|
||||
CGO_ENABLED=0 go build -o bin/kafka-consumer ./cmd/kafka-consumer/
|
||||
|
||||
# Docker
|
||||
docker-build:
|
||||
docker build -t $(IMG) .
|
||||
|
||||
docker-push:
|
||||
docker push $(IMG)
|
||||
|
||||
# Тесты и go mod
|
||||
CGO_ENABLED=0 go build -o bin/sqs-consumer ./cmd/sqs-consumer/
|
||||
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-06 (MQTT→Kafka: убран RABBITMQ_URL, добавлен KAFKA_BROKERS, v0.1.67)
|
||||
# Deployment iot-mqtt-bridge — MQTT→Kafka мост для IoT.
|
||||
# Изменено: 2026-04-12 (замена Kafka → shared-SQS)
|
||||
# Deployment iot-mqtt-bridge — MQTT→SQS мост для IoT.
|
||||
#
|
||||
# Получает MQTT сообщения от EMQX (подписка на "+/telemetry/+")
|
||||
# и публикует в Kafka топик "iot.telemetry" (ключ = namespace).
|
||||
# и отправляет в shared-SQS очередь "iot-telemetry" через AWS SDK.
|
||||
#
|
||||
# Credentials для MQTT подключения берутся из Secret iot-bridge-credentials.
|
||||
# Этот Secret нужно создать вручную ДО деплоя:
|
||||
# Credentials для SQS берутся из Secret iot-sqs-credentials.
|
||||
#
|
||||
# # 1. Создать IoTDevice для bridge через API:
|
||||
# curl -X POST .../v1/namespaces/sless-bridge/iot/devices \
|
||||
# -d '{"name":"bridge","device_id":"bridge","enabled":true}'
|
||||
#
|
||||
# # 2. Получить credentials:
|
||||
# MQTT_USERNAME=$(kubectl get secret iot-bridge -n sless-bridge -o jsonpath='{.data.mqtt-username}' | base64 -d)
|
||||
# MQTT_PASSWORD=$(kubectl get secret iot-bridge -n sless-bridge -o jsonpath='{.data.mqtt-password}' | base64 -d)
|
||||
#
|
||||
# # 3. Создать Secret для bridge Deployment (один раз):
|
||||
# kubectl create secret generic iot-bridge-credentials -n sless \
|
||||
# --from-literal=MQTT_USERNAME="$MQTT_USERNAME" \
|
||||
# --from-literal=MQTT_PASSWORD="$MQTT_PASSWORD"
|
||||
# Создание 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-mqtt-bridge.yaml
|
||||
|
||||
@@ -45,21 +38,22 @@ spec:
|
||||
- name: 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
|
||||
command: ["/iot-mqtt-bridge"]
|
||||
command: ["/mqtt-bridge"]
|
||||
env:
|
||||
- name: MQTT_BROKER_URL
|
||||
value: "tcp://emqx.sless.svc:1883"
|
||||
- name: KAFKA_BROKERS
|
||||
value: "kafka.sless.svc.cluster.local:9092"
|
||||
- name: SQS_QUEUE_NAME
|
||||
value: "iot-telemetry"
|
||||
- name: SQS_REGION
|
||||
value: "us-east-1"
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: iot-bridge-credentials
|
||||
# IOT_PG_DSN — сохранение телеметрии в Postgres (опционально)
|
||||
# SQS_ENDPOINT, SQS_ACCESS_KEY, SQS_SECRET_KEY
|
||||
- secretRef:
|
||||
name: iot-postgres-secret
|
||||
optional: true
|
||||
name: iot-sqs-credentials
|
||||
resources:
|
||||
requests:
|
||||
memory: "32Mi"
|
||||
@@ -67,5 +61,3 @@ spec:
|
||||
limits:
|
||||
memory: "64Mi"
|
||||
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
|
||||
# Postgres для IoT телеметрии — отдельный от sless postgres (тот для invocations логов).
|
||||
# Deployment (не StatefulSet) — для dev/demo. В prod заменить на managed Postgres.
|
||||
# Изменено: 2026-04-12 (заменён self-hosted на 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 TABLE iot_telemetry в tenant DB
|
||||
# Клиенты НЕ имеют прямого доступа — только через REST API платформы.
|
||||
#
|
||||
# Deployment и Service удалены — Postgres managed, только Secret с DSN.
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
@@ -13,54 +18,9 @@ metadata:
|
||||
name: iot-postgres-secret
|
||||
namespace: sless
|
||||
stringData:
|
||||
# Суперпользователь — для управления tenant databases
|
||||
POSTGRES_USER: "iot_admin"
|
||||
POSTGRES_PASSWORD: "iot-pg-super-2026"
|
||||
POSTGRES_DB: "iot_platform"
|
||||
# DSN для оператора и mqtt-bridge (superuser к management DB)
|
||||
IOT_PG_DSN: "postgresql://iot_admin:iot-pg-super-2026@iot-postgres.sless.svc:5432/iot_platform?sslmode=disable"
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: iot-postgres
|
||||
namespace: sless
|
||||
labels:
|
||||
app: iot-postgres
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: iot-postgres
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: iot-postgres
|
||||
spec:
|
||||
containers:
|
||||
- name: postgres
|
||||
image: postgres:16-alpine
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: iot-postgres-secret
|
||||
resources:
|
||||
requests:
|
||||
memory: "128Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: iot-postgres
|
||||
namespace: sless
|
||||
spec:
|
||||
selector:
|
||||
app: iot-postgres
|
||||
ports:
|
||||
- port: 5432
|
||||
targetPort: 5432
|
||||
# Суперпользователь managed Postgres
|
||||
POSTGRES_USER: "super"
|
||||
POSTGRES_PASSWORD: "BQUF5ruECa1ZFlq4wYt3gPJUEmtBMkA9QNK4MM5Sd8al4ArMDlmT16DIKHYBPyif"
|
||||
POSTGRES_DB: "sqsdb"
|
||||
# DSN для iot-operator и sqs-consumer (superuser к managed DB)
|
||||
IOT_PG_DSN: "postgresql://super:BQUF5ruECa1ZFlq4wYt3gPJUEmtBMkA9QNK4MM5Sd8al4ArMDlmT16DIKHYBPyif@postgresqlk8s-master.dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5.svc.cluster.local:5432/sqsdb?sslmode=disable"
|
||||
|
||||
@@ -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) |
|
||||
|
||||
---
|
||||
|
||||
## Обновление 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)
|
||||
- Все 3 бинарника в одном образе
|
||||
- `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.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
|
||||
- [ ] Убрать 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 уже живой.
|
||||
**Минус:** 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
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.14
|
||||
github.com/aws/aws-sdk-go-v2/service/sqs v1.42.25
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/lib/pq v1.11.2
|
||||
github.com/segmentio/kafka-go v0.4.50
|
||||
k8s.io/api v0.26.0
|
||||
k8s.io/apimachinery v0.26.0
|
||||
k8s.io/client-go v0.26.0
|
||||
@@ -15,6 +17,9 @@ 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/cespare/xxhash/v2 v2.1.2 // 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/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.15.9 // indirect
|
||||
github.com/mailru/easyjson v0.7.6 // indirect
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.2 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.15 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/prometheus/client_golang v1.14.0 // indirect
|
||||
github.com/prometheus/client_model v0.3.0 // indirect
|
||||
|
||||
@@ -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-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
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/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
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/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.15.9 h1:wKRjX6JRtDdrE9qwa4b/Cip7ACOshUI4smpCQanqjSY=
|
||||
github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||
@@ -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/gomega v1.24.1 h1:KORJXNNTzJXzu4ScJWssJfJMnJ+2QJqhoQSRwNlze9E=
|
||||
github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM=
|
||||
github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0=
|
||||
github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
@@ -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/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/segmentio/kafka-go v0.4.50 h1:mcyC3tT5WeyWzrFbd6O374t+hmcu1NKt2Pu1L3QaXmc=
|
||||
github.com/segmentio/kafka-go v0.4.50/go.mod h1:Y1gn60kzLEEaW28YshXyk2+VCUKbJ3Qr6DrnT3i4+9E=
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
|
||||
@@ -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.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
|
||||
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
||||
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
|
||||
@@ -10,15 +10,15 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"github.com/gorilla/mux"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
|
||||
"gitea.services.ngcloud.ru/Nail/IoT/internal/storage/iotpg"
|
||||
"gitea.services.ngcloud.ru/Nail/IoT/internal/storage/iotpg"
|
||||
)
|
||||
|
||||
// defaultNamespace — fallback namespace для dev/тестов.
|
||||
@@ -26,36 +26,38 @@ const defaultNamespace = "default"
|
||||
|
||||
// Handler содержит зависимости для всех IoT REST-обработчиков.
|
||||
type Handler struct {
|
||||
K8s client.Client
|
||||
Scheme *runtime.Scheme
|
||||
// IoTPG — хранилище IoT телеметрии (per-tenant Postgres). nil если IOT_PG_DSN не задан.
|
||||
IoTPG *iotpg.IoTPostgresStore
|
||||
// KafkaBrokers — адреса Kafka брокеров для чтения consumer lag на admin странице.
|
||||
KafkaBrokers string
|
||||
Log *slog.Logger
|
||||
K8s client.Client
|
||||
Scheme *runtime.Scheme
|
||||
// IoTPG — хранилище IoT телеметрии (per-tenant Postgres). nil если IOT_PG_DSN не задан.
|
||||
IoTPG *iotpg.IoTPostgresStore
|
||||
Log *slog.Logger
|
||||
// BridgeUsername â username внÑÑÑеннего MQTT bridge (env MQTT_BRIDGE_USERNAME)
|
||||
BridgeUsername string
|
||||
// BridgePassword â паÑÐ¾Ð»Ñ bridge (env MQTT_BRIDGE_PASSWORD)
|
||||
BridgePassword string
|
||||
}
|
||||
|
||||
// writeJSON отправляет JSON-ответ с указанным статусом.
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// errResp возвращает структуру ошибки для JSON.
|
||||
func errResp(msg string) map[string]string {
|
||||
return map[string]string{"error": msg}
|
||||
return map[string]string{"error": msg}
|
||||
}
|
||||
|
||||
// pathVar читает переменную пути из gorilla/mux.
|
||||
func pathVar(r *http.Request, key string) string {
|
||||
return mux.Vars(r)[key]
|
||||
return mux.Vars(r)[key]
|
||||
}
|
||||
|
||||
// namespace читает {namespace} из пути URL.
|
||||
func namespace(r *http.Request) string {
|
||||
if ns := mux.Vars(r)["namespace"]; ns != "" {
|
||||
return ns
|
||||
}
|
||||
return defaultNamespace
|
||||
if ns := mux.Vars(r)["namespace"]; ns != "" {
|
||||
return ns
|
||||
}
|
||||
return defaultNamespace
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Создано: 2026-04-06
|
||||
// Изменено: 2026-04-12 (замена Kafka lag → SQS queue stats)
|
||||
// iot_admin_stats_handler.go — handler для страницы администратора IoT.
|
||||
//
|
||||
// Endpoints:
|
||||
@@ -6,8 +7,8 @@
|
||||
//
|
||||
// Источники данных:
|
||||
// - PostgreSQL (IoTPG): counts per tenant, last 1h/24h, latest rows
|
||||
// - Kafka: consumer lag (latest offset - committed offset для group iot-pg-consumer)
|
||||
// - K8s: статус подов iot-mqtt-bridge и iot-kafka-consumer
|
||||
// - SQS: approximate message count (ApproximateNumberOfMessages)
|
||||
// - K8s: статус подов iot-mqtt-bridge и iot-sqs-consumer
|
||||
//
|
||||
// Авторизация: Bearer из env ADMIN_STATS_TOKEN.
|
||||
// Если ADMIN_STATS_TOKEN не задан — endpoint возвращает 503.
|
||||
@@ -19,10 +20,14 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"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"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
)
|
||||
@@ -36,12 +41,11 @@ type iotAdminPodStatus struct {
|
||||
Age string `json:"age"`
|
||||
}
|
||||
|
||||
// iotAdminKafkaStats — информация о Kafka топике и consumer lag.
|
||||
type iotAdminKafkaStats struct {
|
||||
LatestOffset int64 `json:"latest_offset"`
|
||||
CommittedOffset int64 `json:"committed_offset"`
|
||||
ConsumerLag int64 `json:"consumer_lag"`
|
||||
Error string `json:"error,omitempty"`
|
||||
// iotAdminSQSStats — информация о SQS очереди для мониторинга.
|
||||
type iotAdminSQSStats struct {
|
||||
ApproximateMessages int64 `json:"approximate_messages"`
|
||||
ApproximateMessagesNotVisible int64 `json:"approximate_messages_not_visible"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// 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"}
|
||||
}
|
||||
|
||||
// Kafka: consumer lag для топика iot.telemetry / группы iot-pg-consumer
|
||||
result["kafka"] = h.collectIotKafkaLag(ctx)
|
||||
// SQS: approximate message count для очереди iot-telemetry
|
||||
result["sqs"] = h.collectIotSQSStats(ctx)
|
||||
|
||||
// K8s: статус подов bridge и consumer
|
||||
result["pods"] = h.collectIotPodStatuses(ctx)
|
||||
@@ -87,72 +91,59 @@ func (h *Handler) AdminStats(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// collectIotKafkaLag получает latest offset топика и committed offset consumer group,
|
||||
// вычисляет lag = latest - committed.
|
||||
// Topic: "iot.telemetry", Consumer Group: "iot-pg-consumer".
|
||||
func (h *Handler) collectIotKafkaLag(ctx context.Context) iotAdminKafkaStats {
|
||||
if h.KafkaBrokers == "" {
|
||||
return iotAdminKafkaStats{Error: "KAFKA_BROKERS not configured"}
|
||||
// collectIotSQSStats получает ApproximateNumberOfMessages из SQS очереди.
|
||||
// Использует SQS_ENDPOINT, SQS_ACCESS_KEY, SQS_SECRET_KEY, SQS_QUEUE_NAME из env.
|
||||
func (h *Handler) collectIotSQSStats(ctx context.Context) iotAdminSQSStats {
|
||||
endpoint := os.Getenv("SQS_ENDPOINT")
|
||||
accessKey := os.Getenv("SQS_ACCESS_KEY")
|
||||
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, ",")
|
||||
brokerAddr := kafka.TCP(brokers...)
|
||||
|
||||
kc := &kafka.Client{
|
||||
Addr: brokerAddr,
|
||||
Timeout: 5 * time.Second,
|
||||
if endpoint == "" || accessKey == "" || secretKey == "" {
|
||||
return iotAdminSQSStats{Error: "SQS credentials not configured (SQS_ENDPOINT, SQS_ACCESS_KEY, SQS_SECRET_KEY)"}
|
||||
}
|
||||
|
||||
const topic = "iot.telemetry"
|
||||
const group = "iot-pg-consumer"
|
||||
sqsClient := sqs.New(sqs.Options{
|
||||
Region: region,
|
||||
Credentials: credentials.NewStaticCredentialsProvider(
|
||||
accessKey, secretKey, "",
|
||||
),
|
||||
BaseEndpoint: aws.String(endpoint),
|
||||
})
|
||||
|
||||
// Получаем latest offset (конец лога — сколько всего сообщений прошло)
|
||||
offsetsResp, err := kc.ListOffsets(ctx, &kafka.ListOffsetsRequest{
|
||||
Addr: brokerAddr,
|
||||
Topics: map[string][]kafka.OffsetRequest{
|
||||
topic: {kafka.LastOffsetOf(0)},
|
||||
// Получаем URL очереди
|
||||
queueUrlOut, err := sqsClient.GetQueueUrl(ctx, &sqs.GetQueueUrlInput{
|
||||
QueueName: aws.String(queueName),
|
||||
})
|
||||
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 {
|
||||
return iotAdminKafkaStats{Error: fmt.Sprintf("list offsets: %v", err)}
|
||||
return iotAdminSQSStats{Error: fmt.Sprintf("GetQueueAttributes: %v", err)}
|
||||
}
|
||||
|
||||
var latestOffset int64
|
||||
if partitions, ok := offsetsResp.Topics[topic]; ok && len(partitions) > 0 {
|
||||
if partitions[0].Error == nil {
|
||||
latestOffset = partitions[0].LastOffset
|
||||
}
|
||||
}
|
||||
approxMsg, _ := strconv.ParseInt(attrsOut.Attributes["ApproximateNumberOfMessages"], 10, 64)
|
||||
approxNotVisible, _ := strconv.ParseInt(attrsOut.Attributes["ApproximateNumberOfMessagesNotVisible"], 10, 64)
|
||||
|
||||
// Получаем committed offset consumer group (что consumer уже обработал)
|
||||
fetchResp, err := kc.OffsetFetch(ctx, &kafka.OffsetFetchRequest{
|
||||
Addr: brokerAddr,
|
||||
GroupID: group,
|
||||
Topics: map[string][]int{topic: {0}},
|
||||
})
|
||||
if err != nil {
|
||||
return iotAdminKafkaStats{
|
||||
LatestOffset: latestOffset,
|
||||
Error: fmt.Sprintf("offset fetch: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
var committedOffset int64
|
||||
if partitions, ok := fetchResp.Topics[topic]; ok && len(partitions) > 0 {
|
||||
if partitions[0].Error == nil {
|
||||
committedOffset = partitions[0].CommittedOffset
|
||||
}
|
||||
}
|
||||
|
||||
lag := latestOffset - committedOffset
|
||||
if lag < 0 {
|
||||
lag = 0
|
||||
}
|
||||
|
||||
return iotAdminKafkaStats{
|
||||
LatestOffset: latestOffset,
|
||||
CommittedOffset: committedOffset,
|
||||
ConsumerLag: lag,
|
||||
return iotAdminSQSStats{
|
||||
ApproximateMessages: approxMsg,
|
||||
ApproximateMessagesNotVisible: approxNotVisible,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +151,7 @@ func (h *Handler) collectIotKafkaLag(ctx context.Context) iotAdminKafkaStats {
|
||||
func (h *Handler) collectIotPodStatuses(ctx context.Context) 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{}
|
||||
if err := h.K8s.List(ctx, podList,
|
||||
client.InNamespace("sless"),
|
||||
|
||||
@@ -151,6 +151,18 @@ func (h *Handler) MQTTAuth(w http.ResponseWriter, r *http.Request) {
|
||||
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}"
|
||||
// Namespace содержит только [a-z0-9-], первый "_" — разделитель.
|
||||
idx := strings.Index(req.Username, "_")
|
||||
@@ -181,10 +193,21 @@ func (h *Handler) MQTTAuth(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Проверяем что IoTDevice активно
|
||||
device := &iotv1alpha1.IoTDevice{}
|
||||
if err := h.K8s.Get(r.Context(), client.ObjectKey{Namespace: ns, Name: deviceID}, device); err != nil {
|
||||
// IoTDevice не найден (или удалён) — deny
|
||||
// Ищем IoTDevice по Spec.DeviceID (имя K8s объекта может отличаться от deviceID)
|
||||
deviceList := &iotv1alpha1.IoTDeviceList{}
|
||||
if err := h.K8s.List(r.Context(), deviceList, client.InNamespace(ns)); err != nil {
|
||||
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"})
|
||||
return
|
||||
}
|
||||
@@ -427,6 +450,9 @@ func (h *Handler) MQTTAcl(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Bridge â внÑÑÑенний MQTT bridge клиенÑ. ÐÑовеÑÑем ÑикÑиÑованнÑе credentials
|
||||
// до паÑÑинга namespace_deviceId.
|
||||
// Парсим username → namespace + deviceId (формат: "{ns}_{deviceId}")
|
||||
// strings.Index находит ПЕРВЫЙ '_' — namespace содержит только дефисы
|
||||
idx := strings.Index(req.Username, "_")
|
||||
|
||||
@@ -28,7 +28,7 @@ func (h *Handler) ListIoTTelemetry(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
ns := mux.Vars(r)["namespace"]
|
||||
deviceID := r.URL.Query().Get("device")
|
||||
deviceID := r.URL.Query().Get("device_id")
|
||||
limit := 50
|
||||
if ls := r.URL.Query().Get("limit"); ls != "" {
|
||||
if n, err := strconv.Atoi(ls); err == nil && n > 0 {
|
||||
|
||||
@@ -126,6 +126,13 @@ func (s *IoTPostgresStore) EnsureTenantDB(ctx context.Context, namespace string)
|
||||
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 нельзя в транзакции
|
||||
if _, err = s.adminDB.ExecContext(ctx,
|
||||
fmt.Sprintf(`CREATE DATABASE %s OWNER %s`, dbName, userName),
|
||||
|
||||
Reference in New Issue
Block a user