feat: монолит iot-service (Фаза 1) — config/store/api/bridge/consumer без k8s, устройства в PG, новые Dockerfile/Makefile
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user