// Создано: 2026-03-20 (function-service-split) // service_resource.go — Terraform ресурс sless_service. // sless_service = long-running Deployment + постоянный URL. // Отличие от sless_function: у Service есть url в state (заполняет оператор после деплоя). // // Lifecycle: // Create: POST /services → если code_path/source_dir задан: upload zip → WaitServiceReady // Read: GET /services/{name} → sync state (включая url) // Update: PUT /services/{name} → если code_hash изменился: upload zip → WaitServiceReady // Delete: DELETE /services/{name} // // code_hash, source_dir, build_timeout_sec — логика идентична FunctionResource. package resources import ( "bytes" "context" "fmt" "time" "terraform-provider-sless/internal/client" "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" "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/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 = &ServiceResource{} var _ resource.ResourceWithModifyPlan = &ServiceResource{} type ServiceResource struct { client *client.Client } func NewServiceResource() resource.Resource { return &ServiceResource{} } // ServiceModel — модель состояния terraform для sless_service. // Добавлено поле URL — заполняется оператором, только для чтения. type ServiceModel struct { Name types.String `tfsdk:"name"` Runtime types.String `tfsdk:"runtime"` Entrypoint types.String `tfsdk:"entrypoint"` MemoryMB types.Int64 `tfsdk:"memory_mb"` TimeoutSec types.Int64 `tfsdk:"timeout_sec"` EnvVars types.Map `tfsdk:"env_vars"` CodePath types.String `tfsdk:"code_path"` SourceDir types.String `tfsdk:"source_dir"` CodeHash types.String `tfsdk:"code_hash"` BuildTimeoutSec types.Int64 `tfsdk:"build_timeout_sec"` Phase types.String `tfsdk:"phase"` ImageRef types.String `tfsdk:"image_ref"` // URL — публичный URL сервиса. Заполняется оператором после деплоя. // Доступен сразу без sless_trigger (в отличие от sless_function). URL types.String `tfsdk:"url"` } func (r *ServiceResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_service" } func (r *ServiceResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ MarkdownDescription: "Long-running serverless service с постоянным URL. Разворачивается как k8s Deployment + Ingress.", Attributes: map[string]schema.Attribute{ "name": schema.StringAttribute{ Required: true, PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, }, "runtime": schema.StringAttribute{ Required: true, PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, Validators: []validator.String{ stringvalidator.OneOf("nodejs20", "python3.11", "go1.23"), }, }, "entrypoint": schema.StringAttribute{ Optional: true, Computed: true, PlanModifiers: []planmodifier.String{ stringplanmodifier.UseStateForUnknown(), }, }, "memory_mb": schema.Int64Attribute{ Optional: true, Computed: true, Validators: []validator.Int64{ int64validator.Between(1, 4096), }, }, // timeout_sec — лимит времени выполнения запроса в секундах. // Если не задан (null) — таймаута нет, запрос может выполняться бесконечно. // Диапазон 1–900. Для batch-операций задавай явно, например 120. "timeout_sec": schema.Int64Attribute{ Optional: true, MarkdownDescription: "Таймаут HTTP-вызова в секундах (1–900). Если не задан — нет ограничения.", Validators: []validator.Int64{ int64validator.Between(1, 900), }, }, "env_vars": schema.MapAttribute{ ElementType: types.StringType, Optional: true, }, "code_path": schema.StringAttribute{ Optional: true, }, "source_dir": schema.StringAttribute{ Optional: true, MarkdownDescription: "Путь к директории с исходным кодом. Провайдер сам упакует в zip.", }, "code_hash": schema.StringAttribute{ Optional: true, Computed: true, }, "build_timeout_sec": schema.Int64Attribute{ Optional: true, Computed: true, MarkdownDescription: "Таймаут ожидания сборки и деплоя в секундах. По умолчанию 300.", }, "phase": schema.StringAttribute{ Computed: true, PlanModifiers: []planmodifier.String{ stringplanmodifier.UseStateForUnknown(), }, }, "image_ref": schema.StringAttribute{ Computed: true, }, // url — только для чтения, вычисляется оператором. // Используй в outputs или как зависимость для других ресурсов. "url": schema.StringAttribute{ Computed: true, PlanModifiers: []planmodifier.String{ stringplanmodifier.UseStateForUnknown(), }, }, }, } } func (r *ServiceResource) 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 *ServiceResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { var plan ServiceModel resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) if resp.Diagnostics.HasError() { return } ns := r.client.Namespace envVars, d := mapToStringMap(ctx, plan.EnvVars) resp.Diagnostics.Append(d...) if resp.Diagnostics.HasError() { return } svc, err := r.client.CreateService(ctx, ns, client.ServiceRequest{ Name: plan.Name.ValueString(), Runtime: plan.Runtime.ValueString(), Entrypoint: plan.Entrypoint.ValueString(), MemoryMB: int32(plan.MemoryMB.ValueInt64()), TimeoutSec: int32(plan.TimeoutSec.ValueInt64()), Env: envVars, }) if err != nil { resp.Diagnostics.AddError("create service", err.Error()) return } var codeUploaded bool if !plan.SourceDir.IsNull() && plan.SourceDir.ValueString() != "" { zipData, hash, err := zipDir(plan.SourceDir.ValueString()) if err != nil { resp.Diagnostics.AddError("zip source_dir", err.Error()) return } if err := r.client.UploadServiceCodeReader(ctx, ns, svc.Name, "code.zip", bytes.NewReader(zipData)); err != nil { resp.Diagnostics.AddError("upload service code", err.Error()) return } plan.CodeHash = types.StringValue(hash) codeUploaded = true } else if !plan.CodePath.IsNull() && plan.CodePath.ValueString() != "" { if err := r.client.UploadServiceCode(ctx, ns, svc.Name, plan.CodePath.ValueString()); err != nil { resp.Diagnostics.AddError("upload service code", err.Error()) return } codeUploaded = true } if codeUploaded { buildSec := plan.BuildTimeoutSec.ValueInt64() if buildSec <= 0 { buildSec = defaultBuildTimeoutSec } svc, err = r.client.WaitServiceReady(ctx, ns, svc.Name, time.Duration(buildSec)*time.Second) if err != nil { resp.Diagnostics.AddError("waiting for service ready", err.Error()) return } } resp.Diagnostics.Append(resp.State.Set(ctx, svcToModel(plan, svc))...) } func (r *ServiceResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { var state ServiceModel resp.Diagnostics.Append(req.State.Get(ctx, &state)...) if resp.Diagnostics.HasError() { return } svc, err := r.client.GetService(ctx, r.client.Namespace, state.Name.ValueString()) if err != nil { resp.Diagnostics.AddError("read service", err.Error()) return } if svc == nil { resp.State.RemoveResource(ctx) return } resp.Diagnostics.Append(resp.State.Set(ctx, svcToModel(state, svc))...) } func (r *ServiceResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { var plan, state ServiceModel resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) resp.Diagnostics.Append(req.State.Get(ctx, &state)...) if resp.Diagnostics.HasError() { return } ns := r.client.Namespace name := plan.Name.ValueString() envVars, d := mapToStringMap(ctx, plan.EnvVars) resp.Diagnostics.Append(d...) if resp.Diagnostics.HasError() { return } svc, err := r.client.UpdateService(ctx, ns, name, client.ServiceRequest{ Name: name, Runtime: plan.Runtime.ValueString(), Entrypoint: plan.Entrypoint.ValueString(), MemoryMB: int32(plan.MemoryMB.ValueInt64()), TimeoutSec: int32(plan.TimeoutSec.ValueInt64()), Env: envVars, }) if err != nil { resp.Diagnostics.AddError("update service", err.Error()) return } var codeUploaded bool if !plan.SourceDir.IsNull() && plan.SourceDir.ValueString() != "" { zipData, hash, err := zipDir(plan.SourceDir.ValueString()) if err != nil { resp.Diagnostics.AddError("zip source_dir", err.Error()) return } newHash := types.StringValue(hash) if !newHash.Equal(state.CodeHash) { if err := r.client.UploadServiceCodeReader(ctx, ns, name, "code.zip", bytes.NewReader(zipData)); err != nil { resp.Diagnostics.AddError("upload service code", err.Error()) return } codeUploaded = true } plan.CodeHash = newHash } else if !plan.CodeHash.Equal(state.CodeHash) && !plan.CodePath.IsNull() && plan.CodePath.ValueString() != "" { if err := r.client.UploadServiceCode(ctx, ns, name, plan.CodePath.ValueString()); err != nil { resp.Diagnostics.AddError("upload service code", err.Error()) return } codeUploaded = true } if codeUploaded { buildSec := plan.BuildTimeoutSec.ValueInt64() if buildSec <= 0 { buildSec = defaultBuildTimeoutSec } svc, err = r.client.WaitServiceReady(ctx, ns, name, time.Duration(buildSec)*time.Second) if err != nil { resp.Diagnostics.AddError("waiting for service ready", err.Error()) return } } resp.Diagnostics.Append(resp.State.Set(ctx, svcToModel(plan, svc))...) } func (r *ServiceResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { var state ServiceModel resp.Diagnostics.Append(req.State.Get(ctx, &state)...) if resp.Diagnostics.HasError() { return } if err := r.client.DeleteService(ctx, r.client.Namespace, state.Name.ValueString()); err != nil { resp.Diagnostics.AddError("delete service", err.Error()) } } // ModifyPlan вычисляет hash директории source_dir на фазе plan. // Идентична FunctionResource.ModifyPlan — без этого terraform не видит изменения кода. func (r *ServiceResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { if req.Plan.Raw.IsNull() { return } var plan ServiceModel resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) if resp.Diagnostics.HasError() { return } if plan.SourceDir.IsNull() || plan.SourceDir.ValueString() == "" { return } _, hash, err := zipDir(plan.SourceDir.ValueString()) if err != nil { return } plan.CodeHash = types.StringValue(hash) resp.Diagnostics.Append(resp.Plan.Set(ctx, plan)...) } // svcToModel конвертирует API-ответ + plan → state модель. // URL берётся из API (заполняется оператором), остальные read-only поля — тоже. func svcToModel(plan ServiceModel, svc *client.ServiceResponse) ServiceModel { buildTimeoutSec := plan.BuildTimeoutSec if buildTimeoutSec.IsNull() || buildTimeoutSec.IsUnknown() || buildTimeoutSec.ValueInt64() <= 0 { buildTimeoutSec = types.Int64Value(defaultBuildTimeoutSec) } // TimeoutSec: 0 в API означает «пользователь не установил лимит» → null в state. var timeoutSec types.Int64 if svc.TimeoutSec > 0 { timeoutSec = types.Int64Value(int64(svc.TimeoutSec)) } else { timeoutSec = types.Int64Null() } return ServiceModel{ Name: types.StringValue(svc.Name), Runtime: types.StringValue(svc.Runtime), Entrypoint: types.StringValue(svc.Entrypoint), MemoryMB: types.Int64Value(int64(svc.MemoryMB)), TimeoutSec: timeoutSec, EnvVars: plan.EnvVars, CodePath: plan.CodePath, SourceDir: plan.SourceDir, CodeHash: plan.CodeHash, BuildTimeoutSec: buildTimeoutSec, Phase: types.StringValue(svc.Phase), ImageRef: types.StringValue(svc.ImageRef), URL: types.StringValue(svc.URL), } }