Commit Graph
103 Commits
Author SHA1 Message Date
Repinoid c06fb3a8e2 docs: PG для IoT готов — user iotservice + db iotdb созданы через Nubes API (test) 2026-10-11 14:03:38 +03:00
Repinoid e0a5c77f16 docs: чек-лист деплоя IoT на Nubes (образы Gitea, SQS очередь+DLQ, env) 2026-10-11 10:07:22 +03:00
Repinoid 73dc51b7f5 chore: игнорировать *.key (креды PG/секреты) 2026-10-11 09:54:19 +03:00
Repinoid 16b9f05a86 build: registry → gitea.services.ngcloud.ru/nail (Makefile/Dockerfile/дока) 2026-10-11 09:51:08 +03:00
Repinoid fdb9fa86b8 docs(history): зафиксировать решения по оставленным пунктам (16/12) 2026-10-11 09:19:50 +03:00
Repinoid f781de75cf docs(history): внешнее ревью и исправления 2026-10-11 2026-10-11 09:14:12 +03:00
Repinoid e510e93099 iotpg: валидировать namespace (safeNamespaceRe) в getTenantDB на всех путях чтения/записи 2026-10-11 08:49:00 +03:00
Repinoid 4c1b9b7b9e IoT: maxReceiveCount ко всем ошибкам consumer, ждать Shutdown до deferred Close, не 500 после успешного create 2026-10-11 08:48:29 +03:00
Repinoid 93d47376b1 bridge: MessageID=хэш(дедуп), классификация permanent/transient SQS-ошибок, бесконечный ретрай подписки, валидация ns/deviceID 2026-10-11 08:46:22 +03:00
Repinoid 89b5660a8b iotpg: не возвращать ошибку при постоянной (poison) ошибке flush — данные уже обработаны построчно 2026-10-11 08:37:17 +03:00
Repinoid 994fc054b1 console: namespace = sub (убрать sless-SHA256) и удалить несуществующий /ensure 2026-10-11 08:06:20 +03:00
Repinoid fc26ca1954 docs(HISTORY): хвосты 2026-10-11 закрыты, осталось обновлено 2026-10-11 07:55:35 +03:00
Repinoid e83d804501 test(iot): безопасность enqueue↔closeAndWait под race (фикс 🔴3)
Параллельный enqueue + закрытие канала не должен паниковать «send on closed
channel». go test -race ./... зелёный.
2026-10-11 07:55:08 +03:00
Repinoid ba14d4a767 fix(iot): консоль — убрать старые домены kube5s/sless
- apiBase → window.location.origin (same-origin с API: консоль и /v1 раздаёт один
  iot-service).
- mqttBase → wss://exqx.containerk8s.dev.nubes.ru/mqtt (как в landing.html).
- Убраны упоминания «оператор»/k8s в комментариях; MQTTX-инструкция — новый хост.
2026-10-11 07:53:02 +03:00
Repinoid 3e1d981614 fix(iot): пустое тело сообщения → DLQ (вместо молчаливого подтверждения)
Убран ранний дроп пустого тела; теперь оно идёт через processTelemetry →
errInvalidEnvelope → DLQ (или дроп после maxReceiveCount, если DLQ недоступна).
2026-10-11 07:51:57 +03:00
Repinoid c3106d6e20 fix(iot): ReceivedAt в RFC3339Nano (порядок телеметрии, non-FIFO)
bridge пишет, consumer парсит RFC3339Nano (субсекундная точность). Раньше RFC3339
(секунды) + non-FIFO очередь → показания в одну секунду могли встать не в том
порядке. Parse RFC3339Nano совместим со старыми RFC3339-сообщениями (доля секунды
опциональна).
2026-10-11 07:51:38 +03:00
Repinoid 099900580b docs(HISTORY): журнал сессии 2026-10-10 — легаси, ветка master, ревью и фиксы
Полная трассировка: чистка легаси, переименование в master, правки по ревью Opus,
PRO-ревью (3 находки), Этап 3/4/5, P0-3 bcrypt, тесты, открытые находки (§5),
осталось (§6).
2026-10-10 19:29:06 +03:00
Repinoid 85bcf26220 chore(iot): MaxReconnectInterval 30с + Debug для health-проб и телеметрии (ревью)
- bridge: SetMaxReconnectInterval(30с) — дефолт ~10м слишком долог для телеметрии.
- logging middleware: /health,/healthz,/ready → Debug (не засорять лог).
- processor: 'telemetry buffered' вместо Info 'saved' (данные ещё буферизованы).
2026-10-10 19:24:12 +03:00
Repinoid aac36bab39 docs(iot): деплой-дока — пароль отдаётся один раз, в БД bcrypt-хэш 2026-10-10 19:23:25 +03:00
Repinoid 7c80c7b7f8 test(iot): юнит-тесты валидации идентификаторов и разбора DSN (P1-11)
- handler/devices_test.go: validateIdentifier (charset/лимиты, запрет _ + # /).
- iotpg/dsn_test.go: replaceDSNDatabase (вкл. sslrootcert со слэшем, ошибка для
  key=value) + safeNamespaceRe.
- gofmt для затронутых файлов. go test ./... зелёный.
2026-10-10 19:23:09 +03:00
Repinoid 3a73207abd fix(iot): 409 различает конфликт name vs device_id (ревью)
store.Create классифицирует 23505 по имени constraint: (namespace,device_id) →
ErrDeviceIDExists, иначе ErrAlreadyExists. Handler отдаёт разные 409-сообщения.
Помогает Terraform понять, какое поле конфликтует.
2026-10-10 19:21:47 +03:00
Repinoid 3b7c8a0a63 feat(iot): bcrypt-хэш паролей устройств и бриджа + EMQX на bcrypt (P0-3 ч.1)
- handler CreateIoTDevice: пароль устройства хранится bcrypt-хэшем (hashPassword),
  открытый пароль отдаётся один раз в 201.
- store.EnsureBridgeDevice: пароль бриджа тоже bcrypt-хэшем.
- emqx/emqx.conf: password_hash_algorithm name=bcrypt, salt_rounds=10 (подтверждено
  по emqx/emqx: HOCON-ключ salt_rounds; Go bcrypt.DefaultCost=10).
- Добавлена зависимость golang.org/x/crypto v0.42.0.
2026-10-10 19:21:10 +03:00
Repinoid 59bd743c87 feat(iot): PATCH обновляет metadata (Terraform)
iotDeviceUpdateRequest получил поле metadata; UpdateIoTDevice обновляет переданные
поля (enabled и/или metadata). Добавлен store.UpdateMetadata. Хотя бы одно поле
обязательно. Раньше metadata нельзя было изменить (только enabled).
2026-10-10 19:18:01 +03:00
Repinoid 38e3f65c21 fix(iot): пагинация — tiebreaker ts DESC,id DESC + потолок limit; точный лог (Этап 5)
- QueryTelemetry: ORDER BY ts DESC, id DESC (был без tiebreaker → нестабильная
  пагинация при одинаковых ts).
- ListIoTDevices: limit дефолт 100, потолок 1000 (ошибка парсинга больше не даёт
  безлимитный запрос).
- Логи 'batch dropped' → 'валидные строки — построчно' (после fallback батч НЕ
  теряется целиком).
2026-10-10 19:17:25 +03:00
Repinoid f3927fdf11 fix(iot): готовность consumer по SQS + ограничение wg.Wait + идемпотентный Close (Этап 3)
- consumer.Run получил onReady: /ready теперь false при ошибке ReceiveMessage
  (раньше флаг consumer'а выставлялся до старта и не отражал SQS).
- main.go: wg.Wait заменён на waitWithTimeout (shutdownGrace=15с) — зависшую
  подсистему не ждём бесконечно.
- iotpg.Close идемпотентен (sync.Once) — повторный вызов не паникует.
2026-10-10 19:16:30 +03:00
Repinoid c725676f85 chore(iot): удаление сообщений пакетами DeleteMessageBatch (Этап 5)
Было по одному DeleteMessage на сообщение. Теперь DeleteMessageBatch (до 10 за
вызов) с логированием Failed-записей; хелпер deleteMessage удалён.
2026-10-10 19:15:08 +03:00
Repinoid 4a0372ff05 chore(iot): лимит тела запроса + запрет неизвестных полей + таймауты сервера (Этап 5)
- decodeJSON: http.MaxBytesReader (64 КиБ) + DisallowUnknownFields; используется
  в Create/Update устройства. Ловит опечатки в полях (важно для Terraform).
- http.Server: добавлены ReadTimeout=15с, WriteTimeout=60с, IdleTimeout=120с
  (был только ReadHeaderTimeout).
2026-10-10 19:14:47 +03:00
Repinoid e429f11e7f chore(iot): убрать мёртвый mqtt_auth.go + constant-time админ-токен (Этап 5)
- Удалён internal/service/api/handler/mqtt_auth.go: методы MQTTAuth/MQTTAcl
  нигде не подключены (мёртвый код; при подключении небезопасен — доверял
  клиентскому clientid и разрешал subscribe на '#').
- admin.go: сравнение ADMIN_STATS_TOKEN через subtle.ConstantTimeCompare.
2026-10-10 19:14:16 +03:00
Repinoid faba28f824 fix(iot): replaceDSNDatabase (смешение тенантов) + guard namespace (Этап 4)
- replaceDSNDatabase: возвращает ОШИБКУ для key=value DSN (раньше молча отдавал
  исходный DSN → телеметрия всех тенантов ушла бы в management-БД). Query
  отделяется первым '?', поэтому sslrootcert=/path больше не ломает поиск БД.
- EnsureTenantDB: guard safeNamespaceRe (defense-in-depth, совпадает с API-валидацией);
  небезопасный namespace → ErrInvalidNamespace → в consumer классифицируется как
  постоянная ошибка (DLQ, не вечный ретрай).
2026-10-10 19:13:35 +03:00
Repinoid c3492cb0f1 fix(iot): JWT — обязательный exp, 401 вместо 403, guard тест-режима, JSON-ошибки (Этап 4)
- validateJWT: exp теперь ОБЯЗАТЕЛЕН (без него токен был вечным).
- middleware: невалидный/просроченный токен → 401 (было 403) — клиент/Terraform
  отличают «протух» от «чужой namespace» (там осталось 403).
- AUTH_TEST_MODE игнорируется при заданном JWT_HMAC_SECRET (иначе обходил подпись).
- Все ошибки middleware — application/json через authErr (были text/plain).
2026-10-10 19:12:48 +03:00
Repinoid 6ff5576e7d fix(iot): /ready отражает факт подписки MQTT (находка #3)
Было: main выставлял ready.SetBridge(true) ПЕРЕД bridge.Run, поэтому «соединён,
но подписка не прошла» давал /ready=200 и молча терял телеметрию до реконнекта.
Теперь bridge сам управляет флагом (колбэк onSubscribed): true только после
успешного SUBSCRIBE, false при потере соединения/сбое подписки.
2026-10-10 19:10:44 +03:00
Repinoid d7701f2845 fix(iot): ограничить повторы ядовитого сообщения (находка #2)
Было: при недоступной DLQ битое сообщение НЕ подтверждалось и крутилось на
основной очереди бесконечно. Теперь читается ApproximateReceiveCount; после
maxReceiveCount=5 битое сообщение подтверждается (дроп с громким логом), а не
зацикливается. При доступной DLQ — уходит туда на первом приёме.
2026-10-10 19:10:12 +03:00
Repinoid dc56a9c2e2 fix(iot): run() int вместо os.Exit — финальный flush не теряется (находка #1)
os.Exit обходит defer-ы: на fatal-пути и при ошибке HTTP-сервера iotStore.Close()
(финальный flushAll последнего батча телеметрии) не выполнялся. Логика вынесена в
run() int, main = os.Exit(run()); все выходы через return — defer-ы отрабатывают.
2026-10-10 19:09:33 +03:00
Repinoid 946600238b fix(iot): ретрай GetQueueUrl на старте + выход ≠0 при сбое подсистемы (🔴5)
- Добавлен ResolveQueueURLWithRetry (10 попыток × 2с backoff): временная ошибка
  GetQueueUrl больше не роняет bridge/consumer без ретрая.
- main.go: при ПОСТОЯННОЙ ошибке bridge/consumer процесс завершается с кодом ≠ 0
  (fatal-флаг + cancel), чтобы платформа перезапустила контейнер — раньше процесс
  оставался жив, а /health отдавал 200.
2026-10-10 19:05:08 +03:00
Repinoid 572988a2a1 fix(iot): слить остаток буфера bridge при shutdown + безопасное закрытие канала (🔴3)
- Диспатчер получил собственный ctx (не сигнальный): при shutdown воркеры
  успевают отправить накопленное в SQS (раньше сразу возвращали «context
  canceled» и дропали остаток). Флаш ограничен bridgeFlushTimeout=10с.
- enqueue/closeAndWait синхронизированы мьютексом (closed-флаг): поздний колбэк
  paho больше не паникует на «send on closed channel».
2026-10-10 19:02:30 +03:00
Repinoid 5600646db3 fix(iot): ретраи отправки SQS до shutdown + таймаут на попытку (🔴4)
- sendWithRetry больше не дропает после 3 попыток (~7с) при почти пустом буфере:
  ретраит до ctx.Done() (shutdown) или успеха, backoff капится на 8с.
- Добавлен sendAttemptTimeout=10с на одну попытку — зависшее соединение SQS
  не блокирует воркер навсегда.
2026-10-10 18:59:37 +03:00
Repinoid ab146861d3 fix(iot): не терять битые сообщения при недоступной DLQ (🔴7)
- ResolveQueueURL для DLQ теперь ретраится 3 раза с backoff: временная ошибка
  больше не отключает DLQ навсегда.
- Битый envelope подтверждается только если DLQ реально приняла его; при
  недоступной DLQ/сбое sendToDLQ сообщение НЕ подтверждается (вернётся после
  visibility timeout), а не дропается.
2026-10-10 18:58:26 +03:00
Repinoid 6048777a9e fix(iot): не рвать автореконнект при сбое подписки (🔴6)
OnConnectHandler вызывал c.Disconnect(100) при таймауте/ошибке подписки. В paho
это ЯВНОЕ отключение — автореконнект после него не срабатывает, и bridge
«глохнет» (соединения нет, подписки нет, /ready=true).

Теперь: подписка ретраится 3 раза с паузой 2с; при сбое — ждём следующего
реконнекта (OnConnectHandler снова вызовется). Disconnect не вызывается.
2026-10-10 18:55:34 +03:00
Repinoid 2cca7cb856 fix(iot): семантическая валидация envelope (пустые поля → DLQ)
processTelemetry теперь отбрасывает (в DLQ) envelope с пустым namespace,
device_id или payload ДО записи в БД — иначе пустой namespace создавал бы
tenant-БД «tenant_», а пустой payload давал NULL (23502)/битый JSONB и вечный
ретрай. Дополняет построчный fallback в flushTenant (🔴2).
2026-10-10 18:53:59 +03:00
Repinoid e31f88ba6b fix(iot): построчный fallback при постоянной ошибке батча (🔴2)
Одна «ядовитая» строка (NULL/пустой payload, \u0000 в JSONB, битый JSON)
ронила весь multi-VALUES батч (до telemetryBatchSize валидных строк), который
классифицировался как permanent и выбрасывался целиком.

Добавлен flushRowsIndividually: при не-retryable ошибке батча вставляем строки
по одной (ON CONFLICT DO NOTHING) — валидные сохраняются, битая логируется.
2026-10-10 18:53:34 +03:00
Repinoid 7407de5fd3 fix(iot): сериализовать flush (гонка с фоновым flusher'ом — потеря данных)
🔴1 ревью: flushAll и синхронная запись полного батча в InsertTelemetry писали
параллельно с фоновым flusher'ом (каждые 200мс). Flush() из consumer видел
пустой буфер (фоновый flusher уже забрал строки) и возвращал nil → consumer
подтверждал SQS-сообщение, пока INSERT ещё в полёте/упал.

Добавлен flushMu: сериализует grab буфера + INSERT между фоновым flusher'ом,
Flush() и полным батчом InsertTelemetry. Теперь Flush дожидается in-flight
записи и возвращает реальную ошибку.
2026-10-10 18:52:43 +03:00
Repinoid 8335afb617 fix(iot): валидация namespace/name/device_id (запрет _, +, #, /)
- Добавлен identRe ^[a-zA-Z0-9][a-zA-Z0-9-]*$ и validateIdentifier.
- CreateIoTDevice теперь проверяет namespace (≤56), name (≤64), device_id (≤64).
- Причины: _ ломает username «namespace_device_id» (auth EMQX LIMIT 1 без
  ORDER BY), +/# — wildcard в ACL-топике, / — лишний уровень топика.
2026-10-10 18:50:56 +03:00
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
Repinoid e2bb642df2 fix(iot): created_at в RFC3339 + перечитывание после Create
- deviceToResponse: created_at теперь RFC3339 (был «2006-01-02 15:04:05 UTC»)
  — единый формат с last_connected, без drift для Terraform.
- CreateIoTDevice: после INSERT перечитываем устройство из БД (GetByName),
  чтобы в 201 отдавался реальный created_at (in-memory структура его не имеет).
2026-10-10 18:48:52 +03:00
Repinoid 76f0230f5d fix(iot): отдавать MQTT-пароль при создании + писать phase в БД
- handler/devices.go: CreateIoTDevice отдаёт mqtt_password один раз в 201
  (было deviceToResponse(device, "") — пароль терялся, устройство нельзя
  подключить; блокер для Terraform). Заголовок поправлен (GET — без пароля).
- store/devices.go: Create пишет phase явно через phaseOf(enabled) — иначе
  enabled=false лежал в БД как 'Active' (DEFAULT), расходясь с POST/GET.
2026-10-10 18:48:07 +03:00
Repinoid 4290ecfb0a Merge remote-tracking branch 'origin/master'
# Conflicts:
#	.gitignore
2026-10-10 18:39:10 +03:00
Repinoid 56fadf6ca3 style(iot-admin): переформатирование HTML (форматтер) 2026-10-10 18:38:12 +03:00
Repinoid a510522660 chore(iot): убрать старое (Kafka/K8s) из живого админ-UI и .gitignore
- iot-admin.html: секции Kafka/Pods заменены на Очередь shared-SQS и Рантайм
  (бэкенд admin.go давно отдаёт sqs/runtime, а фронт рисовал мёртвые секции);
  удалены CSS .pod-*/.badge-*/.dot-*/.lag-bar*, исправлен заголовок.
- .gitignore: удалены сталые пути бинарников старого IoT (/iot-operator,
  /mqtt-bridge, /kafka-consumer, /sqs-consumer).
2026-10-10 18:15:13 +03:00
Repinoid b205f886b6 chore(iot): удалить легаси-код (оператор/CRD/старый API/standalone bridge)
Удалён кластер старого IoT, не импортировавшийся активным монолитом:
- cmd/iot-operator/, controllers/, api/v1alpha1/ — k8s-оператор + CRD
- internal/api/ — старый REST API + UI-embeds (заменён internal/service/api)
- cmd/mqtt-bridge/ — standalone bridge (заменён internal/service/bridge)

go mod tidy убрал k8s-зависимости (k8s.io/*, controller-runtime).
2026-10-10 18:11:05 +03:00
Repinoid ecf6b41131 docs(review): Opus code-review of iot-service (bridge->SQS->consumer) verbatim 2026-10-10 18:02:59 +03:00