Files
sless/terraform/provider/internal/resources/trigger_resource.go
T

240 lines
8.3 KiB
Go

// 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
}
if err := r.client.DeleteTrigger(ctx, r.client.Namespace, state.Name.ValueString()); err != nil {
resp.Diagnostics.AddError("delete trigger", err.Error())
}
}
// 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
}