Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6960e87a5c | ||
|
|
11b484e1e7 | ||
|
|
ef7613d09f | ||
|
|
d9ed96e201 | ||
|
|
582b589e38 | ||
|
|
d5ce8c2fac | ||
|
|
a7fc960c82 | ||
|
|
cea13cf239 | ||
|
|
824dad7969 |
@@ -46,20 +46,6 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Именование
|
|
||||||
|
|
||||||
Имена должны быть **уникальными и осмысленными по всему проекту**:
|
|
||||||
- имена файлов
|
|
||||||
- имена функций/методов
|
|
||||||
- имена переменных/констант
|
|
||||||
- имена ресурсов (Terraform, Kubernetes и т.д.)
|
|
||||||
|
|
||||||
Цель: чтобы поиск по проекту находил нужные сущности без неоднозначности, а имя сразу отражало назначение.
|
|
||||||
|
|
||||||
Запрещены безликие и повторяющиеся имена вида `handler.py`, `handle`, `data`, `value`, `temp` без контекста.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Git
|
## Git
|
||||||
|
|
||||||
Коммитить и пушить после каждого завершённого этапа.
|
Коммитить и пушить после каждого завершённого этапа.
|
||||||
|
|||||||
@@ -43,9 +43,6 @@ examples/*/dist/
|
|||||||
**/handler.zip
|
**/handler.zip
|
||||||
test.token
|
test.token
|
||||||
*.tfvars
|
*.tfvars
|
||||||
*.tfplan
|
|
||||||
plan.out
|
|
||||||
sless-plan
|
|
||||||
.e2e-logs/
|
.e2e-logs/
|
||||||
.stress-logs/
|
.stress-logs/
|
||||||
|
|
||||||
@@ -60,8 +57,4 @@ sless-plan
|
|||||||
**/dist/
|
**/dist/
|
||||||
# дополнительные вариации переменных/файлов конфигурации
|
# дополнительные вариации переменных/файлов конфигурации
|
||||||
*.tfvars.json
|
*.tfvars.json
|
||||||
*.tfplan
|
|
||||||
plan.out
|
|
||||||
sless-plan
|
|
||||||
examples/.git
|
examples/.git
|
||||||
event-dispatcher
|
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
# Изменено: 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"]
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
// Изменено: 2026-03-19
|
// Изменено: 2026-03-08
|
||||||
// Описание CRD Trigger — триггер для функции (HTTP, Cron или Event).
|
// Описание CRD Trigger — триггер для функции (HTTP или Cron).
|
||||||
// Один Trigger ссылается на одну Function и определяет способ вызова.
|
// Один Trigger ссылается на одну Function и определяет способ вызова.
|
||||||
// Event-тип: event-dispatcher подписывается на AMQP очередь и при сообщении
|
|
||||||
// вызывает функцию по внутреннему HTTP.
|
|
||||||
|
|
||||||
package v1alpha1
|
package v1alpha1
|
||||||
|
|
||||||
@@ -18,9 +16,6 @@ const (
|
|||||||
TriggerTypeHTTP TriggerType = "http"
|
TriggerTypeHTTP TriggerType = "http"
|
||||||
// TriggerTypeCron — функция вызывается по расписанию (k8s CronJob)
|
// TriggerTypeCron — функция вызывается по расписанию (k8s CronJob)
|
||||||
TriggerTypeCron TriggerType = "cron"
|
TriggerTypeCron TriggerType = "cron"
|
||||||
// TriggerTypeEvent — функция вызывается при получении сообщения из AMQP очереди.
|
|
||||||
// event-dispatcher подписывается на spec.queue в RabbitMQ и делает POST на HTTP endpoint функции.
|
|
||||||
TriggerTypeEvent TriggerType = "event"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// TriggerSpec — желаемое состояние триггера.
|
// TriggerSpec — желаемое состояние триггера.
|
||||||
@@ -35,8 +30,8 @@ type TriggerSpec struct {
|
|||||||
// +kubebuilder:validation:Required
|
// +kubebuilder:validation:Required
|
||||||
FunctionRef string `json:"functionRef"`
|
FunctionRef string `json:"functionRef"`
|
||||||
|
|
||||||
// Type — тип триггера: http, cron или event
|
// Type — тип триггера: http или cron
|
||||||
// +kubebuilder:validation:Enum=http;cron;event
|
// +kubebuilder:validation:Enum=http;cron
|
||||||
// +kubebuilder:validation:Required
|
// +kubebuilder:validation:Required
|
||||||
Type TriggerType `json:"type"`
|
Type TriggerType `json:"type"`
|
||||||
|
|
||||||
@@ -47,10 +42,6 @@ type TriggerSpec struct {
|
|||||||
// Актуально для cron: запускаем pod заранее чтобы избежать cold start.
|
// Актуально для cron: запускаем pod заранее чтобы избежать cold start.
|
||||||
// +kubebuilder:default=300
|
// +kubebuilder:default=300
|
||||||
PreWarmSeconds int32 `json:"preWarmSeconds,omitempty"`
|
PreWarmSeconds int32 `json:"preWarmSeconds,omitempty"`
|
||||||
|
|
||||||
// Queue — имя AMQP очереди в RabbitMQ (только для type=event).
|
|
||||||
// event-dispatcher подпишется на эту очередь и вызовет функцию при каждом сообщении.
|
|
||||||
Queue string `json:"queue,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TriggerStatus — наблюдаемое состояние триггера (заполняет контроллер).
|
// TriggerStatus — наблюдаемое состояние триггера (заполняет контроллер).
|
||||||
@@ -74,7 +65,6 @@ type TriggerStatus struct {
|
|||||||
//+kubebuilder:printcolumn:name="Function",type=string,JSONPath=`.spec.functionRef`
|
//+kubebuilder:printcolumn:name="Function",type=string,JSONPath=`.spec.functionRef`
|
||||||
//+kubebuilder:printcolumn:name="Active",type=boolean,JSONPath=`.status.active`
|
//+kubebuilder:printcolumn:name="Active",type=boolean,JSONPath=`.status.active`
|
||||||
//+kubebuilder:printcolumn:name="URL",type=string,JSONPath=`.status.url`
|
//+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`
|
//+kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
|
||||||
|
|
||||||
// Trigger — ресурс для управления способом вызова Function.
|
// Trigger — ресурс для управления способом вызова Function.
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
// cmd/sless-plan/main.go
|
||||||
|
// Дата: 2026-03-12
|
||||||
|
// CLI-обёртка над `terraform plan`.
|
||||||
|
// Добавляет AI-анализ вывода если AI_HINT_LEVEL > 0.
|
||||||
|
// Существующий terraform plan не меняется — AI только дополняет вывод.
|
||||||
|
//
|
||||||
|
// Использование:
|
||||||
|
// sless-plan [terraform args...]
|
||||||
|
// sless-plan --ai-level=3 [terraform args...]
|
||||||
|
//
|
||||||
|
// Env-переменные:
|
||||||
|
// AI_HINT_LEVEL — уровень анализа 0–5 (0 = выключен, по умолчанию)
|
||||||
|
// AI_PROVIDER — провайдер: "groq" (по умолчанию), "google", "cloud"
|
||||||
|
// GROQ_API_KEY — API ключ Groq (рекомендуется, работает из РФ)
|
||||||
|
// GROQ_MODEL — модель Groq (по умолчанию "llama-3.3-70b-versatile")
|
||||||
|
// GROQ_ENDPOINT — endpoint (по умолчанию https://api.groq.com/openai/v1/chat/completions)
|
||||||
|
// GOOGLE_AI_API_KEY — API ключ Google Gemini (геоблок в РФ)
|
||||||
|
// GOOGLE_AI_MODEL — модель Gemini (по умолчанию "gemini-2.0-flash")
|
||||||
|
// CLOUD_LLM_ENDPOINT — endpoint будущего облачного LLM
|
||||||
|
// CLOUD_LLM_TOKEN — токен для облачного LLM
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/ai"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Флаг --ai-level переопределяет env AI_HINT_LEVEL
|
||||||
|
aiLevel := flag.Int("ai-level", -1, "уровень AI-анализа 0–5 (-1 = читать из AI_HINT_LEVEL)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
// Определяем уровень: флаг > env > 0
|
||||||
|
level := resolveLevel(*aiLevel)
|
||||||
|
|
||||||
|
// Собираем аргументы для terraform plan (всё что после флагов sless-plan)
|
||||||
|
tfArgs := append([]string{"plan"}, flag.Args()...)
|
||||||
|
|
||||||
|
// Запускаем terraform plan, захватывая вывод для AI
|
||||||
|
// но одновременно выводя его пользователю (tee-эффект)
|
||||||
|
planOutput, exitCode := runTerraform(tfArgs)
|
||||||
|
|
||||||
|
// Если уровень > 0 — запускаем AI анализ
|
||||||
|
if level > 0 {
|
||||||
|
runAIAnalysis(level, planOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Exit(exitCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveLevel определяет уровень AI анализа.
|
||||||
|
// Приоритет: --ai-level flag > AI_HINT_LEVEL env > 0
|
||||||
|
func resolveLevel(flagValue int) int {
|
||||||
|
if flagValue >= 0 {
|
||||||
|
// Флаг явно задан
|
||||||
|
if flagValue > 5 {
|
||||||
|
flagValue = 5
|
||||||
|
}
|
||||||
|
return flagValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Читаем из env
|
||||||
|
if envVal := os.Getenv("AI_HINT_LEVEL"); envVal != "" {
|
||||||
|
level, err := strconv.Atoi(envVal)
|
||||||
|
if err == nil && level >= 0 && level <= 5 {
|
||||||
|
return level
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TF_VAR_ai_hint_level тоже поддерживаем
|
||||||
|
if envVal := os.Getenv("TF_VAR_ai_hint_level"); envVal != "" {
|
||||||
|
level, err := strconv.Atoi(envVal)
|
||||||
|
if err == nil && level >= 0 && level <= 5 {
|
||||||
|
return level
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0 // по умолчанию — AI выключен
|
||||||
|
}
|
||||||
|
|
||||||
|
// runTerraform запускает terraform с переданными аргументами.
|
||||||
|
// Вывод идёт напрямую в stdout/stderr пользователя И захватывается для AI.
|
||||||
|
// Возвращает текст вывода и код завершения.
|
||||||
|
func runTerraform(args []string) (string, int) {
|
||||||
|
cmd := exec.Command("terraform", args...)
|
||||||
|
|
||||||
|
// Захватываем stdout для AI, но также пишем напрямую в os.Stdout
|
||||||
|
var buf bytes.Buffer
|
||||||
|
cmd.Stdout = io.MultiWriter(os.Stdout, &buf)
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
cmd.Stdin = os.Stdin
|
||||||
|
|
||||||
|
err := cmd.Run()
|
||||||
|
if err != nil {
|
||||||
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||||
|
return buf.String(), exitErr.ExitCode()
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "ошибка запуска terraform: %v\n", err)
|
||||||
|
return buf.String(), 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.String(), 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// runAIAnalysis запускает AI анализ и выводит результат.
|
||||||
|
// Не прерывает работу если AI недоступен — только показывает предупреждение.
|
||||||
|
func runAIAnalysis(level int, planOutput string) {
|
||||||
|
cfg := ai.Config{
|
||||||
|
HintLevel: level,
|
||||||
|
Provider: getEnv("AI_PROVIDER", "groq"),
|
||||||
|
GroqAPIKey: os.Getenv("GROQ_API_KEY"),
|
||||||
|
GroqModel: getEnv("GROQ_MODEL", "llama-3.3-70b-versatile"),
|
||||||
|
GroqEndpoint: os.Getenv("GROQ_ENDPOINT"),
|
||||||
|
GoogleAPIKey: os.Getenv("GOOGLE_AI_API_KEY"),
|
||||||
|
GoogleModel: getEnv("GOOGLE_AI_MODEL", "gemini-2.0-flash"),
|
||||||
|
CloudEndpoint: os.Getenv("CLOUD_LLM_ENDPOINT"),
|
||||||
|
CloudToken: os.Getenv("CLOUD_LLM_TOKEN"),
|
||||||
|
}
|
||||||
|
|
||||||
|
analyzer, err := ai.NewAnalyzer(cfg)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "\n⚠ AI анализ недоступен: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
result, err := analyzer.Analyze(ctx, planOutput)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "\n⚠ AI анализ завершился с ошибкой: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if result != "" {
|
||||||
|
fmt.Print(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getEnv возвращает значение env-переменной или defaultVal если не задана.
|
||||||
|
func getEnv(key, defaultVal string) string {
|
||||||
|
if val := os.Getenv(key); val != "" {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
return defaultVal
|
||||||
|
}
|
||||||
@@ -20,13 +20,6 @@ rules:
|
|||||||
- get
|
- get
|
||||||
- list
|
- list
|
||||||
- watch
|
- watch
|
||||||
- apiGroups:
|
|
||||||
- ""
|
|
||||||
resources:
|
|
||||||
- secrets
|
|
||||||
verbs:
|
|
||||||
- create
|
|
||||||
- get
|
|
||||||
- apiGroups:
|
- apiGroups:
|
||||||
- ""
|
- ""
|
||||||
resources:
|
resources:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Изменено: 2026-03-17 20:00 (bugfix: job-name label удалён в k8s 1.27+, split-brain cached client)
|
// Изменено: 2026-03-09 (feature B: захват stdout пода Job в status.Message)
|
||||||
// FunctionJobReconciler — контроллер одноразовых запусков функций.
|
// FunctionJobReconciler — контроллер одноразовых запусков функций.
|
||||||
// При создании FunctionJob:
|
// При создании FunctionJob:
|
||||||
// 1. Ждёт пока Function станет Ready
|
// 1. Ждёт пока Function станет Ready
|
||||||
@@ -134,13 +134,6 @@ func (r *FunctionJobReconciler) Reconcile(ctx context.Context, req ctrl.Request)
|
|||||||
// Автоудаление через 10 мин после завершения — чтобы не засорять кластер
|
// Автоудаление через 10 мин после завершения — чтобы не засорять кластер
|
||||||
TTLSecondsAfterFinished: &ttl,
|
TTLSecondsAfterFinished: &ttl,
|
||||||
Template: corev1.PodTemplateSpec{
|
Template: corev1.PodTemplateSpec{
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
|
||||||
Labels: map[string]string{
|
|
||||||
"managed-by": "sless",
|
|
||||||
"functionjob": fj.Name,
|
|
||||||
"function": fn.Name,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Spec: corev1.PodSpec{
|
Spec: corev1.PodSpec{
|
||||||
RestartPolicy: corev1.RestartPolicyNever,
|
RestartPolicy: corev1.RestartPolicyNever,
|
||||||
// Используем тот же образ что и Deployment функции
|
// Используем тот же образ что и Deployment функции
|
||||||
@@ -201,19 +194,12 @@ func (r *FunctionJobReconciler) syncJobStatus(ctx context.Context, fj *slessv1al
|
|||||||
fj.Status.Phase = slessv1alpha1.FunctionJobPhaseSucceeded
|
fj.Status.Phase = slessv1alpha1.FunctionJobPhaseSucceeded
|
||||||
fj.Status.CompletionTime = &now
|
fj.Status.CompletionTime = &now
|
||||||
// Захватываем stdout пода — это return value функции (runner делает print(json.dumps(result)))
|
// Захватываем stdout пода — это return value функции (runner делает print(json.dumps(result)))
|
||||||
fj.Status.Message = getJobPodOutput(ctx, r.KubeClient, job.Namespace, "functionjob="+fj.Name)
|
fj.Status.Message = getJobPodOutput(ctx, r.KubeClient, job.Namespace, job.Name)
|
||||||
} else if job.Status.Failed > 0 {
|
} else if job.Status.Failed > 0 {
|
||||||
now := metav1.Now()
|
now := metav1.Now()
|
||||||
fj.Status.Phase = slessv1alpha1.FunctionJobPhaseFailed
|
fj.Status.Phase = slessv1alpha1.FunctionJobPhaseFailed
|
||||||
fj.Status.CompletionTime = &now
|
fj.Status.CompletionTime = &now
|
||||||
// Захватываем логи по нашему лейблу functionjob= (работает во всех версиях k8s).
|
fj.Status.Message = "job failed, check pod logs: kubectl logs -n sless-fn-" + fj.Namespace + " -l functionjob=" + fj.Name
|
||||||
// Устаревший 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 {
|
} else {
|
||||||
// Job ещё выполняется — перечитаем через 5 секунд
|
// Job ещё выполняется — перечитаем через 5 секунд
|
||||||
if err := r.Status().Update(ctx, fj); err != nil {
|
if err := r.Status().Update(ctx, fj); err != nil {
|
||||||
@@ -227,17 +213,6 @@ func (r *FunctionJobReconciler) syncJobStatus(ctx context.Context, fj *slessv1al
|
|||||||
return ctrl.Result{}, nil
|
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-сервера.
|
// runtimeRunnerCommand возвращает CMD для запуска одноразового runner вместо HTTP-сервера.
|
||||||
// runner читает env SLESS_EVENT и SLESS_ENTRYPOINT, вызывает handle(event) один раз и завершается.
|
// runner читает env SLESS_EVENT и SLESS_ENTRYPOINT, вызывает handle(event) один раз и завершается.
|
||||||
func runtimeRunnerCommand(runtime string) []string {
|
func runtimeRunnerCommand(runtime string) []string {
|
||||||
@@ -305,22 +280,16 @@ func goJobModeEnv(runtime string) []corev1.EnvVar {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getJobPodOutput находит под по labelSelector и возвращает его stdout+stderr (trimmed).
|
// getJobPodOutput находит под созданный Job-ом и возвращает его stdout (trimmed).
|
||||||
// runner.py/runner.js печатают json.dumps(result) в stdout — return value функции.
|
// runner.py/runner.js печатают json.dumps(result) в stdout — это и есть return value функции.
|
||||||
// Исключения/трейсбэки Python/Node пишут в stderr — поэтому собираем оба потока.
|
|
||||||
// Если под не найден или логи недоступны — возвращает "completed successfully" как fallback.
|
// Если под не найден или логи недоступны — возвращает "completed successfully" как fallback.
|
||||||
// labelSelector передаётся снаружи — вызывающий код использует "functionjob=<name>" (наш лейбл,
|
func getJobPodOutput(ctx context.Context, kube kubernetes.Interface, namespace, jobName string) string {
|
||||||
// выставляется на 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{
|
pods, err := kube.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{
|
||||||
LabelSelector: labelSelector,
|
LabelSelector: "job-name=" + jobName,
|
||||||
})
|
})
|
||||||
if err != nil || len(pods.Items) == 0 {
|
if err != nil || len(pods.Items) == 0 {
|
||||||
return "completed successfully"
|
return "completed successfully"
|
||||||
}
|
}
|
||||||
// Stdout: true, Stderr: true — собираем оба потока.
|
|
||||||
// Python исключения идут в stderr, runner.py пишет результат в stdout.
|
|
||||||
req := kube.CoreV1().Pods(namespace).GetLogs(pods.Items[0].Name, &corev1.PodLogOptions{})
|
req := kube.CoreV1().Pods(namespace).GetLogs(pods.Items[0].Name, &corev1.PodLogOptions{})
|
||||||
stream, err := req.Stream(ctx)
|
stream, err := req.Stream(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -124,9 +124,6 @@ func (r *TriggerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct
|
|||||||
case slessv1alpha1.TriggerTypeCron:
|
case slessv1alpha1.TriggerTypeCron:
|
||||||
logger.Info("reconcile cron trigger", "trigger", tr.Name)
|
logger.Info("reconcile cron trigger", "trigger", tr.Name)
|
||||||
return r.reconcileCron(ctx, tr, fn)
|
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
|
return ctrl.Result{}, nil
|
||||||
@@ -328,44 +325,3 @@ func (r *TriggerReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
|||||||
For(&slessv1alpha1.Trigger{}).
|
For(&slessv1alpha1.Trigger{}).
|
||||||
Complete(r)
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
# Изменено: 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
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
# Изменено: 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
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
# Изменено: 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
|
|
||||||
@@ -3,9 +3,9 @@
|
|||||||
# Состав:
|
# Состав:
|
||||||
# - ConfigMap: не-секретные env vars (S3_ENDPOINT, REGISTRY_HOST и т.д.)
|
# - ConfigMap: не-секретные env vars (S3_ENDPOINT, REGISTRY_HOST и т.д.)
|
||||||
# - Secret: секретные данные (S3 keys, postgres DSN, API token, Harbor pass)
|
# - Secret: секретные данные (S3 keys, postgres DSN, API token, Harbor pass)
|
||||||
# - Deployment: оператор naeel/sless-operator:v0.1.33 в namespace sless
|
# - Deployment: оператор naeel/sless-operator:v0.1.23 в namespace sless
|
||||||
# - Service: ClusterIP :9090 (REST API)
|
# - Service: ClusterIP :9090 (REST API)
|
||||||
# - Ingress: sless.kube5s.ru → :9090 (внешний доступ с TLS)
|
# - Ingress: sless-api.kube5s.ru → :9090 (внешний доступ с TLS)
|
||||||
#
|
#
|
||||||
# Чтобы сменить registry — менять только REGISTRY_HOST в ConfigMap.
|
# Чтобы сменить registry — менять только REGISTRY_HOST в ConfigMap.
|
||||||
# Чтобы сменить Harbor-аккаунт — менять HARBOR_USER в ConfigMap + HARBOR_PASS в Secret.
|
# Чтобы сменить Harbor-аккаунт — менять HARBOR_USER в ConfigMap + HARBOR_PASS в Secret.
|
||||||
@@ -32,7 +32,7 @@ data:
|
|||||||
INGRESS_HOST: "fn.kube5s.ru"
|
INGRESS_HOST: "fn.kube5s.ru"
|
||||||
# EXTERNAL_URL — если задан, URL функции = EXTERNAL_URL/fn/{namespace}/{name}
|
# EXTERNAL_URL — если задан, URL функции = EXTERNAL_URL/fn/{namespace}/{name}
|
||||||
# Позволяет обойтись без wildcard DNS *.fn.kube5s.ru
|
# Позволяет обойтись без wildcard DNS *.fn.kube5s.ru
|
||||||
EXTERNAL_URL: "https://sless.kube5s.ru"
|
EXTERNAL_URL: "https://sless-api.kube5s.ru"
|
||||||
---
|
---
|
||||||
# Secret создаётся отдельно через kubectl (не коммитить секреты в git!)
|
# Secret создаётся отдельно через kubectl (не коммитить секреты в git!)
|
||||||
# Описание ключей:
|
# Описание ключей:
|
||||||
@@ -72,7 +72,7 @@ spec:
|
|||||||
containers:
|
containers:
|
||||||
- name: operator
|
- name: operator
|
||||||
# При обновлении версии оператора — менять тег здесь (не latest!)
|
# При обновлении версии оператора — менять тег здесь (не latest!)
|
||||||
image: naeel/sless-operator:v0.1.33
|
image: naeel/sless-operator:v0.1.28
|
||||||
# Always — чтобы всегда тянуть по точному тегу (не кешировать старый)
|
# Always — чтобы всегда тянуть по точному тегу (не кешировать старый)
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
ports:
|
ports:
|
||||||
@@ -130,12 +130,10 @@ metadata:
|
|||||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||||
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
|
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
|
||||||
nginx.ingress.kubernetes.io/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:
|
spec:
|
||||||
ingressClassName: nginx
|
ingressClassName: nginx
|
||||||
rules:
|
rules:
|
||||||
- host: sless.kube5s.ru
|
- host: sless-api.kube5s.ru
|
||||||
http:
|
http:
|
||||||
paths:
|
paths:
|
||||||
- path: /
|
- path: /
|
||||||
@@ -147,5 +145,5 @@ spec:
|
|||||||
number: 9090
|
number: 9090
|
||||||
tls:
|
tls:
|
||||||
- hosts:
|
- hosts:
|
||||||
- sless.kube5s.ru
|
- sless-api.kube5s.ru
|
||||||
secretName: sless-operator-tls
|
secretName: sless-operator-tls
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
# Изменено: 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
|
|
||||||
+8
-38
@@ -1,7 +1,5 @@
|
|||||||
# API Design
|
# API Design
|
||||||
|
|
||||||
Последнее обновление: 2026-03-18
|
|
||||||
|
|
||||||
## Базовый URL
|
## Базовый URL
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -9,38 +7,15 @@ http://<operator-host>:9090/v1
|
|||||||
```
|
```
|
||||||
|
|
||||||
Локально: `http://localhost: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 формат):
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /v1/namespaces/{ns}/functions
|
/v1/namespaces/{namespace}/functions[/{name}]
|
||||||
POST /v1/namespaces/{ns}/functions
|
/v1/namespaces/{namespace}/functions/{name}/upload
|
||||||
GET /v1/namespaces/{ns}/functions/{name}
|
/v1/namespaces/{namespace}/triggers[/{name}]
|
||||||
PUT /v1/namespaces/{ns}/functions/{name}
|
/v1/namespaces/{namespace}/functions/{name}/invocations
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Аутентификация
|
## Аутентификация
|
||||||
@@ -49,11 +24,6 @@ GET https://sless.kube5s.ru/health ← liveness
|
|||||||
Authorization: Bearer <cloud-token>
|
Authorization: Bearer <cloud-token>
|
||||||
```
|
```
|
||||||
|
|
||||||
Токен — JWT от `auth-api`. Middleware в операторе:
|
|
||||||
1. Извлекает `sub` из payload (без проверки подписи — доверяет Ingress)
|
|
||||||
2. Вычисляет namespace: `SHA256(sub)[:8]` hex → `sless-{16 hex символов}`
|
|
||||||
3. Проверяет что запрошенный `{namespace}` совпадает с вычисленным
|
|
||||||
|
|
||||||
## Ресурсы
|
## Ресурсы
|
||||||
|
|
||||||
### Functions
|
### Functions
|
||||||
@@ -117,8 +87,8 @@ field: code = <zip-file>
|
|||||||
## Поддерживаемые runtime (v1)
|
## Поддерживаемые runtime (v1)
|
||||||
|
|
||||||
- `python3.11` — реализован и протестирован
|
- `python3.11` — реализован и протестирован
|
||||||
- `nodejs20` — реализован и протестирован
|
- `go1.21` — планируется
|
||||||
- `go1.23` — реализован
|
- `nodejs20` — планируется
|
||||||
|
|
||||||
## Модель Function
|
## Модель Function
|
||||||
|
|
||||||
|
|||||||
@@ -1,368 +0,0 @@
|
|||||||
# 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 |
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Архитектура системы
|
# Архитектура системы
|
||||||
|
|
||||||
Последнее обновление: 2026-03-18 (v0.1.34 + funcs-service v0.2.0)
|
Последнее обновление: 2026-03-11 (v0.1.22)
|
||||||
|
|
||||||
## Общее описание
|
## Общее описание
|
||||||
|
|
||||||
@@ -13,7 +13,6 @@ Managed Serverless Functions Service для облачного провайде
|
|||||||
| Компонент | Технология | Где запущен |
|
| Компонент | Технология | Где запущен |
|
||||||
|-----------|-----------|-------------|
|
|-----------|-----------|-------------|
|
||||||
| Operator (API + Controllers) | Go (controller-runtime) | Kubernetes, namespace `sless` |
|
| Operator (API + Controllers) | Go (controller-runtime) | Kubernetes, namespace `sless` |
|
||||||
| funcs-service (глобальная консоль) | Go (net/http) | Kubernetes, namespace `sless` |
|
|
||||||
| PostgreSQL | PostgreSQL 16 | Kubernetes, namespace `sless` |
|
| PostgreSQL | PostgreSQL 16 | Kubernetes, namespace `sless` |
|
||||||
| S3 | Ceph (облачный) | `s3.msk-1.ngcloud.ru` |
|
| S3 | Ceph (облачный) | `s3.msk-1.ngcloud.ru` |
|
||||||
| Container Registry | DockerHub (`naeel/`) | внешний |
|
| Container Registry | DockerHub (`naeel/`) | внешний |
|
||||||
@@ -26,44 +25,6 @@ Managed Serverless Functions Service для облачного провайде
|
|||||||
|
|
||||||
> Redis и RabbitMQ — отложены до v2.
|
> 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
|
## Изоляция пользователей — Namespace per user
|
||||||
|
|
||||||
Каждый пользователь облака получает **отдельный k8s namespace**.
|
Каждый пользователь облака получает **отдельный k8s namespace**.
|
||||||
|
|||||||
@@ -1,106 +1,49 @@
|
|||||||
# Структура проекта
|
# Структура проекта
|
||||||
|
|
||||||
Последнее обновление: 2026-03-18 (v0.1.34)
|
|
||||||
|
|
||||||
## Репозиторий
|
## Репозиторий
|
||||||
|
|
||||||
`gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless`
|
`gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless`
|
||||||
|
|
||||||
## Директории (актуальное состояние)
|
## Директории
|
||||||
|
|
||||||
```
|
```
|
||||||
sless/
|
sless/
|
||||||
├── main.go # точка входа оператора (controller-runtime + HTTP сервер)
|
├── cmd/
|
||||||
├── Dockerfile # multi-stage сборка оператора
|
│ └── api/
|
||||||
├── go.mod # module: gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless
|
│ └── main.go # точка входа API сервера
|
||||||
├── 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/
|
├── internal/
|
||||||
│ ├── api/
|
│ ├── api/
|
||||||
│ │ ├── router.go # gorilla/mux: регистрация всех маршрутов
|
│ │ ├── handler/ # HTTP хендлеры (functions, versions, triggers)
|
||||||
│ │ ├── middleware/
|
│ │ ├── middleware/ # auth, logging, rate limit
|
||||||
│ │ │ ├── auth.go # JWT Bearer → namespace (SHA256)
|
│ │ └── router.go # регистрация маршрутов
|
||||||
│ │ │ └── logging.go # access log
|
│ ├── model/ # доменные модели: Function, Version, Trigger, Invocation
|
||||||
│ │ └── 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/
|
│ ├── storage/
|
||||||
│ │ ├── s3/client.go # minio-go: Upload, UploadContext, Download, Delete
|
│ │ ├── postgres/ # CRUD функций, версий, логов вызовов
|
||||||
│ │ └── postgres/ # хранение invocation logs
|
│ │ └── s3/ # загрузка/скачивание zip архивов кода
|
||||||
│ └── config/ # конфиг из env переменных
|
│ ├── builder/ # сборка Docker образа из кода пользователя
|
||||||
├── services/
|
│ ├── runner/ # запуск функций в k8s (Jobs / Deployments)
|
||||||
│ └── funcs/ # отдельный HTTP сервис (web-консоль)
|
│ └── config/ # конфиг из env переменных
|
||||||
│ ├── main.go # v0.2.0: HTML+plain text+proxy к оператору
|
├── migrations/ # SQL миграции (numbered: 001_, 002_, ...)
|
||||||
│ ├── index.html # dark-themed HTML консоль (go:embed)
|
├── deployments/
|
||||||
│ ├── Dockerfile # multi-stage Go → alpine (копирует main.go + index.html)
|
│ └── k8s/ # манифесты: Deployment, Service, Ingress, RBAC
|
||||||
│ └── go.mod # отдельный Go модуль
|
├── api/
|
||||||
├── migrations/
|
│ └── openapi.yaml # OpenAPI 3.0 спецификация
|
||||||
│ └── 001_initial.sql # схема PostgreSQL (invocation logs)
|
├── doc/ # документация проекта (эта папка)
|
||||||
├── deployments/k8s/
|
└── docker-compose.yml # локальная разработка: postgres, redis, minio
|
||||||
│ ├── 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 модули
|
## Go module
|
||||||
|
|
||||||
Два независимых Go модуля в одном репозитории:
|
```
|
||||||
|
module gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless
|
||||||
|
```
|
||||||
|
|
||||||
| Модуль | Путь | Назначение |
|
## Порядок разработки
|
||||||
|--------|------|-----------|
|
|
||||||
| `gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless` | `/` | Оператор + API |
|
1. `internal/config` + `internal/model` — базовые структуры данных
|
||||||
| `gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/services/funcs` | `services/funcs/` | Web-консоль сервис |
|
2. `migrations/` + `internal/storage/postgres` — схема БД и CRUD
|
||||||
| `terra.k8c.ru/naeel/sless` | `terraform/provider/` | Terraform провайдер |
|
3. `internal/api` — HTTP хендлеры, роутер, middleware
|
||||||
|
4. `internal/storage/s3` — загрузка кода функций
|
||||||
|
5. `internal/builder` — сборка Docker образов
|
||||||
|
6. `internal/runner` — запуск функций в k8s
|
||||||
|
7. `deployments/k8s` — манифесты для деплоя
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
# План: AI-анализ terraform plan (уровни 0–5)
|
||||||
|
|
||||||
|
Дата: 2026-03-12
|
||||||
|
Статус: черновик / на согласование
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Суть задачи
|
||||||
|
|
||||||
|
После выполнения `terraform plan` отправлять вывод на анализ в LLM.
|
||||||
|
Глубина анализа управляется переменной `ai_hint_level` (0–5) в Terraform-конфигурации.
|
||||||
|
Сейчас — Google AI (Gemini API). В будущем — собственный облачный LLM (drop-in замена).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Уровни подсказок (`ai_hint_level`)
|
||||||
|
|
||||||
|
| Уровень | Название | Что делает |
|
||||||
|
|---------|----------------|-------------------------------------------------------------------|
|
||||||
|
| 0 | OFF | Ничего. AI не вызывается. `terraform plan` работает как обычно. |
|
||||||
|
| 1 | SYNTAX | Проверка синтаксиса: ошибки в HCL, опечатки, невалидные блоки. |
|
||||||
|
| 2 | BASIC | + базовый анализ: неиспользуемые ресурсы, пустые значения. |
|
||||||
|
| 3 | MODERATE | + анализ зависимостей, потенциальные конфликты, порядок apply. |
|
||||||
|
| 4 | DETAILED | + best practices, безопасность (открытые порты, широкие IAM). |
|
||||||
|
| 5 | FULL | Полные рекомендации: оптимизация, рефакторинг, альтернативы. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Как задаётся уровень
|
||||||
|
|
||||||
|
**По умолчанию AI не активен и ничего не показывает.** Если `ai_hint_level` нигде не задан — уровень 0, `sless plan` работает как обычный `terraform plan`, без единого упоминания об AI.
|
||||||
|
|
||||||
|
Уровень задаётся **только явно**, одним из способов (приоритет сверху вниз):
|
||||||
|
|
||||||
|
1. Env-переменная (рекомендуется):
|
||||||
|
```bash
|
||||||
|
export TF_VAR_ai_hint_level=3
|
||||||
|
```
|
||||||
|
|
||||||
|
2. В `variables.tf` пользовательского проекта (опционально, только если нужно зафиксировать уровень в коде):
|
||||||
|
```hcl
|
||||||
|
variable "ai_hint_level" {
|
||||||
|
description = "Уровень AI-анализа terraform plan (0=off, 5=full)"
|
||||||
|
type = number
|
||||||
|
default = 0
|
||||||
|
|
||||||
|
validation {
|
||||||
|
condition = var.ai_hint_level >= 0 && var.ai_hint_level <= 5
|
||||||
|
error_message = "ai_hint_level должен быть от 0 до 5"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. CLI-флаг напрямую:
|
||||||
|
```bash
|
||||||
|
sless plan --ai-level=3
|
||||||
|
```
|
||||||
|
|
||||||
|
**Важно:** CLI сам читает уровень из env `TF_VAR_ai_hint_level` или флага `--ai-level`. Если ни того ни другого нет — молча применяет уровень 0. Никакой переменной в манифестах не требуется. Пользователь не видит AI, пока не включит его явно.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Архитектура решения
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||||
|
│ terraform │────>│ wrapper-скрипт │────>│ AI Analyzer │
|
||||||
|
│ plan │ │ (bash/Go CLI) │ │ (Go сервис) │
|
||||||
|
│ (вывод JSON)│ │ │ │ │
|
||||||
|
└─────────────┘ └──────────────────┘ └────────┬────────┘
|
||||||
|
│
|
||||||
|
┌────────▼────────┐
|
||||||
|
│ LLM Backend │
|
||||||
|
│ (Google Gemini) │
|
||||||
|
│ → Cloud LLM │
|
||||||
|
└─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Компоненты:
|
||||||
|
|
||||||
|
1. **Wrapper-скрипт / CLI** — вызывает `terraform plan -json`, читает `ai_hint_level`, передаёт вывод в анализатор.
|
||||||
|
2. **AI Analyzer (Go)** — формирует промпт по уровню, отправляет в LLM, форматирует ответ.
|
||||||
|
3. **LLM Backend** — подключаемый через интерфейс (сначала Google, потом облачный).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Реализация по шагам
|
||||||
|
|
||||||
|
### Шаг 1: CLI-обёртка (`sless-plan`)
|
||||||
|
|
||||||
|
Новый бинарник или shell-скрипт, который:
|
||||||
|
- Запускает `terraform plan -json -out=plan.bin`
|
||||||
|
- Читает `ai_hint_level` из state/variables (через `terraform output` или парсинг .tf)
|
||||||
|
- Если level > 0, передаёт JSON-вывод плана в AI Analyzer
|
||||||
|
- Выводит стандартный plan + AI-рекомендации ниже
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Пользователь вызывает:
|
||||||
|
sless plan # вместо terraform plan
|
||||||
|
# или:
|
||||||
|
terraform plan && sless analyze --level=3
|
||||||
|
```
|
||||||
|
|
||||||
|
### Шаг 2: AI Analyzer сервис (Go)
|
||||||
|
|
||||||
|
Расположение: `internal/ai/` или отдельный `cmd/ai-analyzer/`
|
||||||
|
|
||||||
|
```
|
||||||
|
internal/ai/
|
||||||
|
├── analyzer.go # основная логика: формирование промпта, парсинг ответа
|
||||||
|
├── provider.go # интерфейс LLM-провайдера
|
||||||
|
├── google_gemini.go # реализация для Google Gemini API
|
||||||
|
├── cloud_llm.go # (заглушка) реализация для будущего облачного LLM
|
||||||
|
└── prompts.go # шаблоны промптов по уровням 1–5
|
||||||
|
```
|
||||||
|
|
||||||
|
Ключевой интерфейс:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// LLMProvider — абстракция над любым LLM-бэкендом.
|
||||||
|
// Сейчас: Google Gemini. В будущем: облачный LLM (drop-in замена).
|
||||||
|
type LLMProvider interface {
|
||||||
|
Analyze(ctx context.Context, prompt string) (string, error)
|
||||||
|
Name() string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Шаг 3: Промпты по уровням
|
||||||
|
|
||||||
|
Каждый уровень = свой system prompt + ограничения:
|
||||||
|
|
||||||
|
| Уровень | System prompt (суть) |
|
||||||
|
|---------|-----------------------------------------------------------|
|
||||||
|
| 1 | "Проверь только синтаксис HCL. Ничего лишнего." |
|
||||||
|
| 2 | "Синтаксис + базовые проблемы (unused, empty values)." |
|
||||||
|
| 3 | "Анализ зависимостей, порядок, потенциальные конфликты." |
|
||||||
|
| 4 | "Best practices, безопасность, IAM, открытые порты." |
|
||||||
|
| 5 | "Полный аудит: оптимизация, рефакторинг, альтернативы." |
|
||||||
|
|
||||||
|
### Шаг 4: Google Gemini интеграция
|
||||||
|
|
||||||
|
- API: `generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent`
|
||||||
|
- Авторизация: API key (хранить в env `GOOGLE_AI_API_KEY`, не в коде)
|
||||||
|
- SDK: `github.com/google/generative-ai-go` (официальный Go SDK)
|
||||||
|
- Модель: `gemini-pro` или `gemini-1.5-pro`
|
||||||
|
|
||||||
|
```go
|
||||||
|
// google_gemini.go — реализует LLMProvider
|
||||||
|
type GeminiProvider struct {
|
||||||
|
client *genai.Client
|
||||||
|
model string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Шаг 5: Конфигурация
|
||||||
|
|
||||||
|
Через env-переменные (не хардкод):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Какой LLM-провайдер использовать
|
||||||
|
export AI_PROVIDER=google # google | cloud (в будущем)
|
||||||
|
|
||||||
|
# Google Gemini
|
||||||
|
export GOOGLE_AI_API_KEY=AIza... # API ключ
|
||||||
|
|
||||||
|
# Будущий облачный LLM
|
||||||
|
export CLOUD_LLM_ENDPOINT=https://llm.cloud.example.com/v1/analyze
|
||||||
|
export CLOUD_LLM_TOKEN=...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Миграция на облачный LLM
|
||||||
|
|
||||||
|
Когда облако предоставит свой LLM:
|
||||||
|
|
||||||
|
1. Создать `cloud_llm.go` — реализация `LLMProvider` для нового API
|
||||||
|
2. Переключить `AI_PROVIDER=cloud` в env
|
||||||
|
3. Код анализатора НЕ меняется — интерфейс `LLMProvider` тот же
|
||||||
|
4. Google Gemini остаётся как fallback (опционально)
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Фабрика провайдеров — выбор по переменной окружения
|
||||||
|
func NewProvider(cfg Config) (LLMProvider, error) {
|
||||||
|
switch cfg.Provider {
|
||||||
|
case "google":
|
||||||
|
return NewGeminiProvider(cfg.GoogleAPIKey)
|
||||||
|
case "cloud":
|
||||||
|
return NewCloudLLMProvider(cfg.CloudEndpoint, cfg.CloudToken)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown AI provider: %s", cfg.Provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Безопасность
|
||||||
|
|
||||||
|
- API-ключи — только в env/secrets, никогда в коде или .tf файлах
|
||||||
|
- `terraform plan -json` может содержать sensitive data → фильтровать перед отправкой в LLM
|
||||||
|
- Ограничить размер payload (большие планы обрезать/суммаризировать)
|
||||||
|
- Логировать факт вызова AI, но НЕ содержимое запроса/ответа в production
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Структура файлов (итого)
|
||||||
|
|
||||||
|
```
|
||||||
|
sless/
|
||||||
|
├── cmd/
|
||||||
|
│ └── sless-plan/ # CLI-обёртка для terraform plan + AI
|
||||||
|
│ └── main.go
|
||||||
|
├── internal/
|
||||||
|
│ └── ai/
|
||||||
|
│ ├── analyzer.go # логика анализа
|
||||||
|
│ ├── provider.go # интерфейс LLMProvider
|
||||||
|
│ ├── google_gemini.go # Google Gemini реализация
|
||||||
|
│ ├── cloud_llm.go # заглушка для облачного LLM
|
||||||
|
│ └── prompts.go # промпты по уровням 1–5
|
||||||
|
├── terraform/
|
||||||
|
│ └── provider/ # (существующий провайдер — без изменений)
|
||||||
|
└── examples/
|
||||||
|
└── */
|
||||||
|
└── variables.tf # добавить ai_hint_level variable
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Порядок реализации
|
||||||
|
|
||||||
|
| # | Задача | Приоритет |
|
||||||
|
|----|------------------------------------------------|-----------|
|
||||||
|
| 1 | Создать `internal/ai/provider.go` (интерфейс) | HIGH |
|
||||||
|
| 2 | Реализовать `google_gemini.go` | HIGH |
|
||||||
|
| 3 | Написать промпты по уровням (`prompts.go`) | HIGH |
|
||||||
|
| 4 | Создать `analyzer.go` (оркестрация) | HIGH |
|
||||||
|
| 5 | CLI-обёртка `cmd/sless-plan/main.go` | HIGH |
|
||||||
|
| 6 | Добавить `ai_hint_level` в examples | MEDIUM |
|
||||||
|
| 7 | Тесты (unit + integration с mock LLM) | MEDIUM |
|
||||||
|
| 8 | Заглушка `cloud_llm.go` для будущей миграции | LOW |
|
||||||
|
| 9 | Документация для пользователей | MEDIUM |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Пример использования (целевой UX)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# В terraform проекте пользователя:
|
||||||
|
$ export TF_VAR_ai_hint_level=3
|
||||||
|
$ export GOOGLE_AI_API_KEY=AIza...
|
||||||
|
|
||||||
|
$ sless plan
|
||||||
|
# Terraform Plan output (стандартный)
|
||||||
|
# ...
|
||||||
|
# ──────────────── AI Analysis (level 3: MODERATE) ────────────────
|
||||||
|
# ⚠ Resource "kubernetes_deployment.func" зависит от "kubernetes_namespace.ns"
|
||||||
|
# но namespace создаётся в другом модуле — возможен race condition.
|
||||||
|
# ⚠ Порядок destroy может вызвать проблемы: trigger удалится раньше function.
|
||||||
|
# ✓ Синтаксис корректен, конфликтов не обнаружено.
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Решение зафиксировано
|
||||||
|
|
||||||
|
Подход: **интерфейс LLMProvider** → сначала Google Gemini → потом drop-in замена на облачный LLM.
|
||||||
|
Уровень задаётся через обычную Terraform variable. Никаких изменений в самом провайдере sless.
|
||||||
+67
-266
@@ -1,220 +1,5 @@
|
|||||||
# Решения и обоснования
|
# Решения и обоснования
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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 — Отдельная репа для сервиса
|
## 2026-03-06 — Отдельная репа для сервиса
|
||||||
|
|
||||||
**Решение:** Serverless service в отдельной репе, не вместе с Terraform provider.
|
**Решение:** Serverless service в отдельной репе, не вместе с Terraform provider.
|
||||||
@@ -864,69 +649,85 @@ for _, k := range keys { envVars = append(envVars, corev1.EnvVar{Name: k, Value:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2026-03-19 — pgx/v5 как PG-драйвер для Go функций (vs database/sql + lib/pq)
|
## 2026-03-12 — AI-анализ terraform plan через Google Gemini
|
||||||
|
|
||||||
**Контекст:** Go runtime v0.1.1 — добавляем прямой доступ к PostgreSQL из функций.
|
**Решение:** Добавлен необязательный AI-анализ вывода `terraform plan` с уровнями детализации 0–5.
|
||||||
Нужно выбрать: database/sql + lib/pq, или чистый pgx/v5?
|
АI невидим по умолчанию — не влияет на существующие `.tf` файлы и workflow.
|
||||||
|
|
||||||
**Решение:** Использовать `github.com/jackc/pgx/v5` напрямую, без обёртки database/sql.
|
**Причина:**
|
||||||
|
- Разработчики хотят понимать что именно изменится в инфраструктуре перед применением.
|
||||||
|
- Уровни позволяют самостоятельно выбирать глубину анализа (от краткого обзора до полного аудита).
|
||||||
|
- Не ломает backward compatibility: `ai_hint_level` не указан → поведение идентично прежнему.
|
||||||
|
|
||||||
**Причины:**
|
**Архитектура:**
|
||||||
|
|
||||||
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
|
provider "sless" { ai_hint_level = 3 }
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
↓ os.Setenv("AI_HINT_LEVEL", "3") (в provider.Configure)
|
||||||
github.com/jackc/pgservicefile v0.0.0-... // indirect
|
sless-plan ← CLI обёртка над terraform plan
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect (connection pool)
|
↓ запускает terraform plan, захватывает вывод
|
||||||
golang.org/x/crypto v0.31.0 // indirect (scram auth)
|
↓ если AI_HINT_LEVEL > 0 → internal/ai/analyzer.go
|
||||||
golang.org/x/sync v0.10.0 // indirect
|
↓ POST к Gemini REST API (net/http)
|
||||||
golang.org/x/text v0.21.0 // indirect
|
↓ выводит анализ пользователю
|
||||||
```
|
```
|
||||||
|
|
||||||
**go mod download** добавлен в Dockerfile до COPY server.go — слой с зависимостями кешируется отдельно.
|
**Новые файлы:**
|
||||||
Пересборка функции (только изменение handler.go) не перекачивает ~15MB зависимостей.
|
| Файл | Назначение |
|
||||||
|
|------|------------|
|
||||||
|
| `internal/ai/provider.go` | `LLMProvider` interface + `Config` struct + `NewProvider` фабрика |
|
||||||
|
| `internal/ai/google_gemini.go` | Gemini 2.0 Flash через `net/http` (без внешних зависимостей) |
|
||||||
|
| `internal/ai/prompts.go` | Системные промпты для уровней 1–5, `BuildPrompt(level, plan)`, `LevelName(level)` |
|
||||||
|
| `internal/ai/analyzer.go` | Оркестратор: `NewAnalyzer`, `Analyze`, `FormatResult`, `truncatePlan(8000)` |
|
||||||
|
| `internal/ai/cloud_llm.go` | Заглушка: миграционный путь на облачный LLM в будущем |
|
||||||
|
| `cmd/sless-plan/main.go` | CLI: запускает `terraform plan`, читает уровень, вызывает AI |
|
||||||
|
|
||||||
|
**Изменённые файлы:**
|
||||||
|
- `terraform/provider/internal/provider/provider.go` — добавлен атрибут `ai_hint_level` (Optional Int64)
|
||||||
|
|
||||||
|
**Уровни анализа (env: `AI_HINT_LEVEL`):**
|
||||||
|
| Уровень | Описание |
|
||||||
|
|---------|----------|
|
||||||
|
| 0 | Выключен (default) — AI не вызывается |
|
||||||
|
| 1 | Только синтаксические ошибки и опечатки |
|
||||||
|
| 2 | Базовый обзор изменений |
|
||||||
|
| 3 | Умеренный: потенциальные проблемы + рекомендации |
|
||||||
|
| 4 | Детальный: security review + best practices |
|
||||||
|
| 5 | Полный аудит: всё включая compliance |
|
||||||
|
|
||||||
|
**Как передаётся уровень (приоритет по убыванию):**
|
||||||
|
1. `--ai-level` флаг CLI
|
||||||
|
2. `AI_HINT_LEVEL` env var (выставляется провайдером через `os.Setenv` в `Configure`)
|
||||||
|
3. `TF_VAR_ai_hint_level` env var
|
||||||
|
4. Дефолт: 0
|
||||||
|
|
||||||
|
**Почему `net/http` без Google SDK:**
|
||||||
|
Gemini SDK требует внешней зависимости → `go.mod` провайдера должен оставаться неизменным.
|
||||||
|
REST API Gemini достаточен: один endpoint, один JSON запрос/ответ.
|
||||||
|
|
||||||
|
**Backward compatibility:**
|
||||||
|
- Провайдер без `ai_hint_level` → `config.AIHintLevel.IsNull() == true` → `os.Setenv` не вызывается.
|
||||||
|
- `sless-plan` без `AI_HINT_LEVEL` → уровень 0 → AI не вызывается → вывод идентичен `terraform plan`.
|
||||||
|
- Существующие `.tf` файлы изменять не нужно.
|
||||||
|
|
||||||
|
**Коммиты:** `d5ce8c2` (internal/ai + cmd/sless-plan), `582b589` (provider schema)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2026-03-19 — Динамический таймаут в invoke.go из Function.Spec.TimeoutSec
|
## 2026-03-12 — Groq/OpenAI-compat как default AI провайдер, self-hosted LLM не поднимаем
|
||||||
|
|
||||||
**Контекст:** invoke.go проксирует HTTP-запросы к подам функций. До этого — глобальный `http.Client{Timeout: 30s}`.
|
**Решение:** Groq — default AI провайдер (через OpenAI-compat API). Google Gemini — альтернатива (env `AI_PROVIDER=google`). Self-hosted LLM (ollama) — не устанавливаем на VM.
|
||||||
|
|
||||||
**Проблема:** 30s — константа времени написания кода. Функции с `timeout_sec=700` (stress-тесты, batch-задачи) падают с `context deadline exceeded` раньше чем успевают завершиться.
|
**Причина:**
|
||||||
|
- `sless-plan` — **клиентский инструмент**. Запускается на машине разработчика, не на сервере оператора. Разработчик сам отвечает за наличие доступа к AI API.
|
||||||
|
- Self-hosted LLM на VM облака — чужая инфраструктура, нецелевое использование ресурсов, потенциальные трения с облачной командой.
|
||||||
|
- Groq — бесплатный tier, OpenAI-compat API (минимальный код), модель `llama-3.3-70b-versatile` достаточна для code review.
|
||||||
|
|
||||||
**Решение:** Перед каждым вызовом читать `Function.Spec.TimeoutSec` из k8s и создавать `http.Client` с таймаутом = `TimeoutSec + 5s`.
|
**Будущее:** когда облако запустит свой LLM endpoint — добавить case `"cloud"` в `NewProvider`, endpoint из env. Stub `internal/ai/cloud_llm.go` уже есть.
|
||||||
|
|
||||||
**Почему +5s буфер:**
|
**Переменные окружения для разработчика:**
|
||||||
- Нельзя ставить ровно `TimeoutSec` — есть сетевые задержки, TLS handshake, время на DNS резолв внутри кластера.
|
```bash
|
||||||
- 5s достаточно для любых сетевых задержек в локальном k8s кластере.
|
export GROQ_API_KEY=gsk_... # обязательно для Groq
|
||||||
- Если функция реально завис на TimeoutSec — runtime сам должен прервать работу (это ответственность функции, не прокси).
|
export AI_PROVIDER=groq # default, можно опустить
|
||||||
|
export GROQ_MODEL=llama-3.3-70b-versatile # default, можно опустить
|
||||||
**Почему не кешировать 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`
|
**Коммит:** `11b484e`
|
||||||
|
|||||||
-159
@@ -1,159 +0,0 @@
|
|||||||
# 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": "..."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@@ -2,190 +2,6 @@
|
|||||||
|
|
||||||
> Сюда записываем проблемы с которыми столкнулись и как их решили.
|
> Сюда записываем проблемы с которыми столкнулись и как их решили.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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 |
|
|
||||||
|
|
||||||
## Шаблон записи
|
## Шаблон записи
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -631,127 +447,3 @@ Attribute runtime value must be one of: ["nodejs20" "python3.11" "go1.21"], got:
|
|||||||
### Версии
|
### Версии
|
||||||
- Оператор: `naeel/sless-operator:v0.1.13`
|
- Оператор: `naeel/sless-operator:v0.1.13`
|
||||||
- Провайдер: `terra.k8c.ru/naeel/sless v0.1.7`
|
- Провайдер: `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 — обязательны явные таймауты.
|
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# Работа с удалённой машиной Nubes
|
||||||
|
|
||||||
|
Дата: 2026-03-12
|
||||||
|
|
||||||
|
## Проблема
|
||||||
|
|
||||||
|
Локальный терминал Copilot нестабилен из-за VPN — команды зависают, таймауты.
|
||||||
|
**Весь код физически живёт на удалённой Ubuntu VM**, доступной по SSH.
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
```
|
||||||
|
Локальная машина (Krupski)
|
||||||
|
└── SSHFS → /home/naeel/remote_dev → naeel@5.172.178.213:/home/naeel/terra
|
||||||
|
└── sless/ ← реальный репозиторий
|
||||||
|
```
|
||||||
|
|
||||||
|
## Подключение к VM
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Хост
|
||||||
|
VM_HOST=naeel@5.172.178.213
|
||||||
|
VM_KEY=/home/naeel/.ssh/id_ed25519 # ~/.ssh/id_ed25519 = копия secrets/naeel_vm_id_ed25519
|
||||||
|
|
||||||
|
# Проверить подключение
|
||||||
|
ssh -i /home/naeel/.ssh/id_ed25519 -o StrictHostKeyChecking=no naeel@5.172.178.213 "hostname && whoami"
|
||||||
|
|
||||||
|
# Выполнить команду удалённо
|
||||||
|
ssh -i /home/naeel/.ssh/id_ed25519 -o StrictHostKeyChecking=no naeel@5.172.178.213 "cd /home/naeel/terra/sless && <команда>"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Путь к репозиторию на VM
|
||||||
|
|
||||||
|
```
|
||||||
|
/home/naeel/terra/sless
|
||||||
|
```
|
||||||
|
|
||||||
|
## Git push через SSH (Gitea)
|
||||||
|
|
||||||
|
Remote origin использует HTTPS → нужен SSH для беспарольного пуша.
|
||||||
|
|
||||||
|
На VM уже лежит ключ `~/.ssh/gitea_key` (тот же ключ, что `id_ed25519`, зарегистрирован в Gitea).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Пуш с VM
|
||||||
|
ssh -i /home/naeel/.ssh/id_ed25519 -o StrictHostKeyChecking=no naeel@5.172.178.213 \
|
||||||
|
'cd /home/naeel/terra/sless && \
|
||||||
|
GIT_SSH_COMMAND="ssh -i ~/.ssh/gitea_key -o StrictHostKeyChecking=no" \
|
||||||
|
git push git@gitea-naeel.giteak8s.services.ngcloud.ru:naeel/sless.git <branch>'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gitea SSH ключи
|
||||||
|
|
||||||
|
Ключ `~/.ssh/id_ed25519` зарегистрирован в Gitea:
|
||||||
|
- Email: tazet@narod.ru
|
||||||
|
- Fingerprint: `SHA256:WUAvQXXmRUr6jHHdkMDljKKBUEQ8ot2cTU3YSixqYe4`
|
||||||
|
|
||||||
|
## Типовой git workflow (commit + push)
|
||||||
|
|
||||||
|
Токен лежит в `secrets/gitea.token` (Gitea PAT, repository read+write).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TOKEN=$(cat /home/naeel/remote_dev/sless/secrets/gitea.token | tr -d '\n')
|
||||||
|
ssh -i /home/naeel/.ssh/id_ed25519 -o StrictHostKeyChecking=no naeel@5.172.178.213 "
|
||||||
|
cd /home/naeel/terra/sless
|
||||||
|
git add <файлы>
|
||||||
|
git commit -m '<сообщение>'
|
||||||
|
git push https://naeel:${TOKEN}@gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless.git <branch>
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
## SSHFS монтирование (восстановить если упало)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sshfs \
|
||||||
|
-o IdentityFile=/home/naeel/.ssh/id_ed25519 \
|
||||||
|
-o StrictHostKeyChecking=no \
|
||||||
|
-o UserKnownHostsFile=/dev/null \
|
||||||
|
-o reconnect \
|
||||||
|
-o ServerAliveInterval=15 \
|
||||||
|
-o ServerAliveCountMax=3 \
|
||||||
|
naeel@5.172.178.213:/home/naeel/terra /home/naeel/remote_dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Примечания
|
||||||
|
|
||||||
|
- `secrets/naeel_vm_id_ed25519` и `~/.ssh/id_ed25519` — одинаковые ключи (SHA256 совпадает)
|
||||||
|
- При зависании локального терминала — выполнять команды через SSH на VM напрямую
|
||||||
|
- Git config на VM настроен: `user.name=Naeel`, `user.email=naeel@nubes.ru`
|
||||||
-763
@@ -1,763 +0,0 @@
|
|||||||
# Решения и обоснования
|
|
||||||
|
|
||||||
## 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
@@ -1,204 +0,0 @@
|
|||||||
# Архитектура системы
|
|
||||||
|
|
||||||
Последнее обновление: 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 |
|
|
||||||
+32
-325
@@ -1,330 +1,6 @@
|
|||||||
# Прогресс разработки
|
# Прогресс разработки
|
||||||
|
|
||||||
Последнее обновление: 2026-03-19 22:30
|
Последнее обновление: 2026-03-10 (добавлен прагматичный обзор Claude Opus 4.6)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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 |
|
|
||||||
|
|
||||||
|
|
||||||
## Статусы: ✅ готово | 🔄 в процессе | ⏳ не начато
|
## Статусы: ✅ готово | 🔄 в процессе | ⏳ не начато
|
||||||
|
|
||||||
@@ -554,6 +230,37 @@ https://sless.kube5s.ru/fn/sless-ffd1f598c169b0ae/pg-table-reader
|
|||||||
| 6 | `replicas` field в FunctionSpec | Низкий (v1.1) |
|
| 6 | `replicas` field в FunctionSpec | Низкий (v1.1) |
|
||||||
| 7 | RabbitMQ event triggers | Низкий (v2) |
|
| 7 | RabbitMQ event triggers | Низкий (v2) |
|
||||||
| 8 | Метрики → Victoria Metrics | Низкий |
|
| 8 | Метрики → Victoria Metrics | Низкий |
|
||||||
|
| 9 | `sless-plan` реально не запускает `terraform plan` — нужна интеграция в workflow (docs) | Средний |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-03-12 — AI-анализ terraform plan (ветка feat/ai-terraform-analysis)
|
||||||
|
|
||||||
|
| # | Компонент | Статус | Заметки |
|
||||||
|
|---|-----------|--------|----------|
|
||||||
|
| 1 | `internal/ai/provider.go` | ✅ | `LLMProvider` interface + `Config` struct + `NewProvider` фабрика |
|
||||||
|
| 2 | `internal/ai/google_gemini.go` | ✅ | Gemini REST API через `net/http` (без внешних SDK) |
|
||||||
|
| 3 | `internal/ai/prompts.go` | ✅ | Системные промпты уровней 1–5, `BuildPrompt`, `LevelName` |
|
||||||
|
| 4 | `internal/ai/analyzer.go` | ✅ | Оркестратор: `NewAnalyzer`, `Analyze`, `FormatResult`, `truncatePlan(8000)` |
|
||||||
|
| 5 | `internal/ai/cloud_llm.go` | ✅ | Заглушка для будущего облачного LLM |
|
||||||
|
| 6 | `cmd/sless-plan/main.go` | ✅ | CLI обёртка над `terraform plan` + AI анализ после |
|
||||||
|
| 7 | `terraform/provider/internal/provider/provider.go` | ✅ | `ai_hint_level` атрибут (опциональный Int64, default=0) в Schema и Configure |
|
||||||
|
| 8 | `go build ./internal/ai/... && go build ./cmd/sless-plan/...` | ✅ | BUILD OK на VM |
|
||||||
|
| 9 | `go build ./...` в terraform/provider/ | ✅ | BUILD OK на VM |
|
||||||
|
| 10 | Коммит + пуш | ✅ | `d5ce8c2` (ai pkg + cmd) → `582b589` (provider) |
|
||||||
|
|
||||||
|
**Ветка:** `feat/ai-terraform-analysis`
|
||||||
|
**Последний коммит:** `582b589`
|
||||||
|
|
||||||
|
**Как использовать:**
|
||||||
|
```hcl
|
||||||
|
provider "sless" {
|
||||||
|
endpoint = "https://sless-api.kube5s.ru"
|
||||||
|
token = var.token
|
||||||
|
ai_hint_level = 3 # 0=off (default), 1–5 = уровень анализа
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Затем вместо `terraform plan` использовать `sless-plan` — предоставит анализ от Gemini.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Инструкции: монтирование удалённой папки через SSHFS
|
||||||
|
|
||||||
|
Дата: 2026-03-12
|
||||||
|
|
||||||
|
Кратко: эти команды повторяют текущее у вас подключение, где
|
||||||
|
источник: `naeel@5.172.178.213:/home/naeel/terra`, и точка монтирования — `/home/<user>/remote_dev`.
|
||||||
|
|
||||||
|
1) Установка `sshfs` (Debian/Ubuntu):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y sshfs
|
||||||
|
```
|
||||||
|
|
||||||
|
2) Подготовка приватного ключа:
|
||||||
|
|
||||||
|
- Скопируйте приватный ключ на целевую машину или загрузите его безопасным способом.
|
||||||
|
- Пример копирования с локальной машины на целевой хост:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scp /home/naeel/remote_dev/sless/secrets/naeel_vm_id_ed25519 user@target:/home/user/.ssh/id_ed25519_sless
|
||||||
|
ssh user@target 'chmod 600 /home/user/.ssh/id_ed25519_sless'
|
||||||
|
```
|
||||||
|
|
||||||
|
(Оригинал ключа в репозитории: `secrets/naeel_vm_id_ed25519`)
|
||||||
|
|
||||||
|
3) Создать точку монтирования на целевом хосте:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /home/user/remote_dev
|
||||||
|
chown user:user /home/user/remote_dev
|
||||||
|
```
|
||||||
|
|
||||||
|
4) Команда монтирования (пример, повторяет ваше текущее подключение):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sshfs -o IdentityFile=/home/user/.ssh/id_ed25519_sless \
|
||||||
|
-o StrictHostKeyChecking=no \
|
||||||
|
-o UserKnownHostsFile=/dev/null \
|
||||||
|
-o reconnect \
|
||||||
|
-o ServerAliveInterval=15 \
|
||||||
|
-o ServerAliveCountMax=3 \
|
||||||
|
naeel@5.172.178.213:/home/naeel/terra /home/user/remote_dev
|
||||||
|
```
|
||||||
|
|
||||||
|
5) Проверка статуса монтирования и содержимого:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
findmnt /home/user/remote_dev
|
||||||
|
ls -la /home/user/remote_dev
|
||||||
|
```
|
||||||
|
|
||||||
|
6) Отключение:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
fusermount -u /home/user/remote_dev
|
||||||
|
```
|
||||||
|
|
||||||
|
7) (Опционально) systemd unit для автоподключения при старте:
|
||||||
|
|
||||||
|
Создайте файл `/etc/systemd/system/remote_dev.mount` со следующим содержимым (отредактируйте путь к ключу и `Where`):
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=SSHFS mount for /home/user/remote_dev
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Mount]
|
||||||
|
What=naeel@5.172.178.213:/home/naeel/terra
|
||||||
|
Where=/home/user/remote_dev
|
||||||
|
Type=fuse.sshfs
|
||||||
|
Options=IdentityFile=/home/user/.ssh/id_ed25519_sless,allow_other,reconnect,ServerAliveInterval=15,ServerAliveCountMax=3,StrictHostKeyChecking=no,UserKnownHostsFile=/dev/null
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
Затем выполнить:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now remote_dev.mount
|
||||||
|
```
|
||||||
|
|
||||||
|
8) Заметки по безопасности и надёжности:
|
||||||
|
|
||||||
|
- Храните приватный ключ с правами `600` и ограниченным доступом.
|
||||||
|
- Рассмотрите использование `ssh-agent` вместо копирования ключа.
|
||||||
|
- Опция `StrictHostKeyChecking=no` ослабляет проверку host key — используйте осознанно.
|
||||||
|
- Для массовой настройки можете создать скрипт установки и unit-файл через Ansible/Cloud-init.
|
||||||
|
|
||||||
|
---
|
||||||
|
Файл создан автоматически по запросу пользователя. Если хотите, могу:
|
||||||
|
- добавить инструкции для `macOS` или `CentOS`/`RHEL`;
|
||||||
|
- создать такой же unit-файл и загрузить его на целевой хост (при доступе по SSH).
|
||||||
+1
-1
@@ -16,7 +16,7 @@ crash.log
|
|||||||
# Provider plugins / caches
|
# Provider plugins / caches
|
||||||
.terraform.d/
|
.terraform.d/
|
||||||
|
|
||||||
*.tfvars
|
#*.tfvars
|
||||||
|
|
||||||
# Archives and build artifacts
|
# Archives and build artifacts
|
||||||
*.zip
|
*.zip
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
# 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"
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
requests==2.31.0
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "pg-info",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "sless nodejs20 function: pg version + table info",
|
|
||||||
"dependencies": {
|
|
||||||
"pg": "8.11.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
// 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();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# 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()
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
psycopg2-binary==2.9.9
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# 2026-03-17 00:00
|
|
||||||
# requirements.txt — зависимости для функции запуска SQL.
|
|
||||||
psycopg2-binary==2.9.9
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# 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()
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# 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,
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# 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}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
// 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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
// 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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "stress-js-async",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"dependencies": {
|
|
||||||
"pg": "^8.11.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
// 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();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "stress-js-badenv",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"dependencies": {}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
// 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 };
|
|
||||||
};
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# 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(),
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
psycopg2-binary==2.9.9
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# 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)}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
psycopg2-binary==2.9.9
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
# 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()
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
# 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,
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
|
|
||||||
# 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
|
|
||||||
# }
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
// 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"
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,407 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
# 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"
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# 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()
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
#!/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
@@ -12,7 +12,7 @@
|
|||||||
| `sless_trigger` | Публикует функцию: тип `http` создаёт публичный URL, тип `cron` — запуск по расписанию. |
|
| `sless_trigger` | Публикует функцию: тип `http` создаёт публичный URL, тип `cron` — запуск по расписанию. |
|
||||||
| `sless_job` | Запускает функцию однократно и ожидает завершения. Используется для одноразовых операций: инициализация БД, миграции, пакетная обработка. |
|
| `sless_job` | Запускает функцию однократно и ожидает завершения. Используется для одноразовых операций: инициализация БД, миграции, пакетная обработка. |
|
||||||
|
|
||||||
Стандартная связка для HTTP API: `sless_function` + `sless_trigger` с `type = "http"` — в результате функция доступна по URL вида `https://sless.kube5s.ru/fn/<namespace>/<имя-функции>`.
|
Стандартная связка для HTTP API: `sless_function` + `sless_trigger` с `type = "http"` — в результате функция доступна по URL вида `https://sless-api.kube5s.ru/fn/<namespace>/<имя-функции>`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
|
|
||||||
- Terraform >= 1.0
|
- Terraform >= 1.0
|
||||||
- JWT-токен для аутентификации в sless API
|
- JWT-токен для аутентификации в sless API
|
||||||
- Доступ к `https://sless.kube5s.ru`
|
- Доступ к `https://sless-api.kube5s.ru`
|
||||||
|
|
||||||
## Конфигурация провайдера
|
## Конфигурация провайдера
|
||||||
|
|
||||||
@@ -28,9 +28,9 @@
|
|||||||
|
|
||||||
```hcl
|
```hcl
|
||||||
provider "sless" {
|
provider "sless" {
|
||||||
endpoint = "https://sless.kube5s.ru"
|
endpoint = "https://sless-api.kube5s.ru"
|
||||||
token = var.token
|
token = var.token
|
||||||
nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1"
|
nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ terraform init
|
|||||||
terraform apply -auto-approve
|
terraform apply -auto-approve
|
||||||
|
|
||||||
# Вызов HTTP-функции с передачей имени:
|
# Вызов HTTP-функции с передачей имени:
|
||||||
curl -s -X POST https://sless.kube5s.ru/fn/<namespace>/hello-http \
|
curl -s -X POST https://sless-api.kube5s.ru/fn/<namespace>/hello-http \
|
||||||
-H 'Content-Type: application/json' -d '{"name":"World"}'
|
-H 'Content-Type: application/json' -d '{"name":"World"}'
|
||||||
|
|
||||||
# Результат задания:
|
# Результат задания:
|
||||||
@@ -114,7 +114,7 @@ terraform init
|
|||||||
terraform apply -auto-approve
|
terraform apply -auto-approve
|
||||||
|
|
||||||
terraform output job_result
|
terraform output job_result
|
||||||
curl -s https://sless.kube5s.ru/fn/<namespace>/simple-py-time-display
|
curl -s https://sless-api.kube5s.ru/fn/<namespace>/simple-py-time-display
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -127,7 +127,7 @@ terraform init
|
|||||||
terraform apply -auto-approve
|
terraform apply -auto-approve
|
||||||
|
|
||||||
terraform output job_result
|
terraform output job_result
|
||||||
curl -s https://sless.kube5s.ru/fn/<namespace>/simple-node-time-display
|
curl -s https://sless-api.kube5s.ru/fn/<namespace>/simple-node-time-display
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
# 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"
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
requests==2.31.0
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "pg-info",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "sless nodejs20 function: pg version + table info",
|
|
||||||
"dependencies": {
|
|
||||||
"pg": "8.11.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
// 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();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# 2026-03-17 00:00
|
|
||||||
# requirements.txt — зависимости для функции запуска SQL.
|
|
||||||
psycopg2-binary==2.9.9
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# 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()
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
psycopg2-binary==2.9.9
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
# 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()
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
# 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,
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
// 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"
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
# 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"
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# 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()
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
# Изменено: 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()
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# Изменено: 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"}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
# Изменено: 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()
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
# Примеры использования 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/ — исходный код функций
|
|
||||||
```
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# Изменено: 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"}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# Изменено: 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"}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
# Изменено: 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,
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
# 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
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# 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
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# 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"
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# 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"
|
|
||||||
}
|
|
||||||
@@ -5,13 +5,13 @@ terraform {
|
|||||||
required_providers {
|
required_providers {
|
||||||
sless = {
|
sless = {
|
||||||
source = "terra.k8c.ru/naeel/sless"
|
source = "terra.k8c.ru/naeel/sless"
|
||||||
version = "~> 0.1.18"
|
version = "~> 0.1.17"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
provider "sless" {
|
provider "sless" {
|
||||||
endpoint = "https://sless.kube5s.ru"
|
endpoint = "https://sless-api.kube5s.ru"
|
||||||
token = var.token
|
token = var.token
|
||||||
nubes_endpoint = "https://deck-test.ngcloud.ru/api/v1"
|
nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,14 +11,14 @@ terraform {
|
|||||||
required_providers {
|
required_providers {
|
||||||
sless = {
|
sless = {
|
||||||
source = "terra.k8c.ru/naeel/sless"
|
source = "terra.k8c.ru/naeel/sless"
|
||||||
version = "~> 0.1.18"
|
version = "~> 0.1.17"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
provider "sless" {
|
provider "sless" {
|
||||||
endpoint = "https://sless.kube5s.ru"
|
endpoint = "https://sless-api.kube5s.ru"
|
||||||
token = var.token
|
token = var.token
|
||||||
nubes_endpoint = "https://deck-test.ngcloud.ru/api/v1"
|
nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,14 +14,14 @@ terraform {
|
|||||||
# Провайдер для управления serverless функциями через sless API
|
# Провайдер для управления serverless функциями через sless API
|
||||||
sless = {
|
sless = {
|
||||||
source = "terra.k8c.ru/naeel/sless"
|
source = "terra.k8c.ru/naeel/sless"
|
||||||
version = "~> 0.1.18"
|
version = "~> 0.1.17"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# sless провайдер подключается к API кластера.
|
# sless провайдер подключается к API кластера.
|
||||||
provider "sless" {
|
provider "sless" {
|
||||||
endpoint = "https://sless.kube5s.ru"
|
endpoint = "https://sless-api.kube5s.ru"
|
||||||
token = var.token
|
token = var.token
|
||||||
nubes_endpoint = "https://deck-test.ngcloud.ru/api/v1"
|
nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ terraform {
|
|||||||
required_providers {
|
required_providers {
|
||||||
sless = {
|
sless = {
|
||||||
source = "terra.k8c.ru/naeel/sless"
|
source = "terra.k8c.ru/naeel/sless"
|
||||||
version = "~> 0.1.18"
|
version = "~> 0.1.17"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
provider "sless" {
|
provider "sless" {
|
||||||
endpoint = "https://sless.kube5s.ru"
|
endpoint = "https://sless-api.kube5s.ru"
|
||||||
token = var.token
|
token = var.token
|
||||||
nubes_endpoint = "https://deck-test.ngcloud.ru/api/v1"
|
nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1"
|
||||||
|
ai_hint_level = 3
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,13 +19,13 @@ terraform {
|
|||||||
required_providers {
|
required_providers {
|
||||||
sless = {
|
sless = {
|
||||||
source = "terra.k8c.ru/naeel/sless"
|
source = "terra.k8c.ru/naeel/sless"
|
||||||
version = "~> 0.1.18"
|
version = "~> 0.1.17"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
provider "sless" {
|
provider "sless" {
|
||||||
endpoint = "https://sless.kube5s.ru"
|
endpoint = "https://sless-api.kube5s.ru"
|
||||||
token = var.token
|
token = var.token
|
||||||
nubes_endpoint = "https://deck-test.ngcloud.ru/api/v1"
|
nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,13 +18,13 @@ terraform {
|
|||||||
required_providers {
|
required_providers {
|
||||||
sless = {
|
sless = {
|
||||||
source = "terra.k8c.ru/naeel/sless"
|
source = "terra.k8c.ru/naeel/sless"
|
||||||
version = "~> 0.1.18"
|
version = "~> 0.1.17"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
provider "sless" {
|
provider "sless" {
|
||||||
endpoint = "https://sless.kube5s.ru"
|
endpoint = "https://sless-api.kube5s.ru"
|
||||||
token = var.token
|
token = var.token
|
||||||
nubes_endpoint = "https://deck-test.ngcloud.ru/api/v1"
|
nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ require (
|
|||||||
github.com/prometheus/client_model v0.3.0 // indirect
|
github.com/prometheus/client_model v0.3.0 // indirect
|
||||||
github.com/prometheus/common v0.37.0 // indirect
|
github.com/prometheus/common v0.37.0 // indirect
|
||||||
github.com/prometheus/procfs v0.8.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/rs/xid v1.6.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.5 // indirect
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
github.com/tinylib/msgp v1.6.1 // indirect
|
github.com/tinylib/msgp v1.6.1 // indirect
|
||||||
|
|||||||
@@ -270,8 +270,6 @@ 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.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 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5mo=
|
||||||
github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4=
|
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/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 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||||
@@ -307,7 +305,6 @@ 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.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 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
|
||||||
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
|
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 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4=
|
||||||
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
|
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
|
||||||
go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI=
|
go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI=
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// internal/ai/analyzer.go
|
||||||
|
// Дата: 2026-03-12
|
||||||
|
// Оркестратор AI-анализа: читает уровень, формирует промпт,
|
||||||
|
// вызывает LLM-провайдер, форматирует вывод для пользователя.
|
||||||
|
|
||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Analyzer — основной компонент AI-анализа.
|
||||||
|
// Создаётся один раз при старте sless-plan, используется для одного анализа.
|
||||||
|
type Analyzer struct {
|
||||||
|
provider LLMProvider
|
||||||
|
level int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAnalyzer создаёт Analyzer из конфига.
|
||||||
|
// Возвращает ошибку если провайдер не может быть инициализирован.
|
||||||
|
func NewAnalyzer(cfg Config) (*Analyzer, error) {
|
||||||
|
if cfg.HintLevel == 0 {
|
||||||
|
// Уровень 0 — AI выключен, провайдер не нужен
|
||||||
|
return &Analyzer{level: 0}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
provider, err := NewProvider(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ошибка инициализации AI провайдера: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Analyzer{
|
||||||
|
provider: provider,
|
||||||
|
level: cfg.HintLevel,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEnabled возвращает true если уровень > 0 и провайдер инициализирован.
|
||||||
|
func (a *Analyzer) IsEnabled() bool {
|
||||||
|
return a.level > 0 && a.provider != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analyze отправляет вывод terraform plan в LLM и возвращает отформатированный результат.
|
||||||
|
// planText — текстовый вывод terraform plan.
|
||||||
|
func (a *Analyzer) Analyze(ctx context.Context, planText string) (string, error) {
|
||||||
|
if !a.IsEnabled() {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ограничиваем размер payload — большие планы обрезаем
|
||||||
|
// чтобы не превысить лимит токенов LLM и не отправлять лишнее
|
||||||
|
planText = truncatePlan(planText, 8000)
|
||||||
|
|
||||||
|
prompt := BuildPrompt(a.level, planText)
|
||||||
|
result, err := a.provider.Analyze(ctx, prompt)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("AI анализ не удался: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return FormatResult(a.level, a.provider.Name(), result), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatResult оборачивает ответ LLM в читаемый блок для вывода в терминал.
|
||||||
|
func FormatResult(level int, providerName, result string) string {
|
||||||
|
separator := strings.Repeat("─", 60)
|
||||||
|
header := fmt.Sprintf("AI Analysis (level %d: %s) via %s", level, LevelName(level), providerName)
|
||||||
|
return fmt.Sprintf("\n%s\n %s\n%s\n%s\n%s\n",
|
||||||
|
separator,
|
||||||
|
header,
|
||||||
|
separator,
|
||||||
|
result,
|
||||||
|
separator,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncatePlan обрезает текст плана до maxChars символов с пометкой об обрезке.
|
||||||
|
// Нужно чтобы не превышать лимиты токенов LLM и не отправлять лишние данные.
|
||||||
|
func truncatePlan(text string, maxChars int) string {
|
||||||
|
if len(text) <= maxChars {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
return text[:maxChars] + fmt.Sprintf("\n\n[... обрезано, показаны первые %d символов из %d ...]", maxChars, len(text))
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// internal/ai/cloud_llm.go
|
||||||
|
// Дата: 2026-03-12
|
||||||
|
// Заглушка для будущего облачного LLM.
|
||||||
|
// Когда облако предоставит свой LLM — реализовать этот файл
|
||||||
|
// и переключить AI_PROVIDER=cloud. Остальной код не меняется.
|
||||||
|
|
||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CloudLLMProvider — будущая реализация LLMProvider для облачного LLM.
|
||||||
|
// Пока возвращает ошибку "не реализован".
|
||||||
|
type CloudLLMProvider struct {
|
||||||
|
endpoint string
|
||||||
|
token string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCloudLLMProvider создаёт заглушку провайдера облачного LLM.
|
||||||
|
func NewCloudLLMProvider(endpoint, token string) (*CloudLLMProvider, error) {
|
||||||
|
if endpoint == "" {
|
||||||
|
return nil, fmt.Errorf("CLOUD_LLM_ENDPOINT не задан")
|
||||||
|
}
|
||||||
|
return &CloudLLMProvider{
|
||||||
|
endpoint: endpoint,
|
||||||
|
token: token,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CloudLLMProvider) Name() string {
|
||||||
|
return fmt.Sprintf("cloud-llm/%s", c.endpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analyze — TODO: реализовать когда облако предоставит LLM API.
|
||||||
|
// Документация по API будет в doc/api/cloud-llm.md.
|
||||||
|
func (c *CloudLLMProvider) Analyze(_ context.Context, _ string) (string, error) {
|
||||||
|
return "", fmt.Errorf("cloud LLM провайдер ещё не реализован (endpoint: %s)", c.endpoint)
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
// internal/ai/google_gemini.go
|
||||||
|
// Дата: 2026-03-12
|
||||||
|
// Реализация LLMProvider для Google Gemini API.
|
||||||
|
// Использует REST API напрямую через net/http — без внешних SDK,
|
||||||
|
// чтобы не нарушать совместимость существующего go.mod.
|
||||||
|
|
||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const geminiBaseURL = "https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent"
|
||||||
|
|
||||||
|
// GeminiProvider реализует LLMProvider через Google Gemini REST API.
|
||||||
|
type GeminiProvider struct {
|
||||||
|
apiKey string
|
||||||
|
model string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGeminiProvider создаёт провайдера для Google Gemini.
|
||||||
|
// apiKey — значение env GOOGLE_AI_API_KEY.
|
||||||
|
func NewGeminiProvider(apiKey, model string) (*GeminiProvider, error) {
|
||||||
|
if apiKey == "" {
|
||||||
|
return nil, fmt.Errorf("GOOGLE_AI_API_KEY не задан")
|
||||||
|
}
|
||||||
|
return &GeminiProvider{
|
||||||
|
apiKey: apiKey,
|
||||||
|
model: model,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 60 * time.Second,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *GeminiProvider) Name() string {
|
||||||
|
return fmt.Sprintf("google-gemini/%s", g.model)
|
||||||
|
}
|
||||||
|
|
||||||
|
// geminiRequest — структура запроса к Gemini API.
|
||||||
|
type geminiRequest struct {
|
||||||
|
Contents []geminiContent `json:"contents"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type geminiContent struct {
|
||||||
|
Parts []geminiPart `json:"parts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type geminiPart struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// geminiResponse — структура ответа от Gemini API.
|
||||||
|
type geminiResponse struct {
|
||||||
|
Candidates []struct {
|
||||||
|
Content struct {
|
||||||
|
Parts []struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"parts"`
|
||||||
|
} `json:"content"`
|
||||||
|
} `json:"candidates"`
|
||||||
|
Error *struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
} `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analyze отправляет промпт в Gemini и возвращает текстовый ответ.
|
||||||
|
func (g *GeminiProvider) Analyze(ctx context.Context, prompt string) (string, error) {
|
||||||
|
reqBody := geminiRequest{
|
||||||
|
Contents: []geminiContent{
|
||||||
|
{Parts: []geminiPart{{Text: prompt}}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, err := json.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("ошибка маршалинга запроса: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf(geminiBaseURL+"?key=%s", g.model, g.apiKey)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyBytes))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("ошибка создания HTTP запроса: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := g.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("ошибка HTTP запроса к Gemini: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBytes, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("ошибка чтения ответа: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var gemResp geminiResponse
|
||||||
|
if err := json.Unmarshal(respBytes, &gemResp); err != nil {
|
||||||
|
return "", fmt.Errorf("ошибка парсинга ответа Gemini: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем HTTP-ошибку и ошибку в теле ответа
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
msg := fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||||
|
if gemResp.Error != nil {
|
||||||
|
msg = gemResp.Error.Message
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("Gemini API ошибка: %s", msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(gemResp.Candidates) == 0 || len(gemResp.Candidates[0].Content.Parts) == 0 {
|
||||||
|
return "", fmt.Errorf("Gemini вернул пустой ответ")
|
||||||
|
}
|
||||||
|
|
||||||
|
return gemResp.Candidates[0].Content.Parts[0].Text, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
// internal/ai/openai_compat.go
|
||||||
|
// Дата: 2026-03-12
|
||||||
|
// OpenAI-совместимый провайдер — работает с Groq, OpenRouter, OpenAI и любым
|
||||||
|
// другим сервисом реализующим /v1/chat/completions API.
|
||||||
|
// Нет геоблока в отличие от Google Gemini.
|
||||||
|
|
||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OpenAICompatProvider реализует LLMProvider через OpenAI-совместимый API.
|
||||||
|
// Groq endpoint: https://api.groq.com/openai/v1/chat/completions
|
||||||
|
type OpenAICompatProvider struct {
|
||||||
|
endpoint string
|
||||||
|
apiKey string
|
||||||
|
model string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOpenAICompatProvider создаёт провайдера для любого OpenAI-совместимого API.
|
||||||
|
// endpoint — полный URL до /v1/chat/completions
|
||||||
|
// apiKey — Bearer токен (GROQ_API_KEY / OPENAI_API_KEY / OPENROUTER_API_KEY)
|
||||||
|
// model — название модели (например "llama-3.3-70b-versatile" для Groq)
|
||||||
|
func NewOpenAICompatProvider(endpoint, apiKey, model string) (*OpenAICompatProvider, error) {
|
||||||
|
if apiKey == "" {
|
||||||
|
return nil, fmt.Errorf("GROQ_API_KEY не задан")
|
||||||
|
}
|
||||||
|
if endpoint == "" {
|
||||||
|
endpoint = "https://api.groq.com/openai/v1/chat/completions"
|
||||||
|
}
|
||||||
|
if model == "" {
|
||||||
|
model = "llama-3.3-70b-versatile"
|
||||||
|
}
|
||||||
|
return &OpenAICompatProvider{
|
||||||
|
endpoint: endpoint,
|
||||||
|
apiKey: apiKey,
|
||||||
|
model: model,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 60 * time.Second,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *OpenAICompatProvider) Name() string {
|
||||||
|
return fmt.Sprintf("groq/%s", p.model)
|
||||||
|
}
|
||||||
|
|
||||||
|
// openAIRequest — тело запроса к /v1/chat/completions
|
||||||
|
type openAIRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []openAIMessage `json:"messages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type openAIMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// openAIResponse — тело ответа от /v1/chat/completions
|
||||||
|
type openAIResponse struct {
|
||||||
|
Choices []struct {
|
||||||
|
Message struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"message"`
|
||||||
|
} `json:"choices"`
|
||||||
|
Error *struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analyze отправляет промпт в OpenAI-совместимый API и возвращает ответ.
|
||||||
|
func (p *OpenAICompatProvider) Analyze(ctx context.Context, prompt string) (string, error) {
|
||||||
|
reqBody := openAIRequest{
|
||||||
|
Model: p.model,
|
||||||
|
Messages: []openAIMessage{
|
||||||
|
{Role: "user", Content: prompt},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, err := json.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("marshal error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.endpoint, bytes.NewReader(bodyBytes))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create request error: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||||
|
|
||||||
|
resp, err := p.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("http error: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBytes, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read response error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed openAIResponse
|
||||||
|
if err := json.Unmarshal(respBytes, &parsed); err != nil {
|
||||||
|
return "", fmt.Errorf("parse response error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed.Error != nil {
|
||||||
|
return "", fmt.Errorf("API ошибка: %s", parsed.Error.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parsed.Choices) == 0 || parsed.Choices[0].Message.Content == "" {
|
||||||
|
return "", fmt.Errorf("пустой ответ от API")
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.Choices[0].Message.Content, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// internal/ai/prompts.go
|
||||||
|
// Дата: 2026-03-12
|
||||||
|
// Шаблоны system-промптов для каждого уровня анализа (1–5).
|
||||||
|
// Уровень 0 — AI не вызывается вообще (проверка в analyzer.go).
|
||||||
|
|
||||||
|
package ai
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// systemPrompts — system prompt для каждого уровня.
|
||||||
|
// Каждый уровень строго ограничен своей областью — не расширять без явного указания.
|
||||||
|
var systemPrompts = map[int]string{
|
||||||
|
1: `Ты — анализатор Terraform конфигурации. Твоя задача: ТОЛЬКО синтаксический анализ.
|
||||||
|
Найди синтаксические ошибки в HCL: опечатки в ключевых словах, незакрытые блоки, неверные типы значений, невалидные атрибуты.
|
||||||
|
НЕ давай рекомендаций по улучшению, НЕ анализируй логику, НЕ комментируй архитектуру.
|
||||||
|
Формат ответа: список найденных ошибок или "✓ Синтаксических ошибок не обнаружено."`,
|
||||||
|
|
||||||
|
2: `Ты — анализатор Terraform конфигурации. Твоя задача: синтаксис + базовые проблемы.
|
||||||
|
1. Синтаксические ошибки в HCL.
|
||||||
|
2. Базовые проблемы: неиспользуемые переменные/ресурсы, пустые обязательные значения, дублирующиеся ресурсы.
|
||||||
|
НЕ анализируй зависимости, архитектуру, безопасность.
|
||||||
|
Формат: список проблем с пометкой [SYNTAX] или [BASIC], или "✓ Проблем не обнаружено."`,
|
||||||
|
|
||||||
|
3: `Ты — анализатор Terraform конфигурации. Твоя задача: синтаксис + базовые проблемы + зависимости.
|
||||||
|
1. Синтаксические ошибки.
|
||||||
|
2. Базовые проблемы (неиспользуемые ресурсы, пустые значения).
|
||||||
|
3. Анализ зависимостей: потенциальные race conditions при apply/destroy, неявные зависимости, проблемы с порядком создания ресурсов.
|
||||||
|
НЕ давай рекомендаций по безопасности или рефакторингу.
|
||||||
|
Формат: список проблем с пометкой [SYNTAX], [BASIC] или [DEPS], или "✓ Проблем не обнаружено."`,
|
||||||
|
|
||||||
|
4: `Ты — анализатор Terraform конфигурации. Твоя задача: полный анализ + безопасность.
|
||||||
|
1. Синтаксические ошибки.
|
||||||
|
2. Базовые проблемы.
|
||||||
|
3. Зависимости и порядок операций.
|
||||||
|
4. Безопасность и best practices: открытые порты (0.0.0.0/0), избыточные права доступа, незашифрованные ресурсы, жёстко заданные секреты.
|
||||||
|
Формат: список проблем с пометкой [SYNTAX], [BASIC], [DEPS] или [SECURITY], или "✓ Проблем не обнаружено."`,
|
||||||
|
|
||||||
|
5: `Ты — старший DevOps-инженер, проводишь полный аудит Terraform конфигурации.
|
||||||
|
Анализируй:
|
||||||
|
1. Синтаксические ошибки.
|
||||||
|
2. Базовые проблемы.
|
||||||
|
3. Зависимости и порядок операций.
|
||||||
|
4. Безопасность и best practices.
|
||||||
|
5. Оптимизация: возможности для рефакторинга, использование модулей, устаревшие паттерны, альтернативные подходы.
|
||||||
|
Давай конкретные рекомендации с примерами кода где уместно.
|
||||||
|
Формат: список проблем и рекомендаций с пометкой [SYNTAX], [BASIC], [DEPS], [SECURITY] или [OPT].`,
|
||||||
|
}
|
||||||
|
|
||||||
|
// levelNames — человекочитаемые названия уровней для вывода.
|
||||||
|
var levelNames = map[int]string{
|
||||||
|
0: "OFF",
|
||||||
|
1: "SYNTAX",
|
||||||
|
2: "BASIC",
|
||||||
|
3: "MODERATE",
|
||||||
|
4: "DETAILED",
|
||||||
|
5: "FULL",
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildPrompt формирует финальный промпт для отправки в LLM.
|
||||||
|
// planText — вывод terraform plan (текст или JSON).
|
||||||
|
func BuildPrompt(level int, planText string) string {
|
||||||
|
sysPrompt, ok := systemPrompts[level]
|
||||||
|
if !ok {
|
||||||
|
sysPrompt = systemPrompts[1]
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s\n\nTerraform plan output:\n```\n%s\n```", sysPrompt, planText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LevelName возвращает название уровня для вывода пользователю.
|
||||||
|
func LevelName(level int) string {
|
||||||
|
name, ok := levelNames[level]
|
||||||
|
if !ok {
|
||||||
|
return "UNKNOWN"
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// internal/ai/provider.go
|
||||||
|
// Дата: 2026-03-12 (обновлён 2026-03-12: добавлен Groq / OpenAI-compat)
|
||||||
|
// Интерфейс LLMProvider — абстракция над любым LLM-бэкендом.
|
||||||
|
// Реализации: Google Gemini, Groq (OpenAI-compat), облачный LLM (stub).
|
||||||
|
|
||||||
|
package ai
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// LLMProvider — единственный интерфейс для всех LLM-провайдеров.
|
||||||
|
// Менять провайдер = менять одну env-переменную AI_PROVIDER.
|
||||||
|
type LLMProvider interface {
|
||||||
|
// Analyze отправляет промпт в LLM и возвращает текстовый ответ.
|
||||||
|
Analyze(ctx context.Context, prompt string) (string, error)
|
||||||
|
// Name возвращает имя провайдера для логирования.
|
||||||
|
Name() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config — конфигурация анализатора, читается из env-переменных.
|
||||||
|
type Config struct {
|
||||||
|
// Provider: "groq" (по умолчанию), "google", "cloud"
|
||||||
|
Provider string
|
||||||
|
// HintLevel: 0–5, если 0 — AI не вызывается
|
||||||
|
HintLevel int
|
||||||
|
// Groq / OpenAI-совместимые провайдеры
|
||||||
|
GroqAPIKey string
|
||||||
|
GroqModel string // по умолчанию "llama-3.3-70b-versatile"
|
||||||
|
GroqEndpoint string // по умолчанию https://api.groq.com/openai/v1/chat/completions
|
||||||
|
// Google Gemini (геоблок в РФ)
|
||||||
|
GoogleAPIKey string
|
||||||
|
GoogleModel string // по умолчанию "gemini-2.0-flash"
|
||||||
|
// Будущий облачный LLM
|
||||||
|
CloudEndpoint string
|
||||||
|
CloudToken string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewProvider — фабрика провайдеров. Выбор по Config.Provider.
|
||||||
|
// При добавлении нового провайдера — только сюда добавить case.
|
||||||
|
func NewProvider(cfg Config) (LLMProvider, error) {
|
||||||
|
switch cfg.Provider {
|
||||||
|
case "groq":
|
||||||
|
return NewOpenAICompatProvider(cfg.GroqEndpoint, cfg.GroqAPIKey, cfg.GroqModel)
|
||||||
|
case "google":
|
||||||
|
model := cfg.GoogleModel
|
||||||
|
if model == "" {
|
||||||
|
model = "gemini-2.0-flash"
|
||||||
|
}
|
||||||
|
return NewGeminiProvider(cfg.GoogleAPIKey, model)
|
||||||
|
case "cloud":
|
||||||
|
return NewCloudLLMProvider(cfg.CloudEndpoint, cfg.CloudToken)
|
||||||
|
default:
|
||||||
|
// По умолчанию groq — работает из РФ без геоблоков
|
||||||
|
return NewOpenAICompatProvider(cfg.GroqEndpoint, cfg.GroqAPIKey, cfg.GroqModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user