From 1c533fc5779dfddd3016db83e9186fb49bb1b340 Mon Sep 17 00:00:00 2001 From: Naeel Date: Sun, 19 Apr 2026 16:52:22 +0300 Subject: [PATCH] fix: nodejs ESM wrapper via new Function, README for fission-console - Replace two-file zip (_userfn.cjs + main.js) with single-file approach - User code embedded via new Function(module, exports, code) for safe CJS isolation - Auto-adds status:200 when function omits it - Add fission-console technical README section (v0.6.7) --- README.md | 193 ++++++++++++++++++++++++++++++++++++++++++++++++ console/main.go | 79 +++++++++++++++++++- 2 files changed, 269 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b4f1e1c..b97a778 100644 --- a/README.md +++ b/README.md @@ -92,3 +92,196 @@ TF_CLI_CONFIG_FILE=/tmp/terraformrc-fission terraform apply -auto-approve Где: - `900` — число запросов на endpoint - `90` — параллелизм + +--- + +# fission-console + +Backend сервис для управления serverless-функциями поверх Fission. +Предоставляет REST API + embedded Web UI. Каждый пользователь изолирован в отдельном Kubernetes namespace. + +## Деплой + +| Параметр | Значение | +|---|---| +| Кластер | `kube5s.ru` | +| Namespace | `fission` | +| URL | `https://fission.kube5s.ru/console/` | +| Образ | `naeel/fission-console:` | +| Порт | `8090` | +| Текущая версия | `v0.6.7` | + +## Переменные окружения + +| Переменная | По умолчанию | Описание | +|---|---|---| +| `PORT` | `8090` | Порт HTTP-сервера | +| `FISSION_ROUTER_URL` | `http://router.fission.svc.cluster.local` | URL Fission Router для invoke | +| `FISSION_INVOKE_TIMEOUT` | `20s` | Таймаут вызова функции | +| `REAPER_INTERVAL` | `5m` | Интервал очистки истёкших TTL | +| `FISSION_TEST_MODE` | `false` | Тестовый режим — отключает JWT, включает `X-Test-Sub` | +| `FISSION_SYSTEM_NAMESPACE` | `fission` | Namespace самого Fission | +| `KUBECONFIG` | — | Путь к kubeconfig (если не in-cluster) | + +## Аутентификация + +**Production:** `Authorization: Bearer ` через `/console/api/auth`. + +**Test Mode** (`FISSION_TEST_MODE=true`): + +```bash +curl -H "X-Test-Sub: user@example.com" https://fission.kube5s.ru/console/api/functions +``` + +## Изоляция по namespace + +``` +sub → SHA256(sub)[:8] → namespace = "fission-{16 hex chars}" +``` + +При первом обращении namespace + RoleBinding-и создаются автоматически. Namespace регистрируется в `FISSION_RESOURCE_NAMESPACES` всех Fission deployments. + +## API + +Базовый путь: `/console/api` + +| Метод | Путь | Описание | +|---|---|---| +| `GET` | `/console/api/functions` | Список функций | +| `POST` | `/console/api/functions` | Создать функцию | +| `GET` | `/console/api/functions/{name}` | Получить функцию | +| `DELETE` | `/console/api/functions/{name}` | Удалить функцию | +| `PUT` | `/console/api/functions/{name}/code` | Обновить код | +| `POST` | `/console/api/functions/{name}/invoke` | Вызвать функцию | +| `GET` | `/console/api/environments` | Список environments | +| `GET` | `/console/api/packages` | Список packages | +| `GET` | `/console/api/httptriggers` | Список HTTP triggers | + +### Создать функцию + +```bash +curl -X POST https://fission.kube5s.ru/console/api/functions \ + -H "X-Test-Sub: user@example.com" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "hello", + "language": "nodejs", + "ttl": "1h", + "code": "module.exports = async function(ctx) { return { body: \"Hello!\" }; };" + }' +``` + +**Поля запроса:** + +| Поле | Обязательно | Описание | +|---|---|---| +| `name` | да | Имя функции | +| `language` | да* | Язык: `nodejs`, `python`, `go`, `php`, `ruby`, `perl` | +| `environment` | да* | Имя существующего Environment CRD | +| `code` | да | Исходный код | +| `ttl` | нет | Время жизни: `30m`, `1h`, `7d`, `2m` | +| `entrypoint` | нет | Точка входа (auto-detect по языку) | + +\* Одно из двух. + +**Ответ:** +```json +{ + "name": "hello", + "route": "/hello", + "httptrigger": "hello-route", + "package": "hello-pkg", + "expires_at": "2026-04-19T15:00:00Z" +} +``` + +### Вызвать функцию + +```bash +curl -X POST https://fission.kube5s.ru/console/api/functions/hello/invoke \ + -H "X-Test-Sub: user@example.com" \ + -H "Content-Type: application/json" \ + -d '{}' +``` + +**Ответ:** +```json +{ + "invoke_url": "http://router.fission.svc.cluster.local/hello", + "latency_ms": 268, + "status": 200, + "response_raw": "Hello!" +} +``` + +## Языки и формат кода + +### NodeJS (рекомендуется) + +```js +module.exports = async function(ctx) { + return { body: JSON.stringify({ ok: true }) }; +}; +``` + +- Node.js 22, ESM mode +- `status: 200` добавляется автоматически, если не указан +- Код изолируется через `new Function('module', 'exports', code)` +- Поддерживается `module.exports.handler`, `.main`, `.default` + +### Python + +```python +def main(): + return {"body": '{"ok": true}', "status": 200} +``` + +### Go + +```go +func Handler(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("Hello!")) +} +``` + +## TTL + +| Пример | Значение | +|---|---| +| `30m` | 30 минут | +| `1h` | 1 час | +| `7d` | 7 дней | +| `2m` | 2 минуты | + +По истечении TTL функция + package + httptrigger удаляются reaperом. Пустой environment удаляется вместе с последней функцией. + +## Health + +```bash +curl https://fission.kube5s.ru/health +curl https://fission.kube5s.ru/console/health +``` + +## Сборка образа + +```bash +cd console/ +go build . # проверка компиляции +docker build -t naeel/fission-console:vX.Y.Z . +docker push naeel/fission-console:vX.Y.Z +kubectl set image deployment/fission-console console=naeel/fission-console:vX.Y.Z -n fission +``` + +## Changelog + +### v0.6.7 +- NodeJS: однофайловый zip (`main.js` с `new Function` wrapper) +- NodeJS: автоматический `status: 200` если функция не возвращает его + +### v0.6.6 +- NodeJS: упаковка кода в zip (ESM mode требует `.js`) +- NodeJS: исправлена точка входа (`main.main` → `main`) +- Namespace isolation: SHA256(sub)[:8] +- TTL + expiry reaper +- TEST_MODE +- Lazy Environment creation / cleanup diff --git a/console/main.go b/console/main.go index a16fa5e..14a9b18 100644 --- a/console/main.go +++ b/console/main.go @@ -311,7 +311,7 @@ func (s *server) handleCreateFunction(w http.ResponseWriter, r *http.Request) { return } if req.Entrypoint == "" { - req.Entrypoint = "main.main" + req.Entrypoint = defaultEntrypoint(req.Language) } if req.Route == "" { req.Route = "/" + req.Name @@ -358,7 +358,18 @@ func (s *server) handleCreateFunction(w http.ResponseWriter, r *http.Request) { "buildcommand": "build", } } else { - literal := base64.StdEncoding.EncodeToString([]byte(req.Code)) + var deployBytes []byte + if req.Language == "nodejs" { + zipBytes, zipErr := buildJSDeployZip(req.Code) + if zipErr != nil { + writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build nodejs archive: %v", zipErr)) + return + } + deployBytes = zipBytes + } else { + deployBytes = []byte(req.Code) + } + literal := base64.StdEncoding.EncodeToString(deployBytes) pkgSpec = map[string]any{ "deployment": map[string]any{ "type": "literal", @@ -390,6 +401,7 @@ func (s *server) handleCreateFunction(w http.ResponseWriter, r *http.Request) { // Парсим TTL — если указан, записываем аннотацию на Function CRD. // reaper периодически читает эту аннотацию и удаляет протухшие функции + чистит environment если он больше не используется. fnAnnotations := map[string]any{} + fnAnnotations["fission-console/language"] = req.Language if req.TTL != "" { expiresAt, ttlErr := parseTTL(req.TTL) if ttlErr != nil { @@ -669,6 +681,55 @@ func (s *server) buildGoSourceZip(code string) ([]byte, error) { return buf.Bytes(), nil } +func buildJSDeployZip(code string) ([]byte, error) { + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + + // main.js — единственный файл: ESM wrapper + код пользователя инлайн через new Function + // new Function безопасно изолирует module/exports от глобального контекста + codeJSON, err := json.Marshal(code) + if err != nil { + return nil, fmt.Errorf("marshal user code: %w", err) + } + wrapper := fmt.Sprintf(`const __mod = { exports: {} }; +(new Function('module', 'exports', %s))(__mod, __mod.exports); +const _fn = __mod.exports; + +export default async function(ctx) { + const fn = typeof _fn === 'function' ? _fn : (_fn.default || _fn.handler || _fn.main); + if (!fn) throw new Error('no exported function found in user code'); + const result = await fn(ctx); + if (!result) return { status: 200, body: '' }; + if (typeof result.status !== 'undefined') return result; + return { status: 200, ...result }; +} +`, string(codeJSON)) + + fw, err := zw.Create("main.js") + if err != nil { + return nil, err + } + if _, err := fw.Write([]byte(wrapper)); err != nil { + return nil, err + } + + if err := zw.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func defaultEntrypoint(lang string) string { + switch lang { + case "nodejs", "php", "ruby", "perl": + return "main" + case "go": + return "Handler" + default: + return "main.main" + } +} + func (s *server) handleGetFunction(w http.ResponseWriter, r *http.Request, name string) { ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() @@ -764,7 +825,19 @@ func (s *server) handleUpdateFunctionCode(w http.ResponseWriter, r *http.Request return } - literal := base64.StdEncoding.EncodeToString([]byte(req.Code)) + lang, _, _ := unstructured.NestedString(fn.Object, "metadata", "annotations", "fission-console/language") + var deployBytes []byte + if lang == "nodejs" { + zipBytes, zipErr := buildJSDeployZip(req.Code) + if zipErr != nil { + writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build nodejs archive: %v", zipErr)) + return + } + deployBytes = zipBytes + } else { + deployBytes = []byte(req.Code) + } + literal := base64.StdEncoding.EncodeToString(deployBytes) if err := unstructured.SetNestedField(pkg.Object, literal, "spec", "deployment", "literal"); err != nil { writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("set package literal: %v", err)) return