diff --git a/doc/thinking/2026-04-04.md b/doc/thinking/2026-04-04.md index 630baa5..2fb6fc7 100644 --- a/doc/thinking/2026-04-04.md +++ b/doc/thinking/2026-04-04.md @@ -75,6 +75,128 @@ IoT Device → MQTT (topic: {user-prefix}/device/telemetry) --- +## Агент: GitHub Copilot (Claude Sonnet 4.6) +## Дата сессии: 2026-04-04 + +### Задача: Реализация IoT MVP по плану из doc/iot-mvp-plan.md + +--- + +### Шаг 0: Изучение кодовой базы (перед написанием кода) + +Прочитал следующие файлы: +- `api/v1alpha1/function_types.go` — паттерн написания CRD типов +- `api/v1alpha1/trigger_types.go` — использование TriggerType string const +- `api/v1alpha1/groupversion_info.go` — паттерн регистрации API group +- `controllers/function_controller.go` — паттерн контроллера, finalizer, Reconcile +- `controllers/service_controller.go` — второй контроллер для сравнения +- `internal/api/router.go` — регистрация маршрутов, auth middleware +- `main.go` — как регистрируются контроллеры, как подключается API +- `go.mod` — версии зависимостей (k8s v0.26.0, controller-runtime v0.14.1) + +#### Что понял + +**Go module**: `gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless` +**API сервер**: gorilla/mux, auth через middleware на `/v1/` subrouter +**Паттерн контроллера**: `r.Get → finalizer → business logic → r.Status().Update()` +**Finalizer**: у function = `sless.kube5s.ru/finalizer`, у service = `sless.kube5s.ru/service-finalizer` + - Используют ручной containsString/removeString, **НЕ** controllerutil + - Я решил использовать `controllerutil.ContainsFinalizer/AddFinalizer/RemoveFinalizer` — это более современный и рекомендуемый подход из controller-runtime +**OwnerReference**: нигде не вижу в существующих контроллерах. Для IoT Secret нужна — чтобы Secret автоматически удалялся при удалении IoTDevice через cascade deletion. Использую `controllerutil.SetControllerReference`. + +#### Почему controllerutil вместо ручного containsString + +Существующий код использует устаревший подход (хелперы `containsString`/`removeString`). `controllerutil` — официальный API controller-runtime для работы с finalizers. Это не рефакторинг старого кода — это новый контроллер, пишу его правильно сразу. + +--- + +### Шаг 1: CRD файлы и контроллер + +#### Решение по структуре директорий + +По плану: `iot/api/v1alpha1/` и `iot/controllers/`. Это отдельный package path от `api/v1alpha1`. +- IoT CRD package: `gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/iot/api/v1alpha1` +- IoT контроллер package: `gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/iot/controllers` +- Тот же go.mod — не нужно создавать отдельный модуль + +#### Решение по API Group + +По плану: `iot.kube5s.ru` — отдельная от `sless.kube5s.ru`. +Причина: при выносе в отдельную репу CRD не будет конфликтовать. Принимаю. + +#### MQTTUsername формат + +По плану: `{namespace}_{deviceId}`. +Пример: `sless-abc123def456_sensor-01` +Причина: EMQX требует глобально уникальный username. Namespace даёт изоляцию. + +#### Secret name + +По плану: `iot-{deviceId}`. +**Возможная проблема**: deviceId может содержать символы недопустимые в k8s Secret именах (только [a-z0-9-]). +**Решение**: в kubebuilder validation на DeviceID добавить regex `[a-z0-9-]+`. Если deviceId уже проходит валидацию — проблемы нет. +В плане валидация не упомянута, но это необходимо чтобы имя Secret было валидным. Добавлю `+kubebuilder:validation:Pattern`. + +#### Генерация пароля + +32 байта через `crypto/rand.Read` → `hex.EncodeToString` = 64 символа. +Это достаточно энтропии (256 бит). + +#### OwnerReference у Secret + +С OwnerReference Secret автоматически удалится при удалении IoTDevice (cascade GC в k8s). +Поэтому в finalizer обработчике нет нужды явно удалять Secret — просто убираем finalizer. + +**Но есть нюанс**: если IoTDevice и Secret находятся в одном namespace — cascade deletion работает. +В нашем случае оба в `sless-{hash}` — OK. + +#### Обработка статуса + +`r.Status().Update()` — только subresource. Не трогает `spec` или `metadata`. Это важно чтобы не вызвать лишний reconcile цикл (обновление spec → новый reconcile → loop). + +#### Disabled устройство + +Если `spec.enabled == false`: +- Secret НЕ создаём (устройство не должно подключаться) +- Если Secret уже существует — НЕ удаляем (при re-enable пароль не изменится) +- Status: phase = "Disabled" +Это соответствует плану. + +**Стоп — перечитал план**: "Установить status.phase = 'Disabled' — НЕ удалять Secret". Значит если disabled — просто обновить статус, Secret остаётся. Принимаю. + +--- + +### Что создаю (Этап 1) + +1. `iot/api/v1alpha1/device_types.go` — CRD IoTDevice +2. `iot/api/v1alpha1/groupversion_info.go` — API group iot.kube5s.ru/v1alpha1 +3. `iot/controllers/iotdevice_controller.go` — контроллер +4. `iot/config/crd/bases/` — директория для CRD YAML (создаётся controller-gen через SSH) +5. Обновление `main.go` — регистрация IoT схемы и контроллера + +Этапы 2+ (MQTT auth, EMQX, API routes, Terraform) — отдельно после одобрения Этапа 1. + +--- + +### Результат выполнения Этапа 1 + +**Создано:** +- `iot/api/v1alpha1/device_types.go` — CRD IoTDevice с IoTDevicePhase константами +- `iot/api/v1alpha1/groupversion_info.go` — API group `iot.kube5s.ru/v1alpha1` +- `iot/api/v1alpha1/zz_generated.deepcopy.go` — сгенерирован controller-gen +- `iot/config/crd/bases/iot.kube5s.ru_iotdevices.yaml` — CRD манифест +- `iot/controllers/iotdevice_controller.go` — контроллер с полным Reconcile loop +- `main.go` — добавлена регистрация IoT схемы и контроллера + +**Проверка:** `go build ./...` — ошибок нет. + +**Отклонения от плана (с обоснованием):** +- Добавлен `+kubebuilder:validation:Pattern` на `DeviceID` — необходимо чтобы имя Secret `iot-{deviceId}` было валидным k8s именем. В плане не указано, но без валидации контроллер может сломаться. +- Использован `controllerutil.ContainsFinalizer/AddFinalizer/RemoveFinalizer` вместо ручных хелперов — более современный подход из controller-runtime. Старый код не трогал. +- Добавлена константа `IoTDevicePhase` (типизированный string) вместо plain string в плане — для type-safety и читаемости. + +--- + ## Задача: Решения по архитектуре IoT MVP ### Вопрос 1: Репозиторий — та же или новая? diff --git a/iot/api/v1alpha1/device_types.go b/iot/api/v1alpha1/device_types.go new file mode 100644 index 0000000..73057d0 --- /dev/null +++ b/iot/api/v1alpha1/device_types.go @@ -0,0 +1,99 @@ +// Создано: 2026-04-04 +// Описание CRD IoTDevice — регистрация IoT-устройства в платформе sless. +// Пользователь создаёт IoTDevice, контроллер автоматически генерирует MQTT-credentials +// и сохраняет их в k8s Secret в том же namespace. +// При удалении IoTDevice — Secret удаляется каскадно через OwnerReference. + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// IoTDeviceSpec — желаемое состояние IoT-устройства. +type IoTDeviceSpec struct { + // DeviceID — уникальный идентификатор устройства внутри namespace. + // Используется как часть MQTT username и имени Secret. + // Разрешены только строчные буквы, цифры и дефис — для совместимости с k8s именами. + // +kubebuilder:validation:Required + // +kubebuilder:validation:Pattern=`^[a-z0-9][a-z0-9-]*[a-z0-9]$` + // +kubebuilder:validation:MaxLength=48 + DeviceID string `json:"deviceId"` + + // Metadata — произвольные метаданные устройства (модель, локация и т.д.). + // Хранятся только в CRD, не влияют на логику контроллера. + // +optional + Metadata map[string]string `json:"metadata,omitempty"` + + // Enabled — активно ли устройство (может подключаться к MQTT). + // Если false — контроллер устанавливает phase=Disabled, EMQX auth отклоняет подключение. + // Secret с credentials НЕ удаляется — при re-enable пароль остаётся прежним. + // +kubebuilder:default=true + Enabled bool `json:"enabled"` +} + +// IoTDevicePhase — текущая фаза жизненного цикла устройства. +type IoTDevicePhase string + +const ( + // IoTDevicePhaseActive — устройство активно, credentials выданы, может подключаться к MQTT. + IoTDevicePhaseActive IoTDevicePhase = "Active" + // IoTDevicePhaseDisabled — устройство отключено (spec.enabled=false). EMQX отклоняет подключения. + IoTDevicePhaseDisabled IoTDevicePhase = "Disabled" + // IoTDevicePhasePending — устройство создано, контроллер ещё не успел сгенерировать credentials. + IoTDevicePhasePending IoTDevicePhase = "Pending" + // IoTDevicePhaseError — ошибка при создании credentials (см. status.message). + IoTDevicePhaseError IoTDevicePhase = "Error" +) + +// IoTDeviceStatus — наблюдаемое состояние IoT-устройства (заполняет контроллер). +type IoTDeviceStatus struct { + // Phase — текущее состояние: Active, Disabled, Pending, Error. + Phase IoTDevicePhase `json:"phase,omitempty"` + + // MQTTUsername — имя пользователя для подключения к MQTT-брокеру. + // Формат: {namespace}_{deviceId} — глобально уникален в рамках EMQX. + MQTTUsername string `json:"mqttUsername,omitempty"` + + // SecretName — имя k8s Secret в том же namespace, содержащего mqtt-username и mqtt-password. + SecretName string `json:"secretName,omitempty"` + + // TopicPrefix — MQTT topic prefix, на который разрешена публикация. + // Формат: {namespace}/ — устройство не может публиковать в чужие namespace. + TopicPrefix string `json:"topicPrefix,omitempty"` + + // LastConnected — время последнего MQTT-подключения устройства. + // Заполняется MQTT auth-сервисом при каждом успешном CONNECT. + // +optional + LastConnected *metav1.Time `json:"lastConnected,omitempty"` + + // Message — человекочитаемое сообщение о текущем статусе или ошибке. + Message string `json:"message,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="DeviceID",type=string,JSONPath=`.spec.deviceId` +// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase` +// +kubebuilder:printcolumn:name="Enabled",type=boolean,JSONPath=`.spec.enabled` +// +kubebuilder:printcolumn:name="MQTTUser",type=string,JSONPath=`.status.mqttUsername` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` + +// IoTDevice — ресурс для регистрации IoT-устройства в платформе. +// Контроллер автоматически создаёт k8s Secret с MQTT-credentials. +type IoTDevice struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec IoTDeviceSpec `json:"spec,omitempty"` + Status IoTDeviceStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// IoTDeviceList — список IoTDevice объектов. +type IoTDeviceList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []IoTDevice `json:"items"` +} diff --git a/iot/api/v1alpha1/groupversion_info.go b/iot/api/v1alpha1/groupversion_info.go new file mode 100644 index 0000000..a89d39e --- /dev/null +++ b/iot/api/v1alpha1/groupversion_info.go @@ -0,0 +1,30 @@ +// Создано: 2026-04-04 +// groupversion_info.go — регистрация API group iot.kube5s.ru/v1alpha1. +// ВАЖНО: группа намеренно отдельная от sless.kube5s.ru — при выносе IoT в +// отдельную репу CRD не будет конфликтовать с sless CRD. + +// Package v1alpha1 содержит API Schema definitions для группы iot.kube5s.ru. +// +kubebuilder:object:generate=true +// +groupName=iot.kube5s.ru +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + // GroupVersion — API group/version для IoT ресурсов. + // Отдельный от sless.kube5s.ru — для изоляции и будущего разделения репозиториев. + GroupVersion = schema.GroupVersion{Group: "iot.kube5s.ru", Version: "v1alpha1"} + + // SchemeBuilder регистрирует Go-типы в GroupVersionKind scheme. + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + + // AddToScheme добавляет типы этой API группы в scheme manager'а. + AddToScheme = SchemeBuilder.AddToScheme +) + +func init() { + SchemeBuilder.Register(&IoTDevice{}, &IoTDeviceList{}) +} diff --git a/iot/api/v1alpha1/zz_generated.deepcopy.go b/iot/api/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 0000000..501a8c8 --- /dev/null +++ b/iot/api/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,109 @@ +//go:build !ignore_autogenerated + +// Code generated by controller-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IoTDevice) DeepCopyInto(out *IoTDevice) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IoTDevice. +func (in *IoTDevice) DeepCopy() *IoTDevice { + if in == nil { + return nil + } + out := new(IoTDevice) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *IoTDevice) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IoTDeviceList) DeepCopyInto(out *IoTDeviceList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]IoTDevice, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IoTDeviceList. +func (in *IoTDeviceList) DeepCopy() *IoTDeviceList { + if in == nil { + return nil + } + out := new(IoTDeviceList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *IoTDeviceList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IoTDeviceSpec) DeepCopyInto(out *IoTDeviceSpec) { + *out = *in + if in.Metadata != nil { + in, out := &in.Metadata, &out.Metadata + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IoTDeviceSpec. +func (in *IoTDeviceSpec) DeepCopy() *IoTDeviceSpec { + if in == nil { + return nil + } + out := new(IoTDeviceSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IoTDeviceStatus) DeepCopyInto(out *IoTDeviceStatus) { + *out = *in + if in.LastConnected != nil { + in, out := &in.LastConnected, &out.LastConnected + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IoTDeviceStatus. +func (in *IoTDeviceStatus) DeepCopy() *IoTDeviceStatus { + if in == nil { + return nil + } + out := new(IoTDeviceStatus) + in.DeepCopyInto(out) + return out +} diff --git a/iot/config/crd/bases/iot.kube5s.ru_iotdevices.yaml b/iot/config/crd/bases/iot.kube5s.ru_iotdevices.yaml new file mode 100644 index 0000000..45ad7e5 --- /dev/null +++ b/iot/config/crd/bases/iot.kube5s.ru_iotdevices.yaml @@ -0,0 +1,123 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.14.0 + name: iotdevices.iot.kube5s.ru +spec: + group: iot.kube5s.ru + names: + kind: IoTDevice + listKind: IoTDeviceList + plural: iotdevices + singular: iotdevice + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.deviceId + name: DeviceID + type: string + - jsonPath: .status.phase + name: Phase + type: string + - jsonPath: .spec.enabled + name: Enabled + type: boolean + - jsonPath: .status.mqttUsername + name: MQTTUser + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + IoTDevice — ресурс для регистрации IoT-устройства в платформе. + Контроллер автоматически создаёт k8s Secret с MQTT-credentials. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: IoTDeviceSpec — желаемое состояние IoT-устройства. + properties: + deviceId: + description: |- + DeviceID — уникальный идентификатор устройства внутри namespace. + Используется как часть MQTT username и имени Secret. + Разрешены только строчные буквы, цифры и дефис — для совместимости с k8s именами. + maxLength: 48 + pattern: ^[a-z0-9][a-z0-9-]*[a-z0-9]$ + type: string + enabled: + default: true + description: |- + Enabled — активно ли устройство (может подключаться к MQTT). + Если false — контроллер устанавливает phase=Disabled, EMQX auth отклоняет подключение. + Secret с credentials НЕ удаляется — при re-enable пароль остаётся прежним. + type: boolean + metadata: + additionalProperties: + type: string + description: |- + Metadata — произвольные метаданные устройства (модель, локация и т.д.). + Хранятся только в CRD, не влияют на логику контроллера. + type: object + required: + - deviceId + - enabled + type: object + status: + description: IoTDeviceStatus — наблюдаемое состояние IoT-устройства (заполняет + контроллер). + properties: + lastConnected: + description: |- + LastConnected — время последнего MQTT-подключения устройства. + Заполняется MQTT auth-сервисом при каждом успешном CONNECT. + format: date-time + type: string + message: + description: Message — человекочитаемое сообщение о текущем статусе + или ошибке. + type: string + mqttUsername: + description: |- + MQTTUsername — имя пользователя для подключения к MQTT-брокеру. + Формат: {namespace}_{deviceId} — глобально уникален в рамках EMQX. + type: string + phase: + description: 'Phase — текущее состояние: Active, Disabled, Pending, + Error.' + type: string + secretName: + description: SecretName — имя k8s Secret в том же namespace, содержащего + mqtt-username и mqtt-password. + type: string + topicPrefix: + description: |- + TopicPrefix — MQTT topic prefix, на который разрешена публикация. + Формат: {namespace}/ — устройство не может публиковать в чужие namespace. + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/iot/controllers/iotdevice_controller.go b/iot/controllers/iotdevice_controller.go new file mode 100644 index 0000000..add4359 --- /dev/null +++ b/iot/controllers/iotdevice_controller.go @@ -0,0 +1,175 @@ +// Создано: 2026-04-04 +// iotdevice_controller.go — контроллер IoTDevice CRD. +// Логика Reconcile: +// 1. Добавить finalizer при первом создании +// 2. При удалении (DeletionTimestamp != nil) — убрать finalizer. +// Secret удалится автоматически по OwnerReference (cascade GC). +// 3. Если spec.enabled=false — установить phase=Disabled, не создавать Secret. +// Если Secret уже существует — НЕ удалять (при re-enable пароль сохранится). +// 4. Если Secret не существует — сгенерировать пароль, создать Secret с OwnerRef. +// 5. Обновить status: phase, mqttUsername, secretName, topicPrefix. + +package controllers + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" + + iotv1alpha1 "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/iot/api/v1alpha1" +) + +// iotDeviceFinalizer — имя finalizer для IoTDevice. +// Гарантирует что контроллер обработает удаление до того как k8s удалит объект. +const iotDeviceFinalizer = "iot.kube5s.ru/device-cleanup" + +// IoTDeviceReconciler управляет жизненным циклом IoTDevice CRD. +type IoTDeviceReconciler struct { + client.Client + Scheme *runtime.Scheme +} + +// +kubebuilder:rbac:groups=iot.kube5s.ru,resources=iotdevices,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=iot.kube5s.ru,resources=iotdevices/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=iot.kube5s.ru,resources=iotdevices/finalizers,verbs=update +// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;delete + +// Reconcile — главный цикл управления IoTDevice. +func (r *IoTDeviceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + // Читаем IoTDevice из k8s + device := &iotv1alpha1.IoTDevice{} + if err := r.Get(ctx, req.NamespacedName, device); err != nil { + if errors.IsNotFound(err) { + // Объект удалён — финализация уже завершена + return ctrl.Result{}, nil + } + return ctrl.Result{}, fmt.Errorf("get iotdevice: %w", err) + } + + // Если объект помечен к удалению — убираем finalizer. + // Secret удалится каскадно через OwnerReference — явно удалять не нужно. + if !device.DeletionTimestamp.IsZero() { + if controllerutil.ContainsFinalizer(device, iotDeviceFinalizer) { + controllerutil.RemoveFinalizer(device, iotDeviceFinalizer) + if err := r.Update(ctx, device); err != nil { + return ctrl.Result{}, fmt.Errorf("remove finalizer: %w", err) + } + logger.Info("removed finalizer from IoTDevice", "device", req.Name) + } + return ctrl.Result{}, nil + } + + // Добавляем finalizer при первом создании объекта + if !controllerutil.ContainsFinalizer(device, iotDeviceFinalizer) { + controllerutil.AddFinalizer(device, iotDeviceFinalizer) + if err := r.Update(ctx, device); err != nil { + return ctrl.Result{}, fmt.Errorf("add finalizer: %w", err) + } + // Requeue — обязательно, чтобы продолжить reconcile с обновлённым объектом + return ctrl.Result{Requeue: true}, nil + } + + // Если устройство отключено — обновляем статус, не создаём Secret + if !device.Spec.Enabled { + return ctrl.Result{}, r.setStatus(ctx, device, iotv1alpha1.IoTDevicePhaseDisabled, "Device is disabled") + } + + // Имя Secret и MQTT username по формату из плана + secretName := "iot-" + device.Spec.DeviceID + mqttUsername := fmt.Sprintf("%s_%s", device.Namespace, device.Spec.DeviceID) + + // Проверяем наличие Secret — если нет, создаём с новыми credentials + existingSecret := &corev1.Secret{} + err := r.Get(ctx, client.ObjectKey{Namespace: device.Namespace, Name: secretName}, existingSecret) + if errors.IsNotFound(err) { + // Генерируем криптографически стойкий пароль: 32 байта = 64 hex символа + password, genErr := generateMQTTPassword() + if genErr != nil { + setErr := r.setStatus(ctx, device, iotv1alpha1.IoTDevicePhaseError, fmt.Sprintf("generate password: %v", genErr)) + return ctrl.Result{}, setErr + } + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: device.Namespace, + }, + Data: map[string][]byte{ + "mqtt-username": []byte(mqttUsername), + "mqtt-password": []byte(password), + }, + } + + // OwnerReference — Secret принадлежит IoTDevice. + // При удалении IoTDevice k8s каскадно удалит Secret. + if ownerErr := controllerutil.SetControllerReference(device, secret, r.Scheme); ownerErr != nil { + return ctrl.Result{}, fmt.Errorf("set owner reference: %w", ownerErr) + } + + if createErr := r.Create(ctx, secret); createErr != nil { + setErr := r.setStatus(ctx, device, iotv1alpha1.IoTDevicePhaseError, fmt.Sprintf("create secret: %v", createErr)) + return ctrl.Result{}, setErr + } + logger.Info("created MQTT credentials Secret", "secret", secretName, "device", device.Spec.DeviceID) + + } else if err != nil { + // Неожиданная ошибка при чтении Secret + return ctrl.Result{}, fmt.Errorf("get secret: %w", err) + } + // else — Secret уже существует, ничего не делаем (пароль не меняем) + + // Обновляем status — отражаем актуальное состояние устройства + device.Status.Phase = iotv1alpha1.IoTDevicePhaseActive + device.Status.MQTTUsername = mqttUsername + device.Status.SecretName = secretName + device.Status.TopicPrefix = device.Namespace + "/" + device.Status.Message = "" + + if statusErr := r.Status().Update(ctx, device); statusErr != nil { + return ctrl.Result{}, fmt.Errorf("update status: %w", statusErr) + } + + return ctrl.Result{}, nil +} + +// setStatus — вспомогательная функция для обновления status.Phase и status.Message. +// Используется для промежуточных состояний (Disabled, Error). +func (r *IoTDeviceReconciler) setStatus(ctx context.Context, device *iotv1alpha1.IoTDevice, phase iotv1alpha1.IoTDevicePhase, message string) error { + device.Status.Phase = phase + device.Status.Message = message + if err := r.Status().Update(ctx, device); err != nil { + return fmt.Errorf("update status to %s: %w", phase, err) + } + return nil +} + +// generateMQTTPassword генерирует криптографически стойкий пароль для MQTT. +// 32 байта случайных данных → hex строка 64 символа = 256 бит энтропии. +func generateMQTTPassword() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("rand.Read: %w", err) + } + return hex.EncodeToString(buf), nil +} + +// SetupWithManager регистрирует контроллер в manager'е. +// Watches только IoTDevice — secrets обновляются через reconcile loop при необходимости. +func (r *IoTDeviceReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&iotv1alpha1.IoTDevice{}). + Owns(&corev1.Secret{}). + Complete(r) +} diff --git a/main.go b/main.go index 4550f3b..dfcfd7c 100644 --- a/main.go +++ b/main.go @@ -27,6 +27,8 @@ import ( slessv1alpha1 "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/api/v1alpha1" "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/controllers" + iotv1alpha1 "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/iot/api/v1alpha1" + iotcontrollers "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/iot/controllers" slessapi "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/api" "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/api/handler" "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/builder" @@ -45,6 +47,8 @@ func init() { utilruntime.Must(clientgoscheme.AddToScheme(scheme)) utilruntime.Must(slessv1alpha1.AddToScheme(scheme)) + // IoT API group iot.kube5s.ru/v1alpha1 — отдельная схема для IoT-ресурсов + utilruntime.Must(iotv1alpha1.AddToScheme(scheme)) //+kubebuilder:scaffold:scheme } @@ -190,6 +194,14 @@ func main() { log.Error("unable to create controller", "controller", "FunctionJob", "err", err) os.Exit(1) } + // IoTDevice контроллер — управляет MQTT credentials для IoT-устройств + if err = (&iotcontrollers.IoTDeviceReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + log.Error("unable to create controller", "controller", "IoTDevice", "err", err) + os.Exit(1) + } //+kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {