Compare commits

..
Author SHA1 Message Date
“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
“Naeel” aaa1b9a82d feat: billing layer v1.3.86 — PostgreSQL statistics
- internal/billing: Store interface + pgStore (pgx/v5 pool) + NoopStore
- factory.go: NewStore() from BILLING_DSN env var (NoopStore fallback)
- Server.billing: injected into Config, initialized in main
- handleInvokeFunction: record invocation (TriggerConsole)
- invokeInternalFunction: record invocation (TriggerHTTP)
- handleCreateFunction: record event_type=create
- handleCloneFunction: record event_type=clone
- handleDeleteFunction: record event_type=delete
- table: invocations (namespace, function_name, trigger_type, duration_ms, status_code...)
- BILLING_DSN added to console.yaml
- pgx/v5 added to go.mod/go.sum
2026-05-10 08:26:42 +04:00
“Naeel” 11c90bac9e docs: progress.md — v1.3.78-v1.3.85, env vars, clone, tests 2026-05-10 07:49:00 +04:00
“Naeel” 28bbc58293 test: function_clone — 21 unit tests (validation, clone, rollback, download, triggers) 2026-05-10 07:47:04 +04:00
“Naeel” 1ea0c5a9de fix: clone — poll until function visible in list, up to 5 attempts (v1.3.85) 2026-05-09 20:53:17 +04:00
“Naeel” f729bbfd85 feat: help + llm prompts — env vars and clone sections (v1.3.84) 2026-05-09 20:47:09 +04:00
“Naeel” 47caff5c7e fix: clone — delay 800ms before reloadAll to avoid race with k8s list (v1.3.83) 2026-05-09 20:44:05 +04:00
“Naeel” 26728aadbe fix: clone — apiFetch → requestJSON (v1.3.82) 2026-05-09 20:35:26 +04:00
“Naeel” 0a4f64143c fix: clone — loadFunctions → reloadAll (v1.3.81) 2026-05-09 20:31:54 +04:00
“Naeel” d8b36631ee feat: clone function — POST /functions/:name/clone (v1.3.80)
- Backend: function_clone.go — copies archive from storagesvc, creates
  new Package/Function/HTTPTrigger preserving all settings (env vars,
  timeout, entrypoint, executor type, podspec)
- Backend: route handlers.go — /functions/:name/clone POST
- Frontend: 'Клон' button in edit modal, inline name input form
- Frontend: JS toggleCloneArea/submitClone with validation
- Pre-fills clone name as '<original>-copy', validates format
2026-05-09 20:22:51 +04:00
“Naeel” 921c0b5c16 fix: remove LLM topic restriction, bump v1.3.79 2026-05-09 08:58:13 +04:00
“Naeel” bba5904c5f fix: remove s.log (no logger field in Server) 2026-05-09 08:48:01 +04:00
“Naeel” 924375b083 feat: patch Deployment env vars directly via k8s API (v1.3.78)
Fission newdeploy executor does NOT apply fn.Spec.PodSpec when building
Deployment spec - it only uses env.Spec.Runtime.Container and podSpecPatch.

Fix: after updating Function CRD, also directly patch the newdeploy
Deployment via Kubernetes API using StrategicMergePatch, searching by
labels functionName/functionNamespace/executorType=newdeploy.

This ensures env vars immediately appear in the container and survive
pod scaling events (scale to 0 / scale back to 1).
2026-05-09 08:38:59 +04:00
“Naeel” 7887d4597d fix: env vars через newdeploy+podspec — попадают в pod OS env для всех языков 2026-05-09 07:49:36 +04:00
“Naeel” 089994256c fix: валидация имён env vars — только [A-Za-z_][A-Za-z0-9_]* 2026-05-09 07:22:35 +04:00
“Naeel” ee97eebbff fix: ошибка валидации env vars — выводить внутри модалки 2026-05-09 07:06:56 +04:00
“Naeel” c8eaef0e1a fix: валидация дублей ключей env vars + подсветка красным 2026-05-09 07:02:10 +04:00
“Naeel” 4a92e3f372 fix: env vars хранятся в аннотации fission-console/env-vars (не в spec.runtime)
spec.runtime.container.env срезается CRD-схемой Fission.
Хранение перенесено в аннотацию fission-console/env-vars (JSON-массив).
Версия v1.3.73
2026-05-09 06:57:29 +04:00
“Naeel” 1fa92e069f feat: переменные окружения функций (env vars в CRD)
- Backend: handleGetFunctionEnvVars / handlePutFunctionEnvVars в function_crud.go
- API: GET/PUT /functions/:name/envvars
- handleGetFunction теперь возвращает env_vars в ответе
- Frontend: блок env vars в edit modal (key=value + [×] + [+ Добавить])
- TF-функции показывают env vars в режиме read-only
- Документация: doc/descriptions/function-envvars-flow.md
- Версия v1.3.72
2026-05-09 06:50:52 +04:00
“Naeel” fb469d9c5b bump: версия v1.3.71 (исправлен статус Building) 2026-05-09 06:38:54 +04:00
“Naeel” 80ea566816 fix: статус Building только для активной сборки (не для none) 2026-05-09 06:36:01 +04:00
“Naeel” f6f9382887 feat: функции со статусом (Ready/Cold/Building/Error)
- Backend: новый файл status.go с функцией определения статуса пода и Package buildStatus
- API: GET /functions теперь возвращает функции с полем status в аннотациях
- Frontend: добавлена колонка Status в таблицу функций с цветными кругами
- Легенда статусов выше таблицы
- Версия v1.3.70
2026-05-09 06:29:46 +04:00
100 changed files with 7657 additions and 211 deletions
+7 -7
View File
@@ -28,16 +28,16 @@
1. Не трогать рабочий код без явного указания.
2. Файлы редактируются локально:
~/fission
~/fission-console
После ЛЮБЫХ изменений ОБЯЗАТЕЛЬНО синхронизировать на ВМ командой:
rsync -az \
-e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \
~/fission/ \
naeel@5.172.178.213:~/terra/fission/
После ЛЮБЫХ изменений ОБЯЗАТЕЛЬНО синхронизировать на ВМ командой:
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/
3. Git (add/commit/push) выполнять ЛОКАЛЬНО в ~/fission
3. Git (add/commit/push) выполнять ЛОКАЛЬНО в ~/fission-console
4. Docker, kubectl и другие инфраструктурные команды — только через SSH на ВМ:
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 на ВМ:
rsync -az \
-e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \
~/fission/ \
~/fission-console/ \
naeel@5.172.178.213:~/terra/fission/
3. Git (add/commit/push) выполнять ЛОКАЛЬНО в ~/fission
3. Git (add/commit/push) выполнять ЛОКАЛЬНО в ~/fission-console
4. Docker, kubectl и другие инфраструктурные команды — только через SSH на ВМ
5. Перед запуском любой команды на ВМ обязательно убедиться, что синхронизация (rsync) выполнена
6. SCP, sshfs, remote_dev и маунты больше НЕ используются
@@ -50,7 +50,7 @@ ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=
## Git
⛔⛔⛔ АБСОЛЮТНОЕ ПРАВИЛО:
- Git — ТОЛЬКО ЛОКАЛЬНО в `~/fission`. НИКОГДА через SSH на VM.
- Git — ТОЛЬКО ЛОКАЛЬНО в `~/fission-console`. НИКОГДА через SSH на VM.
- Разрешены ТОЛЬКО две операции: `git commit` и `git push`.
- ЗАПРЕЩЕНО: git pull, git fetch, git rebase, git merge, git reset, git stash, git checkout — что угодно кроме commit и push.
- Если 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 \
"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" \
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/
```
**Никогда не разбираться с результатами по памяти / буферу / чату. Только лог.**
+8
View File
@@ -2,6 +2,7 @@
.terraform/
*.tfstate
*.tfstate.*
*.tfvars
# Go
bin/
@@ -15,3 +16,10 @@ examples/*/dist/
terraform-provider-fission
terraform-provider-fission_*
console/fission-console
# Secrets / tokens
token.txt
# Test logs
test-results/
scripts/test_layer1.sh
+87
View File
@@ -0,0 +1,87 @@
# Образы нашего Fission — актуальные теги
> Обновлено: 2026-05-19
## Текущие образы (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.94` |
| **Python environment** | `naeel/fission-python-env` | `v1.1` |
| **metrics-collector** | `naeel/metrics-collector` | `v0.1.3` |
---
## Где в коде консоли прописаны образы
### 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 'КОМАНДА'
```
+20
View File
@@ -0,0 +1,20 @@
MQ-триггер (MessageQueue) — запуск при появлении сообщения в очереди (Kafka, NATS, RabbitMQ и т.д.)
Kube-Watch триггер — запуск при изменении объектов в Kubernetes - а это разве может быть реализовано ?
В Fission — да, это штатные встроенные типы триггеров. Fission из коробки поддерживает MQ (Kafka, NATS, Azure Queue) и Kube-Watch через отдельные компоненты (kubewatcher, mqtrigger).
Но в вашей консоли это не реализовано — нет UI для создания/управления этими триггерами. Только HTTP и Cron. Если потребуется — можно добавить.
от что реально полезно и не очень сложно:
Для пользователя:
История вызовов — последние N вызовов функции: время, статус, время ответа. Сейчас вызвал и забыл.
Метрики — счётчик вызовов, средний latency, % ошибок. У вас уже есть metrics-collector — данные есть, нужен только UI.
Автообновление логов — сейчас нужно жать "Обновить" вручную. Можно автополлинг каждые 3-5 сек.
Копировать маршрут одной кнопкой — иконка 📋 рядом с маршрутом в таблице.
Статус функции — Ready/NotReady/Error прямо в таблице (сейчас не видно).
Для управления:
6. Клонировать функцию — создать копию с новым именем, сохранив код и настройки.
7. Экспорт/импорт — скачать функцию как zip, загрузить обратно.
8. Переменные окружения — env vars для функции через UI (сейчас нельзя задать).
+7
View File
@@ -12,6 +12,8 @@ import (
"fission-console/internal/api"
"fission-console/internal/auth"
"fission-console/internal/billing"
"fission-console/internal/stats"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/kubernetes"
@@ -48,6 +50,9 @@ func main() {
Demo: &auth.DemoAuthenticator{},
}
billingStore := billing.NewStore()
statsProvider := stats.NewProvider()
srv := api.NewServer(api.Config{
Dyn: dyn,
Kube: kube,
@@ -64,6 +69,8 @@ func main() {
LLMUrl: envDefault("FISSION_LLM_URL", "https://api.aillm.ru"),
LLMKey: os.Getenv("FISSION_LLM_KEY"),
// --- end ai/ask feature ---
Billing: billingStore,
Stats: statsProvider,
})
// Запускаем фоновые горутины: reaper истёкших функций
+15 -2
View File
@@ -15,9 +15,12 @@ rules:
- apiGroups: [""]
resources: ["pods/log"]
verbs: ["get"]
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "list", "create", "delete"]
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "update", "patch"]
verbs: ["get", "list", "create", "update", "patch", "delete"]
- apiGroups: ["fission.io"]
resources: ["environments", "packages", "functions", "httptriggers", "timetriggers"]
verbs: ["get", "list", "create", "update", "patch", "delete"]
@@ -55,7 +58,7 @@ spec:
serviceAccountName: fission-console
containers:
- name: console
image: naeel/fission-console:v1.3.69
image: naeel/fission-console:v1.3.94
imagePullPolicy: Always
ports:
- containerPort: 8090
@@ -82,6 +85,16 @@ spec:
key: password
- name: FISSION_STORAGESVC_URL
value: "http://storagesvc.fission.svc.cluster.local"
- name: BILLING_DSN
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:
httpGet:
path: /health
+5
View File
@@ -20,6 +20,10 @@ require (
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/gnostic-models v0.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.9.2 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
@@ -32,6 +36,7 @@ require (
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/term v0.37.0 // indirect
golang.org/x/text v0.31.0 // indirect
+11
View File
@@ -29,6 +29,14 @@ github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgY
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
@@ -70,6 +78,7 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
@@ -99,6 +108,8 @@ golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKl
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+3 -6
View File
@@ -255,6 +255,8 @@ func buildChatSystemPrompt() string {
- **Cron** — можно включить/выключить и изменить расписание
- **Код** — редактируется (для функций созданных из кода)
- **Заменить архив** — загрузить новый zip (для функций из архива)
- **Переменные окружения** — раздел в конце редактора. Кнопка "+ Добавить" добавляет переменную (имя + значение). Кнопка "Сохранить env vars" применяет изменения. Переменные сразу доступны в поде функции через os.environ (Python), process.env (Node.js), os.Getenv (Go) и т.д.
- **📋 Клон** — кнопка в нижней панели. Позволяет создать копию функции с новым именем. Клон наследует код, язык, entrypoint, timeout. Маршрут назначается автоматически.
## Вызов функции (кнопка Вызов)
Открывается модальное окно. Можно указать тело запроса (JSON) и нажать "Вызвать".
@@ -318,12 +320,7 @@ Ruby (entrypoint: handler):
- ` + "`@hourly`" + `, ` + "`@daily`" + `, ` + "`@weekly`" + ` — стандартные псевдонимы
## ESC и закрытие окон
Нажатие ESC закрывает любое открытое окно (модалку).
## Чем НЕ занимаешься
- Не рассказываешь про устройство платформы изнутри
- Не помогаешь с инфраструктурой, деплоем, kubernetes
- Не отвечаешь на вопросы не связанные с работой в консоли`)
Нажатие ESC закрывает любое открытое окно (модалку).`)
}
func prettyLanguageName(lang string) string {
+333
View File
@@ -0,0 +1,333 @@
// Package api — клонирование функций.
//
// handleCloneFunction создаёт полную копию функции с новым именем:
// - скачивает архив из storagesvc (или копирует literal)
// - заливает новый архив (отдельный объект в S3)
// - создаёт новый Package, Function и HTTPTrigger
//
// Архив переливается заново, чтобы удаление оригинала не сломало клон.
package api
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"time"
"fission-console/internal/billing"
"fission-console/internal/fission"
"fission-console/internal/runtime"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
// handleCloneFunction — POST /functions/:name/clone
// Body: {"new_name": "my-clone", "route": "/my-clone"} (route необязателен)
func (s *Server) handleCloneFunction(w http.ResponseWriter, r *http.Request, srcName string) {
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
ns := s.userNS(r)
var req struct {
NewName string `json:"new_name"`
Route string `json:"route"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("decode request: %v", err))
return
}
req.NewName = strings.TrimSpace(req.NewName)
req.Route = strings.TrimSpace(req.Route)
// Валидация нового имени
if req.NewName == "" {
writeJSONError(w, http.StatusBadRequest, "new_name is required")
return
}
validName := regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`)
if !validName.MatchString(req.NewName) || len(req.NewName) > 57 {
writeJSONError(w, http.StatusBadRequest, "invalid new_name: must match ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ and be <= 57 chars")
return
}
// Получаем исходную функцию
srcFn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, srcName, metav1.GetOptions{})
if err != nil {
if apierrors.IsNotFound(err) {
writeJSONError(w, http.StatusNotFound, fmt.Sprintf("function %q not found", srcName))
return
}
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("get function: %v", err))
return
}
// Получаем имя пакета исходной функции
srcPkgName, _, _ := unstructured.NestedString(srcFn.Object, "spec", "package", "packageref", "name")
if srcPkgName == "" {
writeJSONError(w, http.StatusBadGateway, "source function has no package reference")
return
}
// Получаем исходный Package
srcPkg, err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Get(ctx, srcPkgName, metav1.GetOptions{})
if err != nil {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("get source package: %v", err))
return
}
// Скачиваем байты архива из Package
archiveBytes, err := s.downloadPackageBytes(ctx, srcPkg)
if err != nil {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("download archive: %v", err))
return
}
// Загружаем как новый архив
newDeploySpec, err := s.buildDeploySpec(ctx, archiveBytes)
if err != nil {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("upload clone archive: %v", err))
return
}
// Параметры для нового пакета (берём spec из оригинала)
now := time.Now().UTC()
newPkgName := req.NewName + "-" + now.Format("20060102150405")
// Определяем environment из исходной функции
envName, _, _ := unstructured.NestedString(srcFn.Object, "spec", "environment", "name")
// Собираем spec пакета (аналогично оригиналу, но с новыми байтами)
// Если у оригинала есть source (Go) — копируем source spec
srcSourceSpec, _, _ := unstructured.NestedMap(srcPkg.Object, "spec", "source")
hasBuildCmd, _, _ := unstructured.NestedString(srcPkg.Object, "spec", "buildcommand")
var newPkgSpec map[string]any
if hasBuildCmd != "" {
// Go: source package
newPkgSpec = map[string]any{
"source": newDeploySpec, // перезаливаем source
"deployment": map[string]any{},
"environment": map[string]any{"name": envName, "namespace": ns},
"buildcommand": hasBuildCmd,
}
_ = srcSourceSpec
} else {
newPkgSpec = map[string]any{
"deployment": newDeploySpec,
"environment": map[string]any{"name": envName, "namespace": ns},
"source": map[string]any{},
}
}
newPkg := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "Package",
"metadata": map[string]any{"name": newPkgName, "namespace": ns},
"spec": newPkgSpec,
}}
if _, err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Create(ctx, newPkg, metav1.CreateOptions{}); err != nil {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create clone package: %v", err))
return
}
// Копируем аннотации из исходной функции
srcAnnotations := srcFn.GetAnnotations()
newAnnotations := map[string]any{
functionCreatedAtAnnotation: now.Format(time.RFC3339),
functionUpdatedAtAnnotation: now.Format(time.RFC3339),
}
for _, k := range []string{
"fission-console/language",
fissionSourceTypeAnnotation,
"fission-console/env-vars",
"fission-console/archive-filename",
} {
if v, ok := srcAnnotations[k]; ok && v != "" {
newAnnotations[k] = v
}
}
newAnnotations["fission-console/cloned-from"] = srcName
// Копируем entrypoint
entrypoint, _, _ := unstructured.NestedString(srcFn.Object, "spec", "package", "functionName")
timeout, _, _ := unstructured.NestedInt64(srcFn.Object, "spec", "functionTimeout")
if timeout == 0 {
timeout = 60
}
// Копируем InvokeStrategy и podspec
invokeStrategy, _, _ := unstructured.NestedMap(srcFn.Object, "spec", "InvokeStrategy")
if invokeStrategy == nil {
invokeStrategy = map[string]any{
"ExecutionStrategy": map[string]any{"ExecutorType": "poolmgr"},
"StrategyType": "execution",
}
}
podspec, _, _ := unstructured.NestedMap(srcFn.Object, "spec", "podspec")
newFnSpec := map[string]any{
"environment": map[string]any{"name": envName, "namespace": ns},
"functionTimeout": timeout,
"InvokeStrategy": invokeStrategy,
"package": map[string]any{
"packageref": map[string]any{"name": newPkgName, "namespace": ns},
"functionName": entrypoint,
},
}
if len(podspec) > 0 {
newFnSpec["podspec"] = podspec
}
newFn := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "Function",
"metadata": map[string]any{
"name": req.NewName,
"namespace": ns,
"annotations": newAnnotations,
},
"spec": newFnSpec,
}}
if _, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Create(ctx, newFn, metav1.CreateOptions{}); err != nil {
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, newPkgName, metav1.DeleteOptions{})
if apierrors.IsAlreadyExists(err) {
writeJSONError(w, http.StatusConflict, fmt.Sprintf("function %q already exists", req.NewName))
return
}
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create clone function: %v", err))
return
}
// Маршрут для нового триггера
if req.Route == "" {
nsShort := ns
if len(nsShort) > 12 {
nsShort = nsShort[len(nsShort)-12:]
}
req.Route = "/" + nsShort + "/" + req.NewName
}
if !strings.HasPrefix(req.Route, "/") {
req.Route = "/" + req.Route
}
triggerName := req.NewName + "-route"
// Определяем методы из существующего триггера оригинала
methods := s.getTriggerMethods(ctx, ns, srcName)
if len(methods) == 0 {
methods = []any{"GET"}
}
newTrigger := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "HTTPTrigger",
"metadata": map[string]any{"name": triggerName, "namespace": ns},
"spec": map[string]any{
"relativeurl": req.Route,
"methods": methods,
"createingress": true,
"functionref": map[string]any{"type": "name", "name": req.NewName},
},
}}
if _, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).Create(ctx, newTrigger, metav1.CreateOptions{}); err != nil {
_ = s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Delete(ctx, req.NewName, metav1.DeleteOptions{})
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, newPkgName, metav1.DeleteOptions{})
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create httptrigger: %v", err))
return
}
s.billing.RecordInvocation(billing.Invocation{
Namespace: ns,
FunctionName: req.NewName,
TriggerType: billing.TriggerEvent,
StartedAt: now,
StatusCode: http.StatusCreated,
RecordedBy: "console",
EventType: "clone",
})
writeAnyJSON(w, http.StatusCreated, map[string]any{
"name": req.NewName,
"cloned_from": srcName,
"package": newPkgName,
"route": req.Route,
})
}
// downloadPackageBytes извлекает байты архива из Package CRD.
// Поддерживает type:url (скачивает из storagesvc) и type:literal (base64).
func (s *Server) downloadPackageBytes(ctx context.Context, pkg *unstructured.Unstructured) ([]byte, error) {
// Пробуем deployment сначала, потом source (для Go)
for _, field := range [][]string{{"spec", "deployment"}, {"spec", "source"}} {
spec, _, _ := unstructured.NestedMap(pkg.Object, field...)
if len(spec) == 0 {
continue
}
archiveType, _ := spec["type"].(string)
switch archiveType {
case "url":
archiveURL, _ := spec["url"].(string)
if archiveURL == "" {
continue
}
return s.downloadFromStoragesvc(ctx, archiveURL)
case "literal":
lit, _ := spec["literal"].(string)
if lit == "" {
continue
}
return base64.StdEncoding.DecodeString(lit)
}
}
// Последний шанс: если функция Python с простым кодом — возвращаем заглушку
return nil, fmt.Errorf("no downloadable archive found in package (empty deployment and source spec)")
}
// downloadFromStoragesvc скачивает архив по URL из storagesvc.
func (s *Server) downloadFromStoragesvc(ctx context.Context, archiveURL string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, archiveURL, nil)
if err != nil {
return nil, fmt.Errorf("build download request: %w", err)
}
resp, err := s.http.Do(req)
if err != nil {
return nil, fmt.Errorf("download archive: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("download archive status %d", resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read archive body: %w", err)
}
return data, nil
}
// getTriggerMethods возвращает методы HTTP из триггера функции (для копирования в клон).
func (s *Server) getTriggerMethods(ctx context.Context, ns, fnName string) []any {
triggers, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).List(ctx, metav1.ListOptions{})
if err != nil {
return nil
}
for _, t := range triggers.Items {
ref, _, _ := unstructured.NestedString(t.Object, "spec", "functionref", "name")
if ref != fnName {
continue
}
methods, _, _ := unstructured.NestedSlice(t.Object, "spec", "methods")
if len(methods) > 0 {
return methods
}
}
return nil
}
// Ссылка на runtime.DefaultEntrypoint для возможного использования в будущем
var _ = runtime.DefaultEntrypoint
+629
View File
@@ -0,0 +1,629 @@
package api
import (
"archive/zip"
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"fission-console/internal/fission"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
k8sruntime "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
dynamicfake "k8s.io/client-go/dynamic/fake"
)
// ─── helpers ────────────────────────────────────────────────────────────────
// newCloneTestServer создаёт Server с фиктивным k8s-клиентом и
// опциональным HTTP-сервером, имитирующим storagesvc.
func newCloneTestServer(t *testing.T, storagesvcHandler http.HandlerFunc, objs ...*unstructured.Unstructured) (*Server, *httptest.Server) {
t.Helper()
scheme := k8sruntime.NewScheme()
listKinds := map[schema.GroupVersionResource]string{
fission.FunctionGVR: "FunctionList",
fission.PackageGVR: "PackageList",
fission.HTTPTrigGVR: "HTTPTriggerList",
}
runtimeObjs := make([]k8sruntime.Object, len(objs))
for i, o := range objs {
runtimeObjs[i] = o
}
dynClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, runtimeObjs...)
var storagesvc *httptest.Server
httpClient := &http.Client{}
if storagesvcHandler != nil {
storagesvc = httptest.NewServer(storagesvcHandler)
httpClient = storagesvc.Client()
}
s := &Server{
dyn: dynClient,
ns: "fission-test",
http: httpClient,
}
return s, storagesvc
}
// cloneRequest выполняет POST /functions/:srcName/clone с телом body.
func cloneRequest(t *testing.T, s *Server, srcName, body string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost,
"/console/api/functions/"+srcName+"/clone",
strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Auth-Token", "test-token")
rec := httptest.NewRecorder()
s.handleCloneFunction(rec, req, srcName)
return rec
}
// makeZip возвращает минимальный zip-архив с одним файлом filename/content.
func makeZip(t *testing.T, filename, content string) []byte {
t.Helper()
var buf bytes.Buffer
w := zip.NewWriter(&buf)
f, err := w.Create(filename)
if err != nil {
t.Fatalf("zip.Create: %v", err)
}
if _, err := io.WriteString(f, content); err != nil {
t.Fatalf("zip.Write: %v", err)
}
if err := w.Close(); err != nil {
t.Fatalf("zip.Close: %v", err)
}
return buf.Bytes()
}
// packageWithLiteral создаёт Package CRD с type:literal (base64-encoded bytes).
func packageWithLiteral(ns, name, fnName string, data []byte) *unstructured.Unstructured {
return &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "Package",
"metadata": map[string]any{"name": name, "namespace": ns},
"spec": map[string]any{
"deployment": map[string]any{
"type": "literal",
"literal": base64.StdEncoding.EncodeToString(data),
},
"environment": map[string]any{"name": fnName, "namespace": ns},
"source": map[string]any{},
},
}}
}
// packageWithURL создаёт Package CRD с type:url.
func packageWithURL(ns, pkgName, envName, archiveURL string) *unstructured.Unstructured {
return &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "Package",
"metadata": map[string]any{"name": pkgName, "namespace": ns},
"spec": map[string]any{
"deployment": map[string]any{
"type": "url",
"url": archiveURL,
},
"environment": map[string]any{"name": envName, "namespace": ns},
"source": map[string]any{},
},
}}
}
// functionWithPkg создаёт Function CRD, ссылающуюся на pkgName.
func functionWithPkg(ns, fnName, pkgName, envName, entrypoint string) *unstructured.Unstructured {
return &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "Function",
"metadata": map[string]any{
"name": fnName,
"namespace": ns,
"annotations": map[string]any{
"fission-console/language": envName,
"fission-console/source-type": "code",
"fission-console/created-at": "2026-05-09T00:00:00Z",
"fission-console/updated-at": "2026-05-09T00:00:00Z",
},
},
"spec": map[string]any{
"environment": map[string]any{"name": envName, "namespace": ns},
"functionTimeout": int64(120),
"package": map[string]any{
"packageref": map[string]any{"name": pkgName, "namespace": ns},
"functionName": entrypoint,
},
"InvokeStrategy": map[string]any{
"ExecutionStrategy": map[string]any{"ExecutorType": "poolmgr"},
"StrategyType": "execution",
},
},
}}
}
// httpTriggerObject создаёт HTTPTrigger CRD для функции fnName.
func httpTriggerObject(ns, trigName, fnName, route string, methods []any) *unstructured.Unstructured {
return &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "HTTPTrigger",
"metadata": map[string]any{"name": trigName, "namespace": ns},
"spec": map[string]any{
"relativeurl": route,
"methods": methods,
"createingress": true,
"functionref": map[string]any{"type": "name", "name": fnName},
},
}}
}
// ─── Тесты валидации имени ───────────────────────────────────────────────────
func TestHandleCloneFunction_InvalidName_EmptyName(t *testing.T) {
s, _ := newCloneTestServer(t, nil)
rec := cloneRequest(t, s, "src", `{"new_name":""}`)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body)
}
if !strings.Contains(rec.Body.String(), "new_name is required") {
t.Fatalf("body should mention 'new_name is required', got: %s", rec.Body)
}
}
func TestHandleCloneFunction_InvalidName_UpperCase(t *testing.T) {
s, _ := newCloneTestServer(t, nil)
rec := cloneRequest(t, s, "src", `{"new_name":"MyFunc"}`)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body)
}
}
func TestHandleCloneFunction_InvalidName_StartsWithDash(t *testing.T) {
s, _ := newCloneTestServer(t, nil)
rec := cloneRequest(t, s, "src", `{"new_name":"-bad"}`)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body)
}
}
func TestHandleCloneFunction_InvalidName_EndsWithDash(t *testing.T) {
s, _ := newCloneTestServer(t, nil)
rec := cloneRequest(t, s, "src", `{"new_name":"bad-"}`)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body)
}
}
func TestHandleCloneFunction_InvalidName_TooLong(t *testing.T) {
s, _ := newCloneTestServer(t, nil)
longName := strings.Repeat("a", 58)
rec := cloneRequest(t, s, "src", fmt.Sprintf(`{"new_name":%q}`, longName))
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body)
}
}
func TestHandleCloneFunction_ValidName_MaxLength(t *testing.T) {
// 57 символов — допустимо; но функция src не существует → 404
s, _ := newCloneTestServer(t, nil)
name57 := strings.Repeat("a", 57)
rec := cloneRequest(t, s, "src", fmt.Sprintf(`{"new_name":%q}`, name57))
// Ожидаем не 400 (прошло валидацию), а 404 (src не найден)
if rec.Code == http.StatusBadRequest {
t.Fatalf("57-char name should pass validation, got 400; body=%s", rec.Body)
}
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body)
}
}
func TestHandleCloneFunction_ValidName_WithDashes(t *testing.T) {
// Имя вида "a-b-c" — допустимо
s, _ := newCloneTestServer(t, nil)
rec := cloneRequest(t, s, "nonexist", `{"new_name":"a-b-c"}`)
if rec.Code == http.StatusBadRequest {
t.Fatalf("'a-b-c' should pass validation, got 400; body=%s", rec.Body)
}
}
// ─── Тест: источник не существует ────────────────────────────────────────────
func TestHandleCloneFunction_SourceNotFound(t *testing.T) {
s, _ := newCloneTestServer(t, nil)
rec := cloneRequest(t, s, "ghost", `{"new_name":"ghost-copy"}`)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body)
}
if !strings.Contains(rec.Body.String(), "ghost") {
t.Fatalf("body should mention 'ghost', got: %s", rec.Body)
}
}
// ─── Тест: успешный клон с type:literal ──────────────────────────────────────
func TestHandleCloneFunction_Success_Literal(t *testing.T) {
const ns = "fission-test"
zipData := makeZip(t, "main.py", "def main():\n return 'hello'\n")
pkg := packageWithLiteral(ns, "src-pkg", "python", zipData)
fn := functionWithPkg(ns, "src", "src-pkg", "python", "main.main")
trig := httpTriggerObject(ns, "src-route", "src", "/test-ns/src", []any{"GET", "POST"})
// storagesvc принимает загрузку и возвращает URL
storagesvc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
// Имитируем загрузку — возвращаем ID архива
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"id":"fission/new-archive-id-123"}`)
return
}
http.NotFound(w, r)
}))
defer storagesvc.Close()
s, _ := newCloneTestServer(t, nil, pkg, fn, trig)
s.storagesvcURL = storagesvc.URL
s.http = storagesvc.Client()
rec := cloneRequest(t, s, "src", `{"new_name":"src-copy"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body)
}
var resp map[string]any
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp["name"] != "src-copy" {
t.Fatalf("response.name = %v, want 'src-copy'", resp["name"])
}
if resp["cloned_from"] != "src" {
t.Fatalf("response.cloned_from = %v, want 'src'", resp["cloned_from"])
}
if resp["package"] == "" || resp["package"] == nil {
t.Fatalf("response.package is empty")
}
ctx := context.Background()
// Проверяем что Function создана
cloneFn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, "src-copy", metav1.GetOptions{})
if err != nil {
t.Fatalf("clone Function not found: %v", err)
}
// Аннотация cloned-from
ann := cloneFn.GetAnnotations()
if ann["fission-console/cloned-from"] != "src" {
t.Fatalf("annotation cloned-from = %q, want 'src'", ann["fission-console/cloned-from"])
}
// Аннотации language и source-type скопированы
if ann["fission-console/language"] != "python" {
t.Fatalf("annotation language = %q, want 'python'", ann["fission-console/language"])
}
if ann["fission-console/source-type"] != "code" {
t.Fatalf("annotation source-type = %q, want 'code'", ann["fission-console/source-type"])
}
// Entrypoint скопирован
ep, _, _ := unstructured.NestedString(cloneFn.Object, "spec", "package", "functionName")
if ep != "main.main" {
t.Fatalf("entrypoint = %q, want 'main.main'", ep)
}
// Timeout скопирован
timeout, _, _ := unstructured.NestedInt64(cloneFn.Object, "spec", "functionTimeout")
if timeout != 120 {
t.Fatalf("timeout = %d, want 120", timeout)
}
// Проверяем что HTTPTrigger создан
trigName := "src-copy-route"
cloneTrig, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).Get(ctx, trigName, metav1.GetOptions{})
if err != nil {
t.Fatalf("clone HTTPTrigger not found: %v", err)
}
// Методы скопированы из оригинального триггера
methods, _, _ := unstructured.NestedStringSlice(cloneTrig.Object, "spec", "methods")
if len(methods) != 2 {
t.Fatalf("methods = %v, want [GET POST]", methods)
}
// Маршрут сгенерирован автоматически (содержит new_name)
route, _, _ := unstructured.NestedString(cloneTrig.Object, "spec", "relativeurl")
if !strings.HasSuffix(route, "/src-copy") {
t.Fatalf("route = %q, should end with '/src-copy'", route)
}
}
// ─── Тест: маршрут задан явно ────────────────────────────────────────────────
func TestHandleCloneFunction_CustomRoute(t *testing.T) {
const ns = "fission-test"
zipData := makeZip(t, "main.py", "def main():\n return 'hi'\n")
pkg := packageWithLiteral(ns, "fn-pkg", "python", zipData)
fn := functionWithPkg(ns, "fn", "fn-pkg", "python", "main.main")
storagesvc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"id":"fission/arc-456"}`)
}))
defer storagesvc.Close()
s, _ := newCloneTestServer(t, nil, pkg, fn)
s.storagesvcURL = storagesvc.URL
s.http = storagesvc.Client()
rec := cloneRequest(t, s, "fn", `{"new_name":"fn-clone","route":"/custom/path"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body)
}
ctx := context.Background()
trig, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).Get(ctx, "fn-clone-route", metav1.GetOptions{})
if err != nil {
t.Fatalf("HTTPTrigger not found: %v", err)
}
route, _, _ := unstructured.NestedString(trig.Object, "spec", "relativeurl")
if route != "/custom/path" {
t.Fatalf("route = %q, want '/custom/path'", route)
}
}
// ─── Тест: маршрут без слеша — добавляется автоматически ─────────────────────
func TestHandleCloneFunction_RouteWithoutLeadingSlash(t *testing.T) {
const ns = "fission-test"
zipData := makeZip(t, "main.py", "def main():\n return 'hi'\n")
pkg := packageWithLiteral(ns, "fn2-pkg", "python", zipData)
fn := functionWithPkg(ns, "fn2", "fn2-pkg", "python", "main.main")
storagesvc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"id":"fission/arc-789"}`)
}))
defer storagesvc.Close()
s, _ := newCloneTestServer(t, nil, pkg, fn)
s.storagesvcURL = storagesvc.URL
s.http = storagesvc.Client()
rec := cloneRequest(t, s, "fn2", `{"new_name":"fn2-clone","route":"no-leading-slash"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body)
}
ctx := context.Background()
trig, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).Get(ctx, "fn2-clone-route", metav1.GetOptions{})
if err != nil {
t.Fatalf("HTTPTrigger not found: %v", err)
}
route, _, _ := unstructured.NestedString(trig.Object, "spec", "relativeurl")
if !strings.HasPrefix(route, "/") {
t.Fatalf("route = %q, should start with '/'", route)
}
}
// ─── Тест: дублирующее имя → 409 ─────────────────────────────────────────────
func TestHandleCloneFunction_DuplicateName_Conflict(t *testing.T) {
const ns = "fission-test"
zipData := makeZip(t, "main.py", "def main():\n return 'a'\n")
pkg := packageWithLiteral(ns, "dup-pkg", "python", zipData)
fn := functionWithPkg(ns, "dup-src", "dup-pkg", "python", "main.main")
// Уже существующая функция с именем "dup-copy"
existingFn := functionWithPkg(ns, "dup-copy", "other-pkg", "python", "main.main")
storagesvc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"id":"fission/arc-dup"}`)
}))
defer storagesvc.Close()
s, _ := newCloneTestServer(t, nil, pkg, fn, existingFn)
s.storagesvcURL = storagesvc.URL
s.http = storagesvc.Client()
rec := cloneRequest(t, s, "dup-src", `{"new_name":"dup-copy"}`)
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409; body=%s", rec.Code, rec.Body)
}
if !strings.Contains(rec.Body.String(), "dup-copy") {
t.Fatalf("body should mention 'dup-copy', got: %s", rec.Body)
}
}
// ─── Тест: откат при ошибке создания Function ────────────────────────────────
func TestHandleCloneFunction_Rollback_OnFunctionConflict(t *testing.T) {
const ns = "fission-test"
zipData := makeZip(t, "main.py", "def main():\n return 'rb'\n")
pkg := packageWithLiteral(ns, "rb-pkg", "python", zipData)
fn := functionWithPkg(ns, "rb-src", "rb-pkg", "python", "main.main")
// Уже существующая функция с именем клона
existingFn := functionWithPkg(ns, "rb-clone", "other-pkg", "python", "main.main")
storagesvc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"id":"fission/arc-rb"}`)
}))
defer storagesvc.Close()
s, _ := newCloneTestServer(t, nil, pkg, fn, existingFn)
s.storagesvcURL = storagesvc.URL
s.http = storagesvc.Client()
rec := cloneRequest(t, s, "rb-src", `{"new_name":"rb-clone"}`)
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409; body=%s", rec.Code, rec.Body)
}
// После rollback Package с именем "rb-clone-*" не должен остаться
ctx := context.Background()
pkgs, err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).List(ctx, metav1.ListOptions{})
if err != nil {
t.Fatalf("list packages: %v", err)
}
for _, p := range pkgs.Items {
if strings.HasPrefix(p.GetName(), "rb-clone-") {
t.Fatalf("rollback failed: package %q still exists", p.GetName())
}
}
}
// ─── Тест: downloadPackageBytes — type:literal ────────────────────────────────
func TestDownloadPackageBytes_Literal(t *testing.T) {
s, _ := newCloneTestServer(t, nil)
original := []byte("hello from literal")
pkg := packageWithLiteral("fission-test", "lit-pkg", "python", original)
got, err := s.downloadPackageBytes(context.Background(), pkg)
if err != nil {
t.Fatalf("downloadPackageBytes error: %v", err)
}
if !bytes.Equal(got, original) {
t.Fatalf("got %q, want %q", got, original)
}
}
// ─── Тест: downloadPackageBytes — type:url ────────────────────────────────────
func TestDownloadPackageBytes_URL(t *testing.T) {
const content = "archive-content-from-storagesvc"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, content)
}))
defer srv.Close()
pkg := packageWithURL("fission-test", "url-pkg", "python", srv.URL+"/v1/archive?id=abc")
s := &Server{http: srv.Client()}
got, err := s.downloadPackageBytes(context.Background(), pkg)
if err != nil {
t.Fatalf("downloadPackageBytes error: %v", err)
}
if string(got) != content {
t.Fatalf("got %q, want %q", got, content)
}
}
// ─── Тест: downloadPackageBytes — пустой Package → ошибка ────────────────────
func TestDownloadPackageBytes_EmptyPackage_Error(t *testing.T) {
s, _ := newCloneTestServer(t, nil)
emptyPkg := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "Package",
"metadata": map[string]any{"name": "empty", "namespace": "fission-test"},
"spec": map[string]any{},
}}
_, err := s.downloadPackageBytes(context.Background(), emptyPkg)
if err == nil {
t.Fatal("expected error for empty package, got nil")
}
}
// ─── Тест: downloadFromStoragesvc — non-200 → ошибка ────────────────────────
func TestDownloadFromStoragesvc_Non200(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
s := &Server{http: srv.Client()}
_, err := s.downloadFromStoragesvc(context.Background(), srv.URL+"/v1/archive?id=gone")
if err == nil {
t.Fatal("expected error for 404 response, got nil")
}
if !strings.Contains(err.Error(), "404") {
t.Fatalf("error should mention 404, got: %v", err)
}
}
// ─── Тест: getTriggerMethods — триггер существует ────────────────────────────
func TestGetTriggerMethods_Found(t *testing.T) {
const ns = "fission-test"
trig := httpTriggerObject(ns, "my-trig", "my-fn", "/my", []any{"GET", "POST"})
s, _ := newCloneTestServer(t, nil, trig)
methods := s.getTriggerMethods(context.Background(), ns, "my-fn")
if len(methods) != 2 {
t.Fatalf("methods = %v, want 2 elements", methods)
}
}
// ─── Тест: getTriggerMethods — триггера нет → nil ────────────────────────────
func TestGetTriggerMethods_NotFound(t *testing.T) {
s, _ := newCloneTestServer(t, nil)
methods := s.getTriggerMethods(context.Background(), "fission-test", "no-such-fn")
if methods != nil {
t.Fatalf("expected nil, got %v", methods)
}
}
// ─── Тест: невалидный JSON body → 400 ────────────────────────────────────────
func TestHandleCloneFunction_InvalidJSON(t *testing.T) {
s, _ := newCloneTestServer(t, nil)
req := httptest.NewRequest(http.MethodPost, "/console/api/functions/src/clone",
strings.NewReader(`not-json`))
rec := httptest.NewRecorder()
s.handleCloneFunction(rec, req, "src")
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body)
}
}
// ─── Тест: успешный клон — Package содержит ссылку на Function ───────────────
func TestHandleCloneFunction_PackageRefsCloneFunction(t *testing.T) {
const ns = "fission-test"
zipData := makeZip(t, "main.py", "def main():\n return 'ref'\n")
pkg := packageWithLiteral(ns, "ref-pkg", "python", zipData)
fn := functionWithPkg(ns, "ref-src", "ref-pkg", "python", "main.main")
storagesvc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"id":"fission/arc-ref"}`)
}))
defer storagesvc.Close()
s, _ := newCloneTestServer(t, nil, pkg, fn)
s.storagesvcURL = storagesvc.URL
s.http = storagesvc.Client()
rec := cloneRequest(t, s, "ref-src", `{"new_name":"ref-clone"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body)
}
ctx := context.Background()
cloneFn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, "ref-clone", metav1.GetOptions{})
if err != nil {
t.Fatalf("clone function not found: %v", err)
}
pkgRef, _, _ := unstructured.NestedString(cloneFn.Object, "spec", "package", "packageref", "name")
if !strings.HasPrefix(pkgRef, "ref-clone-") {
t.Fatalf("clone Function.spec.package.packageref.name = %q, should start with 'ref-clone-'", pkgRef)
}
// Package с этим именем должен существовать
_, err = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Get(ctx, pkgRef, metav1.GetOptions{})
if err != nil {
t.Fatalf("clone Package %q not found: %v", pkgRef, err)
}
}
+34 -6
View File
@@ -22,6 +22,7 @@ import (
"strings"
"time"
"fission-console/internal/billing"
"fission-console/internal/fission"
"fission-console/internal/model"
"fission-console/internal/runtime"
@@ -34,17 +35,26 @@ import (
// buildDeployArchive упаковывает исходный код в байты для deployment Package.
// Для nodejs — ESM-обёртка (package.json + main.js).
// Для php/ruby — zip с одним файлом скрипта.
// Для остальных (python) — raw bytes кода.
func buildDeployArchive(lang, code string) ([]byte, error) {
// Для остальных (python) — raw bytes кода (или zip если есть deps).
// deps — содержимое файла зависимостей (requirements.txt, Gemfile, composer.json).
// Если deps пустой — поведение как раньше.
func buildDeployArchive(lang, code, deps string) ([]byte, error) {
switch lang {
case "nodejs":
// TODO: поддержка package.json с deps для nodejs — пока игнорируем deps
return runtime.BuildJSDeployZip(code)
case "php":
if deps != "" {
return runtime.BuildScriptZipWithDeps(code, "main.php", deps, "composer.json")
}
return runtime.BuildScriptZip(code, "main.php")
case "ruby":
if deps != "" {
return runtime.BuildScriptZipWithDeps(code, "handler.rb", deps, "Gemfile")
}
return runtime.BuildScriptZip(code, "handler.rb")
default:
return []byte(code), nil
default: // python
return runtime.BuildPythonZip(code, deps)
}
}
@@ -172,7 +182,7 @@ func (s *Server) handleCreateFunction(w http.ResponseWriter, r *http.Request) {
"buildcommand": "build",
}
} else {
deployBytes, archiveErr := buildDeployArchive(req.Language, req.Code)
deployBytes, archiveErr := buildDeployArchive(req.Language, req.Code, req.Deps)
if archiveErr != nil {
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build %s archive: %v", req.Language, archiveErr))
return
@@ -204,6 +214,9 @@ func (s *Server) handleCreateFunction(w http.ResponseWriter, r *http.Request) {
now := time.Now().UTC()
fnAnnotations[functionCreatedAtAnnotation] = now.Format(time.RFC3339)
fnAnnotations[functionUpdatedAtAnnotation] = now.Format(time.RFC3339)
if req.Deps != "" {
fnAnnotations["fission-console/deps"] = req.Deps
}
if req.TTL != "" {
expiresAt, ttlErr := parseTTL(req.TTL)
if ttlErr != nil {
@@ -275,6 +288,16 @@ func (s *Server) handleCreateFunction(w http.ResponseWriter, r *http.Request) {
return
}
s.billing.RecordInvocation(billing.Invocation{
Namespace: ns,
FunctionName: req.Name,
TriggerType: billing.TriggerEvent,
StartedAt: now,
StatusCode: http.StatusCreated,
RecordedBy: "console",
EventType: "create",
})
writeAnyJSON(w, http.StatusCreated, map[string]any{
"name": req.Name,
"package": pkgName,
@@ -318,7 +341,7 @@ func (s *Server) handleUpdateFunctionCode(w http.ResponseWriter, r *http.Request
// Определяем язык из аннотации — нужен для правильной упаковки
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 {
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build %s archive: %v", lang, archiveErr))
return
@@ -382,6 +405,11 @@ func (s *Server) handleUpdateFunctionCode(w http.ResponseWriter, r *http.Request
}
fnAnnotations[functionCreatedAtAnnotation] = createdAt.UTC().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)
if err := unstructured.SetNestedField(fn.Object, map[string]any{
"name": newPkgName,
+247 -4
View File
@@ -1,10 +1,12 @@
// Package api — CRUD операции с функциями: чтение, удаление, обновление таймаута, логи.
// Package api — CRUD операции с функциями: чтение, удаление, обновление таймаута, логи, env vars.
//
// Этот файл содержит операции, не связанные с заменой кода/архива:
// - handleGetFunction — GET /functions/:name (детали: код, route, environment, source_type)
// - handleDeleteFunction — DELETE /functions/:name (каскадное удаление: триггеры, Package, S3)
// - handleGetFunction — GET /functions/:name (детали: код, route, environment, source_type)
// - handleDeleteFunction — DELETE /functions/:name (каскадное удаление: триггеры, Package, S3)
// - handleUpdateFunctionTimeout — PUT /functions/:name/timeout (только таймаут, без замены кода)
// - handleGetFunctionLogs — GET /functions/:name/logs (логи пода через Kubernetes API)
// - handleGetFunctionLogs — GET /functions/:name/logs (логи пода через Kubernetes API)
// - handleGetFunctionEnvVars — GET /functions/:name/envvars (переменные окружения из CRD)
// - handlePutFunctionEnvVars — PUT /functions/:name/envvars (обновить env vars в CRD)
//
// Операции с кодом и архивом — в function_code.go и function_archive.go соответственно.
// Вызов функции — в function_invoke.go.
@@ -19,12 +21,14 @@ import (
"strings"
"time"
"fission-console/internal/billing"
"fission-console/internal/fission"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
k8stypes "k8s.io/apimachinery/pkg/types"
)
// handleGetFunction возвращает детали функции: код, environment, route, methods.
@@ -79,6 +83,7 @@ func (s *Server) handleGetFunction(w http.ResponseWriter, r *http.Request, name
// Читаем source-type аннотацию (code / archive)
sourceType := "code"
archiveFilename := ""
deps := ""
if ann := fn.GetAnnotations(); ann != nil {
if v := ann[fissionSourceTypeAnnotation]; v != "" {
sourceType = v
@@ -86,6 +91,9 @@ func (s *Server) handleGetFunction(w http.ResponseWriter, r *http.Request, name
if v := ann["fission-console/archive-filename"]; v != "" {
archiveFilename = v
}
if v := ann["fission-console/deps"]; v != "" {
deps = v
}
}
writeAnyJSON(w, http.StatusOK, map[string]any{
@@ -98,10 +106,12 @@ func (s *Server) handleGetFunction(w http.ResponseWriter, r *http.Request, name
"created_at": functionTimestampResponse(fn)["created_at"],
"updated_at": functionTimestampResponse(fn)["updated_at"],
"code": code,
"deps": deps,
"source_type": sourceType,
"archive_filename": archiveFilename,
"route": route,
"methods": methods,
"env_vars": extractEnvVars(fn),
"raw": fn.Object,
})
}
@@ -224,6 +234,16 @@ func (s *Server) handleDeleteFunction(w http.ResponseWriter, r *http.Request, na
// (reconciler NS удалён — за FISSION_RESOURCE_NAMESPACES теперь отвечает Layer 1 NSWatcher)
s.billing.RecordInvocation(billing.Invocation{
Namespace: ns,
FunctionName: name,
TriggerType: billing.TriggerEvent,
StartedAt: time.Now(),
StatusCode: http.StatusOK,
RecordedBy: "console",
EventType: "delete",
})
writeAnyJSON(w, http.StatusOK, map[string]any{"deleted": true, "name": name, "package": pkgName})
}
@@ -284,3 +304,226 @@ func (s *Server) handleGetFunctionLogs(w http.ResponseWriter, r *http.Request, n
"logs": allLogs.String(),
})
}
// handleGetFunctionEnvVars возвращает переменные окружения функции из .spec.runtime.container.env
func (s *Server) handleGetFunctionEnvVars(w http.ResponseWriter, r *http.Request, name string) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
ns := s.userNS(r)
fn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
if err != nil {
status := http.StatusBadGateway
if apierrors.IsNotFound(err) {
status = http.StatusNotFound
}
writeJSONError(w, status, fmt.Sprintf("get function %q: %v", name, err))
return
}
envVars := extractEnvVars(fn)
writeAnyJSON(w, http.StatusOK, map[string]any{"env_vars": envVars})
}
// handlePutFunctionEnvVars обновляет переменные окружения функции.
//
// Логика переключения ExecutorType:
// - Если env vars непустые → ExecutorType: newdeploy + spec.podspec.containers[0].env
// (newdeploy создаёт dedicated Deployment, Kubernetes ставит env vars на уровне ОС)
// - Если env vars пустые → ExecutorType: poolmgr, podspec удаляется
// (poolmgr использует warm pool, быстрый cold start)
//
// Это единственный универсальный способ передать env vars в pod для всех языков
// (Python, Go, Ruby, PHP, Node.js) без изменений в env-серверах.
func (s *Server) handlePutFunctionEnvVars(w http.ResponseWriter, r *http.Request, name string) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
ns := s.userNS(r)
var req struct {
EnvVars []map[string]string `json:"env_vars"` // [{name: "KEY", value: "VAL"}, ...]
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("decode request: %v", err))
return
}
// Валидация: имена переменных
for _, ev := range req.EnvVars {
k := ev["name"]
if k == "" {
writeJSONError(w, http.StatusBadRequest, "env var name cannot be empty")
return
}
}
fn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
if err != nil {
status := http.StatusBadGateway
if apierrors.IsNotFound(err) {
status = http.StatusNotFound
}
writeJSONError(w, status, fmt.Sprintf("get function %q: %v", name, err))
return
}
// Сериализуем в JSON для аннотации (для UI)
envJSON, err := json.Marshal(req.EnvVars)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("marshal env vars: %v", err))
return
}
// Обновляем аннотации
now := time.Now().UTC().Format(time.RFC3339)
ann := fn.GetAnnotations()
if ann == nil {
ann = map[string]string{}
}
ann[functionUpdatedAtAnnotation] = now
ann["fission-console/env-vars"] = string(envJSON)
fn.SetAnnotations(ann)
if len(req.EnvVars) > 0 {
// Есть env vars → newdeploy + podspec с env vars
envName, _, _ := unstructured.NestedString(fn.Object, "spec", "environment", "name")
// Строим список env vars для Kubernetes
envList := make([]any, 0, len(req.EnvVars))
for _, ev := range req.EnvVars {
envList = append(envList, map[string]any{
"name": ev["name"],
"value": ev["value"],
})
}
// Устанавливаем podspec.containers[0] с env vars
// Имя контейнера = имя environment (стандарт Fission)
if err := unstructured.SetNestedSlice(fn.Object, []any{
map[string]any{
"name": envName,
"env": envList,
},
}, "spec", "podspec", "containers"); err != nil {
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("set podspec: %v", err))
return
}
// Переключаем на newdeploy (только он поддерживает podspec env)
if err := unstructured.SetNestedField(fn.Object, map[string]any{
"ExecutionStrategy": map[string]any{
"ExecutorType": "newdeploy",
"MinScale": int64(0),
"MaxScale": int64(1),
"SpecializationTimeout": int64(120),
},
"StrategyType": "execution",
}, "spec", "InvokeStrategy"); err != nil {
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("set invoke strategy: %v", err))
return
}
} else {
// Нет env vars → poolmgr, убираем podspec
unstructured.RemoveNestedField(fn.Object, "spec", "podspec")
if err := unstructured.SetNestedField(fn.Object, map[string]any{
"ExecutionStrategy": map[string]any{
"ExecutorType": "poolmgr",
"SpecializationTimeout": int64(120),
},
"StrategyType": "execution",
}, "spec", "InvokeStrategy"); err != nil {
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("set invoke strategy: %v", err))
return
}
}
if _, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Update(ctx, fn, metav1.UpdateOptions{}); err != nil {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("update function %q: %v", name, err))
return
}
// Fission newdeploy НЕ применяет fn.Spec.PodSpec при построении Deployment.
// Поэтому напрямую патчим существующий Deployment через Kubernetes API.
if len(req.EnvVars) > 0 && s.kube != nil {
envName, _, _ := unstructured.NestedString(fn.Object, "spec", "environment", "name")
if envName != "" {
if err := s.patchDeploymentEnvVars(ctx, ns, name, envName, req.EnvVars); err != nil {
// Не фатальная ошибка — CRD обновлён, Deployment будет обновлён позже
_ = err // warn only
}
}
}
executor := "poolmgr"
if len(req.EnvVars) > 0 {
executor = "newdeploy"
}
writeAnyJSON(w, http.StatusOK, map[string]any{"updated": true, "count": len(req.EnvVars), "executor": executor})
}
// extractEnvVars читает аннотацию fission-console/env-vars (JSON) из Function CRD
// Возвращает [{name, value}, ...]
func extractEnvVars(fn *unstructured.Unstructured) []map[string]string {
ann := fn.GetAnnotations()
if ann == nil {
return []map[string]string{}
}
raw := ann["fission-console/env-vars"]
if raw == "" {
return []map[string]string{}
}
var result []map[string]string
if err := json.Unmarshal([]byte(raw), &result); err != nil {
return []map[string]string{}
}
return result
}
// patchDeploymentEnvVars находит Deployment newdeploy для функции и патчит его env vars.
// Fission не применяет fn.Spec.PodSpec при построении Deployment, поэтому патчим напрямую.
// Поиск по labels: functionName=<name>, functionNamespace=<ns>, executorType=newdeploy
func (s *Server) patchDeploymentEnvVars(ctx context.Context, ns, fnName, envContainerName string, envVars []map[string]string) error {
selector := fmt.Sprintf("functionName=%s,functionNamespace=%s,executorType=newdeploy", fnName, ns)
deplList, err := s.kube.AppsV1().Deployments(ns).List(ctx, metav1.ListOptions{LabelSelector: selector})
if err != nil {
return fmt.Errorf("list deployments: %w", err)
}
if len(deplList.Items) == 0 {
return nil // Deployment ещё не создан Fission — ничего страшного
}
// Строим env vars для patch (StrategicMergePatch мержит по "name")
envItems := make([]map[string]string, 0, len(envVars))
for _, ev := range envVars {
envItems = append(envItems, map[string]string{"name": ev["name"], "value": ev["value"]})
}
patch := map[string]any{
"spec": map[string]any{
"template": map[string]any{
"spec": map[string]any{
"containers": []any{
map[string]any{
"name": envContainerName,
"env": envItems,
},
},
},
},
},
}
patchBytes, err := json.Marshal(patch)
if err != nil {
return fmt.Errorf("marshal patch: %w", err)
}
for _, depl := range deplList.Items {
if _, err := s.kube.AppsV1().Deployments(ns).Patch(
ctx, depl.Name, k8stypes.StrategicMergePatchType, patchBytes, metav1.PatchOptions{},
); err != nil {
return fmt.Errorf("patch deployment %s: %w", depl.Name, err)
}
}
return nil
}
+37 -2
View File
@@ -22,6 +22,7 @@ import (
"strings"
"time"
"fission-console/internal/billing"
"fission-console/internal/fission"
apierrors "k8s.io/apimachinery/pkg/api/errors"
@@ -202,9 +203,26 @@ func (s *Server) handleInvokeFunction(w http.ResponseWriter, r *http.Request, na
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
durationMS := time.Since(start).Milliseconds()
s.billing.RecordInvocation(billing.Invocation{
Namespace: ns,
FunctionName: name,
TriggerType: billing.TriggerConsole,
Route: invokeURL,
HTTPMethod: invokeMethod,
StartedAt: start,
DurationMS: durationMS,
StatusCode: resp.StatusCode,
RequestBytes: int64(len(bodyBytes)),
ResponseBytes: int64(len(respBody)),
RecordedBy: "console",
EventType: "invoke",
})
writeAnyJSON(w, http.StatusOK, map[string]any{
"status": resp.StatusCode,
"latency_ms": time.Since(start).Milliseconds(),
"latency_ms": durationMS,
"response_raw": string(respBody),
})
}
@@ -311,9 +329,26 @@ func (s *Server) invokeInternalFunction(w http.ResponseWriter, r *http.Request,
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
durationMS := time.Since(start).Milliseconds()
s.billing.RecordInvocation(billing.Invocation{
Namespace: namespace,
FunctionName: functionName,
TriggerType: billing.TriggerHTTP,
Route: extraPath,
HTTPMethod: r.Method,
StartedAt: start,
DurationMS: durationMS,
StatusCode: resp.StatusCode,
RequestBytes: int64(len(bodyBytes)),
ResponseBytes: int64(len(respBody)),
RecordedBy: "console",
EventType: "invoke",
})
writeAnyJSON(w, http.StatusOK, map[string]any{
"status": resp.StatusCode,
"latency_ms": time.Since(start).Milliseconds(),
"latency_ms": durationMS,
"response_raw": string(respBody),
})
}
+28 -3
View File
@@ -25,7 +25,6 @@ import (
"time"
"fission-console/internal/auth"
"fission-console/internal/fission"
)
// validFuncName — RFC 1123 subdomain label: строчные буквы+цифры+дефис, без дефиса в начале/конце.
@@ -51,11 +50,11 @@ const fissionSourceTypeAnnotation = "fission-console/source-type"
const defaultFunctionInvokeTimeout = 60 * time.Second
// handleFunctionsRoot обрабатывает запросы к /console/api/functions без имени функции.
// GET → список всех функций, POST → создать новую.
// GET → список всех функций со статусами, POST → создать новую.
func (s *Server) handleFunctionsRoot(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
s.handleList(fission.FunctionGVR)(w, r)
s.handleListFunctionsWithStatus(w, r)
case http.MethodPost:
s.handleCreateFunction(w, r)
default:
@@ -127,6 +126,25 @@ func (s *Server) handleFunctionsAction(w http.ResponseWriter, r *http.Request) {
return
}
// /functions/:name/envvars — переменные окружения
if len(parts) == 2 && parts[1] == "envvars" {
switch r.Method {
case http.MethodGet:
s.handleGetFunctionEnvVars(w, r, name)
case http.MethodPut:
s.handlePutFunctionEnvVars(w, r, name)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
return
}
// /functions/:name/clone — клонировать функцию с новым именем
if len(parts) == 2 && parts[1] == "clone" && r.Method == http.MethodPost {
s.handleCloneFunction(w, r, name)
return
}
http.NotFound(w, r)
}
@@ -163,6 +181,13 @@ func (s *Server) handleAuth(w http.ResponseWriter, r *http.Request) {
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")
_ = 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,
}
}
+17
View File
@@ -13,8 +13,10 @@ import (
"time"
"fission-console/internal/auth"
"fission-console/internal/billing"
"fission-console/internal/cloud"
"fission-console/internal/fission"
"fission-console/internal/stats"
"fission-console/ui"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -59,6 +61,12 @@ type Server struct {
// nsManager управляет жизненным циклом пользовательских namespace-ов.
nsManager *cloud.NSManager
// billing — слой записи статистики вызовов. NoopStore если BILLING_DSN не задан.
billing billing.Store
// stats — аналитический слой (Grafana Organizations). NoopProvider если не настроен.
stats stats.StatsProvider
}
// Config содержит все параметры для создания Server.
@@ -76,6 +84,8 @@ type Config struct {
Authenticator auth.Authenticator // слой аутентификации
LLMUrl string
LLMKey string
Billing billing.Store // слой статистики (NoopStore если не задан)
Stats stats.StatsProvider // аналитика (NoopProvider если не настроен)
}
// NewServer создаёт и настраивает HTTP Server со всеми зависимостями.
@@ -96,6 +106,8 @@ func NewServer(cfg Config) *Server {
llmURL: cfg.LLMUrl,
llmKey: cfg.LLMKey,
nsManager: cloud.NewNSManager(cfg.Dyn),
billing: cfg.Billing,
stats: cfg.Stats,
}
}
@@ -157,8 +169,13 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) {
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.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/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/lint-archive", auth(s.handleLintArchive))
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,
})
}
+138
View File
@@ -0,0 +1,138 @@
// Package api — определение статуса функции.
//
// Этот файл содержит функции для определения статуса функции на основе:
// - buildStatus пакета (Package CRD)
// - состояния pod-а (kubernetes Pod)
//
// Возможные статусы:
// - "Ready" (зелёный) — Pod в состоянии Running
// - "Cold" (синий) — функция готова, но нет активного Pod
// - "Building" (жёлтый) — Package ещё собирается (buildStatus != "succeeded")
// - "Error" (красный) — Pod в состоянии ошибки (CrashLoopBackOff, ImagePullBackOff и т.д.) или build failed
package api
import (
"context"
"fmt"
"net/http"
"time"
"fission-console/internal/fission"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
// FunctionStatus — расширенный объект Function с полем status.
type FunctionStatus struct {
// Встраиваем исходный объект и добавляем status
Object map[string]any `json:"object,inline"`
Status string `json:"status"` // "Ready", "Cold", "Building", "Error"
}
// getFunctionStatus определяет статус функции на основе pod-ов и Package.
func (s *Server) getFunctionStatus(ctx context.Context, ns string, fn *unstructured.Unstructured) (string, error) {
fnName := fn.GetName()
// Шаг 1: проверяем buildStatus пакета (если есть)
packageName, _, _ := unstructured.NestedString(fn.Object, "spec", "package", "packageref", "name")
if packageName != "" {
pkg, pkgErr := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Get(ctx, packageName, metav1.GetOptions{})
if pkgErr == nil {
buildStatus, _, _ := unstructured.NestedString(pkg.Object, "status", "buildstatus")
// "failed" → Error
// "none", "succeeded" → OK, Package готов
// "pending", "building" и т.д. → Building
if buildStatus == "failed" {
return "Error", nil
}
if buildStatus != "" && buildStatus != "succeeded" && buildStatus != "none" {
// Building, Pending и т.д.
return "Building", nil
}
}
}
// Шаг 2: ищем Pod-ы для этой функции (по лейблу functionName=<name>)
pods, err := s.kube.CoreV1().Pods(ns).List(ctx, metav1.ListOptions{
LabelSelector: fmt.Sprintf("functionName=%s", fnName),
})
if err != nil && !apierrors.IsNotFound(err) {
// Логируем ошибку, но не падаем — возвращаем "Cold" (функция есть, но pod-а нет)
return "Cold", nil
}
// Шаг 3: анализируем состояние pod-ов
if pods != nil && len(pods.Items) > 0 {
for _, pod := range pods.Items {
// Проверяем фазу pod-а
phase := pod.Status.Phase
if phase == "Running" {
// Дополнительная проверка: все контейнеры готовы?
allReady := true
for _, status := range pod.Status.ContainerStatuses {
if !status.Ready {
allReady = false
break
}
}
if allReady {
return "Ready", nil
}
}
// Проверяем состояние контейнеров (CrashLoopBackOff, ImagePullBackOff и т.д.)
for _, status := range pod.Status.ContainerStatuses {
if status.State.Waiting != nil && status.State.Waiting.Reason == "CrashLoopBackOff" {
return "Error", nil
}
if status.State.Waiting != nil && status.State.Waiting.Reason == "ImagePullBackOff" {
return "Error", nil
}
if status.LastTerminationState.Terminated != nil && status.LastTerminationState.Terminated.ExitCode != 0 {
return "Error", nil
}
}
}
// Pod есть, но не в Running или Error состоянии → Building/Deploying
return "Building", nil
}
// Нет pod-ов → Cold (функция холодная, но готова)
return "Cold", nil
}
// handleListFunctionsWithStatus возвращает список функций с полем "status".
// Добавляет status в поле .metadata.annotations["fission-console/status"] каждой функции.
func (s *Server) handleListFunctionsWithStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) // 30 сек для определения статуса всех функций
defer cancel()
ns := s.userNS(r)
// Получаем список функций
list, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).List(ctx, metav1.ListOptions{})
if err != nil {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("list functions: %v", err))
return
}
// Обогащаем каждую функцию статусом (добавляем в .metadata.annotations["fission-console/status"])
for i := range list.Items {
status, _ := s.getFunctionStatus(ctx, ns, &list.Items[i])
anns := list.Items[i].GetAnnotations()
if anns == nil {
anns = make(map[string]string)
}
anns["fission-console/status"] = status
list.Items[i].SetAnnotations(anns)
}
writeJSON(w, http.StatusOK, list.Items)
}
+42
View File
@@ -0,0 +1,42 @@
// Package billing — слой записи статистики вызовов функций.
//
// Независим от типа БД: снаружи виден только интерфейс Store.
// Если BILLING_DSN не задан — работает NoopStore (тихо, не крашит).
package billing
import "time"
// TriggerType описывает источник вызова.
const (
TriggerHTTP = "http" // вызов через /fn/... снаружи
TriggerCron = "cron" // вызов по расписанию
TriggerConsole = "console" // вызов через кнопку «Вызов» в UI
TriggerEvent = "event" // lifecycle: create/delete/clone/update
)
// Invocation — одна запись о вызове или событии функции.
type Invocation struct {
Namespace string // пользовательский namespace (= пользователь)
FunctionName string // имя функции
TriggerType string // TriggerHTTP / TriggerCron / TriggerConsole / TriggerEvent
Route string // HTTP маршрут (/abc123/my-func), пусто для event
HTTPMethod string // GET/POST/... пусто для event
StartedAt time.Time // время начала
DurationMS int64 // длительность в миллисекундах (0 для event)
StatusCode int // HTTP статус ответа (0 для event)
ColdStart bool // true = первый вызов после создания/простоя
RequestBytes int64 // размер тела запроса
ResponseBytes int64 // размер тела ответа
ErrorMsg string // сообщение об ошибке, пусто если успех
RecordedBy string // "console" | "router"
EventType string // для TriggerEvent: "create" | "delete" | "clone" | "update" | "invoke"
}
// Store — интерфейс записи статистики.
// Реализации: pgStore (PostgreSQL через pgx), NoopStore (заглушка).
type Store interface {
// RecordInvocation записывает одно событие асинхронно (fire-and-forget).
RecordInvocation(inv Invocation)
// Close освобождает ресурсы (пул соединений и т.д.).
Close()
}
+23
View File
@@ -0,0 +1,23 @@
package billing
import (
"context"
"log"
"os"
)
// NewStore создаёт Store из переменной окружения BILLING_DSN.
// Если DSN пустой — возвращает NoopStore (статистика отключена, сервер работает нормально).
func NewStore() Store {
dsn := os.Getenv("BILLING_DSN")
if dsn == "" {
log.Printf("billing: BILLING_DSN not set, statistics disabled")
return NoopStore{}
}
store, err := NewPostgresStore(context.Background(), dsn)
if err != nil {
log.Printf("billing: failed to connect to PostgreSQL: %v — statistics disabled", err)
return NoopStore{}
}
return store
}
+8
View File
@@ -0,0 +1,8 @@
package billing
// NoopStore — заглушка Store, которая тихо игнорирует все записи.
// Используется когда BILLING_DSN не задан.
type NoopStore struct{}
func (NoopStore) RecordInvocation(_ Invocation) {}
func (NoopStore) Close() {}
+90
View File
@@ -0,0 +1,90 @@
package billing
import (
"context"
"log"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// pgStore — реализация Store поверх PostgreSQL через pgx connection pool.
type pgStore struct {
pool *pgxpool.Pool
}
// NewPostgresStore создаёт Store с пулом соединений к PostgreSQL.
// dsn — строка вида postgres://user:pass@host:5432/dbname
// При ошибке подключения — возвращает ошибку, вызывающий код решает как реагировать.
func NewPostgresStore(ctx context.Context, dsn string) (Store, error) {
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
return nil, err
}
cfg.MaxConns = 4
cfg.MinConns = 1
cfg.MaxConnIdleTime = 5 * time.Minute
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
return nil, err
}
// Проверяем живость соединения
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, err
}
log.Printf("billing: connected to PostgreSQL")
return &pgStore{pool: pool}, nil
}
// RecordInvocation вставляет запись асинхронно — не блокирует основной поток.
func (s *pgStore) RecordInvocation(inv Invocation) {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := s.pool.Exec(ctx, `
INSERT INTO invocations (
namespace, function_name, trigger_type, route, http_method,
started_at, duration_ms, status_code, cold_start,
request_bytes, response_bytes, error_msg, recorded_by, event_type
) VALUES (
$1,$2,$3,$4,$5,
$6,$7,$8,$9,
$10,$11,$12,$13,$14
)`,
inv.Namespace,
inv.FunctionName,
inv.TriggerType,
inv.Route,
inv.HTTPMethod,
inv.StartedAt,
inv.DurationMS,
inv.StatusCode,
inv.ColdStart,
inv.RequestBytes,
inv.ResponseBytes,
nullIfEmpty(inv.ErrorMsg),
inv.RecordedBy,
nullIfEmpty(inv.EventType),
)
if err != nil {
log.Printf("billing: insert invocation: %v", err)
}
}()
}
// Close закрывает пул соединений.
func (s *pgStore) Close() {
s.pool.Close()
log.Printf("billing: connection pool closed")
}
// nullIfEmpty возвращает nil для пустой строки (SQL NULL вместо пустой строки).
func nullIfEmpty(s string) interface{} {
if s == "" {
return nil
}
return s
}
+12 -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{})
if pkgListErr == nil {
for _, pkg := range packages.Items {
@@ -402,7 +412,7 @@ func (m *NSManager) reapExpiredFunctionsInNS(ctx context.Context, ns string, now
continue
}
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)
_ = 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"}
HTTPTrigGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "httptriggers"}
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"}
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"
// Проверяем существование — 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 {
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) {
return "", fmt.Errorf("check environment %q: %w", envName, getErr)
+27 -1
View File
@@ -5,11 +5,14 @@ package model
// CreateFunctionRequest — тело POST /console/api/functions.
// TTL пустой → функция живёт вечно; "1d", "24h" — протухнет через указанное время.
// Deps — содержимое файла зависимостей: requirements.txt (python), package.json deps (nodejs),
// Gemfile (ruby), composer.json (php). Если задан — код упаковывается в zip вместе с deps-файлом.
type CreateFunctionRequest struct {
Name string `json:"name"`
Language string `json:"language"`
Environment string `json:"environment"`
Code string `json:"code"`
Deps string `json:"deps"` // содержимое файла зависимостей (опционально)
Entrypoint string `json:"entrypoint"`
Route string `json:"route"`
Methods []string `json:"methods"`
@@ -26,9 +29,32 @@ type CreateTimeTriggerRequest struct {
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.
type UpdateCodeRequest struct {
Code string `json:"code"`
Deps string `json:"deps"` // содержимое файла зависимостей (опционально)
Timeout int64 `json:"timeout"`
}
@@ -44,7 +70,7 @@ type LangEnvDef struct {
// LangEnvMap сопоставляет идентификатор языка (string) с описанием среды выполнения.
// Ключ используется в createFunctionRequest.Language и как суффикс имени Environment.
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"},
"go": {Image: "ghcr.io/fission/go-env", BuilderImage: "naeel/go-builder-fast:v1"},
"php": {Image: "ghcr.io/fission/php-env"},
+3
View File
@@ -11,6 +11,9 @@ package runtime
func DefaultEntrypoint(lang string) string {
switch lang {
case "nodejs":
// "main" (без .js): node-env.specializeV2 делает split(".") для functionName
// с точкой → funcname="js" → ищет экспорт "js" → 500.
// Без расширения: funcname=undefined → берёт default export (module.exports = __invoke).
return "main"
case "php":
// Fission php-env: filename::functionName
+25
View File
@@ -25,3 +25,28 @@ func buildZip(fileName string, content []byte) ([]byte, error) {
}
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
}
+17
View File
@@ -11,3 +11,20 @@ package runtime
func BuildScriptZip(code, fileName string) ([]byte, error) {
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
}
+143 -2
View File
@@ -25,6 +25,7 @@
<script src="js/fn-archive.js"></script>
<script src="js/ai.js"></script>
<script src="js/app.js"></script>
<script src="js/mq.js"></script>
</head>
<body>
@@ -102,13 +103,14 @@
<div class="nubes">NUBES</div>
<div class="product">FISSION CONSOLE</div>
</div>
<div style="font-size:0.65rem; color:var(--text-secondary); margin-left:10px; align-self:center; opacity:0.7;">v1.3.69</div>
<div style="font-size:0.65rem; color:var(--text-secondary); margin-left:10px; align-self:center; opacity:0.7;">v1.3.94</div>
</div>
<div class="row" style="margin:0;">
<button class="btn ghost" onclick="reloadAll()">Refresh</button>
<button class="btn" onclick="openCreateCode()">✏️ Из кода</button>
<button class="btn" onclick="openCreateArchive()">📦 Из архива</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>
</div>
</div>
@@ -135,6 +137,10 @@
<div class="k">Крон-функции</div>
<div id="cron-count" class="v">-</div>
</div>
<div class="card">
<div class="k">MQ-триггеры</div>
<div id="mq-count" class="v">-</div>
</div>
</div>
<div class="box">
@@ -142,10 +148,23 @@
<div style="font-weight:600;">Функции</div>
<div class="hint">Actions: view, edit code, invoke, delete</div>
</div>
<!-- Легенда статусов -->
<div style="margin-bottom:12px; padding:8px; background:var(--bg-alt); border-radius:4px; font-size:0.9em; color:var(--text-secondary);">
<strong>Статусы функций:</strong>
<span style="margin-left:16px;">
<span style="display:inline-block; width:10px; height:10px; border-radius:50%; background:#2a2; vertical-align:middle;"></span> Готова
<span style="margin-left:12px; display:inline-block; width:10px; height:10px; border-radius:50%; background:#22a; vertical-align:middle;"></span> Холодная
<span style="margin-left:12px; display:inline-block; width:10px; height:10px; border-radius:50%; background:#aa2; vertical-align:middle;"></span> Сборка
<span style="margin-left:12px; display:inline-block; width:10px; height:10px; border-radius:50%; background:#a22; vertical-align:middle;"></span> Ошибка
</span>
</div>
<table>
<thead>
<tr>
<th>Имя</th>
<th title="Статус функции (Готова / Холодная / Сборка / Ошибка)">Статус</th>
<th title="Тип источника функции">Тип</th>
<th>Создана</th>
<th>Изменена</th>
@@ -160,6 +179,72 @@
<div id="status" class="status"></div>
<div class="hint">Изменения применяются напрямую через CRD Fission.</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>
<!-- Модалка: создать функцию из кода (prefix cc-) -->
@@ -221,6 +306,12 @@
<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>
</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 id="cc-gen-prompt" style="display:none; margin-top:8px; gap:6px; align-items:center;">
<input id="cc-gen-desc" type="text"
@@ -360,6 +451,12 @@
<button class="btn ghost" id="e-exp-btn" onclick="aiExplain('e-code','e-lang-hidden','e-ai-result')">&#x1F4D6;
LLM: Что делает?</button>
</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 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);">
@@ -376,7 +473,31 @@
style="display:none;margin-top:8px;padding:10px 12px;border-radius:6px;font-size:13px;line-height:1.5;white-space:pre-wrap;font-family:monospace;">
</div>
</div>
<!-- Переменные окружения -->
<div style="margin-top:16px;">
<div style="font-weight:600; margin-bottom:8px; font-size:13px;">Переменные окружения</div>
<div id="e-tf-env-warn" style="display:none; margin-bottom:8px; padding:6px 10px; background:#553300; color:#ffa; border-radius:4px; font-size:12px;">
⚠️ Функция управляется Terraform — переменные только для чтения
</div>
<div id="e-envvars-list" style="display:flex; flex-direction:column; gap:4px; margin-bottom:8px;"></div>
<button id="e-add-envvar-btn" class="btn ghost" style="font-size:12px;" onclick="addEnvVarRow('e')">+ Добавить переменную</button>
</div>
<div id="e-error-msg" style="display:none;color:#f66;font-size:0.85rem;margin-bottom:8px;"></div>
<div id="e-clone-area" style="display:none;margin-top:10px;padding:10px 12px;background:var(--bg-alt,#2a2a3a);border-radius:6px;border:1px solid var(--border,#444);">
<div style="font-size:0.82rem;color:var(--fg-muted,#aaa);margin-bottom:6px;">Имя новой функции (копии):</div>
<div style="display:flex;gap:8px;align-items:center;">
<input id="e-clone-name" placeholder="new-function-name" style="flex:1;font-size:0.85rem;">
<button class="btn" onclick="submitClone()">Клонировать</button>
<button class="btn ghost" onclick="toggleCloneArea(false)"></button>
</div>
<div id="e-clone-error" style="display:none;color:#f66;font-size:0.78rem;margin-top:5px;"></div>
</div>
<div class="actions">
<button class="btn ghost" onclick="toggleCloneArea(true)" title="Создать копию функции">📋 Клон</button>
<button class="btn ghost" onclick="closeEdit()">Отмена</button>
<button id="e-submit" class="btn" onclick="submitEdit()">Сохранить</button>
</div>
@@ -440,6 +561,26 @@
</div>
</div>
<div class="help-section">
<h4>Переменные окружения функции</h4>
<div class="help-list">
<div>В редакторе функции (кнопка <strong>Ред.</strong>) есть раздел <strong>Переменные окружения</strong>.</div>
<div>Нажмите <strong>+ Добавить</strong> чтобы задать переменную: введите имя и значение, нажмите <strong>Сохранить env vars</strong>.</div>
<div>Переменные доступны внутри функции через <span class="mono">os.environ</span> (Python), <span class="mono">process.env</span> (Node.js), <span class="mono">os.Getenv</span> (Go) и т.д.</div>
<div>Изменения применяются к запущенному поду функции сразу после сохранения.</div>
</div>
</div>
<div class="help-section">
<h4>Клонирование функции</h4>
<div class="help-list">
<div>В редакторе функции (кнопка <strong>Ред.</strong>) есть кнопка <strong>📋 Клон</strong> в нижней панели.</div>
<div>Введите имя для новой функции и нажмите <strong>Клонировать</strong>.</div>
<div>Клон получает тот же код, язык, entrypoint, timeout и env vars. Маршрут назначается автоматически.</div>
<div>Имя: строчные буквы, цифры, дефис; не начинается и не заканчивается дефисом; до 57 символов.</div>
</div>
</div>
<div class="help-section">
<h4>Как вызвать функцию вне веб-интерфейса</h4>
<div class="help-list">
@@ -485,7 +626,7 @@
</div>
<div class="actions" style="justify-content:space-between; align-items:center;">
<span style="font-size:0.75rem; color:var(--text-secondary);">v1.3.69</span>
<span style="font-size:0.75rem; color:var(--text-secondary);">v1.3.94</span>
<button class="btn ghost" onclick="closeHelp()">Закрыть</button>
</div>
</div>
+57 -1
View File
@@ -1,5 +1,43 @@
/* 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) {
var text = 'Сделано LLM. Не доверяй, проверяй!';
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 });
resEl.style.background = data.ok ? '#1a3a1a' : '#3a1a1a';
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) {
resEl.style.background = '#3a2a00';
resEl.style.color = '#ffa';
+19 -1
View File
@@ -16,6 +16,9 @@ async function reloadAll() {
getJSON(API_BASE + '/timetriggers')
]);
// MQ-триггеры загружаем параллельно, не блокируем основную таблицу
if (typeof loadMQTriggers === 'function') loadMQTriggers();
S.envs = envs || [];
S.fns = fns || [];
S.httpTriggers = http || [];
@@ -48,6 +51,20 @@ async function reloadAll() {
const createdAt = ann['fission-console/created-at'] || meta.creationTimestamp || '-';
const updatedAt = ann['fission-console/updated-at'] || createdAt;
const sourceType = ann['fission-console/source-type'] || 'code';
const status = ann['fission-console/status'] || 'Cold';
// Статус функции: иконка и цвет
const statusColor = {
'Ready': '#2a2', 'Cold': '#22a', 'Building': '#aa2', 'Error': '#a22'
}[status] || '#666';
const statusTitle = {
'Ready': 'Готова (есть pod)',
'Cold': 'Холодная (pod создаётся при первом вызове)',
'Building': 'Собирается / деплоится',
'Error': 'Ошибка (pod в CrashLoopBackOff или build failed)'
}[status] || 'Неизвестно';
const statusDot = '<span style="display:inline-block; width:12px; height:12px; border-radius:50%; background:' + statusColor + '; cursor:default;" title="' + h(statusTitle) + '"></span>';
const sourceIcon = sourceType === 'archive'
? '<span title="Из архива (.zip)" style="font-size:1.1em; cursor:default;">📦</span>'
: '<span title="Из кода (редактор)" style="font-size:1.1em; cursor:default;">📝</span>';
@@ -58,7 +75,7 @@ async function reloadAll() {
var infoData = h(JSON.stringify({
name: name, env: env, pkg: pkg, entrypoint: entrypoint, timeout: timeout,
route: route, methods: methods, sourceType: sourceType,
createdAt: createdAt, updatedAt: updatedAt, cron: cron
createdAt: createdAt, updatedAt: updatedAt, cron: cron, status: status
}));
var actions = tfBadge +
'<button class="btn ghost" onclick="openInfo(\'' + h(name) + '\', this)" data-info="' + infoData + '">Info</button> ' +
@@ -68,6 +85,7 @@ async function reloadAll() {
'<button class="btn danger" onclick="removeFn(\'' + h(name) + '\')">Удалить</button>';
return '<tr>' +
'<td class="mono">' + h(name) + '</td>' +
'<td style="text-align:center;">' + statusDot + '</td>' +
'<td style="text-align:center;">' + sourceIcon + '</td>' +
'<td>' + timestampCell(createdAt) + '</td>' +
'<td>' + timestampCell(updatedAt) + '</td>' +
+21
View File
@@ -93,6 +93,27 @@ function doLogout() {
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() {
var storedToken = localStorage.getItem('auth_token');
if (!storedToken) {
+20 -1
View File
@@ -7,6 +7,23 @@ function onLangChangeCode() {
document.getElementById('cc-entry').value = t.entrypoint;
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() {
@@ -16,6 +33,7 @@ function openCreateCode() {
document.getElementById('cc-route').value = '';
document.getElementById('cc-methods').value = 'GET';
document.getElementById('cc-timeout').value = '60';
document.getElementById('cc-deps').value = '';
document.getElementById('cc-schedule-enabled').checked = false;
document.getElementById('cc-cron').value = '';
toggleScheduleFields('cc');
@@ -47,7 +65,8 @@ async function submitCreateCode() {
route: document.getElementById('cc-route').value.trim(),
methods: parseMethods(document.getElementById('cc-methods').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 {
+220 -1
View File
@@ -151,6 +151,10 @@ function parseTimeout(v) {
async function openEdit(name) {
try {
var errEl = document.getElementById('e-error-msg');
if (errEl) errEl.style.display = 'none';
var cloneArea = document.getElementById('e-clone-area');
if (cloneArea) cloneArea.style.display = 'none';
const fn = await getJSON(API_BASE + '/functions/' + encodeURIComponent(name));
S.currentEdit = fn;
document.getElementById('e-title').textContent = 'Редактирование: ' + name;
@@ -184,6 +188,13 @@ async function openEdit(name) {
else if (envName.includes('ruby')) lang = 'ruby';
else if (envName.includes('php')) lang = 'php';
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');
if (aiRes) { aiRes.style.display = 'none'; aiRes.textContent = ''; }
var warnEl = document.getElementById('e-tf-warn');
@@ -191,6 +202,11 @@ async function openEdit(name) {
var isTf = /^tf-/.test(name) || /go[-_]env/.test(fn.environment || '');
warnEl.style.display = isTf ? 'block' : 'none';
}
// Env vars — отрисовать блок; TF-функции — только чтение
var isTfFn = /^tf-/.test(name) || /go[-_]env/.test(fn.environment || '');
renderEnvVars('e', fn.env_vars || [], isTfFn);
document.getElementById('edit-modal').classList.add('open');
} catch (e) {
showStatus('Ошибка загрузки функции: ' + e.message, 'err');
@@ -233,16 +249,42 @@ async function submitEdit() {
} else {
await requestJSON(API_BASE + '/functions/' + encodeURIComponent(name) + '/code', 'PUT', {
code: document.getElementById('e-code').value,
deps: document.getElementById('e-deps').value.trim(),
timeout: parseTimeout(document.getElementById('e-timeout').value)
});
}
await syncScheduleForFunction(name, 'e');
// Сохраняем env vars (только если не TF-функция)
var isTfFn = /^tf-/.test(name);
if (!isTfFn) {
var envVars = collectEnvVars('e');
// Проверка формата ключей
var invalidKeys = envVars.map(function(ev) { return ev.name; }).filter(function(k) { return !ENV_KEY_RE.test(k); });
if (invalidKeys.length > 0) {
throw new Error('Недопустимые имена переменных: ' + invalidKeys.join(', ') + ' (только латиница, цифры, _, не с цифры)');
}
// Проверка на дубли ключей
var keys = envVars.map(function(ev) { return ev.name; });
var dupes = keys.filter(function(k, i) { return keys.indexOf(k) !== i; });
if (dupes.length > 0) {
throw new Error('Дублирующиеся ключи: ' + [...new Set(dupes)].join(', '));
}
await requestJSON(API_BASE + '/functions/' + encodeURIComponent(name) + '/envvars', 'PUT', {
env_vars: envVars
});
}
var errEl = document.getElementById('e-error-msg');
if (errEl) errEl.style.display = 'none';
closeEdit();
progress.stop('Код обновлён: ' + name, 'ok');
await reloadAll();
} catch (e) {
progress.stop('Ошибка обновления: ' + e.message, 'err');
var errEl = document.getElementById('e-error-msg');
if (errEl) { errEl.textContent = e.message; errEl.style.display = 'block'; }
progress.stop('', '');
} finally {
btn.disabled = false;
}
@@ -260,3 +302,180 @@ async function removeFn(name) {
progress.stop('Ошибка удаления: ' + e.message, 'err');
}
}
// --- Env Vars UI ---
// renderEnvVars отрисовывает список переменных окружения в блоке prefix-envvars-list
// vars: [{name: "KEY", value: "VAL"}, ...]
// readOnly: true для TF-функций
function renderEnvVars(prefix, vars, readOnly) {
var list = document.getElementById(prefix + '-envvars-list');
var addBtn = document.getElementById(prefix + '-add-envvar-btn');
var tfWarn = document.getElementById(prefix + '-tf-env-warn');
if (!list) return;
list.innerHTML = '';
if (readOnly) {
if (tfWarn) tfWarn.style.display = '';
if (addBtn) addBtn.style.display = 'none';
} else {
if (tfWarn) tfWarn.style.display = 'none';
if (addBtn) addBtn.style.display = '';
}
(vars || []).forEach(function(ev, idx) {
list.appendChild(makeEnvVarRow(prefix, ev.name || '', ev.value || '', readOnly, idx));
});
}
// makeEnvVarRow создаёт одну строку key=value с кнопкой удаления
function makeEnvVarRow(prefix, key, val, readOnly, idx) {
var row = document.createElement('div');
row.style.cssText = 'display:flex; gap:6px; align-items:center;';
row.dataset.envIdx = idx;
var kInput = document.createElement('input');
kInput.placeholder = 'KEY (A-Z, a-z, 0-9, _)';
kInput.value = key;
kInput.disabled = readOnly;
kInput.style.cssText = 'flex:1; font-size:12px; font-family:monospace;';
kInput.dataset.envKey = '1';
// Подсветка дублей при изменении ключа
kInput.addEventListener('input', function() { highlightDupeKeys(prefix); });
var vInput = document.createElement('input');
vInput.placeholder = 'value';
vInput.value = val;
vInput.disabled = readOnly;
vInput.style.cssText = 'flex:2; font-size:12px; font-family:monospace;';
vInput.dataset.envVal = '1';
row.appendChild(kInput);
var eq = document.createElement('span');
eq.textContent = '=';
eq.style.cssText = 'color:var(--text-secondary); font-family:monospace;';
row.appendChild(eq);
row.appendChild(vInput);
if (!readOnly) {
var delBtn = document.createElement('button');
delBtn.textContent = '×';
delBtn.className = 'btn ghost';
delBtn.style.cssText = 'padding:2px 8px; font-size:14px; line-height:1;';
delBtn.title = 'Удалить переменную';
delBtn.onclick = function() { row.remove(); };
row.appendChild(delBtn);
}
return row;
}
var ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
// highlightDupeKeys подсвечивает красным все поля KEY с одинаковыми именами или невалидным именем
function highlightDupeKeys(prefix) {
var list = document.getElementById(prefix + '-envvars-list');
if (!list) return;
var inputs = list.querySelectorAll('[data-env-key]');
var keys = Array.from(inputs).map(function(i) { return i.value.trim(); });
inputs.forEach(function(inp) {
var k = inp.value.trim();
var isDupe = k !== '' && keys.filter(function(x) { return x === k; }).length > 1;
var isInvalid = k !== '' && !ENV_KEY_RE.test(k);
inp.style.outline = (isDupe || isInvalid) ? '2px solid #f44' : '';
inp.title = isDupe ? 'Дублирующийся ключ!' : isInvalid ? 'Только латиница, цифры и _. Не начинается с цифры.' : '';
});
}
// addEnvVarRow добавляет пустую строку в список env vars
function addEnvVarRow(prefix) {
var list = document.getElementById(prefix + '-envvars-list');
if (!list) return;
var idx = list.children.length;
list.appendChild(makeEnvVarRow(prefix, '', '', false, idx));
}
// collectEnvVars читает текущие значения из DOM и возвращает [{name, value}, ...]
function collectEnvVars(prefix) {
var list = document.getElementById(prefix + '-envvars-list');
if (!list) return [];
var result = [];
var rows = list.querySelectorAll('div[data-env-idx]');
rows.forEach(function(row) {
var k = row.querySelector('[data-env-key]');
var v = row.querySelector('[data-env-val]');
var key = k ? k.value.trim() : '';
var val = v ? v.value : '';
if (key) result.push({name: key, value: val});
});
return result;
}
// toggleCloneArea показывает/скрывает форму клонирования
function toggleCloneArea(show) {
var area = document.getElementById('e-clone-area');
var errEl = document.getElementById('e-clone-error');
if (!area) return;
area.style.display = show ? 'block' : 'none';
if (show) {
var nameInput = document.getElementById('e-clone-name');
if (nameInput) {
// Предзаполняем именем оригинала + '-copy'
var srcName = (S.currentEdit && S.currentEdit.name) || '';
nameInput.value = srcName ? srcName + '-copy' : '';
nameInput.focus();
nameInput.select();
}
if (errEl) errEl.style.display = 'none';
}
}
// submitClone отправляет запрос на клонирование функции
async function submitClone() {
var nameInput = document.getElementById('e-clone-name');
var errEl = document.getElementById('e-clone-error');
var btn = document.querySelector('#e-clone-area .btn:not(.ghost)');
var newName = nameInput ? nameInput.value.trim() : '';
if (!newName) {
if (errEl) { errEl.textContent = 'Введите имя новой функции'; errEl.style.display = 'block'; }
return;
}
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(newName) || newName.length > 57) {
if (errEl) { errEl.textContent = 'Имя: строчные буквы, цифры, дефис; не начинается/заканчивается дефисом; до 57 символов'; errEl.style.display = 'block'; }
return;
}
var srcName = S.currentEdit && S.currentEdit.name;
if (!srcName) {
if (errEl) { errEl.textContent = 'Нет функции для клонирования'; errEl.style.display = 'block'; }
return;
}
if (btn) { btn.disabled = true; btn.textContent = '...'; }
if (errEl) errEl.style.display = 'none';
try {
var data = await requestJSON(API_BASE + '/functions/' + encodeURIComponent(srcName) + '/clone', 'POST', {new_name: newName});
// Успех — закрываем модалку, ждём появления в списке
closeEdit();
showStatus('Клонирование завершено, обновляем список...', '');
var attempts = 0;
async function pollUntilVisible() {
attempts++;
await reloadAll();
var found = (S.fns || []).some(function(f) {
return f && f.metadata && f.metadata.name === newName;
});
if (found || attempts >= 5) {
showStatus('Функция «' + newName + '» создана как копия «' + srcName + '»', 'ok');
} else {
setTimeout(pollUntilVisible, 1000);
}
}
setTimeout(pollUntilVisible, 700);
} catch(e) {
if (errEl) { errEl.textContent = e.message; errEl.style.display = 'block'; }
} finally {
if (btn) { btn.disabled = false; btn.textContent = 'Клонировать'; }
}
}
+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
+128
View File
@@ -0,0 +1,128 @@
# Function Env Vars — ПОЛНАЯ ДОКУМЕНТАЦИЯ (v1.3.77+)
## ПРОБЛЕМА (обнаружена 2026-05-09)
Env vars хранились только в аннотации `fission-console/env-vars` на Function CRD.
Это работало для UI (хранение/отображение), но переменные **не попадали в pod функции**.
### Почему не попадали
Fission `poolmgr` создаёт warm pool подов **заранее**, до того как знает какая функция
будет запущена. Pod уже работает в момент первого вызова функции.
`spec.podspec.containers[].env` из Function CRD при poolmgr **игнорируется** — pod уже запущен.
При специализации (загрузке кода функции) fetcher передаёт в pod только:
- путь к файлу с кодом
- имя entrypoint функции
- секреты и configmaps (как файлы в `/userfunc/...`)
Переменные окружения через `poolmgr` можно установить только на уровне Environment deployment —
одинаковые для ВСЕХ функций этого окружения. Индивидуально для функции — невозможно.
### Отвергнутые варианты
| Вариант | Почему нет |
|---------|-----------|
| `spec.podspec.containers[].env` + poolmgr | Pod уже запущен до специализации |
| `spec.configmaps` → файлы `/userfunc/configs/` | Нужно читать файл в коде — не универсально |
| Патч python-env server | Только Python, другие языки сломаны |
| PostgreSQL/отдельное хранилище | Оверкилл, не официальный путь |
---
## РЕШЕНИЕ (v1.3.77+)
**Автоматическое переключение ExecutorType в зависимости от наличия env vars.**
- Функция **без env vars**`ExecutorType: poolmgr` → warm pool, быстрый cold start (~0.5 сек)
- Функция **с env vars**`ExecutorType: newdeploy` → dedicated Deployment, env vars в OS (~2-5 сек)
Пользователь ничего не настраивает — переключение происходит автоматически.
### Почему newdeploy работает
При `newdeploy` Fission создаёт отдельный Kubernetes `Deployment` для функции.
В этот Deployment попадает `spec.podspec` из Function CRD **включая** `containers[0].env`.
Kubernetes ставит переменные на уровне ОС процесса контейнера при старте пода.
### Почему это универсально для всех языков
Переменные окружения ОС — стандарт POSIX. Любой язык читает без изменений в env-сервере:
```python
os.getenv("MY_VAR") # Python
os.Getenv("MY_VAR") # Go
ENV["MY_VAR"] # Ruby
getenv("MY_VAR") # PHP
process.env.MY_VAR # Node.js
```
### Нет дублирования подов
Warm pool принадлежит **Environment**, не функции. Переключение конкретной функции на `newdeploy`
не трогает pool — другие функции без env vars продолжают использовать его.
---
## РЕАЛИЗАЦИЯ
### Backend: `handlePutFunctionEnvVars` (function_crud.go)
При `PUT /functions/:name/envvars`:
1. Сохраняет env vars в аннотацию `fission-console/env-vars` (для UI)
2. Если env vars **непустые**:
- Выставляет `spec.podspec.containers[0].env` (стандартный Kubernetes EnvVar)
- Переключает `spec.InvokeStrategy.ExecutionStrategy.ExecutorType` = `"newdeploy"`
- MinScale=0, MaxScale=1
3. Если env vars **пустые** (очищены):
- Удаляет `spec.podspec`
- Возвращает `ExecutorType` = `"poolmgr"`
### Двойное хранение
| Где | Зачем |
|-----|-------|
| Аннотация `fission-console/env-vars` | UI: отображение, редактирование |
| `spec.podspec.containers[0].env` | Fission/Kubernetes: реальное применение в pod |
### Структура Function CRD с env vars
```yaml
spec:
InvokeStrategy:
ExecutionStrategy:
ExecutorType: newdeploy
MinScale: 0
MaxScale: 1
StrategyType: execution
podspec:
containers:
- name: <env-name>
env:
- name: p1
value: "envv1"
metadata:
annotations:
fission-console/env-vars: '[{"name":"p1","value":"envv1"}]'
```
### Структура Function CRD без env vars
```yaml
spec:
InvokeStrategy:
ExecutionStrategy:
ExecutorType: poolmgr
StrategyType: execution
# нет podspec
```
---
## ОГРАНИЧЕНИЯ
- `newdeploy` медленнее cold start (~2-5 сек vs ~0.5 сек) — выбор пользователя, добавившего env vars
- MinScale=0: при отсутствии трафика pod удаляется → cold start при первом вызове
- Имена переменных: только `[A-Za-z_][A-Za-z0-9_]*` — стандарт POSIX, проверяется на UI
- TF-функции (`tf-*`) — env vars read-only, изменение запрещено через UI
+209 -3
View File
@@ -1,5 +1,43 @@
# Progress Log
## 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)
### Биллинг / статистика для бухгалтерии
@@ -17,12 +55,180 @@
- UI: формы создания по аналогии с cron, счётчики на главной
### Прочий полезный функционал
- ~~Клонировать функцию (копия с новым именем)~~ ✅ v1.3.80
- ~~Переменные окружения (env vars) через UI~~ ✅ v1.3.78
- Метрики на главной странице (данные уже есть в metrics-collector)
- Автообновление логов (polling каждые 3-5 сек)
- Копировать маршрут одной кнопкой (📋 рядом с маршрутом)
- Статус функции (Ready/NotReady/Error) в таблице
- Клонировать функцию (копия с новым именем)
- Переменные окружения (env vars) через UI
---
## 2026-05-09 — 2026-05-10
### Ветка: `feat/function-status-clone-envvars` / `feat/clone-function`
---
#### v1.3.78 — Переменные окружения: патч Deployment напрямую
**Проблема:** Fission newdeploy executor игнорирует `fn.Spec.PodSpec` при построении
Deployment — применяет только `env.Spec.Runtime.Container`. Сохранение env vars в
Function CRD через UI не приводило к попаданию переменных в pod.
**Решение:** После обновления Function CRD — дополнительно патчить Deployment
напрямую через Kubernetes API (`StrategicMergePatch`).
- Файл: `console/internal/api/function_crud.go`
- Новая функция `patchDeploymentEnvVars(ctx, ns, fnName, envContainerName, envVars)`
- Поиск Deployment по labels: `functionName=<name>,functionNamespace=<ns>,executorType=newdeploy`
- Patch: `StrategicMergePatch` с `containers[{name: envContainerName, env: [...]}]`
- Импорт добавлен: `k8stypes "k8s.io/apimachinery/pkg/types"`
- Образ: `naeel/fission-console:v1.3.78`
---
#### v1.3.79 — LLM: убрано ограничение тем чата + сброс кэша роутера
**Проблема 1:** LLM ассистент отказывался отвечать на вопросы не по теме Fission Console.
**Решение:** Удалён блок `## Чем НЕ занимаешься` из `buildChatSystemPrompt()` в
`console/internal/api/ai_prompts.go`.
**Проблема 2:** Env vars всё ещё не работали после v1.3.78 — роутер закешировал
старый poolmgr адрес (funcSvc cache).
**Решение:** `kubectl rollout restart deployment/router -n fission` — сбросил кэш.
После перезапуска env vars начали попадать в поды.
- Образ: `naeel/fission-console:v1.3.79`
---
#### v1.3.80 — Клонирование функции (полная реализация)
**Фича:** `POST /functions/:name/clone` — создаёт полную копию функции с новым именем.
**Backend** (`console/internal/api/function_clone.go`, новый файл):
1. Валидирует `new_name` (regex `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`, ≤57 символов)
2. Скачивает архив из storagesvc (type:url) или base64 (type:literal)
3. Заливает как новый архив в storagesvc (новый S3-объект — удаление оригинала не ломает клон)
4. Создаёт новый Package с именем `<new_name>-<timestamp>`
5. Копирует аннотации: `language`, `source-type`, `env-vars`, `archive-filename`, добавляет `cloned-from`
6. Копирует entrypoint, timeout, InvokeStrategy, podspec
7. Создаёт Function CRD
8. Копирует HTTP методы из оригинального триггера, создаёт HTTPTrigger
9. При ошибке Function/Trigger — откатывает созданные Package/Function
**Route** (`console/internal/api/handlers.go`):
```
POST /functions/:name/clone
```
**Frontend** (`console/ui/index.html`, `console/ui/js/functions.js`):
- Кнопка **📋 Клон** в нижней панели edit modal
- Inline форма: поле имени, кнопки «Клонировать» / «✕»
- Предзаполнение именем `<original>-copy`
- Валидация на клиенте (тот же regex)
- При успехе: закрытие модалки + polling до появления в таблице
**Тест результат:**
```
POST /console/api/functions/clone-src/clone {"new_name":"clone-src-copy"}
→ 201 {"cloned_from":"clone-src","name":"clone-src-copy","package":"clone-src-copy-20260509162619","route":"/47b6ef5e8225/clone-src-copy"}
kubectl get function clone-src-copy ✅
kubectl get package clone-src-copy-... ✅ type:url storagesvc
kubectl get httptrigger clone-src-copy-route ✅ methods=["GET"]
annotation: fission-console/cloned-from=clone-src ✅
```
- Образ: `naeel/fission-console:v1.3.80`
---
#### v1.3.81 — Fix: clone не обновлял таблицу (`loadFunctions` → `reloadAll`)
**Проблема:** После клонирования таблица не обновлялась — функция `loadFunctions()`
не существует, правильное название `reloadAll()`.
**Fix:** `console/ui/js/functions.js` — замена вызова.
- Образ: `naeel/fission-console:v1.3.81`
---
#### v1.3.82 — Fix: `apiFetch is not defined` → `requestJSON`
**Проблема:** `apiFetch` не существует в codebase. Правильный хелпер — `requestJSON(url, method, body)` из `api.js`.
**Fix:** `console/ui/js/functions.js` — переписан `submitClone()` на `requestJSON`.
- Образ: `naeel/fission-console:v1.3.82`
---
#### v1.3.83 — Fix: clone — задержка 800мс перед reloadAll (race condition)
**Проблема:** После клонирования таблица обновлялась раньше, чем k8s возвращал
новый объект в List-ответе.
**Fix:** `setTimeout(reloadAll, 800)` → заменено на polling с ожиданием появления
функции в `S.fns` (до 5 попыток × 1 сек).
- Образ: `naeel/fission-console:v1.3.83`
---
#### v1.3.84 — Help + LLM промпты: разделы env vars и clone
**Help modal** (`console/ui/index.html`):
- Новая секция **«Переменные окружения функции»**: как добавлять через UI, как читать в коде (`os.environ` / `process.env` / `os.Getenv`)
- Новая секция **«Клонирование функции»**: кнопка 📋 Клон, что копируется, правила имени
**LLM системный промпт** (`console/internal/api/ai_prompts.go`):
- В раздел «Редактирование функции» добавлены пункты «Переменные окружения» и «📋 Клон»
- Образ: `naeel/fission-console:v1.3.84`
---
#### v1.3.85 — Fix: polling до появления клона в таблице
**Проблема:** После клонирования — резкий переход в таблицу, клон ещё не виден
(k8s list не успел вернуть новый объект).
**Fix:** `console/ui/js/functions.js` — polling loop:
1. Закрыть модалку
2. Показать статус «Клонирование завершено, обновляем список...»
3. Через 700мс — `reloadAll()`
4. Проверить `S.fns` на наличие нового имени
5. Если не найден — повторить через 1 сек (до 5 попыток)
6. Показать финальное сообщение
- Образ: `naeel/fission-console:v1.3.85`
---
#### Unit-тесты (`console/internal/api/function_clone_test.go`)
21 тест, все PASS. Файл создан с нуля.
| Группа | Тесты |
|---|---|
| Валидация имени | пустое → 400; uppercase → 400; начинается с `-` → 400; заканчивается `-` → 400; 58 символов → 400; 57 символов → OK (404); имя с дефисами → OK |
| Источник не найден | 404 с именем функции в теле |
| Успешный клон (type:literal) | Package создан; Function создана; аннотации `cloned-from`, `language`, `source-type` скопированы; entrypoint и timeout скопированы; HTTPTrigger с методами и маршрутом |
| Маршрут | явный `/custom/path`; автодобавление `/` при отсутствии |
| Конфликт имени | дублирующее → 409 |
| Откат | ошибка создания Function → Package удалён |
| downloadPackageBytes | type:literal; type:url; пустой Package → ошибка |
| downloadFromStoragesvc | HTTP 404 → ошибка с кодом |
| getTriggerMethods | триггер найден; триггер не найден |
| Невалидный JSON | → 400 |
| Ссылка Package→Function | `spec.package.packageref.name` совпадает с реальным Package |
```
ok fission-console/internal/api 0.040s
```
---
+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`) запускаются
+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 (local storage) backend
# Применять: helm upgrade fission fission-charts/fission-all --version 1.22.1 \
# -n fission -f helm/fission-values.yaml
#
# Секретов нет — local storage не требует credentials.
# При деплое в новый кластер — только этот файл.
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 # сетевой block-device VMware Cloud Director (надёжнее local-path)
size: 2Gi
accessMode: ReadWriteOnce
repository: ""
routerServiceType: ClusterIP
serviceType: ClusterIP
analytics: false
+37
View File
@@ -0,0 +1,37 @@
# Демо-пайплайн: IoT → SQS → Fission
## Архитектура
1. **Крон-функция (Fission)**
- Парсит данные о погоде с сайта для нескольких городов
- Эмулирует IoT-датчики: отправляет данные в IoT-сервис (MQTT/HTTP)
2. **IoT-сервис**
- Принимает данные от "датчиков"
- Публикует сообщения в очередь (SQS или аналог)
3. **Очередь (SQS)**
- Хранит сообщения от IoT
- Триггерит Fission-функцию при появлении новых данных (MessageQueue Trigger)
4. **Fission-функция**
- Получает данные из очереди
- Записывает их в таблицу (PostgreSQL, ClickHouse и т.д.)
## Требования к Fission
- Необходим 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
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
+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"
"fmt"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
k8sresource "k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
@@ -38,6 +41,28 @@ var httpTriggerGVR = schema.GroupVersionResource{
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.
type Client struct {
DynClient dynamic.Interface
@@ -253,3 +278,242 @@ func (c *Client) DeleteHTTPTrigger(ctx context.Context, namespace, name string)
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.NewHTTPTriggerResource,
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())
}
}
-4
View File
@@ -1,4 +0,0 @@
ntazetdinov@nubes.ru
stxVxLvM9eqssgZ
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoLWFwaSIsInN1YiI6IjAxOWQ3NmM1LTQyNGMtN2MyYy04OWI3LWUzZjA5OTQ5YTk4ZiIsImV4cCI6MTc5MzA3ODQzOSwiaWF0IjoxNzc3NTI2NDM5LCJqdGkiOiI4MTU1ODNkNy0xZTRkLTRiMmMtOGM5ZS0wZDZmMGU2NmJjNTciLCJhdXRoX3RpbWUiOjAsInR5cCI6IiIsImF6cCI6IiIsInNlc3Npb25fc3RhdGUiOiIiLCJhY3IiOiIiLCJhbGxvd2VkLW9yaWdpbnMiOm51bGwsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6bnVsbH0sInJlc291cmNlX2FjY2VzcyI6eyJhY2NvdW50Ijp7InJvbGVzIjpudWxsfX0sInNjb3BlIjoiIiwic2lkIjoiIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJuYW1lIjoiIiwiQ2xpZW50SUQiOiJXWjAxMTEyIiwiY29tcGFueV9pZCI6IjhlYzcwYWMwLTU0NmQtNDJhNy04Y2ZmLTMzOWM4ZmI1MWEyMyIsImNvbXBhbnlfbmFtZSI6ItCe0J7QniDCq9Cd0KPQkdCV0KHCuyIsImlkcF91c3JfdWlkIjoiMDE5ZDc2YzUtNDI0Yy03YzJjLTg5YjctZTNmMDk5NDlhOThmIiwibG9naW4iOiJudGF6ZXRkaW5vdkBudWJlcy5ydSIsImZpcnN0bmFtZSI6ItCd0LDQuNC70YwiLCJtaWRkbGVuYW1lIjoi0KTQsNGA0LjRgtC-0LLQuNGHIiwibGFzdG5hbWUiOiLQotCw0LfQtdGC0LTQuNC90L7QsiIsImdyb3VwcyI6bnVsbCwicHJlZmVycmVkX3VzZXJuYW1lIjoiIiwiZ2l2ZW5fbmFtZSI6IiIsImZhbWlseV9uYW1lIjoiIiwiZW1haWwiOiJudGF6ZXRkaW5vdkBudWJlcy5ydSJ9.SaZsKAha45-fvKuJkLHUe_09AFsbH5QpzBVdPnjiEDQGIhl1A3ThgnM-oEh_H6CBXXfnOs2QDczzgCD8K8_tYzZKU1Wgk5lj04YW_fbTI89kHTO5wLtrAht9tFEjzBQZ-kmnG8mUK5tgqyNEjsngdQcfqVWRvneF366TiiRXk_76poUPpXWQmqdgCCKb3wq1rRgWcKDZcUC3JvDpOaqYL40zITZlM855drlJhMut4Gkg-EEk7ZGykl6YCsTRWVjFKGQ-7C-BDU2dkmIWhxzBAkvzzM2kBwvtlczCEDiUin7bXE-F_io89oJUYDzY4yPeHzwREwxCp6x9eD1NAIKKuA
+14
View File
@@ -0,0 +1,14 @@
<svg width="310mm" height="110mm" viewBox="0 0 310 110" xmlns="http://www.w3.org/2000/svg">
<!-- ОДНО наклонное кольцо: верхняя кромка синусоида y=40+40*sin(πx/L) -->
<polyline fill="none" stroke="#1976d2" stroke-width="2" points="
0,40 5,39.47 10,38.95 15,38.45 20,37.95 25,37.48 30,37.02 35,36.59 40,36.18 45,35.80 50,35.45 55,35.13 60,34.84 65,34.59 70,34.37 75,34.19 80,34.05 85,33.95 90,33.88 95,33.86 100,33.87 105,33.93 110,34.02 115,34.15 120,34.32 125,34.53 130,34.78 135,35.06 140,35.38 145,35.73 150,36.12 155,36.54 160,37.00 165,37.48 170,38.00 175,38.54 180,39.11 185,39.70 190,40.31 195,40.94 200,41.59 205,42.24 210,42.90 215,43.56 220,44.22 225,44.86 230,45.48 235,46.08 240,46.64 245,47.16 250,47.63 255,48.05 260,48.41 265,48.70 270,48.92 275,49.06 280,49.12 285,49.09 290,48.97 295,48.76 300,48.45 305,48.04
<!-- Нижняя кромка синусоида y=60+40*sin(πx/L) -->
<polyline fill="none" stroke="#d32f2f" stroke-width="2" points="
0,60 5,59.47 10,58.95 15,58.45 20,57.95 25,57.48 30,57.02 35,56.59 40,56.18 45,55.80 50,55.45 55,55.13 60,54.84 65,54.59 70,54.37 75,54.19 80,54.05 85,53.95 90,53.88 95,53.86 100,53.87 105,53.93 110,54.02 115,54.15 120,54.32 125,54.53 130,54.78 135,55.06 140,55.38 145,55.73 150,56.12 155,56.54 160,57.00 165,57.48 170,58.00 175,58.54 180,59.11 185,59.70 190,60.31 195,60.94 200,61.59 205,62.24 210,62.90 215,63.56 220,64.22 225,64.86 230,65.48 235,66.08 240,66.64 245,67.16 250,67.63 255,68.05 260,68.41 265,68.70 270,68.92 275,69.06 280,69.12 285,69.09 290,68.97 295,68.76 300,68.45 305,68.04
" />
<!-- Заливка между кромками -->
<polygon fill="#e0f7fa" fill-opacity="0.6" stroke="none" points="
0,40 5,39.47 10,38.95 15,38.45 20,37.95 25,37.48 30,37.02 35,36.59 40,36.18 45,35.80 50,35.45 55,35.13 60,34.84 65,34.59 70,34.37 75,34.19 80,34.05 85,33.95 90,33.88 95,33.86 100,33.87 105,33.93 110,34.02 115,34.15 120,34.32 125,34.53 130,34.78 135,35.06 140,35.38 145,35.73 150,36.12 155,36.54 160,37.00 165,37.48 170,38.00 175,38.54 180,39.11 185,39.70 190,40.31 195,40.94 200,41.59 205,42.24 210,42.90 215,43.56 220,44.22 225,44.86 230,45.48 235,46.08 240,46.64 245,47.16 250,47.63 255,48.05 260,48.41 265,48.70 270,48.92 275,49.06 280,49.12 285,49.09 290,48.97 295,48.76 300,48.45 305,48.04
305,68.04 300,68.45 295,68.76 290,68.97 285,69.09 280,69.12 275,69.06 270,68.92 265,68.70 260,68.41 255,68.05 250,67.63 245,67.16 240,66.64 235,66.08 230,65.48 225,64.86 220,64.22 215,63.56 210,62.90 205,62.24 200,61.59 195,60.94 190,60.31 185,59.70 180,59.11 175,58.54 170,58.00 165,57.48 160,57.00 155,56.54 150,56.12 145,55.73 140,55.38 135,55.06 130,54.78 125,54.53 120,54.32 115,54.15 110,54.02 105,53.93 100,53.87 95,53.86 90,53.88 85,53.95 80,54.05 75,54.19 70,54.37 65,54.59 60,54.84 55,55.13 50,55.45 45,55.80 40,56.18 35,56.59 30,57.02 25,57.48 20,57.95 15,58.45 10,58.95 5,59.47 0,60
" />
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="301.6mm" height="120mm" viewBox="0 0 301.6 120" xmlns="http://www.w3.org/2000/svg">
<polyline fill="none" stroke="#1976d2" stroke-width="1.5" points="0.0,50.00 3.0,52.51 6.0,55.01 9.0,57.50 12.1,59.95 15.1,62.36 18.1,64.72 21.1,67.03 24.1,69.27 27.1,71.43 30.2,73.51 33.2,75.50 36.2,77.38 39.2,79.16 42.2,80.82 45.2,82.36 48.3,83.77 51.3,85.05 54.3,86.19 57.3,87.19 60.3,88.04 63.3,88.74 66.4,89.29 69.4,89.68 72.4,89.92 75.4,90.00 78.4,89.92 81.4,89.68 84.4,89.29 87.5,88.74 90.5,88.04 93.5,87.19 96.5,86.19 99.5,85.05 102.5,83.77 105.6,82.36 108.6,80.82 111.6,79.16 114.6,77.38 117.6,75.50 120.6,73.51 123.7,71.43 126.7,69.27 129.7,67.03 132.7,64.72 135.7,62.36 138.7,59.95 141.7,57.50 144.8,55.01 147.8,52.51 150.8,50.00 153.8,47.49 156.8,44.99 159.8,42.50 162.9,40.05 165.9,37.64 168.9,35.28 171.9,32.97 174.9,30.73 177.9,28.57 181.0,26.49 184.0,24.50 187.0,22.62 190.0,20.84 193.0,19.18 196.0,17.64 199.1,16.23 202.1,14.95 205.1,13.81 208.1,12.81 211.1,11.96 214.1,11.26 217.1,10.71 220.2,10.32 223.2,10.08 226.2,10.00 229.2,10.08 232.2,10.32 235.2,10.71 238.3,11.26 241.3,11.96 244.3,12.81 247.3,13.81 250.3,14.95 253.3,16.23 256.4,17.64 259.4,19.18 262.4,20.84 265.4,22.62 268.4,24.50 271.4,26.49 274.4,28.57 277.5,30.73 280.5,32.97 283.5,35.28 286.5,37.64 289.5,40.05 292.5,42.50 295.6,44.99 298.6,47.49 301.6,50.00" />
<polyline fill="none" stroke="#d32f2f" stroke-width="1.5" points="0.0,70.00 3.0,72.51 6.0,75.01 9.0,77.50 12.1,79.95 15.1,82.36 18.1,84.72 21.1,87.03 24.1,89.27 27.1,91.43 30.2,93.51 33.2,95.50 36.2,97.38 39.2,99.16 42.2,100.82 45.2,102.36 48.3,103.77 51.3,105.05 54.3,106.19 57.3,107.19 60.3,108.04 63.3,108.74 66.4,109.29 69.4,109.68 72.4,109.92 75.4,110.00 78.4,109.92 81.4,109.68 84.4,109.29 87.5,108.74 90.5,108.04 93.5,107.19 96.5,106.19 99.5,105.05 102.5,103.77 105.6,102.36 108.6,100.82 111.6,99.16 114.6,97.38 117.6,95.50 120.6,93.51 123.7,91.43 126.7,89.27 129.7,87.03 132.7,84.72 135.7,82.36 138.7,79.95 141.7,77.50 144.8,75.01 147.8,72.51 150.8,70.00 153.8,67.49 156.8,64.99 159.8,62.50 162.9,60.05 165.9,57.64 168.9,55.28 171.9,52.97 174.9,50.73 177.9,48.57 181.0,46.49 184.0,44.50 187.0,42.62 190.0,40.84 193.0,39.18 196.0,37.64 199.1,36.23 202.1,34.95 205.1,33.81 208.1,32.81 211.1,31.96 214.1,31.26 217.1,30.71 220.2,30.32 223.2,30.08 226.2,30.00 229.2,30.08 232.2,30.32 235.2,30.71 238.3,31.26 241.3,31.96 244.3,32.81 247.3,33.81 250.3,34.95 253.3,36.23 256.4,37.64 259.4,39.18 262.4,40.84 265.4,42.62 268.4,44.50 271.4,46.49 274.4,48.57 277.5,50.73 280.5,52.97 283.5,55.28 286.5,57.64 289.5,60.05 292.5,62.50 295.6,64.99 298.6,67.49 301.6,70.00" />
<polygon fill="#e0f7fa" fill-opacity="0.5" stroke="none" points="0.0,50.00 3.0,52.51 6.0,55.01 9.0,57.50 12.1,59.95 15.1,62.36 18.1,64.72 21.1,67.03 24.1,69.27 27.1,71.43 30.2,73.51 33.2,75.50 36.2,77.38 39.2,79.16 42.2,80.82 45.2,82.36 48.3,83.77 51.3,85.05 54.3,86.19 57.3,87.19 60.3,88.04 63.3,88.74 66.4,89.29 69.4,89.68 72.4,89.92 75.4,90.00 78.4,89.92 81.4,89.68 84.4,89.29 87.5,88.74 90.5,88.04 93.5,87.19 96.5,86.19 99.5,85.05 102.5,83.77 105.6,82.36 108.6,80.82 111.6,79.16 114.6,77.38 117.6,75.50 120.6,73.51 123.7,71.43 126.7,69.27 129.7,67.03 132.7,64.72 135.7,62.36 138.7,59.95 141.7,57.50 144.8,55.01 147.8,52.51 150.8,50.00 153.8,47.49 156.8,44.99 159.8,42.50 162.9,40.05 165.9,37.64 168.9,35.28 171.9,32.97 174.9,30.73 177.9,28.57 181.0,26.49 184.0,24.50 187.0,22.62 190.0,20.84 193.0,19.18 196.0,17.64 199.1,16.23 202.1,14.95 205.1,13.81 208.1,12.81 211.1,11.96 214.1,11.26 217.1,10.71 220.2,10.32 223.2,10.08 226.2,10.00 229.2,10.08 232.2,10.32 235.2,10.71 238.3,11.26 241.3,11.96 244.3,12.81 247.3,13.81 250.3,14.95 253.3,16.23 256.4,17.64 259.4,19.18 262.4,20.84 265.4,22.62 268.4,24.50 271.4,26.49 274.4,28.57 277.5,30.73 280.5,32.97 283.5,35.28 286.5,37.64 289.5,40.05 292.5,42.50 295.6,44.99 298.6,47.49 301.6,50.00 301.6,70.00 298.6,67.49 295.6,64.99 292.5,62.50 289.5,60.05 286.5,57.64 283.5,55.28 280.5,52.97 277.5,50.73 274.4,48.57 271.4,46.49 268.4,44.50 265.4,42.62 262.4,40.84 259.4,39.18 256.4,37.64 253.3,36.23 250.3,34.95 247.3,33.81 244.3,32.81 241.3,31.96 238.3,31.26 235.2,30.71 232.2,30.32 229.2,30.08 226.2,30.00 223.2,30.08 220.2,30.32 217.1,30.71 214.1,31.26 211.1,31.96 208.1,32.81 205.1,33.81 202.1,34.95 199.1,36.23 196.0,37.64 193.0,39.18 190.0,40.84 187.0,42.62 184.0,44.50 181.0,46.49 177.9,48.57 174.9,50.73 171.9,52.97 168.9,55.28 165.9,57.64 162.9,60.05 159.8,62.50 156.8,64.99 153.8,67.49 150.8,70.00 147.8,72.51 144.8,75.01 141.7,77.50 138.7,79.95 135.7,82.36 132.7,84.72 129.7,87.03 126.7,89.27 123.7,91.43 120.6,93.51 117.6,95.50 114.6,97.38 111.6,99.16 108.6,100.82 105.6,102.36 102.5,103.77 99.5,105.05 96.5,106.19 93.5,107.19 90.5,108.04 87.5,108.74 84.4,109.29 81.4,109.68 78.4,109.92 75.4,110.00 72.4,109.92 69.4,109.68 66.4,109.29 63.3,108.74 60.3,108.04 57.3,107.19 54.3,106.19 51.3,105.05 48.3,103.77 45.2,102.36 42.2,100.82 39.2,99.16 36.2,97.38 33.2,95.50 30.2,93.51 27.1,91.43 24.1,89.27 21.1,87.03 18.1,84.72 15.1,82.36 12.1,79.95 9.0,77.50 6.0,75.01 3.0,72.51 0.0,70.00" />
</svg>

After

Width:  |  Height:  |  Size: 5.0 KiB

+3
View File
@@ -0,0 +1,3 @@
Диаметр цилиндра: 96 мм
Вертикальная проекция большой оси эллипса: 80 мм
Расстояние между срезами по вертикали: 20 мм
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="150.8mm" height="120mm" viewBox="0 0 150.8 120" xmlns="http://www.w3.org/2000/svg">
<!-- Часть 1 (левая) -->
<polygon fill="#e0f7fa" fill-opacity="0.5" stroke="none" points="0.00,50.00 1.51,51.26 3.02,52.51 4.52,53.76 6.03,55.01 7.54,56.26 9.05,57.50 10.56,58.73 12.06,59.95 13.57,61.16 15.08,62.36 16.59,63.55 18.10,64.72 19.60,65.89 21.11,67.03 22.62,68.16 24.13,69.27 25.64,70.36 27.14,71.43 28.65,72.48 30.16,73.51 31.67,74.52 33.18,75.50 34.68,76.45 36.19,77.38 37.70,78.28 39.21,79.16 40.72,80.00 42.22,80.82 43.73,81.61 45.24,82.36 46.75,83.08 48.25,83.77 49.76,84.43 51.27,85.05 52.78,85.64 54.29,86.19 55.79,86.71 57.30,87.19 58.81,87.64 60.32,88.04 61.83,88.41 63.33,88.74 64.84,89.04 66.35,89.29 67.86,89.51 69.37,89.68 70.87,89.82 72.38,89.92 73.89,89.98 75.40,90.00 76.91,89.98 78.41,89.92 79.92,89.82 81.43,89.68 82.94,89.51 84.45,89.29 85.95,89.04 87.46,88.74 88.97,88.41 90.48,88.04 91.99,87.64 93.49,87.19 95.00,86.71 96.51,86.19 98.02,85.64 99.53,85.05 101.03,84.43 102.54,83.77 104.05,83.08 105.56,82.36 107.07,81.61 108.57,80.82 110.08,80.00 111.59,79.16 113.10,78.28 114.61,77.38 116.11,76.45 117.62,75.50 119.13,74.52 120.64,73.51 122.15,72.48 123.65,71.43 125.16,70.36 126.67,69.27 128.18,68.16 129.68,67.03 131.19,65.89 132.70,64.72 134.21,63.55 135.72,62.36 137.22,61.16 138.73,59.95 140.24,58.73 141.75,57.50 143.26,56.26 144.76,55.01 146.27,53.76 147.78,52.51 149.29,51.26 150.80,50.00 150.80,70.00 149.29,71.26 147.78,72.51 146.27,73.76 144.76,75.01 143.26,76.26 141.75,77.50 140.24,78.73 138.73,79.95 137.22,81.16 135.72,82.36 134.21,83.55 132.70,84.72 131.19,85.89 129.68,87.03 128.18,88.16 126.67,89.27 125.16,90.36 123.65,91.43 122.15,92.48 120.64,93.51 119.13,94.52 117.62,95.50 116.11,96.45 114.61,97.38 113.10,98.28 111.59,99.16 110.08,100.00 108.57,100.82 107.07,101.61 105.56,102.36 104.05,103.08 102.54,103.77 101.03,104.43 99.53,105.05 98.02,105.64 96.51,106.19 95.00,106.71 93.49,107.19 91.99,107.64 90.48,108.04 88.97,108.41 87.46,108.74 85.95,109.04 84.45,109.29 82.94,109.51 81.43,109.68 79.92,109.82 78.41,109.92 76.91,109.98 75.40,110.00 73.89,109.98 72.38,109.92 70.87,109.82 69.37,109.68 67.86,109.51 66.35,109.29 64.84,109.04 63.33,108.74 61.83,108.41 60.32,108.04 58.81,107.64 57.30,107.19 55.79,106.71 54.29,106.19 52.78,105.64 51.27,105.05 49.76,104.43 48.25,103.77 46.75,103.08 45.24,102.36 43.73,101.61 42.22,100.82 40.72,100.00 39.21,99.16 37.70,98.28 36.19,97.38 34.68,96.45 33.18,95.50 31.67,94.52 30.16,93.51 28.65,92.48 27.14,91.43 25.64,90.36 24.13,89.27 22.62,88.16 21.11,87.03 19.60,85.89 18.10,84.72 16.59,83.55 15.08,82.36 13.57,81.16 12.06,79.95 10.56,78.73 9.05,77.50 7.54,76.26 6.03,75.01 4.52,73.76 3.02,72.51 1.51,71.26 0.00,70.00" />
<polyline fill="none" stroke="#1976d2" stroke-width="1.5" points="0.00,50.00 1.51,51.26 3.02,52.51 4.52,53.76 6.03,55.01 7.54,56.26 9.05,57.50 10.56,58.73 12.06,59.95 13.57,61.16 15.08,62.36 16.59,63.55 18.10,64.72 19.60,65.89 21.11,67.03 22.62,68.16 24.13,69.27 25.64,70.36 27.14,71.43 28.65,72.48 30.16,73.51 31.67,74.52 33.18,75.50 34.68,76.45 36.19,77.38 37.70,78.28 39.21,79.16 40.72,80.00 42.22,80.82 43.73,81.61 45.24,82.36 46.75,83.08 48.25,83.77 49.76,84.43 51.27,85.05 52.78,85.64 54.29,86.19 55.79,86.71 57.30,87.19 58.81,87.64 60.32,88.04 61.83,88.41 63.33,88.74 64.84,89.04 66.35,89.29 67.86,89.51 69.37,89.68 70.87,89.82 72.38,89.92 73.89,89.98 75.40,90.00 76.91,89.98 78.41,89.92 79.92,89.82 81.43,89.68 82.94,89.51 84.45,89.29 85.95,89.04 87.46,88.74 88.97,88.41 90.48,88.04 91.99,87.64 93.49,87.19 95.00,86.71 96.51,86.19 98.02,85.64 99.53,85.05 101.03,84.43 102.54,83.77 104.05,83.08 105.56,82.36 107.07,81.61 108.57,80.82 110.08,80.00 111.59,79.16 113.10,78.28 114.61,77.38 116.11,76.45 117.62,75.50 119.13,74.52 120.64,73.51 122.15,72.48 123.65,71.43 125.16,70.36 126.67,69.27 128.18,68.16 129.68,67.03 131.19,65.89 132.70,64.72 134.21,63.55 135.72,62.36 137.22,61.16 138.73,59.95 140.24,58.73 141.75,57.50 143.26,56.26 144.76,55.01 146.27,53.76 147.78,52.51 149.29,51.26 150.80,50.00" />
<polyline fill="none" stroke="#d32f2f" stroke-width="1.5" points="0.00,70.00 1.51,71.26 3.02,72.51 4.52,73.76 6.03,75.01 7.54,76.26 9.05,77.50 10.56,78.73 12.06,79.95 13.57,81.16 15.08,82.36 16.59,83.55 18.10,84.72 19.60,85.89 21.11,87.03 22.62,88.16 24.13,89.27 25.64,90.36 27.14,91.43 28.65,92.48 30.16,93.51 31.67,94.52 33.18,95.50 34.68,96.45 36.19,97.38 37.70,98.28 39.21,99.16 40.72,100.00 42.22,100.82 43.73,101.61 45.24,102.36 46.75,103.08 48.25,103.77 49.76,104.43 51.27,105.05 52.78,105.64 54.29,106.19 55.79,106.71 57.30,107.19 58.81,107.64 60.32,108.04 61.83,108.41 63.33,108.74 64.84,109.04 66.35,109.29 67.86,109.51 69.37,109.68 70.87,109.82 72.38,109.92 73.89,109.98 75.40,110.00 76.91,109.98 78.41,109.92 79.92,109.82 81.43,109.68 82.94,109.51 84.45,109.29 85.95,109.04 87.46,108.74 88.97,108.41 90.48,108.04 91.99,107.64 93.49,107.19 95.00,106.71 96.51,106.19 98.02,105.64 99.53,105.05 101.03,104.43 102.54,103.77 104.05,103.08 105.56,102.36 107.07,101.61 108.57,100.82 110.08,100.00 111.59,99.16 113.10,98.28 114.61,97.38 116.11,96.45 117.62,95.50 119.13,94.52 120.64,93.51 122.15,92.48 123.65,91.43 125.16,90.36 126.67,89.27 128.18,88.16 129.68,87.03 131.19,85.89 132.70,84.72 134.21,83.55 135.72,82.36 137.22,81.16 138.73,79.95 140.24,78.73 141.75,77.50 143.26,76.26 144.76,75.01 146.27,73.76 147.78,72.51 149.29,71.26 150.80,70.00" />
<!-- линия склейки -->
<line x1="150.8" y1="0" x2="150.8" y2="120" stroke="#888" stroke-width="0.5" stroke-dasharray="3,3"/>
</svg>

After

Width:  |  Height:  |  Size: 5.5 KiB

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="150.8mm" height="120mm" viewBox="0 0 150.8 120" xmlns="http://www.w3.org/2000/svg">
<!-- Часть 2 (правая) -->
<polygon fill="#e0f7fa" fill-opacity="0.5" stroke="none" points="0.00,50.00 1.51,48.74 3.02,47.49 4.52,46.24 6.03,44.99 7.54,43.74 9.05,42.50 10.56,41.27 12.06,40.05 13.57,38.84 15.08,37.64 16.59,36.45 18.10,35.28 19.60,34.11 21.11,32.97 22.62,31.84 24.13,30.73 25.64,29.64 27.14,28.57 28.65,27.52 30.16,26.49 31.67,25.48 33.18,24.50 34.68,23.55 36.19,22.62 37.70,21.72 39.21,20.84 40.72,20.00 42.22,19.18 43.73,18.39 45.24,17.64 46.75,16.92 48.25,16.23 49.76,15.57 51.27,14.95 52.78,14.36 54.29,13.81 55.79,13.29 57.30,12.81 58.81,12.36 60.32,11.96 61.83,11.59 63.33,11.26 64.84,10.96 66.35,10.71 67.86,10.49 69.37,10.32 70.87,10.18 72.38,10.08 73.89,10.02 75.40,10.00 76.91,10.02 78.41,10.08 79.92,10.18 81.43,10.32 82.94,10.49 84.45,10.71 85.95,10.96 87.46,11.26 88.97,11.59 90.48,11.96 91.99,12.36 93.49,12.81 95.00,13.29 96.51,13.81 98.02,14.36 99.53,14.95 101.03,15.57 102.54,16.23 104.05,16.92 105.56,17.64 107.07,18.39 108.57,19.18 110.08,20.00 111.59,20.84 113.10,21.72 114.61,22.62 116.11,23.55 117.62,24.50 119.13,25.48 120.64,26.49 122.15,27.52 123.65,28.57 125.16,29.64 126.67,30.73 128.18,31.84 129.68,32.97 131.19,34.11 132.70,35.28 134.21,36.45 135.72,37.64 137.22,38.84 138.73,40.05 140.24,41.27 141.75,42.50 143.26,43.74 144.76,44.99 146.27,46.24 147.78,47.49 149.29,48.74 150.80,50.00 150.80,70.00 149.29,68.74 147.78,67.49 146.27,66.24 144.76,64.99 143.26,63.74 141.75,62.50 140.24,61.27 138.73,60.05 137.22,58.84 135.72,57.64 134.21,56.45 132.70,55.28 131.19,54.11 129.68,52.97 128.18,51.84 126.67,50.73 125.16,49.64 123.65,48.57 122.15,47.52 120.64,46.49 119.13,45.48 117.62,44.50 116.11,43.55 114.61,42.62 113.10,41.72 111.59,40.84 110.08,40.00 108.57,39.18 107.07,38.39 105.56,37.64 104.05,36.92 102.54,36.23 101.03,35.57 99.53,34.95 98.02,34.36 96.51,33.81 95.00,33.29 93.49,32.81 91.99,32.36 90.48,31.96 88.97,31.59 87.46,31.26 85.95,30.96 84.45,30.71 82.94,30.49 81.43,30.32 79.92,30.18 78.41,30.08 76.91,30.02 75.40,30.00 73.89,30.02 72.38,30.08 70.87,30.18 69.37,30.32 67.86,30.49 66.35,30.71 64.84,30.96 63.33,31.26 61.83,31.59 60.32,31.96 58.81,32.36 57.30,32.81 55.79,33.29 54.29,33.81 52.78,34.36 51.27,34.95 49.76,35.57 48.25,36.23 46.75,36.92 45.24,37.64 43.73,38.39 42.22,39.18 40.72,40.00 39.21,40.84 37.70,41.72 36.19,42.62 34.68,43.55 33.18,44.50 31.67,45.48 30.16,46.49 28.65,47.52 27.14,48.57 25.64,49.64 24.13,50.73 22.62,51.84 21.11,52.97 19.60,54.11 18.10,55.28 16.59,56.45 15.08,57.64 13.57,58.84 12.06,60.05 10.56,61.27 9.05,62.50 7.54,63.74 6.03,64.99 4.52,66.24 3.02,67.49 1.51,68.74 0.00,70.00" />
<polyline fill="none" stroke="#1976d2" stroke-width="1.5" points="0.00,50.00 1.51,48.74 3.02,47.49 4.52,46.24 6.03,44.99 7.54,43.74 9.05,42.50 10.56,41.27 12.06,40.05 13.57,38.84 15.08,37.64 16.59,36.45 18.10,35.28 19.60,34.11 21.11,32.97 22.62,31.84 24.13,30.73 25.64,29.64 27.14,28.57 28.65,27.52 30.16,26.49 31.67,25.48 33.18,24.50 34.68,23.55 36.19,22.62 37.70,21.72 39.21,20.84 40.72,20.00 42.22,19.18 43.73,18.39 45.24,17.64 46.75,16.92 48.25,16.23 49.76,15.57 51.27,14.95 52.78,14.36 54.29,13.81 55.79,13.29 57.30,12.81 58.81,12.36 60.32,11.96 61.83,11.59 63.33,11.26 64.84,10.96 66.35,10.71 67.86,10.49 69.37,10.32 70.87,10.18 72.38,10.08 73.89,10.02 75.40,10.00 76.91,10.02 78.41,10.08 79.92,10.18 81.43,10.32 82.94,10.49 84.45,10.71 85.95,10.96 87.46,11.26 88.97,11.59 90.48,11.96 91.99,12.36 93.49,12.81 95.00,13.29 96.51,13.81 98.02,14.36 99.53,14.95 101.03,15.57 102.54,16.23 104.05,16.92 105.56,17.64 107.07,18.39 108.57,19.18 110.08,20.00 111.59,20.84 113.10,21.72 114.61,22.62 116.11,23.55 117.62,24.50 119.13,25.48 120.64,26.49 122.15,27.52 123.65,28.57 125.16,29.64 126.67,30.73 128.18,31.84 129.68,32.97 131.19,34.11 132.70,35.28 134.21,36.45 135.72,37.64 137.22,38.84 138.73,40.05 140.24,41.27 141.75,42.50 143.26,43.74 144.76,44.99 146.27,46.24 147.78,47.49 149.29,48.74 150.80,50.00" />
<polyline fill="none" stroke="#d32f2f" stroke-width="1.5" points="0.00,70.00 1.51,68.74 3.02,67.49 4.52,66.24 6.03,64.99 7.54,63.74 9.05,62.50 10.56,61.27 12.06,60.05 13.57,58.84 15.08,57.64 16.59,56.45 18.10,55.28 19.60,54.11 21.11,52.97 22.62,51.84 24.13,50.73 25.64,49.64 27.14,48.57 28.65,47.52 30.16,46.49 31.67,45.48 33.18,44.50 34.68,43.55 36.19,42.62 37.70,41.72 39.21,40.84 40.72,40.00 42.22,39.18 43.73,38.39 45.24,37.64 46.75,36.92 48.25,36.23 49.76,35.57 51.27,34.95 52.78,34.36 54.29,33.81 55.79,33.29 57.30,32.81 58.81,32.36 60.32,31.96 61.83,31.59 63.33,31.26 64.84,30.96 66.35,30.71 67.86,30.49 69.37,30.32 70.87,30.18 72.38,30.08 73.89,30.02 75.40,30.00 76.91,30.02 78.41,30.08 79.92,30.18 81.43,30.32 82.94,30.49 84.45,30.71 85.95,30.96 87.46,31.26 88.97,31.59 90.48,31.96 91.99,32.36 93.49,32.81 95.00,33.29 96.51,33.81 98.02,34.36 99.53,34.95 101.03,35.57 102.54,36.23 104.05,36.92 105.56,37.64 107.07,38.39 108.57,39.18 110.08,40.00 111.59,40.84 113.10,41.72 114.61,42.62 116.11,43.55 117.62,44.50 119.13,45.48 120.64,46.49 122.15,47.52 123.65,48.57 125.16,49.64 126.67,50.73 128.18,51.84 129.68,52.97 131.19,54.11 132.70,55.28 134.21,56.45 135.72,57.64 137.22,58.84 138.73,60.05 140.24,61.27 141.75,62.50 143.26,63.74 144.76,64.99 146.27,66.24 147.78,67.49 149.29,68.74 150.80,70.00" />
<!-- линия склейки -->
<line x1="150.8" y1="0" x2="150.8" y2="120" stroke="#888" stroke-width="0.5" stroke-dasharray="3,3"/>
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB

+13
View File
@@ -0,0 +1,13 @@
<svg width="302mm" height="100mm" viewBox="0 0 302 100" xmlns="http://www.w3.org/2000/svg">
<!-- UPPER edge (blue) — синусоида от y=0 до y=80 mm -->
<polyline fill="none" stroke="#1976d2" stroke-width="2" points="
0.00,40.00 1.51,35.97 3.02,32.44 4.53,29.44 6.04,27.04 7.55,25.23 9.06,24.07 10.57,23.62 12.08,23.92 13.59,24.98 15.10,26.81 16.61,29.41 18.12,32.75 19.63,36.82 21.14,41.58 22.65,46.98 24.16,52.96 25.67,59.45 27.18,66.36 28.69,73.62 30.20,81.16 31.71,88.92 33.22,96.82 34.73,104.78 36.24,112.73 37.75,120.60 39.26,128.30 40.77,135.78 42.28,142.97 43.79,149.82 45.30,156.25 46.81,162.21 48.32,167.64 49.83,172.49 51.34,176.71 52.85,180.24 54.36,183.03 55.87,185.05 57.38,186.28 58.89,186.70 60.40,186.28 61.91,185.05 63.42,183.03 64.93,180.24 66.44,176.71 67.95,172.49 69.46,167.64 70.97,162.21 72.48,156.25 73.99,149.82 75.50,142.97 77.01,135.78 78.52,128.30 80.03,120.60 81.54,112.73 83.05,104.78 84.56,96.82 86.07,88.92 87.58,81.16 89.09,73.62 90.60,66.36 92.11,59.45 93.62,52.96 95.13,46.98 96.64,41.58 98.15,36.82 99.66,32.75 101.17,29.41 102.68,26.81 104.19,24.98 105.70,23.92 107.21,23.62 108.72,24.07 110.23,25.23 111.74,27.04 113.25,29.44 114.76,32.44 116.27,35.97 117.78,40.00 119.29,44.50 120.80,49.42 122.31,54.70 123.82,60.29 125.33,66.14 126.84,72.19 128.35,78.38 129.86,84.65 131.37,90.94 132.88,97.18 134.39,103.30 135.90,109.23 137.41,114.90 138.92,120.24 140.43,125.18 141.94,129.66 143.45,133.62 144.96,137.00 146.47,139.73 147.98,141.77 149.49,143.07 151.00,143.61 152.51,143.35 154.02,142.27 155.53,140.35 157.04,137.57 158.55,133.92 160.06,129.40 161.57,124.00 163.08,117.70 164.59,110.50 166.10,102.40 167.61,93.41 169.12,83.55 170.63,72.88 172.14,61.49 173.65,49.49 175.16,37.02 176.67,24.23 178.18,11.26 179.69,-1.76 181.20,-14.80 182.71,-27.74 184.22,-40.50 185.73,-52.94 187.24,-64.99 188.75,-76.54 190.26,-87.53 191.77,-97.79 193.28,-107.27 194.79,-115.88 196.30,-123.55 197.81,-130.22 199.32,-135.81 200.83,-140.26 202.34,-143.52 203.85,-145.54 205.36,-146.30 206.87,-145.77 208.38,-143.92 209.89,-140.75 211.40,-136.24 212.91,-130.38 214.42,-123.17 215.93,-114.63 217.44,-104.76 218.95,-93.61 220.46,-81.23 221.97,-67.66 223.48,-52.98 224.99,-37.27 226.50,-20.61 228.01,-3.10 229.52,15.57 231.03,35.43 232.54,56.24 234.05,77.85 235.56,100.10 237.07,122.82 238.58,145.86 240.09,169.08 241.60,192.31 243.11,215.41 244.62,238.14 246.13,260.38 247.64,281.98 249.15,302.80 250.66,322.71 252.17,341.57 253.68,359.24 255.19,375.61 256.70,390.57 258.21,403.98 259.72,415.76 261.23,425.78 262.74,433.99 264.25,440.36 265.76,444.87 267.27,447.50 268.78,448.24 270.29,447.09 271.80,444.04 273.31,439.11 274.82,432.30 276.33,423.64 278.18,413.17 278.36,403.12 279.35,399.86 280.86,388.78 282.37,376.32 283.88,362.74 285.39,348.28 286.90,333.16 288.41,317.54 289.92,301.57 291.43,285.38 292.94,269.11 294.45,252.90 295.96,236.88 297.47,221.18 298.98,206.00 300.49,191.44 302.00,177.65
" />
<!-- LOWER edge (red) — синусоида 20 mm ниже (y = base + 20) -->
<polyline fill="none" stroke="#d32f2f" stroke-width="2" points="
0.00,60.00 1.51,55.97 3.02,52.44 4.53,49.44 6.04,47.04 7.55,45.23 9.06,44.07 10.57,43.62 12.08,43.92 13.59,44.98 15.10,46.81 16.61,49.41 18.12,52.75 19.63,56.82 21.14,61.58 22.65,66.98 24.16,72.96 25.67,79.45 27.18,86.36 28.69,93.62 30.20,101.16 31.71,108.92 33.22,116.82 34.73,124.78 36.24,132.73 37.75,140.60 39.26,148.30 40.77,155.78 42.28,162.97 43.79,169.82 45.30,176.25 46.81,182.21 48.32,187.64 49.83,192.49 51.34,196.71 52.85,200.24 54.36,203.03 55.87,205.05 57.38,206.28 58.89,206.70 60.40,206.28 61.91,205.05 63.42,203.03 64.93,200.24 66.44,196.71 67.95,192.49 69.46,187.64 70.97,182.21 72.48,176.25 73.99,169.82 75.50,162.97 77.01,155.78 78.52,148.30 80.03,140.60 81.54,132.73 83.05,124.78 84.56,116.82 86.07,108.92 87.58,101.16 89.09,93.62 90.60,86.36 92.11,79.45 93.62,72.96 95.13,66.98 96.64,61.58 98.15,56.82 99.66,52.75 101.17,49.41 102.68,46.81 104.19,44.98 105.70,43.92 107.21,43.62 108.72,44.07 110.23,45.23 111.74,47.04 113.25,49.44 114.76,52.44 116.27,55.97 117.78,60.00 119.29,64.50 120.80,69.42 122.31,74.70 123.82,80.29 125.33,86.14 126.84,92.19 128.35,98.38 129.86,104.65 131.37,110.94 132.88,117.18 134.39,123.30 135.90,129.23 137.41,134.90 138.92,140.24 140.43,145.18 141.94,149.66 143.45,153.62 144.96,157.00 146.47,159.73 147.98,161.77 149.49,163.07 151.00,163.61 152.51,163.35 154.02,162.27 155.53,160.35 157.04,157.57 158.55,153.92 160.06,149.40 161.57,144.00 163.08,137.70 164.59,130.50 166.10,122.40 167.61,113.41 169.12,103.55 170.63,92.88 172.14,81.49 173.65,69.49 175.16,57.02 176.67,44.23 178.18,31.26 179.69,18.24 181.20,5.20 182.71,-7.74 184.22,-20.50 185.73,-32.94 187.24,-44.99 188.75,-56.54 190.26,-67.53 191.77,-77.79 193.28,-87.27 194.79,-95.88 196.30,-103.55 197.81,-110.22 199.32,-115.81 200.83,-120.26 202.34,-123.52 203.85,-125.54 205.36,-126.30 206.87,-125.77 208.38,-123.92 209.89,-120.75 211.40,-116.24 212.91,-110.38 214.42,-103.17 215.93,-94.63 217.44,-84.76 218.95,-73.61 220.46,-61.23 221.97,-47.66 223.48,-32.98 224.99,-17.27 226.50,-0.61 228.01,16.89 229.52,35.43 231.03,55.43 232.54,76.24 234.05,97.85 235.56,120.10 237.07,142.82 238.58,165.86 240.09,189.08 241.60,212.31 243.11,235.41 244.62,258.14 246.13,280.38 247.64,301.98 249.15,322.80 250.66,342.71 252.17,361.57 253.68,379.24 255.19,395.61 256.70,410.57 258.21,423.98 259.72,435.76 261.23,445.78 262.74,453.99 264.25,460.36 265.76,464.87 267.27,467.50 268.78,468.24 270.29,467.09 271.80,464.04 273.31,459.11 274.82,452.30 276.33,443.64 278.18,433.17 278.36,423.12 279.35,419.86 280.86,408.78 282.37,396.32 283.88,382.74 285.39,368.28 286.90,353.16 288.41,337.54 289.92,321.57 291.43,305.38 292.94,289.11 294.45,272.90 295.96,256.88 297.47,241.18 298.98,226.00 300.49,211.44 302.00,197.65
" />
<polygon fill="#e0f7fa" fill-opacity="0.5" stroke="none" points="
0.00,30.00 1.51,29.50 3.02,29.05 4.53,28.65 6.04,28.32 7.55,28.06 9.06,27.86 10.57,27.75 12.08,27.71 13.59,27.75 15.10,27.86 16.61,28.06 18.12,28.32 19.63,28.65 21.14,29.05 22.65,29.50 24.16,30.00 25.67,30.50 27.18,30.95 28.69,31.35 30.20,31.68 31.71,31.94 33.22,32.14 34.73,32.25 36.24,32.29 37.75,32.25 39.26,32.14 40.77,31.94 42.28,31.68 43.79,31.35 45.30,30.95 46.81,30.50 48.32,30.00 49.83,29.50 51.34,29.05 52.85,28.65 54.36,28.32 55.87,28.06 57.38,27.86 58.89,27.75 60.40,27.71 61.91,27.75 63.42,27.86 64.93,28.06 66.44,28.32 67.95,28.65 69.46,29.05 70.97,29.50 72.48,30.00 73.99,30.50 75.50,30.95 77.01,31.35 78.52,31.68 80.03,31.94 81.54,32.14 83.05,32.25 84.56,32.29 86.07,32.25 87.58,32.14 89.09,31.94 90.60,31.68 92.11,31.35 93.62,30.95 95.13,30.50 96.64,30.00 98.15,29.50 99.66,29.05 101.17,28.65 102.68,28.32 104.19,28.06 105.70,27.86 107.21,27.75 108.72,27.71 110.23,27.75 111.74,27.86 113.25,28.06 114.76,28.32 116.27,28.65 117.78,29.05 119.29,29.50 120.80,30.00 122.31,30.50 123.82,30.95 125.33,31.35 126.84,31.68 128.35,31.94 129.86,32.14 131.37,32.25 132.88,32.29 134.39,32.25 135.90,32.14 137.41,31.94 138.92,31.68 140.43,31.35 141.94,30.95 143.45,30.50 144.96,30.00 146.47,29.50 147.98,29.05 149.49,28.65 151.00,28.32 152.51,28.06 154.02,27.86 155.53,27.75 157.04,27.71 158.55,27.75 160.06,27.86 161.57,28.06 163.08,28.32 164.59,28.65 166.10,29.05 167.61,29.50 169.12,30.00 170.63,30.50 172.14,30.95 173.65,31.35 175.16,31.68 176.67,31.94 178.18,32.14 179.69,32.25 181.20,32.29 182.71,32.25 184.22,32.14 185.73,31.94 187.24,31.68 188.75,31.35 190.26,30.95 191.77,30.50 193.28,30.00 194.79,29.50 196.30,29.05 197.81,28.65 199.32,28.32 200.83,28.06 202.34,27.86 203.85,27.75 205.36,27.71 206.87,27.75 208.38,27.86 209.89,28.06 211.40,28.32 212.91,28.65 214.42,29.05 215.93,29.50 217.44,30.00 218.95,30.50 220.46,30.95 221.97,31.35 223.48,31.68 224.99,31.94 226.50,32.14 228.01,32.25 229.52,32.29 231.03,32.25 232.54,32.14 234.05,31.94 235.56,31.68 237.07,31.35 238.58,30.95 240.09,30.50 241.60,30.00 243.11,29.50 244.62,29.05 246.13,28.65 247.64,28.32 249.15,28.06 250.66,27.86 252.17,27.75 253.68,27.71 255.19,27.75 256.70,27.86 258.21,28.06 259.72,28.32 261.23,28.65 262.74,29.05 264.25,29.50 265.76,30.00 267.27,30.50 268.78,30.95 270.29,31.35 271.80,31.68 273.31,31.94 274.82,32.14 276.33,32.25 277.84,32.29 279.35,32.25 280.86,32.14 282.37,31.94 283.88,31.68 285.39,31.35 286.90,30.95 288.41,30.50 289.92,30.00 291.43,29.50 292.94,29.05 294.45,28.65 295.96,28.32 297.47,28.06 298.98,27.86 300.49,27.75 302.00,27.71 302.00,47.71 300.49,47.75 298.98,47.86 297.47,48.06 295.96,48.32 294.45,48.65 292.94,49.05 291.43,49.50 289.92,50.00 288.41,50.50 286.90,50.95 285.39,51.35 283.88,51.68 282.37,51.94 280.86,52.14 279.35,52.25 277.84,52.29 276.33,52.25 274.82,52.14 273.31,51.94 271.80,51.68 270.29,51.35 268.78,50.95 267.27,50.50 265.76,50.00 264.25,49.50 262.74,49.05 261.23,48.65 259.72,48.32 258.21,48.06 256.70,47.86 255.19,47.75 253.68,47.71 252.17,47.75 250.66,47.86 249.15,48.06 247.64,48.32 246.13,48.65 244.62,49.05 243.11,49.50 241.60,50.00 240.09,50.50 238.58,50.95 237.07,51.35 235.56,51.68 234.05,51.94 232.54,52.14 231.03,52.25 229.52,52.29 228.01,52.25 226.50,52.14 224.99,51.94 223.48,51.68 221.97,51.35 220.46,50.95 218.95,50.50 217.44,50.00 215.93,49.50 214.42,49.05 212.91,48.65 211.40,48.32 209.89,48.06 208.38,47.86 206.87,47.75 205.36,47.71 203.85,47.75 202.34,47.86 200.83,48.06 199.32,48.32 197.81,48.65 196.30,49.05 194.79,49.50 193.28,50.00 191.77,50.50 190.26,50.95 188.75,51.35 187.24,51.68 185.73,51.94 184.22,52.14 182.71,52.25 181.20,52.29 179.69,52.25 178.18,52.14 176.67,51.94 175.16,51.68 173.65,51.35 172.14,50.95 170.63,50.50 169.12,50.00 167.61,49.50 166.10,49.05 164.59,48.65 163.08,48.32 161.57,48.06 160.06,47.86 158.55,47.75 157.04,47.71 155.53,47.75 154.02,47.86 152.51,48.06 151.00,48.32 149.49,48.65 147.98,49.05 146.47,49.50 144.96,50.00 143.45,50.50 141.94,50.95 140.43,51.35 138.92,51.68 137.41,51.94 135.90,52.14 134.39,52.25 132.88,52.29 131.37,52.25 129.86,52.14 128.35,51.94 126.84,51.68 125.33,51.35 123.82,50.95 122.31,50.50 120.80,50.00 119.29,49.50 117.78,49.05 116.27,48.65 114.76,48.32 113.25,48.06 111.74,47.86 110.23,47.75 108.72,47.71 107.21,47.75 105.70,47.86 104.19,48.06 102.68,48.32 101.17,48.65 99.66,49.05 98.15,49.50 96.64,50.00 95.13,50.50 93.62,50.95 92.11,51.35 90.60,51.68 89.09,51.94 87.58,52.14 86.07,52.25 84.56,52.29 83.05,52.25 81.54,52.14 80.03,51.94 78.52,51.68 77.01,51.35 75.50,50.95 73.99,50.50 72.48,50.00 70.97,49.50 69.46,49.05 67.95,48.65 66.44,48.32 64.93,48.06 63.42,47.86 61.91,47.75 60.40,47.71 58.89,47.75 57.38,47.86 55.87,48.06 54.36,48.32 52.85,48.65 51.34,49.05 49.83,49.50 48.32,50.00 46.81,50.50 45.30,50.95 43.79,51.35 42.28,51.68 40.77,51.94 39.26,52.14 37.75,52.25 36.24,52.29 34.73,52.25 33.22,52.14 31.71,51.94 30.20,51.68 28.69,51.35 27.18,50.95 25.67,50.50 24.16,50.00 22.65,49.50 21.14,49.05 19.63,48.65 18.12,48.32 16.61,48.06 15.10,47.86 13.59,47.75 12.08,47.71 10.57,47.75 9.06,47.86 7.55,48.06 6.04,48.32 4.53,48.65 3.02,49.05 1.51,49.50 0.00,50.00
" />
</svg>

After

Width:  |  Height:  |  Size: 11 KiB