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
|
package v1alpha1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"k8s.io/apimachinery/pkg/apis/meta/v1"
|
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
runtime "k8s.io/apimachinery/pkg/runtime"
|
runtime "k8s.io/apimachinery/pkg/runtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -288,6 +288,113 @@ func (in *TriggerList) DeepCopyObject() runtime.Object {
|
|||||||
return nil
|
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.
|
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||||
func (in *TriggerSpec) DeepCopyInto(out *TriggerSpec) {
|
func (in *TriggerSpec) DeepCopyInto(out *TriggerSpec) {
|
||||||
*out = *in
|
*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
|
- jsonPath: .status.url
|
||||||
name: URL
|
name: URL
|
||||||
type: string
|
type: string
|
||||||
|
- jsonPath: .spec.queue
|
||||||
|
name: Queue
|
||||||
|
type: string
|
||||||
- jsonPath: .metadata.creationTimestamp
|
- jsonPath: .metadata.creationTimestamp
|
||||||
name: Age
|
name: Age
|
||||||
type: date
|
type: date
|
||||||
@@ -72,15 +75,21 @@ spec:
|
|||||||
Актуально для cron: запускаем pod заранее чтобы избежать cold start.
|
Актуально для cron: запускаем pod заранее чтобы избежать cold start.
|
||||||
format: int32
|
format: int32
|
||||||
type: integer
|
type: integer
|
||||||
|
queue:
|
||||||
|
description: |-
|
||||||
|
Queue — имя AMQP очереди в RabbitMQ (только для type=event).
|
||||||
|
event-dispatcher подпишется на эту очередь и вызовет функцию при каждом сообщении.
|
||||||
|
type: string
|
||||||
schedule:
|
schedule:
|
||||||
description: 'Schedule — расписание в формате cron (только для type=cron,
|
description: 'Schedule — расписание в формате cron (только для type=cron,
|
||||||
например: "0 2 * * *")'
|
например: "0 2 * * *")'
|
||||||
type: string
|
type: string
|
||||||
type:
|
type:
|
||||||
description: 'Type — тип триггера: http или cron'
|
description: 'Type — тип триггера: http, cron или event'
|
||||||
enum:
|
enum:
|
||||||
- http
|
- http
|
||||||
- cron
|
- cron
|
||||||
|
- event
|
||||||
type: string
|
type: string
|
||||||
required:
|
required:
|
||||||
- enabled
|
- enabled
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
// Изменено: 2026-03-11
|
// Изменено: 2026-03-20 (function-service-split: FunctionReconciler — только build pipeline)
|
||||||
// FunctionReconciler — основной контроллер оператора.
|
// FunctionReconciler — контроллер Function CRD (sless_function = oneshot/Job).
|
||||||
// Следит за CRD Function и управляет lifecycle функции:
|
// Функция = код который выполняется ОДИН РАЗ через k8s Job при каждом вызове.
|
||||||
// Pending → Building (запуск kaniko Job) → Ready (образ собран, Deployment создан) / Failed
|
// Нет Deployment, нет постоянного URL. Вызов — через FunctionJob или invoke API.
|
||||||
// Reconcile вызывается k8s при любом изменении Function объекта.
|
// Reconciler отвечает только за:
|
||||||
|
// 1. Сборку Docker-образа через kaniko (Pending → Building → Ready/Failed)
|
||||||
|
// 2. Очистку ресурсов при удалении (kaniko Job)
|
||||||
|
// Deployment/Service/Ingress — в ServiceReconciler (sless_service).
|
||||||
|
|
||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
@@ -11,15 +14,11 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
appsv1 "k8s.io/api/apps/v1"
|
|
||||||
corev1 "k8s.io/api/core/v1"
|
corev1 "k8s.io/api/core/v1"
|
||||||
netv1 "k8s.io/api/networking/v1"
|
|
||||||
"k8s.io/apimachinery/pkg/api/errors"
|
"k8s.io/apimachinery/pkg/api/errors"
|
||||||
"k8s.io/apimachinery/pkg/api/resource"
|
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
"k8s.io/apimachinery/pkg/runtime"
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
"k8s.io/client-go/kubernetes"
|
"k8s.io/client-go/kubernetes"
|
||||||
@@ -97,7 +96,9 @@ func (r *FunctionReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c
|
|||||||
case slessv1alpha1.FunctionPhaseBuilding:
|
case slessv1alpha1.FunctionPhaseBuilding:
|
||||||
return r.checkBuild(ctx, fn)
|
return r.checkBuild(ctx, fn)
|
||||||
case slessv1alpha1.FunctionPhaseReady:
|
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
|
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
|
return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ensureDeployment создаёт или обновляет Deployment для HTTP функции.
|
// handleDeletion обрабатывает удаление Function: убивает kaniko Job и убирает finalizer.
|
||||||
// Deployment запускается в отдельном namespace sless-fn-{namespace}.
|
// Deployment/Service/Ingress Function не создаёт — они принадлежат Service CRD.
|
||||||
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} НЕ удаляется — он принадлежит пользователю на всё время его существования.
|
|
||||||
func (r *FunctionReconciler) handleDeletion(ctx context.Context, fn *slessv1alpha1.Function) (ctrl.Result, error) {
|
func (r *FunctionReconciler) handleDeletion(ctx context.Context, fn *slessv1alpha1.Function) (ctrl.Result, error) {
|
||||||
deployNS := "sless-fn-" + fn.Namespace
|
// Убиваем kaniko Job если сборка шла в момент удаления
|
||||||
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/память и запушит образ которым никто не воспользуется.
|
|
||||||
if jobName := fn.Annotations["sless.kube5s.ru/build-job"]; jobName != "" {
|
if jobName := fn.Annotations["sless.kube5s.ru/build-job"]; jobName != "" {
|
||||||
_ = r.Builder.Cleanup(ctx, 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)
|
fn.Finalizers = removeString(fn.Finalizers, finalizerName)
|
||||||
if err := r.Update(ctx, fn); err != nil {
|
if err := r.Update(ctx, fn); err != nil {
|
||||||
return ctrl.Result{}, fmt.Errorf("remove finalizer: %w", err)
|
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:
|
containers:
|
||||||
- name: operator
|
- name: operator
|
||||||
# При обновлении версии оператора — менять тег здесь (не latest!)
|
# При обновлении версии оператора — менять тег здесь (не latest!)
|
||||||
image: naeel/sless-operator:v0.1.33
|
image: naeel/sless-operator:v0.1.41
|
||||||
# Always — чтобы всегда тянуть по точному тегу (не кешировать старый)
|
# Always — чтобы всегда тянуть по точному тегу (не кешировать старый)
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
ports:
|
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 оператора.
|
# RBAC для sless оператора.
|
||||||
# ServiceAccount + ClusterRole + ClusterRoleBinding.
|
# ServiceAccount + ClusterRole + ClusterRoleBinding.
|
||||||
# ClusterRole нужен (не namespaced Role) потому что оператор создаёт
|
# ClusterRole нужен (не namespaced Role) потому что оператор создаёт
|
||||||
@@ -17,13 +17,13 @@ metadata:
|
|||||||
rules:
|
rules:
|
||||||
# Наши CRD
|
# Наши CRD
|
||||||
- apiGroups: ["sless.kube5s.ru"]
|
- apiGroups: ["sless.kube5s.ru"]
|
||||||
resources: ["functions", "triggers", "functionjobs"]
|
resources: ["functions", "triggers", "functionjobs", "services"]
|
||||||
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
|
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
|
||||||
- apiGroups: ["sless.kube5s.ru"]
|
- apiGroups: ["sless.kube5s.ru"]
|
||||||
resources: ["functions/status", "triggers/status", "functionjobs/status"]
|
resources: ["functions/status", "triggers/status", "functionjobs/status", "services/status"]
|
||||||
verbs: ["get", "update", "patch"]
|
verbs: ["get", "update", "patch"]
|
||||||
- apiGroups: ["sless.kube5s.ru"]
|
- apiGroups: ["sless.kube5s.ru"]
|
||||||
resources: ["functions/finalizers", "triggers/finalizers", "functionjobs/finalizers"]
|
resources: ["functions/finalizers", "triggers/finalizers", "functionjobs/finalizers", "services/finalizers"]
|
||||||
verbs: ["update"]
|
verbs: ["update"]
|
||||||
# Deployments для функций
|
# Deployments для функций
|
||||||
- apiGroups: ["apps"]
|
- 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 (без хардкода).
|
// 2026-03-20 — содержимое перенесено в два файла:
|
||||||
// Для сверки хардкод остаётся в terraform.tfvars на этапе разработки.
|
// postgres.tf — managed PostgreSQL ресурсы (nubes_postgres, user, database, locals)
|
||||||
// sless_function и sless_job закомментированы — сначала проверяется сетевое соединение.
|
// functions.tf — sless функции, сервисы, джобы, outputs
|
||||||
|
|
||||||
# Актуальные 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
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
+208
-96
@@ -1,125 +1,237 @@
|
|||||||
// Изменено: 2026-03-12
|
// Изменено: 2026-03-20 (function-service-split: dual-mode invoke)
|
||||||
// invoke.go — прокси-обработчик для вызова HTTP-триггеров функций.
|
// invoke.go — обработчик вызова функций и сервисов.
|
||||||
// Маршрут: ANY /fn/{namespace}/{name} и /fn/{namespace}/{name}/**
|
// Маршрут: ANY /fn/{namespace}/{name} и /fn/{namespace}/{name}/**
|
||||||
// Не защищён auth-токеном — это публичный эндпоинт для вызова функций.
|
// Не защищён auth-токеном — публичный эндпоинт.
|
||||||
// Проксирует запрос к ClusterIP Service функции внутри кластера:
|
//
|
||||||
// http://{name}.sless-fn-{namespace}.svc.cluster.local:8080
|
// Два режима:
|
||||||
// Sub-path и query string пробрасываются как есть:
|
// 1. Service mode (sless_service): проксирует запрос к ClusterIP Deployment-пода.
|
||||||
// /fn/ns/notes/add?title=x → http://notes.sless-fn-ns.svc.../add?title=x
|
// URL: http://{name}.sless-fn-{namespace}.svc.cluster.local:8080
|
||||||
// Таймаут берётся из Spec.TimeoutSec функции (+ 5s буфер) чтобы не резать
|
// Таймаут = Spec.TimeoutSec + 5s буфер.
|
||||||
// длительные вызовы (stress-тесты, batch-задачи).
|
//
|
||||||
|
// 2. Function mode (sless_function): создаёт FunctionJob CRD, ждёт завершения,
|
||||||
|
// возвращает содержимое Message (stdout функции).
|
||||||
|
// Таймаут = Spec.TimeoutSec (or 30s default).
|
||||||
|
//
|
||||||
|
// Порядок поиска: сначала Service CRD → Function CRD → 404.
|
||||||
|
|
||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"context"
|
||||||
"io"
|
"fmt"
|
||||||
"net/http"
|
"io"
|
||||||
"strings"
|
"net/http"
|
||||||
"time"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
slessv1alpha1 "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/api/v1alpha1"
|
"k8s.io/apimachinery/pkg/api/errors"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
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).
|
// hopByHopHeaders — заголовки которые нельзя пробрасывать через прокси (RFC 2616 §13.5.1).
|
||||||
// Они управляют соединением между двумя узлами, а не end-to-end.
|
// Они управляют соединением между двумя узлами, а не end-to-end.
|
||||||
// Особо опасен Transfer-Encoding: если пробросить его, клиент неверно интерпретирует тело.
|
// Особо опасен Transfer-Encoding: если пробросить его, клиент неверно интерпретирует тело.
|
||||||
var hopByHopHeaders = map[string]bool{
|
var hopByHopHeaders = map[string]bool{
|
||||||
"Connection": true,
|
"Connection": true,
|
||||||
"Keep-Alive": true,
|
"Keep-Alive": true,
|
||||||
"Proxy-Authenticate": true,
|
"Proxy-Authenticate": true,
|
||||||
"Proxy-Authorization": true,
|
"Proxy-Authorization": true,
|
||||||
"Te": true,
|
"Te": true,
|
||||||
"Trailers": true,
|
"Trailers": true,
|
||||||
"Transfer-Encoding": true,
|
"Transfer-Encoding": true,
|
||||||
"Upgrade": true,
|
"Upgrade": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// invokeHTTPClient создаёт http.Client с таймаутом под конкретный вызов.
|
// invokeHTTPClient создаёт http.Client с таймаутом под конкретный вызов.
|
||||||
// timeout = TimeoutSec функции + 5s буфер на сетевые задержки.
|
// timeout = TimeoutSec + 5s буфер на сетевые задержки.
|
||||||
// Если TimeoutSec == 0 (не задан), используем 30s по умолчанию.
|
// Если TimeoutSec == 0 (не задан), используем 35s по умолчанию.
|
||||||
func invokeHTTPClient(timeoutSec int32) *http.Client {
|
func invokeHTTPClient(timeoutSec int32) *http.Client {
|
||||||
t := time.Duration(timeoutSec)*time.Second + 5*time.Second
|
t := time.Duration(timeoutSec)*time.Second + 5*time.Second
|
||||||
if timeoutSec <= 0 {
|
if timeoutSec <= 0 {
|
||||||
t = 30 * time.Second
|
t = 35 * time.Second
|
||||||
}
|
}
|
||||||
return &http.Client{Timeout: t}
|
return &http.Client{Timeout: t}
|
||||||
}
|
}
|
||||||
|
|
||||||
// InvokeFunction проксирует входящий запрос к Service функции в кластере.
|
// InvokeFunction обрабатывает вызов /fn/{namespace}/{name}[/**].
|
||||||
// Namespace выбирается из пути, имя функции — тоже из пути.
|
// Определяет режим по типу ресурса: Service (proxy) или Function (Job).
|
||||||
// Сохраняет метод, тело, Content-Type, sub-path и query string.
|
|
||||||
// Таймаут прокси-клиента = Spec.TimeoutSec функции + 5s (резинка).
|
|
||||||
func (h *Handler) InvokeFunction(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) InvokeFunction(w http.ResponseWriter, r *http.Request) {
|
||||||
vars := mux.Vars(r)
|
vars := mux.Vars(r)
|
||||||
ns := vars["namespace"]
|
ns := vars["namespace"]
|
||||||
name := vars["name"]
|
name := vars["name"]
|
||||||
|
|
||||||
// Смотрим TimeoutSec из Function CRD, чтобы не резать длительные вызовы.
|
// Пробуем Service CRD первым — это основной режим long-running функций
|
||||||
// Если функция не найдена — продолжаем с дефолтным таймаутом (30s).
|
svc := &slessv1alpha1.Service{}
|
||||||
var timeoutSec int32
|
if err := h.K8s.Get(r.Context(), client.ObjectKey{Name: name, Namespace: ns}, svc); err == nil {
|
||||||
fn := &slessv1alpha1.Function{}
|
h.invokeServiceProxy(w, r, ns, name, svc.Spec.TimeoutSec)
|
||||||
if err := h.K8s.Get(r.Context(), client.ObjectKey{Name: name, Namespace: ns}, fn); err == nil {
|
return
|
||||||
timeoutSec = fn.Spec.TimeoutSec
|
} else if !errors.IsNotFound(err) {
|
||||||
}
|
h.Log.Error("invoke: get service CRD", "err", err, "ns", ns, "name", name)
|
||||||
httpClient := invokeHTTPClient(timeoutSec)
|
writeJSON(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Вычисляем sub-path после /fn/{namespace}/{name}
|
// Пробуем Function CRD — oneshot режим (create Job, wait, return result)
|
||||||
// Например: /fn/default/notes/add → subPath = /add
|
fn := &slessv1alpha1.Function{}
|
||||||
prefix := "/fn/" + ns + "/" + name
|
if err := h.K8s.Get(r.Context(), client.ObjectKey{Name: name, Namespace: ns}, fn); err == nil {
|
||||||
subPath := strings.TrimPrefix(r.URL.Path, prefix)
|
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 внутри кластера)
|
writeJSON(w, http.StatusNotFound, errResp("function or service not found"))
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Создаём проксируемый запрос с тем же методом и телом
|
// invokeServiceProxy проксирует запрос к ClusterIP сервиса в кластере.
|
||||||
proxyReq, err := http.NewRequestWithContext(r.Context(), r.Method, target, r.Body)
|
// Service — long-running Deployment, постоянно доступен по внутреннему DNS.
|
||||||
if err != nil {
|
func (h *Handler) invokeServiceProxy(w http.ResponseWriter, r *http.Request, ns, name string, timeoutSec int32) {
|
||||||
h.Log.Error("invoke: failed to create proxy request", "err", err, "ns", ns, "fn", name)
|
httpClient := invokeHTTPClient(timeoutSec)
|
||||||
writeJSON(w, http.StatusInternalServerError, errResp("failed to create proxy request"))
|
|
||||||
return
|
// Вычисляем 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 если есть.
|
// invokeFunctionJob создаёт FunctionJob CRD и синхронно ждёт завершения (polling 2s).
|
||||||
// Content-Length обязателен: Python BaseHTTPRequestHandler читает тело
|
// Предназначен для sless_function — oneshot вызов без постоянного пода.
|
||||||
// ровно столько байт, сколько указано в заголовке; без него body = пусто.
|
// Тело запроса передаётся как EventJSON в FunctionJobSpec.
|
||||||
if ct := r.Header.Get("Content-Type"); ct != "" {
|
// Job удаляется после получения результата (best-effort cleanup).
|
||||||
proxyReq.Header.Set("Content-Type", ct)
|
func (h *Handler) invokeFunctionJob(w http.ResponseWriter, r *http.Request, ns, name string, fn *slessv1alpha1.Function) {
|
||||||
}
|
if fn.Status.Phase != slessv1alpha1.FunctionPhaseReady {
|
||||||
proxyReq.ContentLength = r.ContentLength
|
writeJSON(w, http.StatusServiceUnavailable, errResp(
|
||||||
|
fmt.Sprintf("function not ready (phase: %s)", fn.Status.Phase),
|
||||||
|
))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
resp, err := httpClient.Do(proxyReq)
|
// Читаем тело запроса как EventJSON (максимум 1MB).
|
||||||
if err != nil {
|
// Функция получит это в handle(event) через runner.
|
||||||
// "no such host" — Service не существует (функция удалена), возвращаем 404.
|
eventJSON := "{}"
|
||||||
// Это отличается от временной сетевой ошибки: NXDOMAIN строго означает отсутствие записи.
|
if r.Body != nil {
|
||||||
if strings.Contains(err.Error(), "no such host") {
|
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||||
writeJSON(w, http.StatusNotFound, errResp("function not found"))
|
if err == nil && len(body) > 0 {
|
||||||
return
|
eventJSON = string(body)
|
||||||
}
|
}
|
||||||
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()
|
|
||||||
|
|
||||||
// Копируем заголовки и статус из ответа функции.
|
// Уникальное имя Job = имя функции + unix nanos для уникальности
|
||||||
// Hop-by-hop заголовки фильтруем: они управляют конкретным TCP-соединением
|
rawName := fmt.Sprintf("%s-%d", name, time.Now().UnixNano())
|
||||||
// и не должны пробрасываться через прокси (RFC 2616 §13.5.1).
|
jobName := rawName
|
||||||
for k, vals := range resp.Header {
|
if len(jobName) > 63 {
|
||||||
if hopByHopHeaders[k] {
|
jobName = jobName[:63]
|
||||||
continue
|
}
|
||||||
}
|
|
||||||
for _, v := range vals {
|
fj := &slessv1alpha1.FunctionJob{
|
||||||
w.Header().Add(k, v)
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
}
|
Name: jobName,
|
||||||
}
|
Namespace: ns,
|
||||||
w.WriteHeader(resp.StatusCode)
|
},
|
||||||
_, _ = io.Copy(w, resp.Body)
|
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.
|
// router.go — регистрация всех REST-маршрутов через gorilla/mux.
|
||||||
// Все маршруты защищены Bearer-токеном (middleware.Auth).
|
// Все маршруты защищены Bearer-токеном (middleware.Auth).
|
||||||
// Маршруты сгруппированы по /v1/namespaces/{namespace}/...
|
// Маршруты сгруппированы по /v1/namespaces/{namespace}/...
|
||||||
@@ -48,6 +48,14 @@ func NewRouter(h *handler.Handler, log *slog.Logger) http.Handler {
|
|||||||
// Source code — возвращает файлы из tar.gz контекста сборки (без Dockerfile)
|
// Source code — возвращает файлы из tar.gz контекста сборки (без Dockerfile)
|
||||||
v1.HandleFunc("/namespaces/{namespace}/functions/{name}/source", h.GetSource).Methods(http.MethodGet)
|
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
|
// Triggers CRUD
|
||||||
v1.HandleFunc("/namespaces/{namespace}/triggers", h.ListTriggers).Methods(http.MethodGet)
|
v1.HandleFunc("/namespaces/{namespace}/triggers", h.ListTriggers).Methods(http.MethodGet)
|
||||||
v1.HandleFunc("/namespaces/{namespace}/triggers", h.CreateTrigger).Methods(http.MethodPost)
|
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 сервер параллельно.
|
// main.go — точка входа. Запускает operator manager и REST API сервер параллельно.
|
||||||
// Operator manager управляет Function/Trigger CRD через reconcile loop.
|
// Operator manager управляет Function/Trigger CRD через reconcile loop.
|
||||||
// REST API (gorilla/mux) принимает запросы от Terraform provider.
|
// REST API (gorilla/mux) принимает запросы от Terraform provider.
|
||||||
@@ -154,6 +154,20 @@ func main() {
|
|||||||
log.Error("unable to create controller", "controller", "Function", "err", err)
|
log.Error("unable to create controller", "controller", "Function", "err", err)
|
||||||
os.Exit(1)
|
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{
|
if err = (&controllers.TriggerReconciler{
|
||||||
Client: mgr.GetClient(),
|
Client: mgr.GetClient(),
|
||||||
Scheme: mgr.GetScheme(),
|
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)
|
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.
|
// provider.go — описание провайдера sless для Terraform.
|
||||||
//
|
//
|
||||||
// Архитектура инициализации (Configure):
|
// Архитектура инициализации (Configure):
|
||||||
@@ -167,6 +167,7 @@ func (p *SlessProvider) Configure(ctx context.Context, req provider.ConfigureReq
|
|||||||
func (p *SlessProvider) Resources(_ context.Context) []func() resource.Resource {
|
func (p *SlessProvider) Resources(_ context.Context) []func() resource.Resource {
|
||||||
return []func() resource.Resource{
|
return []func() resource.Resource{
|
||||||
resources.NewFunctionResource,
|
resources.NewFunctionResource,
|
||||||
|
resources.NewServiceResource,
|
||||||
resources.NewTriggerResource,
|
resources.NewTriggerResource,
|
||||||
resources.NewJobResource,
|
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