// 2026-03-07 // job_resource.go — Terraform ресурс sless_job. // // Lifecycle: // // Create: POST /v1/namespaces/{ns}/jobs → WaitJobDone (10 мин) // Блокирует terraform apply до завершения джоба (Succeeded/Failed). // Если Failed — terraform apply падает с ошибкой. // Read: GET /v1/namespaces/{ns}/jobs/{name} → sync phase/timing в state // Delete: DELETE /v1/namespaces/{ns}/jobs/{name} // Семантически no-op (джоб уже выполнен), но убирает CR из кластера. // // Update не поддерживается — любое изменение name/function/event_json требует пересоздания. // Это корректно: job — одноразовое действие, нельзя "обновить" уже выполненное. package resources import ( "context" "fmt" "time" "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/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework/types" ) // jobTimeout — максимальное время ожидания завершения джоба. const jobTimeout = 10 * time.Minute var _ resource.Resource = &JobResource{} type JobResource struct { client *client.Client } func NewJobResource() resource.Resource { return &JobResource{} } // JobModel — модель состояния terraform для sless_job. type JobModel struct { Namespace types.String `tfsdk:"namespace"` Name types.String `tfsdk:"name"` Function types.String `tfsdk:"function"` EventJSON types.String `tfsdk:"event_json"` Phase types.String `tfsdk:"phase"` StartTime types.String `tfsdk:"start_time"` CompletionTime types.String `tfsdk:"completion_time"` Message types.String `tfsdk:"message"` } func (r *JobResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_job" } func (r *JobResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ MarkdownDescription: "Одноразовый запуск serverless функции. terraform apply блокируется до завершения джоба.", Attributes: map[string]schema.Attribute{ // Все input-поля immutable — джоб нельзя "изменить", только пересоздать. "namespace": schema.StringAttribute{ Required: true, PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, }, "name": schema.StringAttribute{ Required: true, PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, }, "function": schema.StringAttribute{ Required: true, MarkdownDescription: "Имя sless_function ресурса в том же namespace.", PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, }, "event_json": schema.StringAttribute{ Optional: true, MarkdownDescription: `JSON-объект передаваемый в handle(event). По умолчанию "{}".`, PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, }, // Computed — заполняются после завершения джоба "phase": schema.StringAttribute{ Computed: true, MarkdownDescription: "Фаза выполнения: Pending, Running, Succeeded, Failed.", }, "start_time": schema.StringAttribute{ Computed: true, MarkdownDescription: "Время запуска k8s Job (RFC3339).", }, "completion_time": schema.StringAttribute{ Computed: true, MarkdownDescription: "Время завершения k8s Job (RFC3339).", }, "message": schema.StringAttribute{ Computed: true, MarkdownDescription: "Результат выполнения или сообщение об ошибке.", }, }, } } func (r *JobResource) 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 *JobResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { var plan JobModel resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) if resp.Diagnostics.HasError() { return } ns := plan.Namespace.ValueString() eventJSON := plan.EventJSON.ValueString() if eventJSON == "" { eventJSON = "{}" } _, err := r.client.CreateJob(ctx, ns, client.JobRequest{ Name: plan.Name.ValueString(), FunctionRef: plan.Function.ValueString(), EventJSON: eventJSON, }) if err != nil { resp.Diagnostics.AddError("create job", err.Error()) return } // Блокируем apply до завершения джоба (Succeeded или Failed) j, err := r.client.WaitJobDone(ctx, ns, plan.Name.ValueString(), jobTimeout) if err != nil { resp.Diagnostics.AddError("waiting for job to complete", err.Error()) return } resp.Diagnostics.Append(resp.State.Set(ctx, jobToModel(plan, j))...) } func (r *JobResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { var state JobModel resp.Diagnostics.Append(req.State.Get(ctx, &state)...) if resp.Diagnostics.HasError() { return } j, err := r.client.GetJob(ctx, state.Namespace.ValueString(), state.Name.ValueString()) if err != nil { resp.Diagnostics.AddError("read job", err.Error()) return } if j == nil { // Джоб удалён вне terraform — убираем из state resp.State.RemoveResource(ctx) return } resp.Diagnostics.Append(resp.State.Set(ctx, jobToModel(state, j))...) } // Update не реализован — все поля поддерживают только RequiresReplace. // terraform-plugin-framework никогда не вызовет Update для этого ресурса. func (r *JobResource) Update(_ context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) { resp.Diagnostics.AddError("update not supported", "sless_job does not support in-place updates") } func (r *JobResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { var state JobModel resp.Diagnostics.Append(req.State.Get(ctx, &state)...) if resp.Diagnostics.HasError() { return } if err := r.client.DeleteJob(ctx, state.Namespace.ValueString(), state.Name.ValueString()); err != nil { resp.Diagnostics.AddError("delete job", err.Error()) } } // jobToModel конвертирует API-ответ → state модель. func jobToModel(plan JobModel, j *client.JobResponse) JobModel { return JobModel{ Namespace: types.StringValue(j.Namespace), Name: types.StringValue(j.Name), Function: types.StringValue(j.FunctionRef), EventJSON: plan.EventJSON, // API отдаёт event_json но берём из plan для консистентности Phase: types.StringValue(j.Phase), StartTime: types.StringValue(j.StartTime), CompletionTime: types.StringValue(j.CompletionTime), Message: types.StringValue(j.Message), } }