v0.1.59: IoT telemetry pipeline — Postgres storage + REST API + UI table

This commit is contained in:
Naeel
2026-04-05 08:46:17 +03:00
parent d51e33d876
commit 2bdd753f4e
11 changed files with 630 additions and 21 deletions
+1
View File
@@ -68,6 +68,7 @@ event-dispatcher
# build artifacts
/sless
/iot-mqtt-bridge
examples/POSTGRES/stress_log*.txt
examples/VM/vm_key
examples/VM/vm_key.pub
+6 -1
View File
@@ -1,4 +1,5 @@
# Создано: 2026-04-04
# Изменено: 2026-04-05 (добавлен IOT_PG_DSN, версия v0.1.59)
# Deployment iot-mqtt-bridge — MQTT→RabbitMQ мост для IoT.
#
# Получает MQTT сообщения от EMQX (подписка на "+/telemetry/+")
@@ -44,7 +45,7 @@ spec:
- name: mqtt-bridge
# Тот же образ что и оператор — оба бинаря в одном слое (manager + iot-mqtt-bridge).
# При смене версии оператора — менять тег и здесь.
image: pearlharbor.registryk8s.services.ngcloud.ru/naeel/sless-operator:v0.1.53
image: pearlharbor.registryk8s.services.ngcloud.ru/naeel/sless-operator:v0.1.59
imagePullPolicy: Always
command: ["/iot-mqtt-bridge"]
env:
@@ -59,6 +60,10 @@ spec:
envFrom:
- secretRef:
name: iot-bridge-credentials
# IOT_PG_DSN — сохранение телеметрии в Postgres (опционально)
- secretRef:
name: iot-postgres-secret
optional: true
resources:
requests:
memory: "32Mi"
+66
View File
@@ -0,0 +1,66 @@
# Создано: 2026-04-05
# Postgres для IoT телеметрии — отдельный от sless postgres (тот для invocations логов).
# Deployment (не StatefulSet) — для dev/demo. В prod заменить на managed Postgres.
#
# Суперюзер iot_admin используется оператором для:
# - CREATE USER tenant_{ns} + CREATE DATABASE tenant_{ns}
# - CREATE TABLE iot_telemetry в tenant DB
# Клиенты НЕ имеют прямого доступа — только через REST API платформы.
---
apiVersion: v1
kind: Secret
metadata:
name: iot-postgres-secret
namespace: sless
stringData:
# Суперпользователь — для управления tenant databases
POSTGRES_USER: "iot_admin"
POSTGRES_PASSWORD: "iot-pg-super-2026"
POSTGRES_DB: "iot_platform"
# DSN для оператора и mqtt-bridge (superuser к management DB)
IOT_PG_DSN: "postgresql://iot_admin:iot-pg-super-2026@iot-postgres.sless.svc:5432/iot_platform?sslmode=disable"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: iot-postgres
namespace: sless
labels:
app: iot-postgres
spec:
replicas: 1
selector:
matchLabels:
app: iot-postgres
template:
metadata:
labels:
app: iot-postgres
spec:
containers:
- name: postgres
image: postgres:16-alpine
ports:
- containerPort: 5432
envFrom:
- secretRef:
name: iot-postgres-secret
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
---
apiVersion: v1
kind: Service
metadata:
name: iot-postgres
namespace: sless
spec:
selector:
app: iot-postgres
ports:
- port: 5432
targetPort: 5432
+7 -3
View File
@@ -1,4 +1,4 @@
# Изменено: 2026-03-21
# Изменено: 2026-04-05 (добавлен IOT_PG_DSN, версия v0.1.59)
# Деплой sless оператора в кластер.
# Состав:
# - ConfigMap: не-секретные env vars (S3_ENDPOINT, REGISTRY_HOST и т.д.)
@@ -74,8 +74,8 @@ spec:
containers:
- name: operator
# При обновлении версии оператора — менять тег здесь (не latest!)
# v0.1.50 — добавлены IoT controller, IoT REST API, MQTT auth endpoint
image: pearlharbor.registryk8s.services.ngcloud.ru/naeel/sless-operator:v0.1.58
# v0.1.59 — добавлено сохранение телеметрии в IoT Postgres (per-tenant DB)
image: pearlharbor.registryk8s.services.ngcloud.ru/naeel/sless-operator:v0.1.59
# Always — чтобы всегда тянуть по точному тегу (не кешировать старый)
imagePullPolicy: Always
ports:
@@ -90,6 +90,10 @@ spec:
name: sless-operator-config
- secretRef:
name: sless-operator-secret
# IOT_PG_DSN — опциональный ключ: если не задан, IoT Postgres отключён
- secretRef:
name: iot-postgres-secret
optional: true
readinessProbe:
httpGet:
path: /healthz
+5 -2
View File
@@ -1,4 +1,4 @@
// Изменено: 2026-03-11
// Изменено: 2026-04-05 (добавлено поле IoTPG для IoT телеметрии)
// Handler — общий контейнер зависимостей для всех REST handlers.
// Все handlers получают доступ к k8s, S3 и Postgres через эту структуру.
// Логирование через slog, маршрутизация через gorilla/mux.
@@ -28,6 +28,7 @@ import (
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/storage/iotpg"
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/storage/postgres"
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/storage/s3"
)
@@ -43,7 +44,9 @@ type Handler struct {
Scheme *runtime.Scheme
S3 *s3.Client
PG *postgres.Store
Log *slog.Logger
// IoTPG — хранилище IoT телеметрии (per-tenant Postgres). nil если IOT_PG_DSN не задан.
IoTPG *iotpg.IoTPostgresStore
Log *slog.Logger
}
// writeJSON отправляет JSON-ответ с указанным статусом.
@@ -0,0 +1,55 @@
// Создано: 2026-04-05
// iot_telemetry_handler.go — REST handler для чтения IoT телеметрии.
//
// Endpoint:
// GET /v1/namespaces/{namespace}/iot/telemetry?device={id}&limit={n}
//
// Авторизация: Bearer JWT → namespace validation (как все /v1/ маршруты).
// Данные берутся из per-tenant Postgres DB через IoTPostgresStore.
// Если IoTPG не инициализирован (IOT_PG_DSN не задан) — возвращает 503.
package handler
import (
"net/http"
"strconv"
"github.com/gorilla/mux"
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/storage/iotpg"
)
// ListIoTTelemetry обрабатывает GET /v1/namespaces/{namespace}/iot/telemetry.
// Параметры: device (опционально), limit (default 50, max 1000).
func (h *Handler) ListIoTTelemetry(w http.ResponseWriter, r *http.Request) {
if h.IoTPG == nil {
writeJSON(w, http.StatusServiceUnavailable, errResp("IoT telemetry storage not configured"))
return
}
ns := mux.Vars(r)["namespace"]
deviceID := r.URL.Query().Get("device")
limit := 50
if ls := r.URL.Query().Get("limit"); ls != "" {
if n, err := strconv.Atoi(ls); err == nil && n > 0 {
limit = n
}
}
rows, err := h.IoTPG.QueryTelemetry(r.Context(), ns, deviceID, limit)
if err != nil {
h.Log.Error("query IoT telemetry", "namespace", ns, "device", deviceID, "err", err)
writeJSON(w, http.StatusInternalServerError, errResp("failed to query telemetry"))
return
}
// Возвращаем пустой массив вместо null — удобнее для JS
if rows == nil {
rows = []iotpg.TelemetryRow{}
}
writeJSON(w, http.StatusOK, map[string]any{
"items": rows,
"count": len(rows),
})
}
+4 -1
View File
@@ -1,4 +1,4 @@
// Изменено: 2026-04-04 (tls: CORS origin обновлён с http → https для iot.kube5s.ru)
// Изменено: 2026-04-05 (добавлен route GET /iot/telemetry)
// router.go — регистрация всех REST-маршрутов через gorilla/mux.
// Все маршруты /v1/ защищены Bearer-токеном (middleware.Auth).
// /console — публичный маршрут (статический HTML без auth).
@@ -100,6 +100,9 @@ func NewRouter(h *handler.Handler, log *slog.Logger) http.Handler {
v1.HandleFunc("/namespaces/{namespace}/iot/devices/{name}", h.DeleteIoTDevice).Methods(http.MethodDelete)
v1.HandleFunc("/namespaces/{namespace}/iot/devices/{name}", h.UpdateIoTDevice).Methods(http.MethodPatch)
// IoT Telemetry — чтение сырых данных от устройств (Postgres per-tenant)
v1.HandleFunc("/namespaces/{namespace}/iot/telemetry", h.ListIoTTelemetry).Methods(http.MethodGet)
// MQTT Auth — БЕЗ JWT. Вызывается EMQX при MQTT CONNECT из кластера.
// /internal/ недоступен снаружи (Ingress не проксирует /internal/).
r.HandleFunc("/internal/mqtt/auth", h.MQTTAuth).Methods(http.MethodPost)
+145 -7
View File
@@ -267,6 +267,13 @@ const S = {
mqttConnecting: false,
mqttLog: [],
autoTimer: null,
// Телеметрия
telemetryRows: [], // загруженные строки [{id, device_id, ts, payload}]
telemetryLoading: false,
telemetryAutoRefresh: false,
telemetryRefreshTimer: null,
telemetryDeviceFilter: '', // '' = все устройства
};
// Сохраняем auth-параметры в localStorage
@@ -838,6 +845,12 @@ function switchTab(tab) {
// Останавливаем авто-отправку при уходе с вкладки эмулятора
mqttStopAuto();
}
if (tab !== 'telemetry') {
// Останавливаем авто-рефреш при уходе с вкладки телеметрии
clearInterval(S.telemetryRefreshTimer);
S.telemetryRefreshTimer = null;
S.telemetryAutoRefresh = false;
}
S.activeTab = tab;
// Обновляем active-класс на вкладках
@@ -849,6 +862,11 @@ function switchTab(tab) {
if (body && S.deviceDetail) {
body.innerHTML = tabContent(S.deviceDetail, tab);
}
// При переходе на телеметрию — сразу загружаем данные
if (tab === 'telemetry') {
loadTelemetry();
}
}
function tabContent(d, tab) {
@@ -989,6 +1007,10 @@ function emulatorTab(d) {
<div class="form-group">
<label>Payload (JSON)</label>
<textarea id="emu-payload" rows="4">{"temp": 22.5, "humidity": 60}</textarea>
<button class="btn btn-ghost btn-sm" style="margin-top:6px;"
onclick="document.getElementById('emu-payload').value=generateSensorPayload()">
🎲 Случайные данные (температура / влажность)
</button>
</div>
<div class="row" style="margin-bottom:16px;flex-wrap:wrap;">
<button class="btn btn-primary" onclick="emuSendOnce()">Отправить</button>
@@ -1026,6 +1048,14 @@ function emuSendOnce() {
mqttPublish(topic, payload.trim());
}
// generateSensorPayload — случайный payload: температура [18-28°C], влажность [40-80%]
// Используется кнопкой «Случайные данные» в эмуляторе
function generateSensorPayload() {
const temp = parseFloat((18 + Math.random() * 10).toFixed(1));
const hum = Math.round(40 + Math.random() * 40);
return JSON.stringify({ temperature: temp, humidity: hum, ts: new Date().toISOString() });
}
// toggleAuto — включить/выключить авто-отправку
function toggleAuto() {
const btn = document.getElementById('auto-btn');
@@ -1044,18 +1074,126 @@ function toggleAuto() {
// ВКЛАДКА — ТЕЛЕМЕТРИЯ
// =============================================================
function telemetryTab() {
const deviceOptions = S.devices.map(d =>
`<option value="${h(d.device_id)}" ${S.telemetryDeviceFilter === d.device_id ? 'selected' : ''}>${h(d.name)}</option>`
).join('');
const rows = S.telemetryRows.length === 0
? `<tr><td colspan="3" style="text-align:center;color:#888;padding:24px;">Нет данных — нажмите «↻ Обновить»</td></tr>`
: S.telemetryRows.map(r => {
const ts = r.ts ? new Date(r.ts).toLocaleString('ru') : '—';
const dev = h(r.device_id || '—');
let payloadStr = '';
try {
payloadStr = typeof r.payload === 'object'
? JSON.stringify(r.payload)
: String(r.payload);
} catch (_) { payloadStr = String(r.payload); }
return `<tr>
<td style="padding:6px 12px;white-space:nowrap;">${h(ts)}</td>
<td style="padding:6px 12px;">${dev}</td>
<td style="padding:6px 12px;font-family:monospace;font-size:12px;word-break:break-all;">${h(payloadStr)}</td>
</tr>`;
}).join('');
return `<div class="card">
<div class="coming-soon">
<div class="icon">📊</div>
<h3>Телеметрия</h3>
<p class="hint" style="margin-top:8px;">Скоро — хранение данных и API чтения</p>
<p class="hint" style="margin-top:4px;color:#334155;">
В разработке — появится в следующем обновлении
</p>
<div class="card-title">Телеметрия</div>
<!-- Панель управления: фильтр, обновление, авто-рефреш -->
<div class="row" style="margin-bottom:16px;flex-wrap:wrap;gap:12px;align-items:center;">
<div style="display:flex;align-items:center;gap:8px;">
<label class="hint" for="telem-dev-filter">Устройство:</label>
<select id="telem-dev-filter" onchange="S.telemetryDeviceFilter=this.value;loadTelemetry()">
<option value="">Все</option>
${deviceOptions}
</select>
</div>
<button class="btn btn-ghost btn-sm" onclick="loadTelemetry()">↻ Обновить</button>
<label style="display:flex;align-items:center;gap:6px;font-size:13px;cursor:pointer;">
<input type="checkbox" id="telem-auto" ${S.telemetryAutoRefresh ? 'checked' : ''}
onchange="toggleTelemetryAutoRefresh(this.checked)">
Авто (5 с)
</label>
<span id="telem-status" class="hint"></span>
</div>
<!-- Таблица телеметрии -->
<div style="overflow-x:auto;">
<table style="width:100%;border-collapse:collapse;font-size:13px;">
<thead>
<tr style="background:#f0f4f8;text-align:left;">
<th style="padding:8px 12px;white-space:nowrap;">Время</th>
<th style="padding:8px 12px;white-space:nowrap;">Устройство</th>
<th style="padding:8px 12px;">Данные</th>
</tr>
</thead>
<tbody id="telem-tbody">${rows}</tbody>
</table>
</div>
</div>`;
}
// loadTelemetry — загружает историю телеметрии с API.
// URL: GET /v1/namespaces/{ns}/iot/telemetry?limit=100[&device=...]
async function loadTelemetry() {
if (!S.ns || !S.token) return;
const status = document.getElementById('telem-status');
if (status) status.textContent = 'Загрузка...';
// Читаем текущий фильтр из DOM (если вкладка открыта) или из state
const sel = document.getElementById('telem-dev-filter');
if (sel) S.telemetryDeviceFilter = sel.value;
try {
let url = `${S.apiBase}/v1/namespaces/${encodeURIComponent(S.ns)}/iot/telemetry?limit=100`;
if (S.telemetryDeviceFilter) url += `&device=${encodeURIComponent(S.telemetryDeviceFilter)}`;
const resp = await fetch(url, { headers: { Authorization: 'Bearer ' + S.token } });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
S.telemetryRows = data.items || [];
// Обновляем только tbody — не перерисовываем весь таб (сохраняем фильтр/чекбокс)
const tbody = document.getElementById('telem-tbody');
if (tbody) {
if (S.telemetryRows.length === 0) {
tbody.innerHTML = '<tr><td colspan="3" style="text-align:center;color:#888;padding:24px;">Нет данных</td></tr>';
} else {
tbody.innerHTML = S.telemetryRows.map(r => {
const ts = r.ts ? new Date(r.ts).toLocaleString('ru') : '—';
const dev = h(r.device_id || '—');
let payloadStr = '';
try {
payloadStr = typeof r.payload === 'object'
? JSON.stringify(r.payload)
: String(r.payload);
} catch (_) { payloadStr = String(r.payload); }
return `<tr>
<td style="padding:6px 12px;white-space:nowrap;">${h(ts)}</td>
<td style="padding:6px 12px;">${dev}</td>
<td style="padding:6px 12px;font-family:monospace;font-size:12px;word-break:break-all;">${h(payloadStr)}</td>
</tr>`;
}).join('');
}
}
if (status) status.textContent = `Обновлено в ${new Date().toLocaleTimeString('ru')}`;
} catch(e) {
if (status) status.textContent = 'Ошибка: ' + e.message;
}
}
// toggleTelemetryAutoRefresh — включить/выключить авто-обновление каждые 5 секунд
function toggleTelemetryAutoRefresh(enabled) {
S.telemetryAutoRefresh = enabled;
clearInterval(S.telemetryRefreshTimer);
S.telemetryRefreshTimer = null;
if (enabled) {
// Немедленная загрузка + интервал
loadTelemetry();
S.telemetryRefreshTimer = setInterval(loadTelemetry, 5000);
}
}
// =============================================================
// NAVBAR
// =============================================================
@@ -0,0 +1,288 @@
// Создано: 2026-04-05
// iot_telemetry_store.go — управление per-tenant PostgreSQL databases для IoT телеметрии.
//
// Архитектура (принято 2026-04-04, см. doc/decisions/iot-telemetry-storage-2026-04-04.md):
// - Один Postgres инстанс (iot-postgres.sless.svc) — отдельный от sless postgres
// - Отдельная DATABASE per tenant: tenant_{namespace} (дефисы → подчёркивания)
// - Suперюзер iot_admin управляет всеми DBs; клиенты читают только через REST API
// - Пароли tenant хранятся в таблице tenant_credentials в management DB iot_platform
//
// Почему tenant_credentials в БД, а не в k8s Secret:
// mqtt-bridge вызывает InsertTelemetry в горячем пути MQTT.
// k8s API round-trip на каждое сообщение — неприемлемо.
//
// Подключение к tenant DB: суперюзер iot_admin, DSN строится заменой db name в adminDSN.
// Кэширование: sync.Map для *sql.DB per tenant (lazy init при первом обращении).
package iotpg
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"os"
"strings"
"sync"
"time"
"github.com/google/uuid"
_ "github.com/lib/pq"
)
// IoTPostgresStore управляет per-tenant Postgres databases для IoT телеметрии.
type IoTPostgresStore struct {
adminDB *sql.DB
adminDSN string
tenants sync.Map
log *slog.Logger
}
// TelemetryRow — одна запись телеметрии из таблицы iot_telemetry.
type TelemetryRow struct {
ID int64 `json:"id"`
DeviceID string `json:"device_id"`
Ts time.Time `json:"ts"`
Payload json.RawMessage `json:"payload"`
}
// New подключается к management DB (iot_platform) и создаёт служебные таблицы.
func New(adminDSN string, log *slog.Logger) (*IoTPostgresStore, error) {
db, err := sql.Open("postgres", adminDSN)
if err != nil {
return nil, fmt.Errorf("iotpg: open admin DB: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
db.Close()
return nil, fmt.Errorf("iotpg: ping admin DB: %w", err)
}
db.SetMaxOpenConns(5)
db.SetMaxIdleConns(2)
db.SetConnMaxLifetime(5 * time.Minute)
store := &IoTPostgresStore{adminDB: db, adminDSN: adminDSN, log: log}
if err := store.initManagementSchema(ctx); err != nil {
db.Close()
return nil, fmt.Errorf("iotpg: init management schema: %w", err)
}
log.Info("iotpg: connected to IoT Postgres management DB")
return store, nil
}
// NewFromEnv создаёт store из env var IOT_PG_DSN.
// Возвращает (nil, nil) если переменная не задана — IoT Postgres опционален.
func NewFromEnv(log *slog.Logger) (*IoTPostgresStore, error) {
dsn := os.Getenv("IOT_PG_DSN")
if dsn == "" {
log.Info("iotpg: IOT_PG_DSN not set, IoT telemetry disabled")
return nil, nil
}
return New(dsn, log)
}
// initManagementSchema создаёт таблицу tenant_credentials в iot_platform.
func (s *IoTPostgresStore) initManagementSchema(ctx context.Context) error {
_, err := s.adminDB.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS tenant_credentials (
namespace TEXT PRIMARY KEY,
pg_password TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
)
`)
return err
}
// EnsureTenantDB создаёт DATABASE, USER и таблицу iot_telemetry для namespace.
// Идемпотентен — повторный вызов безопасен.
// Вызывается mqtt-bridge при первом сообщении от нового tenant.
func (s *IoTPostgresStore) EnsureTenantDB(ctx context.Context, namespace string) error {
dbName := tenantDBName(namespace)
userName := dbName
var exists bool
err := s.adminDB.QueryRowContext(ctx,
`SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)`, dbName,
).Scan(&exists)
if err != nil {
return fmt.Errorf("iotpg: check tenant DB %s: %w", dbName, err)
}
if !exists {
password := uuid.New().String()
// CREATE USER через DO block — pg не поддерживает CREATE USER IF NOT EXISTS
_, err = s.adminDB.ExecContext(ctx, fmt.Sprintf(
`DO $$ BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '%s') THEN
CREATE USER %s WITH PASSWORD '%s';
END IF;
END $$`, userName, userName, password,
))
if err != nil {
return fmt.Errorf("iotpg: create user %s: %w", userName, err)
}
// CREATE DATABASE нельзя в транзакции
if _, err = s.adminDB.ExecContext(ctx,
fmt.Sprintf(`CREATE DATABASE %s OWNER %s`, dbName, userName),
); err != nil {
return fmt.Errorf("iotpg: create database %s: %w", dbName, err)
}
if _, err = s.adminDB.ExecContext(ctx,
`INSERT INTO tenant_credentials (namespace, pg_password) VALUES ($1, $2)
ON CONFLICT (namespace) DO NOTHING`,
namespace, password,
); err != nil {
return fmt.Errorf("iotpg: save credentials %s: %w", namespace, err)
}
s.log.Info("iotpg: created tenant DB", "namespace", namespace, "db", dbName)
}
// Создаём таблицу в tenant DB (суперюзер имеет доступ)
tenantDB, err := s.getTenantDB(ctx, namespace)
if err != nil {
return err
}
_, err = tenantDB.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS iot_telemetry (
id BIGSERIAL PRIMARY KEY,
device_id TEXT NOT NULL,
ts TIMESTAMPTZ NOT NULL DEFAULT now(),
payload JSONB NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_iot_telemetry_device_ts
ON iot_telemetry (device_id, ts DESC);
`)
return err
}
// InsertTelemetry записывает строку телеметрии в tenant DB.
func (s *IoTPostgresStore) InsertTelemetry(ctx context.Context, namespace, deviceID string, payload json.RawMessage) error {
tenantDB, err := s.getTenantDB(ctx, namespace)
if err != nil {
return fmt.Errorf("iotpg: get tenant DB for insert: %w", err)
}
_, err = tenantDB.ExecContext(ctx,
`INSERT INTO iot_telemetry (device_id, payload) VALUES ($1, $2)`,
deviceID, []byte(payload),
)
return err
}
// QueryTelemetry читает телеметрию из tenant DB (ts DESC).
// deviceID — фильтр (пустая строка = все устройства). limit — max записей (50..1000).
func (s *IoTPostgresStore) QueryTelemetry(ctx context.Context, namespace, deviceID string, limit int) ([]TelemetryRow, error) {
if limit <= 0 {
limit = 50
}
if limit > 1000 {
limit = 1000
}
tenantDB, err := s.getTenantDB(ctx, namespace)
if err != nil {
return nil, fmt.Errorf("iotpg: get tenant DB for query: %w", err)
}
var rows *sql.Rows
if deviceID != "" {
rows, err = tenantDB.QueryContext(ctx,
`SELECT id, device_id, ts, payload FROM iot_telemetry
WHERE device_id = $1 ORDER BY ts DESC LIMIT $2`,
deviceID, limit,
)
} else {
rows, err = tenantDB.QueryContext(ctx,
`SELECT id, device_id, ts, payload FROM iot_telemetry
ORDER BY ts DESC LIMIT $1`,
limit,
)
}
if err != nil {
return nil, fmt.Errorf("iotpg: query telemetry for %s: %w", namespace, err)
}
defer rows.Close()
var result []TelemetryRow
for rows.Next() {
var r TelemetryRow
var rawPayload []byte
if err := rows.Scan(&r.ID, &r.DeviceID, &r.Ts, &rawPayload); err != nil {
return nil, fmt.Errorf("iotpg: scan row: %w", err)
}
r.Payload = json.RawMessage(rawPayload)
result = append(result, r)
}
return result, rows.Err()
}
// Close закрывает все подключения (admin + tenant кэш).
func (s *IoTPostgresStore) Close() error {
s.tenants.Range(func(_, value any) bool {
if db, ok := value.(*sql.DB); ok {
db.Close()
}
return true
})
return s.adminDB.Close()
}
// getTenantDB возвращает *sql.DB для tenant DB из кэша или открывает новый.
func (s *IoTPostgresStore) getTenantDB(ctx context.Context, namespace string) (*sql.DB, error) {
if cached, ok := s.tenants.Load(namespace); ok {
return cached.(*sql.DB), nil
}
dsn := replaceDSNDatabase(s.adminDSN, tenantDBName(namespace))
db, err := sql.Open("postgres", dsn)
if err != nil {
return nil, fmt.Errorf("iotpg: open tenant DB %s: %w", tenantDBName(namespace), err)
}
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := db.PingContext(pingCtx); err != nil {
db.Close()
return nil, fmt.Errorf("iotpg: ping tenant DB %s: %w", tenantDBName(namespace), err)
}
db.SetMaxOpenConns(5)
db.SetMaxIdleConns(2)
db.SetConnMaxLifetime(5 * time.Minute)
actual, loaded := s.tenants.LoadOrStore(namespace, db)
if loaded {
db.Close()
return actual.(*sql.DB), nil
}
return db, nil
}
// replaceDSNDatabase заменяет имя базы данных в DSN.
// Вход: "postgresql://user:pass@host:5432/iot_platform?sslmode=disable"
// Выход: "postgresql://user:pass@host:5432/tenant_abc?sslmode=disable"
func replaceDSNDatabase(dsn, newDBName string) string {
schemeEnd := strings.Index(dsn, "://")
if schemeEnd < 0 {
return dsn
}
hostPart := dsn[schemeEnd+3:]
slashIdx := strings.LastIndex(hostPart, "/")
if slashIdx < 0 {
return dsn
}
afterSlash := hostPart[slashIdx+1:]
suffix := ""
if qIdx := strings.Index(afterSlash, "?"); qIdx >= 0 {
suffix = afterSlash[qIdx:]
}
prefix := dsn[:schemeEnd+3+slashIdx+1]
return prefix + newDBName + suffix
}
// tenantDBName возвращает имя Postgres DATABASE для namespace.
// Дефисы заменяются на подчёркивания (pg не поддерживает дефисы в unquoted именах).
// Пример: "sless-abc123" → "tenant_sless_abc123"
func tenantDBName(namespace string) string {
return "tenant_" + strings.ReplaceAll(namespace, "-", "_")
}
+39 -6
View File
@@ -1,4 +1,5 @@
// Создано: 2026-04-04
// Изменено: 2026-04-05 (добавлен INSERT в IoT Postgres)
// mqtt-bridge/main.go — сервис-мост: MQTT (EMQX) → RabbitMQ.
//
// Роль в архитектуре:
@@ -39,6 +40,8 @@ import (
mqtt "github.com/eclipse/paho.mqtt.golang"
amqp "github.com/rabbitmq/amqp091-go"
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/storage/iotpg"
)
// mqttBridgeConfig — конфигурация сервиса из env vars.
@@ -92,6 +95,18 @@ func main() {
}
defer rabbitCh.Close()
// IoT Postgres — сохранение телеметрии (per-tenant DB).
// Опционально: если IOT_PG_DSN не задан — продолжаем работать без Postgres (только RabbitMQ)
iotPGStore, err := iotpg.NewFromEnv(log)
if err != nil {
log.Error("failed to connect to IoT Postgres", "err", err)
os.Exit(1)
}
if iotPGStore != nil {
defer iotPGStore.Close()
log.Info("connected to IoT Postgres for telemetry storage")
}
// Создаём MQTT клиент
mqttClient, err := connectMQTT(cfg, log)
if err != nil {
@@ -102,7 +117,7 @@ func main() {
// Функция-обработчик MQTT сообщений
// Вызывается в goroutine paho при каждом сообщении
messageHandler := buildMQTTMessageHandler(rabbitCh, log)
messageHandler := buildMQTTMessageHandler(ctx, rabbitCh, iotPGStore, log)
// Подписываемся на все telemetry топики всех namespace
// "+/telemetry/+" = {любой namespace}/telemetry/{любой deviceId}
@@ -203,8 +218,13 @@ func connectRabbitMQWithRetry(ctx context.Context, url string, log *slog.Logger)
}
// buildMQTTMessageHandler возвращает функцию-обработчик MQTT сообщений.
// Замыкание над rabbitCh (RabbitMQ channel) и logger.
func buildMQTTMessageHandler(rabbitCh *amqp.Channel, log *slog.Logger) mqtt.MessageHandler {
// Замыкание над rabbitCh (RabbitMQ channel), iotStore (может быть nil) и logger.
// Порядок действий при получении сообщения:
// 1. INSERT в IoT Postgres (tenant DB) — если iotStore != nil
// 2. Publish в RabbitMQ — всегда (для event-dispatcher → function triggers)
//
// Ошибка INSERT не блокирует RabbitMQ publish — разные failure domain.
func buildMQTTMessageHandler(ctx context.Context, rabbitCh *amqp.Channel, iotStore *iotpg.IoTPostgresStore, log *slog.Logger) mqtt.MessageHandler {
return func(_ mqtt.Client, msg mqtt.Message) {
topic := msg.Topic()
payload := msg.Payload()
@@ -219,15 +239,28 @@ func buildMQTTMessageHandler(rabbitCh *amqp.Channel, log *slog.Logger) mqtt.Mess
ns := parts[0]
deviceID := parts[2]
// Формируем envelope — оборачиваем payload в JSON с метаданными
// Payload от устройства может быть любым JSON или строкой
// Нормализуем payload: если это не JSON — оборачиваем в строку
rawPayload := json.RawMessage(payload)
if !json.Valid(payload) {
// Если payload не JSON — упаковываем в строку
quotedBytes, _ := json.Marshal(string(payload))
rawPayload = json.RawMessage(quotedBytes)
}
// ШАГ 1: INSERT в IoT Postgres — сохраняем телеметрию в per-tenant DB
// EnsureTenantDB идемпотентен: кэшируется после первого вызова
if iotStore != nil {
if err := iotStore.EnsureTenantDB(ctx, ns); err != nil {
log.Error("ensure tenant DB", "namespace", ns, "err", err)
// НЕ возвращаемся — продолжаем RabbitMQ publish
} else if err := iotStore.InsertTelemetry(ctx, ns, deviceID, rawPayload); err != nil {
log.Error("insert telemetry", "topic", topic, "err", err)
// НЕ возвращаемся — RabbitMQ не должен зависеть от Postgres
} else {
log.Debug("telemetry saved to Postgres", "namespace", ns, "device", deviceID)
}
}
// ШАГ 2: Publish в RabbitMQ (для event-dispatcher → function triggers)
envelope := iotTelemetryMessage{
Namespace: ns,
DeviceID: deviceID,
+14 -1
View File
@@ -1,4 +1,4 @@
// Изменено: 2026-03-20 (function-service-split: добавлена регистрация ServiceReconciler)
// Изменено: 2026-04-05 (добавлена инициализация IoT Postgres для телеметрии)
// main.go — точка входа. Запускает operator manager и REST API сервер параллельно.
// Operator manager управляет Function/Trigger CRD через reconcile loop.
// REST API (gorilla/mux) принимает запросы от Terraform provider.
@@ -32,6 +32,7 @@ import (
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/builder"
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/config"
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/harbor"
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/storage/iotpg"
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/storage/postgres"
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/storage/s3"
iotv1alpha1 "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/iot/api/v1alpha1"
@@ -213,12 +214,24 @@ func main() {
os.Exit(1)
}
// IoT Postgres — подключение к per-tenant storage для телеметрии
// Опционально: если IOT_PG_DSN не задан — телеметрия недоступна (503), остальное работает
iotPGStore, err := iotpg.NewFromEnv(log)
if err != nil {
log.Error("connect IoT Postgres", "err", err)
os.Exit(1)
}
if iotPGStore != nil {
defer iotPGStore.Close()
}
// REST API сервер — запускается параллельно с operator manager
apiHandler := slessapi.NewRouter(&handler.Handler{
K8s: mgr.GetClient(),
Scheme: mgr.GetScheme(),
S3: s3Client,
PG: pg,
IoTPG: iotPGStore,
Log: log,
}, log)