upload.go:
- Update → Patch (MergeFrom) to avoid resourceVersion conflict when operator
modifies Function between Get() and Update()
terraform provider v0.1.1:
- trigger_resource.go: trToModel returns StringNull() for empty schedule
(fixes 'provider produced inconsistent result' for http triggers)
- main.go: bump version to 0.1.1
examples/pg-query:
- handler.py: fix column name started_at → created_at (matches migrations/001)
- main.tf: pin provider to ~> 0.1.1
.gitignore: add terraform state, lock, .terraform/, handler.zip
doc/errors/log.md: documented all 5 errors from this session:
- resourceVersion conflict → use Patch
- terraform inconsistent result for schedule → StringNull
- terraform import not implemented → delete+recreate workaround
- wrong column name → check migrations before writing handlers
- Deployment not restarting after image rebuild → rollout restart / TODO: restartedAt annotation
doc/progress.md: terraform apply e2e ✅
192 lines
6.4 KiB
Go
192 lines
6.4 KiB
Go
// 2026-03-07
|
|
// trigger_resource.go — Terraform ресурс sless_trigger.
|
|
// Поддерживает type=http (создаёт Service+Ingress) и type=cron (запускает по schedule).
|
|
// Все ключевые поля имеют RequiresReplace — API не поддерживает обновление триггеров.
|
|
// URL функции (для http-триггера) — computed, пишется оператором в status.url.
|
|
package resources
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"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/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/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"`
|
|
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(),
|
|
},
|
|
},
|
|
// 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(),
|
|
},
|
|
},
|
|
// 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(),
|
|
})
|
|
if err != nil {
|
|
resp.Diagnostics.AddError("create trigger", err.Error())
|
|
return
|
|
}
|
|
|
|
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 — никогда не вызывается: все поля имеют RequiresReplace.
|
|
// Метод обязателен интерфейсом resource.Resource.
|
|
func (r *TriggerResource) Update(_ context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) {
|
|
resp.Diagnostics.AddError("update not supported", "all trigger fields require replacement")
|
|
}
|
|
|
|
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,
|
|
Active: types.BoolValue(tr.Active),
|
|
URL: types.StringValue(tr.URL),
|
|
}
|
|
}
|