v0.1.59: IoT telemetry pipeline — Postgres storage + REST API + UI table
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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, "-", "_")
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user