diff --git a/deployments/k8s/iot-mqtt-bridge.yaml b/deployments/k8s/iot-mqtt-bridge.yaml index f4511de..1fd2fe3 100644 --- a/deployments/k8s/iot-mqtt-bridge.yaml +++ b/deployments/k8s/iot-mqtt-bridge.yaml @@ -38,7 +38,7 @@ spec: - name: mqtt-bridge # Тот же образ что и оператор — оба бинаря в одном слое (manager + iot-mqtt-bridge). # При смене версии оператора — менять тег и здесь. - image: naeel/iot-operator:v0.2.3 + image: naeel/iot-operator:v0.2.5 imagePullPolicy: Always command: ["/mqtt-bridge"] env: diff --git a/deployments/k8s/iot-operator.yaml b/deployments/k8s/iot-operator.yaml index b28a65b..de5898a 100644 --- a/deployments/k8s/iot-operator.yaml +++ b/deployments/k8s/iot-operator.yaml @@ -3,7 +3,7 @@ # # Компоненты: # - ServiceAccount + ClusterRole + ClusterRoleBinding (RBAC для CRD controller) -# - Deployment: naeel/iot-operator:v0.2.3 +# - Deployment: naeel/iot-operator:v0.2.5 # - Service: ClusterIP :9090 (REST API, MQTT auth/acl, admin UI) # # iot-operator обслуживает: @@ -85,7 +85,7 @@ spec: serviceAccountName: iot-operator containers: - name: operator - image: naeel/iot-operator:v0.2.3 + image: naeel/iot-operator:v0.2.5 imagePullPolicy: Always ports: - name: api diff --git a/deployments/k8s/iot-sqs-consumer.yaml b/deployments/k8s/iot-sqs-consumer.yaml index 19ad868..e214215 100644 --- a/deployments/k8s/iot-sqs-consumer.yaml +++ b/deployments/k8s/iot-sqs-consumer.yaml @@ -36,7 +36,7 @@ spec: containers: - name: sqs-consumer # Тот же образ что и оператор — все IoT бинари в одном образе. - image: naeel/iot-operator:v0.2.3 + image: naeel/iot-operator:v0.2.5 imagePullPolicy: Always command: ["/sqs-consumer"] env: diff --git a/doc/progress.md b/doc/progress.md index cc0f8d0..fff4fdc 100644 --- a/doc/progress.md +++ b/doc/progress.md @@ -122,3 +122,18 @@ - [x] doc/architecture/current-v0.2.3.md — актуальная архитектура (без Kafka/RabbitMQ) - [x] doc/api/endpoints-v0.2.3.md — все API эндпоинты с примерами запросов/ответов - [x] doc/run-and-test.md — как запустить и протестировать E2E + +## 2026-04-12 — v0.2.5: баг-фиксы и E2E тест + +### Найдены и исправлены баги: +1. **MQTTAuth: Get по Name=deviceID** — заменено на List + фильтр по Spec.DeviceID +2. **PG15+ GRANT**: добавлен `GRANT role TO CURRENT_USER` перед `CREATE DATABASE ... OWNER` + +### E2E тест пройден: +- Устройство создано через REST API (201) +- MQTT CONNECT + PUBLISH через EMQX — OK +- mqtt-bridge → SQS forwarding — OK +- sqs-consumer → Postgres (tenant DB created + telemetry saved) — OK +- REST API GET /telemetry — 2 записи с payload + +### Docker Hub: `naeel/iot-operator:v0.2.5` diff --git a/doc/thinking/2026-04-12.md b/doc/thinking/2026-04-12.md index 0c17c79..7b1ed3f 100644 --- a/doc/thinking/2026-04-12.md +++ b/doc/thinking/2026-04-12.md @@ -205,3 +205,35 @@ E2E тест: создать устройство → MQTT publish → пров ### Осталось - git commit + push + +--- + +## Агент: GitHub Copilot (Claude Opus 4.6) — E2E тестирование и баг-фиксы + +### Баг 1: MQTTAuth ищет IoTDevice по Name=deviceID + +**Проблема**: `MQTTAuth` хэндлер вызывал `h.K8s.Get(client.ObjectKey{Name: deviceID})`, но имя K8s объекта IoTDevice (`e2e-test-device`) не равно `Spec.DeviceID` (`e2e-test-01`). Результат — `deny`. + +**Диагностика**: +1. Auth endpoint вернул `{"result":"deny"}` при прямом вызове curl +2. Secret `iot-e2e-test-01` существует и пароль совпадает → проблема НЕ в пароле +3. IoTDevice объект называется `e2e-test-device`, а MQTTAuth ищет по `Name: "e2e-test-01"` → NotFound → deny + +**Фикс**: Заменил `Get` на `List` + фильтр по `Spec.DeviceID == deviceID`. Это O(n) по количеству устройств в namespace, но для MVP приемлемо. При необходимости можно добавить label-index. + +### Баг 2: PG15+ требует GRANT перед CREATE DATABASE ... OWNER + +**Проблема**: `EnsureTenantDB` делал `CREATE DATABASE tenant_sless OWNER tenant_sless`, но PG17 (PG15+) требует `SET ROLE` privileges для target owner. Ошибка: `pq: must be able to SET ROLE "tenant_sless" (42501)`. + +**Фикс**: Добавил `GRANT {userName} TO CURRENT_USER` перед `CREATE DATABASE ... OWNER`. + +### E2E тест v0.2.5 — полный пайплайн + +1. `POST /v1/namespaces/sless/iot/devices` → 201, device created +2. `GET /devices/e2e-test-device` → phase=Active, credentials получены +3. `mosquitto_pub` → CONNACK(0), PUBLISH OK +4. mqtt-bridge → `forwarded IoT telemetry to SQS` +5. sqs-consumer → `created tenant DB` + `telemetry saved to Postgres` +6. `GET /v1/namespaces/sless/iot/telemetry?device_id=e2e-test-01` → 2 записи с temperature/humidity + +**Все компоненты работают end-to-end.** diff --git a/internal/api/handler/iot_device_handler.go b/internal/api/handler/iot_device_handler.go index c37ff56..4b2809f 100644 --- a/internal/api/handler/iot_device_handler.go +++ b/internal/api/handler/iot_device_handler.go @@ -193,10 +193,21 @@ func (h *Handler) MQTTAuth(w http.ResponseWriter, r *http.Request) { return } - // Проверяем что IoTDevice активно - device := &iotv1alpha1.IoTDevice{} - if err := h.K8s.Get(r.Context(), client.ObjectKey{Namespace: ns, Name: deviceID}, device); err != nil { - // IoTDevice не найден (или удалён) — deny + // Ищем IoTDevice по Spec.DeviceID (имя K8s объекта может отличаться от deviceID) + deviceList := &iotv1alpha1.IoTDeviceList{} + if err := h.K8s.List(r.Context(), deviceList, client.InNamespace(ns)); err != nil { + writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"}) + return + } + var device *iotv1alpha1.IoTDevice + for i := range deviceList.Items { + if deviceList.Items[i].Spec.DeviceID == deviceID { + device = &deviceList.Items[i] + break + } + } + if device == nil { + // IoTDevice с таким deviceID не найден — deny writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"}) return } diff --git a/internal/storage/iotpg/iot_telemetry_store.go b/internal/storage/iotpg/iot_telemetry_store.go index 798b9e3..1e75a43 100644 --- a/internal/storage/iotpg/iot_telemetry_store.go +++ b/internal/storage/iotpg/iot_telemetry_store.go @@ -126,6 +126,13 @@ func (s *IoTPostgresStore) EnsureTenantDB(ctx context.Context, namespace string) return fmt.Errorf("iotpg: create user %s: %w", userName, err) } + // PG15+: GRANT role TO current_user перед CREATE DATABASE ... OWNER + if _, err = s.adminDB.ExecContext(ctx, + fmt.Sprintf(`GRANT %s TO CURRENT_USER`, userName), + ); err != nil { + return fmt.Errorf("iotpg: grant role %s: %w", userName, err) + } + // CREATE DATABASE нельзя в транзакции if _, err = s.adminDB.ExecContext(ctx, fmt.Sprintf(`CREATE DATABASE %s OWNER %s`, dbName, userName),