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
|
# build artifacts
|
||||||
/sless
|
/sless
|
||||||
|
/iot-mqtt-bridge
|
||||||
examples/POSTGRES/stress_log*.txt
|
examples/POSTGRES/stress_log*.txt
|
||||||
examples/VM/vm_key
|
examples/VM/vm_key
|
||||||
examples/VM/vm_key.pub
|
examples/VM/vm_key.pub
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
# Создано: 2026-04-04
|
# Создано: 2026-04-04
|
||||||
|
# Изменено: 2026-04-05 (добавлен IOT_PG_DSN, версия v0.1.59)
|
||||||
# Deployment iot-mqtt-bridge — MQTT→RabbitMQ мост для IoT.
|
# Deployment iot-mqtt-bridge — MQTT→RabbitMQ мост для IoT.
|
||||||
#
|
#
|
||||||
# Получает MQTT сообщения от EMQX (подписка на "+/telemetry/+")
|
# Получает MQTT сообщения от EMQX (подписка на "+/telemetry/+")
|
||||||
@@ -44,7 +45,7 @@ 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.53
|
image: pearlharbor.registryk8s.services.ngcloud.ru/naeel/sless-operator:v0.1.59
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
command: ["/iot-mqtt-bridge"]
|
command: ["/iot-mqtt-bridge"]
|
||||||
env:
|
env:
|
||||||
@@ -59,6 +60,10 @@ spec:
|
|||||||
envFrom:
|
envFrom:
|
||||||
- secretRef:
|
- secretRef:
|
||||||
name: iot-bridge-credentials
|
name: iot-bridge-credentials
|
||||||
|
# IOT_PG_DSN — сохранение телеметрии в Postgres (опционально)
|
||||||
|
- secretRef:
|
||||||
|
name: iot-postgres-secret
|
||||||
|
optional: true
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
memory: "32Mi"
|
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 оператора в кластер.
|
# Деплой sless оператора в кластер.
|
||||||
# Состав:
|
# Состав:
|
||||||
# - ConfigMap: не-секретные env vars (S3_ENDPOINT, REGISTRY_HOST и т.д.)
|
# - ConfigMap: не-секретные env vars (S3_ENDPOINT, REGISTRY_HOST и т.д.)
|
||||||
@@ -74,8 +74,8 @@ spec:
|
|||||||
containers:
|
containers:
|
||||||
- name: operator
|
- name: operator
|
||||||
# При обновлении версии оператора — менять тег здесь (не latest!)
|
# При обновлении версии оператора — менять тег здесь (не latest!)
|
||||||
# v0.1.50 — добавлены IoT controller, IoT REST API, MQTT auth endpoint
|
# v0.1.59 — добавлено сохранение телеметрии в IoT Postgres (per-tenant DB)
|
||||||
image: pearlharbor.registryk8s.services.ngcloud.ru/naeel/sless-operator:v0.1.58
|
image: pearlharbor.registryk8s.services.ngcloud.ru/naeel/sless-operator:v0.1.59
|
||||||
# Always — чтобы всегда тянуть по точному тегу (не кешировать старый)
|
# Always — чтобы всегда тянуть по точному тегу (не кешировать старый)
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
ports:
|
ports:
|
||||||
@@ -90,6 +90,10 @@ spec:
|
|||||||
name: sless-operator-config
|
name: sless-operator-config
|
||||||
- secretRef:
|
- secretRef:
|
||||||
name: sless-operator-secret
|
name: sless-operator-secret
|
||||||
|
# IOT_PG_DSN — опциональный ключ: если не задан, IoT Postgres отключён
|
||||||
|
- secretRef:
|
||||||
|
name: iot-postgres-secret
|
||||||
|
optional: true
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /healthz
|
path: /healthz
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Изменено: 2026-03-11
|
// Изменено: 2026-04-05 (добавлено поле IoTPG для IoT телеметрии)
|
||||||
// Handler — общий контейнер зависимостей для всех REST handlers.
|
// Handler — общий контейнер зависимостей для всех REST handlers.
|
||||||
// Все handlers получают доступ к k8s, S3 и Postgres через эту структуру.
|
// Все handlers получают доступ к k8s, S3 и Postgres через эту структуру.
|
||||||
// Логирование через slog, маршрутизация через gorilla/mux.
|
// Логирование через slog, маршрутизация через gorilla/mux.
|
||||||
@@ -28,6 +28,7 @@ import (
|
|||||||
"k8s.io/apimachinery/pkg/runtime"
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
|
|
||||||
|
"gitea-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/postgres"
|
||||||
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/storage/s3"
|
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/storage/s3"
|
||||||
)
|
)
|
||||||
@@ -43,7 +44,9 @@ type Handler struct {
|
|||||||
Scheme *runtime.Scheme
|
Scheme *runtime.Scheme
|
||||||
S3 *s3.Client
|
S3 *s3.Client
|
||||||
PG *postgres.Store
|
PG *postgres.Store
|
||||||
Log *slog.Logger
|
// IoTPG — хранилище IoT телеметрии (per-tenant Postgres). nil если IOT_PG_DSN не задан.
|
||||||
|
IoTPG *iotpg.IoTPostgresStore
|
||||||
|
Log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeJSON отправляет JSON-ответ с указанным статусом.
|
// 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.
|
// router.go — регистрация всех REST-маршрутов через gorilla/mux.
|
||||||
// Все маршруты /v1/ защищены Bearer-токеном (middleware.Auth).
|
// Все маршруты /v1/ защищены Bearer-токеном (middleware.Auth).
|
||||||
// /console — публичный маршрут (статический HTML без 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.DeleteIoTDevice).Methods(http.MethodDelete)
|
||||||
v1.HandleFunc("/namespaces/{namespace}/iot/devices/{name}", h.UpdateIoTDevice).Methods(http.MethodPatch)
|
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 из кластера.
|
// MQTT Auth — БЕЗ JWT. Вызывается EMQX при MQTT CONNECT из кластера.
|
||||||
// /internal/ недоступен снаружи (Ingress не проксирует /internal/).
|
// /internal/ недоступен снаружи (Ingress не проксирует /internal/).
|
||||||
r.HandleFunc("/internal/mqtt/auth", h.MQTTAuth).Methods(http.MethodPost)
|
r.HandleFunc("/internal/mqtt/auth", h.MQTTAuth).Methods(http.MethodPost)
|
||||||
|
|||||||
@@ -267,6 +267,13 @@ const S = {
|
|||||||
mqttConnecting: false,
|
mqttConnecting: false,
|
||||||
mqttLog: [],
|
mqttLog: [],
|
||||||
autoTimer: null,
|
autoTimer: null,
|
||||||
|
|
||||||
|
// Телеметрия
|
||||||
|
telemetryRows: [], // загруженные строки [{id, device_id, ts, payload}]
|
||||||
|
telemetryLoading: false,
|
||||||
|
telemetryAutoRefresh: false,
|
||||||
|
telemetryRefreshTimer: null,
|
||||||
|
telemetryDeviceFilter: '', // '' = все устройства
|
||||||
};
|
};
|
||||||
|
|
||||||
// Сохраняем auth-параметры в localStorage
|
// Сохраняем auth-параметры в localStorage
|
||||||
@@ -838,6 +845,12 @@ function switchTab(tab) {
|
|||||||
// Останавливаем авто-отправку при уходе с вкладки эмулятора
|
// Останавливаем авто-отправку при уходе с вкладки эмулятора
|
||||||
mqttStopAuto();
|
mqttStopAuto();
|
||||||
}
|
}
|
||||||
|
if (tab !== 'telemetry') {
|
||||||
|
// Останавливаем авто-рефреш при уходе с вкладки телеметрии
|
||||||
|
clearInterval(S.telemetryRefreshTimer);
|
||||||
|
S.telemetryRefreshTimer = null;
|
||||||
|
S.telemetryAutoRefresh = false;
|
||||||
|
}
|
||||||
S.activeTab = tab;
|
S.activeTab = tab;
|
||||||
|
|
||||||
// Обновляем active-класс на вкладках
|
// Обновляем active-класс на вкладках
|
||||||
@@ -849,6 +862,11 @@ function switchTab(tab) {
|
|||||||
if (body && S.deviceDetail) {
|
if (body && S.deviceDetail) {
|
||||||
body.innerHTML = tabContent(S.deviceDetail, tab);
|
body.innerHTML = tabContent(S.deviceDetail, tab);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// При переходе на телеметрию — сразу загружаем данные
|
||||||
|
if (tab === 'telemetry') {
|
||||||
|
loadTelemetry();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function tabContent(d, tab) {
|
function tabContent(d, tab) {
|
||||||
@@ -989,6 +1007,10 @@ function emulatorTab(d) {
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Payload (JSON)</label>
|
<label>Payload (JSON)</label>
|
||||||
<textarea id="emu-payload" rows="4">{"temp": 22.5, "humidity": 60}</textarea>
|
<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>
|
||||||
<div class="row" style="margin-bottom:16px;flex-wrap:wrap;">
|
<div class="row" style="margin-bottom:16px;flex-wrap:wrap;">
|
||||||
<button class="btn btn-primary" onclick="emuSendOnce()">Отправить</button>
|
<button class="btn btn-primary" onclick="emuSendOnce()">Отправить</button>
|
||||||
@@ -1026,6 +1048,14 @@ function emuSendOnce() {
|
|||||||
mqttPublish(topic, payload.trim());
|
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 — включить/выключить авто-отправку
|
// toggleAuto — включить/выключить авто-отправку
|
||||||
function toggleAuto() {
|
function toggleAuto() {
|
||||||
const btn = document.getElementById('auto-btn');
|
const btn = document.getElementById('auto-btn');
|
||||||
@@ -1044,18 +1074,126 @@ function toggleAuto() {
|
|||||||
// ВКЛАДКА — ТЕЛЕМЕТРИЯ
|
// ВКЛАДКА — ТЕЛЕМЕТРИЯ
|
||||||
// =============================================================
|
// =============================================================
|
||||||
function telemetryTab() {
|
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">
|
return `<div class="card">
|
||||||
<div class="coming-soon">
|
<div class="card-title">Телеметрия</div>
|
||||||
<div class="icon">📊</div>
|
|
||||||
<h3>Телеметрия</h3>
|
<!-- Панель управления: фильтр, обновление, авто-рефреш -->
|
||||||
<p class="hint" style="margin-top:8px;">Скоро — хранение данных и API чтения</p>
|
<div class="row" style="margin-bottom:16px;flex-wrap:wrap;gap:12px;align-items:center;">
|
||||||
<p class="hint" style="margin-top:4px;color:#334155;">
|
<div style="display:flex;align-items:center;gap:8px;">
|
||||||
В разработке — появится в следующем обновлении
|
<label class="hint" for="telem-dev-filter">Устройство:</label>
|
||||||
</p>
|
<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>
|
||||||
</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
|
// 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-04
|
||||||
|
// Изменено: 2026-04-05 (добавлен INSERT в IoT Postgres)
|
||||||
// mqtt-bridge/main.go — сервис-мост: MQTT (EMQX) → RabbitMQ.
|
// mqtt-bridge/main.go — сервис-мост: MQTT (EMQX) → RabbitMQ.
|
||||||
//
|
//
|
||||||
// Роль в архитектуре:
|
// Роль в архитектуре:
|
||||||
@@ -39,6 +40,8 @@ import (
|
|||||||
|
|
||||||
mqtt "github.com/eclipse/paho.mqtt.golang"
|
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||||
amqp "github.com/rabbitmq/amqp091-go"
|
amqp "github.com/rabbitmq/amqp091-go"
|
||||||
|
|
||||||
|
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/storage/iotpg"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mqttBridgeConfig — конфигурация сервиса из env vars.
|
// mqttBridgeConfig — конфигурация сервиса из env vars.
|
||||||
@@ -92,6 +95,18 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer rabbitCh.Close()
|
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 клиент
|
// Создаём MQTT клиент
|
||||||
mqttClient, err := connectMQTT(cfg, log)
|
mqttClient, err := connectMQTT(cfg, log)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -102,7 +117,7 @@ func main() {
|
|||||||
|
|
||||||
// Функция-обработчик MQTT сообщений
|
// Функция-обработчик MQTT сообщений
|
||||||
// Вызывается в goroutine paho при каждом сообщении
|
// Вызывается в goroutine paho при каждом сообщении
|
||||||
messageHandler := buildMQTTMessageHandler(rabbitCh, log)
|
messageHandler := buildMQTTMessageHandler(ctx, rabbitCh, iotPGStore, log)
|
||||||
|
|
||||||
// Подписываемся на все telemetry топики всех namespace
|
// Подписываемся на все telemetry топики всех namespace
|
||||||
// "+/telemetry/+" = {любой namespace}/telemetry/{любой deviceId}
|
// "+/telemetry/+" = {любой namespace}/telemetry/{любой deviceId}
|
||||||
@@ -203,8 +218,13 @@ func connectRabbitMQWithRetry(ctx context.Context, url string, log *slog.Logger)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildMQTTMessageHandler возвращает функцию-обработчик MQTT сообщений.
|
// buildMQTTMessageHandler возвращает функцию-обработчик MQTT сообщений.
|
||||||
// Замыкание над rabbitCh (RabbitMQ channel) и logger.
|
// Замыкание над rabbitCh (RabbitMQ channel), iotStore (может быть nil) и logger.
|
||||||
func buildMQTTMessageHandler(rabbitCh *amqp.Channel, log *slog.Logger) mqtt.MessageHandler {
|
// Порядок действий при получении сообщения:
|
||||||
|
// 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) {
|
return func(_ mqtt.Client, msg mqtt.Message) {
|
||||||
topic := msg.Topic()
|
topic := msg.Topic()
|
||||||
payload := msg.Payload()
|
payload := msg.Payload()
|
||||||
@@ -219,15 +239,28 @@ func buildMQTTMessageHandler(rabbitCh *amqp.Channel, log *slog.Logger) mqtt.Mess
|
|||||||
ns := parts[0]
|
ns := parts[0]
|
||||||
deviceID := parts[2]
|
deviceID := parts[2]
|
||||||
|
|
||||||
// Формируем envelope — оборачиваем payload в JSON с метаданными
|
// Нормализуем payload: если это не JSON — оборачиваем в строку
|
||||||
// Payload от устройства может быть любым JSON или строкой
|
|
||||||
rawPayload := json.RawMessage(payload)
|
rawPayload := json.RawMessage(payload)
|
||||||
if !json.Valid(payload) {
|
if !json.Valid(payload) {
|
||||||
// Если payload не JSON — упаковываем в строку
|
|
||||||
quotedBytes, _ := json.Marshal(string(payload))
|
quotedBytes, _ := json.Marshal(string(payload))
|
||||||
rawPayload = json.RawMessage(quotedBytes)
|
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{
|
envelope := iotTelemetryMessage{
|
||||||
Namespace: ns,
|
Namespace: ns,
|
||||||
DeviceID: deviceID,
|
DeviceID: deviceID,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Изменено: 2026-03-20 (function-service-split: добавлена регистрация ServiceReconciler)
|
// Изменено: 2026-04-05 (добавлена инициализация IoT Postgres для телеметрии)
|
||||||
// main.go — точка входа. Запускает operator manager и REST API сервер параллельно.
|
// main.go — точка входа. Запускает operator manager и REST API сервер параллельно.
|
||||||
// Operator manager управляет Function/Trigger CRD через reconcile loop.
|
// Operator manager управляет Function/Trigger CRD через reconcile loop.
|
||||||
// REST API (gorilla/mux) принимает запросы от Terraform provider.
|
// 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/builder"
|
||||||
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/config"
|
"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/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/postgres"
|
||||||
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/storage/s3"
|
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/storage/s3"
|
||||||
iotv1alpha1 "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/iot/api/v1alpha1"
|
iotv1alpha1 "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/iot/api/v1alpha1"
|
||||||
@@ -213,12 +214,24 @@ func main() {
|
|||||||
os.Exit(1)
|
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
|
// REST API сервер — запускается параллельно с operator manager
|
||||||
apiHandler := slessapi.NewRouter(&handler.Handler{
|
apiHandler := slessapi.NewRouter(&handler.Handler{
|
||||||
K8s: mgr.GetClient(),
|
K8s: mgr.GetClient(),
|
||||||
Scheme: mgr.GetScheme(),
|
Scheme: mgr.GetScheme(),
|
||||||
S3: s3Client,
|
S3: s3Client,
|
||||||
PG: pg,
|
PG: pg,
|
||||||
|
IoTPG: iotPGStore,
|
||||||
Log: log,
|
Log: log,
|
||||||
}, log)
|
}, log)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user