- 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
382 lines
13 KiB
Go
382 lines
13 KiB
Go
// Создано: 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),
|
||
}
|
||
}
|