feat(iot): Этап 1 — CRD IoTDevice + контроллер credentials
- iot/api/v1alpha1/device_types.go — CRD IoTDevice - iot/api/v1alpha1/groupversion_info.go — API group iot.kube5s.ru/v1alpha1 - iot/api/v1alpha1/zz_generated.deepcopy.go — deepcopy (controller-gen) - iot/config/crd/bases/iot.kube5s.ru_iotdevices.yaml — CRD манифест - iot/controllers/iotdevice_controller.go — Reconcile: Secret с MQTT credentials - main.go — регистрация IoT схемы и IoTDeviceReconciler go build ./... — ошибок нет
This commit is contained in:
@@ -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
|
## Задача: Решения по архитектуре IoT MVP
|
||||||
|
|
||||||
### Вопрос 1: Репозиторий — та же или новая?
|
### Вопрос 1: Репозиторий — та же или новая?
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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{})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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: {}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -27,6 +27,8 @@ import (
|
|||||||
|
|
||||||
slessv1alpha1 "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/api/v1alpha1"
|
slessv1alpha1 "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/api/v1alpha1"
|
||||||
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/controllers"
|
"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"
|
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/api/handler"
|
||||||
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/builder"
|
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/builder"
|
||||||
@@ -45,6 +47,8 @@ func init() {
|
|||||||
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
|
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
|
||||||
|
|
||||||
utilruntime.Must(slessv1alpha1.AddToScheme(scheme))
|
utilruntime.Must(slessv1alpha1.AddToScheme(scheme))
|
||||||
|
// IoT API group iot.kube5s.ru/v1alpha1 — отдельная схема для IoT-ресурсов
|
||||||
|
utilruntime.Must(iotv1alpha1.AddToScheme(scheme))
|
||||||
//+kubebuilder:scaffold:scheme
|
//+kubebuilder:scaffold:scheme
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,6 +194,14 @@ func main() {
|
|||||||
log.Error("unable to create controller", "controller", "FunctionJob", "err", err)
|
log.Error("unable to create controller", "controller", "FunctionJob", "err", err)
|
||||||
os.Exit(1)
|
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
|
//+kubebuilder:scaffold:builder
|
||||||
|
|
||||||
if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
|
if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user