// 2026-03-08, обновлено 2025-06-05 // trigger_resource.go — Terraform ресурс sless_trigger. // Поддерживает type=http (создаёт Service+Ingress) и type=cron (запускает по schedule). // enabled=false: масштабирует Deployment функции до 0 (не принимает запросы, не потребляет ресурсы). // enabled не требует RequiresReplace — поддерживает in-place обновление через PATCH. package resources import ( "context" "fmt" "time" "terraform-provider-sless/internal/client" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "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/boolplanmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" ) var _ resource.Resource = &TriggerResource{} type TriggerResource struct { client *client.Client } func NewTriggerResource() resource.Resource { return &TriggerResource{} } // TriggerModel — модель состояния terraform для sless_trigger. type TriggerModel struct { Name types.String `tfsdk:"name"` Type types.String `tfsdk:"type"` FunctionRef types.String `tfsdk:"function"` Schedule types.String `tfsdk:"schedule"` // Enabled — false = Deployment масштабируется до 0, функция не потребляет ресурсы. // Не требует пересоздания - изменяется in-place через PATCH. Enabled types.Bool `tfsdk:"enabled"` Active types.Bool `tfsdk:"active"` URL types.String `tfsdk:"url"` } func (r *TriggerResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_trigger" } func (r *TriggerResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ Attributes: map[string]schema.Attribute{ "name": schema.StringAttribute{ Required: true, PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, }, // type: "http" или "cron" "type": schema.StringAttribute{ Required: true, PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, Validators: []validator.String{ stringvalidator.OneOf("http", "cron"), }, }, // function: имя Function CRD в том же namespace "function": schema.StringAttribute{ Required: true, PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, }, // schedule: только для type=cron, формат cron ("*/5 * * * *") "schedule": schema.StringAttribute{ Optional: true, PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, }, // enabled: false → Deployment масштабируется до 0 (не удаляет ресурс). // Не имеет RequiresReplace: применяется in-place через PATCH. "enabled": schema.BoolAttribute{ Optional: true, Computed: true, Default: booldefault.StaticBool(true), }, // active, url — только для чтения, вычисляются оператором "active": schema.BoolAttribute{ Computed: true, PlanModifiers: []planmodifier.Bool{ boolplanmodifier.UseStateForUnknown(), }, }, "url": schema.StringAttribute{ Computed: true, PlanModifiers: []planmodifier.String{ stringplanmodifier.UseStateForUnknown(), }, }, }, } } func (r *TriggerResource) 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 *TriggerResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { var plan TriggerModel resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) if resp.Diagnostics.HasError() { return } tr, err := r.client.CreateTrigger(ctx, r.client.Namespace, client.TriggerRequest{ Name: plan.Name.ValueString(), Type: plan.Type.ValueString(), FunctionRef: plan.FunctionRef.ValueString(), Schedule: plan.Schedule.ValueString(), Enabled: boolPtr(plan.Enabled.ValueBool()), }) if err != nil { resp.Diagnostics.AddError("create trigger", err.Error()) return } // Контроллер заполняет status.URL асинхронно — ждём до 30 секунд. // Без этого URL остаётся пустым в state и требует ручного terraform refresh. if tr.URL == "" && plan.Type.ValueString() == "http" { ns := r.client.Namespace name := plan.Name.ValueString() deadline := time.Now().Add(30 * time.Second) for time.Now().Before(deadline) { time.Sleep(2 * time.Second) got, gerr := r.client.GetTrigger(ctx, ns, name) if gerr == nil && got != nil && got.URL != "" { tr = got break } } } resp.Diagnostics.Append(resp.State.Set(ctx, trToModel(tr))...) } func (r *TriggerResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { var state TriggerModel resp.Diagnostics.Append(req.State.Get(ctx, &state)...) if resp.Diagnostics.HasError() { return } tr, err := r.client.GetTrigger(ctx, r.client.Namespace, state.Name.ValueString()) if err != nil { resp.Diagnostics.AddError("read trigger", err.Error()) return } if tr == nil { resp.State.RemoveResource(ctx) return } resp.Diagnostics.Append(resp.State.Set(ctx, trToModel(tr))...) } // Update — обновляет enabled через PATCH без пересоздания. // единственное поле без RequiresReplace, поэтому Update срабатывает только если enabled изменился. func (r *TriggerResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { var plan, state TriggerModel resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) resp.Diagnostics.Append(req.State.Get(ctx, &state)...) if resp.Diagnostics.HasError() { return } enabled := plan.Enabled.ValueBool() tr, err := r.client.UpdateTrigger(ctx, r.client.Namespace, plan.Name.ValueString(), client.TriggerUpdateRequest{ Enabled: &enabled, }) if err != nil { resp.Diagnostics.AddError("update trigger", err.Error()) return } resp.Diagnostics.Append(resp.State.Set(ctx, trToModel(tr))...) } func (r *TriggerResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { var state TriggerModel resp.Diagnostics.Append(req.State.Get(ctx, &state)...) if resp.Diagnostics.HasError() { return } name := state.Name.ValueString() if err := r.client.DeleteTrigger(ctx, r.client.Namespace, name); err != nil { resp.Diagnostics.AddError("delete trigger", err.Error()) return } // Ждём, пока триггер исчезнет из API. // Финалайзер контроллера снимается ПОСЛЕ того, как Service+Ingress удалены, // поэтому когда GetTrigger возвращает nil (404) — маршрут уже убран и endpoint мёртв. deadline := time.Now().Add(90 * time.Second) for time.Now().Before(deadline) { tr, err := r.client.GetTrigger(ctx, r.client.Namespace, name) if err != nil { resp.Diagnostics.AddError("wait trigger gone", err.Error()) return } if tr == nil { return // триггер исчез — cleanup завершён } time.Sleep(3 * time.Second) } resp.Diagnostics.AddError("wait trigger gone", fmt.Sprintf("trigger %s still exists after 90s", name)) } // trToModel конвертирует API-ответ → state модель. // schedule: возвращаем Null если пустая строка — terraform требует null для Optional-полей, // которые не заданы в конфиге (иначе план: null → apply: "" → "inconsistent result"). func trToModel(tr *client.TriggerResponse) TriggerModel { schedule := types.StringNull() if tr.Schedule != "" { schedule = types.StringValue(tr.Schedule) } return TriggerModel{ Name: types.StringValue(tr.Name), Type: types.StringValue(tr.Type), FunctionRef: types.StringValue(tr.FunctionRef), Schedule: schedule, Enabled: types.BoolValue(tr.Enabled), Active: types.BoolValue(tr.Active), URL: types.StringValue(tr.URL), } } // boolPtr — хелпер для получения указателя на bool (API требует *bool). func boolPtr(v bool) *bool { return &v }