diff --git a/console/deploy/console.yaml b/console/deploy/console.yaml
index 289ec34..961e24b 100644
--- a/console/deploy/console.yaml
+++ b/console/deploy/console.yaml
@@ -52,7 +52,7 @@ spec:
serviceAccountName: fission-console
containers:
- name: console
- image: naeel/fission-console:v1.3.36
+ image: naeel/fission-console:v1.3.38
imagePullPolicy: Always
ports:
- containerPort: 8090
diff --git a/console/internal/api/lint_archive.go b/console/internal/api/lint_archive.go
new file mode 100644
index 0000000..6ba625d
--- /dev/null
+++ b/console/internal/api/lint_archive.go
@@ -0,0 +1,196 @@
+package api
+
+import (
+ "archive/zip"
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+const (
+ // maxArchiveBytes — максимальный размер zip-архива (100 KB).
+ // Serverless-функции не должны быть большими — только скрипты и небольшие зависимости.
+ maxArchiveBytes = 100 * 1024
+
+ // maxUnzippedBytes — ограничение на суммарный распакованный размер (защита от zip bomb).
+ // 100 KB реального кода — достаточно для любой serverless-функции.
+ maxUnzippedBytes = 100 * 1024
+)
+
+// lintResult — результат проверки одного файла.
+type lintResult struct {
+ File string `json:"file"`
+ OK bool `json:"ok"`
+ Output string `json:"output,omitempty"`
+}
+
+// handleLintArchive — POST /console/api/ai/lint-archive
+// Принимает zip-архив (multipart/form-data, поле "archive"),
+// прогоняет линтер по всем файлам с известным расширением,
+// возвращает список результатов по файлам.
+func (s *Server) handleLintArchive(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
+ return
+ }
+
+ // Ограничиваем тело запроса — не более maxArchiveBytes (+ небольшой overhead для multipart)
+ r.Body = http.MaxBytesReader(w, r.Body, maxArchiveBytes+4*1024)
+
+ if err := r.ParseMultipartForm(maxArchiveBytes); err != nil {
+ writeJSONError(w, http.StatusRequestEntityTooLarge,
+ fmt.Sprintf("архив слишком большой: максимум %d KB", maxArchiveBytes/1024))
+ return
+ }
+
+ f, _, err := r.FormFile("archive")
+ if err != nil {
+ writeJSONError(w, http.StatusBadRequest, "поле 'archive' обязательно")
+ return
+ }
+ defer f.Close()
+
+ // Читаем архив в память
+ var buf bytes.Buffer
+ if _, err := buf.ReadFrom(f); err != nil {
+ writeJSONError(w, http.StatusBadRequest, "ошибка чтения архива: "+err.Error())
+ return
+ }
+ if buf.Len() > maxArchiveBytes {
+ writeJSONError(w, http.StatusRequestEntityTooLarge,
+ fmt.Sprintf("архив слишком большой: %d байт, максимум %d KB", buf.Len(), maxArchiveBytes/1024))
+ return
+ }
+
+ // Открываем zip из памяти
+ zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
+ if err != nil {
+ writeJSONError(w, http.StatusBadRequest, "не удалось открыть zip: "+err.Error())
+ return
+ }
+
+ // Считаем суммарный распакованный размер (защита от zip bomb)
+ var totalUnzipped uint64
+ for _, zf := range zr.File {
+ totalUnzipped += zf.UncompressedSize64
+ }
+ if totalUnzipped > maxUnzippedBytes {
+ writeJSONError(w, http.StatusRequestEntityTooLarge,
+ fmt.Sprintf("суммарный размер распакованных файлов превышает %d KB", maxUnzippedBytes/1024))
+ return
+ }
+
+ // Карта линтеров по расширению
+ type linterCfg struct {
+ cmd []string
+ stdinOk bool // поддерживает stdin
+ stdinArg string // аргумент для stdin-режима (имя "файла")
+ }
+ linters := map[string]linterCfg{
+ ".py": {cmd: []string{"python3", "-m", "py_compile"}, stdinOk: false},
+ ".js": {cmd: []string{"node", "--check"}, stdinOk: false},
+ ".rb": {cmd: []string{"ruby", "-c"}, stdinOk: true},
+ ".php": {cmd: []string{"php", "-l"}, stdinOk: false},
+ }
+
+ var results []lintResult
+ var hasError bool
+
+ for _, zf := range zr.File {
+ if zf.FileInfo().IsDir() {
+ continue
+ }
+ ext := strings.ToLower(filepath.Ext(zf.Name))
+ cfg, supported := linters[ext]
+ if !supported {
+ continue
+ }
+
+ // Читаем содержимое файла из zip в память
+ rc, err := zf.Open()
+ if err != nil {
+ results = append(results, lintResult{File: zf.Name, OK: false, Output: "ошибка чтения: " + err.Error()})
+ hasError = true
+ continue
+ }
+ var content bytes.Buffer
+ _, readErr := content.ReadFrom(rc)
+ rc.Close()
+ if readErr != nil {
+ results = append(results, lintResult{File: zf.Name, OK: false, Output: "ошибка чтения: " + readErr.Error()})
+ hasError = true
+ continue
+ }
+
+ var res lintResult
+ res.File = zf.Name
+
+ // Записываем во временный файл в /dev/shm (RAM) если доступен, иначе os.TempDir()
+ tmpDir := "/dev/shm"
+ if _, statErr := os.Stat(tmpDir); statErr != nil {
+ tmpDir = ""
+ }
+ tmpf, tmpErr := os.CreateTemp(tmpDir, "fission-lint-*"+ext)
+ if tmpErr != nil {
+ res.OK = false
+ res.Output = "не удалось создать tmp: " + tmpErr.Error()
+ results = append(results, res)
+ hasError = true
+ continue
+ }
+ tmpName := tmpf.Name()
+ _, writeErr := tmpf.Write(content.Bytes())
+ tmpf.Close()
+
+ if writeErr != nil {
+ os.Remove(tmpName)
+ res.OK = false
+ res.Output = "ошибка записи tmp: " + writeErr.Error()
+ results = append(results, res)
+ hasError = true
+ continue
+ }
+
+ ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
+ args := append(cfg.cmd, tmpName) //nolint:gocritic — намеренно создаём новый слайс
+ //nolint:gosec — cmd содержит только захардкоженные команды из linters
+ out, lintErr := exec.CommandContext(ctx, args[0], args[1:]...).CombinedOutput()
+ cancel()
+ os.Remove(tmpName)
+
+ outStr := strings.TrimSpace(string(out))
+ // Скрываем путь к tmp-файлу от пользователя
+ outStr = strings.ReplaceAll(outStr, tmpName, zf.Name)
+
+ if lintErr == nil {
+ res.OK = true
+ } else {
+ res.OK = false
+ hasError = true
+ if outStr != "" {
+ res.Output = outStr
+ } else {
+ res.Output = fmt.Sprintf("ошибка синтаксиса (%v)", lintErr)
+ }
+ }
+ results = append(results, res)
+ }
+
+ if len(results) == 0 {
+ writeJSONError(w, http.StatusBadRequest, "архив не содержит файлов поддерживаемых языков (.py, .js, .rb, .php)")
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]any{
+ "ok": !hasError,
+ "results": results,
+ })
+}
diff --git a/console/internal/api/server.go b/console/internal/api/server.go
index 9d52534..46b42fe 100644
--- a/console/internal/api/server.go
+++ b/console/internal/api/server.go
@@ -156,6 +156,7 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/console/api/ns/status", auth(s.handleNSStatus))
mux.HandleFunc("/console/api/ns/debug", auth(s.handleNSDebug))
mux.HandleFunc("/console/api/ai/check", auth(s.handleAICheck))
+ mux.HandleFunc("/console/api/ai/lint-archive", auth(s.handleLintArchive))
// --- ai/ask feature (удалить строку чтобы выкосить роут) ---
mux.HandleFunc("/console/api/ai/ask", auth(s.handleAIAsk))
// --- end ai/ask feature ---
diff --git a/console/ui/index.html b/console/ui/index.html
index 88ee63a..744f6fa 100644
--- a/console/ui/index.html
+++ b/console/ui/index.html
@@ -100,7 +100,7 @@
NUBES
FISSION CONSOLE
- v1.3.36
+ v1.3.38
@@ -228,7 +228,7 @@
@@ -306,7 +306,7 @@
- v1.3.36
+ v1.3.38
diff --git a/console/ui/js/functions.js b/console/ui/js/functions.js
index e7d4d04..53e63ca 100644
--- a/console/ui/js/functions.js
+++ b/console/ui/js/functions.js
@@ -17,15 +17,62 @@ function setCodeMode(prefix, mode) {
if (btnArchive) btnArchive.style.opacity = isCode ? '0.5' : '1';
}
-// lintArchiveFile — заглушка линтера для архивов.
-// Реализация (распаковка + пофайловая проверка) добавляется позже.
+// lintArchiveFile — отправляет zip-архив на /console/api/ai/lint-archive,
+// показывает результат пофайлово в блоке {prefix}-ai-result рядом с кнопкой.
function lintArchiveFile(prefix) {
var input = document.getElementById(prefix + '-archive-file');
+ var resEl = document.getElementById(prefix + '-ai-result');
+ var btn = document.getElementById(prefix + '-lint-btn');
+ function showRes(text, bg, color) {
+ if (resEl) {
+ resEl.style.display = 'block';
+ resEl.style.background = bg;
+ resEl.style.color = color;
+ resEl.textContent = text;
+ }
+ }
if (!input || !input.files || !input.files[0]) {
- showStatus('Выберите .zip файл', 'err');
+ showRes('Выберите .zip файл', '#3a1a1a', '#f88');
return;
}
- showStatus('Линтинг архива — пока не реализован (скоро)', 'info');
+ var file = input.files[0];
+ if (file.size > 100 * 1024) {
+ showRes('Архив слишком большой: максимум 100 KB', '#3a1a1a', '#f88');
+ return;
+ }
+ if (btn) { btn.disabled = true; btn.textContent = '⏳ Проверяю…'; }
+ showRes('Запускаю линтер…', 'var(--bg-alt)', 'var(--fg)');
+ var fd = new FormData();
+ fd.append('archive', file);
+ fetch(API_BASE + '/ai/lint-archive', {
+ method: 'POST',
+ headers: authHeaders(),
+ body: fd
+ })
+ .then(function(r) { return r.json(); })
+ .then(function(data) {
+ if (btn) { btn.disabled = false; btn.textContent = '🔍 Проверка архива линтером'; }
+ if (data.error) {
+ showRes('Ошибка: ' + data.error, '#3a1a1a', '#f88');
+ return;
+ }
+ var results = data.results || [];
+ if (results.length === 0) {
+ showRes('Нет поддерживаемых файлов в архиве (.py, .js, .rb, .php)', '#3a2a00', '#ffa');
+ return;
+ }
+ var ok = results.filter(function(r) { return r.ok; }).length;
+ var fail = results.filter(function(r) { return !r.ok; }).length;
+ var lines = results.map(function(r) {
+ return (r.ok ? '✅ ' : '❌ ') + r.file + (r.output ? '\n ' + r.output : '');
+ });
+ var summary = ok + ' OK, ' + fail + ' ошибок\n\n' + lines.join('\n');
+ showRes(summary, fail > 0 ? '#3a1a1a' : '#1a3a1a', fail > 0 ? '#f88' : '#8f8');
+ })
+ .catch(function(e) {
+ if (btn) { btn.disabled = false; btn.textContent = '🔍 Проверка архива линтером'; }
+ showRes('Ошибка сети: ' + e.message, '#3a2a00', '#ffa');
+ });
}
function parseMethods(v) {
diff --git a/doc/descriptions/lint-archive-flow.md b/doc/descriptions/lint-archive-flow.md
new file mode 100644
index 0000000..8d113d7
--- /dev/null
+++ b/doc/descriptions/lint-archive-flow.md
@@ -0,0 +1,79 @@
+# Линтинг архива функции — flow (май 2026)
+
+## Назначение
+
+Пользователь может загрузить zip-архив с кодом функции.
+Перед сохранением или после загрузки — архив можно прогнать через линтер,
+чтобы убедиться что код синтаксически корректен.
+
+Линтинг выполняется **на сервере** (в Kubernetes), а не на машине пользователя.
+
+---
+
+## Endpoint
+
+```
+POST /console/api/ai/lint-archive
+Authorization: X-Auth-Token:
+Content-Type: multipart/form-data
+```
+
+Поле: `archive` — zip-файл.
+
+### Ограничения
+- Максимальный размер zip-архива: **100 KB**
+- Максимальный суммарный размер распакованных файлов: **100 KB** (защита от zip bomb)
+- При превышении — HTTP 413
+
+### Response (200 OK)
+```json
+{
+ "ok": true,
+ "results": [
+ {"file": "hello.py", "ok": true},
+ {"file": "bad.js", "ok": false, "output": "SyntaxError: Unexpected token (2:0)"}
+ ]
+}
+```
+
+### Response (ошибка)
+```json
+{"error": "архив слишком большой: максимум 100 KB"}
+```
+
+---
+
+## Поддерживаемые языки
+
+| Расширение | Язык | Линтер |
+|-----------|---------|---------------------------|
+| `.py` | Python | `python3 -m py_compile` |
+| `.js` | Node.js | `node --check` |
+| `.rb` | Ruby | `ruby -c` |
+| `.php` | PHP | `php -l` |
+
+Файлы с другими расширениями (`.json`, `.txt`, `.md`, `.yaml` и т.д.) — пропускаются.
+Если в архиве нет ни одного поддерживаемого файла — HTTP 400.
+
+---
+
+## Реализация (backend)
+
+Файл: `console/internal/api/lint_archive.go`
+
+1. Архив читается в память (`bytes.Buffer`), без записи на диск.
+2. zip открывается через `archive/zip.Reader` из буфера в памяти.
+3. Проверяется суммарный распакованный размер — защита от zip bomb.
+4. Каждый поддерживаемый файл читается из zip в память.
+5. Записывается во временный файл в **`/dev/shm`** (RAM-backed tmpfs), fallback — `/tmp`.
+6. Линтер запускается с таймаутом 10 секунд на файл.
+7. Временный файл удаляется сразу после линтинга.
+8. Путь к tmp-файлу скрыт от пользователя в выводе линтера.
+
+---
+
+## Что ещё нужно сделать
+
+- [ ] UI: кнопка "Проверить архив" рядом с полем загрузки архива в форме создания функции
+- [ ] Показывать результаты в UI (список файлов, статус, ошибки)
+- [ ] Тесты: `scripts/test_fn_archives.sh` — добавить вызов lint-archive endpoint
diff --git a/scripts/test_lint_archive.sh b/scripts/test_lint_archive.sh
new file mode 100755
index 0000000..97a6819
--- /dev/null
+++ b/scripts/test_lint_archive.sh
@@ -0,0 +1,151 @@
+#!/usr/bin/env bash
+# test_lint_archive.sh — тестирует POST /console/api/ai/lint-archive
+# Валидные архивы всех 4 языков → ok: true
+# Архив с синтаксической ошибкой → ok: false
+# Архив > 100 KB → HTTP 413
+# Zip bomb (маленький zip, большой распакованный) → HTTP 413
+# Архив без поддерживаемых файлов → HTTP 400
+# Без поля archive → HTTP 400
+set -uo pipefail
+
+ROOT_DIR=$(cd "$(dirname "$0")" && pwd)
+source "${ROOT_DIR}/lib.sh"
+
+SUB="livetest@test.local"
+ARCHIVES_DIR="${ROOT_DIR}/../test-archives"
+TMP=$(mktemp -d)
+
+cleanup() { rm -rf "$TMP"; }
+trap cleanup EXIT
+
+echo ""
+echo "========================================"
+echo " test_lint_archive.sh"
+echo "========================================"
+
+lint_post() {
+ local zip_path="$1"
+ curl -s -w "\n%{http_code}" -X POST "${BASE}/ai/lint-archive" \
+ -H "X-Auth-Token: ${SUB}" \
+ -F "archive=@${zip_path}"
+}
+
+# ── Вспомогательная функция: создать zip с одним файлом ─────────────────────
+
+make_zip() {
+ local name="$1" filename="$2" content="$3"
+ local dir="${TMP}/${name}"
+ mkdir -p "$dir"
+ printf '%s' "$content" > "${dir}/${filename}"
+ (cd "$dir" && zip -q "../${name}.zip" "$filename")
+ echo "${TMP}/${name}.zip"
+}
+
+# ── Python валидный ───────────────────────────────────────────────────────────
+
+echo ""
+echo ">>> Python: валидный архив → ok:true"
+R=$(lint_post "${ARCHIVES_DIR}/py-hello.zip")
+HTTP=$(echo "$R" | tail -1)
+BODY=$(echo "$R" | sed '$d')
+check_http "$HTTP" "200" "Python lint → 200"
+OK=$(json_field "$BODY" ok)
+[ "$OK" = "True" ] || [ "$OK" = "true" ] && pass "Python lint ok=true" || fail "Python lint ok=$OK"
+
+# ── Node.js валидный ─────────────────────────────────────────────────────────
+
+echo ""
+echo ">>> Node.js: валидный архив → ok:true"
+R=$(lint_post "${ARCHIVES_DIR}/node-hello.zip")
+HTTP=$(echo "$R" | tail -1)
+BODY=$(echo "$R" | sed '$d')
+check_http "$HTTP" "200" "Node.js lint → 200"
+OK=$(json_field "$BODY" ok)
+[ "$OK" = "True" ] || [ "$OK" = "true" ] && pass "Node.js lint ok=true" || fail "Node.js lint ok=$OK"
+
+# ── Ruby валидный ────────────────────────────────────────────────────────────
+
+echo ""
+echo ">>> Ruby: валидный архив → ok:true"
+R=$(lint_post "${ARCHIVES_DIR}/ruby-hello.zip")
+HTTP=$(echo "$R" | tail -1)
+BODY=$(echo "$R" | sed '$d')
+check_http "$HTTP" "200" "Ruby lint → 200"
+OK=$(json_field "$BODY" ok)
+[ "$OK" = "True" ] || [ "$OK" = "true" ] && pass "Ruby lint ok=true" || fail "Ruby lint ok=$OK"
+
+# ── PHP валидный ─────────────────────────────────────────────────────────────
+
+echo ""
+echo ">>> PHP: валидный архив → ok:true"
+R=$(lint_post "${ARCHIVES_DIR}/php-hello.zip")
+HTTP=$(echo "$R" | tail -1)
+BODY=$(echo "$R" | sed '$d')
+check_http "$HTTP" "200" "PHP lint → 200"
+OK=$(json_field "$BODY" ok)
+[ "$OK" = "True" ] || [ "$OK" = "true" ] && pass "PHP lint ok=true" || fail "PHP lint ok=$OK"
+
+# ── Синтаксическая ошибка Python ─────────────────────────────────────────────
+
+echo ""
+echo ">>> Python: синтаксическая ошибка → ok:false"
+BAD_PY=$(make_zip "bad-py" "bad.py" "def broken(:\n pass")
+R=$(lint_post "$BAD_PY")
+HTTP=$(echo "$R" | tail -1)
+BODY=$(echo "$R" | sed '$d')
+check_http "$HTTP" "200" "Python bad lint → 200"
+OK=$(json_field "$BODY" ok)
+[ "$OK" = "False" ] || [ "$OK" = "false" ] && pass "Python bad lint ok=false" || fail "Python bad lint ok=$OK"
+
+# ── Синтаксическая ошибка Node.js ────────────────────────────────────────────
+
+echo ""
+echo ">>> Node.js: синтаксическая ошибка → ok:false"
+BAD_JS=$(make_zip "bad-js" "bad.js" "module.exports = function( { return 'broken'; };")
+R=$(lint_post "$BAD_JS")
+HTTP=$(echo "$R" | tail -1)
+BODY=$(echo "$R" | sed '$d')
+check_http "$HTTP" "200" "Node.js bad lint → 200"
+OK=$(json_field "$BODY" ok)
+[ "$OK" = "False" ] || [ "$OK" = "false" ] && pass "Node.js bad lint ok=false" || fail "Node.js bad lint ok=$OK"
+
+# ── Архив > 100 KB → 413 ─────────────────────────────────────────────────────
+
+echo ""
+echo ">>> Архив > 100 KB → 413"
+BIG_ZIP="${TMP}/big.zip"
+# Создаём файл > 100 KB и пакуем
+dd if=/dev/urandom bs=1024 count=110 2>/dev/null | base64 > "${TMP}/big.txt"
+(cd "$TMP" && zip -q big.zip big.txt)
+R=$(lint_post "$BIG_ZIP")
+HTTP=$(echo "$R" | tail -1)
+check_http "$HTTP" "413" "Большой архив → 413"
+
+# ── Нет поддерживаемых файлов → 400 ─────────────────────────────────────────
+
+echo ""
+echo ">>> Архив без поддерживаемых файлов → 400"
+NO_CODE=$(make_zip "no-code" "README.md" "# Hello")
+R=$(lint_post "$NO_CODE")
+HTTP=$(echo "$R" | tail -1)
+check_http "$HTTP" "400" "Нет поддерживаемых файлов → 400"
+
+# ── Без поля archive → 400 ───────────────────────────────────────────────────
+
+echo ""
+echo ">>> Без поля archive → 400"
+R=$(curl -s -w "\n%{http_code}" -X POST "${BASE}/ai/lint-archive" \
+ -H "X-Auth-Token: ${SUB}")
+HTTP=$(echo "$R" | tail -1)
+check_http "$HTTP" "400" "Без поля archive → 400"
+
+# ── Невалидный zip (не zip-файл) → 400 ───────────────────────────────────────
+
+echo ""
+echo ">>> Невалидный zip (текстовый файл) → 400"
+echo "not a zip" > "${TMP}/fake.zip"
+R=$(lint_post "${TMP}/fake.zip")
+HTTP=$(echo "$R" | tail -1)
+check_http "$HTTP" "400" "Невалидный zip → 400"
+
+print_summary