Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6960e87a5c | ||
|
|
11b484e1e7 | ||
|
|
ef7613d09f | ||
|
|
d9ed96e201 | ||
|
|
582b589e38 | ||
|
|
d5ce8c2fac | ||
|
|
a7fc960c82 | ||
|
|
cea13cf239 | ||
|
|
824dad7969 |
@@ -46,20 +46,6 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Именование
|
|
||||||
|
|
||||||
Имена должны быть **уникальными и осмысленными по всему проекту**:
|
|
||||||
- имена файлов
|
|
||||||
- имена функций/методов
|
|
||||||
- имена переменных/констант
|
|
||||||
- имена ресурсов (Terraform, Kubernetes и т.д.)
|
|
||||||
|
|
||||||
Цель: чтобы поиск по проекту находил нужные сущности без неоднозначности, а имя сразу отражало назначение.
|
|
||||||
|
|
||||||
Запрещены безликие и повторяющиеся имена вида `handler.py`, `handle`, `data`, `value`, `temp` без контекста.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Git
|
## Git
|
||||||
|
|
||||||
Коммитить и пушить после каждого завершённого этапа.
|
Коммитить и пушить после каждого завершённого этапа.
|
||||||
|
|||||||
@@ -43,9 +43,6 @@ examples/*/dist/
|
|||||||
**/handler.zip
|
**/handler.zip
|
||||||
test.token
|
test.token
|
||||||
*.tfvars
|
*.tfvars
|
||||||
*.tfplan
|
|
||||||
plan.out
|
|
||||||
sless-plan
|
|
||||||
.e2e-logs/
|
.e2e-logs/
|
||||||
.stress-logs/
|
.stress-logs/
|
||||||
|
|
||||||
@@ -60,7 +57,4 @@ sless-plan
|
|||||||
**/dist/
|
**/dist/
|
||||||
# дополнительные вариации переменных/файлов конфигурации
|
# дополнительные вариации переменных/файлов конфигурации
|
||||||
*.tfvars.json
|
*.tfvars.json
|
||||||
*.tfplan
|
|
||||||
plan.out
|
|
||||||
sless-plan
|
|
||||||
examples/.git
|
examples/.git
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
// cmd/sless-plan/main.go
|
||||||
|
// Дата: 2026-03-12
|
||||||
|
// CLI-обёртка над `terraform plan`.
|
||||||
|
// Добавляет AI-анализ вывода если AI_HINT_LEVEL > 0.
|
||||||
|
// Существующий terraform plan не меняется — AI только дополняет вывод.
|
||||||
|
//
|
||||||
|
// Использование:
|
||||||
|
// sless-plan [terraform args...]
|
||||||
|
// sless-plan --ai-level=3 [terraform args...]
|
||||||
|
//
|
||||||
|
// Env-переменные:
|
||||||
|
// AI_HINT_LEVEL — уровень анализа 0–5 (0 = выключен, по умолчанию)
|
||||||
|
// AI_PROVIDER — провайдер: "groq" (по умолчанию), "google", "cloud"
|
||||||
|
// GROQ_API_KEY — API ключ Groq (рекомендуется, работает из РФ)
|
||||||
|
// GROQ_MODEL — модель Groq (по умолчанию "llama-3.3-70b-versatile")
|
||||||
|
// GROQ_ENDPOINT — endpoint (по умолчанию https://api.groq.com/openai/v1/chat/completions)
|
||||||
|
// GOOGLE_AI_API_KEY — API ключ Google Gemini (геоблок в РФ)
|
||||||
|
// GOOGLE_AI_MODEL — модель Gemini (по умолчанию "gemini-2.0-flash")
|
||||||
|
// CLOUD_LLM_ENDPOINT — endpoint будущего облачного LLM
|
||||||
|
// CLOUD_LLM_TOKEN — токен для облачного LLM
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/ai"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Флаг --ai-level переопределяет env AI_HINT_LEVEL
|
||||||
|
aiLevel := flag.Int("ai-level", -1, "уровень AI-анализа 0–5 (-1 = читать из AI_HINT_LEVEL)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
// Определяем уровень: флаг > env > 0
|
||||||
|
level := resolveLevel(*aiLevel)
|
||||||
|
|
||||||
|
// Собираем аргументы для terraform plan (всё что после флагов sless-plan)
|
||||||
|
tfArgs := append([]string{"plan"}, flag.Args()...)
|
||||||
|
|
||||||
|
// Запускаем terraform plan, захватывая вывод для AI
|
||||||
|
// но одновременно выводя его пользователю (tee-эффект)
|
||||||
|
planOutput, exitCode := runTerraform(tfArgs)
|
||||||
|
|
||||||
|
// Если уровень > 0 — запускаем AI анализ
|
||||||
|
if level > 0 {
|
||||||
|
runAIAnalysis(level, planOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Exit(exitCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveLevel определяет уровень AI анализа.
|
||||||
|
// Приоритет: --ai-level flag > AI_HINT_LEVEL env > 0
|
||||||
|
func resolveLevel(flagValue int) int {
|
||||||
|
if flagValue >= 0 {
|
||||||
|
// Флаг явно задан
|
||||||
|
if flagValue > 5 {
|
||||||
|
flagValue = 5
|
||||||
|
}
|
||||||
|
return flagValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Читаем из env
|
||||||
|
if envVal := os.Getenv("AI_HINT_LEVEL"); envVal != "" {
|
||||||
|
level, err := strconv.Atoi(envVal)
|
||||||
|
if err == nil && level >= 0 && level <= 5 {
|
||||||
|
return level
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TF_VAR_ai_hint_level тоже поддерживаем
|
||||||
|
if envVal := os.Getenv("TF_VAR_ai_hint_level"); envVal != "" {
|
||||||
|
level, err := strconv.Atoi(envVal)
|
||||||
|
if err == nil && level >= 0 && level <= 5 {
|
||||||
|
return level
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0 // по умолчанию — AI выключен
|
||||||
|
}
|
||||||
|
|
||||||
|
// runTerraform запускает terraform с переданными аргументами.
|
||||||
|
// Вывод идёт напрямую в stdout/stderr пользователя И захватывается для AI.
|
||||||
|
// Возвращает текст вывода и код завершения.
|
||||||
|
func runTerraform(args []string) (string, int) {
|
||||||
|
cmd := exec.Command("terraform", args...)
|
||||||
|
|
||||||
|
// Захватываем stdout для AI, но также пишем напрямую в os.Stdout
|
||||||
|
var buf bytes.Buffer
|
||||||
|
cmd.Stdout = io.MultiWriter(os.Stdout, &buf)
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
cmd.Stdin = os.Stdin
|
||||||
|
|
||||||
|
err := cmd.Run()
|
||||||
|
if err != nil {
|
||||||
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||||
|
return buf.String(), exitErr.ExitCode()
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "ошибка запуска terraform: %v\n", err)
|
||||||
|
return buf.String(), 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.String(), 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// runAIAnalysis запускает AI анализ и выводит результат.
|
||||||
|
// Не прерывает работу если AI недоступен — только показывает предупреждение.
|
||||||
|
func runAIAnalysis(level int, planOutput string) {
|
||||||
|
cfg := ai.Config{
|
||||||
|
HintLevel: level,
|
||||||
|
Provider: getEnv("AI_PROVIDER", "groq"),
|
||||||
|
GroqAPIKey: os.Getenv("GROQ_API_KEY"),
|
||||||
|
GroqModel: getEnv("GROQ_MODEL", "llama-3.3-70b-versatile"),
|
||||||
|
GroqEndpoint: os.Getenv("GROQ_ENDPOINT"),
|
||||||
|
GoogleAPIKey: os.Getenv("GOOGLE_AI_API_KEY"),
|
||||||
|
GoogleModel: getEnv("GOOGLE_AI_MODEL", "gemini-2.0-flash"),
|
||||||
|
CloudEndpoint: os.Getenv("CLOUD_LLM_ENDPOINT"),
|
||||||
|
CloudToken: os.Getenv("CLOUD_LLM_TOKEN"),
|
||||||
|
}
|
||||||
|
|
||||||
|
analyzer, err := ai.NewAnalyzer(cfg)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "\n⚠ AI анализ недоступен: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
result, err := analyzer.Analyze(ctx, planOutput)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "\n⚠ AI анализ завершился с ошибкой: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if result != "" {
|
||||||
|
fmt.Print(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getEnv возвращает значение env-переменной или defaultVal если не задана.
|
||||||
|
func getEnv(key, defaultVal string) string {
|
||||||
|
if val := os.Getenv(key); val != "" {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
return defaultVal
|
||||||
|
}
|
||||||
@@ -20,13 +20,6 @@ rules:
|
|||||||
- get
|
- get
|
||||||
- list
|
- list
|
||||||
- watch
|
- watch
|
||||||
- apiGroups:
|
|
||||||
- ""
|
|
||||||
resources:
|
|
||||||
- secrets
|
|
||||||
verbs:
|
|
||||||
- create
|
|
||||||
- get
|
|
||||||
- apiGroups:
|
- apiGroups:
|
||||||
- ""
|
- ""
|
||||||
resources:
|
resources:
|
||||||
|
|||||||
@@ -1,107 +0,0 @@
|
|||||||
# 2026-03-18
|
|
||||||
# funcs-service.yaml — глобальный сервис листинга функций для всех пользователей.
|
|
||||||
# Развёртывается ОДИН РАЗ в namespace sless рядом с оператором.
|
|
||||||
# Доступен по: https://sless.kube5s.ru/funcs (с Bearer токеном пользователя)
|
|
||||||
#
|
|
||||||
# Обновить образ и применить:
|
|
||||||
# docker push naeel/sless-funcs-service:v0.1.0
|
|
||||||
# kubectl apply -f deployments/k8s/funcs-service.yaml
|
|
||||||
|
|
||||||
---
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: sless-funcs-service
|
|
||||||
namespace: sless
|
|
||||||
labels:
|
|
||||||
app: sless-funcs-service
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: sless-funcs-service
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: sless-funcs-service
|
|
||||||
spec:
|
|
||||||
containers:
|
|
||||||
- name: funcs
|
|
||||||
image: naeel/sless-funcs-service:v0.1.3
|
|
||||||
ports:
|
|
||||||
- containerPort: 8090
|
|
||||||
env:
|
|
||||||
- name: SLESS_OPERATOR_URL
|
|
||||||
value: "http://sless-operator.sless.svc.cluster.local:9090"
|
|
||||||
- name: SLESS_EXTERNAL_URL
|
|
||||||
value: "https://sless.kube5s.ru"
|
|
||||||
# Системные функции, скрытые из листинга
|
|
||||||
- name: SLESS_EXCLUDE
|
|
||||||
value: "event-writer,event-monitor,event-cleaner"
|
|
||||||
# Токен сервиса задаётся через kubectl set env или Secret — не хранится в репо
|
|
||||||
# kubectl set env deployment/sless-funcs-service -n sless SLESS_SERVICE_TOKEN="$(cat secrets/test.token)"
|
|
||||||
- name: PORT
|
|
||||||
value: "8090"
|
|
||||||
livenessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /health
|
|
||||||
port: 8090
|
|
||||||
initialDelaySeconds: 5
|
|
||||||
periodSeconds: 30
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /health
|
|
||||||
port: 8090
|
|
||||||
initialDelaySeconds: 3
|
|
||||||
periodSeconds: 10
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
cpu: 10m
|
|
||||||
memory: 16Mi
|
|
||||||
limits:
|
|
||||||
cpu: 100m
|
|
||||||
memory: 64Mi
|
|
||||||
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: sless-funcs-service
|
|
||||||
namespace: sless
|
|
||||||
spec:
|
|
||||||
selector:
|
|
||||||
app: sless-funcs-service
|
|
||||||
ports:
|
|
||||||
- port: 8090
|
|
||||||
targetPort: 8090
|
|
||||||
|
|
||||||
---
|
|
||||||
# Отдельный Ingress для /funcs — nginx выбирает более специфичный путь перед /
|
|
||||||
# Без rewrite: сервис сам обрабатывает /funcs path
|
|
||||||
# TLS-сертификат sless-operator-tls уже управляется cert-manager через ingress оператора;
|
|
||||||
# здесь только ссылаемся на существующий секрет без аннотации cert-manager.io/cluster-issuer.
|
|
||||||
apiVersion: networking.k8s.io/v1
|
|
||||||
kind: Ingress
|
|
||||||
metadata:
|
|
||||||
name: sless-funcs-ingress
|
|
||||||
namespace: sless
|
|
||||||
annotations:
|
|
||||||
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
|
|
||||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
|
||||||
spec:
|
|
||||||
ingressClassName: nginx
|
|
||||||
rules:
|
|
||||||
- host: sless.kube5s.ru
|
|
||||||
http:
|
|
||||||
paths:
|
|
||||||
- path: /funcs
|
|
||||||
pathType: Prefix
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: sless-funcs-service
|
|
||||||
port:
|
|
||||||
number: 8090
|
|
||||||
tls:
|
|
||||||
- hosts:
|
|
||||||
- sless.kube5s.ru
|
|
||||||
secretName: sless-operator-tls
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
# Изменено: 2026-03-14
|
|
||||||
# Node-RED для визуального управления demo сценарием Event Log.
|
|
||||||
# Образ: nodered/node-red:4 (официальный, всегда есть на DockerHub)
|
|
||||||
# UI доступен по http://<ingress-ip>/nodered/
|
|
||||||
# Для AMQP нужен плагин node-red-contrib-amqp — ставится через initContainer.
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: PersistentVolumeClaim
|
|
||||||
metadata:
|
|
||||||
name: nodered-data
|
|
||||||
namespace: sless
|
|
||||||
spec:
|
|
||||||
accessModes:
|
|
||||||
- ReadWriteOnce
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
storage: 1Gi
|
|
||||||
---
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: nodered
|
|
||||||
namespace: sless
|
|
||||||
labels:
|
|
||||||
app: nodered
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: nodered
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: nodered
|
|
||||||
spec:
|
|
||||||
securityContext:
|
|
||||||
fsGroup: 1000
|
|
||||||
# initContainer устанавливает AMQP-плагин в PVC до старта основного контейнера
|
|
||||||
initContainers:
|
|
||||||
- name: install-nodes
|
|
||||||
image: nodered/node-red:latest
|
|
||||||
securityContext:
|
|
||||||
runAsUser: 0
|
|
||||||
runAsGroup: 0
|
|
||||||
command:
|
|
||||||
- sh
|
|
||||||
- -c
|
|
||||||
- |
|
|
||||||
chown -R 1000:1000 /data || true
|
|
||||||
cd /data
|
|
||||||
npm install --prefix /data node-red-contrib-amqp node-red-dashboard 2>&1 || true
|
|
||||||
volumeMounts:
|
|
||||||
- name: data
|
|
||||||
mountPath: /data
|
|
||||||
containers:
|
|
||||||
- name: nodered
|
|
||||||
image: nodered/node-red:latest
|
|
||||||
ports:
|
|
||||||
- containerPort: 1880
|
|
||||||
env:
|
|
||||||
- name: NODE_RED_ENABLE_PROJECTS
|
|
||||||
value: "false"
|
|
||||||
- name: TZ
|
|
||||||
value: "Europe/Moscow"
|
|
||||||
securityContext:
|
|
||||||
runAsUser: 1000
|
|
||||||
runAsGroup: 1000
|
|
||||||
volumeMounts:
|
|
||||||
- name: data
|
|
||||||
mountPath: /data
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
memory: "256Mi"
|
|
||||||
cpu: "100m"
|
|
||||||
limits:
|
|
||||||
memory: "512Mi"
|
|
||||||
cpu: "500m"
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /
|
|
||||||
port: 1880
|
|
||||||
initialDelaySeconds: 15
|
|
||||||
periodSeconds: 10
|
|
||||||
volumes:
|
|
||||||
- name: data
|
|
||||||
persistentVolumeClaim:
|
|
||||||
claimName: nodered-data
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: nodered
|
|
||||||
namespace: sless
|
|
||||||
spec:
|
|
||||||
selector:
|
|
||||||
app: nodered
|
|
||||||
ports:
|
|
||||||
- port: 1880
|
|
||||||
targetPort: 1880
|
|
||||||
---
|
|
||||||
# Ingress на Node-RED — доступен снаружи по http://nodered.185.247.187.147.nip.io
|
|
||||||
apiVersion: networking.k8s.io/v1
|
|
||||||
kind: Ingress
|
|
||||||
metadata:
|
|
||||||
name: nodered
|
|
||||||
namespace: sless
|
|
||||||
annotations:
|
|
||||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
|
|
||||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
|
|
||||||
spec:
|
|
||||||
ingressClassName: nginx
|
|
||||||
rules:
|
|
||||||
- host: nodered.185.247.187.147.nip.io
|
|
||||||
http:
|
|
||||||
paths:
|
|
||||||
- path: /
|
|
||||||
pathType: Prefix
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: nodered
|
|
||||||
port:
|
|
||||||
number: 1880
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
# Изменено: 2026-03-14
|
|
||||||
# Деплой sless оператора на demo-стенде (naeel-test-3, nip.io, без TLS).
|
|
||||||
# Отличия от production operator.yaml:
|
|
||||||
# - REGISTRY_HOST=naeel (DockerHub, не Harbor)
|
|
||||||
# - INGRESS_HOST и EXTERNAL_URL — через nip.io без TLS
|
|
||||||
# - cert-manager аннотации убраны
|
|
||||||
# - API_TOKEN берётся из sless-operator-secret (совпадает с secrets/test.token)
|
|
||||||
#
|
|
||||||
# Перед apply нужно создать секреты:
|
|
||||||
# kubectl create secret generic sless-operator-secret -n sless \
|
|
||||||
# --from-literal=POSTGRES_DSN="postgres://sless:sless-pg-password@postgres.sless.svc.cluster.local:5432/sless?sslmode=disable" \
|
|
||||||
# --from-literal=S3_ACCESS_KEY="0GLQRD38H4I6RBDB0EWJ" \
|
|
||||||
# --from-literal=S3_SECRET_KEY="eTFibiHmBd96IApj9PYsboTR6OBoD7osxoarHykw" \
|
|
||||||
# --from-literal=SLESS_API_TOKEN="<token from secrets/test.token>" \
|
|
||||||
# --from-literal=HARBOR_PASS=""
|
|
||||||
#
|
|
||||||
# kubectl create secret docker-registry sless-registry-auth -n sless \
|
|
||||||
# --docker-server=https://index.docker.io/v1/ \
|
|
||||||
# --docker-username=naeel \
|
|
||||||
# --docker-password=<DOCKERHUB_TOKEN>
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: ConfigMap
|
|
||||||
metadata:
|
|
||||||
name: sless-operator-config
|
|
||||||
namespace: sless
|
|
||||||
data:
|
|
||||||
S3_ENDPOINT: "s3.msk-1.ngcloud.ru"
|
|
||||||
S3_BUCKET: "sless-functions"
|
|
||||||
S3_USE_SSL: "true"
|
|
||||||
# Harbor как registry для demo
|
|
||||||
REGISTRY_HOST: "pearlharbor.registryk8s.services.ngcloud.ru"
|
|
||||||
REGISTRY_SECRET: "sless-registry-auth"
|
|
||||||
HARBOR_USER: "admin"
|
|
||||||
API_PORT: "9090"
|
|
||||||
# nip.io домен без TLS — работает без настройки DNS
|
|
||||||
INGRESS_HOST: "fn.185.247.187.147.nip.io"
|
|
||||||
EXTERNAL_URL: "http://sless-api.185.247.187.147.nip.io"
|
|
||||||
---
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: sless-operator
|
|
||||||
namespace: sless
|
|
||||||
labels:
|
|
||||||
app: sless-operator
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: sless-operator
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: sless-operator
|
|
||||||
spec:
|
|
||||||
serviceAccountName: sless-operator
|
|
||||||
containers:
|
|
||||||
- name: operator
|
|
||||||
image: naeel/sless-operator:v0.1.29
|
|
||||||
imagePullPolicy: Always
|
|
||||||
ports:
|
|
||||||
- name: api
|
|
||||||
containerPort: 9090
|
|
||||||
- name: metrics
|
|
||||||
containerPort: 8080
|
|
||||||
- name: health
|
|
||||||
containerPort: 8081
|
|
||||||
envFrom:
|
|
||||||
- configMapRef:
|
|
||||||
name: sless-operator-config
|
|
||||||
- secretRef:
|
|
||||||
name: sless-operator-secret
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /healthz
|
|
||||||
port: 8081
|
|
||||||
initialDelaySeconds: 5
|
|
||||||
periodSeconds: 10
|
|
||||||
livenessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /healthz
|
|
||||||
port: 8081
|
|
||||||
initialDelaySeconds: 15
|
|
||||||
periodSeconds: 20
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
memory: "64Mi"
|
|
||||||
cpu: "50m"
|
|
||||||
limits:
|
|
||||||
memory: "256Mi"
|
|
||||||
cpu: "500m"
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: sless-operator
|
|
||||||
namespace: sless
|
|
||||||
spec:
|
|
||||||
selector:
|
|
||||||
app: sless-operator
|
|
||||||
ports:
|
|
||||||
- name: api
|
|
||||||
port: 9090
|
|
||||||
targetPort: 9090
|
|
||||||
---
|
|
||||||
# Ingress без TLS — demo стенд через nip.io
|
|
||||||
apiVersion: networking.k8s.io/v1
|
|
||||||
kind: Ingress
|
|
||||||
metadata:
|
|
||||||
name: sless-operator
|
|
||||||
namespace: sless
|
|
||||||
spec:
|
|
||||||
ingressClassName: nginx
|
|
||||||
rules:
|
|
||||||
- host: sless-api.185.247.187.147.nip.io
|
|
||||||
http:
|
|
||||||
paths:
|
|
||||||
- path: /
|
|
||||||
pathType: Prefix
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: sless-operator
|
|
||||||
port:
|
|
||||||
number: 9090
|
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
# - Secret: секретные данные (S3 keys, postgres DSN, API token, Harbor pass)
|
# - 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.kube5s.ru → :9090 (внешний доступ с TLS)
|
# - Ingress: sless-api.kube5s.ru → :9090 (внешний доступ с TLS)
|
||||||
#
|
#
|
||||||
# Чтобы сменить registry — менять только REGISTRY_HOST в ConfigMap.
|
# Чтобы сменить registry — менять только REGISTRY_HOST в ConfigMap.
|
||||||
# Чтобы сменить Harbor-аккаунт — менять HARBOR_USER в ConfigMap + HARBOR_PASS в Secret.
|
# Чтобы сменить Harbor-аккаунт — менять HARBOR_USER в ConfigMap + HARBOR_PASS в Secret.
|
||||||
@@ -32,7 +32,7 @@ data:
|
|||||||
INGRESS_HOST: "fn.kube5s.ru"
|
INGRESS_HOST: "fn.kube5s.ru"
|
||||||
# EXTERNAL_URL — если задан, URL функции = EXTERNAL_URL/fn/{namespace}/{name}
|
# EXTERNAL_URL — если задан, URL функции = EXTERNAL_URL/fn/{namespace}/{name}
|
||||||
# Позволяет обойтись без wildcard DNS *.fn.kube5s.ru
|
# Позволяет обойтись без wildcard DNS *.fn.kube5s.ru
|
||||||
EXTERNAL_URL: "https://sless.kube5s.ru"
|
EXTERNAL_URL: "https://sless-api.kube5s.ru"
|
||||||
---
|
---
|
||||||
# Secret создаётся отдельно через kubectl (не коммитить секреты в git!)
|
# Secret создаётся отдельно через kubectl (не коммитить секреты в git!)
|
||||||
# Описание ключей:
|
# Описание ключей:
|
||||||
@@ -72,7 +72,7 @@ spec:
|
|||||||
containers:
|
containers:
|
||||||
- name: operator
|
- name: operator
|
||||||
# При обновлении версии оператора — менять тег здесь (не latest!)
|
# При обновлении версии оператора — менять тег здесь (не latest!)
|
||||||
image: naeel/sless-operator:v0.1.32
|
image: naeel/sless-operator:v0.1.28
|
||||||
# Always — чтобы всегда тянуть по точному тегу (не кешировать старый)
|
# Always — чтобы всегда тянуть по точному тегу (не кешировать старый)
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
ports:
|
ports:
|
||||||
@@ -133,7 +133,7 @@ metadata:
|
|||||||
spec:
|
spec:
|
||||||
ingressClassName: nginx
|
ingressClassName: nginx
|
||||||
rules:
|
rules:
|
||||||
- host: sless.kube5s.ru
|
- host: sless-api.kube5s.ru
|
||||||
http:
|
http:
|
||||||
paths:
|
paths:
|
||||||
- path: /
|
- path: /
|
||||||
@@ -145,5 +145,5 @@ spec:
|
|||||||
number: 9090
|
number: 9090
|
||||||
tls:
|
tls:
|
||||||
- hosts:
|
- hosts:
|
||||||
- sless.kube5s.ru
|
- sless-api.kube5s.ru
|
||||||
secretName: sless-operator-tls
|
secretName: sless-operator-tls
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
# Изменено: 2026-03-14
|
|
||||||
# RabbitMQ для demo сценария Event Log.
|
|
||||||
# Используется официальный образ с management-плагином для веб-UI.
|
|
||||||
# credentials: sless / sless123
|
|
||||||
# AMQP: amqp://sless:sless123@rabbitmq.sless.svc.cluster.local:5672/
|
|
||||||
# Management UI: http://rabbitmq.sless.svc.cluster.local:15672/
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Secret
|
|
||||||
metadata:
|
|
||||||
name: rabbitmq-secret
|
|
||||||
namespace: sless
|
|
||||||
stringData:
|
|
||||||
RABBITMQ_DEFAULT_USER: "sless"
|
|
||||||
RABBITMQ_DEFAULT_PASS: "sless123"
|
|
||||||
---
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: rabbitmq
|
|
||||||
namespace: sless
|
|
||||||
labels:
|
|
||||||
app: rabbitmq
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: rabbitmq
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: rabbitmq
|
|
||||||
spec:
|
|
||||||
containers:
|
|
||||||
- name: rabbitmq
|
|
||||||
image: rabbitmq:3.13-management-alpine
|
|
||||||
ports:
|
|
||||||
- name: amqp
|
|
||||||
containerPort: 5672
|
|
||||||
- name: management
|
|
||||||
containerPort: 15672
|
|
||||||
envFrom:
|
|
||||||
- secretRef:
|
|
||||||
name: rabbitmq-secret
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
memory: "256Mi"
|
|
||||||
cpu: "100m"
|
|
||||||
limits:
|
|
||||||
memory: "512Mi"
|
|
||||||
cpu: "500m"
|
|
||||||
readinessProbe:
|
|
||||||
tcpSocket:
|
|
||||||
port: 5672
|
|
||||||
initialDelaySeconds: 40
|
|
||||||
periodSeconds: 10
|
|
||||||
timeoutSeconds: 10
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: rabbitmq
|
|
||||||
namespace: sless
|
|
||||||
spec:
|
|
||||||
selector:
|
|
||||||
app: rabbitmq
|
|
||||||
ports:
|
|
||||||
- name: amqp
|
|
||||||
port: 5672
|
|
||||||
targetPort: 5672
|
|
||||||
- name: management
|
|
||||||
port: 15672
|
|
||||||
targetPort: 15672
|
|
||||||
@@ -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.
|
||||||
+85
-58
@@ -1,63 +1,5 @@
|
|||||||
# Решения и обоснования
|
# Решения и обоснования
|
||||||
|
|
||||||
## 2026-03-18 — Смена sless API endpoint: sless-api.kube5s.ru → sless.kube5s.ru
|
|
||||||
|
|
||||||
**Решение:** Оператор sless доступен по `https://sless.kube5s.ru` (не `sless-api.kube5s.ru`). Все examples, deployments и ConfigMap обновлены.
|
|
||||||
|
|
||||||
**Причина:** При пересоздании кластера DNS-запись `sless-api.kube5s.ru` не была обновлена — она указывала на IP `5.172.178.182` (старый, мёртвый кластер). Ingress нового кластера закреплён на `185.247.187.147`. Отдельная запись `sless.kube5s.ru` уже корректно указывала на `185.247.187.147`.
|
|
||||||
TLS handshake timeout возникал потому что старый IP принимал TCP:443, но не завершал TLS (nginx жив, бэкенд мёртв). Go HTTP клиент ждал системный таймаут (~90s) и повторял бесконечно.
|
|
||||||
|
|
||||||
**Изменения:**
|
|
||||||
- `deployments/k8s/operator.yaml`: `EXTERNAL_URL`, `INGRESS_HOST`, ingress host → `sless.kube5s.ru`
|
|
||||||
- ConfigMap `sless-operator-config` в кластере: `EXTERNAL_URL` обновлён через `kubectl patch`
|
|
||||||
- Ingress `sless-operator` в кластере: host + TLS secret → `sless.kube5s.ru`
|
|
||||||
- Все `examples/**/main.tf`: `endpoint = "https://sless.kube5s.ru"`
|
|
||||||
|
|
||||||
**Правило:** При пересоздании кластера — первым делом проверять соответствие DNS → ingress IP.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2026-03-17 — Разделение prod/test endpoint'ов в examples/
|
|
||||||
|
|
||||||
**Решение:** Все `examples/` ОБЯЗАНЫ использовать `deck-api-test.ngcloud.ru` для обоих провайдеров: `nubes` и `sless` (`nubes_endpoint`). Продовый `deck-api.ngcloud.ru` — только для реальных клиентов.
|
|
||||||
|
|
||||||
**Причина:** Смешивание prod и test endpoint'ов в одном `terraform apply` приводит к тому что ресурсы создаются в разных средах. `sless_function`/`sless_job` могут получать данные (PGHOST, credentials) из прода, а pod запускается в тест-кластере — и не может достучаться до хоста.
|
|
||||||
|
|
||||||
**Правило для `main.tf` в examples:**
|
|
||||||
```hcl
|
|
||||||
provider "nubes" {
|
|
||||||
api_endpoint = "https://deck-api-test.ngcloud.ru/api/v1/index.cfm"
|
|
||||||
}
|
|
||||||
provider "sless" {
|
|
||||||
nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2026-03-17 — terraform apply только на удалённом сервере
|
|
||||||
|
|
||||||
**Решение:** `terraform init/plan/apply/destroy` для `examples/` — исключительно через SSH на сервере `naeel@5.172.178.213`. Локальный запуск запрещён.
|
|
||||||
|
|
||||||
**Причина:**
|
|
||||||
1. Провайдер `terra.k8c.ru/naeel/sless` кэширован только на удалённом сервере
|
|
||||||
2. Локальный terraform не имеет сетевого доступа к k8s кластеру и внутренним кластерным адресам (например PGHOST вида `*.svc.cluster.local`)
|
|
||||||
3. Случайный локальный запуск с prod токенами может затронуть боевую среду
|
|
||||||
|
|
||||||
**Как запускать:**
|
|
||||||
```bash
|
|
||||||
# Сначала синхронизировать изменения:
|
|
||||||
rsync -av -e "ssh -i /home/naeel/remote_dev/common/id_ed25519.txt" \
|
|
||||||
/home/naeel/remote_dev/sless/examples/<example>/ \
|
|
||||||
naeel@5.172.178.213:/home/naeel/terra/sless/examples/<example>/
|
|
||||||
|
|
||||||
# Затем запускать на remote:
|
|
||||||
ssh -i /home/naeel/remote_dev/common/id_ed25519.txt naeel@5.172.178.213 \
|
|
||||||
'cd /home/naeel/terra/sless/examples/<example> && terraform apply -auto-approve -no-color'
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2026-03-06 — Отдельная репа для сервиса
|
## 2026-03-06 — Отдельная репа для сервиса
|
||||||
|
|
||||||
**Решение:** Serverless service в отдельной репе, не вместе с Terraform provider.
|
**Решение:** Serverless service в отдельной репе, не вместе с Terraform provider.
|
||||||
@@ -704,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`
|
||||||
+32
-70
@@ -1,75 +1,6 @@
|
|||||||
# Прогресс разработки
|
# Прогресс разработки
|
||||||
|
|
||||||
Последнее обновление: 2026-03-18 12:00 (DNS инцидент устранён, POSTGRES E2E PASS)
|
Последнее обновление: 2026-03-10 (добавлен прагматичный обзор Claude Opus 4.6)
|
||||||
|
|
||||||
## 2026-03-18 — DNS инцидент: sless-api.kube5s.ru → мёртвый кластер
|
|
||||||
|
|
||||||
| # | Задача | Статус | Заметки |
|
|
||||||
|---|--------|--------|----------|
|
|
||||||
| 1 | Диагностика TLS handshake timeout при `terraform apply` POSTGRES | ✅ | `sless-api.kube5s.ru` → `5.172.178.182` (старый кластер). Порт 443 принимал TCP, но TLS не завершался — Go HTTP клиент висел бесконечно |
|
|
||||||
| 2 | Подтверждение нового кластера | ✅ | `kubectl get nodes` в контексте `tazetdinovn@gmail.com@naeel-test-3` → 3 ноды Ready. Ingress IP `185.247.187.147`. DNS `sless.kube5s.ru` уже указывал на него |
|
|
||||||
| 3 | Обновить Ingress в кластере | ✅ | `kubectl patch ingress sless-operator -n sless`: host + TLS → `sless.kube5s.ru`, secretName → `sless-operator-tls-sless` |
|
|
||||||
| 4 | Обновить ConfigMap оператора | ✅ | `EXTERNAL_URL: https://sless.kube5s.ru` (было `https://sless-api.kube5s.ru`) |
|
|
||||||
| 5 | Let's Encrypt сертификат | ✅ | `certificate/sless-operator-tls-sless` READY=True, order valid — выдан сразу после смены ingress host (ACME HTTP-01 challenge на новом IP прошёл) |
|
|
||||||
| 6 | Заменить `sless-api.kube5s.ru` → `sless.kube5s.ru` во всех examples + deployments | ✅ | `POSTGRES/`, `hello-go/`, `hello-node/`, `simple-node/`, `simple-python/`, `notes-python/`, `pg-list-python/`, `demo-managed-functions/`, `README.md`, `deployments/k8s/operator.yaml` |
|
|
||||||
| 7 | POSTGRES `terraform apply` с функцией и job | ✅ PASS | `sless_function` `pg-create-table-runner` — создан за 55s. `sless_job` `pg-create-table-job-main-v13` — создан за 20s. `Apply complete! Resources: 2 added` |
|
|
||||||
| 8 | Коммит и push | ✅ | `cca3a8c` — `fix: migrate sless endpoint from sless-api.kube5s.ru to sless.kube5s.ru` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
## 2026-03-17 — E2E тесты всех примеров на тест-стенде
|
|
||||||
|
|
||||||
| # | Задача | Статус | Заметки |
|
|
||||||
|---|--------|--------|---------|
|
|
||||||
| 1 | Исправить `nubes_endpoint` во всех examples (prod→test) | ✅ | `deck-api.ngcloud.ru` → `deck-api-test.ngcloud.ru/api/v1` в hello-node, hello-go, simple-node, notes-python, pg-list-python, demo-managed-functions |
|
|
||||||
| 2 | Исправить `sless_endpoint` в demo-managed-functions | ✅ | `185.247.187.147.nip.io` → `https://sless-api.kube5s.ru` |
|
|
||||||
| 3 | Postgres для тестов | ✅ | `postgres.sless.svc.cluster.local:5432`, user=`sless`, pass=`sless-pg-password`, db=`sless`. Уже запущен в кластере |
|
|
||||||
| 4 | `hello-node` E2E | ✅ PASS | init → apply (4 ресурса) → modify (memory_mb 128→256, env_var) → apply → destroy |
|
|
||||||
| 5 | `simple-python` E2E | ✅ PASS | init → apply → modify (memory_mb 64→96) → apply → destroy. `job_result = {"time": "..."}` |
|
|
||||||
| 6 | `simple-node` E2E | ✅ PASS | Аналогично simple-python но nodejs20 |
|
|
||||||
| 7 | `notes-python` E2E | ✅ PASS | 7 ресурсов, PostgreSQL init job: `{"ok": true, "executed": 1}`, destroy complete |
|
|
||||||
| 8 | Коммит на remote | ✅ | `a768454` в `/home/naeel/terra/sless/examples` |
|
|
||||||
| 9 | POSTGRES example | 🔄 | Следующий шаг — вернуться к POSTGRES после прохождения всех тестов |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
## 2026-03-17 — v0.1.31: compile fix + POSTGRES example end-to-end
|
|
||||||
|
|
||||||
| # | Задача | Статус | Заметки |
|
|
||||||
|---|--------|--------|---------|
|
|
||||||
| 1 | Баг 3: `PodLogOptions{Stdout/Stderr}` compile error | ✅ | Убраны несуществующие поля. `getJobPodOutput` в `functionjob_controller.go` |
|
|
||||||
| 2 | Сборка `naeel/sless-operator:v0.1.31` | ✅ | Собрано на `naeel@5.172.178.213`, запушено в Docker Hub |
|
|
||||||
| 3 | Деплой v0.1.31 в кластер | ✅ | `kubectl rollout status` → success. Pod `sless-operator-545556c59d-n8qv9` |
|
|
||||||
| 4 | `simple-python` — сквозной тест на тест-стенде | ✅ | `terraform apply` на remote: `Apply complete! Resources: 3 added`. `job_result = {"time": "..."}` |
|
|
||||||
| 5 | Найдена главная причина падения POSTGRES | ✅ | `nubes_endpoint` в `provider "sless"` указывал на **прод** `deck-api.ngcloud.ru` |
|
|
||||||
| 6 | Исправлен `nubes_endpoint` в POSTGRES/main.tf | ✅ | Теперь `deck-api-test.ngcloud.ru/api/v1` |
|
|
||||||
| 7 | Исправлен `nubes_endpoint` в simple-python/main.tf | ✅ | Аналогично |
|
|
||||||
| 8 | End-to-end `terraform apply` для POSTGRES | 🔄 | Следующий шаг: синхронизировать на remote и запустить `terraform apply` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2026-03-17 — POSTGRES example: внешний managed PG, FunctionJob и реальные блокеры
|
|
||||||
|
|
||||||
| # | Задача | Статус | Заметки |
|
|
||||||
|---|--------|--------|---------|
|
|
||||||
| 1 | Перевод `examples/POSTGRES` на `PGSSLMODE=require` | ✅ | Для managed PostgreSQL `allow_no_s_s_l = false`; `disable` был некорректен |
|
|
||||||
| 2 | Подтверждение root-cause `password authentication failed` | ✅ | Из runtime namespace поднят диагностический pod того же image: `psycopg2.OperationalError: FATAL: password authentication failed for user "u-user0"` |
|
|
||||||
| 3 | Проверка источника пароля | ✅ | `nubes_postgres.npg.vault_secrets["users"]` не совпадает с фактическим паролем пользователя в кластере managed PostgreSQL; authoritative source сейчас — k8s secret `u-user0.postgresqlk8s.credentials.postgresql.acid.zalan.do` |
|
|
||||||
| 4 | Попытка читать пароль через `hashicorp/external` | ❌ | В текущем окружении доступен только кастомный registry; `registry.terraform.io/hashicorp/external` не устанавливается |
|
|
||||||
| 5 | One-command `terraform apply` для `examples/POSTGRES` | 🔄 | Остаточный баг локализован в связке credential-source + FunctionJob observability. После переключения на актуальный пароль `sless_job` всё ещё падает, но актуальные pod logs реального job теряются из-за TTL/слабой наблюдаемости |
|
|
||||||
| 6 | Необходимый следующий технический шаг | 🔄 | Либо добавить в проект доступный data-source/провайдер для чтения k8s secret внутри apply, либо починить источник пароля в nubes-потоке, чтобы `vault_secrets` и реальный Postgres user password совпадали |
|
|
||||||
|
|
||||||
|
|
||||||
## 2026-03-17 — Fix: `examples/POSTGRES` / `sless_job` 409 + диагностика FunctionJob
|
|
||||||
|
|
||||||
| # | Задача | Статус | Заметки |
|
|
||||||
|---|--------|--------|---------|
|
|
||||||
| 1 | Идемпотентность `sless_job` при `409 job already exists` | ✅ | `terraform/provider/internal/client/client.go`, `terraform/provider/internal/resources/job_resource.go`: добавлен typed-error `ErrJobAlreadyExists`, в Create при 409 читается существующий Job и продолжается ожидание/синхронизация вместо немедленного падения |
|
|
||||||
| 2 | Диагностика падений FunctionJob | ✅ | `controllers/functionjob_controller.go`: labels добавлены в `PodTemplate`, fail-message теперь включает output pod-а (обрезка до 2000), fallback-команда логов по `job-name` |
|
|
||||||
| 3 | Root-cause для `examples/POSTGRES` | ✅ | Рабочие `examples/notes-python` используют внутренний DSN (`postgres.sless.svc`), а `examples/POSTGRES` работает через managed Nubes Postgres + динамические креды; критичный UX-дефект был в повторном Create после failed apply |
|
|
||||||
|
|
||||||
|
|
||||||
## Статусы: ✅ готово | 🔄 в процессе | ⏳ не начато
|
## Статусы: ✅ готово | 🔄 в процессе | ⏳ не начато
|
||||||
|
|
||||||
@@ -299,6 +230,37 @@
|
|||||||
| 6 | `replicas` field в FunctionSpec | Низкий (v1.1) |
|
| 6 | `replicas` field в FunctionSpec | Низкий (v1.1) |
|
||||||
| 7 | RabbitMQ event triggers | Низкий (v2) |
|
| 7 | RabbitMQ event triggers | Низкий (v2) |
|
||||||
| 8 | Метрики → Victoria Metrics | Низкий |
|
| 8 | Метрики → Victoria Metrics | Низкий |
|
||||||
|
| 9 | `sless-plan` реально не запускает `terraform plan` — нужна интеграция в workflow (docs) | Средний |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-03-12 — AI-анализ terraform plan (ветка feat/ai-terraform-analysis)
|
||||||
|
|
||||||
|
| # | Компонент | Статус | Заметки |
|
||||||
|
|---|-----------|--------|----------|
|
||||||
|
| 1 | `internal/ai/provider.go` | ✅ | `LLMProvider` interface + `Config` struct + `NewProvider` фабрика |
|
||||||
|
| 2 | `internal/ai/google_gemini.go` | ✅ | Gemini REST API через `net/http` (без внешних SDK) |
|
||||||
|
| 3 | `internal/ai/prompts.go` | ✅ | Системные промпты уровней 1–5, `BuildPrompt`, `LevelName` |
|
||||||
|
| 4 | `internal/ai/analyzer.go` | ✅ | Оркестратор: `NewAnalyzer`, `Analyze`, `FormatResult`, `truncatePlan(8000)` |
|
||||||
|
| 5 | `internal/ai/cloud_llm.go` | ✅ | Заглушка для будущего облачного LLM |
|
||||||
|
| 6 | `cmd/sless-plan/main.go` | ✅ | CLI обёртка над `terraform plan` + AI анализ после |
|
||||||
|
| 7 | `terraform/provider/internal/provider/provider.go` | ✅ | `ai_hint_level` атрибут (опциональный Int64, default=0) в Schema и Configure |
|
||||||
|
| 8 | `go build ./internal/ai/... && go build ./cmd/sless-plan/...` | ✅ | BUILD OK на VM |
|
||||||
|
| 9 | `go build ./...` в terraform/provider/ | ✅ | BUILD OK на VM |
|
||||||
|
| 10 | Коммит + пуш | ✅ | `d5ce8c2` (ai pkg + cmd) → `582b589` (provider) |
|
||||||
|
|
||||||
|
**Ветка:** `feat/ai-terraform-analysis`
|
||||||
|
**Последний коммит:** `582b589`
|
||||||
|
|
||||||
|
**Как использовать:**
|
||||||
|
```hcl
|
||||||
|
provider "sless" {
|
||||||
|
endpoint = "https://sless-api.kube5s.ru"
|
||||||
|
token = var.token
|
||||||
|
ai_hint_level = 3 # 0=off (default), 1–5 = уровень анализа
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Затем вместо `terraform plan` использовать `sless-plan` — предоставит анализ от Gemini.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Инструкции: монтирование удалённой папки через SSHFS
|
||||||
|
|
||||||
|
Дата: 2026-03-12
|
||||||
|
|
||||||
|
Кратко: эти команды повторяют текущее у вас подключение, где
|
||||||
|
источник: `naeel@5.172.178.213:/home/naeel/terra`, и точка монтирования — `/home/<user>/remote_dev`.
|
||||||
|
|
||||||
|
1) Установка `sshfs` (Debian/Ubuntu):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y sshfs
|
||||||
|
```
|
||||||
|
|
||||||
|
2) Подготовка приватного ключа:
|
||||||
|
|
||||||
|
- Скопируйте приватный ключ на целевую машину или загрузите его безопасным способом.
|
||||||
|
- Пример копирования с локальной машины на целевой хост:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scp /home/naeel/remote_dev/sless/secrets/naeel_vm_id_ed25519 user@target:/home/user/.ssh/id_ed25519_sless
|
||||||
|
ssh user@target 'chmod 600 /home/user/.ssh/id_ed25519_sless'
|
||||||
|
```
|
||||||
|
|
||||||
|
(Оригинал ключа в репозитории: `secrets/naeel_vm_id_ed25519`)
|
||||||
|
|
||||||
|
3) Создать точку монтирования на целевом хосте:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /home/user/remote_dev
|
||||||
|
chown user:user /home/user/remote_dev
|
||||||
|
```
|
||||||
|
|
||||||
|
4) Команда монтирования (пример, повторяет ваше текущее подключение):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sshfs -o IdentityFile=/home/user/.ssh/id_ed25519_sless \
|
||||||
|
-o StrictHostKeyChecking=no \
|
||||||
|
-o UserKnownHostsFile=/dev/null \
|
||||||
|
-o reconnect \
|
||||||
|
-o ServerAliveInterval=15 \
|
||||||
|
-o ServerAliveCountMax=3 \
|
||||||
|
naeel@5.172.178.213:/home/naeel/terra /home/user/remote_dev
|
||||||
|
```
|
||||||
|
|
||||||
|
5) Проверка статуса монтирования и содержимого:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
findmnt /home/user/remote_dev
|
||||||
|
ls -la /home/user/remote_dev
|
||||||
|
```
|
||||||
|
|
||||||
|
6) Отключение:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
fusermount -u /home/user/remote_dev
|
||||||
|
```
|
||||||
|
|
||||||
|
7) (Опционально) systemd unit для автоподключения при старте:
|
||||||
|
|
||||||
|
Создайте файл `/etc/systemd/system/remote_dev.mount` со следующим содержимым (отредактируйте путь к ключу и `Where`):
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=SSHFS mount for /home/user/remote_dev
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Mount]
|
||||||
|
What=naeel@5.172.178.213:/home/naeel/terra
|
||||||
|
Where=/home/user/remote_dev
|
||||||
|
Type=fuse.sshfs
|
||||||
|
Options=IdentityFile=/home/user/.ssh/id_ed25519_sless,allow_other,reconnect,ServerAliveInterval=15,ServerAliveCountMax=3,StrictHostKeyChecking=no,UserKnownHostsFile=/dev/null
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
Затем выполнить:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now remote_dev.mount
|
||||||
|
```
|
||||||
|
|
||||||
|
8) Заметки по безопасности и надёжности:
|
||||||
|
|
||||||
|
- Храните приватный ключ с правами `600` и ограниченным доступом.
|
||||||
|
- Рассмотрите использование `ssh-agent` вместо копирования ключа.
|
||||||
|
- Опция `StrictHostKeyChecking=no` ослабляет проверку host key — используйте осознанно.
|
||||||
|
- Для массовой настройки можете создать скрипт установки и unit-файл через Ansible/Cloud-init.
|
||||||
|
|
||||||
|
---
|
||||||
|
Файл создан автоматически по запросу пользователя. Если хотите, могу:
|
||||||
|
- добавить инструкции для `macOS` или `CentOS`/`RHEL`;
|
||||||
|
- создать такой же unit-файл и загрузить его на целевой хост (при доступе по SSH).
|
||||||
+1
-1
@@ -16,7 +16,7 @@ crash.log
|
|||||||
# Provider plugins / caches
|
# Provider plugins / caches
|
||||||
.terraform.d/
|
.terraform.d/
|
||||||
|
|
||||||
*.tfvars
|
#*.tfvars
|
||||||
|
|
||||||
# Archives and build artifacts
|
# Archives and build artifacts
|
||||||
*.zip
|
*.zip
|
||||||
|
|||||||
@@ -1,129 +0,0 @@
|
|||||||
# 2026-03-18 (обновлено: фильтрация SLESS_EXCLUDE, читаемый вывод через "#"-ключ)
|
|
||||||
# funcs_list.py — HTTP-функция: список всех пользовательских функций с их статусами.
|
|
||||||
# Вызывает внутренний REST API оператора (ClusterIP, без TLS).
|
|
||||||
# Объединяет данные функций и триггеров в один ответ; скрывает служебные функции.
|
|
||||||
#
|
|
||||||
# Env vars:
|
|
||||||
# SLESS_API_URL — URL оператора (http://sless-operator.sless.svc.cluster.local:9090)
|
|
||||||
# SLESS_NAMESPACE — namespace пользователя (sless-{hex16})
|
|
||||||
# SLESS_TOKEN — JWT токен для /v1/ API
|
|
||||||
# SLESS_EXTERNAL_URL — публичный базовый URL (https://sless.kube5s.ru), для корректных ссылок
|
|
||||||
# SLESS_EXCLUDE — comma-separated имена функций, которые не надо показывать
|
|
||||||
# Пример: "funcs,event-writer,event-monitor,event-cleaner"
|
|
||||||
#
|
|
||||||
# Формат вывода: JSON-объект, где каждая функция содержит поле "#" — краткий комментарий.
|
|
||||||
# При pretty-print (python3 -m json.tool) выглядит как читаемый список с аннотациями.
|
|
||||||
|
|
||||||
import os
|
|
||||||
import requests
|
|
||||||
|
|
||||||
|
|
||||||
def _short_comment(fn, http_triggers, cron_triggers):
|
|
||||||
"""Генерирует однострочный комментарий-описание функции по её метаданным."""
|
|
||||||
phase = fn.get("phase", "")
|
|
||||||
runtime = fn.get("runtime", "")
|
|
||||||
|
|
||||||
if http_triggers:
|
|
||||||
active_str = "активна" if http_triggers[0].get("active") else "неактивна"
|
|
||||||
return f"HTTP endpoint ({runtime}) — {phase}, {active_str}"
|
|
||||||
elif cron_triggers:
|
|
||||||
schedule = cron_triggers[0].get("schedule", "?")
|
|
||||||
active_str = "активна" if cron_triggers[0].get("active") else "неактивна"
|
|
||||||
return f"Cron '{schedule}' ({runtime}) — {phase}, {active_str}"
|
|
||||||
else:
|
|
||||||
return f"Job/runner без триггера ({runtime}) — {phase}"
|
|
||||||
|
|
||||||
|
|
||||||
def list_all(event):
|
|
||||||
api_url = os.environ["SLESS_API_URL"].rstrip("/")
|
|
||||||
namespace = os.environ["SLESS_NAMESPACE"]
|
|
||||||
token = os.environ["SLESS_TOKEN"]
|
|
||||||
ext_url = os.environ.get("SLESS_EXTERNAL_URL", "").rstrip("/")
|
|
||||||
|
|
||||||
# Имена функций, которые не должны присутствовать в выводе.
|
|
||||||
# Включает саму себя ("funcs") и служебные функции других примеров.
|
|
||||||
exclude = {
|
|
||||||
n.strip()
|
|
||||||
for n in os.environ.get("SLESS_EXCLUDE", "").split(",")
|
|
||||||
if n.strip()
|
|
||||||
}
|
|
||||||
|
|
||||||
headers = {"Authorization": f"Bearer {token}"}
|
|
||||||
|
|
||||||
fns_resp = requests.get(
|
|
||||||
f"{api_url}/v1/namespaces/{namespace}/functions",
|
|
||||||
headers=headers,
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
fns_resp.raise_for_status()
|
|
||||||
|
|
||||||
trs_resp = requests.get(
|
|
||||||
f"{api_url}/v1/namespaces/{namespace}/triggers",
|
|
||||||
headers=headers,
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
trs_resp.raise_for_status()
|
|
||||||
|
|
||||||
# Индекс триггеров по имени функции
|
|
||||||
triggers_by_fn = {}
|
|
||||||
for tr in trs_resp.json():
|
|
||||||
fn_name = tr.get("function") or tr.get("functionRef")
|
|
||||||
if fn_name:
|
|
||||||
triggers_by_fn.setdefault(fn_name, []).append(tr)
|
|
||||||
|
|
||||||
result = []
|
|
||||||
for fn in fns_resp.json():
|
|
||||||
name = fn["name"]
|
|
||||||
if name in exclude:
|
|
||||||
continue
|
|
||||||
|
|
||||||
http_triggers = [
|
|
||||||
t for t in triggers_by_fn.get(name, []) if t.get("type") == "http"
|
|
||||||
]
|
|
||||||
cron_triggers = [
|
|
||||||
t for t in triggers_by_fn.get(name, []) if t.get("type") == "cron"
|
|
||||||
]
|
|
||||||
is_active = any(
|
|
||||||
t.get("enabled", True) and t.get("active", False)
|
|
||||||
for t in triggers_by_fn.get(name, [])
|
|
||||||
)
|
|
||||||
|
|
||||||
entry = {
|
|
||||||
# "#" — первый ключ: служит визуальным комментарием при pretty-print
|
|
||||||
"#": _short_comment(fn, http_triggers, cron_triggers),
|
|
||||||
"name": name,
|
|
||||||
"runtime": fn.get("runtime"),
|
|
||||||
"phase": fn.get("phase"),
|
|
||||||
"active": is_active,
|
|
||||||
}
|
|
||||||
|
|
||||||
# URL вычисляем из SLESS_EXTERNAL_URL если задан — state может хранить старый домен
|
|
||||||
if http_triggers:
|
|
||||||
if ext_url:
|
|
||||||
entry["url"] = f"{ext_url}/fn/{namespace}/{name}"
|
|
||||||
else:
|
|
||||||
entry["url"] = http_triggers[0].get("url", "")
|
|
||||||
|
|
||||||
if cron_triggers:
|
|
||||||
entry["cron"] = cron_triggers[0].get("schedule", "")
|
|
||||||
|
|
||||||
if fn.get("message"):
|
|
||||||
entry["message"] = fn["message"]
|
|
||||||
|
|
||||||
# created_at и last_built_at — доступны после обновления оператора до v0.1.32+
|
|
||||||
if fn.get("created_at"):
|
|
||||||
entry["created_at"] = fn["created_at"]
|
|
||||||
if fn.get("last_built_at"):
|
|
||||||
entry["last_built_at"] = fn["last_built_at"]
|
|
||||||
|
|
||||||
result.append(entry)
|
|
||||||
|
|
||||||
# Сортировка: активные вверх, затем по имени
|
|
||||||
result.sort(key=lambda f: (not f["active"], f["name"]))
|
|
||||||
|
|
||||||
return {
|
|
||||||
"namespace": namespace,
|
|
||||||
"count": len(result),
|
|
||||||
"functions": result,
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
requests==2.31.0
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "pg-info",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "sless nodejs20 function: pg version + table info",
|
|
||||||
"dependencies": {
|
|
||||||
"pg": "8.11.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
// 2026-03-18
|
|
||||||
// pg_info.js — NodeJS-функция: проверка работы JS runtime + чтение мета-данных БД.
|
|
||||||
// Подключается к PostgreSQL через пакет pg, возвращает версию сервера и счётчик строк.
|
|
||||||
// Демонстрирует: nodejs20 runtime, npm-зависимость (package.json), PG из JS.
|
|
||||||
//
|
|
||||||
// ENV (те же что у python-функций):
|
|
||||||
// PGHOST, PGPORT, PGDATABASE, PGUSER, PGPASSWORD, PGSSLMODE
|
|
||||||
//
|
|
||||||
// Entrypoint: pg_info.info
|
|
||||||
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const { Client } = require('pg');
|
|
||||||
|
|
||||||
exports.info = async (event) => {
|
|
||||||
const client = new Client({
|
|
||||||
host: process.env.PGHOST,
|
|
||||||
port: parseInt(process.env.PGPORT || '5432'),
|
|
||||||
database: process.env.PGDATABASE,
|
|
||||||
user: process.env.PGUSER,
|
|
||||||
password: process.env.PGPASSWORD,
|
|
||||||
// pg-пакет требует явного ssl-объекта; rejectUnauthorized: false — т.к.
|
|
||||||
// self-signed cert на nubes managed PG, но канал всё равно шифруется.
|
|
||||||
ssl: process.env.PGSSLMODE === 'require' ? { rejectUnauthorized: false } : false,
|
|
||||||
});
|
|
||||||
|
|
||||||
await client.connect();
|
|
||||||
try {
|
|
||||||
const [versionRes, countRes] = await Promise.all([
|
|
||||||
client.query('SELECT version() AS v'),
|
|
||||||
client.query('SELECT COUNT(*) AS cnt FROM terraform_demo_table'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
runtime: 'nodejs20',
|
|
||||||
node_version: process.version,
|
|
||||||
pg_version: versionRes.rows[0].v,
|
|
||||||
table_rows: parseInt(countRes.rows[0].cnt, 10),
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
await client.end();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# 2026-03-17 00:00
|
|
||||||
# requirements.txt — зависимости для функции запуска SQL.
|
|
||||||
psycopg2-binary==2.9.9
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# 2026-03-17 00:00
|
|
||||||
# sql_runner.py — функция для выполнения SQL-операторов из входного события.
|
|
||||||
import os
|
|
||||||
import psycopg2
|
|
||||||
|
|
||||||
|
|
||||||
def run_sql(event):
|
|
||||||
# Выполняет список SQL-операторов в одной транзакции для атомарной инициализации схемы.
|
|
||||||
# Параметры подключения передаются раздельно, чтобы избежать ошибок парсинга DSN при спецсимволах.
|
|
||||||
pg_host = os.environ["PGHOST"]
|
|
||||||
pg_port = os.environ.get("PGPORT", "5432")
|
|
||||||
pg_database = os.environ["PGDATABASE"]
|
|
||||||
pg_user = os.environ["PGUSER"]
|
|
||||||
pg_password = os.environ["PGPASSWORD"]
|
|
||||||
pg_sslmode = os.environ.get("PGSSLMODE", "require")
|
|
||||||
statements = event.get("statements", [])
|
|
||||||
|
|
||||||
if not statements:
|
|
||||||
return {"error": "no statements provided"}
|
|
||||||
|
|
||||||
connection = psycopg2.connect(
|
|
||||||
host=pg_host,
|
|
||||||
port=pg_port,
|
|
||||||
dbname=pg_database,
|
|
||||||
user=pg_user,
|
|
||||||
password=pg_password,
|
|
||||||
sslmode=pg_sslmode,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
cursor = connection.cursor()
|
|
||||||
for statement in statements:
|
|
||||||
cursor.execute(statement)
|
|
||||||
connection.commit()
|
|
||||||
return {"ok": True, "executed": len(statements)}
|
|
||||||
except Exception as error:
|
|
||||||
connection.rollback()
|
|
||||||
return {"error": str(error)}
|
|
||||||
finally:
|
|
||||||
connection.close()
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
psycopg2-binary==2.9.9
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# 2026-03-18
|
|
||||||
# table_reader.py — HTTP-функция: читает строки из terraform_demo_table и возвращает JSON.
|
|
||||||
# Подключается к Postgres через env vars (те же что у sql-runner).
|
|
||||||
import os
|
|
||||||
import psycopg2
|
|
||||||
import psycopg2.extras
|
|
||||||
|
|
||||||
|
|
||||||
def list_rows(event):
|
|
||||||
# Возвращает все строки terraform_demo_table в порядке убывания created_at.
|
|
||||||
connection = psycopg2.connect(
|
|
||||||
host=os.environ["PGHOST"],
|
|
||||||
port=os.environ.get("PGPORT", "5432"),
|
|
||||||
dbname=os.environ["PGDATABASE"],
|
|
||||||
user=os.environ["PGUSER"],
|
|
||||||
password=os.environ["PGPASSWORD"],
|
|
||||||
sslmode=os.environ.get("PGSSLMODE", "require"),
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
cursor = connection.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
|
||||||
cursor.execute(
|
|
||||||
"SELECT id, title, created_at::text FROM terraform_demo_table ORDER BY created_at DESC"
|
|
||||||
)
|
|
||||||
rows = [dict(row) for row in cursor.fetchall()]
|
|
||||||
return {"rows": rows, "count": len(rows)}
|
|
||||||
finally:
|
|
||||||
connection.close()
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
# 2026-03-18 (обновлено: фильтрация SLESS_EXCLUDE, читаемый вывод через "#"-ключ)
|
|
||||||
# funcs_list.py — HTTP-функция: список всех пользовательских функций с их статусами.
|
|
||||||
# Вызывает внутренний REST API оператора (ClusterIP, без TLS).
|
|
||||||
# Объединяет данные функций и триггеров в один ответ; скрывает служебные функции.
|
|
||||||
#
|
|
||||||
# Env vars:
|
|
||||||
# SLESS_API_URL — URL оператора (http://sless-operator.sless.svc.cluster.local:9090)
|
|
||||||
# SLESS_NAMESPACE — namespace пользователя (sless-{hex16})
|
|
||||||
# SLESS_TOKEN — JWT токен для /v1/ API
|
|
||||||
# SLESS_EXTERNAL_URL — публичный базовый URL (https://sless.kube5s.ru), для корректных ссылок
|
|
||||||
# SLESS_EXCLUDE — comma-separated имена функций, которые не надо показывать
|
|
||||||
# Пример: "funcs,event-writer,event-monitor,event-cleaner"
|
|
||||||
#
|
|
||||||
# Формат вывода: JSON-объект, где каждая функция содержит поле "#" — краткий комментарий.
|
|
||||||
# При pretty-print (python3 -m json.tool) выглядит как читаемый список с аннотациями.
|
|
||||||
|
|
||||||
import os
|
|
||||||
import requests
|
|
||||||
|
|
||||||
|
|
||||||
def _short_comment(fn, http_triggers, cron_triggers):
|
|
||||||
"""Генерирует однострочный комментарий-описание функции по её метаданным."""
|
|
||||||
phase = fn.get("phase", "")
|
|
||||||
runtime = fn.get("runtime", "")
|
|
||||||
|
|
||||||
if http_triggers:
|
|
||||||
active_str = "активна" if http_triggers[0].get("active") else "неактивна"
|
|
||||||
return f"HTTP endpoint ({runtime}) — {phase}, {active_str}"
|
|
||||||
elif cron_triggers:
|
|
||||||
schedule = cron_triggers[0].get("schedule", "?")
|
|
||||||
active_str = "активна" if cron_triggers[0].get("active") else "неактивна"
|
|
||||||
return f"Cron '{schedule}' ({runtime}) — {phase}, {active_str}"
|
|
||||||
else:
|
|
||||||
return f"Job/runner без триггера ({runtime}) — {phase}"
|
|
||||||
|
|
||||||
|
|
||||||
def list_all(event):
|
|
||||||
api_url = os.environ["SLESS_API_URL"].rstrip("/")
|
|
||||||
namespace = os.environ["SLESS_NAMESPACE"]
|
|
||||||
token = os.environ["SLESS_TOKEN"]
|
|
||||||
ext_url = os.environ.get("SLESS_EXTERNAL_URL", "").rstrip("/")
|
|
||||||
|
|
||||||
# Имена функций, которые не должны присутствовать в выводе.
|
|
||||||
# Включает саму себя ("funcs") и служебные функции других примеров.
|
|
||||||
exclude = {
|
|
||||||
n.strip()
|
|
||||||
for n in os.environ.get("SLESS_EXCLUDE", "").split(",")
|
|
||||||
if n.strip()
|
|
||||||
}
|
|
||||||
|
|
||||||
headers = {"Authorization": f"Bearer {token}"}
|
|
||||||
|
|
||||||
fns_resp = requests.get(
|
|
||||||
f"{api_url}/v1/namespaces/{namespace}/functions",
|
|
||||||
headers=headers,
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
fns_resp.raise_for_status()
|
|
||||||
|
|
||||||
trs_resp = requests.get(
|
|
||||||
f"{api_url}/v1/namespaces/{namespace}/triggers",
|
|
||||||
headers=headers,
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
trs_resp.raise_for_status()
|
|
||||||
|
|
||||||
# Индекс триггеров по имени функции
|
|
||||||
triggers_by_fn = {}
|
|
||||||
for tr in trs_resp.json():
|
|
||||||
fn_name = tr.get("function") or tr.get("functionRef")
|
|
||||||
if fn_name:
|
|
||||||
triggers_by_fn.setdefault(fn_name, []).append(tr)
|
|
||||||
|
|
||||||
result = []
|
|
||||||
for fn in fns_resp.json():
|
|
||||||
name = fn["name"]
|
|
||||||
if name in exclude:
|
|
||||||
continue
|
|
||||||
|
|
||||||
http_triggers = [
|
|
||||||
t for t in triggers_by_fn.get(name, []) if t.get("type") == "http"
|
|
||||||
]
|
|
||||||
cron_triggers = [
|
|
||||||
t for t in triggers_by_fn.get(name, []) if t.get("type") == "cron"
|
|
||||||
]
|
|
||||||
is_active = any(
|
|
||||||
t.get("enabled", True) and t.get("active", False)
|
|
||||||
for t in triggers_by_fn.get(name, [])
|
|
||||||
)
|
|
||||||
|
|
||||||
entry = {
|
|
||||||
# "#" — первый ключ: служит визуальным комментарием при pretty-print
|
|
||||||
"#": _short_comment(fn, http_triggers, cron_triggers),
|
|
||||||
"name": name,
|
|
||||||
"runtime": fn.get("runtime"),
|
|
||||||
"phase": fn.get("phase"),
|
|
||||||
"active": is_active,
|
|
||||||
}
|
|
||||||
|
|
||||||
# URL вычисляем из SLESS_EXTERNAL_URL если задан — state может хранить старый домен
|
|
||||||
if http_triggers:
|
|
||||||
if ext_url:
|
|
||||||
entry["url"] = f"{ext_url}/fn/{namespace}/{name}"
|
|
||||||
else:
|
|
||||||
entry["url"] = http_triggers[0].get("url", "")
|
|
||||||
|
|
||||||
if cron_triggers:
|
|
||||||
entry["cron"] = cron_triggers[0].get("schedule", "")
|
|
||||||
|
|
||||||
if fn.get("message"):
|
|
||||||
entry["message"] = fn["message"]
|
|
||||||
|
|
||||||
# created_at и last_built_at — доступны после обновления оператора до v0.1.32+
|
|
||||||
if fn.get("created_at"):
|
|
||||||
entry["created_at"] = fn["created_at"]
|
|
||||||
if fn.get("last_built_at"):
|
|
||||||
entry["last_built_at"] = fn["last_built_at"]
|
|
||||||
|
|
||||||
result.append(entry)
|
|
||||||
|
|
||||||
# Сортировка: активные вверх, затем по имени
|
|
||||||
result.sort(key=lambda f: (not f["active"], f["name"]))
|
|
||||||
|
|
||||||
return {
|
|
||||||
"namespace": namespace,
|
|
||||||
"count": len(result),
|
|
||||||
"functions": result,
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
|
|
||||||
# resource "nubes_lucee" "app1" {
|
|
||||||
# # Lucee-приложение, зависит от Postgres
|
|
||||||
# resource_name = "lucy_teststand_0"
|
|
||||||
# # resource_realm = "k8s-3.ext.nubes.ru"
|
|
||||||
# resource_realm = nubes_postgres.db2.resource_realm
|
|
||||||
# # resource_realm = "k8s-4-sandbox-nubes-ru"
|
|
||||||
# domain = "web-test-stand"
|
|
||||||
|
|
||||||
# git_path = "https://gitea-naeel.giteak8s.services.ngcloud.ru/naeel/testlucee"
|
|
||||||
|
|
||||||
# json_env = jsonencode({
|
|
||||||
# # 🔗 Настройки Data Source 'testds' для Lucee (Application.cfc)
|
|
||||||
# testds_class = "org.postgresql.Driver" # 📂 Драйвер БД
|
|
||||||
# testds_bundleName = "org.postgresql.jdbc" # 📦 Имя бандла JDBC
|
|
||||||
# testds_bundleVersion = "42.6.0" # 🔢 Версия драйвера
|
|
||||||
# testds_connectionString = "jdbc:postgresql://${nubes_postgres.db2.state_out_flat["internalConnect.master"]}:5432/postgres?sslmode=require" # 🚀 Строка подключения
|
|
||||||
# testds_username = nubes_postgres_user.db2_user.username # 👤 Логин
|
|
||||||
# testds_password = jsondecode(nubes_postgres.db2.vault_secrets["users"])[nubes_postgres_user.db2_user.username]["password"] # 🔑 Пароль
|
|
||||||
# testds_connectionLimit = "5" # 🚦 Лимит соединений
|
|
||||||
# testds_liveTimeout = "15" # ⏳ Таймаут жизни
|
|
||||||
# testds_validate = "false" # ✅ Валидация при запросе
|
|
||||||
# })
|
|
||||||
|
|
||||||
# resource_c_p_u = 300
|
|
||||||
# resource_memory = 512
|
|
||||||
# resource_instances = 1
|
|
||||||
# app_version = "5.4"
|
|
||||||
|
|
||||||
# depends_on = [nubes_postgres.db2]
|
|
||||||
# }
|
|
||||||
|
|
||||||
# resource "nubes_nodejs" "app3" {
|
|
||||||
# # NodeJS демо, работающий с тем же Postgres.
|
|
||||||
# resource_name = "node_01"
|
|
||||||
# resource_realm = nubes_postgres.db2.resource_realm
|
|
||||||
# domain = "node07"
|
|
||||||
# git_path = "https://gitea-naeel.giteak8s.services.ngcloud.ru/naeel/testnode.git"
|
|
||||||
# health_path = "/healthz"
|
|
||||||
# app_version = "23"
|
|
||||||
|
|
||||||
# json_env = jsonencode({
|
|
||||||
# # Переменные подключения к Postgres.
|
|
||||||
# PGHOST = nubes_postgres.db2.state_out_flat["internalConnect.master"]
|
|
||||||
# PGPORT = "5432"
|
|
||||||
# PGUSER = nubes_postgres_user.db2_user.username
|
|
||||||
# PGPASSWORD = jsondecode(nubes_postgres.db2.vault_secrets["users"])[nubes_postgres_user.db2_user.username]["password"]
|
|
||||||
# PGDATABASE = nubes_postgres_database.db2_app.db_name
|
|
||||||
# PGSSLMODE = "require"
|
|
||||||
# DATABASE_URL = format(
|
|
||||||
# "postgresql://%s:%s@%s:5432/%s?sslmode=require",
|
|
||||||
# nubes_postgres_user.db2_user.username,
|
|
||||||
# jsondecode(nubes_postgres.db2.vault_secrets["users"])[nubes_postgres_user.db2_user.username]["password"],
|
|
||||||
# nubes_postgres.db2.state_out_flat["internalConnect.master"],
|
|
||||||
# nubes_postgres_database.db2_app.db_name
|
|
||||||
# )
|
|
||||||
# })
|
|
||||||
|
|
||||||
# resource_c_p_u = 300
|
|
||||||
# resource_memory = 256
|
|
||||||
# resource_instances = 1
|
|
||||||
|
|
||||||
# depends_on = [nubes_postgres.db2]
|
|
||||||
# }
|
|
||||||
|
|
||||||
# output "pg_vault_secrets" {
|
|
||||||
# value = nubes_postgres.db2.vault_secrets
|
|
||||||
# sensitive = true
|
|
||||||
# }
|
|
||||||
|
|
||||||
# terraform output -json pg_vault_secrets
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
// 2026-03-17 17:05
|
|
||||||
// main.tf — провайдеры и переменные для Nubes + sless.
|
|
||||||
terraform {
|
|
||||||
required_providers {
|
|
||||||
nubes = {
|
|
||||||
source = "terra.k8c.ru/nubes/nubes"
|
|
||||||
version = "5.0.19"
|
|
||||||
}
|
|
||||||
sless = {
|
|
||||||
source = "terra.k8c.ru/naeel/sless"
|
|
||||||
version = "~> 0.1.18"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
variable "api_token" {
|
|
||||||
type = string
|
|
||||||
sensitive = true
|
|
||||||
description = "Nubes API token"
|
|
||||||
}
|
|
||||||
variable "s3_uid" {
|
|
||||||
type = string
|
|
||||||
sensitive = true
|
|
||||||
description = "Nubes S3 UID"
|
|
||||||
}
|
|
||||||
variable "realm" {
|
|
||||||
type = string
|
|
||||||
sensitive = true
|
|
||||||
description = "resource_realm parameter for nubes_postgres resource"
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2026-03-18 — pg_user/pg_password помечены optional (default="") для сверки.
|
|
||||||
// Реальные credentials берутся из vault_secrets через locals в resources.tf.
|
|
||||||
variable "pg_user" {
|
|
||||||
type = string
|
|
||||||
sensitive = true
|
|
||||||
default = ""
|
|
||||||
description = "Только для сверки. Реальный username из nubes_postgres_user.pg_user.username. Должен совпадать с vault."
|
|
||||||
}
|
|
||||||
|
|
||||||
variable "pg_password" {
|
|
||||||
type = string
|
|
||||||
sensitive = true
|
|
||||||
default = ""
|
|
||||||
description = "Только для сверки. Реальный пароль из vault_secrets. Должен совпадать с tfvars."
|
|
||||||
}
|
|
||||||
|
|
||||||
provider "nubes" {
|
|
||||||
api_token = var.api_token
|
|
||||||
api_endpoint = "https://deck-api-test.ngcloud.ru/api/v1/index.cfm"
|
|
||||||
}
|
|
||||||
|
|
||||||
provider "sless" {
|
|
||||||
endpoint = "https://sless.kube5s.ru"
|
|
||||||
token = var.api_token
|
|
||||||
nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1"
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
// 2026-03-18 — добавлены locals для извлечения credentials из vault_secrets (без хардкода).
|
|
||||||
// Для сверки хардкод остаётся в terraform.tfvars на этапе разработки.
|
|
||||||
// sless_function и sless_job закомментированы — сначала проверяется сетевое соединение.
|
|
||||||
|
|
||||||
# Актуальные credentials из vault_secrets (authoritatively) — vault синхронизирован с кластером.
|
|
||||||
# Структура vault_secrets["users"]: JSON-строка {"username": {"password": "...", "username": "..."}}
|
|
||||||
locals {
|
|
||||||
pg_creds_map = jsondecode(nubes_postgres.npg.vault_secrets["users"])
|
|
||||||
pg_username = nubes_postgres_user.pg_user.username
|
|
||||||
pg_password = local.pg_creds_map[local.pg_username]["password"]
|
|
||||||
pg_host = nubes_postgres.npg.state_out_flat["internalConnect.master"]
|
|
||||||
pg_database = nubes_postgres_database.db.db_name
|
|
||||||
}
|
|
||||||
|
|
||||||
resource "nubes_postgres" "npg" {
|
|
||||||
resource_name = "teststand-pg-2"
|
|
||||||
# s3_uid = "s01325"
|
|
||||||
s3_uid = var.s3_uid
|
|
||||||
resource_realm = var.realm
|
|
||||||
resource_instances = 1
|
|
||||||
resource_memory = 512
|
|
||||||
resource_c_p_u = 500
|
|
||||||
resource_disk = "1"
|
|
||||||
app_version = "17"
|
|
||||||
json_parameters = jsonencode({
|
|
||||||
log_connections = "off"
|
|
||||||
log_disconnections = "off"
|
|
||||||
})
|
|
||||||
enable_pg_pooler_master = false
|
|
||||||
enable_pg_pooler_slave = false
|
|
||||||
allow_no_s_s_l = false
|
|
||||||
auto_scale = false
|
|
||||||
auto_scale_percentage = 10
|
|
||||||
auto_scale_tech_window = 0
|
|
||||||
auto_scale_quota_gb = "1"
|
|
||||||
need_external_address_master = false
|
|
||||||
|
|
||||||
# suspend_on_destroy = false
|
|
||||||
operation_timeout = "11m"
|
|
||||||
adopt_existing_on_create = true
|
|
||||||
}
|
|
||||||
|
|
||||||
resource "nubes_postgres_user" "pg_user" {
|
|
||||||
postgres_id = nubes_postgres.npg.id
|
|
||||||
username = "u-user0"
|
|
||||||
role = "ddl_user"
|
|
||||||
adopt_existing_on_create = true
|
|
||||||
}
|
|
||||||
|
|
||||||
resource "nubes_postgres_database" "db" {
|
|
||||||
postgres_id = nubes_postgres.npg.id
|
|
||||||
db_name = "db_terra"
|
|
||||||
db_owner = nubes_postgres_user.pg_user.username
|
|
||||||
adopt_existing_on_create = true
|
|
||||||
# suspend_on_destroy = false
|
|
||||||
}
|
|
||||||
|
|
||||||
# Служебная функция выполняет SQL-операторы из event_json.
|
|
||||||
# Credentials берутся из locals (vault_secrets) — без хардкода.
|
|
||||||
# Для сверки хардкод остаётся в terraform.tfvars.
|
|
||||||
resource "sless_function" "postgres_sql_runner_create_table" {
|
|
||||||
name = "pg-create-table-runner"
|
|
||||||
runtime = "python3.11"
|
|
||||||
entrypoint = "sql_runner.run_sql"
|
|
||||||
memory_mb = 128
|
|
||||||
timeout_sec = 30
|
|
||||||
|
|
||||||
env_vars = {
|
|
||||||
PGHOST = local.pg_host
|
|
||||||
PGPORT = "5432"
|
|
||||||
PGDATABASE = local.pg_database
|
|
||||||
PGUSER = local.pg_username
|
|
||||||
PGPASSWORD = local.pg_password
|
|
||||||
PGSSLMODE = "require"
|
|
||||||
# Для сверки (должно совпадать с vault):
|
|
||||||
# PGUSER = var.pg_user
|
|
||||||
# PGPASSWORD = var.pg_password
|
|
||||||
}
|
|
||||||
|
|
||||||
source_dir = "${path.module}/code/sql-runner"
|
|
||||||
}
|
|
||||||
|
|
||||||
resource "sless_job" "postgres_table_init_job" {
|
|
||||||
name = "pg-create-table-job-main-v13"
|
|
||||||
function = sless_function.postgres_sql_runner_create_table.name
|
|
||||||
wait_timeout_sec = 180
|
|
||||||
run_id = 13
|
|
||||||
|
|
||||||
event_json = jsonencode({
|
|
||||||
statements = [
|
|
||||||
"CREATE TABLE IF NOT EXISTS terraform_demo_table (id serial PRIMARY KEY, title text NOT NULL, created_at timestamp DEFAULT now())"
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
depends_on = [nubes_postgres_database.db]
|
|
||||||
}
|
|
||||||
|
|
||||||
# HTTP-функция на NodeJS: возвращает версию PG-сервера и счётчик строк в таблице.
|
|
||||||
# Единственная функция примера на nodejs20 — проверка что JS runtime работает.
|
|
||||||
# Доступна по URL: https://sless.kube5s.ru/fn/<namespace>/pg-info
|
|
||||||
resource "sless_function" "pg_info" {
|
|
||||||
name = "pg-info"
|
|
||||||
runtime = "nodejs20"
|
|
||||||
entrypoint = "pg_info.info"
|
|
||||||
memory_mb = 128
|
|
||||||
timeout_sec = 15
|
|
||||||
|
|
||||||
env_vars = {
|
|
||||||
PGHOST = local.pg_host
|
|
||||||
PGPORT = "5432"
|
|
||||||
PGDATABASE = local.pg_database
|
|
||||||
PGUSER = local.pg_username
|
|
||||||
PGPASSWORD = local.pg_password
|
|
||||||
PGSSLMODE = "require"
|
|
||||||
}
|
|
||||||
|
|
||||||
source_dir = "${path.module}/code/pg-info"
|
|
||||||
|
|
||||||
depends_on = [sless_job.postgres_table_init_job]
|
|
||||||
}
|
|
||||||
|
|
||||||
resource "sless_trigger" "pg_info_http" {
|
|
||||||
name = "pg-info-http"
|
|
||||||
type = "http"
|
|
||||||
function = sless_function.pg_info.name
|
|
||||||
enabled = true
|
|
||||||
}
|
|
||||||
|
|
||||||
# HTTP-функция читает строки из terraform_demo_table и возвращает JSON.
|
|
||||||
# Использует те же credentials что и sql-runner.
|
|
||||||
# Доступна по URL: https://sless.kube5s.ru/fn/<namespace>/pg-table-reader
|
|
||||||
resource "sless_function" "postgres_table_reader" {
|
|
||||||
name = "pg-table-reader"
|
|
||||||
runtime = "python3.11"
|
|
||||||
entrypoint = "table_reader.list_rows"
|
|
||||||
memory_mb = 128
|
|
||||||
timeout_sec = 30
|
|
||||||
|
|
||||||
env_vars = {
|
|
||||||
PGHOST = local.pg_host
|
|
||||||
PGPORT = "5432"
|
|
||||||
PGDATABASE = local.pg_database
|
|
||||||
PGUSER = local.pg_username
|
|
||||||
PGPASSWORD = local.pg_password
|
|
||||||
PGSSLMODE = "require"
|
|
||||||
}
|
|
||||||
|
|
||||||
source_dir = "${path.module}/code/table-reader"
|
|
||||||
|
|
||||||
depends_on = [sless_job.postgres_table_init_job]
|
|
||||||
}
|
|
||||||
|
|
||||||
resource "sless_trigger" "postgres_table_reader_http" {
|
|
||||||
name = "pg-table-reader-http"
|
|
||||||
type = "http"
|
|
||||||
function = sless_function.postgres_table_reader.name
|
|
||||||
enabled = true
|
|
||||||
}
|
|
||||||
|
|
||||||
output "table_reader_url" {
|
|
||||||
value = sless_trigger.postgres_table_reader_http.url
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
# 2026-03-18 — debug pod для проверки psql-соединения из namespace функций.
|
|
||||||
# Запускается разово. Подключается к тому же postgres, что и sless_function.
|
|
||||||
# kubectl apply -f /tmp/pg-debug-pod.yaml
|
|
||||||
# kubectl logs -n sless-fn-sless-ffd1f598c169b0ae pg-debug-pod
|
|
||||||
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Pod
|
|
||||||
metadata:
|
|
||||||
name: pg-debug-pod
|
|
||||||
namespace: sless-fn-sless-ffd1f598c169b0ae
|
|
||||||
labels:
|
|
||||||
purpose: debug-postgres-connectivity
|
|
||||||
spec:
|
|
||||||
restartPolicy: Never
|
|
||||||
containers:
|
|
||||||
- name: psql
|
|
||||||
image: postgres:17-alpine
|
|
||||||
command:
|
|
||||||
- sh
|
|
||||||
- -c
|
|
||||||
- |
|
|
||||||
echo "=== Testing TCP connectivity to postgres ==="
|
|
||||||
nc -zv -w5 $PGHOST 5432 && echo "TCP OK" || echo "TCP FAILED"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== Testing psql connection ==="
|
|
||||||
PGCONNECT_TIMEOUT=10 psql \
|
|
||||||
"host=$PGHOST port=$PGPORT dbname=$PGDATABASE user=$PGUSER sslmode=$PGSSLMODE" \
|
|
||||||
--command="SELECT current_user, current_database(), version();" \
|
|
||||||
2>&1
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== Listing tables ==="
|
|
||||||
PGCONNECT_TIMEOUT=10 psql \
|
|
||||||
"host=$PGHOST port=$PGPORT dbname=$PGDATABASE user=$PGUSER sslmode=$PGSSLMODE" \
|
|
||||||
--command="\dt" \
|
|
||||||
2>&1
|
|
||||||
env:
|
|
||||||
- name: PGHOST
|
|
||||||
value: "postgresqlk8s-master.36875359-dcea-48c4-a593-b4531f20fe96.svc.cluster.local"
|
|
||||||
- name: PGPORT
|
|
||||||
value: "5432"
|
|
||||||
- name: PGDATABASE
|
|
||||||
value: "db_terra"
|
|
||||||
- name: PGUSER
|
|
||||||
value: "u-user0"
|
|
||||||
- name: PGPASSWORD
|
|
||||||
# Актуальный пароль из vault_secrets (совпадает с tfvars.pg_password на 2026-03-18)
|
|
||||||
value: "M03O6fRsngWcVHB2YGivyLfbfxoii2R21nyh2A2r7WSZS5deLwBgLKkc9Wk24Zyl"
|
|
||||||
- name: PGSSLMODE
|
|
||||||
value: "require"
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# 2026-03-17 13:05
|
|
||||||
# read_pg_user_secret.py — читает пароль пользователя managed PostgreSQL из k8s Secret.
|
|
||||||
# Используется из Terraform external data source, чтобы apply сам получал актуальный пароль
|
|
||||||
# даже для уже существующего пользователя, созданного вне текущего state.
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
# Читаем query от Terraform external provider из stdin.
|
|
||||||
query = json.load(sys.stdin)
|
|
||||||
namespace = query["namespace"]
|
|
||||||
secret_name = query["secret"]
|
|
||||||
|
|
||||||
# kubectl уже настроен на удалённой машине; читаем ровно поле data.password.
|
|
||||||
result = subprocess.run(
|
|
||||||
[
|
|
||||||
"kubectl",
|
|
||||||
"get",
|
|
||||||
"secret",
|
|
||||||
"-n",
|
|
||||||
namespace,
|
|
||||||
secret_name,
|
|
||||||
"-o",
|
|
||||||
"jsonpath={.data.password}",
|
|
||||||
],
|
|
||||||
check=True,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
password = base64.b64decode(result.stdout.strip()).decode()
|
|
||||||
json.dump({"password": password}, sys.stdout)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
+7
-7
@@ -12,7 +12,7 @@
|
|||||||
| `sless_trigger` | Публикует функцию: тип `http` создаёт публичный URL, тип `cron` — запуск по расписанию. |
|
| `sless_trigger` | Публикует функцию: тип `http` создаёт публичный URL, тип `cron` — запуск по расписанию. |
|
||||||
| `sless_job` | Запускает функцию однократно и ожидает завершения. Используется для одноразовых операций: инициализация БД, миграции, пакетная обработка. |
|
| `sless_job` | Запускает функцию однократно и ожидает завершения. Используется для одноразовых операций: инициализация БД, миграции, пакетная обработка. |
|
||||||
|
|
||||||
Стандартная связка для HTTP API: `sless_function` + `sless_trigger` с `type = "http"` — в результате функция доступна по URL вида `https://sless.kube5s.ru/fn/<namespace>/<имя-функции>`.
|
Стандартная связка для HTTP API: `sless_function` + `sless_trigger` с `type = "http"` — в результате функция доступна по URL вида `https://sless-api.kube5s.ru/fn/<namespace>/<имя-функции>`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
|
|
||||||
- Terraform >= 1.0
|
- Terraform >= 1.0
|
||||||
- JWT-токен для аутентификации в sless API
|
- JWT-токен для аутентификации в sless API
|
||||||
- Доступ к `https://sless.kube5s.ru`
|
- Доступ к `https://sless-api.kube5s.ru`
|
||||||
|
|
||||||
## Конфигурация провайдера
|
## Конфигурация провайдера
|
||||||
|
|
||||||
@@ -28,9 +28,9 @@
|
|||||||
|
|
||||||
```hcl
|
```hcl
|
||||||
provider "sless" {
|
provider "sless" {
|
||||||
endpoint = "https://sless.kube5s.ru"
|
endpoint = "https://sless-api.kube5s.ru"
|
||||||
token = var.token
|
token = var.token
|
||||||
nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1"
|
nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ terraform init
|
|||||||
terraform apply -auto-approve
|
terraform apply -auto-approve
|
||||||
|
|
||||||
# Вызов HTTP-функции с передачей имени:
|
# Вызов HTTP-функции с передачей имени:
|
||||||
curl -s -X POST https://sless.kube5s.ru/fn/<namespace>/hello-http \
|
curl -s -X POST https://sless-api.kube5s.ru/fn/<namespace>/hello-http \
|
||||||
-H 'Content-Type: application/json' -d '{"name":"World"}'
|
-H 'Content-Type: application/json' -d '{"name":"World"}'
|
||||||
|
|
||||||
# Результат задания:
|
# Результат задания:
|
||||||
@@ -114,7 +114,7 @@ terraform init
|
|||||||
terraform apply -auto-approve
|
terraform apply -auto-approve
|
||||||
|
|
||||||
terraform output job_result
|
terraform output job_result
|
||||||
curl -s https://sless.kube5s.ru/fn/<namespace>/simple-py-time-display
|
curl -s https://sless-api.kube5s.ru/fn/<namespace>/simple-py-time-display
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -127,7 +127,7 @@ terraform init
|
|||||||
terraform apply -auto-approve
|
terraform apply -auto-approve
|
||||||
|
|
||||||
terraform output job_result
|
terraform output job_result
|
||||||
curl -s https://sless.kube5s.ru/fn/<namespace>/simple-node-time-display
|
curl -s https://sless-api.kube5s.ru/fn/<namespace>/simple-node-time-display
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
# Изменено: 2026-03-14
|
|
||||||
# Функция event-cleaner: удаляет N самых старых строк из таблицы events.
|
|
||||||
# Вызывается через HTTP POST из Node-RED (который слушает RabbitMQ).
|
|
||||||
# Env: POSTGRES_DSN — строка подключения к PostgreSQL.
|
|
||||||
|
|
||||||
import os
|
|
||||||
import json
|
|
||||||
import psycopg2
|
|
||||||
|
|
||||||
def handle(request):
|
|
||||||
"""Удаляет N старейших строк из таблицы events."""
|
|
||||||
dsn = os.environ["POSTGRES_DSN"]
|
|
||||||
|
|
||||||
body = {}
|
|
||||||
if request.get_data():
|
|
||||||
try:
|
|
||||||
body = json.loads(request.get_data())
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Количество строк для удаления — из тела запроса или дефолт 10
|
|
||||||
delete_n = int(body.get("delete_n", 10))
|
|
||||||
# Защита от случайного удаления слишком большого количества строк
|
|
||||||
delete_n = min(delete_n, 100)
|
|
||||||
|
|
||||||
conn = psycopg2.connect(dsn)
|
|
||||||
try:
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
cur.execute("""
|
|
||||||
DELETE FROM events
|
|
||||||
WHERE id IN (
|
|
||||||
SELECT id FROM events ORDER BY created_at ASC LIMIT %s
|
|
||||||
)
|
|
||||||
""", (delete_n,))
|
|
||||||
deleted = cur.rowcount
|
|
||||||
cur.execute("SELECT COUNT(*) FROM events")
|
|
||||||
remaining = cur.fetchone()[0]
|
|
||||||
conn.commit()
|
|
||||||
return json.dumps({
|
|
||||||
"ok": True,
|
|
||||||
"deleted": deleted,
|
|
||||||
"remaining": remaining
|
|
||||||
}), 200, {"Content-Type": "application/json"}
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# Изменено: 2026-03-14
|
|
||||||
# Функция event-monitor: считает строки в events.
|
|
||||||
# Если больше 50 — публикует сообщение в RabbitMQ queue "cleanup-needed".
|
|
||||||
# Запускается по cron (каждую минуту).
|
|
||||||
# Env:
|
|
||||||
# POSTGRES_DSN — строка подключения к PostgreSQL
|
|
||||||
# RABBITMQ_URL — amqp://sless:sless123@rabbitmq.sless.svc.cluster.local:5672/
|
|
||||||
|
|
||||||
import os
|
|
||||||
import json
|
|
||||||
import psycopg2
|
|
||||||
import pika
|
|
||||||
|
|
||||||
THRESHOLD = 50
|
|
||||||
|
|
||||||
def handle(request):
|
|
||||||
"""Мониторит таблицу events. При переполнении шлёт в RabbitMQ."""
|
|
||||||
dsn = os.environ["POSTGRES_DSN"]
|
|
||||||
rabbit_url = os.environ["RABBITMQ_URL"]
|
|
||||||
|
|
||||||
conn = psycopg2.connect(dsn)
|
|
||||||
try:
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
# Считаем количество событий
|
|
||||||
cur.execute("SELECT COUNT(*) FROM events")
|
|
||||||
count = cur.fetchone()[0]
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
result = {"count": count, "threshold": THRESHOLD, "action": "none"}
|
|
||||||
|
|
||||||
if count > THRESHOLD:
|
|
||||||
# Публикуем в очередь — event-cleaner получит и удалит старые строки
|
|
||||||
params = pika.URLParameters(rabbit_url)
|
|
||||||
connection = pika.BlockingConnection(params)
|
|
||||||
channel = connection.channel()
|
|
||||||
channel.queue_declare(queue="cleanup-needed", durable=True)
|
|
||||||
channel.basic_publish(
|
|
||||||
exchange="",
|
|
||||||
routing_key="cleanup-needed",
|
|
||||||
body=json.dumps({"count": count, "delete_n": 10}),
|
|
||||||
properties=pika.BasicProperties(delivery_mode=2) # persistent
|
|
||||||
)
|
|
||||||
connection.close()
|
|
||||||
result["action"] = "cleanup_requested"
|
|
||||||
|
|
||||||
return json.dumps(result), 200, {"Content-Type": "application/json"}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
# Изменено: 2026-03-14
|
|
||||||
# Функция event-writer: принимает HTTP POST, пишет одну строку в таблицу events.
|
|
||||||
# Таблица создаётся автоматически при первом запуске.
|
|
||||||
# Env: POSTGRES_DSN — строка подключения к PostgreSQL.
|
|
||||||
|
|
||||||
import os
|
|
||||||
import json
|
|
||||||
import psycopg2
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
def handle(request):
|
|
||||||
"""Записывает одно событие в таблицу events."""
|
|
||||||
dsn = os.environ["POSTGRES_DSN"]
|
|
||||||
|
|
||||||
body = {}
|
|
||||||
if request.get_data():
|
|
||||||
try:
|
|
||||||
body = json.loads(request.get_data())
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
source = body.get("source", "node-red")
|
|
||||||
message = body.get("message", "ping")
|
|
||||||
|
|
||||||
conn = psycopg2.connect(dsn)
|
|
||||||
try:
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
# Создаём таблицу если нет — безопасно вызывать при каждом запросе
|
|
||||||
cur.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS events (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
source VARCHAR(100) NOT NULL DEFAULT 'unknown',
|
|
||||||
message TEXT NOT NULL DEFAULT '',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
cur.execute(
|
|
||||||
"INSERT INTO events (source, message) VALUES (%s, %s) RETURNING id, created_at",
|
|
||||||
(source, message)
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
conn.commit()
|
|
||||||
return json.dumps({
|
|
||||||
"ok": True,
|
|
||||||
"id": row[0],
|
|
||||||
"created_at": row[1].isoformat()
|
|
||||||
}), 200, {"Content-Type": "application/json"}
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
# Примеры использования sless
|
|
||||||
|
|
||||||
## Обзор платформы
|
|
||||||
|
|
||||||
**sless** — система управления serverless-функциями на базе Kubernetes. Разработчик загружает код функции, платформа собирает из него Docker-образ, разворачивает его в кластере и предоставляет HTTP-эндпоинт для вызова. Всё описывается декларативно через Terraform.
|
|
||||||
|
|
||||||
### Основные ресурсы провайдера
|
|
||||||
|
|
||||||
| Ресурс | Назначение |
|
|
||||||
|---|---|
|
|
||||||
| `sless_function` | Описывает функцию: язык, точку входа, лимиты, переменные окружения. При создании загружает код и запускает его сборку в образ. Сама по себе недоступна снаружи — нужен триггер или задание. |
|
|
||||||
| `sless_trigger` | Публикует функцию: тип `http` создаёт публичный URL, тип `cron` — запуск по расписанию. |
|
|
||||||
| `sless_job` | Запускает функцию однократно и ожидает завершения. Используется для одноразовых операций: инициализация БД, миграции, пакетная обработка. |
|
|
||||||
|
|
||||||
Стандартная связка для HTTP API: `sless_function` + `sless_trigger` с `type = "http"` — в результате функция доступна по URL вида `https://sless.kube5s.ru/fn/<namespace>/<имя-функции>`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Требования
|
|
||||||
|
|
||||||
- Terraform >= 1.0
|
|
||||||
- JWT-токен для аутентификации в sless API
|
|
||||||
- Доступ к `https://sless.kube5s.ru`
|
|
||||||
|
|
||||||
## Конфигурация провайдера
|
|
||||||
|
|
||||||
Во всех примерах файл `main.tf` содержит блок провайдера. Токен передаётся через переменную, значение которой задаётся в `terraform.tfvars`:
|
|
||||||
|
|
||||||
```hcl
|
|
||||||
provider "sless" {
|
|
||||||
endpoint = "https://sless.kube5s.ru"
|
|
||||||
token = var.token
|
|
||||||
nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Namespace функций вычисляется автоматически из JWT-токена: `sless-{sha256[:8]}`.
|
|
||||||
|
|
||||||
> **Перед запуском любого примера** откройте файл `terraform.tfvars` в директории примера и впишите свой токен Nubes API:
|
|
||||||
> ```hcl
|
|
||||||
> token = "ваш токен Nubes API"
|
|
||||||
> ```
|
|
||||||
> Токен выдаётся в личном кабинете Nubes. Файл `terraform.tfvars` добавлен в `.gitignore` — он не попадёт в репозиторий.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Примеры
|
|
||||||
|
|
||||||
### `hello-node` — минимальный пример на Node.js
|
|
||||||
|
|
||||||
Две независимые функции: HTTP-функция, возвращающая приветствие, и одноразовое задание, суммирующее набор чисел. Хорошая отправная точка для знакомства с платформой.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd hello-node
|
|
||||||
terraform init
|
|
||||||
terraform apply -auto-approve
|
|
||||||
|
|
||||||
# Вызов HTTP-функции с передачей имени:
|
|
||||||
curl -s -X POST https://sless.kube5s.ru/fn/<namespace>/hello-http \
|
|
||||||
-H 'Content-Type: application/json' -d '{"name":"World"}'
|
|
||||||
|
|
||||||
# Результат задания:
|
|
||||||
terraform output job_message
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `hello-go` — минимальный пример на Go 1.23
|
|
||||||
|
|
||||||
Аналог `hello-node`, но на Go. Демонстрирует поддержку Go-рантайма: HTTP-функция и одноразовое задание. Код пользователя оформляется как пакет `handler` с функцией `Handle(event)`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd hello-go
|
|
||||||
terraform init
|
|
||||||
terraform apply -auto-approve
|
|
||||||
|
|
||||||
terraform output job_message
|
|
||||||
terraform output trigger_url
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `pg-list-python` — выборка данных из PostgreSQL (Python)
|
|
||||||
|
|
||||||
Минимальный пример работы с базой данных: одна HTTP-функция читает список записей из таблицы PostgreSQL и возвращает их в JSON. Таблица с тестовыми данными создаётся автоматически при первом вызове. Нет заданий, нет инициализации — только функция и триггер.
|
|
||||||
|
|
||||||
**Переменные:**
|
|
||||||
|
|
||||||
| Переменная | Описание | Значение по умолчанию |
|
|
||||||
|---|---|---|
|
|
||||||
| `pg_dsn` | Строка подключения к PostgreSQL | `postgres://sless:sless-pg-password@postgres.sless.svc.cluster.local:5432/sless?sslmode=disable` |
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd pg-list-python
|
|
||||||
terraform init
|
|
||||||
terraform apply -auto-approve
|
|
||||||
|
|
||||||
# URL функции выводится после применения:
|
|
||||||
terraform output catalog_url
|
|
||||||
|
|
||||||
# Запрос к функции:
|
|
||||||
curl -s $(terraform output -raw catalog_url)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `simple-python` — одноразовое задание передаёт данные в HTTP-функцию (Python)
|
|
||||||
|
|
||||||
При `apply` выполняется задание, которое фиксирует текущее время. Результат передаётся в HTTP-функцию через переменные окружения и отображается при каждом запросе.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd simple-python
|
|
||||||
terraform init
|
|
||||||
terraform apply -auto-approve
|
|
||||||
|
|
||||||
terraform output job_result
|
|
||||||
curl -s https://sless.kube5s.ru/fn/<namespace>/simple-py-time-display
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `simple-node` — то же самое на Node.js 20
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd simple-node
|
|
||||||
terraform init
|
|
||||||
terraform apply -auto-approve
|
|
||||||
|
|
||||||
terraform output job_result
|
|
||||||
curl -s https://sless.kube5s.ru/fn/<namespace>/simple-node-time-display
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `notes-python` — CRUD API на Python с PostgreSQL
|
|
||||||
|
|
||||||
Полноценное приложение: инициализация схемы базы данных через задания, CRUD-функция для работы с записями, отдельная функция для получения списка.
|
|
||||||
|
|
||||||
**Переменные:**
|
|
||||||
|
|
||||||
| Переменная | Описание | Значение по умолчанию |
|
|
||||||
|---|---|---|
|
|
||||||
| `pg_dsn` | Строка подключения к PostgreSQL | `postgres://sless:sless-pg-password@postgres.sless.svc.cluster.local:5432/sless?sslmode=disable` |
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd notes-python
|
|
||||||
terraform init
|
|
||||||
terraform apply -auto-approve
|
|
||||||
|
|
||||||
# Статус инициализации базы данных:
|
|
||||||
terraform output db_init_table_status
|
|
||||||
terraform output db_init_index_status
|
|
||||||
|
|
||||||
# Создать запись:
|
|
||||||
curl -s -X POST "$(terraform output -raw notes_url)/add?title=Hello&body=World"
|
|
||||||
|
|
||||||
# Получить список записей:
|
|
||||||
curl -s $(terraform output -raw notes_list_url)
|
|
||||||
|
|
||||||
# Обновить запись (id из предыдущего ответа):
|
|
||||||
curl -s -X POST "$(terraform output -raw notes_url)/update?id=1&title=Updated&body=New+body"
|
|
||||||
|
|
||||||
# Удалить запись:
|
|
||||||
curl -s -X POST "$(terraform output -raw notes_url)/delete?id=1"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Полезные команды
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Посмотреть текущее состояние задеплоенных ресурсов:
|
|
||||||
terraform show
|
|
||||||
|
|
||||||
# Принудительно пересобрать функцию (например, после изменения кода):
|
|
||||||
terraform apply -replace=sless_function.<имя> -auto-approve
|
|
||||||
|
|
||||||
# Повторно запустить задание: увеличить значение run_id в .tf-файле, затем:
|
|
||||||
terraform apply -auto-approve
|
|
||||||
|
|
||||||
# Удалить все ресурсы примера:
|
|
||||||
terraform destroy -auto-approve
|
|
||||||
```
|
|
||||||
|
|
||||||
## Структура примера
|
|
||||||
|
|
||||||
```
|
|
||||||
<пример>/
|
|
||||||
├── main.tf — конфигурация провайдера
|
|
||||||
├── *.tf — ресурсы: функции, триггеры, задания
|
|
||||||
├── variables.tf — входные переменные
|
|
||||||
├── terraform.tfvars — значения переменных (не коммитится в git)
|
|
||||||
└── code/ — исходный код функций
|
|
||||||
```
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# Изменено: 2026-03-14
|
|
||||||
# event-cleaner: HTTP функция для демонстрации контролируемого изменения логики.
|
|
||||||
# Работает без внешних пакетов.
|
|
||||||
|
|
||||||
import json
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
|
|
||||||
def event_cleaner_handle(event_cleaner_event):
|
|
||||||
"""Принимает delete_n и возвращает подтверждение обработки."""
|
|
||||||
|
|
||||||
event_cleaner_payload = event_cleaner_event if isinstance(event_cleaner_event, dict) else {}
|
|
||||||
|
|
||||||
event_cleaner_delete_n = int(event_cleaner_payload.get("delete_n", 10))
|
|
||||||
event_cleaner_delete_n = max(1, min(event_cleaner_delete_n, 100))
|
|
||||||
|
|
||||||
event_cleaner_generated_at = datetime.now(timezone.utc).isoformat()
|
|
||||||
# Здесь intentionally имитируем очистку, чтобы показать реакцию на входные параметры.
|
|
||||||
return json.dumps(
|
|
||||||
{
|
|
||||||
"ok": True,
|
|
||||||
"accepted_delete_n": event_cleaner_delete_n,
|
|
||||||
"status": "simulated-cleanup",
|
|
||||||
"generated_at": event_cleaner_generated_at,
|
|
||||||
}
|
|
||||||
), 200, {"Content-Type": "application/json"}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# Изменено: 2026-03-14
|
|
||||||
# event-monitor: cron-функция для демонстрации расписания и управления кодом.
|
|
||||||
# Работает без внешних библиотек и возвращает диагностический JSON.
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
|
|
||||||
EVENT_MONITOR_THRESHOLD = 50
|
|
||||||
|
|
||||||
|
|
||||||
def event_monitor_handle(event_monitor_event):
|
|
||||||
"""Отдаёт heartbeat для cron-запуска и видимой проверки после apply."""
|
|
||||||
event_monitor_rabbitmq_url = os.environ.get("RABBITMQ_URL", "not-set")
|
|
||||||
event_monitor_generated_at = datetime.now(timezone.utc).isoformat()
|
|
||||||
return json.dumps(
|
|
||||||
{
|
|
||||||
"ok": True,
|
|
||||||
"monitor": "alive",
|
|
||||||
"threshold": EVENT_MONITOR_THRESHOLD,
|
|
||||||
"rabbitmq_configured": event_monitor_rabbitmq_url != "not-set",
|
|
||||||
"generated_at": event_monitor_generated_at,
|
|
||||||
}
|
|
||||||
), 200, {"Content-Type": "application/json"}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
# Изменено: 2026-03-14
|
|
||||||
# event-writer: простая HTTP функция без внешних зависимостей.
|
|
||||||
# Правка поля response_tag в коде сразу видна в ответе после terraform apply.
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
|
|
||||||
def event_writer_handle(event_writer_event):
|
|
||||||
"""Возвращает полезный JSON-ответ для визуальной проверки выката кода."""
|
|
||||||
writer_default_message = os.environ.get("DEFAULT_MESSAGE", "writer-default")
|
|
||||||
|
|
||||||
writer_payload = event_writer_event if isinstance(event_writer_event, dict) else {}
|
|
||||||
|
|
||||||
writer_message_value = writer_payload.get("message", writer_default_message)
|
|
||||||
writer_source_name = writer_payload.get("source", "event-writer")
|
|
||||||
writer_generated_at = datetime.now(timezone.utc).isoformat()
|
|
||||||
|
|
||||||
# response_tag удобно менять для демонстрации hot-update кода через terraform apply.
|
|
||||||
writer_response_tag = "writer-v2"
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"source": writer_source_name,
|
|
||||||
"message": writer_message_value,
|
|
||||||
"response_tag": writer_response_tag,
|
|
||||||
"generated_at": writer_generated_at,
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
# 2026-03-14
|
|
||||||
# function.tf — ресурсы managed serverless функций для демонстрации.
|
|
||||||
# Пользователь правит код в code/* и запускает terraform apply — провайдер сам пересобирает и выкатывает функции.
|
|
||||||
|
|
||||||
resource "sless_function" "event_writer" {
|
|
||||||
name = "event-writer"
|
|
||||||
runtime = "python3.11"
|
|
||||||
entrypoint = "event_writer_handler.event_writer_handle"
|
|
||||||
memory_mb = 128
|
|
||||||
timeout_sec = 20
|
|
||||||
|
|
||||||
source_dir = "${path.module}/code/event-writer"
|
|
||||||
|
|
||||||
env_vars = {
|
|
||||||
POSTGRES_DSN = var.pg_dsn
|
|
||||||
DEFAULT_MESSAGE = var.writer_message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resource "sless_trigger" "event_writer_http" {
|
|
||||||
name = "event-writer-http"
|
|
||||||
type = "http"
|
|
||||||
function = sless_function.event_writer.name
|
|
||||||
enabled = true
|
|
||||||
}
|
|
||||||
|
|
||||||
resource "sless_function" "event_monitor" {
|
|
||||||
name = "event-monitor"
|
|
||||||
runtime = "python3.11"
|
|
||||||
entrypoint = "event_monitor_handler.event_monitor_handle"
|
|
||||||
memory_mb = 128
|
|
||||||
timeout_sec = 20
|
|
||||||
|
|
||||||
source_dir = "${path.module}/code/event-monitor"
|
|
||||||
|
|
||||||
env_vars = {
|
|
||||||
POSTGRES_DSN = var.pg_dsn
|
|
||||||
RABBITMQ_URL = var.rabbitmq_url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resource "sless_trigger" "event_monitor_cron" {
|
|
||||||
name = "event-monitor-cron"
|
|
||||||
type = "cron"
|
|
||||||
function = sless_function.event_monitor.name
|
|
||||||
enabled = true
|
|
||||||
schedule = "*/1 * * * *"
|
|
||||||
}
|
|
||||||
|
|
||||||
resource "sless_function" "event_cleaner" {
|
|
||||||
name = "event-cleaner"
|
|
||||||
runtime = "python3.11"
|
|
||||||
entrypoint = "event_cleaner_handler.event_cleaner_handle"
|
|
||||||
memory_mb = 128
|
|
||||||
timeout_sec = 20
|
|
||||||
|
|
||||||
source_dir = "${path.module}/code/event-cleaner"
|
|
||||||
|
|
||||||
env_vars = {
|
|
||||||
POSTGRES_DSN = var.pg_dsn
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resource "sless_trigger" "event_cleaner_http" {
|
|
||||||
name = "event-cleaner-http"
|
|
||||||
type = "http"
|
|
||||||
function = sless_function.event_cleaner.name
|
|
||||||
enabled = true
|
|
||||||
}
|
|
||||||
|
|
||||||
output "event_writer_url" {
|
|
||||||
value = sless_trigger.event_writer_http.url
|
|
||||||
}
|
|
||||||
|
|
||||||
output "event_cleaner_url" {
|
|
||||||
value = sless_trigger.event_cleaner_http.url
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# 2026-03-14
|
|
||||||
# Terraform demo: managed serverless functions.
|
|
||||||
# Здесь управляем ТОЛЬКО функциями/триггерами, внешние сервисы считаем уже поднятыми.
|
|
||||||
|
|
||||||
terraform {
|
|
||||||
required_providers {
|
|
||||||
sless = {
|
|
||||||
source = "terra.k8c.ru/naeel/sless"
|
|
||||||
version = "~> 0.1.18"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
provider "sless" {
|
|
||||||
endpoint = var.sless_endpoint
|
|
||||||
token = var.token
|
|
||||||
nubes_endpoint = var.nubes_endpoint
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# 2026-03-14
|
|
||||||
# Скопируй в terraform.tfvars и подставь актуальный токен.
|
|
||||||
|
|
||||||
token = "PUT_TOKEN_HERE"
|
|
||||||
sless_endpoint = "https://sless-api.kube5s.ru"
|
|
||||||
nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1"
|
|
||||||
|
|
||||||
pg_dsn = "postgres://sless:sless-pg-password@postgres.sless.svc.cluster.local:5432/sless?sslmode=disable"
|
|
||||||
rabbitmq_url = "amqp://sless:sless123@rabbitmq.sless.svc.cluster.local:5672/"
|
|
||||||
|
|
||||||
writer_message = "writer-default-v1"
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# 2026-03-14
|
|
||||||
# Переменные для demo-managed-functions.
|
|
||||||
|
|
||||||
variable "token" {
|
|
||||||
description = "JWT токен API"
|
|
||||||
type = string
|
|
||||||
sensitive = true
|
|
||||||
}
|
|
||||||
|
|
||||||
variable "sless_endpoint" {
|
|
||||||
description = "Endpoint sless API"
|
|
||||||
type = string
|
|
||||||
default = "https://sless.kube5s.ru"
|
|
||||||
}
|
|
||||||
|
|
||||||
variable "nubes_endpoint" {
|
|
||||||
description = "Nubes endpoint (нужен провайдеру)"
|
|
||||||
type = string
|
|
||||||
default = "https://deck-api-test.ngcloud.ru/api/v1"
|
|
||||||
}
|
|
||||||
|
|
||||||
variable "pg_dsn" {
|
|
||||||
description = "PostgreSQL DSN для функций"
|
|
||||||
type = string
|
|
||||||
default = "postgres://sless:sless-pg-password@postgres.sless.svc.cluster.local:5432/sless?sslmode=disable"
|
|
||||||
}
|
|
||||||
|
|
||||||
variable "rabbitmq_url" {
|
|
||||||
description = "RabbitMQ URL для функций"
|
|
||||||
type = string
|
|
||||||
default = "amqp://sless:sless123@rabbitmq.sless.svc.cluster.local:5672/"
|
|
||||||
}
|
|
||||||
|
|
||||||
variable "writer_message" {
|
|
||||||
description = "Сообщение по умолчанию, которое пишет event-writer"
|
|
||||||
type = string
|
|
||||||
default = "writer-default-v1"
|
|
||||||
}
|
|
||||||
@@ -5,13 +5,13 @@ terraform {
|
|||||||
required_providers {
|
required_providers {
|
||||||
sless = {
|
sless = {
|
||||||
source = "terra.k8c.ru/naeel/sless"
|
source = "terra.k8c.ru/naeel/sless"
|
||||||
version = "~> 0.1.18"
|
version = "~> 0.1.17"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
provider "sless" {
|
provider "sless" {
|
||||||
endpoint = "https://sless.kube5s.ru"
|
endpoint = "https://sless-api.kube5s.ru"
|
||||||
token = var.token
|
token = var.token
|
||||||
nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1"
|
nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,14 +11,14 @@ terraform {
|
|||||||
required_providers {
|
required_providers {
|
||||||
sless = {
|
sless = {
|
||||||
source = "terra.k8c.ru/naeel/sless"
|
source = "terra.k8c.ru/naeel/sless"
|
||||||
version = "~> 0.1.18"
|
version = "~> 0.1.17"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
provider "sless" {
|
provider "sless" {
|
||||||
endpoint = "https://sless.kube5s.ru"
|
endpoint = "https://sless-api.kube5s.ru"
|
||||||
token = var.token
|
token = var.token
|
||||||
nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1"
|
nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,14 +14,14 @@ terraform {
|
|||||||
# Провайдер для управления serverless функциями через sless API
|
# Провайдер для управления serverless функциями через sless API
|
||||||
sless = {
|
sless = {
|
||||||
source = "terra.k8c.ru/naeel/sless"
|
source = "terra.k8c.ru/naeel/sless"
|
||||||
version = "~> 0.1.18"
|
version = "~> 0.1.17"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# sless провайдер подключается к API кластера.
|
# sless провайдер подключается к API кластера.
|
||||||
provider "sless" {
|
provider "sless" {
|
||||||
endpoint = "https://sless.kube5s.ru"
|
endpoint = "https://sless-api.kube5s.ru"
|
||||||
token = var.token
|
token = var.token
|
||||||
nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1"
|
nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ terraform {
|
|||||||
required_providers {
|
required_providers {
|
||||||
sless = {
|
sless = {
|
||||||
source = "terra.k8c.ru/naeel/sless"
|
source = "terra.k8c.ru/naeel/sless"
|
||||||
version = "~> 0.1.18"
|
version = "~> 0.1.17"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
provider "sless" {
|
provider "sless" {
|
||||||
endpoint = "https://sless.kube5s.ru"
|
endpoint = "https://sless-api.kube5s.ru"
|
||||||
token = var.token
|
token = var.token
|
||||||
nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1"
|
nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1"
|
||||||
|
ai_hint_level = 3
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,13 +19,13 @@ terraform {
|
|||||||
required_providers {
|
required_providers {
|
||||||
sless = {
|
sless = {
|
||||||
source = "terra.k8c.ru/naeel/sless"
|
source = "terra.k8c.ru/naeel/sless"
|
||||||
version = "~> 0.1.18"
|
version = "~> 0.1.17"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
provider "sless" {
|
provider "sless" {
|
||||||
endpoint = "https://sless.kube5s.ru"
|
endpoint = "https://sless-api.kube5s.ru"
|
||||||
token = var.token
|
token = var.token
|
||||||
nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1"
|
nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,13 +18,13 @@ terraform {
|
|||||||
required_providers {
|
required_providers {
|
||||||
sless = {
|
sless = {
|
||||||
source = "terra.k8c.ru/naeel/sless"
|
source = "terra.k8c.ru/naeel/sless"
|
||||||
version = "~> 0.1.18"
|
version = "~> 0.1.17"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
provider "sless" {
|
provider "sless" {
|
||||||
endpoint = "https://sless.kube5s.ru"
|
endpoint = "https://sless-api.kube5s.ru"
|
||||||
token = var.token
|
token = var.token
|
||||||
nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1"
|
nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,4 +1,4 @@
|
|||||||
// Изменено: 2026-03-18 (добавлены created_at, last_built_at в functionResponse и fnToResponse)
|
// Изменено: 2026-03-11
|
||||||
// functions.go — CRUD handlers для Function CRD.
|
// functions.go — CRUD handlers для Function CRD.
|
||||||
// Принимает JSON, создаёт/обновляет/удаляет k8s ресурсы Function.
|
// Принимает JSON, создаёт/обновляет/удаляет k8s ресурсы Function.
|
||||||
// Namespace берётся из URL: /v1/namespaces/{namespace}/functions/{name}
|
// Namespace берётся из URL: /v1/namespaces/{namespace}/functions/{name}
|
||||||
@@ -30,29 +30,23 @@ type functionRequest struct {
|
|||||||
|
|
||||||
// functionResponse — ответ при чтении функции.
|
// functionResponse — ответ при чтении функции.
|
||||||
type functionResponse struct {
|
type functionResponse struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Namespace string `json:"namespace"`
|
Namespace string `json:"namespace"`
|
||||||
Runtime string `json:"runtime"`
|
Runtime string `json:"runtime"`
|
||||||
Entrypoint string `json:"entrypoint"`
|
Entrypoint string `json:"entrypoint"`
|
||||||
MemoryMB int32 `json:"memory_mb"`
|
MemoryMB int32 `json:"memory_mb"`
|
||||||
TimeoutSec int32 `json:"timeout_sec"`
|
TimeoutSec int32 `json:"timeout_sec"`
|
||||||
Env map[string]string `json:"env_vars"`
|
Env map[string]string `json:"env_vars"`
|
||||||
S3Bucket string `json:"s3_bucket"`
|
S3Bucket string `json:"s3_bucket"`
|
||||||
S3Key string `json:"s3_key"`
|
S3Key string `json:"s3_key"`
|
||||||
Phase slessv1alpha1.FunctionPhase `json:"phase"`
|
Phase slessv1alpha1.FunctionPhase `json:"phase"`
|
||||||
ImageRef string `json:"image_ref"`
|
ImageRef string `json:"image_ref"`
|
||||||
Message string `json:"message,omitempty"`
|
Message string `json:"message,omitempty"`
|
||||||
// CreatedAt — время создания CRD объекта (metadata.creationTimestamp).
|
|
||||||
// Пустое значение = "0001-01-01T00:00:00Z" сериализуется в "", опускаем через omitempty.
|
|
||||||
CreatedAt string `json:"created_at,omitempty"`
|
|
||||||
// LastBuiltAt — время последней успешной сборки образа (status.lastBuiltAt).
|
|
||||||
// nil если сборки ещё не было.
|
|
||||||
LastBuiltAt string `json:"last_built_at,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// fnToResponse конвертирует CRD в ответ API.
|
// fnToResponse конвертирует CRD в ответ API.
|
||||||
func fnToResponse(fn *slessv1alpha1.Function) functionResponse {
|
func fnToResponse(fn *slessv1alpha1.Function) functionResponse {
|
||||||
resp := functionResponse{
|
return functionResponse{
|
||||||
Name: fn.Name,
|
Name: fn.Name,
|
||||||
Namespace: fn.Namespace,
|
Namespace: fn.Namespace,
|
||||||
Runtime: fn.Spec.Runtime,
|
Runtime: fn.Spec.Runtime,
|
||||||
@@ -66,14 +60,6 @@ func fnToResponse(fn *slessv1alpha1.Function) functionResponse {
|
|||||||
ImageRef: fn.Status.ImageRef,
|
ImageRef: fn.Status.ImageRef,
|
||||||
Message: fn.Status.Message,
|
Message: fn.Status.Message,
|
||||||
}
|
}
|
||||||
// creationTimestamp — всегда заполнен k8s, но zero value опускаем.
|
|
||||||
if !fn.CreationTimestamp.IsZero() {
|
|
||||||
resp.CreatedAt = fn.CreationTimestamp.UTC().Format("2006-01-02 15:04:05 UTC")
|
|
||||||
}
|
|
||||||
if fn.Status.LastBuiltAt != nil && !fn.Status.LastBuiltAt.IsZero() {
|
|
||||||
resp.LastBuiltAt = fn.Status.LastBuiltAt.UTC().Format("2006-01-02 15:04:05 UTC")
|
|
||||||
}
|
|
||||||
return resp
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListFunctions — GET /v1/namespaces/{namespace}/functions
|
// ListFunctions — GET /v1/namespaces/{namespace}/functions
|
||||||
@@ -129,20 +115,13 @@ func (h *Handler) CreateFunction(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
if err := h.K8s.Create(r.Context(), fn); err != nil {
|
if err := h.K8s.Create(r.Context(), fn); err != nil {
|
||||||
if errors.IsAlreadyExists(err) {
|
if errors.IsAlreadyExists(err) {
|
||||||
// IsAlreadyExists может прийти из кеша controller-runtime (split-brain):
|
// Если существующая функция в статусе Failed (build провалился, terraform не
|
||||||
// объект удалён из etcd, но кеш informer ещё южив. Делаем uncached Get:
|
// добавил её в state) — удаляем её и пересоздаём, иначе клиент получит 409 навсегда.
|
||||||
// если реально NotFound — кеш устарел, пересоздаём.
|
|
||||||
// если существует и фаза Failed — тоже пересоздаём (build провалился, терраформ не добавил в state).
|
|
||||||
// если существует и фаза Ready/Building — возвращаем 409 (функция реально есть).
|
|
||||||
existing := &slessv1alpha1.Function{}
|
existing := &slessv1alpha1.Function{}
|
||||||
getErr := h.K8s.Get(r.Context(), client.ObjectKey{Name: req.Name, Namespace: ns}, existing)
|
if getErr := h.K8s.Get(r.Context(), client.ObjectKey{Name: req.Name, Namespace: ns}, existing); getErr == nil &&
|
||||||
shouldRecreate := errors.IsNotFound(getErr) ||
|
existing.Status.Phase == slessv1alpha1.FunctionPhaseFailed {
|
||||||
(getErr == nil && existing.Status.Phase == slessv1alpha1.FunctionPhaseFailed)
|
_ = h.K8s.Delete(r.Context(), existing)
|
||||||
if shouldRecreate {
|
// Создаём заново с теми же параметрами
|
||||||
if getErr == nil {
|
|
||||||
_ = h.K8s.Delete(r.Context(), existing)
|
|
||||||
}
|
|
||||||
// Сбрасываем ResourceVersion — при split-brain etcd считает объект новым
|
|
||||||
fn.ResourceVersion = ""
|
fn.ResourceVersion = ""
|
||||||
if createErr := h.K8s.Create(r.Context(), fn); createErr != nil {
|
if createErr := h.K8s.Create(r.Context(), fn); createErr != nil {
|
||||||
writeJSON(w, http.StatusInternalServerError, errResp(createErr.Error()))
|
writeJSON(w, http.StatusInternalServerError, errResp(createErr.Error()))
|
||||||
|
|||||||
@@ -121,10 +121,8 @@ func main() {
|
|||||||
// Builder использует k8s client из manager'а
|
// Builder использует k8s client из manager'а
|
||||||
// harborClient — nil если HARBOR_USER/HARBOR_PASS не заданы (EnsureProject пропускается).
|
// harborClient — nil если HARBOR_USER/HARBOR_PASS не заданы (EnsureProject пропускается).
|
||||||
var harborClient *harbor.Client
|
var harborClient *harbor.Client
|
||||||
var harborProjecter builder.Projecter
|
|
||||||
if cfg.HarborUser != "" && cfg.HarborPass != "" {
|
if cfg.HarborUser != "" && cfg.HarborPass != "" {
|
||||||
harborClient = harbor.New(cfg.RegistryHost, cfg.HarborUser, cfg.HarborPass)
|
harborClient = harbor.New(cfg.RegistryHost, cfg.HarborUser, cfg.HarborPass)
|
||||||
harborProjecter = harborClient
|
|
||||||
log.Info("harbor client configured", "host", cfg.RegistryHost)
|
log.Info("harbor client configured", "host", cfg.RegistryHost)
|
||||||
} else {
|
} else {
|
||||||
log.Info("harbor client disabled (HARBOR_USER/HARBOR_PASS not set)")
|
log.Info("harbor client disabled (HARBOR_USER/HARBOR_PASS not set)")
|
||||||
@@ -139,7 +137,7 @@ func main() {
|
|||||||
S3SecretKey: cfg.S3SecretKey,
|
S3SecretKey: cfg.S3SecretKey,
|
||||||
S3Bucket: cfg.S3Bucket,
|
S3Bucket: cfg.S3Bucket,
|
||||||
Namespace: "sless",
|
Namespace: "sless",
|
||||||
HarborClient: harborProjecter,
|
HarborClient: harborClient,
|
||||||
})
|
})
|
||||||
|
|
||||||
if err = (&controllers.FunctionReconciler{
|
if err = (&controllers.FunctionReconciler{
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
# 2026-03-18
|
|
||||||
# Dockerfile для sless-funcs-service.
|
|
||||||
# Многоэтапная сборка: Go → alpine (минимальный образ).
|
|
||||||
# Сервис не нуждается во внешних зависимостях — только stdlib.
|
|
||||||
|
|
||||||
FROM golang:1.23-alpine AS builder
|
|
||||||
WORKDIR /build
|
|
||||||
COPY go.mod main.go ./
|
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o funcs-service .
|
|
||||||
|
|
||||||
FROM alpine:3.20
|
|
||||||
RUN apk add --no-cache ca-certificates
|
|
||||||
COPY --from=builder /build/funcs-service /funcs-service
|
|
||||||
EXPOSE 8090
|
|
||||||
ENTRYPOINT ["/funcs-service"]
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
module sless/funcs-service
|
|
||||||
|
|
||||||
go 1.23
|
|
||||||
@@ -1,300 +0,0 @@
|
|||||||
// 2026-03-18
|
|
||||||
// main.go — глобальный HTTP сервис листинга функций пользователя.
|
|
||||||
// Развёрнут ОДИН РАЗ в namespace sless; работает для ВСЕХ пользователей.
|
|
||||||
// Не связан с terraform — деплоится манифестом deployments/k8s/funcs-service.yaml.
|
|
||||||
//
|
|
||||||
// Логика:
|
|
||||||
// 1. Принимает Authorization: Bearer {jwt} от пользователя
|
|
||||||
// 2. Извлекает sub из JWT (без проверки подписи — trusted perimeter за Ingress)
|
|
||||||
// 3. Вычисляет namespace = SHA256(sub)[:8] hex (та же логика что в операторе)
|
|
||||||
// 4. Вызывает внутренний API оператора: GET /v1/namespaces/{ns}/functions + /triggers
|
|
||||||
// 5. Возвращает plain text — человекочитаемый список
|
|
||||||
//
|
|
||||||
// Env vars:
|
|
||||||
// SLESS_OPERATOR_URL — URL оператора внутри кластера (default: http://sless-operator.sless.svc.cluster.local:9090)
|
|
||||||
// SLESS_EXTERNAL_URL — публичный базовый URL для корректных ссылок на функции
|
|
||||||
// SLESS_EXCLUDE — comma-separated список имён функций, скрытых из листинга
|
|
||||||
// PORT — порт сервера (default: 8090)
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// fnResponse — ответ /v1/namespaces/{ns}/functions (подмножество полей оператора)
|
|
||||||
type fnResponse struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Runtime string `json:"runtime"`
|
|
||||||
Phase string `json:"phase"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
LastBuiltAt string `json:"last_built_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// trResponse — ответ /v1/namespaces/{ns}/triggers
|
|
||||||
type trResponse struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
FunctionRef string `json:"function"`
|
|
||||||
Schedule string `json:"schedule"`
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
Active bool `json:"active"`
|
|
||||||
URL string `json:"url"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
operatorURL := strings.TrimRight(env("SLESS_OPERATOR_URL", "http://sless-operator.sless.svc.cluster.local:9090"), "/")
|
|
||||||
externalURL := strings.TrimRight(env("SLESS_EXTERNAL_URL", ""), "/")
|
|
||||||
port := env("PORT", "8090")
|
|
||||||
// serviceToken — токен сервиса для запросов к оператору по /funcs/<namespace> (без токена пользователя)
|
|
||||||
serviceToken := os.Getenv("SLESS_SERVICE_TOKEN")
|
|
||||||
|
|
||||||
exclude := map[string]bool{}
|
|
||||||
for _, n := range strings.Split(os.Getenv("SLESS_EXCLUDE"), ",") {
|
|
||||||
if n = strings.TrimSpace(n); n != "" {
|
|
||||||
exclude[n] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
http.HandleFunc("/funcs", handler(operatorURL, externalURL, serviceToken, exclude))
|
|
||||||
// /health — для liveness/readiness probe без Bearer токена
|
|
||||||
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
||||||
fmt.Fprintln(w, "ok")
|
|
||||||
})
|
|
||||||
// /funcs/<namespace> — без токена, namespace виден в URL всех функций
|
|
||||||
http.HandleFunc("/funcs/", handler(operatorURL, externalURL, serviceToken, exclude))
|
|
||||||
|
|
||||||
log.Printf("sless-funcs-service listening on :%s (operator: %s)", port, operatorURL)
|
|
||||||
log.Fatal(http.ListenAndServe(":"+port, nil))
|
|
||||||
}
|
|
||||||
|
|
||||||
func handler(operatorURL, externalURL, serviceToken string, exclude map[string]bool) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var namespace, authHeader string
|
|
||||||
|
|
||||||
// Вариант 1: /funcs/<namespace> — namespace прямо в URL, токен не нужен.
|
|
||||||
// Namespace виден в URL каждой функции — не является секретом.
|
|
||||||
if strings.HasPrefix(r.URL.Path, "/funcs/") {
|
|
||||||
namespace = strings.TrimPrefix(r.URL.Path, "/funcs/")
|
|
||||||
namespace = strings.Trim(namespace, "/")
|
|
||||||
if namespace == "" {
|
|
||||||
http.Error(w, "usage: /funcs/<namespace>\n", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Используем serviceToken сервиса, если задан
|
|
||||||
if serviceToken != "" {
|
|
||||||
authHeader = "Bearer " + serviceToken
|
|
||||||
}
|
|
||||||
// authHeader может остаться пустым — оператор ответит 401 если не настроен
|
|
||||||
} else {
|
|
||||||
// Вариант 2: ?token=<jwt> — токен как query param (можно bookmarkнуть URL)
|
|
||||||
token := r.URL.Query().Get("token")
|
|
||||||
// Вариант 3: Authorization: Bearer <jwt> — стандартный заголовок
|
|
||||||
if token == "" {
|
|
||||||
token = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
|
||||||
}
|
|
||||||
if token == "" {
|
|
||||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
|
||||||
fmt.Fprintf(w, "Укажите namespace в URL или передайте токен:\n\n")
|
|
||||||
fmt.Fprintf(w, " %s/funcs/<namespace>\n", externalURL)
|
|
||||||
fmt.Fprintf(w, " %s/funcs?token=<jwt>\n", externalURL)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sub, err := subFromJWT(token)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, fmt.Sprintf("invalid token: %s\n", err), http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
namespace = namespaceFromSub(sub)
|
|
||||||
authHeader = "Bearer " + token
|
|
||||||
}
|
|
||||||
|
|
||||||
fns, err := apiGet[[]fnResponse](operatorURL+"/v1/namespaces/"+namespace+"/functions", authHeader)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, fmt.Sprintf("operator error (functions): %s\n", err), http.StatusBadGateway)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
trs, err := apiGet[[]trResponse](operatorURL+"/v1/namespaces/"+namespace+"/triggers", authHeader)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, fmt.Sprintf("operator error (triggers): %s\n", err), http.StatusBadGateway)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Индекс триггеров по имени функции
|
|
||||||
trigIdx := map[string][]trResponse{}
|
|
||||||
for _, tr := range trs {
|
|
||||||
trigIdx[tr.FunctionRef] = append(trigIdx[tr.FunctionRef], tr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Фильтрация и сортировка: активные вверх, потом по имени
|
|
||||||
type entry struct {
|
|
||||||
fn fnResponse
|
|
||||||
httpT []trResponse
|
|
||||||
cronT []trResponse
|
|
||||||
isActive bool
|
|
||||||
}
|
|
||||||
var items []entry
|
|
||||||
for _, fn := range fns {
|
|
||||||
if exclude[fn.Name] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var httpT, cronT []trResponse
|
|
||||||
isActive := false
|
|
||||||
for _, tr := range trigIdx[fn.Name] {
|
|
||||||
switch tr.Type {
|
|
||||||
case "http":
|
|
||||||
httpT = append(httpT, tr)
|
|
||||||
case "cron":
|
|
||||||
cronT = append(cronT, tr)
|
|
||||||
}
|
|
||||||
if tr.Enabled && tr.Active {
|
|
||||||
isActive = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
items = append(items, entry{fn, httpT, cronT, isActive})
|
|
||||||
}
|
|
||||||
sort.Slice(items, func(i, j int) bool {
|
|
||||||
if items[i].isActive != items[j].isActive {
|
|
||||||
return items[i].isActive
|
|
||||||
}
|
|
||||||
return items[i].fn.Name < items[j].fn.Name
|
|
||||||
})
|
|
||||||
|
|
||||||
sep := strings.Repeat("─", 52)
|
|
||||||
var sb strings.Builder
|
|
||||||
for _, it := range items {
|
|
||||||
fn := it.fn
|
|
||||||
sb.WriteString(sep + "\n")
|
|
||||||
sb.WriteString(" " + buildComment(fn, it.httpT, it.cronT) + "\n")
|
|
||||||
sb.WriteString(fmt.Sprintf(" name: %s\n", fn.Name))
|
|
||||||
sb.WriteString(fmt.Sprintf(" runtime: %s\n", fn.Runtime))
|
|
||||||
sb.WriteString(fmt.Sprintf(" phase: %s\n", fn.Phase))
|
|
||||||
if it.isActive {
|
|
||||||
sb.WriteString(" active: да\n")
|
|
||||||
} else {
|
|
||||||
sb.WriteString(" active: нет\n")
|
|
||||||
}
|
|
||||||
if len(it.httpT) > 0 {
|
|
||||||
url := it.httpT[0].URL
|
|
||||||
if externalURL != "" {
|
|
||||||
url = externalURL + "/fn/" + namespace + "/" + fn.Name
|
|
||||||
}
|
|
||||||
sb.WriteString(fmt.Sprintf(" url: %s\n", url))
|
|
||||||
}
|
|
||||||
if len(it.cronT) > 0 {
|
|
||||||
sb.WriteString(fmt.Sprintf(" cron: %s\n", it.cronT[0].Schedule))
|
|
||||||
}
|
|
||||||
if fn.CreatedAt != "" {
|
|
||||||
sb.WriteString(fmt.Sprintf(" created: %s\n", fn.CreatedAt))
|
|
||||||
}
|
|
||||||
if fn.LastBuiltAt != "" {
|
|
||||||
sb.WriteString(fmt.Sprintf(" built: %s\n", fn.LastBuiltAt))
|
|
||||||
}
|
|
||||||
if fn.Message != "" {
|
|
||||||
sb.WriteString(fmt.Sprintf(" message: %s\n", fn.Message))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sb.WriteString(sep + "\n")
|
|
||||||
sb.WriteString(fmt.Sprintf(" namespace: %s | total: %d\n", namespace, len(items)))
|
|
||||||
sb.WriteString(sep + "\n")
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
||||||
fmt.Fprint(w, sb.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildComment(fn fnResponse, httpT, cronT []trResponse) string {
|
|
||||||
if len(httpT) > 0 {
|
|
||||||
active := "активна"
|
|
||||||
if !httpT[0].Active {
|
|
||||||
active = "неактивна"
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("HTTP endpoint (%s) — %s, %s", fn.Runtime, fn.Phase, active)
|
|
||||||
}
|
|
||||||
if len(cronT) > 0 {
|
|
||||||
active := "активна"
|
|
||||||
if !cronT[0].Active {
|
|
||||||
active = "неактивна"
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("Cron '%s' (%s) — %s, %s", cronT[0].Schedule, fn.Runtime, fn.Phase, active)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("Job/runner без триггера (%s) — %s", fn.Runtime, fn.Phase)
|
|
||||||
}
|
|
||||||
|
|
||||||
// subFromJWT декодирует JWT payload (без проверки подписи) и возвращает sub.
|
|
||||||
// Подпись не проверяется — trusted perimeter: сервис работает за Ingress.
|
|
||||||
func subFromJWT(token string) (string, error) {
|
|
||||||
parts := strings.Split(token, ".")
|
|
||||||
if len(parts) != 3 {
|
|
||||||
return "", fmt.Errorf("invalid jwt: expected 3 parts")
|
|
||||||
}
|
|
||||||
// base64url без паддинга — добавляем паддинг стандартно
|
|
||||||
payload := parts[1]
|
|
||||||
switch len(payload) % 4 {
|
|
||||||
case 2:
|
|
||||||
payload += "=="
|
|
||||||
case 3:
|
|
||||||
payload += "="
|
|
||||||
}
|
|
||||||
data, err := base64.URLEncoding.DecodeString(payload)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("decode payload: %w", err)
|
|
||||||
}
|
|
||||||
var claims map[string]any
|
|
||||||
if err := json.Unmarshal(data, &claims); err != nil {
|
|
||||||
return "", fmt.Errorf("unmarshal claims: %w", err)
|
|
||||||
}
|
|
||||||
sub, ok := claims["sub"].(string)
|
|
||||||
if !ok || sub == "" {
|
|
||||||
return "", fmt.Errorf("missing sub claim")
|
|
||||||
}
|
|
||||||
return sub, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// namespaceFromSub — та же логика что в операторе и terraform провайдере.
|
|
||||||
// SHA256(sub) → первые 8 байт → hex → "sless-{16 hex символов}"
|
|
||||||
func namespaceFromSub(sub string) string {
|
|
||||||
hash := sha256.Sum256([]byte(sub))
|
|
||||||
return fmt.Sprintf("sless-%x", hash[:8])
|
|
||||||
}
|
|
||||||
|
|
||||||
func apiGet[T any](url, authHeader string) (T, error) {
|
|
||||||
var zero T
|
|
||||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return zero, err
|
|
||||||
}
|
|
||||||
req.Header.Set("Authorization", authHeader)
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return zero, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return zero, fmt.Errorf("status %d: %s", resp.StatusCode, body)
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &zero); err != nil {
|
|
||||||
return zero, fmt.Errorf("unmarshal: %w", err)
|
|
||||||
}
|
|
||||||
return zero, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func env(key, fallback string) string {
|
|
||||||
if v := os.Getenv(key); v != "" {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user