Compare commits

...
Author SHA1 Message Date
“Naeel” de2598f449 admin-console: bump to v0.1.1 2026-05-25 10:51:26 +04:00
“Naeel” 35c276c7a9 admin-console: add proper Ingress resource, fix namespace detail routing 2026-05-25 10:47:02 +04:00
“Naeel” 21ab7b9d6b admin-console: fast namespace list + async detail loading per ns 2026-05-25 10:40:49 +04:00
“Naeel” d570a6b8a5 admin-console: fix duplicate package main, fix Dockerfile build order 2026-05-25 10:21:13 +04:00
“Naeel” 190361ab0c admin-console: implement Dynamic client CRD counting, S3 storage metrics, CSV export 2026-05-25 10:11:13 +04:00
“Naeel” 55758b6280 admin-console: add DeepSeek handoff instructions 2026-05-25 10:04:23 +04:00
“Naeel” 34a8068da5 restructure: console→client-console, add admin-console skeleton, move docs to doc/ 2026-05-25 09:48:55 +04:00
“Naeel” 3e36ff13a8 docs: fix outdated references — versions, S3/PVC, triggers status 2026-05-21 09:24:42 +04:00
“Naeel” 53ef35968e fix: billing.NoopStore in clone test + repo analysis doc 2026-05-21 09:14:31 +04:00
“Naeel” ab128515ab docs: README — storagesvc S3/PV deploy instructions + comparison table 2026-05-20 09:09:11 +04:00
“Naeel” 296b538d20 bench: S3 results 2026-05-20 PASS=20/20 + PV vs S3 comparison
RUNID=9251970 (S3 ngcloud msk-1):
- PASS=20/20 — все тесты прошли
- Upload: мелкие файлы медленнее (+1-3s), крупные — сопоставимо
- Cold start: S3 ≈ PV (python быстрее, ruby small медленнее, PHP одинаково)
- Hot avg: практически идентично на обоих бэкендах

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

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

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

Это автоматически мигрирует все namespace-ы при следующем создании функции.
2026-05-13 10:55:47 +04:00
“Naeel” 6a8c3a27e2 fix(console): v1.3.91 — deps сохраняются в аннотации и восстанавливаются при edit
- handleCreateFunction: deps → аннотация fission-console/deps
- handleUpdateFunctionCode: deps → аннотация fission-console/deps (или удаление если пусто)
- GET /functions/:name: возвращает deps из аннотации
- openEdit: заполняет e-deps.value = fn.deps (вместо пустого поля)
2026-05-13 10:43:59 +04:00
“Naeel” 75532d8d8e fix(console): v1.3.90 — deps textarea compact, linter внешние импорты, python env v1.1
- deps textarea: rows=3, resize:none, overflow-y:auto
- aiCheck (Python): после линтера проверяет внешние импорты
  если deps поле пустое — предупреждение с именами модулей
- Python env: v1.0 → v1.1 (содержит boto3+requests+psycopg2-binary)
2026-05-13 10:37:14 +04:00
“Naeel” 10b2b4a8e0 feat(console): поле зависимостей для функций из кода (deps → zip)
- UI: textarea 'Зависимости' в формах создания и редактирования
  - плейсхолдер меняется по языку (requirements.txt / package.json / Gemfile / composer.json)
- Python: если deps заполнен → zip(main.py + requirements.txt), иначе raw bytes
- PHP: если deps → zip(main.php + composer.json)
- Ruby: если deps → zip(handler.rb + Gemfile)
- Node.js: пока без изменений (сложная структура package.json)
- runtime: BuildPythonZip, BuildScriptZipWithDeps, buildZipTwo
- model: Deps string в CreateFunctionRequest и UpdateCodeRequest
- v1.3.89
2026-05-13 10:22:45 +04:00
“Naeel” 17f1c5d46f feat: стартовая точка для weather pipeline без Terraform (ветка feat/weather-pipeline-no-tf) 2026-05-13 07:44:38 +04:00
“Naeel” 4ef480d1f8 chore: ignore *.tfvars (contains secrets) 2026-05-12 11:22:44 +04:00
“Naeel” df16b40a9d feat: weather-demo MQ pipeline + sqs-consumer v1.2 with JWT auto-refresh
- sqs-consumer: new Go binary v1.2 with tokenManager (auto-login /auth/login, 5min cache, retry on 401)
- console: add MQ/KW triggers UI (mqtriggers.go, kwtriggers.go, mq.js)
- console: update python-env image to v1.1 (boto3+psycopg2+requests)
- weather-demo: fix consumer/main.py (Flask Request.get_json instead of dict access)
- weather-demo: fix fetcher/main.py (boto3 SQS publish, 5 cities)
- weather-demo: update main.tf (python-env v1.1, deploy_type=literal)
- python-env: add psycopg2-binary to Dockerfile (v1.1)
- terraform provider: client auth fix
2026-05-12 11:19:30 +04:00
“Naeel” fbd565651a feat(provider): add fission_mq_trigger, fission_cron_trigger, fission_iot_device + weather-demo example 2026-05-11 09:37:20 +04:00
167 changed files with 8385 additions and 330 deletions
+4 -4
View File
@@ -28,16 +28,16 @@
1. Не трогать рабочий код без явного указания. 1. Не трогать рабочий код без явного указания.
2. Файлы редактируются локально: 2. Файлы редактируются локально:
~/IoT ~/fission-console
После ЛЮБЫХ изменений ОБЯЗАТЕЛЬНО синхронизировать на ВМ командой: После ЛЮБЫХ изменений ОБЯЗАТЕЛЬНО синхронизировать на ВМ командой:
rsync -az \ rsync -az \
-e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \ -e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \
~/IoT/ \ ~/fission-console/ \
naeel@5.172.178.213:~/terra/IoT/ naeel@5.172.178.213:~/terra/fission/
3. Git (add/commit/push) выполнять ЛОКАЛЬНО в ~/IoT 3. Git (add/commit/push) выполнять ЛОКАЛЬНО в ~/fission-console
4. Docker, kubectl и другие инфраструктурные команды — только через SSH на ВМ: 4. Docker, kubectl и другие инфраструктурные команды — только через SSH на ВМ:
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 'КОМАНДА' ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 'КОМАНДА'
+6 -6
View File
@@ -13,14 +13,14 @@
## Файловая система (актуально) ## Файловая система (актуально)
1. Все файлы редактируются локально: `~/IoT` 1. Все файлы редактируются локально: `~/fission-console`
2. После любых изменений — обязательно rsync на ВМ: 2. После любых изменений — обязательно rsync на ВМ:
rsync -az \ rsync -az \
-e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \ -e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \
~/IoT/ \ ~/fission-console/ \
naeel@5.172.178.213:~/terra/IoT/ naeel@5.172.178.213:~/terra/fission/
3. Git (add/commit/push) выполнять ЛОКАЛЬНО в ~/IoT 3. Git (add/commit/push) выполнять ЛОКАЛЬНО в ~/fission-console
4. Docker, kubectl и другие инфраструктурные команды — только через SSH на ВМ 4. Docker, kubectl и другие инфраструктурные команды — только через SSH на ВМ
5. Перед запуском любой команды на ВМ обязательно убедиться, что синхронизация (rsync) выполнена 5. Перед запуском любой команды на ВМ обязательно убедиться, что синхронизация (rsync) выполнена
6. SCP, sshfs, remote_dev и маунты больше НЕ используются 6. SCP, sshfs, remote_dev и маунты больше НЕ используются
@@ -50,7 +50,7 @@ ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=
## Git ## Git
⛔⛔⛔ АБСОЛЮТНОЕ ПРАВИЛО: ⛔⛔⛔ АБСОЛЮТНОЕ ПРАВИЛО:
- Git — ТОЛЬКО ЛОКАЛЬНО в `~/fission`. НИКОГДА через SSH на VM. - Git — ТОЛЬКО ЛОКАЛЬНО в `~/fission-console`. НИКОГДА через SSH на VM.
- Разрешены ТОЛЬКО две операции: `git commit` и `git push`. - Разрешены ТОЛЬКО две операции: `git commit` и `git push`.
- ЗАПРЕЩЕНО: git pull, git fetch, git rebase, git merge, git reset, git stash, git checkout — что угодно кроме commit и push. - ЗАПРЕЩЕНО: git pull, git fetch, git rebase, git merge, git reset, git stash, git checkout — что угодно кроме commit и push.
- Если push отклонён — СТОП, доложить пользователю. Не лезть в pull/merge/rebase самостоятельно. - Если push отклонён — СТОП, доложить пользователю. Не лезть в pull/merge/rebase самостоятельно.
@@ -86,7 +86,7 @@ LOG="test-results/$(date +%Y-%m-%d_%H-%M).log"
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no naeel@5.172.178.213 \ ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no naeel@5.172.178.213 \
"bash ~/terra/fission/scripts/run_all.sh 2>&1 | tee ~/terra/fission/${LOG}" "bash ~/terra/fission/scripts/run_all.sh 2>&1 | tee ~/terra/fission/${LOG}"
rsync -az -e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no" \ rsync -az -e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no" \
naeel@5.172.178.213:~/terra/IoT/test-results/ ~/IoT/test-results/ naeel@5.172.178.213:~/terra/fission/test-results/ ~/fission-console/test-results/
``` ```
**Никогда не разбираться с результатами по памяти / буферу / чату. Только лог.** **Никогда не разбираться с результатами по памяти / буферу / чату. Только лог.**
+9
View File
@@ -2,6 +2,7 @@
.terraform/ .terraform/
*.tfstate *.tfstate
*.tfstate.* *.tfstate.*
*.tfvars
# Go # Go
bin/ bin/
@@ -15,3 +16,11 @@ examples/*/dist/
terraform-provider-fission terraform-provider-fission
terraform-provider-fission_* terraform-provider-fission_*
console/fission-console console/fission-console
# Secrets / tokens
token.txt
helm/fission-s3-secret.yaml
# Test logs
test-results/
scripts/test_layer1.sh
+56
View File
@@ -235,3 +235,59 @@
### Вывод ### Вывод
- Текущий prompt уже работает на коротких и простых кейсах. - Текущий prompt уже работает на коротких и простых кейсах.
- Следующий шаг — проверить его на более разнообразных задачах, чтобы убедиться, что шаблон не ломается за пределами приветствий. - Следующий шаг — проверить его на более разнообразных задачах, чтобы убедиться, что шаблон не ломается за пределами приветствий.
---
## Итерация 9 (2026-05-19) — RUNTIME BUG DISCOVERY
### Источник
Реальный прогон `test_heavy.sh` на кластере. Обнаружены критические баги в PHP и Node.js runtime-контракте.
### БАГ C: PHP `return array` → пустой response
**Задача LLM:** написать PHP-функцию.
**Сгенерированный код (типичный):**
```php
<?php
function handler(array $ctx): array {
return ['statusCode' => 200, 'body' => json_encode(['tag' => 'ok'])];
}
```
**Результат invoke:** `{"status":200,"response_raw":""}` — тело пустое.
**Причина:** PHP env (`ghcr.io/fission/php-env:latest`, server.php) создаёт `$response = new Response()`,
вызывает функцию и возвращает оригинальный объект. Return value функции игнорируется.
**Правильный код:**
```php
<?php
function handler($ctx) {
$ctx["response"]->getBody()->write(json_encode(['tag' => 'ok']));
}
```
**Что изменено в промпте:**
- PHP секция переписана: запрет на `return array`, требование писать через `$ctx["response"]->getBody()->write(...)`.
- Добавлен пример с JSON-ответом и PSR-7 объектами.
---
### БАГ B: Node.js entrypoint `"main.js"` — split на точке → funcname="js"
**Проблема консоли, не пользовательского кода.**
Node-env `specializeV2` (строка 160 server.js) интерпретирует functionName через `split(".")`:
- `"main.js"``{filename:"main", funcname:"js"}` → ищет `module["js"]` → undefined → specialize 500.
- `"main"``{filename:"main", funcname:undefined}` → берёт `default export` → работает.
**Fix в консоли:** `console/internal/runtime/entrypoint.go`, `DefaultEntrypoint("nodejs")` = `"main"`.
**Влияние на промпт:** никакого — это внутренняя проблема консоли. Пользовательский Node.js код пишется как обычно.
---
### Итог итерации 9
- PHP промпт обновлён: PSR-7 `getBody()->write()` вместо `return array`.
- Node.js промпт уточнён: `module.exports = async function(ctx) {...}`, CJS стиль.
- Документация: `doc/RUNTIME_FUNCTION_GUIDE.md` — полный справочник контрактов.
+25 -3
View File
@@ -54,9 +54,31 @@
- Не ломай контракт функции выдуманными обертками или неподходящими типами. - Не ломай контракт функции выдуманными обертками или неподходящими типами.
### PHP ### PHP
- Возвращай корректный PHP-код функции. - **КРИТИЧНО:** PHP env НЕ читает return value функции. `return ['statusCode'=>200,'body'=>...]` — тело ответа будет ПУСТЫМ.
- Следи за открывающим тегом и синтаксисом массива/строк. - Запись в ответ — ТОЛЬКО через `$ctx["response"]->getBody()->write(...)`:
- Не выдумывай несуществующие `Fission\\Contract` классы или SDK, если задача про обычную функцию. ```php
<?php
function handler($ctx) {
$ctx["response"]->getBody()->write(json_encode(['key' => 'value']));
}
```
- Сигнатура: `function handler($ctx)` — без type hint `array` и без `: array` return type.
- Доступ к запросу: `$ctx["request"]`, к логгеру: `$ctx["logger"]`.
- Открывающий тег `<?php` обязателен.
- Не выдумывай несуществующие `Fission\\Contract` классы или SDK.
- Альтернатива (только если нет доступа к request): простой `echo` без объявления функции handler:
```php
<?php
echo json_encode(['key' => 'value']);
```
### Node.js
- Пользовательский код оборачивается консолью в CJS wrapper. Писать как обычный CommonJS.
- Экспорт: `module.exports = async function(ctx) { return { status: 200, body: "..." }; }`
- Формат ответа: объект `{ status: число, body: строка }`.
- **НЕ** использовать `export default` — код запускается в CJS контексте.
- **НЕ** указывать `.js` в entrypoint — это внутренняя настройка консоли, не пользовательский код.
- Не вставляй ESM import/export обвязку в пользовательский код.
### Ruby ### Ruby
- Возвращай корректный Ruby-код функции. - Возвращай корректный Ruby-код функции.
+52
View File
@@ -102,6 +102,58 @@ Backend сервис для управления serverless-функциями
## Деплой ## Деплой
### Хранилище архивов функций (storagesvc)
Fission хранит ZIP-архивы функций через `storagesvc`. Поддерживается два бэкенда:
#### S3 (рекомендуется)
```bash
# 1. Создать файл с credentials (НЕ коммитить в git):
cat > helm/fission-s3-secret.yaml << 'EOF'
persistence:
s3:
accessKeyId: <ACCESS_KEY>
secretAccessKey: <SECRET_KEY>
EOF
# 2. Применить:
kubectl delete deploy executor router storagesvc -n fission # убрать field manager конфликты
helm upgrade fission fission-charts/fission-all --version 1.22.1 \
-n fission \
-f helm/fission-values.yaml \
-f helm/fission-s3-secret.yaml \
--no-hooks --timeout 180s
```
Параметры S3 (bucket, endpoint, region) — в `helm/fission-values.yaml`.
Credentials — только в `helm/fission-s3-secret.yaml` (файл в `.gitignore`).
#### PV (возврат/альтернатива)
```bash
kubectl delete deploy executor router storagesvc -n fission
helm upgrade fission fission-charts/fission-all --version 1.22.1 \
-n fission \
-f helm/fission-values-pv.yaml \
--no-hooks --timeout 180s
```
Никаких секретов не нужно. StorageClass `vcd-disk-ext4` (VMware VCD), 2Gi.
#### Сравнение
| | S3 | PV |
|---|---|---|
| Upload скорость | ~1-3s (сеть) | ~0.3-2s (кэш) |
| Cold start | ≈ одинаково | ≈ одинаково |
| Hot (основная нагрузка) | идентично | идентично |
| Масштабирование storagesvc | ✅ N реплик | ❌ ReadWriteOnce |
| Переезд кластера | ✅ просто бакет | ❌ надо мигрировать PV |
| Backup | ✅ встроен в S3 | ручной |
---
| Параметр | Значение | | Параметр | Значение |
|---|---| |---|---|
| Кластер | `kube5s.ru` | | Кластер | `kube5s.ru` |
-20
View File
@@ -1,20 +0,0 @@
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 (сейчас нельзя задать).
+164
View File
@@ -0,0 +1,164 @@
# Handoff: admin-console — задание для DeepSeek
## Контекст
Репозиторий `fission-console`, ветка `restructure/repo-layout`.
Это **Fission Serverless Platform** — кастомная установка. Клиентская консоль (`client-console/`) уже готова и работает в проде. Нужно реализовать **admin-console** (`admin-console/`) — панель для оператора платформы (просмотр всех клиентов, использование ресурсов, данные для бухгалтерии).
---
## Что уже сделано (скелет)
```
admin-console/
cmd/server/main.go — точка входа, порт 8091, требует ADMIN_TOKEN
internal/api/server.go — роуты: /admin/api/namespaces, /usage, /users
internal/metrics/k8s.go — k8s-клиент (in-cluster), заглушки для реальных данных
internal/model/types.go — типы NamespaceInfo, UsageEntry, UserInfo
deploy/admin.yaml — K8s Deployment + ClusterRole (read-only всё)
ui/index.html — базовый UI (тёмная тема, 3 вкладки)
go.mod — module admin-console, зависит от k8s.io/client-go v0.31.0
```
---
## Что нужно реализовать
### 1. `internal/metrics/k8s.go` — подсчёт Fission CRD
Метод `ListClientNamespaces` должен для каждого namespace считать объекты через Dynamic client:
```go
import (
"k8s.io/client-go/dynamic"
"k8s.io/apimachinery/pkg/runtime/schema"
)
dynamicClient, _ := dynamic.NewForConfig(cfg)
gvr := schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "functions"}
list, err := dynamicClient.Resource(gvr).Namespace(ns).List(ctx, metav1.ListOptions{})
info.Functions = len(list.Items)
```
Аналогично для: `packages`, `environments`, `httptriggers`, `timetriggers`.
Сохрани `dynamicClient` в структуру `K8sClient` и используй его в обоих методах.
### 2. `internal/metrics/k8s.go` — S3-хранилище в `CollectUsage`
Поле `StorageGB` — читать из S3. Конфигурация через env:
- `S3_ENDPOINT` = `https://s3.msk-1.ngcloud.ru`
- `S3_BUCKET` = `sless-functions`
- `S3_ACCESS_KEY`, `S3_SECRET_KEY`
Использовать `github.com/aws/aws-sdk-go-v2`. Для каждого namespace prefix = `{namespace}/`, суммировать размер всех объектов.
```go
import (
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
awscfg "github.com/aws/aws-sdk-go-v2/config"
)
cfg, _ := awscfg.LoadDefaultConfig(ctx,
awscfg.WithRegion("ru-msk-1"),
awscfg.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")),
awscfg.WithEndpointResolverWithOptions(...), // custom endpoint
)
client := s3.NewFromConfig(cfg, func(o *s3.Options) { o.UsePathStyle = true })
paginator := s3.NewListObjectsV2Paginator(client, &s3.ListObjectsV2Input{
Bucket: aws.String(bucket),
Prefix: aws.String(namespace + "/"),
})
var totalBytes int64
for paginator.HasMorePages() {
page, _ := paginator.NextPage(ctx)
for _, obj := range page.Contents {
totalBytes += aws.ToInt64(obj.Size)
}
}
entry.StorageGB = float64(totalBytes) / (1024 * 1024 * 1024)
```
Поля `Invocations`, `CPUMilliCores`, `MemoryMB` — оставить 0 (TODO).
### 3. `go.mod` — добавить зависимости
```
github.com/aws/aws-sdk-go-v2 v1.30.0
github.com/aws/aws-sdk-go-v2/config v1.27.0
github.com/aws/aws-sdk-go-v2/service/s3 v1.58.0
github.com/aws/aws-sdk-go-v2/credentials v1.17.0
```
Запустить `go mod tidy` после добавления.
### 4. `deploy/admin.yaml` — добавить env в контейнер
```yaml
env:
- name: ADMIN_TOKEN
valueFrom:
secretKeyRef:
name: fission-admin-token
key: token
- name: S3_ENDPOINT
value: "https://s3.msk-1.ngcloud.ru"
- name: S3_BUCKET
value: "sless-functions"
- name: S3_ACCESS_KEY
valueFrom:
secretKeyRef:
name: fission-s3-credentials
key: access_key
- name: S3_SECRET_KEY
valueFrom:
secretKeyRef:
name: fission-s3-credentials
key: secret_key
```
### 5. `ui/index.html` — доработать UI
- В таблице "Использование" показывать реальное поле `storage_gb` (уже есть в шаблоне).
- Добавить кнопку **"Экспорт CSV"** над таблицей Usage — генерировать CSV из текущих данных и скачивать через `Blob`:
```js
function exportCSV(rows) {
const header = 'Namespace,Email,Функции,Вызовы,Хранилище GB,Период\n';
const body = rows.map(u =>
[u.namespace, u.user_email, u.functions, u.invocations_total,
u.storage_gb.toFixed(3), `${u.period_start}${u.period_end}`].join(',')
).join('\n');
const blob = new Blob([header + body], { type: 'text/csv;charset=utf-8;' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `usage-${new Date().toISOString().slice(0,10)}.csv`;
a.click();
}
```
---
## Правила среды
- **Go version**: `1.25.0` (не менять в go.mod)
- **Образ**: `naeel/fission-admin-console:v0.1.0`
- **Build + push**: только через SSH на ВМ `naeel@5.172.178.213` (ключ у оператора)
- **Namespace деплоя**: `fission`
- **S3-секрет** `fission-s3-credentials` уже существует в namespace `fission`
- **Admin-токен** создать вручную перед деплоем:
```bash
kubectl create secret generic fission-admin-token -n fission --from-literal=token=<случайная_строка>
```
- Деплой: `kubectl apply -f ~/terra/fission/admin-console/deploy/admin.yaml`
## Что НЕ трогать
- `client-console/` — рабочий код в проде, **не трогать вообще**
- `helm/`, `terraform/`, `scripts/` — не трогать
- Любой файл вне `admin-console/` — без явного разрешения не трогать
+12
View File
@@ -0,0 +1,12 @@
FROM golang:1.26-alpine AS builder
WORKDIR /build
COPY . .
RUN go mod tidy
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o admin-console ./cmd/server/
FROM alpine:3.20
RUN apk add --no-cache ca-certificates
COPY --from=builder /build/admin-console /admin-console
COPY ui/ /ui/
EXPOSE 8091
ENTRYPOINT ["/admin-console"]
+27
View File
@@ -0,0 +1,27 @@
package main
import (
"log"
"net/http"
"os"
"admin-console/internal/api"
)
func main() {
adminToken := os.Getenv("ADMIN_TOKEN")
if adminToken == "" {
log.Fatal("ADMIN_TOKEN env variable is required")
}
srv := api.NewServer(adminToken)
mux := http.NewServeMux()
srv.RegisterRoutes(mux)
addr := ":8091"
log.Printf("admin-console starting on %s", addr)
if err := http.ListenAndServe(addr, mux); err != nil {
log.Fatalf("server error: %v", err)
}
}
+120
View File
@@ -0,0 +1,120 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: fission-admin-console
namespace: fission
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: fission-admin-console
rules:
# читать ВСЕ namespaces (для отображения клиентов)
- apiGroups: [""]
resources: ["namespaces"]
verbs: ["get", "list"]
# читать секреты в клиентских namespaces (для списка пользователей)
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "list"]
# читать pods (для метрик потребления)
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list"]
# читать Fission CRD
- apiGroups: ["fission.io"]
resources: ["environments", "packages", "functions", "httptriggers", "timetriggers"]
verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: fission-admin-console
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: fission-admin-console
subjects:
- kind: ServiceAccount
name: fission-admin-console
namespace: fission
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: fission-admin-console
namespace: fission
spec:
replicas: 1
selector:
matchLabels:
app: fission-admin-console
template:
metadata:
labels:
app: fission-admin-console
spec:
serviceAccountName: fission-admin-console
containers:
- name: fission-admin-console
image: naeel/fission-admin-console:v0.1.1
imagePullPolicy: Always
ports:
- containerPort: 8091
env:
- name: ADMIN_TOKEN
valueFrom:
secretKeyRef:
name: fission-admin-token
key: token
- name: S3_ENDPOINT
value: "https://s3.msk-1.ngcloud.ru"
- name: S3_BUCKET
value: "sless-functions"
- name: S3_ACCESS_KEY
valueFrom:
secretKeyRef:
name: fission-s3-credentials
key: access_key
- name: S3_SECRET_KEY
valueFrom:
secretKeyRef:
name: fission-s3-credentials
key: secret_key
---
apiVersion: v1
kind: Service
metadata:
name: fission-admin-console
namespace: fission
spec:
selector:
app: fission-admin-console
ports:
- port: 8091
targetPort: 8091
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: fission-admin-console
namespace: fission
annotations:
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
spec:
ingressClassName: nginx
rules:
- host: fission.kube5s.ru
http:
paths:
- backend:
service:
name: fission-admin-console
port:
number: 8091
path: /admin
pathType: Prefix
tls:
- hosts:
- fission.kube5s.ru
secretName: fission-tls
+15
View File
@@ -0,0 +1,15 @@
module admin-console
go 1.25.0
require (
github.com/aws/aws-sdk-go-v2 v1.30.0
github.com/aws/aws-sdk-go-v2/config v1.27.0
github.com/aws/aws-sdk-go-v2/credentials v1.17.0
github.com/aws/aws-sdk-go-v2/service/s3 v1.58.0
k8s.io/api v0.31.0
k8s.io/apimachinery v0.31.0
k8s.io/client-go v0.31.0
)
+129
View File
@@ -0,0 +1,129 @@
package api
import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
"admin-console/internal/metrics"
"admin-console/internal/model"
)
// Server — HTTP-сервер admin-console.
type Server struct {
adminToken string
k8s *metrics.K8sClient
}
func NewServer(adminToken string) *Server {
k8s, err := metrics.NewK8sClient()
if err != nil {
// вне кластера — работаем без k8s (для локальной разработки)
k8s = nil
}
return &Server{adminToken: adminToken, k8s: k8s}
}
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
// статика UI
mux.Handle("/admin/", http.StripPrefix("/admin/", http.FileServer(http.Dir("ui"))))
// API
mux.HandleFunc("/admin/api/health", s.handleHealth)
mux.HandleFunc("/admin/api/namespaces", s.authMiddleware(s.handleNamespaces))
mux.HandleFunc("/admin/api/namespaces/", s.authMiddleware(s.handleNamespaceDetail))
mux.HandleFunc("/admin/api/usage", s.authMiddleware(s.handleUsage))
mux.HandleFunc("/admin/api/users", s.authMiddleware(s.handleUsers))
}
// authMiddleware проверяет Bearer-токен из заголовка Authorization.
func (s *Server) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") != s.adminToken {
writeJSON(w, http.StatusUnauthorized, model.ErrResp("unauthorized"))
return
}
next(w, r)
}
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// handleNamespaces возвращает список клиентских namespace-ов (быстро, без счётчиков).
func (s *Server) handleNamespaces(w http.ResponseWriter, r *http.Request) {
if s.k8s == nil {
writeJSON(w, http.StatusServiceUnavailable, model.ErrResp("k8s unavailable"))
return
}
ns, err := s.k8s.ListClientNamespaces(r.Context())
if err != nil {
writeJSON(w, http.StatusInternalServerError, model.ErrResp(err.Error()))
return
}
writeJSON(w, http.StatusOK, map[string]any{"namespaces": ns})
}
// handleNamespaceDetail возвращает детальную информацию об одном namespace (с CRD-счётчиками).
func (s *Server) handleNamespaceDetail(w http.ResponseWriter, r *http.Request) {
if s.k8s == nil {
writeJSON(w, http.StatusServiceUnavailable, model.ErrResp("k8s unavailable"))
return
}
// URL: /admin/api/namespaces/{name}
name := strings.TrimPrefix(r.URL.Path, "/admin/api/namespaces/")
if name == "" {
writeJSON(w, http.StatusBadRequest, model.ErrResp("missing namespace name"))
return
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
info, err := s.k8s.GetNamespaceDetail(ctx, name)
if err != nil {
writeJSON(w, http.StatusNotFound, model.ErrResp(err.Error()))
return
}
writeJSON(w, http.StatusOK, info)
}
// handleUsage возвращает агрегированные метрики использования для бухгалтерии.
func (s *Server) handleUsage(w http.ResponseWriter, r *http.Request) {
if s.k8s == nil {
writeJSON(w, http.StatusServiceUnavailable, model.ErrResp("k8s unavailable"))
return
}
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
usage, err := s.k8s.CollectUsage(ctx)
if err != nil {
writeJSON(w, http.StatusInternalServerError, model.ErrResp(err.Error()))
return
}
writeJSON(w, http.StatusOK, map[string]any{"usage": usage})
}
// handleUsers возвращает список пользователей из secrets в клиентских namespace-ах.
func (s *Server) handleUsers(w http.ResponseWriter, r *http.Request) {
if s.k8s == nil {
writeJSON(w, http.StatusServiceUnavailable, model.ErrResp("k8s unavailable"))
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
users, err := s.k8s.ListUsers(ctx)
if err != nil {
writeJSON(w, http.StatusInternalServerError, model.ErrResp(err.Error()))
return
}
writeJSON(w, http.StatusOK, map[string]any{"users": users})
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
+254
View File
@@ -0,0 +1,254 @@
package metrics
import (
"context"
"fmt"
"log"
"os"
"strings"
"sync"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
"admin-console/internal/model"
)
// fission CRD group-version-resources.
var (
gvrFunctions = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "functions"}
gvrPackages = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "packages"}
gvrEnvironments = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "environments"}
gvrHTTPTriggers = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "httptriggers"}
gvrTimeTriggers = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "timetriggers"}
)
// K8sClient — обёртка над kubernetes.Clientset для нужд admin-console.
type K8sClient struct {
cs *kubernetes.Clientset
dc dynamic.Interface
s3 *s3.Client
s3Bucket string
}
// NewK8sClient создаёт клиента из in-cluster конфига, с fallback на ~/.kube/config.
func NewK8sClient() (*K8sClient, error) {
cfg, err := rest.InClusterConfig()
if err != nil {
cfg, err = clientcmd.BuildConfigFromFlags("", clientcmd.RecommendedHomeFile)
if err != nil {
return nil, fmt.Errorf("no k8s config: %w", err)
}
}
cs, err := kubernetes.NewForConfig(cfg)
if err != nil {
return nil, err
}
dc, err := dynamic.NewForConfig(cfg)
if err != nil {
return nil, fmt.Errorf("dynamic client: %w", err)
}
k := &K8sClient{cs: cs, dc: dc}
// S3 — опционально (не падаем если нет кредов)
k.initS3()
return k, nil
}
// initS3 создаёт S3-клиент из переменных окружения.
func (k *K8sClient) initS3() {
endpoint := os.Getenv("S3_ENDPOINT")
bucket := os.Getenv("S3_BUCKET")
accessKey := os.Getenv("S3_ACCESS_KEY")
secretKey := os.Getenv("S3_SECRET_KEY")
if endpoint == "" || bucket == "" || accessKey == "" || secretKey == "" {
log.Println("[admin-console] S3 env vars not set — storage metrics disabled")
return
}
cfg, err := config.LoadDefaultConfig(context.Background(),
config.WithRegion("ru-msk-1"),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")),
)
if err != nil {
log.Printf("[admin-console] S3 config error: %v", err)
return
}
k.s3 = s3.NewFromConfig(cfg, func(o *s3.Options) {
o.BaseEndpoint = aws.String(endpoint)
o.UsePathStyle = true
})
k.s3Bucket = bucket
log.Printf("[admin-console] S3 client ready, bucket=%s", bucket)
}
// ListClientNamespaces возвращает список namespace-ов БЕЗ CRD-счётчиков (быстро).
func (k *K8sClient) ListClientNamespaces(ctx context.Context) ([]model.NamespaceInfo, error) {
nsList, err := k.cs.CoreV1().Namespaces().List(ctx, metav1.ListOptions{
LabelSelector: "managed-by=fission-console",
})
if err != nil {
return nil, err
}
result := make([]model.NamespaceInfo, 0, len(nsList.Items))
for _, ns := range nsList.Items {
result = append(result, model.NamespaceInfo{
Name: ns.Name,
UserEmail: ns.Labels["user"],
CreatedAt: ns.CreationTimestamp.Format(time.RFC3339),
})
}
return result, nil
}
// GetNamespaceDetail возвращает информацию об одном namespace с CRD-счётчиками.
func (k *K8sClient) GetNamespaceDetail(ctx context.Context, nsName string) (*model.NamespaceInfo, error) {
ns, err := k.cs.CoreV1().Namespaces().Get(ctx, nsName, metav1.GetOptions{})
if err != nil {
return nil, err
}
info := &model.NamespaceInfo{
Name: ns.Name,
UserEmail: ns.Labels["user"],
CreatedAt: ns.CreationTimestamp.Format(time.RFC3339),
}
k.fillCRDCounts(ctx, nsName, info)
return info, nil
}
// fillCRDCounts заполняет счётчики Fission-ресурсов для одного namespace.
func (k *K8sClient) fillCRDCounts(ctx context.Context, ns string, info *model.NamespaceInfo) {
count := func(gvr schema.GroupVersionResource) int {
list, err := k.dc.Resource(gvr).Namespace(ns).List(ctx, metav1.ListOptions{Limit: 1})
if err != nil {
return 0
}
if rem := list.GetRemainingItemCount(); rem != nil {
return int(*rem) + len(list.Items)
}
return len(list.Items)
}
var wg sync.WaitGroup
wg.Add(5)
go func() { defer wg.Done(); info.Functions = count(gvrFunctions) }()
go func() { defer wg.Done(); info.Packages = count(gvrPackages) }()
go func() { defer wg.Done(); info.Environments = count(gvrEnvironments) }()
go func() { defer wg.Done(); info.HTTPTriggers = count(gvrHTTPTriggers) }()
go func() { defer wg.Done(); info.TimeTriggers = count(gvrTimeTriggers) }()
wg.Wait()
}
// CollectUsage возвращает агрегированную статистику по всем клиентским namespace-ам.
func (k *K8sClient) CollectUsage(ctx context.Context) ([]model.UsageEntry, error) {
nsList, err := k.cs.CoreV1().Namespaces().List(ctx, metav1.ListOptions{
LabelSelector: "managed-by=fission-console",
})
if err != nil {
return nil, err
}
now := time.Now().UTC()
startOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
result := make([]model.UsageEntry, 0, len(nsList.Items))
for _, ns := range nsList.Items {
entry := model.UsageEntry{
Namespace: ns.Name,
UserEmail: ns.Labels["user"],
PeriodStart: startOfMonth.Format("2006-01-02"),
PeriodEnd: now.Format("2006-01-02"),
}
// Считаем функции через Dynamic client.
if list, err := k.dc.Resource(gvrFunctions).Namespace(ns.Name).List(ctx, metav1.ListOptions{Limit: 1}); err == nil {
entry.Functions = len(list.Items)
if rem := list.GetRemainingItemCount(); rem != nil {
entry.Functions += int(*rem)
}
}
// Считаем S3-хранилище.
entry.StorageGB = k.namespaceStorage(ctx, ns.Name)
// Invocations / CPU / Memory — TODO.
result = append(result, entry)
}
return result, nil
}
// namespaceStorage возвращает суммарный размер объектов S3 с префиксом "{ns}/".
func (k *K8sClient) namespaceStorage(ctx context.Context, ns string) float64 {
if k.s3 == nil {
return 0
}
var totalBytes int64
paginator := s3.NewListObjectsV2Paginator(k.s3, &s3.ListObjectsV2Input{
Bucket: aws.String(k.s3Bucket),
Prefix: aws.String(ns + "/"),
})
for paginator.HasMorePages() {
page, err := paginator.NextPage(ctx)
if err != nil {
log.Printf("[admin-console] S3 list error ns=%s: %v", ns, err)
return 0
}
for _, obj := range page.Contents {
if obj.Size != nil {
totalBytes += *obj.Size
}
}
}
return float64(totalBytes) / (1024 * 1024 * 1024)
}
// ListUsers собирает пользователей из secret fission-console-users в каждом
// клиентском namespace.
func (k *K8sClient) ListUsers(ctx context.Context) ([]model.UserInfo, error) {
nsList, err := k.cs.CoreV1().Namespaces().List(ctx, metav1.ListOptions{
LabelSelector: "managed-by=fission-console",
})
if err != nil {
return nil, err
}
var users []model.UserInfo
for _, ns := range nsList.Items {
email := ns.Labels["user"]
if email == "" {
secret, err := k.cs.CoreV1().Secrets(ns.Name).Get(ctx, "fission-console-users", metav1.GetOptions{})
if err == nil {
for key := range secret.Data {
if strings.Contains(key, "@") {
email = key
break
}
}
}
}
users = append(users, model.UserInfo{
Email: email,
Namespace: ns.Name,
CreatedAt: ns.CreationTimestamp.Format(time.RFC3339),
})
}
return users, nil
}
+40
View File
@@ -0,0 +1,40 @@
package model
// ErrResp — стандартный ответ с ошибкой.
func ErrResp(msg string) map[string]string {
return map[string]string{"error": msg}
}
// NamespaceInfo — информация об одном клиентском namespace.
type NamespaceInfo struct {
Name string `json:"name"`
UserEmail string `json:"user_email,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
// счётчики ресурсов Fission
Functions int `json:"functions"`
Packages int `json:"packages"`
Environments int `json:"environments"`
HTTPTriggers int `json:"http_triggers"`
TimeTriggers int `json:"time_triggers"`
}
// UsageEntry — строка использования для одного namespace (для бухгалтерии).
type UsageEntry struct {
Namespace string `json:"namespace"`
UserEmail string `json:"user_email,omitempty"`
Functions int `json:"functions"`
Invocations int64 `json:"invocations_total"` // TODO: из Prometheus/logs
StorageGB float64 `json:"storage_gb"` // TODO: из storagesvc/S3
CPUMilliCores int64 `json:"cpu_millicores"` // TODO: из metrics-server
MemoryMB int64 `json:"memory_mb"` // TODO: из metrics-server
PeriodStart string `json:"period_start"`
PeriodEnd string `json:"period_end"`
}
// UserInfo — пользователь из secret fission-console-users.
type UserInfo struct {
Email string `json:"email"`
Namespace string `json:"namespace"`
CreatedAt string `json:"created_at,omitempty"`
}
+219
View File
@@ -0,0 +1,219 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fission Admin Console v0.1.1</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #0f1117; color: #e0e0e0; }
header { background: #1a1d27; padding: 16px 24px; border-bottom: 1px solid #2a2d3a;
display: flex; align-items: center; gap: 12px; }
header h1 { font-size: 18px; font-weight: 600; color: #fff; }
header .badge { background: #e53e3e; color: #fff; font-size: 10px; font-weight: 700;
padding: 2px 6px; border-radius: 4px; letter-spacing: .5px; }
#login { display: flex; align-items: center; justify-content: center; height: calc(100vh - 57px); }
#login form { background: #1a1d27; border: 1px solid #2a2d3a; border-radius: 8px;
padding: 32px; display: flex; flex-direction: column; gap: 12px; width: 320px; }
#login h2 { font-size: 16px; color: #fff; margin-bottom: 4px; }
input { background: #0f1117; border: 1px solid #2a2d3a; border-radius: 6px;
color: #e0e0e0; padding: 10px 12px; font-size: 14px; outline: none; }
input:focus { border-color: #4a90d9; }
button { background: #2563eb; color: #fff; border: none; border-radius: 6px;
padding: 10px; font-size: 14px; cursor: pointer; font-weight: 600; }
button:hover { background: #1d4ed8; }
#app { display: none; }
nav { background: #1a1d27; border-bottom: 1px solid #2a2d3a;
display: flex; gap: 4px; padding: 0 24px; }
nav button { background: none; border: none; color: #9ca3af; font-size: 14px;
padding: 12px 16px; cursor: pointer; border-bottom: 2px solid transparent; }
nav button.active { color: #fff; border-bottom-color: #2563eb; }
main { padding: 24px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { color: #6b7280; font-weight: 500; text-align: left; padding: 8px 12px;
border-bottom: 1px solid #2a2d3a; }
td { padding: 10px 12px; border-bottom: 1px solid #1a1d27; }
tr:hover td { background: #1a1d27; }
.pill { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 11px; }
.pill-blue { background: #1e3a5f; color: #60a5fa; }
.err { color: #f87171; font-size: 13px; padding: 16px 0; }
.loader { color: #6b7280; font-size: 13px; padding: 16px 0; }
.cnt { color: #6b7280; font-size: 12px; }
.cnt.loading { animation: pulse 1.5s infinite; }
@keyframes pulse { 0%,100%{opacity:.3} 50%{opacity:1} }
</style>
</head>
<body>
<header>
<h1>Fission Admin Console</h1>
<span class="badge">ADMIN</span>
</header>
<div id="login">
<form id="loginForm">
<h2>Вход в Admin Console</h2>
<input type="password" id="tokenInput" placeholder="Admin token" autocomplete="current-password">
<button type="submit">Войти</button>
<div id="loginErr" class="err" style="display:none"></div>
</form>
</div>
<div id="app">
<nav>
<button class="active" onclick="showTab('namespaces', this)">Клиенты</button>
<button onclick="showTab('usage', this)">Использование</button>
<button onclick="showTab('users', this)">Пользователи</button>
</nav>
<main id="content"></main>
</div>
<script>
const API = '/admin/api';
let TOKEN = '';
let NS_LIST = [];
document.getElementById('loginForm').addEventListener('submit', async e => {
e.preventDefault();
TOKEN = document.getElementById('tokenInput').value.trim();
try {
const r = await fetch(`${API}/namespaces`, { headers: { Authorization: `Bearer ${TOKEN}` } });
if (r.ok) {
const data = await r.json();
NS_LIST = data.namespaces || [];
document.getElementById('login').style.display = 'none';
document.getElementById('app').style.display = 'block';
showTab('namespaces', document.querySelector('nav button'));
} else {
const err = document.getElementById('loginErr');
err.style.display = 'block';
err.textContent = 'Неверный токен';
}
} catch(e) {
const err = document.getElementById('loginErr');
err.style.display = 'block';
err.textContent = 'Ошибка соединения: ' + e.message;
}
});
function showTab(tab, btn) {
document.querySelectorAll('nav button').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
loadTab(tab);
}
async function loadTab(tab) {
const content = document.getElementById('content');
if (tab === 'namespaces') {
content.innerHTML = renderNamespaces(NS_LIST);
// загружаем CRD-счётчики асинхронно
loadAllDetails();
return;
}
content.innerHTML = '<p class="loader">Загрузка…</p>';
try {
const r = await fetch(`${API}/${tab}`, { headers: { Authorization: `Bearer ${TOKEN}` } });
const data = await r.json();
if (!r.ok) { content.innerHTML = `<p class="err">${data.error}</p>`; return; }
content.innerHTML = renderTab(tab, data);
} catch(e) {
content.innerHTML = `<p class="err">Ошибка: ${e.message}</p>`;
}
}
function renderTab(tab, data) {
if (tab === 'usage') return renderUsage(data.usage);
if (tab === 'users') return renderUsers(data.users);
return '';
}
function renderNamespaces(rows) {
if (!rows?.length) return '<p class="loader">Нет namespace-ов</p>';
return `<table>
<tr><th>Namespace</th><th>Email</th><th>Fn</th><th>Pkg</th><th>Env</th><th>HTTP</th><th>Time</th><th>Создан</th></tr>
${rows.map((n,i) => `<tr>
<td><span class="pill pill-blue">${n.name}</span></td>
<td>${n.user_email || '—'}</td>
<td class="cnt loading" id="ns${i}-fn">…</td>
<td class="cnt loading" id="ns${i}-pkg">…</td>
<td class="cnt loading" id="ns${i}-env">…</td>
<td class="cnt loading" id="ns${i}-http">…</td>
<td class="cnt loading" id="ns${i}-time">…</td>
<td>${n.created_at ? n.created_at.slice(0,10) : '—'}</td>
</tr>`).join('')}
</table>`;
}
async function loadAllDetails() {
for (let i = 0; i < NS_LIST.length; i++) {
loadDetail(i, NS_LIST[i].name);
}
}
async function loadDetail(idx, name) {
try {
const r = await fetch(`${API}/namespaces/${encodeURIComponent(name)}`, {
headers: { Authorization: `Bearer ${TOKEN}` }
});
if (!r.ok) return;
const d = await r.json();
setCnt(idx, 'fn', d.functions);
setCnt(idx, 'pkg', d.packages);
setCnt(idx, 'env', d.environments);
setCnt(idx, 'http', d.http_triggers);
setCnt(idx, 'time', d.time_triggers);
} catch(e) {}
}
function setCnt(idx, type, val) {
const el = document.getElementById(`ns${idx}-${type}`);
if (el) { el.textContent = val ?? 0; el.classList.remove('loading'); }
}
function renderUsage(rows) {
if (!rows?.length) return '<p class="loader">Нет данных</p>';
const btn = `<div style="margin-bottom:12px">
<button onclick="exportCSV()">📥 Экспорт CSV</button>
</div>`;
return btn + `<table>
<tr><th>Namespace</th><th>Email</th><th>Функции</th><th>Вызовы</th><th>Хранилище GB</th><th>Период</th></tr>
${rows.map(u => `<tr>
<td><span class="pill pill-blue">${u.namespace}</span></td>
<td>${u.user_email || '—'}</td>
<td>${u.functions}</td>
<td>${u.invocations_total}</td>
<td>${(u.storage_gb || 0).toFixed(3)}</td>
<td>${u.period_start}${u.period_end}</td>
</tr>`).join('')}
</table>`;
}
function exportCSV() {
const table = document.querySelector('table');
if (!table) return;
let csv = '\uFEFF';
table.querySelectorAll('tr').forEach(row => {
const cells = [];
row.querySelectorAll('th,td').forEach(c => cells.push('"' + c.textContent.replace(/"/g, '""') + '"'));
csv += cells.join(',') + '\n';
});
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `usage-${new Date().toISOString().slice(0,10)}.csv`;
a.click();
}
function renderUsers(rows) {
if (!rows?.length) return '<p class="loader">Нет пользователей</p>';
return `<table>
<tr><th>Email</th><th>Namespace</th><th>Создан</th></tr>
${rows.map(u => `<tr>
<td>${u.email || '—'}</td>
<td><span class="pill pill-blue">${u.namespace}</span></td>
<td>${u.created_at ? u.created_at.slice(0,10) : '—'}</td>
</tr>`).join('')}
</table>`;
}
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+114
View File
@@ -0,0 +1,114 @@
#!/bin/bash
# Бенчмарк: upload, deploy, cold/hot start для простой функции
# Все результаты пишутся в test-results/bench-func
set -e
RESULTS_DIR="$(dirname "$0")/../test-results/bench-func"
FUNC_DIR="$(dirname "$0")"
FUNC_NAME="bench-hello"
ENV_NAME="bench-python-env"
NS="bench-func-$(date +%s)"
# Donor namespace с готовым RBAC
DONOR_NS=$(kubectl get ns --no-headers | awk '{print $1}' | grep -E '^fission-[a-f0-9]{16}$' | head -1)
LOGFILE="$RESULTS_DIR/bench_func_$(date +%Y-%m-%d_%H-%M-%S).log"
mkdir -p "$RESULTS_DIR"
{
echo "# Bench: $(date)"
echo "Namespace: $NS"
echo "Donor NS: $DONOR_NS"
} | tee "$LOGFILE"
# 1. Create namespace + copy RBAC from donor
echo -n "[1] Create namespace + RBAC... " | tee -a "$LOGFILE"
START_NS=$(date +%s%3N)
kubectl create ns "$NS" >/dev/null
# Копируем ServiceAccount-ы
for sa in fission-builder fission-fetcher; do
kubectl get sa "$sa" -n "$DONOR_NS" -o json 2>/dev/null \
| jq 'del(.metadata.resourceVersion,.metadata.uid,.metadata.creationTimestamp,.metadata.annotations,.metadata.ownerReferences) | .metadata.namespace = $ns' --arg ns "$NS" \
| kubectl apply -f - >/dev/null 2>&1 || true
done
# Копируем RoleBindings из donor
kubectl get rolebinding -n "$DONOR_NS" -o json 2>/dev/null \
| jq -r '.items[] | select(.roleRef.kind=="ClusterRole") | del(.metadata.resourceVersion,.metadata.uid,.metadata.creationTimestamp,.metadata.annotations,.metadata.ownerReferences) | .metadata.namespace = $ns' --arg ns "$NS" \
| kubectl apply -f - >/dev/null 2>&1 || true
END_NS=$(date +%s%3N)
echo "$((END_NS-START_NS)) ms" | tee -a "$LOGFILE"
# 2. Create environment
echo -n "[2] Create environment... " | tee -a "$LOGFILE"
START_ENV=$(date +%s%3N)
fission env create --name "$ENV_NAME" --image naeel/fission-python-env:v1.1 --namespace "$NS" >/dev/null 2>&1
END_ENV=$(date +%s%3N)
echo "$((END_ENV-START_ENV)) ms" | tee -a "$LOGFILE"
# 3. Upload function
echo -n "[3] Upload function... " | tee -a "$LOGFILE"
START_UP=$(date +%s%3N)
fission fn create --name "$FUNC_NAME" --env "$ENV_NAME" --code "$FUNC_DIR/hello.py" --namespace "$NS" >/dev/null 2>&1
END_UP=$(date +%s%3N)
echo "$((END_UP-START_UP)) ms" | tee -a "$LOGFILE"
# 4. Cold start invoke (retry до 60 раз по 2 сек = 2 мин максимум)
echo -n "[4] Cold start invoke... " | tee -a "$LOGFILE"
START_COLD=$(date +%s%3N)
COLD_OK=0
for i in {1..60}; do
RESULT=$(fission fn test --name "$FUNC_NAME" --namespace "$NS" 2>&1 || true)
if echo "$RESULT" | grep -qE "hello|200|statusCode"; then
COLD_OK=1
break
fi
sleep 2
done
END_COLD=$(date +%s%3N)
if [[ $COLD_OK -eq 1 ]]; then
echo "$((END_COLD-START_COLD)) ms" | tee -a "$LOGFILE"
else
echo "FAILED" | tee -a "$LOGFILE"
{
echo "[DEBUG] pods in $NS:"
kubectl get pods -n "$NS" 2>&1
echo "[DEBUG] events in $NS:"
kubectl get events -n "$NS" --sort-by=.metadata.creationTimestamp 2>&1 | tail -20
} >> "$LOGFILE"
exit 1
fi
# 5. Hot start invoke (pod уже прогрет, retry до 5 раз)
echo -n "[5] Hot start invoke... " | tee -a "$LOGFILE"
START_HOT=$(date +%s%3N)
HOT_OK=0
for i in {1..5}; do
RESULT=$(fission fn test --name "$FUNC_NAME" --namespace "$NS" 2>&1 || true)
if echo "$RESULT" | grep -qE "hello|200|statusCode"; then
HOT_OK=1; break
fi
sleep 2
done
END_HOT=$(date +%s%3N)
if [[ $HOT_OK -eq 1 ]]; then
echo "$((END_HOT-START_HOT)) ms" | tee -a "$LOGFILE"
else
echo "FAILED (pod не ответил)" | tee -a "$LOGFILE"
fi
# 6. Hot x5
echo "[6] Hot start x5:" | tee -a "$LOGFILE"
for i in {1..5}; do
START_H=$(date +%s%3N)
RESULT=$(fission fn test --name "$FUNC_NAME" --namespace "$NS" 2>&1 || true)
END_H=$(date +%s%3N)
if echo "$RESULT" | grep -qE "hello|200|statusCode"; then
echo " invoke #$i: $((END_H-START_H)) ms" | tee -a "$LOGFILE"
else
echo " invoke #$i: FAILED" | tee -a "$LOGFILE"
fi
done
echo "---" | tee -a "$LOGFILE"
echo "DONE. Log: $LOGFILE"
+263
View File
@@ -0,0 +1,263 @@
#!/usr/bin/env bash
# diag_bugs.sh — диагностика двух аномалий из bench_full run=9248232
# BUG-1: php/medium + php/large COLD-TIMEOUT (гипотеза: arrow functions PHP 7.4+)
# BUG-2: ruby/medium cold=80451ms (гипотеза: poolmgr eviction — проверяем воспроизводимость)
set -uo pipefail
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
ROOT_DIR=$(cd "${SCRIPT_DIR}/.." && pwd)
source "${ROOT_DIR}/scripts/lib.sh"
SUB="bench@bench.local"
RUNID="diag$(date +%s | tail -c 6)"
TMP=$(mktemp -d)
TIMESTAMP=$(date +%Y-%m-%d_%H-%M-%S)
RESULTS_DIR="${ROOT_DIR}/test-results/bench-func"
LOGFILE="${RESULTS_DIR}/diag_bugs_${TIMESTAMP}.log"
mkdir -p "$RESULTS_DIR"
declare -a ALL_FNS=()
PASS_CNT=0
FAIL_CNT=0
cleanup() {
rm -rf "$TMP" 2>/dev/null || true
for fn in "${ALL_FNS[@]:-}"; do
curl -s -X DELETE "${BASE}/functions/${fn}" \
-H "X-Auth-Token: ${SUB}" >/dev/null 2>&1 || true
done
}
trap cleanup EXIT
log() { echo "$@" | tee -a "$LOGFILE"; }
escape_code() { python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))'; }
get_status() {
python3 -c "import json,sys
try: d=json.load(sys.stdin); print(d.get('status',''))
except: print('')" 2>/dev/null
}
get_field() {
local f="$1"
python3 -c "import json,sys
try: d=json.load(sys.stdin); print(d.get('$f',''))
except: print('')" 2>/dev/null
}
log "════════════════════════════════════════════════════════════════"
log " diag_bugs.sh run=${RUNID} $(date)"
log " API: ${BASE} SUB: ${SUB}"
log "════════════════════════════════════════════════════════════════"
# ═══════════════════════════════════════════════════════════════════
# Вспомогательная: загрузить функцию, сделать cold+hot invoke, вернуть результат
# bench_one LABEL LANG CODEFILE [EXPECTED_STR]
# ═══════════════════════════════════════════════════════════════════
bench_one() {
local label="$1" lang="$2" codefile="$3" expected="${4:-}"
local fn="bench-${label}-${RUNID}"
ALL_FNS+=("$fn")
# Upload
local code_esc t0 t1 R CODE BODY UPLOAD_MS
code_esc=$(cat "$codefile" | escape_code)
t0=$(date +%s%3N)
R=$(curl -s -w "\n%{http_code}" -X POST "${BASE}/functions" \
-H "X-Auth-Token: ${SUB}" -H "Content-Type: application/json" \
-d "{\"name\":\"${fn}\",\"language\":\"${lang}\",\"code\":${code_esc}}" 2>/dev/null)
t1=$(date +%s%3N)
CODE=$(echo "$R" | tail -1)
BODY=$(echo "$R" | head -n -1)
UPLOAD_MS=$((t1-t0))
if [ "$CODE" != "201" ]; then
log " [${label}] UPLOAD FAIL HTTP=${CODE}: $(echo "$BODY" | head -c 200)"
FAIL_CNT=$((FAIL_CNT+1))
return 1
fi
log " [${label}] upload OK ${UPLOAD_MS}ms"
# Cold start (retry 20×5s)
local tc0 COLD_MS=0 cold_ok=0 cold_body=""
tc0=$(date +%s%3N)
for i in $(seq 1 20); do
local resp s raw
resp=$(curl -s --max-time 35 -X POST "${BASE}/functions/${fn}/invoke" \
-H "X-Auth-Token: ${SUB}" -H "Content-Type: application/json" -d '{}' 2>/dev/null)
s=$(echo "$resp" | get_status)
raw=$(echo "$resp" | get_field "response_raw")
if [ "$s" = "200" ]; then
if [ -z "$expected" ] || echo "$raw" | grep -qF "$expected"; then
local tc1; tc1=$(date +%s%3N)
COLD_MS=$((tc1-tc0))
cold_ok=1
cold_body="$raw"
break
fi
fi
sleep 5
done
if [ $cold_ok -eq 0 ]; then
log " [${label}] COLD TIMEOUT"
FAIL_CNT=$((FAIL_CNT+1))
return 1
fi
# Hot ×3
local HOT_TOTAL=0 hot_ok=0
for i in 1 2 3; do
local th0 th1 resp s
th0=$(date +%s%3N)
resp=$(curl -s --max-time 35 -X POST "${BASE}/functions/${fn}/invoke" \
-H "X-Auth-Token: ${SUB}" -H "Content-Type: application/json" -d '{}' 2>/dev/null)
th1=$(date +%s%3N)
s=$(echo "$resp" | get_status)
[ "$s" = "200" ] && { HOT_TOTAL=$((HOT_TOTAL+th1-th0)); hot_ok=$((hot_ok+1)); }
done
local HOT_AVG=0; [ $hot_ok -gt 0 ] && HOT_AVG=$((HOT_TOTAL/hot_ok))
log " [${label}] OK cold=${COLD_MS}ms hot_avg=${HOT_AVG}ms (${hot_ok}/3)"
[ -n "$cold_body" ] && log " [${label}] body=$(echo "$cold_body" | head -c 120)"
PASS_CNT=$((PASS_CNT+1))
}
# ═══════════════════════════════════════════════════════════════════
# BUG-1: PHP arrow functions
# ═══════════════════════════════════════════════════════════════════
log ""
log "┌─────────────────────────────────────────────────────────────────┐"
log "│ BUG-1: PHP arrow functions (fn()=> требует PHP 7.4+) │"
log "└─────────────────────────────────────────────────────────────────┘"
# Тест 1а: минимальный PHP с arrow function — поддерживается?
cat > "${TMP}/php_arrow_min.php" << 'EOF'
<?php
function main($event, $context) {
$nums = [1,2,3,4,5];
$doubled = array_map(fn($x) => $x * 2, $nums);
return "arrow-ok:" . implode(",", $doubled);
}
EOF
log ""
log " [php-arrow-min] Тест: arrow function fn(\$x)=>\$x*2 (PHP 7.4+)"
bench_one "php-arrow-min" "php" "${TMP}/php_arrow_min.php" "arrow-ok"
# Тест 1б: PHP version info через phpinfo-like
cat > "${TMP}/php_version.php" << 'EOF'
<?php
function main($event, $context) {
return "php-version:" . PHP_VERSION . " php-major:" . PHP_MAJOR_VERSION . "." . PHP_MINOR_VERSION;
}
EOF
log ""
log " [php-version] Тест: получить версию PHP runtime"
bench_one "php-version" "php" "${TMP}/php_version.php" "php-version"
# Тест 1в: PHP medium без arrow functions (обычные function)
cat > "${TMP}/php_medium_noarrow.php" << 'EOF'
<?php
function fib($n){$a=0;$b=1;for($i=0;$i<$n;$i++)[$a,$b]=[$b,$a+$b];return $a;}
function sieve($n){$s=array_fill(0,$n+1,true);$s[0]=$s[1]=false;for($i=2;$i*$i<=$n;$i++)if($s[$i])for($j=$i*$i;$j<=$n;$j+=$i)$s[$j]=false;return array_keys(array_filter($s));}
function sqrtSum($n){$s=0;for($i=1;$i<=$n;$i++)$s+=sqrt($i);return $s;}
function bubbleSort($arr){$n=count($arr);for($i=0;$i<$n;$i++)for($j=0;$j<$n-$i-1;$j++)if($arr[$j]>$arr[$j+1]){$t=$arr[$j];$arr[$j]=$arr[$j+1];$arr[$j+1]=$t;}return $arr;}
function matMul($a,$b){$ra=count($a);$ca=count($a[0]);$cb=count($b[0]);$c=array_fill(0,$ra,array_fill(0,$cb,0));for($i=0;$i<$ra;$i++)for($j=0;$j<$cb;$j++)for($k=0;$k<$ca;$k++)$c[$i][$j]+=$a[$i][$k]*$b[$k][$j];return $c;}
function processWordsNoArrow($words){
$out=[];
foreach($words as $s){
if(strlen($s)>3) $out[]=strrev(strtoupper($s));
}
return $out;
}
function statsArrNoArrow($arr){
$n=count($arr);$m=array_sum($arr)/$n;
$v=0; foreach($arr as $x) $v+=($x-$m)*($x-$m); $v/=$n;
return['n'=>$n,'mean'=>round($m,4),'std'=>round(sqrt($v),4)];
}
function main($event,$context){
$t0=microtime(true);
$words=["alpha","beta","gamma","delta","epsilon","zeta","eta","theta","iota","kappa","lambda","mu"];
$f=fib(35);$primes=sieve(500);$s=sqrtSum(1000);
$nums=range(50,1);$sorted=bubbleSort($nums);
$a=[[1,2,3],[4,5,6]];$b=[[7,8],[9,10],[11,12]];$c=matMul($a,$b);
$pw=processWordsNoArrow($words);$st=statsArrNoArrow($primes);
$h=substr(hash('sha256','bench-medium-php'),0,16);
$elapsed=microtime(true)-$t0;
return json_encode(['tag'=>'bench-php-medium-noarrow-ok','fib35'=>$f,'primes'=>count($primes),
'sqrt'=>round($s,1),'sorted0'=>$sorted[0],'mat_c00'=>$c[0][0],
'words'=>count($pw),'stats'=>$st,'hash'=>$h,'elapsed'=>round($elapsed,5)]);
}
EOF
log ""
log " [php-medium-noarrow] PHP medium логика без arrow functions (foreach вместо fn()=>)"
bench_one "php-med-noarr" "php" "${TMP}/php_medium_noarrow.php" "bench-php-medium-noarrow-ok"
# ═══════════════════════════════════════════════════════════════════
# BUG-2: ruby/medium cold=80451ms — воспроизводимость
# ═══════════════════════════════════════════════════════════════════
log ""
log "┌─────────────────────────────────────────────────────────────────┐"
log "│ BUG-2: ruby/medium cold=80451ms — воспроизводимость (×3) │"
log "└─────────────────────────────────────────────────────────────────┘"
cat > "${TMP}/ruby_medium_diag.rb" << 'EOF'
require 'json'
require 'digest'
WORDS=%w[alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau upsilon phi chi psi omega]
def fib(n); a,b=0,1; n.times{a,b=b,a+b}; a; end
def sieve(n)
s=Array.new(n+1,true); s[0]=s[1]=false
(2..Math.sqrt(n).to_i).each{|i|(i*i..n).step(i){|j|s[j]=false} if s[i]}
(0..n).select{|i|s[i]}
end
def sqrt_sum(n); (1..n).sum{|i|Math.sqrt(i)}; end
def bubble_sort(arr)
arr=arr.dup; n=arr.length
n.times{(n-1).times{|j|arr[j],arr[j+1]=arr[j+1],arr[j] if arr[j]>arr[j+1]}}
arr
end
def mat_mul(a,b)
ra,ca,cb=a.length,a[0].length,b[0].length
Array.new(ra){|i|Array.new(cb){|j|(0...ca).sum{|k|a[i][k]*b[k][j]}}}
end
def process_words(w); w.select{|s|s.length>3}.map{|s|s.upcase.reverse}; end
def get_stats(arr)
n=arr.length; m=arr.sum.to_f/n; v=arr.sum{|x|(x-m)**2}/n
{n:n, mean:m.round(4), std:Math.sqrt(v).round(4)}
end
def handler(ctx)
t0=Time.now
f=fib(35); primes=sieve(500); s=sqrt_sum(1000)
nums=(1..50).to_a.reverse; sorted=bubble_sort(nums)
a=[[1,2,3],[4,5,6]]; b=[[7,8],[9,10],[11,12]]; c=mat_mul(a,b)
pw=process_words(WORDS); st=get_stats(primes)
h=Digest::SHA256.hexdigest('bench-medium-ruby')[0,16]
elapsed=Time.now-t0
{ statusCode: 200, body: JSON.generate({tag:"bench-ruby-medium-ok", fib35:f, primes:primes.length,
sqrt:s.round(1), sorted0:sorted[0], mat_c00:c[0][0],
words:pw.length, stats:st, hash:h, elapsed:elapsed.round(5)}) }
end
EOF
log ""
log " ruby/medium × 3 независимых функции (разные имена → чистый cold start каждый раз)"
for attempt in 1 2 3; do
log ""
log " --- Попытка ${attempt}/3 ---"
bench_one "ruby-med-${attempt}" "ruby" "${TMP}/ruby_medium_diag.rb" "bench-ruby-medium-ok"
done
# ═══════════════════════════════════════════════════════════════════
# ИТОГ
# ═══════════════════════════════════════════════════════════════════
log ""
log "════════════════════════════════════════════════════════════════"
log " ИТОГ: PASS=${PASS_CNT} FAIL=${FAIL_CNT} TOTAL=$((PASS_CNT+FAIL_CNT))"
log " Лог: ${LOGFILE}"
log " Готово: $(date)"
log "════════════════════════════════════════════════════════════════"
+5
View File
@@ -0,0 +1,5 @@
def main(event, context):
return {
"statusCode": 200,
"body": "hello, world!"
}
@@ -15,9 +15,12 @@ rules:
- apiGroups: [""] - apiGroups: [""]
resources: ["pods/log"] resources: ["pods/log"]
verbs: ["get"] verbs: ["get"]
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "list", "create", "delete"]
- apiGroups: ["apps"] - apiGroups: ["apps"]
resources: ["deployments"] resources: ["deployments"]
verbs: ["get", "list", "update", "patch"] verbs: ["get", "list", "create", "update", "patch", "delete"]
- apiGroups: ["fission.io"] - apiGroups: ["fission.io"]
resources: ["environments", "packages", "functions", "httptriggers", "timetriggers"] resources: ["environments", "packages", "functions", "httptriggers", "timetriggers"]
verbs: ["get", "list", "create", "update", "patch", "delete"] verbs: ["get", "list", "create", "update", "patch", "delete"]
@@ -55,7 +58,7 @@ spec:
serviceAccountName: fission-console serviceAccountName: fission-console
containers: containers:
- name: console - name: console
image: naeel/fission-console:v1.3.87 image: naeel/fission-console:v1.3.96
imagePullPolicy: Always imagePullPolicy: Always
ports: ports:
- containerPort: 8090 - containerPort: 8090
@@ -13,6 +13,7 @@ import (
"strings" "strings"
"testing" "testing"
"fission-console/internal/billing"
"fission-console/internal/fission" "fission-console/internal/fission"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -48,9 +49,10 @@ func newCloneTestServer(t *testing.T, storagesvcHandler http.HandlerFunc, objs .
} }
s := &Server{ s := &Server{
dyn: dynClient, dyn: dynClient,
ns: "fission-test", ns: "fission-test",
http: httpClient, http: httpClient,
billing: billing.NoopStore{},
} }
return s, storagesvc return s, storagesvc
} }
@@ -35,17 +35,26 @@ import (
// buildDeployArchive упаковывает исходный код в байты для deployment Package. // buildDeployArchive упаковывает исходный код в байты для deployment Package.
// Для nodejs — ESM-обёртка (package.json + main.js). // Для nodejs — ESM-обёртка (package.json + main.js).
// Для php/ruby — zip с одним файлом скрипта. // Для php/ruby — zip с одним файлом скрипта.
// Для остальных (python) — raw bytes кода. // Для остальных (python) — raw bytes кода (или zip если есть deps).
func buildDeployArchive(lang, code string) ([]byte, error) { // deps — содержимое файла зависимостей (requirements.txt, Gemfile, composer.json).
// Если deps пустой — поведение как раньше.
func buildDeployArchive(lang, code, deps string) ([]byte, error) {
switch lang { switch lang {
case "nodejs": case "nodejs":
// TODO: поддержка package.json с deps для nodejs — пока игнорируем deps
return runtime.BuildJSDeployZip(code) return runtime.BuildJSDeployZip(code)
case "php": case "php":
if deps != "" {
return runtime.BuildScriptZipWithDeps(code, "main.php", deps, "composer.json")
}
return runtime.BuildScriptZip(code, "main.php") return runtime.BuildScriptZip(code, "main.php")
case "ruby": case "ruby":
if deps != "" {
return runtime.BuildScriptZipWithDeps(code, "handler.rb", deps, "Gemfile")
}
return runtime.BuildScriptZip(code, "handler.rb") return runtime.BuildScriptZip(code, "handler.rb")
default: default: // python
return []byte(code), nil return runtime.BuildPythonZip(code, deps)
} }
} }
@@ -173,7 +182,7 @@ func (s *Server) handleCreateFunction(w http.ResponseWriter, r *http.Request) {
"buildcommand": "build", "buildcommand": "build",
} }
} else { } else {
deployBytes, archiveErr := buildDeployArchive(req.Language, req.Code) deployBytes, archiveErr := buildDeployArchive(req.Language, req.Code, req.Deps)
if archiveErr != nil { if archiveErr != nil {
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build %s archive: %v", req.Language, archiveErr)) writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build %s archive: %v", req.Language, archiveErr))
return return
@@ -205,6 +214,9 @@ func (s *Server) handleCreateFunction(w http.ResponseWriter, r *http.Request) {
now := time.Now().UTC() now := time.Now().UTC()
fnAnnotations[functionCreatedAtAnnotation] = now.Format(time.RFC3339) fnAnnotations[functionCreatedAtAnnotation] = now.Format(time.RFC3339)
fnAnnotations[functionUpdatedAtAnnotation] = now.Format(time.RFC3339) fnAnnotations[functionUpdatedAtAnnotation] = now.Format(time.RFC3339)
if req.Deps != "" {
fnAnnotations["fission-console/deps"] = req.Deps
}
if req.TTL != "" { if req.TTL != "" {
expiresAt, ttlErr := parseTTL(req.TTL) expiresAt, ttlErr := parseTTL(req.TTL)
if ttlErr != nil { if ttlErr != nil {
@@ -329,7 +341,7 @@ func (s *Server) handleUpdateFunctionCode(w http.ResponseWriter, r *http.Request
// Определяем язык из аннотации — нужен для правильной упаковки // Определяем язык из аннотации — нужен для правильной упаковки
lang, _, _ := unstructured.NestedString(fn.Object, "metadata", "annotations", "fission-console/language") lang, _, _ := unstructured.NestedString(fn.Object, "metadata", "annotations", "fission-console/language")
deployBytes, archiveErr := buildDeployArchive(lang, req.Code) deployBytes, archiveErr := buildDeployArchive(lang, req.Code, req.Deps)
if archiveErr != nil { if archiveErr != nil {
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build %s archive: %v", lang, archiveErr)) writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build %s archive: %v", lang, archiveErr))
return return
@@ -393,6 +405,11 @@ func (s *Server) handleUpdateFunctionCode(w http.ResponseWriter, r *http.Request
} }
fnAnnotations[functionCreatedAtAnnotation] = createdAt.UTC().Format(time.RFC3339) fnAnnotations[functionCreatedAtAnnotation] = createdAt.UTC().Format(time.RFC3339)
fnAnnotations[functionUpdatedAtAnnotation] = now.Format(time.RFC3339) fnAnnotations[functionUpdatedAtAnnotation] = now.Format(time.RFC3339)
if req.Deps != "" {
fnAnnotations["fission-console/deps"] = req.Deps
} else {
delete(fnAnnotations, "fission-console/deps")
}
fn.SetAnnotations(fnAnnotations) fn.SetAnnotations(fnAnnotations)
if err := unstructured.SetNestedField(fn.Object, map[string]any{ if err := unstructured.SetNestedField(fn.Object, map[string]any{
"name": newPkgName, "name": newPkgName,
@@ -83,6 +83,7 @@ func (s *Server) handleGetFunction(w http.ResponseWriter, r *http.Request, name
// Читаем source-type аннотацию (code / archive) // Читаем source-type аннотацию (code / archive)
sourceType := "code" sourceType := "code"
archiveFilename := "" archiveFilename := ""
deps := ""
if ann := fn.GetAnnotations(); ann != nil { if ann := fn.GetAnnotations(); ann != nil {
if v := ann[fissionSourceTypeAnnotation]; v != "" { if v := ann[fissionSourceTypeAnnotation]; v != "" {
sourceType = v sourceType = v
@@ -90,6 +91,9 @@ func (s *Server) handleGetFunction(w http.ResponseWriter, r *http.Request, name
if v := ann["fission-console/archive-filename"]; v != "" { if v := ann["fission-console/archive-filename"]; v != "" {
archiveFilename = v archiveFilename = v
} }
if v := ann["fission-console/deps"]; v != "" {
deps = v
}
} }
writeAnyJSON(w, http.StatusOK, map[string]any{ writeAnyJSON(w, http.StatusOK, map[string]any{
@@ -102,6 +106,7 @@ func (s *Server) handleGetFunction(w http.ResponseWriter, r *http.Request, name
"created_at": functionTimestampResponse(fn)["created_at"], "created_at": functionTimestampResponse(fn)["created_at"],
"updated_at": functionTimestampResponse(fn)["updated_at"], "updated_at": functionTimestampResponse(fn)["updated_at"],
"code": code, "code": code,
"deps": deps,
"source_type": sourceType, "source_type": sourceType,
"archive_filename": archiveFilename, "archive_filename": archiveFilename,
"route": route, "route": route,
+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,
}
}
@@ -113,7 +113,7 @@ func decodeArchiveBytesToSource(decoded []byte) (string, error) {
return "", io.ErrUnexpectedEOF return "", io.ErrUnexpectedEOF
} }
var nodeJSWrapperPattern = regexp.MustCompile(`(?s)^const __mod = \{ exports: \{\} \};\s*\(new Function\('module', 'exports', (.+?)\)\)\(__mod, __mod\.exports\);\s*const _fn = __mod\.exports;`) var nodeJSWrapperPattern = regexp.MustCompile(`(?s)^const __mod = \{ exports: \{\} \};\s*\(new Function\('module', 'exports', (?:'require', )?(.+?)\)\)\(__mod, __mod\.exports(?:, require)?\);\s*const _fn = __mod\.exports;`)
func decodeNodeJSWrapperSource(decoded []byte) (string, bool) { func decodeNodeJSWrapperSource(decoded []byte) (string, bool) {
text := string(decoded) text := string(decoded)
@@ -169,6 +169,10 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/console/api/httptriggers", auth(s.handleList(fission.HTTPTrigGVR))) mux.HandleFunc("/console/api/httptriggers", auth(s.handleList(fission.HTTPTrigGVR)))
mux.HandleFunc("/console/api/timetriggers", auth(s.handleTimeTriggersRoot)) mux.HandleFunc("/console/api/timetriggers", auth(s.handleTimeTriggersRoot))
mux.HandleFunc("/console/api/timetriggers/", auth(s.handleTimeTriggersAction)) mux.HandleFunc("/console/api/timetriggers/", auth(s.handleTimeTriggersAction))
mux.HandleFunc("/console/api/mqtriggers", auth(s.handleMQTriggersRoot))
mux.HandleFunc("/console/api/mqtriggers/", auth(s.handleMQTriggersAction))
mux.HandleFunc("/console/api/kwtriggers", auth(s.handleKWTriggersRoot))
mux.HandleFunc("/console/api/kwtriggers/", auth(s.handleKWTriggersAction))
mux.HandleFunc("/console/api/ns/status", auth(s.handleNSStatus)) mux.HandleFunc("/console/api/ns/status", auth(s.handleNSStatus))
mux.HandleFunc("/console/api/ns/debug", auth(s.handleNSDebug)) mux.HandleFunc("/console/api/ns/debug", auth(s.handleNSDebug))
mux.HandleFunc("/console/api/stats/dashboard-url", auth(s.handleStatsDashboard)) mux.HandleFunc("/console/api/stats/dashboard-url", auth(s.handleStatsDashboard))
@@ -393,7 +393,17 @@ func (m *NSManager) reapExpiredFunctionsInNS(ctx context.Context, ns string, now
} }
} }
// Orphan packages — пакеты без соответствующей функции // Orphan packages — пакеты без соответствующей функции.
// Перечитываем Functions заново (fresh LIST) чтобы избежать race condition:
// новая функция может быть создана между снятием activeFunctions и этой проверкой.
freshFunctions, freshErr := m.dyn.Resource(fission.FunctionGVR).Namespace(ns).List(ctx, metav1.ListOptions{})
if freshErr != nil {
return
}
freshFunctionSet := make(map[string]struct{}, len(freshFunctions.Items))
for _, fn := range freshFunctions.Items {
freshFunctionSet[fn.GetName()] = struct{}{}
}
packages, pkgListErr := m.dyn.Resource(fission.PackageGVR).Namespace(ns).List(ctx, metav1.ListOptions{}) packages, pkgListErr := m.dyn.Resource(fission.PackageGVR).Namespace(ns).List(ctx, metav1.ListOptions{})
if pkgListErr == nil { if pkgListErr == nil {
for _, pkg := range packages.Items { for _, pkg := range packages.Items {
@@ -401,8 +411,13 @@ func (m *NSManager) reapExpiredFunctionsInNS(ctx context.Context, ns string, now
if !strings.HasSuffix(pkgName, "-pkg") { if !strings.HasSuffix(pkgName, "-pkg") {
continue continue
} }
// Пропускаем свежесозданные пакеты: Function CRD может ещё не успеть
// создаться после загрузки Package (race condition при быстром ExpiryReaper).
if age := time.Since(pkg.GetCreationTimestamp().Time); age < 2*time.Minute {
continue
}
fnName := strings.TrimSuffix(pkgName, "-pkg") fnName := strings.TrimSuffix(pkgName, "-pkg")
if _, exists := activeFunctions[fnName]; !exists { if _, exists := freshFunctionSet[fnName]; !exists {
log.Printf("cloud.ExpiryReaper: deleting orphan package %s/%s", ns, pkgName) log.Printf("cloud.ExpiryReaper: deleting orphan package %s/%s", ns, pkgName)
_ = m.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, pkgName, metav1.DeleteOptions{}) _ = m.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, pkgName, metav1.DeleteOptions{})
} }
@@ -12,6 +12,8 @@ var (
FunctionGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "functions"} FunctionGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "functions"}
HTTPTrigGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "httptriggers"} HTTPTrigGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "httptriggers"}
TimeTrigGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "timetriggers"} TimeTrigGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "timetriggers"}
MQTrigGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "messagequeuetriggers"}
KWTrigGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "kuberneteswatchtriggers"}
NamespaceGVR = schema.GroupVersionResource{Group: "", Version: "v1", Resource: "namespaces"} NamespaceGVR = schema.GroupVersionResource{Group: "", Version: "v1", Resource: "namespaces"}
DeploymentGVR = schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"} DeploymentGVR = schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"}
@@ -28,9 +28,20 @@ func EnsureEnvironment(ctx context.Context, dyn dynamic.Interface, ns, lang stri
envName := "console-" + lang + "-env" envName := "console-" + lang + "-env"
// Проверяем существование — Get быстрее чем Create+IsAlreadyExists // Проверяем существование — Get быстрее чем Create+IsAlreadyExists
_, getErr := dyn.Resource(EnvironmentGVR).Namespace(ns).Get(ctx, envName, metav1.GetOptions{}) existing, getErr := dyn.Resource(EnvironmentGVR).Namespace(ns).Get(ctx, envName, metav1.GetOptions{})
if getErr == nil { if getErr == nil {
return envName, nil // уже существует — быстрый путь // Уже существует — проверяем образ. Если устарел — обновляем.
currentImage, _, _ := unstructured.NestedString(existing.Object, "spec", "runtime", "image")
if currentImage != langDef.Image {
if patchErr := unstructured.SetNestedField(existing.Object, langDef.Image, "spec", "runtime", "image"); patchErr == nil {
if _, updateErr := dyn.Resource(EnvironmentGVR).Namespace(ns).Update(ctx, existing, metav1.UpdateOptions{}); updateErr != nil {
log.Printf("ensureEnvironment: update image %s/%s: %v", ns, envName, updateErr)
} else {
log.Printf("ensureEnvironment: updated image %s/%s: %s → %s", ns, envName, currentImage, langDef.Image)
}
}
}
return envName, nil
} }
if !apierrors.IsNotFound(getErr) { if !apierrors.IsNotFound(getErr) {
return "", fmt.Errorf("check environment %q: %w", envName, getErr) return "", fmt.Errorf("check environment %q: %w", envName, getErr)
@@ -5,11 +5,14 @@ package model
// CreateFunctionRequest — тело POST /console/api/functions. // CreateFunctionRequest — тело POST /console/api/functions.
// TTL пустой → функция живёт вечно; "1d", "24h" — протухнет через указанное время. // TTL пустой → функция живёт вечно; "1d", "24h" — протухнет через указанное время.
// Deps — содержимое файла зависимостей: requirements.txt (python), package.json deps (nodejs),
// Gemfile (ruby), composer.json (php). Если задан — код упаковывается в zip вместе с deps-файлом.
type CreateFunctionRequest struct { type CreateFunctionRequest struct {
Name string `json:"name"` Name string `json:"name"`
Language string `json:"language"` Language string `json:"language"`
Environment string `json:"environment"` Environment string `json:"environment"`
Code string `json:"code"` Code string `json:"code"`
Deps string `json:"deps"` // содержимое файла зависимостей (опционально)
Entrypoint string `json:"entrypoint"` Entrypoint string `json:"entrypoint"`
Route string `json:"route"` Route string `json:"route"`
Methods []string `json:"methods"` Methods []string `json:"methods"`
@@ -26,9 +29,32 @@ type CreateTimeTriggerRequest struct {
SubPath string `json:"subpath"` SubPath string `json:"subpath"`
} }
// CreateMQTriggerRequest — тело POST /console/api/mqtriggers.
// Архитектура: Console создаёт K8s Deployment (sqs-consumer) + Secret с credentials
// в namespace пользователя. sqs-consumer поллит shared-sqs → вызывает Fission-функцию.
type CreateMQTriggerRequest struct {
Name string `json:"name"`
FunctionName string `json:"functionName"`
Queue string `json:"queue"` // имя очереди в SQS
SqsEndpoint string `json:"sqsEndpoint"` // URL SQS сервиса (default: internal shared-sqs)
AccessKey string `json:"accessKey"` // SQS access key тенанта
SecretKey string `json:"secretKey"` // SQS secret key тенанта
}
// CreateKWTriggerRequest — тело POST /console/api/kwtriggers.
// Документация полей: kubectl get crd kuberneteswatchtriggers.fission.io -o json
type CreateKWTriggerRequest struct {
Name string `json:"name"`
FunctionName string `json:"functionName"`
ResourceType string `json:"resourceType"` // Pod, Service, Deployment и т.д.
Namespace string `json:"namespace"` // пустое = namespace пользователя
LabelSelector string `json:"labelSelector"` // "app=foo" или "" для всех
}
// UpdateCodeRequest — тело PUT /console/api/functions/:name/code. // UpdateCodeRequest — тело PUT /console/api/functions/:name/code.
type UpdateCodeRequest struct { type UpdateCodeRequest struct {
Code string `json:"code"` Code string `json:"code"`
Deps string `json:"deps"` // содержимое файла зависимостей (опционально)
Timeout int64 `json:"timeout"` Timeout int64 `json:"timeout"`
} }
@@ -44,7 +70,7 @@ type LangEnvDef struct {
// LangEnvMap сопоставляет идентификатор языка (string) с описанием среды выполнения. // LangEnvMap сопоставляет идентификатор языка (string) с описанием среды выполнения.
// Ключ используется в createFunctionRequest.Language и как суффикс имени Environment. // Ключ используется в createFunctionRequest.Language и как суффикс имени Environment.
var LangEnvMap = map[string]LangEnvDef{ var LangEnvMap = map[string]LangEnvDef{
"python": {Image: "naeel/fission-python-env:v1.0"}, "python": {Image: "naeel/fission-python-env:v1.1"},
"nodejs": {Image: "ghcr.io/fission/node-env"}, "nodejs": {Image: "ghcr.io/fission/node-env"},
"go": {Image: "ghcr.io/fission/go-env", BuilderImage: "naeel/go-builder-fast:v1"}, "go": {Image: "ghcr.io/fission/go-env", BuilderImage: "naeel/go-builder-fast:v1"},
"php": {Image: "ghcr.io/fission/php-env"}, "php": {Image: "ghcr.io/fission/php-env"},
@@ -11,6 +11,9 @@ package runtime
func DefaultEntrypoint(lang string) string { func DefaultEntrypoint(lang string) string {
switch lang { switch lang {
case "nodejs": case "nodejs":
// "main" (без .js): node-env.specializeV2 делает split(".") для functionName
// с точкой → funcname="js" → ищет экспорт "js" → 500.
// Без расширения: funcname=undefined → берёт default export (module.exports = __invoke).
return "main" return "main"
case "php": case "php":
// Fission php-env: filename::functionName // Fission php-env: filename::functionName
@@ -0,0 +1,52 @@
package runtime
import (
"archive/zip"
"bytes"
)
// buildZip создаёт zip-архив с одним файлом fileName и содержимым content.
// Вспомогательная функция: используется в buildScriptZip (PHP/Ruby),
// а также как основа для buildGoSourceZip и buildJSDeployZip.
func buildZip(fileName string, content []byte) ([]byte, error) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
fw, err := zw.Create(fileName)
if err != nil {
return nil, err
}
if _, err := fw.Write(content); err != nil {
return nil, err
}
if err := zw.Close(); err != nil {
return nil, err
}
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
}
@@ -23,10 +23,12 @@ func BuildJSDeployZip(code string) ([]byte, error) {
// main.js — CommonJS wrapper: // main.js — CommonJS wrapper:
// 1. new Function создаёт функцию в пустом модульном контексте (нет import/export) // 1. new Function создаёт функцию в пустом модульном контексте (нет import/export)
// 2. Передаём ей module и exports как параметры → пользовательский CJS код работает // 2. Передаём ей module, exports и require как параметры → пользовательский CJS код работает
// 3. Экспортируем main/handler/default через module.exports // 3. require передаётся явно, т.к. new Function выполняется в глобальном scope
// и не имеет доступа к module-local require из CJS контекста main.js
// 4. Экспортируем main/handler/default через module.exports
wrapper := fmt.Sprintf(`const __mod = { exports: {} }; wrapper := fmt.Sprintf(`const __mod = { exports: {} };
(new Function('module', 'exports', %s))(__mod, __mod.exports); (new Function('module', 'exports', 'require', %s))(__mod, __mod.exports, require);
const _fn = __mod.exports; const _fn = __mod.exports;
async function __invoke(ctx) { async function __invoke(ctx) {
@@ -33,18 +33,20 @@ func TestBuildJSDeployZipExportsMainAndHandler(t *testing.T) {
files[file.Name] = string(content) files[file.Name] = string(content)
} }
if files["package.json"] != `{"type":"module"}` {
t.Fatalf("package.json = %q, want ESM marker", files["package.json"])
}
mainJS := files["main.js"] mainJS := files["main.js"]
if !strings.Contains(mainJS, `export { __invoke as main, __invoke as handler };`) { if mainJS == "" {
t.Fatalf("main.js does not export both main and handler: %s", mainJS) t.Fatalf("main.js not found in zip, files: %v", files)
} }
if !strings.Contains(mainJS, `export default __invoke;`) { if !strings.Contains(mainJS, `new Function('module', 'exports', 'require',`) {
t.Fatalf("main.js does not export default invoke: %s", mainJS) t.Fatalf("main.js does not pass require to new Function: %s", mainJS)
}
if !strings.Contains(mainJS, `__mod.exports, require)`) {
t.Fatalf("main.js does not forward require: %s", mainJS)
}
if !strings.Contains(mainJS, `module.exports = __invoke`) {
t.Fatalf("main.js does not export __invoke via module.exports: %s", mainJS)
} }
if !strings.Contains(mainJS, `_fn.default || _fn.handler || _fn.main`) { if !strings.Contains(mainJS, `_fn.default || _fn.handler || _fn.main`) {
t.Fatalf("main.js lost user export resolution: %s", mainJS) t.Fatalf("main.js lost user export resolution: %s", mainJS)
} }
} }
+30
View File
@@ -0,0 +1,30 @@
package runtime
// BuildScriptZip создаёт zip-архив с одним файлом fileName и содержимым code.
// Используется для PHP и Ruby — языков где среда Fission ожидает
// именованный файл (handler.rb, handler.pl, main.php и т.д.) внутри архива.
//
// Почему zip, а не просто literal:
// Fission poolmgr при специализации пода распаковывает deployment archive,
// находит нужный файл по имени и загружает его в среду выполнения.
// Если передать просто байты кода в deployment.literal — среда не знает расширение.
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))
}
@@ -25,6 +25,7 @@
<script src="js/fn-archive.js"></script> <script src="js/fn-archive.js"></script>
<script src="js/ai.js"></script> <script src="js/ai.js"></script>
<script src="js/app.js"></script> <script src="js/app.js"></script>
<script src="js/mq.js"></script>
</head> </head>
<body> <body>
@@ -102,7 +103,7 @@
<div class="nubes">NUBES</div> <div class="nubes">NUBES</div>
<div class="product">FISSION CONSOLE</div> <div class="product">FISSION CONSOLE</div>
</div> </div>
<div style="font-size:0.65rem; color:var(--text-secondary); margin-left:10px; align-self:center; opacity:0.7;">v1.3.87</div> <div style="font-size:0.65rem; color:var(--text-secondary); margin-left:10px; align-self:center; opacity:0.7;">v1.3.96</div>
</div> </div>
<div class="row" style="margin:0;"> <div class="row" style="margin:0;">
<button class="btn ghost" onclick="reloadAll()">Refresh</button> <button class="btn ghost" onclick="reloadAll()">Refresh</button>
@@ -136,6 +137,10 @@
<div class="k">Крон-функции</div> <div class="k">Крон-функции</div>
<div id="cron-count" class="v">-</div> <div id="cron-count" class="v">-</div>
</div> </div>
<div class="card">
<div class="k">MQ-триггеры</div>
<div id="mq-count" class="v">-</div>
</div>
</div> </div>
<div class="box"> <div class="box">
@@ -174,6 +179,72 @@
<div id="status" class="status"></div> <div id="status" class="status"></div>
<div class="hint">Изменения применяются напрямую через CRD Fission.</div> <div class="hint">Изменения применяются напрямую через CRD Fission.</div>
</div> </div>
<!-- MQ-триггеры -->
<div class="box">
<div class="toolbar">
<div style="font-weight:600;">MQ-триггеры</div>
<button class="btn" onclick="openCreateMQ()">+ Добавить</button>
</div>
<table>
<thead>
<tr>
<th>Имя</th>
<th>Очередь</th>
<th>Функция</th>
<th>Endpoint SQS</th>
<th>Статус</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="mq-rows">
<tr><td colspan="6" style="color:var(--fg-muted,#888);text-align:center;">Загрузка...</td></tr>
</tbody>
</table>
<div class="hint">MQ-триггер поллит очередь SQS и вызывает Fission-функцию при появлении сообщений.</div>
</div>
</div>
<!-- Модалка: создать MQ-триггер -->
<div id="mq-create-modal" class="modal">
<div class="panel" style="width:520px;max-width:96vw;">
<h3>📨 Создать MQ-триггер</h3>
<div class="row">
<div class="field">
<label>Имя триггера</label>
<input id="mq-name" placeholder="weather-trigger">
</div>
<div class="field">
<label>Функция</label>
<input id="mq-fn" placeholder="weather-store">
</div>
</div>
<div class="row">
<div class="field">
<label>Имя очереди (SQS)</label>
<input id="mq-queue" placeholder="weather-raw">
</div>
<div class="field">
<label>SQS Endpoint</label>
<input id="mq-endpoint" placeholder="http://shared-sqs.shared-sqs.svc.cluster.local:4100">
</div>
</div>
<div class="row">
<div class="field">
<label>Access Key</label>
<input id="mq-access-key" placeholder="SSAK-...">
</div>
<div class="field">
<label>Secret Key</label>
<input id="mq-secret-key" type="password" placeholder="...">
</div>
</div>
<div id="mq-create-error" class="modal-error"></div>
<div class="actions">
<button class="btn ghost" onclick="closeMQCreate()">Отмена</button>
<button class="btn" onclick="submitCreateMQ()">Создать</button>
</div>
</div>
</div> </div>
<!-- Модалка: создать функцию из кода (prefix cc-) --> <!-- Модалка: создать функцию из кода (prefix cc-) -->
@@ -235,6 +306,12 @@
<button class="btn ghost" id="cc-gen-btn" onclick="showGenPrompt('cc')">&#x2728; Сгенерировать код LLM</button> <button class="btn ghost" id="cc-gen-btn" onclick="showGenPrompt('cc')">&#x2728; Сгенерировать код LLM</button>
<button class="btn ghost" id="cc-exp-btn" onclick="aiExplain('cc-code','cc-lang','cc-ai-result')">&#x1F4D6; LLM: Что делает?</button> <button class="btn ghost" id="cc-exp-btn" onclick="aiExplain('cc-code','cc-lang','cc-ai-result')">&#x1F4D6; LLM: Что делает?</button>
</div> </div>
<div style="margin-top:10px;">
<label id="cc-deps-label" style="font-size:12px; color:var(--text-secondary); margin-bottom:4px; display:block;">Зависимости (requirements.txt)</label>
<textarea id="cc-deps" rows="3" placeholder="boto3
requests>=2.28
psycopg2-binary" style="width:100%; box-sizing:border-box; font-family:monospace; font-size:12px; background:#1a1a2e; border:1px dashed #3a3a5c; border-radius:4px; color:#cdd6f4; padding:6px 8px; resize:none; overflow-y:auto;"></textarea>
</div>
</div> </div>
<div id="cc-gen-prompt" style="display:none; margin-top:8px; gap:6px; align-items:center;"> <div id="cc-gen-prompt" style="display:none; margin-top:8px; gap:6px; align-items:center;">
<input id="cc-gen-desc" type="text" <input id="cc-gen-desc" type="text"
@@ -374,6 +451,12 @@
<button class="btn ghost" id="e-exp-btn" onclick="aiExplain('e-code','e-lang-hidden','e-ai-result')">&#x1F4D6; <button class="btn ghost" id="e-exp-btn" onclick="aiExplain('e-code','e-lang-hidden','e-ai-result')">&#x1F4D6;
LLM: Что делает?</button> LLM: Что делает?</button>
</div> </div>
<div style="margin-top:10px;">
<label id="e-deps-label" style="font-size:12px; color:var(--text-secondary); margin-bottom:4px; display:block;">Зависимости (requirements.txt)</label>
<textarea id="e-deps" rows="3" placeholder="boto3
requests>=2.28
psycopg2-binary" style="width:100%; box-sizing:border-box; font-family:monospace; font-size:12px; background:#1a1a2e; border:1px dashed #3a3a5c; border-radius:4px; color:#cdd6f4; padding:6px 8px; resize:none; overflow-y:auto;"></textarea>
</div>
</div> </div>
<div id="e-archive-area" style="display:none;"> <div id="e-archive-area" style="display:none;">
<div id="e-archive-current" style="margin-bottom:10px; padding:8px 12px; background:var(--bg-alt); border-radius:6px; font-size:13px; color:var(--text-secondary);"> <div id="e-archive-current" style="margin-bottom:10px; padding:8px 12px; background:var(--bg-alt); border-radius:6px; font-size:13px; color:var(--text-secondary);">
@@ -543,7 +626,7 @@
</div> </div>
<div class="actions" style="justify-content:space-between; align-items:center;"> <div class="actions" style="justify-content:space-between; align-items:center;">
<span style="font-size:0.75rem; color:var(--text-secondary);">v1.3.87</span> <span style="font-size:0.75rem; color:var(--text-secondary);">v1.3.96</span>
<button class="btn ghost" onclick="closeHelp()">Закрыть</button> <button class="btn ghost" onclick="closeHelp()">Закрыть</button>
</div> </div>
</div> </div>
@@ -1,5 +1,43 @@
/* ai.js — AI/LLM функции: проверка, генерация, объяснение, ассистент */ /* ai.js — AI/LLM функции: проверка, генерация, объяснение, ассистент */
// PYTHON_STDLIB — стандартная библиотека Python (не требуют pip install).
var PYTHON_STDLIB = new Set([
'os','sys','json','time','re','math','io','collections','itertools','functools',
'datetime','pathlib','urllib','http','threading','asyncio','logging','random',
'string','struct','hashlib','hmac','base64','uuid','copy','abc','typing',
'dataclasses','contextlib','enum','warnings','traceback','inspect','importlib',
'subprocess','socket','ssl','email','html','xml','csv','sqlite3','unittest',
'gc','weakref','array','queue','heapq','bisect','decimal','fractions',
'statistics','textwrap','difflib','shutil','glob','fnmatch','tempfile',
'zipfile','tarfile','gzip','bz2','lzma','pickle','shelve','codecs',
'unicodedata','ast','dis','types','builtins','operator','numbers','cmath',
'pprint','reprlib','platform','signal','mmap','ctypes','multiprocessing',
'concurrent','select','selectors','errno','atexit','sched','calendar',
'locale','gettext','argparse','getopt','shlex','configparser','tokenize',
'runpy','pkgutil','site','sysconfig','distutils','zipimport','abc','io',
'__future__','_thread','threading','contextvars','netrc','plistlib',
'html','xml','http','urllib','email','mailbox','mimetypes','encodings',
'codecs','unicodedata','readline','rlcompleter','curses','idlelib','tkinter',
]);
// detectExternalPythonImports — возвращает список модулей, которых нет в stdlib.
function detectExternalPythonImports(code) {
var external = [];
var seen = {};
var patterns = [/^import\s+([\w]+)/gm, /^from\s+([\w]+)/gm];
for (var pi = 0; pi < patterns.length; pi++) {
var m;
while ((m = patterns[pi].exec(code)) !== null) {
var mod = m[1];
if (!PYTHON_STDLIB.has(mod) && !seen[mod]) {
seen[mod] = true;
external.push(mod);
}
}
}
return external;
}
function llmGeneratedWarning(lang) { function llmGeneratedWarning(lang) {
var text = 'Сделано LLM. Не доверяй, проверяй!'; var text = 'Сделано LLM. Не доверяй, проверяй!';
switch (lang) { switch (lang) {
@@ -45,7 +83,25 @@ async function aiCheck(codeId, langId, resultId) {
var data = await requestJSON(API_BASE + '/ai/check', 'POST', { language: lang, code: code }); var data = await requestJSON(API_BASE + '/ai/check', 'POST', { language: lang, code: code });
resEl.style.background = data.ok ? '#1a3a1a' : '#3a1a1a'; resEl.style.background = data.ok ? '#1a3a1a' : '#3a1a1a';
resEl.style.color = data.ok ? '#8f8' : '#f88'; resEl.style.color = data.ok ? '#8f8' : '#f88';
resEl.textContent = data.result || '(пустой ответ)'; var resultText = data.result || '(пустой ответ)';
// Для Python: проверяем внешние импорты vs поле зависимостей
if (lang === 'python') {
var depsId = codeId.replace('-code', '-deps'); // cc-code→cc-deps, e-code→e-deps
var depsEl = document.getElementById(depsId);
var depsVal = depsEl ? depsEl.value.trim() : '';
var external = detectExternalPythonImports(code);
if (external.length > 0 && !depsVal) {
resultText += '\n\n⚠️ Внешние библиотеки: ' + external.join(', ') +
'\nДобавьте их в поле "Зависимости (requirements.txt)" или убедитесь что они уже есть в Python-окружении.';
if (data.ok) {
resEl.style.background = '#3a2a00';
resEl.style.color = '#ffa';
}
}
}
resEl.textContent = resultText;
} catch (e) { } catch (e) {
resEl.style.background = '#3a2a00'; resEl.style.background = '#3a2a00';
resEl.style.color = '#ffa'; resEl.style.color = '#ffa';
@@ -16,6 +16,9 @@ async function reloadAll() {
getJSON(API_BASE + '/timetriggers') getJSON(API_BASE + '/timetriggers')
]); ]);
// MQ-триггеры загружаем параллельно, не блокируем основную таблицу
if (typeof loadMQTriggers === 'function') loadMQTriggers();
S.envs = envs || []; S.envs = envs || [];
S.fns = fns || []; S.fns = fns || [];
S.httpTriggers = http || []; S.httpTriggers = http || [];
@@ -7,6 +7,23 @@ function onLangChangeCode() {
document.getElementById('cc-entry').value = t.entrypoint; document.getElementById('cc-entry').value = t.entrypoint;
document.getElementById('cc-code').value = t.code; document.getElementById('cc-code').value = t.code;
} }
// Обновляем лейбл поля зависимостей под язык
var depsLabels = {
python: 'Зависимости (requirements.txt)',
nodejs: 'Зависимости (package.json dependencies)',
php: 'Зависимости (composer.json)',
ruby: 'Зависимости (Gemfile)',
};
var depsPlaceholders = {
python: 'boto3\nrequests>=2.28\npsycopg2-binary',
nodejs: 'express: ^4.18.2\naxios: ^1.6.0',
php: '{\n "require": {\n "guzzlehttp/guzzle": "^7.0"\n }\n}',
ruby: "gem 'httparty'\ngem 'pg'",
};
var label = document.getElementById('cc-deps-label');
var area = document.getElementById('cc-deps');
if (label) label.textContent = depsLabels[lang] || 'Зависимости';
if (area) area.placeholder = depsPlaceholders[lang] || '';
} }
function openCreateCode() { function openCreateCode() {
@@ -16,6 +33,7 @@ function openCreateCode() {
document.getElementById('cc-route').value = ''; document.getElementById('cc-route').value = '';
document.getElementById('cc-methods').value = 'GET'; document.getElementById('cc-methods').value = 'GET';
document.getElementById('cc-timeout').value = '60'; document.getElementById('cc-timeout').value = '60';
document.getElementById('cc-deps').value = '';
document.getElementById('cc-schedule-enabled').checked = false; document.getElementById('cc-schedule-enabled').checked = false;
document.getElementById('cc-cron').value = ''; document.getElementById('cc-cron').value = '';
toggleScheduleFields('cc'); toggleScheduleFields('cc');
@@ -47,7 +65,8 @@ async function submitCreateCode() {
route: document.getElementById('cc-route').value.trim(), route: document.getElementById('cc-route').value.trim(),
methods: parseMethods(document.getElementById('cc-methods').value), methods: parseMethods(document.getElementById('cc-methods').value),
timeout: parseTimeout(document.getElementById('cc-timeout').value), timeout: parseTimeout(document.getElementById('cc-timeout').value),
code: document.getElementById('cc-code').value code: document.getElementById('cc-code').value,
deps: document.getElementById('cc-deps').value.trim(),
}); });
try { try {
@@ -188,6 +188,13 @@ async function openEdit(name) {
else if (envName.includes('ruby')) lang = 'ruby'; else if (envName.includes('ruby')) lang = 'ruby';
else if (envName.includes('php')) lang = 'php'; else if (envName.includes('php')) lang = 'php';
document.getElementById('e-lang-hidden').value = lang; document.getElementById('e-lang-hidden').value = lang;
// Обновляем лейбл и плейсхолдер поля зависимостей
var depsLabels = {python:'Зависимости (requirements.txt)', nodejs:'Зависимости (package.json dependencies)', php:'Зависимости (composer.json)', ruby:'Зависимости (Gemfile)'};
var depsPlaceholders = {python:'boto3\nrequests>=2.28', nodejs:'express: ^4.18.2\naxios: ^1.6.0', php:'{\n "require": {\n "guzzlehttp/guzzle": "^7.0"\n }\n}', ruby:"gem 'httparty'\ngem 'pg'"};
var eDepsLabel = document.getElementById('e-deps-label');
var eDepsArea = document.getElementById('e-deps');
if (eDepsLabel) eDepsLabel.textContent = depsLabels[lang] || 'Зависимости';
if (eDepsArea) { eDepsArea.placeholder = depsPlaceholders[lang] || ''; eDepsArea.value = fn.deps || ''; }
var aiRes = document.getElementById('e-ai-result'); var aiRes = document.getElementById('e-ai-result');
if (aiRes) { aiRes.style.display = 'none'; aiRes.textContent = ''; } if (aiRes) { aiRes.style.display = 'none'; aiRes.textContent = ''; }
var warnEl = document.getElementById('e-tf-warn'); var warnEl = document.getElementById('e-tf-warn');
@@ -242,6 +249,7 @@ async function submitEdit() {
} else { } else {
await requestJSON(API_BASE + '/functions/' + encodeURIComponent(name) + '/code', 'PUT', { await requestJSON(API_BASE + '/functions/' + encodeURIComponent(name) + '/code', 'PUT', {
code: document.getElementById('e-code').value, code: document.getElementById('e-code').value,
deps: document.getElementById('e-deps').value.trim(),
timeout: parseTimeout(document.getElementById('e-timeout').value) timeout: parseTimeout(document.getElementById('e-timeout').value)
}); });
} }
+103
View File
@@ -0,0 +1,103 @@
// mq.js — MQ-триггеры (SQS → Fission function)
// Архитектура: Console создаёт K8s Deployment + Secret (sqs-consumer) в namespace пользователя.
// sqs-consumer поллит SQS очередь → HTTP POST в Fission-функцию → DeleteMessage.
// Backend: POST /console/api/mqtriggers, DELETE /console/api/mqtriggers/{name}
// v1.3.88
// ── Загрузка и отрисовка ────────────────────────────────────────────
async function loadMQTriggers() {
try {
const data = await apiFetch('/console/api/mqtriggers');
renderMQTable(data.items || []);
const cnt = document.getElementById('mq-count');
if (cnt) cnt.textContent = (data.items || []).length;
} catch (e) {
renderMQTable([]);
const cnt = document.getElementById('mq-count');
if (cnt) cnt.textContent = '0';
}
}
function renderMQTable(items) {
const tbody = document.getElementById('mq-rows');
if (!tbody) return;
if (!items.length) {
tbody.innerHTML = '<tr><td colspan="6" style="color:var(--fg-muted,#888);text-align:center;">Нет MQ-триггеров</td></tr>';
return;
}
tbody.innerHTML = items.map(t => `
<tr>
<td>${escHtml(t.name)}</td>
<td>${escHtml(t.queue)}</td>
<td>${escHtml(t.functionName)}</td>
<td style="font-size:0.8em;color:var(--fg-muted,#aaa);">${escHtml(t.sqsEndpoint || '')}</td>
<td><span style="color:${t.ready ? '#4a4' : '#a44'}">${t.ready ? '▶ Running' : '◼ Pending'}</span></td>
<td>
<button class="btn ghost small" onclick="deleteMQTrigger('${escHtml(t.name)}')">Удалить</button>
</td>
</tr>
`).join('');
}
// ── Создание ─────────────────────────────────────────────────────────
function openCreateMQ() {
document.getElementById('mq-name').value = '';
document.getElementById('mq-fn').value = '';
document.getElementById('mq-queue').value = '';
document.getElementById('mq-endpoint').value = 'http://shared-sqs.shared-sqs.svc.cluster.local:4100';
document.getElementById('mq-access-key').value = '';
document.getElementById('mq-secret-key').value = '';
document.getElementById('mq-create-error').textContent = '';
document.getElementById('mq-create-modal').style.display = 'flex';
}
function closeMQCreate() {
document.getElementById('mq-create-modal').style.display = 'none';
}
async function submitCreateMQ() {
const name = document.getElementById('mq-name').value.trim();
const fnName = document.getElementById('mq-fn').value.trim();
const queue = document.getElementById('mq-queue').value.trim();
const endpoint = document.getElementById('mq-endpoint').value.trim();
const accessKey = document.getElementById('mq-access-key').value.trim();
const secretKey = document.getElementById('mq-secret-key').value.trim();
const errEl = document.getElementById('mq-create-error');
if (!name || !fnName || !queue || !accessKey || !secretKey) {
errEl.textContent = 'Заполните все поля';
return;
}
errEl.textContent = '';
try {
await apiFetch('/console/api/mqtriggers', {
method: 'POST',
body: JSON.stringify({ name, functionName: fnName, queue, sqsEndpoint: endpoint, accessKey, secretKey })
});
closeMQCreate();
await loadMQTriggers();
} catch (e) {
errEl.textContent = e.message || 'Ошибка создания';
}
}
// ── Удаление ─────────────────────────────────────────────────────────
async function deleteMQTrigger(name) {
if (!confirm(`Удалить MQ-триггер "${name}"?`)) return;
try {
await apiFetch(`/console/api/mqtriggers/${encodeURIComponent(name)}`, { method: 'DELETE' });
await loadMQTriggers();
} catch (e) {
alert('Ошибка удаления: ' + (e.message || e));
}
}
// ── Утилита ──────────────────────────────────────────────────────────
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
-27
View File
@@ -1,27 +0,0 @@
package runtime
import (
"archive/zip"
"bytes"
)
// buildZip создаёт zip-архив с одним файлом fileName и содержимым content.
// Вспомогательная функция: используется в buildScriptZip (PHP/Ruby),
// а также как основа для buildGoSourceZip и buildJSDeployZip.
func buildZip(fileName string, content []byte) ([]byte, error) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
fw, err := zw.Create(fileName)
if err != nil {
return nil, err
}
if _, err := fw.Write(content); err != nil {
return nil, err
}
if err := zw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
-13
View File
@@ -1,13 +0,0 @@
package runtime
// BuildScriptZip создаёт zip-архив с одним файлом fileName и содержимым code.
// Используется для PHP и Ruby — языков где среда Fission ожидает
// именованный файл (handler.rb, handler.pl, main.php и т.д.) внутри архива.
//
// Почему zip, а не просто literal:
// Fission poolmgr при специализации пода распаковывает deployment archive,
// находит нужный файл по имени и загружает его в среду выполнения.
// Если передать просто байты кода в deployment.literal — среда не знает расширение.
func BuildScriptZip(code, fileName string) ([]byte, error) {
return buildZip(fileName, []byte(code))
}

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