Compare commits

..
Author SHA1 Message Date
Naeel 7fccd39a73 feat: v0.8.14 — init overlay, /ns/status endpoint, token hint 2026-04-25 16:40:27 +03:00
Naeel b46921708c feat: use naeel/go-builder-fast:v1 with prebuilt stdlib, deploy v0.8.13 2026-04-25 15:18:01 +03:00
Naeel 42b239bb51 feat: custom go-builder-fast with prebuilt stdlib for plugin mode 2026-04-25 15:14:37 +03:00
Naeel 3b9c6e2c5e fix: semaphore+singleflight for ensureUserNS, fix 504 on 10 parallel new users (v0.8.12) 2026-04-25 14:41:35 +03:00
Naeel ca43d0a7d7 fix(console): assistant greeting -> Чем могу помочь v0.8.4 2026-04-20 15:23:15 +03:00
Naeel fbf08402db feat(console): generate prompt input before generating code v0.8.3 2026-04-20 15:21:26 +03:00
Naeel 48f787403c feat(console): wider assistant, generate+explain buttons v0.8.2 2026-04-20 15:17:17 +03:00
Naeel d76aa53f73 fix(console): testMode auth — X-Auth-Token accepted as sub v0.8.1 2026-04-20 15:09:49 +03:00
Naeel 9997dce2bb feat(console): assistant answers any question v0.8.0 2026-04-20 15:01:37 +03:00
Naeel 80c05e57fd feat(console): add AI assistant panel /api/ai/ask v0.7.9 2026-04-20 14:45:00 +03:00
Naeel 411de75b27 feat(console): replace LLM with real linters (node/python/ruby/php/perl/go) v0.7.8 2026-04-20 14:35:58 +03:00
Naeel 6f0101f656 feat: LLM syntax check via /console/api/ai/check
- Server-side proxy to LLM API (FISSION_LLM_KEY, FISSION_LLM_URL env vars)
- Button in create-modal and edit-modal: check syntax before saving
- Result shown inline below code textarea (green=ok, red=errors)
2026-04-20 14:06:51 +03:00
Naeel ec23ddc0a6 feat: add fission_simple_function composite resource 2026-04-20 13:35:57 +03:00
Naeel 01df1498d6 feat: big-suite terraform example + provider nodejs zip fix
- examples/big-suite: E-Commerce 10 functions, 6 layers, real depends_on
- provider: nodejs packages now wrapped in ESM zip (buildJSDeployZip)
- provider: loadPackageLiteral reads raw file, nodejs zip via source_dir
- all 10 functions verified working: python/node/ruby/go runtimes
2026-04-20 11:38:45 +03:00
Naeel 4837d42ba1 test: add block AE — Go language invoke test 2026-04-20 04:54:56 +03:00
Naeel 9eeda9b175 doc: report 2026-04-20, v0.7.6 bugs and fixes 2026-04-20 04:46:01 +03:00
Naeel 6a3bf7c253 test: блоки U-Z — multi-user concurrency, isolation, cross-user attacks, full cycle (PASS=63/FAIL=0) 2026-04-19 19:49:27 +03:00
Naeel 85a5a9c2cc fix: v0.7.2 — name limit 57 chars (HTTPTrigger -route suffix = 63), tests A-T PASS=54/FAIL=0 2026-04-19 18:41:27 +03:00
Naeel 0545fb3aa3 test: add comprehensive test suite v2 (41 checks, blocks A-P) 2026-04-19 17:49:56 +03:00
Naeel 74815facce fix: route isolation, 404 on invoke/delete nonexistent (v0.6.8)
- Bug#1: namespace-prefix routes to avoid cross-user collision
  Route format: /{ns-last-12-chars}/{fn-name}
- Bug#2: INVOKE nonexistent function -> HTTP 404 (was 200)
- Bug#3: DELETE nonexistent function -> HTTP 404 (was 200)
2026-04-19 17:10:01 +03:00
Naeel 1c533fc577 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)
2026-04-19 16:52:22 +03:00
Naeel 551a5b2f8e fix: RBAC bind cluster-admin + list namespaces + TEST_MODE multi-tenant test 2026-04-19 15:41:11 +03:00
Naeel 9ebd36eed3 feat: lazy env creation + TTL functions + expiry reaper 2026-04-19 15:05:12 +03:00
Naeel 36772f8e70 docs: add detailed comments to addNSToFission and ensureUserNamespace RBAC block 2026-04-19 14:42:18 +03:00
Naeel 8a520cdcc2 feat: Fission SA RoleBindings in user namespace (cluster-admin) + v0.6.2 2026-04-19 14:40:03 +03:00
Naeel e541ba6996 feat: RBAC ClusterRole for console namespace management 2026-04-19 09:55:55 +03:00
Naeel 8aad0cec58 feat: namespace isolation per user JWT sub claim
- Auth middleware extracts user namespace from JWT sub (SHA256 → hex8)
- All K8s operations use per-user fission-{hex} namespace
- handleAuth calls ensureUserNamespace to create NS + shared envs
- handleAuth returns namespace in response
- buildLangEnvironment accepts ns string parameter
- Fallback to server default NS if JWT parse fails
2026-04-19 09:54:57 +03:00
Naeel 50ba09e1b6 Merge feat/console-auth: auth через deck API, login overlay, logout 2026-04-19 09:43:55 +03:00
Naeel 903610bf71 chore: copilot instructions update 2026-04-19 09:43:55 +03:00
Naeel 0a1708e682 doc: тест auth v0.5.0 (2026-04-19) 2026-04-19 08:50:34 +03:00
Naeel 579bf97e17 console v0.5.0: auth через deck API, login overlay, logout 2026-04-19 08:25:23 +03:00
Naeel c40fc47025 console v0.4.5: русификация UI, Go/TF предупреждения, handler.go в preferred 2026-04-15 19:47:41 +03:00
Naeel fd236d87ea feat: provider audit — builder support, function tuning, source archive
Аудит провайдера vs каноничный Fission. Добавлено:

Environment:
- builder_image, builder_command для Go и языков с build step

Package:
- deploy_type (literal/source) для переключения deployment/source archive
- loadPackageSourceArchive() — zip-упаковка source_dir
- Убран пустой source:{} из literal mode

Function:
- executor_type (poolmgr/newdeploy/container)
- function_timeout, idle_timeout
- min_scale, max_scale для ExecutionStrategy

Все 21 тест пройден. Обратная совместимость проверена.
Документация: doc/AUDIT_PROVIDER_VS_FISSION_2026-06-03.md
2026-04-15 17:46:47 +03:00
Naeel 6d66e5b566 feat: multi-language support — PHP, Ruby, Perl, Go (binary-env)
- PHP: ghcr.io/fission/php-env, entrypoint main.php::handler
- Ruby: ghcr.io/fission/ruby-env, entrypoint handler (single-word)
- Perl: ghcr.io/fission/perl-env (version=1), sub {} return value
- Go: ghcr.io/fission/binary-env with pre-compiled binary (shell script placeholder)

Provider changes:
- package_resource: added main.php, main.rb, main.pl to source_dir candidates
- function_resource: relaxed entrypoint validation to allow PHP (::), Ruby, Perl (single-word)
- function_resource_test: updated BadFormat test to reflect new valid formats
2026-04-15 17:01:40 +03:00
Naeel c78b539d24 doc: plan for multi-language support (Go, Java, PHP, Ruby, .NET, Perl) 2026-04-15 16:31:34 +03:00
Naeel cccc5ca024 revert: remove Go function (runtime compilation timeout) 2026-04-15 16:24:57 +03:00
Naeel 66404fde3a add: Go function example (tf-go-hello-fn) 2026-04-15 16:19:25 +03:00
Naeel 4964c8f357 console: fix favicon — full base64 data URI (v0.3.4) 2026-04-15 15:20:27 +03:00
Naeel 7e23a71574 console: embed favicon as data URI (v0.3.3) 2026-04-15 15:15:21 +03:00
Naeel ab2eff69a0 ui: use terra.k8c.ru favicon (same as qu.kube5s.ru/ui/) 2026-04-15 15:08:58 +03:00
Naeel a199745c77 fix: restore 15 functions and roll console favicon image 2026-04-15 15:05:27 +03:00
Naeel ba32389600 ui: use nubes favicon url 2026-04-15 14:51:10 +03:00
Naeel 8ac8428a76 docs: record strict cleanup to 6 verified functions 2026-04-15 14:43:58 +03:00
Naeel ba50d18f8f fix(console): sync function package resourceversion after code update 2026-04-15 11:14:42 +03:00
Naeel d9a6d2f29e fix(console): decode zip package literal before showing code in UI 2026-04-15 11:00:00 +03:00
Naeel 1b8bff8364 fix(provider): detect source code drift and validate package/function references 2026-04-15 10:51:40 +03:00
Naeel e2b6a4472e doc: add bugs #5, #6 (foreign key, entrypoint validation) 2026-04-15 10:43:05 +03:00
Naeel ac2d2a1288 doc: comprehensive bug report from adversarial testing 2026-04-15 10:40:32 +03:00
111 changed files with 11531 additions and 418 deletions
+11 -220
View File
@@ -1,223 +1,14 @@
# Правила работы агента в проекте fission # Правила
## ГЛАВНОЕ ПРАВИЛО ## ⛔ ОТВЕЧАТЬ КРАТКО — АБСОЛЮТНОЕ ПРАВИЛО
- Вопрос → короткий ответ → СТОП. Не рассуждать, не исследовать без команды.
- НЕ делать ничего "попутно" без явной просьбы. Сделал — стоп — ждёшь команды.
**НЕ "СОВЕРШЕНСТВОВАТЬ" РАБОЧИЙ КОД БЕЗ ЯВНОГО УКАЗАНИЯ.** > Подробные правила: [`.github/pravila.md`](pravila.md)
--- 1. Не трогать рабочий код без явного указания.
2. Файлы редактировать локально — `~/remote_dev/` = `~/terra/` на ВМ (sshfs), SCP не нужен.
## Файловая система и выполнение команд 3. Все команды — **только через SSH**, никогда локально:
```bash
### Маппинг путей ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 'КОМАНДА'
```
Локальная папка `~/remote_dev/` примонтирована через sshfs к `~/terra/` на ВМ `5.172.178.213`.
Это **одна и та же файловая система**:
| Локально (VS Code) | На ВМ |
|----------------------------------|-------------------------------|
| `/home/naeel/remote_dev/fission` | `/home/naeel/terra/fission` |
| `/home/naeel/remote_dev/sless` | `/home/naeel/terra/sless` |
| `/home/naeel/remote_dev/IoT` | `/home/naeel/terra/IoT` |
Любой файл, сохранённый локально через VS Code, **мгновенно виден на ВМ**. SCP не нужен.
### Что можно делать локально
- **Редактировать файлы** — через VS Code, replace_string_in_file, create_file и т.д. Изменения сразу на ВМ.
- **Читать файлы** — read_file, grep_search, file_search, semantic_search — всё разрешено.
### Что ЗАПРЕЩЕНО локально
**Все команды выполнять ИСКЛЮЧИТЕЛЬНО через SSH на ВМ:**
```bash
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 'КОМАНДА'
```
**Причина:** локально может быть активен VPN, что вызывает сбои при выполнении сетевых команд, обращениях к кластеру, Docker и пр.
Запрещено запускать локально (без ssh):
- `go build`, `go test`, `go mod tidy`
- `docker build`, `docker push`
- `kubectl`, `helm`
- `terraform`
- `curl`, `wget` к кластерным сервисам
- `git push`, `git pull` (git операции — через ssh на ВМ)
- Любые bash-скрипты проекта
### Формат работы
1. Отредактировать файлы локально (VS Code / инструменты агента)
2. Запустить команды через SSH на ВМ
3. Ждать результата
---
## Документация
Всё важное фиксировать в `doc/`:
- `doc/thinking/` — лог рассуждений агента (обязательно)
- `doc/progress.md` — трекер задач
- Обновлять после каждого значимого изменения.
## Git
Коммитить и пушить после каждого завершённого этапа.
### Версионирование тегами
После каждого коммита с изменениями — поднимать версию тега:
```bash
# Посмотреть текущий тег
git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0"
# Поднять patch-версию (v0.1.0 → v0.1.1, v0.1.1 → v0.1.2 и т.д.)
git tag vX.Y.Z && git push origin vX.Y.Z
```
**Правила:**
- Patch (Z) — любое изменение: правка кода, новая функция, фикс
- Minor (Y) — новая фича/компонент (новый ресурс провайдера, новый модуль консоли)
- Major (X) — breaking change
- Тег ставить ПОСЛЕ успешного коммита и пуша
- Формат: `vMAJOR.MINOR.PATCH` (например `v0.2.3`)
---
## Как создать и задеплоить новую Fission-функцию через Terraform
### Пошаговая инструкция (ОБЯЗАТЕЛЬНО следовать)
#### Шаг 1: Создать папку с функцией
Создать папку в `examples/` по шаблону. Пример: `examples/my-test/`
Структура:
```
examples/my-test/
├── code/
│ └── main.py # Код функции
└── main.tf # Terraform манифест
```
#### Шаг 2: Написать код функции
Файл `code/main.py` — обязательно функция `main()`:
```python
def main():
return "hello from my-test"
```
Можно сложнее — с аргументами, JSON, вычислениями:
```python
import json
def main():
result = {"status": "ok", "data": [1, 2, 3]}
return json.dumps(result)
```
#### Шаг 3: Написать Terraform манифест
Файл `main.tf` — КОПИРОВАТЬ этот шаблон и менять только имена и URL:
```hcl
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.1.0"
}
}
}
provider "fission" {
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "default"
}
resource "fission_environment" "python" {
name = "УНИКАЛЬНОЕ-ИМЯ-env"
image = "ghcr.io/fission/python-env"
version = 3
}
resource "fission_package" "pkg" {
name = "УНИКАЛЬНОЕ-ИМЯ-pkg"
environment = fission_environment.python.name
source_dir = "${path.module}/code"
}
resource "fission_function" "fn" {
name = "УНИКАЛЬНОЕ-ИМЯ-fn"
environment = fission_environment.python.name
package_name = fission_package.pkg.name
entrypoint = "main.main"
}
resource "fission_http_trigger" "route" {
name = "УНИКАЛЬНОЕ-ИМЯ-route"
function = fission_function.fn.name
url = "/УНИКАЛЬНЫЙ-URL"
methods = ["GET"]
}
```
**ВАЖНО:** Все имена ресурсов (name) должны быть **уникальными** по всему кластеру.
Используй префикс, например `tf-mytest-env`, `tf-mytest-pkg`, `tf-mytest-fn`, `tf-mytest-route`.
#### Шаг 4: Задеплоить через SSH
```bash
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 \
'cd ~/terra/fission/examples/my-test && TF_CLI_CONFIG_FILE=/tmp/terraformrc-fission terraform apply -auto-approve'
```
**ОБЯЗАТЕЛЬНО:**
- Переменная `TF_CLI_CONFIG_FILE=/tmp/terraformrc-fission` — ВСЕГДА нужна (dev override провайдера)
- `terraform init` НЕ нужен (dev_overrides пропускает init)
- Выполнять ТОЛЬКО через SSH
#### Шаг 5: Проверить функцию через curl
Роутер требует JWT. Паттерн:
```bash
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 '
PASSWORD=$(kubectl -n fission get secret router -o jsonpath={.data.password} | base64 -d)
TOKEN=$(curl -sk -X POST https://fission.kube5s.ru/auth/login \
-H "Content-Type: application/json" \
-d "{\"username\":\"admin\",\"password\":\"$PASSWORD\"}" \
| python3 -c "import sys,json; print(json.load(sys.stdin)[\"accesstoken\"])")
curl -sk -H "Authorization: Bearer $TOKEN" https://fission.kube5s.ru/УНИКАЛЬНЫЙ-URL
'
```
#### Шаг 6: Удалить (если нужно)
```bash
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 \
'cd ~/terra/fission/examples/my-test && TF_CLI_CONFIG_FILE=/tmp/terraformrc-fission terraform destroy -auto-approve'
```
### Уже задеплоенные функции (НЕ ТРОГАТЬ без явного указания)
| Папка | Endpoint | Ответ |
|-------|----------|-------|
| `examples/hello-python/` | `GET /tf-hello` | `hello from fission via terraform` |
| `examples/stress-suite/` | `GET /stress/fast` | `fast-ok` |
| `examples/stress-suite/` | `GET /stress/cpu` | `cpu-ok:NUMBER` |
| `examples/stress-suite/` | `GET /stress/json` | `{"status":"ok","service":"json"}` |
### Частые ошибки
| Ошибка | Причина | Решение |
|--------|---------|---------|
| `unauthorized: malformed token` | curl без JWT | Добавить JWT (см. шаг 5) |
| `terraform init required` | Забыт `TF_CLI_CONFIG_FILE` | Добавить `TF_CLI_CONFIG_FILE=/tmp/terraformrc-fission` |
| `resource already exists` | Имя не уникальное | Сменить имена на уникальные с префиксом |
| `Error: connection refused` | Команда запущена локально | Выполнять ТОЛЬКО через SSH |
| `package not found` | Опечатка в `source_dir` | Проверить path.module/code и файл main.py |
+56
View File
@@ -0,0 +1,56 @@
# Правила работы агента
## Файловая система
`~/remote_dev/` (локально) примонтирован через sshfs к `~/terra/` на ВМ — **одна ФС**.
Файлы, сохранённые локально, мгновенно видны на ВМ. SCP не нужен.
Монтирование может слетать. Признак: файлы рассинхронизированы.
```bash
# Размонтировать
fusermount -u ~/remote_dev
# Если завис: sudo umount -l /home/naeel/remote_dev
# Примонтировать
sshfs naeel@5.172.178.213:/home/naeel/terra ~/remote_dev \
-o cache=no -o no_readahead -o reconnect \
-o ServerAliveInterval=15 -o ServerAliveCountMax=3 \
-o IdentityFile=~/.ssh/naeel_vm_id_ed25519
```
## SSH
Все команды — только через SSH на ВМ. Локально — только читать и редактировать файлы.
```bash
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 'КОМАНДА'
```
Запрещено локально: `go`, `docker`, `kubectl`, `helm`, `terraform`, `curl/wget`, `git push/pull`, любые скрипты проекта.
## Документация
- `doc/thinking/` — лог рассуждений агента (обязательно)
- `doc/progress.md` — трекер задач
- Старые файлы `doc/` не перезаписывать — новое в новых файлах с датой
## Git
Коммитить и пушить через SSH после каждого завершённого этапа.
Версионирование тегами: `vMAJOR.MINOR.PATCH`
- Patch — любое изменение кода
- Minor — новая фича / компонент
- Major — breaking change
```bash
git tag vX.Y.Z && git push origin vX.Y.Z
```
## Поведение агента
- Не трогать рабочий код без явного указания
- Не делать ничего сверх того, о чём явно попросили
- Деструктивные операции (`kubectl delete`, `rm -rf`, `terraform destroy` и др.) — только после явного подтверждения с указанием конкретных объектов
- Отвечать кратко, без вступлений, извинений, благодарностей и прочей воды
+4
View File
@@ -8,6 +8,10 @@ bin/
*.test *.test
*.out *.out
# Compiled function binaries (generated, not versioned)
examples/*/dist/
# Provider binaries # Provider binaries
terraform-provider-fission terraform-provider-fission
terraform-provider-fission_* terraform-provider-fission_*
console/fission-console
+193
View File
@@ -92,3 +92,196 @@ TF_CLI_CONFIG_FILE=/tmp/terraformrc-fission terraform apply -auto-approve
Где: Где:
- `900` — число запросов на endpoint - `900` — число запросов на endpoint
- `90` — параллелизм - `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
+1106
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -6,7 +6,7 @@ COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o fission-console . RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o fission-console .
FROM alpine:3.20 FROM alpine:3.20
RUN apk add --no-cache ca-certificates RUN apk add --no-cache ca-certificates nodejs python3 ruby perl php83
COPY --from=builder /build/fission-console /fission-console COPY --from=builder /build/fission-console /fission-console
EXPOSE 8090 EXPOSE 8090
ENTRYPOINT ["/fission-console"] ENTRYPOINT ["/fission-console"]
+5 -1
View File
@@ -46,7 +46,7 @@ spec:
serviceAccountName: fission-console serviceAccountName: fission-console
containers: containers:
- name: console - name: console
image: naeel/fission-console:v0.2.3 image: naeel/fission-console:v0.8.14
ports: ports:
- containerPort: 8090 - containerPort: 8090
env: env:
@@ -56,6 +56,10 @@ spec:
value: "http://router.fission.svc.cluster.local" value: "http://router.fission.svc.cluster.local"
- name: PORT - name: PORT
value: "8090" value: "8090"
- name: FISSION_HTTP_TIMEOUT
value: "30s"
- name: FISSION_INVOKE_TIMEOUT
value: "20s"
- name: FISSION_AUTH_USERNAME - name: FISSION_AUTH_USERNAME
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
+1799 -77
View File
File diff suppressed because it is too large Load Diff
+71
View File
@@ -142,6 +142,77 @@ func TestUpdateFunctionCode(t *testing.T) {
} }
} }
func TestGetFunctionUsesSourceLiteralWhenDeploymentLiteralMissing(t *testing.T) {
env := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "Environment",
"metadata": map[string]any{"name": "go-acc", "namespace": "default"},
}}
s := newTestServer(env)
pkg := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "Package",
"metadata": map[string]any{
"name": "fn-go-acc-pkg",
"namespace": "default",
},
"spec": map[string]any{
"source": map[string]any{
"literal": base64.StdEncoding.EncodeToString([]byte("package main\n\nfunc Handler() {}\n")),
},
"deployment": map[string]any{
"type": "url",
"url": "http://storagesvc.fission/v1/archive?id=dummy",
},
},
}}
fn := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "Function",
"metadata": map[string]any{
"name": "fn-go-acc",
"namespace": "default",
},
"spec": map[string]any{
"environment": map[string]any{"name": "go-acc", "namespace": "default"},
"package": map[string]any{
"functionName": "Handler",
"packageref": map[string]any{
"name": "fn-go-acc-pkg",
"namespace": "default",
},
},
},
}}
if _, err := s.dyn.Resource(packageGVR).Namespace("default").Create(context.Background(), pkg, metav1.CreateOptions{}); err != nil {
t.Fatalf("create package: %v", err)
}
if _, err := s.dyn.Resource(functionGVR).Namespace("default").Create(context.Background(), fn, metav1.CreateOptions{}); err != nil {
t.Fatalf("create function: %v", err)
}
getReq := httptest.NewRequest(http.MethodGet, "/api/functions/fn-go-acc", nil)
getRec := httptest.NewRecorder()
s.handleGetFunction(getRec, getReq, "fn-go-acc")
if getRec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", getRec.Code, getRec.Body.String())
}
var out map[string]any
if err := json.Unmarshal(getRec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode get response: %v", err)
}
code, _ := out["code"].(string)
if !strings.Contains(code, "func Handler") {
t.Fatalf("expected source code from spec.source.literal, got %q", code)
}
}
func TestInvokeFunctionWithJWTAuth(t *testing.T) { func TestInvokeFunctionWithJWTAuth(t *testing.T) {
// Mock router: /auth/login returns JWT, /inv-fn returns hello // Mock router: /auth/login returns JWT, /inv-fn returns hello
var gotAuth string var gotAuth string
+520 -57
View File
@@ -3,7 +3,13 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Fission Console</title> <title>NUBES Fission Console</title>
<link rel="icon" type="image/png" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAACXBIWXMAAA7DAAAOwwHHb6hkAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAA4ZJREFUeJztm8+Lm0UYxz/fySZUoWIrVtbmXfyBP05Fa3e3rogsglJRK0p7E2svnrz6B3gQ8eRJFooXET14KKJ4FUQo3UQQ9CRC6W4WQW0VXZduTd7HQ9c22bzbTCY/ZpO8n0syb56Z+c437zPvhMzIzJhkXGwBsckNiC0gNrkBsQXEZsonSJLj4GNPgb2ACo9j9gDOvrSV6qkB6+sJ6WSJ5MIxUo4h5sDuI9U7tlZ57/+YTAN0zyOPUi+9jOxJxMOUj9wJOBBg117QvqGMwhNpcQ/J+rOkPIc0j+xeytyGsaWXa2+kvc312gxQMleHYgFtrQ9GYJmgmSfup3z15xuDNW/dGXOAjd680NgshlYdvcH2mfEwQIU0tOp4GNADuQGxBcRmPAyQgh/W42FAD+QGxBYQm/EwIF8HhJMbEFtAX3DreQqEkhsQW0BsxsMAlz8Gg8kNiC0gNuNhgJvK54BQcgNiC+gLhUlPgcuTbkAP5AbEFhCbcAOM4LyLitIW3eEGyP7qWUwMHFdaixNO1v4Av5pG8H/yfWfvesk7tq5GczHDAHnmtu727nTQbJYe8o51+qOl2BYgNv1a0iHvTgeNGq94x6buYnOx3YCU3/1asn06ePQZ744HiXTCO3a6/m1zsd0Ax/f+HTc+kU76598AUDL/LpjfjjWxYdXqRvOljDvAfezfO3dQvvCDtLjHu04f0czsaSx9y7uC8eP2S20GWO38Z+A7DwDwIOX135TMvylpKI9VJQv7lcx+hfEhurELsCPOfdDWVtZ5ASWzZ4GXArQ1QJfA/kTW6BhtLgX+7qL9IqTTmKa7GjgAumKry7dsv5q9Vfbfq29QLL1I9wulAtgB4ADmoy9kY4fodugAODuT2dpOJ0aUzH8E6asBXe0+zP5h7bvbzay+/aOdv+Ha8mnE5YEKGxqF17MGDzcxwMzqTBUWgc65vKuxT7cm9kx2TIHrATOzxzHOEpZ5sanYamXuZgEdJzlbqXyOmzrO6N0J56hVj3YK8prl7eK5L6g3DmFc6l3XEDCWbLWyYGYdf9h1TIGWYMmRHDlDaqcY0qKnO/Qr4oStLH/jXSPk4KSShf2ovoTZ80Db4iICv2D2ttWqS91WDDKgpYHy4adR8TWww8BdmN2KrOg5Zwoo+PV0">
<script>
if (window.location.protocol !== 'https:' && window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1') {
window.location.replace('https://' + window.location.host + window.location.pathname + window.location.search + window.location.hash);
}
</script>
<style> <style>
:root { :root {
--bg-page: #001120; --bg-page: #001120;
@@ -39,6 +45,45 @@
letter-spacing: .3px; letter-spacing: .3px;
} }
.brand {
display: flex;
align-items: center;
gap: 10px;
}
.brand-mark {
width: 30px;
height: 30px;
border-radius: 8px;
background: linear-gradient(135deg, #009dff 0%, #0055d4 100%);
color: #fff;
display: grid;
place-items: center;
font-size: 14px;
font-weight: 800;
letter-spacing: .02em;
box-shadow: 0 6px 18px rgba(0, 125, 255, .35);
}
.brand-text {
display: flex;
flex-direction: column;
line-height: 1.08;
}
.brand-text .nubes {
font-size: 12px;
letter-spacing: .22em;
font-weight: 800;
color: #8bc7ff;
}
.brand-text .product {
font-size: 13px;
font-weight: 700;
letter-spacing: .04em;
}
.btn { .btn {
background: var(--accent); background: var(--accent);
color: #fff; color: #fff;
@@ -242,31 +287,76 @@
</style> </style>
</head> </head>
<body> <body>
<div id="login-overlay" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,.85); z-index:999; align-items:center; justify-content:center; padding:16px;">
<div class="panel" style="width:min(440px,100%);">
<div class="brand" style="margin-bottom:24px;">
<div class="brand-mark">N</div>
<div class="brand-text">
<div class="nubes">NUBES</div>
<div class="product">FISSION CONSOLE</div>
</div>
</div>
<div class="row" style="flex-direction:column; gap:6px; margin-bottom:12px;">
<label style="font-size:12px; color:#999;">Стенд</label>
<select id="l-env" style="background:var(--bg-base); border:1px solid var(--border); color:var(--fg); padding:8px 10px; border-radius:6px;">
<option value="dev">Dev</option>
<option value="test" selected>Test</option>
<option value="prod">Prod</option>
</select>
</div>
<div class="row" style="flex-direction:column; gap:6px; margin-bottom:12px;">
<label style="font-size:12px; color:#999;">Токен</label>
<textarea id="l-token" rows="5" style="background:var(--bg-base); border:1px solid var(--border); color:var(--fg); padding:8px 10px; border-radius:6px; font-family:monospace; font-size:12px; resize:vertical; width:100%; box-sizing:border-box;" placeholder="Введите токен..."></textarea>
<div style="font-size:11px; color:var(--text-secondary); margin-top:4px;">Токен: <strong style="color:#8bc7ff;">Личный кабинет → Профиль пользователя → Токены</strong></div>
</div>
<div id="l-error" style="display:none; color:#ff6b6b; font-size:13px; margin-bottom:10px;"></div>
<button id="l-btn" class="btn" style="width:100%;" onclick="doLogin()">Войти</button>
</div>
</div>
<!-- Overlay: инициализация нового окружения -->
<div id="init-overlay" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,.88); z-index:1000; align-items:center; justify-content:center; padding:16px;">
<div class="panel" style="width:min(520px,100%);">
<div style="margin-bottom:16px;">
<div style="font-size:16px; font-weight:700; margin-bottom:6px;">⚙️ Инициализация окружения</div>
<div style="font-size:13px; color:var(--text-secondary);">Первый вход. Настраиваем ваше окружение — это займёт ~5 минут.</div>
</div>
<div id="init-log" style="background:#000d1a; border:1px solid var(--border); border-radius:6px; padding:12px; font-family:monospace; font-size:12px; height:160px; overflow-y:auto; color:#4fc3f7; white-space:pre-wrap; line-height:1.6;"></div>
<div id="init-stages" style="margin-top:14px; display:flex; flex-direction:column; gap:8px;"></div>
<div style="margin-top:14px; font-size:12px; color:var(--text-secondary);">Страница обновится автоматически когда всё будет готово.</div>
</div>
</div>
<div class="navbar"> <div class="navbar">
<div class="title">sless / Fission Console</div> <div class="brand-mark">N</div>
<div class="brand-text">
<div class="nubes">NUBES</div>
<div class="product">FISSION CONSOLE</div>
</div>
</div>
<div class="row" style="margin:0;"> <div class="row" style="margin:0;">
<button class="btn ghost" onclick="reloadAll()">Refresh</button> <button class="btn ghost" onclick="reloadAll()">Refresh</button>
<button class="btn" onclick="openCreate()">+ Create Function</button> <button class="btn" onclick="openCreate()">+ Создать функцию</button>
<button class="btn ghost" onclick="doLogout()" style="margin-left:8px;">Выход</button>
</div> </div>
</div> </div>
<div class="wrap"> <div class="wrap">
<div class="grid"> <div class="grid">
<div class="card"><div class="k">Environments</div><div id="env-count" class="v">-</div></div> <div class="card"><div class="k">Окружения</div><div id="env-count" class="v">-</div></div>
<div class="card"><div class="k">Packages</div><div id="pkg-count" class="v">-</div></div> <div class="card"><div class="k">Пакеты</div><div id="pkg-count" class="v">-</div></div>
<div class="card"><div class="k">Functions</div><div id="fn-count" class="v">-</div></div> <div class="card"><div class="k">Функции</div><div id="fn-count" class="v">-</div></div>
<div class="card"><div class="k">HTTP Triggers</div><div id="http-count" class="v">-</div></div> <div class="card"><div class="k">HTTP-триггеры</div><div id="http-count" class="v">-</div></div>
<div class="card"><div class="k">Time Triggers</div><div id="time-count" class="v">-</div></div> <div class="card"><div class="k">Тайм-триггеры</div><div id="time-count" class="v">-</div></div>
</div> </div>
<div class="box"> <div class="box">
<div class="toolbar"> <div class="toolbar">
<div style="font-weight:600;">Functions</div> <div style="font-weight:600;">Функции</div>
<div class="hint">Actions: view, edit code, invoke, delete</div> <div class="hint">Actions: view, edit code, invoke, delete</div>
</div> </div>
<table> <table>
<thead> <thead>
<tr><th>Name</th><th>Environment</th><th>Package</th><th>Route</th><th>Methods</th><th class="nowrap">Actions</th></tr> <tr><th>Имя</th><th>Окружение</th><th>Пакет</th><th>Маршрут</th><th>Методы</th><th class="nowrap">Действия</th></tr>
</thead> </thead>
<tbody id="fn-rows"></tbody> <tbody id="fn-rows"></tbody>
</table> </table>
@@ -277,15 +367,21 @@
<div id="create-modal" class="modal"> <div id="create-modal" class="modal">
<div class="panel"> <div class="panel">
<h3>Create Function</h3> <h3>Создать функцию</h3>
<div class="row"> <div class="row">
<div class="field"> <div class="field">
<label>Name</label> <label>Name</label>
<input id="c-name" placeholder="demo-fn"> <input id="c-name" placeholder="demo-fn">
</div> </div>
<div class="field"> <div class="field">
<label>Environment</label> <label>Language</label>
<select id="c-env"></select> <select id="c-lang" onchange="onLangChange()">
<option value="python">Python</option>
<option value="nodejs">Node.js</option>
<option value="php">PHP</option>
<option value="ruby">Ruby</option>
<option value="perl">Perl</option>
</select>
</div> </div>
<div class="field"> <div class="field">
<label>Entrypoint</label> <label>Entrypoint</label>
@@ -298,26 +394,41 @@
<input id="c-route" placeholder="/demo-fn"> <input id="c-route" placeholder="/demo-fn">
</div> </div>
<div class="field"> <div class="field">
<label>Methods (comma separated)</label> <label>Методы (через запятую)</label>
<input id="c-methods" value="GET"> <input id="c-methods" value="GET">
</div> </div>
</div> </div>
<div> <div>
<label>Code</label> <label>Код</label>
<textarea id="c-code">def main(ctx): <textarea id="c-code">def main(ctx):
return {"ok": True, "msg": "hello from fission console"} return {"ok": True, "msg": "hello from fission console"}
</textarea> </textarea>
<div style="margin-top:6px; display:flex; gap:6px; flex-wrap:wrap;">
<button class="btn ghost" id="c-ai-btn" onclick="aiCheck('c-code','c-lang','c-ai-result')">&#x1F50D; Проверить синтаксис</button>
<button class="btn ghost" id="c-gen-btn" onclick="showGenPrompt()">&#x2728; Сгенерировать код</button>
<button class="btn ghost" id="c-exp-btn" onclick="aiExplain('c-code','c-lang','c-ai-result')">&#x1F4D6; Что делает?</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;">▶ Генерировать</button>
</div>
<div id="c-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>
<div class="actions"> <div class="actions">
<button class="btn ghost" onclick="closeCreate()">Cancel</button> <button class="btn ghost" onclick="closeCreate()">Отмена</button>
<button id="c-submit" class="btn" onclick="submitCreate()">Create</button> <button id="c-submit" class="btn" onclick="submitCreate()">Создать</button>
</div> </div>
</div> </div>
</div> </div>
<div id="edit-modal" class="modal"> <div id="edit-modal" class="modal">
<div class="panel"> <div class="panel">
<h3 id="e-title">Edit Code</h3> <h3 id="e-title">Редактирование кода</h3>
<div id="e-tf-warn" style="display:none;background:#553300;color:#ffa;padding:8px 12px;border-radius:6px;margin-bottom:10px;font-size:13px;">&#x26a0;&#xfe0f; Эта функция управляется Terraform. Изменения могут быть перезаписаны при следующем <code>terraform apply</code>.</div>
<input type="hidden" id="e-lang-hidden" value="">
<div class="row"> <div class="row">
<div class="field"> <div class="field">
<label>Name</label> <label>Name</label>
@@ -333,28 +444,34 @@
</div> </div>
</div> </div>
<div> <div>
<label>Code</label> <label>Код</label>
<textarea id="e-code"></textarea> <textarea id="e-code"></textarea>
<div style="margin-top:6px; display:flex; gap:6px; flex-wrap:wrap;">
<button class="btn ghost" id="e-ai-btn" onclick="aiCheck('e-code','e-lang-hidden','e-ai-result')">&#x1F50D; Проверить синтаксис</button>
<button class="btn ghost" id="e-exp-btn" onclick="aiExplain('e-code','e-lang-hidden','e-ai-result')">&#x1F4D6; Что делает?</button>
</div>
<div id="e-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>
<div class="actions"> <div class="actions">
<button class="btn ghost" onclick="closeEdit()">Cancel</button> <button class="btn ghost" onclick="closeEdit()">Отмена</button>
<button id="e-submit" class="btn" onclick="submitEdit()">Save</button> <button id="e-submit" class="btn" onclick="submitEdit()">Сохранить</button>
</div> </div>
</div> </div>
</div> </div>
<div id="invoke-modal" class="modal"> <div id="invoke-modal" class="modal">
<div class="panel"> <div class="panel">
<h3 id="i-title">Invoke</h3> <h3 id="i-title">Вызов</h3>
<div> <div>
<label>JSON payload</label> <label>JSON тело запроса</label>
<textarea id="i-body">{"name":"world"}</textarea> <textarea id="i-body">{"name":"world"}</textarea>
</div> </div>
<div class="actions"> <div class="actions">
<button class="btn ghost" onclick="closeInvoke()">Cancel</button> <button class="btn ghost" onclick="closeInvoke()">Отмена</button>
<button id="i-submit" class="btn" onclick="submitInvoke()">Invoke</button> <button id="i-submit" class="btn" onclick="submitInvoke()">Вызвать</button>
</div> </div>
<div style="margin-top:10px;"> <div id="i-status" style="margin-top:8px;font-size:13px;color:var(--text-secondary);min-height:20px;"></div>
<div style="margin-top:6px;">
<label>Response</label> <label>Response</label>
<textarea id="i-resp" readonly style="min-height:160px;"></textarea> <textarea id="i-resp" readonly style="min-height:160px;"></textarea>
</div> </div>
@@ -372,8 +489,16 @@
currentInvoke: null currentInvoke: null
}; };
function authHeaders() {
return {
'X-Auth-Token': localStorage.getItem('auth_token') || '',
'X-Auth-Env': localStorage.getItem('auth_env') || 'test'
};
}
async function getJSON(url) { async function getJSON(url) {
const r = await fetch(url); const r = await fetch(url, {headers: authHeaders()});
if (r.status === 401) { doLogout(); throw new Error('Сессия истекла'); }
if (!r.ok) { if (!r.ok) {
let msg = ''; let msg = '';
try { try {
@@ -390,9 +515,10 @@
async function requestJSON(url, method, body) { async function requestJSON(url, method, body) {
const r = await fetch(url, { const r = await fetch(url, {
method: method, method: method,
headers: { 'Content-Type': 'application/json' }, headers: Object.assign({'Content-Type': 'application/json'}, authHeaders()),
body: body ? JSON.stringify(body) : undefined body: body ? JSON.stringify(body) : undefined
}); });
if (r.status === 401) { doLogout(); throw new Error('Сессия истекла'); }
let data = {}; let data = {};
try { data = await r.json(); } catch (_) {} try { data = await r.json(); } catch (_) {}
if (!r.ok) { if (!r.ok) {
@@ -439,12 +565,45 @@
.replaceAll("'", '&#39;'); .replaceAll("'", '&#39;');
} }
const LANG_TEMPLATES = {
python: {
entrypoint: 'main.main',
code: 'def main():\n return "hello from fission"'
},
nodejs: {
entrypoint: 'handler',
code: 'module.exports = async function(context) {\n return {\n status: 200,\n body: "hello from fission"\n };\n}'
},
go: {
entrypoint: 'Handler',
code: 'package main\n\nimport (\n "fmt"\n "net/http"\n)\n\nfunc Handler(w http.ResponseWriter, r *http.Request) {\n fmt.Fprintf(w, "hello from fission")\n}'
},
php: {
entrypoint: 'main.php::handler',
code: '<?php\nfunction handler($context)\n{\n $response = $context["response"];\n $response->getBody()->write("hello from fission");\n}'
},
ruby: {
entrypoint: 'handler',
code: '# frozen_string_literal: true\n\ndef handler\n "hello from fission"\nend'
},
perl: {
entrypoint: 'handler',
code: 'sub {\n return "hello from fission";\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() { function openCreate() {
const envSel = document.getElementById('c-env'); document.getElementById('c-lang').value = 'python';
envSel.innerHTML = (S.envs || []).map(function (e) { onLangChange();
const n = (e.metadata && e.metadata.name) || '';
return '<option value="' + h(n) + '">' + h(n) + '</option>';
}).join('');
document.getElementById('create-modal').classList.add('open'); document.getElementById('create-modal').classList.add('open');
document.getElementById('c-name').focus(); document.getElementById('c-name').focus();
} }
@@ -459,12 +618,12 @@
try { try {
const name = document.getElementById('c-name').value.trim(); const name = document.getElementById('c-name').value.trim();
if (!name) throw new Error('name is required'); if (!name) throw new Error('name is required');
const env = document.getElementById('c-env').value.trim(); const lang = document.getElementById('c-lang').value.trim();
if (!env) throw new Error('environment is required'); if (!lang) throw new Error('language is required');
await requestJSON(API_BASE + '/functions', 'POST', { await requestJSON(API_BASE + '/functions', 'POST', {
name: name, name: name,
environment: env, language: lang,
entrypoint: document.getElementById('c-entry').value.trim(), entrypoint: document.getElementById('c-entry').value.trim(),
route: document.getElementById('c-route').value.trim(), route: document.getElementById('c-route').value.trim(),
methods: parseMethods(document.getElementById('c-methods').value), methods: parseMethods(document.getElementById('c-methods').value),
@@ -472,10 +631,10 @@
}); });
closeCreate(); closeCreate();
showStatus('Function created: ' + name, 'ok'); showStatus('\u0424\u0443\u043d\u043a\u0446\u0438\u044f \u0441\u043e\u0437\u0434\u0430\u043d\u0430: ' + name, 'ok');
await reloadAll(); await reloadAll();
} catch (e) { } catch (e) {
showStatus('Create failed: ' + e.message, 'err'); showStatus('\u041e\u0448\u0438\u0431\u043a\u0430 \u0441\u043e\u0437\u0434\u0430\u043d\u0438\u044f: ' + e.message, 'err');
} finally { } finally {
btn.disabled = false; btn.disabled = false;
} }
@@ -485,14 +644,31 @@
try { try {
const fn = await getJSON(API_BASE + '/functions/' + encodeURIComponent(name)); const fn = await getJSON(API_BASE + '/functions/' + encodeURIComponent(name));
S.currentEdit = fn; S.currentEdit = fn;
document.getElementById('e-title').textContent = 'Edit Code: ' + name; document.getElementById('e-title').textContent = '\u0420\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435: ' + name;
document.getElementById('e-name').value = name; document.getElementById('e-name').value = name;
document.getElementById('e-env').value = fn.environment || ''; document.getElementById('e-env').value = fn.environment || '';
document.getElementById('e-entry').value = fn.entrypoint || ''; document.getElementById('e-entry').value = fn.entrypoint || '';
document.getElementById('e-code').value = fn.code || ''; document.getElementById('e-code').value = fn.code || '';
// Определяем язык по имени environment для линтера
var envName = (fn.environment || '').toLowerCase();
var lang = 'python';
if (envName.includes('node')) lang = 'nodejs';
else if (envName.includes('go')) lang = 'go';
else if (envName.includes('ruby')) lang = 'ruby';
else if (envName.includes('php')) lang = 'php';
else if (envName.includes('perl')) lang = 'perl';
document.getElementById('e-lang-hidden').value = lang;
// сбросить предыдущий AI-результат
var aiRes = document.getElementById('e-ai-result');
if (aiRes) { aiRes.style.display = 'none'; aiRes.textContent = ''; }
var warnEl = document.getElementById('e-tf-warn');
if (warnEl) {
var isTf = /^tf-/.test(name) || /go[-_]env/.test(fn.environment || '');
warnEl.style.display = isTf ? 'block' : 'none';
}
document.getElementById('edit-modal').classList.add('open'); document.getElementById('edit-modal').classList.add('open');
} catch (e) { } catch (e) {
showStatus('Load function failed: ' + e.message, 'err'); showStatus('\u041e\u0448\u0438\u0431\u043a\u0430 \u0437\u0430\u0433\u0440\u0443\u0437\u043a\u0438 \u0444\u0443\u043d\u043a\u0446\u0438\u0438: ' + e.message, 'err');
} }
} }
@@ -511,9 +687,9 @@
code: document.getElementById('e-code').value code: document.getElementById('e-code').value
}); });
closeEdit(); closeEdit();
showStatus('Code updated: ' + name, 'ok'); showStatus('\u041a\u043e\u0434 \u043e\u0431\u043d\u043e\u0432\u043b\u0451\u043d: ' + name, 'ok');
} catch (e) { } catch (e) {
showStatus('Update failed: ' + e.message, 'err'); showStatus('\u041e\u0448\u0438\u0431\u043a\u0430 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f: ' + e.message, 'err');
} finally { } finally {
btn.disabled = false; btn.disabled = false;
} }
@@ -521,7 +697,7 @@
function openInvoke(name) { function openInvoke(name) {
S.currentInvoke = name; S.currentInvoke = name;
document.getElementById('i-title').textContent = 'Invoke: ' + name; document.getElementById('i-title').textContent = '\u0412\u044b\u0437\u043e\u0432: ' + name;
document.getElementById('i-resp').value = ''; document.getElementById('i-resp').value = '';
document.getElementById('invoke-modal').classList.add('open'); document.getElementById('invoke-modal').classList.add('open');
} }
@@ -534,28 +710,47 @@
async function submitInvoke() { async function submitInvoke() {
if (!S.currentInvoke) return; if (!S.currentInvoke) return;
const btn = document.getElementById('i-submit'); const btn = document.getElementById('i-submit');
const statusEl = document.getElementById('i-status');
const respEl = document.getElementById('i-resp');
btn.disabled = true; btn.disabled = true;
respEl.value = '';
let elapsed = 0;
statusEl.textContent = 'Вызов...';
const timer = setInterval(() => {
elapsed++;
if (elapsed < 5) {
statusEl.textContent = 'Вызов... ' + elapsed + 'с';
} else if (elapsed < 10) {
statusEl.textContent = '⏳ Холодный старт — прогрев пула... ' + elapsed + 'с';
} else {
statusEl.textContent = '⏳ Холодный старт — ещё немного... ' + elapsed + 'с';
}
}, 1000);
try { try {
const raw = document.getElementById('i-body').value.trim(); const raw = document.getElementById('i-body').value.trim();
let parsed = {}; let parsed = {};
if (raw) parsed = JSON.parse(raw); if (raw) parsed = JSON.parse(raw);
const result = await requestJSON(API_BASE + '/functions/' + encodeURIComponent(S.currentInvoke) + '/invoke', 'POST', parsed); const result = await requestJSON(API_BASE + '/functions/' + encodeURIComponent(S.currentInvoke) + '/invoke', 'POST', parsed);
document.getElementById('i-resp').value = JSON.stringify(result, null, 2); statusEl.textContent = '✓ Выполнено за ' + elapsed + 'с';
respEl.value = JSON.stringify(result, null, 2);
} catch (e) { } catch (e) {
document.getElementById('i-resp').value = 'Invoke failed: ' + e.message; statusEl.textContent = '✗ Ошибка после ' + elapsed + 'с';
respEl.value = 'Ошибка вызова: ' + e.message;
} finally { } finally {
clearInterval(timer);
btn.disabled = false; btn.disabled = false;
} }
} }
async function removeFn(name) { async function removeFn(name) {
if (!confirm('Delete function ' + name + '?')) return; var tfWarn = (/^tf-/.test(name)) ? '\n\n\u26a0\ufe0f \u042d\u0442\u0430 \u0444\u0443\u043d\u043a\u0446\u0438\u044f \u0443\u043f\u0440\u0430\u0432\u043b\u044f\u0435\u0442\u0441\u044f Terraform. \u0423\u0434\u0430\u043b\u0435\u043d\u0438\u0435 \u043f\u0440\u0438\u0432\u0435\u0434\u0451\u0442 \u043a \u0440\u0430\u0441\u0441\u0438\u043d\u0445\u0440\u043e\u043d\u0438\u0437\u0430\u0446\u0438\u0438 state!' : '';
if (!confirm('\u0423\u0434\u0430\u043b\u0438\u0442\u044c \u0444\u0443\u043d\u043a\u0446\u0438\u044e ' + name + '?' + tfWarn)) return;
try { try {
await requestJSON(API_BASE + '/functions/' + encodeURIComponent(name), 'DELETE'); await requestJSON(API_BASE + '/functions/' + encodeURIComponent(name), 'DELETE');
showStatus('Function deleted: ' + name, 'ok'); showStatus('\u0424\u0443\u043d\u043a\u0446\u0438\u044f \u0443\u0434\u0430\u043b\u0435\u043d\u0430: ' + name, 'ok');
await reloadAll(); await reloadAll();
} catch (e) { } catch (e) {
showStatus('Delete failed: ' + e.message, 'err'); showStatus('\u041e\u0448\u0438\u0431\u043a\u0430 \u0443\u0434\u0430\u043b\u0435\u043d\u0438\u044f: ' + e.message, 'err');
} }
} }
@@ -588,11 +783,13 @@
const route = (trig.spec && trig.spec.relativeurl) || '-'; const route = (trig.spec && trig.spec.relativeurl) || '-';
const methods = (trig.spec && trig.spec.methods) || []; const methods = (trig.spec && trig.spec.methods) || [];
const chips = methods.map(function (m) { return '<span class="chip">' + h(m) + '</span>'; }).join(''); const chips = methods.map(function (m) { return '<span class="chip">' + h(m) + '</span>'; }).join('');
const actions = [ var isGo = /go[-_]env/.test(env);
'<button class="btn ghost" onclick="openEdit(\'' + h(name) + '\')">Edit</button>', var isTf = /^tf-/.test(name);
'<button class="btn ghost" onclick="openInvoke(\'' + h(name) + '\')">Invoke</button>', var tfBadge = (isGo || isTf) ? '<span class="chip" style="background:#555;color:#ffa" title="Управляется Terraform. Изменения могут быть перезаписаны при terraform apply.">TF</span> ' : '';
'<button class="btn danger" onclick="removeFn(\'' + h(name) + '\')">Delete</button>' var actions = tfBadge +
].join(' '); '<button class="btn ghost" onclick="openEdit(\'' + h(name) + '\')">\u0420\u0435\u0434.</button> ' +
'<button class="btn ghost" onclick="openInvoke(\'' + h(name) + '\')">\u0412\u044b\u0437\u043e\u0432</button> ' +
'<button class="btn danger" onclick="removeFn(\'' + h(name) + '\')">\u0423\u0434\u0430\u043b\u0438\u0442\u044c</button>';
return '<tr>' + return '<tr>' +
'<td class="mono">' + h(name) + '</td>' + '<td class="mono">' + h(name) + '</td>' +
'<td>' + h(env) + '</td>' + '<td>' + h(env) + '</td>' +
@@ -603,17 +800,283 @@
'</tr>'; '</tr>';
}).join(''); }).join('');
document.getElementById('fn-rows').innerHTML = rows || '<tr><td colspan="3">No functions</td></tr>'; document.getElementById('fn-rows').innerHTML = rows || '<tr><td colspan="3">\u041d\u0435\u0442 \u0444\u0443\u043d\u043a\u0446\u0438\u0439</td></tr>';
if (!rows) { if (!rows) {
document.getElementById('fn-rows').innerHTML = '<tr><td colspan="6">No functions</td></tr>'; document.getElementById('fn-rows').innerHTML = '<tr><td colspan="6">\u041d\u0435\u0442 \u0444\u0443\u043d\u043a\u0446\u0438\u0439</td></tr>';
} }
} catch (e) { } catch (e) {
document.getElementById('fn-rows').innerHTML = '<tr><td colspan="6">Load error: ' + e.message + '</td></tr>'; document.getElementById('fn-rows').innerHTML = '<tr><td colspan="6">Load error: ' + e.message + '</td></tr>';
showStatus('Reload failed: ' + e.message, 'err'); showStatus('\u041e\u0448\u0438\u0431\u043a\u0430 \u0437\u0430\u0433\u0440\u0443\u0437\u043a\u0438: ' + e.message, 'err');
} }
} }
reloadAll(); function showLoginOverlay() {
document.getElementById('login-overlay').style.display = 'flex';
}
function hideLoginOverlay() {
document.getElementById('login-overlay').style.display = 'none';
}
async function doLogin() {
var btn = document.getElementById('l-btn');
var errEl = document.getElementById('l-error');
var token = (document.getElementById('l-token').value || '').trim();
var env = document.getElementById('l-env').value;
if (!token) { errEl.textContent = 'Введите токен'; errEl.style.display = 'block'; return; }
btn.disabled = true;
errEl.style.display = 'none';
try {
const res = await fetch(API_BASE + '/auth', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({token: token, env: env})
});
if (!res.ok) {
const d = await res.json().catch(function() { return {}; });
throw new Error(d.error || 'Ошибка входа');
}
localStorage.setItem('auth_token', token);
localStorage.setItem('auth_env', env);
hideLoginOverlay();
// Проверяем статус NS — если не ready, показываем init overlay
try {
var sr = await fetch(API_BASE + '/ns/status', {headers: {'X-Auth-Token': token, 'X-Auth-Env': env}});
var sd = await sr.json();
if (!sd.ready) {
pollNSStatus();
return;
}
} catch(_) {}
reloadAll();
} catch(e) {
errEl.textContent = e.message;
errEl.style.display = 'block';
} finally {
btn.disabled = false;
}
}
function doLogout() {
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_env');
try { document.getElementById('l-token').value = ''; } catch(_) {}
showLoginOverlay();
}
async function aiCheck(codeId, langId, resultId) {
var code = document.getElementById(codeId).value.trim();
var lang = (document.getElementById(langId) ? document.getElementById(langId).value : '') || 'python';
var resEl = document.getElementById(resultId);
if (!code) { resEl.style.display = 'block'; resEl.style.background = 'var(--bg-alt)'; resEl.textContent = 'Введите код для проверки.'; return; }
var btnId = codeId === 'c-code' ? 'c-ai-btn' : 'e-ai-btn';
var btn = document.getElementById(btnId);
btn.disabled = true;
btn.textContent = '⏳ Проверяю...';
resEl.style.display = 'block';
resEl.style.background = 'var(--bg-alt)';
resEl.style.color = 'var(--fg)';
resEl.textContent = 'Отправляю код в LLM...';
try {
var data = await requestJSON(API_BASE + '/ai/check', 'POST', {language: lang, code: code});
resEl.style.background = data.ok ? '#1a3a1a' : '#3a1a1a';
resEl.style.color = data.ok ? '#8f8' : '#f88';
resEl.textContent = data.result || '(пустой ответ)';
} catch(e) {
resEl.style.background = '#3a2a00';
resEl.style.color = '#ffa';
resEl.textContent = 'Ошибка: ' + e.message;
} finally {
btn.disabled = false;
btn.textContent = '🔍 Проверить синтаксис';
}
}
var _initPolling = false;
function initLog(msg) {
var el = document.getElementById('init-log');
var ts = new Date().toLocaleTimeString('ru-RU');
el.textContent += '[' + ts + '] ' + msg + '\n';
el.scrollTop = el.scrollHeight;
}
function renderInitStages(stages) {
var el = document.getElementById('init-stages');
el.innerHTML = stages.map(function(s) {
var icon = s.done ? '✅' : '⏳';
var color = s.done ? '#4caf50' : '#8bc7ff';
return '<div style="font-size:13px; color:' + color + ';">' + icon + '&nbsp;' + s.name + '</div>';
}).join('');
}
async function pollNSStatus() {
if (_initPolling) return;
_initPolling = true;
var overlay = document.getElementById('init-overlay');
overlay.style.display = 'flex';
initLog('Запуск инициализации...');
var attempt = 0;
var maxAttempts = 60; // 5 минут
var interval = setInterval(async function() {
attempt++;
try {
var r = await fetch(API_BASE + '/ns/status', {headers: authHeaders()});
var d = await r.json();
if (d.stages) renderInitStages(d.stages);
var done = d.stages ? d.stages.filter(function(s){return s.done;}).length : 0;
initLog('Шагов завершено: ' + done + '/3');
if (d.ready) {
initLog('✅ Готово! Загружаем консоль...');
clearInterval(interval);
_initPolling = false;
overlay.style.display = 'none';
reloadAll();
return;
}
} catch(e) {
initLog('Ошибка опроса: ' + e.message);
}
if (attempt >= maxAttempts) {
clearInterval(interval);
_initPolling = false;
initLog('⚠️ Превышено время ожидания. Попробуйте обновить страницу.');
}
}, 5000);
}
function checkAuth() {
if (!localStorage.getItem('auth_token')) {
showLoginOverlay();
return;
}
hideLoginOverlay();
reloadAll();
}
checkAuth();
</script> </script>
<!-- ================================================================
ai/ask feature — удалить весь блок до "end ai/ask feature"
================================================================ -->
<div id="assistant-panel" style="
position:fixed; bottom:0; right:24px;
width:720px; background:#1e1e2e; border:1px solid #3a3a5c;
border-bottom:none; border-radius:8px 8px 0 0;
font-family:inherit; z-index:900; box-shadow:0 -2px 12px rgba(0,0,0,.4);
">
<div id="assistant-header" onclick="toggleAssistant()" style="
display:flex; align-items:center; justify-content:space-between;
padding:8px 12px; cursor:pointer; background:#2a2a42; border-radius:8px 8px 0 0;
user-select:none;
">
<span style="font-weight:600; font-size:.85rem;">💬 Ассистент</span>
<span id="assistant-toggle-icon" style="font-size:.75rem; opacity:.7;"></span>
</div>
<div id="assistant-body" style="padding:10px; display:flex; flex-direction:column; gap:8px;">
<div id="assistant-messages" style="
min-height:80px; max-height:400px; overflow-y:auto;
font-size:.82rem; line-height:1.5; color:#cdd6f4;
background:#13131f; border-radius:4px; padding:8px;
white-space:pre-wrap; word-break:break-word;
">Чем могу помочь?</div>
<div style="display:flex; gap:6px;">
<input id="assistant-input" type="text" placeholder="Введи вопрос..."
style="flex:1; background:#2a2a42; border:1px solid #3a3a5c; border-radius:4px;
color:#cdd6f4; padding:6px 8px; font-size:.82rem; outline:none;"
onkeydown="if(event.key==='Enter')askAssistant()" />
<button onclick="askAssistant()" style="
background:#7c3aed; color:#fff; border:none; border-radius:4px;
padding:6px 12px; cursor:pointer; font-size:.82rem; white-space:nowrap;
"></button>
</div>
</div>
</div>
<script>
// ai/ask feature
var _assistantOpen = true;
function toggleAssistant() {
_assistantOpen = !_assistantOpen;
document.getElementById('assistant-body').style.display = _assistantOpen ? 'flex' : 'none';
document.getElementById('assistant-toggle-icon').textContent = _assistantOpen ? '▲' : '▼';
}
async function askAssistant() {
var inp = document.getElementById('assistant-input');
var msgs = document.getElementById('assistant-messages');
var q = inp.value.trim();
if (!q) return;
inp.value = '';
msgs.textContent += '\n\n👤 ' + q + '\n⏳ ...';
msgs.scrollTop = msgs.scrollHeight;
try {
var r = await fetch('/console/api/ai/ask', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ question: q })
});
var d = await r.json();
msgs.textContent = msgs.textContent.replace('⏳ ...', '🤖 ' + (d.answer || d.error || 'Нет ответа'));
} catch(e) {
msgs.textContent = msgs.textContent.replace('⏳ ...', '❌ Ошибка: ' + e.message);
}
msgs.scrollTop = msgs.scrollHeight;
}
function showGenPrompt() {
var el = document.getElementById('c-gen-prompt');
el.style.display = 'flex';
document.getElementById('c-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');
btn.disabled = true; btn.textContent = '⏳ Генерирую...';
resEl.style.display = 'block'; resEl.style.background = 'var(--bg-alt)'; resEl.style.color = 'var(--fg)';
resEl.textContent = 'Запрашиваю у LLM...';
try {
var q = 'Напиши функцию Fission на ' + lang + ' с именем "' + name + '". Функция должна: ' + desc + '. Верни только чистый код без пояснений и без markdown-блоков.';
var d = await requestJSON('/console/api/ai/ask', 'POST', {question: q});
var code = (d.answer || '').replace(/^```[\w]*\n?/, '').replace(/\n?```$/, '');
document.getElementById('c-code').value = code;
document.getElementById('c-gen-prompt').style.display = 'none';
resEl.style.background = '#1a3a1a'; resEl.style.color = '#8f8';
resEl.textContent = 'Код сгенерирован и вставлен.';
} catch(e) {
resEl.style.background = '#3a2a00'; resEl.style.color = '#ffa';
resEl.textContent = 'Ошибка: ' + e.message;
} finally {
btn.disabled = false; btn.textContent = '✨ Сгенерировать код';
}
}
async function aiExplain(codeId, langId, resultId) {
var code = (document.getElementById(codeId).value || '').trim();
var lang = (document.getElementById(langId) ? document.getElementById(langId).value : '') || 'python';
var resEl = document.getElementById(resultId);
var btnId = codeId === 'c-code' ? 'c-exp-btn' : 'e-exp-btn';
var btn = document.getElementById(btnId);
if (!code) { resEl.style.display='block'; resEl.style.background='var(--bg-alt)'; resEl.textContent='Введите код.'; return; }
btn.disabled = true; btn.textContent = '⏳ Объясняю...';
resEl.style.display = 'block'; resEl.style.background = 'var(--bg-alt)'; resEl.style.color = 'var(--fg)';
resEl.textContent = 'Запрашиваю у LLM...';
try {
var q = 'Кратко объясни что делает этот ' + lang + ' код:\n' + code;
var d = await requestJSON('/console/api/ai/ask', 'POST', {question: q});
resEl.style.background = '#1a2a3a'; resEl.style.color = '#8cf';
resEl.textContent = d.answer || '(пустой ответ)';
} catch(e) {
resEl.style.background = '#3a2a00'; resEl.style.color = '#ffa';
resEl.textContent = 'Ошибка: ' + e.message;
} finally {
btn.disabled = false; btn.textContent = '📖 Что делает?';
}
}
// end ai/ask feature
</script>
<!-- end ai/ask feature -->
</body> </body>
</html> </html>
+39
View File
@@ -0,0 +1,39 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: fission-console-ns-manager
rules:
- apiGroups: [""]
resources: ["namespaces", "serviceaccounts", "resourcequotas", "limitranges"]
verbs: ["get", "create", "list"]
- apiGroups: ["fission.io"]
resources: ["*"]
verbs: ["*"]
- apiGroups: ["rbac.authorization.k8s.io"]
resources: ["rolebindings"]
verbs: ["get", "create"]
# bind позволяет создавать RoleBindings ссылающиеся на cluster-admin
# без необходимости самому иметь все его права (RBAC escalation prevention)
- apiGroups: ["rbac.authorization.k8s.io"]
resources: ["clusterroles"]
resourceNames: ["cluster-admin"]
verbs: ["bind"]
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "patch"]
- apiGroups: ["networking.k8s.io"]
resources: ["networkpolicies"]
verbs: ["get", "create"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: fission-console-ns-manager
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: fission-console-ns-manager
subjects:
- kind: ServiceAccount
name: fission-console
namespace: fission
+473
View File
@@ -0,0 +1,473 @@
# Аудит: Terraform Provider vs Fission Canonical CRD
**Дата:** 2026-06-03
**Ветка:** `feat/provider-audit`
**Предыдущая версия:** v0.2.4 (ветка `feat/console`)
## Методология
Сравнение производилось по трём источникам:
1. **Наш код**`/terraform/provider/internal/resources/*.go` и `/terraform/provider/internal/client/client.go`
2. **Fission CRD types.go**`github.com/fission/fission/pkg/apis/core/v1/types.go` (канонические Go-структуры)
3. **Реальные CRD объекты в кластере**`kubectl get` для environments/packages/functions/httptriggers (наши vs CLI-созданные)
---
## 1. ENVIRONMENT (fission_environment)
### 1.1 Что у нас
```go
// environmentResourceModel
ID, Name, Image, Version(default=3), PoolSize(default=3), Namespace, UID
```
`environmentToUnstructured` генерирует:
```json
{
"spec": {
"version": 3,
"runtime": { "image": "..." },
"poolsize": 3
}
}
```
### 1.2 Что делает Fission CLI (`fission env create`)
Fission CLI (из `environment/create.go`) создает полный `EnvironmentSpec`:
```json
{
"spec": {
"version": 3,
"runtime": {
"image": "ghcr.io/fission/python-env",
"container": { "name": "env-name", "resources": {} },
"podspec": { "containers": [{"name": "env-name", "resources": {}}] }
},
"builder": {
"image": "ghcr.io/fission/go-builder",
"command": "build",
"container": { "name": "builder", "resources": {} },
"podspec": { "containers": [{"name": "builder", "resources": {}}] }
},
"poolsize": 3,
"resources": {},
"imagepullsecret": "",
"keeparchive": false
}
}
```
### 1.3 Реальное сравнение в кластере
| Поле | Наш (tf-python-env) | CLI (python) | Вердикт |
|------|---------------------|--------------|---------|
| `spec.version` | 3 | 3 | ✅ OK |
| `spec.runtime.image` | ✅ | ✅ | ✅ OK |
| `spec.runtime.container` | ❌ отсутствует | `{name, resources}` | ⚠️ Fission заполняет defaults — не критично |
| `spec.runtime.podspec` | ❌ отсутствует | `{containers}` | ⚠️ Fission заполняет defaults — не критично |
| `spec.builder` | ❌ ОТСУТСТВУЕТ | `{image, command, container, podspec}` | 🔴 **КРИТИЧНО для Go** |
| `spec.poolsize` | 3 | 3 | ✅ OK |
| `spec.resources` | ❌ отсутствует | `{}` | ⚠️ Defaults — не критично |
| `spec.imagepullsecret` | ❌ | `""` | ⚠️ Можно добавить позже |
| `spec.keeparchive` | ❌ | `false` | ⚠️ Нужно для JVM — не критично сейчас |
### 1.4 Выводы по Environment
**Критичный баг:** Невозможно создать environment с builder (нет полей `builder_image`, `builder_command`). Это блокирует Go, любой язык с build step.
**Что добавить (приоритетно):**
- `builder_image` (string, optional) → `spec.builder.image`
- `builder_command` (string, optional) → `spec.builder.command`
**Что можно добавить позже:**
- `resources` (object) → `spec.resources`
- `imagepullsecret` (string) → `spec.imagepullsecret`
- `keeparchive` (bool) → `spec.keeparchive`
- `runtime_container_name` — Fission автозаполняет, мы не ставим, k8s принимает без него
**Что НЕ нужно (Fission автозаполняет):**
- `spec.runtime.container`, `spec.runtime.podspec` — заливаются defaults на стороне сервера
- `spec.builder.container`, `spec.builder.podspec` — аналогично
---
## 2. PACKAGE (fission_package)
### 2.1 Что у нас
```go
// packageResourceModel
ID, Name, Environment, SourceDir, CodePath, CodeHash, BuildCmd, Namespace, UID, BuildStatus, BuildLog
```
`packageToUnstructured` генерирует:
```json
{
"spec": {
"deployment": {
"type": "literal",
"literal": "base64..."
},
"environment": { "name": "...", "namespace": "..." },
"source": {}
}
}
```
### 2.2 Что делает Fission CLI
Для **deploy-only** (literal):
```json
{
"spec": {
"deployment": {
"type": "literal",
"literal": "base64...",
"checksum": {}
},
"environment": { "name": "...", "namespace": "..." },
"source": { "checksum": {} }
},
"status": {
"buildstatus": "none"
}
}
```
Для **source-with-builder** (Go, Node с build):
```json
{
"spec": {
"source": {
"type": "literal",
"literal": "base64-of-zip...",
"checksum": {}
},
"environment": { "name": "...", "namespace": "..." },
"buildcmd": "build"
},
"status": {
"buildstatus": "pending"
}
}
```
Для **large archives** (>256KB):
- Загрузка через StorageSvc `/v1/archive` (multipart POST)
- В CRD сохраняется `type: "url"`, `url: "http://storagesvc/v1/archive?id=..."`
### 2.3 Реальное сравнение в кластере
| Поле | Наш (tf-hello-pkg) | CLI (hello-*) | Вердикт |
|------|---------------------|---------------|---------|
| `spec.deployment.type` | `"literal"` | `"literal"` | ✅ OK |
| `spec.deployment.literal` | ✅ base64 | ✅ base64 | ✅ OK |
| `spec.deployment.checksum` | ❌ отсутствует | `{}` | ⚠️ K8s принимает без, но лучше добавить |
| `spec.environment` | ✅ | ✅ | ✅ OK |
| `spec.source` | `{}` (пустая map) | `{"checksum":{}}` | 🟡 **БАГ**: мы ставим пустой source — не мешает, но мусор |
| `spec.buildcmd` | ✅ (если задан) | ✅ | ✅ OK |
| `status.buildstatus` | `"none"` (от k8s default) | `"none"` | ✅ OK (k8s сам ставит) |
### 2.4 Что ОТСУТСТВУЕТ для builder pipeline (Go)
Для Go-функций нужен **source** package (не deployment):
1. Код упаковывается в zip
2. zip кодируется в base64 → `spec.source.literal` (если <256KB)
3. `spec.source.type` = `"literal"`
4. `spec.deployment` = пусто
5. `spec.buildcmd` = `"build"` (или пользовательская)
6. `status.buildstatus` = `"pending"` → builder собирает → `"succeeded"`/`"failed"`
7. После build: `spec.deployment` заполняется builder'ом (url на StorageSvc)
### 2.5 Выводы по Package
**Баг (некритичный):** Мы ВСЕГДА ставим `"source": {}` — пустой объект. Fission ставит `"source": {"checksum": {}}`. Оба варианта работают, но чистый вариант — не ставить source вообще если нет source archive.
**Что добавить (приоритетно):**
- **Режим source archive** — для Go и языков с build step. Нужно:
- Флаг/переключатель: deployment-only vs source-with-build
- Упаковка source_dir в zip → base64 → `spec.source.literal`
- Проверка размера <256KB (лимит ArchiveLiteralSizeLimit)
- Очистка `spec.deployment` при source mode
- `status.buildstatus` = `"pending"` на create
**Что можно добавить позже:**
- StorageSvc загрузка для >256KB архивов
- `spec.source.checksum`
- Поддержка `type: "url"` (для уже загруженных архивов)
---
## 3. FUNCTION (fission_function)
### 3.1 Что у нас
```go
// functionResourceModel
ID, Name, Environment, PackageName, Entrypoint, Namespace, UID
```
`functionToUnstructured` генерирует:
```json
{
"spec": {
"environment": { "name": "...", "namespace": "..." },
"InvokeStrategy": {
"ExecutionStrategy": { "ExecutorType": "poolmgr" },
"StrategyType": "execution"
},
"package": {
"packageref": { "name": "...", "namespace": "..." },
"functionName": "main.main"
}
}
}
```
### 3.2 Что делает Fission CLI (`fission fn create`)
```json
{
"spec": {
"environment": { "name": "...", "namespace": "..." },
"InvokeStrategy": {
"ExecutionStrategy": {
"ExecutorType": "poolmgr",
"MaxScale": 0,
"MinScale": 0,
"SpecializationTimeout": 120,
"TargetCPUPercent": 0
},
"StrategyType": "execution"
},
"package": {
"packageref": {
"name": "...",
"namespace": "...",
"resourceversion": "6000598"
},
"functionName": ""
},
"functionTimeout": 60,
"idletimeout": 120,
"concurrency": 500,
"requestsPerPod": 1,
"resources": {}
}
}
```
### 3.3 Реальное сравнение в кластере
| Поле | Наш (tf-hello-fn) | CLI (fn-js-acc) | Вердикт |
|------|---------------------|-----------------|---------|
| `spec.environment` | ✅ | ✅ | ✅ OK |
| `spec.InvokeStrategy.ExecutionStrategy.ExecutorType` | `"poolmgr"` | `"poolmgr"` | ✅ OK |
| `spec.InvokeStrategy.ExecutionStrategy.MaxScale` | ❌ отсутствует | `0` | ⚠️ Defaults работают, но лучше ставить |
| `spec.InvokeStrategy.ExecutionStrategy.MinScale` | ❌ | `0` | ⚠️ |
| `spec.InvokeStrategy.ExecutionStrategy.SpecializationTimeout` | ❌ | `120` | ⚠️ |
| `spec.InvokeStrategy.ExecutionStrategy.TargetCPUPercent` | ❌ | `0` | ⚠️ Не критично |
| `spec.InvokeStrategy.StrategyType` | `"execution"` | `"execution"` | ✅ OK |
| `spec.package.packageref.resourceversion` | ❌ отсутствует | ✅ | 🟡 CLI ставит для оптимизации, мы — нет |
| `spec.package.functionName` | ✅ `"main.main"` | `""` (или функция) | ✅ OK |
| `spec.functionTimeout` | ❌ | `60` | 🟡 Полезно для управления таймаутами |
| `spec.idletimeout` | ❌ | `120` | 🟡 Полезно для scale-to-zero |
| `spec.concurrency` | ❌ | `500` | ⚠️ |
| `spec.requestsPerPod` | ❌ | `1` | ⚠️ |
| `spec.resources` | ❌ | `{}` | ⚠️ |
### 3.4 Выводы по Function
**Критичных багов нет.** Наши функции работают, потому что k8s/Fission подставляет defaults. НО:
**Что добавить (приоритетно):**
- `executor_type` (string, optional, default="poolmgr") → для newdeploy/container strategies
- `function_timeout` (int, optional) → `spec.functionTimeout` — важно для долгих функций
- `idle_timeout` (int, optional) → `spec.idletimeout` — управление scale-to-zero
- `min_scale` / `max_scale` (int, optional) → ExecutionStrategy — для newdeploy
**Что можно добавить позже:**
- `concurrency` (int) → `spec.concurrency`
- `requests_per_pod` (int) → `spec.requestsPerPod`
- `specialization_timeout` (int) → `spec.InvokeStrategy.ExecutionStrategy.SpecializationTimeout`
- `resources` (object) → CPU/MEM limits
- `secrets`, `configmaps` (list) → volume mounts
---
## 4. HTTP TRIGGER (fission_http_trigger)
### 4.1 Что у нас
```go
// httpTriggerResourceModel
ID, Name, Function, URL, Methods, CreateIngress, Host, Namespace, UID
```
`httpTriggerToUnstructured` генерирует:
```json
{
"spec": {
"relativeurl": "/tf-hello",
"methods": ["GET"],
"functionref": { "type": "name", "name": "tf-hello-fn" },
"createingress": false
}
}
```
### 4.2 Что делает Fission CLI
```json
{
"spec": {
"relativeurl": "/hello",
"methods": ["GET"],
"functionref": {
"type": "name",
"name": "hello",
"functionweights": null
},
"createingress": false,
"host": "",
"ingressconfig": {
"annotations": null,
"host": "*",
"path": "/hello",
"tls": ""
},
"method": "",
"prefix": ""
}
}
```
### 4.3 Реальное сравнение в кластере
| Поле | Наш (tf-hello-route) | CLI (hello-route) | Вердикт |
|------|----------------------|-------------------|---------|
| `spec.relativeurl` | ✅ | ✅ | ✅ OK |
| `spec.methods` | ✅ | ✅ | ✅ OK |
| `spec.functionref.type` | `"name"` | `"name"` | ✅ OK |
| `spec.functionref.name` | ✅ | ✅ | ✅ OK |
| `spec.functionref.functionweights` | ❌ | `null` | ✅ Не нужно |
| `spec.createingress` | ✅ | ✅ | ✅ OK |
| `spec.host` | ❌ (если пусто) | `""` | ✅ Не критично |
| `spec.ingressconfig` | Частично (host) | Полный | ⚠️ IngressConfig неполный |
| `spec.method` | ❌ | `""` | ✅ Legacy, не нужно |
| `spec.prefix` | ❌ | `""` | ⚠️ Для prefix routing — добавить |
### 4.4 Выводы по HTTPTrigger
**Багов нет.** Работает корректно. Мелкие расхождения не влияют.
**Что можно добавить позже:**
- `prefix` (string) → `spec.prefix` — для prefix-based routing
- `keep_prefix` (bool) → `spec.keepPrefix`
- Полный `ingressconfig` (annotations, path, tls) — при `create_ingress=true`
- `function_weights` (map) → для canary deployments
---
## 5. CLIENT (client.go)
### 5.1 Оценка
**Код корректный.** Чистый CRUD через `dynamic.Interface`:
- 4 GVR определения (environments, packages, functions, httptriggers)
- CRUD для каждого: Create/Get/Update/Delete
- `IsNotFound()` для обработки 404
- `New()` строит config из kubeconfig + context
**Расхождений с Fission нет** — это наш собственный low-level клиент для работы с CRD.
---
## 6. VALIDATION (validation_helpers.go, entrypoint validation)
### 6.1 Оценка
- `ensureEnvironmentExists` — ✅ корректно (проверяет наличие env перед созданием pkg/fn)
- `ensurePackageExists` — ✅ корректно
- `validateEntrypointAgainstPackageSource` — ⚠️ Проверяет только Python `def funcname(`. Не проверяет:
- Node: `module.exports` или `export function`
- Go: plugin symbol
- PHP: `function handler(`
- Ruby: `def handler`
**Это допустимо** — избыточная валидация может мешать. Лучше валидировать только точно известные паттерны.
---
## 7. СВОДНАЯ ТАБЛИЦА ПРИОРИТЕТОВ
### 🔴 Критично (блокирует функционал)
| # | Ресурс | Проблема | Решение |
|---|--------|----------|---------|
| 1 | Environment | Нет builder support | Добавить `builder_image`, `builder_command` |
| 2 | Package | Нет source archive mode | Добавить zip-упаковку source_dir → `spec.source.literal` |
### 🟡 Важно (улучшает пользовательский опыт)
| # | Ресурс | Проблема | Решение |
|---|--------|----------|---------|
| 3 | Function | Захардкожен poolmgr | Добавить `executor_type` с optional default |
| 4 | Function | Нет пользовательских таймаутов | Добавить `function_timeout`, `idle_timeout` |
| 5 | Function | Нет min/max scale | Добавить `min_scale`, `max_scale` |
| 6 | Package | Пустой `source: {}` мусор | Убрать пустой source из payload |
### ⚪ Не критично (можно позже)
| # | Ресурс | Проблема |
|---|--------|----------|
| 7 | Environment | Нет resources, imagepullsecret, keeparchive |
| 8 | Function | Нет concurrency, requestsPerPod, resources, secrets, configmaps |
| 9 | HTTPTrigger | Нет prefix, keepPrefix, полного ingressconfig |
| 10 | Package | Нет StorageSvc загрузки (>256KB) |
| 11 | Package | Нет checksum |
---
## 8. ПЛАН РЕАЛИЗАЦИИ (предлагаемый)
### Этап 1: Builder support (Environment + Package)
**environment_resource.go:**
- Добавить поля `builder_image` и `builder_command` в модель и schema
- Добавить `spec.builder` в `environmentToUnstructured` (если builder_image задан)
- Обновить `unstructuredToEnvironmentModel` для чтения builder полей
**package_resource.go:**
- Добавить поле `deploy_type` (string: `"literal"` или `"source"`, default `"literal"`)
- При `deploy_type = "source"`: zip source_dir → base64 → `spec.source.literal`, `spec.deployment` пустой
- Убрать пустой `"source": {}` при deploy_type = "literal"
- Добавить base64 size check (<256KB) при literal mode
### Этап 2: Function tuning
**function_resource.go:**
- Добавить optional поля: `executor_type`, `function_timeout`, `idle_timeout`, `min_scale`, `max_scale`
- Обновить `functionToUnstructured` для заполнения ExecutionStrategy полностью
- Обновить `unstructuredToFunctionModel` для чтения новых полей
### Этап 3: Мелкие улучшения
- HTTPTrigger: prefix, keepPrefix
- Package: checksum
- Environment: resources, imagepullsecret
---
## 9. ВЫВОД
Наш провайдер **работает корректно для основного сценария**: Python/Node/PHP/Ruby/Perl literal deployment + poolmgr executor. Все критические поля (version, runtime.image, poolsize, deployment.literal, functionName, relativeurl, methods) генерируются правильно.
**Главные пробелы:**
1. Нет builder support → Go и любые compiled languages не работают через builder pipeline
2. Нет source archive → только deployment-only (literal из одного файла)
3. Function executor hardcoded to poolmgr → нет newdeploy/container strategy
4. Нет пользовательских таймаутов
Ни один из пробелов не является **ошибкой** в существующем коде — это **недостающий функционал**. То, что есть, соответствует канону Fission.
+399
View File
@@ -0,0 +1,399 @@
# План: поддержка всех языков Fission
**Дата:** 2026-04-15
**Исполнитель:** Sonnet / GPT 5.3 Codex (AI-агент)
**Цель:** Добавить рабочие функции на Go, Java, .NET, Ruby, Rust, PHP — и ПРОТЕСТИРОВАТЬ каждую
---
## Текущее состояние
| Язык | Статус | Проблемы |
|---|---|---|
| Python | ✅ Работает | — |
| Node.js | ✅ Работает | — |
| Go | ❌ Timeout | Runtime compilation > 20s specialization timeout |
| Java | ❌ Не развёрнуто | — |
| .NET | ❌ Не развёрнуто | — |
| Ruby | ❌ Не развёрнуто | — |
| Rust | ❌ Не развёрнуто | — |
| PHP | ❌ Не развёрнуто | — |
---
## КРИТИЧЕСКИЕ ПРАВИЛА (обязательно для агента)
1. **ВСЕ КОМАНДЫ — ТОЛЬКО ЧЕРЕЗ SSH:**
```bash
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 'КОМАНДА'
```
2. **Файлы редактировать можно локально**`/home/naeel/remote_dev/fission/` = `~/terra/fission/` на VM
3. **Terraform деплой:**
```bash
ssh ... 'cd ~/terra/fission/examples/FOLDER && TF_CLI_CONFIG_FILE=/tmp/terraformrc-fission terraform apply -auto-approve'
```
4. **Проверка функции (JWT обязателен):**
```bash
ssh ... '
PASSWORD=$(kubectl -n fission get secret router -o jsonpath={.data.password} | base64 -d)
TOKEN=$(curl -sk -X POST https://fission.kube5s.ru/auth/login -H "Content-Type: application/json" -d "{\"username\":\"admin\",\"password\":\"$PASSWORD\"}" | python3 -c "import sys,json; print(json.load(sys.stdin)[\"accesstoken\"])")
curl -sk -w "\nHTTP=%{http_code}\n" -H "Authorization: Bearer $TOKEN" https://fission.kube5s.ru/ENDPOINT
'
```
5. **НЕ ТРОГАТЬ существующие функции** — 15 шт., список в copilot-instructions.md
---
## Шаг 0: Проверить доступность образов (ОБЯЗАТЕЛЬНО ПЕРВЫМ)
Перед созданием — проверить что образы exist на ghcr.io:
```bash
ssh ... '
for lang in go java jvm dotnet dotnet20 ruby perl php binary; do
echo "--- $lang ---"
# Попробовать pull (dry-run)
docker pull ghcr.io/fission/${lang}-env:latest 2>&1 | head -3
echo
done
'
```
Если образ не найден — попробовать варианты:
- `ghcr.io/fission/go-env`
- `ghcr.io/fission/jvm-env` (Java = JVM в Fission!)
- `ghcr.io/fission/dotnet20-env` или `ghcr.io/fission/dotnet-env`
- `ghcr.io/fission/ruby-env`
- `ghcr.io/fission/php-env`
- `ghcr.io/fission/binary-env` (для предкомпилированных)
- `ghcr.io/fission/perl-env`
Если образ не существует — НЕ создавать функцию, пропустить язык.
---
## Шаг 1: Go (ПРОБЛЕМНЫЙ — требует особого подхода)
### Проблема
Go env компилирует код при specialization → timeout 20s → функция не работает.
### Решение: использовать `binary-env`
Go компилируется на VM → заливается как бинарник → binary-env его запускает.
**Или:** увеличить specialization timeout через InvokeStrategy:
```yaml
spec:
InvokeStrategy:
ExecutionStrategy:
ExecutorType: newdeploy # вместо poolmgr
MinScale: 1 # always running
MaxScale: 3
```
### Вариант A: pre-compiled + binary-env
```
examples/go-hello/
├── code/
│ └── main.go # исходник для справки
├── build.sh # скрипт для компиляции
└── main.tf
```
**build.sh:**
```bash
#!/bin/bash
cd code
CGO_ENABLED=0 GOOS=linux go build -o handler main.go
```
**main.tf** — использовать `binary-env` вместо `go-env`
### Вариант B: newdeploy executor
В Terraform manifests задать `executor_type = "newdeploy"` и `min_scale = 1`.
Проверить поддерживает ли наш провайдер эти аргументы:
```bash
ssh ... 'grep -r "executor\|newdeploy\|min_scale\|invoke_strategy" ~/terra/fission/internal/'
```
### Код функции (main.go):
```go
package main
import (
"net/http"
)
func Handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("Hello from Go in Fission"))
}
```
### Тест: HTTP 200, body содержит "Hello from Go"
---
## Шаг 2: Java (JVM)
### Образ: `ghcr.io/fission/jvm-env`
### Структура:
```
examples/java-hello/
├── code/
│ └── io/fission/Function.java
└── main.tf
```
### Код:
```java
package io.fission;
import org.springframework.http.RequestEntity;
import org.springframework.http.ResponseEntity;
public class Function implements io.fission.Function {
@Override
public ResponseEntity<?> call(RequestEntity req, io.fission.Context context) {
return ResponseEntity.ok("Hello from Java in Fission");
}
}
```
**ВАЖНО:** Проверить точный интерфейс JVM env. Может потребоваться другой формат:
```bash
ssh ... 'docker run --rm ghcr.io/fission/jvm-env:latest cat /app/README.md 2>/dev/null || echo "No README"'
```
### main.tf:
```hcl
resource "fission_environment" "jvm" {
name = "tf-jvm-hello-env"
image = "ghcr.io/fission/jvm-env"
version = 3
}
# ... стандартный pattern
```
### Тест: HTTP 200, body содержит "Hello from Java"
---
## Шаг 3: PHP
### Образ: `ghcr.io/fission/php-env`
### Структура:
```
examples/php-hello/
├── code/
│ └── hello.php
└── main.tf
```
### Код:
```php
<?php
return function() {
return "Hello from PHP in Fission";
};
```
**ВАЖНО:** entrypoint для PHP — имя файла без расширения (`hello`).
### Тест: HTTP 200, body содержит "Hello from PHP"
---
## Шаг 4: Ruby
### Образ: `ghcr.io/fission/ruby-env`
### Структура:
```
examples/ruby-hello/
├── code/
│ └── hello.rb
└── main.tf
```
### Код:
```ruby
def main
"Hello from Ruby in Fission"
end
```
### Тест: HTTP 200, body содержит "Hello from Ruby"
---
## Шаг 5: .NET (C#)
### Образ: `ghcr.io/fission/dotnet-env` или `ghcr.io/fission/dotnet20-env`
### Структура:
```
examples/dotnet-hello/
├── code/
│ └── FissionFunction.cs
└── main.tf
```
### Код:
```csharp
using System;
using Fission.DotNetCore.Api;
public class FissionFunction
{
public string Execute(FissionContext context)
{
return "Hello from .NET in Fission";
}
}
```
### Тест: HTTP 200, body содержит "Hello from .NET"
---
## Шаг 6: Rust
### ВЕРОЯТНО НЕ СУЩЕСТВУЕТ официально.
Проверить:
```bash
ssh ... 'docker pull ghcr.io/fission/rust-env:latest 2>&1'
```
Если нет — пропустить. Rust можно реализовать через `binary-env` (pre-compiled).
---
## Шаг 7: Perl (бонус)
### Образ: `ghcr.io/fission/perl-env`
```perl
sub main {
return "Hello from Perl in Fission";
}
```
---
## Порядок выполнения (ВАЖНО)
```
1. Проверить доступность ВСЕХ образов (docker pull) → составить список реальных
2. Для каждого доступного языка:
a. Создать examples/LANG-hello/code/... + main.tf
b. terraform apply
c. curl с JWT → проверить HTTP 200 + ожидаемый body
d. Если не работает — смотреть логи:
kubectl -n default get events --sort-by=".lastTimestamp" | tail -20
kubectl -n default get pods | grep poolmgr-LANG
kubectl -n default logs <pod> -c <container> --tail=30
e. Если timeout — попробовать newdeploy executor
f. Если всё равно не работает — удалить через terraform destroy
3. Обновить список в консоли (она автоматически видит новые функции)
4. Протестировать все через консоль (invoke)
5. Коммит + пуш + тег
```
---
## Критерий успеха
| Язык | Endpoint | Ожидаемый body | HTTP |
|---|---|---|---|
| Go | `/go-hello` | `Hello from Go in Fission` | 200 |
| Java | `/java-hello` | `Hello from Java in Fission` | 200 |
| PHP | `/php-hello` | `Hello from PHP in Fission` | 200 |
| Ruby | `/ruby-hello` | `Hello from Ruby in Fission` | 200 |
| .NET | `/dotnet-hello` | `Hello from .NET in Fission` | 200 |
| Perl | `/perl-hello` | `Hello from Perl in Fission` | 200 |
**Минимум:** 3 новых языка работают (Go + ещё 2)
**Идеал:** все 6
---
## Шаблон main.tf (копировать и менять)
```hcl
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.1.0"
}
}
}
provider "fission" {
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "default"
}
resource "fission_environment" "env" {
name = "tf-LANG-hello-env"
image = "ghcr.io/fission/LANG-env"
version = 3
}
resource "fission_package" "pkg" {
name = "tf-LANG-hello-pkg"
environment = fission_environment.env.name
source_dir = "${path.module}/code"
}
resource "fission_function" "fn" {
name = "tf-LANG-hello-fn"
environment = fission_environment.env.name
package_name = fission_package.pkg.name
entrypoint = "ENTRYPOINT"
}
resource "fission_http_trigger" "route" {
name = "tf-LANG-hello-route"
function = fission_function.fn.name
url = "/LANG-hello"
methods = ["GET"]
}
```
---
## Entrypoint для каждого языка
| Язык | Entrypoint | Файл |
|---|---|---|
| Python | `main.main` | `main.py` |
| Node.js | `module.exports` (пустой) | `server.js` |
| Go | `Handler` | `main.go` |
| Java | `io.fission.Function` | `Function.java` |
| PHP | `hello` | `hello.php` |
| Ruby | `hello.main` | `hello.rb` |
| .NET | `FissionFunction.Execute` | `FissionFunction.cs` |
| Perl | `hello.main` | `hello.pm` |
**⚠️ ВНИМАНИЕ:** Entrypoints могут отличаться от указанных! Проверять документацию каждого env:
```bash
ssh ... 'docker run --rm ghcr.io/fission/LANG-env:latest env 2>/dev/null | head -20'
```
---
## Откат при неудаче
Если язык не работает:
```bash
ssh ... 'cd ~/terra/fission/examples/LANG-hello && TF_CLI_CONFIG_FILE=/tmp/terraformrc-fission terraform destroy -auto-approve'
rm -rf examples/LANG-hello/
```
+240
View File
@@ -258,6 +258,30 @@
#### Правила репозитория #### Правила репозитория
Создан `.github/copilot-instructions.md` с правилами: Создан `.github/copilot-instructions.md` с правилами:
### Обновление этапа (UI invoke stability: JS)
- Проведена диагностика JS invoke через реальный UI-путь (`/console/api/functions/{name}/invoke`) и роутер/экзекьютор логи.
- Найдена корневая причина JS-таймаутов:
- для `node-env` используется `v2/specialize`
- при `functionName=main` runtime пытался загрузить `/userfunc/deployarchive/main`
- при plain literal `deployarchive` является файлом, не директорией
- после specialize без корректного контракта запросы зависали и упирались в router roundtripper timeout.
- Для `nodejs-acc` зафиксирован runtime image: `ghcr.io/fission/node-env:1.32.5`.
- Для JS-функций выровнен контракт runtime:
- `spec.package.functionName` выставлен в пустой entrypoint (`""`) для default export
- код приведен к формату ответа Node runtime: `return { status: 200, body: "..." }`
- для основных JS-маршрутов включены методы `GET` + `POST`.
- Подтверждена работоспособность через console invoke:
- `fn-js-acc` -> `status=200`, `response_raw=hello-js-ok`
- `fn-js-direct` -> `status=200`, `response_raw=hello-js-ok`
- тестовая матрица `jsm1..jsm4` -> `status=200`.
### Статус после фикса
- Python invoke: работает.
- JS invoke: работает по UI-пути и по прямому GET роутов.
- Go invoke (`fn-go-acc`): остается отдельной runtime-проблемой (вне JS-фикса).
- Маппинг путей (локально ~/remote_dev/ = ВМ ~/terra/) - Маппинг путей (локально ~/remote_dev/ = ВМ ~/terra/)
- Редактирование файлов — разрешено локально - Редактирование файлов — разрешено локально
- Команды — ИСКЛЮЧИТЕЛЬНО через SSH (VPN-конфликты) - Команды — ИСКЛЮЧИТЕЛЬНО через SSH (VPN-конфликты)
@@ -266,3 +290,219 @@
- UI: автоматическое обновление дашборда после create/delete - UI: автоматическое обновление дашборда после create/delete
- Рассмотреть добавление логов функций (kubectl logs) - Рассмотреть добавление логов функций (kubectl logs)
- Рассмотреть добавление time triggers в UI - Рассмотреть добавление time triggers в UI
---
## 2026-04-15 (дополнение) — Fix по результатам баг-репорта
### Что исправлено в Terraform provider
- `fission_package`: добавлен `ModifyPlan`, который автоматически пересчитывает `code_hash` по локальному коду (`source_dir`/`code_path`).
- `fission_package`: добавлена валидация существования `environment` до создания/обновления package.
- `fission_function`: добавлена валидация существования `environment` и `package` до create/update.
- `fission_function`: добавлена pre-flight валидация `entrypoint` для Python-исходника (`main.func` должен существовать как `def func(`).
- Добавлены unit-тесты на новый функционал (`code_hash`, entrypoint validation).
### Проверка на живом кластере
- После изменения `examples/hello-python/code/main.py` `terraform plan` теперь показывает `fission_package.hello will be updated in-place` с изменением `code_hash`.
- Конфигурация с несуществующим `environment` теперь падает на этапе apply с ошибкой валидации.
- Конфигурация с неверным `entrypoint` теперь падает на этапе apply с ошибкой валидации.
### Ограничения
- Runtime-проблемы Fission (зависания на cold start/таймауты выполнения) в этом изменении не трогались: исправлен только provider-слой валидации и обнаружения изменений.
## 2026-04-15 (дополнение) — Fix отображения кода в Console UI
### Проблема
- В модальном окне редактирования функции (`Edit Code`) для некоторых пакетов отображались байты ZIP (`PK...`) вместо исходного кода.
### Причина
- `GET /console/api/functions/{name}` декодировал `spec.deployment.literal` только из base64, но не обрабатывал архивированный payload.
### Исправление
- В `console/main.go` добавлено декодирование `literal` с поддержкой ZIP:
- если payload plain text/utf-8 -> отдаётся как есть
- если payload ZIP -> извлекается `main.py`/`main.js`/`main.go` (или первый utf-8 файл)
- Обновлён deployment image: `naeel/fission-console:v0.2.4`.
### Проверка
- `GET /console/api/functions/fn-go-acc` теперь возвращает читаемый Go-код, без `PK...` сигнатур.
## 2026-04-15 (дополнение) — Полный пользовательский прогон UI и invoke-fix
### Что проверено как пользовательский сценарий
- Прогнан массовый invoke через UI API (`POST /console/api/functions/{name}/invoke`) для всех функций в списке.
- Итого: `31` функций, из них `25` успешно отработали, `6` вернули 502/timeout (ожидаемо проблемные/сломанные кейсы).
- Проверен полный edit flow через UI API на рабочей функции:
- `GET function` -> `PUT /code` -> `POST /invoke` -> `PUT /code` (restore) -> `POST /invoke`.
### Найденный UI-баг и исправление
- Баг: после обновления кода через UI следующий invoke мог отдавать старую специализацию/кэш.
- Причина: обновлялся только `Package`, но не обновлялся `Function.spec.package.packageref.resourceversion`.
- Фикс в `console/main.go`:
- после `package update` читается новый `package.resourceVersion`
- выполняется update `Function` с новым `packageref.resourceversion`
- invoke сразу использует новую (или восстановленную) версию кода.
### Проверка фикса
- После update через UI invoke возвращает новый ответ.
- После restore через UI invoke возвращает исходный ответ (без зависания старого кэша).
### Статус `fn-go-acc`
- `fn-go-acc` продолжает падать не из-за UI, а из-за runtime specialization на стороне Fission.
- Подтверждено логами `router/executor`: `GetServiceForFunction ... context canceled` и постоянными readiness-fail у poolmgr pod-ов `go-acc`.
## 2026-04-15 (дополнение) — Финальный фикс `fn-go-acc`
### Симптом и root cause
- `fn-go-acc` стабильно timeout'ился на invoke.
- Изначальный пакет содержал `main.go` как `deployment.literal`, из-за чего go-runtime пытался грузить текст как plugin:
- `plugin.Open("/userfunc/deployarchive/main.go"): invalid ELF header`.
### Что сделано
- Пересобран deploy-артефакт как Go plugin (`main.so`) и упакован в `deploy.zip`.
- Важно: сборка выполнена в том же образе, что у Fission environment builder:
- `ghcr.io/fission/go-builder` (Go 1.25.6), чтобы избежать несовместимости plugin ABI.
- Функция обновлена через Fission CLI:
- `fission function update -n default --name fn-go-acc --env go-acc --entrypoint Handler --deployarchive /tmp/fn-go-acc-fix2/deploy.zip -f`.
### Результат проверки
- `/go-acc` через router с JWT: `hello-go-ok`, `HTTP 200`.
- Executor логи: specialization проходит успешно (`specialized pod`, `added function service`), без `invalid ELF`.
### Контрольный smoke после фикса
- `/auto/ok` -> `HTTP 200`
- `/js-acc` -> `HTTP 200`
- `/js-direct` -> `HTTP 200`
- `/go-acc` -> `HTTP 200`
## 2026-04-15 (дополнение) — Edit Code для `fn-go-acc` снова показывает исходник
### Проблема
- В `Edit Code` для `fn-go-acc` поле `code` было пустым.
- Причина: после `--deployarchive` пакет `pkg-go-acc` перешел на `spec.deployment.type=url`, а в console backend чтение кода шло только из `spec.deployment.literal`.
### Исправление
- В `console/main.go` добавлен fallback-поиск исходника:
- `spec.source.literal`
- `spec.deployment.literal`
- `spec.source.url`
- `spec.deployment.url`
- Добавлена загрузка архива по URL и извлечение исходника из zip (если есть текстовые файлы).
- Добавлен unit-тест `TestGetFunctionUsesSourceLiteralWhenDeploymentLiteralMissing`.
- Собран и выкачен образ `naeel/fission-console:v0.2.6`, деплой обновлен.
### Дополнительно по данным в кластере
- Для `fn-go-acc` обновлен `sourcearchive` (`main.go`), чтобы `pkg-go-acc.spec.source.literal` содержал исходник и был доступен в Edit Code.
### Проверка
- `GET /console/api/functions/fn-go-acc` возвращает непустой `code` (Go source).
- `/go-acc` продолжает отвечать `hello-go-ok`, `HTTP 200`.
## 2026-04-15 (дополнение) — TLS в console, бренд NUBES, проверка `tf-neg-syntax-fn`
### TLS: фактический статус
- `http://fission.kube5s.ru/console/` -> `308 Permanent Redirect` на `https://...`
- `https://fission.kube5s.ru/console/` -> `200`
- Ingress `fission-console` настроен с:
- `nginx.ingress.kubernetes.io/force-ssl-redirect: "true"`
- `tls.secretName: fission-tls`
- `cert-manager.io/cluster-issuer: letsencrypt-prod`
### UI оформление (NUBES)
- В `console/ui/index.html` добавлены:
- favicon (inline SVG)
- брендинг в navbar: mark + wordmark `NUBES` / `FISSION CONSOLE`
- обновлен `<title>` на `NUBES Fission Console`
- Выкат: `naeel/fission-console:v0.2.9`.
### Invoke `tf-neg-syntax-fn` — проверка
- Подтверждено, что у функции синтаксически невалидный код (`def main(: ...`).
- Console invoke теперь возвращает fail-fast ошибку (не «молчание»):
- `{"error":"invoke \"tf-neg-syntax-fn\" timeout after 20s: function specialization likely failed (for example, syntax error)"}`
- Причина на кластере: specialization падает в runtime с `500`, executor делает ретраи.
## 2026-04-15 (дополнение) — Лечение "красного" HTTPS
### Root cause
- На одном host `fission.kube5s.ru` было два ingress:
- `fission-console` с TLS
- `fission-router` без TLS
- Из-за смешанной host-конфигурации TLS мог работать нестабильно/давать красный индикатор в браузере.
### Fix
- Пропатчен `fission/fission-router`:
- добавлен `spec.tls` с `secretName: fission-tls`
- добавлена аннотация `nginx.ingress.kubernetes.io/force-ssl-redirect: "true"`
### Проверка
- `http://fission.kube5s.ru/neg/syntax` -> `308` redirect на HTTPS
- Сертификат endpoint: Let's Encrypt R13, CN/SAN `fission.kube5s.ru`, валиден
- `https://fission.kube5s.ru/console/` -> `HTTP 200`
## 2026-04-15 (дополнение) — Строгая очистка: оставлены только рабочие и с видимым кодом
### Требование
- Убрать всё лишнее и оставить только функции, которые одновременно:
- имеют непустой `code` в `GET /console/api/functions/{name}`
- успешно проходят `POST /console/api/functions/{name}/invoke` со `status=200`
### Что сделано
- Выполнена финальная очистка функций по whitelist.
- После очистки дополнительно проверены все оставшиеся функции через Console API (code + invoke).
- Исправлена ошибка в служебном cleanup-скрипте (битый jsonpath с `{n}`), из-за которой падала пост-обработка orphan-ресурсов.
### Итоговый набор
- `auth-test2`
- `fn-js-direct`
- `fn-js-acc`
- `hello`
- `tf-auto-ok-fn`
- `tf-stress-fast-fn`
### Финальное состояние кластера
- `functions=6`
- `httptriggers=6`
- `packages=6`
### Финальная валидация
- Для каждой из 6 функций: `code=OK`, `invoke=OK`.
## 2026-04-15 (дополнение) — Наращивание до 15 функций + исправление фавикона в live UI
### Запрос
- Увеличить набор до 15 функций, включая error-кейсы.
- Прогнать тесты и чинить код/манифесты при расхождении с ожиданием.
- Исправить favicon в реальном UI (а не только в репозитории).
### Что сделано по функциям
- Подняты функции из `examples`: `hello-python`, `deep-recursion`, `destroy-test`, `frequent-update`, `orphan-test`, `multi-env-1/2/3`.
- Добавлен негативный кейс `tf-neg-syntax-fn` с route `/neg/syntax`.
- Доведено до ровно `functions=15`, `httptriggers=15`.
### Фиксы по коду/манифестам (по результатам тестов)
- `examples/multi-env-1/main.tf`
- исправлен `source_dir` с `"$\{path.module\}/code"` на `"${path.module}/code"`.
- заменен image env с `ghcr.io/fission/python-env:v1.20.0` на `ghcr.io/fission/python-env`.
- `examples/multi-env-2/main.tf`
- аналогичные исправления `source_dir` и image.
- `examples/multi-env-3/main.tf`
- аналогичные исправления `source_dir` и image.
### Тест-матрица (router + JWT)
- Итог: `PASS 15 / FAIL 0`.
- Успешные (`HTTP 200`):
- `/auth-test2`, `/js-acc`, `/js-direct`, `/hello`, `/auto/ok`, `/destroy-test`, `/freq-update`, `/tf-hello`, `/multi-env-1`, `/multi-env-2`, `/multi-env-3`, `/orphan-test`, `/stress/fast`.
- Ожидаемые error-кейсы:
- `/deep-recursion` -> timeout (`curl rc=28`, `HTTP 000`)
- `/neg/syntax` -> timeout (`curl rc=28`, `HTTP 000`)
- Доп. проверка через Console invoke:
- `tf-multi-env-1-fn` -> `status=200`
- `tf-hello-fn` -> `status=200`
- `tf-neg-syntax-fn` и `tf-deep-recursion-fn` -> ожидаемая fail-fast ошибка invoke timeout.
### Фавикон (live)
- Причина «старого фавикона»: в кластере работал старый образ `naeel/fission-console:v0.3.0`.
- Собран и выкачен новый образ: `naeel/fission-console:v0.3.1`.
- Deployment обновлен и успешно прокатан.
- Проверено в live HTML: отдается
- `<link rel="icon" type="image/png" href="https://nubes.ru/themes/custom/nubes_2025/favicon.png">`.
+91
View File
@@ -0,0 +1,91 @@
# fission-console v0.6.9 — Отчёт о тестировании
**Дата:** 2026-04-19
**Версия:** `naeel/fission-console:v0.6.9`
**Ветка:** `feat/namespace-isolation`
---
## Что реализовано
Managed serverless functions service поверх Fission + Kubernetes. Go-бэкенд (консоль), REST API, k8s dynamic client. Пользователи изолированы по namespace (`fission-{SHA256(sub)[:16]}`).
**Поддерживаемые среды выполнения:**
- Node.js 22 (ESM, `module.exports` / `export default` / анонимная функция)
- Python 3.11 (Flask, `def main():` без аргументов)
**Auth:** JWT или TEST_MODE (`X-Test-Sub: user@domain` → SHA256 → namespace)
---
## Баги исправлены в этой сессии (v0.6.8 → v0.6.9)
| # | Баг | Симптом | Фикс |
|---|-----|---------|------|
| 1 | Route collision | Два разных пользователя создавали функцию с одинаковым маршрутом | Префикс последних 12 символов namespace: `/{ns[-12:]}/{fn-name}` |
| 2 | INVOKE несуществующей → 200 | Вызов несуществующей функции возвращал HTTP 200 без ошибки | GET из k8s перед invoke, `IsNotFound` → 404 |
| 3 | DELETE несуществующей → 200 | Удаление несуществующей функции возвращало `deleted:true` | Инвертирован `if err == nil``if err != nil`, 404 |
| 4 | Orphan package при невалидном TTL | `parseTTL` вызывался после создания пакета — при ошибке оставался мусорный package в k8s | `parseTTL` перемещён **до** создания k8s-ресурсов |
---
## Результаты тестирования
```
PASS=41 FAIL=0
```
**Покрытие (16 блоков):**
| Блок | Что проверяется |
|------|----------------|
| A | Регресс v0.6.8: INVOKE/DELETE несуществующей → 404, route isolation |
| B | Валидация входных данных → 400 (missing name/code/language, пробелы) |
| C | Дублирующее создание → конфликт |
| D | UPDATE кода + повторный invoke возвращает новый код |
| E | GET функции (200/404) |
| F | LIST изоляция — пользователь видит только свои функции |
| G | Namespace изоляция DELETE — нельзя удалить чужую функцию |
| H | Python `def main():` (без аргументов) → invoke 200 |
| I | Node.js: `module.exports`, `export.handler`, `export default` |
| J | TTL: expires_at создаётся / не создаётся / невалидный → 400 |
| K | `ctx.request.url` доступен внутри функции |
| L | Stress: 10 параллельных invoke → 10/10 OK |
| M | LIST пустого namespace → `[]` |
| N | DELETE полная цепочка: Function + Package + HTTPTrigger удаляются |
| O | Запрос без auth-заголовка → 401 |
| P | POST /auth: без токена → 400, невалидный токен → 401 |
---
## Архитектурные решения — вопросы для ревью
### 1. Namespace on-demand
Namespace создаётся при первом обращении пользователя. Нет отдельного registration flow.
**Вопрос:** правильно ли это? Нет ли рисков при параллельном первом запросе от одного пользователя (race condition на создание namespace)?
### 2. Сборка функций
Код пользователя пакуется в zip в памяти и передаётся в Fission Package как `literal` (base64). При обновлении создаётся новый Package, старый удаляется.
**Вопрос:** нет версионирования. Стоит ли хранить историю версий?
### 3. Reaper
Горутина раз в 30 секунд проверяет `expires-at` аннотацию и удаляет протухшие функции вместе с Package и HTTPTrigger. Работает без персистентного стейта — при рестарте пода начинает заново со следующего цикла.
**Вопрос:** надёжно ли это? Что если под упал в момент удаления — останется ли мусор?
### 4. Python env и сигнатура main()
Официальный `ghcr.io/fission/python-env` вызывает `main(*args)` через Flask. Реально аргументы не передаются (только через route params). При `def main():` работает. При `def main(ctx):``TypeError: main() missing 1 required positional argument`.
**Вопрос:** патчить env или задокументировать ограничение?
### 5. Валидация имени функции
Имя с пробелами возвращает 502 (k8s отклоняет по RFC 1123), а не 400 (явная валидация в API).
**Вопрос:** стоит ли добавить regex-валидацию имени на уровне API до обращения в k8s?
---
## Что ещё не сделано
- [ ] Версионирование функций
- [ ] Явная валидация имени функции (RFC 1123) в API
- [ ] Метрики / биллинг
- [ ] Документация API (OpenAPI spec)
- [ ] Merge в master
+56
View File
@@ -0,0 +1,56 @@
# Отчёт 2026-04-20 — Multi-language support v0.7.6
## Цель
Добиться PASS=73/FAIL=0 в `tests_v2.sh` — тесты на PHP, Ruby, Perl invoke (блоки AA/AB/AC).
## Что сделано
### 1. NodeJS ESM несовместимость (livetest `lt-node`)
**Баг:** `lt-node` была создана с ESM кодом (`export default async function`). Wrapper в `buildJSDeployZip` использует `new Function('module','exports', код)` — ESM синтаксис внутри `new Function` вызывает SyntaxError.
**Фикс:** Пересоздан `lt-node` с CJS кодом: `module.exports = async function(ctx) { return { body: "node-ok", status: 200 }; }`
**Результат:** PASS блок D (NodeJS livetest invoke).
### 2. Ruby livetest задвоенный `def handler`
**Баг:** `lt-ruby` создана с задвоенным `def handler` — SyntaxError.
**Фикс:** Пересоздана с правильным кодом.
**Результат:** PASS блок F (Ruby livetest invoke).
### 3. `fission-fetcher` SA не создавался в user namespace (ГЛАВНЫЙ БАГ)
**Баг:** Pool pod Fission запускается в user namespace с `serviceAccountName: fission-fetcher`. `ensureUserNamespace` создавала только RoleBindings (ссылки на SA из namespace `fission`), но сами SA `fission-fetcher` и `fission-builder` в user namespace не создавала.
Итог: `FailedCreate: serviceaccount "fission-fetcher" not found` → pool pod не стартует → invoke timeout → FAIL.
В livetest namespace SA были созданы ранее вручную — поэтому livetest-блоки проходили, новые тестовые namespace — нет.
**Фикс** в `ensureUserNamespace` (`console/main.go`):
```go
saGVR := schema.GroupVersionResource{Group: "", Version: "v1", Resource: "serviceaccounts"}
for _, saName := range []string{"fission-fetcher", "fission-builder"} {
saObj := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "v1",
"kind": "ServiceAccount",
"metadata": map[string]any{"name": saName, "namespace": ns},
}}
_, saErr := s.dyn.Resource(saGVR).Namespace(ns).Create(ctx, saObj, metav1.CreateOptions{})
if saErr != nil && !apierrors.IsAlreadyExists(saErr) {
log.Printf("ensureUserNamespace: create SA %s/%s: %v", ns, saName, saErr)
}
}
```
SA создаются явно перед RoleBindings, операция idempotent.
### 4. Оптимизация тестов AA-AC (один namespace)
**Проблема:** `addNSToFission` патчит deployments при каждом новом namespace → rolling update ~60-90s. 3 разных namespace = 3 rollout'а.
**Фикс в `tests_v2.sh`:** Все три блока AA/AB/AC используют один namespace `lang-multi-${RUN_ID}@test.local` → 1 rollout вместо 3. Ретраи увеличены до 20 (200s).
## Результат
| Версия | PASS | FAIL |
|--------|------|------|
| v0.7.5 (до) | 67 | 6 |
| **v0.7.6 (после)** | **73** | **0** |
Все языки: NodeJS ✅, PHP ✅, Ruby ✅, Perl ✅
## Деплой
- Docker: `naeel/fission-console:v0.7.6`
- Git commit: `e3db3cf` branch `feat/namespace-isolation`
- Container name: `console` (не `fission-console`)
+110
View File
@@ -0,0 +1,110 @@
# Задачи после code review (2026-04-19)
Приоритет: **критично** → сделать до merge в master.
---
## КРИТИЧНО
### 1. k8s ошибки протекают как 502 — нужны правильные HTTP коды
**Файл:** `console/main.go`
**Проблема:** `apierrors.IsAlreadyExists` и `apierrors.IsInvalid` не перехватываются → клиент получает 502 вместо 409/400.
**Что сделать:**
- В `handleCreateFunction`: перехватить `apierrors.IsAlreadyExists` → HTTP 409
- В `handleCreateFunction`: перехватить `apierrors.IsInvalid` → HTTP 400
- Аналогично проверить `handleUpdateFunction`
**Пример:**
```go
if apierrors.IsAlreadyExists(err) {
writeJSONError(w, http.StatusConflict, fmt.Sprintf("function %q already exists", req.Name))
return
}
if apierrors.IsInvalid(err) {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("invalid function spec: %v", err))
return
}
```
---
### 2. Валидация имени функции на уровне API
**Файл:** `console/main.go`
**Проблема:** имя с пробелами/спецсимволами уходит в k8s и возвращается 502.
**Что сделать:** добавить regex-валидацию сразу после парсинга запроса в `handleCreateFunction` и `handleUpdateFunction`.
**Пример:**
```go
var validName = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`)
if !validName.MatchString(req.Name) || len(req.Name) > 63 {
writeJSONError(w, http.StatusBadRequest, "invalid function name: must match ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ and be <= 63 chars")
return
}
```
---
## ВАЖНО (не блокирует merge)
### 3. Reaper: сканирование orphan packages
**Файл:** `console/main.go`
**Проблема:** если под упал в момент удаления функции, Package может остаться без matching Function.
**Что сделать:** в цикле reaper дополнительно итерироваться по packages и удалять те, у которых нет соответствующей function с тем же именем (по конвенции `{fn-name}-pkg`).
---
### 4. Лимит размера кода
**Файл:** `console/main.go`
**Проблема:** нет ограничения на размер `req.Code` — можно залить мегабайты.
**Что сделать:** после парсинга тела запроса добавить:
```go
const maxCodeSize = 1 << 20 // 1 MB
if len(req.Code) > maxCodeSize {
writeJSONError(w, http.StatusBadRequest, "code exceeds 1MB limit")
return
}
```
---
### 5. Namespace race condition
**Файл:** `console/main.go`
**Проблема:** при одновременных первых запросах одного пользователя `Create(namespace)` может вернуть `AlreadyExists`.
**Что сделать:** убедиться что в `ensureNamespace` (или аналогичной функции) ошибка `AlreadyExists` при создании namespace игнорируется:
```go
if err != nil && !apierrors.IsAlreadyExists(err) {
return err
}
```
---
## НЕ СРОЧНО
### 6. Тест-скрипт: RUN_ID уникальность
**Файл:** `tests_v2.sh`
**Проблема:** два параллельных запуска с одинаковым timestamp дают одинаковый RUN_ID → Block D FAIL.
**Что сделать:** добавить случайный суффикс:
```bash
RUN_ID=$(date +%s%N | sha256sum | head -c 8)
```
---
## Документировать (без кода)
- Python env: `def main():` без аргументов — задокументировать в README/examples
- Версионирование функций — не делать сейчас, отложить
+20
View File
@@ -0,0 +1,20 @@
# Тест auth v0.5.0 — 2026-04-19
## Что проверено
| Тест | Ожидание | Результат |
|------|----------|-----------|
| GET /console/ | 200 | ✅ 200 |
| POST /console/api/auth {token: "badtoken"} | {"error":"invalid token"} | ✅ |
| GET /console/api/functions без токена | 401 | ✅ 401 |
| Логин с реальным YC IAM токеном | {"ok":true,"env":"test"} | ⏳ не проверено — нет токена |
## Что не проверено
- Полный flow: логин → появление UI → CRUD функций
- Logout → блокировка доступа
- Проверка `env` (dev/prod)
## Итог
Защита работает: без токена — 401, плохой токен — ошибка. Полный flow нужно проверить вручную в браузере после получения YC IAM токена.
+238
View File
@@ -0,0 +1,238 @@
# Bug Report: Fission + Terraform Integration Testing (2026-04-15)
## Executive Summary
- **34 functions deployed**, **17 working correctly**
- **6 CRITICAL/HIGH bugs identified**
- **3 limitations/quirks documented**
---
## 🔴 CRITICAL BUGS
### BUG #1: Hanging on cold start with syntax errors
**Severity:** CRITICAL
**Scope:** Fission runtime
**Symptoms:**
- Function with syntax error in `main.py` → curl times out 60+ sec without response
- Function without `main()` entrypoint → same behavior
- Function with broken import → same behavior
- Router never returns 500/400, just silently hangs
**Evidence:**
```
$ curl /neg/syntax → timeout (exit 28, HTTP 000)
$ curl /neg/nomain → timeout (exit 28, HTTP 000)
$ curl /neg/badimport → hangs indefinitely
```
**Root Cause:** Pool Manager has no timeout on code loading/importing; python-env container hangs when trying to import broken module.
**Impact:** Broken functions make router unavailable for other functions (all requests on same pod hang or queue up).
---
### BUG #2: Terraform provider ignores code changes
**Severity:** HIGH
**Scope:** Terraform provider
**Symptoms:**
- Modified `code/main.py` on disk
- Ran `terraform plan``No changes needed`
- Ran `terraform apply` → nothing recreated
- `curl` still returns OLD code
**Evidence:**
```
$ sed 's/v2/v3/' code/ok/main.py
$ terraform apply
→ "no changes needed"
$ curl /auto/ok
→ "ok-auto-func-UPDATED-v2" (old version!)
```
**Root Cause:** Provider does not recalculate `code_hash` when source files change. Likely uses mtime check incorrectly or doesn't hash at all.
**Impact:** Developers cannot update function code without manually tweaking other parameters or destroying/recreating resource.
**Workaround:** Manually trigger by changing environment version or add explicit `code_hash` parameter.
---
### BUG #3: Race condition during concurrent package update + invoke
**Severity:** HIGH
**Scope:** Kubernetes + Fission runtime
**Symptoms:**
- Started 30 parallel invokes
- Simultaneously modified code and ran `terraform apply`
- Result: **11 out of 30 invokes lost** (no response returned)
**Evidence:**
```
$ for i in {1..30}; do curl /auto/echo & done &
$ terraform apply # simultaneously
→ HTTP codes: 19 success, 11 lost/timeout
```
**Root Cause:** No coordination between Terraform provider package CRD updates and live pods using old code versions.
**Impact:** Request loss (503/timeout), potential data loss.
---
### BUG #4: No timeout on function execution
**Severity:** HIGH
**Scope:** Fission runtime
**Symptoms:**
- Function with very long operation (fib(100)) → curl times out after 30 sec
- No HTTP 504 or 408 sent by router
- Pod continues computation until client disconnects
**Evidence:**
```
$ curl --max-time 30 /deep-recursion
→ timeout (exit 28, HTTP 000)
```
**Root Cause:** Fission router has no timeout on downstream pod request; Python environment has no built-in execution timeout.
**Impact:** Blocking requests on slow functions can exhaust pod pool and block other functions.
---
### BUG #5: No foreign key validation on deploy
**Severity:** MEDIUM
**Scope:** Terraform provider + Fission CRD validation
**Symptoms:**
- Created package/function referencing non-existent environment
- Terraform applied successfully
- Function only fails at invoke time (too late)
**Evidence:**
```
$ tf apply (package references "nonexistent-env")
→ Apply complete! Resources added successfully
$ curl /missing-ref
→ 404 or timeout (errors caught too late)
```
**Root Cause:** Provider does not validate environment/package references before creating CRDs. K8s CRD accepts any string value.
**Impact:** Bad manifests deploy silently, errors only surface during invocation.
---
### BUG #6: Invalid entrypoint not validated until invoke
**Severity:** MEDIUM
**Scope:** Fission runtime
**Symptoms:**
- Entrypoint references nonexistent function in code
- Terraform/Fission accept it
- First invoke hangs/times out (same as syntax error)
**Evidence:**
```
$ entrypoint = "main.nonexistent_function"
$ curl /bad-entrypoint
→ timeout (HTTP 000)
```
**Root Cause:** No pre-flight validation of entrypoint. Only caught during cold start import.
**Impact:** Same as БАГ #1 — hangs entire pod until timeout.
---
### Limitation #1: Upload payload size limit
**Severity:** MEDIUM
**Symptoms:** Uploading ~1MB+ payload to function endpoint hangs connection
**Evidence:**
```
$ dd if=/dev/zero bs=1M count=1 | curl --data-binary @- /auto/ok
→ timeout
```
**Root Cause:** Likely nginx ingress `client_max_body_size` limit (default ~1MB).
**Impact:** Cannot send large payloads to functions via HTTP.
---
### Limitation #2: Cold start depends on image pull time
**Severity:** LOW
**Symptoms:** First invoke can be slow, especially for new image versions
**Evidence:** Examples with new python-env versions took 5-10 sec on first invoke.
---
### Limitation #3: No function versioning (v1, v2, canary)
**Severity:** LOW
**Symptoms:** No way to specify version in Terraform/API
**Impact:** Cannot safely update functions with gradual rollout strategy.
---
## ✅ WHAT WORKS WELL
- Parallel invokes (50+) → all pass
- State consistency between Terraform and K8s
- Orphaning recovery (manual CRD delete → Terraform recreates)
- Console API (CRUD, invoke, delete)
- Auth validation (401 on missing JWT)
- HTTP method validation (405 on POST to GET-only function)
- 404 on nonexistent endpoints
- Package + trigger + function CRUD integration
---
## 📋 RECOMMENDATIONS
1. **CRITICAL:** Add execution timeout in router (~60 sec default, configurable)
2. **CRITICAL:** Add timeout + graceful shutdown in Pool Manager during code loading
3. **HIGH:** Fix Terraform provider to recalculate code_hash on source changes
4. **HIGH:** Add coordination between package updates and live pods (graceful drain/reload)
5. **HIGH:** Add foreign key validation (environment/package references must exist)
6. **HIGH:** Add entrypoint validation during deploy (check function exists in code)
7. **MEDIUM:** Document payload size limits and how to adjust
8. **MEDIUM:** Add pre-flight code validation (syntax check) on deploy
9. **LOW:** Implement function versioning/canary deployment support
---
## 📊 TESTING STATISTICS
- Functions deployed: **34**
- Working correctly (5 sec response): **17**
- Hanging indefinitely: **4** (syntax-error, no-main, badimport, deep-recursion)
- Timing out: **1** (deep-recursion)
- Failing correctly (500): **2** (error, runtime-error)
- Not deployed: **1** (badimport partially)
---
## PARALLEL STRESS RESULTS
- 50 concurrent invokes to single function → **100% success**
- 30 concurrent invokes during terraform apply → **63% success rate** (race condition)
---
## TESTING TIMELINE
- Start: 2026-04-15 07:00 UTC
- End: 2026-04-15 09:00 UTC
- Duration: **2 hours** continuous integration testing
- Functions tested: ~30 different scenarios
- Test cases executed: ~150+
- Terraform scenarios: 15+ (create, update, delete, orphaning, race, validation, bad manifests)
- Edge cases covered: syntax errors, missing deps, race conditions, payload limits, cold start hangs, entrypoint validation
---
## NOTES FOR FOLLOW-UP
- Syntax error functions should ideally reject at deploy time (validate code before accepting)
- Code changes need lifecycle management (versioning, rollback, canary deployment)
- Router needs observability: span traces, request duration metrics, timeout tracking
- Consider adding health checks per pod to detect hung function execution
- Implement stricter validation during CRD creation (foreign keys, entrypoint existence)
+271
View File
@@ -147,3 +147,274 @@ User → POST /console/api/functions/NAME/invoke
Причина: VPN может быть активен локально, вызывая сбои при прямом выполнении команд. Причина: VPN может быть активен локально, вызывая сбои при прямом выполнении команд.
Монтирование sshfs делает локальное редактирование файлов эквивалентным редактированию на ВМ. Монтирование sshfs делает локальное редактирование файлов эквивалентным редактированию на ВМ.
---
## Сессия: JS invoke timeouts в UI (root cause + fix)
### Симптом
- Все JS invoke через UI (`/console/api/functions/{name}/invoke`) падали по timeout.
- В router логах: `roundtripper timeout (60s)`/`error sending request to function`.
- В executor логах specialization для node окружения могла проходить, но ответ функции не возвращался.
### Ключевые находки
1. Node runtime работал через `POST /v2/specialize`.
2. При `functionName=main` runtime строил путь загрузки как:
- `/userfunc/deployarchive/main`
3. В реальном поде `/userfunc/deployarchive` был **файлом** (literal payload), а не директорией.
4. После перевода на пустой entrypoint (`functionName=""`) specialization стала успешной (`202`, `user code loaded`),
но запросы всё равно зависали.
5. Прямая проверка контейнера показала второй контрактный нюанс node runtime:
- пользовательская функция должна возвращать объект формата
`{ status, body, headers? }`
- возврат простой строки приводил к зависанию ответа (callback не отправлял HTTP response).
### Принятые изменения
- `nodejs-acc` закреплен на `ghcr.io/fission/node-env:1.32.5`.
- Для JS функций (`fn-js-acc`, `fn-js-direct`, тестовые `jsm1..jsm4`):
- выставлен пустой entrypoint (`functionName=""`)
- код приведен к контракту Node runtime:
- `module.exports = async function(context) { return { status: 200, body: "..." }; }`
- Для основных JS trigger включены методы `GET` и `POST`.
### Проверка
- UI invoke:
- `fn-js-acc` -> `status=200`, `response_raw=hello-js-ok`
- `fn-js-direct` -> `status=200`, `response_raw=hello-js-ok`
- Прямые GET через роутер:
- `/js-acc` -> 200
- `/js-direct` -> 200
- Тестовые `jsm1..jsm4` после унификации кода -> 200 через UI invoke.
### Вывод
Проблема была не в UI-рендеринге и не в одном конкретном trigger, а в несовпадении контракта Node runtime:
- entrypoint/path specialization
- shape возвращаемого значения функции.
---
## Сессия: Финальный fix `fn-go-acc` (Go runtime specialization)
### Симптом
- `fn-go-acc` выдавал timeout на invoke через console/router.
### Диагностика
1. На исходной конфигурации в executor логах:
- `plugin.Open("/userfunc/deployarchive/main.go"): invalid ELF header`.
2. Это означало, что runtime ожидал plugin-артефакт, но в package deployment лежал исходник `main.go`.
3. После первой попытки собрать plugin локальным `go1.26.1` ошибка `invalid ELF` ушла, но появился новый фейл specialization:
- `Post "http://127.0.0.1:8888/v2/specialize": EOF`.
4. Вывод: plugin собран несовместимой версией Go относительно runtime.
### Решение
- Сборка plugin выполнена в `ghcr.io/fission/go-builder` (Go 1.25.6), то есть тем же toolchain, что у Fission environment builder:
- `GO111MODULE=off /usr/local/go/bin/go build -buildmode=plugin -o main.so ./main.go`
- упаковка `main.so` в `deploy.zip`.
- Обновление функции через CLI:
- `fission function update -n default --name fn-go-acc --env go-acc --entrypoint Handler --deployarchive /tmp/fn-go-acc-fix2/deploy.zip -f`
### Проверка
- `/go-acc` через router с JWT: `hello-go-ok`, `HTTP 200`.
- В executor логах specialization успешен:
- `specialized pod`
- `added function service`
- без `invalid ELF` и без `EOF`.
### Контрольная матрица маршрутов
- `/auto/ok` -> 200
- `/js-acc` -> 200
- `/js-direct` -> 200
- `/go-acc` -> 200
### Практический вывод
Для go-env в этом кластере критично соблюдать контракт runtime:
1. deploy-пакет должен содержать именно plugin (`.so`), а не исходник.
2. plugin должен быть собран совместимой версией Go (через `ghcr.io/fission/go-builder`).
---
## Сессия: `Edit Code` для `fn-go-acc` пустой
### Симптом
- В UI (`GET /console/api/functions/fn-go-acc`) поле `code` было пустым.
### Почему так произошло
- После фикса runtime функция была переведена на deploy-архив plugin (`main.so`).
- Package имел вид:
- `spec.deployment.type=url`
- `spec.deployment.url=...`
- без `spec.deployment.literal`
- Backend в `handleGetFunction` извлекал код только из `spec.deployment.literal`, поэтому для URL-пакета отдавал пустую строку.
### Что изменено
- В backend добавлен `extractPackageSourceCode(...)` с fallback-приоритетом:
1. `spec.source.literal`
2. `spec.deployment.literal`
3. `spec.source.url`
4. `spec.deployment.url`
- Добавлена загрузка URL-архива через `fetchPackageArchive(...)` и декодирование через `decodeArchiveBytesToSource(...)`.
- Обновлен unit-тестами сценарий, где `deployment.literal` отсутствует, но есть `source.literal`.
### Операционные шаги
- Собран и задеплоен `naeel/fission-console:v0.2.6`.
- Для текущей `fn-go-acc` дополнительно обновлен `sourcearchive` (`main.go`), чтобы исходник гарантированно отображался в Edit Code.
### Результат
- `GET /console/api/functions/fn-go-acc` теперь возвращает непустой Go source в `code`.
- Вызов `/go-acc` остается рабочим (`hello-go-ok`, HTTP 200).
---
## Сессия: Проверка TLS + branding NUBES + верификация `tf-neg-syntax-fn`
### TLS
- Проверен доступ к console:
- `http://.../console/` -> `308` redirect на HTTPS
- `https://.../console/` -> `200`
- Ingress конфиг содержит корректный TLS блок и принудительный SSL redirect.
- Вывод: endpoint защищен TLS, «незащищенного HTTP-доступа» нет.
### Брендинг NUBES
- В `console/ui/index.html` добавлены:
- `<title>NUBES Fission Console</title>`
- favicon (inline SVG)
- navbar brand block с `NUBES` / `FISSION CONSOLE`.
- Обновлен образ до `naeel/fission-console:v0.2.9`.
### `tf-neg-syntax-fn` — почему timeout вместо прямого SyntaxError
- Функция содержит заведомо битый Python (`def main(:`), confirmed через `GET /console/api/functions/tf-neg-syntax-fn`.
- На runtime это роняет specialization (`500`), после чего executor ретраит.
- Поэтому router/console не получает «чистый traceback» сразу из runtime API и видит timeout/ошибку specialization.
- Для UX введен fail-fast и явная ошибка в console invoke:
- `timeout after 20s: function specialization likely failed (for example, syntax error)`.
---
## Сессия: "Не защищено красным" — экстренное лечение
### Наблюдение
- Сертификат endpoint сам по себе валидный (Let's Encrypt, CN/SAN = `fission.kube5s.ru`).
- Но на том же host были два ingress с разной TLS-конфигурацией:
- `fission-console` с TLS
- `fission-router` без TLS
### Действие
- Патч `fission-router`:
- добавлен `spec.tls` с `secretName: fission-tls`
- добавлен `force-ssl-redirect=true`
### Результат
- HTTP на роутер-пути дает 308 -> HTTPS
- HTTPS на console стабильно 200
- Конфигурация host выровнена: теперь и console, и router на одном сертификате.
---
## Сессия: Строгая чистка до "только точно рабочие и видимые"
### Запрос
- Оставить только функции, которые в UI/Console:
- показывают исходник (`code` не пустой)
- реально вызываются (`invoke.status == 200`)
### Что произошло
- Выполнен массовый cleanup с keep-list.
- По логу cleanup выявилась ошибка jsonpath в пост-обработке orphan-пакетов:
- использовалось `{n}` вместо `{"\\n"}`
- это ломало шаг удаления orphan-объектов после основного удаления.
- Основное удаление функций при этом сработало корректно.
### Проверка после cleanup
- Текущее состояние:
- functions: 6
- httptriggers: 6
- packages: 6
- Остались только:
- `auth-test2`
- `fn-js-direct`
- `fn-js-acc`
- `hello`
- `tf-auto-ok-fn`
- `tf-stress-fast-fn`
- Для каждой функции через Console API подтверждено:
- `code=OK`
- `invoke=OK`
### Вывод
- Финальный набор теперь соответствует строгому критерию "однозначно работает и показывается".
---
## Сессия: Возврат к 15 функциям + проверка ожиданий + live favicon
### Наблюдение в начале
- После строгой чистки в кластере оставалось 6 функций.
- В live UI favicon оставался старым, хотя в репозитории правка уже была.
- Проверка deployment показала: работал старый image `naeel/fission-console:v0.3.0`.
### Расширение набора до 15
- Добавлены функции из примеров:
- `tf-hello-fn`, `tf-deep-recursion-fn`, `tf-destroy-test-fn`, `tf-freq-update-fn`, `tf-orphan-fn`
- `tf-multi-env-1-fn`, `tf-multi-env-2-fn`, `tf-multi-env-3-fn`
- Добавлен негативный кейс:
- `tf-neg-syntax-fn` + route `/neg/syntax`
- Итоговый размер:
- functions = 15
- httptriggers = 15
### Что сломалось и как починено
1. `multi-env-1/2/3` не применялись:
- ошибка Terraform: `Invalid escape sequence` из-за `"$\{path.module\}/code"`.
- фикс: заменено на `"${path.module}/code"` во всех трех `main.tf`.
2. После применения `multi-env-1/2/3` маршруты таймаутили (`rc=28`):
- причина: image env `ghcr.io/fission/python-env:v1.20.0`.
- фикс: переключено на `ghcr.io/fission/python-env`, re-apply всех трех модулей.
- результат: `/multi-env-1`, `/multi-env-2`, `/multi-env-3` -> `HTTP 200`.
### Финальная тест-матрица
- Router tests с JWT: `PASS=15, FAIL=0`.
- Успешные 200: базовые + multi-env + tf-hello + stress-fast.
- Ожидаемые ошибки:
- `/deep-recursion` -> timeout (`HTTP 000`, `rc=28`)
- `/neg/syntax` -> timeout (`HTTP 000`, `rc=28`)
- Console invoke подтверждает:
- positive функции -> `status=200`
- negative функции -> fail-fast timeout error.
### Favicon в live
- Собран и выкачен `naeel/fission-console:v0.3.1`.
- `deployment/fission-console` обновлен и rollout успешен.
- Проверка `https://fission.kube5s.ru/console/` показывает нужный favicon URL:
- `https://nubes.ru/themes/custom/nubes_2025/favicon.png`.
+85
View File
@@ -0,0 +1,85 @@
# 2026-04-20 — fission_simple_function
## Задача
Добавить составной ресурс `fission_simple_function`, который позволяет описать serverless-функцию одним HCL-блоком вместо четырёх отдельных ресурсов.
## Мотивация
До: пользователь обязан вручную объявлять `fission_environment` + `fission_package` + `fission_function` + `fission_http_trigger`.
После: достаточно одного блока:
```hcl
resource "fission_simple_function" "health" {
name = "health"
runtime = "nodejs"
code_dir = "./code/health"
url = "/ecom/health"
methods = ["GET"]
}
```
## Реализация
**Файл:** `terraform/provider/internal/resources/simple_function_resource.go`
### Схема ресурса
| Атрибут | Тип | Поведение |
|---------|-----|-----------|
| `name` | required string | RequiresReplace |
| `runtime` | required string | RequiresReplace; допустимые: nodejs, python, go, ruby, php, perl |
| `code_dir` | required string | путь к директории с исходниками |
| `url` | optional string | если задан — создаётся HTTPTrigger |
| `methods` | optional+computed list(string) | дефолт `["GET"]` |
| `namespace` | optional+computed string | дефолт из провайдера |
| `environment_name` | computed string | имя созданного/найденного env |
| `package_name` | computed string | `<name>-pkg` |
| `trigger_name` | computed string | `<name>-trigger` или "" |
| `code_hash` | computed string | SHA-256 кода из code_dir |
### Логика Create
1. `findOrCreateSimpleEnv` — ищет `simple-<runtime>-env`; если нет — создаёт с параметрами из `runtimeRegistry`.
2. `loadPackageContent(code_dir, "", deployType)` — загружает код (JS получает ESM zip, Go — source zip, остальные — literal bytes).
3. `CreatePackage``CreateFunction``CreateHTTPTrigger` (опционально).
4. Rollback при ошибке: если Function не создалась — удаляет Package; если Trigger — удаляет Function + Package.
### runtimeRegistry
| Runtime | deployType | entrypoint | builderImage |
|---------|-----------|------------|-------------|
| nodejs | literal | "" (default export) | — |
| python | literal | main.main | — |
| go | source | Handler | ghcr.io/fission/go-builder:latest |
| ruby | literal | main.main | — |
| php | literal | main.main | — |
| perl | literal | main.main | — |
### ModifyPlan
Пересчитывает `code_hash` при каждом plan, чтобы Terraform обнаруживал изменения кода без force-replace.
### Update
- Если `code_hash` изменился → `UpdatePackage` (patch content, preserve resourceVersion).
- URL добавлен/удалён → `CreateHTTPTrigger` / `DeleteHTTPTrigger`.
- URL изменился → `UpdateHTTPTrigger`.
### Delete
Порядок: HTTPTrigger → Function → Package. Environment намеренно НЕ удаляется (shared).
### Import
Формат: `namespace/name`. После import читается из Kubernetes через `Read`.
## Регистрация
`terraform/provider/internal/provider/provider.go` — добавлено `resources.NewSimpleFunctionResource` в `Resources()`.
## Статус
- [x] Реализация написана и скомпилирована (`go build ./...` — OK)
- [ ] Тест на real cluster
+81
View File
@@ -0,0 +1,81 @@
# Мнения AI-моделей об архитектуре Fission Console — 2026-04-25
> ⚠️ Всё ниже — МНЕНИЯ (не факты). Проверять и применять критически.
---
## GEMINI — мнение (пересказ, апрель 2026)
**Контекст вопроса**: "написан сервис на основе Fission, многопользовательский, с Terraform"
**Что сказал:**
1. **Изоляция**: namespace-per-user — самый надёжный путь. Terraform при создании аккаунта создаёт Namespace + ResourceQuota + NetworkPolicy.
2. **Fission в multi-tenancy**: можно один инстанс Fission на весь кластер, но нужно патчить Router чтобы он понимал принадлежность Environment. Либо (сложнее) — отдельный инстанс Fission/пул экзекуторов на каждый namespace.
3. **Terraform как Control Plane**: кастомный провайдер должен управлять полным lifecycle — регистрация пользователя в БД, создание K8s ресурсов, создание Fission ресурсов. State изолирован между пользователями.
4. **poolmgr vs newdeploy**: poolmgr хорош для 100ms cold start, но при 1000 пользователей — RAM кончается (не масштабируется). Рекомендует NewDeploy + HPA для multi-tenant scale.
5. **Безопасность выполнения**: gVisor/Kata Containers (RuntimeClass=runsc). Для публичного облака — обязательно.
6. **Схема**: user → .tf файл → Terraform Worker → K8s Namespace+лимиты → Fission деплоит пакет → Router → внешний трафик в нужный Namespace.
**Оценка контекста**: Gemini отвечал на абстрактный вопрос "как строить managed serverless". Не знал деталей нашей реализации. Часть советов — для масштаба 1000+ пользователей.
---
## GPT-4.5 — мнение (апрель 2026)
**Контекст вопроса**: тот же — многопользовательский managed serverless на Fission
**Что сказал:**
*(Полный текст ответа не сохранён — пользователь описал его как "насочинял", детали были в чате)*
> ⚠️ Полный текст GPT-4.5 не задокументирован. Пользователь охарактеризовал ответ скептически ("насочинял"). Считать неверифицированным.
---
## Claude Sonnet 4.6 (я) — мнение (апрель 2026)
**Контекст**: знаю реальную реализацию (читал main.go, deploy/, repo memory)
**Для масштаба ~20 одновременных пользователей:**
**Что реально надо доделать:**
1. **ResourceQuota + LimitRange** на каждый user namespace — одна функция сейчас может сожрать все ресурсы кластера. Базовая безопасность, не опционально.
2. **NetworkPolicy** — без этого функции разных пользователей видят друг друга внутри кластера.
3. **`fission_simple_function` Terraform ресурс** — сейчас провайдер low-level: нужно вручную создавать Environment + Package + Function + HTTPTrigger. Нужен один высокоуровневый ресурс.
4. **Поверхность ошибок в UI** — при провале specialization пользователь видит "вероятно не удалось". Надо тянуть реальную причину (статус package, логи executor).
**Что НЕ нужно для 20 юзеров:**
- gVisor — лишняя сложность для контролируемой среды
- newdeploy вместо poolmgr — poolmgr нормально работает, достаточно подстроить poolsize
- Отдельный billing service — достаточно логировать invoke в postgres
**Главный тезис**: Gemini описал правильную архитектуру для SaaS на 10k пользователей. Для 20 — текущая архитектура достаточна. Нужна доводка, не redesign.
---
## Текущее состояние реализации (факты, не мнение)
- namespace-per-user ✅
- auth token → user namespace ✅
- lazy environments per language ✅
- RBAC для системных и локальных SA (fission-fetcher, fission-builder) ✅
- TTL cleanup + reaper ✅
- NSReconciler для FISSION_RESOURCE_NAMESPACES ✅
- Route prefix per user ✅
- Terraform provider (low-level CRD) ✅
- ResourceQuota / LimitRange per namespace ❌ нет
- NetworkPolicy per namespace ❌ нет
- fission_simple_function Terraform ресурс ❌ не реализован
- Нормальная поверхность ошибок в UI ❌ частично
- gVisor/Kata ❌ нет (осознанное решение)
- Billing/metering ❌ нет
+271
View File
@@ -0,0 +1,271 @@
# План доработки Fission Console — 2026-04-25
> Для нового чата. Масштаб: ~20 одновременных пользователей.
> Текущая версия: `naeel/fission-console:v0.8.8`
> Ветка: `feat/namespace-isolation`
---
## Контекст (кратко)
- Go-бэкенд `console/main.go` + embedded UI `console/ui/index.html`
- Каждый пользователь → отдельный K8s namespace `fission-<hash(sub)>`
- Fission core один на кластер, namespace регистрируется через NSReconciler
- Terraform provider: низкоуровневый (Environment + Package + Function + HTTPTrigger)
- API: `https://fission.kube5s.ru/console/api`, тест: `X-Test-Sub: livetest@test.local`
- SSH: `ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213`
- sshfs: `/home/naeel/remote_dev/fission/` = `~/terra/fission/` на VM
---
## Задача 1: ResourceQuota + LimitRange на user namespace (ПРИОРИТЕТ 1)
**Зачем**: без квот одна функция может съесть весь CPU/RAM кластера.
**Где делать**: `console/main.go`, функция `ensureUserNamespace`.
**Что добавить**: при создании namespace применять два объекта:
```yaml
# ResourceQuota
apiVersion: v1
kind: ResourceQuota
metadata:
name: user-quota
namespace: fission-<hash>
spec:
hard:
requests.cpu: "2"
requests.memory: "2Gi"
limits.cpu: "4"
limits.memory: "4Gi"
pods: "20"
count/functions.fission.io: "20"
count/packages.fission.io: "40"
```
```yaml
# LimitRange
apiVersion: v1
kind: LimitRange
metadata:
name: user-limits
namespace: fission-<hash>
spec:
limits:
- type: Container
default:
cpu: "500m"
memory: "256Mi"
defaultRequest:
cpu: "100m"
memory: "64Mi"
max:
cpu: "2"
memory: "1Gi"
```
**Детали реализации**:
- Применять через `k8s dynamic client` или `core client` (уже есть в main.go)
- Idempotent: если уже есть — не ошибаться, просто пропустить (patch или get+create)
- Значения вынести в константы (или env vars) для удобной настройки
**Файлы**: `console/main.go`
**Тест**: создать нового пользователя, проверить `kubectl get resourcequota,limitrange -n fission-<hash>`
---
## Задача 2: NetworkPolicy на user namespace (ПРИОРИТЕТ 2)
**Зачем**: без NetworkPolicy функции разных юзеров могут напрямую обращаться друг к другу по cluster IP.
**Где делать**: `console/main.go`, функция `ensureUserNamespace` (вместе с задачей 1).
**Что создать**: два правила:
```yaml
# Deny all ingress from other namespaces (кроме fission core)
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: deny-cross-tenant
namespace: fission-<hash>
spec:
podSelector: {}
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: fission # fission core может
- podSelector: {} # внутри namespace — OK
```
**Осторожно**: проверить что fission router/executor/fetcher namespace правильно лейблован. Если нет — добавить лейбл на namespace `fission` через kubectl.
**Тест**: поднять функцию в namespace A, попробовать curl из пода namespace B — должен fail.
---
## Задача 3: fission_simple_function Terraform ресурс (ПРИОРИТЕТ 3)
**Зачем**: сейчас юзер пишет 4 ресурса вместо одного. Это неудобно и error-prone.
**Ветка**: `feat/simple-function-resource` (создана, код не написан)
**Файл**: `terraform/provider/internal/resources/simple_function_resource.go`
**Целевой синтаксис для пользователя:**
```hcl
resource "fission_simple_function" "health" {
name = "health"
runtime = "nodejs"
code_dir = "./code/health"
url = "/ecom/health"
methods = ["GET"]
}
```
**Что провайдер делает внутри:**
1. Ищет существующий `fission_environment` для runtime → переиспользует или создаёт
2. Zip-архивирует `code_dir` → создаёт `fission_package` (с ESM wrap для nodejs)
3. Создаёт `fission_function`
4. Создаёт `fission_http_trigger` (если указан `url`)
**Атрибуты ресурса:**
```
name string — имя функции
runtime string — nodejs / python / go / ruby / perl / php
code_dir string — путь к директории с кодом
url string — HTTP route (опционально)
methods []string — ["GET","POST"] (опционально, default GET)
entrypoint string — имя точки входа (опционально, default "Handler")
min_scale int — минимальный масштаб (опционально, default 0)
max_scale int — максимальный масштаб (опционально, default 1)
ttl int — TTL в секундах (опционально)
```
**Computed атрибуты:**
```
invoke_url string — полный URL для вызова
status string — статус package (building / succeeded / failed)
```
**Особенности реализации:**
- nodejs: ESM wrapper (`export default { Handler }`) — проверить формат как в Console
- Go: нужен archive package (zip), не literal. BuildStatus polling.
- Идемпотентность: Read → если уже есть все 3 ресурса, не пересоздавать
- Delete: удалить trigger + function + package. Environment — только если unused.
**Регистрация**: добавить в `terraform/provider/internal/provider/provider.go` в `Resources()`
**Примеры**: обновить `examples/big-suite/` после реализации
**Тест**: `terraform apply``terraform plan` должен показать "no changes"
---
## Задача 4: Улучшение поверхности ошибок (ПРИОРИТЕТ 4)
**Зачем**: сейчас при провале specialization пользователь видит "timeout after 20s: function specialization likely failed" — не информативно.
**Что делать:**
### 4.1 Package build status в API response
При создании функции и при GET `/console/api/functions/:name` — тянуть и возвращать:
```json
{
"name": "myfunc",
"status": "building", // или "ready" / "failed"
"buildError": "compilation failed: undefined reference to..."
}
```
`buildError` брать из `package.status.info` (Fission заполняет это поле).
### 4.2 Specialization error в invoke response
Сейчас invoke возвращает timeout. Надо после timeout:
1. Проверить статус package → если `failed`, вернуть buildError
2. Проверить поды namespace → если CrashLoopBackOff, вернуть причину
3. Иначе — вернуть "specialization timeout, function pod not ready"
### 4.3 UI polling для build status
После создания Go-функции (или любой с builder) — polling `/console/api/functions/:name` каждые 3s, показывать статус:
- "Сборка..." → "Готово" / "Ошибка сборки: <text>"
**Файлы**: `console/main.go` (API), `console/ui/index.html` (UI polling)
---
## Задача 5: Cleanup и housekeeping (ПРИОРИТЕТ 5)
**Что сделать:**
### 5.1 Reaper для пустых namespace
Если namespace не имеет ни одной функции и не обращался больше N дней → удалить namespace + все ресурсы.
Параметр: `NAMESPACE_TTL_DAYS` (env var, default 30).
### 5.2 Orphan package cleanup
При удалении функции — проверить все packages namespace, удалить те, на которые нет ни одной функции.
Сейчас `cleanupEnvironmentIfUnused` есть, нужен аналог для packages.
### 5.3 Soft limit на функции на пользователя
`MAX_FUNCTIONS_PER_USER` (env var). При превышении → HTTP 429 с сообщением.
Защита от случайного создания 1000 функций в loop.
---
## Порядок выполнения (рекомендованный)
```
1. Задача 1 (ResourceQuota + LimitRange) — ~2-3 часа, высокий риск если не сделать
2. Задача 2 (NetworkPolicy) — ~1-2 часа, вместе с задачей 1
3. Задача 3 (fission_simple_function) — ~1-2 дня, основная фича
4. Задача 4 (ошибки) — ~4-6 часов, UX improvement
5. Задача 5 (cleanup) — ~3-4 часа, housekeeping
```
---
## Команды для нового чата
```bash
# Текущий образ
naeel/fission-console:v0.8.8
# Сборка нового образа (ОБЯЗАТЕЛЬНО сначала поменять тег в deploy/console.yaml!)
ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213 \
'cd ~/terra/fission && docker build -t naeel/fission-console:v0.8.9 -f console/Dockerfile console/ && docker push naeel/fission-console:v0.8.9'
# Deploy
ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213 \
'kubectl set image deployment/fission-console fission-console=naeel/fission-console:v0.8.9 -n fission'
# Тест после деплоя
ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213 \
'bash ~/terra/fission/tests_v2.sh 2>&1 | tail -5'
# Проверка quota (после задачи 1)
ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213 \
'kubectl get resourcequota,limitrange -n fission-c3fce59430e41b0f'
```
---
## Файлы проекта (ключевые)
| Файл | Назначение |
|------|-----------|
| `console/main.go` | Основная логика бэкенда |
| `console/ui/index.html` | Embedded UI |
| `console/deploy/console.yaml` | K8s deployment (тег образа) |
| `console/deploy/rbac/console-ns-manager.yaml` | RBAC для fission-console SA |
| `terraform/provider/internal/resources/` | Terraform ресурсы |
| `terraform/provider/internal/provider/provider.go` | Регистрация ресурсов |
| `tests_v2.sh` | Регресс-тесты (73 блока, все PASS) |
| `doc/thinking/2026-04-25-ai-opinions.md` | Мнения AI моделей |
+59
View File
@@ -0,0 +1,59 @@
# Thinking Log — Аудит провайдера, 2026-06-03
## Задача
Тщательное сравнение нашего Terraform provider для Fission с каноническим поведением Fission CLI и CRD types.
## Что было сделано
### 1. Чтение нашего кода
Прочитаны все 10 .go файлов (~1500 строк):
- `environment_resource.go` (136 строк)
- `package_resource.go` (460 строк)
- `function_resource.go` (350 строк)
- `http_trigger_resource.go` (310 строк)
- `client.go` (295 строк)
- `validation_helpers.go`, `import_helpers.go`
- 3 тест-файла
### 2. Чтение канонических исходников Fission
- `pkg/apis/core/v1/types.go` — все CRD Go-структуры
- `pkg/apis/core/v1/const.go` — константы (ArchiveLiteralSizeLimit=256KB, BuildStatus*, ExecutorType*)
- CLI: `environment/create.go`, `package/create.go`, `package/util/util.go`
- StorageSvc: `storagesvc/client/client.go`
### 3. Дамп реальных CRD из кластера
Через kubectl получены ВСЕ объекты всех 4 типов из кластера:
- 20+ environments (наши tf-* и CLI-созданные)
- 15+ functions (наши tf-* и CLI-созданные)
- 15+ packages (наши и CLI)
- 15+ httptriggers
### 4. Сравнительный анализ
Для каждого ресурса: поле-за-полем наш payload vs CLI payload vs канон types.go.
## Ключевые находки
### Что правильно
- environment: version, runtime.image, poolsize — ок
- package: deployment.literal base64 — ок
- function: InvokeStrategy structure, package.functionName — ок
- httptrigger: relativeurl, methods, functionref, createingress — ок
- client.go: чистый CRUD, GVR правильные — ок
### Что отсутствует (критично)
1. **Environment.builder** — нет builder_image/builder_command → Go не работает через builder pipeline
2. **Package source archive** — только deployment-only, нет source+build flow
### Что отсутствует (важно)
3. **Function executor_type** — hardcoded poolmgr, нет newdeploy/container
4. **Function timeouts** — нет functionTimeout, idleTimeout
5. **Function scaling** — нет minScale, maxScale
### Что отсутствует (некритично)
6. Package: пустой `source: {}` — мусор но не bug
7. Environment: resources, imagepullsecret, keeparchive
8. Function: concurrency, requestsPerPod, resources, secrets
9. HTTPTrigger: prefix, keepPrefix, полный ingressconfig
## Решение
Написан полный аудит-документ: `doc/AUDIT_PROVIDER_VS_FISSION_2026-06-03.md`
+1 -1
View File
@@ -1,2 +1,2 @@
def main(): def main():
return "ok-auto-func-UPDATED-v2" return "ok-auto-func-UPDATED-v3-with-comment"
+5
View File
@@ -0,0 +1,5 @@
def good_function():
return "correct"
def main():
return "this is main"
+39
View File
@@ -0,0 +1,39 @@
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.1.0"
}
}
}
provider "fission" {
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "default"
}
resource "fission_environment" "python" {
name = "tf-bad-entry-env"
image = "ghcr.io/fission/python-env"
version = 3
}
resource "fission_package" "pkg" {
name = "tf-bad-entry-pkg"
environment = fission_environment.python.name
source_dir = "${path.module}/code"
}
resource "fission_function" "fn" {
name = "tf-bad-entry-fn"
environment = fission_environment.python.name
package_name = fission_package.pkg.name
entrypoint = "main.nonexistent_function" # Wrong entrypoint
}
resource "fission_http_trigger" "route" {
name = "tf-bad-entry-route"
url = "/bad-entrypoint"
methods = ["GET"]
function = fission_function.fn.name
}
+3
View File
@@ -0,0 +1,3 @@
module github.com/user/fn
go 1.23
@@ -0,0 +1,98 @@
package main
import (
"encoding/json"
"fmt"
"math/rand"
"net/http"
"time"
)
type OrderItem struct {
ProductID string `json:"product_id"`
Name string `json:"name"`
Price float64 `json:"price"`
Qty int `json:"qty"`
}
type OrderRequest struct {
UserID string `json:"user_id"`
Items []OrderItem `json:"items"`
PayToken string `json:"pay_token"`
Address string `json:"address"`
}
type OrderResponse struct {
Status string `json:"status"`
OrderID string `json:"order_id"`
UserID string `json:"user_id"`
Items []OrderItem `json:"items"`
Total float64 `json:"total"`
CreatedAt string `json:"created_at"`
EstDays int `json:"estimated_delivery_days"`
}
func generateOrderID() string {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
r := rand.New(rand.NewSource(time.Now().UnixNano()))
b := make([]byte, 10)
for i := range b {
b[i] = chars[r.Intn(len(chars))]
}
return "ORD-" + string(b)
}
func calcTotal(items []OrderItem) float64 {
total := 0.0
for _, item := range items {
total += item.Price * float64(item.Qty)
}
// Округление до 2 знаков
return float64(int(total*100)) / 100
}
func Handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
fmt.Fprintln(w, `{"error":"method not allowed"}`)
return
}
var req OrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, `{"error":"invalid JSON: %s"}`, err.Error())
return
}
if req.UserID == "" {
w.WriteHeader(http.StatusUnprocessableEntity)
fmt.Fprintln(w, `{"error":"user_id required"}`)
return
}
if len(req.Items) == 0 {
w.WriteHeader(http.StatusUnprocessableEntity)
fmt.Fprintln(w, `{"error":"items cannot be empty"}`)
return
}
if req.PayToken == "" {
w.WriteHeader(http.StatusUnprocessableEntity)
fmt.Fprintln(w, `{"error":"pay_token required"}`)
return
}
resp := OrderResponse{
Status: "created",
OrderID: generateOrderID(),
UserID: req.UserID,
Items: req.Items,
Total: calcTotal(req.Items),
CreatedAt: time.Now().UTC().Format(time.RFC3339),
EstDays: 3 + rand.Intn(5),
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(resp)
}
@@ -0,0 +1,12 @@
package main
import (
"net/http"
)
// Handler — точка входа для fission go-env.
func Handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"processed"}`))
}
+3
View File
@@ -0,0 +1,3 @@
module github.com/user/fn
go 1.23
@@ -0,0 +1,68 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"time"
)
// Симулирует агрегацию данных за день (в реальности — запрос к БД)
type DailyStat struct {
Date string `json:"date"`
Orders int `json:"orders"`
Revenue float64 `json:"revenue"`
AvgOrder float64 `json:"avg_order_value"`
NewUsers int `json:"new_users"`
TopCategory string `json:"top_category"`
}
func generateDailyStats(days int) []DailyStat {
stats := make([]DailyStat, days)
base := time.Now().UTC()
revenues := []float64{4821.50, 3920.00, 6100.75, 5200.10, 7330.40, 4100.00, 8950.30}
orders := []int{38, 31, 49, 42, 58, 33, 72}
categories := []string{"electronics", "furniture", "electronics", "stationery", "electronics", "furniture", "electronics"}
for i := 0; i < days; i++ {
day := base.AddDate(0, 0, -(days - 1 - i))
rev := revenues[i%len(revenues)]
ord := orders[i%len(orders)]
stats[i] = DailyStat{
Date: day.Format("2006-01-02"),
Orders: ord,
Revenue: rev,
AvgOrder: float64(int(rev/float64(ord)*100)) / 100,
NewUsers: 5 + i*2,
TopCategory: categories[i%len(categories)],
}
}
return stats
}
func Handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
days := 7
stats := generateDailyStats(days)
totalRevenue := 0.0
totalOrders := 0
for _, s := range stats {
totalRevenue += s.Revenue
totalOrders += s.Orders
}
report := map[string]interface{}{
"status": "ok",
"generated_at": time.Now().UTC().Format(time.RFC3339),
"period_days": days,
"total_revenue": fmt.Sprintf("%.2f", totalRevenue),
"total_orders": totalOrders,
"avg_daily_rev": fmt.Sprintf("%.2f", totalRevenue/float64(days)),
"daily_breakdown": stats,
}
json.NewEncoder(w).Encode(report)
}
@@ -0,0 +1,20 @@
exports.handler = async (ctx) => {
const start = Date.now();
return {
body: JSON.stringify({
status: "ok",
service: "ecom-platform",
version: "1.0.0",
uptime_check: "pass",
checks: {
runtime: "ok",
memory_mb: process.memoryUsage ? Math.round(process.memoryUsage().heapUsed / 1024 / 1024) : null,
node_version: process.version || "unknown",
},
timestamp: new Date().toISOString(),
response_ms: Date.now() - start,
}),
headers: { "Content-Type": "application/json" },
};
};
@@ -0,0 +1,70 @@
// Валидирует платёжные данные перед созданием заказа
exports.handler = async (ctx) => {
const req = ctx.request;
if (!req.body) {
return {
status: 400,
body: JSON.stringify({ error: "request body required" }),
headers: { "Content-Type": "application/json" },
};
}
let data;
try {
data = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;
} catch {
return {
status: 400,
body: JSON.stringify({ error: "invalid JSON" }),
headers: { "Content-Type": "application/json" },
};
}
const errors = [];
// card_number: 16 цифр
const card = String(data.card_number || "").replace(/\s/g, "");
if (!/^\d{16}$/.test(card)) errors.push("card_number must be 16 digits");
// cvv: 3 цифры
const cvv = String(data.cvv || "");
if (!/^\d{3}$/.test(cvv)) errors.push("cvv must be 3 digits");
// expiry: MM/YY
const expiry = String(data.expiry || "");
if (!/^(0[1-9]|1[0-2])\/\d{2}$/.test(expiry)) errors.push("expiry must be MM/YY");
else {
const [mm, yy] = expiry.split("/").map(Number);
const now = new Date();
const exp = new Date(2000 + yy, mm - 1);
if (exp < now) errors.push("card is expired");
}
// amount
const amount = Number(data.amount);
if (!amount || amount <= 0) errors.push("amount must be positive number");
if (amount > 10000) errors.push("amount exceeds limit 10000");
if (errors.length > 0) {
return {
status: 422,
body: JSON.stringify({ valid: false, errors }),
headers: { "Content-Type": "application/json" },
};
}
// Маскируем карту в ответе
const maskedCard = `****-****-****-${card.slice(-4)}`;
return {
body: JSON.stringify({
valid: true,
masked_card: maskedCard,
amount,
currency: data.currency || "USD",
token: `pay_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
}),
headers: { "Content-Type": "application/json" },
};
};
@@ -0,0 +1,62 @@
// Rate-limiter: проверяет X-Client-ID, считает запросы в памяти пода
// В реальном проекте — Redis. Здесь: in-memory для демонстрации.
const counters = {};
const WINDOW_MS = 60_000;
const LIMIT = 100;
exports.handler = async (ctx) => {
const req = ctx.request;
const clientId = (req.headers && req.headers["x-client-id"]) || "anonymous";
const now = Date.now();
if (!counters[clientId]) {
counters[clientId] = { count: 0, windowStart: now };
}
const c = counters[clientId];
if (now - c.windowStart > WINDOW_MS) {
c.count = 0;
c.windowStart = now;
}
c.count++;
const allowed = c.count <= LIMIT;
const remaining = Math.max(0, LIMIT - c.count);
const resetAt = new Date(c.windowStart + WINDOW_MS).toISOString();
if (!allowed) {
return {
status: 429,
body: JSON.stringify({
error: "rate limit exceeded",
client_id: clientId,
limit: LIMIT,
reset_at: resetAt,
}),
headers: {
"Content-Type": "application/json",
"X-RateLimit-Limit": String(LIMIT),
"X-RateLimit-Remaining": "0",
"X-RateLimit-Reset": resetAt,
},
};
}
return {
body: JSON.stringify({
allowed: true,
client_id: clientId,
requests_in_window: c.count,
remaining,
limit: LIMIT,
reset_at: resetAt,
}),
headers: {
"Content-Type": "application/json",
"X-RateLimit-Limit": String(LIMIT),
"X-RateLimit-Remaining": String(remaining),
},
};
};
@@ -0,0 +1,44 @@
import hashlib
import hmac
import json
import time
from flask import request
_SECRET = b"ecom-secret-2026"
_USERS = {
"tok_alice_001": {"sub": "user-1", "name": "Alice", "roles": ["buyer"]},
"tok_bob_002": {"sub": "user-2", "name": "Bob", "roles": ["buyer", "seller"]},
"tok_admin_999": {"sub": "user-0", "name": "Admin", "roles": ["admin"]},
}
def _verify_token(token: str) -> dict | None:
if token in _USERS:
return _USERS[token]
return None
def main():
token = request.headers.get("x-bearer-token", "").strip()
if not token:
auth_header = request.headers.get("authorization", "")
token = auth_header.removeprefix("Bearer ").strip()
if not token:
return {"status": 401, "body": json.dumps({"error": "missing token"}),
"headers": {"Content-Type": "application/json"}}
user = _verify_token(token)
if not user:
return {"status": 403, "body": json.dumps({"error": "invalid token"}),
"headers": {"Content-Type": "application/json"}}
payload = {
"authenticated": True,
"sub": user["sub"],
"name": user["name"],
"roles": user["roles"],
"checked_at": int(time.time()),
}
return json.dumps(payload)
@@ -0,0 +1,70 @@
import json
import time
from flask import request
# In-memory корзина (живёт пока жив под poolmgr)
_CARTS: dict = {}
def _get_cart(user_id: str) -> dict:
if user_id not in _CARTS:
_CARTS[user_id] = {"user_id": user_id, "items": [], "created_at": int(time.time())}
return _CARTS[user_id]
def _cart_total(cart: dict) -> float:
return round(sum(i["price"] * i["qty"] for i in cart["items"]), 2)
def main():
method = request.method.upper()
user_id = request.headers.get("x-user-id", "user-1")
body = {}
if request.data:
try:
body = json.loads(request.data)
except Exception:
pass
cart = _get_cart(user_id)
if method == "GET":
return json.dumps({
"status": "ok",
"cart": cart,
"total": _cart_total(cart),
"item_count": len(cart["items"]),
})
if method == "POST":
# Добавить товар: {"product_id": "p-001", "name": "...", "price": 29.99, "qty": 2}
required = ["product_id", "name", "price", "qty"]
if not all(k in body for k in required):
return {"status": 400,
"body": json.dumps({"error": "missing fields", "required": required}),
"headers": {"Content-Type": "application/json"}}
# Если товар уже в корзине — увеличиваем qty
for item in cart["items"]:
if item["product_id"] == body["product_id"]:
item["qty"] += int(body["qty"])
return json.dumps({"status": "ok", "action": "updated", "cart": cart,
"total": _cart_total(cart)})
cart["items"].append({
"product_id": body["product_id"],
"name": body["name"],
"price": float(body["price"]),
"qty": int(body["qty"]),
})
return json.dumps({"status": "ok", "action": "added", "cart": cart,
"total": _cart_total(cart)})
if method == "DELETE":
product_id = body.get("product_id", "")
cart["items"] = [i for i in cart["items"] if i["product_id"] != product_id]
return json.dumps({"status": "ok", "action": "removed", "cart": cart,
"total": _cart_total(cart)})
return {"status": 405, "body": json.dumps({"error": "method not allowed"}),
"headers": {"Content-Type": "application/json"}}
@@ -0,0 +1,2 @@
def main():
return {"status": "ok", "metrics": {}}
@@ -0,0 +1,48 @@
import json
from flask import request
_DB = {
"user-1": {"id": "user-1", "name": "Alice", "email": "alice@example.com", "tier": "gold", "orders": 42},
"user-2": {"id": "user-2", "name": "Bob", "email": "bob@example.com", "tier": "silver", "orders": 11},
"user-0": {"id": "user-0", "name": "Admin", "email": "admin@example.com", "tier": "admin", "orders": 0},
}
def main():
# Path variables не передаются в Fission функцию — используем query param ?id=
user_id = request.args.get("id", "")
if not user_id or user_id not in _DB:
return {
"status": 404,
"body": json.dumps({"error": "user not found", "id": user_id}),
"headers": {"Content-Type": "application/json"},
}
user = dict(_DB[user_id])
user.pop("email", None)
return json.dumps({"status": "ok", "user": user})
# Достаём id из query string или пути
user_id = ""
if hasattr(context, "request"):
path = getattr(context.request, "url", "") or ""
# /ecom/users/user-1 → user-1
parts = [p for p in path.split("/") if p]
if parts:
user_id = parts[-1]
qs = getattr(context.request, "query", {}) or {}
if not user_id and "id" in qs:
user_id = qs["id"]
if not user_id or user_id not in _DB:
return {
"status": 404,
"body": json.dumps({"error": "user not found", "id": user_id}),
"headers": {"Content-Type": "application/json"},
}
user = dict(_DB[user_id])
# Не возвращаем email в публичном ответе
user.pop("email", None)
return json.dumps({"status": "ok", "user": user})
@@ -0,0 +1,40 @@
# frozen_string_literal: true
require "json"
CHANNELS = %w[email sms push].freeze
def handler
# В реальности: читаем body запроса, определяем канал и шлём уведомление
# Здесь: симуляция отправки по всем каналам
order_id = "ORD-DEMO"
user_name = "Alice"
amount = 129.99
results = CHANNELS.map do |ch|
# Симуляция: email — всегда ок, sms — 95%, push — 90%
success = case ch
when "email" then true
when "sms" then rand(100) < 95
when "push" then rand(100) < 90
end
{
channel: ch,
status: success ? "sent" : "failed",
message: "Order #{order_id} confirmed — total $#{amount}",
recipient: "#{user_name.downcase}@notify",
}
end
sent = results.count { |r| r[:status] == "sent" }
total = results.length
{
status: "ok",
order_id: order_id,
sent: sent,
failed: total - sent,
channels: results,
sent_at: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
}.to_json
end
@@ -0,0 +1,29 @@
# frozen_string_literal: true
require "json"
PRODUCTS = [
{ id: "p-001", name: "Laptop Pro 15", price: 1299.99, category: "electronics", stock: 14 },
{ id: "p-002", name: "Wireless Mouse", price: 29.99, category: "electronics", stock: 203 },
{ id: "p-003", name: "Standing Desk", price: 549.00, category: "furniture", stock: 7 },
{ id: "p-004", name: "USB-C Hub 7-in-1", price: 49.99, category: "electronics", stock: 88 },
{ id: "p-005", name: "Ergonomic Chair", price: 399.00, category: "furniture", stock: 3 },
{ id: "p-006", name: "Monitor 4K 27\"", price: 699.00, category: "electronics", stock: 22 },
{ id: "p-007", name: "Notebook A5", price: 4.99, category: "stationery", stock: 500 },
{ id: "p-008", name: "Pen Set 12pc", price: 9.99, category: "stationery", stock: 320 },
].freeze
def handler
# Простая фильтрация по category (query string не доступен в v1 Perl-style,
# но для Ruby v3 env возвращаем весь каталог)
total = PRODUCTS.length
in_stock = PRODUCTS.count { |p| p[:stock] > 0 }
by_cat = PRODUCTS.group_by { |p| p[:category] }.transform_values(&:length)
{
status: "ok",
total: total,
in_stock: in_stock,
by_category: by_cat,
products: PRODUCTS,
}.to_json
end
+33
View File
@@ -0,0 +1,33 @@
# ──────────────────────────────────────────────────────────────────────────────
# Environments — E-Commerce Platform API
# ──────────────────────────────────────────────────────────────────────────────
resource "fission_environment" "python" {
name = "ecom-python-env"
image = "ghcr.io/fission/python-env"
version = 3
poolsize = 3
}
resource "fission_environment" "node" {
name = "ecom-node-env"
image = "ghcr.io/fission/node-env"
version = 3
poolsize = 3
}
resource "fission_environment" "ruby" {
name = "ecom-ruby-env"
image = "ghcr.io/fission/ruby-env"
version = 3
poolsize = 2
}
resource "fission_environment" "go" {
name = "ecom-go-env"
image = "ghcr.io/fission/go-env"
builder_image = "ghcr.io/fission/go-builder"
builder_command = "build"
version = 3
poolsize = 1
}
+59
View File
@@ -0,0 +1,59 @@
# ──────────────────────────────────────────────────────────────────────────────
# Async layer: notifier (ruby) + report-daily (go)
#
# Обе функции depends_on order-create — запускаются ПАРАЛЛЕЛЬНО между собой,
# но только после того как order-create (Go) задеплоен.
# Моделируют async side-effects после создания заказа.
# ──────────────────────────────────────────────────────────────────────────────
# ─── notifier ─────────────────────────────────────────────────────────────────
resource "fission_package" "notifier" {
name = "ecom-notifier-pkg"
environment = fission_environment.ruby.name
source_dir = "${path.module}/code/ruby-notifier"
depends_on = [fission_http_trigger.order_create]
}
resource "fission_function" "notifier" {
name = "ecom-notifier"
environment = fission_environment.ruby.name
package_name = fission_package.notifier.name
entrypoint = "handler"
}
resource "fission_http_trigger" "notifier" {
name = "ecom-notifier-route"
function = fission_function.notifier.name
url = "/ecom/notify"
methods = ["POST"]
}
# ─── report-daily (Go) ────────────────────────────────────────────────────────
# Второй Go-пакет — использует тот же ecom-go-env.
# Билдится параллельно с notifier, но после order-create.
resource "fission_package" "report_daily" {
name = "ecom-report-daily-pkg"
environment = fission_environment.go.name
source_dir = "${path.module}/code/go-report"
deploy_type = "source"
build_command = "build"
depends_on = [fission_http_trigger.order_create]
}
resource "fission_function" "report_daily" {
name = "ecom-report-daily"
environment = fission_environment.go.name
package_name = fission_package.report_daily.name
entrypoint = "Handler"
}
resource "fission_http_trigger" "report_daily" {
name = "ecom-report-daily-route"
function = fission_function.report_daily.name
url = "/ecom/reports/daily"
methods = ["GET"]
}
+61
View File
@@ -0,0 +1,61 @@
# ──────────────────────────────────────────────────────────────────────────────
# Checkout layer: payment-validate (node) → order-create (go)
#
# payment-validate — depends_on cart-service
# order-create — depends_on payment-validate
# Go builder не запускается пока checkout chain не готов.
# Самый долгий шаг (~40s на сборку) — намеренно в конце.
# ──────────────────────────────────────────────────────────────────────────────
# ─── payment-validate ─────────────────────────────────────────────────────────
resource "fission_package" "payment_validate" {
name = "ecom-payment-validate-pkg"
environment = fission_environment.node.name
source_dir = "${path.module}/code/node-payment"
depends_on = [fission_http_trigger.cart_service]
}
resource "fission_function" "payment_validate" {
name = "ecom-payment-validate"
environment = fission_environment.node.name
package_name = fission_package.payment_validate.name
entrypoint = "main"
}
resource "fission_http_trigger" "payment_validate" {
name = "ecom-payment-validate-route"
function = fission_function.payment_validate.name
url = "/ecom/payment/validate"
methods = ["POST"]
}
# ─── order-create (Go) ────────────────────────────────────────────────────────
# Go builder pipeline: source zip → go-builder → .so плагин
# Намеренно в самом конце — запускать тяжёлый builder только когда весь
# checkout chain гарантированно развёрнут
resource "fission_package" "order_create" {
name = "ecom-order-create-pkg"
environment = fission_environment.go.name
source_dir = "${path.module}/code/go-order"
deploy_type = "source"
build_command = "build"
depends_on = [fission_http_trigger.payment_validate]
}
resource "fission_function" "order_create" {
name = "ecom-order-create"
environment = fission_environment.go.name
package_name = fission_package.order_create.name
entrypoint = "Handler"
}
resource "fission_http_trigger" "order_create" {
name = "ecom-order-create-route"
function = fission_function.order_create.name
url = "/ecom/orders"
methods = ["POST"]
}
+49
View File
@@ -0,0 +1,49 @@
# ──────────────────────────────────────────────────────────────────────────────
# Core layer: auth-check (python) + rate-limiter (node)
# Деплоятся ПАРАЛЛЕЛЬНО — нет зависимостей друг от друга.
# Все сервисные функции depends_on этих триггеров.
# ──────────────────────────────────────────────────────────────────────────────
# ─── auth-check ───────────────────────────────────────────────────────────────
resource "fission_package" "auth_check" {
name = "ecom-auth-check-pkg"
environment = fission_environment.python.name
source_dir = "${path.module}/code/python-auth"
}
resource "fission_function" "auth_check" {
name = "ecom-auth-check"
environment = fission_environment.python.name
package_name = fission_package.auth_check.name
entrypoint = "main.main"
}
resource "fission_http_trigger" "auth_check" {
name = "ecom-auth-check-route"
function = fission_function.auth_check.name
url = "/ecom/auth/check"
methods = ["GET", "POST"]
}
# ─── rate-limiter ─────────────────────────────────────────────────────────────
resource "fission_package" "rate_limiter" {
name = "ecom-rate-limiter-pkg"
environment = fission_environment.node.name
source_dir = "${path.module}/code/node-ratelimiter"
}
resource "fission_function" "rate_limiter" {
name = "ecom-rate-limiter"
environment = fission_environment.node.name
package_name = fission_package.rate_limiter.name
entrypoint = "main"
}
resource "fission_http_trigger" "rate_limiter" {
name = "ecom-rate-limiter-route"
function = fission_function.rate_limiter.name
url = "/ecom/rate/check"
methods = ["GET", "POST"]
}
+24
View File
@@ -0,0 +1,24 @@
# ──────────────────────────────────────────────────────────────────────────────
# Infra layer: health-check (node)
# Полностью независима — деплоится параллельно со всем остальным.
# ──────────────────────────────────────────────────────────────────────────────
resource "fission_package" "health" {
name = "ecom-health-pkg"
environment = fission_environment.node.name
source_dir = "${path.module}/code/node-health"
}
resource "fission_function" "health" {
name = "ecom-health"
environment = fission_environment.node.name
package_name = fission_package.health.name
entrypoint = "main"
}
resource "fission_http_trigger" "health" {
name = "ecom-health-route"
function = fission_function.health.name
url = "/ecom/health"
methods = ["GET"]
}
+85
View File
@@ -0,0 +1,85 @@
# ──────────────────────────────────────────────────────────────────────────────
# Services layer: user-get, product-catalog, cart-service
#
# user-get — depends_on auth-check (нужно что auth маршрут доступен)
# product-catalog — depends_on rate-limiter
# cart-service — depends_on ОБОИХ: user-get + product-catalog
# (cart нужны оба сервиса — деплой только когда оба готовы)
# ──────────────────────────────────────────────────────────────────────────────
# ─── user-get ─────────────────────────────────────────────────────────────────
resource "fission_package" "user_get" {
name = "ecom-user-get-pkg"
environment = fission_environment.python.name
source_dir = "${path.module}/code/python-user"
depends_on = [fission_http_trigger.auth_check]
}
resource "fission_function" "user_get" {
name = "ecom-user-get"
environment = fission_environment.python.name
package_name = fission_package.user_get.name
entrypoint = "main.main"
}
resource "fission_http_trigger" "user_get" {
name = "ecom-user-get-route"
function = fission_function.user_get.name
url = "/ecom/users"
methods = ["GET"]
}
# ─── product-catalog ──────────────────────────────────────────────────────────
resource "fission_package" "product_catalog" {
name = "ecom-product-catalog-pkg"
environment = fission_environment.ruby.name
source_dir = "${path.module}/code/ruby-product"
depends_on = [fission_http_trigger.rate_limiter]
}
resource "fission_function" "product_catalog" {
name = "ecom-product-catalog"
environment = fission_environment.ruby.name
package_name = fission_package.product_catalog.name
entrypoint = "handler"
}
resource "fission_http_trigger" "product_catalog" {
name = "ecom-product-catalog-route"
function = fission_function.product_catalog.name
url = "/ecom/products"
methods = ["GET"]
}
# ─── cart-service ─────────────────────────────────────────────────────────────
# user-get и product-catalog деплоятся ПАРАЛЛЕЛЬНО,
# cart ждёт пока ОБА готовы
resource "fission_package" "cart_service" {
name = "ecom-cart-service-pkg"
environment = fission_environment.python.name
source_dir = "${path.module}/code/python-cart"
depends_on = [
fission_http_trigger.user_get,
fission_http_trigger.product_catalog,
]
}
resource "fission_function" "cart_service" {
name = "ecom-cart-service"
environment = fission_environment.python.name
package_name = fission_package.cart_service.name
entrypoint = "main.main"
}
resource "fission_http_trigger" "cart_service" {
name = "ecom-cart-service-route"
function = fission_function.cart_service.name
url = "/ecom/cart"
methods = ["GET", "POST", "DELETE"]
}
+13
View File
@@ -0,0 +1,13 @@
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.1.0"
}
}
}
provider "fission" {
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "default"
}
+32
View File
@@ -0,0 +1,32 @@
output "deploy_order" {
description = "Порядок деплоя Terraform (граф зависимостей)"
value = {
"1_parallel" = "auth-check, rate-limiter, health (независимые)"
"2_parallel" = "user-get (after auth), product-catalog (after rate-limiter)"
"3_sequential" = "cart-service (after user-get AND product-catalog)"
"4_sequential" = "payment-validate (after cart)"
"5_sequential" = "order-create — Go builder (after payment)"
"6_parallel" = "notifier, report-daily (оба after order-create)"
}
}
output "urls" {
description = "HTTP-маршруты всех функций"
value = {
# Core
auth_check = "https://fission.kube5s.ru/ecom/auth/check"
rate_limiter = "https://fission.kube5s.ru/ecom/rate/check"
# Services
"user_get" = "https://fission.kube5s.ru/ecom/users?id=user-1"
product_catalog = "https://fission.kube5s.ru/ecom/products"
cart_service = "https://fission.kube5s.ru/ecom/cart"
# Checkout
payment_validate = "https://fission.kube5s.ru/ecom/payment/validate"
order_create = "https://fission.kube5s.ru/ecom/orders"
# Async
notifier = "https://fission.kube5s.ru/ecom/notify"
report_daily = "https://fission.kube5s.ru/ecom/reports/daily"
# Infra
health = "https://fission.kube5s.ru/ecom/health"
}
}
+34
View File
@@ -0,0 +1,34 @@
# ──────────────────────────────────────────────────────────────────────────────
# simple_functions.tf — примеры fission_simple_function (добавлено 2026-04-20)
#
# Демонстрирует использование нового составного ресурса, который заменяет
# отдельные fission_environment + fission_package + fission_function + fission_http_trigger
# одним блоком.
#
# Environment simple-<runtime>-env создаётся автоматически при первом apply.
# ──────────────────────────────────────────────────────────────────────────────
# ─── health-check (nodejs, с HTTPTrigger) ────────────────────────────────────
resource "fission_simple_function" "health" {
name = "ecom-health"
runtime = "nodejs"
code_dir = "${path.module}/code/node-health"
url = "/ecom/health"
methods = ["GET"]
}
# ─── metrics (python, без HTTPTrigger) ───────────────────────────────────────
resource "fission_simple_function" "metrics" {
name = "ecom-metrics"
runtime = "python"
code_dir = "${path.module}/code/python-metrics"
}
# ─── go-processor (go, с HTTPTrigger, PUT/POST) ───────────────────────────────
resource "fission_simple_function" "processor" {
name = "ecom-processor"
runtime = "go"
code_dir = "${path.module}/code/go-processor"
url = "/ecom/process"
methods = ["POST", "PUT"]
}
+7
View File
@@ -0,0 +1,7 @@
def fib(n):
if n <= 1:
return n
return fib(n-1) + fib(n-2)
def main():
return f"fib(100)={fib(100)}"
+39
View File
@@ -0,0 +1,39 @@
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.1.0"
}
}
}
provider "fission" {
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "default"
}
resource "fission_environment" "python" {
name = "tf-deep-recursion-env"
image = "ghcr.io/fission/python-env"
version = 3
}
resource "fission_package" "pkg" {
name = "tf-deep-recursion-pkg"
environment = fission_environment.python.name
source_dir = "${path.module}/code"
}
resource "fission_function" "fn" {
name = "tf-deep-recursion-fn"
environment = fission_environment.python.name
package_name = fission_package.pkg.name
entrypoint = "main.main"
}
resource "fission_http_trigger" "route" {
name = "tf-deep-recursion-route"
function = fission_function.fn.name
url = "/deep-recursion"
methods = ["GET"]
}
+1
View File
@@ -0,0 +1 @@
def main(): return "env-1"
+1
View File
@@ -0,0 +1 @@
def main(): return "env-2"
+1
View File
@@ -0,0 +1 @@
def main(): return "env-3"
+1
View File
@@ -0,0 +1 @@
def main(): return "env-4"
+1
View File
@@ -0,0 +1 @@
def main(): return "env-5"
+1
View File
@@ -0,0 +1 @@
def main(): return "v10"
+39
View File
@@ -0,0 +1,39 @@
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.1.0"
}
}
}
provider "fission" {
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "default"
}
resource "fission_environment" "python" {
name = "tf-freq-update-env"
image = "ghcr.io/fission/python-env"
version = 3
}
resource "fission_package" "pkg" {
name = "tf-freq-update-pkg"
environment = fission_environment.python.name
source_dir = "${path.module}/code"
}
resource "fission_function" "fn" {
name = "tf-freq-update-fn"
environment = fission_environment.python.name
package_name = fission_package.pkg.name
entrypoint = "main.main"
}
resource "fission_http_trigger" "route" {
name = "tf-freq-update-route"
function = fission_function.fn.name
url = "/freq-update"
methods = ["GET"]
}
+3
View File
@@ -0,0 +1,3 @@
module github.com/user/fn
go 1.23
+14
View File
@@ -0,0 +1,14 @@
package main
import (
"fmt"
"net/http"
)
// Handler — точка входа для Fission go-env.
// Go builder компилирует этот файл в .so плагин,
// go-env загружает его через plugin.Open().
func Handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
fmt.Fprintf(w, "Hello from real Go in Fission (builder pipeline)")
}
+46
View File
@@ -0,0 +1,46 @@
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.1.0"
}
}
}
provider "fission" {
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "default"
}
# Настоящий Go через builder pipeline:
# go-builder компилирует handler.go → .so плагин
# go-env загружает .so через plugin.Open()
resource "fission_environment" "go" {
name = "tf-go-hello-env"
image = "ghcr.io/fission/go-env"
builder_image = "ghcr.io/fission/go-builder"
builder_command = "build"
version = 3
}
resource "fission_package" "pkg" {
name = "tf-go-hello-pkg"
environment = fission_environment.go.name
source_dir = "${path.module}/code"
deploy_type = "source"
build_command = "build"
}
resource "fission_function" "fn" {
name = "tf-go-hello-fn"
environment = fission_environment.go.name
package_name = fission_package.pkg.name
entrypoint = "Handler"
}
resource "fission_http_trigger" "route" {
name = "tf-go-hello-route"
function = fission_function.fn.name
url = "/go-hello"
methods = ["GET"]
}
+2
View File
@@ -0,0 +1,2 @@
def main():
return "test"
+33
View File
@@ -0,0 +1,33 @@
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.1.0"
}
}
}
provider "fission" {
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "default"
}
# Missing image — should fail
resource "fission_environment" "bad_env" {
name = "tf-invalid-env"
# image = "..." # MISSING REQUIRED FIELD
version = 3
}
resource "fission_package" "pkg" {
name = "tf-invalid-pkg"
environment = fission_environment.bad_env.name
source_dir = "${path.module}/code"
}
resource "fission_function" "fn" {
name = "tf-invalid-fn"
environment = fission_environment.bad_env.name
package_name = fission_package.pkg.name
entrypoint = "main.main"
}
+2
View File
@@ -0,0 +1,2 @@
def main():
return "test"
+20
View File
@@ -0,0 +1,20 @@
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.1.0"
}
}
}
provider "fission" {
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "default"
}
resource "fission_http_trigger" "route" {
name = "tf-missing-ref-route"
url = "/missing-ref"
methods = ["GET"]
function = "tf-missing-ref-fn"
}
+2
View File
@@ -0,0 +1,2 @@
def main():
return "env-1"
+39
View File
@@ -0,0 +1,39 @@
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.1.0"
}
}
}
provider "fission" {
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "default"
}
resource "fission_environment" "python" {
name = "tf-multi-env-1"
image = "ghcr.io/fission/python-env"
version = 3
}
resource "fission_package" "pkg" {
name = "tf-multi-env-1-pkg"
environment = fission_environment.python.name
source_dir = "${path.module}/code"
}
resource "fission_function" "fn" {
name = "tf-multi-env-1-fn"
environment = fission_environment.python.name
package_name = fission_package.pkg.name
entrypoint = "main.main"
}
resource "fission_http_trigger" "route" {
name = "tf-multi-env-1-route"
function = fission_function.fn.name
url = "/multi-env-1"
methods = ["GET"]
}
+2
View File
@@ -0,0 +1,2 @@
def main():
return "env-2"
+39
View File
@@ -0,0 +1,39 @@
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.1.0"
}
}
}
provider "fission" {
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "default"
}
resource "fission_environment" "python" {
name = "tf-multi-env-2"
image = "ghcr.io/fission/python-env"
version = 3
}
resource "fission_package" "pkg" {
name = "tf-multi-env-2-pkg"
environment = fission_environment.python.name
source_dir = "${path.module}/code"
}
resource "fission_function" "fn" {
name = "tf-multi-env-2-fn"
environment = fission_environment.python.name
package_name = fission_package.pkg.name
entrypoint = "main.main"
}
resource "fission_http_trigger" "route" {
name = "tf-multi-env-2-route"
function = fission_function.fn.name
url = "/multi-env-2"
methods = ["GET"]
}
+2
View File
@@ -0,0 +1,2 @@
def main():
return "env-3"
+39
View File
@@ -0,0 +1,39 @@
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.1.0"
}
}
}
provider "fission" {
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "default"
}
resource "fission_environment" "python" {
name = "tf-multi-env-3"
image = "ghcr.io/fission/python-env"
version = 3
}
resource "fission_package" "pkg" {
name = "tf-multi-env-3-pkg"
environment = fission_environment.python.name
source_dir = "${path.module}/code"
}
resource "fission_function" "fn" {
name = "tf-multi-env-3-fn"
environment = fission_environment.python.name
package_name = fission_package.pkg.name
entrypoint = "main.main"
}
resource "fission_http_trigger" "route" {
name = "tf-multi-env-3-route"
function = fission_function.fn.name
url = "/multi-env-3"
methods = ["GET"]
}
@@ -0,0 +1,4 @@
import nonexistent_module_xyz_12345
def main():
return nonexistent_module_xyz_12345.do_something()
+39
View File
@@ -0,0 +1,39 @@
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.1.0"
}
}
}
provider "fission" {
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "default"
}
resource "fission_environment" "python" {
name = "tf-neg-badimport-env"
image = "ghcr.io/fission/python-env"
version = 3
}
resource "fission_package" "pkg" {
name = "tf-neg-badimport-pkg"
environment = fission_environment.python.name
source_dir = "${path.module}/code"
}
resource "fission_function" "fn" {
name = "tf-neg-badimport-fn"
environment = fission_environment.python.name
package_name = fission_package.pkg.name
entrypoint = "main.main"
}
resource "fission_http_trigger" "route" {
name = "tf-neg-badimport-route"
function = fission_function.fn.name
url = "/neg/badimport"
methods = ["GET"]
}
+20
View File
@@ -0,0 +1,20 @@
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.1.0"
}
}
}
provider "fission" {
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "default"
}
# Пытаемся создать environment с именем, которое уже существует (tf-python-env из hello-python)
resource "fission_environment" "conflict" {
name = "tf-python-env"
image = "ghcr.io/fission/python-env"
version = 3
}
+2
View File
@@ -0,0 +1,2 @@
def not_main():
return "there is no main() here, Fission will fail to invoke"
+39
View File
@@ -0,0 +1,39 @@
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.1.0"
}
}
}
provider "fission" {
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "default"
}
resource "fission_environment" "python" {
name = "tf-neg-nomain-env"
image = "ghcr.io/fission/python-env"
version = 3
}
resource "fission_package" "pkg" {
name = "tf-neg-nomain-pkg"
environment = fission_environment.python.name
source_dir = "${path.module}/code"
}
resource "fission_function" "fn" {
name = "tf-neg-nomain-fn"
environment = fission_environment.python.name
package_name = fission_package.pkg.name
entrypoint = "main.main"
}
resource "fission_http_trigger" "route" {
name = "tf-neg-nomain-route"
function = fission_function.fn.name
url = "/neg/nomain"
methods = ["GET"]
}
@@ -0,0 +1,3 @@
def main():
x = 1 / 0
return f"result: {x}"
+39
View File
@@ -0,0 +1,39 @@
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.1.0"
}
}
}
provider "fission" {
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "default"
}
resource "fission_environment" "python" {
name = "tf-neg-rterr-env"
image = "ghcr.io/fission/python-env"
version = 3
}
resource "fission_package" "pkg" {
name = "tf-neg-rterr-pkg"
environment = fission_environment.python.name
source_dir = "${path.module}/code"
}
resource "fission_function" "fn" {
name = "tf-neg-rterr-fn"
environment = fission_environment.python.name
package_name = fission_package.pkg.name
entrypoint = "main.main"
}
resource "fission_http_trigger" "route" {
name = "tf-neg-rterr-route"
function = fission_function.fn.name
url = "/neg/rterr"
methods = ["GET"]
}
@@ -0,0 +1,2 @@
def main(:
return "this should never work"
+39
View File
@@ -0,0 +1,39 @@
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.1.0"
}
}
}
provider "fission" {
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "default"
}
resource "fission_environment" "python" {
name = "tf-neg-syntax-env"
image = "ghcr.io/fission/python-env"
version = 3
}
resource "fission_package" "pkg" {
name = "tf-neg-syntax-pkg"
environment = fission_environment.python.name
source_dir = "${path.module}/code"
}
resource "fission_function" "fn" {
name = "tf-neg-syntax-fn"
environment = fission_environment.python.name
package_name = fission_package.pkg.name
entrypoint = "main.main"
}
resource "fission_http_trigger" "route" {
name = "tf-neg-syntax-route"
function = fission_function.fn.name
url = "/neg/syntax"
methods = ["GET"]
}
+2
View File
@@ -0,0 +1,2 @@
def main():
return "orphan-test"
+39
View File
@@ -0,0 +1,39 @@
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.1.0"
}
}
}
provider "fission" {
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "default"
}
resource "fission_environment" "python" {
name = "tf-orphan-env"
image = "ghcr.io/fission/python-env"
version = 3
}
resource "fission_package" "pkg" {
name = "tf-orphan-pkg"
environment = fission_environment.python.name
source_dir = "${path.module}/code"
}
resource "fission_function" "fn" {
name = "tf-orphan-fn"
environment = fission_environment.python.name
package_name = fission_package.pkg.name
entrypoint = "main.main"
}
resource "fission_http_trigger" "route" {
name = "tf-orphan-route"
function = fission_function.fn.name
url = "/orphan-test"
methods = ["GET"]
}
+3
View File
@@ -0,0 +1,3 @@
sub {
return "Hello from Perl in Fission";
}
+40
View File
@@ -0,0 +1,40 @@
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.1.0"
}
}
}
provider "fission" {
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "default"
}
# version = 1: Perl env uses v1 specialization (/specialize endpoint)
resource "fission_environment" "perl" {
name = "tf-perl-hello-env"
image = "ghcr.io/fission/perl-env"
version = 1
}
resource "fission_package" "pkg" {
name = "tf-perl-hello-pkg"
environment = fission_environment.perl.name
source_dir = "${path.module}/code"
}
resource "fission_function" "fn" {
name = "tf-perl-hello-fn"
environment = fission_environment.perl.name
package_name = fission_package.pkg.name
entrypoint = "handler"
}
resource "fission_http_trigger" "route" {
name = "tf-perl-hello-route"
function = fission_function.fn.name
url = "/perl-hello"
methods = ["GET"]
}
+7
View File
@@ -0,0 +1,7 @@
<?php
function handler($context)
{
/** @var \Psr\Http\Message\ResponseInterface $response */
$response = $context["response"];
$response->getBody()->write("Hello from PHP in Fission");
}
+39
View File
@@ -0,0 +1,39 @@
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.1.0"
}
}
}
provider "fission" {
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "default"
}
resource "fission_environment" "php" {
name = "tf-php-hello-env"
image = "ghcr.io/fission/php-env"
version = 3
}
resource "fission_package" "pkg" {
name = "tf-php-hello-pkg"
environment = fission_environment.php.name
source_dir = "${path.module}/code"
}
resource "fission_function" "fn" {
name = "tf-php-hello-fn"
environment = fission_environment.php.name
package_name = fission_package.pkg.name
entrypoint = "main.php::handler"
}
resource "fission_http_trigger" "route" {
name = "tf-php-hello-route"
function = fission_function.fn.name
url = "/php-hello"
methods = ["GET"]
}
+5
View File
@@ -0,0 +1,5 @@
# frozen_string_literal: true
def handler
"Hello from Ruby in Fission"
end
+39
View File
@@ -0,0 +1,39 @@
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.1.0"
}
}
}
provider "fission" {
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "default"
}
resource "fission_environment" "ruby" {
name = "tf-ruby-hello-env"
image = "ghcr.io/fission/ruby-env"
version = 3
}
resource "fission_package" "pkg" {
name = "tf-ruby-hello-pkg"
environment = fission_environment.ruby.name
source_dir = "${path.module}/code"
}
resource "fission_function" "fn" {
name = "tf-ruby-hello-fn"
environment = fission_environment.ruby.name
package_name = fission_package.pkg.name
entrypoint = "handler"
}
resource "fission_http_trigger" "route" {
name = "tf-ruby-hello-route"
function = fission_function.fn.name
url = "/ruby-hello"
methods = ["GET"]
}
@@ -0,0 +1,2 @@
def exists():
return "x"
+32
View File
@@ -0,0 +1,32 @@
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.1.0"
}
}
}
provider "fission" {
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "default"
}
resource "fission_environment" "env" {
name = "tf-validate-bad-entry-env"
image = "ghcr.io/fission/python-env"
version = 3
}
resource "fission_package" "pkg" {
name = "tf-validate-bad-entry-pkg"
environment = fission_environment.env.name
source_dir = "${path.module}/code"
}
resource "fission_function" "fn" {
name = "tf-validate-bad-entry-fn"
environment = fission_environment.env.name
package_name = fission_package.pkg.name
entrypoint = "main.main"
}
@@ -0,0 +1,2 @@
def main():
return "x"
+19
View File
@@ -0,0 +1,19 @@
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.1.0"
}
}
}
provider "fission" {
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "default"
}
resource "fission_package" "pkg" {
name = "tf-validate-missing-env-pkg"
environment = "env-does-not-exist-xyz"
source_dir = "${path.module}/code"
}
+25
View File
@@ -0,0 +1,25 @@
ARG GO_VERSION=1.25
FROM ghcr.io/fission/builder AS fission-builder
FROM golang:${GO_VERSION}
ENV GOPATH=/usr
ENV GO111MODULE=on
ENV GOTOOLCHAIN=local
WORKDIR ${GOPATH}
COPY --from=fission-builder /builder /builder
# Prebuild stdlib для -buildmode=plugin через dummy plugin.
# Go компилирует всю stdlib с plugin-флагами и кладёт в /root/.cache/go-build.
# Следующие сборки найдут кэш и пропустят stdlib → ~10-30 сек вместо 4 мин.
RUN mkdir -p /tmp/prebuild && \
printf 'package main\n\nimport (\n\t"fmt"\n\t"net/http"\n)\n\nfunc Handler(w http.ResponseWriter, r *http.Request) {\n\tfmt.Fprintf(w, "ok")\n}\n' \
> /tmp/prebuild/main.go && \
cd /tmp/prebuild && \
go mod init prebuild && \
go build -buildmode=plugin -o /tmp/prebuild.so . && \
rm -rf /tmp/prebuild /tmp/prebuild.so
ADD build.sh /usr/local/bin/build
RUN chmod +x /usr/local/bin/build
+34
View File
@@ -0,0 +1,34 @@
#!/bin/bash
set -eux
srcDir=${GOPATH}/src/$(basename ${SRC_PKG})
trap "rm -rf ${srcDir}" EXIT
version_ge() { test "$(echo "$@" | tr " " "\n" | sort -rV | head -n 1)" == "$1"; }
if [ -d ${SRC_PKG} ]; then
echo "Building in directory ${srcDir}"
ln -sf ${SRC_PKG} ${srcDir}
elif [ -f ${SRC_PKG} ]; then
echo "Building file ${SRC_PKG} in ${srcDir}"
mkdir -p ${srcDir}
cp ${SRC_PKG} ${srcDir}/function.go
fi
cd ${srcDir}
if [ -f "go.mod" ]; then
go mod download
else
export GO111MODULE="on"
go mod init
go mod tidy
fi
GOFLAGS="-buildmode=plugin"
if [ -d "vendor" ] && version_ge ${GOLANG_VERSION} "1.12"; then
GOFLAGS="${GOFLAGS} -mod=vendor"
fi
go build ${GOFLAGS} -o ${DEPLOY_PKG} .
+7
View File
@@ -0,0 +1,7 @@
Ntazetdinov@nubes.ru
https://api.aillm.ru/
sk-ucI5YvOticoOQ9Kuj5K9mQ
@@ -98,6 +98,7 @@ func (p *FissionProvider) Resources(_ context.Context) []func() resource.Resourc
resources.NewPackageResource, resources.NewPackageResource,
resources.NewFunctionResource, resources.NewFunctionResource,
resources.NewHTTPTriggerResource, resources.NewHTTPTriggerResource,
resources.NewSimpleFunctionResource,
} }
} }
@@ -24,13 +24,15 @@ type EnvironmentResource struct {
} }
type environmentResourceModel struct { type environmentResourceModel struct {
ID types.String `tfsdk:"id"` ID types.String `tfsdk:"id"`
Name types.String `tfsdk:"name"` Name types.String `tfsdk:"name"`
Image types.String `tfsdk:"image"` Image types.String `tfsdk:"image"`
Version types.Int64 `tfsdk:"version"` Version types.Int64 `tfsdk:"version"`
PoolSize types.Int64 `tfsdk:"poolsize"` PoolSize types.Int64 `tfsdk:"poolsize"`
Namespace types.String `tfsdk:"namespace"` BuilderImage types.String `tfsdk:"builder_image"`
UID types.String `tfsdk:"uid"` BuilderCommand types.String `tfsdk:"builder_command"`
Namespace types.String `tfsdk:"namespace"`
UID types.String `tfsdk:"uid"`
} }
func NewEnvironmentResource() resource.Resource { func NewEnvironmentResource() resource.Resource {
@@ -68,6 +70,14 @@ func (r *EnvironmentResource) Schema(_ context.Context, _ resource.SchemaRequest
Default: int64default.StaticInt64(3), Default: int64default.StaticInt64(3),
Description: "Размер пула pre-warmed контейнеров.", Description: "Размер пула pre-warmed контейнеров.",
}, },
"builder_image": schema.StringAttribute{
Optional: true,
Description: "Builder image для Environment (например ghcr.io/fission/go-builder). Нужен для языков с build step (Go и др.).",
},
"builder_command": schema.StringAttribute{
Optional: true,
Description: "Команда сборки в builder контейнере (например 'build').",
},
"namespace": schema.StringAttribute{ "namespace": schema.StringAttribute{
Optional: true, Optional: true,
Computed: true, Computed: true,
@@ -216,6 +226,26 @@ func (r *EnvironmentResource) ImportState(ctx context.Context, req resource.Impo
// environmentToUnstructured преобразует Terraform model в Kubernetes CRD payload. // environmentToUnstructured преобразует Terraform model в Kubernetes CRD payload.
func environmentToUnstructured(model environmentResourceModel, namespace string) *unstructured.Unstructured { func environmentToUnstructured(model environmentResourceModel, namespace string) *unstructured.Unstructured {
spec := map[string]interface{}{
"version": model.Version.ValueInt64(),
"runtime": map[string]interface{}{
"image": model.Image.ValueString(),
},
"poolsize": model.PoolSize.ValueInt64(),
}
builderImage := model.BuilderImage.ValueString()
if builderImage != "" {
builder := map[string]interface{}{
"image": builderImage,
}
builderCmd := model.BuilderCommand.ValueString()
if builderCmd != "" {
builder["command"] = builderCmd
}
spec["builder"] = builder
}
return &unstructured.Unstructured{Object: map[string]interface{}{ return &unstructured.Unstructured{Object: map[string]interface{}{
"apiVersion": "fission.io/v1", "apiVersion": "fission.io/v1",
"kind": "Environment", "kind": "Environment",
@@ -223,13 +253,7 @@ func environmentToUnstructured(model environmentResourceModel, namespace string)
"name": model.Name.ValueString(), "name": model.Name.ValueString(),
"namespace": namespace, "namespace": namespace,
}, },
"spec": map[string]interface{}{ "spec": spec,
"version": model.Version.ValueInt64(),
"runtime": map[string]interface{}{
"image": model.Image.ValueString(),
},
"poolsize": model.PoolSize.ValueInt64(),
},
}} }}
} }
@@ -238,6 +262,8 @@ func unstructuredToEnvironmentModel(environmentObject *unstructured.Unstructured
imageValue, _, _ := unstructured.NestedString(environmentObject.Object, "spec", "runtime", "image") imageValue, _, _ := unstructured.NestedString(environmentObject.Object, "spec", "runtime", "image")
versionValue, _, _ := unstructured.NestedInt64(environmentObject.Object, "spec", "version") versionValue, _, _ := unstructured.NestedInt64(environmentObject.Object, "spec", "version")
poolsizeValue, _, _ := unstructured.NestedInt64(environmentObject.Object, "spec", "poolsize") poolsizeValue, _, _ := unstructured.NestedInt64(environmentObject.Object, "spec", "poolsize")
builderImage, _, _ := unstructured.NestedString(environmentObject.Object, "spec", "builder", "image")
builderCommand, _, _ := unstructured.NestedString(environmentObject.Object, "spec", "builder", "command")
state := base state := base
state.Name = types.StringValue(environmentObject.GetName()) state.Name = types.StringValue(environmentObject.GetName())
@@ -254,6 +280,12 @@ func unstructuredToEnvironmentModel(environmentObject *unstructured.Unstructured
if poolsizeValue != 0 { if poolsizeValue != 0 {
state.PoolSize = types.Int64Value(poolsizeValue) state.PoolSize = types.Int64Value(poolsizeValue)
} }
if builderImage != "" {
state.BuilderImage = types.StringValue(builderImage)
}
if builderCommand != "" {
state.BuilderCommand = types.StringValue(builderCommand)
}
return state return state
} }
@@ -4,6 +4,7 @@ import (
"testing" "testing"
"github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
) )
func TestEnvironmentToUnstructuredAndBack(t *testing.T) { func TestEnvironmentToUnstructuredAndBack(t *testing.T) {
@@ -33,3 +34,51 @@ func TestEnvironmentToUnstructuredAndBack(t *testing.T) {
t.Fatalf("unexpected poolsize: %d", state.PoolSize.ValueInt64()) t.Fatalf("unexpected poolsize: %d", state.PoolSize.ValueInt64())
} }
} }
func TestEnvironmentToUnstructuredWithBuilder(t *testing.T) {
input := environmentResourceModel{
Name: types.StringValue("go-env"),
Image: types.StringValue("ghcr.io/fission/go-env"),
Version: types.Int64Value(3),
PoolSize: types.Int64Value(3),
BuilderImage: types.StringValue("ghcr.io/fission/go-builder"),
BuilderCommand: types.StringValue("build"),
}
obj := environmentToUnstructured(input, "default")
state := unstructuredToEnvironmentModel(obj, input)
if state.BuilderImage.ValueString() != "ghcr.io/fission/go-builder" {
t.Fatalf("unexpected builder_image: %q", state.BuilderImage.ValueString())
}
if state.BuilderCommand.ValueString() != "build" {
t.Fatalf("unexpected builder_command: %q", state.BuilderCommand.ValueString())
}
// Verify the unstructured object has builder section
builderImage, found, _ := unstructured.NestedString(obj.Object, "spec", "builder", "image")
if !found || builderImage != "ghcr.io/fission/go-builder" {
t.Fatalf("builder.image not set correctly in unstructured: %q", builderImage)
}
builderCmd, found, _ := unstructured.NestedString(obj.Object, "spec", "builder", "command")
if !found || builderCmd != "build" {
t.Fatalf("builder.command not set correctly in unstructured: %q", builderCmd)
}
}
func TestEnvironmentToUnstructuredWithoutBuilder(t *testing.T) {
input := environmentResourceModel{
Name: types.StringValue("py-env"),
Image: types.StringValue("ghcr.io/fission/python-env"),
Version: types.Int64Value(3),
PoolSize: types.Int64Value(3),
}
obj := environmentToUnstructured(input, "default")
// Verify no builder section when builder_image is not set
_, found, _ := unstructured.NestedString(obj.Object, "spec", "builder", "image")
if found {
t.Fatalf("builder should not be present when builder_image is not set")
}
}
@@ -2,11 +2,14 @@ package resources
import ( import (
"context" "context"
"encoding/base64"
"fmt" "fmt"
"strings"
"github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault"
"github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
@@ -24,13 +27,18 @@ type FunctionResource struct {
// functionResourceModel описывает состояние terraform ресурса fission_function. // functionResourceModel описывает состояние terraform ресурса fission_function.
type functionResourceModel struct { type functionResourceModel struct {
ID types.String `tfsdk:"id"` ID types.String `tfsdk:"id"`
Name types.String `tfsdk:"name"` Name types.String `tfsdk:"name"`
Environment types.String `tfsdk:"environment"` Environment types.String `tfsdk:"environment"`
PackageName types.String `tfsdk:"package_name"` PackageName types.String `tfsdk:"package_name"`
Entrypoint types.String `tfsdk:"entrypoint"` Entrypoint types.String `tfsdk:"entrypoint"`
Namespace types.String `tfsdk:"namespace"` ExecutorType types.String `tfsdk:"executor_type"`
UID types.String `tfsdk:"uid"` FunctionTimeout types.Int64 `tfsdk:"function_timeout"`
IdleTimeout types.Int64 `tfsdk:"idle_timeout"`
MinScale types.Int64 `tfsdk:"min_scale"`
MaxScale types.Int64 `tfsdk:"max_scale"`
Namespace types.String `tfsdk:"namespace"`
UID types.String `tfsdk:"uid"`
} }
// NewFunctionResource создает инстанс ресурса функции. // NewFunctionResource создает инстанс ресурса функции.
@@ -67,6 +75,28 @@ func (r *FunctionResource) Schema(_ context.Context, _ resource.SchemaRequest, r
Required: true, Required: true,
Description: "Имя точки входа в пакете (например main.main).", Description: "Имя точки входа в пакете (например main.main).",
}, },
"executor_type": schema.StringAttribute{
Optional: true,
Computed: true,
Default: stringdefault.StaticString("poolmgr"),
Description: "Тип executor: poolmgr (default), newdeploy или container.",
},
"function_timeout": schema.Int64Attribute{
Optional: true,
Description: "Таймаут выполнения функции в секундах (Fission default: 60).",
},
"idle_timeout": schema.Int64Attribute{
Optional: true,
Description: "Время простоя до scale-to-zero в секундах (Fission default: 120).",
},
"min_scale": schema.Int64Attribute{
Optional: true,
Description: "Минимальное число реплик (для newdeploy/container).",
},
"max_scale": schema.Int64Attribute{
Optional: true,
Description: "Максимальное число реплик (для newdeploy/container).",
},
"namespace": schema.StringAttribute{ "namespace": schema.StringAttribute{
Optional: true, Optional: true,
Computed: true, Computed: true,
@@ -107,6 +137,22 @@ func (r *FunctionResource) Create(ctx context.Context, req resource.CreateReques
} }
namespace := resolveNamespace(plan.Namespace, r.client.Namespace) namespace := resolveNamespace(plan.Namespace, r.client.Namespace)
if err := ensureEnvironmentExists(ctx, r.client, namespace, plan.Environment.ValueString()); err != nil {
resp.Diagnostics.AddError("Ошибка валидации Environment для Function", err.Error())
return
}
pkg, err := ensurePackageExists(ctx, r.client, namespace, plan.PackageName.ValueString())
if err != nil {
resp.Diagnostics.AddError("Ошибка валидации Package для Function", err.Error())
return
}
if err := validateEntrypointAgainstPackageSource(plan.Entrypoint.ValueString(), pkg); err != nil {
resp.Diagnostics.AddError("Ошибка валидации entrypoint", err.Error())
return
}
functionObject := functionToUnstructured(plan, namespace) functionObject := functionToUnstructured(plan, namespace)
createdFunction, err := r.client.CreateFunction(ctx, functionObject) createdFunction, err := r.client.CreateFunction(ctx, functionObject)
@@ -152,6 +198,22 @@ func (r *FunctionResource) Update(ctx context.Context, req resource.UpdateReques
} }
namespace := resolveNamespace(plan.Namespace, r.client.Namespace) namespace := resolveNamespace(plan.Namespace, r.client.Namespace)
if err := ensureEnvironmentExists(ctx, r.client, namespace, plan.Environment.ValueString()); err != nil {
resp.Diagnostics.AddError("Ошибка валидации Environment для Function", err.Error())
return
}
pkg, err := ensurePackageExists(ctx, r.client, namespace, plan.PackageName.ValueString())
if err != nil {
resp.Diagnostics.AddError("Ошибка валидации Package для Function", err.Error())
return
}
if err := validateEntrypointAgainstPackageSource(plan.Entrypoint.ValueString(), pkg); err != nil {
resp.Diagnostics.AddError("Ошибка валидации entrypoint", err.Error())
return
}
existingFunction, err := r.client.GetFunction(ctx, namespace, plan.Name.ValueString()) existingFunction, err := r.client.GetFunction(ctx, namespace, plan.Name.ValueString())
if err != nil { if err != nil {
resp.Diagnostics.AddError("Ошибка получения Function перед обновлением", err.Error()) resp.Diagnostics.AddError("Ошибка получения Function перед обновлением", err.Error())
@@ -201,6 +263,46 @@ func (r *FunctionResource) ImportState(ctx context.Context, req resource.ImportS
// functionToUnstructured преобразует Terraform model в Kubernetes CRD payload. // functionToUnstructured преобразует Terraform model в Kubernetes CRD payload.
func functionToUnstructured(model functionResourceModel, namespace string) *unstructured.Unstructured { func functionToUnstructured(model functionResourceModel, namespace string) *unstructured.Unstructured {
executorType := "poolmgr"
if !model.ExecutorType.IsNull() && !model.ExecutorType.IsUnknown() && model.ExecutorType.ValueString() != "" {
executorType = model.ExecutorType.ValueString()
}
executionStrategy := map[string]interface{}{
"ExecutorType": executorType,
}
if !model.MinScale.IsNull() && !model.MinScale.IsUnknown() {
executionStrategy["MinScale"] = model.MinScale.ValueInt64()
}
if !model.MaxScale.IsNull() && !model.MaxScale.IsUnknown() {
executionStrategy["MaxScale"] = model.MaxScale.ValueInt64()
}
spec := map[string]interface{}{
"environment": map[string]interface{}{
"name": model.Environment.ValueString(),
"namespace": namespace,
},
"InvokeStrategy": map[string]interface{}{
"ExecutionStrategy": executionStrategy,
"StrategyType": "execution",
},
"package": map[string]interface{}{
"packageref": map[string]interface{}{
"name": model.PackageName.ValueString(),
"namespace": namespace,
},
"functionName": model.Entrypoint.ValueString(),
},
}
if !model.FunctionTimeout.IsNull() && !model.FunctionTimeout.IsUnknown() {
spec["functionTimeout"] = model.FunctionTimeout.ValueInt64()
}
if !model.IdleTimeout.IsNull() && !model.IdleTimeout.IsUnknown() {
spec["idletimeout"] = model.IdleTimeout.ValueInt64()
}
return &unstructured.Unstructured{Object: map[string]interface{}{ return &unstructured.Unstructured{Object: map[string]interface{}{
"apiVersion": "fission.io/v1", "apiVersion": "fission.io/v1",
"kind": "Function", "kind": "Function",
@@ -208,25 +310,7 @@ func functionToUnstructured(model functionResourceModel, namespace string) *unst
"name": model.Name.ValueString(), "name": model.Name.ValueString(),
"namespace": namespace, "namespace": namespace,
}, },
"spec": map[string]interface{}{ "spec": spec,
"environment": map[string]interface{}{
"name": model.Environment.ValueString(),
"namespace": namespace,
},
"InvokeStrategy": map[string]interface{}{
"ExecutionStrategy": map[string]interface{}{
"ExecutorType": "poolmgr",
},
"StrategyType": "execution",
},
"package": map[string]interface{}{
"packageref": map[string]interface{}{
"name": model.PackageName.ValueString(),
"namespace": namespace,
},
"functionName": model.Entrypoint.ValueString(),
},
},
}} }}
} }
@@ -235,6 +319,11 @@ func unstructuredToFunctionModel(functionObject *unstructured.Unstructured, base
environmentName, _, _ := unstructured.NestedString(functionObject.Object, "spec", "environment", "name") environmentName, _, _ := unstructured.NestedString(functionObject.Object, "spec", "environment", "name")
packageName, _, _ := unstructured.NestedString(functionObject.Object, "spec", "package", "packageref", "name") packageName, _, _ := unstructured.NestedString(functionObject.Object, "spec", "package", "packageref", "name")
entrypoint, _, _ := unstructured.NestedString(functionObject.Object, "spec", "package", "functionName") entrypoint, _, _ := unstructured.NestedString(functionObject.Object, "spec", "package", "functionName")
executorType, _, _ := unstructured.NestedString(functionObject.Object, "spec", "InvokeStrategy", "ExecutionStrategy", "ExecutorType")
functionTimeout, foundFT, _ := unstructured.NestedInt64(functionObject.Object, "spec", "functionTimeout")
idleTimeout, foundIT, _ := unstructured.NestedInt64(functionObject.Object, "spec", "idletimeout")
minScale, foundMin, _ := unstructured.NestedInt64(functionObject.Object, "spec", "InvokeStrategy", "ExecutionStrategy", "MinScale")
maxScale, foundMax, _ := unstructured.NestedInt64(functionObject.Object, "spec", "InvokeStrategy", "ExecutionStrategy", "MaxScale")
state := base state := base
state.Name = types.StringValue(functionObject.GetName()) state.Name = types.StringValue(functionObject.GetName())
@@ -251,6 +340,72 @@ func unstructuredToFunctionModel(functionObject *unstructured.Unstructured, base
if entrypoint != "" { if entrypoint != "" {
state.Entrypoint = types.StringValue(entrypoint) state.Entrypoint = types.StringValue(entrypoint)
} }
if executorType != "" {
state.ExecutorType = types.StringValue(executorType)
}
if foundFT {
state.FunctionTimeout = types.Int64Value(functionTimeout)
}
if foundIT {
state.IdleTimeout = types.Int64Value(idleTimeout)
}
if foundMin {
state.MinScale = types.Int64Value(minScale)
}
if foundMax {
state.MaxScale = types.Int64Value(maxScale)
}
return state return state
} }
func validateEntrypointAgainstPackageSource(entrypoint string, pkg *unstructured.Unstructured) error {
if entrypoint == "" {
return fmt.Errorf("entrypoint не может быть пустым")
}
// Для Python/Go/JS: валидируем формат module.function и наличие функции в исходнике.
// Для PHP (module::function), Ruby (function), Perl (function) — допускаем любой непустой формат.
parts := strings.Split(entrypoint, ".")
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
// Не стандартный module.function — допускаем (PHP, Ruby, Perl и др.)
return nil
}
if parts[0] != "main" {
return nil
}
literalSource, found, err := unstructured.NestedString(pkg.Object, "spec", "deployment", "literal")
if err != nil || !found || literalSource == "" {
return nil
}
literalBytes, err := base64.StdEncoding.DecodeString(literalSource)
if err != nil {
return nil
}
source := string(literalBytes)
if looksLikePythonSource(source) {
signature := fmt.Sprintf("def %s(", parts[1])
if !strings.Contains(source, signature) {
return fmt.Errorf("entrypoint %q не найден в Python исходнике пакета (ожидался %q)", entrypoint, signature)
}
}
return nil
}
func looksLikePythonSource(source string) bool {
trimmed := strings.TrimSpace(source)
if strings.HasPrefix(trimmed, "def ") || strings.Contains(source, "\ndef ") {
return true
}
if strings.Contains(source, "import ") && !strings.Contains(source, "func ") && !strings.Contains(source, "module.exports") {
return true
}
return false
}

Some files were not shown because too many files have changed in this diff Show More