8 Commits
Author SHA1 Message Date
Naeel 76c11d5476 fix: rename query param device->device_id in telemetry API (v0.2.6)
- GET /telemetry?device_id= now consistently uses device_id
- was: ?device= (inconsistent with response field name)
- found during extended test suite
2026-04-21 07:20:12 +03:00
Naeel f0cc6df9a6 fix: MQTTAuth device lookup by Spec.DeviceID + PG15+ GRANT (v0.2.5)
- MQTTAuth: replaced Get(Name=deviceID) with List+filter by Spec.DeviceID
  (K8s object name != deviceID — caused deny for all devices)
- EnsureTenantDB: added GRANT role TO CURRENT_USER before CREATE DATABASE OWNER
  (PG15+ requires SET ROLE privileges for target owner)
- Image: naeel/iot-operator:v0.2.5
- E2E test passed: device create → MQTT publish → SQS → Postgres → REST API
2026-04-12 19:00:56 +03:00
Naeel d5a177e23c doc: обновлён progress — аудит sless документации, перечень созданных файлов v0.2.3 2026-04-12 18:43:48 +03:00
Naeel 907aaad100 docs: полная документация v0.2.3 — архитектура, API, деплой, тестирование
Новые файлы:
- doc/architecture/current-v0.2.3.md — актуальная архитектура
- doc/api/endpoints-v0.2.3.md — полная документация REST API
- doc/deployment-v0.2.3.md — инструкция деплоя v0.2.3
- doc/run-and-test.md — руководство по запуску и E2E тесту

Обновлено:
- doc/progress.md — секция документации
- doc/thinking/2026-04-12.md — лог мышления
2026-04-12 18:36:12 +03:00
Naeel 4cba163ace doc: актуальная архитектура, thinking log, правила ФС и документации 2026-04-12 16:32:14 +03:00
Naeel d4f7c5abc8 doc: обновлена документация — дополнения к архитектуре, деплою, прогрессу
- architecture/overview.md: добавлена актуальная схема (SQS, managed PG)
- deployment.md: добавлена процедура деплоя в iot-naeel
- progress.md: добавлен блок 2026-04-12 (Kafka→SQS, деплой)
- api/endpoints.md: добавлен актуальный формат /iot-admin/stats
- thinking/2026-04-12.md: дописан ход реализации и деплоя
- copilot-instructions.md: добавлено правило «документацию не стирать»

Старая документация (Kafka, RabbitMQ) сохранена как база знаний.
2026-04-12 16:16:46 +03:00
Naeel 150146edba deploy: EMQX, iot-operator, managed Postgres, Docker Hub image
- Добавлен EMQX deployment + WS ingress (адаптирован из sless)
- Добавлен iot-operator deployment с RBAC (ServiceAccount, ClusterRole)
- Postgres: заменён self-hosted на managed (dc5db45d namespace)
- Image: naeel/iot-operator:v0.2.0 (Docker Hub)
- Убран imagePullSecrets (Docker Hub публичный)
2026-04-12 16:07:41 +03:00
Naeel 6caab6b729 feat: замена Kafka → shared-SQS в IoT pipeline
- mqtt-bridge: kafka.Writer → SQS SendMessage (AWS SDK Go v2)
- kafka-consumer → sqs-consumer: polling loop с ReceiveMessage/DeleteMessage
- admin stats: Kafka lag → SQS GetQueueAttributes
- Удалён kafka-consumer, добавлен sqs-consumer
- go.mod: убран segmentio/kafka-go, добавлен aws-sdk-go-v2
- Dockerfile, Makefile: kafka-consumer → sqs-consumer
- .gitignore: исправлен чтобы не игнорировать cmd/ директории
- deployments: новый iot-sqs-consumer.yaml, обновлён mqtt-bridge
- doc/decisions: задокументировано решение
2026-04-12 15:28:45 +03:00
32 changed files with 3147 additions and 515 deletions
+6 -171
View File
@@ -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` |
### Шаблон команды
1. Не трогать рабочий код без явного указания.
2. Файлы редактировать локально — `~/remote_dev/` = `~/terra/` на ВМ (sshfs), SCP не нужен.
3. Все команды — **только через SSH**, никогда локально:
```bash ```bash
ssh -i ~/.ssh/id_ed25519 \ ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 'КОМАНДА'
-o StrictHostKeyChecking=no \
-o ConnectTimeout=10 \
naeel@5.172.178.213 \
'cd /home/naeel/terra/IoT && <КОМАНДА>'
``` ```
### Примеры
```bash
# git статус
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 \
'cd /home/naeel/terra/IoT && git status'
# commit + push
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 \
'cd /home/naeel/terra/IoT && git add -A && git commit -m "..." && git push'
# сборка go
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 \
'cd /home/naeel/terra/IoT && go build ./...'
```
### Git / Gitea
- Remote: `https://gitea.services.ngcloud.ru/Nail/IoT.git` (HTTPS)
- SSH до Gitea **недоступен** с удалённой машины — использовать только HTTPS.
- Credentials сохранены на удалённой машине в `~/.git-credentials`.
---
## О проекте
IoT managed service — отдельная репа, вынесенная из sless.
Go модуль: `gitea.services.ngcloud.ru/Nail/IoT`
Репа: https://gitea.services.ngcloud.ru/Nail/IoT
### Компоненты (3 бинарника из одного образа):
1. **iot-operator** (`cmd/iot-operator/`) — controller-manager (IoTDevice CRD) + REST API на :9090
2. **mqtt-bridge** (`cmd/mqtt-bridge/`) — MQTT (EMQX) → Kafka bridge
3. **kafka-consumer** (`cmd/kafka-consumer/`) — Kafka → Postgres pipeline
### Стек:
- Go 1.25, controller-runtime v0.14, gorilla/mux
- CRD: `iot.kube5s.ru/v1alpha1` (IoTDevice)
- EMQX — MQTT брокер, Kafka — очередь телеметрии
- PostgreSQL — per-tenant databases для телеметрии
- Docker Hub: `naeel/iot-operator`
### Структура:
```
cmd/iot-operator/ — точка входа (controller + API сервер)
cmd/mqtt-bridge/ — MQTT→Kafka bridge
cmd/kafka-consumer/ — Kafka→Postgres
api/v1alpha1/ — CRD Go types (IoTDevice)
controllers/ — IoTDevice reconciler
internal/api/ — REST handlers, router, middleware, UI (go:embed)
internal/storage/ — iotpg (per-tenant Postgres)
config/crd/ — CRD YAML manifests
deployments/k8s/ — k8s deployment YAMLs
doc/ — документация
examples/ — примеры (Terraform, handler.py)
```
---
## Комментарии в коде
Комментарии — обязательны:
- В начале каждого файла при создании или правке — дата и время изменения
- На каждой функции/методе — краткое назначение
- На нетривиальной логике — **почему** сделано именно так (не "что делает", а "зачем")
Цель: любой агент в новом чате должен понять логику без дополнительных вопросов.
---
## Темп работы
Не спешить. Перед каждым шагом — убедиться что предыдущий понят и согласован.
---
## Документация
Всё важное фиксировать в `doc/`:
- `doc/architecture/` — архитектура, стек, схемы
- `doc/api/` — дизайн API
- `doc/decisions/` — принятые решения с обоснованием
- `doc/infrastructure/` — инфраструктура, кластер, сервисы
- `doc/errors/` — ошибки и как решили
- `doc/progress.md` — трекер задач
Обновлять после каждого значимого изменения.
---
## Именование
Имена должны быть **уникальными и осмысленными по всему проекту**:
- имена файлов
- имена функций/методов
- имена переменных/констант
- имена ресурсов (Terraform, Kubernetes и т.д.)
Цель: чтобы поиск по проекту находил нужные сущности без неоднозначности, а имя сразу отражало назначение.
Запрещены безликие и повторяющиеся имена вида `handler.py`, `handle`, `data`, `value`, `temp` без контекста.
---
## Лог мышления (обязательно)
Каждый агент в каждом чате **обязан** вести лог своих рассуждений:
- Папка: `doc/thinking/`
- Файл: `ГГГГ-ММ-ДД.md` (по дате сессии)
- В начале файла указать имя агента и модель
- Если файл на текущую дату уже существует — дописывать в конец, добавив разделитель `---` и имя агента
- Записывать **полный** ход мыслей: что анализирую, какие гипотезы, что нашёл, что отбросил, к чему пришёл, почему
- Записывать **до** начала действий (план) и **после** (результат)
Цель: пользователь должен видеть весь процесс рассуждений в читаемом виде.
---
## Git
Коммитить и пушить после каждого завершённого этапа.
+20 -63
View File
@@ -1,35 +1,23 @@
# Правила работы агента # Правила работы агента
## ⛔⛔⛔ DOCKER — ОБЯЗАТЕЛЬНЫЙ ПОРЯДОК ПЕРЕД КАЖДЫМ BUILD ## Файловая система
1. УВЕЛИЧИТЬ ТЕГ в `deployments/k8s/iot-console.yaml` (vX.Y.Z → vX.Y.Z+1) `~/remote_dev/` (локально) примонтирован через sshfs к `~/terra/` на ВМ — **одна ФС**.
2. rsync на ВМ Файлы, сохранённые локально, мгновенно видны на ВМ. SCP не нужен.
3. ПРОВЕРИТЬ что файлы на ВМ новые (grep ключевой строки)
4. docker build с НОВЫМ тегом
5. docker push с НОВЫМ тегом
6. kubectl apply (не rollout restart — apply подтягивает новый тег)
**НИКОГДА не делать `docker build` со старым тегом — под не перетянет образ (imagePullPolicy: IfNotPresent)** Монтирование может слетать. Признак: файлы рассинхронизированы.
## Файловая система (актуально) ```bash
# Размонтировать
fusermount -u ~/remote_dev
# Если завис: sudo umount -l /home/naeel/remote_dev
1. Все файлы редактируются локально: `~/IoT` # Примонтировать
2. После любых изменений — обязательно rsync на ВМ: sshfs naeel@5.172.178.213:/home/naeel/terra ~/remote_dev \
rsync -az \ -o cache=no -o no_readahead -o reconnect \
-e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \ -o ServerAliveInterval=15 -o ServerAliveCountMax=3 \
~/IoT/ \ -o IdentityFile=~/.ssh/naeel_vm_id_ed25519
naeel@5.172.178.213:~/terra/IoT/ ```
3. Git (add/commit/push) выполнять ЛОКАЛЬНО в ~/IoT
4. Docker, kubectl и другие инфраструктурные команды — только через SSH на ВМ
5. Перед запуском любой команды на ВМ обязательно убедиться, что синхронизация (rsync) выполнена
6. SCP, sshfs, remote_dev и маунты больше НЕ используются
7. Только rsync для синхронизации
Пример:
1. Редактируешь локально
2. rsync на ВМ
3. Выполняешь команды через SSH на ВМ
## SSH ## SSH
@@ -49,11 +37,7 @@ ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=
## Git ## Git
⛔⛔⛔ АБСОЛЮТНОЕ ПРАВИЛО: Коммитить и пушить через SSH после каждого завершённого этапа.
- Git — ТОЛЬКО ЛОКАЛЬНО в `~/IoT`. НИКОГДА через SSH на VM.
- Разрешены ТОЛЬКО две операции: `git commit` и `git push`.
- ЗАПРЕЩЕНО: git pull, git fetch, git rebase, git merge, git reset, git stash, git checkout — что угодно кроме commit и push.
- Если push отклонён — СТОП, доложить пользователю. Не лезть в pull/merge/rebase самостоятельно.
Версионирование тегами: `vMAJOR.MINOR.PATCH` Версионирование тегами: `vMAJOR.MINOR.PATCH`
- Patch — любое изменение кода - Patch — любое изменение кода
@@ -64,36 +48,9 @@ ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=
git tag vX.Y.Z && git push origin vX.Y.Z git tag vX.Y.Z && git push origin vX.Y.Z
``` ```
## ⛔ ТЕРМИНАЛЬНЫЙ БУФЕР — НИКОГДА НЕ ЧИТАТЬ СТАРЫЙ ## Поведение агента
**АБСОЛЮТНОЕ ПРАВИЛО:** - Не трогать рабочий код без явного указания
- get_terminal_output из старых сессий — МУСОР. Там старые прогоны. - Не делать ничего сверх того, о чём явно попросили
- Всегда запускать новую команду через SSH и читать её вывод напрямую. - Деструктивные операции (`kubectl delete`, `rm -rf`, `terraform destroy` и др.) — только после явного подтверждения с указанием конкретных объектов
- НИКОГДА не читать буфер терминала из предыдущей сессии как актуальные данные. - Отвечать кратко, без вступлений, извинений, благодарностей и прочей воды
- Актуальный результат — только из команды, которая была запущена СЕЙЧАС.
## ⛔ ДОКУМЕНТАЦИЯ ТЕСТ-ПРОГОН�ОВ — В РЕАЛЬНОМ ВРЕМЕНИ
**Правила:**
1. Перед запуском `run_all.sh` — создать файл `test-results/YYYY-MM-DD_HH-MM.log` и записать в него метку времени и что запускается.
2. Запускать `run_all.sh 2>&1 | tee ~/terra/IoT/test-results/YYYY-MM-DD_HH-MM.log` — вывод пишется сразу в файл и отображается в терминале.
3. После завершения — rsync лога локально. Лог остаётся как документация.
4. Папка `test-results/` в репозитории — `.gitignore` не добавлять, логи коммитить.
**Формат запуска:**
```bash
LOG="test-results/$(date +%Y-%m-%d_%H-%M).log"
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no naeel@5.172.178.213 \
"bash ~/terra/IoT/scripts/run_all.sh 2>&1 | tee ~/terra/IoT/${LOG}"
rsync -az -e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no" \
naeel@5.172.178.213:~/terra/IoT/test-results/ ~/IoT/test-results/
```
**Никогда не разбираться с результатами по памяти / буферу / чату. Только лог.**
## ⛔ РУЧНЫЕ ПАТЧИ — ЗАПРЕЩЕНЫ
- НИКОГДА не применять ручные патчи (`kubectl patch`, `kubectl apply` отдельных полей, `python -c` замены в yaml и т.д.) без явного указания.
- Все изменения — только через код (Helm chart, YAML, Go-код) + сборка + деплой.
- Ручной патч слетает при следующем helm upgrade/redeploy → регрессия.
- Исключение: только если пользователь явно написал "примени ручной патч".
+5 -4
View File
@@ -1,7 +1,8 @@
# Бинарники # Бинарники (только в корне проекта, не директории cmd/)
iot-operator /iot-operator
mqtt-bridge /mqtt-bridge
kafka-consumer /kafka-consumer
/sqs-consumer
bin/ bin/
# Go # Go
+6 -5
View File
@@ -1,6 +1,7 @@
# Создано: 2026-04-12 # Создано: 2026-04-12
# Изменено: 2026-04-12 (kafka-consumer → sqs-consumer)
# Dockerfile для IoT managed service. # Dockerfile для IoT managed service.
# Multi-stage build: 3 бинарника (iot-operator, mqtt-bridge, kafka-consumer). # Multi-stage build: 3 бинарника (iot-operator, mqtt-bridge, sqs-consumer).
# Образ: naeel/iot-operator (Docker Hub). # Образ: naeel/iot-operator (Docker Hub).
FROM golang:1.25 AS builder FROM golang:1.25 AS builder
@@ -19,11 +20,11 @@ COPY internal/ internal/
# iot-operator — controller-manager + REST API # iot-operator — controller-manager + REST API
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o iot-operator ./cmd/iot-operator/ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o iot-operator ./cmd/iot-operator/
# mqtt-bridge — MQTT (EMQX) → Kafka # mqtt-bridge — MQTT (EMQX) → shared-SQS
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o mqtt-bridge ./cmd/mqtt-bridge/ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o mqtt-bridge ./cmd/mqtt-bridge/
# kafka-consumer — Kafka → Postgres # sqs-consumer — shared-SQS → Postgres
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o kafka-consumer ./cmd/kafka-consumer/ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o sqs-consumer ./cmd/sqs-consumer/
# --- Runtime --- # --- Runtime ---
FROM gcr.io/distroless/static:nonroot FROM gcr.io/distroless/static:nonroot
@@ -33,7 +34,7 @@ WORKDIR /
# Какой запускать — определяется command в k8s Deployment. # Какой запускать — определяется command в k8s Deployment.
COPY --from=builder /workspace/iot-operator . COPY --from=builder /workspace/iot-operator .
COPY --from=builder /workspace/mqtt-bridge . COPY --from=builder /workspace/mqtt-bridge .
COPY --from=builder /workspace/kafka-consumer . COPY --from=builder /workspace/sqs-consumer .
USER 65532:65532 USER 65532:65532
ENTRYPOINT ["/iot-operator"] ENTRYPOINT ["/iot-operator"]
+1 -10
View File
@@ -15,16 +15,7 @@ build-bridge:
CGO_ENABLED=0 go build -o bin/mqtt-bridge ./cmd/mqtt-bridge/ CGO_ENABLED=0 go build -o bin/mqtt-bridge ./cmd/mqtt-bridge/
build-consumer: build-consumer:
CGO_ENABLED=0 go build -o bin/kafka-consumer ./cmd/kafka-consumer/ CGO_ENABLED=0 go build -o bin/sqs-consumer ./cmd/sqs-consumer/
# Docker
docker-build:
docker build -t $(IMG) .
docker-push:
docker push $(IMG)
# Тесты и go mod
test: test:
go test ./... go test ./...
-71
View File
@@ -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 цепочки
+145
View File
@@ -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")
}
+255
View File
@@ -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,
)
}
}
+207
View File
@@ -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
}
+68
View File
@@ -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
+193
View File
@@ -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
+17 -25
View File
@@ -1,25 +1,18 @@
# Создано: 2026-04-04 # Создано: 2026-04-04
# Изменено: 2026-04-06 (MQTT→Kafka: убран RABBITMQ_URL, добавлен KAFKA_BROKERS, v0.1.67) # Изменено: 2026-04-12 (замена Kafka → shared-SQS)
# Deployment iot-mqtt-bridge — MQTT→Kafka мост для IoT. # Deployment iot-mqtt-bridge — MQTT→SQS мост для IoT.
# #
# Получает MQTT сообщения от EMQX (подписка на "+/telemetry/+") # Получает MQTT сообщения от EMQX (подписка на "+/telemetry/+")
# и публикует в Kafka топик "iot.telemetry" (ключ = namespace). # и отправляет в shared-SQS очередь "iot-telemetry" через AWS SDK.
# #
# Credentials для MQTT подключения берутся из Secret iot-bridge-credentials. # Credentials для MQTT подключения берутся из Secret iot-bridge-credentials.
# Этот Secret нужно создать вручную ДО деплоя: # Credentials для SQS берутся из Secret iot-sqs-credentials.
# #
# # 1. Создать IoTDevice для bridge через API: # Создание SQS Secret (один раз):
# curl -X POST .../v1/namespaces/sless-bridge/iot/devices \ # kubectl create secret generic iot-sqs-credentials -n sless \
# -d '{"name":"bridge","device_id":"bridge","enabled":true}' # --from-literal=SQS_ENDPOINT="https://qu.kube5s.ru" \
# # --from-literal=SQS_ACCESS_KEY="<access_key>" \
# # 2. Получить credentials: # --from-literal=SQS_SECRET_KEY="<secret_key>"
# MQTT_USERNAME=$(kubectl get secret iot-bridge -n sless-bridge -o jsonpath='{.data.mqtt-username}' | base64 -d)
# MQTT_PASSWORD=$(kubectl get secret iot-bridge -n sless-bridge -o jsonpath='{.data.mqtt-password}' | base64 -d)
#
# # 3. Создать Secret для bridge Deployment (один раз):
# kubectl create secret generic iot-bridge-credentials -n sless \
# --from-literal=MQTT_USERNAME="$MQTT_USERNAME" \
# --from-literal=MQTT_PASSWORD="$MQTT_PASSWORD"
# #
# Применение: kubectl apply -f deployments/k8s/iot-mqtt-bridge.yaml # Применение: kubectl apply -f deployments/k8s/iot-mqtt-bridge.yaml
@@ -45,21 +38,22 @@ spec:
- name: mqtt-bridge - name: mqtt-bridge
# Тот же образ что и оператор — оба бинаря в одном слое (manager + iot-mqtt-bridge). # Тот же образ что и оператор — оба бинаря в одном слое (manager + iot-mqtt-bridge).
# При смене версии оператора — менять тег и здесь. # При смене версии оператора — менять тег и здесь.
image: pearlharbor.registryk8s.services.ngcloud.ru/naeel/sless-operator:v0.1.69 image: naeel/iot-operator:v0.2.6
imagePullPolicy: Always imagePullPolicy: Always
command: ["/iot-mqtt-bridge"] command: ["/mqtt-bridge"]
env: env:
- name: MQTT_BROKER_URL - name: MQTT_BROKER_URL
value: "tcp://emqx.sless.svc:1883" value: "tcp://emqx.sless.svc:1883"
- name: KAFKA_BROKERS - name: SQS_QUEUE_NAME
value: "kafka.sless.svc.cluster.local:9092" value: "iot-telemetry"
- name: SQS_REGION
value: "us-east-1"
envFrom: envFrom:
- secretRef: - secretRef:
name: iot-bridge-credentials name: iot-bridge-credentials
# IOT_PG_DSN — сохранение телеметрии в Postgres (опционально) # SQS_ENDPOINT, SQS_ACCESS_KEY, SQS_SECRET_KEY
- secretRef: - secretRef:
name: iot-postgres-secret name: iot-sqs-credentials
optional: true
resources: resources:
requests: requests:
memory: "32Mi" memory: "32Mi"
@@ -67,5 +61,3 @@ spec:
limits: limits:
memory: "64Mi" memory: "64Mi"
cpu: "100m" cpu: "100m"
imagePullSecrets:
- name: sless-registry-auth
+152
View File
@@ -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
+14 -54
View File
@@ -1,11 +1,16 @@
# Создано: 2026-04-05 # Создано: 2026-04-05
# Postgres для IoT телеметрии — отдельный от sless postgres (тот для invocations логов). # Изменено: 2026-04-12 (заменён self-hosted на managed Postgres через оператор)
# Deployment (не StatefulSet) — для dev/demo. В prod заменить на managed Postgres.
# #
# Суперюзер iot_admin используется оператором для: # Managed PostgreSQL 17 — тот же инстанс что использует shared-SQS.
# Namespace: dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5 (managed оператором)
# Host: postgresqlk8s-master.dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5.svc.cluster.local
#
# IoT оператор использует суперюзер для:
# - CREATE USER tenant_{ns} + CREATE DATABASE tenant_{ns} # - CREATE USER tenant_{ns} + CREATE DATABASE tenant_{ns}
# - CREATE TABLE iot_telemetry в tenant DB # - CREATE TABLE iot_telemetry в tenant DB
# Клиенты НЕ имеют прямого доступа — только через REST API платформы. # Клиенты НЕ имеют прямого доступа — только через REST API платформы.
#
# Deployment и Service удалены — Postgres managed, только Secret с DSN.
--- ---
apiVersion: v1 apiVersion: v1
kind: Secret kind: Secret
@@ -13,54 +18,9 @@ metadata:
name: iot-postgres-secret name: iot-postgres-secret
namespace: sless namespace: sless
stringData: stringData:
# Суперпользователь — для управления tenant databases # Суперпользователь managed Postgres
POSTGRES_USER: "iot_admin" POSTGRES_USER: "super"
POSTGRES_PASSWORD: "iot-pg-super-2026" POSTGRES_PASSWORD: "BQUF5ruECa1ZFlq4wYt3gPJUEmtBMkA9QNK4MM5Sd8al4ArMDlmT16DIKHYBPyif"
POSTGRES_DB: "iot_platform" POSTGRES_DB: "sqsdb"
# DSN для оператора и mqtt-bridge (superuser к management DB) # DSN для iot-operator и sqs-consumer (superuser к managed DB)
IOT_PG_DSN: "postgresql://iot_admin:iot-pg-super-2026@iot-postgres.sless.svc:5432/iot_platform?sslmode=disable" IOT_PG_DSN: "postgresql://super:BQUF5ruECa1ZFlq4wYt3gPJUEmtBMkA9QNK4MM5Sd8al4ArMDlmT16DIKHYBPyif@postgresqlk8s-master.dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5.svc.cluster.local:5432/sqsdb?sslmode=disable"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: iot-postgres
namespace: sless
labels:
app: iot-postgres
spec:
replicas: 1
selector:
matchLabels:
app: iot-postgres
template:
metadata:
labels:
app: iot-postgres
spec:
containers:
- name: postgres
image: postgres:16-alpine
ports:
- containerPort: 5432
envFrom:
- secretRef:
name: iot-postgres-secret
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
---
apiVersion: v1
kind: Service
metadata:
name: iot-postgres
namespace: sless
spec:
selector:
app: iot-postgres
ports:
- port: 5432
targetPort: 5432
+60
View File
@@ -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"
+410
View File
@@ -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"
```
+22
View File
@@ -30,3 +30,25 @@
| Метод | Путь | Описание | | Метод | Путь | Описание |
|-------|------|----------| |-------|------|----------|
| GET | `/iot-admin/stats` | JSON статистика (PG, Kafka lag, pods) | | GET | `/iot-admin/stats` | JSON статистика (PG, Kafka lag, pods) |
---
## Обновление 2026-04-12: admin stats → SQS
> Kafka lag заменён на SQS queue stats. Endpoint тот же, формат ответа изменён.
### `/iot-admin/stats` — актуальный формат ответа
```json
{
"sqs": {
"queue_url": "http://us-east-1.goaws.com:4100/t-96afe7e9f781f6ca/iot-telemetry",
"approximate_messages": "0",
"approximate_messages_not_visible": "0"
},
"sqs_consumer_pods": [...],
"pg_tenants": [...]
}
```
Вместо `kafka_lag` теперь `sqs.approximate_messages` — количество сообщений в очереди, ожидающих обработки.
+256
View File
@@ -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 |
+168
View File
@@ -64,3 +64,171 @@ REST API (iot-operator:9090)
- `naeel/iot-operator` (Docker Hub) - `naeel/iot-operator` (Docker Hub)
- Все 3 бинарника в одном образе - Все 3 бинарника в одном образе
- `command: ["/iot-operator"]` или `["/mqtt-bridge"]` или `["/kafka-consumer"]` - `command: ["/iot-operator"]` или `["/mqtt-bridge"]` или `["/kafka-consumer"]`
---
## Актуальная архитектура (2026-04-12)
> Kafka заменён на shared-SQS. Postgres заменён на managed. Новый кластер iot-naeel.
### Текущая схема
```
IoT Device (MQTT CONNECT)
│ username="{ns}_{deviceId}", password=hex
▼
EMQX 5.5.1 (sless/emqx)
│ POST /internal/mqtt/auth → iot-operator:9090
│ POST /internal/mqtt/acl → iot-operator:9090
▼
│ MQTT PUBLISH → topic: "{ns}/telemetry/{deviceId}"
▼
iot-mqtt-bridge (cmd/mqtt-bridge)
│ подписка на "+/telemetry/+"
│ → AWS SDK SQS SendMessage → shared-SQS (https://qu.kube5s.ru)
│ очередь: "iot-telemetry" (tenant: iot-service, id: t-96afe7e9f781f6ca)
▼
shared-SQS (namespace shared-sqs)
│ AWS SQS-совместимый, multi-tenant
▼
iot-sqs-consumer (cmd/sqs-consumer)
│ ReceiveMessage (WaitTimeSeconds=20, long polling)
│ DeleteMessage после успешной записи (at-least-once)
│ → per-tenant Postgres DB
▼
Managed PostgreSQL 17 (namespace dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5)
│ Host: postgresqlk8s-master.dc5db45d-....svc.cluster.local
│ User: super, DB: sqsdb
│ Per-tenant DB: tenant_{namespace}, Table: iot_telemetry
▼
REST API (iot-operator:9090)
│ GET /v1/namespaces/{ns}/iot/telemetry
▼
Пользователь (Terraform / UI Console)
│ wss://iot.kube5s.ru/mqtt (WebSocket через Ingress)
│ https://iot.kube5s.ru/console (UI)
```
### Компоненты (актуальные)
| Компонент | Бинарник | Порт | Назначение |
|-----------|----------|------|------------|
| iot-operator | cmd/iot-operator | :9090 (API), :8080 (metrics), :8081 (health) | Controller-manager + REST API |
| mqtt-bridge | cmd/mqtt-bridge | — | MQTT → shared-SQS bridge |
| sqs-consumer | cmd/sqs-consumer | — | shared-SQS → Postgres pipeline |
### Инфраструктура
| Сервис | Тип | Namespace | Описание |
|--------|-----|-----------|----------|
| EMQX 5.5.1 | В кластере | sless | MQTT-брокер, HTTP auth/acl → iot-operator |
| shared-SQS | В кластере | shared-sqs | AWS SQS-совместимая очередь, endpoint https://qu.kube5s.ru |
| PostgreSQL 17 | Managed (оператор) | dc5db45d-... | per-tenant DB, тот же инстанс что и SQS billing |
| cert-manager | В кластере | cert-manager | TLS сертификаты для iot.kube5s.ru |
| nginx-ingress | В кластере | ingress | WSS/HTTPS проксирование |
### Секреты в namespace sless
| Secret | Ключи | Источник |
|--------|-------|----------|
| iot-sqs-credentials | SQS_ENDPOINT, SQS_ACCESS_KEY, SQS_SECRET_KEY | shared-SQS tenant iot-service |
| iot-postgres-secret | POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB, IOT_PG_DSN | Managed PG |
| iot-bridge-credentials | MQTT_USERNAME, MQTT_PASSWORD | Для mqtt-bridge → EMQX |
### Docker образ (актуальный)
- `naeel/iot-operator:v0.2.0` (Docker Hub)
- Все 3 бинарника в одном образе
- `command: ["/iot-operator"]` или `["/mqtt-bridge"]` или `["/sqs-consumer"]`
- Базовый: `gcr.io/distroless/static:nonroot`
### Кластер
- Имя: `iot-naeel`
- API: `https://185.247.187.149:6443`
- Ingress IP: `185.247.187.151`
- DNS: `iot.kube5s.ru → 185.247.187.147`
---
## Актуальная архитектура (2026-04-12)
> Kafka заменён на shared-SQS. Postgres заменён на managed. Новый кластер iot-naeel.
### Текущая схема
```
IoT Device (MQTT CONNECT)
│ username="{ns}_{deviceId}", password=hex
▼
EMQX 5.5.1 (sless/emqx)
│ POST /internal/mqtt/auth → iot-operator:9090
│ POST /internal/mqtt/acl → iot-operator:9090
▼
│ MQTT PUBLISH → topic: "{ns}/telemetry/{deviceId}"
▼
iot-mqtt-bridge (cmd/mqtt-bridge)
│ подписка на "+/telemetry/+"
│ → AWS SDK SQS SendMessage → shared-SQS (https://qu.kube5s.ru)
│ очередь: "iot-telemetry" (tenant: iot-service, id: t-96afe7e9f781f6ca)
▼
shared-SQS (namespace shared-sqs)
│ AWS SQS-совместимый, multi-tenant
▼
iot-sqs-consumer (cmd/sqs-consumer)
│ ReceiveMessage (WaitTimeSeconds=20, long polling)
│ DeleteMessage после успешной записи (at-least-once)
│ → per-tenant Postgres DB
▼
Managed PostgreSQL 17 (namespace dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5)
│ Host: postgresqlk8s-master.dc5db45d-....svc.cluster.local
│ User: super, DB: sqsdb
│ Per-tenant DB: tenant_{namespace}, Table: iot_telemetry
▼
REST API (iot-operator:9090)
│ GET /v1/namespaces/{ns}/iot/telemetry
▼
Пользователь (Terraform / UI Console)
│ wss://iot.kube5s.ru/mqtt (WebSocket через Ingress)
│ https://iot.kube5s.ru/console (UI)
```
### Компоненты (актуальные)
| Компонент | Бинарник | Порт | Назначение |
|-----------|----------|------|------------|
| iot-operator | cmd/iot-operator | :9090 (API), :8080 (metrics), :8081 (health) | Controller-manager + REST API |
| mqtt-bridge | cmd/mqtt-bridge | — | MQTT → shared-SQS bridge |
| sqs-consumer | cmd/sqs-consumer | — | shared-SQS → Postgres pipeline |
### Инфраструктура
| Сервис | Тип | Namespace | Описание |
|--------|-----|-----------|----------|
| EMQX 5.5.1 | В кластере | sless | MQTT-брокер, HTTP auth/acl → iot-operator |
| shared-SQS | В кластере | shared-sqs | AWS SQS-совместимая очередь, endpoint https://qu.kube5s.ru |
| PostgreSQL 17 | Managed (оператор) | dc5db45d-... | per-tenant DB, тот же инстанс что и SQS billing |
| cert-manager | В кластере | cert-manager | TLS сертификаты для iot.kube5s.ru |
| nginx-ingress | В кластере | ingress | WSS/HTTPS проксирование |
### Секреты в namespace sless
| Secret | Ключи | Источник |
|--------|-------|----------|
| iot-sqs-credentials | SQS_ENDPOINT, SQS_ACCESS_KEY, SQS_SECRET_KEY | shared-SQS tenant iot-service |
| iot-postgres-secret | POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB, IOT_PG_DSN | Managed PG |
| iot-bridge-credentials | MQTT_USERNAME, MQTT_PASSWORD | Для mqtt-bridge → EMQX |
### Docker образ (актуальный)
- `naeel/iot-operator:v0.2.0` (Docker Hub)
- Все 3 бинарника в одном образе
- `command: ["/iot-operator"]` или `["/mqtt-bridge"]` или `["/sqs-consumer"]`
- Базовый: `gcr.io/distroless/static:nonroot`
### Кластер
- Имя: `iot-naeel`
- API: `https://185.247.187.149:6443`
- Ingress IP: `185.247.187.151`
- DNS: `iot.kube5s.ru → 185.247.187.147`
@@ -0,0 +1,77 @@
# Решение: замена Kafka на shared-SQS
**Дата:** 2026-04-12
**Статус:** в работе
**Ветка:** `feature/replace-kafka-with-sqs`
---
## Контекст
IoT-сервис использует Kafka (библиотека `segmentio/kafka-go`) как промежуточную очередь между MQTT-bridge и Postgres consumer.
Kafka — тяжёлый компонент: требует отдельный деплоймент в кластере (`deployments/k8s/kafka.yaml`), ZooKeeper/KRaft, настройку топиков, партиций.
У нас есть собственный сервис **shared-SQS** — AWS SQS-совместимая очередь сообщений:
- Репа: https://gitea.services.ngcloud.ru/Nail/shared-SQS
- Endpoint: `https://qu.kube5s.ru`
- 17 SQS-операций, multi-tenant, Redis persistence, billing
- Работает через стандартные AWS SDK
RabbitMQ в коде IoT **не используется** (только в старых документах как план MVP).
---
## Решение
Заменить Kafka → shared-SQS во всём IoT pipeline.
---
## Что меняется
### 1. mqtt-bridge (`cmd/mqtt-bridge/main.go`)
- **Было:** `kafka.Writer` → `WriteMessages()` в топик `iot.telemetry`
- **Стало:** AWS SDK Go v2 → `sqs.SendMessage()` в очередь `iot-telemetry`
- Env: `KAFKA_BROKERS` → `SQS_ENDPOINT`, `SQS_ACCESS_KEY`, `SQS_SECRET_KEY`, `SQS_QUEUE_NAME`
### 2. kafka-consumer → sqs-consumer (`cmd/kafka-consumer/` → `cmd/sqs-consumer/`)
- **Было:** `kafka.NewReader` с consumer group, `FetchMessage()` + `CommitMessages()`
- **Стало:** polling loop: `sqs.ReceiveMessage(WaitTimeSeconds=20)` + `sqs.DeleteMessage()`
- Переименовать директорию и бинарник
### 3. admin stats handler (`internal/api/handler/iot_admin_stats_handler.go`)
- **Было:** Kafka lag (ListOffsets + OffsetFetch)
- **Стало:** `sqs.GetQueueAttributes(ApproximateNumberOfMessages, ApproximateNumberOfMessagesNotVisible)`
- Env: `KAFKA_BROKERS` → SQS credentials в handler
### 4. go.mod
- Убрать: `github.com/segmentio/kafka-go`
- Добавить: `github.com/aws/aws-sdk-go-v2`, `github.com/aws/aws-sdk-go-v2/service/sqs`
### 5. Deployments
- Удалить: `deployments/k8s/kafka.yaml`
- Обновить: `deployments/k8s/iot-mqtt-bridge.yaml` — новые env vars
- Обновить: `deployments/k8s/iot-kafka-consumer.yaml` → `iot-sqs-consumer.yaml`
### 6. Dockerfile / Makefile
- Переименовать бинарник `kafka-consumer` → `sqs-consumer`
---
## Плюсы
- Убираем Kafka из кластера (экономия ресурсов)
- Используем свой managed сервис (единая инфраструктура)
- AWS SDK — стандартная библиотека, код проще
- Billing и мониторинг из коробки в shared-SQS
## Риски
- SQS — pull-based (polling latency до 20s long poll vs Kafka push). Для IoT телеметрии приемлемо.
- At-least-once delivery — нужно учитывать idempotency (уже есть в текущем коде: INSERT ON CONFLICT)
---
## SQS endpoint
Пока используем публичный `https://qu.kube5s.ru`. Если есть внутрикластерный сервис — обновим.
+295
View File
@@ -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`
+98
View File
@@ -304,3 +304,101 @@ node {
|--------|------|-----------| |--------|------|-----------|
| v0.1.50 | 2026-04-04 | IoT controller + IoT API + iot-mqtt-bridge бинарь | | v0.1.50 | 2026-04-04 | IoT controller + IoT API + iot-mqtt-bridge бинарь |
| v0.1.49 | ранее | До IoT | | v0.1.49 | ранее | До IoT |
---
## Актуальный деплой (2026-04-12)
> Кластер: iot-naeel (новый). Kafka удалён. Postgres — managed. SQS вместо Kafka.
### Кластер iot-naeel
- API: `https://185.247.187.149:6443`
- Ingress IP: `185.247.187.151`
- DNS: `iot.kube5s.ru → 185.247.187.147`
- kubeconfig: `~/.kube/config` на ВМ (токен с TTL 24ч, обновляется через auth.k8s.ngcloud.ru)
### Namespace sless — содержимое
| Ресурс | Имя | Описание |
|--------|-----|----------|
| Deployment | emqx | MQTT-брокер EMQX 5.5.1 |
| Deployment | iot-operator | Controller-manager + REST API v0.2.0 |
| Deployment | iot-mqtt-bridge | MQTT → shared-SQS bridge v0.2.0 |
| Deployment | iot-sqs-consumer | shared-SQS → Postgres consumer v0.2.0 |
| Service | emqx | :1883 (MQTT), :8083 (WS), :18083 (dashboard) |
| Service | emqx-ws | :8083 (для Ingress) |
| Service | iot-operator | :9090 (REST API) |
| Ingress | emqx-mqtt-websocket | iot.kube5s.ru → /mqtt (WS), /console (UI) |
| Secret | iot-sqs-credentials | SQS_ENDPOINT, SQS_ACCESS_KEY, SQS_SECRET_KEY |
| Secret | iot-postgres-secret | POSTGRES_USER, POSTGRES_PASSWORD, IOT_PG_DSN |
| Secret | iot-bridge-credentials | MQTT_USERNAME, MQTT_PASSWORD |
| CRD | iotdevices.iot.kube5s.ru | IoTDevice v1alpha1 |
| ServiceAccount | iot-operator | + ClusterRole + ClusterRoleBinding |
### Процедура деплоя (актуальная)
```bash
# 0. SSH на ВМ (все команды оттуда)
ssh -i ~/.ssh/id_ed25519 naeel@5.172.178.213
# 1. Сборка и push образа
cd /home/naeel/terra/IoT
docker build -t naeel/iot-operator:v0.2.0 -t naeel/iot-operator:latest .
docker push naeel/iot-operator:v0.2.0
docker push naeel/iot-operator:latest
# 2. CRD (один раз)
kubectl apply -f config/crd/bases/iot.kube5s.ru_iotdevices.yaml
# 3. Postgres Secret (содержит DSN managed PG)
kubectl apply -f deployments/k8s/iot-postgres.yaml
# 4. EMQX
kubectl apply -f deployments/k8s/emqx.yaml
kubectl apply -f deployments/k8s/emqx-ws-ingress.yaml
# 5. Operator
kubectl apply -f deployments/k8s/iot-operator.yaml
# 6. SQS credentials (уже создан через kubectl create secret)
# kubectl get secret iot-sqs-credentials -n sless
# 7. MQTT bridge credentials (уже создан)
# kubectl get secret iot-bridge-credentials -n sless
# 8. Bridge + Consumer
kubectl apply -f deployments/k8s/iot-mqtt-bridge.yaml
kubectl apply -f deployments/k8s/iot-sqs-consumer.yaml
# 9. Проверка
kubectl get pods -n sless
kubectl logs -n sless deployment/iot-operator --tail=10
kubectl logs -n sless deployment/iot-mqtt-bridge --tail=10
kubectl logs -n sless deployment/iot-sqs-consumer --tail=10
```
### Managed PostgreSQL
- Namespace: `dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5`
- Pod: `postgresqlk8s-0`
- Host: `postgresqlk8s-master.dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5.svc.cluster.local`
- User: `super`, DB: `sqsdb`, PG 17
- Тот же инстанс что использует shared-SQS для billing
- Credentials: см. `/home/naeel/terra/SQS-service/secrets/iot_pg.md`
### shared-SQS (очередь вместо Kafka)
- Namespace: `shared-sqs`
- Endpoint: `https://qu.kube5s.ru`
- Tenant ID: `t-96afe7e9f781f6ca` (iot-service)
- Queue: `iot-telemetry`
- Access Key: хранится в Secret `iot-sqs-credentials` namespace `sless`
- Admin token: хранится в Secret `shared-sqs-admin` namespace `shared-sqs`
### Версии образов (актуальные)
| Версия | Дата | Registry | Изменения |
|--------|------|----------|-----------|
| v0.2.0 | 2026-04-12 | Docker Hub naeel/iot-operator | Kafka→SQS, managed PG, новый кластер |
| v0.1.50 | 2026-04-04 | pearlharbor (Harbor) | IoT controller + API + mqtt-bridge (Kafka) |
+102
View File
@@ -35,3 +35,105 @@
- [ ] Деплой в кластер - [ ] Деплой в кластер
- [ ] E2E тест: создание устройства → MQTT → Kafka → Postgres → API - [ ] E2E тест: создание устройства → MQTT → Kafka → Postgres → API
- [ ] Убрать IoT-код из sless (опционально, после подтверждения что всё работает) - [ ] Убрать IoT-код из sless (опционально, после подтверждения что всё работает)
---
## 2026-04-12: Замена Kafka → shared-SQS + деплой в новый кластер
### Выполнено
- [x] Создана ветка `feature/replace-kafka-with-sqs`
- [x] Документировано решение: `doc/decisions/2026-04-12-replace-kafka-with-sqs.md`
- [x] **mqtt-bridge** (`cmd/mqtt-bridge/main.go`): Kafka Writer → AWS SDK SQS SendMessage
- [x] **sqs-consumer** (`cmd/sqs-consumer/main.go`): новый — заменяет kafka-consumer, SQS ReceiveMessage → Postgres
- [x] **kafka-consumer** (`cmd/kafka-consumer/`): удалён из кода
- [x] **admin stats** (`internal/api/handler/iot_admin_stats_handler.go`): Kafka lag → SQS GetQueueAttributes
- [x] **handler.go**: убрано поле KafkaBrokers
- [x] **iot-operator/main.go**: убрана передача KAFKA_BROKERS
- [x] go.mod: убран `segmentio/kafka-go`, добавлен `aws-sdk-go-v2` (v1.41.5 + sqs v1.42.25)
- [x] Dockerfile: kafka-consumer → sqs-consumer
- [x] Makefile: build-consumer → sqs-consumer
- [x] .gitignore: исправлен баг (паттерны без `/` игнорировали `cmd/` директории)
- [x] `go build ./...` проходит на ВМ
- [x] Коммит и пуш в ветку
### Деплой в кластер iot-naeel (новый кластер)
- [x] shared-SQS: создан tenant `iot-service` (t-96afe7e9f781f6ca), очередь `iot-telemetry`
- [x] Namespace `sless` создан
- [x] Secret `iot-sqs-credentials` создан
- [x] Secret `iot-bridge-credentials` создан
- [x] Postgres: переключён на managed PG17 (тот же что SQS billing)
- [x] Secret `iot-postgres-secret` создан (DSN → managed PG)
- [x] Docker: собран и запушен `naeel/iot-operator:v0.2.0` в Docker Hub
- [x] Deployment YAML: обновлены image на Docker Hub, убраны imagePullSecrets
- [x] EMQX: задеплоен (emqx.yaml + emqx-ws-ingress.yaml, адаптирован из sless)
- [x] iot-operator: задеплоен (новый iot-operator.yaml с RBAC)
- [x] CRD `iotdevices.iot.kube5s.ru` установлен
- [x] iot-mqtt-bridge: задеплоен, Running
- [x] iot-sqs-consumer: задеплоен, Running — подключился к PG и SQS
- [x] cert-manager: выпускает TLS для iot.kube5s.ru
- [x] Коммит и пуш
### Что работает
- Все 4 пода Running 1/1
- iot-operator: controller стартовал, MQTT auth/acl обрабатывает запросы от EMQX
- sqs-consumer: подключился к Postgres и SQS, polling loop запущен
- mqtt-bridge: подключён к EMQX и SQS, SQS queue resolved
- EMQX: MQTT :1883, WS :8083, dashboard :18083
### Следующие шаги
- [ ] E2E тест: создание устройства → MQTT → SQS → Postgres → API
- [ ] Нагрузочный тест
- [ ] Мерж ветки в master
- [ ] Убрать IoT-код из sless (опционально)
---
## 2026-04-12: Документация v0.2.3
### Выполнено
- [x] Аудит документации из репозитория sless (thinking 04-04/05/06/09, decisions, progress, api, examples)
- [x] Аудит документации в IoT репозитории (deployment.md, overview.md, endpoints.md, mvp-plan.md)
- [x] Анализ расхождений — определены устаревшие секции (Kafka, RabbitMQ)
- [x] Решение: старые документы НЕ удалять, новые создавать отдельно
- [x] Создан doc/deployment-v0.2.3.md (295 строк) — актуальная инструкция деплоя
- [x] Создан doc/architecture/current-v0.2.3.md (256 строк) — полная архитектура v0.2.3
- [x] Создан doc/api/endpoints-v0.2.3.md (~300 строк) — полная документация REST API
- [x] Создан doc/run-and-test.md (~180 строк) — руководство по запуску и E2E тесту
- [x] Обновлён doc/progress.md
- [x] Дописан doc/thinking/2026-04-12.md — лог мышления сессии
### Баг-фиксы v0.2.1—v0.2.3
- v0.2.1: mqtt-bridge — bridge auth fix (BridgeUsername/BridgePassword проверяется ДО парсинга username)
- v0.2.3: финальный образ со всеми фиксами
### Документация (сессия 2, вечер)
Проведён полный аудит IoT-документации из sless репы. Прочитано:
- sless/doc/thinking/2026-04-04.md (681 строк) — архитектура, первый деплой
- sless/doc/thinking/2026-04-05.md (178 строк) — телеметрия, MQTTX, UX
- sless/doc/thinking/2026-04-06.md (613 строк) — Kafka pipeline, 8 тестов
- sless/doc/thinking/2026-04-09.md (349 строк) — shared-sqs, TenantStore
- sless/doc/iot/deployment.md — инженерный документ деплоя (RabbitMQ)
- sless/doc/progress.md — IoT-записи
- sless/examples/IOT/ — E2E demo
Созданы НОВЫЕ актуальные файлы (старые НЕ тронуты — база знаний):
- [x] doc/deployment-v0.2.3.md — полная процедура деплоя (SQS, managed PG, bridge auth)
- [x] doc/architecture/current-v0.2.3.md — актуальная архитектура (без Kafka/RabbitMQ)
- [x] doc/api/endpoints-v0.2.3.md — все API эндпоинты с примерами запросов/ответов
- [x] doc/run-and-test.md — как запустить и протестировать E2E
## 2026-04-12 — v0.2.5: баг-фиксы и E2E тест
### Найдены и исправлены баги:
1. **MQTTAuth: Get по Name=deviceID** — заменено на List + фильтр по Spec.DeviceID
2. **PG15+ GRANT**: добавлен `GRANT role TO CURRENT_USER` перед `CREATE DATABASE ... OWNER`
### E2E тест пройден:
- Устройство создано через REST API (201)
- MQTT CONNECT + PUBLISH через EMQX — OK
- mqtt-bridge → SQS forwarding — OK
- sqs-consumer → Postgres (tenant DB created + telemetry saved) — OK
- REST API GET /telemetry — 2 записи с payload
### Docker Hub: `naeel/iot-operator:v0.2.5`
+249
View File
@@ -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 секунд
+180
View File
@@ -57,3 +57,183 @@
**Плюс:** AWS SDK for Go — стандартная библиотека, код станет проще. shared-SQS уже живой. **Плюс:** AWS SDK for Go — стандартная библиотека, код станет проще. shared-SQS уже живой.
**Минус:** polling latency (ReceiveMessage WaitTimeSeconds до 20s) vs Kafka push. Для IoT телеметрии — приемлемо. **Минус:** polling latency (ReceiveMessage WaitTimeSeconds до 20s) vs Kafka push. Для IoT телеметрии — приемлемо.
---
## Продолжение — GitHub Copilot (Claude Opus 4.6)
### Реализация замены Kafka → SQS
**Что сделано:**
1. **mqtt-bridge** — полностью переписан:
- Убран `segmentio/kafka-go`, добавлен `aws-sdk-go-v2` (sqs, config, credentials)
- `kafka.Writer` → `sqs.Client.SendMessage`
- При старте: `GetQueueUrl` для резолва URL очереди "iot-telemetry"
- Env vars: `SQS_ENDPOINT`, `SQS_ACCESS_KEY`, `SQS_SECRET_KEY`, `SQS_QUEUE_NAME`, `SQS_REGION`
- Формат сообщения (MessageBody JSON) не изменился: `{namespace, device_id, topic, payload, received_at}`
2. **sqs-consumer** — создан с нуля (заменяет kafka-consumer):
- Long polling: `ReceiveMessage(WaitTimeSeconds=20)` — минимизирует запросы при пустой очереди
- At-least-once: `DeleteMessage` только после успешной записи в Postgres
- Использует `iotpg.IoTPostgresStore` — тот же механизм per-tenant DB что и kafka-consumer
3. **admin stats handler** — переписан:
- Вместо Kafka consumer lag → `GetQueueAttributes(ApproximateNumberOfMessages, ApproximateNumberOfMessagesNotVisible)`
- Pod labels для consumer: `iot-kafka-consumer` → `iot-sqs-consumer`
4. **Баг .gitignore**: паттерны `mqtt-bridge` и `kafka-consumer` без `/` игнорировали `cmd/mqtt-bridge/` и `cmd/kafka-consumer/`. Исправлено добавлением `/` префикса.
5. **Баг router**: при удалении `KafkaBrokers` из handler init случайно удалилась строка `router := iotapi.NewRouter(h, log)`. Восстановлена.
### Деплой в новый кластер iot-naeel
**Обнаружения при деплое:**
1. Кластер **полностью новый** — namespace `sless` не существовал, ничего не задеплоено.
2. **Postgres** — пользователь указал использовать managed PG17, тот же инстанс что SQS billing.
Credentials в `/home/naeel/terra/SQS-service/secrets/iot_pg.md`.
Самодеплоенный postgres:16-alpine из YAML заменён на DSN к managed PG.
3. **EMQX** — пришлось создать deployment для IoT-репы заново, адаптировав из sless.
Ключевое изменение: auth URL `sless-operator.sless.svc:9090` → `iot-operator.sless.svc:9090`.
4. **iot-operator deployment** — его не было в IoT-репе! Создан новый:
- ServiceAccount + ClusterRole (iotdevices CRD, secrets, events, namespaces, leases)
- ClusterRoleBinding
- Deployment + Service :9090
5. **kubectl токен** истекал за 24 часа — пользователь обновлял вручную.
6. **Docker Hub** вместо pearlharbor (Harbor) — убраны imagePullSecrets, image `naeel/iot-operator:v0.2.0`.
7. **shared-SQS tenant** создан через API:
- Tenant: `iot-service`, ID: `t-96afe7e9f781f6ca`
- Queue: `iot-telemetry`
- Admin token из Secret `shared-sqs-admin` в namespace `shared-sqs`
### Результат
Все 4 пода Running 1/1:
- `iot-operator` — controller работает, MQTT auth/acl обрабатывает запросы
- `emqx` — MQTT брокер, подключает IoT устройства
- `iot-mqtt-bridge` — подписан на EMQX, SQS queue resolved
- `iot-sqs-consumer` — подключён к PG и SQS, polling loop активен
TLS сертификат для `iot.kube5s.ru` выпускается cert-manager.
---
## Сессия 2 — Агент: GitHub Copilot (Claude Opus 4.6)
### Контекст
Продолжение деплоя после замены Kafka→SQS. Новый кластер iot-naeel, namespace sless пустой.
### Что обнаружил
1. kubeconfig токен истёк (JWT TTL=24ч) — пользователь обновил вручную
2. Kafka отсутствует в кластере — уже нет, удалять нечего
3. Namespace sless создан в предыдущей сессии, но пуст (только Secret iot-sqs-credentials)
4. IoT Postgres: пользователь указал использовать managed PG из SQS-service (не self-hosted)
- Креды в /home/naeel/terra/SQS-service/secrets/iot_pg.md
- Host: postgresqlk8s-master.dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5.svc.cluster.local
- PG17, managed через оператор
5. EMQX: deployment-файла не было в IoT-репе, адаптировал из sless
- Ключевое: auth URL `sless-operator` → `iot-operator`
6. iot-operator deployment: не было, создал с нуля (RBAC, ServiceAccount, ClusterRole)
### Что сделал
1. Обновил image во всех deployments: pearlharbor → Docker Hub naeel/iot-operator:v0.2.0
2. Убрал imagePullSecrets (Docker Hub публичный)
3. Собрал Docker образ на ВМ, запушил в Docker Hub (v0.2.0 + latest)
4. Обновил iot-postgres.yaml: убрал self-hosted Deployment/Service, оставил только Secret с managed PG DSN
5. Создал emqx.yaml (EMQX 5.5.1 + ConfigMap + Service, auth→iot-operator)
6. Создал emqx-ws-ingress.yaml (ws Ingress + TLS cert-manager)
7. Создал iot-operator.yaml (ServiceAccount + ClusterRole + ClusterRoleBinding + Deployment + Service)
8. Установил CRD iotdevices.iot.kube5s.ru
9. Создал секреты: iot-postgres-secret, iot-bridge-credentials
10. Задеплоил всё: EMQX → operator → mqtt-bridge → sqs-consumer
11. Все 4 пода Running 1/1, логи чистые
### Ошибки по ходу
- Пытался редактировать файлы локально через VS Code tools вместо SSH на ВМ — нарушение правил
- Пытался копировать файлы через scp — не нужно, ~/remote_dev/ = ~/terra/ (одна ФС)
- Правило записано в copilot-instructions.md и в memory
### Результат
Полный IoT стек развёрнут в кластере iot-naeel:
- EMQX (MQTT :1883, WS :8083) → iot-operator (auth/acl, API :9090)
- mqtt-bridge (MQTT→SQS) → sqs-consumer (SQS→Postgres)
- Managed PG17, shared-SQS, cert-manager TLS
- Ingress: wss://iot.kube5s.ru/mqtt, https://iot.kube5s.ru/console
### Следующий шаг
E2E тест: создать устройство → MQTT publish → проверить SQS → Postgres → API.
---
## Сессия: Документация v0.2.3 (продолжение)
Агент: GitHub Copilot (Claude Opus 4.6)
### Что сделано
1. **architecture/current-v0.2.3.md** — полная архитектура:
- Data flow diagram (Device → EMQX → Bridge → SQS → Consumer → PG → API)
- Все 4 компонента с параметрами
- CRD spec/status/reconcile logic
- Auth: REST JWT, MQTT auth/acl, bridge auth
- Storage: PG per-tenant, SQS
- Docker image, сетевая схема, секреты, эволюция архитектуры
2. **api/endpoints-v0.2.3.md** — документация API:
- Изучил router.go, handler.go, iot_device_handler.go, iot_telemetry_handler.go, iot_admin_stats_handler.go
- Задокументировал ВСЕ endpoints: CRUD, telemetry, MQTT auth/acl, admin stats, UI
- Request/response примеры с точными JSON форматами
- Curl примеры для всех операций
3. **run-and-test.md** — руководство по тестированию:
- Сборка, деплой, E2E тест по шагам
- Проверка компонентов, отладка
- WebSocket и port-forward варианты MQTT
4. **progress.md** — дополнена секция документации
### Проблемы
- Скрипт /tmp/write_arch.py был хардкодом на один файл — случайно перезаписал architecture doc при тесте
- Создал универсальный /tmp/write_file.py с аргументом пути — больше проблем нет
### Осталось
- git commit + push
---
## Агент: GitHub Copilot (Claude Opus 4.6) — E2E тестирование и баг-фиксы
### Баг 1: MQTTAuth ищет IoTDevice по Name=deviceID
**Проблема**: `MQTTAuth` хэндлер вызывал `h.K8s.Get(client.ObjectKey{Name: deviceID})`, но имя K8s объекта IoTDevice (`e2e-test-device`) не равно `Spec.DeviceID` (`e2e-test-01`). Результат — `deny`.
**Диагностика**:
1. Auth endpoint вернул `{"result":"deny"}` при прямом вызове curl
2. Secret `iot-e2e-test-01` существует и пароль совпадает → проблема НЕ в пароле
3. IoTDevice объект называется `e2e-test-device`, а MQTTAuth ищет по `Name: "e2e-test-01"` → NotFound → deny
**Фикс**: Заменил `Get` на `List` + фильтр по `Spec.DeviceID == deviceID`. Это O(n) по количеству устройств в namespace, но для MVP приемлемо. При необходимости можно добавить label-index.
### Баг 2: PG15+ требует GRANT перед CREATE DATABASE ... OWNER
**Проблема**: `EnsureTenantDB` делал `CREATE DATABASE tenant_sless OWNER tenant_sless`, но PG17 (PG15+) требует `SET ROLE` privileges для target owner. Ошибка: `pq: must be able to SET ROLE "tenant_sless" (42501)`.
**Фикс**: Добавил `GRANT {userName} TO CURRENT_USER` перед `CREATE DATABASE ... OWNER`.
### E2E тест v0.2.5 — полный пайплайн
1. `POST /v1/namespaces/sless/iot/devices` → 201, device created
2. `GET /devices/e2e-test-device` → phase=Active, credentials получены
3. `mosquitto_pub` → CONNACK(0), PUBLISH OK
4. mqtt-bridge → `forwarded IoT telemetry to SQS`
5. sqs-consumer → `created tenant DB` + `telemetry saved to Postgres`
6. `GET /v1/namespaces/sless/iot/telemetry?device_id=e2e-test-01` → 2 записи с temperature/humidity
**Все компоненты работают end-to-end.**
+6 -3
View File
@@ -3,11 +3,13 @@ module gitea.services.ngcloud.ru/Nail/IoT
go 1.25 go 1.25
require ( require (
github.com/aws/aws-sdk-go-v2 v1.41.5
github.com/aws/aws-sdk-go-v2/credentials v1.19.14
github.com/aws/aws-sdk-go-v2/service/sqs v1.42.25
github.com/eclipse/paho.mqtt.golang v1.5.1 github.com/eclipse/paho.mqtt.golang v1.5.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/mux v1.8.1 github.com/gorilla/mux v1.8.1
github.com/lib/pq v1.11.2 github.com/lib/pq v1.11.2
github.com/segmentio/kafka-go v0.4.50
k8s.io/api v0.26.0 k8s.io/api v0.26.0
k8s.io/apimachinery v0.26.0 k8s.io/apimachinery v0.26.0
k8s.io/client-go v0.26.0 k8s.io/client-go v0.26.0
@@ -15,6 +17,9 @@ require (
) )
require ( require (
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
github.com/aws/smithy-go v1.24.2 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
@@ -36,13 +41,11 @@ require (
github.com/imdario/mergo v0.3.6 // indirect github.com/imdario/mergo v0.3.6 // indirect
github.com/josharian/intern v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.15.9 // indirect
github.com/mailru/easyjson v0.7.6 // indirect github.com/mailru/easyjson v0.7.6 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.2 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pierrec/lz4/v4 v4.1.15 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus/client_golang v1.14.0 // indirect github.com/prometheus/client_golang v1.14.0 // indirect
github.com/prometheus/client_model v0.3.0 // indirect github.com/prometheus/client_model v0.3.0 // indirect
+12 -12
View File
@@ -38,6 +38,18 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
github.com/aws/aws-sdk-go-v2/service/sqs v1.42.25 h1:8Bv3TQ1Cob6HLlpUbAnWxeHhAkYScJO9RIHh2WPXaxw=
github.com/aws/aws-sdk-go-v2/service/sqs v1.42.25/go.mod h1:eDstEbM0OEnBUnNQxIA7j74Jy61cCU1S4EMlCtdMwzs=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
@@ -188,8 +200,6 @@ github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7V
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.15.9 h1:wKRjX6JRtDdrE9qwa4b/Cip7ACOshUI4smpCQanqjSY=
github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
@@ -225,8 +235,6 @@ github.com/onsi/ginkgo/v2 v2.6.0 h1:9t9b9vRUbFq3C4qKFCGkVuq/fIHji802N1nrtkh1mNc=
github.com/onsi/ginkgo/v2 v2.6.0/go.mod h1:63DOGlLAH8+REH8jUGdL3YpCpu7JODesutUjdENfUAc= github.com/onsi/ginkgo/v2 v2.6.0/go.mod h1:63DOGlLAH8+REH8jUGdL3YpCpu7JODesutUjdENfUAc=
github.com/onsi/gomega v1.24.1 h1:KORJXNNTzJXzu4ScJWssJfJMnJ+2QJqhoQSRwNlze9E= github.com/onsi/gomega v1.24.1 h1:KORJXNNTzJXzu4ScJWssJfJMnJ+2QJqhoQSRwNlze9E=
github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM= github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM=
github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0=
github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -260,8 +268,6 @@ github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1
github.com/prometheus/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5mo= github.com/prometheus/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5mo=
github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4= github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/segmentio/kafka-go v0.4.50 h1:mcyC3tT5WeyWzrFbd6O374t+hmcu1NKt2Pu1L3QaXmc=
github.com/segmentio/kafka-go v0.4.50/go.mod h1:Y1gn60kzLEEaW28YshXyk2+VCUKbJ3Qr6DrnT3i4+9E=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
@@ -278,12 +284,6 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+4 -2
View File
@@ -30,9 +30,11 @@ K8s client.Client
Scheme *runtime.Scheme Scheme *runtime.Scheme
// IoTPG — хранилище IoT телеметрии (per-tenant Postgres). nil если IOT_PG_DSN не задан. // IoTPG — хранилище IoT телеметрии (per-tenant Postgres). nil если IOT_PG_DSN не задан.
IoTPG *iotpg.IoTPostgresStore IoTPG *iotpg.IoTPostgresStore
// KafkaBrokers — адреса Kafka брокеров для чтения consumer lag на admin странице.
KafkaBrokers string
Log *slog.Logger Log *slog.Logger
// BridgeUsername — username внутреннего MQTT bridge (env MQTT_BRIDGE_USERNAME)
BridgeUsername string
// BridgePassword — пароль bridge (env MQTT_BRIDGE_PASSWORD)
BridgePassword string
} }
// writeJSON отправляет JSON-ответ с указанным статусом. // writeJSON отправляет JSON-ответ с указанным статусом.
+57 -66
View File
@@ -1,4 +1,5 @@
// Создано: 2026-04-06 // Создано: 2026-04-06
// Изменено: 2026-04-12 (замена Kafka lag → SQS queue stats)
// iot_admin_stats_handler.go — handler для страницы администратора IoT. // iot_admin_stats_handler.go — handler для страницы администратора IoT.
// //
// Endpoints: // Endpoints:
@@ -6,8 +7,8 @@
// //
// Источники данных: // Источники данных:
// - PostgreSQL (IoTPG): counts per tenant, last 1h/24h, latest rows // - PostgreSQL (IoTPG): counts per tenant, last 1h/24h, latest rows
// - Kafka: consumer lag (latest offset - committed offset для group iot-pg-consumer) // - SQS: approximate message count (ApproximateNumberOfMessages)
// - K8s: статус подов iot-mqtt-bridge и iot-kafka-consumer // - K8s: статус подов iot-mqtt-bridge и iot-sqs-consumer
// //
// Авторизация: Bearer из env ADMIN_STATS_TOKEN. // Авторизация: Bearer из env ADMIN_STATS_TOKEN.
// Если ADMIN_STATS_TOKEN не задан — endpoint возвращает 503. // Если ADMIN_STATS_TOKEN не задан — endpoint возвращает 503.
@@ -19,10 +20,14 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
"strconv"
"strings" "strings"
"time" "time"
kafka "github.com/segmentio/kafka-go" "github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/sqs"
sqstypes "github.com/aws/aws-sdk-go-v2/service/sqs/types"
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
"sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client"
) )
@@ -36,11 +41,10 @@ type iotAdminPodStatus struct {
Age string `json:"age"` Age string `json:"age"`
} }
// iotAdminKafkaStats — информация о Kafka топике и consumer lag. // iotAdminSQSStats — информация о SQS очереди для мониторинга.
type iotAdminKafkaStats struct { type iotAdminSQSStats struct {
LatestOffset int64 `json:"latest_offset"` ApproximateMessages int64 `json:"approximate_messages"`
CommittedOffset int64 `json:"committed_offset"` ApproximateMessagesNotVisible int64 `json:"approximate_messages_not_visible"`
ConsumerLag int64 `json:"consumer_lag"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
@@ -78,8 +82,8 @@ func (h *Handler) AdminStats(w http.ResponseWriter, r *http.Request) {
result["postgres"] = map[string]any{"reachable": false, "error": "IoTPG not configured"} result["postgres"] = map[string]any{"reachable": false, "error": "IoTPG not configured"}
} }
// Kafka: consumer lag для топика iot.telemetry / группы iot-pg-consumer // SQS: approximate message count для очереди iot-telemetry
result["kafka"] = h.collectIotKafkaLag(ctx) result["sqs"] = h.collectIotSQSStats(ctx)
// K8s: статус подов bridge и consumer // K8s: статус подов bridge и consumer
result["pods"] = h.collectIotPodStatuses(ctx) result["pods"] = h.collectIotPodStatuses(ctx)
@@ -87,72 +91,59 @@ func (h *Handler) AdminStats(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, result) writeJSON(w, http.StatusOK, result)
} }
// collectIotKafkaLag получает latest offset топика и committed offset consumer group, // collectIotSQSStats получает ApproximateNumberOfMessages из SQS очереди.
// вычисляет lag = latest - committed. // Использует SQS_ENDPOINT, SQS_ACCESS_KEY, SQS_SECRET_KEY, SQS_QUEUE_NAME из env.
// Topic: "iot.telemetry", Consumer Group: "iot-pg-consumer". func (h *Handler) collectIotSQSStats(ctx context.Context) iotAdminSQSStats {
func (h *Handler) collectIotKafkaLag(ctx context.Context) iotAdminKafkaStats { endpoint := os.Getenv("SQS_ENDPOINT")
if h.KafkaBrokers == "" { accessKey := os.Getenv("SQS_ACCESS_KEY")
return iotAdminKafkaStats{Error: "KAFKA_BROKERS not configured"} secretKey := os.Getenv("SQS_SECRET_KEY")
queueName := os.Getenv("SQS_QUEUE_NAME")
if queueName == "" {
queueName = "iot-telemetry"
}
region := os.Getenv("SQS_REGION")
if region == "" {
region = "us-east-1"
} }
brokers := strings.Split(h.KafkaBrokers, ",") if endpoint == "" || accessKey == "" || secretKey == "" {
brokerAddr := kafka.TCP(brokers...) return iotAdminSQSStats{Error: "SQS credentials not configured (SQS_ENDPOINT, SQS_ACCESS_KEY, SQS_SECRET_KEY)"}
kc := &kafka.Client{
Addr: brokerAddr,
Timeout: 5 * time.Second,
} }
const topic = "iot.telemetry" sqsClient := sqs.New(sqs.Options{
const group = "iot-pg-consumer" Region: region,
Credentials: credentials.NewStaticCredentialsProvider(
accessKey, secretKey, "",
),
BaseEndpoint: aws.String(endpoint),
})
// Получаем latest offset (конец лога — сколько всего сообщений прошло) // Получаем URL очереди
offsetsResp, err := kc.ListOffsets(ctx, &kafka.ListOffsetsRequest{ queueUrlOut, err := sqsClient.GetQueueUrl(ctx, &sqs.GetQueueUrlInput{
Addr: brokerAddr, QueueName: aws.String(queueName),
Topics: map[string][]kafka.OffsetRequest{ })
topic: {kafka.LastOffsetOf(0)}, if err != nil {
return iotAdminSQSStats{Error: fmt.Sprintf("GetQueueUrl: %v", err)}
}
// Запрашиваем атрибуты очереди — approximate message counts
attrsOut, err := sqsClient.GetQueueAttributes(ctx, &sqs.GetQueueAttributesInput{
QueueUrl: queueUrlOut.QueueUrl,
AttributeNames: []sqstypes.QueueAttributeName{
sqstypes.QueueAttributeNameApproximateNumberOfMessages,
sqstypes.QueueAttributeNameApproximateNumberOfMessagesNotVisible,
}, },
}) })
if err != nil { if err != nil {
return iotAdminKafkaStats{Error: fmt.Sprintf("list offsets: %v", err)} return iotAdminSQSStats{Error: fmt.Sprintf("GetQueueAttributes: %v", err)}
} }
var latestOffset int64 approxMsg, _ := strconv.ParseInt(attrsOut.Attributes["ApproximateNumberOfMessages"], 10, 64)
if partitions, ok := offsetsResp.Topics[topic]; ok && len(partitions) > 0 { approxNotVisible, _ := strconv.ParseInt(attrsOut.Attributes["ApproximateNumberOfMessagesNotVisible"], 10, 64)
if partitions[0].Error == nil {
latestOffset = partitions[0].LastOffset
}
}
// Получаем committed offset consumer group (что consumer уже обработал) return iotAdminSQSStats{
fetchResp, err := kc.OffsetFetch(ctx, &kafka.OffsetFetchRequest{ ApproximateMessages: approxMsg,
Addr: brokerAddr, ApproximateMessagesNotVisible: approxNotVisible,
GroupID: group,
Topics: map[string][]int{topic: {0}},
})
if err != nil {
return iotAdminKafkaStats{
LatestOffset: latestOffset,
Error: fmt.Sprintf("offset fetch: %v", err),
}
}
var committedOffset int64
if partitions, ok := fetchResp.Topics[topic]; ok && len(partitions) > 0 {
if partitions[0].Error == nil {
committedOffset = partitions[0].CommittedOffset
}
}
lag := latestOffset - committedOffset
if lag < 0 {
lag = 0
}
return iotAdminKafkaStats{
LatestOffset: latestOffset,
CommittedOffset: committedOffset,
ConsumerLag: lag,
} }
} }
@@ -160,7 +151,7 @@ func (h *Handler) collectIotKafkaLag(ctx context.Context) iotAdminKafkaStats {
func (h *Handler) collectIotPodStatuses(ctx context.Context) map[string]any { func (h *Handler) collectIotPodStatuses(ctx context.Context) map[string]any {
result := map[string]any{} result := map[string]any{}
for _, appLabel := range []string{"iot-mqtt-bridge", "iot-kafka-consumer"} { for _, appLabel := range []string{"iot-mqtt-bridge", "iot-sqs-consumer"} {
podList := &corev1.PodList{} podList := &corev1.PodList{}
if err := h.K8s.List(ctx, podList, if err := h.K8s.List(ctx, podList,
client.InNamespace("sless"), client.InNamespace("sless"),
+30 -4
View File
@@ -151,6 +151,18 @@ func (h *Handler) MQTTAuth(w http.ResponseWriter, r *http.Request) {
return return
} }
// Bridge — внутренний MQTT bridge клиент. Проверяем фиксированные credentials
// до парсинга namespace_deviceId.
if h.BridgeUsername != "" && req.Username == h.BridgeUsername {
if subtle.ConstantTimeCompare([]byte(h.BridgePassword), []byte(req.Password)) == 1 {
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "allow"})
return
}
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
return
}
// Парсим username: "{namespace}_{deviceId}" // Парсим username: "{namespace}_{deviceId}"
// Namespace содержит только [a-z0-9-], первый "_" — разделитель. // Namespace содержит только [a-z0-9-], первый "_" — разделитель.
idx := strings.Index(req.Username, "_") idx := strings.Index(req.Username, "_")
@@ -181,10 +193,21 @@ func (h *Handler) MQTTAuth(w http.ResponseWriter, r *http.Request) {
return return
} }
// Проверяем что IoTDevice активно // Ищем IoTDevice по Spec.DeviceID (имя K8s объекта может отличаться от deviceID)
device := &iotv1alpha1.IoTDevice{} deviceList := &iotv1alpha1.IoTDeviceList{}
if err := h.K8s.Get(r.Context(), client.ObjectKey{Namespace: ns, Name: deviceID}, device); err != nil { if err := h.K8s.List(r.Context(), deviceList, client.InNamespace(ns)); err != nil {
// IoTDevice не найден (или удалён) — deny writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
return
}
var device *iotv1alpha1.IoTDevice
for i := range deviceList.Items {
if deviceList.Items[i].Spec.DeviceID == deviceID {
device = &deviceList.Items[i]
break
}
}
if device == nil {
// IoTDevice с таким deviceID не найден — deny
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"}) writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
return return
} }
@@ -427,6 +450,9 @@ func (h *Handler) MQTTAcl(w http.ResponseWriter, r *http.Request) {
return return
} }
// Bridge — внутренний MQTT bridge клиент. Проверяем фиксированные credentials
// до парсинга namespace_deviceId.
// Парсим username → namespace + deviceId (формат: "{ns}_{deviceId}") // Парсим username → namespace + deviceId (формат: "{ns}_{deviceId}")
// strings.Index находит ПЕРВЫЙ '_' — namespace содержит только дефисы // strings.Index находит ПЕРВЫЙ '_' — namespace содержит только дефисы
idx := strings.Index(req.Username, "_") idx := strings.Index(req.Username, "_")
@@ -28,7 +28,7 @@ func (h *Handler) ListIoTTelemetry(w http.ResponseWriter, r *http.Request) {
} }
ns := mux.Vars(r)["namespace"] ns := mux.Vars(r)["namespace"]
deviceID := r.URL.Query().Get("device") deviceID := r.URL.Query().Get("device_id")
limit := 50 limit := 50
if ls := r.URL.Query().Get("limit"); ls != "" { if ls := r.URL.Query().Get("limit"); ls != "" {
if n, err := strconv.Atoi(ls); err == nil && n > 0 { if n, err := strconv.Atoi(ls); err == nil && n > 0 {
@@ -126,6 +126,13 @@ func (s *IoTPostgresStore) EnsureTenantDB(ctx context.Context, namespace string)
return fmt.Errorf("iotpg: create user %s: %w", userName, err) return fmt.Errorf("iotpg: create user %s: %w", userName, err)
} }
// PG15+: GRANT role TO current_user перед CREATE DATABASE ... OWNER
if _, err = s.adminDB.ExecContext(ctx,
fmt.Sprintf(`GRANT %s TO CURRENT_USER`, userName),
); err != nil {
return fmt.Errorf("iotpg: grant role %s: %w", userName, err)
}
// CREATE DATABASE нельзя в транзакции // CREATE DATABASE нельзя в транзакции
if _, err = s.adminDB.ExecContext(ctx, if _, err = s.adminDB.ExecContext(ctx,
fmt.Sprintf(`CREATE DATABASE %s OWNER %s`, dbName, userName), fmt.Sprintf(`CREATE DATABASE %s OWNER %s`, dbName, userName),