Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e128bdc28f | ||
|
|
25816395d3 | ||
|
|
78f72c593f | ||
|
|
26247ea137 | ||
|
|
d09348b6b9 | ||
|
|
4980eb4efa | ||
|
|
6ded699935 | ||
|
|
353cc0f5b1 | ||
|
|
06389a425d | ||
|
|
db7862b8bd | ||
|
|
bb9f158bac | ||
|
|
36f83892aa | ||
|
|
2fdba29f79 | ||
|
|
f97fd8a744 | ||
|
|
6ac22d5f55 | ||
|
|
4f6b4b87be | ||
|
|
1cceb42f83 |
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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
@@ -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')">🔍 Проверить
|
||||
синтаксис линтером</button>
|
||||
<button class="btn ghost" id="c-gen-btn" onclick="showGenPrompt()">✨ Сгенерировать код LLM</button>
|
||||
<button class="btn ghost" id="c-exp-btn" onclick="aiExplain('c-code','c-lang','c-ai-result')">📖 LLM:
|
||||
Что делает?</button>
|
||||
<button class="btn ghost" id="cc-ai-btn" onclick="aiCheck('cc-code','cc-lang','cc-ai-result')">🔍 Проверить синтаксис линтером</button>
|
||||
<button class="btn ghost" id="cc-gen-btn" onclick="showGenPrompt('cc')">✨ Сгенерировать код LLM</button>
|
||||
<button class="btn ghost" id="cc-exp-btn" onclick="aiExplain('cc-code','cc-lang','cc-ai-result')">📖 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')">🔍 Проверка архива линтером</button>
|
||||
<button id="c-explain-archive-btn" class="btn ghost" onclick="explainArchiveFile('c')">📖 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')">🔍 Проверка архива линтером</button>
|
||||
<button id="ca-explain-archive-btn" class="btn ghost" onclick="explainArchiveFile('ca')">📖 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')">🔍 Проверка архива линтером</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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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,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 {
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,2 @@
|
||||
FROM ghcr.io/fission/python-env:latest
|
||||
COPY server.py /app/server.py
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5qc1VUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAbW9kdWxlLmV4cG9ydHMgPSBmdW5jdGlvbiBub3RNYWluKCkgeyByZXR1cm4gIk5vdCBNYWluIjt9ClBLAQIUABQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAAAAAAAAAAACkgQAAAABkZW1vLmpzVVQFAANCBBt1eAsAAQT1AQAABBwAAABQSwUGAAAAAAEAAQBOAAAAUQAAAAAA
|
||||
@@ -0,0 +1 @@
|
||||
UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5qc1VUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAbW9kdWxlLmV4cG9ydHMgPSBmdW5jdGlvbiBtYWluKCkgeyByZXR1cm4gIkphdmFTY3JpcHQgT0siOyB9CmZ1bmN0aW9uIGJhZCgpIHJldHVybiAiZXJyb3IiOwpQSwECFAAUAAgArVvqVAAAAAAAAAAAAAAAAAkABAAAAAAAAAAAApIEAAAAAZGVtby5qc1VUBQADQgQbdXgLAAEE9QEAAAQcAAAAUEsFBgAAAAABAAEATgAAAFEAAAAAAA==
|
||||
@@ -0,0 +1 @@
|
||||
UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5qc1VUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAbW9kdWxlLmV4cG9ydHMgPSBmdW5jdGlvbiBtYWluKCkgeyByZXR1cm4gIkphdmFTY3JpcHQgT0siOyB9ClBLAQIUABQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAAAAAAAAAAACkgQAAAABkZW1vLmpzVVQFAANCBBt1eAsAAQT1AQAABBwAAABQSwUGAAAAAAEAAQBOAAAAUQAAAAAA
|
||||
@@ -0,0 +1 @@
|
||||
UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAKAAQAZGVtby5waHBVVAkAA0MEG2dDBRtnYXgLAAEE9QEAAAQcADw/cGhwCmlmKCFmdW5jdGlvbl9leGlzdHMoIm1haW4iKSkgewogIGZ1bmN0aW9uIG1haW4oKSB7IHJldHVybiAiUEhQIG9rIjsgfQp9ClBLAQIUABQAAAAIAK1b6lQAAAAAAAAAAAAAAAAKAAQAAAAAAAAAAACkgQAAAABkZW1vLnBocFVUBQADQgQbd2F4CwABBPUBAAAEHAAAAFBLAwQUAAAACACtW+pUAAAAAAAAAAAAAAAAAAwAAAHB5LWhlbGxvLnB5VVQJAANCBBtnQwUbZ3V4CwABBPUBAAAEHABkZWYgbWFpbigpOgogICAgcmV0dXJuICJIZWxsbyBvayIKClBLAQIUABQAAAAIAK1b6lQAAAAAAAAAAAAAAAAADAAAAAAAAAAAAAAAACkgQAAAHB5LWhlbGxvLnB5VVQFAANCBBt1eAsAAQT1AQAABBwAAABQSwUGAAAAAAEAAQBOAAAAWgAAAAAA
|
||||
@@ -0,0 +1 @@
|
||||
UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5weVVUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwA
|
||||
@@ -0,0 +1 @@
|
||||
UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5weVVUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAZGVmIG1haW4yKCk6CiAgICByZXR1cm4gIk5vdCBNYWluIgoKUEsBAhQAFAAAAAgArVvqVAAAAAAAAAAAAAAAAAkABAAAAAAAAAAAApIEAAAAAZGVtby5weVVUBQADQgQbdXgLAAEE9QEAAAQcAAAAUEsFBgAAAAABAAEATgAAAFEAAAAAAA==
|
||||
@@ -0,0 +1 @@
|
||||
UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5weVVUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAZGVmIG1haW4oKToKICAgIHJldHVybiAiS29ycmVrdCIKZGVmIGJhZCgpCiAgICByZXR1cm4gIkVycm9yIgoKUEsBAhQAFAAAAAgArVvqVAAAAAAAAAAAAAAAAAkABAAAAAAAAAAAApIEAAAAAZGVtby5weVVUBQADQgQbdXgLAAEE9QEAAAQcAAAAUEsFBgAAAAABAAEATgAAAFEAAAAAAA==
|
||||
@@ -0,0 +1 @@
|
||||
UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5weVVUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAZGVmIG1haW4oKToKICAgIHJldHVybiAiS29ycmVrdCIKZGVmIGJhZCgpCiAgICByZXR1cm4gIkVycm9yIgoKUEsBAhQAFAAAAAgArVvqVAAAAAAAAAAAAAAAAAkABAAAAAAAAAAAApIEAAAAAZGVtby5weVVUBQADQgQbdXgLAAEE9QEAAAQcAAAAUEsFBgAAAAABAAEATgAAAFEAAAAAAA==
|
||||
@@ -0,0 +1 @@
|
||||
UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5weVVUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAY29kZSA9ICJkZWNvcmF0b3IiCkBkZWNvcmF0b3IKZGVmIG1haW4oKToKICAgIHJldHVybiAiZGVjb3JhdG9yIgoKUEsBAhQAFAAAAAgArVvqVAAAAAAAAAAAAAAAAAkABAAAAAAAAAAAApIEAAAAAZGVtby5weVVUBQADQgQbdXgLAAEE9QEAAAQcAAAAUEsFBgAAAAABAAEATgAAAFEAAAAAAA==
|
||||
@@ -0,0 +1 @@
|
||||
UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5weVVUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAZGVmIG1haW46CiAgICByZXR1cm4gIkNvbG9uIgoKUEsBAhQAFAAAAAgArVvqVAAAAAAAAAAAAAAAAAkABAAAAAAAAAAAApIEAAAAAZGVtby5weVVUBQADQgQbdXgLAAEE9QEAAAQcAAAAUEsFBgAAAAABAAEATgAAAFEAAAAAAA==
|
||||
@@ -0,0 +1 @@
|
||||
UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5weVVUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAZGVmIG1haW4oKToKICAgIHJldHVybiAiS29ycmVrdCIKZGVmIG1haW4yKCk6CiAgICByZXR1cm4gIk1haW4yIgoKUEsBAhQAFAAAAAgArVvqVAAAAAAAAAAAAAAAAAkABAAAAAAAAAAAApIEAAAAAZGVtby5weVVUBQADQgQbdXgLAAEE9QEAAAQcAAAAUEsFBgAAAAABAAEATgAAAFEAAAAAAA==
|
||||
@@ -0,0 +1 @@
|
||||
UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5weVVUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAY29kZSA9ICLQnNCw0YDQtdC00L7QvCIKZGVmIM6x0L7RgNC+0LkgKCk6CiAgICByZXR1cm4gIm9rIgogClBLAQIUABQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAAAAAAAAAAACkgQAAAABkZW1vLnB5VVQFAANCBBt1eAsAAQT1AQAABBwAAABQSwUGAAAAAAEAAQBOAAAAUQAAAAAA
|
||||
@@ -0,0 +1,2 @@
|
||||
UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5weVVUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwA
|
||||
ZGVmIG1haW4oKToKICAgIHJldHVybiAiS29ycmVrdCIKClBLAQIUABQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAAAAAAAAAAACkgQAAAABkZW1vLnB5VVQFAANCBBt1eAsAAQT1AQAABBwAAABQSwUGAAAAAAEAAQBOAAAAUQAAAAAA
|
||||
@@ -0,0 +1 @@
|
||||
UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAwAAABzcmMvZGVtby5weVVUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAZGVmIG1haW4oKToKICAgIHJldHVybiAiU3ViZGlyIgoKUEsBAhQAFAAAAAgArVvqVAAAAAAAAAAAAAAAAAwAAAAAAAAAAAAAAACkgQAAAHNyYy9kZW1vLnB5VVQFAANCBBt1eAsAAQT1AQAABBwAAABQSwUGAAAAAAEAAQBOAAAAUQAAAAAA
|
||||
@@ -0,0 +1 @@
|
||||
UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5yYlVUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAZGVmIG1haW4KICAgICJydWJ5IG9rIgogClBLAQIUABQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAAAAAAAAAAACkgQAAAABkZW1vLnJiVVQFAANCBBt1eAsAAQT1AQAABBwAAABQSwUGAAAAAAEAAQBOAAAAUQAAAAAA
|
||||
Reference in New Issue
Block a user