restructure: console→client-console, add admin-console skeleton, move docs to doc/
This commit is contained in:
@@ -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
|
||||
// =============================================================================
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:])
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// Package api — определение статуса функции.
|
||||
//
|
||||
// Этот файл содержит функции для определения статуса функции на основе:
|
||||
// - buildStatus пакета (Package CRD)
|
||||
// - состояния pod-а (kubernetes Pod)
|
||||
//
|
||||
// Возможные статусы:
|
||||
// - "Ready" (зелёный) — Pod в состоянии Running
|
||||
// - "Cold" (синий) — функция готова, но нет активного Pod
|
||||
// - "Building" (жёлтый) — Package ещё собирается (buildStatus != "succeeded")
|
||||
// - "Error" (красный) — Pod в состоянии ошибки (CrashLoopBackOff, ImagePullBackOff и т.д.) или build failed
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"fission-console/internal/fission"
|
||||
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
)
|
||||
|
||||
// FunctionStatus — расширенный объект Function с полем status.
|
||||
type FunctionStatus struct {
|
||||
// Встраиваем исходный объект и добавляем status
|
||||
Object map[string]any `json:"object,inline"`
|
||||
Status string `json:"status"` // "Ready", "Cold", "Building", "Error"
|
||||
}
|
||||
|
||||
// getFunctionStatus определяет статус функции на основе pod-ов и Package.
|
||||
func (s *Server) getFunctionStatus(ctx context.Context, ns string, fn *unstructured.Unstructured) (string, error) {
|
||||
fnName := fn.GetName()
|
||||
|
||||
// Шаг 1: проверяем buildStatus пакета (если есть)
|
||||
packageName, _, _ := unstructured.NestedString(fn.Object, "spec", "package", "packageref", "name")
|
||||
if packageName != "" {
|
||||
pkg, pkgErr := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Get(ctx, packageName, metav1.GetOptions{})
|
||||
if pkgErr == nil {
|
||||
buildStatus, _, _ := unstructured.NestedString(pkg.Object, "status", "buildstatus")
|
||||
// "failed" → Error
|
||||
// "none", "succeeded" → OK, Package готов
|
||||
// "pending", "building" и т.д. → Building
|
||||
if buildStatus == "failed" {
|
||||
return "Error", nil
|
||||
}
|
||||
if buildStatus != "" && buildStatus != "succeeded" && buildStatus != "none" {
|
||||
// Building, Pending и т.д.
|
||||
return "Building", nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Шаг 2: ищем Pod-ы для этой функции (по лейблу functionName=<name>)
|
||||
pods, err := s.kube.CoreV1().Pods(ns).List(ctx, metav1.ListOptions{
|
||||
LabelSelector: fmt.Sprintf("functionName=%s", fnName),
|
||||
})
|
||||
if err != nil && !apierrors.IsNotFound(err) {
|
||||
// Логируем ошибку, но не падаем — возвращаем "Cold" (функция есть, но pod-а нет)
|
||||
return "Cold", nil
|
||||
}
|
||||
|
||||
// Шаг 3: анализируем состояние pod-ов
|
||||
if pods != nil && len(pods.Items) > 0 {
|
||||
for _, pod := range pods.Items {
|
||||
// Проверяем фазу pod-а
|
||||
phase := pod.Status.Phase
|
||||
if phase == "Running" {
|
||||
// Дополнительная проверка: все контейнеры готовы?
|
||||
allReady := true
|
||||
for _, status := range pod.Status.ContainerStatuses {
|
||||
if !status.Ready {
|
||||
allReady = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allReady {
|
||||
return "Ready", nil
|
||||
}
|
||||
}
|
||||
|
||||
// Проверяем состояние контейнеров (CrashLoopBackOff, ImagePullBackOff и т.д.)
|
||||
for _, status := range pod.Status.ContainerStatuses {
|
||||
if status.State.Waiting != nil && status.State.Waiting.Reason == "CrashLoopBackOff" {
|
||||
return "Error", nil
|
||||
}
|
||||
if status.State.Waiting != nil && status.State.Waiting.Reason == "ImagePullBackOff" {
|
||||
return "Error", nil
|
||||
}
|
||||
if status.LastTerminationState.Terminated != nil && status.LastTerminationState.Terminated.ExitCode != 0 {
|
||||
return "Error", nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pod есть, но не в Running или Error состоянии → Building/Deploying
|
||||
return "Building", nil
|
||||
}
|
||||
|
||||
// Нет pod-ов → Cold (функция холодная, но готова)
|
||||
return "Cold", nil
|
||||
}
|
||||
|
||||
// handleListFunctionsWithStatus возвращает список функций с полем "status".
|
||||
// Добавляет status в поле .metadata.annotations["fission-console/status"] каждой функции.
|
||||
func (s *Server) handleListFunctionsWithStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) // 30 сек для определения статуса всех функций
|
||||
defer cancel()
|
||||
ns := s.userNS(r)
|
||||
|
||||
// Получаем список функций
|
||||
list, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("list functions: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Обогащаем каждую функцию статусом (добавляем в .metadata.annotations["fission-console/status"])
|
||||
for i := range list.Items {
|
||||
status, _ := s.getFunctionStatus(ctx, ns, &list.Items[i])
|
||||
anns := list.Items[i].GetAnnotations()
|
||||
if anns == nil {
|
||||
anns = make(map[string]string)
|
||||
}
|
||||
anns["fission-console/status"] = status
|
||||
list.Items[i].SetAnnotations(anns)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, list.Items)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// Package api — работа с Fission storagesvc (S3-совместимое хранилище архивов).
|
||||
//
|
||||
// Этот файл содержит низкоуровневые операции загрузки и удаления архивов через
|
||||
// HTTP API storagesvc. Storagesvc хранит zip-архивы функций в S3 (bucket sless-functions).
|
||||
// Package CRD ссылается на архив через spec.deployment.url (type: url).
|
||||
//
|
||||
// Используется из function_code.go и function_archive.go для сохранения/замены кода функций.
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// deleteFromStoragesvc удаляет архив из S3 через storagesvc по URL из Package spec.
|
||||
// URL имеет формат: http://storagesvc.../v1/archive?id=fission/UUID
|
||||
// Best-effort: ошибка логируется, но не прерывает операцию удаления.
|
||||
func (s *Server) deleteFromStoragesvc(ctx context.Context, archiveURL string) {
|
||||
if s.storagesvcURL == "" || archiveURL == "" {
|
||||
return
|
||||
}
|
||||
// archiveURL = "http://storagesvc.../v1/archive?id=fission/UUID"
|
||||
// Строим DELETE URL к storagesvc, сохраняя query-параметр id
|
||||
parsed, err := url.Parse(archiveURL)
|
||||
if err != nil {
|
||||
log.Printf("deleteFromStoragesvc: parse url %q: %v", archiveURL, err)
|
||||
return
|
||||
}
|
||||
deleteURL := strings.TrimRight(s.storagesvcURL, "/") + "/v1/archive?" + parsed.RawQuery
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, deleteURL, nil)
|
||||
if err != nil {
|
||||
log.Printf("deleteFromStoragesvc: build request: %v", err)
|
||||
return
|
||||
}
|
||||
resp, err := s.http.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("deleteFromStoragesvc: %v", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
log.Printf("deleteFromStoragesvc: status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
return
|
||||
}
|
||||
log.Printf("deleteFromStoragesvc: deleted %s", parsed.Query().Get("id"))
|
||||
}
|
||||
|
||||
// uploadToStoragesvc загружает байты в Fission storagesvc и возвращает URL для package archive.
|
||||
// Если storagesvcURL не задан — возвращает пустую строку (fallback на literal).
|
||||
func (s *Server) uploadToStoragesvc(ctx context.Context, data []byte) (string, error) {
|
||||
if s.storagesvcURL == "" {
|
||||
log.Printf("uploadToStoragesvc: storagesvcURL is empty, skip upload")
|
||||
return "", nil
|
||||
}
|
||||
log.Printf("uploadToStoragesvc: uploading %d bytes to %s", len(data), s.storagesvcURL)
|
||||
uploadURL := strings.TrimRight(s.storagesvcURL, "/") + "/v1/archive"
|
||||
body := &bytes.Reader{}
|
||||
// multipart/form-data с полем uploadfile
|
||||
var buf bytes.Buffer
|
||||
boundary := fmt.Sprintf("fission%d", time.Now().UnixNano())
|
||||
buf.WriteString("--" + boundary + "\r\n")
|
||||
buf.WriteString(fmt.Sprintf("Content-Disposition: form-data; name=\"uploadfile\"; filename=\"archive.zip\"\r\n"))
|
||||
buf.WriteString("Content-Type: application/octet-stream\r\n\r\n")
|
||||
buf.Write(data)
|
||||
buf.WriteString("\r\n--" + boundary + "--\r\n")
|
||||
body = bytes.NewReader(buf.Bytes())
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("build storagesvc upload request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "multipart/form-data; boundary="+boundary)
|
||||
req.Header.Set("X-File-Size", fmt.Sprintf("%d", len(data)))
|
||||
|
||||
resp, err := s.http.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("storagesvc upload: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
return "", fmt.Errorf("storagesvc upload status %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
|
||||
}
|
||||
var result struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &result); err != nil || result.ID == "" {
|
||||
return "", fmt.Errorf("storagesvc upload: bad response: %s", string(respBody))
|
||||
}
|
||||
archiveURL := strings.TrimRight(s.storagesvcURL, "/") + "/v1/archive?id=" + result.ID
|
||||
return archiveURL, nil
|
||||
}
|
||||
|
||||
// buildDeploySpec строит spec.deployment для Fission Package.
|
||||
// Если storagesvcURL задан — загружает архив в S3 через storagesvc и возвращает type: url.
|
||||
// Иначе — возвращает type: literal с base64-кодом.
|
||||
func (s *Server) buildDeploySpec(ctx context.Context, data []byte) (map[string]any, error) {
|
||||
archiveURL, err := s.uploadToStoragesvc(ctx, data)
|
||||
if err != nil {
|
||||
log.Printf("storagesvc upload failed, falling back to literal: %v", err)
|
||||
// fallback — сохраняем как literal
|
||||
return map[string]any{"type": "literal", "literal": base64.StdEncoding.EncodeToString(data)}, nil
|
||||
}
|
||||
if archiveURL == "" {
|
||||
return map[string]any{"type": "literal", "literal": base64.StdEncoding.EncodeToString(data)}, nil
|
||||
}
|
||||
return map[string]any{"type": "url", "url": archiveURL}, nil
|
||||
}
|
||||
@@ -0,0 +1,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,
|
||||
},
|
||||
}}
|
||||
}
|
||||
Reference in New Issue
Block a user