restructure: console→client-console, add admin-console skeleton, move docs to doc/

This commit is contained in:
“Naeel”
2026-05-25 09:48:55 +04:00
parent 3e36ff13a8
commit 34a8068da5
110 changed files with 615 additions and 0 deletions
+210
View File
@@ -0,0 +1,210 @@
package api
import (
"bytes"
"context"
"encoding/json"
"fmt"
"go/parser"
"go/token"
"io"
"net/http"
"os"
"os/exec"
"strings"
"time"
)
// handleAICheck проверяет синтаксис кода через реальный линтер языка.
// POST /console/api/ai/check
// Body: {"language":"python","code":"..."}
// Response: {"ok":true,"result":"..."}
//
// Почему реальные линтеры, а не LLM:
// LLM часто "исправляет" валидный код и врёт о наличии ошибок.
// node --check / python3 -m py_compile / ruby -c / php -l — детерминированы и надёжны.
// Go использует go/parser прямо в процессе — без внешних команд, быстро.
func (s *Server) handleAICheck(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req struct {
Language string `json:"language"`
Code string `json:"code"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 64*1024)).Decode(&req); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
if strings.TrimSpace(req.Code) == "" {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"ok": false, "result": "Код пустой."})
return
}
// Конфигурация линтера для каждого языка
type linterCfg struct {
ext string
cmd []string
}
langs := map[string]linterCfg{
"nodejs": {ext: ".js", cmd: []string{"node", "--check"}},
"python": {ext: ".py", cmd: []string{"python3", "-m", "py_compile"}},
"ruby": {ext: ".rb", cmd: []string{"ruby", "-c"}},
"php": {ext: ".php", cmd: []string{"php", "-l"}},
"go": {ext: ".go", cmd: nil}, // go проверяется через go/parser в процессе
}
cfg, ok := langs[req.Language]
if !ok {
writeJSONError(w, http.StatusBadRequest, "unsupported language: "+req.Language)
return
}
var isOK bool
var result string
if req.Language == "go" {
// Go: используем go/parser прямо в процессе — без внешних команд и temp файлов
fset := token.NewFileSet()
_, parseErr := parser.ParseFile(fset, "code.go", req.Code, parser.AllErrors)
if parseErr == nil {
isOK = true
result = "✅ Синтаксис корректен."
} else {
isOK = false
result = parseErr.Error()
}
} else {
// Записываем код во временный файл с нужным расширением
// (линтеры ruby/php определяют режим проверки по расширению)
tmpf, err := os.CreateTemp("", "fission-lint-*"+cfg.ext)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "tmp file: "+err.Error())
return
}
defer os.Remove(tmpf.Name()) // очищаем независимо от результата
if _, err := tmpf.WriteString(req.Code); err != nil {
tmpf.Close()
writeJSONError(w, http.StatusInternalServerError, "write tmp: "+err.Error())
return
}
tmpf.Close()
args := append(cfg.cmd, tmpf.Name())
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
//nolint:gosec — cfg.cmd содержит только захардкоженные команды из langs (не user input)
out, err := exec.CommandContext(ctx, args[0], args[1:]...).CombinedOutput()
outStr := strings.TrimSpace(string(out))
// Убираем путь к tmp-файлу из вывода — пользователю незачем его видеть
outStr = strings.ReplaceAll(outStr, tmpf.Name(), "<code>")
if err == nil {
isOK = true
result = "✅ Синтаксис корректен."
} else {
isOK = false
if outStr != "" {
result = outStr
} else {
result = "Ошибка синтаксиса (линтер вернул код " + fmt.Sprintf("%v", err) + ")"
}
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"ok": isOK,
"result": result,
})
}
// =============================================================================
// ai/ask feature — удалить весь блок до "end ai/ask feature" чтобы выкосить
// =============================================================================
// handleAIAsk отвечает на произвольный вопрос пользователя через LLM.
// POST /console/api/ai/ask
// Body: {"question":"..."}
// Response: {"answer":"..."}
func (s *Server) handleAIAsk(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
if s.llmKey == "" {
writeJSONError(w, http.StatusServiceUnavailable, "AI-ассистент не настроен (FISSION_LLM_KEY не задан)")
return
}
var req aiPromptRequest
if err := json.NewDecoder(io.LimitReader(r.Body, 4*1024)).Decode(&req); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
chatReq, err := buildLLMChatRequest(req)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err.Error())
return
}
body, _ := json.Marshal(chatReq)
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
llmReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
strings.TrimRight(s.llmURL, "/")+"/chat/completions",
bytes.NewReader(body),
)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
llmReq.Header.Set("Content-Type", "application/json")
llmReq.Header.Set("Authorization", "Bearer "+s.llmKey)
resp, err := s.http.Do(llmReq)
if err != nil {
writeJSONError(w, http.StatusBadGateway, "LLM недоступен: "+err.Error())
return
}
defer resp.Body.Close()
var llmResp struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 64*1024)).Decode(&llmResp); err != nil {
writeJSONError(w, http.StatusBadGateway, "parse error: "+err.Error())
return
}
if llmResp.Error != nil {
writeJSONError(w, http.StatusBadGateway, llmResp.Error.Message)
return
}
if len(llmResp.Choices) == 0 {
writeJSONError(w, http.StatusBadGateway, "empty response from LLM")
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"answer": strings.TrimSpace(llmResp.Choices[0].Message.Content),
})
}
// =============================================================================
// end ai/ask feature
// =============================================================================
+356
View File
@@ -0,0 +1,356 @@
package api
import (
"fmt"
"strings"
)
const (
aiModeChat = "chat"
aiModeCodegen = "codegen"
aiModeExplain = "explain"
)
type llmMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type aiPromptRequest struct {
Mode string `json:"mode"`
Language string `json:"language"`
Name string `json:"name"`
Description string `json:"description"`
Code string `json:"code"`
Question string `json:"question"`
}
type llmChatRequest struct {
Model string `json:"model"`
Messages []llmMessage `json:"messages"`
MaxTokens int `json:"max_tokens"`
}
func buildLLMChatRequest(req aiPromptRequest) (llmChatRequest, error) {
mode := normalizeAIMode(req.Mode)
lang := normalizeLanguage(req.Language)
switch mode {
case aiModeCodegen:
if lang == "" {
return llmChatRequest{}, fmt.Errorf("language is required")
}
return llmChatRequest{
Model: "gpt-oss-120b",
Messages: []llmMessage{
{Role: "system", Content: buildCodegenSystemPrompt(lang)},
{Role: "user", Content: buildCodegenUserPrompt(lang, req.Name, req.Description)},
},
MaxTokens: 1024,
}, nil
case aiModeExplain:
if lang == "" {
return llmChatRequest{}, fmt.Errorf("language is required")
}
if strings.TrimSpace(req.Code) == "" {
return llmChatRequest{}, fmt.Errorf("code is required")
}
return llmChatRequest{
Model: "gpt-oss-120b",
Messages: []llmMessage{
{Role: "system", Content: buildExplainSystemPrompt(lang)},
{Role: "user", Content: buildExplainUserPrompt(lang, req.Code)},
},
MaxTokens: 1024,
}, nil
default:
question := strings.TrimSpace(req.Question)
if question == "" {
return llmChatRequest{}, fmt.Errorf("question is required")
}
return llmChatRequest{
Model: "gpt-oss-120b",
Messages: []llmMessage{
{Role: "system", Content: buildChatSystemPrompt()},
{Role: "user", Content: question},
},
MaxTokens: 1024,
}, nil
}
}
func normalizeAIMode(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case aiModeCodegen:
return aiModeCodegen
case aiModeExplain:
return aiModeExplain
default:
return aiModeChat
}
}
func normalizeLanguage(language string) string {
return strings.ToLower(strings.TrimSpace(language))
}
func buildCodegenSystemPrompt(lang string) string {
switch lang {
case "python":
return strings.TrimSpace(`Ты генератор кода для Fission Console.
Возвращай ТОЛЬКО код, без markdown, без пояснений, без fenced-блоков.
Генерируй только Fission-usable Python code.
Канон для Python: файл main.py, entrypoint main.main, функция def main(): без аргументов.
Не генерируй def main(ctx), def handler, Flask-приложение или обычный CLI-main.
Если нужен ответ HTTP-подобного вида, возвращай dict со status/body/headers или строку; не выдумывай лишние зависимости.
Если описание пользователя расплывчатое, выбери минимальную рабочую Fission-функцию.`)
case "nodejs":
return strings.TrimSpace(`Ты генератор кода для Fission Console.
Возвращай ТОЛЬКО код, без markdown, без пояснений, без fenced-блоков.
Генерируй только Fission-usable Node.js code.
Канон для Node.js: user code должен быть CommonJS-style, обычно module.exports = async function (context) { ... }.
Не используй ESM import/export в user code. Не пиши обычный CLI main.
Возвращай объект вида { status, body, headers } или строку/JSON, совместимые с Fission node wrapper.
Если описание пользователя расплывчатое, выбери минимальную рабочую Fission-функцию.`)
case "go":
return strings.TrimSpace(`Ты генератор кода для Fission Console.
Возвращай ТОЛЬКО код, без markdown, без пояснений, без fenced-блоков.
Генерируй только Fission-usable Go code.
Канон для Go: package main, экспортированная функция func Handler(w http.ResponseWriter, r *http.Request).
Не генерируй func main() как entrypoint и не делай CLI-программу.
Импортируй net/http и другие пакеты только если они реально нужны.
Если описание пользователя расплывчатое, выбери минимальную рабочую Fission-функцию.`)
case "php":
return strings.TrimSpace(`Ты генератор кода для Fission Console.
Возвращай ТОЛЬКО код, без markdown, без пояснений, без fenced-блоков.
Генерируй только Fission-usable PHP code.
Канон для PHP: файл main.php, entrypoint main.php::handler, функция function handler($context).
Не пиши CLI-скрипт и не выдумывай другой entrypoint.
Для ответа используй $context["response"] или совместимый Fission response pattern.
Если описание пользователя расплывчатое, выбери минимальную рабочую Fission-функцию.`)
case "ruby":
return strings.TrimSpace(`Ты генератор кода для Fission Console.
Возвращай ТОЛЬКО код, без markdown, без пояснений, без fenced-блоков.
Генерируй только Fission-usable Ruby code.
Канон для Ruby: файл handler.rb, entrypoint handler, функция def handler.
Не пиши CLI-скрипт и не выдумывай другой entrypoint.
Если описание пользователя расплывчатое, выбери минимальную рабочую Fission-функцию.`)
default:
return strings.TrimSpace(`Ты генератор кода для Fission Console.
Возвращай ТОЛЬКО код, без markdown, без пояснений, без fenced-блоков.
Генерируй только Fission-usable code.
Если язык не распознан, выбери минимальную рабочую реализацию без лишней обвязки.
Если описание пользователя расплывчатое, выбери минимальную рабочую Fission-функцию.`)
}
}
func buildCodegenUserPrompt(lang, name, description string) string {
funcName := strings.TrimSpace(name)
if funcName == "" {
funcName = defaultPromptFunctionName(lang)
}
goal := strings.TrimSpace(description)
if goal == "" {
goal = "Сгенерируй минимальную рабочую Fission-функцию."
}
return strings.TrimSpace(fmt.Sprintf(`Сгенерируй функцию для Fission.
Language: %s
Function name: %s
What to do: %s
Правила:
1) Верни только код.
2) Соблюдай канон Fission для этого языка.
3) Если пользователь описал задачу плохо или противоречиво, выбери самый безопасный и минимальный рабочий вариант.
4) Не добавляй markdown, пояснений и лишних файлов.
5) Код должен быть пригоден для вставки в console и запуска в Fission без ручной правки entrypoint.`, lang, funcName, goal))
}
func buildExplainSystemPrompt(lang string) string {
return strings.TrimSpace(fmt.Sprintf(`Ты анализатор кода для Fission Console.
Отвечай кратко, по делу и без markdown-блоков.
Сначала объясни, что делает код.
Затем отдельно скажи, подходит ли он для Fission %s и почему.
Если entrypoint, сигнатура или return shape не совпадают с каноном Fission, скажи это прямо.
Не переписывай код и не выдумывай поведение, которого нет.`, prettyLanguageName(lang)))
}
func buildExplainUserPrompt(lang, code string) string {
return strings.TrimSpace(fmt.Sprintf(`Объясни этот %s код и проверь его на совместимость с Fission.
Требования к ответу:
1) Что делает код.
2) Совместим ли он с Fission.
3) Если не совместим — что именно сломано (entrypoint, сигнатура, return, экспорт, файл).
Код:
%s`, prettyLanguageName(lang), strings.TrimSpace(code)))
}
func buildChatSystemPrompt() string {
return strings.TrimSpace(`Ты ассистент платформы NUBES Fission Console — веб-интерфейса для запуска serverless-функций.
Отвечай кратко, по делу, понятным языком без технического жаргона.
## Что такое Fission Console
Веб-интерфейс по адресу https://fission.kube5s.ru/console/
Позволяет создавать, редактировать, вызывать и удалять функции без знания инфраструктуры.
## Вход в систему
- Токен из Личного кабинета (Профиль → Токены) — рекомендуется
- Демо-логин — любая строка не менее 6 символов. Один и тот же логин всегда даёт одно и то же окружение (namespace).
## Кнопки верхней панели
- **Refresh** — обновить список функций
- **✏️ Из кода** — создать новую функцию, написав код прямо в браузере
- **📦 Из архива** — создать новую функцию из zip-архива с файлами
- **Help** — справка по консоли
- **Выход** — выйти из системы
## Таблица функций — что означают столбцы
- **Имя** — уникальное имя функции
- **Окружение** — язык выполнения (python, nodejs, go, php, ruby и др.)
- **Пакет** — имя внутреннего объекта с кодом функции (технический, менять не нужно)
- **Создана / Изменена** — дата и время создания и последнего изменения
- **Маршрут** — HTTP-путь через который вызывается функция (например /my-fn)
- **Методы** — разрешённые HTTP методы (GET, POST и др.)
- **Cron** — расписание автозапуска (например ` + "`*/5 * * * *`" + ` = каждые 5 минут). Если пусто — cron не задан.
- **Действия** — кнопки управления функцией (см. ниже)
## Кнопки действий в строке функции
- **Ред.** — открыть редактор: изменить код, timeout, cron
- **Вызов** — вызвать функцию прямо из браузера, увидеть ответ и время выполнения
- **Логи** — посмотреть вывод функции (то что она пишет через print/console.log/log.Printf и т.д.)
- **Удалить** — удалить функцию (необратимо)
## Создание функции "Из кода"
Поля формы:
- **Name** — имя функции (строчные буквы, цифры, дефис; напр. my-func)
- **Language** — язык (Python, Node.js, Go, PHP, Ruby)
- **Entrypoint** — точка входа. По умолчанию подставляется автоматически. Для Python: main.main, для Node.js: main, для Go: main, для PHP: main.php::handler, для Ruby: handler
- **Route** — URL-путь (напр. /my-func). Именно по этому пути функция доступна снаружи
- **Методы** — HTTP методы через запятую (GET, POST, GET,POST и т.д.)
- **Timeout** — максимальное время выполнения в секундах
- **Cron** — расписание в формате cron (необязательно). Включается переключателем.
- **Код** — редактор кода прямо в браузере
Кнопки в редакторе кода:
- **🔍 Проверить синтаксис линтером** — проверяет код на синтаксические ошибки
- **✨ Сгенерировать код LLM** — описать задачу текстом, получить готовый код
- **📖 LLM: Что делает?** — объяснение что делает текущий код
## Создание функции "Из архива"
Аналогично "Из кода", но вместо редактора кода — загрузка zip-файла.
- **🔍 Проверка архива линтером** — проверяет архив на ошибки и совместимость
- **📖 LLM: Что делает?** — объяснение содержимого архива
## Редактирование функции (кнопка Ред.)
- **Name, Environment, Entrypoint** — только для просмотра, не редактируются
- **Timeout** — можно изменить
- **Cron** — можно включить/выключить и изменить расписание
- **Код** — редактируется (для функций созданных из кода)
- **Заменить архив** — загрузить новый zip (для функций из архива)
- **Переменные окружения** — раздел в конце редактора. Кнопка "+ Добавить" добавляет переменную (имя + значение). Кнопка "Сохранить env vars" применяет изменения. Переменные сразу доступны в поде функции через os.environ (Python), process.env (Node.js), os.Getenv (Go) и т.д.
- **📋 Клон** — кнопка в нижней панели. Позволяет создать копию функции с новым именем. Клон наследует код, язык, entrypoint, timeout. Маршрут назначается автоматически.
## Вызов функции (кнопка Вызов)
Открывается модальное окно. Можно указать тело запроса (JSON) и нажать "Вызвать".
Показывается ответ функции, HTTP статус и время выполнения.
Если функция долго не отвечает — это нормально при первом вызове (cold start, прогрев 5-15 секунд).
## Логи (кнопка Логи)
Показывает вывод функции: всё что она пишет через print(), console.log(), fmt.Println() и т.д.
Кнопка "Обновить" — перезагрузить логи.
Логи видны пока функция активна (обрабатывает запросы или работает по cron).
## Вызов функции снаружи (через API)
Функции доступны по адресу: https://fission.kube5s.ru/fn/ВАШ_ROUTE
Нужен заголовок: Authorization: Bearer ВАШ_ТОКЕН
Пример curl:
curl -skL -X POST https://fission.kube5s.ru/fn/ВАШ_ROUTE \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"key":"value"}'
Пример Python:
import requests
response = requests.post(
"https://fission.kube5s.ru/fn/ВАШ_ROUTE",
headers={"Authorization": "Bearer ВАШ_ТОКЕН"},
json={"key": "value"}, timeout=60
)
## Форматы функций по языкам
Python (entrypoint: main.main):
def main():
return "Hello"
Node.js (entrypoint: main):
module.exports = async function(context) {
return { status: 200, body: "Hello" }
}
Go (entrypoint: main):
func Handler(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello"))
}
PHP (entrypoint: main.php::handler):
function handler($context) {
return ["status" => 200, "body" => "Hello"];
}
Ruby (entrypoint: handler):
def handler(context)
"Hello"
end
## Cron расписание — формат
Формат: минута час день_месяца месяц день_недели
Примеры:
- ` + "`*/5 * * * *`" + ` — каждые 5 минут
- ` + "`0 * * * *`" + ` — каждый час
- ` + "`0 9 * * 1-5`" + ` — в 9:00 по будням
- ` + "`@hourly`" + `, ` + "`@daily`" + `, ` + "`@weekly`" + ` — стандартные псевдонимы
## ESC и закрытие окон
Нажатие ESC закрывает любое открытое окно (модалку).`)
}
func prettyLanguageName(lang string) string {
switch normalizeLanguage(lang) {
case "nodejs":
return "Node.js"
case "python":
return "Python"
case "go":
return "Go"
case "php":
return "PHP"
case "ruby":
return "Ruby"
default:
return "этот язык"
}
}
func defaultPromptFunctionName(lang string) string {
switch normalizeLanguage(lang) {
case "go":
return "Handler"
case "php":
return "handler"
case "ruby":
return "handler"
case "nodejs":
return "main"
default:
return "main"
}
}
@@ -0,0 +1,282 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestNormalizeAIMode(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{name: "blank defaults to chat", in: "", want: aiModeChat},
{name: "case-insensitive codegen", in: " CODEGEN ", want: aiModeCodegen},
{name: "case-insensitive explain", in: "Explain", want: aiModeExplain},
{name: "unknown defaults to chat", in: "boom", want: aiModeChat},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := normalizeAIMode(tt.in); got != tt.want {
t.Fatalf("normalizeAIMode(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
func TestDefaultPromptFunctionName(t *testing.T) {
tests := []struct {
lang string
want string
}{
{lang: "python", want: "main"},
{lang: "nodejs", want: "main"},
{lang: "go", want: "Handler"},
{lang: "php", want: "handler"},
{lang: "ruby", want: "handler"},
{lang: "unknown", want: "main"},
}
for _, tt := range tests {
t.Run(tt.lang, func(t *testing.T) {
if got := defaultPromptFunctionName(tt.lang); got != tt.want {
t.Fatalf("defaultPromptFunctionName(%q) = %q, want %q", tt.lang, got, tt.want)
}
})
}
}
func TestBuildLLMChatRequestCodegenPython(t *testing.T) {
req, err := buildLLMChatRequest(aiPromptRequest{
Mode: aiModeCodegen,
Language: "python",
Name: " ",
Description: " сделать простой echo endpoint ",
})
if err != nil {
t.Fatalf("buildLLMChatRequest error = %v", err)
}
if req.Model != "gpt-oss-120b" {
t.Fatalf("unexpected model %q", req.Model)
}
if got, want := len(req.Messages), 2; got != want {
t.Fatalf("messages len = %d, want %d", got, want)
}
sys := req.Messages[0].Content
usr := req.Messages[1].Content
if !strings.Contains(sys, "Fission") || !strings.Contains(sys, "main.main") || !strings.Contains(sys, "def main():") {
t.Fatalf("python system prompt is missing Fission rules: %s", sys)
}
if !strings.Contains(sys, "не генерируй def main(ctx)") && !strings.Contains(strings.ToLower(sys), "def main(ctx)") {
t.Fatalf("python system prompt must forbid ctx main: %s", sys)
}
if !strings.Contains(usr, "Language: python") {
t.Fatalf("python user prompt missing language: %s", usr)
}
if !strings.Contains(usr, "Function name: main") {
t.Fatalf("blank name should fall back to main: %s", usr)
}
if !strings.Contains(usr, "echo endpoint") {
t.Fatalf("description must be present: %s", usr)
}
if !strings.Contains(usr, "Верни только код") || !strings.Contains(usr, "entrypoint") {
t.Fatalf("python user prompt missing constraints: %s", usr)
}
}
func TestBuildLLMChatRequestCodegenNodeJS(t *testing.T) {
req, err := buildLLMChatRequest(aiPromptRequest{
Mode: aiModeCodegen,
Language: "nodejs",
Name: "handler",
Description: "возвращает status/body и не падает на пустом input",
})
if err != nil {
t.Fatalf("buildLLMChatRequest error = %v", err)
}
sys := req.Messages[0].Content
usr := req.Messages[1].Content
for _, want := range []string{"CommonJS", "module.exports", "Fission node wrapper", "status, body, headers"} {
if !strings.Contains(sys, want) {
t.Fatalf("node system prompt missing %q: %s", want, sys)
}
}
if !strings.Contains(usr, "Language: nodejs") || !strings.Contains(usr, "Function name: handler") {
t.Fatalf("node user prompt missing fields: %s", usr)
}
}
func TestBuildLLMChatRequestCodegenGo(t *testing.T) {
req, err := buildLLMChatRequest(aiPromptRequest{
Mode: aiModeCodegen,
Language: "go",
Description: "печатает ответ и ставит content-type",
})
if err != nil {
t.Fatalf("buildLLMChatRequest error = %v", err)
}
sys := req.Messages[0].Content
usr := req.Messages[1].Content
for _, want := range []string{"package main", "func Handler(w http.ResponseWriter, r *http.Request)", "net/http"} {
if !strings.Contains(strings.ToLower(sys), strings.ToLower(want)) {
t.Fatalf("go system prompt missing %q: %s", want, sys)
}
}
if !strings.Contains(usr, "Function name: Handler") {
t.Fatalf("go user prompt must default to Handler: %s", usr)
}
}
func TestBuildLLMChatRequestCodegenPHP(t *testing.T) {
req, err := buildLLMChatRequest(aiPromptRequest{Mode: aiModeCodegen, Language: "php", Description: "simple greeting"})
if err != nil {
t.Fatalf("buildLLMChatRequest error = %v", err)
}
sys := req.Messages[0].Content
for _, want := range []string{"main.php", "main.php::handler", "function handler($context)", "$context[\"response\"]"} {
if !strings.Contains(sys, want) {
t.Fatalf("php system prompt missing %q: %s", want, sys)
}
}
}
func TestBuildLLMChatRequestCodegenRuby(t *testing.T) {
req, err := buildLLMChatRequest(aiPromptRequest{Mode: aiModeCodegen, Language: "ruby", Description: "return hello"})
if err != nil {
t.Fatalf("buildLLMChatRequest error = %v", err)
}
sys := req.Messages[0].Content
for _, want := range []string{"handler.rb", "entrypoint handler", "def handler"} {
if !strings.Contains(sys, want) {
t.Fatalf("ruby system prompt missing %q: %s", want, sys)
}
}
}
func TestBuildLLMChatRequestExplain(t *testing.T) {
req, err := buildLLMChatRequest(aiPromptRequest{
Mode: aiModeExplain,
Language: "python",
Code: "def main():\n return 'ok'\n",
})
if err != nil {
t.Fatalf("buildLLMChatRequest error = %v", err)
}
sys := req.Messages[0].Content
usr := req.Messages[1].Content
for _, want := range []string{"объясни", "совместим ли он с Fission", "Если entrypoint", "не переписывай код"} {
if !strings.Contains(strings.ToLower(sys+"\n"+usr), strings.ToLower(want)) {
t.Fatalf("explain prompt missing %q: sys=%s usr=%s", want, sys, usr)
}
}
}
func TestBuildLLMChatRequestChat(t *testing.T) {
req, err := buildLLMChatRequest(aiPromptRequest{Mode: aiModeChat, Question: "что делает слой 2?"})
if err != nil {
t.Fatalf("buildLLMChatRequest error = %v", err)
}
if len(req.Messages) != 2 {
t.Fatalf("expected 2 messages, got %d", len(req.Messages))
}
if req.Messages[1].Content != "что делает слой 2?" {
t.Fatalf("chat question mismatch: %q", req.Messages[1].Content)
}
}
func TestBuildLLMChatRequestRejectsMissingFields(t *testing.T) {
if _, err := buildLLMChatRequest(aiPromptRequest{Mode: aiModeCodegen, Description: "x"}); err == nil {
t.Fatal("expected codegen without language to fail")
}
if _, err := buildLLMChatRequest(aiPromptRequest{Mode: aiModeExplain, Language: "python"}); err == nil {
t.Fatal("expected explain without code to fail")
}
if _, err := buildLLMChatRequest(aiPromptRequest{Mode: aiModeChat}); err == nil {
t.Fatal("expected chat without question to fail")
}
}
func TestHandleAIAskUsesCodegenPrompt(t *testing.T) {
var gotReq llmChatRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/chat/completions" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {
t.Fatalf("decode upstream request: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"module.exports = async function () { return { status: 200, body: \"ok\" }; }"}}]}`))
}))
defer server.Close()
s := &Server{http: server.Client(), llmURL: server.URL, llmKey: "secret"}
req := httptest.NewRequest(http.MethodPost, "/console/api/ai/ask", bytes.NewBufferString(`{"mode":"codegen","language":"python","description":"сделай echo"}`))
rec := httptest.NewRecorder()
s.handleAIAsk(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
if gotReq.Model != "gpt-oss-120b" {
t.Fatalf("model = %q", gotReq.Model)
}
if len(gotReq.Messages) != 2 {
t.Fatalf("messages len = %d", len(gotReq.Messages))
}
if !strings.Contains(gotReq.Messages[0].Content, "Python") || !strings.Contains(gotReq.Messages[1].Content, "echo") {
t.Fatalf("unexpected upstream prompts: %#v", gotReq.Messages)
}
}
func TestHandleAIAskUsesExplainPrompt(t *testing.T) {
var gotReq llmChatRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {
t.Fatalf("decode upstream request: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"код делает hello"}}]}`))
}))
defer server.Close()
s := &Server{http: server.Client(), llmURL: server.URL, llmKey: "secret"}
req := httptest.NewRequest(http.MethodPost, "/console/api/ai/ask", bytes.NewBufferString(`{"mode":"explain","language":"go","code":"package main\nfunc Handler(w http.ResponseWriter, r *http.Request) {}"}`))
rec := httptest.NewRecorder()
s.handleAIAsk(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
if !strings.Contains(gotReq.Messages[0].Content, "Go") || !strings.Contains(gotReq.Messages[1].Content, "совместим") {
t.Fatalf("unexpected explain prompts: %#v", gotReq.Messages)
}
}
func TestHandleAIAskKeepsChatModeSimple(t *testing.T) {
var gotReq llmChatRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {
t.Fatalf("decode upstream request: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
}))
defer server.Close()
s := &Server{http: server.Client(), llmURL: server.URL, llmKey: "secret"}
req := httptest.NewRequest(http.MethodPost, "/console/api/ai/ask", bytes.NewBufferString(`{"question":"что такое слой 1?"}`))
rec := httptest.NewRecorder()
s.handleAIAsk(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
if gotReq.Messages[1].Content != "что такое слой 1?" {
t.Fatalf("chat question mismatch: %#v", gotReq.Messages)
}
}
+85
View File
@@ -0,0 +1,85 @@
package api
import (
"context"
"fmt"
"net/http"
"strings"
"fission-console/internal/auth"
)
// ctxKeyNS — ключ для K8s namespace пользователя в context.Context.
type ctxKeyNS struct{}
// ctxKeyIdentity — ключ для UserIdentity пользователя в context.Context.
type ctxKeyIdentity struct{}
// authTokenFromRequest извлекает Bearer токен из X-Auth-Token или Authorization заголовка.
func authTokenFromRequest(r *http.Request) string {
token := strings.TrimSpace(r.Header.Get("X-Auth-Token"))
if token != "" {
return token
}
authz := strings.TrimSpace(r.Header.Get("Authorization"))
if authz == "" {
return ""
}
const bearerPrefix = "Bearer "
if len(authz) >= len(bearerPrefix) && strings.EqualFold(authz[:len(bearerPrefix)], bearerPrefix) {
return strings.TrimSpace(authz[len(bearerPrefix):])
}
return ""
}
// userNS возвращает namespace пользователя из контекста запроса.
func (s *Server) userNS(r *http.Request) string {
if ns, ok := r.Context().Value(ctxKeyNS{}).(string); ok && ns != "" {
return ns
}
return s.ns
}
// normalizeEnv приводит название стенда к допустимому значению.
func normalizeEnv(env string) string {
switch strings.TrimSpace(strings.ToLower(env)) {
case "prod", "dev", "test":
return strings.TrimSpace(strings.ToLower(env))
}
return "test"
}
// authMiddleware оборачивает handler аутентификацией.
// Токен передаётся в s.authenticator.Authenticate — детали скрыты за интерфейсом.
func (s *Server) authMiddleware(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var identity auth.UserIdentity
env := normalizeEnv(r.Header.Get("X-Auth-Env"))
token := authTokenFromRequest(r)
var err error
identity, err = s.authenticator.Authenticate(r.Context(), token, env)
if err != nil {
writeJSONError(w, http.StatusUnauthorized, "unauthorized")
return
}
ns := auth.NamespaceForSub(identity.Sub)
ctx := s.contextWithIdentity(r.Context(), identity)
if err := s.nsManager.EnsureUserNS(ctx, ns); err != nil {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("ensure namespace: %v", err))
return
}
h(w, r.WithContext(ctx))
}
}
// contextWithIdentity кладёт UserIdentity и вычисленный namespace в контекст.
func (s *Server) contextWithIdentity(ctx context.Context, identity auth.UserIdentity) context.Context {
ns := auth.NamespaceForSub(identity.Sub)
ctx = context.WithValue(ctx, ctxKeyNS{}, ns)
ctx = context.WithValue(ctx, ctxKeyIdentity{}, identity)
return ctx
}
+203
View File
@@ -0,0 +1,203 @@
package api
import (
"encoding/json"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
)
type CronMetric struct {
Timestamp string `json:"timestamp"`
Source string `json:"source"`
Hostname string `json:"hostname,omitempty"`
Status string `json:"status,omitempty"`
MemoryTotalMB float64 `json:"memory_total_mb,omitempty"`
MemoryFreeMB float64 `json:"memory_free_mb,omitempty"`
MemoryAvailMB float64 `json:"memory_available_mb,omitempty"`
MemoryUsedMB float64 `json:"memory_used_mb,omitempty"`
MemoryPercent float64 `json:"memory_percent,omitempty"`
CpuLoad1m float64 `json:"cpu_load_1m,omitempty"`
CpuLoad5m float64 `json:"cpu_load_5m,omitempty"`
CpuLoad15m float64 `json:"cpu_load_15m,omitempty"`
DiskTotalGB float64 `json:"disk_total_gb,omitempty"`
DiskFreeGB float64 `json:"disk_free_gb,omitempty"`
DiskUsedGB float64 `json:"disk_used_gb,omitempty"`
UptimeSec float64 `json:"uptime_sec,omitempty"`
Notes string `json:"notes,omitempty"`
}
var (
cronMetricMu sync.RWMutex
cronMetricHistory []CronMetric
cronMetricMaxKeep = 240
)
func (s *Server) handleCronMetrics(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
cronMetricMu.RLock()
history := append([]CronMetric(nil), cronMetricHistory...)
cronMetricMu.RUnlock()
latest := CronMetric{}
if len(history) > 0 {
latest = history[len(history)-1]
}
writeAnyJSON(w, http.StatusOK, map[string]any{
"count": len(history),
"latest": latest,
"history": history,
})
case http.MethodPost:
if !allowCronIngest(r) {
writeJSONError(w, http.StatusUnauthorized, "invalid cron token")
return
}
var payload map[string]any
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid json payload")
return
}
sample := normalizeCronMetric(payload)
cronMetricMu.Lock()
cronMetricHistory = append(cronMetricHistory, sample)
if len(cronMetricHistory) > cronMetricMaxKeep {
cronMetricHistory = append([]CronMetric(nil), cronMetricHistory[len(cronMetricHistory)-cronMetricMaxKeep:]...)
}
count := len(cronMetricHistory)
cronMetricMu.Unlock()
writeAnyJSON(w, http.StatusOK, map[string]any{"ok": true, "stored": count, "latest": sample})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func allowCronIngest(r *http.Request) bool {
expected := strings.TrimSpace(os.Getenv("CRON_TOKEN"))
if expected == "" {
return true
}
return strings.TrimSpace(r.Header.Get("X-Cron-Token")) == expected
}
func normalizeCronMetric(payload map[string]any) CronMetric {
now := time.Now().UTC().Format(time.RFC3339)
metric := CronMetric{
Timestamp: now,
Source: textValue(payload, "source", "function", "name", "host", "hostname", "instance"),
Hostname: textValue(payload, "hostname", "host", "node", "instance"),
Status: textValue(payload, "status"),
Notes: textValue(payload, "notes", "note", "message"),
}
if metric.Source == "" {
metric.Source = "cron"
}
metric.MemoryTotalMB = numberValue(payload, "memory_total_mb", "total_memory_mb", "mem_total_mb", "mem_total")
metric.MemoryFreeMB = numberValue(payload, "memory_free_mb", "free_memory_mb", "mem_free_mb", "free_mb")
metric.MemoryAvailMB = numberValue(payload, "memory_available_mb", "available_memory_mb", "mem_available_mb", "avail_memory_mb")
metric.MemoryUsedMB = numberValue(payload, "memory_used_mb", "used_memory_mb", "mem_used_mb", "used_mb")
metric.MemoryPercent = numberValue(payload, "memory_percent", "mem_percent", "memory_usage_percent")
if metric.MemoryUsedMB == 0 && metric.MemoryTotalMB > 0 && metric.MemoryFreeMB > 0 {
metric.MemoryUsedMB = metric.MemoryTotalMB - metric.MemoryFreeMB
}
if metric.MemoryAvailMB == 0 && metric.MemoryFreeMB > 0 {
metric.MemoryAvailMB = metric.MemoryFreeMB
}
if metric.MemoryPercent == 0 && metric.MemoryTotalMB > 0 && metric.MemoryUsedMB > 0 {
metric.MemoryPercent = (metric.MemoryUsedMB / metric.MemoryTotalMB) * 100
}
metric.CpuLoad1m = numberValue(payload, "cpu_load_1m", "loadavg_1m", "load_1m", "load1")
metric.CpuLoad5m = numberValue(payload, "cpu_load_5m", "loadavg_5m", "load_5m", "load5")
metric.CpuLoad15m = numberValue(payload, "cpu_load_15m", "loadavg_15m", "load_15m", "load15")
metric.DiskTotalGB = numberValue(payload, "disk_total_gb", "total_disk_gb", "disk_total_mb", "disk_total")
metric.DiskFreeGB = numberValue(payload, "disk_free_gb", "free_disk_gb", "disk_free_mb", "disk_free")
metric.DiskUsedGB = numberValue(payload, "disk_used_gb", "used_disk_gb", "disk_used_mb", "disk_used")
if metric.DiskUsedGB == 0 && metric.DiskTotalGB > 0 && metric.DiskFreeGB > 0 {
metric.DiskUsedGB = metric.DiskTotalGB - metric.DiskFreeGB
}
if uptime := numberValue(payload, "uptime_sec", "uptime", "uptime_seconds"); uptime > 0 {
metric.UptimeSec = uptime
}
if ts := textValue(payload, "timestamp", "ts", "collected_at"); ts != "" {
metric.Timestamp = ts
}
return metric
}
func textValue(payload map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := payload[key]; ok {
text := strings.TrimSpace(toString(value))
if text != "" {
return text
}
}
}
return ""
}
func numberValue(payload map[string]any, keys ...string) float64 {
for _, key := range keys {
if value, ok := payload[key]; ok {
if number, ok := toFloat64(value); ok {
return number
}
}
}
return 0
}
func toString(value any) string {
switch v := value.(type) {
case string:
return v
case []byte:
return string(v)
case float64:
return strconv.FormatFloat(v, 'f', -1, 64)
case float32:
return strconv.FormatFloat(float64(v), 'f', -1, 32)
case int:
return strconv.FormatInt(int64(v), 10)
case int64:
return strconv.FormatInt(v, 10)
case json.Number:
return v.String()
default:
return ""
}
}
func toFloat64(value any) (float64, bool) {
switch v := value.(type) {
case float64:
return v, true
case float32:
return float64(v), true
case int:
return float64(v), true
case int64:
return float64(v), true
case json.Number:
f, err := v.Float64()
return f, err == nil
case string:
if strings.TrimSpace(v) == "" {
return 0, false
}
n, err := strconv.ParseFloat(strings.TrimSpace(v), 64)
if err == nil {
return n, true
}
return 0, false
default:
return 0, false
}
}
+2
View File
@@ -0,0 +1,2 @@
// Package api содержит HTTP сервер, роутинг, handlers и middleware.
package api
@@ -0,0 +1,180 @@
package api
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
const (
// maxExplainCodeBytes — суммарный лимит кода из архива для отправки в LLM.
// LLM бесплатный и слабый — не перегружаем контекст.
maxExplainCodeBytes = 20 * 1024
)
// handleExplainArchive — POST /console/api/ai/explain-archive
// Принимает zip-архив, извлекает код, отправляет в LLM с вопросом "что делает?".
func (s *Server) handleExplainArchive(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
if s.llmKey == "" {
writeJSONError(w, http.StatusServiceUnavailable, "AI-ассистент не настроен (FISSION_LLM_KEY не задан)")
return
}
r.Body = http.MaxBytesReader(w, r.Body, 10*1024*1024)
if err := r.ParseMultipartForm(maxArchiveBytes); err != nil {
writeJSONError(w, http.StatusBadRequest, "ошибка разбора multipart: "+err.Error())
return
}
f, _, err := r.FormFile("archive")
if err != nil {
writeJSONError(w, http.StatusBadRequest, "поле 'archive' обязательно")
return
}
defer f.Close()
var buf bytes.Buffer
if _, err := buf.ReadFrom(f); err != nil {
writeJSONError(w, http.StatusBadRequest, "ошибка чтения архива: "+err.Error())
return
}
if buf.Len() > maxArchiveBytes {
writeJSONError(w, http.StatusRequestEntityTooLarge,
fmt.Sprintf("архив слишком большой: максимум %d KB", maxArchiveBytes/1024))
return
}
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
if err != nil {
writeJSONError(w, http.StatusBadRequest, "не удалось открыть zip: "+err.Error())
return
}
// Поддерживаемые расширения
supported := map[string]bool{".py": true, ".js": true, ".rb": true, ".php": true, ".go": true}
type fileChunk struct {
name string
code string
}
var chunks []fileChunk
var totalBytes int
for _, zf := range zr.File {
if zf.FileInfo().IsDir() {
continue
}
ext := strings.ToLower(archiveFileExt(zf.Name))
if !supported[ext] {
continue
}
if totalBytes >= maxExplainCodeBytes {
break
}
rc, err := zf.Open()
if err != nil {
continue
}
limit := maxExplainCodeBytes - totalBytes
content, err := io.ReadAll(io.LimitReader(rc, int64(limit)))
rc.Close()
if err != nil || len(content) == 0 {
continue
}
chunks = append(chunks, fileChunk{name: zf.Name, code: string(content)})
totalBytes += len(content)
}
if len(chunks) == 0 {
writeJSONError(w, http.StatusBadRequest, "нет поддерживаемых файлов (.py, .js, .rb, .php, .go)")
return
}
// Собираем текст для LLM
var sb strings.Builder
sb.WriteString("Ниже приведены файлы из архива serverless-функции. Опиши КРАТКО (2-5 предложений) что делает эта функция.\n\n")
for _, c := range chunks {
sb.WriteString(fmt.Sprintf("=== %s ===\n%s\n\n", c.name, c.code))
}
prompt := sb.String()
llmReqBody := llmChatRequest{
Model: "gpt-oss-120b",
Messages: []llmMessage{
{Role: "system", Content: "Ты ассистент для анализа serverless-функций. Отвечай кратко, на русском языке."},
{Role: "user", Content: prompt},
},
MaxTokens: 512,
}
body, _ := json.Marshal(llmReqBody)
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
llmReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
strings.TrimRight(s.llmURL, "/")+"/chat/completions",
bytes.NewReader(body),
)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
llmReq.Header.Set("Content-Type", "application/json")
llmReq.Header.Set("Authorization", "Bearer "+s.llmKey)
resp, err := s.http.Do(llmReq)
if err != nil {
writeJSONError(w, http.StatusBadGateway, "LLM недоступен: "+err.Error())
return
}
defer resp.Body.Close()
var llmResp struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 64*1024)).Decode(&llmResp); err != nil {
writeJSONError(w, http.StatusBadGateway, "parse error: "+err.Error())
return
}
if llmResp.Error != nil {
writeJSONError(w, http.StatusBadGateway, llmResp.Error.Message)
return
}
if len(llmResp.Choices) == 0 {
writeJSONError(w, http.StatusBadGateway, "пустой ответ от LLM")
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"answer": strings.TrimSpace(llmResp.Choices[0].Message.Content),
})
}
// archiveFileExt возвращает расширение файла из пути внутри zip (включая точку).
func archiveFileExt(name string) string {
for i := len(name) - 1; i >= 0 && name[i] != '/'; i-- {
if name[i] == '.' {
return name[i:]
}
}
return ""
}
@@ -0,0 +1,342 @@
// Package api — создание и обновление функций из zip-архива (multipart/form-data).
//
// Этот файл отвечает за два сценария:
// 1. handleCreateFunctionFromArchive — создание новой функции из загруженного .zip файла.
// 2. handleUpdateFunctionArchive — обновление существующей функции новым .zip файлом.
//
// Архив загружается пользователем через форму с полем "archive".
// Содержимое архива передаётся в storagesvc → S3 без модификации.
// В отличие от function_code.go, здесь нет трансформации кода — архив идёт как есть.
//
// Связанная аннотация: fission-console/source-type = "archive"
// позволяет UI определить режим редактирования при открытии функции.
package api
import (
"context"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"fission-console/internal/fission"
"fission-console/internal/runtime"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
// handleCreateFunctionFromArchive создаёт функцию из загруженного zip-архива (multipart/form-data).
// Поля формы: name, language (или environment), entrypoint, route, methods, timeout, ttl.
// Файловое поле: archive (.zip).
func (s *Server) handleCreateFunctionFromArchive(w http.ResponseWriter, r *http.Request, ns string) {
if err := r.ParseMultipartForm(maxArchiveUploadSize); err != nil {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("parse multipart form: %v", err))
return
}
name := strings.TrimSpace(r.FormValue("name"))
if name == "" || (!validFuncName.MatchString(name) || len(name) > 57) {
writeJSONError(w, http.StatusBadRequest, "invalid function name: must match ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ and be <= 57 chars")
return
}
lang := strings.TrimSpace(r.FormValue("language"))
envName := strings.TrimSpace(r.FormValue("environment"))
f, fhCreate, err := r.FormFile("archive")
if err != nil {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("archive file required: %v", err))
return
}
defer f.Close()
archiveBytes, err := io.ReadAll(io.LimitReader(f, maxArchiveUploadSize))
if err != nil {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("read archive: %v", err))
return
}
archiveFilenameCreate := ""
if fhCreate != nil {
archiveFilenameCreate = fhCreate.Filename
}
// Проверяем magic bytes: zip должен начинаться с PK (0x50 0x4B)
if len(archiveBytes) < 4 || archiveBytes[0] != 0x50 || archiveBytes[1] != 0x4B {
writeJSONError(w, http.StatusBadRequest, "загруженный файл не является zip-архивом (ожидается .zip)")
return
}
nsCtx, nsCancel := context.WithTimeout(r.Context(), 60*time.Second)
defer nsCancel()
if err := s.nsManager.EnsureUserNS(nsCtx, ns); err != nil {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("ensure namespace: %v", err))
return
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
// Определяем environment: по языку или явно
if lang != "" {
envCtx, envCancel := context.WithTimeout(r.Context(), 15*time.Second)
defer envCancel()
resolved, envErr := fission.EnsureEnvironment(envCtx, s.dyn, ns, lang)
if envErr != nil {
if strings.Contains(envErr.Error(), "unsupported language") {
writeJSONError(w, http.StatusBadRequest, envErr.Error())
} else {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("ensure environment: %v", envErr))
}
return
}
envName = resolved
}
if envName == "" {
writeJSONError(w, http.StatusBadRequest, "language or environment is required")
return
}
if _, err := s.dyn.Resource(fission.EnvironmentGVR).Namespace(ns).Get(ctx, envName, metav1.GetOptions{}); err != nil {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("environment %q not found: %v", envName, err))
return
}
entrypoint := strings.TrimSpace(r.FormValue("entrypoint"))
if entrypoint == "" {
entrypoint = runtime.DefaultEntrypoint(lang)
}
route := strings.TrimSpace(r.FormValue("route"))
if route == "" {
nsShort := ns
if len(nsShort) > 12 {
nsShort = nsShort[len(nsShort)-12:]
}
route = "/" + nsShort + "/" + name
}
if !strings.HasPrefix(route, "/") {
route = "/" + route
}
methods := normalizeMethods(strings.Split(r.FormValue("methods"), ","))
timeout := normalizeFunctionTimeout(0)
if tv := r.FormValue("timeout"); tv != "" {
if n, err := strconv.ParseInt(tv, 10, 64); err == nil {
timeout = normalizeFunctionTimeout(n)
}
}
// Загружаем архив в storagesvc
deploySpec, uploadErr := s.buildDeploySpec(ctx, archiveBytes)
if uploadErr != nil {
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("upload archive: %v", uploadErr))
return
}
pkgName := name + "-pkg"
triggerName := name + "-route"
pkg := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "Package",
"metadata": map[string]any{"name": pkgName, "namespace": ns},
"spec": map[string]any{
"deployment": deploySpec,
"environment": map[string]any{"name": envName, "namespace": ns},
"source": map[string]any{},
},
}}
now := time.Now().UTC()
fnAnnotations := map[string]any{
"fission-console/language": lang,
fissionSourceTypeAnnotation: "archive",
functionCreatedAtAnnotation: now.Format(time.RFC3339),
functionUpdatedAtAnnotation: now.Format(time.RFC3339),
}
if archiveFilenameCreate != "" {
fnAnnotations["fission-console/archive-filename"] = archiveFilenameCreate
}
if ttl := r.FormValue("ttl"); ttl != "" {
if expiresAt, ttlErr := parseTTL(ttl); ttlErr == nil {
fnAnnotations["fission-console/expires-at"] = expiresAt.UTC().Format(time.RFC3339)
}
}
methodValues := make([]any, 0, len(methods))
for _, m := range methods {
methodValues = append(methodValues, m)
}
fn := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "Function",
"metadata": map[string]any{"name": name, "namespace": ns, "annotations": fnAnnotations},
"spec": map[string]any{
"environment": map[string]any{"name": envName, "namespace": ns},
"package": map[string]any{"packageref": map[string]any{"name": pkgName, "namespace": ns}},
"InvokeStrategy": map[string]any{
"ExecutionStrategy": map[string]any{"ExecutorType": "poolmgr"},
"StrategyType": "execution",
},
"functionTimeout": timeout,
},
}}
if entrypoint != "" {
_ = unstructured.SetNestedField(fn.Object, entrypoint, "spec", "package", "functionName")
}
trigger := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "HTTPTrigger",
"metadata": map[string]any{"name": triggerName, "namespace": ns},
"spec": map[string]any{
"functionref": map[string]any{"name": name, "type": "name"},
"relativeurl": route,
"methods": methodValues,
},
}}
if _, err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Create(ctx, pkg, metav1.CreateOptions{}); err != nil {
if apierrors.IsAlreadyExists(err) {
writeJSONError(w, http.StatusConflict, fmt.Sprintf("function %q already exists", name))
return
}
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create package: %v", err))
return
}
if _, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Create(ctx, fn, metav1.CreateOptions{}); err != nil {
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, pkgName, metav1.DeleteOptions{})
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create function: %v", err))
return
}
if _, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).Create(ctx, trigger, metav1.CreateOptions{}); err != nil {
_ = s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Delete(ctx, name, metav1.DeleteOptions{})
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, pkgName, metav1.DeleteOptions{})
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create trigger: %v", err))
return
}
writeAnyJSON(w, http.StatusCreated, map[string]any{
"name": name,
"namespace": ns,
"environment": envName,
"route": route,
"source_type": "archive",
})
}
// handleUpdateFunctionArchive обновляет функцию из загруженного zip-архива (multipart/form-data).
// Поля формы: timeout (optional), entrypoint (optional). Файловое поле: archive (.zip).
// Создаёт новый Package (новое имя) — чтобы executor сбросил кэш function service.
func (s *Server) handleUpdateFunctionArchive(w http.ResponseWriter, r *http.Request, name string) {
if err := r.ParseMultipartForm(maxArchiveUploadSize); err != nil {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("parse multipart form: %v", err))
return
}
f, _, err := r.FormFile("archive")
if err != nil {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("archive file required: %v", err))
return
}
defer f.Close()
archiveBytes, err := io.ReadAll(io.LimitReader(f, maxArchiveUploadSize))
if err != nil {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("read archive: %v", err))
return
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
ns := s.userNS(r)
fn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
if err != nil {
status := http.StatusBadGateway
if apierrors.IsNotFound(err) {
status = http.StatusNotFound
}
writeJSONError(w, status, fmt.Sprintf("get function %q: %v", name, err))
return
}
oldPkgName, _, _ := unstructured.NestedString(fn.Object, "spec", "package", "packageref", "name")
envName, _, _ := unstructured.NestedString(fn.Object, "spec", "environment", "name")
newPkgName := name + "-pkg-" + strconv.FormatInt(time.Now().UnixMilli(), 36)
deploySpec, uploadErr := s.buildDeploySpec(ctx, archiveBytes)
if uploadErr != nil {
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("upload archive: %v", uploadErr))
return
}
newPkg := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "Package",
"metadata": map[string]any{"name": newPkgName, "namespace": ns},
"spec": map[string]any{
"deployment": deploySpec,
"environment": map[string]any{"name": envName, "namespace": ns},
"source": map[string]any{},
},
}}
createdPkg, err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Create(ctx, newPkg, metav1.CreateOptions{})
if err != nil {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create new package: %v", err))
return
}
// Обновляем timeout если задан
timeout := normalizeFunctionTimeout(0)
if tv := r.FormValue("timeout"); tv != "" {
if n, err := strconv.ParseInt(tv, 10, 64); err == nil {
timeout = normalizeFunctionTimeout(n)
}
}
_ = unstructured.SetNestedField(fn.Object, timeout, "spec", "functionTimeout")
// Обновляем entrypoint если передан
if ep := strings.TrimSpace(r.FormValue("entrypoint")); ep != "" {
_ = unstructured.SetNestedField(fn.Object, ep, "spec", "package", "functionName")
}
fnAnnotations := fn.GetAnnotations()
if fnAnnotations == nil {
fnAnnotations = map[string]string{}
}
fnAnnotations[fissionSourceTypeAnnotation] = "archive"
fnAnnotations[functionUpdatedAtAnnotation] = time.Now().UTC().Format(time.RFC3339)
if fh, fhErr := r.MultipartForm.File["archive"]; fhErr == false || len(fh) > 0 {
if files := r.MultipartForm.File["archive"]; len(files) > 0 && files[0].Filename != "" {
fnAnnotations["fission-console/archive-filename"] = files[0].Filename
}
}
fn.SetAnnotations(fnAnnotations)
if err := unstructured.SetNestedField(fn.Object, map[string]any{
"name": newPkgName,
"namespace": ns,
"resourceversion": createdPkg.GetResourceVersion(),
}, "spec", "package", "packageref"); err != nil {
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, newPkgName, metav1.DeleteOptions{})
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("set packageref: %v", err))
return
}
if _, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Update(ctx, fn, metav1.UpdateOptions{}); err != nil {
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, newPkgName, metav1.DeleteOptions{})
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("update function: %v", err))
return
}
if oldPkgName != "" && oldPkgName != newPkgName {
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, oldPkgName, metav1.DeleteOptions{})
}
writeAnyJSON(w, http.StatusOK, map[string]any{
"updated": true,
"package": newPkgName,
"source_type": "archive",
})
}
@@ -0,0 +1,333 @@
// Package api — клонирование функций.
//
// handleCloneFunction создаёт полную копию функции с новым именем:
// - скачивает архив из storagesvc (или копирует literal)
// - заливает новый архив (отдельный объект в S3)
// - создаёт новый Package, Function и HTTPTrigger
//
// Архив переливается заново, чтобы удаление оригинала не сломало клон.
package api
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"time"
"fission-console/internal/billing"
"fission-console/internal/fission"
"fission-console/internal/runtime"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
// handleCloneFunction — POST /functions/:name/clone
// Body: {"new_name": "my-clone", "route": "/my-clone"} (route необязателен)
func (s *Server) handleCloneFunction(w http.ResponseWriter, r *http.Request, srcName string) {
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
ns := s.userNS(r)
var req struct {
NewName string `json:"new_name"`
Route string `json:"route"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("decode request: %v", err))
return
}
req.NewName = strings.TrimSpace(req.NewName)
req.Route = strings.TrimSpace(req.Route)
// Валидация нового имени
if req.NewName == "" {
writeJSONError(w, http.StatusBadRequest, "new_name is required")
return
}
validName := regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`)
if !validName.MatchString(req.NewName) || len(req.NewName) > 57 {
writeJSONError(w, http.StatusBadRequest, "invalid new_name: must match ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ and be <= 57 chars")
return
}
// Получаем исходную функцию
srcFn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, srcName, metav1.GetOptions{})
if err != nil {
if apierrors.IsNotFound(err) {
writeJSONError(w, http.StatusNotFound, fmt.Sprintf("function %q not found", srcName))
return
}
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("get function: %v", err))
return
}
// Получаем имя пакета исходной функции
srcPkgName, _, _ := unstructured.NestedString(srcFn.Object, "spec", "package", "packageref", "name")
if srcPkgName == "" {
writeJSONError(w, http.StatusBadGateway, "source function has no package reference")
return
}
// Получаем исходный Package
srcPkg, err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Get(ctx, srcPkgName, metav1.GetOptions{})
if err != nil {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("get source package: %v", err))
return
}
// Скачиваем байты архива из Package
archiveBytes, err := s.downloadPackageBytes(ctx, srcPkg)
if err != nil {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("download archive: %v", err))
return
}
// Загружаем как новый архив
newDeploySpec, err := s.buildDeploySpec(ctx, archiveBytes)
if err != nil {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("upload clone archive: %v", err))
return
}
// Параметры для нового пакета (берём spec из оригинала)
now := time.Now().UTC()
newPkgName := req.NewName + "-" + now.Format("20060102150405")
// Определяем environment из исходной функции
envName, _, _ := unstructured.NestedString(srcFn.Object, "spec", "environment", "name")
// Собираем spec пакета (аналогично оригиналу, но с новыми байтами)
// Если у оригинала есть source (Go) — копируем source spec
srcSourceSpec, _, _ := unstructured.NestedMap(srcPkg.Object, "spec", "source")
hasBuildCmd, _, _ := unstructured.NestedString(srcPkg.Object, "spec", "buildcommand")
var newPkgSpec map[string]any
if hasBuildCmd != "" {
// Go: source package
newPkgSpec = map[string]any{
"source": newDeploySpec, // перезаливаем source
"deployment": map[string]any{},
"environment": map[string]any{"name": envName, "namespace": ns},
"buildcommand": hasBuildCmd,
}
_ = srcSourceSpec
} else {
newPkgSpec = map[string]any{
"deployment": newDeploySpec,
"environment": map[string]any{"name": envName, "namespace": ns},
"source": map[string]any{},
}
}
newPkg := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "Package",
"metadata": map[string]any{"name": newPkgName, "namespace": ns},
"spec": newPkgSpec,
}}
if _, err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Create(ctx, newPkg, metav1.CreateOptions{}); err != nil {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create clone package: %v", err))
return
}
// Копируем аннотации из исходной функции
srcAnnotations := srcFn.GetAnnotations()
newAnnotations := map[string]any{
functionCreatedAtAnnotation: now.Format(time.RFC3339),
functionUpdatedAtAnnotation: now.Format(time.RFC3339),
}
for _, k := range []string{
"fission-console/language",
fissionSourceTypeAnnotation,
"fission-console/env-vars",
"fission-console/archive-filename",
} {
if v, ok := srcAnnotations[k]; ok && v != "" {
newAnnotations[k] = v
}
}
newAnnotations["fission-console/cloned-from"] = srcName
// Копируем entrypoint
entrypoint, _, _ := unstructured.NestedString(srcFn.Object, "spec", "package", "functionName")
timeout, _, _ := unstructured.NestedInt64(srcFn.Object, "spec", "functionTimeout")
if timeout == 0 {
timeout = 60
}
// Копируем InvokeStrategy и podspec
invokeStrategy, _, _ := unstructured.NestedMap(srcFn.Object, "spec", "InvokeStrategy")
if invokeStrategy == nil {
invokeStrategy = map[string]any{
"ExecutionStrategy": map[string]any{"ExecutorType": "poolmgr"},
"StrategyType": "execution",
}
}
podspec, _, _ := unstructured.NestedMap(srcFn.Object, "spec", "podspec")
newFnSpec := map[string]any{
"environment": map[string]any{"name": envName, "namespace": ns},
"functionTimeout": timeout,
"InvokeStrategy": invokeStrategy,
"package": map[string]any{
"packageref": map[string]any{"name": newPkgName, "namespace": ns},
"functionName": entrypoint,
},
}
if len(podspec) > 0 {
newFnSpec["podspec"] = podspec
}
newFn := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "Function",
"metadata": map[string]any{
"name": req.NewName,
"namespace": ns,
"annotations": newAnnotations,
},
"spec": newFnSpec,
}}
if _, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Create(ctx, newFn, metav1.CreateOptions{}); err != nil {
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, newPkgName, metav1.DeleteOptions{})
if apierrors.IsAlreadyExists(err) {
writeJSONError(w, http.StatusConflict, fmt.Sprintf("function %q already exists", req.NewName))
return
}
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create clone function: %v", err))
return
}
// Маршрут для нового триггера
if req.Route == "" {
nsShort := ns
if len(nsShort) > 12 {
nsShort = nsShort[len(nsShort)-12:]
}
req.Route = "/" + nsShort + "/" + req.NewName
}
if !strings.HasPrefix(req.Route, "/") {
req.Route = "/" + req.Route
}
triggerName := req.NewName + "-route"
// Определяем методы из существующего триггера оригинала
methods := s.getTriggerMethods(ctx, ns, srcName)
if len(methods) == 0 {
methods = []any{"GET"}
}
newTrigger := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "HTTPTrigger",
"metadata": map[string]any{"name": triggerName, "namespace": ns},
"spec": map[string]any{
"relativeurl": req.Route,
"methods": methods,
"createingress": true,
"functionref": map[string]any{"type": "name", "name": req.NewName},
},
}}
if _, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).Create(ctx, newTrigger, metav1.CreateOptions{}); err != nil {
_ = s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Delete(ctx, req.NewName, metav1.DeleteOptions{})
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, newPkgName, metav1.DeleteOptions{})
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create httptrigger: %v", err))
return
}
s.billing.RecordInvocation(billing.Invocation{
Namespace: ns,
FunctionName: req.NewName,
TriggerType: billing.TriggerEvent,
StartedAt: now,
StatusCode: http.StatusCreated,
RecordedBy: "console",
EventType: "clone",
})
writeAnyJSON(w, http.StatusCreated, map[string]any{
"name": req.NewName,
"cloned_from": srcName,
"package": newPkgName,
"route": req.Route,
})
}
// downloadPackageBytes извлекает байты архива из Package CRD.
// Поддерживает type:url (скачивает из storagesvc) и type:literal (base64).
func (s *Server) downloadPackageBytes(ctx context.Context, pkg *unstructured.Unstructured) ([]byte, error) {
// Пробуем deployment сначала, потом source (для Go)
for _, field := range [][]string{{"spec", "deployment"}, {"spec", "source"}} {
spec, _, _ := unstructured.NestedMap(pkg.Object, field...)
if len(spec) == 0 {
continue
}
archiveType, _ := spec["type"].(string)
switch archiveType {
case "url":
archiveURL, _ := spec["url"].(string)
if archiveURL == "" {
continue
}
return s.downloadFromStoragesvc(ctx, archiveURL)
case "literal":
lit, _ := spec["literal"].(string)
if lit == "" {
continue
}
return base64.StdEncoding.DecodeString(lit)
}
}
// Последний шанс: если функция Python с простым кодом — возвращаем заглушку
return nil, fmt.Errorf("no downloadable archive found in package (empty deployment and source spec)")
}
// downloadFromStoragesvc скачивает архив по URL из storagesvc.
func (s *Server) downloadFromStoragesvc(ctx context.Context, archiveURL string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, archiveURL, nil)
if err != nil {
return nil, fmt.Errorf("build download request: %w", err)
}
resp, err := s.http.Do(req)
if err != nil {
return nil, fmt.Errorf("download archive: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("download archive status %d", resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read archive body: %w", err)
}
return data, nil
}
// getTriggerMethods возвращает методы HTTP из триггера функции (для копирования в клон).
func (s *Server) getTriggerMethods(ctx context.Context, ns, fnName string) []any {
triggers, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).List(ctx, metav1.ListOptions{})
if err != nil {
return nil
}
for _, t := range triggers.Items {
ref, _, _ := unstructured.NestedString(t.Object, "spec", "functionref", "name")
if ref != fnName {
continue
}
methods, _, _ := unstructured.NestedSlice(t.Object, "spec", "methods")
if len(methods) > 0 {
return methods
}
}
return nil
}
// Ссылка на runtime.DefaultEntrypoint для возможного использования в будущем
var _ = runtime.DefaultEntrypoint
@@ -0,0 +1,631 @@
package api
import (
"archive/zip"
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"fission-console/internal/billing"
"fission-console/internal/fission"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
k8sruntime "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
dynamicfake "k8s.io/client-go/dynamic/fake"
)
// ─── helpers ────────────────────────────────────────────────────────────────
// newCloneTestServer создаёт Server с фиктивным k8s-клиентом и
// опциональным HTTP-сервером, имитирующим storagesvc.
func newCloneTestServer(t *testing.T, storagesvcHandler http.HandlerFunc, objs ...*unstructured.Unstructured) (*Server, *httptest.Server) {
t.Helper()
scheme := k8sruntime.NewScheme()
listKinds := map[schema.GroupVersionResource]string{
fission.FunctionGVR: "FunctionList",
fission.PackageGVR: "PackageList",
fission.HTTPTrigGVR: "HTTPTriggerList",
}
runtimeObjs := make([]k8sruntime.Object, len(objs))
for i, o := range objs {
runtimeObjs[i] = o
}
dynClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, runtimeObjs...)
var storagesvc *httptest.Server
httpClient := &http.Client{}
if storagesvcHandler != nil {
storagesvc = httptest.NewServer(storagesvcHandler)
httpClient = storagesvc.Client()
}
s := &Server{
dyn: dynClient,
ns: "fission-test",
http: httpClient,
billing: billing.NoopStore{},
}
return s, storagesvc
}
// cloneRequest выполняет POST /functions/:srcName/clone с телом body.
func cloneRequest(t *testing.T, s *Server, srcName, body string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost,
"/console/api/functions/"+srcName+"/clone",
strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Auth-Token", "test-token")
rec := httptest.NewRecorder()
s.handleCloneFunction(rec, req, srcName)
return rec
}
// makeZip возвращает минимальный zip-архив с одним файлом filename/content.
func makeZip(t *testing.T, filename, content string) []byte {
t.Helper()
var buf bytes.Buffer
w := zip.NewWriter(&buf)
f, err := w.Create(filename)
if err != nil {
t.Fatalf("zip.Create: %v", err)
}
if _, err := io.WriteString(f, content); err != nil {
t.Fatalf("zip.Write: %v", err)
}
if err := w.Close(); err != nil {
t.Fatalf("zip.Close: %v", err)
}
return buf.Bytes()
}
// packageWithLiteral создаёт Package CRD с type:literal (base64-encoded bytes).
func packageWithLiteral(ns, name, fnName string, data []byte) *unstructured.Unstructured {
return &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "Package",
"metadata": map[string]any{"name": name, "namespace": ns},
"spec": map[string]any{
"deployment": map[string]any{
"type": "literal",
"literal": base64.StdEncoding.EncodeToString(data),
},
"environment": map[string]any{"name": fnName, "namespace": ns},
"source": map[string]any{},
},
}}
}
// packageWithURL создаёт Package CRD с type:url.
func packageWithURL(ns, pkgName, envName, archiveURL string) *unstructured.Unstructured {
return &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "Package",
"metadata": map[string]any{"name": pkgName, "namespace": ns},
"spec": map[string]any{
"deployment": map[string]any{
"type": "url",
"url": archiveURL,
},
"environment": map[string]any{"name": envName, "namespace": ns},
"source": map[string]any{},
},
}}
}
// functionWithPkg создаёт Function CRD, ссылающуюся на pkgName.
func functionWithPkg(ns, fnName, pkgName, envName, entrypoint string) *unstructured.Unstructured {
return &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "Function",
"metadata": map[string]any{
"name": fnName,
"namespace": ns,
"annotations": map[string]any{
"fission-console/language": envName,
"fission-console/source-type": "code",
"fission-console/created-at": "2026-05-09T00:00:00Z",
"fission-console/updated-at": "2026-05-09T00:00:00Z",
},
},
"spec": map[string]any{
"environment": map[string]any{"name": envName, "namespace": ns},
"functionTimeout": int64(120),
"package": map[string]any{
"packageref": map[string]any{"name": pkgName, "namespace": ns},
"functionName": entrypoint,
},
"InvokeStrategy": map[string]any{
"ExecutionStrategy": map[string]any{"ExecutorType": "poolmgr"},
"StrategyType": "execution",
},
},
}}
}
// httpTriggerObject создаёт HTTPTrigger CRD для функции fnName.
func httpTriggerObject(ns, trigName, fnName, route string, methods []any) *unstructured.Unstructured {
return &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "HTTPTrigger",
"metadata": map[string]any{"name": trigName, "namespace": ns},
"spec": map[string]any{
"relativeurl": route,
"methods": methods,
"createingress": true,
"functionref": map[string]any{"type": "name", "name": fnName},
},
}}
}
// ─── Тесты валидации имени ───────────────────────────────────────────────────
func TestHandleCloneFunction_InvalidName_EmptyName(t *testing.T) {
s, _ := newCloneTestServer(t, nil)
rec := cloneRequest(t, s, "src", `{"new_name":""}`)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body)
}
if !strings.Contains(rec.Body.String(), "new_name is required") {
t.Fatalf("body should mention 'new_name is required', got: %s", rec.Body)
}
}
func TestHandleCloneFunction_InvalidName_UpperCase(t *testing.T) {
s, _ := newCloneTestServer(t, nil)
rec := cloneRequest(t, s, "src", `{"new_name":"MyFunc"}`)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body)
}
}
func TestHandleCloneFunction_InvalidName_StartsWithDash(t *testing.T) {
s, _ := newCloneTestServer(t, nil)
rec := cloneRequest(t, s, "src", `{"new_name":"-bad"}`)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body)
}
}
func TestHandleCloneFunction_InvalidName_EndsWithDash(t *testing.T) {
s, _ := newCloneTestServer(t, nil)
rec := cloneRequest(t, s, "src", `{"new_name":"bad-"}`)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body)
}
}
func TestHandleCloneFunction_InvalidName_TooLong(t *testing.T) {
s, _ := newCloneTestServer(t, nil)
longName := strings.Repeat("a", 58)
rec := cloneRequest(t, s, "src", fmt.Sprintf(`{"new_name":%q}`, longName))
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body)
}
}
func TestHandleCloneFunction_ValidName_MaxLength(t *testing.T) {
// 57 символов — допустимо; но функция src не существует → 404
s, _ := newCloneTestServer(t, nil)
name57 := strings.Repeat("a", 57)
rec := cloneRequest(t, s, "src", fmt.Sprintf(`{"new_name":%q}`, name57))
// Ожидаем не 400 (прошло валидацию), а 404 (src не найден)
if rec.Code == http.StatusBadRequest {
t.Fatalf("57-char name should pass validation, got 400; body=%s", rec.Body)
}
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body)
}
}
func TestHandleCloneFunction_ValidName_WithDashes(t *testing.T) {
// Имя вида "a-b-c" — допустимо
s, _ := newCloneTestServer(t, nil)
rec := cloneRequest(t, s, "nonexist", `{"new_name":"a-b-c"}`)
if rec.Code == http.StatusBadRequest {
t.Fatalf("'a-b-c' should pass validation, got 400; body=%s", rec.Body)
}
}
// ─── Тест: источник не существует ────────────────────────────────────────────
func TestHandleCloneFunction_SourceNotFound(t *testing.T) {
s, _ := newCloneTestServer(t, nil)
rec := cloneRequest(t, s, "ghost", `{"new_name":"ghost-copy"}`)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body)
}
if !strings.Contains(rec.Body.String(), "ghost") {
t.Fatalf("body should mention 'ghost', got: %s", rec.Body)
}
}
// ─── Тест: успешный клон с type:literal ──────────────────────────────────────
func TestHandleCloneFunction_Success_Literal(t *testing.T) {
const ns = "fission-test"
zipData := makeZip(t, "main.py", "def main():\n return 'hello'\n")
pkg := packageWithLiteral(ns, "src-pkg", "python", zipData)
fn := functionWithPkg(ns, "src", "src-pkg", "python", "main.main")
trig := httpTriggerObject(ns, "src-route", "src", "/test-ns/src", []any{"GET", "POST"})
// storagesvc принимает загрузку и возвращает URL
storagesvc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
// Имитируем загрузку — возвращаем ID архива
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"id":"fission/new-archive-id-123"}`)
return
}
http.NotFound(w, r)
}))
defer storagesvc.Close()
s, _ := newCloneTestServer(t, nil, pkg, fn, trig)
s.storagesvcURL = storagesvc.URL
s.http = storagesvc.Client()
rec := cloneRequest(t, s, "src", `{"new_name":"src-copy"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body)
}
var resp map[string]any
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp["name"] != "src-copy" {
t.Fatalf("response.name = %v, want 'src-copy'", resp["name"])
}
if resp["cloned_from"] != "src" {
t.Fatalf("response.cloned_from = %v, want 'src'", resp["cloned_from"])
}
if resp["package"] == "" || resp["package"] == nil {
t.Fatalf("response.package is empty")
}
ctx := context.Background()
// Проверяем что Function создана
cloneFn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, "src-copy", metav1.GetOptions{})
if err != nil {
t.Fatalf("clone Function not found: %v", err)
}
// Аннотация cloned-from
ann := cloneFn.GetAnnotations()
if ann["fission-console/cloned-from"] != "src" {
t.Fatalf("annotation cloned-from = %q, want 'src'", ann["fission-console/cloned-from"])
}
// Аннотации language и source-type скопированы
if ann["fission-console/language"] != "python" {
t.Fatalf("annotation language = %q, want 'python'", ann["fission-console/language"])
}
if ann["fission-console/source-type"] != "code" {
t.Fatalf("annotation source-type = %q, want 'code'", ann["fission-console/source-type"])
}
// Entrypoint скопирован
ep, _, _ := unstructured.NestedString(cloneFn.Object, "spec", "package", "functionName")
if ep != "main.main" {
t.Fatalf("entrypoint = %q, want 'main.main'", ep)
}
// Timeout скопирован
timeout, _, _ := unstructured.NestedInt64(cloneFn.Object, "spec", "functionTimeout")
if timeout != 120 {
t.Fatalf("timeout = %d, want 120", timeout)
}
// Проверяем что HTTPTrigger создан
trigName := "src-copy-route"
cloneTrig, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).Get(ctx, trigName, metav1.GetOptions{})
if err != nil {
t.Fatalf("clone HTTPTrigger not found: %v", err)
}
// Методы скопированы из оригинального триггера
methods, _, _ := unstructured.NestedStringSlice(cloneTrig.Object, "spec", "methods")
if len(methods) != 2 {
t.Fatalf("methods = %v, want [GET POST]", methods)
}
// Маршрут сгенерирован автоматически (содержит new_name)
route, _, _ := unstructured.NestedString(cloneTrig.Object, "spec", "relativeurl")
if !strings.HasSuffix(route, "/src-copy") {
t.Fatalf("route = %q, should end with '/src-copy'", route)
}
}
// ─── Тест: маршрут задан явно ────────────────────────────────────────────────
func TestHandleCloneFunction_CustomRoute(t *testing.T) {
const ns = "fission-test"
zipData := makeZip(t, "main.py", "def main():\n return 'hi'\n")
pkg := packageWithLiteral(ns, "fn-pkg", "python", zipData)
fn := functionWithPkg(ns, "fn", "fn-pkg", "python", "main.main")
storagesvc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"id":"fission/arc-456"}`)
}))
defer storagesvc.Close()
s, _ := newCloneTestServer(t, nil, pkg, fn)
s.storagesvcURL = storagesvc.URL
s.http = storagesvc.Client()
rec := cloneRequest(t, s, "fn", `{"new_name":"fn-clone","route":"/custom/path"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body)
}
ctx := context.Background()
trig, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).Get(ctx, "fn-clone-route", metav1.GetOptions{})
if err != nil {
t.Fatalf("HTTPTrigger not found: %v", err)
}
route, _, _ := unstructured.NestedString(trig.Object, "spec", "relativeurl")
if route != "/custom/path" {
t.Fatalf("route = %q, want '/custom/path'", route)
}
}
// ─── Тест: маршрут без слеша — добавляется автоматически ─────────────────────
func TestHandleCloneFunction_RouteWithoutLeadingSlash(t *testing.T) {
const ns = "fission-test"
zipData := makeZip(t, "main.py", "def main():\n return 'hi'\n")
pkg := packageWithLiteral(ns, "fn2-pkg", "python", zipData)
fn := functionWithPkg(ns, "fn2", "fn2-pkg", "python", "main.main")
storagesvc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"id":"fission/arc-789"}`)
}))
defer storagesvc.Close()
s, _ := newCloneTestServer(t, nil, pkg, fn)
s.storagesvcURL = storagesvc.URL
s.http = storagesvc.Client()
rec := cloneRequest(t, s, "fn2", `{"new_name":"fn2-clone","route":"no-leading-slash"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body)
}
ctx := context.Background()
trig, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).Get(ctx, "fn2-clone-route", metav1.GetOptions{})
if err != nil {
t.Fatalf("HTTPTrigger not found: %v", err)
}
route, _, _ := unstructured.NestedString(trig.Object, "spec", "relativeurl")
if !strings.HasPrefix(route, "/") {
t.Fatalf("route = %q, should start with '/'", route)
}
}
// ─── Тест: дублирующее имя → 409 ─────────────────────────────────────────────
func TestHandleCloneFunction_DuplicateName_Conflict(t *testing.T) {
const ns = "fission-test"
zipData := makeZip(t, "main.py", "def main():\n return 'a'\n")
pkg := packageWithLiteral(ns, "dup-pkg", "python", zipData)
fn := functionWithPkg(ns, "dup-src", "dup-pkg", "python", "main.main")
// Уже существующая функция с именем "dup-copy"
existingFn := functionWithPkg(ns, "dup-copy", "other-pkg", "python", "main.main")
storagesvc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"id":"fission/arc-dup"}`)
}))
defer storagesvc.Close()
s, _ := newCloneTestServer(t, nil, pkg, fn, existingFn)
s.storagesvcURL = storagesvc.URL
s.http = storagesvc.Client()
rec := cloneRequest(t, s, "dup-src", `{"new_name":"dup-copy"}`)
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409; body=%s", rec.Code, rec.Body)
}
if !strings.Contains(rec.Body.String(), "dup-copy") {
t.Fatalf("body should mention 'dup-copy', got: %s", rec.Body)
}
}
// ─── Тест: откат при ошибке создания Function ────────────────────────────────
func TestHandleCloneFunction_Rollback_OnFunctionConflict(t *testing.T) {
const ns = "fission-test"
zipData := makeZip(t, "main.py", "def main():\n return 'rb'\n")
pkg := packageWithLiteral(ns, "rb-pkg", "python", zipData)
fn := functionWithPkg(ns, "rb-src", "rb-pkg", "python", "main.main")
// Уже существующая функция с именем клона
existingFn := functionWithPkg(ns, "rb-clone", "other-pkg", "python", "main.main")
storagesvc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"id":"fission/arc-rb"}`)
}))
defer storagesvc.Close()
s, _ := newCloneTestServer(t, nil, pkg, fn, existingFn)
s.storagesvcURL = storagesvc.URL
s.http = storagesvc.Client()
rec := cloneRequest(t, s, "rb-src", `{"new_name":"rb-clone"}`)
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409; body=%s", rec.Code, rec.Body)
}
// После rollback Package с именем "rb-clone-*" не должен остаться
ctx := context.Background()
pkgs, err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).List(ctx, metav1.ListOptions{})
if err != nil {
t.Fatalf("list packages: %v", err)
}
for _, p := range pkgs.Items {
if strings.HasPrefix(p.GetName(), "rb-clone-") {
t.Fatalf("rollback failed: package %q still exists", p.GetName())
}
}
}
// ─── Тест: downloadPackageBytes — type:literal ────────────────────────────────
func TestDownloadPackageBytes_Literal(t *testing.T) {
s, _ := newCloneTestServer(t, nil)
original := []byte("hello from literal")
pkg := packageWithLiteral("fission-test", "lit-pkg", "python", original)
got, err := s.downloadPackageBytes(context.Background(), pkg)
if err != nil {
t.Fatalf("downloadPackageBytes error: %v", err)
}
if !bytes.Equal(got, original) {
t.Fatalf("got %q, want %q", got, original)
}
}
// ─── Тест: downloadPackageBytes — type:url ────────────────────────────────────
func TestDownloadPackageBytes_URL(t *testing.T) {
const content = "archive-content-from-storagesvc"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, content)
}))
defer srv.Close()
pkg := packageWithURL("fission-test", "url-pkg", "python", srv.URL+"/v1/archive?id=abc")
s := &Server{http: srv.Client()}
got, err := s.downloadPackageBytes(context.Background(), pkg)
if err != nil {
t.Fatalf("downloadPackageBytes error: %v", err)
}
if string(got) != content {
t.Fatalf("got %q, want %q", got, content)
}
}
// ─── Тест: downloadPackageBytes — пустой Package → ошибка ────────────────────
func TestDownloadPackageBytes_EmptyPackage_Error(t *testing.T) {
s, _ := newCloneTestServer(t, nil)
emptyPkg := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "Package",
"metadata": map[string]any{"name": "empty", "namespace": "fission-test"},
"spec": map[string]any{},
}}
_, err := s.downloadPackageBytes(context.Background(), emptyPkg)
if err == nil {
t.Fatal("expected error for empty package, got nil")
}
}
// ─── Тест: downloadFromStoragesvc — non-200 → ошибка ────────────────────────
func TestDownloadFromStoragesvc_Non200(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
s := &Server{http: srv.Client()}
_, err := s.downloadFromStoragesvc(context.Background(), srv.URL+"/v1/archive?id=gone")
if err == nil {
t.Fatal("expected error for 404 response, got nil")
}
if !strings.Contains(err.Error(), "404") {
t.Fatalf("error should mention 404, got: %v", err)
}
}
// ─── Тест: getTriggerMethods — триггер существует ────────────────────────────
func TestGetTriggerMethods_Found(t *testing.T) {
const ns = "fission-test"
trig := httpTriggerObject(ns, "my-trig", "my-fn", "/my", []any{"GET", "POST"})
s, _ := newCloneTestServer(t, nil, trig)
methods := s.getTriggerMethods(context.Background(), ns, "my-fn")
if len(methods) != 2 {
t.Fatalf("methods = %v, want 2 elements", methods)
}
}
// ─── Тест: getTriggerMethods — триггера нет → nil ────────────────────────────
func TestGetTriggerMethods_NotFound(t *testing.T) {
s, _ := newCloneTestServer(t, nil)
methods := s.getTriggerMethods(context.Background(), "fission-test", "no-such-fn")
if methods != nil {
t.Fatalf("expected nil, got %v", methods)
}
}
// ─── Тест: невалидный JSON body → 400 ────────────────────────────────────────
func TestHandleCloneFunction_InvalidJSON(t *testing.T) {
s, _ := newCloneTestServer(t, nil)
req := httptest.NewRequest(http.MethodPost, "/console/api/functions/src/clone",
strings.NewReader(`not-json`))
rec := httptest.NewRecorder()
s.handleCloneFunction(rec, req, "src")
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body)
}
}
// ─── Тест: успешный клон — Package содержит ссылку на Function ───────────────
func TestHandleCloneFunction_PackageRefsCloneFunction(t *testing.T) {
const ns = "fission-test"
zipData := makeZip(t, "main.py", "def main():\n return 'ref'\n")
pkg := packageWithLiteral(ns, "ref-pkg", "python", zipData)
fn := functionWithPkg(ns, "ref-src", "ref-pkg", "python", "main.main")
storagesvc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"id":"fission/arc-ref"}`)
}))
defer storagesvc.Close()
s, _ := newCloneTestServer(t, nil, pkg, fn)
s.storagesvcURL = storagesvc.URL
s.http = storagesvc.Client()
rec := cloneRequest(t, s, "ref-src", `{"new_name":"ref-clone"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body)
}
ctx := context.Background()
cloneFn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, "ref-clone", metav1.GetOptions{})
if err != nil {
t.Fatalf("clone function not found: %v", err)
}
pkgRef, _, _ := unstructured.NestedString(cloneFn.Object, "spec", "package", "packageref", "name")
if !strings.HasPrefix(pkgRef, "ref-clone-") {
t.Fatalf("clone Function.spec.package.packageref.name = %q, should start with 'ref-clone-'", pkgRef)
}
// Package с этим именем должен существовать
_, err = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Get(ctx, pkgRef, metav1.GetOptions{})
if err != nil {
t.Fatalf("clone Package %q not found: %v", pkgRef, err)
}
}
@@ -0,0 +1,438 @@
// Package api — создание и обновление функций из исходного кода (inline code).
//
// Этот файл отвечает за два сценария:
// 1. handleCreateFunction — создание новой функции из кода (JSON body).
// 2. handleUpdateFunctionCode — обновление существующей функции: новый код → новый Package.
//
// Поддерживаемые языки: python, nodejs, php, ruby, go.
// Для Go создаётся source package (builder компилирует .so плагин).
// Для остальных языков — deployment archive (zip загружается в storagesvc или как literal).
//
// Почему новый Package при обновлении:
// Fission executor кэширует function service по functionUid и не видит изменений
// в существующем Package. Новое имя пакета гарантирует cache miss в executor.
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"fission-console/internal/billing"
"fission-console/internal/fission"
"fission-console/internal/model"
"fission-console/internal/runtime"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
// buildDeployArchive упаковывает исходный код в байты для deployment Package.
// Для nodejs — ESM-обёртка (package.json + main.js).
// Для php/ruby — zip с одним файлом скрипта.
// Для остальных (python) — raw bytes кода (или zip если есть deps).
// deps — содержимое файла зависимостей (requirements.txt, Gemfile, composer.json).
// Если deps пустой — поведение как раньше.
func buildDeployArchive(lang, code, deps string) ([]byte, error) {
switch lang {
case "nodejs":
// TODO: поддержка package.json с deps для nodejs — пока игнорируем deps
return runtime.BuildJSDeployZip(code)
case "php":
if deps != "" {
return runtime.BuildScriptZipWithDeps(code, "main.php", deps, "composer.json")
}
return runtime.BuildScriptZip(code, "main.php")
case "ruby":
if deps != "" {
return runtime.BuildScriptZipWithDeps(code, "handler.rb", deps, "Gemfile")
}
return runtime.BuildScriptZip(code, "handler.rb")
default: // python
return runtime.BuildPythonZip(code, deps)
}
}
// 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, req.Deps)
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.Deps != "" {
fnAnnotations["fission-console/deps"] = req.Deps
}
if req.TTL != "" {
expiresAt, ttlErr := parseTTL(req.TTL)
if ttlErr != nil {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("invalid ttl %q: %v", req.TTL, ttlErr))
return
}
fnAnnotations["fission-console/expires-at"] = expiresAt.UTC().Format(time.RFC3339)
}
if _, err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Create(ctx, pkg, metav1.CreateOptions{}); err != nil {
if apierrors.IsAlreadyExists(err) {
writeJSONError(w, http.StatusConflict, fmt.Sprintf("function %q already exists", req.Name))
return
}
if apierrors.IsInvalid(err) {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("invalid function spec: %v", err))
return
}
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create package: %v", err))
return
}
fn := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "Function",
"metadata": map[string]any{"name": req.Name, "namespace": ns, "annotations": fnAnnotations},
"spec": map[string]any{
"environment": map[string]any{"name": req.Environment, "namespace": ns},
"functionTimeout": req.Timeout,
"InvokeStrategy": map[string]any{
"ExecutionStrategy": map[string]any{"ExecutorType": "poolmgr"},
"StrategyType": "execution",
},
"package": map[string]any{
"packageref": map[string]any{"name": pkgName, "namespace": ns},
"functionName": req.Entrypoint,
},
},
}}
if _, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Create(ctx, fn, metav1.CreateOptions{}); err != nil {
// Откатываем Package если Function не создалась
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, pkgName, metav1.DeleteOptions{})
if apierrors.IsAlreadyExists(err) {
writeJSONError(w, http.StatusConflict, fmt.Sprintf("function %q already exists", req.Name))
return
}
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create function: %v", err))
return
}
httpTrigger := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "HTTPTrigger",
"metadata": map[string]any{"name": triggerName, "namespace": ns},
"spec": map[string]any{
"relativeurl": req.Route,
"methods": methodValues,
"createingress": true,
"functionref": map[string]any{"type": "name", "name": req.Name},
},
}}
if _, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).Create(ctx, httpTrigger, metav1.CreateOptions{}); err != nil {
// Откатываем Function и Package
_ = s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Delete(ctx, req.Name, metav1.DeleteOptions{})
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, pkgName, metav1.DeleteOptions{})
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create httptrigger: %v", err))
return
}
s.billing.RecordInvocation(billing.Invocation{
Namespace: ns,
FunctionName: req.Name,
TriggerType: billing.TriggerEvent,
StartedAt: now,
StatusCode: http.StatusCreated,
RecordedBy: "console",
EventType: "create",
})
writeAnyJSON(w, http.StatusCreated, map[string]any{
"name": req.Name,
"package": pkgName,
"httptrigger": triggerName,
"route": req.Route,
"expires_at": fnAnnotations["fission-console/expires-at"],
})
}
// handleUpdateFunctionCode обновляет код уже существующей функции.
// Создаёт НОВЫЙ Package (вместо обновления старого) чтобы executor сбросил кэш:
// executor кэширует function service по functionUid и не видит изменений в том же Package.
// Новое имя пакета гарантирует cache miss в executor.
func (s *Server) handleUpdateFunctionCode(w http.ResponseWriter, r *http.Request, name string) {
var req model.UpdateCodeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("decode request: %v", err))
return
}
req.Code = strings.TrimSpace(req.Code)
if req.Code == "" {
writeJSONError(w, http.StatusBadRequest, "code is required")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
ns := s.userNS(r)
fn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
if err != nil {
status := http.StatusBadGateway
if apierrors.IsNotFound(err) {
status = http.StatusNotFound
}
writeJSONError(w, status, fmt.Sprintf("get function %q: %v", name, err))
return
}
oldPkgName, _, _ := unstructured.NestedString(fn.Object, "spec", "package", "packageref", "name")
// Определяем язык из аннотации — нужен для правильной упаковки
lang, _, _ := unstructured.NestedString(fn.Object, "metadata", "annotations", "fission-console/language")
deployBytes, archiveErr := buildDeployArchive(lang, req.Code, req.Deps)
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)
if req.Deps != "" {
fnAnnotations["fission-console/deps"] = req.Deps
} else {
delete(fnAnnotations, "fission-console/deps")
}
fn.SetAnnotations(fnAnnotations)
if err := unstructured.SetNestedField(fn.Object, map[string]any{
"name": newPkgName,
"namespace": ns,
"resourceversion": createdPkg.GetResourceVersion(),
}, "spec", "package", "packageref"); err != nil {
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("set function packageref: %v", err))
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, newPkgName, metav1.DeleteOptions{})
return
}
if _, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Update(ctx, fn, metav1.UpdateOptions{}); err != nil {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("update function %q: %v", name, err))
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, newPkgName, metav1.DeleteOptions{})
return
}
// Удаляем старый Package (best effort)
if oldPkgName != "" && oldPkgName != newPkgName {
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, oldPkgName, metav1.DeleteOptions{})
}
writeAnyJSON(w, http.StatusOK, map[string]any{
"updated": true,
"package": newPkgName,
})
}
@@ -0,0 +1,529 @@
// Package api — CRUD операции с функциями: чтение, удаление, обновление таймаута, логи, env vars.
//
// Этот файл содержит операции, не связанные с заменой кода/архива:
// - handleGetFunction — GET /functions/:name (детали: код, route, environment, source_type)
// - handleDeleteFunction — DELETE /functions/:name (каскадное удаление: триггеры, Package, S3)
// - handleUpdateFunctionTimeout — PUT /functions/:name/timeout (только таймаут, без замены кода)
// - handleGetFunctionLogs — GET /functions/:name/logs (логи пода через Kubernetes API)
// - handleGetFunctionEnvVars — GET /functions/:name/envvars (переменные окружения из CRD)
// - handlePutFunctionEnvVars — PUT /functions/:name/envvars (обновить env vars в CRD)
//
// Операции с кодом и архивом — в function_code.go и function_archive.go соответственно.
// Вызов функции — в function_invoke.go.
package api
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"fission-console/internal/billing"
"fission-console/internal/fission"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
k8stypes "k8s.io/apimachinery/pkg/types"
)
// handleGetFunction возвращает детали функции: код, environment, route, methods.
func (s *Server) handleGetFunction(w http.ResponseWriter, r *http.Request, name string) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
ns := s.userNS(r)
fn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
if err != nil {
status := http.StatusBadGateway
if apierrors.IsNotFound(err) {
status = http.StatusNotFound
}
writeJSONError(w, status, fmt.Sprintf("get function %q: %v", name, err))
return
}
packageName, _, _ := unstructured.NestedString(fn.Object, "spec", "package", "packageref", "name")
environment, _, _ := unstructured.NestedString(fn.Object, "spec", "environment", "name")
entrypoint, _, _ := unstructured.NestedString(fn.Object, "spec", "package", "functionName")
functionTimeout, foundTimeout, _ := unstructured.NestedInt64(fn.Object, "spec", "functionTimeout")
if !foundTimeout || functionTimeout <= 0 {
functionTimeout = int64(defaultFunctionInvokeTimeout / time.Second)
}
// Извлекаем исходный код из Package (пробуем source.literal, потом deployment.literal, потом url)
code := ""
if packageName != "" {
pkg, pkgErr := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Get(ctx, packageName, metav1.GetOptions{})
if pkgErr == nil {
code = extractPackageSourceCode(ctx, s, pkg)
}
}
// Ищем HTTPTrigger для получения route и methods
route := ""
methods := []string{}
triggers, trigErr := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).List(ctx, metav1.ListOptions{})
if trigErr == nil {
for _, trig := range triggers.Items {
refName, _, _ := unstructured.NestedString(trig.Object, "spec", "functionref", "name")
if refName != name {
continue
}
route, _, _ = unstructured.NestedString(trig.Object, "spec", "relativeurl")
methods, _, _ = unstructured.NestedStringSlice(trig.Object, "spec", "methods")
break
}
}
// Читаем source-type аннотацию (code / archive)
sourceType := "code"
archiveFilename := ""
deps := ""
if ann := fn.GetAnnotations(); ann != nil {
if v := ann[fissionSourceTypeAnnotation]; v != "" {
sourceType = v
}
if v := ann["fission-console/archive-filename"]; v != "" {
archiveFilename = v
}
if v := ann["fission-console/deps"]; v != "" {
deps = 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,
"deps": deps,
"source_type": sourceType,
"archive_filename": archiveFilename,
"route": route,
"methods": methods,
"env_vars": extractEnvVars(fn),
"raw": fn.Object,
})
}
// handleUpdateFunctionTimeout обновляет только spec.functionTimeout функции (без замены кода/архива).
func (s *Server) handleUpdateFunctionTimeout(w http.ResponseWriter, r *http.Request, name string) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
ns := s.userNS(r)
var req struct {
Timeout int64 `json:"timeout"`
Entrypoint string `json:"entrypoint"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("decode request: %v", err))
return
}
timeout := normalizeFunctionTimeout(req.Timeout)
fn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
if err != nil {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("get function %q: %v", name, err))
return
}
if err := unstructured.SetNestedField(fn.Object, timeout, "spec", "functionTimeout"); err != nil {
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("set timeout: %v", err))
return
}
if req.Entrypoint != "" {
_ = unstructured.SetNestedField(fn.Object, req.Entrypoint, "spec", "package", "functionName")
}
now := time.Now().UTC().Format(time.RFC3339)
ann := fn.GetAnnotations()
if ann == nil {
ann = map[string]string{}
}
ann[functionUpdatedAtAnnotation] = now
fn.SetAnnotations(ann)
if _, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Update(ctx, fn, metav1.UpdateOptions{}); err != nil {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("update function %q: %v", name, err))
return
}
writeAnyJSON(w, http.StatusOK, map[string]any{"updated": true, "timeout": timeout})
}
// handleDeleteFunction удаляет функцию и связанные объекты: HTTPTrigger, TimeTrigger, Package.
// После удаления вызывает CleanupEnvironmentIfUnused — убирает environment если язык больше не используется.
func (s *Server) handleDeleteFunction(w http.ResponseWriter, r *http.Request, name string) {
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
ns := s.userNS(r)
// Получаем Function чтобы знать pkgName и envName для cleanup
var pkgName, envName string
fn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
if err != nil {
if apierrors.IsNotFound(err) {
writeJSONError(w, http.StatusNotFound, fmt.Sprintf("function %q not found", name))
return
}
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("get function %q: %v", name, err))
return
}
pkgName, _, _ = unstructured.NestedString(fn.Object, "spec", "package", "packageref", "name")
envName, _, _ = unstructured.NestedString(fn.Object, "spec", "environment", "name")
// Удаляем связанные HTTPTrigger-ы
triggers, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).List(ctx, metav1.ListOptions{})
if err == nil {
for _, trig := range triggers.Items {
refName, _, _ := unstructured.NestedString(trig.Object, "spec", "functionref", "name")
if refName == name {
_ = s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).Delete(ctx, trig.GetName(), metav1.DeleteOptions{})
}
}
}
// Удаляем связанные TimeTrigger-ы
if triggers, err := s.dyn.Resource(fission.TimeTrigGVR).Namespace(ns).List(ctx, metav1.ListOptions{}); err == nil {
for _, trig := range triggers.Items {
refName, _, _ := unstructured.NestedString(trig.Object, "spec", "functionref", "name")
if refName == name {
_ = s.dyn.Resource(fission.TimeTrigGVR).Namespace(ns).Delete(ctx, trig.GetName(), metav1.DeleteOptions{})
}
}
}
if err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Delete(ctx, name, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("delete function %q: %v", name, err))
return
}
if pkgName != "" {
// Получаем URL архива из Package spec.deployment перед удалением, чтобы потом очистить S3
var archiveURL string
if pkg, pkgErr := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Get(ctx, pkgName, metav1.GetOptions{}); pkgErr == nil {
deployType, _, _ := unstructured.NestedString(pkg.Object, "spec", "deployment", "type")
if deployType == "url" {
archiveURL, _, _ = unstructured.NestedString(pkg.Object, "spec", "deployment", "url")
}
}
if err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, pkgName, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("delete package %q: %v", pkgName, err))
return
}
// Удаляем архив из S3 после успешного удаления Package (best-effort)
if archiveURL != "" {
go s.deleteFromStoragesvc(context.Background(), archiveURL)
}
}
// Убираем environment pool pods если язык больше не используется (best-effort)
if envName != "" {
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cleanupCancel()
fission.CleanupEnvironmentIfUnused(cleanupCtx, s.dyn, ns, envName)
}
// (reconciler NS удалён — за FISSION_RESOURCE_NAMESPACES теперь отвечает Layer 1 NSWatcher)
s.billing.RecordInvocation(billing.Invocation{
Namespace: ns,
FunctionName: name,
TriggerType: billing.TriggerEvent,
StartedAt: time.Now(),
StatusCode: http.StatusOK,
RecordedBy: "console",
EventType: "delete",
})
writeAnyJSON(w, http.StatusOK, map[string]any{"deleted": true, "name": name, "package": pkgName})
}
// handleGetFunctionLogs возвращает логи пода функции (последние 100 строк).
// Ищет под по лейблу functionName=<name> в namespace пользователя.
func (s *Server) handleGetFunctionLogs(w http.ResponseWriter, r *http.Request, name string) {
ns := s.userNS(r)
ctx := r.Context()
if s.kube == nil {
writeJSONError(w, http.StatusServiceUnavailable, "kubernetes client not available")
return
}
labelSelector := "functionName=" + name
pods, err := s.kube.CoreV1().Pods(ns).List(ctx, metav1.ListOptions{
LabelSelector: labelSelector,
})
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "list pods: "+err.Error())
return
}
if len(pods.Items) == 0 {
writeAnyJSON(w, http.StatusOK, map[string]any{
"logs": "(нет запущенных подов для функции " + name + ")",
})
return
}
var allLogs strings.Builder
tailLines := int64(100)
for _, pod := range pods.Items {
containerName := ""
if len(pod.Spec.Containers) > 0 {
containerName = pod.Spec.Containers[0].Name
}
req := s.kube.CoreV1().Pods(ns).GetLogs(pod.Name, &corev1.PodLogOptions{
Container: containerName,
TailLines: &tailLines,
})
rc, err := req.Stream(ctx)
if err != nil {
allLogs.WriteString("[" + pod.Name + ": ошибка чтения логов: " + err.Error() + "]\n")
continue
}
data, _ := io.ReadAll(rc)
rc.Close()
if allLogs.Len() > 0 {
allLogs.WriteString("\n--- " + pod.Name + " ---\n")
} else {
allLogs.WriteString("--- " + pod.Name + " ---\n")
}
allLogs.Write(data)
}
writeAnyJSON(w, http.StatusOK, map[string]any{
"logs": allLogs.String(),
})
}
// handleGetFunctionEnvVars возвращает переменные окружения функции из .spec.runtime.container.env
func (s *Server) handleGetFunctionEnvVars(w http.ResponseWriter, r *http.Request, name string) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
ns := s.userNS(r)
fn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
if err != nil {
status := http.StatusBadGateway
if apierrors.IsNotFound(err) {
status = http.StatusNotFound
}
writeJSONError(w, status, fmt.Sprintf("get function %q: %v", name, err))
return
}
envVars := extractEnvVars(fn)
writeAnyJSON(w, http.StatusOK, map[string]any{"env_vars": envVars})
}
// handlePutFunctionEnvVars обновляет переменные окружения функции.
//
// Логика переключения ExecutorType:
// - Если env vars непустые → ExecutorType: newdeploy + spec.podspec.containers[0].env
// (newdeploy создаёт dedicated Deployment, Kubernetes ставит env vars на уровне ОС)
// - Если env vars пустые → ExecutorType: poolmgr, podspec удаляется
// (poolmgr использует warm pool, быстрый cold start)
//
// Это единственный универсальный способ передать env vars в pod для всех языков
// (Python, Go, Ruby, PHP, Node.js) без изменений в env-серверах.
func (s *Server) handlePutFunctionEnvVars(w http.ResponseWriter, r *http.Request, name string) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
ns := s.userNS(r)
var req struct {
EnvVars []map[string]string `json:"env_vars"` // [{name: "KEY", value: "VAL"}, ...]
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("decode request: %v", err))
return
}
// Валидация: имена переменных
for _, ev := range req.EnvVars {
k := ev["name"]
if k == "" {
writeJSONError(w, http.StatusBadRequest, "env var name cannot be empty")
return
}
}
fn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
if err != nil {
status := http.StatusBadGateway
if apierrors.IsNotFound(err) {
status = http.StatusNotFound
}
writeJSONError(w, status, fmt.Sprintf("get function %q: %v", name, err))
return
}
// Сериализуем в JSON для аннотации (для UI)
envJSON, err := json.Marshal(req.EnvVars)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("marshal env vars: %v", err))
return
}
// Обновляем аннотации
now := time.Now().UTC().Format(time.RFC3339)
ann := fn.GetAnnotations()
if ann == nil {
ann = map[string]string{}
}
ann[functionUpdatedAtAnnotation] = now
ann["fission-console/env-vars"] = string(envJSON)
fn.SetAnnotations(ann)
if len(req.EnvVars) > 0 {
// Есть env vars → newdeploy + podspec с env vars
envName, _, _ := unstructured.NestedString(fn.Object, "spec", "environment", "name")
// Строим список env vars для Kubernetes
envList := make([]any, 0, len(req.EnvVars))
for _, ev := range req.EnvVars {
envList = append(envList, map[string]any{
"name": ev["name"],
"value": ev["value"],
})
}
// Устанавливаем podspec.containers[0] с env vars
// Имя контейнера = имя environment (стандарт Fission)
if err := unstructured.SetNestedSlice(fn.Object, []any{
map[string]any{
"name": envName,
"env": envList,
},
}, "spec", "podspec", "containers"); err != nil {
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("set podspec: %v", err))
return
}
// Переключаем на newdeploy (только он поддерживает podspec env)
if err := unstructured.SetNestedField(fn.Object, map[string]any{
"ExecutionStrategy": map[string]any{
"ExecutorType": "newdeploy",
"MinScale": int64(0),
"MaxScale": int64(1),
"SpecializationTimeout": int64(120),
},
"StrategyType": "execution",
}, "spec", "InvokeStrategy"); err != nil {
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("set invoke strategy: %v", err))
return
}
} else {
// Нет env vars → poolmgr, убираем podspec
unstructured.RemoveNestedField(fn.Object, "spec", "podspec")
if err := unstructured.SetNestedField(fn.Object, map[string]any{
"ExecutionStrategy": map[string]any{
"ExecutorType": "poolmgr",
"SpecializationTimeout": int64(120),
},
"StrategyType": "execution",
}, "spec", "InvokeStrategy"); err != nil {
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("set invoke strategy: %v", err))
return
}
}
if _, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Update(ctx, fn, metav1.UpdateOptions{}); err != nil {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("update function %q: %v", name, err))
return
}
// Fission newdeploy НЕ применяет fn.Spec.PodSpec при построении Deployment.
// Поэтому напрямую патчим существующий Deployment через Kubernetes API.
if len(req.EnvVars) > 0 && s.kube != nil {
envName, _, _ := unstructured.NestedString(fn.Object, "spec", "environment", "name")
if envName != "" {
if err := s.patchDeploymentEnvVars(ctx, ns, name, envName, req.EnvVars); err != nil {
// Не фатальная ошибка — CRD обновлён, Deployment будет обновлён позже
_ = err // warn only
}
}
}
executor := "poolmgr"
if len(req.EnvVars) > 0 {
executor = "newdeploy"
}
writeAnyJSON(w, http.StatusOK, map[string]any{"updated": true, "count": len(req.EnvVars), "executor": executor})
}
// extractEnvVars читает аннотацию fission-console/env-vars (JSON) из Function CRD
// Возвращает [{name, value}, ...]
func extractEnvVars(fn *unstructured.Unstructured) []map[string]string {
ann := fn.GetAnnotations()
if ann == nil {
return []map[string]string{}
}
raw := ann["fission-console/env-vars"]
if raw == "" {
return []map[string]string{}
}
var result []map[string]string
if err := json.Unmarshal([]byte(raw), &result); err != nil {
return []map[string]string{}
}
return result
}
// patchDeploymentEnvVars находит Deployment newdeploy для функции и патчит его env vars.
// Fission не применяет fn.Spec.PodSpec при построении Deployment, поэтому патчим напрямую.
// Поиск по labels: functionName=<name>, functionNamespace=<ns>, executorType=newdeploy
func (s *Server) patchDeploymentEnvVars(ctx context.Context, ns, fnName, envContainerName string, envVars []map[string]string) error {
selector := fmt.Sprintf("functionName=%s,functionNamespace=%s,executorType=newdeploy", fnName, ns)
deplList, err := s.kube.AppsV1().Deployments(ns).List(ctx, metav1.ListOptions{LabelSelector: selector})
if err != nil {
return fmt.Errorf("list deployments: %w", err)
}
if len(deplList.Items) == 0 {
return nil // Deployment ещё не создан Fission — ничего страшного
}
// Строим env vars для patch (StrategicMergePatch мержит по "name")
envItems := make([]map[string]string, 0, len(envVars))
for _, ev := range envVars {
envItems = append(envItems, map[string]string{"name": ev["name"], "value": ev["value"]})
}
patch := map[string]any{
"spec": map[string]any{
"template": map[string]any{
"spec": map[string]any{
"containers": []any{
map[string]any{
"name": envContainerName,
"env": envItems,
},
},
},
},
},
}
patchBytes, err := json.Marshal(patch)
if err != nil {
return fmt.Errorf("marshal patch: %w", err)
}
for _, depl := range deplList.Items {
if _, err := s.kube.AppsV1().Deployments(ns).Patch(
ctx, depl.Name, k8stypes.StrategicMergePatchType, patchBytes, metav1.PatchOptions{},
); err != nil {
return fmt.Errorf("patch deployment %s: %w", depl.Name, err)
}
}
return nil
}
@@ -0,0 +1,458 @@
// Package api — вызов функций через Fission router.
//
// Этот файл содержит три способа вызова функций:
// - handleInvokeFunction — POST /functions/:name/invoke (через консоль, для тестирования)
// - handleInvokeRoute — /fn/<route> (публичный gateway, пользователь вызывает по своему маршруту)
// - handleFissionFunctionGateway — /fission-function/<ns>/<name> (внутренний gateway для cron/timer)
//
// Все три варианта проксируют запрос к Fission router с JWT-токеном router.
// Таймаут вызова берётся из spec.functionTimeout функции (или из конфига если не задан).
//
// Вспомогательные утилиты (buildInternalInvokeURL, copyProxyRequestHeaders и др.) — в этом же файле.
package api
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
"fission-console/internal/billing"
"fission-console/internal/fission"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
// resolveInvokeTimeout возвращает таймаут вызова функции.
// Приоритет: spec.functionTimeout функции → конфиг сервера → defaultFunctionInvokeTimeout.
func (s *Server) resolveInvokeTimeout(fn *unstructured.Unstructured) time.Duration {
if fn != nil {
seconds, found, err := unstructured.NestedInt64(fn.Object, "spec", "functionTimeout")
if err == nil && found && seconds > 0 {
return time.Duration(seconds) * time.Second
}
}
if s.invokeTimeout > 0 {
return s.invokeTimeout
}
return defaultFunctionInvokeTimeout
}
// buildInternalInvokeURL строит URL для вызова функции через Fission router.
// Для namespace "default" — /fission-function/<name>.
// Для остальных — /fission-function/<namespace>/<name>.
func buildInternalInvokeURL(routerURL, namespace, functionName string) string {
if namespace == "default" || namespace == "" {
return fmt.Sprintf("%s/fission-function/%s", routerURL, functionName)
}
return fmt.Sprintf("%s/fission-function/%s/%s", routerURL, namespace, functionName)
}
// shouldForwardRequestBody возвращает true если метод подразумевает тело запроса.
// GET и HEAD не имеют тела — тело не проксируется.
func shouldForwardRequestBody(method string) bool {
switch method {
case http.MethodGet, http.MethodHead:
return false
default:
return true
}
}
// copyProxyRequestHeaders копирует заголовки из входящего запроса в исходящий.
// Пропускаем служебные заголовки чтобы не перезаписать их при проксировании.
func copyProxyRequestHeaders(dst, src http.Header) {
for key, values := range src {
switch http.CanonicalHeaderKey(key) {
case "Authorization", "X-Auth-Token", "X-Auth-Env", "Host", "Content-Length":
continue
}
for _, value := range values {
dst.Add(key, value)
}
}
}
// copyProxyResponseHeaders копирует все заголовки из upstream-ответа в ответ клиенту.
func copyProxyResponseHeaders(dst, src http.Header) {
for key, values := range src {
for _, value := range values {
dst.Add(key, value)
}
}
}
// doRequestWithContextTimeout выполняет HTTP-запрос без глобального таймаута клиента.
// Реальный лимит задаётся через context — это позволяет функции иметь свой таймаут
// независимо от общего HTTP-таймаута console.
func doRequestWithContextTimeout(client *http.Client, req *http.Request) (*http.Response, error) {
if client == nil {
return http.DefaultClient.Do(req)
}
invokeClient := *client
invokeClient.Timeout = 0
return invokeClient.Do(req)
}
// handleInvokeFunction вызывает функцию через Fission router.
// Определяет реальный URL из HTTPTrigger, выбирает метод (POST/GET).
func (s *Server) handleInvokeFunction(w http.ResponseWriter, r *http.Request, name string) {
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("read request body: %v", err))
return
}
if len(bytes.TrimSpace(bodyBytes)) == 0 {
bodyBytes = []byte("{}")
}
ns := s.userNS(r)
lookupCtx, lookupCancel := context.WithTimeout(r.Context(), 10*time.Second)
defer lookupCancel()
// Проверяем существование функции до вызова — лучше 404 чем непонятный timeout
fn, err2 := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(lookupCtx, name, metav1.GetOptions{})
if err2 != nil {
if apierrors.IsNotFound(err2) {
writeJSONError(w, http.StatusNotFound, fmt.Sprintf("function %q not found", name))
return
}
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("get function %q: %v", name, err2))
return
}
invokeTimeout := s.resolveInvokeTimeout(fn)
ctx, cancel := context.WithTimeout(r.Context(), invokeTimeout)
defer cancel()
// Ищем HTTPTrigger чтобы получить реальный URL и метод
invokeURL := buildInternalInvokeURL(s.routerURL, ns, name)
invokeMethod := http.MethodPost
triggers, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).List(ctx, metav1.ListOptions{})
if err == nil {
for _, trig := range triggers.Items {
refName, _, _ := unstructured.NestedString(trig.Object, "spec", "functionref", "name")
if refName != name {
continue
}
route, _, _ := unstructured.NestedString(trig.Object, "spec", "relativeurl")
methods, _, _ := unstructured.NestedStringSlice(trig.Object, "spec", "methods")
hasPost, hasGet := false, false
for _, m := range methods {
switch strings.ToUpper(strings.TrimSpace(m)) {
case http.MethodPost:
hasPost = true
case http.MethodGet:
hasGet = true
}
}
if route != "" {
if !strings.HasPrefix(route, "/") {
route = "/" + route
}
invokeURL = s.routerURL + route
// Если функция поддерживает только GET — используем GET
if !hasPost && hasGet {
invokeMethod = http.MethodGet
}
break
}
}
}
start := time.Now()
var invokeBody io.Reader
if invokeMethod == http.MethodPost {
invokeBody = bytes.NewReader(bodyBytes)
}
req, err := http.NewRequestWithContext(ctx, invokeMethod, invokeURL, invokeBody)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build invoke request: %v", err))
return
}
if invokeMethod == http.MethodPost {
req.Header.Set("Content-Type", "application/json")
}
if token := s.getRouterToken(); token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := doRequestWithContextTimeout(s.http, req)
if err != nil {
// Отличаем timeout от сетевой ошибки.
if errors.Is(err, context.DeadlineExceeded) {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("invoke %q timeout after %s", name, invokeTimeout))
return
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("invoke %q timeout after %s", name, invokeTimeout))
return
}
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("invoke %q: %v", name, err))
return
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
durationMS := time.Since(start).Milliseconds()
s.billing.RecordInvocation(billing.Invocation{
Namespace: ns,
FunctionName: name,
TriggerType: billing.TriggerConsole,
Route: invokeURL,
HTTPMethod: invokeMethod,
StartedAt: start,
DurationMS: durationMS,
StatusCode: resp.StatusCode,
RequestBytes: int64(len(bodyBytes)),
ResponseBytes: int64(len(respBody)),
RecordedBy: "console",
EventType: "invoke",
})
writeAnyJSON(w, http.StatusOK, map[string]any{
"status": resp.StatusCode,
"latency_ms": durationMS,
"response_raw": string(respBody),
})
}
// handleFissionFunctionGateway принимает внутренние invoke-запросы timer/router
// и проксирует их через console в upstream router с корректным router JWT.
func (s *Server) handleFissionFunctionGateway(w http.ResponseWriter, r *http.Request) {
rawPath := strings.Trim(strings.TrimPrefix(r.URL.Path, "/fission-function"), "/")
if rawPath == "" {
http.NotFound(w, r)
return
}
parts := strings.Split(rawPath, "/")
namespace := s.ns
functionName := ""
remainingPath := ""
if len(parts) == 1 {
functionName = strings.TrimSpace(parts[0])
} else {
namespace = strings.TrimSpace(parts[0])
functionName = strings.TrimSpace(parts[1])
if len(parts) > 2 {
remainingPath = "/" + strings.Join(parts[2:], "/")
}
}
if namespace == "" || functionName == "" {
writeJSONError(w, http.StatusBadRequest, "namespace and function name are required")
return
}
s.invokeInternalFunction(w, r, namespace, functionName, remainingPath)
}
// invokeInternalFunction проксирует вызов функции к Fission router.
// Используется как из handleFissionFunctionGateway (cron/timer), так и из handleInvokeRoute.
func (s *Server) invokeInternalFunction(w http.ResponseWriter, r *http.Request, namespace, functionName, extraPath string) {
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("read request body: %v", err))
return
}
if len(bytes.TrimSpace(bodyBytes)) == 0 {
bodyBytes = []byte("{}")
}
lookupCtx, lookupCancel := context.WithTimeout(r.Context(), 10*time.Second)
defer lookupCancel()
fn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(namespace).Get(lookupCtx, functionName, metav1.GetOptions{})
if err != nil {
if apierrors.IsNotFound(err) {
writeJSONError(w, http.StatusNotFound, fmt.Sprintf("function %q not found", functionName))
return
}
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("get function %q: %v", functionName, err))
return
}
invokeTimeout := s.resolveInvokeTimeout(fn)
ctx, cancel := context.WithTimeout(r.Context(), invokeTimeout)
defer cancel()
invokeURL := buildInternalInvokeURL(s.routerURL, namespace, functionName) + extraPath
if r.URL.RawQuery != "" {
invokeURL += "?" + r.URL.RawQuery
}
var invokeBody io.Reader
if shouldForwardRequestBody(r.Method) {
invokeBody = bytes.NewReader(bodyBytes)
}
req, err := http.NewRequestWithContext(ctx, r.Method, invokeURL, invokeBody)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build invoke request: %v", err))
return
}
if shouldForwardRequestBody(r.Method) {
req.Header.Set("Content-Type", "application/json")
}
copyProxyRequestHeaders(req.Header, r.Header)
if token := s.getRouterToken(); token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
start := time.Now()
resp, err := doRequestWithContextTimeout(s.http, req)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("invoke %q timeout after %s", functionName, invokeTimeout))
return
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("invoke %q timeout after %s", functionName, invokeTimeout))
return
}
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("invoke %q: %v", functionName, err))
return
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
durationMS := time.Since(start).Milliseconds()
s.billing.RecordInvocation(billing.Invocation{
Namespace: namespace,
FunctionName: functionName,
TriggerType: billing.TriggerHTTP,
Route: extraPath,
HTTPMethod: r.Method,
StartedAt: start,
DurationMS: durationMS,
StatusCode: resp.StatusCode,
RequestBytes: int64(len(bodyBytes)),
ResponseBytes: int64(len(respBody)),
RecordedBy: "console",
EventType: "invoke",
})
writeAnyJSON(w, http.StatusOK, map[string]any{
"status": resp.StatusCode,
"latency_ms": durationMS,
"response_raw": string(respBody),
})
}
// handleInvokeRoute даёт пользователю прямой HTTP gateway к своей функции по route.
// Внешний контракт: /fn/<route> + Authorization: Bearer <user-token>.
func (s *Server) handleInvokeRoute(w http.ResponseWriter, r *http.Request) {
route := normalizeRoute(strings.TrimPrefix(r.URL.Path, "/fn"))
if route == "/" {
writeJSONError(w, http.StatusBadRequest, "route is required")
return
}
ns := s.userNS(r)
lookupCtx, lookupCancel := context.WithTimeout(r.Context(), 10*time.Second)
defer lookupCancel()
triggers, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).List(lookupCtx, metav1.ListOptions{})
if err != nil {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("list httptriggers: %v", err))
return
}
matchedFunction := ""
allowedMethods := make([]string, 0, 4)
for _, trig := range triggers.Items {
trigRoute, _, _ := unstructured.NestedString(trig.Object, "spec", "relativeurl")
if normalizeRoute(trigRoute) != route {
continue
}
methods, _, _ := unstructured.NestedStringSlice(trig.Object, "spec", "methods")
allowedMethods = appendUniqueMethods(allowedMethods, methods)
if !routeAllowsMethod(methods, r.Method) {
continue
}
matchedFunction, _, _ = unstructured.NestedString(trig.Object, "spec", "functionref", "name")
if matchedFunction != "" {
break
}
}
if matchedFunction == "" {
if len(allowedMethods) > 0 {
w.Header().Set("Allow", strings.Join(allowedMethods, ", "))
writeJSONError(w, http.StatusMethodNotAllowed, fmt.Sprintf("route %q does not allow method %s", route, r.Method))
return
}
writeJSONError(w, http.StatusNotFound, fmt.Sprintf("route %q not found", route))
return
}
fn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(lookupCtx, matchedFunction, metav1.GetOptions{})
if err != nil {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("get function %q: %v", matchedFunction, err))
return
}
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("read request body: %v", err))
return
}
invokeTimeout := s.resolveInvokeTimeout(fn)
ctx, cancel := context.WithTimeout(r.Context(), invokeTimeout)
defer cancel()
invokeURL := s.routerURL + route
if r.URL.RawQuery != "" {
invokeURL += "?" + r.URL.RawQuery
}
var invokeBody io.Reader
if shouldForwardRequestBody(r.Method) {
invokeBody = bytes.NewReader(bodyBytes)
}
req, err := http.NewRequestWithContext(ctx, r.Method, invokeURL, invokeBody)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build invoke request: %v", err))
return
}
copyProxyRequestHeaders(req.Header, r.Header)
if token := s.getRouterToken(); token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := doRequestWithContextTimeout(s.http, req)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("invoke route %q timeout after %s", route, invokeTimeout))
return
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("invoke route %q timeout after %s", route, invokeTimeout))
return
}
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("invoke route %q: %v", route, err))
return
}
defer resp.Body.Close()
copyProxyResponseHeaders(w.Header(), resp.Header)
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
}
@@ -0,0 +1,70 @@
package api
import (
"fmt"
"time"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
const (
functionCreatedAtAnnotation = "fission-console/created-at"
functionUpdatedAtAnnotation = "fission-console/updated-at"
)
func ensureFunctionTimestamps(fn *unstructured.Unstructured, now time.Time) {
if fn == nil {
return
}
annotations := fn.GetAnnotations()
if annotations == nil {
annotations = map[string]string{}
}
if annotations[functionCreatedAtAnnotation] == "" {
if created := fn.GetCreationTimestamp(); !created.IsZero() {
annotations[functionCreatedAtAnnotation] = created.UTC().Format(time.RFC3339)
} else {
annotations[functionCreatedAtAnnotation] = now.UTC().Format(time.RFC3339)
}
}
annotations[functionUpdatedAtAnnotation] = now.UTC().Format(time.RFC3339)
fn.SetAnnotations(annotations)
}
func functionTimestampResponse(fn *unstructured.Unstructured) map[string]string {
result := map[string]string{}
if fn == nil {
return result
}
annotations := fn.GetAnnotations()
if annotations == nil {
annotations = map[string]string{}
}
if created := annotations[functionCreatedAtAnnotation]; created != "" {
result["created_at"] = created
} else if ts := fn.GetCreationTimestamp(); !ts.IsZero() {
result["created_at"] = ts.UTC().Format(time.RFC3339)
}
if updated := annotations[functionUpdatedAtAnnotation]; updated != "" {
result["updated_at"] = updated
} else if v := result["created_at"]; v != "" {
result["updated_at"] = v
}
return result
}
func formatRFC3339Now(now time.Time) string {
return now.UTC().Format(time.RFC3339)
}
func parseRFC3339(value string) (time.Time, error) {
return time.Parse(time.RFC3339, value)
}
func mustParseRFC3339(value string) time.Time {
parsed, err := parseRFC3339(value)
if err != nil {
panic(fmt.Sprintf("parse RFC3339 %q: %v", value, err))
}
return parsed
}
@@ -0,0 +1,88 @@
package api
import (
"testing"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
func TestEnsureFunctionTimestampsSetsBothOnCreate(t *testing.T) {
fn := &unstructured.Unstructured{}
now := mustParseRFC3339("2026-04-28T10:11:12Z")
ensureFunctionTimestamps(fn, now)
ann := fn.GetAnnotations()
if ann[functionCreatedAtAnnotation] != now.Format(time.RFC3339) {
t.Fatalf("created_at = %q, want %q", ann[functionCreatedAtAnnotation], now.Format(time.RFC3339))
}
if ann[functionUpdatedAtAnnotation] != now.Format(time.RFC3339) {
t.Fatalf("updated_at = %q, want %q", ann[functionUpdatedAtAnnotation], now.Format(time.RFC3339))
}
}
func TestEnsureFunctionTimestampsPreservesExistingCreatedAt(t *testing.T) {
fn := &unstructured.Unstructured{}
created := "2026-04-27T09:10:11Z"
annotations := map[string]string{functionCreatedAtAnnotation: created}
fn.SetAnnotations(annotations)
now := mustParseRFC3339("2026-04-28T10:11:12Z")
ensureFunctionTimestamps(fn, now)
ann := fn.GetAnnotations()
if ann[functionCreatedAtAnnotation] != created {
t.Fatalf("created_at = %q, want %q", ann[functionCreatedAtAnnotation], created)
}
if ann[functionUpdatedAtAnnotation] != now.Format(time.RFC3339) {
t.Fatalf("updated_at = %q, want %q", ann[functionUpdatedAtAnnotation], now.Format(time.RFC3339))
}
}
func TestEnsureFunctionTimestampsBackfillsCreatedAtFromCreationTimestamp(t *testing.T) {
fn := &unstructured.Unstructured{}
fn.SetCreationTimestamp(metav1.NewTime(mustParseRFC3339("2026-04-26T08:09:10Z")))
now := mustParseRFC3339("2026-04-28T10:11:12Z")
ensureFunctionTimestamps(fn, now)
ann := fn.GetAnnotations()
if ann[functionCreatedAtAnnotation] != "2026-04-26T08:09:10Z" {
t.Fatalf("created_at = %q", ann[functionCreatedAtAnnotation])
}
if ann[functionUpdatedAtAnnotation] != now.Format(time.RFC3339) {
t.Fatalf("updated_at = %q, want %q", ann[functionUpdatedAtAnnotation], now.Format(time.RFC3339))
}
}
func TestFunctionTimestampResponse(t *testing.T) {
fn := &unstructured.Unstructured{}
annotations := map[string]string{
functionCreatedAtAnnotation: "2026-04-26T08:09:10Z",
functionUpdatedAtAnnotation: "2026-04-28T10:11:12Z",
}
fn.SetAnnotations(annotations)
resp := functionTimestampResponse(fn)
if resp["created_at"] != "2026-04-26T08:09:10Z" {
t.Fatalf("created_at = %q", resp["created_at"])
}
if resp["updated_at"] != "2026-04-28T10:11:12Z" {
t.Fatalf("updated_at = %q", resp["updated_at"])
}
}
func TestFunctionTimestampResponseFallsBackToCreationTimestamp(t *testing.T) {
fn := &unstructured.Unstructured{}
fn.SetCreationTimestamp(metav1.NewTime(mustParseRFC3339("2026-04-26T08:09:10Z")))
resp := functionTimestampResponse(fn)
if resp["created_at"] != "2026-04-26T08:09:10Z" {
t.Fatalf("created_at = %q", resp["created_at"])
}
if resp["updated_at"] != "2026-04-26T08:09:10Z" {
t.Fatalf("updated_at = %q", resp["updated_at"])
}
}
+292
View File
@@ -0,0 +1,292 @@
// Package api — роутинг и общие утилиты HTTP-обработчиков консоли.
//
// Этот файл содержит:
// - Константы и regex для валидации функций
// - handleFunctionsRoot / handleFunctionsAction — диспетчеры запросов к /functions
// - handleAuth — аутентификация пользователя (POST /auth)
// - parseTTL, normalizeMethods, normalizeRoute и другие утилиты
//
// Реализация конкретных операций с функциями вынесена в отдельные файлы:
// - function_code.go — создание и обновление из кода
// - function_archive.go — создание и обновление из zip-архива
// - function_crud.go — GET, DELETE, обновление таймаута, логи
// - function_invoke.go — вызов функции через Fission router
// - storagesvc.go — загрузка/удаление архивов в S3 через storagesvc
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"fission-console/internal/auth"
)
// validFuncName — RFC 1123 subdomain label: строчные буквы+цифры+дефис, без дефиса в начале/конце.
// Максимум 57 символов (не 63): самый длинный суффикс "-route" (HTTPTrigger) = 6 символов.
// 63 - 6 = 57. Fission webhook требует все связанные объекты <= 63 символов.
var validFuncName = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`)
// ⛔⛔⛔ НЕ МЕНЯТЬ БЕЗ ЯВНОГО РАЗРЕШЕНИЯ ВЛАДЕЛЬЦА.
// Любые "улучшения", рефакторинг, новые фичи — ЗАПРЕЩЕНЫ без команды.
// Прецедент: самодеятельный helm upgrade сломал JWT secret router → 401 у всех функций.
// maxCodeSize — максимальный размер кода функции (1 MB).
// Выше — не имеет смысла для inline функции; лучше использовать Package с URL.
const maxCodeSize = 1 << 20
// maxArchiveUploadSize — максимальный размер zip-архива при загрузке функции (32 MB).
const maxArchiveUploadSize = 32 << 20
// fissionSourceTypeAnnotation — аннотация на Function, хранит тип источника: "code" или "archive".
const fissionSourceTypeAnnotation = "fission-console/source-type"
// defaultFunctionInvokeTimeout совпадает с дефолтом Fission для spec.functionTimeout.
const defaultFunctionInvokeTimeout = 60 * time.Second
// handleFunctionsRoot обрабатывает запросы к /console/api/functions без имени функции.
// GET → список всех функций со статусами, POST → создать новую.
func (s *Server) handleFunctionsRoot(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
s.handleListFunctionsWithStatus(w, r)
case http.MethodPost:
s.handleCreateFunction(w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
// handleFunctionsAction обрабатывает запросы к /console/api/functions/:name[/action].
// Парсит имя функции и опциональный sub-path ("code", "archive", "invoke", "logs", "timeout").
func (s *Server) handleFunctionsAction(w http.ResponseWriter, r *http.Request) {
// Убираем оба возможных префикса (legacy /api/ и основной /console/api/)
path := strings.TrimPrefix(r.URL.Path, "/api/functions/")
if path == r.URL.Path {
path = strings.TrimPrefix(r.URL.Path, "/console/api/functions/")
}
path = strings.Trim(path, "/")
if path == "" {
http.NotFound(w, r)
return
}
parts := strings.Split(path, "/")
name := strings.TrimSpace(parts[0])
if name == "" {
writeJSONError(w, http.StatusBadRequest, "function name is required")
return
}
if len(parts) == 1 {
// /functions/:name — CRUD операции с конкретной функцией
switch r.Method {
case http.MethodGet:
s.handleGetFunction(w, r, name)
case http.MethodDelete:
s.handleDeleteFunction(w, r, name)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
return
}
// /functions/:name/code — обновление кода
if len(parts) == 2 && parts[1] == "code" && r.Method == http.MethodPut {
s.handleUpdateFunctionCode(w, r, name)
return
}
// /functions/:name/timeout — обновление только таймаута (без замены архива/кода)
if len(parts) == 2 && parts[1] == "timeout" && r.Method == http.MethodPut {
s.handleUpdateFunctionTimeout(w, r, name)
return
}
// /functions/:name/archive — обновление через zip-архив
if len(parts) == 2 && parts[1] == "archive" && r.Method == http.MethodPut {
s.handleUpdateFunctionArchive(w, r, name)
return
}
// /functions/:name/invoke — вызов функции
if len(parts) == 2 && parts[1] == "invoke" && r.Method == http.MethodPost {
s.handleInvokeFunction(w, r, name)
return
}
// /functions/:name/logs — логи подов функции
if len(parts) == 2 && parts[1] == "logs" && r.Method == http.MethodGet {
s.handleGetFunctionLogs(w, r, name)
return
}
// /functions/:name/envvars — переменные окружения
if len(parts) == 2 && parts[1] == "envvars" {
switch r.Method {
case http.MethodGet:
s.handleGetFunctionEnvVars(w, r, name)
case http.MethodPut:
s.handlePutFunctionEnvVars(w, r, name)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
return
}
// /functions/:name/clone — клонировать функцию с новым именем
if len(parts) == 2 && parts[1] == "clone" && r.Method == http.MethodPost {
s.handleCloneFunction(w, r, name)
return
}
http.NotFound(w, r)
}
// handleAuth обрабатывает POST /console/api/auth.
// Валидирует токен через authenticator, создаёт namespace, возвращает namespace + email.
func (s *Server) handleAuth(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var body struct {
Token string `json:"token"`
Env string `json:"env"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Token) == "" {
writeJSONError(w, http.StatusBadRequest, "token required")
return
}
env := normalizeEnv(body.Env)
identity, err := s.authenticator.Authenticate(r.Context(), body.Token, env)
if err != nil {
writeJSONError(w, http.StatusUnauthorized, "invalid token")
return
}
ns := auth.NamespaceForSub(identity.Sub)
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
if ensureErr := s.nsManager.EnsureUserNS(ctx, ns); ensureErr != nil {
fmt.Printf("handleAuth: ensureUserNS %s: %v\n", ns, ensureErr)
}
// Провизируем Grafana Org для этого namespace (fire-and-forget, идемпотентно).
go func() {
if err := s.stats.EnsureOrgForNamespace(context.Background(), ns, identity.Email); err != nil {
fmt.Printf("handleAuth: EnsureOrgForNamespace %s: %v\n", ns, err)
}
}()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "env": env, "namespace": ns, "email": identity.Email})
}
// parseTTL парсит строку TTL и возвращает время истечения.
// Поддерживаемые форматы: Go duration (1h, 30m, 24h) и дни (1d, 7d, 30d).
// Суффикс "d" не поддерживается стандартным time.ParseDuration — обрабатываем отдельно.
func parseTTL(ttl string) (time.Time, error) {
if strings.HasSuffix(ttl, "d") {
days, err := strconv.Atoi(strings.TrimSuffix(ttl, "d"))
if err != nil || days <= 0 {
return time.Time{}, fmt.Errorf("invalid days value: %q", ttl)
}
return time.Now().Add(time.Duration(days) * 24 * time.Hour), nil
}
d, err := time.ParseDuration(ttl)
if err != nil {
return time.Time{}, err
}
if d <= 0 {
return time.Time{}, fmt.Errorf("ttl must be positive")
}
return time.Now().Add(d), nil
}
// normalizeMethods приводит список HTTP методов к верхнему регистру, убирает дубли.
// Если список пустой или все элементы пустые — возвращает ["GET"].
func normalizeMethods(in []string) []string {
if len(in) == 0 {
return []string{"GET"}
}
out := make([]string, 0, len(in))
seen := map[string]bool{}
for _, method := range in {
m := strings.ToUpper(strings.TrimSpace(method))
if m == "" || seen[m] {
continue
}
seen[m] = true
out = append(out, m)
}
if len(out) == 0 {
return []string{"GET"}
}
return out
}
// normalizeFunctionTimeout нормализует таймаут: отрицательные и нулевые значения
// заменяются дефолтным значением Fission (defaultFunctionInvokeTimeout).
func normalizeFunctionTimeout(seconds int64) int64 {
if seconds <= 0 {
return int64(defaultFunctionInvokeTimeout / time.Second)
}
return seconds
}
// normalizeRoute нормализует URL-маршрут: убирает пробелы, гарантирует prefix "/".
func normalizeRoute(route string) string {
route = strings.TrimSpace(route)
if route == "" || route == "/" {
return "/"
}
if !strings.HasPrefix(route, "/") {
return "/" + route
}
return route
}
// routeAllowsMethod проверяет, допускает ли маршрут указанный HTTP-метод.
// Пустой список methods означает "любой метод".
func routeAllowsMethod(methods []string, method string) bool {
if len(methods) == 0 {
return true
}
for _, candidate := range methods {
if strings.EqualFold(strings.TrimSpace(candidate), method) {
return true
}
}
return false
}
// appendUniqueMethods добавляет методы из src в dst без дублей (case-insensitive).
func appendUniqueMethods(dst []string, src []string) []string {
for _, method := range src {
method = strings.ToUpper(strings.TrimSpace(method))
if method == "" {
continue
}
seen := false
for _, existing := range dst {
if existing == method {
seen = true
break
}
}
if !seen {
dst = append(dst, method)
}
}
return dst
}
+241
View File
@@ -0,0 +1,241 @@
package api
// kwtriggers.go — CRUD хендлеры для KubernetesWatchTrigger (Fission KW Trigger).
//
// РЕШЕНИЕ ПО АРХИТЕКТУРЕ (2026-05-11):
// KubernetesWatchTrigger позволяет вызывать функцию при изменении K8s объектов.
// spec.type — тип ресурса: Pod, Service, Deployment, ConfigMap, и т.д.
// spec.namespace — namespace для слежения (по умолчанию = namespace пользователя)
// spec.labelselector — label selector в формате "key=value,key2=value2"
// spec.functionref — ссылка на функцию
//
// ОСОБЕННОСТИ:
// - Fission kubewatcher компонент должен быть задеплоен.
// - namespace в spec — это WATCHED namespace (не namespace триггера).
// Для безопасности ограничиваем: только namespace пользователя или пустое (тогда = userNS).
// - labelselector опционален, "" = смотрим на все ресурсы типа resourceType в namespace.
//
// ОШИБКИ В ПРОЦЕССЕ:
// - (нет, первая реализация)
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"fission-console/internal/fission"
"fission-console/internal/model"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
// validKWResourceTypes — поддерживаемые типы ресурсов для слежения.
// Расширяемо — это не ограничение CRD, просто UI-валидация.
var validKWResourceTypes = map[string]struct{}{
"pod": {},
"service": {},
"deployment": {},
"configmap": {},
"secret": {},
"namespace": {},
"replicaset": {},
"statefulset": {},
"daemonset": {},
"job": {},
}
func (s *Server) handleKWTriggersRoot(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
s.handleList(fission.KWTrigGVR)(w, r)
case http.MethodPost:
s.handleCreateKWTrigger(w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleKWTriggersAction(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/console/api/kwtriggers/")
path = strings.TrimPrefix(path, "/api/kwtriggers/")
name := strings.Trim(path, "/")
if name == "" || strings.Contains(name, "/") {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodGet:
s.handleGetKWTrigger(w, r, name)
case http.MethodDelete:
s.handleDeleteKWTrigger(w, r, name)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleCreateKWTrigger(w http.ResponseWriter, r *http.Request) {
var req model.CreateKWTriggerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("decode request: %v", err))
return
}
ns := s.userNS(r)
// Если namespace не задан — используем namespace пользователя
if strings.TrimSpace(req.Namespace) == "" {
req.Namespace = ns
}
// Безопасность: нельзя смотреть за чужим namespace
if req.Namespace != ns {
writeJSONError(w, http.StatusForbidden, "can only watch your own namespace")
return
}
if err := validateKWTriggerRequest(req); err != nil {
writeJSONError(w, http.StatusBadRequest, err.Error())
return
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
// Проверяем что функция существует
if _, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, req.FunctionName, metav1.GetOptions{}); err != nil {
if apierrors.IsNotFound(err) {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("function %q not found", req.FunctionName))
return
}
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("get function: %v", err))
return
}
obj := buildKWTriggerObject(ns, req)
created, err := s.dyn.Resource(fission.KWTrigGVR).Namespace(ns).Create(ctx, obj, metav1.CreateOptions{})
if err != nil {
if apierrors.IsAlreadyExists(err) {
writeJSONError(w, http.StatusConflict, fmt.Sprintf("kwtrigger %q already exists", req.Name))
return
}
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create kwtrigger: %v", err))
return
}
writeAnyJSON(w, http.StatusCreated, kwTriggerResponse(created))
}
func (s *Server) handleGetKWTrigger(w http.ResponseWriter, r *http.Request, name string) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
obj, err := s.dyn.Resource(fission.KWTrigGVR).Namespace(s.userNS(r)).Get(ctx, name, metav1.GetOptions{})
if err != nil {
status := http.StatusBadGateway
if apierrors.IsNotFound(err) {
status = http.StatusNotFound
}
writeJSONError(w, status, fmt.Sprintf("get kwtrigger %q: %v", name, err))
return
}
writeAnyJSON(w, http.StatusOK, kwTriggerResponse(obj))
}
func (s *Server) handleDeleteKWTrigger(w http.ResponseWriter, r *http.Request, name string) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
if err := s.dyn.Resource(fission.KWTrigGVR).Namespace(s.userNS(r)).Delete(ctx, name, metav1.DeleteOptions{}); err != nil {
status := http.StatusBadGateway
if apierrors.IsNotFound(err) {
status = http.StatusNotFound
}
writeJSONError(w, status, fmt.Sprintf("delete kwtrigger %q: %v", name, err))
return
}
writeAnyJSON(w, http.StatusOK, map[string]any{"deleted": true, "name": name})
}
// --- Вспомогательные функции ---
func validateKWTriggerRequest(req model.CreateKWTriggerRequest) error {
if strings.TrimSpace(req.Name) == "" {
return fmt.Errorf("name is required")
}
if strings.TrimSpace(req.FunctionName) == "" {
return fmt.Errorf("functionName is required")
}
rt := strings.ToLower(strings.TrimSpace(req.ResourceType))
if _, ok := validKWResourceTypes[rt]; !ok {
return fmt.Errorf("resourceType must be one of: Pod, Service, Deployment, ConfigMap, Secret, Namespace, ReplicaSet, StatefulSet, DaemonSet, Job")
}
return nil
}
func buildKWTriggerObject(ns string, req model.CreateKWTriggerRequest) *unstructured.Unstructured {
// Fission ожидает capitalize: Pod, Service, Deployment
resourceType := capitalize(strings.TrimSpace(req.ResourceType))
spec := map[string]any{
"type": resourceType,
"namespace": req.Namespace,
"functionref": map[string]any{
"type": "name",
"name": req.FunctionName,
},
}
if req.LabelSelector != "" {
spec["labelselector"] = req.LabelSelector
}
return &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "KubernetesWatchTrigger",
"metadata": map[string]any{
"name": req.Name,
"namespace": ns,
},
"spec": spec,
}}
}
func kwTriggerResponse(obj *unstructured.Unstructured) map[string]any {
spec, _ := obj.Object["spec"].(map[string]any)
if spec == nil {
spec = map[string]any{}
}
fnref, _ := spec["functionref"].(map[string]any)
fnName := ""
if fnref != nil {
fnName, _ = fnref["name"].(string)
}
return map[string]any{
"metadata": map[string]any{
"name": obj.GetName(),
"namespace": obj.GetNamespace(),
},
"spec": map[string]any{
"resourceType": spec["type"],
"namespace": spec["namespace"],
"labelSelector": spec["labelselector"],
"functionName": fnName,
},
}
}
// capitalize приводит первый символ к верхнему регистру, остальное без изменений.
// "pod" → "Pod", "deployment" → "Deployment"
func capitalize(s string) string {
if s == "" {
return s
}
return strings.ToUpper(s[:1]) + strings.ToLower(s[1:])
}
+301
View File
@@ -0,0 +1,301 @@
package api
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
const (
// maxArchiveBytes — максимальный размер zip-архива (100 KB).
// Serverless-функции не должны быть большими — только скрипты и небольшие зависимости.
maxArchiveBytes = 100 * 1024
// maxUnzippedBytes — ограничение на суммарный распакованный размер (защита от zip bomb).
// 100 KB реального кода — достаточно для любой serverless-функции.
maxUnzippedBytes = 100 * 1024
)
// lintResult — результат проверки одного файла.
type lintResult struct {
File string `json:"file"`
OK bool `json:"ok"`
Output string `json:"output,omitempty"`
}
// handleLintArchive — POST /console/api/ai/lint-archive
// Принимает zip-архив (multipart/form-data, поле "archive"),
// прогоняет линтер по всем файлам с известным расширением,
// возвращает список результатов по файлам.
func (s *Server) handleLintArchive(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
// Hard-limit 10 MB чтобы соединение не обрывалось (MaxBytesReader с маленьким лимитом
// закрывает соединение до отправки ответа — клиент получает 000 вместо 413).
// Реальный лимит размера архива проверяется после чтения файла.
r.Body = http.MaxBytesReader(w, r.Body, 10*1024*1024)
if err := r.ParseMultipartForm(maxArchiveBytes); err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
writeJSONError(w, http.StatusRequestEntityTooLarge,
fmt.Sprintf("архив слишком большой: максимум %d KB", maxArchiveBytes/1024))
} else {
writeJSONError(w, http.StatusBadRequest, "ошибка разбора multipart: "+err.Error())
}
return
}
// Опциональный entrypoint для проверки наличия файла в архиве (формат: "module.function")
entrypoint := strings.TrimSpace(r.FormValue("entrypoint"))
// Опциональный язык для проверки соответствия файлов в архиве
selectedLang := strings.ToLower(strings.TrimSpace(r.FormValue("language")))
f, _, err := r.FormFile("archive")
if err != nil {
writeJSONError(w, http.StatusBadRequest, "поле 'archive' обязательно")
return
}
defer f.Close()
// Читаем архив в память
var buf bytes.Buffer
if _, err := buf.ReadFrom(f); err != nil {
writeJSONError(w, http.StatusBadRequest, "ошибка чтения архива: "+err.Error())
return
}
if buf.Len() > maxArchiveBytes {
writeJSONError(w, http.StatusRequestEntityTooLarge,
fmt.Sprintf("архив слишком большой: %d байт, максимум %d KB", buf.Len(), maxArchiveBytes/1024))
return
}
// Открываем zip из памяти
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
if err != nil {
writeJSONError(w, http.StatusBadRequest, "не удалось открыть zip: "+err.Error())
return
}
// Считаем суммарный распакованный размер (защита от zip bomb)
var totalUnzipped uint64
for _, zf := range zr.File {
totalUnzipped += zf.UncompressedSize64
}
if totalUnzipped > maxUnzippedBytes {
writeJSONError(w, http.StatusRequestEntityTooLarge,
fmt.Sprintf("суммарный размер распакованных файлов превышает %d KB", maxUnzippedBytes/1024))
return
}
// Карта линтеров по расширению
type linterCfg struct {
cmd []string
stdinOk bool // поддерживает stdin
stdinArg string // аргумент для stdin-режима (имя "файла")
}
linters := map[string]linterCfg{
".py": {cmd: []string{"python3", "-m", "py_compile"}, stdinOk: false},
".js": {cmd: []string{"node", "--check"}, stdinOk: false},
".rb": {cmd: []string{"ruby", "-c"}, stdinOk: true},
".php": {cmd: []string{"php", "-l"}, stdinOk: false},
}
var results []lintResult
var hasError bool
for _, zf := range zr.File {
if zf.FileInfo().IsDir() {
continue
}
ext := strings.ToLower(filepath.Ext(zf.Name))
cfg, supported := linters[ext]
if !supported {
continue
}
// Читаем содержимое файла из zip в память
rc, err := zf.Open()
if err != nil {
results = append(results, lintResult{File: zf.Name, OK: false, Output: "ошибка чтения: " + err.Error()})
hasError = true
continue
}
var content bytes.Buffer
_, readErr := content.ReadFrom(rc)
rc.Close()
if readErr != nil {
results = append(results, lintResult{File: zf.Name, OK: false, Output: "ошибка чтения: " + readErr.Error()})
hasError = true
continue
}
var res lintResult
res.File = zf.Name
// Записываем во временный файл в /dev/shm (RAM) если доступен, иначе os.TempDir()
tmpDir := "/dev/shm"
if _, statErr := os.Stat(tmpDir); statErr != nil {
tmpDir = ""
}
tmpf, tmpErr := os.CreateTemp(tmpDir, "fission-lint-*"+ext)
if tmpErr != nil {
res.OK = false
res.Output = "не удалось создать tmp: " + tmpErr.Error()
results = append(results, res)
hasError = true
continue
}
tmpName := tmpf.Name()
_, writeErr := tmpf.Write(content.Bytes())
tmpf.Close()
if writeErr != nil {
os.Remove(tmpName)
res.OK = false
res.Output = "ошибка записи tmp: " + writeErr.Error()
results = append(results, res)
hasError = true
continue
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
args := append(cfg.cmd, tmpName) //nolint:gocritic — намеренно создаём новый слайс
//nolint:gosec — cmd содержит только захардкоженные команды из linters
out, lintErr := exec.CommandContext(ctx, args[0], args[1:]...).CombinedOutput()
cancel()
os.Remove(tmpName)
outStr := strings.TrimSpace(string(out))
// Скрываем путь к tmp-файлу от пользователя
outStr = strings.ReplaceAll(outStr, tmpName, zf.Name)
if lintErr == nil {
res.OK = true
} else {
res.OK = false
hasError = true
if outStr != "" {
res.Output = outStr
} else {
res.Output = fmt.Sprintf("ошибка синтаксиса (%v)", lintErr)
}
}
results = append(results, res)
}
if len(results) == 0 {
writeJSONError(w, http.StatusBadRequest, "архив не содержит файлов поддерживаемых языков (.py, .js, .rb, .php)")
return
}
// Проверяем соответствие выбранного языка файлам в архиве
if selectedLang != "" {
langExt := map[string]string{
"python": ".py", "python3": ".py",
"node.js": ".js", "nodejs": ".js", "javascript": ".js", "node": ".js",
"ruby": ".rb",
"php": ".php",
"go": ".go",
"perl": ".pl",
}
expectedExt, known := langExt[selectedLang]
if known {
// Собираем расширения файлов в архиве
extCount := map[string]int{}
for _, zf := range zr.File {
if !zf.FileInfo().IsDir() {
ext := strings.ToLower(filepath.Ext(zf.Name))
if ext != "" {
extCount[ext]++
}
}
}
// Если нет ни одного файла с ожидаемым расширением — предупреждение
if extCount[expectedExt] == 0 {
found := []string{}
for ext := range extCount {
found = append(found, ext)
}
results = append(results, lintResult{
File: "язык",
OK: false,
Output: fmt.Sprintf("выбран язык '%s' (ожидается %s), но в архиве таких файлов нет (найдено: %s)", selectedLang, expectedExt, strings.Join(found, ", ")),
})
hasError = true
}
}
}
// Проверяем entrypoint: формат "module.function" → файл "module.py" должен быть в архиве
if entrypoint != "" {
parts := strings.SplitN(entrypoint, ".", 2)
if len(parts) == 2 {
module := parts[0]
funcName := parts[1]
// Ищем файл module.py в архиве (поддерживаем вложенные пути)
foundFile := false
funcDefined := false
for _, zf := range zr.File {
base := strings.TrimSuffix(filepath.Base(zf.Name), ".py")
if base == module && strings.HasSuffix(zf.Name, ".py") {
foundFile = true
// Читаем содержимое и ищем определение функции
rc, openErr := zf.Open()
if openErr == nil {
var content bytes.Buffer
content.ReadFrom(rc) //nolint:errcheck
rc.Close()
for _, line := range strings.Split(content.String(), "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "def "+funcName+"(") || trimmed == "def "+funcName+":" {
funcDefined = true
break
}
}
}
break
}
}
if !foundFile {
results = append(results, lintResult{
File: entrypoint,
OK: false,
Output: fmt.Sprintf("файл '%s.py' не найден в архиве — проверьте Entrypoint", module),
})
hasError = true
} else if !funcDefined {
results = append(results, lintResult{
File: entrypoint,
OK: false,
Output: fmt.Sprintf("функция '%s' не найдена в '%s.py' — проверьте Entrypoint", funcName, module),
})
hasError = true
} else {
results = append(results, lintResult{
File: entrypoint,
OK: true,
Output: fmt.Sprintf("entrypoint '%s' найден", entrypoint),
})
}
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"ok": !hasError,
"results": results,
})
}
+56
View File
@@ -0,0 +1,56 @@
package api
import (
"log"
"net/http"
)
// logRequests логирует метод и путь каждого входящего запроса.
// Middleware-обёртка: оборачивает любой http.Handler.
func logRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Printf("%s %s", r.Method, r.URL.Path)
next.ServeHTTP(w, r)
})
}
// withCORS добавляет CORS заголовки для разрешения cross-origin запросов.
//
// Почему Access-Control-Allow-Origin: *:
// Console — одностраничное приложение, может обслуживаться с любого домена.
// Строгий CORS с конкретным Origin потребовал бы конфигурации при деплое.
// Все чувствительные операции защищены X-Auth-Token — не cookie, не ambient credentials.
func withCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Auth-Token, X-Auth-Env")
// Preflight OPTIONS — отвечаем сразу без передачи в handler
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
// withSecurityHeaders добавляет стандартные HTTP security заголовки.
// Снижает риск XSS, clickjacking и утечки Referer.
func withSecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// nosniff: браузер не должен угадывать MIME тип — только то что в Content-Type
w.Header().Set("X-Content-Type-Options", "nosniff")
// DENY: запрещаем отображение страницы в iframe (clickjacking protection)
w.Header().Set("X-Frame-Options", "DENY")
// strict-origin-when-cross-origin: скрываем полный URL при cross-origin запросах
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
// Отключаем браузерные API которые не нужны console
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
// CSP: разрешаем только ресурсы с того же origin + inline scripts/styles (нужны для embedded UI)
w.Header().Set("Content-Security-Policy",
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; "+
"img-src 'self' data:; connect-src 'self'; font-src 'self' data:; object-src 'none'; "+
"frame-ancestors 'none'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests; block-all-mixed-content")
next.ServeHTTP(w, r)
})
}
+286
View File
@@ -0,0 +1,286 @@
package api
// mqtriggers.go — CRUD для MQ-триггеров через K8s Deployment + Secret.
//
// АРХИТЕКТУРА (2026-05-11):
// Вместо Fission MessageQueueTrigger CRD (требует mqtrigger компонент Kafka/NATS)
// Console деплоит собственный sqs-consumer Deployment в namespace пользователя.
//
// При CREATE:
// 1. Создаём K8s Secret (sqs-mq-<name>) с SQS credentials
// 2. Создаём K8s Deployment (mq-<name>) с образом naeel/sqs-consumer:v1.0
// FUNCTION_URL = http://router.fission.svc.cluster.local/<functionName>
// Лейблы: app.kubernetes.io/managed-by=fission-console, component=mq-trigger
//
// При LIST: deployments -n <ns> -l component=mq-trigger
// При DELETE: удаляем Deployment + Secret
//
// ИЗМЕНЕНИЯ:
// v1 — использовал Fission MQ CRD (компонент отсутствует в кластере)
// v2 — K8s Deployment + наш sqs-consumer образ
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"fission-console/internal/model"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
const (
sqsConsumerImage = "naeel/sqs-consumer:v1.0"
sqsDefaultEndpoint = "http://shared-sqs.shared-sqs.svc.cluster.local:4100"
fissionRouterBase = "http://router.fission.svc.cluster.local"
mqTriggerLabelKey = "component"
mqTriggerLabelVal = "mq-trigger"
mqManagedByLabel = "app.kubernetes.io/managed-by"
mqManagedByVal = "fission-console"
)
func mqSecretName(name string) string { return "sqs-mq-" + name }
func mqDeployName(name string) string { return "mq-" + name }
// ── HTTP хендлеры ────────────────────────────────────────────────────
func (s *Server) handleMQTriggersRoot(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
s.handleListMQTriggers(w, r)
case http.MethodPost:
s.handleCreateMQTrigger(w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleMQTriggersAction(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/console/api/mqtriggers/")
name := strings.Trim(path, "/")
if name == "" || strings.Contains(name, "/") {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodDelete:
s.handleDeleteMQTrigger(w, r, name)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
// ── LIST ────────────────────────────────────────────────────────────
func (s *Server) handleListMQTriggers(w http.ResponseWriter, r *http.Request) {
ns := s.userNS(r)
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
labelSel := fmt.Sprintf("%s=%s,%s=%s", mqManagedByLabel, mqManagedByVal, mqTriggerLabelKey, mqTriggerLabelVal)
deployList, err := s.kube.AppsV1().Deployments(ns).List(ctx, metav1.ListOptions{LabelSelector: labelSel})
if err != nil {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("list mq deployments: %v", err))
return
}
items := make([]map[string]any, 0, len(deployList.Items))
for i := range deployList.Items {
items = append(items, mqDeployToResponse(&deployList.Items[i]))
}
writeAnyJSON(w, http.StatusOK, map[string]any{"items": items})
}
// ── CREATE ──────────────────────────────────────────────────────────
func (s *Server) handleCreateMQTrigger(w http.ResponseWriter, r *http.Request) {
var req model.CreateMQTriggerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, http.StatusBadRequest, "decode request: "+err.Error())
return
}
if err := validateMQRequest(req); err != nil {
writeJSONError(w, http.StatusBadRequest, err.Error())
return
}
ns := s.userNS(r)
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
endpoint := strings.TrimSpace(req.SqsEndpoint)
if endpoint == "" {
endpoint = sqsDefaultEndpoint
}
functionURL := fissionRouterBase + "/" + req.FunctionName
secretName := mqSecretName(req.Name)
deployName := mqDeployName(req.Name)
// 1. Secret с SQS credentials
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: secretName,
Namespace: ns,
Labels: map[string]string{
mqManagedByLabel: mqManagedByVal,
mqTriggerLabelKey: mqTriggerLabelVal,
"mq-trigger-name": req.Name,
},
},
StringData: map[string]string{
"SQS_ACCESS_KEY": req.AccessKey,
"SQS_SECRET_KEY": req.SecretKey,
"SQS_ENDPOINT": endpoint,
},
}
if _, err := s.kube.CoreV1().Secrets(ns).Create(ctx, secret, metav1.CreateOptions{}); err != nil {
if apierrors.IsAlreadyExists(err) {
writeJSONError(w, http.StatusConflict, fmt.Sprintf("mq trigger %q already exists", req.Name))
return
}
writeJSONError(w, http.StatusBadGateway, "create secret: "+err.Error())
return
}
// 2. Deployment (sqs-consumer)
replicas := int32(1)
deploy := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: deployName,
Namespace: ns,
Labels: map[string]string{
mqManagedByLabel: mqManagedByVal,
mqTriggerLabelKey: mqTriggerLabelVal,
"mq-trigger-name": req.Name,
},
Annotations: map[string]string{
"fission-console/mq-trigger-name": req.Name,
"fission-console/function": req.FunctionName,
"fission-console/queue": req.Queue,
"fission-console/sqs-endpoint": endpoint,
},
},
Spec: appsv1.DeploymentSpec{
Replicas: &replicas,
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{"mq-trigger-name": req.Name},
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{
"mq-trigger-name": req.Name,
mqTriggerLabelKey: mqTriggerLabelVal,
},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{{
Name: "sqs-consumer",
Image: sqsConsumerImage,
ImagePullPolicy: corev1.PullAlways,
Env: []corev1.EnvVar{
{Name: "SQS_QUEUE_NAME", Value: req.Queue},
{Name: "SQS_REGION", Value: "us-east-1"},
{Name: "FUNCTION_URL", Value: functionURL},
{Name: "POLL_INTERVAL", Value: "5"},
{Name: "MAX_MESSAGES", Value: "1"},
{Name: "MAX_RETRIES", Value: "3"},
},
EnvFrom: []corev1.EnvFromSource{{
SecretRef: &corev1.SecretEnvSource{
LocalObjectReference: corev1.LocalObjectReference{Name: secretName},
},
}},
Resources: corev1.ResourceRequirements{
Limits: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("50m"),
corev1.ResourceMemory: resource.MustParse("32Mi"),
},
Requests: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("10m"),
corev1.ResourceMemory: resource.MustParse("16Mi"),
},
},
}},
},
},
},
}
created, err := s.kube.AppsV1().Deployments(ns).Create(ctx, deploy, metav1.CreateOptions{})
if err != nil {
_ = s.kube.CoreV1().Secrets(ns).Delete(ctx, secretName, metav1.DeleteOptions{})
writeJSONError(w, http.StatusBadGateway, "create deployment: "+err.Error())
return
}
writeAnyJSON(w, http.StatusCreated, mqDeployToResponse(created))
}
// ── DELETE ──────────────────────────────────────────────────────────
func (s *Server) handleDeleteMQTrigger(w http.ResponseWriter, r *http.Request, name string) {
ns := s.userNS(r)
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
dErr := s.kube.AppsV1().Deployments(ns).Delete(ctx, mqDeployName(name), metav1.DeleteOptions{})
sErr := s.kube.CoreV1().Secrets(ns).Delete(ctx, mqSecretName(name), metav1.DeleteOptions{})
if dErr != nil && !apierrors.IsNotFound(dErr) {
writeJSONError(w, http.StatusBadGateway, "delete deployment: "+dErr.Error())
return
}
if sErr != nil && !apierrors.IsNotFound(sErr) {
writeJSONError(w, http.StatusBadGateway, "delete secret: "+sErr.Error())
return
}
writeAnyJSON(w, http.StatusOK, map[string]any{"deleted": true, "name": name})
}
// ── Вспомогательные ─────────────────────────────────────────────────
func validateMQRequest(req model.CreateMQTriggerRequest) error {
if strings.TrimSpace(req.Name) == "" {
return fmt.Errorf("name is required")
}
if strings.TrimSpace(req.FunctionName) == "" {
return fmt.Errorf("functionName is required")
}
if strings.TrimSpace(req.Queue) == "" {
return fmt.Errorf("queue is required")
}
if strings.TrimSpace(req.AccessKey) == "" {
return fmt.Errorf("accessKey is required")
}
if strings.TrimSpace(req.SecretKey) == "" {
return fmt.Errorf("secretKey is required")
}
return nil
}
func mqDeployToResponse(d *appsv1.Deployment) map[string]any {
ann := d.Annotations
if ann == nil {
ann = map[string]string{}
}
triggerName := ann["fission-console/mq-trigger-name"]
if triggerName == "" {
triggerName = strings.TrimPrefix(d.Name, "mq-")
}
return map[string]any{
"name": triggerName,
"deployName": d.Name,
"functionName": ann["fission-console/function"],
"queue": ann["fission-console/queue"],
"sqsEndpoint": ann["fission-console/sqs-endpoint"],
"ready": d.Status.ReadyReplicas > 0,
"replicas": d.Status.ReadyReplicas,
}
}
+226
View File
@@ -0,0 +1,226 @@
package api
import (
"context"
"net/http"
"os"
"time"
"fission-console/internal/fission"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
)
var requiredBootstrapServiceAccounts = []string{"fission-fetcher", "fission-builder"}
var requiredBootstrapRoleBindings = []string{
"fission-executor-user-ns",
"fission-router-user-ns",
"fission-buildermgr-user-ns",
"fission-kubewatcher-user-ns",
"fission-timer-user-ns",
"fission-fetcher-system-user-ns",
"fission-builder-system-user-ns",
"fission-fetcher-local-user-ns",
"fission-builder-local-user-ns",
}
type namespacePreparationStatus struct {
ManagedByConsole bool `json:"managedByConsole"`
WatcherLabel bool `json:"watcherLabel"`
ServiceAccounts map[string]bool `json:"serviceAccounts"`
RoleBindings map[string]bool `json:"roleBindings"`
}
func (s namespacePreparationStatus) Ready() bool {
if !s.ManagedByConsole || !s.WatcherLabel {
return false
}
for _, ok := range s.ServiceAccounts {
if !ok {
return false
}
}
for _, ok := range s.RoleBindings {
if !ok {
return false
}
}
return true
}
type controlPlaneServiceStatus struct {
Ready bool `json:"ready"`
PodCount int `json:"podCount"`
ReadyPods []string `json:"readyPods"`
}
type controlPlaneStatus struct {
Namespace string `json:"namespace"`
Ready bool `json:"ready"`
Executor controlPlaneServiceStatus `json:"executor"`
Router controlPlaneServiceStatus `json:"router"`
}
// handleNSStatus возвращает статус инициализации пользовательского namespace.
// Используется UI для отображения прогресса при первом входе нового пользователя.
//
// Три стадии:
// 1. Namespace существует и Active
// 2. Namespace подготовлен для Layer 1 NSWatcher: label + bootstrap RBAC/SA
// 3. Fission control-plane готов: executor и router Running + Ready
func (s *Server) handleNSStatus(w http.ResponseWriter, r *http.Request) {
ns := s.userNS(r)
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
type stageInfo struct {
Name string `json:"name"`
Done bool `json:"done"`
}
stages := []stageInfo{
{Name: "Создание пространства имён"},
{Name: "Подготовка namespace для Fission"},
{Name: "Готовность control-plane"},
}
nsObj, err := s.dyn.Resource(fission.NamespaceGVR).Get(ctx, ns, metav1.GetOptions{})
var preparation namespacePreparationStatus
control := controlPlaneStatus{Namespace: fissionSystemNamespace()}
if err == nil {
phase, _, _ := unstructured.NestedString(nsObj.Object, "status", "phase")
stages[0].Done = phase == "Active"
}
if stages[0].Done {
preparation = s.namespacePreparationStatus(ctx, ns, nsObj)
stages[1].Done = preparation.Ready()
}
if stages[1].Done {
control = s.controlPlaneStatus(ctx)
stages[2].Done = control.Ready
}
ready := stages[0].Done && stages[1].Done && stages[2].Done
writeAnyJSON(w, http.StatusOK, map[string]any{
"ready": ready,
"stages": stages,
})
}
func (s *Server) handleNSDebug(w http.ResponseWriter, r *http.Request) {
ns := s.userNS(r)
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
nsObj, err := s.dyn.Resource(fission.NamespaceGVR).Get(ctx, ns, metav1.GetOptions{})
labels := map[string]string{}
phase := ""
exists := err == nil
active := false
preparation := namespacePreparationStatus{}
if exists {
labels, _, _ = unstructured.NestedStringMap(nsObj.Object, "metadata", "labels")
phase, _, _ = unstructured.NestedString(nsObj.Object, "status", "phase")
active = phase == "Active"
preparation = s.namespacePreparationStatus(ctx, ns, nsObj)
}
control := s.controlPlaneStatus(ctx)
writeAnyJSON(w, http.StatusOK, map[string]any{
"namespace": ns,
"exists": exists,
"active": active,
"phase": phase,
"labels": labels,
"prepared": preparation.Ready(),
"preparation": preparation,
"controlPlane": control,
"namespaceReady": active && preparation.Ready() && control.Ready,
})
}
func (s *Server) namespacePreparationStatus(ctx context.Context, ns string, nsObj *unstructured.Unstructured) namespacePreparationStatus {
status := namespacePreparationStatus{
ServiceAccounts: make(map[string]bool, len(requiredBootstrapServiceAccounts)),
RoleBindings: make(map[string]bool, len(requiredBootstrapRoleBindings)),
}
if nsObj != nil {
labels, _, _ := unstructured.NestedStringMap(nsObj.Object, "metadata", "labels")
status.ManagedByConsole = labels["managed-by"] == "fission-console"
status.WatcherLabel = labels["fission.io/managed"] == "true"
}
saGVR := schema.GroupVersionResource{Group: "", Version: "v1", Resource: "serviceaccounts"}
for _, name := range requiredBootstrapServiceAccounts {
_, err := s.dyn.Resource(saGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
status.ServiceAccounts[name] = err == nil
}
rbGVR := schema.GroupVersionResource{Group: "rbac.authorization.k8s.io", Version: "v1", Resource: "rolebindings"}
for _, name := range requiredBootstrapRoleBindings {
_, err := s.dyn.Resource(rbGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
status.RoleBindings[name] = err == nil
}
return status
}
func (s *Server) controlPlaneStatus(ctx context.Context) controlPlaneStatus {
status := controlPlaneStatus{Namespace: fissionSystemNamespace()}
status.Executor = s.serviceStatus(ctx, status.Namespace, "executor")
status.Router = s.serviceStatus(ctx, status.Namespace, "router")
status.Ready = status.Executor.Ready && status.Router.Ready
return status
}
func (s *Server) serviceStatus(ctx context.Context, namespace, service string) controlPlaneServiceStatus {
podGVR := schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}
podList, err := s.dyn.Resource(podGVR).Namespace(namespace).List(ctx, metav1.ListOptions{
LabelSelector: "svc=" + service,
})
if err != nil {
return controlPlaneServiceStatus{}
}
status := controlPlaneServiceStatus{PodCount: len(podList.Items)}
for _, pod := range podList.Items {
if podReady(&pod) {
status.Ready = true
status.ReadyPods = append(status.ReadyPods, pod.GetName())
}
}
return status
}
func fissionSystemNamespace() string {
ns := os.Getenv("FISSION_SYSTEM_NAMESPACE")
if ns == "" {
return "fission"
}
return ns
}
func podReady(pod *unstructured.Unstructured) bool {
if pod == nil {
return false
}
phase, _, _ := unstructured.NestedString(pod.Object, "status", "phase")
if phase != "Running" {
return false
}
conditions, _, _ := unstructured.NestedSlice(pod.Object, "status", "conditions")
for _, c := range conditions {
cond, ok := c.(map[string]any)
if !ok {
continue
}
if cond["type"] == "Ready" && cond["status"] == "True" {
return true
}
}
return false
}
+190
View File
@@ -0,0 +1,190 @@
package api
import (
"archive/zip"
"bytes"
"context"
"encoding/base64"
"encoding/json"
"io"
"log"
"net/http"
"regexp"
"sort"
"strings"
"unicode/utf8"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
// ⛔⛔⛔ НЕ МЕНЯТЬ БЕЗ ЯВНОГО РАЗРЕШЕНИЯ ВЛАДЕЛЬЦА.
// extractPackageSourceCode пытается извлечь исходный код из Fission Package.
// Порядок попыток:
// 1. spec.source.literal (base64) — Go функции (source package)
// 2. spec.deployment.literal (base64) — интерпретируемые языки
// 3. spec.source.url / spec.deployment.url — скачиваем архив по URL
//
// Декодирование: если данные — валидный UTF-8, возвращаем как есть.
// Если это zip — ищем известные файлы (main.py, handler.rb и т.д.).
func extractPackageSourceCode(ctx context.Context, s *Server, pkg *unstructured.Unstructured) string {
literalPaths := [][]string{
{"spec", "source", "literal"},
{"spec", "deployment", "literal"},
}
for _, p := range literalPaths {
literal, found, _ := unstructured.NestedString(pkg.Object, p...)
if !found || strings.TrimSpace(literal) == "" {
continue
}
if code, err := decodeLiteralToSource(literal); err == nil && strings.TrimSpace(code) != "" {
return code
}
}
// Fallback: скачиваем по URL
urlPaths := [][]string{
{"spec", "source", "url"},
{"spec", "deployment", "url"},
}
for _, p := range urlPaths {
urlValue, found, _ := unstructured.NestedString(pkg.Object, p...)
if !found || strings.TrimSpace(urlValue) == "" {
continue
}
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))
}
}
return ""
}
// fetchPackageArchive скачивает архив функции по URL из Fission storage.
func fetchPackageArchive(ctx context.Context, s *Server, archiveURL string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, archiveURL, nil)
if err != nil {
return nil, err
}
resp, err := s.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, io.ErrUnexpectedEOF
}
return io.ReadAll(resp.Body)
}
// decodeLiteralToSource декодирует base64-строку из Package.spec.*.literal в исходный код.
func decodeLiteralToSource(literal string) (string, error) {
decoded, err := base64.StdEncoding.DecodeString(literal)
if err != nil {
return "", err
}
return decodeArchiveBytesToSource(decoded)
}
// decodeArchiveBytesToSource преобразует байты (utf-8 строка или zip архив) в исходный код.
func decodeArchiveBytesToSource(decoded []byte) (string, error) {
if len(decoded) == 0 {
return "", io.ErrUnexpectedEOF
}
if src, ok := decodeNodeJSWrapperSource(decoded); ok {
return src, nil
}
// Если байты — валидный UTF-8, возвращаем напрямую (python, ruby, php)
if utf8.Valid(decoded) {
return string(decoded), nil
}
// PK signature: zip архив (nodejs, go source)
if len(decoded) >= 4 && bytes.Equal(decoded[:4], []byte{'P', 'K', 3, 4}) {
if src, zipErr := decodeZipSource(decoded); zipErr == nil {
return src, nil
}
}
return "", io.ErrUnexpectedEOF
}
var nodeJSWrapperPattern = regexp.MustCompile(`(?s)^const __mod = \{ exports: \{\} \};\s*\(new Function\('module', 'exports', (?:'require', )?(.+?)\)\)\(__mod, __mod\.exports(?:, require)?\);\s*const _fn = __mod\.exports;`)
func decodeNodeJSWrapperSource(decoded []byte) (string, bool) {
text := string(decoded)
matches := nodeJSWrapperPattern.FindStringSubmatch(text)
if len(matches) != 2 {
return "", false
}
var code string
if err := json.Unmarshal([]byte(matches[1]), &code); err != nil {
return "", false
}
if strings.TrimSpace(code) == "" {
return "", false
}
return code, true
}
// decodeZipSource извлекает исходный UTF-8 файл из zip архива.
// Предпочитает хорошо известные имена файлов (main.py, handler.go и т.д.).
func decodeZipSource(zipBytes []byte) (string, error) {
reader, err := zip.NewReader(bytes.NewReader(zipBytes), int64(len(zipBytes)))
if err != nil {
return "", err
}
// Сначала ищем по приоритетным именам
preferred := []string{"main.py", "main.js", "main.go", "main.php", "handler.rb", "handler.pl", "handler.go", "handler.js", "handler.py"}
for _, name := range preferred {
for _, file := range reader.File {
if strings.EqualFold(file.Name, name) {
content, readErr := readZipFile(file)
if readErr == nil && utf8.Valid(content) {
if src, ok := decodeNodeJSWrapperSource(content); ok {
return src, nil
}
return string(content), nil
}
}
}
}
// Fallback: первый UTF-8 файл по алфавиту (не директория)
files := make([]*zip.File, 0, len(reader.File))
for _, file := range reader.File {
if !file.FileInfo().IsDir() {
files = append(files, file)
}
}
sort.Slice(files, func(i, j int) bool { return files[i].Name < files[j].Name })
for _, file := range files {
content, readErr := readZipFile(file)
if readErr == nil && utf8.Valid(content) {
if src, ok := decodeNodeJSWrapperSource(content); ok {
return src, nil
}
return string(content), nil
}
}
return "", io.ErrUnexpectedEOF
}
// readZipFile открывает и читает содержимое файла из zip архива.
func readZipFile(file *zip.File) ([]byte, error) {
rc, err := file.Open()
if err != nil {
return nil, err
}
defer rc.Close()
return io.ReadAll(rc)
}
// decodeZipSource извлекает исходный UTF-8 файл из zip архива.
@@ -0,0 +1,98 @@
package api
import (
"fission-console/internal/runtime"
"testing"
)
func TestDecodeNodeJSWrapperSource(t *testing.T) {
wrapper := []byte("const __mod = { exports: {} };\n(new Function('module', 'exports', \"module.exports = async function () { return { status: 200, body: \\\"ok\\\" }; }\"))(__mod, __mod.exports);\nconst _fn = __mod.exports;")
src, ok := decodeNodeJSWrapperSource(wrapper)
if !ok {
t.Fatalf("decodeNodeJSWrapperSource() = false, want true")
}
want := "module.exports = async function () { return { status: 200, body: \"ok\" }; }"
if src != want {
t.Fatalf("decoded source = %q, want %q", src, want)
}
}
func TestDecodeArchiveBytesToSourcePrefersNodeJSWrapperExtraction(t *testing.T) {
wrapper := []byte("const __mod = { exports: {} };\n(new Function('module', 'exports', \"module.exports = async function () { return { status: 200, body: \\\"ok\\\" }; }\"))(__mod, __mod.exports);\nconst _fn = __mod.exports;\n\n\tasync function __invoke(ctx) {\n const fn = typeof _fn === 'function' ? _fn : (_fn.default || _fn.handler || _fn.main);\n if (!fn) throw new Error('no exported function found in user code');\n const result = await fn(ctx);\n if (!result) return { status: 200, body: '' };\n if (typeof result.status !== 'undefined') return result;\n return { status: 200, ...result };\n}\n\n\texport { __invoke as main, __invoke as handler };\n\texport default __invoke;\n")
src, err := decodeArchiveBytesToSource(wrapper)
if err != nil {
t.Fatalf("decodeArchiveBytesToSource() error = %v", err)
}
want := "module.exports = async function () { return { status: 200, body: \"ok\" }; }"
if src != want {
t.Fatalf("decoded source = %q, want %q", src, want)
}
}
func TestDecodeArchiveBytesToSourcePrefersNodeJSWrapperExtractionWithNewlines(t *testing.T) {
wrapper := []byte("const __mod = { exports: {} };\n(new Function('module', 'exports', \"module.exports = async function (context) {\\n const value = await Promise.resolve(context?.value || 'ok');\\n return { status: 200, body: value };\\n}\"))(__mod, __mod.exports);\nconst _fn = __mod.exports;\n\n\tasync function __invoke(ctx) {\n const fn = typeof _fn === 'function' ? _fn : (_fn.default || _fn.handler || _fn.main);\n if (!fn) throw new Error('no exported function found in user code');\n const result = await fn(ctx);\n if (!result) return { status: 200, body: '' };\n if (typeof result.status !== 'undefined') return result;\n return { status: 200, ...result };\n}\n\n\texport { __invoke as main, __invoke as handler };\n\texport default __invoke;\n")
src, err := decodeArchiveBytesToSource(wrapper)
if err != nil {
t.Fatalf("decodeArchiveBytesToSource() error = %v", err)
}
want := "module.exports = async function (context) {\n const value = await Promise.resolve(context?.value || 'ok');\n return { status: 200, body: value };\n}"
if src != want {
t.Fatalf("decoded source = %q, want %q", src, want)
}
}
func TestDecodeArchiveBytesToSourceKeepsPythonMultilineSource(t *testing.T) {
src := "def main(context):\n value = context.get('value', 'ok')\n return {'status': 200, 'body': value}\n"
got, err := decodeArchiveBytesToSource([]byte(src))
if err != nil {
t.Fatalf("decodeArchiveBytesToSource() error = %v", err)
}
if got != src {
t.Fatalf("decoded source = %q, want %q", got, src)
}
}
func TestDecodeArchiveBytesToSourceUnwrapsNodeJSZipPayload(t *testing.T) {
zipBytes, err := runtime.BuildJSDeployZip(`module.exports = async function (context) {
return { status: 200, body: context?.value || "ok" };
}`)
if err != nil {
t.Fatalf("runtime.BuildJSDeployZip() error = %v", err)
}
got, err := decodeArchiveBytesToSource(zipBytes)
if err != nil {
t.Fatalf("decodeArchiveBytesToSource() error = %v", err)
}
want := `module.exports = async function (context) {
return { status: 200, body: context?.value || "ok" };
}`
if got != want {
t.Fatalf("decoded source = %q, want %q", got, want)
}
}
func TestDecodeArchiveBytesToSourceKeepsRubyMultilineSource(t *testing.T) {
src := "def handler(context)\n value = context[:value] || 'ok'\n { status: 200, body: value }\nend\n"
got, err := decodeArchiveBytesToSource([]byte(src))
if err != nil {
t.Fatalf("decodeArchiveBytesToSource() error = %v", err)
}
if got != src {
t.Fatalf("decoded source = %q, want %q", got, src)
}
}
func TestDecodeArchiveBytesToSourceKeepsPHPMultilineSource(t *testing.T) {
src := "<?php\nfunction handler(array $context): array {\n $value = $context['value'] ?? 'ok';\n return ['status' => 200, 'body' => $value];\n}\n"
got, err := decodeArchiveBytesToSource([]byte(src))
if err != nil {
t.Fatalf("decodeArchiveBytesToSource() error = %v", err)
}
if got != src {
t.Fatalf("decoded source = %q, want %q", got, src)
}
}
+294
View File
@@ -0,0 +1,294 @@
package api
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strings"
"sync"
"time"
"fission-console/internal/auth"
"fission-console/internal/billing"
"fission-console/internal/cloud"
"fission-console/internal/fission"
"fission-console/internal/stats"
"fission-console/ui"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"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-а.
// Используется для авторизации запросов от console к Fission router.
const defaultSATokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token"
// Server — основная структура HTTP сервера.
// Содержит все зависимости: kubernetes client, конфиги, кэши.
type Server struct {
dyn dynamic.Interface
kube kubernetes.Interface // typed client — только для логов подов
ns string // системный namespace (fallback, обычно "fission")
routerURL string
http *http.Client
saTokenPath string
invokeTimeout time.Duration
authUser string
authPass string
storagesvcURL string // URL Fission storagesvc для загрузки архивов кода в S3
// --- ai/ask feature (удалить блок целиком чтобы выкосить) ---
llmURL string // FISSION_LLM_URL
llmKey string // FISSION_LLM_KEY
// --- end ai/ask feature ---
// tokenMu защищает кэш JWT токена для аутентификации в Fission router.
tokenMu sync.Mutex
cachedJWT string
tokenExpAt time.Time
// authenticator — слой аутентификации. Сервер не знает деталей реализации.
authenticator auth.Authenticator
// nsManager управляет жизненным циклом пользовательских namespace-ов.
nsManager *cloud.NSManager
// billing — слой записи статистики вызовов. NoopStore если BILLING_DSN не задан.
billing billing.Store
// stats — аналитический слой (Grafana Organizations). NoopProvider если не настроен.
stats stats.StatsProvider
}
// Config содержит все параметры для создания Server.
type Config struct {
Dyn dynamic.Interface
Kube kubernetes.Interface
Namespace string
RouterURL string
HTTPTimeout time.Duration
InvokeTimeout time.Duration
SATokenPath string
AuthUser string
AuthPass string
StoragesvcURL string
Authenticator auth.Authenticator // слой аутентификации
LLMUrl string
LLMKey string
Billing billing.Store // слой статистики (NoopStore если не задан)
Stats stats.StatsProvider // аналитика (NoopProvider если не настроен)
}
// NewServer создаёт и настраивает HTTP Server со всеми зависимостями.
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},
saTokenPath: cfg.SATokenPath,
invokeTimeout: cfg.InvokeTimeout,
authUser: cfg.AuthUser,
authPass: cfg.AuthPass,
storagesvcURL: cfg.StoragesvcURL,
authenticator: cfg.Authenticator,
llmURL: cfg.LLMUrl,
llmKey: cfg.LLMKey,
nsManager: cloud.NewNSManager(cfg.Dyn),
billing: cfg.Billing,
stats: cfg.Stats,
}
}
// RegisterRoutes регистрирует все HTTP маршруты на mux.
// Разделено на публичные (без auth) и приватные (с authMiddleware).
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
// Публичные: health checks, UI, auth endpoint
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
http.Redirect(w, r, "/console/", http.StatusTemporaryRedirect)
})
mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("ok\n"))
})
mux.HandleFunc("/console/health", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("ok\n"))
})
// UI: embedded single-page app
uiHandler := http.StripPrefix("/console", ui.Handler())
mux.Handle("/console", uiHandler)
mux.Handle("/console/", uiHandler)
// Auth: не требует токена — сам проверяет и возвращает namespace
mux.HandleFunc("/console/api/auth", s.handleAuth)
// Cron: публичные маршруты для метрик
cronHandler := ui.CronHandler()
mux.Handle("/cron", cronHandler)
mux.Handle("/cron/", cronHandler)
mux.Handle("/console/cron", cronHandler)
mux.Handle("/console/cron/", cronHandler)
mux.HandleFunc("/cron/api/metrics", s.handleCronMetrics)
mux.HandleFunc("/console/cron/api/metrics", s.handleCronMetrics)
// Приватные: требуют X-Auth-Token / X-Test-Sub
auth := s.authMiddleware
// Legacy /api/* (без /console префикса) — для обратной совместимости
mux.HandleFunc("/api/environments", s.handleList(fission.EnvironmentGVR))
mux.HandleFunc("/api/packages", s.handleList(fission.PackageGVR))
mux.HandleFunc("/api/functions", s.handleFunctionsRoot)
mux.HandleFunc("/api/functions/", s.handleFunctionsAction)
mux.HandleFunc("/api/httptriggers", s.handleList(fission.HTTPTrigGVR))
mux.HandleFunc("/api/timetriggers", s.handleTimeTriggersRoot)
mux.HandleFunc("/api/timetriggers/", s.handleTimeTriggersAction)
// Основные /console/api/* маршруты
mux.HandleFunc("/console/api/environments", auth(s.handleList(fission.EnvironmentGVR)))
mux.HandleFunc("/console/api/packages", auth(s.handleList(fission.PackageGVR)))
mux.HandleFunc("/console/api/functions", auth(s.handleFunctionsRoot))
mux.HandleFunc("/console/api/functions/", auth(s.handleFunctionsAction))
mux.HandleFunc("/fission-function", s.handleFissionFunctionGateway)
mux.HandleFunc("/fission-function/", s.handleFissionFunctionGateway)
mux.HandleFunc("/fn/", auth(s.handleInvokeRoute))
mux.HandleFunc("/console/api/httptriggers", auth(s.handleList(fission.HTTPTrigGVR)))
mux.HandleFunc("/console/api/timetriggers", auth(s.handleTimeTriggersRoot))
mux.HandleFunc("/console/api/timetriggers/", auth(s.handleTimeTriggersAction))
mux.HandleFunc("/console/api/mqtriggers", auth(s.handleMQTriggersRoot))
mux.HandleFunc("/console/api/mqtriggers/", auth(s.handleMQTriggersAction))
mux.HandleFunc("/console/api/kwtriggers", auth(s.handleKWTriggersRoot))
mux.HandleFunc("/console/api/kwtriggers/", auth(s.handleKWTriggersAction))
mux.HandleFunc("/console/api/ns/status", auth(s.handleNSStatus))
mux.HandleFunc("/console/api/ns/debug", auth(s.handleNSDebug))
mux.HandleFunc("/console/api/stats/dashboard-url", auth(s.handleStatsDashboard))
mux.HandleFunc("/console/api/ai/check", auth(s.handleAICheck))
mux.HandleFunc("/console/api/ai/lint-archive", auth(s.handleLintArchive))
mux.HandleFunc("/console/api/ai/explain-archive", auth(s.handleExplainArchive))
// --- ai/ask feature (удалить строку чтобы выкосить роут) ---
mux.HandleFunc("/console/api/ai/ask", auth(s.handleAIAsk))
// --- end ai/ask feature ---
}
// Handler возвращает http.Handler со всеми middleware: CORS, security headers, логирование.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
s.RegisterRoutes(mux)
return withSecurityHeaders(withCORS(logRequests(mux)))
}
// NSManager возвращает указатель на NSManager для запуска фоновых горутин из main.
func (s *Server) NSManager() *cloud.NSManager {
return s.nsManager
}
// handleList возвращает handler который читает список ресурсов из namespace пользователя.
// Общий для environments, packages, functions, httptriggers, timetriggers.
func (s *Server) handleList(gvr schema.GroupVersionResource) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
list, err := s.dyn.Resource(gvr).Namespace(s.userNS(r)).List(ctx, metav1.ListOptions{})
if err != nil {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("list %s: %v", gvr.Resource, err))
return
}
writeJSON(w, http.StatusOK, list.Items)
}
}
// writeJSON сериализует список unstructured объектов в JSON response.
func writeJSON(w http.ResponseWriter, status int, data []unstructured.Unstructured) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(data)
}
// writeAnyJSON сериализует любое значение в JSON response.
func writeAnyJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(data)
}
// writeJSONError возвращает JSON с ключом "error" и заданным HTTP статусом.
func writeJSONError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]any{"error": msg})
}
// readSAToken читает service account JWT токен из файла.
// Используется как fallback аутентификация для запросов к Fission router
// когда authUser/authPass не заданы.
func (s *Server) readSAToken() string {
if s.saTokenPath == "" {
return ""
}
data, err := os.ReadFile(s.saTokenPath)
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
// getRouterToken возвращает Bearer токен для запросов к Fission router.
// Если authUser+authPass заданы — получает JWT через login endpoint (с кэшем).
// Иначе — использует SA токен из pod filesystem.
func (s *Server) getRouterToken() string {
if s.authUser == "" || s.authPass == "" {
return s.readSAToken()
}
s.tokenMu.Lock()
defer s.tokenMu.Unlock()
if s.cachedJWT != "" && time.Now().Before(s.tokenExpAt) {
return s.cachedJWT // кэш ещё актуален
}
loginURL := s.routerURL + "/auth/login"
body, _ := json.Marshal(map[string]string{"username": s.authUser, "password": s.authPass})
resp, err := s.http.Post(loginURL, "application/json", bytes.NewReader(body))
if err != nil {
return s.readSAToken()
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return s.readSAToken()
}
var result struct {
AccessToken string `json:"accesstoken"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil || result.AccessToken == "" {
return s.readSAToken()
}
s.cachedJWT = result.AccessToken
// Fission JWT живёт ~120s; кэшируем на 100s чтобы не использовать просроченный
s.tokenExpAt = time.Now().Add(100 * time.Second)
return s.cachedJWT
}
+25
View File
@@ -0,0 +1,25 @@
package api
import (
"encoding/json"
"net/http"
)
// handleStatsDashboard GET /console/api/stats/dashboard-url
// Возвращает публичный URL дашборда Grafana для текущего namespace пользователя.
// Если аналитика не настроена — возвращает {"url":""}.
func (s *Server) handleStatsDashboard(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
ns := s.userNS(r)
url := s.stats.DashboardURL(r.Context(), ns)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(map[string]any{
"url": url,
"namespace": ns,
})
}
+138
View File
@@ -0,0 +1,138 @@
// Package api — определение статуса функции.
//
// Этот файл содержит функции для определения статуса функции на основе:
// - buildStatus пакета (Package CRD)
// - состояния pod-а (kubernetes Pod)
//
// Возможные статусы:
// - "Ready" (зелёный) — Pod в состоянии Running
// - "Cold" (синий) — функция готова, но нет активного Pod
// - "Building" (жёлтый) — Package ещё собирается (buildStatus != "succeeded")
// - "Error" (красный) — Pod в состоянии ошибки (CrashLoopBackOff, ImagePullBackOff и т.д.) или build failed
package api
import (
"context"
"fmt"
"net/http"
"time"
"fission-console/internal/fission"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
// FunctionStatus — расширенный объект Function с полем status.
type FunctionStatus struct {
// Встраиваем исходный объект и добавляем status
Object map[string]any `json:"object,inline"`
Status string `json:"status"` // "Ready", "Cold", "Building", "Error"
}
// getFunctionStatus определяет статус функции на основе pod-ов и Package.
func (s *Server) getFunctionStatus(ctx context.Context, ns string, fn *unstructured.Unstructured) (string, error) {
fnName := fn.GetName()
// Шаг 1: проверяем buildStatus пакета (если есть)
packageName, _, _ := unstructured.NestedString(fn.Object, "spec", "package", "packageref", "name")
if packageName != "" {
pkg, pkgErr := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Get(ctx, packageName, metav1.GetOptions{})
if pkgErr == nil {
buildStatus, _, _ := unstructured.NestedString(pkg.Object, "status", "buildstatus")
// "failed" → Error
// "none", "succeeded" → OK, Package готов
// "pending", "building" и т.д. → Building
if buildStatus == "failed" {
return "Error", nil
}
if buildStatus != "" && buildStatus != "succeeded" && buildStatus != "none" {
// Building, Pending и т.д.
return "Building", nil
}
}
}
// Шаг 2: ищем Pod-ы для этой функции (по лейблу functionName=<name>)
pods, err := s.kube.CoreV1().Pods(ns).List(ctx, metav1.ListOptions{
LabelSelector: fmt.Sprintf("functionName=%s", fnName),
})
if err != nil && !apierrors.IsNotFound(err) {
// Логируем ошибку, но не падаем — возвращаем "Cold" (функция есть, но pod-а нет)
return "Cold", nil
}
// Шаг 3: анализируем состояние pod-ов
if pods != nil && len(pods.Items) > 0 {
for _, pod := range pods.Items {
// Проверяем фазу pod-а
phase := pod.Status.Phase
if phase == "Running" {
// Дополнительная проверка: все контейнеры готовы?
allReady := true
for _, status := range pod.Status.ContainerStatuses {
if !status.Ready {
allReady = false
break
}
}
if allReady {
return "Ready", nil
}
}
// Проверяем состояние контейнеров (CrashLoopBackOff, ImagePullBackOff и т.д.)
for _, status := range pod.Status.ContainerStatuses {
if status.State.Waiting != nil && status.State.Waiting.Reason == "CrashLoopBackOff" {
return "Error", nil
}
if status.State.Waiting != nil && status.State.Waiting.Reason == "ImagePullBackOff" {
return "Error", nil
}
if status.LastTerminationState.Terminated != nil && status.LastTerminationState.Terminated.ExitCode != 0 {
return "Error", nil
}
}
}
// Pod есть, но не в Running или Error состоянии → Building/Deploying
return "Building", nil
}
// Нет pod-ов → Cold (функция холодная, но готова)
return "Cold", nil
}
// handleListFunctionsWithStatus возвращает список функций с полем "status".
// Добавляет status в поле .metadata.annotations["fission-console/status"] каждой функции.
func (s *Server) handleListFunctionsWithStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) // 30 сек для определения статуса всех функций
defer cancel()
ns := s.userNS(r)
// Получаем список функций
list, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).List(ctx, metav1.ListOptions{})
if err != nil {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("list functions: %v", err))
return
}
// Обогащаем каждую функцию статусом (добавляем в .metadata.annotations["fission-console/status"])
for i := range list.Items {
status, _ := s.getFunctionStatus(ctx, ns, &list.Items[i])
anns := list.Items[i].GetAnnotations()
if anns == nil {
anns = make(map[string]string)
}
anns["fission-console/status"] = status
list.Items[i].SetAnnotations(anns)
}
writeJSON(w, http.StatusOK, list.Items)
}
+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
}
+505
View File
@@ -0,0 +1,505 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sort"
"strings"
"time"
"fission-console/internal/fission"
"fission-console/internal/model"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
)
const (
timeTriggerDefaultMethod = http.MethodPost
timeTriggerDefaultSubPath = "/"
timeTriggerMaxNameLen = 63
cronGatewayURL = "http://fission-console.fission.svc.cluster.local"
)
var validTimeTriggerMethods = map[string]struct{}{
http.MethodGet: {},
http.MethodPost: {},
http.MethodPut: {},
http.MethodDelete: {},
http.MethodHead: {},
}
func (s *Server) handleTimeTriggersRoot(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
s.handleList(fission.TimeTrigGVR)(w, r)
case http.MethodPost:
s.handleCreateTimeTrigger(w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleTimeTriggersAction(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/timetriggers/")
if path == r.URL.Path {
path = strings.TrimPrefix(r.URL.Path, "/console/api/timetriggers/")
}
path = strings.Trim(path, "/")
if path == "" {
http.NotFound(w, r)
return
}
parts := strings.Split(path, "/")
name := strings.TrimSpace(parts[0])
if name == "" || len(parts) != 1 {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodGet:
s.handleGetTimeTrigger(w, r, name)
case http.MethodPut:
s.handleUpdateTimeTrigger(w, r, name)
case http.MethodDelete:
s.handleDeleteTimeTrigger(w, r, name)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleCreateTimeTrigger(w http.ResponseWriter, r *http.Request) {
var req model.CreateTimeTriggerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("decode request: %v", err))
return
}
ns := s.userNS(r)
if s.nsManager != nil {
nsCtx, nsCancel := context.WithTimeout(r.Context(), 30*time.Second)
defer nsCancel()
if err := s.nsManager.EnsureUserNS(nsCtx, ns); err != nil {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("ensure namespace: %v", err))
return
}
}
if err := s.ensureTimerWatchesNamespace(r.Context(), ns); err != nil {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("sync timer namespaces: %v", err))
return
}
trigger, err := s.createTimeTrigger(r.Context(), ns, req)
if err != nil {
writeJSONError(w, errStatus(err), err.Error())
return
}
writeAnyJSON(w, http.StatusCreated, timeTriggerResponse(trigger))
}
func (s *Server) handleGetTimeTrigger(w http.ResponseWriter, r *http.Request, name string) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
trigger, err := s.dyn.Resource(fission.TimeTrigGVR).Namespace(s.userNS(r)).Get(ctx, name, metav1.GetOptions{})
if err != nil {
status := http.StatusBadGateway
if apierrors.IsNotFound(err) {
status = http.StatusNotFound
}
writeJSONError(w, status, fmt.Sprintf("get timetrigger %q: %v", name, err))
return
}
writeAnyJSON(w, http.StatusOK, timeTriggerResponse(trigger))
}
func (s *Server) handleUpdateTimeTrigger(w http.ResponseWriter, r *http.Request, name string) {
var req model.CreateTimeTriggerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("decode request: %v", err))
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
if err := s.ensureTimerWatchesNamespace(ctx, s.userNS(r)); err != nil {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("sync timer namespaces: %v", err))
return
}
trigger, err := s.updateTimeTrigger(ctx, s.userNS(r), name, req)
if err != nil {
writeJSONError(w, errStatus(err), err.Error())
return
}
writeAnyJSON(w, http.StatusOK, map[string]any{
"updated": true,
"trigger": timeTriggerResponse(trigger),
})
}
func (s *Server) handleDeleteTimeTrigger(w http.ResponseWriter, r *http.Request, name string) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
if err := s.deleteTimeTrigger(ctx, s.userNS(r), name); err != nil {
writeJSONError(w, errStatus(err), err.Error())
return
}
writeAnyJSON(w, http.StatusOK, map[string]any{"deleted": true, "name": name})
}
func (s *Server) createTimeTrigger(ctx context.Context, ns string, req model.CreateTimeTriggerRequest) (*unstructured.Unstructured, error) {
normalized, err := normalizeTimeTriggerRequest(req)
if err != nil {
return nil, err
}
if _, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, normalized.FunctionName, metav1.GetOptions{}); err != nil {
if apierrors.IsNotFound(err) {
return nil, &apiErr{status: http.StatusBadRequest, message: fmt.Sprintf("function %q not found", normalized.FunctionName)}
}
return nil, &apiErr{status: http.StatusBadGateway, message: fmt.Sprintf("get function %q: %v", normalized.FunctionName, err)}
}
trigger := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "TimeTrigger",
"metadata": map[string]any{
"name": normalized.Name,
"namespace": ns,
},
"spec": map[string]any{
"cron": normalized.Cron,
"functionref": map[string]any{
"type": "name",
"name": normalized.FunctionName,
},
"method": normalized.Method,
"subpath": normalized.SubPath,
},
}}
created, err := s.dyn.Resource(fission.TimeTrigGVR).Namespace(ns).Create(ctx, trigger, metav1.CreateOptions{})
if err != nil {
if apierrors.IsAlreadyExists(err) {
return nil, &apiErr{status: http.StatusConflict, message: fmt.Sprintf("timetrigger %q already exists", normalized.Name)}
}
if apierrors.IsInvalid(err) {
return nil, &apiErr{status: http.StatusBadRequest, message: fmt.Sprintf("invalid timetrigger spec: %v", err)}
}
return nil, &apiErr{status: http.StatusBadGateway, message: fmt.Sprintf("create timetrigger: %v", err)}
}
return created, nil
}
func (s *Server) updateTimeTrigger(ctx context.Context, ns, name string, req model.CreateTimeTriggerRequest) (*unstructured.Unstructured, error) {
normalized, err := normalizeTimeTriggerRequest(req)
if err != nil {
return nil, err
}
trigger, err := s.dyn.Resource(fission.TimeTrigGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
if err != nil {
if apierrors.IsNotFound(err) {
return nil, &apiErr{status: http.StatusNotFound, message: fmt.Sprintf("timetrigger %q not found", name)}
}
return nil, &apiErr{status: http.StatusBadGateway, message: fmt.Sprintf("get timetrigger %q: %v", name, err)}
}
if _, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, normalized.FunctionName, metav1.GetOptions{}); err != nil {
if apierrors.IsNotFound(err) {
return nil, &apiErr{status: http.StatusBadRequest, message: fmt.Sprintf("function %q not found", normalized.FunctionName)}
}
return nil, &apiErr{status: http.StatusBadGateway, message: fmt.Sprintf("get function %q: %v", normalized.FunctionName, err)}
}
if err := unstructured.SetNestedField(trigger.Object, normalized.Cron, "spec", "cron"); err != nil {
return nil, &apiErr{status: http.StatusInternalServerError, message: fmt.Sprintf("set timetrigger cron: %v", err)}
}
if err := unstructured.SetNestedField(trigger.Object, map[string]any{
"type": "name",
"name": normalized.FunctionName,
}, "spec", "functionref"); err != nil {
return nil, &apiErr{status: http.StatusInternalServerError, message: fmt.Sprintf("set timetrigger functionref: %v", err)}
}
if err := unstructured.SetNestedField(trigger.Object, normalized.Method, "spec", "method"); err != nil {
return nil, &apiErr{status: http.StatusInternalServerError, message: fmt.Sprintf("set timetrigger method: %v", err)}
}
if err := unstructured.SetNestedField(trigger.Object, normalized.SubPath, "spec", "subpath"); err != nil {
return nil, &apiErr{status: http.StatusInternalServerError, message: fmt.Sprintf("set timetrigger subpath: %v", err)}
}
updated, err := s.dyn.Resource(fission.TimeTrigGVR).Namespace(ns).Update(ctx, trigger, metav1.UpdateOptions{})
if err != nil {
if apierrors.IsInvalid(err) {
return nil, &apiErr{status: http.StatusBadRequest, message: fmt.Sprintf("invalid timetrigger spec: %v", err)}
}
return nil, &apiErr{status: http.StatusBadGateway, message: fmt.Sprintf("update timetrigger %q: %v", name, err)}
}
return updated, nil
}
func (s *Server) deleteTimeTrigger(ctx context.Context, ns, name string) error {
if err := s.dyn.Resource(fission.TimeTrigGVR).Namespace(ns).Delete(ctx, name, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) {
return &apiErr{status: http.StatusBadGateway, message: fmt.Sprintf("delete timetrigger %q: %v", name, err)}
}
return nil
}
func normalizeTimeTriggerRequest(req model.CreateTimeTriggerRequest) (model.CreateTimeTriggerRequest, error) {
req.Name = strings.TrimSpace(req.Name)
req.FunctionName = strings.TrimSpace(req.FunctionName)
req.Cron = strings.TrimSpace(req.Cron)
req.Method = strings.TrimSpace(req.Method)
req.SubPath = strings.TrimSpace(req.SubPath)
if req.Name == "" {
return req, &apiErr{status: http.StatusBadRequest, message: "name is required"}
}
if !validFuncName.MatchString(req.Name) || len(req.Name) > timeTriggerMaxNameLen {
return req, &apiErr{status: http.StatusBadRequest, message: "invalid timetrigger name: must match ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ and be <= 63 chars"}
}
if req.FunctionName == "" {
return req, &apiErr{status: http.StatusBadRequest, message: "functionName is required"}
}
if !validFuncName.MatchString(req.FunctionName) || len(req.FunctionName) > timeTriggerMaxNameLen {
return req, &apiErr{status: http.StatusBadRequest, message: "invalid functionName: must match ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ and be <= 63 chars"}
}
if req.Cron == "" {
return req, &apiErr{status: http.StatusBadRequest, message: "cron is required"}
}
req.Method = strings.ToUpper(req.Method)
if req.Method == "" {
req.Method = timeTriggerDefaultMethod
}
if _, ok := validTimeTriggerMethods[req.Method]; !ok {
return req, &apiErr{status: http.StatusBadRequest, message: "invalid method: must be GET, POST, PUT, DELETE or HEAD"}
}
if req.SubPath == "" || req.SubPath == timeTriggerDefaultSubPath {
req.SubPath = timeTriggerDefaultSubPath
} else if !strings.HasPrefix(req.SubPath, "/") {
req.SubPath = "/" + req.SubPath
}
return req, nil
}
func (s *Server) ensureTimerWatchesNamespace(ctx context.Context, userNS string) error {
userNS = strings.TrimSpace(userNS)
if userNS == "" {
return nil
}
deployGVR := schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"}
deploy, err := s.dyn.Resource(deployGVR).Namespace(fissionSystemNamespace()).Get(ctx, "timer", metav1.GetOptions{})
if err != nil {
if apierrors.IsNotFound(err) {
return nil
}
return fmt.Errorf("get timer deployment: %w", err)
}
containers, found, err := unstructured.NestedSlice(deploy.Object, "spec", "template", "spec", "containers")
if err != nil {
return fmt.Errorf("read timer containers: %w", err)
}
if !found {
return fmt.Errorf("timer containers not found")
}
if len(containers) == 0 {
return fmt.Errorf("timer containers empty")
}
container, ok := containers[0].(map[string]any)
if !ok {
return fmt.Errorf("timer container has unexpected shape")
}
envList, found, err := unstructured.NestedSlice(container, "env")
if err != nil {
return fmt.Errorf("read timer env: %w", err)
}
if !found {
return fmt.Errorf("timer env not found")
}
defaultNS := "default"
resourceNamespaces := []string{"default"}
defaultIdx := -1
resourceIdx := -1
routerIdx := -1
changed := false
for i, item := range envList {
env, ok := item.(map[string]any)
if !ok {
continue
}
name, _ := env["name"].(string)
value, _ := env["value"].(string)
switch name {
case "FISSION_DEFAULT_NAMESPACE":
defaultIdx = i
if strings.TrimSpace(value) != "" {
defaultNS = strings.TrimSpace(value)
}
case "FISSION_RESOURCE_NAMESPACES":
resourceIdx = i
resourceNamespaces = splitCSVNamespaces(value)
case "FISSION_ROUTER_URL":
routerIdx = i
if value != cronGatewayURL {
env["value"] = cronGatewayURL
envList[i] = env
changed = true
}
}
}
if len(resourceNamespaces) == 0 {
resourceNamespaces = []string{defaultNS}
}
resourceNamespaces = appendNamespace(resourceNamespaces, userNS)
resourceNamespaces = ensureDefaultFirst(resourceNamespaces, defaultNS)
joined := strings.Join(resourceNamespaces, ",")
if defaultIdx >= 0 {
env := envList[defaultIdx].(map[string]any)
if env["value"] != defaultNS {
env["value"] = defaultNS
envList[defaultIdx] = env
changed = true
}
}
if resourceIdx >= 0 {
env := envList[resourceIdx].(map[string]any)
if env["value"] != joined {
env["value"] = joined
envList[resourceIdx] = env
changed = true
}
}
if resourceIdx < 0 {
envList = append(envList, map[string]any{"name": "FISSION_RESOURCE_NAMESPACES", "value": joined})
changed = true
}
if defaultIdx < 0 {
envList = append(envList, map[string]any{"name": "FISSION_DEFAULT_NAMESPACE", "value": defaultNS})
changed = true
}
if routerIdx < 0 {
envList = append(envList, map[string]any{"name": "FISSION_ROUTER_URL", "value": cronGatewayURL})
changed = true
}
if !changed {
return nil
}
container["env"] = envList
containers[0] = container
if err := unstructured.SetNestedSlice(deploy.Object, containers, "spec", "template", "spec", "containers"); err != nil {
return fmt.Errorf("write timer env: %w", err)
}
if _, err := s.dyn.Resource(deployGVR).Namespace(fissionSystemNamespace()).Update(ctx, deploy, metav1.UpdateOptions{}); err != nil {
return fmt.Errorf("update timer deployment: %w", err)
}
return nil
}
func splitCSVNamespaces(raw string) []string {
parts := strings.Split(raw, ",")
seen := make(map[string]struct{}, len(parts))
result := make([]string, 0, len(parts))
for _, part := range parts {
name := strings.TrimSpace(part)
if name == "" {
continue
}
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
result = append(result, name)
}
return result
}
func appendNamespace(namespaces []string, ns string) []string {
for _, existing := range namespaces {
if existing == ns {
return namespaces
}
}
return append(namespaces, ns)
}
func ensureDefaultFirst(namespaces []string, defaultNS string) []string {
uniq := splitCSVNamespaces(strings.Join(namespaces, ","))
others := make([]string, 0, len(uniq))
for _, ns := range uniq {
if ns != defaultNS {
others = append(others, ns)
}
}
sort.Strings(others)
return append([]string{defaultNS}, others...)
}
func timeTriggerResponse(trigger *unstructured.Unstructured) map[string]any {
result := map[string]any{}
if trigger == nil {
return result
}
result["raw"] = trigger.Object
result["name"] = trigger.GetName()
result["namespace"] = trigger.GetNamespace()
if cron, found, _ := unstructured.NestedString(trigger.Object, "spec", "cron"); found {
result["cron"] = cron
}
if method, found, _ := unstructured.NestedString(trigger.Object, "spec", "method"); found {
result["method"] = method
}
if subpath, found, _ := unstructured.NestedString(trigger.Object, "spec", "subpath"); found {
result["subpath"] = subpath
}
if functionName, found, _ := unstructured.NestedString(trigger.Object, "spec", "functionref", "name"); found {
result["function"] = functionName
}
return result
}
type apiErr struct {
status int
message string
}
func (e *apiErr) Error() string {
if e == nil {
return ""
}
return e.message
}
func errStatus(err error) int {
if err == nil {
return http.StatusInternalServerError
}
if ae, ok := err.(*apiErr); ok && ae.status != 0 {
return ae.status
}
return http.StatusInternalServerError
}
@@ -0,0 +1,140 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"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"
runtime "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
dynamicfake "k8s.io/client-go/dynamic/fake"
)
func TestHandleTimeTriggersRootCreateListGetUpdateDelete(t *testing.T) {
ctx := context.Background()
scheme := runtime.NewScheme()
client := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(
scheme,
map[schema.GroupVersionResource]string{
fission.TimeTrigGVR: "TimeTriggerList",
},
functionObject("fission-test", "hello"),
)
s := &Server{dyn: client, ns: "fission-test"}
createReq := httptest.NewRequest(http.MethodPost, "/console/api/timetriggers", strings.NewReader(`{"name":"cron-job","functionName":"hello","cron":"*/5 * * * *","method":"get","subpath":"logs"}`))
createRec := httptest.NewRecorder()
s.handleTimeTriggersRoot(createRec, createReq)
if createRec.Code != http.StatusCreated {
t.Fatalf("create status = %d, want %d body=%s", createRec.Code, http.StatusCreated, createRec.Body.String())
}
created := decodeMap(t, createRec.Body.Bytes())
if created["name"] != "cron-job" {
t.Fatalf("create name = %v", created["name"])
}
if created["cron"] != "*/5 * * * *" {
t.Fatalf("create cron = %v", created["cron"])
}
if created["method"] != http.MethodGet {
t.Fatalf("create method = %v", created["method"])
}
if created["subpath"] != "/logs" {
t.Fatalf("create subpath = %v", created["subpath"])
}
createdObj, err := client.Resource(fission.TimeTrigGVR).Namespace("fission-test").Get(ctx, "cron-job", metav1.GetOptions{})
if err != nil {
t.Fatalf("get created timetrigger: %v", err)
}
if cron, _, _ := unstructured.NestedString(createdObj.Object, "spec", "cron"); cron != "*/5 * * * *" {
t.Fatalf("stored cron = %q", cron)
}
listReq := httptest.NewRequest(http.MethodGet, "/console/api/timetriggers", nil)
listRec := httptest.NewRecorder()
s.handleTimeTriggersRoot(listRec, listReq)
if listRec.Code != http.StatusOK {
t.Fatalf("list status = %d body=%s", listRec.Code, listRec.Body.String())
}
if !strings.Contains(listRec.Body.String(), "cron-job") {
t.Fatalf("list body does not contain created trigger: %s", listRec.Body.String())
}
getReq := httptest.NewRequest(http.MethodGet, "/console/api/timetriggers/cron-job", nil)
getRec := httptest.NewRecorder()
s.handleTimeTriggersAction(getRec, getReq)
if getRec.Code != http.StatusOK {
t.Fatalf("get status = %d body=%s", getRec.Code, getRec.Body.String())
}
got := decodeMap(t, getRec.Body.Bytes())
if got["function"] != "hello" {
t.Fatalf("get function = %v", got["function"])
}
updateReq := httptest.NewRequest(http.MethodPut, "/console/api/timetriggers/cron-job", strings.NewReader(`{"name":"cron-job","functionName":"hello","cron":"@hourly","method":"post","subpath":"/"}`))
updateRec := httptest.NewRecorder()
s.handleTimeTriggersAction(updateRec, updateReq)
if updateRec.Code != http.StatusOK {
t.Fatalf("update status = %d body=%s", updateRec.Code, updateRec.Body.String())
}
updated, err := client.Resource(fission.TimeTrigGVR).Namespace("fission-test").Get(ctx, "cron-job", metav1.GetOptions{})
if err != nil {
t.Fatalf("get updated timetrigger: %v", err)
}
if cron, _, _ := unstructured.NestedString(updated.Object, "spec", "cron"); cron != "@hourly" {
t.Fatalf("updated cron = %q", cron)
}
deleteReq := httptest.NewRequest(http.MethodDelete, "/console/api/timetriggers/cron-job", nil)
deleteRec := httptest.NewRecorder()
s.handleTimeTriggersAction(deleteRec, deleteReq)
if deleteRec.Code != http.StatusOK {
t.Fatalf("delete status = %d body=%s", deleteRec.Code, deleteRec.Body.String())
}
if _, err := client.Resource(fission.TimeTrigGVR).Namespace("fission-test").Get(ctx, "cron-job", metav1.GetOptions{}); !apierrors.IsNotFound(err) {
t.Fatalf("expected timetrigger to be deleted, got err=%v", err)
}
}
func TestHandleCreateTimeTriggerRejectsMissingFunction(t *testing.T) {
client := dynamicfake.NewSimpleDynamicClient(runtime.NewScheme())
s := &Server{dyn: client, ns: "fission-test"}
req := httptest.NewRequest(http.MethodPost, "/console/api/timetriggers", strings.NewReader(`{"name":"cron-job","functionName":"missing","cron":"*/5 * * * *"}`))
rec := httptest.NewRecorder()
s.handleTimeTriggersRoot(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "not found") {
t.Fatalf("expected not found error, got %s", rec.Body.String())
}
}
func decodeMap(t *testing.T, data []byte) map[string]any {
t.Helper()
var result map[string]any
if err := json.Unmarshal(data, &result); err != nil {
t.Fatalf("decode json: %v body=%s", err, string(data))
}
return result
}
func functionObject(namespace, name string) *unstructured.Unstructured {
return &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "Function",
"metadata": map[string]any{
"name": name,
"namespace": namespace,
},
}}
}