Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3672f27d1d | ||
|
|
00d61a5284 |
@@ -13,18 +13,6 @@
|
|||||||
3. ЖДАТЬ следующей команды
|
3. ЖДАТЬ следующей команды
|
||||||
**ЗАПРЕЩЕНО** начинать работу, писать код, запускать команды — без явного "делай".
|
**ЗАПРЕЩЕНО** начинать работу, писать код, запускать команды — без явного "делай".
|
||||||
|
|
||||||
## ⛔⛔⛔ НЕ ТРОГАТЬ РАБОЧИЙ КОД — АБСОЛЮТНЫЙ ЗАПРЕТ НАВСЕГДА
|
|
||||||
|
|
||||||
**НИКАКИХ самодеятельных изменений рабочего кода:**
|
|
||||||
- Никаких "оптимизаций", "улучшений", "рефакторинга" без команды
|
|
||||||
- Никаких новых фич без явного разрешения
|
|
||||||
- Никаких helm upgrade, kubectl patch и прочих инфраструктурных изменений без команды
|
|
||||||
- Перед ЛЮБЫМ изменением рабочего кода — объяснить ЗАЧЕМ и ждать "делай"
|
|
||||||
|
|
||||||
**ПРЕЦЕДЕНТЫ:**
|
|
||||||
- v1.3.49: убрали mode toggle в edit modal "для улучшения" → сломали рабочий редактор
|
|
||||||
- helm upgrade попытка → сломал JWT secret router → 401 у всех функций пользователей
|
|
||||||
|
|
||||||
1. Не трогать рабочий код без явного указания.
|
1. Не трогать рабочий код без явного указания.
|
||||||
|
|
||||||
2. Файлы редактируются локально:
|
2. Файлы редактируются локально:
|
||||||
|
|||||||
@@ -91,13 +91,6 @@ rsync -az -e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no" \
|
|||||||
|
|
||||||
**Никогда не разбираться с результатами по памяти / буферу / чату. Только лог.**
|
**Никогда не разбираться с результатами по памяти / буферу / чату. Только лог.**
|
||||||
|
|
||||||
## ⛔ РУЧНЫЕ ПАТЧИ — ЗАПРЕЩЕНЫ
|
|
||||||
|
|
||||||
- НИКОГДА не применять ручные патчи (`kubectl patch`, `kubectl apply` отдельных полей, `python -c` замены в yaml и т.д.) без явного указания.
|
|
||||||
- Все изменения — только через код (Helm chart, YAML, Go-код) + сборка + деплой.
|
|
||||||
- Ручной патч слетает при следующем helm upgrade/redeploy → регрессия.
|
|
||||||
- Исключение: только если пользователь явно написал "примени ручной патч".
|
|
||||||
|
|
||||||
## Поведение агента
|
## Поведение агента
|
||||||
|
|
||||||
- Не трогать рабочий код без явного указания
|
- Не трогать рабочий код без явного указания
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import (
|
|||||||
"fission-console/internal/auth"
|
"fission-console/internal/auth"
|
||||||
|
|
||||||
"k8s.io/client-go/dynamic"
|
"k8s.io/client-go/dynamic"
|
||||||
"k8s.io/client-go/kubernetes"
|
|
||||||
"k8s.io/client-go/rest"
|
"k8s.io/client-go/rest"
|
||||||
"k8s.io/client-go/tools/clientcmd"
|
"k8s.io/client-go/tools/clientcmd"
|
||||||
)
|
)
|
||||||
@@ -38,11 +37,6 @@ func main() {
|
|||||||
log.Fatalf("create dynamic client: %v", err)
|
log.Fatalf("create dynamic client: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
kube, err := kubernetes.NewForConfig(cfg)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("create kubernetes client: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
authenticator := &auth.MultiAuthenticator{
|
authenticator := &auth.MultiAuthenticator{
|
||||||
JWT: auth.NewDeckAuthenticator(auth.DefaultDeckAPIs, nil),
|
JWT: auth.NewDeckAuthenticator(auth.DefaultDeckAPIs, nil),
|
||||||
Demo: &auth.DemoAuthenticator{},
|
Demo: &auth.DemoAuthenticator{},
|
||||||
@@ -50,7 +44,6 @@ func main() {
|
|||||||
|
|
||||||
srv := api.NewServer(api.Config{
|
srv := api.NewServer(api.Config{
|
||||||
Dyn: dyn,
|
Dyn: dyn,
|
||||||
Kube: kube,
|
|
||||||
Namespace: namespace,
|
Namespace: namespace,
|
||||||
RouterURL: routerURL,
|
RouterURL: routerURL,
|
||||||
HTTPTimeout: httpTimeout,
|
HTTPTimeout: httpTimeout,
|
||||||
|
|||||||
@@ -12,9 +12,6 @@ rules:
|
|||||||
- apiGroups: [""]
|
- apiGroups: [""]
|
||||||
resources: ["pods"]
|
resources: ["pods"]
|
||||||
verbs: ["get", "list"]
|
verbs: ["get", "list"]
|
||||||
- apiGroups: [""]
|
|
||||||
resources: ["pods/log"]
|
|
||||||
verbs: ["get"]
|
|
||||||
- apiGroups: ["apps"]
|
- apiGroups: ["apps"]
|
||||||
resources: ["deployments"]
|
resources: ["deployments"]
|
||||||
verbs: ["get", "list", "update", "patch"]
|
verbs: ["get", "list", "update", "patch"]
|
||||||
@@ -55,7 +52,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.45
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 8090
|
- containerPort: 8090
|
||||||
@@ -149,8 +146,13 @@ spec:
|
|||||||
name: fission-console
|
name: fission-console
|
||||||
port:
|
port:
|
||||||
number: 8090
|
number: 8090
|
||||||
# ⛔ /cron НЕ ПРИНАДЛЕЖИТ console. Ingress /cron → metrics-collector:8091 (metrics-collector/deploy/metrics-collector.yaml)
|
- path: /cron
|
||||||
# ⛔ НЕ ДОБАВЛЯТЬ /cron сюда — это сломает независимость сервисов.
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: fission-console
|
||||||
|
port:
|
||||||
|
number: 8090
|
||||||
tls:
|
tls:
|
||||||
- hosts:
|
- hosts:
|
||||||
- fission.kube5s.ru
|
- fission.kube5s.ru
|
||||||
|
|||||||
+1
-4
@@ -3,15 +3,12 @@ module fission-console
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
golang.org/x/net v0.47.0
|
|
||||||
k8s.io/api v0.34.1
|
|
||||||
k8s.io/apimachinery v0.34.1
|
k8s.io/apimachinery v0.34.1
|
||||||
k8s.io/client-go v0.34.1
|
k8s.io/client-go v0.34.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/emicklei/go-restful/v3 v3.12.2 // indirect
|
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||||
@@ -19,7 +16,6 @@ require (
|
|||||||
github.com/go-openapi/swag v0.23.0 // indirect
|
github.com/go-openapi/swag v0.23.0 // indirect
|
||||||
github.com/gogo/protobuf v1.3.2 // indirect
|
github.com/gogo/protobuf v1.3.2 // indirect
|
||||||
github.com/google/gnostic-models v0.7.0 // indirect
|
github.com/google/gnostic-models v0.7.0 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
|
||||||
github.com/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
|
||||||
@@ -31,6 +27,7 @@ require (
|
|||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
|
golang.org/x/net v0.47.0 // indirect
|
||||||
golang.org/x/oauth2 v0.30.0 // indirect
|
golang.org/x/oauth2 v0.30.0 // indirect
|
||||||
golang.org/x/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
|
||||||
|
|||||||
@@ -194,136 +194,7 @@ func buildExplainUserPrompt(lang, code string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildChatSystemPrompt() string {
|
func buildChatSystemPrompt() string {
|
||||||
return strings.TrimSpace(`Ты ассистент платформы NUBES Fission Console — веб-интерфейса для запуска serverless-функций.
|
return "Ты краткий и полезный ассистент для Fission Console. Отвечай по делу и без лишней болтовни."
|
||||||
Отвечай кратко, по делу, понятным языком без технического жаргона.
|
|
||||||
|
|
||||||
## Что такое Fission Console
|
|
||||||
Веб-интерфейс по адресу https://fission.kube5s.ru/console/
|
|
||||||
Позволяет создавать, редактировать, вызывать и удалять функции без знания инфраструктуры.
|
|
||||||
|
|
||||||
## Вход в систему
|
|
||||||
- Токен из Личного кабинета (Профиль → Токены) — рекомендуется
|
|
||||||
- Демо-логин — любая строка не менее 6 символов. Один и тот же логин всегда даёт одно и то же окружение (namespace).
|
|
||||||
|
|
||||||
## Кнопки верхней панели
|
|
||||||
- **Refresh** — обновить список функций
|
|
||||||
- **✏️ Из кода** — создать новую функцию, написав код прямо в браузере
|
|
||||||
- **📦 Из архива** — создать новую функцию из zip-архива с файлами
|
|
||||||
- **Help** — справка по консоли
|
|
||||||
- **Выход** — выйти из системы
|
|
||||||
|
|
||||||
## Таблица функций — что означают столбцы
|
|
||||||
- **Имя** — уникальное имя функции
|
|
||||||
- **Окружение** — язык выполнения (python, nodejs, go, php, ruby и др.)
|
|
||||||
- **Пакет** — имя внутреннего объекта с кодом функции (технический, менять не нужно)
|
|
||||||
- **Создана / Изменена** — дата и время создания и последнего изменения
|
|
||||||
- **Маршрут** — HTTP-путь через который вызывается функция (например /my-fn)
|
|
||||||
- **Методы** — разрешённые HTTP методы (GET, POST и др.)
|
|
||||||
- **Cron** — расписание автозапуска (например ` + "`*/5 * * * *`" + ` = каждые 5 минут). Если пусто — cron не задан.
|
|
||||||
- **Действия** — кнопки управления функцией (см. ниже)
|
|
||||||
|
|
||||||
## Кнопки действий в строке функции
|
|
||||||
- **Ред.** — открыть редактор: изменить код, timeout, cron
|
|
||||||
- **Вызов** — вызвать функцию прямо из браузера, увидеть ответ и время выполнения
|
|
||||||
- **Логи** — посмотреть вывод функции (то что она пишет через print/console.log/log.Printf и т.д.)
|
|
||||||
- **Удалить** — удалить функцию (необратимо)
|
|
||||||
|
|
||||||
## Создание функции "Из кода"
|
|
||||||
Поля формы:
|
|
||||||
- **Name** — имя функции (строчные буквы, цифры, дефис; напр. my-func)
|
|
||||||
- **Language** — язык (Python, Node.js, Go, PHP, Ruby)
|
|
||||||
- **Entrypoint** — точка входа. По умолчанию подставляется автоматически. Для Python: main.main, для Node.js: main, для Go: main, для PHP: main.php::handler, для Ruby: handler
|
|
||||||
- **Route** — URL-путь (напр. /my-func). Именно по этому пути функция доступна снаружи
|
|
||||||
- **Методы** — HTTP методы через запятую (GET, POST, GET,POST и т.д.)
|
|
||||||
- **Timeout** — максимальное время выполнения в секундах
|
|
||||||
- **Cron** — расписание в формате cron (необязательно). Включается переключателем.
|
|
||||||
- **Код** — редактор кода прямо в браузере
|
|
||||||
|
|
||||||
Кнопки в редакторе кода:
|
|
||||||
- **🔍 Проверить синтаксис линтером** — проверяет код на синтаксические ошибки
|
|
||||||
- **✨ Сгенерировать код LLM** — описать задачу текстом, получить готовый код
|
|
||||||
- **📖 LLM: Что делает?** — объяснение что делает текущий код
|
|
||||||
|
|
||||||
## Создание функции "Из архива"
|
|
||||||
Аналогично "Из кода", но вместо редактора кода — загрузка zip-файла.
|
|
||||||
- **🔍 Проверка архива линтером** — проверяет архив на ошибки и совместимость
|
|
||||||
- **📖 LLM: Что делает?** — объяснение содержимого архива
|
|
||||||
|
|
||||||
## Редактирование функции (кнопка Ред.)
|
|
||||||
- **Name, Environment, Entrypoint** — только для просмотра, не редактируются
|
|
||||||
- **Timeout** — можно изменить
|
|
||||||
- **Cron** — можно включить/выключить и изменить расписание
|
|
||||||
- **Код** — редактируется (для функций созданных из кода)
|
|
||||||
- **Заменить архив** — загрузить новый zip (для функций из архива)
|
|
||||||
|
|
||||||
## Вызов функции (кнопка Вызов)
|
|
||||||
Открывается модальное окно. Можно указать тело запроса (JSON) и нажать "Вызвать".
|
|
||||||
Показывается ответ функции, HTTP статус и время выполнения.
|
|
||||||
Если функция долго не отвечает — это нормально при первом вызове (cold start, прогрев 5-15 секунд).
|
|
||||||
|
|
||||||
## Логи (кнопка Логи)
|
|
||||||
Показывает вывод функции: всё что она пишет через print(), console.log(), fmt.Println() и т.д.
|
|
||||||
Кнопка "Обновить" — перезагрузить логи.
|
|
||||||
Логи видны пока функция активна (обрабатывает запросы или работает по cron).
|
|
||||||
|
|
||||||
## Вызов функции снаружи (через API)
|
|
||||||
Функции доступны по адресу: https://fission.kube5s.ru/fn/ВАШ_ROUTE
|
|
||||||
Нужен заголовок: Authorization: Bearer ВАШ_ТОКЕН
|
|
||||||
|
|
||||||
Пример curl:
|
|
||||||
curl -skL -X POST https://fission.kube5s.ru/fn/ВАШ_ROUTE \
|
|
||||||
-H "Authorization: Bearer $TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"key":"value"}'
|
|
||||||
|
|
||||||
Пример Python:
|
|
||||||
import requests
|
|
||||||
response = requests.post(
|
|
||||||
"https://fission.kube5s.ru/fn/ВАШ_ROUTE",
|
|
||||||
headers={"Authorization": "Bearer ВАШ_ТОКЕН"},
|
|
||||||
json={"key": "value"}, timeout=60
|
|
||||||
)
|
|
||||||
|
|
||||||
## Форматы функций по языкам
|
|
||||||
Python (entrypoint: main.main):
|
|
||||||
def main():
|
|
||||||
return "Hello"
|
|
||||||
|
|
||||||
Node.js (entrypoint: main):
|
|
||||||
module.exports = async function(context) {
|
|
||||||
return { status: 200, body: "Hello" }
|
|
||||||
}
|
|
||||||
|
|
||||||
Go (entrypoint: main):
|
|
||||||
func Handler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Write([]byte("Hello"))
|
|
||||||
}
|
|
||||||
|
|
||||||
PHP (entrypoint: main.php::handler):
|
|
||||||
function handler($context) {
|
|
||||||
return ["status" => 200, "body" => "Hello"];
|
|
||||||
}
|
|
||||||
|
|
||||||
Ruby (entrypoint: handler):
|
|
||||||
def handler(context)
|
|
||||||
"Hello"
|
|
||||||
end
|
|
||||||
|
|
||||||
## Cron расписание — формат
|
|
||||||
Формат: минута час день_месяца месяц день_недели
|
|
||||||
Примеры:
|
|
||||||
- ` + "`*/5 * * * *`" + ` — каждые 5 минут
|
|
||||||
- ` + "`0 * * * *`" + ` — каждый час
|
|
||||||
- ` + "`0 9 * * 1-5`" + ` — в 9:00 по будням
|
|
||||||
- ` + "`@hourly`" + `, ` + "`@daily`" + `, ` + "`@weekly`" + ` — стандартные псевдонимы
|
|
||||||
|
|
||||||
## ESC и закрытие окон
|
|
||||||
Нажатие ESC закрывает любое открытое окно (модалку).
|
|
||||||
|
|
||||||
## Чем НЕ занимаешься
|
|
||||||
- Не рассказываешь про устройство платформы изнутри
|
|
||||||
- Не помогаешь с инфраструктурой, деплоем, kubernetes
|
|
||||||
- Не отвечаешь на вопросы не связанные с работой в консоли`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func prettyLanguageName(lang string) string {
|
func prettyLanguageName(lang string) string {
|
||||||
|
|||||||
@@ -1,342 +0,0 @@
|
|||||||
// Package api — создание и обновление функций из zip-архива (multipart/form-data).
|
|
||||||
//
|
|
||||||
// Этот файл отвечает за два сценария:
|
|
||||||
// 1. handleCreateFunctionFromArchive — создание новой функции из загруженного .zip файла.
|
|
||||||
// 2. handleUpdateFunctionArchive — обновление существующей функции новым .zip файлом.
|
|
||||||
//
|
|
||||||
// Архив загружается пользователем через форму с полем "archive".
|
|
||||||
// Содержимое архива передаётся в storagesvc → S3 без модификации.
|
|
||||||
// В отличие от function_code.go, здесь нет трансформации кода — архив идёт как есть.
|
|
||||||
//
|
|
||||||
// Связанная аннотация: fission-console/source-type = "archive"
|
|
||||||
// позволяет UI определить режим редактирования при открытии функции.
|
|
||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"fission-console/internal/fission"
|
|
||||||
"fission-console/internal/runtime"
|
|
||||||
|
|
||||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
||||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
||||||
)
|
|
||||||
|
|
||||||
// handleCreateFunctionFromArchive создаёт функцию из загруженного zip-архива (multipart/form-data).
|
|
||||||
// Поля формы: name, language (или environment), entrypoint, route, methods, timeout, ttl.
|
|
||||||
// Файловое поле: archive (.zip).
|
|
||||||
func (s *Server) handleCreateFunctionFromArchive(w http.ResponseWriter, r *http.Request, ns string) {
|
|
||||||
if err := r.ParseMultipartForm(maxArchiveUploadSize); err != nil {
|
|
||||||
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("parse multipart form: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
name := strings.TrimSpace(r.FormValue("name"))
|
|
||||||
if name == "" || (!validFuncName.MatchString(name) || len(name) > 57) {
|
|
||||||
writeJSONError(w, http.StatusBadRequest, "invalid function name: must match ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ and be <= 57 chars")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
lang := strings.TrimSpace(r.FormValue("language"))
|
|
||||||
envName := strings.TrimSpace(r.FormValue("environment"))
|
|
||||||
|
|
||||||
f, fhCreate, err := r.FormFile("archive")
|
|
||||||
if err != nil {
|
|
||||||
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("archive file required: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
archiveBytes, err := io.ReadAll(io.LimitReader(f, maxArchiveUploadSize))
|
|
||||||
if err != nil {
|
|
||||||
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("read archive: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
archiveFilenameCreate := ""
|
|
||||||
if fhCreate != nil {
|
|
||||||
archiveFilenameCreate = fhCreate.Filename
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверяем magic bytes: zip должен начинаться с PK (0x50 0x4B)
|
|
||||||
if len(archiveBytes) < 4 || archiveBytes[0] != 0x50 || archiveBytes[1] != 0x4B {
|
|
||||||
writeJSONError(w, http.StatusBadRequest, "загруженный файл не является zip-архивом (ожидается .zip)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
nsCtx, nsCancel := context.WithTimeout(r.Context(), 60*time.Second)
|
|
||||||
defer nsCancel()
|
|
||||||
if err := s.nsManager.EnsureUserNS(nsCtx, ns); err != nil {
|
|
||||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("ensure namespace: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// Определяем environment: по языку или явно
|
|
||||||
if lang != "" {
|
|
||||||
envCtx, envCancel := context.WithTimeout(r.Context(), 15*time.Second)
|
|
||||||
defer envCancel()
|
|
||||||
resolved, envErr := fission.EnsureEnvironment(envCtx, s.dyn, ns, lang)
|
|
||||||
if envErr != nil {
|
|
||||||
if strings.Contains(envErr.Error(), "unsupported language") {
|
|
||||||
writeJSONError(w, http.StatusBadRequest, envErr.Error())
|
|
||||||
} else {
|
|
||||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("ensure environment: %v", envErr))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
envName = resolved
|
|
||||||
}
|
|
||||||
if envName == "" {
|
|
||||||
writeJSONError(w, http.StatusBadRequest, "language or environment is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, err := s.dyn.Resource(fission.EnvironmentGVR).Namespace(ns).Get(ctx, envName, metav1.GetOptions{}); err != nil {
|
|
||||||
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("environment %q not found: %v", envName, err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
entrypoint := strings.TrimSpace(r.FormValue("entrypoint"))
|
|
||||||
if entrypoint == "" {
|
|
||||||
entrypoint = runtime.DefaultEntrypoint(lang)
|
|
||||||
}
|
|
||||||
route := strings.TrimSpace(r.FormValue("route"))
|
|
||||||
if route == "" {
|
|
||||||
nsShort := ns
|
|
||||||
if len(nsShort) > 12 {
|
|
||||||
nsShort = nsShort[len(nsShort)-12:]
|
|
||||||
}
|
|
||||||
route = "/" + nsShort + "/" + name
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(route, "/") {
|
|
||||||
route = "/" + route
|
|
||||||
}
|
|
||||||
methods := normalizeMethods(strings.Split(r.FormValue("methods"), ","))
|
|
||||||
timeout := normalizeFunctionTimeout(0)
|
|
||||||
if tv := r.FormValue("timeout"); tv != "" {
|
|
||||||
if n, err := strconv.ParseInt(tv, 10, 64); err == nil {
|
|
||||||
timeout = normalizeFunctionTimeout(n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Загружаем архив в storagesvc
|
|
||||||
deploySpec, uploadErr := s.buildDeploySpec(ctx, archiveBytes)
|
|
||||||
if uploadErr != nil {
|
|
||||||
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("upload archive: %v", uploadErr))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
pkgName := name + "-pkg"
|
|
||||||
triggerName := name + "-route"
|
|
||||||
|
|
||||||
pkg := &unstructured.Unstructured{Object: map[string]any{
|
|
||||||
"apiVersion": "fission.io/v1",
|
|
||||||
"kind": "Package",
|
|
||||||
"metadata": map[string]any{"name": pkgName, "namespace": ns},
|
|
||||||
"spec": map[string]any{
|
|
||||||
"deployment": deploySpec,
|
|
||||||
"environment": map[string]any{"name": envName, "namespace": ns},
|
|
||||||
"source": map[string]any{},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
|
|
||||||
now := time.Now().UTC()
|
|
||||||
fnAnnotations := map[string]any{
|
|
||||||
"fission-console/language": lang,
|
|
||||||
fissionSourceTypeAnnotation: "archive",
|
|
||||||
functionCreatedAtAnnotation: now.Format(time.RFC3339),
|
|
||||||
functionUpdatedAtAnnotation: now.Format(time.RFC3339),
|
|
||||||
}
|
|
||||||
if archiveFilenameCreate != "" {
|
|
||||||
fnAnnotations["fission-console/archive-filename"] = archiveFilenameCreate
|
|
||||||
}
|
|
||||||
if ttl := r.FormValue("ttl"); ttl != "" {
|
|
||||||
if expiresAt, ttlErr := parseTTL(ttl); ttlErr == nil {
|
|
||||||
fnAnnotations["fission-console/expires-at"] = expiresAt.UTC().Format(time.RFC3339)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
methodValues := make([]any, 0, len(methods))
|
|
||||||
for _, m := range methods {
|
|
||||||
methodValues = append(methodValues, m)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn := &unstructured.Unstructured{Object: map[string]any{
|
|
||||||
"apiVersion": "fission.io/v1",
|
|
||||||
"kind": "Function",
|
|
||||||
"metadata": map[string]any{"name": name, "namespace": ns, "annotations": fnAnnotations},
|
|
||||||
"spec": map[string]any{
|
|
||||||
"environment": map[string]any{"name": envName, "namespace": ns},
|
|
||||||
"package": map[string]any{"packageref": map[string]any{"name": pkgName, "namespace": ns}},
|
|
||||||
"InvokeStrategy": map[string]any{
|
|
||||||
"ExecutionStrategy": map[string]any{"ExecutorType": "poolmgr"},
|
|
||||||
"StrategyType": "execution",
|
|
||||||
},
|
|
||||||
"functionTimeout": timeout,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
if entrypoint != "" {
|
|
||||||
_ = unstructured.SetNestedField(fn.Object, entrypoint, "spec", "package", "functionName")
|
|
||||||
}
|
|
||||||
|
|
||||||
trigger := &unstructured.Unstructured{Object: map[string]any{
|
|
||||||
"apiVersion": "fission.io/v1",
|
|
||||||
"kind": "HTTPTrigger",
|
|
||||||
"metadata": map[string]any{"name": triggerName, "namespace": ns},
|
|
||||||
"spec": map[string]any{
|
|
||||||
"functionref": map[string]any{"name": name, "type": "name"},
|
|
||||||
"relativeurl": route,
|
|
||||||
"methods": methodValues,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
|
|
||||||
if _, err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Create(ctx, pkg, metav1.CreateOptions{}); err != nil {
|
|
||||||
if apierrors.IsAlreadyExists(err) {
|
|
||||||
writeJSONError(w, http.StatusConflict, fmt.Sprintf("function %q already exists", name))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create package: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Create(ctx, fn, metav1.CreateOptions{}); err != nil {
|
|
||||||
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, pkgName, metav1.DeleteOptions{})
|
|
||||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create function: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).Create(ctx, trigger, metav1.CreateOptions{}); err != nil {
|
|
||||||
_ = s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Delete(ctx, name, metav1.DeleteOptions{})
|
|
||||||
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, pkgName, metav1.DeleteOptions{})
|
|
||||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create trigger: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeAnyJSON(w, http.StatusCreated, map[string]any{
|
|
||||||
"name": name,
|
|
||||||
"namespace": ns,
|
|
||||||
"environment": envName,
|
|
||||||
"route": route,
|
|
||||||
"source_type": "archive",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleUpdateFunctionArchive обновляет функцию из загруженного zip-архива (multipart/form-data).
|
|
||||||
// Поля формы: timeout (optional), entrypoint (optional). Файловое поле: archive (.zip).
|
|
||||||
// Создаёт новый Package (новое имя) — чтобы executor сбросил кэш function service.
|
|
||||||
func (s *Server) handleUpdateFunctionArchive(w http.ResponseWriter, r *http.Request, name string) {
|
|
||||||
if err := r.ParseMultipartForm(maxArchiveUploadSize); err != nil {
|
|
||||||
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("parse multipart form: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
f, _, err := r.FormFile("archive")
|
|
||||||
if err != nil {
|
|
||||||
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("archive file required: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
archiveBytes, err := io.ReadAll(io.LimitReader(f, maxArchiveUploadSize))
|
|
||||||
if err != nil {
|
|
||||||
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("read archive: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
ns := s.userNS(r)
|
|
||||||
|
|
||||||
fn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
|
|
||||||
if err != nil {
|
|
||||||
status := http.StatusBadGateway
|
|
||||||
if apierrors.IsNotFound(err) {
|
|
||||||
status = http.StatusNotFound
|
|
||||||
}
|
|
||||||
writeJSONError(w, status, fmt.Sprintf("get function %q: %v", name, err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
oldPkgName, _, _ := unstructured.NestedString(fn.Object, "spec", "package", "packageref", "name")
|
|
||||||
envName, _, _ := unstructured.NestedString(fn.Object, "spec", "environment", "name")
|
|
||||||
|
|
||||||
newPkgName := name + "-pkg-" + strconv.FormatInt(time.Now().UnixMilli(), 36)
|
|
||||||
deploySpec, uploadErr := s.buildDeploySpec(ctx, archiveBytes)
|
|
||||||
if uploadErr != nil {
|
|
||||||
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("upload archive: %v", uploadErr))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
newPkg := &unstructured.Unstructured{Object: map[string]any{
|
|
||||||
"apiVersion": "fission.io/v1",
|
|
||||||
"kind": "Package",
|
|
||||||
"metadata": map[string]any{"name": newPkgName, "namespace": ns},
|
|
||||||
"spec": map[string]any{
|
|
||||||
"deployment": deploySpec,
|
|
||||||
"environment": map[string]any{"name": envName, "namespace": ns},
|
|
||||||
"source": map[string]any{},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
|
|
||||||
createdPkg, err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Create(ctx, newPkg, metav1.CreateOptions{})
|
|
||||||
if err != nil {
|
|
||||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create new package: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обновляем timeout если задан
|
|
||||||
timeout := normalizeFunctionTimeout(0)
|
|
||||||
if tv := r.FormValue("timeout"); tv != "" {
|
|
||||||
if n, err := strconv.ParseInt(tv, 10, 64); err == nil {
|
|
||||||
timeout = normalizeFunctionTimeout(n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ = unstructured.SetNestedField(fn.Object, timeout, "spec", "functionTimeout")
|
|
||||||
|
|
||||||
// Обновляем entrypoint если передан
|
|
||||||
if ep := strings.TrimSpace(r.FormValue("entrypoint")); ep != "" {
|
|
||||||
_ = unstructured.SetNestedField(fn.Object, ep, "spec", "package", "functionName")
|
|
||||||
}
|
|
||||||
|
|
||||||
fnAnnotations := fn.GetAnnotations()
|
|
||||||
if fnAnnotations == nil {
|
|
||||||
fnAnnotations = map[string]string{}
|
|
||||||
}
|
|
||||||
fnAnnotations[fissionSourceTypeAnnotation] = "archive"
|
|
||||||
fnAnnotations[functionUpdatedAtAnnotation] = time.Now().UTC().Format(time.RFC3339)
|
|
||||||
if fh, fhErr := r.MultipartForm.File["archive"]; fhErr == false || len(fh) > 0 {
|
|
||||||
if files := r.MultipartForm.File["archive"]; len(files) > 0 && files[0].Filename != "" {
|
|
||||||
fnAnnotations["fission-console/archive-filename"] = files[0].Filename
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn.SetAnnotations(fnAnnotations)
|
|
||||||
|
|
||||||
if err := unstructured.SetNestedField(fn.Object, map[string]any{
|
|
||||||
"name": newPkgName,
|
|
||||||
"namespace": ns,
|
|
||||||
"resourceversion": createdPkg.GetResourceVersion(),
|
|
||||||
}, "spec", "package", "packageref"); err != nil {
|
|
||||||
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, newPkgName, metav1.DeleteOptions{})
|
|
||||||
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("set packageref: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Update(ctx, fn, metav1.UpdateOptions{}); err != nil {
|
|
||||||
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, newPkgName, metav1.DeleteOptions{})
|
|
||||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("update function: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if oldPkgName != "" && oldPkgName != newPkgName {
|
|
||||||
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, oldPkgName, metav1.DeleteOptions{})
|
|
||||||
}
|
|
||||||
|
|
||||||
writeAnyJSON(w, http.StatusOK, map[string]any{
|
|
||||||
"updated": true,
|
|
||||||
"package": newPkgName,
|
|
||||||
"source_type": "archive",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,410 +0,0 @@
|
|||||||
// Package api — создание и обновление функций из исходного кода (inline code).
|
|
||||||
//
|
|
||||||
// Этот файл отвечает за два сценария:
|
|
||||||
// 1. handleCreateFunction — создание новой функции из кода (JSON body).
|
|
||||||
// 2. handleUpdateFunctionCode — обновление существующей функции: новый код → новый Package.
|
|
||||||
//
|
|
||||||
// Поддерживаемые языки: python, nodejs, php, ruby, go.
|
|
||||||
// Для Go создаётся source package (builder компилирует .so плагин).
|
|
||||||
// Для остальных языков — deployment archive (zip загружается в storagesvc или как literal).
|
|
||||||
//
|
|
||||||
// Почему новый Package при обновлении:
|
|
||||||
// Fission executor кэширует function service по functionUid и не видит изменений
|
|
||||||
// в существующем Package. Новое имя пакета гарантирует cache miss в executor.
|
|
||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"fission-console/internal/fission"
|
|
||||||
"fission-console/internal/model"
|
|
||||||
"fission-console/internal/runtime"
|
|
||||||
|
|
||||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
||||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
||||||
)
|
|
||||||
|
|
||||||
// buildDeployArchive упаковывает исходный код в байты для deployment Package.
|
|
||||||
// Для nodejs — ESM-обёртка (package.json + main.js).
|
|
||||||
// Для php/ruby — zip с одним файлом скрипта.
|
|
||||||
// Для остальных (python) — raw bytes кода.
|
|
||||||
func buildDeployArchive(lang, code string) ([]byte, error) {
|
|
||||||
switch lang {
|
|
||||||
case "nodejs":
|
|
||||||
return runtime.BuildJSDeployZip(code)
|
|
||||||
case "php":
|
|
||||||
return runtime.BuildScriptZip(code, "main.php")
|
|
||||||
case "ruby":
|
|
||||||
return runtime.BuildScriptZip(code, "handler.rb")
|
|
||||||
default:
|
|
||||||
return []byte(code), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleCreateFunction создаёт новую функцию: Package + Function + HTTPTrigger.
|
|
||||||
//
|
|
||||||
// Порядок создания: Package → Function → HTTPTrigger.
|
|
||||||
// При ошибке на любом шаге откатываем уже созданные объекты (best-effort).
|
|
||||||
// TTL парсится ДО создания объектов — невалидный TTL не оставляет мусор.
|
|
||||||
func (s *Server) handleCreateFunction(w http.ResponseWriter, r *http.Request) {
|
|
||||||
ns := s.userNS(r)
|
|
||||||
|
|
||||||
// Поддерживаем два формата: JSON (код) и multipart/form-data (архив).
|
|
||||||
isArchiveUpload := strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data")
|
|
||||||
if isArchiveUpload {
|
|
||||||
s.handleCreateFunctionFromArchive(w, r, ns)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req model.CreateFunctionRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("decode request: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Гарантируем namespace — на случай прямого вызова API без handleAuth
|
|
||||||
nsCtx, nsCancel := context.WithTimeout(r.Context(), 60*time.Second)
|
|
||||||
defer nsCancel()
|
|
||||||
if err := s.nsManager.EnsureUserNS(nsCtx, ns); err != nil {
|
|
||||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("ensure namespace: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Name = strings.TrimSpace(req.Name)
|
|
||||||
req.Language = strings.TrimSpace(req.Language)
|
|
||||||
req.Environment = strings.TrimSpace(req.Environment)
|
|
||||||
req.Code = strings.TrimSpace(req.Code)
|
|
||||||
req.Entrypoint = strings.TrimSpace(req.Entrypoint)
|
|
||||||
req.Route = strings.TrimSpace(req.Route)
|
|
||||||
|
|
||||||
// Валидация имени
|
|
||||||
if req.Name != "" && (!validFuncName.MatchString(req.Name) || len(req.Name) > 57) {
|
|
||||||
writeJSONError(w, http.StatusBadRequest, "invalid function name: must match ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ and be <= 57 chars")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(req.Code) > maxCodeSize {
|
|
||||||
writeJSONError(w, http.StatusBadRequest, "code exceeds 1MB limit")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lazy создание Environment по языку (если язык указан явно)
|
|
||||||
if req.Language != "" {
|
|
||||||
envCtx, envCancel := context.WithTimeout(r.Context(), 15*time.Second)
|
|
||||||
defer envCancel()
|
|
||||||
envName, err := fission.EnsureEnvironment(envCtx, s.dyn, ns, req.Language)
|
|
||||||
if err != nil {
|
|
||||||
if strings.Contains(err.Error(), "unsupported language") {
|
|
||||||
writeJSONError(w, http.StatusBadRequest, err.Error())
|
|
||||||
} else {
|
|
||||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("ensure environment: %v", err))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
req.Environment = envName
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Name == "" || req.Environment == "" || req.Code == "" {
|
|
||||||
writeJSONError(w, http.StatusBadRequest, "name, environment/language and code are required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.Entrypoint == "" {
|
|
||||||
req.Entrypoint = runtime.DefaultEntrypoint(req.Language)
|
|
||||||
}
|
|
||||||
if req.Route == "" {
|
|
||||||
// Namespace-prefix route: избегаем коллизий между пользователями
|
|
||||||
// (разные пользователи могут создать функцию с одинаковым именем)
|
|
||||||
nsShort := ns
|
|
||||||
if len(nsShort) > 12 {
|
|
||||||
nsShort = nsShort[len(nsShort)-12:]
|
|
||||||
}
|
|
||||||
req.Route = "/" + nsShort + "/" + req.Name
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(req.Route, "/") {
|
|
||||||
req.Route = "/" + req.Route
|
|
||||||
}
|
|
||||||
req.Methods = normalizeMethods(req.Methods)
|
|
||||||
req.Timeout = normalizeFunctionTimeout(req.Timeout)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// Проверяем что environment существует (мог быть задан явно без language)
|
|
||||||
if _, err := s.dyn.Resource(fission.EnvironmentGVR).Namespace(ns).Get(ctx, req.Environment, metav1.GetOptions{}); err != nil {
|
|
||||||
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("environment %q not found: %v", req.Environment, err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
pkgName := req.Name + "-pkg"
|
|
||||||
triggerName := req.Name + "-route"
|
|
||||||
|
|
||||||
methodValues := make([]any, 0, len(req.Methods))
|
|
||||||
for _, method := range req.Methods {
|
|
||||||
methodValues = append(methodValues, method)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Строим Package spec в зависимости от языка:
|
|
||||||
// - Go: source package → builder job компилирует в .so плагин
|
|
||||||
// - Node.js: deployment zip с ESM wrapper (package.json + main.js)
|
|
||||||
// - Остальные: deployment archive с кодом (S3 или literal fallback)
|
|
||||||
var pkgSpec map[string]any
|
|
||||||
if req.Language == "go" {
|
|
||||||
srcZip, err := runtime.BuildGoSourceZip(req.Code)
|
|
||||||
if err != nil {
|
|
||||||
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("build go source archive: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
srcSpec, srcErr := s.buildDeploySpec(ctx, srcZip)
|
|
||||||
if srcErr != nil {
|
|
||||||
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("upload go source: %v", srcErr))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pkgSpec = map[string]any{
|
|
||||||
"source": srcSpec,
|
|
||||||
"deployment": map[string]any{},
|
|
||||||
"environment": map[string]any{"name": req.Environment, "namespace": ns},
|
|
||||||
"buildcommand": "build",
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
deployBytes, archiveErr := buildDeployArchive(req.Language, req.Code)
|
|
||||||
if archiveErr != nil {
|
|
||||||
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build %s archive: %v", req.Language, archiveErr))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
deploySpec, uploadErr := s.buildDeploySpec(ctx, deployBytes)
|
|
||||||
if uploadErr != nil {
|
|
||||||
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("upload %s archive: %v", req.Language, uploadErr))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pkgSpec = map[string]any{
|
|
||||||
"deployment": deploySpec,
|
|
||||||
"environment": map[string]any{"name": req.Environment, "namespace": ns},
|
|
||||||
"source": map[string]any{},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pkg := &unstructured.Unstructured{Object: map[string]any{
|
|
||||||
"apiVersion": "fission.io/v1",
|
|
||||||
"kind": "Package",
|
|
||||||
"metadata": map[string]any{"name": pkgName, "namespace": ns},
|
|
||||||
"spec": pkgSpec,
|
|
||||||
}}
|
|
||||||
|
|
||||||
// Парсим TTL ДО создания K8s ресурсов — невалидный TTL не оставляет мусор
|
|
||||||
fnAnnotations := map[string]any{
|
|
||||||
"fission-console/language": req.Language,
|
|
||||||
fissionSourceTypeAnnotation: "code",
|
|
||||||
}
|
|
||||||
now := time.Now().UTC()
|
|
||||||
fnAnnotations[functionCreatedAtAnnotation] = now.Format(time.RFC3339)
|
|
||||||
fnAnnotations[functionUpdatedAtAnnotation] = now.Format(time.RFC3339)
|
|
||||||
if req.TTL != "" {
|
|
||||||
expiresAt, ttlErr := parseTTL(req.TTL)
|
|
||||||
if ttlErr != nil {
|
|
||||||
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("invalid ttl %q: %v", req.TTL, ttlErr))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fnAnnotations["fission-console/expires-at"] = expiresAt.UTC().Format(time.RFC3339)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Create(ctx, pkg, metav1.CreateOptions{}); err != nil {
|
|
||||||
if apierrors.IsAlreadyExists(err) {
|
|
||||||
writeJSONError(w, http.StatusConflict, fmt.Sprintf("function %q already exists", req.Name))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if apierrors.IsInvalid(err) {
|
|
||||||
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("invalid function spec: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create package: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fn := &unstructured.Unstructured{Object: map[string]any{
|
|
||||||
"apiVersion": "fission.io/v1",
|
|
||||||
"kind": "Function",
|
|
||||||
"metadata": map[string]any{"name": req.Name, "namespace": ns, "annotations": fnAnnotations},
|
|
||||||
"spec": map[string]any{
|
|
||||||
"environment": map[string]any{"name": req.Environment, "namespace": ns},
|
|
||||||
"functionTimeout": req.Timeout,
|
|
||||||
"InvokeStrategy": map[string]any{
|
|
||||||
"ExecutionStrategy": map[string]any{"ExecutorType": "poolmgr"},
|
|
||||||
"StrategyType": "execution",
|
|
||||||
},
|
|
||||||
"package": map[string]any{
|
|
||||||
"packageref": map[string]any{"name": pkgName, "namespace": ns},
|
|
||||||
"functionName": req.Entrypoint,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
|
|
||||||
if _, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Create(ctx, fn, metav1.CreateOptions{}); err != nil {
|
|
||||||
// Откатываем Package если Function не создалась
|
|
||||||
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, pkgName, metav1.DeleteOptions{})
|
|
||||||
if apierrors.IsAlreadyExists(err) {
|
|
||||||
writeJSONError(w, http.StatusConflict, fmt.Sprintf("function %q already exists", req.Name))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create function: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
httpTrigger := &unstructured.Unstructured{Object: map[string]any{
|
|
||||||
"apiVersion": "fission.io/v1",
|
|
||||||
"kind": "HTTPTrigger",
|
|
||||||
"metadata": map[string]any{"name": triggerName, "namespace": ns},
|
|
||||||
"spec": map[string]any{
|
|
||||||
"relativeurl": req.Route,
|
|
||||||
"methods": methodValues,
|
|
||||||
"createingress": true,
|
|
||||||
"functionref": map[string]any{"type": "name", "name": req.Name},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
|
|
||||||
if _, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).Create(ctx, httpTrigger, metav1.CreateOptions{}); err != nil {
|
|
||||||
// Откатываем Function и Package
|
|
||||||
_ = s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Delete(ctx, req.Name, metav1.DeleteOptions{})
|
|
||||||
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, pkgName, metav1.DeleteOptions{})
|
|
||||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create httptrigger: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeAnyJSON(w, http.StatusCreated, map[string]any{
|
|
||||||
"name": req.Name,
|
|
||||||
"package": pkgName,
|
|
||||||
"httptrigger": triggerName,
|
|
||||||
"route": req.Route,
|
|
||||||
"expires_at": fnAnnotations["fission-console/expires-at"],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleUpdateFunctionCode обновляет код уже существующей функции.
|
|
||||||
// Создаёт НОВЫЙ Package (вместо обновления старого) чтобы executor сбросил кэш:
|
|
||||||
// executor кэширует function service по functionUid и не видит изменений в том же Package.
|
|
||||||
// Новое имя пакета гарантирует cache miss в executor.
|
|
||||||
func (s *Server) handleUpdateFunctionCode(w http.ResponseWriter, r *http.Request, name string) {
|
|
||||||
var req model.UpdateCodeRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("decode request: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
req.Code = strings.TrimSpace(req.Code)
|
|
||||||
if req.Code == "" {
|
|
||||||
writeJSONError(w, http.StatusBadRequest, "code is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
ns := s.userNS(r)
|
|
||||||
|
|
||||||
fn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
|
|
||||||
if err != nil {
|
|
||||||
status := http.StatusBadGateway
|
|
||||||
if apierrors.IsNotFound(err) {
|
|
||||||
status = http.StatusNotFound
|
|
||||||
}
|
|
||||||
writeJSONError(w, status, fmt.Sprintf("get function %q: %v", name, err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
oldPkgName, _, _ := unstructured.NestedString(fn.Object, "spec", "package", "packageref", "name")
|
|
||||||
|
|
||||||
// Определяем язык из аннотации — нужен для правильной упаковки
|
|
||||||
lang, _, _ := unstructured.NestedString(fn.Object, "metadata", "annotations", "fission-console/language")
|
|
||||||
deployBytes, archiveErr := buildDeployArchive(lang, req.Code)
|
|
||||||
if archiveErr != nil {
|
|
||||||
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build %s archive: %v", lang, archiveErr))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Создаём новый Package с уникальным именем.
|
|
||||||
// Это единственный способ сбросить кэш executor: он кэширует по functionUid и
|
|
||||||
// не замечает изменений в существующем Package.
|
|
||||||
envName, _, _ := unstructured.NestedString(fn.Object, "spec", "environment", "name")
|
|
||||||
createdAt := func() time.Time {
|
|
||||||
ann := fn.GetAnnotations()
|
|
||||||
if ann != nil {
|
|
||||||
if v := strings.TrimSpace(ann[functionCreatedAtAnnotation]); v != "" {
|
|
||||||
if ts, err := parseRFC3339(v); err == nil {
|
|
||||||
return ts.UTC()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ts := fn.GetCreationTimestamp(); !ts.IsZero() {
|
|
||||||
return ts.UTC()
|
|
||||||
}
|
|
||||||
return time.Time{}
|
|
||||||
}()
|
|
||||||
now := time.Now().UTC()
|
|
||||||
newPkgName := name + "-pkg-" + strconv.FormatInt(time.Now().UnixMilli(), 36)
|
|
||||||
deploySpec, uploadErr := s.buildDeploySpec(ctx, deployBytes)
|
|
||||||
if uploadErr != nil {
|
|
||||||
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("upload %s archive: %v", lang, uploadErr))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
newPkg := &unstructured.Unstructured{Object: map[string]any{
|
|
||||||
"apiVersion": "fission.io/v1",
|
|
||||||
"kind": "Package",
|
|
||||||
"metadata": map[string]any{"name": newPkgName, "namespace": ns},
|
|
||||||
"spec": map[string]any{
|
|
||||||
"deployment": deploySpec,
|
|
||||||
"environment": map[string]any{"name": envName, "namespace": ns},
|
|
||||||
"source": map[string]any{},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
|
|
||||||
createdPkg, err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Create(ctx, newPkg, metav1.CreateOptions{})
|
|
||||||
if err != nil {
|
|
||||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create new package: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обновляем Function на новый Package
|
|
||||||
if err := unstructured.SetNestedField(fn.Object, normalizeFunctionTimeout(req.Timeout), "spec", "functionTimeout"); err != nil {
|
|
||||||
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("set function timeout: %v", err))
|
|
||||||
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, newPkgName, metav1.DeleteOptions{})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ensureFunctionTimestamps(fn, now)
|
|
||||||
if createdAt.IsZero() {
|
|
||||||
createdAt = now
|
|
||||||
}
|
|
||||||
fnAnnotations := fn.GetAnnotations()
|
|
||||||
if fnAnnotations == nil {
|
|
||||||
fnAnnotations = map[string]string{}
|
|
||||||
}
|
|
||||||
fnAnnotations[functionCreatedAtAnnotation] = createdAt.UTC().Format(time.RFC3339)
|
|
||||||
fnAnnotations[functionUpdatedAtAnnotation] = now.Format(time.RFC3339)
|
|
||||||
fn.SetAnnotations(fnAnnotations)
|
|
||||||
if err := unstructured.SetNestedField(fn.Object, map[string]any{
|
|
||||||
"name": newPkgName,
|
|
||||||
"namespace": ns,
|
|
||||||
"resourceversion": createdPkg.GetResourceVersion(),
|
|
||||||
}, "spec", "package", "packageref"); err != nil {
|
|
||||||
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("set function packageref: %v", err))
|
|
||||||
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, newPkgName, metav1.DeleteOptions{})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Update(ctx, fn, metav1.UpdateOptions{}); err != nil {
|
|
||||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("update function %q: %v", name, err))
|
|
||||||
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, newPkgName, metav1.DeleteOptions{})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Удаляем старый Package (best effort)
|
|
||||||
if oldPkgName != "" && oldPkgName != newPkgName {
|
|
||||||
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, oldPkgName, metav1.DeleteOptions{})
|
|
||||||
}
|
|
||||||
|
|
||||||
writeAnyJSON(w, http.StatusOK, map[string]any{
|
|
||||||
"updated": true,
|
|
||||||
"package": newPkgName,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,286 +0,0 @@
|
|||||||
// Package api — CRUD операции с функциями: чтение, удаление, обновление таймаута, логи.
|
|
||||||
//
|
|
||||||
// Этот файл содержит операции, не связанные с заменой кода/архива:
|
|
||||||
// - handleGetFunction — GET /functions/:name (детали: код, route, environment, source_type)
|
|
||||||
// - handleDeleteFunction — DELETE /functions/:name (каскадное удаление: триггеры, Package, S3)
|
|
||||||
// - handleUpdateFunctionTimeout — PUT /functions/:name/timeout (только таймаут, без замены кода)
|
|
||||||
// - handleGetFunctionLogs — GET /functions/:name/logs (логи пода через Kubernetes API)
|
|
||||||
//
|
|
||||||
// Операции с кодом и архивом — в function_code.go и function_archive.go соответственно.
|
|
||||||
// Вызов функции — в function_invoke.go.
|
|
||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"fission-console/internal/fission"
|
|
||||||
|
|
||||||
corev1 "k8s.io/api/core/v1"
|
|
||||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
||||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
||||||
)
|
|
||||||
|
|
||||||
// handleGetFunction возвращает детали функции: код, environment, route, methods.
|
|
||||||
func (s *Server) handleGetFunction(w http.ResponseWriter, r *http.Request, name string) {
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
ns := s.userNS(r)
|
|
||||||
|
|
||||||
fn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
|
|
||||||
if err != nil {
|
|
||||||
status := http.StatusBadGateway
|
|
||||||
if apierrors.IsNotFound(err) {
|
|
||||||
status = http.StatusNotFound
|
|
||||||
}
|
|
||||||
writeJSONError(w, status, fmt.Sprintf("get function %q: %v", name, err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
packageName, _, _ := unstructured.NestedString(fn.Object, "spec", "package", "packageref", "name")
|
|
||||||
environment, _, _ := unstructured.NestedString(fn.Object, "spec", "environment", "name")
|
|
||||||
entrypoint, _, _ := unstructured.NestedString(fn.Object, "spec", "package", "functionName")
|
|
||||||
functionTimeout, foundTimeout, _ := unstructured.NestedInt64(fn.Object, "spec", "functionTimeout")
|
|
||||||
if !foundTimeout || functionTimeout <= 0 {
|
|
||||||
functionTimeout = int64(defaultFunctionInvokeTimeout / time.Second)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Извлекаем исходный код из Package (пробуем source.literal, потом deployment.literal, потом url)
|
|
||||||
code := ""
|
|
||||||
if packageName != "" {
|
|
||||||
pkg, pkgErr := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Get(ctx, packageName, metav1.GetOptions{})
|
|
||||||
if pkgErr == nil {
|
|
||||||
code = extractPackageSourceCode(ctx, s, pkg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ищем HTTPTrigger для получения route и methods
|
|
||||||
route := ""
|
|
||||||
methods := []string{}
|
|
||||||
triggers, trigErr := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).List(ctx, metav1.ListOptions{})
|
|
||||||
if trigErr == nil {
|
|
||||||
for _, trig := range triggers.Items {
|
|
||||||
refName, _, _ := unstructured.NestedString(trig.Object, "spec", "functionref", "name")
|
|
||||||
if refName != name {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
route, _, _ = unstructured.NestedString(trig.Object, "spec", "relativeurl")
|
|
||||||
methods, _, _ = unstructured.NestedStringSlice(trig.Object, "spec", "methods")
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Читаем source-type аннотацию (code / archive)
|
|
||||||
sourceType := "code"
|
|
||||||
archiveFilename := ""
|
|
||||||
if ann := fn.GetAnnotations(); ann != nil {
|
|
||||||
if v := ann[fissionSourceTypeAnnotation]; v != "" {
|
|
||||||
sourceType = v
|
|
||||||
}
|
|
||||||
if v := ann["fission-console/archive-filename"]; v != "" {
|
|
||||||
archiveFilename = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
writeAnyJSON(w, http.StatusOK, map[string]any{
|
|
||||||
"name": name,
|
|
||||||
"namespace": ns,
|
|
||||||
"environment": environment,
|
|
||||||
"package": packageName,
|
|
||||||
"entrypoint": entrypoint,
|
|
||||||
"timeout": functionTimeout,
|
|
||||||
"created_at": functionTimestampResponse(fn)["created_at"],
|
|
||||||
"updated_at": functionTimestampResponse(fn)["updated_at"],
|
|
||||||
"code": code,
|
|
||||||
"source_type": sourceType,
|
|
||||||
"archive_filename": archiveFilename,
|
|
||||||
"route": route,
|
|
||||||
"methods": methods,
|
|
||||||
"raw": fn.Object,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleUpdateFunctionTimeout обновляет только spec.functionTimeout функции (без замены кода/архива).
|
|
||||||
func (s *Server) handleUpdateFunctionTimeout(w http.ResponseWriter, r *http.Request, name string) {
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
ns := s.userNS(r)
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
Timeout int64 `json:"timeout"`
|
|
||||||
Entrypoint string `json:"entrypoint"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("decode request: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
timeout := normalizeFunctionTimeout(req.Timeout)
|
|
||||||
|
|
||||||
fn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
|
|
||||||
if err != nil {
|
|
||||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("get function %q: %v", name, err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := unstructured.SetNestedField(fn.Object, timeout, "spec", "functionTimeout"); err != nil {
|
|
||||||
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("set timeout: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.Entrypoint != "" {
|
|
||||||
_ = unstructured.SetNestedField(fn.Object, req.Entrypoint, "spec", "package", "functionName")
|
|
||||||
}
|
|
||||||
now := time.Now().UTC().Format(time.RFC3339)
|
|
||||||
ann := fn.GetAnnotations()
|
|
||||||
if ann == nil {
|
|
||||||
ann = map[string]string{}
|
|
||||||
}
|
|
||||||
ann[functionUpdatedAtAnnotation] = now
|
|
||||||
fn.SetAnnotations(ann)
|
|
||||||
|
|
||||||
if _, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Update(ctx, fn, metav1.UpdateOptions{}); err != nil {
|
|
||||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("update function %q: %v", name, err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeAnyJSON(w, http.StatusOK, map[string]any{"updated": true, "timeout": timeout})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleDeleteFunction удаляет функцию и связанные объекты: HTTPTrigger, TimeTrigger, Package.
|
|
||||||
// После удаления вызывает CleanupEnvironmentIfUnused — убирает environment если язык больше не используется.
|
|
||||||
func (s *Server) handleDeleteFunction(w http.ResponseWriter, r *http.Request, name string) {
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
ns := s.userNS(r)
|
|
||||||
|
|
||||||
// Получаем Function чтобы знать pkgName и envName для cleanup
|
|
||||||
var pkgName, envName string
|
|
||||||
fn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
|
|
||||||
if err != nil {
|
|
||||||
if apierrors.IsNotFound(err) {
|
|
||||||
writeJSONError(w, http.StatusNotFound, fmt.Sprintf("function %q not found", name))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("get function %q: %v", name, err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pkgName, _, _ = unstructured.NestedString(fn.Object, "spec", "package", "packageref", "name")
|
|
||||||
envName, _, _ = unstructured.NestedString(fn.Object, "spec", "environment", "name")
|
|
||||||
|
|
||||||
// Удаляем связанные HTTPTrigger-ы
|
|
||||||
triggers, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).List(ctx, metav1.ListOptions{})
|
|
||||||
if err == nil {
|
|
||||||
for _, trig := range triggers.Items {
|
|
||||||
refName, _, _ := unstructured.NestedString(trig.Object, "spec", "functionref", "name")
|
|
||||||
if refName == name {
|
|
||||||
_ = s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).Delete(ctx, trig.GetName(), metav1.DeleteOptions{})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Удаляем связанные TimeTrigger-ы
|
|
||||||
if triggers, err := s.dyn.Resource(fission.TimeTrigGVR).Namespace(ns).List(ctx, metav1.ListOptions{}); err == nil {
|
|
||||||
for _, trig := range triggers.Items {
|
|
||||||
refName, _, _ := unstructured.NestedString(trig.Object, "spec", "functionref", "name")
|
|
||||||
if refName == name {
|
|
||||||
_ = s.dyn.Resource(fission.TimeTrigGVR).Namespace(ns).Delete(ctx, trig.GetName(), metav1.DeleteOptions{})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Delete(ctx, name, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) {
|
|
||||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("delete function %q: %v", name, err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if pkgName != "" {
|
|
||||||
// Получаем URL архива из Package spec.deployment перед удалением, чтобы потом очистить S3
|
|
||||||
var archiveURL string
|
|
||||||
if pkg, pkgErr := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Get(ctx, pkgName, metav1.GetOptions{}); pkgErr == nil {
|
|
||||||
deployType, _, _ := unstructured.NestedString(pkg.Object, "spec", "deployment", "type")
|
|
||||||
if deployType == "url" {
|
|
||||||
archiveURL, _, _ = unstructured.NestedString(pkg.Object, "spec", "deployment", "url")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, pkgName, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) {
|
|
||||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("delete package %q: %v", pkgName, err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Удаляем архив из S3 после успешного удаления Package (best-effort)
|
|
||||||
if archiveURL != "" {
|
|
||||||
go s.deleteFromStoragesvc(context.Background(), archiveURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Убираем environment pool pods если язык больше не используется (best-effort)
|
|
||||||
if envName != "" {
|
|
||||||
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
||||||
defer cleanupCancel()
|
|
||||||
fission.CleanupEnvironmentIfUnused(cleanupCtx, s.dyn, ns, envName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// (reconciler NS удалён — за FISSION_RESOURCE_NAMESPACES теперь отвечает Layer 1 NSWatcher)
|
|
||||||
|
|
||||||
writeAnyJSON(w, http.StatusOK, map[string]any{"deleted": true, "name": name, "package": pkgName})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleGetFunctionLogs возвращает логи пода функции (последние 100 строк).
|
|
||||||
// Ищет под по лейблу functionName=<name> в namespace пользователя.
|
|
||||||
func (s *Server) handleGetFunctionLogs(w http.ResponseWriter, r *http.Request, name string) {
|
|
||||||
ns := s.userNS(r)
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
if s.kube == nil {
|
|
||||||
writeJSONError(w, http.StatusServiceUnavailable, "kubernetes client not available")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
labelSelector := "functionName=" + name
|
|
||||||
pods, err := s.kube.CoreV1().Pods(ns).List(ctx, metav1.ListOptions{
|
|
||||||
LabelSelector: labelSelector,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
writeJSONError(w, http.StatusInternalServerError, "list pods: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(pods.Items) == 0 {
|
|
||||||
writeAnyJSON(w, http.StatusOK, map[string]any{
|
|
||||||
"logs": "(нет запущенных подов для функции " + name + ")",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var allLogs strings.Builder
|
|
||||||
tailLines := int64(100)
|
|
||||||
for _, pod := range pods.Items {
|
|
||||||
containerName := ""
|
|
||||||
if len(pod.Spec.Containers) > 0 {
|
|
||||||
containerName = pod.Spec.Containers[0].Name
|
|
||||||
}
|
|
||||||
req := s.kube.CoreV1().Pods(ns).GetLogs(pod.Name, &corev1.PodLogOptions{
|
|
||||||
Container: containerName,
|
|
||||||
TailLines: &tailLines,
|
|
||||||
})
|
|
||||||
rc, err := req.Stream(ctx)
|
|
||||||
if err != nil {
|
|
||||||
allLogs.WriteString("[" + pod.Name + ": ошибка чтения логов: " + err.Error() + "]\n")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
data, _ := io.ReadAll(rc)
|
|
||||||
rc.Close()
|
|
||||||
if allLogs.Len() > 0 {
|
|
||||||
allLogs.WriteString("\n--- " + pod.Name + " ---\n")
|
|
||||||
} else {
|
|
||||||
allLogs.WriteString("--- " + pod.Name + " ---\n")
|
|
||||||
}
|
|
||||||
allLogs.Write(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
writeAnyJSON(w, http.StatusOK, map[string]any{
|
|
||||||
"logs": allLogs.String(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,423 +0,0 @@
|
|||||||
// Package api — вызов функций через Fission router.
|
|
||||||
//
|
|
||||||
// Этот файл содержит три способа вызова функций:
|
|
||||||
// - handleInvokeFunction — POST /functions/:name/invoke (через консоль, для тестирования)
|
|
||||||
// - handleInvokeRoute — /fn/<route> (публичный gateway, пользователь вызывает по своему маршруту)
|
|
||||||
// - handleFissionFunctionGateway — /fission-function/<ns>/<name> (внутренний gateway для cron/timer)
|
|
||||||
//
|
|
||||||
// Все три варианта проксируют запрос к Fission router с JWT-токеном router.
|
|
||||||
// Таймаут вызова берётся из spec.functionTimeout функции (или из конфига если не задан).
|
|
||||||
//
|
|
||||||
// Вспомогательные утилиты (buildInternalInvokeURL, copyProxyRequestHeaders и др.) — в этом же файле.
|
|
||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"fission-console/internal/fission"
|
|
||||||
|
|
||||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
||||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
||||||
)
|
|
||||||
|
|
||||||
// resolveInvokeTimeout возвращает таймаут вызова функции.
|
|
||||||
// Приоритет: spec.functionTimeout функции → конфиг сервера → defaultFunctionInvokeTimeout.
|
|
||||||
func (s *Server) resolveInvokeTimeout(fn *unstructured.Unstructured) time.Duration {
|
|
||||||
if fn != nil {
|
|
||||||
seconds, found, err := unstructured.NestedInt64(fn.Object, "spec", "functionTimeout")
|
|
||||||
if err == nil && found && seconds > 0 {
|
|
||||||
return time.Duration(seconds) * time.Second
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if s.invokeTimeout > 0 {
|
|
||||||
return s.invokeTimeout
|
|
||||||
}
|
|
||||||
return defaultFunctionInvokeTimeout
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildInternalInvokeURL строит URL для вызова функции через Fission router.
|
|
||||||
// Для namespace "default" — /fission-function/<name>.
|
|
||||||
// Для остальных — /fission-function/<namespace>/<name>.
|
|
||||||
func buildInternalInvokeURL(routerURL, namespace, functionName string) string {
|
|
||||||
if namespace == "default" || namespace == "" {
|
|
||||||
return fmt.Sprintf("%s/fission-function/%s", routerURL, functionName)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%s/fission-function/%s/%s", routerURL, namespace, functionName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// shouldForwardRequestBody возвращает true если метод подразумевает тело запроса.
|
|
||||||
// GET и HEAD не имеют тела — тело не проксируется.
|
|
||||||
func shouldForwardRequestBody(method string) bool {
|
|
||||||
switch method {
|
|
||||||
case http.MethodGet, http.MethodHead:
|
|
||||||
return false
|
|
||||||
default:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// copyProxyRequestHeaders копирует заголовки из входящего запроса в исходящий.
|
|
||||||
// Пропускаем служебные заголовки чтобы не перезаписать их при проксировании.
|
|
||||||
func copyProxyRequestHeaders(dst, src http.Header) {
|
|
||||||
for key, values := range src {
|
|
||||||
switch http.CanonicalHeaderKey(key) {
|
|
||||||
case "Authorization", "X-Auth-Token", "X-Auth-Env", "Host", "Content-Length":
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, value := range values {
|
|
||||||
dst.Add(key, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// copyProxyResponseHeaders копирует все заголовки из upstream-ответа в ответ клиенту.
|
|
||||||
func copyProxyResponseHeaders(dst, src http.Header) {
|
|
||||||
for key, values := range src {
|
|
||||||
for _, value := range values {
|
|
||||||
dst.Add(key, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// doRequestWithContextTimeout выполняет HTTP-запрос без глобального таймаута клиента.
|
|
||||||
// Реальный лимит задаётся через context — это позволяет функции иметь свой таймаут
|
|
||||||
// независимо от общего HTTP-таймаута console.
|
|
||||||
func doRequestWithContextTimeout(client *http.Client, req *http.Request) (*http.Response, error) {
|
|
||||||
if client == nil {
|
|
||||||
return http.DefaultClient.Do(req)
|
|
||||||
}
|
|
||||||
invokeClient := *client
|
|
||||||
invokeClient.Timeout = 0
|
|
||||||
return invokeClient.Do(req)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleInvokeFunction вызывает функцию через Fission router.
|
|
||||||
// Определяет реальный URL из HTTPTrigger, выбирает метод (POST/GET).
|
|
||||||
func (s *Server) handleInvokeFunction(w http.ResponseWriter, r *http.Request, name string) {
|
|
||||||
bodyBytes, err := io.ReadAll(r.Body)
|
|
||||||
if err != nil {
|
|
||||||
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("read request body: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(bytes.TrimSpace(bodyBytes)) == 0 {
|
|
||||||
bodyBytes = []byte("{}")
|
|
||||||
}
|
|
||||||
|
|
||||||
ns := s.userNS(r)
|
|
||||||
lookupCtx, lookupCancel := context.WithTimeout(r.Context(), 10*time.Second)
|
|
||||||
defer lookupCancel()
|
|
||||||
|
|
||||||
// Проверяем существование функции до вызова — лучше 404 чем непонятный timeout
|
|
||||||
fn, err2 := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(lookupCtx, name, metav1.GetOptions{})
|
|
||||||
if err2 != nil {
|
|
||||||
if apierrors.IsNotFound(err2) {
|
|
||||||
writeJSONError(w, http.StatusNotFound, fmt.Sprintf("function %q not found", name))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("get function %q: %v", name, err2))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
invokeTimeout := s.resolveInvokeTimeout(fn)
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), invokeTimeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// Ищем HTTPTrigger чтобы получить реальный URL и метод
|
|
||||||
invokeURL := buildInternalInvokeURL(s.routerURL, ns, name)
|
|
||||||
invokeMethod := http.MethodPost
|
|
||||||
triggers, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).List(ctx, metav1.ListOptions{})
|
|
||||||
if err == nil {
|
|
||||||
for _, trig := range triggers.Items {
|
|
||||||
refName, _, _ := unstructured.NestedString(trig.Object, "spec", "functionref", "name")
|
|
||||||
if refName != name {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
route, _, _ := unstructured.NestedString(trig.Object, "spec", "relativeurl")
|
|
||||||
methods, _, _ := unstructured.NestedStringSlice(trig.Object, "spec", "methods")
|
|
||||||
hasPost, hasGet := false, false
|
|
||||||
for _, m := range methods {
|
|
||||||
switch strings.ToUpper(strings.TrimSpace(m)) {
|
|
||||||
case http.MethodPost:
|
|
||||||
hasPost = true
|
|
||||||
case http.MethodGet:
|
|
||||||
hasGet = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if route != "" {
|
|
||||||
if !strings.HasPrefix(route, "/") {
|
|
||||||
route = "/" + route
|
|
||||||
}
|
|
||||||
invokeURL = s.routerURL + route
|
|
||||||
// Если функция поддерживает только GET — используем GET
|
|
||||||
if !hasPost && hasGet {
|
|
||||||
invokeMethod = http.MethodGet
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
var invokeBody io.Reader
|
|
||||||
if invokeMethod == http.MethodPost {
|
|
||||||
invokeBody = bytes.NewReader(bodyBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, invokeMethod, invokeURL, invokeBody)
|
|
||||||
if err != nil {
|
|
||||||
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build invoke request: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if invokeMethod == http.MethodPost {
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
}
|
|
||||||
if token := s.getRouterToken(); token != "" {
|
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := doRequestWithContextTimeout(s.http, req)
|
|
||||||
if err != nil {
|
|
||||||
// Отличаем timeout от сетевой ошибки.
|
|
||||||
if errors.Is(err, context.DeadlineExceeded) {
|
|
||||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("invoke %q timeout after %s", name, invokeTimeout))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var netErr net.Error
|
|
||||||
if errors.As(err, &netErr) && netErr.Timeout() {
|
|
||||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("invoke %q timeout after %s", name, invokeTimeout))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("invoke %q: %v", name, err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
respBody, _ := io.ReadAll(resp.Body)
|
|
||||||
writeAnyJSON(w, http.StatusOK, map[string]any{
|
|
||||||
"status": resp.StatusCode,
|
|
||||||
"latency_ms": time.Since(start).Milliseconds(),
|
|
||||||
"response_raw": string(respBody),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleFissionFunctionGateway принимает внутренние invoke-запросы timer/router
|
|
||||||
// и проксирует их через console в upstream router с корректным router JWT.
|
|
||||||
func (s *Server) handleFissionFunctionGateway(w http.ResponseWriter, r *http.Request) {
|
|
||||||
rawPath := strings.Trim(strings.TrimPrefix(r.URL.Path, "/fission-function"), "/")
|
|
||||||
if rawPath == "" {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := strings.Split(rawPath, "/")
|
|
||||||
namespace := s.ns
|
|
||||||
functionName := ""
|
|
||||||
remainingPath := ""
|
|
||||||
|
|
||||||
if len(parts) == 1 {
|
|
||||||
functionName = strings.TrimSpace(parts[0])
|
|
||||||
} else {
|
|
||||||
namespace = strings.TrimSpace(parts[0])
|
|
||||||
functionName = strings.TrimSpace(parts[1])
|
|
||||||
if len(parts) > 2 {
|
|
||||||
remainingPath = "/" + strings.Join(parts[2:], "/")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if namespace == "" || functionName == "" {
|
|
||||||
writeJSONError(w, http.StatusBadRequest, "namespace and function name are required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
s.invokeInternalFunction(w, r, namespace, functionName, remainingPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// invokeInternalFunction проксирует вызов функции к Fission router.
|
|
||||||
// Используется как из handleFissionFunctionGateway (cron/timer), так и из handleInvokeRoute.
|
|
||||||
func (s *Server) invokeInternalFunction(w http.ResponseWriter, r *http.Request, namespace, functionName, extraPath string) {
|
|
||||||
bodyBytes, err := io.ReadAll(r.Body)
|
|
||||||
if err != nil {
|
|
||||||
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("read request body: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(bytes.TrimSpace(bodyBytes)) == 0 {
|
|
||||||
bodyBytes = []byte("{}")
|
|
||||||
}
|
|
||||||
|
|
||||||
lookupCtx, lookupCancel := context.WithTimeout(r.Context(), 10*time.Second)
|
|
||||||
defer lookupCancel()
|
|
||||||
|
|
||||||
fn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(namespace).Get(lookupCtx, functionName, metav1.GetOptions{})
|
|
||||||
if err != nil {
|
|
||||||
if apierrors.IsNotFound(err) {
|
|
||||||
writeJSONError(w, http.StatusNotFound, fmt.Sprintf("function %q not found", functionName))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("get function %q: %v", functionName, err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
invokeTimeout := s.resolveInvokeTimeout(fn)
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), invokeTimeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
invokeURL := buildInternalInvokeURL(s.routerURL, namespace, functionName) + extraPath
|
|
||||||
if r.URL.RawQuery != "" {
|
|
||||||
invokeURL += "?" + r.URL.RawQuery
|
|
||||||
}
|
|
||||||
|
|
||||||
var invokeBody io.Reader
|
|
||||||
if shouldForwardRequestBody(r.Method) {
|
|
||||||
invokeBody = bytes.NewReader(bodyBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, r.Method, invokeURL, invokeBody)
|
|
||||||
if err != nil {
|
|
||||||
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build invoke request: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if shouldForwardRequestBody(r.Method) {
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
}
|
|
||||||
copyProxyRequestHeaders(req.Header, r.Header)
|
|
||||||
if token := s.getRouterToken(); token != "" {
|
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
}
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
resp, err := doRequestWithContextTimeout(s.http, req)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, context.DeadlineExceeded) {
|
|
||||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("invoke %q timeout after %s", functionName, invokeTimeout))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var netErr net.Error
|
|
||||||
if errors.As(err, &netErr) && netErr.Timeout() {
|
|
||||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("invoke %q timeout after %s", functionName, invokeTimeout))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("invoke %q: %v", functionName, err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
respBody, _ := io.ReadAll(resp.Body)
|
|
||||||
writeAnyJSON(w, http.StatusOK, map[string]any{
|
|
||||||
"status": resp.StatusCode,
|
|
||||||
"latency_ms": time.Since(start).Milliseconds(),
|
|
||||||
"response_raw": string(respBody),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleInvokeRoute даёт пользователю прямой HTTP gateway к своей функции по route.
|
|
||||||
// Внешний контракт: /fn/<route> + Authorization: Bearer <user-token>.
|
|
||||||
func (s *Server) handleInvokeRoute(w http.ResponseWriter, r *http.Request) {
|
|
||||||
route := normalizeRoute(strings.TrimPrefix(r.URL.Path, "/fn"))
|
|
||||||
if route == "/" {
|
|
||||||
writeJSONError(w, http.StatusBadRequest, "route is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ns := s.userNS(r)
|
|
||||||
lookupCtx, lookupCancel := context.WithTimeout(r.Context(), 10*time.Second)
|
|
||||||
defer lookupCancel()
|
|
||||||
|
|
||||||
triggers, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).List(lookupCtx, metav1.ListOptions{})
|
|
||||||
if err != nil {
|
|
||||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("list httptriggers: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
matchedFunction := ""
|
|
||||||
allowedMethods := make([]string, 0, 4)
|
|
||||||
for _, trig := range triggers.Items {
|
|
||||||
trigRoute, _, _ := unstructured.NestedString(trig.Object, "spec", "relativeurl")
|
|
||||||
if normalizeRoute(trigRoute) != route {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
methods, _, _ := unstructured.NestedStringSlice(trig.Object, "spec", "methods")
|
|
||||||
allowedMethods = appendUniqueMethods(allowedMethods, methods)
|
|
||||||
if !routeAllowsMethod(methods, r.Method) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
matchedFunction, _, _ = unstructured.NestedString(trig.Object, "spec", "functionref", "name")
|
|
||||||
if matchedFunction != "" {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if matchedFunction == "" {
|
|
||||||
if len(allowedMethods) > 0 {
|
|
||||||
w.Header().Set("Allow", strings.Join(allowedMethods, ", "))
|
|
||||||
writeJSONError(w, http.StatusMethodNotAllowed, fmt.Sprintf("route %q does not allow method %s", route, r.Method))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSONError(w, http.StatusNotFound, fmt.Sprintf("route %q not found", route))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(lookupCtx, matchedFunction, metav1.GetOptions{})
|
|
||||||
if err != nil {
|
|
||||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("get function %q: %v", matchedFunction, err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
bodyBytes, err := io.ReadAll(r.Body)
|
|
||||||
if err != nil {
|
|
||||||
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("read request body: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
invokeTimeout := s.resolveInvokeTimeout(fn)
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), invokeTimeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
invokeURL := s.routerURL + route
|
|
||||||
if r.URL.RawQuery != "" {
|
|
||||||
invokeURL += "?" + r.URL.RawQuery
|
|
||||||
}
|
|
||||||
|
|
||||||
var invokeBody io.Reader
|
|
||||||
if shouldForwardRequestBody(r.Method) {
|
|
||||||
invokeBody = bytes.NewReader(bodyBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, r.Method, invokeURL, invokeBody)
|
|
||||||
if err != nil {
|
|
||||||
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build invoke request: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
copyProxyRequestHeaders(req.Header, r.Header)
|
|
||||||
if token := s.getRouterToken(); token != "" {
|
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := doRequestWithContextTimeout(s.http, req)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, context.DeadlineExceeded) {
|
|
||||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("invoke route %q timeout after %s", route, invokeTimeout))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var netErr net.Error
|
|
||||||
if errors.As(err, &netErr) && netErr.Timeout() {
|
|
||||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("invoke route %q timeout after %s", route, invokeTimeout))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("invoke route %q: %v", route, err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
copyProxyResponseHeaders(w.Header(), resp.Header)
|
|
||||||
w.WriteHeader(resp.StatusCode)
|
|
||||||
_, _ = io.Copy(w, resp.Body)
|
|
||||||
}
|
|
||||||
+1337
-72
File diff suppressed because it is too large
Load Diff
@@ -58,11 +58,6 @@ func (s *Server) handleLintArchive(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Опциональный entrypoint для проверки наличия файла в архиве (формат: "module.function")
|
|
||||||
entrypoint := strings.TrimSpace(r.FormValue("entrypoint"))
|
|
||||||
// Опциональный язык для проверки соответствия файлов в архиве
|
|
||||||
selectedLang := strings.ToLower(strings.TrimSpace(r.FormValue("language")))
|
|
||||||
|
|
||||||
f, _, err := r.FormFile("archive")
|
f, _, err := r.FormFile("archive")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSONError(w, http.StatusBadRequest, "поле 'archive' обязательно")
|
writeJSONError(w, http.StatusBadRequest, "поле 'archive' обязательно")
|
||||||
@@ -201,98 +196,6 @@ func (s *Server) handleLintArchive(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверяем соответствие выбранного языка файлам в архиве
|
|
||||||
if selectedLang != "" {
|
|
||||||
langExt := map[string]string{
|
|
||||||
"python": ".py", "python3": ".py",
|
|
||||||
"node.js": ".js", "nodejs": ".js", "javascript": ".js", "node": ".js",
|
|
||||||
"ruby": ".rb",
|
|
||||||
"php": ".php",
|
|
||||||
"go": ".go",
|
|
||||||
"perl": ".pl",
|
|
||||||
}
|
|
||||||
expectedExt, known := langExt[selectedLang]
|
|
||||||
if known {
|
|
||||||
// Собираем расширения файлов в архиве
|
|
||||||
extCount := map[string]int{}
|
|
||||||
for _, zf := range zr.File {
|
|
||||||
if !zf.FileInfo().IsDir() {
|
|
||||||
ext := strings.ToLower(filepath.Ext(zf.Name))
|
|
||||||
if ext != "" {
|
|
||||||
extCount[ext]++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Если нет ни одного файла с ожидаемым расширением — предупреждение
|
|
||||||
if extCount[expectedExt] == 0 {
|
|
||||||
found := []string{}
|
|
||||||
for ext := range extCount {
|
|
||||||
found = append(found, ext)
|
|
||||||
}
|
|
||||||
results = append(results, lintResult{
|
|
||||||
File: "язык",
|
|
||||||
OK: false,
|
|
||||||
Output: fmt.Sprintf("выбран язык '%s' (ожидается %s), но в архиве таких файлов нет (найдено: %s)", selectedLang, expectedExt, strings.Join(found, ", ")),
|
|
||||||
})
|
|
||||||
hasError = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверяем entrypoint: формат "module.function" → файл "module.py" должен быть в архиве
|
|
||||||
if entrypoint != "" {
|
|
||||||
parts := strings.SplitN(entrypoint, ".", 2)
|
|
||||||
if len(parts) == 2 {
|
|
||||||
module := parts[0]
|
|
||||||
funcName := parts[1]
|
|
||||||
// Ищем файл module.py в архиве (поддерживаем вложенные пути)
|
|
||||||
foundFile := false
|
|
||||||
funcDefined := false
|
|
||||||
for _, zf := range zr.File {
|
|
||||||
base := strings.TrimSuffix(filepath.Base(zf.Name), ".py")
|
|
||||||
if base == module && strings.HasSuffix(zf.Name, ".py") {
|
|
||||||
foundFile = true
|
|
||||||
// Читаем содержимое и ищем определение функции
|
|
||||||
rc, openErr := zf.Open()
|
|
||||||
if openErr == nil {
|
|
||||||
var content bytes.Buffer
|
|
||||||
content.ReadFrom(rc) //nolint:errcheck
|
|
||||||
rc.Close()
|
|
||||||
for _, line := range strings.Split(content.String(), "\n") {
|
|
||||||
trimmed := strings.TrimSpace(line)
|
|
||||||
if strings.HasPrefix(trimmed, "def "+funcName+"(") || trimmed == "def "+funcName+":" {
|
|
||||||
funcDefined = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !foundFile {
|
|
||||||
results = append(results, lintResult{
|
|
||||||
File: entrypoint,
|
|
||||||
OK: false,
|
|
||||||
Output: fmt.Sprintf("файл '%s.py' не найден в архиве — проверьте Entrypoint", module),
|
|
||||||
})
|
|
||||||
hasError = true
|
|
||||||
} else if !funcDefined {
|
|
||||||
results = append(results, lintResult{
|
|
||||||
File: entrypoint,
|
|
||||||
OK: false,
|
|
||||||
Output: fmt.Sprintf("функция '%s' не найдена в '%s.py' — проверьте Entrypoint", funcName, module),
|
|
||||||
})
|
|
||||||
hasError = true
|
|
||||||
} else {
|
|
||||||
results = append(results, lintResult{
|
|
||||||
File: entrypoint,
|
|
||||||
OK: true,
|
|
||||||
Output: fmt.Sprintf("entrypoint '%s' найден", entrypoint),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]any{
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
"ok": !hasError,
|
"ok": !hasError,
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -17,7 +16,6 @@ import (
|
|||||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ⛔⛔⛔ НЕ МЕНЯТЬ БЕЗ ЯВНОГО РАЗРЕШЕНИЯ ВЛАДЕЛЬЦА.
|
|
||||||
// extractPackageSourceCode пытается извлечь исходный код из Fission Package.
|
// extractPackageSourceCode пытается извлечь исходный код из Fission Package.
|
||||||
// Порядок попыток:
|
// Порядок попыток:
|
||||||
// 1. spec.source.literal (base64) — Go функции (source package)
|
// 1. spec.source.literal (base64) — Go функции (source package)
|
||||||
@@ -53,13 +51,10 @@ func extractPackageSourceCode(ctx context.Context, s *Server, pkg *unstructured.
|
|||||||
}
|
}
|
||||||
archiveBytes, err := fetchPackageArchive(ctx, s, urlValue)
|
archiveBytes, err := fetchPackageArchive(ctx, s, urlValue)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("extractPackageSourceCode: fetchPackageArchive %s: %v", urlValue, err)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if code, err := decodeArchiveBytesToSource(archiveBytes); err == nil && strings.TrimSpace(code) != "" {
|
if code, err := decodeArchiveBytesToSource(archiveBytes); err == nil && strings.TrimSpace(code) != "" {
|
||||||
return code
|
return code
|
||||||
} else {
|
|
||||||
log.Printf("extractPackageSourceCode: decodeArchiveBytesToSource %s: err=%v, len=%d", urlValue, err, len(archiveBytes))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import (
|
|||||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||||
"k8s.io/client-go/dynamic"
|
"k8s.io/client-go/dynamic"
|
||||||
"k8s.io/client-go/kubernetes"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// defaultSATokenPath — путь к service account токену внутри pod-а.
|
// defaultSATokenPath — путь к service account токену внутри pod-а.
|
||||||
@@ -32,8 +31,7 @@ const defaultSATokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token"
|
|||||||
// Содержит все зависимости: kubernetes client, конфиги, кэши.
|
// Содержит все зависимости: kubernetes client, конфиги, кэши.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
dyn dynamic.Interface
|
dyn dynamic.Interface
|
||||||
kube kubernetes.Interface // typed client — только для логов подов
|
ns string // системный namespace (fallback, обычно "fission")
|
||||||
ns string // системный namespace (fallback, обычно "fission")
|
|
||||||
routerURL string
|
routerURL string
|
||||||
http *http.Client
|
http *http.Client
|
||||||
|
|
||||||
@@ -64,7 +62,6 @@ type Server struct {
|
|||||||
// Config содержит все параметры для создания Server.
|
// Config содержит все параметры для создания Server.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Dyn dynamic.Interface
|
Dyn dynamic.Interface
|
||||||
Kube kubernetes.Interface
|
|
||||||
Namespace string
|
Namespace string
|
||||||
RouterURL string
|
RouterURL string
|
||||||
HTTPTimeout time.Duration
|
HTTPTimeout time.Duration
|
||||||
@@ -83,7 +80,6 @@ func NewServer(cfg Config) *Server {
|
|||||||
log.Printf("NewServer: storagesvcURL=%q", cfg.StoragesvcURL)
|
log.Printf("NewServer: storagesvcURL=%q", cfg.StoragesvcURL)
|
||||||
return &Server{
|
return &Server{
|
||||||
dyn: cfg.Dyn,
|
dyn: cfg.Dyn,
|
||||||
kube: cfg.Kube,
|
|
||||||
ns: cfg.Namespace,
|
ns: cfg.Namespace,
|
||||||
routerURL: cfg.RouterURL,
|
routerURL: cfg.RouterURL,
|
||||||
http: &http.Client{Timeout: cfg.HTTPTimeout},
|
http: &http.Client{Timeout: cfg.HTTPTimeout},
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
// Package api — работа с Fission storagesvc (S3-совместимое хранилище архивов).
|
|
||||||
//
|
|
||||||
// Этот файл содержит низкоуровневые операции загрузки и удаления архивов через
|
|
||||||
// HTTP API storagesvc. Storagesvc хранит zip-архивы функций в S3 (bucket sless-functions).
|
|
||||||
// Package CRD ссылается на архив через spec.deployment.url (type: url).
|
|
||||||
//
|
|
||||||
// Используется из function_code.go и function_archive.go для сохранения/замены кода функций.
|
|
||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// deleteFromStoragesvc удаляет архив из S3 через storagesvc по URL из Package spec.
|
|
||||||
// URL имеет формат: http://storagesvc.../v1/archive?id=fission/UUID
|
|
||||||
// Best-effort: ошибка логируется, но не прерывает операцию удаления.
|
|
||||||
func (s *Server) deleteFromStoragesvc(ctx context.Context, archiveURL string) {
|
|
||||||
if s.storagesvcURL == "" || archiveURL == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// archiveURL = "http://storagesvc.../v1/archive?id=fission/UUID"
|
|
||||||
// Строим DELETE URL к storagesvc, сохраняя query-параметр id
|
|
||||||
parsed, err := url.Parse(archiveURL)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("deleteFromStoragesvc: parse url %q: %v", archiveURL, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
deleteURL := strings.TrimRight(s.storagesvcURL, "/") + "/v1/archive?" + parsed.RawQuery
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, deleteURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("deleteFromStoragesvc: build request: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resp, err := s.http.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("deleteFromStoragesvc: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
log.Printf("deleteFromStoragesvc: status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Printf("deleteFromStoragesvc: deleted %s", parsed.Query().Get("id"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// uploadToStoragesvc загружает байты в Fission storagesvc и возвращает URL для package archive.
|
|
||||||
// Если storagesvcURL не задан — возвращает пустую строку (fallback на literal).
|
|
||||||
func (s *Server) uploadToStoragesvc(ctx context.Context, data []byte) (string, error) {
|
|
||||||
if s.storagesvcURL == "" {
|
|
||||||
log.Printf("uploadToStoragesvc: storagesvcURL is empty, skip upload")
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
log.Printf("uploadToStoragesvc: uploading %d bytes to %s", len(data), s.storagesvcURL)
|
|
||||||
uploadURL := strings.TrimRight(s.storagesvcURL, "/") + "/v1/archive"
|
|
||||||
body := &bytes.Reader{}
|
|
||||||
// multipart/form-data с полем uploadfile
|
|
||||||
var buf bytes.Buffer
|
|
||||||
boundary := fmt.Sprintf("fission%d", time.Now().UnixNano())
|
|
||||||
buf.WriteString("--" + boundary + "\r\n")
|
|
||||||
buf.WriteString(fmt.Sprintf("Content-Disposition: form-data; name=\"uploadfile\"; filename=\"archive.zip\"\r\n"))
|
|
||||||
buf.WriteString("Content-Type: application/octet-stream\r\n\r\n")
|
|
||||||
buf.Write(data)
|
|
||||||
buf.WriteString("\r\n--" + boundary + "--\r\n")
|
|
||||||
body = bytes.NewReader(buf.Bytes())
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, body)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("build storagesvc upload request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "multipart/form-data; boundary="+boundary)
|
|
||||||
req.Header.Set("X-File-Size", fmt.Sprintf("%d", len(data)))
|
|
||||||
|
|
||||||
resp, err := s.http.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("storagesvc upload: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
respBody, _ := io.ReadAll(resp.Body)
|
|
||||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
|
||||||
return "", fmt.Errorf("storagesvc upload status %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
|
|
||||||
}
|
|
||||||
var result struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(respBody, &result); err != nil || result.ID == "" {
|
|
||||||
return "", fmt.Errorf("storagesvc upload: bad response: %s", string(respBody))
|
|
||||||
}
|
|
||||||
archiveURL := strings.TrimRight(s.storagesvcURL, "/") + "/v1/archive?id=" + result.ID
|
|
||||||
return archiveURL, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildDeploySpec строит spec.deployment для Fission Package.
|
|
||||||
// Если storagesvcURL задан — загружает архив в S3 через storagesvc и возвращает type: url.
|
|
||||||
// Иначе — возвращает type: literal с base64-кодом.
|
|
||||||
func (s *Server) buildDeploySpec(ctx context.Context, data []byte) (map[string]any, error) {
|
|
||||||
archiveURL, err := s.uploadToStoragesvc(ctx, data)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("storagesvc upload failed, falling back to literal: %v", err)
|
|
||||||
// fallback — сохраняем как literal
|
|
||||||
return map[string]any{"type": "literal", "literal": base64.StdEncoding.EncodeToString(data)}, nil
|
|
||||||
}
|
|
||||||
if archiveURL == "" {
|
|
||||||
return map[string]any{"type": "literal", "literal": base64.StdEncoding.EncodeToString(data)}, nil
|
|
||||||
}
|
|
||||||
return map[string]any{"type": "url", "url": archiveURL}, nil
|
|
||||||
}
|
|
||||||
@@ -19,10 +19,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
timeTriggerDefaultMethod = http.MethodPost
|
timeTriggerDefaultMethod = http.MethodPost
|
||||||
timeTriggerDefaultSubPath = "/"
|
timeTriggerDefaultSubPath = "/"
|
||||||
timeTriggerMaxNameLen = 63
|
timeTriggerMaxNameLen = 63
|
||||||
cronGatewayURL = "http://fission-console.fission.svc.cluster.local"
|
cronGatewayURL = "http://fission-console.fission.svc.cluster.local"
|
||||||
)
|
)
|
||||||
|
|
||||||
var validTimeTriggerMethods = map[string]struct{}{
|
var validTimeTriggerMethods = map[string]struct{}{
|
||||||
@@ -502,4 +502,4 @@ func errStatus(err error) int {
|
|||||||
return ae.status
|
return ae.status
|
||||||
}
|
}
|
||||||
return http.StatusInternalServerError
|
return http.StatusInternalServerError
|
||||||
}
|
}
|
||||||
@@ -44,7 +44,7 @@ type LangEnvDef struct {
|
|||||||
// LangEnvMap сопоставляет идентификатор языка (string) с описанием среды выполнения.
|
// LangEnvMap сопоставляет идентификатор языка (string) с описанием среды выполнения.
|
||||||
// Ключ используется в createFunctionRequest.Language и как суффикс имени Environment.
|
// Ключ используется в createFunctionRequest.Language и как суффикс имени Environment.
|
||||||
var LangEnvMap = map[string]LangEnvDef{
|
var LangEnvMap = map[string]LangEnvDef{
|
||||||
"python": {Image: "naeel/fission-python-env:v1.0"},
|
"python": {Image: "ghcr.io/fission/python-env"},
|
||||||
"nodejs": {Image: "ghcr.io/fission/node-env"},
|
"nodejs": {Image: "ghcr.io/fission/node-env"},
|
||||||
"go": {Image: "ghcr.io/fission/go-env", BuilderImage: "naeel/go-builder-fast:v1"},
|
"go": {Image: "ghcr.io/fission/go-env", BuilderImage: "naeel/go-builder-fast:v1"},
|
||||||
"php": {Image: "ghcr.io/fission/php-env"},
|
"php": {Image: "ghcr.io/fission/php-env"},
|
||||||
|
|||||||
@@ -318,15 +318,3 @@ textarea {
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-error {
|
|
||||||
display: none;
|
|
||||||
margin-top: 10px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: #3a1a1a;
|
|
||||||
color: #f88;
|
|
||||||
font-size: 13px;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|||||||
+45
-130
@@ -21,8 +21,6 @@
|
|||||||
<script src="js/modals.js"></script>
|
<script src="js/modals.js"></script>
|
||||||
<script src="js/init.js"></script>
|
<script src="js/init.js"></script>
|
||||||
<script src="js/functions.js"></script>
|
<script src="js/functions.js"></script>
|
||||||
<script src="js/fn-code.js"></script>
|
|
||||||
<script src="js/fn-archive.js"></script>
|
|
||||||
<script src="js/ai.js"></script>
|
<script src="js/ai.js"></script>
|
||||||
<script src="js/app.js"></script>
|
<script src="js/app.js"></script>
|
||||||
</head>
|
</head>
|
||||||
@@ -102,12 +100,11 @@
|
|||||||
<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.45</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>
|
||||||
<button class="btn" onclick="openCreateCode()">✏️ Из кода</button>
|
<button class="btn" onclick="openCreate()">+ Создать функцию</button>
|
||||||
<button class="btn" onclick="openCreateArchive()">📦 Из архива</button>
|
|
||||||
<button class="btn ghost" onclick="openHelp()">Help</button>
|
<button class="btn ghost" onclick="openHelp()">Help</button>
|
||||||
<button class="btn ghost" onclick="doLogout()" style="margin-left:8px;">Выход</button>
|
<button class="btn ghost" onclick="doLogout()" style="margin-left:8px;">Выход</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -146,7 +143,8 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Имя</th>
|
<th>Имя</th>
|
||||||
<th title="Тип источника функции">Тип</th>
|
<th>Окружение</th>
|
||||||
|
<th>Пакет</th>
|
||||||
<th>Создана</th>
|
<th>Создана</th>
|
||||||
<th>Изменена</th>
|
<th>Изменена</th>
|
||||||
<th>Маршрут</th>
|
<th>Маршрут</th>
|
||||||
@@ -162,18 +160,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Модалка: создать функцию из кода (prefix cc-) -->
|
<div id="create-modal" class="modal">
|
||||||
<div id="create-code-modal" class="modal">
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<h3>✏️ Создать функцию из кода</h3>
|
<h3>Создать функцию</h3>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Name</label>
|
<label>Name</label>
|
||||||
<input id="cc-name" placeholder="demo-fn">
|
<input id="c-name" placeholder="demo-fn">
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Language</label>
|
<label>Language</label>
|
||||||
<select id="cc-lang" onchange="onLangChangeCode()">
|
<select id="c-lang" onchange="onLangChange()">
|
||||||
<option value="python">Python</option>
|
<option value="python">Python</option>
|
||||||
<option value="nodejs">Node.js</option>
|
<option value="nodejs">Node.js</option>
|
||||||
<option value="php">PHP</option>
|
<option value="php">PHP</option>
|
||||||
@@ -182,131 +179,73 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Entrypoint</label>
|
<label>Entrypoint</label>
|
||||||
<input id="cc-entry" value="main.main">
|
<input id="c-entry" value="main.main">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Route</label>
|
<label>Route</label>
|
||||||
<input id="cc-route" placeholder="/demo-fn">
|
<input id="c-route" placeholder="/demo-fn">
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Методы (через запятую)</label>
|
<label>Методы (через запятую)</label>
|
||||||
<input id="cc-methods" value="GET">
|
<input id="c-methods" value="GET">
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Timeout (сек)</label>
|
<label>Timeout (сек)</label>
|
||||||
<input id="cc-timeout" type="number" min="1" value="60">
|
<input id="c-timeout" type="number" min="1" value="60">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="field" style="min-width:240px; flex:0 0 260px;">
|
<div class="field" style="min-width:240px; flex:0 0 260px;">
|
||||||
<label style="display:flex; align-items:center; gap:8px; color:var(--text-primary); margin-top:18px;">
|
<label style="display:flex; align-items:center; gap:8px; color:var(--text-primary); margin-top:18px;">
|
||||||
<input id="cc-schedule-enabled" type="checkbox" onchange="toggleScheduleFields('cc')" style="width:auto;">
|
<input id="c-schedule-enabled" type="checkbox" onchange="toggleScheduleFields('c')" style="width:auto;">
|
||||||
Выполнять по расписанию
|
Выполнять по расписанию
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Cron</label>
|
<label>Cron</label>
|
||||||
<input id="cc-cron" placeholder="*/5 * * * *" disabled>
|
<input id="c-cron" placeholder="*/5 * * * *" disabled>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div id="cc-code-area">
|
<div style="display:flex; gap:4px; margin-bottom:6px;">
|
||||||
<textarea id="cc-code">def main():
|
<button class="btn ghost" id="c-mode-code" onclick="setCodeMode('c','code')" style="font-size:.8rem; padding:3px 10px;">✏️ Код</button>
|
||||||
|
<button class="btn ghost" id="c-mode-archive" onclick="setCodeMode('c','archive')" style="font-size:.8rem; padding:3px 10px;">📦 Архив (.zip)</button>
|
||||||
|
</div>
|
||||||
|
<div id="c-code-area">
|
||||||
|
<textarea id="c-code">def main():
|
||||||
return {"ok": True, "msg": "hello from fission console"}
|
return {"ok": True, "msg": "hello from fission console"}
|
||||||
</textarea>
|
</textarea>
|
||||||
<div style="margin-top:6px; display:flex; gap:6px; flex-wrap:wrap;">
|
<div style="margin-top:6px; display:flex; gap:6px; flex-wrap:wrap;">
|
||||||
<button class="btn ghost" id="cc-ai-btn" onclick="aiCheck('cc-code','cc-lang','cc-ai-result')">🔍 Проверить синтаксис линтером</button>
|
<button class="btn ghost" id="c-ai-btn" onclick="aiCheck('c-code','c-lang','c-ai-result')">🔍 Проверить
|
||||||
<button class="btn ghost" id="cc-gen-btn" onclick="showGenPrompt('cc')">✨ Сгенерировать код LLM</button>
|
синтаксис линтером</button>
|
||||||
<button class="btn ghost" id="cc-exp-btn" onclick="aiExplain('cc-code','cc-lang','cc-ai-result')">📖 LLM: Что делает?</button>
|
<button class="btn ghost" id="c-gen-btn" onclick="showGenPrompt()">✨ Сгенерировать код LLM</button>
|
||||||
|
<button class="btn ghost" id="c-exp-btn" onclick="aiExplain('c-code','c-lang','c-ai-result')">📖 LLM:
|
||||||
|
Что делает?</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="cc-gen-prompt" style="display:none; margin-top:8px; gap:6px; align-items:center;">
|
<div id="c-archive-area" style="display:none;">
|
||||||
<input id="cc-gen-desc" type="text"
|
<input type="file" id="c-archive-file" accept=".zip" style="display:block; margin-bottom:8px; color:var(--text-primary);">
|
||||||
placeholder="Что должна делать функция? (напр: принять JSON, вернуть сумму чисел)"
|
|
||||||
style="flex:1; background:#2a2a42; border:1px solid #3a3a5c; border-radius:4px; color:#cdd6f4; padding:6px 8px; font-size:.82rem; outline:none; width:100%;"
|
|
||||||
onkeydown="if(event.key==='Enter')aiGenerate('cc')" />
|
|
||||||
<button class="btn" onclick="aiGenerate('cc')" style="white-space:nowrap;">▶ Генерировать LLM</button>
|
|
||||||
</div>
|
|
||||||
<div id="cc-ai-result"
|
|
||||||
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 id="cc-error" class="modal-error"></div>
|
|
||||||
<div class="actions">
|
|
||||||
<button class="btn ghost" onclick="closeCreateCode()">Отмена</button>
|
|
||||||
<button id="cc-submit" class="btn" onclick="submitCreateCode()">Создать</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Модалка: создать функцию из архива (prefix ca-) -->
|
|
||||||
<div id="create-archive-modal" class="modal">
|
|
||||||
<div class="panel">
|
|
||||||
<h3>📦 Создать функцию из архива</h3>
|
|
||||||
<div class="row">
|
|
||||||
<div class="field">
|
|
||||||
<label>Name</label>
|
|
||||||
<input id="ca-name" placeholder="demo-fn">
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label>Language</label>
|
|
||||||
<select id="ca-lang" onchange="onLangChangeArchive()">
|
|
||||||
<option value="python">Python</option>
|
|
||||||
<option value="nodejs">Node.js</option>
|
|
||||||
<option value="php">PHP</option>
|
|
||||||
<option value="ruby">Ruby</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label>Entrypoint</label>
|
|
||||||
<input id="ca-entry" value="main.main">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="field">
|
|
||||||
<label>Route</label>
|
|
||||||
<input id="ca-route" placeholder="/demo-fn">
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label>Методы (через запятую)</label>
|
|
||||||
<input id="ca-methods" value="GET">
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label>Timeout (сек)</label>
|
|
||||||
<input id="ca-timeout" type="number" min="1" value="60">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="field" style="min-width:240px; flex:0 0 260px;">
|
|
||||||
<label style="display:flex; align-items:center; gap:8px; color:var(--text-primary); margin-top:18px;">
|
|
||||||
<input id="ca-schedule-enabled" type="checkbox" onchange="toggleScheduleFields('ca')" style="width:auto;">
|
|
||||||
Выполнять по расписанию
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label>Cron</label>
|
|
||||||
<input id="ca-cron" placeholder="*/5 * * * *" disabled>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div id="ca-archive-area">
|
|
||||||
<label>Архив (.zip)</label>
|
|
||||||
<input type="file" id="ca-archive-file" accept=".zip" style="display:block; margin-bottom:8px; color:var(--text-primary);">
|
|
||||||
<div style="display:flex; gap:6px; flex-wrap:wrap;">
|
<div style="display:flex; gap:6px; flex-wrap:wrap;">
|
||||||
<button id="ca-lint-btn" class="btn ghost" onclick="lintArchiveFile('ca')">🔍 Проверка архива линтером</button>
|
<button id="c-lint-btn" class="btn ghost" onclick="lintArchiveFile('c')">🔍 Проверка архива линтером</button>
|
||||||
<button id="ca-explain-archive-btn" class="btn ghost" onclick="explainArchiveFile('ca')">📖 LLM: Что делает?</button>
|
<button id="c-explain-archive-btn" class="btn ghost" onclick="explainArchiveFile('c')">📖 LLM: Что делает?</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="ca-ai-result"
|
<div id="c-gen-prompt" style="display:none; margin-top:8px; display:none; gap:6px; align-items:center;">
|
||||||
|
<input id="c-gen-desc" type="text"
|
||||||
|
placeholder="Что должна делать функция? (напр: принять JSON, вернуть сумму чисел)" style="flex:1; background:#2a2a42; border:1px solid #3a3a5c; border-radius:4px;
|
||||||
|
color:#cdd6f4; padding:6px 8px; font-size:.82rem; outline:none; width:100%;"
|
||||||
|
onkeydown="if(event.key==='Enter')aiGenerate()" />
|
||||||
|
<button class="btn" onclick="aiGenerate()" style="white-space:nowrap;">▶ Генерировать LLM</button>
|
||||||
|
</div>
|
||||||
|
<div id="c-ai-result"
|
||||||
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 id="ca-error" class="modal-error"></div>
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn ghost" onclick="closeCreateArchive()">Отмена</button>
|
<button class="btn ghost" onclick="closeCreate()">Отмена</button>
|
||||||
<button id="ca-submit" class="btn" onclick="submitCreateArchive()">Создать</button>
|
<button id="c-submit" class="btn" onclick="submitCreate()">Создать</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -331,7 +270,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Entrypoint</label>
|
<label>Entrypoint</label>
|
||||||
<input id="e-entry">
|
<input id="e-entry" disabled>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Timeout (сек)</label>
|
<label>Timeout (сек)</label>
|
||||||
@@ -351,6 +290,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<div style="display:flex; gap:4px; margin-bottom:6px;">
|
||||||
|
<button class="btn ghost" id="e-mode-code" onclick="setCodeMode('e','code')" style="font-size:.8rem; padding:3px 10px;">✏️ Код</button>
|
||||||
|
<button class="btn ghost" id="e-mode-archive" onclick="setCodeMode('e','archive')" style="font-size:.8rem; padding:3px 10px;">📦 Архив (.zip)</button>
|
||||||
|
</div>
|
||||||
<div id="e-code-area">
|
<div id="e-code-area">
|
||||||
<label>Код</label>
|
<label>Код</label>
|
||||||
<textarea id="e-code"></textarea>
|
<textarea id="e-code"></textarea>
|
||||||
@@ -362,10 +305,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="e-archive-area" style="display:none;">
|
<div id="e-archive-area" style="display:none;">
|
||||||
<div id="e-archive-current" style="margin-bottom:10px; padding:8px 12px; background:var(--bg-alt); border-radius:6px; font-size:13px; color:var(--text-secondary);">
|
|
||||||
📦 <span id="e-archive-name"></span>
|
|
||||||
</div>
|
|
||||||
<label style="font-size:12px; color:var(--text-secondary); margin-bottom:4px; display:block;">Заменить архив (необязательно)</label>
|
|
||||||
<input type="file" id="e-archive-file" accept=".zip" style="display:block; margin-bottom:8px; color:var(--text-primary);">
|
<input type="file" id="e-archive-file" accept=".zip" style="display:block; margin-bottom:8px; color:var(--text-primary);">
|
||||||
<div style="display:flex; gap:6px; flex-wrap:wrap;">
|
<div style="display:flex; gap:6px; flex-wrap:wrap;">
|
||||||
<button id="e-lint-btn" class="btn ghost" onclick="lintArchiveFile('e')">🔍 Проверка архива линтером</button>
|
<button id="e-lint-btn" class="btn ghost" onclick="lintArchiveFile('e')">🔍 Проверка архива линтером</button>
|
||||||
@@ -383,30 +322,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="info-modal" class="modal">
|
|
||||||
<div class="panel" style="width:560px; max-width:95vw;">
|
|
||||||
<h3 id="info-title">Информация о функции</h3>
|
|
||||||
<table class="info-table">
|
|
||||||
<tbody id="info-rows"></tbody>
|
|
||||||
</table>
|
|
||||||
<div class="actions" style="margin-top:14px;">
|
|
||||||
<button class="btn ghost" onclick="closeInfo()">Закрыть</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="logs-modal" class="modal">
|
|
||||||
<div class="panel" style="width:700px; max-width:95vw;">
|
|
||||||
<h3 id="logs-title">Логи функции</h3>
|
|
||||||
<textarea id="logs-output" readonly
|
|
||||||
style="width:100%; height:340px; font-family:monospace; font-size:12px; background:var(--bg-alt); color:var(--text-primary); border:1px solid var(--border); border-radius:6px; padding:10px; resize:vertical; white-space:pre;"></textarea>
|
|
||||||
<div class="actions">
|
|
||||||
<button class="btn ghost" onclick="closeLogs()">Закрыть</button>
|
|
||||||
<button class="btn ghost" onclick="refreshLogs()">Обновить</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="help-modal" class="modal">
|
<div id="help-modal" class="modal">
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<h3>Help</h3>
|
<h3>Help</h3>
|
||||||
@@ -485,7 +400,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.45</span>
|
||||||
<button class="btn ghost" onclick="closeHelp()">Закрыть</button>
|
<button class="btn ghost" onclick="closeHelp()">Закрыть</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+12
-14
@@ -86,21 +86,19 @@ async function askAssistant() {
|
|||||||
msgs.scrollTop = msgs.scrollHeight;
|
msgs.scrollTop = msgs.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
function showGenPrompt(prefix) {
|
function showGenPrompt() {
|
||||||
prefix = prefix || 'cc';
|
var el = document.getElementById('c-gen-prompt');
|
||||||
var el = document.getElementById(prefix + '-gen-prompt');
|
|
||||||
el.style.display = 'flex';
|
el.style.display = 'flex';
|
||||||
document.getElementById(prefix + '-gen-desc').focus();
|
document.getElementById('c-gen-desc').focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function aiGenerate(prefix) {
|
async function aiGenerate() {
|
||||||
prefix = prefix || 'cc';
|
var name = (document.getElementById('c-name').value || '').trim() || 'my-func';
|
||||||
var name = (document.getElementById(prefix + '-name').value || '').trim() || 'my-func';
|
var lang = document.getElementById('c-lang').value || 'python';
|
||||||
var lang = document.getElementById(prefix + '-lang').value || 'python';
|
var desc = (document.getElementById('c-gen-desc').value || '').trim();
|
||||||
var desc = (document.getElementById(prefix + '-gen-desc').value || '').trim();
|
if (!desc) { document.getElementById('c-gen-desc').focus(); return; }
|
||||||
if (!desc) { document.getElementById(prefix + '-gen-desc').focus(); return; }
|
var btn = document.getElementById('c-gen-btn');
|
||||||
var btn = document.getElementById(prefix + '-gen-btn');
|
var resEl = document.getElementById('c-ai-result');
|
||||||
var resEl = document.getElementById(prefix + '-ai-result');
|
|
||||||
btn.disabled = true; btn.textContent = '⏳ Генерирую...';
|
btn.disabled = true; btn.textContent = '⏳ Генерирую...';
|
||||||
resEl.style.display = 'block'; resEl.style.background = 'var(--bg-alt)'; resEl.style.color = 'var(--fg)';
|
resEl.style.display = 'block'; resEl.style.background = 'var(--bg-alt)'; resEl.style.color = 'var(--fg)';
|
||||||
resEl.textContent = 'Запрашиваю у LLM...';
|
resEl.textContent = 'Запрашиваю у LLM...';
|
||||||
@@ -112,8 +110,8 @@ async function aiGenerate(prefix) {
|
|||||||
description: desc
|
description: desc
|
||||||
});
|
});
|
||||||
var code = (d.answer || '').replace(/^```[\w]*\n?/, '').replace(/\n?```$/, '');
|
var code = (d.answer || '').replace(/^```[\w]*\n?/, '').replace(/\n?```$/, '');
|
||||||
document.getElementById(prefix + '-code').value = addLLMWarning(code, lang);
|
document.getElementById('c-code').value = addLLMWarning(code, lang);
|
||||||
document.getElementById(prefix + '-gen-prompt').style.display = 'none';
|
document.getElementById('c-gen-prompt').style.display = 'none';
|
||||||
resEl.style.background = '#1a3a1a'; resEl.style.color = '#8f8';
|
resEl.style.background = '#1a3a1a'; resEl.style.color = '#8f8';
|
||||||
resEl.textContent = 'Код сгенерирован и вставлен.';
|
resEl.textContent = 'Код сгенерирован и вставлен.';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
+5
-18
@@ -5,7 +5,7 @@ async function reloadAll() {
|
|||||||
|
|
||||||
// Показываем таблицу сразу с placeholder'ом
|
// Показываем таблицу сразу с placeholder'ом
|
||||||
document.getElementById('fn-rows').innerHTML =
|
document.getElementById('fn-rows').innerHTML =
|
||||||
'<tr><td colspan="8" style="color:var(--text-secondary); font-style:italic;">⏳ Загружаем функции...</td></tr>';
|
'<tr><td colspan="9" style="color:var(--text-secondary); font-style:italic;">⏳ Загружаем функции...</td></tr>';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [envs, pkgs, fns, http, times] = await Promise.all([
|
const [envs, pkgs, fns, http, times] = await Promise.all([
|
||||||
@@ -35,8 +35,6 @@ async function reloadAll() {
|
|||||||
const ann = meta.annotations || {};
|
const ann = meta.annotations || {};
|
||||||
const env = (spec.environment && spec.environment.name) || '-';
|
const env = (spec.environment && spec.environment.name) || '-';
|
||||||
const pkg = (spec.package && spec.package.packageref && spec.package.packageref.name) || '-';
|
const pkg = (spec.package && spec.package.packageref && spec.package.packageref.name) || '-';
|
||||||
const entrypoint = (spec.package && spec.package.functionName) || '-';
|
|
||||||
const timeout = spec.functionTimeout || 60;
|
|
||||||
const name = (f.metadata && f.metadata.name) || '-';
|
const name = (f.metadata && f.metadata.name) || '-';
|
||||||
const trig = httpTriggerByFn(name) || {};
|
const trig = httpTriggerByFn(name) || {};
|
||||||
const timeTrig = timeTriggerByFn(name) || {};
|
const timeTrig = timeTriggerByFn(name) || {};
|
||||||
@@ -47,28 +45,17 @@ async function reloadAll() {
|
|||||||
const cronCell = cron ? '<span class="chip">' + h(cron) + '</span>' : '<span class="mono" style="color:var(--text-secondary)">—</span>';
|
const cronCell = cron ? '<span class="chip">' + h(cron) + '</span>' : '<span class="mono" style="color:var(--text-secondary)">—</span>';
|
||||||
const createdAt = ann['fission-console/created-at'] || meta.creationTimestamp || '-';
|
const createdAt = ann['fission-console/created-at'] || meta.creationTimestamp || '-';
|
||||||
const updatedAt = ann['fission-console/updated-at'] || createdAt;
|
const updatedAt = ann['fission-console/updated-at'] || createdAt;
|
||||||
const sourceType = ann['fission-console/source-type'] || 'code';
|
|
||||||
const sourceIcon = sourceType === 'archive'
|
|
||||||
? '<span title="Из архива (.zip)" style="font-size:1.1em; cursor:default;">📦</span>'
|
|
||||||
: '<span title="Из кода (редактор)" style="font-size:1.1em; cursor:default;">📝</span>';
|
|
||||||
var isGo = /go[-_]env/.test(env);
|
var isGo = /go[-_]env/.test(env);
|
||||||
var isTf = /^tf-/.test(name);
|
var isTf = /^tf-/.test(name);
|
||||||
var tfBadge = (isGo || isTf) ? '<span class="chip" style="background:#555;color:#ffa" title="Управляется Terraform. Изменения могут быть перезаписаны при terraform apply.">TF</span> ' : '';
|
var tfBadge = (isGo || isTf) ? '<span class="chip" style="background:#555;color:#ffa" title="Управляется Terraform. Изменения могут быть перезаписаны при terraform apply.">TF</span> ' : '';
|
||||||
// Сохраняем все данные в data-атрибуте для openInfo (избегаем повторного запроса)
|
|
||||||
var infoData = h(JSON.stringify({
|
|
||||||
name: name, env: env, pkg: pkg, entrypoint: entrypoint, timeout: timeout,
|
|
||||||
route: route, methods: methods, sourceType: sourceType,
|
|
||||||
createdAt: createdAt, updatedAt: updatedAt, cron: cron
|
|
||||||
}));
|
|
||||||
var actions = tfBadge +
|
var actions = tfBadge +
|
||||||
'<button class="btn ghost" onclick="openInfo(\'' + h(name) + '\', this)" data-info="' + infoData + '">Info</button> ' +
|
|
||||||
'<button class="btn ghost" onclick="openEdit(\'' + h(name) + '\')">Ред.</button> ' +
|
'<button class="btn ghost" onclick="openEdit(\'' + h(name) + '\')">Ред.</button> ' +
|
||||||
'<button class="btn ghost" onclick="openInvoke(\'' + h(name) + '\')">Вызов</button> ' +
|
'<button class="btn ghost" onclick="openInvoke(\'' + h(name) + '\')">Вызов</button> ' +
|
||||||
'<button class="btn ghost" onclick="openLogs(\'' + h(name) + '\')">Логи</button> ' +
|
|
||||||
'<button class="btn danger" onclick="removeFn(\'' + h(name) + '\')">Удалить</button>';
|
'<button class="btn danger" onclick="removeFn(\'' + h(name) + '\')">Удалить</button>';
|
||||||
return '<tr>' +
|
return '<tr>' +
|
||||||
'<td class="mono">' + h(name) + '</td>' +
|
'<td class="mono">' + h(name) + '</td>' +
|
||||||
'<td style="text-align:center;">' + sourceIcon + '</td>' +
|
'<td>' + h(env) + '</td>' +
|
||||||
|
'<td class="mono">' + h(pkg) + '</td>' +
|
||||||
'<td>' + timestampCell(createdAt) + '</td>' +
|
'<td>' + timestampCell(createdAt) + '</td>' +
|
||||||
'<td>' + timestampCell(updatedAt) + '</td>' +
|
'<td>' + timestampCell(updatedAt) + '</td>' +
|
||||||
'<td class="mono">' + h(route) + '</td>' +
|
'<td class="mono">' + h(route) + '</td>' +
|
||||||
@@ -81,7 +68,7 @@ async function reloadAll() {
|
|||||||
var fnList = fns || [];
|
var fnList = fns || [];
|
||||||
if (fnList.length === 0) {
|
if (fnList.length === 0) {
|
||||||
progress.stop('Загружено: нет функций', '');
|
progress.stop('Загружено: нет функций', '');
|
||||||
document.getElementById('fn-rows').innerHTML = '<tr><td colspan="8">Нет функций</td></tr>';
|
document.getElementById('fn-rows').innerHTML = '<tr><td colspan="9">Нет функций</td></tr>';
|
||||||
} else {
|
} else {
|
||||||
// Рендерим первую строку сразу — убираем placeholder
|
// Рендерим первую строку сразу — убираем placeholder
|
||||||
var tbody = document.getElementById('fn-rows');
|
var tbody = document.getElementById('fn-rows');
|
||||||
@@ -102,7 +89,7 @@ async function reloadAll() {
|
|||||||
setTimeout(appendNext, 40);
|
setTimeout(appendNext, 40);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
document.getElementById('fn-rows').innerHTML = '<tr><td colspan="8">Load error: ' + e.message + '</td></tr>';
|
document.getElementById('fn-rows').innerHTML = '<tr><td colspan="9">Load error: ' + e.message + '</td></tr>';
|
||||||
progress.stop('Ошибка загрузки: ' + e.message, 'err');
|
progress.stop('Ошибка загрузки: ' + e.message, 'err');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
/* fn-archive.js — создание функции из архива (независимый модуль) */
|
|
||||||
|
|
||||||
function onLangChangeArchive() {
|
|
||||||
var lang = document.getElementById('ca-lang').value;
|
|
||||||
var t = LANG_TEMPLATES[lang];
|
|
||||||
if (t) {
|
|
||||||
document.getElementById('ca-entry').value = t.entrypoint;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCreateArchive() {
|
|
||||||
document.getElementById('ca-lang').value = 'python';
|
|
||||||
onLangChangeArchive();
|
|
||||||
document.getElementById('ca-name').value = '';
|
|
||||||
document.getElementById('ca-route').value = '';
|
|
||||||
document.getElementById('ca-methods').value = 'GET';
|
|
||||||
document.getElementById('ca-timeout').value = '60';
|
|
||||||
document.getElementById('ca-schedule-enabled').checked = false;
|
|
||||||
document.getElementById('ca-cron').value = '';
|
|
||||||
toggleScheduleFields('ca');
|
|
||||||
var fileInput = document.getElementById('ca-archive-file');
|
|
||||||
if (fileInput) fileInput.value = '';
|
|
||||||
var aiRes = document.getElementById('ca-ai-result');
|
|
||||||
if (aiRes) { aiRes.style.display = 'none'; aiRes.textContent = ''; }
|
|
||||||
hideModalError('ca-error');
|
|
||||||
document.getElementById('create-archive-modal').classList.add('open');
|
|
||||||
document.getElementById('ca-name').focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeCreateArchive() {
|
|
||||||
document.getElementById('create-archive-modal').classList.remove('open');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitCreateArchive() {
|
|
||||||
const btn = document.getElementById('ca-submit');
|
|
||||||
btn.disabled = true;
|
|
||||||
const progress = startTimedStatus('Создаём функцию...', 'Создание функции...', explainDelay);
|
|
||||||
try {
|
|
||||||
const name = document.getElementById('ca-name').value.trim();
|
|
||||||
if (!name) throw new Error('name is required');
|
|
||||||
const lang = document.getElementById('ca-lang').value.trim();
|
|
||||||
if (!lang) throw new Error('language is required');
|
|
||||||
const archiveFile = document.getElementById('ca-archive-file').files[0];
|
|
||||||
if (!archiveFile) throw new Error('выберите .zip архив');
|
|
||||||
if (!archiveFile.name.toLowerCase().endsWith('.zip')) throw new Error('файл должен быть .zip архивом, не .' + archiveFile.name.split('.').pop());
|
|
||||||
|
|
||||||
var fd = new FormData();
|
|
||||||
fd.append('name', name);
|
|
||||||
fd.append('language', lang);
|
|
||||||
fd.append('entrypoint', document.getElementById('ca-entry').value.trim());
|
|
||||||
fd.append('route', document.getElementById('ca-route').value.trim());
|
|
||||||
fd.append('methods', document.getElementById('ca-methods').value.trim());
|
|
||||||
fd.append('timeout', String(parseTimeout(document.getElementById('ca-timeout').value)));
|
|
||||||
fd.append('archive', archiveFile);
|
|
||||||
|
|
||||||
var resp = await fetch(API_BASE + '/functions', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: authHeaders(),
|
|
||||||
body: fd
|
|
||||||
});
|
|
||||||
if (!resp.ok) {
|
|
||||||
var e = await resp.json().catch(() => ({}));
|
|
||||||
throw new Error(e.error || resp.statusText);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await syncScheduleForFunction(name, 'ca');
|
|
||||||
} catch (scheduleErr) {
|
|
||||||
try {
|
|
||||||
await requestJSON(API_BASE + '/functions/' + encodeURIComponent(name), 'DELETE');
|
|
||||||
} catch (rollbackErr) {
|
|
||||||
scheduleErr.message += '; rollback failed: ' + rollbackErr.message;
|
|
||||||
}
|
|
||||||
throw scheduleErr;
|
|
||||||
}
|
|
||||||
|
|
||||||
closeCreateArchive();
|
|
||||||
progress.stop('Функция создана: ' + name, 'ok');
|
|
||||||
await reloadAll();
|
|
||||||
} catch (e) {
|
|
||||||
progress.stop('Ошибка создания: ' + e.message, 'err');
|
|
||||||
showModalError('ca-error', 'Ошибка: ' + e.message);
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
/* fn-code.js — создание функции из кода (независимый модуль) */
|
|
||||||
|
|
||||||
function onLangChangeCode() {
|
|
||||||
var lang = document.getElementById('cc-lang').value;
|
|
||||||
var t = LANG_TEMPLATES[lang];
|
|
||||||
if (t) {
|
|
||||||
document.getElementById('cc-entry').value = t.entrypoint;
|
|
||||||
document.getElementById('cc-code').value = t.code;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCreateCode() {
|
|
||||||
document.getElementById('cc-lang').value = 'python';
|
|
||||||
onLangChangeCode();
|
|
||||||
document.getElementById('cc-name').value = '';
|
|
||||||
document.getElementById('cc-route').value = '';
|
|
||||||
document.getElementById('cc-methods').value = 'GET';
|
|
||||||
document.getElementById('cc-timeout').value = '60';
|
|
||||||
document.getElementById('cc-schedule-enabled').checked = false;
|
|
||||||
document.getElementById('cc-cron').value = '';
|
|
||||||
toggleScheduleFields('cc');
|
|
||||||
var aiRes = document.getElementById('cc-ai-result');
|
|
||||||
if (aiRes) { aiRes.style.display = 'none'; aiRes.textContent = ''; }
|
|
||||||
hideModalError('cc-error');
|
|
||||||
document.getElementById('create-code-modal').classList.add('open');
|
|
||||||
document.getElementById('cc-name').focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeCreateCode() {
|
|
||||||
document.getElementById('create-code-modal').classList.remove('open');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitCreateCode() {
|
|
||||||
const btn = document.getElementById('cc-submit');
|
|
||||||
btn.disabled = true;
|
|
||||||
const progress = startTimedStatus('Создаём функцию...', 'Создание функции...', explainDelay);
|
|
||||||
try {
|
|
||||||
const name = document.getElementById('cc-name').value.trim();
|
|
||||||
if (!name) throw new Error('name is required');
|
|
||||||
const lang = document.getElementById('cc-lang').value.trim();
|
|
||||||
if (!lang) throw new Error('language is required');
|
|
||||||
|
|
||||||
await requestJSON(API_BASE + '/functions', 'POST', {
|
|
||||||
name: name,
|
|
||||||
language: lang,
|
|
||||||
entrypoint: document.getElementById('cc-entry').value.trim(),
|
|
||||||
route: document.getElementById('cc-route').value.trim(),
|
|
||||||
methods: parseMethods(document.getElementById('cc-methods').value),
|
|
||||||
timeout: parseTimeout(document.getElementById('cc-timeout').value),
|
|
||||||
code: document.getElementById('cc-code').value
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await syncScheduleForFunction(name, 'cc');
|
|
||||||
} catch (scheduleErr) {
|
|
||||||
try {
|
|
||||||
await requestJSON(API_BASE + '/functions/' + encodeURIComponent(name), 'DELETE');
|
|
||||||
} catch (rollbackErr) {
|
|
||||||
scheduleErr.message += '; rollback failed: ' + rollbackErr.message;
|
|
||||||
}
|
|
||||||
throw scheduleErr;
|
|
||||||
}
|
|
||||||
|
|
||||||
closeCreateCode();
|
|
||||||
progress.stop('Функция создана: ' + name, 'ok');
|
|
||||||
await reloadAll();
|
|
||||||
} catch (e) {
|
|
||||||
progress.stop('Ошибка создания: ' + e.message, 'err');
|
|
||||||
showModalError('cc-error', 'Ошибка: ' + e.message);
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+83
-26
@@ -1,8 +1,4 @@
|
|||||||
/* functions.js — CRUD операции с функциями */
|
/* functions.js — CRUD операции с функциями */
|
||||||
// ⛔⛔⛔ НЕ МЕНЯТЬ БЕЗ ЯВНОГО РАЗРЕШЕНИЯ ВЛАДЕЛЬЦА.
|
|
||||||
// Любые "улучшения", рефакторинг, добавление фич — ЗАПРЕЩЕНЫ без команды.
|
|
||||||
// Прецедент: v1.3.49 — убрали mode toggle "из лучших побуждений" → сломали рабочий редактор.
|
|
||||||
// Прецедент: helm upgrade → сломал JWT secret router → 401 у всех функций.
|
|
||||||
|
|
||||||
// setCodeMode переключает режим формы между вводом кода и загрузкой архива.
|
// setCodeMode переключает режим формы между вводом кода и загрузкой архива.
|
||||||
// prefix: 'c' (create) или 'e' (edit)
|
// prefix: 'c' (create) или 'e' (edit)
|
||||||
@@ -48,16 +44,6 @@ function lintArchiveFile(prefix) {
|
|||||||
showRes('Запускаю линтер…', 'var(--bg-alt)', 'var(--fg)');
|
showRes('Запускаю линтер…', 'var(--bg-alt)', 'var(--fg)');
|
||||||
var fd = new FormData();
|
var fd = new FormData();
|
||||||
fd.append('archive', file);
|
fd.append('archive', file);
|
||||||
// Передаём entrypoint для проверки наличия файла и функции в архиве
|
|
||||||
var entryEl = document.getElementById(prefix + '-entry');
|
|
||||||
if (entryEl && entryEl.value.trim()) {
|
|
||||||
fd.append('entrypoint', entryEl.value.trim());
|
|
||||||
}
|
|
||||||
// Передаём выбранный язык для проверки соответствия файлов
|
|
||||||
var langEl = document.getElementById(prefix + '-lang');
|
|
||||||
if (langEl && langEl.value) {
|
|
||||||
fd.append('language', langEl.value);
|
|
||||||
}
|
|
||||||
fetch(API_BASE + '/ai/lint-archive', {
|
fetch(API_BASE + '/ai/lint-archive', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: authHeaders(),
|
headers: authHeaders(),
|
||||||
@@ -147,7 +133,89 @@ function parseTimeout(v) {
|
|||||||
return Math.round(n);
|
return Math.round(n);
|
||||||
}
|
}
|
||||||
|
|
||||||
// openCreate/closeCreate/submitCreate перенесены в fn-code.js и fn-archive.js
|
function onLangChange() {
|
||||||
|
var lang = document.getElementById('c-lang').value;
|
||||||
|
var t = LANG_TEMPLATES[lang];
|
||||||
|
if (t) {
|
||||||
|
document.getElementById('c-entry').value = t.entrypoint;
|
||||||
|
document.getElementById('c-code').value = t.code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
document.getElementById('c-lang').value = 'python';
|
||||||
|
onLangChange();
|
||||||
|
document.getElementById('c-timeout').value = '60';
|
||||||
|
document.getElementById('c-schedule-enabled').checked = false;
|
||||||
|
document.getElementById('c-cron').value = '';
|
||||||
|
toggleScheduleFields('c');
|
||||||
|
document.getElementById('create-modal').classList.add('open');
|
||||||
|
document.getElementById('c-name').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCreate() {
|
||||||
|
document.getElementById('create-modal').classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCreate() {
|
||||||
|
const btn = document.getElementById('c-submit');
|
||||||
|
btn.disabled = true;
|
||||||
|
const progress = startTimedStatus('Создаём функцию...', 'Создание функции...', explainDelay);
|
||||||
|
try {
|
||||||
|
const name = document.getElementById('c-name').value.trim();
|
||||||
|
if (!name) throw new Error('name is required');
|
||||||
|
const lang = document.getElementById('c-lang').value.trim();
|
||||||
|
if (!lang) throw new Error('language is required');
|
||||||
|
|
||||||
|
// Определяем режим: archiveArea видна → archive mode
|
||||||
|
var archiveArea = document.getElementById('c-archive-area');
|
||||||
|
var isArchiveMode = archiveArea && archiveArea.style.display !== 'none';
|
||||||
|
var archiveFile = isArchiveMode ? document.getElementById('c-archive-file').files[0] : null;
|
||||||
|
|
||||||
|
if (isArchiveMode && archiveFile) {
|
||||||
|
// Отправляем multipart/form-data с архивом
|
||||||
|
var fd = new FormData();
|
||||||
|
fd.append('name', name);
|
||||||
|
fd.append('language', lang);
|
||||||
|
fd.append('entrypoint', document.getElementById('c-entry').value.trim());
|
||||||
|
fd.append('route', document.getElementById('c-route').value.trim());
|
||||||
|
fd.append('methods', document.getElementById('c-methods').value.trim());
|
||||||
|
fd.append('timeout', String(parseTimeout(document.getElementById('c-timeout').value)));
|
||||||
|
fd.append('archive', archiveFile);
|
||||||
|
var resp = await fetch(API_BASE + '/functions', { method: 'POST', headers: authHeaders(), body: fd });
|
||||||
|
if (!resp.ok) { var e = await resp.json().catch(() => ({})); throw new Error(e.error || resp.statusText); }
|
||||||
|
} else {
|
||||||
|
await requestJSON(API_BASE + '/functions', 'POST', {
|
||||||
|
name: name,
|
||||||
|
language: lang,
|
||||||
|
entrypoint: document.getElementById('c-entry').value.trim(),
|
||||||
|
route: document.getElementById('c-route').value.trim(),
|
||||||
|
methods: parseMethods(document.getElementById('c-methods').value),
|
||||||
|
timeout: parseTimeout(document.getElementById('c-timeout').value),
|
||||||
|
code: document.getElementById('c-code').value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await syncScheduleForFunction(name, 'c');
|
||||||
|
} catch (scheduleErr) {
|
||||||
|
try {
|
||||||
|
await requestJSON(API_BASE + '/functions/' + encodeURIComponent(name), 'DELETE');
|
||||||
|
} catch (rollbackErr) {
|
||||||
|
scheduleErr.message += '; rollback failed: ' + rollbackErr.message;
|
||||||
|
}
|
||||||
|
throw scheduleErr;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeCreate();
|
||||||
|
progress.stop('Функция создана: ' + name, 'ok');
|
||||||
|
await reloadAll();
|
||||||
|
} catch (e) {
|
||||||
|
progress.stop('Ошибка создания: ' + e.message, 'err');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function openEdit(name) {
|
async function openEdit(name) {
|
||||||
try {
|
try {
|
||||||
@@ -163,10 +231,6 @@ async function openEdit(name) {
|
|||||||
// Сбрасываем режим: source_type из API (code / archive)
|
// Сбрасываем режим: source_type из API (code / archive)
|
||||||
if (fn.source_type === 'archive') {
|
if (fn.source_type === 'archive') {
|
||||||
setCodeMode('e', 'archive');
|
setCodeMode('e', 'archive');
|
||||||
var archiveNameEl = document.getElementById('e-archive-name');
|
|
||||||
if (archiveNameEl) archiveNameEl.textContent = fn.archive_filename || 'архив';
|
|
||||||
var archiveFile = document.getElementById('e-archive-file');
|
|
||||||
if (archiveFile) archiveFile.value = '';
|
|
||||||
} else {
|
} else {
|
||||||
setCodeMode('e', 'code');
|
setCodeMode('e', 'code');
|
||||||
}
|
}
|
||||||
@@ -219,17 +283,10 @@ async function submitEdit() {
|
|||||||
// Обновляем через архив
|
// Обновляем через архив
|
||||||
var fd = new FormData();
|
var fd = new FormData();
|
||||||
fd.append('timeout', String(parseTimeout(document.getElementById('e-timeout').value)));
|
fd.append('timeout', String(parseTimeout(document.getElementById('e-timeout').value)));
|
||||||
fd.append('entrypoint', document.getElementById('e-entry').value.trim());
|
|
||||||
fd.append('archive', archiveFile);
|
fd.append('archive', archiveFile);
|
||||||
var resp = await fetch(API_BASE + '/functions/' + encodeURIComponent(name) + '/archive',
|
var resp = await fetch(API_BASE + '/functions/' + encodeURIComponent(name) + '/archive',
|
||||||
{ method: 'PUT', headers: authHeaders(), body: fd });
|
{ method: 'PUT', headers: authHeaders(), body: fd });
|
||||||
if (!resp.ok) { var e = await resp.json().catch(() => ({})); throw new Error(e.error || resp.statusText); }
|
if (!resp.ok) { var e = await resp.json().catch(() => ({})); throw new Error(e.error || resp.statusText); }
|
||||||
} else if (isArchiveMode) {
|
|
||||||
// Архив не заменяется — обновляем только timeout + entrypoint
|
|
||||||
await requestJSON(API_BASE + '/functions/' + encodeURIComponent(name) + '/timeout', 'PUT', {
|
|
||||||
timeout: parseTimeout(document.getElementById('e-timeout').value),
|
|
||||||
entrypoint: document.getElementById('e-entry').value.trim()
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
await requestJSON(API_BASE + '/functions/' + encodeURIComponent(name) + '/code', 'PUT', {
|
await requestJSON(API_BASE + '/functions/' + encodeURIComponent(name) + '/code', 'PUT', {
|
||||||
code: document.getElementById('e-code').value,
|
code: document.getElementById('e-code').value,
|
||||||
|
|||||||
+2
-180
@@ -1,81 +1,5 @@
|
|||||||
/* modals.js — управление модальными окнами Help и Invoke */
|
/* modals.js — управление модальными окнами Help и Invoke */
|
||||||
|
|
||||||
// makeDraggable — делает .panel внутри modalId перетаскиваемым за заголовок (h3).
|
|
||||||
function makeDraggable(modalId) {
|
|
||||||
var modal = document.getElementById(modalId);
|
|
||||||
if (!modal) return;
|
|
||||||
var panel = modal.querySelector('.panel');
|
|
||||||
if (!panel) return;
|
|
||||||
var handle = panel.querySelector('h3');
|
|
||||||
if (!handle) return;
|
|
||||||
|
|
||||||
handle.style.cursor = 'move';
|
|
||||||
handle.style.userSelect = 'none';
|
|
||||||
|
|
||||||
var startX, startY, startLeft, startTop;
|
|
||||||
|
|
||||||
handle.addEventListener('mousedown', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
var rect = panel.getBoundingClientRect();
|
|
||||||
// Переводим в position:fixed если ещё не
|
|
||||||
if (!panel.style.left) {
|
|
||||||
panel.style.position = 'fixed';
|
|
||||||
panel.style.left = rect.left + 'px';
|
|
||||||
panel.style.top = rect.top + 'px';
|
|
||||||
panel.style.margin = '0';
|
|
||||||
}
|
|
||||||
startX = e.clientX;
|
|
||||||
startY = e.clientY;
|
|
||||||
startLeft = parseInt(panel.style.left, 10) || rect.left;
|
|
||||||
startTop = parseInt(panel.style.top, 10) || rect.top;
|
|
||||||
|
|
||||||
function onMove(e) {
|
|
||||||
var dx = e.clientX - startX;
|
|
||||||
var dy = e.clientY - startY;
|
|
||||||
panel.style.left = (startLeft + dx) + 'px';
|
|
||||||
panel.style.top = (startTop + dy) + 'px';
|
|
||||||
}
|
|
||||||
function onUp() {
|
|
||||||
document.removeEventListener('mousemove', onMove);
|
|
||||||
document.removeEventListener('mouseup', onUp);
|
|
||||||
}
|
|
||||||
document.addEventListener('mousemove', onMove);
|
|
||||||
document.addEventListener('mouseup', onUp);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Инициализация после загрузки DOM
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
['create-code-modal', 'create-archive-modal', 'edit-modal', 'help-modal', 'invoke-modal', 'logs-modal', 'info-modal'].forEach(makeDraggable);
|
|
||||||
|
|
||||||
// ESC закрывает активное модальное окно
|
|
||||||
document.addEventListener('keydown', function(e) {
|
|
||||||
if (e.key !== 'Escape') return;
|
|
||||||
var modals = ['create-code-modal', 'create-archive-modal', 'edit-modal', 'help-modal', 'invoke-modal', 'logs-modal', 'info-modal'];
|
|
||||||
for (var i = 0; i < modals.length; i++) {
|
|
||||||
var el = document.getElementById(modals[i]);
|
|
||||||
if (el && el.classList.contains('open')) {
|
|
||||||
el.classList.remove('open');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// showModalError — показывает ошибку внутри модалки.
|
|
||||||
// errorDivId — id элемента с классом modal-error.
|
|
||||||
function showModalError(errorDivId, message) {
|
|
||||||
var el = document.getElementById(errorDivId);
|
|
||||||
if (!el) return;
|
|
||||||
el.textContent = message;
|
|
||||||
el.style.display = 'block';
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideModalError(errorDivId) {
|
|
||||||
var el = document.getElementById(errorDivId);
|
|
||||||
if (el) { el.style.display = 'none'; el.textContent = ''; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function openHelp() {
|
function openHelp() {
|
||||||
document.getElementById('help-modal').classList.add('open');
|
document.getElementById('help-modal').classList.add('open');
|
||||||
}
|
}
|
||||||
@@ -133,7 +57,8 @@ async function submitInvoke() {
|
|||||||
metaEl.textContent = [
|
metaEl.textContent = [
|
||||||
'HTTP status: ' + (result.status || 'n/a'),
|
'HTTP status: ' + (result.status || 'n/a'),
|
||||||
'Latency: ' + (latencyMs || 'n/a') + ' ms',
|
'Latency: ' + (latencyMs || 'n/a') + ' ms',
|
||||||
'Причина задержки: ' + explainDelay(measuredSeconds)
|
'Причина задержки: ' + explainDelay(measuredSeconds),
|
||||||
|
'Invoke URL: ' + (result.invoke_url || 'n/a')
|
||||||
].join('\n');
|
].join('\n');
|
||||||
respEl.value = JSON.stringify(result, null, 2);
|
respEl.value = JSON.stringify(result, null, 2);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -149,106 +74,3 @@ async function submitInvoke() {
|
|||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// openLogs — открывает модалку с логами функции.
|
|
||||||
async function openLogs(name) {
|
|
||||||
S.currentLogs = name;
|
|
||||||
document.getElementById('logs-title').textContent = 'Логи: ' + name;
|
|
||||||
document.getElementById('logs-output').value = 'Загрузка...';
|
|
||||||
document.getElementById('logs-modal').classList.add('open');
|
|
||||||
await _fetchLogs(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeLogs() {
|
|
||||||
document.getElementById('logs-modal').classList.remove('open');
|
|
||||||
S.currentLogs = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshLogs() {
|
|
||||||
if (S.currentLogs) await _fetchLogs(S.currentLogs);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function _fetchLogs(name) {
|
|
||||||
var out = document.getElementById('logs-output');
|
|
||||||
try {
|
|
||||||
var data = await getJSON(API_BASE + '/functions/' + encodeURIComponent(name) + '/logs');
|
|
||||||
out.value = data.logs || '(нет логов)';
|
|
||||||
// прокручиваем вниз
|
|
||||||
out.scrollTop = out.scrollHeight;
|
|
||||||
} catch (e) {
|
|
||||||
out.value = 'Ошибка загрузки логов: ' + e.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// openInfo — открывает модалку с подробной информацией о функции.
|
|
||||||
// Данные берутся из data-атрибута кнопки (без дополнительного запроса к API).
|
|
||||||
function openInfo(name, btn) {
|
|
||||||
var raw = btn ? btn.getAttribute('data-info') : null;
|
|
||||||
var info = null;
|
|
||||||
try { info = raw ? JSON.parse(raw) : null; } catch(e) {}
|
|
||||||
if (!info) {
|
|
||||||
document.getElementById('info-rows').innerHTML = '<tr><td colspan="2">Нет данных</td></tr>';
|
|
||||||
document.getElementById('info-title').textContent = 'Информация: ' + name;
|
|
||||||
document.getElementById('info-modal').classList.add('open');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// copyBtn — кнопка копирования конкретного значения через data-атрибут (безопасно для любых символов).
|
|
||||||
function copyBtn(val) {
|
|
||||||
if (!val || val === '-') return '';
|
|
||||||
var encoded = val.replace(/&/g, '&').replace(/"/g, '"');
|
|
||||||
return ' <button class="btn ghost" style="padding:1px 6px; font-size:11px;" data-copy="' + encoded + '" onclick="navigator.clipboard.writeText(this.dataset.copy)">📋</button>';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Строки без кнопки копирования (только отображение).
|
|
||||||
function plain(val) {
|
|
||||||
return '<td class="mono" style="word-break:break-all;padding:4px 0;">' + h(val || '-') + '</td>';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Строки с кнопкой копирования (Имя, Пакет, Маршрут — генерируемые или идентификаторы).
|
|
||||||
function withCopy(val) {
|
|
||||||
return '<td class="mono" style="word-break:break-all;padding:4px 0;">' + h(val || '-') + copyBtn(val) + '</td>';
|
|
||||||
}
|
|
||||||
|
|
||||||
function labelTd(label) {
|
|
||||||
return '<td style="color:var(--text-secondary);padding:4px 10px 4px 0;white-space:nowrap;vertical-align:top;">' + h(label) + '</td>';
|
|
||||||
}
|
|
||||||
|
|
||||||
// CURL-строка для внешнего вызова (полная, с токеном из localStorage).
|
|
||||||
var token = localStorage.getItem('auth_token') || '';
|
|
||||||
var method = (Array.isArray(info.methods) && info.methods.length) ? info.methods[0] : 'GET';
|
|
||||||
var externalUrl = window.location.origin + '/fn' + (info.route !== '-' ? info.route : '');
|
|
||||||
var curlFull = 'curl -H "X-Auth-Token: ' + token + '" -X ' + method + ' "' + externalUrl + '"';
|
|
||||||
// В отображении скрываем токен — показываем только метод и URL.
|
|
||||||
var curlDisplay = 'curl ... -X ' + method + ' "' + externalUrl + '"';
|
|
||||||
// Храним полную команду в data-атрибуте (HTML-encode), читаем через dataset — безопасно для любых символов.
|
|
||||||
var curlAttr = curlFull.replace(/&/g, '&').replace(/"/g, '"');
|
|
||||||
|
|
||||||
var html = [
|
|
||||||
'<tr>' + labelTd('Имя') + withCopy(info.name) + '</tr>',
|
|
||||||
'<tr>' + labelTd('Окружение') + plain(info.env) + '</tr>',
|
|
||||||
'<tr>' + labelTd('Пакет') + withCopy(info.pkg) + '</tr>',
|
|
||||||
'<tr>' + labelTd('Entrypoint') + plain(info.entrypoint) + '</tr>',
|
|
||||||
'<tr>' + labelTd('Таймаут') + plain(info.timeout ? info.timeout + ' сек' : '-') + '</tr>',
|
|
||||||
'<tr>' + labelTd('Тип источника') + plain(info.sourceType === 'archive' ? '📦 archive' : '📝 code') + '</tr>',
|
|
||||||
'<tr>' + labelTd('Маршрут') + withCopy(info.route) + '</tr>',
|
|
||||||
'<tr>' + labelTd('Методы') + plain(Array.isArray(info.methods) && info.methods.length ? info.methods.join(', ') : '-') + '</tr>',
|
|
||||||
'<tr>' + labelTd('Cron') + plain(info.cron || '-') + '</tr>',
|
|
||||||
'<tr>' + labelTd('Создана') + plain(info.createdAt) + '</tr>',
|
|
||||||
'<tr>' + labelTd('Изменена') + plain(info.updatedAt) + '</tr>',
|
|
||||||
'<tr>' + labelTd('curl') +
|
|
||||||
'<td class="mono" style="word-break:break-all;padding:4px 0;color:var(--text-secondary);">' +
|
|
||||||
h(curlDisplay) +
|
|
||||||
' <button class="btn ghost" style="padding:1px 6px; font-size:11px;" data-curl="' + curlAttr + '" onclick="navigator.clipboard.writeText(this.dataset.curl)">📋</button>' +
|
|
||||||
'</td></tr>',
|
|
||||||
].join('');
|
|
||||||
|
|
||||||
document.getElementById('info-title').textContent = 'Информация: ' + name;
|
|
||||||
document.getElementById('info-rows').innerHTML = html;
|
|
||||||
document.getElementById('info-modal').classList.add('open');
|
|
||||||
}
|
|
||||||
|
|
||||||
// closeInfo — закрывает Info-модалку.
|
|
||||||
function closeInfo() {
|
|
||||||
document.getElementById('info-modal').classList.remove('open');
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
def main(event=None, context=None):
|
def main(event, context):
|
||||||
try:
|
try:
|
||||||
r = requests.get('https://httpbin.org/get', timeout=3)
|
r = requests.get('https://httpbin.org/get', timeout=3)
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
# Изменения в fission-src относительно официального Fission
|
|
||||||
|
|
||||||
Базовый коммит официального Fission: `82e1ff76` (Add npm dependabot rules, 2025-12-16)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Что менялось
|
|
||||||
|
|
||||||
### 1. Ядро — `pkg/utils/`
|
|
||||||
- `namespace_manager.go` (+608 строк) — новый компонент: менеджер namespace. Отслеживает появление/исчезновение namespace с меткой `fission.io/managed=true` в реальном времени без рестарта Fission.
|
|
||||||
- `namespace_manager_model.go` (+181 строк) — модель данных менеджера namespace.
|
|
||||||
- `namespace.go` (+66 строк) — расширена логика работы с namespace.
|
|
||||||
- `serviceaccount.go` (+25 строк) — добавлена функция `EnsureNamespaceSA`: при появлении нового namespace автоматически создаёт ServiceAccount, Role, RoleBinding, необходимые для работы fetcher/builder.
|
|
||||||
|
|
||||||
### 2. Executor — `pkg/executor/`
|
|
||||||
- `multitenant/ns_watcher.go` (+137 строк) — watcher namespace-событий для executor. При появлении namespace с меткой `fission.io/managed=true` — executor начинает обслуживать функции в этом namespace без рестарта.
|
|
||||||
- `executortype/poolmgr/gpm.go` (+69 строк) — poolmgr динамически добавляет новые namespace.
|
|
||||||
- `executortype/newdeploy/newdeploymgr.go` (+61 строка) — аналогично для newdeploy.
|
|
||||||
- `executortype/container/containermgr.go` (+49 строк) — аналогично для container executor.
|
|
||||||
- `executortype/poolmgr/poolpodcontroller.go` (+43 строки) — контроллер подов poolmgr знает о новых namespace.
|
|
||||||
|
|
||||||
### 3. Router — `pkg/router/`
|
|
||||||
- `httpTriggers.go` (+116 строк) — роутер подписывается на ns_watcher, динамически добавляет маршруты для функций в новых namespace.
|
|
||||||
- `namespace_subscriber.go` (+31 строка) — подписка роутера на namespace-события.
|
|
||||||
- `ns_watcher.go` (+32 строки) — watcher namespace для роутера.
|
|
||||||
- `functionReferenceResolver.go` (+11 строк) — резолвер функций знает о мультинеймспейс.
|
|
||||||
|
|
||||||
### 4. BuilderMgr — `pkg/buildermgr/`
|
|
||||||
- `ns_watcher.go` (+32 строки) — watcher namespace для buildermgr.
|
|
||||||
- `namespace_subscriber.go` (+43 строки) — подписка buildermgr на namespace-события.
|
|
||||||
- `envwatcher.go` (+45 строк) — watcher окружений теперь реагирует на новые namespace.
|
|
||||||
- `pkgwatcher.go` (+38 строк) — watcher пакетов — аналогично.
|
|
||||||
|
|
||||||
### 5. RBAC — `deploy/multitenant/rbac.yaml` (+122 строки)
|
|
||||||
- Новые ClusterRole и ClusterRoleBinding для автоматического создания SA/Role/RoleBinding в пользовательских namespace.
|
|
||||||
- `deploy/executor-ns-watcher-rbac.yaml` (+26 строк) — дополнительный RBAC для ns_watcher executor.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Обратная совместимость
|
|
||||||
|
|
||||||
**Полная.** Если в кластере нет namespace с меткой `fission.io/managed=true` — поведение идентично официальному Fission v1.22.0.
|
|
||||||
|
|
||||||
- CRD не изменялись.
|
|
||||||
- API (fission CLI) не изменялся.
|
|
||||||
- Helm-чарт не изменялся.
|
|
||||||
- Существующие функции, env, пакеты работают без изменений.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Как активировать мультитенантность
|
|
||||||
|
|
||||||
Добавить метку на namespace:
|
|
||||||
```bash
|
|
||||||
kubectl label namespace <ns> fission.io/managed=true
|
|
||||||
```
|
|
||||||
|
|
||||||
Fission автоматически начнёт обслуживать функции в этом namespace без рестарта.
|
|
||||||
@@ -1,355 +0,0 @@
|
|||||||
# Полный жизненный цикл функции в Fission Console
|
|
||||||
|
|
||||||
Актуально: **v1.3.56**, май 2026
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Два способа создания функции
|
|
||||||
|
|
||||||
| Способ | Content-Type | Обработчик |
|
|
||||||
|--------|-------------|------------|
|
|
||||||
| **Inline-код** (редактор) | `application/json` | `handleCreateFunction` → `handleCreateFunctionFromCode` |
|
|
||||||
| **Архив** (zip-файл) | `multipart/form-data` | `handleCreateFunction` → `handleCreateFunctionFromArchive` |
|
|
||||||
|
|
||||||
Определяется автоматически по заголовку `Content-Type` в `handleCreateFunction` (handlers.go ~строка 343).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Создание из inline-кода (шаг за шагом)
|
|
||||||
|
|
||||||
### 2.1 UI → Backend
|
|
||||||
|
|
||||||
**Файл:** `console/ui/js/functions.js` → `submitCreateFunction()`
|
|
||||||
|
|
||||||
1. Читает поля: `name`, `language`, `entrypoint`, `route`, `methods`, `timeout`, `code` (из CodeMirror)
|
|
||||||
2. Отправляет `POST /console/api/functions` с `Content-Type: application/json`
|
|
||||||
|
|
||||||
### 2.2 Backend обработка
|
|
||||||
|
|
||||||
**Файл:** `console/internal/api/handlers.go` → `handleCreateFunction` (строка ~343)
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /console/api/functions (JSON)
|
|
||||||
↓
|
|
||||||
Валидация имени (regex ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$, max 57 символов)
|
|
||||||
↓
|
|
||||||
EnsureUserNS → создаёт K8s namespace если не существует
|
|
||||||
↓
|
|
||||||
EnsureEnvironment → создаёт Fission Environment CRD если не существует
|
|
||||||
↓
|
|
||||||
buildDeployArchive(lang, code) → упаковывает код в байты
|
|
||||||
↓
|
|
||||||
buildDeploySpec(ctx, bytes) → uploadToStoragesvc → S3
|
|
||||||
↓
|
|
||||||
Создаёт Fission Package CRD (type: url → S3)
|
|
||||||
↓
|
|
||||||
Создаёт Fission Function CRD (ссылается на Package)
|
|
||||||
↓
|
|
||||||
Создаёт Fission HTTPTrigger CRD (роут)
|
|
||||||
↓
|
|
||||||
Аннотация fission-console/source-type = "code"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.3 buildDeployArchive — упаковка кода
|
|
||||||
|
|
||||||
**Файл:** `console/internal/api/handlers.go` → `buildDeployArchive()`
|
|
||||||
|
|
||||||
| Язык | Что происходит |
|
|
||||||
|------|---------------|
|
|
||||||
| Python | код → сырые байты (*.py файл) |
|
|
||||||
| Node.js | код → zip с `package.json` + `main.js` (ESM wrapper) |
|
|
||||||
| PHP | код → zip с `main.php` |
|
|
||||||
| Ruby | код → zip с `handler.rb` |
|
|
||||||
| Go | отдельный путь: `BuildGoSourceZip` → source package → builder job |
|
|
||||||
|
|
||||||
### 2.4 Entrypoint по умолчанию
|
|
||||||
|
|
||||||
**Файл:** `console/internal/runtime/` → `DefaultEntrypoint(lang)`
|
|
||||||
|
|
||||||
| Язык | Entrypoint по умолчанию |
|
|
||||||
|------|------------------------|
|
|
||||||
| python | `user.main` |
|
|
||||||
| nodejs | `main` |
|
|
||||||
| go | `Handler` |
|
|
||||||
| php | `main` |
|
|
||||||
| ruby | `main` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Создание из архива (шаг за шагом)
|
|
||||||
|
|
||||||
### 3.1 UI → Backend
|
|
||||||
|
|
||||||
**Файл:** `console/ui/js/fn-archive.js` → `submitCreateArchive()`
|
|
||||||
|
|
||||||
1. Читает поля: `name`, `language`, `entrypoint`, `route`, `methods`, `timeout`
|
|
||||||
2. Читает файл из `<input id="ca-archive-file">`
|
|
||||||
3. **Валидация на UI:** файл должен заканчиваться на `.zip` — иначе ошибка до отправки
|
|
||||||
4. Отправляет `POST /console/api/functions` с `Content-Type: multipart/form-data`
|
|
||||||
|
|
||||||
### 3.2 Backend обработка
|
|
||||||
|
|
||||||
**Файл:** `console/internal/api/handlers.go` → `handleCreateFunctionFromArchive()` (строка ~1302)
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /console/api/functions (multipart/form-data)
|
|
||||||
↓
|
|
||||||
ParseMultipartForm (лимит 32 MB)
|
|
||||||
↓
|
|
||||||
Валидация имени
|
|
||||||
↓
|
|
||||||
r.FormFile("archive") → читает байты архива
|
|
||||||
↓
|
|
||||||
⛔ ПРОВЕРКА magic bytes: первые 2 байта должны быть 0x50 0x4B (PK = zip)
|
|
||||||
Если нет → HTTP 400 "загруженный файл не является валидным zip-архивом"
|
|
||||||
↓
|
|
||||||
EnsureUserNS
|
|
||||||
↓
|
|
||||||
EnsureEnvironment (по полю "language")
|
|
||||||
↓
|
|
||||||
buildDeploySpec(ctx, archiveBytes) → uploadToStoragesvc → S3
|
|
||||||
↓
|
|
||||||
Создаёт Fission Package CRD (type: url → S3)
|
|
||||||
↓
|
|
||||||
Создаёт Fission Function CRD
|
|
||||||
↓
|
|
||||||
Создаёт Fission HTTPTrigger CRD
|
|
||||||
↓
|
|
||||||
Аннотация fission-console/source-type = "archive"
|
|
||||||
↓
|
|
||||||
Аннотация fission-console/archive-file = <оригинальное имя файла>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Загрузка в S3 через storagesvc
|
|
||||||
|
|
||||||
**Файл:** `console/internal/api/handlers.go` → `uploadToStoragesvc()`
|
|
||||||
|
|
||||||
```
|
|
||||||
Входные данные: []byte (байты zip-архива)
|
|
||||||
↓
|
|
||||||
Строим multipart/form-data вручную:
|
|
||||||
boundary = "fission" + UnixNano
|
|
||||||
поле: name="uploadfile", filename="archive.zip"
|
|
||||||
Content-Type: application/octet-stream
|
|
||||||
тело: байты архива
|
|
||||||
↓
|
|
||||||
POST http://storagesvc.fission.svc.cluster.local/v1/archive
|
|
||||||
заголовок X-File-Size: <размер байт>
|
|
||||||
↓
|
|
||||||
Storagesvc сохраняет в S3 (bucket: sless-functions)
|
|
||||||
↓
|
|
||||||
Ответ: {"id": "fission/UUID"}
|
|
||||||
↓
|
|
||||||
Возвращаем URL: http://storagesvc.../v1/archive?id=fission/UUID
|
|
||||||
```
|
|
||||||
|
|
||||||
**Если storagesvcURL не задан** → fallback на `type: literal` (base64 в etcd). Нежелательно для больших файлов.
|
|
||||||
|
|
||||||
**Переменная окружения:** `STORAGESVC_URL=http://storagesvc.fission.svc.cluster.local`
|
|
||||||
|
|
||||||
### 4.1 Удаление из S3
|
|
||||||
|
|
||||||
При удалении функции → `deleteFromStoragesvc()`:
|
|
||||||
```
|
|
||||||
DELETE http://storagesvc.../v1/archive?id=fission/UUID
|
|
||||||
```
|
|
||||||
Best-effort: ошибка логируется но не прерывает удаление Function/Package CRD.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Environment — КРИТИЧЕСКИ ВАЖНО
|
|
||||||
|
|
||||||
**Файл:** `console/internal/fission/environment.go` → `EnsureEnvironment()`
|
|
||||||
**Конфиг образов:** `console/internal/model/types.go` → `LangEnvMap`
|
|
||||||
|
|
||||||
### 5.1 Lazy creation
|
|
||||||
|
|
||||||
Environment создаётся **только когда пользователь создаёт первую функцию** на языке X в своём namespace. Не заранее — экономия ресурсов.
|
|
||||||
|
|
||||||
### 5.2 Текущие образы (АКТУАЛЬНО)
|
|
||||||
|
|
||||||
```go
|
|
||||||
var LangEnvMap = map[string]LangEnvDef{
|
|
||||||
"python": {Image: "naeel/fission-python-env:v1.0"}, // ⛔ НЕ МЕНЯТЬ НА ОФИЦИАЛЬНЫЙ
|
|
||||||
"nodejs": {Image: "ghcr.io/fission/node-env"},
|
|
||||||
"go": {Image: "ghcr.io/fission/go-env", BuilderImage: "naeel/go-builder-fast:v1"},
|
|
||||||
"php": {Image: "ghcr.io/fission/php-env"},
|
|
||||||
"ruby": {Image: "ghcr.io/fission/ruby-env"},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.3 ⛔⛔⛔ PYTHON — ТОЛЬКО НАШ ОБРАЗ
|
|
||||||
|
|
||||||
`naeel/fission-python-env:v1.0` содержит патч `inspect.signature`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def _count_required_params(func):
|
|
||||||
# inspect.signature → считает обязательные аргументы
|
|
||||||
|
|
||||||
def userfunc_call(self, *args, **kwargs):
|
|
||||||
required = _count_required_params(self.userfunc)
|
|
||||||
if required == 0: return self.userfunc() # def main():
|
|
||||||
elif required == 1: return self.userfunc(request) # def main(request):
|
|
||||||
elif required >= 2: return self.userfunc(request, {}) # def main(event, context):
|
|
||||||
```
|
|
||||||
|
|
||||||
**Официальный `ghcr.io/fission/python-env`** вызывает `userfunc()` без аргументов → `TypeError: missing 2 required positional arguments` → HTTP 500 для любой функции `def main(event, context)`.
|
|
||||||
|
|
||||||
### 5.4 Если environment уже создан с неправильным образом
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl patch environment -n <NS> console-python-env --type=json \
|
|
||||||
-p '[{"op":"replace","path":"/spec/runtime/image","value":"naeel/fission-python-env:v1.0"}]'
|
|
||||||
```
|
|
||||||
|
|
||||||
Имя environment всегда: `console-<lang>-env` (например `console-python-env`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Линтер (lint-archive)
|
|
||||||
|
|
||||||
**Файл:** `console/internal/api/lint_archive.go` → `handleLintArchive()`
|
|
||||||
|
|
||||||
**Эндпоинт:** `POST /console/api/ai/lint-archive`
|
|
||||||
|
|
||||||
### 6.1 Когда вызывается
|
|
||||||
|
|
||||||
Из UI при выборе файла в форме архива (`fn-archive.js` → `lintArchiveFile(prefix)`).
|
|
||||||
Поля: `archive` (zip), `entrypoint` (опционально), `language` (опционально).
|
|
||||||
|
|
||||||
### 6.2 Порядок проверок
|
|
||||||
|
|
||||||
```
|
|
||||||
1. Размер архива ≤ 100 KB (maxArchiveBytes)
|
|
||||||
2. Суммарный распакованный размер ≤ 100 KB (защита от zip bomb)
|
|
||||||
3. Синтаксический линтер по каждому файлу:
|
|
||||||
.py → python3 -m py_compile
|
|
||||||
.js → node --check
|
|
||||||
.rb → ruby -c
|
|
||||||
.php → php -l
|
|
||||||
4. Проверка language vs расширения файлов в архиве:
|
|
||||||
python → нужен хотя бы один .py
|
|
||||||
nodejs → нужен хотя бы один .js
|
|
||||||
...и т.д.
|
|
||||||
5. Проверка entrypoint (формат "module.function"):
|
|
||||||
→ файл module.py должен быть в архиве
|
|
||||||
→ def <function>(...) должна быть определена в файле
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.3 Ответ
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"results": [
|
|
||||||
{"file": "demo.py", "ok": true},
|
|
||||||
{"file": "utils.py", "ok": false, "output": "SyntaxError: ..."},
|
|
||||||
{"file": "язык", "ok": false, "output": "выбран python, но .py файлов нет"},
|
|
||||||
{"file": "entrypoint", "ok": false, "output": "demo.py найден, но def main не определён"}
|
|
||||||
],
|
|
||||||
"ok": false
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Fission: как функция выполняется (runtime)
|
|
||||||
|
|
||||||
```
|
|
||||||
Пользователь вызывает HTTP URL роута
|
|
||||||
↓
|
|
||||||
Fission Router (JWT auth если включён)
|
|
||||||
↓
|
|
||||||
Executor (poolmgr) — выбирает под из пула
|
|
||||||
↓
|
|
||||||
Fetcher sidecar — скачивает архив из S3:
|
|
||||||
GET http://storagesvc.../v1/archive?id=fission/UUID
|
|
||||||
Распаковывает zip → /userfunc/
|
|
||||||
(для inline Python: кладёт .py как /userfunc/user.py)
|
|
||||||
↓
|
|
||||||
Python runtime pod специализируется:
|
|
||||||
POST /specialize на порт 8888 пода
|
|
||||||
server.py импортирует module из /userfunc/
|
|
||||||
↓
|
|
||||||
Вызов функции:
|
|
||||||
_count_required_params(userfunc) → 0/1/2 аргументов
|
|
||||||
0 → userfunc()
|
|
||||||
1 → userfunc(request)
|
|
||||||
2 → userfunc(request, {})
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Namespace пользователя
|
|
||||||
|
|
||||||
Каждый пользователь → отдельный K8s namespace.
|
|
||||||
|
|
||||||
| Пользователь | Namespace |
|
|
||||||
|-------------|-----------|
|
|
||||||
| chiken | `fission-54a1d63ee5e0921b` |
|
|
||||||
| livetest@test.local | `fission-199e39e44b809ec1` |
|
|
||||||
|
|
||||||
Namespace создаётся при первом обращении через `EnsureUserNS()`.
|
|
||||||
|
|
||||||
Имя environment всегда `console-python-env` (не уникально на пользователя) — каждый пользователь имеет СВОЙ environment в своём namespace.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Роут функции
|
|
||||||
|
|
||||||
Формат по умолчанию: `/<последние 12 символов namespace>/<имя функции>`
|
|
||||||
|
|
||||||
Пример: namespace `fission-54a1d63ee5e0921b` → роут `/d63ee5e0921b/funcname`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Аннотации на Function CRD
|
|
||||||
|
|
||||||
| Аннотация | Значение |
|
|
||||||
|-----------|---------|
|
|
||||||
| `fission-console/source-type` | `code` или `archive` |
|
|
||||||
| `fission-console/archive-file` | оригинальное имя загруженного файла (только для архивов) |
|
|
||||||
| `fission-console/expires-at` | Unix timestamp протухания (если задан TTL) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Что НЕ ДОЛЖНО происходить (зафиксированные баги)
|
|
||||||
|
|
||||||
| Баг | Причина | Последствие |
|
|
||||||
|-----|---------|-------------|
|
|
||||||
| `ghcr.io/fission/python-env` в LangEnvMap | Забыли про наш патч | `def main(event, context)` → 500 |
|
|
||||||
| Загрузка `.py` файла вместо `.zip` | UI не проверял расширение | Fission не может распаковать → 500 |
|
|
||||||
| 0 байт в S3 | Файл не zip (magic bytes ≠ PK) | Fetcher не может распаковать → 500 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Файловая структура кода (консоль)
|
|
||||||
|
|
||||||
```
|
|
||||||
console/
|
|
||||||
cmd/server/ — точка входа (main.go)
|
|
||||||
internal/
|
|
||||||
api/
|
|
||||||
handlers.go — HTTP handlers: CRUD функций, invoke, edit
|
|
||||||
lint_archive.go — POST /ai/lint-archive
|
|
||||||
ai_check.go — LLM-анализ кода через OpenAI-совместимый API
|
|
||||||
server.go — Server struct, маршрутизация
|
|
||||||
auth/ — JWT/Basic auth middleware
|
|
||||||
fission/
|
|
||||||
environment.go — EnsureEnvironment, CleanupEnvironmentIfUnused
|
|
||||||
namespace.go — EnsureUserNS
|
|
||||||
gvr.go — GVR константы для K8s dynamic client
|
|
||||||
model/
|
|
||||||
types.go — LangEnvMap ⚠️ ОБРАЗЫ ЯЗЫКОВ ЗДЕСЬ
|
|
||||||
runtime/
|
|
||||||
python.go — DefaultEntrypoint, BuildPythonZip
|
|
||||||
nodejs.go — BuildJSDeployZip
|
|
||||||
go.go — BuildGoSourceZip
|
|
||||||
ui/
|
|
||||||
js/
|
|
||||||
functions.js — создание/редактирование inline-функций
|
|
||||||
fn-archive.js — создание из архива
|
|
||||||
fn-edit-archive.js — редактирование архивной функции
|
|
||||||
app.js — общая логика, auth, API вызовы
|
|
||||||
```
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
# План тестирования: загрузка функций из архива (Fission Console)
|
|
||||||
|
|
||||||
Дата: 2026-05-04
|
|
||||||
|
|
||||||
## Цели
|
|
||||||
- Проверить корректность загрузки функций из архива для всех поддерживаемых языков (Python, Node.js, Ruby, PHP)
|
|
||||||
- Проверить работу линтера, валидацию entrypoint, соответствие языка, структуру архива
|
|
||||||
- Проверить обработку ошибок, edge-cases, граничные размеры
|
|
||||||
|
|
||||||
## Категории тестов
|
|
||||||
|
|
||||||
### 1. Валидные случаи
|
|
||||||
- [ ] Python: demo.py с def main(), entrypoint demo.main
|
|
||||||
- [ ] Node.js: demo.js с module.exports = main, entrypoint demo.main
|
|
||||||
- [ ] Ruby: demo.rb с def main, entrypoint demo.main
|
|
||||||
- [ ] PHP: demo.php с function main, entrypoint demo.main
|
|
||||||
- [ ] Архив с несколькими файлами (несколько языков, только один выбран)
|
|
||||||
- [ ] Архив с подпапками (файл demo.py внутри src/)
|
|
||||||
- [ ] Entrypoint с нестандартным именем (myfunc.py, entrypoint myfunc.main)
|
|
||||||
|
|
||||||
### 2. Ошибочные случаи
|
|
||||||
- [ ] Нет файла с нужным именем (entrypoint demo.main, а demo.py отсутствует)
|
|
||||||
- [ ] Нет функции с нужным именем (demo.py есть, но нет def main)
|
|
||||||
- [ ] Язык выбран Python, а в архиве только .js
|
|
||||||
- [ ] Язык выбран Node.js, а в архиве только .py
|
|
||||||
- [ ] Entrypoint пустой
|
|
||||||
- [ ] Entrypoint без точки ("main")
|
|
||||||
- [ ] Архив пустой
|
|
||||||
- [ ] Архив > 100 KB
|
|
||||||
- [ ] Суммарный размер файлов > 100 KB (zip bomb)
|
|
||||||
- [ ] Файл с синтаксической ошибкой (Python, JS, Ruby, PHP)
|
|
||||||
|
|
||||||
### 3. Пограничные случаи
|
|
||||||
- [ ] demo.py ровно 100 KB
|
|
||||||
- [ ] Архив с demo.py и demo.js, язык Python, entrypoint demo.main
|
|
||||||
- [ ] Архив с demo.py и demo.js, язык Node.js, entrypoint demo.main
|
|
||||||
- [ ] demo.py с def main(event, context)
|
|
||||||
- [ ] demo.py с def main() и def main2()
|
|
||||||
- [ ] demo.py с def main : (без скобок)
|
|
||||||
- [ ] demo.py с декоратором @fission_entry
|
|
||||||
- [ ] demo.py с не-ASCII именем функции
|
|
||||||
|
|
||||||
### 4. UI/UX
|
|
||||||
- [ ] Проверка сообщений линтера (ошибки, предупреждения, OK)
|
|
||||||
- [ ] Проверка отображения имени архива, entrypoint, языка
|
|
||||||
- [ ] Проверка что после ошибки можно загрузить исправленный архив
|
|
||||||
|
|
||||||
## Автоматизация
|
|
||||||
- Тесты реализовать на Go (backend), Python (pytest), JS (playwright) — по необходимости
|
|
||||||
- Для каждого теста: архив, ожидаемый результат, шаги UI/REST, ожидаемое сообщение
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ход выполнения
|
|
||||||
- [ ] Подготовить тестовые архивы
|
|
||||||
- [ ] Реализовать автотесты (минимум: backend REST, опционально UI)
|
|
||||||
- [ ] Зафиксировать результаты в doc/reports/
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
# Plan: console-python-env — fix function call signature
|
|
||||||
|
|
||||||
Date: 2026-05-04
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
Fission Python env v3 calls user function as `userfunc()` with no arguments
|
|
||||||
(Flask URL path params only, empty for parameterless routes).
|
|
||||||
|
|
||||||
Users writing `def main(event, context)` (AWS Lambda style) get:
|
|
||||||
```
|
|
||||||
TypeError: main() missing 2 required positional arguments: 'event' and 'context'
|
|
||||||
```
|
|
||||||
|
|
||||||
This is a real limitation of `ghcr.io/fission/python-env` — it never supported
|
|
||||||
Lambda-style signatures. The user code is valid Python, the env is inflexible.
|
|
||||||
|
|
||||||
## Solution
|
|
||||||
|
|
||||||
Build custom `naeel/console-python-env` based on `ghcr.io/fission/python-env`.
|
|
||||||
Patch `server.py`: before calling `userfunc`, use `inspect.signature` to count
|
|
||||||
required positional args, fill them with `None` if Fission passes fewer.
|
|
||||||
|
|
||||||
Supported signatures after fix:
|
|
||||||
- `def main()` — standard Fission
|
|
||||||
- `def main(event, context)` — AWS Lambda style (event=None, context=None)
|
|
||||||
- `def main(request)` — single-arg style (request=None)
|
|
||||||
- `def main(*args, **kwargs)` — variadic, works as before
|
|
||||||
|
|
||||||
## Steps
|
|
||||||
|
|
||||||
1. Get `server.py` from running pod
|
|
||||||
2. Apply inspect patch to `userfunc_call`
|
|
||||||
3. Write Dockerfile: `FROM ghcr.io/fission/python-env`, COPY patched server.py
|
|
||||||
4. Build `naeel/console-python-env:v1.0`
|
|
||||||
5. Push to Docker Hub
|
|
||||||
6. Update Environment CRD `console-python-env` in k8s → new runtime image
|
|
||||||
7. Restart poolmgr pod for that env
|
|
||||||
8. Test `aa1` with `def main(event, context)` from archive
|
|
||||||
|
|
||||||
## Documentation (after fix)
|
|
||||||
|
|
||||||
Add to user docs: supported function signatures for Fission Python env.
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
# План: разделение Create на два независимых модуля
|
|
||||||
Ветка: `separate-create-modals`
|
|
||||||
Дата: 2026-05-04
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Цель
|
|
||||||
Разделить одну кнопку «Создать функцию» с переключателем код/архив на два полностью независимых модуля.
|
|
||||||
Никакого общего состояния, никакого общего if/else.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Задачи
|
|
||||||
|
|
||||||
### 1. Создать `console/ui/js/fn-code.js` [ ]
|
|
||||||
Содержит только:
|
|
||||||
- `onLangChangeCode()` — читает `cc-lang`, пишет в `cc-entry`, `cc-code`
|
|
||||||
- `openCreateCode()` — инит формы, открывает `create-code-modal`
|
|
||||||
- `closeCreateCode()` — закрывает `create-code-modal`
|
|
||||||
- `submitCreateCode()` — отправляет JSON `POST /functions`, без архивной ветки
|
|
||||||
|
|
||||||
### 2. Создать `console/ui/js/fn-archive.js` [ ]
|
|
||||||
Содержит только:
|
|
||||||
- `onLangChangeArchive()` — читает `ca-lang`, пишет в `ca-entry`
|
|
||||||
- `openCreateArchive()` — инит формы, открывает `create-archive-modal`
|
|
||||||
- `closeCreateArchive()` — закрывает `create-archive-modal`
|
|
||||||
- `submitCreateArchive()` — отправляет multipart `POST /functions`, без кодовой ветки
|
|
||||||
|
|
||||||
### 3. Изменить `index.html` [ ]
|
|
||||||
- Заменить 1 кнопку «+ Создать функцию» на две:
|
|
||||||
- `+ Из кода` → `openCreateCode()`
|
|
||||||
- `+ Из архива` → `openCreateArchive()`
|
|
||||||
- Добавить `create-code-modal` (prefix `cc-`) — только textarea + AI-кнопки
|
|
||||||
- Добавить `create-archive-modal` (prefix `ca-`) — только file input + lint/explain
|
|
||||||
- Удалить старую `create-modal` с переключателями
|
|
||||||
- Подключить `<script src="js/fn-code.js">` и `<script src="js/fn-archive.js">`
|
|
||||||
- Убрать `<script>` для старой логики создания из `functions.js` (или оставить файл без этих функций)
|
|
||||||
|
|
||||||
### 4. Изменить `console/ui/js/functions.js` [ ]
|
|
||||||
Удалить:
|
|
||||||
- `openCreate()`, `closeCreate()`, `submitCreate()`
|
|
||||||
- `setCodeMode('c', ...)` — только `c`-префикс; `e`-префикс (edit) не трогать
|
|
||||||
- `onLangChange()` → заменяется на `onLangChangeCode()` в fn-code.js
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Что НЕ трогаем
|
|
||||||
- `edit-modal` и весь edit-flow — следующий этап
|
|
||||||
- Backend API — без изменений
|
|
||||||
- Таблица функций — без изменений
|
|
||||||
- `lintArchiveFile()`, `explainArchiveFile()` — остаются в `functions.js`, используются из обоих модулей по prefix
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Прогресс
|
|
||||||
|
|
||||||
| # | Задача | Статус |
|
|
||||||
|---|--------|--------|
|
|
||||||
| 1 | Создать `fn-code.js` | ✅ |
|
|
||||||
| 2 | Создать `fn-archive.js` | ✅ |
|
|
||||||
| 3 | Изменить `index.html` | ✅ |
|
|
||||||
| 4 | Изменить `functions.js` | ✅ |
|
|
||||||
| 5 | rsync + проверка на VM | ✅ |
|
|
||||||
| 6 | docker build + push + apply | ✅ v1.3.46 |
|
|
||||||
| 7 | git commit + push | ✅ ветка separate-create-modals |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Лог изменений
|
|
||||||
- `js/fn-code.js` — создан: `onLangChangeCode`, `openCreateCode`, `closeCreateCode`, `submitCreateCode` (prefix `cc-`)
|
|
||||||
- `js/fn-archive.js` — создан: `onLangChangeArchive`, `openCreateArchive`, `closeCreateArchive`, `submitCreateArchive` (prefix `ca-`)
|
|
||||||
- `js/ai.js` — `showGenPrompt(prefix)` и `aiGenerate(prefix)` теперь принимают prefix (default `'cc'`)
|
|
||||||
- `js/functions.js` — удалены `onLangChange`, `openCreate`, `closeCreate`, `submitCreate`
|
|
||||||
- `index.html` — добавлены `<script>` для fn-code.js и fn-archive.js; кнопка заменена на две (✏️ Из кода / 📦 Из архива); старая `create-modal` заменена на `create-code-modal` (prefix `cc-`) и `create-archive-modal` (prefix `ca-`)
|
|
||||||
- Версия: v1.3.45 → v1.3.46
|
|
||||||
+1
-27
@@ -1,32 +1,6 @@
|
|||||||
# Progress Log
|
# Progress Log
|
||||||
|
|
||||||
## TODO (backlog)
|
## 2026-04-14
|
||||||
|
|
||||||
### Биллинг / статистика для бухгалтерии
|
|
||||||
Цель: считать потреблённые ресурсы по пользователям для тарификации.
|
|
||||||
- Метрики: количество вызовов, время выполнения (ms), CPU/RAM пода
|
|
||||||
- Агрегация по namespace (пользователю) за период (день/месяц)
|
|
||||||
- Хранение истории (сейчас metrics-collector хранит только текущее)
|
|
||||||
- Отчёт: пользователь → вызовы + суммарное время → сумма к оплате
|
|
||||||
- API для бухгалтерии или выгрузка CSV/Excel
|
|
||||||
- Что уже есть: metrics-collector (/metrics endpoint), Fission router логирует вызовы, k8s metrics-server
|
|
||||||
- Примерная модель: X руб/1000 вызовов + Y руб/GB·сек
|
|
||||||
|
|
||||||
### MQ-триггер и Kube-Watch триггер в консоли
|
|
||||||
- Backend: добавить GVR для messagequeuetriggers.fission.io и kuberneteswatchtriggers.fission.io
|
|
||||||
- UI: формы создания по аналогии с cron, счётчики на главной
|
|
||||||
|
|
||||||
### Прочий полезный функционал
|
|
||||||
- Метрики на главной странице (данные уже есть в metrics-collector)
|
|
||||||
- Автообновление логов (polling каждые 3-5 сек)
|
|
||||||
- Копировать маршрут одной кнопкой (📋 рядом с маршрутом)
|
|
||||||
- Статус функции (Ready/NotReady/Error) в таблице
|
|
||||||
- Клонировать функцию (копия с новым именем)
|
|
||||||
- Переменные окружения (env vars) через UI
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### План перед работой
|
### План перед работой
|
||||||
- Зафиксировать, что REST `/v2/*` недоступен в текущем кластере Fission v1.22.1 и MVP идет через Kubernetes CRD API.
|
- Зафиксировать, что REST `/v2/*` недоступен в текущем кластере Fission v1.22.1 и MVP идет через Kubernetes CRD API.
|
||||||
|
|||||||
@@ -2,14 +2,10 @@ import os
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import json
|
import json
|
||||||
import datetime
|
|
||||||
|
|
||||||
COLLECTOR_URL = "http://metrics-collector.fission.svc.cluster.local:8091/metrics"
|
COLLECTOR_URL = "http://metrics-collector.fission.svc.cluster.local:8091/metrics"
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
now = datetime.datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
||||||
print(f"[croned] start {now}", flush=True)
|
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"source": "fission-cron-metrics",
|
"source": "fission-cron-metrics",
|
||||||
"hostname": os.uname().nodename,
|
"hostname": os.uname().nodename,
|
||||||
@@ -22,7 +18,6 @@ def main():
|
|||||||
payload["cpu_load_1m"] = load[0]
|
payload["cpu_load_1m"] = load[0]
|
||||||
payload["cpu_load_5m"] = load[1]
|
payload["cpu_load_5m"] = load[1]
|
||||||
payload["cpu_load_15m"] = load[2]
|
payload["cpu_load_15m"] = load[2]
|
||||||
print(f"[croned] cpu load: 1m={load[0]:.2f} 5m={load[1]:.2f} 15m={load[2]:.2f}", flush=True)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -42,7 +37,6 @@ def main():
|
|||||||
payload["memory_available_mb"] = round(avail, 1)
|
payload["memory_available_mb"] = round(avail, 1)
|
||||||
payload["memory_used_mb"] = round(used, 1)
|
payload["memory_used_mb"] = round(used, 1)
|
||||||
payload["memory_percent"] = round(used / total * 100, 1) if total else 0
|
payload["memory_percent"] = round(used / total * 100, 1) if total else 0
|
||||||
print(f"[croned] mem: used={round(used,1)}MB / {round(total,1)}MB ({round(used/total*100,1) if total else 0}%)", flush=True)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -55,7 +49,6 @@ def main():
|
|||||||
payload["disk_total_gb"] = round(total_gb, 1)
|
payload["disk_total_gb"] = round(total_gb, 1)
|
||||||
payload["disk_free_gb"] = round(free_gb, 1)
|
payload["disk_free_gb"] = round(free_gb, 1)
|
||||||
payload["disk_used_gb"] = round(used_gb, 1)
|
payload["disk_used_gb"] = round(used_gb, 1)
|
||||||
print(f"[croned] disk: used={round(used_gb,1)}GB / {round(total_gb,1)}GB", flush=True)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -75,8 +68,6 @@ def main():
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||||
print(f"[croned] sent OK status={resp.status}", flush=True)
|
|
||||||
return {"ok": True, "status": resp.status}
|
return {"ok": True, "status": resp.status}
|
||||||
except urllib.error.URLError as e:
|
except urllib.error.URLError as e:
|
||||||
print(f"[croned] send ERROR: {e}", flush=True)
|
|
||||||
return {"ok": False, "error": str(e)}
|
return {"ok": False, "error": str(e)}
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
# Отчёт по тесту совместимости кастомного Fission CLI (2026-05-04)
|
|
||||||
|
|
||||||
## 1. Базовые команды
|
|
||||||
- fission --help, version, env/fn/pkg/route/spec list — работают корректно, структура команд и опций стандартная.
|
|
||||||
- CLI видит все environments, функции, пакеты, маршруты, включая кастомные.
|
|
||||||
|
|
||||||
## 2. Создание/обновление/удаление сущностей
|
|
||||||
- env create/update/delete, fn create/update/delete, pkg create/delete, route create/delete — работают штатно.
|
|
||||||
- Все объекты создаются, обновляются, удаляются, отображаются в списках.
|
|
||||||
- Ошибка: --version не поддерживается для env update (так и в официальном CLI).
|
|
||||||
|
|
||||||
## 3. Архивы, S3, storagesvc
|
|
||||||
- fission archive upload <file> — работает, архивы появляются в списке.
|
|
||||||
- Не-zip и 0-байтовые архивы не блокируются на upload, но не проходят валидацию при pkg create (ожидаемо).
|
|
||||||
- fission archive delete --id=<id> — работает, архив удаляется.
|
|
||||||
- fission archive get-url --name <file> — не работает (требует id, а не имя).
|
|
||||||
|
|
||||||
## 4. Namespace и RBAC
|
|
||||||
- Создание env, fn, route в отдельном namespace работает корректно.
|
|
||||||
- Все объекты видны через list с указанием namespace.
|
|
||||||
- Удаление объектов и namespace — без ошибок.
|
|
||||||
|
|
||||||
## 5. Ошибки и edge-cases
|
|
||||||
- Попытка создать env с официальным образом fission/python-env — ошибка (валидация работает).
|
|
||||||
- Попытка загрузить не-zip/0-байтовый архив — upload проходит, но pkg create выдаёт ошибку (валидатор).
|
|
||||||
|
|
||||||
## 6. Итог
|
|
||||||
- Все основные и edge-case сценарии CLI работают как в официальном Fission.
|
|
||||||
- Критичных отличий, багов, несовместимости не обнаружено.
|
|
||||||
- Все тестовые объекты и namespace удалены после теста.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Тест проведён: 2026-05-04
|
|
||||||
CLI: кастомный fission-bundle (v1.22.0, naeel)
|
|
||||||
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
import importlib
|
|
||||||
import inspect
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import signal
|
|
||||||
import sys
|
|
||||||
import json
|
|
||||||
|
|
||||||
from flask import Flask, request, abort
|
|
||||||
from gevent.pywsgi import WSGIServer
|
|
||||||
import bjoern
|
|
||||||
import sentry_sdk
|
|
||||||
from sentry_sdk.integrations.flask import FlaskIntegration
|
|
||||||
|
|
||||||
from flask_sockets import Sockets
|
|
||||||
|
|
||||||
IS_PY2 = (sys.version_info.major == 2)
|
|
||||||
SENTRY_DSN = os.environ.get('SENTRY_DSN', None)
|
|
||||||
SENTRY_RELEASE = os.environ.get('SENTRY_RELEASE', None)
|
|
||||||
USERFUNCVOL = os.environ.get("USERFUNCVOL", "/userfunc")
|
|
||||||
RUNTIME_PORT = int(os.environ.get("RUNTIME_PORT", "8888"))
|
|
||||||
|
|
||||||
if SENTRY_DSN:
|
|
||||||
params = {'dsn': SENTRY_DSN, 'integrations': [FlaskIntegration()]}
|
|
||||||
if SENTRY_RELEASE:
|
|
||||||
params['release'] = SENTRY_RELEASE
|
|
||||||
sentry_sdk.init(**params)
|
|
||||||
|
|
||||||
|
|
||||||
def import_src(path):
|
|
||||||
if IS_PY2:
|
|
||||||
import imp
|
|
||||||
return imp.load_source('mod', path)
|
|
||||||
else:
|
|
||||||
return importlib.machinery.SourceFileLoader('mod', path).load_module()
|
|
||||||
|
|
||||||
|
|
||||||
def store_specialize_info(state):
|
|
||||||
json.dump(state, open(os.path.join(USERFUNCVOL, "state.json"), "w"))
|
|
||||||
|
|
||||||
|
|
||||||
def check_specialize_info_exists():
|
|
||||||
return os.path.exists(os.path.join(USERFUNCVOL, "state.json"))
|
|
||||||
|
|
||||||
|
|
||||||
def read_specialize_info():
|
|
||||||
return json.load(open(os.path.join(USERFUNCVOL, "state.json")))
|
|
||||||
|
|
||||||
|
|
||||||
def remove_specialize_info():
|
|
||||||
os.remove(os.path.join(USERFUNCVOL, "state.json"))
|
|
||||||
|
|
||||||
|
|
||||||
class SignalExit(SystemExit):
|
|
||||||
|
|
||||||
def __init__(self, signo, exccode=1):
|
|
||||||
super(SignalExit, self).__init__(exccode)
|
|
||||||
self.signo = signo
|
|
||||||
|
|
||||||
|
|
||||||
def register_signal_handlers(signal_handler=signal.SIG_DFL):
|
|
||||||
signal.signal(signal.SIGINT, signal_handler)
|
|
||||||
signal.signal(signal.SIGTERM, signal_handler)
|
|
||||||
|
|
||||||
|
|
||||||
def _count_required_params(func):
|
|
||||||
"""Return number of required (non-default) positional parameters."""
|
|
||||||
try:
|
|
||||||
sig = inspect.signature(func)
|
|
||||||
count = 0
|
|
||||||
for p in sig.parameters.values():
|
|
||||||
if p.kind in (inspect.Parameter.VAR_POSITIONAL,
|
|
||||||
inspect.Parameter.VAR_KEYWORD):
|
|
||||||
continue
|
|
||||||
if p.default is inspect.Parameter.empty:
|
|
||||||
count += 1
|
|
||||||
return count
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
class FuncApp(Flask):
|
|
||||||
|
|
||||||
def __init__(self, name, loglevel=logging.DEBUG):
|
|
||||||
super(FuncApp, self).__init__(name)
|
|
||||||
|
|
||||||
self.userfunc = None
|
|
||||||
self.root = logging.getLogger()
|
|
||||||
self.ch = logging.StreamHandler(sys.stdout)
|
|
||||||
|
|
||||||
self.root.setLevel(loglevel)
|
|
||||||
self.ch.setLevel(loglevel)
|
|
||||||
self.ch.setFormatter(
|
|
||||||
logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
|
|
||||||
self.logger.addHandler(self.ch)
|
|
||||||
|
|
||||||
if check_specialize_info_exists():
|
|
||||||
self.logger.info('Found state.json')
|
|
||||||
specialize_info = read_specialize_info()
|
|
||||||
self.userfunc = self._load_v2(specialize_info)
|
|
||||||
self.logger.info('Loaded user function {}'.format(specialize_info))
|
|
||||||
|
|
||||||
def load(self):
|
|
||||||
self.logger.info('/specialize called')
|
|
||||||
self.userfunc = import_src('/userfunc/user').main
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def loadv2(self):
|
|
||||||
specialize_info = request.get_json()
|
|
||||||
if check_specialize_info_exists():
|
|
||||||
self.logger.warning("Found state.json, overwriting")
|
|
||||||
self.userfunc = self._load_v2(specialize_info)
|
|
||||||
store_specialize_info(specialize_info)
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def healthz(self):
|
|
||||||
return "", 200
|
|
||||||
|
|
||||||
def userfunc_call(self, *args, **kwargs):
|
|
||||||
"""
|
|
||||||
Call user function with compatible signature detection:
|
|
||||||
def main() -> main()
|
|
||||||
def main(event, context) -> main(request, {})
|
|
||||||
def main(request) -> main(request)
|
|
||||||
def main(event=None, ...) -> main() (all defaults)
|
|
||||||
"""
|
|
||||||
if self.userfunc is None:
|
|
||||||
self.logger.error('userfunc is None')
|
|
||||||
return abort(500)
|
|
||||||
|
|
||||||
required = _count_required_params(self.userfunc)
|
|
||||||
|
|
||||||
if required == 0:
|
|
||||||
# def main() or def main(event=None, context=None) — call with no args
|
|
||||||
return self.userfunc()
|
|
||||||
elif required == 1:
|
|
||||||
# def main(request) style
|
|
||||||
return self.userfunc(request)
|
|
||||||
elif required >= 2:
|
|
||||||
# def main(event, context) — AWS Lambda / Kubeless style
|
|
||||||
# pass flask request as event, empty dict as context
|
|
||||||
return self.userfunc(request, {})
|
|
||||||
else:
|
|
||||||
return self.userfunc(*args)
|
|
||||||
|
|
||||||
def _load_v2(self, specialize_info):
|
|
||||||
filepath = specialize_info['filepath']
|
|
||||||
handler = specialize_info['functionName']
|
|
||||||
self.logger.info(
|
|
||||||
'specialize called with filepath = "{}" handler = "{}"'.format(
|
|
||||||
filepath, handler))
|
|
||||||
parts = handler.rsplit(".", 1)
|
|
||||||
if len(handler) == 0:
|
|
||||||
moduleName = 'main'
|
|
||||||
funcName = 'main'
|
|
||||||
elif len(parts) == 1:
|
|
||||||
moduleName = 'main'
|
|
||||||
funcName = parts[0]
|
|
||||||
else:
|
|
||||||
moduleName = parts[0]
|
|
||||||
funcName = parts[1]
|
|
||||||
self.logger.debug('moduleName = "{}" funcName = "{}"'.format(
|
|
||||||
moduleName, funcName))
|
|
||||||
|
|
||||||
if os.path.isdir(filepath):
|
|
||||||
sys.path.append(filepath)
|
|
||||||
self.logger.debug('__package__ = "{}"'.format(__package__))
|
|
||||||
if __package__:
|
|
||||||
mod = importlib.import_module(moduleName, __package__)
|
|
||||||
else:
|
|
||||||
mod = importlib.import_module(moduleName)
|
|
||||||
else:
|
|
||||||
mod = import_src(filepath)
|
|
||||||
|
|
||||||
return getattr(mod, funcName)
|
|
||||||
|
|
||||||
def signal_handler(self, signalnum, frame):
|
|
||||||
self.logger.info('Received signal {}'.format(
|
|
||||||
signal.strsignal(signalnum)))
|
|
||||||
if check_specialize_info_exists():
|
|
||||||
self.logger.info('Found state.json, removing')
|
|
||||||
remove_specialize_info()
|
|
||||||
signal.signal(signalnum, signal.SIG_DFL)
|
|
||||||
raise SignalExit(signalnum)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
app = FuncApp(__name__, logging.DEBUG)
|
|
||||||
sockets = Sockets(app)
|
|
||||||
register_signal_handlers(app.signal_handler)
|
|
||||||
|
|
||||||
app.add_url_rule('/specialize', 'load', app.load, methods=['POST'])
|
|
||||||
app.add_url_rule('/v2/specialize', 'loadv2', app.loadv2, methods=['POST'])
|
|
||||||
app.add_url_rule('/healthz', 'healthz', app.healthz, methods=['GET'])
|
|
||||||
app.add_url_rule(
|
|
||||||
'/',
|
|
||||||
'userfunc_call',
|
|
||||||
app.userfunc_call,
|
|
||||||
methods=['GET', 'POST', 'PUT', 'HEAD', 'OPTIONS', 'DELETE'])
|
|
||||||
app.add_url_rule(
|
|
||||||
'/<path:path>',
|
|
||||||
'userfunc_call',
|
|
||||||
app.userfunc_call,
|
|
||||||
methods=['GET', 'POST', 'PUT', 'HEAD', 'OPTIONS', 'DELETE'])
|
|
||||||
sockets.add_url_rule(
|
|
||||||
'/',
|
|
||||||
'userfunc_call',
|
|
||||||
app.userfunc_call,
|
|
||||||
methods=['GET', 'POST', 'PUT', 'HEAD', 'OPTIONS', 'DELETE'])
|
|
||||||
|
|
||||||
if os.environ.get("WSGI_FRAMEWORK") == "GEVENT":
|
|
||||||
app.logger.info("Starting gevent based server")
|
|
||||||
from gevent_ws import WebSocketHandler
|
|
||||||
svc = WSGIServer(('0.0.0.0', RUNTIME_PORT),
|
|
||||||
app,
|
|
||||||
handler_class=WebSocketHandler)
|
|
||||||
svc.serve_forever()
|
|
||||||
else:
|
|
||||||
app.logger.info("Starting bjoern based server")
|
|
||||||
bjoern.run(app, '0.0.0.0', RUNTIME_PORT, reuse_port=True)
|
|
||||||
|
|
||||||
|
|
||||||
main()
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
apiVersion: v2
|
|
||||||
name: fission-console
|
|
||||||
description: Fission Console — web UI and API gateway for Fission serverless platform
|
|
||||||
type: application
|
|
||||||
version: 0.1.0
|
|
||||||
appVersion: "v1.3.56"
|
|
||||||
keywords:
|
|
||||||
- fission
|
|
||||||
- serverless
|
|
||||||
- kubernetes
|
|
||||||
- console
|
|
||||||
home: https://gitea.services.ngcloud.ru/Nail/fission
|
|
||||||
maintainers:
|
|
||||||
- name: naeel
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
Fission Console успешно установлен!
|
|
||||||
|
|
||||||
Адрес: https://{{ .Values.ingress.host }}
|
|
||||||
|
|
||||||
Для проверки:
|
|
||||||
kubectl get pods -n {{ .Release.Namespace }} -l app.kubernetes.io/name=fission-console
|
|
||||||
|
|
||||||
Логи:
|
|
||||||
kubectl logs -n {{ .Release.Namespace }} -l app.kubernetes.io/name=fission-console
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
{{/*
|
|
||||||
Expand the name of the chart.
|
|
||||||
*/}}
|
|
||||||
{{- define "fission-console.name" -}}
|
|
||||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
|
||||||
{{- end }}
|
|
||||||
|
|
||||||
{{/*
|
|
||||||
Common labels
|
|
||||||
*/}}
|
|
||||||
{{- define "fission-console.labels" -}}
|
|
||||||
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }}
|
|
||||||
app.kubernetes.io/name: fission-console
|
|
||||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
|
||||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
|
||||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
|
||||||
{{- end }}
|
|
||||||
|
|
||||||
{{/*
|
|
||||||
Selector labels
|
|
||||||
*/}}
|
|
||||||
{{- define "fission-console.selectorLabels" -}}
|
|
||||||
app.kubernetes.io/name: fission-console
|
|
||||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
|
||||||
{{- end }}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: fission-console
|
|
||||||
namespace: {{ .Release.Namespace }}
|
|
||||||
labels:
|
|
||||||
{{- include "fission-console.labels" . | nindent 4 }}
|
|
||||||
spec:
|
|
||||||
replicas: {{ .Values.replicaCount }}
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
{{- include "fission-console.selectorLabels" . | nindent 6 }}
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
{{- include "fission-console.selectorLabels" . | nindent 8 }}
|
|
||||||
spec:
|
|
||||||
serviceAccountName: fission-console
|
|
||||||
containers:
|
|
||||||
- name: console
|
|
||||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
|
|
||||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
|
||||||
ports:
|
|
||||||
- containerPort: {{ .Values.service.port }}
|
|
||||||
env:
|
|
||||||
- name: FISSION_NAMESPACE
|
|
||||||
value: {{ .Values.fission.namespace | quote }}
|
|
||||||
- name: FISSION_ROUTER_URL
|
|
||||||
value: {{ .Values.fission.routerUrl | quote }}
|
|
||||||
- name: PORT
|
|
||||||
value: {{ .Values.service.port | quote }}
|
|
||||||
- name: FISSION_HTTP_TIMEOUT
|
|
||||||
value: {{ .Values.fission.httpTimeout | quote }}
|
|
||||||
- name: FISSION_INVOKE_TIMEOUT
|
|
||||||
value: {{ .Values.fission.invokeTimeout | quote }}
|
|
||||||
- name: FISSION_AUTH_USERNAME
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: {{ .Values.auth.secretName }}
|
|
||||||
key: {{ .Values.auth.usernameKey }}
|
|
||||||
- name: FISSION_AUTH_PASSWORD
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: {{ .Values.auth.secretName }}
|
|
||||||
key: {{ .Values.auth.passwordKey }}
|
|
||||||
- name: FISSION_STORAGESVC_URL
|
|
||||||
value: {{ .Values.fission.storagesvcUrl | quote }}
|
|
||||||
livenessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: {{ .Values.probes.liveness.path }}
|
|
||||||
port: {{ .Values.service.port }}
|
|
||||||
initialDelaySeconds: {{ .Values.probes.liveness.initialDelaySeconds }}
|
|
||||||
periodSeconds: {{ .Values.probes.liveness.periodSeconds }}
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: {{ .Values.probes.readiness.path }}
|
|
||||||
port: {{ .Values.service.port }}
|
|
||||||
initialDelaySeconds: {{ .Values.probes.readiness.initialDelaySeconds }}
|
|
||||||
periodSeconds: {{ .Values.probes.readiness.periodSeconds }}
|
|
||||||
resources:
|
|
||||||
{{- toYaml .Values.resources | nindent 12 }}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
{{- if .Values.ingress.enabled }}
|
|
||||||
apiVersion: networking.k8s.io/v1
|
|
||||||
kind: Ingress
|
|
||||||
metadata:
|
|
||||||
name: fission-console
|
|
||||||
namespace: {{ .Release.Namespace }}
|
|
||||||
labels:
|
|
||||||
{{- include "fission-console.labels" . | nindent 4 }}
|
|
||||||
annotations:
|
|
||||||
{{- range $key, $value := .Values.ingress.annotations }}
|
|
||||||
{{ $key }}: {{ $value | quote }}
|
|
||||||
{{- end }}
|
|
||||||
{{- if .Values.ingress.tls.certManager.enabled }}
|
|
||||||
cert-manager.io/cluster-issuer: {{ .Values.ingress.tls.certManager.clusterIssuer | quote }}
|
|
||||||
{{- end }}
|
|
||||||
spec:
|
|
||||||
ingressClassName: {{ .Values.ingress.className | quote }}
|
|
||||||
rules:
|
|
||||||
- host: {{ .Values.ingress.host | quote }}
|
|
||||||
http:
|
|
||||||
paths:
|
|
||||||
{{- range .Values.ingress.paths }}
|
|
||||||
- path: {{ .path }}
|
|
||||||
pathType: {{ .pathType }}
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: fission-console
|
|
||||||
port:
|
|
||||||
number: {{ $.Values.service.port }}
|
|
||||||
{{- end }}
|
|
||||||
{{- if .Values.ingress.tls.enabled }}
|
|
||||||
tls:
|
|
||||||
- hosts:
|
|
||||||
- {{ .Values.ingress.host | quote }}
|
|
||||||
secretName: {{ .Values.ingress.tls.secretName | quote }}
|
|
||||||
{{- end }}
|
|
||||||
{{- end }}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
{{- if .Values.rbac.create }}
|
|
||||||
apiVersion: v1
|
|
||||||
kind: ServiceAccount
|
|
||||||
metadata:
|
|
||||||
name: fission-console
|
|
||||||
namespace: {{ .Release.Namespace }}
|
|
||||||
labels:
|
|
||||||
{{- include "fission-console.labels" . | nindent 4 }}
|
|
||||||
---
|
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
|
||||||
kind: ClusterRole
|
|
||||||
metadata:
|
|
||||||
name: {{ .Release.Namespace }}-fission-console
|
|
||||||
labels:
|
|
||||||
{{- include "fission-console.labels" . | nindent 4 }}
|
|
||||||
rules:
|
|
||||||
- apiGroups: [""]
|
|
||||||
resources: ["pods"]
|
|
||||||
verbs: ["get", "list"]
|
|
||||||
- apiGroups: ["apps"]
|
|
||||||
resources: ["deployments"]
|
|
||||||
verbs: ["get", "list", "update", "patch"]
|
|
||||||
- apiGroups: ["fission.io"]
|
|
||||||
resources: ["environments", "packages", "functions", "httptriggers", "timetriggers"]
|
|
||||||
verbs: ["get", "list", "create", "update", "patch", "delete"]
|
|
||||||
---
|
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
|
||||||
kind: ClusterRoleBinding
|
|
||||||
metadata:
|
|
||||||
name: {{ .Release.Namespace }}-fission-console
|
|
||||||
labels:
|
|
||||||
{{- include "fission-console.labels" . | nindent 4 }}
|
|
||||||
subjects:
|
|
||||||
- kind: ServiceAccount
|
|
||||||
name: fission-console
|
|
||||||
namespace: {{ .Release.Namespace }}
|
|
||||||
roleRef:
|
|
||||||
kind: ClusterRole
|
|
||||||
name: {{ .Release.Namespace }}-fission-console
|
|
||||||
apiGroup: rbac.authorization.k8s.io
|
|
||||||
{{- end }}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: fission-console
|
|
||||||
namespace: {{ .Release.Namespace }}
|
|
||||||
labels:
|
|
||||||
{{- include "fission-console.labels" . | nindent 4 }}
|
|
||||||
spec:
|
|
||||||
selector:
|
|
||||||
{{- include "fission-console.selectorLabels" . | nindent 4 }}
|
|
||||||
ports:
|
|
||||||
- port: {{ .Values.service.port }}
|
|
||||||
targetPort: {{ .Values.service.port }}
|
|
||||||
protocol: TCP
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
# ─────────────────────────────────────────────
|
|
||||||
# Fission Console — values.yaml
|
|
||||||
# Все секреты и домен задаются при установке:
|
|
||||||
# helm install ... --set ingress.host=... --set auth.username=... --set auth.password=...
|
|
||||||
# ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
# Образ консоли
|
|
||||||
image:
|
|
||||||
repository: naeel/fission-console
|
|
||||||
tag: "v1.3.56"
|
|
||||||
pullPolicy: Always
|
|
||||||
|
|
||||||
# Количество реплик
|
|
||||||
replicaCount: 1
|
|
||||||
|
|
||||||
# Namespace, в котором работает Fission (не namespace самой консоли)
|
|
||||||
fission:
|
|
||||||
namespace: "default"
|
|
||||||
routerUrl: "http://router.fission.svc.cluster.local"
|
|
||||||
storagesvcUrl: "http://storagesvc.fission.svc.cluster.local"
|
|
||||||
httpTimeout: "30s"
|
|
||||||
invokeTimeout: "60s"
|
|
||||||
|
|
||||||
# Порт контейнера
|
|
||||||
service:
|
|
||||||
port: 8090
|
|
||||||
|
|
||||||
# Аутентификация (секрет router в кластере)
|
|
||||||
# Значения передаются через --set или отдельный values-secrets.yaml (не коммитить!)
|
|
||||||
auth:
|
|
||||||
# Имя Kubernetes Secret, из которого берутся username/password
|
|
||||||
secretName: "router"
|
|
||||||
usernameKey: "username"
|
|
||||||
passwordKey: "password"
|
|
||||||
|
|
||||||
# Ingress
|
|
||||||
ingress:
|
|
||||||
enabled: true
|
|
||||||
className: "nginx"
|
|
||||||
host: "" # ОБЯЗАТЕЛЬНО указать: --set ingress.host=fission.mycompany.ru
|
|
||||||
tls:
|
|
||||||
enabled: true
|
|
||||||
certManager:
|
|
||||||
enabled: true
|
|
||||||
clusterIssuer: "letsencrypt-prod"
|
|
||||||
secretName: "fission-console-tls" # имя TLS-секрета (cert-manager создаст сам)
|
|
||||||
annotations:
|
|
||||||
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
|
|
||||||
# Пути, проксируемые через Ingress
|
|
||||||
# По умолчанию: /, /fn, /console, /cron, /default
|
|
||||||
# Переопределять не нужно в большинстве случаев
|
|
||||||
paths:
|
|
||||||
- path: /
|
|
||||||
pathType: Exact
|
|
||||||
- path: /fn
|
|
||||||
pathType: Prefix
|
|
||||||
- path: /console
|
|
||||||
pathType: Prefix
|
|
||||||
- path: /cron
|
|
||||||
pathType: Prefix
|
|
||||||
- path: /default
|
|
||||||
pathType: Prefix
|
|
||||||
|
|
||||||
# Ресурсы контейнера
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
cpu: 20m
|
|
||||||
memory: 32Mi
|
|
||||||
limits:
|
|
||||||
cpu: 200m
|
|
||||||
memory: 128Mi
|
|
||||||
|
|
||||||
# Liveness/Readiness пробы
|
|
||||||
probes:
|
|
||||||
liveness:
|
|
||||||
path: /health
|
|
||||||
initialDelaySeconds: 5
|
|
||||||
periodSeconds: 20
|
|
||||||
readiness:
|
|
||||||
path: /health
|
|
||||||
initialDelaySeconds: 3
|
|
||||||
periodSeconds: 10
|
|
||||||
|
|
||||||
# RBAC
|
|
||||||
rbac:
|
|
||||||
create: true
|
|
||||||
@@ -24,9 +24,6 @@ func main() {
|
|||||||
_, _ = w.Write([]byte("ok\n"))
|
_, _ = w.Write([]byte("ok\n"))
|
||||||
})
|
})
|
||||||
mux.HandleFunc("/metrics", srv.HandleMetrics)
|
mux.HandleFunc("/metrics", srv.HandleMetrics)
|
||||||
// ⛔ /cron/metrics — внешний путь через ingress /cron → metrics-collector:8091.
|
|
||||||
// ⛔ НЕ УДАЛЯТЬ. Без этого маршрута UI на /cron не получает данные.
|
|
||||||
mux.HandleFunc("/cron/metrics", srv.HandleMetrics)
|
|
||||||
mux.Handle("/", ui.Handler())
|
mux.Handle("/", ui.Handler())
|
||||||
|
|
||||||
httpSrv := &http.Server{
|
httpSrv := &http.Server{
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: metrics-collector
|
- name: metrics-collector
|
||||||
image: naeel/metrics-collector:v0.1.3
|
image: naeel/metrics-collector:v0.1.0
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 8091
|
- containerPort: 8091
|
||||||
env:
|
env:
|
||||||
@@ -54,36 +54,3 @@ spec:
|
|||||||
ports:
|
ports:
|
||||||
- port: 8091
|
- port: 8091
|
||||||
targetPort: 8091
|
targetPort: 8091
|
||||||
---
|
|
||||||
# ⛔ АРХИТЕКТУРА: три независимые сущности:
|
|
||||||
# 1. croned (функция) — шлёт метрики POST /metrics → metrics-collector:8091
|
|
||||||
# 2. metrics-collector (этот сервис) — хранит и отдаёт метрики, UI на /cron
|
|
||||||
# 3. fission-console — управление функциями, НЕ ЗНАЕТ про метрики
|
|
||||||
#
|
|
||||||
# ⛔ НЕ ПЕРЕНОСИТЬ /cron в ingress fission-console — это ломает архитектуру.
|
|
||||||
# ⛔ НЕ УБИРАТЬ этот Ingress — без него /cron страница недоступна снаружи.
|
|
||||||
apiVersion: networking.k8s.io/v1
|
|
||||||
kind: Ingress
|
|
||||||
metadata:
|
|
||||||
name: metrics-collector
|
|
||||||
namespace: fission
|
|
||||||
annotations:
|
|
||||||
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
|
|
||||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
|
||||||
spec:
|
|
||||||
ingressClassName: nginx
|
|
||||||
rules:
|
|
||||||
- host: fission.kube5s.ru
|
|
||||||
http:
|
|
||||||
paths:
|
|
||||||
- path: /cron
|
|
||||||
pathType: Prefix
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: metrics-collector
|
|
||||||
port:
|
|
||||||
number: 8091
|
|
||||||
tls:
|
|
||||||
- hosts:
|
|
||||||
- fission.kube5s.ru
|
|
||||||
secretName: fission-tls
|
|
||||||
|
|||||||
@@ -228,7 +228,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const API = '/cron/metrics';
|
const API = '/metrics';
|
||||||
const summaryEl = document.getElementById('summary');
|
const summaryEl = document.getElementById('summary');
|
||||||
const historyBody = document.getElementById('history-body');
|
const historyBody = document.getElementById('history-body');
|
||||||
const lastUpdatedEl = document.getElementById('last-updated');
|
const lastUpdatedEl = document.getElementById('last-updated');
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
FROM ghcr.io/fission/python-env:latest
|
|
||||||
COPY server.py /app/server.py
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
import importlib
|
|
||||||
import inspect
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import signal
|
|
||||||
import sys
|
|
||||||
import json
|
|
||||||
|
|
||||||
from flask import Flask, request, abort
|
|
||||||
from gevent.pywsgi import WSGIServer
|
|
||||||
import bjoern
|
|
||||||
import sentry_sdk
|
|
||||||
from sentry_sdk.integrations.flask import FlaskIntegration
|
|
||||||
|
|
||||||
from flask_sockets import Sockets
|
|
||||||
|
|
||||||
IS_PY2 = (sys.version_info.major == 2)
|
|
||||||
SENTRY_DSN = os.environ.get('SENTRY_DSN', None)
|
|
||||||
SENTRY_RELEASE = os.environ.get('SENTRY_RELEASE', None)
|
|
||||||
USERFUNCVOL = os.environ.get("USERFUNCVOL", "/userfunc")
|
|
||||||
RUNTIME_PORT = int(os.environ.get("RUNTIME_PORT", "8888"))
|
|
||||||
|
|
||||||
if SENTRY_DSN:
|
|
||||||
params = {'dsn': SENTRY_DSN, 'integrations': [FlaskIntegration()]}
|
|
||||||
if SENTRY_RELEASE:
|
|
||||||
params['release'] = SENTRY_RELEASE
|
|
||||||
sentry_sdk.init(**params)
|
|
||||||
|
|
||||||
|
|
||||||
def import_src(path):
|
|
||||||
if IS_PY2:
|
|
||||||
import imp
|
|
||||||
return imp.load_source('mod', path)
|
|
||||||
else:
|
|
||||||
return importlib.machinery.SourceFileLoader('mod', path).load_module()
|
|
||||||
|
|
||||||
|
|
||||||
def store_specialize_info(state):
|
|
||||||
json.dump(state, open(os.path.join(USERFUNCVOL, "state.json"), "w"))
|
|
||||||
|
|
||||||
|
|
||||||
def check_specialize_info_exists():
|
|
||||||
return os.path.exists(os.path.join(USERFUNCVOL, "state.json"))
|
|
||||||
|
|
||||||
|
|
||||||
def read_specialize_info():
|
|
||||||
return json.load(open(os.path.join(USERFUNCVOL, "state.json")))
|
|
||||||
|
|
||||||
|
|
||||||
def remove_specialize_info():
|
|
||||||
os.remove(os.path.join(USERFUNCVOL, "state.json"))
|
|
||||||
|
|
||||||
|
|
||||||
class SignalExit(SystemExit):
|
|
||||||
|
|
||||||
def __init__(self, signo, exccode=1):
|
|
||||||
super(SignalExit, self).__init__(exccode)
|
|
||||||
self.signo = signo
|
|
||||||
|
|
||||||
|
|
||||||
def register_signal_handlers(signal_handler=signal.SIG_DFL):
|
|
||||||
signal.signal(signal.SIGINT, signal_handler)
|
|
||||||
signal.signal(signal.SIGTERM, signal_handler)
|
|
||||||
|
|
||||||
|
|
||||||
def _count_required_params(func):
|
|
||||||
"""Return number of required (non-default) positional parameters."""
|
|
||||||
try:
|
|
||||||
sig = inspect.signature(func)
|
|
||||||
count = 0
|
|
||||||
for p in sig.parameters.values():
|
|
||||||
if p.kind in (inspect.Parameter.VAR_POSITIONAL,
|
|
||||||
inspect.Parameter.VAR_KEYWORD):
|
|
||||||
continue
|
|
||||||
if p.default is inspect.Parameter.empty:
|
|
||||||
count += 1
|
|
||||||
return count
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
class FuncApp(Flask):
|
|
||||||
|
|
||||||
def __init__(self, name, loglevel=logging.DEBUG):
|
|
||||||
super(FuncApp, self).__init__(name)
|
|
||||||
|
|
||||||
self.userfunc = None
|
|
||||||
self.root = logging.getLogger()
|
|
||||||
self.ch = logging.StreamHandler(sys.stdout)
|
|
||||||
|
|
||||||
self.root.setLevel(loglevel)
|
|
||||||
self.ch.setLevel(loglevel)
|
|
||||||
self.ch.setFormatter(
|
|
||||||
logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
|
|
||||||
self.logger.addHandler(self.ch)
|
|
||||||
|
|
||||||
if check_specialize_info_exists():
|
|
||||||
self.logger.info('Found state.json')
|
|
||||||
specialize_info = read_specialize_info()
|
|
||||||
self.userfunc = self._load_v2(specialize_info)
|
|
||||||
self.logger.info('Loaded user function {}'.format(specialize_info))
|
|
||||||
|
|
||||||
def load(self):
|
|
||||||
self.logger.info('/specialize called')
|
|
||||||
self.userfunc = import_src('/userfunc/user').main
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def loadv2(self):
|
|
||||||
specialize_info = request.get_json()
|
|
||||||
if check_specialize_info_exists():
|
|
||||||
self.logger.warning("Found state.json, overwriting")
|
|
||||||
self.userfunc = self._load_v2(specialize_info)
|
|
||||||
store_specialize_info(specialize_info)
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def healthz(self):
|
|
||||||
return "", 200
|
|
||||||
|
|
||||||
def userfunc_call(self, *args, **kwargs):
|
|
||||||
"""
|
|
||||||
Call user function with compatible signature detection:
|
|
||||||
def main() -> main()
|
|
||||||
def main(event, context) -> main(request, {})
|
|
||||||
def main(request) -> main(request)
|
|
||||||
def main(event=None, ...) -> main() (all defaults)
|
|
||||||
"""
|
|
||||||
if self.userfunc is None:
|
|
||||||
self.logger.error('userfunc is None')
|
|
||||||
return abort(500)
|
|
||||||
|
|
||||||
required = _count_required_params(self.userfunc)
|
|
||||||
|
|
||||||
if required == 0:
|
|
||||||
# def main() or def main(event=None, context=None) — call with no args
|
|
||||||
return self.userfunc()
|
|
||||||
elif required == 1:
|
|
||||||
# def main(request) style
|
|
||||||
return self.userfunc(request)
|
|
||||||
elif required >= 2:
|
|
||||||
# def main(event, context) — AWS Lambda / Kubeless style
|
|
||||||
# pass flask request as event, empty dict as context
|
|
||||||
return self.userfunc(request, {})
|
|
||||||
else:
|
|
||||||
return self.userfunc(*args)
|
|
||||||
|
|
||||||
def _load_v2(self, specialize_info):
|
|
||||||
filepath = specialize_info['filepath']
|
|
||||||
handler = specialize_info['functionName']
|
|
||||||
self.logger.info(
|
|
||||||
'specialize called with filepath = "{}" handler = "{}"'.format(
|
|
||||||
filepath, handler))
|
|
||||||
parts = handler.rsplit(".", 1)
|
|
||||||
if len(handler) == 0:
|
|
||||||
moduleName = 'main'
|
|
||||||
funcName = 'main'
|
|
||||||
elif len(parts) == 1:
|
|
||||||
moduleName = 'main'
|
|
||||||
funcName = parts[0]
|
|
||||||
else:
|
|
||||||
moduleName = parts[0]
|
|
||||||
funcName = parts[1]
|
|
||||||
self.logger.debug('moduleName = "{}" funcName = "{}"'.format(
|
|
||||||
moduleName, funcName))
|
|
||||||
|
|
||||||
if os.path.isdir(filepath):
|
|
||||||
sys.path.append(filepath)
|
|
||||||
self.logger.debug('__package__ = "{}"'.format(__package__))
|
|
||||||
if __package__:
|
|
||||||
mod = importlib.import_module(moduleName, __package__)
|
|
||||||
else:
|
|
||||||
mod = importlib.import_module(moduleName)
|
|
||||||
else:
|
|
||||||
mod = import_src(filepath)
|
|
||||||
|
|
||||||
return getattr(mod, funcName)
|
|
||||||
|
|
||||||
def signal_handler(self, signalnum, frame):
|
|
||||||
self.logger.info('Received signal {}'.format(
|
|
||||||
signal.strsignal(signalnum)))
|
|
||||||
if check_specialize_info_exists():
|
|
||||||
self.logger.info('Found state.json, removing')
|
|
||||||
remove_specialize_info()
|
|
||||||
signal.signal(signalnum, signal.SIG_DFL)
|
|
||||||
raise SignalExit(signalnum)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
app = FuncApp(__name__, logging.DEBUG)
|
|
||||||
sockets = Sockets(app)
|
|
||||||
register_signal_handlers(app.signal_handler)
|
|
||||||
|
|
||||||
app.add_url_rule('/specialize', 'load', app.load, methods=['POST'])
|
|
||||||
app.add_url_rule('/v2/specialize', 'loadv2', app.loadv2, methods=['POST'])
|
|
||||||
app.add_url_rule('/healthz', 'healthz', app.healthz, methods=['GET'])
|
|
||||||
app.add_url_rule(
|
|
||||||
'/',
|
|
||||||
'userfunc_call',
|
|
||||||
app.userfunc_call,
|
|
||||||
methods=['GET', 'POST', 'PUT', 'HEAD', 'OPTIONS', 'DELETE'])
|
|
||||||
app.add_url_rule(
|
|
||||||
'/<path:path>',
|
|
||||||
'userfunc_call',
|
|
||||||
app.userfunc_call,
|
|
||||||
methods=['GET', 'POST', 'PUT', 'HEAD', 'OPTIONS', 'DELETE'])
|
|
||||||
sockets.add_url_rule(
|
|
||||||
'/',
|
|
||||||
'userfunc_call',
|
|
||||||
app.userfunc_call,
|
|
||||||
methods=['GET', 'POST', 'PUT', 'HEAD', 'OPTIONS', 'DELETE'])
|
|
||||||
|
|
||||||
if os.environ.get("WSGI_FRAMEWORK") == "GEVENT":
|
|
||||||
app.logger.info("Starting gevent based server")
|
|
||||||
from gevent_ws import WebSocketHandler
|
|
||||||
svc = WSGIServer(('0.0.0.0', RUNTIME_PORT),
|
|
||||||
app,
|
|
||||||
handler_class=WebSocketHandler)
|
|
||||||
svc.serve_forever()
|
|
||||||
else:
|
|
||||||
app.logger.info("Starting bjoern based server")
|
|
||||||
bjoern.run(app, '0.0.0.0', RUNTIME_PORT, reuse_port=True)
|
|
||||||
|
|
||||||
|
|
||||||
main()
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5qc1VUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAbW9kdWxlLmV4cG9ydHMgPSBmdW5jdGlvbiBub3RNYWluKCkgeyByZXR1cm4gIk5vdCBNYWluIjt9ClBLAQIUABQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAAAAAAAAAAACkgQAAAABkZW1vLmpzVVQFAANCBBt1eAsAAQT1AQAABBwAAABQSwUGAAAAAAEAAQBOAAAAUQAAAAAA
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5qc1VUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAbW9kdWxlLmV4cG9ydHMgPSBmdW5jdGlvbiBtYWluKCkgeyByZXR1cm4gIkphdmFTY3JpcHQgT0siOyB9CmZ1bmN0aW9uIGJhZCgpIHJldHVybiAiZXJyb3IiOwpQSwECFAAUAAgArVvqVAAAAAAAAAAAAAAAAAkABAAAAAAAAAAAApIEAAAAAZGVtby5qc1VUBQADQgQbdXgLAAEE9QEAAAQcAAAAUEsFBgAAAAABAAEATgAAAFEAAAAAAA==
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5qc1VUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAbW9kdWxlLmV4cG9ydHMgPSBmdW5jdGlvbiBtYWluKCkgeyByZXR1cm4gIkphdmFTY3JpcHQgT0siOyB9ClBLAQIUABQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAAAAAAAAAAACkgQAAAABkZW1vLmpzVVQFAANCBBt1eAsAAQT1AQAABBwAAABQSwUGAAAAAAEAAQBOAAAAUQAAAAAA
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAKAAQAZGVtby5waHBVVAkAA0MEG2dDBRtnYXgLAAEE9QEAAAQcADw/cGhwCmlmKCFmdW5jdGlvbl9leGlzdHMoIm1haW4iKSkgewogIGZ1bmN0aW9uIG1haW4oKSB7IHJldHVybiAiUEhQIG9rIjsgfQp9ClBLAQIUABQAAAAIAK1b6lQAAAAAAAAAAAAAAAAKAAQAAAAAAAAAAACkgQAAAABkZW1vLnBocFVUBQADQgQbd2F4CwABBPUBAAAEHAAAAFBLAwQUAAAACACtW+pUAAAAAAAAAAAAAAAAAAwAAAHB5LWhlbGxvLnB5VVQJAANCBBtnQwUbZ3V4CwABBPUBAAAEHABkZWYgbWFpbigpOgogICAgcmV0dXJuICJIZWxsbyBvayIKClBLAQIUABQAAAAIAK1b6lQAAAAAAAAAAAAAAAAADAAAAAAAAAAAAAAAACkgQAAAHB5LWhlbGxvLnB5VVQFAANCBBt1eAsAAQT1AQAABBwAAABQSwUGAAAAAAEAAQBOAAAAWgAAAAAA
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5weVVUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwA
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5weVVUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAZGVmIG1haW4yKCk6CiAgICByZXR1cm4gIk5vdCBNYWluIgoKUEsBAhQAFAAAAAgArVvqVAAAAAAAAAAAAAAAAAkABAAAAAAAAAAAApIEAAAAAZGVtby5weVVUBQADQgQbdXgLAAEE9QEAAAQcAAAAUEsFBgAAAAABAAEATgAAAFEAAAAAAA==
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5weVVUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAZGVmIG1haW4oKToKICAgIHJldHVybiAiS29ycmVrdCIKZGVmIGJhZCgpCiAgICByZXR1cm4gIkVycm9yIgoKUEsBAhQAFAAAAAgArVvqVAAAAAAAAAAAAAAAAAkABAAAAAAAAAAAApIEAAAAAZGVtby5weVVUBQADQgQbdXgLAAEE9QEAAAQcAAAAUEsFBgAAAAABAAEATgAAAFEAAAAAAA==
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5weVVUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAZGVmIG1haW4oKToKICAgIHJldHVybiAiS29ycmVrdCIKZGVmIGJhZCgpCiAgICByZXR1cm4gIkVycm9yIgoKUEsBAhQAFAAAAAgArVvqVAAAAAAAAAAAAAAAAAkABAAAAAAAAAAAApIEAAAAAZGVtby5weVVUBQADQgQbdXgLAAEE9QEAAAQcAAAAUEsFBgAAAAABAAEATgAAAFEAAAAAAA==
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5weVVUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAY29kZSA9ICJkZWNvcmF0b3IiCkBkZWNvcmF0b3IKZGVmIG1haW4oKToKICAgIHJldHVybiAiZGVjb3JhdG9yIgoKUEsBAhQAFAAAAAgArVvqVAAAAAAAAAAAAAAAAAkABAAAAAAAAAAAApIEAAAAAZGVtby5weVVUBQADQgQbdXgLAAEE9QEAAAQcAAAAUEsFBgAAAAABAAEATgAAAFEAAAAAAA==
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5weVVUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAZGVmIG1haW46CiAgICByZXR1cm4gIkNvbG9uIgoKUEsBAhQAFAAAAAgArVvqVAAAAAAAAAAAAAAAAAkABAAAAAAAAAAAApIEAAAAAZGVtby5weVVUBQADQgQbdXgLAAEE9QEAAAQcAAAAUEsFBgAAAAABAAEATgAAAFEAAAAAAA==
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5weVVUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAZGVmIG1haW4oKToKICAgIHJldHVybiAiS29ycmVrdCIKZGVmIG1haW4yKCk6CiAgICByZXR1cm4gIk1haW4yIgoKUEsBAhQAFAAAAAgArVvqVAAAAAAAAAAAAAAAAAkABAAAAAAAAAAAApIEAAAAAZGVtby5weVVUBQADQgQbdXgLAAEE9QEAAAQcAAAAUEsFBgAAAAABAAEATgAAAFEAAAAAAA==
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5weVVUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAY29kZSA9ICLQnNCw0YDQtdC00L7QvCIKZGVmIM6x0L7RgNC+0LkgKCk6CiAgICByZXR1cm4gIm9rIgogClBLAQIUABQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAAAAAAAAAAACkgQAAAABkZW1vLnB5VVQFAANCBBt1eAsAAQT1AQAABBwAAABQSwUGAAAAAAEAAQBOAAAAUQAAAAAA
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5weVVUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwA
|
|
||||||
ZGVmIG1haW4oKToKICAgIHJldHVybiAiS29ycmVrdCIKClBLAQIUABQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAAAAAAAAAAACkgQAAAABkZW1vLnB5VVQFAANCBBt1eAsAAQT1AQAABBwAAABQSwUGAAAAAAEAAQBOAAAAUQAAAAAA
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAwAAABzcmMvZGVtby5weVVUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAZGVmIG1haW4oKToKICAgIHJldHVybiAiU3ViZGlyIgoKUEsBAhQAFAAAAAgArVvqVAAAAAAAAAAAAAAAAAwAAAAAAAAAAAAAAACkgQAAAHNyYy9kZW1vLnB5VVQFAANCBBt1eAsAAQT1AQAABBwAAABQSwUGAAAAAAEAAQBOAAAAUQAAAAAA
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5yYlVUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAZGVmIG1haW4KICAgICJydWJ5IG9rIgogClBLAQIUABQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAAAAAAAAAAACkgQAAAABkZW1vLnJiVVQFAANCBBt1eAsAAQT1AQAABBwAAABQSwUGAAAAAAEAAQBOAAAAUQAAAAAA
|
|
||||||
Reference in New Issue
Block a user