Author SHA1 Message Date
Naeel dc65f7ab8f fix: SSH ключ перенесён в ~/.ssh/naeel_vm_id_ed25519 (локально, вне sshfs) 2026-03-20 09:46:32 +03:00
Naeel df84eae75a doc: стресс-тест — 45918 ops, 0 ошибок, 76.5 ops/sec за 600s 2026-03-19 21:51:22 +03:00
Naeel 45bd9389e5 doc: Go runtime v0.1.1, баги 4+5, решения pgx/v5 + dynamic timeout
- progress.md: секция v0.1.1 →  ЗАВЕРШЕНО, все задачи, таблица таймаутов, арх. функции
- errors/log.md: Баг 4 (invoke.go 30s хардкод → context deadline exceeded) + Баг 5 (nginx ingress отсутствие proxy-read-timeout → 504)
- decisions/log.md: pgx/v5 vs database/sql+lib/pq (с обоснованием), динамический таймаут (почему +5s, почему не кешировать, деградация)
2026-03-19 21:39:34 +03:00
Naeel d7fda15d35 feat: Go runtime v0.1.1 (pgx/v5), stress-go-pgstorm, fix invoke.go dynamic timeout, nginx ingress timeout 900s
- runtimes/go1.23: добавлен pgx/v5 v5.7.2 в go.mod, сгенерирован go.sum
- runtimes/go1.23/Dockerfile: один stage golang:1.23-alpine, go mod download кеширует зависимости
- internal/builder/context.go: тег Go runtime v0.1.0 → v0.1.1
- internal/api/handler/invoke.go: таймаут прокси-клиента теперь динамический из Function.Spec.TimeoutSec + 5s буфер (был хардкод 30s)
- examples/POSTGRES/code/stress-go-pgstorm/handler.go: новая функция, 100 горутин, pgxpool, INSERT/COUNT/MAX, параметры: workers/duration_sec/max_delay_ms
- examples/POSTGRES/resources.tf: добавлены sless_function.stress_go_pgstorm + trigger, timeout_sec=700
- deployments/k8s/operator.yaml: nginx ingress proxy-read-timeout=900s, proxy-send-timeout=900s
- examples/*/main.tf: исправлен URL deck-api-test.ngcloud.ru → deck-test.ngcloud.ru (все 9 файлов)
- Оператор: v0.1.39 (pgx/v5) → v0.1.40 (dynamic timeout)
2026-03-19 21:33:41 +03:00
Naeel 8dc07445ac doc: Tests 3-7, 8 стресс-функций, план Go runtime v0.1.1 + pgx/v5 2026-03-19 20:31:49 +03:00
Naeel 014b99ed16 test: 8 стресс-функций (py/go/js), crash-тесты, stress_test.sh — 32 строки в PG 2026-03-19 19:44:26 +03:00
Naeel d87981713d test: полный прогон POSTGRES example — changes, delete/recreate, new function 2026-03-19 18:45:18 +03:00
Naeel 8a8b815492 fix: убрать --no-cache из kaniko Args — флаг не поддерживается в gcr.io/kaniko-project/executor:latest, кэш отключён по умолчанию 2026-03-19 17:38:07 +03:00
Naeel 0ebae25877 feat: event-dispatcher v0.1.0 — Dockerfile, деплой в кластер 2026-03-19 13:51:27 +03:00
Naeel a379091b8a feat: event-trigger (Вариант A) — TriggerTypeEvent, event-dispatcher, reconcileEvent 2026-03-19 13:44:35 +03:00
Naeel 1aac3f5093 doc: архитектура event-trigger — выбран Вариант A (отдельный event-dispatcher) 2026-03-19 13:21:57 +03:00
Naeel d286d92a05 fix: invoke.go — forward Content-Length to proxied request (form POST fix)
Without ContentLength, Python BaseHTTPRequestHandler read 0 bytes from body.
operator v0.1.37, python runtime v0.1.4, pg-table-writer HTML form
2026-03-19 08:58:35 +03:00
Naeel 2c194f6a7f docs: sync all documentation for agent handoff (v0.1.34 + funcs-service v0.2.0) 2026-03-18 20:19:48 +03:00
Naeel a04dfb2d0c fix+docs: FunctionJob label bugfix, job ErrAlreadyExists, python str→text/plain, operator.yaml v0.1.33, progress.md
- controllers/functionjob_controller.go:
  - PodTemplate labels: functionjob=, function= (k8s 1.27+ удалил job-name=)
  - getJobPodOutput принимает labelSelector вместо jobName
  - захват stderr при Failed job; truncateForStatus() helper
- terraform/provider/internal/client/client.go: ErrJobAlreadyExists (409 Conflict)
- terraform/provider/internal/resources/job_resource.go: при конфликте создания — читаем существующий job
- runtimes/python3.11/server.py: str return → text/plain
- internal/builder/context.go: python runtime base image → v0.1.3
- deployments/k8s/operator.yaml: image → v0.1.33
- doc/progress.md: добавлены секции FunctionJob bugfix, str→text/plain, web-console v0.2.0
2026-03-18 17:41:44 +03:00
Naeel bf9f07385e feat: web-console — HTML UI + source viewer + trigger toggle
- operator: GET /v1/namespaces/{ns}/functions/{name}/source
  reads build context tar.gz from S3, strips Dockerfile, returns JSON files
- funcs-service v0.2.0:
  - Accept: text/html → dark-themed HTML console with accordion cards
  - GET /funcs/{ns}/source/{fn} → proxy to operator (service token auth)
  - PATCH /funcs/{ns}/triggers/{name} → proxy enable/disable (only enabled field)
  - curl (no text/html Accept) → plain text as before (backward compat)
- highlight.js syntax highlighting per file extension
- operator v0.1.34, funcs-service v0.2.0
2026-03-18 17:24:43 +03:00
Naeel a3528ff4fe docs: document all changes and web-console plan
- doc/progress.md: funcs global service v0.1.x, URLs, next steps
- doc/decisions/log.md: storage architecture, funcs service decision, web-console plan
- doc/api/design.md: current + planned endpoints (/source, PATCH trigger)
- doc/architecture/overview.md: funcs-service component, S3 storage diagram
2026-03-18 16:38:18 +03:00
Naeel 38bb494ed5 feat: funcs-service v0.1.3 — /funcs/<namespace> URL without token
- /funcs/<namespace>: user opens URL in browser, no auth needed
  service uses SLESS_SERVICE_TOKEN to query operator internally
- /funcs?token=<jwt>: token as query param (bookmarkable URL)
- /funcs: returns usage hint with both URL formats
- SLESS_SERVICE_TOKEN set via kubectl set env (not stored in repo)
2026-03-18 16:11:17 +03:00
Naeel e8cd62e171 feat: funcs as global Go service (sless-funcs-service:v0.1.1)
- services/funcs/main.go: standalone Go HTTP server
  extracts JWT sub -> SHA256[:8] -> namespace -> calls operator API
  returns plain text list, sorted active first, /health probe endpoint
- services/funcs/Dockerfile: multi-stage Go build -> alpine
- deployments/k8s/funcs-service.yaml: Deployment+Service+Ingress in sless ns
  ingress path /funcs -> sless-funcs-service:8090, reuses sless-operator-tls
- examples/POSTGRES/resources.tf: removed funcs_list function+trigger+output

Image: naeel/sless-funcs-service:v0.1.1
2026-03-18 15:58:08 +03:00
Naeel 9bc91841c8 feat: NodeJS pg-info function; funcs endpoint: filter + created_at/last_built_at; operator v0.1.32 2026-03-18 11:03:58 +03:00
Naeel 6010649e7b feat: add funcs endpoint — list all functions with triggers for UI 2026-03-18 10:05:15 +03:00
Naeel ba0375d47e feat: POSTGRES example — add pg-table-reader HTTP function 2026-03-18 09:23:13 +03:00
Naeel 531a54af5d docs: 2026-03-18 DNS incident — sless-api→sless.kube5s.ru, POSTGRES E2E PASS 2026-03-18 09:07:47 +03:00
Naeel cca3a8cdc1 fix: migrate sless endpoint from sless-api.kube5s.ru to sless.kube5s.ru (new cluster ingress 185.247.187.147) 2026-03-18 08:48:50 +03:00
Naeel 8f841e8c81 fix: remove all prod endpoint references from examples (test stand only) 2026-03-18 08:08:20 +03:00
Naeel 5d9045babc docs: E2E results 2026-03-17 — all 4 examples PASS on test stand 2026-03-18 06:27:57 +03:00
“Naeel” 1294ad993f 0 2026-03-15 09:38:27 +04:00
Naeel 8dd5b676c0 feat: demo managed functions with harbor-backed operator setup 2026-03-14 18:16:53 +03:00
Naeel cbd2c8c44c chore: ignore *.tfvars in examples, remove ai_hint_level from pg-list-python 2026-03-12 09:21:20 +03:00
Naeel 2798f3b896 chore: provider v0.1.18, update examples (harbor branch) 2026-03-12 09:19:17 +03:00
112 changed files with 8118 additions and 224 deletions
+14
View File
@@ -46,6 +46,20 @@
---
## Именование
Имена должны быть **уникальными и осмысленными по всему проекту**:
- имена файлов
- имена функций/методов
- имена переменных/констант
- имена ресурсов (Terraform, Kubernetes и т.д.)
Цель: чтобы поиск по проекту находил нужные сущности без неоднозначности, а имя сразу отражало назначение.
Запрещены безликие и повторяющиеся имена вида `handler.py`, `handle`, `data`, `value`, `temp` без контекста.
---
## Git
Коммитить и пушить после каждого завершённого этапа.
+7
View File
@@ -43,6 +43,9 @@ examples/*/dist/
**/handler.zip
test.token
*.tfvars
*.tfplan
plan.out
sless-plan
.e2e-logs/
.stress-logs/
@@ -57,4 +60,8 @@ test.token
**/dist/
# дополнительные вариации переменных/файлов конфигурации
*.tfvars.json
*.tfplan
plan.out
sless-plan
examples/.git
event-dispatcher
+25
View File
@@ -0,0 +1,25 @@
# Изменено: 2026-03-19
# Multi-stage build для event-dispatcher.
# Stage 1: сборка бинаря
# Stage 2: минимальный образ (нужен ca-certificates для TLS к RabbitMQ и k8s API)
FROM golang:1.25-alpine AS builder
ARG TARGETOS
ARG TARGETARCH
WORKDIR /workspace
COPY go.mod go.mod
COPY go.sum go.sum
RUN go mod download
COPY api/ api/
COPY services/event-dispatcher/ services/event-dispatcher/
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} \
go build -a -o event-dispatcher ./services/event-dispatcher/
FROM alpine:3.19
RUN apk add --no-cache ca-certificates
WORKDIR /
COPY --from=builder /workspace/event-dispatcher .
USER 65532:65532
ENTRYPOINT ["/event-dispatcher"]
+14 -4
View File
@@ -1,6 +1,8 @@
// Изменено: 2026-03-08
// Описание CRD Trigger — триггер для функции (HTTP или Cron).
// Изменено: 2026-03-19
// Описание CRD Trigger — триггер для функции (HTTP, Cron или Event).
// Один Trigger ссылается на одну Function и определяет способ вызова.
// Event-тип: event-dispatcher подписывается на AMQP очередь и при сообщении
// вызывает функцию по внутреннему HTTP.
package v1alpha1
@@ -16,6 +18,9 @@ const (
TriggerTypeHTTP TriggerType = "http"
// TriggerTypeCron — функция вызывается по расписанию (k8s CronJob)
TriggerTypeCron TriggerType = "cron"
// TriggerTypeEvent — функция вызывается при получении сообщения из AMQP очереди.
// event-dispatcher подписывается на spec.queue в RabbitMQ и делает POST на HTTP endpoint функции.
TriggerTypeEvent TriggerType = "event"
)
// TriggerSpec — желаемое состояние триггера.
@@ -30,8 +35,8 @@ type TriggerSpec struct {
// +kubebuilder:validation:Required
FunctionRef string `json:"functionRef"`
// Type — тип триггера: http или cron
// +kubebuilder:validation:Enum=http;cron
// Type — тип триггера: http, cron или event
// +kubebuilder:validation:Enum=http;cron;event
// +kubebuilder:validation:Required
Type TriggerType `json:"type"`
@@ -42,6 +47,10 @@ type TriggerSpec struct {
// Актуально для cron: запускаем pod заранее чтобы избежать cold start.
// +kubebuilder:default=300
PreWarmSeconds int32 `json:"preWarmSeconds,omitempty"`
// Queue — имя AMQP очереди в RabbitMQ (только для type=event).
// event-dispatcher подпишется на эту очередь и вызовет функцию при каждом сообщении.
Queue string `json:"queue,omitempty"`
}
// TriggerStatus — наблюдаемое состояние триггера (заполняет контроллер).
@@ -65,6 +74,7 @@ type TriggerStatus struct {
//+kubebuilder:printcolumn:name="Function",type=string,JSONPath=`.spec.functionRef`
//+kubebuilder:printcolumn:name="Active",type=boolean,JSONPath=`.status.active`
//+kubebuilder:printcolumn:name="URL",type=string,JSONPath=`.status.url`
//+kubebuilder:printcolumn:name="Queue",type=string,JSONPath=`.spec.queue`
//+kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
// Trigger — ресурс для управления способом вызова Function.
+7
View File
@@ -20,6 +20,13 @@ rules:
- get
- list
- watch
- apiGroups:
- ""
resources:
- secrets
verbs:
- create
- get
- apiGroups:
- ""
resources:
+38 -7
View File
@@ -1,4 +1,4 @@
// Изменено: 2026-03-09 (feature B: захват stdout пода Job в status.Message)
// Изменено: 2026-03-17 20:00 (bugfix: job-name label удалён в k8s 1.27+, split-brain cached client)
// FunctionJobReconciler — контроллер одноразовых запусков функций.
// При создании FunctionJob:
// 1. Ждёт пока Function станет Ready
@@ -134,6 +134,13 @@ func (r *FunctionJobReconciler) Reconcile(ctx context.Context, req ctrl.Request)
// Автоудаление через 10 мин после завершения — чтобы не засорять кластер
TTLSecondsAfterFinished: &ttl,
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{
"managed-by": "sless",
"functionjob": fj.Name,
"function": fn.Name,
},
},
Spec: corev1.PodSpec{
RestartPolicy: corev1.RestartPolicyNever,
// Используем тот же образ что и Deployment функции
@@ -194,12 +201,19 @@ func (r *FunctionJobReconciler) syncJobStatus(ctx context.Context, fj *slessv1al
fj.Status.Phase = slessv1alpha1.FunctionJobPhaseSucceeded
fj.Status.CompletionTime = &now
// Захватываем stdout пода — это return value функции (runner делает print(json.dumps(result)))
fj.Status.Message = getJobPodOutput(ctx, r.KubeClient, job.Namespace, job.Name)
fj.Status.Message = getJobPodOutput(ctx, r.KubeClient, job.Namespace, "functionjob="+fj.Name)
} else if job.Status.Failed > 0 {
now := metav1.Now()
fj.Status.Phase = slessv1alpha1.FunctionJobPhaseFailed
fj.Status.CompletionTime = &now
fj.Status.Message = "job failed, check pod logs: kubectl logs -n sless-fn-" + fj.Namespace + " -l functionjob=" + fj.Name
// Захватываем логи по нашему лейблу functionjob= (работает во всех версиях k8s).
// Устаревший job-name= удалён в k8s 1.27+, batch.kubernetes.io/job-name= — только с 1.27.
podOutput := strings.TrimSpace(getJobPodOutput(ctx, r.KubeClient, job.Namespace, "functionjob="+fj.Name))
if podOutput == "" || podOutput == "completed successfully" {
fj.Status.Message = "job failed, check pod logs: kubectl logs -n " + job.Namespace + " -l functionjob=" + fj.Name
} else {
fj.Status.Message = "job failed: " + truncateForStatus(podOutput, 2000)
}
} else {
// Job ещё выполняется — перечитаем через 5 секунд
if err := r.Status().Update(ctx, fj); err != nil {
@@ -213,6 +227,17 @@ func (r *FunctionJobReconciler) syncJobStatus(ctx context.Context, fj *slessv1al
return ctrl.Result{}, nil
}
// truncateForStatus ограничивает длину текста для безопасной записи в status.message.
func truncateForStatus(message string, maxLen int) string {
if len(message) <= maxLen {
return message
}
if maxLen <= 3 {
return message[:maxLen]
}
return message[:maxLen-3] + "..."
}
// runtimeRunnerCommand возвращает CMD для запуска одноразового runner вместо HTTP-сервера.
// runner читает env SLESS_EVENT и SLESS_ENTRYPOINT, вызывает handle(event) один раз и завершается.
func runtimeRunnerCommand(runtime string) []string {
@@ -280,16 +305,22 @@ func goJobModeEnv(runtime string) []corev1.EnvVar {
return nil
}
// getJobPodOutput находит под созданный Job-ом и возвращает его stdout (trimmed).
// runner.py/runner.js печатают json.dumps(result) в stdout — это и есть return value функции.
// getJobPodOutput находит под по labelSelector и возвращает его stdout+stderr (trimmed).
// runner.py/runner.js печатают json.dumps(result) в stdout — return value функции.
// Исключения/трейсбэки Python/Node пишут в stderr — поэтому собираем оба потока.
// Если под не найден или логи недоступны — возвращает "completed successfully" как fallback.
func getJobPodOutput(ctx context.Context, kube kubernetes.Interface, namespace, jobName string) string {
// labelSelector передаётся снаружи — вызывающий код использует "functionjob=<name>" (наш лейбл,
// выставляется на PodTemplate контроллером и не зависит от версии k8s).
// НЕ использовать "job-name=" — этот встроенный лейбл удалён в k8s 1.27+ (у нас 1.34.1).
func getJobPodOutput(ctx context.Context, kube kubernetes.Interface, namespace, labelSelector string) string {
pods, err := kube.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{
LabelSelector: "job-name=" + jobName,
LabelSelector: labelSelector,
})
if err != nil || len(pods.Items) == 0 {
return "completed successfully"
}
// Stdout: true, Stderr: true — собираем оба потока.
// Python исключения идут в stderr, runner.py пишет результат в stdout.
req := kube.CoreV1().Pods(namespace).GetLogs(pods.Items[0].Name, &corev1.PodLogOptions{})
stream, err := req.Stream(ctx)
if err != nil {
+44
View File
@@ -124,6 +124,9 @@ func (r *TriggerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct
case slessv1alpha1.TriggerTypeCron:
logger.Info("reconcile cron trigger", "trigger", tr.Name)
return r.reconcileCron(ctx, tr, fn)
case slessv1alpha1.TriggerTypeEvent:
logger.Info("reconcile event trigger", "trigger", tr.Name)
return r.reconcileEvent(ctx, tr, fn)
}
return ctrl.Result{}, nil
@@ -325,3 +328,44 @@ func (r *TriggerReconciler) SetupWithManager(mgr ctrl.Manager) error {
For(&slessv1alpha1.Trigger{}).
Complete(r)
}
// reconcileEvent обрабатывает Trigger{type:event}.
// Оператор не управляет AMQP напрямую — это задача event-dispatcher.
// Здесь: убеждаемся что Service функции существует (dispatcher использует его для POST),
// обновляем статус триггера.
func (r *TriggerReconciler) reconcileEvent(ctx context.Context, tr *slessv1alpha1.Trigger, fn *slessv1alpha1.Function) (ctrl.Result, error) {
if tr.Spec.Queue == "" {
tr.Status.Active = false
tr.Status.Message = "queue is required for type=event"
_ = r.Status().Update(ctx, tr)
return ctrl.Result{}, nil
}
deployNS := "sless-fn-" + tr.Namespace
// Service нужен event-dispatcher для доставки сообщений в функцию по HTTP.
// Имя Service совпадает с именем Function — dispatcher строит URL как
// http://{functionRef}.{deployNS}.svc.cluster.local:8080/
wantSvc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{Name: fn.Name, Namespace: deployNS},
Spec: corev1.ServiceSpec{
Selector: map[string]string{"app": fn.Name},
Ports: []corev1.ServicePort{{Port: 8080, Protocol: corev1.ProtocolTCP}},
},
}
existingSvc := &corev1.Service{}
if err := r.Get(ctx, client.ObjectKey{Name: fn.Name, Namespace: deployNS}, existingSvc); err != nil {
if errors.IsNotFound(err) {
if err := r.Create(ctx, wantSvc); err != nil {
return ctrl.Result{}, fmt.Errorf("create service for event trigger: %w", err)
}
} else {
return ctrl.Result{}, fmt.Errorf("get service: %w", err)
}
}
tr.Status.Active = true
tr.Status.Message = fmt.Sprintf("listening on queue %q via event-dispatcher", tr.Spec.Queue)
_ = r.Status().Update(ctx, tr)
return ctrl.Result{}, nil
}
+77
View File
@@ -0,0 +1,77 @@
# Изменено: 2026-03-19
# event-dispatcher — отдельный сервис для обработки event-триггеров.
# Следит за Trigger CRD{type:event}, подписывается на AMQP очереди,
# при сообщении делает POST на внутренний HTTP endpoint функции.
#
# Требует:
# - SecretRef: sless-operator-secret (RABBITMQ_URL)
# - ClusterRole: event-dispatcher-role (чтение Trigger CRD, Namespace)
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: event-dispatcher
namespace: sless
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: event-dispatcher-role
rules:
# Нужно читать Trigger CRD по всем namespace (event-dispatcher глобальный)
- apiGroups: ["sless.kube5s.ru"]
resources: ["triggers"]
verbs: ["get", "list", "watch"]
# Нужно читать namespace для построения URLs функций
- apiGroups: [""]
resources: ["namespaces"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: event-dispatcher-rolebinding
subjects:
- kind: ServiceAccount
name: event-dispatcher
namespace: sless
roleRef:
kind: ClusterRole
name: event-dispatcher-role
apiGroup: rbac.authorization.k8s.io
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: event-dispatcher
namespace: sless
labels:
app: event-dispatcher
spec:
replicas: 1
selector:
matchLabels:
app: event-dispatcher
template:
metadata:
labels:
app: event-dispatcher
spec:
serviceAccountName: event-dispatcher
containers:
- name: event-dispatcher
image: naeel/sless-event-dispatcher:v0.1.0
imagePullPolicy: Always
env:
- name: RABBITMQ_URL
valueFrom:
secretKeyRef:
name: sless-operator-secret
key: RABBITMQ_URL
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 128Mi
+107
View File
@@ -0,0 +1,107 @@
# 2026-03-18
# funcs-service.yaml — глобальный сервис листинга функций для всех пользователей.
# Развёртывается ОДИН РАЗ в namespace sless рядом с оператором.
# Доступен по: https://sless.kube5s.ru/funcs (с Bearer токеном пользователя)
#
# Обновить образ и применить:
# docker push naeel/sless-funcs-service:v0.1.0
# kubectl apply -f deployments/k8s/funcs-service.yaml
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: sless-funcs-service
namespace: sless
labels:
app: sless-funcs-service
spec:
replicas: 1
selector:
matchLabels:
app: sless-funcs-service
template:
metadata:
labels:
app: sless-funcs-service
spec:
containers:
- name: funcs
image: naeel/sless-funcs-service:v0.1.3
ports:
- containerPort: 8090
env:
- name: SLESS_OPERATOR_URL
value: "http://sless-operator.sless.svc.cluster.local:9090"
- name: SLESS_EXTERNAL_URL
value: "https://sless.kube5s.ru"
# Системные функции, скрытые из листинга
- name: SLESS_EXCLUDE
value: "event-writer,event-monitor,event-cleaner"
# Токен сервиса задаётся через kubectl set env или Secret — не хранится в репо
# kubectl set env deployment/sless-funcs-service -n sless SLESS_SERVICE_TOKEN="$(cat secrets/test.token)"
- name: PORT
value: "8090"
livenessProbe:
httpGet:
path: /health
port: 8090
initialDelaySeconds: 5
periodSeconds: 30
readinessProbe:
httpGet:
path: /health
port: 8090
initialDelaySeconds: 3
periodSeconds: 10
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
cpu: 100m
memory: 64Mi
---
apiVersion: v1
kind: Service
metadata:
name: sless-funcs-service
namespace: sless
spec:
selector:
app: sless-funcs-service
ports:
- port: 8090
targetPort: 8090
---
# Отдельный Ingress для /funcs — nginx выбирает более специфичный путь перед /
# Без rewrite: сервис сам обрабатывает /funcs path
# TLS-сертификат sless-operator-tls уже управляется cert-manager через ingress оператора;
# здесь только ссылаемся на существующий секрет без аннотации cert-manager.io/cluster-issuer.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: sless-funcs-ingress
namespace: sless
annotations:
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
ingressClassName: nginx
rules:
- host: sless.kube5s.ru
http:
paths:
- path: /funcs
pathType: Prefix
backend:
service:
name: sless-funcs-service
port:
number: 8090
tls:
- hosts:
- sless.kube5s.ru
secretName: sless-operator-tls
+122
View File
@@ -0,0 +1,122 @@
# Изменено: 2026-03-14
# Node-RED для визуального управления demo сценарием Event Log.
# Образ: nodered/node-red:4 (официальный, всегда есть на DockerHub)
# UI доступен по http://<ingress-ip>/nodered/
# Для AMQP нужен плагин node-red-contrib-amqp — ставится через initContainer.
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: nodered-data
namespace: sless
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: nodered
namespace: sless
labels:
app: nodered
spec:
replicas: 1
selector:
matchLabels:
app: nodered
template:
metadata:
labels:
app: nodered
spec:
securityContext:
fsGroup: 1000
# initContainer устанавливает AMQP-плагин в PVC до старта основного контейнера
initContainers:
- name: install-nodes
image: nodered/node-red:latest
securityContext:
runAsUser: 0
runAsGroup: 0
command:
- sh
- -c
- |
chown -R 1000:1000 /data || true
cd /data
npm install --prefix /data node-red-contrib-amqp node-red-dashboard 2>&1 || true
volumeMounts:
- name: data
mountPath: /data
containers:
- name: nodered
image: nodered/node-red:latest
ports:
- containerPort: 1880
env:
- name: NODE_RED_ENABLE_PROJECTS
value: "false"
- name: TZ
value: "Europe/Moscow"
securityContext:
runAsUser: 1000
runAsGroup: 1000
volumeMounts:
- name: data
mountPath: /data
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
readinessProbe:
httpGet:
path: /
port: 1880
initialDelaySeconds: 15
periodSeconds: 10
volumes:
- name: data
persistentVolumeClaim:
claimName: nodered-data
---
apiVersion: v1
kind: Service
metadata:
name: nodered
namespace: sless
spec:
selector:
app: nodered
ports:
- port: 1880
targetPort: 1880
---
# Ingress на Node-RED — доступен снаружи по http://nodered.185.247.187.147.nip.io
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: nodered
namespace: sless
annotations:
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
spec:
ingressClassName: nginx
rules:
- host: nodered.185.247.187.147.nip.io
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: nodered
port:
number: 1880
+125
View File
@@ -0,0 +1,125 @@
# Изменено: 2026-03-14
# Деплой sless оператора на demo-стенде (naeel-test-3, nip.io, без TLS).
# Отличия от production operator.yaml:
# - REGISTRY_HOST=naeel (DockerHub, не Harbor)
# - INGRESS_HOST и EXTERNAL_URL — через nip.io без TLS
# - cert-manager аннотации убраны
# - API_TOKEN берётся из sless-operator-secret (совпадает с secrets/test.token)
#
# Перед apply нужно создать секреты:
# kubectl create secret generic sless-operator-secret -n sless \
# --from-literal=POSTGRES_DSN="postgres://sless:sless-pg-password@postgres.sless.svc.cluster.local:5432/sless?sslmode=disable" \
# --from-literal=S3_ACCESS_KEY="0GLQRD38H4I6RBDB0EWJ" \
# --from-literal=S3_SECRET_KEY="eTFibiHmBd96IApj9PYsboTR6OBoD7osxoarHykw" \
# --from-literal=SLESS_API_TOKEN="<token from secrets/test.token>" \
# --from-literal=HARBOR_PASS=""
#
# kubectl create secret docker-registry sless-registry-auth -n sless \
# --docker-server=https://index.docker.io/v1/ \
# --docker-username=naeel \
# --docker-password=<DOCKERHUB_TOKEN>
---
apiVersion: v1
kind: ConfigMap
metadata:
name: sless-operator-config
namespace: sless
data:
S3_ENDPOINT: "s3.msk-1.ngcloud.ru"
S3_BUCKET: "sless-functions"
S3_USE_SSL: "true"
# Harbor как registry для demo
REGISTRY_HOST: "pearlharbor.registryk8s.services.ngcloud.ru"
REGISTRY_SECRET: "sless-registry-auth"
HARBOR_USER: "admin"
API_PORT: "9090"
# nip.io домен без TLS — работает без настройки DNS
INGRESS_HOST: "fn.185.247.187.147.nip.io"
EXTERNAL_URL: "http://sless-api.185.247.187.147.nip.io"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: sless-operator
namespace: sless
labels:
app: sless-operator
spec:
replicas: 1
selector:
matchLabels:
app: sless-operator
template:
metadata:
labels:
app: sless-operator
spec:
serviceAccountName: sless-operator
containers:
- name: operator
image: naeel/sless-operator:v0.1.29
imagePullPolicy: Always
ports:
- name: api
containerPort: 9090
- name: metrics
containerPort: 8080
- name: health
containerPort: 8081
envFrom:
- configMapRef:
name: sless-operator-config
- secretRef:
name: sless-operator-secret
readinessProbe:
httpGet:
path: /healthz
port: 8081
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /healthz
port: 8081
initialDelaySeconds: 15
periodSeconds: 20
resources:
requests:
memory: "64Mi"
cpu: "50m"
limits:
memory: "256Mi"
cpu: "500m"
---
apiVersion: v1
kind: Service
metadata:
name: sless-operator
namespace: sless
spec:
selector:
app: sless-operator
ports:
- name: api
port: 9090
targetPort: 9090
---
# Ingress без TLS — demo стенд через nip.io
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: sless-operator
namespace: sless
spec:
ingressClassName: nginx
rules:
- host: sless-api.185.247.187.147.nip.io
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: sless-operator
port:
number: 9090
+8 -6
View File
@@ -3,9 +3,9 @@
# Состав:
# - ConfigMap: не-секретные env vars (S3_ENDPOINT, REGISTRY_HOST и т.д.)
# - Secret: секретные данные (S3 keys, postgres DSN, API token, Harbor pass)
# - Deployment: оператор naeel/sless-operator:v0.1.23 в namespace sless
# - Deployment: оператор naeel/sless-operator:v0.1.33 в namespace sless
# - Service: ClusterIP :9090 (REST API)
# - Ingress: sless-api.kube5s.ru → :9090 (внешний доступ с TLS)
# - Ingress: sless.kube5s.ru → :9090 (внешний доступ с TLS)
#
# Чтобы сменить registry — менять только REGISTRY_HOST в ConfigMap.
# Чтобы сменить Harbor-аккаунт — менять HARBOR_USER в ConfigMap + HARBOR_PASS в Secret.
@@ -32,7 +32,7 @@ data:
INGRESS_HOST: "fn.kube5s.ru"
# EXTERNAL_URL — если задан, URL функции = EXTERNAL_URL/fn/{namespace}/{name}
# Позволяет обойтись без wildcard DNS *.fn.kube5s.ru
EXTERNAL_URL: "https://sless-api.kube5s.ru"
EXTERNAL_URL: "https://sless.kube5s.ru"
---
# Secret создаётся отдельно через kubectl (не коммитить секреты в git!)
# Описание ключей:
@@ -72,7 +72,7 @@ spec:
containers:
- name: operator
# При обновлении версии оператора — менять тег здесь (не latest!)
image: naeel/sless-operator:v0.1.28
image: naeel/sless-operator:v0.1.33
# Always — чтобы всегда тянуть по точному тегу (не кешировать старый)
imagePullPolicy: Always
ports:
@@ -130,10 +130,12 @@ metadata:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/proxy-read-timeout: "900"
nginx.ingress.kubernetes.io/proxy-send-timeout: "900"
spec:
ingressClassName: nginx
rules:
- host: sless-api.kube5s.ru
- host: sless.kube5s.ru
http:
paths:
- path: /
@@ -145,5 +147,5 @@ spec:
number: 9090
tls:
- hosts:
- sless-api.kube5s.ru
- sless.kube5s.ru
secretName: sless-operator-tls
+73
View File
@@ -0,0 +1,73 @@
# Изменено: 2026-03-14
# RabbitMQ для demo сценария Event Log.
# Используется официальный образ с management-плагином для веб-UI.
# credentials: sless / sless123
# AMQP: amqp://sless:sless123@rabbitmq.sless.svc.cluster.local:5672/
# Management UI: http://rabbitmq.sless.svc.cluster.local:15672/
---
apiVersion: v1
kind: Secret
metadata:
name: rabbitmq-secret
namespace: sless
stringData:
RABBITMQ_DEFAULT_USER: "sless"
RABBITMQ_DEFAULT_PASS: "sless123"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: rabbitmq
namespace: sless
labels:
app: rabbitmq
spec:
replicas: 1
selector:
matchLabels:
app: rabbitmq
template:
metadata:
labels:
app: rabbitmq
spec:
containers:
- name: rabbitmq
image: rabbitmq:3.13-management-alpine
ports:
- name: amqp
containerPort: 5672
- name: management
containerPort: 15672
envFrom:
- secretRef:
name: rabbitmq-secret
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
readinessProbe:
tcpSocket:
port: 5672
initialDelaySeconds: 40
periodSeconds: 10
timeoutSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
name: rabbitmq
namespace: sless
spec:
selector:
app: rabbitmq
ports:
- name: amqp
port: 5672
targetPort: 5672
- name: management
port: 15672
targetPort: 15672
+38 -8
View File
@@ -1,5 +1,7 @@
# API Design
Последнее обновление: 2026-03-18
## Базовый URL
```
@@ -7,15 +9,38 @@ http://<operator-host>:9090/v1
```
Локально: `http://localhost:9090/v1`
В кластере (будущее): `http://sless-operator.sless.svc.cluster.local:9090/v1`
В кластере: `http://sless-operator.sless.svc.cluster.local:9090/v1`
Публично: `https://sless.kube5s.ru/v1/...` (через Ingress)
**Реализовано** (текущий namespace-aware формат):
**Реализованные эндпоинты:**
```
/v1/namespaces/{namespace}/functions[/{name}]
/v1/namespaces/{namespace}/functions/{name}/upload
/v1/namespaces/{namespace}/triggers[/{name}]
/v1/namespaces/{namespace}/functions/{name}/invocations
GET /v1/namespaces/{ns}/functions
POST /v1/namespaces/{ns}/functions
GET /v1/namespaces/{ns}/functions/{name}
PUT /v1/namespaces/{ns}/functions/{name}
DELETE /v1/namespaces/{ns}/functions/{name}
POST /v1/namespaces/{ns}/functions/{name}/upload
GET /v1/namespaces/{ns}/functions/{name}/source ← файлы кода из S3 tar.gz (JSON)
GET /v1/namespaces/{ns}/functions/{name}/invocations
GET /v1/namespaces/{ns}/triggers
POST /v1/namespaces/{ns}/triggers
GET /v1/namespaces/{ns}/triggers/{name}
PATCH /v1/namespaces/{ns}/triggers/{name} ← {"enabled": bool}
DELETE /v1/namespaces/{ns}/triggers/{name}
POST /v1/namespaces/{ns}/jobs
GET /v1/namespaces/{ns}/jobs/{name}
DELETE /v1/namespaces/{ns}/jobs/{name}
```
**Глобальный сервис funcs (не оператор):**
```
GET https://sless.kube5s.ru/funcs/<namespace> ← plain text (курл) / HTML (браузер)
GET https://sless.kube5s.ru/funcs?token=<jwt> ← редирект по namespace
GET https://sless.kube5s.ru/funcs/<namespace>/source/<fn> ← прокси к GET /source
PATCH https://sless.kube5s.ru/funcs/<namespace>/triggers/<name> ← прокси к PATCH /triggers
GET https://sless.kube5s.ru/health ← liveness probe
```
## Аутентификация
@@ -24,6 +49,11 @@ http://<operator-host>:9090/v1
Authorization: Bearer <cloud-token>
```
Токен — JWT от `auth-api`. Middleware в операторе:
1. Извлекает `sub` из payload (без проверки подписи — доверяет Ingress)
2. Вычисляет namespace: `SHA256(sub)[:8]` hex → `sless-{16 hex символов}`
3. Проверяет что запрошенный `{namespace}` совпадает с вычисленным
## Ресурсы
### Functions
@@ -87,8 +117,8 @@ field: code = <zip-file>
## Поддерживаемые runtime (v1)
- `python3.11` — реализован и протестирован
- `go1.21` — планируется
- `nodejs20` — планируется
- `nodejs20` — реализован и протестирован
- `go1.23` — реализован
## Модель Function
@@ -0,0 +1,368 @@
# Agent Handoff — 2026-03-18 (финальное состояние сессии)
Этот файл — полный срез для нового агента: что сделано, как устроено, как работать.
---
## 1. Идентификация проекта
- **Репозиторий:** `gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless`
- **Локальная копия:** `/home/naeel/remote_dev/sless/`
- **Remote server:** `naeel@5.172.178.213` (workspace: `~/terra/sless/`)
- **SSH ключ:** `/home/naeel/.ssh/naeel_vm_id_ed25519`
- **SSH команда:** `ssh -i <ключ> -o StrictHostKeyChecking=no naeel@5.172.178.213`
- **Активная ветка:** `feat/web-console` (последний коммит `a04dfb2`)
- **Git origin:** `https://gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless.git`
### Важно про git
Git **не работает локально** (зависает при записи объектов из-за NFS-подобного поведения volume).
Все `git add / commit / push`**только через SSH на remote machine**.
`scp` для копирования файлов → `ssh` для git-операций.
---
## 2. Что такое проект
Managed Serverless Functions Service для облачного провайдера **nubes.ru**.
Пользователь пишет `main.tf` с ресурсами `sless_function`, `sless_trigger`, `sless_job`.
Terraform провайдер собирает zip → загружает в оператор → оператор запускает kaniko → Docker образ → Deployment в k8s.
**Внешнее API оператора:** `https://sless.kube5s.ru`
**Ingress IP:** `185.247.187.147`
**Namespace оператора в k8s:** `sless`
---
## 3. Стек и компоненты
| Компонент | Технология | Namespace / где |
|-----------|-----------|-----------------|
| Operator (API + Controllers) | Go (controller-runtime) | k8s namespace `sless` |
| funcs-service (web-консоль) | Go (net/http) | k8s namespace `sless` |
| PostgreSQL | PostgreSQL 16 | k8s namespace `sless` |
| S3 | Ceph (облачный) | `s3.msk-1.ngcloud.ru` |
| Container Registry | DockerHub (`naeel/`) | внешний |
| Builder | kaniko Job | namespace пользователя |
| Function (HTTP trigger) | k8s Deployment + Service | namespace пользователя |
| Function (one-shot) | k8s Job | namespace пользователя |
| Function (cron) | k8s CronJob | namespace пользователя |
| Terraform Provider | Go (plugin-framework v6) | localhost/CI |
---
## 4. Текущие версии образов
| Образ | Версия | Что внутри |
|-------|--------|-----------|
| `naeel/sless-operator` | **v0.1.34** | REST API + k8s controllers; GET /source; proxy-готовый PATCH /triggers |
| `naeel/sless-funcs-service` | **v0.2.0** | HTML web-консоль + plain text (backward compat) |
| `naeel/sless-runtime-python3.11` | **v0.1.3** | str return → text/plain |
| `naeel/sless-runtime-nodejs20` | **v0.1.2** | без изменений |
| `naeel/sless-runtime-go1.23` | **v0.1.0** | без изменений |
---
## 5. Структура директорий (актуальная)
```
sless/
├── main.go # точка входа оператора (controller-runtime + HTTP сервер)
├── Dockerfile # сборка оператора
├── go.mod # module: gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless
├── api/v1alpha1/ # CRD типы: Function, FunctionJob, Trigger
│ ├── function_types.go
│ ├── job_types.go
│ └── trigger_types.go
├── controllers/ # k8s reconcilers
│ ├── function_controller.go # Function CRD → kaniko → Deployment/Service
│ ├── functionjob_controller.go # FunctionJob CRD → k8s Job → собирает stdout/stderr
│ └── trigger_controller.go # Trigger CRD → Deployment scale / CronJob
├── internal/
│ ├── api/
│ │ ├── router.go # gorilla/mux: все REST маршруты (актуальный)
│ │ ├── middleware/ # Auth (JWT→namespace), Logging
│ │ └── handler/
│ │ ├── handler.go # Handler struct (K8s, S3, PG, Log)
│ │ ├── functions.go # CRUD Functions
│ │ ├── triggers.go # CRUD Triggers + UpdateTrigger (PATCH enabled)
│ │ ├── upload.go # POST /upload — zip → tar.gz → S3 → Function CRD patch
│ │ ├── source.go # GET /source — tar.gz из S3 → JSON файлы (НОВЫЙ)
│ │ ├── jobs.go # CRUD FunctionJobs
│ │ ├── invocations.go # logs из Postgres
│ │ ├── invoke.go # прокси вызова HTTP функций
│ │ └── namespace.go # EnsureNamespace
│ ├── builder/
│ │ └── context.go # zip + runtime → tar.gz + Dockerfile для kaniko
│ ├── storage/
│ │ ├── s3/client.go # minio-go: Upload, UploadContext, Download, Delete
│ │ └── postgres/ # хранение invocation logs
│ └── config/ # env vars конфиг
├── services/
│ └── funcs/
│ ├── main.go # web-консоль сервис (v0.2.0)
│ ├── index.html # HTML шаблон (embed)
│ ├── Dockerfile # multi-stage Go → alpine
│ └── funcs-service.yaml # (дубль, не деплоится отсюда)
├── deployments/k8s/
│ ├── operator.yaml # ConfigMap + Secret + Deployment + Service + Ingress оператора
│ ├── funcs-service.yaml # Deployment + Service + Ingress funcs-service
│ ├── postgres.yaml # PostgreSQL
│ └── rbac.yaml # ClusterRole для оператора
├── terraform/provider/ # terraform-provider-sless
│ ├── main.go
│ └── internal/
│ ├── client/client.go # HTTP клиент к оператору
│ └── resources/
│ ├── function_resource.go # sless_function: source_dir→zip, code_hash, ModifyPlan
│ ├── trigger_resource.go # sless_trigger
│ └── job_resource.go # sless_job + ErrJobAlreadyExists handling
├── runtimes/
│ ├── python3.11/server.py # HTTP wrapper (str → text/plain)
│ ├── nodejs20/ # HTTP wrapper
│ └── go1.23/ # multi-stage builder образ
├── examples/
│ ├── POSTGRES/ # pg функции: create-table, pg-info, pg-table-reader
│ ├── hello-go/
│ ├── hello-node/
│ └── ...
├── migrations/001_initial.sql # PostgreSQL схема
└── doc/ # ← ты здесь
├── architecture/
│ └── agent-handoff-2026-03-18.md ← ЭТОТ ФАЙЛ
├── api/design.md
├── decisions/log.md
├── errors/log.md
└── progress.md
```
---
## 6. REST API оператора — полный список маршрутов
Все `/v1/` защищены JWT (middleware.Auth проверяет Bearer токен + namespace).
`/fn/` — публичный прокси для HTTP-триггеров (без auth).
```
POST /v1/namespaces/{ns}/ensure # создать namespace (идемпотентно)
GET /v1/namespaces/{ns}/functions # список функций
POST /v1/namespaces/{ns}/functions # создать функцию
GET /v1/namespaces/{ns}/functions/{name} # получить функцию
PUT /v1/namespaces/{ns}/functions/{name} # обновить функцию
DELETE /v1/namespaces/{ns}/functions/{name} # удалить функцию
POST /v1/namespaces/{ns}/functions/{name}/upload # загрузить zip → S3 → kaniko
GET /v1/namespaces/{ns}/functions/{name}/source # НОВЫЙ: файлы кода из S3 (JSON)
GET /v1/namespaces/{ns}/functions/{name}/invocations # логи вызовов
GET /v1/namespaces/{ns}/triggers # список триггеров
POST /v1/namespaces/{ns}/triggers # создать триггер
GET /v1/namespaces/{ns}/triggers/{name} # получить триггер
PATCH /v1/namespaces/{ns}/triggers/{name} # enable/disable: {"enabled": bool}
DELETE /v1/namespaces/{ns}/triggers/{name} # удалить триггер
POST /v1/namespaces/{ns}/jobs # создать FunctionJob
GET /v1/namespaces/{ns}/jobs/{name} # получить FunctionJob
DELETE /v1/namespaces/{ns}/jobs/{name} # удалить FunctionJob
GET/POST/... /fn/{namespace}/{name}[/...] # вызов HTTP функции (без auth)
```
---
## 7. funcs-service — маршруты
Сервис `sless-funcs-service`, namespace `sless`, порт 8090.
Ingress: `sless.kube5s.ru/funcs``sless-funcs-service:8090`.
```
GET /health # liveness/readiness probe (без auth)
GET /funcs # usage hint (нет токена → 401 с подсказкой)
GET /funcs?token=<jwt> # листинг через JWT
GET /funcs/<namespace> # листинг по namespace (браузер→HTML, curl→plain text)
GET /funcs/<namespace>/source/<fn> # прокси → оператор GET /source (serviceToken)
PATCH /funcs/<namespace>/triggers/<name> # прокси → оператор PATCH /triggers (serviceToken)
```
Логика переключения HTML/plain text: `strings.Contains(Accept header, "text/html")`.
Браузер всегда шлёт `text/html` в Accept → HTML консоль.
`curl` без `-H "Accept: text/html"` → plain text (совместимость с v0.1.x).
**Env vars funcs-service:**
```
SLESS_OPERATOR_URL = http://sless-operator.sless.svc.cluster.local:9090
SLESS_EXTERNAL_URL = https://sless.kube5s.ru
SLESS_EXCLUDE = (список функций скрытых из листинга, через запятую)
SLESS_SERVICE_TOKEN = <JWT> (задаётся через kubectl set env, НЕ в git)
PORT = 8090
```
---
## 8. Namespace пользователя
```
JWT.sub (UUID) → SHA256(sub)[:8] → hex → "sless-" + 16 hex символов
```
Пример: sub `019cc268-6c6a-781e-8613-4bed4ec7cd20` → namespace `sless-ffd1f598c169b0ae`
Эта логика **одинакова** в трёх местах:
- `internal/api/middleware/auth.go` (оператор)
- `services/funcs/main.go` (funcs-service)
- `terraform/provider/internal/client/client.go` (провайдер)
---
## 9. S3 хранение кода — ключи
При `POST /upload` создаются **два объекта:**
```
functions/{ns}/{name}/{timestamp}.zip ← исходный код (zip от пользователя)
contexts/{ns}/{name}/{timestamp}.tar.gz ← build context для kaniko (zip + Dockerfile)
```
`Function.Spec.S3Key` хранит путь к `contexts/...`.
`GET /source` читает `Function.Spec.S3Key`, скачивает tar.gz, извлекает файлы без Dockerfile.
**Важно:** zip исходника (`functions/...`) отдельно не хранится в CRD.
Код источника берётся из tar.gz контекста — там те же файлы пользователя.
---
## 10. Terraform провайдер — sless_function
```hcl
resource "sless_function" "my_func" {
name = "my-func"
runtime = "python3.11" # python3.11 | nodejs20 | go1.23
entrypoint = "handler.handle"
source_dir = "${path.module}/code/my-func" # директория → провайдер делает zip сам
# ИЛИ:
# code_path = "./handler.zip" # готовый zip
# code_hash = filesha256("./handler.zip") # для детекции изменений
memory_mb = 128
timeout_sec = 30
env_vars = { KEY = "value" }
build_timeout_sec = 300 # ожидание kaniko (дефолт 300 сек)
}
```
При изменении файлов в `source_dir`:
- `terraform plan` → пересчитывает `code_hash` (ModifyPlan), показывает diff
- `terraform apply` → загружает новый zip, ждёт сборки → `phase = Ready`
- Новый код виден в браузере сразу после apply
---
## 11. Где что запущено (kubectl)
```bash
# Проверить поды оператора
kubectl get pods -n sless
# Проверить версию образа оператора
kubectl get deployment sless-operator -n sless -o jsonpath='{.spec.template.spec.containers[0].image}'
# Посмотреть логи оператора
kubectl logs -n sless -l app=sless-operator --tail=50
# Посмотреть логи funcs-service
kubectl logs -n sless -l app=sless-funcs-service --tail=50
# Обновить образ оператора
kubectl set image deployment/sless-operator operator=naeel/sless-operator:vX.X.X -n sless
kubectl rollout status deployment/sless-operator -n sless --timeout=90s
# Обновить образ funcs-service
kubectl set image deployment/sless-funcs-service funcs=naeel/sless-funcs-service:vX.X.X -n sless
# Посмотреть функции пользователя
kubectl get functions -n sless-ffd1f598c169b0ae
# Посмотреть триггеры
kubectl get triggers -n sless-ffd1f598c169b0ae
# Обновить SLESS_SERVICE_TOKEN (не хранится в git!)
kubectl set env deployment/sless-funcs-service -n sless SLESS_SERVICE_TOKEN=<jwt>
```
---
## 12. Тестовые данные
- **Тестовый токен:** `/home/naeel/remote_dev/sless/secrets/test.token`
- **Namespace тест-пользователя:** `sless-ffd1f598c169b0ae`
- **Рабочие функции:**
- `pg-info` (nodejs20) — читает версию PostgreSQL и счётчик строк
- `pg-table-reader` (python3.11) — читает строки из таблицы
- `pg-create-table-runner` (python3.11) — job: создаёт таблицу
- **Быстрый тест:**
```bash
# plain text список (curl)
curl https://sless.kube5s.ru/funcs/sless-ffd1f598c169b0ae
# HTML консоль (браузер или curl с Accept)
curl -H "Accept: text/html" https://sless.kube5s.ru/funcs/sless-ffd1f598c169b0ae
# исходный код функции
curl https://sless.kube5s.ru/funcs/sless-ffd1f598c169b0ae/source/pg-info
# вызов функции
curl https://sless.kube5s.ru/fn/sless-ffd1f598c169b0ae/pg-info
```
---
## 13. Деплой нового образа — стандартный workflow
```bash
SSH="ssh -i /home/naeel/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no naeel@5.172.178.213"
SCP="scp -i /home/naeel/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no"
# 1. Скопировать изменённые файлы оператора на remote
$SCP /home/naeel/remote_dev/sless/path/to/file.go naeel@5.172.178.213:~/terra/sless/path/to/file.go
# 2. Собрать образ на remote
$SSH "cd ~/terra/sless && docker build -t naeel/sless-operator:vX.X.X . 2>&1 | tail -5"
# 3. Запушить
$SSH "docker push naeel/sless-operator:vX.X.X 2>&1 | tail -3"
# 4. Задеплоить
$SSH "kubectl set image deployment/sless-operator operator=naeel/sless-operator:vX.X.X -n sless && kubectl rollout status deployment/sless-operator -n sless --timeout=90s"
# 5. Коммит (через SSH! не локально)
$SSH "cd ~/terra/sless && git add <файлы> && git commit -m 'msg' && git push origin feat/web-console"
```
Для funcs-service: путь `~/terra/sless/services/funcs/`, контейнер называется `funcs`.
---
## 14. Известные проблемы / особенности
| Проблема | Решение |
|---------|---------|
| `git` зависает локально | Только через SSH на remote machine |
| `SLESS_SERVICE_TOKEN` не в git | Задан через `kubectl set env`, при пересоздании пода — пропадёт! Нужно переставить вручную |
| `job-name=` label удалён в k8s 1.27+ | Используем свой label `functionjob=<name>` на PodTemplate (исправлено в v0.1.34) |
| S3 endpoint с SSL | `useSSL=false` для внутреннего s3, `useSSL=true` для облачного `s3.msk-1.ngcloud.ru` |
| `python3.11`: str return → text/plain | Начиная с `naeel/sless-runtime-python3.11:v0.1.3` |
---
## 15. Следующие возможные задачи (не начаты)
| Задача | Сложность | Заметки |
|--------|-----------|---------|
| Слияние `feat/web-console` в `main` (или базовую ветку) | низкая | Ветка стабильная, все тесты проходят |
| Кнопка "Обновить код" в HTML консоли (upload из браузера) | средняя | Drag&drop zip или указать source_dir |
| Обратная синхронизация (скачать код из S3 в source_dir) | средняя | terraform data source или отдельная команда |
| History/versioning (несколько версий кода) | высокая | S3 уже хранит по timestamp — нужен UI |
| SLESS_SERVICE_TOKEN из k8s Secret | низкая | Сейчас задаётся через kubectl set env — надо в YAML (sealed secret) |
| Логи функции в HTML консоли | средняя | GET /invocations уже есть в операторе |
| Публикация terraform провайдера | средняя | Terraform Registry или Gitea Releases |
+40 -1
View File
@@ -1,6 +1,6 @@
# Архитектура системы
Последнее обновление: 2026-03-11 (v0.1.22)
Последнее обновление: 2026-03-18 (v0.1.34 + funcs-service v0.2.0)
## Общее описание
@@ -13,6 +13,7 @@ Managed Serverless Functions Service для облачного провайде
| Компонент | Технология | Где запущен |
|-----------|-----------|-------------|
| Operator (API + Controllers) | Go (controller-runtime) | Kubernetes, namespace `sless` |
| funcs-service (глобальная консоль) | Go (net/http) | Kubernetes, namespace `sless` |
| PostgreSQL | PostgreSQL 16 | Kubernetes, namespace `sless` |
| S3 | Ceph (облачный) | `s3.msk-1.ngcloud.ru` |
| Container Registry | DockerHub (`naeel/`) | внешний |
@@ -25,6 +26,44 @@ Managed Serverless Functions Service для облачного провайде
> Redis и RabbitMQ — отложены до v2.
## Компонент: funcs-service
Глобальный HTTP сервис — **одна копия** на весь кластер, для всех пользователей.
```
User Browser / curl
└─► https://sless.kube5s.ru/funcs/<namespace>
└─► nginx Ingress (sless-funcs-ingress)
└─► sless-funcs-service:8090 (namespace sless)
└─► http://sless-operator.sless.svc.cluster.local:9090/v1/...
```
**Файлы:**
- `services/funcs/main.go` — логика
- `services/funcs/Dockerfile` — multi-stage Go → alpine
- `deployments/k8s/funcs-service.yaml` — Deployment + Service + Ingress
**Env vars сервиса:**
| Переменная | Значение |
|-----------|---------|
| `SLESS_OPERATOR_URL` | `http://sless-operator.sless.svc.cluster.local:9090` |
| `SLESS_EXTERNAL_URL` | `https://sless.kube5s.ru` |
| `SLESS_EXCLUDE` | `event-writer,event-monitor,event-cleaner` |
| `SLESS_SERVICE_TOKEN` | JWT токен (задаётся через `kubectl set env`, не в git) |
## Хранение кода функций в S3
```
Terraform source_dir (локально)
└─► zip → POST /upload → builder.PrepareContext()
├─► functions/{ns}/{name}/{ts}.zip ← ИСХОДНЫЙ КОД пользователя
└─► contexts/{ns}/{name}/{ts}.tar.gz ← BUILD CONTEXT для kaniko
└─► Function CRD: spec.s3Key = "contexts/..."
└─► контроллер → kaniko Job → Docker image
```
Для web-консоли: `GET /source` читает `contexts/{ns}/{name}/{ts}.tar.gz` (из `Function.Spec.S3Key`), распаковывает tar.gz, фильтрует Dockerfile, возвращает пользовательские файлы.
## Изоляция пользователей — Namespace per user
Каждый пользователь облака получает **отдельный k8s namespace**.
+90 -33
View File
@@ -1,49 +1,106 @@
# Структура проекта
Последнее обновление: 2026-03-18 (v0.1.34)
## Репозиторий
`gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless`
## Директории
## Директории (актуальное состояние)
```
sless/
├── cmd/
│ └── api/
│ └── main.go # точка входа API сервера
├── main.go # точка входа оператора (controller-runtime + HTTP сервер)
├── Dockerfile # multi-stage сборка оператора
├── go.mod # module: gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless
├── Makefile
├── api/
│ └── v1alpha1/ # CRD типы для controller-gen
│ ├── function_types.go # Function CRD: spec, status
│ ├── job_types.go # FunctionJob CRD
│ ├── trigger_types.go # Trigger CRD
│ └── zz_generated.deepcopy.go # автогенерация DeepCopy
├── controllers/
│ ├── function_controller.go # Function → kaniko Job → Deployment/Service
│ ├── functionjob_controller.go # FunctionJob → k8s Job → stdout/stderr → status
│ └── trigger_controller.go # Trigger → Deployment scale / CronJob
├── internal/
│ ├── api/
│ │ ├── handler/ # HTTP хендлеры (functions, versions, triggers)
│ │ ├── middleware/ # auth, logging, rate limit
│ │ └── router.go # регистрация маршрутов
├── model/ # доменные модели: Function, Version, Trigger, Invocation
│ │ ├── router.go # gorilla/mux: регистрация всех маршрутов
│ │ ├── middleware/
│ │ │ ├── auth.go # JWT Bearer → namespace (SHA256)
└── logging.go # access log
│ │ └── handler/
│ │ ├── handler.go # Handler struct (K8s, S3, PG, Log)
│ │ ├── functions.go # GET/POST/PUT/DELETE функций
│ │ ├── triggers.go # GET/POST/PATCH/DELETE триггеров
│ │ ├── upload.go # POST /upload: zip → tar.gz → S3 → CRD patch
│ │ ├── source.go # GET /source: S3 tar.gz → JSON файлы (НОВЫЙ v0.1.34)
│ │ ├── jobs.go # GET/POST/DELETE FunctionJob
│ │ ├── invocations.go # GET /invocations: логи из Postgres
│ │ ├── invoke.go # прокси HTTP вызовов функций
│ │ └── namespace.go # POST /ensure: создать k8s namespace
│ ├── builder/
│ │ └── context.go # zip + runtime → tar.gz + Dockerfile для kaniko
│ ├── storage/
│ │ ├── postgres/ # CRUD функций, версий, логов вызовов
│ │ └── s3/ # загрузка/скачивание zip архивов кода
── builder/ # сборка Docker образа из кода пользователя
│ ├── runner/ # запуск функций в k8s (Jobs / Deployments)
│ └── config/ # конфиг из env переменных
├── migrations/ # SQL миграции (numbered: 001_, 002_, ...)
├── deployments/
└── k8s/ # манифесты: Deployment, Service, Ingress, RBAC
├── api/
│ └── openapi.yaml # OpenAPI 3.0 спецификация
├── doc/ # документация проекта (эта папка)
── docker-compose.yml # локальная разработка: postgres, redis, minio
│ │ ├── s3/client.go # minio-go: Upload, UploadContext, Download, Delete
│ │ └── postgres/ # хранение invocation logs
── config/ # конфиг из env переменных
├── services/
│ └── funcs/ # отдельный HTTP сервис (web-консоль)
│ ├── main.go # v0.2.0: HTML+plain text+proxy к оператору
│ ├── index.html # dark-themed HTML консоль (go:embed)
├── Dockerfile # multi-stage Go → alpine (копирует main.go + index.html)
│ └── go.mod # отдельный Go модуль
├── migrations/
│ └── 001_initial.sql # схема PostgreSQL (invocation logs)
── deployments/k8s/
│ ├── operator.yaml # ConfigMap + Deployment + Service + Ingress оператора
│ ├── funcs-service.yaml # Deployment + Service + Ingress funcs-service
│ ├── postgres.yaml # PostgreSQL
│ └── rbac.yaml # ClusterRole + ClusterRoleBinding для оператора
├── runtimes/
│ ├── python3.11/server.py # HTTP wrapper (str → text/plain начиная с v0.1.3)
│ ├── nodejs20/server.js # HTTP wrapper
│ └── go1.23/ # multi-stage builder образ
├── terraform/
│ └── provider/ # terraform-provider-sless (отдельный Go модуль)
│ ├── main.go
│ └── internal/
│ ├── client/client.go # REST клиент к оператору
│ ├── resources/
│ │ ├── function_resource.go # sless_function: source_dir, code_hash, ModifyPlan
│ │ ├── trigger_resource.go # sless_trigger
│ │ └── job_resource.go # sless_job + ErrJobAlreadyExists
│ └── provider/provider.go
├── examples/
│ ├── POSTGRES/ # pg функции: create-table, pg-info, pg-table-reader
│ ├── hello-go/
│ ├── hello-node/
│ └── simple-python/
├── config/ # kustomize конфиги (controller-gen артефакты)
│ ├── crd/ # CRD манифесты (автогенерация)
│ ├── rbac/
│ └── samples/
├── hack/
│ └── boilerplate.go.txt # шаблон заголовков файлов
└── doc/ # документация проекта
├── architecture/
│ ├── agent-handoff-2026-03-18.md # полный контекст для нового агента
│ ├── overview.md # архитектура системы
│ └── project-structure.md # этот файл
├── api/design.md # дизайн REST API
├── decisions/log.md # журнал архитектурных решений
├── errors/log.md # известные ошибки и решения
└── progress.md # трекер задач
```
## Go module
## Go модули
```
module gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless
```
Два независимых Go модуля в одном репозитории:
## Порядок разработки
1. `internal/config` + `internal/model` — базовые структуры данных
2. `migrations/` + `internal/storage/postgres` — схема БД и CRUD
3. `internal/api` — HTTP хендлеры, роутер, middleware
4. `internal/storage/s3` — загрузка кода функций
5. `internal/builder` — сборка Docker образов
6. `internal/runner` — запуск функций в k8s
7. `deployments/k8s` — манифесты для деплоя
| Модуль | Путь | Назначение |
|--------|------|-----------|
| `gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless` | `/` | Оператор + API |
| `gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/services/funcs` | `services/funcs/` | Web-консоль сервис |
| `terra.k8c.ru/naeel/sless` | `terraform/provider/` | Terraform провайдер |
+284
View File
@@ -1,5 +1,220 @@
# Решения и обоснования
---
## 2026-03-19 — Go runtime v0.1.1: внешние зависимости через go.mod/go.sum
### Контекст
Go runtime `naeel/sless-runtime-go1.23:v0.1.0` содержал `go.mod` только с `module sless/fn` и `go 1.23`.
Никаких `require` — пользовательский код мог использовать только stdlib.
При попытке добавить `pgxpool` в handler.go функция не собиралась (зависимость не найдена).
### Решение
Добавить `require github.com/jackc/pgx/v5 v5.7.2` в `runtimes/go1.23/go.mod`.
Сгенерировать `go.sum` через `go mod tidy` (stub `.go` файл с импортом нужен — иначе tidy удалит deps).
Обновить `Dockerfile` — добавить `COPY go.sum` + `RUN go mod download` **до** копирования пользовательского кода → зависимости кешируются в слое Docker, не скачиваются при каждой сборке функции.
### Почему pgx/v5, а не lib/pq
- `pgx/v5` — современный нативный PG-драйвер, `pgxpool` встроен, не нужен отдельный `database/sql`
- `lib/pq` — legacy, минимальный API, отсутствует connection pool
- `jackc/pgx/v5 v5.7.2` — последний стабильный тег на момент решения
### Что стало возможным
Любая Go функция в платформе может импортировать `pgxpool` и работать с PG напрямую:
```go
import "github.com/jackc/pgx/v5/pgxpool"
```
### Версионирование образа
`v0.1.0``v0.1.1` — изменение breaking: бинарник пересобирается с новыми deps.
Base image в `context.go` обновляется с `v0.1.0` на `v0.1.1`, оператор бампится.
---
## 2026-03-19 — Архитектура event-trigger (Вариант A: отдельный event-dispatcher)
### Контекст
До этого event-monitor/writer/cleaner работали как пользовательские sless-функции
в namespace юзера — это неправильно: они ходили в операторскую Postgres напрямую,
создавали таблицы без миграций, зависели от self-hosted rabbitmq.
Всё это удалено из кластера (audit 2026-03-19).
### Варианты которые рассматривались
**Вариант A: отдельный event-dispatcher сервис** ← ВЫБРАН
**Вариант B: dispatcher встроен горутиной в оператор**
**Вариант C: CronJob polling из очереди**
### Решение: Вариант A
**Почему не B:** AMQP-соединения внутри operator reconciler усложняют lifecycle
и тестирование. Падение AMQP затронет весь оператор.
**Почему не C:** polling — не realtime, не масштабируется, неловкий ACK.
**Почему A:** чистое разделение ответственности. Оператор управляет CRD,
dispatcher управляет AMQP. Независимые restart/deploy. Легко тестировать отдельно.
### Поток данных
```
Пользователь:
kubectl apply — Trigger{type:event, queue:"orders", functionRef:"my-func"}
sless-operator (trigger_controller.go):
reconcileEvent → валидирует что Function существует
→ устанавливает status.active = true
event-dispatcher (services/event-dispatcher/):
k8s informer наблюдает Trigger CRD по всем namespace
При type=event → amqp.Channel.Consume(spec.queue)
При сообщении → POST http://<fn-svc>.<fn-ns>.svc.cluster.local:8080/
→ 2xx → ack
→ не 2xx / timeout → nack (requeue)
При удалении Trigger → закрыть consumer
```
### Что меняется в коде
| Файл | Изменение |
|------|-----------|
| `api/v1alpha1/trigger_types.go` | +TriggerTypeEvent, +Queue в TriggerSpec |
| `controllers/trigger_controller.go` | +reconcileEvent (валидация + status) |
| `internal/config/config.go` | +RabbitMQURL |
| `services/event-dispatcher/` | новый Go-сервис (main + dispatcher + watcher) |
| `deployments/k8s/event-dispatcher.yaml` | Deployment + ServiceAccount + ClusterRole |
### Инфраструктура
RabbitMQ: managed через Nubes (Вариант A требует стабильного брокера).
- Управляется rabbitmq-operator в namespace `operators`
- namespace: `1dbfe9da-ce1c-4958-b359-d016a4b455c8`
- host: `rabbitmqk8s.1dbfe9da-ce1c-4958-b359-d016a4b455c8.svc.cluster.local`
- credentials: в `sless-operator-secret` (RABBITMQ_URL) — добавить при деплое
## 2026-03-18 — Архитектура: funcs как глобальный сервис, web-консоль
### Хранение кода функций
S3 (minio внутри кластера) хранит **два артефакта** на каждый upload:
```
functions/{namespace}/{name}/{timestamp}.zip ← ИСХОДНЫЙ КОД (zip пользователя)
contexts/{namespace}/{name}/{timestamp}.tar.gz ← BUILD CONTEXT для kaniko (zip + Dockerfile)
```
Function CRD хранит `spec.s3Key` — указывает на `contexts/...` (build context).
Из него можно восстановить путь к исходному zip:
`contexts/{ns}/{name}/{ts}.tar.gz``functions/{ns}/{name}/{ts}.zip`
Поэтому для отображения кода в веб-консоли **не нужно ничего менять в CRD**:
достаточно нового эндпоинта `GET /source` который читает zip из S3.
### Решение: funcs как глобальный Go сервис вместо per-user terraform
**Было:** `sless_function.funcs_list` + `sless_trigger.funcs_list_http` в `examples/POSTGRES/resources.tf`
— Для каждого пользователя terraform создавал отдельный pod функции
— Требовал `api_token`, `SLESS_NAMESPACE` как env vars в terraform
— Не масштабируется: N пользователей = N лишних pod'ов
**Стало:** `services/funcs/main.go` — один Go HTTP сервис в namespace `sless`
— Деплоится один раз через `deployments/k8s/funcs-service.yaml`
— Принимает JWT токен → извлекает `sub``SHA256[:8]` → namespace
— URL без токена: `/funcs/<namespace>` (namespace не секрет — виден в URL каждой функции)
`SLESS_SERVICE_TOKEN` задаётся через `kubectl set env` (не хранится в git)
### Про будущую синхронизацию terraform-папок с кластером
Terraform уже работает по схеме: `source_dir` → zip → `POST /upload` → S3.
Обратная синхронизация (кластер → локальная папка): скачать zip из S3 → распаковать в `source_dir`.
Никаких структурных изменений не потребует. Реализовывать ПОСЛЕ web-консоли.
### Архитектура web-консоли (реализовано, ветка feat/web-console, оператор v0.1.34 + funcs-service v0.2.0)
**Принцип:** минимум изменений в операторе, максимум логики в `sless-funcs-service`.
**Два новых эндпоинта в операторе:**
| Метод | Путь | Что делает |
|-------|------|-----------|
| GET | `/v1/namespaces/{ns}/functions/{name}/source` | Читает tar.gz из S3 (`Function.Spec.S3Key`) → JSON `[{name, content}]`, без Dockerfile |
| PATCH | `/v1/namespaces/{ns}/triggers/{name}` | `{"enabled": bool}` → обновляет Trigger CRD |
**`sless-funcs-service` — HTML режим:**
- Если запрос из браузера (`Accept: text/html`) → отдаёт HTML страницу
- Список функций — аккордеон; при раскрытии `fetch(/funcs/{ns}/source/{fn})` подгружает файлы
- Подсветка синтаксиса: `highlight.js` с CDN (не требует сборки)
- Кнопки ▶ Старт / ■ Стоп → `PATCH /funcs/{ns}/triggers/{name}` через fetch
- HTML шаблон `index.html` встроен в бинарник через `//go:embed index.html`
- `text/plain` ответ для curl/CLI остаётся без изменений (браузер шлёт `Accept: text/html`, curl — нет)
---
## 2026-03-18 — Смена sless API endpoint: sless-api.kube5s.ru → sless.kube5s.ru
**Решение:** Оператор sless доступен по `https://sless.kube5s.ru` (не `sless-api.kube5s.ru`). Все examples, deployments и ConfigMap обновлены.
**Причина:** При пересоздании кластера DNS-запись `sless-api.kube5s.ru` не была обновлена — она указывала на IP `5.172.178.182` (старый, мёртвый кластер). Ingress нового кластера закреплён на `185.247.187.147`. Отдельная запись `sless.kube5s.ru` уже корректно указывала на `185.247.187.147`.
TLS handshake timeout возникал потому что старый IP принимал TCP:443, но не завершал TLS (nginx жив, бэкенд мёртв). Go HTTP клиент ждал системный таймаут (~90s) и повторял бесконечно.
**Изменения:**
- `deployments/k8s/operator.yaml`: `EXTERNAL_URL`, `INGRESS_HOST`, ingress host → `sless.kube5s.ru`
- ConfigMap `sless-operator-config` в кластере: `EXTERNAL_URL` обновлён через `kubectl patch`
- Ingress `sless-operator` в кластере: host + TLS secret → `sless.kube5s.ru`
- Все `examples/**/main.tf`: `endpoint = "https://sless.kube5s.ru"`
**Правило:** При пересоздании кластера — первым делом проверять соответствие DNS → ingress IP.
---
## 2026-03-17 — Разделение prod/test endpoint'ов в examples/
**Решение:** Все `examples/` ОБЯЗАНЫ использовать `deck-api-test.ngcloud.ru` для обоих провайдеров: `nubes` и `sless` (`nubes_endpoint`). Продовый `deck-api.ngcloud.ru` — только для реальных клиентов.
**Причина:** Смешивание prod и test endpoint'ов в одном `terraform apply` приводит к тому что ресурсы создаются в разных средах. `sless_function`/`sless_job` могут получать данные (PGHOST, credentials) из прода, а pod запускается в тест-кластере — и не может достучаться до хоста.
**Правило для `main.tf` в examples:**
```hcl
provider "nubes" {
api_endpoint = "https://deck-api-test.ngcloud.ru/api/v1/index.cfm"
}
provider "sless" {
nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1"
}
```
---
## 2026-03-17 — terraform apply только на удалённом сервере
**Решение:** `terraform init/plan/apply/destroy` для `examples/` — исключительно через SSH на сервере `naeel@5.172.178.213`. Локальный запуск запрещён.
**Причина:**
1. Провайдер `terra.k8c.ru/naeel/sless` кэширован только на удалённом сервере
2. Локальный terraform не имеет сетевого доступа к k8s кластеру и внутренним кластерным адресам (например PGHOST вида `*.svc.cluster.local`)
3. Случайный локальный запуск с prod токенами может затронуть боевую среду
**Как запускать:**
```bash
# Сначала синхронизировать изменения:
rsync -av -e "ssh -i /home/naeel/.ssh/naeel_vm_id_ed25519" \
/home/naeel/remote_dev/sless/examples/<example>/ \
naeel@5.172.178.213:/home/naeel/terra/sless/examples/<example>/
# Затем запускать на remote:
ssh -i /home/naeel/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213 \
'cd /home/naeel/terra/sless/examples/<example> && terraform apply -auto-approve -no-color'
```
---
## 2026-03-06 — Отдельная репа для сервиса
**Решение:** Serverless service в отдельной репе, не вместе с Terraform provider.
@@ -646,3 +861,72 @@ for _, k := range keys { envVars = append(envVars, corev1.EnvVar{Name: k, Value:
**Тесты:** 2 теста в `controllers/function_controller_unit_test.go`
(4 env vars → алфавитный порядок после SLESS_ENTRYPOINT; пустой Env → только SLESS_ENTRYPOINT).
---
## 2026-03-19 — pgx/v5 как PG-драйвер для Go функций (vs database/sql + lib/pq)
**Контекст:** Go runtime v0.1.1 — добавляем прямой доступ к PostgreSQL из функций.
Нужно выбрать: database/sql + lib/pq, или чистый pgx/v5?
**Решение:** Использовать `github.com/jackc/pgx/v5` напрямую, без обёртки database/sql.
**Причины:**
1. **pgxpool из коробки**`pgxpool.New()` без дополнительных пакетов. lib/pq требует `sql.Open` + настройку пула через `db.SetMaxOpenConns` и т.д.
2. **Нативный протокол PostgreSQL** — pgx реализует wire protocol напрямую, без CGO.
lib/pq тоже pure Go, но pgx быстрее (~20% в бенчмарках) и активнее поддерживается.
3. **Контекст-нативность**`pgxpool.Pool.Query(ctx, ...)` — context как первый аргумент везде.
В database/sql контекст пришёл только в Go 1.8 как `QueryContext` — неудобный retrofit.
4. **Сканирование строк**`pgx.CollectRows`, `pgx.ForEachRow` — удобнее чем `rows.Scan`.
5. **Экосистема** — pgx — де-факто стандарт в Go+PG проектах (используется в pgx, pgvector, ent).
**Что добавлено в рантайм:**
```
github.com/jackc/pgx/v5 v5.7.2
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-... // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect (connection pool)
golang.org/x/crypto v0.31.0 // indirect (scram auth)
golang.org/x/sync v0.10.0 // indirect
golang.org/x/text v0.21.0 // indirect
```
**go mod download** добавлен в Dockerfile до COPY server.go — слой с зависимостями кешируется отдельно.
Пересборка функции (только изменение handler.go) не перекачивает ~15MB зависимостей.
---
## 2026-03-19 — Динамический таймаут в invoke.go из Function.Spec.TimeoutSec
**Контекст:** invoke.go проксирует HTTP-запросы к подам функций. До этого — глобальный `http.Client{Timeout: 30s}`.
**Проблема:** 30s — константа времени написания кода. Функции с `timeout_sec=700` (stress-тесты, batch-задачи) падают с `context deadline exceeded` раньше чем успевают завершиться.
**Решение:** Перед каждым вызовом читать `Function.Spec.TimeoutSec` из k8s и создавать `http.Client` с таймаутом = `TimeoutSec + 5s`.
**Почему +5s буфер:**
- Нельзя ставить ровно `TimeoutSec` — есть сетевые задержки, TLS handshake, время на DNS резолв внутри кластера.
- 5s достаточно для любых сетевых задержек в локальном k8s кластере.
- Если функция реально завис на TimeoutSec — runtime сам должен прервать работу (это ответственность функции, не прокси).
**Почему не кешировать http.Client:**
- Каждый вызов может прийти к разной функции с разным TimeoutSec.
- http.Client создаётся дёшево — только структура с одним полем Timeout.
- Кеш потребовал бы sync.Map или mutex — лишняя сложность без измеримой пользы.
**Деградация при недоступности k8s:**
```go
if err := h.K8s.Get(r.Context(), client.ObjectKey{...}, fn); err == nil {
timeoutSec = fn.Spec.TimeoutSec
}
// если Get упал — timeoutSec=0 → invokeHTTPClient вернёт 30s (дефолт)
```
Это осознанный выбор: если мы не можем прочитать функцию — мы не знаем её таймаут,
используем разумный дефолт вместо возврата ошибки.
**Коммит:** `d7fda15`, оператор `v0.1.40`
+159
View File
@@ -0,0 +1,159 @@
# API Design
Последнее обновление: 2026-03-18
## Базовый URL
```
http://<operator-host>:9090/v1
```
Локально: `http://localhost:9090/v1`
В кластере: `http://sless-operator.sless.svc.cluster.local:9090/v1`
Публично: `https://sless.kube5s.ru/v1/...` (через Ingress)
**Реализованные эндпоинты:**
```
GET /v1/namespaces/{ns}/functions
POST /v1/namespaces/{ns}/functions
GET /v1/namespaces/{ns}/functions/{name}
DELETE /v1/namespaces/{ns}/functions/{name}
POST /v1/namespaces/{ns}/functions/{name}/upload
GET /v1/namespaces/{ns}/triggers
POST /v1/namespaces/{ns}/triggers
GET /v1/namespaces/{ns}/triggers/{name}
DELETE /v1/namespaces/{ns}/triggers/{name}
GET /v1/namespaces/{ns}/functions/{name}/invocations
```
**Запланированные эндпоинты (ветка feat/web-console):**
```
GET /v1/namespaces/{ns}/functions/{name}/source ← НОВЫЙ: код из S3 zip
PATCH /v1/namespaces/{ns}/triggers/{name} ← НОВЫЙ: enable/disable триггера
```
**Глобальный сервис funcs (не оператор):**
```
GET https://sless.kube5s.ru/funcs/<namespace> ← без токена, plain text / HTML
GET https://sless.kube5s.ru/funcs?token=<jwt> ← с токеном
GET https://sless.kube5s.ru/health ← liveness probe
```
## Аутентификация
```
Authorization: Bearer <cloud-token>
```
Токен — JWT от `auth-api`. Middleware в операторе:
1. Извлекает `sub` из payload (без проверки подписи — доверяет Ingress)
2. Вычисляет namespace: `SHA256(sub)[:8]` hex → `sless-{16 hex символов}`
3. Проверяет что запрошенный `{namespace}` совпадает с вычисленным
## Ресурсы
### Functions
| Метод | Путь | Описание |
|-------|------|----------|
| GET | /functions | Список функций |
| POST | /functions | Создать функцию |
| GET | /functions/{id} | Получить функцию |
| PUT | /functions/{id} | Обновить функцию |
| DELETE | /functions/{id} | Удалить функцию |
### Versions (код функции)
| Метод | Путь | Описание |
|-------|------|----------|
| GET | /functions/{id}/versions | Список версий |
| POST | /functions/{id}/versions | Загрузить новый код (multipart zip) |
| GET | /functions/{id}/versions/{ver} | Получить версию |
| POST | /functions/{id}/versions/{ver}/activate | Активировать версию |
### Triggers
| Метод | Путь | Описание |
|-------|------|----------|
| GET | /functions/{id}/triggers | Список триггеров |
| POST | /functions/{id}/triggers | Создать триггер (HTTP/Cron) |
| DELETE | /functions/{id}/triggers/{tid} | Удалить триггер |
### Invocations (вызов и логи)
| Метод | Путь | Описание |
|-------|------|----------|
| POST | /functions/{id}/invoke | Синхронный вызов |
| GET | /functions/{id}/invocations | История вызовов |
| GET | /functions/{id}/invocations/{iid} | Детали вызова + логи |
## Upload endpoint
```
POST /v1/namespaces/{namespace}/functions/{name}/upload
Content-Type: multipart/form-data
Authorization: Bearer <token>
field: code = <zip-file>
```
Процесс:
1. Принимает zip (max 32MB)
2. Распаковывает zip
3. Генерирует `Dockerfile` (`FROM naeel/sless-runtime-{runtime}:latest\nCOPY . /app/function/`)
4. Перепаковывает в `tar.gz` (kaniko требует tar format)
5. Загружает в S3: `contexts/{ns}/{name}/{timestamp}.tar.gz`
6. Обновляет `fn.Spec.S3Key` → контроллер видит изменение и запускает kaniko Job
Ответ `200 OK`:
```json
{"message": "build queued", "phase": "Pending", "s3_key": "contexts/..."}
```
## Поддерживаемые runtime (v1)
- `python3.11` — реализован и протестирован
- `go1.21` — планируется
- `nodejs20` — планируется
## Модель Function
```json
{
"id": "fn-uuid",
"name": "my-function",
"description": "...",
"runtime": "python3.11",
"entrypoint": "handler.handle",
"memory_mb": 128,
"timeout_sec": 30,
"env_vars": {"KEY": "value"},
"active_version": "1",
"status": "active",
"created_at": "...",
"updated_at": "..."
}
```
## Модель Trigger
```json
{
"id": "tr-uuid",
"type": "http",
"url": "https://sless.api.ngcloud.ru/invoke/fn-uuid",
"created_at": "..."
}
```
```json
{
"id": "tr-uuid",
"type": "cron",
"schedule": "0 * * * *",
"created_at": "..."
}
```
+308
View File
@@ -2,6 +2,190 @@
> Сюда записываем проблемы с которыми столкнулись и как их решили.
---
## 2026-03-17 — Баг 3: PodLogOptions compile error (v0.1.31)
**Проблема:** Оператор не компилировался. Ошибка:
```
controllers/functionjob_controller.go: unknown field Stdout in corev1.PodLogOptions
controllers/functionjob_controller.go: unknown field Stderr in corev1.PodLogOptions
```
**Причина:** `corev1.PodLogOptions{}` в k8s API не имеет полей `Stdout` и `Stderr` — это поля из `corev1.ContainerState`. Код выглядел так:
```go
req := kube.CoreV1().Pods(namespace).GetLogs(pods.Items[0].Name, &corev1.PodLogOptions{
Stdout: true, // не существует
Stderr: true, // не существует
})
```
**Решение:** Убрать несуществующие поля. `GetLogs` по умолчанию возвращает stdout+stderr без дополнительных флагов:
```go
req := kube.CoreV1().Pods(namespace).GetLogs(pods.Items[0].Name, &corev1.PodLogOptions{})
```
**Файл:** `controllers/functionjob_controller.go`, функция `getJobPodOutput`
**Версия:** исправлено в `naeel/sless-operator:v0.1.31`
---
## 2026-03-17 — Главная причина провала POSTGRES example: неправильный nubes_endpoint
### Симптом
`terraform apply` для `examples/POSTGRES` падал с `"job failed, check pod logs"`. При этом `examples/simple-python` и `examples/hello-node` работали нормально на тест-стенде.
### Корневая причина
В `examples/POSTGRES/main.tf` в блоке `provider "sless"` был указан **продовый** endpoint Nubes API:
```hcl
provider "sless" {
endpoint = "https://sless-api.kube5s.ru"
token = var.api_token
nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1" # ← ПРОД
}
```
При этом `provider "nubes"` уже указывал на тест:
```hcl
provider "nubes" {
api_token = var.api_token
api_endpoint = "https://deck-api-test.ngcloud.ru/api/v1/index.cfm" # ← тест ✓
}
```
Из-за этого `sless_function` и `sless_job` стучались в **продовый** Nubes API, где у тестового пользователя другой namespace и другой кластер. Postgres и pod запускались в контексте прода, а не тест-стенда. Поды не могли подключиться к базе.
### Фикс
```hcl
# examples/POSTGRES/main.tf
provider "sless" {
endpoint = "https://sless-api.kube5s.ru"
token = var.api_token
nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1" # ← тест
}
```
Аналогично исправлено в `examples/simple-python/main.tf`.
### Почему долго искали
Долгое расследование ушло на "split-brain" — API-созданные FunctionJob'ы были невидимы через `kubectl get functionjobs -A`. Это объяснялось тем что:
1. Job завершался (Failed) за ~5 секунд
2. Между завершением и опросом kubectl объект уже мог быть очищен
3. Параллельно в кластере фигурировал другой namespace (`sless-ffd1f598c169b0ae` — прод) vs тест-namespace
На самом деле контроллер работал корректно. Проблема была исключительно в `nubes_endpoint`.
### Урок
**Всегда проверять**: когда в `main.tf` два провайдера (`nubes` + `sless`) — у обоих endpoint'ы должны указывать на **одну среду**. Смешивание прод/тест endpoint'ов в одном apply даёт непредсказуемые результаты.
---
## 2026-03-17 — Ошибка агента: локальный запуск terraform
**Проблема:** Агент запускал `terraform apply` **локально** (`/home/naeel/remote_dev/sless/examples/...`) вместо запуска на удалённом сервере `naeel@5.172.178.213`.
**Почему неправильно:**
- Провайдер `terra.k8c.ru/naeel/sless` установлен только на удалённом сервере
- Локальный terraform не имеет доступа к k8s кластеру напрямую
- Потенциально затрагивал продовые API из локальной сети
**Правило:** Все `terraform init/plan/apply/destroy` для `examples/` — только через SSH на `naeel@5.172.178.213`:
```bash
ssh -i /home/naeel/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213 \
'cd /home/naeel/terra/sless/examples/<example> && terraform apply -auto-approve -no-color'
```
---
## 2026-03-17 — FunctionJob всегда "job failed, check pod logs" — расследование и два бага
### Хронология расследования
**Симптом:** `terraform apply` на `examples/POSTGRES` завершается ошибкой:
```
job failed: job failed, check pod logs: kubectl logs -n sless-fn-sless-ffd1f598c169b0ae -l functionjob=pg-create-table-job-main-v12
```
Команда из сообщения — ничего не выдаёт (под не найден).
---
**Ошибка агента №1 — SQL-диагностика вместо анализа кода**
Первая реакция — запустить диагностические поды в кластере с psycopg2 чтобы проверить подключение к PostgreSQL. Это было **неправильно**: проблема не в подключении, а в том что job не запускался вообще. Пользователь остановил — "ПРИ ЧЁМ тут SQL запросы???".
---
**Ошибка агента №2 — не читал собственный код**
Раньше читались логи оператора и kubectl describe — но не исходный код контроллеров. Пользователь указал: "мы пишем ВСЁ сами, смотри в код". После прочтения `functionjob_controller.go` и `functions.go` картина сложилась за одно чтение.
---
### Баг 1 — k8s label `job-name` удалён в 1.27+
**Файл:** `controllers/functionjob_controller.go`, функция `getJobPodOutput`
**Код до:**
```go
pods, err := kube.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{
LabelSelector: "job-name=" + jobName,
})
if err != nil || len(pods.Items) == 0 {
return "completed successfully" // ← сюда всегда попадали
}
```
**Причина:** В k8s 1.27 лейбл `job-name` на pod-ах был deprecated, в 1.32+ удалён полностью. У нас кластер **1.34.1**. Поэтому `List` возвращал 0 подов всегда → fallback `"completed successfully"` → при `phase=Failed` финальное сообщение `"job failed, check pod logs: ..."`.
Логи реального сбоя никогда не попадали в `status.Message` FunctionJob, поэтому **корневая ошибка Python-функции была невидима**.
**Как нашли:** Проверили `kubectl get pod -l "batch.kubernetes.io/job-name"` — вернул поды. `kubectl get pod -l "job-name"` — не вернул. Версия кластера: `v1.34.1` (kubectl `v1.35.2`).
**Фикс:** Использовать собственный лейбл `functionjob=<fj.Name>`, который мы сами выставляем на PodTemplate и который работает независимо от версии k8s.
---
**Также:** В строке 211 подсказка для пользователя тоже использовала устаревший лейбл:
```go
"job failed, check pod logs: kubectl logs -n " + job.Namespace + " -l job-name=" + job.Name
```
Исправлено на `functionjob=<fj.Name>` — теперь команда реально работает.
---
### Баг 2 — Split-brain cached client при CreateFunction
**Файл:** `internal/api/handler/functions.go`, функция `CreateFunction`
**Проявление:** API возвращает `409 "function already exists"`, а `kubectl get function -A` функцию не видит. `terraform state rm sless_function.*` не помогает — следующий `terraform apply` снова получает 409.
**Причина:** `h.K8s` в handler — это **cached client** controller-runtime. Цепочка:
1. Предыдущий `terraform destroy` вызвал `DELETE /functions/pg-create-table-runner`
2. Handler вызвал `h.K8s.Delete(ctx, fn)` — объект удалён из **etcd**
3. Кеш controller-runtime обновляется асинхронно (informer watch). Несколько секунд объект ещё жив в памяти оператора
4. Следующий `terraform apply``POST /functions``h.K8s.Create(ctx, fn)``IsAlreadyExists` (кеш ещё видит объект)
5. Код проверяет `phase == Failed` — но объект в кеше в фазе `Ready` → уходит в 409 навсегда
`kubectl get function` шёл **мимо кеша** (прямо в k8s API) → NotFound. API handler шёл **через кеш** → AlreadyExists. Поэтому они показывали разные результаты.
**Фикс:** При `IsAlreadyExists` делать `uncached Get` (через `client.ObjectKey` напрямую). Если объект реально не найден в etcd (`IsNotFound`) — значит кеш устарел, смело создаём заново. Если найден — возвращаем 409 как обычно (объект реально существует).
---
### Итог
| # | Баг | Файл | Строка | Тип |
|---|-----|------|--------|-----|
| 1 | `LabelSelector: "job-name="` deprecated k8s 1.27+ | `controllers/functionjob_controller.go` | 311 | Совместимость |
| 1b | Подсказка `kubectl logs -l job-name=` тоже устарела | `controllers/functionjob_controller.go` | 211 | UX |
| 2 | Cached client → perpetual 409 при CreateFunction | `internal/api/handler/functions.go` | 117-133 | Race condition |
## Шаблон записи
```
@@ -447,3 +631,127 @@ Attribute runtime value must be one of: ["nodejs20" "python3.11" "go1.21"], got:
### Версии
- Оператор: `naeel/sless-operator:v0.1.13`
- Провайдер: `terra.k8c.ru/naeel/sless v0.1.7`
---
## 2026-03-19 — Баг 4: Хардкодный 30s таймаут в invoke.go → context deadline exceeded
### Симптом
Вызов функции `stress-go-pgstorm` с `duration_sec=30` возвращал:
```json
{"error": "function unreachable: ... context deadline exceeded"}
```
При этом под был `Running`, логи показывали нормальную работу pgxpool.
### Корневая причина
В `internal/api/handler/invoke.go` (строка 24) был глобальный http.Client:
```go
var httpClient = &http.Client{Timeout: 30 * time.Second}
```
Функция реально отрабатывала ровно 30 секунд (duration_sec=30) + накладные расходы
на pgxpool.New() и первый коннект к БД ≈ 1-2 секунды.
Итого запрос превышал 30s → оператор разрывал соединение раньше чем функция успевала ответить.
### Почему так было написано
При создании invoke.go в марте 2026 таймаут 30s считался "достаточным для холодного
старта". Длительные функции тогда не планировались. Когда появились batch/stress задачи
с timeout_sec=600-700 — баг стал критическим.
### Решение
Убрать глобальный `httpClient`. Перед каждым вызовом:
1. Получить Function CRD из k8s: `h.K8s.Get(ctx, ObjectKey{name, ns}, fn)`
2. Прочитать `fn.Spec.TimeoutSec`
3. Создать `http.Client{Timeout: TimeoutSec*time.Second + 5*time.Second}`
4. Если функция не найдена (Get вернул ошибку) — дефолт 30s
```go
func invokeHTTPClient(timeoutSec int32) *http.Client {
t := time.Duration(timeoutSec)*time.Second + 5*time.Second
if timeoutSec <= 0 {
t = 30 * time.Second
}
return &http.Client{Timeout: t}
}
```
### Файл
`internal/api/handler/invoke.go` — исправлено в коммите `d7fda15`
Оператор пересобран: `naeel/sless-operator:v0.1.40`
### Урок
**Никогда не хардкодить таймауты** в прокси-слое. Таймаут всегда должен браться
из конфигурации вызываемого ресурса. `Function.Spec.TimeoutSec` существует именно для этого.
---
## 2026-03-19 — Баг 5: nginx ingress proxy-read-timeout не задан → 504 Gateway Time-out
### Симптом
После фикса invoke.go (баг 4) — повторный вызов `stress-go-pgstorm` вернул:
```html
<html><head><title>504 Gateway Time-out</title></head>
<body><center><h1>504 Gateway Time-out</h1></center>
<hr><center>nginx</center></body></html>
```
curl exit code 5 (не 0), процесс завершился через ~60 секунд после старта запроса.
### Корневая причина
У ingress `sless-operator` не было аннотации `proxy-read-timeout`.
nginx ingress controller использует дефолт **60 секунд** если аннотация отсутствует.
```yaml
# Было — аннотаций timeout нет вообще:
annotations:
kubernetes.io/ingress.class: nginx
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
```
Цепочка: клиент → nginx (60s timeout) → оператор (705s) → функция (600s).
Nginx оборвал соединение на 60-й секунде, хотя и оператор и функция были живы.
### Диагностика
Проверили аннотации всех ingress в ns sless:
- `nodered` ingress: `proxy-read-timeout: "3600"` ✅ (кто-то правильно настроил)
- `sless-funcs-ingress`: нет timeout аннотаций
- `sless-operator`: нет timeout аннотаций ← **виновник**
### Решение
1. `kubectl annotate` для мгновенного применения:
```bash
kubectl annotate ingress sless-operator -n sless \
nginx.ingress.kubernetes.io/proxy-read-timeout="900" \
nginx.ingress.kubernetes.io/proxy-send-timeout="900" --overwrite
```
2. Сохранить в манифест `deployments/k8s/operator.yaml`:
```yaml
nginx.ingress.kubernetes.io/proxy-read-timeout: "900"
nginx.ingress.kubernetes.io/proxy-send-timeout: "900"
```
### Почему 900s
- function timeout_sec = 700 → оператор ждёт 705s
- nginx должен ждать дольше чем оператор → 900s с запасом
- Не ставим 3600s как у nodered — избыточно для функций
### Файл
`deployments/k8s/operator.yaml` — обновлено в коммите `d7fda15`
### Урок
При развёртывании нового ingress **всегда явно задавать** `proxy-read-timeout`
и `proxy-send-timeout`. Nginx дефолт 60s подходит только для быстрых API.
Для любых операций дольше 30s — обязательны явные таймауты.
+763
View File
@@ -0,0 +1,763 @@
# Решения и обоснования
## 2026-03-18 — Архитектура: funcs как глобальный сервис, web-консоль
### Хранение кода функций
S3 (minio внутри кластера) хранит **два артефакта** на каждый upload:
```
functions/{namespace}/{name}/{timestamp}.zip ← ИСХОДНЫЙ КОД (zip пользователя)
contexts/{namespace}/{name}/{timestamp}.tar.gz ← BUILD CONTEXT для kaniko (zip + Dockerfile)
```
Function CRD хранит `spec.s3Key` — указывает на `contexts/...` (build context).
Из него можно восстановить путь к исходному zip:
`contexts/{ns}/{name}/{ts}.tar.gz``functions/{ns}/{name}/{ts}.zip`
Поэтому для отображения кода в веб-консоли **не нужно ничего менять в CRD**:
достаточно нового эндпоинта `GET /source` который читает zip из S3.
### Решение: funcs как глобальный Go сервис вместо per-user terraform
**Было:** `sless_function.funcs_list` + `sless_trigger.funcs_list_http` в `examples/POSTGRES/resources.tf`
— Для каждого пользователя terraform создавал отдельный pod функции
— Требовал `api_token`, `SLESS_NAMESPACE` как env vars в terraform
— Не масштабируется: N пользователей = N лишних pod'ов
**Стало:** `services/funcs/main.go` — один Go HTTP сервис в namespace `sless`
— Деплоится один раз через `deployments/k8s/funcs-service.yaml`
— Принимает JWT токен → извлекает `sub``SHA256[:8]` → namespace
— URL без токена: `/funcs/<namespace>` (namespace не секрет — виден в URL каждой функции)
`SLESS_SERVICE_TOKEN` задаётся через `kubectl set env` (не хранится в git)
### Про будущую синхронизацию terraform-папок с кластером
Terraform уже работает по схеме: `source_dir` → zip → `POST /upload` → S3.
Обратная синхронизация (кластер → локальная папка): скачать zip из S3 → распаковать в `source_dir`.
Никаких структурных изменений не потребует. Реализовывать ПОСЛЕ web-консоли.
### Архитектура web-консоли (план, ветка feat/web-console)
**Принцип:** минимум изменений в операторе, максимум логики в `sless-funcs-service`.
**Два новых эндпоинта в операторе:**
| Метод | Путь | Что делает |
|-------|------|-----------|
| GET | `/v1/namespaces/{ns}/functions/{name}/source` | Читает zip из S3 → JSON `[{name, content}]` |
| PATCH | `/v1/namespaces/{ns}/triggers/{name}` | `{"enabled": bool}` → обновляет Trigger CRD |
**`sless-funcs-service` — HTML режим:**
- Если запрос из браузера (`Accept: text/html`) → отдаёт HTML страницу
- Список функций — аккордеон; при раскрытии `fetch(/source)` подгружает файлы
- Подсветка синтаксиса: `highlight.js` с CDN (не требует сборки)
- Кнопки ▶ Старт / ■ Стоп → `PATCH /triggers/{name}` через fetch
- `text/plain` ответ для curl/CLI остаётся без изменений
---
## 2026-03-18 — Смена sless API endpoint: sless-api.kube5s.ru → sless.kube5s.ru
**Решение:** Оператор sless доступен по `https://sless.kube5s.ru` (не `sless-api.kube5s.ru`). Все examples, deployments и ConfigMap обновлены.
**Причина:** При пересоздании кластера DNS-запись `sless-api.kube5s.ru` не была обновлена — она указывала на IP `5.172.178.182` (старый, мёртвый кластер). Ingress нового кластера закреплён на `185.247.187.147`. Отдельная запись `sless.kube5s.ru` уже корректно указывала на `185.247.187.147`.
TLS handshake timeout возникал потому что старый IP принимал TCP:443, но не завершал TLS (nginx жив, бэкенд мёртв). Go HTTP клиент ждал системный таймаут (~90s) и повторял бесконечно.
**Изменения:**
- `deployments/k8s/operator.yaml`: `EXTERNAL_URL`, `INGRESS_HOST`, ingress host → `sless.kube5s.ru`
- ConfigMap `sless-operator-config` в кластере: `EXTERNAL_URL` обновлён через `kubectl patch`
- Ingress `sless-operator` в кластере: host + TLS secret → `sless.kube5s.ru`
- Все `examples/**/main.tf`: `endpoint = "https://sless.kube5s.ru"`
**Правило:** При пересоздании кластера — первым делом проверять соответствие DNS → ingress IP.
---
## 2026-03-17 — Разделение prod/test endpoint'ов в examples/
**Решение:** Все `examples/` ОБЯЗАНЫ использовать `deck-api-test.ngcloud.ru` для обоих провайдеров: `nubes` и `sless` (`nubes_endpoint`). Продовый `deck-api.ngcloud.ru` — только для реальных клиентов.
**Причина:** Смешивание prod и test endpoint'ов в одном `terraform apply` приводит к тому что ресурсы создаются в разных средах. `sless_function`/`sless_job` могут получать данные (PGHOST, credentials) из прода, а pod запускается в тест-кластере — и не может достучаться до хоста.
**Правило для `main.tf` в examples:**
```hcl
provider "nubes" {
api_endpoint = "https://deck-api-test.ngcloud.ru/api/v1/index.cfm"
}
provider "sless" {
nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1"
}
```
---
## 2026-03-17 — terraform apply только на удалённом сервере
**Решение:** `terraform init/plan/apply/destroy` для `examples/` — исключительно через SSH на сервере `naeel@5.172.178.213`. Локальный запуск запрещён.
**Причина:**
1. Провайдер `terra.k8c.ru/naeel/sless` кэширован только на удалённом сервере
2. Локальный terraform не имеет сетевого доступа к k8s кластеру и внутренним кластерным адресам (например PGHOST вида `*.svc.cluster.local`)
3. Случайный локальный запуск с prod токенами может затронуть боевую среду
**Как запускать:**
```bash
# Сначала синхронизировать изменения:
rsync -av -e "ssh -i /home/naeel/.ssh/naeel_vm_id_ed25519" \
/home/naeel/remote_dev/sless/examples/<example>/ \
naeel@5.172.178.213:/home/naeel/terra/sless/examples/<example>/
# Затем запускать на remote:
ssh -i /home/naeel/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213 \
'cd /home/naeel/terra/sless/examples/<example> && terraform apply -auto-approve -no-color'
```
---
## 2026-03-06 — Отдельная репа для сервиса
**Решение:** Serverless service в отдельной репе, не вместе с Terraform provider.
**Причина:** Разные зоны ответственности, разные релизы, потенциально разные команды.
---
## 2026-03-06 — Один бинарник для v1
**Решение:** Один Go бинарник вместо микросервисов.
**Причина:** Нагрузки изначально нет. Проще деплоить, проще отлаживать. Разделим при необходимости.
---
## 2026-03-06 — Аутентификация через облачный токен
**Решение:** Использовать Bearer token облака, без Keycloak.
**Причина:** Terraform provider уже работает с токенами облака. Keycloak — лишняя зависимость для v1.
---
## 2026-03-06 — S3 облачный, остальное в кубере
**Решение:** S3 (Ceph) использовать облачный (`ceph.tst.nubes.ru`), PostgreSQL/Redis — в кластере.
**Причина:** S3 имеет внешний доступ и уже готов. Для PostgreSQL/Redis сетевого связывания с облаком пока нет — настраивается через devops облака.
---
## 2026-03-06 — Текущий кластер для разработки
**Решение:** Использовать существующий k8s кластер (namespace `sless`), потом перенести на новый.
**Причина:** Новый кластер ещё не готов. Изоляция через namespace — безопасно для существующих сервисов.
---
## 2026-03-06 — RabbitMQ откладываем
**Решение:** В v1 только HTTP и Cron триггеры. RabbitMQ/event triggers — в v2.
**Причина:** Упрощение первой итерации.
---
## 2026-03-07 — DockerHub вместо внутреннего registry
**Решение:** Образы функций и runtime базовые образы публикуются на DockerHub (user `naeel`).
**Причина:** Namespace `registry` в кластере — это Apache NiFi Registry (NOT Docker). Отдельный Docker registry не поднят. DockerHub доступен и достаточен для разработки.
---
## 2026-03-07 — Terraform провайдер sless — отдельный модуль в той же репе
**Решение:** `terraform/provider/` — независимый Go-модуль внутри репы `sless`.
**Причина:** Удобно держать рядом с кодом оператора во время разработки.
**Важно:** `provider "sless"` и `provider "nubes"` — это **два отдельных независимых провайдера**. Объединять их нельзя:
- разные зоны ответственности (`nubes` — облачная инфраструктура, `sless` — serverless функции)
- разные релизные циклы
- разные команды в будущем
Пользователь использует оба провайдера вместе в одном `.tf` файле, но это не означает что они должны быть одним бинарником.
---
## 2026-03-07 — WaitReady в Terraform провайдере при создании функции
**Решение:** После `UploadCode` провайдер ждёт `phase=Ready` (polling каждые 5 сек, таймаут 5 мин).
**Причина:** Kaniko-сборка занимает ~1 минуту. Без ожидания `terraform apply` завершился бы с `phase=Building` в state, что неверно отображало бы реальное состояние ресурса.
---
## 2026-03-07 — code_hash для детектирования изменений кода функции
**Решение:** Атрибут `code_hash` в `sless_function` — пользователь задаёт через `filemd5("./handler.zip")`. Изменение hash → провайдер перезагружает zip и запускает пересборку.
**Причина:** Terraform не отслеживает содержимое файлов автоматически. Это стандартный паттерн (аналогично `aws_lambda_function.source_code_hash`).
---
## 2026-03-07 — Scale-to-zero откладываем до v2
**Решение:** В v1 функции работают как Deployment с постоянно живым подом (always-on). Scale-to-zero — в v2 через KEDA HTTP Add-on.
**Причина:** Scale-to-zero меняет архитектуру контроллера и routing. Для MVP это несоразмерная сложность. Пользователь может управлять ресурсами вручную через `replicas = 0/1/N` (планируется в v1.1).
**v2 план:** Заменить Deployment на `HTTPScaledObject` (KEDA), минимальные реплики = 0. KEDA буферизует запросы во время cold start (~1-3 сек).
---
## 2026-03-07 — replicas как ручное управление масштабом (TODO v1.1)
**Решение:** Добавить поле `replicas *int32` в `FunctionSpec`. Пользователь задаёт через Terraform: `replicas = 0` (выключить), `replicas = 1` (включить), `replicas = N` (масштабировать).
**Причина:** Без этого функция жрёт ресурсы 24/7 даже если не нужна. Это минимальный механизм контроля потребления до реализации scale-to-zero.
---
## 2026-03-07 — PostgreSQL опционален для базового Function Hosting
**Решение:** Postgres нужен только для логов вызовов (`invocations`). Для базового деплоя функций — не нужен. Оператор работает без него (просто не пишет логи).
**Минимальные зависимости для production:** k8s кластер + S3 + Docker registry + Ingress.
## 2026-03-07 — Версионированные теги для runtime образов (не :latest)
**Решение:** Runtime базовые образы (`sless-runtime-python3.11`, `sless-runtime-nodejs20`) и образ оператора (`sless-operator`) тегируются по схеме `v<major>.<minor>.<patch>`. `:latest` не используется.
**Причина:**
- `:latest` приводит к непредсказуемому поведению: kaniko может взять старый кешированный образ, pod не перезапускается если `imagePullPolicy: IfNotPresent`.
- Версионированные теги дают явный контроль: при изменении runtime нужно обновить тег в `upload.go` → это принудительно пересобирает все функции с новым базовым образом.
- Аудит и откат: можно пинить конкретную версию runtime.
**Соглашение:**
- Runtime образы: `naeel/sless-runtime-{lang}:v{версия}` (например `v0.1.0`)
- Оператор: `naeel/sless-operator:v{версия}`
- При изменении runtime — инкрементировать минорную версию образа и обновить константу в `upload.go`
---
## 2026-03-07 — nodejs20 как второй поддерживаемый runtime
**Решение:** Добавлен nodejs20 runtime (`node:20-alpine` base, `server.js` HTTP wrapper, `exports.handle(event)`).
**Причина:** Node.js — стандарт для serverless (AWS Lambda, Vercel). Покрывает JS/TypeScript аудиторию. Паттерн идентичен python3.11: runtime image → kaniko → Deployment.
**Детали реализации:**
- `runtimes/nodejs20/server.js``http.createServer`, динамический `require(HANDLER_PATH)`
- Зависимости через `package.json``npm install --omit=dev` (аналог `requirements.txt``pip install`)
- `entrypoint` в HCL игнорируется для Node.js (всегда `handler.js` + `exports.handle`) — TODO: поддержать произвольный entrypoint в v1.1
---
## 2026-03-07 — FunctionJob CRD: одноразовые запуски функций
**Решение:** Добавлен `FunctionJob` CRD для одноразового запуска функции с произвольным JSON-событием.
**Причина:** Нужны sync-вызовы без HTTP — для батч-обработки, миграций, крон-задач через Terraform.
**Реализация:**
- `api/v1alpha1/job_types.go` — CRD: `FunctionRef`, `EventJSON`, phases: Pending/Running/Succeeded/Failed
- `controllers/functionjob_controller.go` — создаёт k8s Job, ждёт завершения, синхронизирует статус
- `internal/api/handler/jobs.go` — REST: CreateJob/GetJob/DeleteJob
- `terraform/provider/internal/resources/job_resource.go` — ресурс `sless_job`
- Настраиваемые таймауты: `build_timeout_sec` (sless_function), `wait_timeout_sec` (sless_job)
---
## 2026-03-07 — Прокси /fn/ вместо wildcard Ingress
**Проблема:** wildcard DNS `*.fn.kube5s.ru` недоступен (провайдер не позволяет).
**Решение:** HTTP-прокси внутри оператора — маршрут `GET|POST|... /fn/{namespace}/{name}` на `sless-api.kube5s.ru`.
**Реализация:**
- `internal/api/handler/invoke.go` — форвардит запрос к `http://{fn}.sless-fn-{ns}.svc.cluster.local:8080`
- `internal/api/router.go``/fn/` регистрируется **до** auth middleware, публично доступен; `/v1/` — по-прежнему с Bearer токеном (gorilla `Use()`)
- `internal/config/config.go` — новое поле `ExternalURL` (env `EXTERNAL_URL`)
- `controllers/trigger_controller.go` — если `ExternalURL` задан, `Trigger.Status.URL = ExternalURL/fn/{ns}/{name}`; иначе fallback: создаёт Ingress с поддоменом (прежнее поведение)
- `deployments/k8s/operator.yaml``EXTERNAL_URL=https://sless-api.kube5s.ru`
**URL функции:** `https://sless-api.kube5s.ru/fn/{namespace}/{name}`
**E2E:** `curl https://sless-api.kube5s.ru/fn/default/hello-node``{"message":"Hello, Naeel! (nodejs20)"}`
---
## 2026-03-08 — Lifecycle control: trigger.enabled + job.run_id
**Задача:** управление жизненным циклом ресурсов без удаления.
### trigger.enabled
**Проблема:** нет способа "заморозить" функцию без удаления Trigger/Function (
освобождение ресурсов под праздники, дебаггинг и т.д.).
**Решение:** `enabled bool` (по умолчанию `true`) в `TriggerSpec`.
- `enabled=false` → trigger_controller масштабирует Deployment функции до 0 реплик.
- Функция не принимает запросы, не потребляет CPU (pod не запущен).
- Изменение **не** пересоздаёт ресурс (нет RequiresReplace) — in-place через PATCH.
**Реализация:**
- `api/v1alpha1/trigger_types.go``Enabled bool` в TriggerSpec, `//+kubebuilder:default=true`
- `controllers/trigger_controller.go` — патчит Deployment replicas=0/1 в зависимости от Enabled
- `internal/api/handler/triggers.go``UpdateTrigger` handler (PATCH), поле `enabled` в request/response
- `internal/api/router.go``PATCH /v1/namespaces/{namespace}/triggers/{name}`
- `internal/client/client.go``TriggerUpdateRequest`, `UpdateTrigger()` метод
- `terraform/provider/internal/resources/trigger_resource.go` — атрибут `enabled` (Optional+Computed, default=true), реализован `Update` метод
### job.run_id
**Проблема:** нет способа создать FunctionJob "отложенным" — с явным контролем когда запускать.
Также нет механизма повторного запуска с сохранением структуры ресурса.
**Решение:** `run_id int64` (по умолчанию `0`) в `FunctionJobSpec`.
- `run_id=0` → FunctionJob создаётся в k8s, но k8s Job не запускается (phase=Skipped).
- `run_id>0` → запускает Job. Увеличение значения (1→2→3) = повторный запуск через пересоздание.
**Реализация:**
- `api/v1alpha1/job_types.go``RunID int64` в FunctionJobSpec, `//+kubebuilder:default=0`
- `controllers/functionjob_controller.go` — если RunID==0 → устанавливает phase=Skipped, return
- `internal/api/handler/jobs.go` — поле `run_id` в jobRequest/jobResponse
- `internal/client/client.go``RunID int64` в JobRequest/JobResponse
- `terraform/provider/internal/resources/job_resource.go` — атрибут `run_id` (RequiresReplace, default=0). Если run_id=0 → не ждёт завершения, phase=Skipped сразу в state.
**Версии:**
- operator: `naeel/sless-operator:v0.1.6`
- provider: `terra.k8c.ru/naeel/sless v0.1.4`
---
## 2026-03-08 — Переключение registry с Harbor на DockerHub
**Проблема:** Harbor (`pearlharbor.registryk8s.services.ngcloud.ru`) — внешний сервис облачного провайдера. Нестабилен: `/v2/` периодически зависает на 10+ секунд или возвращает 504. Kaniko не мог завершить push образа.
**Решение:** `REGISTRY_HOST=naeel` (DockerHub namespace). Образы функций пушатся как `naeel/sless-default-{namespace}-{name}:latest`.
**Реализация:**
- `deployments/k8s/operator.yaml` — configmap `REGISTRY_HOST: "naeel"`
- Secret `sless-registry-auth` уже содержал DockerHub credentials → дополнительных изменений не потребовалось
**Компромисс:** DockerHub — публичный registry. Образы функций пользователей публично видимы. Для production нужен приватный registry (Harbor, ECR, GCR и т.д.).
**Версии:**
- operator: оператор не пересобирался, только configmap
- commit: `b69f795`
---
## 2026-03-08 — FunctionJob polling вместо Owns watch
**Проблема:** `Owns(&batchv1.Job{})` в `SetupWithManager` не работает cross-namespace. Job создаётся в `sless-fn-{ns}`, FunctionJob — в user namespace. Watch никогда не срабатывал.
**Решение:** Убрать `Owns`. В `syncJobStatus` при Running статусе возвращать `ctrl.Result{RequeueAfter: 5 * time.Second}` — контроллер сам поллит k8s Job каждые 5 сек.
**Версии:**
- operator: `naeel/sless-operator:v0.1.10`
- commit: `461ac09`
---
## 2026-03-08 — code_hash: filesha256 вместо output_md5
**Проблема:** `hashicorp/archive v2.7.x` имеет баг: `output_md5` возвращает MD5 предыдущей версии zip. `output_sha` и `output_sha256` обновляются корректно.
**Решение:** `code_hash = filesha256("${path.module}/code/handler.js")` — хэшируется исходный файл напрямую.
**Правило проекта:** В `sless_function.code_hash` всегда использовать `filesha256(source_file)`, не `archive_file.output_md5`.
---
## 2026-03-08 — Rollout restart после kaniko build (imagePullPolicy + :latest)
**Проблема:** После успешной kaniko сборки pod не перезапускался — kubelet брал кешированный образ `:latest` (imagePullPolicy: IfNotPresent). Функция возвращала старый код.
**Решение:** В `ensureDeployment` при обновлении существующего Deployment проставляем аннотацию:
```go
existing.Spec.Template.Annotations["kubectl.kubernetes.io/restartedAt"] = fn.Status.LastBuiltAt.Time.Format(time.RFC3339)
```
Значение привязано к `fn.Status.LastBuiltAt` → меняется при каждой сборке → Kubernetes делает rolling restart → свежий образ гарантированно пул-ится.
**Правило проекта:** При использовании `:latest` tag всегда явно проставлять `restartedAt` annotation при обновлении кода.
**Версия:** operator `naeel/sless-operator:v0.1.11`
---
## 2026-03-10 — LLM-валидация кода при upload (pre-build security gate)
**Решение:** Интегрировать вызов облачного LLM в pipeline upload кода. LLM анализирует исходники пользователя **до** отправки в S3 и запуска kaniko. Если код подозрительный — upload отклоняется с HTTP 400 и причиной.
**Причина:**
1. LLM уже развёрнут (или скоро будет) в облаке nubes.ru — его нужно загрузить реальной работой.
2. Dogfooding: облачный провайдер использует собственный сервис ИИ в своём же продукте serverless.
3. Маркетинг: "ваш код проверяется ИИ перед деплоем" — реальная продающая фича.
4. Security: защита от криптомайнеров, ботнетов, DDoS-агентов, port scanners в пользовательских функциях.
**Точка интеграции:** `internal/api/handler/upload.go` — между распаковкой zip и упаковкой tar.gz.
**Pipeline с LLM:**
```
POST /upload (zip)
→ распаковка zip
→ извлечение текстовых файлов (.py, .js, .ts, .json, .sh, .sql ...)
→ POST к облачному LLM API с исходниками + системным промптом
→ safe=true → Dockerfile + tar.gz → S3 → CRD patch (обычный путь)
→ safe=false → HTTP 400 {"error": "code validation failed: <reason>"}
→ LLM error → warning в лог, upload продолжается (soft-fail)
```
**Режим работы: blocking + soft-fail**
- `safe=false` → upload отклоняется (HTTP 400), код не попадает в S3, сборка не начинается.
- LLM недоступен (timeout, 5xx) → upload **пропускается** (soft-fail), логируется warning.
Причина: недоступность LLM не должна ломать весь pipeline деплоя.
**Новый пакет:** `internal/validator/`
**Интерфейс:**
```go
// internal/validator/validator.go
type CodeValidator interface {
// Validate проверяет код функции перед сборкой.
// files — map[filename]content (текстовые файлы из zip).
// Возвращает (true, "") если код safe, (false, reason) если нет.
// При ошибке связи с LLM — возвращает (true, "") + логирует warning (soft-fail).
Validate(ctx context.Context, files map[string]string, runtime string) (safe bool, reason string, err error)
}
```
**LLM-реализация:**
```go
// internal/validator/llm.go
type LLMValidator struct {
endpoint string // URL облачного LLM API (OpenAI-compatible)
apiKey string // токен доступа
timeout time.Duration // default: 15s
log *slog.Logger
}
```
**Конфигурация (env vars):**
| Переменная | Default | Описание |
|------------|---------|----------|
| `LLM_ENABLED` | `false` | Включатель. false → NoopValidator (всегда safe) |
| `LLM_ENDPOINT` | — | URL LLM API, например `https://llm.nubes.ru/v1/chat/completions` |
| `LLM_API_KEY` | — | Bearer-токен для LLM API |
| `LLM_TIMEOUT` | `15s` | Максимальное время ожидания ответа |
`LLM_ENABLED=false` → оператор работает без LLM зависимости. По умолчанию выключено.
**Prompt-стратегия:**
Промпт НЕ хардкодится в Go — выносится в константу с возможностью override через ConfigMap.
```
You are a security reviewer for a serverless cloud platform.
Analyze the following {runtime} code deployed as a cloud function.
Check for:
1. Cryptocurrency mining (crypto hash algorithms, pool connections, stratum protocol)
2. DDoS/botnet behavior (mass outbound HTTP/UDP, connection floods)
3. Port scanning / network reconnaissance
4. Reverse shells, backdoors, C2 communication
5. Attempts to escape container (access host filesystem, /proc, /sys)
6. Obfuscated code designed to hide malicious intent
Files:
{files_content}
Respond ONLY with valid JSON, no other text:
{"safe": true} or {"safe": false, "reason": "brief explanation"}
```
**Что НЕ проверяем через LLM (не его задача):**
- Качество кода, стиль, best practices
- Уязвимости в зависимостях (это Trivy/npm audit, потом)
- Бизнес-логику пользователя
**Ограничения по размеру:**
- Суммарный размер текстовых файлов > 100KB → skip LLM (дорого, context window). Деплой проходит.
- Бинарные файлы (.pyc, .so, node_modules/) → не отправляются в LLM.
- Только расширения: `.py`, `.js`, `.ts`, `.json`, `.yaml`, `.yml`, `.txt`, `.sh`, `.sql`, `.go`.
**Встраивание в upload.go:**
```go
// После распаковки zip, до generateDockerfile
if h.Validator != nil {
files := extractTextFiles(zipData)
safe, reason, err := h.Validator.Validate(r.Context(), files, fn.Spec.Runtime)
if err != nil {
h.Log.Warn("llm validation error (soft-fail)", "err", err)
} else if !safe {
writeJSON(w, http.StatusBadRequest, errResp("code validation failed: "+reason))
return
}
}
```
**Terraform provider:** Получит `status 400: code validation failed: <reason>` — пользователь видит причину в `terraform apply` output.
**Файлы для реализации:**
1. `internal/validator/validator.go` — интерфейс CodeValidator + NoopValidator
2. `internal/validator/llm.go` — LLMValidator с HTTP client к OpenAI-compatible API
3. `internal/validator/extract.go` — extractTextFiles: zip → map[string]string
4. `internal/config/config.go` — добавить LLM_ENABLED, LLM_ENDPOINT, LLM_API_KEY, LLM_TIMEOUT
5. `internal/api/handler/handler.go` — добавить Validator поле
6. `internal/api/handler/upload.go` — вызов Validator между zip и tar.gz
7. `main.go` — wire: if LLM_ENABLED → LLMValidator, else → NoopValidator
**Компромиссы:**
- +5-15 секунд к каждому деплою (зависит от скорости LLM).
- False positives: пользователь получит 400 с причиной, может обратиться в support.
- Soft-fail при недоступности LLM: security degraded, но деплой работает.
- Prompt не идеален: LLM не ловит всё. Это дополнительный слой, не единственный.
---
## 2026-03-11 — Два провайдера: sless и nubes — нельзя объединять
**Решение:** Провайдеры `sless` и `nubes`**два отдельных независимых провайдера**.
Объединять их в один бинарник нельзя.
**Причина:**
- Разные зоны ответственности: `nubes` — облачная инфраструктура (ВМ, сети, объектное хранилище),
`sless` — serverless функции.
- Разные релизные циклы.
- В будущем — разные команды.
Пользователь использует оба в одном `.tf` файле — это нормально, это не значит что они один бинарник.
---
## 2026-03-11 — Namespace-per-user через JWT sub → SHA256
**Решение:** Каждый пользователь облака получает отдельный k8s namespace.
Namespace вычисляется детерминированно из JWT sub.
**Алгоритм:**
```
namespace = "sless-" + hex(SHA256(JWT.sub)[:8])
```
Итоговая длина: 22 символа. Пример: `sless-cdd874dfa31ba6ca`.
**Почему SHA256, а не UUID напрямую:**
- UUID (sub) напрямую в имени namespace — раскрывает внутренний ID пользователя.
- SHA256 — необратим, namespace не позволяет восстановить sub.
**Реализация:**
- `client.SubFromJWT(token)` — декодирует JWT payload → возвращает sub
- `client.NamespaceFromSub(sub)` — SHA256(sub)[:8] → hex → "sless-{hex16}"
- Вычисляется в `provider.Configure()` до создания Client
---
## 2026-03-11 — EnsureNamespace как отдельный endpoint (SoC)
**Проблема:** Создание namespace было в resource-хендлерах (CreateFunction, CreateTrigger, CreateJob).
Это нарушение разделения ответственностей: ресурс должен заниматься только тем, для чего предназначен.
**Решение:**
- Создан отдельный endpoint `POST /v1/namespaces/{namespace}/ensure`
- Хендлер вынесен в отдельный файл `internal/api/handler/namespace.go`
- Провайдер вызывает его **один раз** в `Configure()` до создания любых ресурсов
- `handler.go` очищен от k8s-типов (corev1, k8serrors, metav1) — только инфраструктура
**Поведение endpoint:**
- 200 OK `{"namespace": "...", "status": "exists"}` — namespace уже был
- 201 Created `{"namespace": "...", "status": "created"}` — namespace создан
- Идемпотентен: параллельные запросы не падают (IsAlreadyExists обработан)
**Кто отвечает за namespace:**
Только `EnsureNamespace`. Ни один другой хендлер namespace не трогает.
---
## 2026-03-11 — JWT validation в операторе вместо статического токена
**Проблема:** Оператор сравнивал Bearer токен со статическим `apiToken` из конфига.
JWT-токены облака не совпадали → все запросы от провайдера отклонялись с 401.
**Решение:** `internal/api/middleware/auth.go` — заменена проверка:
- Было: `token == cfg.APIToken` (строковое сравнение)
- Стало: `validateJWT(token)` — проверяет структуру JWT (3 части), наличие `sub`, срок действия `exp`
**Почему подпись не проверяется:**
Оператор находится за Ingress в закрытом кластере (trusted perimeter).
Проверка подписи требует публичный ключ issuer — усложнение без реальной пользы в данной топологии.
Подпись проверяется косвенно через `PingNubesAPI` в провайдере при `terraform init`.
**Версия:** operator v0.1.20
---
## 2026-03-11 — Валидация токена через nubes API при Configure
**Решение:** При `terraform init` / `terraform apply` провайдер пингует nubes API
для подтверждения что токен действителен.
**Реализация:** `client.PingNubesAPI(ctx, endpoint, token)`:
- `GET <nubes_endpoint>` с Bearer токеном
- 401/403 → токен отклонён → ошибка инициализации провайдера
- Ошибка соединения → ошибка инициализации
- Любой другой статус (200, 404, 500...) → токен не декларирован невалидным → OK
**Конфигурация:**
```hcl
provider "sless" {
endpoint = "https://sless-api.kube5s.ru"
token = file("./secrets/prod.token")
nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1"
}
```
Env-альтернативы: SLESS_ENDPOINT, SLESS_API_TOKEN, NUBES_ENDPOINT.
---
## 2026-03-11 — SoC рефакторинг handler.go
**Решение:** Файл `handler.go` — чистая инфраструктура.
Бизнес-логика по доменам — в отдельных файлах одного package.
**Структура handler/ package:**
```
handler.go — Handler struct + helpers (writeJSON, errResp, pathVar, namespace)
namespace.go — EnsureNamespace (k8s namespace lifecycle)
functions.go — CRUD Function
triggers.go — CRUD Trigger
jobs.go — CRUD FunctionJob
upload.go — zip -> tar.gz -> S3 -> CRD patch
invoke.go — прокси /fn/ -> in-cluster
invocations.go — 501 stub
```
**Принцип:** каждый файл отвечает за один домен.
`handler.go` не импортирует `corev1/k8serrors/metav1` — эти зависимости только в `namespace.go`.
---
## 2026-03-11 — Namespace пользователя никогда не удаляется
**Решение:** User namespace (`sless-{hex16}`) **не удаляется** ни при каких обстоятельствах.
**Причина:**
- Namespace вычисляется из `JWT.sub` — неизменяемого идентификатора пользователя.
- Namespace = "home directory" пользователя в кластере: `terraform destroy` удаляет
функции/триггеры/джобы, но не сам контейнер для ресурсов.
- Удаление namespace уничтожило бы все CRD объекты пользователя.
- Повторный `terraform apply` (после destroy) нашёл бы свой ns живым — правильное поведение.
**Верификация (проверено):**
- В API нет маршрута `DELETE /v1/namespaces/{namespace}`.
- `handleDeletion` в `function_controller.go` удаляет: Deployment, Service, Ingress, kaniko Job.
- `handleTriggerDeletion` в `trigger_controller.go` удаляет: CronJob (в deployNS), Service, Ingress.
- Оба контроллера содержат явный комментарий: "Namespace sless-fn-{userNS} НЕ удаляется — он принадлежит пользователю".
- Тест: `kubectl get ns sless-cdd874dfa31ba6ca` — namespace жив через 93 минуты после `terraform destroy`.
**Оба namespace предохраняются:**
- `sless-{hex16}` — user namespace (хранит CRD объекты Function/Trigger/FunctionJob)
- `sless-fn-{hex16}` — deploy namespace (хранит Deployment/Service/Ingress/CronJob)
---
## 2026-03-11 — Builder SoC: context.go отделён от upload.go
**Проблема:** `generateDockerfile`, `runtimeBaseImage`, `zipToTarGz` жили в `handler/upload.go`.
Знание о runtime образах и структуре build context — детали **сборки**, не HTTP-хендлера.
Нарушение SoC: HTTP-файл знал о Docker, kaniko, tar.gz, zip-разборе.
**Решение:** Перенести в `internal/builder/context.go`, единственный публичный API:
```go
func PrepareContext(zipData []byte, runtime string) (*bytes.Buffer, error)
```
**Результат:**
- `upload.go`: ~200 LOC → ~60 LOC (только HTTP: принять zip, вызвать PrepareContext, сохранить в S3)
- `context.go`: всё знание о runtime образах, zip→tar, Dockerfile генерации
**Детали реализации:**
- `zipToTarGz` принимает `*zip.Reader` вместо `[]byte` — zip парсится один раз в `PrepareContext`
- `PrepareContext` сама сканирует zip-архив (requirements.txt, package.json) — хендлер не знает об этом
- `runtimeBaseImage` возвращает ошибку для неизвестного runtime — ранний fail до kaniko
**Тесты:** 4 теста в `internal/builder/context_test.go` (python+requirements, node без package.json, unsupported runtime, Dockerfile-first в tar).
---
## 2026-03-11 — Фильтрация hop-by-hop headers в /fn/ прокси
**Проблема:** `invoke.go` пробрасывал все заголовки ответа функции клиенту, включая hop-by-hop.
`Transfer-Encoding: chunked` особенно опасен: Go `http.ResponseWriter` не умеет его воспроизводить,
клиент получал некорректное тело ответа (или ошибку framing).
**Решение:** Фильтровать по RFC 2616 §13.5.1 перед записью в `w`:
```go
var hopByHopHeaders = map[string]bool{
"Connection": true, "Keep-Alive": true, "Proxy-Authenticate": true,
"Proxy-Authorization": true, "Te": true, "Trailers": true,
"Transfer-Encoding": true, "Upgrade": true,
}
// В цикле:
if hopByHopHeaders[k] { continue }
```
**Почему map[string]bool:** O(1) lookup, ключи в canonical form (`http.CanonicalHeaderKey`),
совпадает с форматом ключей в `http.Header` — нет нужды нормализовывать.
**Тесты:** 3 теста в `internal/api/handler/invoke_test.go`
(filtered from response, map contains all RFC2616, canonical key form).
---
## 2026-03-11 — JWKS insertion point stub в auth.go
**Контекст:** v1 auth — `validateJWT` проверяет структуру токена (sub, exp) без проверки подписи.
Это допустимо в trusted perimeter (оператор в k8s, доступен только изнутри).
**Решение:** Добавлена `verifySignature()` как закомментированная заготовка в `auth.go`.
**v2 план (когда nubes даст JWKS endpoint):**
1. `GET {NUBES_JWKS_URL}/.well-known/jwks.json`
2. Найти ключ по `kid` из JWT header
3. Проверить подпись RS256/ES256 через `github.com/lestrrat-go/jwx/v2`
4. Добавить вызов `verifySignature(token)` в `validateJWT` после проверки структуры.
**Зачем stub:** любой агент или разработчик видит точную строку для вставки. Нет риска забыть.
---
## 2026-03-11 — CronJob перенесён в deployNS
**Проблема:** CronJob для HTTP-триггеров создавался в `tr.Namespace` (user namespace: `sless-{hex16}`).
При применении NetworkPolicy (каждый namespace изолирован) — CronJob не мог бы дотянуться до API.
**Решение:** CronJob создаётся в `deployNS` = `"sless-fn-" + tr.Namespace`,
где живут Deployment/Service — NetworkPolicy там уже правильная.
**Затронутые места в trigger_controller.go:**
- `buildCronJob` — namespace в ObjectMeta
- `r.Client.Create` — нет изменений (namespace из объекта)
- `r.Client.Get` в reconcile — `deployNS` вместо ns
- `handleTriggerDeletion` — удаление CronJob из `deployNS`
**Дополнительно:** `curlimages/curl:latest``curlimages/curl:8.5.0` (pin версии).
---
## 2026-03-11 — Sort env vars в buildDeployment
**Проблема:** `fn.Spec.Env` — это `map[string]string`. Итерация по map в Go недетерминирована.
Каждый reconcile мог генерировать Pod spec с другим порядком env vars → лишние rollout'ы.
**Решение:**
```go
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]}) }
```
**Тесты:** 2 теста в `controllers/function_controller_unit_test.go`
(4 env vars → алфавитный порядок после SLESS_ENTRYPOINT; пустой Env → только SLESS_ENTRYPOINT).
+204
View File
@@ -0,0 +1,204 @@
# Архитектура системы
Последнее обновление: 2026-03-18 (v0.1.33 + funcs-service v0.1.3)
## Общее описание
Managed Serverless Functions Service для облачного провайдера nubes.ru.
Пользователь загружает код через Terraform, сервис его собирает (kaniko) и запускает
по HTTP-триггеру, расписанию (cron) или вручную через one-shot Job.
## Стек
| Компонент | Технология | Где запущен |
|-----------|-----------|-------------|
| Operator (API + Controllers) | Go (controller-runtime) | Kubernetes, namespace `sless` |
| funcs-service (глобальная консоль) | Go (net/http) | Kubernetes, namespace `sless` |
| PostgreSQL | PostgreSQL 16 | Kubernetes, namespace `sless` |
| S3 | Ceph (облачный) | `s3.msk-1.ngcloud.ru` |
| Container Registry | DockerHub (`naeel/`) | внешний |
| Builder | kaniko (k8s Job) | namespace пользователя |
| Функции (HTTP) | k8s Deployment + Service | namespace пользователя |
| Функции (one-shot) | k8s Job | namespace пользователя |
| Функции (cron) | k8s CronJob | namespace пользователя |
| Terraform Provider | Go (plugin framework v6) | localhost/CI |
| nubes API | REST (облако) | `deck-api.ngcloud.ru` |
> Redis и RabbitMQ — отложены до v2.
## Компонент: funcs-service
Глобальный HTTP сервис — **одна копия** на весь кластер, для всех пользователей.
```
User Browser / curl
└─► https://sless.kube5s.ru/funcs/<namespace>
└─► nginx Ingress (sless-funcs-ingress)
└─► sless-funcs-service:8090 (namespace sless)
└─► http://sless-operator.sless.svc.cluster.local:9090/v1/...
```
**Файлы:**
- `services/funcs/main.go` — логика
- `services/funcs/Dockerfile` — multi-stage Go → alpine
- `deployments/k8s/funcs-service.yaml` — Deployment + Service + Ingress
**Env vars сервиса:**
| Переменная | Значение |
|-----------|---------|
| `SLESS_OPERATOR_URL` | `http://sless-operator.sless.svc.cluster.local:9090` |
| `SLESS_EXTERNAL_URL` | `https://sless.kube5s.ru` |
| `SLESS_EXCLUDE` | `event-writer,event-monitor,event-cleaner` |
| `SLESS_SERVICE_TOKEN` | JWT токен (задаётся через `kubectl set env`, не в git) |
## Хранение кода функций в S3
```
Terraform source_dir (локально)
└─► zip → POST /upload → builder.PrepareContext()
├─► functions/{ns}/{name}/{ts}.zip ← ИСХОДНЫЙ КОД пользователя
└─► contexts/{ns}/{name}/{ts}.tar.gz ← BUILD CONTEXT для kaniko
└─► Function CRD: spec.s3Key = "contexts/..."
└─► контроллер → kaniko Job → Docker image
```
Для web-консоли: `GET /source` читает `functions/{ns}/{name}/{ts}.zip` напрямую.
## Изоляция пользователей — Namespace per user
Каждый пользователь облака получает **отдельный k8s namespace**.
```
JWT токен (Bearer)
└─► JWT.sub (строка "0199e325-1cdf-7cda-9319-e5302a85e291")
└─► SHA256(sub) → первые 8 байт → hex → "sless-{16 hex символов}"
└─► namespace = "sless-cdd874dfa31ba6ca"
```
- Namespace детерминирован: один sub → всегда один namespace.
- sub не раскрывается в имени namespace (SHA256 необратим).
- Длина 22 символа — укладывается в лимит k8s (63).
**Кто создаёт namespace:**
Terraform провайдер при Configure() вызывает POST /v1/namespaces/{ns}/ensure
**один раз**, до любых ресурсных операций.
Resource-хендлеры (Function, Trigger, Job) namespace **не создают** — это не их ответственность.
## Аутентификация
### Оператор (REST API)
- Bearer JWT в заголовке Authorization
- Проверяется структура JWT (3 части), наличие sub claim, срок действия exp
- Подпись **не проверяется** — trusted perimeter (оператор за Ingress)
### Terraform Provider (при Configure)
1. Декодирует JWT → sub
2. Вычисляет namespace через SHA256
3. Если задан nubes_endpoint — пингует nubes API (GET <nubes_endpoint>) с тем же токеном
- HTTP 401/403 → ошибка инициализации провайдера
- Недоступен → ошибка инициализации
4. Вызывает POST /v1/namespaces/{ns}/ensure (создаёт namespace если нет)
## Схема вызова
```
Пользователь (curl / браузер)
|
v GET|POST|... /fn/{namespace}/{name}/*
sless-api Ingress -> Operator /fn/ прокси
|
v HTTP forward -> http://{name}.{namespace}.svc.cluster.local:8080
k8s Service -> Deployment/Pod функции
```
```
terraform apply
|
v provider Configure()
1. JWT -> sub -> namespace
2. PingNubesAPI (валидация токена)
3. POST /v1/namespaces/{ns}/ensure <- создаёт k8s namespace
|
+-> POST /v1/namespaces/{ns}/functions <- создаёт Function CRD
| +-> POST /upload (zip) <- загружает код -> S3 -> kaniko Job
| +-> polling phase=Ready
|
+-> POST /v1/namespaces/{ns}/triggers <- создаёт Trigger CRD
| +-> controller: Deployment + Service + (CronJob для cron)
|
+-> POST /v1/namespaces/{ns}/jobs <- создаёт FunctionJob CRD
+-> controller: k8s Job -> result в status
```
## Структура кода
```
sless/
|-- main.go точка входа: k8s manager + REST API сервер (goroutine)
|-- internal/
| |-- api/
| | |-- router.go gorilla/mux: /fn/ (публичный), /v1/ (auth + middleware)
| | |-- handler/
| | | |-- handler.go Handler struct + helpers (writeJSON, namespace(), pathVar())
| | | |-- namespace.go EnsureNamespace (POST /v1/namespaces/{ns}/ensure)
| | | |-- functions.go CRUD Function
| | | |-- triggers.go CRUD Trigger
| | | |-- jobs.go CRUD FunctionJob
| | | |-- upload.go zip -> Dockerfile -> tar.gz -> S3 -> CRD patch
| | | |-- invoke.go прокси /fn/{ns}/{name} -> in-cluster DNS
| | | +-- invocations.go 501 stub (реализация отложена)
| | +-- middleware/
| | |-- auth.go JWT validation (struct + sub + exp, подпись не проверяется)
| | +-- logging.go slog request logger
| |-- builder/
| | |-- builder.go kaniko Job lifecycle (Build, JobStatus, Cleanup)
| | +-- context.go PrepareContext: zip+runtime → tar.gz+Dockerfile для kaniko
| |-- config/config.go Load() из env vars
| +-- storage/
| |-- postgres/store.go SaveInvocation, ListInvocations, RunMigrations
| +-- s3/client.go Upload, Download, UploadContext (tar.gz для kaniko)
|-- controllers/
| |-- function_controller.go Reconcile: Pending->Building->Ready/Failed + Deployment
| |-- trigger_controller.go Reconcile: Service+Ingress (http) / CronJob (cron)
| +-- functionjob_controller.go Reconcile: k8s Job -> Succeeded/Failed + output capture
|-- api/v1alpha1/
| |-- function_types.go Function CRD
| |-- trigger_types.go Trigger CRD
| +-- job_types.go FunctionJob CRD
|-- deployments/k8s/
| |-- operator.yaml Deployment + Service + Ingress
| +-- rbac.yaml ClusterRole + ClusterRoleBinding + ServiceAccount
|-- terraform/provider/ независимый Go-модуль
| +-- internal/
| |-- client/client.go SubFromJWT, NamespaceFromSub, PingNubesAPI + CRUD
| |-- provider/provider.go Configure(): JWT->NS->ping->EnsureNamespace
| +-- resources/
| |-- function_resource.go sless_function
| |-- trigger_resource.go sless_trigger
| +-- job_resource.go sless_job
+-- runtimes/
|-- python3.11/ server.py + Dockerfile -> naeel/sless-runtime-python3.11:v0.1.1
+-- nodejs20/ server.js + Dockerfile -> naeel/sless-runtime-nodejs20:v0.1.2
```
## Kubernetes кластер
Сейчас используется существующий кластер (временный).
Планируется переезд на новый кластер — манифесты переносятся без изменений.
Ноды:
- wheel-control-plane-fm9sr — control-plane
- wheel-workers-tv4qr-r45xs — worker
- wheel-workers-tv4qr-x8xw7 — worker
Ingress: nginx, external IP 5.172.178.182
API endpoint: https://sless-api.kube5s.ru
## Версии в production
| Артефакт | Тег/Версия |
|---------|-----------|
| naeel/sless-operator | v0.1.22 |
| terra.k8c.ru/naeel/sless провайдер | v0.1.13 |
| naeel/sless-runtime-python3.11 | v0.1.1 |
| naeel/sless-runtime-nodejs20 | v0.1.2 |
+325 -1
View File
@@ -1,6 +1,330 @@
# Прогресс разработки
Последнее обновление: 2026-03-10 (добавлен прагматичный обзор Claude Opus 4.6)
Последнее обновление: 2026-03-19 22:30
---
## 2026-03-19 — Go runtime v0.1.1: pgx/v5 + stress-go-pgstorm ✅ ЗАВЕРШЕНО
### Цель
Новая функция `stress-go-pgstorm` — Go код со 100 горутинами, которые 10 минут
долбят PostgreSQL напрямую через `pgxpool`. Проверяем: Go runtime под нагрузкой,
connection pool под конкурентными запросами, устойчивость кластера.
**Попутно выявлены и исправлены два системных бага:**
- Хардкодный таймаут 30s в invoke.go (прокси оператора)
- Отсутствие proxy-read-timeout на nginx ingress sless-operator
### Задачи
| # | Задача | Статус | Заметки |
|---|--------|--------|---------|
| 1 | `runtimes/go1.23/go.mod` — добавить `pgx/v5 v5.7.2` | ✅ | go mod tidy на remote, go.sum сгенерирован (28 строк) |
| 2 | `runtimes/go1.23/Dockerfile``go mod download` кешируем deps | ✅ | Один stage `golang:1.23-alpine`, COPY go.mod+go.sum перед server.go |
| 3 | Собрать `naeel/sless-runtime-go1.23:v0.1.1`, запушить | ✅ | docker build + push, sha256 verified |
| 4 | `internal/builder/context.go` — тег `go1.23` v0.1.0 → v0.1.1 | ✅ | строка `return "naeel/sless-runtime-go1.23:v0.1.1", nil` |
| 5 | `internal/api/handler/invoke.go` — динамический таймаут | ✅ | Таймаут = Function.Spec.TimeoutSec + 5s (был хардкод 30s) |
| 6 | Пересобрать и задеплоить оператор `v0.1.40` | ✅ | rodlout complete |
| 7 | `deployments/k8s/operator.yaml` — nginx proxy-read-timeout=900s | ✅ | kubectl annotate + манифест обновлён |
| 8 | `code/stress-go-pgstorm/handler.go` — горутины + pgxpool | ✅ | 100 горутин, INSERT/COUNT/MAX, параметры workers/duration_sec/max_delay_ms |
| 9 | `resources.tf` — новая функция + trigger | ✅ | timeout_sec=700, memory_mb=256, все PG env vars |
| 10 | terraform apply — 2 ресурса добавлено | ✅ | apply complete: 2 added |
| 11 | Запуск 10-минутного стресс-теста | ✅ | workers=100, duration_sec=600, err_ops=0, ok_ops=45918, ops_per_sec=76.5 — PostgreSQL выдержал |
| 12 | Коммит d7fda15 | ✅ | feat: Go runtime v0.1.1 (pgx/v5)... |
### Архитектура функции stress-go-pgstorm
```
Handle(event) → запускает N горутин (default 100)
каждая горутина в цикле duration_sec (default 600):
- случайная задержка 0-300ms (max_delay_ms)
- чередующиеся операции: INSERT / SELECT COUNT / SELECT MAX
- считает ok/err атомарно через sync/atomic
WaitGroup.Wait() → возвращает итог
возвращает: {runtime, version, workers, duration_sec, elapsed_sec,
total_ops, ok_ops, err_ops, ops_per_sec}
pgxpool.New() — connection pool, MaxConns=20
env: PGHOST, PGPORT, PGDATABASE, PGUSER, PGPASSWORD, PGSSLMODE
```
### Цепочка таймаутов (после фиксов)
| Слой | Таймаут | Где задаётся |
|------|---------|--------------|
| curl --max-time | 720s | клиент |
| nginx ingress proxy-read-timeout | 900s | аннотация ingress sless-operator |
| operator http.Client (invoke.go) | TimeoutSec+5s = 705s | Function.Spec.TimeoutSec=700 |
| function runtime (Go) | duration_sec = 600s | параметр в JSON body |
### Что было сломано до этой сессии
1. invoke.go: `var httpClient = &http.Client{Timeout: 30 * time.Second}` — хардкод в строке 24
2. nginx ingress sless-operator: аннотации proxy-read-timeout не было → nginx дефолт 60s → 504
## 2026-03-19 — Tests 3-7: E2E прогон POSTGRES + 8 стресс-функций
### Tests 3-7
| Тест | Действие | Результат |
|------|----------|-----------|
| Test 3 | `table_rw.py`: добавлен `version: v2-with-hostname`, `host: socket.gethostname()` | ✅ Terraform apply, функция пересобрана |
| Test 4 | `pg_info.js`: добавлен `code_version: v2-agent-test` | ✅ code_hash изменился, вернул `{"code_version":"v2-agent-test"}` |
| Test 5 | Удалить trigger → 404 → пересоздать → работает | ✅ |
| Test 6 | Удалить function + trigger → 404 → пересоздать (rebuild 46s) → работает | ✅ |
| Test 7 | Новая функция `pg-stats` (Python): version, total_rows → протестирована → удалена | ✅ `{"version":"v1-test7","total_rows":3}` |
### 8 стресс-функций (коммит `014b99e`)
Задеплоены и прогнаны дважды через `stress_test.sh` (3 раунда).
После crash-тестов все 8 подов: `Running`, 0 restarts.
| # | Функция | Runtime | Что проверяет | Результат |
|---|---------|---------|---------------|-----------|
| 1 | `stress-slow` | Python | sleep 3-N сек | ✅ `{"slept_sec":3}` |
| 2 | `stress-bigloop` | Python | CPU n=2M | ✅ 0.31s |
| 3 | `stress-divzero` | Python | ZeroDivisionError crash | ✅ pod restart → при d=7: `{"result":6.0}` |
| 4 | `stress-writer` | Python | batch INSERT в PG | ✅ +3/+10 строк |
| 5 | `stress-go-fast` | Go | factorial+fib без deps | ✅ factorial(20), fib(20) |
| 6 | `stress-go-nil` | Go | nil pointer panic | ✅ pod restart → при crash=false: ok |
| 7 | `stress-js-async` | NodeJS | 3 PG запроса Promise.all | ✅ version/count/max_id |
| 8 | `stress-js-badenv` | NodeJS | TypeError (pod жив, HTTP 500) | ✅ нет restart |
**Итого в таблице после двух прогонов: 32 строки.**
### Поведение crash-функций
- `stress-divzero` / `stress-go-nil`: **pod restart** при краше (EOF на первый запрос после падения) — нормальное k8s поведение, runtime процесс умирает целиком
- `stress-js-badenv`: **HTTP 500 без restart** — NodeJS поймал TypeError внутри async handler, pod остался жив
- Это различие задокументировано: Python/Go crash = process exit, NodeJS crash = unhandled rejection в async = 500
### git
- Бинарник `event-dispatcher` (50MB) удалён из истории через `git reset --soft HEAD~2`
- Добавлен в `.gitignore`
- Force push: `d879817``014b99e`
---
## 2026-03-19 — event-trigger refactor (ветка feat/event-trigger-refactor)
| # | Задача | Статус | Заметки |
|---|--------|--------|---------|
| 1 | Аудит кластера — удаление мусора | ✅ | Удалены: event-monitor/writer/cleaner, pg-* тестовые функции, failed build jobs, orphan namespaces b1e4df2d/b794a3c4 |
| 2 | Managed RabbitMQ через Terraform | ✅ | `terraform/RABBIT/`, ns `1dbfe9da-...`, host: `rabbitmqk8s.1dbfe9da-...svc.cluster.local` |
| 3 | Архитектура event-trigger задокументирована | ✅ | `doc/decisions/log.md` — выбран Вариант A (отдельный event-dispatcher) |
| 4 | trigger_types.go: +TriggerTypeEvent, +Queue | ✅ | |
| 5 | internal/config: +RabbitMQURL | ✅ | |
| 6 | services/event-dispatcher/ | ✅ | Go-сервис: dispatcher.go, watcher.go, main.go |
| 7 | controllers/trigger_controller.go: +reconcileEvent | ✅ | Создаёт Service, обновляет status |
| 8 | deployments/k8s/event-dispatcher.yaml | ✅ | ServiceAccount + ClusterRole + Deployment |
| 9 | Образ naeel/sless-event-dispatcher:v0.1.0 | ✅ | Собран, запушен в DockerHub |
| 10 | RABBITMQ_URL в sless-operator-secret | ✅ | managed rabbit (1dbfe9da namespace) |
| 11 | event-dispatcher задеплоен в кластер | ✅ | Running в sless namespace |
## 2026-03-19 — pg-table-writer HTML + bugfix invoke.go Content-Length (оператор v0.1.37)
| # | Задача | Статус | Заметки |
|---|--------|--------|---------|
| 1 | Python runtime v0.1.4: `text/html` поддержка | ✅ | `server.py`: str начинается с `<``text/html; charset=utf-8`; добавлен `_accept` в event |
| 2 | `internal/builder/context.go`: python runtime → v0.1.4 | ✅ | |
| 3 | Образ `naeel/sless-runtime-python3.11:v0.1.4` | ✅ | Собран и запушен |
| 4 | `code/table-rw/table_rw.py` | ✅ | Комбинированный reader+writer. `list_rows` — JSON. `add_row`: GET → HTML форма, POST form → INSERT + HTML ответ. `_render_page` шаблон с тёмной темой |
| 5 | `examples/POSTGRES/resources.tf` | ✅ | reader: `source_dir=code/table-rw`, `entrypoint=table_rw.list_rows`. Новые: `sless_function.postgres_table_writer`, `sless_trigger.postgres_table_writer_http`, `output.table_writer_url` |
| 6 | Удалена папка `code/table-reader/` | ✅ | |
| 7 | `internal/builder/builder.go`: убран `--cache=true` | ✅ | kaniko по умолчанию не кэширует слои |
| 8 | Оператор v0.1.35 | ✅ (промежуточный) | |
| 9 | Оператор v0.1.36 | ✅ | Убран ошибочный `--no-cache` (kaniko v1.24.0 не поддерживает этот флаг) |
| 10 | Оператор v0.1.37 | ✅ | **Bugfix `invoke.go`**: добавлен `proxyReq.ContentLength = r.ContentLength`. Без этого Python-сервер читал тело как 0 байт (бесконечно chunked) — форма не парсилась |
| 11 | POST через прокси | ✅ | `curl POST title=test-proxy-fix` → HTML с «Добавлено: «test-proxy-fix»», строка в таблице |
### Root cause бага формы
`internal/api/handler/invoke.go`: при проксировании POST-запроса не устанавливался `Content-Length`.
Go `http.Client` отправлял запрос без `Content-Length` → Python `BaseHTTPRequestHandler.headers.get("Content-Length", 0)` = 0 → тело не читалось → `title` пустой → JSON ошибка.
Фикс: одна строка `proxyReq.ContentLength = r.ContentLength`.
### Образы
| Образ | Версия | Что изменилось |
|-------|--------|----------------|
| `naeel/sless-operator` | v0.1.37 | `invoke.go`: `proxyReq.ContentLength = r.ContentLength` |
| `naeel/sless-runtime-python3.11` | v0.1.4 | `text/html` support, `_accept` in event |
---
## 2026-03-18 — web-консоль (оператор v0.1.34 + funcs-service v0.2.0)
## 2026-03-18 — web-консоль (оператор v0.1.34 + funcs-service v0.2.0)
| # | Задача | Статус | Заметки |
|---|--------|--------|---------|
| 1 | Оператор: `GET /v1/.../functions/{name}/source` | ✅ | `internal/api/handler/source.go` — скачивает tar.gz контекст из S3; извлекает пользовательские файлы (без Dockerfile); возвращает `[{name, content, binary}]` |
| 2 | Оператор: `PATCH /v1/.../triggers/{name}` | ✅ | Уже существовал в v0.1.33 — Handler обрабатывает поле `enabled` |
| 3 | `internal/api/router.go` — маршрут /source | ✅ | `GET /namespaces/{ns}/functions/{name}/source → h.GetSource` |
| 4 | `services/funcs/index.html` — HTML web-консоль | ✅ | Тёмная тема, аккордеоны, highlight.js, кнопки ▶/■ для enable/disable триггеров |
| 5 | `services/funcs/main.go` — HTML режим + proxy endpoints | ✅ | `Accept: text/html` → HTML; `GET /funcs/{ns}/source/{fn}` и `PATCH /funcs/{ns}/triggers/{name}` — прокси к оператору через SLESS_SERVICE_TOKEN |
| 6 | Backward compat: plain text | ✅ | curl без `Accept: text/html` — старое поведение v0.1.x |
| 7 | Оператор v0.1.34 | ✅ | Build+push+deploy в кластер |
| 8 | funcs-service v0.2.0 | ✅ | Build+push+deploy в кластер |
| 9 | Коммит | ✅ | `bf9f073``feat: web-console — HTML UI + source viewer + trigger toggle` |
### Текущие URL
```
https://sless.kube5s.ru/funcs/sless-ffd1f598c169b0ae # браузер → HTML консоль
# curl → plain text (backward compat)
https://sless.kube5s.ru/funcs/sless-ffd1f598c169b0ae/source/pg-info # JSON файлы функции
https://sless.kube5s.ru/fn/sless-ffd1f598c169b0ae/pg-info # вызов функции
```
### Образы
| Образ | Версия | Что изменилось |
|-------|--------|----------------|
| `naeel/sless-operator` | v0.1.34 | GET /source endpoint, HTML web-консоль proxy routes |
| `naeel/sless-funcs-service` | v0.2.0 | HTML консоль + source/trigger proxy endpoints |
---
## 2026-03-18 — FunctionJob bugfix (functionjob label + stderr capture)
| # | Задача | Статус | Заметки |
|---|--------|--------|---------|
| 1 | `functionjob_controller.go`: label на PodTemplate | ✅ | Добавлен `PodTemplate.ObjectMeta.Labels: {managed-by, functionjob, function}` — k8s 1.27+ удалил встроенный `job-name=` лейбл |
| 2 | `getJobPodOutput` по `labelSelector` | ✅ | Принимает `"functionjob=<name>"` вместо `jobName`; используется для успешного и упавшего job |
| 3 | Захват stderr при ошибке Job | ✅ | При `job.Status.Failed > 0` — берём `podOutput`; если непустой — пишем в `status.Message` (truncate до 2000 символов) |
| 4 | `truncateForStatus()` helper | ✅ | Ограничивает длину `status.Message` для безопасной записи в k8s |
| 5 | terraform client: `ErrJobAlreadyExists` | ✅ | `client.go`: 409 Conflict → `ErrJobAlreadyExists`; `job_resource.go`: при конфликте создания — читаем существующий job вместо ошибки |
---
## 2026-03-18 — Python runtime str→text/plain
| # | Задача | Статус | Заметки |
|---|--------|--------|---------|
| 1 | `runtimes/python3.11/server.py` | ✅ | Если функция вернула `str``Content-Type: text/plain`, тело без `json.dumps`. Dict/list остаются `application/json` |
| 2 | `internal/builder/context.go` | ✅ | Python runtime базовый образ → `v0.1.3` |
| 3 | Образ `naeel/sless-runtime-python3.11:v0.1.3` | ✅ | Собран и задеплоен |
---
## 2026-03-18 — funcs: глобальный Go сервис + ветка web-console
| # | Задача | Статус | Заметки |
|---|--------|--------|---------|
| 1 | Python runtime str→text/plain | ✅ | `runtimes/python3.11/server.py`: если функция возвращает `str``Content-Type: text/plain`. Образ `naeel/sless-runtime-python3.11:v0.1.3` |
| 2 | funcs_list.py: plain text вывод | ✅ | Переписан с JSON на plain text с `─` разделителями, фильтр `SLESS_EXCLUDE` |
| 3 | Оператор v0.1.33 | ✅ | context.go: python runtime → v0.1.3; `internal/builder/context.go` обновлён |
| 4 | `funcs` как глобальный Go сервис | ✅ | `services/funcs/main.go` — standalone Go HTTP сервер. Деплоится ОДИН РАЗ в namespace `sless`, работает для ВСЕХ пользователей. Образ `naeel/sless-funcs-service:v0.1.3` |
| 5 | URL без токена | ✅ | `https://sless.kube5s.ru/funcs/<namespace>` — открывается в браузере без токена. `SLESS_SERVICE_TOKEN` задан через `kubectl set env` |
| 6 | Удалить funcs из terraform | ✅ | `examples/POSTGRES/resources.tf`: удалены `sless_function.funcs_list`, `sless_trigger.funcs_list_http`, `output "funcs_url"`, `local.user_namespace`. `terraform apply` — 2 destroyed |
| 7 | Ветка feat/web-console | ✅ | Создана от `feat/harbor-integration` на remote, запушена |
| 8 | Документация архитектуры хранения кода | ✅ | См. `doc/decisions/log.md` раздел "Хранение кода функций и web-консоль" |
| 9 | Коммиты | ✅ | `e8cd62e` (funcs Go service), `38bb494` (v0.1.3 — /funcs/namespace без токена) |
### Текущие URL функций
```
https://sless.kube5s.ru/funcs/sless-ffd1f598c169b0ae # список функций (без токена)
https://sless.kube5s.ru/funcs?token=<jwt> # то же, через токен
https://sless.kube5s.ru/fn/sless-ffd1f598c169b0ae/pg-info
https://sless.kube5s.ru/fn/sless-ffd1f598c169b0ae/pg-table-reader
```
### Образы
| Образ | Версия | Что изменилось |
|-------|--------|----------------|
| `naeel/sless-operator` | v0.1.33 | created_at, last_built_at в API; python runtime v0.1.3 |
| `naeel/sless-runtime-python3.11` | v0.1.3 | str → text/plain в server.py |
| `naeel/sless-funcs-service` | v0.1.3 | /funcs/<namespace> без токена, /health probe |
---
## 2026-03-18 — Следующие шаги: web-консоль (ветка feat/web-console)
| # | Задача | Статус | Заметки |
|---|--------|--------|---------|
| 1 | Оператор: `GET /v1/namespaces/{ns}/functions/{name}/source` | ✅ | Реализовано — `bf9f073` |
| 2 | Оператор: `PATCH /v1/namespaces/{ns}/triggers/{name}` | ✅ | Существовал — проверено, поддерживает `enabled` |
| 3 | `sless-funcs-service`: HTML страница | ✅ | `Accept: text/html` → HTML консоль. Аккордеоны + highlight.js + кнопки ▶/■ |
| 4 | Bump оператора до v0.1.34 | ✅ | Задеплоен |
| 5 | Bump funcs-service до v0.2.0 | ✅ | Задеплоен |
---
## 2026-03-18 12:00 — DNS инцидент устранён, POSTGRES E2E PASS
## 2026-03-18 — DNS инцидент: sless-api.kube5s.ru → мёртвый кластер
| # | Задача | Статус | Заметки |
|---|--------|--------|----------|
| 1 | Диагностика TLS handshake timeout при `terraform apply` POSTGRES | ✅ | `sless-api.kube5s.ru``5.172.178.182` (старый кластер). Порт 443 принимал TCP, но TLS не завершался — Go HTTP клиент висел бесконечно |
| 2 | Подтверждение нового кластера | ✅ | `kubectl get nodes` в контексте `tazetdinovn@gmail.com@naeel-test-3` → 3 ноды Ready. Ingress IP `185.247.187.147`. DNS `sless.kube5s.ru` уже указывал на него |
| 3 | Обновить Ingress в кластере | ✅ | `kubectl patch ingress sless-operator -n sless`: host + TLS → `sless.kube5s.ru`, secretName → `sless-operator-tls-sless` |
| 4 | Обновить ConfigMap оператора | ✅ | `EXTERNAL_URL: https://sless.kube5s.ru` (было `https://sless-api.kube5s.ru`) |
| 5 | Let's Encrypt сертификат | ✅ | `certificate/sless-operator-tls-sless` READY=True, order valid — выдан сразу после смены ingress host (ACME HTTP-01 challenge на новом IP прошёл) |
| 6 | Заменить `sless-api.kube5s.ru``sless.kube5s.ru` во всех examples + deployments | ✅ | `POSTGRES/`, `hello-go/`, `hello-node/`, `simple-node/`, `simple-python/`, `notes-python/`, `pg-list-python/`, `demo-managed-functions/`, `README.md`, `deployments/k8s/operator.yaml` |
| 7 | POSTGRES `terraform apply` с функцией и job | ✅ PASS | `sless_function` `pg-create-table-runner` — создан за 55s. `sless_job` `pg-create-table-job-main-v13` — создан за 20s. `Apply complete! Resources: 2 added` |
| 8 | Коммит и push | ✅ | `cca3a8c``fix: migrate sless endpoint from sless-api.kube5s.ru to sless.kube5s.ru` |
---
## 2026-03-17 — E2E тесты всех примеров на тест-стенде
| # | Задача | Статус | Заметки |
|---|--------|--------|---------|
| 1 | Исправить `nubes_endpoint` во всех examples (prod→test) | ✅ | `deck-api.ngcloud.ru``deck-api-test.ngcloud.ru/api/v1` в hello-node, hello-go, simple-node, notes-python, pg-list-python, demo-managed-functions |
| 2 | Исправить `sless_endpoint` в demo-managed-functions | ✅ | `185.247.187.147.nip.io``https://sless-api.kube5s.ru` |
| 3 | Postgres для тестов | ✅ | `postgres.sless.svc.cluster.local:5432`, user=`sless`, pass=`sless-pg-password`, db=`sless`. Уже запущен в кластере |
| 4 | `hello-node` E2E | ✅ PASS | init → apply (4 ресурса) → modify (memory_mb 128→256, env_var) → apply → destroy |
| 5 | `simple-python` E2E | ✅ PASS | init → apply → modify (memory_mb 64→96) → apply → destroy. `job_result = {"time": "..."}` |
| 6 | `simple-node` E2E | ✅ PASS | Аналогично simple-python но nodejs20 |
| 7 | `notes-python` E2E | ✅ PASS | 7 ресурсов, PostgreSQL init job: `{"ok": true, "executed": 1}`, destroy complete |
| 8 | Коммит на remote | ✅ | `a768454` в `/home/naeel/terra/sless/examples` |
| 9 | POSTGRES example | 🔄 | Следующий шаг — вернуться к POSTGRES после прохождения всех тестов |
---
## 2026-03-17 — v0.1.31: compile fix + POSTGRES example end-to-end
| # | Задача | Статус | Заметки |
|---|--------|--------|---------|
| 1 | Баг 3: `PodLogOptions{Stdout/Stderr}` compile error | ✅ | Убраны несуществующие поля. `getJobPodOutput` в `functionjob_controller.go` |
| 2 | Сборка `naeel/sless-operator:v0.1.31` | ✅ | Собрано на `naeel@5.172.178.213`, запушено в Docker Hub |
| 3 | Деплой v0.1.31 в кластер | ✅ | `kubectl rollout status` → success. Pod `sless-operator-545556c59d-n8qv9` |
| 4 | `simple-python` — сквозной тест на тест-стенде | ✅ | `terraform apply` на remote: `Apply complete! Resources: 3 added`. `job_result = {"time": "..."}` |
| 5 | Найдена главная причина падения POSTGRES | ✅ | `nubes_endpoint` в `provider "sless"` указывал на **прод** `deck-api.ngcloud.ru` |
| 6 | Исправлен `nubes_endpoint` в POSTGRES/main.tf | ✅ | Теперь `deck-api-test.ngcloud.ru/api/v1` |
| 7 | Исправлен `nubes_endpoint` в simple-python/main.tf | ✅ | Аналогично |
| 8 | End-to-end `terraform apply` для POSTGRES | 🔄 | Следующий шаг: синхронизировать на remote и запустить `terraform apply` |
---
## 2026-03-17 — POSTGRES example: внешний managed PG, FunctionJob и реальные блокеры
| # | Задача | Статус | Заметки |
|---|--------|--------|---------|
| 1 | Перевод `examples/POSTGRES` на `PGSSLMODE=require` | ✅ | Для managed PostgreSQL `allow_no_s_s_l = false`; `disable` был некорректен |
| 2 | Подтверждение root-cause `password authentication failed` | ✅ | Из runtime namespace поднят диагностический pod того же image: `psycopg2.OperationalError: FATAL: password authentication failed for user "u-user0"` |
| 3 | Проверка источника пароля | ✅ | `nubes_postgres.npg.vault_secrets["users"]` не совпадает с фактическим паролем пользователя в кластере managed PostgreSQL; authoritative source сейчас — k8s secret `u-user0.postgresqlk8s.credentials.postgresql.acid.zalan.do` |
| 4 | Попытка читать пароль через `hashicorp/external` | ❌ | В текущем окружении доступен только кастомный registry; `registry.terraform.io/hashicorp/external` не устанавливается |
| 5 | One-command `terraform apply` для `examples/POSTGRES` | 🔄 | Остаточный баг локализован в связке credential-source + FunctionJob observability. После переключения на актуальный пароль `sless_job` всё ещё падает, но актуальные pod logs реального job теряются из-за TTL/слабой наблюдаемости |
| 6 | Необходимый следующий технический шаг | 🔄 | Либо добавить в проект доступный data-source/провайдер для чтения k8s secret внутри apply, либо починить источник пароля в nubes-потоке, чтобы `vault_secrets` и реальный Postgres user password совпадали |
## 2026-03-17 — Fix: `examples/POSTGRES` / `sless_job` 409 + диагностика FunctionJob
| # | Задача | Статус | Заметки |
|---|--------|--------|---------|
| 1 | Идемпотентность `sless_job` при `409 job already exists` | ✅ | `terraform/provider/internal/client/client.go`, `terraform/provider/internal/resources/job_resource.go`: добавлен typed-error `ErrJobAlreadyExists`, в Create при 409 читается существующий Job и продолжается ожидание/синхронизация вместо немедленного падения |
| 2 | Диагностика падений FunctionJob | ✅ | `controllers/functionjob_controller.go`: labels добавлены в `PodTemplate`, fail-message теперь включает output pod-а (обрезка до 2000), fallback-команда логов по `job-name` |
| 3 | Root-cause для `examples/POSTGRES` | ✅ | Рабочие `examples/notes-python` используют внутренний DSN (`postgres.sless.svc`), а `examples/POSTGRES` работает через managed Nubes Postgres + динамические креды; критичный UX-дефект был в повторном Create после failed apply |
## Статусы: ✅ готово | 🔄 в процессе | ⏳ не начато
+1 -1
View File
@@ -16,7 +16,7 @@ crash.log
# Provider plugins / caches
.terraform.d/
#*.tfvars
*.tfvars
# Archives and build artifacts
*.zip
@@ -0,0 +1,94 @@
# 2026-03-18 (обновлено: plain text вывод; фильтрация SLESS_EXCLUDE)
# funcs_list.py — HTTP-функция: список пользовательских функций, человекочитаемый plain text.
# Вызывает внутренний REST API оператора (ClusterIP, без TLS).
# Возвращает str → python runtime отдаёт text/plain напрямую без json.dumps.
#
# Env vars:
# SLESS_API_URL — URL оператора (http://sless-operator.sless.svc.cluster.local:9090)
# SLESS_NAMESPACE — namespace пользователя (sless-{hex16})
# SLESS_TOKEN — JWT токен для /v1/ API
# SLESS_EXTERNAL_URL — публичный базовый URL (https://sless.kube5s.ru)
# SLESS_EXCLUDE — comma-separated имена функций, которые не показывать
import os
import requests
SEP = "" * 52
def _comment(fn, http_trigs, cron_trigs):
phase = fn.get("phase", "?")
runtime = fn.get("runtime", "?")
if http_trigs:
active = "активна" if http_trigs[0].get("active") else "неактивна"
return f"HTTP endpoint ({runtime}) — {phase}, {active}"
elif cron_trigs:
schedule = cron_trigs[0].get("schedule", "?")
active = "активна" if cron_trigs[0].get("active") else "неактивна"
return f"Cron '{schedule}' ({runtime}) — {phase}, {active}"
else:
return f"Job/runner без триггера ({runtime}) — {phase}"
def list_all(event):
api_url = os.environ["SLESS_API_URL"].rstrip("/")
namespace = os.environ["SLESS_NAMESPACE"]
token = os.environ["SLESS_TOKEN"]
ext_url = os.environ.get("SLESS_EXTERNAL_URL", "").rstrip("/")
exclude = {n.strip() for n in os.environ.get("SLESS_EXCLUDE", "").split(",") if n.strip()}
headers = {"Authorization": f"Bearer {token}"}
fns = requests.get(f"{api_url}/v1/namespaces/{namespace}/functions", headers=headers, timeout=10)
trs = requests.get(f"{api_url}/v1/namespaces/{namespace}/triggers", headers=headers, timeout=10)
fns.raise_for_status()
trs.raise_for_status()
trig_idx = {}
for tr in trs.json():
fn_name = tr.get("function") or tr.get("functionRef")
if fn_name:
trig_idx.setdefault(fn_name, []).append(tr)
items = []
for fn in fns.json():
name = fn["name"]
if name in exclude:
continue
http_t = [t for t in trig_idx.get(name, []) if t.get("type") == "http"]
cron_t = [t for t in trig_idx.get(name, []) if t.get("type") == "cron"]
is_active = any(t.get("enabled", True) and t.get("active", False) for t in trig_idx.get(name, []))
items.append((fn, http_t, cron_t, is_active))
# Сортировка: активные вверх, затем по имени
items.sort(key=lambda x: (not x[3], x[0]["name"]))
lines = []
for fn, http_t, cron_t, is_active in items:
name = fn["name"]
lines.append(SEP)
lines.append(f" {_comment(fn, http_t, cron_t)}")
lines.append(f" name: {name}")
lines.append(f" runtime: {fn.get('runtime', '?')}")
lines.append(f" phase: {fn.get('phase', '?')}")
lines.append(f" active: {'да' if is_active else 'нет'}")
if http_t:
url = f"{ext_url}/fn/{namespace}/{name}" if ext_url else http_t[0].get("url", "")
lines.append(f" url: {url}")
if cron_t:
lines.append(f" cron: {cron_t[0].get('schedule', '?')}")
if fn.get("created_at"):
lines.append(f" created: {fn['created_at']}")
if fn.get("last_built_at"):
lines.append(f" built: {fn['last_built_at']}")
if fn.get("message"):
lines.append(f" message: {fn['message']}")
lines.append(SEP)
lines.append(f" namespace: {namespace} | total: {len(items)}")
lines.append(SEP)
# Возвращаем str — python runtime отдаст text/plain напрямую
return "\n".join(lines) + "\n"
@@ -0,0 +1 @@
requests==2.31.0
@@ -0,0 +1,8 @@
{
"name": "pg-info",
"version": "1.0.0",
"description": "sless nodejs20 function: pg version + table info",
"dependencies": {
"pg": "8.11.0"
}
}
+44
View File
@@ -0,0 +1,44 @@
// 2026-03-18
// pg_info.js — NodeJS-функция: проверка работы JS runtime + чтение мета-данных БД.
// Подключается к PostgreSQL через пакет pg, возвращает версию сервера и счётчик строк.
// Демонстрирует: nodejs20 runtime, npm-зависимость (package.json), PG из JS.
//
// ENV (те же что у python-функций):
// PGHOST, PGPORT, PGDATABASE, PGUSER, PGPASSWORD, PGSSLMODE
//
// Entrypoint: pg_info.info
'use strict';
const { Client } = require('pg');
exports.info = async (event) => {
const client = new Client({
host: process.env.PGHOST,
port: parseInt(process.env.PGPORT || '5432'),
database: process.env.PGDATABASE,
user: process.env.PGUSER,
password: process.env.PGPASSWORD,
// pg-пакет требует явного ssl-объекта; rejectUnauthorized: false — т.к.
// self-signed cert на nubes managed PG, но канал всё равно шифруется.
ssl: process.env.PGSSLMODE === 'require' ? { rejectUnauthorized: false } : false,
});
await client.connect();
try {
const [versionRes, countRes] = await Promise.all([
client.query('SELECT version() AS v'),
client.query('SELECT COUNT(*) AS cnt FROM terraform_demo_table'),
]);
return {
runtime: 'nodejs20',
node_version: process.version,
pg_version: versionRes.rows[0].v,
table_rows: parseInt(countRes.rows[0].cnt, 10),
code_version: 'v2-agent-test',
};
} finally {
await client.end();
}
};
@@ -0,0 +1,38 @@
# 2026-03-19
# pg_stats.py — тестовая функция (Test 7): возвращает агрегированную статистику
# по таблице terraform_demo_table: кол-во строк, дата первой и последней записи.
# Создаётся и удаляется в рамках тестового прогона.
#
# Entrypoint: pg_stats.get_stats
import os
import psycopg2
import json
_CODE_VERSION = "v1-test7"
def get_stats(event):
conn = psycopg2.connect(
host=os.environ["PGHOST"],
port=int(os.environ.get("PGPORT", "5432")),
dbname=os.environ["PGDATABASE"],
user=os.environ["PGUSER"],
password=os.environ["PGPASSWORD"],
sslmode=os.environ.get("PGSSLMODE", "require"),
)
try:
with conn.cursor() as cur:
cur.execute(
"SELECT COUNT(*) AS cnt, MIN(created_at) AS first, MAX(created_at) AS last "
"FROM terraform_demo_table"
)
row = cur.fetchone()
return {
"version": _CODE_VERSION,
"total_rows": row[0],
"first_row_at": str(row[1]) if row[1] else None,
"last_row_at": str(row[2]) if row[2] else None,
}
finally:
conn.close()
@@ -0,0 +1 @@
psycopg2-binary==2.9.9
@@ -0,0 +1,3 @@
# 2026-03-17 00:00
# requirements.txt — зависимости для функции запуска SQL.
psycopg2-binary==2.9.9
@@ -0,0 +1,39 @@
# 2026-03-17 00:00
# sql_runner.py — функция для выполнения SQL-операторов из входного события.
import os
import psycopg2
def run_sql(event):
# Выполняет список SQL-операторов в одной транзакции для атомарной инициализации схемы.
# Параметры подключения передаются раздельно, чтобы избежать ошибок парсинга DSN при спецсимволах.
pg_host = os.environ["PGHOST"]
pg_port = os.environ.get("PGPORT", "5432")
pg_database = os.environ["PGDATABASE"]
pg_user = os.environ["PGUSER"]
pg_password = os.environ["PGPASSWORD"]
pg_sslmode = os.environ.get("PGSSLMODE", "require")
statements = event.get("statements", [])
if not statements:
return {"error": "no statements provided"}
connection = psycopg2.connect(
host=pg_host,
port=pg_port,
dbname=pg_database,
user=pg_user,
password=pg_password,
sslmode=pg_sslmode,
)
try:
cursor = connection.cursor()
for statement in statements:
cursor.execute(statement)
connection.commit()
return {"ok": True, "executed": len(statements)}
except Exception as error:
connection.rollback()
return {"error": str(error)}
finally:
connection.close()
@@ -0,0 +1,20 @@
# 2026-03-19
# stress_bigloop.py — CPU-интенсивная функция: считает сумму квадратов N чисел.
# Проверяет поведение под нагрузкой (большая и средняя итерация).
import time
_VERSION = "v1"
def run(event):
n = int(event.get("n", 500_000))
start = time.monotonic()
total = sum(i * i for i in range(n))
elapsed = round(time.monotonic() - start, 4)
return {
"version": _VERSION,
"n": n,
"sum_of_squares": total,
"elapsed_sec": elapsed,
}
@@ -0,0 +1,13 @@
# 2026-03-19
# stress_divzero.py — намеренно делит на ноль (ZeroDivisionError).
# Проверяет: платформа перехватывает панику, возвращает HTTP 500, не роняет под.
_VERSION = "v1"
def run(event):
numerator = int(event.get("n", 42))
denominator = int(event.get("d", 0)) # по умолчанию 0 — намеренный краш
# ZeroDivisionError: проверяем что платформа обрабатывает исключения
result = numerator / denominator
return {"version": _VERSION, "result": result}
@@ -0,0 +1,43 @@
package handler
// 2026-03-19
// handler.go — быстрая Go функция: факториал + числа Фибоначчи.
// Проверяет Go runtime под лёгкой нагрузкой и корректность JSON-ответа.
// Entrypoint: handler.Handle
package handler
import "fmt"
func factorial(n int) uint64 {
if n <= 1 {
return 1
}
return uint64(n) * factorial(n-1)
}
func fib(n int) int {
if n <= 1 {
return n
}
a, b := 0, 1
for i := 2; i <= n; i++ {
a, b = b, a+b
}
return b
}
func Handle(event map[string]interface{}) interface{} {
n := 10
if v, ok := event["n"].(float64); ok {
n = int(v)
if n > 20 {
n = 20
}
}
return map[string]interface{}{
"runtime": "go1.23",
"version": "v1",
"n": n,
"factorial": fmt.Sprintf("%d", factorial(n)),
"fib": fib(n),
}
}
@@ -0,0 +1,21 @@
// 2026-03-19
// handler.go — намеренный nil pointer dereference в Go.
// Проверяет что Go runtime recover() перехватывает панику и платформа возвращает 500.
// Entrypoint: handler.Handle
package handler
func Handle(event map[string]interface{}) interface{} {
crash := true
if v, ok := event["crash"].(bool); ok {
crash = v
}
if crash {
var p *string
_ = *p // panic: намеренный nil pointer для stress-теста
}
return map[string]interface{}{
"runtime": "go1.23",
"version": "v1",
"crashed": false,
}
}
@@ -0,0 +1,148 @@
// 2026-03-19
// handler.go — Go стресс-тест PostgreSQL через pgxpool.
// Запускает N горутин (default 100), каждая в цикле duration_sec (default 600)
// долбит PG попеременно: INSERT / SELECT COUNT / SELECT MAX с случайными задержками.
// Цель: проверить Go runtime под конкурентной нагрузкой и устойчивость PG connection pool.
// Entrypoint: handler.Handle
package handler
import (
"context"
"fmt"
"math/rand"
"os"
"sync"
"sync/atomic"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// pgDSN собирает DSN из env vars (PGHOST, PGPORT, PGDATABASE, PGUSER, PGPASSWORD, PGSSLMODE).
func pgDSN() string {
host := os.Getenv("PGHOST")
port := os.Getenv("PGPORT")
if port == "" {
port = "5432"
}
db := os.Getenv("PGDATABASE")
user := os.Getenv("PGUSER")
pass := os.Getenv("PGPASSWORD")
sslmode := os.Getenv("PGSSLMODE")
if sslmode == "" {
sslmode = "require"
}
return fmt.Sprintf("host=%s port=%s dbname=%s user=%s password=%s sslmode=%s",
host, port, db, user, pass, sslmode)
}
// worker — одна горутина: чередует INSERT/COUNT/MAX с случайной задержкой до maxDelayMs.
// При ошибке инкрементирует errOps и продолжает (не паникует).
func worker(ctx context.Context, pool *pgxpool.Pool, workerID int, maxDelayMs int, okOps, errOps *int64) {
rng := rand.New(rand.NewSource(time.Now().UnixNano() + int64(workerID)))
op := 0
for {
select {
case <-ctx.Done():
return
default:
}
// Случайная задержка перед следующей операцией: 0..maxDelayMs мс
delay := rng.Intn(maxDelayMs + 1)
time.Sleep(time.Duration(delay) * time.Millisecond)
var err error
switch op % 3 {
case 0: // INSERT
title := fmt.Sprintf("pgstorm-w%d-%d", workerID, time.Now().UnixNano())
_, err = pool.Exec(ctx,
"INSERT INTO terraform_demo_table (title) VALUES ($1)", title)
case 1: // SELECT COUNT
var count int64
err = pool.QueryRow(ctx,
"SELECT COUNT(*) FROM terraform_demo_table").Scan(&count)
case 2: // SELECT MAX id
var maxID *int64
err = pool.QueryRow(ctx,
"SELECT MAX(id) FROM terraform_demo_table").Scan(&maxID)
}
if err != nil && ctx.Err() == nil {
atomic.AddInt64(errOps, 1)
} else if err == nil {
atomic.AddInt64(okOps, 1)
}
op++
}
}
func Handle(event map[string]interface{}) interface{} {
// Параметры из event (все опциональны — разумные defaults)
workers := 100
if v, ok := event["workers"].(float64); ok && v > 0 && v <= 500 {
workers = int(v)
}
durationSec := 600
if v, ok := event["duration_sec"].(float64); ok && v > 0 && v <= 3600 {
durationSec = int(v)
}
maxDelayMs := 300
if v, ok := event["max_delay_ms"].(float64); ok && v >= 0 && v <= 5000 {
maxDelayMs = int(v)
}
// Инициализация pgxpool — единый pool на всю функцию, MaxConns ограничен
// чтобы не перегрузить managed PG при большом числе горутин.
poolCfg, err := pgxpool.ParseConfig(pgDSN())
if err != nil {
return map[string]interface{}{"error": fmt.Sprintf("parse dsn: %v", err)}
}
maxConns := 20
if workers < 20 {
maxConns = workers
}
poolCfg.MaxConns = int32(maxConns)
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(durationSec)*time.Second)
defer cancel()
pool, err := pgxpool.NewWithConfig(ctx, poolCfg)
if err != nil {
return map[string]interface{}{"error": fmt.Sprintf("connect pool: %v", err)}
}
defer pool.Close()
var okOps, errOps int64
startTime := time.Now()
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
worker(ctx, pool, id, maxDelayMs, &okOps, &errOps)
}(i)
}
wg.Wait()
elapsed := time.Since(startTime).Seconds()
total := okOps + errOps
opsPerSec := 0.0
if elapsed > 0 {
opsPerSec = float64(total) / elapsed
}
return map[string]interface{}{
"runtime": "go1.23",
"version": "v1",
"workers": workers,
"duration_sec": durationSec,
"max_delay_ms": maxDelayMs,
"elapsed_sec": fmt.Sprintf("%.1f", elapsed),
"total_ops": total,
"ok_ops": okOps,
"err_ops": errOps,
"ops_per_sec": fmt.Sprintf("%.1f", opsPerSec),
}
}
@@ -0,0 +1,7 @@
{
"name": "stress-js-async",
"version": "1.0.0",
"dependencies": {
"pg": "^8.11.0"
}
}
@@ -0,0 +1,37 @@
// 2026-03-19
// stress_js_async.js — делает 3 параллельных запроса к PG через Promise.all.
// Проверяет nodejs20 runtime под умеренной нагрузкой и async/await.
//
// Entrypoint: stress_js_async.run
'use strict';
const { Client } = require('pg');
exports.run = async (event) => {
const client = new Client({
host: process.env.PGHOST,
port: parseInt(process.env.PGPORT || '5432'),
database: process.env.PGDATABASE,
user: process.env.PGUSER,
password: process.env.PGPASSWORD,
ssl: process.env.PGSSLMODE === 'require' ? { rejectUnauthorized: false } : false,
});
await client.connect();
try {
const [ver, cnt, max] = await Promise.all([
client.query('SELECT version() AS v'),
client.query('SELECT COUNT(*) AS cnt FROM terraform_demo_table'),
client.query('SELECT MAX(id) AS max_id FROM terraform_demo_table'),
]);
return {
runtime: 'nodejs20',
version: 'v1',
pg_version: ver.rows[0].v.split(' ').slice(0, 2).join(' '),
total_rows: parseInt(cnt.rows[0].cnt, 10),
max_id: max.rows[0].max_id,
};
} finally {
await client.end();
}
};
@@ -0,0 +1,5 @@
{
"name": "stress-js-badenv",
"version": "1.0.0",
"dependencies": {}
}
@@ -0,0 +1,17 @@
// 2026-03-19
// stress_js_badenv.js — читает несуществующую переменную env и падает.
// Проверяет: платформа перехватывает TypeError/undefined, возвращает 500.
//
// Entrypoint: stress_js_badenv.run
'use strict';
exports.run = async (event) => {
const crash = event.crash !== false; // по умолчанию crash=true
if (crash) {
// Читаем несуществующий env, пытаемся вызвать .toUpperCase() на undefined
const val = process.env.THIS_VAR_DOES_NOT_EXIST_AT_ALL;
return { shout: val.toUpperCase() }; // TypeError: Cannot read properties of undefined
}
return { runtime: 'nodejs20', version: 'v1', crashed: false };
};
@@ -0,0 +1,18 @@
# 2026-03-19
# stress_slow.py — долгая функция: спит N секунд (по умолчанию 8).
# Проверяет что timeout-механизм и параллельные запросы не блокируют друг друга.
import time
import os
_VERSION = "v1"
def run(event):
secs = int(event.get("sleep", 8))
time.sleep(secs)
return {
"version": _VERSION,
"slept_sec": secs,
"pid": os.getpid(),
}
@@ -0,0 +1 @@
psycopg2-binary==2.9.9
@@ -0,0 +1,39 @@
# 2026-03-19
# stress_writer.py — пишет N строк в terraform_demo_table (по умолчанию 5).
# Проверяет параллельные INSERT'ы и устойчивость соединения с PG при нагрузке.
import os
import psycopg2
import time
_VERSION = "v1"
def run(event):
n = int(event.get("rows", 5))
prefix = event.get("prefix", "stress")
conn = psycopg2.connect(
host=os.environ["PGHOST"],
port=int(os.environ.get("PGPORT", "5432")),
dbname=os.environ["PGDATABASE"],
user=os.environ["PGUSER"],
password=os.environ["PGPASSWORD"],
sslmode=os.environ.get("PGSSLMODE", "require"),
)
inserted = []
try:
with conn.cursor() as cur:
for i in range(n):
title = f"{prefix}-{int(time.time()*1000)}-{i}"
cur.execute(
"INSERT INTO terraform_demo_table (title) VALUES (%s) RETURNING id",
(title,),
)
row = cur.fetchone()
inserted.append({"id": row[0], "title": title})
conn.commit()
finally:
conn.close()
return {"version": _VERSION, "inserted": inserted, "count": len(inserted)}
@@ -0,0 +1 @@
psycopg2-binary==2.9.9
+133
View File
@@ -0,0 +1,133 @@
# 2026-03-19 — добавлен version и hostname в ответ list_rows для тестирования обновления кода
# table_rw.py — чтение и запись строк в terraform_demo_table.
# Два entrypoint в одном файле: list_rows (JSON API) и add_row (HTML-страница + POST-обработчик).
# ENV: PGHOST, PGPORT, PGDATABASE, PGUSER, PGPASSWORD, PGSSLMODE
import os
import json
import socket
import psycopg2
import psycopg2.extras
_CODE_VERSION = "v2-with-hostname"
def _connect():
return psycopg2.connect(
host=os.environ["PGHOST"],
port=os.environ.get("PGPORT", "5432"),
dbname=os.environ["PGDATABASE"],
user=os.environ["PGUSER"],
password=os.environ["PGPASSWORD"],
sslmode=os.environ.get("PGSSLMODE", "require"),
)
def list_rows(event):
# Возвращает все строки terraform_demo_table, отсортированные по убыванию created_at.
conn = _connect()
try:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"SELECT id, title, created_at::text FROM terraform_demo_table ORDER BY created_at DESC"
)
rows = [dict(r) for r in cur.fetchall()]
return {"rows": rows, "count": len(rows), "version": _CODE_VERSION, "host": socket.gethostname()}
finally:
conn.close()
def _render_page(rows, message=""):
# HTML-страница с формой ввода и таблицей строк.
# message — статус последней операции (успех / ошибка).
rows_html = "".join(
f"<tr><td>{r['id']}</td><td>{r['title']}</td><td>{r['created_at']}</td></tr>"
for r in rows
)
msg_html = f'<p class="msg">{message}</p>' if message else ""
return f"""<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>pg-table-writer</title>
<style>
body {{ font-family: sans-serif; max-width: 700px; margin: 40px auto; background: #111; color: #eee; }}
h1 {{ color: #7dd3fc; }}
form {{ display: flex; gap: 8px; margin-bottom: 24px; }}
input[type=text] {{ flex: 1; padding: 8px 12px; border-radius: 6px; border: 1px solid #444; background: #1e1e1e; color: #eee; font-size: 15px; }}
button {{ padding: 8px 18px; background: #2563eb; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 15px; }}
button:hover {{ background: #1d4ed8; }}
table {{ width: 100%; border-collapse: collapse; }}
th, td {{ padding: 8px 10px; border-bottom: 1px solid #333; text-align: left; }}
th {{ color: #7dd3fc; }}
.msg {{ color: #4ade80; margin-bottom: 12px; }}
</style>
</head>
<body>
<h1>pg-table-writer</h1>
<form method="POST">
<input type="text" name="title" placeholder="Введите строку..." autofocus required>
<button type="submit">Добавить</button>
</form>
{msg_html}
<table>
<thead><tr><th>#</th><th>title</th><th>created_at</th></tr></thead>
<tbody>{rows_html}</tbody>
</table>
</body>
</html>"""
def add_row(event):
# GET → HTML-страница с формой и списком строк.
# POST → вставляет строку из form-поля title или JSON-поля title,
# затем возвращает обновлённую HTML-страницу.
# POST с Content-Type: application/json (curl/API) → возвращает JSON.
method = event.get("_method", "GET")
if method == "GET":
conn = _connect()
try:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT id, title, created_at::text FROM terraform_demo_table ORDER BY created_at DESC")
rows = [dict(r) for r in cur.fetchall()]
finally:
conn.close()
return _render_page(rows)
# POST — вставка строки
# Поле title приходит либо из JSON-тела, либо из application/x-www-form-urlencoded.
# Сервер уже распарсил JSON в event; form-данные приходят как event["body"] = "title=...".
title = event.get("title", "").strip()
if not title:
# Попытка распарсить form-encoded body (браузерная форма)
body = event.get("body", "")
if body.startswith("title="):
from urllib.parse import unquote_plus
title = unquote_plus(body[len("title="):].split("&")[0]).strip()
if not title:
return {"ok": False, "error": "title is required"}
conn = _connect()
try:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"INSERT INTO terraform_demo_table (title) VALUES (%s) RETURNING id, title, created_at::text",
(title,),
)
row = dict(cur.fetchone())
conn.commit()
# Если запрос из браузера (form POST) — возвращаем обновлённую страницу.
# Если из curl/API — возвращаем JSON.
accept = event.get("_accept", "")
if "application/json" in accept:
return {"ok": True, "row": row}
# Перечитываем все строки для обновлённой страницы
cur.execute("SELECT id, title, created_at::text FROM terraform_demo_table ORDER BY created_at DESC")
rows = [dict(r) for r in cur.fetchall()]
return _render_page(rows, message=f"Добавлено: «{row['title']}»")
finally:
conn.close()
+129
View File
@@ -0,0 +1,129 @@
# 2026-03-18 (обновлено: фильтрация SLESS_EXCLUDE, читаемый вывод через "#"-ключ)
# funcs_list.py — HTTP-функция: список всех пользовательских функций с их статусами.
# Вызывает внутренний REST API оператора (ClusterIP, без TLS).
# Объединяет данные функций и триггеров в один ответ; скрывает служебные функции.
#
# Env vars:
# SLESS_API_URL — URL оператора (http://sless-operator.sless.svc.cluster.local:9090)
# SLESS_NAMESPACE — namespace пользователя (sless-{hex16})
# SLESS_TOKEN — JWT токен для /v1/ API
# SLESS_EXTERNAL_URL — публичный базовый URL (https://sless.kube5s.ru), для корректных ссылок
# SLESS_EXCLUDE — comma-separated имена функций, которые не надо показывать
# Пример: "funcs,event-writer,event-monitor,event-cleaner"
#
# Формат вывода: JSON-объект, где каждая функция содержит поле "#" — краткий комментарий.
# При pretty-print (python3 -m json.tool) выглядит как читаемый список с аннотациями.
import os
import requests
def _short_comment(fn, http_triggers, cron_triggers):
"""Генерирует однострочный комментарий-описание функции по её метаданным."""
phase = fn.get("phase", "")
runtime = fn.get("runtime", "")
if http_triggers:
active_str = "активна" if http_triggers[0].get("active") else "неактивна"
return f"HTTP endpoint ({runtime}) — {phase}, {active_str}"
elif cron_triggers:
schedule = cron_triggers[0].get("schedule", "?")
active_str = "активна" if cron_triggers[0].get("active") else "неактивна"
return f"Cron '{schedule}' ({runtime}) — {phase}, {active_str}"
else:
return f"Job/runner без триггера ({runtime}) — {phase}"
def list_all(event):
api_url = os.environ["SLESS_API_URL"].rstrip("/")
namespace = os.environ["SLESS_NAMESPACE"]
token = os.environ["SLESS_TOKEN"]
ext_url = os.environ.get("SLESS_EXTERNAL_URL", "").rstrip("/")
# Имена функций, которые не должны присутствовать в выводе.
# Включает саму себя ("funcs") и служебные функции других примеров.
exclude = {
n.strip()
for n in os.environ.get("SLESS_EXCLUDE", "").split(",")
if n.strip()
}
headers = {"Authorization": f"Bearer {token}"}
fns_resp = requests.get(
f"{api_url}/v1/namespaces/{namespace}/functions",
headers=headers,
timeout=10,
)
fns_resp.raise_for_status()
trs_resp = requests.get(
f"{api_url}/v1/namespaces/{namespace}/triggers",
headers=headers,
timeout=10,
)
trs_resp.raise_for_status()
# Индекс триггеров по имени функции
triggers_by_fn = {}
for tr in trs_resp.json():
fn_name = tr.get("function") or tr.get("functionRef")
if fn_name:
triggers_by_fn.setdefault(fn_name, []).append(tr)
result = []
for fn in fns_resp.json():
name = fn["name"]
if name in exclude:
continue
http_triggers = [
t for t in triggers_by_fn.get(name, []) if t.get("type") == "http"
]
cron_triggers = [
t for t in triggers_by_fn.get(name, []) if t.get("type") == "cron"
]
is_active = any(
t.get("enabled", True) and t.get("active", False)
for t in triggers_by_fn.get(name, [])
)
entry = {
# "#" — первый ключ: служит визуальным комментарием при pretty-print
"#": _short_comment(fn, http_triggers, cron_triggers),
"name": name,
"runtime": fn.get("runtime"),
"phase": fn.get("phase"),
"active": is_active,
}
# URL вычисляем из SLESS_EXTERNAL_URL если задан — state может хранить старый домен
if http_triggers:
if ext_url:
entry["url"] = f"{ext_url}/fn/{namespace}/{name}"
else:
entry["url"] = http_triggers[0].get("url", "")
if cron_triggers:
entry["cron"] = cron_triggers[0].get("schedule", "")
if fn.get("message"):
entry["message"] = fn["message"]
# created_at и last_built_at — доступны после обновления оператора до v0.1.32+
if fn.get("created_at"):
entry["created_at"] = fn["created_at"]
if fn.get("last_built_at"):
entry["last_built_at"] = fn["last_built_at"]
result.append(entry)
# Сортировка: активные вверх, затем по имени
result.sort(key=lambda f: (not f["active"], f["name"]))
return {
"namespace": namespace,
"count": len(result),
"functions": result,
}
+69
View File
@@ -0,0 +1,69 @@
# resource "nubes_lucee" "app1" {
# # Lucee-приложение, подключается к nubes_postgres.npg (наш Postgres в реалме var.realm).
# resource_name = "lucy_teststand_0"
# resource_realm = nubes_postgres.npg.resource_realm
# domain = "web-test-stand"
# git_path = "https://gitea-naeel.giteak8s.services.ngcloud.ru/naeel/testlucee"
# json_env = jsonencode({
# # Настройки Data Source 'testds' для Lucee (Application.cfc)
# testds_class = "org.postgresql.Driver"
# testds_bundleName = "org.postgresql.jdbc"
# testds_bundleVersion = "42.6.0"
# testds_connectionString = "jdbc:postgresql://${local.pg_host}:5432/${local.pg_database}?sslmode=require"
# testds_username = local.pg_username
# testds_password = local.pg_password
# testds_connectionLimit = "5"
# testds_liveTimeout = "15"
# testds_validate = "false"
# })
# resource_c_p_u = 300
# resource_memory = 512
# resource_instances = 1
# app_version = "5.4"
# depends_on = [nubes_postgres.npg]
# }
# resource "nubes_nodejs" "app3" {
# # NodeJS — застрял при создании на тест-стенде (операция в ожидании).
# # Раскомментировать когда тест-стенд стабилен.
# resource_name = "node_01"
# resource_realm = nubes_postgres.npg.resource_realm
# domain = "node07"
# git_path = "https://gitea-naeel.giteak8s.services.ngcloud.ru/naeel/testnode.git"
# health_path = "/healthz"
# app_version = "23"
#
# json_env = jsonencode({
# PGHOST = local.pg_host
# PGPORT = "5432"
# PGUSER = local.pg_username
# PGPASSWORD = local.pg_password
# PGDATABASE = local.pg_database
# PGSSLMODE = "require"
# DATABASE_URL = format(
# "postgresql://%s:%s@%s:5432/%s?sslmode=require",
# local.pg_username,
# local.pg_password,
# local.pg_host,
# local.pg_database
# )
# })
#
# resource_c_p_u = 300
# resource_memory = 256
# resource_instances = 1
#
# depends_on = [nubes_postgres.npg]
# }
# output "pg_vault_secrets" {
# value = nubes_postgres.db2.vault_secrets
# sensitive = true
# }
+58
View File
@@ -0,0 +1,58 @@
// 2026-03-17 17:05
// main.tf — провайдеры и переменные для Nubes + sless.
terraform {
required_providers {
nubes = {
source = "terra.k8c.ru/nubes/nubes"
version = "5.0.19"
}
sless = {
source = "terra.k8c.ru/naeel/sless"
version = "~> 0.1.18"
}
}
}
variable "api_token" {
type = string
sensitive = true
description = "Nubes API token"
}
variable "s3_uid" {
type = string
sensitive = true
description = "Nubes S3 UID"
}
variable "realm" {
type = string
sensitive = true
description = "resource_realm parameter for nubes_postgres resource"
}
// 2026-03-18 — pg_user/pg_password помечены optional (default="") для сверки.
// Реальные credentials берутся из vault_secrets через locals в resources.tf.
variable "pg_user" {
type = string
sensitive = true
default = ""
description = "Только для сверки. Реальный username из nubes_postgres_user.pg_user.username. Должен совпадать с vault."
}
variable "pg_password" {
type = string
sensitive = true
default = ""
description = "Только для сверки. Реальный пароль из vault_secrets. Должен совпадать с tfvars."
}
provider "nubes" {
api_token = var.api_token
api_endpoint = "https://deck-test.ngcloud.ru/api/v1/index.cfm"
}
provider "sless" {
endpoint = "https://sless.kube5s.ru"
token = var.api_token
nubes_endpoint = "https://deck-test.ngcloud.ru/api/v1"
}
+407
View File
@@ -0,0 +1,407 @@
// 2026-03-18 — добавлены locals для извлечения credentials из vault_secrets (без хардкода).
// Для сверки хардкод остаётся в terraform.tfvars на этапе разработки.
// sless_function и sless_job закомментированы — сначала проверяется сетевое соединение.
# Актуальные credentials из vault_secrets (authoritatively) — vault синхронизирован с кластером.
# Структура vault_secrets["users"]: JSON-строка {"username": {"password": "...", "username": "..."}}
locals {
pg_creds_map = jsondecode(nubes_postgres.npg.vault_secrets["users"])
pg_username = nubes_postgres_user.pg_user.username
pg_password = local.pg_creds_map[local.pg_username]["password"]
pg_host = nubes_postgres.npg.state_out_flat["internalConnect.master"]
pg_database = nubes_postgres_database.db.db_name
}
resource "nubes_postgres" "npg" {
resource_name = "teststand-pg-2"
# s3_uid = "s01325"
s3_uid = var.s3_uid
resource_realm = var.realm
resource_instances = 1
resource_memory = 512
resource_c_p_u = 500
resource_disk = "1"
app_version = "17"
json_parameters = jsonencode({
log_connections = "off"
log_disconnections = "off"
})
enable_pg_pooler_master = false
enable_pg_pooler_slave = false
allow_no_s_s_l = false
auto_scale = false
auto_scale_percentage = 10
auto_scale_tech_window = 0
auto_scale_quota_gb = "1"
need_external_address_master = false
# suspend_on_destroy = false
operation_timeout = "11m"
adopt_existing_on_create = true
}
resource "nubes_postgres_user" "pg_user" {
postgres_id = nubes_postgres.npg.id
username = "u-user0"
role = "ddl_user"
adopt_existing_on_create = true
}
resource "nubes_postgres_database" "db" {
postgres_id = nubes_postgres.npg.id
db_name = "db_terra"
db_owner = nubes_postgres_user.pg_user.username
adopt_existing_on_create = true
# suspend_on_destroy = false
}
# Служебная функция выполняет SQL-операторы из event_json.
# Credentials берутся из locals (vault_secrets) — без хардкода.
# Для сверки хардкод остаётся в terraform.tfvars.
resource "sless_function" "postgres_sql_runner_create_table" {
name = "pg-create-table-runner"
runtime = "python3.11"
entrypoint = "sql_runner.run_sql"
memory_mb = 128
timeout_sec = 30
env_vars = {
PGHOST = local.pg_host
PGPORT = "5432"
PGDATABASE = local.pg_database
PGUSER = local.pg_username
PGPASSWORD = local.pg_password
PGSSLMODE = "require"
# Для сверки (должно совпадать с vault):
# PGUSER = var.pg_user
# PGPASSWORD = var.pg_password
}
source_dir = "${path.module}/code/sql-runner"
}
resource "sless_job" "postgres_table_init_job" {
name = "pg-create-table-job-main-v13"
function = sless_function.postgres_sql_runner_create_table.name
wait_timeout_sec = 180
run_id = 13
event_json = jsonencode({
statements = [
"CREATE TABLE IF NOT EXISTS terraform_demo_table (id serial PRIMARY KEY, title text NOT NULL, created_at timestamp DEFAULT now())"
]
})
depends_on = [nubes_postgres_database.db]
}
# HTTP-функция на NodeJS: возвращает версию PG-сервера и счётчик строк в таблице.
# Единственная функция примера на nodejs20 — проверка что JS runtime работает.
# Доступна по URL: https://sless.kube5s.ru/fn/<namespace>/pg-info
resource "sless_function" "pg_info" {
name = "pg-info"
runtime = "nodejs20"
entrypoint = "pg_info.info"
memory_mb = 128
timeout_sec = 15
env_vars = {
PGHOST = local.pg_host
PGPORT = "5432"
PGDATABASE = local.pg_database
PGUSER = local.pg_username
PGPASSWORD = local.pg_password
PGSSLMODE = "require"
}
source_dir = "${path.module}/code/pg-info"
depends_on = [sless_job.postgres_table_init_job]
}
resource "sless_trigger" "pg_info_http" {
name = "pg-info-http"
type = "http"
function = sless_function.pg_info.name
enabled = true
}
# HTTP-функции чтения и записи строк terraform_demo_table — в одном файле table_rw.py.
# list_rows (GET) — читает все строки; add_row (POST {title}) — вставляет строку.
# Доступны по URL: https://sless.kube5s.ru/fn/<namespace>/pg-table-reader
# https://sless.kube5s.ru/fn/<namespace>/pg-table-writer
resource "sless_function" "postgres_table_reader" {
name = "pg-table-reader"
runtime = "python3.11"
entrypoint = "table_rw.list_rows"
memory_mb = 128
timeout_sec = 30
env_vars = {
PGHOST = local.pg_host
PGPORT = "5432"
PGDATABASE = local.pg_database
PGUSER = local.pg_username
PGPASSWORD = local.pg_password
PGSSLMODE = "require"
}
source_dir = "${path.module}/code/table-rw"
depends_on = [sless_job.postgres_table_init_job]
}
resource "sless_trigger" "postgres_table_reader_http" {
name = "pg-table-reader-http"
type = "http"
function = sless_function.postgres_table_reader.name
enabled = true
}
output "table_reader_url" {
value = sless_trigger.postgres_table_reader_http.url
}
resource "sless_function" "postgres_table_writer" {
name = "pg-table-writer"
runtime = "python3.11"
entrypoint = "table_rw.add_row"
memory_mb = 256
timeout_sec = 45
env_vars = {
PGHOST = local.pg_host
PGPORT = "5432"
PGDATABASE = local.pg_database
PGUSER = local.pg_username
PGPASSWORD = local.pg_password
PGSSLMODE = "require"
}
source_dir = "${path.module}/code/table-rw"
depends_on = [sless_job.postgres_table_init_job]
}
resource "sless_trigger" "postgres_table_writer_http" {
name = "pg-table-writer-http"
type = "http"
function = sless_function.postgres_table_writer.name
enabled = true
}
output "table_writer_url" {
value = sless_trigger.postgres_table_writer_http.url
}
# =============================================================================
# STRESS-ТЕСТЫ: 8 функций для проверки устойчивости платформы.
# Python: slow, divzero, bigloop, writer
# Go: fast, nil-panic
# NodeJS: async-parallel, badenv
# =============================================================================
# --- [1] Python: долгая (sleep N сек) ---
resource "sless_function" "stress_slow" {
name = "stress-slow"
runtime = "python3.11"
entrypoint = "stress_slow.run"
memory_mb = 128
timeout_sec = 30
env_vars = {}
source_dir = "${path.module}/code/stress-slow"
depends_on = [sless_job.postgres_table_init_job]
}
resource "sless_trigger" "stress_slow_http" {
name = "stress-slow-http"
type = "http"
function = sless_function.stress_slow.name
enabled = true
}
# --- [2] Python: деление на ноль ---
resource "sless_function" "stress_divzero" {
name = "stress-divzero"
runtime = "python3.11"
entrypoint = "stress_divzero.run"
memory_mb = 128
timeout_sec = 10
env_vars = {}
source_dir = "${path.module}/code/stress-divzero"
depends_on = [sless_job.postgres_table_init_job]
}
resource "sless_trigger" "stress_divzero_http" {
name = "stress-divzero-http"
type = "http"
function = sless_function.stress_divzero.name
enabled = true
}
# --- [3] Python: CPU bigloop ---
resource "sless_function" "stress_bigloop" {
name = "stress-bigloop"
runtime = "python3.11"
entrypoint = "stress_bigloop.run"
memory_mb = 256
timeout_sec = 30
env_vars = {}
source_dir = "${path.module}/code/stress-bigloop"
depends_on = [sless_job.postgres_table_init_job]
}
resource "sless_trigger" "stress_bigloop_http" {
name = "stress-bigloop-http"
type = "http"
function = sless_function.stress_bigloop.name
enabled = true
}
# --- [4] Python: массовая запись в PG ---
resource "sless_function" "stress_writer" {
name = "stress-writer"
runtime = "python3.11"
entrypoint = "stress_writer.run"
memory_mb = 128
timeout_sec = 30
env_vars = {
PGHOST = local.pg_host
PGPORT = "5432"
PGDATABASE = local.pg_database
PGUSER = local.pg_username
PGPASSWORD = local.pg_password
PGSSLMODE = "require"
}
source_dir = "${path.module}/code/stress-writer"
depends_on = [sless_job.postgres_table_init_job]
}
resource "sless_trigger" "stress_writer_http" {
name = "stress-writer-http"
type = "http"
function = sless_function.stress_writer.name
enabled = true
}
# --- [5] Go: быстрая математика (факториал + Фибоначчи) ---
resource "sless_function" "stress_go_fast" {
name = "stress-go-fast"
runtime = "go1.23"
entrypoint = "handler.Handle"
memory_mb = 64
timeout_sec = 10
env_vars = {}
source_dir = "${path.module}/code/stress-go-fast"
depends_on = [sless_job.postgres_table_init_job]
}
resource "sless_trigger" "stress_go_fast_http" {
name = "stress-go-fast-http"
type = "http"
function = sless_function.stress_go_fast.name
enabled = true
}
# --- [6] Go: nil pointer panic ---
resource "sless_function" "stress_go_nil" {
name = "stress-go-nil"
runtime = "go1.23"
entrypoint = "handler.Handle"
memory_mb = 64
timeout_sec = 10
env_vars = {}
source_dir = "${path.module}/code/stress-go-nil"
depends_on = [sless_job.postgres_table_init_job]
}
resource "sless_trigger" "stress_go_nil_http" {
name = "stress-go-nil-http"
type = "http"
function = sless_function.stress_go_nil.name
enabled = true
}
# --- [7] NodeJS: 3 параллельных запроса к PG через Promise.all ---
resource "sless_function" "stress_js_async" {
name = "stress-js-async"
runtime = "nodejs20"
entrypoint = "stress_js_async.run"
memory_mb = 128
timeout_sec = 15
env_vars = {
PGHOST = local.pg_host
PGPORT = "5432"
PGDATABASE = local.pg_database
PGUSER = local.pg_username
PGPASSWORD = local.pg_password
PGSSLMODE = "require"
}
source_dir = "${path.module}/code/stress-js-async"
depends_on = [sless_job.postgres_table_init_job]
}
resource "sless_trigger" "stress_js_async_http" {
name = "stress-js-async-http"
type = "http"
function = sless_function.stress_js_async.name
enabled = true
}
# --- [8] NodeJS: TypeError на несуществующей env-переменной ---
resource "sless_function" "stress_js_badenv" {
name = "stress-js-badenv"
runtime = "nodejs20"
entrypoint = "stress_js_badenv.run"
memory_mb = 128
timeout_sec = 10
env_vars = {}
source_dir = "${path.module}/code/stress-js-badenv"
depends_on = [sless_job.postgres_table_init_job]
}
resource "sless_trigger" "stress_js_badenv_http" {
name = "stress-js-badenv-http"
type = "http"
function = sless_function.stress_js_badenv.name
enabled = true
}
# --- [9] Go: PG Storm — 100 горутин долбят PostgreSQL напрямую через pgxpool ---
# Тестирует: Go runtime под конкурентной нагрузкой, pgxpool connection pool,
# устойчивость managed PG при массовых INSERT/SELECT.
# Параметры: workers (default 100), duration_sec (default 600), max_delay_ms (default 300).
resource "sless_function" "stress_go_pgstorm" {
name = "stress-go-pgstorm"
runtime = "go1.23"
entrypoint = "handler.Handle"
memory_mb = 256
timeout_sec = 700
env_vars = {
PGHOST = local.pg_host
PGPORT = "5432"
PGDATABASE = local.pg_database
PGUSER = local.pg_username
PGPASSWORD = local.pg_password
PGSSLMODE = "require"
}
source_dir = "${path.module}/code/stress-go-pgstorm"
depends_on = [sless_job.postgres_table_init_job]
}
resource "sless_trigger" "stress_go_pgstorm_http" {
name = "stress-go-pgstorm-http"
type = "http"
function = sless_function.stress_go_pgstorm.name
enabled = true
}
@@ -0,0 +1,51 @@
# 2026-03-18 — debug pod для проверки psql-соединения из namespace функций.
# Запускается разово. Подключается к тому же postgres, что и sless_function.
# kubectl apply -f /tmp/pg-debug-pod.yaml
# kubectl logs -n sless-fn-sless-ffd1f598c169b0ae pg-debug-pod
apiVersion: v1
kind: Pod
metadata:
name: pg-debug-pod
namespace: sless-fn-sless-ffd1f598c169b0ae
labels:
purpose: debug-postgres-connectivity
spec:
restartPolicy: Never
containers:
- name: psql
image: postgres:17-alpine
command:
- sh
- -c
- |
echo "=== Testing TCP connectivity to postgres ==="
nc -zv -w5 $PGHOST 5432 && echo "TCP OK" || echo "TCP FAILED"
echo ""
echo "=== Testing psql connection ==="
PGCONNECT_TIMEOUT=10 psql \
"host=$PGHOST port=$PGPORT dbname=$PGDATABASE user=$PGUSER sslmode=$PGSSLMODE" \
--command="SELECT current_user, current_database(), version();" \
2>&1
echo ""
echo "=== Listing tables ==="
PGCONNECT_TIMEOUT=10 psql \
"host=$PGHOST port=$PGPORT dbname=$PGDATABASE user=$PGUSER sslmode=$PGSSLMODE" \
--command="\dt" \
2>&1
env:
- name: PGHOST
value: "postgresqlk8s-master.36875359-dcea-48c4-a593-b4531f20fe96.svc.cluster.local"
- name: PGPORT
value: "5432"
- name: PGDATABASE
value: "db_terra"
- name: PGUSER
value: "u-user0"
- name: PGPASSWORD
# Актуальный пароль из vault_secrets (совпадает с tfvars.pg_password на 2026-03-18)
value: "M03O6fRsngWcVHB2YGivyLfbfxoii2R21nyh2A2r7WSZS5deLwBgLKkc9Wk24Zyl"
- name: PGSSLMODE
value: "require"
@@ -0,0 +1,40 @@
# 2026-03-17 13:05
# read_pg_user_secret.py — читает пароль пользователя managed PostgreSQL из k8s Secret.
# Используется из Terraform external data source, чтобы apply сам получал актуальный пароль
# даже для уже существующего пользователя, созданного вне текущего state.
import base64
import json
import subprocess
import sys
def main():
# Читаем query от Terraform external provider из stdin.
query = json.load(sys.stdin)
namespace = query["namespace"]
secret_name = query["secret"]
# kubectl уже настроен на удалённой машине; читаем ровно поле data.password.
result = subprocess.run(
[
"kubectl",
"get",
"secret",
"-n",
namespace,
secret_name,
"-o",
"jsonpath={.data.password}",
],
check=True,
capture_output=True,
text=True,
)
password = base64.b64decode(result.stdout.strip()).decode()
json.dump({"password": password}, sys.stdout)
if __name__ == "__main__":
main()
+57
View File
@@ -0,0 +1,57 @@
#!/bin/bash
# 2026-03-19 — stress test script: параллельный запуск всех 8 стресс-функций
BASE="https://sless.kube5s.ru/fn/sless-ffd1f598c169b0ae"
echo "=== РАУНД 1: первый холодный запуск ==="
curl -s -m 35 "$BASE/stress-slow" -d '{"sleep":3}' -H "Content-Type:application/json" > /tmp/r_slow.json &
curl -s -m 10 "$BASE/stress-divzero" > /tmp/r_divzero.json &
curl -s -m 40 "$BASE/stress-bigloop" -d '{"n":1000000}' -H "Content-Type:application/json"> /tmp/r_bigloop.json &
curl -s -m 35 "$BASE/stress-writer" -d '{"rows":3,"prefix":"batch1"}' -H "Content-Type:application/json" > /tmp/r_writer.json &
curl -s -m 15 "$BASE/stress-go-fast" -d '{"n":15}' -H "Content-Type:application/json" > /tmp/r_go_fast.json &
curl -s -m 10 "$BASE/stress-go-nil" > /tmp/r_go_nil.json &
curl -s -m 20 "$BASE/stress-js-async" > /tmp/r_js_async.json &
curl -s -m 10 "$BASE/stress-js-badenv" > /tmp/r_js_badenv.json &
wait
echo "[slow]: $(cat /tmp/r_slow.json)"
echo "[divzero]: $(cat /tmp/r_divzero.json)"
echo "[bigloop]: $(cat /tmp/r_bigloop.json)"
echo "[writer]: $(cat /tmp/r_writer.json)"
echo "[go-fast]: $(cat /tmp/r_go_fast.json)"
echo "[go-nil]: $(cat /tmp/r_go_nil.json)"
echo "[js-async]: $(cat /tmp/r_js_async.json)"
echo "[js-badenv]:$(cat /tmp/r_js_badenv.json)"
echo ""
echo "=== РАУНД 2: повторный (горячий кэш) ==="
curl -s -m 15 "$BASE/stress-bigloop" -d '{"n":2000000}' -H "Content-Type:application/json" > /tmp/r2_bigloop.json &
curl -s -m 10 "$BASE/stress-go-fast" -d '{"n":20}' -H "Content-Type:application/json" > /tmp/r2_go_fast.json &
curl -s -m 20 "$BASE/stress-js-async" > /tmp/r2_async.json &
curl -s -m 35 "$BASE/stress-writer" -d '{"rows":10,"prefix":"batch2"}' -H "Content-Type:application/json" > /tmp/r2_writer.json &
wait
echo "[bigloop-2M]: $(cat /tmp/r2_bigloop.json)"
echo "[go-fast-20]: $(cat /tmp/r2_go_fast.json)"
echo "[js-async-2]: $(cat /tmp/r2_async.json)"
echo "[writer-10]: $(cat /tmp/r2_writer.json)"
echo ""
echo "=== РАУНД 3: crash функции с неверными параметрами ==="
curl -s -m 10 "$BASE/stress-divzero" -d '{"n":100,"d":0}' -H "Content-Type:application/json" > /tmp/r3_dz.json &
curl -s -m 10 "$BASE/stress-go-nil" -d '{"crash":true}' -H "Content-Type:application/json" > /tmp/r3_nil.json &
curl -s -m 10 "$BASE/stress-js-badenv" -d '{"crash":true}' -H "Content-Type:application/json" > /tmp/r3_bad.json &
# divzero с нормальным делителем — должен вернуть результат
curl -s -m 10 "$BASE/stress-divzero" -d '{"n":42,"d":7}' -H "Content-Type:application/json" > /tmp/r3_ok.json &
# go-nil без краша — должен вернуть ok
curl -s -m 10 "$BASE/stress-go-nil" -d '{"crash":false}' -H "Content-Type:application/json" > /tmp/r3_nil_ok.json &
wait
echo "[divzero crash]: $(cat /tmp/r3_dz.json)"
echo "[go-nil crash]: $(cat /tmp/r3_nil.json)"
echo "[js-badenv crash]: $(cat /tmp/r3_bad.json)"
echo "[divzero ok 42/7]: $(cat /tmp/r3_ok.json)"
echo "[go-nil ok]: $(cat /tmp/r3_nil_ok.json)"
echo ""
echo "=== ИТОГ: количество строк в таблице ==="
curl -s -m 15 "$BASE/pg-table-reader"
echo ""
echo "=== DONE ==="
+7 -7
View File
@@ -12,7 +12,7 @@
| `sless_trigger` | Публикует функцию: тип `http` создаёт публичный URL, тип `cron` — запуск по расписанию. |
| `sless_job` | Запускает функцию однократно и ожидает завершения. Используется для одноразовых операций: инициализация БД, миграции, пакетная обработка. |
Стандартная связка для HTTP API: `sless_function` + `sless_trigger` с `type = "http"` — в результате функция доступна по URL вида `https://sless-api.kube5s.ru/fn/<namespace>/<имя-функции>`.
Стандартная связка для HTTP API: `sless_function` + `sless_trigger` с `type = "http"` — в результате функция доступна по URL вида `https://sless.kube5s.ru/fn/<namespace>/<имя-функции>`.
---
@@ -20,7 +20,7 @@
- Terraform >= 1.0
- JWT-токен для аутентификации в sless API
- Доступ к `https://sless-api.kube5s.ru`
- Доступ к `https://sless.kube5s.ru`
## Конфигурация провайдера
@@ -28,9 +28,9 @@
```hcl
provider "sless" {
endpoint = "https://sless-api.kube5s.ru"
endpoint = "https://sless.kube5s.ru"
token = var.token
nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1"
nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1"
}
```
@@ -56,7 +56,7 @@ terraform init
terraform apply -auto-approve
# Вызов HTTP-функции с передачей имени:
curl -s -X POST https://sless-api.kube5s.ru/fn/<namespace>/hello-http \
curl -s -X POST https://sless.kube5s.ru/fn/<namespace>/hello-http \
-H 'Content-Type: application/json' -d '{"name":"World"}'
# Результат задания:
@@ -114,7 +114,7 @@ terraform init
terraform apply -auto-approve
terraform output job_result
curl -s https://sless-api.kube5s.ru/fn/<namespace>/simple-py-time-display
curl -s https://sless.kube5s.ru/fn/<namespace>/simple-py-time-display
```
---
@@ -127,7 +127,7 @@ terraform init
terraform apply -auto-approve
terraform output job_result
curl -s https://sless-api.kube5s.ru/fn/<namespace>/simple-node-time-display
curl -s https://sless.kube5s.ru/fn/<namespace>/simple-node-time-display
```
---
@@ -0,0 +1,94 @@
# 2026-03-18 (обновлено: plain text вывод; фильтрация SLESS_EXCLUDE)
# funcs_list.py — HTTP-функция: список пользовательских функций, человекочитаемый plain text.
# Вызывает внутренний REST API оператора (ClusterIP, без TLS).
# Возвращает str → python runtime отдаёт text/plain напрямую без json.dumps.
#
# Env vars:
# SLESS_API_URL — URL оператора (http://sless-operator.sless.svc.cluster.local:9090)
# SLESS_NAMESPACE — namespace пользователя (sless-{hex16})
# SLESS_TOKEN — JWT токен для /v1/ API
# SLESS_EXTERNAL_URL — публичный базовый URL (https://sless.kube5s.ru)
# SLESS_EXCLUDE — comma-separated имена функций, которые не показывать
import os
import requests
SEP = "" * 52
def _comment(fn, http_trigs, cron_trigs):
phase = fn.get("phase", "?")
runtime = fn.get("runtime", "?")
if http_trigs:
active = "активна" if http_trigs[0].get("active") else "неактивна"
return f"HTTP endpoint ({runtime}) — {phase}, {active}"
elif cron_trigs:
schedule = cron_trigs[0].get("schedule", "?")
active = "активна" if cron_trigs[0].get("active") else "неактивна"
return f"Cron '{schedule}' ({runtime}) — {phase}, {active}"
else:
return f"Job/runner без триггера ({runtime}) — {phase}"
def list_all(event):
api_url = os.environ["SLESS_API_URL"].rstrip("/")
namespace = os.environ["SLESS_NAMESPACE"]
token = os.environ["SLESS_TOKEN"]
ext_url = os.environ.get("SLESS_EXTERNAL_URL", "").rstrip("/")
exclude = {n.strip() for n in os.environ.get("SLESS_EXCLUDE", "").split(",") if n.strip()}
headers = {"Authorization": f"Bearer {token}"}
fns = requests.get(f"{api_url}/v1/namespaces/{namespace}/functions", headers=headers, timeout=10)
trs = requests.get(f"{api_url}/v1/namespaces/{namespace}/triggers", headers=headers, timeout=10)
fns.raise_for_status()
trs.raise_for_status()
trig_idx = {}
for tr in trs.json():
fn_name = tr.get("function") or tr.get("functionRef")
if fn_name:
trig_idx.setdefault(fn_name, []).append(tr)
items = []
for fn in fns.json():
name = fn["name"]
if name in exclude:
continue
http_t = [t for t in trig_idx.get(name, []) if t.get("type") == "http"]
cron_t = [t for t in trig_idx.get(name, []) if t.get("type") == "cron"]
is_active = any(t.get("enabled", True) and t.get("active", False) for t in trig_idx.get(name, []))
items.append((fn, http_t, cron_t, is_active))
# Сортировка: активные вверх, затем по имени
items.sort(key=lambda x: (not x[3], x[0]["name"]))
lines = []
for fn, http_t, cron_t, is_active in items:
name = fn["name"]
lines.append(SEP)
lines.append(f" {_comment(fn, http_t, cron_t)}")
lines.append(f" name: {name}")
lines.append(f" runtime: {fn.get('runtime', '?')}")
lines.append(f" phase: {fn.get('phase', '?')}")
lines.append(f" active: {'да' if is_active else 'нет'}")
if http_t:
url = f"{ext_url}/fn/{namespace}/{name}" if ext_url else http_t[0].get("url", "")
lines.append(f" url: {url}")
if cron_t:
lines.append(f" cron: {cron_t[0].get('schedule', '?')}")
if fn.get("created_at"):
lines.append(f" created: {fn['created_at']}")
if fn.get("last_built_at"):
lines.append(f" built: {fn['last_built_at']}")
if fn.get("message"):
lines.append(f" message: {fn['message']}")
lines.append(SEP)
lines.append(f" namespace: {namespace} | total: {len(items)}")
lines.append(SEP)
# Возвращаем str — python runtime отдаст text/plain напрямую
return "\n".join(lines) + "\n"
@@ -0,0 +1 @@
requests==2.31.0
+8
View File
@@ -0,0 +1,8 @@
{
"name": "pg-info",
"version": "1.0.0",
"description": "sless nodejs20 function: pg version + table info",
"dependencies": {
"pg": "8.11.0"
}
}
+43
View File
@@ -0,0 +1,43 @@
// 2026-03-18
// pg_info.js — NodeJS-функция: проверка работы JS runtime + чтение мета-данных БД.
// Подключается к PostgreSQL через пакет pg, возвращает версию сервера и счётчик строк.
// Демонстрирует: nodejs20 runtime, npm-зависимость (package.json), PG из JS.
//
// ENV (те же что у python-функций):
// PGHOST, PGPORT, PGDATABASE, PGUSER, PGPASSWORD, PGSSLMODE
//
// Entrypoint: pg_info.info
'use strict';
const { Client } = require('pg');
exports.info = async (event) => {
const client = new Client({
host: process.env.PGHOST,
port: parseInt(process.env.PGPORT || '5432'),
database: process.env.PGDATABASE,
user: process.env.PGUSER,
password: process.env.PGPASSWORD,
// pg-пакет требует явного ssl-объекта; rejectUnauthorized: false — т.к.
// self-signed cert на nubes managed PG, но канал всё равно шифруется.
ssl: process.env.PGSSLMODE === 'require' ? { rejectUnauthorized: false } : false,
});
await client.connect();
try {
const [versionRes, countRes] = await Promise.all([
client.query('SELECT version() AS v'),
client.query('SELECT COUNT(*) AS cnt FROM terraform_demo_table'),
]);
return {
runtime: 'nodejs20',
node_version: process.version,
pg_version: versionRes.rows[0].v,
table_rows: parseInt(countRes.rows[0].cnt, 10),
};
} finally {
await client.end();
}
};
@@ -0,0 +1,3 @@
# 2026-03-17 00:00
# requirements.txt — зависимости для функции запуска SQL.
psycopg2-binary==2.9.9
@@ -0,0 +1,39 @@
# 2026-03-17 00:00
# sql_runner.py — функция для выполнения SQL-операторов из входного события.
import os
import psycopg2
def run_sql(event):
# Выполняет список SQL-операторов в одной транзакции для атомарной инициализации схемы.
# Параметры подключения передаются раздельно, чтобы избежать ошибок парсинга DSN при спецсимволах.
pg_host = os.environ["PGHOST"]
pg_port = os.environ.get("PGPORT", "5432")
pg_database = os.environ["PGDATABASE"]
pg_user = os.environ["PGUSER"]
pg_password = os.environ["PGPASSWORD"]
pg_sslmode = os.environ.get("PGSSLMODE", "require")
statements = event.get("statements", [])
if not statements:
return {"error": "no statements provided"}
connection = psycopg2.connect(
host=pg_host,
port=pg_port,
dbname=pg_database,
user=pg_user,
password=pg_password,
sslmode=pg_sslmode,
)
try:
cursor = connection.cursor()
for statement in statements:
cursor.execute(statement)
connection.commit()
return {"ok": True, "executed": len(statements)}
except Exception as error:
connection.rollback()
return {"error": str(error)}
finally:
connection.close()
@@ -0,0 +1 @@
psycopg2-binary==2.9.9
+130
View File
@@ -0,0 +1,130 @@
# 2026-03-19
# table_rw.py — чтение и запись строк в terraform_demo_table.
# Два entrypoint в одном файле: list_rows (JSON API) и add_row (HTML-страница + POST-обработчик).
# ENV: PGHOST, PGPORT, PGDATABASE, PGUSER, PGPASSWORD, PGSSLMODE
import os
import json
import psycopg2
import psycopg2.extras
def _connect():
return psycopg2.connect(
host=os.environ["PGHOST"],
port=os.environ.get("PGPORT", "5432"),
dbname=os.environ["PGDATABASE"],
user=os.environ["PGUSER"],
password=os.environ["PGPASSWORD"],
sslmode=os.environ.get("PGSSLMODE", "require"),
)
def list_rows(event):
# Возвращает все строки terraform_demo_table, отсортированные по убыванию created_at.
conn = _connect()
try:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"SELECT id, title, created_at::text FROM terraform_demo_table ORDER BY created_at DESC"
)
rows = [dict(r) for r in cur.fetchall()]
return {"rows": rows, "count": len(rows)}
finally:
conn.close()
def _render_page(rows, message=""):
# HTML-страница с формой ввода и таблицей строк.
# message — статус последней операции (успех / ошибка).
rows_html = "".join(
f"<tr><td>{r['id']}</td><td>{r['title']}</td><td>{r['created_at']}</td></tr>"
for r in rows
)
msg_html = f'<p class="msg">{message}</p>' if message else ""
return f"""<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>pg-table-writer</title>
<style>
body {{ font-family: sans-serif; max-width: 700px; margin: 40px auto; background: #111; color: #eee; }}
h1 {{ color: #7dd3fc; }}
form {{ display: flex; gap: 8px; margin-bottom: 24px; }}
input[type=text] {{ flex: 1; padding: 8px 12px; border-radius: 6px; border: 1px solid #444; background: #1e1e1e; color: #eee; font-size: 15px; }}
button {{ padding: 8px 18px; background: #2563eb; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 15px; }}
button:hover {{ background: #1d4ed8; }}
table {{ width: 100%; border-collapse: collapse; }}
th, td {{ padding: 8px 10px; border-bottom: 1px solid #333; text-align: left; }}
th {{ color: #7dd3fc; }}
.msg {{ color: #4ade80; margin-bottom: 12px; }}
</style>
</head>
<body>
<h1>pg-table-writer</h1>
<form method="POST">
<input type="text" name="title" placeholder="Введите строку..." autofocus required>
<button type="submit">Добавить</button>
</form>
{msg_html}
<table>
<thead><tr><th>#</th><th>title</th><th>created_at</th></tr></thead>
<tbody>{rows_html}</tbody>
</table>
</body>
</html>"""
def add_row(event):
# GET → HTML-страница с формой и списком строк.
# POST → вставляет строку из form-поля title или JSON-поля title,
# затем возвращает обновлённую HTML-страницу.
# POST с Content-Type: application/json (curl/API) → возвращает JSON.
method = event.get("_method", "GET")
if method == "GET":
conn = _connect()
try:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT id, title, created_at::text FROM terraform_demo_table ORDER BY created_at DESC")
rows = [dict(r) for r in cur.fetchall()]
finally:
conn.close()
return _render_page(rows)
# POST — вставка строки
# Поле title приходит либо из JSON-тела, либо из application/x-www-form-urlencoded.
# Сервер уже распарсил JSON в event; form-данные приходят как event["body"] = "title=...".
title = event.get("title", "").strip()
if not title:
# Попытка распарсить form-encoded body (браузерная форма)
body = event.get("body", "")
if body.startswith("title="):
from urllib.parse import unquote_plus
title = unquote_plus(body[len("title="):].split("&")[0]).strip()
if not title:
return {"ok": False, "error": "title is required"}
conn = _connect()
try:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"INSERT INTO terraform_demo_table (title) VALUES (%s) RETURNING id, title, created_at::text",
(title,),
)
row = dict(cur.fetchone())
conn.commit()
# Если запрос из браузера (form POST) — возвращаем обновлённую страницу.
# Если из curl/API — возвращаем JSON.
accept = event.get("_accept", "")
if "application/json" in accept:
return {"ok": True, "row": row}
# Перечитываем все строки для обновлённой страницы
cur.execute("SELECT id, title, created_at::text FROM terraform_demo_table ORDER BY created_at DESC")
rows = [dict(r) for r in cur.fetchall()]
return _render_page(rows, message=f"Добавлено: «{row['title']}»")
finally:
conn.close()
+129
View File
@@ -0,0 +1,129 @@
# 2026-03-18 (обновлено: фильтрация SLESS_EXCLUDE, читаемый вывод через "#"-ключ)
# funcs_list.py — HTTP-функция: список всех пользовательских функций с их статусами.
# Вызывает внутренний REST API оператора (ClusterIP, без TLS).
# Объединяет данные функций и триггеров в один ответ; скрывает служебные функции.
#
# Env vars:
# SLESS_API_URL — URL оператора (http://sless-operator.sless.svc.cluster.local:9090)
# SLESS_NAMESPACE — namespace пользователя (sless-{hex16})
# SLESS_TOKEN — JWT токен для /v1/ API
# SLESS_EXTERNAL_URL — публичный базовый URL (https://sless.kube5s.ru), для корректных ссылок
# SLESS_EXCLUDE — comma-separated имена функций, которые не надо показывать
# Пример: "funcs,event-writer,event-monitor,event-cleaner"
#
# Формат вывода: JSON-объект, где каждая функция содержит поле "#" — краткий комментарий.
# При pretty-print (python3 -m json.tool) выглядит как читаемый список с аннотациями.
import os
import requests
def _short_comment(fn, http_triggers, cron_triggers):
"""Генерирует однострочный комментарий-описание функции по её метаданным."""
phase = fn.get("phase", "")
runtime = fn.get("runtime", "")
if http_triggers:
active_str = "активна" if http_triggers[0].get("active") else "неактивна"
return f"HTTP endpoint ({runtime}) — {phase}, {active_str}"
elif cron_triggers:
schedule = cron_triggers[0].get("schedule", "?")
active_str = "активна" if cron_triggers[0].get("active") else "неактивна"
return f"Cron '{schedule}' ({runtime}) — {phase}, {active_str}"
else:
return f"Job/runner без триггера ({runtime}) — {phase}"
def list_all(event):
api_url = os.environ["SLESS_API_URL"].rstrip("/")
namespace = os.environ["SLESS_NAMESPACE"]
token = os.environ["SLESS_TOKEN"]
ext_url = os.environ.get("SLESS_EXTERNAL_URL", "").rstrip("/")
# Имена функций, которые не должны присутствовать в выводе.
# Включает саму себя ("funcs") и служебные функции других примеров.
exclude = {
n.strip()
for n in os.environ.get("SLESS_EXCLUDE", "").split(",")
if n.strip()
}
headers = {"Authorization": f"Bearer {token}"}
fns_resp = requests.get(
f"{api_url}/v1/namespaces/{namespace}/functions",
headers=headers,
timeout=10,
)
fns_resp.raise_for_status()
trs_resp = requests.get(
f"{api_url}/v1/namespaces/{namespace}/triggers",
headers=headers,
timeout=10,
)
trs_resp.raise_for_status()
# Индекс триггеров по имени функции
triggers_by_fn = {}
for tr in trs_resp.json():
fn_name = tr.get("function") or tr.get("functionRef")
if fn_name:
triggers_by_fn.setdefault(fn_name, []).append(tr)
result = []
for fn in fns_resp.json():
name = fn["name"]
if name in exclude:
continue
http_triggers = [
t for t in triggers_by_fn.get(name, []) if t.get("type") == "http"
]
cron_triggers = [
t for t in triggers_by_fn.get(name, []) if t.get("type") == "cron"
]
is_active = any(
t.get("enabled", True) and t.get("active", False)
for t in triggers_by_fn.get(name, [])
)
entry = {
# "#" — первый ключ: служит визуальным комментарием при pretty-print
"#": _short_comment(fn, http_triggers, cron_triggers),
"name": name,
"runtime": fn.get("runtime"),
"phase": fn.get("phase"),
"active": is_active,
}
# URL вычисляем из SLESS_EXTERNAL_URL если задан — state может хранить старый домен
if http_triggers:
if ext_url:
entry["url"] = f"{ext_url}/fn/{namespace}/{name}"
else:
entry["url"] = http_triggers[0].get("url", "")
if cron_triggers:
entry["cron"] = cron_triggers[0].get("schedule", "")
if fn.get("message"):
entry["message"] = fn["message"]
# created_at и last_built_at — доступны после обновления оператора до v0.1.32+
if fn.get("created_at"):
entry["created_at"] = fn["created_at"]
if fn.get("last_built_at"):
entry["last_built_at"] = fn["last_built_at"]
result.append(entry)
# Сортировка: активные вверх, затем по имени
result.sort(key=lambda f: (not f["active"], f["name"]))
return {
"namespace": namespace,
"count": len(result),
"functions": result,
}
+73
View File
@@ -0,0 +1,73 @@
# resource "nubes_lucee" "app1" {
# # Lucee-приложение, зависит от Postgres
# resource_name = "lucy_teststand_0"
# # resource_realm = "k8s-3.ext.nubes.ru"
# resource_realm = nubes_postgres.db2.resource_realm
# # resource_realm = "k8s-4-sandbox-nubes-ru"
# domain = "web-test-stand"
# git_path = "https://gitea-naeel.giteak8s.services.ngcloud.ru/naeel/testlucee"
# json_env = jsonencode({
# # 🔗 Настройки Data Source 'testds' для Lucee (Application.cfc)
# testds_class = "org.postgresql.Driver" # 📂 Драйвер БД
# testds_bundleName = "org.postgresql.jdbc" # 📦 Имя бандла JDBC
# testds_bundleVersion = "42.6.0" # 🔢 Версия драйвера
# testds_connectionString = "jdbc:postgresql://${nubes_postgres.db2.state_out_flat["internalConnect.master"]}:5432/postgres?sslmode=require" # 🚀 Строка подключения
# testds_username = nubes_postgres_user.db2_user.username # 👤 Логин
# testds_password = jsondecode(nubes_postgres.db2.vault_secrets["users"])[nubes_postgres_user.db2_user.username]["password"] # 🔑 Пароль
# testds_connectionLimit = "5" # 🚦 Лимит соединений
# testds_liveTimeout = "15" # ⏳ Таймаут жизни
# testds_validate = "false" # ✅ Валидация при запросе
# })
# resource_c_p_u = 300
# resource_memory = 512
# resource_instances = 1
# app_version = "5.4"
# depends_on = [nubes_postgres.db2]
# }
# resource "nubes_nodejs" "app3" {
# # NodeJS демо, работающий с тем же Postgres.
# resource_name = "node_01"
# resource_realm = nubes_postgres.db2.resource_realm
# domain = "node07"
# git_path = "https://gitea-naeel.giteak8s.services.ngcloud.ru/naeel/testnode.git"
# health_path = "/healthz"
# app_version = "23"
# json_env = jsonencode({
# # Переменные подключения к Postgres.
# PGHOST = nubes_postgres.db2.state_out_flat["internalConnect.master"]
# PGPORT = "5432"
# PGUSER = nubes_postgres_user.db2_user.username
# PGPASSWORD = jsondecode(nubes_postgres.db2.vault_secrets["users"])[nubes_postgres_user.db2_user.username]["password"]
# PGDATABASE = nubes_postgres_database.db2_app.db_name
# PGSSLMODE = "require"
# DATABASE_URL = format(
# "postgresql://%s:%s@%s:5432/%s?sslmode=require",
# nubes_postgres_user.db2_user.username,
# jsondecode(nubes_postgres.db2.vault_secrets["users"])[nubes_postgres_user.db2_user.username]["password"],
# nubes_postgres.db2.state_out_flat["internalConnect.master"],
# nubes_postgres_database.db2_app.db_name
# )
# })
# resource_c_p_u = 300
# resource_memory = 256
# resource_instances = 1
# depends_on = [nubes_postgres.db2]
# }
# output "pg_vault_secrets" {
# value = nubes_postgres.db2.vault_secrets
# sensitive = true
# }
# terraform output -json pg_vault_secrets
+58
View File
@@ -0,0 +1,58 @@
// 2026-03-17 17:05
// main.tf — провайдеры и переменные для Nubes + sless.
terraform {
required_providers {
nubes = {
source = "terra.k8c.ru/nubes/nubes"
version = "5.0.19"
}
sless = {
source = "terra.k8c.ru/naeel/sless"
version = "~> 0.1.18"
}
}
}
variable "api_token" {
type = string
sensitive = true
description = "Nubes API token"
}
variable "s3_uid" {
type = string
sensitive = true
description = "Nubes S3 UID"
}
variable "realm" {
type = string
sensitive = true
description = "resource_realm parameter for nubes_postgres resource"
}
// 2026-03-18 — pg_user/pg_password помечены optional (default="") для сверки.
// Реальные credentials берутся из vault_secrets через locals в resources.tf.
variable "pg_user" {
type = string
sensitive = true
default = ""
description = "Только для сверки. Реальный username из nubes_postgres_user.pg_user.username. Должен совпадать с vault."
}
variable "pg_password" {
type = string
sensitive = true
default = ""
description = "Только для сверки. Реальный пароль из vault_secrets. Должен совпадать с tfvars."
}
provider "nubes" {
api_token = var.api_token
api_endpoint = "https://deck-test.ngcloud.ru/api/v1/index.cfm"
}
provider "sless" {
endpoint = "https://sless.kube5s.ru"
token = var.api_token
nubes_endpoint = "https://deck-test.ngcloud.ru/api/v1"
}
+196
View File
@@ -0,0 +1,196 @@
// 2026-03-18 — добавлены locals для извлечения credentials из vault_secrets (без хардкода).
// Для сверки хардкод остаётся в terraform.tfvars на этапе разработки.
// sless_function и sless_job закомментированы — сначала проверяется сетевое соединение.
# Актуальные credentials из vault_secrets (authoritatively) — vault синхронизирован с кластером.
# Структура vault_secrets["users"]: JSON-строка {"username": {"password": "...", "username": "..."}}
locals {
pg_creds_map = jsondecode(nubes_postgres.npg.vault_secrets["users"])
pg_username = nubes_postgres_user.pg_user.username
pg_password = local.pg_creds_map[local.pg_username]["password"]
pg_host = nubes_postgres.npg.state_out_flat["internalConnect.master"]
pg_database = nubes_postgres_database.db.db_name
}
resource "nubes_postgres" "npg" {
resource_name = "testnarod-pg-0"
# 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 = 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_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
}
+51
View File
@@ -0,0 +1,51 @@
# 2026-03-18 — debug pod для проверки psql-соединения из namespace функций.
# Запускается разово. Подключается к тому же postgres, что и sless_function.
# kubectl apply -f /tmp/pg-debug-pod.yaml
# kubectl logs -n sless-fn-sless-ffd1f598c169b0ae pg-debug-pod
apiVersion: v1
kind: Pod
metadata:
name: pg-debug-pod
namespace: sless-fn-sless-ffd1f598c169b0ae
labels:
purpose: debug-postgres-connectivity
spec:
restartPolicy: Never
containers:
- name: psql
image: postgres:17-alpine
command:
- sh
- -c
- |
echo "=== Testing TCP connectivity to postgres ==="
nc -zv -w5 $PGHOST 5432 && echo "TCP OK" || echo "TCP FAILED"
echo ""
echo "=== Testing psql connection ==="
PGCONNECT_TIMEOUT=10 psql \
"host=$PGHOST port=$PGPORT dbname=$PGDATABASE user=$PGUSER sslmode=$PGSSLMODE" \
--command="SELECT current_user, current_database(), version();" \
2>&1
echo ""
echo "=== Listing tables ==="
PGCONNECT_TIMEOUT=10 psql \
"host=$PGHOST port=$PGPORT dbname=$PGDATABASE user=$PGUSER sslmode=$PGSSLMODE" \
--command="\dt" \
2>&1
env:
- name: PGHOST
value: "postgresqlk8s-master.36875359-dcea-48c4-a593-b4531f20fe96.svc.cluster.local"
- name: PGPORT
value: "5432"
- name: PGDATABASE
value: "db_terra"
- name: PGUSER
value: "u-user0"
- name: PGPASSWORD
# Актуальный пароль из vault_secrets (совпадает с tfvars.pg_password на 2026-03-18)
value: "M03O6fRsngWcVHB2YGivyLfbfxoii2R21nyh2A2r7WSZS5deLwBgLKkc9Wk24Zyl"
- name: PGSSLMODE
value: "require"
@@ -0,0 +1,40 @@
# 2026-03-17 13:05
# read_pg_user_secret.py — читает пароль пользователя managed PostgreSQL из k8s Secret.
# Используется из Terraform external data source, чтобы apply сам получал актуальный пароль
# даже для уже существующего пользователя, созданного вне текущего state.
import base64
import json
import subprocess
import sys
def main():
# Читаем query от Terraform external provider из stdin.
query = json.load(sys.stdin)
namespace = query["namespace"]
secret_name = query["secret"]
# kubectl уже настроен на удалённой машине; читаем ровно поле data.password.
result = subprocess.run(
[
"kubectl",
"get",
"secret",
"-n",
namespace,
secret_name,
"-o",
"jsonpath={.data.password}",
],
check=True,
capture_output=True,
text=True,
)
password = base64.b64decode(result.stdout.strip()).decode()
json.dump({"password": password}, sys.stdout)
if __name__ == "__main__":
main()
@@ -0,0 +1,45 @@
# Изменено: 2026-03-14
# Функция event-cleaner: удаляет N самых старых строк из таблицы events.
# Вызывается через HTTP POST из Node-RED (который слушает RabbitMQ).
# Env: POSTGRES_DSN — строка подключения к PostgreSQL.
import os
import json
import psycopg2
def handle(request):
"""Удаляет N старейших строк из таблицы events."""
dsn = os.environ["POSTGRES_DSN"]
body = {}
if request.get_data():
try:
body = json.loads(request.get_data())
except Exception:
pass
# Количество строк для удаления — из тела запроса или дефолт 10
delete_n = int(body.get("delete_n", 10))
# Защита от случайного удаления слишком большого количества строк
delete_n = min(delete_n, 100)
conn = psycopg2.connect(dsn)
try:
with conn.cursor() as cur:
cur.execute("""
DELETE FROM events
WHERE id IN (
SELECT id FROM events ORDER BY created_at ASC LIMIT %s
)
""", (delete_n,))
deleted = cur.rowcount
cur.execute("SELECT COUNT(*) FROM events")
remaining = cur.fetchone()[0]
conn.commit()
return json.dumps({
"ok": True,
"deleted": deleted,
"remaining": remaining
}), 200, {"Content-Type": "application/json"}
finally:
conn.close()
@@ -0,0 +1,47 @@
# Изменено: 2026-03-14
# Функция event-monitor: считает строки в events.
# Если больше 50 — публикует сообщение в RabbitMQ queue "cleanup-needed".
# Запускается по cron (каждую минуту).
# Env:
# POSTGRES_DSN — строка подключения к PostgreSQL
# RABBITMQ_URL — amqp://sless:sless123@rabbitmq.sless.svc.cluster.local:5672/
import os
import json
import psycopg2
import pika
THRESHOLD = 50
def handle(request):
"""Мониторит таблицу events. При переполнении шлёт в RabbitMQ."""
dsn = os.environ["POSTGRES_DSN"]
rabbit_url = os.environ["RABBITMQ_URL"]
conn = psycopg2.connect(dsn)
try:
with conn.cursor() as cur:
# Считаем количество событий
cur.execute("SELECT COUNT(*) FROM events")
count = cur.fetchone()[0]
finally:
conn.close()
result = {"count": count, "threshold": THRESHOLD, "action": "none"}
if count > THRESHOLD:
# Публикуем в очередь — event-cleaner получит и удалит старые строки
params = pika.URLParameters(rabbit_url)
connection = pika.BlockingConnection(params)
channel = connection.channel()
channel.queue_declare(queue="cleanup-needed", durable=True)
channel.basic_publish(
exchange="",
routing_key="cleanup-needed",
body=json.dumps({"count": count, "delete_n": 10}),
properties=pika.BasicProperties(delivery_mode=2) # persistent
)
connection.close()
result["action"] = "cleanup_requested"
return json.dumps(result), 200, {"Content-Type": "application/json"}
@@ -0,0 +1,49 @@
# Изменено: 2026-03-14
# Функция event-writer: принимает HTTP POST, пишет одну строку в таблицу events.
# Таблица создаётся автоматически при первом запуске.
# Env: POSTGRES_DSN — строка подключения к PostgreSQL.
import os
import json
import psycopg2
from datetime import datetime, timezone
def handle(request):
"""Записывает одно событие в таблицу events."""
dsn = os.environ["POSTGRES_DSN"]
body = {}
if request.get_data():
try:
body = json.loads(request.get_data())
except Exception:
pass
source = body.get("source", "node-red")
message = body.get("message", "ping")
conn = psycopg2.connect(dsn)
try:
with conn.cursor() as cur:
# Создаём таблицу если нет — безопасно вызывать при каждом запросе
cur.execute("""
CREATE TABLE IF NOT EXISTS events (
id SERIAL PRIMARY KEY,
source VARCHAR(100) NOT NULL DEFAULT 'unknown',
message TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
""")
cur.execute(
"INSERT INTO events (source, message) VALUES (%s, %s) RETURNING id, created_at",
(source, message)
)
row = cur.fetchone()
conn.commit()
return json.dumps({
"ok": True,
"id": row[0],
"created_at": row[1].isoformat()
}), 200, {"Content-Type": "application/json"}
finally:
conn.close()
+194
View File
@@ -0,0 +1,194 @@
# Примеры использования sless
## Обзор платформы
**sless** — система управления serverless-функциями на базе Kubernetes. Разработчик загружает код функции, платформа собирает из него Docker-образ, разворачивает его в кластере и предоставляет HTTP-эндпоинт для вызова. Всё описывается декларативно через Terraform.
### Основные ресурсы провайдера
| Ресурс | Назначение |
|---|---|
| `sless_function` | Описывает функцию: язык, точку входа, лимиты, переменные окружения. При создании загружает код и запускает его сборку в образ. Сама по себе недоступна снаружи — нужен триггер или задание. |
| `sless_trigger` | Публикует функцию: тип `http` создаёт публичный URL, тип `cron` — запуск по расписанию. |
| `sless_job` | Запускает функцию однократно и ожидает завершения. Используется для одноразовых операций: инициализация БД, миграции, пакетная обработка. |
Стандартная связка для HTTP API: `sless_function` + `sless_trigger` с `type = "http"` — в результате функция доступна по URL вида `https://sless.kube5s.ru/fn/<namespace>/<имя-функции>`.
---
## Требования
- Terraform >= 1.0
- JWT-токен для аутентификации в sless API
- Доступ к `https://sless.kube5s.ru`
## Конфигурация провайдера
Во всех примерах файл `main.tf` содержит блок провайдера. Токен передаётся через переменную, значение которой задаётся в `terraform.tfvars`:
```hcl
provider "sless" {
endpoint = "https://sless.kube5s.ru"
token = var.token
nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1"
}
```
Namespace функций вычисляется автоматически из JWT-токена: `sless-{sha256[:8]}`.
> **Перед запуском любого примера** откройте файл `terraform.tfvars` в директории примера и впишите свой токен Nubes API:
> ```hcl
> token = "ваш токен Nubes API"
> ```
> Токен выдаётся в личном кабинете Nubes. Файл `terraform.tfvars` добавлен в `.gitignore` — он не попадёт в репозиторий.
---
## Примеры
### `hello-node` — минимальный пример на Node.js
Две независимые функции: HTTP-функция, возвращающая приветствие, и одноразовое задание, суммирующее набор чисел. Хорошая отправная точка для знакомства с платформой.
```bash
cd hello-node
terraform init
terraform apply -auto-approve
# Вызов HTTP-функции с передачей имени:
curl -s -X POST https://sless.kube5s.ru/fn/<namespace>/hello-http \
-H 'Content-Type: application/json' -d '{"name":"World"}'
# Результат задания:
terraform output job_message
```
---
### `hello-go` — минимальный пример на Go 1.23
Аналог `hello-node`, но на Go. Демонстрирует поддержку Go-рантайма: HTTP-функция и одноразовое задание. Код пользователя оформляется как пакет `handler` с функцией `Handle(event)`.
```bash
cd hello-go
terraform init
terraform apply -auto-approve
terraform output job_message
terraform output trigger_url
```
---
### `pg-list-python` — выборка данных из PostgreSQL (Python)
Минимальный пример работы с базой данных: одна HTTP-функция читает список записей из таблицы PostgreSQL и возвращает их в JSON. Таблица с тестовыми данными создаётся автоматически при первом вызове. Нет заданий, нет инициализации — только функция и триггер.
**Переменные:**
| Переменная | Описание | Значение по умолчанию |
|---|---|---|
| `pg_dsn` | Строка подключения к PostgreSQL | `postgres://sless:sless-pg-password@postgres.sless.svc.cluster.local:5432/sless?sslmode=disable` |
```bash
cd pg-list-python
terraform init
terraform apply -auto-approve
# URL функции выводится после применения:
terraform output catalog_url
# Запрос к функции:
curl -s $(terraform output -raw catalog_url)
```
---
### `simple-python` — одноразовое задание передаёт данные в HTTP-функцию (Python)
При `apply` выполняется задание, которое фиксирует текущее время. Результат передаётся в HTTP-функцию через переменные окружения и отображается при каждом запросе.
```bash
cd simple-python
terraform init
terraform apply -auto-approve
terraform output job_result
curl -s https://sless.kube5s.ru/fn/<namespace>/simple-py-time-display
```
---
### `simple-node` — то же самое на Node.js 20
```bash
cd simple-node
terraform init
terraform apply -auto-approve
terraform output job_result
curl -s https://sless.kube5s.ru/fn/<namespace>/simple-node-time-display
```
---
### `notes-python` — CRUD API на Python с PostgreSQL
Полноценное приложение: инициализация схемы базы данных через задания, CRUD-функция для работы с записями, отдельная функция для получения списка.
**Переменные:**
| Переменная | Описание | Значение по умолчанию |
|---|---|---|
| `pg_dsn` | Строка подключения к PostgreSQL | `postgres://sless:sless-pg-password@postgres.sless.svc.cluster.local:5432/sless?sslmode=disable` |
```bash
cd notes-python
terraform init
terraform apply -auto-approve
# Статус инициализации базы данных:
terraform output db_init_table_status
terraform output db_init_index_status
# Создать запись:
curl -s -X POST "$(terraform output -raw notes_url)/add?title=Hello&body=World"
# Получить список записей:
curl -s $(terraform output -raw notes_list_url)
# Обновить запись (id из предыдущего ответа):
curl -s -X POST "$(terraform output -raw notes_url)/update?id=1&title=Updated&body=New+body"
# Удалить запись:
curl -s -X POST "$(terraform output -raw notes_url)/delete?id=1"
```
---
## Полезные команды
```bash
# Посмотреть текущее состояние задеплоенных ресурсов:
terraform show
# Принудительно пересобрать функцию (например, после изменения кода):
terraform apply -replace=sless_function.<имя> -auto-approve
# Повторно запустить задание: увеличить значение run_id в .tf-файле, затем:
terraform apply -auto-approve
# Удалить все ресурсы примера:
terraform destroy -auto-approve
```
## Структура примера
```
<пример>/
├── main.tf — конфигурация провайдера
├── *.tf — ресурсы: функции, триггеры, задания
├── variables.tf — входные переменные
├── terraform.tfvars — значения переменных (не коммитится в git)
└── code/ — исходный код функций
```
@@ -0,0 +1,26 @@
# Изменено: 2026-03-14
# event-cleaner: HTTP функция для демонстрации контролируемого изменения логики.
# Работает без внешних пакетов.
import json
from datetime import datetime, timezone
def event_cleaner_handle(event_cleaner_event):
"""Принимает delete_n и возвращает подтверждение обработки."""
event_cleaner_payload = event_cleaner_event if isinstance(event_cleaner_event, dict) else {}
event_cleaner_delete_n = int(event_cleaner_payload.get("delete_n", 10))
event_cleaner_delete_n = max(1, min(event_cleaner_delete_n, 100))
event_cleaner_generated_at = datetime.now(timezone.utc).isoformat()
# Здесь intentionally имитируем очистку, чтобы показать реакцию на входные параметры.
return json.dumps(
{
"ok": True,
"accepted_delete_n": event_cleaner_delete_n,
"status": "simulated-cleanup",
"generated_at": event_cleaner_generated_at,
}
), 200, {"Content-Type": "application/json"}
@@ -0,0 +1,25 @@
# Изменено: 2026-03-14
# event-monitor: cron-функция для демонстрации расписания и управления кодом.
# Работает без внешних библиотек и возвращает диагностический JSON.
import json
import os
from datetime import datetime, timezone
EVENT_MONITOR_THRESHOLD = 50
def event_monitor_handle(event_monitor_event):
"""Отдаёт heartbeat для cron-запуска и видимой проверки после apply."""
event_monitor_rabbitmq_url = os.environ.get("RABBITMQ_URL", "not-set")
event_monitor_generated_at = datetime.now(timezone.utc).isoformat()
return json.dumps(
{
"ok": True,
"monitor": "alive",
"threshold": EVENT_MONITOR_THRESHOLD,
"rabbitmq_configured": event_monitor_rabbitmq_url != "not-set",
"generated_at": event_monitor_generated_at,
}
), 200, {"Content-Type": "application/json"}
@@ -0,0 +1,28 @@
# Изменено: 2026-03-14
# event-writer: простая HTTP функция без внешних зависимостей.
# Правка поля response_tag в коде сразу видна в ответе после terraform apply.
import json
import os
from datetime import datetime, timezone
def event_writer_handle(event_writer_event):
"""Возвращает полезный JSON-ответ для визуальной проверки выката кода."""
writer_default_message = os.environ.get("DEFAULT_MESSAGE", "writer-default")
writer_payload = event_writer_event if isinstance(event_writer_event, dict) else {}
writer_message_value = writer_payload.get("message", writer_default_message)
writer_source_name = writer_payload.get("source", "event-writer")
writer_generated_at = datetime.now(timezone.utc).isoformat()
# response_tag удобно менять для демонстрации hot-update кода через terraform apply.
writer_response_tag = "writer-v2"
return {
"ok": True,
"source": writer_source_name,
"message": writer_message_value,
"response_tag": writer_response_tag,
"generated_at": writer_generated_at,
}
@@ -0,0 +1,77 @@
# 2026-03-14
# function.tf — ресурсы managed serverless функций для демонстрации.
# Пользователь правит код в code/* и запускает terraform apply — провайдер сам пересобирает и выкатывает функции.
resource "sless_function" "event_writer" {
name = "event-writer"
runtime = "python3.11"
entrypoint = "event_writer_handler.event_writer_handle"
memory_mb = 128
timeout_sec = 20
source_dir = "${path.module}/code/event-writer"
env_vars = {
POSTGRES_DSN = var.pg_dsn
DEFAULT_MESSAGE = var.writer_message
}
}
resource "sless_trigger" "event_writer_http" {
name = "event-writer-http"
type = "http"
function = sless_function.event_writer.name
enabled = true
}
resource "sless_function" "event_monitor" {
name = "event-monitor"
runtime = "python3.11"
entrypoint = "event_monitor_handler.event_monitor_handle"
memory_mb = 128
timeout_sec = 20
source_dir = "${path.module}/code/event-monitor"
env_vars = {
POSTGRES_DSN = var.pg_dsn
RABBITMQ_URL = var.rabbitmq_url
}
}
resource "sless_trigger" "event_monitor_cron" {
name = "event-monitor-cron"
type = "cron"
function = sless_function.event_monitor.name
enabled = true
schedule = "*/1 * * * *"
}
resource "sless_function" "event_cleaner" {
name = "event-cleaner"
runtime = "python3.11"
entrypoint = "event_cleaner_handler.event_cleaner_handle"
memory_mb = 128
timeout_sec = 20
source_dir = "${path.module}/code/event-cleaner"
env_vars = {
POSTGRES_DSN = var.pg_dsn
}
}
resource "sless_trigger" "event_cleaner_http" {
name = "event-cleaner-http"
type = "http"
function = sless_function.event_cleaner.name
enabled = true
}
output "event_writer_url" {
value = sless_trigger.event_writer_http.url
}
output "event_cleaner_url" {
value = sless_trigger.event_cleaner_http.url
}
+18
View File
@@ -0,0 +1,18 @@
# 2026-03-14
# Terraform demo: managed serverless functions.
# Здесь управляем ТОЛЬКО функциями/триггерами, внешние сервисы считаем уже поднятыми.
terraform {
required_providers {
sless = {
source = "terra.k8c.ru/naeel/sless"
version = "~> 0.1.18"
}
}
}
provider "sless" {
endpoint = var.sless_endpoint
token = var.token
nubes_endpoint = var.nubes_endpoint
}
@@ -0,0 +1,11 @@
# 2026-03-14
# Скопируй в terraform.tfvars и подставь актуальный токен.
token = "PUT_TOKEN_HERE"
sless_endpoint = "https://sless-api.kube5s.ru"
nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1"
pg_dsn = "postgres://sless:sless-pg-password@postgres.sless.svc.cluster.local:5432/sless?sslmode=disable"
rabbitmq_url = "amqp://sless:sless123@rabbitmq.sless.svc.cluster.local:5672/"
writer_message = "writer-default-v1"
@@ -0,0 +1,38 @@
# 2026-03-14
# Переменные для demo-managed-functions.
variable "token" {
description = "JWT токен API"
type = string
sensitive = true
}
variable "sless_endpoint" {
description = "Endpoint sless API"
type = string
default = "https://sless.kube5s.ru"
}
variable "nubes_endpoint" {
description = "Nubes endpoint (нужен провайдеру)"
type = string
default = "https://deck-test.ngcloud.ru/api/v1"
}
variable "pg_dsn" {
description = "PostgreSQL DSN для функций"
type = string
default = "postgres://sless:sless-pg-password@postgres.sless.svc.cluster.local:5432/sless?sslmode=disable"
}
variable "rabbitmq_url" {
description = "RabbitMQ URL для функций"
type = string
default = "amqp://sless:sless123@rabbitmq.sless.svc.cluster.local:5672/"
}
variable "writer_message" {
description = "Сообщение по умолчанию, которое пишет event-writer"
type = string
default = "writer-default-v1"
}
+3 -3
View File
@@ -5,13 +5,13 @@ terraform {
required_providers {
sless = {
source = "terra.k8c.ru/naeel/sless"
version = "~> 0.1.16"
version = "~> 0.1.18"
}
}
}
provider "sless" {
endpoint = "https://sless-api.kube5s.ru"
endpoint = "https://sless.kube5s.ru"
token = var.token
nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1"
nubes_endpoint = "https://deck-test.ngcloud.ru/api/v1"
}
+3 -3
View File
@@ -11,14 +11,14 @@ terraform {
required_providers {
sless = {
source = "terra.k8c.ru/naeel/sless"
version = "~> 0.1.16"
version = "~> 0.1.18"
}
}
}
provider "sless" {
endpoint = "https://sless-api.kube5s.ru"
endpoint = "https://sless.kube5s.ru"
token = var.token
nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1"
nubes_endpoint = "https://deck-test.ngcloud.ru/api/v1"
}
+3 -3
View File
@@ -14,14 +14,14 @@ terraform {
# Провайдер для управления serverless функциями через sless API
sless = {
source = "terra.k8c.ru/naeel/sless"
version = "~> 0.1.16"
version = "~> 0.1.18"
}
}
}
# sless провайдер подключается к API кластера.
provider "sless" {
endpoint = "https://sless-api.kube5s.ru"
endpoint = "https://sless.kube5s.ru"
token = var.token
nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1"
nubes_endpoint = "https://deck-test.ngcloud.ru/api/v1"
}
+3 -3
View File
@@ -5,13 +5,13 @@ terraform {
required_providers {
sless = {
source = "terra.k8c.ru/naeel/sless"
version = "~> 0.1.16"
version = "~> 0.1.18"
}
}
}
provider "sless" {
endpoint = "https://sless-api.kube5s.ru"
endpoint = "https://sless.kube5s.ru"
token = var.token
nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1"
nubes_endpoint = "https://deck-test.ngcloud.ru/api/v1"
}
+3 -3
View File
@@ -19,13 +19,13 @@ terraform {
required_providers {
sless = {
source = "terra.k8c.ru/naeel/sless"
version = "~> 0.1.16"
version = "~> 0.1.18"
}
}
}
provider "sless" {
endpoint = "https://sless-api.kube5s.ru"
endpoint = "https://sless.kube5s.ru"
token = var.token
nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1"
nubes_endpoint = "https://deck-test.ngcloud.ru/api/v1"
}
+3 -3
View File
@@ -18,13 +18,13 @@ terraform {
required_providers {
sless = {
source = "terra.k8c.ru/naeel/sless"
version = "~> 0.1.16"
version = "~> 0.1.18"
}
}
}
provider "sless" {
endpoint = "https://sless-api.kube5s.ru"
endpoint = "https://sless.kube5s.ru"
token = var.token
nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1"
nubes_endpoint = "https://deck-test.ngcloud.ru/api/v1"
}
+1
View File
@@ -54,6 +54,7 @@ require (
github.com/prometheus/client_model v0.3.0 // indirect
github.com/prometheus/common v0.37.0 // indirect
github.com/prometheus/procfs v0.8.0 // indirect
github.com/rabbitmq/amqp091-go v1.10.0 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/tinylib/msgp v1.6.1 // indirect
+3
View File
@@ -270,6 +270,8 @@ github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1
github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5mo=
github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4=
github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
@@ -305,6 +307,7 @@ go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI=
+41 -20
View File
@@ -1,4 +1,4 @@
// Изменено: 2026-03-11
// Изменено: 2026-03-18 (добавлены created_at, last_built_at в functionResponse и fnToResponse)
// functions.go — CRUD handlers для Function CRD.
// Принимает JSON, создаёт/обновляет/удаляет k8s ресурсы Function.
// Namespace берётся из URL: /v1/namespaces/{namespace}/functions/{name}
@@ -30,23 +30,29 @@ type functionRequest struct {
// functionResponse — ответ при чтении функции.
type functionResponse 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.FunctionPhase `json:"phase"`
ImageRef string `json:"image_ref"`
Message string `json:"message,omitempty"`
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.FunctionPhase `json:"phase"`
ImageRef string `json:"image_ref"`
Message string `json:"message,omitempty"`
// CreatedAt — время создания CRD объекта (metadata.creationTimestamp).
// Пустое значение = "0001-01-01T00:00:00Z" сериализуется в "", опускаем через omitempty.
CreatedAt string `json:"created_at,omitempty"`
// LastBuiltAt — время последней успешной сборки образа (status.lastBuiltAt).
// nil если сборки ещё не было.
LastBuiltAt string `json:"last_built_at,omitempty"`
}
// fnToResponse конвертирует CRD в ответ API.
func fnToResponse(fn *slessv1alpha1.Function) functionResponse {
return functionResponse{
resp := functionResponse{
Name: fn.Name,
Namespace: fn.Namespace,
Runtime: fn.Spec.Runtime,
@@ -60,6 +66,14 @@ func fnToResponse(fn *slessv1alpha1.Function) functionResponse {
ImageRef: fn.Status.ImageRef,
Message: fn.Status.Message,
}
// creationTimestamp — всегда заполнен k8s, но zero value опускаем.
if !fn.CreationTimestamp.IsZero() {
resp.CreatedAt = fn.CreationTimestamp.UTC().Format("2006-01-02 15:04:05 UTC")
}
if fn.Status.LastBuiltAt != nil && !fn.Status.LastBuiltAt.IsZero() {
resp.LastBuiltAt = fn.Status.LastBuiltAt.UTC().Format("2006-01-02 15:04:05 UTC")
}
return resp
}
// ListFunctions — GET /v1/namespaces/{namespace}/functions
@@ -115,13 +129,20 @@ func (h *Handler) CreateFunction(w http.ResponseWriter, r *http.Request) {
}
if err := h.K8s.Create(r.Context(), fn); err != nil {
if errors.IsAlreadyExists(err) {
// Если существующая функция в статусе Failed (build провалился, terraform не
// добавил её в state) — удаляем её и пересоздаём, иначе клиент получит 409 навсегда.
// IsAlreadyExists может прийти из кеша controller-runtime (split-brain):
// объект удалён из etcd, но кеш informer ещё южив. Делаем uncached Get:
// если реально NotFound — кеш устарел, пересоздаём.
// если существует и фаза Failed — тоже пересоздаём (build провалился, терраформ не добавил в state).
// если существует и фаза Ready/Building — возвращаем 409 (функция реально есть).
existing := &slessv1alpha1.Function{}
if getErr := h.K8s.Get(r.Context(), client.ObjectKey{Name: req.Name, Namespace: ns}, existing); getErr == nil &&
existing.Status.Phase == slessv1alpha1.FunctionPhaseFailed {
_ = h.K8s.Delete(r.Context(), existing)
// Создаём заново с теми же параметрами
getErr := h.K8s.Get(r.Context(), client.ObjectKey{Name: req.Name, Namespace: ns}, existing)
shouldRecreate := errors.IsNotFound(getErr) ||
(getErr == nil && existing.Status.Phase == slessv1alpha1.FunctionPhaseFailed)
if shouldRecreate {
if getErr == nil {
_ = h.K8s.Delete(r.Context(), existing)
}
// Сбрасываем ResourceVersion — при split-brain etcd считает объект новым
fn.ResourceVersion = ""
if createErr := h.K8s.Create(r.Context(), fn); createErr != nil {
writeJSON(w, http.StatusInternalServerError, errResp(createErr.Error()))
+99 -75
View File
@@ -1,4 +1,4 @@
// Изменено: 2026-03-11
// Изменено: 2026-03-12
// invoke.go — прокси-обработчик для вызова HTTP-триггеров функций.
// Маршрут: ANY /fn/{namespace}/{name} и /fn/{namespace}/{name}/**
// Не защищён auth-токеном — это публичный эндпоинт для вызова функций.
@@ -6,96 +6,120 @@
// http://{name}.sless-fn-{namespace}.svc.cluster.local:8080
// Sub-path и query string пробрасываются как есть:
// /fn/ns/notes/add?title=x → http://notes.sless-fn-ns.svc.../add?title=x
// Таймаут берётся из Spec.TimeoutSec функции (+ 5s буфер) чтобы не резать
// длительные вызовы (stress-тесты, batch-задачи).
package handler
import (
"fmt"
"io"
"net/http"
"strings"
"time"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/gorilla/mux"
"github.com/gorilla/mux"
slessv1alpha1 "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/api/v1alpha1"
"sigs.k8s.io/controller-runtime/pkg/client"
)
// httpClient используется для обращения к функциям внутри кластера.
// Таймаут 30s — достаточно для холодного старта функции.
var httpClient = &http.Client{Timeout: 30 * time.Second}
// hopByHopHeaders — заголовки которые нельзя пробрасывать через прокси (RFC 2616 §13.5.1).
// Они управляют соединением между двумя узлами, а не end-to-end.
// Особо опасен Transfer-Encoding: если пробросить его, клиент неверно интерпретирует тело.
var hopByHopHeaders = map[string]bool{
"Connection": true,
"Keep-Alive": true,
"Proxy-Authenticate": true,
"Proxy-Authorization": true,
"Te": true,
"Trailers": true,
"Transfer-Encoding": true,
"Upgrade": true,
"Connection": true,
"Keep-Alive": true,
"Proxy-Authenticate": true,
"Proxy-Authorization": true,
"Te": true,
"Trailers": true,
"Transfer-Encoding": true,
"Upgrade": true,
}
// invokeHTTPClient создаёт http.Client с таймаутом под конкретный вызов.
// timeout = TimeoutSec функции + 5s буфер на сетевые задержки.
// Если TimeoutSec == 0 (не задан), используем 30s по умолчанию.
func invokeHTTPClient(timeoutSec int32) *http.Client {
t := time.Duration(timeoutSec)*time.Second + 5*time.Second
if timeoutSec <= 0 {
t = 30 * time.Second
}
return &http.Client{Timeout: t}
}
// InvokeFunction проксирует входящий запрос к Service функции в кластере.
// Namespace выбирается из пути, имя функции — тоже из пути.
// Сохраняет метод, тело, Content-Type, sub-path и query string.
// Таймаут прокси-клиента = Spec.TimeoutSec функции + 5s (резинка).
func (h *Handler) InvokeFunction(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
ns := vars["namespace"]
name := vars["name"]
vars := mux.Vars(r)
ns := vars["namespace"]
name := vars["name"]
// Вычисляем sub-path после /fn/{namespace}/{name}
// Например: /fn/default/notes/add → subPath = /add
prefix := "/fn/" + ns + "/" + name
subPath := strings.TrimPrefix(r.URL.Path, prefix)
// Внутренний URL к Service функции (DNS внутри кластера)
target := fmt.Sprintf("http://%s.sless-fn-%s.svc.cluster.local:8080%s", name, ns, subPath)
// Пробрасываем query string если есть
if r.URL.RawQuery != "" {
target += "?" + r.URL.RawQuery
}
// Создаём проксируемый запрос с тем же методом и телом
proxyReq, err := http.NewRequestWithContext(r.Context(), r.Method, target, r.Body)
if err != nil {
h.Log.Error("invoke: failed to create proxy request", "err", err, "ns", ns, "fn", name)
writeJSON(w, http.StatusInternalServerError, errResp("failed to create proxy request"))
return
}
// Пробрасываем Content-Type если есть
if ct := r.Header.Get("Content-Type"); ct != "" {
proxyReq.Header.Set("Content-Type", ct)
}
resp, err := httpClient.Do(proxyReq)
if err != nil {
// "no such host" — Service не существует (функция удалена), возвращаем 404.
// Это отличается от временной сетевой ошибки: NXDOMAIN строго означает отсутствие записи.
if strings.Contains(err.Error(), "no such host") {
writeJSON(w, http.StatusNotFound, errResp("function not found"))
return
}
h.Log.Error("invoke: function unreachable", "err", err, "ns", ns, "fn", name, "target", target)
writeJSON(w, http.StatusBadGateway, errResp("function unreachable: "+err.Error()))
return
}
defer resp.Body.Close()
// Копируем заголовки и статус из ответа функции.
// Hop-by-hop заголовки фильтруем: они управляют конкретным TCP-соединением
// и не должны пробрасываться через прокси (RFC 2616 §13.5.1).
for k, vals := range resp.Header {
if hopByHopHeaders[k] {
continue
}
for _, v := range vals {
w.Header().Add(k, v)
}
}
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
// Смотрим TimeoutSec из Function CRD, чтобы не резать длительные вызовы.
// Если функция не найдена — продолжаем с дефолтным таймаутом (30s).
var timeoutSec int32
fn := &slessv1alpha1.Function{}
if err := h.K8s.Get(r.Context(), client.ObjectKey{Name: name, Namespace: ns}, fn); err == nil {
timeoutSec = fn.Spec.TimeoutSec
}
httpClient := invokeHTTPClient(timeoutSec)
// Вычисляем sub-path после /fn/{namespace}/{name}
// Например: /fn/default/notes/add → subPath = /add
prefix := "/fn/" + ns + "/" + name
subPath := strings.TrimPrefix(r.URL.Path, prefix)
// Внутренний URL к Service функции (DNS внутри кластера)
target := fmt.Sprintf("http://%s.sless-fn-%s.svc.cluster.local:8080%s", name, ns, subPath)
// Пробрасываем query string если есть
if r.URL.RawQuery != "" {
target += "?" + r.URL.RawQuery
}
// Создаём проксируемый запрос с тем же методом и телом
proxyReq, err := http.NewRequestWithContext(r.Context(), r.Method, target, r.Body)
if err != nil {
h.Log.Error("invoke: failed to create proxy request", "err", err, "ns", ns, "fn", name)
writeJSON(w, http.StatusInternalServerError, errResp("failed to create proxy request"))
return
}
// Пробрасываем Content-Type и Content-Length если есть.
// Content-Length обязателен: Python BaseHTTPRequestHandler читает тело
// ровно столько байт, сколько указано в заголовке; без него body = пусто.
if ct := r.Header.Get("Content-Type"); ct != "" {
proxyReq.Header.Set("Content-Type", ct)
}
proxyReq.ContentLength = r.ContentLength
resp, err := httpClient.Do(proxyReq)
if err != nil {
// "no such host" — Service не существует (функция удалена), возвращаем 404.
// Это отличается от временной сетевой ошибки: NXDOMAIN строго означает отсутствие записи.
if strings.Contains(err.Error(), "no such host") {
writeJSON(w, http.StatusNotFound, errResp("function not found"))
return
}
h.Log.Error("invoke: function unreachable", "err", err, "ns", ns, "fn", name, "target", target)
writeJSON(w, http.StatusBadGateway, errResp("function unreachable: "+err.Error()))
return
}
defer resp.Body.Close()
// Копируем заголовки и статус из ответа функции.
// Hop-by-hop заголовки фильтруем: они управляют конкретным TCP-соединением
// и не должны пробрасываться через прокси (RFC 2616 §13.5.1).
for k, vals := range resp.Header {
if hopByHopHeaders[k] {
continue
}
for _, v := range vals {
w.Header().Add(k, v)
}
}
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
}
+119
View File
@@ -0,0 +1,119 @@
// Создано: 2026-04-25
// source.go — handler для GET /v1/namespaces/{namespace}/functions/{name}/source
// Возвращает список файлов исходного кода функции из tar.gz контекста сборки (S3).
// Dockerfile исключается — он сгенерирован builder-ом, не является кодом пользователя.
// Если код ещё не загружен (S3Key пустой) — возвращает пустой список.
package handler
import (
"archive/tar"
"compress/gzip"
"encoding/base64"
"fmt"
"io"
"net/http"
"strings"
"k8s.io/apimachinery/pkg/api/errors"
"sigs.k8s.io/controller-runtime/pkg/client"
slessv1alpha1 "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/api/v1alpha1"
)
// sourceFileEntry — один файл из исходного кода функции.
type sourceFileEntry struct {
Name string `json:"name"`
Content string `json:"content"` // UTF-8 текст; бинарные файлы — base64 encoded
Binary bool `json:"binary"` // true если файл бинарный (base64 в content)
}
// GetSource — GET /v1/namespaces/{namespace}/functions/{name}/source
// Скачивает tar.gz контекст сборки из S3 и возвращает пользовательские файлы (без Dockerfile).
func (h *Handler) GetSource(w http.ResponseWriter, r *http.Request) {
ns := namespace(r)
name := pathVar(r, "name")
fn := &slessv1alpha1.Function{}
if err := h.K8s.Get(r.Context(), client.ObjectKey{Name: name, Namespace: ns}, fn); err != nil {
if errors.IsNotFound(err) {
writeJSON(w, http.StatusNotFound, errResp("function not found"))
return
}
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
return
}
// Код ещё не был загружен — возвращаем пустой список без ошибки
if fn.Spec.S3Key == "" {
writeJSON(w, http.StatusOK, []sourceFileEntry{})
return
}
rc, err := h.S3.Download(r.Context(), fn.Spec.S3Key)
if err != nil {
writeJSON(w, http.StatusInternalServerError, errResp("download from S3: "+err.Error()))
return
}
defer rc.Close()
files, err := extractSourceFilesFromTarGz(rc)
if err != nil {
writeJSON(w, http.StatusInternalServerError, errResp("extract source: "+err.Error()))
return
}
writeJSON(w, http.StatusOK, files)
}
// extractSourceFilesFromTarGz читает tar.gz и возвращает пользовательские файлы.
// Dockerfile пропускается — он генерируется builder-ом автоматически.
// Бинарные файлы (содержат null bytes) возвращаются в base64 с binary=true.
func extractSourceFilesFromTarGz(r io.Reader) ([]sourceFileEntry, error) {
gr, err := gzip.NewReader(r)
if err != nil {
return nil, fmt.Errorf("gzip: %w", err)
}
defer gr.Close()
tr := tar.NewReader(gr)
var result []sourceFileEntry
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("tar next: %w", err)
}
// Dockerfile сгенерирован builder-ом — не показываем пользователю
if hdr.Name == "Dockerfile" || hdr.FileInfo().IsDir() {
continue
}
data, err := io.ReadAll(tr)
if err != nil {
return nil, fmt.Errorf("read %s: %w", hdr.Name, err)
}
// Бинарные файлы — base64, текстовые — как есть
content := string(data)
isBinary := strings.ContainsRune(content, '\x00')
if isBinary {
content = base64.StdEncoding.EncodeToString(data)
}
result = append(result, sourceFileEntry{
Name: hdr.Name,
Content: content,
Binary: isBinary,
})
}
if result == nil {
result = []sourceFileEntry{}
}
return result, nil
}
+4 -1
View File
@@ -1,4 +1,4 @@
// Изменено: 2026-03-08
// Изменено: 2026-04-25
// router.go — регистрация всех REST-маршрутов через gorilla/mux.
// Все маршруты защищены Bearer-токеном (middleware.Auth).
// Маршруты сгруппированы по /v1/namespaces/{namespace}/...
@@ -45,6 +45,9 @@ func NewRouter(h *handler.Handler, log *slog.Logger) http.Handler {
// Upload code — принимает zip, генерирует Dockerfile, кладёт tar.gz в S3, запускает сборку
v1.HandleFunc("/namespaces/{namespace}/functions/{name}/upload", h.UploadCode).Methods(http.MethodPost)
// Source code — возвращает файлы из tar.gz контекста сборки (без Dockerfile)
v1.HandleFunc("/namespaces/{namespace}/functions/{name}/source", h.GetSource).Methods(http.MethodGet)
// Triggers CRUD
v1.HandleFunc("/namespaces/{namespace}/triggers", h.ListTriggers).Methods(http.MethodGet)
v1.HandleFunc("/namespaces/{namespace}/triggers", h.CreateTrigger).Methods(http.MethodPost)
+1 -1
View File
@@ -125,7 +125,7 @@ func (b *Builder) Build(ctx context.Context, namespace, funcName, s3Key string)
Args: []string{
"--context=" + s3ContextURL,
"--destination=" + imageRef,
"--cache=true", // кешируем слои для ускорения повторных сборок
// --no-cache не поддерживается этой версией kaniko; кэш отключён по умолчанию
},
Env: []corev1.EnvVar{
{Name: "AWS_ACCESS_KEY_ID", Value: b.s3AccessKey},
+3 -3
View File
@@ -66,13 +66,13 @@ func PrepareContext(zipData []byte, runtime string) (*bytes.Buffer, error) {
func runtimeBaseImage(runtime string) (string, error) {
switch runtime {
case "python3.11":
return "naeel/sless-runtime-python3.11:v0.1.2", nil
return "naeel/sless-runtime-python3.11:v0.1.4", nil
case "nodejs20":
return "naeel/sless-runtime-nodejs20:v0.1.2", nil
case "go1.23":
// Go использует builder-образ (содержит golang:1.23 + server.go).
// Go использует builder-образ (содержит golang:1.23 + server.go + go.mod/go.sum с pgx/v5).
// Конечный образ multi-stage — alpine без компилятора.
return "naeel/sless-runtime-go1.23:v0.1.0", nil
return "naeel/sless-runtime-go1.23:v0.1.1", nil
default:
return "", fmt.Errorf("unsupported runtime: %q (supported: python3.11, nodejs20, go1.23)", runtime)
}
+10 -2
View File
@@ -1,4 +1,4 @@
// Изменено: 2026-03-11
// Изменено: 2026-03-19
// Конфигурация сервиса — читается из env переменных при старте.
// Все компоненты (API, builder, runner) получают конфиг через эту структуру.
// Используем env а не файлы конфигурации — стандарт для k8s (ConfigMap/Secret → env).
@@ -56,6 +56,11 @@ type Config struct {
// Если не заданы, EnsureProject пропускается (только DockerHub/без проектов).
HarborUser string
HarborPass string
// RabbitMQURL — AMQP URL брокера для event-dispatcher.
// Формат: amqp://user:pass@host:5672/
// Опционально — если не задан, event-триггеры не будут обрабатываться.
RabbitMQURL string
}
func Load() (*Config, error) {
@@ -133,9 +138,12 @@ func Load() (*Config, error) {
cfg.APIToken = os.Getenv("SLESS_API_TOKEN")
// Harbor API креды — опциональны.
// Если не заданы, EnsureProject пропускается (push работает через docker-кред в kaniko).
// Если не заданы, EnsureProject пропускается (push работает через docker-кред в канiko).
cfg.HarborUser = os.Getenv("HARBOR_USER")
cfg.HarborPass = os.Getenv("HARBOR_PASS")
// RabbitMQ — опционально. Нужен только если используются event-триггеры.
cfg.RabbitMQURL = os.Getenv("RABBITMQ_URL")
return cfg, nil
}
+3 -1
View File
@@ -121,8 +121,10 @@ func main() {
// Builder использует k8s client из manager'а
// harborClient — nil если HARBOR_USER/HARBOR_PASS не заданы (EnsureProject пропускается).
var harborClient *harbor.Client
var harborProjecter builder.Projecter
if cfg.HarborUser != "" && cfg.HarborPass != "" {
harborClient = harbor.New(cfg.RegistryHost, cfg.HarborUser, cfg.HarborPass)
harborProjecter = harborClient
log.Info("harbor client configured", "host", cfg.RegistryHost)
} else {
log.Info("harbor client disabled (HARBOR_USER/HARBOR_PASS not set)")
@@ -137,7 +139,7 @@ func main() {
S3SecretKey: cfg.S3SecretKey,
S3Bucket: cfg.S3Bucket,
Namespace: "sless",
HarborClient: harborClient,
HarborClient: harborProjecter,
})
if err = (&controllers.FunctionReconciler{
+19 -23
View File
@@ -1,33 +1,29 @@
# Создано: 2026-03-11
# Изменено: 2026-03-19 — v0.1.1: добавлены go.sum + go mod download (pgx/v5 v5.7.2).
# Base builder image для Go 1.23 serverless функций.
# Это BUILDER-образ (не runtime): содержит Go компилятор + server.go.
# server.go — HTTP-wrapper + job-runner, компилируется ВМЕСТЕ с кодом пользователя.
# Это BUILDER-образ: golang:1.23-alpine + server.go + pre-cached зависимости.
# go mod download кеширует pgx/v5 в /root/go/pkg/mod — kaniko не скачивает их при каждой сборке.
#
# kaniko делает multi-stage build:
# Stage 1: golang:1.23-alpine → COPY server.go + handler/ → go build → /server
# Stage 2: FROM alpine → COPY /server → минимальный финальный образ.
# kaniko генерирует Dockerfile для каждой функции:
# FROM naeel/sless-runtime-go1.23:v0.1.1 AS builder
# WORKDIR /app
# COPY . /app/handler/
# RUN CGO_ENABLED=0 go build -o /server .
# FROM alpine:3.20
# COPY --from=builder /server /server → финальный образ
#
# Почему builder-образ, а не runtime: Go требует статической компиляции — нельзя
# "подключить" пользовательский код динамически как в Python/Node.
# Почему не multi-stage здесь: base образ должен иметь Go компилятор и module cache.
# Бинарник собирается kaniko, а не здесь.
FROM golang:1.23-alpine AS builder
FROM golang:1.23-alpine
WORKDIR /app
# server.go — точка входа (main package), вызывает Handler() из пользовательского кода.
# Копируем mod файлы первыми — отдельный кешируемый слой.
# При изменении go.mod/go.sum слой инвалидируется и go mod download запускается заново.
COPY go.mod go.sum /app/
RUN go mod download
# server.go — HTTP-wrapper + job-runner (main package, импортирует sless/fn/handler).
COPY server.go /app/server.go
# Код пользователя kaniko копирует в /app/handler/
COPY handler/ /app/handler/
# Статическая сборка (без libc) — работает в alpine-контейнере
RUN CGO_ENABLED=0 go build -o /server /app/server.go
# Финальный минимальный образ
FROM alpine:3.20
COPY --from=builder /server /server
EXPOSE 8080
CMD ["/server"]
+11
View File
@@ -1,3 +1,14 @@
module sless/fn
go 1.23
require github.com/jackc/pgx/v5 v5.7.2
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/text v0.21.0 // indirect
)

Some files were not shown because too many files have changed in this diff Show More