From 680beb675b75c97dfc427e1cdbbe763d521916dd Mon Sep 17 00:00:00 2001 From: Naeel Date: Fri, 20 Mar 2026 13:03:12 +0300 Subject: [PATCH] feat: sless_service CRD + ServiceReconciler, RBAC fix, split postgres/functions.tf, operator v0.1.41 --- api/v1alpha1/service_types.go | 110 +++++ api/v1alpha1/zz_generated.deepcopy.go | 109 ++++- .../crd/bases/sless.kube5s.ru_services.yaml | 199 ++++++++ .../crd/bases/sless.kube5s.ru_triggers.yaml | 11 +- controllers/function_controller.go | 193 +------- controllers/service_controller.go | 459 ++++++++++++++++++ deployments/k8s/operator.yaml | 2 +- deployments/k8s/rbac.yaml | 8 +- doc/progress.md | 128 ++++- examples/POSTGRES/functions.tf | 280 +++++++++++ examples/POSTGRES/postgres.tf | 54 +++ examples/POSTGRES/resources.tf | 410 +--------------- internal/api/handler/invoke.go | 304 ++++++++---- internal/api/handler/services.go | 305 ++++++++++++ internal/api/router.go | 10 +- main.go | 16 +- terraform/provider/internal/client/client.go | 167 +++++++ .../provider/internal/provider/provider.go | 3 +- .../internal/resources/service_resource.go | 373 ++++++++++++++ 19 files changed, 2448 insertions(+), 693 deletions(-) create mode 100644 api/v1alpha1/service_types.go create mode 100644 config/crd/bases/sless.kube5s.ru_services.yaml create mode 100644 controllers/service_controller.go create mode 100644 examples/POSTGRES/functions.tf create mode 100644 examples/POSTGRES/postgres.tf create mode 100644 internal/api/handler/services.go create mode 100644 terraform/provider/internal/resources/service_resource.go diff --git a/api/v1alpha1/service_types.go b/api/v1alpha1/service_types.go new file mode 100644 index 0000000..229ca4f --- /dev/null +++ b/api/v1alpha1/service_types.go @@ -0,0 +1,110 @@ +// Создано: 2026-03-20 +// service_types.go — CRD Service (sless_service): долгоживущий HTTP-сервис с постоянным URL. +// В отличие от Function (oneshot через Job), Service запускается как Deployment +// и всегда доступен по URL: https://sless.kube5s.ru/fn/{namespace}/{name} +// HTTP-триггер отдельно создавать не нужно — URL выдаётся оператором автоматически. + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// ServiceSpec — желаемое состояние сервиса. +// Поля идентичны FunctionSpec, но нет семантики "одноразового вызова". +type ServiceSpec struct { + // Runtime — язык и версия выполнения (go1.23, python3.11, nodejs20) + // +kubebuilder:validation:Enum=go1.23;python3.11;nodejs20 + // +kubebuilder:validation:Required + Runtime string `json:"runtime"` + + // Entrypoint — точка входа в код сервиса (например: handler.handle) + // +kubebuilder:validation:Required + Entrypoint string `json:"entrypoint"` + + // S3Bucket — бакет S3 где хранится zip архив с кодом + S3Bucket string `json:"s3Bucket"` + + // S3Key — ключ объекта в S3 (путь до zip архива) + S3Key string `json:"s3Key"` + + // MemoryMB — лимит памяти в мегабайтах (default: 128) + // +kubebuilder:default=128 + MemoryMB int32 `json:"memoryMB,omitempty"` + + // TimeoutSec — таймаут HTTP-прокси в секундах (default: 30). + // Ограничивает время ожидания ответа от пода в invoke.go. + // Для длительных вызовов (batch, pgstorm) увеличить до нужного значения. + // +kubebuilder:default=30 + TimeoutSec int32 `json:"timeoutSec,omitempty"` + + // Env — переменные окружения, передаются в контейнер сервиса + Env map[string]string `json:"env,omitempty"` +} + +// ServicePhase — текущая фаза жизненного цикла сервиса. +type ServicePhase string + +const ( + // ServicePhasePending — сервис создан, ожидает сборки образа + ServicePhasePending ServicePhase = "Pending" + // ServicePhaseBuilding — идёт сборка Docker образа + ServicePhaseBuilding ServicePhase = "Building" + // ServicePhaseReady — образ собран, Deployment поднят, URL доступен + ServicePhaseReady ServicePhase = "Ready" + // ServicePhaseFailed — ошибка при сборке или деплое + ServicePhaseFailed ServicePhase = "Failed" +) + +// ServiceStatus — наблюдаемое состояние сервиса (заполняет контроллер). +type ServiceStatus struct { + // Phase — текущая фаза: Pending, Building, Ready, Failed + Phase ServicePhase `json:"phase,omitempty"` + + // ImageRef — полный путь к собранному Docker образу в registry + ImageRef string `json:"imageRef,omitempty"` + + // URL — публичный URL сервиса, заполняется оператором после создания Ingress. + // Формат: {ExternalURL}/fn/{namespace}/{name} + URL string `json:"url,omitempty"` + + // Message — человекочитаемое сообщение об ошибке или статусе + Message string `json:"message,omitempty"` + + // Conditions — стандартные k8s conditions для интеграции с инструментами + Conditions []metav1.Condition `json:"conditions,omitempty"` + + // LastBuiltAt — время последней успешной сборки образа + LastBuiltAt *metav1.Time `json:"lastBuiltAt,omitempty"` +} + +//+kubebuilder:object:root=true +//+kubebuilder:subresource:status +//+kubebuilder:printcolumn:name="Runtime",type=string,JSONPath=`.spec.runtime` +//+kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase` +//+kubebuilder:printcolumn:name="URL",type=string,JSONPath=`.status.url` +//+kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` + +// Service — ресурс для долгоживущего HTTP-сервиса. +// Оператор создаёт Deployment + k8s Service + Ingress автоматически. +// URL доступен сразу после фазы Ready, без создания sless_trigger. +type Service struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec ServiceSpec `json:"spec,omitempty"` + Status ServiceStatus `json:"status,omitempty"` +} + +//+kubebuilder:object:root=true + +// ServiceList contains a list of Service +type ServiceList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Service `json:"items"` +} + +func init() { + SchemeBuilder.Register(&Service{}, &ServiceList{}) +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 601b434..8153617 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -21,7 +21,7 @@ limitations under the License. package v1alpha1 import ( - "k8s.io/apimachinery/pkg/apis/meta/v1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) @@ -288,6 +288,113 @@ func (in *TriggerList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Service) DeepCopyInto(out *Service) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Service. +func (in *Service) DeepCopy() *Service { + if in == nil { + return nil + } + out := new(Service) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Service) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceList) DeepCopyInto(out *ServiceList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Service, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceList. +func (in *ServiceList) DeepCopy() *ServiceList { + if in == nil { + return nil + } + out := new(ServiceList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ServiceList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceSpec) DeepCopyInto(out *ServiceSpec) { + *out = *in + if in.Env != nil { + in, out := &in.Env, &out.Env + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceSpec. +func (in *ServiceSpec) DeepCopy() *ServiceSpec { + if in == nil { + return nil + } + out := new(ServiceSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceStatus) DeepCopyInto(out *ServiceStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.LastBuiltAt != nil { + in, out := &in.LastBuiltAt, &out.LastBuiltAt + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceStatus. +func (in *ServiceStatus) DeepCopy() *ServiceStatus { + if in == nil { + return nil + } + out := new(ServiceStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TriggerSpec) DeepCopyInto(out *TriggerSpec) { *out = *in diff --git a/config/crd/bases/sless.kube5s.ru_services.yaml b/config/crd/bases/sless.kube5s.ru_services.yaml new file mode 100644 index 0000000..1969d01 --- /dev/null +++ b/config/crd/bases/sless.kube5s.ru_services.yaml @@ -0,0 +1,199 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.14.0 + name: services.sless.kube5s.ru +spec: + group: sless.kube5s.ru + names: + kind: Service + listKind: ServiceList + plural: services + singular: service + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.runtime + name: Runtime + type: string + - jsonPath: .status.phase + name: Phase + type: string + - jsonPath: .status.url + name: URL + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + Service — ресурс для долгоживущего HTTP-сервиса. + Оператор создаёт Deployment + k8s Service + Ingress автоматически. + URL доступен сразу после фазы Ready, без создания sless_trigger. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + ServiceSpec — желаемое состояние сервиса. + Поля идентичны FunctionSpec, но нет семантики "одноразового вызова". + properties: + entrypoint: + description: 'Entrypoint — точка входа в код сервиса (например: handler.handle)' + type: string + env: + additionalProperties: + type: string + description: Env — переменные окружения, передаются в контейнер сервиса + type: object + memoryMB: + default: 128 + description: 'MemoryMB — лимит памяти в мегабайтах (default: 128)' + format: int32 + type: integer + runtime: + description: Runtime — язык и версия выполнения (go1.23, python3.11, + nodejs20) + enum: + - go1.23 + - python3.11 + - nodejs20 + type: string + s3Bucket: + description: S3Bucket — бакет S3 где хранится zip архив с кодом + type: string + s3Key: + description: S3Key — ключ объекта в S3 (путь до zip архива) + type: string + timeoutSec: + default: 30 + description: |- + TimeoutSec — таймаут HTTP-прокси в секундах (default: 30). + Ограничивает время ожидания ответа от пода в invoke.go. + Для длительных вызовов (batch, pgstorm) увеличить до нужного значения. + format: int32 + type: integer + required: + - entrypoint + - runtime + - s3Bucket + - s3Key + type: object + status: + description: ServiceStatus — наблюдаемое состояние сервиса (заполняет + контроллер). + properties: + conditions: + description: Conditions — стандартные k8s conditions для интеграции + с инструментами + items: + description: "Condition contains details for one aspect of the current + state of this API Resource.\n---\nThis struct is intended for + direct use as an array at the field path .status.conditions. For + example,\n\n\n\ttype FooStatus struct{\n\t // Represents the + observations of a foo's current state.\n\t // Known .status.conditions.type + are: \"Available\", \"Progressing\", and \"Degraded\"\n\t // + +patchMergeKey=type\n\t // +patchStrategy=merge\n\t // +listType=map\n\t + \ // +listMapKey=type\n\t Conditions []metav1.Condition `json:\"conditions,omitempty\" + patchStrategy:\"merge\" patchMergeKey:\"type\" protobuf:\"bytes,1,rep,name=conditions\"`\n\n\n\t + \ // other fields\n\t}" + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: |- + type of condition in CamelCase or in foo.example.com/CamelCase. + --- + Many .condition.type values are consistent across resources like Available, but because arbitrary conditions can be + useful (see .node.status.conditions), the ability to deconflict is important. + The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + imageRef: + description: ImageRef — полный путь к собранному Docker образу в registry + type: string + lastBuiltAt: + description: LastBuiltAt — время последней успешной сборки образа + format: date-time + type: string + message: + description: Message — человекочитаемое сообщение об ошибке или статусе + type: string + phase: + description: 'Phase — текущая фаза: Pending, Building, Ready, Failed' + type: string + url: + description: |- + URL — публичный URL сервиса, заполняется оператором после создания Ingress. + Формат: {ExternalURL}/fn/{namespace}/{name} + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/sless.kube5s.ru_triggers.yaml b/config/crd/bases/sless.kube5s.ru_triggers.yaml index 25015e6..3ee02d4 100644 --- a/config/crd/bases/sless.kube5s.ru_triggers.yaml +++ b/config/crd/bases/sless.kube5s.ru_triggers.yaml @@ -27,6 +27,9 @@ spec: - jsonPath: .status.url name: URL type: string + - jsonPath: .spec.queue + name: Queue + type: string - jsonPath: .metadata.creationTimestamp name: Age type: date @@ -72,15 +75,21 @@ spec: Актуально для cron: запускаем pod заранее чтобы избежать cold start. format: int32 type: integer + queue: + description: |- + Queue — имя AMQP очереди в RabbitMQ (только для type=event). + event-dispatcher подпишется на эту очередь и вызовет функцию при каждом сообщении. + type: string schedule: description: 'Schedule — расписание в формате cron (только для type=cron, например: "0 2 * * *")' type: string type: - description: 'Type — тип триггера: http или cron' + description: 'Type — тип триггера: http, cron или event' enum: - http - cron + - event type: string required: - enabled diff --git a/controllers/function_controller.go b/controllers/function_controller.go index d139607..2da300b 100644 --- a/controllers/function_controller.go +++ b/controllers/function_controller.go @@ -1,8 +1,11 @@ -// Изменено: 2026-03-11 -// FunctionReconciler — основной контроллер оператора. -// Следит за CRD Function и управляет lifecycle функции: -// Pending → Building (запуск kaniko Job) → Ready (образ собран, Deployment создан) / Failed -// Reconcile вызывается k8s при любом изменении Function объекта. +// Изменено: 2026-03-20 (function-service-split: FunctionReconciler — только build pipeline) +// FunctionReconciler — контроллер Function CRD (sless_function = oneshot/Job). +// Функция = код который выполняется ОДИН РАЗ через k8s Job при каждом вызове. +// Нет Deployment, нет постоянного URL. Вызов — через FunctionJob или invoke API. +// Reconciler отвечает только за: +// 1. Сборку Docker-образа через kaniko (Pending → Building → Ready/Failed) +// 2. Очистку ресурсов при удалении (kaniko Job) +// Deployment/Service/Ingress — в ServiceReconciler (sless_service). package controllers @@ -11,15 +14,11 @@ import ( "context" "fmt" "io" - "sort" "strings" "time" - appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" - netv1 "k8s.io/api/networking/v1" "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/kubernetes" @@ -97,7 +96,9 @@ func (r *FunctionReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c case slessv1alpha1.FunctionPhaseBuilding: return r.checkBuild(ctx, fn) case slessv1alpha1.FunctionPhaseReady: - return r.ensureDeployment(ctx, fn) + // Function = oneshot. После успешной сборки образ готов — Deployment не создаём. + // Вызов через FunctionJob или invoke API (Job per call). + return ctrl.Result{}, nil } return ctrl.Result{}, nil @@ -184,180 +185,14 @@ func (r *FunctionReconciler) checkBuild(ctx context.Context, fn *slessv1alpha1.F return ctrl.Result{RequeueAfter: 10 * time.Second}, nil } -// ensureDeployment создаёт или обновляет Deployment для HTTP функции. -// Deployment запускается в отдельном namespace sless-fn-{namespace}. -func (r *FunctionReconciler) ensureDeployment(ctx context.Context, fn *slessv1alpha1.Function) (ctrl.Result, error) { - deployNS := "sless-fn-" + fn.Namespace - // Создаём namespace для функций если не существует - ns := &corev1.Namespace{} - if err := r.Get(ctx, client.ObjectKey{Name: deployNS}, ns); err != nil { - if errors.IsNotFound(err) { - ns = &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: deployNS}} - if err := r.Create(ctx, ns); err != nil { - return ctrl.Result{}, fmt.Errorf("create function namespace: %w", err) - } - // Создаём Harbor-проект для namespace сразу при создании k8s NS (best-effort). - // Если не удалось — EnsureProject повторит вызов внутри Build(). - if r.HarborClient != nil { - if err := r.HarborClient.EnsureProject(ctx, fn.Namespace); err != nil { - log.FromContext(ctx).Error(err, "harbor ensure project on ns create", "project", fn.Namespace) - } - } - } else { - return ctrl.Result{}, fmt.Errorf("get function namespace: %w", err) - } - } - - // Обеспечиваем наличие registry pull-секрета в namespace функций. - // Без него kubelet не сможет pull-нуть private образ из Harbor. - if r.RegistrySecret != "" && r.OperatorNamespace != "" { - if err := r.ensureRegistrySecret(ctx, deployNS); err != nil { - // Не фатальная ошибка — логируем, но продолжаем - log.FromContext(ctx).Error(err, "failed to ensure registry secret", "ns", deployNS) - } - } - - desired := r.buildDeployment(fn, deployNS) - existing := &appsv1.Deployment{} - err := r.Get(ctx, client.ObjectKey{Name: fn.Name, Namespace: deployNS}, existing) - if errors.IsNotFound(err) { - if err := r.Create(ctx, desired); err != nil { - return ctrl.Result{}, fmt.Errorf("create deployment: %w", err) - } - return ctrl.Result{}, nil - } - if err != nil { - return ctrl.Result{}, fmt.Errorf("get deployment: %w", err) - } - - // Обновляем образ, env и imagePullSecrets при пересборке или изменении конфига. - // Тег образа уникален per build (sha256 от s3Key) → imagePullPolicy: IfNotPresent - // корректно подтягивает новый образ без дополнительных хаков. - // Env обновляем целиком — иначе изменение entrypoint/env_vars не применяется. - existing.Spec.Template.Spec.Containers[0].Image = fn.Status.ImageRef - existing.Spec.Template.Spec.Containers[0].Env = desired.Spec.Template.Spec.Containers[0].Env - existing.Spec.Template.Spec.ImagePullSecrets = desired.Spec.Template.Spec.ImagePullSecrets - if err := r.Update(ctx, existing); err != nil { - return ctrl.Result{}, fmt.Errorf("update deployment: %w", err) - } - return ctrl.Result{}, nil -} - -// Изменено: 2026-03-11// buildDeployment формирует Deployment манифест для функции. -func (r *FunctionReconciler) buildDeployment(fn *slessv1alpha1.Function, namespace string) *appsv1.Deployment { - replicas := int32(1) - envVars := []corev1.EnvVar{ - // SLESS_ENTRYPOINT сообщает server.py/server.js какой файл и функцию загружать. - // Формат: "module-name.funcName" (например: handler-http.handle) - {Name: "SLESS_ENTRYPOINT", Value: fn.Spec.Entrypoint}, - } - // Сортируем ключи env vars для стабильного порядка в Pod spec. - // map range в Go — недетерминирован: разный порядок при каждом вызове. - // Нестабильный порядок → k8s видит изменение контейнера → лишние rollout'ы. - keys := make([]string, 0, len(fn.Spec.Env)) - for k := range fn.Spec.Env { - keys = append(keys, k) - } - sort.Strings(keys) - for _, k := range keys { - envVars = append(envVars, corev1.EnvVar{Name: k, Value: fn.Spec.Env[k]}) - } - - return &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: fn.Name, - Namespace: namespace, - Labels: map[string]string{"app": fn.Name, "managed-by": "sless"}, - }, - Spec: appsv1.DeploymentSpec{ - Replicas: &replicas, - Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": fn.Name}}, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": fn.Name}}, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: fn.Name, - Image: fn.Status.ImageRef, - Env: envVars, - Resources: corev1.ResourceRequirements{ - Limits: corev1.ResourceList{ - corev1.ResourceMemory: resource.MustParse(fmt.Sprintf("%dMi", fn.Spec.MemoryMB)), - }, - }, - }, - }, - ImagePullSecrets: func() []corev1.LocalObjectReference { - if r.RegistrySecret != "" { - return []corev1.LocalObjectReference{{Name: r.RegistrySecret}} - } - return nil - }(), - }, - }, - }, - } -} - -// ensureRegistrySecret копирует pull-секрет из namespace оператора в namespace функций. -// Вызывается при каждом reconcile — если секрет уже есть, ничего не делает. -func (r *FunctionReconciler) ensureRegistrySecret(ctx context.Context, targetNS string) error { - // Проверяем что секрет уже есть в целевом namespace - existing := &corev1.Secret{} - if err := r.Get(ctx, client.ObjectKey{Name: r.RegistrySecret, Namespace: targetNS}, existing); err == nil { - return nil // уже есть - } else if !errors.IsNotFound(err) { - return fmt.Errorf("check secret: %w", err) - } - - // Копируем из namespace оператора - src := &corev1.Secret{} - if err := r.Get(ctx, client.ObjectKey{Name: r.RegistrySecret, Namespace: r.OperatorNamespace}, src); err != nil { - return fmt.Errorf("get source secret from %s: %w", r.OperatorNamespace, err) - } - - copy := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: r.RegistrySecret, - Namespace: targetNS, - }, - Type: src.Type, - Data: src.Data, - } - if err := r.Create(ctx, copy); err != nil { - if !errors.IsAlreadyExists(err) { - return fmt.Errorf("create secret in %s: %w", targetNS, err) - } - } - return nil -} - -// handleDeletion обрабатывает удаление Function: удаляет Deployment, Service, Ingress и убирает finalizer. -// ВАЖНО: Namespace sless-fn-{userNS} НЕ удаляется — он принадлежит пользователю на всё время его существования. +// handleDeletion обрабатывает удаление Function: убивает kaniko Job и убирает finalizer. +// Deployment/Service/Ingress Function не создаёт — они принадлежат Service CRD. func (r *FunctionReconciler) handleDeletion(ctx context.Context, fn *slessv1alpha1.Function) (ctrl.Result, error) { - deployNS := "sless-fn-" + fn.Namespace - dep := &appsv1.Deployment{} - if err := r.Get(ctx, client.ObjectKey{Name: fn.Name, Namespace: deployNS}, dep); err == nil { - _ = r.Delete(ctx, dep) - } - - // Если функция удалена в процессе сборки — убиваем kaniko Job. - // Без этого Job продолжит работу, займёт CPU/память и запушит образ которым никто не воспользуется. + // Убиваем kaniko Job если сборка шла в момент удаления if jobName := fn.Annotations["sless.kube5s.ru/build-job"]; jobName != "" { _ = r.Builder.Cleanup(ctx, jobName) } - // Удаляем Service и Ingress — созданы HTTP триггером, но именованы по функции. - // Если function_controller не удалит их, Ingress остаётся после destroy → 502. - svc := &corev1.Service{} - if err := r.Get(ctx, client.ObjectKey{Name: fn.Name, Namespace: deployNS}, svc); err == nil { - _ = r.Delete(ctx, svc) - } - ing := &netv1.Ingress{} - if err := r.Get(ctx, client.ObjectKey{Name: fn.Name, Namespace: deployNS}, ing); err == nil { - _ = r.Delete(ctx, ing) - } - fn.Finalizers = removeString(fn.Finalizers, finalizerName) if err := r.Update(ctx, fn); err != nil { return ctrl.Result{}, fmt.Errorf("remove finalizer: %w", err) diff --git a/controllers/service_controller.go b/controllers/service_controller.go new file mode 100644 index 0000000..d469e89 --- /dev/null +++ b/controllers/service_controller.go @@ -0,0 +1,459 @@ +// Создано: 2026-03-20 +// ServiceReconciler — контроллер для Service CRD (sless_service). +// Service = долгоживущий HTTP-сервис с постоянным URL. +// Lifecycle: Pending → Building (kaniko) → Ready (Deployment+k8s Service+Ingress, URL в Status) / Failed +// +// Отличие от Function: +// Function = oneshot, запускается k8s Job через FunctionJob/invoke. +// Service = HTTP-сервис, Deployment всегда запущен, URL доступен без отдельного sless_trigger. + +package controllers + +import ( + "bytes" + "context" + "fmt" + "io" + "sort" + "strings" + "time" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + netv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + + slessv1alpha1 "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/api/v1alpha1" + "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/builder" + "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/harbor" +) + +// ServiceReconciler reconciles a Service object +type ServiceReconciler struct { + client.Client + Scheme *runtime.Scheme + Builder *builder.Builder + KubeClient kubernetes.Interface // typed client для чтения логов build-подов + RegistrySecret string // имя Secret с docker credentials + OperatorNamespace string // откуда копируем RegistrySecret в sless-fn-* + HarborClient *harbor.Client // nil — EnsureProject пропускается + ExternalURL string // базовый URL для Status.URL: {ExternalURL}/fn/{ns}/{name} + IngressHost string // fallback домен если ExternalURL не задан +} + +//+kubebuilder:rbac:groups=sless.kube5s.ru,resources=services,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=sless.kube5s.ru,resources=services/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=sless.kube5s.ru,resources=services/finalizers,verbs=update +//+kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups="",resources=services,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=networking.k8s.io,resources=ingresses,verbs=get;list;watch;create;update;patch;delete + +const serviceFinalizerName = "sless.kube5s.ru/service-finalizer" + +// Reconcile — главный цикл управления Service. +// Pending → Building (запуск kaniko) → Ready (Deployment+Service+Ingress созданы, URL в Status) +func (r *ServiceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + svc := &slessv1alpha1.Service{} + if err := r.Get(ctx, req.NamespacedName, svc); err != nil { + if errors.IsNotFound(err) { + return ctrl.Result{}, nil + } + return ctrl.Result{}, fmt.Errorf("get service: %w", err) + } + + if !svc.DeletionTimestamp.IsZero() { + return r.handleServiceDeletion(ctx, svc) + } + + if !containsString(svc.Finalizers, serviceFinalizerName) { + svc.Finalizers = append(svc.Finalizers, serviceFinalizerName) + if err := r.Update(ctx, svc); err != nil { + return ctrl.Result{}, fmt.Errorf("add service finalizer: %w", err) + } + return ctrl.Result{Requeue: true}, nil + } + + // Идентичная логике FunctionReconciler: если s3Key изменился — пересобираем образ. + builtKey := svc.Annotations["sless.kube5s.ru/last-built-s3key"] + needsBuild := svc.Spec.S3Key != "" && builtKey != svc.Spec.S3Key + + if needsBuild && svc.Status.Phase != slessv1alpha1.ServicePhaseBuilding { + logger.Info("starting service build", "service", svc.Name) + return r.startServiceBuild(ctx, svc) + } + + switch svc.Status.Phase { + case slessv1alpha1.ServicePhaseBuilding: + return r.checkServiceBuild(ctx, svc) + case slessv1alpha1.ServicePhaseReady: + return r.ensureServiceDeployment(ctx, svc) + } + + return ctrl.Result{}, nil +} + +// startServiceBuild запускает kaniko Job и помечает сервис как Building. +func (r *ServiceReconciler) startServiceBuild(ctx context.Context, svc *slessv1alpha1.Service) (ctrl.Result, error) { + jobName, err := r.Builder.Build(ctx, svc.Namespace, svc.Name, svc.Spec.S3Key) + if err != nil { + return r.setServiceFailed(ctx, svc, fmt.Sprintf("failed to start build: %v", err)) + } + + if svc.Annotations == nil { + svc.Annotations = map[string]string{} + } + svc.Annotations["sless.kube5s.ru/build-job"] = jobName + svc.Annotations["sless.kube5s.ru/last-built-s3key"] = svc.Spec.S3Key + if err := r.Update(ctx, svc); err != nil { + return ctrl.Result{}, fmt.Errorf("update service build annotations: %w", err) + } + + svc.Status.Phase = slessv1alpha1.ServicePhaseBuilding + svc.Status.Message = "Building image: " + jobName + if err := r.Status().Update(ctx, svc); err != nil { + return ctrl.Result{}, fmt.Errorf("update service status to building: %w", err) + } + + return ctrl.Result{RequeueAfter: 10 * time.Second}, nil +} + +// checkServiceBuild проверяет статус kaniko Job. +func (r *ServiceReconciler) checkServiceBuild(ctx context.Context, svc *slessv1alpha1.Service) (ctrl.Result, error) { + jobName := svc.Annotations["sless.kube5s.ru/build-job"] + if jobName == "" { + svc.Status.Phase = slessv1alpha1.ServicePhasePending + _ = r.Status().Update(ctx, svc) + return ctrl.Result{Requeue: true}, nil + } + + status, err := r.Builder.JobStatus(ctx, jobName) + if err != nil { + return ctrl.Result{}, fmt.Errorf("check service build job: %w", err) + } + + switch status { + case "running": + return ctrl.Result{RequeueAfter: 10 * time.Second}, nil + case "succeeded": + imageRef := r.Builder.ImageRef(svc.Namespace, svc.Name, svc.Spec.S3Key) + svc.Status.Phase = slessv1alpha1.ServicePhaseReady + svc.Status.ImageRef = imageRef + svc.Status.Message = "" + now := metav1.Now() + svc.Status.LastBuiltAt = &now + if err := r.Status().Update(ctx, svc); err != nil { + return ctrl.Result{}, fmt.Errorf("update service status to ready: %w", err) + } + _ = r.Builder.Cleanup(ctx, jobName) + return ctrl.Result{Requeue: true}, nil + case "failed": + logs := getServiceBuildPodLogs(ctx, r.KubeClient, r.OperatorNamespace, jobName) + msg := "build job failed" + if logs != "" { + msg = "build job failed:\n" + logs + } + return r.setServiceFailed(ctx, svc, msg) + } + + return ctrl.Result{RequeueAfter: 10 * time.Second}, nil +} + +// ensureServiceDeployment создаёт или обновляет Deployment + k8s Service + Ingress. +// URL записывается в svc.Status.URL — доступен сразу без отдельного sless_trigger. +func (r *ServiceReconciler) ensureServiceDeployment(ctx context.Context, svc *slessv1alpha1.Service) (ctrl.Result, error) { + deployNS := "sless-fn-" + svc.Namespace + + // Создаём namespace для функций/сервисов если не существует + ns := &corev1.Namespace{} + if err := r.Get(ctx, client.ObjectKey{Name: deployNS}, ns); err != nil { + if errors.IsNotFound(err) { + ns = &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: deployNS}} + if err := r.Create(ctx, ns); err != nil { + return ctrl.Result{}, fmt.Errorf("create service namespace: %w", err) + } + if r.HarborClient != nil { + if err := r.HarborClient.EnsureProject(ctx, svc.Namespace); err != nil { + log.FromContext(ctx).Error(err, "harbor ensure project", "project", svc.Namespace) + } + } + } else { + return ctrl.Result{}, fmt.Errorf("get service namespace: %w", err) + } + } + + // Копируем pull-секрет чтобы kubelet мог скачать образ из приватного registry + if r.RegistrySecret != "" && r.OperatorNamespace != "" { + if err := r.ensureServiceRegistrySecret(ctx, deployNS); err != nil { + log.FromContext(ctx).Error(err, "failed to ensure registry secret", "ns", deployNS) + } + } + + // Deployment + desired := r.buildServiceDeployment(svc, deployNS) + existing := &appsv1.Deployment{} + err := r.Get(ctx, client.ObjectKey{Name: svc.Name, Namespace: deployNS}, existing) + if errors.IsNotFound(err) { + if err := r.Create(ctx, desired); err != nil { + return ctrl.Result{}, fmt.Errorf("create service deployment: %w", err) + } + } else if err != nil { + return ctrl.Result{}, fmt.Errorf("get service deployment: %w", err) + } else { + existing.Spec.Template.Spec.Containers[0].Image = svc.Status.ImageRef + existing.Spec.Template.Spec.Containers[0].Env = desired.Spec.Template.Spec.Containers[0].Env + existing.Spec.Template.Spec.ImagePullSecrets = desired.Spec.Template.Spec.ImagePullSecrets + if err := r.Update(ctx, existing); err != nil { + return ctrl.Result{}, fmt.Errorf("update service deployment: %w", err) + } + } + + // k8s Service — направляет трафик к Deployment + wantSvc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: svc.Name, + Namespace: deployNS, + Labels: map[string]string{"managed-by": "sless"}, + }, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{"app": svc.Name}, + Ports: []corev1.ServicePort{{Port: 8080, Protocol: corev1.ProtocolTCP}}, + }, + } + existingSvc := &corev1.Service{} + if err := r.Get(ctx, client.ObjectKey{Name: svc.Name, Namespace: deployNS}, existingSvc); err != nil { + if errors.IsNotFound(err) { + if err := r.Create(ctx, wantSvc); err != nil { + return ctrl.Result{}, fmt.Errorf("create k8s service: %w", err) + } + } else { + return ctrl.Result{}, fmt.Errorf("get k8s service: %w", err) + } + } + + // URL и Ingress формируются либо через ExternalURL (прокси через API), либо через Ingress + var funcURL string + if r.ExternalURL != "" { + // ExternalURL/fn/{ns}/{name} — работает через sless-api прокси, без wildcard DNS + funcURL = fmt.Sprintf("%s/fn/%s/%s", r.ExternalURL, svc.Namespace, svc.Name) + } else { + // Fallback: Ingress с поддоменом (требует wildcard DNS *.IngressHost) + host := fmt.Sprintf("%s-%s.%s", svc.Name, svc.Namespace, r.IngressHost) + pathType := netv1.PathTypePrefix + wantIng := &netv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: svc.Name, + Namespace: deployNS, + Annotations: map[string]string{ + "kubernetes.io/ingress.class": "nginx", + }, + }, + Spec: netv1.IngressSpec{ + Rules: []netv1.IngressRule{{ + Host: host, + IngressRuleValue: netv1.IngressRuleValue{ + HTTP: &netv1.HTTPIngressRuleValue{ + Paths: []netv1.HTTPIngressPath{{ + Path: "/", + PathType: &pathType, + Backend: netv1.IngressBackend{ + Service: &netv1.IngressServiceBackend{ + Name: svc.Name, + Port: netv1.ServiceBackendPort{Number: 8080}, + }, + }, + }}, + }, + }, + }}, + }, + } + existingIng := &netv1.Ingress{} + if err := r.Get(ctx, client.ObjectKey{Name: svc.Name, Namespace: deployNS}, existingIng); err != nil { + if errors.IsNotFound(err) { + if err := r.Create(ctx, wantIng); err != nil { + return ctrl.Result{}, fmt.Errorf("create service ingress: %w", err) + } + } else { + return ctrl.Result{}, fmt.Errorf("get service ingress: %w", err) + } + } + funcURL = "https://" + host + } + + // Записываем URL в Status если изменился + if svc.Status.URL != funcURL { + svc.Status.URL = funcURL + if err := r.Status().Update(ctx, svc); err != nil { + return ctrl.Result{}, fmt.Errorf("update service status url: %w", err) + } + } + + return ctrl.Result{}, nil +} + +// buildServiceDeployment формирует Deployment манифест. +func (r *ServiceReconciler) buildServiceDeployment(svc *slessv1alpha1.Service, namespace string) *appsv1.Deployment { + replicas := int32(1) + envVars := []corev1.EnvVar{ + {Name: "SLESS_ENTRYPOINT", Value: svc.Spec.Entrypoint}, + } + // Сортируем ключи для стабильного порядка — нестабильный порядок env vars вызывает лишние rollout'ы + keys := make([]string, 0, len(svc.Spec.Env)) + for k := range svc.Spec.Env { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + envVars = append(envVars, corev1.EnvVar{Name: k, Value: svc.Spec.Env[k]}) + } + + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: svc.Name, + Namespace: namespace, + Labels: map[string]string{"app": svc.Name, "managed-by": "sless"}, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas, + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": svc.Name}}, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": svc.Name}}, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{ + Name: svc.Name, + Image: svc.Status.ImageRef, + Env: envVars, + Resources: corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse(fmt.Sprintf("%dMi", svc.Spec.MemoryMB)), + }, + }, + }}, + ImagePullSecrets: func() []corev1.LocalObjectReference { + if r.RegistrySecret != "" { + return []corev1.LocalObjectReference{{Name: r.RegistrySecret}} + } + return nil + }(), + }, + }, + }, + } +} + +// ensureServiceRegistrySecret копирует pull-секрет в namespace сервисов. +func (r *ServiceReconciler) ensureServiceRegistrySecret(ctx context.Context, targetNS string) error { + existing := &corev1.Secret{} + if err := r.Get(ctx, client.ObjectKey{Name: r.RegistrySecret, Namespace: targetNS}, existing); err == nil { + return nil + } else if !errors.IsNotFound(err) { + return fmt.Errorf("check secret: %w", err) + } + src := &corev1.Secret{} + if err := r.Get(ctx, client.ObjectKey{Name: r.RegistrySecret, Namespace: r.OperatorNamespace}, src); err != nil { + return fmt.Errorf("get source secret from %s: %w", r.OperatorNamespace, err) + } + copy := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: r.RegistrySecret, + Namespace: targetNS, + }, + Type: src.Type, + Data: src.Data, + } + if err := r.Create(ctx, copy); err != nil { + if !errors.IsAlreadyExists(err) { + return fmt.Errorf("create secret in %s: %w", targetNS, err) + } + } + return nil +} + +// handleServiceDeletion удаляет Deployment, k8s Service, Ingress и убирает finalizer. +func (r *ServiceReconciler) handleServiceDeletion(ctx context.Context, svc *slessv1alpha1.Service) (ctrl.Result, error) { + deployNS := "sless-fn-" + svc.Namespace + + dep := &appsv1.Deployment{} + if err := r.Get(ctx, client.ObjectKey{Name: svc.Name, Namespace: deployNS}, dep); err == nil { + _ = r.Delete(ctx, dep) + } + + // Убиваем kaniko Job если сборка шла в момент удаления + if jobName := svc.Annotations["sless.kube5s.ru/build-job"]; jobName != "" { + _ = r.Builder.Cleanup(ctx, jobName) + } + + k8sSvc := &corev1.Service{} + if err := r.Get(ctx, client.ObjectKey{Name: svc.Name, Namespace: deployNS}, k8sSvc); err == nil { + _ = r.Delete(ctx, k8sSvc) + } + + ing := &netv1.Ingress{} + if err := r.Get(ctx, client.ObjectKey{Name: svc.Name, Namespace: deployNS}, ing); err == nil { + _ = r.Delete(ctx, ing) + } + + svc.Finalizers = removeString(svc.Finalizers, serviceFinalizerName) + if err := r.Update(ctx, svc); err != nil { + return ctrl.Result{}, fmt.Errorf("remove service finalizer: %w", err) + } + return ctrl.Result{}, nil +} + +// setServiceFailed переводит сервис в фазу Failed. +func (r *ServiceReconciler) setServiceFailed(ctx context.Context, svc *slessv1alpha1.Service, msg string) (ctrl.Result, error) { + svc.Status.Phase = slessv1alpha1.ServicePhaseFailed + svc.Status.Message = msg + if err := r.Status().Update(ctx, svc); err != nil { + return ctrl.Result{}, fmt.Errorf("update service status to failed: %w", err) + } + return ctrl.Result{}, nil +} + +// getServiceBuildPodLogs читает логи kaniko пода (последние 50 строк). +// Идентична getBuildPodLogs из function_controller.go, но вынесена в service_controller +// чтобы не создавать shared-помощника ради двух вызовов. +func getServiceBuildPodLogs(ctx context.Context, kube kubernetes.Interface, namespace, jobName string) string { + if kube == nil { + return "" + } + pods, err := kube.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: "job-name=" + jobName, + }) + if err != nil || len(pods.Items) == 0 { + return "" + } + req := kube.CoreV1().Pods(namespace).GetLogs(pods.Items[0].Name, &corev1.PodLogOptions{}) + stream, err := req.Stream(ctx) + if err != nil { + return "" + } + defer stream.Close() + buf := new(bytes.Buffer) + _, _ = io.Copy(buf, stream) + raw := strings.TrimSpace(buf.String()) + if raw == "" { + return "" + } + lines := strings.Split(raw, "\n") + if len(lines) > 50 { + lines = lines[len(lines)-50:] + } + return strings.Join(lines, "\n") +} + +// SetupWithManager sets up the controller with the Manager. +func (r *ServiceReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&slessv1alpha1.Service{}). + Complete(r) +} diff --git a/deployments/k8s/operator.yaml b/deployments/k8s/operator.yaml index 467a4c0..a1908d9 100644 --- a/deployments/k8s/operator.yaml +++ b/deployments/k8s/operator.yaml @@ -72,7 +72,7 @@ spec: containers: - name: operator # При обновлении версии оператора — менять тег здесь (не latest!) - image: naeel/sless-operator:v0.1.33 + image: naeel/sless-operator:v0.1.41 # Always — чтобы всегда тянуть по точному тегу (не кешировать старый) imagePullPolicy: Always ports: diff --git a/deployments/k8s/rbac.yaml b/deployments/k8s/rbac.yaml index b61aff1..0a83ecc 100644 --- a/deployments/k8s/rbac.yaml +++ b/deployments/k8s/rbac.yaml @@ -1,4 +1,4 @@ -# Изменено: 2026-03-09 (добавлен доступ к pods/log для feature B) +# Изменено: 2026-03-20 (добавлен Service CRD sless.kube5s.ru — services + status + finalizers) # RBAC для sless оператора. # ServiceAccount + ClusterRole + ClusterRoleBinding. # ClusterRole нужен (не namespaced Role) потому что оператор создаёт @@ -17,13 +17,13 @@ metadata: rules: # Наши CRD - apiGroups: ["sless.kube5s.ru"] - resources: ["functions", "triggers", "functionjobs"] + resources: ["functions", "triggers", "functionjobs", "services"] verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - apiGroups: ["sless.kube5s.ru"] - resources: ["functions/status", "triggers/status", "functionjobs/status"] + resources: ["functions/status", "triggers/status", "functionjobs/status", "services/status"] verbs: ["get", "update", "patch"] - apiGroups: ["sless.kube5s.ru"] - resources: ["functions/finalizers", "triggers/finalizers", "functionjobs/finalizers"] + resources: ["functions/finalizers", "triggers/finalizers", "functionjobs/finalizers", "services/finalizers"] verbs: ["update"] # Deployments для функций - apiGroups: ["apps"] diff --git a/doc/progress.md b/doc/progress.md index df36d9c..e703439 100644 --- a/doc/progress.md +++ b/doc/progress.md @@ -1,6 +1,132 @@ # Прогресс разработки -Последнее обновление: 2026-03-19 22:30 +Последнее обновление: 2026-03-20 17:00 + +--- + +## 2026-03-20 — Архитектурный рефакторинг: sless_function + sless_service (ветка feat/function-service-split) + +### Цель + +Разделить единый тип `sless_function` на два независимых: +- `sless_function` — **oneshot** (запускается один раз через Kubernetes Job, нет постоянного URL) +- `sless_service` — **long-running** (постоянный Deployment, Ingress, встроенный URL без sless_trigger) + +Мотивация: устранить архитектурное несоответствие — функции с постоянным HTTP URL не должны требовать отдельного sless_trigger. + +### Изменения в operator (Go) + +| Файл | Что сделано | +|------|-------------| +| `api/v1alpha1/service_types.go` | Новый CRD-тип `Service` с фазами Pending/Building/Ready/Failed, поле `URL` в статусе | +| `api/v1alpha1/zz_generated.deepcopy.go` | DeepCopy методы для Service/ServiceList/ServiceSpec/ServiceStatus | +| `controllers/service_controller.go` | Полный `ServiceReconciler`: kaniko build → Deployment → k8s Service → Ingress → Status.URL | +| `controllers/function_controller.go` | Удалены мёртвые методы: `ensureDeployment`, `buildDeployment`, `ensureRegistrySecret`. Function = только oneshot (Job). | +| `internal/api/handler/services.go` | CRUD handlers: ListServices, CreateService, GetService, UpdateService, DeleteService, UploadServiceCode | +| `internal/api/handler/invoke.go` | Dual-mode invoke: сначала проверяет Service CRD (proxy к Deployment), затем Function CRD (FunctionJob poll) | +| `internal/api/router.go` | 6 новых маршрутов `/namespaces/{ns}/services/...` | +| `main.go` | Регистрация ServiceReconciler | + +### Изменения в Terraform-провайдере + +| Файл | Что сделано | +|------|-------------| +| `terraform/provider/internal/client/client.go` | `ServiceRequest`, `ServiceResponse`, `CreateService/Get/Update/Delete`, `UploadServiceCode`, `WaitServiceReady` | +| `terraform/provider/internal/resources/service_resource.go` | Новый ресурс `sless_service` с полями name/runtime/entrypoint/memory_mb/timeout_sec/env_vars/source_dir/url (computed) | +| `terraform/provider/internal/provider/provider.go` | `NewServiceResource` добавлен в список ресурсов | + +### Изменения в примерах (examples/POSTGRES/) + +| Файл | Что сделано | +|------|-------------| +| `resources.tf` | Разбит на два файла; содержит только комментарий-указатель | +| `postgres.tf` | Managed PostgreSQL ресурсы: `locals`, `nubes_postgres`, `nubes_postgres_user`, `nubes_postgres_database` | +| `functions.tf` | Sless ресурсы: `sless_function` (только postgres_sql_runner_create_table + stress-*), `sless_service` (pg-info, pg-table-reader, pg-table-writer), `sless_job`; все `sless_trigger` блоки удалены | + +### Изменения в deployments/k8s/ + +| Файл | Что сделано | +|------|-------------| +| `operator.yaml` | Обновлён image `v0.1.33` → `v0.1.41` | +| `rbac.yaml` | Добавлен `services` в rules группы `sless.kube5s.ru` (resources + status + finalizers) | + +### Полная миграция terraform state + +``` +# Удалено из state (миграция Function → Service): +terraform state rm sless_function.pg_info ✅ +terraform state rm sless_trigger.pg_info_http ✅ +terraform state rm sless_function.postgres_table_reader ✅ +terraform state rm sless_trigger.postgres_table_reader_http ✅ +terraform state rm sless_function.postgres_table_writer ✅ +terraform state rm sless_trigger.postgres_table_writer_http ✅ + +# Дополнительно удалено 9 sless_trigger для stress-* функций: +terraform state rm sless_trigger.stress_bigloop_http ✅ +terraform state rm sless_trigger.stress_divzero_http ✅ +terraform state rm sless_trigger.stress_go_fast_http ✅ +terraform state rm sless_trigger.stress_go_nil_http ✅ +terraform state rm sless_trigger.stress_go_pgstorm_http ✅ +terraform state rm sless_trigger.stress_js_async_http ✅ +terraform state rm sless_trigger.stress_js_badenv_http ✅ +terraform state rm sless_trigger.stress_slow_http ✅ +terraform state rm sless_trigger.stress_writer_http ✅ +``` + +### RBAC fix (2026-03-20) + +ClusterRole `sless-operator` не содержала прав на новый CRD `services.sless.kube5s.ru`. +Причина: `rbac.yaml` создавался до введения `Service` CRD; `controller-gen rbac` не запускался перед деплоем v0.1.41. + +Исправление: +```bash +kubectl patch clusterrole sless-operator --type=json -p='[ + {"op":"add","path":"/rules/0/resources/-","value":"services"}, + {"op":"add","path":"/rules/1/resources/-","value":"services/status"}, + {"op":"add","path":"/rules/2/resources/-","value":"services/finalizers"} +]' +``` + +После патча `terraform apply` завершился успешно. Файл `deployments/k8s/rbac.yaml` синхронизирован. + +### Статус задач + +| # | Задача | Статус | +|---|--------|--------| +| 1 | Создать `service_types.go` + DeepCopy | ✅ | +| 2 | `service_controller.go` с полным lifecycle | ✅ | +| 3 | Очистить `function_controller.go` от мёртвых методов | ✅ | +| 4 | API handler `services.go` | ✅ | +| 5 | Dual-mode `invoke.go` | ✅ | +| 6 | `router.go` + 6 маршрутов | ✅ | +| 7 | `main.go` + ServiceReconciler | ✅ | +| 8 | Terraform client + service_resource.go + provider.go | ✅ | +| 9 | Разбить `resources.tf` → `postgres.tf` + `functions.tf` | ✅ | +| 10 | terraform state rm (15 ресурсов: 6 pg + 9 stress triggers) | ✅ | +| 11 | Удалить все `sless_trigger` блоки из `functions.tf` | ✅ | +| 12 | Новый провайдер скомпилирован на VM (v0.1.18 dev) | ✅ | +| 13 | Build + push operator image `v0.1.41` | ✅ | +| 14 | Apply CRD Service + deploy оператора v0.1.41 | ✅ | +| 15 | Исправить RBAC ClusterRole: добавить services.sless.kube5s.ru | ✅ | +| 16 | `terraform apply -target sless_service.*` | ✅ | +| 17 | Удалить старые Function объекты pg-info/reader/writer из k8s | ✅ | +| 18 | Smoke test curl (pg-info, pg-table-reader, pg-table-writer) | ✅ | +| 19 | Синхронизировать `deployments/k8s/rbac.yaml` с кластером | ✅ | +| 20 | Commit + push | ✅ | + +### Итоговое состояние кластера (sless-ffd1f598c169b0ae) + +``` +service.sless.kube5s.ru/pg-info nodejs20 Ready https://sless.kube5s.ru/fn/.../pg-info +service.sless.kube5s.ru/pg-table-reader python3.11 Ready https://sless.kube5s.ru/fn/.../pg-table-reader +service.sless.kube5s.ru/pg-table-writer python3.11 Ready https://sless.kube5s.ru/fn/.../pg-table-writer + +function.sless.kube5s.ru/pg-create-table-runner python3.11 Ready +function.sless.kube5s.ru/stress-bigloop python3.11 Ready +... (8 stress functions) +``` + +Старые `function.sless.kube5s.ru/pg-info`, `pg-table-reader`, `pg-table-writer` — удалены. --- diff --git a/examples/POSTGRES/functions.tf b/examples/POSTGRES/functions.tf new file mode 100644 index 0000000..0d081b8 --- /dev/null +++ b/examples/POSTGRES/functions.tf @@ -0,0 +1,280 @@ +// 2026-03-20 — выделено из resources.tf: sless функции, сервисы и джобы. +// 2026-03-19 — миграция: sless_function (oneshot/Job) + sless_service (long-running Deployment). +// sless_trigger(type=http) удалены — HTTP URL теперь автоматически в sless_service. +// postgres_sql_runner_create_table и stress-* остаются sless_function (oneshot/Job). +// pg-info, pg-table-reader, pg-table-writer → sless_service. + +# Служебная функция выполняет SQL-операторы из event_json. +# Credentials берутся из locals (vault_secrets) — без хардкода. +# Для сверки хардкод остаётся в terraform.tfvars. +resource "sless_function" "postgres_sql_runner_create_table" { + name = "pg-create-table-runner" + runtime = "python3.11" + entrypoint = "sql_runner.run_sql" + memory_mb = 128 + timeout_sec = 30 + + env_vars = { + PGHOST = local.pg_host + PGPORT = "5432" + PGDATABASE = local.pg_database + PGUSER = local.pg_username + PGPASSWORD = local.pg_password + PGSSLMODE = "require" + # Для сверки (должно совпадать с vault): + # PGUSER = var.pg_user + # PGPASSWORD = var.pg_password + } + + source_dir = "${path.module}/code/sql-runner" +} + +resource "sless_job" "postgres_table_init_job" { + name = "pg-create-table-job-main-v13" + function = sless_function.postgres_sql_runner_create_table.name + wait_timeout_sec = 180 + run_id = 13 + + event_json = jsonencode({ + statements = [ + "CREATE TABLE IF NOT EXISTS terraform_demo_table (id serial PRIMARY KEY, title text NOT NULL, created_at timestamp DEFAULT now())" + ] + }) + + depends_on = [nubes_postgres_database.db] +} + +# Long-running сервис на NodeJS: возвращает версию PG-сервера и счётчик строк в таблице. +# Единственная функция примера на nodejs20 — проверка что JS runtime работает. +# URL автоматически: https://sless.kube5s.ru/fn//pg-info +resource "sless_service" "pg_info" { + name = "pg-info" + runtime = "nodejs20" + entrypoint = "pg_info.info" + memory_mb = 128 + timeout_sec = 15 + + env_vars = { + PGHOST = local.pg_host + PGPORT = "5432" + PGDATABASE = local.pg_database + PGUSER = local.pg_username + PGPASSWORD = local.pg_password + PGSSLMODE = "require" + } + + source_dir = "${path.module}/code/pg-info" + + depends_on = [sless_job.postgres_table_init_job] +} + +# Long-running сервисы чтения и записи строк terraform_demo_table — в одном файле table_rw.py. +# list_rows (GET) — читает все строки; add_row (POST {title}) — вставляет строку. +# URL автоматически: https://sless.kube5s.ru/fn//pg-table-reader +# https://sless.kube5s.ru/fn//pg-table-writer +resource "sless_service" "postgres_table_reader" { + name = "pg-table-reader" + runtime = "python3.11" + entrypoint = "table_rw.list_rows" + memory_mb = 128 + timeout_sec = 30 + + env_vars = { + PGHOST = local.pg_host + PGPORT = "5432" + PGDATABASE = local.pg_database + PGUSER = local.pg_username + PGPASSWORD = local.pg_password + PGSSLMODE = "require" + } + + source_dir = "${path.module}/code/table-rw" + + depends_on = [sless_job.postgres_table_init_job] +} + +output "table_reader_url" { + value = sless_service.postgres_table_reader.url +} + +resource "sless_service" "postgres_table_writer" { + name = "pg-table-writer" + runtime = "python3.11" + entrypoint = "table_rw.add_row" + memory_mb = 256 + timeout_sec = 45 + + env_vars = { + PGHOST = local.pg_host + PGPORT = "5432" + PGDATABASE = local.pg_database + PGUSER = local.pg_username + PGPASSWORD = local.pg_password + PGSSLMODE = "require" + } + + source_dir = "${path.module}/code/table-rw" + + depends_on = [sless_job.postgres_table_init_job] +} + +output "table_writer_url" { + value = sless_service.postgres_table_writer.url +} + +# ============================================================================= +# STRESS-ТЕСТЫ: 9 функций для проверки устойчивости платформы. +# Python: slow, divzero, bigloop, writer +# Go: fast, nil-panic, pgstorm +# NodeJS: async-parallel, badenv +# ============================================================================= + +# --- [1] Python: долгая (sleep N сек) --- +resource "sless_function" "stress_slow" { + name = "stress-slow" + runtime = "python3.11" + entrypoint = "stress_slow.run" + memory_mb = 128 + timeout_sec = 30 + + env_vars = {} + + source_dir = "${path.module}/code/stress-slow" + depends_on = [sless_job.postgres_table_init_job] +} + +# --- [2] Python: деление на ноль --- +resource "sless_function" "stress_divzero" { + name = "stress-divzero" + runtime = "python3.11" + entrypoint = "stress_divzero.run" + memory_mb = 128 + timeout_sec = 10 + + env_vars = {} + + source_dir = "${path.module}/code/stress-divzero" + depends_on = [sless_job.postgres_table_init_job] +} + +# --- [3] Python: CPU bigloop --- +resource "sless_function" "stress_bigloop" { + name = "stress-bigloop" + runtime = "python3.11" + entrypoint = "stress_bigloop.run" + memory_mb = 256 + timeout_sec = 30 + + env_vars = {} + + source_dir = "${path.module}/code/stress-bigloop" + depends_on = [sless_job.postgres_table_init_job] +} + +# --- [4] Python: массовая запись в PG --- +resource "sless_function" "stress_writer" { + name = "stress-writer" + runtime = "python3.11" + entrypoint = "stress_writer.run" + memory_mb = 128 + timeout_sec = 30 + + env_vars = { + PGHOST = local.pg_host + PGPORT = "5432" + PGDATABASE = local.pg_database + PGUSER = local.pg_username + PGPASSWORD = local.pg_password + PGSSLMODE = "require" + } + + source_dir = "${path.module}/code/stress-writer" + depends_on = [sless_job.postgres_table_init_job] +} + +# --- [5] Go: быстрая математика (факториал + Фибоначчи) --- +resource "sless_function" "stress_go_fast" { + name = "stress-go-fast" + runtime = "go1.23" + entrypoint = "handler.Handle" + memory_mb = 64 + timeout_sec = 10 + + env_vars = {} + + source_dir = "${path.module}/code/stress-go-fast" + depends_on = [sless_job.postgres_table_init_job] +} + +# --- [6] Go: nil pointer panic --- +resource "sless_function" "stress_go_nil" { + name = "stress-go-nil" + runtime = "go1.23" + entrypoint = "handler.Handle" + memory_mb = 64 + timeout_sec = 10 + + env_vars = {} + + source_dir = "${path.module}/code/stress-go-nil" + depends_on = [sless_job.postgres_table_init_job] +} + +# --- [7] NodeJS: 3 параллельных запроса к PG через Promise.all --- +resource "sless_function" "stress_js_async" { + name = "stress-js-async" + runtime = "nodejs20" + entrypoint = "stress_js_async.run" + memory_mb = 128 + timeout_sec = 15 + + env_vars = { + PGHOST = local.pg_host + PGPORT = "5432" + PGDATABASE = local.pg_database + PGUSER = local.pg_username + PGPASSWORD = local.pg_password + PGSSLMODE = "require" + } + + source_dir = "${path.module}/code/stress-js-async" + depends_on = [sless_job.postgres_table_init_job] +} + +# --- [8] NodeJS: TypeError на несуществующей env-переменной --- +resource "sless_function" "stress_js_badenv" { + name = "stress-js-badenv" + runtime = "nodejs20" + entrypoint = "stress_js_badenv.run" + memory_mb = 128 + timeout_sec = 10 + + env_vars = {} + + source_dir = "${path.module}/code/stress-js-badenv" + depends_on = [sless_job.postgres_table_init_job] +} + +# --- [9] Go: PG Storm — 100 горутин долбят PostgreSQL напрямую через pgxpool --- +# Тестирует: Go runtime под конкурентной нагрузкой, pgxpool connection pool, +# устойчивость managed PG при массовых INSERT/SELECT. +# Параметры: workers (default 100), duration_sec (default 600), max_delay_ms (default 300). +resource "sless_function" "stress_go_pgstorm" { + name = "stress-go-pgstorm" + runtime = "go1.23" + entrypoint = "handler.Handle" + memory_mb = 256 + timeout_sec = 700 + + env_vars = { + PGHOST = local.pg_host + PGPORT = "5432" + PGDATABASE = local.pg_database + PGUSER = local.pg_username + PGPASSWORD = local.pg_password + PGSSLMODE = "require" + } + + source_dir = "${path.module}/code/stress-go-pgstorm" + depends_on = [sless_job.postgres_table_init_job] +} diff --git a/examples/POSTGRES/postgres.tf b/examples/POSTGRES/postgres.tf new file mode 100644 index 0000000..64bec6c --- /dev/null +++ b/examples/POSTGRES/postgres.tf @@ -0,0 +1,54 @@ +// 2026-03-20 — выделено из resources.tf: только managed PostgreSQL ресурсы. + +# Актуальные credentials из vault_secrets (authoritatively) — vault синхронизирован с кластером. +# Структура vault_secrets["users"]: JSON-строка {"username": {"password": "...", "username": "..."}} +locals { + pg_creds_map = jsondecode(nubes_postgres.npg.vault_secrets["users"]) + pg_username = nubes_postgres_user.pg_user.username + pg_password = local.pg_creds_map[local.pg_username]["password"] + pg_host = nubes_postgres.npg.state_out_flat["internalConnect.master"] + pg_database = nubes_postgres_database.db.db_name +} + +resource "nubes_postgres" "npg" { + resource_name = "teststand-pg-2" + # s3_uid = "s01325" + s3_uid = var.s3_uid + resource_realm = var.realm + resource_instances = 1 + resource_memory = 512 + resource_c_p_u = 500 + resource_disk = "1" + app_version = "17" + json_parameters = jsonencode({ + log_connections = "off" + log_disconnections = "off" + }) + enable_pg_pooler_master = false + enable_pg_pooler_slave = false + allow_no_s_s_l = false + auto_scale = false + auto_scale_percentage = 10 + auto_scale_tech_window = 0 + auto_scale_quota_gb = "1" + need_external_address_master = false + + # suspend_on_destroy = false + operation_timeout = "11m" + adopt_existing_on_create = true +} + +resource "nubes_postgres_user" "pg_user" { + postgres_id = nubes_postgres.npg.id + username = "u-user0" + role = "ddl_user" + adopt_existing_on_create = true +} + +resource "nubes_postgres_database" "db" { + postgres_id = nubes_postgres.npg.id + db_name = "db_terra" + db_owner = nubes_postgres_user.pg_user.username + adopt_existing_on_create = true + # suspend_on_destroy = false +} diff --git a/examples/POSTGRES/resources.tf b/examples/POSTGRES/resources.tf index 46afc70..cd662b8 100644 --- a/examples/POSTGRES/resources.tf +++ b/examples/POSTGRES/resources.tf @@ -1,407 +1,3 @@ -// 2026-03-18 — добавлены locals для извлечения credentials из vault_secrets (без хардкода). -// Для сверки хардкод остаётся в terraform.tfvars на этапе разработки. -// sless_function и sless_job закомментированы — сначала проверяется сетевое соединение. - -# Актуальные credentials из vault_secrets (authoritatively) — vault синхронизирован с кластером. -# Структура vault_secrets["users"]: JSON-строка {"username": {"password": "...", "username": "..."}} -locals { - pg_creds_map = jsondecode(nubes_postgres.npg.vault_secrets["users"]) - pg_username = nubes_postgres_user.pg_user.username - pg_password = local.pg_creds_map[local.pg_username]["password"] - pg_host = nubes_postgres.npg.state_out_flat["internalConnect.master"] - pg_database = nubes_postgres_database.db.db_name -} - -resource "nubes_postgres" "npg" { - resource_name = "teststand-pg-2" - # s3_uid = "s01325" - s3_uid = var.s3_uid - resource_realm = var.realm - resource_instances = 1 - resource_memory = 512 - resource_c_p_u = 500 - resource_disk = "1" - app_version = "17" - json_parameters = jsonencode({ - log_connections = "off" - log_disconnections = "off" - }) - enable_pg_pooler_master = false - enable_pg_pooler_slave = false - allow_no_s_s_l = false - auto_scale = false - auto_scale_percentage = 10 - auto_scale_tech_window = 0 - auto_scale_quota_gb = "1" - need_external_address_master = false - - # suspend_on_destroy = false - operation_timeout = "11m" - adopt_existing_on_create = true -} - -resource "nubes_postgres_user" "pg_user" { - postgres_id = nubes_postgres.npg.id - username = "u-user0" - role = "ddl_user" - adopt_existing_on_create = true -} - -resource "nubes_postgres_database" "db" { - postgres_id = nubes_postgres.npg.id - db_name = "db_terra" - db_owner = nubes_postgres_user.pg_user.username - adopt_existing_on_create = true - # suspend_on_destroy = false -} - -# Служебная функция выполняет SQL-операторы из event_json. -# Credentials берутся из locals (vault_secrets) — без хардкода. -# Для сверки хардкод остаётся в terraform.tfvars. -resource "sless_function" "postgres_sql_runner_create_table" { - name = "pg-create-table-runner" - runtime = "python3.11" - entrypoint = "sql_runner.run_sql" - memory_mb = 128 - timeout_sec = 30 - - env_vars = { - PGHOST = local.pg_host - PGPORT = "5432" - PGDATABASE = local.pg_database - PGUSER = local.pg_username - PGPASSWORD = local.pg_password - PGSSLMODE = "require" - # Для сверки (должно совпадать с vault): - # PGUSER = var.pg_user - # PGPASSWORD = var.pg_password - } - - source_dir = "${path.module}/code/sql-runner" -} - -resource "sless_job" "postgres_table_init_job" { - name = "pg-create-table-job-main-v13" - function = sless_function.postgres_sql_runner_create_table.name - wait_timeout_sec = 180 - run_id = 13 - - event_json = jsonencode({ - statements = [ - "CREATE TABLE IF NOT EXISTS terraform_demo_table (id serial PRIMARY KEY, title text NOT NULL, created_at timestamp DEFAULT now())" - ] - }) - - depends_on = [nubes_postgres_database.db] -} - -# HTTP-функция на NodeJS: возвращает версию PG-сервера и счётчик строк в таблице. -# Единственная функция примера на nodejs20 — проверка что JS runtime работает. -# Доступна по URL: https://sless.kube5s.ru/fn//pg-info -resource "sless_function" "pg_info" { - name = "pg-info" - runtime = "nodejs20" - entrypoint = "pg_info.info" - memory_mb = 128 - timeout_sec = 15 - - env_vars = { - PGHOST = local.pg_host - PGPORT = "5432" - PGDATABASE = local.pg_database - PGUSER = local.pg_username - PGPASSWORD = local.pg_password - PGSSLMODE = "require" - } - - source_dir = "${path.module}/code/pg-info" - - depends_on = [sless_job.postgres_table_init_job] -} - -resource "sless_trigger" "pg_info_http" { - name = "pg-info-http" - type = "http" - function = sless_function.pg_info.name - enabled = true -} - -# HTTP-функции чтения и записи строк terraform_demo_table — в одном файле table_rw.py. -# list_rows (GET) — читает все строки; add_row (POST {title}) — вставляет строку. -# Доступны по URL: https://sless.kube5s.ru/fn//pg-table-reader -# https://sless.kube5s.ru/fn//pg-table-writer -resource "sless_function" "postgres_table_reader" { - name = "pg-table-reader" - runtime = "python3.11" - entrypoint = "table_rw.list_rows" - memory_mb = 128 - timeout_sec = 30 - - env_vars = { - PGHOST = local.pg_host - PGPORT = "5432" - PGDATABASE = local.pg_database - PGUSER = local.pg_username - PGPASSWORD = local.pg_password - PGSSLMODE = "require" - } - - source_dir = "${path.module}/code/table-rw" - - depends_on = [sless_job.postgres_table_init_job] -} - -resource "sless_trigger" "postgres_table_reader_http" { - name = "pg-table-reader-http" - type = "http" - function = sless_function.postgres_table_reader.name - enabled = true -} - -output "table_reader_url" { - value = sless_trigger.postgres_table_reader_http.url -} - -resource "sless_function" "postgres_table_writer" { - name = "pg-table-writer" - runtime = "python3.11" - entrypoint = "table_rw.add_row" - memory_mb = 256 - timeout_sec = 45 - - env_vars = { - PGHOST = local.pg_host - PGPORT = "5432" - PGDATABASE = local.pg_database - PGUSER = local.pg_username - PGPASSWORD = local.pg_password - PGSSLMODE = "require" - } - - source_dir = "${path.module}/code/table-rw" - - depends_on = [sless_job.postgres_table_init_job] -} - -resource "sless_trigger" "postgres_table_writer_http" { - name = "pg-table-writer-http" - type = "http" - function = sless_function.postgres_table_writer.name - enabled = true -} - -output "table_writer_url" { - value = sless_trigger.postgres_table_writer_http.url -} - -# ============================================================================= -# STRESS-ТЕСТЫ: 8 функций для проверки устойчивости платформы. -# Python: slow, divzero, bigloop, writer -# Go: fast, nil-panic -# NodeJS: async-parallel, badenv -# ============================================================================= - -# --- [1] Python: долгая (sleep N сек) --- -resource "sless_function" "stress_slow" { - name = "stress-slow" - runtime = "python3.11" - entrypoint = "stress_slow.run" - memory_mb = 128 - timeout_sec = 30 - - env_vars = {} - - source_dir = "${path.module}/code/stress-slow" - depends_on = [sless_job.postgres_table_init_job] -} -resource "sless_trigger" "stress_slow_http" { - name = "stress-slow-http" - type = "http" - function = sless_function.stress_slow.name - enabled = true -} - -# --- [2] Python: деление на ноль --- -resource "sless_function" "stress_divzero" { - name = "stress-divzero" - runtime = "python3.11" - entrypoint = "stress_divzero.run" - memory_mb = 128 - timeout_sec = 10 - - env_vars = {} - - source_dir = "${path.module}/code/stress-divzero" - depends_on = [sless_job.postgres_table_init_job] -} -resource "sless_trigger" "stress_divzero_http" { - name = "stress-divzero-http" - type = "http" - function = sless_function.stress_divzero.name - enabled = true -} - -# --- [3] Python: CPU bigloop --- -resource "sless_function" "stress_bigloop" { - name = "stress-bigloop" - runtime = "python3.11" - entrypoint = "stress_bigloop.run" - memory_mb = 256 - timeout_sec = 30 - - env_vars = {} - - source_dir = "${path.module}/code/stress-bigloop" - depends_on = [sless_job.postgres_table_init_job] -} -resource "sless_trigger" "stress_bigloop_http" { - name = "stress-bigloop-http" - type = "http" - function = sless_function.stress_bigloop.name - enabled = true -} - -# --- [4] Python: массовая запись в PG --- -resource "sless_function" "stress_writer" { - name = "stress-writer" - runtime = "python3.11" - entrypoint = "stress_writer.run" - memory_mb = 128 - timeout_sec = 30 - - env_vars = { - PGHOST = local.pg_host - PGPORT = "5432" - PGDATABASE = local.pg_database - PGUSER = local.pg_username - PGPASSWORD = local.pg_password - PGSSLMODE = "require" - } - - source_dir = "${path.module}/code/stress-writer" - depends_on = [sless_job.postgres_table_init_job] -} -resource "sless_trigger" "stress_writer_http" { - name = "stress-writer-http" - type = "http" - function = sless_function.stress_writer.name - enabled = true -} - -# --- [5] Go: быстрая математика (факториал + Фибоначчи) --- -resource "sless_function" "stress_go_fast" { - name = "stress-go-fast" - runtime = "go1.23" - entrypoint = "handler.Handle" - memory_mb = 64 - timeout_sec = 10 - - env_vars = {} - - source_dir = "${path.module}/code/stress-go-fast" - depends_on = [sless_job.postgres_table_init_job] -} -resource "sless_trigger" "stress_go_fast_http" { - name = "stress-go-fast-http" - type = "http" - function = sless_function.stress_go_fast.name - enabled = true -} - -# --- [6] Go: nil pointer panic --- -resource "sless_function" "stress_go_nil" { - name = "stress-go-nil" - runtime = "go1.23" - entrypoint = "handler.Handle" - memory_mb = 64 - timeout_sec = 10 - - env_vars = {} - - source_dir = "${path.module}/code/stress-go-nil" - depends_on = [sless_job.postgres_table_init_job] -} -resource "sless_trigger" "stress_go_nil_http" { - name = "stress-go-nil-http" - type = "http" - function = sless_function.stress_go_nil.name - enabled = true -} - -# --- [7] NodeJS: 3 параллельных запроса к PG через Promise.all --- -resource "sless_function" "stress_js_async" { - name = "stress-js-async" - runtime = "nodejs20" - entrypoint = "stress_js_async.run" - memory_mb = 128 - timeout_sec = 15 - - env_vars = { - PGHOST = local.pg_host - PGPORT = "5432" - PGDATABASE = local.pg_database - PGUSER = local.pg_username - PGPASSWORD = local.pg_password - PGSSLMODE = "require" - } - - source_dir = "${path.module}/code/stress-js-async" - depends_on = [sless_job.postgres_table_init_job] -} -resource "sless_trigger" "stress_js_async_http" { - name = "stress-js-async-http" - type = "http" - function = sless_function.stress_js_async.name - enabled = true -} - -# --- [8] NodeJS: TypeError на несуществующей env-переменной --- -resource "sless_function" "stress_js_badenv" { - name = "stress-js-badenv" - runtime = "nodejs20" - entrypoint = "stress_js_badenv.run" - memory_mb = 128 - timeout_sec = 10 - - env_vars = {} - - source_dir = "${path.module}/code/stress-js-badenv" - depends_on = [sless_job.postgres_table_init_job] -} -resource "sless_trigger" "stress_js_badenv_http" { - name = "stress-js-badenv-http" - type = "http" - function = sless_function.stress_js_badenv.name - enabled = true -} - -# --- [9] Go: PG Storm — 100 горутин долбят PostgreSQL напрямую через pgxpool --- -# Тестирует: Go runtime под конкурентной нагрузкой, pgxpool connection pool, -# устойчивость managed PG при массовых INSERT/SELECT. -# Параметры: workers (default 100), duration_sec (default 600), max_delay_ms (default 300). -resource "sless_function" "stress_go_pgstorm" { - name = "stress-go-pgstorm" - runtime = "go1.23" - entrypoint = "handler.Handle" - memory_mb = 256 - timeout_sec = 700 - - env_vars = { - PGHOST = local.pg_host - PGPORT = "5432" - PGDATABASE = local.pg_database - PGUSER = local.pg_username - PGPASSWORD = local.pg_password - PGSSLMODE = "require" - } - - source_dir = "${path.module}/code/stress-go-pgstorm" - depends_on = [sless_job.postgres_table_init_job] -} -resource "sless_trigger" "stress_go_pgstorm_http" { - name = "stress-go-pgstorm-http" - type = "http" - function = sless_function.stress_go_pgstorm.name - enabled = true -} - +// 2026-03-20 — содержимое перенесено в два файла: +// postgres.tf — managed PostgreSQL ресурсы (nubes_postgres, user, database, locals) +// functions.tf — sless функции, сервисы, джобы, outputs diff --git a/internal/api/handler/invoke.go b/internal/api/handler/invoke.go index bfc4f64..7dc2c7b 100644 --- a/internal/api/handler/invoke.go +++ b/internal/api/handler/invoke.go @@ -1,125 +1,237 @@ -// Изменено: 2026-03-12 -// invoke.go — прокси-обработчик для вызова HTTP-триггеров функций. +// Изменено: 2026-03-20 (function-service-split: dual-mode invoke) +// invoke.go — обработчик вызова функций и сервисов. // Маршрут: ANY /fn/{namespace}/{name} и /fn/{namespace}/{name}/** -// Не защищён auth-токеном — это публичный эндпоинт для вызова функций. -// Проксирует запрос к ClusterIP Service функции внутри кластера: -// http://{name}.sless-fn-{namespace}.svc.cluster.local:8080 -// Sub-path и query string пробрасываются как есть: -// /fn/ns/notes/add?title=x → http://notes.sless-fn-ns.svc.../add?title=x -// Таймаут берётся из Spec.TimeoutSec функции (+ 5s буфер) чтобы не резать -// длительные вызовы (stress-тесты, batch-задачи). +// Не защищён auth-токеном — публичный эндпоинт. +// +// Два режима: +// 1. Service mode (sless_service): проксирует запрос к ClusterIP Deployment-пода. +// URL: http://{name}.sless-fn-{namespace}.svc.cluster.local:8080 +// Таймаут = Spec.TimeoutSec + 5s буфер. +// +// 2. Function mode (sless_function): создаёт FunctionJob CRD, ждёт завершения, +// возвращает содержимое Message (stdout функции). +// Таймаут = Spec.TimeoutSec (or 30s default). +// +// Порядок поиска: сначала Service CRD → Function CRD → 404. package handler import ( -"fmt" -"io" -"net/http" -"strings" -"time" + "context" + "fmt" + "io" + "net/http" + "strings" + "time" -"github.com/gorilla/mux" -slessv1alpha1 "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/api/v1alpha1" -"sigs.k8s.io/controller-runtime/pkg/client" + "github.com/gorilla/mux" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + slessv1alpha1 "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/api/v1alpha1" ) // hopByHopHeaders — заголовки которые нельзя пробрасывать через прокси (RFC 2616 §13.5.1). // Они управляют соединением между двумя узлами, а не end-to-end. // Особо опасен Transfer-Encoding: если пробросить его, клиент неверно интерпретирует тело. var hopByHopHeaders = map[string]bool{ -"Connection": true, -"Keep-Alive": true, -"Proxy-Authenticate": true, -"Proxy-Authorization": true, -"Te": true, -"Trailers": true, -"Transfer-Encoding": true, -"Upgrade": true, + "Connection": true, + "Keep-Alive": true, + "Proxy-Authenticate": true, + "Proxy-Authorization": true, + "Te": true, + "Trailers": true, + "Transfer-Encoding": true, + "Upgrade": true, } // invokeHTTPClient создаёт http.Client с таймаутом под конкретный вызов. -// timeout = TimeoutSec функции + 5s буфер на сетевые задержки. -// Если TimeoutSec == 0 (не задан), используем 30s по умолчанию. +// timeout = TimeoutSec + 5s буфер на сетевые задержки. +// Если TimeoutSec == 0 (не задан), используем 35s по умолчанию. func invokeHTTPClient(timeoutSec int32) *http.Client { -t := time.Duration(timeoutSec)*time.Second + 5*time.Second -if timeoutSec <= 0 { -t = 30 * time.Second -} -return &http.Client{Timeout: t} + t := time.Duration(timeoutSec)*time.Second + 5*time.Second + if timeoutSec <= 0 { + t = 35 * time.Second + } + return &http.Client{Timeout: t} } -// InvokeFunction проксирует входящий запрос к Service функции в кластере. -// Namespace выбирается из пути, имя функции — тоже из пути. -// Сохраняет метод, тело, Content-Type, sub-path и query string. -// Таймаут прокси-клиента = Spec.TimeoutSec функции + 5s (резинка). +// InvokeFunction обрабатывает вызов /fn/{namespace}/{name}[/**]. +// Определяет режим по типу ресурса: Service (proxy) или Function (Job). func (h *Handler) InvokeFunction(w http.ResponseWriter, r *http.Request) { -vars := mux.Vars(r) -ns := vars["namespace"] -name := vars["name"] + vars := mux.Vars(r) + ns := vars["namespace"] + name := vars["name"] -// Смотрим TimeoutSec из Function CRD, чтобы не резать длительные вызовы. -// Если функция не найдена — продолжаем с дефолтным таймаутом (30s). -var timeoutSec int32 -fn := &slessv1alpha1.Function{} -if err := h.K8s.Get(r.Context(), client.ObjectKey{Name: name, Namespace: ns}, fn); err == nil { -timeoutSec = fn.Spec.TimeoutSec -} -httpClient := invokeHTTPClient(timeoutSec) + // Пробуем Service CRD первым — это основной режим long-running функций + svc := &slessv1alpha1.Service{} + if err := h.K8s.Get(r.Context(), client.ObjectKey{Name: name, Namespace: ns}, svc); err == nil { + h.invokeServiceProxy(w, r, ns, name, svc.Spec.TimeoutSec) + return + } else if !errors.IsNotFound(err) { + h.Log.Error("invoke: get service CRD", "err", err, "ns", ns, "name", name) + writeJSON(w, http.StatusInternalServerError, errResp("internal error")) + return + } -// Вычисляем sub-path после /fn/{namespace}/{name} -// Например: /fn/default/notes/add → subPath = /add -prefix := "/fn/" + ns + "/" + name -subPath := strings.TrimPrefix(r.URL.Path, prefix) + // Пробуем Function CRD — oneshot режим (create Job, wait, return result) + fn := &slessv1alpha1.Function{} + if err := h.K8s.Get(r.Context(), client.ObjectKey{Name: name, Namespace: ns}, fn); err == nil { + h.invokeFunctionJob(w, r, ns, name, fn) + return + } else if !errors.IsNotFound(err) { + h.Log.Error("invoke: get function CRD", "err", err, "ns", ns, "name", name) + writeJSON(w, http.StatusInternalServerError, errResp("internal error")) + return + } -// Внутренний URL к Service функции (DNS внутри кластера) -target := fmt.Sprintf("http://%s.sless-fn-%s.svc.cluster.local:8080%s", name, ns, subPath) - -// Пробрасываем query string если есть -if r.URL.RawQuery != "" { -target += "?" + r.URL.RawQuery + writeJSON(w, http.StatusNotFound, errResp("function or service not found")) } -// Создаём проксируемый запрос с тем же методом и телом -proxyReq, err := http.NewRequestWithContext(r.Context(), r.Method, target, r.Body) -if err != nil { -h.Log.Error("invoke: failed to create proxy request", "err", err, "ns", ns, "fn", name) -writeJSON(w, http.StatusInternalServerError, errResp("failed to create proxy request")) -return +// invokeServiceProxy проксирует запрос к ClusterIP сервиса в кластере. +// Service — long-running Deployment, постоянно доступен по внутреннему DNS. +func (h *Handler) invokeServiceProxy(w http.ResponseWriter, r *http.Request, ns, name string, timeoutSec int32) { + httpClient := invokeHTTPClient(timeoutSec) + + // Вычисляем sub-path после /fn/{namespace}/{name} + // Например: /fn/default/notes/add → subPath = /add + prefix := "/fn/" + ns + "/" + name + subPath := strings.TrimPrefix(r.URL.Path, prefix) + + // Внутренний URL к k8s Service (DNS внутри кластера) + target := fmt.Sprintf("http://%s.sless-fn-%s.svc.cluster.local:8080%s", name, ns, subPath) + if r.URL.RawQuery != "" { + target += "?" + r.URL.RawQuery + } + + proxyReq, err := http.NewRequestWithContext(r.Context(), r.Method, target, r.Body) + if err != nil { + h.Log.Error("invoke: failed to create proxy request", "err", err, "ns", ns, "name", name) + writeJSON(w, http.StatusInternalServerError, errResp("failed to create proxy request")) + return + } + + // Content-Type и Content-Length обязательны для корректной работы Python/Node серверов. + // Content-Length: Python BaseHTTPRequestHandler читает тело ровно столько байт; + // без него body = пусто. + if ct := r.Header.Get("Content-Type"); ct != "" { + proxyReq.Header.Set("Content-Type", ct) + } + proxyReq.ContentLength = r.ContentLength + + resp, err := httpClient.Do(proxyReq) + if err != nil { + // "no such host" — k8s Service не существует (сервис удалён или не задеплоен) + if strings.Contains(err.Error(), "no such host") { + writeJSON(w, http.StatusNotFound, errResp("service not found or not ready")) + return + } + h.Log.Error("invoke: service unreachable", "err", err, "ns", ns, "name", name, "target", target) + writeJSON(w, http.StatusBadGateway, errResp("service unreachable: "+err.Error())) + return + } + defer resp.Body.Close() + + // Пробрасываем заголовки из ответа функции, фильтруя hop-by-hop + for k, vals := range resp.Header { + if hopByHopHeaders[k] { + continue + } + for _, v := range vals { + w.Header().Add(k, v) + } + } + w.WriteHeader(resp.StatusCode) + _, _ = io.Copy(w, resp.Body) } -// Пробрасываем Content-Type и Content-Length если есть. -// Content-Length обязателен: Python BaseHTTPRequestHandler читает тело -// ровно столько байт, сколько указано в заголовке; без него body = пусто. -if ct := r.Header.Get("Content-Type"); ct != "" { -proxyReq.Header.Set("Content-Type", ct) -} -proxyReq.ContentLength = r.ContentLength +// invokeFunctionJob создаёт FunctionJob CRD и синхронно ждёт завершения (polling 2s). +// Предназначен для sless_function — oneshot вызов без постоянного пода. +// Тело запроса передаётся как EventJSON в FunctionJobSpec. +// Job удаляется после получения результата (best-effort cleanup). +func (h *Handler) invokeFunctionJob(w http.ResponseWriter, r *http.Request, ns, name string, fn *slessv1alpha1.Function) { + if fn.Status.Phase != slessv1alpha1.FunctionPhaseReady { + writeJSON(w, http.StatusServiceUnavailable, errResp( + fmt.Sprintf("function not ready (phase: %s)", fn.Status.Phase), + )) + return + } -resp, err := httpClient.Do(proxyReq) -if err != nil { -// "no such host" — Service не существует (функция удалена), возвращаем 404. -// Это отличается от временной сетевой ошибки: NXDOMAIN строго означает отсутствие записи. -if strings.Contains(err.Error(), "no such host") { -writeJSON(w, http.StatusNotFound, errResp("function not found")) -return -} -h.Log.Error("invoke: function unreachable", "err", err, "ns", ns, "fn", name, "target", target) -writeJSON(w, http.StatusBadGateway, errResp("function unreachable: "+err.Error())) -return -} -defer resp.Body.Close() + // Читаем тело запроса как EventJSON (максимум 1MB). + // Функция получит это в handle(event) через runner. + eventJSON := "{}" + if r.Body != nil { + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err == nil && len(body) > 0 { + eventJSON = string(body) + } + } -// Копируем заголовки и статус из ответа функции. -// Hop-by-hop заголовки фильтруем: они управляют конкретным TCP-соединением -// и не должны пробрасываться через прокси (RFC 2616 §13.5.1). -for k, vals := range resp.Header { -if hopByHopHeaders[k] { -continue -} -for _, v := range vals { -w.Header().Add(k, v) -} -} -w.WriteHeader(resp.StatusCode) -_, _ = io.Copy(w, resp.Body) + // Уникальное имя Job = имя функции + unix nanos для уникальности + rawName := fmt.Sprintf("%s-%d", name, time.Now().UnixNano()) + jobName := rawName + if len(jobName) > 63 { + jobName = jobName[:63] + } + + fj := &slessv1alpha1.FunctionJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: jobName, + Namespace: ns, + }, + Spec: slessv1alpha1.FunctionJobSpec{ + FunctionRef: name, + EventJSON: eventJSON, + RunID: time.Now().UnixNano(), + }, + } + if err := h.K8s.Create(r.Context(), fj); err != nil { + h.Log.Error("invoke: create FunctionJob", "err", err, "ns", ns, "fn", name) + writeJSON(w, http.StatusInternalServerError, errResp("failed to create job: "+err.Error())) + return + } + + // Cleanup после получения результата — best-effort, не блокирует ответ. + // Используем context.Background() т.к. r.Context() может быть уже закрыт. + defer func() { + delCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = h.K8s.Delete(delCtx, fj) + }() + + // Опрашиваем каждые 2 секунды пока не завершится или не истечёт таймаут + timeoutSec := fn.Spec.TimeoutSec + if timeoutSec <= 0 { + timeoutSec = 30 + } + deadline := time.Now().Add(time.Duration(timeoutSec) * time.Second) + + for time.Now().Before(deadline) { + select { + case <-r.Context().Done(): + writeJSON(w, http.StatusGatewayTimeout, errResp("request cancelled")) + return + case <-time.After(2 * time.Second): + } + + current := &slessv1alpha1.FunctionJob{} + if err := h.K8s.Get(r.Context(), client.ObjectKey{Name: jobName, Namespace: ns}, current); err != nil { + h.Log.Error("invoke: poll FunctionJob", "err", err, "job", jobName) + continue + } + + switch current.Status.Phase { + case slessv1alpha1.FunctionJobPhaseSucceeded: + writeJSON(w, http.StatusOK, map[string]string{"result": current.Status.Message}) + return + case slessv1alpha1.FunctionJobPhaseFailed: + writeJSON(w, http.StatusInternalServerError, errResp(current.Status.Message)) + return + } + } + + writeJSON(w, http.StatusGatewayTimeout, errResp( + fmt.Sprintf("function %s/%s timed out after %ds", ns, name, timeoutSec), + )) } diff --git a/internal/api/handler/services.go b/internal/api/handler/services.go new file mode 100644 index 0000000..6ad5c00 --- /dev/null +++ b/internal/api/handler/services.go @@ -0,0 +1,305 @@ +// Создано: 2026-03-20 (function-service-split) +// services.go — CRUD handlers для Service CRD (sless_service). +// sless_service = long-running Deployment + URL. Каждый вызов проксируется к поду. +// Namespace берётся из URL: /v1/namespaces/{namespace}/services/{name} + +package handler + +import ( + "encoding/json" + "io" + "net/http" + "time" + + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + slessv1alpha1 "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/api/v1alpha1" + "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/builder" +) + +// serviceRequest — тело запроса для создания/обновления сервиса. +type serviceRequest struct { + Name string `json:"name"` + 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"` +} + +// serviceResponse — ответ при чтении сервиса. +// URL — ключевое отличие от functionResponse: заполняется оператором сразу после деплоя. +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 slessv1alpha1.ServicePhase `json:"phase"` + ImageRef string `json:"image_ref"` + URL string `json:"url,omitempty"` + Message string `json:"message,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + LastBuiltAt string `json:"last_built_at,omitempty"` +} + +// svcToResponse конвертирует Service CRD в ответ API. +func svcToResponse(svc *slessv1alpha1.Service) serviceResponse { + resp := serviceResponse{ + Name: svc.Name, + Namespace: svc.Namespace, + Runtime: svc.Spec.Runtime, + Entrypoint: svc.Spec.Entrypoint, + MemoryMB: svc.Spec.MemoryMB, + TimeoutSec: svc.Spec.TimeoutSec, + Env: svc.Spec.Env, + S3Bucket: svc.Spec.S3Bucket, + S3Key: svc.Spec.S3Key, + Phase: svc.Status.Phase, + ImageRef: svc.Status.ImageRef, + URL: svc.Status.URL, + Message: svc.Status.Message, + } + if !svc.CreationTimestamp.IsZero() { + resp.CreatedAt = svc.CreationTimestamp.UTC().Format("2006-01-02 15:04:05 UTC") + } + if svc.Status.LastBuiltAt != nil && !svc.Status.LastBuiltAt.IsZero() { + resp.LastBuiltAt = svc.Status.LastBuiltAt.UTC().Format("2006-01-02 15:04:05 UTC") + } + return resp +} + +// ListServices — GET /v1/namespaces/{namespace}/services +func (h *Handler) ListServices(w http.ResponseWriter, r *http.Request) { + ns := namespace(r) + list := &slessv1alpha1.ServiceList{} + if err := h.K8s.List(r.Context(), list, client.InNamespace(ns)); err != nil { + writeJSON(w, http.StatusInternalServerError, errResp(err.Error())) + return + } + result := make([]serviceResponse, 0, len(list.Items)) + for i := range list.Items { + result = append(result, svcToResponse(&list.Items[i])) + } + writeJSON(w, http.StatusOK, result) +} + +// CreateService — POST /v1/namespaces/{namespace}/services +func (h *Handler) CreateService(w http.ResponseWriter, r *http.Request) { + ns := namespace(r) + var req serviceRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, errResp("invalid JSON: "+err.Error())) + return + } + if req.Name == "" || req.Runtime == "" { + writeJSON(w, http.StatusBadRequest, errResp("name and runtime are required")) + return + } + if req.Entrypoint == "" { + writeJSON(w, http.StatusBadRequest, errResp("entrypoint is required")) + return + } + if req.MemoryMB <= 0 || req.MemoryMB > 4096 { + writeJSON(w, http.StatusBadRequest, errResp("memory_mb must be between 1 and 4096")) + return + } + + svc := &slessv1alpha1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: req.Name, + Namespace: ns, + }, + Spec: slessv1alpha1.ServiceSpec{ + Runtime: req.Runtime, + Entrypoint: req.Entrypoint, + MemoryMB: req.MemoryMB, + TimeoutSec: req.TimeoutSec, + Env: req.Env, + S3Bucket: req.S3Bucket, + S3Key: req.S3Key, + }, + } + if err := h.K8s.Create(r.Context(), svc); err != nil { + if errors.IsAlreadyExists(err) { + // Повторяем логику function_handler: обрабатываем split-brain кеша. + // Если объект реально есть и не в фазе Failed — возвращаем 409. + existing := &slessv1alpha1.Service{} + getErr := h.K8s.Get(r.Context(), client.ObjectKey{Name: req.Name, Namespace: ns}, existing) + shouldRecreate := errors.IsNotFound(getErr) || + (getErr == nil && existing.Status.Phase == slessv1alpha1.ServicePhaseFailed) + if shouldRecreate { + if getErr == nil { + _ = h.K8s.Delete(r.Context(), existing) + } + svc.ResourceVersion = "" + if createErr := h.K8s.Create(r.Context(), svc); createErr != nil { + writeJSON(w, http.StatusInternalServerError, errResp(createErr.Error())) + return + } + writeJSON(w, http.StatusCreated, svcToResponse(svc)) + return + } + writeJSON(w, http.StatusConflict, errResp("service already exists")) + return + } + writeJSON(w, http.StatusInternalServerError, errResp(err.Error())) + return + } + writeJSON(w, http.StatusCreated, svcToResponse(svc)) +} + +// GetService — GET /v1/namespaces/{namespace}/services/{name} +func (h *Handler) GetService(w http.ResponseWriter, r *http.Request) { + ns := namespace(r) + name := pathVar(r, "name") + svc := &slessv1alpha1.Service{} + if err := h.K8s.Get(r.Context(), client.ObjectKey{Name: name, Namespace: ns}, svc); err != nil { + if errors.IsNotFound(err) { + writeJSON(w, http.StatusNotFound, errResp("service not found")) + return + } + writeJSON(w, http.StatusInternalServerError, errResp(err.Error())) + return + } + writeJSON(w, http.StatusOK, svcToResponse(svc)) +} + +// UpdateService — PUT /v1/namespaces/{namespace}/services/{name} +func (h *Handler) UpdateService(w http.ResponseWriter, r *http.Request) { + ns := namespace(r) + name := pathVar(r, "name") + var req serviceRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, errResp("invalid JSON: "+err.Error())) + return + } + + svc := &slessv1alpha1.Service{} + if err := h.K8s.Get(r.Context(), client.ObjectKey{Name: name, Namespace: ns}, svc); err != nil { + if errors.IsNotFound(err) { + writeJSON(w, http.StatusNotFound, errResp("service not found")) + return + } + writeJSON(w, http.StatusInternalServerError, errResp(err.Error())) + return + } + + if req.Runtime == "" || req.Entrypoint == "" { + writeJSON(w, http.StatusBadRequest, errResp("runtime and entrypoint are required")) + return + } + if req.MemoryMB <= 0 || req.MemoryMB > 4096 { + writeJSON(w, http.StatusBadRequest, errResp("memory_mb must be between 1 and 4096")) + return + } + + svc.Spec.Runtime = req.Runtime + svc.Spec.Entrypoint = req.Entrypoint + svc.Spec.MemoryMB = req.MemoryMB + svc.Spec.TimeoutSec = req.TimeoutSec + svc.Spec.Env = req.Env + if req.S3Bucket != "" { + svc.Spec.S3Bucket = req.S3Bucket + } + if req.S3Key != "" { + svc.Spec.S3Key = req.S3Key + } + + if err := h.K8s.Update(r.Context(), svc); err != nil { + writeJSON(w, http.StatusInternalServerError, errResp(err.Error())) + return + } + writeJSON(w, http.StatusOK, svcToResponse(svc)) +} + +// DeleteService — DELETE /v1/namespaces/{namespace}/services/{name} +func (h *Handler) DeleteService(w http.ResponseWriter, r *http.Request) { + ns := namespace(r) + name := pathVar(r, "name") + svc := &slessv1alpha1.Service{} + if err := h.K8s.Get(r.Context(), client.ObjectKey{Name: name, Namespace: ns}, svc); err != nil { + if errors.IsNotFound(err) { + w.WriteHeader(http.StatusNoContent) + return + } + writeJSON(w, http.StatusInternalServerError, errResp(err.Error())) + return + } + if err := h.K8s.Delete(r.Context(), svc); err != nil { + writeJSON(w, http.StatusInternalServerError, errResp(err.Error())) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// UploadServiceCode — POST /v1/namespaces/{namespace}/services/{name}/upload +// Принимает multipart/form-data с полем "code" (zip архив с кодом сервиса). +// Обновляет Service CRD — оператор запустит kaniko и затем задеплоит Deployment. +// Логика идентична UploadCode (functions), но работает с Service CRD. +func (h *Handler) UploadServiceCode(w http.ResponseWriter, r *http.Request) { + ns := namespace(r) + name := pathVar(r, "name") + + svc := &slessv1alpha1.Service{} + if err := h.K8s.Get(r.Context(), client.ObjectKey{Name: name, Namespace: ns}, svc); err != nil { + if errors.IsNotFound(err) { + writeJSON(w, http.StatusNotFound, errResp("service not found")) + return + } + writeJSON(w, http.StatusInternalServerError, errResp(err.Error())) + return + } + + if err := r.ParseMultipartForm(32 << 20); err != nil { + writeJSON(w, http.StatusBadRequest, errResp("invalid multipart form: "+err.Error())) + return + } + file, _, err := r.FormFile("code") + if err != nil { + writeJSON(w, http.StatusBadRequest, errResp(`field "code" is required (zip file)`)) + return + } + defer file.Close() + + zipData, err := io.ReadAll(file) + if err != nil { + writeJSON(w, http.StatusInternalServerError, errResp("read upload: "+err.Error())) + return + } + + buf, err := builder.PrepareContext(zipData, svc.Spec.Runtime) + if err != nil { + writeJSON(w, http.StatusBadRequest, errResp("prepare build context: "+err.Error())) + return + } + + version := time.Now().Format("20060102150405") + s3Key, err := h.S3.UploadContext(r.Context(), ns, name, version, buf, int64(buf.Len())) + if err != nil { + writeJSON(w, http.StatusInternalServerError, errResp("upload to S3: "+err.Error())) + return + } + + patch := client.MergeFrom(svc.DeepCopy()) + svc.Spec.S3Key = s3Key + svc.Spec.S3Bucket = h.S3.Bucket() + if err := h.K8s.Patch(r.Context(), svc, patch); err != nil { + writeJSON(w, http.StatusInternalServerError, errResp("update service: "+err.Error())) + return + } + + writeJSON(w, http.StatusOK, map[string]string{ + "s3_key": s3Key, + "phase": string(slessv1alpha1.ServicePhasePending), + "message": "build queued", + }) +} diff --git a/internal/api/router.go b/internal/api/router.go index 7cae452..4f43259 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -1,4 +1,4 @@ -// Изменено: 2026-04-25 +// Изменено: 2026-03-20 (function-service-split: добавлены /services маршруты) // router.go — регистрация всех REST-маршрутов через gorilla/mux. // Все маршруты защищены Bearer-токеном (middleware.Auth). // Маршруты сгруппированы по /v1/namespaces/{namespace}/... @@ -48,6 +48,14 @@ func NewRouter(h *handler.Handler, log *slog.Logger) http.Handler { // Source code — возвращает файлы из tar.gz контекста сборки (без Dockerfile) v1.HandleFunc("/namespaces/{namespace}/functions/{name}/source", h.GetSource).Methods(http.MethodGet) + // Services CRUD — long-running Deployment + URL (sless_service) + v1.HandleFunc("/namespaces/{namespace}/services", h.ListServices).Methods(http.MethodGet) + v1.HandleFunc("/namespaces/{namespace}/services", h.CreateService).Methods(http.MethodPost) + v1.HandleFunc("/namespaces/{namespace}/services/{name}", h.GetService).Methods(http.MethodGet) + v1.HandleFunc("/namespaces/{namespace}/services/{name}", h.UpdateService).Methods(http.MethodPut) + v1.HandleFunc("/namespaces/{namespace}/services/{name}", h.DeleteService).Methods(http.MethodDelete) + v1.HandleFunc("/namespaces/{namespace}/services/{name}/upload", h.UploadServiceCode).Methods(http.MethodPost) + // Triggers CRUD v1.HandleFunc("/namespaces/{namespace}/triggers", h.ListTriggers).Methods(http.MethodGet) v1.HandleFunc("/namespaces/{namespace}/triggers", h.CreateTrigger).Methods(http.MethodPost) diff --git a/main.go b/main.go index 555989c..9fe1697 100644 --- a/main.go +++ b/main.go @@ -1,4 +1,4 @@ -// Изменено: 2026-03-07 +// Изменено: 2026-03-20 (function-service-split: добавлена регистрация ServiceReconciler) // main.go — точка входа. Запускает operator manager и REST API сервер параллельно. // Operator manager управляет Function/Trigger CRD через reconcile loop. // REST API (gorilla/mux) принимает запросы от Terraform provider. @@ -154,6 +154,20 @@ func main() { log.Error("unable to create controller", "controller", "Function", "err", err) os.Exit(1) } + if err = (&controllers.ServiceReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Builder: bldr, + KubeClient: kubernetes.NewForConfigOrDie(mgr.GetConfig()), + RegistrySecret: cfg.RegistrySecret, + OperatorNamespace: "sless", + HarborClient: harborClient, + ExternalURL: cfg.ExternalURL, + IngressHost: cfg.IngressHost, + }).SetupWithManager(mgr); err != nil { + log.Error("unable to create controller", "controller", "Service", "err", err) + os.Exit(1) + } if err = (&controllers.TriggerReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), diff --git a/terraform/provider/internal/client/client.go b/terraform/provider/internal/client/client.go index 775aac3..058ee18 100644 --- a/terraform/provider/internal/client/client.go +++ b/terraform/provider/internal/client/client.go @@ -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) +} diff --git a/terraform/provider/internal/provider/provider.go b/terraform/provider/internal/provider/provider.go index 1238903..daeaa1a 100644 --- a/terraform/provider/internal/provider/provider.go +++ b/terraform/provider/internal/provider/provider.go @@ -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, } diff --git a/terraform/provider/internal/resources/service_resource.go b/terraform/provider/internal/resources/service_resource.go new file mode 100644 index 0000000..36f8b9a --- /dev/null +++ b/terraform/provider/internal/resources/service_resource.go @@ -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), + } +}