// Создано: 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), } }