From b920dc5c9d8f49feb6da8c1c23830e994df9b895 Mon Sep 17 00:00:00 2001 From: Naeel Date: Sat, 4 Apr 2026 09:56:18 +0300 Subject: [PATCH] =?UTF-8?q?feat(iot):=20=D0=AD=D1=82=D0=B0=D0=BF=206=20?= =?UTF-8?q?=E2=80=94=20Terraform=20=D1=80=D0=B5=D1=81=D1=83=D1=80=D1=81=20?= =?UTF-8?q?sless=5Fiot=5Fdevice=20(=D0=BF=D1=80=D0=BE=D0=B2=D0=B0=D0=B9?= =?UTF-8?q?=D0=B4=D0=B5=D1=80=20v0.1.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- terraform/provider/internal/client/client.go | 132 +++++++++ .../provider/internal/provider/provider.go | 1 + .../internal/resources/iot_device_resource.go | 254 ++++++++++++++++++ terraform/provider/main.go | 6 +- 4 files changed, 390 insertions(+), 3 deletions(-) create mode 100644 terraform/provider/internal/resources/iot_device_resource.go diff --git a/terraform/provider/internal/client/client.go b/terraform/provider/internal/client/client.go index 920e7c4..32338a9 100644 --- a/terraform/provider/internal/client/client.go +++ b/terraform/provider/internal/client/client.go @@ -522,6 +522,138 @@ func (c *Client) DeleteJob(ctx context.Context, ns, name string) error { return nil } +// --- IoTDevice CRUD --- +// Изменено: 2026-04-04 + +// IoTDeviceRequest — тело POST /v1/namespaces/{ns}/iot/devices +type IoTDeviceRequest struct { + Name string `json:"name"` + DeviceID string `json:"device_id"` + Enabled *bool `json:"enabled"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// IoTDeviceUpdateRequest — тело PATCH /v1/namespaces/{ns}/iot/devices/{name} +type IoTDeviceUpdateRequest struct { + Enabled *bool `json:"enabled"` +} + +// IoTDeviceResponse — ответ GET /v1/namespaces/{ns}/iot/devices/{name} +// mqtt_password заполняется только если устройство в фазе Active. +type IoTDeviceResponse struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + DeviceID string `json:"device_id"` + Enabled bool `json:"enabled"` + Phase string `json:"phase"` + MQTTUsername string `json:"mqtt_username"` + MQTTPassword string `json:"mqtt_password"` + SecretName string `json:"secret_name"` + TopicPrefix string `json:"topic_prefix"` + LastConnected string `json:"last_connected,omitempty"` + Message string `json:"message,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + CreatedAt string `json:"created_at,omitempty"` +} + +// CreateIoTDevice — POST /v1/namespaces/{ns}/iot/devices → 201 +func (c *Client) CreateIoTDevice(ctx context.Context, ns string, req IoTDeviceRequest) (*IoTDeviceResponse, error) { + url := fmt.Sprintf("%s/v1/namespaces/%s/iot/devices", c.endpoint, ns) + resp, err := c.doJSON(ctx, http.MethodPost, url, req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("create iot device: status %d: %s", resp.StatusCode, body) + } + var d IoTDeviceResponse + return &d, json.NewDecoder(resp.Body).Decode(&d) +} + +// GetIoTDevice — GET /v1/namespaces/{ns}/iot/devices/{name} → nil если 404 +// Возвращает mqtt_password из Secret. После создания ждать phase=Active. +func (c *Client) GetIoTDevice(ctx context.Context, ns, name string) (*IoTDeviceResponse, error) { + url := fmt.Sprintf("%s/v1/namespaces/%s/iot/devices/%s", c.endpoint, ns, name) + resp, err := c.doJSON(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return nil, nil + } + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("get iot device: status %d: %s", resp.StatusCode, body) + } + var d IoTDeviceResponse + return &d, json.NewDecoder(resp.Body).Decode(&d) +} + +// DeleteIoTDevice — DELETE /v1/namespaces/{ns}/iot/devices/{name} → 204 +func (c *Client) DeleteIoTDevice(ctx context.Context, ns, name string) error { + url := fmt.Sprintf("%s/v1/namespaces/%s/iot/devices/%s", c.endpoint, ns, name) + resp, err := c.doJSON(ctx, http.MethodDelete, url, nil) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusNotFound { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("delete iot device: status %d: %s", resp.StatusCode, body) + } + return nil +} + +// UpdateIoTDevice — PATCH /v1/namespaces/{ns}/iot/devices/{name} → 200 +// Позволяет изменить enabled без пересоздания устройства. +func (c *Client) UpdateIoTDevice(ctx context.Context, ns, name string, req IoTDeviceUpdateRequest) (*IoTDeviceResponse, error) { + url := fmt.Sprintf("%s/v1/namespaces/%s/iot/devices/%s", c.endpoint, ns, name) + resp, err := c.doJSON(ctx, http.MethodPatch, url, req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("update iot device: status %d: %s", resp.StatusCode, body) + } + var d IoTDeviceResponse + return &d, json.NewDecoder(resp.Body).Decode(&d) +} + +// WaitIoTDeviceActive опрашивает устройство каждые 2 секунды пока phase != Active/Error/Disabled. +// Нужен после CreateIoTDevice — контроллер асинхронно создаёт Secret с credentials. +func (c *Client) WaitIoTDeviceActive(ctx context.Context, ns, name string, timeout time.Duration) (*IoTDeviceResponse, error) { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + d, err := c.GetIoTDevice(ctx, ns, name) + if err != nil { + return nil, err + } + if d == nil { + return nil, fmt.Errorf("iot device %s/%s not found while waiting", ns, name) + } + switch d.Phase { + case "Active": + return d, nil + case "Error": + return nil, fmt.Errorf("iot device provisioning failed: %s", d.Message) + case "Disabled": + // enabled=false при создании — credentials не создаются, это ожидаемо + return d, nil + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(2 * time.Second): + } + } + return nil, fmt.Errorf("timeout waiting for iot device %s/%s to become Active", ns, name) +} + // EnsureNamespace — POST /v1/namespaces/{ns}/ensure // Создаёт k8s namespace пользователя если не существует. Идемпотентен. // Вызывается ОДИН РАЗ из provider.Configure() до любых ресурсных операций. diff --git a/terraform/provider/internal/provider/provider.go b/terraform/provider/internal/provider/provider.go index daeaa1a..136b3ff 100644 --- a/terraform/provider/internal/provider/provider.go +++ b/terraform/provider/internal/provider/provider.go @@ -170,6 +170,7 @@ func (p *SlessProvider) Resources(_ context.Context) []func() resource.Resource resources.NewServiceResource, resources.NewTriggerResource, resources.NewJobResource, + resources.NewIoTDeviceResource, } } diff --git a/terraform/provider/internal/resources/iot_device_resource.go b/terraform/provider/internal/resources/iot_device_resource.go new file mode 100644 index 0000000..89517a7 --- /dev/null +++ b/terraform/provider/internal/resources/iot_device_resource.go @@ -0,0 +1,254 @@ +// Создано: 2026-04-04 +// iot_device_resource.go — Terraform ресурс sless_iot_device. +// +// Lifecycle: +// Create: POST /iot/devices → WaitIoTDeviceActive (30 сек) → сохранить credentials в state +// Read: GET /iot/devices/{name} → sync state (включая mqtt_password) +// Update: PATCH /iot/devices/{name} → только enabled (in-place, без пересоздания) +// Delete: DELETE /iot/devices/{name} → контроллер каскадно удалит Secret +// +// mqtt_password — sensitive, хранится в terraform state. +// Это осознанное решение: credentials нужны для конфигурации физического устройства. +// Пользователь их получает через terraform output. +// +// device_id — RequiresReplace: изменение deviceId = другое устройство (другой Secret, другой username). +// name — RequiresReplace: имя k8s объекта нельзя изменить. + +package resources + +import ( + "context" + "fmt" + "time" + + "terraform-provider-sless/internal/client" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// defaultIoTWaitSec — таймаут ожидания provisioning credentials контроллером. +const defaultIoTWaitSec = 30 * time.Second + +var _ resource.Resource = &IoTDeviceResource{} + +// IoTDeviceResource — Terraform ресурс sless_iot_device. +type IoTDeviceResource struct { + client *client.Client +} + +func NewIoTDeviceResource() resource.Resource { + return &IoTDeviceResource{} +} + +// IoTDeviceModel — модель terraform state для sless_iot_device. +type IoTDeviceModel struct { + Name types.String `tfsdk:"name"` + DeviceID types.String `tfsdk:"device_id"` + Enabled types.Bool `tfsdk:"enabled"` + Metadata types.Map `tfsdk:"metadata"` + Phase types.String `tfsdk:"phase"` + MQTTUsername types.String `tfsdk:"mqtt_username"` + // mqtt_password — sensitive: не отображается в terraform plan/apply output. + // Хранится в state (зашифрованном) — единственный способ передать в устройство. + MQTTPassword types.String `tfsdk:"mqtt_password"` + TopicPrefix types.String `tfsdk:"topic_prefix"` +} + +func (r *IoTDeviceResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_iot_device" +} + +func (r *IoTDeviceResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "IoT устройство в платформе sless. Контроллер автоматически генерирует MQTT credentials.", + Attributes: map[string]schema.Attribute{ + // name — имя k8s объекта IoTDevice (уникальное в namespace) + "name": schema.StringAttribute{ + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + // device_id — идентификатор устройства. Используется в MQTT username: {namespace}_{device_id} + // Только [a-z0-9-] — совпадает с валидацией в CRD (+kubebuilder:validation:Pattern). + "device_id": schema.StringAttribute{ + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + // enabled — включить/отключить MQTT подключения. in-place через PATCH. + "enabled": schema.BoolAttribute{ + Optional: true, + Computed: true, + Default: booldefault.StaticBool(true), + }, + // metadata — произвольные метаданные (модель устройства, локация и т.д.) + "metadata": schema.MapAttribute{ + ElementType: types.StringType, + Optional: true, + Computed: true, + }, + // Computed — заполняются оператором после прохождения reconcile + "phase": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "mqtt_username": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + // mqtt_password — sensitive because it is a secret credential. + // Хранится в terraform state. Доступен через: terraform output -raw mqtt_password + "mqtt_password": schema.StringAttribute{ + Computed: true, + Sensitive: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "topic_prefix": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, + } +} + +func (r *IoTDeviceResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + c, ok := req.ProviderData.(*client.Client) + if !ok { + resp.Diagnostics.AddError( + "unexpected provider data", + fmt.Sprintf("expected *client.Client, got: %T", req.ProviderData), + ) + return + } + r.client = c +} + +func (r *IoTDeviceResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan IoTDeviceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + // Конвертируем metadata из types.Map в map[string]string + metadata := make(map[string]string) + if !plan.Metadata.IsNull() && !plan.Metadata.IsUnknown() { + resp.Diagnostics.Append(plan.Metadata.ElementsAs(ctx, &metadata, false)...) + if resp.Diagnostics.HasError() { + return + } + } + + enabled := plan.Enabled.ValueBool() + _, err := r.client.CreateIoTDevice(ctx, r.client.Namespace, client.IoTDeviceRequest{ + Name: plan.Name.ValueString(), + DeviceID: plan.DeviceID.ValueString(), + Enabled: &enabled, + Metadata: metadata, + }) + if err != nil { + resp.Diagnostics.AddError("create iot device", err.Error()) + return + } + + // Ждём пока контроллер сгенерирует MQTT credentials (phase=Active). + // Без ожидания mqtt_password будет пустым в state — terraform output выдаст "". + d, err := r.client.WaitIoTDeviceActive(ctx, r.client.Namespace, plan.Name.ValueString(), defaultIoTWaitSec) + if err != nil { + resp.Diagnostics.AddError("wait iot device active", err.Error()) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, deviceToModel(d))...) +} + +func (r *IoTDeviceResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state IoTDeviceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + d, err := r.client.GetIoTDevice(ctx, r.client.Namespace, state.Name.ValueString()) + if err != nil { + resp.Diagnostics.AddError("read iot device", err.Error()) + return + } + if d == nil { + resp.State.RemoveResource(ctx) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, deviceToModel(d))...) +} + +// Update — изменяет только enabled (единственное поле без RequiresReplace). +func (r *IoTDeviceResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan IoTDeviceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + enabled := plan.Enabled.ValueBool() + d, err := r.client.UpdateIoTDevice(ctx, r.client.Namespace, plan.Name.ValueString(), client.IoTDeviceUpdateRequest{ + Enabled: &enabled, + }) + if err != nil { + resp.Diagnostics.AddError("update iot device", err.Error()) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, deviceToModel(d))...) +} + +func (r *IoTDeviceResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state IoTDeviceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + if err := r.client.DeleteIoTDevice(ctx, r.client.Namespace, state.Name.ValueString()); err != nil { + resp.Diagnostics.AddError("delete iot device", err.Error()) + return + } +} + +// deviceToModel конвертирует API ответ в terraform state модель. +func deviceToModel(d *client.IoTDeviceResponse) IoTDeviceModel { + metadataMap := make(map[string]string) + if d.Metadata != nil { + metadataMap = d.Metadata + } + metadataValue, _ := types.MapValueFrom(context.Background(), types.StringType, metadataMap) + + return IoTDeviceModel{ + Name: types.StringValue(d.Name), + DeviceID: types.StringValue(d.DeviceID), + Enabled: types.BoolValue(d.Enabled), + Metadata: metadataValue, + Phase: types.StringValue(d.Phase), + MQTTUsername: types.StringValue(d.MQTTUsername), + MQTTPassword: types.StringValue(d.MQTTPassword), + TopicPrefix: types.StringValue(d.TopicPrefix), + } +} diff --git a/terraform/provider/main.go b/terraform/provider/main.go index a1084eb..2befca1 100644 --- a/terraform/provider/main.go +++ b/terraform/provider/main.go @@ -1,4 +1,4 @@ -// 2026-03-07 +// 2026-04-04 — добавлен ресурс sless_iot_device // main.go — точка входа Terraform провайдера sless. // Address: terra.k8c.ru/naeel/sless — путь в реестре terra.k8c.ru. package main @@ -12,8 +12,8 @@ import ( "github.com/hashicorp/terraform-plugin-framework/providerserver" ) -// version задаётся ldflags при сборке: -ldflags "-X main.version=0.1.1" -var version string = "0.1.1" +// version задаётся ldflags при сборке: -ldflags "-X main.version=0.1.2" +var version string = "0.1.2" func main() { opts := providerserver.ServeOpts{