Files
sless/terraform/provider/internal/resources/service_resource.go
T
Naeel b86ff3a62e feat(service): timeout_sec без дефолта; 0=нет таймаута; operator v0.1.48
- api/v1alpha1/service_types.go: убрать +kubebuilder:default=30
- invoke.go: TimeoutSec=0 → &http.Client{} (без таймаута)
- services.go: валидация timeout_sec < 0 || > 900 → HTTP 400
- service_resource.go: TF schema Optional (без Computed); 0 → Int64Null()
- deployments/k8s/operator.yaml: v0.1.47 → v0.1.48
- doc/: progress.md + api/design.md (модель Service) + decisions/log.md
- examples/POSTGRES/: bug_hunter.sh, chaos_marathon.sh, chaos_marathon.tf
2026-03-21 16:58:43 +03:00

382 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Создано: 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),
}
}