Compare commits

...
Author SHA1 Message Date
“Naeel” e128bdc28f chore: sync все локальные изменения (auto, 2026-05-04) 2026-05-04 11:55:49 +04:00
“Naeel” 25816395d3 test: полный CLI-совместимость кастомного fission-bundle (2026-05-04) [auto-report] 2026-05-04 11:53:19 +04:00
“Naeel” 78f72c593f doc: add full function lifecycle description (S3, lint, env, runtime) 2026-05-04 11:13:27 +04:00
“Naeel” 26247ea137 v1.3.56: use naeel/fission-python-env:v1.0 for new Python environments 2026-05-04 11:06:33 +04:00
“Naeel” d09348b6b9 v1.3.55: validate zip magic bytes on upload (UI + backend) 2026-05-04 10:57:32 +04:00
“Naeel” 4980eb4efa fix: форматирование lint_archive.go 2026-05-04 09:30:21 +04:00
“Naeel” 6ded699935 v1.3.54: лintер проверяет соответствие языка файлам в архиве
Если выбран Node.js но в архиве .py — сразу ошибка.
Фронтенд передаёт выбранный язык (ca-lang / e-lang-hidden) в запрос лintера.
2026-05-04 09:28:21 +04:00
“Naeel” 353cc0f5b1 v1.3.53: лintер проверяет entrypoint — файл и функция должны существовать в архиве
Entrypoint формат module.function: лintер ищет module.py в архиве
и проверяет что def function(...) определена внутри.
Если нет — сразу ошибка, не нужно ждать таймаута вызова.
Фронтенд передаёт entrypoint из поля e-entry / ca-entry.
2026-05-04 09:21:31 +04:00
“Naeel” 06389a425d test: aa1 def main(event, context) из архива — 200 OK
naeel/fission-python-env:v1.0 с патчем inspect.signature работает.
Любая сигнатура main() поддерживается без изменений кода пользователя:
- def main() -> main()
- def main(event, context) -> main(request, {})
- def main(request) -> main(request)
2026-05-04 09:13:48 +04:00
“Naeel” db7862b8bd fix: console-python-env не поддерживает def main(event, context) — TypeError при вызове архивных функций
Проблема: ghcr.io/fission/python-env v3 вызывает userfunc() без аргументов.
Пользователи пишут def main(event, context) (AWS Lambda стиль) — получают:
TypeError: main() missing 2 required positional arguments: 'event' and 'context'

Это НЕ ошибка пользователя. Fission просто не реализует Lambda-конвенцию.
demo.py: временно исправлен на event=None, context=None.

План: собрать naeel/console-python-env с патчем inspect.signature
чтобы любая сигнатура main() работала автоматически.
Подробнее: doc/plans/console-python-env-fix-2026-05-04.md
2026-05-04 09:03:51 +04:00
“Naeel” bb9f158bac v1.3.52: fix archive name display — remove package name fallback 2026-05-04 08:27:27 +04:00
“Naeel” 36f83892aa v1.3.51: remove invoke_url from response, unlock entrypoint field in edit modal 2026-05-04 08:22:12 +04:00
“Naeel” 2fdba29f79 v1.3.50: fix archive fn — entrypoint update in edit, store/show archive filename 2026-05-04 08:14:19 +04:00
“Naeel” f97fd8a744 v1.3.49: edit modal — remove mode toggle, show archive name, timeout-only save; add PUT /functions/:name/timeout 2026-05-04 07:03:43 +04:00
“Naeel” 6ac22d5f55 v1.3.48: remove internal invoke_url from invoke modal output 2026-05-04 06:59:25 +04:00
“Naeel” 4f6b4b87be v1.3.47: fix InvokeStrategy in archive handler, modal error display, draggable modals 2026-05-04 06:54:18 +04:00
“Naeel” 1cceb42f83 v1.3.46: separate create modals — code (cc-) and archive (ca-) as independent modules 2026-05-04 06:46:45 +04:00
38 changed files with 1607 additions and 163 deletions
+1 -1
View File
@@ -52,7 +52,7 @@ spec:
serviceAccountName: fission-console
containers:
- name: console
image: naeel/fission-console:v1.3.45
image: naeel/fission-console:v1.3.56
imagePullPolicy: Always
ports:
- containerPort: 8090
+97 -18
View File
@@ -310,6 +310,12 @@ func (s *Server) handleFunctionsAction(w http.ResponseWriter, r *http.Request) {
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)
@@ -612,26 +618,31 @@ func (s *Server) handleGetFunction(w http.ResponseWriter, r *http.Request, name
// Читаем source-type аннотацию (code / archive)
sourceType := "code"
archiveFilename := ""
if ann := fn.GetAnnotations(); ann != nil {
if v := ann[fissionSourceTypeAnnotation]; v != "" {
sourceType = v
}
if v := ann["fission-console/archive-filename"]; v != "" {
archiveFilename = v
}
}
writeAnyJSON(w, http.StatusOK, map[string]any{
"name": name,
"namespace": ns,
"environment": environment,
"package": packageName,
"entrypoint": entrypoint,
"timeout": functionTimeout,
"created_at": functionTimestampResponse(fn)["created_at"],
"updated_at": functionTimestampResponse(fn)["updated_at"],
"code": code,
"source_type": sourceType,
"route": route,
"methods": methods,
"raw": fn.Object,
"name": name,
"namespace": ns,
"environment": environment,
"package": packageName,
"entrypoint": entrypoint,
"timeout": functionTimeout,
"created_at": functionTimestampResponse(fn)["created_at"],
"updated_at": functionTimestampResponse(fn)["updated_at"],
"code": code,
"source_type": sourceType,
"archive_filename": archiveFilename,
"route": route,
"methods": methods,
"raw": fn.Object,
})
}
@@ -760,6 +771,49 @@ func (s *Server) handleUpdateFunctionCode(w http.ResponseWriter, r *http.Request
})
}
// 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})
}
// handleInvokeFunction вызывает функцию через Fission router.
// Определяет реальный URL из HTTPTrigger, выбирает метод (POST/GET).
func (s *Server) handleInvokeFunction(w http.ResponseWriter, r *http.Request, name string) {
@@ -865,7 +919,6 @@ func (s *Server) handleInvokeFunction(w http.ResponseWriter, r *http.Request, na
writeAnyJSON(w, http.StatusOK, map[string]any{
"status": resp.StatusCode,
"latency_ms": time.Since(start).Milliseconds(),
"invoke_url": invokeURL,
"response_raw": string(respBody),
})
}
@@ -980,7 +1033,6 @@ func (s *Server) invokeInternalFunction(w http.ResponseWriter, r *http.Request,
writeAnyJSON(w, http.StatusOK, map[string]any{
"status": resp.StatusCode,
"latency_ms": time.Since(start).Milliseconds(),
"invoke_url": invokeURL,
"response_raw": string(respBody),
})
}
@@ -1265,7 +1317,7 @@ func (s *Server) handleCreateFunctionFromArchive(w http.ResponseWriter, r *http.
lang := strings.TrimSpace(r.FormValue("language"))
envName := strings.TrimSpace(r.FormValue("environment"))
f, _, err := r.FormFile("archive")
f, fhCreate, err := r.FormFile("archive")
if err != nil {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("archive file required: %v", err))
return
@@ -1276,6 +1328,16 @@ func (s *Server) handleCreateFunctionFromArchive(w http.ResponseWriter, r *http.
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()
@@ -1362,6 +1424,9 @@ func (s *Server) handleCreateFunctionFromArchive(w http.ResponseWriter, r *http.
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)
@@ -1378,8 +1443,12 @@ func (s *Server) handleCreateFunctionFromArchive(w http.ResponseWriter, r *http.
"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}},
"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,
},
}}
@@ -1497,12 +1566,22 @@ func (s *Server) handleUpdateFunctionArchive(w http.ResponseWriter, r *http.Requ
}
_ = 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{
+97
View File
@@ -58,6 +58,11 @@ func (s *Server) handleLintArchive(w http.ResponseWriter, r *http.Request) {
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' обязательно")
@@ -196,6 +201,98 @@ func (s *Server) handleLintArchive(w http.ResponseWriter, r *http.Request) {
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,
+1 -1
View File
@@ -44,7 +44,7 @@ type LangEnvDef struct {
// LangEnvMap сопоставляет идентификатор языка (string) с описанием среды выполнения.
// Ключ используется в createFunctionRequest.Language и как суффикс имени Environment.
var LangEnvMap = map[string]LangEnvDef{
"python": {Image: "ghcr.io/fission/python-env"},
"python": {Image: "naeel/fission-python-env:v1.0"},
"nodejs": {Image: "ghcr.io/fission/node-env"},
"go": {Image: "ghcr.io/fission/go-env", BuilderImage: "naeel/go-builder-fast:v1"},
"php": {Image: "ghcr.io/fission/php-env"},
+12
View File
@@ -318,3 +318,15 @@ textarea {
white-space: pre-wrap;
word-break: break-word;
}
.modal-error {
display: none;
margin-top: 10px;
padding: 8px 12px;
border-radius: 6px;
background: #3a1a1a;
color: #f88;
font-size: 13px;
white-space: pre-wrap;
word-break: break-word;
}
+107 -45
View File
@@ -21,6 +21,8 @@
<script src="js/modals.js"></script>
<script src="js/init.js"></script>
<script src="js/functions.js"></script>
<script src="js/fn-code.js"></script>
<script src="js/fn-archive.js"></script>
<script src="js/ai.js"></script>
<script src="js/app.js"></script>
</head>
@@ -100,11 +102,12 @@
<div class="nubes">NUBES</div>
<div class="product">FISSION CONSOLE</div>
</div>
<div style="font-size:0.65rem; color:var(--text-secondary); margin-left:10px; align-self:center; opacity:0.7;">v1.3.45</div>
<div style="font-size:0.65rem; color:var(--text-secondary); margin-left:10px; align-self:center; opacity:0.7;">v1.3.56</div>
</div>
<div class="row" style="margin:0;">
<button class="btn ghost" onclick="reloadAll()">Refresh</button>
<button class="btn" onclick="openCreate()">+ Создать функцию</button>
<button class="btn" onclick="openCreateCode()">✏️ Из кода</button>
<button class="btn" onclick="openCreateArchive()">📦 Из архива</button>
<button class="btn ghost" onclick="openHelp()">Help</button>
<button class="btn ghost" onclick="doLogout()" style="margin-left:8px;">Выход</button>
</div>
@@ -160,17 +163,18 @@
</div>
</div>
<div id="create-modal" class="modal">
<!-- Модалка: создать функцию из кода (prefix cc-) -->
<div id="create-code-modal" class="modal">
<div class="panel">
<h3>Создать функцию</h3>
<h3>✏️ Создать функцию из кода</h3>
<div class="row">
<div class="field">
<label>Name</label>
<input id="c-name" placeholder="demo-fn">
<input id="cc-name" placeholder="demo-fn">
</div>
<div class="field">
<label>Language</label>
<select id="c-lang" onchange="onLangChange()">
<select id="cc-lang" onchange="onLangChangeCode()">
<option value="python">Python</option>
<option value="nodejs">Node.js</option>
<option value="php">PHP</option>
@@ -179,73 +183,131 @@
</div>
<div class="field">
<label>Entrypoint</label>
<input id="c-entry" value="main.main">
<input id="cc-entry" value="main.main">
</div>
</div>
<div class="row">
<div class="field">
<label>Route</label>
<input id="c-route" placeholder="/demo-fn">
<input id="cc-route" placeholder="/demo-fn">
</div>
<div class="field">
<label>Методы (через запятую)</label>
<input id="c-methods" value="GET">
<input id="cc-methods" value="GET">
</div>
<div class="field">
<label>Timeout (сек)</label>
<input id="c-timeout" type="number" min="1" value="60">
<input id="cc-timeout" type="number" min="1" value="60">
</div>
</div>
<div class="row">
<div class="field" style="min-width:240px; flex:0 0 260px;">
<label style="display:flex; align-items:center; gap:8px; color:var(--text-primary); margin-top:18px;">
<input id="c-schedule-enabled" type="checkbox" onchange="toggleScheduleFields('c')" style="width:auto;">
<input id="cc-schedule-enabled" type="checkbox" onchange="toggleScheduleFields('cc')" style="width:auto;">
Выполнять по расписанию
</label>
</div>
<div class="field">
<label>Cron</label>
<input id="c-cron" placeholder="*/5 * * * *" disabled>
<input id="cc-cron" placeholder="*/5 * * * *" disabled>
</div>
</div>
<div>
<div style="display:flex; gap:4px; margin-bottom:6px;">
<button class="btn ghost" id="c-mode-code" onclick="setCodeMode('c','code')" style="font-size:.8rem; padding:3px 10px;">✏️ Код</button>
<button class="btn ghost" id="c-mode-archive" onclick="setCodeMode('c','archive')" style="font-size:.8rem; padding:3px 10px;">📦 Архив (.zip)</button>
</div>
<div id="c-code-area">
<textarea id="c-code">def main():
<div id="cc-code-area">
<textarea id="cc-code">def main():
return {"ok": True, "msg": "hello from fission console"}
</textarea>
<div style="margin-top:6px; display:flex; gap:6px; flex-wrap:wrap;">
<button class="btn ghost" id="c-ai-btn" onclick="aiCheck('c-code','c-lang','c-ai-result')">&#x1F50D; Проверить
синтаксис линтером</button>
<button class="btn ghost" id="c-gen-btn" onclick="showGenPrompt()">&#x2728; Сгенерировать код LLM</button>
<button class="btn ghost" id="c-exp-btn" onclick="aiExplain('c-code','c-lang','c-ai-result')">&#x1F4D6; LLM:
Что делает?</button>
<button class="btn ghost" id="cc-ai-btn" onclick="aiCheck('cc-code','cc-lang','cc-ai-result')">&#x1F50D; Проверить синтаксис линтером</button>
<button class="btn ghost" id="cc-gen-btn" onclick="showGenPrompt('cc')">&#x2728; Сгенерировать код LLM</button>
<button class="btn ghost" id="cc-exp-btn" onclick="aiExplain('cc-code','cc-lang','cc-ai-result')">&#x1F4D6; LLM: Что делает?</button>
</div>
</div>
<div id="c-archive-area" style="display:none;">
<input type="file" id="c-archive-file" accept=".zip" style="display:block; margin-bottom:8px; color:var(--text-primary);">
<div style="display:flex; gap:6px; flex-wrap:wrap;">
<button id="c-lint-btn" class="btn ghost" onclick="lintArchiveFile('c')">&#x1F50D; Проверка архива линтером</button>
<button id="c-explain-archive-btn" class="btn ghost" onclick="explainArchiveFile('c')">&#x1F4D6; LLM: Что делает?</button>
</div>
<div id="cc-gen-prompt" style="display:none; margin-top:8px; gap:6px; align-items:center;">
<input id="cc-gen-desc" type="text"
placeholder="Что должна делать функция? (напр: принять JSON, вернуть сумму чисел)"
style="flex:1; background:#2a2a42; border:1px solid #3a3a5c; border-radius:4px; color:#cdd6f4; padding:6px 8px; font-size:.82rem; outline:none; width:100%;"
onkeydown="if(event.key==='Enter')aiGenerate('cc')" />
<button class="btn" onclick="aiGenerate('cc')" style="white-space:nowrap;">▶ Генерировать LLM</button>
</div>
<div id="c-gen-prompt" style="display:none; margin-top:8px; display:none; gap:6px; align-items:center;">
<input id="c-gen-desc" type="text"
placeholder="Что должна делать функция? (напр: принять JSON, вернуть сумму чисел)" style="flex:1; background:#2a2a42; border:1px solid #3a3a5c; border-radius:4px;
color:#cdd6f4; padding:6px 8px; font-size:.82rem; outline:none; width:100%;"
onkeydown="if(event.key==='Enter')aiGenerate()" />
<button class="btn" onclick="aiGenerate()" style="white-space:nowrap;">▶ Генерировать LLM</button>
</div>
<div id="c-ai-result"
<div id="cc-ai-result"
style="display:none;margin-top:8px;padding:10px 12px;border-radius:6px;font-size:13px;line-height:1.5;white-space:pre-wrap;font-family:monospace;">
</div>
</div>
<div id="cc-error" class="modal-error"></div>
<div class="actions">
<button class="btn ghost" onclick="closeCreate()">Отмена</button>
<button id="c-submit" class="btn" onclick="submitCreate()">Создать</button>
<button class="btn ghost" onclick="closeCreateCode()">Отмена</button>
<button id="cc-submit" class="btn" onclick="submitCreateCode()">Создать</button>
</div>
</div>
</div>
<!-- Модалка: создать функцию из архива (prefix ca-) -->
<div id="create-archive-modal" class="modal">
<div class="panel">
<h3>📦 Создать функцию из архива</h3>
<div class="row">
<div class="field">
<label>Name</label>
<input id="ca-name" placeholder="demo-fn">
</div>
<div class="field">
<label>Language</label>
<select id="ca-lang" onchange="onLangChangeArchive()">
<option value="python">Python</option>
<option value="nodejs">Node.js</option>
<option value="php">PHP</option>
<option value="ruby">Ruby</option>
</select>
</div>
<div class="field">
<label>Entrypoint</label>
<input id="ca-entry" value="main.main">
</div>
</div>
<div class="row">
<div class="field">
<label>Route</label>
<input id="ca-route" placeholder="/demo-fn">
</div>
<div class="field">
<label>Методы (через запятую)</label>
<input id="ca-methods" value="GET">
</div>
<div class="field">
<label>Timeout (сек)</label>
<input id="ca-timeout" type="number" min="1" value="60">
</div>
</div>
<div class="row">
<div class="field" style="min-width:240px; flex:0 0 260px;">
<label style="display:flex; align-items:center; gap:8px; color:var(--text-primary); margin-top:18px;">
<input id="ca-schedule-enabled" type="checkbox" onchange="toggleScheduleFields('ca')" style="width:auto;">
Выполнять по расписанию
</label>
</div>
<div class="field">
<label>Cron</label>
<input id="ca-cron" placeholder="*/5 * * * *" disabled>
</div>
</div>
<div>
<div id="ca-archive-area">
<label>Архив (.zip)</label>
<input type="file" id="ca-archive-file" accept=".zip" style="display:block; margin-bottom:8px; color:var(--text-primary);">
<div style="display:flex; gap:6px; flex-wrap:wrap;">
<button id="ca-lint-btn" class="btn ghost" onclick="lintArchiveFile('ca')">&#x1F50D; Проверка архива линтером</button>
<button id="ca-explain-archive-btn" class="btn ghost" onclick="explainArchiveFile('ca')">&#x1F4D6; LLM: Что делает?</button>
</div>
</div>
<div id="ca-ai-result"
style="display:none;margin-top:8px;padding:10px 12px;border-radius:6px;font-size:13px;line-height:1.5;white-space:pre-wrap;font-family:monospace;">
</div>
</div>
<div id="ca-error" class="modal-error"></div>
<div class="actions">
<button class="btn ghost" onclick="closeCreateArchive()">Отмена</button>
<button id="ca-submit" class="btn" onclick="submitCreateArchive()">Создать</button>
</div>
</div>
</div>
@@ -270,7 +332,7 @@
</div>
<div class="field">
<label>Entrypoint</label>
<input id="e-entry" disabled>
<input id="e-entry">
</div>
<div class="field">
<label>Timeout (сек)</label>
@@ -290,10 +352,6 @@
</div>
</div>
<div>
<div style="display:flex; gap:4px; margin-bottom:6px;">
<button class="btn ghost" id="e-mode-code" onclick="setCodeMode('e','code')" style="font-size:.8rem; padding:3px 10px;">✏️ Код</button>
<button class="btn ghost" id="e-mode-archive" onclick="setCodeMode('e','archive')" style="font-size:.8rem; padding:3px 10px;">📦 Архив (.zip)</button>
</div>
<div id="e-code-area">
<label>Код</label>
<textarea id="e-code"></textarea>
@@ -305,6 +363,10 @@
</div>
</div>
<div id="e-archive-area" style="display:none;">
<div id="e-archive-current" style="margin-bottom:10px; padding:8px 12px; background:var(--bg-alt); border-radius:6px; font-size:13px; color:var(--text-secondary);">
📦 <span id="e-archive-name"></span>
</div>
<label style="font-size:12px; color:var(--text-secondary); margin-bottom:4px; display:block;">Заменить архив (необязательно)</label>
<input type="file" id="e-archive-file" accept=".zip" style="display:block; margin-bottom:8px; color:var(--text-primary);">
<div style="display:flex; gap:6px; flex-wrap:wrap;">
<button id="e-lint-btn" class="btn ghost" onclick="lintArchiveFile('e')">&#x1F50D; Проверка архива линтером</button>
@@ -400,7 +462,7 @@
</div>
<div class="actions" style="justify-content:space-between; align-items:center;">
<span style="font-size:0.75rem; color:var(--text-secondary);">v1.3.45</span>
<span style="font-size:0.75rem; color:var(--text-secondary);">v1.3.56</span>
<button class="btn ghost" onclick="closeHelp()">Закрыть</button>
</div>
</div>
+14 -12
View File
@@ -86,19 +86,21 @@ async function askAssistant() {
msgs.scrollTop = msgs.scrollHeight;
}
function showGenPrompt() {
var el = document.getElementById('c-gen-prompt');
function showGenPrompt(prefix) {
prefix = prefix || 'cc';
var el = document.getElementById(prefix + '-gen-prompt');
el.style.display = 'flex';
document.getElementById('c-gen-desc').focus();
document.getElementById(prefix + '-gen-desc').focus();
}
async function aiGenerate() {
var name = (document.getElementById('c-name').value || '').trim() || 'my-func';
var lang = document.getElementById('c-lang').value || 'python';
var desc = (document.getElementById('c-gen-desc').value || '').trim();
if (!desc) { document.getElementById('c-gen-desc').focus(); return; }
var btn = document.getElementById('c-gen-btn');
var resEl = document.getElementById('c-ai-result');
async function aiGenerate(prefix) {
prefix = prefix || 'cc';
var name = (document.getElementById(prefix + '-name').value || '').trim() || 'my-func';
var lang = document.getElementById(prefix + '-lang').value || 'python';
var desc = (document.getElementById(prefix + '-gen-desc').value || '').trim();
if (!desc) { document.getElementById(prefix + '-gen-desc').focus(); return; }
var btn = document.getElementById(prefix + '-gen-btn');
var resEl = document.getElementById(prefix + '-ai-result');
btn.disabled = true; btn.textContent = '⏳ Генерирую...';
resEl.style.display = 'block'; resEl.style.background = 'var(--bg-alt)'; resEl.style.color = 'var(--fg)';
resEl.textContent = 'Запрашиваю у LLM...';
@@ -110,8 +112,8 @@ async function aiGenerate() {
description: desc
});
var code = (d.answer || '').replace(/^```[\w]*\n?/, '').replace(/\n?```$/, '');
document.getElementById('c-code').value = addLLMWarning(code, lang);
document.getElementById('c-gen-prompt').style.display = 'none';
document.getElementById(prefix + '-code').value = addLLMWarning(code, lang);
document.getElementById(prefix + '-gen-prompt').style.display = 'none';
resEl.style.background = '#1a3a1a'; resEl.style.color = '#8f8';
resEl.textContent = 'Код сгенерирован и вставлен.';
} catch (e) {
+86
View File
@@ -0,0 +1,86 @@
/* fn-archive.js — создание функции из архива (независимый модуль) */
function onLangChangeArchive() {
var lang = document.getElementById('ca-lang').value;
var t = LANG_TEMPLATES[lang];
if (t) {
document.getElementById('ca-entry').value = t.entrypoint;
}
}
function openCreateArchive() {
document.getElementById('ca-lang').value = 'python';
onLangChangeArchive();
document.getElementById('ca-name').value = '';
document.getElementById('ca-route').value = '';
document.getElementById('ca-methods').value = 'GET';
document.getElementById('ca-timeout').value = '60';
document.getElementById('ca-schedule-enabled').checked = false;
document.getElementById('ca-cron').value = '';
toggleScheduleFields('ca');
var fileInput = document.getElementById('ca-archive-file');
if (fileInput) fileInput.value = '';
var aiRes = document.getElementById('ca-ai-result');
if (aiRes) { aiRes.style.display = 'none'; aiRes.textContent = ''; }
hideModalError('ca-error');
document.getElementById('create-archive-modal').classList.add('open');
document.getElementById('ca-name').focus();
}
function closeCreateArchive() {
document.getElementById('create-archive-modal').classList.remove('open');
}
async function submitCreateArchive() {
const btn = document.getElementById('ca-submit');
btn.disabled = true;
const progress = startTimedStatus('Создаём функцию...', 'Создание функции...', explainDelay);
try {
const name = document.getElementById('ca-name').value.trim();
if (!name) throw new Error('name is required');
const lang = document.getElementById('ca-lang').value.trim();
if (!lang) throw new Error('language is required');
const archiveFile = document.getElementById('ca-archive-file').files[0];
if (!archiveFile) throw new Error('выберите .zip архив');
if (!archiveFile.name.toLowerCase().endsWith('.zip')) throw new Error('файл должен быть .zip архивом, не .' + archiveFile.name.split('.').pop());
var fd = new FormData();
fd.append('name', name);
fd.append('language', lang);
fd.append('entrypoint', document.getElementById('ca-entry').value.trim());
fd.append('route', document.getElementById('ca-route').value.trim());
fd.append('methods', document.getElementById('ca-methods').value.trim());
fd.append('timeout', String(parseTimeout(document.getElementById('ca-timeout').value)));
fd.append('archive', archiveFile);
var resp = await fetch(API_BASE + '/functions', {
method: 'POST',
headers: authHeaders(),
body: fd
});
if (!resp.ok) {
var e = await resp.json().catch(() => ({}));
throw new Error(e.error || resp.statusText);
}
try {
await syncScheduleForFunction(name, 'ca');
} catch (scheduleErr) {
try {
await requestJSON(API_BASE + '/functions/' + encodeURIComponent(name), 'DELETE');
} catch (rollbackErr) {
scheduleErr.message += '; rollback failed: ' + rollbackErr.message;
}
throw scheduleErr;
}
closeCreateArchive();
progress.stop('Функция создана: ' + name, 'ok');
await reloadAll();
} catch (e) {
progress.stop('Ошибка создания: ' + e.message, 'err');
showModalError('ca-error', 'Ошибка: ' + e.message);
} finally {
btn.disabled = false;
}
}
+73
View File
@@ -0,0 +1,73 @@
/* fn-code.js — создание функции из кода (независимый модуль) */
function onLangChangeCode() {
var lang = document.getElementById('cc-lang').value;
var t = LANG_TEMPLATES[lang];
if (t) {
document.getElementById('cc-entry').value = t.entrypoint;
document.getElementById('cc-code').value = t.code;
}
}
function openCreateCode() {
document.getElementById('cc-lang').value = 'python';
onLangChangeCode();
document.getElementById('cc-name').value = '';
document.getElementById('cc-route').value = '';
document.getElementById('cc-methods').value = 'GET';
document.getElementById('cc-timeout').value = '60';
document.getElementById('cc-schedule-enabled').checked = false;
document.getElementById('cc-cron').value = '';
toggleScheduleFields('cc');
var aiRes = document.getElementById('cc-ai-result');
if (aiRes) { aiRes.style.display = 'none'; aiRes.textContent = ''; }
hideModalError('cc-error');
document.getElementById('create-code-modal').classList.add('open');
document.getElementById('cc-name').focus();
}
function closeCreateCode() {
document.getElementById('create-code-modal').classList.remove('open');
}
async function submitCreateCode() {
const btn = document.getElementById('cc-submit');
btn.disabled = true;
const progress = startTimedStatus('Создаём функцию...', 'Создание функции...', explainDelay);
try {
const name = document.getElementById('cc-name').value.trim();
if (!name) throw new Error('name is required');
const lang = document.getElementById('cc-lang').value.trim();
if (!lang) throw new Error('language is required');
await requestJSON(API_BASE + '/functions', 'POST', {
name: name,
language: lang,
entrypoint: document.getElementById('cc-entry').value.trim(),
route: document.getElementById('cc-route').value.trim(),
methods: parseMethods(document.getElementById('cc-methods').value),
timeout: parseTimeout(document.getElementById('cc-timeout').value),
code: document.getElementById('cc-code').value
});
try {
await syncScheduleForFunction(name, 'cc');
} catch (scheduleErr) {
try {
await requestJSON(API_BASE + '/functions/' + encodeURIComponent(name), 'DELETE');
} catch (rollbackErr) {
scheduleErr.message += '; rollback failed: ' + rollbackErr.message;
}
throw scheduleErr;
}
closeCreateCode();
progress.stop('Функция создана: ' + name, 'ok');
await reloadAll();
} catch (e) {
progress.stop('Ошибка создания: ' + e.message, 'err');
showModalError('cc-error', 'Ошибка: ' + e.message);
} finally {
btn.disabled = false;
}
}
+22 -83
View File
@@ -44,6 +44,16 @@ function lintArchiveFile(prefix) {
showRes('Запускаю линтер…', 'var(--bg-alt)', 'var(--fg)');
var fd = new FormData();
fd.append('archive', file);
// Передаём entrypoint для проверки наличия файла и функции в архиве
var entryEl = document.getElementById(prefix + '-entry');
if (entryEl && entryEl.value.trim()) {
fd.append('entrypoint', entryEl.value.trim());
}
// Передаём выбранный язык для проверки соответствия файлов
var langEl = document.getElementById(prefix + '-lang');
if (langEl && langEl.value) {
fd.append('language', langEl.value);
}
fetch(API_BASE + '/ai/lint-archive', {
method: 'POST',
headers: authHeaders(),
@@ -133,89 +143,7 @@ function parseTimeout(v) {
return Math.round(n);
}
function onLangChange() {
var lang = document.getElementById('c-lang').value;
var t = LANG_TEMPLATES[lang];
if (t) {
document.getElementById('c-entry').value = t.entrypoint;
document.getElementById('c-code').value = t.code;
}
}
function openCreate() {
document.getElementById('c-lang').value = 'python';
onLangChange();
document.getElementById('c-timeout').value = '60';
document.getElementById('c-schedule-enabled').checked = false;
document.getElementById('c-cron').value = '';
toggleScheduleFields('c');
document.getElementById('create-modal').classList.add('open');
document.getElementById('c-name').focus();
}
function closeCreate() {
document.getElementById('create-modal').classList.remove('open');
}
async function submitCreate() {
const btn = document.getElementById('c-submit');
btn.disabled = true;
const progress = startTimedStatus('Создаём функцию...', 'Создание функции...', explainDelay);
try {
const name = document.getElementById('c-name').value.trim();
if (!name) throw new Error('name is required');
const lang = document.getElementById('c-lang').value.trim();
if (!lang) throw new Error('language is required');
// Определяем режим: archiveArea видна → archive mode
var archiveArea = document.getElementById('c-archive-area');
var isArchiveMode = archiveArea && archiveArea.style.display !== 'none';
var archiveFile = isArchiveMode ? document.getElementById('c-archive-file').files[0] : null;
if (isArchiveMode && archiveFile) {
// Отправляем multipart/form-data с архивом
var fd = new FormData();
fd.append('name', name);
fd.append('language', lang);
fd.append('entrypoint', document.getElementById('c-entry').value.trim());
fd.append('route', document.getElementById('c-route').value.trim());
fd.append('methods', document.getElementById('c-methods').value.trim());
fd.append('timeout', String(parseTimeout(document.getElementById('c-timeout').value)));
fd.append('archive', archiveFile);
var resp = await fetch(API_BASE + '/functions', { method: 'POST', headers: authHeaders(), body: fd });
if (!resp.ok) { var e = await resp.json().catch(() => ({})); throw new Error(e.error || resp.statusText); }
} else {
await requestJSON(API_BASE + '/functions', 'POST', {
name: name,
language: lang,
entrypoint: document.getElementById('c-entry').value.trim(),
route: document.getElementById('c-route').value.trim(),
methods: parseMethods(document.getElementById('c-methods').value),
timeout: parseTimeout(document.getElementById('c-timeout').value),
code: document.getElementById('c-code').value
});
}
try {
await syncScheduleForFunction(name, 'c');
} catch (scheduleErr) {
try {
await requestJSON(API_BASE + '/functions/' + encodeURIComponent(name), 'DELETE');
} catch (rollbackErr) {
scheduleErr.message += '; rollback failed: ' + rollbackErr.message;
}
throw scheduleErr;
}
closeCreate();
progress.stop('Функция создана: ' + name, 'ok');
await reloadAll();
} catch (e) {
progress.stop('Ошибка создания: ' + e.message, 'err');
} finally {
btn.disabled = false;
}
}
// openCreate/closeCreate/submitCreate перенесены в fn-code.js и fn-archive.js
async function openEdit(name) {
try {
@@ -231,6 +159,10 @@ async function openEdit(name) {
// Сбрасываем режим: source_type из API (code / archive)
if (fn.source_type === 'archive') {
setCodeMode('e', 'archive');
var archiveNameEl = document.getElementById('e-archive-name');
if (archiveNameEl) archiveNameEl.textContent = fn.archive_filename || 'архив';
var archiveFile = document.getElementById('e-archive-file');
if (archiveFile) archiveFile.value = '';
} else {
setCodeMode('e', 'code');
}
@@ -283,10 +215,17 @@ async function submitEdit() {
// Обновляем через архив
var fd = new FormData();
fd.append('timeout', String(parseTimeout(document.getElementById('e-timeout').value)));
fd.append('entrypoint', document.getElementById('e-entry').value.trim());
fd.append('archive', archiveFile);
var resp = await fetch(API_BASE + '/functions/' + encodeURIComponent(name) + '/archive',
{ method: 'PUT', headers: authHeaders(), body: fd });
if (!resp.ok) { var e = await resp.json().catch(() => ({})); throw new Error(e.error || resp.statusText); }
} else if (isArchiveMode) {
// Архив не заменяется — обновляем только timeout + entrypoint
await requestJSON(API_BASE + '/functions/' + encodeURIComponent(name) + '/timeout', 'PUT', {
timeout: parseTimeout(document.getElementById('e-timeout').value),
entrypoint: document.getElementById('e-entry').value.trim()
});
} else {
await requestJSON(API_BASE + '/functions/' + encodeURIComponent(name) + '/code', 'PUT', {
code: document.getElementById('e-code').value,
+64 -2
View File
@@ -1,5 +1,68 @@
/* modals.js — управление модальными окнами Help и Invoke */
// makeDraggable — делает .panel внутри modalId перетаскиваемым за заголовок (h3).
function makeDraggable(modalId) {
var modal = document.getElementById(modalId);
if (!modal) return;
var panel = modal.querySelector('.panel');
if (!panel) return;
var handle = panel.querySelector('h3');
if (!handle) return;
handle.style.cursor = 'move';
handle.style.userSelect = 'none';
var startX, startY, startLeft, startTop;
handle.addEventListener('mousedown', function(e) {
e.preventDefault();
var rect = panel.getBoundingClientRect();
// Переводим в position:fixed если ещё не
if (!panel.style.left) {
panel.style.position = 'fixed';
panel.style.left = rect.left + 'px';
panel.style.top = rect.top + 'px';
panel.style.margin = '0';
}
startX = e.clientX;
startY = e.clientY;
startLeft = parseInt(panel.style.left, 10) || rect.left;
startTop = parseInt(panel.style.top, 10) || rect.top;
function onMove(e) {
var dx = e.clientX - startX;
var dy = e.clientY - startY;
panel.style.left = (startLeft + dx) + 'px';
panel.style.top = (startTop + dy) + 'px';
}
function onUp() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
}
// Инициализация после загрузки DOM
document.addEventListener('DOMContentLoaded', function() {
['create-code-modal', 'create-archive-modal', 'edit-modal', 'help-modal', 'invoke-modal'].forEach(makeDraggable);
});
// showModalError — показывает ошибку внутри модалки.
// errorDivId — id элемента с классом modal-error.
function showModalError(errorDivId, message) {
var el = document.getElementById(errorDivId);
if (!el) return;
el.textContent = message;
el.style.display = 'block';
}
function hideModalError(errorDivId) {
var el = document.getElementById(errorDivId);
if (el) { el.style.display = 'none'; el.textContent = ''; }
}
function openHelp() {
document.getElementById('help-modal').classList.add('open');
}
@@ -57,8 +120,7 @@ async function submitInvoke() {
metaEl.textContent = [
'HTTP status: ' + (result.status || 'n/a'),
'Latency: ' + (latencyMs || 'n/a') + ' ms',
'Причина задержки: ' + explainDelay(measuredSeconds),
'Invoke URL: ' + (result.invoke_url || 'n/a')
'Причина задержки: ' + explainDelay(measuredSeconds)
].join('\n');
respEl.value = JSON.stringify(result, null, 2);
} catch (e) {
+1 -1
View File
@@ -1,6 +1,6 @@
import requests
def main(event, context):
def main(event=None, context=None):
try:
r = requests.get('https://httpbin.org/get', timeout=3)
return {
BIN
View File
Binary file not shown.
+355
View File
@@ -0,0 +1,355 @@
# Полный жизненный цикл функции в Fission Console
Актуально: **v1.3.56**, май 2026
---
## 1. Два способа создания функции
| Способ | Content-Type | Обработчик |
|--------|-------------|------------|
| **Inline-код** (редактор) | `application/json` | `handleCreateFunction``handleCreateFunctionFromCode` |
| **Архив** (zip-файл) | `multipart/form-data` | `handleCreateFunction``handleCreateFunctionFromArchive` |
Определяется автоматически по заголовку `Content-Type` в `handleCreateFunction` (handlers.go ~строка 343).
---
## 2. Создание из inline-кода (шаг за шагом)
### 2.1 UI → Backend
**Файл:** `console/ui/js/functions.js``submitCreateFunction()`
1. Читает поля: `name`, `language`, `entrypoint`, `route`, `methods`, `timeout`, `code` (из CodeMirror)
2. Отправляет `POST /console/api/functions` с `Content-Type: application/json`
### 2.2 Backend обработка
**Файл:** `console/internal/api/handlers.go``handleCreateFunction` (строка ~343)
```
POST /console/api/functions (JSON)
Валидация имени (regex ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$, max 57 символов)
EnsureUserNS → создаёт K8s namespace если не существует
EnsureEnvironment → создаёт Fission Environment CRD если не существует
buildDeployArchive(lang, code) → упаковывает код в байты
buildDeploySpec(ctx, bytes) → uploadToStoragesvc → S3
Создаёт Fission Package CRD (type: url → S3)
Создаёт Fission Function CRD (ссылается на Package)
Создаёт Fission HTTPTrigger CRD (роут)
Аннотация fission-console/source-type = "code"
```
### 2.3 buildDeployArchive — упаковка кода
**Файл:** `console/internal/api/handlers.go``buildDeployArchive()`
| Язык | Что происходит |
|------|---------------|
| Python | код → сырые байты (*.py файл) |
| Node.js | код → zip с `package.json` + `main.js` (ESM wrapper) |
| PHP | код → zip с `main.php` |
| Ruby | код → zip с `handler.rb` |
| Go | отдельный путь: `BuildGoSourceZip` → source package → builder job |
### 2.4 Entrypoint по умолчанию
**Файл:** `console/internal/runtime/``DefaultEntrypoint(lang)`
| Язык | Entrypoint по умолчанию |
|------|------------------------|
| python | `user.main` |
| nodejs | `main` |
| go | `Handler` |
| php | `main` |
| ruby | `main` |
---
## 3. Создание из архива (шаг за шагом)
### 3.1 UI → Backend
**Файл:** `console/ui/js/fn-archive.js``submitCreateArchive()`
1. Читает поля: `name`, `language`, `entrypoint`, `route`, `methods`, `timeout`
2. Читает файл из `<input id="ca-archive-file">`
3. **Валидация на UI:** файл должен заканчиваться на `.zip` — иначе ошибка до отправки
4. Отправляет `POST /console/api/functions` с `Content-Type: multipart/form-data`
### 3.2 Backend обработка
**Файл:** `console/internal/api/handlers.go``handleCreateFunctionFromArchive()` (строка ~1302)
```
POST /console/api/functions (multipart/form-data)
ParseMultipartForm (лимит 32 MB)
Валидация имени
r.FormFile("archive") → читает байты архива
⛔ ПРОВЕРКА magic bytes: первые 2 байта должны быть 0x50 0x4B (PK = zip)
Если нет → HTTP 400 "загруженный файл не является валидным zip-архивом"
EnsureUserNS
EnsureEnvironment (по полю "language")
buildDeploySpec(ctx, archiveBytes) → uploadToStoragesvc → S3
Создаёт Fission Package CRD (type: url → S3)
Создаёт Fission Function CRD
Создаёт Fission HTTPTrigger CRD
Аннотация fission-console/source-type = "archive"
Аннотация fission-console/archive-file = <оригинальное имя файла>
```
---
## 4. Загрузка в S3 через storagesvc
**Файл:** `console/internal/api/handlers.go``uploadToStoragesvc()`
```
Входные данные: []byte (байты zip-архива)
Строим multipart/form-data вручную:
boundary = "fission" + UnixNano
поле: name="uploadfile", filename="archive.zip"
Content-Type: application/octet-stream
тело: байты архива
POST http://storagesvc.fission.svc.cluster.local/v1/archive
заголовок X-File-Size: <размер байт>
Storagesvc сохраняет в S3 (bucket: sless-functions)
Ответ: {"id": "fission/UUID"}
Возвращаем URL: http://storagesvc.../v1/archive?id=fission/UUID
```
**Если storagesvcURL не задан** → fallback на `type: literal` (base64 в etcd). Нежелательно для больших файлов.
**Переменная окружения:** `STORAGESVC_URL=http://storagesvc.fission.svc.cluster.local`
### 4.1 Удаление из S3
При удалении функции → `deleteFromStoragesvc()`:
```
DELETE http://storagesvc.../v1/archive?id=fission/UUID
```
Best-effort: ошибка логируется но не прерывает удаление Function/Package CRD.
---
## 5. Environment — КРИТИЧЕСКИ ВАЖНО
**Файл:** `console/internal/fission/environment.go``EnsureEnvironment()`
**Конфиг образов:** `console/internal/model/types.go``LangEnvMap`
### 5.1 Lazy creation
Environment создаётся **только когда пользователь создаёт первую функцию** на языке X в своём namespace. Не заранее — экономия ресурсов.
### 5.2 Текущие образы (АКТУАЛЬНО)
```go
var LangEnvMap = map[string]LangEnvDef{
"python": {Image: "naeel/fission-python-env:v1.0"}, // ⛔ НЕ МЕНЯТЬ НА ОФИЦИАЛЬНЫЙ
"nodejs": {Image: "ghcr.io/fission/node-env"},
"go": {Image: "ghcr.io/fission/go-env", BuilderImage: "naeel/go-builder-fast:v1"},
"php": {Image: "ghcr.io/fission/php-env"},
"ruby": {Image: "ghcr.io/fission/ruby-env"},
}
```
### 5.3 ⛔⛔⛔ PYTHON — ТОЛЬКО НАШ ОБРАЗ
`naeel/fission-python-env:v1.0` содержит патч `inspect.signature`:
```python
def _count_required_params(func):
# inspect.signature → считает обязательные аргументы
def userfunc_call(self, *args, **kwargs):
required = _count_required_params(self.userfunc)
if required == 0: return self.userfunc() # def main():
elif required == 1: return self.userfunc(request) # def main(request):
elif required >= 2: return self.userfunc(request, {}) # def main(event, context):
```
**Официальный `ghcr.io/fission/python-env`** вызывает `userfunc()` без аргументов → `TypeError: missing 2 required positional arguments` → HTTP 500 для любой функции `def main(event, context)`.
### 5.4 Если environment уже создан с неправильным образом
```bash
kubectl patch environment -n <NS> console-python-env --type=json \
-p '[{"op":"replace","path":"/spec/runtime/image","value":"naeel/fission-python-env:v1.0"}]'
```
Имя environment всегда: `console-<lang>-env` (например `console-python-env`).
---
## 6. Линтер (lint-archive)
**Файл:** `console/internal/api/lint_archive.go``handleLintArchive()`
**Эндпоинт:** `POST /console/api/ai/lint-archive`
### 6.1 Когда вызывается
Из UI при выборе файла в форме архива (`fn-archive.js``lintArchiveFile(prefix)`).
Поля: `archive` (zip), `entrypoint` (опционально), `language` (опционально).
### 6.2 Порядок проверок
```
1. Размер архива ≤ 100 KB (maxArchiveBytes)
2. Суммарный распакованный размер ≤ 100 KB (защита от zip bomb)
3. Синтаксический линтер по каждому файлу:
.py → python3 -m py_compile
.js → node --check
.rb → ruby -c
.php → php -l
4. Проверка language vs расширения файлов в архиве:
python → нужен хотя бы один .py
nodejs → нужен хотя бы один .js
...и т.д.
5. Проверка entrypoint (формат "module.function"):
→ файл module.py должен быть в архиве
→ def <function>(...) должна быть определена в файле
```
### 6.3 Ответ
```json
{
"results": [
{"file": "demo.py", "ok": true},
{"file": "utils.py", "ok": false, "output": "SyntaxError: ..."},
{"file": "язык", "ok": false, "output": "выбран python, но .py файлов нет"},
{"file": "entrypoint", "ok": false, "output": "demo.py найден, но def main не определён"}
],
"ok": false
}
```
---
## 7. Fission: как функция выполняется (runtime)
```
Пользователь вызывает HTTP URL роута
Fission Router (JWT auth если включён)
Executor (poolmgr) — выбирает под из пула
Fetcher sidecar — скачивает архив из S3:
GET http://storagesvc.../v1/archive?id=fission/UUID
Распаковывает zip → /userfunc/
(для inline Python: кладёт .py как /userfunc/user.py)
Python runtime pod специализируется:
POST /specialize на порт 8888 пода
server.py импортирует module из /userfunc/
Вызов функции:
_count_required_params(userfunc) → 0/1/2 аргументов
0 → userfunc()
1 → userfunc(request)
2 → userfunc(request, {})
```
---
## 8. Namespace пользователя
Каждый пользователь → отдельный K8s namespace.
| Пользователь | Namespace |
|-------------|-----------|
| chiken | `fission-54a1d63ee5e0921b` |
| livetest@test.local | `fission-199e39e44b809ec1` |
Namespace создаётся при первом обращении через `EnsureUserNS()`.
Имя environment всегда `console-python-env` (не уникально на пользователя) — каждый пользователь имеет СВОЙ environment в своём namespace.
---
## 9. Роут функции
Формат по умолчанию: `/<последние 12 символов namespace>/<имя функции>`
Пример: namespace `fission-54a1d63ee5e0921b` → роут `/d63ee5e0921b/funcname`
---
## 10. Аннотации на Function CRD
| Аннотация | Значение |
|-----------|---------|
| `fission-console/source-type` | `code` или `archive` |
| `fission-console/archive-file` | оригинальное имя загруженного файла (только для архивов) |
| `fission-console/expires-at` | Unix timestamp протухания (если задан TTL) |
---
## 11. Что НЕ ДОЛЖНО происходить (зафиксированные баги)
| Баг | Причина | Последствие |
|-----|---------|-------------|
| `ghcr.io/fission/python-env` в LangEnvMap | Забыли про наш патч | `def main(event, context)` → 500 |
| Загрузка `.py` файла вместо `.zip` | UI не проверял расширение | Fission не может распаковать → 500 |
| 0 байт в S3 | Файл не zip (magic bytes ≠ PK) | Fetcher не может распаковать → 500 |
---
## 12. Файловая структура кода (консоль)
```
console/
cmd/server/ — точка входа (main.go)
internal/
api/
handlers.go — HTTP handlers: CRUD функций, invoke, edit
lint_archive.go — POST /ai/lint-archive
ai_check.go — LLM-анализ кода через OpenAI-совместимый API
server.go — Server struct, маршрутизация
auth/ — JWT/Basic auth middleware
fission/
environment.go — EnsureEnvironment, CleanupEnvironmentIfUnused
namespace.go — EnsureUserNS
gvr.go — GVR константы для K8s dynamic client
model/
types.go — LangEnvMap ⚠️ ОБРАЗЫ ЯЗЫКОВ ЗДЕСЬ
runtime/
python.go — DefaultEntrypoint, BuildPythonZip
nodejs.go — BuildJSDeployZip
go.go — BuildGoSourceZip
ui/
js/
functions.js — создание/редактирование inline-функций
fn-archive.js — создание из архива
fn-edit-archive.js — редактирование архивной функции
app.js — общая логика, auth, API вызовы
```
@@ -0,0 +1,57 @@
# План тестирования: загрузка функций из архива (Fission Console)
Дата: 2026-05-04
## Цели
- Проверить корректность загрузки функций из архива для всех поддерживаемых языков (Python, Node.js, Ruby, PHP)
- Проверить работу линтера, валидацию entrypoint, соответствие языка, структуру архива
- Проверить обработку ошибок, edge-cases, граничные размеры
## Категории тестов
### 1. Валидные случаи
- [ ] Python: demo.py с def main(), entrypoint demo.main
- [ ] Node.js: demo.js с module.exports = main, entrypoint demo.main
- [ ] Ruby: demo.rb с def main, entrypoint demo.main
- [ ] PHP: demo.php с function main, entrypoint demo.main
- [ ] Архив с несколькими файлами (несколько языков, только один выбран)
- [ ] Архив с подпапками (файл demo.py внутри src/)
- [ ] Entrypoint с нестандартным именем (myfunc.py, entrypoint myfunc.main)
### 2. Ошибочные случаи
- [ ] Нет файла с нужным именем (entrypoint demo.main, а demo.py отсутствует)
- [ ] Нет функции с нужным именем (demo.py есть, но нет def main)
- [ ] Язык выбран Python, а в архиве только .js
- [ ] Язык выбран Node.js, а в архиве только .py
- [ ] Entrypoint пустой
- [ ] Entrypoint без точки ("main")
- [ ] Архив пустой
- [ ] Архив > 100 KB
- [ ] Суммарный размер файлов > 100 KB (zip bomb)
- [ ] Файл с синтаксической ошибкой (Python, JS, Ruby, PHP)
### 3. Пограничные случаи
- [ ] demo.py ровно 100 KB
- [ ] Архив с demo.py и demo.js, язык Python, entrypoint demo.main
- [ ] Архив с demo.py и demo.js, язык Node.js, entrypoint demo.main
- [ ] demo.py с def main(event, context)
- [ ] demo.py с def main() и def main2()
- [ ] demo.py с def main : (без скобок)
- [ ] demo.py с декоратором @fission_entry
- [ ] demo.py с не-ASCII именем функции
### 4. UI/UX
- [ ] Проверка сообщений линтера (ошибки, предупреждения, OK)
- [ ] Проверка отображения имени архива, entrypoint, языка
- [ ] Проверка что после ошибки можно загрузить исправленный архив
## Автоматизация
- Тесты реализовать на Go (backend), Python (pytest), JS (playwright) — по необходимости
- Для каждого теста: архив, ожидаемый результат, шаги UI/REST, ожидаемое сообщение
---
## Ход выполнения
- [ ] Подготовить тестовые архивы
- [ ] Реализовать автотесты (минимум: backend REST, опционально UI)
- [ ] Зафиксировать результаты в doc/reports/
@@ -0,0 +1,43 @@
# Plan: console-python-env — fix function call signature
Date: 2026-05-04
## Problem
Fission Python env v3 calls user function as `userfunc()` with no arguments
(Flask URL path params only, empty for parameterless routes).
Users writing `def main(event, context)` (AWS Lambda style) get:
```
TypeError: main() missing 2 required positional arguments: 'event' and 'context'
```
This is a real limitation of `ghcr.io/fission/python-env` — it never supported
Lambda-style signatures. The user code is valid Python, the env is inflexible.
## Solution
Build custom `naeel/console-python-env` based on `ghcr.io/fission/python-env`.
Patch `server.py`: before calling `userfunc`, use `inspect.signature` to count
required positional args, fill them with `None` if Fission passes fewer.
Supported signatures after fix:
- `def main()` — standard Fission
- `def main(event, context)` — AWS Lambda style (event=None, context=None)
- `def main(request)` — single-arg style (request=None)
- `def main(*args, **kwargs)` — variadic, works as before
## Steps
1. Get `server.py` from running pod
2. Apply inspect patch to `userfunc_call`
3. Write Dockerfile: `FROM ghcr.io/fission/python-env`, COPY patched server.py
4. Build `naeel/console-python-env:v1.0`
5. Push to Docker Hub
6. Update Environment CRD `console-python-env` in k8s → new runtime image
7. Restart poolmgr pod for that env
8. Test `aa1` with `def main(event, context)` from archive
## Documentation (after fix)
Add to user docs: supported function signatures for Fission Python env.
+75
View File
@@ -0,0 +1,75 @@
# План: разделение Create на два независимых модуля
Ветка: `separate-create-modals`
Дата: 2026-05-04
---
## Цель
Разделить одну кнопку «Создать функцию» с переключателем код/архив на два полностью независимых модуля.
Никакого общего состояния, никакого общего if/else.
---
## Задачи
### 1. Создать `console/ui/js/fn-code.js` [ ]
Содержит только:
- `onLangChangeCode()` — читает `cc-lang`, пишет в `cc-entry`, `cc-code`
- `openCreateCode()` — инит формы, открывает `create-code-modal`
- `closeCreateCode()` — закрывает `create-code-modal`
- `submitCreateCode()` — отправляет JSON `POST /functions`, без архивной ветки
### 2. Создать `console/ui/js/fn-archive.js` [ ]
Содержит только:
- `onLangChangeArchive()` — читает `ca-lang`, пишет в `ca-entry`
- `openCreateArchive()` — инит формы, открывает `create-archive-modal`
- `closeCreateArchive()` — закрывает `create-archive-modal`
- `submitCreateArchive()` — отправляет multipart `POST /functions`, без кодовой ветки
### 3. Изменить `index.html` [ ]
- Заменить 1 кнопку «+ Создать функцию» на две:
- `+ Из кода``openCreateCode()`
- `+ Из архива``openCreateArchive()`
- Добавить `create-code-modal` (prefix `cc-`) — только textarea + AI-кнопки
- Добавить `create-archive-modal` (prefix `ca-`) — только file input + lint/explain
- Удалить старую `create-modal` с переключателями
- Подключить `<script src="js/fn-code.js">` и `<script src="js/fn-archive.js">`
- Убрать `<script>` для старой логики создания из `functions.js` (или оставить файл без этих функций)
### 4. Изменить `console/ui/js/functions.js` [ ]
Удалить:
- `openCreate()`, `closeCreate()`, `submitCreate()`
- `setCodeMode('c', ...)` — только `c`-префикс; `e`-префикс (edit) не трогать
- `onLangChange()` → заменяется на `onLangChangeCode()` в fn-code.js
---
## Что НЕ трогаем
- `edit-modal` и весь edit-flow — следующий этап
- Backend API — без изменений
- Таблица функций — без изменений
- `lintArchiveFile()`, `explainArchiveFile()` — остаются в `functions.js`, используются из обоих модулей по prefix
---
## Прогресс
| # | Задача | Статус |
|---|--------|--------|
| 1 | Создать `fn-code.js` | ✅ |
| 2 | Создать `fn-archive.js` | ✅ |
| 3 | Изменить `index.html` | ✅ |
| 4 | Изменить `functions.js` | ✅ |
| 5 | rsync + проверка на VM | ✅ |
| 6 | docker build + push + apply | ✅ v1.3.46 |
| 7 | git commit + push | ✅ ветка separate-create-modals |
---
## Лог изменений
- `js/fn-code.js` — создан: `onLangChangeCode`, `openCreateCode`, `closeCreateCode`, `submitCreateCode` (prefix `cc-`)
- `js/fn-archive.js` — создан: `onLangChangeArchive`, `openCreateArchive`, `closeCreateArchive`, `submitCreateArchive` (prefix `ca-`)
- `js/ai.js``showGenPrompt(prefix)` и `aiGenerate(prefix)` теперь принимают prefix (default `'cc'`)
- `js/functions.js` — удалены `onLangChange`, `openCreate`, `closeCreate`, `submitCreate`
- `index.html` — добавлены `<script>` для fn-code.js и fn-archive.js; кнопка заменена на две (✏️ Из кода / 📦 Из архива); старая `create-modal` заменена на `create-code-modal` (prefix `cc-`) и `create-archive-modal` (prefix `ca-`)
- Версия: v1.3.45 → v1.3.46
+36
View File
@@ -0,0 +1,36 @@
# Отчёт по тесту совместимости кастомного Fission CLI (2026-05-04)
## 1. Базовые команды
- fission --help, version, env/fn/pkg/route/spec list — работают корректно, структура команд и опций стандартная.
- CLI видит все environments, функции, пакеты, маршруты, включая кастомные.
## 2. Создание/обновление/удаление сущностей
- env create/update/delete, fn create/update/delete, pkg create/delete, route create/delete — работают штатно.
- Все объекты создаются, обновляются, удаляются, отображаются в списках.
- Ошибка: --version не поддерживается для env update (так и в официальном CLI).
## 3. Архивы, S3, storagesvc
- fission archive upload <file> — работает, архивы появляются в списке.
- Не-zip и 0-байтовые архивы не блокируются на upload, но не проходят валидацию при pkg create (ожидаемо).
- fission archive delete --id=<id> — работает, архив удаляется.
- fission archive get-url --name <file> — не работает (требует id, а не имя).
## 4. Namespace и RBAC
- Создание env, fn, route в отдельном namespace работает корректно.
- Все объекты видны через list с указанием namespace.
- Удаление объектов и namespace — без ошибок.
## 5. Ошибки и edge-cases
- Попытка создать env с официальным образом fission/python-env — ошибка (валидация работает).
- Попытка загрузить не-zip/0-байтовый архив — upload проходит, но pkg create выдаёт ошибку (валидатор).
## 6. Итог
- Все основные и edge-case сценарии CLI работают как в официальном Fission.
- Критичных отличий, багов, несовместимости не обнаружено.
- Все тестовые объекты и namespace удалены после теста.
---
Тест проведён: 2026-05-04
CLI: кастомный fission-bundle (v1.22.0, naeel)
+224
View File
@@ -0,0 +1,224 @@
#!/usr/bin/env python
import importlib
import inspect
import logging
import os
import signal
import sys
import json
from flask import Flask, request, abort
from gevent.pywsgi import WSGIServer
import bjoern
import sentry_sdk
from sentry_sdk.integrations.flask import FlaskIntegration
from flask_sockets import Sockets
IS_PY2 = (sys.version_info.major == 2)
SENTRY_DSN = os.environ.get('SENTRY_DSN', None)
SENTRY_RELEASE = os.environ.get('SENTRY_RELEASE', None)
USERFUNCVOL = os.environ.get("USERFUNCVOL", "/userfunc")
RUNTIME_PORT = int(os.environ.get("RUNTIME_PORT", "8888"))
if SENTRY_DSN:
params = {'dsn': SENTRY_DSN, 'integrations': [FlaskIntegration()]}
if SENTRY_RELEASE:
params['release'] = SENTRY_RELEASE
sentry_sdk.init(**params)
def import_src(path):
if IS_PY2:
import imp
return imp.load_source('mod', path)
else:
return importlib.machinery.SourceFileLoader('mod', path).load_module()
def store_specialize_info(state):
json.dump(state, open(os.path.join(USERFUNCVOL, "state.json"), "w"))
def check_specialize_info_exists():
return os.path.exists(os.path.join(USERFUNCVOL, "state.json"))
def read_specialize_info():
return json.load(open(os.path.join(USERFUNCVOL, "state.json")))
def remove_specialize_info():
os.remove(os.path.join(USERFUNCVOL, "state.json"))
class SignalExit(SystemExit):
def __init__(self, signo, exccode=1):
super(SignalExit, self).__init__(exccode)
self.signo = signo
def register_signal_handlers(signal_handler=signal.SIG_DFL):
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
def _count_required_params(func):
"""Return number of required (non-default) positional parameters."""
try:
sig = inspect.signature(func)
count = 0
for p in sig.parameters.values():
if p.kind in (inspect.Parameter.VAR_POSITIONAL,
inspect.Parameter.VAR_KEYWORD):
continue
if p.default is inspect.Parameter.empty:
count += 1
return count
except (ValueError, TypeError):
return 0
class FuncApp(Flask):
def __init__(self, name, loglevel=logging.DEBUG):
super(FuncApp, self).__init__(name)
self.userfunc = None
self.root = logging.getLogger()
self.ch = logging.StreamHandler(sys.stdout)
self.root.setLevel(loglevel)
self.ch.setLevel(loglevel)
self.ch.setFormatter(
logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
self.logger.addHandler(self.ch)
if check_specialize_info_exists():
self.logger.info('Found state.json')
specialize_info = read_specialize_info()
self.userfunc = self._load_v2(specialize_info)
self.logger.info('Loaded user function {}'.format(specialize_info))
def load(self):
self.logger.info('/specialize called')
self.userfunc = import_src('/userfunc/user').main
return ""
def loadv2(self):
specialize_info = request.get_json()
if check_specialize_info_exists():
self.logger.warning("Found state.json, overwriting")
self.userfunc = self._load_v2(specialize_info)
store_specialize_info(specialize_info)
return ""
def healthz(self):
return "", 200
def userfunc_call(self, *args, **kwargs):
"""
Call user function with compatible signature detection:
def main() -> main()
def main(event, context) -> main(request, {})
def main(request) -> main(request)
def main(event=None, ...) -> main() (all defaults)
"""
if self.userfunc is None:
self.logger.error('userfunc is None')
return abort(500)
required = _count_required_params(self.userfunc)
if required == 0:
# def main() or def main(event=None, context=None) — call with no args
return self.userfunc()
elif required == 1:
# def main(request) style
return self.userfunc(request)
elif required >= 2:
# def main(event, context) — AWS Lambda / Kubeless style
# pass flask request as event, empty dict as context
return self.userfunc(request, {})
else:
return self.userfunc(*args)
def _load_v2(self, specialize_info):
filepath = specialize_info['filepath']
handler = specialize_info['functionName']
self.logger.info(
'specialize called with filepath = "{}" handler = "{}"'.format(
filepath, handler))
parts = handler.rsplit(".", 1)
if len(handler) == 0:
moduleName = 'main'
funcName = 'main'
elif len(parts) == 1:
moduleName = 'main'
funcName = parts[0]
else:
moduleName = parts[0]
funcName = parts[1]
self.logger.debug('moduleName = "{}" funcName = "{}"'.format(
moduleName, funcName))
if os.path.isdir(filepath):
sys.path.append(filepath)
self.logger.debug('__package__ = "{}"'.format(__package__))
if __package__:
mod = importlib.import_module(moduleName, __package__)
else:
mod = importlib.import_module(moduleName)
else:
mod = import_src(filepath)
return getattr(mod, funcName)
def signal_handler(self, signalnum, frame):
self.logger.info('Received signal {}'.format(
signal.strsignal(signalnum)))
if check_specialize_info_exists():
self.logger.info('Found state.json, removing')
remove_specialize_info()
signal.signal(signalnum, signal.SIG_DFL)
raise SignalExit(signalnum)
def main():
app = FuncApp(__name__, logging.DEBUG)
sockets = Sockets(app)
register_signal_handlers(app.signal_handler)
app.add_url_rule('/specialize', 'load', app.load, methods=['POST'])
app.add_url_rule('/v2/specialize', 'loadv2', app.loadv2, methods=['POST'])
app.add_url_rule('/healthz', 'healthz', app.healthz, methods=['GET'])
app.add_url_rule(
'/',
'userfunc_call',
app.userfunc_call,
methods=['GET', 'POST', 'PUT', 'HEAD', 'OPTIONS', 'DELETE'])
app.add_url_rule(
'/<path:path>',
'userfunc_call',
app.userfunc_call,
methods=['GET', 'POST', 'PUT', 'HEAD', 'OPTIONS', 'DELETE'])
sockets.add_url_rule(
'/',
'userfunc_call',
app.userfunc_call,
methods=['GET', 'POST', 'PUT', 'HEAD', 'OPTIONS', 'DELETE'])
if os.environ.get("WSGI_FRAMEWORK") == "GEVENT":
app.logger.info("Starting gevent based server")
from gevent_ws import WebSocketHandler
svc = WSGIServer(('0.0.0.0', RUNTIME_PORT),
app,
handler_class=WebSocketHandler)
svc.serve_forever()
else:
app.logger.info("Starting bjoern based server")
bjoern.run(app, '0.0.0.0', RUNTIME_PORT, reuse_port=True)
main()
+2
View File
@@ -0,0 +1,2 @@
FROM ghcr.io/fission/python-env:latest
COPY server.py /app/server.py
+224
View File
@@ -0,0 +1,224 @@
#!/usr/bin/env python
import importlib
import inspect
import logging
import os
import signal
import sys
import json
from flask import Flask, request, abort
from gevent.pywsgi import WSGIServer
import bjoern
import sentry_sdk
from sentry_sdk.integrations.flask import FlaskIntegration
from flask_sockets import Sockets
IS_PY2 = (sys.version_info.major == 2)
SENTRY_DSN = os.environ.get('SENTRY_DSN', None)
SENTRY_RELEASE = os.environ.get('SENTRY_RELEASE', None)
USERFUNCVOL = os.environ.get("USERFUNCVOL", "/userfunc")
RUNTIME_PORT = int(os.environ.get("RUNTIME_PORT", "8888"))
if SENTRY_DSN:
params = {'dsn': SENTRY_DSN, 'integrations': [FlaskIntegration()]}
if SENTRY_RELEASE:
params['release'] = SENTRY_RELEASE
sentry_sdk.init(**params)
def import_src(path):
if IS_PY2:
import imp
return imp.load_source('mod', path)
else:
return importlib.machinery.SourceFileLoader('mod', path).load_module()
def store_specialize_info(state):
json.dump(state, open(os.path.join(USERFUNCVOL, "state.json"), "w"))
def check_specialize_info_exists():
return os.path.exists(os.path.join(USERFUNCVOL, "state.json"))
def read_specialize_info():
return json.load(open(os.path.join(USERFUNCVOL, "state.json")))
def remove_specialize_info():
os.remove(os.path.join(USERFUNCVOL, "state.json"))
class SignalExit(SystemExit):
def __init__(self, signo, exccode=1):
super(SignalExit, self).__init__(exccode)
self.signo = signo
def register_signal_handlers(signal_handler=signal.SIG_DFL):
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
def _count_required_params(func):
"""Return number of required (non-default) positional parameters."""
try:
sig = inspect.signature(func)
count = 0
for p in sig.parameters.values():
if p.kind in (inspect.Parameter.VAR_POSITIONAL,
inspect.Parameter.VAR_KEYWORD):
continue
if p.default is inspect.Parameter.empty:
count += 1
return count
except (ValueError, TypeError):
return 0
class FuncApp(Flask):
def __init__(self, name, loglevel=logging.DEBUG):
super(FuncApp, self).__init__(name)
self.userfunc = None
self.root = logging.getLogger()
self.ch = logging.StreamHandler(sys.stdout)
self.root.setLevel(loglevel)
self.ch.setLevel(loglevel)
self.ch.setFormatter(
logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
self.logger.addHandler(self.ch)
if check_specialize_info_exists():
self.logger.info('Found state.json')
specialize_info = read_specialize_info()
self.userfunc = self._load_v2(specialize_info)
self.logger.info('Loaded user function {}'.format(specialize_info))
def load(self):
self.logger.info('/specialize called')
self.userfunc = import_src('/userfunc/user').main
return ""
def loadv2(self):
specialize_info = request.get_json()
if check_specialize_info_exists():
self.logger.warning("Found state.json, overwriting")
self.userfunc = self._load_v2(specialize_info)
store_specialize_info(specialize_info)
return ""
def healthz(self):
return "", 200
def userfunc_call(self, *args, **kwargs):
"""
Call user function with compatible signature detection:
def main() -> main()
def main(event, context) -> main(request, {})
def main(request) -> main(request)
def main(event=None, ...) -> main() (all defaults)
"""
if self.userfunc is None:
self.logger.error('userfunc is None')
return abort(500)
required = _count_required_params(self.userfunc)
if required == 0:
# def main() or def main(event=None, context=None) — call with no args
return self.userfunc()
elif required == 1:
# def main(request) style
return self.userfunc(request)
elif required >= 2:
# def main(event, context) — AWS Lambda / Kubeless style
# pass flask request as event, empty dict as context
return self.userfunc(request, {})
else:
return self.userfunc(*args)
def _load_v2(self, specialize_info):
filepath = specialize_info['filepath']
handler = specialize_info['functionName']
self.logger.info(
'specialize called with filepath = "{}" handler = "{}"'.format(
filepath, handler))
parts = handler.rsplit(".", 1)
if len(handler) == 0:
moduleName = 'main'
funcName = 'main'
elif len(parts) == 1:
moduleName = 'main'
funcName = parts[0]
else:
moduleName = parts[0]
funcName = parts[1]
self.logger.debug('moduleName = "{}" funcName = "{}"'.format(
moduleName, funcName))
if os.path.isdir(filepath):
sys.path.append(filepath)
self.logger.debug('__package__ = "{}"'.format(__package__))
if __package__:
mod = importlib.import_module(moduleName, __package__)
else:
mod = importlib.import_module(moduleName)
else:
mod = import_src(filepath)
return getattr(mod, funcName)
def signal_handler(self, signalnum, frame):
self.logger.info('Received signal {}'.format(
signal.strsignal(signalnum)))
if check_specialize_info_exists():
self.logger.info('Found state.json, removing')
remove_specialize_info()
signal.signal(signalnum, signal.SIG_DFL)
raise SignalExit(signalnum)
def main():
app = FuncApp(__name__, logging.DEBUG)
sockets = Sockets(app)
register_signal_handlers(app.signal_handler)
app.add_url_rule('/specialize', 'load', app.load, methods=['POST'])
app.add_url_rule('/v2/specialize', 'loadv2', app.loadv2, methods=['POST'])
app.add_url_rule('/healthz', 'healthz', app.healthz, methods=['GET'])
app.add_url_rule(
'/',
'userfunc_call',
app.userfunc_call,
methods=['GET', 'POST', 'PUT', 'HEAD', 'OPTIONS', 'DELETE'])
app.add_url_rule(
'/<path:path>',
'userfunc_call',
app.userfunc_call,
methods=['GET', 'POST', 'PUT', 'HEAD', 'OPTIONS', 'DELETE'])
sockets.add_url_rule(
'/',
'userfunc_call',
app.userfunc_call,
methods=['GET', 'POST', 'PUT', 'HEAD', 'OPTIONS', 'DELETE'])
if os.environ.get("WSGI_FRAMEWORK") == "GEVENT":
app.logger.info("Starting gevent based server")
from gevent_ws import WebSocketHandler
svc = WSGIServer(('0.0.0.0', RUNTIME_PORT),
app,
handler_class=WebSocketHandler)
svc.serve_forever()
else:
app.logger.info("Starting bjoern based server")
bjoern.run(app, '0.0.0.0', RUNTIME_PORT, reuse_port=True)
main()
+1
View File
@@ -0,0 +1 @@
UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5qc1VUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAbW9kdWxlLmV4cG9ydHMgPSBmdW5jdGlvbiBub3RNYWluKCkgeyByZXR1cm4gIk5vdCBNYWluIjt9ClBLAQIUABQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAAAAAAAAAAACkgQAAAABkZW1vLmpzVVQFAANCBBt1eAsAAQT1AQAABBwAAABQSwUGAAAAAAEAAQBOAAAAUQAAAAAA
+1
View File
@@ -0,0 +1 @@
UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5qc1VUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAbW9kdWxlLmV4cG9ydHMgPSBmdW5jdGlvbiBtYWluKCkgeyByZXR1cm4gIkphdmFTY3JpcHQgT0siOyB9CmZ1bmN0aW9uIGJhZCgpIHJldHVybiAiZXJyb3IiOwpQSwECFAAUAAgArVvqVAAAAAAAAAAAAAAAAAkABAAAAAAAAAAAApIEAAAAAZGVtby5qc1VUBQADQgQbdXgLAAEE9QEAAAQcAAAAUEsFBgAAAAABAAEATgAAAFEAAAAAAA==
View File
+1
View File
@@ -0,0 +1 @@
UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5qc1VUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAbW9kdWxlLmV4cG9ydHMgPSBmdW5jdGlvbiBtYWluKCkgeyByZXR1cm4gIkphdmFTY3JpcHQgT0siOyB9ClBLAQIUABQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAAAAAAAAAAACkgQAAAABkZW1vLmpzVVQFAANCBBt1eAsAAQT1AQAABBwAAABQSwUGAAAAAAEAAQBOAAAAUQAAAAAA
+1
View File
@@ -0,0 +1 @@
UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAKAAQAZGVtby5waHBVVAkAA0MEG2dDBRtnYXgLAAEE9QEAAAQcADw/cGhwCmlmKCFmdW5jdGlvbl9leGlzdHMoIm1haW4iKSkgewogIGZ1bmN0aW9uIG1haW4oKSB7IHJldHVybiAiUEhQIG9rIjsgfQp9ClBLAQIUABQAAAAIAK1b6lQAAAAAAAAAAAAAAAAKAAQAAAAAAAAAAACkgQAAAABkZW1vLnBocFVUBQADQgQbd2F4CwABBPUBAAAEHAAAAFBLAwQUAAAACACtW+pUAAAAAAAAAAAAAAAAAAwAAAHB5LWhlbGxvLnB5VVQJAANCBBtnQwUbZ3V4CwABBPUBAAAEHABkZWYgbWFpbigpOgogICAgcmV0dXJuICJIZWxsbyBvayIKClBLAQIUABQAAAAIAK1b6lQAAAAAAAAAAAAAAAAADAAAAAAAAAAAAAAAACkgQAAAHB5LWhlbGxvLnB5VVQFAANCBBt1eAsAAQT1AQAABBwAAABQSwUGAAAAAAEAAQBOAAAAWgAAAAAA
+1
View File
@@ -0,0 +1 @@
UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5weVVUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwA
@@ -0,0 +1 @@
UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5weVVUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAZGVmIG1haW4yKCk6CiAgICByZXR1cm4gIk5vdCBNYWluIgoKUEsBAhQAFAAAAAgArVvqVAAAAAAAAAAAAAAAAAkABAAAAAAAAAAAApIEAAAAAZGVtby5weVVUBQADQgQbdXgLAAEE9QEAAAQcAAAAUEsFBgAAAAABAAEATgAAAFEAAAAAAA==
+1
View File
@@ -0,0 +1 @@
UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5weVVUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAZGVmIG1haW4oKToKICAgIHJldHVybiAiS29ycmVrdCIKZGVmIGJhZCgpCiAgICByZXR1cm4gIkVycm9yIgoKUEsBAhQAFAAAAAgArVvqVAAAAAAAAAAAAAAAAAkABAAAAAAAAAAAApIEAAAAAZGVtby5weVVUBQADQgQbdXgLAAEE9QEAAAQcAAAAUEsFBgAAAAABAAEATgAAAFEAAAAAAA==
@@ -0,0 +1 @@
UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5weVVUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAZGVmIG1haW4oKToKICAgIHJldHVybiAiS29ycmVrdCIKZGVmIGJhZCgpCiAgICByZXR1cm4gIkVycm9yIgoKUEsBAhQAFAAAAAgArVvqVAAAAAAAAAAAAAAAAAkABAAAAAAAAAAAApIEAAAAAZGVtby5weVVUBQADQgQbdXgLAAEE9QEAAAQcAAAAUEsFBgAAAAABAAEATgAAAFEAAAAAAA==
+1
View File
@@ -0,0 +1 @@
UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5weVVUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAY29kZSA9ICJkZWNvcmF0b3IiCkBkZWNvcmF0b3IKZGVmIG1haW4oKToKICAgIHJldHVybiAiZGVjb3JhdG9yIgoKUEsBAhQAFAAAAAgArVvqVAAAAAAAAAAAAAAAAAkABAAAAAAAAAAAApIEAAAAAZGVtby5weVVUBQADQgQbdXgLAAEE9QEAAAQcAAAAUEsFBgAAAAABAAEATgAAAFEAAAAAAA==
View File
+1
View File
@@ -0,0 +1 @@
UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5weVVUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAZGVmIG1haW46CiAgICByZXR1cm4gIkNvbG9uIgoKUEsBAhQAFAAAAAgArVvqVAAAAAAAAAAAAAAAAAkABAAAAAAAAAAAApIEAAAAAZGVtby5weVVUBQADQgQbdXgLAAEE9QEAAAQcAAAAUEsFBgAAAAABAAEATgAAAFEAAAAAAA==
+1
View File
@@ -0,0 +1 @@
UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5weVVUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAZGVmIG1haW4oKToKICAgIHJldHVybiAiS29ycmVrdCIKZGVmIG1haW4yKCk6CiAgICByZXR1cm4gIk1haW4yIgoKUEsBAhQAFAAAAAgArVvqVAAAAAAAAAAAAAAAAAkABAAAAAAAAAAAApIEAAAAAZGVtby5weVVUBQADQgQbdXgLAAEE9QEAAAQcAAAAUEsFBgAAAAABAAEATgAAAFEAAAAAAA==
+1
View File
@@ -0,0 +1 @@
UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5weVVUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAY29kZSA9ICLQnNCw0YDQtdC00L7QvCIKZGVmIM6x0L7RgNC+0LkgKCk6CiAgICByZXR1cm4gIm9rIgogClBLAQIUABQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAAAAAAAAAAACkgQAAAABkZW1vLnB5VVQFAANCBBt1eAsAAQT1AQAABBwAAABQSwUGAAAAAAEAAQBOAAAAUQAAAAAA
+2
View File
@@ -0,0 +1,2 @@
UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5weVVUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwA
ZGVmIG1haW4oKToKICAgIHJldHVybiAiS29ycmVrdCIKClBLAQIUABQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAAAAAAAAAAACkgQAAAABkZW1vLnB5VVQFAANCBBt1eAsAAQT1AQAABBwAAABQSwUGAAAAAAEAAQBOAAAAUQAAAAAA
+1
View File
@@ -0,0 +1 @@
UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAwAAABzcmMvZGVtby5weVVUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAZGVmIG1haW4oKToKICAgIHJldHVybiAiU3ViZGlyIgoKUEsBAhQAFAAAAAgArVvqVAAAAAAAAAAAAAAAAAwAAAAAAAAAAAAAAACkgQAAAHNyYy9kZW1vLnB5VVQFAANCBBt1eAsAAQT1AQAABBwAAABQSwUGAAAAAAEAAQBOAAAAUQAAAAAA
+1
View File
@@ -0,0 +1 @@
UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5yYlVUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAZGVmIG1haW4KICAgICJydWJ5IG9rIgogClBLAQIUABQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAAAAAAAAAAACkgQAAAABkZW1vLnJiVVQFAANCBBt1eAsAAQT1AQAABBwAAABQSwUGAAAAAAEAAQBOAAAAUQAAAAAA