Compare commits

...
Author SHA1 Message Date
“Naeel” 3e36ff13a8 docs: fix outdated references — versions, S3/PVC, triggers status 2026-05-21 09:24:42 +04:00
“Naeel” 53ef35968e fix: billing.NoopStore in clone test + repo analysis doc 2026-05-21 09:14:31 +04:00
“Naeel” ab128515ab docs: README — storagesvc S3/PV deploy instructions + comparison table 2026-05-20 09:09:11 +04:00
“Naeel” 296b538d20 bench: S3 results 2026-05-20 PASS=20/20 + PV vs S3 comparison
RUNID=9251970 (S3 ngcloud msk-1):
- PASS=20/20 — все тесты прошли
- Upload: мелкие файлы медленнее (+1-3s), крупные — сопоставимо
- Cold start: S3 ≈ PV (python быстрее, ruby small медленнее, PHP одинаково)
- Hot avg: практически идентично на обоих бэкендах

ruby/medium cold=80568ms — аномалия воспроизвелась 4-й раз подряд на S3.
Подтверждено: не связана с типом хранилища, это поведение poolmgr.

Вердикт: S3 == PV по производительности. S3 предпочтительнее.
2026-05-20 08:45:40 +04:00
“Naeel” 7539680ae0 chore: switch storagesvc to S3 backend (ngcloud msk-1)
- helm/fission-values.yaml: storageType=s3, bucket=sless-functions, endPoint=https://s3.msk-1.ngcloud.ru
- helm/fission-values-pv.yaml: сохранён PV конфиг для быстрого возврата
- helm/fission-s3-secret.yaml: credentials (в .gitignore, не в репо)
- .gitignore: добавлен helm/fission-s3-secret.yaml

Возврат на PV: kubectl delete deploy executor router storagesvc -n fission
  helm upgrade fission ... -f helm/fission-values-pv.yaml --no-hooks
2026-05-20 08:39:51 +04:00
“Naeel” 005b4786c0 docs: bench PV results 2026-05-20 PASS=20/20; misc updates 2026-05-20 08:30:26 +04:00
“Naeel” a2e1723e33 bench: fix PHP arrow functions (fn()=> requires 7.4+), PASS=20/20 on PV
- Replace fn()=> with traditional function() syntax in php_medium and php_large
- 8 arrow function occurrences fixed across processWords/statsArr/caesar/lookupOps/nested array_map
- Added diag_bugs.sh: diagnostic script for PHP pool exhaustion + ruby/medium cold anomaly
- bench_full.sh RUNID=9250986: PASS=20 FAIL=0 (was PASS=18 FAIL=2)
- ruby/medium cold=81549ms anomaly confirmed reproducible (3rd time), not a FAIL
- Added doc/report-2026-05-20-bench-php-analysis.md
2026-05-20 08:27:32 +04:00
“Naeel” dd7e42caf4 fix: ExpiryReaper orphan race min-age 2m + Ruby handler format (v1.3.96) 2026-05-20 07:37:04 +04:00
“Naeel” fe8870af71 fix: nodejs wrapper передаёт require в new Function (v1.3.95) 2026-05-19 16:48:19 +04:00
“Naeel” 27dbe3bccb docs: add FISSION_IMAGES.md — image tags and rebuild instructions 2026-05-19 11:33:58 +04:00
“Naeel” 090a7afdd7 fix: nodejs entrypoint back to 'main' — node-env splits on '.' giving funcname='js' 2026-05-19 11:05:07 +04:00
“Naeel” 042b1e5046 fix: PHP env PSR-7 format — getBody()->write() instead of return array 2026-05-19 10:41:46 +04:00
“Naeel” 72e5d50cd0 fix: ExpiryReaper orphan race + nodejs ESM entrypoint (v1.3.93) 2026-05-19 10:30:28 +04:00
“Naeel” fd075a7f80 test: add test_heavy.sh — big code, CPU, mem, archive, rapid, parallel, slow 2026-05-19 10:16:09 +04:00
“Naeel” 78f1a2cfc4 doc: progress 2026-05-19 PV migration + fetcher v1.23.0, PASS=8 FAIL=0 2026-05-19 09:59:41 +04:00
“Naeel” 2aadae339e chore: rename storage-check → storage-bench-s3-archived (S3 отключён) 2026-05-19 09:50:33 +04:00
“Naeel” 3eb4f5669b feat: PV storage backend + fix fetcher image v1.23.0
- helm/fission-values.yaml: storagesvc на vcd-disk-ext4 PV 2Gi
  fetcher image: naeel/fetcher:v1.23.0 (собран из fission-src/cmd/fetcher)
- scripts/migrate_s3_to_pv.py: скрипт перелива S3→storagesvc (no-op: 0 пакетов с s3://)
- doc/report-2026-05-19-pv-migration-bugs.md: баги helm upgrade, FETCHER_IMAGE, field manager
2026-05-19 09:46:57 +04:00
“Naeel” 865d33a0e1 feat: storage benchmark — local-path PV, vcd-disk-ext4 PV, S3 (10 iter each) 2026-05-19 07:28:45 +04:00
“Naeel” 1d81ca34e1 doc: план и разъяснения по устранению 401 'key is of invalid type' (root cause, порядок исправления, volume-mount secret, отказ от fallback на SA token) 2026-05-18 19:51:59 +04:00
“Naeel” 8374254020 chore: gitignore scripts/test_layer1.sh 2026-05-15 14:16:30 +04:00
“Naeel” ae913f7ad6 chore: gitignore token.txt and test-results/ 2026-05-15 14:16:03 +04:00
“Naeel” 68296d8fe0 fix: update paths from ~/IoT and ~/fission to ~/fission-console in .github docs 2026-05-15 14:14:45 +04:00
“Naeel” 949b0b52d6 fix(console): v1.3.92 — EnsureEnvironment обновляет образ если устарел
Вместо костыля (kubectl patch вручную) — системный фикс:
EnsureEnvironment теперь проверяет spec.runtime.image у существующего env
и обновляет его если отличается от LangEnvMap[lang].Image.

Это автоматически мигрирует все namespace-ы при следующем создании функции.
2026-05-13 10:55:47 +04:00
“Naeel” 6a8c3a27e2 fix(console): v1.3.91 — deps сохраняются в аннотации и восстанавливаются при edit
- handleCreateFunction: deps → аннотация fission-console/deps
- handleUpdateFunctionCode: deps → аннотация fission-console/deps (или удаление если пусто)
- GET /functions/:name: возвращает deps из аннотации
- openEdit: заполняет e-deps.value = fn.deps (вместо пустого поля)
2026-05-13 10:43:59 +04:00
“Naeel” 75532d8d8e fix(console): v1.3.90 — deps textarea compact, linter внешние импорты, python env v1.1
- deps textarea: rows=3, resize:none, overflow-y:auto
- aiCheck (Python): после линтера проверяет внешние импорты
  если deps поле пустое — предупреждение с именами модулей
- Python env: v1.0 → v1.1 (содержит boto3+requests+psycopg2-binary)
2026-05-13 10:37:14 +04:00
“Naeel” 10b2b4a8e0 feat(console): поле зависимостей для функций из кода (deps → zip)
- UI: textarea 'Зависимости' в формах создания и редактирования
  - плейсхолдер меняется по языку (requirements.txt / package.json / Gemfile / composer.json)
- Python: если deps заполнен → zip(main.py + requirements.txt), иначе raw bytes
- PHP: если deps → zip(main.php + composer.json)
- Ruby: если deps → zip(handler.rb + Gemfile)
- Node.js: пока без изменений (сложная структура package.json)
- runtime: BuildPythonZip, BuildScriptZipWithDeps, buildZipTwo
- model: Deps string в CreateFunctionRequest и UpdateCodeRequest
- v1.3.89
2026-05-13 10:22:45 +04:00
“Naeel” 17f1c5d46f feat: стартовая точка для weather pipeline без Terraform (ветка feat/weather-pipeline-no-tf) 2026-05-13 07:44:38 +04:00
“Naeel” 4ef480d1f8 chore: ignore *.tfvars (contains secrets) 2026-05-12 11:22:44 +04:00
“Naeel” df16b40a9d feat: weather-demo MQ pipeline + sqs-consumer v1.2 with JWT auto-refresh
- sqs-consumer: new Go binary v1.2 with tokenManager (auto-login /auth/login, 5min cache, retry on 401)
- console: add MQ/KW triggers UI (mqtriggers.go, kwtriggers.go, mq.js)
- console: update python-env image to v1.1 (boto3+psycopg2+requests)
- weather-demo: fix consumer/main.py (Flask Request.get_json instead of dict access)
- weather-demo: fix fetcher/main.py (boto3 SQS publish, 5 cities)
- weather-demo: update main.tf (python-env v1.1, deploy_type=literal)
- python-env: add psycopg2-binary to Dockerfile (v1.1)
- terraform provider: client auth fix
2026-05-12 11:19:30 +04:00
“Naeel” fbd565651a feat(provider): add fission_mq_trigger, fission_cron_trigger, fission_iot_device + weather-demo example 2026-05-11 09:37:20 +04:00
“Naeel” fe8f6a871b feat: plan.md + minor fixes (stats, server, copilot rules) 2026-05-11 07:46:14 +04:00
“Naeel” c9f97e2f4c feat: v1.3.87 — Grafana Organizations per namespace (StatsProvider + public dashboards + UI button) 2026-05-10 18:47:21 +04:00
“Naeel” 4b8c776357 feat: grafana subpath /grafana on fission.kube5s.ru (temp, easy migration to grafana.kube5s.ru) 2026-05-10 09:12:21 +04:00
106 changed files with 8200 additions and 288 deletions
+7 -7
View File
@@ -28,16 +28,16 @@
1. Не трогать рабочий код без явного указания. 1. Не трогать рабочий код без явного указания.
2. Файлы редактируются локально: 2. Файлы редактируются локально:
~/fission ~/fission-console
После ЛЮБЫХ изменений ОБЯЗАТЕЛЬНО синхронизировать на ВМ командой: После ЛЮБЫХ изменений ОБЯЗАТЕЛЬНО синхронизировать на ВМ командой:
rsync -az \ rsync -az \
-e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \ -e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \
~/fission/ \ ~/fission-console/ \
naeel@5.172.178.213:~/terra/fission/ naeel@5.172.178.213:~/terra/fission/
3. Git (add/commit/push) выполнять ЛОКАЛЬНО в ~/fission 3. Git (add/commit/push) выполнять ЛОКАЛЬНО в ~/fission-console
4. Docker, kubectl и другие инфраструктурные команды — только через SSH на ВМ: 4. Docker, kubectl и другие инфраструктурные команды — только через SSH на ВМ:
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 'КОМАНДА' ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 'КОМАНДА'
+5 -5
View File
@@ -13,14 +13,14 @@
## Файловая система (актуально) ## Файловая система (актуально)
1. Все файлы редактируются локально: `~/fission` 1. Все файлы редактируются локально: `~/fission-console`
2. После любых изменений — обязательно rsync на ВМ: 2. После любых изменений — обязательно rsync на ВМ:
rsync -az \ rsync -az \
-e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \ -e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \
~/fission/ \ ~/fission-console/ \
naeel@5.172.178.213:~/terra/fission/ naeel@5.172.178.213:~/terra/fission/
3. Git (add/commit/push) выполнять ЛОКАЛЬНО в ~/fission 3. Git (add/commit/push) выполнять ЛОКАЛЬНО в ~/fission-console
4. Docker, kubectl и другие инфраструктурные команды — только через SSH на ВМ 4. Docker, kubectl и другие инфраструктурные команды — только через SSH на ВМ
5. Перед запуском любой команды на ВМ обязательно убедиться, что синхронизация (rsync) выполнена 5. Перед запуском любой команды на ВМ обязательно убедиться, что синхронизация (rsync) выполнена
6. SCP, sshfs, remote_dev и маунты больше НЕ используются 6. SCP, sshfs, remote_dev и маунты больше НЕ используются
@@ -50,7 +50,7 @@ ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=
## Git ## Git
⛔⛔⛔ АБСОЛЮТНОЕ ПРАВИЛО: ⛔⛔⛔ АБСОЛЮТНОЕ ПРАВИЛО:
- Git — ТОЛЬКО ЛОКАЛЬНО в `~/fission`. НИКОГДА через SSH на VM. - Git — ТОЛЬКО ЛОКАЛЬНО в `~/fission-console`. НИКОГДА через SSH на VM.
- Разрешены ТОЛЬКО две операции: `git commit` и `git push`. - Разрешены ТОЛЬКО две операции: `git commit` и `git push`.
- ЗАПРЕЩЕНО: git pull, git fetch, git rebase, git merge, git reset, git stash, git checkout — что угодно кроме commit и push. - ЗАПРЕЩЕНО: git pull, git fetch, git rebase, git merge, git reset, git stash, git checkout — что угодно кроме commit и push.
- Если push отклонён — СТОП, доложить пользователю. Не лезть в pull/merge/rebase самостоятельно. - Если push отклонён — СТОП, доложить пользователю. Не лезть в pull/merge/rebase самостоятельно.
@@ -86,7 +86,7 @@ LOG="test-results/$(date +%Y-%m-%d_%H-%M).log"
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no naeel@5.172.178.213 \ ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no naeel@5.172.178.213 \
"bash ~/terra/fission/scripts/run_all.sh 2>&1 | tee ~/terra/fission/${LOG}" "bash ~/terra/fission/scripts/run_all.sh 2>&1 | tee ~/terra/fission/${LOG}"
rsync -az -e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no" \ rsync -az -e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no" \
naeel@5.172.178.213:~/terra/fission/test-results/ ~/fission/test-results/ naeel@5.172.178.213:~/terra/fission/test-results/ ~/fission-console/test-results/
``` ```
**Никогда не разбираться с результатами по памяти / буферу / чату. Только лог.** **Никогда не разбираться с результатами по памяти / буферу / чату. Только лог.**
+9
View File
@@ -2,6 +2,7 @@
.terraform/ .terraform/
*.tfstate *.tfstate
*.tfstate.* *.tfstate.*
*.tfvars
# Go # Go
bin/ bin/
@@ -15,3 +16,11 @@ examples/*/dist/
terraform-provider-fission terraform-provider-fission
terraform-provider-fission_* terraform-provider-fission_*
console/fission-console console/fission-console
# Secrets / tokens
token.txt
helm/fission-s3-secret.yaml
# Test logs
test-results/
scripts/test_layer1.sh
+92
View File
@@ -0,0 +1,92 @@
# Образы нашего Fission — актуальные теги
> Обновлено: 2026-05-21
## Текущие образы (production)
| Компонент | Docker-образ | Тег |
|---|---|---|
| **fission-bundle** (storagesvc, router, executor, buildermgr, kubewatcher, mqtrigger, timer, webhook) | `naeel/fission-bundle` | `v1.23.1` |
| **fission-console** (UI) | `naeel/fission-console` | `v1.3.96` |
| **Python environment** | `naeel/fission-python-env` | `v1.1` |
| **metrics-collector** | `naeel/metrics-collector` | `v0.1.3` |
## Storagesvc backend
**Текущий (2026-05-20): S3** — ngcloud msk-1, бакет `sless-functions`.
PV-конфиг сохранён в `helm/fission-values-pv.yaml` для быстрого возврата.
---
## Где в коде консоли прописаны образы
### Python environment
Файл: `console/internal/model/types.go`
```go
LangEnvMap["python"] = EnvConfig{Image: "naeel/fission-python-env:v1.1", ...}
```
---
## Как пересобрать fission-bundle (после правки в fission-src)
```bash
# 1. Внести правки в ~/fission-src/...
# 2. rsync на ВМ
rsync -az -e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \
~/fission-src/ naeel@5.172.178.213:~/terra/fission-src/
# 3. На ВМ — собрать бинарник
ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213 '
cd ~/terra/fission-src &&
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o _build/linux_amd64/fission-bundle ./cmd/fission-bundle/ &&
echo OK'
# 4. На ВМ — собрать и запушить образ (инкрементировать тег!)
ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213 '
cd ~/terra/fission-src/_build/linux_amd64 &&
docker build -t naeel/fission-bundle:vX.Y.Z . &&
docker push naeel/fission-bundle:vX.Y.Z'
# 5. Задеплоить только тот сервис, который менялся
ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213 '
kubectl set image -n fission deploy/storagesvc storagesvc=naeel/fission-bundle:vX.Y.Z &&
kubectl rollout status -n fission deploy/storagesvc'
```
### Сервисы и имена контейнеров (для kubectl set image)
| Deployment | Container name |
|---|---|
| `deploy/storagesvc` | `storagesvc` |
| `deploy/router` | `router` |
| `deploy/executor` | `executor` |
| `deploy/buildermgr` | `buildermgr` |
| `deploy/kubewatcher` | `kubewatcher` |
| `deploy/timer` | `timer` |
| `deploy/webhook` | `webhook` |
| `deploy/mqtrigger-keda` | `mqtrigger-keda` |
| `deploy/fission-console` | `fission-console` |
---
## Как пересобрать fission-console (после правки в fission-console)
Подробный порядок — в `CONTRIBUTING.md` консоли.
Кратко:
1. Увеличить тег в `console/deploy/console.yaml` (vX.Y.Z → vX.Y.Z+1)
2. Обновить версию в `console/ui/index.html` (2 места)
3. rsync на ВМ
4. Проверить файлы на ВМ (grep)
5. `docker build -t naeel/fission-console:vX.Y.Z+1`
6. `docker push naeel/fission-console:vX.Y.Z+1`
7. `kubectl apply -f ~/terra/fission/console/deploy/console.yaml`**не** rollout restart!
---
## ⚠️ ВАЖНО
- **НИКОГДА** не использовать официальный `ghcr.io/fission/python-env` — не поддерживает `def main(event, context)` → 500
- Наш образ `naeel/fission-python-env:v1.1` содержит патч `inspect.signature`
- imagePullPolicy: `IfNotPresent` → при смене образа **всегда** менять тег, не переиспользовать старый
+117
View File
@@ -0,0 +1,117 @@
# Fix: 401 "key is of invalid type" при invoke функций
## Статус: ГОТОВО К ПРАВКЕ (не исправлено)
## Root Cause (установлен 2026-05-18)
Secret `router` в k8s namespace `fission` был ротирован.
Router pod перезапустился — подхватил новый пароль.
Console pod НЕ перезапускался (5d8h) — имеет старый пароль в env var.
| | |
|---|---|
| `FISSION_AUTH_PASSWORD` в console pod (env) | `ToAjBTs0Sb8mLC1J9NN3` (СТАРЫЙ) |
| `password` в k8s secret `router` (текущий) | `slxGj3G3FAD7l5ms2tJ9` |
### Цепочка отказа
1. `getRouterToken()``POST /auth/login` со старым паролем → **401 Unauthorized**
2. Fallback: `return s.readSAToken()` → Kubernetes SA token, `alg: RS256`
3. Console отправляет `Authorization: Bearer <RS256-SA-token>` в router
4. Router keyfunc: `return []byte(JWT_SIGNING_KEY), nil` — возвращает `[]byte` для **любого** алгоритма
5. `jwt.Parse` получает RS256 JWT но ключ `[]byte` (ожидается `*rsa.PublicKey`) → **"key is of invalid type"**
### Подтверждено
```bash
# Старый пароль → 401:
kubectl exec -n fission fission-console-874c786c6-j6fvd -- wget -qO- \
--post-data='{"username":"admin","password":"ToAjBTs0Sb8mLC1J9NN3"}' \
--header='Content-Type: application/json' \
http://router.fission.svc.cluster.local/auth/login
# → HTTP/1.1 401 Unauthorized
# Новый пароль → OK:
# password=slxGj3G3FAD7l5ms2tJ9 → {"accesstoken":"eyJhbGciOiJIUzI1NiI..."}
```
---
## Что нужно исправить
### Файл 1: `console/internal/api/server.go` — функция `getRouterToken()`
**Проблема A:** читает пароль из env var один раз при старте → устаревает при ротации secret.
**Проблема B:** при ошибке login молча возвращает SA token вместо ошибки.
**Нужно:**
- Читать username/password из **файла** при каждом вызове `getRouterToken()`, а не из `os.Getenv` при старте.
Путь файла: `/etc/fission-router-secret/username` и `/etc/fission-router-secret/password`
- При ошибке login — возвращать `("", error)`, не `readSAToken()`.
Caller (`handleInvokeFunction`) должен вернуть 503 с понятным сообщением.
### Файл 2: `console/deploy/console.yaml`
**Нужно:** заменить `env``secretKeyRef` на volume mount.
```yaml
# Убрать из env:
- name: FISSION_AUTH_USERNAME
valueFrom:
secretKeyRef:
name: router
key: username
- name: FISSION_AUTH_PASSWORD
valueFrom:
secretKeyRef:
name: router
key: password
# Добавить volume:
volumes:
- name: router-secret
secret:
secretName: router
items:
- key: username
path: username
- key: password
path: password
# Добавить volumeMount:
volumeMounts:
- name: router-secret
mountPath: /etc/fission-router-secret
readOnly: true
```
Kubernetes обновляет смонтированные secret-файлы автоматически в течение ~60с после изменения secret.
---
## Порядок работы
1. Прочитать текущий код `getRouterToken()` в `console/internal/api/server.go`
2. Найти где инициализируются `s.authUser` / `s.authPass` (вероятно в `NewServer()` или аналоге)
3. Убрать сохранение в struct, читать из файла на каждый вызов `getRouterToken()`
4. Изменить возврат при ошибке login: `return "", fmt.Errorf("router login failed: %w", err)` вместо `readSAToken()`
5. Найти всех callers `getRouterToken()` — обработать ошибку (вернуть 503)
6. Обновить `console/deploy/console.yaml` (volume mount вместо env)
7. Увеличить тег образа (согласно правилам: vX.Y.Z → vX.Y.Z+1)
8. rsync → build → push → apply
## Текущий тег образа
Проверить: `grep "naeel/fission-console" console/deploy/console.yaml`
Последний известный: `v1.3.92`
## SSH / rsync
```bash
# rsync:
rsync -az -e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \
~/fission-console/ naeel@5.172.178.213:~/terra/fission/
# SSH:
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 'КОМАНДА'
```
+113
View File
@@ -0,0 +1,113 @@
# FIX: archivePruner удаляет архивы tenant namespaces
## Симптом
Node.js функции не специализируются → fetcher 404 при скачивании архива.
Storagesvc логи: `archive_pruner` удаляет архив через ~90 сек после создания функции.
## Причина
`pkg/storagesvc/archivePruner.go`, функция `getOrphanArchives`, строка 95:
```go
for _, namespace := range utils.DefaultNSResolver().Snapshot() {
pkgList, err := pruner.crdClient.CoreV1().Packages(namespace).List(...)
```
`DefaultNSResolver().Snapshot()` возвращает только namespaces зарегистрированные
через `AddNamespace()`. Storagesvc не слушает namespace events → tenant namespaces
(`fission-*`) туда не попадают → их Package CRD невидимы → все загруженные архивы
считаются orphan → удаляются.
## Правка
**Файл:** `pkg/storagesvc/archivePruner.go`
**Заменить** (строки 94125):
```go
// get all pkgs from kubernetes
for _, namespace := range utils.DefaultNSResolver().Snapshot() {
pkgList, err := pruner.crdClient.CoreV1().Packages(namespace).List(ctx, metav1.ListOptions{})
if err != nil {
pruner.logger.Error("error getting package list from kubernetes", zap.Error(err))
return
}
// extract archives referenced by these pkgs
for _, pkg := range pkgList.Items {
if pkg.Spec.Deployment.URL != "" {
archiveID, err = getQueryParamValue(pkg.Spec.Deployment.URL, "id")
if err != nil {
pruner.logger.Error("error extracting value of archiveID from deployment url",
zap.Error(err),
zap.String("url", pkg.Spec.Deployment.URL))
return
}
archivesRefByPkgs = append(archivesRefByPkgs, archiveID)
}
if pkg.Spec.Source.URL != "" {
archiveID, err = getQueryParamValue(pkg.Spec.Source.URL, "id")
if err != nil {
pruner.logger.Error("error extracting value of archiveID from source url",
zap.Error(err),
zap.String("url", pkg.Spec.Source.URL))
return
}
archivesRefByPkgs = append(archivesRefByPkgs, archiveID)
}
}
}
```
**На:**
```go
// get all pkgs from kubernetes across ALL namespaces (including tenant namespaces)
// Fix: DefaultNSResolver().Snapshot() returns only fission-registered namespaces,
// not tenant namespaces (fission-*). Use NamespaceAll to scan everything.
pkgList, err := pruner.crdClient.CoreV1().Packages(metav1.NamespaceAll).List(ctx, metav1.ListOptions{})
if err != nil {
pruner.logger.Error("error getting package list from kubernetes", zap.Error(err))
return
}
// extract archives referenced by these pkgs
for _, pkg := range pkgList.Items {
if pkg.Spec.Deployment.URL != "" {
archiveID, err = getQueryParamValue(pkg.Spec.Deployment.URL, "id")
if err != nil {
pruner.logger.Error("error extracting value of archiveID from deployment url",
zap.Error(err),
zap.String("url", pkg.Spec.Deployment.URL))
return
}
archivesRefByPkgs = append(archivesRefByPkgs, archiveID)
}
if pkg.Spec.Source.URL != "" {
archiveID, err = getQueryParamValue(pkg.Spec.Source.URL, "id")
if err != nil {
pruner.logger.Error("error extracting value of archiveID from source url",
zap.Error(err),
zap.String("url", pkg.Spec.Source.URL))
return
}
archivesRefByPkgs = append(archivesRefByPkgs, archiveID)
}
}
```
**Импорты** — удалить `"github.com/fission/fission/pkg/utils"` (больше не используется).
## После правки
```bash
cd ~/terra/fission-src
go build ./pkg/storagesvc/... # проверить компиляцию
# пересобрать storagesvc образ и передеплоить
docker build -t naeel/fission-storagesvc:<новый-тег> -f cmd/storagesvc/Dockerfile .
docker push naeel/fission-storagesvc:<новый-тег>
kubectl set image -n fission deploy/storagesvc storagesvc=naeel/fission-storagesvc:<новый-тег>
kubectl rollout status -n fission deploy/storagesvc
```
+56
View File
@@ -235,3 +235,59 @@
### Вывод ### Вывод
- Текущий prompt уже работает на коротких и простых кейсах. - Текущий prompt уже работает на коротких и простых кейсах.
- Следующий шаг — проверить его на более разнообразных задачах, чтобы убедиться, что шаблон не ломается за пределами приветствий. - Следующий шаг — проверить его на более разнообразных задачах, чтобы убедиться, что шаблон не ломается за пределами приветствий.
---
## Итерация 9 (2026-05-19) — RUNTIME BUG DISCOVERY
### Источник
Реальный прогон `test_heavy.sh` на кластере. Обнаружены критические баги в PHP и Node.js runtime-контракте.
### БАГ C: PHP `return array` → пустой response
**Задача LLM:** написать PHP-функцию.
**Сгенерированный код (типичный):**
```php
<?php
function handler(array $ctx): array {
return ['statusCode' => 200, 'body' => json_encode(['tag' => 'ok'])];
}
```
**Результат invoke:** `{"status":200,"response_raw":""}` — тело пустое.
**Причина:** PHP env (`ghcr.io/fission/php-env:latest`, server.php) создаёт `$response = new Response()`,
вызывает функцию и возвращает оригинальный объект. Return value функции игнорируется.
**Правильный код:**
```php
<?php
function handler($ctx) {
$ctx["response"]->getBody()->write(json_encode(['tag' => 'ok']));
}
```
**Что изменено в промпте:**
- PHP секция переписана: запрет на `return array`, требование писать через `$ctx["response"]->getBody()->write(...)`.
- Добавлен пример с JSON-ответом и PSR-7 объектами.
---
### БАГ B: Node.js entrypoint `"main.js"` — split на точке → funcname="js"
**Проблема консоли, не пользовательского кода.**
Node-env `specializeV2` (строка 160 server.js) интерпретирует functionName через `split(".")`:
- `"main.js"``{filename:"main", funcname:"js"}` → ищет `module["js"]` → undefined → specialize 500.
- `"main"``{filename:"main", funcname:undefined}` → берёт `default export` → работает.
**Fix в консоли:** `console/internal/runtime/entrypoint.go`, `DefaultEntrypoint("nodejs")` = `"main"`.
**Влияние на промпт:** никакого — это внутренняя проблема консоли. Пользовательский Node.js код пишется как обычно.
---
### Итог итерации 9
- PHP промпт обновлён: PSR-7 `getBody()->write()` вместо `return array`.
- Node.js промпт уточнён: `module.exports = async function(ctx) {...}`, CJS стиль.
- Документация: `doc/RUNTIME_FUNCTION_GUIDE.md` — полный справочник контрактов.
+25 -3
View File
@@ -54,9 +54,31 @@
- Не ломай контракт функции выдуманными обертками или неподходящими типами. - Не ломай контракт функции выдуманными обертками или неподходящими типами.
### PHP ### PHP
- Возвращай корректный PHP-код функции. - **КРИТИЧНО:** PHP env НЕ читает return value функции. `return ['statusCode'=>200,'body'=>...]` — тело ответа будет ПУСТЫМ.
- Следи за открывающим тегом и синтаксисом массива/строк. - Запись в ответ — ТОЛЬКО через `$ctx["response"]->getBody()->write(...)`:
- Не выдумывай несуществующие `Fission\\Contract` классы или SDK, если задача про обычную функцию. ```php
<?php
function handler($ctx) {
$ctx["response"]->getBody()->write(json_encode(['key' => 'value']));
}
```
- Сигнатура: `function handler($ctx)` — без type hint `array` и без `: array` return type.
- Доступ к запросу: `$ctx["request"]`, к логгеру: `$ctx["logger"]`.
- Открывающий тег `<?php` обязателен.
- Не выдумывай несуществующие `Fission\\Contract` классы или SDK.
- Альтернатива (только если нет доступа к request): простой `echo` без объявления функции handler:
```php
<?php
echo json_encode(['key' => 'value']);
```
### Node.js
- Пользовательский код оборачивается консолью в CJS wrapper. Писать как обычный CommonJS.
- Экспорт: `module.exports = async function(ctx) { return { status: 200, body: "..." }; }`
- Формат ответа: объект `{ status: число, body: строка }`.
- **НЕ** использовать `export default` — код запускается в CJS контексте.
- **НЕ** указывать `.js` в entrypoint — это внутренняя настройка консоли, не пользовательский код.
- Не вставляй ESM import/export обвязку в пользовательский код.
### Ruby ### Ruby
- Возвращай корректный Ruby-код функции. - Возвращай корректный Ruby-код функции.
+52
View File
@@ -102,6 +102,58 @@ Backend сервис для управления serverless-функциями
## Деплой ## Деплой
### Хранилище архивов функций (storagesvc)
Fission хранит ZIP-архивы функций через `storagesvc`. Поддерживается два бэкенда:
#### S3 (рекомендуется)
```bash
# 1. Создать файл с credentials (НЕ коммитить в git):
cat > helm/fission-s3-secret.yaml << 'EOF'
persistence:
s3:
accessKeyId: <ACCESS_KEY>
secretAccessKey: <SECRET_KEY>
EOF
# 2. Применить:
kubectl delete deploy executor router storagesvc -n fission # убрать field manager конфликты
helm upgrade fission fission-charts/fission-all --version 1.22.1 \
-n fission \
-f helm/fission-values.yaml \
-f helm/fission-s3-secret.yaml \
--no-hooks --timeout 180s
```
Параметры S3 (bucket, endpoint, region) — в `helm/fission-values.yaml`.
Credentials — только в `helm/fission-s3-secret.yaml` (файл в `.gitignore`).
#### PV (возврат/альтернатива)
```bash
kubectl delete deploy executor router storagesvc -n fission
helm upgrade fission fission-charts/fission-all --version 1.22.1 \
-n fission \
-f helm/fission-values-pv.yaml \
--no-hooks --timeout 180s
```
Никаких секретов не нужно. StorageClass `vcd-disk-ext4` (VMware VCD), 2Gi.
#### Сравнение
| | S3 | PV |
|---|---|---|
| Upload скорость | ~1-3s (сеть) | ~0.3-2s (кэш) |
| Cold start | ≈ одинаково | ≈ одинаково |
| Hot (основная нагрузка) | идентично | идентично |
| Масштабирование storagesvc | ✅ N реплик | ❌ ReadWriteOnce |
| Переезд кластера | ✅ просто бакет | ❌ надо мигрировать PV |
| Backup | ✅ встроен в S3 | ручной |
---
| Параметр | Значение | | Параметр | Значение |
|---|---| |---|---|
| Кластер | `kube5s.ru` | | Кластер | `kube5s.ru` |
+21 -15
View File
@@ -1,20 +1,26 @@
MQ-триггер (MessageQueue) — запуск при появлении сообщения в очереди (Kafka, NATS, RabbitMQ и т.д.) # TODO / Статус фич (актуально: 2026-05-21)
Kube-Watch триггер — запуск при изменении объектов в Kubernetes - а это разве может быть реализовано ?
В Fission — да, это штатные встроенные типы триггеров. Fission из коробки поддерживает MQ (Kafka, NATS, Azure Queue) и Kube-Watch через отдельные компоненты (kubewatcher, mqtrigger). ✅ = реализовано, ❌ = нет, ⚠️ = частично
Но в вашей консоли это не реализовано — нет UI для создания/управления этими триггерами. Только HTTP и Cron. Если потребуется — можно добавить. ### Триггеры
✅ HTTP Trigger — автосоздание при create function
✅ Time Trigger (Cron) — CRUD
✅ MQ Trigger (SQS) — sqs-consumer Deployment + Secret
✅ Kubernetes Watch Trigger — CRUD
от что реально полезно и не очень сложно: ### Функции
✅ Клонирование (v1.3.80)
✅ Переменные окружения (v1.3.71)
✅ Зависимости deps (requirements.txt/Gemfile/composer.json)
✅ AI: проверка синтаксиса (реальные линтеры)
✅ AI: объяснение архива (LLM)
✅ AI: генерация кода (LLM)
Для пользователя: ### Статистика
✅ Billing в PostgreSQL
✅ Grafana per namespace
История вызовов — последние N вызовов функции: время, статус, время ответа. Сейчас вызвал и забыл. ### Не реализовано
Метрики — счётчик вызовов, средний latency, % ошибок. У вас уже есть metrics-collector — данные есть, нужен только UI. ❌ Экспорт/импорт функций (zip)
Автообновление логов — сейчас нужно жать "Обновить" вручную. Можно автополлинг каждые 3-5 сек. Автообновление логов (polling)
Копировать маршрут одной кнопкой — иконка 📋 рядом с маршрутом в таблице. ❌ Уведомления при ошибках
Статус функции — Ready/NotReady/Error прямо в таблице (сейчас не видно).
Для управления:
6. Клонировать функцию — создать копию с новым именем, сохранив код и настройки.
7. Экспорт/импорт — скачать функцию как zip, загрузить обратно.
8. Переменные окружения — env vars для функции через UI (сейчас нельзя задать).
File diff suppressed because it is too large Load Diff
+114
View File
@@ -0,0 +1,114 @@
#!/bin/bash
# Бенчмарк: upload, deploy, cold/hot start для простой функции
# Все результаты пишутся в test-results/bench-func
set -e
RESULTS_DIR="$(dirname "$0")/../test-results/bench-func"
FUNC_DIR="$(dirname "$0")"
FUNC_NAME="bench-hello"
ENV_NAME="bench-python-env"
NS="bench-func-$(date +%s)"
# Donor namespace с готовым RBAC
DONOR_NS=$(kubectl get ns --no-headers | awk '{print $1}' | grep -E '^fission-[a-f0-9]{16}$' | head -1)
LOGFILE="$RESULTS_DIR/bench_func_$(date +%Y-%m-%d_%H-%M-%S).log"
mkdir -p "$RESULTS_DIR"
{
echo "# Bench: $(date)"
echo "Namespace: $NS"
echo "Donor NS: $DONOR_NS"
} | tee "$LOGFILE"
# 1. Create namespace + copy RBAC from donor
echo -n "[1] Create namespace + RBAC... " | tee -a "$LOGFILE"
START_NS=$(date +%s%3N)
kubectl create ns "$NS" >/dev/null
# Копируем ServiceAccount-ы
for sa in fission-builder fission-fetcher; do
kubectl get sa "$sa" -n "$DONOR_NS" -o json 2>/dev/null \
| jq 'del(.metadata.resourceVersion,.metadata.uid,.metadata.creationTimestamp,.metadata.annotations,.metadata.ownerReferences) | .metadata.namespace = $ns' --arg ns "$NS" \
| kubectl apply -f - >/dev/null 2>&1 || true
done
# Копируем RoleBindings из donor
kubectl get rolebinding -n "$DONOR_NS" -o json 2>/dev/null \
| jq -r '.items[] | select(.roleRef.kind=="ClusterRole") | del(.metadata.resourceVersion,.metadata.uid,.metadata.creationTimestamp,.metadata.annotations,.metadata.ownerReferences) | .metadata.namespace = $ns' --arg ns "$NS" \
| kubectl apply -f - >/dev/null 2>&1 || true
END_NS=$(date +%s%3N)
echo "$((END_NS-START_NS)) ms" | tee -a "$LOGFILE"
# 2. Create environment
echo -n "[2] Create environment... " | tee -a "$LOGFILE"
START_ENV=$(date +%s%3N)
fission env create --name "$ENV_NAME" --image naeel/fission-python-env:v1.1 --namespace "$NS" >/dev/null 2>&1
END_ENV=$(date +%s%3N)
echo "$((END_ENV-START_ENV)) ms" | tee -a "$LOGFILE"
# 3. Upload function
echo -n "[3] Upload function... " | tee -a "$LOGFILE"
START_UP=$(date +%s%3N)
fission fn create --name "$FUNC_NAME" --env "$ENV_NAME" --code "$FUNC_DIR/hello.py" --namespace "$NS" >/dev/null 2>&1
END_UP=$(date +%s%3N)
echo "$((END_UP-START_UP)) ms" | tee -a "$LOGFILE"
# 4. Cold start invoke (retry до 60 раз по 2 сек = 2 мин максимум)
echo -n "[4] Cold start invoke... " | tee -a "$LOGFILE"
START_COLD=$(date +%s%3N)
COLD_OK=0
for i in {1..60}; do
RESULT=$(fission fn test --name "$FUNC_NAME" --namespace "$NS" 2>&1 || true)
if echo "$RESULT" | grep -qE "hello|200|statusCode"; then
COLD_OK=1
break
fi
sleep 2
done
END_COLD=$(date +%s%3N)
if [[ $COLD_OK -eq 1 ]]; then
echo "$((END_COLD-START_COLD)) ms" | tee -a "$LOGFILE"
else
echo "FAILED" | tee -a "$LOGFILE"
{
echo "[DEBUG] pods in $NS:"
kubectl get pods -n "$NS" 2>&1
echo "[DEBUG] events in $NS:"
kubectl get events -n "$NS" --sort-by=.metadata.creationTimestamp 2>&1 | tail -20
} >> "$LOGFILE"
exit 1
fi
# 5. Hot start invoke (pod уже прогрет, retry до 5 раз)
echo -n "[5] Hot start invoke... " | tee -a "$LOGFILE"
START_HOT=$(date +%s%3N)
HOT_OK=0
for i in {1..5}; do
RESULT=$(fission fn test --name "$FUNC_NAME" --namespace "$NS" 2>&1 || true)
if echo "$RESULT" | grep -qE "hello|200|statusCode"; then
HOT_OK=1; break
fi
sleep 2
done
END_HOT=$(date +%s%3N)
if [[ $HOT_OK -eq 1 ]]; then
echo "$((END_HOT-START_HOT)) ms" | tee -a "$LOGFILE"
else
echo "FAILED (pod не ответил)" | tee -a "$LOGFILE"
fi
# 6. Hot x5
echo "[6] Hot start x5:" | tee -a "$LOGFILE"
for i in {1..5}; do
START_H=$(date +%s%3N)
RESULT=$(fission fn test --name "$FUNC_NAME" --namespace "$NS" 2>&1 || true)
END_H=$(date +%s%3N)
if echo "$RESULT" | grep -qE "hello|200|statusCode"; then
echo " invoke #$i: $((END_H-START_H)) ms" | tee -a "$LOGFILE"
else
echo " invoke #$i: FAILED" | tee -a "$LOGFILE"
fi
done
echo "---" | tee -a "$LOGFILE"
echo "DONE. Log: $LOGFILE"
+263
View File
@@ -0,0 +1,263 @@
#!/usr/bin/env bash
# diag_bugs.sh — диагностика двух аномалий из bench_full run=9248232
# BUG-1: php/medium + php/large COLD-TIMEOUT (гипотеза: arrow functions PHP 7.4+)
# BUG-2: ruby/medium cold=80451ms (гипотеза: poolmgr eviction — проверяем воспроизводимость)
set -uo pipefail
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
ROOT_DIR=$(cd "${SCRIPT_DIR}/.." && pwd)
source "${ROOT_DIR}/scripts/lib.sh"
SUB="bench@bench.local"
RUNID="diag$(date +%s | tail -c 6)"
TMP=$(mktemp -d)
TIMESTAMP=$(date +%Y-%m-%d_%H-%M-%S)
RESULTS_DIR="${ROOT_DIR}/test-results/bench-func"
LOGFILE="${RESULTS_DIR}/diag_bugs_${TIMESTAMP}.log"
mkdir -p "$RESULTS_DIR"
declare -a ALL_FNS=()
PASS_CNT=0
FAIL_CNT=0
cleanup() {
rm -rf "$TMP" 2>/dev/null || true
for fn in "${ALL_FNS[@]:-}"; do
curl -s -X DELETE "${BASE}/functions/${fn}" \
-H "X-Auth-Token: ${SUB}" >/dev/null 2>&1 || true
done
}
trap cleanup EXIT
log() { echo "$@" | tee -a "$LOGFILE"; }
escape_code() { python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))'; }
get_status() {
python3 -c "import json,sys
try: d=json.load(sys.stdin); print(d.get('status',''))
except: print('')" 2>/dev/null
}
get_field() {
local f="$1"
python3 -c "import json,sys
try: d=json.load(sys.stdin); print(d.get('$f',''))
except: print('')" 2>/dev/null
}
log "════════════════════════════════════════════════════════════════"
log " diag_bugs.sh run=${RUNID} $(date)"
log " API: ${BASE} SUB: ${SUB}"
log "════════════════════════════════════════════════════════════════"
# ═══════════════════════════════════════════════════════════════════
# Вспомогательная: загрузить функцию, сделать cold+hot invoke, вернуть результат
# bench_one LABEL LANG CODEFILE [EXPECTED_STR]
# ═══════════════════════════════════════════════════════════════════
bench_one() {
local label="$1" lang="$2" codefile="$3" expected="${4:-}"
local fn="bench-${label}-${RUNID}"
ALL_FNS+=("$fn")
# Upload
local code_esc t0 t1 R CODE BODY UPLOAD_MS
code_esc=$(cat "$codefile" | escape_code)
t0=$(date +%s%3N)
R=$(curl -s -w "\n%{http_code}" -X POST "${BASE}/functions" \
-H "X-Auth-Token: ${SUB}" -H "Content-Type: application/json" \
-d "{\"name\":\"${fn}\",\"language\":\"${lang}\",\"code\":${code_esc}}" 2>/dev/null)
t1=$(date +%s%3N)
CODE=$(echo "$R" | tail -1)
BODY=$(echo "$R" | head -n -1)
UPLOAD_MS=$((t1-t0))
if [ "$CODE" != "201" ]; then
log " [${label}] UPLOAD FAIL HTTP=${CODE}: $(echo "$BODY" | head -c 200)"
FAIL_CNT=$((FAIL_CNT+1))
return 1
fi
log " [${label}] upload OK ${UPLOAD_MS}ms"
# Cold start (retry 20×5s)
local tc0 COLD_MS=0 cold_ok=0 cold_body=""
tc0=$(date +%s%3N)
for i in $(seq 1 20); do
local resp s raw
resp=$(curl -s --max-time 35 -X POST "${BASE}/functions/${fn}/invoke" \
-H "X-Auth-Token: ${SUB}" -H "Content-Type: application/json" -d '{}' 2>/dev/null)
s=$(echo "$resp" | get_status)
raw=$(echo "$resp" | get_field "response_raw")
if [ "$s" = "200" ]; then
if [ -z "$expected" ] || echo "$raw" | grep -qF "$expected"; then
local tc1; tc1=$(date +%s%3N)
COLD_MS=$((tc1-tc0))
cold_ok=1
cold_body="$raw"
break
fi
fi
sleep 5
done
if [ $cold_ok -eq 0 ]; then
log " [${label}] COLD TIMEOUT"
FAIL_CNT=$((FAIL_CNT+1))
return 1
fi
# Hot ×3
local HOT_TOTAL=0 hot_ok=0
for i in 1 2 3; do
local th0 th1 resp s
th0=$(date +%s%3N)
resp=$(curl -s --max-time 35 -X POST "${BASE}/functions/${fn}/invoke" \
-H "X-Auth-Token: ${SUB}" -H "Content-Type: application/json" -d '{}' 2>/dev/null)
th1=$(date +%s%3N)
s=$(echo "$resp" | get_status)
[ "$s" = "200" ] && { HOT_TOTAL=$((HOT_TOTAL+th1-th0)); hot_ok=$((hot_ok+1)); }
done
local HOT_AVG=0; [ $hot_ok -gt 0 ] && HOT_AVG=$((HOT_TOTAL/hot_ok))
log " [${label}] OK cold=${COLD_MS}ms hot_avg=${HOT_AVG}ms (${hot_ok}/3)"
[ -n "$cold_body" ] && log " [${label}] body=$(echo "$cold_body" | head -c 120)"
PASS_CNT=$((PASS_CNT+1))
}
# ═══════════════════════════════════════════════════════════════════
# BUG-1: PHP arrow functions
# ═══════════════════════════════════════════════════════════════════
log ""
log "┌─────────────────────────────────────────────────────────────────┐"
log "│ BUG-1: PHP arrow functions (fn()=> требует PHP 7.4+) │"
log "└─────────────────────────────────────────────────────────────────┘"
# Тест 1а: минимальный PHP с arrow function — поддерживается?
cat > "${TMP}/php_arrow_min.php" << 'EOF'
<?php
function main($event, $context) {
$nums = [1,2,3,4,5];
$doubled = array_map(fn($x) => $x * 2, $nums);
return "arrow-ok:" . implode(",", $doubled);
}
EOF
log ""
log " [php-arrow-min] Тест: arrow function fn(\$x)=>\$x*2 (PHP 7.4+)"
bench_one "php-arrow-min" "php" "${TMP}/php_arrow_min.php" "arrow-ok"
# Тест 1б: PHP version info через phpinfo-like
cat > "${TMP}/php_version.php" << 'EOF'
<?php
function main($event, $context) {
return "php-version:" . PHP_VERSION . " php-major:" . PHP_MAJOR_VERSION . "." . PHP_MINOR_VERSION;
}
EOF
log ""
log " [php-version] Тест: получить версию PHP runtime"
bench_one "php-version" "php" "${TMP}/php_version.php" "php-version"
# Тест 1в: PHP medium без arrow functions (обычные function)
cat > "${TMP}/php_medium_noarrow.php" << 'EOF'
<?php
function fib($n){$a=0;$b=1;for($i=0;$i<$n;$i++)[$a,$b]=[$b,$a+$b];return $a;}
function sieve($n){$s=array_fill(0,$n+1,true);$s[0]=$s[1]=false;for($i=2;$i*$i<=$n;$i++)if($s[$i])for($j=$i*$i;$j<=$n;$j+=$i)$s[$j]=false;return array_keys(array_filter($s));}
function sqrtSum($n){$s=0;for($i=1;$i<=$n;$i++)$s+=sqrt($i);return $s;}
function bubbleSort($arr){$n=count($arr);for($i=0;$i<$n;$i++)for($j=0;$j<$n-$i-1;$j++)if($arr[$j]>$arr[$j+1]){$t=$arr[$j];$arr[$j]=$arr[$j+1];$arr[$j+1]=$t;}return $arr;}
function matMul($a,$b){$ra=count($a);$ca=count($a[0]);$cb=count($b[0]);$c=array_fill(0,$ra,array_fill(0,$cb,0));for($i=0;$i<$ra;$i++)for($j=0;$j<$cb;$j++)for($k=0;$k<$ca;$k++)$c[$i][$j]+=$a[$i][$k]*$b[$k][$j];return $c;}
function processWordsNoArrow($words){
$out=[];
foreach($words as $s){
if(strlen($s)>3) $out[]=strrev(strtoupper($s));
}
return $out;
}
function statsArrNoArrow($arr){
$n=count($arr);$m=array_sum($arr)/$n;
$v=0; foreach($arr as $x) $v+=($x-$m)*($x-$m); $v/=$n;
return['n'=>$n,'mean'=>round($m,4),'std'=>round(sqrt($v),4)];
}
function main($event,$context){
$t0=microtime(true);
$words=["alpha","beta","gamma","delta","epsilon","zeta","eta","theta","iota","kappa","lambda","mu"];
$f=fib(35);$primes=sieve(500);$s=sqrtSum(1000);
$nums=range(50,1);$sorted=bubbleSort($nums);
$a=[[1,2,3],[4,5,6]];$b=[[7,8],[9,10],[11,12]];$c=matMul($a,$b);
$pw=processWordsNoArrow($words);$st=statsArrNoArrow($primes);
$h=substr(hash('sha256','bench-medium-php'),0,16);
$elapsed=microtime(true)-$t0;
return json_encode(['tag'=>'bench-php-medium-noarrow-ok','fib35'=>$f,'primes'=>count($primes),
'sqrt'=>round($s,1),'sorted0'=>$sorted[0],'mat_c00'=>$c[0][0],
'words'=>count($pw),'stats'=>$st,'hash'=>$h,'elapsed'=>round($elapsed,5)]);
}
EOF
log ""
log " [php-medium-noarrow] PHP medium логика без arrow functions (foreach вместо fn()=>)"
bench_one "php-med-noarr" "php" "${TMP}/php_medium_noarrow.php" "bench-php-medium-noarrow-ok"
# ═══════════════════════════════════════════════════════════════════
# BUG-2: ruby/medium cold=80451ms — воспроизводимость
# ═══════════════════════════════════════════════════════════════════
log ""
log "┌─────────────────────────────────────────────────────────────────┐"
log "│ BUG-2: ruby/medium cold=80451ms — воспроизводимость (×3) │"
log "└─────────────────────────────────────────────────────────────────┘"
cat > "${TMP}/ruby_medium_diag.rb" << 'EOF'
require 'json'
require 'digest'
WORDS=%w[alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau upsilon phi chi psi omega]
def fib(n); a,b=0,1; n.times{a,b=b,a+b}; a; end
def sieve(n)
s=Array.new(n+1,true); s[0]=s[1]=false
(2..Math.sqrt(n).to_i).each{|i|(i*i..n).step(i){|j|s[j]=false} if s[i]}
(0..n).select{|i|s[i]}
end
def sqrt_sum(n); (1..n).sum{|i|Math.sqrt(i)}; end
def bubble_sort(arr)
arr=arr.dup; n=arr.length
n.times{(n-1).times{|j|arr[j],arr[j+1]=arr[j+1],arr[j] if arr[j]>arr[j+1]}}
arr
end
def mat_mul(a,b)
ra,ca,cb=a.length,a[0].length,b[0].length
Array.new(ra){|i|Array.new(cb){|j|(0...ca).sum{|k|a[i][k]*b[k][j]}}}
end
def process_words(w); w.select{|s|s.length>3}.map{|s|s.upcase.reverse}; end
def get_stats(arr)
n=arr.length; m=arr.sum.to_f/n; v=arr.sum{|x|(x-m)**2}/n
{n:n, mean:m.round(4), std:Math.sqrt(v).round(4)}
end
def handler(ctx)
t0=Time.now
f=fib(35); primes=sieve(500); s=sqrt_sum(1000)
nums=(1..50).to_a.reverse; sorted=bubble_sort(nums)
a=[[1,2,3],[4,5,6]]; b=[[7,8],[9,10],[11,12]]; c=mat_mul(a,b)
pw=process_words(WORDS); st=get_stats(primes)
h=Digest::SHA256.hexdigest('bench-medium-ruby')[0,16]
elapsed=Time.now-t0
{ statusCode: 200, body: JSON.generate({tag:"bench-ruby-medium-ok", fib35:f, primes:primes.length,
sqrt:s.round(1), sorted0:sorted[0], mat_c00:c[0][0],
words:pw.length, stats:st, hash:h, elapsed:elapsed.round(5)}) }
end
EOF
log ""
log " ruby/medium × 3 независимых функции (разные имена → чистый cold start каждый раз)"
for attempt in 1 2 3; do
log ""
log " --- Попытка ${attempt}/3 ---"
bench_one "ruby-med-${attempt}" "ruby" "${TMP}/ruby_medium_diag.rb" "bench-ruby-medium-ok"
done
# ═══════════════════════════════════════════════════════════════════
# ИТОГ
# ═══════════════════════════════════════════════════════════════════
log ""
log "════════════════════════════════════════════════════════════════"
log " ИТОГ: PASS=${PASS_CNT} FAIL=${FAIL_CNT} TOTAL=$((PASS_CNT+FAIL_CNT))"
log " Лог: ${LOGFILE}"
log " Готово: $(date)"
log "════════════════════════════════════════════════════════════════"
+5
View File
@@ -0,0 +1,5 @@
def main(event, context):
return {
"statusCode": 200,
"body": "hello, world!"
}
+3
View File
@@ -13,6 +13,7 @@ import (
"fission-console/internal/api" "fission-console/internal/api"
"fission-console/internal/auth" "fission-console/internal/auth"
"fission-console/internal/billing" "fission-console/internal/billing"
"fission-console/internal/stats"
"k8s.io/client-go/dynamic" "k8s.io/client-go/dynamic"
"k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes"
@@ -50,6 +51,7 @@ func main() {
} }
billingStore := billing.NewStore() billingStore := billing.NewStore()
statsProvider := stats.NewProvider()
srv := api.NewServer(api.Config{ srv := api.NewServer(api.Config{
Dyn: dyn, Dyn: dyn,
@@ -68,6 +70,7 @@ func main() {
LLMKey: os.Getenv("FISSION_LLM_KEY"), LLMKey: os.Getenv("FISSION_LLM_KEY"),
// --- end ai/ask feature --- // --- end ai/ask feature ---
Billing: billingStore, Billing: billingStore,
Stats: statsProvider,
}) })
// Запускаем фоновые горутины: reaper истёкших функций // Запускаем фоновые горутины: reaper истёкших функций
+13 -2
View File
@@ -15,9 +15,12 @@ rules:
- apiGroups: [""] - apiGroups: [""]
resources: ["pods/log"] resources: ["pods/log"]
verbs: ["get"] verbs: ["get"]
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "list", "create", "delete"]
- apiGroups: ["apps"] - apiGroups: ["apps"]
resources: ["deployments"] resources: ["deployments"]
verbs: ["get", "list", "update", "patch"] verbs: ["get", "list", "create", "update", "patch", "delete"]
- apiGroups: ["fission.io"] - apiGroups: ["fission.io"]
resources: ["environments", "packages", "functions", "httptriggers", "timetriggers"] resources: ["environments", "packages", "functions", "httptriggers", "timetriggers"]
verbs: ["get", "list", "create", "update", "patch", "delete"] verbs: ["get", "list", "create", "update", "patch", "delete"]
@@ -55,7 +58,7 @@ spec:
serviceAccountName: fission-console serviceAccountName: fission-console
containers: containers:
- name: console - name: console
image: naeel/fission-console:v1.3.86 image: naeel/fission-console:v1.3.96
imagePullPolicy: Always imagePullPolicy: Always
ports: ports:
- containerPort: 8090 - containerPort: 8090
@@ -84,6 +87,14 @@ spec:
value: "http://storagesvc.fission.svc.cluster.local" value: "http://storagesvc.fission.svc.cluster.local"
- name: BILLING_DSN - name: BILLING_DSN
value: "postgres://super:BQUF5ruECa1ZFlq4wYt3gPJUEmtBMkA9QNK4MM5Sd8al4ArMDlmT16DIKHYBPyif@postgresqlk8s-master.dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5.svc.cluster.local:5432/sqsdb" value: "postgres://super:BQUF5ruECa1ZFlq4wYt3gPJUEmtBMkA9QNK4MM5Sd8al4ArMDlmT16DIKHYBPyif@postgresqlk8s-master.dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5.svc.cluster.local:5432/sqsdb"
- name: GRAFANA_INTERNAL_URL
value: "http://grafana.grafana.svc.cluster.local:3000"
- name: GRAFANA_PUBLIC_URL
value: "https://fission.kube5s.ru/grafana"
- name: GRAFANA_ADMIN_USER
value: "admin"
- name: GRAFANA_ADMIN_PASS
value: "GrafanaAdmin2026!"
livenessProbe: livenessProbe:
httpGet: httpGet:
path: /health path: /health
+5 -3
View File
@@ -13,6 +13,7 @@ import (
"strings" "strings"
"testing" "testing"
"fission-console/internal/billing"
"fission-console/internal/fission" "fission-console/internal/fission"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -48,9 +49,10 @@ func newCloneTestServer(t *testing.T, storagesvcHandler http.HandlerFunc, objs .
} }
s := &Server{ s := &Server{
dyn: dynClient, dyn: dynClient,
ns: "fission-test", ns: "fission-test",
http: httpClient, http: httpClient,
billing: billing.NoopStore{},
} }
return s, storagesvc return s, storagesvc
} }
+23 -6
View File
@@ -35,17 +35,26 @@ import (
// buildDeployArchive упаковывает исходный код в байты для deployment Package. // buildDeployArchive упаковывает исходный код в байты для deployment Package.
// Для nodejs — ESM-обёртка (package.json + main.js). // Для nodejs — ESM-обёртка (package.json + main.js).
// Для php/ruby — zip с одним файлом скрипта. // Для php/ruby — zip с одним файлом скрипта.
// Для остальных (python) — raw bytes кода. // Для остальных (python) — raw bytes кода (или zip если есть deps).
func buildDeployArchive(lang, code string) ([]byte, error) { // deps — содержимое файла зависимостей (requirements.txt, Gemfile, composer.json).
// Если deps пустой — поведение как раньше.
func buildDeployArchive(lang, code, deps string) ([]byte, error) {
switch lang { switch lang {
case "nodejs": case "nodejs":
// TODO: поддержка package.json с deps для nodejs — пока игнорируем deps
return runtime.BuildJSDeployZip(code) return runtime.BuildJSDeployZip(code)
case "php": case "php":
if deps != "" {
return runtime.BuildScriptZipWithDeps(code, "main.php", deps, "composer.json")
}
return runtime.BuildScriptZip(code, "main.php") return runtime.BuildScriptZip(code, "main.php")
case "ruby": case "ruby":
if deps != "" {
return runtime.BuildScriptZipWithDeps(code, "handler.rb", deps, "Gemfile")
}
return runtime.BuildScriptZip(code, "handler.rb") return runtime.BuildScriptZip(code, "handler.rb")
default: default: // python
return []byte(code), nil return runtime.BuildPythonZip(code, deps)
} }
} }
@@ -173,7 +182,7 @@ func (s *Server) handleCreateFunction(w http.ResponseWriter, r *http.Request) {
"buildcommand": "build", "buildcommand": "build",
} }
} else { } else {
deployBytes, archiveErr := buildDeployArchive(req.Language, req.Code) deployBytes, archiveErr := buildDeployArchive(req.Language, req.Code, req.Deps)
if archiveErr != nil { if archiveErr != nil {
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build %s archive: %v", req.Language, archiveErr)) writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build %s archive: %v", req.Language, archiveErr))
return return
@@ -205,6 +214,9 @@ func (s *Server) handleCreateFunction(w http.ResponseWriter, r *http.Request) {
now := time.Now().UTC() now := time.Now().UTC()
fnAnnotations[functionCreatedAtAnnotation] = now.Format(time.RFC3339) fnAnnotations[functionCreatedAtAnnotation] = now.Format(time.RFC3339)
fnAnnotations[functionUpdatedAtAnnotation] = now.Format(time.RFC3339) fnAnnotations[functionUpdatedAtAnnotation] = now.Format(time.RFC3339)
if req.Deps != "" {
fnAnnotations["fission-console/deps"] = req.Deps
}
if req.TTL != "" { if req.TTL != "" {
expiresAt, ttlErr := parseTTL(req.TTL) expiresAt, ttlErr := parseTTL(req.TTL)
if ttlErr != nil { if ttlErr != nil {
@@ -329,7 +341,7 @@ func (s *Server) handleUpdateFunctionCode(w http.ResponseWriter, r *http.Request
// Определяем язык из аннотации — нужен для правильной упаковки // Определяем язык из аннотации — нужен для правильной упаковки
lang, _, _ := unstructured.NestedString(fn.Object, "metadata", "annotations", "fission-console/language") lang, _, _ := unstructured.NestedString(fn.Object, "metadata", "annotations", "fission-console/language")
deployBytes, archiveErr := buildDeployArchive(lang, req.Code) deployBytes, archiveErr := buildDeployArchive(lang, req.Code, req.Deps)
if archiveErr != nil { if archiveErr != nil {
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build %s archive: %v", lang, archiveErr)) writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build %s archive: %v", lang, archiveErr))
return return
@@ -393,6 +405,11 @@ func (s *Server) handleUpdateFunctionCode(w http.ResponseWriter, r *http.Request
} }
fnAnnotations[functionCreatedAtAnnotation] = createdAt.UTC().Format(time.RFC3339) fnAnnotations[functionCreatedAtAnnotation] = createdAt.UTC().Format(time.RFC3339)
fnAnnotations[functionUpdatedAtAnnotation] = now.Format(time.RFC3339) fnAnnotations[functionUpdatedAtAnnotation] = now.Format(time.RFC3339)
if req.Deps != "" {
fnAnnotations["fission-console/deps"] = req.Deps
} else {
delete(fnAnnotations, "fission-console/deps")
}
fn.SetAnnotations(fnAnnotations) fn.SetAnnotations(fnAnnotations)
if err := unstructured.SetNestedField(fn.Object, map[string]any{ if err := unstructured.SetNestedField(fn.Object, map[string]any{
"name": newPkgName, "name": newPkgName,
+5
View File
@@ -83,6 +83,7 @@ func (s *Server) handleGetFunction(w http.ResponseWriter, r *http.Request, name
// Читаем source-type аннотацию (code / archive) // Читаем source-type аннотацию (code / archive)
sourceType := "code" sourceType := "code"
archiveFilename := "" archiveFilename := ""
deps := ""
if ann := fn.GetAnnotations(); ann != nil { if ann := fn.GetAnnotations(); ann != nil {
if v := ann[fissionSourceTypeAnnotation]; v != "" { if v := ann[fissionSourceTypeAnnotation]; v != "" {
sourceType = v sourceType = v
@@ -90,6 +91,9 @@ func (s *Server) handleGetFunction(w http.ResponseWriter, r *http.Request, name
if v := ann["fission-console/archive-filename"]; v != "" { if v := ann["fission-console/archive-filename"]; v != "" {
archiveFilename = v archiveFilename = v
} }
if v := ann["fission-console/deps"]; v != "" {
deps = v
}
} }
writeAnyJSON(w, http.StatusOK, map[string]any{ writeAnyJSON(w, http.StatusOK, map[string]any{
@@ -102,6 +106,7 @@ func (s *Server) handleGetFunction(w http.ResponseWriter, r *http.Request, name
"created_at": functionTimestampResponse(fn)["created_at"], "created_at": functionTimestampResponse(fn)["created_at"],
"updated_at": functionTimestampResponse(fn)["updated_at"], "updated_at": functionTimestampResponse(fn)["updated_at"],
"code": code, "code": code,
"deps": deps,
"source_type": sourceType, "source_type": sourceType,
"archive_filename": archiveFilename, "archive_filename": archiveFilename,
"route": route, "route": route,
+7
View File
@@ -181,6 +181,13 @@ func (s *Server) handleAuth(w http.ResponseWriter, r *http.Request) {
fmt.Printf("handleAuth: ensureUserNS %s: %v\n", ns, ensureErr) fmt.Printf("handleAuth: ensureUserNS %s: %v\n", ns, ensureErr)
} }
// Провизируем Grafana Org для этого namespace (fire-and-forget, идемпотентно).
go func() {
if err := s.stats.EnsureOrgForNamespace(context.Background(), ns, identity.Email); err != nil {
fmt.Printf("handleAuth: EnsureOrgForNamespace %s: %v\n", ns, err)
}
}()
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "env": env, "namespace": ns, "email": identity.Email}) _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "env": env, "namespace": ns, "email": identity.Email})
} }
+241
View File
@@ -0,0 +1,241 @@
package api
// kwtriggers.go — CRUD хендлеры для KubernetesWatchTrigger (Fission KW Trigger).
//
// РЕШЕНИЕ ПО АРХИТЕКТУРЕ (2026-05-11):
// KubernetesWatchTrigger позволяет вызывать функцию при изменении K8s объектов.
// spec.type — тип ресурса: Pod, Service, Deployment, ConfigMap, и т.д.
// spec.namespace — namespace для слежения (по умолчанию = namespace пользователя)
// spec.labelselector — label selector в формате "key=value,key2=value2"
// spec.functionref — ссылка на функцию
//
// ОСОБЕННОСТИ:
// - Fission kubewatcher компонент должен быть задеплоен.
// - namespace в spec — это WATCHED namespace (не namespace триггера).
// Для безопасности ограничиваем: только namespace пользователя или пустое (тогда = userNS).
// - labelselector опционален, "" = смотрим на все ресурсы типа resourceType в namespace.
//
// ОШИБКИ В ПРОЦЕССЕ:
// - (нет, первая реализация)
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"fission-console/internal/fission"
"fission-console/internal/model"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
// validKWResourceTypes — поддерживаемые типы ресурсов для слежения.
// Расширяемо — это не ограничение CRD, просто UI-валидация.
var validKWResourceTypes = map[string]struct{}{
"pod": {},
"service": {},
"deployment": {},
"configmap": {},
"secret": {},
"namespace": {},
"replicaset": {},
"statefulset": {},
"daemonset": {},
"job": {},
}
func (s *Server) handleKWTriggersRoot(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
s.handleList(fission.KWTrigGVR)(w, r)
case http.MethodPost:
s.handleCreateKWTrigger(w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleKWTriggersAction(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/console/api/kwtriggers/")
path = strings.TrimPrefix(path, "/api/kwtriggers/")
name := strings.Trim(path, "/")
if name == "" || strings.Contains(name, "/") {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodGet:
s.handleGetKWTrigger(w, r, name)
case http.MethodDelete:
s.handleDeleteKWTrigger(w, r, name)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleCreateKWTrigger(w http.ResponseWriter, r *http.Request) {
var req model.CreateKWTriggerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("decode request: %v", err))
return
}
ns := s.userNS(r)
// Если namespace не задан — используем namespace пользователя
if strings.TrimSpace(req.Namespace) == "" {
req.Namespace = ns
}
// Безопасность: нельзя смотреть за чужим namespace
if req.Namespace != ns {
writeJSONError(w, http.StatusForbidden, "can only watch your own namespace")
return
}
if err := validateKWTriggerRequest(req); err != nil {
writeJSONError(w, http.StatusBadRequest, err.Error())
return
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
// Проверяем что функция существует
if _, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, req.FunctionName, metav1.GetOptions{}); err != nil {
if apierrors.IsNotFound(err) {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("function %q not found", req.FunctionName))
return
}
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("get function: %v", err))
return
}
obj := buildKWTriggerObject(ns, req)
created, err := s.dyn.Resource(fission.KWTrigGVR).Namespace(ns).Create(ctx, obj, metav1.CreateOptions{})
if err != nil {
if apierrors.IsAlreadyExists(err) {
writeJSONError(w, http.StatusConflict, fmt.Sprintf("kwtrigger %q already exists", req.Name))
return
}
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create kwtrigger: %v", err))
return
}
writeAnyJSON(w, http.StatusCreated, kwTriggerResponse(created))
}
func (s *Server) handleGetKWTrigger(w http.ResponseWriter, r *http.Request, name string) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
obj, err := s.dyn.Resource(fission.KWTrigGVR).Namespace(s.userNS(r)).Get(ctx, name, metav1.GetOptions{})
if err != nil {
status := http.StatusBadGateway
if apierrors.IsNotFound(err) {
status = http.StatusNotFound
}
writeJSONError(w, status, fmt.Sprintf("get kwtrigger %q: %v", name, err))
return
}
writeAnyJSON(w, http.StatusOK, kwTriggerResponse(obj))
}
func (s *Server) handleDeleteKWTrigger(w http.ResponseWriter, r *http.Request, name string) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
if err := s.dyn.Resource(fission.KWTrigGVR).Namespace(s.userNS(r)).Delete(ctx, name, metav1.DeleteOptions{}); err != nil {
status := http.StatusBadGateway
if apierrors.IsNotFound(err) {
status = http.StatusNotFound
}
writeJSONError(w, status, fmt.Sprintf("delete kwtrigger %q: %v", name, err))
return
}
writeAnyJSON(w, http.StatusOK, map[string]any{"deleted": true, "name": name})
}
// --- Вспомогательные функции ---
func validateKWTriggerRequest(req model.CreateKWTriggerRequest) error {
if strings.TrimSpace(req.Name) == "" {
return fmt.Errorf("name is required")
}
if strings.TrimSpace(req.FunctionName) == "" {
return fmt.Errorf("functionName is required")
}
rt := strings.ToLower(strings.TrimSpace(req.ResourceType))
if _, ok := validKWResourceTypes[rt]; !ok {
return fmt.Errorf("resourceType must be one of: Pod, Service, Deployment, ConfigMap, Secret, Namespace, ReplicaSet, StatefulSet, DaemonSet, Job")
}
return nil
}
func buildKWTriggerObject(ns string, req model.CreateKWTriggerRequest) *unstructured.Unstructured {
// Fission ожидает capitalize: Pod, Service, Deployment
resourceType := capitalize(strings.TrimSpace(req.ResourceType))
spec := map[string]any{
"type": resourceType,
"namespace": req.Namespace,
"functionref": map[string]any{
"type": "name",
"name": req.FunctionName,
},
}
if req.LabelSelector != "" {
spec["labelselector"] = req.LabelSelector
}
return &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "KubernetesWatchTrigger",
"metadata": map[string]any{
"name": req.Name,
"namespace": ns,
},
"spec": spec,
}}
}
func kwTriggerResponse(obj *unstructured.Unstructured) map[string]any {
spec, _ := obj.Object["spec"].(map[string]any)
if spec == nil {
spec = map[string]any{}
}
fnref, _ := spec["functionref"].(map[string]any)
fnName := ""
if fnref != nil {
fnName, _ = fnref["name"].(string)
}
return map[string]any{
"metadata": map[string]any{
"name": obj.GetName(),
"namespace": obj.GetNamespace(),
},
"spec": map[string]any{
"resourceType": spec["type"],
"namespace": spec["namespace"],
"labelSelector": spec["labelselector"],
"functionName": fnName,
},
}
}
// capitalize приводит первый символ к верхнему регистру, остальное без изменений.
// "pod" → "Pod", "deployment" → "Deployment"
func capitalize(s string) string {
if s == "" {
return s
}
return strings.ToUpper(s[:1]) + strings.ToLower(s[1:])
}
+286
View File
@@ -0,0 +1,286 @@
package api
// mqtriggers.go — CRUD для MQ-триггеров через K8s Deployment + Secret.
//
// АРХИТЕКТУРА (2026-05-11):
// Вместо Fission MessageQueueTrigger CRD (требует mqtrigger компонент Kafka/NATS)
// Console деплоит собственный sqs-consumer Deployment в namespace пользователя.
//
// При CREATE:
// 1. Создаём K8s Secret (sqs-mq-<name>) с SQS credentials
// 2. Создаём K8s Deployment (mq-<name>) с образом naeel/sqs-consumer:v1.0
// FUNCTION_URL = http://router.fission.svc.cluster.local/<functionName>
// Лейблы: app.kubernetes.io/managed-by=fission-console, component=mq-trigger
//
// При LIST: deployments -n <ns> -l component=mq-trigger
// При DELETE: удаляем Deployment + Secret
//
// ИЗМЕНЕНИЯ:
// v1 — использовал Fission MQ CRD (компонент отсутствует в кластере)
// v2 — K8s Deployment + наш sqs-consumer образ
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"fission-console/internal/model"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
const (
sqsConsumerImage = "naeel/sqs-consumer:v1.0"
sqsDefaultEndpoint = "http://shared-sqs.shared-sqs.svc.cluster.local:4100"
fissionRouterBase = "http://router.fission.svc.cluster.local"
mqTriggerLabelKey = "component"
mqTriggerLabelVal = "mq-trigger"
mqManagedByLabel = "app.kubernetes.io/managed-by"
mqManagedByVal = "fission-console"
)
func mqSecretName(name string) string { return "sqs-mq-" + name }
func mqDeployName(name string) string { return "mq-" + name }
// ── HTTP хендлеры ────────────────────────────────────────────────────
func (s *Server) handleMQTriggersRoot(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
s.handleListMQTriggers(w, r)
case http.MethodPost:
s.handleCreateMQTrigger(w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleMQTriggersAction(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/console/api/mqtriggers/")
name := strings.Trim(path, "/")
if name == "" || strings.Contains(name, "/") {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodDelete:
s.handleDeleteMQTrigger(w, r, name)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
// ── LIST ────────────────────────────────────────────────────────────
func (s *Server) handleListMQTriggers(w http.ResponseWriter, r *http.Request) {
ns := s.userNS(r)
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
labelSel := fmt.Sprintf("%s=%s,%s=%s", mqManagedByLabel, mqManagedByVal, mqTriggerLabelKey, mqTriggerLabelVal)
deployList, err := s.kube.AppsV1().Deployments(ns).List(ctx, metav1.ListOptions{LabelSelector: labelSel})
if err != nil {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("list mq deployments: %v", err))
return
}
items := make([]map[string]any, 0, len(deployList.Items))
for i := range deployList.Items {
items = append(items, mqDeployToResponse(&deployList.Items[i]))
}
writeAnyJSON(w, http.StatusOK, map[string]any{"items": items})
}
// ── CREATE ──────────────────────────────────────────────────────────
func (s *Server) handleCreateMQTrigger(w http.ResponseWriter, r *http.Request) {
var req model.CreateMQTriggerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, http.StatusBadRequest, "decode request: "+err.Error())
return
}
if err := validateMQRequest(req); err != nil {
writeJSONError(w, http.StatusBadRequest, err.Error())
return
}
ns := s.userNS(r)
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
endpoint := strings.TrimSpace(req.SqsEndpoint)
if endpoint == "" {
endpoint = sqsDefaultEndpoint
}
functionURL := fissionRouterBase + "/" + req.FunctionName
secretName := mqSecretName(req.Name)
deployName := mqDeployName(req.Name)
// 1. Secret с SQS credentials
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: secretName,
Namespace: ns,
Labels: map[string]string{
mqManagedByLabel: mqManagedByVal,
mqTriggerLabelKey: mqTriggerLabelVal,
"mq-trigger-name": req.Name,
},
},
StringData: map[string]string{
"SQS_ACCESS_KEY": req.AccessKey,
"SQS_SECRET_KEY": req.SecretKey,
"SQS_ENDPOINT": endpoint,
},
}
if _, err := s.kube.CoreV1().Secrets(ns).Create(ctx, secret, metav1.CreateOptions{}); err != nil {
if apierrors.IsAlreadyExists(err) {
writeJSONError(w, http.StatusConflict, fmt.Sprintf("mq trigger %q already exists", req.Name))
return
}
writeJSONError(w, http.StatusBadGateway, "create secret: "+err.Error())
return
}
// 2. Deployment (sqs-consumer)
replicas := int32(1)
deploy := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: deployName,
Namespace: ns,
Labels: map[string]string{
mqManagedByLabel: mqManagedByVal,
mqTriggerLabelKey: mqTriggerLabelVal,
"mq-trigger-name": req.Name,
},
Annotations: map[string]string{
"fission-console/mq-trigger-name": req.Name,
"fission-console/function": req.FunctionName,
"fission-console/queue": req.Queue,
"fission-console/sqs-endpoint": endpoint,
},
},
Spec: appsv1.DeploymentSpec{
Replicas: &replicas,
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{"mq-trigger-name": req.Name},
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{
"mq-trigger-name": req.Name,
mqTriggerLabelKey: mqTriggerLabelVal,
},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{{
Name: "sqs-consumer",
Image: sqsConsumerImage,
ImagePullPolicy: corev1.PullAlways,
Env: []corev1.EnvVar{
{Name: "SQS_QUEUE_NAME", Value: req.Queue},
{Name: "SQS_REGION", Value: "us-east-1"},
{Name: "FUNCTION_URL", Value: functionURL},
{Name: "POLL_INTERVAL", Value: "5"},
{Name: "MAX_MESSAGES", Value: "1"},
{Name: "MAX_RETRIES", Value: "3"},
},
EnvFrom: []corev1.EnvFromSource{{
SecretRef: &corev1.SecretEnvSource{
LocalObjectReference: corev1.LocalObjectReference{Name: secretName},
},
}},
Resources: corev1.ResourceRequirements{
Limits: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("50m"),
corev1.ResourceMemory: resource.MustParse("32Mi"),
},
Requests: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("10m"),
corev1.ResourceMemory: resource.MustParse("16Mi"),
},
},
}},
},
},
},
}
created, err := s.kube.AppsV1().Deployments(ns).Create(ctx, deploy, metav1.CreateOptions{})
if err != nil {
_ = s.kube.CoreV1().Secrets(ns).Delete(ctx, secretName, metav1.DeleteOptions{})
writeJSONError(w, http.StatusBadGateway, "create deployment: "+err.Error())
return
}
writeAnyJSON(w, http.StatusCreated, mqDeployToResponse(created))
}
// ── DELETE ──────────────────────────────────────────────────────────
func (s *Server) handleDeleteMQTrigger(w http.ResponseWriter, r *http.Request, name string) {
ns := s.userNS(r)
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
dErr := s.kube.AppsV1().Deployments(ns).Delete(ctx, mqDeployName(name), metav1.DeleteOptions{})
sErr := s.kube.CoreV1().Secrets(ns).Delete(ctx, mqSecretName(name), metav1.DeleteOptions{})
if dErr != nil && !apierrors.IsNotFound(dErr) {
writeJSONError(w, http.StatusBadGateway, "delete deployment: "+dErr.Error())
return
}
if sErr != nil && !apierrors.IsNotFound(sErr) {
writeJSONError(w, http.StatusBadGateway, "delete secret: "+sErr.Error())
return
}
writeAnyJSON(w, http.StatusOK, map[string]any{"deleted": true, "name": name})
}
// ── Вспомогательные ─────────────────────────────────────────────────
func validateMQRequest(req model.CreateMQTriggerRequest) error {
if strings.TrimSpace(req.Name) == "" {
return fmt.Errorf("name is required")
}
if strings.TrimSpace(req.FunctionName) == "" {
return fmt.Errorf("functionName is required")
}
if strings.TrimSpace(req.Queue) == "" {
return fmt.Errorf("queue is required")
}
if strings.TrimSpace(req.AccessKey) == "" {
return fmt.Errorf("accessKey is required")
}
if strings.TrimSpace(req.SecretKey) == "" {
return fmt.Errorf("secretKey is required")
}
return nil
}
func mqDeployToResponse(d *appsv1.Deployment) map[string]any {
ann := d.Annotations
if ann == nil {
ann = map[string]string{}
}
triggerName := ann["fission-console/mq-trigger-name"]
if triggerName == "" {
triggerName = strings.TrimPrefix(d.Name, "mq-")
}
return map[string]any{
"name": triggerName,
"deployName": d.Name,
"functionName": ann["fission-console/function"],
"queue": ann["fission-console/queue"],
"sqsEndpoint": ann["fission-console/sqs-endpoint"],
"ready": d.Status.ReadyReplicas > 0,
"replicas": d.Status.ReadyReplicas,
}
}
+1 -1
View File
@@ -113,7 +113,7 @@ func decodeArchiveBytesToSource(decoded []byte) (string, error) {
return "", io.ErrUnexpectedEOF return "", io.ErrUnexpectedEOF
} }
var nodeJSWrapperPattern = regexp.MustCompile(`(?s)^const __mod = \{ exports: \{\} \};\s*\(new Function\('module', 'exports', (.+?)\)\)\(__mod, __mod\.exports\);\s*const _fn = __mod\.exports;`) var nodeJSWrapperPattern = regexp.MustCompile(`(?s)^const __mod = \{ exports: \{\} \};\s*\(new Function\('module', 'exports', (?:'require', )?(.+?)\)\)\(__mod, __mod\.exports(?:, require)?\);\s*const _fn = __mod\.exports;`)
func decodeNodeJSWrapperSource(decoded []byte) (string, bool) { func decodeNodeJSWrapperSource(decoded []byte) (string, bool) {
text := string(decoded) text := string(decoded)
+12 -1
View File
@@ -16,6 +16,7 @@ import (
"fission-console/internal/billing" "fission-console/internal/billing"
"fission-console/internal/cloud" "fission-console/internal/cloud"
"fission-console/internal/fission" "fission-console/internal/fission"
"fission-console/internal/stats"
"fission-console/ui" "fission-console/ui"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -63,6 +64,9 @@ type Server struct {
// billing — слой записи статистики вызовов. NoopStore если BILLING_DSN не задан. // billing — слой записи статистики вызовов. NoopStore если BILLING_DSN не задан.
billing billing.Store billing billing.Store
// stats — аналитический слой (Grafana Organizations). NoopProvider если не настроен.
stats stats.StatsProvider
} }
// Config содержит все параметры для создания Server. // Config содержит все параметры для создания Server.
@@ -80,7 +84,8 @@ type Config struct {
Authenticator auth.Authenticator // слой аутентификации Authenticator auth.Authenticator // слой аутентификации
LLMUrl string LLMUrl string
LLMKey string LLMKey string
Billing billing.Store // слой статистики (NoopStore если не задан) Billing billing.Store // слой статистики (NoopStore если не задан)
Stats stats.StatsProvider // аналитика (NoopProvider если не настроен)
} }
// NewServer создаёт и настраивает HTTP Server со всеми зависимостями. // NewServer создаёт и настраивает HTTP Server со всеми зависимостями.
@@ -102,6 +107,7 @@ func NewServer(cfg Config) *Server {
llmKey: cfg.LLMKey, llmKey: cfg.LLMKey,
nsManager: cloud.NewNSManager(cfg.Dyn), nsManager: cloud.NewNSManager(cfg.Dyn),
billing: cfg.Billing, billing: cfg.Billing,
stats: cfg.Stats,
} }
} }
@@ -163,8 +169,13 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/console/api/httptriggers", auth(s.handleList(fission.HTTPTrigGVR))) mux.HandleFunc("/console/api/httptriggers", auth(s.handleList(fission.HTTPTrigGVR)))
mux.HandleFunc("/console/api/timetriggers", auth(s.handleTimeTriggersRoot)) mux.HandleFunc("/console/api/timetriggers", auth(s.handleTimeTriggersRoot))
mux.HandleFunc("/console/api/timetriggers/", auth(s.handleTimeTriggersAction)) mux.HandleFunc("/console/api/timetriggers/", auth(s.handleTimeTriggersAction))
mux.HandleFunc("/console/api/mqtriggers", auth(s.handleMQTriggersRoot))
mux.HandleFunc("/console/api/mqtriggers/", auth(s.handleMQTriggersAction))
mux.HandleFunc("/console/api/kwtriggers", auth(s.handleKWTriggersRoot))
mux.HandleFunc("/console/api/kwtriggers/", auth(s.handleKWTriggersAction))
mux.HandleFunc("/console/api/ns/status", auth(s.handleNSStatus)) mux.HandleFunc("/console/api/ns/status", auth(s.handleNSStatus))
mux.HandleFunc("/console/api/ns/debug", auth(s.handleNSDebug)) mux.HandleFunc("/console/api/ns/debug", auth(s.handleNSDebug))
mux.HandleFunc("/console/api/stats/dashboard-url", auth(s.handleStatsDashboard))
mux.HandleFunc("/console/api/ai/check", auth(s.handleAICheck)) mux.HandleFunc("/console/api/ai/check", auth(s.handleAICheck))
mux.HandleFunc("/console/api/ai/lint-archive", auth(s.handleLintArchive)) mux.HandleFunc("/console/api/ai/lint-archive", auth(s.handleLintArchive))
mux.HandleFunc("/console/api/ai/explain-archive", auth(s.handleExplainArchive)) mux.HandleFunc("/console/api/ai/explain-archive", auth(s.handleExplainArchive))
+25
View File
@@ -0,0 +1,25 @@
package api
import (
"encoding/json"
"net/http"
)
// handleStatsDashboard GET /console/api/stats/dashboard-url
// Возвращает публичный URL дашборда Grafana для текущего namespace пользователя.
// Если аналитика не настроена — возвращает {"url":""}.
func (s *Server) handleStatsDashboard(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
ns := s.userNS(r)
url := s.stats.DashboardURL(r.Context(), ns)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(map[string]any{
"url": url,
"namespace": ns,
})
}
+17 -2
View File
@@ -393,7 +393,17 @@ func (m *NSManager) reapExpiredFunctionsInNS(ctx context.Context, ns string, now
} }
} }
// Orphan packages — пакеты без соответствующей функции // Orphan packages — пакеты без соответствующей функции.
// Перечитываем Functions заново (fresh LIST) чтобы избежать race condition:
// новая функция может быть создана между снятием activeFunctions и этой проверкой.
freshFunctions, freshErr := m.dyn.Resource(fission.FunctionGVR).Namespace(ns).List(ctx, metav1.ListOptions{})
if freshErr != nil {
return
}
freshFunctionSet := make(map[string]struct{}, len(freshFunctions.Items))
for _, fn := range freshFunctions.Items {
freshFunctionSet[fn.GetName()] = struct{}{}
}
packages, pkgListErr := m.dyn.Resource(fission.PackageGVR).Namespace(ns).List(ctx, metav1.ListOptions{}) packages, pkgListErr := m.dyn.Resource(fission.PackageGVR).Namespace(ns).List(ctx, metav1.ListOptions{})
if pkgListErr == nil { if pkgListErr == nil {
for _, pkg := range packages.Items { for _, pkg := range packages.Items {
@@ -401,8 +411,13 @@ func (m *NSManager) reapExpiredFunctionsInNS(ctx context.Context, ns string, now
if !strings.HasSuffix(pkgName, "-pkg") { if !strings.HasSuffix(pkgName, "-pkg") {
continue continue
} }
// Пропускаем свежесозданные пакеты: Function CRD может ещё не успеть
// создаться после загрузки Package (race condition при быстром ExpiryReaper).
if age := time.Since(pkg.GetCreationTimestamp().Time); age < 2*time.Minute {
continue
}
fnName := strings.TrimSuffix(pkgName, "-pkg") fnName := strings.TrimSuffix(pkgName, "-pkg")
if _, exists := activeFunctions[fnName]; !exists { if _, exists := freshFunctionSet[fnName]; !exists {
log.Printf("cloud.ExpiryReaper: deleting orphan package %s/%s", ns, pkgName) log.Printf("cloud.ExpiryReaper: deleting orphan package %s/%s", ns, pkgName)
_ = m.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, pkgName, metav1.DeleteOptions{}) _ = m.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, pkgName, metav1.DeleteOptions{})
} }
+2
View File
@@ -12,6 +12,8 @@ var (
FunctionGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "functions"} FunctionGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "functions"}
HTTPTrigGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "httptriggers"} HTTPTrigGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "httptriggers"}
TimeTrigGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "timetriggers"} TimeTrigGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "timetriggers"}
MQTrigGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "messagequeuetriggers"}
KWTrigGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "kuberneteswatchtriggers"}
NamespaceGVR = schema.GroupVersionResource{Group: "", Version: "v1", Resource: "namespaces"} NamespaceGVR = schema.GroupVersionResource{Group: "", Version: "v1", Resource: "namespaces"}
DeploymentGVR = schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"} DeploymentGVR = schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"}
+13 -2
View File
@@ -28,9 +28,20 @@ func EnsureEnvironment(ctx context.Context, dyn dynamic.Interface, ns, lang stri
envName := "console-" + lang + "-env" envName := "console-" + lang + "-env"
// Проверяем существование — Get быстрее чем Create+IsAlreadyExists // Проверяем существование — Get быстрее чем Create+IsAlreadyExists
_, getErr := dyn.Resource(EnvironmentGVR).Namespace(ns).Get(ctx, envName, metav1.GetOptions{}) existing, getErr := dyn.Resource(EnvironmentGVR).Namespace(ns).Get(ctx, envName, metav1.GetOptions{})
if getErr == nil { if getErr == nil {
return envName, nil // уже существует — быстрый путь // Уже существует — проверяем образ. Если устарел — обновляем.
currentImage, _, _ := unstructured.NestedString(existing.Object, "spec", "runtime", "image")
if currentImage != langDef.Image {
if patchErr := unstructured.SetNestedField(existing.Object, langDef.Image, "spec", "runtime", "image"); patchErr == nil {
if _, updateErr := dyn.Resource(EnvironmentGVR).Namespace(ns).Update(ctx, existing, metav1.UpdateOptions{}); updateErr != nil {
log.Printf("ensureEnvironment: update image %s/%s: %v", ns, envName, updateErr)
} else {
log.Printf("ensureEnvironment: updated image %s/%s: %s → %s", ns, envName, currentImage, langDef.Image)
}
}
}
return envName, nil
} }
if !apierrors.IsNotFound(getErr) { if !apierrors.IsNotFound(getErr) {
return "", fmt.Errorf("check environment %q: %w", envName, getErr) return "", fmt.Errorf("check environment %q: %w", envName, getErr)
+27 -1
View File
@@ -5,11 +5,14 @@ package model
// CreateFunctionRequest — тело POST /console/api/functions. // CreateFunctionRequest — тело POST /console/api/functions.
// TTL пустой → функция живёт вечно; "1d", "24h" — протухнет через указанное время. // TTL пустой → функция живёт вечно; "1d", "24h" — протухнет через указанное время.
// Deps — содержимое файла зависимостей: requirements.txt (python), package.json deps (nodejs),
// Gemfile (ruby), composer.json (php). Если задан — код упаковывается в zip вместе с deps-файлом.
type CreateFunctionRequest struct { type CreateFunctionRequest struct {
Name string `json:"name"` Name string `json:"name"`
Language string `json:"language"` Language string `json:"language"`
Environment string `json:"environment"` Environment string `json:"environment"`
Code string `json:"code"` Code string `json:"code"`
Deps string `json:"deps"` // содержимое файла зависимостей (опционально)
Entrypoint string `json:"entrypoint"` Entrypoint string `json:"entrypoint"`
Route string `json:"route"` Route string `json:"route"`
Methods []string `json:"methods"` Methods []string `json:"methods"`
@@ -26,9 +29,32 @@ type CreateTimeTriggerRequest struct {
SubPath string `json:"subpath"` SubPath string `json:"subpath"`
} }
// CreateMQTriggerRequest — тело POST /console/api/mqtriggers.
// Архитектура: Console создаёт K8s Deployment (sqs-consumer) + Secret с credentials
// в namespace пользователя. sqs-consumer поллит shared-sqs → вызывает Fission-функцию.
type CreateMQTriggerRequest struct {
Name string `json:"name"`
FunctionName string `json:"functionName"`
Queue string `json:"queue"` // имя очереди в SQS
SqsEndpoint string `json:"sqsEndpoint"` // URL SQS сервиса (default: internal shared-sqs)
AccessKey string `json:"accessKey"` // SQS access key тенанта
SecretKey string `json:"secretKey"` // SQS secret key тенанта
}
// CreateKWTriggerRequest — тело POST /console/api/kwtriggers.
// Документация полей: kubectl get crd kuberneteswatchtriggers.fission.io -o json
type CreateKWTriggerRequest struct {
Name string `json:"name"`
FunctionName string `json:"functionName"`
ResourceType string `json:"resourceType"` // Pod, Service, Deployment и т.д.
Namespace string `json:"namespace"` // пустое = namespace пользователя
LabelSelector string `json:"labelSelector"` // "app=foo" или "" для всех
}
// UpdateCodeRequest — тело PUT /console/api/functions/:name/code. // UpdateCodeRequest — тело PUT /console/api/functions/:name/code.
type UpdateCodeRequest struct { type UpdateCodeRequest struct {
Code string `json:"code"` Code string `json:"code"`
Deps string `json:"deps"` // содержимое файла зависимостей (опционально)
Timeout int64 `json:"timeout"` Timeout int64 `json:"timeout"`
} }
@@ -44,7 +70,7 @@ type LangEnvDef struct {
// LangEnvMap сопоставляет идентификатор языка (string) с описанием среды выполнения. // LangEnvMap сопоставляет идентификатор языка (string) с описанием среды выполнения.
// Ключ используется в createFunctionRequest.Language и как суффикс имени Environment. // Ключ используется в createFunctionRequest.Language и как суффикс имени Environment.
var LangEnvMap = map[string]LangEnvDef{ var LangEnvMap = map[string]LangEnvDef{
"python": {Image: "naeel/fission-python-env:v1.0"}, "python": {Image: "naeel/fission-python-env:v1.1"},
"nodejs": {Image: "ghcr.io/fission/node-env"}, "nodejs": {Image: "ghcr.io/fission/node-env"},
"go": {Image: "ghcr.io/fission/go-env", BuilderImage: "naeel/go-builder-fast:v1"}, "go": {Image: "ghcr.io/fission/go-env", BuilderImage: "naeel/go-builder-fast:v1"},
"php": {Image: "ghcr.io/fission/php-env"}, "php": {Image: "ghcr.io/fission/php-env"},
+3
View File
@@ -11,6 +11,9 @@ package runtime
func DefaultEntrypoint(lang string) string { func DefaultEntrypoint(lang string) string {
switch lang { switch lang {
case "nodejs": case "nodejs":
// "main" (без .js): node-env.specializeV2 делает split(".") для functionName
// с точкой → funcname="js" → ищет экспорт "js" → 500.
// Без расширения: funcname=undefined → берёт default export (module.exports = __invoke).
return "main" return "main"
case "php": case "php":
// Fission php-env: filename::functionName // Fission php-env: filename::functionName
+25
View File
@@ -25,3 +25,28 @@ func buildZip(fileName string, content []byte) ([]byte, error) {
} }
return buf.Bytes(), nil return buf.Bytes(), nil
} }
// buildZipTwo создаёт zip-архив с двумя файлами.
// Используется когда пользователь указал файл зависимостей (requirements.txt и т.д.).
func buildZipTwo(file1, file2 string, content1, content2 []byte) ([]byte, error) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for _, f := range []struct {
name string
content []byte
}{{file1, content1}, {file2, content2}} {
fw, err := zw.Create(f.name)
if err != nil {
return nil, err
}
if _, err := fw.Write(f.content); err != nil {
return nil, err
}
}
if err := zw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
+5 -3
View File
@@ -23,10 +23,12 @@ func BuildJSDeployZip(code string) ([]byte, error) {
// main.js — CommonJS wrapper: // main.js — CommonJS wrapper:
// 1. new Function создаёт функцию в пустом модульном контексте (нет import/export) // 1. new Function создаёт функцию в пустом модульном контексте (нет import/export)
// 2. Передаём ей module и exports как параметры → пользовательский CJS код работает // 2. Передаём ей module, exports и require как параметры → пользовательский CJS код работает
// 3. Экспортируем main/handler/default через module.exports // 3. require передаётся явно, т.к. new Function выполняется в глобальном scope
// и не имеет доступа к module-local require из CJS контекста main.js
// 4. Экспортируем main/handler/default через module.exports
wrapper := fmt.Sprintf(`const __mod = { exports: {} }; wrapper := fmt.Sprintf(`const __mod = { exports: {} };
(new Function('module', 'exports', %s))(__mod, __mod.exports); (new Function('module', 'exports', 'require', %s))(__mod, __mod.exports, require);
const _fn = __mod.exports; const _fn = __mod.exports;
async function __invoke(ctx) { async function __invoke(ctx) {
+10 -8
View File
@@ -33,16 +33,18 @@ func TestBuildJSDeployZipExportsMainAndHandler(t *testing.T) {
files[file.Name] = string(content) files[file.Name] = string(content)
} }
if files["package.json"] != `{"type":"module"}` {
t.Fatalf("package.json = %q, want ESM marker", files["package.json"])
}
mainJS := files["main.js"] mainJS := files["main.js"]
if !strings.Contains(mainJS, `export { __invoke as main, __invoke as handler };`) { if mainJS == "" {
t.Fatalf("main.js does not export both main and handler: %s", mainJS) t.Fatalf("main.js not found in zip, files: %v", files)
} }
if !strings.Contains(mainJS, `export default __invoke;`) { if !strings.Contains(mainJS, `new Function('module', 'exports', 'require',`) {
t.Fatalf("main.js does not export default invoke: %s", mainJS) t.Fatalf("main.js does not pass require to new Function: %s", mainJS)
}
if !strings.Contains(mainJS, `__mod.exports, require)`) {
t.Fatalf("main.js does not forward require: %s", mainJS)
}
if !strings.Contains(mainJS, `module.exports = __invoke`) {
t.Fatalf("main.js does not export __invoke via module.exports: %s", mainJS)
} }
if !strings.Contains(mainJS, `_fn.default || _fn.handler || _fn.main`) { if !strings.Contains(mainJS, `_fn.default || _fn.handler || _fn.main`) {
t.Fatalf("main.js lost user export resolution: %s", mainJS) t.Fatalf("main.js lost user export resolution: %s", mainJS)
+17
View File
@@ -11,3 +11,20 @@ package runtime
func BuildScriptZip(code, fileName string) ([]byte, error) { func BuildScriptZip(code, fileName string) ([]byte, error) {
return buildZip(fileName, []byte(code)) return buildZip(fileName, []byte(code))
} }
// BuildScriptZipWithDeps создаёт zip с кодом и файлом зависимостей.
// fileName — имя файла кода (handler.rb, main.php и т.д.)
// depsName — имя файла зависимостей (Gemfile, composer.json и т.д.)
func BuildScriptZipWithDeps(code, fileName, deps, depsName string) ([]byte, error) {
return buildZipTwo(fileName, depsName, []byte(code), []byte(deps))
}
// BuildPythonZip создаёт zip с main.py (и опционально requirements.txt).
// Если deps пустой — возвращает raw bytes кода (текущее поведение Python).
// Если deps задан — zip с main.py + requirements.txt для pip install.
func BuildPythonZip(code, deps string) ([]byte, error) {
if deps == "" {
return []byte(code), nil
}
return buildZipTwo("main.py", "requirements.txt", []byte(code), []byte(deps))
}
+36
View File
@@ -0,0 +1,36 @@
package stats
import (
"log"
"os"
"strings"
)
// NewProvider создаёт StatsProvider из переменных окружения.
//
// Переменные:
// - GRAFANA_INTERNAL_URL — внутренний URL (http://grafana.grafana.svc.cluster.local:3000)
// - GRAFANA_PUBLIC_URL — публичный URL для ссылок (https://fission.kube5s.ru/grafana)
// - GRAFANA_ADMIN_USER — имя admin (default: "admin")
// - GRAFANA_ADMIN_PASS — пароль admin
func NewProvider() StatsProvider {
internalURL := strings.TrimSpace(os.Getenv("GRAFANA_INTERNAL_URL"))
if internalURL == "" {
log.Printf("stats: GRAFANA_INTERNAL_URL not set — using NoopProvider")
return NoopProvider{}
}
publicURL := strings.TrimSpace(os.Getenv("GRAFANA_PUBLIC_URL"))
if publicURL == "" {
publicURL = "https://fission.kube5s.ru/grafana"
}
adminUser := strings.TrimSpace(os.Getenv("GRAFANA_ADMIN_USER"))
if adminUser == "" {
adminUser = "admin"
}
adminPass := os.Getenv("GRAFANA_ADMIN_PASS")
log.Printf("stats: GrafanaProvider internalURL=%s publicURL=%s user=%s", internalURL, publicURL, adminUser)
return NewGrafanaProvider(internalURL, publicURL, adminUser, adminPass)
}
+382
View File
@@ -0,0 +1,382 @@
package stats
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"sync"
"time"
)
// GrafanaProvider реализует StatsProvider через Grafana HTTP API.
//
// Для каждого namespace создаётся изолированная Grafana Organization:
// - PostgreSQL datasource (тот же DSN, uid="fission-user-pg")
// - Dashboard с hardcoded WHERE namespace='...'
// - Public Dashboard (без логина) → accessToken
//
// Потокобезопасен: sync.RWMutex + per-namespace singleflight.
type GrafanaProvider struct {
internalURL string // http://grafana.grafana.svc.cluster.local:3000
publicURL string // https://fission.kube5s.ru/grafana
adminUser string
adminPass string
http *http.Client
mu sync.RWMutex
tokens map[string]string // namespace → publicDashboardAccessToken
orgIDs map[string]int64 // namespace → grafana orgId
}
// NewGrafanaProvider создаёт GrafanaProvider.
func NewGrafanaProvider(internalURL, publicURL, adminUser, adminPass string) *GrafanaProvider {
return &GrafanaProvider{
internalURL: strings.TrimRight(internalURL, "/"),
publicURL: strings.TrimRight(publicURL, "/"),
adminUser: adminUser,
adminPass: adminPass,
http: &http.Client{Timeout: 20 * time.Second},
tokens: make(map[string]string),
orgIDs: make(map[string]int64),
}
}
// EnsureOrgForNamespace идемпотентно создаёт Grafana Org + datasource + dashboard + public link.
func (g *GrafanaProvider) EnsureOrgForNamespace(ctx context.Context, namespace, email string) error {
// Быстрый путь: уже провизировано в этом процессе
g.mu.RLock()
_, cached := g.tokens[namespace]
g.mu.RUnlock()
if cached {
return nil
}
// Шаг 1: получить или создать Org
orgID, err := g.getOrCreateOrg(ctx, namespace)
if err != nil {
return fmt.Errorf("getOrCreateOrg(%s): %w", namespace, err)
}
// Шаг 2: создать datasource в этой Org (идемпотентно)
if err := g.ensureDatasource(ctx, orgID); err != nil {
log.Printf("stats: ensureDatasource org=%d ns=%s: %v", orgID, namespace, err)
// не фатально — dashboard может не работать но org создана
}
// Шаг 3: создать dashboard с hardcoded namespace (идемпотентно)
dashUID, err := g.ensureDashboard(ctx, orgID, namespace)
if err != nil {
return fmt.Errorf("ensureDashboard org=%d ns=%s: %w", orgID, namespace, err)
}
// Шаг 4: получить или создать public dashboard → accessToken
token, err := g.ensurePublicDashboard(ctx, orgID, dashUID)
if err != nil {
return fmt.Errorf("ensurePublicDashboard org=%d dash=%s: %w", orgID, dashUID, err)
}
// Кэшируем
g.mu.Lock()
g.tokens[namespace] = token
g.orgIDs[namespace] = orgID
g.mu.Unlock()
log.Printf("stats: org provisioned ns=%s orgId=%d publicToken=%s...", namespace, orgID, token[:8])
return nil
}
// DashboardURL возвращает публичный URL или "" если ещё не провизировано.
func (g *GrafanaProvider) DashboardURL(ctx context.Context, namespace string) string {
// Сначала пробуем из кэша
g.mu.RLock()
token, ok := g.tokens[namespace]
g.mu.RUnlock()
if ok && token != "" {
return g.publicURL + "/public-dashboards/" + token
}
// Кэш промах (после перезапуска сервера) — провизируем заново
if err := g.EnsureOrgForNamespace(ctx, namespace, ""); err != nil {
log.Printf("stats: DashboardURL re-provision ns=%s: %v", namespace, err)
return ""
}
g.mu.RLock()
token = g.tokens[namespace]
g.mu.RUnlock()
if token == "" {
return ""
}
return g.publicURL + "/public-dashboards/" + token
}
// --- Grafana API helpers ---
// getOrCreateOrg возвращает orgId существующей или создаёт новую Org.
func (g *GrafanaProvider) getOrCreateOrg(ctx context.Context, namespace string) (int64, error) {
// Проверяем кэш orgIDs
g.mu.RLock()
if id, ok := g.orgIDs[namespace]; ok {
g.mu.RUnlock()
return id, nil
}
g.mu.RUnlock()
// GET /api/orgs/name/{namespace}
resp, body, err := g.grafanaRequest(ctx, http.MethodGet, "/api/orgs/name/"+namespace, 0, nil)
if err != nil {
return 0, err
}
if resp.StatusCode == http.StatusOK {
var org struct {
ID int64 `json:"id"`
}
if err := json.Unmarshal(body, &org); err != nil {
return 0, fmt.Errorf("parse org: %w", err)
}
return org.ID, nil
}
// Org не найдена — создаём
payload := map[string]string{"name": namespace}
resp, body, err = g.grafanaRequest(ctx, http.MethodPost, "/api/orgs", 0, payload)
if err != nil {
return 0, err
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return 0, fmt.Errorf("create org status=%d body=%s", resp.StatusCode, string(body))
}
var created struct {
OrgID int64 `json:"orgId"`
}
if err := json.Unmarshal(body, &created); err != nil {
return 0, fmt.Errorf("parse create org: %w", err)
}
return created.OrgID, nil
}
// ensureDatasource создаёт PostgreSQL datasource в org (uid="fission-user-pg").
// Идемпотентен: 409 Conflict считается успехом.
func (g *GrafanaProvider) ensureDatasource(ctx context.Context, orgID int64) error {
// Проверяем есть ли уже datasource в этой org
resp, _, err := g.grafanaRequest(ctx, http.MethodGet, "/api/datasources/uid/fission-user-pg", orgID, nil)
if err != nil {
return err
}
if resp.StatusCode == http.StatusOK {
return nil // уже есть
}
// Получаем DSN из уже существующего datasource в Org 1 (uid=fission-pg)
_, body, err := g.grafanaRequest(ctx, http.MethodGet, "/api/datasources/uid/fission-pg", 1, nil)
if err != nil {
return fmt.Errorf("get main datasource: %w", err)
}
var ds struct {
URL string `json:"url"`
JSONData json.RawMessage `json:"jsonData"`
SecureJSONData struct {
Password string `json:"password"`
} `json:"secureJsonData"`
}
if err := json.Unmarshal(body, &ds); err != nil {
return fmt.Errorf("parse main datasource: %w", err)
}
// Создаём копию datasource в новой Org
payload := map[string]any{
"name": "fission-pg",
"type": "postgres",
"uid": "fission-user-pg",
"url": ds.URL,
"access": "proxy",
"jsonData": map[string]any{
"sslmode": "disable",
"postgresVersion": 1700,
"timescaledb": false,
},
"secureJsonData": ds.SecureJSONData,
}
resp, body, err = g.grafanaRequest(ctx, http.MethodPost, "/api/datasources", orgID, payload)
if err != nil {
return err
}
if resp.StatusCode == http.StatusConflict {
return nil // уже существует
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return fmt.Errorf("create datasource status=%d body=%s", resp.StatusCode, string(body))
}
return nil
}
// ensureDashboard создаёт/обновляет user-дашборд в org.
// Возвращает uid дашборда.
func (g *GrafanaProvider) ensureDashboard(ctx context.Context, orgID int64, namespace string) (string, error) {
const dashUID = "fission-user-overview"
// Проверяем существование
resp, _, err := g.grafanaRequest(ctx, http.MethodGet, "/api/dashboards/uid/"+dashUID, orgID, nil)
if err != nil {
return "", err
}
if resp.StatusCode == http.StatusOK {
return dashUID, nil // уже есть
}
// Импортируем dashboard JSON с hardcoded namespace
dashJSON := userDashboardJSON(namespace)
payload := map[string]any{
"dashboard": json.RawMessage(dashJSON),
"overwrite": true,
"folderId": 0,
}
resp, body, err := g.grafanaRequest(ctx, http.MethodPost, "/api/dashboards/db", orgID, payload)
if err != nil {
return "", err
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("import dashboard status=%d body=%s", resp.StatusCode, string(body))
}
return dashUID, nil
}
// ensurePublicDashboard создаёт public dashboard и возвращает accessToken.
// Идемпотентен: если уже существует — возвращает существующий token.
func (g *GrafanaProvider) ensurePublicDashboard(ctx context.Context, orgID int64, dashUID string) (string, error) {
path := "/api/dashboards/uid/" + dashUID + "/public-dashboards"
// Проверяем существование
resp, body, err := g.grafanaRequest(ctx, http.MethodGet, path, orgID, nil)
if err != nil {
return "", err
}
if resp.StatusCode == http.StatusOK {
var pd struct {
AccessToken string `json:"accessToken"`
}
if err := json.Unmarshal(body, &pd); err != nil {
return "", fmt.Errorf("parse public dashboard: %w", err)
}
if pd.AccessToken != "" {
return pd.AccessToken, nil
}
}
// Создаём
payload := map[string]any{
"isEnabled": true,
"annotationsEnabled": false,
"timeSelectionEnabled": true,
}
resp, body, err = g.grafanaRequest(ctx, http.MethodPost, path, orgID, payload)
if err != nil {
return "", err
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return "", fmt.Errorf("create public dashboard status=%d body=%s", resp.StatusCode, string(body))
}
var pd struct {
AccessToken string `json:"accessToken"`
}
if err := json.Unmarshal(body, &pd); err != nil {
return "", fmt.Errorf("parse created public dashboard: %w", err)
}
if pd.AccessToken == "" {
return "", fmt.Errorf("empty accessToken in response: %s", string(body))
}
return pd.AccessToken, nil
}
// grafanaRequest выполняет HTTP запрос к Grafana API.
// orgID > 0 → устанавливает X-Grafana-Org-Id заголовок (thread-safe, без смены контекста).
// orgID == 0 → без заголовка (используется Org 1 admin по умолчанию).
func (g *GrafanaProvider) grafanaRequest(ctx context.Context, method, path string, orgID int64, payload any) (*http.Response, []byte, error) {
var bodyReader io.Reader
if payload != nil {
data, err := json.Marshal(payload)
if err != nil {
return nil, nil, fmt.Errorf("marshal payload: %w", err)
}
bodyReader = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, method, g.internalURL+path, bodyReader)
if err != nil {
return nil, nil, fmt.Errorf("new request: %w", err)
}
req.SetBasicAuth(g.adminUser, g.adminPass)
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
if orgID > 0 {
req.Header.Set("X-Grafana-Org-Id", fmt.Sprintf("%d", orgID))
}
resp, err := g.http.Do(req)
if err != nil {
return nil, nil, fmt.Errorf("do request %s %s: %w", method, path, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return resp, nil, fmt.Errorf("read body: %w", err)
}
return resp, body, nil
}
// userDashboardJSON генерирует JSON дашборда для конкретного namespace.
// Namespace вшит прямо в SQL запросы — без template variables.
// Dashboard uid="fission-user-overview" (per-org, без конфликтов между org).
func userDashboardJSON(namespace string) string {
// Безопасное экранирование namespace для SQL (namespace это sha256 hex — только [a-z0-9-])
ns := strings.ReplaceAll(namespace, "'", "''")
return fmt.Sprintf(`{
"title": "Мои функции — %s",
"uid": "fission-user-overview",
"tags": ["fission", "user"],
"timezone": "browser",
"refresh": "1m",
"time": {"from": "now-24h", "to": "now"},
"panels": [
{
"id": 1, "title": "Вызовы в час", "type": "timeseries",
"gridPos": {"x": 0, "y": 0, "w": 16, "h": 8},
"datasource": {"type": "postgres", "uid": "fission-user-pg"},
"targets": [{"rawSql": "SELECT date_trunc('hour', started_at) AS time, count(*) AS value, function_name FROM invocations WHERE namespace = '%s' AND started_at BETWEEN $__timeFrom() AND $__timeTo() GROUP BY 1, function_name ORDER BY 1", "format": "time_series", "refId": "A"}]
},
{
"id": 2, "title": "Успех vs Ошибки", "type": "piechart",
"gridPos": {"x": 16, "y": 0, "w": 8, "h": 8},
"datasource": {"type": "postgres", "uid": "fission-user-pg"},
"targets": [{"rawSql": "SELECT CASE WHEN status_code >= 200 AND status_code < 300 THEN 'success' WHEN status_code = 0 THEN 'event' ELSE 'error' END AS metric, count(*) AS value FROM invocations WHERE namespace = '%s' AND started_at BETWEEN $__timeFrom() AND $__timeTo() GROUP BY 1", "format": "table", "refId": "A"}]
},
{
"id": 3, "title": "Топ функций", "type": "bargauge",
"gridPos": {"x": 0, "y": 8, "w": 12, "h": 8},
"datasource": {"type": "postgres", "uid": "fission-user-pg"},
"targets": [{"rawSql": "SELECT function_name AS metric, count(*) AS value FROM invocations WHERE namespace = '%s' AND started_at BETWEEN $__timeFrom() AND $__timeTo() GROUP BY 1 ORDER BY 2 DESC LIMIT 10", "format": "table", "refId": "A"}]
},
{
"id": 4, "title": "Средняя латентность (ms)", "type": "timeseries",
"gridPos": {"x": 12, "y": 8, "w": 12, "h": 8},
"datasource": {"type": "postgres", "uid": "fission-user-pg"},
"targets": [{"rawSql": "SELECT date_trunc('hour', started_at) AS time, round(avg(duration_ms)) AS avg_ms FROM invocations WHERE namespace = '%s' AND started_at BETWEEN $__timeFrom() AND $__timeTo() AND trigger_type != 'event' GROUP BY 1 ORDER BY 1", "format": "time_series", "refId": "A"}]
},
{
"id": 5, "title": "Последние события", "type": "table",
"gridPos": {"x": 0, "y": 16, "w": 24, "h": 8},
"datasource": {"type": "postgres", "uid": "fission-user-pg"},
"targets": [{"rawSql": "SELECT started_at AS time, function_name, trigger_type, event_type, status_code, duration_ms, error_msg FROM invocations WHERE namespace = '%s' ORDER BY started_at DESC LIMIT 50", "format": "table", "refId": "A"}]
}
],
"schemaVersion": 39
}`, namespace, ns, ns, ns, ns, ns)
}
+10
View File
@@ -0,0 +1,10 @@
package stats
import "context"
// NoopProvider — заглушка когда GRAFANA_INTERNAL_URL не задан.
// Ни на что не влияет, не крашит.
type NoopProvider struct{}
func (NoopProvider) EnsureOrgForNamespace(_ context.Context, _, _ string) error { return nil }
func (NoopProvider) DashboardURL(_ context.Context, _ string) string { return "" }
+20
View File
@@ -0,0 +1,20 @@
// Package stats — аналитический слой консоли.
//
// StatsProvider абстрагирует конкретный инструмент (Grafana, Prometheus, etc.).
// При смене инструмента достаточно заменить реализацию и переменные окружения.
package stats
import "context"
// StatsProvider — интерфейс аналитики.
// Реализации: GrafanaProvider (GRAFANA_INTERNAL_URL задан), NoopProvider (заглушка).
type StatsProvider interface {
// EnsureOrgForNamespace идемпотентно создаёт аналитическое пространство
// для namespace (Grafana Org + datasource + dashboard + public link).
// Вызывается при handleAuth — fire-and-forget горутиной.
EnsureOrgForNamespace(ctx context.Context, namespace, email string) error
// DashboardURL возвращает публичный URL дашборда без логина.
// Возвращает "" если аналитика не настроена или provisioning не завершён.
DashboardURL(ctx context.Context, namespace string) string
}
+86 -2
View File
@@ -25,6 +25,7 @@
<script src="js/fn-archive.js"></script> <script src="js/fn-archive.js"></script>
<script src="js/ai.js"></script> <script src="js/ai.js"></script>
<script src="js/app.js"></script> <script src="js/app.js"></script>
<script src="js/mq.js"></script>
</head> </head>
<body> <body>
@@ -102,13 +103,14 @@
<div class="nubes">NUBES</div> <div class="nubes">NUBES</div>
<div class="product">FISSION CONSOLE</div> <div class="product">FISSION CONSOLE</div>
</div> </div>
<div style="font-size:0.65rem; color:var(--text-secondary); margin-left:10px; align-self:center; opacity:0.7;">v1.3.86</div> <div style="font-size:0.65rem; color:var(--text-secondary); margin-left:10px; align-self:center; opacity:0.7;">v1.3.96</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="openCreateCode()">✏️ Из кода</button> <button class="btn" onclick="openCreateCode()">✏️ Из кода</button>
<button class="btn" onclick="openCreateArchive()">📦 Из архива</button> <button class="btn" onclick="openCreateArchive()">📦 Из архива</button>
<button class="btn ghost" onclick="openHelp()">Help</button> <button class="btn ghost" onclick="openHelp()">Help</button>
<button class="btn ghost" onclick="openAnalytics()" title="Открыть дашборд Grafana">📊 Аналитика</button>
<button class="btn ghost" onclick="doLogout()" style="margin-left:8px;">Выход</button> <button class="btn ghost" onclick="doLogout()" style="margin-left:8px;">Выход</button>
</div> </div>
</div> </div>
@@ -135,6 +137,10 @@
<div class="k">Крон-функции</div> <div class="k">Крон-функции</div>
<div id="cron-count" class="v">-</div> <div id="cron-count" class="v">-</div>
</div> </div>
<div class="card">
<div class="k">MQ-триггеры</div>
<div id="mq-count" class="v">-</div>
</div>
</div> </div>
<div class="box"> <div class="box">
@@ -173,6 +179,72 @@
<div id="status" class="status"></div> <div id="status" class="status"></div>
<div class="hint">Изменения применяются напрямую через CRD Fission.</div> <div class="hint">Изменения применяются напрямую через CRD Fission.</div>
</div> </div>
<!-- MQ-триггеры -->
<div class="box">
<div class="toolbar">
<div style="font-weight:600;">MQ-триггеры</div>
<button class="btn" onclick="openCreateMQ()">+ Добавить</button>
</div>
<table>
<thead>
<tr>
<th>Имя</th>
<th>Очередь</th>
<th>Функция</th>
<th>Endpoint SQS</th>
<th>Статус</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="mq-rows">
<tr><td colspan="6" style="color:var(--fg-muted,#888);text-align:center;">Загрузка...</td></tr>
</tbody>
</table>
<div class="hint">MQ-триггер поллит очередь SQS и вызывает Fission-функцию при появлении сообщений.</div>
</div>
</div>
<!-- Модалка: создать MQ-триггер -->
<div id="mq-create-modal" class="modal">
<div class="panel" style="width:520px;max-width:96vw;">
<h3>📨 Создать MQ-триггер</h3>
<div class="row">
<div class="field">
<label>Имя триггера</label>
<input id="mq-name" placeholder="weather-trigger">
</div>
<div class="field">
<label>Функция</label>
<input id="mq-fn" placeholder="weather-store">
</div>
</div>
<div class="row">
<div class="field">
<label>Имя очереди (SQS)</label>
<input id="mq-queue" placeholder="weather-raw">
</div>
<div class="field">
<label>SQS Endpoint</label>
<input id="mq-endpoint" placeholder="http://shared-sqs.shared-sqs.svc.cluster.local:4100">
</div>
</div>
<div class="row">
<div class="field">
<label>Access Key</label>
<input id="mq-access-key" placeholder="SSAK-...">
</div>
<div class="field">
<label>Secret Key</label>
<input id="mq-secret-key" type="password" placeholder="...">
</div>
</div>
<div id="mq-create-error" class="modal-error"></div>
<div class="actions">
<button class="btn ghost" onclick="closeMQCreate()">Отмена</button>
<button class="btn" onclick="submitCreateMQ()">Создать</button>
</div>
</div>
</div> </div>
<!-- Модалка: создать функцию из кода (prefix cc-) --> <!-- Модалка: создать функцию из кода (prefix cc-) -->
@@ -234,6 +306,12 @@
<button class="btn ghost" id="cc-gen-btn" onclick="showGenPrompt('cc')">&#x2728; Сгенерировать код LLM</button> <button class="btn ghost" id="cc-gen-btn" onclick="showGenPrompt('cc')">&#x2728; Сгенерировать код LLM</button>
<button class="btn ghost" id="cc-exp-btn" onclick="aiExplain('cc-code','cc-lang','cc-ai-result')">&#x1F4D6; LLM: Что делает?</button> <button class="btn ghost" id="cc-exp-btn" onclick="aiExplain('cc-code','cc-lang','cc-ai-result')">&#x1F4D6; LLM: Что делает?</button>
</div> </div>
<div style="margin-top:10px;">
<label id="cc-deps-label" style="font-size:12px; color:var(--text-secondary); margin-bottom:4px; display:block;">Зависимости (requirements.txt)</label>
<textarea id="cc-deps" rows="3" placeholder="boto3
requests>=2.28
psycopg2-binary" style="width:100%; box-sizing:border-box; font-family:monospace; font-size:12px; background:#1a1a2e; border:1px dashed #3a3a5c; border-radius:4px; color:#cdd6f4; padding:6px 8px; resize:none; overflow-y:auto;"></textarea>
</div>
</div> </div>
<div id="cc-gen-prompt" style="display:none; margin-top:8px; gap:6px; align-items:center;"> <div id="cc-gen-prompt" style="display:none; margin-top:8px; gap:6px; align-items:center;">
<input id="cc-gen-desc" type="text" <input id="cc-gen-desc" type="text"
@@ -373,6 +451,12 @@
<button class="btn ghost" id="e-exp-btn" onclick="aiExplain('e-code','e-lang-hidden','e-ai-result')">&#x1F4D6; <button class="btn ghost" id="e-exp-btn" onclick="aiExplain('e-code','e-lang-hidden','e-ai-result')">&#x1F4D6;
LLM: Что делает?</button> LLM: Что делает?</button>
</div> </div>
<div style="margin-top:10px;">
<label id="e-deps-label" style="font-size:12px; color:var(--text-secondary); margin-bottom:4px; display:block;">Зависимости (requirements.txt)</label>
<textarea id="e-deps" rows="3" placeholder="boto3
requests>=2.28
psycopg2-binary" style="width:100%; box-sizing:border-box; font-family:monospace; font-size:12px; background:#1a1a2e; border:1px dashed #3a3a5c; border-radius:4px; color:#cdd6f4; padding:6px 8px; resize:none; overflow-y:auto;"></textarea>
</div>
</div> </div>
<div id="e-archive-area" style="display:none;"> <div id="e-archive-area" style="display:none;">
<div id="e-archive-current" style="margin-bottom:10px; padding:8px 12px; background:var(--bg-alt); border-radius:6px; font-size:13px; color:var(--text-secondary);"> <div id="e-archive-current" style="margin-bottom:10px; padding:8px 12px; background:var(--bg-alt); border-radius:6px; font-size:13px; color:var(--text-secondary);">
@@ -542,7 +626,7 @@
</div> </div>
<div class="actions" style="justify-content:space-between; align-items:center;"> <div class="actions" style="justify-content:space-between; align-items:center;">
<span style="font-size:0.75rem; color:var(--text-secondary);">v1.3.86</span> <span style="font-size:0.75rem; color:var(--text-secondary);">v1.3.96</span>
<button class="btn ghost" onclick="closeHelp()">Закрыть</button> <button class="btn ghost" onclick="closeHelp()">Закрыть</button>
</div> </div>
</div> </div>
+57 -1
View File
@@ -1,5 +1,43 @@
/* ai.js — AI/LLM функции: проверка, генерация, объяснение, ассистент */ /* ai.js — AI/LLM функции: проверка, генерация, объяснение, ассистент */
// PYTHON_STDLIB — стандартная библиотека Python (не требуют pip install).
var PYTHON_STDLIB = new Set([
'os','sys','json','time','re','math','io','collections','itertools','functools',
'datetime','pathlib','urllib','http','threading','asyncio','logging','random',
'string','struct','hashlib','hmac','base64','uuid','copy','abc','typing',
'dataclasses','contextlib','enum','warnings','traceback','inspect','importlib',
'subprocess','socket','ssl','email','html','xml','csv','sqlite3','unittest',
'gc','weakref','array','queue','heapq','bisect','decimal','fractions',
'statistics','textwrap','difflib','shutil','glob','fnmatch','tempfile',
'zipfile','tarfile','gzip','bz2','lzma','pickle','shelve','codecs',
'unicodedata','ast','dis','types','builtins','operator','numbers','cmath',
'pprint','reprlib','platform','signal','mmap','ctypes','multiprocessing',
'concurrent','select','selectors','errno','atexit','sched','calendar',
'locale','gettext','argparse','getopt','shlex','configparser','tokenize',
'runpy','pkgutil','site','sysconfig','distutils','zipimport','abc','io',
'__future__','_thread','threading','contextvars','netrc','plistlib',
'html','xml','http','urllib','email','mailbox','mimetypes','encodings',
'codecs','unicodedata','readline','rlcompleter','curses','idlelib','tkinter',
]);
// detectExternalPythonImports — возвращает список модулей, которых нет в stdlib.
function detectExternalPythonImports(code) {
var external = [];
var seen = {};
var patterns = [/^import\s+([\w]+)/gm, /^from\s+([\w]+)/gm];
for (var pi = 0; pi < patterns.length; pi++) {
var m;
while ((m = patterns[pi].exec(code)) !== null) {
var mod = m[1];
if (!PYTHON_STDLIB.has(mod) && !seen[mod]) {
seen[mod] = true;
external.push(mod);
}
}
}
return external;
}
function llmGeneratedWarning(lang) { function llmGeneratedWarning(lang) {
var text = 'Сделано LLM. Не доверяй, проверяй!'; var text = 'Сделано LLM. Не доверяй, проверяй!';
switch (lang) { switch (lang) {
@@ -45,7 +83,25 @@ async function aiCheck(codeId, langId, resultId) {
var data = await requestJSON(API_BASE + '/ai/check', 'POST', { language: lang, code: code }); var data = await requestJSON(API_BASE + '/ai/check', 'POST', { language: lang, code: code });
resEl.style.background = data.ok ? '#1a3a1a' : '#3a1a1a'; resEl.style.background = data.ok ? '#1a3a1a' : '#3a1a1a';
resEl.style.color = data.ok ? '#8f8' : '#f88'; resEl.style.color = data.ok ? '#8f8' : '#f88';
resEl.textContent = data.result || '(пустой ответ)'; var resultText = data.result || '(пустой ответ)';
// Для Python: проверяем внешние импорты vs поле зависимостей
if (lang === 'python') {
var depsId = codeId.replace('-code', '-deps'); // cc-code→cc-deps, e-code→e-deps
var depsEl = document.getElementById(depsId);
var depsVal = depsEl ? depsEl.value.trim() : '';
var external = detectExternalPythonImports(code);
if (external.length > 0 && !depsVal) {
resultText += '\n\n⚠️ Внешние библиотеки: ' + external.join(', ') +
'\nДобавьте их в поле "Зависимости (requirements.txt)" или убедитесь что они уже есть в Python-окружении.';
if (data.ok) {
resEl.style.background = '#3a2a00';
resEl.style.color = '#ffa';
}
}
}
resEl.textContent = resultText;
} catch (e) { } catch (e) {
resEl.style.background = '#3a2a00'; resEl.style.background = '#3a2a00';
resEl.style.color = '#ffa'; resEl.style.color = '#ffa';
+3
View File
@@ -16,6 +16,9 @@ async function reloadAll() {
getJSON(API_BASE + '/timetriggers') getJSON(API_BASE + '/timetriggers')
]); ]);
// MQ-триггеры загружаем параллельно, не блокируем основную таблицу
if (typeof loadMQTriggers === 'function') loadMQTriggers();
S.envs = envs || []; S.envs = envs || [];
S.fns = fns || []; S.fns = fns || [];
S.httpTriggers = http || []; S.httpTriggers = http || [];
+21
View File
@@ -93,6 +93,27 @@ function doLogout() {
showLoginOverlay(); showLoginOverlay();
} }
async function openAnalytics() {
var token = localStorage.getItem('auth_token');
var env = localStorage.getItem('auth_env') || 'test';
if (!token) { alert('Требуется авторизация'); return; }
try {
var r = await fetch(API_BASE + '/stats/dashboard-url', {
headers: { 'X-Auth-Token': token, 'X-Auth-Env': env }
});
if (!r.ok) throw new Error('HTTP ' + r.status);
var d = await r.json();
if (d.url) {
window.open(d.url, '_blank', 'noopener');
} else {
// Grafana не настроена или org ещё провизируется — ссылка на оператора
window.open('/grafana/', '_blank', 'noopener');
}
} catch (e) {
alert('Аналитика временно недоступна: ' + e.message);
}
}
function checkAuth() { function checkAuth() {
var storedToken = localStorage.getItem('auth_token'); var storedToken = localStorage.getItem('auth_token');
if (!storedToken) { if (!storedToken) {
+20 -1
View File
@@ -7,6 +7,23 @@ function onLangChangeCode() {
document.getElementById('cc-entry').value = t.entrypoint; document.getElementById('cc-entry').value = t.entrypoint;
document.getElementById('cc-code').value = t.code; document.getElementById('cc-code').value = t.code;
} }
// Обновляем лейбл поля зависимостей под язык
var depsLabels = {
python: 'Зависимости (requirements.txt)',
nodejs: 'Зависимости (package.json dependencies)',
php: 'Зависимости (composer.json)',
ruby: 'Зависимости (Gemfile)',
};
var depsPlaceholders = {
python: 'boto3\nrequests>=2.28\npsycopg2-binary',
nodejs: 'express: ^4.18.2\naxios: ^1.6.0',
php: '{\n "require": {\n "guzzlehttp/guzzle": "^7.0"\n }\n}',
ruby: "gem 'httparty'\ngem 'pg'",
};
var label = document.getElementById('cc-deps-label');
var area = document.getElementById('cc-deps');
if (label) label.textContent = depsLabels[lang] || 'Зависимости';
if (area) area.placeholder = depsPlaceholders[lang] || '';
} }
function openCreateCode() { function openCreateCode() {
@@ -16,6 +33,7 @@ function openCreateCode() {
document.getElementById('cc-route').value = ''; document.getElementById('cc-route').value = '';
document.getElementById('cc-methods').value = 'GET'; document.getElementById('cc-methods').value = 'GET';
document.getElementById('cc-timeout').value = '60'; document.getElementById('cc-timeout').value = '60';
document.getElementById('cc-deps').value = '';
document.getElementById('cc-schedule-enabled').checked = false; document.getElementById('cc-schedule-enabled').checked = false;
document.getElementById('cc-cron').value = ''; document.getElementById('cc-cron').value = '';
toggleScheduleFields('cc'); toggleScheduleFields('cc');
@@ -47,7 +65,8 @@ async function submitCreateCode() {
route: document.getElementById('cc-route').value.trim(), route: document.getElementById('cc-route').value.trim(),
methods: parseMethods(document.getElementById('cc-methods').value), methods: parseMethods(document.getElementById('cc-methods').value),
timeout: parseTimeout(document.getElementById('cc-timeout').value), timeout: parseTimeout(document.getElementById('cc-timeout').value),
code: document.getElementById('cc-code').value code: document.getElementById('cc-code').value,
deps: document.getElementById('cc-deps').value.trim(),
}); });
try { try {
+8
View File
@@ -188,6 +188,13 @@ async function openEdit(name) {
else if (envName.includes('ruby')) lang = 'ruby'; else if (envName.includes('ruby')) lang = 'ruby';
else if (envName.includes('php')) lang = 'php'; else if (envName.includes('php')) lang = 'php';
document.getElementById('e-lang-hidden').value = lang; document.getElementById('e-lang-hidden').value = lang;
// Обновляем лейбл и плейсхолдер поля зависимостей
var depsLabels = {python:'Зависимости (requirements.txt)', nodejs:'Зависимости (package.json dependencies)', php:'Зависимости (composer.json)', ruby:'Зависимости (Gemfile)'};
var depsPlaceholders = {python:'boto3\nrequests>=2.28', nodejs:'express: ^4.18.2\naxios: ^1.6.0', php:'{\n "require": {\n "guzzlehttp/guzzle": "^7.0"\n }\n}', ruby:"gem 'httparty'\ngem 'pg'"};
var eDepsLabel = document.getElementById('e-deps-label');
var eDepsArea = document.getElementById('e-deps');
if (eDepsLabel) eDepsLabel.textContent = depsLabels[lang] || 'Зависимости';
if (eDepsArea) { eDepsArea.placeholder = depsPlaceholders[lang] || ''; eDepsArea.value = fn.deps || ''; }
var aiRes = document.getElementById('e-ai-result'); var aiRes = document.getElementById('e-ai-result');
if (aiRes) { aiRes.style.display = 'none'; aiRes.textContent = ''; } if (aiRes) { aiRes.style.display = 'none'; aiRes.textContent = ''; }
var warnEl = document.getElementById('e-tf-warn'); var warnEl = document.getElementById('e-tf-warn');
@@ -242,6 +249,7 @@ async function submitEdit() {
} else { } else {
await requestJSON(API_BASE + '/functions/' + encodeURIComponent(name) + '/code', 'PUT', { await requestJSON(API_BASE + '/functions/' + encodeURIComponent(name) + '/code', 'PUT', {
code: document.getElementById('e-code').value, code: document.getElementById('e-code').value,
deps: document.getElementById('e-deps').value.trim(),
timeout: parseTimeout(document.getElementById('e-timeout').value) timeout: parseTimeout(document.getElementById('e-timeout').value)
}); });
} }
+103
View File
@@ -0,0 +1,103 @@
// mq.js — MQ-триггеры (SQS → Fission function)
// Архитектура: Console создаёт K8s Deployment + Secret (sqs-consumer) в namespace пользователя.
// sqs-consumer поллит SQS очередь → HTTP POST в Fission-функцию → DeleteMessage.
// Backend: POST /console/api/mqtriggers, DELETE /console/api/mqtriggers/{name}
// v1.3.88
// ── Загрузка и отрисовка ────────────────────────────────────────────
async function loadMQTriggers() {
try {
const data = await apiFetch('/console/api/mqtriggers');
renderMQTable(data.items || []);
const cnt = document.getElementById('mq-count');
if (cnt) cnt.textContent = (data.items || []).length;
} catch (e) {
renderMQTable([]);
const cnt = document.getElementById('mq-count');
if (cnt) cnt.textContent = '0';
}
}
function renderMQTable(items) {
const tbody = document.getElementById('mq-rows');
if (!tbody) return;
if (!items.length) {
tbody.innerHTML = '<tr><td colspan="6" style="color:var(--fg-muted,#888);text-align:center;">Нет MQ-триггеров</td></tr>';
return;
}
tbody.innerHTML = items.map(t => `
<tr>
<td>${escHtml(t.name)}</td>
<td>${escHtml(t.queue)}</td>
<td>${escHtml(t.functionName)}</td>
<td style="font-size:0.8em;color:var(--fg-muted,#aaa);">${escHtml(t.sqsEndpoint || '')}</td>
<td><span style="color:${t.ready ? '#4a4' : '#a44'}">${t.ready ? '▶ Running' : '◼ Pending'}</span></td>
<td>
<button class="btn ghost small" onclick="deleteMQTrigger('${escHtml(t.name)}')">Удалить</button>
</td>
</tr>
`).join('');
}
// ── Создание ─────────────────────────────────────────────────────────
function openCreateMQ() {
document.getElementById('mq-name').value = '';
document.getElementById('mq-fn').value = '';
document.getElementById('mq-queue').value = '';
document.getElementById('mq-endpoint').value = 'http://shared-sqs.shared-sqs.svc.cluster.local:4100';
document.getElementById('mq-access-key').value = '';
document.getElementById('mq-secret-key').value = '';
document.getElementById('mq-create-error').textContent = '';
document.getElementById('mq-create-modal').style.display = 'flex';
}
function closeMQCreate() {
document.getElementById('mq-create-modal').style.display = 'none';
}
async function submitCreateMQ() {
const name = document.getElementById('mq-name').value.trim();
const fnName = document.getElementById('mq-fn').value.trim();
const queue = document.getElementById('mq-queue').value.trim();
const endpoint = document.getElementById('mq-endpoint').value.trim();
const accessKey = document.getElementById('mq-access-key').value.trim();
const secretKey = document.getElementById('mq-secret-key').value.trim();
const errEl = document.getElementById('mq-create-error');
if (!name || !fnName || !queue || !accessKey || !secretKey) {
errEl.textContent = 'Заполните все поля';
return;
}
errEl.textContent = '';
try {
await apiFetch('/console/api/mqtriggers', {
method: 'POST',
body: JSON.stringify({ name, functionName: fnName, queue, sqsEndpoint: endpoint, accessKey, secretKey })
});
closeMQCreate();
await loadMQTriggers();
} catch (e) {
errEl.textContent = e.message || 'Ошибка создания';
}
}
// ── Удаление ─────────────────────────────────────────────────────────
async function deleteMQTrigger(name) {
if (!confirm(`Удалить MQ-триггер "${name}"?`)) return;
try {
await apiFetch(`/console/api/mqtriggers/${encodeURIComponent(name)}`, { method: 'DELETE' });
await loadMQTriggers();
} catch (e) {
alert('Ошибка удаления: ' + (e.message || e));
}
}
// ── Утилита ──────────────────────────────────────────────────────────
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
@@ -0,0 +1,102 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: grafana-dashboard-fission
namespace: grafana
data:
fission-overview.json: |
{
"title": "Fission — Operator Overview",
"uid": "fission-overview",
"tags": ["fission"],
"timezone": "browser",
"refresh": "30s",
"time": { "from": "now-24h", "to": "now" },
"templating": {
"list": [
{
"name": "namespace",
"type": "query",
"datasource": { "type": "postgres", "uid": "fission-pg" },
"query": "SELECT DISTINCT namespace FROM invocations ORDER BY 1",
"includeAll": true,
"multi": true,
"label": "Namespace",
"current": { "text": "All", "value": "$__all" }
}
]
},
"panels": [
{
"id": 1,
"title": "Вызовы в час",
"type": "timeseries",
"gridPos": { "x": 0, "y": 0, "w": 16, "h": 8 },
"datasource": { "type": "postgres", "uid": "fission-pg" },
"targets": [
{
"rawSql": "SELECT date_trunc('hour', started_at) AS time, count(*) AS value, namespace FROM invocations WHERE started_at BETWEEN $__timeFrom() AND $__timeTo() AND ('$namespace' = '$__all' OR namespace = ANY(string_to_array('$namespace', ','))) GROUP BY 1, namespace ORDER BY 1",
"format": "time_series",
"refId": "A"
}
]
},
{
"id": 2,
"title": "Успех vs Ошибки",
"type": "piechart",
"gridPos": { "x": 16, "y": 0, "w": 8, "h": 8 },
"datasource": { "type": "postgres", "uid": "fission-pg" },
"targets": [
{
"rawSql": "SELECT CASE WHEN status_code >= 200 AND status_code < 300 THEN 'success' ELSE 'error' END AS metric, count(*) AS value FROM invocations WHERE started_at BETWEEN $__timeFrom() AND $__timeTo() AND ('$namespace' = '$__all' OR namespace = ANY(string_to_array('$namespace', ','))) GROUP BY 1",
"format": "table",
"refId": "A"
}
]
},
{
"id": 3,
"title": "Топ функций по вызовам",
"type": "bargauge",
"gridPos": { "x": 0, "y": 8, "w": 12, "h": 8 },
"datasource": { "type": "postgres", "uid": "fission-pg" },
"targets": [
{
"rawSql": "SELECT function_name AS metric, count(*) AS value FROM invocations WHERE started_at BETWEEN $__timeFrom() AND $__timeTo() AND ('$namespace' = '$__all' OR namespace = ANY(string_to_array('$namespace', ','))) GROUP BY 1 ORDER BY 2 DESC LIMIT 10",
"format": "table",
"refId": "A"
}
]
},
{
"id": 4,
"title": "Средняя латентность (ms)",
"type": "timeseries",
"gridPos": { "x": 12, "y": 8, "w": 12, "h": 8 },
"datasource": { "type": "postgres", "uid": "fission-pg" },
"targets": [
{
"rawSql": "SELECT date_trunc('hour', started_at) AS time, round(avg(duration_ms)) AS avg_ms, round(percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms)) AS p95_ms FROM invocations WHERE started_at BETWEEN $__timeFrom() AND $__timeTo() AND ('$namespace' = '$__all' OR namespace = ANY(string_to_array('$namespace', ','))) GROUP BY 1 ORDER BY 1",
"format": "time_series",
"refId": "A"
}
]
},
{
"id": 5,
"title": "Последние вызовы",
"type": "table",
"gridPos": { "x": 0, "y": 16, "w": 24, "h": 8 },
"datasource": { "type": "postgres", "uid": "fission-pg" },
"targets": [
{
"rawSql": "SELECT started_at AS time, namespace, function_name, trigger_type, event_type, status_code, duration_ms FROM invocations WHERE ('$namespace' = '$__all' OR namespace = ANY(string_to_array('$namespace', ','))) ORDER BY started_at DESC LIMIT 50",
"format": "table",
"refId": "A"
}
]
}
],
"schemaVersion": 38
}
@@ -0,0 +1,15 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: grafana-dashboard-providers
namespace: grafana
data:
providers.yaml: |
apiVersion: 1
providers:
- name: fission
type: file
disableDeletion: false
editable: true
options:
path: /var/lib/grafana/dashboards
+22
View File
@@ -0,0 +1,22 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: grafana-datasources
namespace: grafana
data:
postgres.yaml: |
apiVersion: 1
datasources:
- name: PostgreSQL
type: postgres
uid: fission-pg
url: postgresqlk8s-master.dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5.svc.cluster.local:5432
database: sqsdb
user: super
secureJsonData:
password: "BQUF5ruECa1ZFlq4wYt3gPJUEmtBMkA9QNK4MM5Sd8al4ArMDlmT16DIKHYBPyif"
jsonData:
sslmode: disable
postgresVersion: 1700
timescaledb: false
editable: false
+98
View File
@@ -0,0 +1,98 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: grafana
namespace: grafana
labels:
app: grafana
spec:
replicas: 1
selector:
matchLabels:
app: grafana
template:
metadata:
labels:
app: grafana
spec:
securityContext:
fsGroup: 472
runAsUser: 472
containers:
- name: grafana
image: grafana/grafana:11.6.1
imagePullPolicy: IfNotPresent
ports:
- containerPort: 3000
env:
- name: GF_SECURITY_ADMIN_USER
valueFrom:
secretKeyRef:
name: grafana-admin
key: admin-user
- name: GF_SECURITY_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: grafana-admin
key: admin-password
- name: GF_SERVER_ROOT_URL
value: "https://fission.kube5s.ru/grafana"
- name: GF_SERVER_DOMAIN
value: "fission.kube5s.ru"
- name: GF_SERVER_SERVE_FROM_SUB_PATH
value: "true"
- name: GF_USERS_ALLOW_SIGN_UP
value: "false"
- name: GF_AUTH_ANONYMOUS_ENABLED
value: "false"
- name: GF_ORGS_AUTO_ASSIGN_ORG
value: "true"
- name: GF_ORGS_AUTO_ASSIGN_ORG_ID
value: "1"
- name: GF_ORGS_AUTO_ASSIGN_ORG_ROLE
value: "Viewer"
- name: GF_FEATURE_TOGGLES_ENABLE
value: "publicDashboards"
- name: GF_PATHS_PROVISIONING
value: "/etc/grafana/provisioning"
volumeMounts:
- name: storage
mountPath: /var/lib/grafana
- name: datasources
mountPath: /etc/grafana/provisioning/datasources
- name: dashboard-providers
mountPath: /etc/grafana/provisioning/dashboards
- name: dashboards
mountPath: /var/lib/grafana/dashboards
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
livenessProbe:
httpGet:
path: /grafana/api/health
port: 3000
initialDelaySeconds: 30
periodSeconds: 30
readinessProbe:
httpGet:
path: /grafana/api/health
port: 3000
initialDelaySeconds: 10
periodSeconds: 10
volumes:
- name: storage
persistentVolumeClaim:
claimName: grafana-storage
- name: datasources
configMap:
name: grafana-datasources
- name: dashboard-providers
configMap:
name: grafana-dashboard-providers
- name: dashboards
configMap:
name: grafana-dashboard-fission
+28
View File
@@ -0,0 +1,28 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: grafana-subpath
namespace: grafana
annotations:
# Без rewrite — Grafana сама обрабатывает /grafana/... через serve_from_sub_path
nginx.ingress.kubernetes.io/proxy-read-timeout: "600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "600"
spec:
ingressClassName: nginx
rules:
- host: fission.kube5s.ru
http:
paths:
- path: /grafana
pathType: Prefix
backend:
service:
name: grafana
port:
number: 3000
# TLS не нужен — управляется Ingress в namespace fission (fission-tls)
# При миграции на grafana.kube5s.ru:
# 1. Поменять GF_SERVER_ROOT_URL → https://grafana.kube5s.ru
# 2. Убрать GF_SERVER_SERVE_FROM_SUB_PATH (или оставить false)
# 3. Создать Ingress в namespace grafana с host grafana.kube5s.ru + TLS
# 4. Удалить этот файл
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: grafana
+11
View File
@@ -0,0 +1,11 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: grafana-storage
namespace: grafana
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 2Gi
+9
View File
@@ -0,0 +1,9 @@
apiVersion: v1
kind: Secret
metadata:
name: grafana-admin
namespace: grafana
type: Opaque
stringData:
admin-user: admin
admin-password: "GrafanaAdmin2026!"
+37
View File
@@ -0,0 +1,37 @@
apiVersion: v1
kind: Service
metadata:
name: grafana
namespace: grafana
spec:
selector:
app: grafana
ports:
- port: 3000
targetPort: 3000
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: grafana
namespace: grafana
annotations:
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
cert-manager.io/cluster-issuer: "letsencrypt-prod"
spec:
ingressClassName: nginx
tls:
- hosts:
- grafana.kube5s.ru
secretName: grafana-tls
rules:
- host: grafana.kube5s.ru
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: grafana
port:
number: 3000
+339
View File
@@ -0,0 +1,339 @@
# Руководство: контракт функций в Fission runtime
Дата: 2026-05-19
Версия консоли: v1.3.94+
Источник: дизассемблирование реальных env-образов в кластере + отладка тестов
---
## Содержание
1. [PHP](#php)
2. [Node.js](#nodejs)
3. [Python](#python)
4. [Ruby](#ruby)
5. [Найденные баги и исправления](#баги)
---
## PHP
### Образ
```
ghcr.io/fission/php-env:latest
```
Сервер: `ReactPHP` на порту `:8888`, файл `/app/server.php`.
### Entrypoint
```
main.php::handler
```
Формат: `filename::functionName`. Двойное двоеточие — разделитель файла и функции.
### ⚠️ ГЛАВНЫЙ БАГ (2026-05-19)
**`return ['statusCode' => 200, 'body' => ...]` — PHP env ИГНОРИРУЕТ return value функции.**
Почему: server.php создаёт `$response = new Response()` (пустой PSR-7 объект),
вызывает функцию и возвращает этот же объект без чтения результата вызова:
```php
// server.php — реальный код PHP env:
if (function_exists($userFunction)) {
$response = new Response();
ob_end_clean();
$userFunction(['request' =>$request, 'response' => $response, 'logger' => $logger]);
return $response; // ← возвращает оригинальный объект, не результат вызова
}
```
### Правильный формат — PSR-7
Функция должна **писать** в `$ctx["response"]->getBody()`:
```php
<?php
function handler($ctx) {
$response = $ctx["response"];
$response->getBody()->write(json_encode([
'status' => 'ok',
'value' => 42,
]));
}
```
Доступные ключи контекста:
- `$ctx["request"]``Psr\Http\Message\ServerRequestInterface`
- `$ctx["response"]``Psr\Http\Message\ResponseInterface` (RingCentral\Psr7)
- `$ctx["logger"]``Monolog\Logger`
### Backwards compatibility (echo)
Если функция `handler` НЕ объявлена, PHP env перехватывает весь вывод через `ob_start()` и возвращает его как тело ответа:
```php
<?php
// Работает, но нет доступа к request/response/logger
echo json_encode(['key' => 'value']);
```
Использовать только для самых простых случаев без работы с запросом.
### Примеры
**Минимальный (PSR-7):**
```php
<?php
function handler($ctx) {
$ctx["response"]->getBody()->write("Hello from PHP");
}
```
**С JSON-ответом:**
```php
<?php
function handler($ctx) {
$result = ['sum' => 1 + 2, 'lang' => 'php'];
$ctx["response"]->getBody()->write(json_encode($result));
}
```
**С чтением тела запроса:**
```php
<?php
function handler($ctx) {
$body = json_decode((string)$ctx["request"]->getBody(), true);
$name = $body['name'] ?? 'World';
$ctx["response"]->getBody()->write("Hello, $name!");
}
```
**CPU-нагрузка:**
```php
<?php
function fib($n) {
if ($n < 2) return $n;
return fib($n-1) + fib($n-2);
}
function handler($ctx) {
$ctx["response"]->getBody()->write('result:' . fib(28));
}
```
### Что НЕ работает
```php
// ❌ НЕПРАВИЛЬНО — return игнорируется
function handler(array $ctx): array {
return ['statusCode' => 200, 'body' => 'hello'];
}
// ❌ НЕПРАВИЛЬНО — echo не работает если функция объявлена
function handler($ctx) {
echo "hello"; // ob_start уже закрыт перед вызовом
}
```
---
## Node.js
### Образ
```
ghcr.io/fission/node-env:latest
```
Сервер: Express на порту `:8888`, файл `/usr/src/app/server.js`.
### Entrypoint
```
main
```
**Без расширения `.js`.**
### ⚠️ ГЛАВНЫЙ БАГ (2026-05-19)
**`functionName = "main.js"` ломает специализацию.**
Почему: server.js делает `split(".")` для любого functionName содержащего точку:
```js
// server.js — реальный код node-env:
if (req.body.functionName && req.body.functionName.includes('.')) {
// ПРЕДПОЛАГАЕТСЯ формат: 'file.function'
const entrypoint = req.body.functionName.split(".");
filename = entrypoint[0]; // "main"
funcname = entrypoint[1]; // "js" ← ищет экспорт с именем "js"!
}
```
`"main.js"``filename="main"`, `funcname="js"` → после загрузки модуля ищет `module["js"]` → undefined → specialize 500.
**Правильно:** `functionName = "main"``funcname = undefined` → берёт `default export` = `module.exports`.
### Формат кода
Консоль оборачивает пользовательский код в CJS wrapper. Пользователь пишет:
```js
// Простая функция, экспортирует async handler:
module.exports = async function(ctx) {
return { status: 200, body: "Hello from Node.js" };
};
```
Или через именованный экспорт:
```js
async function handler(ctx) {
return { status: 200, body: "ok" };
}
module.exports = handler;
```
Формат возвращаемого объекта:
```js
{ status: 200, body: "строка или JSON" }
```
### Примеры
**Минимальный:**
```js
module.exports = async function(ctx) {
return { status: 200, body: "hello-node" };
};
```
**С JSON:**
```js
module.exports = async function(ctx) {
const result = { lang: "nodejs", fib25: 75025 };
return { status: 200, body: JSON.stringify(result) };
};
```
**CPU-нагрузка:**
```js
function fib(n) { return n < 2 ? n : fib(n-1) + fib(n-2); }
module.exports = async function(ctx) {
return { status: 200, body: "fib35:" + fib(35) };
};
```
---
## Python
### Образ
```
naeel/fission-python-env:v1.1
```
(НЕ использовать официальный `ghcr.io/fission/python-env` — не поддерживает `def main(event, context)`)
### Entrypoint
```
main.main
```
Формат: `module.function`.
### Формат функции
```python
def main(event, context):
return {
"status": 200,
"body": "Hello from Python"
}
```
### Примеры
```python
import json
def main(event, context):
result = {"lang": "python", "sum": sum(range(100))}
return {"status": 200, "body": json.dumps(result)}
```
---
## Ruby
### Образ
```
ghcr.io/fission/ruby-env:latest
```
### Entrypoint
```
handler
```
Имя метода в загруженном файле.
### Формат функции
```ruby
def handler(event, context)
{ statusCode: 200, body: "Hello from Ruby" }
end
```
### Примеры
```ruby
require 'json'
def handler(event, context)
result = { lang: "ruby", value: (1..10).reduce(:+) }
{ statusCode: 200, body: result.to_json }
end
```
---
## Баги
### БАГ A — ExpiryReaper race condition (исправлен в v1.3.93)
**Симптом:** PHP/последний язык в цикле CREATE теряет Package — удаляется ExpiryReaper'ом.
**Причина:** `activeFunctions` snapshot снимается ДО создания Function. Reaper проверяет orphan packages по устаревшему снимку → последний Package = orphan → удалён.
**Коммит:** `72e5d50`
**Файл:** `console/internal/cloud/tenant.go`
**Fix:** свежий LIST functions (`freshFunctions`) перед orphan-check вместо кешированного снимка.
---
### БАГ B — Node.js entrypoint `"main.js"` ломает specializeV2 (исправлен в v1.3.94)
**Симптом:** Node.js функция не специализируется, invoke timeout.
**Причина:** node-env `specializeV2` делает `split(".")` по любой точке в functionName. `"main.js"``funcname="js"` → ищет `module["js"]` → undefined → specialize 500 → бесконечный retry.
**Коммит:** `090a7af`
**Файл:** `console/internal/runtime/entrypoint.go`
**Fix:** entrypoint Node.js = `"main"` (без расширения). funcname=undefined → берёт `default export`.
---
### БАГ C — PHP `return array` игнорируется (исправлен в test_heavy.sh, v1.3.94)
**Симптом:** PHP invoke возвращает `status=200` но `response_raw=""`.
**Причина:** PHP env создаёт пустой `$response` объект, вызывает функцию, возвращает оригинальный объект — return value функции не читается.
**Коммит:** `042b1e5`
**Файл:** `scripts/test_heavy.sh`
**Fix:** PHP код в тестах переписан на PSR-7: `$ctx["response"]->getBody()->write(...)`.
---
## Сводная таблица: правильный контракт
| Язык | Entrypoint | Формат функции | return/write |
|--------|---------------------|-------------------------------------------------------|---------------------------|
| Python | `main.main` | `def main(event, context):` | `return {"status":200,...}`|
| Node.js| `main` | `module.exports = async function(ctx) {...}` | `return { status, body }` |
| PHP | `main.php::handler` | `function handler($ctx) {...}` | `$ctx["response"]->getBody()->write(...)` |
| Ruby | `handler` | `def handler(event, context)` | `{ statusCode: 200, body: ... }` |
+40
View File
@@ -0,0 +1,40 @@
# Bench Results — PV Storage — 2026-05-20
**RUNID:** 9250986
**Дата:** Wed May 20 07:23:0607:27:07 MSK 2026
**Storage:** PVC (PV)
**API:** https://fission.kube5s.ru/console/api
**SUB:** bench@bench.local
**Итог: PASS=20 FAIL=0 TOTAL=20**
## Таблица результатов
| LANG | SIZE | UPLOAD(ms) | COLD(ms) | HOT_AVG(ms) | STATUS |
|--------|---------|-----------|---------|------------|--------|
| python | tiny | 308 | 4384 | 771 | OK |
| python | small | 161 | 1947 | 1860 | OK |
| python | medium | 2751 | 1387 | 1183 | OK |
| python | large | 2764 | 1382 | 1184 | OK |
| nodejs | tiny | 3150 | 8011 | 1175 | OK |
| nodejs | small | 2764 | 1526 | 1198 | OK |
| nodejs | medium | 2745 | 2597 | 1185 | OK |
| nodejs | large | 2737 | 2603 | 1314 | OK |
| ruby | tiny | 3148 | 2105 | 1209 | OK |
| ruby | small | 2768 | 1339 | 1194 | OK |
| ruby | medium | 2759 | **81549** | 1183 | OK (аномалия cold, 3-й прогон подряд) |
| ruby | large | 2743 | 1390 | 787 | OK |
| php | tiny | 1547 | 2546 | 94 | OK |
| php | small | 904 | 1106 | 1213 | OK |
| php | medium | 2744 | 1571 | 1198 | OK ✓ FIXED |
| php | large | 2732 | 1508 | 1151 | OK ✓ FIXED |
| python | archive | 2759 | 1392 | 1178 | OK |
| nodejs | archive | 3177 | 1545 | 1197 | OK |
| ruby | archive | 2780 | 1376 | 1187 | OK |
| php | archive | 2785 | 1416 | 1172 | OK |
## Что было исправлено
PHP medium/large — заменены arrow functions `fn()=>` на традиционный синтаксис `function(){}`.
PHP env в кластере < 7.4, arrow functions добавлены в PHP 7.4.
Лог на VM: `/home/naeel/terra/fission/test-results/bench-func/bench_full_2026-05-20_07-23-06.log`
+89
View File
@@ -0,0 +1,89 @@
# Bench Results — S3 Storage (ngcloud msk-1) — 2026-05-20
**RUNID:** 9251970
**Дата:** Wed May 20 07:39:3007:43:27 MSK 2026
**Storage:** S3 (`sless-functions`, `s3.msk-1.ngcloud.ru`, region `msk-1`)
**API:** https://fission.kube5s.ru/console/api
**SUB:** bench@bench.local
**Итог: PASS=20 FAIL=0 TOTAL=20**
## Таблица результатов S3
| LANG | SIZE | UPLOAD(ms) | COLD(ms) | HOT_AVG(ms) | STATUS |
|--------|---------|-----------|---------|------------|--------|
| python | tiny | 3115 | 2453 | 621 | OK |
| python | small | 1360 | 889 | 481 | OK |
| python | medium | 1370 | 982 | 446 | OK |
| python | large | 2561 | 1471 | 1157 | OK |
| nodejs | tiny | 3348 | 8347 | 1210 | OK |
| nodejs | small | 2907 | 1528 | 1270 | OK |
| nodejs | medium | 2949 | 1576 | 1187 | OK |
| nodejs | large | 2747 | 2705 | 1146 | OK |
| ruby | tiny | 3145 | 2164 | 1190 | OK |
| ruby | small | 2959 | 1886 | 1151 | OK |
| ruby | medium | 2756 | **80568** | 120 | OK (аномалия, 4-й прогон подряд) |
| ruby | large | 1386 | 1652 | 1168 | OK |
| php | tiny | 3158 | 1845 | 1156 | OK |
| php | small | 2751 | 1565 | 1218 | OK |
| php | medium | 2676 | 1516 | 1143 | OK |
| php | large | 3146 | 1581 | 1190 | OK |
| python | archive | 2752 | 1463 | 1160 | OK |
| nodejs | archive | 3191 | 1832 | 1166 | OK |
| ruby | archive | 2771 | 1454 | 1297 | OK |
| php | archive | 2768 | 1589 | 1188 | OK |
Лог на VM: `/home/naeel/terra/fission/test-results/bench-func/bench_full_2026-05-20_07-39-30.log`
## Сравнение PV vs S3
PV RUNID=9250986 (2026-05-20 07:23) vs S3 RUNID=9251970 (2026-05-20 07:39).
| LANG | SIZE | UPLOAD PV | UPLOAD S3 | COLD PV | COLD S3 | HOT PV | HOT S3 |
|--------|---------|-----------|-----------|---------|---------|--------|--------|
| python | tiny | 308 | 3115 | 4384 | 2453 | 771 | 621 |
| python | small | 161 | 1360 | 1947 | 889 | 1860 | 481 |
| python | medium | 2751 | 1370 | 1387 | 982 | 1183 | 446 |
| python | large | 2764 | 2561 | 1382 | 1471 | 1184 | 1157 |
| nodejs | tiny | 3150 | 3348 | 8011 | 8347 | 1175 | 1210 |
| nodejs | small | 2764 | 2907 | 1526 | 1528 | 1198 | 1270 |
| nodejs | medium | 2745 | 2949 | 2597 | 1576 | 1185 | 1187 |
| nodejs | large | 2737 | 2747 | 2603 | 2705 | 1314 | 1146 |
| ruby | tiny | 3148 | 3145 | 2105 | 2164 | 1209 | 1190 |
| ruby | small | 2768 | 2959 | 1339 | 1886 | 1194 | 1151 |
| ruby | medium | 2759 | 2756 | 81549 | 80568 | 1183 | 120 |
| ruby | large | 2743 | 1386 | 1390 | 1652 | 787 | 1168 |
| php | tiny | 1547 | 3158 | 2546 | 1845 | 94 | 1156 |
| php | small | 904 | 2751 | 1106 | 1565 | 1213 | 1218 |
| php | medium | 2744 | 2676 | 1571 | 1516 | 1198 | 1143 |
| php | large | 2732 | 3146 | 1508 | 1581 | 1314 | 1190 |
| python | archive | 2759 | 2752 | 1392 | 1463 | 1178 | 1160 |
| nodejs | archive | 3177 | 3191 | 1545 | 1832 | 1197 | 1166 |
| ruby | archive | 2780 | 2771 | 1376 | 1454 | 1297 | 1297 |
| php | archive | 2785 | 2768 | 1416 | 1589 | 1172 | 1188 |
## Выводы
### Upload (загрузка архива в storagesvc)
- Мелкие файлы (tiny/small): S3 **медленнее** — python/tiny: 308→3115ms (+2807ms), python/small: 161→1360ms (+1199ms)
- Средние/крупные файлы: сопоставимо (±200ms)
- Причина: HTTP round-trip до S3 ngcloud vs локальный PVC (vcd-disk-ext4 тоже сетевой, но внутри кластера)
### Cold start (первый invoke)
- Python: S3 **быстрее** — small: 1947→889ms, medium: 1387→982ms
- Nodejs medium: S3 быстрее — 2597→1576ms
- PHP: сопоставимо (±50ms)
- Ruby small: S3 медленнее — 1339→1886ms
- Итог: cold start на S3 ≈ на PV, разброс в пределах погрешности кластера
### Hot avg (последующие invoke)
- Практически одинаково на обоих бэкендах (±100ms)
- Фетчер кэширует архив в pod — хранилище не влияет на hot path
### ruby/medium аномалия
- PV: 81549ms → S3: 80568ms — воспроизводится **4-й раз подряд** на разных бэкендах
- Подтверждает: аномалия не связана с типом хранилища, это поведение executor/poolmgr
### Вердикт
**S3 и PV практически эквивалентны по производительности** при нагрузке нашего масштаба.
Upload мелких файлов чуть медленнее на S3, cold start — сопоставим.
S3 предпочтительнее из-за простоты операций (нет PVC, нет проблем с PV provisioner).
+131
View File
@@ -1,5 +1,136 @@
# Progress Log # Progress Log
---
## 2026-05-19 (вечер) — Node.js require fix (v1.3.95) + суровые тесты
### Ветка: `feat/console-pv-storage` (git commit `fe8870a`)
---
### Исправленный баг: Node.js `require` недоступен внутри функции
**Симптом:** Любая Node.js функция, использующая `require(...)`, падала с:
```
ERR_MODULE_NOT_FOUND: Cannot find module '/userfunc/deployarchive/main'
```
**Реальная причина:** В `console/internal/runtime/nodejs.go` wrapper генерировался как:
```js
(new Function('module', 'exports', code))(__mod, __mod.exports);
```
`new Function` выполняется в изолированном глобальном scope — `require` там не существует.
Вызов `require('crypto')` внутри кода → `ReferenceError` → ESM fallback → `import('file:///userfunc/deployarchive/main')` без `.js` → ERR_MODULE_NOT_FOUND.
**Fix** (`console/internal/runtime/nodejs.go`):
```js
// было:
(new Function('module', 'exports', code))(__mod, __mod.exports);
// стало:
(new Function('module', 'exports', 'require', code))(__mod, __mod.exports, require);
```
**Также исправлено** (`console/internal/api/package.go`):
Regex `nodeJSWrapperPattern` обновлён — принимает оба формата (старый без `require` и новый с `require`), backward compatible.
**Unit-тест** (`console/internal/runtime/nodejs_test.go`):
Убраны устаревшие ESM-проверки, добавлены актуальные CJS-проверки.
**Образ:** `naeel/fission-console:v1.3.95`
---
### Прогоны `test_heavy.sh`
Два прогона подряд с теми же условиями (namespace `fission-c3511430abe5675b`):
| Секция | af2f674f | be04cc1b | Вывод |
|--------|---------|---------|-------|
| S1 CREATE (4 яз) | PHP 502 → FAIL | **4/4 PASS** | PHP 502 в первом — transient |
| S1 invoke "не стабилизировался" | FAIL×4 | FAIL×4 | баг теста — WAIT_SEC < cold start |
| S2 invoke "не стабилизировался" | FAIL×4 | FAIL×4 | то же |
| S3 BIG RESPONSE size=46/48 | FAIL×2 | FAIL×2 | баг теста — проверяет поле `raw` (truncated), не реальный ответ |
| S4 invoke "не стабилизировался" | FAIL×4 | FAIL×4 | баг теста — WAIT_SEC < cold start |
| S5 RAPID 12/12 | PASS | PASS | ✓ |
| S6 Python PARALLEL 5/5 | PASS | PASS | ✓ |
| S6 Node.js PARALLEL 5/5 | **2/5 FAIL** | **2/5 FAIL** | pool size=1 не хватает на 5 параллельных — стабильно |
| S6 Ruby PARALLEL 5/5 | PASS | PASS | ✓ |
| S6 PHP PARALLEL 5/5 | 1/5 FAIL | **PASS** | первый — transient |
| S7 Python SLOW(6s) | PASS | PASS | ✓ |
| S7 Node.js SLOW(6s) | PASS (TRY=2) | PASS (TRY=1) | ✓ require fix работает |
| S8 Python MEMORY(20MB) | PASS | PASS | ✓ |
| S8 Node.js MEMORY(20MB) | PASS | PASS | ✓ |
| **ИТОГ** | **PASS=29 FAIL=17** | **PASS=31 FAIL=15** | |
Логи: `test-results/2026-05-19_*` (два файла)
---
### Анализ стабильных FAIL=15 (оба прогона)
#### 1. S1+S2+S4 "не стабилизировался" (12 FAIL)
**Причина:** `WAIT_SEC` в `test_heavy.sh` меньше реального cold start.
Функции реально работают — проверено прямыми SSH-запросами через `sleep + curl`.
**Что нужно:** увеличить `WAIT_SEC` или добавить retry-loop с адекватным таймаутом.
#### 2. S3 BIG RESPONSE (2 FAIL)
**Причина:** тест проверяет `response_raw length` — это длина JSON-поля `raw` из ответа консоли.
Консоль обрезает `raw` до ~46-48 символов. Реальный ответ функции = 50KB (status=200 подтверждено).
**Что нужно:** либо фиксить тест (проверять Content-Length/реальный body), либо фиксить консоль (не обрезать `raw`).
#### 3. S6 Node.js PARALLEL 2/5 (1 FAIL)
**Причина:** pool size=1 (один pod). При 5 параллельных запросах 2 таймаутятся пока ждут специализацию нового пода.
Python/Ruby справляются 5/5 — у них специализация быстрее.
**Что нужно:** либо увеличить pool size в Node.js environment, либо уменьшить параллельность в тесте до 3.
---
### Что делать дальше с тестами
- [ ] Исправить `test_heavy.sh` S1/S2/S4: увеличить `WAIT_SEC` до 120-180s или добавить retry-poll
- [ ] Исправить `test_heavy.sh` S3: проверять реальный ответ, не поле `raw`
- [ ] Решить S6 Node.js PARALLEL: увеличить pool или снизить параллельность
---
## 2026-05-19 — PV Storage Migration + Fetcher v1.23.0
### Ветка: `feat/console-pv-storage` (git commit `2aadae3`, pushed)
**Задача:** Перевести storagesvc с S3 на PV (PersistentVolume), привести все образы к v1.23.0.
#### Что сделано
- `helm/fission-values.yaml``imageTag: v1.23.0`, `fetcher.image: naeel/fetcher`, `fetcher.imageTag: v1.23.0`, PV backend включён
- `naeel/fetcher:v1.23.0` — собран из `fission-src/cmd/fetcher/`, запушен в Docker Hub (отдельный бинарник, в `naeel/fission-bundle` не входит)
- `fission-storage-pvc` — Bound, 2Gi, `vcd-disk-ext4`, namespace `fission`; S3 полностью отключён
#### Баги найдены и задокументированы (`doc/report-2026-05-19-pv-migration-bugs.md`)
| # | Описание | Причина | Fix |
|---|----------|---------|-----|
| 1 | Helm upgrade откатил образы с v1.23.0 на v1.22.1 | `imageTag: v1.22.1` в values | Исправлено на v1.23.0 |
| 2 | Field manager конфликт при helm upgrade | fission-console патчит FISSION_RESOURCE_NAMESPACES | Перед upgrade: `kubectl delete deploy executor router -n fission` |
| 3 | Pool pods ImagePullBackOff — `FETCHER_IMAGE=fission/fetcher:v1.22.0` (не существует) | fetcher — отдельный образ, его нет в fission-bundle | Собрали `naeel/fetcher:v1.23.0` |
| 4 | Router JWT password сменился после helm upgrade | Новый secret | Console читает динамически — работает; CLI токены устарели |
#### Тест-прогон `run_all.sh` — PASS=8 FAIL=0 (319 сек)
| Скрипт | Результат | Кол-во проверок |
|--------|-----------|----------------|
| test_auth.sh | ✅ PASS | 14 |
| test_functions_crud.sh | ✅ PASS | 29 |
| test_ttl.sh | ✅ PASS | 10 |
| test_isolation.sh | ✅ PASS | 11 |
| test_update.sh | ✅ PASS | 10 |
| test_invoke.sh | ✅ PASS | 9 |
| test_linters.sh | ✅ PASS | 31 |
| test_stress.sh | ✅ PASS | 3 (8 параллельных пользователей) |
Лог: `test-results/2026-05-19_08-51-31.log`
---
## TODO (backlog) ## TODO (backlog)
### Биллинг / статистика для бухгалтерии ### Биллинг / статистика для бухгалтерии
+118
View File
@@ -0,0 +1,118 @@
# Отчёт: миграция S3→PV + баги helm upgrade — 2026-05-19
## Что делали
1. Бенчмарк 3 хранилищ (local-path PV, vcd-disk-ext4 PV, S3)
2. Переехали storagesvc с S3 на PV (vcd-disk-ext4, 2Gi)
3. Обнаружили что S3 фактически не использовался (все Package URL — http://storagesvc..., не s3://)
4. Миграция была no-op: перенесено=0, пропущено=44
---
## Баги и ошибки
### БАГ 1: helm upgrade откатил executor/router с v1.23.0 → v1.22.1
**Причина:** в `helm/fission-values.yaml` был прописан `imageTag: v1.22.1` вместо `v1.23.0`.
До helm upgrade executor/router жили вне helm (задеплоены через `kubectl set image`).
**Симптом:** консоль не показывала функции.
**Исправление:** изменён `imageTag: v1.23.0` в values, пересоздан executor/router.
---
### БАГ 2: field manager конфликт — fission-console vs helm
**Причина:** fission-console патчит `FISSION_RESOURCE_NAMESPACES` в executor и router через server-side apply.
Когда helm пытается обновить те же deployment-ы — конфликт field manager.
**Симптом:**
```
conflict with "fission-console" using apps/v1:
.spec.template.spec.containers[name="executor"].env[name="FISSION_RESOURCE_NAMESPACES"].value
```
**Обход:** перед `helm upgrade` удалять executor и router вручную:
```bash
kubectl delete deploy executor router -n fission
helm upgrade fission fission-charts/fission-all --version 1.22.1 \
-n fission -f helm/fission-values.yaml --no-hooks --timeout 180s
```
**Системное решение:** нужно чтобы fission-console не трогал deployments executor/router напрямую (FISSION_RESOURCE_NAMESPACES должен читаться из ConfigMap или CRD, а не инжектироваться в deployment env).
---
### БАГ 3: FETCHER_IMAGE = fission/fetcher:v1.22.0 — образ не существует
**Причина:** helm chart `fission-all:1.22.1` имеет дефолтное значение:
```yaml
fetcher:
image: fission/fetcher
imageTag: v1.22.0
```
Этот образ на Docker Hub не существует (fission переехал на ghcr.io).
До наших helm upgrade executor не управлялся helm — FETCHER_IMAGE либо не был задан (код падал на дефолт `ghcr.io/fission/fetcher`), либо был задан правильно вручную.
После helm upgrade — chart прописал неправильный FETCHER_IMAGE.
**Симптом:**
- Pool pods: `ImagePullBackOff` на контейнере `fetcher`
- `fission/fetcher:v1.22.0: not found` на Docker Hub
- Функции не запускаются (504 при invoke)
**Правильное значение:** `naeel/fission-bundle:v1.23.0`
Fetcher — часть fission-bundle, отдельного образа нет.
Находится в: `fission-src/pkg/fetcher/`
**Неправильные ключи в values (игнорируются чартом):**
```yaml
fetcherImage: naeel/fission-bundle # ← НЕПРАВИЛЬНО, такого ключа нет
fetcherImageTag: v1.23.0 # ← НЕПРАВИЛЬНО
```
**Правильные ключи:**
```yaml
fetcher:
image: naeel/fission-bundle # ← ПРАВИЛЬНО
imageTag: v1.23.0 # ← ПРАВИЛЬНО
```
Шаблон чарта (`charts/fission-all/templates/_helpers.tpl`):
```
{{- define "fetcherImage" -}}
{{- $args := list (.Values.fetcher.repository | default .Values.repository) .Values.fetcher.image .Values.fetcher.imageTag -}}
```
---
### БАГ 4: router JWT пароль сменился при helm upgrade
**Причина:** helm upgrade пересоздал secret `router` с новым паролем.
| До | После |
|----|-------|
| `slxGj3G3FAD7l5ms2tJ9` | `58oKMuBIvmBBXHXqaLHw` |
fission-console читает пароль из secret динамически (`valueFrom: secretKeyRef`) — поэтому сам работает.
CLI-инструменты с сохранённым токеном требуют перегенерации токена.
---
## Текущее состояние (на момент отчёта)
| Компонент | Образ | Статус |
|-----------|-------|--------|
| executor, router, buildermgr, storagesvc, timer, webhook, kubewatcher | `naeel/fission-bundle:v1.23.0` | ✅ Running |
| fission-console | `naeel/fission-console:v1.3.92` | ✅ Running |
| FETCHER_IMAGE (env в executor) | `fission/fetcher:v1.22.0` | ❌ НЕПРАВИЛЬНО |
| fission-storage-pvc | 2Gi, vcd-disk-ext4, Bound | ✅ OK |
## Что нужно сделать
1. Исправить `helm/fission-values.yaml` — заменить `fetcherImage`/`fetcherImageTag` на правильную секцию `fetcher:`
2. Сделать helm upgrade (с предварительным удалением executor/router)
3. Проверить что pool pods поднимаются без ImagePullBackOff
4. Проверить что функции nubes1 (`a1`, `c1`) запускаются
+161
View File
@@ -0,0 +1,161 @@
# Анализ бенчмарка bench_full.sh — 2026-05-20
## Контекст
Запущен полный бенчмарк `bench-func/bench_full.sh` (RUNID=9248232).
Namespace: `fission-08f114ea3595cccf` (sub: bench@bench.local).
4 языка × 4 размера (tiny/small/medium/large) + 4 архивных теста = 20 итого.
---
## Результаты bench_full.sh
| LANG | SIZE | UPLOAD(ms) | COLD(ms) | HOT_AVG(ms) | STATUS |
|--------|---------|-----------|---------|------------|----------------|
| python | tiny | 2529 | 3317 | 1198 | OK |
| python | small | 2764 | 1387 | 1182 | OK |
| python | medium | 2762 | 1374 | 1185 | OK |
| python | large | 2769 | 1366 | 1653 | OK |
| nodejs | tiny | 3158 | 9410 | 1172 | OK (аномалия*) |
| nodejs | small | 2751 | 1559 | 1193 | OK |
| nodejs | medium | 2754 | 2471 | 1158 | OK |
| nodejs | large | 2745 | 1583 | 1190 | OK |
| ruby | tiny | 3142 | 3255 | 1162 | OK |
| ruby | small | 2737 | 1402 | 1181 | OK |
| ruby | medium | 3151 | **80451** | 115 | OK (аномалия!) |
| ruby | large | 1116 | 2448 | 104 | OK |
| php | tiny | 1137 | 2528 | 1200 | OK |
| php | small | 2747 | 1469 | 1158 | OK |
| php | medium | 2749 | — | — | **COLD-TIMEOUT** |
| php | large | 2725 | — | — | **COLD-TIMEOUT** |
| python | archive | 2749 | 1403 | 1176 | OK |
| nodejs | archive | 2777 | 1539 | 535 | OK |
| ruby | archive | 1367 | 821 | 787 | OK |
| php | archive | 2744 | 1575 | 1189 | OK |
**ИТОГ: PASS=18, FAIL=2**
*nodejs/tiny cold=9410ms — одиночный выброс, не воспроизводился.
---
## Аномалия: ruby/medium cold=80451ms
### Анализ
Проведено ×6 независимых прогонов (diag_bugs.sh, два запуска):
- 3780 / 1402 / 1384 / 3225 / 1435 / 1773 ms
**Вывод:** не воспроизводится. Разовый выброс (вероятно node pressure или transient network).
**Статус: ЗАКРЫТ. Не баг.**
---
## Баг: PHP medium + large COLD-TIMEOUT
### Первичная гипотеза (верна)
`bench_full.sh` генерирует PHP код для medium/large с arrow functions (PHP 7.4+):
```php
// medium
array_map(fn($s) => strrev(strtoupper($s)), $words)
array_map(fn($x) => ($x - $m) ** 2, $data)
// large
array_map(fn($i) => array_map(fn($j) => ..., ...), ...)
```
PHP env на кластере — версия **< 7.4**. Arrow syntax → Parse error → pod возвращает 500.
PHP tiny/small и PHP archive — используют традиционный синтаксис без `fn()=>` → OK.
### Механизм pool exhaustion (важно!)
При invoke сломанной функции:
1. Executor берёт pool pod → специализирует под функцию
2. PHP парсер: Parse error → возвращает 500
3. Executor считает это ошибкой вызова → ретраит следующий pod
4. 20 ретраев × 5s = 100s → COLD TIMEOUT
5. За это время пул (`console-php-env`) в namespace исчерпан
**Следствие:** php/large идёт ПОСЛЕ php/medium. К моменту его запуска пул уже разрушен → php/large тоже COLD TIMEOUT даже если бы его код был правильным.
### Диагностика diag_bugs.sh — что мы делали
Создан скрипт `bench-func/diag_bugs.sh` с 6 тестами:
1. `php-arrow-min` — минимальный PHP с `fn($x)=>$x*2`
2. `php-version` — только `return PHP_VERSION` (стандартный PHP)
3. `php-med-noarr` — medium логика через foreach вместо arrow
4. `ruby-med-1/2/3` — ruby/medium ×3 независимых функции
### Ошибки в процессе диагностики
**Ошибка 1: SIGPIPE при `| tee`**
Первый прогон завершился досрочно — скрипт через SSH pipe убивался SIGPIPE при прокрутке/чтении терминала. Исправлено: nohup + прямая запись в файл.
**Ошибка 2: 5 параллельных прогонов diag_bugs.sh**
В процессе диагностики случайно накопилось 5+ одновременных процессов `diag_bugs.sh` на VM (PIDs: 2476997, 2478955, 2480445, 2482045, 2482452). Все конкурировали за PHP pool → дополнительный pool exhaustion.
**Следствие:** тесты `php-version` и `php-med-noarr` тоже дали COLD TIMEOUT — не из-за собственного бага, а из-за занятого пула.
### Что реально подтверждено vs что предполагается
| Факт | Статус |
|------|--------|
| `fn()=>` вызывает COLD TIMEOUT | **ПОДТВЕРЖДЕНО** (×2 прогона) |
| PHP env версия < 7.4 | **ПРЕДПОЛОЖЕНИЕ** (php-version не дал ответа из-за pool exhaustion) |
| php-version FAIL = самостоятельный баг | **ОПРОВЕРГНУТО** — следствие pool exhaustion |
| php-med-noarr FAIL = PHP env broken | **НЕ ПОДТВЕРЖДЕНО** — тест не прошёл чисто |
### Что НЕ удалось выяснить
- Точная версия PHP в env образе
- Работает ли `php-med-noarr` (medium без arrow) в изолированном тесте
---
## Механизм pool exhaustion — общая проблема
Одна функция с синтаксической ошибкой может "заморозить" весь PHP пул namespace:
1. Сломанная функция → 20 ретраев × пул pod
2. Пул исчерпан → **все остальные PHP функции того же пользователя** получают COLD TIMEOUT
3. Пользователь видит что "рабочая" функция сломалась — не понимает почему
В реальном проде это критично: один неосторожный деплой = все PHP функции пользователя не отвечают.
### Почему pod "держится"
Pool pod ≠ выполнение кода. Pod — контейнер-заготовка. При invoke:
- Executor специализирует pod (загружает код через /specialize)
- PHP парсер падает → 500 → executor ретраит СЛЕДУЮЩИЙ pod
- Каждый ретрай = новый pod из пула
Pod не "держится" в одном — executor расходует пул по одному pod'у за ретрай.
### Системные решения (не реализованы)
1. **Circuit breaker в executor** — после N провалов подряд для одной функции → вернуть ошибку, не трогать пул
2. **Синтаксическая валидация при upload**`php -l`, `python3 -m py_compile`, `ruby -c`, `node --check` — сломанный код не попадает в систему
3. **Pool size limit per namespace** — ограничить max pods на env × namespace
Вариант 2 (валидация при upload) — реализуем в нашем коде (fission-console), остальные — в fission-bundle.
---
## Предложения — что делать
### Приоритет 1 (быстро): исправить bench_full.sh
Заменить arrow functions в PHP medium/large на традиционный синтаксис.
- `fn($s) => strrev(strtoupper($s))``function($s){ return strrev(strtoupper($s)); }`
- `fn($x) => ($x - $m) ** 2` → foreach цикл
- Аналогично для large (вложенные array_map)
После фикса запустить bench_full.sh заново — должно быть PASS=20.
### Приоритет 2 (чистая диагностика): проверить php-med-noarr
Убить фоновые процессы на VM, запустить одиночный тест php-version/php-med-noarr в изоляции — убедиться что PHP env работает для стандартного кода.
### Приоритет 3 (системный): валидация кода при upload
Добавить в fission-console синтаксическую проверку кода перед созданием функции. Защита от pool exhaustion для всех пользователей.
---
## Файлы
- `bench-func/bench_full.sh` — основной бенчмарк (требует фикс PHP medium/large)
- `bench-func/diag_bugs.sh` — диагностический скрипт (создан в этой сессии)
- `test-results/bench-func/` — логи прогонов
+275
View File
@@ -0,0 +1,275 @@
# Анализ репозитория fission-console
Составлен: 2026-05-21. Основан только на чтении исходного кода + истории git.
---
## Что это
**Web UI + API proxy** для управления Fission (Kubernetes-native serverless).
Написан на Go. Работает внутри K8s-кластера. Пользователи аутентифицируются
через Deck API (облачный SSO).
## Архитектура — поток данных
```
Browser (SPA) → Console API → K8s API (CRD — dynamic client)
→ Fission Router (invoke proxy — /fission-function)
→ storagesvc (архивы → S3 ngcloud msk-1, бакет sless-functions)
→ Deck API (auth — prod/dev/test)
→ PostgreSQL (billing invocations)
→ Grafana (дашборды — по одному Org на namespace)
```
### Пакеты
| Пакет | Роль |
|---|---|
| `cmd/server/` | Точка входа: парсинг env vars, создание зависимостей, запуск HTTP |
| `internal/api/` | **Ядро**: все HTTP-хендлеры (29 файлов) |
| `internal/auth/` | Аутентификация: Deck API + Demo (test fallback) |
| `internal/cloud/` | Namespace lifecycle: квата, NetworkPolicy, одноразовый создаватель |
| `internal/fission/` | Константы GVR + SetupFissionNamespace + EnsureEnvironment |
| `internal/model/` | Общие типы: CreateFunctionRequest, LangEnvMap |
| `internal/runtime/` | Упаковка кода: zip, ESM-обёртка NodeJS, python/phh/ruby/go |
| `internal/billing/` | Статистика: PostgreSQL (NoopStore если BILLING_DSN пуст) |
| `internal/stats/` | Grafana: Organization/dashboard per namespace |
| `ui/` | Embed SPA (index.html), отдельный /cron/ UI |
---
## Фичи — детальный разбор
### 1. Аутентификация
**Файлы:** `auth.go`, `middleware.go`, `handlers.go` (строка handleAuth)
- `POST /console/api/auth` — тело `{token, env}`. Валидация через Deck API.
Возвращает `{ok, env, namespace, email}`.
- **Middleware** — каждый приватный роут → `Authenticator.Authenticate(token, env)`.
Без токена → 401.
- **Namespace** = `"fission-"` + hex(SHA256(sub)[:8]). Для test-пользователей:
`"fission-test-"` + hex(SHA256(sub)[:8]).
- **DeckAuthenticator** — HTTP-запрос к `deck-api-{env}.ngcloud.ru/api/v1/user`.
Кеш в `sync.Map` (ключ = `env:token`, значение = expiry time). Таймаут 5s.
- **DemoAuthenticator** — для тестовых стендов: sub = email = token, без проверки.
- **MultiAuthenticator** — последовательно пробует JWT → Demo.
### 2. Управление Namespace
**Файлы:** `cloud/tenant.go`, `cloud/quota.go`, `cloud/network.go`
- **EnsureUserNS** — единая точка входа. Singleflight (параллельные запросы
для одного NS ждут на chan). Семафор: не более 3 одновременных созданий.
In-memory кэш уже созданных NS.
- **Что создаётся при первом запросе пользователя:**
- Namespace с лейблами `managed-by=fission-console`, `fission.io/managed=true`
- ServiceAccounts: `fission-fetcher`, `fission-builder`
- RoleBindings: для executor, router, buildermgr, kubewatcher, timer, fetcher, builder
- ResourceQuota (из env vars, по умолчанию: CPU req 1/lim 12, mem 2Gi/6Gi, pods 30, functions 20)
- LimitRange (дефолтный CPU 500m, mem 256Mi на контейнер)
- NetworkPolicy `deny-cross-tenant` — ingress только из своего NS, fission NS, kube-system
- **ExpiryReaper** — горутина в main, раз в 5 минут (REAPER_INTERVAL). Удаляет
функции с аннотацией `expires_at` в прошлом.
- **NSReconciler** — сверяет FISSION_RESOURCE_NAMESPACES.
### 3. CRUD функций
**Файлы:** `function_code.go`, `function_archive.go`, `function_crud.go`,
`function_clone.go`, `storagesvc.go`
#### Создание из кода (POST /console/api/functions, JSON)
- Тело: `{name, language, code, deps?, entrypoint?, route?, methods?, timeout?, ttl?}`
- **Валидация:** имя `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`, ≤57 символов, код ≤1MB.
- **Языки:** python, nodejs, php, ruby, go.
- **Порядок:** Package CRD → Function CRD → HTTPTrigger CRD. При ошибке откат.
- **Типы пакетов:**
- Python: zip с кодом + опционально requirements.txt (если deps не пуст)
- NodeJS: ESM-wrapped zip (package.json + main.js с `__invoke` dispatcher)
- PHP: zip с `main.php` + опционально composer.json
- Ruby: zip с `handler.rb` + опционально Gemfile
- Go: source package (builder компилирует .so плагин)
- **Загрузка:** через storagesvc → S3 (ngcloud msk-1, бакет `sless-functions`).
Fallback на type:literal если storagesvcURL пуст.
- **TTL:** парсит `1h`, `30m`, `7d`, `24h`. Ставит аннотацию `expires_at` на
Function CRD в `metadata.annotations`.
- **Env vars:** сохраняются в аннотации `fission-console/env-vars` (JSON).
#### Создание из архива (POST /console/api/functions, multipart/form-data)
- Поле формы: `archive` (zip-файл).
- Проверка magic bytes: `PK` (0x50 0x4B).
- Размер ≤32MB.
- Загружается в storagesvc как есть, без модификации.
- Аннотация `fission-console/source-type: "archive"`.
- Поля формы: `name, language/environment, entrypoint, route, methods, timeout, ttl`.
#### GET функции (GET /console/api/functions/:name)
- Возвращает: name, namespace, environment, package, entrypoint, timeout,
created_at, updated_at, code, deps, source_type, archive_filename, route,
methods, env_vars, raw CRD.
- **Извлечение кода:** пробует source.literal → deployment.literal → URL
(через storagesvc download для type:url). Для NodeJS распознаёт и анрапает
ESM-обёртку (`decodeArchiveBytesToSource` в `package.go`).
#### Обновление кода (PUT /console/api/functions/:name/code)
- Тело: `{code, deps?, timeout?}`
- Создаёт **новый Package** (новое имя = cache miss в executor). Старый удаляется.
- Та же сборка архива что и при создании (buildDeployArchive).
#### Обновление архива (PUT /console/api/functions/:name/archive)
- multipart/form-data, поле `archive`. Загружается в storagesvc, новый Package.
#### Клонирование (POST /console/api/functions/:name/clone)
- Тело: `{new_name, route?}`
- Для type:literal — копирует base64 literal в новый Package.
- Для type:url — скачивает архив из storagesvc → заливает новый → новый Package.
- Создаёт новый Function + HTTPTrigger. Для Go копирует source-пакет.
- При ошибке откатывает созданные объекты.
#### DELETE (DELETE /console/api/functions/:name)
- Каскадное: удаляет HTTPTrigger → Package CRD → удаляет архив из storagesvc
(DELETE /v1/archive?id=... через storagesvc).
- Проверяет что функция реально удалена из K8s.
#### Timeout, Logs, EnvVars
- `PUT /functions/:name/timeout` — патчит только `spec.functionTimeout`
(без замены кода/архива).
- `GET /functions/:name/logs` — читает логи пода функции через K8s API
(rest client, `pod/log`).
- `GET /functions/:name/envvars` / `PUT /functions/:name/envvars` — аннотация
`fission-console/env-vars`.
### 4. Invoke (вызов функций)
**Файл:** `function_invoke.go`
- **`POST /console/api/functions/:name/invoke`** — для UI. Проксирует через
Fission Router. URL: `routerURL/fission-function/<ns>/<name>`.
Ответ: `{status, body, headers, cold_start, response_raw, pod, duration_ms}`.
Записывает в billing.
- **`/fn/<route>`** — публичный gateway. Строит URL по маршруту, проксирует
тело/заголовки (кроме служебных). Таймаут из `spec.functionTimeout`.
- **`/fission-function/<ns>/<name>`** — внутренний gateway для cron/timer/webhook.
- JWT-токен router: ServiceAccount token из `/var/run/secrets/...` (или SA_TOKEN_PATH),
кешируется на 12ч в `sync.Mutex`.
### 5. Триггеры
**Файлы:** `timetriggers.go`, `mqtriggers.go`, `kwtriggers.go`
- **HTTP Triggers** — создаются автоматически при создании функции.
Чтение списка: GET `/console/api/httptriggers`.
- **Time Triggers** — CRUD через `/console/api/timetriggers`.
Поля: `name, functionName, cron, method, subpath`. Создаются как
`fission.io/v1 TimeTrigger` CRD.
- **MQ Triggers** — `/console/api/mqtriggers`. Поля: `name, functionName,
queue, sqsEndpoint, accessKey, secretKey`. Создаёт Deployment (sqs-consumer)
+ Secret с credentials в NS пользователя. sqs-consumer поллит SQS →
вызывает функцию через JWT-authenticated router.
- **Kubernetes Watch Triggers** — `/console/api/kwtriggers`. Поля:
`name, functionName, resourceType, namespace, labelSelector`.
### 6. AI/Lint фичи
**Файлы:** `ai_check.go`, `lint_archive.go`, `explain_archive.go`, `ai_prompts.go`
- **`POST /ai/check`** — синтаксическая проверка кода. Реальные линтеры
(не LLM): `node --check`, `python3 -m py_compile`, `ruby -c`, `php -l`,
Go через `go/parser` прямо в процессе.
- **`POST /ai/lint-archive`** — принимает zip, линтит каждый файл с
известным расширением. Защита от zip bomb: max 100KB архив, max 100KB
распакованных. Опционально: проверка entrypoint и соответствия языка.
- **`POST /ai/explain-archive`** — извлекает код из архива (max 20KB),
отправляет в LLM через OpenRouter API с вопросом "что делает?".
- **`POST /ai/ask`** — chat/генерация кода через LLM.
4 режима codegen: генерация с нуля, объяснение, исправление ошибки,
универсальный. Плюс explain и chat режимы.
- **Prompts** (`ai_prompts.go`) — шаблоны для каждого языка с сигнатурами
функций (Python: `def main(event, context)`, NodeJS: `module.exports`,
PHP: `function handler(...)`, Ruby: `def handler(context)`,
Go: `func Handler(...)`).
### 7. Billing (статистика)
**Файлы:** `billing/billing.go`, `billing/pg.go`, `billing/noop.go`,
`billing/factory.go`
- **Интерфейс Store** — `RecordInvocation(Invocation)`. Асинхронный,
fire-and-forget.
- **pgStore** — PostgreSQL через pgxpool. Таблица `invocations`.
Пулы: max 4 conn, min 1. Каждая запись в своей горутине.
- **NoopStore** — если `BILLING_DSN` не задан или не удалось подключиться —
статистика молча отключена.
- **Поля Invocation:** Namespace, FunctionName, TriggerType (http/cron/console/event),
Route, HTTPMethod, StartedAt, DurationMS, StatusCode, ColdStart,
RequestBytes, ResponseBytes, ErrorMsg, RecordedBy ("console"/"router"),
EventType (create/delete/clone/update/invoke).
- **Где записывается:** handleInvokeFunction, handleInvokeRoute,
handleFissionFunctionGateway, handleCreateFunction, handleDeleteFunction,
handleUpdateFunctionCode, handleCloneFunction.
### 8. Stats / Grafana
**Файлы:** `stats/grafana.go`, `stats/factory.go`, `stats/noop.go`,
`stats/provider.go`
- **GrafanaProvider** — для каждого namespace создаёт Grafana Organization
(имя = namespace), PostgreSQL datasource (тот же DSN что billing,
uid="fission-user-pg"), hardcoded dashboard (SQL: WHERE namespace='...'),
public dashboard link (accessToken).
- **Кеш:** `sync.RWMutex` + per-namespace кеш orgID и accessToken.
Идемпотентно — если Org уже есть, повторно не создаётся.
- **NoopProvider** — если `GRAFANA_INTERNAL_URL` не задан.
- **Провизия:** вызывается в `handleAuth` (fire-and-forget горутина),
и в `handleStatsDashboard`.
### 9. UI
**Файлы:** `ui/embed.go`, `ui/cron.go`
- **Embedded SPA** — `//go:embed index.html`. Раздаётся на `/console/`.
- **Cron UI** — отдельный handler для `/cron/` (Cron метрики, статика).
- **CORS:** `Access-Control-Allow-Origin: *` (все операции защищены токеном,
не cookie).
- **Security headers:** nosniff, X-Frame-Options DENY, CSP (default-src 'self'),
Referrer-Policy strict-origin-when-cross-origin, Permissions-Policy (нет камере/микрофону).
### 10. Мониторинг
- `/health`, `/console/health` — `ok\n`
- `/console/api/ns/status` — статус namespace (существует, кол-во функций)
- `/console/api/ns/debug` — дебаг-инфо (NS, user, labels)
- `/console/api/stats/dashboard-url` — возвращает Grafana public dashboard URL
---
## Эволюция storagesvc (история git)
- **До 19 мая 2026:** S3 (изначальная архитектура)
- **19 мая:** `3eb4f56 feat: PV storage backend` — перешли на PVC (временное решение)
- **20 мая:** `7539680 chore: switch storagesvc to S3 backend (ngcloud msk-1)` —
вернулись на S3. PV-конфиг сохранён в `helm/fission-values-pv.yaml` для быстрого
отката.
- **Текущее состояние (21 мая):** S3, бакет `sless-functions`, endpoint
`https://s3.msk-1.ngcloud.ru`. Ветка `feat/s3-replicated`.
---
## Чего нет
- **Нет аутентификации через cookie** — только токен в заголовке.
- **Нет rate limiting** — только ResourceQuota на уровне K8s.
- **Нет WebSocket** — только REST.
- **Нет аудит-лога** — billing таблица не immutable, можно переписать.
- **Нет swagger/OpenAPI** — документация только в doc/.
- **Нет gRPC** — только HTTP/JSON.
- **Нет метрик Prometheus** — только billing в PostgreSQL.
- **Нет CI/CD** — тесты запускаются вручную.
+3
View File
@@ -0,0 +1,3 @@
<svg width="126mm" height="91mm" viewBox="0 0 126 91" version="1.1" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="63" cy="45.5" rx="63" ry="45.5" fill="#cccccc" stroke="#222222" stroke-width="1" />
</svg>

After

Width:  |  Height:  |  Size: 210 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="210mm" height="297mm" viewBox="0 0 210 297" version="1.1" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="105" cy="148.5" rx="63" ry="45.5" fill="#cccccc" stroke="#222222" stroke-width="1" />
</svg>

After

Width:  |  Height:  |  Size: 214 B

+105
View File
@@ -0,0 +1,105 @@
#!/bin/bash
# Fission storage benchmark: local-path PV / vcd-disk-ext4 PV / S3
# Каждый вызов = полный путь: curl → Fission Router → Executor → Pod → Storage
set -e
N=${1:-10} # число итераций, по умолчанию 10
OUTDIR="$(cd "$(dirname "$0")/../../test-results" 2>/dev/null && pwd || echo /tmp)"
OUTFILE="$OUTDIR/bench-storage-$(date +%Y-%m-%d_%H-%M-%S).txt"
mkdir -p "$OUTDIR"
# Port-forward router
kubectl port-forward svc/router 8889:80 -n fission &>/tmp/pf-bench.log &
PF_PID=$!
trap "kill $PF_PID 2>/dev/null" EXIT
sleep 3
ROUTER="http://localhost:8889"
# JWT auth
PASSWORD=$(kubectl get secret router -n fission -o jsonpath="{.data.password}" | base64 -d)
USERNAME=$(kubectl get secret router -n fission -o jsonpath="{.data.username}" | base64 -d)
TOKEN=$(curl -s -X POST "$ROUTER/auth/login" \
-H "Content-Type: application/json" \
-d "{\"username\":\"$USERNAME\",\"password\":\"$PASSWORD\"}" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['accesstoken'])")
if [ -z "$TOKEN" ]; then
echo "ERROR: не удалось получить JWT токен" >&2
exit 1
fi
AUTH="-H \"Authorization: Bearer $TOKEN\""
# Функция запуска N итераций и сбора статистики
bench_endpoint() {
local LABEL="$1"
local URL="$2"
local times=()
local errors=0
echo ""
echo "=== $LABEL ==="
printf "%-5s %-12s %s\n" "iter" "ms" "response"
for i in $(seq 1 $N); do
START=$(date +%s%N)
RESP=$(curl -s --max-time 60 -H "Authorization: Bearer $TOKEN" "$URL" 2>/dev/null)
END=$(date +%s%N)
MS=$(( (END - START) / 1000000 ))
if echo "$RESP" | python3 -c "import sys,json; d=json.load(sys.stdin); assert d['status']=='ok'" 2>/dev/null; then
STATUS="ok"
INNER=$(echo "$RESP" | python3 -c "import sys,json; d=json.load(sys.stdin); k=list(d.keys()); print(' '.join(f'{k}={d[k]}' for k in k if k not in ['status']))" 2>/dev/null)
else
STATUS="ERR"
INNER="$RESP"
(( errors++ )) || true
fi
times+=($MS)
printf "%-5s %-12s %s\n" "$i" "${MS}ms" "$STATUS $INNER"
done
# Статистика
local sum=0 min=999999999 max=0
for t in "${times[@]}"; do
sum=$((sum + t))
[ $t -lt $min ] && min=$t
[ $t -gt $max ] && max=$t
done
local avg=$((sum / N))
local ok=$((N - errors))
echo "---"
printf " Успешно: %d/%d | min=%dms avg=%dms max=%dms\n" "$ok" "$N" "$min" "$avg" "$max"
}
# Заголовок отчёта
{
echo "============================================================"
echo " Fission Storage Benchmark"
echo " Дата: $(date)"
echo " Итераций: $N на endpoint"
echo " Стек: curl → Router → Executor → Pod → Storage"
echo "============================================================"
bench_endpoint "local-path PV (100Mi, rawfile CSI, WaitForFirstConsumer)" \
"$ROUTER/check/local"
bench_endpoint "vcd-disk-ext4 PV (10Gi, VMware Cloud Director)" \
"$ROUTER/check/vcd"
bench_endpoint "S3 ngcloud (s3.msk-1.ngcloud.ru, bucket=sless-functions)" \
"$ROUTER/check/s3"
echo ""
echo "============================================================"
echo " Завершено: $(date)"
echo "============================================================"
} | tee "$OUTFILE"
echo ""
echo "Результаты записаны: $OUTFILE"
@@ -0,0 +1,31 @@
import os
import time
def main(event, context):
path = "/mnt/data/check.txt"
test_data = "storage-check-ok"
try:
# Write
t0 = time.time()
with open(path, "w") as f:
f.write(test_data)
write_ms = round((time.time() - t0) * 1000, 2)
# Read
t0 = time.time()
with open(path, "r") as f:
result = f.read()
read_ms = round((time.time() - t0) * 1000, 2)
# Cleanup
os.remove(path)
if result == test_data:
return {"status": "ok", "write_ms": write_ms, "read_ms": read_ms, "mount": path}
else:
return {"status": "error", "detail": "data mismatch"}
except Exception as e:
return {"status": "error", "detail": str(e)}
@@ -0,0 +1,48 @@
import time
import boto3
from botocore.client import Config
def main(event, context):
# Credentials mounted by Fission at /secrets/<namespace>/<secretname>/<key>
try:
with open("/secrets/default/bench-s3-secret/access-key") as f:
access_key = f.read().strip()
with open("/secrets/default/bench-s3-secret/secret-key") as f:
secret_key = f.read().strip()
except Exception as e:
return {"status": "error", "detail": f"secret read: {e}"}
s3 = boto3.client(
"s3",
endpoint_url="https://s3.msk-1.ngcloud.ru",
aws_access_key_id=access_key,
aws_secret_access_key=secret_key,
config=Config(signature_version="s3v4"),
)
bucket = "sless-functions"
key = "storage-check/check.txt"
test_data = b"storage-check-ok"
try:
# Write
t0 = time.time()
s3.put_object(Bucket=bucket, Key=key, Body=test_data)
write_ms = round((time.time() - t0) * 1000, 2)
# Read
t0 = time.time()
result = s3.get_object(Bucket=bucket, Key=key)["Body"].read()
read_ms = round((time.time() - t0) * 1000, 2)
# Cleanup
s3.delete_object(Bucket=bucket, Key=key)
if result == test_data:
return {"status": "ok", "write_ms": write_ms, "read_ms": read_ms, "bucket": bucket}
else:
return {"status": "error", "detail": "data mismatch"}
except Exception as e:
return {"status": "error", "detail": str(e)}
@@ -0,0 +1,24 @@
#!/bin/bash
NS=default
echo "=== Removing HTTP triggers ==="
fission httptrigger delete --name check-local-t --namespace $NS 2>/dev/null || true
fission httptrigger delete --name check-vcd-t --namespace $NS 2>/dev/null || true
fission httptrigger delete --name check-s3-t --namespace $NS 2>/dev/null || true
echo "=== Removing functions ==="
fission fn delete --name check-local --namespace $NS 2>/dev/null || true
fission fn delete --name check-vcd --namespace $NS 2>/dev/null || true
fission fn delete --name check-s3 --namespace $NS 2>/dev/null || true
echo "=== Removing environment ==="
fission env delete --name bench-py --namespace $NS 2>/dev/null || true
echo "=== Removing PVCs ==="
kubectl delete pvc storage-check-local -n $NS 2>/dev/null || true
kubectl delete pvc storage-check-vcd -n $NS 2>/dev/null || true
echo "=== Removing secret ==="
kubectl delete secret bench-s3-secret -n $NS 2>/dev/null || true
echo "Done."
@@ -0,0 +1,49 @@
#!/bin/bash
set -e
cd "$(dirname "$0")"
NS=default
S3_KEY=0GLQRD38H4I6RBDB0EWJ
S3_SECRET=eTFibiHmBd96IApj9PYsboTR6OBoD7osxoarHykw
echo "=== PVCs ==="
kubectl apply -f pvc-local.yaml
kubectl apply -f pvc-vcd.yaml
echo "=== S3 Secret ==="
kubectl create secret generic bench-s3-secret \
--from-literal=access-key="$S3_KEY" \
--from-literal=secret-key="$S3_SECRET" \
-n $NS 2>/dev/null || echo "secret already exists"
echo "=== Environment (no resource limits to avoid quota) ==="
fission env create --name bench-py --image naeel/fission-python-env:v1.1 \
--mincpu 0 --maxcpu 0 --minmemory 0 --maxmemory 0 \
--poolsize 1 --namespace $NS 2>/dev/null || echo "env already exists"
echo "=== Functions ==="
# check-local и check-vcd используют newdeploy: 1 под = 1 PVC (RWO работает)
fission fn create --name check-local --env bench-py --code check_pv.py \
--executortype newdeploy --minscale 1 --maxscale 1 --namespace $NS 2>/dev/null || \
fission fn update --name check-local --code check_pv.py --namespace $NS
fission fn create --name check-vcd --env bench-py --code check_pv.py \
--executortype newdeploy --minscale 1 --maxscale 1 --namespace $NS 2>/dev/null || \
fission fn update --name check-vcd --code check_pv.py --namespace $NS
# check-s3 использует poolmgr
fission fn create --name check-s3 --env bench-py --code check_s3.py \
--namespace $NS --secret bench-s3-secret 2>/dev/null || \
fission fn update --name check-s3 --code check_s3.py --namespace $NS --secret bench-s3-secret
echo "=== Patching podspec (PVC mounts) ==="
kubectl patch function check-local -n $NS --type=merge -p "$(cat patch-local.json)"
kubectl patch function check-vcd -n $NS --type=merge -p "$(cat patch-vcd.json)"
echo "=== HTTP Triggers ==="
fission httptrigger create --name check-local-t --url /check/local --function check-local --method GET --namespace $NS 2>/dev/null || true
fission httptrigger create --name check-vcd-t --url /check/vcd --function check-vcd --method GET --namespace $NS 2>/dev/null || true
fission httptrigger create --name check-s3-t --url /check/s3 --function check-s3 --method GET --namespace $NS 2>/dev/null || true
echo ""
echo "Done! Жди ~60s (newdeploy pods + PVC binding), затем: ./test.sh"
@@ -0,0 +1,30 @@
apiVersion: fission.io/v1
kind: Function
metadata:
name: check-local
namespace: default
spec:
InvokeStrategy:
ExecutionStrategy:
ExecutorType: newdeploy
MinScale: 1
MaxScale: 1
StrategyType: execution
environment:
name: bench-py
namespace: default
package:
functionName: main.main
packageref:
name: check-local-7d087421-7802-4af1-b111-03d71fe738cd
namespace: default
podspec:
volumes:
- name: data
persistentVolumeClaim:
claimName: storage-check-local
containers:
- name: bench-py
volumeMounts:
- name: data
mountPath: /mnt/data
@@ -0,0 +1,30 @@
apiVersion: fission.io/v1
kind: Function
metadata:
name: check-vcd
namespace: default
spec:
InvokeStrategy:
ExecutionStrategy:
ExecutorType: newdeploy
MinScale: 1
MaxScale: 1
StrategyType: execution
environment:
name: bench-py
namespace: default
package:
functionName: main.main
packageref:
name: check-vcd-6e6076b2-2207-49f3-a12d-83fb50633285
namespace: default
podspec:
volumes:
- name: data
persistentVolumeClaim:
claimName: storage-check-vcd
containers:
- name: bench-py
volumeMounts:
- name: data
mountPath: /mnt/data
@@ -0,0 +1,25 @@
{
"spec": {
"podspec": {
"volumes": [
{
"name": "data",
"persistentVolumeClaim": {
"claimName": "storage-check-local"
}
}
],
"containers": [
{
"name": "bench-py",
"volumeMounts": [
{
"name": "data",
"mountPath": "/mnt/data"
}
]
}
]
}
}
}
@@ -0,0 +1,25 @@
{
"spec": {
"podspec": {
"volumes": [
{
"name": "data",
"persistentVolumeClaim": {
"claimName": "storage-check-vcd"
}
}
],
"containers": [
{
"name": "bench-py",
"volumeMounts": [
{
"name": "data",
"mountPath": "/mnt/data"
}
]
}
]
}
}
}
@@ -0,0 +1,12 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: storage-check-local
namespace: default
spec:
accessModes:
- ReadWriteOnce
storageClassName: local-path
resources:
requests:
storage: 100Mi
@@ -0,0 +1,12 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: storage-check-vcd
namespace: default
spec:
accessModes:
- ReadWriteOnce
storageClassName: vcd-disk-ext4
resources:
requests:
storage: 10Gi
@@ -0,0 +1,23 @@
#!/bin/bash
set -e
# Port-forward router to localhost:8888
kubectl port-forward svc/router 8888:80 -n fission &>/tmp/pf.log &
PF_PID=$!
trap "kill $PF_PID 2>/dev/null" EXIT
sleep 3
ROUTER="localhost:8888"
echo "Router via port-forward: $ROUTER"
echo ""
echo "=== [1/3] local-path ==="
curl -sf --max-time 30 "http://$ROUTER/check/local" | python3 -m json.tool || echo "ERROR: no response"
echo ""
echo "=== [2/3] vcd-disk-ext4 ==="
curl -sf --max-time 30 "http://$ROUTER/check/vcd" | python3 -m json.tool || echo "ERROR: no response"
echo ""
echo "=== [3/3] S3 (ngcloud) ==="
curl -sf --max-time 30 "http://$ROUTER/check/s3" | python3 -m json.tool || echo "ERROR: no response"
+51
View File
@@ -0,0 +1,51 @@
import os
import json
import psycopg2
def main(event, context):
pg_dsn = os.environ.get(
"PG_DSN",
"postgresql://super:BQUF5ruECa1ZFlq4wYt3gPJUEmtBMkA9QNK4MM5Sd8al4ArMDlmT16DIKHYBPyif"
"@postgresqlk8s-master.dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5.svc.cluster.local:5432"
"/sqsdb?sslmode=disable",
)
# Извлекаем тело сообщения из SQS (POST от sqs-consumer)
# event — Flask Request object: используем .data (bytes) или .get_json()
try:
data = event.get_json(force=True, silent=False)
except Exception:
raw = getattr(event, "data", None) or getattr(event, "body", b"")
if isinstance(raw, (bytes, bytearray)):
raw = raw.decode("utf-8")
data = json.loads(raw) if raw else {}
conn = psycopg2.connect(pg_dsn)
try:
cur = conn.cursor()
cur.execute(
"""
INSERT INTO weather_metrics
(city, country, temperature, feels_like, humidity,
pressure, wind_speed, description, recorded_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, to_timestamp(%s))
""",
(
data["city"],
data["country"],
data["temperature"],
data["feels_like"],
data["humidity"],
data["pressure"],
data["wind_speed"],
data["description"],
data["owm_timestamp"],
),
)
conn.commit()
cur.close()
finally:
conn.close()
return {"status": "ok", "city": data["city"], "temp": data["temperature"]}
@@ -0,0 +1 @@
psycopg2-binary==2.9.9
+92
View File
@@ -0,0 +1,92 @@
import os
import json
import time
import requests
import boto3
from botocore.config import Config
# Open-Meteo: бесплатный API без ключа, реальные данные.
# https://open-meteo.com/en/docs
CITIES = [
{"name": "Moscow", "country": "RU", "lat": 55.7558, "lon": 37.6173},
{"name": "London", "country": "GB", "lat": 51.5074, "lon": -0.1278},
{"name": "Paphos", "country": "CY", "lat": 34.7753, "lon": 32.4242},
{"name": "Ulyanovsk", "country": "RU", "lat": 54.3282, "lon": 48.3866},
{"name": "Santiago", "country": "CL", "lat": -33.4489, "lon": -70.6693},
]
# WMO weather code → описание
WMO_DESCRIPTIONS = {
0: "clear sky", 1: "mainly clear", 2: "partly cloudy", 3: "overcast",
45: "fog", 48: "icy fog", 51: "light drizzle", 53: "drizzle",
55: "heavy drizzle", 61: "light rain", 63: "rain", 65: "heavy rain",
71: "light snow", 73: "snow", 75: "heavy snow", 80: "rain showers",
81: "showers", 82: "violent showers", 95: "thunderstorm",
}
def fetch_city(city):
params = {
"latitude": city["lat"],
"longitude": city["lon"],
"current": "temperature_2m,apparent_temperature,relative_humidity_2m,surface_pressure,wind_speed_10m,weather_code",
"wind_speed_unit": "ms",
"timezone": "UTC",
}
resp = requests.get(
"https://api.open-meteo.com/v1/forecast",
params=params,
timeout=10,
)
resp.raise_for_status()
cur = resp.json()["current"]
code = cur.get("weather_code", 0)
return {
"city": city["name"],
"country": city["country"],
"temperature": round(cur["temperature_2m"], 1),
"feels_like": round(cur["apparent_temperature"], 1),
"humidity": int(cur["relative_humidity_2m"]),
"pressure": int(cur["surface_pressure"]),
"wind_speed": round(cur["wind_speed_10m"], 1),
"description": WMO_DESCRIPTIONS.get(code, f"wmo:{code}"),
"owm_timestamp": int(time.time()),
}
def main(event, context):
sqs_endpoint = os.environ.get(
"SQS_ENDPOINT", "http://shared-sqs.shared-sqs.svc.cluster.local:4100"
)
access_key = os.environ.get("SQS_ACCESS_KEY", "SSAK-a9964f2723bc6d347f48d153")
secret_key = os.environ.get(
"SQS_SECRET_KEY",
"2069e1ce05aaf94efe07aee18697352879e7626df239a9c71af0e9650b43bdd6",
)
queue_name = os.environ.get("SQS_QUEUE_NAME", "weather-data")
cfg = Config(signature_version="s3v4", s3={"addressing_style": "path"})
sqs = boto3.client(
"sqs",
endpoint_url=sqs_endpoint,
aws_access_key_id=access_key,
aws_secret_access_key=secret_key,
region_name="us-east-1",
config=cfg,
)
try:
queue_url = sqs.get_queue_url(QueueName=queue_name)["QueueUrl"]
except Exception:
queue_url = sqs.create_queue(QueueName=queue_name)["QueueUrl"]
results = []
for city in CITIES:
try:
msg = fetch_city(city)
sqs.send_message(QueueUrl=queue_url, MessageBody=json.dumps(msg))
results.append({"city": msg["city"], "temp": msg["temperature"]})
except Exception as e:
results.append({"city": city["name"], "error": str(e)})
return {"status": "ok", "sent": len(results), "results": results}
@@ -0,0 +1,2 @@
requests==2.31.0
boto3==1.34.0
@@ -0,0 +1,79 @@
{
"dashboard": {
"title": "Weather Metrics",
"uid": "weather-metrics",
"timezone": "browser",
"refresh": "1m",
"time": { "from": "now-6h", "to": "now" },
"panels": [
{
"id": 1,
"type": "timeseries",
"title": "Temperature by City (°C)",
"gridPos": { "x": 0, "y": 0, "w": 24, "h": 10 },
"datasource": { "type": "grafana-postgresql-datasource", "uid": "fission-pg" },
"fieldConfig": {
"defaults": {
"unit": "celsius",
"custom": { "lineWidth": 2, "drawStyle": "line", "showPoints": "never", "spanNulls": true }
}
},
"options": { "legend": { "displayMode": "table", "placement": "right" } },
"targets": [
{
"refId": "A",
"rawSql": "SELECT\n recorded_at AS time,\n temperature,\n city\nFROM weather_metrics\nWHERE $__timeFilter(recorded_at)\nORDER BY recorded_at",
"format": "time_series"
}
]
},
{
"id": 2,
"type": "table",
"title": "Current Conditions",
"gridPos": { "x": 0, "y": 10, "w": 24, "h": 8 },
"datasource": { "type": "grafana-postgresql-datasource", "uid": "fission-pg" },
"options": { "sortBy": [{ "displayName": "City" }] },
"targets": [
{
"refId": "A",
"rawSql": "SELECT DISTINCT ON (city)\n city,\n country,\n temperature,\n feels_like,\n humidity,\n wind_speed,\n description,\n recorded_at\nFROM weather_metrics\nORDER BY city, recorded_at DESC",
"format": "table"
}
]
},
{
"id": 3,
"type": "timeseries",
"title": "Humidity by City (%)",
"gridPos": { "x": 0, "y": 18, "w": 12, "h": 8 },
"datasource": { "type": "grafana-postgresql-datasource", "uid": "fission-pg" },
"fieldConfig": { "defaults": { "unit": "percent", "min": 0, "max": 100, "custom": { "lineWidth": 2, "drawStyle": "line", "showPoints": "never", "spanNulls": true } } },
"targets": [
{
"refId": "A",
"rawSql": "SELECT\n recorded_at AS time,\n humidity,\n city\nFROM weather_metrics\nWHERE $__timeFilter(recorded_at)\nORDER BY recorded_at",
"format": "time_series"
}
]
},
{
"id": 4,
"type": "timeseries",
"title": "Wind Speed by City (m/s)",
"gridPos": { "x": 12, "y": 18, "w": 12, "h": 8 },
"datasource": { "type": "grafana-postgresql-datasource", "uid": "fission-pg" },
"fieldConfig": { "defaults": { "unit": "velocityms", "custom": { "lineWidth": 2, "drawStyle": "line", "showPoints": "never", "spanNulls": true } } },
"targets": [
{
"refId": "A",
"rawSql": "SELECT\n recorded_at AS time,\n wind_speed,\n city\nFROM weather_metrics\nWHERE $__timeFilter(recorded_at)\nORDER BY recorded_at",
"format": "time_series"
}
]
}
],
"schemaVersion": 38
},
"overwrite": true
}
+143
View File
@@ -0,0 +1,143 @@
terraform {
required_providers {
fission = {
source = "nail/fission"
version = "~> 0.2.0"
}
}
}
provider "fission" {
kubeconfig_path = var.kubeconfig_path
namespace = var.namespace
}
# ── Переменные ───────────────────────────────────────────────────────
variable "kubeconfig_path" {
default = "/home/naeel/.kube/config"
description = "Путь к kubeconfig."
}
variable "namespace" {
default = "fission-weather"
description = "Namespace для функций и триггеров."
}
variable "sqs_access_key" {
sensitive = true
description = "SQS Access Key."
}
variable "sqs_secret_key" {
sensitive = true
description = "SQS Secret Key."
}
variable "pg_dsn" {
sensitive = true
description = "PostgreSQL DSN для записи метрик. Пример: postgresql://user:pass@host:5432/db?sslmode=disable"
}
# ── IoT-устройство — виртуальная метеостанция ────────────────────────
resource "fission_iot_device" "weather_station" {
name = "weather-station"
device_id = "weather-station-01"
namespace = "sless"
metadata = {
type = "weather-station"
location = "multi-city"
cities = "Moscow,London,Paphos,Ulyanovsk,Santiago"
}
}
# ── Python environment ───────────────────────────────────────────────
resource "fission_environment" "python" {
name = "weather-python"
image = "naeel/fission-python-env:v1.1"
version = 2
namespace = var.namespace
}
# ── Пакет: fetcher ───────────────────────────────────────────────────
resource "fission_package" "fetcher" {
name = "weather-fetcher-pkg"
environment = fission_environment.python.name
namespace = var.namespace
source_dir = "${path.module}/fetcher"
deploy_type = "literal"
}
# ── Пакет: consumer ──────────────────────────────────────────────────
resource "fission_package" "consumer" {
name = "weather-consumer-pkg"
environment = fission_environment.python.name
namespace = var.namespace
source_dir = "${path.module}/consumer"
deploy_type = "literal"
}
# ── Функция: fetcher (читает Open-Meteo, пишет в SQS) ───────────────
# Open-Meteo: бесплатный API без ключа. https://open-meteo.com
resource "fission_function" "fetcher" {
name = "weather-fetcher"
environment = fission_environment.python.name
namespace = var.namespace
package_name = fission_package.fetcher.name
entrypoint = "main"
# Env vars задаются через K8s Secret weather-fetcher-env (namespace: var.namespace)
# Ключи: SQS_ACCESS_KEY, SQS_SECRET_KEY
}
# ── CRON trigger: каждые 10 минут ────────────────────────────────────
resource "fission_cron_trigger" "fetcher" {
name = "weather-cron"
function = fission_function.fetcher.name
namespace = var.namespace
cron = "*/10 * * * *"
}
# ── Функция: consumer (читает из SQS, пишет в PG) ────────────────────
resource "fission_function" "consumer" {
name = "weather-consumer"
environment = fission_environment.python.name
namespace = var.namespace
package_name = fission_package.consumer.name
entrypoint = "main"
# Env vars: PG_DSN задаётся через K8s Secret weather-consumer-env
}
# ── MQ trigger: SQS queue → consumer function ────────────────────────
resource "fission_mq_trigger" "weather" {
name = "weather-mq"
function = fission_function.consumer.name
namespace = var.namespace
queue = "weather-data"
access_key = var.sqs_access_key
secret_key = var.sqs_secret_key
sqs_endpoint = "http://shared-sqs.shared-sqs.svc.cluster.local:4100"
}
# ── Outputs ──────────────────────────────────────────────────────────
output "iot_device_phase" {
value = fission_iot_device.weather_station.phase
description = "Статус IoT-устройства weather-station."
}
output "iot_mqtt_username" {
value = fission_iot_device.weather_station.mqtt_username
description = "MQTT username для weather-station."
}
output "iot_topic_prefix" {
value = fission_iot_device.weather_station.topic_prefix
description = "MQTT topic prefix для weather-station."
}
+18
View File
@@ -0,0 +1,18 @@
-- Миграция: таблица метрик погоды для weather-demo
-- Применять: psql $PG_DSN -f migration.sql
CREATE TABLE IF NOT EXISTS weather_metrics (
id BIGSERIAL PRIMARY KEY,
city TEXT NOT NULL,
country TEXT NOT NULL,
temperature NUMERIC(5,2),
feels_like NUMERIC(5,2),
humidity INTEGER,
pressure INTEGER,
wind_speed NUMERIC(6,2),
description TEXT,
recorded_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_weather_city_time
ON weather_metrics (city, recorded_at DESC);
@@ -0,0 +1,10 @@
# Скопируй в terraform.tfvars и заполни значения.
# НЕ коммитить файл с реальными секретами!
kubeconfig_path = "/home/naeel/.kube/config"
namespace = "fission-weather"
owm_api_key = "YOUR_OPENWEATHERMAP_API_KEY"
sqs_access_key = "SSAK-a9964f2723bc6d347f48d153"
sqs_secret_key = "YOUR_SQS_SECRET_KEY"
pg_dsn = "postgresql://super:PASSWORD@postgresqlk8s-master.dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5.svc.cluster.local:5432/sqsdb?sslmode=disable"
+30
View File
@@ -0,0 +1,30 @@
# Fission helm values — PV (vcd-disk-ext4) backend
# Применять: helm upgrade fission fission-charts/fission-all --version 1.22.1 \
# -n fission -f helm/fission-values-pv.yaml
#
# Секретов нет — local storage не требует credentials.
# ПЕРЕД upgrade: kubectl delete deploy executor router -n fission
authentication:
enabled: true
defaultNamespace: fission-functions
image: naeel/fission-bundle
imageTag: v1.23.0
fetcher:
image: naeel/fetcher
imageTag: v1.23.0
persistence:
enabled: true
storageType: local
storageClass: vcd-disk-ext4
size: 2Gi
accessMode: ReadWriteOnce
repository: ""
routerServiceType: ClusterIP
serviceType: ClusterIP
analytics: false
+34
View File
@@ -0,0 +1,34 @@
# Fission helm values — S3 backend (ngcloud msk-1)
# Применять: helm upgrade fission fission-charts/fission-all --version 1.22.1 \
# -n fission -f helm/fission-values.yaml -f helm/fission-s3-secret.yaml
#
# Секреты — в helm/fission-s3-secret.yaml (в .gitignore, не коммитить!)
# Вернуться на PV: -f helm/fission-values-pv.yaml (без s3-secret)
# ПЕРЕД upgrade: kubectl delete deploy executor router -n fission
authentication:
enabled: true
defaultNamespace: fission-functions
image: naeel/fission-bundle
imageTag: v1.23.0
fetcher:
image: naeel/fetcher
imageTag: v1.23.0
persistence:
enabled: true
storageType: s3
s3:
bucketName: sless-functions
subDir: fission
region: msk-1
endPoint: https://s3.msk-1.ngcloud.ru
# accessKeyId и secretAccessKey — в fission-s3-secret.yaml
repository: ""
routerServiceType: ClusterIP
serviceType: ClusterIP
analytics: false
+43
View File
@@ -0,0 +1,43 @@
# Демо-пайплайн: IoT → SQS → Fission
> ⚠️ LEGACY — написано до реализации MQ-триггера.
> Сейчас MQ-триггер реализован (см. TODO.txt).
## Архитектура
1. **Крон-функция (Fission)**
- Парсит данные о погоде с сайта для нескольких городов
- Эмулирует IoT-датчики: отправляет данные в IoT-сервис (MQTT/HTTP)
2. **IoT-сервис**
- Принимает данные от "датчиков"
- Публикует сообщения в очередь (SQS или аналог)
3. **Очередь (SQS)**
- Хранит сообщения от IoT
- Триггерит Fission-функцию при появлении новых данных (MessageQueue Trigger)
4. **Fission-функция**
- Получает данные из очереди
- Записывает их в таблицу (PostgreSQL, ClickHouse и т.д.)
## Требования к Fission
> ⚠️ LEGACY: MQ-триггер реализован через sqs-consumer (май 2026).
> Этот раздел оставлен для исторического контекста.
- Необходим MQ-триггер (MessageQueue Trigger) для автоматического запуска функции по сообщениям из очереди.
- Сейчас в Fission есть HTTP, Cron, Event, но нет универсального MQ-триггера.
- Возможные варианты:
- Реализовать внешний watcher (SQS/Kafka/RabbitMQ → invoke HTTP endpoint Fission)
- Добавить поддержку MQ-триггера в сам Fission (новый CRD + контроллер)
## Примечания
- Все компоненты связаны через API/очереди, каждый слой изолирован.
- Такой пайплайн типовой для облачных платформ и легко масштабируется.
- Для MVP достаточно watcher-а очереди, который вызывает функцию через HTTP.
---
Если потребуется — расписать детальный план интеграции или примеры кода для каждого этапа.
+1
View File
@@ -1,2 +1,3 @@
FROM ghcr.io/fission/python-env:latest FROM ghcr.io/fission/python-env:latest
RUN pip install --no-cache-dir boto3==1.34.0 requests==2.31.0 psycopg2-binary==2.9.9
COPY server.py /app/server.py COPY server.py /app/server.py
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env python3
"""
migrate_s3_to_pv.py — перелив архивов функций из S3 → storagesvc (PV-backend)
Запуск: kubectl exec -n fission deploy/storagesvc -- python3 /tmp/migrate.py
ИЛИ: запустить как Job в кластере
Что делает:
1. Читает все Package CRD во всех namespace
2. Для каждого пакета с URL s3://... скачивает архив из S3
3. Загружает в storagesvc HTTP API → получает новый URL
4. Обновляет Package CRD: .spec.deployment.url = новый URL
"""
import os
import sys
import json
import time
import urllib.request
import urllib.parse
# S3 credentials (из env или хардкод для ручного запуска)
S3_ENDPOINT = os.environ.get("S3_ENDPOINT", "https://s3.msk-1.ngcloud.ru")
S3_BUCKET = os.environ.get("S3_BUCKET", "sless-functions")
S3_ACCESS_KEY = os.environ.get("S3_ACCESS_KEY", "0GLQRD38H4I6RBDB0EWJ")
S3_SECRET_KEY = os.environ.get("S3_SECRET_KEY", "eTFibiHmBd96IApj9PYsboTR6OBoD7osxoarHykw")
S3_REGION = os.environ.get("S3_REGION", "msk-1")
STORAGESVC_URL = os.environ.get("STORAGESVC_URL", "http://storagesvc.fission.svc.cluster.local")
import subprocess
def kubectl(args, input_data=None):
cmd = ["kubectl"] + args
r = subprocess.run(cmd, capture_output=True, text=True, input=input_data)
if r.returncode != 0:
raise RuntimeError(f"kubectl {' '.join(args)} failed: {r.stderr}")
return r.stdout
def get_all_packages():
out = kubectl(["get", "packages", "--all-namespaces", "-o", "json"])
return json.loads(out)["items"]
def is_s3_url(url):
return url and url.startswith("s3://")
def s3_key_from_url(url):
# s3://sless-functions/fission/abc123 → fission/abc123
path = url[len(f"s3://{S3_BUCKET}/"):]
return path
def download_from_s3(s3_key):
import boto3
s3 = boto3.client(
"s3",
endpoint_url=S3_ENDPOINT,
aws_access_key_id=S3_ACCESS_KEY,
aws_secret_access_key=S3_SECRET_KEY,
region_name=S3_REGION,
)
obj = s3.get_object(Bucket=S3_BUCKET, Key=s3_key)
return obj["Body"].read()
def upload_to_storagesvc(data):
import urllib.request, uuid
boundary = uuid.uuid4().hex
body = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="uploadfile"; filename="archive.zip"\r\n'
f"Content-Type: application/octet-stream\r\n\r\n"
).encode() + data + f"\r\n--{boundary}--\r\n".encode()
req = urllib.request.Request(
f"{STORAGESVC_URL}/v1/archive",
data=body,
method="POST",
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
)
with urllib.request.urlopen(req, timeout=60) as resp:
result = json.loads(resp.read())
return result["id"] # storagesvc возвращает {"id": "http://storagesvc.../v1/archive?id=..."}
def update_package_url(ns, name, new_url):
patch = json.dumps({"spec": {"deployment": {"url": new_url, "type": "url"}}})
kubectl(["patch", "package", name, "-n", ns, "--type=merge", f"--patch={patch}"])
def main():
try:
import boto3
except ImportError:
print("ERROR: boto3 не установлен. Запусти: pip install boto3")
sys.exit(1)
packages = get_all_packages()
total = len(packages)
migrated = 0
skipped = 0
errors = 0
print(f"Всего пакетов: {total}")
print()
for pkg in packages:
ns = pkg["metadata"]["namespace"]
name = pkg["metadata"]["name"]
url = pkg.get("spec", {}).get("deployment", {}).get("url", "")
if not is_s3_url(url):
print(f" SKIP {ns}/{name} — url: {url or '(empty)'}")
skipped += 1
continue
s3_key = s3_key_from_url(url)
print(f" MIG {ns}/{name} s3://{S3_BUCKET}/{s3_key}", end=" ", flush=True)
try:
data = download_from_s3(s3_key)
new_id = upload_to_storagesvc(data)
new_url = f"{STORAGESVC_URL}/v1/archive?id={new_id}" if not new_id.startswith("http") else new_id
update_package_url(ns, name, new_url)
print(f"{new_url}")
migrated += 1
except Exception as e:
print(f" ERROR: {e}")
errors += 1
print()
print(f"Готово: перенесено={migrated} пропущено={skipped} ошибок={errors}")
if __name__ == "__main__":
main()
+994
View File
@@ -0,0 +1,994 @@
#!/usr/bin/env bash
# test_heavy.sh — поиск багов: большой код, CPU-нагрузка, большой ответ,
# архивы из ZIP, быстрые серии invoke, параллельные вызовы,
# медленные функции, давление по памяти.
# Все 4 языка: Python, Node.js, Ruby, PHP.
#
# ВАЖНО: Ruby и PHP invoke ранее не тестировались вообще!
# Умеренная нагрузка (кластер небольшой, но нагрузка реальная).
#
# Запуск: bash scripts/test_heavy.sh
# На ВМ: bash ~/terra/fission/scripts/test_heavy.sh
set -uo pipefail
ROOT_DIR=$(cd "$(dirname "$0")" && pwd)
source "${ROOT_DIR}/lib.sh"
RUN_ID=$(mk_run_id)
SUB="livetest@test.local"
TMP=$(mktemp -d)
echo ""
echo "════════════════════════════════════════════════════════"
echo " test_heavy.sh run=${RUN_ID}"
echo "════════════════════════════════════════════════════════"
declare -a ALL_FNS=()
cleanup() {
rm -rf "$TMP" 2>/dev/null || true
for fn in "${ALL_FNS[@]:-}"; do
curl -s -X DELETE "${BASE}/functions/${fn}" \
-H "X-Auth-Token: ${SUB}" >/dev/null 2>&1 || true
done
}
trap cleanup EXIT
# ─── helpers ───────────────────────────────────────────────────────────────
# reg FN — регистрирует имя функции для cleanup
reg() { ALL_FNS+=("$1"); }
# create_json LANG FN CODE_FILE — создаёт функцию из файла с кодом
create_json() {
local lang="$1" fn="$2" codefile="$3"
local code_escaped
code_escaped=$(cat "$codefile" | json_escape)
reg "$fn"
local R
R=$(curl -s -w "\n%{http_code}" -X POST "${BASE}/functions" \
-H "X-Auth-Token: ${SUB}" -H "Content-Type: application/json" \
-d "{\"name\":\"${fn}\",\"language\":\"${lang}\",\"code\":${code_escaped}}" 2>/dev/null)
check_http "$(echo "$R" | tail -1)" "201" "${lang^^} ${fn} CREATE → 201"
}
# invoke_once FN EXPECTED LABEL — один invoke, без retry
invoke_once() {
local fn="$1" expected="$2" label="$3"
local resp status raw err
resp=$(curl -s --max-time 60 -X POST "${BASE}/functions/${fn}/invoke" \
-H "X-Auth-Token: ${SUB}" -H "Content-Type: application/json" -d '{}')
status=$(json_field "$resp" status)
raw=$(json_field "$resp" response_raw)
err=$(json_field "$resp" error)
echo " fn=$fn status=$status raw=$(printf '%s' "$raw" | head -c 100) err=$(printf '%s' "$err" | head -c 80)"
if [ "$status" = "200" ] && printf '%s' "$raw" | grep -qF "$expected"; then
pass "$label → 200 + '${expected}'"
return 0
else
fail "$label → status=${status:-?} (ожидали 200 + '${expected}')"
return 1
fi
}
# ═══════════════════════════════════════════════════════════════════════════
# SECTION 1: БОЛЬШОЙ КОД — ~100 строк на язык, из inline-кода
# Ищем: лимиты на размер кода, проблемы загрузки, синтаксические баги
# ═══════════════════════════════════════════════════════════════════════════
echo ""
echo "────────────────────────────────────────────────────────"
echo " S1: БОЛЬШОЙ КОД (Python / Node.js / Ruby / PHP)"
echo "────────────────────────────────────────────────────────"
FN_BIG_PY="t-big-py-${RUN_ID}"
cat > "${TMP}/big_py.py" << 'PYEOF'
import math, hashlib, json
CONSTS = {f"k{i}": i * 3.14159265 for i in range(20)}
def fib(n):
a, b = 0, 1
for _ in range(n - 1): a, b = b, a + b
return b
def sieve(limit):
is_p = bytearray([1]) * (limit + 1)
is_p[0] = is_p[1] = 0
for i in range(2, int(limit**0.5) + 1):
if is_p[i]:
for j in range(i*i, limit+1, i): is_p[j] = 0
return sum(is_p)
def rot13(s):
return s.translate(str.maketrans(
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',
'nopqrstuvwxyzabcdefghijklmNOPQRSTUVWXYZABCDEFGHIJKLM'))
def caesar(s, shift=7):
res = []
for c in s:
if 'a' <= c <= 'z': res.append(chr((ord(c) - 97 + shift) % 26 + 97))
elif 'A' <= c <= 'Z': res.append(chr((ord(c) - 65 + shift) % 26 + 65))
else: res.append(c)
return ''.join(res)
def mat_trace(m):
return sum(m[i][i] for i in range(len(m)))
def palindrome(s):
t = s.lower().replace(' ', '')
return t == t[::-1]
def flatten(lst):
out = []
for item in lst:
if isinstance(item, list): out.extend(flatten(item))
else: out.append(item)
return out
def checksum(data):
return hashlib.sha256(data.encode()).hexdigest()[:16]
def running_sum(lst):
total = 0; out = []
for v in lst: total += v; out.append(total)
return out
def count_distinct(lst):
return len(set(lst))
def group_by_mod(lst, n):
g = {i: [] for i in range(n)}
for v in lst: g[v % n].append(v)
return {str(k): len(v) for k, v in g.items()}
def pascal_row(n):
row = [1]
for i in range(1, n + 1): row.append(row[-1] * (n - i + 1) // i)
return row
def deduplicate(lst):
seen = set(); out = []
for v in lst:
if v not in seen: seen.add(v); out.append(v)
return out
def reverse_words(s):
return ' '.join(s.split()[::-1])
def count_vowels(s):
return sum(1 for c in s.lower() if c in 'aeiou')
def is_prime(n):
if n < 2: return False
if n == 2: return True
if n % 2 == 0: return False
for i in range(3, int(math.sqrt(n)) + 1, 2):
if n % i == 0: return False
return True
def main(event, context):
mat = [[1,2,3,4],[5,6,7,8],[9,10,11,12],[13,14,15,16]]
data = list(range(1, 51))
nested = [[1,[2,3]],[4,[5,[6,7]]],8]
result = {
"tag": "big-code-py-ok",
"fib_25": fib(25),
"primes_50": sieve(50),
"rot13": rot13("Hello Fission"),
"caesar": caesar("heavy"),
"const_sum": round(sum(CONSTS.values()), 2),
"mat_trace": mat_trace(mat),
"palindrome": palindrome("racecar"),
"distinct": count_distinct([1,2,2,3,3,3,4]),
"flat_len": len(flatten(nested)),
"checksum": checksum("fission-heavy-test"),
"rsum_last": running_sum(data)[-1],
"pascal_4": pascal_row(4),
"dedup_len": len(deduplicate([1,2,2,3,3,3,4,5,5])),
"rev_words": reverse_words("big code python test"),
"vowels": count_vowels("fission heavy test"),
"is_prime_97": is_prime(97),
"groups_3": group_by_mod(data[:30], 3),
}
return json.dumps(result), 200
PYEOF
create_json "python" "$FN_BIG_PY" "${TMP}/big_py.py"
FN_BIG_NODE="t-big-node-${RUN_ID}"
cat > "${TMP}/big_node.js" << 'JSEOF'
const crypto = require('crypto');
const CONSTS = {};
for (let i = 0; i < 20; i++) CONSTS[`k${i}`] = i * Math.PI;
function fib(n) {
let a = 0, b = 1;
for (let i = 1; i < n; i++) { let t = a + b; a = b; b = t; }
return b;
}
function sieve(limit) {
const isP = new Uint8Array(limit + 1).fill(1);
isP[0] = isP[1] = 0;
for (let i = 2; i * i <= limit; i++)
if (isP[i]) for (let j = i*i; j <= limit; j += i) isP[j] = 0;
return isP.reduce((s, v) => s + v, 0);
}
function rot13(s) {
return s.replace(/[a-zA-Z]/g, c => {
const b = c <= 'Z' ? 65 : 97;
return String.fromCharCode((c.charCodeAt(0) - b + 13) % 26 + b);
});
}
function caesar(s, shift = 7) {
return s.replace(/[a-zA-Z]/g, c => {
const b = c <= 'Z' ? 65 : 97;
return String.fromCharCode((c.charCodeAt(0) - b + shift) % 26 + b);
});
}
function matTrace(m) { return m.reduce((s, row, i) => s + row[i], 0); }
function palindrome(s) { const t = s.toLowerCase().replace(/ /g, ''); return t === t.split('').reverse().join(''); }
function flatten(arr) { return arr.reduce((a, v) => a.concat(Array.isArray(v) ? flatten(v) : v), []); }
function checksum(d) { return crypto.createHash('sha256').update(d).digest('hex').slice(0, 16); }
function runningSum(arr) { let t = 0; return arr.map(v => (t += v, t)); }
function countDistinct(arr) { return new Set(arr).size; }
function deduplicate(arr) { const s = new Set(); return arr.filter(v => !s.has(v) && s.add(v)); }
function groupByMod(arr, n) { const g = {}; for (let i = 0; i < n; i++) g[i] = 0; arr.forEach(v => g[v % n]++); return g; }
function pascalRow(n) { const r = [1]; for (let i = 1; i <= n; i++) r.push((r[i-1] * (n-i+1) / i) | 0); return r; }
function reverseWords(s) { return s.split(' ').reverse().join(' '); }
function countVowels(s) { return (s.toLowerCase().match(/[aeiou]/g) || []).length; }
function isPrime(n) {
if (n < 2) return false;
for (let i = 2; i * i <= n; i++) if (n % i === 0) return false;
return true;
}
const mat = [[1,2,3,4],[5,6,7,8],[9,10,11,12],[13,14,15,16]];
const nested = [[1,[2,3]],[4,[5,[6,7]]],8];
module.exports = async function(ctx) {
const data = Array.from({length: 50}, (_, i) => i + 1);
const result = {
tag: "big-code-node-ok",
fib_25: fib(25),
primes_50: sieve(50),
rot13: rot13("Hello Fission"),
caesar: caesar("heavy"),
const_sum: Math.round(Object.values(CONSTS).reduce((a,b) => a+b, 0) * 100) / 100,
mat_trace: matTrace(mat),
palindrome: palindrome("racecar"),
distinct: countDistinct([1,2,2,3,3,3,4]),
flat_len: flatten(nested).length,
checksum: checksum("fission-heavy-test"),
rsum_last: runningSum(data).at(-1),
pascal_4: pascalRow(4),
dedup_len: deduplicate([1,2,2,3,3,3,4,5,5]).length,
rev_words: reverseWords("big code node test"),
vowels: countVowels("fission heavy test"),
is_prime_97: isPrime(97),
groups_3: groupByMod(data.slice(0,30), 3),
};
return { status: 200, body: JSON.stringify(result) };
};
JSEOF
create_json "nodejs" "$FN_BIG_NODE" "${TMP}/big_node.js"
FN_BIG_RUBY="t-big-ruby-${RUN_ID}"
cat > "${TMP}/big_ruby.rb" << 'RBEOF'
require 'json'
require 'digest'
CONSTS = (0...20).map { |i| [:"k#{i}", i * Math::PI] }.to_h
def fib(n)
a, b = 0, 1
(n - 1).times { a, b = b, a + b }
b
end
def sieve(limit)
is_p = Array.new(limit + 1, true)
is_p[0] = is_p[1] = false
(2..Math.sqrt(limit).to_i).each do |i|
next unless is_p[i]
(i*i..limit).step(i) { |j| is_p[j] = false }
end
is_p.count(true)
end
def rot13(s)
s.tr('a-zA-Z', 'n-za-mN-ZA-M')
end
def caesar(s, shift = 7)
s.chars.map do |c|
if c =~ /[a-z]/ then ((c.ord - 97 + shift) % 26 + 97).chr
elsif c =~ /[A-Z]/ then ((c.ord - 65 + shift) % 26 + 65).chr
else c
end
end.join
end
def mat_trace(m)
m.each_with_index.sum { |row, i| row[i] }
end
def palindrome?(s)
t = s.downcase.delete(' ')
t == t.reverse
end
def flatten_nested(lst)
lst.each_with_object([]) do |item, out|
item.is_a?(Array) ? out.concat(flatten_nested(item)) : out << item
end
end
def checksum(data)
Digest::SHA256.hexdigest(data)[0, 16]
end
def running_sum(arr)
total = 0
arr.map { |v| total += v; total }
end
def count_distinct(arr)
arr.uniq.length
end
def group_by_mod(arr, n)
(0...n).each_with_object({}) { |i, h| h[i.to_s] = arr.count { |v| v % n == i } }
end
def pascal_row(n)
row = [1]
(1..n).each { |i| row << row[-1] * (n - i + 1) / i }
row
end
def reverse_words(s)
s.split.reverse.join(' ')
end
def count_vowels(s)
s.downcase.count('aeiou')
end
def is_prime(n)
return false if n < 2
(2..Math.sqrt(n).to_i).none? { |i| n % i == 0 }
end
def handler(ctx)
mat = [[1,2,3,4],[5,6,7,8],[9,10,11,12],[13,14,15,16]]
data = (1..50).to_a
nested = [[1,[2,3]],[4,[5,[6,7]]],8]
result = {
tag: "big-code-ruby-ok",
fib_25: fib(25),
primes_50: sieve(50),
rot13: rot13("Hello Fission"),
caesar: caesar("heavy"),
const_sum: CONSTS.values.sum.round(2),
mat_trace: mat_trace(mat),
palindrome: palindrome?("racecar"),
distinct: count_distinct([1,2,2,3,3,3,4]),
flat_len: flatten_nested(nested).length,
checksum: checksum("fission-heavy-test"),
rsum_last: running_sum(data).last,
pascal_4: pascal_row(4),
rev_words: reverse_words("big code ruby test"),
vowels: count_vowels("fission heavy test"),
is_prime_97: is_prime(97),
groups_3: group_by_mod(data.first(30), 3),
}
{ statusCode: 200, body: result.to_json }
end
RBEOF
create_json "ruby" "$FN_BIG_RUBY" "${TMP}/big_ruby.rb"
FN_BIG_PHP="t-big-php-${RUN_ID}"
cat > "${TMP}/big_php.php" << 'PHPEOF'
<?php
$CONSTS = [];
for ($i = 0; $i < 20; $i++) $CONSTS["k{$i}"] = $i * M_PI;
function fib($n) {
$a = 0; $b = 1;
for ($i = 1; $i < $n; $i++) { $t = $a + $b; $a = $b; $b = $t; }
return $b;
}
function sieve($limit) {
$isP = array_fill(0, $limit + 1, true);
$isP[0] = $isP[1] = false;
for ($i = 2; $i * $i <= $limit; $i++)
if ($isP[$i]) for ($j = $i*$i; $j <= $limit; $j += $i) $isP[$j] = false;
return array_sum(array_map('intval', $isP));
}
function rot13($s) { return str_rot13($s); }
function caesar($s, $shift = 7) {
$r = '';
for ($i = 0; $i < strlen($s); $i++) {
$c = $s[$i];
if ($c >= 'a' && $c <= 'z') $r .= chr((ord($c) - 97 + $shift) % 26 + 97);
elseif ($c >= 'A' && $c <= 'Z') $r .= chr((ord($c) - 65 + $shift) % 26 + 65);
else $r .= $c;
}
return $r;
}
function matTrace($m) {
$s = 0;
foreach ($m as $i => $row) $s += $row[$i];
return $s;
}
function palindrome($s) {
$t = str_replace(' ', '', strtolower($s));
return $t === strrev($t);
}
function checksumStr($data) { return substr(hash('sha256', $data), 0, 16); }
function runningSum($arr) {
$t = 0; $out = [];
foreach ($arr as $v) { $t += $v; $out[] = $t; }
return $out;
}
function countDistinct($arr) { return count(array_unique($arr)); }
function groupByMod($arr, $n) {
$g = array_fill(0, $n, 0);
foreach ($arr as $v) $g[$v % $n]++;
return $g;
}
function pascalRow($n) {
$row = [1];
for ($i = 1; $i <= $n; $i++) $row[] = intdiv($row[$i-1] * ($n-$i+1), $i);
return $row;
}
function reverseWords($s) { return implode(' ', array_reverse(explode(' ', $s))); }
function countVowels($s) { return preg_match_all('/[aeiou]/i', $s); }
function isPrime($n) {
if ($n < 2) return false;
for ($i = 2; $i * $i <= $n; $i++) if ($n % $i === 0) return false;
return true;
}
function handler($ctx) {
global $CONSTS;
$mat = [[1,2,3,4],[5,6,7,8],[9,10,11,12],[13,14,15,16]];
$data = range(1, 50);
$constSum = 0; foreach ($CONSTS as $v) $constSum += $v;
$rsum = runningSum($data);
$result = [
'tag' => 'big-code-php-ok',
'fib_25' => fib(25),
'primes_50' => sieve(50),
'rot13' => rot13('Hello Fission'),
'caesar' => caesar('heavy'),
'const_sum' => round($constSum, 2),
'mat_trace' => matTrace($mat),
'palindrome' => palindrome('racecar'),
'distinct' => countDistinct([1,2,2,3,3,3,4]),
'checksum' => checksumStr('fission-heavy-test'),
'rsum_last' => end($rsum),
'pascal_4' => pascalRow(4),
'rev_words' => reverseWords('big code php test'),
'vowels' => countVowels('fission heavy test'),
'is_prime_97'=> isPrime(97),
'groups_3' => groupByMod(array_slice($data, 0, 30), 3),
];
$ctx["response"]->getBody()->write(json_encode($result));
}
PHPEOF
create_json "php" "$FN_BIG_PHP" "${TMP}/big_php.php"
echo ""
echo ">>> S1: invoke больших функций — ждём cold start всех 4 языков..."
for fn_tag in \
"${FN_BIG_PY}:big-code-py-ok:Python" \
"${FN_BIG_NODE}:big-code-node-ok:Node.js" \
"${FN_BIG_RUBY}:big-code-ruby-ok:Ruby" \
"${FN_BIG_PHP}:big-code-php-ok:PHP"; do
fn="${fn_tag%%:*}"; rest="${fn_tag#*:}"; tag="${rest%%:*}"; label="${rest#*:}"
(
if wait_invoke "$SUB" "$fn" "$tag" 20 6; then
echo "OK"
else
echo "FAIL"
fi
) > "${TMP}/s1-${fn}.out" &
done
wait
for fn_tag in \
"${FN_BIG_PY}:big-code-py-ok:Python" \
"${FN_BIG_NODE}:big-code-node-ok:Node.js" \
"${FN_BIG_RUBY}:big-code-ruby-ok:Ruby" \
"${FN_BIG_PHP}:big-code-php-ok:PHP"; do
fn="${fn_tag%%:*}"; rest="${fn_tag#*:}"; tag="${rest%%:*}"; label="${rest#*:}"
result=$(cat "${TMP}/s1-${fn}.out" 2>/dev/null || echo "FAIL")
if [ "$result" = "OK" ]; then
pass "S1 $label BIG CODE invoke → 200"
else
fail "S1 $label BIG CODE invoke — не стабилизировался"
fi
done
# ═══════════════════════════════════════════════════════════════════════════
# SECTION 2: CPU BURN — рекурсивный fib, нагрузка на CPU пода
# Ищем: OOM kill, timeout, зависание пула, медленный poolmgr
# ═══════════════════════════════════════════════════════════════════════════
echo ""
echo "────────────────────────────────────────────────────────"
echo " S2: CPU BURN — рекурсивный fib (Python/Node.js/Ruby/PHP)"
echo "────────────────────────────────────────────────────────"
FN_CPU_PY="t-cpu-py-${RUN_ID}"
cat > "${TMP}/cpu_py.py" << 'PYEOF'
def fib(n):
if n < 2: return n
return fib(n-1) + fib(n-2)
def main(event, context):
result = fib(28)
return "cpu-burn-py:" + str(result), 200
PYEOF
create_json "python" "$FN_CPU_PY" "${TMP}/cpu_py.py"
FN_CPU_NODE="t-cpu-node-${RUN_ID}"
cat > "${TMP}/cpu_node.js" << 'JSEOF'
function fib(n) {
if (n < 2) return n;
return fib(n-1) + fib(n-2);
}
module.exports = async function(ctx) {
return { status: 200, body: "cpu-burn-node:" + fib(35) };
};
JSEOF
create_json "nodejs" "$FN_CPU_NODE" "${TMP}/cpu_node.js"
FN_CPU_RUBY="t-cpu-ruby-${RUN_ID}"
cat > "${TMP}/cpu_ruby.rb" << 'RBEOF'
def fib(n)
return n if n < 2
fib(n-1) + fib(n-2)
end
def handler(ctx)
{ statusCode: 200, body: "cpu-burn-ruby:#{fib(25)}" }
end
RBEOF
create_json "ruby" "$FN_CPU_RUBY" "${TMP}/cpu_ruby.rb"
FN_CPU_PHP="t-cpu-php-${RUN_ID}"
cat > "${TMP}/cpu_php.php" << 'PHPEOF'
<?php
function fib($n) {
if ($n < 2) return $n;
return fib($n-1) + fib($n-2);
}
function handler($ctx) {
$ctx["response"]->getBody()->write('cpu-burn-php:' . fib(28));
}
PHPEOF
create_json "php" "$FN_CPU_PHP" "${TMP}/cpu_php.php"
echo ""
echo ">>> S2: invoke CPU-burn функций..."
for fn_tag in \
"${FN_CPU_PY}:cpu-burn-py:Python" \
"${FN_CPU_NODE}:cpu-burn-node:Node.js" \
"${FN_CPU_RUBY}:cpu-burn-ruby:Ruby" \
"${FN_CPU_PHP}:cpu-burn-php:PHP"; do
fn="${fn_tag%%:*}"; rest="${fn_tag#*:}"; tag="${rest%%:*}"; label="${rest#*:}"
(
if wait_invoke "$SUB" "$fn" "$tag" 18 6; then
echo "OK"
else
echo "FAIL"
fi
) > "${TMP}/s2-${fn}.out" &
done
wait
for fn_tag in \
"${FN_CPU_PY}:cpu-burn-py:Python" \
"${FN_CPU_NODE}:cpu-burn-node:Node.js" \
"${FN_CPU_RUBY}:cpu-burn-ruby:Ruby" \
"${FN_CPU_PHP}:cpu-burn-php:PHP"; do
fn="${fn_tag%%:*}"; rest="${fn_tag#*:}"; tag="${rest%%:*}"; label="${rest#*:}"
result=$(cat "${TMP}/s2-${fn}.out" 2>/dev/null || echo "FAIL")
if [ "$result" = "OK" ]; then
pass "S2 $label CPU BURN invoke → 200"
else
fail "S2 $label CPU BURN — не стабилизировался"
fi
done
# ═══════════════════════════════════════════════════════════════════════════
# SECTION 3: БОЛЬШОЙ ОТВЕТ — 50KB body
# Ищем: truncation, proxy memory issues, timeout на больших ответах
# ═══════════════════════════════════════════════════════════════════════════
echo ""
echo "────────────────────────────────────────────────────────"
echo " S3: БОЛЬШОЙ ОТВЕТ — 50KB (Python + Node.js)"
echo "────────────────────────────────────────────────────────"
FN_RESP_PY="t-resp-py-${RUN_ID}"
cat > "${TMP}/resp_py.py" << 'PYEOF'
def main(event, context):
chunk = "FISSION-HEAVY-DATA-" * 100 # 1900 chars
data = (chunk * 27)[:50000] # ровно 50000 chars
return "bigresponse-py-ok:" + data[:20] + "..." + str(len(data)), 200
PYEOF
create_json "python" "$FN_RESP_PY" "${TMP}/resp_py.py"
FN_RESP_NODE="t-resp-node-${RUN_ID}"
cat > "${TMP}/resp_node.js" << 'JSEOF'
module.exports = async function(ctx) {
const chunk = "FISSION-HEAVY-DATA-".repeat(100);
const data = (chunk.repeat(27)).slice(0, 50000);
return { status: 200, body: "bigresponse-node-ok:" + data.slice(0,20) + "..." + data.length };
};
JSEOF
create_json "nodejs" "$FN_RESP_NODE" "${TMP}/resp_node.js"
echo ""
echo ">>> S3: invoke и проверка размера ответа..."
for fn_tag in \
"${FN_RESP_PY}:bigresponse-py-ok:Python" \
"${FN_RESP_NODE}:bigresponse-node-ok:Node.js"; do
fn="${fn_tag%%:*}"; rest="${fn_tag#*:}"; tag="${rest%%:*}"; label="${rest#*:}"
if wait_invoke "$SUB" "$fn" "$tag" 15 6; then
# Проверяем реальный размер ответа
RESP=$(curl -s --max-time 60 -X POST "${BASE}/functions/${fn}/invoke" \
-H "X-Auth-Token: ${SUB}" -H "Content-Type: application/json" -d '{}')
RAW=$(json_field "$RESP" response_raw)
RAW_LEN=${#RAW}
echo " response_raw length = $RAW_LEN chars"
if [ "$RAW_LEN" -gt 40000 ]; then
pass "S3 $label BIG RESPONSE size=$RAW_LEN (>40000)"
else
fail "S3 $label BIG RESPONSE слишком мал: size=$RAW_LEN"
fi
else
fail "S3 $label BIG RESPONSE — invoke не стабилизировался"
fi
done
# ═══════════════════════════════════════════════════════════════════════════
# SECTION 4: АРХИВЫ ИЗ ZIP — создаём архивы на лету, все 4 языка
# Ищем: баги пути upload vs inline, entrypoint defaults, storagesvc PV
# ═══════════════════════════════════════════════════════════════════════════
echo ""
echo "────────────────────────────────────────────────────────"
echo " S4: АРХИВ ИЗ ZIP (Python / Node.js / Ruby / PHP)"
echo "────────────────────────────────────────────────────────"
# --- Создаём ZIP-архивы на лету через python3 ---
# Python archive: main.py (entrypoint по умолчанию: main.main)
python3 - > "${TMP}/arc-py.zip" << 'EOF'
import zipfile, io, sys
buf = io.BytesIO()
code = """import json
def main(event, context):
return json.dumps({"tag": "archive-py-ok", "fib25": 75025}), 200
"""
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
zf.writestr('main.py', code)
sys.stdout.buffer.write(buf.getvalue())
EOF
# Node.js archive: main.js (entrypoint: main → файл main.js)
python3 - > "${TMP}/arc-node.zip" << 'EOF'
import zipfile, io, sys
buf = io.BytesIO()
code = """module.exports = async function(ctx) {
return { status: 200, body: JSON.stringify({ tag: "archive-node-ok", fib25: 75025 }) };
};
"""
pkg = '{"name":"heavy-node","version":"1.0.0"}'
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
zf.writestr('main.js', code)
zf.writestr('package.json', pkg)
sys.stdout.buffer.write(buf.getvalue())
EOF
# Ruby archive: handler.rb (entrypoint: handler)
python3 - > "${TMP}/arc-ruby.zip" << 'EOF'
import zipfile, io, sys
buf = io.BytesIO()
code = """require 'json'
def handler(ctx)
{ statusCode: 200, body: JSON.generate({ tag: "archive-ruby-ok", fib25: 75025 }) }
end
"""
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
zf.writestr('handler.rb', code)
sys.stdout.buffer.write(buf.getvalue())
EOF
# PHP archive: main.php (entrypoint: main.php::handler)
python3 - > "${TMP}/arc-php.zip" << 'EOF'
import zipfile, io, sys
buf = io.BytesIO()
code = """<?php
function handler($ctx) {
$ctx[\"response\"]->getBody()->write(json_encode(['tag' => 'archive-php-ok', 'fib25' => 75025]));
}
"""
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
zf.writestr('main.php', code)
sys.stdout.buffer.write(buf.getvalue())
EOF
# Загружаем архивы через multipart POST /functions
declare -A ARC_LANGS=( ["py"]="python" ["node"]="nodejs" ["ruby"]="ruby" ["php"]="php" )
declare -A ARC_TAGS=( ["py"]="archive-py-ok" ["node"]="archive-node-ok" ["ruby"]="archive-ruby-ok" ["php"]="archive-php-ok" )
for lang_short in py node ruby php; do
fn="t-arc-${lang_short}-${RUN_ID}"
reg "$fn"
lang="${ARC_LANGS[$lang_short]}"
arcfile="${TMP}/arc-${lang_short}.zip"
R=$(curl -s -w "\n%{http_code}" -X POST "${BASE}/functions" \
-H "X-Auth-Token: ${SUB}" \
-F "name=${fn}" \
-F "language=${lang}" \
-F "archive=@${arcfile};type=application/zip" 2>/dev/null)
CODE=$(echo "$R" | tail -1)
BODY=$(echo "$R" | head -n -1)
check_http "$CODE" "201" "S4 ${lang_short^^} ARCHIVE upload → 201"
if [ "$CODE" != "201" ]; then
echo " upload error: $(printf '%s' "$BODY" | head -c 200)"
fi
done
echo ""
echo ">>> S4: invoke архивных функций — ждём cold start..."
for lang_short in py node ruby php; do
fn="t-arc-${lang_short}-${RUN_ID}"
tag="${ARC_TAGS[$lang_short]}"
(
if wait_invoke "$SUB" "$fn" "$tag" 20 6; then
echo "OK"
else
echo "FAIL"
fi
) > "${TMP}/s4-${fn}.out" &
done
wait
for lang_short in py node ruby php; do
fn="t-arc-${lang_short}-${RUN_ID}"
tag="${ARC_TAGS[$lang_short]}"
result=$(cat "${TMP}/s4-${fn}.out" 2>/dev/null || echo "FAIL")
if [ "$result" = "OK" ]; then
pass "S4 ${lang_short^^} ARCHIVE invoke → 200"
else
fail "S4 ${lang_short^^} ARCHIVE invoke — не стабилизировался"
fi
done
# ═══════════════════════════════════════════════════════════════════════════
# SECTION 5: БЫСТРЫЕ СЕРИИ — 12 последовательных invoke одной функции
# Ищем: poolmgr reuse bugs, утечки памяти, деградацию latency
# ═══════════════════════════════════════════════════════════════════════════
echo ""
echo "────────────────────────────────────────────────────────"
echo " S5: БЫСТРЫЕ СЕРИИ — 12x invoke подряд (Python)"
echo "────────────────────────────────────────────────────────"
FN_RAPID="t-rapid-py-${RUN_ID}"
cat > "${TMP}/rapid_py.py" << 'PYEOF'
import time
_count = 0
def main(event, context):
global _count
_count += 1
return "rapid-ok:{}".format(_count), 200
PYEOF
create_json "python" "$FN_RAPID" "${TMP}/rapid_py.py"
echo ">>> Ждём cold start..."
if wait_invoke "$SUB" "$FN_RAPID" "rapid-ok" 15 5; then
echo ">>> S5: Запускаем 12 последовательных invoke..."
RAPID_OK=0
RAPID_FAIL=0
LATENCIES=()
for i in $(seq 1 12); do
T_START=$(date +%s%3N)
RESP=$(curl -s --max-time 20 -X POST "${BASE}/functions/${FN_RAPID}/invoke" \
-H "X-Auth-Token: ${SUB}" -H "Content-Type: application/json" -d '{}')
T_END=$(date +%s%3N)
LAT=$(( T_END - T_START ))
STATUS=$(json_field "$RESP" status)
RAW=$(json_field "$RESP" response_raw)
echo " invoke ${i}/12: status=$STATUS raw=$(printf '%s' "$RAW" | head -c 40) latency=${LAT}ms"
if [ "$STATUS" = "200" ]; then
RAPID_OK=$((RAPID_OK+1))
LATENCIES+=("$LAT")
else
RAPID_FAIL=$((RAPID_FAIL+1))
fi
done
[ "$RAPID_FAIL" -eq 0 ] && pass "S5 RAPID FIRE: 12/12 invoke → 200" \
|| fail "S5 RAPID FIRE: ${RAPID_FAIL}/12 упали"
# Статистика latency
if [ "${#LATENCIES[@]}" -gt 0 ]; then
LAT_MIN="${LATENCIES[0]}"; LAT_MAX="${LATENCIES[0]}"
for lat in "${LATENCIES[@]}"; do
[ "$lat" -lt "$LAT_MIN" ] && LAT_MIN="$lat"
[ "$lat" -gt "$LAT_MAX" ] && LAT_MAX="$lat"
done
echo " latency min=${LAT_MIN}ms max=${LAT_MAX}ms"
fi
else
fail "S5 RAPID FIRE — cold start не прошёл, тест пропущен"
fi
# ═══════════════════════════════════════════════════════════════════════════
# SECTION 6: ПАРАЛЛЕЛЬНЫЕ ВЫЗОВЫ — 5 параллельных invoke на каждый язык
# Ищем: race conditions, конфликты при pod scheduling, deadlocks
# ═══════════════════════════════════════════════════════════════════════════
echo ""
echo "────────────────────────────────────────────────────────"
echo " S6: ПАРАЛЛЕЛЬНЫЕ ВЫЗОВЫ — 5 одновременных invoke × 4 языка"
echo "────────────────────────────────────────────────────────"
PARALLEL_N=5
# Используем уже тёплые функции из S2 (CPU burn — уже invoke'd)
declare -A PAR_FNS=( ["Python"]="$FN_CPU_PY" ["Node.js"]="$FN_CPU_NODE" ["Ruby"]="$FN_CPU_RUBY" ["PHP"]="$FN_CPU_PHP" )
declare -A PAR_TAGS=( ["Python"]="cpu-burn-py" ["Node.js"]="cpu-burn-node" ["Ruby"]="cpu-burn-ruby" ["PHP"]="cpu-burn-php" )
for lang in "Python" "Node.js" "Ruby" "PHP"; do
fn="${PAR_FNS[$lang]}"
tag="${PAR_TAGS[$lang]}"
echo ""
echo ">>> S6 $lang: $PARALLEL_N параллельных invoke..."
for i in $(seq 1 "$PARALLEL_N"); do
(
RESP=$(curl -s --max-time 60 -X POST "${BASE}/functions/${fn}/invoke" \
-H "X-Auth-Token: ${SUB}" -H "Content-Type: application/json" -d '{}')
STATUS=$(json_field "$RESP" status)
RAW=$(json_field "$RESP" response_raw)
if [ "$STATUS" = "200" ] && printf '%s' "$RAW" | grep -qF "$tag"; then
echo "OK" > "${TMP}/par-${lang}-${i}.out"
else
echo "FAIL:status=${STATUS}" > "${TMP}/par-${lang}-${i}.out"
fi
) &
done
wait
PAR_OK=0; PAR_FAIL=0
for i in $(seq 1 "$PARALLEL_N"); do
result=$(cat "${TMP}/par-${lang}-${i}.out" 2>/dev/null || echo "FAIL:no-file")
if [ "$result" = "OK" ]; then
PAR_OK=$((PAR_OK+1))
else
PAR_FAIL=$((PAR_FAIL+1))
echo " invoke $i: $result"
fi
done
[ "$PAR_FAIL" -eq 0 ] \
&& pass "S6 $lang PARALLEL: ${PARALLEL_N}/${PARALLEL_N} invoke → 200" \
|| fail "S6 $lang PARALLEL: ${PAR_FAIL}/${PARALLEL_N} упали"
done
# ═══════════════════════════════════════════════════════════════════════════
# SECTION 7: МЕДЛЕННЫЕ ФУНКЦИИ — sleep 6s, Python + Node.js
# Ищем: router timeout, poolmgr idle eviction, 504 под нагрузкой
# ═══════════════════════════════════════════════════════════════════════════
echo ""
echo "────────────────────────────────────────────────────────"
echo " S7: МЕДЛЕННЫЕ ФУНКЦИИ — sleep(6s) × Python + Node.js"
echo "────────────────────────────────────────────────────────"
FN_SLOW_PY="t-slow-py-${RUN_ID}"
cat > "${TMP}/slow_py.py" << 'PYEOF'
import time
def main(event, context):
time.sleep(6)
return "slow-py-ok", 200
PYEOF
create_json "python" "$FN_SLOW_PY" "${TMP}/slow_py.py"
FN_SLOW_NODE="t-slow-node-${RUN_ID}"
cat > "${TMP}/slow_node.js" << 'JSEOF'
module.exports = async function(ctx) {
await new Promise(r => setTimeout(r, 6000));
return { status: 200, body: "slow-node-ok" };
};
JSEOF
create_json "nodejs" "$FN_SLOW_NODE" "${TMP}/slow_node.js"
echo ""
echo ">>> S7: invoke (ждём cold start + 6s sleep)..."
for fn_tag in \
"${FN_SLOW_PY}:slow-py-ok:Python" \
"${FN_SLOW_NODE}:slow-node-ok:Node.js"; do
fn="${fn_tag%%:*}"; rest="${fn_tag#*:}"; tag="${rest%%:*}"; label="${rest#*:}"
T_START=$(date +%s%3N)
if wait_invoke "$SUB" "$fn" "$tag" 15 8; then
T_END=$(date +%s%3N)
LAT=$(( T_END - T_START ))
pass "S7 $label SLOW(6s) → 200 за ~${LAT}ms total"
# Повторный invoke (warm pod): должно занять ≥6s
T2=$(date +%s%3N)
RESP=$(curl -s --max-time 30 -X POST "${BASE}/functions/${fn}/invoke" \
-H "X-Auth-Token: ${SUB}" -H "Content-Type: application/json" -d '{}')
T2E=$(date +%s%3N)
LAT2=$(( T2E - T2 ))
STATUS=$(json_field "$RESP" status)
RAW=$(json_field "$RESP" response_raw)
echo " warm invoke: status=$STATUS latency=${LAT2}ms raw=$(printf '%s' "$RAW" | head -c 40)"
if [ "$STATUS" = "200" ] && [ "$LAT2" -ge 5000 ]; then
pass "S7 $label SLOW warm invoke → 200 latency=${LAT2}ms (≥5s)"
elif [ "$STATUS" = "200" ]; then
fail "S7 $label SLOW warm слишком быстро: ${LAT2}ms — sleep не сработал?"
else
fail "S7 $label SLOW warm invoke → status=$STATUS"
fi
else
fail "S7 $label SLOW — cold start не прошёл"
fi
done
# ═══════════════════════════════════════════════════════════════════════════
# SECTION 8: ДАВЛЕНИЕ ПО ПАМЯТИ — аллокация 20MB, Python + Node.js
# Ищем: OOM kill на поде, зависание, некорректный ответ
# ═══════════════════════════════════════════════════════════════════════════
echo ""
echo "────────────────────────────────────────────────────────"
echo " S8: ПАМЯТЬ — аллокация 20MB (Python + Node.js)"
echo "────────────────────────────────────────────────────────"
FN_MEM_PY="t-mem-py-${RUN_ID}"
cat > "${TMP}/mem_py.py" << 'PYEOF'
def main(event, context):
# Аллоцируем 20MB и суммируем (чтобы не оптимизировалось)
data = bytearray(20 * 1024 * 1024)
for i in range(0, len(data), 4096):
data[i] = i % 256
checksum = sum(data[i] for i in range(0, len(data), 4096)) % 65536
return "mem-py-ok:{}:{}".format(len(data), checksum), 200
PYEOF
create_json "python" "$FN_MEM_PY" "${TMP}/mem_py.py"
FN_MEM_NODE="t-mem-node-${RUN_ID}"
cat > "${TMP}/mem_node.js" << 'JSEOF'
module.exports = async function(ctx) {
const size = 20 * 1024 * 1024;
const buf = Buffer.alloc(size);
for (let i = 0; i < size; i += 4096) buf[i] = i % 256;
let checksum = 0;
for (let i = 0; i < size; i += 4096) checksum = (checksum + buf[i]) % 65536;
return { status: 200, body: `mem-node-ok:${size}:${checksum}` };
};
JSEOF
create_json "nodejs" "$FN_MEM_NODE" "${TMP}/mem_node.js"
echo ""
echo ">>> S8: invoke (20MB аллокация)..."
for fn_tag in \
"${FN_MEM_PY}:mem-py-ok:Python" \
"${FN_MEM_NODE}:mem-node-ok:Node.js"; do
fn="${fn_tag%%:*}"; rest="${fn_tag#*:}"; tag="${rest%%:*}"; label="${rest#*:}"
if wait_invoke "$SUB" "$fn" "$tag" 18 6; then
pass "S8 $label MEMORY(20MB) → 200"
# Проверяем что данные в ответе правильные
RESP=$(curl -s --max-time 30 -X POST "${BASE}/functions/${fn}/invoke" \
-H "X-Auth-Token: ${SUB}" -H "Content-Type: application/json" -d '{}')
RAW=$(json_field "$RESP" response_raw)
if printf '%s' "$RAW" | grep -qE "$tag:[0-9]+:[0-9]+"; then
pass "S8 $label MEMORY ответ содержит checksum: $(printf '%s' "$RAW" | head -c 60)"
else
fail "S8 $label MEMORY ответ некорректен: $(printf '%s' "$RAW" | head -c 60)"
fi
else
fail "S8 $label MEMORY(20MB) — не вернулась (OOM kill?)"
fi
done
# ═══════════════════════════════════════════════════════════════════════════
print_summary
-158
View File
@@ -1,158 +0,0 @@
#!/usr/bin/env bash
# test_layer1.sh — простой тест пропатченного Fission NSWatcher (Layer 1 only).
# Без консоли. Только kubectl + fission CLI.
#
# Что проверяем:
# 1. Создаём NS с меткой fission.io/managed=true
# 2. Executor регистрирует его через NSWatcher (лог)
# 3. Создаём Python env + function в новом NS
# 4. Вызываем функцию — должна ответить 200
#
# Запуск: bash ~/terra/fission/test_layer1.sh
set -uo pipefail
NS="l1-test-$(date +%s | tail -c 6)"
PASS=0; FAIL=0
green() { echo -e "\033[32m PASS: $1\033[0m"; PASS=$((PASS+1)); }
red() { echo -e "\033[31m FAIL: $1\033[0m"; FAIL=$((FAIL+1)); }
echo ""
echo "========================================"
echo " Layer 1 NSWatcher — простой тест"
echo " NS: $NS"
echo "========================================"
# ── 1. Создаём NS с меткой ────────────────────────────────────────────────────
echo ""
echo ">>> [1/5] Создаём NS $NS с меткой fission.io/managed=true..."
kubectl create ns "$NS"
kubectl label ns "$NS" fission.io/managed=true --overwrite
kubectl get ns "$NS" --show-labels | grep "fission.io/managed"
if [ $? -eq 0 ]; then
green "NS создан с меткой"
else
red "Метка не применилась"
exit 1
fi
# ── 2. Ждём регистрации в executor ────────────────────────────────────────────
echo ""
echo ">>> [2/5] Ждём регистрации NS в executor (до 15 сек)..."
DEADLINE=$((SECONDS + 15))
REGISTERED=false
while [ $SECONDS -lt $DEADLINE ]; do
if kubectl logs -n fission deployment/executor 2>/dev/null | grep "registered namespace" | grep -q "$NS"; then
REGISTERED=true
break
fi
sleep 2
done
if [ "$REGISTERED" = "true" ]; then
green "Executor зарегистрировал NS $NS"
else
red "Executor НЕ зарегистрировал NS за 15 сек"
echo " Лог executor (последние строки про namespace):"
kubectl logs -n fission deployment/executor 2>/dev/null | grep "namespace" | tail -5
fi
# ── 3. Создаём environment в новом NS ─────────────────────────────────────────
echo ""
echo ">>> [3/5] Создаём Python environment в NS $NS..."
fission env create \
--name py-env \
--namespace "$NS" \
--image ghcr.io/fission/python-env:latest \
--poolsize 1 2>&1
if kubectl get environment py-env -n "$NS" &>/dev/null; then
green "Environment py-env создан в $NS"
else
red "Environment не создался"
fi
# ── 4. Создаём функцию ────────────────────────────────────────────────────────
echo ""
echo ">>> [4/5] Создаём функцию hello..."
cat > /tmp/hello.py << 'EOF'
def main():
return "hello from layer1"
EOF
fission fn create \
--name hello \
--namespace "$NS" \
--env py-env \
--code /tmp/hello.py 2>&1
fission httptrigger create \
--name hello-route \
--namespace "$NS" \
--function hello \
--url "/${NS}/hello" \
--method GET 2>&1
if kubectl get httptrigger hello-route -n "$NS" &>/dev/null; then
green "Function + HTTPTrigger созданы"
else
red "HTTPTrigger не создался"
fi
# ── 5. Вызываем функцию через внутренний router с JWT ────────────────────────
echo ""
echo ">>> [5/5] Вызываем функцию через internal router + JWT (cold start до 180 сек)..."
ROUTER_INT="http://router.fission.svc.cluster.local"
DEADLINE=$((SECONDS + 180))
FN_OK=false
while [ $SECONDS -lt $DEADLINE ]; do
RESULT=$(kubectl run fn-probe-$$ --rm -i --restart=Never \
--image=curlimages/curl:8.7.1 \
--namespace=fission \
-- sh -c "
TOKEN=\$(curl -sf --max-time 15 -X POST ${ROUTER_INT}/auth/login \
-H 'Content-Type: application/json' \
-d '{\"username\":\"admin\",\"password\":\"7XG1lSg0EFqPLE4pf3He\"}' \
| grep -o '\"accesstoken\":\"[^\"]*' | cut -d'\"' -f4)
if [ -z \"\$TOKEN\" ]; then echo 'LOGIN_FAIL|||000'; exit 0; fi
curl -s -w '|||%{http_code}' --max-time 90 \
-H \"Authorization: Bearer \$TOKEN\" \
${ROUTER_INT}/${NS}/hello
" 2>/dev/null | grep "|||" || echo "|||000")
CODE=$(echo "$RESULT" | grep -o '|||[0-9]*' | tr -d '|||')
BODY=$(echo "$RESULT" | sed 's/|||[0-9]*$//')
echo " HTTP $CODE$(echo "$BODY" | head -c 80)"
[ "$CODE" = "200" ] && FN_OK=true && break
sleep 5
done
if [ "$FN_OK" = "true" ]; then
green "Функция ответила 200: $BODY"
else
red "Функция не ответила 200 (timeout)"
echo " Логи router (last 3):"
kubectl logs -n fission deployment/router 2>/dev/null | tail -3
fi
# ── Cleanup ───────────────────────────────────────────────────────────────────
echo ""
echo ">>> Cleanup..."
fission httptrigger delete --name hello-route --namespace "$NS" 2>/dev/null || true
fission fn delete --name hello --namespace "$NS" 2>/dev/null || true
fission env delete --name py-env --namespace "$NS" 2>/dev/null || true
kubectl delete ns "$NS" --wait=false 2>/dev/null || true
echo " Готово."
# ── Итог ─────────────────────────────────────────────────────────────────────
echo ""
echo "========================================"
echo " ИТОГ: PASS=$PASS FAIL=$FAIL"
echo "========================================"
[ $FAIL -eq 0 ] && exit 0 || exit 1
+16
View File
@@ -0,0 +1,16 @@
# Dockerfile для sqs-consumer
# Назначение: поллит SQS-совместимую очередь (shared-sqs) и вызывает Fission-функцию.
# Образ: naeel/sqs-consumer:v1.0
# 2026-05-11
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod ./
COPY main.go ./
RUN go mod tidy && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o sqs-consumer .
FROM gcr.io/distroless/static:nonroot
WORKDIR /
COPY --from=builder /app/sqs-consumer .
USER 65532:65532
ENTRYPOINT ["/sqs-consumer"]
+10
View File
@@ -0,0 +1,10 @@
module sqs-consumer
go 1.22
require (
github.com/aws/aws-sdk-go-v2 v1.26.1
github.com/aws/aws-sdk-go-v2/config v1.27.11
github.com/aws/aws-sdk-go-v2/credentials v1.17.11
github.com/aws/aws-sdk-go-v2/service/sqs v1.31.4
)
+28
View File
@@ -0,0 +1,28 @@
github.com/aws/aws-sdk-go-v2 v1.26.1 h1:5554eUqIYVWpU0YmeeYZ0wU64H2VLBs8TlhRB2L+EkA=
github.com/aws/aws-sdk-go-v2 v1.26.1/go.mod h1:ffIFB97e2yNsv4aTSGkqtHnppsIJzw7G7BReUZ3jCXM=
github.com/aws/aws-sdk-go-v2/config v1.27.11 h1:f47rANd2LQEYHda2ddSCKYId18/8BhSRM4BULGmfgNA=
github.com/aws/aws-sdk-go-v2/config v1.27.11/go.mod h1:SMsV78RIOYdve1vf36z8LmnszlRWkwMQtomCAI0/mIE=
github.com/aws/aws-sdk-go-v2/credentials v1.17.11 h1:YuIB1dJNf1Re822rriUOTxopaHHvIq0l/pX3fwO+Tzs=
github.com/aws/aws-sdk-go-v2/credentials v1.17.11/go.mod h1:AQtFPsDH9bI2O+71anW6EKL+NcD7LG3dpKGMV4SShgo=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1 h1:FVJ0r5XTHSmIHJV6KuDmdYhEpvlHpiSd38RQWhut5J4=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1/go.mod h1:zusuAeqezXzAB24LGuzuekqMAEgWkVYukBec3kr3jUg=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5 h1:aw39xVGeRWlWx9EzGVnhOR4yOjQDHPQ6o6NmBlscyQg=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5/go.mod h1:FSaRudD0dXiMPK2UjknVwwTYyZMRsHv3TtkabsZih5I=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5 h1:PG1F3OD1szkuQPzDw3CIQsRIrtTlUC3lP84taWzHlq0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5/go.mod h1:jU1li6RFryMz+so64PpKtudI+QzbKoIEivqdf6LNpOc=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 h1:Ji0DY1xUsUr3I8cHps0G+XM3WWU16lP6yG8qu1GAZAs=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2/go.mod h1:5CsjAbs3NlGQyZNFACh+zztPDI7fU6eW9QsxjfnuBKg=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.7 h1:ogRAwT1/gxJBcSWDMZlgyFUM962F51A5CRhDLbxLdmo=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.7/go.mod h1:YCsIZhXfRPLFFCl5xxY+1T9RKzOKjCut+28JSX2DnAk=
github.com/aws/aws-sdk-go-v2/service/sqs v1.31.4 h1:mE2ysZMEeQ3ulHWs4mmc4fZEhOfeY1o6QXAfDqjbSgw=
github.com/aws/aws-sdk-go-v2/service/sqs v1.31.4/go.mod h1:lCN2yKnj+Sp9F6UzpoPPTir+tSaC9Jwf6LcmTqnXFZw=
github.com/aws/aws-sdk-go-v2/service/sso v1.20.5 h1:vN8hEbpRnL7+Hopy9dzmRle1xmDc7o8tmY0klsr175w=
github.com/aws/aws-sdk-go-v2/service/sso v1.20.5/go.mod h1:qGzynb/msuZIE8I75DVRCUXw3o3ZyBmUvMwQ2t/BrGM=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.4 h1:Jux+gDDyi1Lruk+KHF91tK2KCuY61kzoCpvtvJJBtOE=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.4/go.mod h1:mUYPBhaF2lGiukDEjJX2BLRRKTmoUSitGDUgM4tRxak=
github.com/aws/aws-sdk-go-v2/service/sts v1.28.6 h1:cwIxeBttqPN3qkaAjcEcsh8NYr8n2HZPkcKgPAi1phU=
github.com/aws/aws-sdk-go-v2/service/sts v1.28.6/go.mod h1:FZf1/nKNEkHdGGJP/cI2MoIMquumuRK6ol3QQJNDxmw=
github.com/aws/smithy-go v1.20.2 h1:tbp628ireGtzcHDDmLT/6ADHidqnwgF57XOXZe6tp4Q=
github.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
+256
View File
@@ -0,0 +1,256 @@
// sqs-consumer — SQS poller → Fission function invoker
//
// Env vars:
// SQS_ENDPOINT — URL SQS сервиса
// SQS_QUEUE_NAME — имя очереди (обязательно)
// SQS_ACCESS_KEY — AccessKeyId тенанта
// SQS_SECRET_KEY — SecretAccessKey тенанта
// SQS_REGION — регион (default: us-east-1)
// FUNCTION_URL — полный URL функции
// ROUTER_USERNAME — логин для /auth/login роутера (optional)
// ROUTER_PASSWORD — пароль для /auth/login роутера (optional)
// ROUTER_LOGIN_URL — URL /auth/login (default: выводится из FUNCTION_URL)
// AUTH_TOKEN — статичный Bearer токен (если ROUTER_USERNAME не задан)
// POLL_INTERVAL — интервал поллинга в секундах (default: 5)
// MAX_MESSAGES — макс. сообщений за раз (default: 1)
// MAX_RETRIES — попыток вызова функции перед skip (default: 3)
//
// 2026-05-12
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/sqs"
)
// tokenManager управляет JWT токеном с автообновлением.
type tokenManager struct {
mu sync.Mutex
client *http.Client
loginURL string
username string
password string
staticToken string
cached string
expiresAt time.Time
}
func newTokenManager(client *http.Client, functionURL, loginURL, username, password, staticToken string) *tokenManager {
if loginURL == "" && username != "" {
if i := strings.Index(functionURL, "://"); i >= 0 {
rest := functionURL[i+3:]
if j := strings.Index(rest, "/"); j >= 0 {
loginURL = functionURL[:i+3] + rest[:j] + "/auth/login"
} else {
loginURL = functionURL + "/auth/login"
}
}
}
return &tokenManager{
client: client,
loginURL: loginURL,
username: username,
password: password,
staticToken: staticToken,
}
}
func (tm *tokenManager) getToken(log *slog.Logger) string {
tm.mu.Lock()
defer tm.mu.Unlock()
if tm.username == "" {
return tm.staticToken
}
if tm.cached != "" && time.Now().Add(30*time.Second).Before(tm.expiresAt) {
return tm.cached
}
token, exp := tm.login(log)
if token != "" {
tm.cached = token
tm.expiresAt = exp
log.Info("router token refreshed", "expiresAt", exp)
}
return tm.cached
}
func (tm *tokenManager) login(log *slog.Logger) (string, time.Time) {
body, _ := json.Marshal(map[string]string{"username": tm.username, "password": tm.password})
resp, err := tm.client.Post(tm.loginURL, "application/json", bytes.NewReader(body))
if err != nil {
log.Error("router login failed", "err", err)
return "", time.Time{}
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
log.Error("router login non-2xx", "status", resp.StatusCode)
return "", time.Time{}
}
var result struct {
AccessToken string `json:"accesstoken"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil || result.AccessToken == "" {
log.Error("router login decode failed", "err", err)
return "", time.Time{}
}
return result.AccessToken, time.Now().Add(5 * time.Minute)
}
func (tm *tokenManager) invalidate() {
tm.mu.Lock()
defer tm.mu.Unlock()
tm.cached = ""
}
func main() {
log := slog.New(slog.NewJSONHandler(os.Stdout, nil))
endpoint := getenv("SQS_ENDPOINT", "http://shared-sqs.shared-sqs.svc.cluster.local:4100")
queueName := mustenv("SQS_QUEUE_NAME")
accessKey := mustenv("SQS_ACCESS_KEY")
secretKey := mustenv("SQS_SECRET_KEY")
region := getenv("SQS_REGION", "us-east-1")
functionURL := mustenv("FUNCTION_URL")
routerUser := getenv("ROUTER_USERNAME", "")
routerPass := getenv("ROUTER_PASSWORD", "")
routerLoginURL := getenv("ROUTER_LOGIN_URL", "")
staticToken := getenv("AUTH_TOKEN", "")
pollSec := parseInt(getenv("POLL_INTERVAL", "5"), 5)
maxMsg := int32(parseInt(getenv("MAX_MESSAGES", "1"), 1))
maxRetries := parseInt(getenv("MAX_RETRIES", "3"), 3)
customResolver := aws.EndpointResolverWithOptionsFunc(
func(service, reg string, opts ...interface{}) (aws.Endpoint, error) {
return aws.Endpoint{URL: endpoint, HostnameImmutable: true}, nil
},
)
cfg, err := awsconfig.LoadDefaultConfig(context.Background(),
awsconfig.WithRegion(region),
awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")),
awsconfig.WithEndpointResolverWithOptions(customResolver),
)
if err != nil {
log.Error("failed to create AWS config", "err", err)
os.Exit(1)
}
client := sqs.NewFromConfig(cfg)
ctx := context.Background()
urlResult, err := client.GetQueueUrl(ctx, &sqs.GetQueueUrlInput{QueueName: aws.String(queueName)})
if err != nil {
log.Error("GetQueueUrl failed", "queue", queueName, "err", err)
os.Exit(1)
}
queueURL := aws.ToString(urlResult.QueueUrl)
log.Info("sqs-consumer started", "queue", queueName, "endpoint", endpoint, "functionURL", functionURL)
httpClient := &http.Client{Timeout: 30 * time.Second}
tm := newTokenManager(httpClient, functionURL, routerLoginURL, routerUser, routerPass, staticToken)
ticker := time.NewTicker(time.Duration(pollSec) * time.Second)
defer ticker.Stop()
for range ticker.C {
poll(ctx, log, client, httpClient, tm, queueURL, functionURL, maxMsg, maxRetries)
}
}
func poll(ctx context.Context, log *slog.Logger, sqsClient *sqs.Client, httpClient *http.Client,
tm *tokenManager, queueURL, functionURL string, maxMsg int32, maxRetries int) {
result, err := sqsClient.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{
QueueUrl: aws.String(queueURL),
MaxNumberOfMessages: maxMsg,
WaitTimeSeconds: 5,
})
if err != nil {
log.Error("SQS ReceiveMessage failed", "err", err)
return
}
for _, msg := range result.Messages {
body := aws.ToString(msg.Body)
receipt := aws.ToString(msg.ReceiptHandle)
if invokeWithRetry(log, httpClient, tm, functionURL, body, maxRetries) {
if _, err := sqsClient.DeleteMessage(ctx, &sqs.DeleteMessageInput{
QueueUrl: aws.String(queueURL),
ReceiptHandle: aws.String(receipt),
}); err != nil {
log.Error("DeleteMessage failed", "err", err)
} else {
log.Info("message processed", "msgId", aws.ToString(msg.MessageId))
}
}
}
}
func invokeWithRetry(log *slog.Logger, client *http.Client, tm *tokenManager, url, body string, maxRetries int) bool {
for attempt := 1; attempt <= maxRetries; attempt++ {
token := tm.getToken(log)
req, _ := http.NewRequest(http.MethodPost, url, bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := client.Do(req)
if err != nil {
log.Warn("function invoke error", "attempt", attempt, "err", err)
time.Sleep(time.Duration(attempt) * time.Second)
continue
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return true
}
if resp.StatusCode == http.StatusUnauthorized {
log.Warn("got 401, refreshing token", "attempt", attempt)
tm.invalidate()
} else {
log.Warn("function returned non-2xx", "attempt", attempt, "status", resp.StatusCode)
}
time.Sleep(time.Duration(attempt) * time.Second)
}
log.Error("all retries exhausted, skipping message", "url", url)
return false
}
func getenv(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func mustenv(key string) string {
v := os.Getenv(key)
if v == "" {
fmt.Fprintf(os.Stderr, "ERROR: env var %s is required\n", key)
os.Exit(1)
}
return v
}
func parseInt(s string, def int) int {
n, err := strconv.Atoi(s)
if err != nil || n <= 0 {
return def
}
return n
}
@@ -4,7 +4,10 @@ import (
"context" "context"
"fmt" "fmt"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors" apierrors "k8s.io/apimachinery/pkg/api/errors"
k8sresource "k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/schema"
@@ -38,6 +41,28 @@ var httpTriggerGVR = schema.GroupVersionResource{
Resource: "httptriggers", Resource: "httptriggers",
} }
var timeTriggerGVR = schema.GroupVersionResource{
Group: "fission.io",
Version: "v1",
Resource: "timetriggers",
}
var iotDeviceGVR = schema.GroupVersionResource{
Group: "iot.kube5s.ru",
Version: "v1alpha1",
Resource: "iotdevices",
}
const (
sqsConsumerImage = "naeel/sqs-consumer:v1.0"
sqsDefaultEndpoint = "http://shared-sqs.shared-sqs.svc.cluster.local:4100"
fissionRouterBase = "http://router.fission.svc.cluster.local"
mqManagedByLabel = "app.kubernetes.io/managed-by"
mqManagedByVal = "fission-terraform"
mqComponentLabel = "component"
mqComponentVal = "mq-trigger"
)
// Client хранит клиентов Kubernetes API для работы с CRD Fission. // Client хранит клиентов Kubernetes API для работы с CRD Fission.
type Client struct { type Client struct {
DynClient dynamic.Interface DynClient dynamic.Interface
@@ -253,3 +278,242 @@ func (c *Client) DeleteHTTPTrigger(ctx context.Context, namespace, name string)
return nil return nil
} }
// ── MQ Trigger (K8s Deployment + Secret) ────────────────────────────
func mqSecretName(name string) string { return "sqs-mq-" + name }
func mqDeployName(name string) string { return "mq-" + name }
// MQTriggerInfo — данные MQ trigger, считанные из K8s.
type MQTriggerInfo struct {
Function string
Queue string
SQSEndpoint string
DeployUID string
}
// CreateMQTrigger создаёт Secret + Deployment для SQS consumer.
func (c *Client) CreateMQTrigger(ctx context.Context, name, ns, functionName, queue, sqsEndpoint, accessKey, secretKey string) (*MQTriggerInfo, error) {
if sqsEndpoint == "" {
sqsEndpoint = sqsDefaultEndpoint
}
functionURL := fissionRouterBase + "/" + functionName
secretName := mqSecretName(name)
deployName := mqDeployName(name)
labels := map[string]string{
mqManagedByLabel: mqManagedByVal,
mqComponentLabel: mqComponentVal,
"mq-trigger-name": name,
}
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: ns, Labels: labels},
StringData: map[string]string{
"SQS_ACCESS_KEY": accessKey,
"SQS_SECRET_KEY": secretKey,
"SQS_ENDPOINT": sqsEndpoint,
},
}
if _, err := c.K8sClient.CoreV1().Secrets(ns).Create(ctx, secret, metav1.CreateOptions{}); err != nil {
return nil, fmt.Errorf("create mq secret %q: %w", secretName, err)
}
replicas := int32(1)
deploy := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: deployName,
Namespace: ns,
Labels: labels,
Annotations: map[string]string{
"fission-terraform/mq-trigger-name": name,
"fission-terraform/function": functionName,
"fission-terraform/queue": queue,
"fission-terraform/sqs-endpoint": sqsEndpoint,
},
},
Spec: appsv1.DeploymentSpec{
Replicas: &replicas,
Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"mq-trigger-name": name}},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{
"mq-trigger-name": name,
mqComponentLabel: mqComponentVal,
}},
Spec: corev1.PodSpec{
Containers: []corev1.Container{{
Name: "sqs-consumer",
Image: sqsConsumerImage,
ImagePullPolicy: corev1.PullAlways,
Env: []corev1.EnvVar{
{Name: "SQS_QUEUE_NAME", Value: queue},
{Name: "SQS_REGION", Value: "us-east-1"},
{Name: "FUNCTION_URL", Value: functionURL},
{Name: "POLL_INTERVAL", Value: "5"},
{Name: "MAX_MESSAGES", Value: "1"},
{Name: "MAX_RETRIES", Value: "3"},
},
EnvFrom: []corev1.EnvFromSource{{
SecretRef: &corev1.SecretEnvSource{
LocalObjectReference: corev1.LocalObjectReference{Name: secretName},
},
}},
Resources: corev1.ResourceRequirements{
Limits: corev1.ResourceList{
corev1.ResourceCPU: k8sresource.MustParse("50m"),
corev1.ResourceMemory: k8sresource.MustParse("32Mi"),
},
Requests: corev1.ResourceList{
corev1.ResourceCPU: k8sresource.MustParse("10m"),
corev1.ResourceMemory: k8sresource.MustParse("16Mi"),
},
},
}},
},
},
},
}
created, err := c.K8sClient.AppsV1().Deployments(ns).Create(ctx, deploy, metav1.CreateOptions{})
if err != nil {
_ = c.K8sClient.CoreV1().Secrets(ns).Delete(ctx, secretName, metav1.DeleteOptions{})
return nil, fmt.Errorf("create mq deployment %q: %w", deployName, err)
}
return &MQTriggerInfo{
Function: functionName,
Queue: queue,
SQSEndpoint: sqsEndpoint,
DeployUID: string(created.UID),
}, nil
}
// GetMQTrigger возвращает информацию о MQ trigger по аннотациям Deployment.
func (c *Client) GetMQTrigger(ctx context.Context, ns, name string) (*MQTriggerInfo, error) {
deploy, err := c.K8sClient.AppsV1().Deployments(ns).Get(ctx, mqDeployName(name), metav1.GetOptions{})
if err != nil {
return nil, fmt.Errorf("get mq deployment %q: %w", name, err)
}
ann := deploy.Annotations
return &MQTriggerInfo{
Function: ann["fission-terraform/function"],
Queue: ann["fission-terraform/queue"],
SQSEndpoint: ann["fission-terraform/sqs-endpoint"],
DeployUID: string(deploy.UID),
}, nil
}
// DeleteMQTrigger удаляет Deployment + Secret MQ trigger.
func (c *Client) DeleteMQTrigger(ctx context.Context, ns, name string) error {
dErr := c.K8sClient.AppsV1().Deployments(ns).Delete(ctx, mqDeployName(name), metav1.DeleteOptions{})
sErr := c.K8sClient.CoreV1().Secrets(ns).Delete(ctx, mqSecretName(name), metav1.DeleteOptions{})
if dErr != nil && !apierrors.IsNotFound(dErr) {
return fmt.Errorf("delete mq deployment %q: %w", name, dErr)
}
if sErr != nil && !apierrors.IsNotFound(sErr) {
return fmt.Errorf("delete mq secret %q: %w", name, sErr)
}
return nil
}
// ── TimeTrigger (Fission CRD) ────────────────────────────────────────
// CreateTimeTrigger создаёт объект TimeTrigger (CRON) в заданном namespace.
func (c *Client) CreateTimeTrigger(ctx context.Context, tt *unstructured.Unstructured) (*unstructured.Unstructured, error) {
created, err := c.DynClient.Resource(timeTriggerGVR).Namespace(tt.GetNamespace()).Create(ctx, tt, metav1.CreateOptions{})
if err != nil {
return nil, fmt.Errorf("create time trigger %q: %w", tt.GetName(), err)
}
return created, nil
}
// GetTimeTrigger возвращает объект TimeTrigger по имени.
func (c *Client) GetTimeTrigger(ctx context.Context, namespace, name string) (*unstructured.Unstructured, error) {
tt, err := c.DynClient.Resource(timeTriggerGVR).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return nil, fmt.Errorf("get time trigger %q: %w", name, err)
}
return tt, nil
}
// UpdateTimeTrigger обновляет объект TimeTrigger.
func (c *Client) UpdateTimeTrigger(ctx context.Context, tt *unstructured.Unstructured) (*unstructured.Unstructured, error) {
updated, err := c.DynClient.Resource(timeTriggerGVR).Namespace(tt.GetNamespace()).Update(ctx, tt, metav1.UpdateOptions{})
if err != nil {
return nil, fmt.Errorf("update time trigger %q: %w", tt.GetName(), err)
}
return updated, nil
}
// DeleteTimeTrigger удаляет объект TimeTrigger по имени.
func (c *Client) DeleteTimeTrigger(ctx context.Context, namespace, name string) error {
if err := c.DynClient.Resource(timeTriggerGVR).Namespace(namespace).Delete(ctx, name, metav1.DeleteOptions{}); err != nil {
return fmt.Errorf("delete time trigger %q: %w", name, err)
}
return nil
}
// ── IoTDevice (CRD iot.kube5s.ru/v1alpha1) ──────────────────────────
// IoTDeviceInfo — данные IoTDevice из статуса CRD.
type IoTDeviceInfo struct {
Phase string
MQTTUsername string
SecretName string
TopicPrefix string
}
// CreateIoTDevice создаёт объект IoTDevice.
func (c *Client) CreateIoTDevice(ctx context.Context, name, namespace, deviceID string, metadata map[string]string) (*IoTDeviceInfo, error) {
obj := &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "iot.kube5s.ru/v1alpha1",
"kind": "IoTDevice",
"metadata": map[string]any{
"name": name,
"namespace": namespace,
},
"spec": map[string]any{
"deviceId": deviceID,
"enabled": true,
"metadata": metadata,
},
},
}
created, err := c.DynClient.Resource(iotDeviceGVR).Namespace(namespace).Create(ctx, obj, metav1.CreateOptions{})
if err != nil {
return nil, fmt.Errorf("create iot device %q: %w", name, err)
}
return extractIoTDeviceInfo(created), nil
}
// GetIoTDevice возвращает IoTDevice по имени.
func (c *Client) GetIoTDevice(ctx context.Context, namespace, name string) (*IoTDeviceInfo, error) {
obj, err := c.DynClient.Resource(iotDeviceGVR).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return nil, fmt.Errorf("get iot device %q: %w", name, err)
}
return extractIoTDeviceInfo(obj), nil
}
// DeleteIoTDevice удаляет IoTDevice по имени.
func (c *Client) DeleteIoTDevice(ctx context.Context, namespace, name string) error {
if err := c.DynClient.Resource(iotDeviceGVR).Namespace(namespace).Delete(ctx, name, metav1.DeleteOptions{}); err != nil {
return fmt.Errorf("delete iot device %q: %w", name, err)
}
return nil
}
func extractIoTDeviceInfo(obj *unstructured.Unstructured) *IoTDeviceInfo {
status, _, _ := unstructured.NestedMap(obj.Object, "status")
phase, _ := status["phase"].(string)
mqttUsername, _ := status["mqttUsername"].(string)
secretName, _ := status["secretName"].(string)
topicPrefix, _ := status["topicPrefix"].(string)
return &IoTDeviceInfo{
Phase: phase,
MQTTUsername: mqttUsername,
SecretName: secretName,
TopicPrefix: topicPrefix,
}
}
@@ -99,6 +99,9 @@ func (p *FissionProvider) Resources(_ context.Context) []func() resource.Resourc
resources.NewFunctionResource, resources.NewFunctionResource,
resources.NewHTTPTriggerResource, resources.NewHTTPTriggerResource,
resources.NewSimpleFunctionResource, resources.NewSimpleFunctionResource,
resources.NewMQTriggerResource,
resources.NewCronTriggerResource,
resources.NewIoTDeviceResource,
} }
} }
@@ -0,0 +1,208 @@
package resources
import (
"context"
"fmt"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/types"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"terraform-provider-fission/internal/client"
)
var _ resource.Resource = &CronTriggerResource{}
// CronTriggerResource управляет Fission TimeTrigger (CRON).
type CronTriggerResource struct {
client *client.Client
}
type cronTriggerResourceModel struct {
ID types.String `tfsdk:"id"`
Name types.String `tfsdk:"name"`
Namespace types.String `tfsdk:"namespace"`
Function types.String `tfsdk:"function"`
Cron types.String `tfsdk:"cron"`
UID types.String `tfsdk:"uid"`
}
func NewCronTriggerResource() resource.Resource {
return &CronTriggerResource{}
}
func (r *CronTriggerResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_cron_trigger"
}
func (r *CronTriggerResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "Fission TimeTrigger — запускает функцию по CRON-расписанию.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
Description: "Идентификатор ресурса (namespace/name).",
},
"name": schema.StringAttribute{
Required: true,
Description: "Имя TimeTrigger.",
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"namespace": schema.StringAttribute{
Optional: true,
Computed: true,
Description: "Kubernetes namespace.",
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"function": schema.StringAttribute{
Required: true,
Description: "Имя Fission Function для запуска.",
},
"cron": schema.StringAttribute{
Required: true,
Description: "CRON-расписание, например \"*/10 * * * *\".",
},
"uid": schema.StringAttribute{
Computed: true,
Description: "UID объекта в Kubernetes.",
},
},
}
}
func (r *CronTriggerResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
c, ok := req.ProviderData.(*client.Client)
if !ok {
resp.Diagnostics.AddError("Неверный тип провайдера", fmt.Sprintf("ожидался *client.Client, получен %T", req.ProviderData))
return
}
r.client = c
}
func (r *CronTriggerResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan cronTriggerResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
ns := plan.Namespace.ValueString()
if ns == "" {
ns = r.client.Namespace
}
obj := buildTimeTrigger(plan.Name.ValueString(), ns, plan.Function.ValueString(), plan.Cron.ValueString())
created, err := r.client.CreateTimeTrigger(ctx, obj)
if err != nil {
resp.Diagnostics.AddError("Ошибка создания CRON trigger", err.Error())
return
}
plan.ID = types.StringValue(ns + "/" + plan.Name.ValueString())
plan.Namespace = types.StringValue(ns)
plan.UID = types.StringValue(string(created.GetUID()))
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
func (r *CronTriggerResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state cronTriggerResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
obj, err := r.client.GetTimeTrigger(ctx, state.Namespace.ValueString(), state.Name.ValueString())
if err != nil {
if client.IsNotFound(err) {
resp.State.RemoveResource(ctx)
return
}
resp.Diagnostics.AddError("Ошибка чтения CRON trigger", err.Error())
return
}
spec, _, _ := unstructured.NestedMap(obj.Object, "spec")
if cron, ok := spec["cron"].(string); ok {
state.Cron = types.StringValue(cron)
}
if fnRef, ok := spec["functionref"].(map[string]any); ok {
if name, ok := fnRef["name"].(string); ok {
state.Function = types.StringValue(name)
}
}
state.UID = types.StringValue(string(obj.GetUID()))
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *CronTriggerResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan cronTriggerResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
existing, err := r.client.GetTimeTrigger(ctx, plan.Namespace.ValueString(), plan.Name.ValueString())
if err != nil {
resp.Diagnostics.AddError("Ошибка получения CRON trigger для обновления", err.Error())
return
}
if err := unstructured.SetNestedField(existing.Object, plan.Cron.ValueString(), "spec", "cron"); err != nil {
resp.Diagnostics.AddError("Ошибка обновления cron", err.Error())
return
}
if err := unstructured.SetNestedField(existing.Object, plan.Function.ValueString(), "spec", "functionref", "name"); err != nil {
resp.Diagnostics.AddError("Ошибка обновления function", err.Error())
return
}
updated, err := r.client.UpdateTimeTrigger(ctx, existing)
if err != nil {
resp.Diagnostics.AddError("Ошибка обновления CRON trigger", err.Error())
return
}
plan.UID = types.StringValue(string(updated.GetUID()))
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
func (r *CronTriggerResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state cronTriggerResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.client.DeleteTimeTrigger(ctx, state.Namespace.ValueString(), state.Name.ValueString()); err != nil {
resp.Diagnostics.AddError("Ошибка удаления CRON trigger", err.Error())
}
}
func buildTimeTrigger(name, ns, functionName, cron string) *unstructured.Unstructured {
return &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "TimeTrigger",
"metadata": map[string]any{
"name": name,
"namespace": ns,
},
"spec": map[string]any{
"cron": cron,
"functionref": map[string]any{
"type": "name",
"name": functionName,
},
},
},
}
}
@@ -0,0 +1,230 @@
package resources
import (
"context"
"fmt"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/mapdefault"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/types"
"terraform-provider-fission/internal/client"
)
var _ resource.Resource = &IoTDeviceResource{}
// IoTDeviceResource управляет CRD IoTDevice (iot.kube5s.ru/v1alpha1).
type IoTDeviceResource struct {
client *client.Client
}
type iotDeviceResourceModel struct {
ID types.String `tfsdk:"id"`
Name types.String `tfsdk:"name"`
Namespace types.String `tfsdk:"namespace"`
DeviceID types.String `tfsdk:"device_id"`
Enabled types.Bool `tfsdk:"enabled"`
Metadata types.Map `tfsdk:"metadata"`
Phase types.String `tfsdk:"phase"`
MQTTUsername types.String `tfsdk:"mqtt_username"`
SecretName types.String `tfsdk:"secret_name"`
TopicPrefix types.String `tfsdk:"topic_prefix"`
}
func NewIoTDeviceResource() resource.Resource {
return &IoTDeviceResource{}
}
func (r *IoTDeviceResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_iot_device"
}
func (r *IoTDeviceResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "IoT-устройство, зарегистрированное в платформе sless (CRD iot.kube5s.ru/v1alpha1/IoTDevice).",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
Description: "Идентификатор ресурса (namespace/name).",
},
"name": schema.StringAttribute{
Required: true,
Description: "Имя IoTDevice.",
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"namespace": schema.StringAttribute{
Optional: true,
Computed: true,
Default: stringdefault.StaticString("sless"),
Description: "Namespace IoTDevice. По умолчанию sless.",
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"device_id": schema.StringAttribute{
Required: true,
Description: "Уникальный ID устройства внутри namespace (строчные буквы, цифры, дефис).",
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"enabled": schema.BoolAttribute{
Optional: true,
Computed: true,
Default: booldefault.StaticBool(true),
Description: "Активно ли устройство.",
},
"metadata": schema.MapAttribute{
Optional: true,
Computed: true,
ElementType: types.StringType,
Default: mapdefault.StaticValue(types.MapValueMust(types.StringType, map[string]attr.Value{})),
Description: "Произвольные метаданные устройства (модель, локация и т.д.).",
},
// Computed (заполняет iot-operator)
"phase": schema.StringAttribute{
Computed: true,
Description: "Текущая фаза: Active, Disabled, Pending, Error.",
},
"mqtt_username": schema.StringAttribute{
Computed: true,
Description: "MQTT username, выданный iot-operator.",
},
"secret_name": schema.StringAttribute{
Computed: true,
Description: "Имя K8s Secret с MQTT credentials.",
},
"topic_prefix": schema.StringAttribute{
Computed: true,
Description: "MQTT topic prefix для публикации.",
},
},
}
}
func (r *IoTDeviceResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
c, ok := req.ProviderData.(*client.Client)
if !ok {
resp.Diagnostics.AddError("Неверный тип провайдера", fmt.Sprintf("ожидался *client.Client, получен %T", req.ProviderData))
return
}
r.client = c
}
func (r *IoTDeviceResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan iotDeviceResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
ns := plan.Namespace.ValueString()
meta := map[string]string{}
if !plan.Metadata.IsNull() && !plan.Metadata.IsUnknown() {
elems := plan.Metadata.Elements()
for k, v := range elems {
if sv, ok := v.(types.String); ok {
meta[k] = sv.ValueString()
}
}
}
info, err := r.client.CreateIoTDevice(ctx, plan.Name.ValueString(), ns, plan.DeviceID.ValueString(), meta)
if err != nil {
resp.Diagnostics.AddError("Ошибка создания IoT device", err.Error())
return
}
plan.ID = types.StringValue(ns + "/" + plan.Name.ValueString())
plan.Phase = types.StringValue(info.Phase)
plan.MQTTUsername = types.StringValue(info.MQTTUsername)
plan.SecretName = types.StringValue(info.SecretName)
plan.TopicPrefix = types.StringValue(info.TopicPrefix)
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
func (r *IoTDeviceResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state iotDeviceResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
info, err := r.client.GetIoTDevice(ctx, state.Namespace.ValueString(), state.Name.ValueString())
if err != nil {
if client.IsNotFound(err) {
resp.State.RemoveResource(ctx)
return
}
resp.Diagnostics.AddError("Ошибка чтения IoT device", err.Error())
return
}
state.Phase = types.StringValue(info.Phase)
state.MQTTUsername = types.StringValue(info.MQTTUsername)
state.SecretName = types.StringValue(info.SecretName)
state.TopicPrefix = types.StringValue(info.TopicPrefix)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *IoTDeviceResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
// name/namespace/device_id — RequiresReplace, значит Update только для enabled/metadata.
// Для простоты: пересоздаём объект.
var plan iotDeviceResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
ns := plan.Namespace.ValueString()
name := plan.Name.ValueString()
if err := r.client.DeleteIoTDevice(ctx, ns, name); err != nil && !client.IsNotFound(err) {
resp.Diagnostics.AddError("Ошибка удаления IoT device при обновлении", err.Error())
return
}
meta := map[string]string{}
if !plan.Metadata.IsNull() && !plan.Metadata.IsUnknown() {
elems := plan.Metadata.Elements()
for k, v := range elems {
if sv, ok := v.(types.String); ok {
meta[k] = sv.ValueString()
}
}
}
info, err := r.client.CreateIoTDevice(ctx, name, ns, plan.DeviceID.ValueString(), meta)
if err != nil {
resp.Diagnostics.AddError("Ошибка создания IoT device при обновлении", err.Error())
return
}
plan.Phase = types.StringValue(info.Phase)
plan.MQTTUsername = types.StringValue(info.MQTTUsername)
plan.SecretName = types.StringValue(info.SecretName)
plan.TopicPrefix = types.StringValue(info.TopicPrefix)
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
func (r *IoTDeviceResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state iotDeviceResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.client.DeleteIoTDevice(ctx, state.Namespace.ValueString(), state.Name.ValueString()); err != nil {
resp.Diagnostics.AddError("Ошибка удаления IoT device", err.Error())
}
}
@@ -0,0 +1,205 @@
package resources
import (
"context"
"fmt"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/types"
"terraform-provider-fission/internal/client"
)
var _ resource.Resource = &MQTriggerResource{}
// MQTriggerResource управляет MQ-триггером через K8s Deployment+Secret (sqs-consumer).
type MQTriggerResource struct {
client *client.Client
}
type mqTriggerResourceModel struct {
ID types.String `tfsdk:"id"`
Name types.String `tfsdk:"name"`
Namespace types.String `tfsdk:"namespace"`
Function types.String `tfsdk:"function"`
Queue types.String `tfsdk:"queue"`
AccessKey types.String `tfsdk:"access_key"`
SecretKey types.String `tfsdk:"secret_key"`
SQSEndpoint types.String `tfsdk:"sqs_endpoint"`
}
func NewMQTriggerResource() resource.Resource {
return &MQTriggerResource{}
}
func (r *MQTriggerResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_mq_trigger"
}
func (r *MQTriggerResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "MQ-триггер через K8s Deployment (sqs-consumer) + Secret с SQS credentials.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
Description: "Идентификатор ресурса (namespace/name).",
},
"name": schema.StringAttribute{
Required: true,
Description: "Имя MQ trigger.",
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"namespace": schema.StringAttribute{
Optional: true,
Computed: true,
Description: "Kubernetes namespace.",
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"function": schema.StringAttribute{
Required: true,
Description: "Имя Fission Function, к которой направляются сообщения из очереди.",
},
"queue": schema.StringAttribute{
Required: true,
Description: "Имя SQS очереди.",
},
"access_key": schema.StringAttribute{
Required: true,
Sensitive: true,
Description: "SQS Access Key.",
},
"secret_key": schema.StringAttribute{
Required: true,
Sensitive: true,
Description: "SQS Secret Key.",
},
"sqs_endpoint": schema.StringAttribute{
Optional: true,
Computed: true,
Default: stringdefault.StaticString("http://shared-sqs.shared-sqs.svc.cluster.local:4100"),
Description: "SQS endpoint URL. По умолчанию — внутренний shared-sqs.",
},
},
}
}
func (r *MQTriggerResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
c, ok := req.ProviderData.(*client.Client)
if !ok {
resp.Diagnostics.AddError("Неверный тип провайдера", fmt.Sprintf("ожидался *client.Client, получен %T", req.ProviderData))
return
}
r.client = c
}
func (r *MQTriggerResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan mqTriggerResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
ns := plan.Namespace.ValueString()
if ns == "" {
ns = r.client.Namespace
}
info, err := r.client.CreateMQTrigger(
ctx,
plan.Name.ValueString(),
ns,
plan.Function.ValueString(),
plan.Queue.ValueString(),
plan.SQSEndpoint.ValueString(),
plan.AccessKey.ValueString(),
plan.SecretKey.ValueString(),
)
if err != nil {
resp.Diagnostics.AddError("Ошибка создания MQ trigger", err.Error())
return
}
plan.ID = types.StringValue(ns + "/" + plan.Name.ValueString())
plan.Namespace = types.StringValue(ns)
plan.SQSEndpoint = types.StringValue(info.SQSEndpoint)
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
func (r *MQTriggerResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state mqTriggerResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
ns := state.Namespace.ValueString()
info, err := r.client.GetMQTrigger(ctx, ns, state.Name.ValueString())
if err != nil {
if client.IsNotFound(err) {
resp.State.RemoveResource(ctx)
return
}
resp.Diagnostics.AddError("Ошибка чтения MQ trigger", err.Error())
return
}
state.Function = types.StringValue(info.Function)
state.Queue = types.StringValue(info.Queue)
state.SQSEndpoint = types.StringValue(info.SQSEndpoint)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *MQTriggerResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan mqTriggerResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
ns := plan.Namespace.ValueString()
name := plan.Name.ValueString()
// Удаляем старый и создаём заново с новыми параметрами.
if err := r.client.DeleteMQTrigger(ctx, ns, name); err != nil {
resp.Diagnostics.AddError("Ошибка удаления MQ trigger при обновлении", err.Error())
return
}
info, err := r.client.CreateMQTrigger(
ctx, name, ns,
plan.Function.ValueString(),
plan.Queue.ValueString(),
plan.SQSEndpoint.ValueString(),
plan.AccessKey.ValueString(),
plan.SecretKey.ValueString(),
)
if err != nil {
resp.Diagnostics.AddError("Ошибка создания MQ trigger при обновлении", err.Error())
return
}
plan.SQSEndpoint = types.StringValue(info.SQSEndpoint)
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
func (r *MQTriggerResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state mqTriggerResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.client.DeleteMQTrigger(ctx, state.Namespace.ValueString(), state.Name.ValueString()); err != nil {
resp.Diagnostics.AddError("Ошибка удаления MQ trigger", err.Error())
}
}
-66
View File
@@ -1,66 +0,0 @@
ntazetdinov@nubes.ru
stxVxLvM9eqssgZ
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoLWFwaSIsInN1YiI6IjAxOWQ3NmM1LTQyNGMtN2MyYy04OWI3LWUzZjA5OTQ5YTk4ZiIsImV4cCI6MTc5MzA3ODQzOSwiaWF0IjoxNzc3NTI2NDM5LCJqdGkiOiI4MTU1ODNkNy0xZTRkLTRiMmMtOGM5ZS0wZDZmMGU2NmJjNTciLCJhdXRoX3RpbWUiOjAsInR5cCI6IiIsImF6cCI6IiIsInNlc3Npb25fc3RhdGUiOiIiLCJhY3IiOiIiLCJhbGxvd2VkLW9yaWdpbnMiOm51bGwsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6bnVsbH0sInJlc291cmNlX2FjY2VzcyI6eyJhY2NvdW50Ijp7InJvbGVzIjpudWxsfX0sInNjb3BlIjoiIiwic2lkIjoiIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJuYW1lIjoiIiwiQ2xpZW50SUQiOiJXWjAxMTEyIiwiY29tcGFueV9pZCI6IjhlYzcwYWMwLTU0NmQtNDJhNy04Y2ZmLTMzOWM4ZmI1MWEyMyIsImNvbXBhbnlfbmFtZSI6ItCe0J7QniDCq9Cd0KPQkdCV0KHCuyIsImlkcF91c3JfdWlkIjoiMDE5ZDc2YzUtNDI0Yy03YzJjLTg5YjctZTNmMDk5NDlhOThmIiwibG9naW4iOiJudGF6ZXRkaW5vdkBudWJlcy5ydSIsImZpcnN0bmFtZSI6ItCd0LDQuNC70YwiLCJtaWRkbGVuYW1lIjoi0KTQsNGA0LjRgtC-0LLQuNGHIiwibGFzdG5hbWUiOiLQotCw0LfQtdGC0LTQuNC90L7QsiIsImdyb3VwcyI6bnVsbCwicHJlZmVycmVkX3VzZXJuYW1lIjoiIiwiZ2l2ZW5fbmFtZSI6IiIsImZhbWlseV9uYW1lIjoiIiwiZW1haWwiOiJudGF6ZXRkaW5vdkBudWJlcy5ydSJ9.SaZsKAha45-fvKuJkLHUe_09AFsbH5QpzBVdPnjiEDQGIhl1A3ThgnM-oEh_H6CBXXfnOs2QDczzgCD8K8_tYzZKU1Wgk5lj04YW_fbTI89kHTO5wLtrAht9tFEjzBQZ-kmnG8mUK5tgqyNEjsngdQcfqVWRvneF366TiiRXk_76poUPpXWQmqdgCCKb3wq1rRgWcKDZcUC3JvDpOaqYL40zITZlM855drlJhMut4Gkg-EEk7ZGykl6YCsTRWVjFKGQ-7C-BDU2dkmIWhxzBAkvzzM2kBwvtlczCEDiUin7bXE-F_io89oJUYDzY4yPeHzwREwxCp6x9eD1NAIKKuA
----------- token on VM --------------
https://deck-api-test.ngcloud.ru/api/v1 - для тест стенда где постгрес, файл с токеном -
naeel@naeel-vm:~/terra/sless$ ll test.token
-rw-r--r-- 1 naeel naeel 998 Feb 28 07:18 test.token
--------------------- pg IoT-PG -----------------------
users
JSON объект (раскройте для просмотра)
password:
BQUF5ruECa1ZFlq4wYt3gPJUEmtBMkA9QNK4MM5Sd8al4ArMDlmT16DIKHYBPyif
username:
super
Поля состояния (сгенерированные значения)
monitoring
{
"resourceMetrics": "https://grafana.ngcloud.ru/d/vzjb4zd/kubernetes-pods?orgId=954&var-namespace=dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5&var-resource_realm=iot-naeel"
}
users
{
"super": {
"role": "ddl_user",
"username": "super"
}
}
backups
{}
databases
{
"sqsdb": {
"dbName": "sqsdb",
"dbOwner": "super"
}
}
externalConnect
{
"slave": {
"ip": "",
"fqdn": "",
"uuid": "",
"isExternal": false
},
"master": {
"ip": "",
"fqdn": "",
"uuid": "",
"isExternal": false
}
}
internalConnect
{
"slave": "",
"master": "postgresqlk8s-master.dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5.svc.cluster.local"
}

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