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.
248 lines
8.6 KiB
Go
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
|
|
}
|