Compare commits

...
Author SHA1 Message Date
“Naeel” 810980cfcd fix: v1.3.69 — Info modal curl copy: use data-attr to fix broken onclick with quotes 2026-05-08 14:09:07 +04:00
“Naeel” c9b5867373 feat: v1.3.68 — Info modal: copy buttons only on name/pkg/route, add curl copy field 2026-05-08 14:01:55 +04:00
“Naeel” 8f4659d2ef feat: v1.3.67 — UI table refactor: remove Env/Pkg columns, add Type icon + Info modal 2026-05-08 09:19:01 +04:00
“Naeel” f9bb898893 release: v1.3.66 — refactor deploy 2026-05-08 09:03:52 +04:00
“Naeel” 7cd8a7ec12 chore: misc local changes (server.go, croned.py) 2026-05-08 09:00:58 +04:00
“Naeel” f42fa366de refactor: split handlers.go into focused modules
handlers.go (267 lines) — routing, constants, handleAuth, utils
storagesvc.go — S3 upload/delete via storagesvc
function_code.go — create/update function from inline code
function_archive.go — create/update function from zip archive
function_crud.go — GET, DELETE, timeout update, pod logs
function_invoke.go — invoke via router, /fn route gateway, cron gateway

No logic changes. go build OK.
2026-05-08 09:00:25 +04:00
“Naeel” 7f15a2c61d doc: TODO — биллинг, MQ-триггеры, метрики, прочий функционал 2026-05-07 21:48:30 +04:00
“Naeel” 3960b55181 feat: полный user-friendly промпт для LLM-ассистента — UI, кнопки, таблица, cron, примеры (v1.3.65) 2026-05-07 21:33:02 +04:00
“Naeel” fcce5d3b50 fix: chat system prompt — user-friendly, без внутренней кухни (v1.3.64) 2026-05-07 21:26:36 +04:00
“Naeel” 10cf4783a3 fix: logs — брать имя контейнера из pod.Spec.Containers[0] (v1.3.63) 2026-05-07 21:04:23 +04:00
“Naeel” af6e7354d6 fix: logs — убрать хардкод user-container, взять первый контейнер пода (v1.3.62) 2026-05-07 20:58:23 +04:00
“Naeel” 9b0089e15a feat: logs modal + ESC close + /functions/:name/logs endpoint (v1.3.61) 2026-05-07 20:51:02 +04:00
“Naeel” a9bd2e93c7 rules: запрет самодеятельных изменений рабочего кода — комментарии + правила 2026-05-07 20:39:24 +04:00
“Naeel” 87a46acc48 v1.3.60: debug logging in extractPackageSourceCode, fix edit modal (no mode toggle) 2026-05-07 20:28:57 +04:00
“Naeel” 817ee40fd2 v1.3.59: add logging to extractPackageSourceCode for debug 2026-05-07 20:14:12 +04:00
“Naeel” bd507db3ae v1.3.59: restore edit modal mode toggle — code/archive buttons 2026-05-07 20:08:10 +04:00
“Naeel” a9a81ce403 fix: правильный ingress /cron → metrics-collector, чистый console v1.3.58, запрет костылей 2026-05-07 12:06:27 +04:00
“Naeel” 72004dd943 doc: описание изменений fission-src vs официальный Fission (multi-tenant) 2026-05-04 15:39:53 +04:00
“Naeel” 796a938aa3 feat: helm chart для fission-console (без хардкодов, параметризован) 2026-05-04 12:17:09 +04:00
34 changed files with 2414 additions and 1441 deletions
+12
View File
@@ -13,6 +13,18 @@
3. ЖДАТЬ следующей команды
**ЗАПРЕЩЕНО** начинать работу, писать код, запускать команды — без явного "делай".
## ⛔⛔⛔ НЕ ТРОГАТЬ РАБОЧИЙ КОД — АБСОЛЮТНЫЙ ЗАПРЕТ НАВСЕГДА
**НИКАКИХ самодеятельных изменений рабочего кода:**
- Никаких "оптимизаций", "улучшений", "рефакторинга" без команды
- Никаких новых фич без явного разрешения
- Никаких helm upgrade, kubectl patch и прочих инфраструктурных изменений без команды
- Перед ЛЮБЫМ изменением рабочего кода — объяснить ЗАЧЕМ и ждать "делай"
**ПРЕЦЕДЕНТЫ:**
- v1.3.49: убрали mode toggle в edit modal "для улучшения" → сломали рабочий редактор
- helm upgrade попытка → сломал JWT secret router → 401 у всех функций пользователей
1. Не трогать рабочий код без явного указания.
2. Файлы редактируются локально:
+7
View File
@@ -91,6 +91,13 @@ rsync -az -e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no" \
**Никогда не разбираться с результатами по памяти / буферу / чату. Только лог.**
## ⛔ РУЧНЫЕ ПАТЧИ — ЗАПРЕЩЕНЫ
- НИКОГДА не применять ручные патчи (`kubectl patch`, `kubectl apply` отдельных полей, `python -c` замены в yaml и т.д.) без явного указания.
- Все изменения — только через код (Helm chart, YAML, Go-код) + сборка + деплой.
- Ручной патч слетает при следующем helm upgrade/redeploy → регрессия.
- Исключение: только если пользователь явно написал "примени ручной патч".
## Поведение агента
- Не трогать рабочий код без явного указания
+7
View File
@@ -14,6 +14,7 @@ import (
"fission-console/internal/auth"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
)
@@ -37,6 +38,11 @@ func main() {
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{
JWT: auth.NewDeckAuthenticator(auth.DefaultDeckAPIs, nil),
Demo: &auth.DemoAuthenticator{},
@@ -44,6 +50,7 @@ func main() {
srv := api.NewServer(api.Config{
Dyn: dyn,
Kube: kube,
Namespace: namespace,
RouterURL: routerURL,
HTTPTimeout: httpTimeout,
+6 -8
View File
@@ -12,6 +12,9 @@ rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list"]
- apiGroups: [""]
resources: ["pods/log"]
verbs: ["get"]
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "update", "patch"]
@@ -52,7 +55,7 @@ spec:
serviceAccountName: fission-console
containers:
- name: console
image: naeel/fission-console:v1.3.56
image: naeel/fission-console:v1.3.69
imagePullPolicy: Always
ports:
- containerPort: 8090
@@ -146,13 +149,8 @@ spec:
name: fission-console
port:
number: 8090
- path: /cron
pathType: Prefix
backend:
service:
name: fission-console
port:
number: 8090
# ⛔ /cron НЕ ПРИНАДЛЕЖИТ console. Ingress /cron → metrics-collector:8091 (metrics-collector/deploy/metrics-collector.yaml)
# ⛔ НЕ ДОБАВЛЯТЬ /cron сюда — это сломает независимость сервисов.
tls:
- hosts:
- fission.kube5s.ru
+4 -1
View File
@@ -3,12 +3,15 @@ module fission-console
go 1.25.0
require (
golang.org/x/net v0.47.0
k8s.io/api v0.34.1
k8s.io/apimachinery v0.34.1
k8s.io/client-go v0.34.1
)
require (
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/go-logr/logr v1.4.3 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
@@ -16,6 +19,7 @@ require (
github.com/go-openapi/swag v0.23.0 // indirect
github.com/gogo/protobuf v1.3.2 // 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/json-iterator/go v1.1.12 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
@@ -27,7 +31,6 @@ require (
github.com/x448/float16 v0.8.4 // indirect
go.yaml.in/yaml/v2 v2.4.3 // 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/sys v0.38.0 // indirect
golang.org/x/term v0.37.0 // indirect
+130 -1
View File
@@ -194,7 +194,136 @@ func buildExplainUserPrompt(lang, code string) string {
}
func buildChatSystemPrompt() string {
return "Ты краткий и полезный ассистент для Fission Console. Отвечай по делу и без лишней болтовни."
return strings.TrimSpace(`Ты ассистент платформы NUBES Fission Console — веб-интерфейса для запуска serverless-функций.
Отвечай кратко, по делу, понятным языком без технического жаргона.
## Что такое Fission Console
Веб-интерфейс по адресу https://fission.kube5s.ru/console/
Позволяет создавать, редактировать, вызывать и удалять функции без знания инфраструктуры.
## Вход в систему
- Токен из Личного кабинета (Профиль → Токены) — рекомендуется
- Демо-логин — любая строка не менее 6 символов. Один и тот же логин всегда даёт одно и то же окружение (namespace).
## Кнопки верхней панели
- **Refresh** — обновить список функций
- **✏️ Из кода** — создать новую функцию, написав код прямо в браузере
- **📦 Из архива** — создать новую функцию из zip-архива с файлами
- **Help** — справка по консоли
- **Выход** — выйти из системы
## Таблица функций — что означают столбцы
- **Имя** — уникальное имя функции
- **Окружение** — язык выполнения (python, nodejs, go, php, ruby и др.)
- **Пакет** — имя внутреннего объекта с кодом функции (технический, менять не нужно)
- **Создана / Изменена** — дата и время создания и последнего изменения
- **Маршрут** — HTTP-путь через который вызывается функция (например /my-fn)
- **Методы** — разрешённые HTTP методы (GET, POST и др.)
- **Cron** — расписание автозапуска (например ` + "`*/5 * * * *`" + ` = каждые 5 минут). Если пусто — cron не задан.
- **Действия** — кнопки управления функцией (см. ниже)
## Кнопки действий в строке функции
- **Ред.** — открыть редактор: изменить код, timeout, cron
- **Вызов** — вызвать функцию прямо из браузера, увидеть ответ и время выполнения
- **Логи** — посмотреть вывод функции (то что она пишет через print/console.log/log.Printf и т.д.)
- **Удалить** — удалить функцию (необратимо)
## Создание функции "Из кода"
Поля формы:
- **Name** — имя функции (строчные буквы, цифры, дефис; напр. my-func)
- **Language** — язык (Python, Node.js, Go, PHP, Ruby)
- **Entrypoint** — точка входа. По умолчанию подставляется автоматически. Для Python: main.main, для Node.js: main, для Go: main, для PHP: main.php::handler, для Ruby: handler
- **Route** — URL-путь (напр. /my-func). Именно по этому пути функция доступна снаружи
- **Методы** — HTTP методы через запятую (GET, POST, GET,POST и т.д.)
- **Timeout** — максимальное время выполнения в секундах
- **Cron** — расписание в формате cron (необязательно). Включается переключателем.
- **Код** — редактор кода прямо в браузере
Кнопки в редакторе кода:
- **🔍 Проверить синтаксис линтером** — проверяет код на синтаксические ошибки
- **✨ Сгенерировать код LLM** — описать задачу текстом, получить готовый код
- **📖 LLM: Что делает?** — объяснение что делает текущий код
## Создание функции "Из архива"
Аналогично "Из кода", но вместо редактора кода — загрузка zip-файла.
- **🔍 Проверка архива линтером** — проверяет архив на ошибки и совместимость
- **📖 LLM: Что делает?** — объяснение содержимого архива
## Редактирование функции (кнопка Ред.)
- **Name, Environment, Entrypoint** — только для просмотра, не редактируются
- **Timeout** — можно изменить
- **Cron** — можно включить/выключить и изменить расписание
- **Код** — редактируется (для функций созданных из кода)
- **Заменить архив** — загрузить новый zip (для функций из архива)
## Вызов функции (кнопка Вызов)
Открывается модальное окно. Можно указать тело запроса (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 {
+342
View File
@@ -0,0 +1,342 @@
// Package api — создание и обновление функций из zip-архива (multipart/form-data).
//
// Этот файл отвечает за два сценария:
// 1. handleCreateFunctionFromArchive — создание новой функции из загруженного .zip файла.
// 2. handleUpdateFunctionArchive — обновление существующей функции новым .zip файлом.
//
// Архив загружается пользователем через форму с полем "archive".
// Содержимое архива передаётся в storagesvc → S3 без модификации.
// В отличие от function_code.go, здесь нет трансформации кода — архив идёт как есть.
//
// Связанная аннотация: fission-console/source-type = "archive"
// позволяет UI определить режим редактирования при открытии функции.
package api
import (
"context"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"fission-console/internal/fission"
"fission-console/internal/runtime"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
// handleCreateFunctionFromArchive создаёт функцию из загруженного zip-архива (multipart/form-data).
// Поля формы: name, language (или environment), entrypoint, route, methods, timeout, ttl.
// Файловое поле: archive (.zip).
func (s *Server) handleCreateFunctionFromArchive(w http.ResponseWriter, r *http.Request, ns string) {
if err := r.ParseMultipartForm(maxArchiveUploadSize); err != nil {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("parse multipart form: %v", err))
return
}
name := strings.TrimSpace(r.FormValue("name"))
if name == "" || (!validFuncName.MatchString(name) || len(name) > 57) {
writeJSONError(w, http.StatusBadRequest, "invalid function name: must match ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ and be <= 57 chars")
return
}
lang := strings.TrimSpace(r.FormValue("language"))
envName := strings.TrimSpace(r.FormValue("environment"))
f, fhCreate, err := r.FormFile("archive")
if err != nil {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("archive file required: %v", err))
return
}
defer f.Close()
archiveBytes, err := io.ReadAll(io.LimitReader(f, maxArchiveUploadSize))
if err != nil {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("read archive: %v", err))
return
}
archiveFilenameCreate := ""
if fhCreate != nil {
archiveFilenameCreate = fhCreate.Filename
}
// Проверяем magic bytes: zip должен начинаться с PK (0x50 0x4B)
if len(archiveBytes) < 4 || archiveBytes[0] != 0x50 || archiveBytes[1] != 0x4B {
writeJSONError(w, http.StatusBadRequest, "загруженный файл не является zip-архивом (ожидается .zip)")
return
}
nsCtx, nsCancel := context.WithTimeout(r.Context(), 60*time.Second)
defer nsCancel()
if err := s.nsManager.EnsureUserNS(nsCtx, ns); err != nil {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("ensure namespace: %v", err))
return
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
// Определяем environment: по языку или явно
if lang != "" {
envCtx, envCancel := context.WithTimeout(r.Context(), 15*time.Second)
defer envCancel()
resolved, envErr := fission.EnsureEnvironment(envCtx, s.dyn, ns, lang)
if envErr != nil {
if strings.Contains(envErr.Error(), "unsupported language") {
writeJSONError(w, http.StatusBadRequest, envErr.Error())
} else {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("ensure environment: %v", envErr))
}
return
}
envName = resolved
}
if envName == "" {
writeJSONError(w, http.StatusBadRequest, "language or environment is required")
return
}
if _, err := s.dyn.Resource(fission.EnvironmentGVR).Namespace(ns).Get(ctx, envName, metav1.GetOptions{}); err != nil {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("environment %q not found: %v", envName, err))
return
}
entrypoint := strings.TrimSpace(r.FormValue("entrypoint"))
if entrypoint == "" {
entrypoint = runtime.DefaultEntrypoint(lang)
}
route := strings.TrimSpace(r.FormValue("route"))
if route == "" {
nsShort := ns
if len(nsShort) > 12 {
nsShort = nsShort[len(nsShort)-12:]
}
route = "/" + nsShort + "/" + name
}
if !strings.HasPrefix(route, "/") {
route = "/" + route
}
methods := normalizeMethods(strings.Split(r.FormValue("methods"), ","))
timeout := normalizeFunctionTimeout(0)
if tv := r.FormValue("timeout"); tv != "" {
if n, err := strconv.ParseInt(tv, 10, 64); err == nil {
timeout = normalizeFunctionTimeout(n)
}
}
// Загружаем архив в storagesvc
deploySpec, uploadErr := s.buildDeploySpec(ctx, archiveBytes)
if uploadErr != nil {
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("upload archive: %v", uploadErr))
return
}
pkgName := name + "-pkg"
triggerName := name + "-route"
pkg := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "Package",
"metadata": map[string]any{"name": pkgName, "namespace": ns},
"spec": map[string]any{
"deployment": deploySpec,
"environment": map[string]any{"name": envName, "namespace": ns},
"source": map[string]any{},
},
}}
now := time.Now().UTC()
fnAnnotations := map[string]any{
"fission-console/language": lang,
fissionSourceTypeAnnotation: "archive",
functionCreatedAtAnnotation: now.Format(time.RFC3339),
functionUpdatedAtAnnotation: now.Format(time.RFC3339),
}
if archiveFilenameCreate != "" {
fnAnnotations["fission-console/archive-filename"] = archiveFilenameCreate
}
if ttl := r.FormValue("ttl"); ttl != "" {
if expiresAt, ttlErr := parseTTL(ttl); ttlErr == nil {
fnAnnotations["fission-console/expires-at"] = expiresAt.UTC().Format(time.RFC3339)
}
}
methodValues := make([]any, 0, len(methods))
for _, m := range methods {
methodValues = append(methodValues, m)
}
fn := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "Function",
"metadata": map[string]any{"name": name, "namespace": ns, "annotations": fnAnnotations},
"spec": map[string]any{
"environment": map[string]any{"name": envName, "namespace": ns},
"package": map[string]any{"packageref": map[string]any{"name": pkgName, "namespace": ns}},
"InvokeStrategy": map[string]any{
"ExecutionStrategy": map[string]any{"ExecutorType": "poolmgr"},
"StrategyType": "execution",
},
"functionTimeout": timeout,
},
}}
if entrypoint != "" {
_ = unstructured.SetNestedField(fn.Object, entrypoint, "spec", "package", "functionName")
}
trigger := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "HTTPTrigger",
"metadata": map[string]any{"name": triggerName, "namespace": ns},
"spec": map[string]any{
"functionref": map[string]any{"name": name, "type": "name"},
"relativeurl": route,
"methods": methodValues,
},
}}
if _, err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Create(ctx, pkg, metav1.CreateOptions{}); err != nil {
if apierrors.IsAlreadyExists(err) {
writeJSONError(w, http.StatusConflict, fmt.Sprintf("function %q already exists", name))
return
}
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create package: %v", err))
return
}
if _, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Create(ctx, fn, metav1.CreateOptions{}); err != nil {
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, pkgName, metav1.DeleteOptions{})
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create function: %v", err))
return
}
if _, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).Create(ctx, trigger, metav1.CreateOptions{}); err != nil {
_ = s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Delete(ctx, name, metav1.DeleteOptions{})
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, pkgName, metav1.DeleteOptions{})
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create trigger: %v", err))
return
}
writeAnyJSON(w, http.StatusCreated, map[string]any{
"name": name,
"namespace": ns,
"environment": envName,
"route": route,
"source_type": "archive",
})
}
// handleUpdateFunctionArchive обновляет функцию из загруженного zip-архива (multipart/form-data).
// Поля формы: timeout (optional), entrypoint (optional). Файловое поле: archive (.zip).
// Создаёт новый Package (новое имя) — чтобы executor сбросил кэш function service.
func (s *Server) handleUpdateFunctionArchive(w http.ResponseWriter, r *http.Request, name string) {
if err := r.ParseMultipartForm(maxArchiveUploadSize); err != nil {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("parse multipart form: %v", err))
return
}
f, _, err := r.FormFile("archive")
if err != nil {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("archive file required: %v", err))
return
}
defer f.Close()
archiveBytes, err := io.ReadAll(io.LimitReader(f, maxArchiveUploadSize))
if err != nil {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("read archive: %v", err))
return
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
ns := s.userNS(r)
fn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
if err != nil {
status := http.StatusBadGateway
if apierrors.IsNotFound(err) {
status = http.StatusNotFound
}
writeJSONError(w, status, fmt.Sprintf("get function %q: %v", name, err))
return
}
oldPkgName, _, _ := unstructured.NestedString(fn.Object, "spec", "package", "packageref", "name")
envName, _, _ := unstructured.NestedString(fn.Object, "spec", "environment", "name")
newPkgName := name + "-pkg-" + strconv.FormatInt(time.Now().UnixMilli(), 36)
deploySpec, uploadErr := s.buildDeploySpec(ctx, archiveBytes)
if uploadErr != nil {
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("upload archive: %v", uploadErr))
return
}
newPkg := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "Package",
"metadata": map[string]any{"name": newPkgName, "namespace": ns},
"spec": map[string]any{
"deployment": deploySpec,
"environment": map[string]any{"name": envName, "namespace": ns},
"source": map[string]any{},
},
}}
createdPkg, err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Create(ctx, newPkg, metav1.CreateOptions{})
if err != nil {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create new package: %v", err))
return
}
// Обновляем timeout если задан
timeout := normalizeFunctionTimeout(0)
if tv := r.FormValue("timeout"); tv != "" {
if n, err := strconv.ParseInt(tv, 10, 64); err == nil {
timeout = normalizeFunctionTimeout(n)
}
}
_ = unstructured.SetNestedField(fn.Object, timeout, "spec", "functionTimeout")
// Обновляем entrypoint если передан
if ep := strings.TrimSpace(r.FormValue("entrypoint")); ep != "" {
_ = unstructured.SetNestedField(fn.Object, ep, "spec", "package", "functionName")
}
fnAnnotations := fn.GetAnnotations()
if fnAnnotations == nil {
fnAnnotations = map[string]string{}
}
fnAnnotations[fissionSourceTypeAnnotation] = "archive"
fnAnnotations[functionUpdatedAtAnnotation] = time.Now().UTC().Format(time.RFC3339)
if fh, fhErr := r.MultipartForm.File["archive"]; fhErr == false || len(fh) > 0 {
if files := r.MultipartForm.File["archive"]; len(files) > 0 && files[0].Filename != "" {
fnAnnotations["fission-console/archive-filename"] = files[0].Filename
}
}
fn.SetAnnotations(fnAnnotations)
if err := unstructured.SetNestedField(fn.Object, map[string]any{
"name": newPkgName,
"namespace": ns,
"resourceversion": createdPkg.GetResourceVersion(),
}, "spec", "package", "packageref"); err != nil {
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, newPkgName, metav1.DeleteOptions{})
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("set packageref: %v", err))
return
}
if _, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Update(ctx, fn, metav1.UpdateOptions{}); err != nil {
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, newPkgName, metav1.DeleteOptions{})
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("update function: %v", err))
return
}
if oldPkgName != "" && oldPkgName != newPkgName {
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, oldPkgName, metav1.DeleteOptions{})
}
writeAnyJSON(w, http.StatusOK, map[string]any{
"updated": true,
"package": newPkgName,
"source_type": "archive",
})
}
+410
View File
@@ -0,0 +1,410 @@
// 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,
})
}
+286
View File
@@ -0,0 +1,286 @@
// 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(),
})
}
+423
View File
@@ -0,0 +1,423 @@
// 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)
}
File diff suppressed because it is too large Load Diff
+5
View File
@@ -7,6 +7,7 @@ import (
"encoding/base64"
"encoding/json"
"io"
"log"
"net/http"
"regexp"
"sort"
@@ -16,6 +17,7 @@ import (
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
// ⛔⛔⛔ НЕ МЕНЯТЬ БЕЗ ЯВНОГО РАЗРЕШЕНИЯ ВЛАДЕЛЬЦА.
// extractPackageSourceCode пытается извлечь исходный код из Fission Package.
// Порядок попыток:
// 1. spec.source.literal (base64) — Go функции (source package)
@@ -51,10 +53,13 @@ func extractPackageSourceCode(ctx context.Context, s *Server, pkg *unstructured.
}
archiveBytes, err := fetchPackageArchive(ctx, s, urlValue)
if err != nil {
log.Printf("extractPackageSourceCode: fetchPackageArchive %s: %v", urlValue, err)
continue
}
if code, err := decodeArchiveBytesToSource(archiveBytes); err == nil && strings.TrimSpace(code) != "" {
return code
} else {
log.Printf("extractPackageSourceCode: decodeArchiveBytesToSource %s: err=%v, len=%d", urlValue, err, len(archiveBytes))
}
}
+5 -1
View File
@@ -21,6 +21,7 @@ import (
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/kubernetes"
)
// defaultSATokenPath — путь к service account токену внутри pod-а.
@@ -31,7 +32,8 @@ const defaultSATokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token"
// Содержит все зависимости: kubernetes client, конфиги, кэши.
type Server struct {
dyn dynamic.Interface
ns string // системный namespace (fallback, обычно "fission")
kube kubernetes.Interface // typed client — только для логов подов
ns string // системный namespace (fallback, обычно "fission")
routerURL string
http *http.Client
@@ -62,6 +64,7 @@ type Server struct {
// Config содержит все параметры для создания Server.
type Config struct {
Dyn dynamic.Interface
Kube kubernetes.Interface
Namespace string
RouterURL string
HTTPTimeout time.Duration
@@ -80,6 +83,7 @@ func NewServer(cfg Config) *Server {
log.Printf("NewServer: storagesvcURL=%q", cfg.StoragesvcURL)
return &Server{
dyn: cfg.Dyn,
kube: cfg.Kube,
ns: cfg.Namespace,
routerURL: cfg.RouterURL,
http: &http.Client{Timeout: cfg.HTTPTimeout},
+118
View File
@@ -0,0 +1,118 @@
// Package api — работа с Fission storagesvc (S3-совместимое хранилище архивов).
//
// Этот файл содержит низкоуровневые операции загрузки и удаления архивов через
// HTTP API storagesvc. Storagesvc хранит zip-архивы функций в S3 (bucket sless-functions).
// Package CRD ссылается на архив через spec.deployment.url (type: url).
//
// Используется из function_code.go и function_archive.go для сохранения/замены кода функций.
package api
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"time"
)
// deleteFromStoragesvc удаляет архив из S3 через storagesvc по URL из Package spec.
// URL имеет формат: http://storagesvc.../v1/archive?id=fission/UUID
// Best-effort: ошибка логируется, но не прерывает операцию удаления.
func (s *Server) deleteFromStoragesvc(ctx context.Context, archiveURL string) {
if s.storagesvcURL == "" || archiveURL == "" {
return
}
// archiveURL = "http://storagesvc.../v1/archive?id=fission/UUID"
// Строим DELETE URL к storagesvc, сохраняя query-параметр id
parsed, err := url.Parse(archiveURL)
if err != nil {
log.Printf("deleteFromStoragesvc: parse url %q: %v", archiveURL, err)
return
}
deleteURL := strings.TrimRight(s.storagesvcURL, "/") + "/v1/archive?" + parsed.RawQuery
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, deleteURL, nil)
if err != nil {
log.Printf("deleteFromStoragesvc: build request: %v", err)
return
}
resp, err := s.http.Do(req)
if err != nil {
log.Printf("deleteFromStoragesvc: %v", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
body, _ := io.ReadAll(resp.Body)
log.Printf("deleteFromStoragesvc: status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
return
}
log.Printf("deleteFromStoragesvc: deleted %s", parsed.Query().Get("id"))
}
// uploadToStoragesvc загружает байты в Fission storagesvc и возвращает URL для package archive.
// Если storagesvcURL не задан — возвращает пустую строку (fallback на literal).
func (s *Server) uploadToStoragesvc(ctx context.Context, data []byte) (string, error) {
if s.storagesvcURL == "" {
log.Printf("uploadToStoragesvc: storagesvcURL is empty, skip upload")
return "", nil
}
log.Printf("uploadToStoragesvc: uploading %d bytes to %s", len(data), s.storagesvcURL)
uploadURL := strings.TrimRight(s.storagesvcURL, "/") + "/v1/archive"
body := &bytes.Reader{}
// multipart/form-data с полем uploadfile
var buf bytes.Buffer
boundary := fmt.Sprintf("fission%d", time.Now().UnixNano())
buf.WriteString("--" + boundary + "\r\n")
buf.WriteString(fmt.Sprintf("Content-Disposition: form-data; name=\"uploadfile\"; filename=\"archive.zip\"\r\n"))
buf.WriteString("Content-Type: application/octet-stream\r\n\r\n")
buf.Write(data)
buf.WriteString("\r\n--" + boundary + "--\r\n")
body = bytes.NewReader(buf.Bytes())
req, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, body)
if err != nil {
return "", fmt.Errorf("build storagesvc upload request: %w", err)
}
req.Header.Set("Content-Type", "multipart/form-data; boundary="+boundary)
req.Header.Set("X-File-Size", fmt.Sprintf("%d", len(data)))
resp, err := s.http.Do(req)
if err != nil {
return "", fmt.Errorf("storagesvc upload: %w", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return "", fmt.Errorf("storagesvc upload status %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
}
var result struct {
ID string `json:"id"`
}
if err := json.Unmarshal(respBody, &result); err != nil || result.ID == "" {
return "", fmt.Errorf("storagesvc upload: bad response: %s", string(respBody))
}
archiveURL := strings.TrimRight(s.storagesvcURL, "/") + "/v1/archive?id=" + result.ID
return archiveURL, nil
}
// buildDeploySpec строит spec.deployment для Fission Package.
// Если storagesvcURL задан — загружает архив в S3 через storagesvc и возвращает type: url.
// Иначе — возвращает type: literal с base64-кодом.
func (s *Server) buildDeploySpec(ctx context.Context, data []byte) (map[string]any, error) {
archiveURL, err := s.uploadToStoragesvc(ctx, data)
if err != nil {
log.Printf("storagesvc upload failed, falling back to literal: %v", err)
// fallback — сохраняем как literal
return map[string]any{"type": "literal", "literal": base64.StdEncoding.EncodeToString(data)}, nil
}
if archiveURL == "" {
return map[string]any{"type": "literal", "literal": base64.StdEncoding.EncodeToString(data)}, nil
}
return map[string]any{"type": "url", "url": archiveURL}, nil
}
+3 -3
View File
@@ -19,10 +19,10 @@ import (
)
const (
timeTriggerDefaultMethod = http.MethodPost
timeTriggerDefaultMethod = http.MethodPost
timeTriggerDefaultSubPath = "/"
timeTriggerMaxNameLen = 63
cronGatewayURL = "http://fission-console.fission.svc.cluster.local"
cronGatewayURL = "http://fission-console.fission.svc.cluster.local"
)
var validTimeTriggerMethods = map[string]struct{}{
@@ -502,4 +502,4 @@ func errStatus(err error) int {
return ae.status
}
return http.StatusInternalServerError
}
}
+27 -4
View File
@@ -102,7 +102,7 @@
<div class="nubes">NUBES</div>
<div class="product">FISSION CONSOLE</div>
</div>
<div style="font-size:0.65rem; color:var(--text-secondary); margin-left:10px; align-self:center; opacity:0.7;">v1.3.56</div>
<div style="font-size:0.65rem; color:var(--text-secondary); margin-left:10px; align-self:center; opacity:0.7;">v1.3.69</div>
</div>
<div class="row" style="margin:0;">
<button class="btn ghost" onclick="reloadAll()">Refresh</button>
@@ -146,8 +146,7 @@
<thead>
<tr>
<th>Имя</th>
<th>Окружение</th>
<th>Пакет</th>
<th title="Тип источника функции">Тип</th>
<th>Создана</th>
<th>Изменена</th>
<th>Маршрут</th>
@@ -384,6 +383,30 @@
</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 class="panel">
<h3>Help</h3>
@@ -462,7 +485,7 @@
</div>
<div class="actions" style="justify-content:space-between; align-items:center;">
<span style="font-size:0.75rem; color:var(--text-secondary);">v1.3.56</span>
<span style="font-size:0.75rem; color:var(--text-secondary);">v1.3.69</span>
<button class="btn ghost" onclick="closeHelp()">Закрыть</button>
</div>
</div>
+18 -5
View File
@@ -5,7 +5,7 @@ async function reloadAll() {
// Показываем таблицу сразу с placeholder'ом
document.getElementById('fn-rows').innerHTML =
'<tr><td colspan="9" style="color:var(--text-secondary); font-style:italic;">⏳ Загружаем функции...</td></tr>';
'<tr><td colspan="8" style="color:var(--text-secondary); font-style:italic;">⏳ Загружаем функции...</td></tr>';
try {
const [envs, pkgs, fns, http, times] = await Promise.all([
@@ -35,6 +35,8 @@ async function reloadAll() {
const ann = meta.annotations || {};
const env = (spec.environment && spec.environment.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 trig = httpTriggerByFn(name) || {};
const timeTrig = timeTriggerByFn(name) || {};
@@ -45,17 +47,28 @@ async function reloadAll() {
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 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 isTf = /^tf-/.test(name);
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 +
'<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="openInvoke(\'' + h(name) + '\')">Вызов</button> ' +
'<button class="btn ghost" onclick="openLogs(\'' + h(name) + '\')">Логи</button> ' +
'<button class="btn danger" onclick="removeFn(\'' + h(name) + '\')">Удалить</button>';
return '<tr>' +
'<td class="mono">' + h(name) + '</td>' +
'<td>' + h(env) + '</td>' +
'<td class="mono">' + h(pkg) + '</td>' +
'<td style="text-align:center;">' + sourceIcon + '</td>' +
'<td>' + timestampCell(createdAt) + '</td>' +
'<td>' + timestampCell(updatedAt) + '</td>' +
'<td class="mono">' + h(route) + '</td>' +
@@ -68,7 +81,7 @@ async function reloadAll() {
var fnList = fns || [];
if (fnList.length === 0) {
progress.stop('Загружено: нет функций', '');
document.getElementById('fn-rows').innerHTML = '<tr><td colspan="9">Нет функций</td></tr>';
document.getElementById('fn-rows').innerHTML = '<tr><td colspan="8">Нет функций</td></tr>';
} else {
// Рендерим первую строку сразу — убираем placeholder
var tbody = document.getElementById('fn-rows');
@@ -89,7 +102,7 @@ async function reloadAll() {
setTimeout(appendNext, 40);
}
} catch (e) {
document.getElementById('fn-rows').innerHTML = '<tr><td colspan="9">Load error: ' + e.message + '</td></tr>';
document.getElementById('fn-rows').innerHTML = '<tr><td colspan="8">Load error: ' + e.message + '</td></tr>';
progress.stop('Ошибка загрузки: ' + e.message, 'err');
}
}
+4
View File
@@ -1,4 +1,8 @@
/* functions.js — CRUD операции с функциями */
// ⛔⛔⛔ НЕ МЕНЯТЬ БЕЗ ЯВНОГО РАЗРЕШЕНИЯ ВЛАДЕЛЬЦА.
// Любые "улучшения", рефакторинг, добавление фич — ЗАПРЕЩЕНЫ без команды.
// Прецедент: v1.3.49 — убрали mode toggle "из лучших побуждений" → сломали рабочий редактор.
// Прецедент: helm upgrade → сломал JWT secret router → 401 у всех функций.
// setCodeMode переключает режим формы между вводом кода и загрузкой архива.
// prefix: 'c' (create) или 'e' (edit)
+117 -1
View File
@@ -46,7 +46,20 @@ function makeDraggable(modalId) {
// Инициализация после загрузки DOM
document.addEventListener('DOMContentLoaded', function() {
['create-code-modal', 'create-archive-modal', 'edit-modal', 'help-modal', 'invoke-modal'].forEach(makeDraggable);
['create-code-modal', 'create-archive-modal', 'edit-modal', 'help-modal', 'invoke-modal', 'logs-modal', 'info-modal'].forEach(makeDraggable);
// ESC закрывает активное модальное окно
document.addEventListener('keydown', function(e) {
if (e.key !== 'Escape') return;
var modals = ['create-code-modal', 'create-archive-modal', 'edit-modal', 'help-modal', 'invoke-modal', 'logs-modal', 'info-modal'];
for (var i = 0; i < modals.length; i++) {
var el = document.getElementById(modals[i]);
if (el && el.classList.contains('open')) {
el.classList.remove('open');
break;
}
}
});
});
// showModalError — показывает ошибку внутри модалки.
@@ -136,3 +149,106 @@ async function submitInvoke() {
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, '&amp;').replace(/"/g, '&quot;');
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, '&amp;').replace(/"/g, '&quot;');
var html = [
'<tr>' + labelTd('Имя') + withCopy(info.name) + '</tr>',
'<tr>' + labelTd('Окружение') + plain(info.env) + '</tr>',
'<tr>' + labelTd('Пакет') + withCopy(info.pkg) + '</tr>',
'<tr>' + labelTd('Entrypoint') + plain(info.entrypoint) + '</tr>',
'<tr>' + labelTd('Таймаут') + plain(info.timeout ? info.timeout + ' сек' : '-') + '</tr>',
'<tr>' + labelTd('Тип источника') + plain(info.sourceType === 'archive' ? '📦 archive' : '📝 code') + '</tr>',
'<tr>' + labelTd('Маршрут') + withCopy(info.route) + '</tr>',
'<tr>' + labelTd('Методы') + plain(Array.isArray(info.methods) && info.methods.length ? info.methods.join(', ') : '-') + '</tr>',
'<tr>' + labelTd('Cron') + plain(info.cron || '-') + '</tr>',
'<tr>' + labelTd('Создана') + plain(info.createdAt) + '</tr>',
'<tr>' + labelTd('Изменена') + plain(info.updatedAt) + '</tr>',
'<tr>' + labelTd('curl') +
'<td class="mono" style="word-break:break-all;padding:4px 0;color:var(--text-secondary);">' +
h(curlDisplay) +
' <button class="btn ghost" style="padding:1px 6px; font-size:11px;" data-curl="' + curlAttr + '" onclick="navigator.clipboard.writeText(this.dataset.curl)">📋</button>' +
'</td></tr>',
].join('');
document.getElementById('info-title').textContent = 'Информация: ' + name;
document.getElementById('info-rows').innerHTML = html;
document.getElementById('info-modal').classList.add('open');
}
// closeInfo — закрывает Info-модалку.
function closeInfo() {
document.getElementById('info-modal').classList.remove('open');
}
@@ -0,0 +1,58 @@
# Изменения в fission-src относительно официального Fission
Базовый коммит официального Fission: `82e1ff76` (Add npm dependabot rules, 2025-12-16)
---
## Что менялось
### 1. Ядро — `pkg/utils/`
- `namespace_manager.go` (+608 строк) — новый компонент: менеджер namespace. Отслеживает появление/исчезновение namespace с меткой `fission.io/managed=true` в реальном времени без рестарта Fission.
- `namespace_manager_model.go` (+181 строк) — модель данных менеджера namespace.
- `namespace.go` (+66 строк) — расширена логика работы с namespace.
- `serviceaccount.go` (+25 строк) — добавлена функция `EnsureNamespaceSA`: при появлении нового namespace автоматически создаёт ServiceAccount, Role, RoleBinding, необходимые для работы fetcher/builder.
### 2. Executor — `pkg/executor/`
- `multitenant/ns_watcher.go` (+137 строк) — watcher namespace-событий для executor. При появлении namespace с меткой `fission.io/managed=true` — executor начинает обслуживать функции в этом namespace без рестарта.
- `executortype/poolmgr/gpm.go` (+69 строк) — poolmgr динамически добавляет новые namespace.
- `executortype/newdeploy/newdeploymgr.go` (+61 строка) — аналогично для newdeploy.
- `executortype/container/containermgr.go` (+49 строк) — аналогично для container executor.
- `executortype/poolmgr/poolpodcontroller.go` (+43 строки) — контроллер подов poolmgr знает о новых namespace.
### 3. Router — `pkg/router/`
- `httpTriggers.go` (+116 строк) — роутер подписывается на ns_watcher, динамически добавляет маршруты для функций в новых namespace.
- `namespace_subscriber.go` (+31 строка) — подписка роутера на namespace-события.
- `ns_watcher.go` (+32 строки) — watcher namespace для роутера.
- `functionReferenceResolver.go` (+11 строк) — резолвер функций знает о мультинеймспейс.
### 4. BuilderMgr — `pkg/buildermgr/`
- `ns_watcher.go` (+32 строки) — watcher namespace для buildermgr.
- `namespace_subscriber.go` (+43 строки) — подписка buildermgr на namespace-события.
- `envwatcher.go` (+45 строк) — watcher окружений теперь реагирует на новые namespace.
- `pkgwatcher.go` (+38 строк) — watcher пакетов — аналогично.
### 5. RBAC — `deploy/multitenant/rbac.yaml` (+122 строки)
- Новые ClusterRole и ClusterRoleBinding для автоматического создания SA/Role/RoleBinding в пользовательских namespace.
- `deploy/executor-ns-watcher-rbac.yaml` (+26 строк) — дополнительный RBAC для ns_watcher executor.
---
## Обратная совместимость
**Полная.** Если в кластере нет namespace с меткой `fission.io/managed=true` — поведение идентично официальному Fission v1.22.0.
- CRD не изменялись.
- API (fission CLI) не изменялся.
- Helm-чарт не изменялся.
- Существующие функции, env, пакеты работают без изменений.
---
## Как активировать мультитенантность
Добавить метку на namespace:
```bash
kubectl label namespace <ns> fission.io/managed=true
```
Fission автоматически начнёт обслуживать функции в этом namespace без рестарта.
+27 -1
View File
@@ -1,6 +1,32 @@
# Progress Log
## 2026-04-14
## TODO (backlog)
### Биллинг / статистика для бухгалтерии
Цель: считать потреблённые ресурсы по пользователям для тарификации.
- Метрики: количество вызовов, время выполнения (ms), CPU/RAM пода
- Агрегация по namespace (пользователю) за период (день/месяц)
- Хранение истории (сейчас metrics-collector хранит только текущее)
- Отчёт: пользователь → вызовы + суммарное время → сумма к оплате
- API для бухгалтерии или выгрузка CSV/Excel
- Что уже есть: metrics-collector (/metrics endpoint), Fission router логирует вызовы, k8s metrics-server
- Примерная модель: X руб/1000 вызовов + Y руб/GB·сек
### MQ-триггер и Kube-Watch триггер в консоли
- Backend: добавить GVR для messagequeuetriggers.fission.io и kuberneteswatchtriggers.fission.io
- UI: формы создания по аналогии с cron, счётчики на главной
### Прочий полезный функционал
- Метрики на главной странице (данные уже есть в metrics-collector)
- Автообновление логов (polling каждые 3-5 сек)
- Копировать маршрут одной кнопкой (📋 рядом с маршрутом)
- Статус функции (Ready/NotReady/Error) в таблице
- Клонировать функцию (копия с новым именем)
- Переменные окружения (env vars) через UI
---
### План перед работой
- Зафиксировать, что REST `/v2/*` недоступен в текущем кластере Fission v1.22.1 и MVP идет через Kubernetes CRD API.
@@ -2,10 +2,14 @@ import os
import urllib.request
import urllib.error
import json
import datetime
COLLECTOR_URL = "http://metrics-collector.fission.svc.cluster.local:8091/metrics"
def main():
now = datetime.datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC")
print(f"[croned] start {now}", flush=True)
payload = {
"source": "fission-cron-metrics",
"hostname": os.uname().nodename,
@@ -18,6 +22,7 @@ def main():
payload["cpu_load_1m"] = load[0]
payload["cpu_load_5m"] = load[1]
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:
pass
@@ -37,6 +42,7 @@ def main():
payload["memory_available_mb"] = round(avail, 1)
payload["memory_used_mb"] = round(used, 1)
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:
pass
@@ -49,6 +55,7 @@ def main():
payload["disk_total_gb"] = round(total_gb, 1)
payload["disk_free_gb"] = round(free_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:
pass
@@ -68,6 +75,8 @@ def main():
)
try:
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}
except urllib.error.URLError as e:
print(f"[croned] send ERROR: {e}", flush=True)
return {"ok": False, "error": str(e)}
+14
View File
@@ -0,0 +1,14 @@
apiVersion: v2
name: fission-console
description: Fission Console — web UI and API gateway for Fission serverless platform
type: application
version: 0.1.0
appVersion: "v1.3.56"
keywords:
- fission
- serverless
- kubernetes
- console
home: https://gitea.services.ngcloud.ru/Nail/fission
maintainers:
- name: naeel
+9
View File
@@ -0,0 +1,9 @@
Fission Console успешно установлен!
Адрес: https://{{ .Values.ingress.host }}
Для проверки:
kubectl get pods -n {{ .Release.Namespace }} -l app.kubernetes.io/name=fission-console
Логи:
kubectl logs -n {{ .Release.Namespace }} -l app.kubernetes.io/name=fission-console
@@ -0,0 +1,25 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "fission-console.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "fission-console.labels" -}}
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }}
app.kubernetes.io/name: fission-console
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "fission-console.selectorLabels" -}}
app.kubernetes.io/name: fission-console
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
@@ -0,0 +1,61 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: fission-console
namespace: {{ .Release.Namespace }}
labels:
{{- include "fission-console.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "fission-console.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "fission-console.selectorLabels" . | nindent 8 }}
spec:
serviceAccountName: fission-console
containers:
- name: console
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- containerPort: {{ .Values.service.port }}
env:
- name: FISSION_NAMESPACE
value: {{ .Values.fission.namespace | quote }}
- name: FISSION_ROUTER_URL
value: {{ .Values.fission.routerUrl | quote }}
- name: PORT
value: {{ .Values.service.port | quote }}
- name: FISSION_HTTP_TIMEOUT
value: {{ .Values.fission.httpTimeout | quote }}
- name: FISSION_INVOKE_TIMEOUT
value: {{ .Values.fission.invokeTimeout | quote }}
- name: FISSION_AUTH_USERNAME
valueFrom:
secretKeyRef:
name: {{ .Values.auth.secretName }}
key: {{ .Values.auth.usernameKey }}
- name: FISSION_AUTH_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Values.auth.secretName }}
key: {{ .Values.auth.passwordKey }}
- name: FISSION_STORAGESVC_URL
value: {{ .Values.fission.storagesvcUrl | quote }}
livenessProbe:
httpGet:
path: {{ .Values.probes.liveness.path }}
port: {{ .Values.service.port }}
initialDelaySeconds: {{ .Values.probes.liveness.initialDelaySeconds }}
periodSeconds: {{ .Values.probes.liveness.periodSeconds }}
readinessProbe:
httpGet:
path: {{ .Values.probes.readiness.path }}
port: {{ .Values.service.port }}
initialDelaySeconds: {{ .Values.probes.readiness.initialDelaySeconds }}
periodSeconds: {{ .Values.probes.readiness.periodSeconds }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
@@ -0,0 +1,37 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: fission-console
namespace: {{ .Release.Namespace }}
labels:
{{- include "fission-console.labels" . | nindent 4 }}
annotations:
{{- range $key, $value := .Values.ingress.annotations }}
{{ $key }}: {{ $value | quote }}
{{- end }}
{{- if .Values.ingress.tls.certManager.enabled }}
cert-manager.io/cluster-issuer: {{ .Values.ingress.tls.certManager.clusterIssuer | quote }}
{{- end }}
spec:
ingressClassName: {{ .Values.ingress.className | quote }}
rules:
- host: {{ .Values.ingress.host | quote }}
http:
paths:
{{- range .Values.ingress.paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: fission-console
port:
number: {{ $.Values.service.port }}
{{- end }}
{{- if .Values.ingress.tls.enabled }}
tls:
- hosts:
- {{ .Values.ingress.host | quote }}
secretName: {{ .Values.ingress.tls.secretName | quote }}
{{- end }}
{{- end }}
+41
View File
@@ -0,0 +1,41 @@
{{- if .Values.rbac.create }}
apiVersion: v1
kind: ServiceAccount
metadata:
name: fission-console
namespace: {{ .Release.Namespace }}
labels:
{{- include "fission-console.labels" . | nindent 4 }}
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: {{ .Release.Namespace }}-fission-console
labels:
{{- include "fission-console.labels" . | nindent 4 }}
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list"]
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "update", "patch"]
- apiGroups: ["fission.io"]
resources: ["environments", "packages", "functions", "httptriggers", "timetriggers"]
verbs: ["get", "list", "create", "update", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: {{ .Release.Namespace }}-fission-console
labels:
{{- include "fission-console.labels" . | nindent 4 }}
subjects:
- kind: ServiceAccount
name: fission-console
namespace: {{ .Release.Namespace }}
roleRef:
kind: ClusterRole
name: {{ .Release.Namespace }}-fission-console
apiGroup: rbac.authorization.k8s.io
{{- end }}
@@ -0,0 +1,14 @@
apiVersion: v1
kind: Service
metadata:
name: fission-console
namespace: {{ .Release.Namespace }}
labels:
{{- include "fission-console.labels" . | nindent 4 }}
spec:
selector:
{{- include "fission-console.selectorLabels" . | nindent 4 }}
ports:
- port: {{ .Values.service.port }}
targetPort: {{ .Values.service.port }}
protocol: TCP
+86
View File
@@ -0,0 +1,86 @@
# ─────────────────────────────────────────────
# Fission Console — values.yaml
# Все секреты и домен задаются при установке:
# helm install ... --set ingress.host=... --set auth.username=... --set auth.password=...
# ─────────────────────────────────────────────
# Образ консоли
image:
repository: naeel/fission-console
tag: "v1.3.56"
pullPolicy: Always
# Количество реплик
replicaCount: 1
# Namespace, в котором работает Fission (не namespace самой консоли)
fission:
namespace: "default"
routerUrl: "http://router.fission.svc.cluster.local"
storagesvcUrl: "http://storagesvc.fission.svc.cluster.local"
httpTimeout: "30s"
invokeTimeout: "60s"
# Порт контейнера
service:
port: 8090
# Аутентификация (секрет router в кластере)
# Значения передаются через --set или отдельный values-secrets.yaml (не коммитить!)
auth:
# Имя Kubernetes Secret, из которого берутся username/password
secretName: "router"
usernameKey: "username"
passwordKey: "password"
# Ingress
ingress:
enabled: true
className: "nginx"
host: "" # ОБЯЗАТЕЛЬНО указать: --set ingress.host=fission.mycompany.ru
tls:
enabled: true
certManager:
enabled: true
clusterIssuer: "letsencrypt-prod"
secretName: "fission-console-tls" # имя TLS-секрета (cert-manager создаст сам)
annotations:
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
# Пути, проксируемые через Ingress
# По умолчанию: /, /fn, /console, /cron, /default
# Переопределять не нужно в большинстве случаев
paths:
- path: /
pathType: Exact
- path: /fn
pathType: Prefix
- path: /console
pathType: Prefix
- path: /cron
pathType: Prefix
- path: /default
pathType: Prefix
# Ресурсы контейнера
resources:
requests:
cpu: 20m
memory: 32Mi
limits:
cpu: 200m
memory: 128Mi
# Liveness/Readiness пробы
probes:
liveness:
path: /health
initialDelaySeconds: 5
periodSeconds: 20
readiness:
path: /health
initialDelaySeconds: 3
periodSeconds: 10
# RBAC
rbac:
create: true
+3
View File
@@ -24,6 +24,9 @@ func main() {
_, _ = w.Write([]byte("ok\n"))
})
mux.HandleFunc("/metrics", srv.HandleMetrics)
// ⛔ /cron/metrics — внешний путь через ingress /cron → metrics-collector:8091.
// ⛔ НЕ УДАЛЯТЬ. Без этого маршрута UI на /cron не получает данные.
mux.HandleFunc("/cron/metrics", srv.HandleMetrics)
mux.Handle("/", ui.Handler())
httpSrv := &http.Server{
@@ -17,7 +17,7 @@ spec:
spec:
containers:
- name: metrics-collector
image: naeel/metrics-collector:v0.1.0
image: naeel/metrics-collector:v0.1.3
ports:
- containerPort: 8091
env:
@@ -54,3 +54,36 @@ spec:
ports:
- port: 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
+1 -1
View File
@@ -228,7 +228,7 @@
</div>
<script>
const API = '/metrics';
const API = '/cron/metrics';
const summaryEl = document.getElementById('summary');
const historyBody = document.getElementById('history-body');
const lastUpdatedEl = document.getElementById('last-updated');
+1
View File
@@ -1,3 +1,4 @@
ntazetdinov@nubes.ru
stxVxLvM9eqssgZ
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoLWFwaSIsInN1YiI6IjAxOWQ3NmM1LTQyNGMtN2MyYy04OWI3LWUzZjA5OTQ5YTk4ZiIsImV4cCI6MTc5MzA3ODQzOSwiaWF0IjoxNzc3NTI2NDM5LCJqdGkiOiI4MTU1ODNkNy0xZTRkLTRiMmMtOGM5ZS0wZDZmMGU2NmJjNTciLCJhdXRoX3RpbWUiOjAsInR5cCI6IiIsImF6cCI6IiIsInNlc3Npb25fc3RhdGUiOiIiLCJhY3IiOiIiLCJhbGxvd2VkLW9yaWdpbnMiOm51bGwsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6bnVsbH0sInJlc291cmNlX2FjY2VzcyI6eyJhY2NvdW50Ijp7InJvbGVzIjpudWxsfX0sInNjb3BlIjoiIiwic2lkIjoiIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJuYW1lIjoiIiwiQ2xpZW50SUQiOiJXWjAxMTEyIiwiY29tcGFueV9pZCI6IjhlYzcwYWMwLTU0NmQtNDJhNy04Y2ZmLTMzOWM4ZmI1MWEyMyIsImNvbXBhbnlfbmFtZSI6ItCe0J7QniDCq9Cd0KPQkdCV0KHCuyIsImlkcF91c3JfdWlkIjoiMDE5ZDc2YzUtNDI0Yy03YzJjLTg5YjctZTNmMDk5NDlhOThmIiwibG9naW4iOiJudGF6ZXRkaW5vdkBudWJlcy5ydSIsImZpcnN0bmFtZSI6ItCd0LDQuNC70YwiLCJtaWRkbGVuYW1lIjoi0KTQsNGA0LjRgtC-0LLQuNGHIiwibGFzdG5hbWUiOiLQotCw0LfQtdGC0LTQuNC90L7QsiIsImdyb3VwcyI6bnVsbCwicHJlZmVycmVkX3VzZXJuYW1lIjoiIiwiZ2l2ZW5fbmFtZSI6IiIsImZhbWlseV9uYW1lIjoiIiwiZW1haWwiOiJudGF6ZXRkaW5vdkBudWJlcy5ydSJ9.SaZsKAha45-fvKuJkLHUe_09AFsbH5QpzBVdPnjiEDQGIhl1A3ThgnM-oEh_H6CBXXfnOs2QDczzgCD8K8_tYzZKU1Wgk5lj04YW_fbTI89kHTO5wLtrAht9tFEjzBQZ-kmnG8mUK5tgqyNEjsngdQcfqVWRvneF366TiiRXk_76poUPpXWQmqdgCCKb3wq1rRgWcKDZcUC3JvDpOaqYL40zITZlM855drlJhMut4Gkg-EEk7ZGykl6YCsTRWVjFKGQ-7C-BDU2dkmIWhxzBAkvzzM2kBwvtlczCEDiUin7bXE-F_io89oJUYDzY4yPeHzwREwxCp6x9eD1NAIKKuA