feat: монолит iot-service (Фаза 1) — config/store/api/bridge/consumer без k8s, устройства в PG, новые Dockerfile/Makefile

This commit is contained in:
“Naeel”
2026-08-16 10:07:51 +04:00
parent f0c36a11ee
commit 9d08473d5f
27 changed files with 3716 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
// Package store — хранилище устройств IoT в PostgreSQL.
//
// Таблица iot_devices заменяет CRD IoTDevice и k8s Secrets старого IoT.
// Устройства: namespace, name, device_id, enabled, mqtt_password, metadata.
package store
import (
"errors"
"time"
)
// ErrNotFound — устройство не найдено.
var ErrNotFound = errors.New("device not found")
// ErrAlreadyExists — конфликт уникальности (namespace, name) или (namespace, device_id).
var ErrAlreadyExists = errors.New("device already exists")
// Device — устройство IoT из таблицы iot_devices.
type Device struct {
Namespace string
Name string
DeviceID string
Enabled bool
MQTTPassword string
Metadata map[string]string
Phase string
LastConnected *time.Time
CreatedAt time.Time
}
+173
View File
@@ -0,0 +1,173 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
)
// Create вставляет новое устройство. Конфликт (namespace, name) или
// (namespace, device_id) возвращает ErrAlreadyExists.
func (s *DeviceStore) Create(ctx context.Context, d *Device) error {
metadata, err := json.Marshal(d.Metadata)
if err != nil {
return fmt.Errorf("store: marshal metadata: %w", err)
}
_, err = s.db.ExecContext(ctx, `
INSERT INTO iot_devices (namespace, name, device_id, enabled, mqtt_password, metadata)
VALUES ($1, $2, $3, $4, $5, $6)`,
d.Namespace, d.Name, d.DeviceID, d.Enabled, d.MQTTPassword, metadata,
)
if err != nil {
if isUniqueViolation(err) {
return ErrAlreadyExists
}
return fmt.Errorf("store: insert device: %w", err)
}
return nil
}
// List возвращает устройства namespace (без сортировки по паролям — пароли на месте, но API их не отдаёт).
func (s *DeviceStore) List(ctx context.Context, namespace string) ([]Device, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT namespace, name, device_id, enabled, mqtt_password, metadata, phase,
last_connected, created_at
FROM iot_devices WHERE namespace = $1 ORDER BY name`, namespace)
if err != nil {
return nil, fmt.Errorf("store: list devices: %w", err)
}
defer rows.Close()
return scanDevices(rows)
}
// GetByName возвращает устройство по (namespace, name).
func (s *DeviceStore) GetByName(ctx context.Context, namespace, name string) (*Device, error) {
return s.get(ctx, `SELECT namespace, name, device_id, enabled, mqtt_password, metadata, phase,
last_connected, created_at
FROM iot_devices WHERE namespace = $1 AND name = $2`, namespace, name)
}
// GetByDeviceID возвращает устройство по (namespace, device_id) — для MQTT auth.
func (s *DeviceStore) GetByDeviceID(ctx context.Context, namespace, deviceID string) (*Device, error) {
return s.get(ctx, `SELECT namespace, name, device_id, enabled, mqtt_password, metadata, phase,
last_connected, created_at
FROM iot_devices WHERE namespace = $1 AND device_id = $2`, namespace, deviceID)
}
// get — общая выборка одного устройства.
func (s *DeviceStore) get(ctx context.Context, query string, args ...any) (*Device, error) {
d, err := scanDevice(s.db.QueryRowContext(ctx, query, args...))
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("store: get device: %w", err)
}
return d, nil
}
// UpdateEnabled включает/выключает устройство.
func (s *DeviceStore) UpdateEnabled(ctx context.Context, namespace, name string, enabled bool) error {
res, err := s.db.ExecContext(ctx,
`UPDATE iot_devices SET enabled = $3, phase = $4
WHERE namespace = $1 AND name = $2`,
namespace, name, enabled, phaseOf(enabled))
if err != nil {
return fmt.Errorf("store: update device: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// Delete удаляет устройство.
func (s *DeviceStore) Delete(ctx context.Context, namespace, name string) error {
res, err := s.db.ExecContext(ctx,
`DELETE FROM iot_devices WHERE namespace = $1 AND name = $2`, namespace, name)
if err != nil {
return fmt.Errorf("store: delete device: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// TouchLastConnected обновляет last_connected (best-effort при MQTT auth).
func (s *DeviceStore) TouchLastConnected(ctx context.Context, namespace, deviceID string) {
_, _ = s.db.ExecContext(ctx, `
UPDATE iot_devices SET last_connected = now()
WHERE namespace = $1 AND device_id = $2`, namespace, deviceID)
}
// Count возвращает количество устройств в namespace (для админ-статистики).
func (s *DeviceStore) Count(ctx context.Context, namespace string) (int64, error) {
var n int64
err := s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM iot_devices WHERE namespace = $1`, namespace).Scan(&n)
if err != nil {
return 0, fmt.Errorf("store: count devices: %w", err)
}
return n, nil
}
// scanDevices читает все строки результата.
func scanDevices(rows *sql.Rows) ([]Device, error) {
var out []Device
for rows.Next() {
d, err := scanDevice(rows)
if err != nil {
return nil, err
}
out = append(out, *d)
}
return out, rows.Err()
}
// scanDevice читает одну строку устройства.
type rowScanner interface {
Scan(dest ...any) error
}
func scanDevice(r rowScanner) (*Device, error) {
var (
d Device
metadata []byte
lastConn sql.NullTime
)
err := r.Scan(&d.Namespace, &d.Name, &d.DeviceID, &d.Enabled, &d.MQTTPassword,
&metadata, &d.Phase, &lastConn, &d.CreatedAt)
if err != nil {
return nil, err
}
if len(metadata) > 0 {
_ = json.Unmarshal(metadata, &d.Metadata)
}
if lastConn.Valid {
t := lastConn.Time
d.LastConnected = &t
}
return &d, nil
}
// phaseOf — статус устройства в зависимости от enabled.
func phaseOf(enabled bool) string {
if enabled {
return "Active"
}
return "Disabled"
}
// isUniqueViolation определяет нарушение уникальности PostgreSQL.
func isUniqueViolation(err error) bool {
return err != nil && err.Error() != "" && hasSQLState(err, "23505")
}
func hasSQLState(err error, state string) bool {
type stater interface{ SQLState() string }
var e stater
return errors.As(err, &e) && e.SQLState() == state
}
+62
View File
@@ -0,0 +1,62 @@
package store
import (
"context"
"database/sql"
"fmt"
"time"
)
// schemaSQL — схема таблицы устройств.
// PRIMARY KEY (namespace, name) — как в REST-маршруте /devices/{name}.
// Уникальность device_id в рамках namespace — для MQTT username.
const schemaSQL = `
CREATE TABLE IF NOT EXISTS iot_devices (
namespace TEXT NOT NULL,
name TEXT NOT NULL,
device_id TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT true,
mqtt_password TEXT NOT NULL,
metadata JSONB,
phase TEXT NOT NULL DEFAULT 'Active',
last_connected TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (namespace, name)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_iot_devices_ns_device
ON iot_devices (namespace, device_id);
`
// DeviceStore — доступ к таблице iot_devices (собственный пул соединений).
type DeviceStore struct {
db *sql.DB
}
// Open подключается к PostgreSQL по DSN и инициализирует схему.
func Open(ctx context.Context, dsn string) (*DeviceStore, error) {
db, err := sql.Open("postgres", dsn)
if err != nil {
return nil, fmt.Errorf("store: open DB: %w", err)
}
db.SetMaxOpenConns(5)
db.SetMaxIdleConns(2)
db.SetConnMaxLifetime(5 * time.Minute)
pingCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if err := db.PingContext(pingCtx); err != nil {
db.Close()
return nil, fmt.Errorf("store: ping DB: %w", err)
}
if _, err := db.ExecContext(ctx, schemaSQL); err != nil {
db.Close()
return nil, fmt.Errorf("store: init schema: %w", err)
}
return &DeviceStore{db: db}, nil
}
// Close закрывает пул соединений.
func (s *DeviceStore) Close() error {
return s.db.Close()
}