Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aaa1b9a82d | ||
|
|
11c90bac9e | ||
|
|
28bbc58293 | ||
|
|
1ea0c5a9de | ||
|
|
f729bbfd85 | ||
|
|
47caff5c7e | ||
|
|
26728aadbe | ||
|
|
0a4f64143c | ||
|
|
d8b36631ee | ||
|
|
921c0b5c16 | ||
|
|
bba5904c5f | ||
|
|
924375b083 | ||
|
|
7887d4597d | ||
|
|
089994256c | ||
|
|
ee97eebbff | ||
|
|
c8eaef0e1a | ||
|
|
4a92e3f372 | ||
|
|
1fa92e069f | ||
|
|
fb469d9c5b | ||
|
|
80ea566816 | ||
|
|
f6f9382887 | ||
|
|
810980cfcd | ||
|
|
c9b5867373 | ||
|
|
8f4659d2ef | ||
|
|
f9bb898893 | ||
|
|
7cd8a7ec12 | ||
|
|
f42fa366de | ||
|
|
7f15a2c61d | ||
|
|
3960b55181 | ||
|
|
fcce5d3b50 | ||
|
|
10cf4783a3 | ||
|
|
af6e7354d6 | ||
|
|
9b0089e15a | ||
|
|
a9bd2e93c7 | ||
|
|
87a46acc48 | ||
|
|
817ee40fd2 | ||
|
|
bd507db3ae | ||
|
|
a9a81ce403 | ||
|
|
72004dd943 | ||
|
|
796a938aa3 |
@@ -13,6 +13,18 @@
|
|||||||
3. ЖДАТЬ следующей команды
|
3. ЖДАТЬ следующей команды
|
||||||
**ЗАПРЕЩЕНО** начинать работу, писать код, запускать команды — без явного "делай".
|
**ЗАПРЕЩЕНО** начинать работу, писать код, запускать команды — без явного "делай".
|
||||||
|
|
||||||
|
## ⛔⛔⛔ НЕ ТРОГАТЬ РАБОЧИЙ КОД — АБСОЛЮТНЫЙ ЗАПРЕТ НАВСЕГДА
|
||||||
|
|
||||||
|
**НИКАКИХ самодеятельных изменений рабочего кода:**
|
||||||
|
- Никаких "оптимизаций", "улучшений", "рефакторинга" без команды
|
||||||
|
- Никаких новых фич без явного разрешения
|
||||||
|
- Никаких helm upgrade, kubectl patch и прочих инфраструктурных изменений без команды
|
||||||
|
- Перед ЛЮБЫМ изменением рабочего кода — объяснить ЗАЧЕМ и ждать "делай"
|
||||||
|
|
||||||
|
**ПРЕЦЕДЕНТЫ:**
|
||||||
|
- v1.3.49: убрали mode toggle в edit modal "для улучшения" → сломали рабочий редактор
|
||||||
|
- helm upgrade попытка → сломал JWT secret router → 401 у всех функций пользователей
|
||||||
|
|
||||||
1. Не трогать рабочий код без явного указания.
|
1. Не трогать рабочий код без явного указания.
|
||||||
|
|
||||||
2. Файлы редактируются локально:
|
2. Файлы редактируются локально:
|
||||||
|
|||||||
@@ -91,6 +91,13 @@ rsync -az -e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no" \
|
|||||||
|
|
||||||
**Никогда не разбираться с результатами по памяти / буферу / чату. Только лог.**
|
**Никогда не разбираться с результатами по памяти / буферу / чату. Только лог.**
|
||||||
|
|
||||||
|
## ⛔ РУЧНЫЕ ПАТЧИ — ЗАПРЕЩЕНЫ
|
||||||
|
|
||||||
|
- НИКОГДА не применять ручные патчи (`kubectl patch`, `kubectl apply` отдельных полей, `python -c` замены в yaml и т.д.) без явного указания.
|
||||||
|
- Все изменения — только через код (Helm chart, YAML, Go-код) + сборка + деплой.
|
||||||
|
- Ручной патч слетает при следующем helm upgrade/redeploy → регрессия.
|
||||||
|
- Исключение: только если пользователь явно написал "примени ручной патч".
|
||||||
|
|
||||||
## Поведение агента
|
## Поведение агента
|
||||||
|
|
||||||
- Не трогать рабочий код без явного указания
|
- Не трогать рабочий код без явного указания
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
MQ-триггер (MessageQueue) — запуск при появлении сообщения в очереди (Kafka, NATS, RabbitMQ и т.д.)
|
||||||
|
Kube-Watch триггер — запуск при изменении объектов в Kubernetes - а это разве может быть реализовано ?
|
||||||
|
|
||||||
|
В Fission — да, это штатные встроенные типы триггеров. Fission из коробки поддерживает MQ (Kafka, NATS, Azure Queue) и Kube-Watch через отдельные компоненты (kubewatcher, mqtrigger).
|
||||||
|
|
||||||
|
Но в вашей консоли это не реализовано — нет UI для создания/управления этими триггерами. Только HTTP и Cron. Если потребуется — можно добавить.
|
||||||
|
|
||||||
|
от что реально полезно и не очень сложно:
|
||||||
|
|
||||||
|
Для пользователя:
|
||||||
|
|
||||||
|
История вызовов — последние N вызовов функции: время, статус, время ответа. Сейчас вызвал и забыл.
|
||||||
|
Метрики — счётчик вызовов, средний latency, % ошибок. У вас уже есть metrics-collector — данные есть, нужен только UI.
|
||||||
|
Автообновление логов — сейчас нужно жать "Обновить" вручную. Можно автополлинг каждые 3-5 сек.
|
||||||
|
Копировать маршрут одной кнопкой — иконка 📋 рядом с маршрутом в таблице.
|
||||||
|
Статус функции — Ready/NotReady/Error прямо в таблице (сейчас не видно).
|
||||||
|
Для управления:
|
||||||
|
6. Клонировать функцию — создать копию с новым именем, сохранив код и настройки.
|
||||||
|
7. Экспорт/импорт — скачать функцию как zip, загрузить обратно.
|
||||||
|
8. Переменные окружения — env vars для функции через UI (сейчас нельзя задать).
|
||||||
@@ -12,8 +12,10 @@ import (
|
|||||||
|
|
||||||
"fission-console/internal/api"
|
"fission-console/internal/api"
|
||||||
"fission-console/internal/auth"
|
"fission-console/internal/auth"
|
||||||
|
"fission-console/internal/billing"
|
||||||
|
|
||||||
"k8s.io/client-go/dynamic"
|
"k8s.io/client-go/dynamic"
|
||||||
|
"k8s.io/client-go/kubernetes"
|
||||||
"k8s.io/client-go/rest"
|
"k8s.io/client-go/rest"
|
||||||
"k8s.io/client-go/tools/clientcmd"
|
"k8s.io/client-go/tools/clientcmd"
|
||||||
)
|
)
|
||||||
@@ -37,13 +39,21 @@ func main() {
|
|||||||
log.Fatalf("create dynamic client: %v", err)
|
log.Fatalf("create dynamic client: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
kube, err := kubernetes.NewForConfig(cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("create kubernetes client: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
authenticator := &auth.MultiAuthenticator{
|
authenticator := &auth.MultiAuthenticator{
|
||||||
JWT: auth.NewDeckAuthenticator(auth.DefaultDeckAPIs, nil),
|
JWT: auth.NewDeckAuthenticator(auth.DefaultDeckAPIs, nil),
|
||||||
Demo: &auth.DemoAuthenticator{},
|
Demo: &auth.DemoAuthenticator{},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
billingStore := billing.NewStore()
|
||||||
|
|
||||||
srv := api.NewServer(api.Config{
|
srv := api.NewServer(api.Config{
|
||||||
Dyn: dyn,
|
Dyn: dyn,
|
||||||
|
Kube: kube,
|
||||||
Namespace: namespace,
|
Namespace: namespace,
|
||||||
RouterURL: routerURL,
|
RouterURL: routerURL,
|
||||||
HTTPTimeout: httpTimeout,
|
HTTPTimeout: httpTimeout,
|
||||||
@@ -57,6 +67,7 @@ func main() {
|
|||||||
LLMUrl: envDefault("FISSION_LLM_URL", "https://api.aillm.ru"),
|
LLMUrl: envDefault("FISSION_LLM_URL", "https://api.aillm.ru"),
|
||||||
LLMKey: os.Getenv("FISSION_LLM_KEY"),
|
LLMKey: os.Getenv("FISSION_LLM_KEY"),
|
||||||
// --- end ai/ask feature ---
|
// --- end ai/ask feature ---
|
||||||
|
Billing: billingStore,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Запускаем фоновые горутины: reaper истёкших функций
|
// Запускаем фоновые горутины: reaper истёкших функций
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ rules:
|
|||||||
- apiGroups: [""]
|
- apiGroups: [""]
|
||||||
resources: ["pods"]
|
resources: ["pods"]
|
||||||
verbs: ["get", "list"]
|
verbs: ["get", "list"]
|
||||||
|
- apiGroups: [""]
|
||||||
|
resources: ["pods/log"]
|
||||||
|
verbs: ["get"]
|
||||||
- apiGroups: ["apps"]
|
- apiGroups: ["apps"]
|
||||||
resources: ["deployments"]
|
resources: ["deployments"]
|
||||||
verbs: ["get", "list", "update", "patch"]
|
verbs: ["get", "list", "update", "patch"]
|
||||||
@@ -52,7 +55,7 @@ spec:
|
|||||||
serviceAccountName: fission-console
|
serviceAccountName: fission-console
|
||||||
containers:
|
containers:
|
||||||
- name: console
|
- name: console
|
||||||
image: naeel/fission-console:v1.3.56
|
image: naeel/fission-console:v1.3.86
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 8090
|
- containerPort: 8090
|
||||||
@@ -79,6 +82,8 @@ spec:
|
|||||||
key: password
|
key: password
|
||||||
- name: FISSION_STORAGESVC_URL
|
- name: FISSION_STORAGESVC_URL
|
||||||
value: "http://storagesvc.fission.svc.cluster.local"
|
value: "http://storagesvc.fission.svc.cluster.local"
|
||||||
|
- name: BILLING_DSN
|
||||||
|
value: "postgres://super:BQUF5ruECa1ZFlq4wYt3gPJUEmtBMkA9QNK4MM5Sd8al4ArMDlmT16DIKHYBPyif@postgresqlk8s-master.dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5.svc.cluster.local:5432/sqsdb"
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /health
|
path: /health
|
||||||
@@ -146,13 +151,8 @@ spec:
|
|||||||
name: fission-console
|
name: fission-console
|
||||||
port:
|
port:
|
||||||
number: 8090
|
number: 8090
|
||||||
- path: /cron
|
# ⛔ /cron НЕ ПРИНАДЛЕЖИТ console. Ingress /cron → metrics-collector:8091 (metrics-collector/deploy/metrics-collector.yaml)
|
||||||
pathType: Prefix
|
# ⛔ НЕ ДОБАВЛЯТЬ /cron сюда — это сломает независимость сервисов.
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: fission-console
|
|
||||||
port:
|
|
||||||
number: 8090
|
|
||||||
tls:
|
tls:
|
||||||
- hosts:
|
- hosts:
|
||||||
- fission.kube5s.ru
|
- fission.kube5s.ru
|
||||||
|
|||||||
+9
-1
@@ -3,12 +3,15 @@ module fission-console
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
golang.org/x/net v0.47.0
|
||||||
|
k8s.io/api v0.34.1
|
||||||
k8s.io/apimachinery v0.34.1
|
k8s.io/apimachinery v0.34.1
|
||||||
k8s.io/client-go v0.34.1
|
k8s.io/client-go v0.34.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/emicklei/go-restful/v3 v3.12.2 // indirect
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||||
@@ -16,6 +19,11 @@ require (
|
|||||||
github.com/go-openapi/swag v0.23.0 // indirect
|
github.com/go-openapi/swag v0.23.0 // indirect
|
||||||
github.com/gogo/protobuf v1.3.2 // indirect
|
github.com/gogo/protobuf v1.3.2 // indirect
|
||||||
github.com/google/gnostic-models v0.7.0 // indirect
|
github.com/google/gnostic-models v0.7.0 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/pgx/v5 v5.9.2 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/josharian/intern v1.0.0 // indirect
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/mailru/easyjson v0.7.7 // indirect
|
github.com/mailru/easyjson v0.7.7 // indirect
|
||||||
@@ -27,8 +35,8 @@ require (
|
|||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/net v0.47.0 // indirect
|
|
||||||
golang.org/x/oauth2 v0.30.0 // indirect
|
golang.org/x/oauth2 v0.30.0 // indirect
|
||||||
|
golang.org/x/sync v0.18.0 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
golang.org/x/term v0.37.0 // indirect
|
golang.org/x/term v0.37.0 // indirect
|
||||||
golang.org/x/text v0.31.0 // indirect
|
golang.org/x/text v0.31.0 // indirect
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgY
|
|||||||
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
|
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||||
|
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
@@ -70,6 +78,7 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE
|
|||||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
@@ -99,6 +108,8 @@ golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKl
|
|||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||||
|
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
|||||||
@@ -194,7 +194,133 @@ func buildExplainUserPrompt(lang, code string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildChatSystemPrompt() string {
|
func buildChatSystemPrompt() string {
|
||||||
return "Ты краткий и полезный ассистент для Fission Console. Отвечай по делу и без лишней болтовни."
|
return strings.TrimSpace(`Ты ассистент платформы NUBES Fission Console — веб-интерфейса для запуска serverless-функций.
|
||||||
|
Отвечай кратко, по делу, понятным языком без технического жаргона.
|
||||||
|
|
||||||
|
## Что такое Fission Console
|
||||||
|
Веб-интерфейс по адресу https://fission.kube5s.ru/console/
|
||||||
|
Позволяет создавать, редактировать, вызывать и удалять функции без знания инфраструктуры.
|
||||||
|
|
||||||
|
## Вход в систему
|
||||||
|
- Токен из Личного кабинета (Профиль → Токены) — рекомендуется
|
||||||
|
- Демо-логин — любая строка не менее 6 символов. Один и тот же логин всегда даёт одно и то же окружение (namespace).
|
||||||
|
|
||||||
|
## Кнопки верхней панели
|
||||||
|
- **Refresh** — обновить список функций
|
||||||
|
- **✏️ Из кода** — создать новую функцию, написав код прямо в браузере
|
||||||
|
- **📦 Из архива** — создать новую функцию из zip-архива с файлами
|
||||||
|
- **Help** — справка по консоли
|
||||||
|
- **Выход** — выйти из системы
|
||||||
|
|
||||||
|
## Таблица функций — что означают столбцы
|
||||||
|
- **Имя** — уникальное имя функции
|
||||||
|
- **Окружение** — язык выполнения (python, nodejs, go, php, ruby и др.)
|
||||||
|
- **Пакет** — имя внутреннего объекта с кодом функции (технический, менять не нужно)
|
||||||
|
- **Создана / Изменена** — дата и время создания и последнего изменения
|
||||||
|
- **Маршрут** — HTTP-путь через который вызывается функция (например /my-fn)
|
||||||
|
- **Методы** — разрешённые HTTP методы (GET, POST и др.)
|
||||||
|
- **Cron** — расписание автозапуска (например ` + "`*/5 * * * *`" + ` = каждые 5 минут). Если пусто — cron не задан.
|
||||||
|
- **Действия** — кнопки управления функцией (см. ниже)
|
||||||
|
|
||||||
|
## Кнопки действий в строке функции
|
||||||
|
- **Ред.** — открыть редактор: изменить код, timeout, cron
|
||||||
|
- **Вызов** — вызвать функцию прямо из браузера, увидеть ответ и время выполнения
|
||||||
|
- **Логи** — посмотреть вывод функции (то что она пишет через print/console.log/log.Printf и т.д.)
|
||||||
|
- **Удалить** — удалить функцию (необратимо)
|
||||||
|
|
||||||
|
## Создание функции "Из кода"
|
||||||
|
Поля формы:
|
||||||
|
- **Name** — имя функции (строчные буквы, цифры, дефис; напр. my-func)
|
||||||
|
- **Language** — язык (Python, Node.js, Go, PHP, Ruby)
|
||||||
|
- **Entrypoint** — точка входа. По умолчанию подставляется автоматически. Для Python: main.main, для Node.js: main, для Go: main, для PHP: main.php::handler, для Ruby: handler
|
||||||
|
- **Route** — URL-путь (напр. /my-func). Именно по этому пути функция доступна снаружи
|
||||||
|
- **Методы** — HTTP методы через запятую (GET, POST, GET,POST и т.д.)
|
||||||
|
- **Timeout** — максимальное время выполнения в секундах
|
||||||
|
- **Cron** — расписание в формате cron (необязательно). Включается переключателем.
|
||||||
|
- **Код** — редактор кода прямо в браузере
|
||||||
|
|
||||||
|
Кнопки в редакторе кода:
|
||||||
|
- **🔍 Проверить синтаксис линтером** — проверяет код на синтаксические ошибки
|
||||||
|
- **✨ Сгенерировать код LLM** — описать задачу текстом, получить готовый код
|
||||||
|
- **📖 LLM: Что делает?** — объяснение что делает текущий код
|
||||||
|
|
||||||
|
## Создание функции "Из архива"
|
||||||
|
Аналогично "Из кода", но вместо редактора кода — загрузка zip-файла.
|
||||||
|
- **🔍 Проверка архива линтером** — проверяет архив на ошибки и совместимость
|
||||||
|
- **📖 LLM: Что делает?** — объяснение содержимого архива
|
||||||
|
|
||||||
|
## Редактирование функции (кнопка Ред.)
|
||||||
|
- **Name, Environment, Entrypoint** — только для просмотра, не редактируются
|
||||||
|
- **Timeout** — можно изменить
|
||||||
|
- **Cron** — можно включить/выключить и изменить расписание
|
||||||
|
- **Код** — редактируется (для функций созданных из кода)
|
||||||
|
- **Заменить архив** — загрузить новый zip (для функций из архива)
|
||||||
|
- **Переменные окружения** — раздел в конце редактора. Кнопка "+ Добавить" добавляет переменную (имя + значение). Кнопка "Сохранить env vars" применяет изменения. Переменные сразу доступны в поде функции через os.environ (Python), process.env (Node.js), os.Getenv (Go) и т.д.
|
||||||
|
- **📋 Клон** — кнопка в нижней панели. Позволяет создать копию функции с новым именем. Клон наследует код, язык, entrypoint, timeout. Маршрут назначается автоматически.
|
||||||
|
|
||||||
|
## Вызов функции (кнопка Вызов)
|
||||||
|
Открывается модальное окно. Можно указать тело запроса (JSON) и нажать "Вызвать".
|
||||||
|
Показывается ответ функции, HTTP статус и время выполнения.
|
||||||
|
Если функция долго не отвечает — это нормально при первом вызове (cold start, прогрев 5-15 секунд).
|
||||||
|
|
||||||
|
## Логи (кнопка Логи)
|
||||||
|
Показывает вывод функции: всё что она пишет через print(), console.log(), fmt.Println() и т.д.
|
||||||
|
Кнопка "Обновить" — перезагрузить логи.
|
||||||
|
Логи видны пока функция активна (обрабатывает запросы или работает по cron).
|
||||||
|
|
||||||
|
## Вызов функции снаружи (через API)
|
||||||
|
Функции доступны по адресу: https://fission.kube5s.ru/fn/ВАШ_ROUTE
|
||||||
|
Нужен заголовок: Authorization: Bearer ВАШ_ТОКЕН
|
||||||
|
|
||||||
|
Пример curl:
|
||||||
|
curl -skL -X POST https://fission.kube5s.ru/fn/ВАШ_ROUTE \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"key":"value"}'
|
||||||
|
|
||||||
|
Пример Python:
|
||||||
|
import requests
|
||||||
|
response = requests.post(
|
||||||
|
"https://fission.kube5s.ru/fn/ВАШ_ROUTE",
|
||||||
|
headers={"Authorization": "Bearer ВАШ_ТОКЕН"},
|
||||||
|
json={"key": "value"}, timeout=60
|
||||||
|
)
|
||||||
|
|
||||||
|
## Форматы функций по языкам
|
||||||
|
Python (entrypoint: main.main):
|
||||||
|
def main():
|
||||||
|
return "Hello"
|
||||||
|
|
||||||
|
Node.js (entrypoint: main):
|
||||||
|
module.exports = async function(context) {
|
||||||
|
return { status: 200, body: "Hello" }
|
||||||
|
}
|
||||||
|
|
||||||
|
Go (entrypoint: main):
|
||||||
|
func Handler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("Hello"))
|
||||||
|
}
|
||||||
|
|
||||||
|
PHP (entrypoint: main.php::handler):
|
||||||
|
function handler($context) {
|
||||||
|
return ["status" => 200, "body" => "Hello"];
|
||||||
|
}
|
||||||
|
|
||||||
|
Ruby (entrypoint: handler):
|
||||||
|
def handler(context)
|
||||||
|
"Hello"
|
||||||
|
end
|
||||||
|
|
||||||
|
## Cron расписание — формат
|
||||||
|
Формат: минута час день_месяца месяц день_недели
|
||||||
|
Примеры:
|
||||||
|
- ` + "`*/5 * * * *`" + ` — каждые 5 минут
|
||||||
|
- ` + "`0 * * * *`" + ` — каждый час
|
||||||
|
- ` + "`0 9 * * 1-5`" + ` — в 9:00 по будням
|
||||||
|
- ` + "`@hourly`" + `, ` + "`@daily`" + `, ` + "`@weekly`" + ` — стандартные псевдонимы
|
||||||
|
|
||||||
|
## ESC и закрытие окон
|
||||||
|
Нажатие ESC закрывает любое открытое окно (модалку).`)
|
||||||
}
|
}
|
||||||
|
|
||||||
func prettyLanguageName(lang string) string {
|
func prettyLanguageName(lang string) string {
|
||||||
|
|||||||
@@ -0,0 +1,342 @@
|
|||||||
|
// Package api — создание и обновление функций из zip-архива (multipart/form-data).
|
||||||
|
//
|
||||||
|
// Этот файл отвечает за два сценария:
|
||||||
|
// 1. handleCreateFunctionFromArchive — создание новой функции из загруженного .zip файла.
|
||||||
|
// 2. handleUpdateFunctionArchive — обновление существующей функции новым .zip файлом.
|
||||||
|
//
|
||||||
|
// Архив загружается пользователем через форму с полем "archive".
|
||||||
|
// Содержимое архива передаётся в storagesvc → S3 без модификации.
|
||||||
|
// В отличие от function_code.go, здесь нет трансформации кода — архив идёт как есть.
|
||||||
|
//
|
||||||
|
// Связанная аннотация: fission-console/source-type = "archive"
|
||||||
|
// позволяет UI определить режим редактирования при открытии функции.
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fission-console/internal/fission"
|
||||||
|
"fission-console/internal/runtime"
|
||||||
|
|
||||||
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleCreateFunctionFromArchive создаёт функцию из загруженного zip-архива (multipart/form-data).
|
||||||
|
// Поля формы: name, language (или environment), entrypoint, route, methods, timeout, ttl.
|
||||||
|
// Файловое поле: archive (.zip).
|
||||||
|
func (s *Server) handleCreateFunctionFromArchive(w http.ResponseWriter, r *http.Request, ns string) {
|
||||||
|
if err := r.ParseMultipartForm(maxArchiveUploadSize); err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("parse multipart form: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimSpace(r.FormValue("name"))
|
||||||
|
if name == "" || (!validFuncName.MatchString(name) || len(name) > 57) {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, "invalid function name: must match ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ and be <= 57 chars")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lang := strings.TrimSpace(r.FormValue("language"))
|
||||||
|
envName := strings.TrimSpace(r.FormValue("environment"))
|
||||||
|
|
||||||
|
f, fhCreate, err := r.FormFile("archive")
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("archive file required: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
archiveBytes, err := io.ReadAll(io.LimitReader(f, maxArchiveUploadSize))
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("read archive: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
archiveFilenameCreate := ""
|
||||||
|
if fhCreate != nil {
|
||||||
|
archiveFilenameCreate = fhCreate.Filename
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем magic bytes: zip должен начинаться с PK (0x50 0x4B)
|
||||||
|
if len(archiveBytes) < 4 || archiveBytes[0] != 0x50 || archiveBytes[1] != 0x4B {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, "загруженный файл не является zip-архивом (ожидается .zip)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
nsCtx, nsCancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||||
|
defer nsCancel()
|
||||||
|
if err := s.nsManager.EnsureUserNS(nsCtx, ns); err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("ensure namespace: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Определяем environment: по языку или явно
|
||||||
|
if lang != "" {
|
||||||
|
envCtx, envCancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||||
|
defer envCancel()
|
||||||
|
resolved, envErr := fission.EnsureEnvironment(envCtx, s.dyn, ns, lang)
|
||||||
|
if envErr != nil {
|
||||||
|
if strings.Contains(envErr.Error(), "unsupported language") {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, envErr.Error())
|
||||||
|
} else {
|
||||||
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("ensure environment: %v", envErr))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
envName = resolved
|
||||||
|
}
|
||||||
|
if envName == "" {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, "language or environment is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.dyn.Resource(fission.EnvironmentGVR).Namespace(ns).Get(ctx, envName, metav1.GetOptions{}); err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("environment %q not found: %v", envName, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entrypoint := strings.TrimSpace(r.FormValue("entrypoint"))
|
||||||
|
if entrypoint == "" {
|
||||||
|
entrypoint = runtime.DefaultEntrypoint(lang)
|
||||||
|
}
|
||||||
|
route := strings.TrimSpace(r.FormValue("route"))
|
||||||
|
if route == "" {
|
||||||
|
nsShort := ns
|
||||||
|
if len(nsShort) > 12 {
|
||||||
|
nsShort = nsShort[len(nsShort)-12:]
|
||||||
|
}
|
||||||
|
route = "/" + nsShort + "/" + name
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(route, "/") {
|
||||||
|
route = "/" + route
|
||||||
|
}
|
||||||
|
methods := normalizeMethods(strings.Split(r.FormValue("methods"), ","))
|
||||||
|
timeout := normalizeFunctionTimeout(0)
|
||||||
|
if tv := r.FormValue("timeout"); tv != "" {
|
||||||
|
if n, err := strconv.ParseInt(tv, 10, 64); err == nil {
|
||||||
|
timeout = normalizeFunctionTimeout(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем архив в storagesvc
|
||||||
|
deploySpec, uploadErr := s.buildDeploySpec(ctx, archiveBytes)
|
||||||
|
if uploadErr != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("upload archive: %v", uploadErr))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pkgName := name + "-pkg"
|
||||||
|
triggerName := name + "-route"
|
||||||
|
|
||||||
|
pkg := &unstructured.Unstructured{Object: map[string]any{
|
||||||
|
"apiVersion": "fission.io/v1",
|
||||||
|
"kind": "Package",
|
||||||
|
"metadata": map[string]any{"name": pkgName, "namespace": ns},
|
||||||
|
"spec": map[string]any{
|
||||||
|
"deployment": deploySpec,
|
||||||
|
"environment": map[string]any{"name": envName, "namespace": ns},
|
||||||
|
"source": map[string]any{},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
fnAnnotations := map[string]any{
|
||||||
|
"fission-console/language": lang,
|
||||||
|
fissionSourceTypeAnnotation: "archive",
|
||||||
|
functionCreatedAtAnnotation: now.Format(time.RFC3339),
|
||||||
|
functionUpdatedAtAnnotation: now.Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
if archiveFilenameCreate != "" {
|
||||||
|
fnAnnotations["fission-console/archive-filename"] = archiveFilenameCreate
|
||||||
|
}
|
||||||
|
if ttl := r.FormValue("ttl"); ttl != "" {
|
||||||
|
if expiresAt, ttlErr := parseTTL(ttl); ttlErr == nil {
|
||||||
|
fnAnnotations["fission-console/expires-at"] = expiresAt.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
methodValues := make([]any, 0, len(methods))
|
||||||
|
for _, m := range methods {
|
||||||
|
methodValues = append(methodValues, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn := &unstructured.Unstructured{Object: map[string]any{
|
||||||
|
"apiVersion": "fission.io/v1",
|
||||||
|
"kind": "Function",
|
||||||
|
"metadata": map[string]any{"name": name, "namespace": ns, "annotations": fnAnnotations},
|
||||||
|
"spec": map[string]any{
|
||||||
|
"environment": map[string]any{"name": envName, "namespace": ns},
|
||||||
|
"package": map[string]any{"packageref": map[string]any{"name": pkgName, "namespace": ns}},
|
||||||
|
"InvokeStrategy": map[string]any{
|
||||||
|
"ExecutionStrategy": map[string]any{"ExecutorType": "poolmgr"},
|
||||||
|
"StrategyType": "execution",
|
||||||
|
},
|
||||||
|
"functionTimeout": timeout,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
if entrypoint != "" {
|
||||||
|
_ = unstructured.SetNestedField(fn.Object, entrypoint, "spec", "package", "functionName")
|
||||||
|
}
|
||||||
|
|
||||||
|
trigger := &unstructured.Unstructured{Object: map[string]any{
|
||||||
|
"apiVersion": "fission.io/v1",
|
||||||
|
"kind": "HTTPTrigger",
|
||||||
|
"metadata": map[string]any{"name": triggerName, "namespace": ns},
|
||||||
|
"spec": map[string]any{
|
||||||
|
"functionref": map[string]any{"name": name, "type": "name"},
|
||||||
|
"relativeurl": route,
|
||||||
|
"methods": methodValues,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
|
||||||
|
if _, err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Create(ctx, pkg, metav1.CreateOptions{}); err != nil {
|
||||||
|
if apierrors.IsAlreadyExists(err) {
|
||||||
|
writeJSONError(w, http.StatusConflict, fmt.Sprintf("function %q already exists", name))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create package: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Create(ctx, fn, metav1.CreateOptions{}); err != nil {
|
||||||
|
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, pkgName, metav1.DeleteOptions{})
|
||||||
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create function: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).Create(ctx, trigger, metav1.CreateOptions{}); err != nil {
|
||||||
|
_ = s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Delete(ctx, name, metav1.DeleteOptions{})
|
||||||
|
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, pkgName, metav1.DeleteOptions{})
|
||||||
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create trigger: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeAnyJSON(w, http.StatusCreated, map[string]any{
|
||||||
|
"name": name,
|
||||||
|
"namespace": ns,
|
||||||
|
"environment": envName,
|
||||||
|
"route": route,
|
||||||
|
"source_type": "archive",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUpdateFunctionArchive обновляет функцию из загруженного zip-архива (multipart/form-data).
|
||||||
|
// Поля формы: timeout (optional), entrypoint (optional). Файловое поле: archive (.zip).
|
||||||
|
// Создаёт новый Package (новое имя) — чтобы executor сбросил кэш function service.
|
||||||
|
func (s *Server) handleUpdateFunctionArchive(w http.ResponseWriter, r *http.Request, name string) {
|
||||||
|
if err := r.ParseMultipartForm(maxArchiveUploadSize); err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("parse multipart form: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
f, _, err := r.FormFile("archive")
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("archive file required: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
archiveBytes, err := io.ReadAll(io.LimitReader(f, maxArchiveUploadSize))
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("read archive: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
ns := s.userNS(r)
|
||||||
|
|
||||||
|
fn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
|
||||||
|
if err != nil {
|
||||||
|
status := http.StatusBadGateway
|
||||||
|
if apierrors.IsNotFound(err) {
|
||||||
|
status = http.StatusNotFound
|
||||||
|
}
|
||||||
|
writeJSONError(w, status, fmt.Sprintf("get function %q: %v", name, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
oldPkgName, _, _ := unstructured.NestedString(fn.Object, "spec", "package", "packageref", "name")
|
||||||
|
envName, _, _ := unstructured.NestedString(fn.Object, "spec", "environment", "name")
|
||||||
|
|
||||||
|
newPkgName := name + "-pkg-" + strconv.FormatInt(time.Now().UnixMilli(), 36)
|
||||||
|
deploySpec, uploadErr := s.buildDeploySpec(ctx, archiveBytes)
|
||||||
|
if uploadErr != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("upload archive: %v", uploadErr))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newPkg := &unstructured.Unstructured{Object: map[string]any{
|
||||||
|
"apiVersion": "fission.io/v1",
|
||||||
|
"kind": "Package",
|
||||||
|
"metadata": map[string]any{"name": newPkgName, "namespace": ns},
|
||||||
|
"spec": map[string]any{
|
||||||
|
"deployment": deploySpec,
|
||||||
|
"environment": map[string]any{"name": envName, "namespace": ns},
|
||||||
|
"source": map[string]any{},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
|
||||||
|
createdPkg, err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Create(ctx, newPkg, metav1.CreateOptions{})
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create new package: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем timeout если задан
|
||||||
|
timeout := normalizeFunctionTimeout(0)
|
||||||
|
if tv := r.FormValue("timeout"); tv != "" {
|
||||||
|
if n, err := strconv.ParseInt(tv, 10, 64); err == nil {
|
||||||
|
timeout = normalizeFunctionTimeout(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = unstructured.SetNestedField(fn.Object, timeout, "spec", "functionTimeout")
|
||||||
|
|
||||||
|
// Обновляем entrypoint если передан
|
||||||
|
if ep := strings.TrimSpace(r.FormValue("entrypoint")); ep != "" {
|
||||||
|
_ = unstructured.SetNestedField(fn.Object, ep, "spec", "package", "functionName")
|
||||||
|
}
|
||||||
|
|
||||||
|
fnAnnotations := fn.GetAnnotations()
|
||||||
|
if fnAnnotations == nil {
|
||||||
|
fnAnnotations = map[string]string{}
|
||||||
|
}
|
||||||
|
fnAnnotations[fissionSourceTypeAnnotation] = "archive"
|
||||||
|
fnAnnotations[functionUpdatedAtAnnotation] = time.Now().UTC().Format(time.RFC3339)
|
||||||
|
if fh, fhErr := r.MultipartForm.File["archive"]; fhErr == false || len(fh) > 0 {
|
||||||
|
if files := r.MultipartForm.File["archive"]; len(files) > 0 && files[0].Filename != "" {
|
||||||
|
fnAnnotations["fission-console/archive-filename"] = files[0].Filename
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn.SetAnnotations(fnAnnotations)
|
||||||
|
|
||||||
|
if err := unstructured.SetNestedField(fn.Object, map[string]any{
|
||||||
|
"name": newPkgName,
|
||||||
|
"namespace": ns,
|
||||||
|
"resourceversion": createdPkg.GetResourceVersion(),
|
||||||
|
}, "spec", "package", "packageref"); err != nil {
|
||||||
|
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, newPkgName, metav1.DeleteOptions{})
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("set packageref: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Update(ctx, fn, metav1.UpdateOptions{}); err != nil {
|
||||||
|
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, newPkgName, metav1.DeleteOptions{})
|
||||||
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("update function: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if oldPkgName != "" && oldPkgName != newPkgName {
|
||||||
|
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, oldPkgName, metav1.DeleteOptions{})
|
||||||
|
}
|
||||||
|
|
||||||
|
writeAnyJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"updated": true,
|
||||||
|
"package": newPkgName,
|
||||||
|
"source_type": "archive",
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
// Package api — клонирование функций.
|
||||||
|
//
|
||||||
|
// handleCloneFunction создаёт полную копию функции с новым именем:
|
||||||
|
// - скачивает архив из storagesvc (или копирует literal)
|
||||||
|
// - заливает новый архив (отдельный объект в S3)
|
||||||
|
// - создаёт новый Package, Function и HTTPTrigger
|
||||||
|
//
|
||||||
|
// Архив переливается заново, чтобы удаление оригинала не сломало клон.
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fission-console/internal/billing"
|
||||||
|
"fission-console/internal/fission"
|
||||||
|
"fission-console/internal/runtime"
|
||||||
|
|
||||||
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleCloneFunction — POST /functions/:name/clone
|
||||||
|
// Body: {"new_name": "my-clone", "route": "/my-clone"} (route необязателен)
|
||||||
|
func (s *Server) handleCloneFunction(w http.ResponseWriter, r *http.Request, srcName string) {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
ns := s.userNS(r)
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
NewName string `json:"new_name"`
|
||||||
|
Route string `json:"route"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("decode request: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.NewName = strings.TrimSpace(req.NewName)
|
||||||
|
req.Route = strings.TrimSpace(req.Route)
|
||||||
|
|
||||||
|
// Валидация нового имени
|
||||||
|
if req.NewName == "" {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, "new_name is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
validName := regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`)
|
||||||
|
if !validName.MatchString(req.NewName) || len(req.NewName) > 57 {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, "invalid new_name: must match ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ and be <= 57 chars")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем исходную функцию
|
||||||
|
srcFn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, srcName, metav1.GetOptions{})
|
||||||
|
if err != nil {
|
||||||
|
if apierrors.IsNotFound(err) {
|
||||||
|
writeJSONError(w, http.StatusNotFound, fmt.Sprintf("function %q not found", srcName))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("get function: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем имя пакета исходной функции
|
||||||
|
srcPkgName, _, _ := unstructured.NestedString(srcFn.Object, "spec", "package", "packageref", "name")
|
||||||
|
if srcPkgName == "" {
|
||||||
|
writeJSONError(w, http.StatusBadGateway, "source function has no package reference")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем исходный Package
|
||||||
|
srcPkg, err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Get(ctx, srcPkgName, metav1.GetOptions{})
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("get source package: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Скачиваем байты архива из Package
|
||||||
|
archiveBytes, err := s.downloadPackageBytes(ctx, srcPkg)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("download archive: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем как новый архив
|
||||||
|
newDeploySpec, err := s.buildDeploySpec(ctx, archiveBytes)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("upload clone archive: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Параметры для нового пакета (берём spec из оригинала)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
newPkgName := req.NewName + "-" + now.Format("20060102150405")
|
||||||
|
|
||||||
|
// Определяем environment из исходной функции
|
||||||
|
envName, _, _ := unstructured.NestedString(srcFn.Object, "spec", "environment", "name")
|
||||||
|
|
||||||
|
// Собираем spec пакета (аналогично оригиналу, но с новыми байтами)
|
||||||
|
// Если у оригинала есть source (Go) — копируем source spec
|
||||||
|
srcSourceSpec, _, _ := unstructured.NestedMap(srcPkg.Object, "spec", "source")
|
||||||
|
hasBuildCmd, _, _ := unstructured.NestedString(srcPkg.Object, "spec", "buildcommand")
|
||||||
|
|
||||||
|
var newPkgSpec map[string]any
|
||||||
|
if hasBuildCmd != "" {
|
||||||
|
// Go: source package
|
||||||
|
newPkgSpec = map[string]any{
|
||||||
|
"source": newDeploySpec, // перезаливаем source
|
||||||
|
"deployment": map[string]any{},
|
||||||
|
"environment": map[string]any{"name": envName, "namespace": ns},
|
||||||
|
"buildcommand": hasBuildCmd,
|
||||||
|
}
|
||||||
|
_ = srcSourceSpec
|
||||||
|
} else {
|
||||||
|
newPkgSpec = map[string]any{
|
||||||
|
"deployment": newDeploySpec,
|
||||||
|
"environment": map[string]any{"name": envName, "namespace": ns},
|
||||||
|
"source": map[string]any{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newPkg := &unstructured.Unstructured{Object: map[string]any{
|
||||||
|
"apiVersion": "fission.io/v1",
|
||||||
|
"kind": "Package",
|
||||||
|
"metadata": map[string]any{"name": newPkgName, "namespace": ns},
|
||||||
|
"spec": newPkgSpec,
|
||||||
|
}}
|
||||||
|
if _, err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Create(ctx, newPkg, metav1.CreateOptions{}); err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create clone package: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Копируем аннотации из исходной функции
|
||||||
|
srcAnnotations := srcFn.GetAnnotations()
|
||||||
|
newAnnotations := map[string]any{
|
||||||
|
functionCreatedAtAnnotation: now.Format(time.RFC3339),
|
||||||
|
functionUpdatedAtAnnotation: now.Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
for _, k := range []string{
|
||||||
|
"fission-console/language",
|
||||||
|
fissionSourceTypeAnnotation,
|
||||||
|
"fission-console/env-vars",
|
||||||
|
"fission-console/archive-filename",
|
||||||
|
} {
|
||||||
|
if v, ok := srcAnnotations[k]; ok && v != "" {
|
||||||
|
newAnnotations[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newAnnotations["fission-console/cloned-from"] = srcName
|
||||||
|
|
||||||
|
// Копируем entrypoint
|
||||||
|
entrypoint, _, _ := unstructured.NestedString(srcFn.Object, "spec", "package", "functionName")
|
||||||
|
timeout, _, _ := unstructured.NestedInt64(srcFn.Object, "spec", "functionTimeout")
|
||||||
|
if timeout == 0 {
|
||||||
|
timeout = 60
|
||||||
|
}
|
||||||
|
|
||||||
|
// Копируем InvokeStrategy и podspec
|
||||||
|
invokeStrategy, _, _ := unstructured.NestedMap(srcFn.Object, "spec", "InvokeStrategy")
|
||||||
|
if invokeStrategy == nil {
|
||||||
|
invokeStrategy = map[string]any{
|
||||||
|
"ExecutionStrategy": map[string]any{"ExecutorType": "poolmgr"},
|
||||||
|
"StrategyType": "execution",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
podspec, _, _ := unstructured.NestedMap(srcFn.Object, "spec", "podspec")
|
||||||
|
|
||||||
|
newFnSpec := map[string]any{
|
||||||
|
"environment": map[string]any{"name": envName, "namespace": ns},
|
||||||
|
"functionTimeout": timeout,
|
||||||
|
"InvokeStrategy": invokeStrategy,
|
||||||
|
"package": map[string]any{
|
||||||
|
"packageref": map[string]any{"name": newPkgName, "namespace": ns},
|
||||||
|
"functionName": entrypoint,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if len(podspec) > 0 {
|
||||||
|
newFnSpec["podspec"] = podspec
|
||||||
|
}
|
||||||
|
|
||||||
|
newFn := &unstructured.Unstructured{Object: map[string]any{
|
||||||
|
"apiVersion": "fission.io/v1",
|
||||||
|
"kind": "Function",
|
||||||
|
"metadata": map[string]any{
|
||||||
|
"name": req.NewName,
|
||||||
|
"namespace": ns,
|
||||||
|
"annotations": newAnnotations,
|
||||||
|
},
|
||||||
|
"spec": newFnSpec,
|
||||||
|
}}
|
||||||
|
if _, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Create(ctx, newFn, metav1.CreateOptions{}); err != nil {
|
||||||
|
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, newPkgName, metav1.DeleteOptions{})
|
||||||
|
if apierrors.IsAlreadyExists(err) {
|
||||||
|
writeJSONError(w, http.StatusConflict, fmt.Sprintf("function %q already exists", req.NewName))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create clone function: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Маршрут для нового триггера
|
||||||
|
if req.Route == "" {
|
||||||
|
nsShort := ns
|
||||||
|
if len(nsShort) > 12 {
|
||||||
|
nsShort = nsShort[len(nsShort)-12:]
|
||||||
|
}
|
||||||
|
req.Route = "/" + nsShort + "/" + req.NewName
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(req.Route, "/") {
|
||||||
|
req.Route = "/" + req.Route
|
||||||
|
}
|
||||||
|
triggerName := req.NewName + "-route"
|
||||||
|
|
||||||
|
// Определяем методы из существующего триггера оригинала
|
||||||
|
methods := s.getTriggerMethods(ctx, ns, srcName)
|
||||||
|
if len(methods) == 0 {
|
||||||
|
methods = []any{"GET"}
|
||||||
|
}
|
||||||
|
|
||||||
|
newTrigger := &unstructured.Unstructured{Object: map[string]any{
|
||||||
|
"apiVersion": "fission.io/v1",
|
||||||
|
"kind": "HTTPTrigger",
|
||||||
|
"metadata": map[string]any{"name": triggerName, "namespace": ns},
|
||||||
|
"spec": map[string]any{
|
||||||
|
"relativeurl": req.Route,
|
||||||
|
"methods": methods,
|
||||||
|
"createingress": true,
|
||||||
|
"functionref": map[string]any{"type": "name", "name": req.NewName},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
if _, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).Create(ctx, newTrigger, metav1.CreateOptions{}); err != nil {
|
||||||
|
_ = s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Delete(ctx, req.NewName, metav1.DeleteOptions{})
|
||||||
|
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, newPkgName, metav1.DeleteOptions{})
|
||||||
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create httptrigger: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.billing.RecordInvocation(billing.Invocation{
|
||||||
|
Namespace: ns,
|
||||||
|
FunctionName: req.NewName,
|
||||||
|
TriggerType: billing.TriggerEvent,
|
||||||
|
StartedAt: now,
|
||||||
|
StatusCode: http.StatusCreated,
|
||||||
|
RecordedBy: "console",
|
||||||
|
EventType: "clone",
|
||||||
|
})
|
||||||
|
|
||||||
|
writeAnyJSON(w, http.StatusCreated, map[string]any{
|
||||||
|
"name": req.NewName,
|
||||||
|
"cloned_from": srcName,
|
||||||
|
"package": newPkgName,
|
||||||
|
"route": req.Route,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// downloadPackageBytes извлекает байты архива из Package CRD.
|
||||||
|
// Поддерживает type:url (скачивает из storagesvc) и type:literal (base64).
|
||||||
|
func (s *Server) downloadPackageBytes(ctx context.Context, pkg *unstructured.Unstructured) ([]byte, error) {
|
||||||
|
// Пробуем deployment сначала, потом source (для Go)
|
||||||
|
for _, field := range [][]string{{"spec", "deployment"}, {"spec", "source"}} {
|
||||||
|
spec, _, _ := unstructured.NestedMap(pkg.Object, field...)
|
||||||
|
if len(spec) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
archiveType, _ := spec["type"].(string)
|
||||||
|
switch archiveType {
|
||||||
|
case "url":
|
||||||
|
archiveURL, _ := spec["url"].(string)
|
||||||
|
if archiveURL == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return s.downloadFromStoragesvc(ctx, archiveURL)
|
||||||
|
case "literal":
|
||||||
|
lit, _ := spec["literal"].(string)
|
||||||
|
if lit == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.DecodeString(lit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Последний шанс: если функция Python с простым кодом — возвращаем заглушку
|
||||||
|
return nil, fmt.Errorf("no downloadable archive found in package (empty deployment and source spec)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// downloadFromStoragesvc скачивает архив по URL из storagesvc.
|
||||||
|
func (s *Server) downloadFromStoragesvc(ctx context.Context, archiveURL string) ([]byte, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, archiveURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build download request: %w", err)
|
||||||
|
}
|
||||||
|
resp, err := s.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("download archive: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("download archive status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read archive body: %w", err)
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getTriggerMethods возвращает методы HTTP из триггера функции (для копирования в клон).
|
||||||
|
func (s *Server) getTriggerMethods(ctx context.Context, ns, fnName string) []any {
|
||||||
|
triggers, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).List(ctx, metav1.ListOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, t := range triggers.Items {
|
||||||
|
ref, _, _ := unstructured.NestedString(t.Object, "spec", "functionref", "name")
|
||||||
|
if ref != fnName {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
methods, _, _ := unstructured.NestedSlice(t.Object, "spec", "methods")
|
||||||
|
if len(methods) > 0 {
|
||||||
|
return methods
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ссылка на runtime.DefaultEntrypoint для возможного использования в будущем
|
||||||
|
var _ = runtime.DefaultEntrypoint
|
||||||
@@ -0,0 +1,629 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"fission-console/internal/fission"
|
||||||
|
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||||
|
k8sruntime "k8s.io/apimachinery/pkg/runtime"
|
||||||
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||||
|
dynamicfake "k8s.io/client-go/dynamic/fake"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// newCloneTestServer создаёт Server с фиктивным k8s-клиентом и
|
||||||
|
// опциональным HTTP-сервером, имитирующим storagesvc.
|
||||||
|
func newCloneTestServer(t *testing.T, storagesvcHandler http.HandlerFunc, objs ...*unstructured.Unstructured) (*Server, *httptest.Server) {
|
||||||
|
t.Helper()
|
||||||
|
scheme := k8sruntime.NewScheme()
|
||||||
|
listKinds := map[schema.GroupVersionResource]string{
|
||||||
|
fission.FunctionGVR: "FunctionList",
|
||||||
|
fission.PackageGVR: "PackageList",
|
||||||
|
fission.HTTPTrigGVR: "HTTPTriggerList",
|
||||||
|
}
|
||||||
|
runtimeObjs := make([]k8sruntime.Object, len(objs))
|
||||||
|
for i, o := range objs {
|
||||||
|
runtimeObjs[i] = o
|
||||||
|
}
|
||||||
|
dynClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, runtimeObjs...)
|
||||||
|
|
||||||
|
var storagesvc *httptest.Server
|
||||||
|
httpClient := &http.Client{}
|
||||||
|
if storagesvcHandler != nil {
|
||||||
|
storagesvc = httptest.NewServer(storagesvcHandler)
|
||||||
|
httpClient = storagesvc.Client()
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &Server{
|
||||||
|
dyn: dynClient,
|
||||||
|
ns: "fission-test",
|
||||||
|
http: httpClient,
|
||||||
|
}
|
||||||
|
return s, storagesvc
|
||||||
|
}
|
||||||
|
|
||||||
|
// cloneRequest выполняет POST /functions/:srcName/clone с телом body.
|
||||||
|
func cloneRequest(t *testing.T, s *Server, srcName, body string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodPost,
|
||||||
|
"/console/api/functions/"+srcName+"/clone",
|
||||||
|
strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-Auth-Token", "test-token")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.handleCloneFunction(rec, req, srcName)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeZip возвращает минимальный zip-архив с одним файлом filename/content.
|
||||||
|
func makeZip(t *testing.T, filename, content string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w := zip.NewWriter(&buf)
|
||||||
|
f, err := w.Create(filename)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("zip.Create: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := io.WriteString(f, content); err != nil {
|
||||||
|
t.Fatalf("zip.Write: %v", err)
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
t.Fatalf("zip.Close: %v", err)
|
||||||
|
}
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// packageWithLiteral создаёт Package CRD с type:literal (base64-encoded bytes).
|
||||||
|
func packageWithLiteral(ns, name, fnName string, data []byte) *unstructured.Unstructured {
|
||||||
|
return &unstructured.Unstructured{Object: map[string]any{
|
||||||
|
"apiVersion": "fission.io/v1",
|
||||||
|
"kind": "Package",
|
||||||
|
"metadata": map[string]any{"name": name, "namespace": ns},
|
||||||
|
"spec": map[string]any{
|
||||||
|
"deployment": map[string]any{
|
||||||
|
"type": "literal",
|
||||||
|
"literal": base64.StdEncoding.EncodeToString(data),
|
||||||
|
},
|
||||||
|
"environment": map[string]any{"name": fnName, "namespace": ns},
|
||||||
|
"source": map[string]any{},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// packageWithURL создаёт Package CRD с type:url.
|
||||||
|
func packageWithURL(ns, pkgName, envName, archiveURL string) *unstructured.Unstructured {
|
||||||
|
return &unstructured.Unstructured{Object: map[string]any{
|
||||||
|
"apiVersion": "fission.io/v1",
|
||||||
|
"kind": "Package",
|
||||||
|
"metadata": map[string]any{"name": pkgName, "namespace": ns},
|
||||||
|
"spec": map[string]any{
|
||||||
|
"deployment": map[string]any{
|
||||||
|
"type": "url",
|
||||||
|
"url": archiveURL,
|
||||||
|
},
|
||||||
|
"environment": map[string]any{"name": envName, "namespace": ns},
|
||||||
|
"source": map[string]any{},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// functionWithPkg создаёт Function CRD, ссылающуюся на pkgName.
|
||||||
|
func functionWithPkg(ns, fnName, pkgName, envName, entrypoint string) *unstructured.Unstructured {
|
||||||
|
return &unstructured.Unstructured{Object: map[string]any{
|
||||||
|
"apiVersion": "fission.io/v1",
|
||||||
|
"kind": "Function",
|
||||||
|
"metadata": map[string]any{
|
||||||
|
"name": fnName,
|
||||||
|
"namespace": ns,
|
||||||
|
"annotations": map[string]any{
|
||||||
|
"fission-console/language": envName,
|
||||||
|
"fission-console/source-type": "code",
|
||||||
|
"fission-console/created-at": "2026-05-09T00:00:00Z",
|
||||||
|
"fission-console/updated-at": "2026-05-09T00:00:00Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"spec": map[string]any{
|
||||||
|
"environment": map[string]any{"name": envName, "namespace": ns},
|
||||||
|
"functionTimeout": int64(120),
|
||||||
|
"package": map[string]any{
|
||||||
|
"packageref": map[string]any{"name": pkgName, "namespace": ns},
|
||||||
|
"functionName": entrypoint,
|
||||||
|
},
|
||||||
|
"InvokeStrategy": map[string]any{
|
||||||
|
"ExecutionStrategy": map[string]any{"ExecutorType": "poolmgr"},
|
||||||
|
"StrategyType": "execution",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpTriggerObject создаёт HTTPTrigger CRD для функции fnName.
|
||||||
|
func httpTriggerObject(ns, trigName, fnName, route string, methods []any) *unstructured.Unstructured {
|
||||||
|
return &unstructured.Unstructured{Object: map[string]any{
|
||||||
|
"apiVersion": "fission.io/v1",
|
||||||
|
"kind": "HTTPTrigger",
|
||||||
|
"metadata": map[string]any{"name": trigName, "namespace": ns},
|
||||||
|
"spec": map[string]any{
|
||||||
|
"relativeurl": route,
|
||||||
|
"methods": methods,
|
||||||
|
"createingress": true,
|
||||||
|
"functionref": map[string]any{"type": "name", "name": fnName},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Тесты валидации имени ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestHandleCloneFunction_InvalidName_EmptyName(t *testing.T) {
|
||||||
|
s, _ := newCloneTestServer(t, nil)
|
||||||
|
rec := cloneRequest(t, s, "src", `{"new_name":""}`)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "new_name is required") {
|
||||||
|
t.Fatalf("body should mention 'new_name is required', got: %s", rec.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleCloneFunction_InvalidName_UpperCase(t *testing.T) {
|
||||||
|
s, _ := newCloneTestServer(t, nil)
|
||||||
|
rec := cloneRequest(t, s, "src", `{"new_name":"MyFunc"}`)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleCloneFunction_InvalidName_StartsWithDash(t *testing.T) {
|
||||||
|
s, _ := newCloneTestServer(t, nil)
|
||||||
|
rec := cloneRequest(t, s, "src", `{"new_name":"-bad"}`)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleCloneFunction_InvalidName_EndsWithDash(t *testing.T) {
|
||||||
|
s, _ := newCloneTestServer(t, nil)
|
||||||
|
rec := cloneRequest(t, s, "src", `{"new_name":"bad-"}`)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleCloneFunction_InvalidName_TooLong(t *testing.T) {
|
||||||
|
s, _ := newCloneTestServer(t, nil)
|
||||||
|
longName := strings.Repeat("a", 58)
|
||||||
|
rec := cloneRequest(t, s, "src", fmt.Sprintf(`{"new_name":%q}`, longName))
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleCloneFunction_ValidName_MaxLength(t *testing.T) {
|
||||||
|
// 57 символов — допустимо; но функция src не существует → 404
|
||||||
|
s, _ := newCloneTestServer(t, nil)
|
||||||
|
name57 := strings.Repeat("a", 57)
|
||||||
|
rec := cloneRequest(t, s, "src", fmt.Sprintf(`{"new_name":%q}`, name57))
|
||||||
|
// Ожидаем не 400 (прошло валидацию), а 404 (src не найден)
|
||||||
|
if rec.Code == http.StatusBadRequest {
|
||||||
|
t.Fatalf("57-char name should pass validation, got 400; body=%s", rec.Body)
|
||||||
|
}
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleCloneFunction_ValidName_WithDashes(t *testing.T) {
|
||||||
|
// Имя вида "a-b-c" — допустимо
|
||||||
|
s, _ := newCloneTestServer(t, nil)
|
||||||
|
rec := cloneRequest(t, s, "nonexist", `{"new_name":"a-b-c"}`)
|
||||||
|
if rec.Code == http.StatusBadRequest {
|
||||||
|
t.Fatalf("'a-b-c' should pass validation, got 400; body=%s", rec.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Тест: источник не существует ────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestHandleCloneFunction_SourceNotFound(t *testing.T) {
|
||||||
|
s, _ := newCloneTestServer(t, nil)
|
||||||
|
rec := cloneRequest(t, s, "ghost", `{"new_name":"ghost-copy"}`)
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "ghost") {
|
||||||
|
t.Fatalf("body should mention 'ghost', got: %s", rec.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Тест: успешный клон с type:literal ──────────────────────────────────────
|
||||||
|
|
||||||
|
func TestHandleCloneFunction_Success_Literal(t *testing.T) {
|
||||||
|
const ns = "fission-test"
|
||||||
|
zipData := makeZip(t, "main.py", "def main():\n return 'hello'\n")
|
||||||
|
|
||||||
|
pkg := packageWithLiteral(ns, "src-pkg", "python", zipData)
|
||||||
|
fn := functionWithPkg(ns, "src", "src-pkg", "python", "main.main")
|
||||||
|
trig := httpTriggerObject(ns, "src-route", "src", "/test-ns/src", []any{"GET", "POST"})
|
||||||
|
|
||||||
|
// storagesvc принимает загрузку и возвращает URL
|
||||||
|
storagesvc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
// Имитируем загрузку — возвращаем ID архива
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, `{"id":"fission/new-archive-id-123"}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}))
|
||||||
|
defer storagesvc.Close()
|
||||||
|
|
||||||
|
s, _ := newCloneTestServer(t, nil, pkg, fn, trig)
|
||||||
|
s.storagesvcURL = storagesvc.URL
|
||||||
|
s.http = storagesvc.Client()
|
||||||
|
|
||||||
|
rec := cloneRequest(t, s, "src", `{"new_name":"src-copy"}`)
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]any
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if resp["name"] != "src-copy" {
|
||||||
|
t.Fatalf("response.name = %v, want 'src-copy'", resp["name"])
|
||||||
|
}
|
||||||
|
if resp["cloned_from"] != "src" {
|
||||||
|
t.Fatalf("response.cloned_from = %v, want 'src'", resp["cloned_from"])
|
||||||
|
}
|
||||||
|
if resp["package"] == "" || resp["package"] == nil {
|
||||||
|
t.Fatalf("response.package is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Проверяем что Function создана
|
||||||
|
cloneFn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, "src-copy", metav1.GetOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("clone Function not found: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Аннотация cloned-from
|
||||||
|
ann := cloneFn.GetAnnotations()
|
||||||
|
if ann["fission-console/cloned-from"] != "src" {
|
||||||
|
t.Fatalf("annotation cloned-from = %q, want 'src'", ann["fission-console/cloned-from"])
|
||||||
|
}
|
||||||
|
// Аннотации language и source-type скопированы
|
||||||
|
if ann["fission-console/language"] != "python" {
|
||||||
|
t.Fatalf("annotation language = %q, want 'python'", ann["fission-console/language"])
|
||||||
|
}
|
||||||
|
if ann["fission-console/source-type"] != "code" {
|
||||||
|
t.Fatalf("annotation source-type = %q, want 'code'", ann["fission-console/source-type"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entrypoint скопирован
|
||||||
|
ep, _, _ := unstructured.NestedString(cloneFn.Object, "spec", "package", "functionName")
|
||||||
|
if ep != "main.main" {
|
||||||
|
t.Fatalf("entrypoint = %q, want 'main.main'", ep)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeout скопирован
|
||||||
|
timeout, _, _ := unstructured.NestedInt64(cloneFn.Object, "spec", "functionTimeout")
|
||||||
|
if timeout != 120 {
|
||||||
|
t.Fatalf("timeout = %d, want 120", timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем что HTTPTrigger создан
|
||||||
|
trigName := "src-copy-route"
|
||||||
|
cloneTrig, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).Get(ctx, trigName, metav1.GetOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("clone HTTPTrigger not found: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Методы скопированы из оригинального триггера
|
||||||
|
methods, _, _ := unstructured.NestedStringSlice(cloneTrig.Object, "spec", "methods")
|
||||||
|
if len(methods) != 2 {
|
||||||
|
t.Fatalf("methods = %v, want [GET POST]", methods)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Маршрут сгенерирован автоматически (содержит new_name)
|
||||||
|
route, _, _ := unstructured.NestedString(cloneTrig.Object, "spec", "relativeurl")
|
||||||
|
if !strings.HasSuffix(route, "/src-copy") {
|
||||||
|
t.Fatalf("route = %q, should end with '/src-copy'", route)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Тест: маршрут задан явно ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestHandleCloneFunction_CustomRoute(t *testing.T) {
|
||||||
|
const ns = "fission-test"
|
||||||
|
zipData := makeZip(t, "main.py", "def main():\n return 'hi'\n")
|
||||||
|
|
||||||
|
pkg := packageWithLiteral(ns, "fn-pkg", "python", zipData)
|
||||||
|
fn := functionWithPkg(ns, "fn", "fn-pkg", "python", "main.main")
|
||||||
|
|
||||||
|
storagesvc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprint(w, `{"id":"fission/arc-456"}`)
|
||||||
|
}))
|
||||||
|
defer storagesvc.Close()
|
||||||
|
|
||||||
|
s, _ := newCloneTestServer(t, nil, pkg, fn)
|
||||||
|
s.storagesvcURL = storagesvc.URL
|
||||||
|
s.http = storagesvc.Client()
|
||||||
|
|
||||||
|
rec := cloneRequest(t, s, "fn", `{"new_name":"fn-clone","route":"/custom/path"}`)
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
trig, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).Get(ctx, "fn-clone-route", metav1.GetOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HTTPTrigger not found: %v", err)
|
||||||
|
}
|
||||||
|
route, _, _ := unstructured.NestedString(trig.Object, "spec", "relativeurl")
|
||||||
|
if route != "/custom/path" {
|
||||||
|
t.Fatalf("route = %q, want '/custom/path'", route)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Тест: маршрут без слеша — добавляется автоматически ─────────────────────
|
||||||
|
|
||||||
|
func TestHandleCloneFunction_RouteWithoutLeadingSlash(t *testing.T) {
|
||||||
|
const ns = "fission-test"
|
||||||
|
zipData := makeZip(t, "main.py", "def main():\n return 'hi'\n")
|
||||||
|
|
||||||
|
pkg := packageWithLiteral(ns, "fn2-pkg", "python", zipData)
|
||||||
|
fn := functionWithPkg(ns, "fn2", "fn2-pkg", "python", "main.main")
|
||||||
|
|
||||||
|
storagesvc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprint(w, `{"id":"fission/arc-789"}`)
|
||||||
|
}))
|
||||||
|
defer storagesvc.Close()
|
||||||
|
|
||||||
|
s, _ := newCloneTestServer(t, nil, pkg, fn)
|
||||||
|
s.storagesvcURL = storagesvc.URL
|
||||||
|
s.http = storagesvc.Client()
|
||||||
|
|
||||||
|
rec := cloneRequest(t, s, "fn2", `{"new_name":"fn2-clone","route":"no-leading-slash"}`)
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
trig, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).Get(ctx, "fn2-clone-route", metav1.GetOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HTTPTrigger not found: %v", err)
|
||||||
|
}
|
||||||
|
route, _, _ := unstructured.NestedString(trig.Object, "spec", "relativeurl")
|
||||||
|
if !strings.HasPrefix(route, "/") {
|
||||||
|
t.Fatalf("route = %q, should start with '/'", route)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Тест: дублирующее имя → 409 ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestHandleCloneFunction_DuplicateName_Conflict(t *testing.T) {
|
||||||
|
const ns = "fission-test"
|
||||||
|
zipData := makeZip(t, "main.py", "def main():\n return 'a'\n")
|
||||||
|
|
||||||
|
pkg := packageWithLiteral(ns, "dup-pkg", "python", zipData)
|
||||||
|
fn := functionWithPkg(ns, "dup-src", "dup-pkg", "python", "main.main")
|
||||||
|
// Уже существующая функция с именем "dup-copy"
|
||||||
|
existingFn := functionWithPkg(ns, "dup-copy", "other-pkg", "python", "main.main")
|
||||||
|
|
||||||
|
storagesvc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprint(w, `{"id":"fission/arc-dup"}`)
|
||||||
|
}))
|
||||||
|
defer storagesvc.Close()
|
||||||
|
|
||||||
|
s, _ := newCloneTestServer(t, nil, pkg, fn, existingFn)
|
||||||
|
s.storagesvcURL = storagesvc.URL
|
||||||
|
s.http = storagesvc.Client()
|
||||||
|
|
||||||
|
rec := cloneRequest(t, s, "dup-src", `{"new_name":"dup-copy"}`)
|
||||||
|
if rec.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("status = %d, want 409; body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "dup-copy") {
|
||||||
|
t.Fatalf("body should mention 'dup-copy', got: %s", rec.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Тест: откат при ошибке создания Function ────────────────────────────────
|
||||||
|
|
||||||
|
func TestHandleCloneFunction_Rollback_OnFunctionConflict(t *testing.T) {
|
||||||
|
const ns = "fission-test"
|
||||||
|
zipData := makeZip(t, "main.py", "def main():\n return 'rb'\n")
|
||||||
|
|
||||||
|
pkg := packageWithLiteral(ns, "rb-pkg", "python", zipData)
|
||||||
|
fn := functionWithPkg(ns, "rb-src", "rb-pkg", "python", "main.main")
|
||||||
|
// Уже существующая функция с именем клона
|
||||||
|
existingFn := functionWithPkg(ns, "rb-clone", "other-pkg", "python", "main.main")
|
||||||
|
|
||||||
|
storagesvc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprint(w, `{"id":"fission/arc-rb"}`)
|
||||||
|
}))
|
||||||
|
defer storagesvc.Close()
|
||||||
|
|
||||||
|
s, _ := newCloneTestServer(t, nil, pkg, fn, existingFn)
|
||||||
|
s.storagesvcURL = storagesvc.URL
|
||||||
|
s.http = storagesvc.Client()
|
||||||
|
|
||||||
|
rec := cloneRequest(t, s, "rb-src", `{"new_name":"rb-clone"}`)
|
||||||
|
if rec.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("status = %d, want 409; body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// После rollback Package с именем "rb-clone-*" не должен остаться
|
||||||
|
ctx := context.Background()
|
||||||
|
pkgs, err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).List(ctx, metav1.ListOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list packages: %v", err)
|
||||||
|
}
|
||||||
|
for _, p := range pkgs.Items {
|
||||||
|
if strings.HasPrefix(p.GetName(), "rb-clone-") {
|
||||||
|
t.Fatalf("rollback failed: package %q still exists", p.GetName())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Тест: downloadPackageBytes — type:literal ────────────────────────────────
|
||||||
|
|
||||||
|
func TestDownloadPackageBytes_Literal(t *testing.T) {
|
||||||
|
s, _ := newCloneTestServer(t, nil)
|
||||||
|
|
||||||
|
original := []byte("hello from literal")
|
||||||
|
pkg := packageWithLiteral("fission-test", "lit-pkg", "python", original)
|
||||||
|
|
||||||
|
got, err := s.downloadPackageBytes(context.Background(), pkg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("downloadPackageBytes error: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(got, original) {
|
||||||
|
t.Fatalf("got %q, want %q", got, original)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Тест: downloadPackageBytes — type:url ────────────────────────────────────
|
||||||
|
|
||||||
|
func TestDownloadPackageBytes_URL(t *testing.T) {
|
||||||
|
const content = "archive-content-from-storagesvc"
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprint(w, content)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
pkg := packageWithURL("fission-test", "url-pkg", "python", srv.URL+"/v1/archive?id=abc")
|
||||||
|
s := &Server{http: srv.Client()}
|
||||||
|
|
||||||
|
got, err := s.downloadPackageBytes(context.Background(), pkg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("downloadPackageBytes error: %v", err)
|
||||||
|
}
|
||||||
|
if string(got) != content {
|
||||||
|
t.Fatalf("got %q, want %q", got, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Тест: downloadPackageBytes — пустой Package → ошибка ────────────────────
|
||||||
|
|
||||||
|
func TestDownloadPackageBytes_EmptyPackage_Error(t *testing.T) {
|
||||||
|
s, _ := newCloneTestServer(t, nil)
|
||||||
|
emptyPkg := &unstructured.Unstructured{Object: map[string]any{
|
||||||
|
"apiVersion": "fission.io/v1",
|
||||||
|
"kind": "Package",
|
||||||
|
"metadata": map[string]any{"name": "empty", "namespace": "fission-test"},
|
||||||
|
"spec": map[string]any{},
|
||||||
|
}}
|
||||||
|
_, err := s.downloadPackageBytes(context.Background(), emptyPkg)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty package, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Тест: downloadFromStoragesvc — non-200 → ошибка ────────────────────────
|
||||||
|
|
||||||
|
func TestDownloadFromStoragesvc_Non200(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
s := &Server{http: srv.Client()}
|
||||||
|
_, err := s.downloadFromStoragesvc(context.Background(), srv.URL+"/v1/archive?id=gone")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for 404 response, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "404") {
|
||||||
|
t.Fatalf("error should mention 404, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Тест: getTriggerMethods — триггер существует ────────────────────────────
|
||||||
|
|
||||||
|
func TestGetTriggerMethods_Found(t *testing.T) {
|
||||||
|
const ns = "fission-test"
|
||||||
|
trig := httpTriggerObject(ns, "my-trig", "my-fn", "/my", []any{"GET", "POST"})
|
||||||
|
s, _ := newCloneTestServer(t, nil, trig)
|
||||||
|
|
||||||
|
methods := s.getTriggerMethods(context.Background(), ns, "my-fn")
|
||||||
|
if len(methods) != 2 {
|
||||||
|
t.Fatalf("methods = %v, want 2 elements", methods)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Тест: getTriggerMethods — триггера нет → nil ────────────────────────────
|
||||||
|
|
||||||
|
func TestGetTriggerMethods_NotFound(t *testing.T) {
|
||||||
|
s, _ := newCloneTestServer(t, nil)
|
||||||
|
methods := s.getTriggerMethods(context.Background(), "fission-test", "no-such-fn")
|
||||||
|
if methods != nil {
|
||||||
|
t.Fatalf("expected nil, got %v", methods)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Тест: невалидный JSON body → 400 ────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestHandleCloneFunction_InvalidJSON(t *testing.T) {
|
||||||
|
s, _ := newCloneTestServer(t, nil)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/console/api/functions/src/clone",
|
||||||
|
strings.NewReader(`not-json`))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.handleCloneFunction(rec, req, "src")
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Тест: успешный клон — Package содержит ссылку на Function ───────────────
|
||||||
|
|
||||||
|
func TestHandleCloneFunction_PackageRefsCloneFunction(t *testing.T) {
|
||||||
|
const ns = "fission-test"
|
||||||
|
zipData := makeZip(t, "main.py", "def main():\n return 'ref'\n")
|
||||||
|
pkg := packageWithLiteral(ns, "ref-pkg", "python", zipData)
|
||||||
|
fn := functionWithPkg(ns, "ref-src", "ref-pkg", "python", "main.main")
|
||||||
|
|
||||||
|
storagesvc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprint(w, `{"id":"fission/arc-ref"}`)
|
||||||
|
}))
|
||||||
|
defer storagesvc.Close()
|
||||||
|
|
||||||
|
s, _ := newCloneTestServer(t, nil, pkg, fn)
|
||||||
|
s.storagesvcURL = storagesvc.URL
|
||||||
|
s.http = storagesvc.Client()
|
||||||
|
|
||||||
|
rec := cloneRequest(t, s, "ref-src", `{"new_name":"ref-clone"}`)
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
cloneFn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, "ref-clone", metav1.GetOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("clone function not found: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pkgRef, _, _ := unstructured.NestedString(cloneFn.Object, "spec", "package", "packageref", "name")
|
||||||
|
if !strings.HasPrefix(pkgRef, "ref-clone-") {
|
||||||
|
t.Fatalf("clone Function.spec.package.packageref.name = %q, should start with 'ref-clone-'", pkgRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Package с этим именем должен существовать
|
||||||
|
_, err = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Get(ctx, pkgRef, metav1.GetOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("clone Package %q not found: %v", pkgRef, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
// Package api — создание и обновление функций из исходного кода (inline code).
|
||||||
|
//
|
||||||
|
// Этот файл отвечает за два сценария:
|
||||||
|
// 1. handleCreateFunction — создание новой функции из кода (JSON body).
|
||||||
|
// 2. handleUpdateFunctionCode — обновление существующей функции: новый код → новый Package.
|
||||||
|
//
|
||||||
|
// Поддерживаемые языки: python, nodejs, php, ruby, go.
|
||||||
|
// Для Go создаётся source package (builder компилирует .so плагин).
|
||||||
|
// Для остальных языков — deployment archive (zip загружается в storagesvc или как literal).
|
||||||
|
//
|
||||||
|
// Почему новый Package при обновлении:
|
||||||
|
// Fission executor кэширует function service по functionUid и не видит изменений
|
||||||
|
// в существующем Package. Новое имя пакета гарантирует cache miss в executor.
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fission-console/internal/billing"
|
||||||
|
"fission-console/internal/fission"
|
||||||
|
"fission-console/internal/model"
|
||||||
|
"fission-console/internal/runtime"
|
||||||
|
|
||||||
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildDeployArchive упаковывает исходный код в байты для deployment Package.
|
||||||
|
// Для nodejs — ESM-обёртка (package.json + main.js).
|
||||||
|
// Для php/ruby — zip с одним файлом скрипта.
|
||||||
|
// Для остальных (python) — raw bytes кода.
|
||||||
|
func buildDeployArchive(lang, code string) ([]byte, error) {
|
||||||
|
switch lang {
|
||||||
|
case "nodejs":
|
||||||
|
return runtime.BuildJSDeployZip(code)
|
||||||
|
case "php":
|
||||||
|
return runtime.BuildScriptZip(code, "main.php")
|
||||||
|
case "ruby":
|
||||||
|
return runtime.BuildScriptZip(code, "handler.rb")
|
||||||
|
default:
|
||||||
|
return []byte(code), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCreateFunction создаёт новую функцию: Package + Function + HTTPTrigger.
|
||||||
|
//
|
||||||
|
// Порядок создания: Package → Function → HTTPTrigger.
|
||||||
|
// При ошибке на любом шаге откатываем уже созданные объекты (best-effort).
|
||||||
|
// TTL парсится ДО создания объектов — невалидный TTL не оставляет мусор.
|
||||||
|
func (s *Server) handleCreateFunction(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ns := s.userNS(r)
|
||||||
|
|
||||||
|
// Поддерживаем два формата: JSON (код) и multipart/form-data (архив).
|
||||||
|
isArchiveUpload := strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data")
|
||||||
|
if isArchiveUpload {
|
||||||
|
s.handleCreateFunctionFromArchive(w, r, ns)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req model.CreateFunctionRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("decode request: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Гарантируем namespace — на случай прямого вызова API без handleAuth
|
||||||
|
nsCtx, nsCancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||||
|
defer nsCancel()
|
||||||
|
if err := s.nsManager.EnsureUserNS(nsCtx, ns); err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("ensure namespace: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
|
req.Language = strings.TrimSpace(req.Language)
|
||||||
|
req.Environment = strings.TrimSpace(req.Environment)
|
||||||
|
req.Code = strings.TrimSpace(req.Code)
|
||||||
|
req.Entrypoint = strings.TrimSpace(req.Entrypoint)
|
||||||
|
req.Route = strings.TrimSpace(req.Route)
|
||||||
|
|
||||||
|
// Валидация имени
|
||||||
|
if req.Name != "" && (!validFuncName.MatchString(req.Name) || len(req.Name) > 57) {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, "invalid function name: must match ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ and be <= 57 chars")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Code) > maxCodeSize {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, "code exceeds 1MB limit")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazy создание Environment по языку (если язык указан явно)
|
||||||
|
if req.Language != "" {
|
||||||
|
envCtx, envCancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||||
|
defer envCancel()
|
||||||
|
envName, err := fission.EnsureEnvironment(envCtx, s.dyn, ns, req.Language)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "unsupported language") {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, err.Error())
|
||||||
|
} else {
|
||||||
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("ensure environment: %v", err))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Environment = envName
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Name == "" || req.Environment == "" || req.Code == "" {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, "name, environment/language and code are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Entrypoint == "" {
|
||||||
|
req.Entrypoint = runtime.DefaultEntrypoint(req.Language)
|
||||||
|
}
|
||||||
|
if req.Route == "" {
|
||||||
|
// Namespace-prefix route: избегаем коллизий между пользователями
|
||||||
|
// (разные пользователи могут создать функцию с одинаковым именем)
|
||||||
|
nsShort := ns
|
||||||
|
if len(nsShort) > 12 {
|
||||||
|
nsShort = nsShort[len(nsShort)-12:]
|
||||||
|
}
|
||||||
|
req.Route = "/" + nsShort + "/" + req.Name
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(req.Route, "/") {
|
||||||
|
req.Route = "/" + req.Route
|
||||||
|
}
|
||||||
|
req.Methods = normalizeMethods(req.Methods)
|
||||||
|
req.Timeout = normalizeFunctionTimeout(req.Timeout)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Проверяем что environment существует (мог быть задан явно без language)
|
||||||
|
if _, err := s.dyn.Resource(fission.EnvironmentGVR).Namespace(ns).Get(ctx, req.Environment, metav1.GetOptions{}); err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("environment %q not found: %v", req.Environment, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pkgName := req.Name + "-pkg"
|
||||||
|
triggerName := req.Name + "-route"
|
||||||
|
|
||||||
|
methodValues := make([]any, 0, len(req.Methods))
|
||||||
|
for _, method := range req.Methods {
|
||||||
|
methodValues = append(methodValues, method)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Строим Package spec в зависимости от языка:
|
||||||
|
// - Go: source package → builder job компилирует в .so плагин
|
||||||
|
// - Node.js: deployment zip с ESM wrapper (package.json + main.js)
|
||||||
|
// - Остальные: deployment archive с кодом (S3 или literal fallback)
|
||||||
|
var pkgSpec map[string]any
|
||||||
|
if req.Language == "go" {
|
||||||
|
srcZip, err := runtime.BuildGoSourceZip(req.Code)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("build go source archive: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
srcSpec, srcErr := s.buildDeploySpec(ctx, srcZip)
|
||||||
|
if srcErr != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("upload go source: %v", srcErr))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pkgSpec = map[string]any{
|
||||||
|
"source": srcSpec,
|
||||||
|
"deployment": map[string]any{},
|
||||||
|
"environment": map[string]any{"name": req.Environment, "namespace": ns},
|
||||||
|
"buildcommand": "build",
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
deployBytes, archiveErr := buildDeployArchive(req.Language, req.Code)
|
||||||
|
if archiveErr != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build %s archive: %v", req.Language, archiveErr))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deploySpec, uploadErr := s.buildDeploySpec(ctx, deployBytes)
|
||||||
|
if uploadErr != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("upload %s archive: %v", req.Language, uploadErr))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pkgSpec = map[string]any{
|
||||||
|
"deployment": deploySpec,
|
||||||
|
"environment": map[string]any{"name": req.Environment, "namespace": ns},
|
||||||
|
"source": map[string]any{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pkg := &unstructured.Unstructured{Object: map[string]any{
|
||||||
|
"apiVersion": "fission.io/v1",
|
||||||
|
"kind": "Package",
|
||||||
|
"metadata": map[string]any{"name": pkgName, "namespace": ns},
|
||||||
|
"spec": pkgSpec,
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Парсим TTL ДО создания K8s ресурсов — невалидный TTL не оставляет мусор
|
||||||
|
fnAnnotations := map[string]any{
|
||||||
|
"fission-console/language": req.Language,
|
||||||
|
fissionSourceTypeAnnotation: "code",
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
fnAnnotations[functionCreatedAtAnnotation] = now.Format(time.RFC3339)
|
||||||
|
fnAnnotations[functionUpdatedAtAnnotation] = now.Format(time.RFC3339)
|
||||||
|
if req.TTL != "" {
|
||||||
|
expiresAt, ttlErr := parseTTL(req.TTL)
|
||||||
|
if ttlErr != nil {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("invalid ttl %q: %v", req.TTL, ttlErr))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fnAnnotations["fission-console/expires-at"] = expiresAt.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Create(ctx, pkg, metav1.CreateOptions{}); err != nil {
|
||||||
|
if apierrors.IsAlreadyExists(err) {
|
||||||
|
writeJSONError(w, http.StatusConflict, fmt.Sprintf("function %q already exists", req.Name))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if apierrors.IsInvalid(err) {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("invalid function spec: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create package: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fn := &unstructured.Unstructured{Object: map[string]any{
|
||||||
|
"apiVersion": "fission.io/v1",
|
||||||
|
"kind": "Function",
|
||||||
|
"metadata": map[string]any{"name": req.Name, "namespace": ns, "annotations": fnAnnotations},
|
||||||
|
"spec": map[string]any{
|
||||||
|
"environment": map[string]any{"name": req.Environment, "namespace": ns},
|
||||||
|
"functionTimeout": req.Timeout,
|
||||||
|
"InvokeStrategy": map[string]any{
|
||||||
|
"ExecutionStrategy": map[string]any{"ExecutorType": "poolmgr"},
|
||||||
|
"StrategyType": "execution",
|
||||||
|
},
|
||||||
|
"package": map[string]any{
|
||||||
|
"packageref": map[string]any{"name": pkgName, "namespace": ns},
|
||||||
|
"functionName": req.Entrypoint,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
|
||||||
|
if _, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Create(ctx, fn, metav1.CreateOptions{}); err != nil {
|
||||||
|
// Откатываем Package если Function не создалась
|
||||||
|
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, pkgName, metav1.DeleteOptions{})
|
||||||
|
if apierrors.IsAlreadyExists(err) {
|
||||||
|
writeJSONError(w, http.StatusConflict, fmt.Sprintf("function %q already exists", req.Name))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create function: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
httpTrigger := &unstructured.Unstructured{Object: map[string]any{
|
||||||
|
"apiVersion": "fission.io/v1",
|
||||||
|
"kind": "HTTPTrigger",
|
||||||
|
"metadata": map[string]any{"name": triggerName, "namespace": ns},
|
||||||
|
"spec": map[string]any{
|
||||||
|
"relativeurl": req.Route,
|
||||||
|
"methods": methodValues,
|
||||||
|
"createingress": true,
|
||||||
|
"functionref": map[string]any{"type": "name", "name": req.Name},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
|
||||||
|
if _, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).Create(ctx, httpTrigger, metav1.CreateOptions{}); err != nil {
|
||||||
|
// Откатываем Function и Package
|
||||||
|
_ = s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Delete(ctx, req.Name, metav1.DeleteOptions{})
|
||||||
|
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, pkgName, metav1.DeleteOptions{})
|
||||||
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create httptrigger: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.billing.RecordInvocation(billing.Invocation{
|
||||||
|
Namespace: ns,
|
||||||
|
FunctionName: req.Name,
|
||||||
|
TriggerType: billing.TriggerEvent,
|
||||||
|
StartedAt: now,
|
||||||
|
StatusCode: http.StatusCreated,
|
||||||
|
RecordedBy: "console",
|
||||||
|
EventType: "create",
|
||||||
|
})
|
||||||
|
|
||||||
|
writeAnyJSON(w, http.StatusCreated, map[string]any{
|
||||||
|
"name": req.Name,
|
||||||
|
"package": pkgName,
|
||||||
|
"httptrigger": triggerName,
|
||||||
|
"route": req.Route,
|
||||||
|
"expires_at": fnAnnotations["fission-console/expires-at"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUpdateFunctionCode обновляет код уже существующей функции.
|
||||||
|
// Создаёт НОВЫЙ Package (вместо обновления старого) чтобы executor сбросил кэш:
|
||||||
|
// executor кэширует function service по functionUid и не видит изменений в том же Package.
|
||||||
|
// Новое имя пакета гарантирует cache miss в executor.
|
||||||
|
func (s *Server) handleUpdateFunctionCode(w http.ResponseWriter, r *http.Request, name string) {
|
||||||
|
var req model.UpdateCodeRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("decode request: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Code = strings.TrimSpace(req.Code)
|
||||||
|
if req.Code == "" {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, "code is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
ns := s.userNS(r)
|
||||||
|
|
||||||
|
fn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
|
||||||
|
if err != nil {
|
||||||
|
status := http.StatusBadGateway
|
||||||
|
if apierrors.IsNotFound(err) {
|
||||||
|
status = http.StatusNotFound
|
||||||
|
}
|
||||||
|
writeJSONError(w, status, fmt.Sprintf("get function %q: %v", name, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
oldPkgName, _, _ := unstructured.NestedString(fn.Object, "spec", "package", "packageref", "name")
|
||||||
|
|
||||||
|
// Определяем язык из аннотации — нужен для правильной упаковки
|
||||||
|
lang, _, _ := unstructured.NestedString(fn.Object, "metadata", "annotations", "fission-console/language")
|
||||||
|
deployBytes, archiveErr := buildDeployArchive(lang, req.Code)
|
||||||
|
if archiveErr != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build %s archive: %v", lang, archiveErr))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаём новый Package с уникальным именем.
|
||||||
|
// Это единственный способ сбросить кэш executor: он кэширует по functionUid и
|
||||||
|
// не замечает изменений в существующем Package.
|
||||||
|
envName, _, _ := unstructured.NestedString(fn.Object, "spec", "environment", "name")
|
||||||
|
createdAt := func() time.Time {
|
||||||
|
ann := fn.GetAnnotations()
|
||||||
|
if ann != nil {
|
||||||
|
if v := strings.TrimSpace(ann[functionCreatedAtAnnotation]); v != "" {
|
||||||
|
if ts, err := parseRFC3339(v); err == nil {
|
||||||
|
return ts.UTC()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ts := fn.GetCreationTimestamp(); !ts.IsZero() {
|
||||||
|
return ts.UTC()
|
||||||
|
}
|
||||||
|
return time.Time{}
|
||||||
|
}()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
newPkgName := name + "-pkg-" + strconv.FormatInt(time.Now().UnixMilli(), 36)
|
||||||
|
deploySpec, uploadErr := s.buildDeploySpec(ctx, deployBytes)
|
||||||
|
if uploadErr != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("upload %s archive: %v", lang, uploadErr))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
newPkg := &unstructured.Unstructured{Object: map[string]any{
|
||||||
|
"apiVersion": "fission.io/v1",
|
||||||
|
"kind": "Package",
|
||||||
|
"metadata": map[string]any{"name": newPkgName, "namespace": ns},
|
||||||
|
"spec": map[string]any{
|
||||||
|
"deployment": deploySpec,
|
||||||
|
"environment": map[string]any{"name": envName, "namespace": ns},
|
||||||
|
"source": map[string]any{},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
|
||||||
|
createdPkg, err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Create(ctx, newPkg, metav1.CreateOptions{})
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create new package: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем Function на новый Package
|
||||||
|
if err := unstructured.SetNestedField(fn.Object, normalizeFunctionTimeout(req.Timeout), "spec", "functionTimeout"); err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("set function timeout: %v", err))
|
||||||
|
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, newPkgName, metav1.DeleteOptions{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ensureFunctionTimestamps(fn, now)
|
||||||
|
if createdAt.IsZero() {
|
||||||
|
createdAt = now
|
||||||
|
}
|
||||||
|
fnAnnotations := fn.GetAnnotations()
|
||||||
|
if fnAnnotations == nil {
|
||||||
|
fnAnnotations = map[string]string{}
|
||||||
|
}
|
||||||
|
fnAnnotations[functionCreatedAtAnnotation] = createdAt.UTC().Format(time.RFC3339)
|
||||||
|
fnAnnotations[functionUpdatedAtAnnotation] = now.Format(time.RFC3339)
|
||||||
|
fn.SetAnnotations(fnAnnotations)
|
||||||
|
if err := unstructured.SetNestedField(fn.Object, map[string]any{
|
||||||
|
"name": newPkgName,
|
||||||
|
"namespace": ns,
|
||||||
|
"resourceversion": createdPkg.GetResourceVersion(),
|
||||||
|
}, "spec", "package", "packageref"); err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("set function packageref: %v", err))
|
||||||
|
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, newPkgName, metav1.DeleteOptions{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Update(ctx, fn, metav1.UpdateOptions{}); err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("update function %q: %v", name, err))
|
||||||
|
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, newPkgName, metav1.DeleteOptions{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаляем старый Package (best effort)
|
||||||
|
if oldPkgName != "" && oldPkgName != newPkgName {
|
||||||
|
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, oldPkgName, metav1.DeleteOptions{})
|
||||||
|
}
|
||||||
|
|
||||||
|
writeAnyJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"updated": true,
|
||||||
|
"package": newPkgName,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,524 @@
|
|||||||
|
// Package api — CRUD операции с функциями: чтение, удаление, обновление таймаута, логи, env vars.
|
||||||
|
//
|
||||||
|
// Этот файл содержит операции, не связанные с заменой кода/архива:
|
||||||
|
// - handleGetFunction — GET /functions/:name (детали: код, route, environment, source_type)
|
||||||
|
// - handleDeleteFunction — DELETE /functions/:name (каскадное удаление: триггеры, Package, S3)
|
||||||
|
// - handleUpdateFunctionTimeout — PUT /functions/:name/timeout (только таймаут, без замены кода)
|
||||||
|
// - handleGetFunctionLogs — GET /functions/:name/logs (логи пода через Kubernetes API)
|
||||||
|
// - handleGetFunctionEnvVars — GET /functions/:name/envvars (переменные окружения из CRD)
|
||||||
|
// - handlePutFunctionEnvVars — PUT /functions/:name/envvars (обновить env vars в CRD)
|
||||||
|
//
|
||||||
|
// Операции с кодом и архивом — в function_code.go и function_archive.go соответственно.
|
||||||
|
// Вызов функции — в function_invoke.go.
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fission-console/internal/billing"
|
||||||
|
"fission-console/internal/fission"
|
||||||
|
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||||
|
k8stypes "k8s.io/apimachinery/pkg/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleGetFunction возвращает детали функции: код, environment, route, methods.
|
||||||
|
func (s *Server) handleGetFunction(w http.ResponseWriter, r *http.Request, name string) {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
ns := s.userNS(r)
|
||||||
|
|
||||||
|
fn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
|
||||||
|
if err != nil {
|
||||||
|
status := http.StatusBadGateway
|
||||||
|
if apierrors.IsNotFound(err) {
|
||||||
|
status = http.StatusNotFound
|
||||||
|
}
|
||||||
|
writeJSONError(w, status, fmt.Sprintf("get function %q: %v", name, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
packageName, _, _ := unstructured.NestedString(fn.Object, "spec", "package", "packageref", "name")
|
||||||
|
environment, _, _ := unstructured.NestedString(fn.Object, "spec", "environment", "name")
|
||||||
|
entrypoint, _, _ := unstructured.NestedString(fn.Object, "spec", "package", "functionName")
|
||||||
|
functionTimeout, foundTimeout, _ := unstructured.NestedInt64(fn.Object, "spec", "functionTimeout")
|
||||||
|
if !foundTimeout || functionTimeout <= 0 {
|
||||||
|
functionTimeout = int64(defaultFunctionInvokeTimeout / time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Извлекаем исходный код из Package (пробуем source.literal, потом deployment.literal, потом url)
|
||||||
|
code := ""
|
||||||
|
if packageName != "" {
|
||||||
|
pkg, pkgErr := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Get(ctx, packageName, metav1.GetOptions{})
|
||||||
|
if pkgErr == nil {
|
||||||
|
code = extractPackageSourceCode(ctx, s, pkg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ищем HTTPTrigger для получения route и methods
|
||||||
|
route := ""
|
||||||
|
methods := []string{}
|
||||||
|
triggers, trigErr := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).List(ctx, metav1.ListOptions{})
|
||||||
|
if trigErr == nil {
|
||||||
|
for _, trig := range triggers.Items {
|
||||||
|
refName, _, _ := unstructured.NestedString(trig.Object, "spec", "functionref", "name")
|
||||||
|
if refName != name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
route, _, _ = unstructured.NestedString(trig.Object, "spec", "relativeurl")
|
||||||
|
methods, _, _ = unstructured.NestedStringSlice(trig.Object, "spec", "methods")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Читаем source-type аннотацию (code / archive)
|
||||||
|
sourceType := "code"
|
||||||
|
archiveFilename := ""
|
||||||
|
if ann := fn.GetAnnotations(); ann != nil {
|
||||||
|
if v := ann[fissionSourceTypeAnnotation]; v != "" {
|
||||||
|
sourceType = v
|
||||||
|
}
|
||||||
|
if v := ann["fission-console/archive-filename"]; v != "" {
|
||||||
|
archiveFilename = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeAnyJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"name": name,
|
||||||
|
"namespace": ns,
|
||||||
|
"environment": environment,
|
||||||
|
"package": packageName,
|
||||||
|
"entrypoint": entrypoint,
|
||||||
|
"timeout": functionTimeout,
|
||||||
|
"created_at": functionTimestampResponse(fn)["created_at"],
|
||||||
|
"updated_at": functionTimestampResponse(fn)["updated_at"],
|
||||||
|
"code": code,
|
||||||
|
"source_type": sourceType,
|
||||||
|
"archive_filename": archiveFilename,
|
||||||
|
"route": route,
|
||||||
|
"methods": methods,
|
||||||
|
"env_vars": extractEnvVars(fn),
|
||||||
|
"raw": fn.Object,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUpdateFunctionTimeout обновляет только spec.functionTimeout функции (без замены кода/архива).
|
||||||
|
func (s *Server) handleUpdateFunctionTimeout(w http.ResponseWriter, r *http.Request, name string) {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
ns := s.userNS(r)
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Timeout int64 `json:"timeout"`
|
||||||
|
Entrypoint string `json:"entrypoint"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("decode request: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
timeout := normalizeFunctionTimeout(req.Timeout)
|
||||||
|
|
||||||
|
fn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("get function %q: %v", name, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := unstructured.SetNestedField(fn.Object, timeout, "spec", "functionTimeout"); err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("set timeout: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Entrypoint != "" {
|
||||||
|
_ = unstructured.SetNestedField(fn.Object, req.Entrypoint, "spec", "package", "functionName")
|
||||||
|
}
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
ann := fn.GetAnnotations()
|
||||||
|
if ann == nil {
|
||||||
|
ann = map[string]string{}
|
||||||
|
}
|
||||||
|
ann[functionUpdatedAtAnnotation] = now
|
||||||
|
fn.SetAnnotations(ann)
|
||||||
|
|
||||||
|
if _, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Update(ctx, fn, metav1.UpdateOptions{}); err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("update function %q: %v", name, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeAnyJSON(w, http.StatusOK, map[string]any{"updated": true, "timeout": timeout})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeleteFunction удаляет функцию и связанные объекты: HTTPTrigger, TimeTrigger, Package.
|
||||||
|
// После удаления вызывает CleanupEnvironmentIfUnused — убирает environment если язык больше не используется.
|
||||||
|
func (s *Server) handleDeleteFunction(w http.ResponseWriter, r *http.Request, name string) {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
ns := s.userNS(r)
|
||||||
|
|
||||||
|
// Получаем Function чтобы знать pkgName и envName для cleanup
|
||||||
|
var pkgName, envName string
|
||||||
|
fn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
|
||||||
|
if err != nil {
|
||||||
|
if apierrors.IsNotFound(err) {
|
||||||
|
writeJSONError(w, http.StatusNotFound, fmt.Sprintf("function %q not found", name))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("get function %q: %v", name, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pkgName, _, _ = unstructured.NestedString(fn.Object, "spec", "package", "packageref", "name")
|
||||||
|
envName, _, _ = unstructured.NestedString(fn.Object, "spec", "environment", "name")
|
||||||
|
|
||||||
|
// Удаляем связанные HTTPTrigger-ы
|
||||||
|
triggers, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).List(ctx, metav1.ListOptions{})
|
||||||
|
if err == nil {
|
||||||
|
for _, trig := range triggers.Items {
|
||||||
|
refName, _, _ := unstructured.NestedString(trig.Object, "spec", "functionref", "name")
|
||||||
|
if refName == name {
|
||||||
|
_ = s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).Delete(ctx, trig.GetName(), metav1.DeleteOptions{})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаляем связанные TimeTrigger-ы
|
||||||
|
if triggers, err := s.dyn.Resource(fission.TimeTrigGVR).Namespace(ns).List(ctx, metav1.ListOptions{}); err == nil {
|
||||||
|
for _, trig := range triggers.Items {
|
||||||
|
refName, _, _ := unstructured.NestedString(trig.Object, "spec", "functionref", "name")
|
||||||
|
if refName == name {
|
||||||
|
_ = s.dyn.Resource(fission.TimeTrigGVR).Namespace(ns).Delete(ctx, trig.GetName(), metav1.DeleteOptions{})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Delete(ctx, name, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) {
|
||||||
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("delete function %q: %v", name, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if pkgName != "" {
|
||||||
|
// Получаем URL архива из Package spec.deployment перед удалением, чтобы потом очистить S3
|
||||||
|
var archiveURL string
|
||||||
|
if pkg, pkgErr := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Get(ctx, pkgName, metav1.GetOptions{}); pkgErr == nil {
|
||||||
|
deployType, _, _ := unstructured.NestedString(pkg.Object, "spec", "deployment", "type")
|
||||||
|
if deployType == "url" {
|
||||||
|
archiveURL, _, _ = unstructured.NestedString(pkg.Object, "spec", "deployment", "url")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, pkgName, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) {
|
||||||
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("delete package %q: %v", pkgName, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Удаляем архив из S3 после успешного удаления Package (best-effort)
|
||||||
|
if archiveURL != "" {
|
||||||
|
go s.deleteFromStoragesvc(context.Background(), archiveURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Убираем environment pool pods если язык больше не используется (best-effort)
|
||||||
|
if envName != "" {
|
||||||
|
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cleanupCancel()
|
||||||
|
fission.CleanupEnvironmentIfUnused(cleanupCtx, s.dyn, ns, envName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// (reconciler NS удалён — за FISSION_RESOURCE_NAMESPACES теперь отвечает Layer 1 NSWatcher)
|
||||||
|
|
||||||
|
s.billing.RecordInvocation(billing.Invocation{
|
||||||
|
Namespace: ns,
|
||||||
|
FunctionName: name,
|
||||||
|
TriggerType: billing.TriggerEvent,
|
||||||
|
StartedAt: time.Now(),
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
RecordedBy: "console",
|
||||||
|
EventType: "delete",
|
||||||
|
})
|
||||||
|
|
||||||
|
writeAnyJSON(w, http.StatusOK, map[string]any{"deleted": true, "name": name, "package": pkgName})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetFunctionLogs возвращает логи пода функции (последние 100 строк).
|
||||||
|
// Ищет под по лейблу functionName=<name> в namespace пользователя.
|
||||||
|
func (s *Server) handleGetFunctionLogs(w http.ResponseWriter, r *http.Request, name string) {
|
||||||
|
ns := s.userNS(r)
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
if s.kube == nil {
|
||||||
|
writeJSONError(w, http.StatusServiceUnavailable, "kubernetes client not available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
labelSelector := "functionName=" + name
|
||||||
|
pods, err := s.kube.CoreV1().Pods(ns).List(ctx, metav1.ListOptions{
|
||||||
|
LabelSelector: labelSelector,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, "list pods: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(pods.Items) == 0 {
|
||||||
|
writeAnyJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"logs": "(нет запущенных подов для функции " + name + ")",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var allLogs strings.Builder
|
||||||
|
tailLines := int64(100)
|
||||||
|
for _, pod := range pods.Items {
|
||||||
|
containerName := ""
|
||||||
|
if len(pod.Spec.Containers) > 0 {
|
||||||
|
containerName = pod.Spec.Containers[0].Name
|
||||||
|
}
|
||||||
|
req := s.kube.CoreV1().Pods(ns).GetLogs(pod.Name, &corev1.PodLogOptions{
|
||||||
|
Container: containerName,
|
||||||
|
TailLines: &tailLines,
|
||||||
|
})
|
||||||
|
rc, err := req.Stream(ctx)
|
||||||
|
if err != nil {
|
||||||
|
allLogs.WriteString("[" + pod.Name + ": ошибка чтения логов: " + err.Error() + "]\n")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, _ := io.ReadAll(rc)
|
||||||
|
rc.Close()
|
||||||
|
if allLogs.Len() > 0 {
|
||||||
|
allLogs.WriteString("\n--- " + pod.Name + " ---\n")
|
||||||
|
} else {
|
||||||
|
allLogs.WriteString("--- " + pod.Name + " ---\n")
|
||||||
|
}
|
||||||
|
allLogs.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeAnyJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"logs": allLogs.String(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetFunctionEnvVars возвращает переменные окружения функции из .spec.runtime.container.env
|
||||||
|
func (s *Server) handleGetFunctionEnvVars(w http.ResponseWriter, r *http.Request, name string) {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
ns := s.userNS(r)
|
||||||
|
|
||||||
|
fn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
|
||||||
|
if err != nil {
|
||||||
|
status := http.StatusBadGateway
|
||||||
|
if apierrors.IsNotFound(err) {
|
||||||
|
status = http.StatusNotFound
|
||||||
|
}
|
||||||
|
writeJSONError(w, status, fmt.Sprintf("get function %q: %v", name, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
envVars := extractEnvVars(fn)
|
||||||
|
writeAnyJSON(w, http.StatusOK, map[string]any{"env_vars": envVars})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePutFunctionEnvVars обновляет переменные окружения функции.
|
||||||
|
//
|
||||||
|
// Логика переключения ExecutorType:
|
||||||
|
// - Если env vars непустые → ExecutorType: newdeploy + spec.podspec.containers[0].env
|
||||||
|
// (newdeploy создаёт dedicated Deployment, Kubernetes ставит env vars на уровне ОС)
|
||||||
|
// - Если env vars пустые → ExecutorType: poolmgr, podspec удаляется
|
||||||
|
// (poolmgr использует warm pool, быстрый cold start)
|
||||||
|
//
|
||||||
|
// Это единственный универсальный способ передать env vars в pod для всех языков
|
||||||
|
// (Python, Go, Ruby, PHP, Node.js) без изменений в env-серверах.
|
||||||
|
func (s *Server) handlePutFunctionEnvVars(w http.ResponseWriter, r *http.Request, name string) {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
ns := s.userNS(r)
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
EnvVars []map[string]string `json:"env_vars"` // [{name: "KEY", value: "VAL"}, ...]
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("decode request: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Валидация: имена переменных
|
||||||
|
for _, ev := range req.EnvVars {
|
||||||
|
k := ev["name"]
|
||||||
|
if k == "" {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, "env var name cannot be empty")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
|
||||||
|
if err != nil {
|
||||||
|
status := http.StatusBadGateway
|
||||||
|
if apierrors.IsNotFound(err) {
|
||||||
|
status = http.StatusNotFound
|
||||||
|
}
|
||||||
|
writeJSONError(w, status, fmt.Sprintf("get function %q: %v", name, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сериализуем в JSON для аннотации (для UI)
|
||||||
|
envJSON, err := json.Marshal(req.EnvVars)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("marshal env vars: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем аннотации
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
ann := fn.GetAnnotations()
|
||||||
|
if ann == nil {
|
||||||
|
ann = map[string]string{}
|
||||||
|
}
|
||||||
|
ann[functionUpdatedAtAnnotation] = now
|
||||||
|
ann["fission-console/env-vars"] = string(envJSON)
|
||||||
|
fn.SetAnnotations(ann)
|
||||||
|
|
||||||
|
if len(req.EnvVars) > 0 {
|
||||||
|
// Есть env vars → newdeploy + podspec с env vars
|
||||||
|
envName, _, _ := unstructured.NestedString(fn.Object, "spec", "environment", "name")
|
||||||
|
|
||||||
|
// Строим список env vars для Kubernetes
|
||||||
|
envList := make([]any, 0, len(req.EnvVars))
|
||||||
|
for _, ev := range req.EnvVars {
|
||||||
|
envList = append(envList, map[string]any{
|
||||||
|
"name": ev["name"],
|
||||||
|
"value": ev["value"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Устанавливаем podspec.containers[0] с env vars
|
||||||
|
// Имя контейнера = имя environment (стандарт Fission)
|
||||||
|
if err := unstructured.SetNestedSlice(fn.Object, []any{
|
||||||
|
map[string]any{
|
||||||
|
"name": envName,
|
||||||
|
"env": envList,
|
||||||
|
},
|
||||||
|
}, "spec", "podspec", "containers"); err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("set podspec: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Переключаем на newdeploy (только он поддерживает podspec env)
|
||||||
|
if err := unstructured.SetNestedField(fn.Object, map[string]any{
|
||||||
|
"ExecutionStrategy": map[string]any{
|
||||||
|
"ExecutorType": "newdeploy",
|
||||||
|
"MinScale": int64(0),
|
||||||
|
"MaxScale": int64(1),
|
||||||
|
"SpecializationTimeout": int64(120),
|
||||||
|
},
|
||||||
|
"StrategyType": "execution",
|
||||||
|
}, "spec", "InvokeStrategy"); err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("set invoke strategy: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Нет env vars → poolmgr, убираем podspec
|
||||||
|
unstructured.RemoveNestedField(fn.Object, "spec", "podspec")
|
||||||
|
|
||||||
|
if err := unstructured.SetNestedField(fn.Object, map[string]any{
|
||||||
|
"ExecutionStrategy": map[string]any{
|
||||||
|
"ExecutorType": "poolmgr",
|
||||||
|
"SpecializationTimeout": int64(120),
|
||||||
|
},
|
||||||
|
"StrategyType": "execution",
|
||||||
|
}, "spec", "InvokeStrategy"); err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("set invoke strategy: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Update(ctx, fn, metav1.UpdateOptions{}); err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("update function %q: %v", name, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fission newdeploy НЕ применяет fn.Spec.PodSpec при построении Deployment.
|
||||||
|
// Поэтому напрямую патчим существующий Deployment через Kubernetes API.
|
||||||
|
if len(req.EnvVars) > 0 && s.kube != nil {
|
||||||
|
envName, _, _ := unstructured.NestedString(fn.Object, "spec", "environment", "name")
|
||||||
|
if envName != "" {
|
||||||
|
if err := s.patchDeploymentEnvVars(ctx, ns, name, envName, req.EnvVars); err != nil {
|
||||||
|
// Не фатальная ошибка — CRD обновлён, Deployment будет обновлён позже
|
||||||
|
_ = err // warn only
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
executor := "poolmgr"
|
||||||
|
if len(req.EnvVars) > 0 {
|
||||||
|
executor = "newdeploy"
|
||||||
|
}
|
||||||
|
writeAnyJSON(w, http.StatusOK, map[string]any{"updated": true, "count": len(req.EnvVars), "executor": executor})
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractEnvVars читает аннотацию fission-console/env-vars (JSON) из Function CRD
|
||||||
|
// Возвращает [{name, value}, ...]
|
||||||
|
func extractEnvVars(fn *unstructured.Unstructured) []map[string]string {
|
||||||
|
ann := fn.GetAnnotations()
|
||||||
|
if ann == nil {
|
||||||
|
return []map[string]string{}
|
||||||
|
}
|
||||||
|
raw := ann["fission-console/env-vars"]
|
||||||
|
if raw == "" {
|
||||||
|
return []map[string]string{}
|
||||||
|
}
|
||||||
|
var result []map[string]string
|
||||||
|
if err := json.Unmarshal([]byte(raw), &result); err != nil {
|
||||||
|
return []map[string]string{}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// patchDeploymentEnvVars находит Deployment newdeploy для функции и патчит его env vars.
|
||||||
|
// Fission не применяет fn.Spec.PodSpec при построении Deployment, поэтому патчим напрямую.
|
||||||
|
// Поиск по labels: functionName=<name>, functionNamespace=<ns>, executorType=newdeploy
|
||||||
|
func (s *Server) patchDeploymentEnvVars(ctx context.Context, ns, fnName, envContainerName string, envVars []map[string]string) error {
|
||||||
|
selector := fmt.Sprintf("functionName=%s,functionNamespace=%s,executorType=newdeploy", fnName, ns)
|
||||||
|
deplList, err := s.kube.AppsV1().Deployments(ns).List(ctx, metav1.ListOptions{LabelSelector: selector})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list deployments: %w", err)
|
||||||
|
}
|
||||||
|
if len(deplList.Items) == 0 {
|
||||||
|
return nil // Deployment ещё не создан Fission — ничего страшного
|
||||||
|
}
|
||||||
|
|
||||||
|
// Строим env vars для patch (StrategicMergePatch мержит по "name")
|
||||||
|
envItems := make([]map[string]string, 0, len(envVars))
|
||||||
|
for _, ev := range envVars {
|
||||||
|
envItems = append(envItems, map[string]string{"name": ev["name"], "value": ev["value"]})
|
||||||
|
}
|
||||||
|
|
||||||
|
patch := map[string]any{
|
||||||
|
"spec": map[string]any{
|
||||||
|
"template": map[string]any{
|
||||||
|
"spec": map[string]any{
|
||||||
|
"containers": []any{
|
||||||
|
map[string]any{
|
||||||
|
"name": envContainerName,
|
||||||
|
"env": envItems,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
patchBytes, err := json.Marshal(patch)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal patch: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, depl := range deplList.Items {
|
||||||
|
if _, err := s.kube.AppsV1().Deployments(ns).Patch(
|
||||||
|
ctx, depl.Name, k8stypes.StrategicMergePatchType, patchBytes, metav1.PatchOptions{},
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("patch deployment %s: %w", depl.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,458 @@
|
|||||||
|
// Package api — вызов функций через Fission router.
|
||||||
|
//
|
||||||
|
// Этот файл содержит три способа вызова функций:
|
||||||
|
// - handleInvokeFunction — POST /functions/:name/invoke (через консоль, для тестирования)
|
||||||
|
// - handleInvokeRoute — /fn/<route> (публичный gateway, пользователь вызывает по своему маршруту)
|
||||||
|
// - handleFissionFunctionGateway — /fission-function/<ns>/<name> (внутренний gateway для cron/timer)
|
||||||
|
//
|
||||||
|
// Все три варианта проксируют запрос к Fission router с JWT-токеном router.
|
||||||
|
// Таймаут вызова берётся из spec.functionTimeout функции (или из конфига если не задан).
|
||||||
|
//
|
||||||
|
// Вспомогательные утилиты (buildInternalInvokeURL, copyProxyRequestHeaders и др.) — в этом же файле.
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fission-console/internal/billing"
|
||||||
|
"fission-console/internal/fission"
|
||||||
|
|
||||||
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||||
|
)
|
||||||
|
|
||||||
|
// resolveInvokeTimeout возвращает таймаут вызова функции.
|
||||||
|
// Приоритет: spec.functionTimeout функции → конфиг сервера → defaultFunctionInvokeTimeout.
|
||||||
|
func (s *Server) resolveInvokeTimeout(fn *unstructured.Unstructured) time.Duration {
|
||||||
|
if fn != nil {
|
||||||
|
seconds, found, err := unstructured.NestedInt64(fn.Object, "spec", "functionTimeout")
|
||||||
|
if err == nil && found && seconds > 0 {
|
||||||
|
return time.Duration(seconds) * time.Second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.invokeTimeout > 0 {
|
||||||
|
return s.invokeTimeout
|
||||||
|
}
|
||||||
|
return defaultFunctionInvokeTimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildInternalInvokeURL строит URL для вызова функции через Fission router.
|
||||||
|
// Для namespace "default" — /fission-function/<name>.
|
||||||
|
// Для остальных — /fission-function/<namespace>/<name>.
|
||||||
|
func buildInternalInvokeURL(routerURL, namespace, functionName string) string {
|
||||||
|
if namespace == "default" || namespace == "" {
|
||||||
|
return fmt.Sprintf("%s/fission-function/%s", routerURL, functionName)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s/fission-function/%s/%s", routerURL, namespace, functionName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// shouldForwardRequestBody возвращает true если метод подразумевает тело запроса.
|
||||||
|
// GET и HEAD не имеют тела — тело не проксируется.
|
||||||
|
func shouldForwardRequestBody(method string) bool {
|
||||||
|
switch method {
|
||||||
|
case http.MethodGet, http.MethodHead:
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyProxyRequestHeaders копирует заголовки из входящего запроса в исходящий.
|
||||||
|
// Пропускаем служебные заголовки чтобы не перезаписать их при проксировании.
|
||||||
|
func copyProxyRequestHeaders(dst, src http.Header) {
|
||||||
|
for key, values := range src {
|
||||||
|
switch http.CanonicalHeaderKey(key) {
|
||||||
|
case "Authorization", "X-Auth-Token", "X-Auth-Env", "Host", "Content-Length":
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, value := range values {
|
||||||
|
dst.Add(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyProxyResponseHeaders копирует все заголовки из upstream-ответа в ответ клиенту.
|
||||||
|
func copyProxyResponseHeaders(dst, src http.Header) {
|
||||||
|
for key, values := range src {
|
||||||
|
for _, value := range values {
|
||||||
|
dst.Add(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// doRequestWithContextTimeout выполняет HTTP-запрос без глобального таймаута клиента.
|
||||||
|
// Реальный лимит задаётся через context — это позволяет функции иметь свой таймаут
|
||||||
|
// независимо от общего HTTP-таймаута console.
|
||||||
|
func doRequestWithContextTimeout(client *http.Client, req *http.Request) (*http.Response, error) {
|
||||||
|
if client == nil {
|
||||||
|
return http.DefaultClient.Do(req)
|
||||||
|
}
|
||||||
|
invokeClient := *client
|
||||||
|
invokeClient.Timeout = 0
|
||||||
|
return invokeClient.Do(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleInvokeFunction вызывает функцию через Fission router.
|
||||||
|
// Определяет реальный URL из HTTPTrigger, выбирает метод (POST/GET).
|
||||||
|
func (s *Server) handleInvokeFunction(w http.ResponseWriter, r *http.Request, name string) {
|
||||||
|
bodyBytes, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("read request body: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(bytes.TrimSpace(bodyBytes)) == 0 {
|
||||||
|
bodyBytes = []byte("{}")
|
||||||
|
}
|
||||||
|
|
||||||
|
ns := s.userNS(r)
|
||||||
|
lookupCtx, lookupCancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||||
|
defer lookupCancel()
|
||||||
|
|
||||||
|
// Проверяем существование функции до вызова — лучше 404 чем непонятный timeout
|
||||||
|
fn, err2 := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(lookupCtx, name, metav1.GetOptions{})
|
||||||
|
if err2 != nil {
|
||||||
|
if apierrors.IsNotFound(err2) {
|
||||||
|
writeJSONError(w, http.StatusNotFound, fmt.Sprintf("function %q not found", name))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("get function %q: %v", name, err2))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
invokeTimeout := s.resolveInvokeTimeout(fn)
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), invokeTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Ищем HTTPTrigger чтобы получить реальный URL и метод
|
||||||
|
invokeURL := buildInternalInvokeURL(s.routerURL, ns, name)
|
||||||
|
invokeMethod := http.MethodPost
|
||||||
|
triggers, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).List(ctx, metav1.ListOptions{})
|
||||||
|
if err == nil {
|
||||||
|
for _, trig := range triggers.Items {
|
||||||
|
refName, _, _ := unstructured.NestedString(trig.Object, "spec", "functionref", "name")
|
||||||
|
if refName != name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
route, _, _ := unstructured.NestedString(trig.Object, "spec", "relativeurl")
|
||||||
|
methods, _, _ := unstructured.NestedStringSlice(trig.Object, "spec", "methods")
|
||||||
|
hasPost, hasGet := false, false
|
||||||
|
for _, m := range methods {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(m)) {
|
||||||
|
case http.MethodPost:
|
||||||
|
hasPost = true
|
||||||
|
case http.MethodGet:
|
||||||
|
hasGet = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if route != "" {
|
||||||
|
if !strings.HasPrefix(route, "/") {
|
||||||
|
route = "/" + route
|
||||||
|
}
|
||||||
|
invokeURL = s.routerURL + route
|
||||||
|
// Если функция поддерживает только GET — используем GET
|
||||||
|
if !hasPost && hasGet {
|
||||||
|
invokeMethod = http.MethodGet
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
var invokeBody io.Reader
|
||||||
|
if invokeMethod == http.MethodPost {
|
||||||
|
invokeBody = bytes.NewReader(bodyBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, invokeMethod, invokeURL, invokeBody)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build invoke request: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if invokeMethod == http.MethodPost {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
if token := s.getRouterToken(); token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := doRequestWithContextTimeout(s.http, req)
|
||||||
|
if err != nil {
|
||||||
|
// Отличаем timeout от сетевой ошибки.
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("invoke %q timeout after %s", name, invokeTimeout))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var netErr net.Error
|
||||||
|
if errors.As(err, &netErr) && netErr.Timeout() {
|
||||||
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("invoke %q timeout after %s", name, invokeTimeout))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("invoke %q: %v", name, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
durationMS := time.Since(start).Milliseconds()
|
||||||
|
s.billing.RecordInvocation(billing.Invocation{
|
||||||
|
Namespace: ns,
|
||||||
|
FunctionName: name,
|
||||||
|
TriggerType: billing.TriggerConsole,
|
||||||
|
Route: invokeURL,
|
||||||
|
HTTPMethod: invokeMethod,
|
||||||
|
StartedAt: start,
|
||||||
|
DurationMS: durationMS,
|
||||||
|
StatusCode: resp.StatusCode,
|
||||||
|
RequestBytes: int64(len(bodyBytes)),
|
||||||
|
ResponseBytes: int64(len(respBody)),
|
||||||
|
RecordedBy: "console",
|
||||||
|
EventType: "invoke",
|
||||||
|
})
|
||||||
|
|
||||||
|
writeAnyJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"status": resp.StatusCode,
|
||||||
|
"latency_ms": durationMS,
|
||||||
|
"response_raw": string(respBody),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleFissionFunctionGateway принимает внутренние invoke-запросы timer/router
|
||||||
|
// и проксирует их через console в upstream router с корректным router JWT.
|
||||||
|
func (s *Server) handleFissionFunctionGateway(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rawPath := strings.Trim(strings.TrimPrefix(r.URL.Path, "/fission-function"), "/")
|
||||||
|
if rawPath == "" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(rawPath, "/")
|
||||||
|
namespace := s.ns
|
||||||
|
functionName := ""
|
||||||
|
remainingPath := ""
|
||||||
|
|
||||||
|
if len(parts) == 1 {
|
||||||
|
functionName = strings.TrimSpace(parts[0])
|
||||||
|
} else {
|
||||||
|
namespace = strings.TrimSpace(parts[0])
|
||||||
|
functionName = strings.TrimSpace(parts[1])
|
||||||
|
if len(parts) > 2 {
|
||||||
|
remainingPath = "/" + strings.Join(parts[2:], "/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if namespace == "" || functionName == "" {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, "namespace and function name are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.invokeInternalFunction(w, r, namespace, functionName, remainingPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// invokeInternalFunction проксирует вызов функции к Fission router.
|
||||||
|
// Используется как из handleFissionFunctionGateway (cron/timer), так и из handleInvokeRoute.
|
||||||
|
func (s *Server) invokeInternalFunction(w http.ResponseWriter, r *http.Request, namespace, functionName, extraPath string) {
|
||||||
|
bodyBytes, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("read request body: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(bytes.TrimSpace(bodyBytes)) == 0 {
|
||||||
|
bodyBytes = []byte("{}")
|
||||||
|
}
|
||||||
|
|
||||||
|
lookupCtx, lookupCancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||||
|
defer lookupCancel()
|
||||||
|
|
||||||
|
fn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(namespace).Get(lookupCtx, functionName, metav1.GetOptions{})
|
||||||
|
if err != nil {
|
||||||
|
if apierrors.IsNotFound(err) {
|
||||||
|
writeJSONError(w, http.StatusNotFound, fmt.Sprintf("function %q not found", functionName))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("get function %q: %v", functionName, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
invokeTimeout := s.resolveInvokeTimeout(fn)
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), invokeTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
invokeURL := buildInternalInvokeURL(s.routerURL, namespace, functionName) + extraPath
|
||||||
|
if r.URL.RawQuery != "" {
|
||||||
|
invokeURL += "?" + r.URL.RawQuery
|
||||||
|
}
|
||||||
|
|
||||||
|
var invokeBody io.Reader
|
||||||
|
if shouldForwardRequestBody(r.Method) {
|
||||||
|
invokeBody = bytes.NewReader(bodyBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, r.Method, invokeURL, invokeBody)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build invoke request: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if shouldForwardRequestBody(r.Method) {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
copyProxyRequestHeaders(req.Header, r.Header)
|
||||||
|
if token := s.getRouterToken(); token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
resp, err := doRequestWithContextTimeout(s.http, req)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("invoke %q timeout after %s", functionName, invokeTimeout))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var netErr net.Error
|
||||||
|
if errors.As(err, &netErr) && netErr.Timeout() {
|
||||||
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("invoke %q timeout after %s", functionName, invokeTimeout))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("invoke %q: %v", functionName, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
durationMS := time.Since(start).Milliseconds()
|
||||||
|
s.billing.RecordInvocation(billing.Invocation{
|
||||||
|
Namespace: namespace,
|
||||||
|
FunctionName: functionName,
|
||||||
|
TriggerType: billing.TriggerHTTP,
|
||||||
|
Route: extraPath,
|
||||||
|
HTTPMethod: r.Method,
|
||||||
|
StartedAt: start,
|
||||||
|
DurationMS: durationMS,
|
||||||
|
StatusCode: resp.StatusCode,
|
||||||
|
RequestBytes: int64(len(bodyBytes)),
|
||||||
|
ResponseBytes: int64(len(respBody)),
|
||||||
|
RecordedBy: "console",
|
||||||
|
EventType: "invoke",
|
||||||
|
})
|
||||||
|
|
||||||
|
writeAnyJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"status": resp.StatusCode,
|
||||||
|
"latency_ms": durationMS,
|
||||||
|
"response_raw": string(respBody),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleInvokeRoute даёт пользователю прямой HTTP gateway к своей функции по route.
|
||||||
|
// Внешний контракт: /fn/<route> + Authorization: Bearer <user-token>.
|
||||||
|
func (s *Server) handleInvokeRoute(w http.ResponseWriter, r *http.Request) {
|
||||||
|
route := normalizeRoute(strings.TrimPrefix(r.URL.Path, "/fn"))
|
||||||
|
if route == "/" {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, "route is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ns := s.userNS(r)
|
||||||
|
lookupCtx, lookupCancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||||
|
defer lookupCancel()
|
||||||
|
|
||||||
|
triggers, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).List(lookupCtx, metav1.ListOptions{})
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("list httptriggers: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
matchedFunction := ""
|
||||||
|
allowedMethods := make([]string, 0, 4)
|
||||||
|
for _, trig := range triggers.Items {
|
||||||
|
trigRoute, _, _ := unstructured.NestedString(trig.Object, "spec", "relativeurl")
|
||||||
|
if normalizeRoute(trigRoute) != route {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
methods, _, _ := unstructured.NestedStringSlice(trig.Object, "spec", "methods")
|
||||||
|
allowedMethods = appendUniqueMethods(allowedMethods, methods)
|
||||||
|
if !routeAllowsMethod(methods, r.Method) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
matchedFunction, _, _ = unstructured.NestedString(trig.Object, "spec", "functionref", "name")
|
||||||
|
if matchedFunction != "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if matchedFunction == "" {
|
||||||
|
if len(allowedMethods) > 0 {
|
||||||
|
w.Header().Set("Allow", strings.Join(allowedMethods, ", "))
|
||||||
|
writeJSONError(w, http.StatusMethodNotAllowed, fmt.Sprintf("route %q does not allow method %s", route, r.Method))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSONError(w, http.StatusNotFound, fmt.Sprintf("route %q not found", route))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(lookupCtx, matchedFunction, metav1.GetOptions{})
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("get function %q: %v", matchedFunction, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("read request body: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
invokeTimeout := s.resolveInvokeTimeout(fn)
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), invokeTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
invokeURL := s.routerURL + route
|
||||||
|
if r.URL.RawQuery != "" {
|
||||||
|
invokeURL += "?" + r.URL.RawQuery
|
||||||
|
}
|
||||||
|
|
||||||
|
var invokeBody io.Reader
|
||||||
|
if shouldForwardRequestBody(r.Method) {
|
||||||
|
invokeBody = bytes.NewReader(bodyBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, r.Method, invokeURL, invokeBody)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build invoke request: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
copyProxyRequestHeaders(req.Header, r.Header)
|
||||||
|
if token := s.getRouterToken(); token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := doRequestWithContextTimeout(s.http, req)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("invoke route %q timeout after %s", route, invokeTimeout))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var netErr net.Error
|
||||||
|
if errors.As(err, &netErr) && netErr.Timeout() {
|
||||||
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("invoke route %q timeout after %s", route, invokeTimeout))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("invoke route %q: %v", route, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
copyProxyResponseHeaders(w.Header(), resp.Header)
|
||||||
|
w.WriteHeader(resp.StatusCode)
|
||||||
|
_, _ = io.Copy(w, resp.Body)
|
||||||
|
}
|
||||||
+76
-1402
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -16,6 +17,7 @@ import (
|
|||||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ⛔⛔⛔ НЕ МЕНЯТЬ БЕЗ ЯВНОГО РАЗРЕШЕНИЯ ВЛАДЕЛЬЦА.
|
||||||
// extractPackageSourceCode пытается извлечь исходный код из Fission Package.
|
// extractPackageSourceCode пытается извлечь исходный код из Fission Package.
|
||||||
// Порядок попыток:
|
// Порядок попыток:
|
||||||
// 1. spec.source.literal (base64) — Go функции (source package)
|
// 1. spec.source.literal (base64) — Go функции (source package)
|
||||||
@@ -51,10 +53,13 @@ func extractPackageSourceCode(ctx context.Context, s *Server, pkg *unstructured.
|
|||||||
}
|
}
|
||||||
archiveBytes, err := fetchPackageArchive(ctx, s, urlValue)
|
archiveBytes, err := fetchPackageArchive(ctx, s, urlValue)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("extractPackageSourceCode: fetchPackageArchive %s: %v", urlValue, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if code, err := decodeArchiveBytesToSource(archiveBytes); err == nil && strings.TrimSpace(code) != "" {
|
if code, err := decodeArchiveBytesToSource(archiveBytes); err == nil && strings.TrimSpace(code) != "" {
|
||||||
return code
|
return code
|
||||||
|
} else {
|
||||||
|
log.Printf("extractPackageSourceCode: decodeArchiveBytesToSource %s: err=%v, len=%d", urlValue, err, len(archiveBytes))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"fission-console/internal/auth"
|
"fission-console/internal/auth"
|
||||||
|
"fission-console/internal/billing"
|
||||||
"fission-console/internal/cloud"
|
"fission-console/internal/cloud"
|
||||||
"fission-console/internal/fission"
|
"fission-console/internal/fission"
|
||||||
"fission-console/ui"
|
"fission-console/ui"
|
||||||
@@ -21,6 +22,7 @@ import (
|
|||||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||||
"k8s.io/client-go/dynamic"
|
"k8s.io/client-go/dynamic"
|
||||||
|
"k8s.io/client-go/kubernetes"
|
||||||
)
|
)
|
||||||
|
|
||||||
// defaultSATokenPath — путь к service account токену внутри pod-а.
|
// defaultSATokenPath — путь к service account токену внутри pod-а.
|
||||||
@@ -31,6 +33,7 @@ const defaultSATokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token"
|
|||||||
// Содержит все зависимости: kubernetes client, конфиги, кэши.
|
// Содержит все зависимости: kubernetes client, конфиги, кэши.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
dyn dynamic.Interface
|
dyn dynamic.Interface
|
||||||
|
kube kubernetes.Interface // typed client — только для логов подов
|
||||||
ns string // системный namespace (fallback, обычно "fission")
|
ns string // системный namespace (fallback, обычно "fission")
|
||||||
routerURL string
|
routerURL string
|
||||||
http *http.Client
|
http *http.Client
|
||||||
@@ -57,11 +60,15 @@ type Server struct {
|
|||||||
|
|
||||||
// nsManager управляет жизненным циклом пользовательских namespace-ов.
|
// nsManager управляет жизненным циклом пользовательских namespace-ов.
|
||||||
nsManager *cloud.NSManager
|
nsManager *cloud.NSManager
|
||||||
|
|
||||||
|
// billing — слой записи статистики вызовов. NoopStore если BILLING_DSN не задан.
|
||||||
|
billing billing.Store
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config содержит все параметры для создания Server.
|
// Config содержит все параметры для создания Server.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Dyn dynamic.Interface
|
Dyn dynamic.Interface
|
||||||
|
Kube kubernetes.Interface
|
||||||
Namespace string
|
Namespace string
|
||||||
RouterURL string
|
RouterURL string
|
||||||
HTTPTimeout time.Duration
|
HTTPTimeout time.Duration
|
||||||
@@ -73,6 +80,7 @@ type Config struct {
|
|||||||
Authenticator auth.Authenticator // слой аутентификации
|
Authenticator auth.Authenticator // слой аутентификации
|
||||||
LLMUrl string
|
LLMUrl string
|
||||||
LLMKey string
|
LLMKey string
|
||||||
|
Billing billing.Store // слой статистики (NoopStore если не задан)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer создаёт и настраивает HTTP Server со всеми зависимостями.
|
// NewServer создаёт и настраивает HTTP Server со всеми зависимостями.
|
||||||
@@ -80,6 +88,7 @@ func NewServer(cfg Config) *Server {
|
|||||||
log.Printf("NewServer: storagesvcURL=%q", cfg.StoragesvcURL)
|
log.Printf("NewServer: storagesvcURL=%q", cfg.StoragesvcURL)
|
||||||
return &Server{
|
return &Server{
|
||||||
dyn: cfg.Dyn,
|
dyn: cfg.Dyn,
|
||||||
|
kube: cfg.Kube,
|
||||||
ns: cfg.Namespace,
|
ns: cfg.Namespace,
|
||||||
routerURL: cfg.RouterURL,
|
routerURL: cfg.RouterURL,
|
||||||
http: &http.Client{Timeout: cfg.HTTPTimeout},
|
http: &http.Client{Timeout: cfg.HTTPTimeout},
|
||||||
@@ -92,6 +101,7 @@ func NewServer(cfg Config) *Server {
|
|||||||
llmURL: cfg.LLMUrl,
|
llmURL: cfg.LLMUrl,
|
||||||
llmKey: cfg.LLMKey,
|
llmKey: cfg.LLMKey,
|
||||||
nsManager: cloud.NewNSManager(cfg.Dyn),
|
nsManager: cloud.NewNSManager(cfg.Dyn),
|
||||||
|
billing: cfg.Billing,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
// Package api — определение статуса функции.
|
||||||
|
//
|
||||||
|
// Этот файл содержит функции для определения статуса функции на основе:
|
||||||
|
// - buildStatus пакета (Package CRD)
|
||||||
|
// - состояния pod-а (kubernetes Pod)
|
||||||
|
//
|
||||||
|
// Возможные статусы:
|
||||||
|
// - "Ready" (зелёный) — Pod в состоянии Running
|
||||||
|
// - "Cold" (синий) — функция готова, но нет активного Pod
|
||||||
|
// - "Building" (жёлтый) — Package ещё собирается (buildStatus != "succeeded")
|
||||||
|
// - "Error" (красный) — Pod в состоянии ошибки (CrashLoopBackOff, ImagePullBackOff и т.д.) или build failed
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fission-console/internal/fission"
|
||||||
|
|
||||||
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FunctionStatus — расширенный объект Function с полем status.
|
||||||
|
type FunctionStatus struct {
|
||||||
|
// Встраиваем исходный объект и добавляем status
|
||||||
|
Object map[string]any `json:"object,inline"`
|
||||||
|
Status string `json:"status"` // "Ready", "Cold", "Building", "Error"
|
||||||
|
}
|
||||||
|
|
||||||
|
// getFunctionStatus определяет статус функции на основе pod-ов и Package.
|
||||||
|
func (s *Server) getFunctionStatus(ctx context.Context, ns string, fn *unstructured.Unstructured) (string, error) {
|
||||||
|
fnName := fn.GetName()
|
||||||
|
|
||||||
|
// Шаг 1: проверяем buildStatus пакета (если есть)
|
||||||
|
packageName, _, _ := unstructured.NestedString(fn.Object, "spec", "package", "packageref", "name")
|
||||||
|
if packageName != "" {
|
||||||
|
pkg, pkgErr := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Get(ctx, packageName, metav1.GetOptions{})
|
||||||
|
if pkgErr == nil {
|
||||||
|
buildStatus, _, _ := unstructured.NestedString(pkg.Object, "status", "buildstatus")
|
||||||
|
// "failed" → Error
|
||||||
|
// "none", "succeeded" → OK, Package готов
|
||||||
|
// "pending", "building" и т.д. → Building
|
||||||
|
if buildStatus == "failed" {
|
||||||
|
return "Error", nil
|
||||||
|
}
|
||||||
|
if buildStatus != "" && buildStatus != "succeeded" && buildStatus != "none" {
|
||||||
|
// Building, Pending и т.д.
|
||||||
|
return "Building", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Шаг 2: ищем Pod-ы для этой функции (по лейблу functionName=<name>)
|
||||||
|
pods, err := s.kube.CoreV1().Pods(ns).List(ctx, metav1.ListOptions{
|
||||||
|
LabelSelector: fmt.Sprintf("functionName=%s", fnName),
|
||||||
|
})
|
||||||
|
if err != nil && !apierrors.IsNotFound(err) {
|
||||||
|
// Логируем ошибку, но не падаем — возвращаем "Cold" (функция есть, но pod-а нет)
|
||||||
|
return "Cold", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Шаг 3: анализируем состояние pod-ов
|
||||||
|
if pods != nil && len(pods.Items) > 0 {
|
||||||
|
for _, pod := range pods.Items {
|
||||||
|
// Проверяем фазу pod-а
|
||||||
|
phase := pod.Status.Phase
|
||||||
|
if phase == "Running" {
|
||||||
|
// Дополнительная проверка: все контейнеры готовы?
|
||||||
|
allReady := true
|
||||||
|
for _, status := range pod.Status.ContainerStatuses {
|
||||||
|
if !status.Ready {
|
||||||
|
allReady = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if allReady {
|
||||||
|
return "Ready", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем состояние контейнеров (CrashLoopBackOff, ImagePullBackOff и т.д.)
|
||||||
|
for _, status := range pod.Status.ContainerStatuses {
|
||||||
|
if status.State.Waiting != nil && status.State.Waiting.Reason == "CrashLoopBackOff" {
|
||||||
|
return "Error", nil
|
||||||
|
}
|
||||||
|
if status.State.Waiting != nil && status.State.Waiting.Reason == "ImagePullBackOff" {
|
||||||
|
return "Error", nil
|
||||||
|
}
|
||||||
|
if status.LastTerminationState.Terminated != nil && status.LastTerminationState.Terminated.ExitCode != 0 {
|
||||||
|
return "Error", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pod есть, но не в Running или Error состоянии → Building/Deploying
|
||||||
|
return "Building", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Нет pod-ов → Cold (функция холодная, но готова)
|
||||||
|
return "Cold", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListFunctionsWithStatus возвращает список функций с полем "status".
|
||||||
|
// Добавляет status в поле .metadata.annotations["fission-console/status"] каждой функции.
|
||||||
|
func (s *Server) handleListFunctionsWithStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) // 30 сек для определения статуса всех функций
|
||||||
|
defer cancel()
|
||||||
|
ns := s.userNS(r)
|
||||||
|
|
||||||
|
// Получаем список функций
|
||||||
|
list, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).List(ctx, metav1.ListOptions{})
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("list functions: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обогащаем каждую функцию статусом (добавляем в .metadata.annotations["fission-console/status"])
|
||||||
|
for i := range list.Items {
|
||||||
|
status, _ := s.getFunctionStatus(ctx, ns, &list.Items[i])
|
||||||
|
anns := list.Items[i].GetAnnotations()
|
||||||
|
if anns == nil {
|
||||||
|
anns = make(map[string]string)
|
||||||
|
}
|
||||||
|
anns["fission-console/status"] = status
|
||||||
|
list.Items[i].SetAnnotations(anns)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, list.Items)
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// Package api — работа с Fission storagesvc (S3-совместимое хранилище архивов).
|
||||||
|
//
|
||||||
|
// Этот файл содержит низкоуровневые операции загрузки и удаления архивов через
|
||||||
|
// HTTP API storagesvc. Storagesvc хранит zip-архивы функций в S3 (bucket sless-functions).
|
||||||
|
// Package CRD ссылается на архив через spec.deployment.url (type: url).
|
||||||
|
//
|
||||||
|
// Используется из function_code.go и function_archive.go для сохранения/замены кода функций.
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// deleteFromStoragesvc удаляет архив из S3 через storagesvc по URL из Package spec.
|
||||||
|
// URL имеет формат: http://storagesvc.../v1/archive?id=fission/UUID
|
||||||
|
// Best-effort: ошибка логируется, но не прерывает операцию удаления.
|
||||||
|
func (s *Server) deleteFromStoragesvc(ctx context.Context, archiveURL string) {
|
||||||
|
if s.storagesvcURL == "" || archiveURL == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// archiveURL = "http://storagesvc.../v1/archive?id=fission/UUID"
|
||||||
|
// Строим DELETE URL к storagesvc, сохраняя query-параметр id
|
||||||
|
parsed, err := url.Parse(archiveURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("deleteFromStoragesvc: parse url %q: %v", archiveURL, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deleteURL := strings.TrimRight(s.storagesvcURL, "/") + "/v1/archive?" + parsed.RawQuery
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, deleteURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("deleteFromStoragesvc: build request: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := s.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("deleteFromStoragesvc: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
log.Printf("deleteFromStoragesvc: status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("deleteFromStoragesvc: deleted %s", parsed.Query().Get("id"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// uploadToStoragesvc загружает байты в Fission storagesvc и возвращает URL для package archive.
|
||||||
|
// Если storagesvcURL не задан — возвращает пустую строку (fallback на literal).
|
||||||
|
func (s *Server) uploadToStoragesvc(ctx context.Context, data []byte) (string, error) {
|
||||||
|
if s.storagesvcURL == "" {
|
||||||
|
log.Printf("uploadToStoragesvc: storagesvcURL is empty, skip upload")
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
log.Printf("uploadToStoragesvc: uploading %d bytes to %s", len(data), s.storagesvcURL)
|
||||||
|
uploadURL := strings.TrimRight(s.storagesvcURL, "/") + "/v1/archive"
|
||||||
|
body := &bytes.Reader{}
|
||||||
|
// multipart/form-data с полем uploadfile
|
||||||
|
var buf bytes.Buffer
|
||||||
|
boundary := fmt.Sprintf("fission%d", time.Now().UnixNano())
|
||||||
|
buf.WriteString("--" + boundary + "\r\n")
|
||||||
|
buf.WriteString(fmt.Sprintf("Content-Disposition: form-data; name=\"uploadfile\"; filename=\"archive.zip\"\r\n"))
|
||||||
|
buf.WriteString("Content-Type: application/octet-stream\r\n\r\n")
|
||||||
|
buf.Write(data)
|
||||||
|
buf.WriteString("\r\n--" + boundary + "--\r\n")
|
||||||
|
body = bytes.NewReader(buf.Bytes())
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("build storagesvc upload request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "multipart/form-data; boundary="+boundary)
|
||||||
|
req.Header.Set("X-File-Size", fmt.Sprintf("%d", len(data)))
|
||||||
|
|
||||||
|
resp, err := s.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("storagesvc upload: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||||
|
return "", fmt.Errorf("storagesvc upload status %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
|
||||||
|
}
|
||||||
|
var result struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(respBody, &result); err != nil || result.ID == "" {
|
||||||
|
return "", fmt.Errorf("storagesvc upload: bad response: %s", string(respBody))
|
||||||
|
}
|
||||||
|
archiveURL := strings.TrimRight(s.storagesvcURL, "/") + "/v1/archive?id=" + result.ID
|
||||||
|
return archiveURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildDeploySpec строит spec.deployment для Fission Package.
|
||||||
|
// Если storagesvcURL задан — загружает архив в S3 через storagesvc и возвращает type: url.
|
||||||
|
// Иначе — возвращает type: literal с base64-кодом.
|
||||||
|
func (s *Server) buildDeploySpec(ctx context.Context, data []byte) (map[string]any, error) {
|
||||||
|
archiveURL, err := s.uploadToStoragesvc(ctx, data)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("storagesvc upload failed, falling back to literal: %v", err)
|
||||||
|
// fallback — сохраняем как literal
|
||||||
|
return map[string]any{"type": "literal", "literal": base64.StdEncoding.EncodeToString(data)}, nil
|
||||||
|
}
|
||||||
|
if archiveURL == "" {
|
||||||
|
return map[string]any{"type": "literal", "literal": base64.StdEncoding.EncodeToString(data)}, nil
|
||||||
|
}
|
||||||
|
return map[string]any{"type": "url", "url": archiveURL}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// Package billing — слой записи статистики вызовов функций.
|
||||||
|
//
|
||||||
|
// Независим от типа БД: снаружи виден только интерфейс Store.
|
||||||
|
// Если BILLING_DSN не задан — работает NoopStore (тихо, не крашит).
|
||||||
|
package billing
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// TriggerType описывает источник вызова.
|
||||||
|
const (
|
||||||
|
TriggerHTTP = "http" // вызов через /fn/... снаружи
|
||||||
|
TriggerCron = "cron" // вызов по расписанию
|
||||||
|
TriggerConsole = "console" // вызов через кнопку «Вызов» в UI
|
||||||
|
TriggerEvent = "event" // lifecycle: create/delete/clone/update
|
||||||
|
)
|
||||||
|
|
||||||
|
// Invocation — одна запись о вызове или событии функции.
|
||||||
|
type Invocation struct {
|
||||||
|
Namespace string // пользовательский namespace (= пользователь)
|
||||||
|
FunctionName string // имя функции
|
||||||
|
TriggerType string // TriggerHTTP / TriggerCron / TriggerConsole / TriggerEvent
|
||||||
|
Route string // HTTP маршрут (/abc123/my-func), пусто для event
|
||||||
|
HTTPMethod string // GET/POST/... пусто для event
|
||||||
|
StartedAt time.Time // время начала
|
||||||
|
DurationMS int64 // длительность в миллисекундах (0 для event)
|
||||||
|
StatusCode int // HTTP статус ответа (0 для event)
|
||||||
|
ColdStart bool // true = первый вызов после создания/простоя
|
||||||
|
RequestBytes int64 // размер тела запроса
|
||||||
|
ResponseBytes int64 // размер тела ответа
|
||||||
|
ErrorMsg string // сообщение об ошибке, пусто если успех
|
||||||
|
RecordedBy string // "console" | "router"
|
||||||
|
EventType string // для TriggerEvent: "create" | "delete" | "clone" | "update" | "invoke"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store — интерфейс записи статистики.
|
||||||
|
// Реализации: pgStore (PostgreSQL через pgx), NoopStore (заглушка).
|
||||||
|
type Store interface {
|
||||||
|
// RecordInvocation записывает одно событие асинхронно (fire-and-forget).
|
||||||
|
RecordInvocation(inv Invocation)
|
||||||
|
// Close освобождает ресурсы (пул соединений и т.д.).
|
||||||
|
Close()
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package billing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewStore создаёт Store из переменной окружения BILLING_DSN.
|
||||||
|
// Если DSN пустой — возвращает NoopStore (статистика отключена, сервер работает нормально).
|
||||||
|
func NewStore() Store {
|
||||||
|
dsn := os.Getenv("BILLING_DSN")
|
||||||
|
if dsn == "" {
|
||||||
|
log.Printf("billing: BILLING_DSN not set, statistics disabled")
|
||||||
|
return NoopStore{}
|
||||||
|
}
|
||||||
|
store, err := NewPostgresStore(context.Background(), dsn)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("billing: failed to connect to PostgreSQL: %v — statistics disabled", err)
|
||||||
|
return NoopStore{}
|
||||||
|
}
|
||||||
|
return store
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package billing
|
||||||
|
|
||||||
|
// NoopStore — заглушка Store, которая тихо игнорирует все записи.
|
||||||
|
// Используется когда BILLING_DSN не задан.
|
||||||
|
type NoopStore struct{}
|
||||||
|
|
||||||
|
func (NoopStore) RecordInvocation(_ Invocation) {}
|
||||||
|
func (NoopStore) Close() {}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package billing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pgStore — реализация Store поверх PostgreSQL через pgx connection pool.
|
||||||
|
type pgStore struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPostgresStore создаёт Store с пулом соединений к PostgreSQL.
|
||||||
|
// dsn — строка вида postgres://user:pass@host:5432/dbname
|
||||||
|
// При ошибке подключения — возвращает ошибку, вызывающий код решает как реагировать.
|
||||||
|
func NewPostgresStore(ctx context.Context, dsn string) (Store, error) {
|
||||||
|
cfg, err := pgxpool.ParseConfig(dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.MaxConns = 4
|
||||||
|
cfg.MinConns = 1
|
||||||
|
cfg.MaxConnIdleTime = 5 * time.Minute
|
||||||
|
|
||||||
|
pool, err := pgxpool.NewWithConfig(ctx, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Проверяем живость соединения
|
||||||
|
if err := pool.Ping(ctx); err != nil {
|
||||||
|
pool.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
log.Printf("billing: connected to PostgreSQL")
|
||||||
|
return &pgStore{pool: pool}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordInvocation вставляет запись асинхронно — не блокирует основной поток.
|
||||||
|
func (s *pgStore) RecordInvocation(inv Invocation) {
|
||||||
|
go func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO invocations (
|
||||||
|
namespace, function_name, trigger_type, route, http_method,
|
||||||
|
started_at, duration_ms, status_code, cold_start,
|
||||||
|
request_bytes, response_bytes, error_msg, recorded_by, event_type
|
||||||
|
) VALUES (
|
||||||
|
$1,$2,$3,$4,$5,
|
||||||
|
$6,$7,$8,$9,
|
||||||
|
$10,$11,$12,$13,$14
|
||||||
|
)`,
|
||||||
|
inv.Namespace,
|
||||||
|
inv.FunctionName,
|
||||||
|
inv.TriggerType,
|
||||||
|
inv.Route,
|
||||||
|
inv.HTTPMethod,
|
||||||
|
inv.StartedAt,
|
||||||
|
inv.DurationMS,
|
||||||
|
inv.StatusCode,
|
||||||
|
inv.ColdStart,
|
||||||
|
inv.RequestBytes,
|
||||||
|
inv.ResponseBytes,
|
||||||
|
nullIfEmpty(inv.ErrorMsg),
|
||||||
|
inv.RecordedBy,
|
||||||
|
nullIfEmpty(inv.EventType),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("billing: insert invocation: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close закрывает пул соединений.
|
||||||
|
func (s *pgStore) Close() {
|
||||||
|
s.pool.Close()
|
||||||
|
log.Printf("billing: connection pool closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// nullIfEmpty возвращает nil для пустой строки (SQL NULL вместо пустой строки).
|
||||||
|
func nullIfEmpty(s string) interface{} {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
+84
-4
@@ -102,7 +102,7 @@
|
|||||||
<div class="nubes">NUBES</div>
|
<div class="nubes">NUBES</div>
|
||||||
<div class="product">FISSION CONSOLE</div>
|
<div class="product">FISSION CONSOLE</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size:0.65rem; color:var(--text-secondary); margin-left:10px; align-self:center; opacity:0.7;">v1.3.56</div>
|
<div style="font-size:0.65rem; color:var(--text-secondary); margin-left:10px; align-self:center; opacity:0.7;">v1.3.86</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row" style="margin:0;">
|
<div class="row" style="margin:0;">
|
||||||
<button class="btn ghost" onclick="reloadAll()">Refresh</button>
|
<button class="btn ghost" onclick="reloadAll()">Refresh</button>
|
||||||
@@ -142,12 +142,24 @@
|
|||||||
<div style="font-weight:600;">Функции</div>
|
<div style="font-weight:600;">Функции</div>
|
||||||
<div class="hint">Actions: view, edit code, invoke, delete</div>
|
<div class="hint">Actions: view, edit code, invoke, delete</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Легенда статусов -->
|
||||||
|
<div style="margin-bottom:12px; padding:8px; background:var(--bg-alt); border-radius:4px; font-size:0.9em; color:var(--text-secondary);">
|
||||||
|
<strong>Статусы функций:</strong>
|
||||||
|
<span style="margin-left:16px;">
|
||||||
|
<span style="display:inline-block; width:10px; height:10px; border-radius:50%; background:#2a2; vertical-align:middle;"></span> Готова
|
||||||
|
<span style="margin-left:12px; display:inline-block; width:10px; height:10px; border-radius:50%; background:#22a; vertical-align:middle;"></span> Холодная
|
||||||
|
<span style="margin-left:12px; display:inline-block; width:10px; height:10px; border-radius:50%; background:#aa2; vertical-align:middle;"></span> Сборка
|
||||||
|
<span style="margin-left:12px; display:inline-block; width:10px; height:10px; border-radius:50%; background:#a22; vertical-align:middle;"></span> Ошибка
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Имя</th>
|
<th>Имя</th>
|
||||||
<th>Окружение</th>
|
<th title="Статус функции (Готова / Холодная / Сборка / Ошибка)">Статус</th>
|
||||||
<th>Пакет</th>
|
<th title="Тип источника функции">Тип</th>
|
||||||
<th>Создана</th>
|
<th>Создана</th>
|
||||||
<th>Изменена</th>
|
<th>Изменена</th>
|
||||||
<th>Маршрут</th>
|
<th>Маршрут</th>
|
||||||
@@ -377,13 +389,61 @@
|
|||||||
style="display:none;margin-top:8px;padding:10px 12px;border-radius:6px;font-size:13px;line-height:1.5;white-space:pre-wrap;font-family:monospace;">
|
style="display:none;margin-top:8px;padding:10px 12px;border-radius:6px;font-size:13px;line-height:1.5;white-space:pre-wrap;font-family:monospace;">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Переменные окружения -->
|
||||||
|
<div style="margin-top:16px;">
|
||||||
|
<div style="font-weight:600; margin-bottom:8px; font-size:13px;">Переменные окружения</div>
|
||||||
|
<div id="e-tf-env-warn" style="display:none; margin-bottom:8px; padding:6px 10px; background:#553300; color:#ffa; border-radius:4px; font-size:12px;">
|
||||||
|
⚠️ Функция управляется Terraform — переменные только для чтения
|
||||||
|
</div>
|
||||||
|
<div id="e-envvars-list" style="display:flex; flex-direction:column; gap:4px; margin-bottom:8px;"></div>
|
||||||
|
<button id="e-add-envvar-btn" class="btn ghost" style="font-size:12px;" onclick="addEnvVarRow('e')">+ Добавить переменную</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="e-error-msg" style="display:none;color:#f66;font-size:0.85rem;margin-bottom:8px;"></div>
|
||||||
|
|
||||||
|
<div id="e-clone-area" style="display:none;margin-top:10px;padding:10px 12px;background:var(--bg-alt,#2a2a3a);border-radius:6px;border:1px solid var(--border,#444);">
|
||||||
|
<div style="font-size:0.82rem;color:var(--fg-muted,#aaa);margin-bottom:6px;">Имя новой функции (копии):</div>
|
||||||
|
<div style="display:flex;gap:8px;align-items:center;">
|
||||||
|
<input id="e-clone-name" placeholder="new-function-name" style="flex:1;font-size:0.85rem;">
|
||||||
|
<button class="btn" onclick="submitClone()">Клонировать</button>
|
||||||
|
<button class="btn ghost" onclick="toggleCloneArea(false)">✕</button>
|
||||||
|
</div>
|
||||||
|
<div id="e-clone-error" style="display:none;color:#f66;font-size:0.78rem;margin-top:5px;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
|
<button class="btn ghost" onclick="toggleCloneArea(true)" title="Создать копию функции">📋 Клон</button>
|
||||||
<button class="btn ghost" onclick="closeEdit()">Отмена</button>
|
<button class="btn ghost" onclick="closeEdit()">Отмена</button>
|
||||||
<button id="e-submit" class="btn" onclick="submitEdit()">Сохранить</button>
|
<button id="e-submit" class="btn" onclick="submitEdit()">Сохранить</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="info-modal" class="modal">
|
||||||
|
<div class="panel" style="width:560px; max-width:95vw;">
|
||||||
|
<h3 id="info-title">Информация о функции</h3>
|
||||||
|
<table class="info-table">
|
||||||
|
<tbody id="info-rows"></tbody>
|
||||||
|
</table>
|
||||||
|
<div class="actions" style="margin-top:14px;">
|
||||||
|
<button class="btn ghost" onclick="closeInfo()">Закрыть</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="logs-modal" class="modal">
|
||||||
|
<div class="panel" style="width:700px; max-width:95vw;">
|
||||||
|
<h3 id="logs-title">Логи функции</h3>
|
||||||
|
<textarea id="logs-output" readonly
|
||||||
|
style="width:100%; height:340px; font-family:monospace; font-size:12px; background:var(--bg-alt); color:var(--text-primary); border:1px solid var(--border); border-radius:6px; padding:10px; resize:vertical; white-space:pre;"></textarea>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn ghost" onclick="closeLogs()">Закрыть</button>
|
||||||
|
<button class="btn ghost" onclick="refreshLogs()">Обновить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="help-modal" class="modal">
|
<div id="help-modal" class="modal">
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<h3>Help</h3>
|
<h3>Help</h3>
|
||||||
@@ -417,6 +477,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="help-section">
|
||||||
|
<h4>Переменные окружения функции</h4>
|
||||||
|
<div class="help-list">
|
||||||
|
<div>В редакторе функции (кнопка <strong>Ред.</strong>) есть раздел <strong>Переменные окружения</strong>.</div>
|
||||||
|
<div>Нажмите <strong>+ Добавить</strong> чтобы задать переменную: введите имя и значение, нажмите <strong>Сохранить env vars</strong>.</div>
|
||||||
|
<div>Переменные доступны внутри функции через <span class="mono">os.environ</span> (Python), <span class="mono">process.env</span> (Node.js), <span class="mono">os.Getenv</span> (Go) и т.д.</div>
|
||||||
|
<div>Изменения применяются к запущенному поду функции сразу после сохранения.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-section">
|
||||||
|
<h4>Клонирование функции</h4>
|
||||||
|
<div class="help-list">
|
||||||
|
<div>В редакторе функции (кнопка <strong>Ред.</strong>) есть кнопка <strong>📋 Клон</strong> в нижней панели.</div>
|
||||||
|
<div>Введите имя для новой функции и нажмите <strong>Клонировать</strong>.</div>
|
||||||
|
<div>Клон получает тот же код, язык, entrypoint, timeout и env vars. Маршрут назначается автоматически.</div>
|
||||||
|
<div>Имя: строчные буквы, цифры, дефис; не начинается и не заканчивается дефисом; до 57 символов.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="help-section">
|
<div class="help-section">
|
||||||
<h4>Как вызвать функцию вне веб-интерфейса</h4>
|
<h4>Как вызвать функцию вне веб-интерфейса</h4>
|
||||||
<div class="help-list">
|
<div class="help-list">
|
||||||
@@ -462,7 +542,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions" style="justify-content:space-between; align-items:center;">
|
<div class="actions" style="justify-content:space-between; align-items:center;">
|
||||||
<span style="font-size:0.75rem; color:var(--text-secondary);">v1.3.56</span>
|
<span style="font-size:0.75rem; color:var(--text-secondary);">v1.3.86</span>
|
||||||
<button class="btn ghost" onclick="closeHelp()">Закрыть</button>
|
<button class="btn ghost" onclick="closeHelp()">Закрыть</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+33
-5
@@ -5,7 +5,7 @@ async function reloadAll() {
|
|||||||
|
|
||||||
// Показываем таблицу сразу с placeholder'ом
|
// Показываем таблицу сразу с placeholder'ом
|
||||||
document.getElementById('fn-rows').innerHTML =
|
document.getElementById('fn-rows').innerHTML =
|
||||||
'<tr><td colspan="9" style="color:var(--text-secondary); font-style:italic;">⏳ Загружаем функции...</td></tr>';
|
'<tr><td colspan="8" style="color:var(--text-secondary); font-style:italic;">⏳ Загружаем функции...</td></tr>';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [envs, pkgs, fns, http, times] = await Promise.all([
|
const [envs, pkgs, fns, http, times] = await Promise.all([
|
||||||
@@ -35,6 +35,8 @@ async function reloadAll() {
|
|||||||
const ann = meta.annotations || {};
|
const ann = meta.annotations || {};
|
||||||
const env = (spec.environment && spec.environment.name) || '-';
|
const env = (spec.environment && spec.environment.name) || '-';
|
||||||
const pkg = (spec.package && spec.package.packageref && spec.package.packageref.name) || '-';
|
const pkg = (spec.package && spec.package.packageref && spec.package.packageref.name) || '-';
|
||||||
|
const entrypoint = (spec.package && spec.package.functionName) || '-';
|
||||||
|
const timeout = spec.functionTimeout || 60;
|
||||||
const name = (f.metadata && f.metadata.name) || '-';
|
const name = (f.metadata && f.metadata.name) || '-';
|
||||||
const trig = httpTriggerByFn(name) || {};
|
const trig = httpTriggerByFn(name) || {};
|
||||||
const timeTrig = timeTriggerByFn(name) || {};
|
const timeTrig = timeTriggerByFn(name) || {};
|
||||||
@@ -45,17 +47,43 @@ async function reloadAll() {
|
|||||||
const cronCell = cron ? '<span class="chip">' + h(cron) + '</span>' : '<span class="mono" style="color:var(--text-secondary)">—</span>';
|
const cronCell = cron ? '<span class="chip">' + h(cron) + '</span>' : '<span class="mono" style="color:var(--text-secondary)">—</span>';
|
||||||
const createdAt = ann['fission-console/created-at'] || meta.creationTimestamp || '-';
|
const createdAt = ann['fission-console/created-at'] || meta.creationTimestamp || '-';
|
||||||
const updatedAt = ann['fission-console/updated-at'] || createdAt;
|
const updatedAt = ann['fission-console/updated-at'] || createdAt;
|
||||||
|
const sourceType = ann['fission-console/source-type'] || 'code';
|
||||||
|
const status = ann['fission-console/status'] || 'Cold';
|
||||||
|
|
||||||
|
// Статус функции: иконка и цвет
|
||||||
|
const statusColor = {
|
||||||
|
'Ready': '#2a2', 'Cold': '#22a', 'Building': '#aa2', 'Error': '#a22'
|
||||||
|
}[status] || '#666';
|
||||||
|
const statusTitle = {
|
||||||
|
'Ready': 'Готова (есть pod)',
|
||||||
|
'Cold': 'Холодная (pod создаётся при первом вызове)',
|
||||||
|
'Building': 'Собирается / деплоится',
|
||||||
|
'Error': 'Ошибка (pod в CrashLoopBackOff или build failed)'
|
||||||
|
}[status] || 'Неизвестно';
|
||||||
|
const statusDot = '<span style="display:inline-block; width:12px; height:12px; border-radius:50%; background:' + statusColor + '; cursor:default;" title="' + h(statusTitle) + '"></span>';
|
||||||
|
|
||||||
|
const sourceIcon = sourceType === 'archive'
|
||||||
|
? '<span title="Из архива (.zip)" style="font-size:1.1em; cursor:default;">📦</span>'
|
||||||
|
: '<span title="Из кода (редактор)" style="font-size:1.1em; cursor:default;">📝</span>';
|
||||||
var isGo = /go[-_]env/.test(env);
|
var isGo = /go[-_]env/.test(env);
|
||||||
var isTf = /^tf-/.test(name);
|
var isTf = /^tf-/.test(name);
|
||||||
var tfBadge = (isGo || isTf) ? '<span class="chip" style="background:#555;color:#ffa" title="Управляется Terraform. Изменения могут быть перезаписаны при terraform apply.">TF</span> ' : '';
|
var tfBadge = (isGo || isTf) ? '<span class="chip" style="background:#555;color:#ffa" title="Управляется Terraform. Изменения могут быть перезаписаны при terraform apply.">TF</span> ' : '';
|
||||||
|
// Сохраняем все данные в data-атрибуте для openInfo (избегаем повторного запроса)
|
||||||
|
var infoData = h(JSON.stringify({
|
||||||
|
name: name, env: env, pkg: pkg, entrypoint: entrypoint, timeout: timeout,
|
||||||
|
route: route, methods: methods, sourceType: sourceType,
|
||||||
|
createdAt: createdAt, updatedAt: updatedAt, cron: cron, status: status
|
||||||
|
}));
|
||||||
var actions = tfBadge +
|
var actions = tfBadge +
|
||||||
|
'<button class="btn ghost" onclick="openInfo(\'' + h(name) + '\', this)" data-info="' + infoData + '">Info</button> ' +
|
||||||
'<button class="btn ghost" onclick="openEdit(\'' + h(name) + '\')">Ред.</button> ' +
|
'<button class="btn ghost" onclick="openEdit(\'' + h(name) + '\')">Ред.</button> ' +
|
||||||
'<button class="btn ghost" onclick="openInvoke(\'' + h(name) + '\')">Вызов</button> ' +
|
'<button class="btn ghost" onclick="openInvoke(\'' + h(name) + '\')">Вызов</button> ' +
|
||||||
|
'<button class="btn ghost" onclick="openLogs(\'' + h(name) + '\')">Логи</button> ' +
|
||||||
'<button class="btn danger" onclick="removeFn(\'' + h(name) + '\')">Удалить</button>';
|
'<button class="btn danger" onclick="removeFn(\'' + h(name) + '\')">Удалить</button>';
|
||||||
return '<tr>' +
|
return '<tr>' +
|
||||||
'<td class="mono">' + h(name) + '</td>' +
|
'<td class="mono">' + h(name) + '</td>' +
|
||||||
'<td>' + h(env) + '</td>' +
|
'<td style="text-align:center;">' + statusDot + '</td>' +
|
||||||
'<td class="mono">' + h(pkg) + '</td>' +
|
'<td style="text-align:center;">' + sourceIcon + '</td>' +
|
||||||
'<td>' + timestampCell(createdAt) + '</td>' +
|
'<td>' + timestampCell(createdAt) + '</td>' +
|
||||||
'<td>' + timestampCell(updatedAt) + '</td>' +
|
'<td>' + timestampCell(updatedAt) + '</td>' +
|
||||||
'<td class="mono">' + h(route) + '</td>' +
|
'<td class="mono">' + h(route) + '</td>' +
|
||||||
@@ -68,7 +96,7 @@ async function reloadAll() {
|
|||||||
var fnList = fns || [];
|
var fnList = fns || [];
|
||||||
if (fnList.length === 0) {
|
if (fnList.length === 0) {
|
||||||
progress.stop('Загружено: нет функций', '');
|
progress.stop('Загружено: нет функций', '');
|
||||||
document.getElementById('fn-rows').innerHTML = '<tr><td colspan="9">Нет функций</td></tr>';
|
document.getElementById('fn-rows').innerHTML = '<tr><td colspan="8">Нет функций</td></tr>';
|
||||||
} else {
|
} else {
|
||||||
// Рендерим первую строку сразу — убираем placeholder
|
// Рендерим первую строку сразу — убираем placeholder
|
||||||
var tbody = document.getElementById('fn-rows');
|
var tbody = document.getElementById('fn-rows');
|
||||||
@@ -89,7 +117,7 @@ async function reloadAll() {
|
|||||||
setTimeout(appendNext, 40);
|
setTimeout(appendNext, 40);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
document.getElementById('fn-rows').innerHTML = '<tr><td colspan="9">Load error: ' + e.message + '</td></tr>';
|
document.getElementById('fn-rows').innerHTML = '<tr><td colspan="8">Load error: ' + e.message + '</td></tr>';
|
||||||
progress.stop('Ошибка загрузки: ' + e.message, 'err');
|
progress.stop('Ошибка загрузки: ' + e.message, 'err');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+216
-1
@@ -1,4 +1,8 @@
|
|||||||
/* functions.js — CRUD операции с функциями */
|
/* functions.js — CRUD операции с функциями */
|
||||||
|
// ⛔⛔⛔ НЕ МЕНЯТЬ БЕЗ ЯВНОГО РАЗРЕШЕНИЯ ВЛАДЕЛЬЦА.
|
||||||
|
// Любые "улучшения", рефакторинг, добавление фич — ЗАПРЕЩЕНЫ без команды.
|
||||||
|
// Прецедент: v1.3.49 — убрали mode toggle "из лучших побуждений" → сломали рабочий редактор.
|
||||||
|
// Прецедент: helm upgrade → сломал JWT secret router → 401 у всех функций.
|
||||||
|
|
||||||
// setCodeMode переключает режим формы между вводом кода и загрузкой архива.
|
// setCodeMode переключает режим формы между вводом кода и загрузкой архива.
|
||||||
// prefix: 'c' (create) или 'e' (edit)
|
// prefix: 'c' (create) или 'e' (edit)
|
||||||
@@ -147,6 +151,10 @@ function parseTimeout(v) {
|
|||||||
|
|
||||||
async function openEdit(name) {
|
async function openEdit(name) {
|
||||||
try {
|
try {
|
||||||
|
var errEl = document.getElementById('e-error-msg');
|
||||||
|
if (errEl) errEl.style.display = 'none';
|
||||||
|
var cloneArea = document.getElementById('e-clone-area');
|
||||||
|
if (cloneArea) cloneArea.style.display = 'none';
|
||||||
const fn = await getJSON(API_BASE + '/functions/' + encodeURIComponent(name));
|
const fn = await getJSON(API_BASE + '/functions/' + encodeURIComponent(name));
|
||||||
S.currentEdit = fn;
|
S.currentEdit = fn;
|
||||||
document.getElementById('e-title').textContent = 'Редактирование: ' + name;
|
document.getElementById('e-title').textContent = 'Редактирование: ' + name;
|
||||||
@@ -187,6 +195,11 @@ async function openEdit(name) {
|
|||||||
var isTf = /^tf-/.test(name) || /go[-_]env/.test(fn.environment || '');
|
var isTf = /^tf-/.test(name) || /go[-_]env/.test(fn.environment || '');
|
||||||
warnEl.style.display = isTf ? 'block' : 'none';
|
warnEl.style.display = isTf ? 'block' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Env vars — отрисовать блок; TF-функции — только чтение
|
||||||
|
var isTfFn = /^tf-/.test(name) || /go[-_]env/.test(fn.environment || '');
|
||||||
|
renderEnvVars('e', fn.env_vars || [], isTfFn);
|
||||||
|
|
||||||
document.getElementById('edit-modal').classList.add('open');
|
document.getElementById('edit-modal').classList.add('open');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showStatus('Ошибка загрузки функции: ' + e.message, 'err');
|
showStatus('Ошибка загрузки функции: ' + e.message, 'err');
|
||||||
@@ -234,11 +247,36 @@ async function submitEdit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await syncScheduleForFunction(name, 'e');
|
await syncScheduleForFunction(name, 'e');
|
||||||
|
|
||||||
|
// Сохраняем env vars (только если не TF-функция)
|
||||||
|
var isTfFn = /^tf-/.test(name);
|
||||||
|
if (!isTfFn) {
|
||||||
|
var envVars = collectEnvVars('e');
|
||||||
|
// Проверка формата ключей
|
||||||
|
var invalidKeys = envVars.map(function(ev) { return ev.name; }).filter(function(k) { return !ENV_KEY_RE.test(k); });
|
||||||
|
if (invalidKeys.length > 0) {
|
||||||
|
throw new Error('Недопустимые имена переменных: ' + invalidKeys.join(', ') + ' (только латиница, цифры, _, не с цифры)');
|
||||||
|
}
|
||||||
|
// Проверка на дубли ключей
|
||||||
|
var keys = envVars.map(function(ev) { return ev.name; });
|
||||||
|
var dupes = keys.filter(function(k, i) { return keys.indexOf(k) !== i; });
|
||||||
|
if (dupes.length > 0) {
|
||||||
|
throw new Error('Дублирующиеся ключи: ' + [...new Set(dupes)].join(', '));
|
||||||
|
}
|
||||||
|
await requestJSON(API_BASE + '/functions/' + encodeURIComponent(name) + '/envvars', 'PUT', {
|
||||||
|
env_vars: envVars
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var errEl = document.getElementById('e-error-msg');
|
||||||
|
if (errEl) errEl.style.display = 'none';
|
||||||
closeEdit();
|
closeEdit();
|
||||||
progress.stop('Код обновлён: ' + name, 'ok');
|
progress.stop('Код обновлён: ' + name, 'ok');
|
||||||
await reloadAll();
|
await reloadAll();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
progress.stop('Ошибка обновления: ' + e.message, 'err');
|
var errEl = document.getElementById('e-error-msg');
|
||||||
|
if (errEl) { errEl.textContent = e.message; errEl.style.display = 'block'; }
|
||||||
|
progress.stop('', '');
|
||||||
} finally {
|
} finally {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
}
|
}
|
||||||
@@ -256,3 +294,180 @@ async function removeFn(name) {
|
|||||||
progress.stop('Ошибка удаления: ' + e.message, 'err');
|
progress.stop('Ошибка удаления: ' + e.message, 'err');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Env Vars UI ---
|
||||||
|
|
||||||
|
// renderEnvVars отрисовывает список переменных окружения в блоке prefix-envvars-list
|
||||||
|
// vars: [{name: "KEY", value: "VAL"}, ...]
|
||||||
|
// readOnly: true для TF-функций
|
||||||
|
function renderEnvVars(prefix, vars, readOnly) {
|
||||||
|
var list = document.getElementById(prefix + '-envvars-list');
|
||||||
|
var addBtn = document.getElementById(prefix + '-add-envvar-btn');
|
||||||
|
var tfWarn = document.getElementById(prefix + '-tf-env-warn');
|
||||||
|
if (!list) return;
|
||||||
|
list.innerHTML = '';
|
||||||
|
if (readOnly) {
|
||||||
|
if (tfWarn) tfWarn.style.display = '';
|
||||||
|
if (addBtn) addBtn.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
if (tfWarn) tfWarn.style.display = 'none';
|
||||||
|
if (addBtn) addBtn.style.display = '';
|
||||||
|
}
|
||||||
|
(vars || []).forEach(function(ev, idx) {
|
||||||
|
list.appendChild(makeEnvVarRow(prefix, ev.name || '', ev.value || '', readOnly, idx));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeEnvVarRow создаёт одну строку key=value с кнопкой удаления
|
||||||
|
function makeEnvVarRow(prefix, key, val, readOnly, idx) {
|
||||||
|
var row = document.createElement('div');
|
||||||
|
row.style.cssText = 'display:flex; gap:6px; align-items:center;';
|
||||||
|
row.dataset.envIdx = idx;
|
||||||
|
|
||||||
|
var kInput = document.createElement('input');
|
||||||
|
kInput.placeholder = 'KEY (A-Z, a-z, 0-9, _)';
|
||||||
|
kInput.value = key;
|
||||||
|
kInput.disabled = readOnly;
|
||||||
|
kInput.style.cssText = 'flex:1; font-size:12px; font-family:monospace;';
|
||||||
|
kInput.dataset.envKey = '1';
|
||||||
|
// Подсветка дублей при изменении ключа
|
||||||
|
kInput.addEventListener('input', function() { highlightDupeKeys(prefix); });
|
||||||
|
|
||||||
|
var vInput = document.createElement('input');
|
||||||
|
vInput.placeholder = 'value';
|
||||||
|
vInput.value = val;
|
||||||
|
vInput.disabled = readOnly;
|
||||||
|
vInput.style.cssText = 'flex:2; font-size:12px; font-family:monospace;';
|
||||||
|
vInput.dataset.envVal = '1';
|
||||||
|
|
||||||
|
row.appendChild(kInput);
|
||||||
|
|
||||||
|
var eq = document.createElement('span');
|
||||||
|
eq.textContent = '=';
|
||||||
|
eq.style.cssText = 'color:var(--text-secondary); font-family:monospace;';
|
||||||
|
row.appendChild(eq);
|
||||||
|
row.appendChild(vInput);
|
||||||
|
|
||||||
|
if (!readOnly) {
|
||||||
|
var delBtn = document.createElement('button');
|
||||||
|
delBtn.textContent = '×';
|
||||||
|
delBtn.className = 'btn ghost';
|
||||||
|
delBtn.style.cssText = 'padding:2px 8px; font-size:14px; line-height:1;';
|
||||||
|
delBtn.title = 'Удалить переменную';
|
||||||
|
delBtn.onclick = function() { row.remove(); };
|
||||||
|
row.appendChild(delBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||||
|
|
||||||
|
// highlightDupeKeys подсвечивает красным все поля KEY с одинаковыми именами или невалидным именем
|
||||||
|
function highlightDupeKeys(prefix) {
|
||||||
|
var list = document.getElementById(prefix + '-envvars-list');
|
||||||
|
if (!list) return;
|
||||||
|
var inputs = list.querySelectorAll('[data-env-key]');
|
||||||
|
var keys = Array.from(inputs).map(function(i) { return i.value.trim(); });
|
||||||
|
inputs.forEach(function(inp) {
|
||||||
|
var k = inp.value.trim();
|
||||||
|
var isDupe = k !== '' && keys.filter(function(x) { return x === k; }).length > 1;
|
||||||
|
var isInvalid = k !== '' && !ENV_KEY_RE.test(k);
|
||||||
|
inp.style.outline = (isDupe || isInvalid) ? '2px solid #f44' : '';
|
||||||
|
inp.title = isDupe ? 'Дублирующийся ключ!' : isInvalid ? 'Только латиница, цифры и _. Не начинается с цифры.' : '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// addEnvVarRow добавляет пустую строку в список env vars
|
||||||
|
function addEnvVarRow(prefix) {
|
||||||
|
var list = document.getElementById(prefix + '-envvars-list');
|
||||||
|
if (!list) return;
|
||||||
|
var idx = list.children.length;
|
||||||
|
list.appendChild(makeEnvVarRow(prefix, '', '', false, idx));
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectEnvVars читает текущие значения из DOM и возвращает [{name, value}, ...]
|
||||||
|
function collectEnvVars(prefix) {
|
||||||
|
var list = document.getElementById(prefix + '-envvars-list');
|
||||||
|
if (!list) return [];
|
||||||
|
var result = [];
|
||||||
|
var rows = list.querySelectorAll('div[data-env-idx]');
|
||||||
|
rows.forEach(function(row) {
|
||||||
|
var k = row.querySelector('[data-env-key]');
|
||||||
|
var v = row.querySelector('[data-env-val]');
|
||||||
|
var key = k ? k.value.trim() : '';
|
||||||
|
var val = v ? v.value : '';
|
||||||
|
if (key) result.push({name: key, value: val});
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// toggleCloneArea показывает/скрывает форму клонирования
|
||||||
|
function toggleCloneArea(show) {
|
||||||
|
var area = document.getElementById('e-clone-area');
|
||||||
|
var errEl = document.getElementById('e-clone-error');
|
||||||
|
if (!area) return;
|
||||||
|
area.style.display = show ? 'block' : 'none';
|
||||||
|
if (show) {
|
||||||
|
var nameInput = document.getElementById('e-clone-name');
|
||||||
|
if (nameInput) {
|
||||||
|
// Предзаполняем именем оригинала + '-copy'
|
||||||
|
var srcName = (S.currentEdit && S.currentEdit.name) || '';
|
||||||
|
nameInput.value = srcName ? srcName + '-copy' : '';
|
||||||
|
nameInput.focus();
|
||||||
|
nameInput.select();
|
||||||
|
}
|
||||||
|
if (errEl) errEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// submitClone отправляет запрос на клонирование функции
|
||||||
|
async function submitClone() {
|
||||||
|
var nameInput = document.getElementById('e-clone-name');
|
||||||
|
var errEl = document.getElementById('e-clone-error');
|
||||||
|
var btn = document.querySelector('#e-clone-area .btn:not(.ghost)');
|
||||||
|
|
||||||
|
var newName = nameInput ? nameInput.value.trim() : '';
|
||||||
|
if (!newName) {
|
||||||
|
if (errEl) { errEl.textContent = 'Введите имя новой функции'; errEl.style.display = 'block'; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(newName) || newName.length > 57) {
|
||||||
|
if (errEl) { errEl.textContent = 'Имя: строчные буквы, цифры, дефис; не начинается/заканчивается дефисом; до 57 символов'; errEl.style.display = 'block'; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var srcName = S.currentEdit && S.currentEdit.name;
|
||||||
|
if (!srcName) {
|
||||||
|
if (errEl) { errEl.textContent = 'Нет функции для клонирования'; errEl.style.display = 'block'; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (btn) { btn.disabled = true; btn.textContent = '...'; }
|
||||||
|
if (errEl) errEl.style.display = 'none';
|
||||||
|
|
||||||
|
try {
|
||||||
|
var data = await requestJSON(API_BASE + '/functions/' + encodeURIComponent(srcName) + '/clone', 'POST', {new_name: newName});
|
||||||
|
// Успех — закрываем модалку, ждём появления в списке
|
||||||
|
closeEdit();
|
||||||
|
showStatus('Клонирование завершено, обновляем список...', '');
|
||||||
|
var attempts = 0;
|
||||||
|
async function pollUntilVisible() {
|
||||||
|
attempts++;
|
||||||
|
await reloadAll();
|
||||||
|
var found = (S.fns || []).some(function(f) {
|
||||||
|
return f && f.metadata && f.metadata.name === newName;
|
||||||
|
});
|
||||||
|
if (found || attempts >= 5) {
|
||||||
|
showStatus('Функция «' + newName + '» создана как копия «' + srcName + '»', 'ok');
|
||||||
|
} else {
|
||||||
|
setTimeout(pollUntilVisible, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setTimeout(pollUntilVisible, 700);
|
||||||
|
} catch(e) {
|
||||||
|
if (errEl) { errEl.textContent = e.message; errEl.style.display = 'block'; }
|
||||||
|
} finally {
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = 'Клонировать'; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+117
-1
@@ -46,7 +46,20 @@ function makeDraggable(modalId) {
|
|||||||
|
|
||||||
// Инициализация после загрузки DOM
|
// Инициализация после загрузки DOM
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
['create-code-modal', 'create-archive-modal', 'edit-modal', 'help-modal', 'invoke-modal'].forEach(makeDraggable);
|
['create-code-modal', 'create-archive-modal', 'edit-modal', 'help-modal', 'invoke-modal', 'logs-modal', 'info-modal'].forEach(makeDraggable);
|
||||||
|
|
||||||
|
// ESC закрывает активное модальное окно
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key !== 'Escape') return;
|
||||||
|
var modals = ['create-code-modal', 'create-archive-modal', 'edit-modal', 'help-modal', 'invoke-modal', 'logs-modal', 'info-modal'];
|
||||||
|
for (var i = 0; i < modals.length; i++) {
|
||||||
|
var el = document.getElementById(modals[i]);
|
||||||
|
if (el && el.classList.contains('open')) {
|
||||||
|
el.classList.remove('open');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// showModalError — показывает ошибку внутри модалки.
|
// showModalError — показывает ошибку внутри модалки.
|
||||||
@@ -136,3 +149,106 @@ async function submitInvoke() {
|
|||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// openLogs — открывает модалку с логами функции.
|
||||||
|
async function openLogs(name) {
|
||||||
|
S.currentLogs = name;
|
||||||
|
document.getElementById('logs-title').textContent = 'Логи: ' + name;
|
||||||
|
document.getElementById('logs-output').value = 'Загрузка...';
|
||||||
|
document.getElementById('logs-modal').classList.add('open');
|
||||||
|
await _fetchLogs(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLogs() {
|
||||||
|
document.getElementById('logs-modal').classList.remove('open');
|
||||||
|
S.currentLogs = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshLogs() {
|
||||||
|
if (S.currentLogs) await _fetchLogs(S.currentLogs);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _fetchLogs(name) {
|
||||||
|
var out = document.getElementById('logs-output');
|
||||||
|
try {
|
||||||
|
var data = await getJSON(API_BASE + '/functions/' + encodeURIComponent(name) + '/logs');
|
||||||
|
out.value = data.logs || '(нет логов)';
|
||||||
|
// прокручиваем вниз
|
||||||
|
out.scrollTop = out.scrollHeight;
|
||||||
|
} catch (e) {
|
||||||
|
out.value = 'Ошибка загрузки логов: ' + e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// openInfo — открывает модалку с подробной информацией о функции.
|
||||||
|
// Данные берутся из data-атрибута кнопки (без дополнительного запроса к API).
|
||||||
|
function openInfo(name, btn) {
|
||||||
|
var raw = btn ? btn.getAttribute('data-info') : null;
|
||||||
|
var info = null;
|
||||||
|
try { info = raw ? JSON.parse(raw) : null; } catch(e) {}
|
||||||
|
if (!info) {
|
||||||
|
document.getElementById('info-rows').innerHTML = '<tr><td colspan="2">Нет данных</td></tr>';
|
||||||
|
document.getElementById('info-title').textContent = 'Информация: ' + name;
|
||||||
|
document.getElementById('info-modal').classList.add('open');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyBtn — кнопка копирования конкретного значения через data-атрибут (безопасно для любых символов).
|
||||||
|
function copyBtn(val) {
|
||||||
|
if (!val || val === '-') return '';
|
||||||
|
var encoded = val.replace(/&/g, '&').replace(/"/g, '"');
|
||||||
|
return ' <button class="btn ghost" style="padding:1px 6px; font-size:11px;" data-copy="' + encoded + '" onclick="navigator.clipboard.writeText(this.dataset.copy)">📋</button>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Строки без кнопки копирования (только отображение).
|
||||||
|
function plain(val) {
|
||||||
|
return '<td class="mono" style="word-break:break-all;padding:4px 0;">' + h(val || '-') + '</td>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Строки с кнопкой копирования (Имя, Пакет, Маршрут — генерируемые или идентификаторы).
|
||||||
|
function withCopy(val) {
|
||||||
|
return '<td class="mono" style="word-break:break-all;padding:4px 0;">' + h(val || '-') + copyBtn(val) + '</td>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelTd(label) {
|
||||||
|
return '<td style="color:var(--text-secondary);padding:4px 10px 4px 0;white-space:nowrap;vertical-align:top;">' + h(label) + '</td>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// CURL-строка для внешнего вызова (полная, с токеном из localStorage).
|
||||||
|
var token = localStorage.getItem('auth_token') || '';
|
||||||
|
var method = (Array.isArray(info.methods) && info.methods.length) ? info.methods[0] : 'GET';
|
||||||
|
var externalUrl = window.location.origin + '/fn' + (info.route !== '-' ? info.route : '');
|
||||||
|
var curlFull = 'curl -H "X-Auth-Token: ' + token + '" -X ' + method + ' "' + externalUrl + '"';
|
||||||
|
// В отображении скрываем токен — показываем только метод и URL.
|
||||||
|
var curlDisplay = 'curl ... -X ' + method + ' "' + externalUrl + '"';
|
||||||
|
// Храним полную команду в data-атрибуте (HTML-encode), читаем через dataset — безопасно для любых символов.
|
||||||
|
var curlAttr = curlFull.replace(/&/g, '&').replace(/"/g, '"');
|
||||||
|
|
||||||
|
var html = [
|
||||||
|
'<tr>' + labelTd('Имя') + withCopy(info.name) + '</tr>',
|
||||||
|
'<tr>' + labelTd('Окружение') + plain(info.env) + '</tr>',
|
||||||
|
'<tr>' + labelTd('Пакет') + withCopy(info.pkg) + '</tr>',
|
||||||
|
'<tr>' + labelTd('Entrypoint') + plain(info.entrypoint) + '</tr>',
|
||||||
|
'<tr>' + labelTd('Таймаут') + plain(info.timeout ? info.timeout + ' сек' : '-') + '</tr>',
|
||||||
|
'<tr>' + labelTd('Тип источника') + plain(info.sourceType === 'archive' ? '📦 archive' : '📝 code') + '</tr>',
|
||||||
|
'<tr>' + labelTd('Маршрут') + withCopy(info.route) + '</tr>',
|
||||||
|
'<tr>' + labelTd('Методы') + plain(Array.isArray(info.methods) && info.methods.length ? info.methods.join(', ') : '-') + '</tr>',
|
||||||
|
'<tr>' + labelTd('Cron') + plain(info.cron || '-') + '</tr>',
|
||||||
|
'<tr>' + labelTd('Создана') + plain(info.createdAt) + '</tr>',
|
||||||
|
'<tr>' + labelTd('Изменена') + plain(info.updatedAt) + '</tr>',
|
||||||
|
'<tr>' + labelTd('curl') +
|
||||||
|
'<td class="mono" style="word-break:break-all;padding:4px 0;color:var(--text-secondary);">' +
|
||||||
|
h(curlDisplay) +
|
||||||
|
' <button class="btn ghost" style="padding:1px 6px; font-size:11px;" data-curl="' + curlAttr + '" onclick="navigator.clipboard.writeText(this.dataset.curl)">📋</button>' +
|
||||||
|
'</td></tr>',
|
||||||
|
].join('');
|
||||||
|
|
||||||
|
document.getElementById('info-title').textContent = 'Информация: ' + name;
|
||||||
|
document.getElementById('info-rows').innerHTML = html;
|
||||||
|
document.getElementById('info-modal').classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
// closeInfo — закрывает Info-модалку.
|
||||||
|
function closeInfo() {
|
||||||
|
document.getElementById('info-modal').classList.remove('open');
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Изменения в fission-src относительно официального Fission
|
||||||
|
|
||||||
|
Базовый коммит официального Fission: `82e1ff76` (Add npm dependabot rules, 2025-12-16)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что менялось
|
||||||
|
|
||||||
|
### 1. Ядро — `pkg/utils/`
|
||||||
|
- `namespace_manager.go` (+608 строк) — новый компонент: менеджер namespace. Отслеживает появление/исчезновение namespace с меткой `fission.io/managed=true` в реальном времени без рестарта Fission.
|
||||||
|
- `namespace_manager_model.go` (+181 строк) — модель данных менеджера namespace.
|
||||||
|
- `namespace.go` (+66 строк) — расширена логика работы с namespace.
|
||||||
|
- `serviceaccount.go` (+25 строк) — добавлена функция `EnsureNamespaceSA`: при появлении нового namespace автоматически создаёт ServiceAccount, Role, RoleBinding, необходимые для работы fetcher/builder.
|
||||||
|
|
||||||
|
### 2. Executor — `pkg/executor/`
|
||||||
|
- `multitenant/ns_watcher.go` (+137 строк) — watcher namespace-событий для executor. При появлении namespace с меткой `fission.io/managed=true` — executor начинает обслуживать функции в этом namespace без рестарта.
|
||||||
|
- `executortype/poolmgr/gpm.go` (+69 строк) — poolmgr динамически добавляет новые namespace.
|
||||||
|
- `executortype/newdeploy/newdeploymgr.go` (+61 строка) — аналогично для newdeploy.
|
||||||
|
- `executortype/container/containermgr.go` (+49 строк) — аналогично для container executor.
|
||||||
|
- `executortype/poolmgr/poolpodcontroller.go` (+43 строки) — контроллер подов poolmgr знает о новых namespace.
|
||||||
|
|
||||||
|
### 3. Router — `pkg/router/`
|
||||||
|
- `httpTriggers.go` (+116 строк) — роутер подписывается на ns_watcher, динамически добавляет маршруты для функций в новых namespace.
|
||||||
|
- `namespace_subscriber.go` (+31 строка) — подписка роутера на namespace-события.
|
||||||
|
- `ns_watcher.go` (+32 строки) — watcher namespace для роутера.
|
||||||
|
- `functionReferenceResolver.go` (+11 строк) — резолвер функций знает о мультинеймспейс.
|
||||||
|
|
||||||
|
### 4. BuilderMgr — `pkg/buildermgr/`
|
||||||
|
- `ns_watcher.go` (+32 строки) — watcher namespace для buildermgr.
|
||||||
|
- `namespace_subscriber.go` (+43 строки) — подписка buildermgr на namespace-события.
|
||||||
|
- `envwatcher.go` (+45 строк) — watcher окружений теперь реагирует на новые namespace.
|
||||||
|
- `pkgwatcher.go` (+38 строк) — watcher пакетов — аналогично.
|
||||||
|
|
||||||
|
### 5. RBAC — `deploy/multitenant/rbac.yaml` (+122 строки)
|
||||||
|
- Новые ClusterRole и ClusterRoleBinding для автоматического создания SA/Role/RoleBinding в пользовательских namespace.
|
||||||
|
- `deploy/executor-ns-watcher-rbac.yaml` (+26 строк) — дополнительный RBAC для ns_watcher executor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Обратная совместимость
|
||||||
|
|
||||||
|
**Полная.** Если в кластере нет namespace с меткой `fission.io/managed=true` — поведение идентично официальному Fission v1.22.0.
|
||||||
|
|
||||||
|
- CRD не изменялись.
|
||||||
|
- API (fission CLI) не изменялся.
|
||||||
|
- Helm-чарт не изменялся.
|
||||||
|
- Существующие функции, env, пакеты работают без изменений.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Как активировать мультитенантность
|
||||||
|
|
||||||
|
Добавить метку на namespace:
|
||||||
|
```bash
|
||||||
|
kubectl label namespace <ns> fission.io/managed=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Fission автоматически начнёт обслуживать функции в этом namespace без рестарта.
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# Function Env Vars — ПОЛНАЯ ДОКУМЕНТАЦИЯ (v1.3.77+)
|
||||||
|
|
||||||
|
## ПРОБЛЕМА (обнаружена 2026-05-09)
|
||||||
|
|
||||||
|
Env vars хранились только в аннотации `fission-console/env-vars` на Function CRD.
|
||||||
|
Это работало для UI (хранение/отображение), но переменные **не попадали в pod функции**.
|
||||||
|
|
||||||
|
### Почему не попадали
|
||||||
|
|
||||||
|
Fission `poolmgr` создаёт warm pool подов **заранее**, до того как знает какая функция
|
||||||
|
будет запущена. Pod уже работает в момент первого вызова функции.
|
||||||
|
`spec.podspec.containers[].env` из Function CRD при poolmgr **игнорируется** — pod уже запущен.
|
||||||
|
|
||||||
|
При специализации (загрузке кода функции) fetcher передаёт в pod только:
|
||||||
|
- путь к файлу с кодом
|
||||||
|
- имя entrypoint функции
|
||||||
|
- секреты и configmaps (как файлы в `/userfunc/...`)
|
||||||
|
|
||||||
|
Переменные окружения через `poolmgr` можно установить только на уровне Environment deployment —
|
||||||
|
одинаковые для ВСЕХ функций этого окружения. Индивидуально для функции — невозможно.
|
||||||
|
|
||||||
|
### Отвергнутые варианты
|
||||||
|
|
||||||
|
| Вариант | Почему нет |
|
||||||
|
|---------|-----------|
|
||||||
|
| `spec.podspec.containers[].env` + poolmgr | Pod уже запущен до специализации |
|
||||||
|
| `spec.configmaps` → файлы `/userfunc/configs/` | Нужно читать файл в коде — не универсально |
|
||||||
|
| Патч python-env server | Только Python, другие языки сломаны |
|
||||||
|
| PostgreSQL/отдельное хранилище | Оверкилл, не официальный путь |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## РЕШЕНИЕ (v1.3.77+)
|
||||||
|
|
||||||
|
**Автоматическое переключение ExecutorType в зависимости от наличия env vars.**
|
||||||
|
|
||||||
|
- Функция **без env vars** → `ExecutorType: poolmgr` → warm pool, быстрый cold start (~0.5 сек)
|
||||||
|
- Функция **с env vars** → `ExecutorType: newdeploy` → dedicated Deployment, env vars в OS (~2-5 сек)
|
||||||
|
|
||||||
|
Пользователь ничего не настраивает — переключение происходит автоматически.
|
||||||
|
|
||||||
|
### Почему newdeploy работает
|
||||||
|
|
||||||
|
При `newdeploy` Fission создаёт отдельный Kubernetes `Deployment` для функции.
|
||||||
|
В этот Deployment попадает `spec.podspec` из Function CRD **включая** `containers[0].env`.
|
||||||
|
Kubernetes ставит переменные на уровне ОС процесса контейнера при старте пода.
|
||||||
|
|
||||||
|
### Почему это универсально для всех языков
|
||||||
|
|
||||||
|
Переменные окружения ОС — стандарт POSIX. Любой язык читает без изменений в env-сервере:
|
||||||
|
|
||||||
|
```python
|
||||||
|
os.getenv("MY_VAR") # Python
|
||||||
|
os.Getenv("MY_VAR") # Go
|
||||||
|
ENV["MY_VAR"] # Ruby
|
||||||
|
getenv("MY_VAR") # PHP
|
||||||
|
process.env.MY_VAR # Node.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### Нет дублирования подов
|
||||||
|
|
||||||
|
Warm pool принадлежит **Environment**, не функции. Переключение конкретной функции на `newdeploy`
|
||||||
|
не трогает pool — другие функции без env vars продолжают использовать его.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## РЕАЛИЗАЦИЯ
|
||||||
|
|
||||||
|
### Backend: `handlePutFunctionEnvVars` (function_crud.go)
|
||||||
|
|
||||||
|
При `PUT /functions/:name/envvars`:
|
||||||
|
|
||||||
|
1. Сохраняет env vars в аннотацию `fission-console/env-vars` (для UI)
|
||||||
|
2. Если env vars **непустые**:
|
||||||
|
- Выставляет `spec.podspec.containers[0].env` (стандартный Kubernetes EnvVar)
|
||||||
|
- Переключает `spec.InvokeStrategy.ExecutionStrategy.ExecutorType` = `"newdeploy"`
|
||||||
|
- MinScale=0, MaxScale=1
|
||||||
|
3. Если env vars **пустые** (очищены):
|
||||||
|
- Удаляет `spec.podspec`
|
||||||
|
- Возвращает `ExecutorType` = `"poolmgr"`
|
||||||
|
|
||||||
|
### Двойное хранение
|
||||||
|
|
||||||
|
| Где | Зачем |
|
||||||
|
|-----|-------|
|
||||||
|
| Аннотация `fission-console/env-vars` | UI: отображение, редактирование |
|
||||||
|
| `spec.podspec.containers[0].env` | Fission/Kubernetes: реальное применение в pod |
|
||||||
|
|
||||||
|
### Структура Function CRD с env vars
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
spec:
|
||||||
|
InvokeStrategy:
|
||||||
|
ExecutionStrategy:
|
||||||
|
ExecutorType: newdeploy
|
||||||
|
MinScale: 0
|
||||||
|
MaxScale: 1
|
||||||
|
StrategyType: execution
|
||||||
|
podspec:
|
||||||
|
containers:
|
||||||
|
- name: <env-name>
|
||||||
|
env:
|
||||||
|
- name: p1
|
||||||
|
value: "envv1"
|
||||||
|
metadata:
|
||||||
|
annotations:
|
||||||
|
fission-console/env-vars: '[{"name":"p1","value":"envv1"}]'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Структура Function CRD без env vars
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
spec:
|
||||||
|
InvokeStrategy:
|
||||||
|
ExecutionStrategy:
|
||||||
|
ExecutorType: poolmgr
|
||||||
|
StrategyType: execution
|
||||||
|
# нет podspec
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ОГРАНИЧЕНИЯ
|
||||||
|
|
||||||
|
- `newdeploy` медленнее cold start (~2-5 сек vs ~0.5 сек) — выбор пользователя, добавившего env vars
|
||||||
|
- MinScale=0: при отсутствии трафика pod удаляется → cold start при первом вызове
|
||||||
|
- Имена переменных: только `[A-Za-z_][A-Za-z0-9_]*` — стандарт POSIX, проверяется на UI
|
||||||
|
- TF-функции (`tf-*`) — env vars read-only, изменение запрещено через UI
|
||||||
+195
-1
@@ -1,6 +1,200 @@
|
|||||||
# Progress Log
|
# Progress Log
|
||||||
|
|
||||||
## 2026-04-14
|
## TODO (backlog)
|
||||||
|
|
||||||
|
### Биллинг / статистика для бухгалтерии
|
||||||
|
Цель: считать потреблённые ресурсы по пользователям для тарификации.
|
||||||
|
- Метрики: количество вызовов, время выполнения (ms), CPU/RAM пода
|
||||||
|
- Агрегация по namespace (пользователю) за период (день/месяц)
|
||||||
|
- Хранение истории (сейчас metrics-collector хранит только текущее)
|
||||||
|
- Отчёт: пользователь → вызовы + суммарное время → сумма к оплате
|
||||||
|
- API для бухгалтерии или выгрузка CSV/Excel
|
||||||
|
- Что уже есть: metrics-collector (/metrics endpoint), Fission router логирует вызовы, k8s metrics-server
|
||||||
|
- Примерная модель: X руб/1000 вызовов + Y руб/GB·сек
|
||||||
|
|
||||||
|
### MQ-триггер и Kube-Watch триггер в консоли
|
||||||
|
- Backend: добавить GVR для messagequeuetriggers.fission.io и kuberneteswatchtriggers.fission.io
|
||||||
|
- UI: формы создания по аналогии с cron, счётчики на главной
|
||||||
|
|
||||||
|
### Прочий полезный функционал
|
||||||
|
- ~~Клонировать функцию (копия с новым именем)~~ ✅ v1.3.80
|
||||||
|
- ~~Переменные окружения (env vars) через UI~~ ✅ v1.3.78
|
||||||
|
- Метрики на главной странице (данные уже есть в metrics-collector)
|
||||||
|
- Автообновление логов (polling каждые 3-5 сек)
|
||||||
|
- Копировать маршрут одной кнопкой (📋 рядом с маршрутом)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-05-09 — 2026-05-10
|
||||||
|
|
||||||
|
### Ветка: `feat/function-status-clone-envvars` / `feat/clone-function`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### v1.3.78 — Переменные окружения: патч Deployment напрямую
|
||||||
|
|
||||||
|
**Проблема:** Fission newdeploy executor игнорирует `fn.Spec.PodSpec` при построении
|
||||||
|
Deployment — применяет только `env.Spec.Runtime.Container`. Сохранение env vars в
|
||||||
|
Function CRD через UI не приводило к попаданию переменных в pod.
|
||||||
|
|
||||||
|
**Решение:** После обновления Function CRD — дополнительно патчить Deployment
|
||||||
|
напрямую через Kubernetes API (`StrategicMergePatch`).
|
||||||
|
|
||||||
|
- Файл: `console/internal/api/function_crud.go`
|
||||||
|
- Новая функция `patchDeploymentEnvVars(ctx, ns, fnName, envContainerName, envVars)`
|
||||||
|
- Поиск Deployment по labels: `functionName=<name>,functionNamespace=<ns>,executorType=newdeploy`
|
||||||
|
- Patch: `StrategicMergePatch` с `containers[{name: envContainerName, env: [...]}]`
|
||||||
|
- Импорт добавлен: `k8stypes "k8s.io/apimachinery/pkg/types"`
|
||||||
|
- Образ: `naeel/fission-console:v1.3.78`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### v1.3.79 — LLM: убрано ограничение тем чата + сброс кэша роутера
|
||||||
|
|
||||||
|
**Проблема 1:** LLM ассистент отказывался отвечать на вопросы не по теме Fission Console.
|
||||||
|
|
||||||
|
**Решение:** Удалён блок `## Чем НЕ занимаешься` из `buildChatSystemPrompt()` в
|
||||||
|
`console/internal/api/ai_prompts.go`.
|
||||||
|
|
||||||
|
**Проблема 2:** Env vars всё ещё не работали после v1.3.78 — роутер закешировал
|
||||||
|
старый poolmgr адрес (funcSvc cache).
|
||||||
|
|
||||||
|
**Решение:** `kubectl rollout restart deployment/router -n fission` — сбросил кэш.
|
||||||
|
После перезапуска env vars начали попадать в поды.
|
||||||
|
|
||||||
|
- Образ: `naeel/fission-console:v1.3.79`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### v1.3.80 — Клонирование функции (полная реализация)
|
||||||
|
|
||||||
|
**Фича:** `POST /functions/:name/clone` — создаёт полную копию функции с новым именем.
|
||||||
|
|
||||||
|
**Backend** (`console/internal/api/function_clone.go`, новый файл):
|
||||||
|
1. Валидирует `new_name` (regex `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`, ≤57 символов)
|
||||||
|
2. Скачивает архив из storagesvc (type:url) или base64 (type:literal)
|
||||||
|
3. Заливает как новый архив в storagesvc (новый S3-объект — удаление оригинала не ломает клон)
|
||||||
|
4. Создаёт новый Package с именем `<new_name>-<timestamp>`
|
||||||
|
5. Копирует аннотации: `language`, `source-type`, `env-vars`, `archive-filename`, добавляет `cloned-from`
|
||||||
|
6. Копирует entrypoint, timeout, InvokeStrategy, podspec
|
||||||
|
7. Создаёт Function CRD
|
||||||
|
8. Копирует HTTP методы из оригинального триггера, создаёт HTTPTrigger
|
||||||
|
9. При ошибке Function/Trigger — откатывает созданные Package/Function
|
||||||
|
|
||||||
|
**Route** (`console/internal/api/handlers.go`):
|
||||||
|
```
|
||||||
|
POST /functions/:name/clone
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend** (`console/ui/index.html`, `console/ui/js/functions.js`):
|
||||||
|
- Кнопка **📋 Клон** в нижней панели edit modal
|
||||||
|
- Inline форма: поле имени, кнопки «Клонировать» / «✕»
|
||||||
|
- Предзаполнение именем `<original>-copy`
|
||||||
|
- Валидация на клиенте (тот же regex)
|
||||||
|
- При успехе: закрытие модалки + polling до появления в таблице
|
||||||
|
|
||||||
|
**Тест результат:**
|
||||||
|
```
|
||||||
|
POST /console/api/functions/clone-src/clone {"new_name":"clone-src-copy"}
|
||||||
|
→ 201 {"cloned_from":"clone-src","name":"clone-src-copy","package":"clone-src-copy-20260509162619","route":"/47b6ef5e8225/clone-src-copy"}
|
||||||
|
kubectl get function clone-src-copy ✅
|
||||||
|
kubectl get package clone-src-copy-... ✅ type:url storagesvc
|
||||||
|
kubectl get httptrigger clone-src-copy-route ✅ methods=["GET"]
|
||||||
|
annotation: fission-console/cloned-from=clone-src ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
- Образ: `naeel/fission-console:v1.3.80`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### v1.3.81 — Fix: clone не обновлял таблицу (`loadFunctions` → `reloadAll`)
|
||||||
|
|
||||||
|
**Проблема:** После клонирования таблица не обновлялась — функция `loadFunctions()`
|
||||||
|
не существует, правильное название `reloadAll()`.
|
||||||
|
|
||||||
|
**Fix:** `console/ui/js/functions.js` — замена вызова.
|
||||||
|
|
||||||
|
- Образ: `naeel/fission-console:v1.3.81`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### v1.3.82 — Fix: `apiFetch is not defined` → `requestJSON`
|
||||||
|
|
||||||
|
**Проблема:** `apiFetch` не существует в codebase. Правильный хелпер — `requestJSON(url, method, body)` из `api.js`.
|
||||||
|
|
||||||
|
**Fix:** `console/ui/js/functions.js` — переписан `submitClone()` на `requestJSON`.
|
||||||
|
|
||||||
|
- Образ: `naeel/fission-console:v1.3.82`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### v1.3.83 — Fix: clone — задержка 800мс перед reloadAll (race condition)
|
||||||
|
|
||||||
|
**Проблема:** После клонирования таблица обновлялась раньше, чем k8s возвращал
|
||||||
|
новый объект в List-ответе.
|
||||||
|
|
||||||
|
**Fix:** `setTimeout(reloadAll, 800)` → заменено на polling с ожиданием появления
|
||||||
|
функции в `S.fns` (до 5 попыток × 1 сек).
|
||||||
|
|
||||||
|
- Образ: `naeel/fission-console:v1.3.83`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### v1.3.84 — Help + LLM промпты: разделы env vars и clone
|
||||||
|
|
||||||
|
**Help modal** (`console/ui/index.html`):
|
||||||
|
- Новая секция **«Переменные окружения функции»**: как добавлять через UI, как читать в коде (`os.environ` / `process.env` / `os.Getenv`)
|
||||||
|
- Новая секция **«Клонирование функции»**: кнопка 📋 Клон, что копируется, правила имени
|
||||||
|
|
||||||
|
**LLM системный промпт** (`console/internal/api/ai_prompts.go`):
|
||||||
|
- В раздел «Редактирование функции» добавлены пункты «Переменные окружения» и «📋 Клон»
|
||||||
|
|
||||||
|
- Образ: `naeel/fission-console:v1.3.84`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### v1.3.85 — Fix: polling до появления клона в таблице
|
||||||
|
|
||||||
|
**Проблема:** После клонирования — резкий переход в таблицу, клон ещё не виден
|
||||||
|
(k8s list не успел вернуть новый объект).
|
||||||
|
|
||||||
|
**Fix:** `console/ui/js/functions.js` — polling loop:
|
||||||
|
1. Закрыть модалку
|
||||||
|
2. Показать статус «Клонирование завершено, обновляем список...»
|
||||||
|
3. Через 700мс — `reloadAll()`
|
||||||
|
4. Проверить `S.fns` на наличие нового имени
|
||||||
|
5. Если не найден — повторить через 1 сек (до 5 попыток)
|
||||||
|
6. Показать финальное сообщение
|
||||||
|
|
||||||
|
- Образ: `naeel/fission-console:v1.3.85`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Unit-тесты (`console/internal/api/function_clone_test.go`)
|
||||||
|
|
||||||
|
21 тест, все PASS. Файл создан с нуля.
|
||||||
|
|
||||||
|
| Группа | Тесты |
|
||||||
|
|---|---|
|
||||||
|
| Валидация имени | пустое → 400; uppercase → 400; начинается с `-` → 400; заканчивается `-` → 400; 58 символов → 400; 57 символов → OK (404); имя с дефисами → OK |
|
||||||
|
| Источник не найден | 404 с именем функции в теле |
|
||||||
|
| Успешный клон (type:literal) | Package создан; Function создана; аннотации `cloned-from`, `language`, `source-type` скопированы; entrypoint и timeout скопированы; HTTPTrigger с методами и маршрутом |
|
||||||
|
| Маршрут | явный `/custom/path`; автодобавление `/` при отсутствии |
|
||||||
|
| Конфликт имени | дублирующее → 409 |
|
||||||
|
| Откат | ошибка создания Function → Package удалён |
|
||||||
|
| downloadPackageBytes | type:literal; type:url; пустой Package → ошибка |
|
||||||
|
| downloadFromStoragesvc | HTTP 404 → ошибка с кодом |
|
||||||
|
| getTriggerMethods | триггер найден; триггер не найден |
|
||||||
|
| Невалидный JSON | → 400 |
|
||||||
|
| Ссылка Package→Function | `spec.package.packageref.name` совпадает с реальным Package |
|
||||||
|
|
||||||
|
```
|
||||||
|
ok fission-console/internal/api 0.040s
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### План перед работой
|
### План перед работой
|
||||||
- Зафиксировать, что REST `/v2/*` недоступен в текущем кластере Fission v1.22.1 и MVP идет через Kubernetes CRD API.
|
- Зафиксировать, что REST `/v2/*` недоступен в текущем кластере Fission v1.22.1 и MVP идет через Kubernetes CRD API.
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ import os
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import json
|
import json
|
||||||
|
import datetime
|
||||||
|
|
||||||
COLLECTOR_URL = "http://metrics-collector.fission.svc.cluster.local:8091/metrics"
|
COLLECTOR_URL = "http://metrics-collector.fission.svc.cluster.local:8091/metrics"
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
now = datetime.datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||||
|
print(f"[croned] start {now}", flush=True)
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"source": "fission-cron-metrics",
|
"source": "fission-cron-metrics",
|
||||||
"hostname": os.uname().nodename,
|
"hostname": os.uname().nodename,
|
||||||
@@ -18,6 +22,7 @@ def main():
|
|||||||
payload["cpu_load_1m"] = load[0]
|
payload["cpu_load_1m"] = load[0]
|
||||||
payload["cpu_load_5m"] = load[1]
|
payload["cpu_load_5m"] = load[1]
|
||||||
payload["cpu_load_15m"] = load[2]
|
payload["cpu_load_15m"] = load[2]
|
||||||
|
print(f"[croned] cpu load: 1m={load[0]:.2f} 5m={load[1]:.2f} 15m={load[2]:.2f}", flush=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -37,6 +42,7 @@ def main():
|
|||||||
payload["memory_available_mb"] = round(avail, 1)
|
payload["memory_available_mb"] = round(avail, 1)
|
||||||
payload["memory_used_mb"] = round(used, 1)
|
payload["memory_used_mb"] = round(used, 1)
|
||||||
payload["memory_percent"] = round(used / total * 100, 1) if total else 0
|
payload["memory_percent"] = round(used / total * 100, 1) if total else 0
|
||||||
|
print(f"[croned] mem: used={round(used,1)}MB / {round(total,1)}MB ({round(used/total*100,1) if total else 0}%)", flush=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -49,6 +55,7 @@ def main():
|
|||||||
payload["disk_total_gb"] = round(total_gb, 1)
|
payload["disk_total_gb"] = round(total_gb, 1)
|
||||||
payload["disk_free_gb"] = round(free_gb, 1)
|
payload["disk_free_gb"] = round(free_gb, 1)
|
||||||
payload["disk_used_gb"] = round(used_gb, 1)
|
payload["disk_used_gb"] = round(used_gb, 1)
|
||||||
|
print(f"[croned] disk: used={round(used_gb,1)}GB / {round(total_gb,1)}GB", flush=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -68,6 +75,8 @@ def main():
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||||
|
print(f"[croned] sent OK status={resp.status}", flush=True)
|
||||||
return {"ok": True, "status": resp.status}
|
return {"ok": True, "status": resp.status}
|
||||||
except urllib.error.URLError as e:
|
except urllib.error.URLError as e:
|
||||||
|
print(f"[croned] send ERROR: {e}", flush=True)
|
||||||
return {"ok": False, "error": str(e)}
|
return {"ok": False, "error": str(e)}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
apiVersion: v2
|
||||||
|
name: fission-console
|
||||||
|
description: Fission Console — web UI and API gateway for Fission serverless platform
|
||||||
|
type: application
|
||||||
|
version: 0.1.0
|
||||||
|
appVersion: "v1.3.56"
|
||||||
|
keywords:
|
||||||
|
- fission
|
||||||
|
- serverless
|
||||||
|
- kubernetes
|
||||||
|
- console
|
||||||
|
home: https://gitea.services.ngcloud.ru/Nail/fission
|
||||||
|
maintainers:
|
||||||
|
- name: naeel
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
Fission Console успешно установлен!
|
||||||
|
|
||||||
|
Адрес: https://{{ .Values.ingress.host }}
|
||||||
|
|
||||||
|
Для проверки:
|
||||||
|
kubectl get pods -n {{ .Release.Namespace }} -l app.kubernetes.io/name=fission-console
|
||||||
|
|
||||||
|
Логи:
|
||||||
|
kubectl logs -n {{ .Release.Namespace }} -l app.kubernetes.io/name=fission-console
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{{/*
|
||||||
|
Expand the name of the chart.
|
||||||
|
*/}}
|
||||||
|
{{- define "fission-console.name" -}}
|
||||||
|
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Common labels
|
||||||
|
*/}}
|
||||||
|
{{- define "fission-console.labels" -}}
|
||||||
|
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }}
|
||||||
|
app.kubernetes.io/name: fission-console
|
||||||
|
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||||
|
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||||
|
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Selector labels
|
||||||
|
*/}}
|
||||||
|
{{- define "fission-console.selectorLabels" -}}
|
||||||
|
app.kubernetes.io/name: fission-console
|
||||||
|
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: fission-console
|
||||||
|
namespace: {{ .Release.Namespace }}
|
||||||
|
labels:
|
||||||
|
{{- include "fission-console.labels" . | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
replicas: {{ .Values.replicaCount }}
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
{{- include "fission-console.selectorLabels" . | nindent 6 }}
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
{{- include "fission-console.selectorLabels" . | nindent 8 }}
|
||||||
|
spec:
|
||||||
|
serviceAccountName: fission-console
|
||||||
|
containers:
|
||||||
|
- name: console
|
||||||
|
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
|
||||||
|
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||||
|
ports:
|
||||||
|
- containerPort: {{ .Values.service.port }}
|
||||||
|
env:
|
||||||
|
- name: FISSION_NAMESPACE
|
||||||
|
value: {{ .Values.fission.namespace | quote }}
|
||||||
|
- name: FISSION_ROUTER_URL
|
||||||
|
value: {{ .Values.fission.routerUrl | quote }}
|
||||||
|
- name: PORT
|
||||||
|
value: {{ .Values.service.port | quote }}
|
||||||
|
- name: FISSION_HTTP_TIMEOUT
|
||||||
|
value: {{ .Values.fission.httpTimeout | quote }}
|
||||||
|
- name: FISSION_INVOKE_TIMEOUT
|
||||||
|
value: {{ .Values.fission.invokeTimeout | quote }}
|
||||||
|
- name: FISSION_AUTH_USERNAME
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ .Values.auth.secretName }}
|
||||||
|
key: {{ .Values.auth.usernameKey }}
|
||||||
|
- name: FISSION_AUTH_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ .Values.auth.secretName }}
|
||||||
|
key: {{ .Values.auth.passwordKey }}
|
||||||
|
- name: FISSION_STORAGESVC_URL
|
||||||
|
value: {{ .Values.fission.storagesvcUrl | quote }}
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: {{ .Values.probes.liveness.path }}
|
||||||
|
port: {{ .Values.service.port }}
|
||||||
|
initialDelaySeconds: {{ .Values.probes.liveness.initialDelaySeconds }}
|
||||||
|
periodSeconds: {{ .Values.probes.liveness.periodSeconds }}
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: {{ .Values.probes.readiness.path }}
|
||||||
|
port: {{ .Values.service.port }}
|
||||||
|
initialDelaySeconds: {{ .Values.probes.readiness.initialDelaySeconds }}
|
||||||
|
periodSeconds: {{ .Values.probes.readiness.periodSeconds }}
|
||||||
|
resources:
|
||||||
|
{{- toYaml .Values.resources | nindent 12 }}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{{- if .Values.ingress.enabled }}
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: fission-console
|
||||||
|
namespace: {{ .Release.Namespace }}
|
||||||
|
labels:
|
||||||
|
{{- include "fission-console.labels" . | nindent 4 }}
|
||||||
|
annotations:
|
||||||
|
{{- range $key, $value := .Values.ingress.annotations }}
|
||||||
|
{{ $key }}: {{ $value | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.ingress.tls.certManager.enabled }}
|
||||||
|
cert-manager.io/cluster-issuer: {{ .Values.ingress.tls.certManager.clusterIssuer | quote }}
|
||||||
|
{{- end }}
|
||||||
|
spec:
|
||||||
|
ingressClassName: {{ .Values.ingress.className | quote }}
|
||||||
|
rules:
|
||||||
|
- host: {{ .Values.ingress.host | quote }}
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
{{- range .Values.ingress.paths }}
|
||||||
|
- path: {{ .path }}
|
||||||
|
pathType: {{ .pathType }}
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: fission-console
|
||||||
|
port:
|
||||||
|
number: {{ $.Values.service.port }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.ingress.tls.enabled }}
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- {{ .Values.ingress.host | quote }}
|
||||||
|
secretName: {{ .Values.ingress.tls.secretName | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{{- if .Values.rbac.create }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: fission-console
|
||||||
|
namespace: {{ .Release.Namespace }}
|
||||||
|
labels:
|
||||||
|
{{- include "fission-console.labels" . | nindent 4 }}
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: ClusterRole
|
||||||
|
metadata:
|
||||||
|
name: {{ .Release.Namespace }}-fission-console
|
||||||
|
labels:
|
||||||
|
{{- include "fission-console.labels" . | nindent 4 }}
|
||||||
|
rules:
|
||||||
|
- apiGroups: [""]
|
||||||
|
resources: ["pods"]
|
||||||
|
verbs: ["get", "list"]
|
||||||
|
- apiGroups: ["apps"]
|
||||||
|
resources: ["deployments"]
|
||||||
|
verbs: ["get", "list", "update", "patch"]
|
||||||
|
- apiGroups: ["fission.io"]
|
||||||
|
resources: ["environments", "packages", "functions", "httptriggers", "timetriggers"]
|
||||||
|
verbs: ["get", "list", "create", "update", "patch", "delete"]
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: ClusterRoleBinding
|
||||||
|
metadata:
|
||||||
|
name: {{ .Release.Namespace }}-fission-console
|
||||||
|
labels:
|
||||||
|
{{- include "fission-console.labels" . | nindent 4 }}
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: fission-console
|
||||||
|
namespace: {{ .Release.Namespace }}
|
||||||
|
roleRef:
|
||||||
|
kind: ClusterRole
|
||||||
|
name: {{ .Release.Namespace }}-fission-console
|
||||||
|
apiGroup: rbac.authorization.k8s.io
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: fission-console
|
||||||
|
namespace: {{ .Release.Namespace }}
|
||||||
|
labels:
|
||||||
|
{{- include "fission-console.labels" . | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
{{- include "fission-console.selectorLabels" . | nindent 4 }}
|
||||||
|
ports:
|
||||||
|
- port: {{ .Values.service.port }}
|
||||||
|
targetPort: {{ .Values.service.port }}
|
||||||
|
protocol: TCP
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# Fission Console — values.yaml
|
||||||
|
# Все секреты и домен задаются при установке:
|
||||||
|
# helm install ... --set ingress.host=... --set auth.username=... --set auth.password=...
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Образ консоли
|
||||||
|
image:
|
||||||
|
repository: naeel/fission-console
|
||||||
|
tag: "v1.3.56"
|
||||||
|
pullPolicy: Always
|
||||||
|
|
||||||
|
# Количество реплик
|
||||||
|
replicaCount: 1
|
||||||
|
|
||||||
|
# Namespace, в котором работает Fission (не namespace самой консоли)
|
||||||
|
fission:
|
||||||
|
namespace: "default"
|
||||||
|
routerUrl: "http://router.fission.svc.cluster.local"
|
||||||
|
storagesvcUrl: "http://storagesvc.fission.svc.cluster.local"
|
||||||
|
httpTimeout: "30s"
|
||||||
|
invokeTimeout: "60s"
|
||||||
|
|
||||||
|
# Порт контейнера
|
||||||
|
service:
|
||||||
|
port: 8090
|
||||||
|
|
||||||
|
# Аутентификация (секрет router в кластере)
|
||||||
|
# Значения передаются через --set или отдельный values-secrets.yaml (не коммитить!)
|
||||||
|
auth:
|
||||||
|
# Имя Kubernetes Secret, из которого берутся username/password
|
||||||
|
secretName: "router"
|
||||||
|
usernameKey: "username"
|
||||||
|
passwordKey: "password"
|
||||||
|
|
||||||
|
# Ingress
|
||||||
|
ingress:
|
||||||
|
enabled: true
|
||||||
|
className: "nginx"
|
||||||
|
host: "" # ОБЯЗАТЕЛЬНО указать: --set ingress.host=fission.mycompany.ru
|
||||||
|
tls:
|
||||||
|
enabled: true
|
||||||
|
certManager:
|
||||||
|
enabled: true
|
||||||
|
clusterIssuer: "letsencrypt-prod"
|
||||||
|
secretName: "fission-console-tls" # имя TLS-секрета (cert-manager создаст сам)
|
||||||
|
annotations:
|
||||||
|
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
|
||||||
|
# Пути, проксируемые через Ingress
|
||||||
|
# По умолчанию: /, /fn, /console, /cron, /default
|
||||||
|
# Переопределять не нужно в большинстве случаев
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Exact
|
||||||
|
- path: /fn
|
||||||
|
pathType: Prefix
|
||||||
|
- path: /console
|
||||||
|
pathType: Prefix
|
||||||
|
- path: /cron
|
||||||
|
pathType: Prefix
|
||||||
|
- path: /default
|
||||||
|
pathType: Prefix
|
||||||
|
|
||||||
|
# Ресурсы контейнера
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 20m
|
||||||
|
memory: 32Mi
|
||||||
|
limits:
|
||||||
|
cpu: 200m
|
||||||
|
memory: 128Mi
|
||||||
|
|
||||||
|
# Liveness/Readiness пробы
|
||||||
|
probes:
|
||||||
|
liveness:
|
||||||
|
path: /health
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 20
|
||||||
|
readiness:
|
||||||
|
path: /health
|
||||||
|
initialDelaySeconds: 3
|
||||||
|
periodSeconds: 10
|
||||||
|
|
||||||
|
# RBAC
|
||||||
|
rbac:
|
||||||
|
create: true
|
||||||
@@ -24,6 +24,9 @@ func main() {
|
|||||||
_, _ = w.Write([]byte("ok\n"))
|
_, _ = w.Write([]byte("ok\n"))
|
||||||
})
|
})
|
||||||
mux.HandleFunc("/metrics", srv.HandleMetrics)
|
mux.HandleFunc("/metrics", srv.HandleMetrics)
|
||||||
|
// ⛔ /cron/metrics — внешний путь через ingress /cron → metrics-collector:8091.
|
||||||
|
// ⛔ НЕ УДАЛЯТЬ. Без этого маршрута UI на /cron не получает данные.
|
||||||
|
mux.HandleFunc("/cron/metrics", srv.HandleMetrics)
|
||||||
mux.Handle("/", ui.Handler())
|
mux.Handle("/", ui.Handler())
|
||||||
|
|
||||||
httpSrv := &http.Server{
|
httpSrv := &http.Server{
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: metrics-collector
|
- name: metrics-collector
|
||||||
image: naeel/metrics-collector:v0.1.0
|
image: naeel/metrics-collector:v0.1.3
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 8091
|
- containerPort: 8091
|
||||||
env:
|
env:
|
||||||
@@ -54,3 +54,36 @@ spec:
|
|||||||
ports:
|
ports:
|
||||||
- port: 8091
|
- port: 8091
|
||||||
targetPort: 8091
|
targetPort: 8091
|
||||||
|
---
|
||||||
|
# ⛔ АРХИТЕКТУРА: три независимые сущности:
|
||||||
|
# 1. croned (функция) — шлёт метрики POST /metrics → metrics-collector:8091
|
||||||
|
# 2. metrics-collector (этот сервис) — хранит и отдаёт метрики, UI на /cron
|
||||||
|
# 3. fission-console — управление функциями, НЕ ЗНАЕТ про метрики
|
||||||
|
#
|
||||||
|
# ⛔ НЕ ПЕРЕНОСИТЬ /cron в ingress fission-console — это ломает архитектуру.
|
||||||
|
# ⛔ НЕ УБИРАТЬ этот Ingress — без него /cron страница недоступна снаружи.
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: metrics-collector
|
||||||
|
namespace: fission
|
||||||
|
annotations:
|
||||||
|
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
|
||||||
|
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||||
|
spec:
|
||||||
|
ingressClassName: nginx
|
||||||
|
rules:
|
||||||
|
- host: fission.kube5s.ru
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /cron
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: metrics-collector
|
||||||
|
port:
|
||||||
|
number: 8091
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- fission.kube5s.ru
|
||||||
|
secretName: fission-tls
|
||||||
|
|||||||
@@ -228,7 +228,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const API = '/metrics';
|
const API = '/cron/metrics';
|
||||||
const summaryEl = document.getElementById('summary');
|
const summaryEl = document.getElementById('summary');
|
||||||
const historyBody = document.getElementById('history-body');
|
const historyBody = document.getElementById('history-body');
|
||||||
const lastUpdatedEl = document.getElementById('last-updated');
|
const lastUpdatedEl = document.getElementById('last-updated');
|
||||||
|
|||||||
@@ -1,3 +1,66 @@
|
|||||||
ntazetdinov@nubes.ru
|
ntazetdinov@nubes.ru
|
||||||
|
stxVxLvM9eqssgZ
|
||||||
|
|
||||||
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoLWFwaSIsInN1YiI6IjAxOWQ3NmM1LTQyNGMtN2MyYy04OWI3LWUzZjA5OTQ5YTk4ZiIsImV4cCI6MTc5MzA3ODQzOSwiaWF0IjoxNzc3NTI2NDM5LCJqdGkiOiI4MTU1ODNkNy0xZTRkLTRiMmMtOGM5ZS0wZDZmMGU2NmJjNTciLCJhdXRoX3RpbWUiOjAsInR5cCI6IiIsImF6cCI6IiIsInNlc3Npb25fc3RhdGUiOiIiLCJhY3IiOiIiLCJhbGxvd2VkLW9yaWdpbnMiOm51bGwsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6bnVsbH0sInJlc291cmNlX2FjY2VzcyI6eyJhY2NvdW50Ijp7InJvbGVzIjpudWxsfX0sInNjb3BlIjoiIiwic2lkIjoiIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJuYW1lIjoiIiwiQ2xpZW50SUQiOiJXWjAxMTEyIiwiY29tcGFueV9pZCI6IjhlYzcwYWMwLTU0NmQtNDJhNy04Y2ZmLTMzOWM4ZmI1MWEyMyIsImNvbXBhbnlfbmFtZSI6ItCe0J7QniDCq9Cd0KPQkdCV0KHCuyIsImlkcF91c3JfdWlkIjoiMDE5ZDc2YzUtNDI0Yy03YzJjLTg5YjctZTNmMDk5NDlhOThmIiwibG9naW4iOiJudGF6ZXRkaW5vdkBudWJlcy5ydSIsImZpcnN0bmFtZSI6ItCd0LDQuNC70YwiLCJtaWRkbGVuYW1lIjoi0KTQsNGA0LjRgtC-0LLQuNGHIiwibGFzdG5hbWUiOiLQotCw0LfQtdGC0LTQuNC90L7QsiIsImdyb3VwcyI6bnVsbCwicHJlZmVycmVkX3VzZXJuYW1lIjoiIiwiZ2l2ZW5fbmFtZSI6IiIsImZhbWlseV9uYW1lIjoiIiwiZW1haWwiOiJudGF6ZXRkaW5vdkBudWJlcy5ydSJ9.SaZsKAha45-fvKuJkLHUe_09AFsbH5QpzBVdPnjiEDQGIhl1A3ThgnM-oEh_H6CBXXfnOs2QDczzgCD8K8_tYzZKU1Wgk5lj04YW_fbTI89kHTO5wLtrAht9tFEjzBQZ-kmnG8mUK5tgqyNEjsngdQcfqVWRvneF366TiiRXk_76poUPpXWQmqdgCCKb3wq1rRgWcKDZcUC3JvDpOaqYL40zITZlM855drlJhMut4Gkg-EEk7ZGykl6YCsTRWVjFKGQ-7C-BDU2dkmIWhxzBAkvzzM2kBwvtlczCEDiUin7bXE-F_io89oJUYDzY4yPeHzwREwxCp6x9eD1NAIKKuA
|
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoLWFwaSIsInN1YiI6IjAxOWQ3NmM1LTQyNGMtN2MyYy04OWI3LWUzZjA5OTQ5YTk4ZiIsImV4cCI6MTc5MzA3ODQzOSwiaWF0IjoxNzc3NTI2NDM5LCJqdGkiOiI4MTU1ODNkNy0xZTRkLTRiMmMtOGM5ZS0wZDZmMGU2NmJjNTciLCJhdXRoX3RpbWUiOjAsInR5cCI6IiIsImF6cCI6IiIsInNlc3Npb25fc3RhdGUiOiIiLCJhY3IiOiIiLCJhbGxvd2VkLW9yaWdpbnMiOm51bGwsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6bnVsbH0sInJlc291cmNlX2FjY2VzcyI6eyJhY2NvdW50Ijp7InJvbGVzIjpudWxsfX0sInNjb3BlIjoiIiwic2lkIjoiIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJuYW1lIjoiIiwiQ2xpZW50SUQiOiJXWjAxMTEyIiwiY29tcGFueV9pZCI6IjhlYzcwYWMwLTU0NmQtNDJhNy04Y2ZmLTMzOWM4ZmI1MWEyMyIsImNvbXBhbnlfbmFtZSI6ItCe0J7QniDCq9Cd0KPQkdCV0KHCuyIsImlkcF91c3JfdWlkIjoiMDE5ZDc2YzUtNDI0Yy03YzJjLTg5YjctZTNmMDk5NDlhOThmIiwibG9naW4iOiJudGF6ZXRkaW5vdkBudWJlcy5ydSIsImZpcnN0bmFtZSI6ItCd0LDQuNC70YwiLCJtaWRkbGVuYW1lIjoi0KTQsNGA0LjRgtC-0LLQuNGHIiwibGFzdG5hbWUiOiLQotCw0LfQtdGC0LTQuNC90L7QsiIsImdyb3VwcyI6bnVsbCwicHJlZmVycmVkX3VzZXJuYW1lIjoiIiwiZ2l2ZW5fbmFtZSI6IiIsImZhbWlseV9uYW1lIjoiIiwiZW1haWwiOiJudGF6ZXRkaW5vdkBudWJlcy5ydSJ9.SaZsKAha45-fvKuJkLHUe_09AFsbH5QpzBVdPnjiEDQGIhl1A3ThgnM-oEh_H6CBXXfnOs2QDczzgCD8K8_tYzZKU1Wgk5lj04YW_fbTI89kHTO5wLtrAht9tFEjzBQZ-kmnG8mUK5tgqyNEjsngdQcfqVWRvneF366TiiRXk_76poUPpXWQmqdgCCKb3wq1rRgWcKDZcUC3JvDpOaqYL40zITZlM855drlJhMut4Gkg-EEk7ZGykl6YCsTRWVjFKGQ-7C-BDU2dkmIWhxzBAkvzzM2kBwvtlczCEDiUin7bXE-F_io89oJUYDzY4yPeHzwREwxCp6x9eD1NAIKKuA
|
||||||
|
|
||||||
|
----------- token on VM --------------
|
||||||
|
https://deck-api-test.ngcloud.ru/api/v1 - для тест стенда где постгрес, файл с токеном -
|
||||||
|
naeel@naeel-vm:~/terra/sless$ ll test.token
|
||||||
|
-rw-r--r-- 1 naeel naeel 998 Feb 28 07:18 test.token
|
||||||
|
|
||||||
|
--------------------- pg IoT-PG -----------------------
|
||||||
|
users
|
||||||
|
JSON объект (раскройте для просмотра)
|
||||||
|
|
||||||
|
password:
|
||||||
|
BQUF5ruECa1ZFlq4wYt3gPJUEmtBMkA9QNK4MM5Sd8al4ArMDlmT16DIKHYBPyif
|
||||||
|
|
||||||
|
username:
|
||||||
|
super
|
||||||
|
|
||||||
|
Поля состояния (сгенерированные значения)
|
||||||
|
monitoring
|
||||||
|
{
|
||||||
|
"resourceMetrics": "https://grafana.ngcloud.ru/d/vzjb4zd/kubernetes-pods?orgId=954&var-namespace=dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5&var-resource_realm=iot-naeel"
|
||||||
|
}
|
||||||
|
|
||||||
|
users
|
||||||
|
{
|
||||||
|
"super": {
|
||||||
|
"role": "ddl_user",
|
||||||
|
"username": "super"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
backups
|
||||||
|
{}
|
||||||
|
|
||||||
|
databases
|
||||||
|
{
|
||||||
|
"sqsdb": {
|
||||||
|
"dbName": "sqsdb",
|
||||||
|
"dbOwner": "super"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
externalConnect
|
||||||
|
{
|
||||||
|
"slave": {
|
||||||
|
"ip": "",
|
||||||
|
"fqdn": "",
|
||||||
|
"uuid": "",
|
||||||
|
"isExternal": false
|
||||||
|
},
|
||||||
|
"master": {
|
||||||
|
"ip": "",
|
||||||
|
"fqdn": "",
|
||||||
|
"uuid": "",
|
||||||
|
"isExternal": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internalConnect
|
||||||
|
{
|
||||||
|
"slave": "",
|
||||||
|
"master": "postgresqlk8s-master.dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5.svc.cluster.local"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user