Compare commits
21
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 |
@@ -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,6 +12,7 @@ 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/kubernetes"
|
||||||
@@ -48,6 +49,8 @@ func main() {
|
|||||||
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,
|
Kube: kube,
|
||||||
@@ -64,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 истёкших функций
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ spec:
|
|||||||
serviceAccountName: fission-console
|
serviceAccountName: fission-console
|
||||||
containers:
|
containers:
|
||||||
- name: console
|
- name: console
|
||||||
image: naeel/fission-console:v1.3.69
|
image: naeel/fission-console:v1.3.86
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 8090
|
- containerPort: 8090
|
||||||
@@ -82,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
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ require (
|
|||||||
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/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
|
||||||
@@ -32,6 +36,7 @@ require (
|
|||||||
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/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=
|
||||||
|
|||||||
@@ -255,6 +255,8 @@ func buildChatSystemPrompt() string {
|
|||||||
- **Cron** — можно включить/выключить и изменить расписание
|
- **Cron** — можно включить/выключить и изменить расписание
|
||||||
- **Код** — редактируется (для функций созданных из кода)
|
- **Код** — редактируется (для функций созданных из кода)
|
||||||
- **Заменить архив** — загрузить новый zip (для функций из архива)
|
- **Заменить архив** — загрузить новый zip (для функций из архива)
|
||||||
|
- **Переменные окружения** — раздел в конце редактора. Кнопка "+ Добавить" добавляет переменную (имя + значение). Кнопка "Сохранить env vars" применяет изменения. Переменные сразу доступны в поде функции через os.environ (Python), process.env (Node.js), os.Getenv (Go) и т.д.
|
||||||
|
- **📋 Клон** — кнопка в нижней панели. Позволяет создать копию функции с новым именем. Клон наследует код, язык, entrypoint, timeout. Маршрут назначается автоматически.
|
||||||
|
|
||||||
## Вызов функции (кнопка Вызов)
|
## Вызов функции (кнопка Вызов)
|
||||||
Открывается модальное окно. Можно указать тело запроса (JSON) и нажать "Вызвать".
|
Открывается модальное окно. Можно указать тело запроса (JSON) и нажать "Вызвать".
|
||||||
@@ -318,12 +320,7 @@ Ruby (entrypoint: handler):
|
|||||||
- ` + "`@hourly`" + `, ` + "`@daily`" + `, ` + "`@weekly`" + ` — стандартные псевдонимы
|
- ` + "`@hourly`" + `, ` + "`@daily`" + `, ` + "`@weekly`" + ` — стандартные псевдонимы
|
||||||
|
|
||||||
## ESC и закрытие окон
|
## ESC и закрытие окон
|
||||||
Нажатие ESC закрывает любое открытое окно (модалку).
|
Нажатие ESC закрывает любое открытое окно (модалку).`)
|
||||||
|
|
||||||
## Чем НЕ занимаешься
|
|
||||||
- Не рассказываешь про устройство платформы изнутри
|
|
||||||
- Не помогаешь с инфраструктурой, деплоем, kubernetes
|
|
||||||
- Не отвечаешь на вопросы не связанные с работой в консоли`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func prettyLanguageName(lang string) string {
|
func prettyLanguageName(lang string) string {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"fission-console/internal/billing"
|
||||||
"fission-console/internal/fission"
|
"fission-console/internal/fission"
|
||||||
"fission-console/internal/model"
|
"fission-console/internal/model"
|
||||||
"fission-console/internal/runtime"
|
"fission-console/internal/runtime"
|
||||||
@@ -275,6 +276,16 @@ func (s *Server) handleCreateFunction(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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{
|
writeAnyJSON(w, http.StatusCreated, map[string]any{
|
||||||
"name": req.Name,
|
"name": req.Name,
|
||||||
"package": pkgName,
|
"package": pkgName,
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
// Package api — CRUD операции с функциями: чтение, удаление, обновление таймаута, логи.
|
// Package api — CRUD операции с функциями: чтение, удаление, обновление таймаута, логи, env vars.
|
||||||
//
|
//
|
||||||
// Этот файл содержит операции, не связанные с заменой кода/архива:
|
// Этот файл содержит операции, не связанные с заменой кода/архива:
|
||||||
// - handleGetFunction — GET /functions/:name (детали: код, route, environment, source_type)
|
// - handleGetFunction — GET /functions/:name (детали: код, route, environment, source_type)
|
||||||
// - handleDeleteFunction — DELETE /functions/:name (каскадное удаление: триггеры, Package, S3)
|
// - handleDeleteFunction — DELETE /functions/:name (каскадное удаление: триггеры, Package, S3)
|
||||||
// - handleUpdateFunctionTimeout — PUT /functions/:name/timeout (только таймаут, без замены кода)
|
// - handleUpdateFunctionTimeout — PUT /functions/:name/timeout (только таймаут, без замены кода)
|
||||||
// - handleGetFunctionLogs — GET /functions/:name/logs (логи пода через Kubernetes API)
|
// - 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_code.go и function_archive.go соответственно.
|
||||||
// Вызов функции — в function_invoke.go.
|
// Вызов функции — в function_invoke.go.
|
||||||
@@ -19,12 +21,14 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"fission-console/internal/billing"
|
||||||
"fission-console/internal/fission"
|
"fission-console/internal/fission"
|
||||||
|
|
||||||
corev1 "k8s.io/api/core/v1"
|
corev1 "k8s.io/api/core/v1"
|
||||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||||
|
k8stypes "k8s.io/apimachinery/pkg/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// handleGetFunction возвращает детали функции: код, environment, route, methods.
|
// handleGetFunction возвращает детали функции: код, environment, route, methods.
|
||||||
@@ -102,6 +106,7 @@ func (s *Server) handleGetFunction(w http.ResponseWriter, r *http.Request, name
|
|||||||
"archive_filename": archiveFilename,
|
"archive_filename": archiveFilename,
|
||||||
"route": route,
|
"route": route,
|
||||||
"methods": methods,
|
"methods": methods,
|
||||||
|
"env_vars": extractEnvVars(fn),
|
||||||
"raw": fn.Object,
|
"raw": fn.Object,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -224,6 +229,16 @@ func (s *Server) handleDeleteFunction(w http.ResponseWriter, r *http.Request, na
|
|||||||
|
|
||||||
// (reconciler NS удалён — за FISSION_RESOURCE_NAMESPACES теперь отвечает Layer 1 NSWatcher)
|
// (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})
|
writeAnyJSON(w, http.StatusOK, map[string]any{"deleted": true, "name": name, "package": pkgName})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,3 +299,226 @@ func (s *Server) handleGetFunctionLogs(w http.ResponseWriter, r *http.Request, n
|
|||||||
"logs": allLogs.String(),
|
"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
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"fission-console/internal/billing"
|
||||||
"fission-console/internal/fission"
|
"fission-console/internal/fission"
|
||||||
|
|
||||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
@@ -202,9 +203,26 @@ func (s *Server) handleInvokeFunction(w http.ResponseWriter, r *http.Request, na
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
respBody, _ := io.ReadAll(resp.Body)
|
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{
|
writeAnyJSON(w, http.StatusOK, map[string]any{
|
||||||
"status": resp.StatusCode,
|
"status": resp.StatusCode,
|
||||||
"latency_ms": time.Since(start).Milliseconds(),
|
"latency_ms": durationMS,
|
||||||
"response_raw": string(respBody),
|
"response_raw": string(respBody),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -311,9 +329,26 @@ func (s *Server) invokeInternalFunction(w http.ResponseWriter, r *http.Request,
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
respBody, _ := io.ReadAll(resp.Body)
|
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{
|
writeAnyJSON(w, http.StatusOK, map[string]any{
|
||||||
"status": resp.StatusCode,
|
"status": resp.StatusCode,
|
||||||
"latency_ms": time.Since(start).Milliseconds(),
|
"latency_ms": durationMS,
|
||||||
"response_raw": string(respBody),
|
"response_raw": string(respBody),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"fission-console/internal/auth"
|
"fission-console/internal/auth"
|
||||||
"fission-console/internal/fission"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// validFuncName — RFC 1123 subdomain label: строчные буквы+цифры+дефис, без дефиса в начале/конце.
|
// validFuncName — RFC 1123 subdomain label: строчные буквы+цифры+дефис, без дефиса в начале/конце.
|
||||||
@@ -51,11 +50,11 @@ const fissionSourceTypeAnnotation = "fission-console/source-type"
|
|||||||
const defaultFunctionInvokeTimeout = 60 * time.Second
|
const defaultFunctionInvokeTimeout = 60 * time.Second
|
||||||
|
|
||||||
// handleFunctionsRoot обрабатывает запросы к /console/api/functions без имени функции.
|
// handleFunctionsRoot обрабатывает запросы к /console/api/functions без имени функции.
|
||||||
// GET → список всех функций, POST → создать новую.
|
// GET → список всех функций со статусами, POST → создать новую.
|
||||||
func (s *Server) handleFunctionsRoot(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleFunctionsRoot(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
s.handleList(fission.FunctionGVR)(w, r)
|
s.handleListFunctionsWithStatus(w, r)
|
||||||
case http.MethodPost:
|
case http.MethodPost:
|
||||||
s.handleCreateFunction(w, r)
|
s.handleCreateFunction(w, r)
|
||||||
default:
|
default:
|
||||||
@@ -127,6 +126,25 @@ func (s *Server) handleFunctionsAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// /functions/:name/envvars — переменные окружения
|
||||||
|
if len(parts) == 2 && parts[1] == "envvars" {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
s.handleGetFunctionEnvVars(w, r, name)
|
||||||
|
case http.MethodPut:
|
||||||
|
s.handlePutFunctionEnvVars(w, r, name)
|
||||||
|
default:
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// /functions/:name/clone — клонировать функцию с новым именем
|
||||||
|
if len(parts) == 2 && parts[1] == "clone" && r.Method == http.MethodPost {
|
||||||
|
s.handleCloneFunction(w, r, name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -59,6 +60,9 @@ type Server struct {
|
|||||||
|
|
||||||
// nsManager управляет жизненным циклом пользовательских namespace-ов.
|
// nsManager управляет жизненным циклом пользовательских namespace-ов.
|
||||||
nsManager *cloud.NSManager
|
nsManager *cloud.NSManager
|
||||||
|
|
||||||
|
// billing — слой записи статистики вызовов. NoopStore если BILLING_DSN не задан.
|
||||||
|
billing billing.Store
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config содержит все параметры для создания Server.
|
// Config содержит все параметры для создания Server.
|
||||||
@@ -76,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 со всеми зависимостями.
|
||||||
@@ -96,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,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
|
||||||
|
}
|
||||||
+59
-2
@@ -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.69</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,10 +142,23 @@
|
|||||||
<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 title="Статус функции (Готова / Холодная / Сборка / Ошибка)">Статус</th>
|
||||||
<th title="Тип источника функции">Тип</th>
|
<th title="Тип источника функции">Тип</th>
|
||||||
<th>Создана</th>
|
<th>Создана</th>
|
||||||
<th>Изменена</th>
|
<th>Изменена</th>
|
||||||
@@ -376,7 +389,31 @@
|
|||||||
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>
|
||||||
@@ -440,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">
|
||||||
@@ -485,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.69</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>
|
||||||
|
|||||||
+16
-1
@@ -48,6 +48,20 @@ async function reloadAll() {
|
|||||||
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 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'
|
const sourceIcon = sourceType === 'archive'
|
||||||
? '<span title="Из архива (.zip)" style="font-size:1.1em; cursor:default;">📦</span>'
|
? '<span title="Из архива (.zip)" style="font-size:1.1em; cursor:default;">📦</span>'
|
||||||
: '<span title="Из кода (редактор)" style="font-size:1.1em; cursor:default;">📝</span>';
|
: '<span title="Из кода (редактор)" style="font-size:1.1em; cursor:default;">📝</span>';
|
||||||
@@ -58,7 +72,7 @@ async function reloadAll() {
|
|||||||
var infoData = h(JSON.stringify({
|
var infoData = h(JSON.stringify({
|
||||||
name: name, env: env, pkg: pkg, entrypoint: entrypoint, timeout: timeout,
|
name: name, env: env, pkg: pkg, entrypoint: entrypoint, timeout: timeout,
|
||||||
route: route, methods: methods, sourceType: sourceType,
|
route: route, methods: methods, sourceType: sourceType,
|
||||||
createdAt: createdAt, updatedAt: updatedAt, cron: cron
|
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="openInfo(\'' + h(name) + '\', this)" data-info="' + infoData + '">Info</button> ' +
|
||||||
@@ -68,6 +82,7 @@ async function reloadAll() {
|
|||||||
'<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 style="text-align:center;">' + statusDot + '</td>' +
|
||||||
'<td style="text-align:center;">' + sourceIcon + '</td>' +
|
'<td style="text-align:center;">' + sourceIcon + '</td>' +
|
||||||
'<td>' + timestampCell(createdAt) + '</td>' +
|
'<td>' + timestampCell(createdAt) + '</td>' +
|
||||||
'<td>' + timestampCell(updatedAt) + '</td>' +
|
'<td>' + timestampCell(updatedAt) + '</td>' +
|
||||||
|
|||||||
+212
-1
@@ -151,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;
|
||||||
@@ -191,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');
|
||||||
@@ -238,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;
|
||||||
}
|
}
|
||||||
@@ -260,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 = 'Клонировать'; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
+171
-3
@@ -17,12 +17,180 @@
|
|||||||
- UI: формы создания по аналогии с cron, счётчики на главной
|
- UI: формы создания по аналогии с cron, счётчики на главной
|
||||||
|
|
||||||
### Прочий полезный функционал
|
### Прочий полезный функционал
|
||||||
|
- ~~Клонировать функцию (копия с новым именем)~~ ✅ v1.3.80
|
||||||
|
- ~~Переменные окружения (env vars) через UI~~ ✅ v1.3.78
|
||||||
- Метрики на главной странице (данные уже есть в metrics-collector)
|
- Метрики на главной странице (данные уже есть в metrics-collector)
|
||||||
- Автообновление логов (polling каждые 3-5 сек)
|
- Автообновление логов (polling каждые 3-5 сек)
|
||||||
- Копировать маршрут одной кнопкой (📋 рядом с маршрутом)
|
- Копировать маршрут одной кнопкой (📋 рядом с маршрутом)
|
||||||
- Статус функции (Ready/NotReady/Error) в таблице
|
|
||||||
- Клонировать функцию (копия с новым именем)
|
---
|
||||||
- Переменные окружения (env vars) через UI
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,66 @@
|
|||||||
ntazetdinov@nubes.ru
|
ntazetdinov@nubes.ru
|
||||||
stxVxLvM9eqssgZ
|
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