feat: sless_service CRD + ServiceReconciler, RBAC fix, split postgres/functions.tf, operator v0.1.41

This commit is contained in:
Naeel
2026-03-20 13:03:12 +03:00
parent dc65f7ab8f
commit 680beb675b
19 changed files with 2448 additions and 693 deletions
@@ -0,0 +1,373 @@
// Создано: 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 для сервиса = прокси-таймаут invoke (не таймаут Job).
// При длинных операциях (batch) увеличивай.
"timeout_sec": schema.Int64Attribute{
Optional: true,
Computed: true,
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)
}
return ServiceModel{
Name: types.StringValue(svc.Name),
Runtime: types.StringValue(svc.Runtime),
Entrypoint: types.StringValue(svc.Entrypoint),
MemoryMB: types.Int64Value(int64(svc.MemoryMB)),
TimeoutSec: types.Int64Value(int64(svc.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),
}
}