Files
IoT/internal/service/api/handler/devices.go
T
Repinoid 2aef884d02 fix(iot): telemetry_topic в ответе + нормализация metadata
- iotDeviceResponse: добавлено telemetry_topic = «ns/telemetry/device» (явный
  топик для Terraform; topic_prefix = «ns/» был неполным).
- metadata теперь без omitempty и всегда «{}» при пустом — убран drift
  «{}» против null (ревью 2026-10-10, «Что стабилизировать для Terraform»).
2026-10-10 18:49:40 +03:00

294 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// devices.go — CRUD устройств на PostgreSQL.
//
// Endpoints:
//
// POST /v1/namespaces/{ns}/iot/devices — создать (отдаёт mqtt_password ОДИН раз)
// GET /v1/namespaces/{ns}/iot/devices — список (без паролей)
// GET /v1/namespaces/{ns}/iot/devices/{name} — одно (без пароля)
// DELETE /v1/namespaces/{ns}/iot/devices/{name} — удалить
// PATCH /v1/namespaces/{ns}/iot/devices/{name} — enabled
package handler
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"strconv"
"time"
"gitea.services.ngcloud.ru/Nail/IoT/internal/service/store"
)
// iotDeviceCreateRequest — тело POST.
type iotDeviceCreateRequest struct {
Name string `json:"name"`
DeviceID string `json:"device_id"`
Enabled *bool `json:"enabled"`
Metadata map[string]string `json:"metadata,omitempty"`
}
// iotDeviceUpdateRequest — тело PATCH.
type iotDeviceUpdateRequest struct {
Enabled *bool `json:"enabled"`
}
// iotDeviceResponse — ответ API. MQTTPassword заполняется ТОЛЬКО при создании
// устройства (отдаётся один раз) и НИКОГДА при чтении — чтобы пароль не утекал
// через GET и не хранился в логах/истории.
type iotDeviceResponse struct {
Name string `json:"name"`
Namespace string `json:"namespace"`
DeviceID string `json:"device_id"`
Enabled bool `json:"enabled"`
Phase string `json:"phase"`
MQTTUsername string `json:"mqtt_username,omitempty"`
MQTTPassword string `json:"mqtt_password,omitempty"`
TopicPrefix string `json:"topic_prefix,omitempty"`
// TelemetryTopic — полный MQTT-топик телеметрии устройства «ns/telemetry/device».
// Явное поле (в отличие от topic_prefix = «ns/»): Terraform должен знать
// точный топик для публикации/подписки (ревью 2026-10-10).
TelemetryTopic string `json:"telemetry_topic"`
LastConnected string `json:"last_connected,omitempty"`
// Metadata — без omitempty: всегда присутствует в ответе. Пустой → «{}»,
// иначе Terraform видел бы drift «{}» против null (ревью 2026-10-10).
Metadata map[string]string `json:"metadata"`
CreatedAt string `json:"created_at,omitempty"`
}
// deviceToResponse конвертирует устройство из БД в ответ API.
// password отдаётся только если непустая — только на создании, при чтении
// передаётся пустая строка (пароль повторно не показывается).
func deviceToResponse(d *store.Device, password string) iotDeviceResponse {
// Пустой metadata нормализуем в «{}» (а не null/omit): иначе Terraform видел
// бы drift между «{}» и отсутствующим полем. nil-карту не мутируем — подменяем
// только для сериализации ответа.
metadata := d.Metadata
if metadata == nil {
metadata = map[string]string{}
}
resp := iotDeviceResponse{
Name: d.Name,
Namespace: d.Namespace,
DeviceID: d.DeviceID,
Enabled: d.Enabled,
Phase: d.Phase,
MQTTUsername: d.Namespace + "_" + d.DeviceID,
MQTTPassword: password,
TopicPrefix: d.Namespace + "/",
TelemetryTopic: d.Namespace + "/telemetry/" + d.DeviceID,
Metadata: metadata,
}
if d.LastConnected != nil && !d.LastConnected.IsZero() {
resp.LastConnected = d.LastConnected.UTC().Format(time.RFC3339)
}
if !d.CreatedAt.IsZero() {
// RFC3339 — как last_connected: единый формат для Terraform (drift между
// POST/GET из-за формата «2006-01-02 15:04:05 UTC» устранён).
resp.CreatedAt = d.CreatedAt.UTC().Format(time.RFC3339)
}
return resp
}
// generateMQTTPassword — 32 случайных байта в hex (как старый контроллер).
// crypto/rand ошибку даёт только при сбое системного PRNG — 3 попытки
// (ревью 2026-08-16: не возвращать пустой пароль).
func generateMQTTPassword() (string, error) {
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err == nil {
return hex.EncodeToString(buf), nil
} else {
lastErr = err
}
}
return "", lastErr
}
// CreateIoTDevice — POST /v1/namespaces/{ns}/iot/devices.
func (h *Handler) CreateIoTDevice(w http.ResponseWriter, r *http.Request) {
ns := pathVar(r, "namespace")
if ns == "" {
ns = "default"
}
var req iotDeviceCreateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, errResp("invalid JSON: "+err.Error()))
return
}
if req.Name == "" {
writeJSON(w, http.StatusBadRequest, errResp("name is required"))
return
}
if req.DeviceID == "" {
writeJSON(w, http.StatusBadRequest, errResp("device_id is required"))
return
}
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}
password, err := generateMQTTPassword()
if err != nil {
h.Log.Error("generate device password", "err", err)
writeJSON(w, http.StatusInternalServerError, errResp("failed to generate credentials"))
return
}
phase := "Active"
if !enabled {
phase = "Disabled"
}
device := &store.Device{
Namespace: ns,
Name: req.Name,
DeviceID: req.DeviceID,
Enabled: enabled,
MQTTPassword: password,
Metadata: req.Metadata,
Phase: phase,
}
if err := h.Devices.Create(r.Context(), device); err != nil {
if errors.Is(err, store.ErrAlreadyExists) {
writeJSON(w, http.StatusConflict, errResp("iot device already exists"))
return
}
h.Log.Error("create device", "namespace", ns, "name", req.Name, "err", err)
writeJSON(w, http.StatusInternalServerError, errResp("failed to create device"))
return
}
// Перечитываем устройство из БД: после INSERT нужны БД-поля created_at
// (и phase из БД), которых нет в in-memory структуре. Иначе POST отдал бы
// пустой created_at → drift между POST и GET (ревью 2026-10-10, «Что
// стабилизировать в REST для Terraform»).
created, err := h.Devices.GetByName(r.Context(), ns, req.Name)
if err != nil {
h.Log.Error("reload device after create", "namespace", ns, "name", req.Name, "err", err)
writeJSON(w, http.StatusInternalServerError, errResp("failed to reload device"))
return
}
// Пароль отдаётся ТОЛЬКО здесь, один раз в ответе 201. Дальше (GET/PATCH/
// LIST) он не возвращается — иначе пароль утекал бы через чтение и в
// историю/логи. Это блокер для Terraform: без пароля устройство нельзя
// подключить к MQTT, значит ресурс не создать. Именно поэтому пароль
// пробрасывается в deviceToResponse только в этой точке (GetByName его
// не отдаёт — см. P0-3 ч.2).
writeJSON(w, http.StatusCreated, deviceToResponse(created, password))
}
// ListIoTDevices — GET /v1/namespaces/{ns}/iot/devices (без паролей).
// Пагинация: ?limit=N&offset=M (0 = без ограничения).
func (h *Handler) ListIoTDevices(w http.ResponseWriter, r *http.Request) {
ns := pathVar(r, "namespace")
if ns == "" {
ns = "default"
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
devices, err := h.Devices.List(r.Context(), ns, limit, offset)
if err != nil {
h.Log.Error("list devices", "namespace", ns, "err", err)
writeJSON(w, http.StatusInternalServerError, errResp("failed to list devices"))
return
}
result := make([]iotDeviceResponse, 0, len(devices))
for i := range devices {
result = append(result, deviceToResponse(&devices[i], ""))
}
writeJSON(w, http.StatusOK, result)
}
// GetIoTDevice — GET /v1/namespaces/{ns}/iot/devices/{name} (с паролем).
func (h *Handler) GetIoTDevice(w http.ResponseWriter, r *http.Request) {
ns := pathVar(r, "namespace")
if ns == "" {
ns = "default"
}
name := pathVar(r, "name")
device, err := h.Devices.GetByName(r.Context(), ns, name)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeJSON(w, http.StatusNotFound, errResp("iot device not found"))
return
}
h.Log.Error("get device", "namespace", ns, "name", name, "err", err)
writeJSON(w, http.StatusInternalServerError, errResp("failed to get device"))
return
}
writeJSON(w, http.StatusOK, deviceToResponse(device, ""))
}
// DeleteIoTDevice — DELETE /v1/namespaces/{ns}/iot/devices/{name}.
func (h *Handler) DeleteIoTDevice(w http.ResponseWriter, r *http.Request) {
ns := pathVar(r, "namespace")
if ns == "" {
ns = "default"
}
name := pathVar(r, "name")
err := h.Devices.Delete(r.Context(), ns, name)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeJSON(w, http.StatusNotFound, errResp("iot device not found"))
return
}
h.Log.Error("delete device", "namespace", ns, "name", name, "err", err)
writeJSON(w, http.StatusInternalServerError, errResp("failed to delete device"))
return
}
w.WriteHeader(http.StatusNoContent)
}
// UpdateIoTDevice — PATCH /v1/namespaces/{ns}/iot/devices/{name} (enabled).
func (h *Handler) UpdateIoTDevice(w http.ResponseWriter, r *http.Request) {
ns := pathVar(r, "namespace")
if ns == "" {
ns = "default"
}
name := pathVar(r, "name")
var req iotDeviceUpdateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, errResp("invalid JSON: "+err.Error()))
return
}
if req.Enabled == nil {
writeJSON(w, http.StatusBadRequest, errResp("enabled field is required"))
return
}
err := h.Devices.UpdateEnabled(r.Context(), ns, name, *req.Enabled)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeJSON(w, http.StatusNotFound, errResp("iot device not found"))
return
}
h.Log.Error("update device", "namespace", ns, "name", name, "err", err)
writeJSON(w, http.StatusInternalServerError, errResp("failed to update device"))
return
}
device, err := h.Devices.GetByName(r.Context(), ns, name)
if err != nil {
writeJSON(w, http.StatusInternalServerError, errResp("failed to reload device"))
return
}
writeJSON(w, http.StatusOK, deviceToResponse(device, ""))
}