Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6960e87a5c | ||
|
|
11b484e1e7 | ||
|
|
ef7613d09f | ||
|
|
d9ed96e201 | ||
|
|
582b589e38 | ||
|
|
d5ce8c2fac | ||
|
|
a7fc960c82 | ||
|
|
cea13cf239 | ||
|
|
824dad7969 | ||
|
|
0011bd0f39 | ||
|
|
20fb5f6539 | ||
|
|
57193536f3 | ||
|
|
d3c54eb521 | ||
|
|
e5e6d273d2 | ||
|
|
871e6e8a1d | ||
|
|
6de90ac5ac | ||
|
|
3372cb1983 | ||
|
|
d6a2e224ca | ||
|
|
000d45ad6f | ||
|
|
5c6a37313b | ||
|
|
81be52f6ef | ||
|
|
2ca3137c0b | ||
|
|
64bd495cf9 | ||
|
|
c4559dd365 | ||
|
|
78d11aeb26 | ||
|
|
a709b38f6b | ||
|
|
babd8e6109 | ||
|
|
6443f21ddc |
@@ -57,3 +57,4 @@ test.token
|
|||||||
**/dist/
|
**/dist/
|
||||||
# дополнительные вариации переменных/файлов конфигурации
|
# дополнительные вариации переменных/файлов конфигурации
|
||||||
*.tfvars.json
|
*.tfvars.json
|
||||||
|
examples/.git
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import (
|
|||||||
// FunctionSpec — желаемое состояние функции.
|
// FunctionSpec — желаемое состояние функции.
|
||||||
// Описывает всё необходимое для сборки и запуска пользовательского кода.
|
// Описывает всё необходимое для сборки и запуска пользовательского кода.
|
||||||
type FunctionSpec struct {
|
type FunctionSpec struct {
|
||||||
// Runtime — язык и версия выполнения (go1.21, python3.11, nodejs20)
|
// Runtime — язык и версия выполнения (go1.23, python3.11, nodejs20)
|
||||||
// +kubebuilder:validation:Enum=go1.21;python3.11;nodejs20
|
// +kubebuilder:validation:Enum=go1.23;python3.11;nodejs20
|
||||||
// +kubebuilder:validation:Required
|
// +kubebuilder:validation:Required
|
||||||
Runtime string `json:"runtime"`
|
Runtime string `json:"runtime"`
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -65,10 +65,10 @@ spec:
|
|||||||
format: int32
|
format: int32
|
||||||
type: integer
|
type: integer
|
||||||
runtime:
|
runtime:
|
||||||
description: Runtime — язык и версия выполнения (go1.21, python3.11,
|
description: Runtime — язык и версия выполнения (go1.23, python3.11,
|
||||||
nodejs20)
|
nodejs20)
|
||||||
enum:
|
enum:
|
||||||
- go1.21
|
- go1.23
|
||||||
- python3.11
|
- python3.11
|
||||||
- nodejs20
|
- nodejs20
|
||||||
type: string
|
type: string
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Изменено: 2026-03-08
|
// Изменено: 2026-03-11
|
||||||
// FunctionReconciler — основной контроллер оператора.
|
// FunctionReconciler — основной контроллер оператора.
|
||||||
// Следит за CRD Function и управляет lifecycle функции:
|
// Следит за CRD Function и управляет lifecycle функции:
|
||||||
// Pending → Building (запуск kaniko Job) → Ready (образ собран, Deployment создан) / Failed
|
// Pending → Building (запуск kaniko Job) → Ready (образ собран, Deployment создан) / Failed
|
||||||
@@ -7,9 +7,12 @@
|
|||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
appsv1 "k8s.io/api/apps/v1"
|
appsv1 "k8s.io/api/apps/v1"
|
||||||
@@ -19,12 +22,14 @@ import (
|
|||||||
"k8s.io/apimachinery/pkg/api/resource"
|
"k8s.io/apimachinery/pkg/api/resource"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
"k8s.io/apimachinery/pkg/runtime"
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
|
"k8s.io/client-go/kubernetes"
|
||||||
ctrl "sigs.k8s.io/controller-runtime"
|
ctrl "sigs.k8s.io/controller-runtime"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/log"
|
"sigs.k8s.io/controller-runtime/pkg/log"
|
||||||
|
|
||||||
slessv1alpha1 "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/api/v1alpha1"
|
slessv1alpha1 "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/api/v1alpha1"
|
||||||
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/builder"
|
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/builder"
|
||||||
|
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/harbor"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FunctionReconciler reconciles a Function object
|
// FunctionReconciler reconciles a Function object
|
||||||
@@ -32,8 +37,10 @@ type FunctionReconciler struct {
|
|||||||
client.Client
|
client.Client
|
||||||
Scheme *runtime.Scheme
|
Scheme *runtime.Scheme
|
||||||
Builder *builder.Builder
|
Builder *builder.Builder
|
||||||
RegistrySecret string // имя Secret с docker credentials (для imagePullSecrets в подах функций)
|
KubeClient kubernetes.Interface // typed client для чтения логов build-подов
|
||||||
OperatorNamespace string // namespace оператора — откуда копируем RegistrySecret в sless-fn-*
|
RegistrySecret string // имя Secret с docker credentials (для imagePullSecrets в подах функций)
|
||||||
|
OperatorNamespace string // namespace оператора — откуда копируем RegistrySecret в sless-fn-*
|
||||||
|
HarborClient *harbor.Client // nil — Harbor не используется, EnsureProject пропускается
|
||||||
}
|
}
|
||||||
|
|
||||||
//+kubebuilder:rbac:groups=sless.kube5s.ru,resources=functions,verbs=get;list;watch;create;update;patch;delete
|
//+kubebuilder:rbac:groups=sless.kube5s.ru,resources=functions,verbs=get;list;watch;create;update;patch;delete
|
||||||
@@ -165,7 +172,13 @@ func (r *FunctionReconciler) checkBuild(ctx context.Context, fn *slessv1alpha1.F
|
|||||||
return ctrl.Result{Requeue: true}, nil
|
return ctrl.Result{Requeue: true}, nil
|
||||||
|
|
||||||
case "failed":
|
case "failed":
|
||||||
return r.setFailed(ctx, fn, "build job failed")
|
// Захватываем логи build-пода чтобы разработчик видел причину ошибки (pip error и т.д.).
|
||||||
|
logs := getBuildPodLogs(ctx, r.KubeClient, r.OperatorNamespace, jobName)
|
||||||
|
msg := "build job failed"
|
||||||
|
if logs != "" {
|
||||||
|
msg = "build job failed:\n" + logs
|
||||||
|
}
|
||||||
|
return r.setFailed(ctx, fn, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
|
return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
|
||||||
@@ -183,6 +196,13 @@ func (r *FunctionReconciler) ensureDeployment(ctx context.Context, fn *slessv1al
|
|||||||
if err := r.Create(ctx, ns); err != nil {
|
if err := r.Create(ctx, ns); err != nil {
|
||||||
return ctrl.Result{}, fmt.Errorf("create function namespace: %w", err)
|
return ctrl.Result{}, fmt.Errorf("create function namespace: %w", err)
|
||||||
}
|
}
|
||||||
|
// Создаём Harbor-проект для namespace сразу при создании k8s NS (best-effort).
|
||||||
|
// Если не удалось — EnsureProject повторит вызов внутри Build().
|
||||||
|
if r.HarborClient != nil {
|
||||||
|
if err := r.HarborClient.EnsureProject(ctx, fn.Namespace); err != nil {
|
||||||
|
log.FromContext(ctx).Error(err, "harbor ensure project on ns create", "project", fn.Namespace)
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return ctrl.Result{}, fmt.Errorf("get function namespace: %w", err)
|
return ctrl.Result{}, fmt.Errorf("get function namespace: %w", err)
|
||||||
}
|
}
|
||||||
@@ -380,3 +400,37 @@ func (r *FunctionReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
|||||||
For(&slessv1alpha1.Function{}).
|
For(&slessv1alpha1.Function{}).
|
||||||
Complete(r)
|
Complete(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getBuildPodLogs возвращает логи (stderr+stdout) пода kaniko build Job'а.
|
||||||
|
// Используется чтобы пробросить ошибку pip/kaniko в Function.Status.Message.
|
||||||
|
// Возвращает не более 50 последних строк — достаточно для диагностики, не засоряет CRD.
|
||||||
|
// Если логи недоступны — возвращает пустую строку (caller покажет generic msg).
|
||||||
|
func getBuildPodLogs(ctx context.Context, kube kubernetes.Interface, namespace, jobName string) string {
|
||||||
|
if kube == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
pods, err := kube.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{
|
||||||
|
LabelSelector: "job-name=" + jobName,
|
||||||
|
})
|
||||||
|
if err != nil || len(pods.Items) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
req := kube.CoreV1().Pods(namespace).GetLogs(pods.Items[0].Name, &corev1.PodLogOptions{})
|
||||||
|
stream, err := req.Stream(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer stream.Close()
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
_, _ = io.Copy(buf, stream)
|
||||||
|
raw := strings.TrimSpace(buf.String())
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// Оставляем последние 50 строк — ошибки pip всегда в конце вывода.
|
||||||
|
lines := strings.Split(raw, "\n")
|
||||||
|
if len(lines) > 50 {
|
||||||
|
lines = lines[len(lines)-50:]
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|||||||
@@ -147,11 +147,11 @@ func (r *FunctionJobReconciler) Reconcile(ctx context.Context, req ctrl.Request)
|
|||||||
// runner читает SLESS_EVENT и вызывает handle(event) один раз
|
// runner читает SLESS_EVENT и вызывает handle(event) один раз
|
||||||
Command: runtimeRunnerCommand(fn.Spec.Runtime),
|
Command: runtimeRunnerCommand(fn.Spec.Runtime),
|
||||||
Env: append(
|
Env: append(
|
||||||
fnEnvVars(fn),
|
append(fnEnvVars(fn), corev1.EnvVar{
|
||||||
corev1.EnvVar{
|
|
||||||
Name: "SLESS_EVENT",
|
Name: "SLESS_EVENT",
|
||||||
Value: eventJSON,
|
Value: eventJSON,
|
||||||
},
|
}),
|
||||||
|
goJobModeEnv(fn.Spec.Runtime)...,
|
||||||
),
|
),
|
||||||
Resources: corev1.ResourceRequirements{
|
Resources: corev1.ResourceRequirements{
|
||||||
Limits: corev1.ResourceList{
|
Limits: corev1.ResourceList{
|
||||||
@@ -217,6 +217,12 @@ func (r *FunctionJobReconciler) syncJobStatus(ctx context.Context, fj *slessv1al
|
|||||||
// 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 {
|
||||||
switch runtime {
|
switch runtime {
|
||||||
|
case "go1.23":
|
||||||
|
// Go runtime: SLESS_MODE=job заставляет server читать SLESS_EVENT и выйти.
|
||||||
|
// CMD остаётся как в образе (/server), переопределяем через Env.
|
||||||
|
// Передаём пустую команду — используется CMD из образа (/server).
|
||||||
|
// SLESS_MODE=job добавляется через Env в fnEnvVars.
|
||||||
|
return nil // nil = использовать CMD из образа; SLESS_MODE=job в Env
|
||||||
case "nodejs20":
|
case "nodejs20":
|
||||||
// inline runner — не требует отдельного файла в образе.
|
// inline runner — не требует отдельного файла в образе.
|
||||||
// SLESS_ENTRYPOINT="module.func": module=имя файла, func=экспортируемая функция
|
// SLESS_ENTRYPOINT="module.func": module=имя файла, func=экспортируемая функция
|
||||||
@@ -265,6 +271,15 @@ func fnEnvVars(fn *slessv1alpha1.Function) []corev1.EnvVar {
|
|||||||
|
|
||||||
func int32Ptr(i int32) *int32 { return &i }
|
func int32Ptr(i int32) *int32 { return &i }
|
||||||
|
|
||||||
|
// goJobModeEnv возвращает SLESS_MODE=job для Go runtime — сигнал /server выполниться разово и выйти.
|
||||||
|
// Для Python/Node runner задаётся через Command, для Go — через env (CMD /server общий).
|
||||||
|
func goJobModeEnv(runtime string) []corev1.EnvVar {
|
||||||
|
if runtime == "go1.23" {
|
||||||
|
return []corev1.EnvVar{{Name: "SLESS_MODE", Value: "job"}}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// getJobPodOutput находит под созданный Job-ом и возвращает его stdout (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 функции.
|
||||||
// Если под не найден или логи недоступны — возвращает "completed successfully" как fallback.
|
// Если под не найден или логи недоступны — возвращает "completed successfully" как fallback.
|
||||||
|
|||||||
@@ -1,20 +1,14 @@
|
|||||||
# Изменено: 2026-03-07
|
# Изменено: 2026-03-11
|
||||||
# Деплой sless оператора в кластер.
|
# Деплой sless оператора в кластер.
|
||||||
# Состав:
|
# Состав:
|
||||||
# - ConfigMap: не-секретные env vars (S3_ENDPOINT, REGISTRY_HOST и т.д.)
|
# - ConfigMap: не-секретные env vars (S3_ENDPOINT, REGISTRY_HOST и т.д.)
|
||||||
# - Secret: секретные данные (S3 keys, postgres DSN, API token, docker auth)
|
# - Secret: секретные данные (S3 keys, postgres DSN, API token, Harbor pass)
|
||||||
# - Deployment: оператор naeel/sless-operator:v0.1.23 в namespace sless
|
# - Deployment: оператор naeel/sless-operator:v0.1.23 в namespace sless
|
||||||
# - Service: ClusterIP :9090 (REST API)
|
# - Service: ClusterIP :9090 (REST API)
|
||||||
# - Ingress: sless-api.kube5s.ru → :9090 (внешний доступ с TLS)
|
# - Ingress: sless-api.kube5s.ru → :9090 (внешний доступ с TLS)
|
||||||
#
|
#
|
||||||
# Перед применением:
|
# Чтобы сменить registry — менять только REGISTRY_HOST в ConfigMap.
|
||||||
# kubectl create secret generic sless-operator-secret \
|
# Чтобы сменить Harbor-аккаунт — менять HARBOR_USER в ConfigMap + HARBOR_PASS в Secret.
|
||||||
# --namespace=sless \
|
|
||||||
# --from-literal=POSTGRES_DSN="..." \
|
|
||||||
# --from-literal=S3_ACCESS_KEY="..." \
|
|
||||||
# --from-literal=S3_SECRET_KEY="..." \
|
|
||||||
# --from-literal=SLESS_API_TOKEN="change-me" \
|
|
||||||
# --dry-run=client -o yaml | kubectl apply -f -
|
|
||||||
#
|
#
|
||||||
# Применение: kubectl apply -f deployments/k8s/operator.yaml
|
# Применение: kubectl apply -f deployments/k8s/operator.yaml
|
||||||
---
|
---
|
||||||
@@ -27,8 +21,13 @@ data:
|
|||||||
S3_ENDPOINT: "s3.msk-1.ngcloud.ru"
|
S3_ENDPOINT: "s3.msk-1.ngcloud.ru"
|
||||||
S3_BUCKET: "sless-functions"
|
S3_BUCKET: "sless-functions"
|
||||||
S3_USE_SSL: "true"
|
S3_USE_SSL: "true"
|
||||||
REGISTRY_HOST: "naeel"
|
# REGISTRY_HOST — единственное место, где прописан адрес registry.
|
||||||
|
# Чтобы сменить реестр — менять только здесь.
|
||||||
|
# Harbor: pearlharbor.registryk8s.services.ngcloud.ru
|
||||||
|
# DockerHub (legacy): naeel
|
||||||
|
REGISTRY_HOST: "pearlharbor.registryk8s.services.ngcloud.ru"
|
||||||
REGISTRY_SECRET: "sless-registry-auth"
|
REGISTRY_SECRET: "sless-registry-auth"
|
||||||
|
HARBOR_USER: "admin"
|
||||||
API_PORT: "9090"
|
API_PORT: "9090"
|
||||||
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}
|
||||||
@@ -41,13 +40,16 @@ data:
|
|||||||
# S3_ACCESS_KEY — ключ доступа к S3/Ceph
|
# S3_ACCESS_KEY — ключ доступа к S3/Ceph
|
||||||
# S3_SECRET_KEY — секретный ключ S3/Ceph
|
# S3_SECRET_KEY — секретный ключ S3/Ceph
|
||||||
# SLESS_API_TOKEN — токен аутентификации API
|
# SLESS_API_TOKEN — токен аутентификации API
|
||||||
|
# HARBOR_PASS — пароль Harbor API (для EnsureProject, не для kaniko push)
|
||||||
#
|
#
|
||||||
# Пример создания:
|
# Пример создания:
|
||||||
# kubectl create secret generic sless-operator-secret -n sless \
|
# kubectl create secret generic sless-operator-secret -n sless \
|
||||||
# --from-literal=POSTGRES_DSN="postgres://sless:PASSWORD@postgres.sless.svc.cluster.local:5432/sless?sslmode=disable" \
|
# --from-literal=POSTGRES_DSN="postgres://sless:PASSWORD@postgres.sless.svc.cluster.local:5432/sless?sslmode=disable" \
|
||||||
# --from-literal=S3_ACCESS_KEY="ACCESS_KEY" \
|
# --from-literal=S3_ACCESS_KEY="ACCESS_KEY" \
|
||||||
# --from-literal=S3_SECRET_KEY="SECRET_KEY" \
|
# --from-literal=S3_SECRET_KEY="SECRET_KEY" \
|
||||||
# --from-literal=SLESS_API_TOKEN="your-token-here"
|
# --from-literal=SLESS_API_TOKEN="your-token-here" \
|
||||||
|
# --from-literal=HARBOR_PASS="harbor-admin-password"
|
||||||
|
# kubectl apply -f hack/create-registry-secret.sh # docker-креды для kaniko
|
||||||
---
|
---
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
@@ -70,7 +72,7 @@ spec:
|
|||||||
containers:
|
containers:
|
||||||
- name: operator
|
- name: operator
|
||||||
# При обновлении версии оператора — менять тег здесь (не latest!)
|
# При обновлении версии оператора — менять тег здесь (не latest!)
|
||||||
image: naeel/sless-operator:v0.1.23
|
image: naeel/sless-operator:v0.1.28
|
||||||
# Always — чтобы всегда тянуть по точному тегу (не кешировать старый)
|
# Always — чтобы всегда тянуть по точному тегу (не кешировать старый)
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -646,3 +646,88 @@ for _, k := range keys { envVars = append(envVars, corev1.EnvVar{Name: k, Value:
|
|||||||
|
|
||||||
**Тесты:** 2 теста в `controllers/function_controller_unit_test.go`
|
**Тесты:** 2 теста в `controllers/function_controller_unit_test.go`
|
||||||
(4 env vars → алфавитный порядок после SLESS_ENTRYPOINT; пустой Env → только SLESS_ENTRYPOINT).
|
(4 env vars → алфавитный порядок после SLESS_ENTRYPOINT; пустой Env → только SLESS_ENTRYPOINT).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-03-12 — AI-анализ terraform plan через Google Gemini
|
||||||
|
|
||||||
|
**Решение:** Добавлен необязательный AI-анализ вывода `terraform plan` с уровнями детализации 0–5.
|
||||||
|
АI невидим по умолчанию — не влияет на существующие `.tf` файлы и workflow.
|
||||||
|
|
||||||
|
**Причина:**
|
||||||
|
- Разработчики хотят понимать что именно изменится в инфраструктуре перед применением.
|
||||||
|
- Уровни позволяют самостоятельно выбирать глубину анализа (от краткого обзора до полного аудита).
|
||||||
|
- Не ломает backward compatibility: `ai_hint_level` не указан → поведение идентично прежнему.
|
||||||
|
|
||||||
|
**Архитектура:**
|
||||||
|
```
|
||||||
|
provider "sless" { ai_hint_level = 3 }
|
||||||
|
↓ os.Setenv("AI_HINT_LEVEL", "3") (в provider.Configure)
|
||||||
|
sless-plan ← CLI обёртка над terraform plan
|
||||||
|
↓ запускает terraform plan, захватывает вывод
|
||||||
|
↓ если AI_HINT_LEVEL > 0 → internal/ai/analyzer.go
|
||||||
|
↓ POST к Gemini REST API (net/http)
|
||||||
|
↓ выводит анализ пользователю
|
||||||
|
```
|
||||||
|
|
||||||
|
**Новые файлы:**
|
||||||
|
| Файл | Назначение |
|
||||||
|
|------|------------|
|
||||||
|
| `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-12 — Groq/OpenAI-compat как default AI провайдер, self-hosted LLM не поднимаем
|
||||||
|
|
||||||
|
**Решение:** Groq — default AI провайдер (через OpenAI-compat API). Google Gemini — альтернатива (env `AI_PROVIDER=google`). Self-hosted LLM (ollama) — не устанавливаем на VM.
|
||||||
|
|
||||||
|
**Причина:**
|
||||||
|
- `sless-plan` — **клиентский инструмент**. Запускается на машине разработчика, не на сервере оператора. Разработчик сам отвечает за наличие доступа к AI API.
|
||||||
|
- Self-hosted LLM на VM облака — чужая инфраструктура, нецелевое использование ресурсов, потенциальные трения с облачной командой.
|
||||||
|
- Groq — бесплатный tier, OpenAI-compat API (минимальный код), модель `llama-3.3-70b-versatile` достаточна для code review.
|
||||||
|
|
||||||
|
**Будущее:** когда облако запустит свой LLM endpoint — добавить case `"cloud"` в `NewProvider`, endpoint из env. Stub `internal/ai/cloud_llm.go` уже есть.
|
||||||
|
|
||||||
|
**Переменные окружения для разработчика:**
|
||||||
|
```bash
|
||||||
|
export GROQ_API_KEY=gsk_... # обязательно для Groq
|
||||||
|
export AI_PROVIDER=groq # default, можно опустить
|
||||||
|
export GROQ_MODEL=llama-3.3-70b-versatile # default, можно опустить
|
||||||
|
```
|
||||||
|
|
||||||
|
**Коммит:** `11b484e`
|
||||||
|
|||||||
@@ -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`
|
||||||
+124
@@ -230,3 +230,127 @@
|
|||||||
| 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-03-11 — Harbor integration + Go 1.23 runtime (v0.1.24–v0.1.26)
|
||||||
|
|
||||||
|
### Что сделано
|
||||||
|
|
||||||
|
| # | Компонент | Изменение |
|
||||||
|
|---|-----------|-----------|
|
||||||
|
| 1 | `internal/harbor/` | Harbor-клиент: `EnsureProject` создаёт проект перед push образа |
|
||||||
|
| 2 | `internal/builder/builder.go` | Поддержка `HarborClient`, push в `pearlharbor.registryk8s.services.ngcloud.ru` |
|
||||||
|
| 3 | `runtimes/go1.23/` | Новый Go 1.23 runtime: `server.go` + `runner.go` + Dockerfile |
|
||||||
|
| 4 | `internal/builder/context.go` | Go 1.23 в switch; `COPY . /app/handler/` вместо `COPY handler/` |
|
||||||
|
| 5 | `api/v1alpha1/function_types.go` | `go1.23` в enum поддерживаемых рантаймов |
|
||||||
|
| 6 | `terraform/provider/` | v0.1.14: `go1.23` в `stringvalidator.OneOf` |
|
||||||
|
| 7 | `deployments/k8s/operator.yaml` | v0.1.26 |
|
||||||
|
| 8 | `examples/hello-go/` | Новый пример: HTTP + job на Go 1.23 |
|
||||||
|
| 9 | `examples/hello-go/code/greeting.go` | Переименован из handler.go, функция `buildGreeting(guestName)` |
|
||||||
|
| 10 | `examples/pg-list-python/` | Новый пример: Python + PostgreSQL, только HTTP, без job |
|
||||||
|
|
||||||
|
### Docker Hub auth на 192.168.1.220
|
||||||
|
- Токен: `dckr_pat_aElN35tdXMBrtOAPraV5Fi0o58s` (сохранён в `secrets/dockerhub.env`, chmod 600)
|
||||||
|
- `docker login -u naeel --password-stdin` выполнен на remote machine
|
||||||
|
|
||||||
|
### Исправленные баги
|
||||||
|
- kaniko `COPY handler/ /app/handler/` — файлы в tar-контексте без prefix → исправлено на `COPY .`
|
||||||
|
- `Decimal` из psycopg2 не сериализуется → `float(row[2])` в `catalog.py`
|
||||||
|
- Formatter добавил дублирующий `package code` в `greeting.go` — удалён вручную
|
||||||
|
- CRD в кластере имел только `go1.21` в enum → `kubectl apply` обновлён CRD
|
||||||
|
- Provider v0.1.13 имел только `go1.21` → пересобран v0.1.14
|
||||||
|
|
||||||
|
### Git HEAD
|
||||||
|
```
|
||||||
|
d6a2e22 fix: восстановлен requirements.txt после теста
|
||||||
|
5c6a373 docs: переработан examples/README.md + комментарии function.tf
|
||||||
|
2ca3137 feat: пример pg-list-python
|
||||||
|
c4559dd fix: go1.23 build context
|
||||||
|
78d11ae refactor: rename handler.go→greeting.go
|
||||||
|
a709b38 feat: go1.23 runtime support
|
||||||
|
babd8e6 feat: harbor integration
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-03-11 — UX улучшения: build logs + JSON для всех HTTP методов (v0.1.27–v0.1.28)
|
||||||
|
|
||||||
|
### Проблемы до исправления
|
||||||
|
|
||||||
|
| Проблема | Симптом |
|
||||||
|
|---|---|
|
||||||
|
| Ошибка сборки без деталей | `terraform apply` показывал только `function build failed: build job failed` без причины |
|
||||||
|
| HTML 501 при DELETE/HEAD/PUT | Python runtime `http.server` не обрабатывал эти методы → HTML-ответ вместо JSON |
|
||||||
|
|
||||||
|
### Что изменили
|
||||||
|
|
||||||
|
#### `controllers/function_controller.go`
|
||||||
|
- `FunctionReconciler` получил поле `KubeClient kubernetes.Interface`
|
||||||
|
- Добавлен `getBuildPodLogs(ctx, kube, namespace, jobName)` — читает логи kaniko-пода (последние 50 строк)
|
||||||
|
- При сбое сборки (`case "failed"`) вызывает `getBuildPodLogs` и включает вывод в `Function.Status.Message`
|
||||||
|
- Провайдер пробрасывает `Message` в ошибку `terraform apply` — разработчик видит точную причину
|
||||||
|
|
||||||
|
#### `runtimes/python3.11/server.py`
|
||||||
|
- Выделен `_handle_with_body()` — общий обработчик для методов с телом
|
||||||
|
- `do_PUT`, `do_DELETE`, `do_PATCH` = `_handle_with_body` — вызывают функцию пользователя
|
||||||
|
- `do_HEAD` — отдаёт заголовки без тела (HTTP-стандарт; тело вычисляется но не отправляется)
|
||||||
|
- `send_error()` переопределён → JSON `{"error": "...", "code": N}` вместо HTML 501
|
||||||
|
- Runtime пересобран: `naeel/sless-runtime-python3.11:v0.1.2`
|
||||||
|
|
||||||
|
#### `main.go`
|
||||||
|
- `KubeClient: kubernetes.NewForConfigOrDie(mgr.GetConfig())` передаётся в `FunctionReconciler`
|
||||||
|
|
||||||
|
#### `internal/builder/context.go`
|
||||||
|
- python3.11 runtime: `v0.1.1` → `v0.1.2`
|
||||||
|
|
||||||
|
### Версии после исправления
|
||||||
|
- Оператор: `naeel/sless-operator:v0.1.28`
|
||||||
|
- Python runtime: `naeel/sless-runtime-python3.11:v0.1.2`
|
||||||
|
- Provider: `terra.k8c.ru/naeel/sless v0.1.14` (без изменений)
|
||||||
|
|
||||||
|
### Результат тестирования
|
||||||
|
|
||||||
|
| Тест | До | После |
|
||||||
|
|---|---|---|
|
||||||
|
| `DELETE /fn/...` | HTML 501 | ✅ JSON 200, функция вызывается |
|
||||||
|
| `HEAD /fn/...` | HTML 501 | ✅ HTTP 200, Content-Type: application/json |
|
||||||
|
| `PUT /fn/...` | HTML 501 | ✅ JSON 200, функция вызывается |
|
||||||
|
| Невалидный `requirements.txt` | `build job failed` | ✅ Полный вывод pip включая строку с ошибкой |
|
||||||
|
|
||||||
|
### Git коммиты
|
||||||
|
```
|
||||||
|
6de90ac chore: python runtime v0.1.2 + operator v0.1.28
|
||||||
|
3372cb1 fix: build logs in status + JSON for all HTTP methods in python runtime
|
||||||
|
```
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Created: 2026-03-11
|
||||||
|
# Purpose: ignore generated artifacts for the `examples` repository
|
||||||
|
|
||||||
|
# Terraform
|
||||||
|
.terraform/
|
||||||
|
*.tfstate
|
||||||
|
*.tfstate.*
|
||||||
|
.terraform.lock.hcl
|
||||||
|
crash.log
|
||||||
|
|
||||||
|
# Terraform plans / backups
|
||||||
|
*.tfplan
|
||||||
|
*.backup
|
||||||
|
*.bak
|
||||||
|
|
||||||
|
# Provider plugins / caches
|
||||||
|
.terraform.d/
|
||||||
|
|
||||||
|
#*.tfvars
|
||||||
|
|
||||||
|
# Archives and build artifacts
|
||||||
|
*.zip
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Node / Python
|
||||||
|
node_modules/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# Editor / OS files
|
||||||
|
.DS_Store
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
*.local
|
||||||
|
*.log
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
# Bug Report: HTTP route is not removed after Terraform destroy
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
При удалении примера `hello-node` через Terraform команда `terraform destroy` завершается успешно, но публичный HTTP endpoint не удаляется.
|
|
||||||
|
|
||||||
Фактическое поведение после `destroy` такое:
|
|
||||||
|
|
||||||
1. Сразу после удаления endpoint ещё некоторое время отвечает `HTTP 200` и возвращает корректный ответ функции.
|
|
||||||
2. Затем backend функции действительно исчезает, но публичный маршрут остаётся опубликованным и начинает отвечать `HTTP 502 function unreachable`.
|
|
||||||
3. Даже через 120 секунд endpoint не исчезает.
|
|
||||||
|
|
||||||
Это выглядит как баг cleanup в platform/backend/provider lifecycle для HTTP trigger/route.
|
|
||||||
|
|
||||||
## Affected Example
|
|
||||||
|
|
||||||
- Example: `hello-node`
|
|
||||||
- Terraform files: `hello-node/main.tf`, `hello-node/http.tf`, `hello-node/job.tf`
|
|
||||||
- Public URL: `https://sless-api.kube5s.ru/fn/default/hello-http`
|
|
||||||
- Function name: `hello-http`
|
|
||||||
- Trigger name: `hello-http-trigger`
|
|
||||||
|
|
||||||
## Reproduction
|
|
||||||
|
|
||||||
Использовался репозиторий examples и скрипт:
|
|
||||||
|
|
||||||
- Script: `./run_terraform_examples.sh`
|
|
||||||
|
|
||||||
Шаги воспроизведения:
|
|
||||||
|
|
||||||
1. Выполнить `terraform init` в `hello-node`
|
|
||||||
2. Выполнить `terraform apply`
|
|
||||||
3. Убедиться, что endpoint живой
|
|
||||||
4. Выполнить `terraform destroy`
|
|
||||||
5. Проверять публичный URL после destroy
|
|
||||||
|
|
||||||
Логика проверки встроена в `run_terraform_examples.sh`:
|
|
||||||
|
|
||||||
1. После `apply` endpoint обязан отвечать `200`
|
|
||||||
2. После `destroy` endpoint должен исчезнуть
|
|
||||||
3. Скрипт ждёт до 120 секунд и перепроверяет endpoint каждые 5 секунд
|
|
||||||
|
|
||||||
## Expected Result
|
|
||||||
|
|
||||||
После успешного `terraform destroy`:
|
|
||||||
|
|
||||||
1. Публичный URL должен перестать существовать
|
|
||||||
2. Запрос на URL должен вернуть `404` или другой явный признак отсутствия маршрута
|
|
||||||
3. Provider не должен возвращать успешный destroy раньше, чем cleanup HTTP route завершён
|
|
||||||
|
|
||||||
## Actual Result
|
|
||||||
|
|
||||||
После успешного `terraform destroy`:
|
|
||||||
|
|
||||||
1. Terraform сообщает `Destroy complete! Resources: 4 destroyed.`
|
|
||||||
2. Endpoint `https://sless-api.kube5s.ru/fn/default/hello-http` продолжает отвечать `200`
|
|
||||||
3. Через некоторое время тот же endpoint начинает отвечать `502`
|
|
||||||
4. Тело ответа на `502`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"error":"function unreachable: Post \"http://hello-http.sless-fn-default.svc.cluster.local:8080\": dial tcp 10.106.128.167:8080: connect: operation not permitted"}
|
|
||||||
```
|
|
||||||
|
|
||||||
Это означает:
|
|
||||||
|
|
||||||
1. внешний HTTP маршрут всё ещё существует;
|
|
||||||
2. запрос по нему всё ещё направляется внутрь платформы;
|
|
||||||
3. backend функции уже удалён или недоступен;
|
|
||||||
4. cleanup маршрута не завершён.
|
|
||||||
|
|
||||||
## Timeline From Real Run
|
|
||||||
|
|
||||||
Подтверждённая последовательность из фактического прогона:
|
|
||||||
|
|
||||||
1. `terraform destroy` завершился успешно
|
|
||||||
2. первые проверки после destroy возвращали `HTTP 200`
|
|
||||||
3. затем проверки начали возвращать `HTTP 502 function unreachable`
|
|
||||||
4. в течение всех 24 проверок по 5 секунд endpoint не исчез
|
|
||||||
5. итоговое время ожидания: 120 секунд
|
|
||||||
|
|
||||||
Итоговый summary из скрипта:
|
|
||||||
|
|
||||||
```text
|
|
||||||
ERROR SUMMARY
|
|
||||||
example: hello-node
|
|
||||||
step: endpoint cleanup after clean destroy
|
|
||||||
reason: route cleanup bug: public endpoint still exists but backend is already gone (HTTP 502 function unreachable); endpoint was still published after 120s
|
|
||||||
```
|
|
||||||
|
|
||||||
## Why This Is A Real Platform Bug
|
|
||||||
|
|
||||||
Это не похоже на проблему тестового скрипта или Terraform CLI по следующим причинам:
|
|
||||||
|
|
||||||
1. `terraform destroy` завершается без ошибки
|
|
||||||
2. state Terraform очищается как ожидалось
|
|
||||||
3. сначала endpoint отвечает `200`, значит маршрут реально жив после destroy
|
|
||||||
4. потом endpoint отвечает `502 function unreachable`, значит backend уже исчез, но route ещё остался
|
|
||||||
5. скрипт ждёт 120 секунд, то есть это не мгновенная eventual consistency на 1-2 секунды
|
|
||||||
|
|
||||||
Иными словами: удаление backend и удаление публичного маршрута расходятся по времени, а route cleanup либо не выполняется, либо не дожидается завершения.
|
|
||||||
|
|
||||||
## Most Likely Broken Layer
|
|
||||||
|
|
||||||
Наиболее вероятные точки проблемы:
|
|
||||||
|
|
||||||
1. API/backend destroy trigger возвращает success до фактического удаления HTTP route
|
|
||||||
2. Controller удаляет function workload, но не удаляет route/ingress/virtualservice/gateway mapping
|
|
||||||
3. Удаление route запускается асинхронно, но его результат не awaited
|
|
||||||
4. В системе остаётся запись маршрута на имя функции, хотя service/backend уже удалён
|
|
||||||
|
|
||||||
## What To Check In The Development Repo
|
|
||||||
|
|
||||||
Нужно проверить destroy flow именно для HTTP trigger:
|
|
||||||
|
|
||||||
1. Удаляется ли объект trigger только в metadata/storage или реально удаляется и внешний маршрут
|
|
||||||
2. Какие Kubernetes/ingress объекты создаются для HTTP trigger и все ли они удаляются
|
|
||||||
3. Есть ли race condition между удалением function/service и удалением route
|
|
||||||
4. Не возвращает ли provider success раньше, чем backend подтверждает полное удаление маршрута
|
|
||||||
5. Есть ли финальный polling/wait на исчезновение route перед возвратом успешного destroy
|
|
||||||
|
|
||||||
Если архитектура использует отдельные сущности route/service/function, то destroy должен идти в таком порядке:
|
|
||||||
|
|
||||||
1. disable/remove public routing
|
|
||||||
2. дождаться, что endpoint больше не публикуется снаружи
|
|
||||||
3. удалить backend/service/workload
|
|
||||||
4. завершить destroy success
|
|
||||||
|
|
||||||
Сейчас по фактическому поведению порядок либо обратный, либо неполный.
|
|
||||||
|
|
||||||
## Minimal Acceptance Criteria For Fix
|
|
||||||
|
|
||||||
Исправление можно считать рабочим, если после `terraform destroy` для `hello-node` выполняются все условия:
|
|
||||||
|
|
||||||
1. URL `https://sless-api.kube5s.ru/fn/default/hello-http` перестаёт отвечать как живой маршрут
|
|
||||||
2. URL не возвращает `502 function unreachable`
|
|
||||||
3. URL исчезает в разумное время после destroy
|
|
||||||
4. `./run_terraform_examples.sh` проходит шаг `endpoint cleanup after clean destroy`
|
|
||||||
|
|
||||||
## Current Status
|
|
||||||
|
|
||||||
На данный момент массовый прогон examples корректно останавливается на `hello-node`, потому что это первый воспроизводимый failure.
|
|
||||||
|
|
||||||
Дальше прогонять остальные примеры без исправления destroy cleanup смысла нет: тест уже доказал platform bug на базовом HTTP сценарии.
|
|
||||||
+110
-76
@@ -1,64 +1,125 @@
|
|||||||
# Примеры sless
|
# Примеры использования sless
|
||||||
|
|
||||||
## Что такое sless
|
## Обзор платформы
|
||||||
|
|
||||||
**sless** — платформа для запуска serverless-функций в Kubernetes-кластере.
|
**sless** — система управления serverless-функциями на базе Kubernetes. Разработчик загружает код функции, платформа собирает из него Docker-образ, разворачивает его в кластере и предоставляет HTTP-эндпоинт для вызова. Всё описывается декларативно через Terraform.
|
||||||
|
|
||||||
Код на Python или Node.js загружается в платформу, которая собирает Docker-образ, деплоит его в кластер и публикует HTTP-эндпоинт. Всё управляется через Terraform.
|
### Основные ресурсы провайдера
|
||||||
|
|
||||||
### Ресурсы
|
| Ресурс | Назначение |
|
||||||
|
|
||||||
| Ресурс | Что делает |
|
|
||||||
|---|---|
|
|---|---|
|
||||||
| `sless_function` | Загружает код и собирает Docker-образ. Сама по себе не принимает запросы — нужен триггер или джоб |
|
| `sless_function` | Описывает функцию: язык, точку входа, лимиты, переменные окружения. При создании загружает код и запускает его сборку в образ. Сама по себе недоступна снаружи — нужен триггер или задание. |
|
||||||
| `sless_trigger` | Публикует функцию — либо как HTTP-эндпоинт, либо по расписанию (cron) |
|
| `sless_trigger` | Публикует функцию: тип `http` создаёт публичный URL, тип `cron` — запуск по расписанию. |
|
||||||
| `sless_job` | Запускает функцию один раз (например, для инициализации БД) и ждёт результата |
|
| `sless_job` | Запускает функцию однократно и ожидает завершения. Используется для одноразовых операций: инициализация БД, миграции, пакетная обработка. |
|
||||||
|
|
||||||
**Типичный сценарий:** `sless_function` с кодом + `sless_trigger` с `type = "http"` → публичный URL вида `https://sless-api.kube5s.ru/fn/default/имя-функции`.
|
Стандартная связка для HTTP API: `sless_function` + `sless_trigger` с `type = "http"` — в результате функция доступна по URL вида `https://sless-api.kube5s.ru/fn/<namespace>/<имя-функции>`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Примеры показывают различные сценарии использования serverless функций через Terraform провайдер `terra.k8c.ru/naeel/sless`.
|
|
||||||
|
|
||||||
## Требования
|
## Требования
|
||||||
|
|
||||||
- Terraform >= 1.0
|
- Terraform >= 1.0
|
||||||
|
- JWT-токен для аутентификации в sless API
|
||||||
- Доступ к `https://sless-api.kube5s.ru`
|
- Доступ к `https://sless-api.kube5s.ru`
|
||||||
|
|
||||||
## Провайдер
|
## Конфигурация провайдера
|
||||||
|
|
||||||
Во всех примерах `main.tf` содержит:
|
Во всех примерах файл `main.tf` содержит блок провайдера. Токен передаётся через переменную, значение которой задаётся в `terraform.tfvars`:
|
||||||
|
|
||||||
```hcl
|
```hcl
|
||||||
provider "sless" {
|
provider "sless" {
|
||||||
endpoint = "https://sless-api.kube5s.ru"
|
endpoint = "https://sless-api.kube5s.ru"
|
||||||
token = "dev-token-change-me"
|
token = var.token
|
||||||
|
nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Namespace функций вычисляется автоматически из JWT-токена: `sless-{sha256[:8]}`.
|
||||||
|
|
||||||
|
> **Перед запуском любого примера** откройте файл `terraform.tfvars` в директории примера и впишите свой токен Nubes API:
|
||||||
|
> ```hcl
|
||||||
|
> token = "ваш токен Nubes API"
|
||||||
|
> ```
|
||||||
|
> Токен выдаётся в личном кабинете Nubes. Файл `terraform.tfvars` добавлен в `.gitignore` — он не попадёт в репозиторий.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Примеры
|
## Примеры
|
||||||
|
|
||||||
### `simple-python` — джоб передаёт результат в HTTP-функцию (Python)
|
### `hello-node` — минимальный пример на Node.js
|
||||||
|
|
||||||
При `apply` запускается джоб, его вывод передаётся в HTTP-функцию через `env_vars`.
|
Две независимые функции: HTTP-функция, возвращающая приветствие, и одноразовое задание, суммирующее набор чисел. Хорошая отправная точка для знакомства с платформой.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd hello-node
|
||||||
|
terraform init
|
||||||
|
terraform apply -auto-approve
|
||||||
|
|
||||||
|
# Вызов HTTP-функции с передачей имени:
|
||||||
|
curl -s -X POST https://sless-api.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
|
```bash
|
||||||
cd simple-python
|
cd simple-python
|
||||||
terraform init
|
terraform init
|
||||||
terraform apply -auto-approve
|
terraform apply -auto-approve
|
||||||
|
|
||||||
# Что вернул джоб (время на момент деплоя):
|
|
||||||
terraform output job_result
|
terraform output job_result
|
||||||
|
curl -s https://sless-api.kube5s.ru/fn/<namespace>/simple-py-time-display
|
||||||
# Проверить функцию:
|
|
||||||
curl -s https://sless-api.kube5s.ru/fn/default/simple-py-time-display
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### `simple-node` — то же самое, но на Node.js 20
|
### `simple-node` — то же самое на Node.js 20
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd simple-node
|
cd simple-node
|
||||||
@@ -66,95 +127,68 @@ terraform init
|
|||||||
terraform apply -auto-approve
|
terraform apply -auto-approve
|
||||||
|
|
||||||
terraform output job_result
|
terraform output job_result
|
||||||
curl -s https://sless-api.kube5s.ru/fn/default/simple-node-time-display
|
curl -s https://sless-api.kube5s.ru/fn/<namespace>/simple-node-time-display
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### `hello-node` — минимальный пример на Node.js
|
### `notes-python` — CRUD API на Python с PostgreSQL
|
||||||
|
|
||||||
Две независимые функции: HTTP-функция (возвращает приветствие) и одноразовый джоб (суммирует числа).
|
Полноценное приложение: инициализация схемы базы данных через задания, CRUD-функция для работы с записями, отдельная функция для получения списка.
|
||||||
|
|
||||||
```bash
|
|
||||||
cd hello-node
|
|
||||||
terraform init
|
|
||||||
terraform apply -auto-approve
|
|
||||||
|
|
||||||
# Проверить HTTP-функцию:
|
|
||||||
curl -s -X POST https://sless-api.kube5s.ru/fn/default/hello-http \
|
|
||||||
-H 'Content-Type: application/json' -d '{"name":"World"}'
|
|
||||||
|
|
||||||
# Посмотреть результат джоба:
|
|
||||||
terraform output job_message
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `notes-python` — CRUD API на Python + PostgreSQL
|
|
||||||
|
|
||||||
Полноценное приложение: инициализация схемы БД через джобы, CRUD-функция, read-only функция для списка записей.
|
|
||||||
|
|
||||||
**Переменные:**
|
**Переменные:**
|
||||||
|
|
||||||
| Переменная | Описание | Дефолт |
|
| Переменная | Описание | Значение по умолчанию |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `pg_dsn` | DSN для подключения к PostgreSQL | `postgres://sless:sless-pg-password@postgres.sless.svc.cluster.local:5432/sless?sslmode=disable` |
|
| `pg_dsn` | Строка подключения к PostgreSQL | `postgres://sless:sless-pg-password@postgres.sless.svc.cluster.local:5432/sless?sslmode=disable` |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd notes-python
|
cd notes-python
|
||||||
terraform init
|
terraform init
|
||||||
|
|
||||||
# Опционально — переопределить DSN:
|
|
||||||
# export TF_VAR_pg_dsn="postgres://user:pass@host:5432/db?sslmode=disable"
|
|
||||||
|
|
||||||
terraform apply -auto-approve
|
terraform apply -auto-approve
|
||||||
|
|
||||||
# Проверить инициализацию БД:
|
# Статус инициализации базы данных:
|
||||||
terraform output db_init_table_status
|
terraform output db_init_table_status
|
||||||
terraform output db_init_index_status
|
terraform output db_init_index_status
|
||||||
|
|
||||||
# URL функций:
|
|
||||||
terraform output notes_url # CRUD
|
|
||||||
terraform output notes_list_url # список всех записей
|
|
||||||
|
|
||||||
# Создать запись:
|
# Создать запись:
|
||||||
curl -s -X POST "https://sless-api.kube5s.ru/fn/default/notes/add?title=Hello&body=World"
|
curl -s -X POST "$(terraform output -raw notes_url)/add?title=Hello&body=World"
|
||||||
|
|
||||||
# Список записей:
|
# Получить список записей:
|
||||||
curl -s https://sless-api.kube5s.ru/fn/default/notes-list
|
curl -s $(terraform output -raw notes_list_url)
|
||||||
|
|
||||||
# Обновить (id из предыдущего ответа):
|
# Обновить запись (id из предыдущего ответа):
|
||||||
curl -s -X POST "https://sless-api.kube5s.ru/fn/default/notes/update?id=1&title=Updated&body=New+body"
|
curl -s -X POST "$(terraform output -raw notes_url)/update?id=1&title=Updated&body=New+body"
|
||||||
|
|
||||||
# Удалить:
|
# Удалить запись:
|
||||||
curl -s -X POST "https://sless-api.kube5s.ru/fn/default/notes/delete?id=1"
|
curl -s -X POST "$(terraform output -raw notes_url)/delete?id=1"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Общие команды
|
## Полезные команды
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Посмотреть текущее состояние ресурсов:
|
# Посмотреть текущее состояние задеплоенных ресурсов:
|
||||||
terraform show
|
terraform show
|
||||||
|
|
||||||
# Пересоздать конкретный ресурс:
|
# Принудительно пересобрать функцию (например, после изменения кода):
|
||||||
terraform apply -replace=sless_function.имя -auto-approve
|
terraform apply -replace=sless_function.<имя> -auto-approve
|
||||||
|
|
||||||
# Повторно запустить джоб — увеличить run_id в .tf файле, затем:
|
# Повторно запустить задание: увеличить значение run_id в .tf-файле, затем:
|
||||||
terraform apply -auto-approve
|
terraform apply -auto-approve
|
||||||
|
|
||||||
# Удалить все ресурсы примера:
|
# Удалить все ресурсы примера:
|
||||||
terraform destroy -auto-approve
|
terraform destroy -auto-approve
|
||||||
```
|
```
|
||||||
|
|
||||||
## Структура каждого примера
|
## Структура примера
|
||||||
|
|
||||||
```
|
```
|
||||||
пример/
|
<пример>/
|
||||||
├── main.tf — провайдер
|
├── main.tf — конфигурация провайдера
|
||||||
├── *.tf — ресурсы (функции, триггеры, джобы)
|
├── *.tf — ресурсы: функции, триггеры, задания
|
||||||
├── outputs.tf — URLs и статусы после apply
|
├── variables.tf — входные переменные
|
||||||
├── variables.tf — входные переменные (если есть)
|
├── terraform.tfvars — значения переменных (не коммитится в git)
|
||||||
└── code/ — исходный код функций
|
└── code/ — исходный код функций
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// Изменено: 2026-03-11
|
||||||
|
// greeting.go — пример Go функции: возвращает приветствие.
|
||||||
|
//
|
||||||
|
// ТРЕБОВАНИЕ РАНТАЙМА: пакет должен называться handler, точка входа — Handle.
|
||||||
|
// Вся бизнес-логика — в отдельных функциях с нормальными именами.
|
||||||
|
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// buildGreeting — формирует текст приветствия для указанного имени гостя.
|
||||||
|
func buildGreeting(guestName string) string {
|
||||||
|
return fmt.Sprintf("Hello, %s! (Go 1.23)", guestName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle — точка входа, вызывается рантаймом на каждый запрос/событие.
|
||||||
|
// Не переименовывать: это жёсткий контракт рантайма (server.go вызывает handler.Handle).
|
||||||
|
func Handle(event map[string]interface{}) interface{} {
|
||||||
|
guestName, ok := event["name"].(string)
|
||||||
|
if !ok || guestName == "" {
|
||||||
|
guestName = "world"
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"message": buildGreeting(guestName),
|
||||||
|
"runtime": "go1.23",
|
||||||
|
"event": event,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# 2026-03-11
|
||||||
|
# http.tf — HTTP-функция на Go: принимает запрос, возвращает приветствие.
|
||||||
|
|
||||||
|
resource "sless_function" "hello_go_http" {
|
||||||
|
name = "hello-go-http"
|
||||||
|
runtime = "go1.23"
|
||||||
|
entrypoint = "handler.Handle"
|
||||||
|
memory_mb = 128
|
||||||
|
timeout_sec = 60
|
||||||
|
|
||||||
|
source_dir = "${path.module}/code"
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "sless_trigger" "hello_go_http" {
|
||||||
|
name = "hello-go-http-trigger"
|
||||||
|
type = "http"
|
||||||
|
function = sless_function.hello_go_http.name
|
||||||
|
enabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
output "trigger_url" {
|
||||||
|
value = sless_trigger.hello_go_http.url
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# 2026-03-11
|
||||||
|
# job.tf — одноразовый запуск Go функции с event payload.
|
||||||
|
|
||||||
|
resource "sless_function" "hello_go_job" {
|
||||||
|
name = "hello-go-job"
|
||||||
|
runtime = "go1.23"
|
||||||
|
entrypoint = "handler.Handle"
|
||||||
|
memory_mb = 128
|
||||||
|
timeout_sec = 60
|
||||||
|
|
||||||
|
source_dir = "${path.module}/code"
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "sless_job" "hello_go_run" {
|
||||||
|
name = "hello-go-run"
|
||||||
|
function = sless_function.hello_go_job.name
|
||||||
|
event_json = jsonencode({ name = "Go" })
|
||||||
|
wait_timeout_sec = 300
|
||||||
|
run_id = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
output "job_phase" {
|
||||||
|
value = sless_job.hello_go_run.phase
|
||||||
|
}
|
||||||
|
|
||||||
|
output "job_message" {
|
||||||
|
value = sless_job.hello_go_run.message
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# 2026-03-11
|
||||||
|
# main.tf — провайдеры для hello-go примера.
|
||||||
|
|
||||||
|
terraform {
|
||||||
|
required_providers {
|
||||||
|
sless = {
|
||||||
|
source = "terra.k8c.ru/naeel/sless"
|
||||||
|
version = "~> 0.1.17"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
provider "sless" {
|
||||||
|
endpoint = "https://sless-api.kube5s.ru"
|
||||||
|
token = var.token
|
||||||
|
nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1"
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
token = "замени на Nubes API token" и переименуй файл в terraform.tfvars
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# 2026-03-11
|
||||||
|
# variables.tf — входные переменные для hello-node примера.
|
||||||
|
|
||||||
|
# JWT токен облака (nubes). Передаётся через terraform.tfvars (gitignored).
|
||||||
|
# Из токена провайдер вычисляет namespace: sless-{sha256[:8]}
|
||||||
|
variable "token" {
|
||||||
|
description = "JWT токен облака для аутентификации в sless API"
|
||||||
|
type = string
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ terraform {
|
|||||||
required_providers {
|
required_providers {
|
||||||
sless = {
|
sless = {
|
||||||
source = "terra.k8c.ru/naeel/sless"
|
source = "terra.k8c.ru/naeel/sless"
|
||||||
version = "~> 0.1.13"
|
version = "~> 0.1.17"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
token = "замени на Nubes API token" и переименуй файл в terraform.tfvars
|
||||||
@@ -14,7 +14,7 @@ terraform {
|
|||||||
# Провайдер для управления serverless функциями через sless API
|
# Провайдер для управления serverless функциями через sless API
|
||||||
sless = {
|
sless = {
|
||||||
source = "terra.k8c.ru/naeel/sless"
|
source = "terra.k8c.ru/naeel/sless"
|
||||||
version = "~> 0.1.13"
|
version = "~> 0.1.17"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
token = "замени на Nubes API token" и переименуй файл в terraform.tfvars
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# pg-list-python
|
||||||
|
|
||||||
|
Python-функция, которая читает из PostgreSQL и возвращает JSON. Таблица с демо-данными создаётся автоматически при первом вызове — никакой инициализации руками.
|
||||||
|
|
||||||
|
## Что тут есть
|
||||||
|
|
||||||
|
```
|
||||||
|
code/
|
||||||
|
catalog.py — функция list_products(event): SELECT из demo_products
|
||||||
|
requirements.txt — psycopg2-binary
|
||||||
|
|
||||||
|
function.tf — sless_function + sless_trigger + output
|
||||||
|
main.tf — конфигурация провайдера
|
||||||
|
variables.tf — token, pg_dsn
|
||||||
|
terraform.tfvars — значения переменных (не в git)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
**1. Впишите токен в `terraform.tfvars`:**
|
||||||
|
|
||||||
|
```hcl
|
||||||
|
# terraform.tfvars
|
||||||
|
token = "ваш токен Nubes API" # ← заменить на реальный токен из личного кабинета Nubes
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Деплой:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
terraform init
|
||||||
|
terraform apply -auto-approve
|
||||||
|
|
||||||
|
curl -s $(terraform output -raw catalog_url)
|
||||||
|
```
|
||||||
|
|
||||||
|
Ответ:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"products": [
|
||||||
|
{"id": 1, "name": "Ноутбук", "price": 89999.0},
|
||||||
|
{"id": 2, "name": "Мышь", "price": 1299.0},
|
||||||
|
{"id": 3, "name": "Клавиатура", "price": 3499.0},
|
||||||
|
{"id": 4, "name": "Монитор", "price": 32000.0}
|
||||||
|
],
|
||||||
|
"count": 4
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Переменные
|
||||||
|
|
||||||
|
| Переменная | Обязательна | Описание |
|
||||||
|
|---|---|---|
|
||||||
|
| `token` | ✅ | JWT-токен облака. Задаётся в `terraform.tfvars` |
|
||||||
|
| `pg_dsn` | — | DSN подключения к PostgreSQL. Дефолт — внутрикластерный адрес |
|
||||||
|
|
||||||
|
`terraform.tfvars` не коммитится в git. Минимальное содержимое:
|
||||||
|
|
||||||
|
```hcl
|
||||||
|
token = "ваш токен Nubes API"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Как работает
|
||||||
|
|
||||||
|
```
|
||||||
|
terraform apply
|
||||||
|
→ загружает код в S3
|
||||||
|
→ kaniko собирает Docker-образ (pip install psycopg2-binary)
|
||||||
|
→ Deployment поднимается в кластере
|
||||||
|
→ Ingress публикует URL
|
||||||
|
|
||||||
|
GET /fn/<namespace>/product-catalog
|
||||||
|
→ рантайм вызывает list_products(event)
|
||||||
|
→ psycopg2 подключается к PostgreSQL
|
||||||
|
→ если таблица demo_products не существует — создаёт и заполняет демо-данными
|
||||||
|
→ возвращает JSON со списком
|
||||||
|
```
|
||||||
|
|
||||||
|
Таблица `demo_products` создаётся один раз — при первом GET. Повторные вызовы просто читают данные.
|
||||||
|
|
||||||
|
## Изменение данных
|
||||||
|
|
||||||
|
Функция только читает. Чтобы добавить/изменить записи — подключитесь к PostgreSQL напрямую:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO demo_products (name, price) VALUES ('Кабель', 499.00);
|
||||||
|
UPDATE demo_products SET price = 95000 WHERE name = 'Ноутбук';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Пересборка если изменили код
|
||||||
|
|
||||||
|
```bash
|
||||||
|
terraform apply -replace=sless_function.product_catalog -auto-approve
|
||||||
|
```
|
||||||
|
|
||||||
|
## Удаление
|
||||||
|
|
||||||
|
```bash
|
||||||
|
terraform destroy -auto-approve
|
||||||
|
```
|
||||||
|
|
||||||
|
Таблица `demo_products` в PostgreSQL **не удаляется** — только k8s-ресурсы.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# 2026-03-11
|
||||||
|
# catalog.py — читает список продуктов из PostgreSQL.
|
||||||
|
# Таблица demo_products создаётся автоматически при первом вызове.
|
||||||
|
# Точка входа: list_products(event)
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
|
||||||
|
def list_products(event):
|
||||||
|
dsn = os.environ["PG_DSN"]
|
||||||
|
conn = psycopg2.connect(dsn)
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
_ensure_table(cur)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
cur.execute("SELECT id, name, price FROM demo_products ORDER BY id")
|
||||||
|
rows = cur.fetchall()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
products = [{"id": row[0], "name": row[1], "price": float(row[2])} for row in rows]
|
||||||
|
return {"products": products, "count": len(products)}
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_table(cur):
|
||||||
|
# Создаём таблицу и наполняем демо-данными — только один раз
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS demo_products (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
price NUMERIC(10,2) NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
cur.execute("SELECT COUNT(*) FROM demo_products")
|
||||||
|
if cur.fetchone()[0] == 0:
|
||||||
|
cur.executemany(
|
||||||
|
"INSERT INTO demo_products (name, price) VALUES (%s, %s)",
|
||||||
|
[
|
||||||
|
("Ноутбук", 89999.00),
|
||||||
|
("Мышь", 1299.00),
|
||||||
|
("Клавиатура", 3499.00),
|
||||||
|
("Монитор", 32000.00),
|
||||||
|
],
|
||||||
|
)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
psycopg2-binary
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# 2026-03-11
|
||||||
|
# function.tf — HTTP-функция: читает из PostgreSQL и возвращает список записей.
|
||||||
|
# Нет джобов, нет инициализации — только функция + HTTP триггер.
|
||||||
|
|
||||||
|
resource "sless_function" "product_catalog" { # объявляем serverless-функцию; "product_catalog" — локальное имя в tf-state
|
||||||
|
name = "product-catalog" # имя функции в кластере; по нему формируется URL и имя k8s-объекта
|
||||||
|
runtime = "python3.11" # базовый образ рантайма; определяет как собирается и запускается код
|
||||||
|
entrypoint = "catalog.list_products" # файл.функция которую вызывает рантайм: catalog.py → def list_products(event)
|
||||||
|
memory_mb = 128 # лимит памяти пода в мегабайтах
|
||||||
|
timeout_sec = 10 # максимальное время выполнения одного запроса в секундах
|
||||||
|
|
||||||
|
source_dir = "${path.module}/code" # директория с кодом функции; провайдер упакует её в zip и загрузит
|
||||||
|
|
||||||
|
# DSN передаётся через env — функция не знает об инфраструктуре
|
||||||
|
env_vars = {
|
||||||
|
PG_DSN = var.pg_dsn # строка подключения к PostgreSQL; берётся из переменной (variables.tf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "sless_trigger" "product_catalog_http" { # триггер публикует функцию наружу; без него функция существует, но недоступна
|
||||||
|
name = "product-catalog-http" # имя триггера в кластере
|
||||||
|
type = "http" # тип триггера: "http" создаёт публичный URL; альтернатива — "cron"
|
||||||
|
function = sless_function.product_catalog.name # ссылка на имя функции выше; terraform гарантирует порядок создания
|
||||||
|
enabled = true # триггер активен сразу после создания
|
||||||
|
}
|
||||||
|
|
||||||
|
output "catalog_url" {
|
||||||
|
value = sless_trigger.product_catalog_http.url # URL вида https://sless-api.../fn/<namespace>/<name>; выводится после apply
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 2026-03-11
|
||||||
|
# main.tf — провайдер для pg-list-python примера.
|
||||||
|
|
||||||
|
terraform {
|
||||||
|
required_providers {
|
||||||
|
sless = {
|
||||||
|
source = "terra.k8c.ru/naeel/sless"
|
||||||
|
version = "~> 0.1.17"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
provider "sless" {
|
||||||
|
endpoint = "https://sless-api.kube5s.ru"
|
||||||
|
token = var.token
|
||||||
|
nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1"
|
||||||
|
ai_hint_level = 3
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
token = "замени на Nubes API token" и переименуй файл в terraform.tfvars
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# 2026-03-11
|
||||||
|
# variables.tf
|
||||||
|
|
||||||
|
variable "token" {
|
||||||
|
description = "JWT токен облака"
|
||||||
|
type = string
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "pg_dsn" {
|
||||||
|
description = "DSN подключения к PostgreSQL"
|
||||||
|
type = string
|
||||||
|
default = "postgres://sless:sless-pg-password@postgres.sless.svc.cluster.local:5432/sless?sslmode=disable"
|
||||||
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# 2026-03-11 10:00
|
|
||||||
# Minimal sample image to push to PearlHarbor registry
|
|
||||||
# Purpose: небольшой образ для тестирования пуша в реестр
|
|
||||||
|
|
||||||
FROM alpine:3.18
|
|
||||||
|
|
||||||
CMD ["sh", "-c", "echo Hello from pearlharbor sample image"]
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
# Пример для пуша в PearlHarbor
|
|
||||||
|
|
||||||
Файлы:
|
|
||||||
- [examples/push-sample/Dockerfile](examples/push-sample/Dockerfile) — минимальный образ
|
|
||||||
- [examples/push-sample/build_and_push.sh](examples/push-sample/build_and_push.sh) — сборка и опциональный пуш
|
|
||||||
|
|
||||||
Как использовать:
|
|
||||||
|
|
||||||
1. Сборка локально (в корне репы):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker build -t sless-sample:local -f examples/push-sample/Dockerfile examples/push-sample
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Протестировать скрипт (скрипт не будет пушить без переменной DO_PUSH):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd examples/push-sample
|
|
||||||
./build_and_push.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Для реального пуша установите `DO_PUSH=true`. Скрипт прочитает `secrets/pearlharbor_registry.txt`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
DO_PUSH=true ./build_and_push.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Примечание: скрипт использует по умолчанию пользователя `admin`. Для другого пользователя задайте `REGISTRY_USER`.
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# 2026-03-11 10:02
|
|
||||||
# Скрипт: собирает минимальный образ и, при разрешении, пушит в реестр PearlHarbor
|
|
||||||
# Требования: `docker` в PATH. Скрипт НЕ будет пушить без DO_PUSH=true.
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Получаем значения из файла секретов
|
|
||||||
SECRETS_FILE="secrets/pearlharbor_registry.txt"
|
|
||||||
if [ ! -f "$SECRETS_FILE" ]; then
|
|
||||||
echo "Файл с секретами не найден: $SECRETS_FILE"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
connection_url=$(grep -E '^connection_url=' "$SECRETS_FILE" | cut -d'=' -f2-)
|
|
||||||
admin_pass=$(grep -E '^admin_pass=' "$SECRETS_FILE" | cut -d'=' -f2-)
|
|
||||||
|
|
||||||
if [ -z "$connection_url" ]; then
|
|
||||||
echo "Не найден connection_url в $SECRETS_FILE"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Убираем протокол и возможный слеш на конце
|
|
||||||
registry_host=$(echo "$connection_url" | sed -E 's~https?://~~' | sed -E 's~/$~~')
|
|
||||||
|
|
||||||
image_name="$registry_host/sless-sample:latest"
|
|
||||||
|
|
||||||
echo "Registry host: $registry_host"
|
|
||||||
echo "Image name: $image_name"
|
|
||||||
|
|
||||||
echo "Собираю образ локально..."
|
|
||||||
docker build -t sless-sample:local -f Dockerfile .. || {
|
|
||||||
echo "Сборка не удалась"; exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "Готово. Образ: sless-sample:local"
|
|
||||||
|
|
||||||
if [ "${DO_PUSH:-}" != "true" ]; then
|
|
||||||
echo "DO_PUSH != true — пуш не будет выполнен. Чтобы запушить: DO_PUSH=true ./build_and_push.sh"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Если дошли до сюда — выполняем login/push
|
|
||||||
registry_user=${REGISTRY_USER:-admin}
|
|
||||||
|
|
||||||
echo "Выполняю docker login к $registry_host как '$registry_user'"
|
|
||||||
echo "$admin_pass" | docker login "$registry_host" -u "$registry_user" --password-stdin
|
|
||||||
|
|
||||||
echo "Тегирую и пушу образ: $image_name"
|
|
||||||
docker tag sless-sample:local "$image_name"
|
|
||||||
docker push "$image_name"
|
|
||||||
|
|
||||||
echo "Пуш завершён. Проверьте реестр для образа: $image_name"
|
|
||||||
@@ -1,333 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
LOG_DIR="$ROOT_DIR/.test-logs"
|
|
||||||
mkdir -p "$LOG_DIR"
|
|
||||||
|
|
||||||
EXAMPLES=(
|
|
||||||
"hello-node"
|
|
||||||
"simple-node"
|
|
||||||
"simple-python"
|
|
||||||
"notes-python"
|
|
||||||
)
|
|
||||||
|
|
||||||
declare -A CHECK_METHOD=(
|
|
||||||
[hello-node]="POST"
|
|
||||||
[simple-node]="GET"
|
|
||||||
[simple-python]="GET"
|
|
||||||
[notes-python]="GET"
|
|
||||||
)
|
|
||||||
|
|
||||||
declare -A CHECK_URL=(
|
|
||||||
[hello-node]="https://sless-api.kube5s.ru/fn/default/hello-http"
|
|
||||||
[simple-node]="https://sless-api.kube5s.ru/fn/default/simple-node-time-display"
|
|
||||||
[simple-python]="https://sless-api.kube5s.ru/fn/default/simple-py-time-display"
|
|
||||||
[notes-python]="https://sless-api.kube5s.ru/fn/default/notes-list"
|
|
||||||
)
|
|
||||||
|
|
||||||
declare -A CHECK_DATA=(
|
|
||||||
[hello-node]='{"name":"Smoke"}'
|
|
||||||
[simple-node]=''
|
|
||||||
[simple-python]=''
|
|
||||||
[notes-python]=''
|
|
||||||
)
|
|
||||||
|
|
||||||
declare -a PREEXISTING_EXAMPLES=()
|
|
||||||
LAST_LOG_FILE=""
|
|
||||||
CURRENT_EXAMPLE=""
|
|
||||||
CURRENT_STEP=""
|
|
||||||
LAST_ERROR_SUMMARY=""
|
|
||||||
|
|
||||||
fail_run() {
|
|
||||||
local example="$1"
|
|
||||||
local step="$2"
|
|
||||||
local details="$3"
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "ERROR SUMMARY"
|
|
||||||
echo "example: $example"
|
|
||||||
echo "step: $step"
|
|
||||||
echo "reason: $details"
|
|
||||||
|
|
||||||
if [ -n "$LAST_LOG_FILE" ] && [ -f "$LAST_LOG_FILE" ]; then
|
|
||||||
echo "log: $LAST_LOG_FILE"
|
|
||||||
echo "last log lines:"
|
|
||||||
tail -n 20 "$LAST_LOG_FILE"
|
|
||||||
fi
|
|
||||||
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
run_step() {
|
|
||||||
local example="$1"
|
|
||||||
local step="$2"
|
|
||||||
shift 2
|
|
||||||
|
|
||||||
CURRENT_EXAMPLE="$example"
|
|
||||||
CURRENT_STEP="$step"
|
|
||||||
LAST_ERROR_SUMMARY=""
|
|
||||||
|
|
||||||
if ! "$@"; then
|
|
||||||
local details="$LAST_ERROR_SUMMARY"
|
|
||||||
if [ -z "$details" ]; then
|
|
||||||
details="step failed without explicit summary"
|
|
||||||
fi
|
|
||||||
fail_run "$example" "$step" "$details"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
restore_any_backups() {
|
|
||||||
local backup
|
|
||||||
while IFS= read -r backup; do
|
|
||||||
[ -n "$backup" ] || continue
|
|
||||||
if [ -f "$backup" ]; then
|
|
||||||
mv "$backup" "${backup%.copilot.bak}"
|
|
||||||
fi
|
|
||||||
done < <(find "$ROOT_DIR" -name '*.copilot.bak' | sort)
|
|
||||||
}
|
|
||||||
|
|
||||||
trap restore_any_backups EXIT
|
|
||||||
|
|
||||||
clean_local_artifacts() {
|
|
||||||
local example="$1"
|
|
||||||
rm -rf \
|
|
||||||
"$ROOT_DIR/$example/.terraform" \
|
|
||||||
"$ROOT_DIR/$example/.terraform.lock.hcl" \
|
|
||||||
"$ROOT_DIR/$example/terraform.tfstate" \
|
|
||||||
"$ROOT_DIR/$example/terraform.tfstate.backup" \
|
|
||||||
"$ROOT_DIR/$example"/terraform.tfstate.*.backup \
|
|
||||||
"$ROOT_DIR/$example/dist"
|
|
||||||
}
|
|
||||||
|
|
||||||
retry_tf() {
|
|
||||||
local example="$1"
|
|
||||||
local label="$2"
|
|
||||||
shift 2
|
|
||||||
|
|
||||||
local attempt=1
|
|
||||||
while [ "$attempt" -le 3 ]; do
|
|
||||||
LAST_LOG_FILE="$LOG_DIR/${example//\//_}-${label// /_}-${attempt}.log"
|
|
||||||
echo "==> [$example] $label (attempt $attempt/3)"
|
|
||||||
|
|
||||||
(
|
|
||||||
cd "$ROOT_DIR/$example"
|
|
||||||
"$@"
|
|
||||||
) 2>&1 | tee "$LAST_LOG_FILE"
|
|
||||||
|
|
||||||
local status=${PIPESTATUS[0]}
|
|
||||||
if [ "$status" -eq 0 ]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if grep -Eiq 'Unauthorized|401|403' "$LAST_LOG_FILE"; then
|
|
||||||
LAST_ERROR_SUMMARY="authorization error during $label"
|
|
||||||
echo "[$example] authorization error during $label"
|
|
||||||
return 41
|
|
||||||
fi
|
|
||||||
|
|
||||||
if grep -Eiq 'TLS handshake timeout|tls:.*timeout|i/o timeout|Client\.Timeout exceeded while awaiting headers|context deadline exceeded|unexpected EOF' "$LAST_LOG_FILE" && [ "$attempt" -lt 3 ]; then
|
|
||||||
attempt=$((attempt + 1))
|
|
||||||
sleep 2
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
if grep -Eiq 'TLS handshake timeout|tls:.*timeout|i/o timeout|Client\.Timeout exceeded while awaiting headers|context deadline exceeded|unexpected EOF' "$LAST_LOG_FILE"; then
|
|
||||||
LAST_ERROR_SUMMARY="network/provider download failure during $label after retries"
|
|
||||||
else
|
|
||||||
LAST_ERROR_SUMMARY="terraform command failed during $label with exit code $status"
|
|
||||||
fi
|
|
||||||
|
|
||||||
return "$status"
|
|
||||||
done
|
|
||||||
|
|
||||||
LAST_ERROR_SUMMARY="terraform command failed during $label after exhausting retries"
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
record_preexisting_if_needed() {
|
|
||||||
local example="$1"
|
|
||||||
if grep -Fq 'No changes. Your infrastructure matches the configuration.' "$LAST_LOG_FILE"; then
|
|
||||||
PREEXISTING_EXAMPLES+=("$example")
|
|
||||||
echo "[$example] detected preexisting remote resources on clean apply"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
probe_endpoint() {
|
|
||||||
local example="$1"
|
|
||||||
local body_file="$LOG_DIR/${example//\//_}-endpoint-body.txt"
|
|
||||||
local status_file="$LOG_DIR/${example//\//_}-endpoint-status.txt"
|
|
||||||
local method="${CHECK_METHOD[$example]}"
|
|
||||||
local url="${CHECK_URL[$example]}"
|
|
||||||
local data="${CHECK_DATA[$example]}"
|
|
||||||
|
|
||||||
if [ "$method" = "POST" ]; then
|
|
||||||
curl -sS -X POST -H 'Content-Type: application/json' -d "$data" -o "$body_file" -w '%{http_code}' "$url" > "$status_file"
|
|
||||||
else
|
|
||||||
curl -sS -o "$body_file" -w '%{http_code}' "$url" > "$status_file"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_live_endpoint() {
|
|
||||||
local example="$1"
|
|
||||||
probe_endpoint "$example"
|
|
||||||
|
|
||||||
local body_file="$LOG_DIR/${example//\//_}-endpoint-body.txt"
|
|
||||||
local status
|
|
||||||
status="$(cat "$LOG_DIR/${example//\//_}-endpoint-status.txt")"
|
|
||||||
|
|
||||||
if [ "$status" != "200" ]; then
|
|
||||||
LAST_ERROR_SUMMARY="live endpoint check failed with HTTP $status"
|
|
||||||
echo "[$example] live endpoint check failed with HTTP $status"
|
|
||||||
cat "$body_file"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if grep -Fq 'function unreachable' "$body_file"; then
|
|
||||||
LAST_ERROR_SUMMARY="live endpoint returned function unreachable"
|
|
||||||
echo "[$example] live endpoint check returned unreachable function"
|
|
||||||
cat "$body_file"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_destroyed_endpoint() {
|
|
||||||
local example="$1"
|
|
||||||
probe_endpoint "$example"
|
|
||||||
|
|
||||||
local body_file="$LOG_DIR/${example//\//_}-endpoint-body.txt"
|
|
||||||
local status
|
|
||||||
status="$(cat "$LOG_DIR/${example//\//_}-endpoint-status.txt")"
|
|
||||||
|
|
||||||
if [ "$status" = "404" ] || [ "$status" = "000" ]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if grep -Eiq 'not found|404 page not found' "$body_file"; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$status" = "502" ] && grep -Fq 'function unreachable' "$body_file"; then
|
|
||||||
LAST_ERROR_SUMMARY="route cleanup bug: public endpoint still exists but backend is already gone (HTTP 502 function unreachable)"
|
|
||||||
echo "[$example] route still exists after destroy, but backend is already gone (HTTP 502 function unreachable)"
|
|
||||||
cat "$body_file"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
LAST_ERROR_SUMMARY="endpoint still responds after destroy with HTTP $status"
|
|
||||||
echo "[$example] endpoint still responds after destroy with HTTP $status"
|
|
||||||
cat "$body_file"
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
wait_for_destroyed_endpoint() {
|
|
||||||
local example="$1"
|
|
||||||
local attempts=24
|
|
||||||
local sleep_sec=5
|
|
||||||
local try=1
|
|
||||||
|
|
||||||
while [ "$try" -le "$attempts" ]; do
|
|
||||||
if assert_destroyed_endpoint "$example"; then
|
|
||||||
echo "[$example] endpoint disappeared after destroy"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[$example] endpoint still present after destroy, waiting (${try}/${attempts})"
|
|
||||||
try=$((try + 1))
|
|
||||||
sleep "$sleep_sec"
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "[$example] endpoint did not disappear after destroy within $((attempts * sleep_sec))s"
|
|
||||||
if [ -z "$LAST_ERROR_SUMMARY" ]; then
|
|
||||||
LAST_ERROR_SUMMARY="endpoint remained reachable for more than $((attempts * sleep_sec))s after destroy"
|
|
||||||
else
|
|
||||||
LAST_ERROR_SUMMARY="$LAST_ERROR_SUMMARY; endpoint was still published after $((attempts * sleep_sec))s"
|
|
||||||
fi
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
backup_and_modify() {
|
|
||||||
local example="$1"
|
|
||||||
case "$example" in
|
|
||||||
hello-node)
|
|
||||||
cp "$ROOT_DIR/$example/http.tf" "$ROOT_DIR/$example/http.tf.copilot.bak"
|
|
||||||
perl -0pi -e 's/enabled\s+=\s+true/enabled = false/' "$ROOT_DIR/$example/http.tf"
|
|
||||||
;;
|
|
||||||
simple-node)
|
|
||||||
cp "$ROOT_DIR/$example/time-display.tf" "$ROOT_DIR/$example/time-display.tf.copilot.bak"
|
|
||||||
perl -0pi -e 's/memory_mb\s+=\s+64/memory_mb = 96/' "$ROOT_DIR/$example/time-display.tf"
|
|
||||||
;;
|
|
||||||
simple-python)
|
|
||||||
cp "$ROOT_DIR/$example/time-display.tf" "$ROOT_DIR/$example/time-display.tf.copilot.bak"
|
|
||||||
perl -0pi -e 's/memory_mb\s+=\s+64/memory_mb = 96/' "$ROOT_DIR/$example/time-display.tf"
|
|
||||||
;;
|
|
||||||
notes-python)
|
|
||||||
cp "$ROOT_DIR/$example/notes-list.tf" "$ROOT_DIR/$example/notes-list.tf.copilot.bak"
|
|
||||||
perl -0pi -e 's/memory_mb\s+=\s+128/memory_mb = 160/' "$ROOT_DIR/$example/notes-list.tf"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
restore_modified_files() {
|
|
||||||
local example="$1"
|
|
||||||
case "$example" in
|
|
||||||
hello-node)
|
|
||||||
mv "$ROOT_DIR/$example/http.tf.copilot.bak" "$ROOT_DIR/$example/http.tf"
|
|
||||||
;;
|
|
||||||
simple-node)
|
|
||||||
mv "$ROOT_DIR/$example/time-display.tf.copilot.bak" "$ROOT_DIR/$example/time-display.tf"
|
|
||||||
;;
|
|
||||||
simple-python)
|
|
||||||
mv "$ROOT_DIR/$example/time-display.tf.copilot.bak" "$ROOT_DIR/$example/time-display.tf"
|
|
||||||
;;
|
|
||||||
notes-python)
|
|
||||||
mv "$ROOT_DIR/$example/notes-list.tf.copilot.bak" "$ROOT_DIR/$example/notes-list.tf"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
run_example() {
|
|
||||||
local example="$1"
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "==== $example ===="
|
|
||||||
|
|
||||||
clean_local_artifacts "$example"
|
|
||||||
run_step "$example" "terraform init" retry_tf "$example" "terraform init" terraform init -input=false -no-color
|
|
||||||
run_step "$example" "terraform apply clean" retry_tf "$example" "terraform apply clean" terraform apply -auto-approve -input=false -no-color
|
|
||||||
record_preexisting_if_needed "$example"
|
|
||||||
run_step "$example" "endpoint check after clean apply" assert_live_endpoint "$example"
|
|
||||||
|
|
||||||
run_step "$example" "terraform destroy clean" retry_tf "$example" "terraform destroy clean" terraform destroy -auto-approve -input=false -no-color
|
|
||||||
run_step "$example" "endpoint cleanup after clean destroy" wait_for_destroyed_endpoint "$example"
|
|
||||||
|
|
||||||
run_step "$example" "terraform apply second" retry_tf "$example" "terraform apply second" terraform apply -auto-approve -input=false -no-color
|
|
||||||
run_step "$example" "endpoint check after second apply" assert_live_endpoint "$example"
|
|
||||||
|
|
||||||
backup_and_modify "$example"
|
|
||||||
run_step "$example" "terraform apply modified" retry_tf "$example" "terraform apply modified" terraform apply -auto-approve -input=false -no-color
|
|
||||||
restore_modified_files "$example"
|
|
||||||
|
|
||||||
run_step "$example" "terraform destroy final" retry_tf "$example" "terraform destroy final" terraform destroy -auto-approve -input=false -no-color
|
|
||||||
run_step "$example" "endpoint cleanup after final destroy" wait_for_destroyed_endpoint "$example"
|
|
||||||
clean_local_artifacts "$example"
|
|
||||||
}
|
|
||||||
|
|
||||||
main() {
|
|
||||||
local example
|
|
||||||
for example in "${EXAMPLES[@]}"; do
|
|
||||||
run_example "$example"
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ "${#PREEXISTING_EXAMPLES[@]}" -gt 0 ]; then
|
|
||||||
echo
|
|
||||||
echo "Preexisting remote resources were detected on first apply for: ${PREEXISTING_EXAMPLES[*]}"
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "All Terraform example lifecycles completed successfully."
|
|
||||||
}
|
|
||||||
|
|
||||||
main "$@"
|
|
||||||
@@ -19,7 +19,7 @@ terraform {
|
|||||||
required_providers {
|
required_providers {
|
||||||
sless = {
|
sless = {
|
||||||
source = "terra.k8c.ru/naeel/sless"
|
source = "terra.k8c.ru/naeel/sless"
|
||||||
version = "~> 0.1.13"
|
version = "~> 0.1.17"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
token = "замени на Nubes API token" и переименуй файл в terraform.tfvars
|
||||||
@@ -18,7 +18,7 @@ terraform {
|
|||||||
required_providers {
|
required_providers {
|
||||||
sless = {
|
sless = {
|
||||||
source = "terra.k8c.ru/naeel/sless"
|
source = "terra.k8c.ru/naeel/sless"
|
||||||
version = "~> 0.1.13"
|
version = "~> 0.1.17"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
token = "замени на Nubes API token" и переименуй файл в terraform.tfvars
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Изменено: 2026-03-07
|
# Изменено: 2026-03-11
|
||||||
# Создаёт k8s Secret sless-registry-auth с DockerHub кредами для kaniko.
|
# Создаёт k8s Secret sless-registry-auth с кредами Harbor для kaniko.
|
||||||
# Kaniko монтирует этот secret как /kaniko/.docker/config.json для push образов.
|
# Kaniko монтирует этот secret как /kaniko/.docker/config.json для push образов.
|
||||||
|
# Отдельно от HARBOR_PASS в operator-secret: этот secret — для kaniko (push),
|
||||||
|
# HARBOR_PASS — для Harbor API (создание проектов через EnsureProject).
|
||||||
#
|
#
|
||||||
# Использование:
|
# Использование:
|
||||||
# DOCKER_HUB_USER=naeel DOCKER_HUB_TOKEN=<token> ./hack/create-registry-secret.sh
|
# HARBOR_USER=admin HARBOR_PASS=<password> REGISTRY=pearlharbor.registryk8s.services.ngcloud.ru \
|
||||||
|
# ./hack/create-registry-secret.sh
|
||||||
#
|
#
|
||||||
# Требуется: kubectl, авторизованный context с доступом к namespace sless.
|
# Требуется: kubectl, авторизованный context с доступом к namespace sless.
|
||||||
|
|
||||||
@@ -12,21 +15,22 @@ set -euo pipefail
|
|||||||
|
|
||||||
NAMESPACE="sless"
|
NAMESPACE="sless"
|
||||||
SECRET_NAME="sless-registry-auth"
|
SECRET_NAME="sless-registry-auth"
|
||||||
|
REGISTRY="${REGISTRY:-pearlharbor.registryk8s.services.ngcloud.ru}"
|
||||||
|
|
||||||
if [[ -z "${DOCKER_HUB_USER:-}" ]]; then
|
if [[ -z "${HARBOR_USER:-}" ]]; then
|
||||||
echo "ERROR: DOCKER_HUB_USER env var is required"
|
echo "ERROR: HARBOR_USER env var is required"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if [[ -z "${DOCKER_HUB_TOKEN:-}" ]]; then
|
if [[ -z "${HARBOR_PASS:-}" ]]; then
|
||||||
echo "ERROR: DOCKER_HUB_TOKEN env var is required (DockerHub access token, not password)"
|
echo "ERROR: HARBOR_PASS env var is required"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
kubectl create secret docker-registry "${SECRET_NAME}" \
|
kubectl create secret docker-registry "${SECRET_NAME}" \
|
||||||
--docker-username="${DOCKER_HUB_USER}" \
|
--docker-username="${HARBOR_USER}" \
|
||||||
--docker-password="${DOCKER_HUB_TOKEN}" \
|
--docker-password="${HARBOR_PASS}" \
|
||||||
--docker-server="https://index.docker.io/v1/" \
|
--docker-server="${REGISTRY}" \
|
||||||
--namespace="${NAMESPACE}" \
|
--namespace="${NAMESPACE}" \
|
||||||
--dry-run=client -o yaml | kubectl apply -f -
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
|
||||||
echo "OK: secret '${SECRET_NAME}' applied in namespace '${NAMESPACE}'"
|
echo "OK: secret '${SECRET_NAME}' applied in namespace '${NAMESPACE}' (server: ${REGISTRY})"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
// Изменено: 2026-03-07
|
// Изменено: 2026-03-11
|
||||||
// Builder — запускает kaniko Job в k8s для сборки Docker образа из кода функции.
|
// Builder — запускает kaniko Job в k8s для сборки Docker образа из кода функции.
|
||||||
// Почему kaniko, а не docker-in-docker (DinD):
|
// Почему kaniko, а не docker-in-docker (DinD):
|
||||||
// kaniko не требует privileged контейнер, что безопаснее в managed кластере.
|
// kaniko не требует privileged контейнер, что безопаснее в managed кластере.
|
||||||
// kaniko читает контекст сборки из S3 напрямую.
|
// kaniko читает контекст сборки из S3 напрямую.
|
||||||
// Workflow: FunctionController вызывает Build → Job запускается → образ пушится в registry.
|
// Workflow: FunctionController вызывает Build → EnsureProject (Harbor) → Job запускается → образ пушится в registry.
|
||||||
|
|
||||||
package builder
|
package builder
|
||||||
|
|
||||||
@@ -20,17 +20,24 @@ import (
|
|||||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Projecter — опциональный интерфейс для реестров с поддержкой проектов (Harbor).
|
||||||
|
// Если nil — вызов EnsureProject пропускается (Docker Hub, другие реестры без проектов).
|
||||||
|
type Projecter interface {
|
||||||
|
EnsureProject(ctx context.Context, name string) error
|
||||||
|
}
|
||||||
|
|
||||||
// Builder — управляет сборкой Docker образов через kaniko Jobs в k8s.
|
// Builder — управляет сборкой Docker образов через kaniko Jobs в k8s.
|
||||||
type Builder struct {
|
type Builder struct {
|
||||||
client client.Client
|
client client.Client
|
||||||
builderImage string // образ kaniko
|
builderImage string // образ kaniko
|
||||||
registryHost string // куда пушим образ
|
registryHost string // куда пушим образ (Harbor: host; DockerHub: user/org)
|
||||||
registrySecret string // имя k8s Secret с docker-кредами для kaniko
|
registrySecret string // имя k8s Secret с docker-кредами для kaniko
|
||||||
s3Endpoint string // откуда kaniko берёт код
|
s3Endpoint string // откуда kaniko берёт код
|
||||||
s3AccessKey string
|
s3AccessKey string
|
||||||
s3SecretKey string
|
s3SecretKey string
|
||||||
s3Bucket string
|
s3Bucket string
|
||||||
namespace string // namespace где запускаем build Job'ы
|
namespace string // namespace где запускаем build Job'ы
|
||||||
|
harborClient Projecter // nil если Harbor не используется
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config — параметры для создания Builder'а.
|
// Config — параметры для создания Builder'а.
|
||||||
@@ -43,6 +50,7 @@ type Config struct {
|
|||||||
S3SecretKey string
|
S3SecretKey string
|
||||||
S3Bucket string
|
S3Bucket string
|
||||||
Namespace string
|
Namespace string
|
||||||
|
HarborClient Projecter // nil — Harbor не используется, EnsureProject пропускается
|
||||||
}
|
}
|
||||||
|
|
||||||
// New создаёт новый Builder.
|
// New создаёт новый Builder.
|
||||||
@@ -57,21 +65,34 @@ func New(c client.Client, cfg Config) *Builder {
|
|||||||
s3SecretKey: cfg.S3SecretKey,
|
s3SecretKey: cfg.S3SecretKey,
|
||||||
s3Bucket: cfg.S3Bucket,
|
s3Bucket: cfg.S3Bucket,
|
||||||
namespace: cfg.Namespace,
|
namespace: cfg.Namespace,
|
||||||
|
harborClient: cfg.HarborClient,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImageRef возвращает полный путь к образу в registry для данной функции и версии.
|
// ImageRef возвращает полный путь к образу в registry для данной функции и версии.
|
||||||
// Тег = первые 12 символов sha256(s3Key): уникален per build, не меняется при
|
// Тег = первые 12 символов sha256(s3Key): уникален per build, не меняется при
|
||||||
// повторном reconcile с тем же s3Key, не требует хаков с :latest.
|
// повторном reconcile с тем же s3Key, не требует хаков с :latest.
|
||||||
|
// Формат: {registryHost}/{namespace}/{funcName}:{tag}
|
||||||
|
//
|
||||||
|
// Harbor: pearlharbor.registryk8s.services.ngcloud.ru/{ns}/{func}:{tag}
|
||||||
|
//
|
||||||
|
// Чтобы сменить реестр — достаточно изменить REGISTRY_HOST в одном месте (ConfigMap).
|
||||||
func (b *Builder) ImageRef(namespace, funcName, s3Key string) string {
|
func (b *Builder) ImageRef(namespace, funcName, s3Key string) string {
|
||||||
h := sha256.Sum256([]byte(s3Key))
|
h := sha256.Sum256([]byte(s3Key))
|
||||||
tag := fmt.Sprintf("%x", h[:6]) // 12 hex-символов
|
tag := fmt.Sprintf("%x", h[:6]) // 12 hex-символов
|
||||||
return fmt.Sprintf("%s/sless-%s-%s:%s", b.registryHost, namespace, funcName, tag)
|
return fmt.Sprintf("%s/%s/%s:%s", b.registryHost, namespace, funcName, tag)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build запускает kaniko Job для сборки образа функции.
|
// Build запускает kaniko Job для сборки образа функции.
|
||||||
// Возвращает имя Job'а чтобы контроллер мог следить за его статусом.
|
// Возвращает имя Job'а чтобы контроллер мог следить за его статусом.
|
||||||
func (b *Builder) Build(ctx context.Context, namespace, funcName, s3Key string) (string, error) {
|
func (b *Builder) Build(ctx context.Context, namespace, funcName, s3Key string) (string, error) {
|
||||||
|
// Идемпотентно создаём Harbor-проект перед push — kaniko сам его не создаёт.
|
||||||
|
// Пропускаем если HarborClient не задан (Docker Hub и другие реестры без проектов).
|
||||||
|
if b.harborClient != nil {
|
||||||
|
if err := b.harborClient.EnsureProject(ctx, namespace); err != nil {
|
||||||
|
return "", fmt.Errorf("harbor ensure project %q: %w", namespace, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
imageRef := b.ImageRef(namespace, funcName, s3Key)
|
imageRef := b.ImageRef(namespace, funcName, s3Key)
|
||||||
jobName := fmt.Sprintf("build-%s-%s", funcName, time.Now().Format("20060102150405"))
|
jobName := fmt.Sprintf("build-%s-%s", funcName, time.Now().Format("20060102150405"))
|
||||||
|
|
||||||
|
|||||||
+48
-16
@@ -35,16 +35,19 @@ func PrepareContext(zipData []byte, runtime string) (*bytes.Buffer, error) {
|
|||||||
// Это знание принадлежит builder-у, не HTTP-хендлеру.
|
// Это знание принадлежит builder-у, не HTTP-хендлеру.
|
||||||
hasRequirements := false // requirements.txt — python3.11: pip install
|
hasRequirements := false // requirements.txt — python3.11: pip install
|
||||||
hasPackageJSON := false // package.json — nodejs20: npm install
|
hasPackageJSON := false // package.json — nodejs20: npm install
|
||||||
|
hasGoMod := false // go.mod — go1.23: пользователь может дать свой go.mod
|
||||||
for _, f := range zr.File {
|
for _, f := range zr.File {
|
||||||
switch f.Name {
|
switch f.Name {
|
||||||
case "requirements.txt":
|
case "requirements.txt":
|
||||||
hasRequirements = true
|
hasRequirements = true
|
||||||
case "package.json":
|
case "package.json":
|
||||||
hasPackageJSON = true
|
hasPackageJSON = true
|
||||||
|
case "go.mod":
|
||||||
|
hasGoMod = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dockerfileContent, err := generateDockerfile(runtime, hasRequirements, hasPackageJSON)
|
dockerfileContent, err := generateDockerfile(runtime, hasRequirements, hasPackageJSON, hasGoMod)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Ошибка здесь означает неподдерживаемый runtime — 400 на уровне handler'а.
|
// Ошибка здесь означает неподдерживаемый runtime — 400 на уровне handler'а.
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -63,36 +66,65 @@ func PrepareContext(zipData []byte, runtime string) (*bytes.Buffer, error) {
|
|||||||
func runtimeBaseImage(runtime string) (string, error) {
|
func runtimeBaseImage(runtime string) (string, error) {
|
||||||
switch runtime {
|
switch runtime {
|
||||||
case "python3.11":
|
case "python3.11":
|
||||||
return "naeel/sless-runtime-python3.11:v0.1.1", nil
|
return "naeel/sless-runtime-python3.11:v0.1.2", nil
|
||||||
case "nodejs20":
|
case "nodejs20":
|
||||||
return "naeel/sless-runtime-nodejs20:v0.1.2", nil
|
return "naeel/sless-runtime-nodejs20:v0.1.2", nil
|
||||||
|
case "go1.23":
|
||||||
|
// Go использует builder-образ (содержит golang:1.23 + server.go).
|
||||||
|
// Конечный образ multi-stage — alpine без компилятора.
|
||||||
|
return "naeel/sless-runtime-go1.23:v0.1.0", nil
|
||||||
default:
|
default:
|
||||||
return "", fmt.Errorf("unsupported runtime: %q (supported: python3.11, nodejs20)", runtime)
|
return "", fmt.Errorf("unsupported runtime: %q (supported: python3.11, nodejs20, go1.23)", runtime)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateDockerfile генерирует Dockerfile для kaniko.
|
// generateDockerfile генерирует Dockerfile для kaniko.
|
||||||
// Базовый образ содержит HTTP-обёртку (server.py / server.js).
|
// Базовый образ содержит HTTP-обёртку (server.py / server.js) или builder (для Go).
|
||||||
// Пользовательский код копируется в /app/function/ поверх базового образа.
|
// Пользовательский код копируется в /app/function/ (или /app/handler/ для Go) поверх базового образа.
|
||||||
// Зависимости устанавливаются ПОСЛЕ COPY — чтобы кеш слоёв работал при повторных сборках.
|
// Зависимости устанавливаются ПОСЛЕ COPY — чтобы кеш слоёв работал при повторных сборках.
|
||||||
func generateDockerfile(runtime string, hasRequirements bool, hasPackageJSON bool) ([]byte, error) {
|
func generateDockerfile(runtime string, hasRequirements bool, hasPackageJSON bool, hasGoMod bool) ([]byte, error) {
|
||||||
baseImage, err := runtimeBaseImage(runtime)
|
baseImage, err := runtimeBaseImage(runtime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
content := fmt.Sprintf("FROM %s\nCOPY . /app/function/\n", baseImage)
|
|
||||||
switch runtime {
|
switch runtime {
|
||||||
case "python3.11":
|
case "go1.23":
|
||||||
if hasRequirements {
|
// Go: multi-stage build.
|
||||||
content += "RUN pip install --no-cache-dir -r /app/function/requirements.txt\n"
|
// base-образ содержит: server.go (package main) + go.mod (module sless/fn).
|
||||||
}
|
// kaniko копирует пользовательский код в handler/ (package handler).
|
||||||
case "nodejs20":
|
// go build видит: /app/server.go + /app/handler/*.go под одним модулем sless/fn.
|
||||||
if hasPackageJSON {
|
// Если у пользователя есть go.mod — он кладётся в /app/handler/go.mod (игнорируется).
|
||||||
// cd нужен т.к. npm install читает package.json из текущей директории
|
// COPY . /app/handler/ — пользовательские файлы лежат в корне tar-контекста (без prefix).
|
||||||
content += "RUN cd /app/function && npm install --omit=dev\n"
|
// kaniko не умеет переименовывать пути при COPY, поэтому копируем всё "." в handler/.
|
||||||
|
content := fmt.Sprintf(
|
||||||
|
"FROM %s AS builder\n"+
|
||||||
|
"WORKDIR /app\n"+
|
||||||
|
"COPY . /app/handler/\n"+
|
||||||
|
"RUN CGO_ENABLED=0 go build -o /server .\n"+
|
||||||
|
"FROM alpine:3.20\n"+
|
||||||
|
"COPY --from=builder /server /server\n"+
|
||||||
|
"EXPOSE 8080\n"+
|
||||||
|
"CMD [\"/server\"]\n",
|
||||||
|
baseImage,
|
||||||
|
)
|
||||||
|
return []byte(content), nil
|
||||||
|
|
||||||
|
default: // python3.11, nodejs20
|
||||||
|
content := fmt.Sprintf("FROM %s\nCOPY . /app/function/\n", baseImage)
|
||||||
|
switch runtime {
|
||||||
|
case "python3.11":
|
||||||
|
if hasRequirements {
|
||||||
|
content += "RUN pip install --no-cache-dir -r /app/function/requirements.txt\n"
|
||||||
|
}
|
||||||
|
case "nodejs20":
|
||||||
|
if hasPackageJSON {
|
||||||
|
// cd нужен т.к. npm install читает package.json из текущей директории
|
||||||
|
content += "RUN cd /app/function && npm install --omit=dev\n"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return []byte(content), nil
|
||||||
}
|
}
|
||||||
return []byte(content), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// zipToTarGz принимает уже распарсенный *zip.Reader и упаковывает содержимое + Dockerfile в tar.gz.
|
// zipToTarGz принимает уже распарсенный *zip.Reader и упаковывает содержимое + Dockerfile в tar.gz.
|
||||||
|
|||||||
@@ -50,10 +50,14 @@ type Config struct {
|
|||||||
// Поле сохранено для совместимости конфигурации.
|
// Поле сохранено для совместимости конфигурации.
|
||||||
// Аутентификация происходит в middleware/auth.go через validateJWT().
|
// Аутентификация происходит в middleware/auth.go через validateJWT().
|
||||||
APIToken string
|
APIToken string
|
||||||
|
|
||||||
|
// Harbor — креды для Harbor API (создание проектов до push образов).
|
||||||
|
// REGISTRY_HOST уже содержит адрес реестра — дублировать не нужно.
|
||||||
|
// Если не заданы, EnsureProject пропускается (только DockerHub/без проектов).
|
||||||
|
HarborUser string
|
||||||
|
HarborPass string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load читает конфиг из env переменных.
|
|
||||||
// Возвращает ошибку если обязательные переменные не заданы.
|
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
APIPort: 8080,
|
APIPort: 8080,
|
||||||
@@ -128,5 +132,10 @@ func Load() (*Config, error) {
|
|||||||
// Не required: auth middleware использует validateJWT, а не статический токен.
|
// Не required: auth middleware использует validateJWT, а не статический токен.
|
||||||
cfg.APIToken = os.Getenv("SLESS_API_TOKEN")
|
cfg.APIToken = os.Getenv("SLESS_API_TOKEN")
|
||||||
|
|
||||||
|
// Harbor API креды — опциональны.
|
||||||
|
// Если не заданы, EnsureProject пропускается (push работает через docker-кред в kaniko).
|
||||||
|
cfg.HarborUser = os.Getenv("HARBOR_USER")
|
||||||
|
cfg.HarborPass = os.Getenv("HARBOR_PASS")
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// Изменено: 2026-03-11
|
||||||
|
// Harbor API клиент — управление проектами (create/ensure) перед push образов.
|
||||||
|
// Почему здесь: kaniko не создаёт Harbor-проект сам, нужен явный вызов API.
|
||||||
|
// Все операции идемпотентны - повторный вызов EnsureProject безопасен.
|
||||||
|
package harbor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client — минимальный HTTP-клиент для Harbor API v2.
|
||||||
|
// Используется только для создания проектов; пуш образов делает kaniko напрямую.
|
||||||
|
type Client struct {
|
||||||
|
baseURL string // https://{registryHost}
|
||||||
|
user string
|
||||||
|
password string
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// New создаёт Harbor клиент.
|
||||||
|
// registryHost — хост реестра без схемы (e.g. "pearlharbor.registryk8s.services.ngcloud.ru").
|
||||||
|
func New(registryHost, user, password string) *Client {
|
||||||
|
return &Client{
|
||||||
|
baseURL: "https://" + registryHost,
|
||||||
|
user: user,
|
||||||
|
password: password,
|
||||||
|
http: &http.Client{Timeout: 15 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureProject идемпотентно создаёт Harbor проект с именем name (private).
|
||||||
|
// GET /api/v2.0/projects?name={name} → если не найден → POST /api/v2.0/projects.
|
||||||
|
// Возвращает nil если проект уже существовал или был успешно создан.
|
||||||
|
func (c *Client) EnsureProject(ctx context.Context, name string) error {
|
||||||
|
// Harbor GET projects возвращает список проектов по фильтру name=
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
||||||
|
fmt.Sprintf("%s/api/v2.0/projects?name=%s", c.baseURL, name), nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("harbor: build GET request: %w", err)
|
||||||
|
}
|
||||||
|
req.SetBasicAuth(c.user, c.password)
|
||||||
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("harbor: GET project %q: %w", name, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusUnauthorized {
|
||||||
|
return fmt.Errorf("harbor: unauthorized — проверьте HARBOR_USER и HARBOR_PASS")
|
||||||
|
}
|
||||||
|
|
||||||
|
var projects []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&projects); err != nil {
|
||||||
|
return fmt.Errorf("harbor: decode GET response: %w", err)
|
||||||
|
}
|
||||||
|
for _, p := range projects {
|
||||||
|
if p.Name == name {
|
||||||
|
return nil // проект уже существует
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проект не найден — создаём
|
||||||
|
body, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"project_name": name,
|
||||||
|
"public": false,
|
||||||
|
})
|
||||||
|
req, err = http.NewRequestWithContext(ctx, http.MethodPost,
|
||||||
|
c.baseURL+"/api/v2.0/projects", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("harbor: build POST request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.SetBasicAuth(c.user, c.password)
|
||||||
|
|
||||||
|
resp2, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("harbor: POST project %q: %w", name, err)
|
||||||
|
}
|
||||||
|
defer resp2.Body.Close()
|
||||||
|
|
||||||
|
// 201 Created — успешно; 409 Conflict — гонка (кто-то создал параллельно), оба ок
|
||||||
|
if resp2.StatusCode != http.StatusCreated && resp2.StatusCode != http.StatusConflict {
|
||||||
|
return fmt.Errorf("harbor: POST project %q: unexpected status %d", name, resp2.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ import (
|
|||||||
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/api/handler"
|
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/api/handler"
|
||||||
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/builder"
|
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/builder"
|
||||||
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/config"
|
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/config"
|
||||||
|
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/harbor"
|
||||||
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/storage/postgres"
|
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/storage/postgres"
|
||||||
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/storage/s3"
|
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/storage/s3"
|
||||||
//+kubebuilder:scaffold:imports
|
//+kubebuilder:scaffold:imports
|
||||||
@@ -118,6 +119,15 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Builder использует k8s client из manager'а
|
// Builder использует k8s client из manager'а
|
||||||
|
// harborClient — nil если HARBOR_USER/HARBOR_PASS не заданы (EnsureProject пропускается).
|
||||||
|
var harborClient *harbor.Client
|
||||||
|
if cfg.HarborUser != "" && cfg.HarborPass != "" {
|
||||||
|
harborClient = harbor.New(cfg.RegistryHost, cfg.HarborUser, cfg.HarborPass)
|
||||||
|
log.Info("harbor client configured", "host", cfg.RegistryHost)
|
||||||
|
} else {
|
||||||
|
log.Info("harbor client disabled (HARBOR_USER/HARBOR_PASS not set)")
|
||||||
|
}
|
||||||
|
|
||||||
bldr := builder.New(mgr.GetClient(), builder.Config{
|
bldr := builder.New(mgr.GetClient(), builder.Config{
|
||||||
BuilderImage: cfg.BuilderImage,
|
BuilderImage: cfg.BuilderImage,
|
||||||
RegistryHost: cfg.RegistryHost,
|
RegistryHost: cfg.RegistryHost,
|
||||||
@@ -127,14 +137,17 @@ func main() {
|
|||||||
S3SecretKey: cfg.S3SecretKey,
|
S3SecretKey: cfg.S3SecretKey,
|
||||||
S3Bucket: cfg.S3Bucket,
|
S3Bucket: cfg.S3Bucket,
|
||||||
Namespace: "sless",
|
Namespace: "sless",
|
||||||
|
HarborClient: harborClient,
|
||||||
})
|
})
|
||||||
|
|
||||||
if err = (&controllers.FunctionReconciler{
|
if err = (&controllers.FunctionReconciler{
|
||||||
Client: mgr.GetClient(),
|
Client: mgr.GetClient(),
|
||||||
Scheme: mgr.GetScheme(),
|
Scheme: mgr.GetScheme(),
|
||||||
Builder: bldr,
|
Builder: bldr,
|
||||||
|
KubeClient: kubernetes.NewForConfigOrDie(mgr.GetConfig()),
|
||||||
RegistrySecret: cfg.RegistrySecret,
|
RegistrySecret: cfg.RegistrySecret,
|
||||||
OperatorNamespace: "sless",
|
OperatorNamespace: "sless",
|
||||||
|
HarborClient: harborClient,
|
||||||
}).SetupWithManager(mgr); err != nil {
|
}).SetupWithManager(mgr); err != nil {
|
||||||
log.Error("unable to create controller", "controller", "Function", "err", err)
|
log.Error("unable to create controller", "controller", "Function", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Создано: 2026-03-11
|
||||||
|
# Base builder image для Go 1.23 serverless функций.
|
||||||
|
# Это BUILDER-образ (не runtime): содержит Go компилятор + server.go.
|
||||||
|
# server.go — HTTP-wrapper + job-runner, компилируется ВМЕСТЕ с кодом пользователя.
|
||||||
|
#
|
||||||
|
# kaniko делает multi-stage build:
|
||||||
|
# Stage 1: golang:1.23-alpine → COPY server.go + handler/ → go build → /server
|
||||||
|
# Stage 2: FROM alpine → COPY /server → минимальный финальный образ.
|
||||||
|
#
|
||||||
|
# Почему builder-образ, а не runtime: Go требует статической компиляции — нельзя
|
||||||
|
# "подключить" пользовательский код динамически как в Python/Node.
|
||||||
|
|
||||||
|
FROM golang:1.23-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# server.go — точка входа (main package), вызывает Handler() из пользовательского кода.
|
||||||
|
COPY server.go /app/server.go
|
||||||
|
|
||||||
|
# Код пользователя kaniko копирует в /app/handler/
|
||||||
|
COPY handler/ /app/handler/
|
||||||
|
|
||||||
|
# Статическая сборка (без libc) — работает в alpine-контейнере
|
||||||
|
RUN CGO_ENABLED=0 go build -o /server /app/server.go
|
||||||
|
|
||||||
|
# Финальный минимальный образ
|
||||||
|
FROM alpine:3.20
|
||||||
|
|
||||||
|
COPY --from=builder /server /server
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
CMD ["/server"]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module sless/fn
|
||||||
|
|
||||||
|
go 1.23
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// Создано: 2026-03-11
|
||||||
|
// HTTP-обёртка для serverless функций на Go 1.23.
|
||||||
|
// Компилируется kaniko ВМЕСТЕ с пользовательским кодом (package handler).
|
||||||
|
//
|
||||||
|
// Интерфейс пользователя — файл handler.go:
|
||||||
|
//
|
||||||
|
// package handler
|
||||||
|
//
|
||||||
|
// func Handle(event map[string]interface{}) interface{} {
|
||||||
|
// return map[string]interface{}{"hello": event["name"]}
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// SLESS_MODE=job: разово вызвать Handle(event) → вывести JSON → выйти (для FunctionJob).
|
||||||
|
// По умолчанию: HTTP-сервер, каждый запрос → Handle(event) → JSON ответ.
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"sless/fn/handler"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
mode := os.Getenv("SLESS_MODE")
|
||||||
|
|
||||||
|
// job-runner: разово вызвать Handle(event), вывести результат в JSON и выйти.
|
||||||
|
if mode == "job" {
|
||||||
|
eventJSON := os.Getenv("SLESS_EVENT")
|
||||||
|
if eventJSON == "" {
|
||||||
|
eventJSON = "{}"
|
||||||
|
}
|
||||||
|
var event map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(eventJSON), &event); err != nil {
|
||||||
|
event = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
result := handler.Handle(event)
|
||||||
|
out, _ := json.Marshal(result)
|
||||||
|
fmt.Println(string(out))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP-сервер
|
||||||
|
port := "8080"
|
||||||
|
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, `{"status":"ok"}`)
|
||||||
|
})
|
||||||
|
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
event := map[string]interface{}{}
|
||||||
|
if r.Method == http.MethodPost || r.Method == http.MethodPut {
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err == nil && len(body) > 0 {
|
||||||
|
_ = json.Unmarshal(body, &event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
event["_path"] = r.URL.Path
|
||||||
|
event["_method"] = r.Method
|
||||||
|
if q := r.URL.Query(); len(q) > 0 {
|
||||||
|
qmap := map[string]interface{}{}
|
||||||
|
for k, v := range q {
|
||||||
|
if len(v) == 1 {
|
||||||
|
qmap[k] = v[0]
|
||||||
|
} else {
|
||||||
|
qmap[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
event["_query"] = qmap
|
||||||
|
}
|
||||||
|
result := handler.Handle(event)
|
||||||
|
out, err := json.Marshal(result)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"marshal failed"}`, 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write(out)
|
||||||
|
})
|
||||||
|
log.Printf("sless runtime (go1.23) listening on :%s", port)
|
||||||
|
log.Fatal(http.ListenAndServe(":"+port, nil))
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# Изменено: 2026-03-09
|
# Изменено: 2026-03-11
|
||||||
# HTTP-обёртка для serverless функций на Python 3.11.
|
# HTTP-обёртка для serverless функций на Python 3.11.
|
||||||
# Загружает модуль из SLESS_ENTRYPOINT или handler.py по умолчанию.
|
# Загружает модуль из SLESS_ENTRYPOINT или handler.py по умолчанию.
|
||||||
# Формат SLESS_ENTRYPOINT: "module_name.func_name" (например: handler.handle)
|
# Формат SLESS_ENTRYPOINT: "module_name.func_name" (например: handler.handle)
|
||||||
@@ -65,17 +65,49 @@ class FunctionHandler(BaseHTTPRequestHandler):
|
|||||||
event = self._parse_request_meta({})
|
event = self._parse_request_meta({})
|
||||||
self._respond(200, _handle(event))
|
self._respond(200, _handle(event))
|
||||||
|
|
||||||
def do_POST(self):
|
def _handle_with_body(self):
|
||||||
|
# Общий обработчик для методов с телом (POST, PUT, PATCH, DELETE и др.)
|
||||||
|
# Читаем тело только если Content-Length > 0, иначе передаём пустой event.
|
||||||
length = int(self.headers.get("Content-Length", 0))
|
length = int(self.headers.get("Content-Length", 0))
|
||||||
body = self.rfile.read(length)
|
body = self.rfile.read(length) if length > 0 else b""
|
||||||
try:
|
try:
|
||||||
event = json.loads(body) if body else {}
|
event = json.loads(body) if body else {}
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
# Если тело не JSON — передаём как строку, не ломаем вызов
|
# Если тело не JSON — передаём как строку, не ломаем вызов
|
||||||
event = {"body": body.decode("utf-8", errors="replace")}
|
event = {"body": body.decode("utf-8", errors="replace")}
|
||||||
event = self._parse_request_meta(event)
|
event = self._parse_request_meta(event)
|
||||||
result = _handle(event)
|
self._respond(200, _handle(event))
|
||||||
self._respond(200, result)
|
|
||||||
|
def do_POST(self):
|
||||||
|
self._handle_with_body()
|
||||||
|
|
||||||
|
# PUT, DELETE, PATCH — передаём в функцию как обычные вызовы.
|
||||||
|
# event['_method'] позволяет функции роутить по методу внутри.
|
||||||
|
do_PUT = _handle_with_body
|
||||||
|
do_DELETE = _handle_with_body
|
||||||
|
do_PATCH = _handle_with_body
|
||||||
|
|
||||||
|
def do_HEAD(self):
|
||||||
|
# HEAD: те же заголовки что и GET, но без тела (HTTP-стандарт).
|
||||||
|
if self.path == "/health":
|
||||||
|
result = {"status": "ok"}
|
||||||
|
else:
|
||||||
|
result = _handle(self._parse_request_meta({}))
|
||||||
|
body = json.dumps(result).encode("utf-8")
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
# Тело не отправляем — семантика HEAD
|
||||||
|
|
||||||
|
def send_error(self, code, message=None, explain=None):
|
||||||
|
# Переопределяем дефолтный HTML-ответ на JSON — пользователь всегда получает JSON.
|
||||||
|
body = json.dumps({"error": message or f"HTTP {code}", "code": code}).encode("utf-8")
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
def _respond(self, status, data):
|
def _respond(self, status, data):
|
||||||
body = json.dumps(data).encode("utf-8")
|
body = json.dumps(data).encode("utf-8")
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ gpg --batch --yes --detach-sign --default-key "$key_id" \
|
|||||||
"${BUILD_DIR}/terraform-provider-sless_${VERSION}_SHA256SUMS"
|
"${BUILD_DIR}/terraform-provider-sless_${VERSION}_SHA256SUMS"
|
||||||
|
|
||||||
echo " Загружаем в S3..."
|
echo " Загружаем в S3..."
|
||||||
# Алиас mc включается в путь: <alias>/<bucket>/<path>
|
# mc = MinIO Client (/usr/local/bin/mc); Midnight Commander переименован в midn
|
||||||
mc alias set sless-registry "$s3_url" "$S3_ACCESS_KEY" "$S3_SECRET_KEY" --api S3v4 >/dev/null
|
mc alias set sless-registry "$s3_url" "$S3_ACCESS_KEY" "$S3_SECRET_KEY" --api S3v4 >/dev/null
|
||||||
S3_PATH="sless-registry/${S3_BUCKET}/${REGISTRY_HOSTNAME}/${NAMESPACE}/${PROVIDER_NAME}/${VERSION}/"
|
S3_PATH="sless-registry/${S3_BUCKET}/${REGISTRY_HOSTNAME}/${NAMESPACE}/${PROVIDER_NAME}/${VERSION}/"
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"terraform-provider-sless/internal/client"
|
"terraform-provider-sless/internal/client"
|
||||||
@@ -45,6 +46,7 @@ type SlessProviderModel struct {
|
|||||||
Endpoint types.String `tfsdk:"endpoint"`
|
Endpoint types.String `tfsdk:"endpoint"`
|
||||||
Token types.String `tfsdk:"token"`
|
Token types.String `tfsdk:"token"`
|
||||||
NubesEndpoint types.String `tfsdk:"nubes_endpoint"`
|
NubesEndpoint types.String `tfsdk:"nubes_endpoint"`
|
||||||
|
AIHintLevel types.Int64 `tfsdk:"ai_hint_level"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// New возвращает фабрику провайдера — точная копия паттерна nubes.
|
// New возвращает фабрику провайдера — точная копия паттерна nubes.
|
||||||
@@ -75,6 +77,10 @@ func (p *SlessProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp
|
|||||||
MarkdownDescription: "URL nubes API для валидации токена (env: NUBES_ENDPOINT). Опционально. Пример: https://deck-api.ngcloud.ru/api/v1",
|
MarkdownDescription: "URL nubes API для валидации токена (env: NUBES_ENDPOINT). Опционально. Пример: https://deck-api.ngcloud.ru/api/v1",
|
||||||
Optional: true,
|
Optional: true,
|
||||||
},
|
},
|
||||||
|
"ai_hint_level": schema.Int64Attribute{
|
||||||
|
MarkdownDescription: "Уровень AI-анализа terraform plan: 0=off, 1=syntax, 2=basic, 3=moderate, 4=detailed, 5=full. По умолчанию 0 (выключен).",
|
||||||
|
Optional: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,6 +166,20 @@ func (p *SlessProvider) Configure(ctx context.Context, req provider.ConfigureReq
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 7. Экспортируем ai_hint_level в env AI_HINT_LEVEL для sless-plan ---
|
||||||
|
// sless-plan читает AI_HINT_LEVEL при запуске — так уровень из provider "sless" подхватывается автоматически.
|
||||||
|
// Если атрибут не задан — уровень 0 (AI выключен), env не выставляется.
|
||||||
|
if !config.AIHintLevel.IsNull() {
|
||||||
|
level := int(config.AIHintLevel.ValueInt64())
|
||||||
|
if level < 0 {
|
||||||
|
level = 0
|
||||||
|
}
|
||||||
|
if level > 5 {
|
||||||
|
level = 5
|
||||||
|
}
|
||||||
|
os.Setenv("AI_HINT_LEVEL", strconv.Itoa(level))
|
||||||
|
}
|
||||||
|
|
||||||
resp.ResourceData = c
|
resp.ResourceData = c
|
||||||
resp.DataSourceData = c
|
resp.DataSourceData = c
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
//
|
//
|
||||||
// Lifecycle:
|
// Lifecycle:
|
||||||
//
|
//
|
||||||
// Create: POST /functions → если code_path задан: upload zip → WaitReady (5 мин)
|
// Create: POST /functions → если code_path задан: upload zip → WaitReady (5 мин)
|
||||||
// Read: GET /functions/{name} → sync state
|
// Read: GET /functions/{name} → sync state
|
||||||
// Update: PUT /functions/{name} → если code_hash изменился: upload zip → WaitReady
|
// Update: PUT /functions/{name} → если code_hash изменился: upload zip → WaitReady
|
||||||
// Delete: DELETE /functions/{name}
|
// Delete: DELETE /functions/{name}
|
||||||
|
// ModifyPlan: вычисляет hash source_dir на фазе plan → terraform видит diff при изменении кода
|
||||||
//
|
//
|
||||||
// code_hash — пользователь задаёт сам (например filemd5("./handler.zip")).
|
// code_hash — пользователь задаёт сам (например filemd5("./handler.zip")).
|
||||||
// Изменение hash → провайдер перезагружает zip и ждёт новой сборки.
|
// Изменение hash → провайдер перезагружает zip и ждёт новой сборки.
|
||||||
@@ -47,6 +48,7 @@ import (
|
|||||||
const defaultBuildTimeoutSec = 300
|
const defaultBuildTimeoutSec = 300
|
||||||
|
|
||||||
var _ resource.Resource = &FunctionResource{}
|
var _ resource.Resource = &FunctionResource{}
|
||||||
|
var _ resource.ResourceWithModifyPlan = &FunctionResource{}
|
||||||
|
|
||||||
type FunctionResource struct {
|
type FunctionResource struct {
|
||||||
client *client.Client
|
client *client.Client
|
||||||
@@ -97,7 +99,7 @@ func (r *FunctionResource) Schema(_ context.Context, _ resource.SchemaRequest, r
|
|||||||
stringplanmodifier.RequiresReplace(),
|
stringplanmodifier.RequiresReplace(),
|
||||||
},
|
},
|
||||||
Validators: []validator.String{
|
Validators: []validator.String{
|
||||||
stringvalidator.OneOf("nodejs20", "python3.11", "go1.21"),
|
stringvalidator.OneOf("nodejs20", "python3.11", "go1.23"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"entrypoint": schema.StringAttribute{
|
"entrypoint": schema.StringAttribute{
|
||||||
@@ -344,6 +346,34 @@ func (r *FunctionResource) Delete(ctx context.Context, req resource.DeleteReques
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ModifyPlan вычисляет hash директории source_dir на фазе terraform plan.
|
||||||
|
// Без этого terraform не видит изменения файлов кода между apply — потому что
|
||||||
|
// code_hash Computed и при plan берётся из state. Здесь мы явно пересчитываем
|
||||||
|
// и записываем актуальный hash в plan → если отличается от state, terraform
|
||||||
|
// показывает diff и вызывает Update.
|
||||||
|
func (r *FunctionResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) {
|
||||||
|
// При destroy plan пустой — ничего не делаем
|
||||||
|
if req.Plan.Raw.IsNull() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var plan FunctionModel
|
||||||
|
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// source_dir не задан — нечего считать
|
||||||
|
if plan.SourceDir.IsNull() || plan.SourceDir.ValueString() == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, hash, err := zipDir(plan.SourceDir.ValueString())
|
||||||
|
if err != nil {
|
||||||
|
// Директория может не существовать при первом init — не фейлим plan
|
||||||
|
return
|
||||||
|
}
|
||||||
|
plan.CodeHash = types.StringValue(hash)
|
||||||
|
resp.Diagnostics.Append(resp.Plan.Set(ctx, plan)...)
|
||||||
|
}
|
||||||
|
|
||||||
// fnToModel конвертирует API-ответ + plan (для локальных полей) → state модель.
|
// fnToModel конвертирует API-ответ + plan (для локальных полей) → state модель.
|
||||||
// plan используется для code_path, code_hash, build_timeout_sec — API их не хранит.
|
// plan используется для code_path, code_hash, build_timeout_sec — API их не хранит.
|
||||||
func fnToModel(plan FunctionModel, fn *client.FunctionResponse) FunctionModel {
|
func fnToModel(plan FunctionModel, fn *client.FunctionResponse) FunctionModel {
|
||||||
|
|||||||
Reference in New Issue
Block a user