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
@@ -547,3 +547,170 @@ func (c *Client) WaitJobDone(ctx context.Context, ns, name string, timeout time.
}
return nil, fmt.Errorf("timeout waiting for job %s/%s to complete", ns, name)
}
// --- Service CRUD (sless_service — long-running Deployment + URL) ---
// ServiceRequest — тело POST/PUT /v1/namespaces/{ns}/services[/{name}]
type ServiceRequest struct {
Name string `json:"name"`
Runtime string `json:"runtime"`
Entrypoint string `json:"entrypoint,omitempty"`
MemoryMB int32 `json:"memory_mb,omitempty"`
TimeoutSec int32 `json:"timeout_sec,omitempty"`
Env map[string]string `json:"env_vars,omitempty"`
}
// ServiceResponse — ответ GET /v1/namespaces/{ns}/services/{name}
// URL — ключевое поле: заполняется оператором после деплоя Deployment+Ingress.
type ServiceResponse struct {
Name string `json:"name"`
Namespace string `json:"namespace"`
Runtime string `json:"runtime"`
Entrypoint string `json:"entrypoint"`
MemoryMB int32 `json:"memory_mb"`
TimeoutSec int32 `json:"timeout_sec"`
Env map[string]string `json:"env_vars"`
S3Bucket string `json:"s3_bucket"`
S3Key string `json:"s3_key"`
Phase string `json:"phase"`
ImageRef string `json:"image_ref"`
URL string `json:"url"`
Message string `json:"message"`
}
// CreateService — POST /v1/namespaces/{ns}/services → 201
func (c *Client) CreateService(ctx context.Context, ns string, req ServiceRequest) (*ServiceResponse, error) {
url := fmt.Sprintf("%s/v1/namespaces/%s/services", c.endpoint, ns)
resp, err := c.doJSON(ctx, http.MethodPost, url, req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("create service: status %d: %s", resp.StatusCode, body)
}
var svc ServiceResponse
return &svc, json.NewDecoder(resp.Body).Decode(&svc)
}
// GetService — GET /v1/namespaces/{ns}/services/{name} → nil если 404
func (c *Client) GetService(ctx context.Context, ns, name string) (*ServiceResponse, error) {
url := fmt.Sprintf("%s/v1/namespaces/%s/services/%s", c.endpoint, ns, name)
resp, err := c.doJSON(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, nil
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("get service: status %d: %s", resp.StatusCode, body)
}
var svc ServiceResponse
return &svc, json.NewDecoder(resp.Body).Decode(&svc)
}
// UpdateService — PUT /v1/namespaces/{ns}/services/{name} → 200
func (c *Client) UpdateService(ctx context.Context, ns, name string, req ServiceRequest) (*ServiceResponse, error) {
url := fmt.Sprintf("%s/v1/namespaces/%s/services/%s", c.endpoint, ns, name)
resp, err := c.doJSON(ctx, http.MethodPut, url, req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("update service: status %d: %s", resp.StatusCode, body)
}
var svc ServiceResponse
return &svc, json.NewDecoder(resp.Body).Decode(&svc)
}
// DeleteService — DELETE /v1/namespaces/{ns}/services/{name} → 204
func (c *Client) DeleteService(ctx context.Context, ns, name string) error {
url := fmt.Sprintf("%s/v1/namespaces/%s/services/%s", c.endpoint, ns, name)
resp, err := c.doJSON(ctx, http.MethodDelete, url, nil)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusNotFound {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("delete service: status %d: %s", resp.StatusCode, body)
}
return nil
}
// UploadServiceCode — POST /v1/namespaces/{ns}/services/{name}/upload (multipart, field=code)
// После вызова оператор начинает kaniko-сборку и затем деплоит Deployment.
func (c *Client) UploadServiceCode(ctx context.Context, ns, name, zipPath string) error {
f, err := os.Open(zipPath)
if err != nil {
return fmt.Errorf("open zip %q: %w", zipPath, err)
}
defer f.Close()
return c.UploadServiceCodeReader(ctx, ns, name, filepath.Base(zipPath), f)
}
// UploadServiceCodeReader — загружает код сервиса из произвольного io.Reader.
func (c *Client) UploadServiceCodeReader(ctx context.Context, ns, name, filename string, r io.Reader) error {
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
fw, err := mw.CreateFormFile("code", filename)
if err != nil {
return fmt.Errorf("create form file: %w", err)
}
if _, err := io.Copy(fw, r); err != nil {
return fmt.Errorf("copy zip: %w", err)
}
mw.Close()
url := fmt.Sprintf("%s/v1/namespaces/%s/services/%s/upload", c.endpoint, ns, name)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, &buf)
if err != nil {
return err
}
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+c.token)
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("upload service code: status %d: %s", resp.StatusCode, body)
}
return nil
}
// WaitServiceReady опрашивает сервис каждые 5 секунд пока phase != Ready/Failed.
// Нужен после UploadServiceCode — kaniko-сборка + деплой занимают ~1-2 минуты.
func (c *Client) WaitServiceReady(ctx context.Context, ns, name string, timeout time.Duration) (*ServiceResponse, error) {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
svc, err := c.GetService(ctx, ns, name)
if err != nil {
return nil, err
}
if svc == nil {
return nil, fmt.Errorf("service %s/%s not found while waiting", ns, name)
}
switch svc.Phase {
case "Ready":
return svc, nil
case "Failed":
return nil, fmt.Errorf("service build failed: %s", svc.Message)
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(5 * time.Second):
}
}
return nil, fmt.Errorf("timeout waiting for service %s/%s to become Ready", ns, name)
}
@@ -1,4 +1,4 @@
// 2026-03-11
// 2026-03-20 (function-service-split: добавлен NewServiceResource)
// provider.go — описание провайдера sless для Terraform.
//
// Архитектура инициализации (Configure):
@@ -167,6 +167,7 @@ func (p *SlessProvider) Configure(ctx context.Context, req provider.ConfigureReq
func (p *SlessProvider) Resources(_ context.Context) []func() resource.Resource {
return []func() resource.Resource{
resources.NewFunctionResource,
resources.NewServiceResource,
resources.NewTriggerResource,
resources.NewJobResource,
}
@@ -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),
}
}