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)
This commit is contained in:
Naeel
2026-04-19 16:52:22 +03:00
parent 551a5b2f8e
commit 1c533fc577
2 changed files with 269 additions and 3 deletions
+193
View File
@@ -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:<version>` |
| Порт | `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 <jwt>` через `/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
+76 -3
View File
@@ -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