feat: sless_service CRD + ServiceReconciler, RBAC fix, split postgres/functions.tf, operator v0.1.41
This commit is contained in:
@@ -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{})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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: {}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
+127
-1
@@ -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` — удалены.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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/<namespace>/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/<namespace>/pg-table-reader
|
||||
# https://sless.kube5s.ru/fn/<namespace>/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]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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/<namespace>/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/<namespace>/pg-table-reader
|
||||
# https://sless.kube5s.ru/fn/<namespace>/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
|
||||
|
||||
+208
-96
@@ -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),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -547,3 +547,170 @@ func (c *Client) WaitJobDone(ctx context.Context, ns, name string, timeout time.
|
||||
}
|
||||
return nil, fmt.Errorf("timeout waiting for job %s/%s to complete", ns, name)
|
||||
}
|
||||
|
||||
// --- Service CRUD (sless_service — long-running Deployment + URL) ---
|
||||
|
||||
// ServiceRequest — тело POST/PUT /v1/namespaces/{ns}/services[/{name}]
|
||||
type ServiceRequest struct {
|
||||
Name string `json:"name"`
|
||||
Runtime string `json:"runtime"`
|
||||
Entrypoint string `json:"entrypoint,omitempty"`
|
||||
MemoryMB int32 `json:"memory_mb,omitempty"`
|
||||
TimeoutSec int32 `json:"timeout_sec,omitempty"`
|
||||
Env map[string]string `json:"env_vars,omitempty"`
|
||||
}
|
||||
|
||||
// ServiceResponse — ответ GET /v1/namespaces/{ns}/services/{name}
|
||||
// URL — ключевое поле: заполняется оператором после деплоя Deployment+Ingress.
|
||||
type ServiceResponse struct {
|
||||
Name string `json:"name"`
|
||||
Namespace string `json:"namespace"`
|
||||
Runtime string `json:"runtime"`
|
||||
Entrypoint string `json:"entrypoint"`
|
||||
MemoryMB int32 `json:"memory_mb"`
|
||||
TimeoutSec int32 `json:"timeout_sec"`
|
||||
Env map[string]string `json:"env_vars"`
|
||||
S3Bucket string `json:"s3_bucket"`
|
||||
S3Key string `json:"s3_key"`
|
||||
Phase string `json:"phase"`
|
||||
ImageRef string `json:"image_ref"`
|
||||
URL string `json:"url"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// CreateService — POST /v1/namespaces/{ns}/services → 201
|
||||
func (c *Client) CreateService(ctx context.Context, ns string, req ServiceRequest) (*ServiceResponse, error) {
|
||||
url := fmt.Sprintf("%s/v1/namespaces/%s/services", c.endpoint, ns)
|
||||
resp, err := c.doJSON(ctx, http.MethodPost, url, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("create service: status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
var svc ServiceResponse
|
||||
return &svc, json.NewDecoder(resp.Body).Decode(&svc)
|
||||
}
|
||||
|
||||
// GetService — GET /v1/namespaces/{ns}/services/{name} → nil если 404
|
||||
func (c *Client) GetService(ctx context.Context, ns, name string) (*ServiceResponse, error) {
|
||||
url := fmt.Sprintf("%s/v1/namespaces/%s/services/%s", c.endpoint, ns, name)
|
||||
resp, err := c.doJSON(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("get service: status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
var svc ServiceResponse
|
||||
return &svc, json.NewDecoder(resp.Body).Decode(&svc)
|
||||
}
|
||||
|
||||
// UpdateService — PUT /v1/namespaces/{ns}/services/{name} → 200
|
||||
func (c *Client) UpdateService(ctx context.Context, ns, name string, req ServiceRequest) (*ServiceResponse, error) {
|
||||
url := fmt.Sprintf("%s/v1/namespaces/%s/services/%s", c.endpoint, ns, name)
|
||||
resp, err := c.doJSON(ctx, http.MethodPut, url, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("update service: status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
var svc ServiceResponse
|
||||
return &svc, json.NewDecoder(resp.Body).Decode(&svc)
|
||||
}
|
||||
|
||||
// DeleteService — DELETE /v1/namespaces/{ns}/services/{name} → 204
|
||||
func (c *Client) DeleteService(ctx context.Context, ns, name string) error {
|
||||
url := fmt.Sprintf("%s/v1/namespaces/%s/services/%s", c.endpoint, ns, name)
|
||||
resp, err := c.doJSON(ctx, http.MethodDelete, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusNotFound {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("delete service: status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UploadServiceCode — POST /v1/namespaces/{ns}/services/{name}/upload (multipart, field=code)
|
||||
// После вызова оператор начинает kaniko-сборку и затем деплоит Deployment.
|
||||
func (c *Client) UploadServiceCode(ctx context.Context, ns, name, zipPath string) error {
|
||||
f, err := os.Open(zipPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open zip %q: %w", zipPath, err)
|
||||
}
|
||||
defer f.Close()
|
||||
return c.UploadServiceCodeReader(ctx, ns, name, filepath.Base(zipPath), f)
|
||||
}
|
||||
|
||||
// UploadServiceCodeReader — загружает код сервиса из произвольного io.Reader.
|
||||
func (c *Client) UploadServiceCodeReader(ctx context.Context, ns, name, filename string, r io.Reader) error {
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
fw, err := mw.CreateFormFile("code", filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create form file: %w", err)
|
||||
}
|
||||
if _, err := io.Copy(fw, r); err != nil {
|
||||
return fmt.Errorf("copy zip: %w", err)
|
||||
}
|
||||
mw.Close()
|
||||
|
||||
url := fmt.Sprintf("%s/v1/namespaces/%s/services/%s/upload", c.endpoint, ns, name)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("upload service code: status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WaitServiceReady опрашивает сервис каждые 5 секунд пока phase != Ready/Failed.
|
||||
// Нужен после UploadServiceCode — kaniko-сборка + деплой занимают ~1-2 минуты.
|
||||
func (c *Client) WaitServiceReady(ctx context.Context, ns, name string, timeout time.Duration) (*ServiceResponse, error) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
svc, err := c.GetService(ctx, ns, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if svc == nil {
|
||||
return nil, fmt.Errorf("service %s/%s not found while waiting", ns, name)
|
||||
}
|
||||
switch svc.Phase {
|
||||
case "Ready":
|
||||
return svc, nil
|
||||
case "Failed":
|
||||
return nil, fmt.Errorf("service build failed: %s", svc.Message)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(5 * time.Second):
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("timeout waiting for service %s/%s to become Ready", ns, name)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// 2026-03-11
|
||||
// 2026-03-20 (function-service-split: добавлен NewServiceResource)
|
||||
// provider.go — описание провайдера sless для Terraform.
|
||||
//
|
||||
// Архитектура инициализации (Configure):
|
||||
@@ -167,6 +167,7 @@ func (p *SlessProvider) Configure(ctx context.Context, req provider.ConfigureReq
|
||||
func (p *SlessProvider) Resources(_ context.Context) []func() resource.Resource {
|
||||
return []func() resource.Resource{
|
||||
resources.NewFunctionResource,
|
||||
resources.NewServiceResource,
|
||||
resources.NewTriggerResource,
|
||||
resources.NewJobResource,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
// Создано: 2026-03-20 (function-service-split)
|
||||
// service_resource.go — Terraform ресурс sless_service.
|
||||
// sless_service = long-running Deployment + постоянный URL.
|
||||
// Отличие от sless_function: у Service есть url в state (заполняет оператор после деплоя).
|
||||
//
|
||||
// Lifecycle:
|
||||
// Create: POST /services → если code_path/source_dir задан: upload zip → WaitServiceReady
|
||||
// Read: GET /services/{name} → sync state (включая url)
|
||||
// Update: PUT /services/{name} → если code_hash изменился: upload zip → WaitServiceReady
|
||||
// Delete: DELETE /services/{name}
|
||||
//
|
||||
// code_hash, source_dir, build_timeout_sec — логика идентична FunctionResource.
|
||||
|
||||
package resources
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"terraform-provider-sless/internal/client"
|
||||
|
||||
"github.com/hashicorp/terraform-plugin-framework-validators/int64validator"
|
||||
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
|
||||
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
|
||||
"github.com/hashicorp/terraform-plugin-framework/types"
|
||||
)
|
||||
|
||||
var _ resource.Resource = &ServiceResource{}
|
||||
var _ resource.ResourceWithModifyPlan = &ServiceResource{}
|
||||
|
||||
type ServiceResource struct {
|
||||
client *client.Client
|
||||
}
|
||||
|
||||
func NewServiceResource() resource.Resource {
|
||||
return &ServiceResource{}
|
||||
}
|
||||
|
||||
// ServiceModel — модель состояния terraform для sless_service.
|
||||
// Добавлено поле URL — заполняется оператором, только для чтения.
|
||||
type ServiceModel struct {
|
||||
Name types.String `tfsdk:"name"`
|
||||
Runtime types.String `tfsdk:"runtime"`
|
||||
Entrypoint types.String `tfsdk:"entrypoint"`
|
||||
MemoryMB types.Int64 `tfsdk:"memory_mb"`
|
||||
TimeoutSec types.Int64 `tfsdk:"timeout_sec"`
|
||||
EnvVars types.Map `tfsdk:"env_vars"`
|
||||
CodePath types.String `tfsdk:"code_path"`
|
||||
SourceDir types.String `tfsdk:"source_dir"`
|
||||
CodeHash types.String `tfsdk:"code_hash"`
|
||||
BuildTimeoutSec types.Int64 `tfsdk:"build_timeout_sec"`
|
||||
Phase types.String `tfsdk:"phase"`
|
||||
ImageRef types.String `tfsdk:"image_ref"`
|
||||
// URL — публичный URL сервиса. Заполняется оператором после деплоя.
|
||||
// Доступен сразу без sless_trigger (в отличие от sless_function).
|
||||
URL types.String `tfsdk:"url"`
|
||||
}
|
||||
|
||||
func (r *ServiceResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
|
||||
resp.TypeName = req.ProviderTypeName + "_service"
|
||||
}
|
||||
|
||||
func (r *ServiceResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
|
||||
resp.Schema = schema.Schema{
|
||||
MarkdownDescription: "Long-running serverless service с постоянным URL. Разворачивается как k8s Deployment + Ingress.",
|
||||
Attributes: map[string]schema.Attribute{
|
||||
"name": schema.StringAttribute{
|
||||
Required: true,
|
||||
PlanModifiers: []planmodifier.String{
|
||||
stringplanmodifier.RequiresReplace(),
|
||||
},
|
||||
},
|
||||
"runtime": schema.StringAttribute{
|
||||
Required: true,
|
||||
PlanModifiers: []planmodifier.String{
|
||||
stringplanmodifier.RequiresReplace(),
|
||||
},
|
||||
Validators: []validator.String{
|
||||
stringvalidator.OneOf("nodejs20", "python3.11", "go1.23"),
|
||||
},
|
||||
},
|
||||
"entrypoint": schema.StringAttribute{
|
||||
Optional: true,
|
||||
Computed: true,
|
||||
PlanModifiers: []planmodifier.String{
|
||||
stringplanmodifier.UseStateForUnknown(),
|
||||
},
|
||||
},
|
||||
"memory_mb": schema.Int64Attribute{
|
||||
Optional: true,
|
||||
Computed: true,
|
||||
Validators: []validator.Int64{
|
||||
int64validator.Between(1, 4096),
|
||||
},
|
||||
},
|
||||
// timeout_sec для сервиса = прокси-таймаут invoke (не таймаут Job).
|
||||
// При длинных операциях (batch) увеличивай.
|
||||
"timeout_sec": schema.Int64Attribute{
|
||||
Optional: true,
|
||||
Computed: true,
|
||||
Validators: []validator.Int64{
|
||||
int64validator.Between(1, 900),
|
||||
},
|
||||
},
|
||||
"env_vars": schema.MapAttribute{
|
||||
ElementType: types.StringType,
|
||||
Optional: true,
|
||||
},
|
||||
"code_path": schema.StringAttribute{
|
||||
Optional: true,
|
||||
},
|
||||
"source_dir": schema.StringAttribute{
|
||||
Optional: true,
|
||||
MarkdownDescription: "Путь к директории с исходным кодом. Провайдер сам упакует в zip.",
|
||||
},
|
||||
"code_hash": schema.StringAttribute{
|
||||
Optional: true,
|
||||
Computed: true,
|
||||
},
|
||||
"build_timeout_sec": schema.Int64Attribute{
|
||||
Optional: true,
|
||||
Computed: true,
|
||||
MarkdownDescription: "Таймаут ожидания сборки и деплоя в секундах. По умолчанию 300.",
|
||||
},
|
||||
"phase": schema.StringAttribute{
|
||||
Computed: true,
|
||||
PlanModifiers: []planmodifier.String{
|
||||
stringplanmodifier.UseStateForUnknown(),
|
||||
},
|
||||
},
|
||||
"image_ref": schema.StringAttribute{
|
||||
Computed: true,
|
||||
},
|
||||
// url — только для чтения, вычисляется оператором.
|
||||
// Используй в outputs или как зависимость для других ресурсов.
|
||||
"url": schema.StringAttribute{
|
||||
Computed: true,
|
||||
PlanModifiers: []planmodifier.String{
|
||||
stringplanmodifier.UseStateForUnknown(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ServiceResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
|
||||
if req.ProviderData == nil {
|
||||
return
|
||||
}
|
||||
c, ok := req.ProviderData.(*client.Client)
|
||||
if !ok {
|
||||
resp.Diagnostics.AddError(
|
||||
"unexpected provider data",
|
||||
fmt.Sprintf("expected *client.Client, got: %T", req.ProviderData),
|
||||
)
|
||||
return
|
||||
}
|
||||
r.client = c
|
||||
}
|
||||
|
||||
func (r *ServiceResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
|
||||
var plan ServiceModel
|
||||
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
ns := r.client.Namespace
|
||||
envVars, d := mapToStringMap(ctx, plan.EnvVars)
|
||||
resp.Diagnostics.Append(d...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
svc, err := r.client.CreateService(ctx, ns, client.ServiceRequest{
|
||||
Name: plan.Name.ValueString(),
|
||||
Runtime: plan.Runtime.ValueString(),
|
||||
Entrypoint: plan.Entrypoint.ValueString(),
|
||||
MemoryMB: int32(plan.MemoryMB.ValueInt64()),
|
||||
TimeoutSec: int32(plan.TimeoutSec.ValueInt64()),
|
||||
Env: envVars,
|
||||
})
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddError("create service", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var codeUploaded bool
|
||||
if !plan.SourceDir.IsNull() && plan.SourceDir.ValueString() != "" {
|
||||
zipData, hash, err := zipDir(plan.SourceDir.ValueString())
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddError("zip source_dir", err.Error())
|
||||
return
|
||||
}
|
||||
if err := r.client.UploadServiceCodeReader(ctx, ns, svc.Name, "code.zip", bytes.NewReader(zipData)); err != nil {
|
||||
resp.Diagnostics.AddError("upload service code", err.Error())
|
||||
return
|
||||
}
|
||||
plan.CodeHash = types.StringValue(hash)
|
||||
codeUploaded = true
|
||||
} else if !plan.CodePath.IsNull() && plan.CodePath.ValueString() != "" {
|
||||
if err := r.client.UploadServiceCode(ctx, ns, svc.Name, plan.CodePath.ValueString()); err != nil {
|
||||
resp.Diagnostics.AddError("upload service code", err.Error())
|
||||
return
|
||||
}
|
||||
codeUploaded = true
|
||||
}
|
||||
if codeUploaded {
|
||||
buildSec := plan.BuildTimeoutSec.ValueInt64()
|
||||
if buildSec <= 0 {
|
||||
buildSec = defaultBuildTimeoutSec
|
||||
}
|
||||
svc, err = r.client.WaitServiceReady(ctx, ns, svc.Name, time.Duration(buildSec)*time.Second)
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddError("waiting for service ready", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
resp.Diagnostics.Append(resp.State.Set(ctx, svcToModel(plan, svc))...)
|
||||
}
|
||||
|
||||
func (r *ServiceResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
|
||||
var state ServiceModel
|
||||
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
svc, err := r.client.GetService(ctx, r.client.Namespace, state.Name.ValueString())
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddError("read service", err.Error())
|
||||
return
|
||||
}
|
||||
if svc == nil {
|
||||
resp.State.RemoveResource(ctx)
|
||||
return
|
||||
}
|
||||
|
||||
resp.Diagnostics.Append(resp.State.Set(ctx, svcToModel(state, svc))...)
|
||||
}
|
||||
|
||||
func (r *ServiceResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
|
||||
var plan, state ServiceModel
|
||||
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
|
||||
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
ns := r.client.Namespace
|
||||
name := plan.Name.ValueString()
|
||||
|
||||
envVars, d := mapToStringMap(ctx, plan.EnvVars)
|
||||
resp.Diagnostics.Append(d...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
svc, err := r.client.UpdateService(ctx, ns, name, client.ServiceRequest{
|
||||
Name: name,
|
||||
Runtime: plan.Runtime.ValueString(),
|
||||
Entrypoint: plan.Entrypoint.ValueString(),
|
||||
MemoryMB: int32(plan.MemoryMB.ValueInt64()),
|
||||
TimeoutSec: int32(plan.TimeoutSec.ValueInt64()),
|
||||
Env: envVars,
|
||||
})
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddError("update service", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var codeUploaded bool
|
||||
if !plan.SourceDir.IsNull() && plan.SourceDir.ValueString() != "" {
|
||||
zipData, hash, err := zipDir(plan.SourceDir.ValueString())
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddError("zip source_dir", err.Error())
|
||||
return
|
||||
}
|
||||
newHash := types.StringValue(hash)
|
||||
if !newHash.Equal(state.CodeHash) {
|
||||
if err := r.client.UploadServiceCodeReader(ctx, ns, name, "code.zip", bytes.NewReader(zipData)); err != nil {
|
||||
resp.Diagnostics.AddError("upload service code", err.Error())
|
||||
return
|
||||
}
|
||||
codeUploaded = true
|
||||
}
|
||||
plan.CodeHash = newHash
|
||||
} else if !plan.CodeHash.Equal(state.CodeHash) && !plan.CodePath.IsNull() && plan.CodePath.ValueString() != "" {
|
||||
if err := r.client.UploadServiceCode(ctx, ns, name, plan.CodePath.ValueString()); err != nil {
|
||||
resp.Diagnostics.AddError("upload service code", err.Error())
|
||||
return
|
||||
}
|
||||
codeUploaded = true
|
||||
}
|
||||
if codeUploaded {
|
||||
buildSec := plan.BuildTimeoutSec.ValueInt64()
|
||||
if buildSec <= 0 {
|
||||
buildSec = defaultBuildTimeoutSec
|
||||
}
|
||||
svc, err = r.client.WaitServiceReady(ctx, ns, name, time.Duration(buildSec)*time.Second)
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddError("waiting for service ready", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
resp.Diagnostics.Append(resp.State.Set(ctx, svcToModel(plan, svc))...)
|
||||
}
|
||||
|
||||
func (r *ServiceResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
|
||||
var state ServiceModel
|
||||
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.client.DeleteService(ctx, r.client.Namespace, state.Name.ValueString()); err != nil {
|
||||
resp.Diagnostics.AddError("delete service", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// ModifyPlan вычисляет hash директории source_dir на фазе plan.
|
||||
// Идентична FunctionResource.ModifyPlan — без этого terraform не видит изменения кода.
|
||||
func (r *ServiceResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) {
|
||||
if req.Plan.Raw.IsNull() {
|
||||
return
|
||||
}
|
||||
var plan ServiceModel
|
||||
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
if plan.SourceDir.IsNull() || plan.SourceDir.ValueString() == "" {
|
||||
return
|
||||
}
|
||||
_, hash, err := zipDir(plan.SourceDir.ValueString())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
plan.CodeHash = types.StringValue(hash)
|
||||
resp.Diagnostics.Append(resp.Plan.Set(ctx, plan)...)
|
||||
}
|
||||
|
||||
// svcToModel конвертирует API-ответ + plan → state модель.
|
||||
// URL берётся из API (заполняется оператором), остальные read-only поля — тоже.
|
||||
func svcToModel(plan ServiceModel, svc *client.ServiceResponse) ServiceModel {
|
||||
buildTimeoutSec := plan.BuildTimeoutSec
|
||||
if buildTimeoutSec.IsNull() || buildTimeoutSec.IsUnknown() || buildTimeoutSec.ValueInt64() <= 0 {
|
||||
buildTimeoutSec = types.Int64Value(defaultBuildTimeoutSec)
|
||||
}
|
||||
return ServiceModel{
|
||||
Name: types.StringValue(svc.Name),
|
||||
Runtime: types.StringValue(svc.Runtime),
|
||||
Entrypoint: types.StringValue(svc.Entrypoint),
|
||||
MemoryMB: types.Int64Value(int64(svc.MemoryMB)),
|
||||
TimeoutSec: types.Int64Value(int64(svc.TimeoutSec)),
|
||||
EnvVars: plan.EnvVars,
|
||||
CodePath: plan.CodePath,
|
||||
SourceDir: plan.SourceDir,
|
||||
CodeHash: plan.CodeHash,
|
||||
BuildTimeoutSec: buildTimeoutSec,
|
||||
Phase: types.StringValue(svc.Phase),
|
||||
ImageRef: types.StringValue(svc.ImageRef),
|
||||
URL: types.StringValue(svc.URL),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user