Files
sless/terraform/provider/internal/resources/trigger_resource.go
T
“Naeel” 8602b023fc fix: provider v0.1.8 — wait for trigger URL after create
trigger_resource.go: after CreateTrigger, if URL is empty (HTTP trigger),
retry GetTrigger every 2s up to 30s until controller sets status.URL.
Without this, URL in terraform state was always empty after apply.
2026-03-09 09:58:37 +04:00

248 lines
8.6 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 {
Namespace types.String `tfsdk:"namespace"`
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{
"namespace": schema.StringAttribute{
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"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, plan.Namespace.ValueString(), 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 := plan.Namespace.ValueString()
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, state.Namespace.ValueString(), 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, plan.Namespace.ValueString(), 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, state.Namespace.ValueString(), 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{
Namespace: types.StringValue(tr.Namespace),
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
}